外观
VueJS原理
约 3833 字大约 13 分钟
2025-12-05
命令式与声明式
- 命令式框架关注过程 (jquery)
- 原生 javascript 操作 DOM
- 声明式框架关注结果 (vuejs)
- 找出不同声明式状态的差异之后再操作 DOM
- 声明式框架的性能不优于命令式,但声明式的写法对用户更加友好,可维护性强。
- 采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。
- 声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗
- 虚拟 DOM 的出现是为了最小化找出差异这一步的性能消耗
运行时和编译时
- 纯编译时: 所有工作都在编译阶段完成,运行时几乎没有框架代码 (Svelte/SolidJS)
- 编译阶段将模板/组件转换为纯 JavaScript/原生 DOM 操作代码
- 运行时直接执行编译后的代码,无需额外解析或虚拟DOM
- 框架代码体积小,运行时性能高
- 纯运行时: 所有工作都在运行时完成 (CDN 方式引入的 vue2)
- 浏览器直接加载框架和源码
- 运行时解析模板、创建虚拟DOM、执行Diff算法
- 需要完整的运行时库
- 运行时 + 编译时 (vue3+vite)
- 编译阶段:预编译模板,优化代码
- 运行阶段:处理核心运行时逻辑(响应式、虚拟DOM等)
良好的框架设计
- 提供友好的警告信息
- 控制运行时的框架代码的体积
- Tree-Shaking 移除掉没有副作用的代码
- 设置特性开关灵活控制功能是否开启
- 框架输出的产物根据不同的需求而不同
- 灵活的错误处理机制
- 用户可以选择忽略错误
- 自定义错误处理函数
- 将错误上报给统一的错误处理程序
- 良好的 TypeScript 类型支持
vue3 的设计思路
可以用模板来描述 UI,也可以用 JS 对象来描述
使用 JS 对象(函数)来描述的 UI 更加的灵活,代码更简洁
- 使用变量和函数来表达通用的数据或逻辑,避免写结构类似的模板代码
- 这个用来描述 UI 的 JS 对象就是 虚拟DOM
编译器的作用是将模板编译为渲染函数
渲染器的作用是将 虚拟DOM 渲染为 真实DOM
function renderer(vnode, container)- vnode 是虚拟DOM,container 是真实DOM,作为虚拟DOM的挂载点
组件是一组 DOM 元素的封装,也可以用虚拟 DOM 对象来表示
完整的编译和渲染过程:
模板 (Template)
↓ 编译器编译
渲染函数 (Render Function)
↓ 运行时执行
虚拟DOM (Virtual DOM)
↓ 渲染器渲染(patch/diff算法)
真实DOM (Actual DOM)
开发者(厨师长) → 模板(灵感) → 编译器(配方师)→ 编译 → 渲染函数(标准化配方) → 生成 → 虚拟DOM(食材清单) → 渲染器(厨师) → 渲染 → 真实DOM(菜肴)编译器和渲染器如何配合
渲染器的作用之一是寻找变化并更新内容,那么它如何知道哪些数据是可能会变化的呢?编译器能识别出哪些是静态属性,哪些是动态属性,并在生成渲染函数的时候附带这些信息。对于下面的模板:
<div id="foo" :class="cls"></div>经过编译器编译之后,生成如下渲染函数。其中虚拟 DOM 对象的 patchFlags 属性的数字 1 代表:class是动态的
render() {
return {
tag: 'div',
props: {
id: 'foo',
class: cls
},
patchFlags: 1 // 假设数字 1 代表 class 是动态的
}
}虚拟 DOM 对象中会包含多种数据字段,每个字段都代表一定的含义,这便是编译器和渲染器之间配合工作的桥梁。
响应式系统
- 副作用函数:副作用函数的执行会直接或间接影响其他函数的执行,比如修改了全局变量
- 响应式数据
- 当读取操作发生时,将副作用函数收集到“桶”中;
- 当设置操作发生时,从“桶”中取出副作用函数并执行。
- WeakMap 对 key 是弱引用,不影响垃圾回收器的工作
响应式的基础实现
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
let depsMap = bucket.get(target)
if (!activeEffect) return
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}以上的基础实现没有考虑到下面的问题:
- 分支切换会导致副作用函数遗留,需要 clean_up
- effect函数嵌套时,需要引入副作用函数栈
- 自增操作会导致无限递归循环,改进:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
- 支持调度函数:当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式
计算属性
- 设置
options.lazy选项懒执行 effect 函数 - 设置 dirty 选项来支持缓存
watch 的实现原理
- 利用 effect 和 scheduler 选项来实现 watch
- immediate 选项来设置回调是否需要立即执行
- flush 选项指定调度函数的执行时机
- 'pre' | 'post' | 'sync'
- watch 函数的回调函数接收第三个参数 onInvalidate, 它注册一个回调,这个回调函数会在当前副作用函数过期时执行
响应系统总结
在本章中,我们首先介绍了副作用函数和响应式数据的概念,以及它们之间的关系。一个响应式数据最基本的实现依赖于对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶”中;当“设置”操作发生时,再将副作用函数从“桶”里取出并执行。这就是响应系统的根本实现原理。
接着,我们实现了一个相对完善的响应系统。使用 WeakMap 配合 Map 构建了新的“桶”结构,从而能够在响应式数据与副作用函数之间建立更加精确的联系。同时,我们也介绍了 WeakMap 与 Map 这两个数据结构之间的区别。WeakMap 是弱引用的,它不影响垃圾回收器的工作。当用户代码对一个对象没有引用关系时,WeakMap 不会阻止垃圾回收器回收该对象。
我们还讨论了分支切换导致的冗余副作用的问题,这个问题会导致副作用函数进行不必要的更新。为了解决这个问题,我们需要在每次副作用函数重新执行之前,清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系中不存在冗余副作用问题,从而解决了问题。但在此过程中,我们还遇到了遍历 Set 数据结构导致无限循环的新问题,该问题产生的原因可以从 ECMA 规范中得知,即“在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但这个值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么这个值会重新被访问。”解决方案是建立一个新的 Set 数据结构用来遍历。
然后,我们讨论了关于嵌套的副作用函数的问题。在实际场景中,嵌套的副作用函数发生在组件嵌套的场景中,即父子组件关系。这时为了避免在响应式数据与副作用函数之间建立的响应联系发生错乱,我们需要使用副作用函数栈来存储不同的副作用函数。当一个副作用函数执行完毕后,将其从栈中弹出。当读取响应式数据的时候,被读取的响应式数据只会与当前栈顶的副作用函数建立响应联系,从而解决问题。而后,我们遇到了副作用函数无限递归地调用自身,导致栈溢出的问题。该问题的根本原因在于,对响应式数据的读取和设置操作发生在同一个副作用函数内。解决办法很简单,如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
随后,我们讨论了响应系统的可调度性。所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。为了实现调度能力,我们为 effect 函数增加了第二个选项参数,可以通过 scheduler 选项指定调用器,这样用户可以通过调度器自行完成任务的调度。我们还讲解了如何通过调度器实现任务去重,即通过一个微任务队列对任务进行缓存,从而实现去重。
而后,我们讲解了计算属性,即 computed。计算属性实际上是一个懒执行的副作用函数,我们通过 lazy 选项使得副作用函数可以懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。利用这个特点,我们设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。当计算属性依赖的响应式数据发生变化时,会通过 scheduler 将 dirty 标记设置为 true,代表“脏”。这样,下次读取计算属性的值时,我们会重新计算真正的值。
之后,我们讨论了 watch 的实现原理。它本质上利用了副作用函数重新执行时的可调度性。一个 watch 本身会创建一个 effect,当这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度器函数,即 scheduler。这里的 scheduler 可以理解为“回调”,所以我们只需要在 scheduler 中执行用户通过 watch 函数注册的回调函数即可。此外,我们还讲解了立即执行回调的 watch,通过添加新的 immediate 选项来实现,还讨论了如何控制回调函数的执行时机,通过 flush 选项来指定回调函数具体的执行时机,本质上是利用了调用器和异步的微任务队列。
最后,我们讨论了过期的副作用函数,它会导致竞态问题。为了解决这个问题,Vue.js 为 watch 的回调函数设计了第三个参数,即 onInvalidate。它是一个函数,用来注册过期回调。每当 watch 的回调函数执行之前,会优先执行用户通过 onInvalidate 注册的过期回调。这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。
渲染器
- 渲染器首次把虚拟DOM节点渲染为真实DOM节点的过程叫做“挂载(mount)”。
- 渲染器后续把虚拟DOM节点渲染为真实DOM节点的过程叫做“打补丁(patch)”。
- 会与旧的 vnode 节点进行比对后更新
- 挂载可以理解为特殊的“打补丁”,即旧的 vnode 不存在。这样我们就统一了这两个概念。
function createRenderer() {
function render(vnode, container) {
// ...
}
function hydrate(vnode, container) {
// ...
}
return {
render,
hydrate
}
}- 自定义渲染器在将虚拟DOM渲染为浏览器真实DOM的基础上进行抽象,使得渲染的功能不依赖平台特有的API,支持个性化配置来实现跨平台。
- HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值
- 一个 HTML Attributes 可能会对应到多个 DOM Properties
const vnode = {
type: 'div',
// 使用 props 描述一个元素的属性
props: {
id: 'foo'
},
children: [
{
type: 'p',
children: 'hello'
}
]
}元素属性的处理
- 对于单值的属性
disabled等,在转换中可能会出现语义不一致的情况 - 渲染器不应该总是使用 setAttribute 函数将 vnode.props 对象中的属性设置到 HTML 元素上
- 该问题在etaf中不会出现,因为etaf的属性都是使用明确的键值对,不会出现 disabled 等这种单个值的属性
- 对于单值的属性
class 和 style 的处理
- vue 对 class 和 style 做了增强,需要 normalize
卸载操作
- 卸载操作应该封装到 unmount 中
- 在 unmount 函数内,我们有机会调用绑定在 DOM 元素上的指令钩子函数,例如 beforeUnmount、unmounted 等。
- 当 unmount 函数执行时,我们有机会检测虚拟节点 vnode 的类型。如果该虚拟节点描述的是组件,则我们有机会调用组件相关的生命周期函数。
patch 的多种情况
- 前后元素类型不同,应该先卸载,再挂载
- 根据 vnode.type 调用不用的挂载和更新方法
事件处理
- 事件在虚拟节点中存储在 props 中
- 如何添加到真实DOM中: 在 patchProps 中调用 addEventListener 函数来绑定事件
- 先从
el._vei中读取对应的 invoker,如果 invoker 不存在,则将伪造的 invoker 作为事件处理函数,并将它缓存到el._vei属性中。 - 把真正的事件处理函数赋值给 invoker.value 属性,然后把伪造的 invoker 函数作为事件处理函数绑定到元素上。可以看到,当事件触发时,实际上执行的是伪造的事件处理函数,在其内部间接执行了真正的事件处理函数 invoker.value(e)。
- 先从
- 一个元素不仅可以绑定多种类型的事件,对于同一类型的事件而言,还可以绑定多个事件处理函数
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
// 否则直接作为函数调用
invoker.value(e)
}
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {- 事件冒泡与更新时机
