阅读本文前,假设你已经完整的阅读过 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

如果还有啥问题,就自己探索吧,毕竟博主并不是个好心人,啊哈哈哈哈。。。。

在这里插入图片描述

Logo

前往低代码交流专区

更多推荐