閱讀本文前,假設你已經完整的閱讀過 vue ssr 的文檔,知道如何搭建一個 vue ssr 的項目了。
如題,提出這個需求,多半是 SEO 大佬那邊說這個東西影響收錄。官方文檔上雖然有這麼句話:
在 2.5.0+ 版本中,嵌入式 script 也可以在生產模式 (production mode) 下自行移除。
但是實際上,雖然調試控制檯上確實沒有了相應的 script 標籤,但是查看源碼的時候依舊可以看到這塊內容,這說明在服務端生成 HTML代碼時依舊是有注入的,只是在到達客戶端之後通過 removeChild 移除掉了而已。 顯然這是無法滿足 SEO 的需求的。那要如何處理呢?如果是有瘋狂搜索過,那應該看過這句話:
如果能同步兩端數據,那麼不注入 window.INITIAL_STATE 也是可以的
這似乎就是解決方案,同步兩端數據,但是具體是什麼意思呢?
我們先回想一下整個服務端渲染的過程:
-
啓動一個服務,監聽指定端口,接收來自客戶端的請求
// server.js const express = require('express') const app = express() const port = process.env.PORT || 80; // 這裏是 ssr 渲染處理相關的代碼 app.get('*', isProd ? renderHandler : (req, res, next) => { readyPromise.then(() => renderHandler(req, res, next)) }) app.listen(port, () => { console.log(`server started at localhost:${port}`) })
-
客戶端發起請求,然後服務端接收,並處理之,請求先走 express 的路由,然後在這裏轉交 vue 中的路由適配、解析、處理,然後路由匹配到的組件請求數據接口,請求完成後把數據寫入 vuex store 當中,這部基本就結束了
renderHandler () { // 這裏調用後進入 entry-server.js 的代碼邏輯 renderer.renderToString(context, (err, html) => { if (err) { return handleError(err) } res.send(html); }) }
entry-server.js 基本和官方文檔的內容一模一樣
export default context => { return new Promise((resolve, reject) => { const { app, router, store } = createApp() // 路由跳轉 router.push(context.url) // 路由適配與解析完畢後獲取匹配的組件,調用其獲取數據的方法,請求數據 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); if (!matchedComponents.length) { return reject({ code: 404 }) } // 對所有匹配的路由組件調用 `asyncData()`,把數據寫入 vuex store Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, hostname: context.hostname, route: router.currentRoute }) } })).then(() => { context.state = store.state; resolve(app) }, err => { reject(err); }).catch(() => { }) }, reject) }) }
-
數據準備完畢後,回到 renderer,將 vue 單文件的內容結合數據轉換成 HTML 字符串,返回到客戶端,如果註釋掉 entry-client.js 所有代碼,此時訪問的話客戶端應該呈現與設計稿內容一致的頁面,(這裏需要說明一下,渲染 vue 單文件時的數據,全都取自服務端的 vuex store)
renderer.renderToString(context, (err, html) => { // 將轉換後的代碼返回客戶端 res.send(html); })
到這裏整個渲染流程基本就結束了。
但是會有問題,我寫的交互代碼怎麼都沒生效呢?我的點擊事件呢?我的炫酷特效呢?
回頭想想:
交互代碼寫在哪呢?
vue 單文件內。
它爲啥不生效呢?
因爲服務端只是返回了HTML字符串,而沒有返回 vue 組件
現在咋辦呢?
激活它,使其變回組件
具體做法很簡單:
在 entry-client.js 裏只需要簡單的幾行代碼就可以了
import { createApp } from '../app'
const { app, router, store } = createApp()
router.onReady(() => {
app.$mount('#app')
})
// 獲取服務端傳過來的 vuex store 的數據,存入客戶端 vuex store
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
現在交互又好了。到這裏,整個服務端渲染流程就結束了。
那怎麼去除 window.INITIAL_STATE 注入呢?,這就需要用到 vue ssr 的手動資源注入
默認情況下,當提供 template 渲染選項時,資源注入是自動執行的。但是有時候,你可能需要對資源注入的模板進行更細粒度 (finer-grained) 的控制,或者你根本不使用模板。在這種情況下,你可以在創建 renderer 並手動執行資源注入時,傳入 inject: false。
然後修改模板文件,去掉 vuex store 注入的代碼
<html>
<head>
<!-- 使用三花括號(triple-mustache)進行 HTML 不轉義插值(non-HTML-escaped interpolation) -->
{{{ renderResourceHints() }}}
{{{ renderStyles() }}}
</head>
<body>
<!--vue-ssr-outlet-->
<!-- 去掉下面這一行即可 -->
{{{ renderState() }}}
{{{ renderScripts() }}}
</body>
</html>
似乎確實很簡單,代碼粘貼上去,保存,重啓服務。刷新頁面,我頁面呢?剛啥沒有的時候還好好的呢,你代碼是不是有 BUG?
其實不是,問題其實出現在 entry-client.js 的代碼裏。entry-client.js 這簡單的幾行代碼,到底做了些什麼事情呢?實際上也很好理解:
- 初始化 vue 組件
- 構建虛擬 DOM 樹
- 然後對比當前頁面已有的 DOM 結構,將不一致的部分替換爲虛擬 DOM 樹中的內容。
消失的內容,都是被替換掉了。爲啥客戶端構建的虛擬 DOM 樹與服務端返回的結構不一致呢?因爲數據它不一樣啊,服務端獲取的數據存在於服務端實例化的 vuex 對象,之前的方式是通過把數據以字符串的方式注入 HTML 內容中(即 window.INITIAL_STATE),然後在客戶端獲取,現在不讓注入了,沒有了數據,生成的 DOM 內容肯定是不一樣的。
需要另想辦法同步兩端數據,確切的說,是得想辦法把服務端的 vuex store 傳遞到客戶端。
我的做法:
- 在獲取完數據之後,把 vuex store 緩存起來
- 然後把緩存的 key 注入頁面中(對的,還是逃不過注入,但是注入內容會少非常多,至少能讓 SEO 覺得 OK)
- 客戶端通過請求的方式獲取實例的數據,注入客戶端的 vuex store。
緩存方式有兩種,一種是直接寫在內存裏,另一種是使用 redis ,我對 redis 不是很熟,所以沒有采用這種。下面來看看具體做法:
-
需要一個緩存容器,這裏使用 lru-cache 來存儲數據,新建文件 routerDataCache.js,代碼如下
const LRU = require('lru-cache') const dataCache = new LRU({ max: 1000, maxAge: 1000 * 60 * 15, // 單位爲毫秒,這裏設置爲十五分鐘 }); // 需要直接返回對象,作爲單例調用,因爲需要共享 module.exports = dataCache;
-
然後修改 server.js 的 render 函數,在這裏做數據緩存(記得引入緩存對象 routerDataCache.js)
function render(req, res, next) { // ... 原來的業務代碼 // 創建一個緩存 key,key 的生成規則看項目需求,只要能保證前後端能根據一定條件匹配上就可以了 // md5 也不是必須的,開心就好 let cachekey = md5(`vuex state cache:${ req.hostname }${ req.url }`); // 渲染上下文 let context = { cachekey, url: req.url, hostname: req.hostname } // 判斷是否有緩存,有緩存數據則讀緩存裏的數據 if (dataCache.has(cachekey)) { context = _.assign({}, dataCache.peek(cachekey), context); } renderer.renderToString(context, (err, html) => { if (err) { return handleError(err) } // 走完所有的流程後,把 context 存到緩存中 if(!dataCache.has(cachekey)) { dataCache.set(cachekey, context); } res.send(html) }) }
-
現在 vuex store 已經存到了內存中,客戶端要怎麼獲取呢?得寫個接口,把服務端緩存的數據返回客戶端。新建文件 CacheRouter.js
const express = require("express"); const router = express.Router(); const cache = require('routerDataCache') router.get('/route-cache/:key', (req, res) => { let key = req.params.key; res.setHeader("Content-Type", "application/json") res.send(cache.peek(key)); }) module.exports = router
然後掛載到 express 中,需要在 server.js 當中加入相關內容:
const apiRouter = require('router/CacheRouter') app.use('/apidata', apiRouter);
-
在客戶端獲取之,然後注入客戶端 vuex store,需要修改模板頁面和 client-entry.js 當中的代碼
模板中在</body>之前加入以下代碼:
<script> window.cachekey = '{{ cachekey }}'; </script>
然後修改 client-entry.js 的代碼
import { createApp } from '../app' import axios from '@lib/axios' const { app, router, store } = createApp() router.onReady(() => { app.$mount('#app') }) function getStoreData () { let key = window.cachekey; axios.get(`/apidata/route-cache/${key}`).then( ({ data: result }) => { if(result) { store.replaceState(result.state); } }, (err) => { }) } getStoreData();
到這裏整個數據注入的調整就結束了。
如果頁面的內容更新不頻繁,還可以在服務端渲染時也從緩存中讀取數據,減少接口請求,我們修改一下 entry-server.js 文件:
router.onReady(() => {
// ... 代碼
// 如果已有緩存數據,則直接 resolve ,否則執行獲取數據的相關操作
if(context.state) {
// 這一步很重要,服務端渲染期間的數據,全部都是從這裏讀取的,而不是 context 對象
store.replaceState(context.state);
resolve(app); return;
}
// ... 代碼
}, reject)
這裏要注意,讀取緩存數據的代碼一定要放在路由的回調 router.onReady 裏,直接在外面 resolve 的話,是不會好使的。
好了,現在如果有緩存,則讀取緩存當中的數據。
已知的問題:
- 如果啓用 pm2 來管理進程,同時啓動多個進程,那把數據緩存在內存中的做法是不行的,因爲進程之間的內存不共享,頁面的請求與獲取 vuex store 數據的請求不是同一個進程處理的話,就獲取不到對應的數據裏,這種情況推薦使用別的緩存方案,比如 redis
如果還有啥問題,就自己探索吧,畢竟博主並不是個好心人,啊哈哈哈哈。。。。