关于 Deno 依赖管理的疑惑

我们知道 Node.js (NPM) 使用语义化的版本号来指定依赖版本或者版本范围,并且默认情况下是使用 ^ 这个版本范围,即只要是同一个 major version 并且版本号大于等于指定版本都行。在这种情况下,假如我们有 2 个依赖 A 和 B,A 和 B 都依赖 lodash,A 的 package.json 里面写的是 "lodash": "^4.0.0",B 的 package.json 里面写的是 "lodash": "^4.1.2",那么最终 NPM 安装的 lodash 可能就是 lodash 满足 4.x.x 的最新版,然后 A 和 B 会共用这一份 lodash。

而在 Deno 这边,由于不存在指定版本范围这一概念,所以很可能 A 用的是 "https://deno.land/x/lodash@4.0.0/lodash.js" B 用的是 "https://deno.land/x/lodash@4.1.2/lodash.js",这样就会导致我们最终的依赖里面有 2 个不同版本的 lodash。而且由于 Deno 不存在唯一的中心化仓库,甚至可能出现 A 和 B 虽然用的是同一个版本的 lodash,但 A 用的是 "http://foo/lodash@4.0.0/lodash.js",B 用的是 "http://bar/lodash@4.0.0/lodash.js",这样我们的依赖里就出现了 2 个同一版本的 lodash 了。

如果是 lodash 这样的工具函数库还好,似乎只会带来依赖代码体积膨胀的问题,但如果是 React.js 这种要求全局只能有同一个实例的库,依赖里出现 2 个不同版本的 React.js 就直接 GG 了。

目前还没有解决方案。将来应该会使用第三方工具结合 import maps 来解决

如果我没理解错的话,import maps 似乎是个侵入式的改动,需要把 import _ from "https://deno.land/x/lodash@4.1.2/lodash.js" 改成 import _ from "lodash"。要求目前 Deno 所有的库都按照这种方式去重写他们的依赖导入方式,不太现实。

另外除了我最开始提到的那种情况之外,还有另外一种情况,就是 A 依赖 3.x.x 版本的 lodash,B 依赖 4.x.x 版本的 lodash,那这时正确的做法应该是依赖里就会出现 3.x.x 版本 和 4.x.x 版本的 2 个 lodash。如果你提到的第三方工具要完美 cover 这些 case,我感觉差不多等于再造一个 Deno 版本的 NPM 了,而且由于要兼容 Deno 现有的这套依赖管理的方式,恐怕实现起来的复杂度比 NPM 还要高上不少。

说句题外话,我还是不太喜欢 Deno 直接照抄 Go 的那种简单粗暴的包管理,比起 NPM 来说它缺少了相当多的功能。而它比起 NPM 的优势,像直接拿一个可以访问的 URL 就能作为一个 JS module 导入的这种便利性,在我看来最多只是一些锦上添花的东西,不是非有不可的。

说一个我相当主观的观点,我觉得一个包管理器的「高级」程度,很大程度上决定了这个语言(或者 runtime)的第三方库的生态。正因为 NPM 功能很强大,在一个库里去使用其他库也很方便,所以大家都倾向于把很小的功能也封装成一个独立的库,在一个库里大家也倾向于引入其他大量的第三方库。而对于一个比较「低级」的包管理器,如果用它来安装其他库不是很方便的话,大家就会倾向于自己在项目里去实现相应的功能而不是去装一个相应的库。如果在库里使用另一个库容易造成版本冲突的话,大家就会倾向于只把很多很复杂的代码才封装成一个库,在这个库里也尽量不要使用其他的库。

目前看来 Deno 的包管理确实比起 NPM 缺失了这些功能,在目前 Deno 的一个开发目标就是去兼容 NPM 生态的背景下,这恐怕会成为一个大的限制因素。

2 个赞

类似的 issues 讨论的非常激烈,你可以去官方仓库的 issue 下面看看。目前 fresh 使用的是 import maps 方案

直接把所有外部资源引用写在一个文件deps.ts里,不就搞定。其他地方都从这个文件导入。(修改依赖包版本也方便统一集中在这个文件里管理,不写版本号不就是代表使用最新版)
这包管理就是node的缺点。(npm 和 yarn 依赖使用的是拷贝缓存, pnpm 使用符号链接和硬链接,比npm他们更快)

你没明白我在说什么。

直接的外部资源引用管理当然简单,我值得是在外部资源中所引用的外部资源,即依赖的依赖。

deno 有环境变量 DENO_INSTALL_ROOT 和 缓存 deno cache,加载的依赖都在那吧

没有解决依赖的依赖问题

这个依赖问题确实很麻烦,使用importmap是可以解决这个问题的,比如引入 lodash 通过 import lodash from "lodash" 而非url, 但是这需要包作者和开发者同时设置importmap指定lodash url,这会增加一些不确定性。而且一旦有别的模块通过url引入模块这种方式就失效了。项目复杂到一点程度后这个依赖问题会变的十分棘手。
目前我的感受是尽量减少依赖,但是这会导致项目的utils目录特别臃肿,新项目需要复制粘贴一堆helper

@_ije importmap 是解决不了这个问题的。

这个问题并不是简单地让所有的依赖使用的 lodash 用上同一个版本就行了。

举个简单的例子,在 NPM 体系下,package A 依赖的 lodash 时申明的版本范围是 ^3.4.5,package B 依赖 lodash 时申明的版本范围是 ^3.0.0,package C 依赖 lodash 时申明的版本范围是 ^2.0.0。那么最终会安装两个版本的 lodash,一个是 3.x.x 的最新版,一个 2.x.x 的最新版。package A 和 package B 会用上 3.x.x 那个版本, package C 会用上 2.x.x 版本。

这种情况下靠 import map 就没法做到的。

@hronro 不写全路径就可以解决,fresh 就是这么干的

@justjavac 你说的不写路径是指 "preact/": "https://esm.sh/preact@10.8.2/" 这种写法?请问这解决了上面我提到过的哪个问题?

scoped import maps 可解决这个问题,但是 Deno 目前还不支持。

import map 可以解决安装多个版本的问题,而 scoped 规范可以让多个不同的版本共存

@justjavac 谢谢解答,之前不知道 import map 还可以有 scope,我去看了下 https://github.com/WICG/import-maps#scoping-examples,确实是可以让多个版本共存的。

但我觉得要想让 import map 能够解决我之前提到的问题,至少还有 2 个难题需要解决:

  1. 目前 Deno 里的 import map 都是手写的。参考一下一个中等规模的 Node.js 的依赖的数量,手写 import map 的工作量几乎是不可能完成的。我们需要一个工具能自动分析目前 Deno 项目里的依赖树,并为其生成 import map。
  2. 在目前 Deno 的生态里,我们能唯一确认的依赖的信息就是其 URL,其他的信息如依赖的名字、依赖的版本,都只能从 URL 里进行猜测。即使我们限定所有的依赖都是从 https://deno.land/x 里引用的(虽然这违背了 Deno 去中心化依赖仓库的初衷),其 URL 的格式相对固定,像 https://deno.land/x/lodash@4.17.19/lodash.js 这样我们也只能拿到 4.17.9 这一版本信息,而至于 4.17.9 到底对应了 NPM 中的 "^4.17.9" 还是 "~4.17.9" 还是 "4.17.9",我们是无从得知的。如果没有版本范围只有一个孤零零的版本,是很难将不同依赖中对 lodash 的引用合并成同一个的。

问题 1 可以期待官方或者第三方出工具解决,但问题 2 我目前还没有想到有什么可行的方案。而且如果问题 2 没办法解决的话,在问题 1 里我们实际上也没办法做出一个可靠的工具。

不一定哦,Trex可以帮你生成import map

但如果https://foo/lodash@4.0.0/lodash.js和https://bar/lodash@4.0.0/lodash.js不一样呢?如果某一边不是原版的lodash呢?

所以我觉得deno的依赖管理比node要好得多,因为url是能够无歧义地唯一指定依赖的。
如果不希望使用某个来源的依赖,比如想要把https://bar/lodash@4.0.0/lodash.js改成https://bar/lodash@4.0.0/lodash.js,我觉得应该使用类似url重写的技术,比如由某个东西审核所有的网络请求和原因,然后把import https://bar/$1 重写到 https://foo/$1。

如果是 React.js 这种要求全局只能有同一个实例的库
那么你不应该重新import react,而是要求你的调用者传递进来它使用的react实例。

esm.sh 最近的更新中加入了 external 选项,可以一定程度上改善这个问题:

// import_map.json
{
  "imports": {
    "preact": "https://esm.sh/preact@10.7.2",
    "preact-render-to-string": "https://esm.sh/preact-render-to-string@5.2.0?external=preact",
  }
}

对啊。 import_map 最大的问题就是 通过url 引入失效。

我看fresh 也是通过 deps.ts 或 imports.ts 来解决。
感觉很原始啊。