Skip to content

@Raw 装饰器 field 实现方案分析

背景

@Raw 装饰器用于标记类字段,使其值始终保持 markRaw(不被 Vue 响应式系统代理)。 在实现 rawFieldDecorator 时,先后经历了三个方案,每个方案都踩到了同一个坑。


关键前提:field decorator 的执行顺序

这是理解所有问题的基础。TC39 Stage 3 规范中,对于每个字段,初始化顺序如下

① 求值初始值表达式(如 = { version: '1.0.0' })
② 若 decorator 返回了 initializer 函数,将初始值传入该函数,结果作为新初始值
③ 将最终初始值赋给字段(写入 data property,存于 [[Value]])
④ 执行该字段上 addInitializer 注册的所有回调

核心误解addInitializer 不是在字段赋值"之前"运行的拦截钩子,而是在赋值"完成之后"才执行。


方案一:只用 addInitializer

ts
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.configundefined

根本原因:以为 addInitializer 会在赋值之前执行,从而"拦截" setter。实际上它在赋值之后执行,直接把已有的 { version: '1.0.0' } 覆盖掉了。


方案二:addInitializer + 返回 ensureRaw

ts
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.configundefined

根本原因:返回的 initializer 函数正确处理了初始值(步骤②③),但 addInitializer(步骤④)又用一个 cachedValue = undefined 的空 getter/setter 把 data property 覆盖掉了。两个阶段各自独立执行,没有"接力"。


方案三:addInitializer 读取 this[propertyName] + 返回 ensureRaw(当前实现)

ts
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.configmarkRaw({ 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

ts
return {
  get() { ... },
  set(newVal) { ... },
  init: ensureRaw,   // ← 在存入私有字段之前转换初始值,无时序问题
};

field 没有 init 钩子,所以才需要 return + addInitializer 两步配合。


结论

addInitializer 常被误解为"在字段初始化之前拦截",实际上它是在字段初始化完成之后执行的清理/增强回调。

对于需要同时处理初始值和后续赋值的 field decorator,正确模式是:

  1. return initializer 函数:处理初始值,使其经过转换后写入 data property
  2. addInitializer 读取 this[propertyName]:捕获已处理的初始值,再用 Object.defineProperty 替换 data property 为拦截 setter 的 getter/setter 对