全新 Javascript 装饰器实战上篇:用 MobX 的方式打开 Vue
去年三月份装饰器提案进入了 Stage 3 阶段,而今年三月份 Typescript 在 5.0 也正式支持了 。装饰器提案距离正式的语言标准,只差临门一脚。
这也意味着旧版的装饰器(Stage 1) 将逐渐退出历史舞台。然而旧版的装饰器已经被广泛的使用,比如 MobX、Angular、NestJS… 未来较长的一段时间内,都会是新旧并存的局面。
本文将把装饰器语法带到 Vue Reactivity API
中,让我们可以像 MobX 一样,使用类来定义数据模型, 例如:
class Counter { |
在这个过程中,我们可以体会到新旧装饰器版本之间的差异和实践中的各种陷阱。
概览
关于装饰器的主要 API 都在上述思维导图中,除此之外,读者可以通过下文「扩展阅读」中提及的链接来深入了解它们。
Legacy
首先,我们使用旧的装饰器来实现相关的功能。
在 Typescript 下,需要通过 experimentalDecorators
来启用装饰器语法:
{ |
如果使用 Babel 7 ,配置大概如下:
{ |
@observable
我们先来实现 @observable
装饰器,它只能作用于「类属性成员
」,比如:
class Counter { |
属性值可以是原始类型
或者对象类型
,没有限制。
为了让 Vue 的视图可以响应它的变化,我们可以使用 ref
来包装它。ref
刚好符合我们的需求,可以放置原始类型,也可以是对象, ref
会将其包装为 reactive
。
初步实现如下:
export const observable: PropertyDecorator = function (target, propertyKey) { |
解释一下上面的代码:
- 将装饰器的类型设置为
PropertyDecorator
。📢 对应的类型还有: ClassDecorator、MethodDecorator、ParameterDecorator
⚠️ 旧版装饰器使用位置上 Typescript 并没作类型检查,装饰器可以随意用在类、方法、属性各种位置上。
- 可以通过
target
的类型,来判断装饰器作用于静态成员
上还是实例成员
上。如果是静态成员,target 是类本身;如果是实例成员,target 为类的原型对象(prototype)
属性装饰器
只会接收两个参数:类和属性名。因为属性在构造函数中创建, 在类定义阶段,获取不到更多信息:class A {
foo = 1
}
// transpile to
class A {
constructor() {
this.foo = 1
}
}我们定义了一个新的
getter
/setter
成员, 这样外部才能透明地使用 ref, 不需要加上.value
后缀惰性初始化
ref。旧版的装饰器并没有提供addInitializer
这样的初始化钩子,我们曲线救国,使用惰性初始化的方式:const REACTIVE_CACHE = Symbol('reactive_cache')
export interface ReactiveAccessor {
get(): any
set(value: any): void
}
function getReactiveCache(target: any): Record<string | symbol, any> {
if (!hasProp(target, REACTIVE_CACHE)) {
addHiddenProp(target, REACTIVE_CACHE, {})
}
return target[REACTIVE_CACHE]
}
export type Initializer = (target: any) => ReactiveAccessor
export function initialIfNeed(target: any, key: string | symbol, initializer: Initializer) {
const cache = getReactiveCache(target)
// 如果属性未定义,就执行初始化
if (!hasProp(cache, key)) {
cache[key] = initializer(target)
}
return cache[key]
}这里我们将信息缓存在 REACTIVE_CACHE 字段中,实现惰性初始化。
写个单元测试看看:
test('base type', () => { |
💥 在较新的构建工具中(比如 vite),上述的测试大概率无法通过!为什么?
经过调试会发现我们在 observable 中的 defineProperty
并没有生效?
通过阅读 Vite 的文档可以找到一些线索,即 Typescript 的 [useDefineForClassFields](https://cn.vitejs.dev/guide/features.html#usedefineforclassfields)
:
从 Vite v2.5.0 开始,如果 TypeScript 的 target 是
ESNext
或ES2022
及更新版本,此选项默认值则为true
。这与[tsc
v4.3.2 及以后版本的行为](https://github.com/microsoft/TypeScript/pull/42663) 一致。这也是标准的 ECMAScript 的运行时行为
useDefineForClassFields
会改变类实例属性
的定义方式:
class A { |
这就是为什么我们装饰器内的 defineProperty
无法生效的原因。
解决办法:
方法 1: 显式关闭掉 useDefineForClassFields。如果是 Babel 需要配置 @babel/plugin-transform-class-properties
的 loose
为 true:
{ |
方法 2: 或者模仿 MobX V6 的 API:
class TodoList { |
MobX 的 observable、computed 等装饰器只是收集了一些标记信息
, 本身不会对类进行转换,真正进行转换是在 makeObservable
中进行的, 而 makeObservable
的执行时机是在所有属性都初始化完毕之后。
由于本文只关注装饰器的能力,这里就不展开了,有兴趣的读者可以看下 MobX 的源码。
@computed
按照同样的方法,我们来实现一下 @computed
装饰器,MobX 的 computed 和 Vue 的 computed 概念基本一致,就是用来做衍生数据的计算。
@computed 只能应用在 getter
上面:
export const computed: MethodDecorator = function (target, propertyKey, descriptor) { |
- getter/setter/method 装饰器的用法一致。会接收
descriptor
作为第三个参数,我们可以对descriptor
进行修改,或者返回一个新的descriptor
。 - 我们使用 vue 的 computed API 对 getter 函数进行简单包装。
测试一下:
test('computed', () => { |
Ok, 没问题,可以正常运行。我们配合组件的实际场景再测试看看:
test('render', () => { |
上面的用例没有通过,在组件卸载之后,@computed 装饰的 double 就失去了响应性。Why?
解决这个问题之前,我们需要了解一下 [effectScope](https://cn.vuejs.org/api/reactivity-advanced.html#effectscope)
, effectScope
创建一个 effect 作用域
,可以捕获其中所创建的响应式副作用 (即计算属性和侦听器),这样捕获到的副作用可以一起处理和销毁。
Vue setup
就是包装在 effectScope 之下,如果我们的 computed 在 setup 下被初始化,就会被 setup 捕获,当组件卸载时就会被随之清理掉。
我们的 @computed
是为全局作用域设计的,不能因为某个组件卸载而被销毁掉。为了解决这个问题,我们需要自己构造一个独立的 悬挂 effectScope
(Detached effectScope
):
const accessor: Initializer = (self) => { |
💡 watch 也会有相同的问题,读者可以自行尝试一下
💥 会不会内存泄露?理论上会泄露,取决于被 computed 订阅的数据源。如果该订阅源长期未释放,可能会出现内存泄露。
解决办法是将对应的类实例
和组件
的生命周期绑定。当组件释放时,调用类实例的释放方法,例如:
const providerStore = <T,>(store: new () => T): T => { |
比如 全局Store
可以和 Vue App
绑定,页面 Store
可以和页面组件
绑定。
🔴 MobX computed 并没有该问题,MobX 的 computed 在订阅者
清空时,会「挂起
(suspend)」,清空自己的订阅
(除非显式设置了 keepAlive),从而可以规避这种内存泄露。详见这里。
只能看后续 Vue 官方是否也作类似的支持了。
New
2022/3 装饰器议案正式进入 Stage 3 阶段,按照惯例,Typescript 也在 5.0 版本加入了该功能。
新版装饰器外形如下:
type Decorator = ( |
相比旧版的装饰器,新版的 API 形式上更加统一了,并且提供了一些上下文信息,对于开发者来说更加便利。
核心的变化如下:
形式上更加统一,不管是什么位置,都遵循
(value, context) ⇒ output | void
, 这个心智上更接近管道(pipe)
, 接收一个 Value , 可以返回一个新的 Value 来替换旧的 Value。
linux 管道context
提供了必要的上下文信息,对开发者来说更加便利,可以快速判断装饰器的类型、是否为静态属性、私有属性等等。更倾向于将装饰器当做一个纯函数(管道、转换器)来使用,尽量不包含副作用(比如修改类的结构)。
为了限制副作用,装饰器基本上屏蔽了一些底层细节,比如 descriptor,构造函数、原型对象,这些在新的装饰器中基本拿不到。
副作用只能在
context.addInitializer
中调用,但是能力也非常有限。就拿属性装饰器
来举例,initializer 通常在 class 内置的 defineProperty 之前调用,如果你在initializer
中使用了defineProperty
,那么将被覆盖:以 Typescript 的编译结果为例:
class Bar {
@d
foo = 1
}
// 编译结果:
let Bar = (() => {
var _a;
let _instanceExtraInitializers_1 = [];
let _foo_decorators;
let _foo_initializers = [];
return _a = class Bar {
constructor() {
// 🔴 ③ 定义属性
Object.defineProperty(this, "foo", {
enumerable: true,
configurable: true,
writable: true,
value:
// 🔴 ① 先执行其他装饰器的 addInitializer 回调
(__runInitializers(this, _instanceExtraInitializers_1),
// 🔴 ② 属性装饰器的 initializer
__runInitializers(this, _foo_initializers, 1))
});
}
},
(() => {
_foo_decorators = [d];
__esDecorate(null, null, _foo_decorators, { kind: "field", name: "foo", static: false, private: false, access: { has: obj => "foo" in obj, get: obj => obj.foo, set: (obj, value) => { obj.foo = value; } } }, _foo_initializers, _instanceExtraInitializers_1);
})(),
_a;
这样做的好处,笔者认为主要有以下几点:
- 性能优化:旧版的装饰器可以对 class 进行魔改,这就导致了引擎在解析完 Class 体后再去执行装饰器时,最终的 Class 结构可能发生较大的改变,导致引擎的优化无法生效(来源:ECMAScript 双月报告:装饰器提案进入 Stage 3)。
- 因为旧版可能会对类的结构进行破坏性魔改,这种副作用可能导致多个装饰器组合时,有难以预期的问题。
- 更容易测试
另外 Typescript 针对新的装饰器也提供了更严格的类型检查,比如可以约束装饰器使用的位置,旧版可以使用在任意位置,只能通过运行时进行检查。
Typescript 为新版装饰器提供了更严格的类型检查
💡 目前装饰器还未成为正式的语言特性,不排除后面还有特性变更。
💡 截止至文章发布的时间,Vite 使用新版装饰器还有一些问题。本文使用 Babel + Jest 来测试相关代码。
@observable
新版的属性装饰器
API 如下:
type ClassFieldDecorator = ( |
- value 始终为 undefined,因为属性在类定义时不存在,无法获取到初始值
- context 没有
addInitializer
。属性装饰器的返回值是一个函数,这个实际上就是一个initializer
- 访问不到类和类的原型
- 在 initializer 中也不能调用 defineProperty。原因见上文
也就是说,属性装饰器基本上堵死了我们去改造属性的机会…
且慢,跟随装饰器发布的还有一个自动访问器
(Auto Accessor)的特性(🙂 越来越像 Java、C# 了)
自动访问器使用 accessor
关键字定义:
class C { |
相当于:
class C { |
这有啥用?稍安勿躁,它在装饰器场景有大用,先来看下它的 API:
type ClassAutoAccessorDecorator = ( |
- value 接收 getter 和 setter
- 可以返回新的 getter 和 setter
- init 可以对初始值进行_转换_。
它的妙用在于,我们可以「兵不血刃」(不改变结构或者新增属性)地实现拦截,看看我们 observable 的实现就知道了:
export function observable<This, Value>( |
- 通过
context
,我们可以更方便地判断是否是静态成员、是否装饰在预期的位置 - 上述代码我们没有修改任何类的结构、新增任何属性。我们直接在 init 中将初始值转换为 ref, 相对应的 getter/setter 也作简单的改造。
很简单是不是?只不过,这个对已有的代码倾入性太大了,所有相关的属性都需要修改为 accessor
, 但对于 API 使用者来说没什么区别:
class A { |
@computed
Getter 装饰器和 Setter、Method 装饰器类型基本一致:
type ClassGetterDecorator = ( |
直接来看 computed 实现:
export function computed<This, Return, Value extends () => Return>( |
通过 addInitializer
来添加初始化逻辑(副作用), this 为当前类的实例。旧版的装饰器并没有提供类似的时机,我们只能通过惰性初始化
去模拟这种效果。
不过上面的程序也有个潜在的 BUG, 我们在新建一个 log 装饰器,组合在一起看看:
function log(value: Function, context: ClassGetterDecoratorContext) { |
执行上述代码,我们会发现并没有打印 start calling...
邪恶的副作用…
主要原因是上述代码我们在 addInitializer
中引用的 ‘value’ 是类原始的 getter 值,而我们又重新用 defineProperty 覆盖了属性,导致 @log 装饰的值丢失了。
实际上在新版的装饰器中,更符合规范的用法是:返回新的值来替换旧的值
const COMPUTED_CACHE: unique symbol = Symbol('computed_cache') |
上面的代码中,我们返回的新的函数来取代原有的 getter
,另外在 addInitializer
中初始化缓存属性。我们建议在 addInitializer
中一次性将需要的属性都初始化完毕,避免在 getter 中动态去添加新的属性,利好 JavaScript 引擎的优化。
这样做的好处是更符合新版装饰器的心智和设计意图,也可以保证装饰器按照组合的顺序调用。
总结
本文主要详细对比了新版和旧版的装饰器差异,通过实战将装饰器的能力和陷阱挖掘出来。
总得来说,新版的装饰器更加统一直观、更容易入手,在能力上也克制地收敛了。不过目前社区上大量的库和框架还停留在 Stage 1 装饰器,升级和改造需要较大的成本,我们可以暂时观望观望。
下一步:装饰器比较复杂的应用是依赖注入,当前的依赖注入库都深度依赖 reflect-metadata
来实现。而 Decorator Metadata 目前也进入了 Stage 3 阶段,很快就会和我们见面(Typescript 5.2),届时我们再聊聊如何实现依赖注入(🐶 看你们的点赞)。