TypeScript: MobX 中使用映射类型设计实现对象的响应式代理

Map<K, V> 不是映射类型,它只是个泛型接口。MobX 里真正的映射类型是 makeObservable 的注解参数;而「响应式代理」里的 Proxy 只用在普通对象上,类实例走的是另一条路。

预计
6 分钟

先把一个常见的混淆挡掉。下面这行代码里没有映射类型:

这是一个泛型接口,不是映射类型
private values = new Map<string, ObservableValue<any>>();

Map<K, V>lib.es2015.collection.d.ts 里的一个泛型接口KV 是两个普通的类型参数。它中文叫「映射」,是因为它在数据结构意义上是一张键值表,和 TypeScript 类型系统里的「映射类型」(mapped type)是同名不同物的两件事。

映射类型指的是这个语法 —— 在一个已有类型的键上遍历,为每个键生成一个新属性:

映射类型:在 keyof 上遍历
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 这几种注解之一。

注解参数的键被约束在 keyof T 上
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 是为了拦住「后来才加上去的属性」—— 把一个普通对象当动态查找表用时,事先不知道会有哪些键,只有 Proxyget / 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 还是 stringage 还是 number —— 类型层面几乎没有变化,变的是这些属性背后被换成了 getter/setter。ObservableValue 是 MobX 内部持有的东西,不出现在你的类型里。

MobX 6 起默认不用装饰器

MobX 4/5 时代的写法是 @observable name = 'John',很多旧文章还停在那里。MobX 6 把装饰器从默认路径上拿掉了,改成在构造函数里显式调用 makeObservablemakeAutoObservable

这个改动的动机和 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 和 AOPTypescript:Nest.js 中使用声明文件定义依赖注入的类型