目录
一些值得思考的问题
我们知道,在 Vue 和 React 中都采用了虚拟 DOM,那么什么是虚拟 DOM,这两个框架内部为什么使用虚拟 DOM 呢?是为了提高页面的渲染性能?还是其他的原因呢?
Vue 的虚拟 DOM 代码是通过改造一个开源库叫做 Snabbdom,首先我们需要知道 Snabbdom 的一些基本使用和源码,面试过程中,可能会涉及到一些问题诸如:虚拟 DOM 是如何工作的?
什么是虚拟 DOM
虚拟 DOM 的概念非常简单,就是采用普通的 Javascript 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual DOM
那么又牵扯到了一个问题,我们为什么放着好好的真实 DOM 不用,跑去用虚拟 DOM 描述真实 DOM 呢?
① 成本问题:创建虚拟 DOM 的成本要比创建真实 DOM 的成本小很多,如下真实 DOM 节点:
<div id="app">
hell vue
</div>
虽然上面的元素仅仅是一个 div 元素节点包裹一个文本节点,但是可以通过以下代码打印出这个div 节点所拥有的属性:
<script>
let ele = document.querySelector('#app')
let s = ''
for (let key in ele) {
s += key + ','
}
console.log(s)
</script>
打印结果如下,可以说仅仅一个 div 元素就包含了很多属性:
align,title,lang,translate,dir,hidden,accessKey,draggable,spellcheck,autocapitalize,contentEditable,isContentEditable,inputMode,offsetParent,offsetTop,offsetLeft,offsetWidth,offsetHeight,style,innerText,outerText,onabort,onblur,oncancel,oncanplay,oncanplaythrough,onchange,onclick,onclose,oncontextmenu,oncuechange,ondblclick,ondrag,ondragend,ondragenter,ondragleave,ondragover,ondragstart,ondrop,ondurationchange,onemptied,onended,onerror,onfocus,onformdata,oninput,oninvalid,onkeydown,onkeypress,onkeyup,onload,onloadeddata,onloadedmetadata,onloadstart,onmousedown,onmouseenter,onmouseleave,onmousemove,onmouseout,onmouseover,onmouseup,onmousewheel,onpause,onplay,onplaying,onprogress,onratechange,onreset,onresize,onscroll,onseeked,onseeking,onselect,onstalled,onsubmit,onsuspend,ontimeupdate,ontoggle,onvolumechange,onwaiting,onwebkitanimationend,onwebkitanimationiteration,onwebkitanimationstart,onwebkittransitionend,onwheel,onauxclick,ongotpointercapture,onlostpointercapture,onpointerdown,onpointermove,onpointerup,onpointercancel,onpointerover,onpointerout,onpointerenter,onpointerleave,onselectstart,onselectionchange,onanimationend,onanimationiteration,onanimationstart,ontransitionrun,ontransitionstart,ontransitionend,ontransitioncancel,oncopy,oncut,onpaste,dataset,nonce,autofocus,tabIndex,attachInternals,blur,click,focus,onpointerrawupdate,enterKeyHint,namespaceURI,prefix,localName,tagName,id,className,classList,slot,attributes,shadowRoot,part,assignedSlot,innerHTML,outerHTML,scrollTop,scrollLeft,scrollWidth,scrollHeight,clientTop,clientLeft,clientWidth,clientHeight,attributeStyleMap,onbeforecopy,onbeforecut,onbeforepaste,onsearch,elementTiming,onfullscreenchange,onfullscreenerror,onwebkitfullscreenchange,onwebkitfullscreenerror,onbeforexrselect,children,firstElementChild,lastElementChild,childElementCount,previousElementSibling,nextElementSibling,after,animate,append,attachShadow,before,closest,computedStyleMap,getAttribute,getAttributeNS,getAttributeNames,getAttributeNode,getAttributeNodeNS,getBoundingClientRect,getClientRects,getElementsByClassName,getElementsByTagName,getElementsByTagNameNS,hasAttribute,hasAttributeNS,hasAttributes,hasPointerCapture,insertAdjacentElement,insertAdjacentHTML,insertAdjacentText,matches,prepend,querySelector,querySelectorAll,releasePointerCapture,remove,removeAttribute,removeAttributeNS,removeAttributeNode,replaceWith,requestFullscreen,requestPointerLock,scroll,scrollBy,scrollIntoView,scrollIntoViewIfNeeded,scrollTo,setAttribute,setAttributeNS,setAttributeNode,setAttributeNodeNS,setPointerCapture,toggleAttribute,webkitMatchesSelector,webkitRequestFullScreen,webkitRequestFullscreen,ariaAtomic,ariaAutoComplete,ariaBusy,ariaChecked,ariaColCount,ariaColIndex,ariaColSpan,ariaCurrent,ariaDescription,ariaDisabled,ariaExpanded,ariaHasPopup,ariaHidden,ariaKeyShortcuts,ariaLabel,ariaLevel,ariaLive,ariaModal,ariaMultiLine,ariaMultiSelectable,ariaOrientation,ariaPlaceholder,ariaPosInSet,ariaPressed,ariaReadOnly,ariaRelevant,ariaRequired,ariaRoleDescription,ariaRowCount,ariaRowIndex,ariaRowSpan,ariaSelected,ariaSetSize,ariaSort,ariaValueMax,ariaValueMin,ariaValueNow,ariaValueText,getAnimations,replaceChildren,nodeType,nodeName,baseURI,isConnected,ownerDocument,parentNode,parentElement,childNodes,firstChild,lastChild,previousSibling,nextSibling,nodeValue,textContent,ELEMENT_NODE,ATTRIBUTE_NODE,TEXT_NODE,CDATA_SECTION_NODE,ENTITY_REFERENCE_NODE,ENTITY_NODE,PROCESSING_INSTRUCTION_NODE,COMMENT_NODE,DOCUMENT_NODE,DOCUMENT_TYPE_NODE,DOCUMENT_FRAGMENT_NODE,NOTATION_NODE,DOCUMENT_POSITION_DISCONNECTED,DOCUMENT_POSITION_PRECEDING,DOCUMENT_POSITION_FOLLOWING,DOCUMENT_POSITION_CONTAINS,DOCUMENT_POSITION_CONTAINED_BY,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC,appendChild,cloneNode,compareDocumentPosition,contains,getRootNode,hasChildNodes,insertBefore,isDefaultNamespace,isEqualNode,isSameNode,lookupNamespaceURI,lookupPrefix,normalize,removeChild,replaceChild,addEventListener,dispatchEvent,removeEventListener,
那么,与之对比的,采用 JavaScript 对象去描述这个节点的信息,成本只需如下:
{
sel: 'div',
data: {},
children: undefined,
text: 'hello virtual dom',
elm: undefined,
key: undefined
}
通过比较可以发现,创建一个虚拟 DOM 的成本比创建一个真实 DOM 的成本少太多
最后总结一下:虚拟 DOM 就是一个普通的 JavaScript 对象,用来描述真实的 DOM,创建虚拟 DOM 的开销要比创建真实 DOM 的开销小很多
为什么使用 Virtual DOM
- 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂,DOM 操作复杂提升
- 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题,也就是当数据变化自动更新视图,当视图变化自动更新数据
- 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题(模板引擎无法获取上一次数据的状态,每次渲染只能将界面上的元素先删除再重新创建),于是 Virtual DOM 实现了
- Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述 DOM,Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM,diff 算法会找到两次状态变化的差异,以最小的代价去改变有差异的地方
- 使用虚拟 DOM 的动机:虚拟 DOM 可以维护程序的状态,跟踪上一次的状态,通过比较前后两次状态的差异(diff),以最优代价去更新真实 DOM
虚拟 DOM 的作用
- 维护视图和状态的关系
- 复杂视图情况下能提升渲染性能(并不是所有的情况下虚拟 DOM 都能提升渲染性能)
Virtual DOM 库
- Vue2.x 内部使用的 Virtual DOM 就是改造的 Snabbdom
- 大约 200 SLOC(single line of code)
- 通过模块可扩展
- 源码使用 TypeScript 开发
- 最快的 Virtual DOM 之一
Snabbdom 基本使用
创建项目:
- 打包工具为了方便使用 parcel
- 创建项目,并安装 parcel
mkdir snabbdom-demo
cd snabbdom-demo
yarn init -y
yarn add parcel-bundler
- 配置 package.json 的 scripts
"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
}
- 创建目录结构
| index.html
| package.json
| src
-- 01-basicUsage.js
snabbdom 的基本使用
snabbdom 的基本使用离不开这个库提供的 init、h、patch 函数,关于这几个函数的基本使用可以参考如下案例
snabbdom 中的模块
snabbdom 的核心库并不能处理元素的属性、样式、事件等,如果需要处理的话,可以使用模块
常用模块
snabbdom 官方提供了 6 个常用模块:
- attributes
- 设置 DOM 元素的属性,使用 setAttribute()
- 处理布尔类型的数据
- props
- 和 attributes 模块相似,设置 DOM 元素的属性,element[attr] = value
- 不处理布尔类型的属性
- class
- 切换类样式
- 注意:给元素设置类样式是通过 sel 选择器
- dataset
- 设置 data-* 的自定义属性
- eventlisteners
- 注册和移除事件
- style
- 设置行内样式,支持动画
- delayed、remove、destroy
这些模块如果不满足我们的需求,也可以根据需求自定义模块,关于这些模块,无需记忆,只需看懂即可
模块使用
模块使用步骤:
- 导入需要的模块
- init()中注册模块
- 使用 h() 函数创建 VNode 的时候,可以把第二个参数设置为对象,其他参数往后移
Snabbdom 的核心
- 使用 h() 函数创建 Javascript 对象(VNode)来描述真实 DOM
- init()设置模块,创建 patch()
- patch()比较两个新旧 VNode
- 把变化的内容更新到真实 DOM 上
h 函数
h 函数介绍:
- 在使用 Vue 的时候见过 h()函数,只不过 Vue 中增强了 h 函数的能力,使其支持组件,比如下面代码就传入了 APP 组件
new Vue({
router,
store,
render: h => h(App)
})
- h 函数最早见于 hyperscript,使用 Javascript 创建超文本
- Snabbdom 中的 h 函数不是用来创建超文本,而是创建 VNode
函数重载:
- 参数个数或类型不同的函数
- Javascript 中没有重载的概念
- TypeScript 中有重载概念,不过重载的实现还是通过代码调整参数
function add (a, b) {
console.log(a + b)
}
function add (a, b, c) {
console.log(a + b + c)
}
add(1, 2)
add(1, 2, 3)
VNode 对象具有的特征
export interface VNode {
// 选择器,调用 h 函数时传入的第一个参数
sel: string | undefined;
// 节点数据:属性,样式,事件等,这些数据会交给 snabbdom 的模块使用
data: VNodeData | undefined;
// 子节点,和 text 只能互斥
children: Array<VNode | string> | undefined;
// 记录 vnode 对应的真实 DOM
elm: Node | undefined;
// 节点中的内容,和 children 只能互斥
text: string | undefined;
// 优化用
key: Key | undefined;
}
Snabbdom 的核心
- patch(oldValue,newvalue)
- 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否是相同节点(节点的 key 和 sel 相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有,并且和 oldVNode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
- diff 过程只进行同层级比较
patch 函数
patch 函数是用于比较两个虚拟 VNode 节点,并将最新的 VNode 节点中内容更新到真实 DOM 上的方法
这个方法是虚拟 DOM 算法中最关键最直接的一个工具,下面围绕着这个方法介绍一些相关的依赖函数等
isVnode方法:
判断一个对象是否是 VNode 节点
function isVnode(vnode) {
// isVnode 方法通过判断对象是否包含 sel 属性去决定一个对象是否是 VNode 虚拟节点
return vnode.sel !== undefined
}
emptyNodeAt方法:
将真实 DOM 转为 VNode
function emptyNodeAt(elm) {
const id = elm.id ? '# + elm.id : ''
const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
// vnode 方法用于创建虚拟节点,第一个参数即为 sel,也就是选择器
return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
}
samVnode:
判断两个虚拟节点是否是相同节点,判断的标准是 key 和 sel,这里的 key 就是 v-for 时强调的 key
function sameVnode(vnode1, vnode2) {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}
createElm:
将 Vnode 转为对应的 DOM 元素,这个函数并不会把直接把转成的 DOM 渲染到页面上,它会把转成的真实 DOM 存储到 vnode 对象上的 elm 属性上,此外,它的返回值也是转换后的真实 DOM 对象,在后续可以根据需求手动将真实 DOM 插入到真实页面,如下:
createElm(vnode, insertedVnodeQueue)
// createElm 调用后,vnode的 elm 属性上就会挂载被创建后的真实 DOM
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
}
// 此外,类似下面用法也可以
const el = createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
api.insertBefore(parent, el!, api.nextSibling(elm))
}
createElm 函数内部较为复杂,详细代码不再给出,只需知道该函数的功能即可
patchVNode:
patchVNode 函数是 patch 函数内部最核心的函数,它判断新的虚拟节点是否发生了变化,如果新节点的 text 属性或者子节点发生变化,会去更新 DOM
updateChildren 方法
在 patchVnode 函数中,会对新老节点进行比对,当新老节点都含有 children 时,就需要调用 updateChildren 方法去对比子节点、更新子节点
updateChildren 方法是 diff 算法的核心,它负责对比新旧节点的 children,更新 DOM
diff 算法在进行新旧节点 children 内容比较的时候,会采用一种同级节点比较的方式
这是一张很经典的图,出自《React’s diff algorithm》,Vue的diff算法也同样,即仅在同级的vnode间做diff,递归地进行同级vnode的diff,最终实现整个DOM树的更新。那同级vnode diff的细节又是怎样的呢?
在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中会根据不同情况移动索引
设置四个位置的索引只是第一步,之后便会对新旧节点的开始和结束节点进行比较,比较首先分为四种情况:
- oldStartVnode / newStartVnode(旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode(旧结束节点 / 新结束节点)
- oldStartVnode / oldEndVnode(旧开始节点 / 新结束节点)
- oldEndVnode / newStartVnode(旧结束节点 / 新开始节点)
首先,以上四种方式是分顺序的,会依次按照这四个规则比较,找出相同节点,一旦匹配,针对这四种情况,索引值的变化如下:
- oldStartIdx++、newStartIdx++
- oldEndIdx--、newEndIdx--
- oldStartIdx++、newEndIdx--
- oldEndIdx--、newStartIdx++
举个例子,首先第一次比较时,如果满足第一种情况,那么 oldStartIdx++、newStartIdx++,接着会继续根据最新的索引值接着匹配,看是否满足第一种、第二种、第三种、第四种,假设此时在第二轮比较满足了第三种情况,那么oldStartIdx++、newEndIdx--,此时又会进行新一轮比较,又从第一种开始,如此循环往复
那么,上面都是最理想的情况,当四种情况都不满足时,会发生什么呢?
此时会拿着新节点数组中的 newStartIdx 对应的节点去旧节点数组中一个一个找,一旦匹配(sameVnode 函数返回 true),如果没有找到,说明此时新节点数组中的开始节点是一个全新的节点,那么便要创建这样一个 DOM 元素,并把它插入到节点数组的最开始位置,而如果找到了,那么只需将这个旧节点数组中的节点直接移动到节点数组的开始位置即可
上面所述便是同级节点互相比较的流程了,只要新旧节点数组中的任意一个 startIdx 大于 endIdx,那么比较就结束了
当比较结束后,diff 算法并没有结束,还有一些收尾工作,此时需要判断新旧节点数组的节点数量是否相同,此时分为三种情况:
- 老节点数组中节点数量小于新节点数组,说明有新增节点,应找到这些新增的节点,并将之插入到 DOM
- 老节点数组中节点数量大于新节点数组,说明有部分节点被删除了,应该找到这些被删除的节点,并在 DOM 中删除
值得注意的,一旦某一次比较匹配(sameVnode 函数返回 true),例如满足第一种情况,此时,在索引值变化的同时,也会直接调用 patchVnode 函数,比较这两个节点的差异并更新到 DOM 上,因此,需要知道,diff 算法中每一次比较都会去实时修改 DOM,这种操作或者说行为被称作打补丁
v-for 循环中增加 key 值的目的
在内容发生变化后,尽可能复用旧的节点,减少创建节点带来的成本