@Raw 装饰器 field 实现方案分析
背景
@Raw 装饰器用于标记类字段,使其值始终保持 markRaw(不被 Vue 响应式系统代理)。 在实现 rawFieldDecorator 时,先后经历了三个方案,每个方案都踩到了同一个坑。
关键前提:field decorator 的执行顺序
这是理解所有问题的基础。TC39 Stage 3 规范中,对于每个字段,初始化顺序如下:
① 求值初始值表达式(如 = { version: '1.0.0' })
② 若 decorator 返回了 initializer 函数,将初始值传入该函数,结果作为新初始值
③ 将最终初始值赋给字段(写入 data property,存于 [[Value]])
④ 执行该字段上 addInitializer 注册的所有回调核心误解:addInitializer 不是在字段赋值"之前"运行的拦截钩子,而是在赋值"完成之后"才执行。
方案一:只用 addInitializer
function rawFieldDecorator(_value: any, context: ClassFieldDecoratorContext): void {
const propertyName = context.name;
context.addInitializer(function(this: any) {
let cachedValue: unknown; // ← undefined
Object.defineProperty(this, propertyName, {
configurable: true,
enumerable: true,
get() { return cachedValue; },
set(newVal) { cachedValue = ensureRaw(newVal); },
});
});
// 没有 return
}执行过程
① { version: '1.0.0' } 求值
② 没有 initializer 函数 → 初始值原样保留
③ this.config = { version: '1.0.0' } ← data property,[[Value]] 有值
④ addInitializer 执行:
- let cachedValue ← undefined!
- Object.defineProperty 用 getter/setter 覆盖了步骤③的 data property
- [[Value]] 被销毁,cachedValue 从未赋值结果
this.config → undefined ❌
根本原因:以为 addInitializer 会在赋值之前执行,从而"拦截" setter。实际上它在赋值之后执行,直接把已有的 { version: '1.0.0' } 覆盖掉了。
方案二:addInitializer + 返回 ensureRaw
function rawFieldDecorator(_value: any, context: ClassFieldDecoratorContext): (v: unknown) => unknown {
const propertyName = context.name;
context.addInitializer(function(this: any) {
let cachedValue: unknown; // ← 仍然是 undefined!
Object.defineProperty(this, propertyName, { get, set });
});
return (initialValue: unknown) => ensureRaw(initialValue);
}执行过程
① { version: '1.0.0' } 求值
② initializer 函数执行:ensureRaw({ version: '1.0.0' }) → markRaw({...})
③ this.config = markRaw({...}) ← data property,值已正确 markRaw
④ addInitializer 执行:
- let cachedValue ← 仍然是 undefined!
- Object.defineProperty 覆盖步骤③的 data property
- markRaw({...}) 被销毁结果
this.config → undefined ❌
根本原因:返回的 initializer 函数正确处理了初始值(步骤②③),但 addInitializer(步骤④)又用一个 cachedValue = undefined 的空 getter/setter 把 data property 覆盖掉了。两个阶段各自独立执行,没有"接力"。
方案三:addInitializer 读取 this[propertyName] + 返回 ensureRaw(当前实现)
function rawFieldDecorator(_value: any, context: ClassFieldDecoratorContext): (v: unknown) => unknown {
const propertyName = context.name;
context.addInitializer(function(this: any) {
let cachedValue: unknown = (this as any)[propertyName]; // ← 读取步骤③已赋好的值
Object.defineProperty(this, propertyName, {
configurable: true,
enumerable: true,
get() { return cachedValue; },
set(newVal: unknown) { cachedValue = ensureRaw(newVal); },
});
});
return (initialValue: unknown) => ensureRaw(initialValue);
}执行过程
① { version: '1.0.0' } 求值
② initializer 函数:ensureRaw({...}) → markRaw({...})
③ this.config = markRaw({...}) ← data property,值已 markRaw
④ addInitializer 执行:
- cachedValue = this.config ← 捕获 markRaw({...}) ✓
- Object.defineProperty 替换 data property 为 getter/setter
- get() { return cachedValue } ← 返回 markRaw({...}) ✓
- set(newVal) { cachedValue = ensureRaw(newVal) } ← 后续赋值也 markRaw ✓结果
this.config → markRaw({ version: '1.0.0' }) ✓
关键:addInitializer 通过 this[propertyName] 读取,本质上是在"接收"第一阶段(return 的 initializer)的处理结果,让它在 Object.defineProperty 覆盖之前先被捕获到 cachedValue 里。
两个阶段的职责划分
| 阶段 | 触发时机 | 负责的事 |
|---|---|---|
return (init) => ensureRaw(init) | 字段赋值时(步骤②③) | 确保初始值经过 markRaw |
addInitializer 中的 Object.defineProperty | 字段赋值后(步骤④) | 将 data property 替换为 getter/setter,使后续每次赋值也自动 markRaw |
两个阶段通过 this[propertyName](data property 的 [[Value]])隐式接力。
为何 accessor 没有这个问题
rawAccessorDecorator 使用的是 init 钩子,这是 accessor 装饰器原生支持的初始值转换接口,天然与字段赋值时序对齐,不需要借助 addInitializer:
return {
get() { ... },
set(newVal) { ... },
init: ensureRaw, // ← 在存入私有字段之前转换初始值,无时序问题
};field 没有 init 钩子,所以才需要 return + addInitializer 两步配合。
结论
addInitializer 常被误解为"在字段初始化之前拦截",实际上它是在字段初始化完成之后执行的清理/增强回调。
对于需要同时处理初始值和后续赋值的 field decorator,正确模式是:
- return initializer 函数:处理初始值,使其经过转换后写入 data property
addInitializer读取this[propertyName]:捕获已处理的初始值,再用Object.defineProperty替换 data property 为拦截 setter 的 getter/setter 对