这是说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,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,context,mountAll,componentRoot) { if (!diffLevel++) { //重新判定DOM树的类型 isSvgMode = parent!=null && parent.ownerSVGElement!==undefined; // 判定是否缓存了数据 hydrating = dom!=null && !(ATTR_KEY in dom); } //更新dom 或返回新的dom let ret = idiff(dom,componentRoot); // 插入父节点 if (parent && ret.parentNode!==parent) parent.appendChild(ret); if (!--diffLevel) { hydrating = false; // 执行所有DidMount钩子 if (!componentRoot) flushMounts(); } return ret; }
从用户一般的使用来看,传到diff里面的参数一般是
diff(undefined,false);
它的参数严重不足,我们再看idiff。
function idiff(dom,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,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,hydrating || props.dangerouslySetInnerHTML!=null); } // 更新这个真实DOM 的属性 diffAttributes(out,vnode.attributes,props); // 还原isSvgMode isSvgMode = prevSvgMode; return out; }
idiff的逻辑可分成这几步
可以看作是对当个元素的diff实现。
而更外围的diff方法,主要通过diffLevel这个变量,控制所有插入组件的DidMount钩子的调用。
idiff内部有一个叫innerDiffNode的方法,如果是我作主,我更愿意命名为diffChildren.
innerDiffNode方法是非常长,好像每次我阅读它,它都变长一点。一点点猴子补丁往上加,完全不考虑用设计模式对它进行拆分。
function innerDiffNode(dom,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],isHydrating)) { child = c; children[j] = undefined; if (j===childrenLen-1) childrenLen--; if (j===min) min++; break; } } } // 更新它的孩子与属性 child = idiff(child,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,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时少传参,坏处是需要做好内存泄露的工作。