vue源码解析——diff算法
文章目录
- vue渲染过程
- 虚拟DOM
- 介绍
- 作用
- 优缺点
- 虚拟节点VNode
- diff
- h函数
- patch函数
- 完整代码
vue渲染过程
初始状态:
[真实DOM] [VDOM]
<div> { tag: 'div',<h1>Vue</h1> children: [<button>Toggle</button> { tag: 'h1', text: 'Vue' },
</div> { tag: 'button', text: 'Toggle' }]}数据变更后(showButton = false):
1. 生成新VDOM:
{ tag: 'div',children: [{ tag: 'h1', text: 'Vue' },null // 按钮节点被移除]
}2. 对比新旧VDOM:
- 发现button节点从存在 → 不存在3. 更新真实DOM:
[真实DOM]
<div><h1>Vue</h1><!-- 按钮被移除 -->
</div>
步骤解析:
模版编译:
- 词法分析:将模板字符串拆解为 标签、属性、文本节点 等 token(如
<div>Hello {{name}}</div>
拆分为<div>
、Hello
、{{name}}
、</div>
)。 - 语法分析:根据 token 生成 抽象语法树(AST),描述模板的嵌套结构和逻辑(如指令
v-if
、插值表达式{{}}
等)。 - 优化:标记模板中的 静态节点(如
<div>标题</div>
),避免后续重新渲染时重复计算。 - 代码生成:将 AST 转换为
render
函数的代码字符串(如_c('div', [_v('Hello ' + _s(name))])
),最终通过new Function
编译为可执行函数。
进行渲染
通过 render
函数生成 虚拟 DOM(VDOM),即 JavaScript 对象描述真实 DOM 的结构。
// 模板:<div class="box">{{ message }}</div>
const render = () => _c('div', { attrs: { class: 'box' } }, [_v(_s(message))]);
const vnode = render(); // 生成 VDOM 对象
//_c:创建元素节点的函数(对应真实 DOM 的 createElement)。
//_v:创建文本节点的函数(对应真实 DOM 的 createTextNode)。
//_s:将数据转为字符串(处理插值表达式)。
diff算法
当响应式数据(如 data.message
)变化时,触发视图更新。
- 依赖收集:在首次渲染
{{ message }}
时,自动追踪message
的依赖关系。 - 数据变更触发更新:当
message
改变时,通知相关组件重新执行render
函数,生成 新 VDOM。 - diff 对比:通过 快速 diff 算法 对比新旧 VDOM,找出需要更新的节点(如文本内容变化、属性修改、节点增删等)。
真实DOM更新
根据 diff 结果,仅更新真实 DOM 中变化的部分,减少浏览器操作开销。
在上述过程中,虚拟DOM和diff算法有着关键作用,下面就着重介绍一下。
虚拟DOM
介绍
Virtual DOM 是一棵以 JavaScript 对象作为基础的树,每一个节点称为 VNode ,用对象属性来描述节点,对象内最少包含标签名 (tag
)、属性 (attrs
) 和子元素对象 (children
) 三个属性,实际上它是一层对真实 DOM 的抽象,最终可以通过渲染操作使这棵树映射到真实环境上。
简单来说,虚拟dom就是一个js对象。
真实的dom
<div id="app"><p id="p">节点内容</p><h3>{{ foo }}</h3>
</div>
虚拟dom表现形式
{tag: 'div'attributes: { id: 'app' }children: [{tag: 'div'attributes: { id: 'p' },text:'节点内容'},......]
};
作用
为什么要使用虚拟dom?因为DOM
是很慢的,其元素非常庞大,页面的性能问题,大部分都是由DOM
操作引起的,真实的DOM
节点,哪怕一个最简单的div
也包含着很多属性,操作DOM
的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户的体验。
而且虚拟dom渲染是批量渲染的。
当你在一次操作时,需要更新10个DOM
节点,浏览器没这么智能,收到第一个更新DOM
请求后,并不知道后续还有9次更新操作,因此会马上执行流程,最终执行10次流程。
而通过VNode
,同样更新10个DOM
节点,虚拟DOM
不会立即操作DOM
,而是将这10次更新的diff
内容保存到本地的一个js
对象中,最终将这个js
对象一次性attach
到DOM
树上,避免大量的无谓计算。
虚拟DOM在Vue.js主要做了两件事:
- 提供与真实DOM节点所对应的虚拟节点vnode
- 将虚拟节点vnode和旧虚拟节点oldVnode进行对比,然后更新视图
优缺点
优点:
-
跨平台:由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。
很多人认为虚拟 DOM 最大的优势是 diff 算法,减少 JavaScript 操作真实 DOM 的带来的性能消耗。虽然这一个虚拟 DOM 带来的一个优势,但并不是全部。虚拟 DOM 最大的优势在于抽象了原本的渲染过程,实现了跨平台的能力,而不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,可以是近期很火热的小程序,也可以是各种GUI
-
快速的渲染: Vue和其他的前端框架一样,使用了Diff算法来更新DOM。虚拟DOM允许Vue在内存中追踪DOM的状态。在需要更新视图时,Vue会先进行虚拟DOM的比对,然后只应用必要的更改,这比每次都重新渲染整个视图要高效得多。
-
提升渲染性能 Virtual DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新,虚拟DOM就是为了解决浏览器性能问题而被设计出来的。
-
易于集成: 虚拟DOM允许Vue与其他库和现有的项目更容易地集成,因为它不依赖于特定的DOM实现。
-
便捷的测试: 由于虚拟DOM,Vue使得单元测试变得更加容易,因为你可以在没有实际DOM的情况下进行测试。
-
社区和支持: Vue拥有一个庞大的社区和丰富的生态系统,其中包括了大量的插件和库,可以帮助开发者更快地构建应用。
缺点:
- 初次渲染更慢:由于需要将虚拟DOM转换为实际DOM,因此在第一次渲染页面时,可能会略微慢一些。
- 不支持一些高级特性:由于虚拟DOM是一个抽象层,它可能不支持某些DOM特性,例如双向数据绑定的输入框(需要特定的处理)。
- 更多内存使用:虚拟DOM需要额外的内存来存储虚拟节点树,这可能会稍微增加页面加载时的内存使用。
- 不适用于某些应用:如果你的应用需要进行大量DOM操作,或者需要直接访问DOM节点(例如,使用第三方DOM库),那么Vue的虚拟DOM可能不适合你的应用。
- 可能的性能问题:在某些极端情况下,虚拟DOM可能会导致性能问题,例如在大型或复杂的列表中,虚拟DOM的diff算法可能会变得低效。
虚拟节点VNode
VNode主要用于diff算法中的比较,通过比较新旧VNode来进行增删改查。
- vnode只是一个名字,本质上其实是Javascript中一个普通的对象
- vnode可以理解成节点描述对象,它描述了应该怎样去创建真实的DOM节点。例如tag表示一个元素节点的名称,text表示一个文本节点的文本,children表示子节点等。
- vnode表示一个真实的DOM元素,所有真实的DOM节点都使用vnode创建并插入到页面中。(vnode–>DOM–>视图)
和虚拟dom的关系:
虚拟 DOM (Virtual DOM)
┌─────────────────────────────────────────────┐
│ 虚拟 DOM 树 (JavaScript 对象) │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ VNode 对象 (根节点) │ │
│ │ │ │
│ │ { │ │
│ │ tag: 'div', │ │
│ │ props: { class: 'container' }, │ │
│ │ children: [ │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ 子 VNode │ │ │
│ │ │ { tag: 'h1', ... } │ │ │
│ │ └─────────────────────┘ │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ 子 VNode │ │ │
│ │ │ { tag: 'p', ... } │ │ │
│ │ └─────────────────────┘ │ │
│ │ ] │ │
│ │ } │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
属性含义
tag: 当前节点的标签名
data: 当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息
children: 当前节点的子节点,是一个数组
text: 当前节点的文本
elm: 当前虚拟节点对应的真实dom节点
ns: 当前节点的名字空间
context: 当前节点的编译作用域
functionalContext: 函数化组件作用域
key: 节点的key属性,被当作节点的标志,用以优化
componentOptions: 组件的option选项
componentInstance: 当前节点对应的组件的实例
parent: 当前节点的父节点
raw: 简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false
isStatic: 是否为静态节点
isRootInsert: 是否作为跟节点插入
isComment: 是否为注释节点
isCloned: 是否为克隆节点
isOnce: 是否有v-once指令
vNode是通过h函数产生的,h函数其实是调用的createElement来生成VNode
diff
著名的虚拟 DOM 库,是 diff 算法的鼻祖,vue 就借鉴了 snabbdom.
h函数
作用:生成虚拟节点vnode
调用 h 函数:h("a", { props: { href: "/foo" } }, "I'll take you places!")
得到的虚拟节点:
{ tag: "a", data: { props: { href: "/foo" } }, children: undefined, text: "I'll take you places!", elm: undefined, // 挂载到真实 DOM 树上了才有值key: undefined
}
//表示真实的DOM节点
//<a href="/foo">I'll take you places!</a>
简单实现
我们手写实现的 h 函数,将规定只能传递 3 个参数,并且如果第 3 个参数为数组,数组里的元素只能是 h 函数。但在源码中是很灵活的,可以传1-3个参数,第三个参数可以是文字、数字或者数组。
1.准备vnode函数
vnode 函数的功能很简单,就是把传入的值组合成一个对象返回,这个对象有 5 个属性( key 属性下面会讲):tag, data, children, text, elm,它们的值为传入的 tag, data, children, text, elm。
//把传入的值包装成对象返回
export default (tag, data, children, text, elm) => {const { key } = datareturn { tag, data, children, text, elm, key }
}
2.h函数
传入 h 函数的第 3 个参数,如果里面有 h 函数,其实是 h 函数的执行,执行结果是 vnode 函数的返回,是一个拥有 tag, data, children, text, elm 属性的对象:
import vnode from "./vnode";//tag是标签,data是key的对象
export default function h(tag, data, c) {if (arguments.length !== 3) {throw Error('请传入三个参数')}//如果第三个参数为字符串或者数字,则返回的值中,children为undefined,text就是c。if (typeof c === ('string' || 'number')) {return vnode(tag, data, undefined, c, undefined)} else if (Array.isArray(c) && c.length) {const children = []c.forEach(item => {//判断c中是否包含tag,是的话就是h函数if (!(typeof item === 'object' && item.hasOwnProperty('tag')))throw Error('传入的数组的元素不是h函数')//如果是h函数,就加入到children中children.push(item)})return vnode(tag, data, children, undefined, undefined)} else if (typeof c === 'object' && c.hasOwnProperty('tag')) {return vnode(tag, data, [c], undefined, undefined)} else {throw Error('第三个参数不正确')}
}
3.执行
在 index.js 文件引入 h 函数,并传递正确的参数
// index.js
import h from './h.js'
const vnode = h('div', {}, [h("div", {}, "子元素一"), h("div", {}, "子元素二")])
console.log(vnode)
patch函数
当组件创建和更新时,vue均会执行内部的update函数,该函数使用render函数生成的虚拟dom树,将新旧两树进行对比,找到差异点,最终更新到真实dom。
// vue构造函数
function Vue(){// ... 其他代码var updateComponent = () => {this._update(this._render())}new Watcher(updateComponent);// ... 其他代码
}
_update
函数接收的参数是新的虚拟dom树,并通过当前组件的_vnode
属性拿到旧的虚拟dom树,然后调用patch
函数对两颗树进行对比。
对比差异的过程叫diff,vue在内部通过一个叫patch的函数完成该过程,在对比时,vue采用深度优先、同层比较的方式进行比对。
diff的特点
-
如果是往数组的最后面添加节点,那么前面的节点不会改动
比如有如下新(vnode2) 旧(vnode1) 两个节点,那么执行patch(vnode1, vnode2)
会发现浏览器仅仅只是追加了一个节点<div>东风破</div>
, 不会改变前两个。 -
key 很重要,key 作为节点的标识,告诉 diff 算法在更改前后节点是否为同一个
如果是往数组的开头添加节点,则所有的节点都会被改动,想要做到最小化更新,需要给每个节点添加 key 属性,这样<div>七里香</div>
和<div>东风破</div>
两个节点就不会被改动了:const vnode1 = h("div", {}, [h("div", { key: 1 }, "七里香"),h("div", { key: 2 }, "东风破") ]) const vnode2 = h("div", {}, [h("div", { key: 3 }, "兰亭序"),h("div", { key: 1 }, "七里香"),h("div", { key: 2 }, "东风破") ])
-
只有是同一个虚拟节点,才进行精细化比较,否则直接删除旧节点,插入新节点
判断两个节点是否为同一个,是根据比较选择器,也就是 tag 的值和 key 的值是否都相同,都相等则判断为同一个虚拟节点。 -
只进行同层比较
新旧节点的层级要相同,比如下面的例子里新节点比旧节点多了层 div,则不会进行精细化比较,直接删除旧节点插入新节点:实现patch函数
1.实现creatElement函数,用于创建节点
export default function createElemnt(vnode) {const domNode = document.createElement(vnode.tag)vnode.elm = domNode//判读vnode有子节点(children)还是textif (vnode.children !== undefined && vnode.children.length && vnode.text === undefined) {vnode.children.froEach(item => {//调用createElement意味着 创建出了Dom,并且将这个虚拟节点的elm属性指向了这个DOM//但是这个DOM还没上树//const childrenNode = createElemnt(item)item.elm = childrenNodedomNode.appendChildren(childrenNode)})} else {//内部是文本domNode.innText = vnode.textvnode.elm = domNode}return domNode
}
2.实现patch
// patch.js
import vnode from './vnode.js'
import creatElement from './creatElement.js'export default (oldVnode, newVnode) => {// 判断 oldVnode 是否为虚拟节点if (oldVnode.tag === undefined) {// oldVnode 不是虚拟节点,则包装成虚拟节点oldVnode = vnode(oldVnode.tagName.toLowerCase, {}, [], undefined, oldVnode)} // 判断 oldVnode, newVnode 是否为同一节点if (oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key) {// 同一节点} else {// 不是同一节点const domNode = creatElement(newVnode)// 将新节点上树oldVnode.elm.parentNode?.insertBefore(domNode, oldVnode.elm)// 删除旧节点oldVnode.elm.parentNode?.removeChild(oldVnode.elm)}
}
至此,我们已经完成了上面 patch 函数流程图中除了同一节点比较之外的内容。
同一节点涉及很多情况,下面展开来讲一讲。
同一节点比较
主要的问题在于新旧节点的 text 和 children 属性是否有值的 4(2x2) 种情况的处理,而当新节点 text 有值时,旧节点的 text 有值或 children 有值这 2 种情况的处理都是一样的,即直接给旧节点的真实 DOM 的 innerText 赋值,所以其实只有 3 种情况的处理:
- 新节点的 text 属性有值
- 直接 innerText 赋值,不用管旧节点的 text 和 children 属性是什么情况
- 新节点的 children 属性有值
- 旧节点的 text 属性有值
- 旧节点的 children 属性有值
对于这种情况,新建patchVnode函数处理
// patchVnode.js
import creatElement from './creatElement.js'
import updateChildren from './updateChildren.js'// 处理新旧虚拟节点为同一节点的情况
export default (oldVnode, newVnode) => {// oldVnode 和 newVnode 是否为内存中同一对象if (oldVnode === newVnode) return// newVnode 的 text 属性有没有值if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {// 有 text// 新旧虚拟节点的 text 的值是否一样if (newVnode.text !== oldVnode.text) {// oldVnode.elm 就是旧虚拟节点的真实 DOMoldVnode.elm.innerText = newVnode.text}} else {// 没有 text (说明 newVnode.children 有值 )// 判断旧节点是 children 有值还是 text 有值if (oldVnode.text !== undefined && (oldVnode.children === undefined || oldVnode.children.length === 0)) {// 旧节点 text 属性有值,新节点 children 属性有值oldVnode.elm.innerHTML = ''newVnode.children.forEach(item => {const newDom = creatElement(item)oldVnode.elm.appendChild(newDom)})} else {// 新旧节点都是 children 属性有值updateChildren(oldVnode.elm, oldVnode.children, newVnode.children) // 后面详细解释}}
}
在 patch.js 处理新旧为同一节点的地方引入 patchVnode.js:
if (oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key) {// 同一节点patchVnode(oldVnode, newVnode)
}
下面处理新旧节点均为 children 属性有值的情况。
我们在对比新旧节点的 children 里的每个子虚拟节点的时候,需要这些节点都定义了 key 属性,不然不定义都是 undefined 就都判断为同一节点了。所以首先来补充一下之前生成虚拟节点时忽略掉的 key 属性:
// vnode.js
export default (tag, data, children, text, elm) => {const { key } = datareturn { tag, data, children, text, elm, key }
}
对于这种情况,最重要的就是四种命中查找:
vue对每个子节点数组使用了两个指针,分别指向头尾,然后不断向中间靠拢来进行对比(双端对比),这样做的目的是尽量复用真实dom,尽量少的销毁和创建真实dom。如果发现相同,则进入和根节点一样的对比流程,如果发现不同,则移动真实dom到合适的位置。 这样一直递归的遍历下去,直到整棵树完成对比。
双端对比算法过程:
- 旧前与新前对比,如果相同则patch更新dom属性,然后新旧头指针递增
- 旧后与新后对比,如果相同则patch更新dom属性,然后新旧尾指针递减
- 旧前与新后对比,如果相同则移动真实DOM,将旧前dom移动到旧尾dom后面,然后patch更新dom属性,然后旧头指针递增,新尾指针递减
双端对比子节点数组的前提是父节点相同,已经复用了父节点DOM,所以这时新旧指针指向的虚拟DOM对应的真实DOM是相同的
- 旧后与新前对比,如果相同则移动真实DOM,将旧后dom移动到旧前dom前面,然后patch更新dom属性,然后旧尾指针递减,新头指针递增
- 如果上面四种都不相同,则在旧节点数组找与新前相同的节点:
- 找到了就将它与新前进行patch更新dom属性,然后将它的dom移动到旧前dom前面,并将它在数组中设置为undefined(防止下次又找到该节点)
- 找不到就新建一个节点
- 最后如果新节点数组有剩余,那就批量的新增,如果旧节点数组有剩余那就批量的删除。
用图示展示一下详细过程:
1. 新前与旧前
首先进行旧前和新前指针指向的节点是否为同一节点的判断,发现 tag 相同均为 li,key 相同均为 A,命中判断,不再进行之后的判断,由 patchVnode 函数处理 h(‘li’, { key: ‘A’}, ‘A’) 和 h(‘li’, { key: ‘A’}, ‘AAA’),然后让旧前指针下移一位改变指针所指的节点,新前指针也下移一位改变指针所指的节点:
然后发现旧前和新前指针指向的节点又是同一节点,就再各自下移一位。如此,直到旧前指针移到了旧后指针的下面或是新前指针移到了新后指针的下面,跳出 while 循环语句。
if (sameVnode(oldStartVnode, newStartVnode)) {patchVnode(oldStartVnode, newStartVnode)if(newStartVnode) newStartVnode.elm = oldStartVnode?.elm // 给新节点的 elm 赋值,后面有用 oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx]
}
2.新后与旧后
一开始是新前与旧前命中,然后指针各自下移:
此时新前和旧前指针指向的节点不是同一节点,则开始判断新后与旧后是否命中,发现命中,则新后与旧后各自上移一位改变指针所指节点,如下图:
此时新前 > 新后,跳出 while 循坏。
// 2. 新后与旧后
if (sameVnode(oldEndVnode, newEndVnode)) {patchVnode(oldEndVnode, newEndVnode)if(newEndVnode) newEndVnode.elm = oldEndVnode?.elmoldEndVnode = oldCh[--oldEndIdx]newEndVnode = newCh[--newEndIdx]
}
3.新后与旧前
先判断新前与旧前,不命中;再判断新后与旧后,不命中;再判断新后与旧前,发现命中,先进行 patchVnode 处理,再移动节点,把旧前指向的节点移动到旧后指向的节点的后面,然后旧前指针下移一位改变所指的节点,新后指针上移一位改变所指的节点:
此时又是新后与旧前命中,则新后继续上移,旧前继续下移:
以此类推,最终跳出 while 循环 。
// 3. 新后与旧前
if (sameVnode(oldStartVnode, newEndVnode)) {patchVnode(oldStartVnode, newEndVnode)if(newEndVnode) newEndVnode.elm = oldStartVnode?.elm// 把旧前指向的节点移动到旧后指向的节点的后面parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)oldStartVnode = oldCh[++oldStartIdx]newEndVnode = newCh[--newEndIdx]
}
注意这里用的是 insertBefore 而不是 appendChild,因为旧后指针指向的节点不一定是 parentElm 的最后一位(比如之前的操作已经有节点被移动到后面来了)。
4.新前与旧后
依次经过新前与旧前、新后与旧后、新后与旧前的判断都没命中,在新前与旧后命中,先进行 patchVnode 处理,再移动节点,将旧后指向的节点移动到旧前的前面,然后旧后指针上移一位,新前指针下移一位:
// 新前与旧后
if (sameVnode(oldEndVnode, newStartVnode)) {patchVnode(oldEndVnode, newStartVnode)if(newStartVnode) newStartVnode.elm = oldEndVnode?.elm// 将旧后指向的节点移动到旧前的前面parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)oldEndVnode = oldCh[--oldEndIdx]newStartVnode = newCh[++newStartIdx]
}
如果四种命中都未命中呢?
我们需要循环遍历 oldVnode.children 中的旧前指针到旧后指针之前的节点,看看有没有 key 值与新前的 key 值是一样的(这里不考虑 tag 的值不一样的情况)。
// 创建一个 key 的映射对象,方便新节点在旧节点中寻找是否有相同的 key
const keyMap = {}
for (let i = oldStartIdx; i <= oldEndIdx; i++) {const key = oldCh[i].keyif (key) keyMap[key] = i
}
const idxInOld = keyMap[newStartVnode.key] // 在旧节点中寻找新前指向的节点
如果 idxInOld 有值
说明此时的新前指向的节点在旧节点中存在 key 值相同的节点,只需要移动该旧节点位置。 比如下面这种情况,一开始的这个新前 h(‘li’, { key: ‘B’}, ‘BBB’) 不满足四种命中任何一种,但是在旧节点中存在 tag 为 li,key 为 B 的节点。
在旧节点中找到后,用 elmToMove 变量保存它,再进行新旧同一节点的 patchVnode,然后要把处理过的旧节点赋值 undefined(因为一个节点不能同时位于文档的两个点中),最后用 insertBefore 移动 elmToMove:
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
// 处理过的节点赋值为 undefined
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
处理完就要移动新前指针,让新前指针指向的节点改为下一个 newStartVnode = newCh[++newStartIdx]
。
此时情况如下图,新前指针来到了 h(‘li’, { key: ‘A’}, ‘AAA’),此时新前与旧前命中,则同时下移:
当旧前移动到 undefined 的位置,则跳过继续下移一位,所以 while 循环最开始的时候要先有相应判断,再去匹配四种命中:
if (oldStartVnode === undefined) { // 有可能已经是处理过的情况oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode === undefined) {oldEndVnode = oldCh[--oldEndIdx]
}
如果 idxInOld 没有值
直接创建新节点插入
parentElm.insertBefore(creatElement(newStartVnode), oldStartVnode.elm)
while循环结束后
1.oldStartIdx > oldEndIdx
旧节点先处理完毕,说明新节点还有指针没指到并处理的节点,新节点有增加
直接批量生成新节点插入
2.newStartIdx > newEndIdx
新节点先处理完毕,说明新节点有删除,批量删除
if (oldStartIdx > oldEndIdx) { // 新节点有增加// 这里不能用真实 DOM 才有的属性 nextSiblingconst before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : nullfor (let i = newStartIdx; i <= newEndIdx; i++) {parentElm.insertBefore(creatElement(newCh[i]), before)}
} else { // 新节点有删除for (let i = oldStartIdx; i <= oldEndIdx; i++) {parentElm.removeChild(oldCh[i].elm)}
}
上述过程就是updateChildren 函数
// updateChildren.js
import patchVnode from './patchVnode.js'
import creatElement from './creatElement.js'// 判断两个虚拟节点是否为同一节点
function sameVnode(vnode1, vnode2) {return vnode1.tag === vnode2.tag && vnode1.key === vnode2.key
}export default (parentElm, oldCh, newCh) => {let oldStartIdx = 0 // 旧前指针let oldEndIdx = oldCh.length - 1 // 旧后指针let newStartIdx = 0 // 新前指针let newEndIdx = newCh.length - 1 // 新后指针let oldStartVnode = oldCh[0] // 初始时旧前指针指向的虚拟节点let oldEndVnode = oldCh[oldEndIdx] // 初始时旧后指针指向的虚拟节点let newStartVnode = newCh[0] // 初始时新前指针指向的虚拟节点let newEndVnode = newCh[newEndIdx] // 初始时新后指针指向的虚拟节点while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (oldStartVnode === undefined) { // 有可能已经是处理过的情况oldStartVnode = oldCh[++oldStartIdx]} else if (oldEndVnode === undefined) {oldEndVnode = oldCh[--oldEndIdx]} else if (sameVnode(oldStartVnode, newStartVnode)) {// 1. 新前与旧前patchVnode(oldStartVnode, newStartVnode)if (newStartVnode) newStartVnode.elm = oldStartVnode?.elmoldStartVnode = oldCh[++oldStartIdx]newStartVnode = newCh[++newStartIdx]} else if (sameVnode(oldEndVnode, newEndVnode)) {// 2. 新后与旧后patchVnode(oldEndVnode, newEndVnode)if (newEndVnode) newEndVnode.elm = oldEndVnode?.elmconsole.log(oldEndVnode.elm, newEndVnode.elm)oldEndVnode = oldCh[--oldEndIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldStartVnode, newEndVnode)) {// 3. 新后与旧前patchVnode(oldStartVnode, newEndVnode)if (newEndVnode) newEndVnode.elm = oldStartVnode?.elm// 把旧前指向的节点移动到旧后指向的节点的后面parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)oldStartVnode = oldCh[++oldStartIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldEndVnode, newStartVnode)) {// 新前与旧后patchVnode(oldEndVnode, newStartVnode)if (newStartVnode) newStartVnode.elm = oldEndVnode?.elm// 将旧后指向的节点移动到旧前的前面parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)oldEndVnode = oldCh[--oldEndIdx]newStartVnode = newCh[++newStartIdx]} else {// 四种命中都没有成功// 创建一个 key 的映射对象,方便新节点在旧节点中寻找是否有相同的 keyconst keyMap = {}for (let i = oldStartIdx; i <= oldEndIdx; i++) {const key = oldCh[i]?.keyif (key) keyMap[key] = i}const idxInOld = keyMap[newStartVnode.key] // 在旧节点中寻找新前指向的节点if (idxInOld) {// 如果有,说明该节点在旧节点中存在,只需要移动节点位置const elmToMove = oldCh[idxInOld]patchVnode(elmToMove, newStartVnode)// 处理过的节点赋值为 undefinedoldCh[idxInOld] = undefinedparentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)} else {// 如果没有,说明是个新节点parentElm.insertBefore(creatElement(newStartVnode), oldStartVnode.elm)}newStartVnode = newCh[++newStartIdx]}}/*** while 循环结束的条件只有两种* 1. oldStartIdx > oldEndIdx * 旧节点先处理完毕,说明新节点还有指针没指到并处理的节点,新节点有增加* 2. newStartIdx > newEndIdx * 新节点先处理完毕,说明新节点有删除*/if (oldStartIdx > oldEndIdx) { // 新节点有增加// 这里不能用真实 DOM 才有的属性 nextSiblingconst before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : nullconsole.log(newCh[newEndIdx + 1], newCh[newEndIdx + 1].elm)for (let i = newStartIdx; i <= newEndIdx; i++) {parentElm.insertBefore(creatElement(newCh[i]), before)}} else { // 新节点有删除for (let i = oldStartIdx; i <= oldEndIdx; i++) {parentElm.removeChild(oldCh[i].elm)}}
}
完整代码
文件结构
//vnode.js
//把传入的值包装成对象返回
export default (tag, data, children, text, elm) => {const { key } = datareturn { tag, data, children, text, elm, key }
}//createElemnt.jsexport default function createElemnt(vnode) {const domNode = document.createElement(vnode.tag)vnode.elm = domNode//判读vnode有子节点(children)还是textif (vnode.children !== undefined && vnode.children.length && vnode.text === undefined) {vnode.children.froEach(item => {//调用createElement意味着 创建出了Dom,并且将这个虚拟节点的elm属性指向了这个DOM//但是这个DOM还没上树//const childrenNode = createElemnt(item)item.elm = childrenNodedomNode.appendChildren(childrenNode)})} else {//内部是文本domNode.innText = vnode.textvnode.elm = domNode}return domNode
}//h.jsimport vnode from "./vnode";//tag是标签,data是key的对象
export default function h(tag, data, c) {if (arguments.length !== 3) {throw Error('请传入三个参数')}//如果第三个参数为字符串或者数字,则返回的值中,children为undefined,text就是c。if (typeof c === ('string' || 'number')) {return vnode(tag, data, undefined, c, undefined)} else if (Array.isArray(c) && c.length) {const children = []c.forEach(item => {//判断c中是否包含tag,是的话就是h函数if (!(typeof item === 'object' && item.hasOwnProperty('tag')))throw Error('传入的数组的元素不是h函数')//如果是h函数,就加入到children中children.push(item)})return vnode(tag, data, children, undefined, undefined)} else if (typeof c === 'object' && c.hasOwnProperty('tag')) {return vnode(tag, data, [c], undefined, undefined)} else {throw Error('第三个参数不正确')}
}//patch.js
import vnode from "./vnode";
import createElemnt from "./createElemnt.js";
import patchVnode from "./patchVnode.js";
//patch函数的作用,先判断oldnode是否为虚拟节点,否(真实dom)的话,将oldnode包装成虚拟节点。
//判断oldnode和newnode是否为同一节点,否的话,删除纠结点,插入新节点。
//最后精细化比较
export default function patch(oldnode, newnode) {//先判断是否是虚拟节点if (oldnode.tag === undefined) {//不是虚拟节点,就包装成虚拟节点oldnode = vnode(oldnode.tagName.toLowerCase, {}, [], undefined, oldnode)}//判断oldnode和newnode是否为同一节点if (oldnode.tag === newnode.tag && oldnode.key === newnode.tag) {console.log('是同一个节点');patchVnode(oldnode, newnode)} else {//不是同一个节点const domNode = createElemnt(newnode)//将新节点上树oldnode.elm.parentNode?.insertBefore(domNode, oldnode.elm)//删除旧节点oldnode.elm.parentNode?.removeChild(oldnode.elm)}
}//patchVnode.js
import createElemnt from "./createElemnt";
import updateChildren from './updateChildren'//主要处理俩种情况
//1.新节点的 text 属性有值
// 此时旧节点的 text 和 children 属性是什么情况无所谓,直接 innerText 赋值// 2.新节点的 children 属性有值
// 旧节点的 text 属性有值
// 旧节点的 children 属性有值//处理新旧节点为同一节点的情况
export default function patchVnode(oldnode, newnode) {if (oldnode === newnode) returnif (newnode.text !== undefined && (newnode.children === undefined || newnode.children.length === 0)) {//有text//新旧虚拟节点text的值是否一样if (newnode.text !== oldnode.text) {//直接赋值oldnode.elm.innerText = newnode.text}} else {//没有text//判断旧节点是children有值还是text有值if (oldnode.text !== undefined && (oldnode.children === undefined || oldnode.children.length === 0)) {//旧节点text属性有值,新节点children属性有值oldnode.elm.innerHTML = ''newnode.children.forEach(item => {const newDom = createElemnt(item)oldnode.elm.appendChild(newDom)})} else {updateChildren(oldnode.elm, oldnode.children, newnode.children)}}
}//updateChildren.js
// updateChildren.js
import patchVnode from './patchVnode.js'
import creatElement from './creatElement.js'// 判断两个虚拟节点是否为同一节点
function sameVnode(vnode1, vnode2) {return vnode1.tag === vnode2.tag && vnode1.key === vnode2.key
}export default (parentElm, oldCh, newCh) => {let oldStartIdx = 0 // 旧前指针let oldEndIdx = oldCh.length - 1 // 旧后指针let newStartIdx = 0 // 新前指针let newEndIdx = newCh.length - 1 // 新后指针let oldStartVnode = oldCh[0] // 初始时旧前指针指向的虚拟节点let oldEndVnode = oldCh[oldEndIdx] // 初始时旧后指针指向的虚拟节点let newStartVnode = newCh[0] // 初始时新前指针指向的虚拟节点let newEndVnode = newCh[newEndIdx] // 初始时新后指针指向的虚拟节点while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (oldStartVnode === undefined) { // 有可能已经是处理过的情况oldStartVnode = oldCh[++oldStartIdx]} else if (oldEndVnode === undefined) {oldEndVnode = oldCh[--oldEndIdx]} else if (sameVnode(oldStartVnode, newStartVnode)) {// 1. 新前与旧前patchVnode(oldStartVnode, newStartVnode)if (newStartVnode) newStartVnode.elm = oldStartVnode?.elmoldStartVnode = oldCh[++oldStartIdx]newStartVnode = newCh[++newStartIdx]} else if (sameVnode(oldEndVnode, newEndVnode)) {// 2. 新后与旧后patchVnode(oldEndVnode, newEndVnode)if (newEndVnode) newEndVnode.elm = oldEndVnode?.elmconsole.log(oldEndVnode.elm, newEndVnode.elm)oldEndVnode = oldCh[--oldEndIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldStartVnode, newEndVnode)) {// 3. 新后与旧前patchVnode(oldStartVnode, newEndVnode)if (newEndVnode) newEndVnode.elm = oldStartVnode?.elm// 把旧前指向的节点移动到旧后指向的节点的后面parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)oldStartVnode = oldCh[++oldStartIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldEndVnode, newStartVnode)) {// 新前与旧后patchVnode(oldEndVnode, newStartVnode)if (newStartVnode) newStartVnode.elm = oldEndVnode?.elm// 将旧后指向的节点移动到旧前的前面parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)oldEndVnode = oldCh[--oldEndIdx]newStartVnode = newCh[++newStartIdx]} else {// 四种命中都没有成功// 创建一个 key 的映射对象,方便新节点在旧节点中寻找是否有相同的 keyconst keyMap = {}for (let i = oldStartIdx; i <= oldEndIdx; i++) {const key = oldCh[i]?.keyif (key) keyMap[key] = i}const idxInOld = keyMap[newStartVnode.key] // 在旧节点中寻找新前指向的节点if (idxInOld) {// 如果有,说明该节点在旧节点中存在,只需要移动节点位置const elmToMove = oldCh[idxInOld]patchVnode(elmToMove, newStartVnode)// 处理过的节点赋值为 undefinedoldCh[idxInOld] = undefinedparentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)} else {// 如果没有,说明是个新节点parentElm.insertBefore(creatElement(newStartVnode), oldStartVnode.elm)}newStartVnode = newCh[++newStartIdx]}}/*** while 循环结束的条件只有两种* 1. oldStartIdx > oldEndIdx * 旧节点先处理完毕,说明新节点还有指针没指到并处理的节点,新节点有增加* 2. newStartIdx > newEndIdx * 新节点先处理完毕,说明新节点有删除*/if (oldStartIdx > oldEndIdx) { // 新节点有增加// 这里不能用真实 DOM 才有的属性 nextSiblingconst before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : nullconsole.log(newCh[newEndIdx + 1], newCh[newEndIdx + 1].elm)for (let i = newStartIdx; i <= newEndIdx; i++) {parentElm.insertBefore(creatElement(newCh[i]), before)}} else { // 新节点有删除for (let i = oldStartIdx; i <= oldEndIdx; i++) {parentElm.removeChild(oldCh[i].elm)}}
}