webpack4从零开始构建(一)
webpack4+React16项目构建(二)
webpack4功能配置划分细化(三)
webpack4引入Ant Design和Typescript(四)
webpack4代码去重,简化信息和构建优化(五)
webpack4配置Vue版脚手架(六)javascript
服务器渲染 --- Vue+Koa从零搭建成功输出页面
服务器渲染 --- 数据预取和状态
本文最终代码仓库在Vue-ssr-demo/demo1css
yarn add --dev vue-server-renderer koa Vue
这些是实现服务器渲染的关键库,先安装,而后建立一个server.js
建立Vue实例并输出步骤:html
Koa实例
,接收请求返回数据Vue实例
vue-server-renderer
建立一个 Renderer 实例, 将 Vue 实例渲染为字符串插入HtmlHtml
返回const Koa = require('koa') const Vue = require('Vue') const renderer = require('vue-server-renderer').createRenderer() // 建立Koa实例 const app = new Koa() app.use(async ctx => { // 建立Vue实例 const app = new Vue({ template: `<div>SSR_DEMO</div>` }) // 将 Vue 实例渲染为字符串, 回调函数第一个参数是可能抛出的错误,第二个参数是渲染完毕的字符串. renderer.renderToString(app, (err, html) => { // 发生错误输出500 if (err) { ctx.throw(500, 'Internal Server Error') return } // 响应返回html格式 ctx.body = (` <!DOCTYPE html> <html lang="en"> <head><title>demo</title></head> <body>${html}</body> </html> `) }) }).listen(3000); console.log('已创建链接,效果请看http://127.0.0.1:3000/');
保存以后,打开终端运行文件vue
node server.js // 已创建链接,效果请看http://127.0.0.1:3000/
打开浏览器访问地址,输出SSR_DEMO
文字咱们就算完成第一步了java
简单搭建一个Vue+Webpack4的demo,大体目录以下node
里面东西不少,咱们不用一下都看完,先慢慢补起来,webpack4的基本配置就不说了,只说关键位置webpack
自定义的模块简化路径git
const path = require("path"); // 建立 import 或 require 的别名,来确保模块引入变得更简单 module.exports = { "@": path.resolve(__dirname, "../src/"), IMG: path.resolve(__dirname, "../src/img"), ROUTER: path.resolve(__dirname, "../src/router"), VUEX: path.resolve(__dirname, "../src/vuex"), PAGE: path.resolve(__dirname, "../src/page"), CMT: path.resolve(__dirname, "../src/component"), };
渲染基本界面导航切换验证github
<template> <div id="app"> <h2>欢迎来到SSR渲染页面</h2> <router-link to="/view1">view1</router-link> <router-link to="/view2">view2</router-link> <router-view></router-view> </div> </template> <script> export default {}; </script>
因为没有动态更新,全部的生命周期钩子函数中,只有 beforeCreate
和 created
会在服务器端渲染 (SSR) 过程当中被调用.这就是说任何其余生命周期钩子函数中的代码,只会在客户端执行.你应该避免在 beforeCreate
和 created
生命周期时产生全局反作用的代码,例如定时器,由于没法在beforeDestroy
或 destroyed
清除.web
下面的输出用于测试
<template> <div> <p>Page1</p> </div> </template> <script> export default { created() { console.log('created') }, mounted() { console.log('mounted') }, }; </script>
避免使用特定平台的 API,例如window
或 document
,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此, 官方推荐方案:
<template> <div> <p>Page2</p> </div> </template> <script> export default { created() { try { console.log(window); } catch (err) { console.log(err); } }, }; </script>
使用history
模式方便服务器渲染.
路由作惰性加载,有助于减小浏览器在初始渲染中下载的资源体积
由于客户端和服务端要共用同一份路由配置,因此不要直接导出实例,而是导出一个建立函数
import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) export default function createRouter () { return new Router({ // 要记得增长mode属性,由于#后面的内容不会发送至服务器,服务器不知道请求的是哪个路由 mode: 'history', routes: [ { // 首页 alias: '/', path: '/view1', component: () => import('../page/view1.vue') }, { path: '/view2', component: () => import('../page/view2.vue') }, { path: '*', redirect: '/view1' } ] }) }
服务端针对每一个请求都应该建立一个全新独立的Vue实例,由于它们须要在服务器里预先请求对应的数据,这样能够避免状态污染
// app.js import Vue from 'vue' import App from './App.vue' import createRouter from './router' export default function createApp () { // 建立 router 实例 const router = createRouter() const app = new Vue({ // 注入 router 到根 Vue 实例 router, render: (h) => h(App) }) // 返回 app 和 router return { app, router } }
由于新版的HtmlWebpackPlugin
不支持html变量编译,须要转成ejs
模板,而后直接输出html
格式
做为浏览器渲染的模板,很是常规的一种写法,无需复述
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title><%= htmlWebpackPlugin.options.title %></title> </head> <body> <div id="app"></div> </body> </html>
服务器渲染页面模板,注意 <!--vue-ssr-outlet-->
注释, 这里将是应用程序 HTML 标记注入的地方,很重要!!!
里面引入的变量htmlWebpackPlugin.options.files.js
后面再详解
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title><%= htmlWebpackPlugin.options.title %></title> </head> <body> <!--vue-ssr-outlet--> <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script> </body> </html>
官方图例
Server Bundle
和Client Bundle
上面说的混合静态标记,由于服务器已经预先渲染好静态HTMl给到客户端,即Vue在浏览器接管由服务端发送的静态HTML,使其变成由Vue管理的动态DOM过程.
客户端会直接挂载到根元素
// 这里假定 App.vue template 根元素的 `id="app"` app.$mount('#app')
而从服务端获取到的HTML里能够看到该根元素多了特殊属性
<div id="app" data-server-rendered="true">
这属性是让客户端知道这部分HTML是有服务器渲染无需再执行,而是应该以激活模式进行挂载.
在没有该属性的状况下也还能够向 $mount
函数的 hydrating
参数位置传入 true
,来强制使用激活模式(hydration):
// 强制使用应用程序的激活模式 app.$mount('#app', true)
在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。若是没法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以免性能损耗。
切记: 浏览器可能会更改的一些特殊的 HTML 结构
客户端的入口文件只需建立应用程序,而且将其挂载到 DOM 中, 在路由完成初始导航时调用,这意味着它能够解析全部的异步进入钩子和路由初始化相关联的异步组件,这能够有效确保服务端渲染时服务端和客户端输出的一致.
import createApp from '../src/app' const { app, router } = createApp() // 路由完成初始导航时调用 router.onReady(() => { // 挂载App.vue模板中根元素 app.$mount('#app') })
服务器的入口文件作了如下几个步骤:
getMatchedComponents
返回当前路由匹配的组件数组import createApp from '../src/app' export default (context) => { // 由于有可能会是异步路由钩子函数或组件,因此咱们将返回一个 Promise, // 以便服务器可以等待全部的内容在渲染前, // 就已经准备就绪. return new Promise((resolve, reject) => { const { app, router } = createApp() // 设置服务器端 router 的位置 router.push(context.url) // 等到 router 将可能的异步组件和钩子函数解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents() // 匹配不到的路由,执行 reject 函数,并返回 404 if (!matchedComponents.length) { return reject({ code: 404 }) } // Promise 应该 resolve 应用程序实例,以便它能够渲染 resolve(app) }, reject) }) }
经过使用 webpack 的自定义插件,server bundle将生成为可传递到 bundle renderer 的特殊 JSON 文件,它相比直接打包成js有如下优点:
主要是设置client
和server
的全部关键配置了
const path = require('path') const isDev = process.env.NODE_ENV === 'DEV' const isProd = true || process.env.NODE_ENV === 'PROD' const isServer = process.env.NODE_ENV === 'SERVER' const client = { entry: { client: path.resolve(__dirname, '../entry/entry-client.js') }, output: { // 打包文件名 filename: 'bundle.client.js', // 输出路径 path: path.resolve(__dirname, '../dist/client'), // 资源请求路径 publicPath: '/' }, htmlPluginOpt: { title: "浏览器渲染", // 本地模板文件的位置 template: path.resolve(__dirname, '../ejs/client.ejs'), // 输出文件的文件名称 filename: 'client.html' } } const server = { entry: { server: path.resolve(__dirname, '../entry/entry-server.js') }, output: { // 打包文件名 filename: 'bundle.server.js', // 输出路径 path: path.resolve(__dirname, '../dist/server'), // 资源请求路径 publicPath: '/', // 导出的是 module.exports.default libraryTarget: 'commonjs2' }, htmlPluginOpt: { title: "服务端渲染", // 本地模板文件的位置 template: path.resolve(__dirname, '../ejs/server.ejs'), // 输出文件的文件名称 filename: 'server.html', // webpack的stats对象的assetsByChunkName属性表明的值 files: { js: 'bundle.client.js' }, // 不容许注入 excludeChunks: ['server'] } } const title = 'test' module.exports = { isDev, isProd, isServer, client, server, title }
须要注意的是server.htmlPluginOpt
的配置,它控制模板禁止注入自己的chunk
,而后手动注入客户端的bundle
,
客户端执行入口,忽略一些webpack的配置,最终生成客户端构建清单vue-ssr-client-manifest.json
文件
const path = require('path') const HtmlWebpackPlugin = require('html-webpack-plugin') const merge = require('webpack-merge') const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const common = require('./webpack.common.js') const dev_conf = require('./webpack.dev.js') const { client } = require('./env') module.exports = merge(common, dev_conf, { // 入口 entry: client.entry, // 输出 output: client.output, plugins: [ // 生成客户端构建清单 (client build manifest) // 默认文件名为 `vue-ssr-client-manifest.json` new VueSSRClientPlugin(), new HtmlWebpackPlugin(client.htmlPluginOpt) ] })
服务端执行入口,跟客户端相比有几个不一样
node
CommonJS
环境webpack-node-externals'
,将须要打包的模块加入白名单vue-ssr-server-bundle.json
文件CommonsChunkPlugin
const path = require('path') const HtmlWebpackPlugin = require('html-webpack-plugin') const merge = require('webpack-merge') const nodeExternals = require('webpack-node-externals') const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') const common = require('./webpack.common.js') const dev_conf = require('./webpack.dev.js') const { server } = require('./env') module.exports = merge(common, dev_conf, { // 入口 entry: server.entry, // 输出 output: server.output, // 对 bundle renderer 提供 source map 支持 devtool: 'source-map', // 这容许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import), // 而且还会在编译 Vue 组件时, // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。 target: 'node', externals: nodeExternals({ // 不要外置化 webpack 须要处理的依赖模块。 // 你能够在这里添加更多的文件类型。例如,未处理 *.vue 原始文件, // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单 whitelist: /\.css$/ }), // 这是将服务器的整个输出 // 构建为单个 JSON 文件的插件。 // 默认文件名为 `vue-ssr-server-bundle.json` plugins: [ new VueSSRServerPlugin(), new HtmlWebpackPlugin(server.htmlPluginOpt) ] })
在package.json
里咱们配置几个简单的命令
"scripts": { "client": "cross-env NODE_ENV=PROD webpack --config ./config/webpack-client.js", "server": "cross-env NODE_ENV=PROD webpack --config ./config/webpack-server.js", "build": "yarn client && yarn server", "start": "node server", "rnm": "rimraf node_modules" },
运行命令,生成dist/client/vue-ssr-client-manifest.json
和dist/server/vue-ssr-server-bundle.json
yarn build
服务器官方教程选择Express
,可是我以为过重了,换成同个团队开发的Koa
createBundleRenderer
建立一个 BundleRenderer 实例const path = require('path') const Router = require('koa-router') const router = new Router() const { createBundleRenderer } = require('vue-server-renderer') const { client, server } = require('../config/env') // 服务器 bundle const serverBundle = require(`${server.output.path}/vue-ssr-server-bundle.json`); // 客户端清单, 自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive),以及 css 连接 / script 标签到所渲染的 HTML const clientManifest = require(`${client.output.path}/vue-ssr-client-manifest.json`); const template = require('fs').readFileSync(path.resolve(__dirname, '../dist/server/ssr.html'), 'utf-8'); const renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推荐, 默认状况下,对于每次渲染,bundle renderer 将建立一个新的 V8 上下文并从新执行整个 bundle template, clientManifest, // (可选)客户端构建 manifest }); class Server { static async createHtml (ctx, next) { // 将 Vue 实例渲染为字符串, 回调函数第一个参数是可能抛出的错误,第二个参数是渲染完毕的字符串。 try { const html = await renderer.renderToStream({ url: ctx.request.url }) ctx.status = 200 ctx.type = 'html' ctx.body = html } catch (err) { console.log('err: ', err) ctx.throw(500, 'Internal Server Error') } } } router.get('*', Server.createHtml) module.exports = router
const path = require('path') const Koa = require('koa') const logger = require('koa-logger') const serve = require('koa-static') const router = require('./router') // 建立Koa实例 const app = new Koa() app .use(logger()) .use(serve(path.resolve(__dirname, '../dist/client'))) .use(router.routes()) .use(router.allowedMethods()) .listen(3005) console.log('已创建链接,效果请看http://127.0.0.1:3005/')
运行文件启动服务器便可查看效果
yarn start