如何使用vue-cli,做vue3.0的服务端渲染(ssr)
上个月有网友看我之前用vite搭建的vue3.0服务端渲染demo之后,就在评论区问我有没有不是vite的vue3.0服务端渲染教程。闻此,我心中窃喜(ps:兄弟们来活了),沉睡了很长时间的我,终于又开始鼓捣了。记得上一篇vite的文章是去年3月份发布的,一晃居然一年过去了,不由得感叹光阴似箭,日月如梭啊。在去年调研vite的时候,其实刚开始是调研的webpack和vue-cli去做构建工具,但是
上个月有网友看我之前用vite搭建的vue3.0服务端渲染demo之后,就在评论区问我有没有不是vite的vue3.0服务端渲染教程。闻此,我心中窃喜(ps:兄弟们来活了),沉睡了很长时间的我,终于又开始鼓捣了。
记得上一篇vite的文章是去年3月份发布的,一晃居然一年过去了,不由得感叹光阴似箭,日月如梭啊。在去年调研vite的时候,其实刚开始是调研的webpack和vue-cli去做构建工具,但是当时这方面的生态太差了,一些关键的地方进行不下去,无奈只能弃之,转用当时风头正盛的大明星-vite,不得不说vite由于尤大的大力支持,在当时来说生态已经是很好了,用来做ssr,基本稍加改动即可,想了解的同学可以看之前的vite帖子,不知不觉废话有多了,接下来进入我们的正题吧。
不行,还得说一句,太难了,实在是有点脑壳疼,文章有点长,各位看客先准备袋瓜子,看我慢慢道来。
从我们平时对vue-cli的使用知道,其实vue-cli已经帮我们做了很多底层构建的封装,但是它的这些封装都是基于csr模式去做的,并不一定适合ssr。所以我们在转为ssr的时候,毫无疑问要去改装它的vue-config.js文件。这里是官方文档给出的实例,喜欢循序渐进的同学可以去看看。
cli-service 命令
对cli-service注册不是很了解的同学,可以查看下下面官方文档:
添加一个新的 cli-service 命令
项目本地的插件
下面我用到的是本地注册的插件,当然你们也可以按上面的方法,独立成一个插件包来使用,奇怪的知识是不是又多了?
与官方实例比,这样通过自定义命令实现比较清晰明了,对原有架构没有太多的入侵,即可实现ssr。相信看了上一篇对cli-service的介绍,应该都了解的差不多了,下面不再做太多的赘叙,直接就入正题了,不然通篇看下来全是废话,浪费你们的时间。
注册ssr:build
首选我们注册ssr:build命令,用于生产打包,开发思路可以借鉴上面官方文档给出的代码,具体如下:
const webpackConfig = (api) =>
// 根据不用的构建任务,实例化不同的wepack配置实例
api.chainWebpack((webpackConfig) => {
const { ClientWebpack, ServerWebpack } = require("./webpack");
const { VUE_CLI_SSR_TARGET } = process.env;
if (!VUE_CLI_SSR_TARGET || VUE_CLI_SSR_TARGET === "client")
return new ClientWebpack(webpackConfig);
return new ServerWebpack(webpackConfig);
});
// vue-cli提供的注册指令
api.registerCommand(
"ssr:build",
{
description: "build for production (SSR)",
},
async (args) => {
const webpack = require("webpack");
// 把vue-cli自带的webpack配置和当前指令的配置进行合并
webpackConfig(api);
const rimraf = require('rimraf');
const formatStats = require("@vue/cli-service/lib/commands/build/formatStats");
// 删除构建产物
rimraf.sync(api.resolve(config.distPath));
const { getWebpackConfigs } = require("./webpack");
// 提取css
api.service.projectOptions.css.extract = true;
// 文件名添加hash
api.service.projectOptions.filenameHashing = true;
// 获取合并后的webpack配置
const [clientConfig, serverConfig] = getWebpackConfigs(api.service);
// 生成编译器
const compiler = webpack([clientConfig, serverConfig]);
// 开始构建
compiler.run();
}
);
webpack
接下来我们来看看webpack配置文件
const webpack = require('webpack');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin') // 形成服务端manifest文件
const nodeExternals = require('webpack-node-externals')
const WebpackBar = require('webpackbar');
const { config: baseConfig } = require('./config');
const HtmlFilterPlugin = require('./plugins/HtmlFilterPlugin');
const RemoveUselessAssetsPlugin = require('./plugins/RemoveUselessAssetsPlugin');
const VueSSRClientPlugin = require('./plugins/VueSSRClientPlugin');
const CssContextLoader = require.resolve('./loaders/css-context');
class BaseWebpack {
constructor(config) {
const isProd = process.env.NODE_ENV === 'production';
const isBuild = process.env.RUN_TYPE === 'build';
config.plugins.delete('hmr');
// 禁用 cache loader,否则客户端构建版本会从服务端构建版本使用缓存过的组件
config.module.rule('vue').uses.delete('cache-loader');
config.module.rule('js').uses.delete('cache-loader');
config.module.rule('ts').uses.delete('cache-loader');
config.module.rule('tsx').uses.delete('cache-loader');
// 一些报错的友好提示
config.stats(isProd ? 'normal' : 'none');
// 构建js文件添加hash
isBuild && config.output.filename('js/[name].[hash].js').chunkFilename('js/[name].[hash].js');
// 一些报错的友好提示
config.devServer
.stats('errors-only')
.quiet(true)
.noInfo(true);
}
}
// 客户端构建配置
class ClientWebpack extends BaseWebpack {
constructor(config) {
super(config);
config
.entry('app')
.clear()
.add('./src/entry-client');
config
.plugin('loader')
.use(WebpackBar, [{ name: 'Client', color: 'green' }]);
// 过滤掉index.html模板文件里面的js和css注入
config.plugin('html-filter').use(HtmlFilterPlugin);
// block clear comments in template
config.plugin('html').tap((args) => {
args[0].minify && (args[0].minify.removeComments = false);
return args;
});
// 生成客户端文件映射
config.plugin('VueSSRClientPlugin')
.use(VueSSRClientPlugin);
}
}
class ServerWebpack extends BaseWebpack {
constructor(config) {
super(config);
config
.entry('app')
.clear()
.add('./src/entry-server');
config
.output
.libraryTarget('commonjs2');
// 这允许 webpack 以适合于 Node 的方式处理动态导入,
// 同时也告诉 `vue-loader` 在编译 Vue 组件的时候抛出面向服务端的代码。
config.target('node');
// 生成客户端资源清单
config
.plugin('manifest')
.use(new WebpackManifestPlugin({ fileName: 'ssr-manifest.json' }));
// server-side remove public file
config.plugins.delete('copy');
// 由于共用的vue-cli配置会生产一些无用文件,则进行清除
config.plugin('RemoveUselessAssetsPlugin')
.use(new RemoveUselessAssetsPlugin());
// 忽略掉没有必要的构建依赖
config.externals(nodeExternals({ allowlist: baseConfig.nodeExternalsWhitelist }));
// 不需要代码分割,合成一个文件即可
config.optimization.splitChunks(false).minimize(false);
// 删除服务端不支持的plugins
config.plugins.delete('preload');
config.plugins.delete('prefetch');
config.plugins.delete('progress');
config.plugins.delete('friendly-errors');
const isExtracting = config.plugins.has('extract-css');
if (isExtracting) {
// Remove extract
const langs = ['css', 'postcss', 'scss', 'sass', 'less', 'stylus'];
const types = ['vue-modules', 'vue', 'normal-modules', 'normal'];
for (const lang of langs) {
for (const type of types) {
const rule = config.module.rule(lang).oneOf(type);
rule.uses.delete('extract-css-loader');
// Critical CSS
rule.use('css-context')
.loader(CssContextLoader)
.before('css-loader');
}
}
config.plugins.delete('extract-css');
}
config.plugin('limit').use(
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1
})
);
config
.plugin('loader')
.use(WebpackBar, [{ name: 'Server', color: 'orange' }]);
config.node.clear();
}
}
const getWebpackConfigs = (service) => {
process.env.VUE_CLI_SSR_TARGET = 'client';
// Override outputDir before resolving webpack config
service.projectOptions.outputDir = `${baseConfig.distPath}/client`;
const clientConfig = service.resolveWebpackConfig();
process.env.VUE_CLI_SSR_TARGET = 'server';
// 重写outputDir,使客户端和服务端打包产物隔离
service.projectOptions.outputDir = `${baseConfig.distPath}/server`;
const serverConfig = service.resolveWebpackConfig();
return [clientConfig, serverConfig];
};
写到这里,敲过官方实例的同学就会发现,把它的代码原封不动的copy下来,构建出来的产物,服务端会多出很多无用的文件,运行之后也会发现在页面首次加载的同时,也会把一些暂时不需要的js,css文件也一并加载了,这是没必要的。所以上面手写了几个插件用来避免这些问题。
HtmlFilterPlugin
阻止vue-cli自带的html-webpack-plugin插件向模板文件注入js和css文件。
const ID = 'vue-cli-plugin-ssr:html-filter';
module.exports = class HtmlFilterPlugin {
apply(compiler) {
compiler.hooks.compilation.tap(ID, (compilation) => {
compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync(
ID,
(data, cb) => {
data.head = data.head.filter(
(tag) => !this.isCssOrJs(tag)
);
data.body = data.body.filter(
(tag) => !this.isCssOrJs(tag)
);
cb(null, data);
}
);
});
}
isCssOrJs(tag) {
const { href, src } = tag.attributes;
return /.(css|js)$/.test(href || src);
}
};
RemoveUselessAssetsPlugin
移除掉服务端生成的无用文件
class RemoveUselessAssetsPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('webpack', (compilation, callback) => {
Object.keys(compilation.assets).forEach(k => {
if (k.match('precache-manifest'))
delete compilation.assets[k];
})
delete compilation.assets['index.html'];
delete compilation.assets['service-worker.js'];
delete compilation.assets['manifest.json'];
callback();
});
}
}
既然我们用到上述插件移除了html-webpack-plugin对index.html模板文件的资源注入,那么问题就来了,我们在请求页面的时候,要如何的去正确的注入当面路由匹配的页面所需要的资源呢?就在百思不得其解的时候,突然想起来vue2.0 ssr,那么它又是如何去做的呢?我们来打开vue2.0用到的ssr插件vue-server-renderer的仓库,可以很清晰的看到表层就有一个client-plugin.js文件,这个就是生成客户端资源对应清单的关键所在,我们可以点进去借鉴一下源码的思路即可实现,即上面代码中使用的VueSSRClientPlugin插件,文件地址:https://github.com/Vitaminaq/cfsw-vue-cli3.0/blob/ssr-vue3.0-cli/plugins/ssr/plugins/VueSSRClientPlugin.js。
既然生成了客户端资源清单,那么问题又来了,我们如何在请求到达服务器的时候去动态按需注入到ssr模板中去呢?可以说问题环环相扣,非常之烧脑。这个时候我又满脸奸笑的把目光瞄上了vue-server-renderer插件,那么它是怎么来做资源的匹配按需加载的呢。我们把鼠标点向它的源码处:https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/build.dev.js同样也是动动小手,即可改装成我们所需功能,觉得麻烦的同学也可以直接装它这个插件来用用,不得不说。
用前朝的剑,斩本朝的官,你好大的胆子啊
到了这里生产构建也就差不多了,接下来开始啃本地开发的配置,一个字:麻烦!
注册ssr:serve
用于本地开发,首先看下注册代码
api.registerCommand(
"ssr:serve",
{ description: "Run the included server." },
async (args) => {
webpackConfig(api);
const { createServer } = require("./server");
const port = args.port || config.port || process.env.PORT;
// 防止端口冲突
if (!port) {
const portfinder = require("portfinder");
port = await portfinder.getPortPromise();
}
await createServer({ port, api });
}
);
看起来比上面的ssr:build简单点太多,事实并非如此,createServer创建本地开发服务器只是个开始。
createServer
启动ssr服务器核心代码如下,看过之前vite那篇的同学应该不会太陌生,换汤不换药:
module.exports = async (app) => {
const isBuild = process.env.RUN_TYPE === 'build';
try {
let createApp; // entry-server导出的构建函数
let template; // 模板文件
let clientManifest; // 客户端资源清单
// 经过构建的,直接读取dist目录下相应文件即可
if (isBuild) {
const manifest = require(resolveSource('server/ssr-manifest.json'));
const appPath = resolveSource(`server/${manifest['app.js']}`);
createApp = require(appPath).default
template = fs.readFileSync(resolveSource('client/index.html'), 'utf-8');
clientManifest = require(resolveSource('client/vue-ssr-client-manifest.json'));
} else {
// 开发环境后续讲解
const { setupDevServer } = require('./dev-server');
await setupDevServer({
server: app,
onUpdate: ({ca, tl, cm}) => {
createApp = ca;
template = tl;
clientManifest = cm;
}
});
}
app.use(compression({ threshold: 0 }));
// Serve static files
if (isBuild) {
const serve = (filePath) =>
express.static(filePath, {
maxAge: config.maxAge,
index: false
});
// 把打包好的文件转成静态资源
const serveStaticFiles = serve(resolveSource('client'));
// 拒绝访问index.html模板文件
app.use((req, res, next) => {
if (/index\.html/g.test(req.path)) {
next();
} else {
serveStaticFiles(req, res, next);
}
});
}
app.get('*', async(req, res, next) => {
if (config.skipRequests(req)) return next();
// 读取配置文件,注入给客户端
const envConfig = require('dotenv').config({ path: `.env.${process.env.NODE_ENV}` }).parsed;
const { app, store } = await createApp(req.originalUrl, envConfig);
const appContent = await renderToString(app);
const state =
'<script>window.__INIT_STATE__=' +
serialize(store, { isJSON: true }) + ';' +
'window.__APP_CONFIG__=' + serialize(envConfig, { isJSON: true }) +
'</script>';
// 调用从vue-server-render插件里面提取的模板渲染函数,来进行模板静态资源按需加载
const render = new TemplateRenderer({
template,
inject: true,
clientManifest
});
// Load resources on demand
const html = render.render('')
.replace('<div id="app">', `<div id="app">${appContent}`)
.replace(`<!--app-store-->`, state);
res.setHeader('Content-Type', 'text/html');
res.send(html)
});
return createApp;
} catch (e) {
console.error(e);
}
};
setupDevServer
module.exports.setupDevServer = ({ server, onUpdate }) =>
new Promise((resolve, reject) => {
const { getWebpackConfigs } = require('./webpack');
const [clientConfig, serverConfig] = getWebpackConfigs(config.api.service);
let createApp;
let template;
let clientManifest;
// 触发更新函数
const update = () => {
if (createApp && template && clientManifest) {
onUpdate({ ca: createApp, tl: template, cm: clientManifest });
resolve();
}
};
// modify client config to work with hot middleware
clientConfig.entry.app = [
'webpack-hot-middleware/client',
...clientConfig.entry.app
];
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin());
// dev middleware
const clientCompiler = webpack(clientConfig);
const clientMfs = new MFS();
// watch file update
const devMiddleware = require('webpack-dev-middleware')(
clientCompiler,
{
outputFileSystem: clientMfs, // 改写编译输出文件配置,写入内存
publicPath: clientConfig.output.publicPath,
stats: 'none',
index: false
}
);
server.use(devMiddleware);
clientCompiler.hooks.done.tap('cli ssr', async (stats) => {
// 读取内存里面的模板文件以及客户端资源清单
template = clientMfs.readFileSync(path.join(clientConfig.output.path, 'index.html'), 'utf8');
clientManifest = JSON.parse(clientMfs.readFileSync(path.join(clientConfig.output.path, 'vue-ssr-client-manifest.json'), 'utf8'));
// 编译完毕,触发更新
update();
});
// hot module replacement middleware - refresh page
server.use(
require('webpack-hot-middleware')(clientCompiler, {
heartbeat: 5000
})
);
// watch and update server renderer
const serverCompiler = webpack(serverConfig);
// 服务端逻辑同客户端类似
const serverMfs = new MFS();
serverCompiler.outputFileSystem = serverMfs;
serverCompiler.watch({}, (err, stats) => {
// 读取内存里面的文件
const appFile = serverMfs.readFileSync(path.join(serverConfig.output.path, 'js/app.js'), 'utf-8');
createApp = eval(appFile).default;
update();
});
});
综上所述,其实就在于三个点,服务端导出的createApp,客户端编译的template,客户端构建时形成的clientManifest。利用crateApp生成当前路由匹配的dom节点,插入template中,再根据clientManifest动态按需加载当前页面所需要的资源文件。
对于ssr的改造做了上述这些,还有些项目优化,比如模块化,ts,store的按需注册,以及一些自定义插件等,就不一一道来了,喜欢的同学可以download源码或者fork过去玩玩。
有需要交流的同学,也欢迎评论区交流交流。
项目仓库:https://github.com/Vitaminaq/cfsw-vue-cli3.0/tree/ssr-vue3.0-cli
注册插件源码:https://github.com/Vitaminaq/cfsw-vue-cli3.0/tree/ssr-vue3.0-cli/plugins/ssr
项目中用到的插件仓库:https://github.com/Vitaminaq/plugins-vue(喜欢的同学可以自取,欢迎同学们加入开发)
更多推荐
所有评论(0)