面试被问「虚拟 DOM 为什么快」,很多人会条件反射地答「因为操作 JS 对象比操作 DOM 快」。这个答案经不起追问。真要比单次操作,直接改一次 textContent 显然比走一整套 diff 流程省事。虚拟 DOM 真正解决的不是快慢,是在「状态变了」和「DOM 该怎么改」这两件事之间插了一层,让你只描述结果,不用手写变更过程。这篇把 Vue 2 底层用的 Snabbdom 从头拆一遍,h 函数怎么造 VNode,patch 怎么打补丁,diff 的双端比较到底比了哪四种情况,key 在其中起什么作用。读完你对「v-for 为什么别拿 index 当 key」会有一个能自己推导出来的解释。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 虚拟 DOM 是什么,用一个 JS 对象描述 DOM 到底描述了哪几个字段
- 为什么要有虚拟 DOM,它解决的是性能问题还是别的问题
- Snabbdom 上手,从建项目到用模块处理属性、样式、事件
- h 函数的重载怎么实现,VNode 的六个字段各管什么
- init 为什么写成高阶函数,patch 的完整分支走向
- createElm 和 patchVnode 的执行顺序,钩子函数在哪几个点被触发
- updateChildren 的双端比较,四种命中情况加两种收尾情况
- 这套算法反过来对写业务代码有什么约束,比如 key 该怎么选
# 一、虚拟 DOM 到底是什么
虚拟 DOM 就是用普通的 JavaScript 对象来描述真实 DOM。因为它不是真的 DOM 对象,所以叫 Virtual DOM。
那为什么非得拿一个普通对象去描述?先看看真实的 DOM 对象身上挂了多少东西。随便捞一个元素,把它的属性名全打印出来:
let element = document.querySelector('#app')
let s = ''
for (var key in element) {
s += key + ','
}
console.log(s)
// 打印结果 align,title,lang,translate,dir,hidden,accessKey,draggable,spellcheck,aut ocapitalize,contentEditable,isContentEditable,inputMode,offsetParent,off setTop,offsetLeft,offsetWidth,offsetHeight,style,innerText,outerText,onc opy,oncut,onpaste,onabort,onblur,oncancel,oncanplay,oncanplaythrough,onc hange,onclick,onclose,oncontextmenu,oncuechange,ondblclick,ondrag,ondrag end,ondragenter,ondragleave,ondragover,ondragstart,ondrop,ondurationchan ge,onemptied,onended,onerror,onfocus,oninput,oninvalid,onkeydown,onkeypr ess,onkeyup,onload,onloadeddata,onloadedmetadata,onloadstart,onmousedown ,onmouseenter,onmouseleave,onmousemove,onmouseout,onmouseover,onmouseup, onmousewheel,onpause,onplay,onplaying,onprogress,onratechange,onreset,on resize,onscroll,onseeked,onseeking,onselect,onstalled,onsubmit,onsuspend ,ontimeupdate,ontoggle,onvolumechange,onwaiting,onwheel,onauxclick,ongot pointercapture,onlostpointercapture,onpointerdown,onpointermove,onpointe rup,onpointercancel,onpointerover,onpointerout,onpointerenter,onpointerl eave,onselectstart,onselectionchange,onanimationend,onanimationiteration ,onanimationstart,ontransitionend,dataset,nonce,autofocus,tabIndex,click ,focus,blur,enterKeyHint,onformdata,onpointerrawupdate,attachInternals,n amespaceURI,prefix,localName,tagName,id,className,classList,slot,part,at tributes,shadowRoot,assignedSlot,innerHTML,outerHTML,scrollTop,scrollLef t,scrollWidth,scrollHeight,clientTop,clientLeft,clientWidth,clientHeight ,attributeStyleMap,onbeforecopy,onbeforecut,onbeforepaste,onsearch,eleme ntTiming,previousElementSibling,nextElementSibling,children,firstElement Child,lastElementChild,childElementCount,onfullscreenchange,onfullscreen error,onwebkitfullscreenchange,onwebkitfullscreenerror,setPointerCapture ,releasePointerCapture,hasPointerCapture,hasAttributes,getAttributeNames ,getAttribute,getAttributeNS,setAttribute,setAttributeNS,removeAttribute ,removeAttributeNS,hasAttribute,hasAttributeNS,toggleAttribute,getAttrib uteNode,getAttributeNodeNS,setAttributeNode,setAttributeNodeNS,removeAtt ributeNode,closest,matches,webkitMatchesSelector,attachShadow,getElement sByTagName,getElementsByTagNameNS,getElementsByClassName,insertAdjacentE lement,insertAdjacentText,insertAdjacentHTML,requestPointerLock,getClien tRects,getBoundingClientRect,scrollIntoView,scroll,scrollTo,scrollBy,scr ollIntoViewIfNeeded,animate,computedStyleMap,before,after,replaceWith,re move,prepend,append,querySelector,querySelectorAll,requestFullscreen,web kitRequestFullScreen,webkitRequestFullscreen,createShadowRoot,getDestina tionInsertionPoints,ELEMENT_NODE,ATTRIBUTE_NODE,TEXT_NODE,CDATA_SECTION_ NODE,ENTITY_REFERENCE_NODE,ENTITY_NODE,PROCESSING_INSTRUCTION_NODE,COMME NT_NODE,DOCUMENT_NODE,DOCUMENT_TYPE_NODE,DOCUMENT_FRAGMENT_NODE,NOTATION _NODE,DOCUMENT_POSITION_DISCONNECTED,DOCUMENT_POSITION_PRECEDING,DOCUMEN T_POSITION_FOLLOWING,DOCUMENT_POSITION_CONTAINS,DOCUMENT_POSITION_CONTAI NED_BY,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC,nodeType,nodeName,baseU RI,isConnected,ownerDocument,parentNode,parentElement,childNodes,firstCh ild,lastChild,previousSibling,nextSibling,nodeValue,textContent,hasChild Nodes,getRootNode,normalize,cloneNode,isEqualNode,isSameNode,compareDocu mentPosition,contains,lookupPrefix,lookupNamespaceURI,isDefaultNamespace ,insertBefore,appendChild,replaceChild,removeChild,addEventListener,remo veEventListener,dispatchEvent
这一坨输出还只是 Chrome 里一个 div 的属性名列表,几百个。如果每次状态变化都要在这样一个庞然大物上做增删改查,还要考虑各家浏览器的行为差异,代码很快就会变成一团乱麻。
所以换个思路,我不描述「怎么改」,我描述「现在应该长什么样」。用一个对象就够了:
{
sel: "div",
data: {},
children: undefined,
text: "Hello Virtual DOM",
elm: undefined,
key: undefined
}
六个字段,一个不多。sel 是选择器,data 装属性样式事件,children 和 text 互斥(一个节点要么有子节点要么有文本),elm 指向它对应的真实 DOM,key 用来做同一层节点的身份标识。一棵这样的对象树,就是一份 DOM 的快照。
# 二、为什么要有虚拟 DOM
先说结论,虚拟 DOM 解决的核心问题是状态跟踪,性能只是它顺带换来的东西。
顺着历史看一遍就清楚了。最早我们手动操作 DOM,麻烦不说,还得处理浏览器兼容,jQuery 把这层抹平了一部分,但项目一复杂,「哪个状态变了要改哪几个节点」这件事依然全靠人脑记,DOM 操作的复杂度随着页面复杂度一起涨。
再往后各种 MVVM 框架出现,把视图和状态的同步问题接管了。模板引擎也简化了视图的书写,但模板引擎有个致命短板,它不知道这次和上次相比哪里变了,只能整个重新渲染一遍。页面一大,整块重渲的代价就上来了,输入框失焦、滚动位置丢失这些副作用还得另外补。