Vue 基础体系 · 第 64/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 静态站点交付:Nginx、CDN、History 回退、缓存和压缩

Vue 3 应用在开发服务器中由 Vite 提供服务时,访问路径、模块加载、History 路由和静态资源通常会被工具链自动处理;部署到生产环境后,浏览器首先面对的是 Nginx 或 CDN,而不是 Vue。此时 Vue 应用已经变成一组静态文件,交付问题转化为:

  1. 构建产物应该如何生成和放置;
  2. Nginx 如何找到静态文件;
  3. 使用 history 路由时,刷新或直接访问深层 URL 如何仍然返回应用入口;
  4. HTML、JavaScript、CSS、图片分别应该缓存多久;
  5. CDN 如何参与缓存和回源;
  6. gzip、Brotli 和预压缩文件如何影响响应;
  7. 部署新版本、回滚和排查故障时,哪些状态必须保持一致。

本文示例基于 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 按顺序尝试:

  1. $uri:请求路径对应的文件;
  2. $uri/:请求路径对应的目录;
  3. /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 为例:

  1. 浏览器向 CDN 请求 /users/42
  2. CDN 没有缓存,回源到 Nginx;
  3. Nginx 在文件系统中找不到 /users/42
  4. try_files 回退到 /index.html
  5. Nginx 返回 HTML;
  6. CDN 根据响应头决定是否缓存;
  7. 浏览器解析 HTML,继续请求 /assets/index-a1b2c3d4.js
  8. 对资源请求,Nginx 找到真实文件,不执行 History 回退;
  9. CDN 缓存带 hash 的资源;
  10. 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.html404 不应被 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

因此发布系统需要同时考虑:

  1. HTML 尽快重新验证;
  2. 带 hash 的资源长期缓存;
  3. 旧资源在一段时间内保留,避免旧 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 命令本身,而是发布顺序:

  1. 在新目录完整构建;
  2. 检查 index.html 和资源是否存在;
  3. 最后切换 current
  4. 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;
}

不过 aliastry_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 静态站点交付依赖以下因果关系:

  1. Vite 根据 base 生成正确的资源 URL;
  2. 构建产物被完整复制到 Nginx 的 root 或 alias 目标;
  3. Nginx 对真实文件直接返回,对前端路由回退到 index.html
  4. API 和静态资源被排除在不适当的 History 回退之外;
  5. HTML 短缓存,带 hash 资源长缓存;
  6. 旧资源在缓存和运行中的旧页面仍可能使用期间继续存在;
  7. CDN 按源站头和自身规则缓存,并能在未知前端路径上正确回源;
  8. 压缩协商通过 Accept-EncodingVary 保持响应可解释;
  9. 每次发布后用 curl、日志和浏览器 Network 面板验证源站与 CDN 两层行为。

其中任何一层违反前提,都可能产生相似的“页面打不开”现象,但修复位置不同:路由回退问题应看 Nginx/CDN,资源 URL 问题应看 Vite base,旧版本问题应看 HTML/CDN 缓存,资源内容错误则应看发布目录和压缩响应。把这些层次分开,静态站点交付就不再只是复制 dist 目录,而是一个可验证的 HTTP 请求系统。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。