搭建多页博客总结(四)服务端ssr渲染
关键字:vue,ssr渲染,服务端
大概这两三天的时间里面,抽空弄了这个!目前而言我认为还是很有必要的,我就趁热打铁,总结一番吧!
既然提到服务端渲染,那就先聊聊这几年的web的发展:
早在先些年,IE老大哥还是最屌的时候,由于js的各种各样兼容性问题等,那个时候的网页基本都是服务端由用php,jsp,asp等渲染而来的,js能做的事情很局限,效率也相当低下。也就是说那个时候的web基本上是通过后台获取请求后,经过后台的渲染从而得到一张页面的结构内容等,在浏览器上得到展示,一个网站也便是通过后台路由+渲染而来
在之后,便是Google引领的技术革新了,Google不仅遵循了ECMA的相关规范,还开发出了高效的v8引擎(js的运行环境),页面的部分内容渲染由服务端向客户端转移,这个便是前端这个行业的由来…(有点扯远了)其实这样的目的也是想带给用户更好的体验,以及减轻服务器的压力。
随后,随着Google推出angular到现在和react,vue三分天下,标志着spa时代的到来,直接把后台的路由也移到前台来了,后台只需要提供resfulAPI,网页在客户端获取完毕后独立渲染,路由也在前台完成,一气呵成,服务端以更小的压力,带给用户以接近app的体验。
但这还不够完美,Google又…(超范围了,到这够了,下次再接着讲故事)
由于短短几年,前端发展的太过于迅速,也给spa带来了致命的缺点,就像我的博客没做ssr渲染之前那样,鼠标右键显示源码,只能剩下一个空荡荡的HTML5框架(如图)
搜索引擎自然跟不上前端的发展(你没听错说的就是你卖假药的那位),搜索引擎的爬虫,就无法获得到页面数据,可以说离开搜索引擎,你就少了大部分的客户来源…
这个时候只能略微妥协,对spa请求的首页做ssr服务端渲染(当然还有另外一种方式暂且不提),这么一来搜索引擎也能得到数据,顺便能减少首页白屏时间(我觉得这个主要还是看客户端cpu的性能)
好了,废话就说到这里,下面我们开始吧!
起步
首先先去参考一下官方文档,他提供了一个完美的解决方案,Next.js框架,照着往上面套就好了!
你们以为这篇文章这就结束了,那我也真是太无聊了!
修改相关内容
其实修改这些内容的主要目的是想让这些代码在服务端执行,从而起到ssr服务端渲染的效果。
main.js修改
先为app,router,store创建工厂方法createApp,createRouter,createStore
export function createApp() {
const router = new createRouter();
const store = new createStore();
sync(store, router);
const app = new Vue({
el: '#app',
render: h => h(App),
router,
store,
template: '<App/>',
components: { App }
});
return { app, router, store }
}
路由修改
export function createRouter(){
return new Router({
mode:'history',
saveScrollPosition:true,
routes: [
{
path:'/',
name: 'home',
component: home,
},
...
]
})
}
网络请求及其他
网络请求用封装好的Axios,也是vue推荐使用的,这个好处在于后台也能用它来请求数据。这里要做的就是找出那些是只能在客户端完成的,因为node和浏览器的全局对象不同,node无法完成DOM等ui操作,在此找出所有只能浏览器运行的内容,做个判断让node不执行客户端内容。
为了防止发生报错出现未定义的情况,都采用这样操作:
if(typeof window !== "undefined"){
let fastClick = require('fastclick');
fastClick.attach(document.body);
Vue.directive('highlight',function (el) {
let blocks = el.querySelectorAll('pre code');
blocks.forEach((block)=>{
hljs.highlightBlock(block)
})
})
}
创建入口
分别新建两个js,一个服务端入口,一个客户端入口
服务端入口
server-entry.js
import { createApp } from './index'
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
// 设置服务器端 router 的位置
router.push(context.url)
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
// eslint-disable-next-line
return reject({ code: 404 })
}
resolve(app)
},reject)
})
}
客户端入口
client-entry.js
import { createApp } from './index'
// 客户端特定引导逻辑……
const { app, router, store } = createApp()
// 这里假定 App.vue 模板中根元素具有 `id="app"`(服务器渲染后就有这个id)
router.onReady(() => {
app.$mount('#app')
})
vuex修改
为了提前在服务端先拿到数据,这个要费不少事…
我就简单说一下,先把store用工厂方法导出
export function createStore() {
return new Vuex.Store({
state:{
token: null,
indexPageList: {},
articleInfo: {
markList:[]
},
toggle:{},
onLoading:false,
tagAndClassicList:{}
},
actions,
mutations,
});
}
然后,去想办法拿数据,因为没有ui渲染,自然生命周期钩子在服务端无法执行,那就只能先靠在服务端入口配置并且在对应要获取状态的父组件中创建preFetch方法如
//等同生命周期获取状态
preFetch(store){
return store.dispatch('getArticleList'store.state.route.params.id);
}
让他去得到store状态,找到对应组件中的preFetch方法,执行请求,先行渲染
客户端入口添加异步操作
import { createApp } from './index'
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
// 设置服务器端 router 的位置
router.push(context.url)
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
// eslint-disable-next-line
return reject({ code: 404 })
}
// Promise 应该 resolve 应用程序实例,以便它可以渲染
// 在这里查找有preFetch方法的父组件,并回调执行,更新状态
Promise.all(matchedComponents.map(component => {
if(component.preFetch){
return component.preFetch(store)
}
})).then(()=>{
context.state = store.state;
resolve(app)
}).catch(reject)
})
})
}
客户端入口添加数据同步,客户端获取到的数据和服务端渲染的进行匹配
import { createApp } from './index'
// 客户端特定引导逻辑……
const { app, router, store } = createApp()
// store替换使client rendering和server rendering匹配
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// 这里假定 App.vue 模板中根元素具有 `id="app"`(服务器渲染后就有这个id)
router.onReady(() => {
app.$mount('#app')
})
模板修改
添加<!--vue-ssr-outlet-->
就像这样
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<title>冰空的作品展示</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
当然这个别忘了,最开始我忘记删除<div id="app"></div>
以至于客户端渲染一次,服务端渲染一次,产生两个页面!
构建工具
干活之前,首先得把工具弄得顺手。不过由于博客逻辑之前已经写完了,那现在我就先完成生产环境,尽快上线为好。
package.json配置
修改package.json中的打包命令,添加build:client和build:server
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node app.js",
"dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js --host 0.0.0.0",
"unit": "jest --config test/unit/jest.conf.js --coverage",
"e2e": "node test/e2e/runner.js",
"build": "rimraf dist && npm run build:client && npm run build:server",
"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules"
},
webpack客户端渲染配置
内容其实和vue-cli的webpack.prod.conf.js差不多,修改入口文件为对应上面新建的客户端入口,单页的话直接注释掉之前打包HTML的部分,也就是new HtmlWebpackPlugin
的内容,然后引入vue-server-renderer/client-plugin
,并且实例化即可,这是官方推荐的方式。
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const config = merge(base, {
entry: {
'app': `./src/pages/index/client-entry.js`
},
output: {
filename: '[name].[chunkhash:8].js'
},
plugins: [
// strip dev-only code in Vue source
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
new VueSSRClientPlugin(),
],
]);
其实不用改也一样,原用之前的生产环境即可,只需修改入口文件为客户端入口即可。其余的根据需求修改!
webpack服务端渲染配置
配置这部分的目的在于打包生成vue-ssr-server-bundle.json,让服务端知道,他要什么,需要渲染什么。要做的重点就是添加入口,设置你需要的模块的白名单,最后同样也是new VueSSRServerPlugin()
实例化即可!
...
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(base, {
target: 'node',
devtool: '#source-map',
entry: './src/pages/index/server-entry.js',
output: {
filename: `server.[hash:8].js`,
libraryTarget: 'commonjs2'
},
externals: nodeExternals({
// 不要外置化 webpack 需要处理的依赖模块。
// 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
whitelist: [/\.css$/,/iview/]
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
Koa服务端配置
以上步骤完成后,就该来改服务端实现了。由于服务端之前只提供resful
API因此要做一些修改…多说一点,因为我想要和之前在nginx上配置的一样采用http/2协议,所以我使用了node9.5.0版;框架使用了koa2…
这里要做的就是先引入渲染器
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
构建渲染函数
// 生成服务端渲染函数
let renderer
function createRender(){
return createBundleRenderer(require(__dirname+'/public/dist/vue-ssr-server-bundle.json'), {
// 推荐
runInNewContext: false,
// 模板html文件
template: fs.readFileSync(resolve(__dirname+'/public/dist/app.html'), 'utf-8'),
//缓存
cache: require('lru-cache')({
max: 1000,
maxAge: 1000 * 60 * 15
}),
// client manifest
// 客户端若是用new HtmlWebpackPlugin渲染的直接设置模板即可
// clientManifest: require('./dist/vue-ssr-client-manifest.json')
})
}
function renderToString (context) {
return new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => {
err ? reject(err) : resolve(html)
})
})
}
配置新的路由
index.get('*',async(ctx,next)=>{
try {
const context = {
url: ctx.url
}
// 将服务器端渲染好的html返回给客户端
renderer = createRender()
ctx.body = await renderToString(context)
// 设置请求头
ctx.set('Content-Type', 'text/html')
} catch (e) {
// 如果没找到,放过请求,继续运行后面的中间件
console.error(e)
next()
}
});
app
.use(index.routes())
.use(index.allowedMethods())
大概至此手工搭建服务端渲染大概就完成了…
其他
由于我的博客构建了两个页面,一个展示页,一个管理页,管理页面没有必要去做服务端渲染。所以打包直接由webpack直出,在koa2中引用connect-history-api-fallback
代理admin.html来实现后端路由,并且能以history模式访问
首先为了能够让非admin的页面能够路由穿透,实现访问,那我们先来封装这个中间件
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()
} else {
// 修改content-type
ctx.type = 'text/html'
return expressMiddleware(ctx.req, ctx.res, next)
}
}
}
module.exports = historyApiFallback
然后回到主程序,配置路由匹配规则,用正则匹配,并调用historyApiFallback
++ 这里会有个坑,对应静态资源服务器必须在这个模块后建立,不然会找不到相应的内容,然后路由穿透抛出404!++
app.use(convert(historyApiFallback({
verbose:true,
index: '/dist/manage.html',
rewrites: [
{ from: /^\/admin/, to:'/dist/manage.html' },
{ from: /^\/admin#\/.*$/, to: '/dist/manage.html' }
],
path: /^\/admin/
})))
app.use(koaStatic(resolve(__dirname+'/public')))
总结
嗦不出话,又特么浪费我超多的时间。明知是个坑,还非要往坑里跳!
总而言之,服务端渲染在改个开发模式,就可以告一个段落了…现在有了ssr服务端渲染,移动端的体验,能比之前提升不少,相信不久的将来也许搜索引擎能够发现我了吧!
附一张现在的源码~