近来终于有了实战 Vue3 的机会,我当然不能放过。恰好 UED 提出使用新的依赖于 Vue3 的组件库,一拍即合,开搞。

一开始,确实碰到了许多深坑,有时莫名其妙地报红,有时又莫名其妙地变好了,有时纠结在如何组织代码架构,有时又发现想了很多到头来既不简洁也不完备。原来我一心憧憬的 TypeScript 也就那样,它并不如期待的像 Java 那样可以很好的发挥强类型的优势,而且有时不能像 Java 那样,只考虑架构的易用,还需要考虑性能问题

比如,Java 传递数据时,可以随意重新新建实例承接数据,而运行在性能堪忧的浏览器端的 JavaScript,则没办法这么潇洒的处事,具体到这次的项目中,由于涉及 AI,后端的语言无疑首选 Python,Python 的变量风格是下划线,如user_data,而我当时已经写了多半的 TypeScript 代码,内部组件之间传递的数据,均仍然遵从原有的驼峰命名法,如userData,如果现在全部改变风格,时间和可能出现的报错都是棘手的问题,而如果全部使用一个工具方法,批量地将下划线格式的变量转换成驼峰命名,则会有两个问题,其一无意义的数据转换拖慢了性能,其二需要另外定义一套类型以适配转换后的对象。最终采取折中方案,内部数据仍然使用驼峰命名,与接口交互的数据对象则使用下划线命名法直接承接。下次遇到 Python 写的后端,八成要预先想好代码格式了,省得这样纠结一下

以上是踩的数个坑中的简单代表,实际还有很多让我纠结的点,这是第一个我写的正式的 Vue3 项目,总想尽可能设计一个比较完善的初始架构,这样可以大幅放缓第一次重构的到来

话不多说,本文不在于纠结上述的坑,而是探讨从 Vue2 到 Vue3 需要经历的原理和代码写法的转变

从 Vue2 到 Vue3(一):响应式

其实我本不想探讨响应式原理,因为这个好像大家都有所了解,但是这里还是多说一嘴

在 Vue2 中,如大家所知,使用Object.defineProperty()方法劫持数据的读写,从而让 Vue 可以追踪依赖,在 property 被访问和修改时通知变更[1]

Vue2响应式原理

Vue 2 使用 getter / setters 完全是出于支持旧版本浏览器的限制。而在 Vue 3 中则使用了 Proxy 来创建响应式对象,仅将 getter / setter 用于 ref。[2]

那么什么是 Proxy?Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)[3],语法如下

1
const p = new Proxy(target, handler)

其中handler为包含捕捉器的占位符对象,包含getset等方法。简而言之与Object.defineProperty()相似的作用,但不会改变对象。需要说明的是,虽然使用了 Proxy 之后的对象表现得很像原始的对象,但是通过===还是可以看出区别

下面的伪代码说明了 Vue3 响应式的两大核心 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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)
return true
}
})
}

function ref(value) {
const refObject = {
get value() {
track(refObject, 'value')
return value
},
set value(newValue) {
value = newValue
trigger(refObject, 'value')
return true
}
}
return refObject
}

可以看出 reactive 使用了 Proxy,而 ref 并没有直接使用 Proxy,而是通过 value 包裹了一层传入的对象,类似 Proxy,但是无法像操作 Proxy 那样无感操作包裹的对象

track方法收集了依赖,trigger方法触发了更新方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
let activeEffect

function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}

function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}

这里的 activeEffect 是一个全局的值,在尝试触发更新时会赋值为当前的副作用方法,这个稍后再说。getSubscribersForProperty方法返回了一个存储在全局的WeakMap<target, Map<key, Set<effect>>>副作用集合映射,可以预见伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
let allEffects = new WeakMap();

function getSubscribersForProperty(target, key) {
if (!allEffects.has(target)) {
allEffects.set(target, new Map());
}
if (!allEffects.get(target).has(key)) {
allEffects.get(target).set(key, new Set());
}
return allEffects.get(target).get(key);
}

现在假设有这样一种情况:

1
2
3
4
5
const obj1 = reactive({k1: 'k1', k2: 'k2'})
const obj2 = {foo: null}
const update = function() {
obj2.foo = obj1.k1 + obj1.k2
}

预期当 obj1 的 k1 或者 k2 发生变化时,update方法被自动调用,于是 obj2.foo 更新。这需要一个whenDepsChange方法:

1
2
3
4
5
6
7
8
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect
update()
activeEffect = null
}
effect()
}

可以发现当调用whenDepsChange方法时,全局的 activeEffect 被赋值,update方法被触发,此时 obj1.k1 和 obj2.k2 的track方法均被触发,将当前的effect方法作为副作用方法记录在各自的副作用集合中,这就算完成了依赖收集

然后当 obj1.k1 或者 obj1.k2 被改变时,它们各自的trigger方法将各自的副作用方法取出并执行,此时可以预见,上述代码中的effect方法会再被触发,于是update方法再被触发,obj2.foo 被自动改变,完成了一次“响应式”

Vue3 响应式流程图

根据 Vue 官网说明,这里建立了一个线上示例:Vue3 响应式 demo

reactive 的局限性

reactive() API 有一些局限性:[4]

  1. 有限的值类型:它只能用于对象类型 (对象、数组和如 Map、Set 这样的集合类型)。它不能持有如stringnumberboolean这样的原始类型。
  2. 不能替换整个对象:由于 Vue 的响应式跟踪是通过属性访问实现的,因此我们必须始终保持对响应式对象的相同引用。这意味着我们不能轻易地“替换”响应式对象,因为这样的话与第一个引用的响应性连接将丢失
  3. 对解构操作不友好:当我们将响应式对象的原始类型属性解构为本地变量时,或者将该属性传递给函数时,我们将丢失响应性连接

由于这些限制,我们建议使用 ref() 作为声明响应式状态的主要 API。当被 ref 的值是一个对象时,ref() 也会在内部调用 reactive 包装为响应式对象

在使用 ref() 时,需要时刻注意访问或赋值其内部值需要操作value属性,如果项目使用了 TypeScript,一般会有类型校验提醒

参考


  1. Vue.js如何追踪变化 ↩︎

  2. Vue.js中的响应性是如何工作的 ↩︎

  3. Proxy-mdn ↩︎

  4. reactive的局限性 ↩︎