Read OSS

Deno 的工具链与 Node.js 兼容性:NPM、LSP 与 Tools 目录

高级

前置知识

  • 第 1–4 篇文章
  • Node.js/npm 生态系统(package.json、node_modules、npm registry)
  • LSP 协议基础概念

Deno 的工具链与 Node.js 兼容性:NPM、LSP 与 Tools 目录

在前四篇文章中,我们从进程入口与子命令分发(第 1 篇)出发,依次深入了解了 Rust-V8 扩展桥接机制(第 2 篇)、模块加载与 TypeScript 编译(第 3 篇),以及 Worker 引导与权限系统(第 4 篇)。本篇是系列的收尾,将从更宏观的视角审视 Deno 的整体能力:替代 prettiereslintjestesbuild 的集成工具链;deno compile 独立二进制构建流程;支持两种解析模式的深度 npm 兼容层;200 多个 Node.js API polyfill;完整的 LSP 实现;以及用于验证上述一切的规范测试框架。

Tools 目录:内置开发工具链

cli/tools/mod.rs 中声明了 23 个工具模块:

pub mod bench;
pub mod bundle;
pub mod check;
pub mod clean;
pub mod compile;
pub mod coverage;
pub mod deploy;
pub mod doc;
pub mod fmt;
pub mod info;
pub mod init;
pub mod installer;
pub mod jupyter;
pub mod lint;
pub mod pm;        // package management (add, remove, audit, outdated)
pub mod publish;
pub mod repl;
pub mod run;
pub mod serve;
pub mod task;
pub mod test;
pub mod upgrade;
pub mod x;

每个工具模块都由第 1 篇中介绍的 run_subcommand() 进行分发,接收一个 Arc<Flags> 以及各自的子命令专属 flags 结构体。各工具通过 CliFactory 共享基础设施——按需调用 factory.cli_options()factory.module_graph_builder()factory.type_checker(),而 Deferred<T> 确保每个服务只初始化一次。

graph LR
    subgraph "Tools"
        FMT[fmt]
        LINT[lint]
        TEST[test]
        BENCH[bench]
        DOC[doc]
        COMPILE[compile]
        PUBLISH[publish]
        TASK[task]
        REPL[repl]
    end

    subgraph "Shared via CliFactory"
        FF[FileFetcher]
        MGB[ModuleGraphBuilder]
        TC[TypeChecker]
        RF[ResolverFactory]
    end

    FMT --> FF
    LINT --> FF
    TEST --> MGB
    TEST --> TC
    BENCH --> MGB
    DOC --> MGB
    COMPILE --> MGB
    PUBLISH --> MGB
    PUBLISH --> TC
    TASK --> RF

这里有一个关键设计思路值得关注:deno fmt 完全不会触碰类型检查器,deno lint 不需要模块图构建器,deno task 几乎不依赖这些服务中的任何一个。第 1 篇介绍的 Deferred<T> 模式确保启动开销与每个工具的实际需求成正比。

提示: 有些工具的实现有繁简之分。cli/tools/fmt.rs 只是单个文件,而 cli/tools/test/ 则是一个完整的目录,包含用于测试发现、运行器、报告器和覆盖率集成的多个子模块。添加新工具时,可以参考类似工具的结构。

deno compile:独立二进制构建流程

deno compile 命令能生成一个自包含的可执行文件——目标机器上无需安装 Deno。cli/standalone/binary.rs 中的构建流程分为以下几个阶段:

flowchart TD
    SRC["Source module<br/>main.ts"]
    GRAPH["Build ModuleGraph<br/>(all dependencies)"]
    RESOLVE["Resolve npm packages<br/>+ node_modules"]
    VFS["Build VirtualFs<br/>Embed files in memory"]
    META["Serialize Metadata<br/>flags, import map, lockfile"]
    BASE["Fetch base Deno binary<br/>(for target platform)"]
    APPEND["Append eszip + VFS + metadata<br/>to binary"]
    MAGIC["Write MAGIC_BYTES trailer"]
    OUT["Standalone executable"]

    SRC --> GRAPH
    GRAPH --> RESOLVE
    RESOLVE --> VFS
    VFS --> META
    META --> BASE
    BASE --> APPEND
    APPEND --> MAGIC
    MAGIC --> OUT

cli/standalone/virtual_fs.rs 模块负责处理 VirtualFs——一个嵌入在二进制文件中的文件系统,用于承载 node_modules、本地源文件及其他资源。VfsBuilder 会对文件进行去重(相同内容只存储一份),output_vfs() 函数则以树形结构展示已嵌入的文件及其大小:

pub fn output_vfs(vfs: &BuiltVfs, executable_name: &str) {
  if !log::log_enabled!(log::Level::Info) {
    return;
  }
  let display_tree = vfs_as_display_tree(vfs, executable_name);
  log::info!("\n{}\n", deno_terminal::colors::bold("Embedded Files"));
  log::info!("{}", text.trim());
}

序列化到二进制文件中的 Metadata 结构体包含了重建运行时环境所需的全部信息:CLI flags、workspace resolver 配置、npm 解析快照、CJS 导出分析结果,以及 import map。运行时,独立二进制文件从 MAGIC_BYTES 尾部读取这些数据,并重建一个由嵌入式 eszip 和虚拟文件系统支撑的、配有自定义 ModuleLoaderMainWorker

NPM 集成深度解析

Deno 支持两种 npm 解析模式,具体取决于是否存在 node_modules/ 目录:

托管模式(Managed mode,默认):Deno 在 $DENO_DIR 下管理自己的 npm 缓存。包会被解析、下载并全局缓存,除非显式指定,否则不会创建 node_modules/ 目录。该模式由 libs/npm_installer/ crate 实现。

BYONM 模式(Bring Your Own Node Modules):当项目中已存在 node_modules/ 目录(通常由 npm installpnpm install 创建)时,Deno 会直接读取该目录。通过 --node-modules-dir 选项或检测到已有 node_modules/ 时,Deno 会自动切换到此模式。

graph TD
    REQ["npm:express@4"]
    MODE{Resolution mode?}
    REQ --> MODE
    
    MODE -->|Managed| MGLOBAL["Global npm cache<br/>$DENO_DIR/npm/"]
    MGLOBAL --> RESOLVE_M["Resolve version<br/>from registry"]
    RESOLVE_M --> CACHE["Cache tarball<br/>+ extract"]
    CACHE --> LINK["Create virtual<br/>node_modules layout"]
    
    MODE -->|BYONM| LOCAL["Read node_modules/<br/>on disk"]
    LOCAL --> RESOLVE_B["Resolve from<br/>package.json"]
    RESOLVE_B --> FOUND["Use installed<br/>package"]

    LINK --> READY["Package ready"]
    FOUND --> READY

cli/npm.rs 文件定义了 CLI 专用的 npm 类型,包括 CliNpmInstallerFactoryCliNpmInstallerCliNpmCache——它们都以系统 trait CliSys 为泛型参数。生命周期脚本执行器负责处理 preinstallinstallpostinstall 脚本,其中有一个值得关注的检查:is_broken_default_install_script() 会识别并跳过在 Deno 环境中已知会失败的安装脚本。

deno adddeno remove 命令(分发至 tools::pm)会修改 deno.jsonpackage.json,更新依赖声明并执行安装。deno outdated 命令则检查所有依赖是否有新版本可用。这些包管理命令共享 libs/npm/libs/npm_cache/libs/npm_installer/ crate 提供的基础设施。

Node.js Polyfill 层(ext/node/)

ext/node/lib.rs 扩展是 Deno 中体量最大的单个扩展,通过 Rust ops 和 JavaScript polyfill 两种方式提供了 200 多个 Node.js API shim:

pub use node_resolver::DENO_SUPPORTED_BUILTIN_NODE_MODULES 
    as SUPPORTED_BUILTIN_NODE_MODULES;

该扩展在两个层面上发挥作用:

  1. JavaScript polyfillext/node/polyfills/):以纯 JavaScript 实现的 Node.js 模块,涵盖 pathurleventsbufferstreamcrypto(部分)、assert 等。

  2. Rust opsext/node/ops/):用 Rust 实现的性能敏感操作,包括 node:crypto 操作(委托给 ext/node_crypto/)、node:child_process(映射到 Deno 的子进程系统)、node:fs(委托给 ext/fs),以及 node:vm(自定义 V8 context 管理)。

graph TD
    subgraph "Node.js Compatibility"
        REQUIRE["require('fs')"]
        IMPORT["import fs from 'node:fs'"]
        
        RESOLVER["Node Module Resolver<br/>libs/node_resolver/"]
        POLYFILL["JavaScript Polyfills<br/>ext/node/polyfills/"]
        RUST_OPS["Rust Ops<br/>ext/node/ops/"]
        DENO_EXT["Deno Extensions<br/>ext/fs, ext/net, ext/crypto"]
    end
    
    REQUIRE --> RESOLVER
    IMPORT --> RESOLVER
    RESOLVER --> POLYFILL
    POLYFILL --> RUST_OPS
    RUST_OPS --> DENO_EXT

NodeRequireLoader trait 抽象允许在 CLI、standalone 和测试等不同上下文中使用不同的 require() 实现。is_maybe_cjs() 方法根据最近的 package.json 中的 "type" 字段、文件扩展名及其他启发式规则,判断一个模块应以 CJS(require() 语义)还是 ESM 方式加载。

提示: ext/node/ops/vm.rs 中的 VM_CONTEXT_INDEX 常量以及 create_v8_context() / init_global_template() 函数实现了 Node 的 vm.createContext()——它们会创建一个拥有独立全局对象的 V8 context,这正是 jsdom 等依赖该 API 的 npm 包所需要的。

LSP 架构

cli/lsp/mod.rs 包含 34 个子模块,基于 tower-lsp 实现了完整的 Language Server Protocol 服务器:

mod analysis;
mod cache;
mod capabilities;
mod client;
mod code_lens;
mod completions;
mod config;
mod diagnostics;
mod documents;
mod jsr;
mod lint;
mod npm;
mod performance;
mod refactor;
mod registries;
mod resolver;
mod semantic_tokens;
mod testing;
mod tsc;
mod tsgo;
// ... more

服务器由 lsp::start() 启动,创建一个 tower-lsp 服务:

pub async fn start() -> Result<(), AnyError> {
  let stdin = tokio::io::stdin();
  let stdout = tokio::io::stdout();
  let builder = LspService::build(|client| {
    LanguageServer::new(client::Client::from_tower(client))
  });
  // ...
}

LanguageServer 结构体负责管理文档状态、诊断信息、TypeScript 服务集成以及客户端通信。与 CLI 类型检查相比,LSP 架构有以下几点关键差异:

  • 以文档为中心:LSP 追踪已打开的文档及其依赖,而非只关注入口文件
  • 增量更新:单个文件的变更只触发针对性的重分析,而非重建完整的模块图
  • 双重 TypeScript 支持lsp/tsc.rs(传统方式)和 lsp/tsgo.rs(基于 Go 的新方式)均可提供类型信息
  • 诊断聚合流水线:来自多个来源(TypeScript、deno_lint、import 解析)的诊断信息会被合并与去重
flowchart LR
    subgraph "LSP Server"
        DOC["Document Manager<br/>Open files + dependencies"]
        DIAG["Diagnostics Pipeline<br/>TS + lint + resolution"]
        TSC["TypeScript Service<br/>Completions, hover, refs"]
        CONFIG["Config Manager<br/>deno.json, workspace settings"]
    end

    EDITOR["Editor / IDE"] <-->|LSP Protocol| DOC
    DOC --> DIAG
    DOC --> TSC
    CONFIG --> DIAG
    CONFIG --> TSC
    DIAG -->|publishDiagnostics| EDITOR
    TSC -->|completion, hover| EDITOR

测试基础设施:规范测试与集成测试

Deno 的主要集成测试格式是"规范测试(spec test)",相关说明记录在 CLAUDE.md 中。每个测试是一个目录,其中包含一个 __test__.jsonc 文件,用于描述 CLI 命令和预期输出:

{
  "tests": {
    "basic_case": {
      "args": "run main.ts",
      "output": "expected.out"
    },
    "with_flag": {
      "steps": [{
        "args": "run --allow-net main.ts",
        "output": "[WILDCARD]success[WILDCARD]"
      }]
    }
  }
}

输出匹配语言支持通配符:

模式 含义
[WILDCARD] 匹配 0 个或多个任意字符(类似 .*),可跨越换行
[WILDLINE] 匹配 0 个或多个任意字符,在行尾停止
[WILDCHAR] 精确匹配一个字符
[WILDCHARS(5)] 精确匹配 5 个字符
[UNORDERED_START]...[UNORDERED_END] 以任意顺序匹配行
[# comment] 行注释,忽略

这套通配符系统对于测试一个输出中包含文件路径(因机器而异)、时间戳及其他不确定性内容的运行时至关重要。[UNORDERED_START/END] 块则用于处理并发输出中的竞争条件。

第 1 篇中提到的运行回退到任务(run-to-task fallback)本身也是一个值得关注的 UX 设计决策。should_fallback_on_run_error() 函数用于判断在模块未找到时,是否将 deno run <name> 重试为 deno task <name>

fn should_fallback_on_run_error(script_err: &str) -> bool {
  if script_err.starts_with(MODULE_NOT_FOUND)
    || script_err.starts_with(UNSUPPORTED_SCHEME)
  {
    return true;
  }
  let re = lazy_regex::regex!(
    r"Import 'file:///.+?' failed\.\n\s+0: .+ \(os error \d+\)"
  );
  re.is_match(script_err)
}

对应的规范测试会验证回退行为本身,以及当模块和任务均不存在时的错误提示信息。

系列总结

通过这五篇文章,我们完整地梳理了 Deno 的整体架构:

  1. 架构概览:75 个 crate 分布在五个目录,分层依赖图,带有懒初始化服务的 CliFactory
  2. V8 桥接层:扩展将 ops 和 JS 打包在一起,#[op2] 生成 V8 绑定,快照在构建时序列化 JS 堆
  3. 模块加载:通过多层栈进行 specifier 解析,deno_graph 预分析,tsc 与 tsgo 双重 TypeScript 检查
  4. Worker 机制MainWorker 创建过程,跨 Rust-V8-JS 边界的引导流程,支持 broker 的 8 种权限类型系统
  5. 工具链:23 个集成工具,deno compile 虚拟文件系统,npm 托管/BYONM 两种模式,200 多个 Node.js polyfill,完整的 LSP 实现

Deno 的代码库体量庞大,但结构清晰。libs/ext/runtime/cli/ 之间的分层不仅仅是代码组织上的考量——它同时也是编译防火墙和抽象边界。扩展是可组合的单元,ops 是安全边界,而 Deferred<T> 模式确保你只为实际用到的功能付出代价。无论你是在修复 bug、添加 op,还是基于 deno_core 构建自己的运行时,这五层架构都是你最可靠的参考蓝图。