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

1,915 阅读27分钟

前言

  首先欢迎大家关注我的掘金账号和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流程图
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: 不用说,就是我们需要渲染的虚拟dom节点
  • parent: 就是你要将虚拟dom挂载的父节点
  • dom: 这里的dom其实就是当前的vnode所对应的之前未更新的真实dom。那么就有两种可能: 第一就是null或者是上面例子的contaienr(就是render函数对应的第三个参数),其本质都是首次渲染,第二种就是vnode的对应的未更新的真实dom,那么对应的就是渲染刷新界面
  • context: 组件相关,暂时可以不考虑,对应React中的context
  • mountAll: 组件相关,暂时可以不考虑
  • componentRoot: 组件相关,暂时可以不考虑

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

  • diffLevel:用来记录当前渲染的层数(递归的深度),其实在代码中并没有在进入每层递归的时候都增加并且退出递归的时候减小。只是记录了是不是渲染的第一层,所以对应的值只有01
  • isSvgMode:用来指代当前的渲染是否内SVG元素的内部或者我们是否在diff一个SVG元素(SVG元素需要特殊处理)。
  • hydrating: 这个变量是我一直所困惑的,我还专门查了一下,hydrating指的是保湿、吸水 的意思。hydrating = dom != null && !(ATTR_KEY in dom);(ATTR_KEY对应常量__preactattr_,preact会将props等缓存信息存储在dom的__preactattr_属性中),作者给的是下面的注释:

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在渲染过程(或者更新过程)中仅仅会调用一次,所以说diff函数接受的vnode就是整个应用的虚拟dom,而dom也就是当前整个虚拟dom所对应的节点。但是idiff的调用是递归的,因此domvnode开始时diff函数相等,但是在之后递归的过程中,就对应的是整个应用的部分

  • 首先来看第一块(block-1)的代码:

  变量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再进行删除该节点的。  

  • 第二块代码,主要是针对的组件的渲染,如果vnode.nodeName对应的是函数类型,表明要渲染的是一个组件,直接调用了函数buildComponentFromVNode(组件不是本次叙述内容)。

  • 第三块代码,首先:

    isSvgMode = vnodeName==='svg' ? true : vnodeName==='foreignObject' ? false : isSvgMode;

      变量isSvgMode还是用来标记当前创建的元素是否是SVG元素。foreignObject元素允许包含外来的XML命名空间,一个foreignObject内部的任何SVG元素都不会被绘制,所以如果是vnodeNameforeignObject话,isSvgMode会被置为false(其实Svg对我来说也是比较生疏的内容,但是不影响我们分析整个渲染过程)。

    // 如果不是一个已经存在的元素或者类型有问题,则重新创建一个
    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函数的参数:

  • dom: diff的虚拟子元素的父元素对应的真实dom节点
  • vchildren: diff的虚拟子元素
  • context: 类似于React中的context,组件使用
  • mountAll: 组件相关,暂时可以不考虑
  • componentRoot: 组件相关,暂时可以不考虑

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

  • 第一部分代码:
// 创建一个包含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的参数分别对应于:

  • dom: 虚拟dom对应的真实dom
  • attrs: 期望的最终键值属性对
  • old: 当前或者之前的属性(从之前的VNode或者元素props属性缓存中)

    函数diffAttributes并不复杂,首先遍历old中的属性,如果当前的属性attrs中不存在是,则通过函数setAccessor将其删除。然后将attr中的属性赋值通过setAccessor赋值给当前的dom元素。是否需要赋值需要同时满足下满三个条件:

  • 属性不能是children,原因children表示的是子元素,其实Preact在h函数已经做了处理(详情见系列文章第一篇),这里其实是不会存在children属性的。

  • 属性也不能是innerHTML。其实这一点Preact与React是在这点是相同的,不能通过innerHTML给dom添加内容,只能通过dangerouslySetInnerHTML进行设置。
  • 属性在该dom中不存在 或者 如果当该属性不是value或者checked时,缓存的属性(old)必须和现在的属性(attrs)不一样,如果该属性是value或者checked时,则dom的属性必须和现在不一样,这么判断的主要目的就是如果属性值是value或者checked表明该dom属于表单元素,防止该表单元素是不受控的,缓存的属性存在可能不等于当前dom中的属性。那为什么不都用dom中的属性呢?肯定是由于JavaScript对象中取属性要比dom中拿到属性的速度快很多。

  到这里我们有个地方需要注意的是,调用函数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的结构,首先看看各个参数:

  • node: 对应的dom节点
  • name: 属性名
  • old: 该属性之前存储的值
  • value: 该属性当前要修改的值
  • isSvg: 是否为SVG元素

  然后看一下函数的流程:

  • 如果属性名为className,则属性名修改为class,这一点Preact与React是不相同的,React对css中的类仅支持属性名className,但Preact既支持className的属性名也支持class,并且Preact更推荐使用class.
  • 如果属性名为key时,不做任何处理。
  • 如果属性名为class并且不是svg元素,则直接将值赋值给dom元素。
  • 如果属性名为style时,第一种情况是将字符串类型的样式赋值给dom.style.cssText。如果value是空或者是字符串这么赋值非常能够理解,但是为什么之前的属性值old是字串符为什么也需要通过dom.style.cssText,经过我的实验发现作用应该是覆盖之前通过cssText赋值的样式(所以这里的代码并不是if-else),而是两个if的结构。下面的第二种情况是value是对象类型,所进行的操作是剔除取消的属性,添加新的或者更改的属性。
  • 如果属性是dangerouslySetInnerHTML,则将value中的__html值赋值给innerHtml属性。
  • 如果属性是以on开头,说明要绑定的是事件,因为我们知道Preact不同于React,并没有采用事件代理的机制,所有的事件都会被注册到真实的dom中。而且另一点与React不相同的是,如果你的事件名后添加Capture,例如onClickCapture,那么该事件将在dom的捕获阶段响应,默认会在冒泡事件响应。如果value存在则是注册事件,否则会将注册的事件移除。我们发现在调用addEventListener并没有直接将value作为其第二个参数传入,而是传入了eventProxy:
function eventProxy(e) {
    return this._listeners[e.type](e);
}

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

  • 接下来为除了typelist以外的自有属性进行赋值或者删除。其中函数setProperty为:
    function setProperty(node, name, value) {
     try {
         node[name] = value;
     } catch (e) {
     }
    }
      这个函数尝试给为DOM的自有属性赋值,赋值的过程可能在于IE浏览器和FireFox中抛出异常。所以这里有一个try-catch的结构。
  • 最后是为svg元素以及普通元素的非自有属性进行赋值或者删除。因为对于非自有属性是无非直接通过dom对象进行设置的,仅可以通过函数setAttribute进行赋值。

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