第 32 章

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 流水线?

读完本章你将理解


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

这表示:

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[]);
}

服务端渲染的关键限制

  1. 没有 windowdocumentnavigator:Node.js 环境中这些全局对象不存在
  2. 生命周期只运行到 onServerPrefetchsetup()onMounted 和之后的钩子在服务端不执行
  3. 响应式更新不生效:服务端渲染是一次性的,不追踪响应式依赖

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 对象变成字符串,MapSetundefined 等类型同样不能正确传递。

正确做法:在 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);
});

本章小结

  1. 水合是关联而非重渲染:Vue 水合时将 VNode 与服务端 DOM 节点配对、绑定事件,不创建新 DOM;水合不匹配触发强制重渲染,代价比 CSR 更高——避免在服务端和客户端使用不同的值(Date.now()、Math.random()、浏览器 API)是首要原则。

  2. Nuxt 3 的 routeRules 是混合渲染的关键:首页用 prerender: true(构建时生成),产品列表用 swr: 60(服务端缓存 60 秒),用户仪表盘用 ssr: false(客户端渲染)——按页面特性选择渲染模式,而不是一刀切。

  3. useFetch 的数据在 HTML 中传递:服务端的 useFetch 数据被序列化到 __NUXT_DATA__,客户端直接使用,不重复请求;但 DateMapSet 在序列化后丢失类型,API 层应统一返回字符串。

  4. 缓存策略的两个极端index.html 绝对不缓存(no-store);哈希资源永久缓存(max-age=31536000, immutable)——中间层(API 响应)使用 stale-while-revalidate 在新鲜度和性能之间取得平衡。

  5. CI/CD 流水线的顺序是保障质量的护城河:lint → typecheck(vue-tsc)→ test(vitest)→ build → docker push → deploy,前一步失败则停止后续步骤;Docker 多阶段构建将镜像从 800MB 压缩到 25MB,层缓存策略将依赖安装缓存可以节省 2-5 分钟构建时间。

本章评分
4.8  / 5  (3 评分)

💬 留言讨论