6

这是说preact的diff机制。preact在diff的过程中创建,更新与移除真实DOM。diff机制是preact中最难懂的部分。

我们先看render方法。

//render.js
import { diff } from './vdom/diff';

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

vnode为虚拟DOM,parent为作为容器的元素节点,merge是另一个真实DOM,但也可能不存在。从这个render方法,我们可以看到,它与官方React出入比较大,因为官方react的render第三个参数是回调。

//用于收集那些等待被调用componentDidMount回调的组件
export const mounts = [];

//判定递归的层次
export let diffLevel = 0;
//判定当前的DOM树是否为SVG
let isSvgMode = false;

//判定这个元素是否已经缓存了之前的虚拟DOM数据
let hydrating = false;
//批量触发componentDidMount与afterMount
export function flushMounts() {
    let c;
    while ((c=mounts.pop())) {
        if (options.afterMount) options.afterMount(c);
        if (c.componentDidMount) c.componentDidMount();
    }
}

export function diff(dom, vnode, context, mountAll, parent, componentRoot) {
    if (!diffLevel++) {
        //重新判定DOM树的类型
        isSvgMode = parent!=null && parent.ownerSVGElement!==undefined;

        // 判定是否缓存了数据
        hydrating = dom!=null && !(ATTR_KEY in dom);
    }
    //更新dom 或返回新的dom
    let ret = idiff(dom, vnode, context, mountAll, componentRoot);

    // 插入父节点
    if (parent && ret.parentNode!==parent) parent.appendChild(ret);

    if (!--diffLevel) {
        hydrating = false;
        // 执行所有DidMount钩子
        if (!componentRoot) flushMounts();
    }

    return ret;
}

从用户一般的使用来看,传到diff里面的参数一般是

diff(undefined, vnode, {}, false, parent, false);

它的参数严重不足,我们再看idiff。

function idiff(dom, vnode, context, mountAll, componentRoot) {
    let out = dom,
        prevSvgMode = isSvgMode;

    // 转换null, undefined, boolean为空字符
    if (vnode==null || typeof vnode==='boolean') vnode = '';
    //将字符串与数字转换为文本节点
    if (typeof vnode==='string' || typeof vnode==='number') {

        // 如果已经存在,注意在IE6-8下,文本节点是不能添加自定义属性,因此dom._component总是为undefined
        if (dom && dom.splitText!==undefined && dom.parentNode && (!dom._component || componentRoot)) {
            
            if (dom.nodeValue!=vnode) {
                dom.nodeValue = vnode;
            }
        }
        else {
            // 创建新的虚拟DOM
            out = document.createTextNode(vnode);
            if (dom) {
                if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
                recollectNodeTree(dom, true);
            }
        }

        out[ATTR_KEY] = true;

        return out;
    }


    // 如果是组件
    let vnodeName = vnode.nodeName;
    if (typeof vnodeName==='function') {
        return buildComponentFromVNode(dom, vnode, context, mountAll);
    }


    // 更新isSvgMode
    isSvgMode = vnodeName==='svg' ? true : vnodeName==='foreignObject' ? false : isSvgMode;


    //这个应该是防御性代码,因为到这里都是div, p, span这样的标签名
    vnodeName = String(vnodeName);
    //如果没有DOM,或标签类型不一致
    if (!dom || !isNamedNode(dom, vnodeName)) {
        out = createNode(vnodeName, isSvgMode);

        if (dom) {
            // 转移里面的真实DOM
            while (dom.firstChild) out.appendChild(dom.firstChild);

            // 插入到父节点
            if (dom.parentNode) dom.parentNode.replaceChild(out, dom);

            // GC
            recollectNodeTree(dom, true);
        }
    }


    let fc = out.firstChild,
    //取得之前的虚拟DOM的props
        props = out[ATTR_KEY],
        vchildren = vnode.children;

    if (props==null) {
        //将元素节点的attributes转换为props,方便进行比较
        //不过这里有一个致命的缺憾在IE6-7中,因为IE6-7不区分attributes与property,这里会存在大量的属性,导致巨耗性能
        props = out[ATTR_KEY] = {};
        for (let a=out.attributes, i=a.length; i--; ) props[a[i].name] = a[i].value;
    }

    // Optimization: fast-path for elements containing a single TextNode:
    // 如果当前位置的真实DOM 是文本节点,并没有缓存任何数据,而虚拟DOM 则是一个字符串,那么直接修改nodeValue
    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];
        }
    }
    //更新这个真实DOM 的孩子
    else if (vchildren && vchildren.length || fc!=null) {
        innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML!=null);
    }


    // 更新这个真实DOM 的属性
    diffAttributes(out, vnode.attributes, props);


    // 还原isSvgMode
    isSvgMode = prevSvgMode;

    return out;
}

idiff的逻辑可分成这几步

  1. 保存现有的文档为型
  2. 更新或创建文本节点
  3. 更新或创建组件对应的真实DOM
  4. 更新普通元素节点
  5. 收集元素当前的真实属性
  6. 更新元素的内部(孩子)
  7. diff元素的属性
  8. 还原之前的文档类型

可以看作是对当个元素的diff实现。

而更外围的diff方法,主要通过diffLevel这个变量,控制所有插入组件的DidMount钩子的调用。

idiff内部有一个叫innerDiffNode的方法,如果是我作主,我更愿意命名为diffChildren.

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;

    // 如果真实DOM 存在孩子,可以进行diff,这时要收集设置到key属性的孩子到keyed对象,剩余的则放在children数组中
    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;
            }
        }
    }

    if (vlen!==0) {
//遍历当前虚拟DOM children
        for (let i=0; i<vlen; i++) {
            vchild = vchildren[i];
            child = null;

            // 先尝试根据key来寻找已有的DOM
            let key = vchild.key;
            if (key!=null) {
                if (keyedLen && keyed[key]!==undefined) {
                    child = keyed[key];
                    keyed[key] = undefined;
                    keyedLen--;
                }
            }
            // 如果没有key ,那么就根据nodeName来寻找最近的那个节点
            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;
                    }
                }
            }

            // 更新它的孩子与属性
            child = idiff(child, vchild, context, mountAll);

            f = originalChildren[i];
            if (child && child!==dom && child!==f) {
                 //各种形式的插入DOM树
                if (f==null) {
                    dom.appendChild(child);
                }
                else if (child===f.nextSibling) {
                    removeNode(f);
                }
                else {
                    dom.insertBefore(child, f);
                }
            }
        }
    }


    // GC
    if (keyedLen) {
        for (let i in keyed) if (keyed[i]!==undefined) recollectNodeTree(keyed[i], false);
    }

    // GC
    while (min<=childrenLen) {
        if ((child = children[childrenLen--])!==undefined) recollectNodeTree(child, false);
    }
}


export function isSameNodeType(node, vnode, hydrating) {
    if (typeof vnode==='string' || typeof vnode==='number') {  
//文本节点与字符串,文本节点是对等的,但我不明白为什么不用nodeType === 3来判定文本节点
        return node.splitText!==undefined;
    }
    if (typeof vnode.nodeName==='string') {
        return !node._componentConstructor && isNamedNode(node, vnode.nodeName);
    }
    return hydrating || node._componentConstructor===vnode.nodeName;
}

innerDiffNode方法在创建keyed对象中其实存在巨大的缺憾,它无法阻止用户在同一组孩子 使用两个相同的key的情况,因此会出错。而官方react,其实还结合父节点的深度,因此可以规避。

比如下面的JSX ,preact在diff时就会出错:

<div>{[1,2,3].map((el,index)=>{ <span key={"x"+index}>{el}</span>  })}xxx
{[4,5,6].map((el,index)=>{ <span key={"x"+index}>{el}</span>  })}
</div>

这里我们比较一下官方react与preact的diff差异。官方react是有两组虚拟DOM 树在diff,diff完毕再将差异点应用于真实DOM 中。在preact则是先从真实DOM树中还原出之前的虚拟DOM出来,然后新旧vtree进行边diff边patch的操作。

之于怎么还原呢,利用缓存数据与nodeValue!

真实DOM 拥有_component对象的元素节点 拥有ATTR_KET对象的元素节点 拥有ATTR_KET布尔值的文本节点
对应的prevVNode 组件虚拟DOM 元素虚拟DOM 简单类型的虚拟DOM

这种深度耦合DOM 树的实现的优缺点都很明显,好处是它总是最真实地反映之前的虚拟DOM树的情况,diff时少传参,坏处是需要做好内存泄露的工作。


司徒正美
5.6k 声望3.5k 粉丝

穿梭于二次元与二进制间的魔法师( ̄(工) ̄) 凸ส้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้