Rails Importmap 还是 jsbundling?我全都要

Rei · 2024年10月12日 · 最后由 Rei 回复于 2024年10月15日 · 243 次阅读

原文地址: https://geeknote.net/Rei/posts/3049


从 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 包兼容前,不妨用这个方法处理复杂依赖。

问题:如果只用 jsbundling 呢?

daqing 回复

我不想每次改代码都让 js 缓存整个失效。codemirror 依赖压缩之后有 230 KB,未压缩前是 800 多 KB。

通过 importmap 提供可以让每个依赖单独缓存。

Rei 回复

又研究了一下,代码分割也可以在 esbuild 这层做

https://esbuild.github.io/api/#external

不知道会不会跟 asset pipeline 的 hash 文件名冲突,做过的可以分享一下。

Rei 回复

好像有看过一个类似的问题,可以通过让 esbuild/webpack 这一类构建工具的构建产物文件名中包含 digested 的方式来跳过 Sprockets 给文件名加 Hash 的逻辑(https://github.com/rails/sprockets/blob/e4686d580512918669c0eb7a2bc16ba6269dd17d/lib/sprockets/asset.rb#L66)。

例如 webpack 构建的文件名格式是 [name]-[contenthash].digested.js,之后再由 Sprockets 处理的时候就不会加 Hash 而是直接使用原有的文件名了。

时间有点久了,不知道是不是说的同一件事情😂

yuchiXiong 回复

Propshaft 文档提到了 https://github.com/rails/propshaft?tab=readme-ov-file#bypassing-the-digest-step

所以用 esbuild 处理也可以。区别是一个默认拆分,选择性打包;一个默认打包,选择性拆分。看自己习惯哪种。

Rails 8 會取消打包吧?

Aiken00 回复

7 默认也不打包了。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号