1. bundle文件解析
我们简单来看下构建生成的两个文件都做了什么
1.1 vue-ssr-server-bundle.json
serverBundle有三部分内容:
entry: 服务端打包入口文件
files: 这部分内容其实就是入口文件打包后的代码
maps: 入口文件的sourceMap映射
{
// 入口文件
"entry": "server.bundle.js",
"files": { // server.bundle.js打包为了以下代码
"0.bundle.js": "exports.ids = [0];\nexports.modules = {\n...."
},
// 映射
"maps": {
"0.bundle.js": {
"version": 3,
"sources": [
"webpack:///src/components/Foo.vue",
"webpack:///./src/components/Foo.vue?482d",
"webpack:///./src/components/Foo.vue?9694",
"webpack:///./src/components/Foo.vue?4c83",
"webpack:///./src/components/Foo.vue",
"webpack:///./src/components/Foo.vue?c0f4",
"webpack:///./src/components/Foo.vue?1b8e",
"webpack:///./src/components/Foo.vue?1efa"
],
"names": [],
}
}
}
1.2 vue-ssr-client-manifest.json
作用:进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk
publicPath: webpack output中设置的publicPath
all: 打包后的所有静态资源文件路径(包括map文件)
initial: 页面初始化时需要加载的文件(需要注入到模版页面中的资源),会在页面加载时配置到 preload 中,
async: 页面跳转时需要加载的文件(异步路由),会在页面加载时配置到 prefetch 中
modules: 项目的各个模块包含的文件的序号,对应 all 中文件的顺序;moduleIdentifier和 和all数组中文件的映射关系(modules对象是我们查找文件引用的重要数据)
注意:
preload:预加载的资源是本页面js,注意只是下载,并不执行,下载js不会堵塞页面渲染,真正执行是在body底部,页面渲染完成后
prefetch:加载的是下一个页面的js, 浏览器空闲时候再加载,不一定加载成功
都是加载资源文件,不是执行
{
"publicPath": "/", webpack output中设置的publicPath
"all": [ // 所有打包后的js文件名称
"0.bundle.js",
"0.bundle.js.map"
...
],
"initial": [ // 需要注入到模版页面中的资源
"vendor.bundle.js",
"client.css",
"client.bundle.js"
],
"async": [ // 异步资源信息
"0.bundle.js",
"0.css",
"1.bundle.js"
],
"modules": { // 原始模块的依赖信息
"14534400": [ // 模块标识
4, // 对应all里的文件索引
3
],
}
}
2. server-entry在哪里执行
const { createBundleRenderer } = require('vue-server-renderer')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
template,
clientManifest
})
let context = {}
const html = await renderer.renderToString(context)
我们看以上代码,这里的context参数就是server-entry.js中的context行参
export default async context => {
const { app, router, store } = createApp()
return app
}
3. 模版
3.1 注入
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
注意 <!--vue-ssr-outlet--> 注释 -- 这里将是应用程序 HTML 标记注入的地方
const app = new Vue({
data: {
url: req.url
},
template: `<div>访问的 URL 是: {{ url }}</div>`
})
const renderer = require('vue-server-renderer').createRenderer({
template: require('fs').readFileSync('./index.template.html', 'utf-8')
})
renderer.renderToString(app, (err, html) => {
console.log(html) // html 将是注入应用程序内容的完整页面
})
3.2 插值
<html>
<head>
<!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
<title>{{ title }}</title>
<!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
{{{ meta }}}
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
我们可以通过传入一个"渲染上下文对象",作为 renderToString 函数的第二个参数,来提供插值数据:
const context = {
title: 'hello',
meta: `
<meta ...>
<meta ...>
`
}
renderer.renderToString(app, context, (err, html) => {
// 页面 title 将会是 "Hello"
// meta 标签也会注入
})
4. 客户端激活
<div id="app" data-server-rendered="true">
添加data-server-rendered 让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。
注意:这里并没有添加 id="app",我们需要自行添加 ID 或其他能够选取到应用程序根元素的选择器,否则应用程序将无法正常激活。
注意:在没有 data-server-rendered 属性的元素上,还可以向 $mount 函数的 hydrating 参数位置传入 true,来强制使用激活模式(hydration):
// 强制使用应用程序的激活模式
app.$mount('#app', true)
注意:在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗
详细参考:ssr.vuejs.org/zh/guide/hy…
5. 编写通用代码
5.1 数据响应
我们在服务器上需要“预取”数据 ("pre-fetching" data) - 即在渲染前,数据已经拿到了。所以将数据进行响应式的过程在服务器上是多余的,默认情况下禁用。禁用响应式数据,还可以避免将「数据」转换为「响应式对象」的性能开销
5.2 组件生命周期钩子函数
由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染 (SSR) 过程中被调用
应该避免在 beforeCreate 和 created 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。因为在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。为了避免这种情况,最好将副作用代码移动到 beforeMount 或 mounted 生命周期中
5.3 特定API
像 window 或 document,这种仅浏览器可用的全局变量,会在 Node.js 中执行时抛出错误
5.4 自定义指令
大多数自定义指令直接操作 DOM,因此会在服务器端渲染 (SSR) 过程中导致错误。有两种方法可以解决这个问题:
-
- 推荐使用组件作为抽象机制,并运行在「虚拟 DOM 层级(Virtual-DOM level)」(例如,使用渲染函数(render function))。
-
- 如果你有一个自定义指令,但是不是很容易替换为组件,则可以在创建服务器 renderer 时,使用 directives 选项所提供"服务器端版本(server-side version)"
详细参考:ssr.vuejs.org/zh/guide/un…
6. 数据预取和渲染
-
- 在开始渲染过程之前,需要先预取和解析好数据
-
- 服务端取到的数据必须同步到客户端 - 否则,客户端应用程序会因为使用与服务器端应用程序数据不同,导致混合失败。
为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器中。在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,并在 HTML 中序列化和内联预置。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置状态。
注意:beforeCreate 和 created虽然可以在服务端执行,但是这样拿数据是无效的,因为获取数据是异步的,服务端渲染不会等待beforeCreate 和 created的异步操作,而且不支持响应式数据,所以以下操作是无效的
async created () {
console.log('Posts Created Start')
const { data } = await axios({
method: 'GET',
url: 'https://cnodejs.org/api/v1/topics'
})
this.posts = data.data
console.log('Posts Created End')
}
我们通过vuex获取数据
6.1 serverPrefetch
注意:在服务端获取数据必须返回一个Promise,因为服务端必须等待action执行完后再执行渲染操作
actions: {
// 在服务端渲染期间务必让 action 返回一个 Promise
async getPosts ({ commit }) {
// return new Promise()
const { data } = await axios.get('https://cnodejs.org/api/v1/topics')
commit('setPosts', data.data)
}
}
然后在serverPrefetch钩子中调用
// Vue SSR 特殊为服务端渲染提供的一个生命周期钩子函数
serverPrefetch () {
// 发起 action,返回 Promise
// this.$store.dispatch('getPosts')
return this.getPosts()
},
6.2 ssyncData
在路由组件上暴露出一个自定义静态函数 asyncData。
注意: 由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去
asyncData ({ store, route }) {
// 触发 action 后,会返回 Promise
return store.dispatch('fetchItem', route.params.id)
},
server-entry.js中添加asyncData
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 对所有匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
6.3 客户端预取数据
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
详细参考:ssr.vuejs.org/zh/guide/da…
7. head管理
建议使用vue-meta
7.1 在页面组件中设置
<template>
<div>
<h1>About Page</h1>
</div>
</template>
<script>
export default {
name: 'AboutPage',
metaInfo: {
title: '关于'
}
}
</script>
<style>
</style>
7.2 main.js中引入
import VueMeta from 'vue-meta'
Vue.use(VueMeta)
Vue.mixin({
metaInfo: {
titleTemplate: '%s - 拉勾教育'
}
})
7.3 在服务端渲染入口模块中适配
// entry-server.js
import { createApp } from './app'
export default async context => {
const { app, router, store } = createApp()
const meta = app.$meta()
// 设置服务器端 router 的位置
router.push(context.url)
context.meta = meta
// 等到 router 将可能的异步组件和钩子函数解析完
await new Promise(router.onReady.bind(router))
context.rendered = () => {
// Renderer 会把 context.state 数据对象内联到页面模板中
// 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
// 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
context.state = store.state
}
return app
}
7.4 在模板页面中注入 meta 信息
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 使用三个大括号 -->
{{{ meta.inject().title.text() }}}
{{{ meta.inject().meta.text() }}}
</head>
详细查看:ssr.vuejs.org/zh/guide/he…
8. 路由
在 Vue 2.5 以下的版本中,服务端渲染时异步组件只能用在路由组件上。
然而在 2.5+ 的版本中,得益于核心算法的升级,异步组件现在可以在应用中的任何地方使用。
注意:所有需要在挂载 app 之前调用 router.onReady,因为路由器必须要提前解析路由配置中的异步组件,才能正确地调用组件中可能存在的路由钩子。除了在服务器入口 (server entry) 中实现外,在客户端入口 (client entry) 我们页需要实现
router.onReady(() => {
app.$mount('#app')
})
详细查看:ssr.vuejs.org/zh/guide/ro…
9. 构建开发模式
思路
- 监视代码变化然后自动构建
- 监听文件变化 chokidar
- 将打包结果输入内存中 webpack-dev-middleware
- 热更新 webpack-hot-middleware
9.1 setupDevServer
const fs = require('fs')
const path = require('path')
const chokidar = require('chokidar') // 监听文件变化
const webpack = require('webpack')
const devMiddleware = require('webpack-dev-middleware') // 将打包结果输入内存中,相对于memory-fs更方便一些
const hotMiddleware = require('webpack-hot-middleware') // 热更新,打包后自动更新网页内容
const resolve = file => path.resolve(__dirname, file)
module.exports = (server, callback) => {
let ready
const onReady = new Promise(r => ready = r)
// 监视构建 -> 更新 Renderer
let template
let serverBundle
let clientManifest
const update = () => {
if (template && serverBundle && clientManifest) {
ready() // resolve
callback(serverBundle, template, clientManifest)
}
}
// 监视构建 template -> 调用 update -> 更新 Renderer 渲染器
const templatePath = path.resolve(__dirname, '../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()
// fs.watch、fs.watchFile
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
update()
})
// 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
const serverConfig = require('./webpack.server.config')
const serverCompiler = webpack(serverConfig)
const serverDevMiddleware = devMiddleware(serverCompiler, {
logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
})
// 添加一个钩子,'.hooks.done.tap',代表编译结束。 'server'是起一个名字
// 编译结束触发回调函数
serverCompiler.hooks.done.tap('server', () => {
serverBundle = JSON.parse(
serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
)
update()
})
// 读取硬盘,上面为读取内存
// serverCompiler.watch({
// // 监视打包的可选配置参数
// }, (err, stats) => {
// // console.log('err => ', err)
// // console.log('stats => ', stats)
// if (err) throw err
// if (stats.hasErrors()) return
// // read bundle generated by vue-ssr-webpack-plugin
// serverBundle = JSON.parse(fs.readFileSync('./dist/vue-ssr-server-bundle.json', 'utf-8'))
// // 更新 Renderer
// update()
// })
// 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
const clientConfig = require('./webpack.client.config')
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
clientConfig.entry.app = [
'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本, quiet去掉日志输出, reload=true如果页面卡死,强制刷新页面
clientConfig.entry.app
]
clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash,所以去掉hash
const clientCompiler = webpack(clientConfig)
const clientDevMiddleware = devMiddleware(clientCompiler, {
publicPath: clientConfig.output.publicPath,
logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
})
clientCompiler.hooks.done.tap('client', () => {
clientManifest = JSON.parse(
clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
)
update()
})
server.use(hotMiddleware(clientCompiler, {
log: false // 关闭它本身的日志输出
}))
// server.use('/dist', express.static('./dist'))读取的是物理磁盘中的文件,读不到内存文件,所以需要将 clientDevMiddleware 挂载到 Express 服务中
// 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
server.use(clientDevMiddleware)
return onReady
}
9.2 方式2
const Router = require("koa-router")
const axios = require("axios")
const config = require("../../config/webpack.config")
const path = require("path")
const fs = require("fs")
// 相当于fs,但是不写入磁盘中
const MemoryFS = require("memory-fs")
const webpack = require("webpack")
const VueServerRenderer = require("vue-server-renderer")
const serverRender = require("./server-render")
const serverConfig = require("../../build/webpack.server.config")
// 运行webpack
const serverCompiler = webpack(serverConfig)
const mfs = new MemoryFS()
// 输出到mfs
serverCompiler.outputFileSystem = mfs
// 记录每次打包生成文件
let bundle
serverCompiler.watch({}, (err, stats) => {
// 监听,修改文件时重新打包
if (err) throw err // 配置文件错误
if(stats.hasErrors()) return // 我们自己的代码错误
stats = stats.toJson()
// 报出错误(不是webpack打包的错误)
stats.errors.forEach(err => console.log(err))
// 报出警告
stats.warnings.forEach(warn => console.warn(warn))
const bundlePath = path.join(
// 拼接输出路径
serverConfig.output.path,
"vue-ssr-server-bundle.json"
)
// 读取文件
bundle = JSON.parse(mfs.readFileSync(bundlePath, "utf-8"))
console.log("new bundle generated")
})
const handleSSR = async ctx => {
if (!bundle) {
ctx.body = "你等一会,别着急......"
return
}
const clientManifestResp = await axios.get(
// 获取vue-ssr-client-manifest.json
`${config.cdnUrl}vue-ssr-client-manifest.json`
)
const clientManifest = clientManifestResp.data
const template = fs.readFileSync(
// 读取模版
path.join(__dirname, "../server.template.ejs"),
"utf-8"
)
// 创建一个 Renderer 实例
const renderer = VueServerRenderer.createBundleRenderer(bundle, {
inject: false, // 不使用vue自己的模版
clientManifest // 带有script标签的js文件引用
})
await serverRender(ctx, renderer, template)
}
const router = new Router()
router.get("*", handleSSR)
module.exports = router