什么是虚拟DOM
虚拟DOM是一个普通的JavaScript对象,用来描述真实的DOM
创建虚拟DOM的开销要比创建真实DOM小很多
为什么要使用虚拟DOM?
手动操作DOM比较麻烦,还需要考虑浏览器兼容性问题,虽然有jQuery等库来简化DOM操作,但随着项目的复杂,DOM操作的复杂度提升
为了简化DOM的复杂操作出现了各种MVVM框架,MVVM框架解决了视图和状态的同步问题
为了简化视图的操作,我们可以使用模板引擎,但模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM出现
Virtual DOM的好处是,当状态发生变化时,不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual DOM内部通过diff算法来有效的更新DOM
-
github上virtual-dom的描述
Virtual DOM可以维护程序的状态,同时跟踪上一次状态
-
通过比较前后两次状态差异来更新视图(真实DOM)
虚拟DOM的作用和虚拟DOM库
虚拟DOM的作用
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 除了渲染DOM外,还可以实现SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等
虚拟DOM库
-
Snabbdom
- Vue 2.x 内部使用的Virtual DOM就是改造的Snabbdom
- 仅有200行代码SLOC (single line of code)
- 通过模块可扩展
- 源码使用TypeScript开发
- 最快的Virtual DOM之一
- virtual-dom
Snabbdom的基本使用
注:以下使用的Snabbdom均为2.1.x版本,不同版本的使用方式上存在一定的差异
创建项目
- 创建项目目录,并使用
yarn init -y
初始化 - 使用
yarn add parcel-bundler
命令安装parcel
来进行项目的打包构建 - 在
pacakge.json
中添加script属性,并设置相关的编译命令"dev": "parcel index.html --open"
"build": "parcel build index.html"
- 新建index.html文件
- 新建src目录,并创建basicusage.js文件
- 在index.html中引入创建的basicusage.js
导入Snabbdom
使用
yarn add snabbdom
命令来安装snabbdom-
使用ES6 Module import语法引入snabbdom的相关方法
// snabbdom 2.1.x版本导入方式 import { init } from 'snabbdom/build/package/init' import { h } from 'snabbdom/build/package/h'
基本使用
通过两个简单的例子来演示snabbdom的基本使用方式
-
hello world
-
调用init函数初始化
- 参数:数组/模块
- 返回值:patch函数,作用是对比两个vnode之间的差异,并更新到真实DOM
-
调用h函数创建vnode
- 第一个参数:标签 + 选择器
- 第二个参数:如果是字符串的话,是标签的内容
- 返回值:vnode对象
-
调用patch函数
- 第一个参数:可以是DOM元素,内部会把DOM元素转换为vnode
- 第二个参数:vnode
- 返回值:vnode
// 1. hello world // init函数 // 参数:数组/模块 // 返回值:patch函数,作用是对比两个vnode的差异,更新到真实DOM const patch = init([]) // h函数 // 第一个参数:标签 + 选择器 // 第二个参数:如果是字符串的话,是标签的内容 // 返回值:vnode const vnode = h('div#container.cls', 'Hello World') // 获取app元素 const app = document.querySelector('#app') // patch函数 // 第一个参数:可以是DOM元素,内部会把DOM元素转换为vnode // 第二个参数:vnode // 返回值:vnode const oldVNode = patch(app, vnode) const newVNode = h('div', 'hello snabbdom') patch(oldVNode, newVNode)
-
-
div中放置子元素
h函数的第二个参数为数组时,接收一个子元素的vnode列表
-
如果想要清空DOM元素,可以通过h函数
h('!')
创建一个注释节点,通过patch替换let vnode = h('div#container', [ h('h1', 'hello snabbdom'), h('p', '这是一个p标签') ]) const oldVNode = patch(app, vnode) setTimeout(() => { vnode = h('div#container', [ h('h1', 'hello world'), h('p', 'hello p') ]) patch(oldVNode, vnode) // 使用h('!')创建注释节点替换清空DOM元素 // patch(oldVNode, h('!')) }, 2000)
snabbdom中的模块
snabbdom的核心库不能处理元素的属性/样式/事件等,如果需要处理,可以使用模块
官方提供了6个常用的模块
- attributes
- 设置DOM元素的属性,使用
setAttribute()
- 处理布尔类型的属性
- 设置DOM元素的属性,使用
- props
- 和
attributes
类似,设置DOM元素的属性element[attr] = value
- 不处理布尔类型的属性
- 和
- class
- 切换类样式
- dataset
- 设置data-*的自定义属性
- eventlisteners
- 注册和移除事件
- style
- 设置行内样式,支持动画
- delayed/remove/destory
模块的使用
- 导入需要的模块
- init()中注册模块
- 使用h()创建vnode时,可以将第二个参数设置为对象,传入模块相应的数据,子元素数组等参数后移到第三个参数
// 1. 导入模块
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
import { styleModule } from 'snabbdom/build/package/modules/style';
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners';
// 2. 注册模块
const patch = init([
styleModule,
eventListenersModule,
])
// 3. 使用h()函数第二个参数传入模块需要的数据
let vnode = h('div', {
style: {
backgroundColor: 'red'
},
on: {
click: function() {
console.log('click')
}
}
}, [
h('h1', 'hello world'),
h('p', 'hello p')
])
const app = document.querySelector('#app')
patch(app, vnode)
Snabbdom核心源码解析
h()
Vue中也存在h()函数,在snabbdom的基础上,支持传入组件作为参数,snabbdom的h()函数不支持组件参数
h()函数最早见于HyperScript,使用javascript创建超文本
snabbdom的h()函数不用来创建超文本,而是用来创建vnode
-
snabbdom使用TypeScript编写,并使用了函数重载,h()函数通过重载来处理不同参数时的不同逻辑
// h.ts import { vnode, VNode, VNodeData } from './vnode' import * as is from './is' export type VNodes = VNode[] export type VNodeChildElement = VNode | string | number | undefined | null export type ArrayOrElement<T> = T | T[] export type VNodeChildren = ArrayOrElement<VNodeChildElement> // 递归添加ns命名空间属性 function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void { data.ns = 'http://www.w3.org/2000/svg' if (sel !== 'foreignObject' && children !== undefined) { for (let i = 0; i < children.length; ++i) { const childData = children[i].data if (childData !== undefined) { addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel) } } } } // h 函数重载 export function h (sel: string): VNode export function h (sel: string, data: VNodeData | null): VNode export function h (sel: string, children: VNodeChildren): VNode export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode export function h (sel: any, b?: any, c?: any): VNode { var data: VNodeData = {} var children: any var text: any var i: number // 处理参数,实现重载机制 if (c !== undefined) { // 处理三个参数的情况 sel、data、children/text if (b !== null) { data = b } if (is.array(c)) { // 子元素数组 children = c } else if (is.primitive(c)) { // 元素内容 text = c } else if (c && c.sel) { // vnode children = [c] } } else if (b !== undefined && b !== null) { // 处理两个参数的情况 sel、children/text if (is.array(b)) { children = b } else if (is.primitive(b)) { text = b } else if (b && b.sel) { children = [b] } else { data = b } } if (children !== undefined) { // 处理children中的文本节点 for (i = 0; i < children.length; ++i) { if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined) } } if ( sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' && (sel.length === 3 || sel[3] === '.' || sel[3] === '#') ) { addNS(data, children, sel) } return vnode(sel, data, children, text, undefined) };
vnode
vnode是包含sel、data、children、elm、text、key这6个属性的对象
-
vnode创建时接收key以外的5个参数,key的指取data.key或者undefined
import ... export type Key = string | number export interface VNode { sel: string | undefined // 选择器 data: VNodeData | undefined // 模块节点数据 属性/样式/事件等 children: Array<VNode | string> | undefined // 子节点,和text互斥 elm: Node | undefined // vnode对应的真实DOM text: string | undefined // 节点内容 key: Key | undefined // 用于优化性能,取data.key或undefined } export interface VNodeData { props?: Props attrs?: Attrs class?: Classes style?: VNodeStyle dataset?: Dataset on?: On hero?: Hero attachData?: AttachData hook?: Hooks key?: Key ns?: string // for SVGs fn?: () => VNode // for thunks args?: any[] // for thunks [key: string]: any // for any other 3rd party module } export function vnode (sel: string | undefined, data: any | undefined, children: Array<VNode | string> | undefined, text: string | undefined, elm: Element | Text | undefined): VNode { const key = data === undefined ? undefined : data.key return { sel, data, children, text, elm, key } }
init(modules, domApi)
接收模块数组作为第一个参数,没有使用模块时可以传空数组
接收domApi作为第二个参数,这是一个可选参数,用来提供转换虚拟节点的api,不传时,默认使用htmlDomApi
-
最终返回patch函数
... const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post'] export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { let i: number let j: number const cbs: ModuleHooks = { create: [], update: [], remove: [], destroy: [], pre: [], post: [] } // 初始化转换虚拟节点的 api ,如果没有通过第二个参数传入,则使用默认的 htmlDomApi const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi // 遍历并把传入的所有模块的钩子函数,统一存储到 cbs 对象中 // 最终构建的 cbs 对象的形式为 cbs = { create: [fn1, fn2, ...], update: [fn1, fn2, ...], ... } for (i = 0; i < hooks.length; ++i) { // 遍历 hooks 并将 hook 的名称作为 cbs 的属性名初始化成空数组 [] cbs[hooks[i]] = [] for (j = 0; j < modules.length; ++j) { // 遍历 modules 传入的模块数组 // 获取模块中的 hook 函数 const hook = modules[j][hooks[i]] if (hook !== undefined) { // 把获取到的 hook 函数放入到 cbs 对应的钩子函数数组中 (cbs[hooks[i]] as any[]).push(hook) } } } function emptyNodeAt (elm: Element) { ... } function createRmCb (childElm: Node, listeners: number) { ... } function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node { ... } function addVnodes ( parentElm: Node, before: Node | null, vnodes: VNode[], startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue ) { ... } function invokeDestroyHook (vnode: VNode) { ... } function removeVnodes (parentElm: Node, ... } function updateChildren (parentElm: Node, ... } function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { ... } // 返回 patch 函数 return function patch (oldVnode: VNode | Element, vnode: VNode): VNode { ... } }
patch(oldVNode, newVNode)
比较新旧vnode的变化,并将新节点中变化的内容渲染到真实DOM,最终返回新节点作为下一次处理的旧节点
patch执行的整体过程
- 对比新旧vnode是否相同节点(key 和 sel相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,判断新的vnode是否有text且与旧vnode的text不同,则更新文本内容
- 如果新的vnode有children,判断子节点是否有变化,判断子节点的过程使用diff算法
- diff过程只进行同层比较
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
// 保存新插入节点的队列,用于触发钩子函数
const insertedVnodeQueue: VNodeQueue = []
// 执行模块的所有 pre 钩子函数
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
// 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm
if (!isVnode(oldVnode)) { // 通过对象是否包含 sel 属性判断
// 把 DOM 元素转换成空的 VNode
// 调用 vnode 构造函数创建 VNode
oldVnode = emptyNodeAt(oldVnode)
}
// 判断新旧节点是否相同
// 判断新旧vnode的key与sel是否相同
if (sameVnode(oldVnode, vnode)) {
// 找节点的差异并更新 DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// 如果新旧节点不同,vnode创建对应的 DOM
// 获取当前的 DOM 元素
elm = oldVnode.elm!
// 获取 DOM 元素的父节点
parent = api.parentNode(elm) as Node
// 创建 vnode 对应的 DOM 元素,并触发 init/create 钩子函数
createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
// 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
// 移除老节点
removeVnodes(parent, [oldVnode], 0, 0)
}
}
// 执行用户设置的 insert 钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
// 执行用户设置的 post 钩子函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
// 返回 vnode
return vnode
}
createElm(vnode, insertedVnodeQueue)
创建 vnode 对应的 DOM 元素,并触发 init/create 钩子函数
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any
let data = vnode.data
if (data !== undefined) {
// 执行用户传入的 init 钩子函数
const init = data.hook?.init
if (isDef(init)) {
init(vnode)
// 用户传入的 init 函数可能修改 vnode 的 data
// 需要重新赋值 data
data = vnode.data
}
}
// 把 vnode 转换成真实 DOM 对象 (但并没有渲染到页面)
const children = vnode.children
const sel = vnode.sel
if (sel === '!') {
// 创建注释节点
if (isUndef(vnode.text)) {
vnode.text = ''
}
vnode.elm = api.createComment(vnode.text!)
} else if (sel !== undefined) {
// Parse selector
// 解析选择器
const hashIdx = sel.indexOf('#')
const dotIdx = sel.indexOf('.', hashIdx)
const hash = hashIdx > 0 ? hashIdx : sel.length
const dot = dotIdx > 0 ? dotIdx : sel.length
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel
const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
? api.createElementNS(i, tag) // 创建带命名空间的 DOM 元素
: api.createElement(tag) // 创建普通的 DOM 元素
// 设置元素 id
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
// 设置元素 class
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
// 执行模块中的 create 钩子函数
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
// 如果 vnode 中有子节点,递归调用 createElm 创建子 vnode 对应的 DOM 元素,并追加到 DOM 树
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i]
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
}
}
} else if (is.primitive(vnode.text)) {
// 如果 vnode 的 text 是 string/number, 创建文本节点并追加到 DOM 树
api.appendChild(elm, api.createTextNode(vnode.text))
}
const hook = vnode.data!.hook
if (isDef(hook)) {
// 执行传入的 create 钩子函数
hook.create?.(emptyNode, vnode)
if (hook.insert) {
// 把 vnode 添加到队列中,为后续执行 insert 钩子做准备
insertedVnodeQueue.push(vnode)
}
}
} else {
// 如果选择器为空,创建文本节点
vnode.elm = api.createTextNode(vnode.text!)
}
// 返回新创建的 DOM
return vnode.elm
}
removeVnodes(parentElm, vnodes, start, end)
用于批量移除节点
function removeVnodes (parentElm: Node,
vnodes: VNode[],
startIdx: number,
endIdx: number): void {
for (; startIdx <= endIdx; ++startIdx) {
let listeners: number
let rm: () => void
const ch = vnodes[startIdx]
// 判断 vnode 是否有值
if (ch != null) {
// 判断 sel 是否有值
// 有值为元素节点,否则为文本节点
if (isDef(ch.sel)) {
// 执行用户定义的 destory 钩子函数(包含子节点)
invokeDestroyHook(ch)
listeners = cbs.remove.length + 1
// 创建删除的回调函数
// 通过listeners计数判断,最终当listeners为0时才会真正执行
rm = createRmCb(ch.elm!, listeners)
// 执行用户设置模块的 remove 钩子函数
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
const removeHook = ch?.data?.hook?.remove
// 判断是否存在用户定义的 remove 钩子函数
// 存在则先执行用户定义的钩子函数
// 不存在则直接执行删除元素的方法
if (isDef(removeHook)) {
removeHook(ch, rm)
} else {
rm()
}
} else { // Text node
// 文本节点,直接移除
api.removeChild(parentElm, ch.elm!)
}
}
}
}
addVnodes(parentElm, before, vnodes, start, end, insertedVnodeQueue)
用于批量添加节点
function addVnodes (
parentElm: Node,
before: Node | null,
vnodes: VNode[],
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue
) {
// 遍历 vnodes
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (ch != null) {
// 通过 createElm 将 vnode 转换为真实的 DOM,并插入指定的元素之前
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
}
}
}
patchVnode(oldVnode, vnode, insertedVnodeQueue)
用于比较新老 vnode 之间的差异,并更新 DOM
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook
// 执行用户设置的 prepatch 钩子函数
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
// 如果新老 vnode 相同,则返回
if (oldVnode === vnode) return
if (vnode.data !== undefined) {
// 执行模块的 update 钩子函数
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
// 执行用户设置的 update 钩子函数
vnode.data.hook?.update?.(oldVnode, vnode)
}
// 如果 vnode.text 未定义
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 新旧 vnode 都存在子节点,且子节点不相同
// 调用 updateChildren 更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
// 新 vnode 存在子节点,老 vnode 不存在子节点
// 如果老 vnode.text 存在,则清空 DOM 元素的 textContent
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
// 批量添加新 vnode 中的子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 老 vnode 存在子节点,新 vnode 不存在子节点
// 批量删除老 vnode 下的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 新老 vnode 都不存在子节点
// 老 vnode.text 存在,则清空 DOM 元素的 textContent
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 老 vnode.text 与新 vnode.text 不相同
// 老 vnode 存在子节点,则批量移除老 vnode 的所有子节点
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 将 DOM 元素的 textContent 设置成新 vnode.text
api.setTextContent(elm, vnode.text!)
}
// 执行用户设置的 postpatch 钩子函数
hook?.postpatch?.(oldVnode, vnode)
}
updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue)
比较新老 vnode 的子节点,并更新,是diff算法的核心
-
关于diff算法
- 要对比两个 DOM 树的差异,可以取第一棵树的每一个节点依次与第二棵树的每一个节点进行比较,这样的时间复杂度是O(n^n)
- 实际使用过程中,极少的情况下会将一个父节点移动更新到某一个子节点
- 因此只需要找同级别的子节点依次比较,这样算法的时间复杂度是O(n)
- 在同级别比较时,对新老节点数组的开始和结尾设置标记索引,比较过程中移动索引的位置
- 比较时依次共有四种情况(由上至下,不是相同节点时继续使用下一种比较):
- oldStartVnode vs newStartVnode
- oldEndVnode vs newEndVnode
- oldStartVnode vs newEndVnode
- oldEndVnode vs newStartVnode
- 如果比较的两个新旧节点相同,则调用patchVnode更新DOM,并移动标记索引(开始索引++,结尾索引--)
- 如果上面四种情况均不满足,则使用newStartVnode在所有oldVnode中查找是否存在相同的节点(比较key与sel),如果找到相同节点,则将相应的节点移动到数组的最前面,如果未找到相同节点,则创建一个新的DOM元素,插入到节点数组最前面
- 当新老节点数量不一样
- 老节点数量大于新节点数量,则删除多余的老节点
- 老节点数量小于新节点数量,则创建多余的新节点
function updateChildren (parentElm: Node, oldCh: VNode[], newCh: VNode[], insertedVnodeQueue: VNodeQueue) { let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let oldStartVnode = oldCh[0] let oldEndVnode = oldCh[oldEndIdx] let newEndIdx = newCh.length - 1 let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx: KeyToIndexMap | undefined let idxInOld: number let elmToMove: VNode let before: any while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 判断节点是否为null并移动索引标记 // 比较移动过程中,可能将节点设置为null if (oldStartVnode == null) { oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left } else if (oldEndVnode == null) { oldEndVnode = oldCh[--oldEndIdx] } else if (newStartVnode == null) { newStartVnode = newCh[++newStartIdx] } else if (newEndVnode == null) { newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { // 都不为null的情况下 // 比较 oldStartVnode 与 newStartVnode 是否相同节点 // 是则调用 patchVnode 更新 DOM // 然后移动索引 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { // 比较 oldEndVnode 与 newEndVnode 是否相同节点 // 是则调用 patchVnode 更新 DOM // 然后移动索引 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // 比较 oldStartVnode 与 newEndVnode 是否相同节点 // 是则调用 patchVnode 更新 DOM // 将 oldStartVnode 向右移动到 oldEndVnode 前面 // 然后移动索引 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // 比较 oldStartVnode 与 newEndVnode 是否相同节点 // 是则调用 patchVnode 更新 DOM // 将 oldEndVnode 向左移动到 oldStartVnode 前面 // 然后移动索引 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 如果上述四种比较均不能找到相同的节点 // 使用 newStartVnode.key 在老节点数组中寻找相同节点 // 调用 createKeyToOldIdx 记录 key 和 index 对象 // 遍历老节点数组,将非 undefined 的 key 作为 map 的属性,保存对应的 index,最终返回 map,即为 oldKeyToIdx if (oldKeyToIdx === undefined) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) } // 获取 newStartVnode.key 在老节点中的索引 idxInOld idxInOld = oldKeyToIdx[newStartVnode.key as string] if (isUndef(idxInOld)) { // New element // 如果 idxInOld 是 undefined,说明 newStartVnode 在老节点数组中不存在 // 创建一个新的 DOM 元素,并插入 oldStartVnode 之前 api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!) } else { // newStartVnode.key 在老节点数组中存在 // 保存到 elmToMove elmToMove = oldCh[idxInOld] if (elmToMove.sel !== newStartVnode.sel) { // key 相同但 sel 不同,说明节点被修改 // 创建一个新的 DOM 元素,并插入 oldStartVnode 之前 api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!) } else { // key 与 sel 相同,是相同节点 // 调用 patchVnode 更新 DOM // 将老数组中的节点置为 undefined // 并将该节点移动到 oldStartVnode 之前 patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) oldCh[idxInOld] = undefined as any api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!) } } // 移动 newStartIdx 索引,进入下一次循环 newStartVnode = newCh[++newStartIdx] } } // 循环结束,通过索引判断新老数组是否有剩余节点未被遍历 // 即剩余新老节点长度是否不一致 if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) { if (oldStartIdx > oldEndIdx) { // 新节点多 // 创建多余的新节点 before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else { // 老节点多 // 移除多余的老节点 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } } }
**从diff算法执行过程看 key 的重要性,未指定 key 的时候,key的值都是undefined,此时不同的节点会被误认为是相同的节点发生内容修改,从而导致频繁的 DOM 更新,而指定了 key 的情况下,对于只是节点顺序发生变化的情况,只需要调整节点的顺序,而不需要更新 DOM **
模块源码
snabbdom为了保证核心代码的精简,将处理元素属性、样式、事件等工作,放到了模块中
模块可以按需引入
模块实现的核心基于Hooks
以 attributes 模块为例
function updateAttrs (oldVnode: VNode, vnode: VNode): void {
var key: string
var elm: Element = vnode.elm as Element
var oldAttrs = (oldVnode.data as VNodeData).attrs
var attrs = (vnode.data as VNodeData).attrs
// 新老节点没有属性,直接返回
if (!oldAttrs && !attrs) return
// 新老节点属性相同,直接返回
if (oldAttrs === attrs) return
oldAttrs = oldAttrs || {}
attrs = attrs || {}
// update modified attributes, add new attributes
// 遍历新元素的属性
for (key in attrs) {
const cur = attrs[key]
const old = oldAttrs[key]
// 新老节点属性不同时
if (old !== cur) {
// 处理布尔类型
if (cur === true) {
elm.setAttribute(key, '')
} else if (cur === false) {
elm.removeAttribute(key)
} else {
if (key.charCodeAt(0) !== xChar) {
elm.setAttribute(key, cur as any)
} else if (key.charCodeAt(3) === colonChar) {
// Assume xml namespace
elm.setAttributeNS(xmlNS, key, cur as any)
} else if (key.charCodeAt(5) === colonChar) {
// Assume xlink namespace
elm.setAttributeNS(xlinkNS, key, cur as any)
} else {
elm.setAttribute(key, cur as any)
}
}
}
}
// remove removed attributes
// use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)
// the other option is to remove all attributes with value == undefined
// 遍历老元素的属性,判断在新元素中是否存在,没有则移除
for (key in oldAttrs) {
if (!(key in attrs)) {
elm.removeAttribute(key)
}
}
}
// 使用 create update 两个 hook
export const attributesModule: Module = { create: updateAttrs, update: updateAttrs }