Vue 2.x + Webpack 3.x + Nodejs 多頁面項目框架(下篇——多頁面VueSSR+熱更新Server)

Vue 2.x + Webpack 3.x + Nodejs 多頁面項目框架(下篇——多頁面VueSSR+熱更新Server)

@(HTML/JS)css

這是Vue多頁面框架系列文章的第二篇,上一篇(純前端Vue多頁面)中,咱們嘗試從webpack-simple原型項目改造爲一個多頁面的Vue項目。而這裏,咱們繼續往前,嘗試把Vue多頁面改造爲Nodejs直出。因爲步驟較多,因此本文片幅較長。html

本文源代碼: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月上旬),也是有點麻煩。vue

我總結一下大概的運行步驟:node

  • 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直出,咱們須要解決幾個問題。webpack

  • 一、怎麼打包爲Nodejs支持的js?
  • 二、在這個狀況下,客戶端部分是否要特殊打包?怎麼打包?
  • 三、使用什麼方式運行打包後的兩部分代碼,並生成最終的HTML?
  • 四、怎麼注入數據?客戶端又怎麼獲取數據做用於Vue?
  • 五、如何啓動項目?熱更新還能有效嗎?
    接下來就帶着這幾個問題,學習官方資料,看如何實現Vue的SSR。

2 Nodejs和瀏覽器分別打包

從以前的純瀏覽器運行建模+渲染,到如今拆分兩個過程:Nodejs輸出結構、瀏覽器端重建虛擬dom和綁定事件,這裏必然須要修改已有的webpack打包配置。
官方提供了vue-server-renderer組件。git

這個組件分爲client-pluginserver-plugin,分別用於客戶端和Nodejs部分的打包。針對這個狀況,咱們須要把webpack文件修改一下,把基礎部分抽離出來,把多餘部分去除(例如生成html的HtmlWebpackPlugin)。github


簡單看看webpack.base.config.jsweb

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和客戶端將有新的入口文件。vuex


而後,看看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

上述代碼作的是大概是:
一、讀入模版html文件、打包後的兩個json,從而生成bundleRenderer
二、建立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-middlewarewebpack-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組件和插件。


文章寫得乾貨很少,算是針對例子作的補充說明,間隙中提到了一些官方文檔中沒有說明的細節,但願對你們有所幫助。

相關文章
相關標籤/搜索