前端部署
前端上线后,浏览器会先取得 HTML,再下载脚本、样式和图片,随后由脚本请求接口。有些代码要等到用户打开某个页面才下载。因此,服务器已经切换到新版本时,用户仍可能在执行旧版本代码。
部署需要照顾两类访问:新打开的页面应得到新版本,尚未关闭的页面应能继续取得它引用的资源。把这两类请求分开,路由刷新、缓存、白屏与回退就有了明确的处理位置。
构建产物决定运行方式
静态应用与服务端渲染
Vue、React 描述的是开发方式。部署时应检查项目实际生成什么。
| 产物 | 服务器运行什么 | 需要保留什么 |
|---|---|---|
| SPA 静态目录 | Nginx、对象存储等静态服务 | HTML、脚本、样式、字体、图片 |
| 预渲染或静态导出页面 | 静态服务 | 每条已生成路由的 HTML 与共享资源 |
| SSR 应用 | Node 或框架指定的服务运行时 | 服务端输出、客户端资源、运行依赖、外部配置 |
SSR 在请求时执行服务端代码,需要进程托管、健康检查、依赖连接、日志和退出处理。不能把它的输出目录随意放进 Nginx 就当作静态站点;部署方式应以框架的适配器和输出模式为准。Vite SSR 指南说明了客户端与服务端构建的分工。
静态应用在构建完成后通常不需要 Node。vite preview 用于本地检查产物,生产环境应使用正式的 Web 服务。Vite 静态部署说明给出了这一区别。
先构建一个完整应用
下载并解压前端部署示例工程。示例使用 Vite 8.2.2,构建环境为 Node 24.20.0、npm 11.19.0;包含首页、订单路由、接口请求和一个点击后才加载的模块。页面逻辑使用浏览器原生 API,资源交付方式与常见 Vue、React SPA 相同。
以下命令在 Linux Bash 中执行,需要已安装 Docker Engine 与 Compose 插件,并且当前普通用户有权调用 Docker。Docker 的安装与权限见 Docker。示例只监听宿主机回环地址,不直接提供公网服务。
cd frontend-lab
docker pull node:24.20.0-bookworm-slim
docker run --rm --user "$(id -u):$(id -g)" \
--mount "type=bind,source=$PWD,target=/app" \
--workdir /app \
node:24.20.0-bookworm-slim sh -ec '
node -v
npm -v
npm ci --cache /tmp/npm
APP_RELEASE=r1 npm run build
'容器中的构建进程使用当前 UID/GID,输出仍由自己管理。/tmp/npm 是本次容器内可写缓存,删除容器后消失。若挂载共享缓存,缓存目录也要允许该 UID 写入,且应避免不同项目混用认证配置。
正常输出包含 vite v8.2.2 和 built in,并生成如下文件;文件名中的哈希随内容变化。
site/
└── releases/
└── r1/
├── index.html
└── assets/
├── index-<hash>.js
├── index-<hash>.css
└── detail-<hash>.jsnpm ci 按锁文件安装,并在锁文件与 package.json 不一致时失败。它仍会运行安装脚本;对外部项目应先检查依赖来源和脚本行为。构建依赖经常位于 devDependencies,不能在构建前使用 --omit=dev 把 Vite 删除。npm ci 文档解释了锁文件要求与安装选项。
使用 pnpm 或 Yarn 的项目应沿用仓库自己的包管理器、锁文件和固定版本,不要临时改用 npm 生成另一份锁文件。构建工具、依赖仓库和网络问题可接着查阅构建与部署制品。
版本路径在构建时写入 HTML
示例的关键构建配置是:
const release = process.env.APP_RELEASE || 'r1';
// 实际配置还会校验 release,只允许小写字母、数字、下划线和连字符。
export default {
base: `/releases/${release}/`,
define: { __RELEASE__: JSON.stringify(release) },
build: {
outDir: `site/releases/${release}`,
emptyOutDir: true,
sourcemap: false,
},
};r1/index.html 引用 /releases/r1/assets/...,而不是一个会随部署改变含义的 /assets/...。Vite 同时处理构建图中的导入与资源引用;手写字符串、第三方脚本生成的地址及外部 CDN 地址仍需自行检查。Vite 构建指南说明了公共基础路径和资源加载问题。
emptyOutDir 只清理当前输出目录。已发布的 r1 不应被覆盖重建;下一次使用 r2 或唯一的发布编号,保证同一资源 URL 始终对应相同内容。
构建变量与运行配置
Vite 的 VITE_* 变量会进入客户端代码,访问页面的人能够读取它们。数据库密码、私钥和服务端 API 密钥必须留在服务端。.env.production 中的值主要在构建时替换,启动 Nginx 时修改环境变量不会重新改写 JS。mode 与 NODE_ENV 也各有用途,不能仅凭变量文件名推断构建行为。Vite 环境变量说明列出了加载顺序和暴露规则。
需要同一份静态产物跨环境使用时,可以让页面启动后读取独立 JSON:
{"apiBase":"/api"}示例把它放在站点根目录的 runtime-config.json,读取时检查 HTTP 状态、JSON 结构和允许的接口前缀,再发起业务请求。配置加载失败时显示明确错误,不继续猜测生产接口地址。它依然是公开数据,只适合服务地址、功能开关等非秘密配置。
后端身份认证、数据隔离和权限校验必须由服务端执行,隐藏前端按钮或修改这个 JSON 无法代替它们。
让页面、路由和接口各走正确的路径
启动静态服务和示例接口
仍在工程目录中执行。首次运行时 site/current 尚不存在:
cp runtime-config.json site/runtime-config.json
ln -s releases/r1 site/current
docker compose up -d --build --wait
docker compose ps
curl -q --noproxy '*' --fail-with-body --max-time 5 \
http://127.0.0.1:18092/api/greeting预期接口返回 {"message":"hello","path":"/api/greeting"}。浏览器打开 http://127.0.0.1:18092/,页面显示 r1 和 hello;点击“订单”后刷新,仍能显示“订单列表”。若在远程 Linux 上运行,可通过 SSH 本地转发访问,不必开放防火墙端口,方法见 SSH。
Compose 中的 web 使用 Nginx,api 使用 Node 启动示例接口。两者以 UID/GID 10001:10001 运行,根文件系统只读;Nginx 的 PID 和临时文件写到 /tmp。构建镜像时由 root 安装软件,与运行应用的身份分开。
web 仅发布 127.0.0.1:18092:8080,api 没有宿主机端口。静态目录以 ./site:/srv/site:ro 挂载,宿主机管理员能发布文件,Nginx 只能读取。健康检查访问 HTML;接口可用性由上面的独立请求和页面实际请求验证。depends_on 在这里仅安排启动顺序,接口进程启动后的短暂不可用仍可能影响页面。
浏览器地址、容器服务名和磁盘目录分别用于不同位置:
浏览器 http://127.0.0.1:18092
├── /orders → current/index.html → 浏览器路由
├── /releases/r1/assets/index-*.js → site/releases/r1/assets/index-*.js
├── /runtime-config.json → site/runtime-config.json
└── /api/greeting → Nginx → api:8080/api/greeting浏览器无法使用 Compose 内部的 http://api:8080。让页面请求同源 /api/,再由 Nginx 使用容器 DNS 查找 api,可以避免把内部地址泄露为不可达的客户端地址。
Nginx 的四类处理
工程中的完整 nginx.conf 包含 MIME、日志、临时目录和压缩配置。server 内最关键的处理如下,部署到已有 Nginx 时应放入对应虚拟主机,不要覆盖其他站点:
root /srv/site/current;
location = /index.html {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
location = /runtime-config.json {
root /srv/site;
add_header Cache-Control "no-store" always;
try_files $uri =404;
}
location ~ ^/releases/[a-z0-9_-]+/assets/ {
root /srv/site;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location /releases/ { return 404; }
location ^~ /api/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
}
location ~ /\. { return 404; }
location ~* \.(?:js|css|json|map|png|svg|ico|woff2?)$ { return 404; }
location / { try_files $uri /index.html; }/orders 没有磁盘文件时转入 /index.html,让客户端路由接管。缺失的 JS、配置和 API 则保留错误响应。如果把所有失败都改成 HTML 200,浏览器可能报 MIME 错误或 Unexpected token '<',真正缺失的文件反而被隐藏。
版本资源使用单独的 /srv/site 根目录,路径中包含 releases/r1。HTML 所在的 current 改变后,这个路径依然能找到旧资源。/releases/ 兜底拒绝其他文件,资源正则必须位于后面的扩展名正则之前。Nginx 的 URI 到文件映射、内部重定向及匹配规则见核心模块文档,代理 URI 的保留规则见 proxy_pass。
工程还为 /api-v2/ 配置了同样的代理,用于验证运行配置切换。这里的上游地址属于单机实验;服务容器更换 IP 后,应按实际 Nginx 版本配置动态解析,或在确认新服务可用后重新加载 Nginx。不能假定启动时解析的地址会永久有效,相关配置见 Nginx。
History 路由、子路径和真正的 404
History 路由使用正常路径,如 /orders。点击站内链接时由 JS 更新页面,刷新时浏览器直接向服务器请求该路径,因此需要服务器回退到入口 HTML。Hash 路由中的 #/orders 不会随 HTTP 请求发送到服务器,通常不需要同样的回退规则。Vue Router 部署说明包含两种模式及服务器配置。
应用部署到 /console/ 时,需要同时确认:客户端路由的基础路径、构建资源的公共路径、入口 HTML 的服务器映射,以及手写的配置和 API 地址。它们可以相同,也可以像示例一样刻意分开。示例的页面路由在 /,资源基础路径在 /releases/r1/;不能把资源 base 直接当作路由前缀。
SPA 的未知页面通常也会取得入口 HTML 200,然后由客户端显示“页面不存在”。若搜索引擎或业务要求真实 HTTP 404,应采用服务端路由判断、SSR 或预渲染规则。静态回退本身无法知道业务路由是否存在。
公网入口与跨域
本地验证通过后,公网域名应指向正式入口,由入口提供可信 HTTPS,再转发静态资源和 API。配置步骤见域名与 DNS、HTTPS 与证书;不要通过忽略证书错误来完成验收。
前端与 API 分属不同源时,浏览器会执行 CORS 检查。自定义请求头或某些方法会触发预检;携带 Cookie 时还要匹配凭据许可、Cookie 的域、路径、Secure 和 SameSite。服务端需要精确允许来源,不能用任意来源反射配合凭据。curl 成功后仍应在浏览器验证,因为 curl 不执行浏览器的同源策略。MDN CORS 文档给出了完整请求与响应条件。
发布新版本,同时保留旧页面的资源
不同文件采用不同缓存策略
| 文件 | 示例策略 | 原因 |
|---|---|---|
| 入口 HTML | no-cache | 允许存储,但使用前向服务端验证是否更新 |
| 运行配置 | no-store | 不在 HTTP 缓存中存储这份环境配置 |
| 版本目录中的哈希资源 | 一年有效期与 immutable | URL 内容固定,可重复使用已有副本 |
| 缺失资源 | 404,不附加长期缓存头 | 防止上传完成前的错误被长期保存 |
no-cache 与 no-store 的语义见 RFC 9111,immutable 的适用条件见 RFC 8246。示例资源响应的 add_header 刻意不使用 always,避免在 404 上附加一年缓存。CDN 仍可能有独立的负缓存规则,需要另外检查。
哈希资源还可以启用 gzip 或 Brotli,但应核对真实 Content-Encoding、Vary 和 MIME,不能只依据扩展名。Source map 如含源码、内部路径或注释,应按错误监控平台的私有上传流程管理;隐藏页面入口链接不足以保护公开 .map 文件。
验证旧页面的延迟模块
先打开 r1 页面,暂时不要点击“加载延迟模块”。在工程目录构建 r2:
docker run --rm --user "$(id -u):$(id -g)" \
--mount "type=bind,source=$PWD,target=/app" --workdir /app \
node:24.20.0-bookworm-slim sh -ec 'APP_RELEASE=r2 npm run build'
test -f site/releases/r2/index.html &&
ln -sT releases/r2 site/current.next &&
mv -Tf site/current.next site/current命令适用于 Linux GNU 工具,且 current.next 与 current 位于同一文件系统。ln -sT 将目标当作链接名称,拒绝已有文件、目录以及有效或悬空链接;不加 -T 时,有效目录链接可能被当成目标目录,反而在其中创建链接。遇到残留应先检查上次发布状态,不能继续执行 mv。新链接创建成功后再重命名替换 current,入口不会经历先删除后创建的空窗。参数语义见 GNU ln。
切换后新开页面应显示 r2;回到尚未刷新的 r1 页面,再点击按钮,应该取得 r1 的延迟模块。两个页面使用不同资源目录。实际部署还要核对内容摘要、权限以及全部资源上传完成,再执行入口切换。

图中是示例的实际页面:页面与延迟模块均为 r2,接口仍使用同源 /api/greeting。它与另一个保持打开的 r1 页面可以同时存在。
Compose 挂载的是 site 父目录,所以 Nginx 能观察到其中的链接变化。若直接把宿主机 current 链接解析后的目录绑定进容器,修改链接可能不会改变已建立的挂载。使用 Nginx open_file_cache 时,也要考虑文件描述符与缓存有效期;示例没有启用它。
单次链接替换只保证该文件系统上的目录入口切换。跨多台服务器发布、CDN 节点更新和浏览器已有代码执行发生在不同时间,需要按版本兼容处理,不能据此宣称全局同时升级。
模拟过早清理,再恢复
在这个专用实验目录中临时移走旧资源,不删除文件:
mv site/releases/r1/assets site/retired-r1-assets从 r1 HTML 取得其中一个真实资源路径,再请求它:
OLD_JS=$(sed -n 's/.*src="\([^"]*\.js\)".*/\1/p' site/releases/r1/index.html)
STATUS=$(curl -q --noproxy '*' --silent --show-error --max-time 5 \
--output /tmp/frontend-old-resource.body --write-out '%{http_code}' \
"http://127.0.0.1:18092$OLD_JS")
test "$STATUS" = 404
mv site/retired-r1-assets site/releases/r1/assets
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"http://127.0.0.1:18092$OLD_JS" --output /dev/null预期移走后为 404,恢复后为 200。浏览器若已缓存某个文件,可能暂时感觉不到故障;尚未加载的模块更容易暴露问题。模块导入失败还可能在当前页面上下文中保留失败状态,恢复服务器文件后应重新加载页面验证。
旧目录应至少覆盖业务允许的页面存活期、缓存与灰度窗口,清理前观察旧资源访问量。仅保留“上一个版本”未必足够:用户可能连续数天不关闭页面。对于长期打开的后台系统,可以提供温和的版本更新提示,但不能无条件强制刷新而丢失未保存输入。
回退与 CDN 发布
恢复 r1 的入口使用同一方式:
ln -sT releases/r1 site/current.next &&
mv -Tf site/current.next site/current
curl -q --noproxy '*' --fail-with-body --max-time 5 \
http://127.0.0.1:18092/index.html检查返回的资源路径包含 /releases/r1/,再用新页面检查路由、API 和延迟模块。若运行配置也改过,应恢复匹配的配置版本;后端接口与数据格式必须仍兼容 r1。已经执行的 JS 不会因服务器回退而自动消失,故障期间发出的业务写入也要单独处理。
使用对象存储和 CDN 时,发布顺序相同:先上传完整的版本资源并验证源站,再更新 HTML 与运行配置,最后刷新必要的入口缓存。查看响应头确认实际命中了哪个节点、缓存多久,并检查源站和边缘节点的 MIME、压缩及错误缓存。不要把存储桶写权限交给客户端;需要防止绕过 CDN 访问源站时,使用服务商提供的源站访问控制,而不是暴露目录列表。
Service Worker 另有 Cache Storage 和更新生命周期。它可能在普通 HTTP 缓存清除后仍返回旧页面。采用 PWA 时应把缓存版本、激活时机和离线回退纳入发布设计,具体机制见 Service Worker 生命周期。
从浏览器看到的故障定位到具体响应
F12 中先保存失败请求
打开浏览器开发者工具的 Network 面板,勾选 Preserve log 保留跳转记录。首次复现保留正常缓存状态;再勾选 Disable cache 做对照并刷新,避免把缓存关闭后的成功误当作原问题消失。Chrome 的面板操作与请求详情见 Network 官方说明。
按请求类型依次检查:
- Document:最终 URL、跳转链、状态码、HTML 中的资源版本。
- JS/CSS:完整资源 URL、响应
Content-Type、响应体、缓存来源和 Initiator。 - Fetch/XHR:接口 URL、请求方法、请求体、预检、Cookie 是否被发送及响应内容。
- Console:语法、模块导入、CORS、CSP 和运行配置错误;把错误中的 URL 与 Network 对上。
如果资源显示来自 Service Worker,到 Application 面板查看注册与 Cache Storage。先保留故障证据,再在自己的测试环境停用对应 Worker 做对照;不要把清除所有站点数据作为交给用户的默认修复。
复制为 curl 或导出 HAR 可以帮助复现请求,但通常包含 Cookie、Authorization、查询参数和业务响应。提交工单前应删除这些秘密与个人数据,截图也要遮蔽账号、域名中的内部信息和令牌。
用 curl 检查服务器实际返回什么
预期成功的请求同时检查 HTTP 状态和内容。-q 放在第一个参数位置,用于不读取默认 curl 配置;--noproxy '*' 排除环境代理对回环实验的影响。curl 手册说明了这些参数与 --fail-with-body 的行为。
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--dump-header /tmp/frontend.headers \
--output /tmp/frontend.html \
http://127.0.0.1:18092/orders
sed -n '1,20p' /tmp/frontend.headers
head -c 300 /tmp/frontend.html
docker compose exec -T web nginx -t
docker compose logs --tail=80 web api/orders 应返回入口 HTML,脚本应返回 JavaScript,接口应返回 JSON。对预期 404 的负例不要使用成功条件,而应像前面的资源实验一样保存状态码并明确断言。日志可能包含实际请求参数,应只保留排障所需字段。
| 现象 | 先检查 | 修复后验证 |
|---|---|---|
| 站内跳转正常,刷新 404 | History 基础路径与入口回退 | 直接输入深层 URL 再刷新 |
| JS 返回 HTML,模块 MIME 错误 | 资源路径是否错误、是否被 SPA 回退吞掉 | 缺失 JS 为 404,真实 JS 为正确 MIME |
| 发布后旧页面偶发白屏 | 失败模块的版本目录是否还可访问 | 保持旧页面打开后切版本,再延迟加载 |
| HTML 仍旧 | HTTP/CDN 缓存与 Service Worker 分别检查 | 正常缓存模式下新页面得到新版本 |
| 页面正常但接口失败 | 浏览器可达地址、代理 URI、认证和 CORS | 使用真实浏览器完成接口调用 |
| 容器启动失败或 403 | 文件读权限、父目录执行权限、只读挂载和临时目录 | 以运行 UID 读取文件并重新请求 |
实验结束,在工程目录执行 docker compose down 停止并移除本项目容器与网络。静态版本目录和镜像仍保留,便于继续验证回退;它们不会随这个命令被删除。
权威资料与规范地址
按具体配置、协议或操作查阅完整定义:
构建与环境配置
| 查阅内容 | 官方地址 |
|---|---|
| Vite SSR 指南 | https://vite.dev/guide/ssr.html |
| Vite 静态部署说明 | https://vite.dev/guide/static-deploy |
| npm ci 文档 | https://docs.npmjs.com/cli/v11/commands/npm-ci/ |
| Vite 构建指南 | https://vite.dev/guide/build |
| Vite 环境变量说明 | https://vite.dev/guide/env-and-mode |
路由与跨域
缓存与发布切换
浏览器与命令行排查
| 查阅内容 | 官方地址 |
|---|---|
| Network 官方说明 | https://developer.chrome.com/docs/devtools/network/ |
| curl 手册 | https://curl.se/docs/manpage.html |
