如何使用vue-cli,做vue3.0的服务端渲染(ssr)

上个月有网友看我之前用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(喜欢的同学可以自取,欢迎同学们加入开发)

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值