MrErHu / blog

Star 就是最大的鼓励 👏👏👏
MIT License
605 stars 40 forks source link

从Preact了解一个类React的框架是怎么实现的(二): 元素diff #23

Open MrErHu opened 7 years ago

MrErHu commented 7 years ago

前言

  首先欢迎大家关注我的掘金账号和Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。   之前分享过几篇关于React的文章:

  其实我在阅读React源码的时候,真的非常痛苦。React的代码及其复杂、庞大,阅读起来挑战非常大,但是这却又挡不住我们的React的原理的好奇。前段时间有人就安利过Preact,千行代码就基本实现了React的绝大部分功能,相比于React动辄几万行的代码,Preact显得别样的简洁,这也就为了我们学习React开辟了另一条路。本系列文章将重点分析类似于React的这类框架是如何实现的,欢迎大家关注和讨论。如有不准确的地方,欢迎大家指正。      在上篇文章从preact了解一个类React的框架是怎么实现的(一): 元素创建我们了解了我们平时所书写的JSX是怎样转化成Preact中的虚拟DOM结构的,接下来我们就要了解一下这些虚拟DOM节点是如何渲染成真实的DOM节点的以及虚拟DOM节点的改变如何映射到真实DOM节点的改变(也就是diff算法的过程)。这篇文章相比第一篇会比较冗长和枯燥,为了能集中分析diff过程,我们只关注dom元素,暂时不去考虑组件。   

渲染与diff

render函数

  我们知道在React中渲染是并不是由React完成的,而是由ReactDOM中的render函数去实现的。其实在最早的版本中,render函数也是属于React的,只不过后来React的开发者想实现一个于平台无关的库(其目的也是为了React Native服务的),因此将Web中渲染的部分独立成ReactDOM库。Preact作为一个极度精简的库,render函数是属于Preact本身的。Preact的render函数与ReactDOM的render函数也是有有所区别的:

ReactDOM.render(
  element,
  container,
  [callback]
)

  ReactDOM.render接受三个参数,element是需要渲染的React元素,而container挂载点,即React元素将被渲染进container中,第三个参数callback是可选的,当组件被渲染或者更新的时候会被调用。ReactDOM.render会返回渲染组元素的真实DOM节点。如果之前container中含有dom节点,则渲染时会将之前的所有节点清除。例如:

html:

<div id="root">
  <div>Hello React!</div>
</div>

javascript:

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);

  最终的显示效果为:

Hello, world!

  而Preact的render函数为:   

Preact.render(
  vnode, 
  parent, 
  [merge]
)

  Preact.renderReactDOM.render的前两个参数代表的意义相同,区域在于最后一个,Preact.render可选的第三个参数merge,要求必须是第二个参数的子元素,是指会被替换的根节点,否则,如果没有这个参数,Preact 默认追加,而不是像React进行替换。      例如不存在第三个参数的情况下:

html:

<div id="root">
  <div id='container'>Hello Preact!</div>
</div>

javascript:

preact.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);

  最终的显示效果为:

Hello Preact Hello, world!

  如果调用函数有第三个参数:

javascript:

preact.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root'),
  document.getElementById('container')
);

  显示效果是:

Hello, world!   

实现

  其实在Preact中无论是初次渲染还是之后虚拟DOM改变导致的UI更新最终调用的都是diff函数,这也是非常合理的,毕竟我们可以将首次渲染当做是diff过程中用现有的虚拟dom去与空的真实dom基础上进行更新的过程。下面我们首先给出整个diff过程的大致流程图,我们可以对照流程图对代码进行分析:   `diff`流程图      首先从render函数入手,render函数调用的就是diff函数:

function render(vnode, parent, merge) {
    return diff(merge, vnode, {}, false, parent, false);
}

  我们可以看到Preact中的render调用了diff函数,而diff定义在vdom/diff中:

function diff(dom, vnode, context, mountAll, parent, componentRoot) {

    // diffLevel为 0 时表示第一次进入diff函数
    if (!diffLevel++) {
        // 第一次执行diff,查看我们是否在diff SVG元素或者是元素在SVG内部
        isSvgMode = parent!=null && parent.ownerSVGElement!==undefined;

        // hydration 指示的是被diff的现存元素是否含有属性props的缓存
        // 属性props的缓存被存在dom节点的__preactattr_属性中
        hydrating = dom!=null && !(ATTR_KEY in dom);
    }

    let ret = idiff(dom, vnode, context, mountAll, componentRoot);

    // 如果父节点之前没有创建的这个子节点,则将子节点添加到父节点之后
    if (parent && ret.parentNode!==parent) parent.appendChild(ret);

    // diffLevel回减到0说明已经要结束diff的调用
    if (!--diffLevel) {
        hydrating = false;
        // 负责触发组件的componentDidMount生命周期函数
        if (!componentRoot) flushMounts();
    }

    return ret;
}

  这部分的函数内容比较庞杂,很难做到面面俱到,我会在代码中做相关的注释。diff函数主要负责就是将当前的虚拟node节点映射到真实的DOM节点中。参数如下:

  vnode对应的就是一个递归的结构,那么不用想diff函数肯定也是递归的。我们首先看一下函数初始的几个变量:

hydration is indicated by the existing element to be diffed not having a prop cache

也就是说hydrating是指当前的diff的元素没有缓存但是对应的dom元素必须存在。那么什么时候才会出现dom节点中没有存储缓存?只有当前的dom节点并不是由Preact所创建并渲染的才会使得hydrating为true。

  idiff函数就是diff算法的内部实现,相对来说代码会比较复杂,idiff会返回虚拟dom对应创建的真实dom节点。下面的代码是是向父级元素有选择性添加创建的dom节点,之所以这么做,主要是有可能之前该节点就没有渲染过,所以需要将新创建的dom节点添加到父级dom。但是如果仅仅只是修改了之前dom中的某一个属性(比如样式),那么其实是不需要添加的,因为该dom节点已经存在于父级dom。      后面的内容,一方面结束递归之后,回置diffLevel(diffLevel此时应该为0,表明此时要退出diff函数),退出diff前,将hydrating置为false,相当于一个复位的功能。下面的flushMounts函数是组件相关,在这里我们只需要知道它要做的就是去执行所有刚才安装组件的componentDidMount生命周期函数。      下面让我们看看idiff的实现(代码已经分块,具体见注释),代码比较长,可以先大致浏览一下,做到心里有数,下面会逐块分析,可以对照流程图看:

/** 内部的diff函数 */
function idiff(dom, vnode, context, mountAll, componentRoot) {
    // block-1
    let out = dom, prevSvgMode = isSvgMode;

    // 空的node 渲染空的文本节点
    if (vnode==null || typeof vnode==='boolean') vnode = '';

    // String & Number 类型的节点 创建/更新 文本节点
    if (typeof vnode==='string' || typeof vnode==='number') {

        // 更新如果存在的原有文本节点
        // 这里如果节点值是文本类型,其父节点又是文本类型的节点,则直接更新
        if (dom && dom.splitText!==undefined && dom.parentNode && (!dom._component || componentRoot)) {
            if (dom.nodeValue!=vnode) {
                dom.nodeValue = vnode;
            }
        }
        else {
            // 不是文本节点,替换之前的节点,回收之前的节点
            out = document.createTextNode(vnode);
            if (dom) {
                if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
                recollectNodeTree(dom, true);
            }
        }

        out[ATTR_KEY] = true;
        return out;
    }

    // block-2
    // 如果是VNode代表的是一个组件,使用组件的diff
    let vnodeName = vnode.nodeName;
    if (typeof vnodeName==='function') {
        return buildComponentFromVNode(dom, vnode, context, mountAll);
    }

    // block-3  
    // 沿着树向下时记录记录存在的SVG命名空间
    isSvgMode = vnodeName==='svg' ? true : vnodeName==='foreignObject' ? false : isSvgMode;

    // 如果不是一个已经存在的元素或者类型有问题,则重新创建一个
    vnodeName = String(vnodeName);
    if (!dom || !isNamedNode(dom, vnodeName)) {
        out = createNode(vnodeName, isSvgMode);

        if (dom) {
            // 移动dom中的子元素到out中
            while (dom.firstChild) out.appendChild(dom.firstChild);

            // 如果之前的元素已经属于某一个DOM节点,则将其替换
            if (dom.parentNode) dom.parentNode.replaceChild(out, dom);

            // 回收之前的dom元素(跳过非元素类型)
            recollectNodeTree(dom, true);
        }
    }

    // block-4
    let fc = out.firstChild,
        props = out[ATTR_KEY],
        vchildren = vnode.children;

    if (props==null) {
        props = out[ATTR_KEY] = {};
        for (let a=out.attributes, i=a.length; i--; ) props[a[i].name] = a[i].value;
    }

    // 优化: 对于元素只包含一个单一文本节点的优化路径
    if (!hydrating && vchildren && vchildren.length===1 && typeof vchildren[0]==='string' && fc!=null && fc.splitText!==undefined && fc.nextSibling==null) {
        if (fc.nodeValue!=vchildren[0]) {
            fc.nodeValue = vchildren[0];
        }
    }
    // 否则,如果有存在的子节点或者新的孩子节点,执行diff
    else if (vchildren && vchildren.length || fc!=null) {
        innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML!=null);
    }

    // 将props和atrributes从VNode中应用到DOM元素
    diffAttributes(out, vnode.attributes, props);

    // 恢复之前的SVG模式
    isSvgMode = prevSvgMode;

    return out;
}

  idiff函数所接受的参数与diff是完全相同的,但是二者也是有所区别的。diff在渲染过程(或者更新过程)中接受的vnode就是整个应用的虚拟dom(或者组件的虚拟DOM)。但是idiff的调用是递归的,因此domvnode开始时diff函数相等,但是在之后递归的过程中,就对应的是整个应用的部分

  变量prevSvgMode用来存储之前的isSvgMode,目的就是在退出这一次递归调用时恢复到调用前的值。然后如果vnode是null或者布尔类型,都按照空字符去处理。接下的渲染是整对于字符串(sting或者number类型),主要分为两部分: 更新或者创建元素。如果dom本身存在并且就是一个文本节点,那就只需要将其中的值更新为当前的值即可。否则创建一个新的文本节点,并且将其替换到父元素上,并回收之前的节点值。因为文本节点是没有什么需要缓存的属性值(文本的颜色等属性实际是存储的父级的元素中),所以直接将其ATTR_KEY(实际值为__preactattr_)赋值为true,并返回新创建的元素。这段代码有两个需要注意的地方:

if (dom.nodeValue!=vnode) {
    dom.nodeValue = vnode;
}

  为什么在赋值文本节点值时,需要首先进行一个判断?根据代码注释得知Firfox浏览器不会默认做等值比较(其他的浏览器例如Chrome即使直接赋值,如果相等也不会修改dom元素),所以人为的增加了比较的过程,目的就是为了防止文本节点每次都会被更新,这算是一个浏览器怪癖(quirk)。

  回收dom节点的recollectNodeTree函数做了什么?看代码:

/**
 * 递归地回收(或者卸载)节点及其后代节点
 * @param node
 * @param unmountOnly 如果为`true`,仅仅触发卸载的生命周期,跳过删除
 */
function recollectNodeTree(node, unmountOnly) {
    let component = node._component;
    if (component) {
        // 如果该节点属于某个组件,卸载该组件(最终在这里递归),主要包括组件的回收和相依卸载生命周期的调用
        unmountComponent(component);
    }
    else {
        // 如果节点含有ref函数,则执行ref函数,参数为null(这里是React的规范,用于取消设置引用)
        // 确实在React如果设置了ref的话,在卸载的时候,也会被回调,得到的参数是null
        if (node[ATTR_KEY]!=null && node[ATTR_KEY].ref) node[ATTR_KEY].ref(null);

        if (unmountOnly===false || node[ATTR_KEY]==null) {
            //要做的无非是从父节点将该子节点删除
            removeNode(node);
        }

        //递归删除子节点
        removeChildren(node);
    }
}
/**
 * 回收/卸载所有的子元素
 * 我们在这里使用了.lastChild而不是使用.firstChild,是因为访问节点的代价更低。
 */
export function removeChildren(node) {
    node = node.lastChild;
    while (node) {
        let next = node.previousSibling;
        recollectNodeTree(node, true);
        node = next;
    }
}
/** 从父节点删除该节点
 *    @param {Element} node        待删除的节点
 */
function removeNode(node) {
    let parentNode = node.parentNode;
    if (parentNode) parentNode.removeChild(node);
}

  我们看到在函数recollectNodeTree中,如果dom元素属于某个组件,首先递归卸载组件(不是本次讲述的重点,主要包括组件的回收和相依卸载生命周期的调用)。否则,只需要先判别该dom节点中是否被在jsx中存在ref函数(也是缓存在__preactattr_属性中),因为存在ref函数时,我们在组件卸载时以null参数作为回调(React文档做了相应的规定,详情见Refs and the DOM)。recollectNodeTree中第二个参数unmountOnly,表示仅仅触发卸载的生命周期,跳过删除的过程,如果unmountOnlyfalse或者dom中的ATTR_KEY属性不存在(说明这个属性不是preact所渲染的,否则肯定会存在该属性),则直接将其从父节点删除。最后递归删除子节点,我们可以看到递归删除子元素的过程是从右到左删除的(首先删除的lastChild元素),主要考虑到的是从后访问会有性能的优势。我们在这里(block-1)调用函数recollectNodeTree的第二个参数是true,原因是在调用之前我们已经将其在父元素中进行替换,所以是不需要进行调用的函数removeNode再进行删除该节点的。  

    // 如果不是一个已经存在的元素或者类型有问题,则重新创建一个
    vnodeName = String(vnodeName);
    if (!dom || !isNamedNode(dom, vnodeName)) {
        out = createNode(vnodeName, isSvgMode);

        if (dom) {
            // 移动dom中的子元素到out中
            while (dom.firstChild) out.appendChild(dom.firstChild);

            // 如果之前的元素已经属于某一个DOM节点,则将其替换
            if (dom.parentNode) dom.parentNode.replaceChild(out, dom);

            // 回收之前的dom元素(跳过非元素类型)
            recollectNodeTree(dom, true);
        }
    }

  然后开始尝试创建dom元素,如果之前的dom为空(说明之前没有渲染)或者dom的名称与vnode.nodename不一致时,说明我们要创建新的元素,然后如果之前的dom节点中存在子元素,则将其全部移入新创建的元素中。如果之前的dom已经有父元素了,则将其替换成新的元素,最后回收该元素。   在判断节点dom类型与虚拟dom的vnodeName类型是否相同时使用了函数isNamedNode:   

function isNamedNode(node, nodeName) {
    return node.normalizedNodeName===nodeName || node.nodeName.toLowerCase()===nodeName.toLowerCase();
}

  如果节点是由Preact创建的(即由函数createNode创建的),其中dom节点中含有属性normalizedNodeName(node.normalizedNodeName = nodeName),则使用normalizedNodeName去判断节点类型是否相等,否则直接采用dom节点中的nodeName属性去判断。     到此为止渲染的当前虚拟dom的过程已经结束,接下来就是处理子元素的过程。

    let fc = out.firstChild,
        props = out[ATTR_KEY],
        vchildren = vnode.children;

    if (props==null) {
        props = out[ATTR_KEY] = {};
        for (let a=out.attributes, i=a.length; i--; ) props[a[i].name] = a[i].value;
    }

    // 优化: 对于元素只包含一个单一文本节点的优化路径
    if (!hydrating && vchildren && vchildren.length===1 && typeof vchildren[0]==='string' && fc!=null && fc.splitText!==undefined && fc.nextSibling==null) {
        if (fc.nodeValue!=vchildren[0]) {
            fc.nodeValue = vchildren[0];
        }
    }
    // 否则,如果有存在的子节点或者新的孩子节点,执行diff
    else if (vchildren && vchildren.length || fc!=null) {
        innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML!=null);
    }

  然后我们看到,如果out是新创建的元素或者该元素不是由Preact创建的(即不存在属性__preactattr_),我们会初始化out中的__preactattr_属性中并将out元素(刚创建的dom元素)中属性attributes缓存在out元素的ATTR_KEY(__preactattr_)属性上。但是需要注意的是,比如某个节点的属性发生改变,比如name1变成了2,那么out属性中的缓存(__preactattr_)也需要得到更新,但是更新的操作并不发生在这里,而是下面的diffAttributes函数中。      接下来就是处理子元素只有一个文本节点的情况(其实这部分也可以没有,通过下一层的递归也能解决,这样做只不过是为了优化性能),比如处理下面的情形:

<l1>1</li>

  进入单个节点的判断条件也是比较明确的,唯一需要注意的一点是,必须满足hydrating不为true,因为我们知道当hydratingtrue是说明当前的节点并不是由Preact渲染的,因此不能进行直接的优化,需要由下一层递归中创建新的文本元素。   

    //将props和atrributes从VNode中应用到DOM元素
    diffAttributes(out, vnode.attributes, props);
    // 恢复之前的SVG模式
    isSvgMode = prevSvgMode;
    return out;

  函数diffAttributes的主要作用就是将虚拟dom中attributes更新到真实的dom中(后面详细讲)。最后重置变量isSvgMode,并返回vnode所渲染的真实dom节点。      看完了函数idiff,接下来要关心的就是,在idiff中对虚拟dom的子元素调用的innerDiffNode函数(代码依然很长,我们依然做分块,对照流程图看):

function innerDiffNode(dom, vchildren, context, mountAll, isHydrating) {
    let originalChildren = dom.childNodes,
        children = [],
        keyed = {},
        keyedLen = 0,
        min = 0,
        len = originalChildren.length,
        childrenLen = 0,
        vlen = vchildren ? vchildren.length : 0,
        j, c, f, vchild, child;

    // block-1
    // 创建一个包含key的子元素和一个不包含有子元素的Map
    if (len!==0) {
        for (let i=0; i<len; i++) {
            let child = originalChildren[i],
                props = child[ATTR_KEY],
                key = vlen && props ? child._component ? child._component.__key : props.key : null;
            if (key!=null) {
                keyedLen++;
                keyed[key] = child;
            }
            else if (props || (child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)) {
                children[childrenLen++] = child;
            }
        }
    }
    // block-2
    if (vlen!==0) {
        for (let i=0; i<vlen; i++) {
            vchild = vchildren[i];
            child = null;

            // 尝试通过键值匹配去寻找节点
            let key = vchild.key;
            if (key!=null) {
                if (keyedLen && keyed[key]!==undefined) {
                    child = keyed[key];
                    keyed[key] = undefined;
                    keyedLen--;
                }
            }
            // 尝试从现有的孩子节点中找出类型相同的节点
            else if (!child && min<childrenLen) {
                for (j=min; j<childrenLen; j++) {
                    if (children[j]!==undefined && isSameNodeType(c = children[j], vchild, isHydrating)) {
                        child = c;
                        children[j] = undefined;
                        if (j===childrenLen-1) childrenLen--;
                        if (j===min) min++;
                        break;
                    }
                }
            }

            // 变形匹配/寻找到/创建的DOM子元素来匹配vchild(深度匹配)
            child = idiff(child, vchild, context, mountAll);

            f = originalChildren[i];
            if (child && child!==dom && child!==f) {
                if (f==null) {
                    dom.appendChild(child);
                }
                else if (child===f.nextSibling) {
                    removeNode(f);
                }
                else {
                    dom.insertBefore(child, f);
                }
            }
        }
    }
    // block-3
    // 移除未使用的带有keyed的子元素
    if (keyedLen) {
        for (let i in keyed) if (keyed[i]!==undefined) recollectNodeTree(keyed[i], false);
    }
    // 移除没有父节点的不带有key值的子元素
    while (min<=childrenLen) {
        if ((child = children[childrenLen--])!==undefined) recollectNodeTree(child, false);
    }
}

  首先看innerDiffNode函数的参数:

  函数代码将近百行,为了方便阅读,我们将其分为四个部分(看代码注释):

// 创建一个包含key的子元素和一个不包含有子元素的Map
if (len!==0) {
    //len === dom.childNodes.length
    for (let i=0; i<len; i++) {
        let child = originalChildren[i],
            props = child[ATTR_KEY],
            key = vlen && props ? child._component ? child._component.__key : props.key : null;
        if (key!=null) {
            keyedLen++;
            keyed[key] = child;
        }
        else if (props || (child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)) {
            children[childrenLen++] = child;
        }
    }
}

  我们所希望的diff的过程肯定是以最少的dom操作使得更改后的dom与虚拟dom相匹配,所以之前父节点的dom重用也是非常必要。len是父级dom的子元素个数,首先对所有的子元素进行遍历,如果该元素是由Preact所渲染(也就是有props的缓存)并且含有key值(不考虑组件的情况下,我们暂时只看该元素props中是否有key值),我们将其存储在keyed中,否则如果该元素也是Preact所渲染(有props的缓存)或者满足条件(child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)时,我们将其分配到children中。这样我们其实就将子元素划分为两类,一类是带有key值的子元素,一类是没有key的子元素。

  关于条件(child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)我们分析一下,我们知道hydratingtrue时表示的是dom元素不是Preact创建的,我们知道调用函数innerDiffNode时,isHydrating的值是hydrating || props.dangerouslySetInnerHTML!=null,那么isHydratingtrue表示的就是子dom节点不是由Preact所创建的,那么现在看起来上面的判断条件也非常容易理解了。如果节点child不是文本节点,根据该节点是否是由Preact所创建的做决定,如果是不是由Preact创建的,则添加到children,否则不添加。如果是文本节点的话,如果是由Preact创建的话则添加,否则执行child.nodeValue.trim(),我们知道函数trim返回的是去掉字符串前后空格的新字符串,如果该节点有非空字符,则会被添加到children中,否则不添加。这样做的目的也无非是最大程度利用之前的文本节点,减少创建不必要的文本节点。

if (vlen!==0) {

    for (let i=0; i<vlen; i++) {
        vchild = vchildren[i];
        child = null;

        // 尝试通过键值匹配去寻找节点
        let key = vchild.key;
        if (key!=null) {
            if (keyedLen && keyed[key]!==undefined) {
                child = keyed[key];
                keyed[key] = undefined;
                keyedLen--;
            }
        }
        // 尝试从现有的孩子节点中找出类型相同的节点
        else if (!child && min<childrenLen) {
            for (j=min; j<childrenLen; j++) {
                if (children[j]!==undefined && isSameNodeType(c = children[j], vchild, isHydrating)) {
                    child = c;
                    children[j] = undefined;
                    if (j===childrenLen-1) childrenLen--;
                    if (j===min) min++;
                    break;
                }
            }
        }
        // 变形匹配/寻找到/创建的DOM子元素来匹配vchild(深度匹配)
        child = idiff(child, vchild, context, mountAll);

        f = originalChildren[i];
        if (child && child!==dom && child!==f) {
            if (f==null) {
                dom.appendChild(child);
            }
            else if (child===f.nextSibling) {
                removeNode(f);
            }
            else {
                dom.insertBefore(child, f);
            }
        }
    }
}

  该部分代码首先对虚拟dom中的子元素进行遍历,对每一个子元素,首先判断该子元素是否含有属性key,如果含有则在keyed中查找对应keyed的dom元素,并在keyed将该元素删除。否则在children查找是否含有和该元素相同类型的节点(利用函数isSameNodeType),如果查找到相同类型的节点,则在children中删除并根据对应的情况(即查到的元素在children查找范围的首尾)缩小排查范围。然后递归执行函数idiff,如果之前child没有查找到的话,会在idiff中创建对应类型的节点。然后根据之前的所分析的,idiff会返回新的dom节点。      如果idiff返回dom不为空并且该dom与原始dom中对应位置的dom不相同时,将其添加到父节点。如果不存在对应位置的真实节点,则直接添加到父节点。如果child已经添加到对应位置的真实dom后,则直接将其移除当前位置的真实dom,否则都将其添加到对应位置之前。

    if (keyedLen) {
        for (let i in keyed) if (keyed[i]!==undefined) recollectNodeTree(keyed[i], false);
    }
    // 移除没有父节点的不带有key值的子元素
    while (min<=childrenLen) {
        if ((child = children[childrenLen--])!==undefined) recollectNodeTree(child, false);
    }

  这段代码所作的工作就是将keyed中与children中没有用到的原始dom节点回收。到此我们已经基本讲完了整个diff的所有大致流程,还剩idiff中的diffAttributes函数没有讲,因为里面涉及到dom中的事件触发,所以还是有必要讲一下:   

function diffAttributes(dom, attrs, old) {
    let name;

    // 通过将其设置为undefined,移除不在vnode中的属性
    for (name in old) {
        // 判断的条件是如果old[name]中存在,但attrs[name]不存在
        if (!(attrs && attrs[name]!=null) && old[name]!=null) {
            setAccessor(dom, name, old[name], old[name] = undefined, isSvgMode);
        }
    }
    // 增加或者更新的属性
    for (name in attrs) {
        // 如果attrs中的属性不是 children或者 innerHTML 并且
        // 要么 之前的old里面没有该属性 ====> 说明是新增属性
        // 要么 如果name是value或者checked属性(表单), attrs[name] 与 dom[name] 不同,或者不是value或者checked属性,则和old[name]属性不同 ====> 说明是更新属性
        if (name!=='children' && name!=='innerHTML' && (!(name in old) || attrs[name]!==(name==='value' || name==='checked' ? dom[name] : old[name]))) {
            setAccessor(dom, name, old[name], old[name] = attrs[name], isSvgMode);
        }
    }
}

  diffAttributes的参数分别对应于:

  到这里我们有个地方需要注意的是,调用函数setAccessor时的第三个实参为old[name] = undefined或者old[name] = attrs[name],我们在前面说过,如果虚拟dom中的attributes发生改变时也需要将真实dom中的__preactattr_进行更新,其实更新的过程就发生在这里,old的实参就是props = out[ATTR_KEY],所以更新old时也对应修改了dom的缓存。

  我们最后需要关注的是函数setAccessor,这个函数比较长但是结构是及其的简单:   

function setAccessor(node, name, old, value, isSvg) {
    if (name === 'className') name = 'class';

    if (name === 'key') {
        // key属性忽略
    }
    else if (name === 'ref') {
        // 如果是ref 函数被改变了,以null去执行之前的ref函数,并以node节点去执行新的ref函数
        if (old) old(null);
        if (value) value(node);
    }
    else if (name === 'class' && !isSvg) {
        // 直接赋值
        node.className = value || '';
    }
    else if (name === 'style') {
        if (!value || typeof value === 'string' || typeof old === 'string') {
            node.style.cssText = value || '';
        }
        if (value && typeof value === 'object') {
            if (typeof old !== 'string') {
                // 从dom的style中剔除已经被删除的属性
                for (let i in old) if (!(i in value)) node.style[i] = '';
            }
            for (let i in value) {
                node.style[i] = typeof value[i] === 'number' && IS_NON_DIMENSIONAL.test(i) === false ? (value[i] + 'px') : value[i];
            }
        }
    }
    else if (name === 'dangerouslySetInnerHTML') {
        //dangerouslySetInnerHTML属性设置
        if (value) node.innerHTML = value.__html || '';
    }
    else if (name[0] == 'o' && name[1] == 'n') {
        // 事件处理函数 属性赋值
        // 如果事件的名称是以Capture为结尾的,则去掉,并在捕获阶段节点监听事件
        let useCapture = name !== (name = name.replace(/Capture$/, ''));
        name = name.toLowerCase().substring(2);
        if (value) {
            if (!old) node.addEventListener(name, eventProxy, useCapture);
        }
        else {
            node.removeEventListener(name, eventProxy, useCapture);
        }
        (node._listeners || (node._listeners = {}))[name] = value;
    }
    else if (name !== 'list' && name !== 'type' && !isSvg && name in node) {
        setProperty(node, name, value == null ? '' : value);
        if (value == null || value === false) node.removeAttribute(name);
    }
    else {
        // SVG元素
        let ns = isSvg && (name !== (name = name.replace(/^xlink\:?/, '')));
        if (value == null || value === false) {
            if (ns) node.removeAttributeNS('http://www.w3.org/1999/xlink', name.toLowerCase());
            else node.removeAttribute(name);
        }
        else if (typeof value !== 'function') {
            if (ns) node.setAttributeNS('http://www.w3.org/1999/xlink', name.toLowerCase(), value);
            else node.setAttribute(name, value);
        }
    }
}

  整个函数都是if-else的结构,首先看看各个参数:

  然后看一下函数的流程:

function eventProxy(e) {
    return this._listeners[e.type](e);
}

  我们看到因为有语句(node._listeners || (node._listeners = {}))[name] = value,所以某个对应事件的处理函数是保存在node._listeners对象中,因此当函数eventProxy调用时,就可以触发对应的事件处理程序,其实这也算是一种简单的事件代理机制,如果该元素对应的某个事件处理程序发生改变时,也就不需要删除之前的处理事件并绑定新的处理,只需要改变node._listeners对象存储的对应事件处理函数即可。   

  到此为止,我们已经基本全部分析完了Preact中diff算法的过程,我们看到Preact相比于庞大的React,短短数百行语句就实现了diff的功能并能达到一个相当不错的性能。由于本人能力所限,不能达到面面俱到,但希望这篇文章能起到抛砖引玉的作用,如果不正确指出,欢迎指出和讨论~

mjc224000 commented 6 years ago

大神 膜拜

mjc224000 commented 6 years ago

idiff的图是不是有错误