阅读本文前,假设你已经完整的阅读过 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
如果还有啥问题,就自己探索吧,毕竟博主并不是个好心人,啊哈哈哈哈。。。。