跟着Vue2官文实现一个SSR(SSR新手起步)
前言
Vue.js 服务器端渲染指南 | Vue SSR 指南 (vuejs.org)
Demo地址:github.com/vuejs/vue-h…
Demo是尤大做得一个博客项目,里面有了官文中大部分提及的Vue SSR必要的一些代码。官文中的代码也可以直接copy到项目中直接使用,都是一些通用的模板代码。唯一的缺陷是这个demo是6年前(2017左右)的产物了,webpack、node、npm的版本都已经是非常旧了,建议使用nvm进行node与npm的管理。
在demo的package.json中可以看到node版本最低为7.0,npm版本最低为4.0,我自己在搭建的时候则是使用了node@9 + npm@5,为了避免因为node与npm版本引发的不明问题,都建议减低一下node与npm的版本。
本文的主要内容就是搭建一个最简化的SSR,方便新手快速上手,大部分代码都从官文上copy,而npm依赖的版本严格与demo中的保持一致,demo只做参照作用,当然也可以用demo删除一些代码来做到。
原理
这个图一开始看了也是似懂非懂,大概就是原来的vue项目的结构(Universal Application Code)包括Store、Router、Components;入口文件原本为main.js,变为了app.js,并且还分为服务端入口(Server-entry)和客户端入口(Client-entry)。经过webpack的打包之后,生成了一个Server Bundle和一个Client Bundle,Server Bundle负责渲染出HTML,Client Bundle负责Hydrate到HTML(同构)激活成为一个Vue 应用。
大概原理就是这么回事,然后再区分生产环境和开发环境,生产环境自然而然可以生成Server Bundle和Client Bundle放在dist中,但开发环境中并不需要真正的输出到文件系统,我们需要从内存中读取出这两个Bundle,实际的代码接着来看。
目录结构
server.js
这个是最主要的文件了,整体的逻辑就是获取Server Bundle和Client Bundle,还有template,其中template是html的模板,Server Bundle和Client Bundle由于还没输出到文件系统,所以分别利用了webpack-dev-middleware和memory-fs的文件系统,直接从内存的webpack compiler对象中读取。最后一个注意的点就是因为是开发环境,热更新这个问题要注意以下 ,比如Client Bundle是使用了webpack-dev-middleware编译的,Server Bundle则是用了webpack Compiler.watch进行文件变更监听,同时,监听到文件的变更之后,对应的renderer也要更新,所以这里切忌不要用readyPromise来传入renderer给到express中间件,否则就没有热更新了。
const fs = require('fs')
const MFS = require('memory-fs')
const path = require('path')
const webpack = require('webpack')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const clientConfig = require('./build/webpack.client.config')
const serverConfig = require('./build/webpack.server.config')
let template = fs.readFileSync('./src/index.template.html', 'utf-8')
let serverBundle
let clientManifest
let ready
let renderer
function updated({ serverBundle, clientManifest, template }) {
// @see https://v2.ssr.vuejs.org/zh/guide/bundle-renderer.html#%E4%BC%A0%E5%85%A5-bundlerenderer
renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template,
clientManifest
})
}
let readyPromise = new Promise(r => ready = r)
// @see https://www.webpackjs.com/api/node/#compiler-instance
const clientCompiler = webpack(clientConfig)
// 这里找不回webpack3的文档了,webpack5中没找到这个用法,但不难看出是给Compiler对象注册钩子函数。
clientCompiler.plugin('done', () => {
// 因为clientConfig是交给webpack-dev-middleware进行执行编译的,可从它的输出中读取内存生成的Client Bundle
clientManifest = JSON.parse(devMiddleware.fileSystem.readFileSync(path.join(clientConfig.output.path, 'vue-ssr-client-manifest.json'), 'utf-8'))
if (serverBundle) {
// 这里的因为webpack-dev-middleware会监听文件的变化再次编译,所以可能会被多次updated,用promise resolve不合适
updated({ serverBundle, clientManifest, template })
ready()
}
})
// @see https://www.webpackjs.com/guides/development/#using-webpack-dev-middleware
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true,
})
// 基本同上
const serverCompiler = webpack(serverConfig)
// @see https://www.npmjs.com/package/memory-fs A simple in-memory filesystem.
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, () => {
serverBundle = JSON.parse(mfs.readFileSync(path.join(clientConfig.output.path, 'vue-ssr-server-bundle.json')))
if (clientManifest) {
updated({ serverBundle, clientManifest, template })
ready()
}
})
const app = express()
// @see https://www.webpackjs.com/guides/development/#using-webpack-dev-middleware
app.use(devMiddleware)
app.get('*', (req, res) => {
// 等待编译完成读取到Server Bundle和Client Bundle
readyPromise.then(() => {
const context = { url: req.url }
// @see https://v2.ssr.vuejs.org/zh/api/#renderer-rendertostring
// 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
// 现在我们的服务器与应用程序已经解耦!
renderer.renderToString(context, (err, html) => {
// 处理异常……
res.end(html)
})
})
})
app.listen(8084, () => {
console.log('http://localhost:8084')
})
entry-server.js
数据预取和状态 | Vue SSR 指南 (vuejs.org) 官文中提到的通用模板代码 直接copy,大致就是等router的挂载,然后首页渲染的页面有异步请求获取的数据,也需要交给asyncData和store处理。
// entry-server.js
import { createApp } from './app'
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
// 设置服务器端 router 的位置
router.push(context.url)
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
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)
})
}
entry-client.js
数据预取和状态 | Vue SSR 指南 (vuejs.org) 一样的copy
import Vue from 'vue'
import { createApp } from './app'
// 客户端特定引导逻辑……
const { app, router,store } = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
Vue.mixin({
beforeRouteUpdate (to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
})
// 这里假定 App.vue 模板中根元素具有 `id="app"`
router.onReady(() => {
// 添加路由钩子函数,用于处理 asyncData.
// 在初始路由 resolve 后执行,
// 以便我们不会二次预取(double-fetch)已有的数据。
// 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
// 我们只关心非预渲染的组件
// 所以我们对比它们,找出两个匹配列表的差异组件
let diffed = false
const activated = matched.filter((c, i) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
if (!activated.length) {
return next()
}
// 这里如果有加载指示器 (loading indicator),就触发
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
// 停止加载指示器(loading indicator)
next()
}).catch(next)
})
app.$mount('#app')
})
webpack.base.config.js
只配了一个vue-loader和一个babel-loader
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
module.exports = {
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].js',
publicPath: '/dist/',
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
prettify: false,
}
},
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
loader: 'babel-loader',
options: {
"presets": [
["env", { "modules": false }]
],
"plugins": [
"syntax-dynamic-import"
]
}
}
]
},
plugins: [
// 请确保引入这个插件!
new VueLoaderPlugin()
]
}
webpack.client.config.js
构建配置 | Vue SSR 指南 (vuejs.org) 直接copy,关键就指定一个入口和VueSSRClientPlugin
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
module.exports = merge(baseConfig, {
entry: './src/entry-client.js',
plugins: [
new VueSSRClientPlugin(),
]
})
webpack.server.config.js
构建配置 | Vue SSR 指南 (vuejs.org) 直接copy, 关键就指定一个入口和VueSSRServerPlugin,由于是在node端运行的还需添加一些选项
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
module.exports = merge(baseConfig, {
entry: './src/entry-server.js',
plugins: [
new VueSSRServerPlugin(),
],
// 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
// 并且还会在编译 Vue 组件时,
// 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
target: 'node',
// 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
output: {
libraryTarget: 'commonjs2'
},
})
其余的
在写法上也有一些注意事项
避免状态单例
源码结构 | Vue SSR 指南 (vuejs.org),在app、router、store这些生成一个对象的代码中,由于要在服务端也生成一份相同的,所以要避免单例,影响了其余访问用户的对象状态。
数据预取和状态
数据预取和状态 | Vue SSR 指南 (vuejs.org),这一篇是重中之重,SSR主要解决的就是首屏渲染的问题,这篇讲述的就是如何解决首屏渲染遇到异步请求时的解决方案。
编写通用代码
编写通用代码 | Vue SSR 指南 (vuejs.org),上面的代码基本都是ssr的一个搭建的方案,但使用这个方案还需要注意业务逻辑编写时的一些小细节。比如里面提到的 组件生命周期钩子函数,很明显我们的服务端也有一个new Vue的操作,但new Vue中可会触发beforeCreate和created两个钩子,我们平时会在这两个钩子里写定时器、请求异步数据等,然后再beforeDestory里注销定时器,但在服务端可没有执行到beforeDestory钩子,那么定时器就不会被注销,服务端就出现了内存泄漏了;异步请求上一小节也提到了要写到asyncData中了就不谈了。还有其他的小细节请直接到官文中阅读。
生产环境
生产环境直接加在打包出来的Bundle就好啦,都不需要webpack-dev-middleware或者memory-fs了,然后想想一些分包,缓存的问题,这些在官文中都是又提到的,尤大的Demo中也是大部分都配置了。
总结
原理:createBundleRenderer,生成Client Bundle与Server Bundle即可,最主要的逻辑都写在server.js中了,需要一点webpack和node的基础,上文代码中基本都用了@see注释注明可以在哪里学习到关键的知识点,非常保姆级别的Vue SSR新手教程了,但需要阅读的文档还是比较多。
转载自:https://juejin.cn/post/7256620538092830779