@(HTML/JS)css
这是Vue多页面框架系列文章的第二篇,上一篇(纯前端Vue多页面)中,咱们尝试从webpack-simple原型项目改造为一个多页面的Vue项目。而这里,咱们继续往前,尝试把Vue多页面改造为Nodejs直出。因为步骤较多,因此本文片幅较长。html
本文源代码:https://github.com/kenkozheng/HTML5_research/tree/master/Vue-SSR-Multipages-Webpack3前端
稍微详细的信息,你们能够参考官网:https://ssr.vuejs.org/zh/
还有官方的例子:https://github.com/vuejs/vue-hackernews-2.0
不过,文档写得并不详细,也没看到文档对应的代码在哪里;而例子呢,下载后没法运行(2017年12月上旬),也是有点麻烦。vue
我总结一下大概的运行步骤:node
那么从已有的多页面Vue框架出发,要作成多页面nodejs直出,咱们须要解决几个问题。webpack
从以前的纯浏览器运行建模+渲染,到如今拆分两个过程:Nodejs输出结构、浏览器端重建虚拟dom和绑定事件,这里必然须要修改已有的webpack打包配置。
官方提供了vue-server-renderer
组件。git
这个组件分为client-plugin
和server-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
工具,便于合并先后两份配置。
有几个关键点:
再看看客户端的修改。
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
这里作了几个关键事情:
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,确定太麻烦。
这里,借鉴了官方例子,能够简单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,另外把临时文件生成到内存中咱们本身主要须要修改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
接下来,咱们在已有基础上,再实现动态数据。这里列出我认为比较简单易懂的两种方式和相应例子,可能实现的方式有更多。
先考虑没有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。。。完成。。。
这里建了一个例子,模拟初始化时获取数据,而后再返回给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的数据。
运行测试,能够发现两种方式都能正常完成页面渲染。
上边提到的例子都只针对一个页面,由于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组件和插件。