一、先说说我踩过的坑
相信不少用 Phoenix 框架做网站的同学,都经历过类似这样的场景:半夜把样式调整好,高高兴兴地部署到服务器上,结果第二天用户反馈说页面还是老样子。你第一反应是缓存,于是让用户强制刷新,用户照做了,但依然没变化。你开始怀疑是不是部署失败了,检查后端代码没问题,最后打开浏览器开发者工具,发现加载的 CSS 文件地址还是 /assets/app.css,没有带上任何版本号。这就是静态资源缓存失效的典型症状:浏览器认为这个文件名没变,于是直接用了本地缓存,根本没去服务器拿新文件。
要弄明白这个问题,得先看看 Phoenix 默认是怎么处理静态资源的。Phoenix 项目默认用 esbuild 打包前端代码,esbuild 会把 assets/js/app.js 和 assets/css/app.css 这些文件输出到 priv/static/assets 目录。在开发环境,你直接用 mix phx.server 启动,访问页面时加载的就是这些原始文件。到了生产环境,光有这些还不够,因为如果你继续用同样的文件名,浏览器就会像前面说的那样,一直用旧的缓存。
所以 Phoenix 提供了一个非常关键的命令:mix phx.digest。这个命令会扫描 priv/static 下的所有文件,给每个文件的内容算一个哈希值,然后生成一个新的带哈希的文件名,比如 app.css 变成 app-2a9f0b3.css。同时,它还会生成一个 cache_manifest.json,里面记录了原始文件名和带哈希文件名之间的对应关系。这样,只要文件内容变了,哈希就变,文件名就变,浏览器就会乖乖地下载新文件。
听上去很完美,但为什么还会出现缓存失效呢?问题就出在 esbuild 和版本哈希的配合上。
二、默认配置下 esbuild 在做什么
本文所有示例都基于 Elixir / Phoenix 技术栈。
我们看一个典型的 Phoenix 项目里的 mix.exs 配置。在 deps 函数里,你肯定会看到 esbuild 这个依赖。然后在文件底部,通常会有一个 esbuild_config 函数,长这样:
# mix.exs
defp esbuild_config do
[
version: "0.19.0",
default: [
# 这里告诉 esbuild 把 assets/js/app.js 作为入口文件
# 经过打包后输出到 ../priv/static/assets 目录
args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets),
cd: "assets"
]
]
end
注意看 args 里的参数,我们只指定了入口文件和输出目录,并没有告诉 esbuild 生成带哈希的文件名。所以 esbuild 默认会把打包结果输出为 app.js。这个文件被直接放到了 priv/static/assets 目录下。
接下来,你还需要定义一个部署用的 alias,把编译和生成哈希的操作串起来。在 mix.exs 里你会这样写:
# mix.exs
defp aliases do
[
# assets.deploy 这个任务会先运行 esbuild 打包静态资源
# 然后运行 phx.digest 生成带哈希的文件名和 manifest
"assets.deploy": ["esbuild default --minify", "phx.digest"]
]
end
这个流程本身没问题。执行 mix assets.deploy 之后,priv/static/assets 目录下会出现两个文件:一个是原始的 app.js,另一个是类似 app-<hash>.js 的文件,再加上 cache_manifest.json。
2.1 看看 manifest 里到底有什么
很多人可能从来没打开过 cache_manifest.json,其实它长得非常朴素。你可以在项目根目录执行这个命令,瞧一眼它的内容:
# 把生成好的 manifest 文件内容打印到终端
cat priv/static/cache_manifest.json
然后你会看到类似下面这样的 JSON 结构:
{
"version": "1.1.3",
"entries": {
"assets/app.css": "app-2a9f0b3.css",
"assets/app.js": "app-6d2e1f0.js"
}
}
这里的 key 是原始文件名,value 是带哈希的文件名。后面你写模板的时候,只要用对的辅助函数,Phoenix 就会自动去查询这张表,然后返回带哈希的 URL。
三、缓存失效的真正根源
如果你严格按照官方的写法,并且在模板中使用了 Phoenix 提供的辅助函数,理论上不会出问题。但实际情况是,很多人为了方便,直接在模板里写死了资源路径,比如这样:
<!-- 这是一个错误示例:直接硬编码静态资源路径 -->
<link rel="stylesheet" href="/assets/app.css">
<script src="/assets/app.js"></script>
这样写的问题在于,/assets/app.css 这个路径不会自动带上哈希。即使你运行了 mix phx.digest,浏览器请求的还是 /assets/app.css,而 Phoenix 服务器确实也能返回这个文件,因为它存在。但关键的是,这个文件的内容是随着每次部署被覆盖的,可它的名字永远不变。于是浏览器继续拿着旧缓存,压根不会向服务器发起新请求。
正确做法是使用 asset_path 这个辅助函数。它会去查 cache_manifest.json,把原始文件名映射成带哈希的文件名。比如:
<!-- 这是正确的写法,使用 Phoenix 的 asset_path 辅助函数 -->
<link rel="stylesheet" href="<%= asset_path("/assets/app.css") %>">
<script src="<%= asset_path("/assets/app.js") %>"></script>
这里 asset_path 接收一个以 /assets/ 开头的路径,然后返回一个带着哈希的新路径。因为新版本的内容会生成新的哈希,所以每次部署后,文件名都会变化,浏览器的缓存自然就不会命中旧文件。
3.1 为什么普通刷新没用
有时候你自己在服务器上测试,明明已经部署了新代码,但手动刷新页面还是旧样式。这是因为普通刷新(F5)并不会强制绕过缓存。如果响应头里带了 ETag 或者 Last-Modified,浏览器可能会发条件请求,但如果你之前直接硬编码了文件名,服务器返回 304 Not Modified,浏览器还是会用本地缓存的旧资源。所以只有让文件名变化,才能真正避开缓存协商。
四、esbuild 的另一个坑:输出文件名带哈希会怎样
有的人可能会想:既然最终要哈希,那不如让 esbuild 直接生成带哈希的文件名,这样不就省去 phx.digest 一步了吗?听起来很合理,但实践起来会碰到一个更麻烦的问题。
比如你在 esbuild 配置里加上 --entry-names=[name]-[hash],那么输出文件会变成类似 app-abc123.js 这样的名字。这时候你面临两个难题:第一,每次构建的哈希都不同,你怎么在模板里引用它?你总不能手动去改模板吧。第二,mix phx.digest 会扫描到 app-abc123.js,然后把它改名成 app-abc123-<另一个哈希>.js,这个双哈希虽然能工作,但会让你的 cache_manifest.json 变得很混乱,而且 asset_path 也不知道该查哪个原始名。
4.1 尝试一下错误的配置
我们先做一个错误尝试。在 mix.exs 中,给 esbuild 的 args 加上一个参数,让输出文件带上内容哈希:
# mix.exs
# 注意:下面的配置是错误的,请勿直接使用
defp esbuild_config do
[
version: "0.19.0",
default: [
# 这一行多了 --entry-names 参数,会导致输出文件名变成 app-<hash>.js
args: ~w(js/app.js --bundle --target=es2017 --entry-names=[name]-[hash] --outdir=../priv/static/assets),
cd: "assets"
]
]
end
然后你运行 mix assets.deploy,会发现 priv/static/assets 下面出现了类似于 app-1f2e3d4.js 的文件。紧接着 mix phx.digest 又会把它变成 app-1f2e3d4-9a8b7c.js。最后你再去翻 cache_manifest.json,里面的 key 根本不是你熟悉的名字:
{
"version": "1.1.3",
"entries": {
"assets/app-1f2e3d4.js": "app-1f2e3d4-9a8b7c.js"
}
}
如果你的模板里仍然写着 asset_path("/assets/app.js"),那显然查不到对应关系,最终得到的还是不带哈希的 /assets/app.js,问题照样还在。
4.2 为什么不用 esbuild 的哈希
核心原因很简单:Phoenix 的静态资源服务、manifest 映射、以及 asset_path 辅助函数,都是围绕“原始文件名不带哈希”这一假设设计的。如果你强行让 esbuild 参与哈希生成,就相当于把两套哈希逻辑叠加在一起,最后谁都不好过。更关键的是,开发环境你怎么办?难道每次改代码,都去手动查一下新的哈希文件名吗?那开发体验就太糟糕了。
五、到底该怎么配置才靠谱
经过上面的分析,结论已经很清楚了:在 Phoenix 项目里,正确且省心的做法是让 esbuild 输出不带哈希的文件名,然后依靠 mix phx.digest 去统一生成版本哈希。同时,模板里必须使用 asset_path 或者 stylesheet_tag、javascript_tag 这类辅助函数,来动态生成带哈希的 URL。
我们来看一个完整的正确配置。首先,mix.exs 中 esbuild 的配置保持默认,不要加任何哈希相关的参数:
# mix.exs
defp esbuild_config do
[
version: "0.19.0",
default: [
# 注意:不添加 --entry-names 参数,保持输出为 app.js / app.css
args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets),
cd: "assets",
# 可以在这里设置 NODE_PATH 环境变量,避免某些依赖找不到
env: %{"NODE_PATH" => "deps"}
]
]
end
然后,aliases 的设置也保持官方推荐:
# mix.exs
defp aliases do
[
# assets.deploy 会在部署时依次执行两条命令
"assets.deploy": [
"esbuild default --minify",
"phx.digest"
]
]
end
最后,在模板里,一定要使用辅助函数来生成资源路径。比如:
<!-- 推荐使用 stylesheet_tag 和 javascript_tag -->
<%= stylesheet_tag("/assets/app.css") %>
<%= javascript_tag("/assets/app.js") %>
如果需要在 EEx 模板中给标签加别的属性,也可以用 asset_path:
<link rel="preload" href="<%= asset_path("/assets/app.js") %>" as="script">
这样配置之后,每次部署,只要前端代码有变化,mix phx.digest 生成的哈希就会变化,asset_path 返回的 URL 也随之变化,浏览器就会重新下载资源,缓存问题基本不会出现。
5.1 如何确认你的配置生效
你可以在部署完成后,直接打开线上页面的源码,看看 link 和 script 标签里的路径。如果里面带有类似 app-2a9f0b3.css 这样的哈希字符串,那就说明配置成功了。如果还是 app.css,那你大概率是在模板中硬编码了路径。建议用“查看网页源代码”而不是开发者工具,因为开发者工具可能会显示被浏览器解析后的绝对地址。
六、除了文件名,还需要注意什么
版本哈希是解决缓存问题的第一步,但还有一些细节会影响你的部署体验。
第一,HTTP 缓存头。 就算文件名变了,如果服务器返回的响应头里没有正确的 Cache-Control,浏览器可能还是会有一些意料之外的行为。一般而言,对于带哈希的静态资源,我们可以设置长期缓存,比如一年。对于不带哈希的入口文件,比如 HTML 页面,则应该禁用缓存。在 Phoenix 中,可以通过 Plug 静态文件服务来配置。你可以在 lib/your_app/endpoint.ex 里看到类似这样的配置:
# lib/your_app/endpoint.ex
plug Plug.Static,
at: "/",
from: :your_app,
gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt)
如果你想让带哈希的文件被缓存更久,可以自定义 Plug.Static 的 headers 选项,或者使用 ets 缓存管理器。不过大多数情况下,默认配置已经够用了。
第二,开发环境不要开启生产哈希。 mix phx.digest 只在生产环境或明确执行时运行。开发环境没有必要生成哈希,因为你可以实时编译。因此,开发环境直接使用 /assets/app.js 即可。Phoenix 也足够智能,当 cache_manifest.json 不存在时,asset_path 会直接返回你传入的原始路径,不会报错。
第三,esbuild 的版本要盯紧。 esbuild 升级很快,有时候一些参数会发生变化。比如 --entry-names 在旧版本中不可用,新版本才支持。如果你照抄网上的旧配置,可能会导致构建失败。建议锁定一个稳定的版本,并且使用 Phoenix 官方推荐的 esbuild 依赖。
七、总结
回过头来看,静态资源缓存失效其实是“文件名未变化”导致的。Phoenix 提供了 mix phx.digest 这个强大的工具,理论上能解决所有缓存问题,但前提是你必须正确地使用它。esbuild 只负责把资源从 assets 目录编译到 priv/static 目录,它不应该插手最终哈希名的生成。把这两件事分开,按照官方推荐的方式去配置,你就能避免缓存带来的各种烦恼。
如果你现在正被这个问题困扰,先检查两件事:第一,priv/static/cache_manifest.json 是否存在;第二,模板中的资源路径是否使用了 asset_path 或对应的辅助函数。只要这两点没问题,你就可以高枕无忧了。
希望这篇文章能帮到和曾经的我一样,在深夜对着缓存发愁的朋友。记住,让文件名跟着内容走,让哈希去打破缓存,而不是靠运气刷新。
评论
围绕“Phoenix生产环境静态资源缓存失效:使用esbuild与版本哈希的配置难题”参与讨论