Vue 2.x + Webpack 3.x + Nodejs 多頁面項目框架(下篇——多頁面VueSSR+熱更新Server)
@(HTML/JS)
這是Vue多頁面框架系列文章的第二篇,上一篇(純前端Vue多頁面)中,我們嘗試從webpack-simple原型項目改造為一個多頁面的Vue項目。而這里,我們繼續往前,嘗試把Vue多頁面改造為Nodejs直出。由於步驟較多,所以本文片幅較長。
本文源代碼:https://github.com/kenkozheng/HTML5_research/tree/master/Vue-SSR-Multipages-Webpack3
1 認識原理
稍微詳細的信息,大家可以參考官網:https://ssr.vuejs.org/zh/
還有官方的例子:https://github.com/vuejs/vue-hackernews-2.0
不過,文檔寫得並不詳細,也沒看到文檔對應的代碼在哪里;而例子呢,下載后無法運行(2017年12月上旬),也是有點麻煩。
我總結一下大概的運行步驟:
- Nodejs運行vue組件輸出html片段:這一步,可以理解為虛擬dom運行在Nodejs環境,換算出html的字符串,很好理解。
- Nodejs把html片段拼接到整個HTML上:這里跟客戶端版本略有不同,上一篇文章中,我們針對多頁面生成了多個html,而這里因為有了Nodejs的動態輸出能力,就沒必要生成多個html了,只需要每次把動態部分拼接到模版html上即可。
- 對HTML注入數據:上一步有了HTML,但這個html只是死的字符串,到了瀏覽器解析后只能是普通的dom,無法啟動vue還原為虛擬dom。那么就需要原始的數據,好讓客戶端重建對應的虛擬dom。
- 瀏覽器運行vue重建虛擬dom:這一步跟之前純前端的vue架構類似,不同的是,vue會識別到div已經是服務器渲染好的,並不需要重新渲染dom結構,只需要重建虛擬dom,備好數據,綁定事件即可。
那么從已有的多頁面Vue框架出發,要做成多頁面nodejs直出,我們需要解決幾個問題。
- 1、怎么打包為Nodejs支持的js?
- 2、在這個情況下,客戶端部分是否要特殊打包?怎么打包?
- 3、使用什么方式運行打包后的兩部分代碼,並生成最終的HTML?
- 4、怎么注入數據?客戶端又怎么獲取數據作用於Vue?
- 5、如何啟動項目?熱更新還能有效嗎?
接下來就帶着這幾個問題,學習官方資料,看如何實現Vue的SSR。
2 Nodejs和瀏覽器分別打包
從之前的純瀏覽器運行建模+渲染,到現在拆分兩個過程:Nodejs輸出結構、瀏覽器端重建虛擬dom和綁定事件,這里必然需要修改已有的webpack打包配置。
官方提供了vue-server-renderer
組件。
這個組件分為client-plugin
和server-plugin
,分別用於客戶端和Nodejs部分的打包。針對這個情況,我們需要把webpack文件修改一下,把基礎部分抽離出來,把多余部分去除(例如生成html的HtmlWebpackPlugin
)。
簡單看看webpack.base.config.js
var path = require('path');
var webpack = require('webpack');
module.exports = {
output: {
path: path.resolve(__dirname, `../dist/`),
publicPath: '/dist/', //發布后在線訪問的url
filename: `[name].[hash:8].js` //'[name].[chunkhash].js', '[name].[hash:8].js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
],
}, {
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: 'img/[name].[hash:8].[ext]' //自動hash命名圖片等資源,並修改路徑。路徑需要根據項目實際情況確定。語法參考:https://doc.webpack-china.org/loaders/file-loader/
}
}
]
},
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['*', '.js', '.vue', '.json']
},
performance: {
hints: false
},
devtool: '#eval-source-map'
};
if (process.env.NODE_ENV === 'production') {
module.exports.devtool = '#source-map'
// http://vue-loader.vuejs.org/en/workflow/production.html
module.exports.plugins = (module.exports.plugins || []).concat([
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.optimize.UglifyJsPlugin({
//sourceMap: true, //開啟max_line_len后會有報錯,二選一
compress: {
warnings: false,
drop_debugger: true,
drop_console: true,
pure_funcs: ['alert'] //去除相應的函數
},
output: {
max_line_len: 100
}
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
]);
}
跟webpack-simple原型項目的配置沒什么差異。主要是去掉了entry的配置,因為針對nodejs和客戶端將有新的入口文件。
然后,看看Nodejs端怎么處理。
首先,需要新建一個新的app和entry文件。
app.js
import Vue from 'vue'
import App from './App.vue'
// import '../../css/base.css' //要寫到vue文件中
// 從客戶端渲染改為SSR
// new Vue({
// el: '#app',
// render: h => h(App)
// })
// 導出一個工廠函數,用於創建新的
// 應用程序、router 和 store 實例
export function createApp () {
const app = new Vue({
// 根實例簡單的渲染應用程序組件。
render: h => h(App)
})
return { app }
}
原來客戶端渲染是直接new Vue(),而這里改為export一個工廠方法,好讓后續服務器和客戶端分別用各自的方式創建。這里有個題外話,import css不能寫在這了,會導致nodejs運行時缺少document對象而報錯,需要寫到vue文件中。
然后是server-entry.js
import { createApp } from './app'
export default context => {
const { app } = createApp()
return app
}
就是簡單創建Vue實例,然后返回。這個函數接受context參數,是vue-server-renderer傳入的,往context中塞數據,可以作用於最終生成的HTML,例如注入數據,這個稍后再說明。
接着再看webpack的配置。
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(base, {
target: 'node',
devtool: '#source-map',
entry: './web/pages/page1/entry-server.js',
output: {
filename: `[name].[hash:8].js`,
libraryTarget: 'commonjs2'
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
配置不多,利用webpack-merge
工具,便於合並前后兩份配置。
有幾個關鍵點:
- target: 'node'。這個讓webpack針對nodejs的module做處理。
- output的libraryTarget:設置module的具體引用方式。
- plugins中加入VueSSRServerPlugin:這個插件會讓文件最后打包為一個json,用於后續運行時讀入到Vue的vue-server-renderer中
再看看客戶端的修改。
client-entry.js
import { createApp } from './app'
// 客戶端特定引導邏輯……
const { app } = createApp()
// 這里假定 App.vue 模板中根元素具有 `id="app"`(服務器渲染后就有這個id)
app.$mount('#app')
跟服務器的略有不同,這個是針對瀏覽器運行的代碼,創建Vue實例后,就手工掛載到已存在的節點#app上。
webpack的配置也要相應處理:
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = merge(base, {
entry: {
app: `./web/pages/page1/entry-client.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"'
}),
// extract vendor chunks for better caching
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
// a module is extracted into the vendor chunk if...
return (
// it's inside node_modules
/node_modules/.test(module.context) &&
// and not a CSS file (due to extract-text-webpack-plugin limitation)
!/\.css$/.test(module.request)
)
}
}),
// extract webpack runtime & manifest to avoid vendor chunk hash changing
// on every build.
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
new VueSSRClientPlugin()
]
})
module.exports = config
這里做了幾個關鍵事情:
- entry指向客戶端打包入口
- 利用chunkPlugin生成vendor.js,抽離部分庫文件
- 生成manifest文件,記錄文件名
- VueSSRClientPlugin,這個插件生成vue-ssr-client-manifest.json,記錄頁面所有依賴文件列表,在生成最終HTML時方便注入相應的js鏈接和css鏈接。
3 服務器運行
Nodejs端,我們需要引入vue-server-renderer
。
主要代碼如下:
const { createBundleRenderer } = require('vue-server-renderer');
const createRenderer = (bundle, options) => createBundleRenderer(bundle, Object.assign(options, {
// for component caching
cache: LRU({
max: 1000,
maxAge: 1000 * 60 * 15
}),
// recommended for performance
runInNewContext: false
}));
const templatePath = resolve('../web/tpl.html');
const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json')
let renderer = createRenderer(bundle, {
template,
clientManifest
});
let render = (req, res) => {
//context是一個對象,在模版中,使用<title>{{ title }}</title>方式填充 https://ssr.vuejs.org/zh/basic.html
let context = {title: 'VueSSR Multipages'};
renderer.renderToString(context, (err, html) => {
if (err) {
console.log(err);
res.status(500).end('Internal Server Error');
return
}
res.send(html);
res.end();
});
};
詳細代碼請查github:
https://github.com/kenkozheng/HTML5_research/blob/master/Vue-SSR-Single-Page-Webpack3/server/server.js
上述代碼做的是大概是:
1、讀入模版html文件、打包后的兩個json,從而生成bundleRenderer
2、創建render函數,接受req和res(例如用於express),使用renderToString方法,簡單把整個網頁拼裝好返回。其中context是作用於模版html的參數對象,用法跟普通的模版引擎類似。例如填充title:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{title}}</title>
</head>
<body>
<!--The template should contain a comment <!--vue-ssr-outlet- -> which serves as the placeholder for rendered app content.-->
<!--vue-ssr-outlet-->
</body>
</html>
順帶說一句,HTML中需要有特殊標記<!--vue-ssr-outlet-->
,用於替換為動態的Vue html片段。
vue-server-renderer會自動向模版填充js和css的外鏈。這個是默認的行為,如果想要把各種js和css做特殊處理,或輸出更多內容,可以參考手工注入:
https://ssr.vuejs.org/zh/build-config.html#manual-asset-injection
如果想更進一步,例如css、js打入html中,還可以拋棄template(createRenderer時不傳入template),改為自行拼接html,只需要renderer返回vue的html片段。
至此,粗略的SSR就已經完成了。
project.json中加入
"scripts": {
"start": "cross-env NODE_ENV=production node server/server",
"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"
},
先npm run build
,然后npm start
就可以了。
跟上一篇文章完成的架構不一樣,這里不通過webpack-dev-server啟動,所以沒有熱更新的功能。對於實際開發而言,每次修改都要build再run,肯定太麻煩。
4 搭建熱更新功能
這里,借鑒了官方例子,可以簡單copy setup-dev-server.js
。
setup-dev-server.js的代碼比較長,就不列出來了。github:https://github.com/kenkozheng/HTML5_research/blob/master/Vue-SSR-Single-Page-Webpack3/build/setup-dev-server.js
實現原理跟webpack-dev-server是相同的,基於express的服務。做的主要是:
- 引入
webpack-hot-middleware
和webpack-dev-middleware
,建立客戶端和服務器之間熱更新websocket,另外把臨時文件生成到內存中 - 使用webpack和chokidar,監控vue、js、html等的變化
- 實現了異步的編譯回調和不斷的監控
我們自己主要需要修改server.js,判斷是否開發環境。如果是,則使用dev-server特殊的renderer。
const devServerSetup = require('../build/setup-dev-server');
let renderer;
var promise = devServerSetup(server, templatePath, (bundle, options) => {
renderer = createRenderer(bundle, options); //刷新renderer
});
render = (req, res) => {
promise.then(() => baseRender(renderer, req, res)); //需要等待文件初始化
};
devServerSetup每次callback都返回最新的bundle和clientManifest,用於刷新renderer。
那么,使用node server/server
就能啟動熱更新服務器了。
到這里,我們實現了一個沒有動態數據的SSR版本,方便初學者對整個概念的理解。代碼在:https://github.com/kenkozheng/HTML5_research/tree/master/Vue-SSR-Single-Page-Webpack3
5 數據注入
接下來,我們在已有基礎上,再實現動態數據。這里列出我認為比較簡單易懂的兩種方式和相應例子,可能實現的方式有更多。
情況1:不使用Vuex
先考慮沒有Vuex的情況,只是簡單粗暴的組件式從上往下傳遞數據。這個情況適合一些簡單頁面,純粹的展示信息和幾個簡單的點擊處理。
各個文件,我們都稍作修改。
app.vue
<script>
export default {
name: 'app2',
props: ['appData'],
methods: {
}
}
</script>
vue的寫法從原來固定data,改為從父節點傳入的props標簽(appData)獲取數據。
app.js
export function createApp (data) {
const app = new Vue({
components: {App}, //演示如何從初始化地方傳遞數據給子組件。這個頁面不使用vuex,展示簡單粗暴的方式,配合global event bus即可https://vuejs.org/v2/guide/components.html#Non-Parent-Child-Communication
template: '<App :appData="appData"/>',
data: {
//數據先在服務器渲染一遍,到了客戶端會在重建一遍,如果客戶端部分數據不一致,會重新渲染
appData: data
},
mounted : function () {
console.log('mounted')
}
});
return { app };
}
entry-server.js
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
setTimeout(() => { //模擬拉取接口獲取數據
var data = {
msg: 'page1 data'
};
context.state = data; //生成到tpl.html中作為瀏覽器端全局變量
const { app } = createApp(data);
resolve(app);
}, 100);
//reject({code: 500}); //對應server.js的baseRender方法
})
}
server除了像之前那樣直接返回app還可以返回promise對象,從而實現異步處理。關鍵點是把data賦值給context.state。state會被自動注入到html中,作為全局js變量__INITIAL_STATE__
。
entry-client.js
import { createApp } from './app'
const { app } = createApp(__INITIAL_STATE__)
app.$mount('#app')
最后在client的代碼中,拿到這個全局對象,並賦值給Vue。。。完成。。。
情況2:使用Vuex
這里建了一個例子,模擬初始化時獲取數據,然后再返回給Server去渲染。
先建立一個Store
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);
import { getData } from 'page2Data' //一個別名,不指向具體的js,需要在webpack配置中alias指定,目的是讓瀏覽器端和nodejs端引入不同的文件,實現不同的獲取方式
export function createStore () {
return new Vuex.Store({
//state就是數據
state: {
msg: 'default'
},
//通過事件觸發action的函數,而不是直接調用
actions: {
//vue文件中調用getData時,傳入id。commit是vuex內部方法
getData ({ commit }, id) {
return getData(id).then(data => {
commit('setMsg', data.msg) //調用mutations的方法
})
},
setData ({ commit }, msg) {
commit('setMsg', msg) //調用mutations的方法
},
},
//mutations做所有數據的修改
mutations: {
setMsg (state, msg) {
state.msg = msg;
}
}
})
}
上述代碼使用了page2Data別名,利用webpack的alias功能,可以快速實現一份代碼,同時對接瀏覽器和服務器不同的數據獲取方式。這也許就是“同構”的一種思路吧,有利於客戶端做一些刷新邏輯時,不需要整個頁面重載。
app.vue
<script>
export default {
name: 'app',
methods: {
change (event) {
this.$store.dispatch('setData', 'hello click');
}
},
/**
* 動態類型數據
*/
computed: {
msg () {
return this.$store.state.msg
}
}
}
</script>
app.js
import Vue from 'vue'
import App from './App.vue'
import {createStore} from './store.js'
export function createApp () {
const store = createStore();
const app = new Vue({
store,
// 根實例簡單的渲染應用程序組件。
render: h => h(App)
});
return { app, store }
}
Vue使用store,而不是組件式的傳遞數據。
entry-server.js
export default context => {
return new Promise((resolve, reject) => {
setTimeout(() => { //模擬拉取接口獲取數據
const {app, store} = createApp();
// 調用store actions的方法
store.dispatch('getData', 'page2').then(() => {
context.state = store.state; //生成到tpl.html中作為瀏覽器端全局變量
resolve(app);
}).catch(reject);
}, 100);
})
}
初始化時,調用store的方法,獲得數據后再返回渲染。跟不用Vuex類似,數據也是塞到context.state中。
entry-client.js
// 客戶端特定引導邏輯……
const { app, store } = createApp();
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// 這里假定 App.vue 模板中根元素具有 `id="app"`(服務器渲染后就有這個id)
app.$mount('#app')
客戶端手工設置store的數據。
運行測試,可以發現兩種方式都能正常完成頁面渲染。
6 多頁面並存
上邊提到的例子都只針對一個頁面,因為webpack后,生成的vue-ssr-client-manifest.json等都只有一份。我們需要做一些優化。
既然是多頁面Nodejs,那肯定需要一個路由表。我們可以在路由表中配置訪問url(express正則)和代碼目錄。例如:
router.js
module.exports = {
'page1': {
url: '/page1.html', //訪問的url規則,用於express的get
dir: './web/pages/page1', //頁面目錄,默認有app.js作為入口
title: 'Page1', //生成html的title
template: './web/pages/page1/tpl.html' //特殊指定一個html
},
'page2': {
url: '/page2.html', //訪問的url規則,用於express的get
dir: './web/pages/page2', //頁面目錄,默認有app.js作為入口
title: 'Page2' //生成html的title
}
}
然后根據每個頁面,動態生成相應的webpack配置,用於build和dev-server。
const isProd = process.env.NODE_ENV === 'production';
let webpackConfigMap = {};
for (let pageName in router) {
let config = router[pageName];
let cConfig = merge({}, clientConfig, {
entry: {
[pageName]: `${config.dir}/entry-client.js` //buildEntryFiles生成的配置文件
},
output: {
filename: isProd ? `js/${pageName}/[name].[chunkhash:8].js` : `js/${pageName}/[name].js` //dist目錄
},
plugins: [
new VueSSRClientPlugin({
filename: `server/${pageName}/vue-ssr-client-manifest.json`//dist目錄
})
]
});
let sConfig = merge({}, serverConfig, {
entry: {
[pageName]: `${config.dir}/entry-server.js` //buildEntryFiles生成的配置文件
},
plugins: [
new VueSSRServerPlugin({
filename: `server/${pageName}/vue-ssr-server-bundle.json` //dist目錄
})
]
});
webpackConfigMap[pageName] = {clientConfig: cConfig, serverConfig: sConfig};
}
這里關鍵點是動態設置entry和設置VueSSRClientPlugin/VueSSRServerPlugin的filename。
filename這個字段官方文檔是沒有的,不過,node_modules基本都能找到源碼,可以發現有這個動態設置的辦法。
通過上述配置,讓瀏覽器使用的js和服務器打包后的json文件分開,便於設置訪問權限,防止服務器信息泄漏。build之后的dist目錄結構如下所示:
相應的,server.js中運行時和build的腳本都需要調整。
server.js
for (let pageName in router) {
let pageConfig = router[pageName];
server.get(pageConfig.url, ((pageName) => {
return (req, res) => {
render(pageName, req, res);
}
})(pageName));
}
server是express實例,設置路由時,創建閉包,每個處理器都能帶上對應的pageKey,從而訪問對應的renderer。
build.js
const appEntry = require('./multipageWebpackConfig');
const webpack = require('webpack');
console.log('building...');
for (var page in appEntry) {
webpack(appEntry[page].clientConfig, ()=>{});
webpack(appEntry[page].serverConfig, ()=>{});
}
build改為我們自建的js腳本。
至此,一個多頁面VueSSR就完成了,后續可以根據項目的具體情況添加實際的Vue組件和插件。