0. 服務端渲染簡介
服務端渲染不是一個新的技術;在 Web 最初的時候,頁面就是通過服務端渲染來返回的,用 PHP 來說,通常是使用 Smarty 等模板寫模板文件,然后 PHP 服務端框架將數據和模板渲染為頁面返回,這樣的服務端渲染有個缺點就是一旦要查看新的頁面,就需要請求服務端,刷新頁面。
但如今的前端,為了追求一些體驗上的優化,通常整個渲染在瀏覽器端使用 JS 來完成,配合 history.pushState
等方式來做單頁應用(SPA: Single-Page Application),也收到不錯的效果,但是這樣還是有一些缺點:第一次加載過慢,用戶需要等待較長時間來等待瀏覽器端渲染完成;對搜索引擎爬蟲等不友好。這時候就出現了類似於 React,Vue 2.0 等前端框架來做服務端渲染。
使用這些框架來做服務端渲染的兼顧了上面的幾個優點,而且寫一份代碼就可以跑在服務端和瀏覽器端。Vue 2.0 發布了也有一段時間了,新版本比較大的更新就是支持服務端渲染,最近有空折騰了下 Vue 的服務端渲染,記錄下來。
1. 在 Vue 2.0 中使用服務端渲染
官方文檔給了一個簡單的例子來做服務端渲染:
// 步驟 1:創建一個Vue實例 var Vue = require('vue') var app = new Vue({ render: function (h) { return h('p', 'hello world') } }) // 步驟 2: 創建一個渲染器 var renderer = require('vue-server-renderer').createRenderer() // 步驟 3: 將 Vue實例 渲染成 HTML renderer.renderToString(app, function (error, html) { if (error) throw error console.log(html) // => <p server-rendered="true">hello world</p> })
這樣子,配合通常的 Node 服務端框架就可以簡單來實現服務端渲染了,可是,在真實場景中,我們一般采用 .vue
文件的模塊組織方式,這樣的話,服務端渲染就需要使用 webpack 來將 Vue 組件進行打包為單個文件。
2. 配合 Webpack 渲染 .vue
文件
先建立一個服務端的入口文件 server.js
import Vue from 'vue'; import App from './vue/App'; export default function (options) { const VueApp = Vue.extend(App); const app = new VueApp(Object.assign({}, options)); return new Promise(resolve => { resolve(app); }); }
這里和瀏覽器端的入口文件大同小異,只是默認導出了一個函數,這個函數接收一個服務端渲染時服務端傳入的一些配置,返回一個包含了 app 實例的 Promise;
簡單寫一個 App.vue 的文件
<template>
<h1>{{ title }}</h1>
</template>
<script>
module.exports = {
props: ['title']
</script>
這里將會讀取服務端入口文件傳入 options 的 data 屬性,取到 title 值,渲染到對應 DOM 中;
再看看 Webpack 的配置,和客戶端渲染同樣是大同小異:
const webpack = require('webpack'); const path = require('path'); const projectRoot = __dirname; const env = process.env.NODE_ENV || 'development'; module.exports = { target: 'node', // 告訴 Webpack 是 node 代碼的打包 devtool: null, // 既然是 node 就不用 devtool 了 entry: { app: path.join(projectRoot, 'src/server.js') }, output: Object.assign({}, base.output, { path: path.join(projectRoot, 'src'), filename: 'bundle.server.js', libraryTarget: 'commonjs2' // 和客戶端不同 }), plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(env), 'process.env.VUE_ENV': '"server"' // 配置 vue 的環境變量,告訴 vue 是服務端渲染,就不會做耗性能的 dom-diff 操作了 }) ], resolve: { extensions: ['', '.js', '.vue'], fallback: [path.join(projectRoot, 'node_modules')] }, resolveLoader: { root: path.join(projectRoot, 'node_modules') }, module: { loaders: [ { test: /\.vue$/, loader: 'vue' }, { test: /\.js$/, loader: 'babel', include: projectRoot, exclude: /node_modules/ } ] } };
其中主要就是三處不同:聲明 node 模塊打包;修改打包后模塊加載方式為 commonjs(commonjs2 具體可以看 Webpack 官方文檔);再就是 vue 的服務端打包優化了,這部分如果不傳的話后面 vue 服務端渲染會慢至幾十秒,一度以為服務端代碼掛了。
最后就是服務端載入生成的 bundle.server.js 文件:
const fs = require('fs'); const path = require('path'); const vueServerRenderer = require('vue-server-renderer'); const filePath = path.join(__dirname, 'src/bundle.server.js'); // 讀取 bundle 文件,並創建渲染器 const code = fs.readFileSync(filePath, 'utf8'); const bundleRenderer = vueServerRenderer.createBundleRenderer(code); // 渲染 Vue 應用為一個字符串 bundleRenderer.renderToString(options, (err, html) => { if (err) { console.error(err); } content.replace('<div id="app"></div>', html); });
這里 options 可以傳入 vue 組件所需要的 data 等信息;下面還是以官方實例中的 express 來做服務端示例下:
const fs = require('fs'); const path = require('path'); const vueServerRenderer = require('vue-server-renderer'); const filePath = path.join(think.ROOT_PATH, 'view/bundle.server.js'); global.Vue = require('vue') // 讀取 bundle 文件,並創建渲染器 const code = fs.readFileSync(filePath, 'utf8'); const bundleRenderer = vueServerRenderer.createBundleRenderer(code); // 創建一個Express服務器 var express = require('express'); var server = express(); // 部署靜態文件夾為 "assets" 文件夾 server.use('/assets', express.static( path.resolve(__dirname, 'assets'); )); // 處理所有的 Get 請求 server.get('*', function (request, response) { // 設置一些數據,可以是數據庫讀取等等 const options = { data: { title: 'hello world' } }; // 渲染 Vue 應用為一個字符串 bundleRenderer.renderToString(options, (err, html) => { // 如果渲染時發生了錯誤 if (err) { // 打印錯誤到控制台 console.error(err); // 告訴客戶端錯誤 return response.status(500).send('Server Error'); } // 發送布局和HTML文件 response.send(layout.replace('<div id="app"></div>', html)); }); // 監聽5000端口 server.listen(5000, function (error) { if (error) throw error console.log('Server is running at localhost:5000') });
這樣子基本就是 Vue 服務端渲染的整個流程了,這樣子和使用普通的模板渲染並沒有什么其他的優勢,可是當渲染完成后再由客戶端接管渲染后就可以做到無縫切換了,下面我們來看看和客戶端結合渲染;
3. 和瀏覽器渲染無縫集合
為了和客戶端渲染結合,我們將 webpack 配置文件分為三部分,base 共用的配置,server 配置,client 瀏覽器端配置,如下:
webpack.base.js
const path = require('path'); const projectRoot = path.resolve(__dirname, '../'); module.exports = { devtool: '#source-map', entry: { app: path.join(projectRoot, 'src/client.js') }, output: { path: path.join(projectRoot, 'www/static'), filename: 'index.js' }, resolve: { extensions: ['', '.js', '.vue'], fallback: [path.join(projectRoot, 'node_modules')], alias: { 'Common': path.join(projectRoot, 'src/vue/Common'), 'Components': path.join(projectRoot, 'src/vue/Components') } }, resolveLoader: { root: path.join(projectRoot, 'node_modules') }, module: { loaders: [ { test: /\.vue$/, loader: 'vue' }, { test: /\.js$/, loader: 'babel', include: projectRoot, exclude: /node_modules/ } ] } };
webpack.server.js
const webpack = require('webpack'); const base = require('./webpack.base'); const path = require('path'); const projectRoot = path.resolve(__dirname, '../'); const env = process.env.NODE_ENV || 'development'; module.exports = Object.assign({}, base, { target: 'node', devtool: null, entry: { app: path.join(projectRoot, 'view/server.js') }, output: Object.assign({}, base.output, { path: path.join(projectRoot, 'view'), filename: 'bundle.server.js', libraryTarget: 'commonjs2' }), plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(env), 'process.env.VUE_ENV': '"server"', 'isBrowser': false }) ] });
服務端的配置,和之前多了一個 isBrowser
的全局變量,用於在 Vue 模塊中做一些差異處理;
webpack.client.js
const webpack = require('webpack'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); const base = require('./webpack.base'); const env = process.env.NODE_ENV || 'development'; const config = Object.assign({}, base, { plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(env), 'isBrowser': true }) ] }); config.vue = { loaders: { css: ExtractTextPlugin.extract({ loader: 'css-loader', fallbackLoader: 'vue-style-loader' }), sass: ExtractTextPlugin.extract('vue-style-loader', 'css!sass?indentedSyntax'), scss: ExtractTextPlugin.extract('vue-style-loader', 'css!sass') } }; config.plugins.push(new ExtractTextPlugin('style.css')); if (env === 'production') { config.plugins.push( new webpack.LoaderOptionsPlugin({ minimize: true }), new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } }) ); } module.exports = config;
服務端打包時候會忽略 css 等樣式文件,瀏覽器端打包時候就將樣式文件單獨打包到一個 css 文件。這樣在執行 webpack 時候就需要指定 --config
參數來編譯不同的版本了,如下:
# 編譯 客戶端 webpack --config webpack.client.js # 編譯 服務器端 webpack --config webpack.server.js
同樣,入口文件也提出三個文件,index.js, server.js, client.js
index.js
import Vue from 'vue'; import App from './vue/App'; import ClipButton from 'Components/ClipButton'; import Toast from 'Components/Toast'; Vue.filter('byte-format', value => { const unit = ['Byte', 'KB', 'MB', 'GB', 'TB']; let index = 0; let size = parseInt(value, 10); while (size >= 1024 && index < unit.length) { size /= 1024; index++; } return [size.toString().substr(0, 5), unit[index]].join(' '); }); Vue.use(Toast); Vue.component('maple-clip-button', ClipButton); const createApp = function createApp(options) { const VueApp = Vue.extend(App); return new VueApp(Object.assign({}, options)); }; export {Vue, createApp};
index.js 中做一些通用的組件、插件加載,一些全局的設置,最后返回一個可以生成 app 實例的函數供不同環境來調用;
server.js
import {createApp} from './index'; export default function (options) { const app = createApp(options); return new Promise(resolve => { resolve(app); }); }
大部分邏輯已經抽為共用了,所以服務端這里就是簡單將 app 實例通過 promise 返回;
client.js
import VueResource from 'vue-resource'; import {createApp, Vue} from './index'; Vue.use(VueResource); const title = 'Test'; const app = createApp({ data: { title }, el: '#app' }); export default app;
客戶端也類似,這里在客戶端加載 VueResource 這個插件,用於客戶端的 ajax 請求;通常通過 ajax 請求服務端返回數據再初始化 app,這樣基本就是一個單頁的服務端渲染框架了,一般情況下,我們做單頁應用還會配合 history.pushState
等通過 URL 做路由分發;這樣,我們服務端也最好使用同一套路由來渲染不同的頁面。
4. 服務端和瀏覽器路由共用
路由這里使用 vue-router 就可以了,瀏覽器端還是通過正常的方式載入路由配置,服務端同樣載入路由配置,並在渲染之前使用 router.push
渲染需要展現的頁面,所以,在通用的入口文件加入路由配置:
import Vue from 'vue'; import router from './router'; import App from './vue/App'; const createApp = function createApp(options) { const VueApp = Vue.extend(App); return new VueApp(Object.assign({ router }, options)); }; export {Vue, router, createApp};
路由文件是這樣子的:
import Vue from 'vue'; import VueRouter from 'vue-router'; import ViewUpload from '../vue/ViewUpload'; import ViewHistory from '../vue/ViewHistory'; import ViewLibs from '../vue/ViewLibs'; Vue.use(VueRouter); const routes = [ { path: '/', component: ViewUpload }, { path: '/history', component: ViewHistory }, { path: '/libs', component: ViewLibs } ]; const router = new VueRouter({mode: 'history', routes, base: __dirname}); export default router;
這里路由的使用 HTML5 的 history 模式;
服務端入口文件這樣配置:
import {createApp, router} from './index'; export default function (options) { const app = createApp({ data: options.data }); router.push(options.url); return new Promise(resolve => { resolve(app); }); }
這里在初始化 app 實例后,調用 router.push(options.url)
將服務端取到的 url push 到路由之中;
5. 使用中遇到的坑
整個過程還算順利,其中遇到最多的問題就是有些模塊只能在服務端或者瀏覽器來使用,而使用 ES6 模塊加載是靜態的,所以需要將靜態加載的模塊改為動態加載,所以就有了上面配置 isBrowser
這個全局屬性,通過這個屬性來判斷模塊加載了,比如我項目中用到的 clipboard 模塊,之前是直接使用 ES6 加載的;
<template> <a @click.prevent :href="text"><slot></slot></a> </template> <script> import Clipboard from 'clipboard'; export default { props: ['text'], mounted() { return this.$nextTick(() => { this.clipboard = new Clipboard(this.$el, { text: () => { return this.text; } }); this.clipboard.on('success', () => { this.$emit('copied', this.text); }); }); } }; </script>
這樣就會在服務端渲染時候報錯,將加載改為動態加載就可以了:
let Clipboard = null; if (isBrowser) { Clipboard = require('clipboard'); }
如果這個模塊在服務端並不會渲染,那后面的代碼並不需要更改;
還有 VueResource 插件也是需要瀏覽器環境的,所以需要將它單獨配置在 client.js 中;
6. 總結
同樣的路由通過 Vue 服務端渲染后的 HTML 總是一樣的,這和 React 渲染后會加上哈希不同,所以可以做渲染后結果的緩存優化,這部分可以參考官方文檔的做法,總的來說,Vue 服務端渲染沿襲了 Vue 客戶端的輕量做法,也顯得比較輕量,唯一不足之處可能就是服務端也同樣需要 webpack 來完成。
原文:https://blog.alphatr.com/how-to-use-ssr-in-vue-2.0.html