TL;DR
使用 Class Fields 定义的类字段会作为新的属性绑定到类的实例上,即使是派生的类。
class A {
field;
}
Object.defineProperty(A.prototype, 'field', {
get() { return 'get field'; },
set(v) { console.log(`set field: ${v}`); },
enumerable: true,
});
class B extends A {}
const instance = new B();
console.log(instance.hasOwnProperty('field')); // true
console.log(Object.getOwnPropertyDescriptor(instance, 'field') === Object.getOwnPropertyDescriptor(A.prototype, 'field')); // false
console.log(instance.field); // undefined
delete instance.field;
console.log(instance.field); // get field
JAVASCRIPT问题处理过程
背景
项目中和其他模块做联调,他们用 experimental decorators 实现了一个 @Trace 装饰器,用于代理对象上某个字段的访问和修改,大致实现如下:
const Trace = (target: object, key: string) => {
const innerKey = `__inner_${key}`;
target[innerKey] = target[key];
Object.defineProperty(target, key, {
get() { return this[innerKey]; },
set(v) {
console.log(`set value to: ${v}`);
// other effects
this[innerKey] = v;
},
enumerable: true,
});
}
[trace.docorator.ts] TYPESCRIPT我侧使用这里提供的 @Trace 装饰器调试时,发现属性的改变根本没有走到上面的 set 方法中,代码实现长这样:
class Pos {
@Trace x: number;
@Trace y: number;
constructor({x, y}: {x: number; y: number}) {
// 这里预期是要打印 set value to XXX,但实际上没有
this.x = x;
this.y = y;
}
}
export default { Pos };
[pos.ts] TYPESCRIPT测试用的代码:
const instance = new Pos({
x: 640,
y: 320,
});
instance.x = 1024; // 这里预期是要打印 set value to 1024,但实际上也没有
[pos.spec.ts] TYPESCRIPT但是对方说他们已经充分验证过,且已经有部分功能用上了这个能力,没有发现问题,最后两个领域一起拉在线会议分析。
怀疑点1:某个流程中改变了类 Pos 的原型
项目中实际的实现代码比刚才展示的复杂得多,且 Pos 还被其他类装饰器装饰,最先想到的是 Pos 在某个流程中原型被改变了。
验证起来很简单,在实例上观察是否存在 __inner_x 或 __inner_y 这两属性即可验证原型是否被改变:
const instance = new Pos({
x: 640,
y: 320,
});
console.log('__inner_x' in instance); // true
console.log('__inner_y' in instance); // true
TYPESCRIPT发现 Pos 实例上能拿到 @Trace 装饰后添加的属性,原型链未改变。
怀疑点2:原型上的属性被重定义
之后想到 Pos 原型上的属性在某个流程被重新定义,例如:
class Pos {
@Trace x: number;
// xxx
}
Object.defineProperty(Pos.prototype, 'x', {
// xxx
});
TYPESCRIPT但是 @Trace 装饰器实现中未将属性定义为 configurable: true ,其不能被二次定义[1],也排除了可能性。
怀疑点3:实例上有同名的自有属性
最后想到可能实例上有同名的自由属性,导致 instance.x 走不到原型上的 getter 和 setter。
const instance = new Pos({
x: 640,
y: 320,
});
console.log(instance.hasOwnProperty('x')); // true
console.log(Object.getOwnPropertyDescriptor(instance, 'x')); // {value: undefiend, writable: true, enumerable: true, configurable: true}
TYPESCRIPT由此可见实例上的 x 已经被重新定义,而非原型上的属性了,那么为什么会有这种情况呢?
原因
上面虽然找到了实现上的原因,但是并没有找到问题根因,也不能解释为什么对方自己验证是可用的,经过一番定位,最终我们将问题锁定到 typescript 编译产物上。
下面是项目中 typescript 编译选项配置文件 tsconfig.json 的一个简化版本:
{
"files": ["test.ts"],
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"noEmitOnError": true,
"experimentalDecorators": true
}
}
[tsconfig.json] JSON当我们在项目根目录下运行 tsc 编译命令时,会生成 pos.js 这个编译后的文件,由于我们指定了 target: "esnext" ,typescript 的编译产物会尽可能地使用 EcmaScript 的新特性。
问题场景我们的编译产物如下:
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
class Pos {
x;
y;
constructor({ x, y }) {
this.x = x;
this.y = y;
}
}
__decorate([
Trace
], Pos.prototype, "x", void 0);
__decorate([
Trace
], Pos.prototype, "y", void 0);
export default { Pos };
[pos.js with target:"esnext"] JAVASCRIPT当换用将 tsconfig.json 中的 target 配置替换为 es2021 及以下版本时运行 tsc 编译时,生成的文件代码有一些差别:
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
class Pos {
constructor({ x, y }) {
this.x = x;
this.y = y;
}
}
__decorate([
Trace
], Pos.prototype, "x", void 0);
__decorate([
Trace
], Pos.prototype, "y", void 0);
export default { Pos };
[pos.js with target:"es2021"] JAVASCRIPT可以明显看到,使用 esnext 编译的代码中包含 x、y 的类字段定义,而 es2021 及以下版本没有。由于对方验证时用的较早的 es6,而我侧调试时用的最新的 esnext,正是这个差异导致了问题发生。
EcmaScript Class Fields (类字段)
有关类字段声明 [[set]] 和 [[define]] 语义的争论由来已久[2],但最终各个编译/转译工具都拥抱了 EcmaScript 标准的 class-fields 提案。
简单来讲,现有标准下类中声明的公有实例字段存在于类的每个已创建实例中。通过声明公共字段,可以确保该字段始终存在,而且类的定义也更加自文档化(self-documenting)[3]。
引用 mdn 上的例子:
const PREFIX = "prefix";
class ClassWithField {
field;
fieldWithInitializer = "实例字段";
[`${PREFIX}Field`] = "带前缀字段";
}
const instance = new ClassWithField();
console.log(Object.hasOwn(instance, "field")); // true
console.log(instance.field); // undefined
console.log(instance.fieldWithInitializer); // "实例字段"
console.log(instance.prefixField); // "带前缀字段"
JAVASCRIPT类中定义的字段相当于用 Object.defineProperty(this, 'xxx', { // xxx }) 在实例上创建了这个属性。之后访问这个字段时,首先拿到的是实例上定义的自有属性。
从这点可以看出 typescript 的 experimentalDecorators 与新的 EcmaScript 标准并不适合,类属性装饰器没有影响类实例属性的能力,难以达到开发者的预期效果。从属性装饰器的实现不难看出:
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
const SomeLegacyPropertyDecorator: PropertyDecorator = (target: object, key: string) => {
// 传入参数是类的 prototype 和属性名
// 如何在这里影响类实例的属性?🤔
}
TYPESCRIPT如果要实现上文中 @Trace 装饰器想要的效果,我们可能得搭配上一个新的类装饰器,并在构造器中主动删除掉指定名称的装饰器。但是原始类中构造器中对属性的赋值仍然无法被追踪,因为派生类的 super() 方法必须在构造器的顶部。
const TraceableClass = <T extends new (...args: any[]) => any>(target: T) => {
return class extends target {
constructor(...args: any[]) {
super(...args);
delete this['x'];
delete this['y'];
}
};
};
const Trace = (target: object, key: string) => {
const innerKey = `__inner_${key}`;
target[innerKey] = target[key];
Object.defineProperty(target, key, {
get() {
return this[innerKey];
},
set(v) {
console.log(`set value to: ${v}`);
// other effects
this[innerKey] = v;
},
enumerable: true,
});
};
@TraceableClass
class Pos {
@Trace x: number;
@Trace y: number;
constructor({ x, y }: { x: number; y: number }) {
// 这里仍然不会打印 set value to XXX 😢
this.x = x;
this.y = y;
}
}
const instance = new Pos({
x: 0,
y: 0,
});
instance.x = 1; // 这里会打印 set value to 1 🎉
TYPESCRIPT结语
在使用 typescript 开发的时候,需要注意 Class Fields 特性在不同构建 target 下带来的编译产物差异,周期性关注某个语言 Changelog 和特性说明可以防止踩不少坑。
是时候拥抱 EcmaScript 标准的装饰器了!
对于开发者来说,全部使用最新语言标准开发不一定是好事,但坚持在过时的技术和实现上一定不是好事。