Deno 依赖管理全解:deno add / deno install 的配置改写、node_modules 安装与源码级原理
本文为 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 添加依赖的完整流程可以概括为四步:
- 标志解析:
deno add pkg或deno install pkg(install 的本地安装分支)解析出统一的AddFlags结构; - 版本决议:
add()将每个包请求解析为具体版本(find_package_and_select_version_for_req); - 配置改写:由
ConfigUpdater决定改写deno.json的imports还是package.json的某个依赖小节,并落盘; - 实际安装:
npm_install_after_modification()构建新CliFactory后调用cache_top_level_deps(),把包物化到node_modules并更新 lockfile。
核心实现集中在 cli/tools/pm/mod.rs 与 cli/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.rs(
ADD_SUBCOMMAND、INSTALL_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.rs 与 libs/cli_parser/src/tests_full.rs 中;共享测试用例 add_or_install_subcommand 会对 add 和 install 两个子命令循环断言,是两套解析器行为一致性的护栏。
大写字母短标志的 lint 约束
仓库还有一条自定义 lint(tools/lint.js 中的 ensureNoNonPermissionCapitalLetterShortFlags)禁止大写字母短标志,除非在带文档化先例的显式白名单中。-D(dev)与 -O(save-optional)在白名单内,二者的正当性都来自与 npm install 对应短标志保持一致。这也是为什么在 libs/cli_parser/src/defs.rs 的 INSTALL_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.json比package.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)完成,kind 是 DependencyKind 枚举:
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.json:kind被忽略 —— 一切写入imports。源码中该分支只取root_object.object_value_or_set("imports"),然后以import_name -> 包名@版本范围的形式插入或更新,且按名称字典序选择插入位置(insert_index()),保持imports键有序。package.json:kind决定小节(dependencies/devDependencies/optionalDependencies)。并且add()会从另外两个小节中移除同名包,保证一个包永远不会被声明两次(L239-L254)。新建小节时,new_dependency_section_index()(L274)保证稳定的出现顺序:dependencies→devDependencies→optionalDependencies—— 例如创建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 == true 时 commit() 才会写盘。
包如何真正被安装
配置(可选地)改写并提交后,add() 调用 npm_install_after_modification():它构建一个全新的 CliFactory(以便从磁盘拾取刚编辑过的配置),然后调用 cache_deps::cache_top_level_deps()。
cache_top_level_deps()(cli/tools/pm/cache_deps.rs)是 add、remove、install、outdated、audit、x 等命令共享的安装例程,其模型是:
- 从项目的 import map(
deno.json的imports)与 package.json 依赖推导图根集合(graph roots)——源码中遍历 import map 条目,jsr:条目会跳过 workspace 内已声明的 JSR 包、对 dist-tag 的 jsr 规范发出警告并跳过,npm:条目会跳过版本匹配的 workspace npm 包; - 用 npm 解析构建模块图(
build_graph_roots_with_npm_resolution); - 由
npm_installer.cache_packages(PackageCaching::All)把所有东西物化进node_modules(--lockfile-only时则只做install_resolution_if_pending()的解析安装)。
关键坑:optionalDependencies 永远不会从 package.json 被安装
安装器只能看到 dependencies 和 devDependencies。这是外部 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_json 的 resolve_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_req 的 save_exact 参数控制 |
--package-json |
— | 强制 package.json 管理(不存在则创建) |
见 load_configs() 的 force_package_json 分支 |
要点补充:
--dev、--save-optional、--no-save三者互斥,在两套解析器中都用conflicts_with强制(可对照 libs/cli_parser/src/defs.rs 中INSTALL_SUBCOMMAND的定义:no-save与dev、save-optional、entrypoint、global互斥);additional_roots还会流经cache_top_level_deps():建图条件从“有 import map”放宽为“有 import map 或 有 additional roots”(cache_deps.rs#L55 的if 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/(目录中还有alias、version_ranges、prefer_package_json、jsr_prefers_deno_json等更多场景)。以 tests/specs/add/no_save/test.jsonc 为例,这类测试通常包含deno.json与package.json两种工程布局,分别断言配置改写结果。只关心配置结果(不想要下载噪音)的规格测试,会把add步骤的输出设为"output": "[WILDCARD]",然后在后续eval步骤中断言文件内容。运行一个子集:./x test-spec add::。 - 解析器单元测试:
add_or_install_subcommand同时存在于两套解析器的测试中——cli/args/flags.rs:cargo test -p deno --lib add_or_install- libs/cli_parser/src/tests_full.rs:
cargo test -p deno_cli_parser add_or_install
已知限制与后续方向
文档明确列出两项待办(对应源码现状):
- 让安装器尊重
package.json的optionalDependencies:需要改动上游deno_package_json的resolve_local_package_json_deps与 npm 安装器。完成后--save-optional即可走常规的配置推导安装路径,并可以移除为其设置的additional_rootsworkaround。在改动完成前,--no-save对additional_roots的依赖仍需保留。 --no-save在无配置目录中会创建空配置文件:这是load_configs()的副作用(它需要一份配置来管理node_modules),与“no save”的字面语义略有冲突;常见场景(已有项目)不受影响。
小结
Deno 的 deno add / deno install <pkg> 是一条“解析 → 决议 → 精确改写配置 → 重建工厂并缓存依赖”的完整管线:两套标志解析器(clap 旧版与 libs/cli_parser/ 手写新版)共同产出 AddFlags;cli/tools/pm/mod.rs 中的 ConfigUpdater 基于 JSONC CST 以保留格式的方式把依赖写入 deno.json 的 imports 或 package.json 的对应小节;cli/tools/pm/cache_deps.rs 的 cache_top_level_deps() 从 import map 与 package.json 推导图根并物化 node_modules。理解 additional_roots 这一机制,是理解 --no-save 与 --save-optional 为何必须绕过“配置推导图根”模型的关键;而 optionalDependencies 不被安装器支持这一限制,则是当前实现中最需要向使用者说明的行为边界。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00