上个月有网友看我之前用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(喜欢的同学可以自取,欢迎同学们加入开发)
原文:https://juejin.cn/post/7094641120633683982