在 production 的 React、Next 与 Hono code 里,class 不是组织 application code 的方式。它是一种 runtime identity:一个 constructor、一个 prototype,以及一个 instanceof 能认出来的 object。
这篇 note 是 JavaScript 核心概念 没有覆盖的 object model。那篇讲的是 code 何时运行。这篇讲的是 class 是什么,以及它何时值得写:instanceof、一份被共享的可变 lifetime,或一个作为单一 object 的 client。否则用 types 与 functions。Application code consume 的 classes 比它写的多。
1. Runtime
TypeScript class 是一个 constructor function、挂在该 constructor 上的 prototype object,以及 new 创建出来的 instances。
class Counter {
count = 0
inc(): number {
this.count += 1
return this.count
}
}
const a = new Counter()
const b = new Counter()
a.inc() // 1
b.inc() // 1 — separate instance fields
a.inc === b.inc // true — one shared method on Counter.prototype
a.inc === Counter.prototype.inc // truenew Counter() → instance -([[Prototype]])→ Counter.prototype -([[Prototype]])→ Object.prototype- 写在 class body 里的 methods 装在 prototype 上。在 constructor 里赋值的 fields,或 instance fields,活在每个 instance 上。
staticmembers 活在 constructor 上。 new创建一个普通 object、把[[Prototype]]设为Class.prototype、以新 object 为this跑 constructor,然后返回它(除非 constructor 返回了另一个 object)。- 不带
new调用 class constructor 会 throw。Class bodies 永远是 strict mode。 - Closure 可以在没有 prototype 的情况下藏起同样可变的
count—— JavaScript 核心概念 就是那样做 counter —— 但它不提供instanceof,也不跨 instances 共享 methods。
2. Chain
extends 接上两条 prototype chains:child 的 instances 在 Child.prototype 上找 methods,然后 Parent.prototype,再然后 Object.prototype。Constructors 以同样方式相连,所以 Child 能看见 Parent 的 static members。
class Animal {
move(): string {
return "moved"
}
}
class Bird extends Animal {
fly(): string {
return "flew"
}
}
const bird = new Bird()
bird instanceof Bird // true
bird instanceof Animal // true
bird.move() // "moved" — found on Animal.prototype
const lookalike = { move: () => "moved", fly: () => "flew" }
lookalike instanceof Bird // falseinstanceof沿着 instance chain 走,找到对的.prototype就返回 true。这是 runtime 测试,不是 TypeScript 测试。这条 walk 会到Object.prototype,所以bird instanceof Object为 true。super()在 child 碰this之前跑 parent constructor。派生 class 里忘了它是 syntax error。- Type-level
extends—— constraint、ternary、infer—— 见 TypeScript Infer、Extends 与 Ternaries。这节讲的是 prototype chain。 noImplicitOverride要求 child method 取代 parent method 时写override。那个 flag 见 TypeScript Beyond Strict。- Failure: target 太旧,compile 之后 prototype chain 就没了。Target ES2015 或更新。长得一样的 plain object 带同样 fields,
instanceof仍会失败。
3. this
Prototype method 里的 this 是 method 被 调用时所在 的 object,不是 function 被定义时所在的 object。把 method 抽出来,binding 就没了。
class Button {
label = "save"
onClick(): string {
return this.label
}
onClickBound = (): string => this.label
}
const button = new Button()
button.onClick() // "save"
button.onClickBound() // "save"
const { onClick, onClickBound } = button
onClickBound() // "save" — arrow captured the instance
onClick() // TypeError: Cannot read properties of undefined- Class bodies 是 strict,所以丢失的
this是undefined,不是 global object。 - 把
button.onClick传给addEventListener或 ReactonClickprop 时,同样会丢。.bind(button)、arrow field,或 wrapper() => button.onClick()都能修好。 - Arrow fields 在 construction 时 close over instance。它们不上 prototype;每个 instance 拿到自己的 function。
- Failure: 目标是一份共享的 prototype function,却写成 arrow field。Prototype method 更便宜去共享。Arrow field 更便宜去传来传去。
4. Types vs Values
class 声明创造出两样东西:给 new 与 instanceof 用的 value,以及给 annotations 用的 type。interface 或 type alias 只创造 type。它会被 erased。
interface Clock {
now(): number
}
class SystemClock implements Clock {
now(): number {
return Date.now()
}
}
class Secret {
private compileTime = "visible after emit"
#runtime = "hidden"
reveal(): string {
return this.#runtime
}
}
const clock: Clock = new SystemClock()
clock instanceof SystemClock // true
// clock instanceof Clock // not a value; does not exist at runtime
const secret = new Secret()
secret.reveal() // "hidden"
// secret.compileTime // type error; still a property on the object at runtime
// secret.#runtime // syntax error in JavaScript tooimplements是 compile-time 检查,确认 instance 形状吻合。它不改变 runtime object。- TypeScript
private与protected是同一种检查:compiler 拒绝obj.secret,然后把 keyword erase 掉。#fields 是 JavaScript,emit 之后仍然 private。 - Constructor parameter properties(
constructor(private name: string))与enum会 emit values。erasableSyntaxOnly禁止它们,好让 types 能干净地 strip。那个 flag 见 TypeScript Beyond Strict。 - 如果检查必须在
catchblock、JSON.parse之后,或跨 bundle boundary 仍能工作,它必须是 value:instanceof、discriminant field(type: "error"),或 branded function。 - Failure: 对一个 runtime 从不存在的 interface 做
instanceof。
5. Error Subclasses
Application code 里出现的 class,几乎总是 Error subclass。catch 得到的是 unknown。instanceof 把 domain failure 与 programmer error 分开,也把一种 domain failure 与另一种分开。
class AppError extends Error {
constructor(
readonly code: string,
message: string,
options?: ErrorOptions
) {
super(message, options)
this.name = "AppError"
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super("not_found", `${resource} ${id} not found`)
this.name = "NotFoundError"
}
}
try {
throw new NotFoundError("user", "missing")
} catch (error) {
if (error instanceof NotFoundError) error.code // "not_found"
else if (!(error instanceof AppError)) throw error
}- 额外 fields ——
code、HTTP status、cause—— 骑在 instance 上。Plain object{ message, code }被 throw 之后做不了那个测试。 - Failure 是 return value 时,result type 上的 string union 往往更好。那一半见 TypeScript Error Handling。
- Failure: throw 一个 plain object。一旦被
throw,能活下来的测试是 prototype chain。
6. Stateful Clients and Stores
当 一个 object 必须持有可变 lifetime 时,class 才值得写:token、abort controller、connection、in-memory cache。Methods 共享那份 state。Callers 共享那个 object。
class ApiClient {
private token: string | null = null
constructor(private readonly baseUrl: string) {}
setToken(token: string): void {
this.token = token
}
async get<T>(path: string): Promise<T> {
const headers: HeadersInit = this.token
? { Authorization: `Bearer ${this.token}` }
: {}
const response = await fetch(`${this.baseUrl}${path}`, { headers })
if (!response.ok) throw new AppError("http", `${response.status} ${path}`)
return response.json() as Promise<T>
}
}
const api = new ApiClient("https://api.example.com")
api.setToken("secret")- 这不是 React tree。Client 不 render。它与 SDK client 同一形状:construct 一次、传来传去、调用 methods、让 fields 就地改变。
- Module-level closure 也能持有
token。当 callers 需要 construct 不止一个 —— 第二个 base URL、一个 test double、每个 tenant 一个 client —— 或需要instanceof ApiClient时,class 才合适。 - 永远只会有一个、而且用不到 prototype 时,closure 合适。
7. Classes Are Consumed More Than They Are Written
TypeScript 程序里的大多数 classes 并不是在那个程序里写的。Error、Map、Set、Date、Response、URL、EventTarget、DOM nodes、SDK clients。对着那些 types 做 instanceof 是普通 control flow。写一个对得上的 class 则不是。
- Host 与 standard library 已经在 identity 与
instanceof重要的地方选了 classes:JSON.parse之后的error instanceof SyntaxError,event.currentTarget instanceof HTMLButtonElement。 - Application code 在边界继承这个选择 ——
catch、DOM events、fetch。 - Failure: 为明天就会变成 JSON 的 data 发明一套平行 hierarchy。
8. What Not to Class
React components、API payloads 与 Hono handlers 不需要 runtime identity。它们需要 type 与 function。
type User = {
id: string
name: string
}
function displayName(user: User): string {
return user.name
}
const user = JSON.parse('{"id":"1","name":"Ada"}') as User
displayName(user) // "Ada"- React class components 把 state 存在
this上,并亲手 rebind event handlers。Hooks 把那份 state 挪到 Fiber。这个 stack 里的新 UI 是 function。Rendering model 见 深入理解 React。 - 跨网络的 DTO 是 type。
JSON.parse返回 plain object。挂在class User上的 methods 不会在,instanceof User会是 false。 - Hono handler 是从 context 到 response 的 function。Nest 风格的 controller classes 属于另一套 stack。用 Hono 打造 Backend APIs 把 routes 保持为 functions。
- Failure: 为明天就会变成 JSON 的 data 发明一套 class hierarchy。
Takeaway
TypeScript class 是 constructor 加上 prototype。Instance fields 活在 object 上。Methods 活在 prototype 上。interface 与 TypeScript private 会被 erased。# 与 instanceof 不会。
当问题是要不要写一个时:
- 这是否需要在
throw之后或跨 boundary 做instanceof? - 是否有一个 object 需要 callers 共享的可变 lifetime?
- 还是这只是 data 与一个 function? 第三种情况不需要 class。