由于前端渲染SEO的问题,所以首先博客优化点先把服务端渲染(Server-Side Rendering
)放在首位,折腾了段时间将博客前台部分以及服务端koa2部分改版,成功实现服务端渲染,这篇文章旨在记录下本次博客的升级以及实现vue2
与koa2
配合服务端渲染的相关经验和小结。
先睹为快
Talk is cheap. Show me the code
- SSR在线体验:http://123.206.67.156:9000/ (对,域名还是没有备案好?)
大家可以打开network看下渲染出的html是否实现了SSR
这个博客项目会持续更新,追求更完美的博客体验,欢迎star、fork,提出你宝贵的意见啊~?
上一篇文章:基于vue2、koa2、mongodb的个人博客
再谈技术架构
这是原来的
更新后
可以对比一下上述两图的区别
博客主要更新点
- 考虑到博客前台应用日后可能变复杂,将前台front端由vue event bus也改成了vuex, 并且用vuex的话直接前端拿到服务端渲染后的数据后直接替换store也比较方便
- 当然重点还在于SSR,后台部分还是沿用之前的方案,使用historycallback,通过自己强化historycallback中间件,增加可以不匹配的参数,只匹配
/admin
(这部分后面会讲到),然后前台部分则是使用vue-server-renderer
的方法通过读取template和vue-ssr-server-bundle.json来渲染出html返回实现服务端渲染
Server-Side Rendering
相关概念
我们都在说SSR,其实就是Server-Side Rendering(服务端渲染)的缩写,它可以解决前端渲染的两个痛点SEO(搜索引擎优化)
以及首屏渲染性能
因为前端渲染往往初始页面基本上几个div,然后其他什么数据之类都是需要用js渲染到dom上,SEO方面有些爬虫就只会到html(虽然现在有些爬虫已经能识别到js加载出的,不过还是按照它的url), 首屏渲染方面更加显而易见,很多前端渲染的应用往往js相对较大,就得等到js加载解析完成才能加载到首屏,影响体验。
相关分类
这篇文章写得不错 实测Vue SSR的渲染性能:避开20倍耗时
文章中讲到分为两种
- string-based (基于字符串拼接)
- virtual-dom-based(基于虚拟dom对象)
前一种是我们之前很常见到的,通过ejs
或者pug
等等这些引擎通过它们一些规则实现一些数据的填充
第二种则是往往和前端渲染相配合的服务端同构渲染(isomorphic
),同构即前后端共用一套代码,后端通过编写一些规则将前端代码转成virtual-dom
对象,调用render再取数据渲染出HTML出来
具体也不深入介绍这部分,上面那篇文章有比较详细的分析,有兴趣的同学可以前往观看
Thanks for the suggestion. We are obviously aware of the fact that the virtual-dom-based SSR is slower than string-based ones; but one important reason for Vue's SSR to be virtual-dom-based is so that it fully supports manually written render functions as well. This is critical for advanced components such as
<transition>
,<keep-alive>
,<router-view>
etc. to work properly - a lot of these features are simply impossible with plain string templates.It may be possible to use a hybrid strategy where we render simple components using string concatenation, but advanced ones using the current vdom-based algorithm. If the user's app contains large amount of template-only components this should still result in significant perf win.
vue2与koa2配合实现服务端渲染
这里开始步入本文的重点
vue SSR整套流程
有可能有些朋友还不怎么了解这部分的流程,这里我简单介绍一下
这里讲述是生产环境下的
- 比如,用户输入浏览器地址栏输入网址,发送一个get请求
- 服务端收到这个请求后,按我们以前的想法,就是找到html直接返回,或者render模板,这里通过的是
vue-server-renderer
的createRenderer
,读取两个文件,一个客户端相关的html模板(现在也可以使用生成的json),一个服务端相关的json, (这两个文件都是webpack生成,这里看不懂不要紧,后面会接着讲),然后通过createRenderer
构造出一个渲染器 - 这个渲染器调用
renderToString
或者renderToStream
在上下文中传入req.url
,刚刚不是传入个服务端相关的json(其实它是由我们自定义的一个entry-server.js生成,这里面写着如何去提前取数据),然后调用后就进入构造初始化store,初始化router,初始化App,进入提前取数据的逻辑,通过匹配路由的组件,然后调用我们在组件事先写好的preFetch
方法去取数据,最后将appresolve
出来,这样提前请求数据的步骤完成 - 上面那个步骤完成其实就可以将完整的首屏返回了,这里很多人其实还有一个疑问,我服务端拿到了数据然后前端还要不要拿,我前端那些逻辑怎么跟后端渲染好的数据相配合,其实上一步拿到数据后还有关键的一步,
context.state = store.state
,这部分上下文拿到取好的数据后,会在html里嵌入一段window.__INITIAL_STATE__={//...}
,然后前端部分我们可以这样store.replaceState(window.__INITIAL_STATE__)
,然后我们就初始化的store数据就是服务端已经请求渲染好的数据,就达到了匹配,关于前端还要不要拿数据,如果服务端渲染的数据已经满足了其实就不用拿了,不过有些时候因为我们需要更快的响应速度,可以让服务端取一部分数据,前端取大数据来提升速度,不过这里也要注意到页面的那些元素的匹配问题,假如渲染出的跟前端部分不匹配的话,vue部分会报出warning[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render. warn复制代码
上部分其实就是整个SSR流程,当然上面也略写了很多背后的渲染深层原理以及部分细节,想看细节读者可以继续啦~?
如何实现
webpack入口文件部分
这里有两个很关键的文件,一个是entry-client.js和entry-server.js
// entry-client.js
import { createApp } from './app'
const { app, router, store } = createApp()
// store替换使client rendering和server rendering匹配
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// 挂载#app
router.onReady(() => {
app.$mount('#app')
})复制代码
entry-client.js主要起到的作用是替换store来跟服务端匹配,可以通过阅读上一节的流程看到
// entry-server.js
import { createApp } from './app'
const isDev = process.env.NODE_ENV !== 'production'
export default context => {
console.log(context)
const s = isDev && Date.now()
// 注意下面这句话要写在export函数里供服务端渲染调用,重新初始化那store、router
const { app, router, store } = createApp()
return new Promise((resolve, reject) => {
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
reject({ code: 404 })
}
Promise.all(matchedComponents.map(component => {
if(component.preFetch) {
// 调用组件上的preFetch(这部分只能拿到router第一级别组件,子组件的preFetch拿不到)
return component.preFetch(store)
}
})).then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
// 暴露数据到HTMl,使得客户端渲染拿到数据,跟服务端渲染匹配
context.state = store.state
context.state.posts.forEach((element, index) => {
context.state.posts[index].content = '';
})
resolve(app)
}).catch(reject)
})
})
}复制代码
entry-server.js通过webpack中的vue-server-renderer/server-plugin
打包成一个json供服务端vue-server-renderer
的createRenderer
读取,主要起到每一次SSR服务端请求重新createApp
以及匹配路由提前取数据渲染的作用
服务端关键部分
构造渲染器部分
const bundle = require('../client/dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync(resolve('../client/dist/front.html'), 'utf-8')
renderer = createRenderer(bundle, template)复制代码
router匹配路由部分renderToString或者renderToStream
router.get('*', async(ctx, next) => {
let res = ctx.res;
let req = ctx.req;
// 由于koa内有处理type,此处需要额外修改content-type
ctx.type = 'html';
const s = Date.now();
let context = { url: req.url };
// let r = renderer.renderToStream(context)
// .on('end', () => console.log(`whole request: ${Date.now() - s}ms`))
// ctx.body = r
function renderToStringPromise() {
return new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => {
if (err) {
console.log(err);
}
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
}
resolve(html);
})
})
}
ctx.body = await renderToStringPromise();
})复制代码
vue组件关键部分
vue方面我们得提前定义好preFetch逻辑, entry-server.js会传入store然后调用action等就可以提前取数据
export default {
name: 'list',
//...
preFetch(store) {
store.dispatch('getAllTags')
return store.dispatch('getAllPosts',{page:store.state.route.params.page}).then(()=>{
})
}
//...
}复制代码
如何与koa配合
改写express中间件
网上很多例子都是围绕着express
来的,虽然koa
的异步处理很优秀,但不得不承认express
的生态比koa
好太多,很多中间件都有express
版本,但是没有koa
版本。
不过改写那些中间件并不是很复杂,我们只要搞清楚express和koa中的req、res、ctx、next这些相关概念以及了解koa对req与res的封装,就能去改写
比如对connect-history-api-fallback
function historyApiFallback (options) {
const expressMiddleware = require('connect-history-api-fallback')(options)
const url = require('url')
return (ctx, next) => {
let parseUrl = url.parse(ctx.req.url)
// 添加path match,让不匹配的路由可以直接穿过中间件
if(!parseUrl.pathname.match(options.path)) {
return next()
}
// 修改content-type
ctx.type = 'html'
return expressMiddleware(ctx.req, ctx.res, next)
}
}
module.exports = historyApiFallback复制代码
比如对webpack-dev-middleware
const devMiddleware = require('webpack-dev-middleware');
module.exports = (compiler, opts) => {
const expressMiddleware = devMiddleware(compiler, opts)
let nextFlag = false;
function nextFn() {
nextFlag = true;
}
function devFn(ctx, next) {
expressMiddleware(ctx.req, {
end: (content) => {
ctx.body = content
},
setHeader: (name, value) => {
ctx.headers[name] = value
}
}, nextFn)
if(nextFlag) {
nextFlag = false;
return next();
}
}
devFn.fileSystem = expressMiddleware.fileSystem
return devFn;
}复制代码
经验之谈其实就是返回一个(ctx, next)=>{}
类似的函数,然后我们取看express中间件的源码,看它对req和res有什么相关操作,然后我们根据这些操作传入koa的处理方式,比如下面
expressMiddleware(ctx.req, {
end: (content) => {
ctx.body = content
},
setHeader: (name, value) => {
ctx.headers[name] = value
}
}, nextFn)复制代码
然后看一下它调用next的逻辑,选择我们手动调用或者直接将koa的next传入
这种改写问题具体情况具体分析~上面也只是写了个大概思路
开发环境
其实就是使用我们改写好的webpack-dev-middleware
与webpack-hot-middleware
然后在内存中拿文件,然后hot监听文件修改reload页面
// 开发环境下使用hot/dev middleware拿到bundle与template
require('../client/build/setup-dev-server')(app, (bundle, template) => {
renderer = createRenderer(bundle, template)
})复制代码
生产环境
其实上面也已经说到了,这里已经提前生成好template
和json
读取到然后调用渲染器render方法即可
一些经验之谈
避开服务端和浏览器端的环境差异
服务端和客户端同构,但是服务端并没有window
和document
这些方法怎么办
环境判断
可以通过全局window的存在与否去判断
// 解决移动端300ms延迟问题
if (typeof window !== "undefined") {
const Fastclick = require('fastclick')
Fastclick.attach(document.body)
}复制代码
特殊的生命周期钩子
其实服务端渲染vue-server-renderer
并没有所有钩子都调用,所以这部分我们就可以利用这个,将一些需要操作window以及dom相关的放入类似beforeMount
等等这些钩子里,具体可以看vue文档,都有介绍是否支持服务端渲染
遇到not match
问题怎么办
[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render.
warn复制代码
- 检查是否entry-client.js是否替换store
- 检查客户端其他生命周期钩子是否影响到页面数据的显示,比如用到一些关于数据的v-if等等
renderToString还是renderToStream?
这两个我都试过,可能是由于我的应用复杂程度较低,两者差异不大,有兴趣的读者也可以把我的源码clone下来本地跑一下试试,目前使用的是renderToString,注释部分有renderToStream
由于差异不大,考虑到可扩展性,相对string可能可扩展的程度较高一点,并且SSR文档写的如下,
大致意思就是虽然流式响应获取到第一块数据能第一时间返回,但是那时子组件还没有实例化,就没办法在它们的生命周期钩子里拿到数据渲染,还有因为前面的head头部信息以及内嵌style有可能很多的缘故,所以最后它表述的是不建议当你的组件生命周期钩子依赖于上下文数据的时候使用stream模式
In stream rendering mode, data is emitted as soon as possible when the renderer traverses the Virtual DOM tree. This means we can get an earlier "first chunk" and start sending it to the client faster.
However, when the first data chunk is emitted, the child components may not even be instantiated yet, neither will their lifecycle hooks get called. This means if the child components need to attach data to the render context in their lifecycle hooks, these data will not be available when the stream starts. Since a lot of the context information (like head information or inlined critical CSS) needs to be appear before the application markup, we essentially have to wait until the stream to complete before we can start making use of these context data.
It is therefore NOT recommended to use streaming mode if you rely on context data populated by component lifecycle hooks.
官方文档出来啦!!
现在大家可以阅读vue ssr服务端指南 ssr.vuejs.org/en/
最后
谢谢阅读~
欢迎follow我哈哈github.com/BUPT-HJM
看到这里,不star不行了?
github.com/BUPT-HJM/vu…
欢迎继续观光我的博客~
欢迎关注