- 响应式系统的基本原理
- 依赖收集追踪原理
- 实现 Virtual DOM 下的一个 VNode 节点
- template 模板是怎样通过 Compile 编译的
- 数据状态更新时的差异 diff 及 patch 机制
- 批量异步更新策略及 nextTick 原理
- 穿插部分vue3源码新特性
首先看下 Vue.js 内部的整个流程,希望能让大家对全局有一个整体的印象,然后我们再来逐个模块进行讲解
初始化及挂载
在 new Vue()
之后。 Vue 会调用 _init
函数进行初始化,也就是这里的 init
过程,它会初始化生命周期、事件、 props、 methods、 data、 computed 与 watch 等。其中最重要的是通过 Object.defineProperty
设置 setter
与 getter
函数,用来实现「响应式」以及「依赖收集」,后面会详细讲到,这里只要有一个印象即可。
初始化之后调用 $mount
会挂载组件,如果是运行时编译,即不存在 render function 但是存在 template 的情况,需要进行「编译」步骤。
一、响应式系统的基本原理
Vue.js 是一款 MVVM 框架,数据模型仅仅是普通的 JavaScript 对象,但是对这些对象进行操作时,却能影响对应视图,它的核心实现就是「响应式系统」。尽管我们在使用 Vue.js 进行开发时不会直接修改「响应式系统」,但是理解它的实现有助于避开一些常见的「坑」,也有助于在遇见一些琢磨不透的问题时可以深入其原理来解决它。
vue2.x Object.defineProperty
作用:在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象。
使用方法:
/*
obj: 目标对象
prop: 需要操作的目标对象的属性名
descriptor: 描述符
return value 传入对象
*/
Object.defineProperty(obj, prop, descriptor)
descriptor的一些属性,简单介绍几个属性,具体可以参考 MDN 文档。
enumerable
,属性是否可枚举,默认 false。configurable
,属性是否可以被修改或者删除,默认 false。get
,获取属性的方法。set
,设置属性的方法。
vue3.0 Proxy
语法:const p = new Proxy(target, handler)
参数:
- target:要使用
Proxy
包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理) - handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理
p
的行为。
通过Proxy,我们可以对设置代理的对象
上的一些操作进行拦截,外界对这个对象的各种操作,都要先通过这层拦截。(和defineProperty差不多)
可以看出,Proxy代理的是整个对象,而不是对象的某个特定属性,不需要我们通过遍历来逐个进行数据绑定。
解决Object.defineProperty中遇到的问题
1.一次只能对一个属性进行监听,需要遍历来对所有属性监听。
2. 在遇到一个对象的属性还是一个对象的情况下,需要递归监听。
3. 对于对象的新增属性,需要手动监听
4. 对于数组通过push、unshift等方法增加的元素,通过下标来修改数组的值也无法监听
二、响应式系统的依赖收集追踪原理
为什么要依赖收集?
先举个栗子🌰
我们现在有这么一个 Vue 对象。
new Vue({
template:
`<div>
<span>{{text1}}</span>
<span>{{text2}}</span>
<div>`,
data: {
text1: 'text1',
text2: 'text2',
text3: 'text3'
}
});
然后我们做了这么一个操作
我们修改了 data
中 text3
的数据,但是因为视图中并不需要用到 text3
,所以我们并不需要触发上一章所讲的 cb
函数来更新视图,调用 cb
显然是不正确的。
再来一个栗子🌰
假设我们现在有一个全局的对象,我们可能会在多个 Vue 对象中用到它进行展示。
let globalObj = {
text1: 'text1'
};
let o1 = new Vue({
template:
`<div>
<span>{{text1}}</span>
<div>`,
data: globalObj
});
let o2 = new Vue({
template:
`<div>
<span>{{text1}}</span>
<div>`,
data: globalObj
});
这个时候,我们执行了如下操作。
globalObj.text1 = 'hello,text1';
我们应该需要通知 o1
以及 o2
两个vm实例进行视图的更新
「依赖收集」会让 text1
这个数据知道“~有两个地方依赖我的数据,我变化的时候需要通知它们”。
接下来我们来介绍一下「依赖收集」是如何实现的。
订阅者 Dep类
首先我们实现一个订阅者 Dep ,它的主要作用是用来存放 Watcher
观察者对象。
为了便于理解我们只实现了部分代码,主要是两件事情:
- 用
addSub
方法可以在目前的Dep
对象中增加一个Watcher
的订阅操作; - 用
notify
方法通知目前Dep
对象的subs
中的所有Watcher
对象触发更新操作。
观察者 Watcher类
实例化时往Dep订阅器里添加自己/* 在new一个Watcher对象时将该对象赋值给Dep.target,在get中会用到 */
自身必须有一个update方法,
接下来我们修改一下 defineReactive
以及 Vue 的构造函数,来完成依赖收集。
依赖收集总结:
我们在闭包中增加了一个 Dep 类的对象,用来收集 Watcher
对象。在对象被「读」的时候,会触发 reactiveGetter
函数把当前的 Watcher
对象(存放在 Dep.target 中)收集到 Dep
类中去。之后如果当该对象被「写」的时候,则会触发 setter
方法,通知 Dep
类调用 notify
来触发所有 Watcher
对象的 update
方法更新对应视图。
我们介绍了「依赖收集」的过程,配合之前的响应式原理,就把整个「响应式系统」介绍完毕了。
这里的 getter
跟 setter
已经在之前介绍过了,在 init
的时候通过 Object.defineProperty
进行了绑定,它使得当被设置的对象被读取的时候会执行 getter
函数,而在当被赋值的时候会执行 setter
函数。
当 render function 被渲染的时候,因为会读取所需对象的值,所以会触发 getter
函数进行「依赖收集」,「依赖收集」的目的是将观察者 Watcher 对象存放到当前闭包中的订阅者 Dep 的 subs 中。形成如下所示的这样一个关系。
在修改对象的值的时候,会触发对应的 setter
, setter
通知之前「依赖收集」得到的 Dep 中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher 就会开始调用 update
来更新视图,这中间还有一个 patch
的过程以及使用队列来异步更新的策略
三、实现 Virtual DOM 下的一个 VNode 节点
什么是VNode
我们知道,render function 会被转化成 VNode 节点。Virtual DOM 其实就是一棵以 JavaScript 对象(VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象
实现一个VNode
class VNode {
constructor (tag, data, children, text, elm) {
/*当前节点的标签名*/
this.tag = tag;
/*当前节点的一些数据信息,比如props、attrs等数据*/
this.data = data;
/*当前节点的子节点,是一个数组*/
this.children = children;
/*当前节点的文本*/
this.text = text;
/*当前虚拟节点对应的真实dom节点*/
this.elm = elm;
}
}
比如我目前有这么一个 Vue 组件。
<template>
<span class="demo" v-show="isShow">
This is a span.
</span>
</template>
用 JavaScript 代码形式就是这样的。
function render () {
return new VNode(
'span',
{
/* 指令集合数组 */
directives: [
{
/* v-show指令 */
rawName: 'v-show',
expression: 'isShow',
name: 'show',
value: true
}
],
/* 静态class */
staticClass: 'demo'
},
[ new VNode(undefined, undefined, undefined, 'This is a span.') ]
);
}
看看转换成 VNode 以后的情况。
四、template 模板是怎样通过 Compile 编译的
compile 的内容非常多,大致分为三块主要内容,他们是Vue的 渲染三巨头
就是 parse,optimize,generate
虽然分为三块,但是要明确一点
compile 的作用是解析模板,生成渲染模板的 render
比如这样的模板
经过 compile 之后,就会生成下面的 render
_c('div', [_c('span'), _v(num)])
复制代码
而 render 的作用,也是为了生成跟模板节点一一对应的 Vnode
{
tag: "div",
children:[{
tag: "span",
text: undefined
},{
tag: undefined
text: "111"
}]
}
Parse
这是 compile 的第一个步骤
作用是
接收 template 原始模板,按照模板的节点 和数据 生成对应的 ast
比如这样
生成的 ast 是这样,所有模板中出现的数据,你都可以在 ast 中找到
{
tag: "div",
attrsMap: {test: "2"},
children:[{
tag: "span",
children: [],
attrsMap: {name: "1"}
}]
}
parse 是怎么生成 ast 的,因为涉及很多源码,此处不便讲解
Optimize
这是 compile 的第二步
optimize
主要作用就跟它的名字一样,用作「优化」。
作用是
这个涉及到后面要讲 patch
的过程,因为 patch
的过程实际上是将 VNode 节点进行一层一层的比对,然后将「差异」更新到视图上。那么一些静态节点是不会根据数据变化而产生变化的,这些节点我们没有比对的需求,是不是可以跳过这些静态节点的比对,从而节省一些性能呢?
那么我们就需要为静态的节点做上一些「标记」,在 patch
的时候我们就可以直接跳过这些被标记的节点的比对,从而达到「优化」的目的。
经过 optimize
这层的处理,遍历递归每一个ast节点,每个节点会加上 static
属性,用来标记是否是静态的。
这样就知道那部分不会变化,于是在页面需要更新时,减少去比对这部分DOM
比如这个模板
span 和 b 就是静态节点,在 optimize 处理中,就会给他们添加 static 判断是否是静态节点
{
static: false,
staticRoot: false,
tag: "div",
children: [{
staticRoot: true,
tag: "span",
children: [{
static: true,
tag: "b"
}]
},{
static: false,
text: "{{a}}"
}]
}
复制代码
staticRoot,这个是表示这个节点是否是静态根节点的意思
用来标记 某部分静态节点 最大的祖宗节点,后面更新的时候,只要碰到这个属性,就知道他的所有子孙节点都是静态节点了,而不需要每个子孙节点都要判断一次浪费时间。
Generate
这是 compile 的第三步
作用是
把前两步生成完善的 ast 组装成 render function 字符串(这个 render 变成函数后是可执行的函数,不过现在是字符串的形态,后面会转成函数)
看个例子
经过前两步变成 ast
然后,generate 接收 ast,先处理最外层 ast,然后开始递归遍历子节点,直到所有节点被处理完
这个过程中,字符串会被一点一点拼接完成,比如上面的 ast 拼接结果就是下面这样
这些 _c
,_v
到底是什么?其实他们是 Vue.js 对一些函数的简写,比如说 _c
对应的是
是生成节点对应的 Vnode 的一个函数
`
_c('div', [
_c('span', [
_v(b)
]),
_v(a)
])
`
复制代码
简单说一下拼接流程
1、一开始接收到 ast,处理最外层 ast 这个点,是 div,于是拼接得到字符串
code = ` _c('div', [ `
复制代码
2、遍历 div 子节点,遇到 span,拼接在 div 的子节点数组中
code = `_c('div', [ _c('span', [ `
复制代码
3、开始处理 span 的子节点 b,放进 span 的 子节点数组中
code = ` _c('div', [ _c('span', [ _v(b) `
复制代码
4、span 子节点处理完,闭合 span 的 子节点数组
code = ` _c('div', [ _c('span', [ _v(b) ] `
复制代码
5、继续处理 span 同级 的子节点,是个文本节点,但是是动态值,变量是 a
code = `_c('div', [ _c('span', [ _v(b) ] , _v(a) `
复制代码
6、所有子节点都处理完毕,闭合 div 的 子节点数组
code = ` _c('div', [ _c('span', [ _v(b) ] , _v(a) )] `
复制代码
经历过这些过程以后,我们已经把 template 顺利转成了 render function 了,接下来我们将介绍 patch
的过程,来看一下具体 VNode 节点如何进行差异的比对。
五、数据状态更新时的差异 diff 及 patch 机制
数据更新视图
之前讲到,在对 model
进行操作对时候,会触发对应 Dep
中的 Watcher
对象。Watcher
对象会调用对应的 update
来修改视图。最终是将新产生的 VNode 节点与老 VNode 进行一个 patch
的过程,比对得出「差异」,最终将这些「差异」更新到视图上。
这一章就来介绍一下这个 patch
的过程,因为 patch
过程本身比较复杂,这一章的内容会比较多,但是不要害怕,我们逐块代码去看,一定可以理解。
首先说一下 patch
的核心 diff 算法,我们用 diff 算法可以比对出两颗树的「差异」
patch
- 创建需要新增的节点
- 移除已经废弃的节点
- 移动或修改需要更新的节点
sameVnode
patchVnode
updateChildren
vue2 双端比较
vue3最长递增子序列
react;仅右移
Vue3 在编译时针对虚拟 DOM 的性能优化??
尤大已经在 beta 版的线上直播上告诉了我们答案。
Vue3 的 patch 优化 ——
1.PatchFlag(静态标记)
- 编译模板时,动态节点做标记
- 标记,分为不同的类型,如TEXT PROPS
- diff算法时,可以区分静态节点,以及不同类型的动态节点
Vue 2.x 中的虚拟 DOM 是全量对比的模式,而到了 Vue 3.0 开始,新增了静态标记(PatchFlag)。
在更新前的节点进行对比的时候,只会去对比带有静态标记的节点。并且 PatchFlag 枚举定义了十几种类型,用以更精确的定位需要对比节点的类型。下面我们通过图文实例分析这个对比的过程。
假设我们有下面一段代码:
<div>
<p>老八食堂</p>
<p>{{ message }}</p>
</div>
在 Vue 2.x 的全量对比模式下,如下图所示:
通过上图,我们发现,Vue 2.x 的 diff 算法将每个标签都比较了一次,最后发现带有 {{ message }}
变量的标签是需要被更新的标签,显然这还有优化的空间。
在 Vue 3.0 中,对 diff 算法进行了优化,在创建虚拟 DOM 时,根据 DOM 内容是否会发生变化,而给予相对应类型的静态标记(PatchFlag),如下图所示:
观察上图,不难发现试图的更新只对带有 flag 标记的标签进行了对比(diff),所以只进行了 1 次比较,而相同情况下,Vue 2.x 则进行了 3 次比较。这便是 Vue 3.0 比 Vue2.x 性能好的第一个原因。
模板转化
Vue Template Explorer
上图蓝色框内为转译后的虚拟 DOM 节点,第一个 P 标签为写死的静态文字,而第二个 P 标签则为绑定的变量,所以打上了 1 标签,代表的是 TEXT(文字),标记枚举类型如下:
export const enum PatchFlags {
TEXT = 1,// 动态的文本节点
CLASS = 1 << 1, // 2 动态的 class
STYLE = 1 << 2, // 4 动态的 style
PROPS = 1 << 3, // 8 动态属性,不包括类名和样式
FULL_PROPS = 1 << 4, // 16 动态 key,当 key 变化时需要完整的 diff 算法做比较
HYDRATE_EVENTS = 1 << 5, // 32 表示带有事件监听器的节点
STABLE_FRAGMENT = 1 << 6, // 64 一个不会改变子节点顺序的 Fragment
KEYED_FRAGMENT = 1 << 7, // 128 带有 key 属性的 Fragment
UNKEYED_FRAGMENT = 1 << 8, // 256 子节点没有 key 的 Fragment
NEED_PATCH = 1 << 9, // 512
DYNAMIC_SLOTS = 1 << 10, // 动态 solt
HOISTED = -1, // 特殊标志是负整数表示永远不会用作 diff
BAIL = -2 // 一个特殊的标志,指代差异算法
}
2.hoistStatic(静态提升)
将静态节点的定义,提升到父作用域,缓存起来
多个相邻的静态节点,会被合并起来
典型的拿空间换时间的优化策略
静态提升后:
老八食堂
被提到了 render
函数外,每次渲染的时候只要取 _hoisted_1
变量便可。 _hoisted_1
被打上了 PatchFlag
,静态标记值为 -1 ,特殊标志是负整数表示永远不会用作 Diff。也就是说被打上 -1 标记的,将不在参与 Diff 算法,这又提升了 Vue 的性能。
cacheHandler(事件监听缓存)
默认情况下 @click
事件被认为是动态变量,所以每次更新视图的时候都会追踪它的变化。但是正常情况下,我们的 @click
事件在视图渲染前和渲染后,都是同一个事件,基本上不需要去追踪它的变化,所以 Vue 3.0 对此作出了相应的优化叫事件监听缓存
编译后如下图所示(还未开启 cacheHandler):
在未开启事件监听缓存的情况下,我们看到这串代码编译后被静态标记为 8,之前讲解过被静态标记的标签就会被拉去做比较,而静态标记 8 对应的是“动态属性,不包括类名和样式”。 @click
被认为是动态属性,所以我们需要开启 Options 下的 cacheHandler
属性,如下图所示:
开启 cacheHandler
之后,编译后的代码已经没有静态标记(PatchFlag),也就表明图中 P 标签不再被追踪比较变化,进而提升了 Vue 的性能。
SSR 服务端渲染
当你在开发中使用 SSR 开发时,Vue 3.0 会将静态标签直接转化为文本,相比 React 先将 jsx 转化为虚拟 DOM,再将虚拟 DOM 转化为 HTML,Vue 3.0 已经赢了。
六、批量异步更新策略及 nextTick 原理
为什么要异步更新
通过前面几个章节我们介绍,相信大家已经明白了 Vue.js 是如何在我们修改 data
中的数据后修改视图了。简单回顾一下,这里面其实就是一个“setter -> Dep -> Watcher -> patch -> 视图
”的过程。
假设我们有如下这么一种情况。
假设我们有如下这么一种情况。
<template>
<div>
<div>{{number}}</div>
<div @click="handleClick">click</div>
</div>
</template>
export default {
data () {
return {
number: 0
};
},
methods: {
handleClick () {
for(let i = 0; i < 1000; i++) {
this.number++;
}
}
}
}
当我们按下 click 按钮的时候,number
会被循环增加1000次。
那么按照之前的理解,每次 number
被 +1 的时候,都会触发 number
的 setter
方法,从而根据上面的流程一直跑下来最后修改真实 DOM。那么在这个过程中,DOM 会被更新 1000 次!太可怕了。
Vue.js 肯定不会以如此低效的方法来处理。Vue.js在默认情况下,每次触发某个数据的 setter
方法后,对应的 Watcher
对象其实会被 push
进一个队列 queue
中,在下一个 tick 的时候将这个队列 queue
全部拿出来 run
( Watcher
对象的一个方法,用来触发 patch
操作) 一遍。
nextTick
Vue.js 实现了一个 nextTick
函数,传入一个 cb
,这个 cb
会被存储到一个队列中,在下一个 tick 时触发队列中的所有 cb
事件。
因为目前浏览器平台并没有实现 nextTick
方法,所以 Vue.js 源码中分别用 Promise
、setTimeout
、setImmediate
等方式在 microtask(或是task)中创建一个事件,目的是在当前调用栈执行完毕以后(不一定立即)才会去执行这个事件。
再写 Watcher
第一个例子中,当我们将 number
增加 1000 次时,先将对应的 Watcher
对象给 push
进一个队列 queue
中去,等下一个 tick 的时候再去执行,这样做是对的。但是有没有发现,另一个问题出现了?
因为 number
执行 ++ 操作以后对应的 Watcher
对象都是同一个,我们并不需要在下一个 tick 的时候执行 1000 个同样的 Watcher
对象去修改界面,而是只需要执行一个 Watcher
对象,使其将界面上的 0 变成 1000 即可。
那么,我们就需要执行一个过滤的操作,同一个的 Watcher
在同一个 tick 的时候应该只被执行一次,也就是说队列 queue
中不应该出现重复的 Watcher
对象。
那么我们给 Watcher
对象起个名字吧~用 id
来标记每一个 Watcher
对象,让他们看起来“不太一样”。
实现 update
方法,在修改数据后由 Dep
来调用, 而 run
方法才是真正的触发 patch
更新视图的方法。
let uid = 0;
class Watcher {
constructor () {
this.id = ++uid;
}
update () {
console.log('watch' + this.id + ' update');
queueWatcher(this);
}
run () {
console.log('watch' + this.id + '视图更新啦~');
}
}
queueWatcher
不知道大家注意到了没有?笔者已经将 Watcher
的 update
中的实现改成了
queueWatcher(this);
将 Watcher
对象自身传递给 queueWatcher
方法。
我们来实现一下 queueWatcher
方法。
let has = {};
let queue = [];
let waiting = false;
function queueWatcher(watcher) {
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
if (!waiting) {
waiting = true;
nextTick(flushSchedulerQueue);
}
}
}
我们使用一个叫做 has
的 map,里面存放 id -> true ( false ) 的形式,用来判断是否已经存在相同的 Watcher
对象 (这样比每次都去遍历 queue
效率上会高很多)。
如果目前队列 queue
中还没有这个 Watcher
对象,则该对象会被 push
进队列 queue
中去。
waiting
是一个标记位,标记是否已经向 nextTick
传递了 flushSchedulerQueue
方法,在下一个 tick 的时候执行 flushSchedulerQueue
方法来 flush 队列 queue
,执行它里面的所有 Watcher
对象的 run
方法。
flushSchedulerQueue
function flushSchedulerQueue () {
let watcher, id;
for (index = 0; index < queue.length; index++) {
watcher = queue[index];
id = watcher.id;
has[id] = null;
watcher.run();
}
waiting = false;
}
举个例子
let watch1 = new Watcher();
let watch2 = new Watcher();
watch1.update();
watch1.update();
watch2.update();
我们现在 new 了两个 Watcher
对象,因为修改了 data
的数据,所以我们模拟触发了两次 watch1
的 update
以及 一次 watch2
的 update
。
假设没有批量异步更新策略的话,理论上应该执行 Watcher
对象的 run
,那么会打印。
watch1 update
watch1视图更新啦~
watch1 update
watch1视图更新啦~
watch2 update
watch2视图更新啦~
实际上则执行
watch1 update
watch1 update
watch2 update
watch1视图更新啦~
watch2视图更新啦~
这就是异步更新策略的效果,相同的 Watcher
对象会在这个过程中被剔除,在下一个 tick 的时候去更新视图,从而达到对我们第一个例子的优化。
掌握这些原理以后再去尝试阅读源码,相信会事半功倍,也会对 Vue.js 有更深一层的理解。
vue3.x GitHub - vuejs/core: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web. - GitHub - vuejs/core: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.https://github.com/vuejs/core