任何事情都应力求简单,但不能过于简单。
阿尔伯特·爱因斯坦
从一开始,HTTP 导入就是 Deno 的一个关键特性。多年来,这都是完整的模块系统,旨在通过利用网络的分布式特性来简化 JavaScript 开发,这与 npm 的集中式注册表不同。
例如,你可以这样从标准库导入 assertEquals()
函数
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
assertEquals(1, 2);
这个想法具有划时代意义(并且仍然可以如此)。我们努力追求它,但最终意识到:这个设计决策带来了显著的权衡。
让我们探讨为什么这种方法无法像我们最初希望的那样很好地随项目复杂性扩展,以及 Deno 今天如何推荐共享和使用模块以克服这些挑战。
梦想
围绕 HTTP 导入设计 Deno 的模块系统是一个雄心勃勃的计划。它旨在用基于 HTTP 的分布式系统取代 npm,与 ES Modules 在浏览器中的工作方式保持一致。这消除了对 package.json
文件和 node_modules
文件夹的需求,简化了项目结构。Deno 脚本可以缩减为不带项目目录或配置的单文件程序。与下载大型 tar 包的 npm 不同,HTTP 导入只获取必要的源代码。私有注册表就变成了经过身份验证的代理。
我们将其深度集成到 Deno 的工作流程中,包括缓存、预加载和重新加载。我们还构建了 deno.land/x,一个用于连接 Git 仓库并通过 HTTP 共享它的注册表,并附带生成文档等功能。
现实
尽管有其承诺,但自最初实现以来,HTTP 导入出现了几个问题。
URL 长度
长 URL 会使代码库变得混乱,尤其是在大型项目中。比较一下
import express from "express"; // Node
import oak from "https://deno.land/x/oak@v16.1.0"; // Deno 1.x
Node 的导入方式显然更短(而且更容易记忆)。
依赖管理
随着项目规模的增长,管理长 URL 和版本变得越来越繁琐。
最初,我们采用了 deps.ts
约定,将项目中的依赖项集中到一个文件中
// deps.ts
export { concat } from "https://deno.land/std@0.200.0/bytes/mod.ts";
export * as base64 from "https://deno.land/std@0.200.0/encoding/base64.ts";
然后,依赖项可以像这样导入
import { concat } from "../../deps.ts";
尽管这可行,但与简单的 package.json
文件相比,它显得笨重。
重复依赖
URL 缺乏语义版本控制,使得依赖管理变得困难。
尽管版本字符串可以嵌入到 URL 中(例如,https://deno.land/std@0.224.0/fs/copy.ts
),但 HTTP 导入会将你锁定在一个精确的版本中,除非你手动更新 URL。在大型项目中,这意味着你的代码库中很容易出现同一库的多个变体(这在实践中当然很少是必要或有益的)。
语义版本控制有助于消除重复依赖,减少加载的模块数量。理想情况下,Deno 应该识别可互换的模块,并使用最新版本。
可靠性
去中心化的模块系统也导致了可靠性问题。许多模块托管在随机的网站或个人服务器上,导致了运行时间问题。虽然这些服务器的停机不会立即导致 Deno 程序停机(因为我们缓存了远程依赖项),但它可能会导致 CI 和新部署失败。尽管 Deno 确保其 deno.land/x
注册表具有高可用性,但它无法控制其他主机,使得整体可用性取决于依赖图中可靠性最低的主机。
解决方案
为解决上述所有问题,Deno 引入了两项重大改进:导入映射(Import Maps)和 JSR。
使用导入映射和 JSR 迈向新篇章
让我们明确一点:Deno 并没有移除 HTTP 导入。我们仍然相信它们的实用性。然而,显而易见的是,通常需要更多的结构。
我们致力于通过简化代码的编写和分发来改进 JavaScript 生态系统。JavaScript 作为本质上的默认编程语言,理应拥有一个出色的模块系统。
该解决方案的一部分是 导入映射,这是 Deno 中实现的另一个来自浏览器的 Web 标准。导入映射允许您恢复短小且易于记忆的说明符,并在多个文件中管理版本
{
"imports": {
"$ga4": "https://raw.githubusercontent.com/denoland/ga4/main/mod.ts",
"$marked-mangle": "https://esm.sh/marked-mangle@1.0.1",
"@astral/astral": "jsr:@astral/astral@^0.4.0",
"@fresh/plugin-tailwind": "./plugin-tailwindcss/src/mod.ts",
"@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.10.3"
}
}
然而,导入映射本身并不能解决语义版本控制问题或可靠性问题——这就是 JSR 的作用。
我们创建了 JSR 作为理解语义版本控制的集中式仓库,以解决其余两个问题
- JSR 避免了依赖多个主机提供模块的可靠性问题;以及
- JSR 使用语义版本控制(类似于 Node 中
package.json
与 npm 的工作方式)避免了重复依赖问题。
我们相信这个新的注册表将大大简化 JavaScript 的使用和共享。虽然它确实比 HTTP 导入更复杂一些,但我们认为这些好处值得权衡。
什么是 JSR?
我们于三月推出了 JSR。JSR 是一个开源的跨运行时代码注册表,允许用户轻松共享现代 JavaScript 和 TypeScript。它旨在实现可靠且低成本的托管,由于 不变性保证,它实质上是一个高度缓存的文件服务器。
JSR 理解并强制执行语义版本控制,解决了重复依赖问题。一个集中式仓库也使我们能够提供许多否则无法实现的改进,从简单的 TypeScript 发布 到鼓励最佳实践的 包评分。(您可以在此处阅读更多关于 JSR 的信息,以及我们为何构建 JSR。)
在底层,JSR 仍然使用 HTTP 导入。例如,以下说明符
jsr:@luca/flag
以上实际上可以被认为是智能重定向到
https://jsr.deno.org.cn/@luca/flag/1.0.0/mod.ts
这意味着 JSR 继承了 HTTP 导入中真正出色的部分。 例如:只下载实际导入的代码(没有大型 tarball!)。然而,由于用户不直接接触这些 HTTP 导入,长 URL 和手动字符串管理等问题就消失了。
这对现代 Deno 意味着什么
现有使用 HTTP 导入的 Deno 脚本将继续工作——它们非常适合一定规模的项目。然而,我们现在建议使用导入映射而不是 deps.ts
,并使用 JSR 而不是 deno.land/x
和/或 npm。
因此,回到上面的 assert
示例:你会发现在这个新系统中它更加简洁。而且由于语义版本解析,依赖项会自动保持最新(只要它们没有被 lockfile 锁定)!
// ❌ Deno 1.x:
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
// ✅ Deno 2
import { assertEquals } from "jsr:@std/assert@1";
assertEquals(1, 2);
当在大型项目中使用时,您可以选择添加导入映射以使导入说明符更短,并更容易地在多个文件中管理版本。然后断言示例看起来更加简洁
import { assertEquals } from "@std/assert";
assert(1, 2);
{
"imports": {
"@std/assert": "jsr:@std/assert@1"
}
}
何时或是否选择这种方法取决于您。我们重视 Deno 脚本缩减为单个文件(无需 deno.json
配置)的能力,因此导入映射完全可选非常重要。
Deno 2 即将到来
JavaScript 应该拥有一个与浏览器标准对齐的简单模块系统。我们希望提升生态系统,并帮助它成为我们相信它终将成为的行业基石。
要达到这个目标,我们需要良好的设计,而良好的设计需要迭代——我们必须诚实地审视问题并解决它们。
解决这些问题定义了 Deno 2 中的许多变化
- JSR 用于共享模块,而不是随机的文件服务器
- 使用语义版本控制来管理 Deno 包的版本
- 使用导入映射来管理依赖
Deno 2 还有一些我们尚未讨论的其他特性
- 工作区和 monorepo 支持, 已在 Deno 1.45 中实现
- 深度 Node/npm 兼容性,包括 N-API 支持和与 Next.js 的兼容性
我们将在今年九月发布 Deno 2(这次是真的)。
我很高兴看到人们如何利用下一代“尽可能简单但不更简单”的 JavaScript 工具链。
🚨️ Deno 2 即将到来 🚨️
Deno 2 中有一些 细微的破坏性变更,但您可以使用
DENO_FUTURE=1
标志,今天就能使您的迁移更加顺畅。