有人先搜「C# 的诞生和参考的谁」,再问 TypeScript 怎么走到今天——这条线其实是同一条:Anders Hejlsberg。
C# 对标 Java、继承 C 家族语法、塞进 Delphi 的工程基因;十多年后,同一个人在微软把「给 JavaScript 加类型」做成 TypeScript。语言本身没变魔术,变的是前端工程:从无标准模块、到 AMD/CJS、再到 ESM 与 esbuild/Vite。旧时代的 namespace、enum、三斜杠指令,以及 export {} 和 declare global 的沙雕组合,都不是语法糖笑话——它们是模块边界、类型作用域、打包器与编辑器之间,长期左右脑互搏后留下的防御性疤痕。
读者背景
流畅阅读本文,至少需要:
- 写过一段时间 TypeScript,碰过
tsconfig.json - 知道
import/export是模块边界 - 用过 Vite(或同类:esbuild / webpack / Rolldown)打过前端包
若对「脚本文件 vs 模块文件」几乎没概念,先记住一句:有没有顶层 import/export,决定这个文件是不是模块——后文会反复用到。
Anders 的一条线
TypeScript 不是从「前端社区投票」里长出来的,而是从一位语言设计师的连续作品里长出来的。
Anders Hejlsberg 的履历几乎是一本迷你语言史:Turbo Pascal → Delphi → C# → TypeScript。搜 C# 的诞生,答案通常绕不开三点:
- 商业对手是 Java:语法气质、GC、CLR 对标 JVM,早期常被戏称「微软版 Java」。
- 语法根基是 C/C++:大括号、表达式、控制流;名字里的
#也被解读成比++再进一步。 - 工程基因来自 Delphi:属性、事件、组件化——设计师个人经验直接写进语言。
2012 年前后,微软面对的不是「再做一个 JVM」,而是:浏览器里的 JavaScript 已经承载大型应用,却几乎没有可扩展的静态结构。TypeScript 的目标很克制:在 JS 之上加一层可擦除的类型系统,编译产物仍是可读的 JavaScript,尽量不发明第二套运行时。
所以读 TS 时,偶尔会闻到 C# 的气味——enum、namespace、装饰器争论、声明合并——不全是偶然。它们是同一套「大型工程语言」直觉,被硬塞进一门天生没有模块标准、以脚本拼接为荣的宿主语言里。
无标准模块的年代
TS 早期要解决的,不只是「类型」,还有「如何在没有官方模块的 JS 里组织代码」。
2010 年代初期的前端日常大致是:
- 多个
<script>按顺序挂到页面上 - 全局变量互相覆盖,靠命名约定自救
- 需要异步加载时上 AMD(RequireJS)
- Node 侧是 CommonJS(
require/module.exports) - 要两边通吃就写 UMD
那时还没有「文件即模块」的语言级共识。TypeScript 给出的组织方案,带着明显的桌面/.NET 口音:
- 内部模块(后来改名
namespace):把符号收进嵌套对象,减少全局污染 - 三斜杠指令(
/// <reference path="..." />):告诉编译器「先把这个文件拉进编译图」 enum:给魔法数字/字符串一个具名集合,编译期与运行期都有实体
在「全靠 script 拼接」的世界里,这套东西是有用的。问题是:ES2015 把 import/export 写进语言标准之后,游戏规则变了。
ESM 成为正统之后
一旦文件因 import/export 变成模块,文件本身就是隔离边界——namespace 的主业被抢走了。
ES Module 的核心约定很硬:
- 有顶层
import或export→ 这是模块,有自己的作用域 - 没有 → 这是脚本,顶层声明默认进全局(浏览器里常表现为挂到
window一类)
于是出现了现在教科书式的劝退:
| 旧工具 | 现代替代 | 劝退理由(工程向) |
|---|---|---|
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 看起来像类型特性,实际常常是运行时对象——和「类型可擦除」的现代前端理想打架。
几个现实摩擦点:
- 不是纯类型:普通
enum会生成反向映射对象;打包体积与语义都「比看起来重」。 const enum更危险:内联依赖整个项目如何编译;在isolatedModules(esbuild/SWC/Vite 默认假设)下要么禁用,要么行为与tsc全量编译不一致。- 可替代且更 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 的典型路径:
- 开发:esbuild(或后续同类)快速剥离类型,几乎不做完整类型检查
- 类型检查:另开
tsc --noEmit/vue-tsc/astro check - 产物:Rollup(或 Rolldown)做打包与优化
这对语言的冲击是结构性的:
- 单一真相源碎裂:
tsc的世界(project references、const enum、namespace 合并、某些 emit 语义)≠ bundler 的世界(逐文件转译、isolated modules)。 module/moduleResolution变成政治问题:node、node16、nodenext、bundler——选错就像左右脑同时开车。- 类型与运行时路径必须分治:
paths映射在tsc里成立,不代表 Vite 不配resolve.alias就能跑;反过来 alias 配了,类型还可能红着。 - 声明文件成为边界契约:
.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?
- 这是环境类型注入,不是运行时依赖
- 目标是扩展全局 /
import.meta的形状,而不是引入一个值 - 放在
vite-env.d.ts这类「只存在于类型世界」的文件里最干净
对照旧时代的:
/// <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 模块 的作用域规则:
-
无
import/export的.ts默认是脚本
顶层的interface Window会进入全局,可能和别的脚本文件「声明合并」——在老项目里像魔法,在多包工程里像污染。 -
有任一
import/export(包括空的export {})→ 文件变成模块
顶层声明变成模块私有。你不再意外污染全局——但你也失去了直接改全局的资格。 -
所以要改全局,必须显式穿越边界
declare global { ... }的含义是:我人在模块里,但这段声明请并入全局作用域。
export {} 是开关:强制「本文件按模块处理」。
declare global 是闸门:在模块世界里,受控地打回全局。
两者同时出现,不是作者脑子进水,而是:
我要模块的隔离,又要合法地扩展全局类型。
为什么现代工程还要扩展全局
因为现实里总有「不属于任何一个 npm 包、却谁都要读」的东西:
- 注入到
window的运行时配置 - 分析脚本、灰度开关、原生 bridge
- 测试里挂上的 mock
- 框架在
import.meta/ 环境变量上的类型(又连回 Vite 的reference types)
ESM 把默认全局污染堵死了;类型系统就需要一条显式、可审查、可搜索的回流通道。declare global 就是这条通道。export {} 则保证:「这个 .d.ts / ambient 文件自己不要再变回脚本,和别的脚本文件发生隐式合并事故。」
防御性编程的读法
把它当成工程契约,而不是谜语:
- 默认模块化:能
export {}就不要依赖「无导出脚本」的隐式全局合并。 - 全局扩展要集中:例如
src/types/global.d.ts,避免每个业务文件随手declare global。 - 值与类型分开:运行时赋值
window.__APP_CONFIG__ = ...是值;.d.ts只描述形状。不要指望声明会变出值。 - 能不用全局就不用:优先
import.meta.env、依赖注入、显式参数。全局是边界上的逃生舱,不是客厅。 - 和 bundler 对齐:全局类型改了,确认没有另一份
env.d.ts/ 三斜杠types在抢同一块形状。
复杂仓库里,这类写法难看,但比「二十个脚本文件顶层乱合并」可推理得多。丑陋常常是防御成功后的外观。
一条时间线
把前面串成一条可记忆的链:
- Anders:Pascal/Delphi 工程观 → C#(对标 Java、C 家族语法)→ TypeScript(给 JS 加可擦除类型)。
- 无标准模块:
namespace+ 三斜杠path+enum组织大型前端。 - ESM 正统化:文件即模块;namespace/enum 失去主场。
- 打包器时代:TS 主责类型;emit 交给 esbuild/Vite;
isolatedModules成为宪法条款。 - 残留工具:
reference types仍用于环境类型;export {}+declare global用于模块世界里的受控全局。 - tsconfig:同时服务编辑器、
tscCI、bundler 解析——左右脑互搏是常态,配置的目标是把冲突写清楚,而不是假装只有一个大脑。
结语
TypeScript 今天的别扭,很多不是设计失败,而是宿主语言与工程范式换代后,旧器官还没完全凋亡。
C# 那条谱系解释了它为何曾热爱 namespace 与 enum;前端模块史解释了它们为何被劝退;Vite 解释了三斜杠为何还活着一截;export {} 与 declare global 则提醒我们:模块隔离与全局扩展的张力不会消失,只能被写成更显式的契约。
下次再嫌 export {} 沙雕,可以把它读成一句工程口令——「此文件是模块;若要碰全局,请走闸门。」 口令丑陋,闸门必要。