EggJs+Vue服务端渲染实践
最近需要把公司基于Vue进行开发的webView页面改造成ssr的。由于现有的node后台是基于EggJs开发的,如果改用其他脚手架搭建需要的成本也忒大了,这得加多少班啊,索性就自己瞎捣鼓一下吧。
我们先来看看当我们使用vue客户端渲染时一个页面的加载过程。首先是我们请求的index.html到达浏览器,然后浏览器再根据index.html中的script标签去请求js文件,请求到了js文件后开始执行Vue实例的初始化操作。 vue的实例初始化大体上需要经过:
实例初始属性设置,挂载createElement方法->beforecreate->initState中对Props/data声明响应式对象,初始化methods并bind执行上下文,对computed属性生成computed watcher->created->生成渲染watcher->执行updateComponent->执行render方法,通过之前挂载的createElement方法生成vnode节点->执行_update方法,调用_patch_方法根据虚拟dom生成真实的dom节点->mounted
这么多步骤的处理之后,我们才能在浏览器上看到我们的页面,而且注意是每一个组件都需要经历这么多步骤才可以哦,所以如果在对首屏加载时间有一定要求的页面中我们还使用客户端渲染的方式去处理。那么大概,可能,应该是会被凶残的产品经理打死的吧。。。
然后我们先来看一下通过客户端渲染完成的页面的屏幕快照(使用chrome的Capture screenshots截取)
我们可以看到,页面在337ms之前页面处于不忍直视的状态,直到403ms页面终于显示出了第一幅画面。。。
进行服务端渲染就是在我们请求index.html时,在node服务器中先将我们的vue实例渲染成html字符串,然后再从服务器返回。这时候浏览器获取到的html文件中将不再是空空如也。
这是服务器端渲染返回的结果,在58ms时浏览器就已经显现出了我们页面的大体样式,省去了前面大约0.3s的从vue实例创建到mounted的空白期应该足够给产品经理交差了。
vue-ssr实现的代码在
https://ssr.vuejs.org/zh/
中已经有了相当详细的实现了,这里主要写写如何将vue-ssr集成到EggJs框架中。
项目目录结构:
config文件夹下放的是我们的webpack配置文件;public是EggJs的静态资源目录,所以我把打包后的dist文件夹放在了这里,顺便把模板index.html也放在这儿;src目录下是vue项目的源文件。
首先是webpack-base-config.js文件:
// webpack-base-config.js const { VueLoaderPlugin } = require('vue-loader'); const path = require('path'); module.exports = { //输出路径为EggJs静态资源文件夹public下的dist文件夹 output: { publicPath: '/public/dist/', path: path.join(__dirname, '../public/dist'), }, resolve: { extensions: ['.js', '.vue', '.json'], alias: { 'vue$': 'vue/dist/vue.esm.js', } }, module: { rules: [{ test: /\.vue$/, loader: 'vue-loader' }, { test: /\.css$/, use: ["vue-style-loader", "css-loader", 'less-loader'] }, { test: /\.less$/, use: ["vue-style-loader", "css-loader", 'less-loader'] }, { test: /\.(gif|png|jpg|woff|svg|ttf|eot)\??.*$/, loader: { loader: 'url-loader', options: { limit: 8192, name: './resource/[name].[ext]', }, } }, { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }], }, plugins: [ new VueLoaderPlugin(), ] }
webpack-client-config.js
const webpack = require('webpack'); const merge = require('webpack-merge'); const baseConfig = require('./webpack-base-config.js'); const VueSSRClientPlugin = require('vue-server-renderer/client-plugin'); const path = require('path'); module.exports = merge(baseConfig, { // 为了兼容安卓4.0版本,编译成es5语法 entry: [ 'babel-polyfill', path.join(__dirname, '../src/entry-client.js') ], plugins: [ new webpack.optimize.SplitChunksPlugin({ name: 'manifest', minChunks: Infinity }), new VueSSRClientPlugin() ] })
webpack-server-config.js
const merge = require('webpack-merge'); const nodeExternals = require('webpack-node-externals'); const baseConfig = require('./webpack-base-config'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); const path = require('path'); module.exports = merge(baseConfig, { entry: [ 'babel-polyfill', path.join(__dirname, '../src/entry-server.js') ], target: 'node', devtool: 'source-map', output: { libraryTarget: 'commonjs2' }, externals: nodeExternals({ whitelist: /\.css$/ }), plugins: [ new VueSSRServerPlugin() ] })
这里由于是服务端渲染,hash模式的路由并不能够让服务器获取到路由改变事件,所以我们的路由模式必须是history模式而不是hash模式,具体router文件如下:
import Vue from 'vue'; import Router from 'vue-router'; const home = (resolve) => { require(["../views/home.vue"], resolve) }; Vue.use(Router); let router = new Router({ mode: 'history', routes: [{ path: '/home', component: home }] }); export function createRouter() { return router; }
Vue项目的入口文件需要由原来的直接new Vue()实例并mount修改为暴露一个createApp实例的方法:
import Vue from 'vue'; import App from './App.vue'; import { createRouter } from './router/index.js'; export function createApp() { // 创建router实例 const router = createRouter() // 创建vue对象实例 const app = new Vue({ render: h => h(App), router }); return { app, router }; }
客户端打包入口文件:
import { createApp } from './app'; const { app, router } = createApp(); router.onReady(() => { app.$mount('#app', true); });
服务器端打包入口文件:
import { createApp } from './app'; export default context => { return new Promise((resolve, reject) => { const { app, router } = createApp(); router.push(context.url); router.onReady(() => { const matchedComponents = router.getMatchedComponents(); if (!matchedComponents.length) { return reject({ code: 404 }); } resolve(app); }, reject); }); };
配置完以上文件之后运行我们熟悉的npm run build指令在dist文件夹下会生成一个服务端渲染所需的bundle和客户端混合所需的manifest文件:
然后我们在EggJs项目的根目录下添加app.js文件,在EggJs启动的willReady生命周期中根据我们的bundle和manifest文件创建renderer对象,
并将renderer方法挂载到EggJs全局的Application对象上方便之后在controller中调用该方法进行服务端渲染。
然后我们在EggJs项目的根目录下添加app.js文件,在EggJs启动的willReady生命周期中根据我们的bundle和manifest文件创建renderer对象,并将renderer方法挂载到EggJs全局的Application对象上方便之后在controller中调用该方法进行服务端渲染。
const { createBundleRenderer } = require('vue-server-renderer'); const serverBundle = require('./app/public/dist/vue-ssr-server-bundle.json'); const clientManifest = require('./app/public/dist/vue-ssr-client-manifest.json'); const path = require('path'); const file = require('fs'); class AppBootHook { constructor(app) { this.app = app; } // 配置文件加载完毕事件 async willReady() { let renderer = createBundleRenderer(serverBundle, { runInNewContext: false, template: file.readFileSync(path.join(__dirname, './app/public/index.html'), 'utf-8'), clientManifest }); this.app.renderer = renderer; } } module.exports = AppBootHook;
之后再在controller中根据请求的url路径调用renderToString方法把页面渲染为html字符串返回给浏览器就大功告成了。
'use strict'; const Controller = require('egg').Controller; class HomeController extends Controller { async index() { let renderer = this.app.renderer; let context = { url: this.ctx.request.url }; renderer.renderToString(context, (err, html) => { if (err) { if (err.code === 404) { this.ctx.body = "404"; } else { this.ctx.body = process.env.NODE_ENV; } } else { this.ctx.body = html; } }); } } module.exports = HomeController;
git地址:https://github.com/cgy-tiaopi/egg-vue-ssr