导读
今天分享一下学习的内容: Virtual DOM 的实现原理
一、虚拟DOM
1、什么是虚拟DOM
Virtual DOM (虚拟DOM),是由普通的JS对象来描述DOM对象,因为不是真实的DOM对象,所以叫虚拟DOM
2、为什么使用虚拟DOM
手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升,为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题
为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,例如数据发生变化时,无法获取上一次的状态,只能删除重新创建。于是Virtual DOM 出现了
Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述 DOM,
Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM
虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
虚拟DOM的作用
-
维护视图和状态的关系:虚拟DOM可以记录上一次DOM的状态变化,只更新变化的位置
-
复杂视图情况下提升渲染性能:与传统DOM相比,在性能上虚拟DOM的性能更好
-
除了渲染DOM之外,还可以实现SSR服务端渲染(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等
常用虚拟DOM库
常用的虚拟DOM库有Snabbdom、virtual-dom
Snabbdom
由于vue2版本的虚拟DOM是基于Snabbdom来实现的,所以下面我们来学习一下Snabbdom。首先定义一个html文件
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>snabbdom-demo</title>
</head>
<body>
<div id="app"></div>
<script src="./src/basicusage.js"></script>
</body>
</html>
导入Snabbdom
在官方文档中,Snabbdom是通过commonjs规范导入,这里我们通过import的方式去导入,注意:这里我们的snabbdom的版本是0.7.4,新版本通过import导入会出现Cannot resolve dependency 'snabbdom'这种问题,这里通过0.7.4版本进行演示和学习
// 导入
// import snabbdom from 'snabbdom'
import { h, thunk, init } from 'snabbdom'
可以看到,通过import导入snabbdom,不能直接通过第一种方式导入,这是因为在snabbdom源码中,没有通过export default的方式导出对象,所以只能通过大括号的形式导出。Snabbdom 的核心仅提供最基本的功能,只导出了三个函数 init()、h()、thunk()
init() 是一个高阶函数,返回 patch();
h() 返回虚拟节点 VNode,这种方式在vue中很常见,之前的响应式原理中也使用过h函数;
thunk() 是一种优化策略,可以在处理不可变数据时使用
snabbdom的使用
import { h, thunk, init } from 'snabbdom'
// 1. hello world
// 参数:数组,模块
// 返回值: patch函数,对比两个vnode的差异更新到真实DOM中
let patch = init([])
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串就是标签中的内容
let vnode = h('div#container.cls', 'Hello World')
let app = document.querySelector('#app')
// 第一个参数:可以是DOM元素,内部会把DOM元素转换成vnode
// 第二个参数:vnode
// 返回值:VNode
let oldVnode = patch(app, vnode)
这里演示第一个案例,在这个案例,首先他们通过snabbdom中的init函数初始化了patch函数,随后通过h函数创建了一个id为container,class为cls的div元素,这个元素为创建的vnode,内容为Hello World,并且获取到了#app的dom元素,随后通过初始化的patch函数对两个vnode进行对比,patch中的第一个参数为Dom,patch函数会自动将DOM元素转换为vnode,并更新到视图上。通过这个案例,可以在index.html中发现#app的位置,内容已经更改为Hello World
这里我们假设一个情景,我们在某一时刻需要从服务器中获取数据,随后传入到页面中进行展示
// 假设的时刻
vnode = h('div', 'Hello Snabbdom')
patch(oldVnode, vnode)
这里我们对上一次patch函数处理过后的vnode进行了保存,保存到了oldVnode中,随后将原vnode的内容通过h函数进行修改,再通过patch函数进行比较,并将数据更新到视图上。
上述案例是创建了一个div节点,当我们想要创建的虚拟dom节点中有子节点,我们需要这么做
import { h, thunk, init } from 'snabbdom'
// 2. div中放置子元素 h1,p
let patch = init([])
let vnode = h('div#container', [
h('h1', 'Hello Snabbdom'),
h('p', 'Hello P')
])
let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)
setTimeout(() => {
// 清空页面元素
// patch(oldVnode, null)
patch(oldVnode, h('!'))
}, 2000)
这里主要区别在于,创建vnode时,h函数的第二个参数为一个数组,数组中为所有子节点及其内容,同时我们通过patch函数对其进行对比并渲染在页面中。当我们想要对页面中指定位置的内容进行清空,可以通过将patch函数中的第二个参数设置为h('!'),通过h函数设置注释节点,更改对应的内容。(官网中通过传null为第二个参数,这个做法是错误的,页面会报错)
snabbdom 模块
snabbdom中提供了6个常用的模块
当然我们可以自己拓展自己想要的模块
模块的使用
模块的使用步骤:
1.导入需要的模块
2.init()函数中注册模块
3.使用h函数创建VNode时,可以把第二个参数设置为对象,其余参数往后挪
import { h, thunk, init } from 'snabbdom'
// 导入模块
import style from 'snabbdom/modules/style'
import eventlisteners from 'snabbdom/modules/eventlisteners'
// 注册模块
let patch = init([
style,
eventlisteners
])
// 使用h函数的第二个参数传入模块需要的数据
let vnode = h('div', {
style: {
backgroundColor: 'red'
},
on: {
click: eventHandler
}
}, [
h('h1', 'Hello Snabbdom'),
h('p', 'Hello P')
])
let app = document.querySelector('#app')
patch(app, vnode)
function eventHandler() {
console.log('点击我了!~')
}
这里我们通过import导入模块,模块的位置在snabbdom/modules中,随后通过init()函数,在数组中传入导入的模块,实现对模块的注册,随后在h函数中,将第二个参数传入模块需要的数据,这里传入了style和on,这两个数据都为对象,最后通过patch进行两个vnode的对比并渲染页面,页面效果如下
snabbdom 源码分析
snabbdom的核心是使用h函数创建VNode,通过init设置模块并创建patch,利用patch函数比较两个vnode,并将内容更新到dom中,所以我们主要分析一下这三个ts文件
h函数
最开始我们在vue中见过h函数,不过相对于snabbdom,vue对h函数实现了组件的机制
new Vue({
router,
store,
render:h => h(App)
}).$mount('#app')
h函数最早见于hyperscript,使用javascript创建超文本
在snabbdom中,h函数不是用来创建超文本,而是创建VNode
函数重载
javascript是不支持函数的重载的,而typescript中有重载
我们来看一下源码,这里我将源码进行了注释,方便理解
import {vnode, VNode, VNodeData} from './vnode';
export type VNodes = Array<VNode>;
export type VNodeChildElement = VNode | string | number | undefined | null;
export type ArrayOrElement<T> = T | T[];
export type VNodeChildren = ArrayOrElement<VNodeChildElement>
import * as is from './is';
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) {
let 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): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}, children: any, text: any, i: number;
// 三个参数的情况
if (c !== undefined) {
data = b;
// array 说明有子元素(子节点)
if (is.array(c)) { children = c; }
// string or number
else if (is.primitive(c)) { text = c; }
// vnode
else if (c && c.sel) { children = [c]; }
}
// 两个参数
else if (b !== undefined) {
// array 说明有子元素(子节点)
if (is.array(b)) { children = b; }
// string or number
else if (is.primitive(b)) { text = b; }
// vnode
else if (b && b.sel) { children = [b]; }
else { data = b; }
}
if (children !== undefined) {
// 处理children中的原始值(string/number)
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] === '#')
) {
// svg 添加命名空间
addNS(data, children, sel);
}
// 返回VNode
return vnode(sel, data, children, text, undefined);
};
export default h;
源码中通过函数重载的方式,定义了参数不同的同名函数,在下面的函数实现中,首先对第三个参数进行判断,如果存在,说明传入了三个参数,将第二个参数b传入data中,随后对c进行判断,判断c的类型为数组、字符串、数字或者vnode,并根据判断结果将对应的children或者text进行赋值。若第三个参数c不存在,判断b是否存在,若存在,按照判断c的方式进行判断,在最后,判断children是否为空,并处理children中的原始值合兵创建虚拟文本节点,下面对svg进行判断,并添加命名空间,addNS方法在上面,通过循环调用addNS方法,实现对svg中的子节点添加命名空间,随后通过vnode函数返回
vnode函数
在h函数的最后调用了vnode方法,我们来看一下vnode方法
import {Hooks} from './hooks';
import {AttachData} from './helpers/attachto'
import {VNodeStyle} from './modules/style'
import {On} from './modules/eventlisteners'
import {Attrs} from './modules/attributes'
import {Classes} from './modules/class'
import {Props} from './modules/props'
import {Dataset} from './modules/dataset'
import {Hero} from './modules/hero'
export type Key = string | number;
export interface VNode {
// 选择器
sel: string | undefined;
// 节点数据:属性/样式/事件
data: VNodeData | undefined;
// 子节点,与text互斥
children: Array<VNode | string> | undefined;
// 记录vnode对应的真实Dom
elm: Node | undefined;
// 节点内容,和children互斥
text: string | undefined;
// 优化
key: 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?: Array<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 {
let key = data === undefined ? undefined : data.key;
return {sel, data, children, text, elm, key};
}
export default vnode;
在这个方法中引用了对应的模块文件,随后,定义了两个接口,VNode和VNodeData。VNode是用来约束对象拥有相同的属性,包括选择器、节点数据、子节点、VNode对应的真实DOM、节点内容和优化的key,VNodeData是模块中需要的相关参数,在下面的vnode方法中,传入了5个参数,最后一个参数key通过data来声明,最后通过对象的方式返回这6个参数值
init函数
由于patch函数是通过init函数来创建的,我们首先看一下init函数
在init函数中,传入了两个参数,第二个参数可选,用于转换虚拟节点的api,首先对 module 中的 hook 进行收集,保存到 cbs 中,这里的cbs为模块中对应的钩子函数,随后在内部定义了一些辅助函数,在最后返回一个patch函数,两个参数为旧节点和新节点。
patch函数
由于我们学习的版本为0.7.4,所以patch函数定义在snabbdom.ts中,高版本的有可能在init.ts中
patch函数是用来对比两个vnode节点之间的诧异,对比流程如下:
对比新旧Vnode是否是相同节点(节点的key和sel相同)
如果不是相同节点,删除之前的内容重新渲染
如果是相同节点,再判断新的Vnode是否有text,如果有并且和oldVnode的text不同,则直接拿新Vnode的text替换掉原有的text
如果新的VNode有children,判断子节点否发生变化,判断子节点的过程使用的就是diff算法
diff算法只进行同级比较
下面我们来看一下patch函数
// init内部返回patch函数,把vnode渲染成真实dom,并返回vnode
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)) {
oldVnode = emptyNodeAt(oldVnode);
}
// 如果新旧节点是相同节点(key 和 sel 相同)
if (sameVnode(oldVnode, vnode)) {
// 找节点的差异并更新DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 若新旧节点不同,创建对应的DOM
// 获取当前的DOM元素
elm = oldVnode.elm as Node;
parent = api.parentNode(elm);
// 创建 vnode 对应的DOM 元素,并触发 init/create 钩子函数
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 父节点不为空,把 vnode 对应的dom插入到文档中
api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
// 移除旧节点
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 执行用户设置的insert钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
}
// 执行模块的post钩子函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
function emptyNodeAt(elm: Element) {
const id = elm.id ? '#' + elm.id : '';
const c = elm.className ? '.' + elm.className.split(' ').join('.') : '';
return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);
}
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
patch为init函数返回的,在patch函数中,首先会调用 module 的 pre hook,然后会判断传入的第一个参数是否为 vnode 类型,如果不是,会调用 emptyNodeAt 然后将其转换成一个 vnode,emptyNodeAt 的具体实现也很简单,注意这里只是保留了 class 和 style,这个和 toVnode 的实现有些区别,因为这里并不需要保存很多信息,比如 prop attribute 等。接着调用 sameVnode 来判断是否为相同的 vnode 节点,具体实现也很简单,这里只是判断了 key 和 sel 是否相同。如果相同,调用 patchVnode,如果不相同,会调用 createElm 来创建一个新的 dom 节点,然后如果存在父节点,便将其插入到 dom 上,然后移除旧的 dom 节点来完成更新。最后调用元素上的 insert hook 和 module 上的 post hook。
这里的重点是 patchVnode 和 createElm 函数,我们先看 createElm 函数,看看是如何来创建 dom 节点的。
createElm 函数
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any, data = vnode.data;
if (data !== undefined) {
// 执行用户设置的init钩子函数
if (isDef(i = data.hook) && isDef(i = i.init)) {
i(vnode);
data = vnode.data;
}
}
// 把 vnode 转换成真实DOM对象(没渲染到页面)
let children = vnode.children, sel = vnode.sel;
if (sel === '!') {
// !创建注释节点
if (isUndef(vnode.text)) {
vnode.text = '';
}
vnode.elm = api.createComment(vnode.text as string);
} 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 as VNodeData).ns) ? api.createElementNS(i, tag)
: api.createElement(tag);
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
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中有子节点,创建子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));
}
i = (vnode.data as VNodeData).hook; // Reuse variable
if (isDef(i)) {
// 执行用户传入的钩子 create
if (i.create) i.create(emptyNode, vnode);
// 把vnode添加到队列中,为执行insert钩子做准备
if (i.insert) insertedVnodeQueue.push(vnode);
}
} else {
vnode.elm = api.createTextNode(vnode.text as string);
}
// 返回新创建的DOM
return vnode.elm;
}
根据思维导图和代码可以看出,首先会调用元素的 init hook,接着这里会存在三种情况:
如果当前元素是注释节点,会调用 createComment 来创建一个注释节点,然后挂载到 vnode.elm
如果不存在选择器,只是单纯的文本,调用 createTextNode 来创建文本,然后挂载到 vnode.elm
如果存在选择器,会对这个选择器做解析,得到 tag、id 和 class,然后调用 createElement 或 createElementNS 来生成节点,并挂载到 vnode.elm。接着调用 module 上的 create hook,如果存在 children,遍历所有子节点并递归调用 createElm 创建 dom,通过 appendChild 挂载到当前的 elm 上,不存在 children 但存在 text,便使用 createTextNode 来创建文本。最后调用调用元素上的 create hook 和保存存在 insert hook 的 vnode,因为 insert hook 需要等 dom 真正挂载到 document 上才会调用,这里用个数组来保存可以避免真正需要调用时需要对 vnode树做遍历。
addVnodes 和 removeVnodes
function addVnodes(parentElm: Node, before: Node | null, vnodes: Array<VNode>, startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (ch != null) {
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
}
}
}
function removeVnodes(parentElm: Node, vnodes: Array<VNode>, startIdx: number, endIdx: number): void {
for (; startIdx <= endIdx; ++startIdx) {
let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
if (ch != null) {
if (isDef(ch.sel)) {
// 调用 destory hook
invokeDestroyHook(ch);
// 计算需要调用 removecallback 的次数 只有全部调用了才会移除 dom
listeners = cbs.remove.length + 1;
rm = createRmCb(ch.elm as Node, listeners);
// 调用 module 中是 remove hook
for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
// 调用 vnode 的 remove hook
if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
i(ch, rm);
} else {
rm();
}
} else { // Text node
api.removeChild(parentElm, ch.elm as Node);
}
}
}
}
// 调用 destory hook
// 如果存在 children 递归调用
function invokeDestroyHook(vnode: VNode) {
let i: any, j: number, data = vnode.data;
if (data !== undefined) {
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
if (vnode.children !== undefined) {
for (j = 0; j < vnode.children.length; ++j) {
i = vnode.children[j];
if (i != null && typeof i !== "string") {
invokeDestroyHook(i);
}
}
}
}
}
// 只有当所有的 remove hook 都调用了 remove callback 才会移除 dom
function createRmCb(childElm: Node, listeners: number) {
return function rmCb() {
if (--listeners === 0) {
const parent = api.parentNode(childElm);
api.removeChild(parent, childElm);
}
};
}
这两个函数主要用来添加 vnode 和移除 vnode
patchVnode
这个函数做的事情是对传入的两个 vnode 做 diff,如果存在更新,将其反馈到 dom 上。
首先调用 vnode 上的 prepatch hook,如果当前的两个 vnode 完全相同,直接返回。接着调用 module 和 vnode 上的 update hook。然后会分为以下几种情况做处理:
均存在 children 且不相同,调用 updateChildren
新 vnode 存在 children,旧 vnode 不存在 children,如果旧 vnode 存在 text 先清空,然后调用 addVnodes
新 vnode 不存在 children,旧 vnode 存在 children,调用 removeVnodes 移除 children
均不存在 children,新 vnode不存在text,移除旧 vnode的text均存在text,更新 text`
updateChildren
function updateChildren(parentElm: Node, oldCh: Array<VNode>, newCh: Array<VNode>, insertedVnodeQueue: VNodeQueue) {
let oldStartIdx = 0,
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: any;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
// 遍历 oldCh newCh,对节点进行比较和更新
// 每轮比较最多处理一个节点,算法复杂度 O(n)
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 如果进行比较的 4 个节点中存在空节点,为空的节点下标向中间推进,继续下个循环
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];
// 新旧开始节点相同,直接调用 patchVnode 进行更新,下标向中间推进
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
// 新旧结束节点相同,逻辑同上
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
// 旧开始节点等于新的节点节点,说明节点向右移动了,调用 patchVnode 进行更新
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
// 旧开始节点等于新的结束节点,说明节点向右移动了
// 具体移动到哪,因为新节点处于末尾,所以添加到旧结束节点(会随着 updateChildren 左移)的后面
// 注意这里需要移动 dom,因为节点右移了,而为什么是插入 oldEndVnode 的后面呢?
// 可以分为两个情况来理解:
// 1. 当循环刚开始,下标都还没有移动,那移动到 oldEndVnode 的后面就相当于是最后面,是合理的
// 2. 循环已经执行过一部分了,因为每次比较结束后,下标都会向中间靠拢,而且每次都会处理一个节点,
// 这时下标左右两边已经处理完成,可以把下标开始到结束区域当成是并未开始循环的一个整体,
// 所以插入到 oldEndVnode 后面是合理的(在当前循环来说,也相当于是最后面,同 1)
api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
// 旧的结束节点等于新的开始节点,说明节点是向左移动了,逻辑同上
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
// 如果以上 4 种情况都不匹配,可能存在下面 2 种情况
// 1. 这个节点是新创建的
// 2. 这个节点在原来的位置是处于中间的(oldStartIdx 和 endStartIdx之间)
} else {
// 如果 oldKeyToIdx 不存在,创建 key 到 index 的映射
// 而且也存在各种细微的优化,只会创建一次,并且已经完成的部分不需要映射
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 拿到在 oldCh 下对应的下标
idxInOld = oldKeyToIdx[newStartVnode.key as string];
// 如果下标不存在,说明这个节点是新创建的
if (isUndef(idxInOld)) {
// New element
// 插入到 oldStartVnode 的前面(对于当前循环来说,相当于最前面)
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
newStartVnode = newCh[++newStartIdx];
} else {
// 如果是已经存在的节点 找到需要移动位置的节点
elmToMove = oldCh[idxInOld];
// 虽然 key 相同了,但是 seletor 不相同,需要调用 createElm 来创建新的 dom 节点
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
} else {
// 否则调用 patchVnode 对旧 vnode 做更新
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
// 在 oldCh 中将当前已经处理的 vnode 置空,等下次循环到这个下标的时候直接跳过
oldCh[idxInOld] = undefined as any;
// 插入到 oldStartVnode 的前面(对于当前循环来说,相当于最前面)
api.insertBefore(parentElm, elmToMove.elm as Node, oldStartVnode.elm as Node);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
// 循环结束后,可能会存在两种情况
// 1. oldCh 已经全部处理完成,而 newCh 还有新的节点,需要对剩下的每个项都创建新的 dom
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);
// 2. newCh 已经全部处理完成,而 oldCh 还有旧的节点,需要将多余的节点移除
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
在updateChildren中,实现了diff算法,这里我们主要来分析一下diff算法
由于在dom操作的时候我们很少会吧一个父节点移动或更新到某一个子节点中,所以我们只需要找同级别的节点进行比较,这样在时间复杂度上会优化很多
在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置索引,遍历过程中移动索引
在开始和结束节点比较时,会出现4中情况
- oldStartVnode / newStartVnode (旧开始节点/新开始节点)
- oldEndVnode / newEndVnode (旧结束节点/新结束节点)
- oldStartVnode / oldEndVnode (旧开始节点/新结束节点)
- oldEndVnode / newStartVnode (旧结束节点/新开始节点)
在 oldStartVnode 与 newStartVnode 进行比较时,会调用sameVnode 判断是否是相同节点,也就是比较key和sel,相同的话,直接调用patchVnode对比内部的差异,更新到dom中,随即移动索引比较下一个节点;如果下一个节点不相同,会比较oldEndVnode和newEndVnode,同样调用sameVnode 判断是否是相同节点相同的话,调用patchVnode对比内部的差异,更新到dom中,随即向前移动索引比较上一个节点。
在oldStartVnode与oldEndVnode进行比较时,同样调用sameVnode 判断是否是相同节点,相同的话,将旧开始节点移动到最后面的位置上,并将旧节点的索引向后移动,新节点的索引向前移动,继续对比
在oldEndVnode与newStartVnode进行比较时,同样调用sameVnode 判断是否是相同节点,相同的话,将旧开始节点移动到最前面的位置上,并将旧节点的索引向前移动,新节点的索引向后移动,继续对比
若以上四种情况都不满足:
使用newStartVnode在旧节点中寻找相同key值的节点,若没有找到,说明是新的节点,创建对应DOM元素并插入到节点数组最前面,若找到了相同key的节点,需要判断是否是相同的sel选择器,若sel不相同,说明还是新的节点,创建对应DOM元素并插入到节点数组最前面,若找到了相同key的节点,并且sel也相同,那么直接将该节点移动到数组最前面
最后,判断一下旧节点和新节点的个数,如果新节点的个数 > 旧节点的个数,说明剩余未遍历的节点为新节点,插入到节点数组最后
若新节点的个数 < 旧节点的个数,说明剩余未遍历的节点为需要删除的节点,执行删除
以上就是diff算法的原理,同样也是updateChildren的实现原理。
结语
以上就是我对snabbdom的学习理解,如果有不对的地方欢迎大家指出,谢谢