文章目录
- 1. VUE的响应式原理
- 1.1 ViewModel
- 1.2 双向绑定的基本原理
- 1.3 什么是响应性
- 1.4 Vue 中的响应性是如何工作的
- 2. Vue 渲染机制
- 2.1 虚拟 DOM
- 2.2 渲染管线
- 2.3 带编译时信息的虚拟 DOM
- 2.3.1 静态提升
- 2.3.2 修补标记 Flags
- 2.3.3 树结构打平
- 2.3.4 对 SSR 激活的影响
1. VUE的响应式原理
响应式的基本原理:双向数据绑定
,就是把Model
绑定到View
,当我们用JavaScript
代码更新Model
时,View
就会自动更新,在单向绑定的基础上,如果用户更新了View
,Model
的数据也会自动更新。
双向绑定由三个重要部分构成:
数据层(Model
):应用数据及业务逻辑
视图层(View
):应用的展示效果,各类UI组件
业务逻辑层(ViewModel
):框架封装的核心,负责将数据与视图关联起来
1.1 ViewModel
作用:
- 数据变化更新视图
- 视图变化更新数据
它还有两个主要部分组成:
- 监听器(
Observer
):对所有数据的属性进行监听 - 解析器(
Compiler
):对每个节点的指令进行扫描跟解析,根据指令模板替换数据,以及绑定相应的更新函数
1.2 双向绑定的基本原理
在 JavaScript
中有两种劫持属性访问的方式:Object.defineProperty
和 Proxy
。
Vue 2
使用Object.defineProperty
完全由于需支持更旧版本浏览器的限制。- 在
Vue 3
中使用了Proxy
来创建响应式对象,将getter/setter
用于ref
。
首先要对数据(data)
进行劫持监听
。所以需要设置一个监听器Observer
,用来监听所有的属性。
每一个组件都有一个Watcher
实例。如果属性发生变化,需要通知订阅者Watcher
,看是否需要更新。因为订阅者有多个,所以需要一个消息订阅器(发布者)Dep
(订阅者集合的管理数组)来专门收集这些订阅者,在Observer
和Watcher
之间进行统一管理。
还需要一个指令解析器Compile
,对每个节点元素进行扫描和解析,将相关指令初始化为一个订阅者Watcher
,并替换模板数据或绑定相应的函数,此时当订阅者Watcher
接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。
1、实现一个监听器
Observer
,用来劫持并监听所有属性,如果发生变化,就通知订阅者。
2、实现一个订阅者Watcher
,可以收到属性的变化通知并执行相应的函数,从而更新视图。
3、实现一个解析器Compile
,可以扫描和解析每个节点的相关指令,并据此初始化视图和订阅器Watcher。
1.3 什么是响应性
如果我们在 JavaScript 写类似的逻辑:
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // 仍然是 3
当我们更改 A0 后,A2 不会自动更新。
那么我们如何在 JavaScript 中做到这一点呢?首先,为了能重新运行计算的代码来更新 A2,我们需要将其包装为一个函数:
let A2
function update() {
A2 = A0 + A1
}
然后,我们需要定义几个术语:
- 这个
update()
函数会产生一个副作用
,或者就简称为作用
,因为它会更改程序里的状态。 A0
和A1
被视为这个作用的依赖
,因为它们的值被用来执行这个作用。因此这次作用也可以说是一个它依赖的订阅者
。
我们需要一个魔法函数,能够在 A0
或 A1
(这两个依赖) 变化时调用 update()
(产生作用)。
whenDepsChange(update)
这个 whenDepsChange()
函数有如下的任务:
- 当一个变量被读取时进行追踪。例如我们执行了表达式
A0 + A1
的计算,则A0
和A1
都被读取到了。 - 如果一个变量在当前运行的
副作用
中被读取了,就将该副作用
设为此变量
的一个订阅者
。例如由于A0
和A1
在update()
执行时被访问到了,则update()
需要在第一次调用之后成为A0
和A1
的订阅者。 - 探测一个变量的变化。例如当我们给
A0
赋了一个新的值后,应该通知其所有订阅了的副作用重新执行。
1.4 Vue 中的响应性是如何工作的
我们是可以追踪一个对象的属性
进行读和写的。
在 JavaScript
中有两种劫持属性访问的方式:getter/setters
和 Proxies
。Vue 2 使用 getter/setters
完全由于需支持更旧版本浏览器的限制。而在 Vue 3
中使用了 Proxy
来创建响应式对象,将 getter/setter
用于 ref
。下面的伪代码将会说明它们是如何工作的:
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
}
})
}
function ref(value) {
const refObject = {
get value() {
track(refObject, 'value')
return value
},
set value(newValue) {
value = newValue
trigger(refObject, 'value')
}
}
return refObject
}
- 当你将一个响应性对象的属性解构为一个局部变量时,响应性就会“断开连接”,因为对局部变量的访问不再触发
get / set
代理捕获。 - 从
reactive()
返回的代理尽管行为上表现得像原始对象,但我们通过使用===
运算符还是能够比较出它们的不同。
在 track()
内部,我们会检查当前是否有正在运行的副作用。如果有,我们会查找到一个所有追踪了该属性的订阅者,它们存储在一个 Set
中,然后将当前这个副作用添加到该 Set
中。
// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
副作用订阅将被存储在一个全局的 WeakMap<target, Map<key, Set<effect>>>
数据结构中。如果在第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建。这就是 getSubscribersForProperty()
函数所做的事。为了简化描述,我们跳过了它其中的细节。
在 trigger()
之中,我们会再查找到该属性的所有订阅副作用。但这一次我们是去调用它们:
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}
现在让我们回到 whenDepsChange()
函数中:
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect
update()
activeEffect = null
}
effect()
}
它包装了原先的 update
函数到一个副作用中,并在运行实际的更新之前,将它自己设为当前活跃的副作用。而在更新期间开启的 track()
调用,都将能定位到这个当前活跃的副作用。
此时,我们已经创建了一个能自动跟踪其依赖关系的副作用,它会在依赖关系更改时重新运行。我们称其为响应式副作用
。
Vue 提供了一个 API 来让你创建响应式副作用 watchEffect()
。事实上,你会发现它的使用方式和我们上面示例中说的魔法函数 whenDepsChange()
非常相似。我们可以用真正的 Vue API
改写上面的例子:
import { ref, watchEffect } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = ref()
watchEffect(() => {
// 追踪 A0 和 A1
A2.value = A0.value + A1.value
})
// 将触发副作用
A0.value = 2
使用一个响应式副作用来更改一个 ref 并不是最优解,事实上使用计算属性会更直观简洁:
import { ref, computed } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)
A0.value = 2
在内部,computed
会使用响应式副作用来管理失效与重新计算的过程。
那么,常见的响应式副作用的用例是什么呢?自然是更新 DOM!我们可以像下面这样实现一个简单的“响应式渲染”:
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
document.body.innerHTML = `计数:${count.value}`
})
// 更新 DOM
count.value++
实际上,这与 Vue 组件保持状态和 DOM 同步的方式非常接近。每个组件实例创建一个响应式副作用来渲染和更新 DOM。当然,Vue 组件使用了比 innerHTML
更高效的方式来更新 DOM。这会在渲染机制
一章中详细介绍。
ref()
、computed()
和 watchEffect()
这些 API 都是组合式 API
的一部分,如果你至今只使用过选项式 API,那么你需要知道的是组合式 API 更贴近 Vue 底层的响应式系统。事实上,Vue 3 中的选项式 API 正是基于组合式 API 建立的。对该组件实例 (this
) 所有的属性访问都会触发 getter/setter
的响应式追踪,而像 watch
和 computed
这样的选项也是在内部调用相应等价的组合式 API。
2. Vue 渲染机制
2.1 虚拟 DOM
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
/* 更多 vnode */
]
}
这里所说的 vnode
即一个纯 JavaScript 的对象 (一个“虚拟节点”),它代表着一个 <div>
元素。它包含我们创建实际元素所需的所有信息。它还包含更多的子节点,这使它成为虚拟 DOM 树的根节点。
一个运行时渲染器将会遍历整个虚拟 DOM 树,并据此构建真实的 DOM 树。这个过程被称为挂载 (mount)
。
如果我们有两份虚拟 DOM 树,渲染器将会有比较地遍历它们,找出它们之间的区别,并应用这其中的变化到真实的 DOM 上。这个过程被称为修补 (patch)
,又被称为“比较差异 (diffing)
”或“协调 (reconciliation)
”。
虚拟 DOM 带来的主要收益是它赋予了开发者编程式地、声明式地创建、审查和组合所需 UI 结构的能力,而把直接与 DOM 相关的操作交给了渲染器。
2.2 渲染管线
- 编译:Vue 模板被编译为了渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。
- 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为
响应式副作用
执行,因此它会追踪其中所用到的所有响应式依赖。 - 修补:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。
2.3 带编译时信息的虚拟 DOM
虚拟 DOM 在 React 和大多数其他实现中都是纯运行时的:协调算法无法预知新的虚拟 DOM 树会是怎样,因此它总是需要遍历整棵树、比较每个 vnode 上 props 的区别来确保正确性。另外,即使一棵树的某个部分从未改变,还是会在每次重渲染时创建新的 vnode,带来了完全不必要的内存压力。这也是虚拟 DOM 最受诟病的地方之一:这种有点暴力的协调过程通过牺牲效率来换取可声明性和正确性。
但实际上我们并不需要这样。在 Vue 中,框架同时控制着编译器和运行时。这使得我们可以为紧密耦合的模板渲染器应用许多编译时优化。编译器可以静态分析模板并在生成的代码中留下标记,使得运行时尽可能地走捷径。与此同时,我们仍旧保留了边界情况时用户想要使用底层渲染函数的能力。我们称这种混合解决方案为带编译时信息的虚拟 DOM
。
下面,我们将讨论一些 Vue 编译器用来提高虚拟 DOM 运行时性能的主要优化:
2.3.1 静态提升
在模板中常常有部分内容是不带任何动态绑定的:
<div>
<div>foo</div> <!-- 需提升 -->
<div>bar</div> <!-- 需提升 -->
<div>{{ dynamic }}</div>
</div>
foo
和 bar
这两个 div 是完全静态的,没有必要在重新渲染时再次创建和比对它们。Vue 编译器自动地会提升这部分 vnode 创建函数到这个模板的渲染函数之外,并在每次渲染时都使用这份相同的 vnode,渲染器知道新旧 vnode 在这部分是完全相同的,所以会完全跳过对它们的差异比对。
此外,当有足够多连续的静态元素时,它们还会再被压缩为一个“静态 vnode”,其中包含的是这些节点相应的纯 HTML 字符串。这些静态节点会直接通过 innerHTML
来挂载。同时还会在初次挂载后缓存相应的 DOM 节点。如果这部分内容在应用中其他地方被重用,那么将会使用原生的 cloneNode()
方法来克隆新的 DOM 节点,这会非常高效。
2.3.2 修补标记 Flags
对于单个有动态绑定的元素来说,我们可以在编译时推断出大量信息:
<!-- 仅含 class 绑定 -->
<div :class="{ active }"></div>
<!-- 仅含 id 和 value 绑定 -->
<input :id="id" :value="value">
<!-- 仅含文本子节点 -->
<div>{{ dynamic }}</div>
在为这些元素生成渲染函数时,Vue 在 vnode 创建调用中直接编码了每个元素所需的更新类型:
createElementVNode("div", {
class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)
最后这个参数 2 就是一个修补标记 (patch flag)。一个元素可以有多个修补标记,会被合并成一个数字。运行时渲染器也将会使用位运算来检查这些标记,确定相应的更新操作:
if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
// 更新节点的 CSS class
}
位运算检查是非常快的。通过这样的修补标记,Vue 能够在更新带有动态绑定的元素时做最少的操作。
Vue 也为 vnode 的子节点标记了类型。举个例子,包含多个根节点的模板被表示为一个片段 (fragment),大多数情况下,我们可以确定其顺序是永远不变的,所以这部分信息就可以提供给运行时作为一个修补标记。
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
2.3.3 树结构打平
再来看看上面这个例子中生成的代码,你会发现所返回的虚拟 DOM 树是经一个特殊的 createElementBlock()
调用创建的:
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
这里我们引入一个概念“区块”,内部结构是稳定的一个部分可被称之为一个区块。在这个用例中,整个模板只有一个区块,因为这里没有用到任何结构性指令 (比如 v-if
或者 v-for
)。
每一个块都会追踪其所有带修补标记的后代节点 (不只是直接子节点),举个例子:
<div> <!-- root block -->
<div>...</div> <!-- 不会追踪 -->
<div :id="id"></div> <!-- 要追踪 -->
<div> <!-- 不会追踪 -->
<div>{{ bar }}</div> <!-- 要追踪 -->
</div>
</div>
编译的结果会被打平为一个数组,仅包含所有动态的后代节点:
div (block root)
- div 带有 :id 绑定
- div 带有 {{ bar }} 绑定
当这个组件需要重渲染时,只需要遍历这个打平的树而非整棵树。这也就是我们所说的树结构打平,这大大减少了我们在虚拟 DOM 协调时需要遍历的节点数量。模板中任何的静态部分都会被高效地略过。
v-if
和 v-for
指令会创建新的区块节点:
<div> <!-- 根区块 -->
<div>
<div v-if> <!-- if 区块 -->
...
<div>
</div>
</div>
一个子区块会在父区块的动态子节点数组中被追踪,这为他们的父区块保留了一个稳定的结构。
2.3.4 对 SSR 激活的影响
修补标记和树结构打平都大大提升了 Vue SSR 激活
的性能表现:
- 单个元素的激活可以基于相应 vnode 的修补标记走更快的捷径。
- 在激活时只有区块节点和其动态子节点需要被遍历,这在模板层面上实现更高效的部分激活。