SSR、Nuxt 3 与生产部署:水合机制、缓存策略与 CI/CD 流水线
第32章:SSR、Nuxt 3 与生产部署——水合机制、缓存策略与 CI/CD 流水线
SSR 的水合(Hydration)不是"再次渲染",而是将 Vue 的 VNode 树与服务端生成的实际 DOM 节点一一关联起来并绑定事件——正因为它跳过了 DOM 创建,水合比从头渲染快 3-10 倍;而水合不匹配(Hydration Mismatch)会让浏览器强制重建整棵 DOM 树,代价比不使用 SSR 还高。
本章核心问题:SSR 的水合机制如何工作,Nuxt 3 的三种渲染模式如何选择,以及如何构建一个从代码提交到生产部署全自动的 CI/CD 流水线?
读完本章你将理解:
- 水合的本质(不是重渲染而是关联),以及水合不匹配的 5 个常见根因和防范措施
useFetchvs$fetch的执行环境差异,以及 Nuxt 3 的routeRules混合渲染配置- 多阶段 Docker 构建(800MB → 25MB)和 GitHub Actions CI/CD 完整流水线的配置方法
Level 1 · 你需要知道的(1-3年经验)
1.1 SSR 的完整工作流程
服务端渲染(Server-Side Rendering,SSR)的工作过程分为两个阶段:
第一阶段:服务端生成 HTML
用户请求 → Node.js 服务器
→ 创建 Vue 应用实例
→ 执行 setup()(获取数据)
→ 渲染为 HTML 字符串(@vue/server-renderer)
→ 返回完整的 HTML 页面
第二阶段:客户端接管(水合)
浏览器接收 HTML → 立即显示页面内容(用户看到页面)
→ 下载 JavaScript
→ Vue 在客户端"水合":将 VNode 与已有 DOM 关联
→ 绑定事件监听器
→ 页面变得可交互
为什么需要 SSR?
| 特性 | CSR(纯客户端渲染) | SSR |
|---|---|---|
| 首屏速度 | 慢(需等 JS 下载执行) | 快(HTML 直接显示) |
| SEO | 差(爬虫看不到内容) | 好(HTML 包含内容) |
| 服务器负载 | 低 | 高 |
| 交互前延迟 | JS 下载 + 解析 + 渲染 | JS 下载 + 水合 |
1.2 水合的本质:关联而不是重渲染
错误认知:很多人认为水合是"客户端再渲染一遍"。
正确理解:水合是将 Vue 的虚拟 DOM 树与服务端生成的真实 DOM 节点逐一配对,然后为这些 DOM 节点绑定事件监听器——整个过程不创建任何新的 DOM 节点。
服务端生成的 DOM:
<div id="app">
<h1>Hello Vue SSR</h1> ← 真实 DOM 节点(已在浏览器中)
<button>Click me</button> ← 真实 DOM 节点(已在浏览器中)
</div>
Vue 水合时:
VNode { tag: 'div', ... } → 关联 → <div id="app">(已有 DOM)
VNode { tag: 'h1', ... } → 关联 → <h1>(已有 DOM)
VNode { tag: 'button', ... } → 关联 → <button>(已有 DOM)
↳ 绑定 @click 事件监听器
这就是为什么水合比从头渲染快:document.createElement()(创建 DOM 节点)的成本远高于 querySelector()(查找 DOM 节点)。
1.3 水合不匹配:最危险的 SSR 陷阱
当服务端渲染的 HTML 与客户端期望的 VNode 结构不一致时,会发生"水合不匹配"(Hydration Mismatch)。Vue 3 会在开发模式下输出警告,并强制重新渲染(丢弃服务端 HTML,重新创建 DOM):
[Vue warn]: Hydration node mismatch:
- Client vnode: div
- Server rendered DOM: span
5 个常见的水合不匹配根因:
根因1:使用 Date.now() 或 Math.random()
<script setup>
// 服务端:Date.now() = 1700000000000
// 客户端:Date.now() = 1700000000123(不同!)
const timestamp = ref(Date.now());
</script>
<template>
<div>{{ timestamp }}</div> <!-- 不匹配! -->
</template>
根因2:使用浏览器专有 API
<script setup>
// window 在 Node.js 端不存在,服务端为 undefined
// 客户端为实际值
const screenWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 0);
</script>
根因3:不正确地使用 v-if 条件渲染
<script setup>
// 服务端:isLoggedIn = true(来自请求 cookie)
// 客户端水合时:isLoggedIn = false(还没读取 localStorage)
const isLoggedIn = ref(false);
onMounted(() => {
isLoggedIn.value = !!localStorage.getItem('token');
});
</script>
<template>
<!-- 服务端渲染了"已登录",客户端水合时期望"未登录" → 不匹配 -->
<div v-if="isLoggedIn">欢迎回来</div>
<div v-else>请登录</div>
</template>
根因4:时区差异
<!-- 服务端(UTC+8): 2024-01-01 08:00 -->
<!-- 客户端(UTC-5): 2023-12-31 19:00 -->
<div>{{ new Date().toLocaleDateString() }}</div>
根因5:随机的 CSS 类名(某些 CSS-in-JS 库)
CSS-in-JS 库在服务端生成的类名哈希(如 sc-abc123)与客户端不一致,导致类名属性不匹配。
1.4 水合不匹配的解决方案
方案1:使用 <ClientOnly> 包裹仅客户端的内容
<template>
<!-- 服务端跳过这部分,客户端才渲染 -->
<ClientOnly>
<div>当前时间:{{ currentTime }}</div>
<template #fallback>
<!-- 服务端显示的占位内容(可选) -->
<div>加载中...</div>
</template>
</ClientOnly>
</template>
方案2:在 onMounted 后更新数据
<script setup>
const isClient = ref(false);
const screenWidth = ref(1920); // 服务端安全的默认值
onMounted(() => {
isClient.value = true;
screenWidth.value = window.innerWidth; // onMounted 只在客户端执行
});
</script>
<template>
<div>{{ isClient ? screenWidth : '加载中' }}</div>
</template>
方案3:使用 v-if 条件性地跳过水合检查
<div :suppressHydrationWarning="true">
<!-- 对已知会不匹配的内容,抑制 Vue 的水合警告 -->
{{ Date.now() }}
</div>
1.5 Nuxt 3 的三种渲染模式
Nuxt 3 支持三种渲染模式,可以在页面级别配置:
模式1:全量 SSR(默认)
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true // 默认值,所有页面都 SSR
});
模式2:纯 CSR(关闭 SSR)
export default defineNuxtConfig({
ssr: false // 所有页面都在客户端渲染(类似 Vite SPA)
});
模式3:混合渲染(routeRules,最灵活)
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
routeRules: {
// 首页:预渲染(构建时生成 HTML,等同于 SSG)
'/': { prerender: true },
// 产品列表:SSR + 服务器缓存(CDN 层缓存 60 秒)
'/products': { swr: 60 }, // Stale-While-Revalidate
// 用户仪表盘:CSR(不 SSR,因为是私密的个性化内容)
'/dashboard/**': { ssr: false },
// API 路由:不做特殊处理
'/api/**': {},
// 静态文档:预渲染 + 永久缓存
'/docs/**': {
prerender: true,
headers: { 'cache-control': 'max-age=31536000, immutable' }
},
}
});
1.6 useFetch vs $fetch
这是 Nuxt 3 中最常见的混淆点之一:
useFetch(SSR 友好):
<script setup>
// useFetch 在服务端执行请求,将数据序列化到 HTML 的 __NUXT_DATA__
// 客户端水合时直接使用这些数据,不重复发请求
const { data, pending, error } = await useFetch('/api/products');
</script>
<template>
<div v-for="product in data" :key="product.id">
{{ product.name }}
</div>
</template>
$fetch(客户端请求):
<script setup>
// $fetch 只在客户端执行,不参与 SSR 数据传递
const products = ref([]);
onMounted(async () => {
// 这里在客户端执行(类似浏览器中的 fetch)
products.value = await $fetch('/api/products');
});
</script>
选择建议:
| 场景 | 使用 |
|---|---|
| 需要 SEO 的数据 | useFetch(数据进入 HTML) |
| 首屏必须展示的内容 | useFetch(不等 JS 加载) |
| 用户交互触发的请求 | $fetch(简洁灵活) |
| 登录后的私密数据 | $fetch(客户端请求,不进 HTML) |
Level 2 · 它是怎么运行的(3-5年经验)
2.1 Nuxt 3 的数据传递机制
当 useFetch 在服务端执行时,Nuxt 3 通过以下流程将数据从服务端传递到客户端:
服务端:
1. 执行 useFetch('/api/products')
2. 数据存储在 Nuxt payload 中(useNuxtApp().payload)
3. 将 payload 序列化为 JSON
4. 注入到 HTML 中的 <script> 标签:
<script type="application/json" id="__NUXT_DATA__">
{ "products": [...] }
</script>
客户端:
1. Vue 水合时,检测到 useNuxtApp().payload 中已有数据
2. useFetch 直接返回 payload 中的数据,不发网络请求
3. 响应式数据与服务端数据完全一致 → 不会水合不匹配
这就是为什么 useFetch 是 SSR 友好的:服务端和客户端使用完全相同的数据,不存在不一致的可能。
2.2 缓存策略:正确配置 Cache-Control
生产环境的缓存策略直接影响用户体验和服务器负载。
关键原则:
index.html → 永不缓存(Cache-Control: no-store)
↳ 原因:用户每次访问必须获取最新的 HTML,其中包含最新的哈希文件名引用
哈希资源 → 永久缓存(Cache-Control: max-age=31536000, immutable)
↳ 原因:文件名包含内容哈希(如 main.8f3a2b.js),
内容变化时文件名也变化,无需手动失效缓存
API 响应 → 按需设置短期缓存
↳ 示例:Cache-Control: max-age=60, stale-while-revalidate=3600
Nginx 配置示例:
server {
listen 80;
root /usr/share/nginx/html;
# index.html:不允许任何缓存
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
}
# 哈希资源(JS/CSS/字体):永久缓存
location ~* \.(js|css|woff2|woff|ttf|eot)$ {
# 这些文件名包含内容哈希,永久缓存是安全的
add_header Cache-Control "public, max-age=31536000, immutable";
# 开启 gzip 压缩
gzip_static on;
gzip_types text/javascript application/javascript text/css;
}
# 图片:长期缓存(但不是永久,因为文件名可能不变)
location ~* \.(png|jpg|jpeg|gif|webp|avif|svg|ico)$ {
add_header Cache-Control "public, max-age=2592000"; # 30 天
}
# SPA 的 HTML5 History 路由
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store";
}
}
stale-while-revalidate 策略(适用于 Nuxt SSR 的 API 缓存):
Cache-Control: max-age=60, stale-while-revalidate=3600
这表示:
- 60 秒内:直接从缓存返回(最快)
- 60s 到 3600s 内:返回缓存中的过期数据(stale),同时在后台重新获取(不阻塞用户)
- 3600s 后:必须重新获取
2.3 多阶段 Docker 构建:从 800MB 到 25MB
生产 Docker 镜像的关键是多阶段构建(Multi-stage build),将构建环境和运行环境分离:
# ===== 第一阶段:构建 =====
FROM node:20-alpine AS builder
# 完整的 node:20-alpine 镜像约 170MB
# 加上 node_modules 可达 500-800MB
WORKDIR /app
# 先复制 package 文件(利用 Docker 层缓存)
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile # 精确安装
# 复制源码并构建
COPY . .
RUN npm run build
# ===== 第二阶段:运行(SPA 静态文件) =====
FROM nginx:alpine AS runner
# nginx:alpine 只有约 25MB
# 复制 Nginx 配置
COPY nginx.conf /etc/nginx/nginx.conf
# 从构建阶段复制产物(只复制 dist/,不复制 node_modules)
COPY --from=builder /app/dist /usr/share/nginx/html
# 暴露端口
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
对于 Nuxt 3 SSR(需要 Node.js 运行时):
# ===== 第一阶段:安装依赖 =====
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
# ===== 第二阶段:构建 =====
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build # nuxt build → 生成 .output/
# ===== 第三阶段:运行 =====
FROM node:20-alpine AS runner
# 精简 Node.js 镜像约 150MB(比完整 node:20 的 900MB 小很多)
WORKDIR /app
ENV NODE_ENV=production
# 只复制运行时需要的文件(Nuxt 的 standalone 模式)
COPY --from=builder /app/.output ./
EXPOSE 3000
CMD ["node", "server/index.mjs"]
Nuxt 3 的 nuxt build 命令使用 Nitro 构建,默认生成 standalone 输出(.output/ 目录),包含所有运行时依赖,不需要额外的 node_modules。
2.4 GitHub Actions CI/CD 完整流水线
# .github/workflows/deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# ===== 第一步:代码质量检查 =====
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint # ESLint
- run: npm run format:check # Prettier
# ===== 第二步:类型检查 =====
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run typecheck # vue-tsc --noEmit
# ===== 第三步:单元测试 =====
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:unit # vitest run
env:
VITE_API_URL: https://api-staging.example.com
- name: Upload coverage
uses: codecov/codecov-action@v3
# ===== 第四步:构建并推送 Docker 镜像 =====
build-docker:
name: Build & Push Docker
runs-on: ubuntu-latest
needs: [lint, typecheck, test] # 必须前三步通过
if: github.ref == 'refs/heads/main' # 只在 main 分支执行
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
yourusername/yourapp:latest
yourusername/yourapp:${{ github.sha }}
# 启用 BuildKit 缓存,加速构建
cache-from: type=gha
cache-to: type=gha,mode=max
# ===== 第五步:部署到生产 =====
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [build-docker]
environment: production # 需要在 GitHub 仓库设置中配置保护规则
steps:
- name: Deploy to production server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
docker pull yourusername/yourapp:${{ github.sha }}
docker stop app-container || true
docker rm app-container || true
docker run -d \
--name app-container \
--restart always \
-p 3000:3000 \
-e NODE_ENV=production \
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
yourusername/yourapp:${{ github.sha }}
docker image prune -f # 清理旧镜像
vue-tsc 类型检查配置:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"jsx": "preserve"
},
"include": ["src/**/*", "env.d.ts"]
}
// package.json
{
"scripts": {
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.ts",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
Level 3 · 设计文档与源码(资深开发者)
3.1 @vue/server-renderer 的 renderToString 源码
@vue/server-renderer 的核心是将 Vue 应用渲染为 HTML 字符串:
// packages/server-renderer/src/renderToString.ts(简化)
export async function renderToString(
input: App | VNode,
context: SSRContext = {}
): Promise<string> {
if (isVNode(input)) {
// 允许直接渲染单个 VNode(用于组件测试)
const app = createApp({ render: () => input });
return renderToString(app, context);
}
// 创建组件树
const vnode = createVNode(input._component, input._props);
vnode.appContext = input._context;
// 注入 SSR 上下文
input.provide(ssrContextKey, context);
const buffer: SSRBuffer = [];
// 渲染 VNode 树到 buffer
await renderVNode(buffer, vnode, input._context, null);
// 等待所有异步操作完成(teleport 等)
await resolvePromises(context);
return unrollBuffer(buffer as SSRBufferItem[]);
}
服务端渲染的关键限制:
- 没有
window、document、navigator:Node.js 环境中这些全局对象不存在 - 生命周期只运行到
onServerPrefetch和setup():onMounted和之后的钩子在服务端不执行 - 响应式更新不生效:服务端渲染是一次性的,不追踪响应式依赖
3.2 Nuxt 3 的 Nitro 服务器引擎
Nuxt 3 使用 Nitro 作为底层服务器引擎,而不是直接使用 Express 或 Koa:
// Nitro 的路由处理(.nuxt/server/index.mjs 的简化版)
import { createApp, fromNodeMiddleware, toNodeListener } from 'h3';
const app = createApp();
// 注册 API 路由
app.use('/api/', apiHandler);
// 注册 SSR 渲染器
app.use('/', async (event) => {
const ssrContext = {
event,
url: getRequestURL(event),
head: createHead(),
};
const { html } = await renderNuxtApp(ssrContext);
return html;
});
// Nitro 支持多种适配器:
// - node(直接运行在 Node.js)
// - cloudflare-pages(Cloudflare Workers)
// - vercel(Vercel Edge Functions)
// - netlify(Netlify Functions)
export default fromNodeMiddleware(toNodeListener(app));
Nitro 的预渲染(Prerender):
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
routes: ['/'], // 明确指定预渲染路由
crawlLinks: true, // 自动爬取所有链接并预渲染
failOnError: false, // 预渲染失败时不中断构建
}
}
});
3.3 水合的源码实现
@vue/runtime-dom 中的水合逻辑在 hydrate.ts:
// packages/runtime-core/src/hydration.ts(大幅简化)
function hydrateNode(
node: Node, // 服务端生成的真实 DOM 节点
vnode: VNode, // 客户端的虚拟 DOM 节点
parentComponent: ComponentInternalInstance | null,
slotScopeIds: string[] | null,
optimized: boolean
): Node | null {
const { type, props, patchFlag, shapeFlag, children } = vnode;
// 关键:将 VNode 与真实 DOM 节点关联
vnode.el = node;
if (shapeFlag & ShapeFlags.ELEMENT) {
// 验证节点类型是否匹配
if (node.nodeType === DOMNodeTypes.ELEMENT) {
const el = node as Element;
// 如果内容不需要更新(patchFlag 优化),直接关联
if (patchFlag !== PatchFlags.BAIL && !hasMismatch) {
// 直接复用服务端 DOM,仅绑定事件
if (props) {
// 只处理事件监听(不更新其他 DOM 属性,因为服务端已经设置了)
if (props.onClick) {
patchProp(el, 'onClick', null, props.onClick, ...);
}
}
} else {
// 发生不匹配:强制重新渲染(昂贵的操作!)
patchElement(node.parentNode!, null, vnode, ...);
}
// 递归处理子节点
if (children) {
hydrateChildren(el.firstChild, vnode, el, parentComponent, ...);
}
} else {
// 严重不匹配:类型不对(如期望 div 但得到 span)
hasMismatch = true;
}
}
return node.nextSibling; // 返回下一个待处理的节点
}
patchFlag 优化在 SSR 中的作用:
Vue 3 的编译器在编译模板时会标注 patchFlag(补丁标志),标识哪些内容是动态的。在水合时,Vue 可以跳过对静态内容的检查:
// 编译后的渲染函数(含 patchFlag)
createElementVNode("div", null, [
createElementVNode("h1", null, "Static Title"), // 静态,水合时跳过检查
createElementVNode("p", null, _ctx.dynamicText,
1 /* TEXT */), // 动态文本,水合时检查
])
3.4 生产环境监控:健康检查与告警
// server/api/health.get.ts(Nuxt 3 API 路由)
export default defineEventHandler(async (event) => {
const checks = {
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || 'unknown',
checks: {
database: await checkDatabase(),
redis: await checkRedis(),
memory: process.memoryUsage().heapUsed < 500 * 1024 * 1024,
}
};
const isHealthy = Object.values(checks.checks).every(Boolean);
if (!isHealthy) {
setResponseStatus(event, 503); // Service Unavailable
checks.status = 'degraded';
}
return checks;
});
Docker 健康检查:
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
Level 4 · 边界与陷阱(全体适用)
陷阱 1:SSR 中使用 localStorage/sessionStorage 导致报错
错误代码:
// 在 setup() 或模块顶层(Node.js 中执行)
const token = localStorage.getItem('token'); // 报错!Node.js 中没有 localStorage
现象:服务端渲染时直接报 ReferenceError: localStorage is not defined,页面返回 500 错误。
正确做法:
// 方案1:useCookie(Nuxt 3 提供,SSR 安全)
const token = useCookie('auth_token');
// 方案2:process.client 判断(Nuxt 3 特有)
const token = ref(null);
if (process.client) {
token.value = localStorage.getItem('token');
}
// 方案3:onMounted 中访问(通用 Vue 方案)
onMounted(() => {
token.value = localStorage.getItem('token');
});
陷阱 2:useFetch 的响应数据被序列化后丢失类型
错误场景:
// API 返回的日期字符串
// server/api/events.get.ts
return { events: [{ startDate: new Date('2024-01-01') }] };
// 客户端接收时
const { data } = await useFetch('/api/events');
// data.value.events[0].startDate 是字符串!不是 Date 对象!
// JSON 序列化后 Date → string,客户端接收到的是字符串
原因:Nuxt 的 payload 通过 JSON 序列化传递,Date 对象变成字符串,Map、Set、undefined 等类型同样不能正确传递。
正确做法:在 API 层统一使用字符串(ISO 8601 格式),在客户端按需转换:
// API 层:始终返回字符串
return {
events: events.map(e => ({
...e,
startDate: e.startDate.toISOString() // 字符串
}))
};
// 客户端:按需转换
const startDate = computed(() =>
data.value ? new Date(data.value.events[0].startDate) : null
);
陷阱 3:在 CI/CD 中将敏感环境变量暴露给客户端
错误配置:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// 这两个都会被序列化到客户端 HTML!
DATABASE_URL: process.env.DATABASE_URL, // 危险!数据库连接字符串泄露
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, // 危险!私钥泄露
}
});
正确配置:
export default defineNuxtConfig({
runtimeConfig: {
// 服务端专用(不暴露给客户端)
databaseUrl: process.env.DATABASE_URL,
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
// public 下的才会暴露给客户端
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE, // 公开 API 地址
stripePublicKey: process.env.STRIPE_PUBLIC_KEY, // 公开 Key
}
}
});
在组件中访问:
const config = useRuntimeConfig();
// 仅服务端:config.databaseUrl
// 客户端和服务端:config.public.apiBase
陷阱 4:Docker 镜像层缓存失效导致构建慢
错误的 Dockerfile 写法:
FROM node:20-alpine
WORKDIR /app
# 错误:先复制所有文件,导致任何源码变化都使 npm install 层失效
COPY . . # 每次代码变化,这层和后续层都要重新执行
RUN npm ci # 慢!每次都要重新安装全部依赖
RUN npm run build
正确写法:利用 Docker 层缓存:
FROM node:20-alpine AS builder
WORKDIR /app
# 第一步:只复制 package 文件(变化频率低)
COPY package.json package-lock.json ./
# 第二步:安装依赖(如果 package.json 没变,这层会被缓存)
RUN npm ci --frozen-lockfile
# 第三步:复制源码(变化频率高,放在最后)
COPY . .
# 第四步:构建
RUN npm run build
为什么顺序重要:Docker 从上到下执行,每一层的缓存依赖于前面所有层的内容。package.json 变化频率远低于源码,将其提前可以让 npm ci 命中缓存,避免每次都重新安装数百个依赖包(节省 2-5 分钟构建时间)。
陷阱 5:Nuxt 3 的 server/middleware 引发 SSR 内存泄漏
错误场景:在 server middleware 中使用全局 Map 缓存请求数据:
// server/middleware/cache.ts
// 错误!这个 Map 是模块级别的全局变量
const cache = new Map();
export default defineEventHandler((event) => {
const key = getRequestURL(event).pathname;
if (!cache.has(key)) {
cache.set(key, fetchData(key)); // 每个路由都缓存
}
// cache 永远增长,永远不清理 → 内存泄漏
});
正确做法:使用 LRU 缓存或设置 TTL:
// server/middleware/cache.ts
import { LRUCache } from 'lru-cache';
// LRU 缓存:最多 1000 条,每条最多存 5 分钟
const cache = new LRUCache({
max: 1000,
ttl: 1000 * 60 * 5, // 5 分钟 TTL
});
export default defineEventHandler((event) => {
const key = getRequestURL(event).pathname;
if (!cache.has(key)) {
cache.set(key, computeValue(key));
}
return cache.get(key);
});
本章小结
-
水合是关联而非重渲染:Vue 水合时将 VNode 与服务端 DOM 节点配对、绑定事件,不创建新 DOM;水合不匹配触发强制重渲染,代价比 CSR 更高——避免在服务端和客户端使用不同的值(Date.now()、Math.random()、浏览器 API)是首要原则。
-
Nuxt 3 的 routeRules 是混合渲染的关键:首页用
prerender: true(构建时生成),产品列表用swr: 60(服务端缓存 60 秒),用户仪表盘用ssr: false(客户端渲染)——按页面特性选择渲染模式,而不是一刀切。 -
useFetch 的数据在 HTML 中传递:服务端的
useFetch数据被序列化到__NUXT_DATA__,客户端直接使用,不重复请求;但Date、Map、Set在序列化后丢失类型,API 层应统一返回字符串。 -
缓存策略的两个极端:
index.html绝对不缓存(no-store);哈希资源永久缓存(max-age=31536000, immutable)——中间层(API 响应)使用stale-while-revalidate在新鲜度和性能之间取得平衡。 -
CI/CD 流水线的顺序是保障质量的护城河:lint → typecheck(vue-tsc)→ test(vitest)→ build → docker push → deploy,前一步失败则停止后续步骤;Docker 多阶段构建将镜像从 800MB 压缩到 25MB,层缓存策略将依赖安装缓存可以节省 2-5 分钟构建时间。