npm 包是动态前沿:它们随包发布的 JavaScript 没有类型、经过压缩,并且是面向 V8 编写的。scriptc 的应对方案是动态孤岛:一个内嵌的 JavaScript 引擎(quickjs-ng,约 620KB),在你的可执行文件内部执行依赖代码,用 --dynamic 显式启用;每个值在跨回静态代码时都会经过校验。
孤岛的故事
tool.ts
import { Command } from "commander";
const program = new Command();
program
.name("greet")
.argument("<name>", "who to greet")
.option("-u, --upper", "shout it")
.action((name: string, opts: { upper?: boolean }) => {
const text = `hello, ${name}`;
console.log(opts.upper ? text.toUpperCase() : text);
});
program.parse();
$ npm install commander
$ scriptc build tool.ts --dynamic -o greet
$ ./greet ada --upper
HELLO, ADA
逐步来看发生了什么:
- 解析:
commander按 Node 自身的解析算法从你的node_modules中解析出来,并遵循 package.json 的exports条件。 - 类型:包自带的
.d.ts就是你的代码进行类型检查所依据的类型接口,与在 Node 项目中完全一致。 - 内嵌:包的 JavaScript(以及它导入的一切,按同样方式解析)在构建时被内嵌进可执行文件。可执行文件在运行时从不读取
node_modules,它可以在同一平台上任意机器的任意目录下运行。 - 执行:内嵌代码在引擎中以完整的 JavaScript 语义运行:是真正的
commander,未经修改。 - 边界:值以拷贝方式跨界,绝不以引用方式。你的回调的带类型参数(
name: string)在调用时接受校验:如果包传入了不是字符串的东西,那是一个可捕获的TypeError,而不是内存损坏。
你自己的代码(.action 回调体、它调用的函数)仍然静态编译。引擎只运行必须动态的部分,而 scriptc coverage --dynamic 会显示精确的划分,包括内嵌包导入了哪些 Node 内置模块,以及每个是否被垫片化。
可执行入口是 TypeScript 的 workspace 包会作为你的程序的一部分自动编译,包括包内部那些解析到 .ts 源码的 .js 导入拼写。随包发布可执行 JavaScript 的 workspace 包则沿用常规的依赖策略:要么用 --dynamic 走孤岛,要么把它们加入 --npm-static。
孤岛是什么
- 它是 quickjs-ng,不是 V8。 内嵌的依赖代码运行是正确的,但在 CPU 密集的工作下比在 Node 下慢。收益在于启动、体积、内存和部署形态,而不是依赖的原始吞吐量。
- 是垫片,不是 Node。 要求内置模块(
node:events、node:path、node:process等)的内嵌包代码,会得到忠实还原的岛内实现,其中一些直接桥接到静态代码使用的同一套原生运行时函数。覆盖率报告会列出内嵌依赖图触及的每个内置模块;未被垫片化的会被报告出来,绝不悄悄打桩。 - 边界按拷贝。 流入依赖代码的静态值按值编组;动态代码做出的修改对静态原值不可见,反之亦然。在 JS 会发生别名的地方,scriptc 采用拷贝,这是记录在案的一处差异。
any类型的代码也在岛内运行。 在--dynamic下,检查器判定为any的表达式在引擎中以完整 JS 语义执行,且每条any→ 静态的边都是一次经过校验的出口。- 每个进程只有一个引擎,首次使用时惰性创建。不含孤岛的
--dynamic构建与静态构建发出相同的代码。
--npm-static(实验性)
--npm-static <pkg[,pkg…]|auto> 要求编译器把指定包移出孤岛:它们随包发布的 JavaScript 作为程序模块进行静态编译,类型信息取自它们自己的 .d.ts。
这是实验性的。真实的包能以高但部分的覆盖率完成静态编译。静态编译器无法处理的位点会被延后:构建成功,报告会列出每个延后的位点,但运行时一旦触及其中一个,就会得到一个指明具体不支持操作的错误。有些包的代码会直接撞上静态边界,今天无法静态构建;被预检拒绝的包会带着一条覆盖率说明回退到孤岛。如果某个包对你的静态构建很重要,试一试并阅读报告,答案是逐包具体的。
--provenance-sources(实验性)
--provenance-sources 更进一步:对于带 npm 出处证明(provenance attestation)发布的包,编译器会获取该证明所指提交处的包源码并编译那份源码:TypeScript 按 TypeScript 编译,而不是编译随包发布的 JS。没有可用证明的包仍走引擎路径,并附带一条说明,绝不会导致失败。成熟度方面的注意事项与 --npm-static 相同。
这套设计能撑多大
设计目标是不加修改地运行真实世界的包。开发时使用的标尺是已发布的 Vercel CLI:直接从 registry 取包,用 --dynamic 编译成一个自包含的可执行文件,运行它的真实工作流,替代了约 120MB 的 Node 运行时加 181MB 的 node_modules。
当前限制
- 没有自带或已安装类型声明的包会通不过类型检查门禁(标准的
Could not find a declaration file错误):添加@types/<pkg>或本地声明即可,就像在任何严格 TypeScript 项目中那样。 - 类实例和 Promise 无法流入
any位(岛内没有对应表示);闭包只能在特定形态下作为宿主函数跨界。每一处拒绝都是一条说明修复方式的编译错误。