1. 上线前夜:我差点把SimpleBlog部署搞砸了
先说结论:本地跑得飞快的项目,第一次部署上线时几乎必出问题,这不是诅咒,是流程缺失的必然结果。SimpleBlog从需求、后端接口到前端页面,写到第三篇时已经能跑通了,但真正要丢到服务器上给外人访问,我一开始还是走了不少弯路。
最开始我用的土办法:在服务器上装Node.js、装Nginx,然后把打包后的dist目录用scp拽上去,改个nginx配置指向它。听起来没问题对吧?结果一连串事故让我一整晚都在补救。
第一,服务器上的Node版本和本地不一致。本地用的是Node 18,服务器默认源里是Node 14,导致npm run build时报错SyntaxError: Unexpected token '.'。当时我没细想,直接升级Node,又发现Nginx配置里忘加try_files,博客首页能开,但只要点进文章详情页再刷新,直接404。还有,进程我用nohup node server.js &挂着写接口服务,结果服务器一重启,接口服务没了,前端页面在但数据全挂。这一晚让我彻底意识到,手工部署的每一步都是在给第二天埋雷。
后来我冷静下来分析,问题不在操作本身,而在“不可复现”和“状态不可控”。于是决定用Docker把整个前端构建、运行环境固化下来。这也是这篇博文想分享的核心:SimpleBlog上线走的是Docker路线,重点解决前端怎么用Docker部署上线,以及项目到了收尾阶段还需要处理哪些事。
如果你也正在做一个小型个人博客、企业官网或者轻量级Web应用,这篇内容应该能用上。我会把从Dockerfile编写、Nginx配置、环境变量注入、自动化部署到上线后的优化全部走一遍,里面也会穿插我实际踩过的坑。
2. Docker化前端:Dockerfile不是把dist复制进去那么简单
很多教程会把前端部署讲成三句话:npm run build、写个Dockerfile、docker run。但实际坑全在细节里。我先解释清楚一个关键决策:为什么前端也要用Docker,而不是直接把dist扔给服务器上的Nginx。
2.1 前端镜像的两种思路:运行时容器 vs 静态服务器
前端项目打包后就是一堆静态文件,所以后端那种带运行时的镜像并不适合前端。常见的写法有两种。
思路一:用Node镜像直接跑静态服务。比如node:18-alpine镜像里装个serve或者http-server,把dist目录作为静态资源提供。这种方案简单,但缺点也明显:Node运行时体积大、内存占用高,而且需要额外管理进程,和直接用Nginx比没有优势。
思路二:多阶段构建,最终镜像只保留Nginx和静态文件。这是目前前端部署的主流做法。第一阶段用Node镜像安装依赖并执行构建,第二阶段用Nginx镜像把构建产物复制进Web目录。最终镜像体积可以控制在几十MB以内,而且Nginx本身对静态文件处理能力极强,配置也灵活。
SimpleBlog的前端我选的是第二种。不仅镜像小,还因为Nginx可以顺带解决路由刷新、反向代理、缓存控制这些部署中最容易出问题的点。
2.2 多阶段构建的Dockerfile实战
先看我最终定稿的Dockerfile:
# 第一阶段:构建 FROM node:18-alpine AS builder WORKDIR /app # 先复制 package.json 和 package-lock.json,充分利用 Docker 缓存 COPY package.json package-lock.json ./ RUN npm config set registry https://registry.npmmirror.com && \ npm install --frozen-lockfile # 再复制源码并构建 COPY . . ARG API_BASE_URL ENV VITE_API_BASE_URL=$API_BASE_URL RUN npm run build # 第二阶段:运行 FROM nginx:1.25-alpine # 把构建产物复制到 Nginx 的 html 目录 COPY --from=builder /app/dist /usr/share/nginx/html # 自定义 nginx 配置 COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]这里的核心有两点:
先把
package.json和锁文件复制进去安装依赖,再复制其他源码。因为 Docker 构建时每一层都会做缓存,只要文件内容没变,这一层就不会重新执行。每次改代码不需要重新npm install,构建速度能快好几倍。我第一次写的时候是COPY . .然后才npm install,结果改了任一行组件代码就得全量重装依赖,耗时从几十秒飙到几分钟。构建参数
ARG和ENV配合,将接口地址在构建阶段注入。这里有个深坑,后面专门用一章讲。先记住一句话:前端在构建时就把接口地址“写死”进了打包产物,运行后再想改必须重新构建。
最终镜像用nginx:1.25-alpine,alpine版本体积非常小,但注意它内部的shell是ash,不是bash,调试时如果习惯敲bash命令会发现没有。我一开始没注意,进入容器排查问题的时候bash直接not found,还以为镜像坏了。
2.3 构建时的真实踩坑记录
整个Dockerfile写完,第一次构建就遇到两个问题,记录一下。
问题一:npm install的依赖和本地不一致。
本地用的npm版本是9.x,lock文件版本是3,但Docker镜像里Node 18自带的npm可能行为有差异。为了确保锁文件生效,我用npm install --frozen-lockfile,这样凡是lock文件与package.json不一致,安装会直接失败而不是默默更新依赖,避免同一套代码在不同环境装出不同依赖树。这个坑在团队协作时尤其致命,我见过两个同事本地都没问题,但打包产物一个能用、一个白屏,最后就是对出来的依赖版本不同。
问题二:Nginx镜像里没有default.conf,需要自己覆盖。
Nginx官方的default.conf配置里server_name是localhost,而且默认只监听80端口。直接复制dist进html目录能访问首页,但路由刷新会404。所以我单独写了一个 nginx.conf,放在项目根目录下。这里先贴配置,下一章详细说为什么:
server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } 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; } }第一版我把nginx.conf直接复制到/etc/nginx/nginx.conf,结果Nginx启动失败,因为原文件里有include /etc/nginx/conf.d/*.conf;的关键结构,我覆盖后等于把默认的include丢了。更合理的做法是只复制到/etc/nginx/conf.d/default.conf,替换默认的server块。这个细节不踩一次真的想不到。
3. Nginx与后端API联调:路由刷新和proxy_pass背后的门道
Dockerfile里最后那一大段nginx配置,其实是整个前端部署最考验经验的部分。SimpleBlog前端用了Vue Router的history模式,这意味着刷新页面时浏览器会请求真实的URL(比如/article/1),如果Nginx没有正确处理,返回的就是404。下面拆开讲。
3.1 history路由刷新404的解决方案
对SPA来说,前端路由的变化绝大多数情况下不会真正向后端发起请求。但用户手动刷新或者直接通过URL访问时,浏览器会拿着完整路径去请求服务器。服务器在文件系统里找不到/article/1这个物理文件,自然返回404。
解决办法就是try_files:
location / { try_files $uri $uri/ /index.html; }作用是:对于请求路径,依次尝试$uri(访问的文件)、$uri/(访问的目录),如果都没有,则把请求内部重写到/index.html。这样Vue Router拿到的是入口HTML,再根据路径渲染对应组件,刷新404就解决了。
但要注意,try_files不能把所有路径都无脑指到index.html。如果项目里有真实的静态文件,比如PDF、图片上传目录,需要在Nginx里单独配置location,否则这些文件也会被规则吞掉,全部返回index.html的内容。SimpleBlog目前没有上传图片,但我在配置里保留了/uploads/的别名,等以后功能扩展了不会踩坑。
3.2 proxy_pass接口转发:少写一个斜杠,接口全挂
前端访问后端接口分两种情况:
- 开发环境:前端把请求代理到本地起在8080端口的后端服务,Vite配置是
/api -> http://localhost:8080。 - 生产环境:浏览器直接访问SimpleBlog的域名,浏览器不知道后端服务在哪。所以我通过Nginx把
/api/前缀的请求反向代理到后端容器。
先看配置:
location /api/ { proxy_pass http://backend:8080/; }注意proxy_pass后面的URL末尾有没有斜杠,行为完全不同:
proxy_pass http://backend:8080;(无斜杠):请求/api/user/login会原样转发给后端,后端收到的是/api/user/login。适合后端本身也挂着/api前缀的场景。proxy_pass http://backend:8080/;(有斜杠):匹配到/api/之后,会把请求路径中location匹配的部分替换掉,发送给后端的是/user/login。适合后端接口本身不带/api前缀的场景。
SimpleBlog的后端接口在设计时就定义了/api前缀,所以我用无斜杠的写法。如果你用的是有proxy_pass斜杠,但后端没处理好路由,就会出现404或者重定向异常。这个细节我吃了两次亏,一次是登录接口404,一次是代理后Cookie的路径不对。
3.3 缓存策略:hash文件强缓存,index.html走协商缓存
部署上线后,最头疼的是“用户看到的还是旧页面”。这源于浏览器缓存。构建出来的静态资源名通常带hash,比如js/index-1a2b3c4d.js,文件内容变了hash就变,所以这类资源可以做很长的强缓存。而index.html是所有资源的入口,一旦强缓存,用户就永远拉不到新的js和css。
我的Nginx配置里分了两类:
location /assets/ { expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; } location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }immutable这个指令很关键,表示资源在过期时间内完全不需要再向服务器确认。我用在带hash的静态资源上,省去每次刷新都发条件请求的开销。而index.html用的是no-cache,意思是“每次使用前都要去服务器确认是否变化”,服务器若返回304,则复用本地缓存,若返回200,则下载新页面。
一开始我没设这些,结果更新部署后自己点了好几次刷新都是旧页面,后来打开DevTools“禁用缓存”才好。配置完成后再用Lighthouse跑性能,缓存这一项的得分大幅提升。
4. 环境变量注入:同一份镜像怎么适配测试、预发布、生产
SimpleBlog上线之前,我分别在服务器上跑过测试环境、预发布环境和生产环境。三套环境最明显的差异就是后端接口地址不同。如何让同一套构建产物不重新打包就能切换后端地址?这问题看起来简单,实际处理起来有点绕。
4.1 前端环境变量的致命陷阱:打包后改不了接口地址
很多前端项目在开发时会用到.env.development和.env.production,里面写VITE_API_BASE_URL之类的变量。Vite或Webpack在构建时会把以特定前缀开头的变量内联到代码里。也就是说:
VITE_API_BASE_URL = https://api.blog.example.com构建后,代码里所有引用这个变量的地方都会被字符串替换。这个值被写死在静态文件里,运行容器的环境变量根本不会影响它。所以就算你用docker run -e API_BASE_URL=xxx传了环境变量,前端也已经不认识了。
这带来的直接后果是:如果测试环境和生产环境的接口地址不同,就要分别为两个环境构建两次镜像,或者构建时传不同的ARG。作为个人项目,SimpleBlog准备阶段只用一套生产环境,但后续如果要给朋友演示或者自己多环境联调,这个问题就绕不过去。我最终采用的方案是分两步走。
第一步,构建时通过ARG传构建变量,在Dockerfile里已经有:
ARG API_BASE_URL ENV VITE_API_BASE_URL=$API_BASE_URL RUN npm run build这样构建时执行:
docker build --build-arg API_BASE_URL=https://api.blog.example.com -t simpleblog-frontend:latest .可以生成指定后端地址的镜像。这个方案能解决环境隔离问题,但代价是每个环境要单独构建、单独打tag。如果公司里有完整的CI/CD,这样也没什么不好。
第二步,如果不想为每个环境构建不同镜像,可以改成运行时注入。常见办法是用Nginx的sub_filter来替换占位符。思路是:构建时在index.html里放一个占位符字符串,例如__API_BASE_URL__,然后Nginx在返回HTML前把占位符替换成容器的环境变量。做法下面展开。
4.2 运行时注入环境变量的Nginx方案
SimpleBlog里我选择了一种更“轻”的方式:构建产物内所有API请求都走相对路径/api,然后由Nginx的反向代理把请求转发到不同的后端服务。这样前端根本不知道后端地址,只需要在Nginx层根据当前环境配置目标后端。
比如测试环境和生产环境用同一套前端镜像,只是部署时的nginx配置里proxy_pass指向的地址不同:
# 测试环境 location /api/ { proxy_pass http://test-backend:8080/; } # 生产环境 location /api/ { proxy_pass http://prod-backend:8080/; }这个方法对“前端项目”本身来说没有环境变量了,环境差异完全下沉到Nginx配置里。缺点是每次切换环境要改Nginx配置,所以我把配置文件做成模板,利用envsubst在容器启动时动态生成。
Docker镜像里Nginx启动时可以执行一段shell脚本:
#!/bin/sh envsubst '${BACKEND_UPSTREAM}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf nginx -g 'daemon off;'对应Dockerfile里的CMD改成执行这个脚本。这样一来,运行容器时传不同的BACKEND_UPSTREAM,就能动态改变Nginx里的代理目标,前端镜像完全不用重新构建。实测测试环境和生产环境用同一个镜像跑得很稳。
4.3 用Docker Compose编排前后端与数据库
部署上线不只需要前端容器,SimpleBlog还有后端接口和数据库。手动一个个docker run非常容易乱,我用docker-compose把它们编排在一起,这是最稳妥的上线前准备。
docker-compose.yml的核心服务:
version: '3.8' services: db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: simpleblog POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: simpleblog volumes: - pgdata:/var/lib/postgresql/data backend: build: ./backend restart: unless-stopped environment: DB_HOST: db DB_PORT: 5432 DB_USER: simpleblog DB_PASSWORD: ${DB_PASSWORD} DB_NAME: simpleblog depends_on: - db frontend: build: context: ./frontend args: API_BASE_URL: /api restart: unless-stopped ports: - "80:80" environment: BACKEND_UPSTREAM: "http://backend:8080" depends_on: - backend volumes: pgdata:注意几个细节:
restart: unless-stopped:容器异常退出时,Docker会自动重启它。服务器重启后不需要手动启动服务,这是和nohup方案最大的区别。depends_on:控制启动顺序,但要注意它是等容器启动,不是等服务就绪。如果后端启动慢,前端或数据库连接可能报错,需要后端代码做重试机制。- 环境变量从
.env文件读取:${DB_PASSWORD}来自服务器上的.env文件,这样不会把数据库密码写死在Compose文件里。
我在第一次用Compose跑起来后,登录博客的后台时发现登录接口一直报数据库连接失败,后来看日志发现是Postgres容器还在启动中,后端已经尝试连接了。最后给后端代码加了三分钟以内的重试逻辑才解决,这类问题在单机手动部署时几乎不会遇到,因为进程启动顺序是全靠人控制的。
5. 自动化部署:从本地敲命令到GitHub Actions一条龙
Compose配置完成后,把服务跑起来的步骤依然是一个个命令,例如docker compose build、docker compose up -d。这些命令如果在本地敲,一次两次还能接受,可每次改完代码都要重复从头到尾来一遍,很容易犯“忘了重新构建镜像结果只重启了容器”的错。为了彻底解决重复劳动,我把SimpleBlog的部署流程接到了GitHub Actions上。
5.1 服务器端的Docker运行环境准备
在配置CI之前,先要在服务器上装好Docker和Docker Compose插件。我用的服务器是Ubuntu 22.04,安装方式不做赘述,需要注意两点:
- 不要直接用root账号跑Docker守护进程,最好将当前用户加入
docker用户组,避免所有命令都加sudo。 - 安装
docker-compose-plugin,注意是否支持docker compose命令(新版插件形式)。如果服务器上的Docker版本较旧,可能需要用旧的docker-compose二进制,但语法和配置有细微区别。
启动服务后,我习惯用下面的命令确认所有容器状态:
docker compose ps能看到每个服务运行了多久、占用了多少端口、当前状态。如果App出现异常,这个命令是最快的检查入口。
5.2 GitHub Actions构建镜像并推送到镜像仓库
自动化部署的核心思想是:本地只负责push代码,服务器不主动构建,而是从镜像仓库拉取已经构建好的镜像。这样可以保证本地环境与线上环境完全一致。我的流程是:
- 本地推送代码到GitHub main分支
- GitHub Actions检测到push,安装依赖并构建前端、构建后端
- 构建好的镜像推送到Docker Hub或私有镜像仓库
- SSH登录服务器,让服务器拉取最新镜像并重启容器
.github/workflows/deploy.yml关键片段:
name: Deploy SimpleBlog on: push: branches: [ main ] jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push frontend uses: docker/build-push-action@v5 with: context: ./frontend build-args: | API_BASE_URL=/api push: true tags: yourname/simpleblog-frontend:latest - name: Build and push backend uses: docker/build-push-action@v5 with: context: ./backend push: true tags: yourname/simpleblog-backend:latest - name: Deploy to server uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: | cd /opt/simpleblog docker compose pull docker compose up -d docker image prune -f这里有几个要点。
- 密钥都放在GitHub仓库的Secrets里,不要写死在代码中。Docker Hub的密码用access token,避免用账号密码直接暴露。
- 每次部署前先
docker compose pull,把远程仓库的最新镜像拉到本地,再up -d。如果本地没有变化,容器不会被重建。这样一个看似简单的动作,实际上是保证线上环境和GitHub最新代码一致的基石。 docker image prune -f用于清理老镜像,避免服务器硬盘被历史镜像堆满。
5.3 部署后如何快速回滚
自动化部署虽然爽,但回滚能力更要命。有一次我改了个样式,新镜像推上去后发现首屏组件报错,根本原因是一个组件里导入了不存在的变量。此时如果没有回滚方案,用户会长时间看到白屏。
我的回滚策略是:每次构建都给镜像打上一个版本标签,不只是latest。例如:
docker build -t yourname/simpleblog-frontend:main-${GITHUB_SHA::7} .推送时同时打latest和 commit短hash。服务器上如果新版本有问题,只需在docker-compose.yml里把镜像tag改回上一个hash,再docker compose up -d即可。整个过程三分钟内完成。
这里还要提一个实操细节:服务器上的docker-compose.yml文件没有写死镜像tag,而是使用了.env文件:
frontend: image: ${FRONTEND_IMAGE}然后.env内容定义为:
FRONTEND_IMAGE=yourname/simpleblog-frontend:main-abc1234这样回滚时只需要修改服务器的.env,再执行docker compose up -d,不需要动Compose文件。我刚开始直接把tag写死在Compose里,回滚时还要改文件再拉镜像,耽误了不少时间。
6. 项目收尾:光上线还不够,能稳定跑才叫交付
服务跑通、域名解析也生效之后,千万不要以为项目就结束了。上线只是开始,稳定运行才是交付。SimpleBlog作为一个个人作品,虽然不需要企业级运维,但该有的收尾清单一个不能少。我按优先级整理了下面四件事。
6.1 日志收集与错误监控
Nginx的访问日志默认输出到容器stdout。通过docker logs可以查看,但如果服务崩溃重启,旧日志会丢失。更麻烦的是,前端页面上的JS报错,服务器端根本看不到。
我给SimpleBlog做了两层监控:
第一层,Nginx和容器日志持久化。修改docker-compose.yml,将日志文件挂载到宿主机:
frontend: volumes: - ./logs/nginx:/var/log/nginx这样即使容器重建,日志也不会丢。然后我写了个简单的shell定时任务,每天凌晨把前一天的日志压缩归档,7天前的日志删除,避免磁盘被刷爆。
第二层,前端错误上报。我在前端入口文件里给window.onerror挂了一个上报函数,把错误信息发送到后端接口,存入数据库。后来在后台里能看到用户访问博客时遇到了哪些JS报错,包括浏览器类型、页面路径和错误堆栈。这个小功能在项目展示和答辩时很有价值,能证明你考虑了用户侧的工程质量。
实际跑了两周后,我发现最多的错误来自老旧的移动端浏览器不支持Object.fromEntries。为了兼容,我在构建时加了@vitejs/plugin-legacy插件,将ES6+代码降级并注入polyfill。如果你也做个人项目,希望被别人正常使用,建议一开始就考虑这类兼容问题,而不是等监控报错了再补救。
6.2 数据备份与恢复测试
SimpleBlog用的是PostgreSQL。前端部署再花哨,后端数据库一丢,什么都白搭。我的备份策略并不复杂,核心是“定时备份+异地存放”。
挂在宿主机上的cron任务:
0 3 * * * docker compose exec -T db pg_dump -U simpleblog simpleblog | gzip > /backup/simpleblog-$(date +\%F).sql.gz把备份文件放在/backup目录下,之后再用rsync同步到另一台机器或者对象存储。定时备份是运行基础,但真正重要的是定期演练恢复过程。我第一次测试恢复时就发现,用pg_restore恢复pg_dump生成的SQL文件,和直接执行.sql文件语法略有不同。建议至少每月做一次从备份文件恢复到本地库的完整演练,确保备份实际可用。
另外,数据库容器如果挂在本地卷上,容器重建时卷不会自动删除,但手动执行docker compose down -v会把卷一起清空,这个-v参数在正式环境要谨慎使用。
6.3 域名、HTTPS和Nginx TLS配置
对博客这类网站来说,没有HTTPS的站点在浏览器里会被标记为“不安全”,而且Google和百度都会优先收录HTTPS页面。我用Certbot申请了免费的Let's Encrypt证书,通过Nginx自动续期。
证书申请完成后,SimpleBlog的Nginx配置增加443端口和HTTP跳转:
server { listen 80; server_name blog.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name blog.example.com; ssl_certificate /etc/letsencrypt/live/blog.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem; # 其他配置和之前一致 }但要注意,如果Nginx跑在Docker容器里,容器内需要挂载证书目录。我把证书放在宿主机/etc/letsencrypt,然后在docker-compose.yml中:
frontend: volumes: - /etc/letsencrypt:/etc/letsencrypt:ro这样Certbot宿主机续期后,容器里能直接读到新证书。Nginx本身会在启动时加载一次证书,续期之后需要nginx -s reload才生效。我在Certbot的续期命令后加了docker compose exec frontend nginx -s reload,这样自动续期后不需要手动干预。
6.4 项目文档与维护清单
写文档这件事看起来不产生直接价值,但对收尾来说非常关键。我的做法是:在项目根目录下写一个DEPLOY.md,里面包括:
- 服务器、数据库的IP、账号、端口清单(密码放在
.env里不写死) - 一次完整部署的执行步骤
- 日志查看位置
- 故障排查FAQ
- 回滚操作指引
为什么强调写清楚?因为半个月后你再看自己的项目,大概率会忘记当初如何部署的。我经历过服务器到期迁移,旧服务器没了,新服务器环境从零搭建。当时如果没文档,我恐怕要重新研究一遍Docker配置才能恢复服务。
另外,我还给项目加了个docs/目录,把第三篇的前端架构和本篇的部署流程都放进去。SimpleBlog规划里还有后续迭代,有文档兜底,后面加功能会轻松很多。
7. 上线后的性能体检:并优化了这些配置
上线不是终点,体验优化才是真正的收尾。我把SimpleBlog部署到线上后,用浏览器开发者工具看了一眼网络瀑布图,发现虽然功能没问题,但加载速度还不够理想。通俗地说,第一次打开页面时白屏时间比较长,尤其是新手访问时的加载很慢。这一章讲一下我做的几项关键优化,以及它们背后的原理。
7.1 从网络瀑布看性能瓶颈
打开DevTools的Network面板,禁用缓存后刷新首页,我看到了几个问题:
index.html体积有4KB不大,但后续请求了很多JS、CSS、字体,全部加起来超过1.2MB。- 图片资源压根没做优化,有些文章封面使用的原图直接以几MB的形式加载。
- 没有启用gzip或Brotli压缩,JS和CSS文件以原始体积传输。
- 并发的资源请求很多,但因为都集中在同一个域名下,没有充分利用HTTP/2的多路复用(后来查了下,我用Nginx默认配置没有显式开启HTTP/2,let's encrypt那个配置只是给了TLS,但协议还是HTTP/1.1)。
7.2 压缩、缓存和HTTP/2配置
在Nginx层打开这几项优化:
server { listen 443 ssl http2; # ... 其他配置 gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss image/svg+xml; gzip_min_length 1024; gzip_comp_level 6; location /assets/ { expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; } }注意listen 443 ssl http2;是HTTP/2的开启方式。现在Nginx的新写法是listen 443 ssl; http2 on;或者直接加http2参数,不同版本有细微差异。开启HTTP/2后,同一个域名下的并发请求不需要排队了,加载速度提升非常明显。
Brotli比gzip压缩率更高,但Nginx默认并不支持。我原本想装brotli模块,后来考虑到镜像体积和构建复杂度,暂时先用gzip,效果已经可以接受。如果你用的是CDN,一般CDN边缘节点都支持Brotli,可以在CDN侧开启。
7.3 实测数据对比
优化后我用无痕模式测了几次,记录到下面的对比(三秒加载次数取中位数):
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首页资源总大小 | 1.3 MB | 约780 KB |
| 文档完成时间 | 2.8 s | 1.1 s |
| DOMContentLoaded | 2.1 s | 0.8 s |
| 首屏渲染完成 | 3.0 s | 1.4 s |
主要收益来自三块:开启gzip后JS和CSS从200KB左右降到60KB左右;HTTP/2加快了并发资源获取;图片压缩后减少了几百KB。如果想继续优化,还可以将静态资源放到CDN,但SimpleBlog访问量不大,服务器带宽也够用,暂时没有引入额外依赖。
整个排查过程让我领悟到一个道理:前端性能优化的起点是“先感知,再量化”,而不是一上来就上各种工具。通过Network面板看到真实瓶颈,再针对性优化,才不会白费功夫。
8. 写在最后:未来迭代时我仍会坚持的几点原则
部署上线和项目收尾这段经历,让我改变了对“开发完成”的定义。以前我以为写完代码、本地测试通过就算完成,现在我认为真正完成的标志是:用到的机器全部从零到一部署起来了,中途所有过程还能重演,坏了能修,出问题能查。SimpleBlog从手动部署到Docker化部署,再到自动化流水线,这三步走完,整套架构才稍微算得上“能交付”。
如果让我说一个最重要的经验,那就是尽量把环境差异消除掉。本地能跑,服务器上不能跑,这类问题大多来自环境不一致,比如Node版本、系统库、Nginx配置、环境变量。Docker把前端构建环境和运行环境固化后,这类问题几乎从根上消灭了。我后来新起的个人小项目,不管是不是博客,都会优先考虑用同样的多阶段构建方式部署,哪怕一开始上手慢一点,后期维护成本会低很多。
当然,Docker方案也不是银弹。比如每次构建都要拉取一次依赖,如果网络不稳定,构建时间会很长。这时候可以配置镜像加速器,或者在服务器上直接构建并缓存中间层。此外,Docker Compose虽然简化了编排,但如果是多台服务器集群,就需要引入Swarm或Kubernetes,那是另一个复杂度层级。SimpleBlog目前跑在一台云主机上,暂时没有必要上太重的方案。
最后再分享一个小技巧:上线后我给自己设了两个“稳定运行”指标,一个是服务可用时长,一个是页面首屏加载时间。前者通过uptime和监控脚本观察,后者定期用PageSpeed Insights检测。这两个指标能直观反映博客是否健康,也方便在后续迭代里判断改动是修正了问题还是引入了新问题。SimpleBlog虽然只是个人博客,但把它当作一个真正的产品来运维,能学到的东西远比写几个组件多得多。