一. 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
和newStartVnode
oldEndVnode
和newEndVnode
oldStartVnode
和oldEndVnode
newStartVnode
和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
算法,值得借鉴学习。