跳到正文

我所理解的 TypeScript 最佳实践(4):模块边界与 TypeScript 脚本

我所理解的 TypeScript 最佳实践

  1. 为什么选择 TypeScript
  2. 严格模式
  3. 状态、映射与泛型
  4. 模块边界与 TypeScript 脚本(当前)
  5. 类型检查、Lint 与测试
  6. TypeScript 7 升级与工具链兼容

博客仓库里有两种 TS 入口。网络测速的汇总脚本用 tsx 直接运行,Hexo 插件则由 Hexo 加载构建后的 CommonJS 文件。入口怎么写,取决于最后接手的是仓库自己的命令、浏览器,还是有加载约定的工具。

从稳定入口导入

模块的 index.ts 可以作为公开入口,列出调用方可以导入的函数和值,内部文件按职责拆分。仅用于类型的位置写 import type,参与运行时比较、函数调用或模板求值的符号使用值导入。同一来源同时导入类型和值时,可以合成一条 import,例如 import { parseContract, type ContractDto } from './contracts.js';。扩展名怎么写,要看文件最终交给谁加载。

执行方式 相对导入与产物
浏览器项目由 bundler 解析 项目配置允许时可省略扩展名。检查编译器、bundler 和测试解析是否一致。
Node 执行编译后的 ESM 最终 JS 需要完整相对路径。源码可以写输出侧的 .js,或者采用能在编译时改写相对 TS 扩展名的配置。
Node 原生执行 TS 源码 使用真实源码扩展名,受当前 Node 对可擦除语法的支持范围约束。
用 tsx 执行内部 TS 工具 按运行器解析源码,并让 tsc --noEmit 单独检查项目。

路径别名也要确认实际加载器支持。paths 让 TypeScript 知道如何找模块,本身不会重写输出中的 import。完整边界见模块入口与 import 后缀和 TypeScript Modules Reference。

本地脚本和发布产物

仓库内的 Node.js 脚本默认用 tsx

当脚本只在仓库内部使用,维护者和 CI 都会安装同一份开发依赖时,我把 tsx 装成开发依赖,执行命令放进 package.json。构建、迁移、发布和 Node 内置测试都能沿用这个入口。脚本目录也可以有自己的 tsconfig.json。

pnpm add -D tsx
pnpm exec tsx scripts/report.ts
pnpm exec tsx watch scripts/report.ts
pnpm exec tsx --test scripts/report.test.ts
node --import tsx scripts/report.ts
pnpm exec tsc -p scripts/tsconfig.json --noEmit

tsx watch 适合反复调试,依赖变化后会重跑。需要保留 node 命令时,用 --import tsx 在加载入口前注册转换。tsx 能处理 enum、参数属性和 TSX,也能按配置解析 paths。它用 esbuild 转译,只读取部分 tsconfig.json 选项。例如依赖 emitDecoratorMetadata 的项目,要用能生成该元数据的编译链。类型错误则由单独的 tsc --noEmit 检查,跑通脚本和检查整个模块依赖图是两件事。其他加载方式和编译限制分别见 tsx 的 Node 接入文档和 TypeScript 支持说明。

仓库已经声明 "type": "module" 时,.ts 沿用 ESM 语义。只有一个脚本需要明确使用 ESM,例如要写顶层 await,可以直接用 .mts,避免改动整个包的模块类型。.cts 则用于明确采用 CommonJS 的入口。

本站回收网络测速的 TSV 后,在仓库根目录运行下面的命令。汇总脚本从同目录的 spec.json 读取基线约定,把报告写到 .tools/。

pnpm exec tsx tools/probes/cross-network/aggregate.ts ./results --out .tools/network-probe-summary

tsx、ts-node 和 Node 原生执行怎么选

方式 转换与类型检查 适合什么情况
pnpm exec tsx scripts/report.ts esbuild 转换常用 TS 语法,支持 ESM 和 CommonJS。另跑 tsc --noEmit。 仓库内的默认入口,同一套命令可以运行脚本、监听修改和跑 Node 测试。
pnpm exec ts-node scripts/report.ts 或 pnpm exec ts-node --esm scripts/report.ts 默认用 TypeScript 编译器,检查运行中加载的代码。--transpileOnly 可跳过检查,CommonJS 和原生 ESM 分别配置。 既有项目优先迁移到 tsx。依赖特定 REPL、register 或编译输出的入口,先核对兼容性。
node scripts/report.mts Node 自带的类型擦除只移除类型。它不处理 enum、运行时 namespace、构造函数参数属性或 .tsx,也不读取 tsconfig.json 来解析 paths 或降级语法。类型检查另跑。 需要复现原生加载,或明确要求零运行器依赖。固定 Node 版本,确认整条源码依赖只用可擦除语法,导入写真实文件后缀。

用 Node 26 直接运行含 enum 的脚本会报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,同一份文件能通过 tsx 执行。内部脚本随着项目增长,可能从几行代码变成要复用应用里的类型、别名和模块。用 tsx 可以沿用一套执行命令,类型错误仍由固定的检查命令处理。旧项目的 ts-node ESM 模式依赖 Node 加载钩子,官方文档提醒生产入口应另行构建。 tsx 的编译限制 · ts-node 的编译选项 · ts-node 的模块文档

我也会看运行器近期有没有发布。以 2026 年 11 月 9 日的公开记录为准,tsx 的默认分支在 2026 年 9 月 25 日还有提交,9 月 20 日发布了稳定版 v4.23.15。ts-node 的默认分支最近一次提交是 2023 年 12 月 12 日,最近的稳定版 v10.9.2 发布于同年 12 月 8 日。运行器依赖 Node 的加载行为,升级 Node 时,近期还有发布的项目更有机会把兼容修复交给用户。因此新项目直接用 tsx,已有 ts-node 的脚本也尽量迁移。 tsx 默认分支提交 · tsx v4.23.15 · ts-node 默认分支提交 · ts-node v10.9.2

迁移时先找 ts-node/register、ESM loader 和额外编译选项,再逐个替换执行命令。原先随脚本运行的类型诊断移到 tsc --noEmit。如果入口依赖 emitDecoratorMetadata 一类编译输出,就先给它配好能生成这些输出的构建链,再迁移运行命令。

确实需要原生执行时,erasableSyntaxOnly 可以提前找出部分不能擦除的语法,verbatimModuleSyntax 可以检查类型导入有没有标明 type。Node 22.18 和 23.6 起在各自版本线上默认开启类型擦除,Node 26 移除了早期的 --experimental-transform-types。最终还是要用目标版本的 node 实际启动一次。 Node.js 的 TypeScript 文档 · erasableSyntaxOnly 的选项说明

独立打包的 Node 工具交付 JavaScript

工具作为独立 CLI 或服务交给别人时,运行环境可能只有 Node 和生产依赖。构建阶段先把 TS 变成 JS,使用方直接用 Node 运行。仅需转换语法、依赖继续由 Node 从安装目录加载时,可以用 tsc。例如在 "type": "module" 的 Node 项目里,用下面这份 tsconfig.build.json 编译 src/。

{
  "compilerOptions": {
    "module": "NodeNext",
    "target": "ES2022",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "noEmitOnError": true,
    "sourceMap": true
  },
  "include": ["src/**/*.ts"]
}
pnpm exec tsc -p tsconfig.build.json
node --enable-source-maps dist/main.js

在这套配置里,源码可以用 ./format.js 导入 format.ts。TypeScript 在检查时会找到 TS 源文件,生成的 JS 保留 ./format.js,Node 就能接着读输出文件。如果同一份源码还要由原生 Node 直接运行,可以改写成相对 .ts 导入,再让 TypeScript 5.7 起提供的 rewriteRelativeImportExtensions 在输出时替换后缀。它只覆盖对应的相对导入,不会替你重写 paths 别名。解析与改写的细节分别见 TypeScript 的模块解析参考和相对导入扩展名改写说明。

需要把依赖一起装进产物时,可以换用 esbuild 等打包工具,再单独跑类型检查。发 npm 库时,还要给调用方交付 .d.ts,例如用 tsc 生成声明,由 package.json 的 exports 指向运行入口和类型入口。若承诺同时支持 ESM 和 CommonJS,应分别验证 import 和 require,并让声明的模块格式跟对应入口匹配。参见工具库的依赖、API 与发布边界和 Node 程序怎样构建与交付。

CLI 可以通过 npm 的 bin 暴露命令,入口仍指向构建后的 JS。把产物放到仓库外,用最低受支持的 Node 跑一次真实命令,同时检查旁边的配置文件、模板和外置依赖能否找到。

需要生成 JS 时,把产物放在单独的目录,由 Git 和源码 Lint 排除。源码检查仍覆盖维护脚本、配置与插件。验证产物时另用消费者实际执行,例如用目标版本的 Node.js 启动 CLI,或让 Hexo 加载插件入口。

浏览器里的 TypeScript

以 Vite 为例,源码可以从 index.html 写 <script type="module" src="/src/main.ts"></script>。开发时浏览器请求这个 URL,Vite 在响应前把 TS 转成 JavaScript。生产构建时 Vite 会生成 JavaScript 并更新 HTML 里的入口,部署 dist/ 即可。纯 TS 用 tsc --noEmit 检查,Vue 项目用 vue-tsc --noEmit 一并检查模板和组件。可以对照 Vite 的 TypeScript 支持和生产构建文档配置这两步。

pnpm exec vite
pnpm exec tsc --noEmit
pnpm exec vite build
pnpm exec vite preview

通过静态服务器打开的小页面,可以提前用 pnpm exec esbuild src/main.ts --bundle --platform=browser --format=esm --outfile=public/main.js 生成 JS,再让同目录的 public/index.html 用 type="module" 引用 ./main.js。这里只用到 esbuild 的 TypeScript 转译和打包。

Worker 的入口也要单独处理

浏览器的 Web Worker 和 Service Worker

页面用 Vite 时,可以让它同时处理页面入口和专用 Web Worker 的入口。new URL() 直接放进 new Worker(),路径和选项写成字面量,Vite 就能在构建时识别这个入口并生成 Worker 脚本。

// src/main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', (event) => console.log(event.data));
worker.postMessage(21);
// src/worker.ts
self.addEventListener('message', (event: MessageEvent<number>) => {
  self.postMessage(event.data * 2);
});

这里仍用 pnpm exec vite 开发、用 pnpm exec vite build 生成产物。Worker 里有 self 和消息事件,没有页面的 document。类型检查可以给页面和 Worker 各用一份配置,页面带 DOM,Worker 用 "lib": ["ES2022", "WebWorker"],再用 tsc -p tsconfig.worker.json --noEmit 检查。这样误用页面 API 时,编辑器也能报出来。new Worker() 的行为由 HTML 标准定义,构建时识别入口的条件见 Vite 的 Worker 文档。

Service Worker 由浏览器按 URL 注册。可以让框架插件生成入口,也可以先用 pnpm exec esbuild src/service-worker.ts --bundle --platform=browser --format=esm --outfile=public/sw.js 生成独立文件,再在页面中调用 navigator.serviceWorker.register('/sw.js', { type: 'module' })。实际部署时,检查 HTTPS、脚本公开路径和可控制的页面范围。注册接口和作用域规则见 Service Worker 标准。

Node.js 的 Worker 线程和边缘 Worker

在 Node.js 里,线程入口由 node:worker_threads 加载。服务采用先编译再运行时,可以把 src/main.ts 和 src/task-worker.ts 都放进同一份构建配置,主线程使用下面的路径。编译后执行 node dist/main.js,Worker 会接着读取同目录里的 task-worker.js。

import { Worker } from 'node:worker_threads';

const worker = new Worker(new URL('./task-worker.js', import.meta.url));
worker.once('message', (message) => console.log(message));

本地如果想直接跑 TS 线程入口,主线程改用真实的 ./task-worker.ts 路径,可以用 node --import tsx src/main.ts 启动。Worker 默认继承父线程的 execArgv,单独覆盖它时要确认还带着所需的加载选项。选项的继承规则见 Node.js 的 Worker 线程文档。

Cloudflare Workers 里的 Worker 是平台托管的服务入口。可以在 Wrangler 配置把 main 设为 src/index.ts,用 pnpm exec wrangler dev 本地启动,由 Wrangler 构建并交给平台运行时。类型声明可以用 pnpm exec wrangler types 按项目配置生成,再用 tsc --noEmit 检查。平台支持的 API 由兼容日期和开关决定,nodejs_compat 要按实际用到的能力配置。生成类型的选项见 Cloudflare Workers 的 TypeScript 文档。

构建脚本和工具配置由谁加载

主动发起构建的脚本和被工具加载的配置,可以同时写 TS。比如本站的 pnpm run build:theme 用 tsx 启动 build-scripts/build-theme.ts,它再调用 Vite,Vite 负责加载主题的 vite.config.ts。在直接使用 Vite CLI 的项目里,可以用 pnpm exec vite --config vite.config.ts 指定配置。Vite 默认会处理配置文件,显式用 --configLoader native 时,则要满足当前运行时直接加载该文件的要求。各模式的差异见 Vite 的配置加载文档。

ESLint 和 Stylelint 也各有自己的入口。本站的 pnpm run lint:source 让 ESLint 在 Node 中加载 .mts 配置,pnpm run lint:theme:style 让 Stylelint 加载 .ts 配置。遇到只接受某种 JS 入口的工具,就用构建步骤生成它需要的文件。验证时用工具本来的命令加载,才能覆盖文件发现、模块格式和宿主提供的变量。

Hexo 怎样加载 TypeScript 写的插件

本站有三个工作区插件,分别处理首页文章筛选、系列文件名和 Markdown 扩展。首页插件需要等主题注册完 index 生成器再包装它。系列文件名插件则要在渲染前补齐文章元数据。迁移这类插件时,首先要找清楚注册代码由谁调用,什么时候开始执行。

以 Hexo 8.1.2 为例,Hexo 先解析插件包的入口,再读取入口文件,将代码包进函数执行,并给这个函数传入当前的 hexo 实例。源码里的 declare const hexo: Hexo 告诉 TypeScript 这个变量由宿主提供,编译后声明会擦除。构建输出用 CommonJS,包清单的 main 指向 dist/index.cjs。即使站点根目录设置了 "type": "module",插件的运行入口仍按这个约定交给 Hexo。见 Hexo 插件文档和 Hexo 8.1.2 的加载实现。

本站用同一份 esbuild 构建脚本生成工作区插件的 dist/index.cjs,也把站点和主题维护的注册脚本编译到 Hexo 自动扫描的 scripts/。Node 入口按 CommonJS 输出,包依赖留给包管理器安装。Markdown 插件的浏览器入口单独按浏览器平台构建,再由 Hexo 发布到站点目录。转换和打包由 esbuild 负责,TypeScript 检查在构建前单独执行。

pnpm run build:plugins
pnpm exec hexo generate

第一条命令检查维护源码和类型,再生成插件和站点扩展的入口。工作区安装、预览和打包也走这一步。第二条命令用真实宿主执行产物。比如验证首页插件,要看隐藏子篇是否仍能在归档和正文里打开。验证系列文件名插件,要看跨日文章的地址和显式写在 frontmatter 里的日期是否正确。独立安装时,再用打出来的包生成一次站点,确认包内入口和运行依赖齐全。本站的扩展配置见《让 Hexo 支持 Markdown 扩展语法》。