《工欲善其事,必先利其器》
既然点进来了,麻烦你看下去,希望你有不一样的收获。
大家好,我是 vk
。今天我们需要来研究一下,diff算法
和 虚拟节点
。因为篇幅实在是太长了。而且,在实现 diff算法
的过程中,我也没有添加过多的代码注释,而是根据自己的思路一点点实现的。所以在阅读本文之前,我会先推荐你去读一遍 yk菌 的高赞文章。
同样,在文末,我也会把源码上传至码云,需要的小伙伴可以去下载查看。好了,废话不多说,我们马上开始!!
要了解
Vue
为什么会应用虚拟节点这门技术,以及,diff算法
,就要先了解一些前置的储备知识。
本文我会大部分以图片的形式讲解,我们可以结合图解,更好的理解Vue
的源码。另外,本文还会涉及一些抽象语法树的知识,大家如果还没看过的话可以去翻一下我以前的文章~
在学习 Vue
源码的过程中,我的心中一直存在一个疑问:就是为什么不在 AST 抽象语法
树做 diff 算法
,而是要把它变成虚拟节点再进行比较呢?
首先来说,Vue
拥有三个最核心的部分:compiler
,reactivity
和 runtime
。它们分别代表着不同的任务模块:
compiler
的目的是将 template
模板提取为一份,有规律的数据结构,即 AST 抽象语法树
;reactivity
的职责是实现数据的响应式,v3
使用 proxy
,v2
使用 Object.defineProperty
;runtime
表示运行时部分功能模块的实现,包含 render
成虚拟节点、diff算法
和操作真实节点、指令API等。本质上,任务模块1(compiler)是为了适配 SFC
才会出现的。言外之意,我们完全可以抛开 compiler
的开发模式,只采用 runtime
运行时就可以进行开发的模式,主要利用 Vue
提供的render函数来实现,如下:
render(h) {
let bool = false;
return (
<div>
{ bool ? <button>按钮1</button> : <button>按钮2</button>}
</div>
);
}
我相信,在你初学 Vue
的时候,肯定在某篇文章或者官网看见这段代码,你会想到什么?没错,这就是 JSX
的开发模式!
篇幅走到这里,我们了解了以下几点:
Vue
的主要任务板块是什么;- 以及,什么是
JSX
的开发模式。但是,你有没有思考过几个问题?
JSX
是否也会被转化为Vnode
?- 如果会,那为什么
SFC
和JSX
都会被转化为VNode
?AST
和Vnode
的区别是什么?
我相信你已经注意到 render
函数的参数 h
了。实际上,不管是 Vue SFC
还是 JSX
开发模式所编写的代码,最后都会被 render
函数编译为 h
函数,从而转化为虚拟节点(VNode
)。
我们也可以自己尝试写一下 h
函数:
render(h){
return h('div',{ style : "color: red" }, "你好")
}
乍一看好像还挺简单?可是如果有很多个结构层次需要书写的时候,render
往往不是最优的选择(这里不包括 JSX
开发模式,单纯指 render
的 h
函数)。相反,它会变得很繁琐。
因此,compiler
开发模式(SFC
)和 runtime
开发模式(JSX
)就诞生了。
现在我们知道了,尽管是
runtime
开发模式(JSX
),它最终也是会被编译转化为Vnode
。
但是,灵魂拷问来了:为什么都要被转化为Vnode
呢?
这是因为,JavaScript 的引擎是单线程的,因为对于一个单页面前端框架来说(SPA
),如果频繁的直接操控和渲染 DOM
时候,势必会带来很多不必要的性能消耗,而且用户体验也及其差劲。因此引发的思考就是:能否利用 JavaScript 执行速度快的优势,先通过这个手段把我们需要操作的过程和节点分析、提炼然后整合起来,最后再一次性的挂载到 DOM
上?
答案当然是可以的。在这之前,前端界就已经有过这种虚拟节点的库,叫做 Snabbdom
。这个库可以说是虚拟节点技术的鼻祖,大家可以去了解一下。(documentFragment
也可以了解一下)
但这还不算完。
为了追求更高的性能,如果每次挂载就把旧的 DOM
直接完全替换掉,那这种方法显然又会把一些不必要更新的节点也给重新渲染了一遍。这就相当于变相的浪费了性能,与一开始选择采用 VNode
的初衷不符。
因此我们的祖师爷们又开始新一轮的头脑风暴,想在 VNode
节点挂载上树之前,让他跟旧节点做一次比较,把需要变化上树的节点更新了,其他不变的不用更新。但这种节点树之间的比较,需要一种非常精妙的算法,才可以优雅的实现,否则就是为 VNode
画蛇添足了。
最后,虚拟节点(VNode
)就伴随着 diff算法
一同问世,这给当时的前端界带来了不小的轰动。这也被我们今天学习的 Vue
框架所应用且发扬光大。
篇幅走到这里,我相信应该会为你扫除了心中的一部分阴霾。现在我们剩下最后一个问题:
AST
和VNode
到底有什么区别?为什么不在AST
编译阶段就开启diff算法
?
为此我画了一张图,可以清晰的看到,Vue SFC
的解析过程:
其实这个 Vue SFC
确实是方便了绝大部分程序员,但是对于开发者来说,就要多一道手续,那就是通过把 template
先解析成 AST
抽象语法树,然后再通过 render
函数的编译,render
函数再提炼成 VNode
。
我们接着来看一张对比图,分别po出了,AST
和 h
函数转化完的结果:
如上,以上两个流程图分别代表了,AST 抽象语法树
提取 template 的流程,以及,JSX
通过 render(即:h函数)转化成 VNode
的流程。其实我们可以看见,这两部分的代码功能似乎都差不多,都具备有把节点的文本和属性分开提炼的特点,只是前者被提取为 有规律性的数据结构
,后者则是利用h函数转化为 VNode
。
所以文章到这里,上面的问题我们也解决了,那就是:
AST
抽象语法树和虚拟节点VNode
的职责不同。前者注重模板编译,后者注重性能的提升。所以,diff算法
也就最适合在虚拟节点VNode
阶段开启。
虚拟 DOM 非常有趣,他允许我们以函数的形式来表达程序视图,但现有的解决方式基本都过于臃肿、性能不佳、功能缺乏、API 偏向于 OOP 或者缺少一些我所需要的功能。但也有框架没有采用虚拟节点的技术,那就是 Svelte
,大家有兴趣的也可以去了解一下。
回来说 diff算法
,归根结底它是一种算法。只是它不同于普通的算法,这个算法总共运用了,递归、和4个指针好几种策略。
为了更好的理解这个算法,我画了一张图,从头到尾帮大家梳理了一下整个 patchVnode
的过程:
左边 h
函数我们上面讲过,将 AST
或者 JSX
转化成虚拟节点的函数;右边的 patchVnode
才是真正实现 diff算法
的地方。
然后那4个匹配策略其实也很简单,一句话概括:任何一种策略命中的都需要递归调用一下 patchVnode
,用于比较新老节点是否有差异,然后将指针一个个的往下(新前老前策略)或者往上(新后老后策略)走。第3种策略如果命中,因为是新节点的后面命中,说明新节点永远在老节点的后面,因此不光要比较差异,还要将命中的老节点提升到节点树的顶部,策略4命中则反之。
最后还要整体循环遍历一下,看是否有需要上树或者删除的节点即可。
React
中假如 ChildComponent
里还有十层嵌套子元素,那么所有层次都会递归的重新 render
(在不进行手动优化的情况下),这是性能上的灾难。(因此,React
创造了 Fiber
,创造了异步渲染,其实本质上是弥补被自己搞砸了的性能)。
他们能用收集依赖的这套体系吗?不能,因为他们遵从 Immutable
的设计思想,永远不在原对象上修改属性,那么基于 Object.defineProperty
或 Proxy
的响应式依赖收集机制就无从下手了(你永远返回一个新的对象,我哪知道你修改了旧对象的哪部分?)
同时,由于没有响应式的收集依赖,React
只能递归的把所有子组件都重新 render
一遍(除了 memo
和 shouldComponentUpdate
这些优化手段),然后再通过 diff算法
决定要更新哪部分的视图,这个递归的过程叫做 reconciler
,听起来很酷,但是性能很灾难。
Vue
其实每个组件都有自己的渲染 Watcher
,它掌管了当前组件的视图更新,但是并不会掌管 ChildComponent 的更新。例如组件1种渲染了组件2,某个时刻组件1的某个片段需要更新,Vue
不会去深入到子组件的更新。
当然了,没有最好的框架,只有最好的程序员,如果这位看官你不赞同我的观点,那你就当看个笑话就好了~ 毕竟各花入各眼嘛~
Vue
为什么使用虚拟节点以及为什么采用 AST
抽象语法树;Vue SFC
是如何编译的以及 JSX
是如何编译的,两者各有什么优点。diff算法
的匹配策略和虚拟节点是如何生成的;React
和 Vue
对于 diff算法
的性能差异。其中,diff算法
的所有代码我托管在了码云上面,有需要的小伙伴可以下载来看看。—— 码云地址
感谢你的阅读,希望你的未来一片光明!
参考文献:
- Vue中的AST和VNode有什么区别? —— 知乎社区
- 为什么说 Vue 的响应式更新精确到组件级别?(原理深度解析) —— 掘金社区