vue ssr 如何移除 window.__INITIAL_STATE__ 注入

閱讀本文前,假設你已經完整的閱讀過 vue ssr 的文檔,知道如何搭建一個 vue ssr 的項目了。
如題,提出這個需求,多半是 SEO 大佬那邊說這個東西影響收錄。官方文檔上雖然有這麼句話:

在 2.5.0+ 版本中,嵌入式 script 也可以在生產模式 (production mode) 下自行移除。

但是實際上,雖然調試控制檯上確實沒有了相應的 script 標籤,但是查看源碼的時候依舊可以看到這塊內容,這說明在服務端生成 HTML代碼時依舊是有注入的,只是在到達客戶端之後通過 removeChild 移除掉了而已。 顯然這是無法滿足 SEO 的需求的。那要如何處理呢?如果是有瘋狂搜索過,那應該看過這句話:

如果能同步兩端數據,那麼不注入 window.INITIAL_STATE 也是可以的

這似乎就是解決方案,同步兩端數據,但是具體是什麼意思呢?
我們先回想一下整個服務端渲染的過程:

  1. 啓動一個服務,監聽指定端口,接收來自客戶端的請求

    // 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}`)
    })
    
  2. 客戶端發起請求,然後服務端接收,並處理之,請求先走 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)
        })
    }
    
  3. 數據準備完畢後,回到 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 不是很熟,所以沒有采用這種。下面來看看具體做法:

  1. 需要一個緩存容器,這裏使用 lru-cache 來存儲數據,新建文件 routerDataCache.js,代碼如下

    const LRU = require('lru-cache')
    
    const dataCache = new LRU({
        max: 1000,
        maxAge: 1000 * 60 * 15, // 單位爲毫秒,這裏設置爲十五分鐘
    });
    
    // 需要直接返回對象,作爲單例調用,因爲需要共享
    module.exports = dataCache;
    
  2. 然後修改 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)
        })
    }
    
  3. 現在 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);
    
  4. 在客戶端獲取之,然後注入客戶端 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

如果還有啥問題,就自己探索吧,畢竟博主並不是個好心人,啊哈哈哈哈。。。。

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章