Skip to content

SSR 服务端渲染

概念

客户端渲染 (CSR)

使用 Vue 开发的 SPA 项目一般都是 CSR(Client Side Rendering)模式,即客户端渲染。客户端接收到的是 HTML 模板和 JavaScript 脚本,在浏览器中运行 JavaScript 脚本,将 HTML 模板渲染成页面内容。

传统的服务端渲染 (SSR)

传统的 SSR(Server Side Rendering)是指在服务器端将 Vue 组件渲染为 HTML 字符串,然后将这些字符串发送到客户端。客户端接收到 HTML 字符串后,将其插入到页面中,并使用 JavaScript 进行交互。

同构的服务端渲染 (SSR)

同构的 SSR 是指在服务器端和客户端都渲染 Vue 组件(在 Nuxt 中叫做通用渲染 Universal Rendering)。服务器端渲染出初始的 HTML 字符串,然后将这些字符串发送到客户端。客户端接收到 HTML 字符串后,浏览器会将其插入到页面中,同时 Vue.js 会接管该文档,曾经在服务端上运行的 JavaScript 代码再次在客户端上运行,然后通过水合(Hydration)过程将 DOM 状态同步到客户端上。当水合完成后,客户端的 Vue.js 应用程序就完全接管了页面的控制权,包括响应用户的交互和更新页面内容。

  • 用户可以立即访问页面内容,因为浏览器显示静态内容的速度比 JavaScript 生成的内容快得多。同时,在水合过程中保留了 Web 应用程序的交互性
  • 通用渲染将页面的整个 HTML 内容作为经典服务器应用程序传递给浏览器。网络爬虫可以直接索引页面的内容,这使得通用渲染成为您想要快速索引的任何内容的绝佳选择
ssr.svg

实现

虚拟 DOM

html
<ul id="list">
  <li class="item">1</li>
  <li class="item">2</li>
  <li class="item">3</li>
</ul>
js
const tree = {
  tag: "ul", // 节点标签名
  props: {
    id: "list", // DOM 的属性,用一个对象存储键值对
  },
  children: [
    // 该节点的子节点
    { tag: "li", props: { class: "item" }, children: ["1"] },
    { tag: "li", props: { class: "item" }, children: ["2"] },
    { tag: "li", props: { class: "item" }, children: ["3"] },
  ],
};

虚拟 DOM 为组件的跨平台渲染提供了可能,在 Vue 中内置了 vue-server-renderer (Vue2) 和 vue/server-renderer (Vue3) 库,用于跨平台渲染

该库的功能简单来说就是将 Vue 组件变为字符串,并且通过模板引擎将数据注入到字符串中,最后返回一个完整的 HTML 页面

代码实现

1️⃣ 通用创建应用

在这里执行应用的创建逻辑,包括创建应用实例、注册插件、挂载路由、挂载状态管理器等操作,这里的代码在客户端和服务端都会执行

通用代码,在服务端和客户端共享 main.ts
typescript
// 通用代码,在服务器和客户端之间共享
import { createHead } from "@unhead/vue";
import { createSSRApp } from "vue";
import VueLazyLoad from "vue3-lazyload";

import App from "@/App.vue";
import AdsbyGoogle from "@/components/AdsbyGoogle/index.vue";
import { loadSvg } from "@/icons";
import { createRouter } from "@/router";
import { createStore } from "@/stores";

// SSR 每个请求都需要一个新的应用实例,因此我们导出一个函数来创建一个新的应用实例
// 如果使用状态管理器,我们也会在这里创建一个新的存储(store)
// 每次请求时调用
export async function createApp(type: "client" | "server") {
  const app = createSSRApp(App);

  // 集成 Pinia 状态管理器
  const store = createStore();
  app.use(store);

  // 集成 Vue Router
  const router = createRouter(type);
  app.use(router);

  // 集成 unhead
  const head = createHead();
  app.use(head);

  // 集成 VueLazyLoad
  app.use(VueLazyLoad, {});

  // 全局注册组件 SvgIcon
  loadSvg(app);

  // 全局注册组件 AdSense
  app.component("AdsbyGoogle", AdsbyGoogle);

  return { app, store, router, head };
}

2️⃣ 服务端渲染

在服务端渲染中,我们需要将 Vue 组件渲染为 HTML 字符串或数据流,然后将这些字符串发送到客户端。因此,我们需要在服务器端创建一个 Vue 应用实例,并将该实例渲染为 HTML 字符串或数据流。

服务端渲染代码 entry.server.ts
typescript
// 服务端入口文件
import { basename } from "node:path";

import { renderSSRHead } from "@unhead/ssr";
import type { Request } from "express";
import { renderToWebStream } from "vue/server-renderer";

import { DeviceEnum } from "@/configs/constants";
import { createApp } from "@/main";
import { useAppStore } from "@/stores/modules/app";
import webConfigs from "@/web-configs";

export async function render(url: string, host: string, ssrManifest: string, req: Request) {
  const manifest: Record<string, string[]> = ssrManifest && JSON.parse(ssrManifest); // 将字符串格式的 manifest 转换为对象
  // 根据请求的 host 获取对应的网站配置
  const webConfig = webConfigs[host] || webConfigs.localhost;

  const { app, store, router, head } = await createApp("server");

  // 根据请求头判断设备类型并存储状态
  const userAgent = req.headers["user-agent"] || "mobile";
  const isMobile = /mobile|android|webos|iphone|ipod|blackberry/i.test(userAgent);
  // NOTE 这里必须传入 store,否则会导致状态污染
  const appStore = useAppStore(store);

  // 服务端设置设备类型和网站配置,存储到 state 中
  appStore.toggleDevice(isMobile ? DeviceEnum.Mobile : DeviceEnum.Desktop);
  appStore.webConfig = webConfig;

  // 将状态序列化为 JSON 字符串
  const state = JSON.stringify(store.state.value);

  await router.push(url);
  await router.isReady();

  const headPayload = await renderSSRHead(head);

  const ctx: { modules?: string[] } = {};
  const stream = renderToWebStream(app, ctx);

  // Vite 生成的 SSR manifest 包含模块到 chunk/资源的映射,之后我们可以利用它来确定此请求需要预加载哪些文件
  const preloadLinks = renderPreloadLinks(ctx.modules, manifest);

  return { stream, preloadLinks, state, headPayload };
}

/** 服务端预加载文件 */
const renderPreloadLinks = (modules: undefined | string[], manifest: Record<string, string[]>) => {
  let links = "";
  const seen = new Set();
  if (modules === undefined || manifest === undefined) {
    return links;
  }
  modules.forEach((id) => {
    const files = manifest[id];
    if (files) {
      files.forEach((file) => {
        if (!seen.has(file)) {
          seen.add(file);
          const filename = basename(file);
          if (manifest[filename]) {
            for (const depFile of manifest[filename]) {
              links += renderPreloadLink(depFile);
              seen.add(depFile);
            }
          }
          links += renderPreloadLink(file);
        }
      });
    }
  });
  return links;
};

const renderPreloadLink = (file: string) => {
  if (file.endsWith(".js")) {
    return `<link rel="modulepreload" href="${file}">`;
  } else if (file.endsWith(".css")) {
    return `<link rel="stylesheet" href="${file}">`;
  } else if (file.endsWith(".woff")) {
    return `<link rel="preload" href="${file}" as="font" type="font/woff">`;
  } else if (file.endsWith(".woff2")) {
    return `<link rel="preload" href="${file}" as="font" type="font/woff2">`;
  } else if (file.endsWith(".gif")) {
    return `<link rel="preload" href="${file}" as="image" type="image/gif">`;
  } else if (file.endsWith(".jpg") || file.endsWith(".jpeg")) {
    return `<link rel="preload" href="${file}" as="image" type="image/jpeg">`;
  } else if (file.endsWith(".png")) {
    return `<link rel="preload" href="${file}" as="image" type="image/png">`;
  } else if (file.endsWith(".webp")) {
    return `<link rel="preload" href="${file}" as="image" type="image/webp">`;
  } else {
    return "";
  }
};

/** 生产环境下暴露获取 webConfigs 的函数 */
export function getWebConfigs() {
  return webConfigs;
}

3️⃣ 客户端水合

客户端接收到 HTML 之后,也需要执行一次 Vue 实例的初始化,然后拿到服务端传递过来的状态,将其注入到客户端的 Vue 实例中,然后执行客户端的代码,实现页面激活。

客户端激活代码 entry.client.ts
typescript
// 客户端入口文件
// 用于挂载应用实例,将服务端渲染的 HTML 转换为可交互的 DOM(Hydrate)
import "@/styles/main.scss";

import { createApp } from "@/main";

const initializeApp = async () => {
  const { app, store, router } = await createApp("client");

  // 将服务端渲染的初始状态注入到 store 中
  if (window.__INITIAL_STATE__) {
    const stateObj = JSON.parse(window.__INITIAL_STATE__);
    store.state.value = stateObj;
  }

  router.isReady().then(() => {
    app.mount("#app", true);
  });
};

initializeApp();

4️⃣ 服务器配置

服务器配置文件用于启动一个服务器,处理客户端请求,将客户端渲染的 HTML 返回给客户端。

服务器配置代码 server.js
js
// 入口文件,启动 SSR 服务器
import fs from "node:fs/promises"; // 导入文件系统模块,用于读取文件(使用 Promises)
import os from "node:os"; // 导入操作系统模块,用于获取环境变量

import express from "express"; // 导入 Express,用于创建 HTTP 服务器

// 常量
const isProduction = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5173;
const base = process.env.BASE || "/";

// 在生产环境中缓存静态文件(HTML 和 ssr-manifest)
const templateHtml = isProduction
  ? await fs.readFile("./dist/client/index.html", "utf-8") // 读取生产环境下的 HTML 模板
  : ""; // 开发环境下 HTML 模板不缓存,后续动态读取
const ssrManifest = isProduction
  ? await fs.readFile("./dist/client/.vite/ssr-manifest.json", "utf-8") // 读取生产环境下的 SSR Manifest 文件
  : undefined; // 开发环境不使用 SSR Manifest

// 创建 HTTP 服务器实例
const app = express();

// 根据环境加载相应的中间件
let vite;
if (!isProduction) {
  // 如果是开发环境,使用 Vite 的中间件
  const { createServer } = await import("vite"); // 动态导入 Vite
  vite = await createServer({
    server: { middlewareMode: true }, // 以中间件模式启动 Vite
    appType: "custom", // 自定义应用类型
    base, // 设置基础路径
  });
  app.use(vite.middlewares); // 使用 Vite 中间件
} else {
  // 如果是生产环境,使用生产中间件
  const compression = (await import("compression")).default; // 动态导入 compression 模块,用于压缩响应
  const sirv = (await import("sirv")).default; // 动态导入 sirv,用于提供静态文件服务
  app.use(compression()); // 启用 gzip 压缩
  app.use(base, sirv("./dist/client", { extensions: [] })); // 提供静态资源服务,服务路径为 './dist/client'
}

// 处理 ads.txt 请求
app.get("/ads.txt", async (req, res) => {
  try {
    const originHost = req.headers.host.split(":")[0] || "localhost";
    const host = originHost.replace(/^www\./, "");
    let content;
    if (!isProduction) {
      content = (await vite.ssrLoadModule("/src/web-configs.ts")).default[host].adSense.ads;
    } else {
      content = (await import("../dist/server/entry-server.js")).getWebConfigs()[host].adSense.ads;
    }
    res.type("text/plain").send(content);
  } catch (e) {
    console.log(e.stack); // 打印错误堆栈
    res.status(500).end(e.stack); // 返回 500 错误,并输出堆栈信息
  }
});

// 处理所有的 HTML 请求
app.use("*", async (req, res) => {
  try {
    const originHost = req.headers.host.split(":")[0] || "localhost";
    const host = originHost.replace(/^www\./, "");
    const url = req.originalUrl.replace(base, ""); // 获取请求的 URL,并去除基础路径

    let template;
    let render;
    if (!isProduction) {
      // 在开发环境下,动态读取 HTML 模板并处理
      template = await fs.readFile("./index.html", "utf-8"); // 读取开发环境的 HTML 模板
      template = await vite.transformIndexHtml(url, template); // 使用 Vite 处理模板
      render = (await vite.ssrLoadModule("/src/entry-server.ts")).render; // 动态加载 SSR 入口并获取 render 函数
    } else {
      // 在生产环境下,使用缓存的模板和 render 函数
      template = templateHtml; // 使用缓存的生产环境模板
      render = (await import("../dist/server/entry-server.js")).render; // 从已构建的服务器端模块中导入 render 函数
    }

    // 调用服务端的 render 函数,生成流式内容和 Pinia 状态
    const { stream, preloadLinks, state, headPayload } = await render(url, host, ssrManifest, req);

    const [htmlStart, htmlEnd] = template
      .replace("<!--preload-links-->", preloadLinks)
      .replace("<!--pinia-state-->", state)
      .replace("<!--headTags-->", headPayload.headTags)
      .split("<!--app-html-->"); // 将模板分割为头部和尾部

    res.status(200).set({ "Content-Type": "text/html" }); // 设置响应头,表示返回 HTML 内容

    // 流式发送 HTML 的头部内容
    res.write(htmlStart);
    // 通过流式发送组件渲染的 HTML 内容
    for await (const chunk of stream) {
      if (res.closed) break; // 如果连接已关闭,则停止写入
      res.write(chunk); // 写入每个渲染块
    }
    // 发送 HTML 的尾部内容
    res.write(htmlEnd);
    res.end(); // 完成响应
  } catch (e) {
    vite?.ssrFixStacktrace(e); // 在开发环境中,修复 Vite 的 SSR 堆栈跟踪
    console.log(e.stack); // 打印错误堆栈
    res.status(500).end(e.stack); // 返回 500 错误,并输出堆栈信息
  }
});

// 启动 HTTP 服务器并监听指定端口
app.listen(port, "0.0.0.0", () => {
  // 获取局域网 IP 地址
  const interfaces = os.networkInterfaces();
  let localIP = "localhost";

  for (let iface in interfaces) {
    for (let alias of interfaces[iface]) {
      if (alias.family === "IPv4" && !alias.internal && alias.address.startsWith("192.168")) {
        localIP = alias.address; // 获取非本地回环地址的 IPv4
        break;
      }
    }
  }
  console.log(`Server started at:`);
  console.log(`- Local:   http://localhost:${port}`);
  console.log(`- Network: http://${localIP}:${port}`); // 打印局域网内可访问的 IP 地址
});