Vue 服务端渲染(SSR)详解

Vue SSR是一种在服务端将 Vue 应用渲染成 HTML 字符串,然后直接发送到客户端的技术。相比传统的客户端渲染,Vue SSR 能带来更好的 SEO 性能和更快的首屏加载时间。下面我们从零到一,结合项目源码,详细讲解如何实现一个 Vue SSR 项目。

1. 项目结构

以下以一个基本的Demo来说明服务端渲染的实现,下图是项目的基本结构:

2. 安装项目依赖

以下是package.json中的配置:

javascript 复制代码
{
    "name": "vue-ssr-example",
    "version": "1.0.0",
    "scripts": {
        "dev": "node server",
        "dev:client": "vite",
        "dev:server": "node server",
        "dev:both": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
        "build": "npm run build:client && npm run build:server",
        "build:client": "vite build --ssrManifest --outDir dist/client",
        "build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
        "serve": "cross-env NODE_ENV=production node server"
    },
    "dependencies": {
        "vue": "^3.5.6",
        "vue-router": "^4.0.0",
        "pinia": "^2.0.0",
        "express": "^4.17.1"
    },
    "devDependencies": {
        "@vitejs/plugin-vue": "^4.0.0",
        "vite": "^4.0.0",
        "cross-env": "^7.0.3",
        "concurrently": "^6.2.0"
    }
}

3. 配置脚手架

javascript 复制代码
// vite.config.js
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
    plugins: [vue()],
    build: {
        minify: false,
    },
});

4. 服务端渲染流程

4.1. 请求阶段

Node服务器接收请求,以下是server代码:

javascript 复制代码
// server/index.js

// 引入必要的模块
const fs = require("fs");
const path = require("path");
const express = require("express");
const { createServer: createViteServer } = require("vite"); // 重命名Vite的createServer方法

// 创建SSR服务器的主函数,接受生产环境标志参数
async function createServer(isProd = process.env.NODE_ENV === "production") {
    // 创建Express实例
    const app = express(); 
    let vite;
    // 开发环境配置
    if (!isProd) {
        // 创建Vite开发服务器
        vite = await createViteServer({
            server: { middlewareMode: true }, // 中间件模式
            appType: "custom" // 自定义应用类型(避免Vite的默认SPA处理)
        });
        app.use(vite.middlewares); // 使用Vite中间件处理请求
    } else {
        // 生产环境直接使用构建好的静态文件
        app.use(express.static(path.resolve(__dirname, "../dist/client")));
    }

    // 处理所有路由的中间件
    app.use("*", async (req, res) => {
        const url = req.originalUrl; // 获取请求URL

        try {
            let template, render;
            // 开发环境处理
            if (!isProd) {
                // 读取HTML模板文件
                template = fs.readFileSync(
                    path.resolve(__dirname, "../index.html"),
                    "utf-8"
                );
                // 使用Vite转换HTML模板(包含HMR支持)
                template = await vite.transformIndexHtml(url, template);
                // 加载服务端入口模块
                render = (await vite.ssrLoadModule("/src/entry-server.js")).render;
            } else {
                // 生产环境处理
                template = fs.readFileSync(
                    path.resolve(__dirname, "../dist/client/index.html"),
                    "utf-8"
                );
                // 直接加载构建后的服务端入口
                render = require("../dist/server/entry-server.js").render;
            }

            // 调用渲染函数获取SSR结果
            const [appHtml, preloadLinks, initialState] = await render(url);

            // 替换模板中的占位符
            const html = template
                .replace(`<!--app-html-->`, appHtml) // 插入应用HTML
                .replace(`"<!--pinia-state-->"`, JSON.stringify(initialState)); // 序列化Pinia状态
            // 返回最终HTML
            res.status(200).set({ "Content-Type": "text/html" }).end(html);
        } catch (e) {
            // 开发环境下修正错误堆栈跟踪
            if (!isProd) {
                vite.ssrFixStacktrace(e);
            }
            res.status(500).end(e.message);
        }
    });

    // 启动服务器
    const port = process.env.PORT || 3000;
    app.listen(port, () => {
        console.log(`Server is running on http://localhost:${port}`);
    });
}

// 启动服务器
createServer();

4.2. 应用初始化

创建Vue实例,以下是entry-server.js文件的代码:

javascript 复制代码
// src/entry-server.js
// 从主模块导入应用创建函数
import { createApp } from "./main";
// 导入Vue服务端渲染工具
import { renderToString } from "vue/server-renderer";

// 服务端渲染函数,接收请求URL作为参数
export async function render(url) {
    // 创建Vue应用实例(包含应用、路由和状态管理)
    const { app, router, pinia } = createApp();

    // 设置当前路由位置
    await router.push(url);
    // 等待路由导航完成
    await router.isReady();

    // 创建SSR上下文对象(用于收集渲染过程中的资源信息)
    const context = {};
    // 将Vue应用渲染为HTML字符串
    const appHtml = await renderToString(app, context);

    // 序列化Pinia状态(用于客户端hydration)
    const initialState = JSON.stringify(pinia.state.value);

    // 返回渲染结果数组:
    // [0] 应用HTML字符串
    // [1] 预加载模块信息(用于资源预加载)
    // [2] 初始状态数据
    return [appHtml, context.modules, initialState];
	
}

以下是上面代码中引入的main.js文件代码:

javascript 复制代码
// /src/main.js
// 导入SSR专用Vue应用创建方法和核心模块
import { createSSRApp } from "vue"; // 服务端渲染专用应用创建方法
import { createRouter } from "./router"; // 自定义路由配置
import { createPinia } from "pinia"; // 状态管理库
import App from "./App.vue"; // 根组件

// 应用工厂函数(SSR核心要求)
export function createApp() {
    // 创建SSR应用实例(与客户端createApp的区别在于SSR优化)
    const app = createSSRApp(App);

    // 初始化路由系统
    const router = createRouter();
    // 创建Pinia状态管理实例
    const pinia = createPinia();

    // 注册路由插件(使this.$router可用)
    app.use(router);
    // 注册状态管理(使this.$pinia可用)
    app.use(pinia);

    // 返回应用核心三件套,供entry-server和entry-client使用:
    // app: Vue应用实例
    // router: 路由实例(处理服务端/客户端路由同步)
    // pinia: 状态管理实例(保证服务端/客户端状态一致)
    return { app, router, pinia };
}

以下是根组件App.vue代码:

html 复制代码
<template>
    <div>
        <nav>
            <router-link to="/">Home</router-link> |
            <router-link to="/about">About</router-link>
        </nav>
        <router-view></router-view>
    </div>
</template>

<script>
export default {
    name: "App",
};
</script>

4.3. 路由解析

通过router.js匹配对应组件文件,以下是router.js文件代码:

javascript 复制代码
// src/route.js
import {
    createRouter as _createRouter,
    createMemoryHistory,
    createWebHistory,
} from "vue-router";
import Home from "./pages/Home.vue";
import About from "./pages/About.vue";

const routes = [
    { path: "/", component: Home },
    { path: "/about", component: About },
];

export function createRouter() {
    return _createRouter({
        history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
        routes,
    });
}

4.4. 数据预取

数据预取通常是通过执行组件asyncData方法获取数据注入到组件文件里,本例中为了方便演示已省略。

以下是About.vue文件代码:

html 复制代码
<!--src/pages/About.vue-->
<template>
    <div>
        <h1>About</h1>
        <p>This is the about page.</p>
    </div>
</template>

<script>
export default {
    name: "About",
};
</script>

以下是Home.vue文件代码:

html 复制代码
<!--src/pages/Home.vue-->
<template>
    <div>
        <h1>Home</h1>
        <p>Count: {{ count }}</p>
        <button @click="increment">Increment</button>
    </div>
</template>

<script setup>
import { useCounterStore } from "../store";
import { storeToRefs } from "pinia";

const store = useCounterStore();
const { count } = storeToRefs(store);
const { increment } = store;

</script>

4.5. 状态同步

准备初始状态,以下是store的代码:

javascript 复制代码
// src/store/counter.js

import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", {
    state: () => ({
        count: 10,
    }),
    actions: {
        increment() {
            this.count++;
        },
    },
});

4.6. HTML生成

Vue SSR 将组件树递归渲染为 HTML 字符串,包含初始状态和激活标记,用于服务端返回完整页面结构。

4.7. 响应返回

将响应的结果注入状态到模板中,以下是index.html文件代码:

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue 3 SSR Example</title>
</head>

<body>
    <div id="app"><!--app-html--></div>
    <script>
        window.__INITIAL_STATE__ = "<!--pinia-state-->";
    </script>
    <script type="module" src="/src/entry-client.js"></script>
</body>

</html>

4.8. 客户端激活

客户端激活页面交互,以下是entry-client.js文件代码:

javascript 复制代码
// /src/entry-client.js

// 导入应用创建函数和状态管理库
import { createApp } from "./main";
import { createPinia } from "pinia";

// 创建Vue应用实例(包含应用、路由和状态管理)
const { app, router, pinia } = createApp();

// 服务端渲染注入的初始状态处理
// 从全局变量获取服务端序列化的状态数据
if (window.__INITIAL_STATE__) {
    try {
        // 将JSON字符串还原为Pinia状态对象
        pinia.state.value = JSON.parse(window.__INITIAL_STATE__);
    } catch (e) {
        // 解析失败时输出错误信息(开发环境调试用)
        console.error("Failed to parse initial state:", e);
    }
}

// 等待路由导航准备就绪后挂载应用
// 确保异步路由组件解析完成后再执行挂载
router.isReady().then(() => {
    // 将Vue实例挂载到ID为app的DOM节点
    // 客户端hydration的入口点
    app.mount("#app");
});

5. 效果预览

观察控制台返回的结果,可以清楚的看到文件不再只是一个空壳文件,而是带有样式的页面,在浏览器上点击按钮数字均有变化,说明事件和状态已经被客户端激活了。