前段時間剛寫完畢業論文,現在一上來就是“基于”,哈哈。🤯 這篇文章持續更新,涉及到的技術棧是Koa、Vue和Vite (用React手搓服務端渲染好麻煩)。但是現在能上生產的服務端渲染估計是Next(配合React)和Nuxt(配合Vue)用的比較多。關于Next框架的學習見煮啵的另一篇文章,也將持續更新。
目錄
1?? 最基本的服務端渲染
2?? Koa配合Vue
3?? Koa配合Vue和Vite
最基本的服務端渲染
懶得BB,直接上代碼:
// koa-pro/demos/basic_ssr.js
export default async (ctx) => {ctx.type = 'html';ctx.body = `<!DOCTYPE html><html><head><title>Hello</title></head><body><h1>Hello World</h1></body></html>`;
}// koa-pro/index.js
import Koa from 'koa';
import Router from '@koa/router';
import basicSSR from './demos/basic_ssr.js'const koa = new Koa();
const router = new Router();router.get('/basic_ssr', basicSSR)
koa.use(router.routes());
koa.listen(3001, () => console.log('Server is running on port 3001'))
這串代碼我們平時根本不屑一顧,但他卻實現了最基本的SSR。因為它在Koa服務端處理了一個 HTTP 請求,并返回了一段完整的 HTML 內容。
Koa配合Vue
Koa配合Vue來實現SSR,一開始煮啵是跟著Vue3官方文檔的教程走的,但是它提供的Demo上有大坑 (也可能不是坑,是因為煮啵技術不行),煮啵只介紹自己實現的過程。首先我們將創建Vue應用的邏輯封裝在自定義的createApp
函數中:
// koa-ssr/scripts/vue_ssr/app.js
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
export default async function createApp() {let createSSRApp; // Vue提供的創建SSR應用的APIif (isBrowser) {createSSRApp = (await import("https://unpkg.com/vue@3.5.13/dist/vue.esm-browser.js")).createSSRApp;} else {createSSRApp = (await import('vue')).createSSRApp;}// 創建我們自己的應用return createSSRApp({data: () => ({ count: 1 }),template: `<button @click="count++">{{ count }}</button>`,});
}
createApp
這個函數的封裝在Vue3官方文檔里也有,但最大的區別就是官方文檔里是直接用import createSSRApp from ‘vue’
,而此處卻需要根據當前JS代碼所處的運行環境(Node或瀏覽器)來動態的引入createSSRApp
這個玩意。為什么這樣做見下文分析。
其次我們需要處理HTML模版,并用Koa的路由來掛載:
// koa-ssr/demos/vue_ssr.js
import { renderToString } from '@vue/server-renderer';
import createApp from '../scripts/vue_ssr/app.js';
export default async (ctx) => {const app = await createApp()const html = await renderToString(app);ctx.type = 'html';ctx.body = `<!DOCTYPE html><html><head><title>Vue SSR</title></head><body><div id="app">${html}</div><script type="module" src="/vue_ssr/client.js"></script></body></html>`
}// koa-ssr/index.js
// ...已有內容省略,見上一處代碼塊。在已有的基礎上添加下面這些代碼??:
import vueSSR from './demos/vue_ssr.js'
import koaStatic from 'koa-static';
import path from "path";
import { fileURLToPath } from "url";const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);router.get('/vue_ssr', vueSSR)
koa.use(koaStatic(path.join(dirname, 'scripts')));
此處,在模版中用script
標簽來加載腳本文件是必要的,否則SSR只返回了“空殼”,而無法提供任何交互。此外,用script
標簽來載腳本文件必須對外暴露(此處用的是koa-static)來實現。
為什么必須對外暴露?因為瀏覽器在拿到Koa返回的HTML后,會請求script
的腳本文件,在這個Demo中瀏覽器發起的資源請求的URL即為:http://localhost:3001/scripts/vue_ssr/client.js
。問題來了,如果沒有用koa-static對外暴露,Koa便沒有處理這個請求的邏輯,會返回404。
最后我們需要實現這個client.js
腳本文件,客戶端就是靠這個腳本來“接管”服務端返回的HTML。這個文件只在客戶端執行:
// koa-ssr/scripts/vue_ssr/client.js
import createApp from './app.js';
(async function activate() {const app = await createApp();app.mount('#app');
})()
此處最重要的便是app.mount(“#app”)
這句代碼,它的作用是在客戶端激活Vue。
接著我們回答在封裝createApp
的時候遺留的問題:為什么需要根據JS代碼的運行環境來動態導入createSSRApp
?因為koa-ssr/scripts/vue_ssr/app.js
這個文件是需要執行兩次的,一次在客戶端,一次在服務端。
在服務端執行的時候(即在koa-router收到http://127.0.0.1:3001/vue_ssr
請求的時候),它負責將Vue組件渲染成HTML內容,然后發送到客戶端。
在客戶端執行的時候,此時瀏覽器會加載client.js
文件,client.js
又impoet
了createApp
這個函數。這個文件用于做Hydration(激活)操作,將Vue組件的行為附加到已經渲染的HTML上。
但問題是,app.js
在客戶端執行的時候,如果代碼為import { createSSRApp } from “vue”
是會報錯的,在瀏覽器中運行時無法解析“vue”
模塊,因為瀏覽器并不像Node或Vite開發環境那樣有“模塊解析系統”,它無法直接識別“vue”
是個什么路徑。
Koa配合Vue和Vite
既然用到Vite,也算是半只腳踏入“工程化”的大門了。但是Vite大多數情況下我們會用來開發一個SPA應用,此處可以拋出一個困擾了煮啵很久的一個問題:考慮到如果使用了SSR服務端渲染,那么每次切換瀏覽器的導航,是否都需要服務端都需要根據請求來生成HTML頁面并返回給客戶端?那是否可以認為用Vite構建的SSR應用是無法實現SPA應用的?或者說這兩者一定是對立的?
答案當然是否定的。SSR和SPA并不是完全對立的,兩者常常結合使用,可以大致分為以下兩個步驟:
- 服務端渲染:在第一次訪問時,Vite會生成HTML并返回給瀏覽器。這些 HTML 包括了從服務器渲染的數據。
- 客戶端接管 (Hydration):一旦頁面加載完成,Vue、React或其他前端框架會在客戶端 “接管” 這個頁面,即把頁面變成一個SPA。瀏覽器加載應用的腳本代碼,綁定事件,并且使得頁面可以進行客戶端路由和狀態管理。
總的來說,SPA和SSR的結合我們可以認為是先SSR,然后由客戶端接管為SPA的過程。且煮啵始終認為,再復雜的應用,只要是SPA應用,服務端渲染便往往只發生在首頁渲染的時候(這個“首頁”可以是SPA應用中的任意一個頁面)。借助Vite官網提供的教程,我們可以快速搭建一個基于Vite的Vue&SSR應用:
// pro/src/entry-client.js
// 客戶端入口文件,瀏覽器靠這個文件在客戶端激活Vue,接管服務端返回的HTML
import { createApp } from './main'
const { app } = createApp()
app.mount('#app')// pro/src/entry-serve.js
// 服務端入口文件,返回的stream用于構建HTML文件
import { renderToWebStream } from 'vue/server-renderer'
import { createApp } from './main'
export function render() {const { app } = createApp()const ctx = {}const stream = renderToWebStream(app, ctx)return { stream }
}// pro/src/main.js
// 創建應用實例。此文件在客戶端和服務端各執行一次,具體原因見《Koa配合Vue》中的分析。
import { createSSRApp } from 'vue'
import App from './App.vue'
import router from './router/index'
export function createApp() {const app = createSSRApp(App)app.use(router)return { app }
}// pro/src/App.vue
<template><div>Hello World!</div><li><router-link to="/home">首頁</router-link></li><li><router-link to="/user">個人中心</router-link></li><div><router-view></router-view></div>
</template>// pro/src/router/index.js
// 路由文件
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
import Home from '../views/home.vue'
import User from '../views/user.vue'
const isSSR = typeof window === 'undefined'
const routes = [{path: '/home',component: Home,name: 'home',meta: { ssr: true }},{path: '/user',component: User,name: 'user',meta: { ssr: true }},
]
const router = createRouter({// 此處必須判斷當前所處環境時服務端還是客戶端。// 因為createWebHistory的實現依賴于瀏覽器全局對象window// 而在服務端渲染的時候是沒有window的history: isSSR ? createMemoryHistory() : createWebHistory(),routes,
})
export default router
主要的文件就是上面這幾個了。之后每次進入前端應用的時候,瀏覽器發送GET請求得到的HTML文件中的內容都不再是只有一個id為app的div
那么簡單,而是會將App.vue
中靜態的內容渲染出來。但是有一個問題:在SSR的時候能不能獲取數據庫中的數據來填充HTML文件中的內容,然后再返回給客戶端?
如果是客戶端渲染,我們通常會在onmount
這個生命周期中做響應操作,但是onmount
中的副作用永遠屬于客戶端行為,它只有在客戶端接管HTML后才能發生作用。
但是,這種需求在Next中實現起來很簡單:實現getServerSideProps
即可。且在Nuxt中估計也是類似的。框架永遠有一個好處就是幫我們封裝了很多很繁瑣的東西,再向上暴露有限的接口。
但是如果硬要用Vite來操作,煮啵現在并沒找到很好的方法🤯。唯一能想到的就是從pro/src/entry-server.js
或者pro/server.js
這兩個文件中切入來實現,后續找到方法再來分享。
還有一個問題:既然配合了vue-router
使用,那能不能在首次進入頁面的時候,讓SSR返回的HTML內容中包含當前路徑映射到的組件,而不僅僅是一個App.vue
的內容?
在Vite最基礎的框架上煮啵也沒找到方法🤯,嘗試過在定義路由表的時候給每個路由選項都添加meta: { ssr: true }
這樣的配置,但沒有生效,之后再找方法吧。但是話又說回來了🤓在Next中這樣的功能是內置的,框架已經幫你搞定了。