Joplin 依赖剔除技巧:用 @joplin/empty 空包与 Yarn resolutions 中和破坏构建的原生依赖
在大型 monorepo 中,经常会遇到“某个依赖只是被传递依赖顺带引入、实际从不被调用,但它的原生编译又能在某些环境弄崩整个构建”的困境。本文以 Joplin 仓库中的 .yarn/joplin-empty-package/README.md 为核心,讲解 Joplin 如何用 @joplin/empty 这个空包配合 Yarn 的 resolutions 机制,把 canvas、sharp 这类“只要装着就可能坏事”的依赖从构建中整体剔除,读完你可以掌握一套可复制的“依赖中性化”实操方案。
一、问题背景:为什么要把依赖“剔除”而不是“升级”或“禁用”
Joplin 是一个多端(桌面、移动、服务端、CLI、插件生态)的 monorepo,根 package.json 声明了 yarn 4.12.0 / packageManager: yarn@4.16.0 与 packages/* workspaces 布局,并通过 .yarnrc.yml 使用 nodeLinker: node-modules 链接器。在这种依赖树极其庞大的工程里,有两条典型的“麻烦依赖”路径:
canvas:它是pdfjs-dist的可选依赖(optional dependency)。Joplin 本身并不使用它,但canvas需要本地 C 编译,在缺少构建工具链的环境下会让安装/构建直接失败。这正是 README 中给出的原始动机:“canvasis an optional dependency ofpdfjs-dist. However, it isn't used by Joplin and can cause build to fail in certain environments.”sharp:Joplin 的桌面端与服务端引入@huggingface/transformers(用于端侧 AI 功能,见 packages/app-desktop/package.json 与 packages/lib/package.json 中固定版本4.2.0的声明),而 transformers 会传递引入sharp。Joplin 只在构建期需要相关能力,运行期从不调用sharp,因此它同样被“中性化”。.yarn/joplin-empty-package/index.js 的源码注释明确写了这一点:
// Empty stub. Used via the root `resolutions` map to neutralise packages
// pulled in transitively but never actually called (e.g. @xenova/transformers
// → sharp, which we only need at build time).
module.exports = {};
注意“neutralise(中和)”这个措辞:目标不是卸载,而是让它在解析结果中仍然存在(占位、满足依赖树声明),但内容变成一个空对象——这样既不会真的去编译原生代码,也不会让 require('canvas') 之类(假设存在的)引用拿到危险实现。
二、@joplin/empty 空包长什么样:CJS 与 ESM 双入口
整个“空包”只有三个文件,设计得非常克制,但恰好覆盖 Node 两种模块解析体系。
1. package.json:声明双入口的 exports
.yarn/joplin-empty-package/package.json 的关键内容:
{
"name": "@joplin/empty",
"version": "0.0.0",
"description": "An empty package, used as a way to exclude certain packages from build",
"private": true,
"main": "./index.js",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.js",
"default": "./index.js"
}
}
}
几个值得注意的细节:
"private": true:它永远不会被发布到 npm,纯粹是仓库内部的“替身包”;"main": "./index.js"服务于旧的 CJS 解析路径(不做exports推断时回退到 index.js);exports字段则区分import与require两个条件,ESM 引入时命中index.mjs。
2. index.js 与 index.mjs:两行即全部实现
.yarn/joplin-empty-package/index.js:
module.exports = {};
.yarn/joplin-empty-package/index.mjs:
// Empty ESM stub — see index.js. Needed because Node's ESM resolver looks
// at the `exports` field (or `main`) rather than guessing index.js the way
// the legacy CJS resolver does.
export default {};
注释解释了为什么必须单独准备一个 .mjs 文件:Node 的 ESM 解析器会优先读 exports 字段(或 main),不会像传统 CJS 解析器那样自动猜测 index.js。如果空包只提供一个 CJS 入口,ESM 侧的解析可能落到非预期文件上——对“空包”这种极端轻量的替身来说,双入口就是最低成本的兼容性保障。
三、resolutions 是怎么把目标依赖指向空包的
核心机制在根 package.json 的 resolutions 块(Yarn 的 manifest-level 解析重定向)。与空包直接相关的两行是:
"canvas@npm:^2.11.2": "link:./.yarn/joplin-empty-package/",
"@huggingface/transformers/sharp": "link:./.yarn/joplin-empty-package/"
逐条解释:
canvas@npm:^2.11.2:定位器写法,表示“凡是版本匹配^2.11.2的 npm 源canvas包”。这比裸写包名更精确——只拦截特定版本范围,不影响其他场景。@huggingface/transformers/sharp:这是针对“依赖的依赖”的写法,表示“@huggingface/transformers所声明的那个sharp依赖”,精确地把 transformers 这条链路上的 sharp 替换掉,而不伤及仓库中其他可能真实使用 sharp 的包。link:./.yarn/joplin-empty-package/:Yarn 的链接协议,把目标解析到仓库内的本地目录。于是安装时 Yarn 不再从 npm 拉取真正的canvas/sharp发行版,而是把这个本地空目录当作该依赖的实体。
这里还有一个值得指出的细节:README 正文里的示例写的是 “resolving canvas@npm:^2.11 to file:./packages/empty/”,而当前仓库中实际生效的路径是 link:./.yarn/joplin-empty-package/。从源码结构看,这个包从早期的 packages/empty/ 移动到了 .yarn/joplin-empty-package/(与根目录下同样存放 patches 的 .yarn/ 区域放在一起),文档示例属于历史路径;以 package.json 的 resolutions 实际内容为准。
从 yarn.lock 验证“剔除”确实生效
yarn.lock 中的解析记录可以直接证明重定向已生效(约第 24723 行与第 53125 行):
"canvas@link:./.yarn/joplin-empty-package/::locator=root%40workspace%3A.":
resolution: "canvas@link:./.yarn/joplin-empty-package/::locator=root%40workspace%3A."
"sharp@link:./.yarn/joplin-empty-package/::locator=root%40workspace%3A.":
resolution: "sharp@link:./.yarn/joplin-empty-package/::locator=root%40workspace%3A."
两条 resolution 都指向同一个本地空包目录,说明在 Yarn 的依赖解析结果中,canvas 与 sharp 的最终实体就是 @joplin/empty——原生编译、下载预编译二进制等安装脚本自然无从发生。这也是验证这类手法是否真正起效的最直接方法:在 lockfile 中搜索目标包名,确认其 resolution 指向你的空包。
四、可复制的操作步骤:在自己的 monorepo 中“中性化”一个依赖
Joplin 的做法可以抽象为四步,适用于任何使用 Yarn 2+ 的工程:
- 建一个空包。新建目录(Joplin 放在 .yarn/joplin-empty-package/),包含三部分:
package.json:设private: true,提供main与exports双入口;index.js:module.exports = {};;index.mjs:export default {};(防止 ESM 解析落空)。
- 在根 package.json 的
resolutions中加映射,按需选择定位器写法:前者拦截 npm 源的某版本范围,后者精确拦截“某个上游依赖声明的那个包”。"resolutions": { "问题包@npm:^x.y.z": "link:./空包目录/", "上游包/问题包": "link:./空包目录/" } - 重新安装并检查 lockfile,确认
resolution已指向空包(对照本文第三节yarn.lock的验证方式)。 - 确认运行期确实无人调用该包。这是整套手法成立的前提——README 中反复强调的是 “never actually called”。如果代码里真的
require('canvas')并调用了其 API,空对象会立刻在运行时暴露为属性缺失错误,那时应该做的是换实现或补环境,而不是剔除。
五、注意事项与适用边界
- 只适用于“从不被调用”的依赖。空包返回
{},一旦真实调用其 API 就是运行时错误;它的价值恰恰在于“占位而不工作”。 - ESM 场景必须提供
.mjs入口。如 index.mjs 注释所述,Node ESM 解析不会按 CJS 习惯猜测 index 文件,缺了它替身可能失效。 - 定位器要写准。
canvas@npm:^2.11.2、@huggingface/transformers/sharp这类精确写法可以避免误伤其他合法依赖链;写得太宽(裸包名)可能把真实需要的依赖也换掉。 - README 中的示例路径已过时。文档示例指向
file:./packages/empty/,当前仓库实际使用的是link:./.yarn/joplin-empty-package/,阅读时以根 package.json 的resolutions为准。 - 依赖 Yarn 的 manifest resolutions 能力。README 引用的 Yarn 官方
resolutions文档(Manifest resolutions)说明了该机制的通用语义;npm 的overrides、pnpm 的overrides有类似思路,但语法与本协议不同,不能照搬link:写法。
小结
Joplin 用不到二十行的代码(一个双入口空包 + 两条 resolutions 映射)解决了一个很实际的问题:把 canvas(pdfjs-dist 的可选原生依赖)和 sharp(transformers 的传递依赖)从多端构建的依赖树中“中和”掉,既不破坏依赖声明的完整性,又避免原生编译在安装链路上制造失败。这套“空包 + resolutions”的组合对任何被原生可选依赖折磨过的大型 Node 工程都有直接的参考价值,而 yarn.lock 中两条 link: 解析记录则是这套机制生效的一手证据。
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