Importmap 还是 jsbundling?我全都要

从 Rails 7 开始,Importmap 成为处理 JavaScript 加载的默认机制。它可以充分利用 HTTP/2 的并行下载和缓存机制,避免打一个大包每次改动都需要下载所有代码。

对于 js 依赖,Importmap 提供了一个 pin 功能,例如运行:

./bin/importmap pin local-time

Importmap 就会从 CDN 下载 local-time 的 js 文件放到 vendor/javascript 目录,自动添加 config/importmap.rb 配置,随后就可以在 js 文件里面导入:

import LocalTime from "local-time"
LocalTime.start()

但某些 js 库预设开发者会使用打包工具,没有将源码打包成一个完整的包,而是拆分了很多文件,这时候用 importmap pin 就会遇到问题。例如 Lit,如果执行:

bin/importmap pin lit

会看到输出:

Pinning "lit" to vendor/javascript/lit.js via download from https://ga.jspm.io/npm:[email protected]/index.js
Pinning "@lit/reactive-element" to vendor/javascript/@lit/reactive-element.js via download from https://ga.jspm.io/npm:@lit/[email protected]/reactive-element.js
Pinning "lit-element/lit-element.js" to vendor/javascript/lit-element/lit-element.js.js via download from https://ga.jspm.io/npm:[email protected]/lit-element.js
Pinning "lit-html" to vendor/javascript/lit-html.js via download from https://ga.jspm.io/npm:[email protected]/lit-html.js
Pinning "lit-html/is-server.js" to vendor/javascript/lit-html/is-server.js.js via download from https://ga.jspm.io/npm:[email protected]/is-server.js

可以看到 Lit 引用了很多子包。糟糕的是,即使下载了这么多包,导入还是不完整的,如果在 js 代码中 `import { LitElement } from "lit",会在浏览器中报错:

GET http://localhost:3000/assets/css-tag.js net::ERR_ABORTED 404 (Not Found) 

这是因为 @lit/reactive-element 这个包中有很多可选模块没有下载下来。但如果下载所有可选模块,那么 importmap 配置会膨胀得很厉害。有一个 PR 正在处理(#235),不好说能不能解决,因为问题在于库作者没有考虑不打包导入的需求。

那么不妨改变一下思路,先用 jsbunding 将依赖打包,然后再用 importmap 导入。以下展示如何实现。

实现

假设已经使用 Rails 创建了项目,并且默认使用了 importmap:

rails new myapp

代码在 Rails 8.0.0.beta1 测试,但应该可用于 Rails 7+。

接下来安装 jsbundling:

./bin/bundle add jsbundling-rails
./bin/rails javascript:install:esbuild

这时你会看到 js 编译错误,因为 jsbundling 和 importmap 的默认配置有冲突,接下来会修复冲突。

删除 app/views/layouts/application.html.erb 内这行内容:

  <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>

修改 package.json,将内容改为:

  "scripts": {
    "build": "esbuild app/assets/javascripts/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets"
  }

注意路径改为 app/assets/javascripts/*.*,这是以后放置需要 esbuild 编译的 js 文件的目录。

config/applicatoin.rb 里面添加内容:

config.assets.excluded_paths << Rails.root.join("app/assets/javascripts")

创建文件夹 app/javascript/src/,添加文件 app/assets/javascripts/lit.js,内容为:

export * from 'lit';

通过 yarn 安装 lit 包:

yarn add lit

config/importmap.rb 内添加配置:

pin "lit", to: "lit.js"

现在启动开发进程 ./bin/dev ,你会看到 esbuild 将 lit 编译到 app/assets/builds/lit.js。打开浏览器查看页面源码,importmap 的内容增加了:

  "imports": {
    ...
    "lit": "/assets/lit-9c62c803.js",
    ...
  }

整个工作流程是:esbuild 将 app/assets/javascripts 的源码编译到 app/assets/buildapp/assets/build 的内容会被 assets pipeline 处理,在 config/importmap.rb 中添加导入名和文件名的映射,模块就可以被应用的 js 代码导入。

现在,你可以在 js 中 import { LitElement } from "lit"

总结

本文使用 esbuild 和 importmap 结合的方式,解决 impotmap 无法处理复杂依赖的问题。虽然这破坏了 nobuild 的期望,还是能利用到细粒度缓存的优点。在 importmap 普遍被 js 包兼容前,不妨用这个方法处理复杂依赖。