前言
snabbdom
是著名的虚拟DOM
库,是diff
算法的奠基者,Vue
也借用了它的思想- 因此,在学习
Vue
的虚拟DOM和diff算法
之前,先学习snabbdom
能加更容易理解其设计思想
snabbdom
- 安装
snabbdom
npm i -S snabbdom@2
webpack
环境配置webpack
安装
npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
-
目录:
-
webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
publicPath: 'xuni',
filename: 'bundle.js'
},
devServer: {
port: 8080,
contentBase: 'www'
}
}
package.json
...
"scripts": {
...
"dev": "webpack-dev-server"
}
...
www/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="/xuni/bundle.js"></script>
</body>
</html>
前置知识
-
虚拟DOM
-
diff:
diff
算法是发生在 新旧虚拟DOM
之上的,算出如何最小更新,最后反映到真实DOM
-
h函数: 用于生成虚拟结点,如下
import {h} from 'snabbdom/h'
let myVNode = h('a', {props: {href: 'http://www.baidu.com'}}, 'click me')
console.log(myVNode)
- patch函数: 让虚拟结点上树
import {init} from 'snabbdom/init'
import {classModule} from 'snabbdom/modules/class'
import {propsModule} from 'snabbdom/modules/props'
import {styleModule} from 'snabbdom/modules/style'
import {eventListenersModule} from 'snabbdom/modules/eventlisteners'
import {h} from 'snabbdom/h'
// 生成 patch 函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule])
let myVNode = h('a', {props: {href: 'http://www.baidu.com'}}, 'click me')
const container = document.getElementById('container')
// 将虚拟结点渲染到容器中
patch(container, myVNode)
手写简单源码
-
目录结构:
-
h 函数:用于生成
虚拟DOM
,是对vnode
函数的封装 -
需知:为了简单,不允许虚拟节点同时存在
text
和children
import vnode from './vnode'
// c 要么是text 要么是 children
export default function h (sel, data, c) {
// 如果传入的 c 是 string 或 number ,则算作 虚拟节点的 text
if (typeof c == 'string' || typeof c == 'number') {
return vnode(sel, data, undefined, c, undefined)
}
// 如果传入的 c 是 array,则算作 虚拟节点的 children
else if (Array.isArray(c)) {
return vnode (sel, data, c, undefined, undefined)
}
// 如果传入的 c 是 vnode,则将其他包装成数组,再算作虚拟节点的 children
else if ( typeof c == 'object' && c.hasOwnProperty('sel')) {
return vnode (sel, data, [c], undefined, undefined)
}
}
- vnode 函数:功能简明,即将传入的参数封装成对象
// elm 为 vnode 对应的 rnode (真实节点)
export default function (sel, data, children, text, elm) {
const {key} = data
return {sel, data, children, text, elm, key}
}
- 有了以上两个函数,就可以创建
虚拟DOM
了,如下
- 创建出
虚拟DOM
之后,下一步是如何利用虚拟DOM
生成真实DOM
- createElement 函数:
// 创建真实DOM
export default function createElement (vnode) {
let DOMNode = document.createElement(vnode.sel)
let {children=[]} = vnode
vnode.elm = DOMNode
// 如果 该结点中只有文字
if (vnode.text != '' && children.length == 0) {
DOMNode.innerText = vnode.text
}
// 如果该结点中只有孩子
else if (children.length) {
// 它内部是子节点,就要递归创建节点
for (let i = 0; i < children.length; i++) {
// 得到当前这个children
let ch = children[i];
// 创建出它的DOM,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点。
let chDOM = createElement(ch);
ch.elm = chDOM
// 上树
DOMNode.appendChild(chDOM);
}
}
return vnode.elm
}
-
现在可以创建出
真实DOM
了,下一步是:当虚拟DOM
更新时,如何最小量更新真实DOM
-
patch函数:
import vnode from './vnode'
import createElement from './createElement'
import patchVnode from './patchVnode'
export default function patch (oldVnode, newVnode) {
// 如果 oldVnode 是 DOM 结点,则先转换成 Vnode
if (oldVnode.sel == '' || oldVnode.sel == undefined) {
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
// 如果 新旧节点的 key 跟 sel 都相同,那么再进行细致比较
if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
patchVnode(oldVnode, newVnode)
}
// 否则,直接创建新结点,替换旧结点
else {
let newVnodeElm = createElement(newVnode)
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
-
如果
选择器sel
跟key
有一个不匹配,则认为是不同的节点,需要重新创建真实DOM
-
而如果
选择器 sel
跟key
完全匹配,则认为是可能相同的节点,需要使用patchVnode
进行细致比较,尽可能的进行复用 -
patchVnode 函数
import createElement from "./createElement"
import updateChildren from './updateChildren'
export default function patchVnode (oldVnode, newVnode) {
let oldChildren = oldVnode.children || []
let newChildren = newVnode.children || []
// 如果新旧Vnode是堆中同一对象
if (oldVnode === newVnode) return
// 新节点仅有 text
if (newVnode.text && !newChildren.length) {
// text 不相等,则用新的替换旧的
if (newVnode.text != oldVnode.text) {
oldVnode.elm.innerText = newVnode.text
}
}
// 新节点仅有 children
else {
// 旧节点仅有 children
if (oldChildren.length) {
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
}
// 旧节点没有children (可能有 text)
else {
oldVnode.elm.innerText = ''
for (let i=0; i<newChildren.length; i++) {
let childDOM = createElement(newChildren[i])
newChildren[i].elm = childDOM
oldVnode.elm.appendChild(childDOM)
}
}
}
}
- 上面最复杂的情况是:新旧节点都有children,因为这时,我们需要比较哪些
真实DOM
是可以复用的,借助另一个函数来处理:updateChildren
- updateChildren 函数
import createElement from "./createElement"
import patchVnode from "./patchVnode"
function checkSameVnode (a, b) {
return a.sel === b.sel && a.key === b.key
}
export default function updateChildren (parentElm, oldCh, newCh) {
// 旧前
let oldStartIdx = 0
// 新前
let newStartIdx = 0
// 旧后
let oldEndIdx = oldCh.length - 1
// 新后
let newEndIdx = newCh.length -1
// 旧前节点
let oldStartVnode = oldCh[0]
// 旧后节点
let oldEndVnode = oldCh[oldEndIdx]
// 新前节点
let newStartVnode = newCh[0]
// 新后节点
let newEndVnode = newCh[newEndIdx]
// 记录 oldVnode 的 key - index
let keyMap = null
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (!newStartVnode) {
newStartVnode = newCh[++newStartIdx]
} else if (!newEndVnode) {
newEndVnode = newCh[--newEndIdx]
}
if (checkSameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (!keyMap) {
keyMap = {}
for (let i=oldStartIdx; i<=oldEndIdx; i++) {
let key = oldCh[i].key
keyMap[key] = i
}
const idxInOld = keyMap[newStartVnode.key]
// 新增节点
if (!idxInOld) {
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
}
// 需要移动
else {
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
// 需要新增
if (newStartIdx <= newEndIdx) {
for (let i=newStartIdx; i<=newEndIdx; i++) {
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx]?.elm)
}
}
// 需要删除
else if (oldStartIdx <= oldEndIdx) {
for (let i=oldStartIdx; i<=oldEndIdx; i++) {
if (oldCh[i].elm) {
oldCh[i].elm.remove()
oldCh[i].elm = undefined
}
}
}
}
updateChildren
使用了四个指针(索引),分别指向新前、新后,旧前、旧后- 然后依次判断:新前跟后前、新后跟旧后、新后跟旧前、新前跟旧后是否命中。
- 前两者命中,则需要使用
patchVnode
,进行精细比较,并将指针移动。 - 后两者命中,也需要使用
patchVnode
,进行精细比较,并将指针移动,同时还需要移动真实DOM
- 如果四种都不命中,则建立
旧虚拟DOM
中,key
跟index
的map
- 然后从新前开始,判断新前是否在
旧的虚拟DOM
中,如果不在,则创建新的并插入,如果在,则进行移动