最近需要把公司基於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