TypeScript: MobX 中使用映射类型设计实现对象的响应式代理
Map<K, V> 不是映射类型,它只是个泛型接口。MobX 里真正的映射类型是 makeObservable 的注解参数;而「响应式代理」里的 Proxy 只用在普通对象上,类实例走的是另一条路。
先把一个常见的混淆挡掉。下面这行代码里没有映射类型:
private values = new Map<string, ObservableValue<any>>();Map<K, V> 是 lib.es2015.collection.d.ts 里的一个泛型接口,K 和 V 是两个普通的类型参数。它中文叫「映射」,是因为它在数据结构意义上是一张键值表,和 TypeScript 类型系统里的「映射类型」(mapped type)是同名不同物的两件事。
映射类型指的是这个语法 —— 在一个已有类型的键上遍历,为每个键生成一个新属性:
type AnnotationsMap<T> = { [P in Exclude<keyof T, 'toString'>]?: Annotation;};差别不是术语洁癖,是能力上的差别。映射类型知道 T 上到底有哪些键,Map<string, V> 不知道:
class Person { constructor(public name: string, public age: number) {} }
const ok: AnnotationsMap<Person> = { name: 'observable', age: 'observable' };const typo: AnnotationsMap<Person> = { nmae: 'observable' };// error TS2353: Object literal may only specify known properties,// and 'nmae' does not exist in type 'AnnotationsMap<Person>'
const values = new Map<string, unknown>();values.set('nmae', 1); // 键类型只是 string,拼成什么样都合法Map 的键类型是 string,和 Person 有哪些属性毫无关系。所以 MobX 内部那张存 ObservableValue 的表,虽然确实是响应式系统的核心数据结构,但它在类型层面提供不了任何按属性名的检查 —— 那部分工作是另一个类型做的。
MobX 里真正的映射类型在注解参数上
makeObservable 的第二个参数用来逐个成员指定注解,它的类型就是一个映射类型,形状大致是 AnnotationsMap<T, AdditionalKeys>:在 keyof T 上遍历,每个键可选,值是 observable / computed / action / false 这几种注解之一。
import { makeObservable, observable, computed, action } from 'mobx';
class Person { name = 'John'; age = 30;
constructor() { makeObservable(this, { name: observable, age: observable, label: computed, grow: action, // nmae: observable, ← 写错一个字母,这里就编译不过 }); }
get label() { return `${this.name} (${this.age})`; } grow() { this.age++; }}这是映射类型在这里的全部价值:注解表的键和类的成员名被绑死了。改名字忘了同步、拼错一个字母,编译期就报出来,而不是等到运行时发现某个字段没有响应式。
那个 Exclude<keyof T, 'toString'> 也有具体来由 —— toString 是每个对象都从原型上继承来的方法,如果不排掉,它会出现在注解表的候选键里,徒增噪音。
类实例不会被 Proxy 包起来
「响应式代理」这个说法容易让人以为 MobX 一律用 Proxy。实际上分两条路,而且分界线是普通对象还是类实例:
| 入口 | 做了什么 | 结果 |
|---|---|---|
observable({ name, age }) |
用 Proxy 包一层 |
返回一个新对象,能拦截到后加的属性 |
makeObservable(this, {…}) |
在实例自身上 defineProperty |
返回原对象,属性集合在调用时就定死 |
makeAutoObservable(this) |
同上,注解由 MobX 自己推断 | 返回原对象 |
用 Proxy 是为了拦住「后来才加上去的属性」—— 把一个普通对象当动态查找表用时,事先不知道会有哪些键,只有 Proxy 的 get / set 陷阱接得住。类实例没有这个需求:成员在类定义里写死了,逐个 defineProperty 装上 getter/setter 就够,还省掉一层代理开销。
这条区分有两个直接后果,都是实际会踩到的:
makeObservable之后类实例上新加的属性不是响应式的。person.nickname = 'J'这种运行时加字段,defineProperty那条路没有任何机会介入。要动态键就得显式用observable.map。- 类实例不会因为被塞进可观察对象就变成可观察的。 MobX 的文档把这件事说得很直接:类实例永远不会被自动转成 observable,让类的成员可观察是构造函数自己的责任。所以
observable({ user: new Person() })里那个Person实例仍然是死的,除非它自己在构造函数里调了makeObservable。
顺带纠正一个形状上的误解:makeAutoObservable(new Person('John', 30)) 不会产出一个「每个属性都被包成 ObservableValue 的新类型」。它返回的就是你传进去的那个实例,name 还是 string,age 还是 number —— 类型层面几乎没有变化,变的是这些属性背后被换成了 getter/setter。ObservableValue 是 MobX 内部持有的东西,不出现在你的类型里。
MobX 6 起默认不用装饰器
MobX 4/5 时代的写法是 @observable name = 'John',很多旧文章还停在那里。MobX 6 把装饰器从默认路径上拿掉了,改成在构造函数里显式调用 makeObservable 或 makeAutoObservable。
这个改动的动机和 TypeScript 的装饰器现状直接相关:装饰器提案至今没有进入任何一版 ECMAScript,各家实现互不兼容,把一个库的核心 API 建在上面意味着用户必须先把构建配置调对。改成普通函数调用之后,MobX 不再要求任何编译器开关。
装饰器仍然可以用,但它现在是可选语法糖,而且用了也仍然要在构造函数里调一次 makeObservable(this) —— 装饰器只负责登记注解,真正的改造还是那个函数干的。
makeAutoObservable 是省事的那个:不用列注解,MobX 自己按成员类型推断(普通字段 observable、getter computed、方法 action)。代价是它有使用限制,最明显的一条是不能用在有父类或者会被继承的类上 —— 继承链上的成员它推不明白,这种情况只能退回 makeObservable 逐个写。
选型上的横向比较可以看Redux、MobX、Recoil、Zustand、Jotai 对比那篇,这里只说类型这一层。
代价
- 注解表要手工维护。 映射类型能保证「写进去的键存在于类上」,但保证不了「类上的每个成员都写进了注解表」—— 新加一个字段忘了登记,编译器不会提醒,那个字段只是安静地不响应。
makeAutoObservable用推断换掉这份负担,换来的是推断错时更难查。 - 两条路的行为不一致,而类型看不出来。 同样叫「可观察对象」,
observable({})的产物能追踪新增属性,makeObservable(this)的不能,两者在 TypeScript 里的类型却看不出区别。这类差异只能靠记。 Proxy那条路会换掉对象标识。observable(obj)返回的是代理,proxy === obj为假。把原对象和代理混着用,会出现「改了没反应」这类很难查的问题。- 类型层面几乎没有反馈。 MobX 的响应式基本发生在运行时,TypeScript 能帮上忙的只有注解表的键名这一处。指望类型系统告诉你「这个值有没有被追踪」是不现实的。
这篇归在 TypeScript 下。同一主题里最近的另外两篇是 TypeScript:从架构分层设计到 IOC 和 AOP 和 Typescript:Nest.js 中使用声明文件定义依赖注入的类型。