前端部署
本文范围
这篇文章讲 Vue / React / Vite 这类前端单页应用的部署运维主线:Node LTS、包管理器和 lockfile、企业 npm 源、环境变量边界、构建产物、Nginx 静态托管、history 路由刷新 404、缓存策略、gzip、接口代理、Docker 静态站点、OSS / CDN、发布回滚、白屏和构建失败排障。
本文不讲前端框架开发、组件设计、状态管理、SSR 全量架构、微前端平台和性能专项优化。后面提到 Nginx、CDN、Docker,只从“静态资源如何被稳定发布、验证和回滚”的部署角度讲。
读完以后,你应该能把前端上线从“复制 dist”变成可复现流程:工具链固定、构建可验证、缓存不误伤、API 边界清楚、旧资源能回滚。
前置条件
建议先准备:
- 一个能本地构建的 Vue / React / Vite 项目,并且仓库里已经提交
package.json和唯一的 lockfile。 - 一条明确的 Node LTS 主线;截至 2026-07-10,Node 官方发布页显示 Node 24 为 LTS、Node 26 为 Current,生产构建优先选择 Active LTS 或 Maintenance LTS。
- 一台能托管静态文件的 Nginx 服务器、Docker 运行环境,或 OSS / CDN 发布目标。
- 一个后端 API 健康检查地址,用于验证前端代理、跨域和环境变量是否指向正确环境。
- 能使用
node、npm/pnpm/yarn、curl、nginx -t和浏览器 DevTools 做构建与白屏排查。
先把前端部署说清楚
很多人第一次部署 Vue / React 项目,会下意识觉得“前端服务要启动起来”。其实大多数 Vue / React 单页应用构建完以后,就是一堆静态文件:index.html、JS、CSS、图片、字体。Nginx 不会运行 Vue,也不会运行 React,它只负责把这些文件按 HTTP 响应给浏览器。
浏览器拿到 index.html 后,再去下载里面引用的 JS 和 CSS。JS 执行起来以后,前端路由、页面渲染、接口请求才开始工作。所以前端部署出问题时,一般先看三件事:
index.html有没有拿到。- JS / CSS / 图片这些静态资源有没有拿到。
- 前端请求 API 时,路径、跨域和代理有没有对上。
一条比较稳的上线链路大概是这样:
固定 Node 版本
-> 固定包管理器和 lockfile
-> 从可信 npm 源安装依赖
-> 读取构建环境变量
-> npm run build / pnpm build
-> 检查 dist 或 build
-> 打包静态产物
-> 发布到 Nginx / Docker / OSS / CDN
-> 验证首页、深层路由、静态资源、接口、缓存头
-> 保留旧版本,随时回滚这里要注意,前端部署的核心不是“把 dist 复制到服务器”这么一句话,而是构建可复现、资源路径正确、缓存不会坑用户、出问题能回滚。
命令下面都会写清楚它解决什么问题、正常应该看到什么、失败时先查哪里。
第一步:确认 Node 版本
先不要急着 npm install。前端构建最常见的问题,就是开发机、CI、Docker build 阶段用的 Node 版本不一样。你本机能构建,不代表 CI 能构建;CI 能构建,也不代表下个月换一台机器还能构建。
先在项目根目录执行:
node -v
npm -v
corepack --version这三个命令解决的是“我现在到底用哪套工具链”的问题。
正常情况下,你应该看到类似:
v24.18.0
11.x.x
0.x.x截至 2026-07-09,Node.js 官方发布页显示 Node 24 是 LTS,Node 26 是 Current。生产构建一般使用 Active LTS 或 Maintenance LTS,不建议直接拿 Current 当生产基线。Current 适合验证新特性,LTS 才适合长期构建链路。
如果 node -v 看到的是很老的版本,例如 16、18,先看项目是否还能支持。Node 20 在 2026-07-09 已经 EOL,新的构建工具、依赖包和安全基线都不应该再围着它设计。
一般需要在项目里放一个 .nvmrc:
24再在 package.json 里写清楚:
{
"engines": {
"node": ">=24 <25"
},
"packageManager": "pnpm@11.0.0"
}这里的 engines 是给人和 CI 看的,告诉大家这个项目按什么 Node 主线构建。packageManager 是给 Corepack 看的,告诉它应该启用哪个包管理器版本。
如果你希望 CI 里版本不对就直接失败,可以执行:
npm config set engine-strict true这条命令解决的是“错误版本不要混过去”的问题。以后执行 npm ci 时,如果 Node 版本不满足 engines,会直接失败。
失败时先查:
node -v
which node
npm config get engine-strict
cat package.json | grep -A5 enginesWindows 上可以把 which node 换成:
where node如果路径指到了旧的 Node 安装目录,先处理 PATH 或版本管理工具,不要继续构建。
生产建议:开发机、CI、Docker build 基础镜像尽量使用同一条 LTS 主线。升级 Node 不要顺手改,要单独跑构建、单测、预发和回滚验证。
第二步:只选一种包管理器
一个项目里最怕同时出现三套 lockfile:
package-lock.json
pnpm-lock.yaml
yarn.lock这等于告诉团队:不同人可以用不同依赖树构建同一个项目。短期看没事,线上出问题时就会很难复现。
先看项目到底用了哪个:
ls -1 package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null如果你看到多个 lockfile,要先和团队确认保留哪一个。一般建议:
| 包管理器 | lockfile | CI 命令 | 适合场景 |
|---|---|---|---|
| npm | package-lock.json | npm ci | 最通用,Node 自带 |
| pnpm | pnpm-lock.yaml | pnpm install --frozen-lockfile | 依赖多、monorepo、希望安装快 |
| Yarn Berry | yarn.lock | yarn install --immutable | 团队已经标准化 Yarn 现代版本 |
如果项目用 pnpm,先启用 Corepack:
corepack enable
corepack prepare pnpm@11.0.0 --activate
pnpm -v第一行让 Node 自带的 Corepack 接管包管理器;第二行启用指定 pnpm 版本;第三行确认版本。看到 11.x.x 就说明包管理器版本已经对上。
CI 里不要用随手安装命令:
# npm 项目
npm ci
# pnpm 项目
pnpm install --frozen-lockfile
# Yarn Berry 项目
yarn install --immutable这几条命令解决的是“按 lockfile 原样安装”。如果 package.json 和 lockfile 不一致,它们会失败,这是好事。失败说明开发者改了依赖但没有提交 lockfile,不应该让 CI 自己“聪明地修一下”。
常见失败:
ERR_PNPM_OUTDATED_LOCKFILE
npm ci can only install packages when package.json and package-lock.json are in sync处理方式不是给 CI 加 --force,而是在本地用项目规定的包管理器重新安装,然后把 lockfile 一起提交:
pnpm install
git diff -- package.json pnpm-lock.yaml这里要注意,--force、--legacy-peer-deps、--no-frozen-lockfile 都可以临时救火,但不能写进生产构建脚本。否则构建结果会漂。
第三步:配置 npm 源和企业私服
依赖源慢,构建就慢;依赖源不稳定,构建就不稳定。先看当前 registry:
npm config get registry
npm ping第一条命令看你现在从哪里拉包;第二条命令试一下 registry 是否能通。正常情况下,npm ping 应该返回 PONG 或类似成功信息。
如果 npm ping 超时,先不要怪项目。先查网络、代理、DNS、私服地址。
项目里可以放 .npmrc,让开发机和 CI 都走同一个入口:
registry=https://registry.npmjs.org/
strict-ssl=true
fund=false
audit=false中国大陆公网环境,如果官方 registry 访问不稳定,可以使用团队批准的国内镜像,例如:
registry=https://registry.npmmirror.com/
strict-ssl=true但生产构建更推荐走企业私服。镜像站适合加速,企业私服才适合权限、审计、缓存和内网闭环。
企业私服常见写法:
registry=https://repo.example.com/repository/npm-group/
strict-ssl=true
always-auth=true
@company:registry=https://repo.example.com/repository/npm-private/
//repo.example.com/repository/npm-private/:_authToken=${NPM_TOKEN}这里分两层:
- 默认依赖走
npm-group,它可以代理官方源、镜像源和内部仓库。 - 公司私有包走
@companyscope,token 通过 CI 环境变量注入。
验证私服:
npm config get registry
npm view vue version --registry=https://repo.example.com/repository/npm-group/
npm whoami --registry=https://repo.example.com/repository/npm-private/如果 npm view vue version 能返回版本号,说明 group 仓库能读公共包。如果 npm whoami 能返回用户名,说明私有仓库认证可用。
常见问题这样看:
| 现象 | 先查什么 | 处理方向 |
|---|---|---|
401 Unauthorized | token 是否注入,scope 是否写对 | 检查 CI secret 和 .npmrc |
404 Not Found | 包名、scope、私服 group 路由 | 用 npm view 包名 验证 |
ETIMEDOUT | 网络、DNS、代理、私服到外网的链路 | 看私服日志和 CI 节点网络 |
| 本地成功 CI 失败 | 本地全局 .npmrc 或缓存 | 在干净环境重跑 |
还有一个容易忽略的点:有些包不只下载 npm 包,还会下载二进制文件,比如 esbuild、Sharp、Puppeteer、Playwright、node-gyp 相关依赖。内网里只代理 npm registry 不一定够。
处理建议:
- 能升级就升级,老项目里的
node-sass尽量迁移到sass。 - 二进制下载地址要纳入内网依赖清单。
- CI 缓存 key 要包含操作系统、CPU 架构、Node 版本和 lockfile。
- 离线现场尽量不要做 npm install,优先交付已经构建好的静态包。
pnpm 项目可以提前预取依赖:
pnpm fetch
pnpm install --frozen-lockfile --offline第一条把 lockfile 里的依赖拉到 store;第二条只从本地 store 安装。执行第二条时如果还去联网,说明离线依赖没有准备完整。
第四步:环境变量不要当密钥
前端环境变量大多数是构建时变量。你执行 npm run build 时,变量就已经被写进生成的 JS 里了。部署以后再去服务器上改环境变量,通常不会影响已经生成的页面。
所以前端环境变量只能放公开配置,例如:
API 基础路径
站点标题
部署子路径
公开功能开关不要放:
数据库密码
后端服务密钥
云厂商 secret
内部 tokenVite 项目一般这样写:
# .env.production
VITE_API_BASE=/api
VITE_APP_TITLE=Console代码里读取:
const apiBase = import.meta.env.VITE_API_BASE这里要注意,Vite 默认只把 VITE_ 前缀变量暴露给客户端代码。如果变量名写成 API_BASE,前端代码里是读不到的。
Vue CLI 项目常见写法:
VUE_APP_API_BASE=/apiCRA 遗留项目常见写法:
REACT_APP_API_BASE=/api
PUBLIC_URL=/consoleReact 新项目不建议再新建 CRA。React 官方已经在 2025-02-14 宣布 Create React App 退场,新项目更建议使用 Vite 或框架方案。老项目还在 CRA 上,也不用慌,部署上主要关注 build/ 输出、PUBLIC_URL 和缓存策略。
如果应用不是部署在根路径,而是部署在 /console/,构建配置必须跟着变。
Vite:
import { defineConfig } from 'vite'
export default defineConfig({
base: process.env.APP_BASE || '/',
})构建时:
APP_BASE=/console/ pnpm buildReact Router:
<BrowserRouter basename="/console">
<App />
</BrowserRouter>验证方式很简单,构建后打开 dist/index.html,看 JS 路径是不是带了 /console/:
grep -E "script|stylesheet" dist/index.html正常应该看到类似:
<script type="module" src="/console/assets/index.abc123.js"></script>如果看到的是:
<script type="module" src="/assets/index.abc123.js"></script>而你的访问路径又是 /console/,那上线后大概率白屏,因为浏览器会去错误路径拿资源。
第五步:构建并检查 dist
先用最常见的 pnpm 项目举例。构建前先清掉旧产物:
rm -rf dist
corepack enable
pnpm install --frozen-lockfile
pnpm run build这几条命令分别解决:
rm -rf dist:避免旧文件混进新包。corepack enable:启用项目声明的包管理器。pnpm install --frozen-lockfile:按 lockfile 安装依赖。pnpm run build:执行生产构建。
构建成功后,一般应该看到:
dist/
index.html
assets/
index.xxxxx.js
index.xxxxx.css马上检查:
test -f dist/index.html
find dist -maxdepth 2 -type f | sort | head -50
du -sh dist第一条命令没有输出,说明 index.html 存在;如果失败,脚本会返回非 0。第二条看产物里到底有什么。第三条看体积,防止把测试视频、大 JSON、未压缩地图数据误打进去。
如果项目是 CRA,构建目录一般是 build/:
npm ci
npm run build
test -f build/index.html如果你要先在本机预览构建产物:
pnpm run preview -- --host 0.0.0.0访问后应该能看到首页。这里看的不是开发服务器效果,而是构建后的静态产物效果。很多路径错误、环境变量错误,只有 build 后才暴露。
建议构建时生成一个版本文件:
cat > dist/build-info.json <<EOF
{
"version": "$(date +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)",
"commit": "$(git rev-parse --short HEAD)",
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"node": "$(node -v)"
}
EOF它解决的是“线上到底跑的是哪个版本”的问题。上线后访问:
curl -fsS https://example.com/build-info.json如果能看到版本号,排障时就不用靠猜。
最后打包:
VERSION="$(date +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
tar -czf "dist-${VERSION}.tar.gz" dist
sha256sum "dist-${VERSION}.tar.gz" > "dist-${VERSION}.tar.gz.sha256"tar 生成交付包,sha256sum 生成校验文件。上线前后都可以用:
sha256sum -c "dist-${VERSION}.tar.gz.sha256"看到 OK 才说明包没有被传坏。
第六步:用 Nginx 托管静态资源
先按最普通的根路径部署讲。我们把前端应用放到:
/opt/web/console/
releases/
current -> releases/某个版本/dist如果目录没有,就新建:
sudo mkdir -p /opt/web/console/releases上传 dist-${VERSION}.tar.gz 后解压:
VERSION=20260709103000-a1b2c3d
sudo mkdir -p "/opt/web/console/releases/${VERSION}"
sudo tar -xzf "dist-${VERSION}.tar.gz" -C "/opt/web/console/releases/${VERSION}"
sudo test -f "/opt/web/console/releases/${VERSION}/dist/index.html"最后一条命令是关键,它确认包里真的有首页。如果这里失败,不要 reload Nginx,先回去查构建和上传。
切换入口:
sudo ln -sfn "/opt/web/console/releases/${VERSION}/dist" /opt/web/console/current
ls -lah /opt/web/console/currentln -sfn 做的是原子切换。你可以理解为把 Nginx 指向的新版本换了一下,而不是在旧目录里覆盖文件。这样回滚很快。
Nginx 最小配置:
server {
listen 80;
server_name example.com;
root /opt/web/console/current;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}保存后先检查:
sudo nginx -t看到 syntax is ok 和 test is successful,再 reload:
sudo systemctl reload nginx访问验证:
curl -I http://example.com/
curl -I http://example.com/index.html
curl -fsS http://example.com/build-info.json正常应该看到 200 OK,并且 build-info.json 返回刚才构建的版本。
发布评审时,不要只盯着 nginx.conf 里有没有 root。更稳的做法是把构建产物、版本目录、Nginx 查找规则、缓存层和 API 指向放在一张链路图里逐格核对。线上排障也按这张图走:首页 404 先查 current 和 root,深层路由 404 查 try_files,静态资源 404 查构建 base 和资源是否发布,接口异常查 API base、代理和健康检查。
这里要注意,Nginx 的 root 是把请求 URI 拼到目录后面。比如:
root /opt/web/console/current;访问 /assets/app.js 时,Nginx 找的是:
/opt/web/console/current/assets/app.js如果使用 alias,含义就不一样了:
location /console/ {
alias /opt/web/console/current/;
index index.html;
try_files $uri $uri/ /console/index.html;
}alias 是把 /console/ 替换成后面的目录。这里最容易错的是末尾斜杠:location /console/ 和 alias /opt/web/console/current/ 建议都以 / 结尾。
如果应用部署在 /console/,前端构建也要设置 /console/,Nginx 也要配置 /console/。只改一边不行。
验证子路径:
curl -I http://example.com/console/
curl -I http://example.com/console/assets/index.abc123.js
curl -I http://example.com/console/order/list如果首页 200,但 JS 404,先查前端 base / publicPath。如果 JS 存在但刷新路由 404,先查 Nginx try_files。
第七步:解决 history 路由刷新 404
Vue Router / React Router 的 history 模式有一个经典现象:
首页能打开
点击菜单能进入 /order/list
刷新 /order/list 就 404
复制 /order/list 到新窗口也 404原因很简单:浏览器刷新 /order/list 时,会真的向服务器请求 /order/list。Nginx 按文件找,找不到这个文件,就返回 404。前端路由只有在 index.html 加载以后才有机会接管。
根路径部署这样配:
location / {
try_files $uri $uri/ /index.html;
}这条配置的意思是:
- 先找真实文件。
- 再找真实目录。
- 都找不到,就返回
index.html,让前端路由接管。
验证:
curl -I http://example.com/order/list正常应该返回 200 OK,不是 Nginx 的 404 页面。
但是这里有个坑:不要把所有静态资源 404 都吞成 index.html。如果 /assets/app.old.js 不存在,却返回了 index.html,浏览器会报:
Expected a JavaScript module script but the server responded with a MIME type of "text/html"更稳的配置是先把 API 和静态资源分出去:
location ^~ /api/ {
proxy_pass http://backend:8080/;
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;
}
location ^~ /assets/ {
try_files $uri =404;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|webp|avif|woff2?)$ {
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}这样业务路由能回到 index.html,真实缺失的 JS / CSS 还是 404。排障时会清楚很多。
第八步:把缓存策略配对
前端缓存不要一刀切。正确思路是:
index.html短缓存或每次重新验证。- 带 hash 的 JS / CSS / 图片长缓存。
build-info.json、env.js短缓存。- 发布后保留旧 hash 资源一段时间。
Nginx 配置:
location = /index.html {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
location = /build-info.json {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|webp|avif|woff2?)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}验证:
curl -I https://example.com/index.html
curl -I https://example.com/assets/index.abc123.jsindex.html 应该看到:
Cache-Control: no-cachehash 资源应该看到:
Cache-Control: public, max-age=31536000, immutable为什么这样配?因为 index.html 是入口,它引用最新的 JS / CSS;hash 文件内容变了文件名也会变,所以可以长缓存。
最常见的生产事故是:发布时清空旧目录,再复制新目录。结果一部分用户浏览器或 CDN 还拿着旧 index.html,旧 HTML 引用的 assets/index.old.js 已经被删了,页面直接白屏。
生产建议:
- 用版本目录发布,不要覆盖旧目录。
index.html短缓存。- hash 资源长缓存。
- 至少保留最近几个版本的静态资源。
- CDN TTL 没过期之前,不要急着删旧资源。
这张缓存和回滚链路图适合放进发布评审清单。它的重点不是“缓存开得越长越好”,而是入口文件和 hash 资源必须分治:index.html 决定版本入口,hash 资源决定这个版本能不能完整运行。排障时先用 curl -I 看 Cache-Control、ETag、Age、Via、X-Cache,再判断是浏览器缓存、CDN 缓存、源站软链还是旧资源清理过早。
第九步:开启 gzip,谨慎处理 Brotli
JS 和 CSS 通常很适合压缩。Nginx gzip 可以这样开:
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types
text/plain
text/css
text/xml
application/json
application/javascript
application/xml
image/svg+xml;检查配置:
sudo nginx -t
sudo systemctl reload nginx验证 gzip:
curl -H "Accept-Encoding: gzip" -I https://example.com/assets/index.abc123.js正常应该看到:
Content-Encoding: gzip
Vary: Accept-Encoding如果没看到,先查四件事:
- 请求有没有带
Accept-Encoding: gzip。 - 文件是否小于
gzip_min_length。 - MIME 类型是否在
gzip_types里。 - CDN 或上游代理是否改写了响应。
Brotli 压缩率通常更好,但开源 Nginx 默认不一定带 Brotli 模块。不要直接把 brotli on; 复制到生产配置里。先看:
nginx -V 2>&1 | grep -i brotli如果没有模块,nginx -t 会失败。生产上可以选择:
- 使用企业标准的带 Brotli 模块 Nginx 镜像。
- 在 CDN 上开启 Brotli。
- CI 预生成
.br文件,并使用支持 Brotli static 的 Nginx。
第十步:接口代理和跨域
前端页面和 API 最稳的方式是同源:
https://example.com/ 前端页面
https://example.com/api/ 后端接口Nginx 代理:
location ^~ /api/ {
proxy_pass http://backend: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;
}这里要注意 proxy_pass 后面的斜杠。
location /api/ {
proxy_pass http://backend:8080/;
}请求 /api/users 会转成后端 /users。
location /api/ {
proxy_pass http://backend:8080;
}请求 /api/users 会保留 /api/users。
接口 404 时,不要只看浏览器。先看:
curl -I https://example.com/api/health
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log再看后端日志里收到的路径到底是 /users 还是 /api/users。
如果前端和 API 必须跨域,例如:
https://app.example.com
https://api.example.com优先让后端或 API 网关统一处理 CORS。Nginx 只在入口层明确承担 CORS 时配置。
验证预检:
curl -i -X OPTIONS 'https://api.example.com/user' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type'正常应该看到允许的 Origin、Method、Headers。如果请求带 cookie 或认证凭证,Access-Control-Allow-Origin 不能是 *,还要有:
Access-Control-Allow-Credentials: true跨域问题很多时候不是前端代码问题。前端只能发请求,允不允许跨域是服务端、网关或 Nginx 的策略。
第十一步:用 Docker 部署前端静态站点
如果团队使用容器交付,前端镜像一般分两段:
- build 阶段:用 Node 安装依赖、执行构建。
- runtime 阶段:用 Nginx 托管静态文件。
注意,下面的基础镜像写成企业镜像仓库地址。真实落地时,一般需要把官方基础镜像同步到企业 Harbor、云厂商镜像仓库或内网仓库,不建议生产构建直接依赖外网拉取。
Dockerfile:
FROM registry.example.com/base/node:24-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM registry.example.com/base/nginx:1.30-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html如果是 npm 项目,把 pnpm 那两行换成:
COPY package.json package-lock.json ./
RUN npm ci.dockerignore 一般要有:
node_modules
dist
build
.git
.env.local
npm-debug.log
pnpm-debug.log它解决的是“不要把本地垃圾和旧产物塞进镜像构建上下文”。如果上下文很大,docker build 一开始就会很慢。
构建:
docker build -t registry.example.com/web/console:20260709-a1b2c3d .运行验证:
docker run -d --name console-test -p 8080:80 registry.example.com/web/console:20260709-a1b2c3d
curl -I http://127.0.0.1:8080/
docker logs --tail=100 console-test访问后应该看到 200 OK。如果是 403,先进入容器看静态文件有没有复制进去:
docker exec -it console-test sh
ls -lah /usr/share/nginx/html
nginx -t推送:
docker push registry.example.com/web/console:20260709-a1b2c3d生产不要只用 latest。至少要固定 tag,关键系统还要记录 digest:
docker inspect --format='{{index .RepoDigests 0}}' registry.example.com/web/console:20260709-a1b2c3d运行时配置怎么办
静态前端的环境变量在 build 阶段已经写死。你执行:
docker run -e VITE_API_BASE=/api-new ...不会自动改变已经构建好的 JS。
如果要同一个镜像部署到多个环境,可以生成 env.js:
<script src="/env.js"></script>容器启动脚本:
cat >/usr/share/nginx/html/env.js <<EOF
window.__APP_CONFIG__ = {
apiBase: "${API_BASE:-/api}"
}
EOF
nginx -g "daemon off;"Nginx 里给 env.js 短缓存:
location = /env.js {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}这里仍然不能放密钥。env.js 是浏览器能直接访问的文件。
第十二步:OSS / CDN 怎么放边界
前端静态资源也可以直接放对象存储和 CDN。这个方案适合公网访问量大、静态资源多、希望边缘加速的系统。
上传顺序要注意:
先上传 assets 里的 hash 资源
-> 再上传 index.html
-> 刷新 /index.html 或入口路径
-> 验证首页、深层路由和核心资源不要先覆盖 index.html,再慢慢传 JS / CSS。用户可能先拿到新 HTML,但新资源还没上传完,直接白屏。
CDN 缓存建议:
| 路径 | 建议 |
|---|---|
/index.html | 短 TTL,发布时刷新 |
/build-info.json、/env.js | 短 TTL 或不缓存 |
/assets/*.hash.js、/assets/*.hash.css | 长 TTL,immutable |
| 图片、字体 | 带 hash 可长缓存,不带 hash 谨慎 |
/api/ | 通常不走静态 CDN 缓存 |
history 路由也要在 CDN 上处理。源站 Nginx 配了 try_files,不代表 CDN 边缘节点就会自动回源到 index.html。如果 CDN 自己返回 404,用户还是会看到刷新 404。
生产建议:
- CDN rewrite 只兜业务路由,不要把静态资源缺失也改成
index.html。 - 每次发布只刷新入口文件,不要无脑全站刷新。
- 旧 hash 资源保留到 CDN TTL 过期以后。
- 用
curl -I对比源站和 CDN 响应头。
第十三步:中国大陆、内网和离线环境
中国大陆公网环境先确认这些:
npm registry 能不能稳定访问
企业 npm 私服能不能代理依赖
基础镜像能不能从企业镜像仓库拉取
域名、备案、证书、DNS、安全组、防火墙是否就绪
OSS / CDN 回源是否稳定很多公网故障不是 Nginx 配错,而是 DNS 没生效、安全组没开、证书链不完整、CDN 回源失败。
企业内网建议这样准备:
Git 仓库
npm 私服
Docker 镜像仓库
通用制品仓库
Nginx 或内部对象存储
标准部署脚本内网构建不要依赖开发者本机缓存。标准构建应该能在干净机器上跑通:
node -v
corepack enable
pnpm install --frozen-lockfile
pnpm run build
test -f dist/index.html离线现场更建议交付已经构建好的静态包,而不是现场 npm install。离线包建议包含:
dist-${version}.tar.gz
dist-${version}.tar.gz.sha256
nginx.conf
release.sh
rollback.sh
build-info.json
README-deploy.mdDocker 离线交付:
docker save registry.example.com/web/console:20260709-a1b2c3d \
-o console-20260709-a1b2c3d.tar
sha256sum console-20260709-a1b2c3d.tar > console-20260709-a1b2c3d.tar.sha256现场导入:
sha256sum -c console-20260709-a1b2c3d.tar.sha256
docker load -i console-20260709-a1b2c3d.tar
docker images | grep console看到镜像列表里有对应 tag,才说明镜像导入成功。
第十四步:发布和回滚
前端回滚不要重新构建旧代码。真正稳的回滚,是切回旧产物。
发布脚本示例:
set -euo pipefail
APP_NAME=console
VERSION="$1"
BASE_DIR="/opt/web/${APP_NAME}"
RELEASE_DIR="${BASE_DIR}/releases/${VERSION}"
PACKAGE="dist-${VERSION}.tar.gz"
test -f "${PACKAGE}"
sha256sum -c "${PACKAGE}.sha256"
sudo mkdir -p "${RELEASE_DIR}"
sudo tar -xzf "${PACKAGE}" -C "${RELEASE_DIR}"
sudo test -f "${RELEASE_DIR}/dist/index.html"
sudo ln -sfn "${RELEASE_DIR}/dist" "${BASE_DIR}/current"
sudo nginx -t
sudo systemctl reload nginx
curl -fsS "https://example.com/" >/dev/null
curl -fsS "https://example.com/build-info.json" >/dev/null执行:
./release.sh 20260709103000-a1b2c3d这段脚本每一步都在防一个坑:
test -f:包不存在就别继续。sha256sum -c:包损坏就别上线。test -f index.html:空包不上线。ln -sfn:用软链接切换版本。nginx -t:配置错不 reload。curl:上线后立刻验证入口。
回滚脚本:
set -euo pipefail
APP_NAME=console
ROLLBACK_TO="$1"
BASE_DIR="/opt/web/${APP_NAME}"
sudo test -f "${BASE_DIR}/releases/${ROLLBACK_TO}/dist/index.html"
sudo ln -sfn "${BASE_DIR}/releases/${ROLLBACK_TO}/dist" "${BASE_DIR}/current"
sudo nginx -t
sudo systemctl reload nginx
curl -fsS "https://example.com/build-info.json"执行:
./rollback.sh 20260709100000-old1234访问 build-info.json 后应该看到旧版本号。这里要注意,回滚后如果 CDN 缓存了新 index.html,还要刷新入口文件,否则用户可能仍然拿到新入口。
白屏排查:按这条线走
白屏不要先猜代码。先打开浏览器 DevTools,按顺序看:
- Network 里
index.html是不是 200。 - JS / CSS 是不是 200。
- JS 请求返回的 Content-Type 对不对。
- Console 有没有
ChunkLoadError、CORS、MIME type、运行时报错。 - Application 里有没有 Service Worker 缓存旧文件。
这类问题最容易被误判成“前端代码有 bug”。排障时先按下面这棵树分流:入口、静态资源、API、缓存和 Service Worker 是五条不同链路,任何一条错了都会表现成白屏。
图里的每个节点都能落到命令或响应头:curl -I 看入口和静态资源,DevTools 看 Console 和 Application,Nginx access log 看 404,CDN 响应头看 Age、Via、X-Cache。不要先清缓存碰运气,先判断到底是哪一层给了错误版本。
命令侧先查:
curl -I https://example.com/
curl -I https://example.com/index.html
curl -I https://example.com/assets/index.abc123.js
curl -fsS https://example.com/build-info.json常见情况:
| 现象 | 原因 | 处理 |
|---|---|---|
| JS 404 | base / publicPath 错,或资源没上传 | 查 HTML 里的资源路径和服务器文件 |
| JS 返回 HTML | fallback 把资源 404 吞成 index.html | 静态资源 location 用 try_files $uri =404 |
ChunkLoadError | 旧入口引用的 chunk 被删 | 保留旧资源或回滚 |
| CORS 报错 | API 跨域策略不对 | 查 OPTIONS 和响应头 |
| 首页旧版本 | index.html 或 CDN 缓存太长 | 刷新入口,调整缓存头 |
如果怀疑 Nginx 路径错:
sudo nginx -T | grep -n "server_name example.com" -A80
ls -lah /opt/web/console/current
find /opt/web/console/current -maxdepth 2 -type f | head看到文件存在但请求 404,重点查 root、alias、location 匹配顺序和文件权限。
构建失败排查
构建失败先把环境打印出来:
node -v
npm -v
corepack --version
npm config get registryNode 版本不对:
error package requires node >=24处理:切到项目要求的 LTS 版本,再重跑安装。
lockfile 不一致:
ERR_PNPM_OUTDATED_LOCKFILE处理:
pnpm install
git diff -- package.json pnpm-lock.yamlregistry 失败:
ETIMEDOUT
401 Unauthorized
404 Not Found处理:
npm config get registry
npm ping
npm view react version401 查 token,404 查 scope 和私服路由,超时查网络和私服代理。
node-gyp 失败:
gyp ERR!
Python not found
make: not found处理方向:
- 优先升级依赖,避免老旧原生包。
- Alpine 镜像缺构建工具时安装
python3 make g++。 - 企业内网准备二进制依赖缓存。
构建内存不足:
JavaScript heap out of memory临时处理:
export NODE_OPTIONS=--max-old-space-size=4096
pnpm run build长期要分析 bundle,拆路由、拆大依赖、清理误打包的大文件。
缓存未更新排查
发布后用户还看到旧页面,先看响应头:
curl -I https://example.com/index.html
curl -H "Cache-Control: no-cache" -I https://example.com/index.html
curl -fsS https://example.com/build-info.json重点看:
Cache-Control
ETag
Last-Modified
Age
Via
X-Cache如果源站已经是新版本,CDN 还是旧版本,刷新 CDN 的 /、/index.html 和实际入口路径。不要一上来全站刷新,先刷新入口文件。
如果用户局部白屏,查旧 hash 资源是否被删除:
curl -I https://example.com/assets/index.oldhash.js如果返回 404,说明旧入口还在某处缓存,但旧资源没了。处理方式是恢复旧资源、回滚,或者等缓存过期并刷新入口。
落地工程深水区
前端发布最容易被低估的地方,不是 npm run build,而是浏览器、CDN、Nginx、对象存储、API 网关和用户缓存共同参与之后,版本会不会变成一锅粥。
| 深水区 | 现场表现 | 落地做法 |
|---|---|---|
| 构建环境漂移 | 本地能构建,CI 或服务器构建失败 | 固定 Node LTS、包管理器和 lockfile;发布日志记录 node -v、pnpm -v、commit |
| 入口缓存失控 | 发布后部分用户还是旧首页 | index.html 短缓存,hash 资源长缓存;发布时优先刷新入口和 build-info.json |
| 旧资源被清理过早 | 用户打开旧入口后 JS 404 或 ChunkLoadError | 保留最近多个 release 的 assets;清理任务按版本和时间窗口执行 |
| 深层路由被误判 404 | /order/list 直接刷新失败 | history 模式配置 fallback,但静态资源 location 必须 try_files $uri =404 |
| API 地址混乱 | 测试环境包打到生产,或生产包请求测试 API | 构建时显式传 mode;产物写入环境、commit、构建时间;上线后用 build-info.json 验证 |
| CDN 掩盖源站问题 | 源站已修复,用户仍异常 | 同时看源站和 CDN 响应头;用 Age、Via、X-Cache 判断缓存层 |
| Service Worker 旧缓存 | 少数用户长期白屏,刷新也不恢复 | 评估是否真的需要 SW;发布新版本时处理缓存淘汰和兜底刷新 |
| 回滚只切软链 | 源站回滚了,但 CDN 仍分发新入口 | 回滚脚本包含入口刷新、健康检查、版本文件验证和 CDN 操作记录 |
| 静态资源权限不清 | 发布用户能写任意 Web 根目录 | 产物目录、release 目录、Nginx 运行用户分权;发布脚本只写指定版本目录 |
| 观测缺口 | 用户报白屏,服务器看不到异常 | 前端错误上报带 release、路由、资源 URL;Nginx access log 保留静态资源状态码 |
一条实用规则:入口文件决定用户拿到哪个版本,hash 资源决定这个版本能不能完整运行。上线、回滚、清理、缓存策略都要围绕这两类文件分开设计。
常用命令速查
构建环境:
node -v
corepack --version
pnpm -v
pnpm install --frozen-lockfile
pnpm run build产物校验:
test -f dist/index.html
test -f dist/build-info.json
find dist/assets -maxdepth 1 -type f | head
sha256sum dist.tar.gz > dist.tar.gz.sha256
sha256sum -c dist.tar.gz.sha256入口验证:
curl -I https://example.com/
curl -I https://example.com/index.html
curl -fsS https://example.com/build-info.json
curl -H "Accept-Encoding: gzip" -I https://example.com/assets/index.abc123.jsNginx 验证:
sudo nginx -t
sudo nginx -T | grep -n "server_name example.com" -A80
sudo systemctl reload nginx缓存排查:
curl -I https://example.com/index.html
curl -H "Cache-Control: no-cache" -I https://example.com/index.html
curl -I https://example.com/assets/index.oldhash.js官方文档入口
| 主题 | 文档 |
|---|---|
| Node.js 发布周期 | Node.js Previous Releases |
| Vite 生产构建 | Building for Production |
| Vite 环境变量与模式 | Env Variables and Modes |
| Vite 静态部署 | Deploying a Static Site |
| Vue Router history 模式 | Different History modes |
| Nginx core module | ngx_http_core_module |
| Nginx gzip | ngx_http_gzip_module |
上线前检查表
| 检查项 | 怎么验证 |
|---|---|
| Node 使用 LTS | node -v |
| 包管理器固定 | `cat package.json |
| 只有一个 lockfile | ls package-lock.json pnpm-lock.yaml yarn.lock |
| 依赖源可用 | npm ping、npm view vue version |
| 构建产物存在 | test -f dist/index.html |
| 版本文件存在 | cat dist/build-info.json |
| Nginx 配置正确 | nginx -t |
| 首页正常 | curl -I https://example.com/ |
| 深层路由正常 | curl -I https://example.com/order/list |
| 静态资源正常 | curl -I https://example.com/assets/index.abc123.js |
index.html 短缓存 | curl -I https://example.com/index.html |
| hash 资源长缓存 | curl -I https://example.com/assets/index.abc123.js |
| gzip 生效 | curl -H "Accept-Encoding: gzip" -I 静态资源地址 |
| API 代理正常 | curl -I https://example.com/api/health |
| 回滚可执行 | ./rollback.sh 上一个版本号 |
| 旧资源有保留 | ls /opt/web/console/releases |
前端部署最后拼的不是概念,而是这些小动作能不能连起来:能稳定构建,能明确验证,能快速回滚,能从日志和响应头里定位问题。做到这一步,Vue / React 上线就不再是“把包扔上去试试”,而是一套可复制的生产流程。
