You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

TypeScript装饰器实现Vue响应式时属性重定义报错解决方案

问题根源

你遇到的继承场景属性失效问题,来自三个核心逻辑错误,和JS类字段初始化的规范特性直接相关:

  1. 显性代码笔误
    你在UseVueReactivity实现中写死了从Extendable.prototype读取标记的属性键,这个Extendable对象在代码中从未定义,直接导致子类装饰时读取不到自身标记的响应式属性列表。
  2. 属性标记的数组污染问题
    你在@Ref这类属性装饰器中,判断标记数组是否存在时如果直接用真值判断、没有用hasOwnProperty校验属性是否属于当前类原型,会通过原型链拿到父类原型上的标记数组,把子类的属性键直接push到父类的数组中,既污染了父类的属性列表,也导致子类自身原型上没有专属的标记数组,装饰时无法正确读取子类的响应式属性。
  3. 存取器定义时机晚于类字段初始化(核心原因)
    根据ES2022类字段规范,类的实例字段(比如bar = 20)会在super()调用返回后、子类构造器剩余逻辑执行前,直接在实例上执行赋值操作。你当前的逻辑是在super()执行完成后,才通过defineProperty给实例逐个添加响应式存取器,此时类字段已经完成了第一轮赋值:
    • 如果存取器设置configurable: false,后续类字段赋值尝试重定义属性就会抛出Cannot redefine property错误
    • 如果设置configurable: true,你后续添加的存取器虽然能覆盖实例上的自有属性,但如果属性键收集逻辑出错(比如前两个笔误),就会出现属性初始值丢失、变成undefined的问题

修复方案

按照以下逻辑调整代码即可解决所有继承场景的问题:

  1. 修正属性装饰器逻辑:给每个类的原型初始化专属的标记数组,必须用hasOwnProperty判断数组是否存在于当前原型自身,避免原型链污染
  2. 修正类装饰器的属性收集逻辑:沿着被装饰类的原型链向上遍历,收集所有层级标记的响应式属性,去重后得到完整的处理列表
  3. 把存取器定义从实例构造阶段移到类装饰阶段:直接在装饰返回的新类的原型上定义响应式属性的get/set,不要等实例化时再给实例打补丁。这样类字段初始化执行赋值时,会直接触发原型上的setter,把初始值写入响应式对象,不会出现属性覆盖问题
  4. 用Symbol作为响应式实例的存储键,不要用Ref.xxx这类字符串键,避免和用户自定义属性重名

修正后的核心代码示例
import { ref } from 'vue'
// 存储各类响应式属性标记的Symbol,以及存储实例上响应式对象的Symbol
const refKeysSymbol = Symbol('Ref keys')
const refStoreSymbol = Symbol('Ref store')

// 属性装饰器:标记属性为ref类型
const Ref = (target: object, key: string) => {
  // 必须判断当前类自身原型上有没有标记数组,避免拿到父类的数组造成污染
  if (!Object.prototype.hasOwnProperty.call(target, refKeysSymbol)) {
    // 给当前类原型新建专属的标记数组,不要修改父类的数组
    Object.defineProperty(target, refKeysSymbol, {
      value: [],
      enumerable: false,
      configurable: false
    })
  }
  (target as any)[refKeysSymbol].push(key)
}

// 类装饰器:包装类为响应式类
const UseVueReactivity = <T extends new (...args: any[]) => any>(Original: T) => {
  // 沿原型链收集所有层级标记的ref属性,去重
  const allRefKeys = new Set<string>()
  let proto = Original.prototype
  while (proto) {
    if (Object.prototype.hasOwnProperty.call(proto, refKeysSymbol)) {
      ;(proto as any)[refKeysSymbol].forEach((key: string) => allRefKeys.add(key))
    }
    proto = Object.getPrototypeOf(proto)
  }

  // 创建装饰后的新类
  class ReactifiedClass extends Original {
    constructor(...args: any[]) {
      super(...args)
      // 初始化当前实例的ref存储
      Object.defineProperty(this, refStoreSymbol, {
        value: Object.create(null),
        enumerable: false,
        configurable: false
      })
    }
  }

  // 在原型上为每个ref属性定义存取器,所有实例共享
  for (const key of allRefKeys) {
    Object.defineProperty(ReactifiedClass.prototype, key, {
      get() {
        // 如果ref还没初始化(第一次访问,还没赋值过),先创建初始ref
        if (!this[refStoreSymbol][key]) {
          this[refStoreSymbol][key] = ref(undefined)
        }
        return this[refStoreSymbol][key].value
      },
      set(newVal) {
        if (!this[refStoreSymbol][key]) {
          this[refStoreSymbol][key] = ref(newVal)
        } else {
          this[refStoreSymbol][key].value = newVal
        }
      },
      enumerable: true,
      configurable: true
    })
  }

  return ReactifiedClass as T
}

验证效果

用你之前的继承场景测试:

@UseVueReactivity
class A {
  @Ref
  foo = 10
}

@UseVueReactivity
class B extends A {
  @Ref
  bar = 20
}

const a = new A()
const b = new B()
console.log(a.foo) // 10
console.log(b.foo, b.bar) // 10 20
b.bar++
console.log(b.bar) // 21,无NaN问题

不会再出现属性重定义报错、初始值丢失的问题,多层继承也能正常工作。相关的知识点可以参考MDN上的类字段初始化顺序、Object.defineProperty属性描述符规则、原型链继承相关章节。


内容的提问来源于stack exchange,提问作者vivilor

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 16:36:29