一. snabbdom介绍
snabbdom是虚拟DOM库,通过虚拟DOM操作视图的更新。
例如下面实例代码,调用init方法执行初始化逻辑,返回patch方法。通过调用h方法创建虚拟DOM节点,接着通过patch方法将其更新到指定DOM节点。
javascript
import {
init,
classModule,
styleModule,
attributesModule,
eventListenersModule,
h,
} from 'snabbdom'
const patch = init([
classModule,
styleModule,
attributesModule,
eventListenersModule,
])
const vnode = h(
'div',
{
attrs: { id: 'app' },
},
[h('h1', {}, 'hello world')],
)
patch(document.querySelector('#app'), vnode)
二. 实现snabbdom
2.1 定义vnode
vnode节点包含以下属性:
sel属性是标签选择器key属性是节点唯一标识,用于判断节点是否一致data属性包含样式,类名和事件的对象children是child vnode数组text是节点文本elm是节点对应的真实DOM节点
javascript
function vnode(sel, data, children, text, elm) {
const key = data === undefined ? undefined : data.key
return { sel, key, data, children, text, elm }
}
2.1.1 h
便捷创建vnode方法
javascript
import { vnode } from './vnode'
export function h(sel, data, children) {
let text
if (typeof children === 'string') {
text = children
} else if (!Array.isArray(children)) {
children = [children]
}
if (Array.isArray(children)) {
for (let i = 0; i < children.length; i++) {
if (typeof children[i] === 'string')
children[i] = vnode(
undefined,
undefined,
undefined,
children[i],
undefined,
)
}
}
return vnode(
sel,
data,
Array.isArray(children) ? children : undefined,
text,
undefined,
)
}
2.2 定义modules
vnode节点的data属性中的每一个key都对应一个module,如class对应classModule,专门处理class。
每个module会暴露一些生命周期方法,如create、update等,会在特定时期会调用执行。
2.2.1 classModule
处理class,暴露create和update两个钩子。逻辑比较简单,主要diff新旧class属性值,然后调用DOM节点的classList属性方法修改class。
javascript
function updateClass(oldVnode, newVnode) {
let oldClass = oldVnode.data?.class
let newClass = newVnode.data?.class
const elm = newVnode.elm
if (!oldClass && !newClass) return
if (oldClass === newClass) return
oldClass = oldClass || {}
newClass = newClass || {}
for (const key in oldClass) {
if (oldClass[key] && !Object.prototype.hasOwnProperty(newClass, key)) {
elm.classList.remove(key)
}
}
for (const key in newClass) {
const value = newClass[key]
if (value !== oldClass[key]) {
elm.classLcist[value ? 'add' : 'remove'](key)
}
}
}
export const classModule = { create: updateClass, update: updateClass }
2.2.2 styleModule
处理style,暴露create和update两个钩子。逻辑比较简单,先将旧style属性重置,然后赋值新style属性值
javascript
function updateStyle(oldVnode, newVnode) {
const elm = newVnode.elm
let oldStyle = oldVnode.data?.style
let newStyle = newVnode.data?.style
if (!oldStyle && !newStyle) return
if (oldStyle === newStyle) return
oldStyle = oldStyle || {}
newStyle = newStyle || {}
for (const key in oldStyle) {
elm.style[key] = ''
}
for (const key in newStyle) {
const value = newStyle[key]
if (value !== oldStyle[key]) {
elm.style[key] = value
}
}
}
export const styleModule = { create: updateStyle, update: updateStyle }
2.2.3 attributesModule
处理attrs,暴露create和update两个钩子。逻辑比较简单,主要diff新旧attrs属性值,然后调用DOM节点的removeAttribute方法修改attr。
javascript
function updateAttributes(oldVnode, newVnode) {
let oldAttrs = oldVnode.data?.attrs
let newAttrs = newVnode.data?.attrs
const elm = newVnode.elm
if (!oldAttrs && !newAttrs) return
if (oldAttrs === newAttrs) return
oldAttrs = oldAttrs || {}
newAttrs = newAttrs || {}
for (const key in oldAttrs) {
if (!Object.prototype.hasOwnProperty.call(newAttrs, key)) {
elm.removeAttribute(key)
}
}
for (const key in newAttrs) {
const value = newAttrs[key]
if (value !== oldAttrs[key]) {
if (value === true) {
elm.setAttribute(key, '')
} else if (value === false) {
elm.removeAttribute(key)
} else {
elm.setAttribute(key, value)
}
}
}
}
export const attributesModule = {
create: updateAttributes,
update: updateAttributes,
}
2.2.4 eventListenersModule
处理eventlistener,核心逻辑是提供一个装饰的listener方法,作为统一的事件类型监听回调方法。
该方法主要会记录当前vnode节点,当触发事件时,会通过event获取事件类型,然后跟vnode节点的on属性进行比对,如果有对应的事件类型回调则调用执行。
javascript
function invokeHandler(handler, vnode, event) {
if (typeof handler === 'function') {
handler.call(vnode, event)
} else if (typeof handler === 'object') {
for (let i = 0; i < handler.length; i++) {
invokeHandler(handler[i], vnode, event)
}
}
}
function handleEvent(event, vnode) {
const name = event.type
const on = vnode.data?.on
if (on && on[name]) {
invokeHandler(on[name], vnode, event)
}
}
function createListener() {
return function handler(event) {
handleEvent(event, handler.vnode)
}
}
function updateEventListeners(oldVnode, newVnode) {
const oldOn = oldVnode.data?.on
const oldListener = oldVnode.listener
const oldElm = oldVnode.elm
const on = newVnode?.data?.on
const elm = newVnode?.elm
if (oldOn === on) return
if (oldOn && oldListener) {
if (!on) {
for (const name in oldOn) {
oldElm.removeEventListener(name, oldListener, false)
}
} else {
for (const name in oldOn) {
if (!on[name]) {
oldElm.removeEventListener(name, oldListener, false)
}
}
}
}
if (on) {
const listener = (newVnode.listener = oldListener || createListener())
listener.vnode = newVnode
if (!oldOn) {
for (const name in on) {
elm.addEventListener(name, listener, false)
}
} else {
for (const name in on) {
if (!oldOn[name]) {
elm.addEventListener(name, listener, false)
}
}
}
}
}
export const eventListenersModule = {
create: updateEventListeners,
update: updateEventListeners,
destroy: updateEventListeners,
}
2.3 定义init
init方法主要负责收集module的生命周期函数,返回patch方法
javascript
function init(modules) {
const cbs = {
create: [],
update: [],
destroy: [],
}
for (const hook in cbs) {
for (const module of modules) {
const cb = module[hook]
if (cb) cbs[hook].push(cb)
}
}
return function patch(oldVnode, newVnode) {}
}
2.4 定义patch
patch方法主要负责挂载vnode对应DOM节点以及更新DOM,接收两个入参,第一个是旧vnode,第二个是新vnode,通过diff新旧vnode完成视图更新。
需要注意的是第一个vnode可以是真实DOM节点,会将其转换成vnode。
javascript
function patch(oldVnode, newVnode) {
// 将DOM节点转成vnode
if (oldVnode.nodeType === Node.ELEMENT_NODE) {
oldVnode = emptyNodeAt(oldVnode)
}
// 如果新旧vnode相同,则进行diff,更新对应节点
if (sameVnode(oldVnode, newVnode)) {
patchVnode(oldVnode, newVnode)
} else {
const elm = oldVnode.elm
const parent = elm.parentNode
// 创建vnode对应的DOM节点
createElm(newVnode)
// 将新DOM节点插入到页面指定节点
parent.insertBefore(newVnode.elm, elm.nextSibling)
// 移除旧vnode对应的DOM节点
removeVnodes(parent, [oldVnode], 0, 0)
}
}
2.4.1 emptyNodeAt
emptyNodeAt方法逻辑比较简单,即创建DOM节点对应的vnode节点。
javascript
function emptyNodeAt(elm) {
const id = elm.id ? `#${elm.id}` : ''
return vnode(elm.tagName.toLowerCase() + id, {}, [], undefined, elm)
}
2.4.2 createElm
- 根据
vnode.sel创建对应类型的DOM节点 - 调用
module的create钩子 - 递归创建
vnode.children对应的DOM节点
javascript
function createElm(vnode) {
if (vnode.sel === '') {
vnode.elm = document.createTextNode(vnode.text)
} else {
const tag = vnode.sel
const elm = (vnode.elm = document.createElement(tag))
const text = vnode.text
const children = vnode.children
// 调用module的create钩子
for (let i = 0; i < cbs.create.length; i++)
cbs.create[i](emptyNode, vnode)
if (
(typeof text === 'string' && !Array.isArray(children)) ||
children.length === 0
) {
elm.appendChild(document.createTextNode(text))
}
if (Array.isArray(children)) {
for (let i = 0; i < children.length; i++) {
elm.appendChild(createElm(children[i]))
}
}
}
return vnode.elm
}
2.4.3 removeVnodes
- 调用
module的destroy钩子 - 移除
DOM节点
javascript
function invokeDestroyHook(vnode) {
if (vnode.data) {
for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
if (vnode.children) {
for (let i = 0; i < vnode.children.length; i++) {
if (typeof vnode.children[i] !== 'string') {
invokeDestroyHook(vnode.children[i])
}
}
}
}
}
function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const vnode = vnodes[startIdx]
if (vnode.sel) {
invokeDestroyHook(vnode)
}
parentElm.removeChild(vnode.elm)
}
}
2.4.4 addVnodes
创建vnode对应的DOM节点,插入到DOM树中。
javascript
function addVnodes(parentElm, before, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
if (vnodes[startIdx]) {
parentElm.insertBefore(createElm(vnodes[startIdx]), before)
}
}
}
2.4.5 patchVnode
diff核心逻辑如下:
-
新节点是文本节点且新旧节点文本不一致,则移除旧
vnode.children对应的DOM节点,更新节点文本内容。 -
新节点非文本节点
- 如果新旧
vnode.children都存在,则diff新旧vnode.children - 如果新
vnode.children存在,旧vnode.children不存在,则添加新vnode.children对应的DOM节点 - 如果旧
vnode.children存在,新vnode.children不存在,则删除旧vnode.children对应的DOM节点 - 如果新旧
vnode.children都不存在,判断旧vnode是否为文本节点,是则清空文本内容
- 如果新旧
javascript
function patchVnode(oldVnode, newVnode) {
if (oldVnode === newVnode) return
const elm = (newVnode.elm = oldVnode.elm)
if (newVnode.data || (newVnode.text && newVnode.text === oldVnode.text)) {
newVnode ??= {}
oldVnode ??= {}
for (let i = 0; i < cbs.update.length; i++)
cbs.update[i](oldVnode, newVnode)
}
const oldChild = oldVnode.children
const newChild = newVnode.children
if (newVnode.text === undefined) {
if (oldChild && newChild) {
if (oldChild !== newChild) updateChildren(elm, oldChild, newChild)
} else if (newChild) {
if (oldChild.text !== undefined) elm.textContent = ''
addVnodes(elm, null, newChild, 0, newChild.length - 1)
} else if (oldChild) {
removeVnodes(elm, oldChild, 0, oldChild.length - 1)
} else if (oldChild.text !== undefined) {
elm.textContent = ''
}
} else if (oldVnode.text !== newVnode.text) {
if (oldChild) {
removeVnodes(elm, oldChild, 0, oldChild.length - 1)
}
elm.textContent = newVnode.text
}
}
2.4.6 updateChildren
首先设置oldStartIdx和oldEndIdx指向旧vnode.children的首尾元素下标,oldStartVnode和oldEndVnode指向旧vnode.children的首尾元素,newStartIdx和newEndIdx执行新vnode.children的首尾元素下标,newStartVnode和newEndVnode执行新vnode.children的首尾元素。
接着判断新旧vnode.children元素是否相同,会有以下四种比较:
oldStartVnode和newStartVnodeoldEndVnode和newEndVnodeoldStartVnode和oldEndVnodenewStartVnode和newEndVnode
如果上述其一满足条件,则调用patchVnode方法进行下一轮diff,然后移动对应指针。
如果上述条件都不满足,则获取旧vnode.children的key与index的映射关系,然后进行以下判断:
- 判断新
newStartVnode.key在旧映射关系中是否有对应索引,如果没有说明是新vnode,则创建对应的DOM节点,然后插入到对应位置中 - 判断新
newEndVnode.key在旧映射关系中是否有对应索引,如果没有说明是新vnode,则创建对应的DOM节点,然后插入到对应位置中 - 获取新
newStartVnode对应的旧vnode,判断sel是否相同,不相同说明则直接创建newStartVnode对应的DOM节点,插入到对应位置中。相同则调用patchVnode方法进行下一轮diff,然后调整旧vnode的DOM节点位置
javascript
// 建立vnode.key -> index映射关系
function createKeyToOldIdx(children, beginIdx, endIdx) {
const map = {}
for (let i = beginIdx; i <= endIdx; i++) {
const key = children[i]?.key
if (key !== undefined) {
map[key] = i
}
}
return map
}
function updateChildren(parentElm, oldChild, newChild) {
let oldStartIdx = 0
let oldEndIdx = oldChild.length - 1
let newStartIdx = 0
let newEndIdx = newChild.length - 1
let oldStartVnode = oldChild[oldStartIdx]
let oldEndVnode = oldChild[oldEndIdx]
let newStartVnode = newChild[newStartIdx]
let newEndVnode = newChild[newEndIdx]
let oldKeyToIdx
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldChild[++oldStartIdx]
} else if (oldEndVnode == null) {
oldEndVnode = oldChild[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newChild[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newChild[--newEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldChild[++oldStartIdx]
newStartVnode = newChild[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldChild[--oldEndIdx]
newEndVnode = newChild[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
// 把旧start vnode对应的DOM节点移到末尾
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm)
oldStartVnode = oldChild[++oldStartIdx]
newEndVnode = newChild[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
// 把旧end vnode对应的DOM节点移到开头
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldChild[--oldEndIdx]
newStartVnode = newChild[++newStartIdx]
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldChild, oldStartIdx, oldEndIdx)
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (idxInOld === undefined) {
// `newStartVnode` is new, create and insert it in beginning
parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
newStartVnode = newChild[++newStartIdx]
} else if (oldKeyToIdx[newEndVnode.key] === undefined) {
// `newEndVnode` is new, create and insert it in the end
parentElm.insertBefore(
createElm(newEndVnode),
oldEndVnode.elm.nextSibling,
)
newEndVnode = newChild[--newEndIdx]
} else {
elmToMove = oldChild[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
} else {
patchVnode(elmToMove, newStartVnode)
oldChild[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}
newStartVnode = newChild[++newStartIdx]
}
}
}
if (newStartIdx <= newEndIdx) {
before =
newChild[newEndIdx + 1] == null ? null : newChild[newEndIdx + 1].elm
addVnodes(parentElm, before, newChild, newStartIdx, newEndIdx)
}
if (oldStartIdx <= oldEndIdx) {
removeVnodes(parentElm, oldChild, oldStartIdx, oldEndIdx)
}
}
三. 总结
snabbdom通过虚拟DOM操作视图更新,自定义diff算法,值得借鉴学习。