TS 谱系与 tsconfig

发布于

有人先搜「C# 的诞生和参考的谁」,再问 TypeScript 怎么走到今天——这条线其实是同一条:Anders Hejlsberg。

C# 对标 Java、继承 C 家族语法、塞进 Delphi 的工程基因;十多年后,同一个人在微软把「给 JavaScript 加类型」做成 TypeScript。语言本身没变魔术,变的是前端工程:从无标准模块、到 AMD/CJS、再到 ESM 与 esbuild/Vite。旧时代的 namespace、enum、三斜杠指令,以及 export {} 和 declare global 的沙雕组合,都不是语法糖笑话——它们是模块边界、类型作用域、打包器与编辑器之间,长期左右脑互搏后留下的防御性疤痕。

读者背景

流畅阅读本文,至少需要:

若对「脚本文件 vs 模块文件」几乎没概念,先记住一句:有没有顶层 import/export,决定这个文件是不是模块——后文会反复用到。

Anders 的一条线

TypeScript 不是从「前端社区投票」里长出来的,而是从一位语言设计师的连续作品里长出来的。

Anders Hejlsberg 的履历几乎是一本迷你语言史:Turbo Pascal → Delphi → C# → TypeScript。搜 C# 的诞生,答案通常绕不开三点:

  1. 商业对手是 Java:语法气质、GC、CLR 对标 JVM,早期常被戏称「微软版 Java」。
  2. 语法根基是 C/C++:大括号、表达式、控制流;名字里的 # 也被解读成比 ++ 再进一步。
  3. 工程基因来自 Delphi:属性、事件、组件化——设计师个人经验直接写进语言。

2012 年前后,微软面对的不是「再做一个 JVM」,而是:浏览器里的 JavaScript 已经承载大型应用,却几乎没有可扩展的静态结构。TypeScript 的目标很克制:在 JS 之上加一层可擦除的类型系统,编译产物仍是可读的 JavaScript,尽量不发明第二套运行时。

所以读 TS 时,偶尔会闻到 C# 的气味——enum、namespace、装饰器争论、声明合并——不全是偶然。它们是同一套「大型工程语言」直觉,被硬塞进一门天生没有模块标准、以脚本拼接为荣的宿主语言里。

无标准模块的年代

TS 早期要解决的,不只是「类型」,还有「如何在没有官方模块的 JS 里组织代码」。

2010 年代初期的前端日常大致是:

那时还没有「文件即模块」的语言级共识。TypeScript 给出的组织方案,带着明显的桌面/.NET 口音:

在「全靠 script 拼接」的世界里,这套东西是有用的。问题是:ES2015 把 import/export 写进语言标准之后,游戏规则变了。

ESM 成为正统之后

一旦文件因 import/export 变成模块,文件本身就是隔离边界——namespace 的主业被抢走了。

ES Module 的核心约定很硬:

于是出现了现在教科书式的劝退:

旧工具 现代替代 劝退理由(工程向)
namespace ESM import/export 与文件模块边界重复;打包器、tree-shaking、跨包类型解析都以 ESM 为中心
内部 module 别名 同上 历史命名,已并入 namespace 叙事
数字/const enum as const 对象、联合类型、字面量 运行期残留、const enum 跨边界易碎、和 isolatedModules 冲突

这不是「语法难看」,而是工具链的引力中心换了。社区、打包器、Node、浏览器、CDN 都押 ESM;TypeScript 若继续鼓励第二套模块哲学,整条生态会精神分裂。

官方文档如今也明确:优先 modules,避免 namespaces(声明文件、刻意的全局 API 包装等少数场景除外)。

为什么 enum 也被劝退

enum 看起来像类型特性,实际常常是运行时对象——和「类型可擦除」的现代前端理想打架。

几个现实摩擦点:

  1. 不是纯类型:普通 enum 会生成反向映射对象;打包体积与语义都「比看起来重」。
  2. const enum 更危险:内联依赖整个项目如何编译;在 isolatedModules(esbuild/SWC/Vite 默认假设)下要么禁用,要么行为与 tsc 全量编译不一致。
  3. 可替代且更 ESM 友好:
const Status = {
  Idle: "idle",
  Loading: "loading",
  Error: "error",
} as const;

type Status = (typeof Status)[keyof typeof Status];

联合类型 + as const 对象,类型在编译期消失,值是普通对象字面量,打包器最熟。

劝退 enum,本质是劝退「再维护一套半编译期、半运行期的平行世界」。

打包器改写了 TS 的身份

现代前端里,TypeScript 越来越像「编辑器与 CI 的类型层」,而不是「唯一编译器」。

Vite 的典型路径:

这对语言的冲击是结构性的:

  1. 单一真相源碎裂:tsc 的世界(project references、const enum、namespace 合并、某些 emit 语义)≠ bundler 的世界(逐文件转译、isolated modules)。
  2. module / moduleResolution 变成政治问题:node、node16、nodenext、bundler——选错就像左右脑同时开车。
  3. 类型与运行时路径必须分治:paths 映射在 tsc 里成立,不代表 Vite 不配 resolve.alias 就能跑;反过来 alias 配了,类型还可能红着。
  4. 声明文件成为边界契约:.d.ts、export type、verbatimModuleSyntax——都在逼你诚实地区分 type-only 与 value。

TypeScript 没死;它从「JS 的超集编译器」变成了「大型前端工程的类型操作系统」。语言特性是否「推荐」,越来越取决于能否在 isolated + ESM + bundler 三角里活下来。

三斜杠为何还在

Vite 模板里仍常见 /// <reference types="vite/client" />——这不是怀旧,是类型入口。

三斜杠指令有几类,现代用法大致只剩「有用的那一类」:

/// <reference types="vite/client" />

它在说:把 vite/client 这份类型包拉进当前编译上下文。于是你才能在模块里写:

import.meta.env.VITE_API_URL;
// 以及 *.svg?raw、import.meta.hot 等

为什么不用普通 import?

对照旧时代的:

/// <reference path="./other.ts" />

path 版是在无模块/弱模块年代拼编译图;今天应用代码里再这么干,通常是在对抗 ESM,而不是拥抱它。

还在用的是 types 引用;该退役的是用 path 手工串源文件。 Vite 把它留在脚手架里,是因为「客户端环境类型」仍然需要一个稳定的、非运行时的挂载点。

tsconfig 的大坑

tsconfig.json 看起来是开关表,实际是好几套互相打架的世界观的压缩包。

module 与 moduleResolution

module: "ESNext" 配 moduleResolution: "node" 曾能糊弄过关;在 Node 原生 ESM、package exports、TS 5.x 之后,糊弄成本指数上升。

常见现代前端组合(示意,不是唯一真理):

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2022",
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  }
}

你在告诉 TS:「类型按打包器语义解析;不要假装自己是 Node 的 emit 管线。」

若这是一个 Node 库要双发布 CJS/ESM,世界观又换成 nodenext——同一把语言,两套宪法。

paths 不是运行时

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

这只保证 类型检查与编辑器跳转。Vite 还要:

// vite.config.ts
export default defineConfig({
  resolve: {
    alias: { "@": path.resolve(__dirname, "src") },
  },
});

忘一边,就会出现「IDE 全绿、运行时报错」或反过来。左右脑互搏的第一现场,往往就在这里。

include / exclude / references

monorepo 里一份根 tsconfig 吞天下,和「每包一份 + project references」是两种治理术。前者爽在开头,疼在边界;后者烦在开头,稳在规模。没有免费午餐。

skipLibCheck 的交易

打开它,CI 更快,依赖的 .d.ts 冲突被无视。这是防御性加速,也是把地雷往后埋。大型仓库几乎人人开,但要清楚自己买的是什么。

export {} 与 declare global

最像沙雕、却最常见的组合,往往长这样:

declare global {
  interface Window {
    __APP_CONFIG__?: { apiBase: string };
  }
}

export {};

(export {} 可在文件头或文件尾;关键是「出现任意 export,文件即模块」。)

表面逻辑有多蠢

「我什么都不导出,却写了 export {}。」
「我明明在模块里,却要 declare global 才能改 Window。」

这读起来像语言在自嘲。

真实逻辑是什么

关键是 脚本 vs 模块 的作用域规则:

  1. 无 import/export 的 .ts 默认是脚本
    顶层的 interface Window 会进入全局,可能和别的脚本文件「声明合并」——在老项目里像魔法,在多包工程里像污染。

  2. 有任一 import/export(包括空的 export {})→ 文件变成模块
    顶层声明变成模块私有。你不再意外污染全局——但你也失去了直接改全局的资格。

  3. 所以要改全局,必须显式穿越边界
    declare global { ... } 的含义是:我人在模块里,但这段声明请并入全局作用域。

export {} 是开关:强制「本文件按模块处理」。
declare global 是闸门:在模块世界里,受控地打回全局。

两者同时出现,不是作者脑子进水,而是:

我要模块的隔离,又要合法地扩展全局类型。

为什么现代工程还要扩展全局

因为现实里总有「不属于任何一个 npm 包、却谁都要读」的东西:

ESM 把默认全局污染堵死了;类型系统就需要一条显式、可审查、可搜索的回流通道。declare global 就是这条通道。export {} 则保证:「这个 .d.ts / ambient 文件自己不要再变回脚本,和别的脚本文件发生隐式合并事故。」

防御性编程的读法

把它当成工程契约,而不是谜语:

  1. 默认模块化:能 export {} 就不要依赖「无导出脚本」的隐式全局合并。
  2. 全局扩展要集中:例如 src/types/global.d.ts,避免每个业务文件随手 declare global。
  3. 值与类型分开:运行时赋值 window.__APP_CONFIG__ = ... 是值;.d.ts 只描述形状。不要指望声明会变出值。
  4. 能不用全局就不用:优先 import.meta.env、依赖注入、显式参数。全局是边界上的逃生舱,不是客厅。
  5. 和 bundler 对齐:全局类型改了,确认没有另一份 env.d.ts / 三斜杠 types 在抢同一块形状。

复杂仓库里,这类写法难看,但比「二十个脚本文件顶层乱合并」可推理得多。丑陋常常是防御成功后的外观。

一条时间线

把前面串成一条可记忆的链:

  1. Anders:Pascal/Delphi 工程观 → C#(对标 Java、C 家族语法)→ TypeScript(给 JS 加可擦除类型)。
  2. 无标准模块:namespace + 三斜杠 path + enum 组织大型前端。
  3. ESM 正统化:文件即模块;namespace/enum 失去主场。
  4. 打包器时代:TS 主责类型;emit 交给 esbuild/Vite;isolatedModules 成为宪法条款。
  5. 残留工具:reference types 仍用于环境类型;export {} + declare global 用于模块世界里的受控全局。
  6. tsconfig:同时服务编辑器、tsc CI、bundler 解析——左右脑互搏是常态,配置的目标是把冲突写清楚,而不是假装只有一个大脑。

结语

TypeScript 今天的别扭,很多不是设计失败,而是宿主语言与工程范式换代后,旧器官还没完全凋亡。

C# 那条谱系解释了它为何曾热爱 namespace 与 enum;前端模块史解释了它们为何被劝退;Vite 解释了三斜杠为何还活着一截;export {} 与 declare global 则提醒我们:模块隔离与全局扩展的张力不会消失,只能被写成更显式的契约。

下次再嫌 export {} 沙雕,可以把它读成一句工程口令——「此文件是模块;若要碰全局,请走闸门。」 口令丑陋,闸门必要。

评论

可对整篇留言,或划选正文中的句子后评论该段。

登录后即可发表评论。

  • 还没有公开评论。
↑