Vue SSR是一種在服務端將 Vue 應用渲染成 HTML 字符串,然后直接發送到客戶端的技術。相比傳統的客戶端渲染,Vue SSR 能帶來更好的 SEO 性能和更快的首屏加載時間。下面我們從零到一,結合項目源碼,詳細講解如何實現一個 Vue SSR 項目。
1. 項目結構
以下以一個基本的Demo來說明服務端渲染的實現,下圖是項目的基本結構:
2.?安裝項目依賴
以下是package.json中的配置:
{"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.?配置腳手架
// 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代碼:
// 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; // 獲取請求URLtry {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狀態// 返回最終HTMLres.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文件的代碼:
// 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文件代碼:
// /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代碼:
<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文件代碼:
// 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文件代碼:
<!--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文件代碼:
<!--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的代碼:
// src/store/counter.jsimport { 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文件代碼:
<!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文件代碼:
// /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. 效果預覽
觀察控制臺返回的結果,可以清楚的看到文件不再只是一個空殼文件,而是帶有樣式的頁面,在瀏覽器上點擊按鈕數字均有變化,說明事件和狀態已經被客戶端激活了。