教你使用 koa2 + vite + ts + vue3 + pinia 構建前端 SSR 企業級專案
回覆 交流 ,加入前端程式設計面試演算法每日一題群
面試官也在看的前端面試資料
前言
大家好,我是 易 [1] , 今天給大家帶來爆肝許久的 如何使用vite 打造前端 SSR 企業級專案
,希望大家能喜歡!
如果大家對 Vite 感興趣可以去看看專欄: 《Vite 從入門到精通》 [3]
瞭解 SSR
什麼是 SSR
伺服器端渲染
(Server-Side Rendering)是指由服務端完成頁面的 HTML 結構拼接的頁面處理技術,傳送到瀏覽器,然後為其繫結狀態與事件,成為完全可互動頁面的過程。
簡單理解就是html是由服務端寫出,可以動態改變頁面內容,即所謂的動態頁面。早年的 php [4] 、 asp [5] 、 jsp [6] 這些 Server page 都是 SSR 的。
為什麼使用 SSR
-
網頁內容在伺服器端渲染完成,一次性傳輸到瀏覽器,所以
首屏載入速度非常快
; -
有利於SEO
,因為伺服器返回的是一個完整的 html,在瀏覽器可以看到完整的 dom,對於爬蟲、百度搜索等引擎就比較友好;
快速檢視
github 倉庫地址 [7]
長話短說,直接開幹 ~
建議包管理器使用優先順序:pnpm > yarn > npm > cnpm
一、初始化專案
pnpm create vite koa2-ssr-vue3-ts-pinia -- --template vue-ts 複製程式碼
整合基本配置
由於本文的重點在於 SSR 配置
,為了優化讀者的觀感體驗,所以專案的 基本配置
就不做詳細介紹,在我上一篇文章 《手把手教你用 vite+vue3+ts+pinia+vueuse 打造企業級前端專案》 [8] 中已詳細介紹,大家可以自行查閱
-
修改
tsconfig.json
: 檢視程式碼 [9] -
修改
vite.config.ts
: 檢視程式碼 [10] -
整合
eslint
和prettier
統一程式碼質量風格的: 檢視教程 [11] -
整合
commitizen
和husky
規範 git 提交: 檢視教程 [12]
到這裡我們專案的基本框架都搭建完成啦~
二、修改客戶端入口
-
修改 `~/src/main.ts`
import { createSSRApp } from "vue"; import App from "./App.vue"; // 為了保證資料的互不干擾,每次請求需要匯出一個新的例項 export const createApp = () => { const app = createSSRApp(App); return { app }; } 複製程式碼
-
新建 `~/src/entry-client.ts`
import { createApp } from "./main" const { app } = createApp(); app.mount("#app"); 複製程式碼
-
修改 `~/index.html` 的入口
<!DOCTYPE html> <html lang="en"> ... <script type="module" src="/src/entry-client.ts"></script> ... </html> 複製程式碼
到這裡你執行 pnpm run dev
,發現頁面中還是可以正常顯示,因為到目前只是做了一個檔案的拆分,以及更換了 createSSRApp
方法;
三、建立開發伺服器
使用 Koa2
-
安裝 `koa2`
pnpm i koa --save && pnpm i @types/koa --save-dev 複製程式碼
-
安裝中介軟體 `koa-connect`
pnpm i koa-connect --save 複製程式碼
-
使用:新建
~/server.js
備註:因為該檔案為 node 執行入口,所以用 js 即可,如果用 ts 檔案,需單獨使用 ts-node 等去執行,導致程式變複雜
const Koa = require('koa'); (async () => { const app = new Koa(); app.use(async (ctx) => { ctx.body = `<!DOCTYPE html> <html lang="en"> <head><title>koa2 + vite + ts + vue3 + vue-router</title></head> <body> <h1 style="text-align: center;">使用 koa2 + vite + ts + vue3 + vue-router 整合前端 SSR 企業級專案</h1> </body> </html>`; }); app.listen(9000, () => { console.log('server is listening in 9000'); }); })(); 複製程式碼
-
執行
node server.js
-
結果:

渲染替換成專案根目錄下的 index.html
-
修改 `server.js` 中的 `ctx.body` 返回的是 `index.html`
const fs = require('fs'); const path = require('path'); const Koa = require('koa'); (async () => { const app = new Koa(); // 獲取 index.html const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8'); app.use(async (ctx) => { ctx.body = template; }); app.listen(9000, () => { console.log('server is listening in 9000'); }); })(); 複製程式碼
-
執行 `node server.js`後, 我們就會看到返回的是空白內容的 `index.html` 了,但是我們需要返回的是 `vue 模板` ,那麼我們只需要做個 `正則的替換`
-
給 `index.html` 新增 `<!--app-html-->` 標記
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>koa2 + vite + ts + vue3</title> </head> <body> <div id="app"><!--app-html--></div> <script type="module" src="/src/entry-client.ts"></script> </body> </html> 複製程式碼
-
修改 `server.js` 中的 `ctx.body`
// other code ... (async () => { const app = new Koa(); // 獲取index.html const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8'); app.use(async (ctx) => { let vueTemplate = '<h1 style="text-align:center;">現在假裝這是一個vue模板</h1>'; // 替換 index.html 中的 <!--app-html--> 標記 let html = template.replace('<!--app-html-->', vueTemplate); ctx.body = html; }); app.listen(9000, () => { console.log('server is listening in 9000'); }); })(); 複製程式碼
-
執行
node server.js
後,我們就會看到返回的變數 vueTemplate
內容
那麼到現在服務已正常啟動了,但是我們試想一下,我們頁面模板使用的是 vue,並且 vue 返回的是一個 vue 例項模板
,所以我就要把這個 vue 例項模板
轉換成 可渲染的 html
,那麼 @vue/server-renderer
就應運而生了
四、新增服務端入口
因為 vue 返回的是 vue 例項模板
而不是 可渲染的 html
,所以我們需要使用 @vue/server-renderer
進行轉換
-
安裝 `@vue/server-renderer`
pnpm i @vue/server-renderer --save 複製程式碼
-
新建 `~/src/entry-server.ts`
import { createApp } from './main'; import { renderToString } from '@vue/server-renderer'; export const render = async () => { const { app } = createApp(); // 注入vue ssr中的上下文物件 const renderCtx: {modules?: string[]} = {} let renderedHtml = await renderToString(app, renderCtx) return { renderedHtml }; } 複製程式碼
那麼如何去使用 entry-server.ts
呢,到這裡就需要 vite
了
五、注入 vite
-
修改 `~/server.js`
const fs = require('fs') const path = require('path') const Koa = require('koa') const koaConnect = require('koa-connect') const vite = require('vite') ;(async () => { const app = new Koa(); // 建立 vite 服務 const viteServer = await vite.createServer({ root: process.cwd(), logLevel: 'error', server: { middlewareMode: true, }, }) // 註冊 vite 的 Connect 例項作為中介軟體(注意:vite.middlewares 是一個 Connect 例項) app.use(koaConnect(viteServer.middlewares)) app.use(async ctx => { try { // 1. 獲取index.html let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8'); // 2. 應用 Vite HTML 轉換。這將會注入 Vite HMR 客戶端, template = await viteServer.transformIndexHtml(ctx.path, template) // 3. 載入伺服器入口, vite.ssrLoadModule 將自動轉換 const { render } = await viteServer.ssrLoadModule('/src/entry-server.ts') // 4. 渲染應用的 HTML const { renderedHtml } = await render(ctx, {}) const html = template.replace('<!--app-html-->', renderedHtml) ctx.type = 'text/html' ctx.body = html } catch (e) { viteServer && viteServer.ssrFixStacktrace(e) console.log(e.stack) ctx.throw(500, e.stack) } }) app.listen(9000, () => { console.log('server is listening in 9000'); }); })() 複製程式碼
-
執行
node server.js
就可以看到返回的 App.vue 模板中的內容了,如下圖

-
並且我們 `右鍵檢視顯示網頁原始碼`,也會看到渲染的正常 html
<!DOCTYPE html> <html lang="en"> <head> <script type="module" src="/@vite/client"></script> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>koa2 + vite + ts + vue3</title> </head> <body> <div id="app"><!--[--><img alt="Vue logo" src="/src/assets/logo.png"><!--[--><h1 data-v-469af010>Hello Vue 3 + TypeScript + Vite</h1><p data-v-469af010> Recommended IDE setup: <a href="<https://code.visualstudio.com/>" target="_blank" data-v-469af010>VSCode</a> + <a href="<https://github.com/johnsoncodehk/volar>" target="_blank" data-v-469af010>Volar</a></p><p data-v-469af010>See <code data-v-469af010>README.md</code> for more information.</p><p data-v-469af010><a href="<https://vitejs.dev/guide/features.html>" target="_blank" data-v-469af010> Vite Docs </a> | <a href="<https://v3.vuejs.org/>" target="_blank" data-v-469af010>Vue 3 Docs</a></p><button type="button" data-v-469af010>count is: 0</button><p data-v-469af010> Edit <code data-v-469af010>components/HelloWorld.vue</code> to test hot module replacement. </p><!--]--><!--]--></div> <script type="module" src="/src/entry-client.ts"></script> </body> </html> 複製程式碼
到這裡我們就已經在 開發環境
已經正常的渲染了,但我們想一下,在 生產環境
我們應該怎麼做呢,因為咱們不可能直接在 生產環境
執行使用 vite
吧!
所以咱們接下來處理如何在 生產環境
執行吧
六、新增開發環境
為了將 SSR 專案可以在生產環境執行,我們需要:
-
正常構建生成一個 `客戶端構建包`;
-
再生成一個 SSR 構建,使其通過 `require()` 直接載入,這樣便無需再使用 Vite 的 `ssrLoadModule`;
-
修改 `package.json`
... { "scripts": { // 開發環境 "dev": "node server-dev.js", // 生產環境 "server": "node server-prod.js", // 構建 "build": "pnpm build:client && pnpm build:server", "build:client": "vite build --outDir dist/client", "build:server": "vite build --ssr src/entry-server.js --outDir dist/server", }, } ... 複製程式碼
-
修改
server.js
為server-dev.js
-
執行
pnpm run build
構建包 -
新增
server-prod.js
注意:為了處理靜態資源,需要在此新增 koa-send
中介軟體: pnpm i koa-send \--save
const Koa = require('koa'); const sendFile = require('koa-send'); const path = require('path'); const fs = require('fs'); const resolve = (p) => path.resolve(__dirname, p); const clientRoot = resolve('dist/client'); const template = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8'); const render = require('./dist/server/entry-server.js').render; const manifest = require('./dist/client/ssr-manifest.json'); (async () => { const app = new Koa(); app.use(async (ctx) => { // 請求的是靜態資源 if (ctx.path.startsWith('/assets')) { await sendFile(ctx, ctx.path, { root: clientRoot }); return; } const [ appHtml ] = await render(ctx, manifest); const html = template .replace('<!--app-html-->', appHtml); ctx.type = 'text/html'; ctx.body = html; }); app.listen(8080, () => console.log('started server on http://localhost:8080')); })(); 複製程式碼
到這裡,我們在 開發環境
和 生成環境
已經都可以正常訪問了,那麼是不是就萬事無憂了呢?
為了使用者的更極致的使用者體驗,那麼 預載入
就必須要安排了
七、預載入
我們知道 vue 元件
在 html 中渲染時都是動態去生成的對應的 js
和 css
等;
那麼我們要是在使用者獲取 服務端模板
(也就是執行 vite build
後生成的 dist/client
目錄) 的時候,直接在 html 中把對應的 js
和 css
檔案預渲染了,這就是 靜態站點生成(SSG)
的形式。
閒話少說,明白道理了之後,直接開幹 ~
-
生成預載入指令`:在 package.json 中的 `build:client` 新增 `--ssrManifest` 標誌,執行後生成 `ssr-manifest.json`
... { "scripts": { ... "build:client": "vite build --ssrManifest --outDir dist/client", ... }, } ... 複製程式碼
-
在 `entry-sercer.ts` 中新增解析生成的 `ssr-manifest.json` 方法
export const render = async ( ctx: ParameterizedContext, manifest: Record<string, string[]> ): Promise<[string, string]> => { const { app } = createApp(); console.log(ctx, manifest, ''); const renderCtx: { modules?: string[] } = {}; const renderedHtml = await renderToString(app, renderCtx); const preloadLinks = renderPreloadLinks(renderCtx.modules, manifest); return [renderedHtml, preloadLinks]; }; /** * 解析需要預載入的連結 * @param modules * @param manifest * @returns string */ function renderPreloadLinks( modules: undefined | string[], manifest: Record<string, string[]> ): string { let links = ''; const seen = new Set(); if (modules === undefined) throw new Error(); modules.forEach((id) => { const files = manifest[id]; if (files) { files.forEach((file) => { if (!seen.has(file)) { seen.add(file); links += renderPreloadLink(file); } }); } }); return links; } /** * 預載入的對應的地址 * 下面的方法只針對了 js 和 css,如果需要處理其它檔案,自行新增即可 * @param file * @returns string */ function renderPreloadLink(file: string): string { if (file.endsWith('.js')) { return `<link rel="modulepreload" crossorigin href="${file}">`; } else if (file.endsWith('.css')) { return `<link rel="stylesheet" href="${file}">`; } else { return ''; } } 複製程式碼
-
給 `index.html` 新增 `<!--preload-links-->` 標記
-
改造 `server-prod.js`
... (async () => { const app = new Koa(); app.use(async (ctx) => { ... const [appHtml, preloadLinks] = await render(ctx, manifest); const html = template .replace('<!--preload-links-->', preloadLinks) .replace('<!--app-html-->', appHtml); // do something }); app.listen(8080, () => console.log('started server on http://localhost:8080')); })(); 複製程式碼
-
執行
pnpm run build && pnpm run serve
就可正常顯示了
到這裡基本的渲染就完成了,因為我們是需要在瀏覽器上渲染的,所以 路由 vue-router
就必不可少了
八、整合 vue-router
-
安裝 vue-router
pnpm i vue-router --save 複製程式碼
-
新增對應的路由頁面 `index.vue` 、 `login.vue` 、 `user.vue`
-
新增 `src/router/index.ts`
import { createRouter as createVueRouter, createMemoryHistory, createWebHistory, Router } from 'vue-router'; export const createRouter = (type: 'client' | 'server'): Router => createVueRouter({ history: type === 'client' ? createWebHistory() : createMemoryHistory(), routes: [ { path: '/', name: 'index', meta: { title: '首頁', keepAlive: true, requireAuth: true }, component: () => import('@/pages/index.vue') }, { path: '/login', name: 'login', meta: { title: '登入', keepAlive: true, requireAuth: false }, component: () => import('@/pages/login.vue') }, { path: '/user', name: 'user', meta: { title: '使用者中心', keepAlive: true, requireAuth: true }, component: () => import('@/pages/user.vue') } ] }); 複製程式碼
-
修改入口檔案 `src/enter-client.ts`
import { createApp } from './main'; import { createRouter } from './router'; const router = createRouter('client'); const { app } = createApp(); app.use(router); router.isReady().then(() => { app.mount('#app', true); }); 複製程式碼
-
修改入口檔案 `src/enter-server.ts`
... import { createRouter } from './router' const router = createRouter('client'); export const render = async ( ctx: ParameterizedContext, manifest: Record<string, string[]> ): Promise<[string, string]> => { const { app } = createApp(); // 路由註冊 const router = createRouter('server'); app.use(router); await router.push(ctx.path); await router.isReady(); ... }; ... 複製程式碼
-
執行
pnpm run build && pnpm run serve
就可正常顯示了
九、整合 pinia
-
安裝
pnpm i pinia --save 複製程式碼
-
新建 `src/store/user.ts`
import { defineStore } from 'pinia'; export default defineStore('user', { state: () => { return { name: '張三', age: 20 }; }, actions: { updateName(name: string) { this.name = name; }, updateAge(age: number) { this.age = age; } } }); 複製程式碼
-
新建 `src/store/index.ts`
import { createPinia } from 'pinia'; import useUserStore from './user'; export default () => { const pinia = createPinia(); useUserStore(pinia); return pinia; }; 複製程式碼
-
新建 `UsePinia.vue` 使用,並且在 `pages/index.vue` 中引入
<template> <h2>歡迎使用vite+vue3+ts+pinia+vue-router4</h2> <div>{{ userStore.name }}的年齡: {{ userStore.age }}</div ><br /> <button @click="addAge">點選給{{ userStore.name }}的年齡增加一歲</button> <br /> </template> <script lang="ts"> import { defineComponent } from 'vue'; import useUserStore from '@/store/user'; export default defineComponent({ name: 'UsePinia', setup() { const userStore = useUserStore(); const addAge = () => { userStore.updateAge(++userStore.age); }; return { userStore, addAge }; } }); </script> 複製程式碼
-
注入 `pinia` :修改 `src/entry-client.ts`
... import createStore from '@/store'; const pinia = createStore(); const { app } = createApp(); app.use(router); app.use(pinia); // 初始化 pini // 注意:__INITIAL_STATE__需要在 src/types/shims-global.d.ts中定義 if (window.__INITIAL_STATE__) { pinia.state.value = JSON.parse(window.__INITIAL_STATE__); } ... 複製程式碼
-
修改 `src/entry-server.ts`
... import createStore from '@/store'; export const render = () => { ... // pinia const pinia = createStore(); app.use(pinia); const state = JSON.stringify(pinia.state.value); ... return [renderedHtml, state, preloadLinks]; } ... 複製程式碼
-
修改 `server-dev.js` 和 `server-prod.js`
... const [renderedHtml, state, preloadLinks] = await render(ctx, {}); const html = template .replace('<!--app-html-->', renderedHtml) .replace('<!--pinia-state-->', state); // server-prod.js .replace('<!--preload-links-->', preloadLinks) ... 複製程式碼
-
給 `index.html` 新增 `<!--pinia-state-->` 標記
<script> window.__INITIAL_STATE__ = '<!--pinia-state-->'; </script> 複製程式碼
-
執行
pnpm run dev
就可正常顯示了
備註: 整合 pinia
這塊由於注入較為 複雜且方法不一
,暫時不做詳細講解,如果大家有需要,後面會出詳細解析!
十、其它
-
vueuse
的整合:可參考 《手把手教你用 vite+vue3+ts+pinia+vueuse 打造大廠企業級前端專案》 [13] -
CSS 整合
: 參考如上 [14] -
原生 css variable 新特性 scss less
-
CSS 的 UI 庫
: 參考同上 [15] -
需要注意的是
按需引入
-
壓測 併發 負載均衡
-
負載均衡 pm2 docker
專案模板地址
傳送門 [16]
最後
友情提示:目前 Vite 的 SSR 支援還處於試驗階段,可能會遇到一些未知 bug ,所以在公司的生產環境請謹慎使用,個人專案中可以濫用喲 ~
該系列會是一個持續更新系列,關於整個 《Vite 從入門到精通》專欄 [17] ,我主要會從如下圖幾個方面講解,請大家拭目以待吧!!!

靚仔靚女們
,都看到這裡了,要不點個贊再走唄 :rose::rose::rose:
關於本文
作者:易師傅
https://juejin.cn/post/7086467466703929358
最後
歡迎關注「 三分鐘學前端 」
號內回覆:
「 網路 」,自動獲取三分鐘學前端網路篇小書(90+頁)
「 JS 」,自動獲取三分鐘學前端 JS 篇小書(120+頁)
「 演算法 」,自動獲取 github 2.9k+ 的前端演算法小書
「 面試 」,自動獲取 github 23.2k+ 的前端面試小書
「 簡歷 」,自動獲取程式設計師系列的 120
套模版
》》面試官也在看的前端面試資料《《
“在看和轉發” 就是最大的
- Vue3生命週期Hooks的原理及其與排程器(Scheduler)的關係
- 10 個不錯的 CSS 小技巧
- 教你使用 koa2 vite ts vue3 pinia 構建前端 SSR 企業級專案
- 別捲了,快來玩 | React Three.js 實現一個超好玩的3D遊戲:美女與龍珠
- 手動實現Vue3 & 原理解析:setup環境 & reactive函式 & effect函式(一)
- 前端程式碼的三種設計模式
- 覺得自己的頁面不夠花哨嗎,試試clip-path吧
- 簡易版 useState 實現
- 回溯演算法彙總一
- CSS 的 Filter屬性竟然如此好玩
- 輕輕鬆鬆拿下 JS 淺拷貝、深拷貝
- 2022 前端應該掌握的 10 個 JS 小技巧
- 一文搞懂 Vue3.0 為什麼採用 Proxy
- 位元組飛書面試——請實現 Promise.all
- 我把 Vue3 專案中的 Vuex 去除了,改用 Pinia
- 從0到1400star,從阮一峰週刊到尤雨溪推薦,小透明開源專案的2021年總結
- type 和 interface的區別知多少?
- 當webpack有了vite的速度你會喜歡嗎?
- 前端面試百問(含解答)
- 很多人上來就刪除的package-lock.json,還有這麼多你不知道的(深度內容)