打包命令敲下去,控制台一路绿字,dist目录也生成了,然后呢?我第一次把 vue 项目往服务器上搬的时候,就卡在这个"然后"。本地npm run dev一切正常,传到服务器上要么整页白屏,要么刷新一下 404,要么路由点着没问题、地址栏一刷新就跳回默认首页。后来带过几个新人,发现大家踩的坑高度重合,问题基本不在业务代码,而在"打包产物怎么理解"和"服务器怎么把它喂给浏览器"这两件事上。这篇就把 vue 项目打包后部署到服务器的完整链条拆开讲,从dist里每个文件是干什么的,到 Nginx 每一行配置为什么这么写,再到打包后布局异常、刷新 404、缓存不更新这些真实翻车场景的排查链路。适合刚接触前端上线流程的同学,也适合已经部署过但总在细节上返工的同学,照着走一遍能省掉大量来回试探的时间。
1. 打包产物到底长什么样:先看懂 dist 再谈部署
1.1 npm run build 究竟做了哪些事
不管你是 Vue CLI 还是 Vite,npm run build的本质都是把一堆浏览器看不懂的东西(.vue单文件组件、TypeScript、SCSS、ES Module 的import)翻译成浏览器能直接执行的静态文件。这个过程里发生的关键动作有这么几个,理解它们对后面排错很有用。
第一个动作是模块合并与摇树。你在main.js里import了几十个组件,打包器要顺着依赖图把它们全部串起来,同时把没被引用到的代码删掉。这就是为什么有时候你在工具函数文件里写了个export,结果没在项目里用过,上线后去搜产物源码压根搜不到。
第二个动作是拆分。默认情况下,框架运行时(Vue 本身)、第三方库、你自己的业务代码会被分成不同的 chunk。比如 Vue CLI 默认会产出chunk-vendors.[hash].js和app.[hash].js,Vite 则倾向于按动态import()的边界来拆。拆分的意义是缓存友好:第三方库几个月不变,用户第二次访问就不用重新下载了,所以你会在文件名里看到那一串哈希值。
第三个动作是压缩与降级。JS 会被 Terser 或 esbuild 压成一行并做变量名混淆,CSS 会被合并压缩,图片小于阈值的一般会转成 base64 内联。产物名字长得像乱码是正常的,不要试图去读它。
最终目录结构大致是:
dist/ ├── index.html ├── favicon.ico ── assets/ ├── index-a1b2c3d4.js ├── index-e5f6g7h8.css ├── chunk-vendors-i9j0k1l2.js └── logo-m3n4o5p6.png这里有个新手最容易忽略的点:index.html是入口,它里面用<script src="/assets/index-a1b2c3d4.js">这种方式引用资源,默认是绝对路径,从域名根开始算。你把dist目录双击用file://协议打开,一定会白屏,因为浏览器会去找file:///assets/index-xxx.js,那个路径根本不存在。这不是项目坏了,是打开方式错了。
1.2 上传之前,先在本地用静态服务把 dist 跑一遍
我见过太多人跳过这一步直接传服务器,然后在服务器上反复改配置,效率极低。正确的顺序是:先在本地确认dist本身没问题,再去折腾服务器。
跑本地静态服务有两个层级,一定要区分开:
| 命令 | 是否支持 history 路由刷新 | 适用场景 |
|---|---|---|
python3 -m http.server 5000 -d dist | 不支持 | 只想确认资源能不能加载 |
npx serve dist | 不支持 | 同上,带目录列表 |
npx serve -s dist -l 5000 | 支持,会 fallback 到 index.html | 模拟真实 Nginx 行为 |
区别在哪?假设你的路由是/user/123,用python3 -m http.server访问这个地址,服务器会去找dist/user/123这个文件,找不到就 404。而加了-s参数之后,任何找不到的路径都返回index.html,前端路由就能接管。Nginx 那边的try_files干的正是同一件事,先在本地把这层行为验证清楚,到服务器上你就知道该找谁的问题了。
另外建议顺手加一个--host 0.0.0.0或者用局域网 IP 打开一次,用手机连同一个网络访问一下。有些布局问题只在真实移动端浏览器上暴露,电脑上拖窄窗口是模拟不出来的。
1.3 publicPath 与 base 填错,是白屏的头号元凶
这个参数在 Vue CLI 里叫publicPath,在 Vite 里叫base,默认值都是/。它决定了产物里资源引用的前缀。只有当你的站点部署在域名根目录时,才应该保持/。
三种常见情况:
- 部署在
https://example.com/,配置保持默认,产物引用/assets/xxx.js,正确。 - 部署在
https://example.com/admin/,必须改成/admin/,否则浏览器会去域名根找资源,全部 404,页面白屏。 - 部署在二级目录但不想写死路径,可以填
./,让打包器产出相对路径。但这里有个坑:相对路径和 history 路由是相冲的。因为路由是/admin/user/123,浏览器解析./assets/xxx.js时会以当前路径为基准,算出来是/admin/user/assets/xxx.js,照样 404。所以只要用了 history 模式,就别用相对路径,老老实实写完整的子目录前缀。
配置位置:
// vue.config.js(Vue CLI) module.exports = { publicPath: '/admin/', outputDir: 'dist', assetsDir: 'assets' }// vite.config.js(Vite) import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ base: '/admin/', build: { outDir: 'dist', assetsDir: 'assets' }, plugins: [vue()] })改完这两个参数,记得重新build一次,别拿旧的dist去部署,这个低级错误我自己犯过不止一次。
2. 服务器侧的准备工作:从裸机到能接住前端包
2.1 机器怎么选,系统装哪个版本
纯前端静态站点的资源消耗极低,一台 1 核 2G 的入门配置,配合 Nginx,扛住每天几千到几万 PV 没什么压力,因为 Nginx 处理静态文件的性能非常可观,真正吃资源的是后端接口和数据库。如果你还打算在这台机器上跑 Node 服务、数据库、构建任务,那就直接上 2 核 4G 起步,别在内存上省钱,编译时被 OOM 杀掉进程是很挫败的体验。
系统方面,我一般选 Ubuntu 的 LTS 版本或者 Debian 的 stable。原因很实际:软件源里的 Nginx、certbot、rsync 版本都比较新,遇到问题搜出来的资料也最多。CentOS 系不是不能用,但近两年生态变动大,新手容易在装源这一步就卡住。
买机器的时候有个细节值得注意:带宽比 CPU 更重要。静态站点的 js 包动辄几百 KB,1M 带宽的理论下载速度只有 128KB/s 左右,用户首屏等待会很难受。预算有限的情况下,宁可降一档 CPU,也要保证 3M 以上的带宽,同时把 gzip 打开,能省掉六成以上的传输体积。
2.2 前端静态站点到底需不需要装 Node
这是被问得最多的问题之一。答案很明确:如果你的项目是纯前端 SPA,服务器上完全不需要装 Node,也不需要 pm2 之类的东西。
道理很简单。npm run build是构建时行为,不是运行时行为。构建完成之后,产物就是一堆 html、js、css、图片,浏览器请求它们,Nginx 从磁盘读出来返回,中间没有任何 JavaScript 执行环境参与。数据从哪来?从后端接口来,浏览器直接发请求。
那什么时候需要 Node 常驻?两种情况。一是用了 SSR 框架,比如 Nuxt 的服务端渲染模式,页面 HTML 需要在服务器上实时生成。二是你的项目里塞了一个 BFF 层,也就是"浏览器到后端之间的中间接口层",专门做接口聚合和数据裁剪。这两种情况才需要node server.js常驻,并且用 pm2 做进程守护和开机自启。
判断方法很简单:看你的package.json里有没有start脚本用来启服务。如果没有,就是纯静态,Nginx 搞定一切。
2.3 目录规划与上传方式的选择
服务器上的目录别乱放。我习惯这样规划:
/var/www/myapp/ ├── releases/ │ ├── 20240512103000/ │ ├── 20240513141500/ │ └── 20240514162000/ └── current -> releases/20240514162000releases存放每一次发布的完整产物,用时间戳命名,方便追溯。current是一个软链接,永远指向当前生效的那一份。Nginx 的root指向current,发布新版本就是"传新目录 + 改软链接指向",回滚就是"把软链接指回上一个目录",秒级完成,不用重新传文件。这套做法借鉴了 Capistrano 的思路,用过一次就回不去了。
上传方式我列个对比,按场景选:
| 方式 | 优点 | 缺点 | 适合谁 |
|---|---|---|---|
scp -r dist/* user@host:/path | 简单直接,命令短 | 每次全量传,慢 | 偶尔发一次的小项目 |
rsync -avz --delete dist/ user@host:/path | 增量同步,只传变化的文件 | 参数需要理解 | 高频发布,推荐 |
| Git 拉取 + 服务器上构建 | 一条命令,版本可追溯 | 服务器要装 Node,构建吃内存 | 团队协作,代码托管在私有仓库 |
| CI 自动发布 | 完全无人值守,规范 | 首次配置有学习成本 | 已有持续集成流程的团队 |
rsync那个--delete参数要特别小心,它的作用是"删掉目标目录里源目录没有的文件",保证两边完全一致。如果路径写错一个斜杠,可能把整个目录清空。建议第一次加-n(dry run)先看看它准备干什么。
还有一点,rsync源路径结尾的斜杠含义完全不同:dist/表示"把 dist 里面的内容同步过去",dist表示"把 dist 这个目录本身同步过去"。这个细节坑过无数人。
2.4 别用 root 上传,权限最小化处理
生产环境直接用 root 传文件、跑服务,是个坏习惯。正确做法是建一个专门的部署账号,比如deploy,只给它/var/www/myapp目录的写权限:
sudo adduser deploy sudo mkdir -p /var/www/myapp/releases sudo chown -R deploy:deploy /var/www/myapp sudo chmod -R 755 /var/www/myappNginx 的工作进程通常会以www-data用户运行,它需要能读取这些文件,所以目录给 755、文件给 644 就行,千万不要图省事给 777,那等于把整个目录敞开。
配置完成后用ls -l检查一下,重点看 Nginx 用户能不能读。有个很隐蔽的场景:你从本地传上去的文件带着本地的权限位,如果某个文件权限是 600 且属主不是 Nginx 用户,Nginx 读不了,返回 403,而浏览器控制台只显示"加载失败",很难往权限方向想。
3. Nginx 配置的每一行在干什么
3.1 一份最小可用的站点配置
先看骨架,再逐行拆。
server { listen 80; server_name example.com www.example.com; root /var/www/myapp/current; index index.html; location / { try_files $uri $uri/ /index.html; } }listen 80是监听端口,server_name是域名匹配规则,这两个不用多说。root指定网站根目录,我这里指向了软链接current,所以每次发布换链接就生效了,不用改配置。
index index.html告诉 Nginx:当请求的是目录时,默认返回哪个文件。
关键是location /这一段,它是整个前端部署里最重要的一行。
3.2 try_files 与刷新 404:history 模式的根因
Vue Router 有两种模式。hash模式下地址栏长这样example.com/#/user/123,井号后面的内容浏览器不会发给服务器,所以服务器永远只看到/,不会 404。history模式下去掉了井号,地址变成example.com/user/123,用户在页面上点链接没问题,因为那是前端 JS 拦截的跳转,没有真实请求;但用户按 F5 刷新,浏览器会真真切切地向服务器请求/user/123这个路径,而服务器磁盘上根本没有这个文件,于是 404。
try_files $uri $uri/ /index.html;就是解决这个问题的。它的执行逻辑是:
- 先看请求的路径能不能对应到一个真实文件(
$uri),能就直接返回,比如/assets/index-a1b2.js会命中真实文件。 - 不能的话,看是不是一个目录(
$uri/),比如请求/assets/,会尝试找assets/index.html。 - 都不行,就返回
/index.html,交给前端路由去解析。
所以用户刷新/user/123时,服务器返回的是index.html,Vue 启动后读地址栏,发现是/user/123,渲染出对应用户页。看起来一切正常。
这里埋着一个非常经典的坑,后面第 4 章会展开:如果某个 js 文件不存在了(比如你清理旧版本文件时误删),try_files也会返回index.html,也就是一段 HTML 内容,但响应头里的 Content-Type 还是text/html。浏览器满心期待地要执行这个 js,结果拿到一段 HTML,控制台会报类似Uncaught SyntaxError: Unexpected token '<'的错误。这个报错信息看起来特别莫名其妙,但只要你知道了机制,一眼就能定位。
3.3 缓存策略:index.html 必须不缓存,带哈希的资源可以长期缓存
缓存是发布流程里最容易出"我明明改了,用户看到的还是旧版本"问题的地方。核心原则只有一条:
文件名带哈希的资源,可以设置超长缓存;不带哈希的入口文件,必须禁用强缓存。
因为index-a1b2c3d4.js这个文件名本身就是内容指纹,内容一变哈希就变,文件名就变,浏览器自然去请求新文件,旧文件缓存在那里也无所谓。而index.html的文件名永远不变,如果它被缓存了,用户浏览器里存的还是旧的 HTML,里面引用的还是旧哈希的 js,新版本就永远上不了线。
配置写法:
# 带哈希的静态资源,长期缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; } # 入口文件不缓存 location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; expires 0; }这里涉及 Nginx location 的匹配优先级,必须搞清楚,否则配置写了不生效你还不知道为什么:
| 修饰符 | 含义 | 优先级 |
|---|---|---|
= | 精确匹配,路径必须完全一致 | 最高 |
^~ | 前缀匹配,命中后不再尝试正则 | 次高 |
~ | 正则匹配,区分大小写 | 中 |
~* | 正则匹配,不区分大小写 | 中 |
| 无修饰符 | 普通前缀匹配 | 最低 |
匹配顺序是:先找精确匹配,命中就结束;否则找最长的前缀匹配,如果那个前缀带了^~,就用它;否则记住这个前缀匹配结果,再去按顺序试正则,正则一旦命中就用正则的;正则都没命中,才回退到刚才记住的前缀匹配。
所以上面那份配置等价于:请求/index.html时,location = /index.html精确命中,不缓存;请求别的.js、.css,正则命中,长缓存;请求/user/123这种路径,两个规则都不匹配,落到location /的try_files。
3.4 gzip 该开多大,会不会拖慢服务器
gzip 是性价比极高的优化。一个 500KB 的 js 文件,压缩后通常只剩 130KB 左右,用户下载时间直接砍掉一大半。配置:
gzip on; gzip_comp_level 5; gzip_min_length 1024; gzip_vary on; gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml image/svg+xml;几个参数的取舍值得说说。gzip_comp_level范围是 1 到 9,级别越高压缩率越好但越吃 CPU。实测下来 5 到 6 是最舒服的区间,再往上压缩率提升很有限,CPU 开销却成倍增加。静态文件其实更适合在构建阶段预压缩好,也就是生成.js.gz文件,Nginx 用gzip_static on;直接返回现成的压缩包,服务器运行时完全不消耗 CPU 做压缩,这是高流量站点的标准做法。
gzip_min_length设成 1024 是为了避免压缩小文件,因为压缩后的头部开销可能比省下来的还多,得不偿失。
gzip_vary on会加上Vary: Accept-Encoding响应头,告诉中间的任何缓存层"同一个 URL 针对不同的编码方式要分别缓存",不加的话可能出现有的用户拿到压缩版、有的拿到未压缩版然后解码失败的怪问题。
验证是否生效:
curl -I -H "Accept-Encoding: gzip" https://example.com/assets/index-a1b2c3d4.js响应头里出现Content-Encoding: gzip就成了。如果没出现,检查两件事:文件类型是否在gzip_types列表里,Nginx 是否重载了配置(nginx -s reload或systemctl reload nginx)。
3.5 把 /api 请求转发给后端,避开跨域
前端部署到example.com,接口在api.example.com:8080,浏览器会因为同源策略拦下请求。解决办法是在 Nginx 层做一次请求转发,让浏览器以为接口和页面同源。
location /api/ { proxy_pass http://127.0.0.1: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; proxy_connect_timeout 30s; proxy_read_timeout 60s; }这里有一个必踩的坑:proxy_pass结尾带不带斜杠,转发行为完全不同。
proxy_pass http://127.0.0.1:8080/;(带斜杠):请求/api/user/list会被转发到http://127.0.0.1:8080/user/list,/api前缀被剥掉了。proxy_pass http://127.0.0.1:8080;(不带斜杠):转发到http://127.0.0.1:8080/api/user/list,前缀保留。
后端接口如果本身就是/api/user/list,你却配了带斜杠的版本,那就会 404,然后你会怀疑人生地去看后端日志。记住这个规则:location 和 proxy_pass 至少有一个带斜杠结尾,才不会出路径拼接事故。
那几个proxy_set_header也是必须的。不加Host,后端拿到的域名是127.0.0.1:8080,如果后端做了域名校验会直接拒绝。不加X-Real-IP和X-Forwarded-For,后端日志里所有请求的来源 IP 都是127.0.0.1,出问题没法追踪真实用户。不加X-Forwarded-Proto,后端不知道自己是被 HTTP 还是 HTTPS 访问的,生成回调地址时会出错。
前端代码里就把请求基地址写成相对路径/api,不要写死完整域名:
// 推荐 const baseURL = '/api' // 不推荐,写死了域名,换环境就得改代码重新打包 const baseURL = 'https://api.example.com'4. 打包后布局异常、白屏、样式错乱:一次完整的排查链路
这一章讲讲真实翻车现场。重点不是给你答案,而是给你排查顺序,因为同样一个"白屏"现象,背后可能是四五种完全不同的原因,按顺序排下去能最快收敛。
4.1 白屏的第一现场:Console 和 Network 面板
打开浏览器开发者工具,先看 Console 有没有红色报错,再看 Network 里所有请求的状态码。
index.html就是 404:Nginx 的root路径写错了,或者文件根本没传上去。先用ls -l /var/www/myapp/current/index.html在服务器上确认文件存在。- HTML 能加载,但 js 全部 404:典型的
publicPath配置错误。去 Network 面板看请求的 URL 是什么,如果是https://example.com/assets/xxx.js而你实际部署在/admin/下,说明publicPath没改。 - js 请求返回 200,但控制台报
Unexpected token '<':这个前面提过,是try_files把不存在的 js 请求 fallback 到了index.html,浏览器拿到一段 HTML 当 js 执行。根本原因通常是旧版本文件被删了但用户浏览器还缓存着旧 HTML,或者发布时文件没传全。检查方法是直接在浏览器地址栏访问那个 js 的 URL,看返回的是代码还是 HTML。 - js 请求返回 403:文件权限问题,Nginx 工作用户读不了,参考 2.4 节的权限设置。
- js 是 200 但 MIME 类型不对:响应头里
Content-Type不是application/javascript。这通常也是 fallback 到 index.html 导致的,因为 index.html 的类型是text/html。
顺着这条链路走一遍,九成白屏问题能定位到根因。
4.2 布局异常往往不是代码问题,而是资源没加载全
"打包后布局异常"是个高频搜索词,我遇到的案例里,真正因为代码逻辑出问题的比例很低,大多数是下面这几种:
第一种:CSS 文件 404,页面变成"裸 HTML"。表现形式是所有样式全丢,文字左对齐、没有背景色、按钮变成原生样式。去 Network 面板过滤.css一看便知。原因和 js 404 一样,publicPath或者文件同步不全。
第二种:字体图标没加载,图标位置空出一块或者显示成方块。iconfont、Font Awesome 这类方案依赖字体文件,而 CSS 里引用字体文件的路径是相对 CSS 文件本身的位置计算的。如果你的 CSS 和字体文件都被放到assets/下,路径正常;但如果构建配置把 CSS 输出到别处,字体文件路径就会错位。检查方法是在 Network 面板里搜woff、ttf之类的请求。
第三种:样式覆盖顺序变了。开发环境下,各个.vue文件里的<style>是按组件引入顺序注入的,生产环境下会被提取合并成一个 CSS 文件并压缩,顺序可能发生变化。如果你写了依赖"后来者覆盖"的样式,比如在某个页面里覆盖公共组件的样式,生产环境就可能失效。规范做法是不要在业务页面里去覆盖公共组件样式,而是通过 props 或者 CSS 变量来控制。
第四种:浏览器兼容导致的 flex 或 grid 表现不一致。这个不常见但确实有,尤其是用了较新的 CSS 特性又没有配置兼容性处理时。构建时注意browserslist配置要和实际用户群体匹配,不要无脑排除旧浏览器。
第五种:缓存导致的"新旧混合"。用户浏览器里缓存了旧版本的 CSS,同时 HTML 已经更新到新版本,js 是新版、css 是旧版,两个版本的结构对不上,页面就错位了。这就是为什么 3.3 节强调index.html必须不缓存。
4.3 "我明明改了但页面没变"的三层缓存排查
这个现象通常是三层缓存在作祟,按照从近到远的顺序排:
第一层,浏览器缓存。最直接的验证方式是开无痕窗口,或者按 F12 打开 Network 面板勾上 "Disable cache" 再刷新。如果无痕下正常、正常窗口异常,就是浏览器缓存。彻底验证可以硬刷新(Ctrl+Shift+R)。
第二层,Nginx 缓存。主要是expires和Cache-Control头设置不当。用curl -I https://example.com/index.html看响应头里的缓存指令,确认index.html返回的是no-cache而不是max-age=31536000。
第三层,CDN 或中间缓存层。如果你在 Nginx 前面还挂了 CDN,那 CDN 节点上可能有旧内容。这种时候需要在发布流程里加一步"刷新 CDN 缓存",或者给 HTML 请求配置不缓存规则。这是最容易漏掉的一层,很多人排查到最后才发现是 CDN 的问题。
排查顺序建议是:先无痕验证,再 curl 看响应头,最后查 CDN。不要一上来就去改 Nginx 配置,先确认问题出在哪一层。
4.4 移动端上表现不同:viewport 与适配方案
有些布局问题只在手机上看得到,电脑上拖窄浏览器窗口完全正常。常见原因:
index.html里缺少 viewport meta 标签,导致移动端按桌面宽度渲染然后整体缩放,看起来字很小、布局挤在一起。正确写法是<meta name="viewport" content="width=device-width, initial-scale=1.0">。
使用了rem或vw适配方案,但基准值没配好或者做了动态计算。这类方案依赖 JS 在页面加载时计算根字号,如果 JS 执行晚于首屏渲染,会有一瞬间的错位,叫做"闪动"。缓解办法是把关键的首屏样式抽出来内联到index.html里,让首屏不依赖外部 CSS。
还有一种是软键盘弹起导致的高度变化。手机上输入框获得焦点时,可视区域高度会变小,如果布局用了100vh,内容会被挤压变形。移动端更推荐用100dvh或者干脆用 flex 布局让内容自适应。
5. 把它做成一次可复现的发布流程
5.1 用一个脚本把"打包 + 上传 + 切换"串起来
手工敲命令发几次之后,你就会想写脚本。因为手工操作一定会漏步骤,比如忘了重新 build 就上传、忘了改软链接、忘了 reload Nginx。
服务器端的部署脚本思路是这样:
#!/bin/bash set -e APP_DIR=/var/www/myapp RELEASE_DIR=$APP_DIR/releases/$(date +%Y%m%d%H%M%S) mkdir -p "$RELEASE_DIR" # 从标准输入解压上传上来的产物 tar -xzf - -C "$RELEASE_DIR" # 切换软链接,用 -n 避免在已有软链接时创建嵌套目录 ln -sfn "$RELEASE_DIR" "$APP_DIR/current" # 保留最近 5 个版本,其余删掉 cd "$APP_DIR/releases" ls -1dt */ | tail -n +6 | xargs -r rm -rf # 检查 Nginx 配置语法后重载 nginx -t && nginx -s reload echo "deployed: $RELEASE_DIR"本地这边配合:
#!/bin/bash set -e npm run build tar -czf - -C dist . | ssh deploy@example.com 'bash /var/www/myapp/deploy.sh'用 tar 通过标准输入流管道传过去,省掉了先在服务器落一个临时文件再解压的步骤,也能保持目录权限。set -e的作用是任何一步出错就立刻终止,避免带着错误继续往下跑。
ln -sfn里的-n参数要特别注意。如果目标是已存在的软链接,不加-n的话ln会在软链接指向的目录里面创建新链接,结果就是current/current/xxx这种诡异的嵌套结构,网站直接挂掉。
5.2 版本化目录带来的回滚能力
用了releases加current软链接的结构之后,回滚变得异常简单:
cd /var/www/myapp/releases ls -1dt */ ln -sfn /var/www/myapp/releases/20240513141500 /var/www/myapp/current把软链接指回上一个版本,Nginx 不用重启,新请求立刻就走旧版本了。整个过程一两秒,不需要重新上传文件。有这套机制之后,遇到线上问题可以先回滚止血,再慢慢排查原因,心态会从容很多。
需要提醒的是,回滚之后记得把出问题的版本目录留着别删,方便后续分析。清理策略里保留 5 个版本就是出于这个考虑,既能省磁盘,又给排查留了余量。
5.3 用持续集成把发布变成自动动作
团队协作场景下,手工发布早晚会出事。接入 CI 之后,流程变成"合并到主分支,自动构建并发布"。
以常见的 CI 平台为例,核心配置大概是这个形态:
deploy: stage: deploy only: - main script: - npm ci - npm run build - tar -czf - -C dist . | ssh -o StrictHostKeyChecking=no deploy@example.com 'bash /var/www/myapp/deploy.sh' environment: name: production url: https://example.com有三件事必须提前准备好,否则 CI 会卡住。
第一是免密登录。CI 机器上要有一对密钥,公钥放到服务器的deploy用户下,私钥配到 CI 的密钥变量里,注意私钥要用平台的加密变量存储,绝对不能写在代码仓库里。
第二是npm ci而不是npm install。npm ci严格按照package-lock.json安装,保证每次构建的依赖版本完全一致,避免"我本地能跑线上不行"这种问题。
第三是环境变量。接口地址这类配置项通过环境变量注入,在.env.production或者 CI 的变量配置里定义,构建时被打包进去。不要把敏感信息硬编码在源码里。
还有一个经验:CI 里的构建失败一定要有通知。不然某次发布失败了,但没人注意到,线上还是旧版本,问题会以更奇怪的方式暴露出来。
6. HTTPS、域名与上线后的收尾检查
6.1 证书配置与 HTTP 自动跳转
现在浏览器对 HTTP 站点的态度越来越严格,很多 API 在非安全上下文下直接不可用,比如地理位置、摄像头、剪贴板。所以 HTTPS 基本是必须的。
申请免费证书的流程很成熟,装好工具之后一条命令就能把证书签下来并自动改写 Nginx 配置。证书签完之后,Nginx 配置会变成两个 server 块:
server { listen 80; server_name example.com www.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name example.com www.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; root /var/www/myapp/current; index index.html; location / { try_files $uri $uri/ /index.html; } }listen 80那个块只做一件事:把所有 HTTP 请求 301 重定向到 HTTPS。这里要注意$host$request_uri的写法,$request_uri保留完整的路径和查询参数,用$uri的话查询参数会丢,用户从带参数链接进来会跳到首页。
免费证书通常 90 天有效期,一定要配置自动续期任务,或者至少设置到期提醒。我见过不少站点因为忘了续期,某天早上突然全站报证书过期,用户体验极差。配置好自动续期之后,可以手动跑一次演练命令验证流程能走通。
6.2 上线后必须过一遍的检查清单
发布完之后别急着关终端,花三分钟过一遍下面这些:
curl -I https://example.com/看状态码是不是 200,Content-Type是不是text/html。curl -I https://example.com/assets/index-xxx.js看是不是 200 且类型正确,缓存头是不是长缓存。curl -I -H "Accept-Encoding: gzip" https://example.com/assets/index-xxx.js确认Content-Encoding: gzip存在。- 浏览器无痕窗口打开,走一遍核心业务流程,特别是刷新页面和直接访问深层路由。
- 手机上打开一次,确认布局没崩。
- 检查
favicon.ico,虽然是小东西,但 404 会在控制台留红字,强迫症看着难受。 - 确认 404 页面配置正确,用户输错地址时不会看到 Nginx 的默认错误页。
这几项花不了几分钟,但能拦下绝大多数"上线后才发现"的问题。
最后分享一个我自己的教训。有一次上线后收到用户反馈说页面空白,我第一反应是代码有问题,回滚之后发现旧版本也空白了,这才意识到不是新版本引入的。查了快两小时,最后发现是同一次发布时我用rsync --delete同步,源目录路径多写了一层,导致assets目录被整个删掉了,而那次回滚只切了软链接,没恢复文件。从那以后我改了两件事:一是发布脚本里加了产物完整性检查,确认index.html和assets目录都存在且非空才切换软链接;二十每次发布后第一件事就是无痕窗口打开线上地址确认页面能出来。这些看起来啰嗦的检查,恰恰是把"偶尔出事故"变成"一直不出事故"的关键。