首页
/ Deno 依赖管理全解:deno add / deno install 的配置改写、node_modules 安装与源码级原理

Deno 依赖管理全解:deno add / deno install 的配置改写、node_modules 安装与源码级原理

2026-09-05 15:31:38作者:傅爽业Veleda

本文为 Deno 仓库中 deno add / deno install <pkg> 依赖管理链路的深度技术指南:从 CLI 标志解析、配置文件(deno.json / package.json)的精确改写,到依赖如何真正落入 node_modules 与 lockfile。读完本文,你将掌握 --dev--save-optional--no-save--save-exact--package-json 等标志的端到端行为,理解 optionalDependencies 不会被常规安装路径物化这一关键限制及其源码级 workaround,并知道如何定位和验证相关代码。

整体链路概览

Deno 添加依赖的完整流程可以概括为四步:

  1. 标志解析deno add pkgdeno install pkg(install 的本地安装分支)解析出统一的 AddFlags 结构;
  2. 版本决议add() 将每个包请求解析为具体版本(find_package_and_select_version_for_req);
  3. 配置改写:由 ConfigUpdater 决定改写 deno.jsonimports 还是 package.json 的某个依赖小节,并落盘;
  4. 实际安装npm_install_after_modification() 构建新 CliFactory 后调用 cache_top_level_deps(),把包物化到 node_modules 并更新 lockfile。

核心实现集中在 cli/tools/pm/mod.rscli/tools/pm/cache_deps.rs,标志解析则分布在两套解析器中(见下节)。

两套必须保持同步的标志解析器

Deno 的 CLI 标志解析目前存在于两处,且必须同步维护(第二套正在逐步取代第一套,属于 CLI parser 拆分工作的一部分):

  • cli/args/flags.rs — 基于 clap 的旧版解析器。add 子命令在 add_subcommand() 中定义;install 复用共享参数构造器(add_dev_arg()add_optional_arg()add_no_save_arg())。两者最终都汇入 add_parse_inner(),构建 AddFlags 结构。
  • libs/cli_parser/ — 新的手写解析器。命令形状声明在 libs/cli_parser/src/defs.rsADD_SUBCOMMANDINSTALL_SUBCOMMAND),在 libs/cli_parser/src/convert.rs 中(add_parse 及产出 InstallFlagsLocal::Add 的 install 分支)转换为具体 flags。

AddFlags 结构本身定义在 libs/cli_parser/src/flags.rs,并通过 crate::args 再导出,其字段直接对应文档中提到的全部标志:

pub struct AddFlags {
  pub dev: bool,
  pub optional: bool,
  pub no_save: bool,
  pub default_registry: Option<DefaultRegistry>,
  pub lockfile_only: bool,
  pub save_exact: bool,
  pub package_json: bool,
  pub unscoped: bool,
  // ...
}

修改 AddFlags 时的硬性要求(来自仓库维护文档的明确警告):你必须同时更新——结构体本身、两套解析器、以及每一处 AddFlags { .. } 字面量。字面量存在于两个解析器的测试套件 cli/args/flags.rslibs/cli_parser/src/tests_full.rs 中;共享测试用例 add_or_install_subcommand 会对 addinstall 两个子命令循环断言,是两套解析器行为一致性的护栏。

大写字母短标志的 lint 约束

仓库还有一条自定义 lint(tools/lint.js 中的 ensureNoNonPermissionCapitalLetterShortFlags)禁止大写字母短标志,除非在带文档化先例的显式白名单中。-D(dev)与 -O(save-optional)在白名单内,二者的正当性都来自与 npm install 对应短标志保持一致。这也是为什么在 libs/cli_parser/src/defs.rsINSTALL_SUBCOMMAND 定义中,你能看到 dev 绑定短标志 'D'save-optional 绑定 'O',并分别声明了 conflicts_with 互斥关系。

依赖写到哪里:配置改写器

cli/tools/pm/mod.rs 是配置改写的核心。add() 入口(约 L536)先解析每个包请求并选出具体版本,再决定触碰哪个配置文件。

两个可能同时生效的配置文件

load_configs()L442)负责发现配置,规则如下:

  • deno.json:依赖写入 imports 字段;
  • package.json:依赖写入某个依赖小节(dependencies / devDependencies / optionalDependencies)。

重要行为load_configs() 在找不到合适的配置文件时会创建一个create_deno_json() / create_package_json() 写入一个 {}),因为 Deno 需要一个配置来管理 node_modules。当 deno.json 不存在、package.json 存在且请求中没有 jsr: 前缀的规范时,会直接使用 package.json 而不创建 deno.json

npm 包在两者并存时落入哪边,由三者共同决定:

  • --package-json 标志(add_flags.package_json):强制 package.json 管理,此时若 package.json 不存在会被创建;
  • preferPackageJson 配置项:效果等同 --package-json
  • 路径就近原则:从源码看,add()prefer_npm_config 的计算是用 path_distance() 比较两个配置文件到起始目录的距离——如果 deno.jsonpackage.json 离 CWD 更近,npm 依赖会写入更近的 deno.json(源码注释给出的例子:deno.json 在 CWD 而 package.json 在父目录时,写入 deno.json)。

另有一个前置校验值得注意:若 deno.json 中存在 importMap 字段,deno add / deno install <pkg> 会直接报错,提示需要把 import map 内联进 Deno 配置文件(mod.rs 中的 bail!)。

ConfigUpdater::add:按 DependencyKind 精确改写

实际的改写由 ConfigUpdater::add(selected, kind)L186)完成,kindDependencyKind 枚举:

enum DependencyKind {
  Normal,
  Dev,
  Optional,
}

impl DependencyKind {
  fn package_json_section(self) -> &'static str {
    match self {
      DependencyKind::Normal => "dependencies",
      DependencyKind::Dev => "devDependencies",
      DependencyKind::Optional => "optionalDependencies",
    }
  }
}

两种配置文件的改写语义不同:

  • deno.jsonkind 被忽略 —— 一切写入 imports。源码中该分支只取 root_object.object_value_or_set("imports"),然后以 import_name -> 包名@版本范围 的形式插入或更新,且按名称字典序选择插入位置(insert_index()),保持 imports 键有序。
  • package.jsonkind 决定小节dependencies / devDependencies / optionalDependencies)。并且 add()从另外两个小节中移除同名包,保证一个包永远不会被声明两次(L239-L254)。新建小节时,new_dependency_section_index()L274)保证稳定的出现顺序:dependenciesdevDependenciesoptionalDependencies —— 例如创建 optionalDependencies 时优先插到已有的 devDependencies 之后,否则插到 dependencies 之后,最后才回落到对象末尾。

对 npm 别名与 JSR 包的写入格式也值得了解(package_json_dependency_entry()L392):

  • 带 npm 前缀且别名与包名一致的,写入 包名 -> 版本;带自定义别名(如 my-lib@npm:pkg@^1)的,别名映射为 npm:pkg@版本
  • jsr: 包写入 package.json 时会被转换为 npm 兼容的 npm:@jsr/<scope 中 / 替换为 __>/<name>@版本 形式。

ConfigUpdater::remove()L300)与此镜像:对 package.json 一次性清理全部三个小节中的匹配项,若删空了整个小节还会顺手移除父属性并修正换行(remove_prop_and_maybe_parent_prop())。

改写基于 jsonc_parser 的 CST(具体语法树)进行,因此能保留原有注释与格式;只有 modified == truecommit() 才会写盘。

包如何真正被安装

配置(可选地)改写并提交后,add() 调用 npm_install_after_modification():它构建一个全新的 CliFactory(以便从磁盘拾取刚编辑过的配置),然后调用 cache_deps::cache_top_level_deps()

cache_top_level_deps()cli/tools/pm/cache_deps.rs)是 addremoveinstalloutdatedauditx 等命令共享的安装例程,其模型是:

  1. 从项目的 import mapdeno.jsonimports)与 package.json 依赖推导图根集合(graph roots)——源码中遍历 import map 条目,jsr: 条目会跳过 workspace 内已声明的 JSR 包、对 dist-tag 的 jsr 规范发出警告并跳过,npm: 条目会跳过版本匹配的 workspace npm 包;
  2. 用 npm 解析构建模块图(build_graph_roots_with_npm_resolution);
  3. npm_installer.cache_packages(PackageCaching::All) 把所有东西物化进 node_modules--lockfile-only 时则只做 install_resolution_if_pending() 的解析安装)。

关键坑:optionalDependencies 永远不会从 package.json 被安装

安装器只能看到 dependenciesdevDependencies。这是外部 deno_package_json crate 的限制:PackageJsonDeps / resolve_local_package_json_deps() 只暴露这两个映射——安装器使用的解析依赖中不存在 optional_dependencies。因此写入 optionalDependencies 的包不会被常规安装路径物化,即使是普通的 deno install 也不例外。

为了让 --save-optional--save-dev(后者在 add 时安装)行为对等,add() 选择直接安装可选包,而不是依赖配置推导的根。具体实现是 CacheTopLevelDepsOptions 中的 additional_roots: Vec<Url> 字段(cache_deps.rs#L26-L32):放入其中的规范会被无条件追加到图根集合(L240)并安装,无论它是否出现在配置文件中。

源码中 add() 的处理逻辑(mod.rs#L742-L780)清晰地注释了这一点:

// Some packages must be resolved and installed directly as additional graph
// roots, rather than relying on them being picked up from the configuration
// file during the install step:
//
// * `--no-save`: the package is installed into `node_modules` (and the
//   lockfile) but not declared as a dependency at all.
// * `--save-optional`: Deno's installer does not materialize
//   `optionalDependencies` from `package.json`, so install the package
//   directly to keep parity with `--save-dev` (which does install on add).
let install_directly = add_flags.no_save || kind == DependencyKind::Optional;

也就是说,add() 会为 --save-optional --no-save(见下)同时填充 additional_roots。真正的修复——让安装器理解 optionalDependencies——是一项跨 crate 的独立改动(上游 deno_package_jsonresolve_local_package_json_deps + npm 安装器),目前尚未完成

标志端到端行为对照

标志 解析为 写入位置 安装行为
--dev / -D DependencyKind::Dev devDependencies(deno.json 则仍是 imports 正常安装(dev 依赖属于配置推导的图根)
--save-optional / -O DependencyKind::Optional optionalDependencies 因安装器忽略该小节,包同时被推入 additional_roots,保证 add 时被安装
--no-save —(不写配置) 不重写、不提交任何配置文件 解析并安装进 node_modules 与 lockfile;实现方式是跳过 ConfigUpdater::add/commit 调用并把包推入 additional_roots
--save-exact / --exact 写入的版本范围 保存精确版本号(不带 ^ find_package_and_select_version_for_reqsave_exact 参数控制
--package-json 强制 package.json 管理(不存在则创建) load_configs()force_package_json 分支

要点补充:

  • --dev--save-optional--no-save 三者互斥,在两套解析器中都用 conflicts_with 强制(可对照 libs/cli_parser/src/defs.rsINSTALL_SUBCOMMAND 的定义:no-savedevsave-optionalentrypointglobal 互斥);
  • additional_roots 还会流经 cache_top_level_deps():建图条件从“有 import map”放宽为“有 import map 有 additional roots”(cache_deps.rs#L55if has_import_map || !options.additional_roots.is_empty()),因此 --no-save 在只有 package.json 而无 import map 的项目中也能工作;
  • 由于 --no-save 跳过 commit,配置内容保持不变,包仅进入 node_modules 与 lockfile。

测试与验证

  • 规格测试位于 tests/specs/add/ 下,相关的目录包括 dev/save_optional/no_save/package_json_flag/exiting_dev_deps/(目录中还有 aliasversion_rangesprefer_package_jsonjsr_prefers_deno_json 等更多场景)。以 tests/specs/add/no_save/test.jsonc 为例,这类测试通常包含 deno.jsonpackage.json 两种工程布局,分别断言配置改写结果。只关心配置结果(不想要下载噪音)的规格测试,会把 add 步骤的输出设为 "output": "[WILDCARD]",然后在后续 eval 步骤中断言文件内容。运行一个子集:./x test-spec add::
  • 解析器单元测试add_or_install_subcommand 同时存在于两套解析器的测试中——

已知限制与后续方向

文档明确列出两项待办(对应源码现状):

  1. 让安装器尊重 package.jsonoptionalDependencies:需要改动上游 deno_package_jsonresolve_local_package_json_deps 与 npm 安装器。完成后 --save-optional 即可走常规的配置推导安装路径,并可以移除为其设置的 additional_roots workaround。在改动完成前,--no-saveadditional_roots 的依赖仍需保留。
  2. --no-save 在无配置目录中会创建空配置文件:这是 load_configs() 的副作用(它需要一份配置来管理 node_modules),与“no save”的字面语义略有冲突;常见场景(已有项目)不受影响。

小结

Deno 的 deno add / deno install <pkg> 是一条“解析 → 决议 → 精确改写配置 → 重建工厂并缓存依赖”的完整管线:两套标志解析器(clap 旧版与 libs/cli_parser/ 手写新版)共同产出 AddFlagscli/tools/pm/mod.rs 中的 ConfigUpdater 基于 JSONC CST 以保留格式的方式把依赖写入 deno.jsonimportspackage.json 的对应小节;cli/tools/pm/cache_deps.rscache_top_level_deps() 从 import map 与 package.json 推导图根并物化 node_modules。理解 additional_roots 这一机制,是理解 --no-save--save-optional 为何必须绕过“配置推导图根”模型的关键;而 optionalDependencies 不被安装器支持这一限制,则是当前实现中最需要向使用者说明的行为边界。

登录后查看全文
热门项目推荐
相关项目推荐