前端开发··2 阅读·预计 9 分钟

TypeScript 类型的运行时退场:从编译期优化到 monomorphic 调用点的性能闭环

引言

TypeScript 常被误解为「只在编译期起作用,运行时零成本」。后半句是对的——类型擦除后不会留下任何运行时检查;但前半句漏了一个关键环节:你写的类型,会反向塑造编译后 JS 的对象形状,而对象形状正是 V8 内联缓存判单态还是多态的依据。于是类型设计顺理成章地成了性能的前置变量。本文拆解这条从类型到引擎的隐秘链路。

一、类型擦除不等于形状无关

TS 把类型删掉了,但删不掉你「怎么构造对象」这件事。一个 interface 不会跑到运行时,可你为了让类型满足它而写的字段赋值顺序,会原封不动地留下来。

// 反例:为了满足类型,在不同分支里以不同顺序赋值
interface User { id: number; name: string; role: string }

function makeUser(isAdmin: boolean): User {
  const u: Partial<User> = {};
  u.name = 'LX';
  if (isAdmin) {
    u.role = 'admin';  // 先 role 后 id
    u.id = 1;
  } else {
    u.id = 2;          // 先 id 后 role
    u.role = 'viewer';
  }
  return u as User;
}

类型检查器满意了,但两个分支产出了两个不同的隐藏类。编译后的 u.id 访问,IC 在两个 Map 间切换。

// 正例:让「类型字段声明顺序」与「运行时赋值顺序」对齐
interface User { id: number; name: string; role: string }

function makeUser(isAdmin: boolean): User {
  const u: User = { id: 0, name: 'LX', role: 'viewer' }; // 一次声明全部字段
  if (isAdmin) { u.id = 1; u.role = 'admin'; }
  return u;
}

对象字面量一次性写全字段,隐藏类在构造瞬间定型,后续赋值只改值不改形状。类型声明在这里成了「形状模板」,帮你把运行时可能的形状分叉提前拍平。

二、联合类型的运行时成本:判别字段决定 IC 命运

联合类型是 TS 最强大的特性,但它的价值在编译期,隐患在运行时——不同成员若形状差异过大,会在消费端制造多态。

// 反例:联合成员缺少统一判别字段,消费处靠类型断言硬转
type Result = { ok: true; data: string } | { ok: false; error: string };

function handle(r: Result) {
  // 运行时其实是安全的,但对象形状完全不同,属性和类都在漂移
  if (r.ok) return r.data;
  return (r as any).error; // 靠 any 绕过,形状也更难被引擎预判
}

{ ok: true; data }{ ok: false; error } 是两个截然不同的隐藏类。消费函数同时处理两种形状,IC 直接多态。

// 正例:用统一的前置判别字段 + 显式结构,让消费点保持形状稳定
type Result =
  | { kind: 'success'; value: string }
  | { kind: 'failure'; message: string };

function handle(r: Result) {
  switch (r.kind) {
    case 'success': return r.value;
    case 'failure': return r.message;
  }
}

kind 作为字面量判别字段,让 TS 收窄更顺,也让每个 case 分支内部的属性访问各自保持单态——这是「可辨识联合」在性能侧的隐藏红利。

三、const 断言:把易变形状锁成常量形状

as const 常被当成「让类型更窄」的工具,但它同时有一个运行时副产物:把对象锁成只读、固定形状,恰好契合 V8 对稳定形状的偏好。

// 反例:枚举式配置被当成可变对象,形状在运行时可被改写
export const COLORS = {
  primary: '#333',
  secondary: '#666',
};
// 类型是 { primary: string; secondary: string },字段可改,形状可被破坏

// 正例:as const 同时定死类型与形状
const COLORS = {
  primary: '#333',
  secondary: '#666',
} as const;
// 类型是 { readonly primary: '#333'; readonly secondary: '#666' }

as const 之后,字段既不能改值也不能新增,隐藏类从「可变容器」退化为「稳定形状」。对高频读取的配置对象,这是零成本的形状优化。

四、类型收窄的「提前」与「延后」

类型收窄的位置,决定了运行时判断发生多少次。把收窄放在循环外还是循环内,性能天差地别,这也直接反映在 IC 的稳定性上。

// 反例:把类型收窄放进热循环里,每次迭代都做一次形状判断
function sum(items: (number | string)[]) {
  let total = 0;
  for (const it of items) {
    if (typeof it === 'number') {  // 每次循环都判断,IC 在多态间抖动
      total += it;
    } else {
      total += Number(it);
    }
  }
  return total;
}
// 正例:在入口一次性收敛类型,循环体只处理单一类型
function sum(items: (number | string)[]): number {
  const nums = items.map((it) => (typeof it === 'number' ? it : Number(it)));
  return nums.reduce((a, b) => a + b, 0);
}

map 成统一的 number[],循环体(reduce)只面对一种类型。类型判断从「每次迭代」降为「一次遍历」,内层 IC 保持单态。

五、类型设计如何写进性能契约

把上面的观察收敛成三条可执行原则,让「类型安全」与「引擎友好」不再对立:

// 正例:性能契约三原则
// 1. 接口字段的声明顺序 = 对象字面量的赋值顺序(形状一次性定型)
// 2. 联合类型必须带统一判别字段(可辨识联合),消费点按 kind 分派
// 3. 类型收窄放在数据入口,热循环只吃单一类型(单态 IC)

// 反模式速查
//   any 断言:绕过类型检查,也摧毁了引擎的形状预判
//   分支内不同赋值顺序:类型通过,隐藏类分叉
//   循环内收窄:类型正确,IC 多态抖动

这五条里,没有一条要求你「为了 V8 牺牲类型安全」。恰恰相反,它们都是更严谨的类型设计的自然结果——严谨的类型,往往也是稳定形状的类型。

结语

TypeScript 的类型确实在运行时消失,但它通过「对象形状」这条暗线,持续影响着 V8 的优化决策。真正的性能红利不在「少写几个类型」,而在让类型的设计本身产出稳定形状:对象一次性定型、联合带判别字段、收窄提前到入口。当你的类型越精确,编译后的对象形状就越稳定,引擎的单态假设就越容易成立。类型系统的价值,最终落在了它从未宣称过的地方——运行时。

0 评论

评论区

登录 后参与评论