从 URL 到执行:Deno 的模块加载、解析与 TypeScript 处理流程
前置知识
- ›第 1-2 篇文章
- ›JavaScript 模块系统(ESM、CJS、import map)
- ›TypeScript 编译基础
从 URL 到执行:Deno 的模块加载、解析与 TypeScript 处理流程
在第 1 和第 2 篇文章中,我们了解了 CLI 如何分发子命令,以及 deno_core 如何通过扩展和 ops 在 Rust 与 JavaScript 之间架起桥梁。但当你执行 deno run https://example.com/mod.ts 时,一系列精妙的事情随之发生:Deno 从网络获取一个 TypeScript 文件,解析其中的导入(可能是 URL、npm 包或 JSR 模块),转译所有内容,最终完成执行——整个过程无需 package.json,也不需要任何构建步骤。本文将深入剖析这条处理链路:各种各样的模块标识符类型、分层的解析器栈、模块图的预分析机制,以及目前正处于架构迁移中的双重 TypeScript 类型检查系统——从基于 JavaScript 的 tsc 迁移到基于 Go 的 tsgo。
模块标识符的多样性
Deno 能处理的模块标识符类型,比任何其他 JavaScript 运行时都要丰富。Node.js 主要处理裸标识符和相对路径,而 Deno 需要解析的范围则宽泛得多:
| 标识符类型 | 示例 | 由谁解析 |
|---|---|---|
| File URL | file:///home/user/mod.ts |
直接访问文件系统 |
| HTTPS URL | https://deno.land/std/path/mod.ts |
HTTP 获取 + 缓存 |
| Import map 条目 | std/path → 映射 URL |
Import map 解析 |
| JSR 标识符 | jsr:@std/path@1.0.0 |
JSR 注册表解析 |
| npm 标识符 | npm:express@4 |
npm 注册表 + node_modules |
| 裸标识符 | lodash |
package.json、import map 或报错 |
| Data URL | data:text/javascript,... |
内联解析 |
| Node 内置模块 | node:fs |
ext/node polyfill |
graph TD
SPEC[Module Specifier] --> TYPE{Specifier Type?}
TYPE -->|file://| FS[Read from filesystem]
TYPE -->|https://| HTTP[Fetch + HTTP cache]
TYPE -->|jsr:| JSR[JSR registry lookup]
TYPE -->|npm:| NPM[npm resolution]
TYPE -->|node:| NODE[ext/node polyfill]
TYPE -->|import map| IMAP[Import map transform]
TYPE -->|bare| BARE{Has package.json<br/>or import map?}
BARE -->|Yes| IMAP
BARE -->|No| ERR[Error: Module not found]
IMAP --> TYPE
JSR --> HTTP
libs/resolver/lib.rs crate 是提取出来的解析器库。它从 node_resolver 引入 Node.js 兼容解析逻辑,从 deno_package_json 引入 package.json 解析能力,并在此之上叠加 Deno 自身的解析策略。
解析器栈
Deno 的模块解析不是一个单一函数,而是一组分层的解析器,每层负责处理不同的关注点。CLI 通过 cli/resolver.rs 中的类型别名将它们串联在一起:
pub type CliResolver = deno_resolver::graph::DenoResolver<
DenoInNpmPackageChecker,
DenoIsBuiltInNodeModuleChecker,
CliNpmResolver,
CliSys,
>;
DenoResolver 通过四个泛型类型参数实现了高度灵活性,可以适配 CLI、独立二进制文件和测试等不同场景。解析流程按层次展开:
flowchart TD
INPUT["import 'foo/bar'"]
IM["1. Import Map Resolution<br/>Maps bare specifiers to URLs"]
JSR["2. JSR Resolution<br/>jsr: → registry URL"]
NPM["3. npm Resolution<br/>npm: → node_modules path"]
CJS["4. CJS/ESM Detection<br/>package.json type field"]
SLOPPY["5. Sloppy Imports<br/>Try .ts, .js, /index.ts"]
NODE_RES["6. Node Resolution<br/>node_modules traversal"]
FINAL["Resolved URL"]
INPUT --> IM
IM --> JSR
JSR --> NPM
NPM --> CJS
CJS --> SLOPPY
SLOPPY --> NODE_RES
NODE_RES --> FINAL
"宽松导入"(Sloppy imports)是 Deno 为照顾开发者习惯而做出的一项让步——启用后,它会依次尝试添加 .ts、.js、/index.ts 等后缀,模拟开发者在使用打包工具时习以为常的解析行为。当宽松解析介入时,解析器会发出警告,引导开发者使用显式导入路径。
提示:
CliCjsTracker类型用于追踪某个模块应当被视为 CJS 还是 ESM。这对 npm 兼容性至关重要:很多 npm 包在内部使用require(),Deno 需要在模块加载阶段而非执行阶段就完成这一判断。
CliModuleLoaderFactory 与 ModuleLoader Trait
cli/module_loader.rs 实现了 deno_core::ModuleLoader——这是 V8 在需要解析和加载模块时所调用的 trait。该文件约有 1500 行代码,因为模块加载几乎牵涉每一个子系统:文件获取、转译、npm 解析、代码缓存和模块图分析。
单个模块的加载流程如下:
sequenceDiagram
participant V8 as V8 Engine
participant ML as ModuleLoader
participant Graph as Module Graph
participant Fetch as File Fetcher
participant Trans as Transpiler
participant Cache as Code Cache
V8->>ML: resolve(specifier, referrer)
ML->>Graph: Check prepared graph
Graph-->>ML: Resolved specifier
V8->>ML: load(specifier)
ML->>Graph: Lookup in graph
alt In graph (pre-loaded)
Graph-->>ML: Source + media type
else Not in graph
ML->>Fetch: Fetch module
Fetch-->>ML: Raw source
end
ML->>Trans: Transpile if TypeScript
Trans-->>ML: JavaScript source
ML->>Cache: Check V8 code cache
Cache-->>ML: Cached bytecode (if any)
ML-->>V8: ModuleSource { code, code_cache }
ModuleLoader 的实现区分了两种情况:已在模块图中预分析的模块(deno run 的常规路径),以及通过动态 import() 加载的模块。预分析的模块已完成获取和转译;动态导入则可能触发新的网络请求。
代码缓存的集成尤为值得关注。Deno 将 V8 编译后的字节码与模块源码一同存储,以源码内容的哈希值作为缓存键。在后续运行中,Deno 直接加载缓存的字节码,V8 便可跳过解析和编译阶段。这套缓存机制独立于 HTTP 缓存,存储的是 V8 的内部表示形式。
模块图:依赖关系的预分析
在执行任何代码之前,Deno 会使用 deno_graph 构建完整的依赖关系图。cli/graph_util.rs 负责协调整个过程:
flowchart LR
ROOT["Root module<br/>hello.ts"] --> BUILD["ModuleGraphBuilder<br/>.build()"]
BUILD --> FETCH["Parallel fetch<br/>all dependencies"]
FETCH --> PARSE["Parse imports<br/>(deno_ast)"]
PARSE --> RESOLVE["Resolve specifiers<br/>(deno_resolver)"]
RESOLVE --> RECURSE{More deps?}
RECURSE -->|Yes| FETCH
RECURSE -->|No| GRAPH["Complete<br/>ModuleGraph"]
GRAPH --> CHECK["Type check<br/>(optional)"]
GRAPH --> PREP["ModuleLoadPreparer<br/>makes graph available<br/>to ModuleLoader"]
CHECK --> EXEC["Execute"]
PREP --> EXEC
deno_graph crate 提供的 ModuleGraph 通过递归遍历导入关系来构建。每个模块被获取(可能通过 HTTP),解析出其中的 import/export 语句,依赖项随后被解析并加入队列。整个过程并发进行——多个 HTTP 请求同时飞出,CPU 密集型的解析任务与之交织执行。
模块图承担着多重职责:
- 并行获取:所有模块在执行开始前完成获取,避免瀑布式延迟
- 类型检查:类型检查器需要完整的模块图来解析跨模块的类型引用
- 错误报告:缺失模块、循环依赖和解析失败等问题在任何代码运行前就会被报告出来
- lock 文件校验:模块完整性哈希会与 lock 文件进行比对
ModuleLoadPreparer 在模块图和 ModuleLoader 之间起到桥梁作用——它负责构建模块图,可选地执行类型检查,然后将结果存储在 ModuleLoader 在 V8 模块求值期间可以访问的位置。
TypeScript 编译:tsc 与 tsgo
Deno 的类型检查系统正处于架构迁移的关键阶段。cli/type_checker.rs 负责协调两套后端。
旧系统(cli/tsc/mod.rs)在一个独立的 V8 isolate 中运行基于 JavaScript 的 TypeScript 编译器。Deno 通过 ops 提供自定义的 CompilerHost 实现,TypeScript 编译器通过它来解析模块、读取文件并写出产物。这本质上是:一个 TypeScript 编译器嵌入在 JavaScript 运行时中,而该运行时又嵌入在 Rust 程序里。
新系统(cli/tsc/go.rs)使用 tsgo——一个基于 Go 的 TypeScript 类型检查器,通过 RPC 与 Deno 通信。第 45 行的注释揭示了一个务实的变通方案:
// the way tsgo currently works, it really wants an actual tsconfig.json file.
// it also doesn't let you just pass in root file names. instead of making more
// changes in tsgo, work around both by making a fake tsconfig.json file with
// the "files" field set to the root file names.
Go 客户端在内存中构造一个虚拟的 tsconfig.json,将其作为真实文件传递给 tsgo,并通过 RPC 通道(SyncRpcChannel)处理 Go 回调到 Rust 的模块解析请求。
sequenceDiagram
participant TC as TypeChecker
participant Legacy as tsc (JS in V8)
participant Go as tsgo (Go via RPC)
TC->>TC: Check DENO_USE_TSGO env var
alt Legacy mode
TC->>Legacy: Create V8 isolate
Legacy->>Legacy: Load TypeScript compiler
Legacy->>TC: Request modules (via ops)
TC-->>Legacy: Module sources
Legacy-->>TC: Diagnostics
else tsgo mode
TC->>Go: Spawn tsgo process
Go->>TC: ResolveModuleName callback
TC-->>Go: Resolved specifier
Go->>TC: ReadFile callback
TC-->>Go: File contents
Go-->>TC: Diagnostics
end
需要特别注意的是,类型检查与转译是完全独立的两个过程。TypeScript 的转译(即剥离类型信息)由 deno_ast 通过 swc 在模块加载时完成——这一步速度很快,且始终会执行。而类型检查是可选的(--no-check 可跳过),开销较大,负责在整个模块图范围内验证类型正确性。
提示:
TypeCheckMode枚举有三个变体:None(跳过检查)、Local(仅检查本地文件,跳过node_modules)和All(检查所有内容,包括依赖项)。deno check默认使用Local模式——如需检查依赖项,请使用--all参数。
文件获取与缓存
cli/file_fetcher.rs 负责从任意来源获取模块——本地文件、HTTP URL 或 data URL。对于远程模块,它与 deno_cache_dir 集成以实现 HTTP 缓存:
flowchart TD
REQ["Fetch request<br/>https://deno.land/std/path/mod.ts"]
LOCAL{Local file?}
REQ --> LOCAL
LOCAL -->|Yes| READ[Read from disk]
LOCAL -->|No| CACHE{In HTTP cache?}
CACHE -->|Yes, fresh| HIT[Return cached]
CACHE -->|Yes, stale| REVALIDATE[Conditional GET<br/>If-None-Match / If-Modified-Since]
CACHE -->|No| FETCH[HTTP GET]
REVALIDATE -->|304| HIT
REVALIDATE -->|200| STORE[Store in cache]
FETCH --> STORE
STORE --> DECODE[Detect charset<br/>Decode to UTF-8]
READ --> DECODE
HIT --> DECODE
DECODE --> FILE["TextDecodedFile<br/>{specifier, source, media_type}"]
TextDecodedFile 结构体是统一的输出格式:包含模块标识符、解码后的源码文本,以及根据 HTTP 响应头或文件扩展名确定的 MediaType(TypeScript、JavaScript、JSX、JSON 等)。媒体类型决定了后续的转译方式——TypeScript 文件经由 swc 处理,JavaScript 文件则原样通过。
CacheSetting 枚举控制缓存行为:Use(默认——优先使用缓存,缺失时重新获取)、Only(离线模式——未缓存则报错)、ReloadAll(忽略缓存,重新获取所有内容)和 ReloadSome(选择性重新加载)。--cached-only 和 --reload 标志分别映射到这些设置。
下一步
至此,我们已经完整追踪了一个模块从标识符字符串到最终执行的全过程:通过分层解析器栈进行解析、并行获取构建依赖图、通过 JS 或 Go 版 TypeScript 编译器进行可选的类型检查、经由 swc 转译,最终在 V8 中执行并享受代码缓存的加速。在下一篇文章中,我们将深入 MainWorker 本身——它是如何创建的,启动序列如何从扩展模块构建出 Deno 全局命名空间,以及由 8 种类型组成的权限系统如何在 op 边界处强制执行安全策略。