开始
《Vue.js 设计与实现》是 2022 年出版的新书,价格也比较贵,但物有所值,作者肯定是花费了大量的精力来编写。
标题是“设计和实现”,这本书讲的都是 Vue3 的原理,如何设计的,以及如何通过代码实现最基本的功能。所以,看这本书必须要先熟悉 Vue3 应用,否则看起来会很被动。
Vue3 的主要模块
从这本书的目录可以看出
- 响应系统:监听可变数据,数据变化时触发回调函数
- 渲染器:将 VDOM 挂载或更新为真实 DOM ,其中涉及到 diff 算法
- 组件化:支持把一个大型系统拆分为若干组件,形成组件树
- 编译器:把 Vue 模板编译为 JS 代码 (对应 React 中的 JSX)
知识点记录
命令式和声明式
- 命令式 - 关注过程,例如使用 jQuery 进行 DOM 操作
- 声明式 - 关注结果,例如 Vue 模板中绑定事件、使用插值和指令
前端开发中,无论是 jQuery Vue 还是 React ,其实都是两者的结合:用声明式去写 UI 配置,用命令式去做业务逻辑处理。
但 Vue 更倾向于声明式,这主要得益于它的响应式。定义好 data 和模板,直接修改 data 属性值即可,不用执行什么特殊的命令。反观 React 的 setState 就是命令式的,当然 JSX 是声明式的。
从原理上来讲,命令式的性能会更好,因为你可以直接操作基础 API ,简单粗暴。但声明式却更易于扩展和维护,而且性能也不会差。
为何使用 VDOM
同理,VDOM 的性能不会比直接操作 DOM 更好,越基础、越底层的 API 性能越好。
但 VDOM 结合 diff 算法可以在大量 DOM 更新时取得优势,因为此时如果直接进行 DOM 操作会带来极高的复杂度,开发难度很大,维护成本也很高。
响应式的基本设计思路
- 使用 Proxy 监听数据属性的 get set
- get 时记录 effectFn 到一个 WeakMap (按属性分别记录)—— 所以,想实现响应式,要先执行一次 effectFn ,即 touch
- set 时找到 Map 中所有的 effectFn ,然后分别触发
bucket: WeakMap {
target1: Map {
key1: Set[
fn1,
fn2,
fn3
],
key1: Set[ ... ]
},
target2: Map { ... }
}
还有很多其他的情况,如:三元表达式、嵌套 effectFn 、循环调用等,书中都做了详细的讲解。
computed 基本原理
响应式支持自行配置“执行调度”功能,可以传入 lazy
这样 effectFn 不会立刻触发,而需要手动执行。
effect(
() => { console.log(obj.foo) },
{
lazy: true // obj.foo 被修改时,函数不会被触发
}
)
Vue 中的 computed 也是这样被动触发的,不是主动执行的。可以这样定义 computed
function computed(getter) {
const effectFn = effect(getter, { lazy: true })
const obj = {
get value() {
return effectFn() // 当读取 .value 时再执行 effctFn
}
}
return obj
}
computed 另一个重要功能是缓存计算结果,可以结合 scheduler
调度功能来实现缓存,非常简洁
function computed(getter) {
let value
let dirty = true // 默认为缓存失效
const effectFn = effect(getter, {
lazy: true, // 修改数据不会触发 getter
scheduler() {
dirty = true // 修改数据会触发 scheduler ,让之前的缓存失效
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn() // 重新计算,并记录缓存
return value
}
return value // 缓存未失效
}
}
return obj
}
watch 基本原理
watch 可以使用 lazy
和 scheduler
调度功能来实现。 watch 和 computed 在内部实现上有相关性。
function watch(source, cb) {
let newValue, oldValue
const effectFn = effect(
() => source.foo, // 要监听的数据
{
lazy: true, // 用于下面的被动调用,获取 newValue
scheduler() {
newValue = effectFn()
cb(newValue, oldValue) // 执行 watch 回调函数
oldValue = newValue // 更新旧值
}
}
)
oldValue = effectFn()
}
Reflect API 的作用
执行如下代码,返回结果见注释
const obj = {
_name: 'xxx',
get name() {
console.log('this', this) // obj 对象
return this._name
}
}
const p = new Proxy(obj, {
get(target, key, receiver) {
console.log('key', key) // 'name' (没有 '_name')
return target[key] // 【注意】这里不用 Reflect.get
}
})
p.name
把以上代码做简单修改,使用 return Reflect.get(target, key, receiver)
,结果就不一样了
const obj = {
_name: 'xxx',
get name() {
console.log('this', this) // p 对象
return this._name
}
}
const p = new Proxy(obj, {
get(target, key, receiver) {
console.log('key', key) // 'name' 和 '_name'
return return Reflect.get(target, key, receiver) // 【注意】使用了 Reflect.get
}
})
p.name
这两者有什么区别呢?
- 第一种情况,无法监听
_name
属性 get ,不符合预期 - 第二种情况,可以监听
_name
属性 get ,符合预期
Reflect API 的作用就是:能改变对象 getter 里的 this
。
注意,这仅仅是针对对象的 getter
,如果把 get name()
换成一个普通的函数 getName()
就不会有这个问题了。
const obj = {
_name: 'xxx',
getName() {
console.log(this) // p 对象
return this._name
}
}
const p = new Proxy(obj, {
get(target, key, receiver) {
console.log('key', key) // 'name' 和 '_name'
// 以下两个,打印的效果一样
return target[key]
// return Reflect.get(target, key, receiver)
}
})
遇事要查标准规范
当遇到语法或 API 的问题时,最先想到的方式应该是查询业界标准规范,而不是自己试验或者上网查各种博客资料
- 自己试验会受限于自己的浏览器或系统版本,或者自己考虑不全面
- 网络上的资料也有很多不对的,或者过时的
书中提到了几种规范
- ECMA-262 规范。如使用 Proxy 监听对象属性时,如何监听
in
和for...in
,监听数组时如何监听for...of
和length
等 - WHATWG 规范。编译器解析 Vue 模板时,可参考 HTML 的规范。
同理,我们遇到 Vue 使用的问题,最快的解决方式就是看 Vue 官方文档,保证全面无坑。
ref 的本质
ref 的本质就是 reactive
function ref(val) {
const wrapper = {
value: val
}
// 定义一个 ref 的标记。在模板中可直接使用 ref 而不用 value ,就根据这个标记判断 (这跟响应式无关)
// 【注意】使用 defineProperty 定义属性,只定义一个 value ,其他的(configurable, enumerable, writable)都默认是 false
Object.defineProperty(wrapper, '__v_isRef', { value: true })
return reactive(wrapper) // 使用 reactive 做响应式
}
toRef 也是一样的
function toRef(obj, key) {
// obj 本身是 reactive() 封装过的
const wrapper = {
get value() {
return obj[key]
}
set value(v) {
obj[key] = v
}
}
Object.defineProperty(wrapper, '__v_isRef', { value: true }) // 标记为 ref
return wrapper
}
toRefs 就是遍历属性,挨个执行 toRef
渲染时高效切换 DOM 事件
Vue 模板中绑定了事件,那渲染为真实 DOM 也需要绑定 DOM 事件。如果事件更新了,按照一般的思路是先 removeEventListener
然后再 addEventListener
,就是两次 DOM 操作 —— DOM 操作是昂贵的。
Vue 对此进行了优化,极大减少了 DOM 操作。其实很简单:
invoker = { value: someFn }
elem.addEventListener(type, invoker.value)
// 如果事件更新,只修改 invoker.value 即可,不用进行 DOM 操作
Diff 算法
Vue2 使用双端比较的 diff 算法,参考了 snabbdom.js 。
Vue3 使用了快速 diff 算法,参考了 ivi 和 inferno 。思路是:
- 先进行双端比较
- 剩余的部分计算出最长递增子序列(一个很常见的算法),以找到不用重建和移动的节点
- 最后处理剩余部分
异步更新
响应式原本是同步的,即 data 属性变化之后,effectFn 会同步触发执行。
但如果多次修改 data 属性,会同步触发多次 effectFn 执行,如果用于渲染 DOM 就太浪费性能了。
所以,Vue 在此基础上进行了优化,改为异步渲染,即多次修改 data 属性,只会在最后一次触发 effectFn 执行,中间不会连续触发。
const queue = new Set() // 任务队列。Set 可自动去重,这很重要,否则重复添加 fn 将导致重复执行
let isFlushing = false // 标记是否正在刷新
const p = new Promise()
function queueJob(job) {
queue.add(job) // 添加任务
// 如果还没有开始刷新,则启动
if (!isFlushing) {
isFlushing = true // 标记为刷新中
p.then(() => {
try {
queue.forEach(job => job())
} finally {
isFlushing = false // 标记为刷新完成
queue.length = 0 // 清空任务队列
}
})
}
}
实现方式如上述代码,先把 effectFn 缓存到一个任务队列中(要去重),然后触发一个 promise then 的回调,通过 isFlushing
标记只触发一次。最后,通过 queueJob
函数触发 effectFn 执行即可。
Composition API 如何得到组件实例
如 onMounted
可以在组件内部使用,也可以在组件外部使用(但必须在 setup
中触发)。当它在组件外部使用时,怎么知道当前是哪个组件呢?
Vue 是定义了一个全局变量 currentInstance
,该变量就存储着当前正在执行 setup
的组件实例,执行结束即清空。
let currentInstance = null // 全局变量,存储当前正在 setup 的组件实例
// 挂载组件
function mountComponent(vnode, container, anchor) {
const instance = { ... } // 当前组件实例
currentInstance = instance // 存储到全局变量
// 执行组件的 setup 函数,其中可能会调用 onMounted
currentInstance = null // 清空全局变量
}
function onMounted(fn) {
if (currentInstance == null) throw new Error('找不到组件实例') // 说明 onMounted 没有在 setup 中触发
// 把 fn 记录到当前组件的 mounted 函数列表中,等待 mounted 之后被触发
currentInstance.mounted.push(fn)
}
至此,就明白为什么 onMounted
必须要在 setup
内部触发了。
关于 Vue 函数组件
Vue 函数组件是无状态组件,只有 props ,没有 data 和生命周期。Composition API 还是用于普通组件,不能用于函数组件。这一点和 React Hooks 不一样。
Vue2 函数组件比普通组件性能好。而 Vue3 的普通组件初始化也很快,所以用函数组件主要是为了简单,没有性能的优势。
keep-alive 缓存原理
keep-alive 内部的组件和 DOM elem 会被缓存起来,切换时只触发 activate 和 deactivate 生命周期,不会重复创建。
缓存不能无限制扩展,需要有一个裁剪机制,Vue 通过 LRU 算法进行裁剪。可以通过 max
include
exclude
来自行控制缓存。
编译器流程
编译器就是将 Vue 模板生成 JS 代码,即 render 函数
- 输入模板字符串
parse(tplStr)
生成模板 AST - 输入模板 AST
transform(ast)
生成 JS AST - 输入 JS AST
generate(JSAST)
生成 JS 代码
Vue 采用“编译时 + 运行时”模式,既可以在开发环境直接编译模板,也可以在运行环境编译模板。一般使用前者。
Vue3 编译优化
编译优化是所有编译器都会做的事情,例如 JS 代码中写 const a = 10; const b = a + 10;
编译之后就会是 const b = 20;
,这是最简单的优化。
Vue3 在编译时也做了很多优化:第一,提高执行 render 函数生成 vnode 的效率;第二,提高 diff 算法的执行效率。
patchFlag
补丁标记 - 区分静态节点和动态节点,diff 时可以只对比动态节点- 静态提升 - 把静态节点的生成提升到 render 函数外部,这样只执行一次即可,不用每次 render 时都执行
- 缓存内联事件 - 把模板内联事件缓存,不用每次 render 时都重新生成事件
对 SSR 的误解
严格来说应该叫同构,不是真正的 SSR (如 PHP JSP)。
首次渲染时服务端返回:1. 纯静态的页面;2. 打包好的 JS 和 CSS 代码。浏览器会直接展示静态页面,然后再加载 JS 和 CSS 代码,待加载完比、执行 JS 完毕之后,网页才算是真正可用。
所以,同构渲染只是能解决首次渲染时网页白屏的问题,以及对 SEO 比较友好。但它不能提升可交互时间(TTI),因为还需要下载并执行 JS ,网页才能真正可交互。这个时间和 CSR 相差无几。
SSR 组件的生命周期
SSR 生成是当前组件的“快照”,纯静态的 HTML 代码,生成之后立刻返回给客户端。
服务端没法渲染 DOM ,也就没有 beforeMount 和 mounted ,同理也没有 beforeDestroy 和 destroyed 。服务端也不需要绑定 DOM 事件,只有客户端才能执行事件。服务端也没必要监听响应式,所以也没有 beforeUpdate 和 updated 。
所以,SSR 组件生命周期只有 beforeCreate 和 created ,其他都没有。
SSR 组件在客户端的激活操作
SSR 返回的是组件快照,纯静态 HTML 代码,没有 DOM 事件,到浏览器中被渲染为真实 DOM 。此时还需要在客户端进行激活 hydrate
,激活之后网页才真正可用。主要两件事:
- 关联真实 DOM 和 VDOM ,即
vnode.el = elem
- 绑定 DOM 事件
另外,由于真实 DOM 已经被渲染,所以此时 Vue 不会在重新渲染 DOM ,只激活即可。
总结
如果你想了解 Vue3 原理,这本书真的是最佳选择,当然需要你提前熟悉 Vue3 的常见应用。
不过,看书有技巧,书里的细节非常多,不要指望全部都能看懂。其实 Vue 等第三方框架的研发,这属于一个独立的细分领域,和我们日常的业务开发关联并不是那么大。所以,我们学习 Vue 的原理和实现方式,扩展我们的技术视野,这是没错的。但千万千万不要误入细节,更不要妄想自己也去造一个一模一样的轮子 —— 知道原理和实现,离着一个成熟可用的框架还差很多很多。
加油,共勉~