本文和个人博客同步发表
更多优质文章查看个人博客
前言
手动搭建 vue ssr 一直是一些前端开发者的噩梦,因为其中牵扯到很多依赖包之间的配置以及webpack在node中的使用。就拿webpack配置来说,很多前端开发者还是喜欢用webpack-cli脚手架搭建项目。导致这样的原因之一无外乎学习成本高,软件复杂等。这也是有些前端开发者直接拥抱nuxt.js的部分原因。这篇博客使用vue2,以步骤为主,来展示如何创建完整ssr开发环境。
主要参考文章
- vue2 ssr 中文官网
- webpack 中文官网
- webpack-dev-middleware
- webpack-hot-middleware
- webpack tapable
- memory-fs
- express
- vue2 ssr 官方参考案例
构建ssr所需依赖包
{
"devDependencies": {
"chokidar": "^3.5.3",
"css-loader": "^6.7.3",
"memory-fs": "^0.5.0",
"vue-loader": "^15.9.8",
"vue-style-loader": "^4.1.3",
"webpack": "^5.54.0",
"webpack-dev-middleware": "^5.2.1",
"webpack-hot-middleware": "^2.25.1",
"webpack-node-externals": "^3.0.0"
},
"scripts": {
"dev": "node ./server/index.cjs"
},
"dependencies": {
"express": "^4.18.2",
"vue": "^2.6.14",
"vue-router": "^3.5.2",
"vue-server-renderer": "^2.6.14",
"vue-template-compiler": "^2.6.14",
"vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0"
}
}
目录结构
需要创建如下图的目录结构,方能进行后面的代码编写
ssr是如何生成的?
了解完目录架构之后,首先需要知道ssr是如何生成的是至关重要的,只有这样我们才了解后续通过什么样的操作来构建ssr。首先看一张官方给出的构建图。
从图中可以看出,想要实现ssr,必选通过webpack构建生成的服务端bundle文件和客户端bundle文件,服务端bundle在bundle renderer的作用下生成html字符串,并发送给浏览器端并且和客户端bundle一起作用下激活,最终实现ssr。代码中创建html字符串和发送到浏览器的实现如下所示
let devServerPromise = devServer((serverBundle, options) => {
// 服务端生成的bundele和客户端生成的clientManifest结合,并返回renderer
renderer = createBundleRenderer(serverBundle, Object.assign(options, {
runInNewContext: false,
}))
});
ROUTER.get('*', (req, res) => {
const context = {
url: req.url
}
devServerPromise.then((random) => {
// 将 Vue 实例渲染为字符串,发送给客户端
renderer.renderToString(context).then(html => {
res.send(html)
}).catch(err => {
console.log('err',req.url,err)
})
})
})
其实在构建图中还少一个关键点,如下所示
webpack在编译客户端时会生成客户端构建清单(clientManifest),清单里面的内容其实是当html字符串在浏览器中解析时要获取的资源内容(也可简单理解为client bundle),当解析html时根据内容去请求client bundle。
好!到目前为止,实现vue ssr思路已经很清晰了。在这里简单梳理一下。
根据上面的图可知,想要实现ssr,就是需要serverBundle、clientBundle、clientManifest文件。而这三种文件又是通过webpack生成的。所以现在的要面临的问题就是配置webpack,生成这三种文件,然后通过renderToString函数实现ssr。话不多说开始配置。
配置 webpack
在配置webpack之前,需要先创建要打包的代码、app.js、entry-client.js和entry-server.js。创建这些内容的作用是为后续webpack打包做准备。
1. 创建UAC(Universal Application Code)相关文件
在ssr-demo文件夹下的client文件夹中,分别创建router、store和views文件夹和相关内容。
1.1 router文件夹和相关内容
// 路径 client/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router);
export function createRouter(){
return new Router({
mode:'history',
routes:[
{path:'/',component: () => import('../views/index.vue')},
]
})
}
1.2 store文件夹和相关内容
// 路径 client/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);
export function createStore(){
return new Vuex.Store({
state:{},
actions:{},
mutations:{}
})
}
1.3 view文件夹和相关内容
<!-- 路径 client/views/index.vue -->
<template>
<div>
kinghiee ssr test
</div>
</template>
<script>
export default {
}
</script>
<style>
</style>
2. 创建app.js文件
// 路径 client/app.js
import Vue from 'vue';
import App from './App.vue';
import { createRouter } from './router/index';
import { createStore } from './store';
import { sync } from 'vuex-router-sync';
// 简单工厂模式创建vue实例
export function createApp() {
const router = createRouter();
const store = createStore();
sync(store, router);
const app = new Vue({
router,
store,
render: h => h(App),
})
return {
app,
router,
store
}
};
app.js文件的作用: 使用简单工厂模式,创建vue实例,为每个请求创建新的应用程序实例,避免状态单例。更加详细解释请查看官网;
3. 创建entry-client.js文件
// 路径 client/entry-client.js
import { createApp } from './app';
import Vue from 'vue';
const { app, router, store } = createApp();
Vue.mixin({
/**
* 路由更新触发组件内异步获取数据方法
* @param {*} to
* @param {*} from
* @param {*} next
*/
beforeRouteUpdate(to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
});
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}
router.onReady(() => {
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();
}
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
next();
}).catch(next)
})
app.$mount('#app')
})
4. 创建entry-server.js文件
// 路径 client/entry-server.js
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 对所有匹配的路由组件调用 `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)
})
}
注:entry-client.js和entry-server.js文件以及文件中为什么这么写在官网中都能找的到,这里只说明搭建步骤 官网
相关代码创建完毕之后,client文件夹内容大致如下
到此为止,webpack打包前期的准备工作已经结束。接下来开始配置webpack打包
5. webpack配置
在ssr webpack打包配置中分为客户端和服务端配置。客户端打包配置主要生成后面用到的客户端bundle和clientManifest,而服务端打包配置主要生成后面用到的服务端bundle,这三种文件正是ssr所需的关键文件。
5.1 客户端打包配置
// 路径 build/webpack.client.dev.js
const { resolve: RESOLVE } = require('path');
const WEBPACK = require('webpack');
const { VueLoaderPlugin: VUELOADERPLUGIN } = require('vue-loader');
const VUESSRCLIENTPLUGIN = require('vue-server-renderer/client-plugin')
module.exports = {
mode: 'development',
entry: { app: RESOLVE(__dirname, '../client/entry-client.js') },
output: {
path: RESOLVE(__dirname, '../dist'),
filename: 'src/[name].[contenthash:6].js',
publicPath: '/dist/'
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.css$/i,
use: ["vue-style-loader", "css-loader"],
},
]
},
resolve: {
extensions: ['.js', '.ts', '.vue', '.json'],
alias: {
'@client':RESOLVE(__dirname,'../client')
}
},
plugins: [
new VUELOADERPLUGIN(),
new VUESSRCLIENTPLUGIN({
filename: 'src/vue-ssr-client-manifest.json'
})
]
}
5.2 服务端打包配置
// 路径 build/webpack.server.dev.js
const { resolve: RESOLVE } = require('path');
const { VueLoaderPlugin: VUELOADERPLUGIN } = require('vue-loader');
const VUESERVERPlUGINSSR = require('vue-server-renderer/server-plugin')
const NODEEETERNALS = require('webpack-node-externals');
module.exports = {
target: 'node',
devtool: 'eval-cheap-source-map',
entry: RESOLVE(__dirname, '../client/entry-server.js'),
output: {
path: RESOLVE(__dirname, '../dist'),
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.css$/i,
use: ["vue-style-loader", "css-loader"],
},
]
},
externalsPresets: { node: true }, // in order to ignore built-in modules like path, fs, etc.
externals: [NODEEETERNALS()], // in order to ignore all modules in node_modules folder
plugins: [
new VUELOADERPLUGIN(),
new VUESERVERPlUGINSSR({
filename: 'src/vue-ssr-server-bundle.json'
})
]
}
webpack 客户端和服务端都已配置好了,那如何生成相应的三种文件呐?其实生成这三种文件需要在webpack编译阶段,而对于配置开发环境来说,一般还用到热更新和webpack dev中间件,所以webpack编译和热更新常在一起出现。目前为止该准备的都已经到位了,现在就可以开始webpack编译和配置热更新操作了。
webpack编译和配置热更新
在server文件夹内创建如下文件夹和文件
1. webpack编译
本博客给出的代码示例和官方的示例组织上有不同的地方,但功能上一样。本博客按照功能的不同对代码进行了合理的拆分和封装,而不是把全部功能写到一个函数下面。这样做的目的是关注点单一、功能单一、便于开发和维护。
1.1 编译客户端
// 路径 dev/clientCompile.cjs
let webpack = require('webpack');
let path = require('path');
module.exports = function clientCompile(clientConfig, clientManifestCb) {
// 向客户端 webpack 修改配置
clientConfig.entry.app = [ 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', clientConfig.entry.app ];
clientConfig.output.filename = '[name].[contenthash:6].js';
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
);
// 编译客户端 webpack 配置
let clientCompiler = webpack(clientConfig); // 获取 compiler 实例
let devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
serverSideRender: true,
stats: {//可选
colors: true,
modules: true,
},
});
// done是AsyncSeriesHook类型钩子
clientCompiler.hooks.done.tap('done', stats => {
stats = stats.toJson({stats:'errors-warnings'});
// 如果客户端编译完毕,有错误或者警告会打印到控制台
stats.errors.forEach(err => console.error(err));
stats.warnings.forEach(err => console.warn(err));
// 有错误后续不生成 manifest 文件
if (stats.errors.length) return;
console.log('\n客户端更新...\n');
let manifestContent = devMiddleware.context.outputFileSystem.readFileSync(
path.resolve(clientConfig.output.path, 'src/vue-ssr-client-manifest.json'),
'utf-8'
);
clientManifestCb(JSON.parse(manifestContent));
});
let hotMiddleware = require('webpack-hot-middleware')(clientCompiler);
return {
devMiddleware,
hotMiddleware
}
}
如上代码所示,把编译客户端的代码写到一个文件内,并导处客户端编译函数,在其他地方用。
1.2 编译服务端
// 路径 dev/serverCompile.cjs
let webpack = require('webpack');
let path = require('path');
const MFS = require('memory-fs');
module.exports = function serverCompile(
serverConfig,
serverBundleCb
) {
let serverCompiler = webpack(serverConfig);
let mfs = new MFS();
serverCompiler.outputFileSystem = mfs; // 把 webpack 默认的普通文件系统更换为内存文件系统
serverCompiler.watch({ ignored: /node_modules/, }, (err, stats) => {
if (err) throw err;
stats = stats.toJson();
// 有错误后续不执行
if (stats.errors.length) return;
console.log('\n服务端更新...\n');
// 获取服务端bundle文件路径
let bundlePath = path.resolve(
serverConfig.output.path,
'src/vue-ssr-server-bundle.json'
);
serverBundleCb(JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')))
});
}
写完客户端和服务端编译后,需要把函数导出来在后面的地方使用。代码如下
2. webpack编译和热更新配置
dev.cjs文件如下
// 路由 router/dev.cjs
const SERVER = require('express');
const ROUTER = SERVER.Router();
const FS = require('fs');
const PATH = require('path');
let clientConfig = require('../../build/webpack.client.dev');
let serverConfig = require('../../build/webpack.server.dev');
let templatePath = PATH.resolve(__dirname, '../server.template.html');
let { createBundleRenderer } = require('vue-server-renderer')
let serverCompile = require('../dev/serverCompile.cjs');
let clientCompile = require('../dev/clientCompile.cjs');
let tempWatch = require('../dev/tempWatch.cjs');
let renderer;
const devServer = (cb) => {
let clientManifest, serverBundle, readyResolve, templateContent;
templateContent = FS.readFileSync(templatePath, 'utf-8');
let readyPromise = new Promise(resolve => readyResolve = resolve );
// 更新客户端和服务端内容
let updateClientAndServer = () => {
// 只有构建清单文件都存在时,执行更新操作
if(clientManifest && serverBundle) {
readyResolve(); // 把promise resolve掉
cb(serverBundle, {
template: templateContent,
clientManifest
})
}
};
// 监听模板文件
tempWatch(templatePath, () => {
updateClientAndServer();
});
// 客户端 编译
let { devMiddleware, hotMiddleware} = clientCompile(
clientConfig, (clientManifestContent) => {
clientManifest = clientManifestContent;
updateClientAndServer();
})
ROUTER.use(devMiddleware);
ROUTER.use(hotMiddleware);
// 服务端 编译
serverCompile(serverConfig, (serverBundleContent) => {
serverBundle = serverBundleContent;
updateClientAndServer();
})
return readyPromise;
}
let devServerPromise = devServer((serverBundle, options) => {
renderer = createBundleRenderer(serverBundle, Object.assign(options, {
runInNewContext: false,
}))
});
在devServer函数中,分别使用clientCompile函数,编译客户端。在回调函数把生成的clientManifestContent内容赋值给clientManifest,然后通知updateClientAndServer函数完成后续内容。同时clientCompile函数也返回了两个中间件并放入use函数中,完成后续的热更新和webpack dev server. 和clientCompile函数类似,serverCompile函数,它也是在回调函数中把生成的serverBundleContent赋值给serverBundle,并通知updateClientAndServer函数完成其他内容。在updateClientAndServer函数中,当clientManifest和serverBundle内容都有时,就可以把promise resolve掉,进而可以调用renderToString函数生成html字符串,发送给浏览器,最后实现ssr。
在devServer函数中还是用了tempWatch,该函数的作用是当模板文件发生变化时,更新相关内容。代码如下
// 路径 dev/tempWatch.cjs
let fs = require('fs');
let chokidar = require('chokidar');
module.exports = function tempWatch(templatePath, watchCb) {
// 监听模板html文件 change
chokidar.watch(templatePath).on('change', () => {
console.log('模板更新中...');
templateContent = fs.readFileSync(templatePath, 'utf-8');
console.log('模板更新成功!');
// 更新模块
watchCb();
});
}
到此为止,vue ssr的配置和生成基本结束。但是现在通过浏览器还是访问不了,还需要最后一步配置服务器
配置服务器提供访问
在dev.cjs中添加路由配置,然后导处路由,在程序入口处使用。
// 路径 router/dev.cjs
const SERVER = require('express');
const ROUTER = SERVER.Router();
const FS = require('fs');
const PATH = require('path');
let clientConfig = require('../../build/webpack.client.dev');
let serverConfig = require('../../build/webpack.server.dev');
let templatePath = PATH.resolve(__dirname, '../server.template.html');
let { createBundleRenderer } = require('vue-server-renderer')
let serverCompile = require('../dev/serverCompile.cjs');
let clientCompile = require('../dev/clientCompile.cjs');
let tempWatch = require('../dev/tempWatch.cjs');
let renderer;
const devServer = (cb) => {
let clientManifest, serverBundle, readyResolve, templateContent;
templateContent = FS.readFileSync(templatePath, 'utf-8');
let readyPromise = new Promise(resolve => readyResolve = resolve );
// 更新客户端和服务端内容
let updateClientAndServer = () => {
// 只有构建清单文件都存在时,执行更新操作
if(clientManifest && serverBundle) {
readyResolve(); // 把promise resolve掉
cb(serverBundle, {
template: templateContent,
clientManifest
})
}
};
// 监听模板文件
tempWatch(templatePath, () => {
updateClientAndServer();
});
// 客户端 编译
let { devMiddleware, hotMiddleware} = clientCompile(
clientConfig, (clientManifestContent) => {
clientManifest = clientManifestContent;
updateClientAndServer();
})
ROUTER.use(devMiddleware);
ROUTER.use(hotMiddleware);
// 服务端 编译
serverCompile(serverConfig, (serverBundleContent) => {
serverBundle = serverBundleContent;
updateClientAndServer();
})
return readyPromise;
}
let devServerPromise = devServer((serverBundle, options) => {
renderer = createBundleRenderer(serverBundle, Object.assign(options, {
runInNewContext: false,
}))
});
ROUTER.get('*', (req, res) => {
const context = {
url: req.url
}
devServerPromise.then(() => {
renderer.renderToString(context).then(html => {
res.send(html)
}).catch(err => {
console.log('err',req.url,err)
})
})
})
module.exports = ROUTER;
在程序入口处使用该路由
// 路径 server/index.cjs
const SERVER = require('express')();
const SSRROUTER = require('./router/dev.cjs');
const PORT = 8000;
SERVER.use(SSRROUTER);
SERVER.listen(PORT,() => {
console.log(`app listening at port ${PORT}`);
});
最后输入npm run dev启动项目,结果如下
注: 配置ssr的过程有点繁琐,如果途中有配置错的地方可以查看我的github ssr demo
如果博客中有什么不理解的或者错误内容,欢迎指出,及时更正