Vue 基础体系 · 第 64/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 静态站点交付:Nginx、CDN、History 回退、缓存和压缩
Vue 3 应用在开发服务器中由 Vite 提供服务时,访问路径、模块加载、History 路由和静态资源通常会被工具链自动处理;部署到生产环境后,浏览器首先面对的是 Nginx 或 CDN,而不是 Vue。此时 Vue 应用已经变成一组静态文件,交付问题转化为:
- 构建产物应该如何生成和放置;
- Nginx 如何找到静态文件;
- 使用
history路由时,刷新或直接访问深层 URL 如何仍然返回应用入口; - HTML、JavaScript、CSS、图片分别应该缓存多久;
- CDN 如何参与缓存和回源;
- gzip、Brotli 和预压缩文件如何影响响应;
- 部署新版本、回滚和排查故障时,哪些状态必须保持一致。
本文示例基于 Vue 3、Composition API、TypeScript 和现代 Vite 工具链。Vite 的构建行为、配置项和默认目录应以当前版本文档为准;下面使用的是 Vite 当前工具链中稳定且常见的能力。
一、从 Vue 源码到浏览器请求
一个 Vue 静态站点至少涉及三类文件:
源码
├── index.html
├── src/
│ ├── main.ts
│ ├── App.vue
│ └── router.ts
└── public/
构建产物
└── dist/
├── index.html
└── assets/
├── index-xxxxxxxx.js
├── index-yyyyyyyy.css
└── logo-zzzzzzzz.svg
执行:
npm install
npm run build
典型结果类似:
vite v...
✓ ... modules transformed.
dist/index.html 0.xx kB
dist/assets/index-xxxxxxxx.js xx.xx kB │ gzip: xx.xx kB
dist/assets/index-yyyyyyyy.css x.xx kB │ gzip: x.xx kB
✓ built in ...ms
这里的 dist 是静态站点的发布目录。Nginx 不需要理解 .vue 文件、Composition API 或 TypeScript,它只需要根据 URL 映射到 dist 下的文件,并将文件内容返回给浏览器。
1. Vite 的 base 决定资源 URL
假设站点部署在域名根路径:
https://example.com/
通常可以使用默认配置:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/',
})
构建后的 index.html 可能引用:
<script type="module" src="/assets/index-xxxxxxxx.js"></script>
如果站点实际部署在:
https://example.com/console/
则构建资源必须以 /console/ 为基础路径:
export default defineConfig({
plugins: [vue()],
base: '/console/',
})
否则 HTML 会请求 /assets/index-xxxxxxxx.js,而真实文件可能位于 /console/assets/index-xxxxxxxx.js,结果就是入口 HTML 返回成功,但 JavaScript 加载失败。
Vue Router 的 history 模式也需要使用相同的基础路径:
// src/router.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'
import AboutView from './views/AboutView.vue'
export const router = createRouter({
history: createWebHistory('/console/'),
routes: [
{ path: '/', component: HomeView },
{ path: '/about', component: AboutView },
],
})
在根路径部署时,createWebHistory('/') 或不传参数通常即可;在子路径部署时,base 和 Router 的基础路径必须保持一致。这里的路径不一致属于构建配置错误,不是 Nginx 缓存问题。
2. 为什么文件名中有 hash
Vite 通常会为 JavaScript、CSS 和其他参与构建的资源生成带内容指纹的文件名:
assets/index-a1b2c3d4.js
assets/index-e5f6g7h8.css
当文件内容发生变化时,文件名通常也会变化。于是可以将这类文件缓存很久:
Cache-Control: public, max-age=31536000, immutable
其中:
max-age=31536000表示响应在一年内可以直接使用;immutable表示在新鲜期内,客户端不必为了确认文件是否变化而再次发起条件请求;- 只有文件名变化时,浏览器才会请求新文件。
这依赖一个重要前提:已经发布的带 hash 文件不能被原地修改。如果 index-a1b2c3d4.js 的 URL 不变却被替换为另一份内容,长缓存会让用户继续使用旧内容,甚至永久使用错误内容直到缓存失效。
二、History 路由为什么需要服务器回退
1. 两种前端路由的请求形式
Vue Router 常见的两种路由模式如下。
Hash 模式
https://example.com/#/users/42
浏览器请求服务器时,# 以及后面的片段不会发送给 HTTP 服务器。Nginx 实际收到的仍然是:
GET /
因此服务器只要返回 index.html,Vue Router 在浏览器中读取 hash 后即可显示对应页面。
History 模式
https://example.com/users/42
浏览器会真实请求:
GET /users/42
如果 Nginx 只按文件系统查找路径,通常会得到:
/srv/www/app/current/users/42
这个文件一般不存在,于是返回 404。此时 Vue 代码甚至还没有加载,客户端路由没有机会接管请求。
2. History 回退的准确含义
History 回退不是“所有请求都返回 index.html”,而是:
当请求对应的静态文件不存在,且该请求可能是前端路由路径时,服务器返回应用入口
index.html,让 Vue Router 在浏览器中继续解析路径。
典型 Nginx 配置:
location / {
try_files $uri $uri/ /index.html;
}
try_files 按顺序尝试:
$uri:请求路径对应的文件;$uri/:请求路径对应的目录;/index.html:前两者都不存在时,内部回退到应用入口。
例如请求 /about:
$uri = /about → 文件不存在
$uri/ = /about/ → 目录不存在
回退 = /index.html → 返回 Vue 应用入口
浏览器收到 index.html 后加载 JavaScript,Vue Router 读取当前 URL /about,最后渲染 AboutView。
3. 为什么不能无条件回退
假设用户请求:
GET /assets/missing.js
如果 Nginx 把所有未知路径都回退到 index.html,响应可能是:
HTTP/1.1 200 OK
Content-Type: text/html
浏览器却把它当作 JavaScript 加载,于是出现类似错误:
Failed to load module script:
Expected a JavaScript module script but the server responded with a MIME type of "text/html".
这会掩盖真实问题:文件确实缺失,但服务器返回了错误类型的成功响应。
因此,History 回退应该与静态资源和 API 请求区分开来:
location ^~ /assets/ {
try_files $uri =404;
}
location = /index.html {
# 单独配置入口 HTML 的缓存策略
}
location /api/ {
proxy_pass http://backend;
}
location / {
try_files $uri $uri/ /index.html;
}
这里:
/assets/下的文件缺失时返回404;/api/交给后端,不应返回 Vue HTML;- 其他不存在的路径才可能被视为前端路由并回退到
index.html。
^~ 用于让 /assets/ 的前缀匹配优先于后续正则 location,避免资源请求被意外送到其他处理逻辑。
三、完整的请求和交付链路
生产环境通常不是浏览器直接访问 Nginx,而是:
flowchart LR
B[浏览器] --> C[CDN 边缘节点]
C -->|缓存命中| B
C -->|缓存未命中| N[Nginx 源站]
N --> F[静态文件系统]
N -->|/api/| A[后端 API]
N -->|不存在的前端路径| I[index.html]
F --> N
I --> N
N --> C
C --> B
以首次访问 /users/42 为例:
- 浏览器向 CDN 请求
/users/42; - CDN 没有缓存,回源到 Nginx;
- Nginx 在文件系统中找不到
/users/42; try_files回退到/index.html;- Nginx 返回 HTML;
- CDN 根据响应头决定是否缓存;
- 浏览器解析 HTML,继续请求
/assets/index-a1b2c3d4.js; - 对资源请求,Nginx 找到真实文件,不执行 History 回退;
- CDN 缓存带 hash 的资源;
- Vue 应用启动,Vue Router 在客户端渲染
/users/42。
这里有两个不同层面的路由:
- 服务器路由:决定请求由静态文件、回退入口或 API 处理;
- 客户端路由:Vue Router 加载后,决定显示哪个组件。
History 回退只负责把入口程序送到浏览器,不能替代 Vue Router 的路由定义,也不能让服务端执行 Vue 组件。
四、Nginx 静态站点配置
以下配置适用于部署在域名根路径、使用 Vue Router History 模式的站点。
server {
listen 80;
server_name example.com;
root /srv/www/app/current;
index index.html;
# API 必须先于通用前端回退规则处理
location ^~ /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 带内容 hash 的构建资源可以长期缓存
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# 入口 HTML 不应被长期缓存
location = /index.html {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
# 其余静态文件和前端路由
location / {
try_files $uri $uri/ /index.html;
}
# 只对文本类响应启用 gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/plain
text/css
text/xml
application/javascript
application/json
application/xml
application/wasm
image/svg+xml;
}
1. 这份配置的前置条件
配置假设:
/srv/www/app/current/index.html
/srv/www/app/current/assets/...
确实存在,并且 Nginx worker 用户有读取权限。可以先验证:
sudo nginx -t
成功时通常输出:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
然后重新加载:
sudo systemctl reload nginx
reload 通常会让新 worker 使用新配置,同时尽量让已有连接平滑结束;不要在没有必要时使用会立即终止连接的停止方式。
检查文件和权限:
ls -l /srv/www/app/current/index.html
find /srv/www/app/current/assets -maxdepth 1 -type f | head
namei -l /srv/www/app/current/index.html
namei -l 可以逐级显示目录权限,适合排查“文件存在但 Nginx 仍然 403”的情况。
2. add_header 的边界
Nginx 的 add_header 受响应状态和配置继承规则影响。示例中使用 always,表示即使响应是某些错误状态,也添加该响应头。
但这不代表所有状态都能随意设置相同缓存策略。比如:
index.html的404不应被 CDN 长期缓存;- 错误页不应继承静态资源的一年缓存;
- 如果在更深层
location中重新写add_header,可能改变继承行为。
配置完成后必须用实际请求验证,而不能只看配置文件。
五、缓存:HTML、资源和 CDN 不是同一个概念
1. HTTP 缓存的基本状态
一个响应是否可以直接使用,取决于缓存对象、缓存时间和验证信息。
常见响应头:
Cache-Control: public, max-age=31536000, immutable
ETag: "a1b2c3d4"
Last-Modified: Tue, 12 Mar 2024 10:00:00 GMT
max-age 的单位是秒。若当前时间与响应生成时间之差小于 max-age,缓存通常可直接返回;超过后,缓存可以带着验证信息重新询问源站:
If-None-Match: "a1b2c3d4"
如果内容未变,源站返回:
HTTP/1.1 304 Not Modified
304 没有重新传输完整响应体,浏览器继续使用本地内容。
no-cache 容易被误解。它不是“禁止存储”,而是要求使用前重新验证。若目标是禁止缓存,可使用:
Cache-Control: no-store
对于公开静态站点,入口 HTML 常用:
Cache-Control: no-cache
因为 HTML 很小,重新验证成本通常可接受,同时浏览器仍可以保存其副本。
2. 推荐的资源分类
| 内容 | 示例 | 典型策略 | 原因 |
|---|---|---|---|
| 入口 HTML | /index.html |
no-cache |
它引用当前版本资源清单 |
| 带 hash 的 JS/CSS | /assets/index-a1b2c3d4.js |
长期缓存、immutable |
URL 变化代表内容变化 |
| 指纹化图片/字体 | /assets/logo-a1b2.svg |
长期缓存 | 同样依赖不可变 URL |
| 未指纹化文件 | /favicon.ico |
短期缓存或重新验证 | 内容变化时 URL 不变 |
| API 响应 | /api/users |
按业务决定 | 可能包含用户或实时数据 |
不能因为文件位于 /assets/ 就自动认为它永远安全地长期缓存。若部署脚本会原地覆盖某个固定 URL,必须缩短缓存时间或修改发布方式。
3. 为什么 HTML 不能和静态资源使用同一缓存策略
设当前版本入口为:
<script type="module" src="/assets/index-new.js"></script>
如果浏览器或 CDN 长期缓存旧 HTML:
<script type="module" src="/assets/index-old.js"></script>
即使新版本已经发布,用户仍可能持续拿到旧入口。反过来,如果 HTML 已经更新,而旧的 index-old.js 被删除,旧页面可能报:
GET /assets/index-old.js 404
因此发布系统需要同时考虑:
- HTML 尽快重新验证;
- 带 hash 的资源长期缓存;
- 旧资源在一段时间内保留,避免旧 HTML、正在运行的旧页面或回滚版本找不到依赖。
4. current 软链接和原子切换
可以把每次构建放在独立目录:
/srv/www/app/releases/20240312-1000/
/srv/www/app/releases/20240313-0900/
/srv/www/app/current -> /srv/www/app/releases/20240313-0900/
发布流程示例:
set -euo pipefail
release="/srv/www/app/releases/$(date +%Y%m%d-%H%M%S)"
npm ci
npm run build
mkdir -p "$release"
cp -a dist/. "$release/"
ln -sfn "$release" /srv/www/app/current
sudo nginx -t
sudo systemctl reload nginx
这里的关键不是 ln 命令本身,而是发布顺序:
- 在新目录完整构建;
- 检查
index.html和资源是否存在; - 最后切换
current; - Nginx 读取到的是完整目录,而不是正在复制中的目录。
保留旧 release 可以支持快速回滚:
ln -sfn /srv/www/app/releases/20240312-1000 /srv/www/app/current
sudo systemctl reload nginx
但如果 CDN 仍缓存了新 HTML,回滚源站不一定立即改变用户看到的内容,还需要根据 CDN 的缓存规则等待过期或执行精确刷新。
六、CDN 如何改变交付过程
CDN 是分布在多个网络位置的缓存和代理节点。浏览器请求的域名解析到 CDN 后:
- 命中缓存:CDN 直接响应;
- 未命中:CDN 向源站 Nginx 回源;
- 回源响应可被 CDN 保存,供后续用户使用。
1. CDN 缓存什么由什么决定
CDN 通常会综合以下因素:
- 请求 URL;
- 查询字符串;
- 请求方法;
Cache-Control;Expires;- CDN 自己配置的缓存规则;
- 是否允许缓存带认证信息的响应;
Vary等响应头。
因此,源站设置:
Cache-Control: no-cache
不代表某个 CDN 一定会严格执行。如果 CDN 控制台配置了“忽略源站缓存头并缓存 HTML 1 小时”,实际行为就会由 CDN 规则决定。
对于静态站点,常见 CDN 规则是:
/index.html 不缓存或极短缓存
/assets/* 长期缓存
/api/* 不缓存,直接回源
如果使用 SPA History 回退,还要确认 CDN 在边缘节点拿不到 /users/42 时会回源,而不是直接生成 CDN 自己的 404。否则 Nginx 的回退规则根本不会执行。
2. CDN 缓存键和查询参数
两个 URL 是否共享缓存,取决于缓存键。常见情况下:
/assets/app.js?v=1
/assets/app.js?v=2
可能被视为不同缓存对象,也可能被 CDN 忽略查询参数后视为同一个对象。
这会影响:
- 资源版本参数是否真的生效;
- API 查询结果是否被错误复用;
- 带签名 URL 是否能正常工作。
对于 Vite 生成的 hash 文件,通常不需要额外添加 ?v=。文件名本身已经承担了版本标识,减少缓存键差异更容易排查。
3. 发布后的缓存一致性
发布新版本时可能出现这样的状态:
CDN 中仍是旧 index.html
源站已经切换到新 index.html
旧资源正在被删除
或者:
CDN 已缓存新 index.html
但某个边缘节点仍没有新 JavaScript
如果资源 URL 带 hash,第二种情况通常只是 CDN 回源获取新文件;真正危险的是删除旧资源。建议:
- 新资源使用新 hash;
- 旧资源保留一段时间;
- HTML 使用短缓存或重新验证;
- 必要时只刷新
/index.html,不要无差别清空整个 CDN; - CDN 刷新后验证多个边缘位置,而不是只验证源站。
CDN 刷新是供应商能力,不是 HTTP 标准保证。不同供应商的路径刷新、通配符刷新、刷新传播时间和费用都可能不同,发布脚本必须以实际 CDN 产品行为为准。
七、压缩:传输编码不是文件格式转换
压缩的目标是减少 HTTP 响应体在网络上的传输大小。常见算法包括:
- gzip:部署广泛,兼容性好;
- Brotli:对文本资源通常有较好的压缩效果,现代浏览器支持广泛;
- zstd:在其他场景中逐渐使用,但不能假设所有浏览器和 CDN 都支持。
浏览器会通过请求头声明能力:
Accept-Encoding: gzip, deflate, br
服务器根据协商结果返回:
Content-Encoding: gzip
或:
Content-Encoding: br
Content-Encoding 描述的是传输时编码,Content-Type 仍描述原始内容类型:
Content-Type: application/javascript
Content-Encoding: br
不能把压缩后的响应错误标成某种文件类型。浏览器先根据 Content-Encoding 解压,再根据 Content-Type 解析。
1. Nginx 动态 gzip
前面配置中的:
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/plain
text/css
application/javascript
application/json
image/svg+xml;
含义是:
gzip on:启用 gzip;gzip_vary on:添加Vary: Accept-Encoding;gzip_min_length 1024:小于 1024 字节的响应不压缩;gzip_comp_level 5:在 CPU 消耗和压缩率之间取中间值;gzip_types:声明需要压缩的 MIME 类型。
Vary: Accept-Encoding 很重要。相同 URL 对支持 gzip 的客户端和不支持 gzip 的客户端可能有不同响应。如果共享缓存没有区分 Accept-Encoding,可能把 gzip 内容发给不支持 gzip 的客户端,或者缓存未压缩版本供支持压缩的客户端使用。
现代 CDN 往往会在边缘节点自行压缩或按照自己的缓存策略处理 Accept-Encoding。因此源站是否压缩、CDN 是否重新压缩,需要用实际响应头验证,不能只凭配置推断。
2. 预压缩文件与 gzip_static
也可以提前生成压缩文件:
gzip -k -9 dist/assets/index-xxxxxxxx.js
结果类似:
index-xxxxxxxx.js
index-xxxxxxxx.js.gz
然后让 Nginx 在客户端支持 gzip 时优先使用 .gz 文件:
location ^~ /assets/ {
try_files $uri =404;
gzip_static on;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
gzip_static 并不会自动为所有文件生成 .gz,它只是尝试读取已经存在的预压缩文件。若 .gz 文件不存在,Nginx 会回到普通文件处理流程。
实际使用预压缩文件时,需要验证:
curl -I -H 'Accept-Encoding: gzip' \
https://example.com/assets/index-xxxxxxxx.js
预期可能包含:
HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Encoding: gzip
Vary: Accept-Encoding
如果只看到 Content-Type,没有 Content-Encoding,说明请求没有使用 gzip 版本,可能是:
.gz文件不存在;gzip_static没有在实际生效的 location 中开启;- CDN 已经回源或重新处理;
- 请求头没有声明 gzip;
- Nginx 配置未成功加载。
3. Brotli 的配置边界
标准 Nginx 安装通常自带 gzip,但 Brotli 是否可用取决于 Nginx 构建方式、动态模块或发行版打包方式。不能直接假设下面配置在所有服务器有效:
brotli on;
如果模块不存在,nginx -t 可能报未知指令。启用 Brotli 前应确认:
nginx -V 2>&1
检查编译参数和模块信息,并遵循当前发行版或供应商的安装方式。
如果 CDN 支持 Brotli,常见做法是让 CDN 在边缘协商 Brotli,而源站至少提供未压缩或 gzip 响应。无论使用哪种方案,都必须检查:
curl -I -H 'Accept-Encoding: br' \
https://example.com/assets/index-xxxxxxxx.js
curl -I -H 'Accept-Encoding: gzip' \
https://example.com/assets/index-xxxxxxxx.js
curl -I \
https://example.com/assets/index-xxxxxxxx.js
三次请求的 Content-Encoding 可以不同,但 Content-Type、缓存键和 Vary 必须保持逻辑一致。
4. 哪些文件不应继续压缩
JPEG、PNG、WebP、AVIF、已经压缩的字体或压缩包通常已经经过专用压缩。再次 gzip 往往收益很小,却增加 CPU 和延迟。
JavaScript、CSS、JSON、HTML、SVG 是文本资源,通常更适合压缩。对于已经很小的响应,压缩头部和 CPU 成本可能超过节省的字节数,因此需要 gzip_min_length 一类阈值。
八、一个可运行的最小 Vue 例子
创建项目:
npm create vite@latest vue-delivery-demo -- --template vue-ts
cd vue-delivery-demo
npm install
npm install vue-router
src/router.ts:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'
import AboutView from './views/AboutView.vue'
export const router = createRouter({
history: createWebHistory('/'),
routes: [
{
path: '/',
component: HomeView,
},
{
path: '/about',
component: AboutView,
},
],
})
src/main.ts:
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
createApp(App)
.use(router)
.mount('#app')
开发时:
npm run dev
访问 /about 往往没有问题,因为 Vite 开发服务器已经实现了开发环境所需的 History 回退。生产环境必须由 Nginx、静态托管平台或 CDN 的回源规则实现同等行为。
构建并检查:
npm run build
find dist -maxdepth 2 -type f -print
预期可以看到:
dist/index.html
dist/assets/...
如果使用 Vite 的本地预览服务器:
npm run preview
它适合检查构建产物在本地是否能运行,但它不是生产级 Nginx/CDN 配置的替代品。必须继续验证真实生产服务器的路由回退、缓存头和压缩头。
九、部署在子路径时的 Nginx 配置
如果应用部署在 /console/,构建配置:
// vite.config.ts
export default defineConfig({
plugins: [vue()],
base: '/console/',
})
Router:
export const router = createRouter({
history: createWebHistory('/console/'),
routes: [
{ path: '/', component: HomeView },
{ path: '/about', component: AboutView },
],
})
Nginx 可以使用:
server {
listen 80;
server_name example.com;
location = /console {
return 301 /console/;
}
location ^~ /console/assets/ {
root /srv/www/app/current;
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location = /console/index.html {
root /srv/www/app/current;
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
location /console/ {
root /srv/www/app/current;
try_files $uri $uri/ /console/index.html;
}
}
这里需要特别注意 root 的路径拼接规则。请求:
/console/assets/app.js
使用:
root /srv/www/app/current;
时,Nginx 通常查找:
/srv/www/app/current/console/assets/app.js
但 Vite 的产物通常是:
/srv/www/app/current/assets/app.js
因此,上面的配置在真实部署中可能需要使用 alias 或调整目录布局,不能机械复制。
一种更明确的写法是把 /console/ 映射到构建目录:
location ^~ /console/assets/ {
alias /srv/www/app/current/assets/;
try_files $uri =404;
}
不过 alias 与 try_files 组合时容易因为 URI 到文件路径的拼接规则产生误判,应该在目标 Nginx 版本上用真实文件测试。另一种简单方式是让文件系统目录也包含 console:
/srv/www/app/current/console/index.html
/srv/www/app/current/console/assets/...
无论选择哪种方式,必须用 curl 验证实际路径,不能只验证首页。
十、验证:不要只看浏览器页面是否打开
1. 验证入口和 History 回退
curl -i https://example.com/
curl -i https://example.com/about
预期:
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
/about 返回 HTML 是正确的,因为它是前端路由;但它不应意味着服务器上存在 /about 文件。
2. 验证资源缺失仍然是 404
curl -i https://example.com/assets/does-not-exist.js
预期:
HTTP/1.1 404 Not Found
如果返回:
HTTP/1.1 200 OK
Content-Type: text/html
说明通用回退规则覆盖了资源请求。此时浏览器可能报 MIME 类型错误,应该修正 /assets/ 的独立处理。
3. 验证缓存头
curl -sSI https://example.com/index.html
curl -sSI https://example.com/assets/index-xxxxxxxx.js
入口应接近:
Cache-Control: no-cache
带 hash 的资源应接近:
Cache-Control: public, max-age=31536000, immutable
如果响应经过 CDN,还要观察:
Age: ...
X-Cache: HIT
X-Cache: MISS
Via: ...
这些字段不是 HTTP 标准,名称由 CDN 供应商决定;它们只能作为诊断线索,不能假设所有平台都有相同字段。
4. 验证压缩
curl -sSI -H 'Accept-Encoding: gzip' \
https://example.com/assets/index-xxxxxxxx.js
检查:
Content-Encoding: gzip
Vary: Accept-Encoding
若有 CDN,应分别对 CDN 域名和源站地址测试,因为 CDN 可能改变压缩和缓存行为。
5. 使用浏览器 Network 面板定位失败层级
典型故障可以按请求顺序判断:
| 表现 | 更可能的问题 |
|---|---|
/about 刷新 404 |
没有 History 回退,或 CDN 未回源 |
| 首页 200,但 JS 404 | base 错误、发布目录不完整或资源被删除 |
| JS 请求返回 HTML | 资源被错误回退到 index.html |
| 页面是旧版本 | HTML 或 CDN 缓存过久 |
| 新 HTML 引用的资源 404 | 发布不完整、CDN 回源异常或旧资源清理过快 |
| CSS/JS MIME 错误 | Nginx MIME 配置、回退规则或响应内容错误 |
| gzip 资源解析失败 | Content-Encoding 与实际字节不一致,或缓存未区分编码 |
可以同时查看 Nginx 日志:
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
如果访问 /assets/missing.js 在 access log 中显示 200,就应优先检查回退规则;如果显示 404 但浏览器请求的是错误路径,则检查 Vite base 和 HTML 中生成的资源 URL。
十一、常见失败方案及其原因
1. 只配置 /,不处理 History
location / {
root /srv/www/app/current;
}
这种配置只适合直接访问实际存在的文件。首页可以打开,但访问 /about 或刷新 /users/42 会得到 404。
2. 所有路径都返回 index.html
某些配置使用:
location / {
try_files $uri /index.html;
}
如果没有为资源和 API 添加更具体的规则,资源拼写错误也会返回 HTML。短期内首页“看起来能用”,但错误会延迟到浏览器模块解析阶段,诊断难度更高。
3. 给 index.html 设置一年缓存
Cache-Control: public, max-age=31536000, immutable
这会让发布新版本后用户继续使用旧入口。只要 HTML 中引用的资源文件名发生变化,旧入口就可能继续运行很久;如果旧资源被删除,还会出现资源 404。
4. 依赖清理旧资源来“节省磁盘”
带 hash 的资源适合保留旧版本,因为它们不会互相覆盖。真正需要控制的是 release 总量和清理窗口,而不是发布完成后立刻删除上一版本。清理窗口至少要覆盖:
- 浏览器可能仍打开的旧页面;
- CDN 缓存过期时间;
- 回滚需要的时间;
- 发布异常后的人工处理时间。
具体时长取决于站点缓存策略和发布频率,不能从 HTTP 标准推出一个固定数字。
5. 认为 npm run build 成功就代表生产可用
构建成功只能证明 Vite 完成了模块转换和产物生成。它不能证明:
- Nginx root 指向了正确目录;
- CDN 能够回源;
- History 路径能刷新;
- HTML 的资源 URL 与部署路径一致;
- API 没有被前端回退接管;
- CDN 没有缓存错误响应;
- 压缩响应的头和内容匹配。
生产验收必须包含 URL、状态码、响应头和真实资源内容的检查。
十二、生产交付的因果链
一个稳定的 Vue 静态站点交付依赖以下因果关系:
- Vite 根据
base生成正确的资源 URL; - 构建产物被完整复制到 Nginx 的 root 或 alias 目标;
- Nginx 对真实文件直接返回,对前端路由回退到
index.html; - API 和静态资源被排除在不适当的 History 回退之外;
- HTML 短缓存,带 hash 资源长缓存;
- 旧资源在缓存和运行中的旧页面仍可能使用期间继续存在;
- CDN 按源站头和自身规则缓存,并能在未知前端路径上正确回源;
- 压缩协商通过
Accept-Encoding和Vary保持响应可解释; - 每次发布后用
curl、日志和浏览器 Network 面板验证源站与 CDN 两层行为。
其中任何一层违反前提,都可能产生相似的“页面打不开”现象,但修复位置不同:路由回退问题应看 Nginx/CDN,资源 URL 问题应看 Vite base,旧版本问题应看 HTML/CDN 缓存,资源内容错误则应看发布目录和压缩响应。把这些层次分开,静态站点交付就不再只是复制 dist 目录,而是一个可验证的 HTTP 请求系统。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue CI 质量流水线:类型、Lint、测试、构建、预览和制品
- 下一篇:Nuxt 路由与布局:文件约定、中间件、错误页和导航
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论