前言#
这里排除独立包异步化等场景,如果可以,优先选择文件独立分包异步化。
另外这里的背景是基于uniapp编译成微信小程序。
vue是2.x版本,脚手架是vue-cli。
最近发现部门负责的小程序中存在一些全局文件只有在分包中有依赖,想去优化这块,于是衍生出两种开发场景。
- 依旧放到全局也就是主包中,这样只用维护一块,但是侵占主包空间。
- 放到各个分包中,但是需要维护多份。
方案#
那么有没有办法可以将两个优点结合呢?
也就是文件依旧放到主包,但是打包后各分包依赖的文件不是主包的。
有的,并且方案不少。
- 基于配置文件,自定义一个配置文件,这个文件存放需要派发到分包中文件资源路径。
- 优点:快速定位,不需要做过滤处理。
- 缺点:开发体验差,需要开发者自行去添加/移除。
- 基于文件源码搜索,通过自定义
loader对开发源码匹配处理(或者自己写一套预编译处理)。
- 优点:开发体验较好,基本可以无感。
- 缺点:需要对每个请求的文件进行源码匹配,性能较低耗时较高。
这里我个人经过考虑后选择方案2,因为这个是打包准备部署的阶段而并非开发阶段,所以这点耗时影响可以接受。
实现#
既然选定了方案,接下来就是实现。
那么该从哪个点切入呢?
这里选择对请求的路径进行改动,将主包请求路径改动到分包自己种,比如@/components/xxx/xx.vue改动为@/subpkg/xxx/xx.vue,这样后续打包就不会去请求主包里的xx.vue文件,自然就不会参与打包也就不侵占主包空间。
不过这里还有另一个问题,那就是分包中的xxx/xx.vue是不存在的,如果直接改请求路径那么是找不到的,会导致报错。
我这里就直接用暴力的方案,在请求之前做copyDir处理,这里面有挺多可以优化的点。
那么这俩问题的解决理论有了,可以动手实现了。
先来处理第一个问题,文件请求路径改写。
尝试1:通过babel自定义plugin#
我这里最先是考虑通过babel自定义plugin的方式来改写路径。
// 废弃
const { dirname, resolve } = require('path');
const { copySync } = require('fs-extra');
let page_json = {};
try {
page_json = require(resolve(process.cwd(), `src/pages.js`))();
} catch (error) {
console.log(error);
throw Error(error);
}
const CAPTURE_PATH = `__need_dispatch__`
const cp_from_target = `src/components/${CAPTURE_PATH}`
const DEST_PATH = '__been_dispatch__'
const componentDispatch = (sourcePath, currentFile) => {
// console.log(sourcePath, currentFile, babelPath);
const index = sourcePath.indexOf(CAPTURE_PATH)
if (index !== -1) {
let is_imported_by_main_package = false;
for (let i = 0; i < page_json.pages.length; i++) {
const { path = '' } = page_json.pages[i];
if (currentFile.indexOf(path) !== -1) {
// 说明是主包引用,这个时候不做处理
is_imported_by_main_package = true
break;
}
}
console.log('is_in_main_package', is_imported_by_main_package);
// 不是主包引用
if (!is_imported_by_main_package) {
// 判断是否已经存在
const prefix = resolve(process.cwd(), 'src/');
const sub_path = currentFile.split(prefix);
if (sub_path[1]) {
console.log('the_package_is', sub_path[1])
let sub_pub_list = sub_path[1].split(`\\`);
console.log(111, sub_pub_list);
if (sub_pub_list[1]) {
// 判断是否是子包中的名字
let subName = sub_pub_list[1];
let is_imported_by_sub_package = false;
for (let i = 0; i < page_json.subPackages.length; i++) {
const { root: sub_pack_name = '' } = page_json.subPackages[i];
console.log(sub_pack_name, subName)
if (sub_pack_name == subName) {
is_imported_by_sub_package = true;
break;
}
}
console.log('is_in_sub_package', is_imported_by_sub_package);
// 确定是子包中的
if (is_imported_by_sub_package) {
// 可以开始copy了。
const end_dir_list = (dirname(sourcePath) || '').split(CAPTURE_PATH);
const end_dir_name = end_dir_list[end_dir_list.length - 1].slice(1);
let basePath = process.cwd();
let cp_from_path = resolve(basePath, `${cp_from_target}/${end_dir_name}`);
let cp_to_path = resolve(basePath, `src/${subName}/${DEST_PATH}/${end_dir_name}`);
console.log(basePath, cp_to_path);
try {
console.log(cp_from_path);
console.log(cp_to_path);
copySync(cp_from_path, cp_to_path)
// const req_file = sourcePath.split(`/${CAPTURE_PATH}/`)
// if (req_file[1]) {
// sourcePath = `@/${subName}/${DEST_PATH}/${req_file[1]}`;
// }
// console.log(sourcePath);
} catch (error) {
console.log(error)
}
}
}
}
}
}
return sourcePath;
}
module.exports = {
componentDispatch
}
// plugins.push([
// 'module-resolver',
// {
// "root": ["./"],
// resolvePath(sourcePath, currentFile) {
// return componentDispatch(sourcePath, currentFile, __dirname);
// }
// }
// ])但是这里有两个问题:
- 开发者源码被改动
- 打包之后主包虽然没有对应文件了,但是分包中主包文件副本内容丢失
于是放弃。
尝试2:通过webpack自定义插件注册hooks监听生命周期#
针对于尝试1中的第一个问题,我这里想着如果要不改动源码并且改动请求路径,那么就得在module生成之前改动, 我选择监听的生命周期是normalModuleFactory[1] 拿到normalModuleFactory实例之后再监听它的生命周期beforeResolve[2] ,这个时候是还没有处理request的,所以在这里改动最好。
class rewriteNeedDispatchRequestURL {
apply (compiler) {
compiler.hooks.normalModuleFactory.tap('rewrite request need dispatch file path', (factory) => {
factory.hooks.beforeResolve.tap('rewrite request path', (req) => {
if (req.request.indexOf('@/components/__need_dispatch__/') !== -1) {
// test: use literal path
req.request = `@/xxx/__been_dispatch__/nd-test/nd-test.vue`;
if (req?.dependencies?.length > 0) {
for (let i = 0; i < req.dependencies.length; i++) {
const dep = req.dependencies[i];
if (dep.request || dep.userRequest) {
dep.request = req.request;
dep.userRequest = req.request;
}
}
}
console.log(req);
}
})
})
}
} 那么结果呢?
打包结果:
- 主包不包含对应文件,符合预期。
- 分包中的副本内容丢失,不符合预期。
- 开发者源码没有被改动,符合预期。
由于第二个问题过于奇怪,在挣扎了一段时间后选择放弃。
尝试3:通过自定义loader实现,规则/.js$/#
经过尝试2,我发现应该是选择的生命周期位置不够接近,所以干脆选择含有请求路径的文件被处理的时候来改写,这样应该就是最接近的时刻了。
那么自然是选择loader来处理。
并且得在babel-loader之前,因为得赶在源码被篡改之前动手改动。
代码就不放了,最终方案的代码差不多,到时直接看最终方案即可。
尝试之后依旧是失败了:
- 主包中有对应文件,不符合预期。
- 分包文件副本内容丢失,不符合预期。
- 开发者源码没有被改动,符合预期。
反思了下,想法是可以的,但是还是不够靠前,因为vue-loader会调用@vue/compiler-sfc将代码进行切割分别处理,babel拿到的已经不是第一手代码了。
那么自然就得在vue-loader之前。
尝试4:通过自定义loader实现,规则/.vue$/#
这回咱们赶在vue-loader处理前改动源码。
先在vue.config.js中写规则
const _module = {
rules: []
}
if (process.env.NODE_ENV === 'production' && process.env.UNI_PLATFORM === 'mp-weixin') {
plugins.push(new RemoveBeenDispatch());
_module.rules.push({
test: /\.vue$/,
use: ['./scripts/loader/rewriteNeedDispatchReqUrl.js']
})
}
module.exports = {
// ...
configureWebpack: {
plugins,
module: _module,
devtool: "source-map",
},
//...
};然后我们来实现这个loader
const reg = /@\/components\/_need_dispatch_\/(.*?)\.vue/gi
const { dirname, resolve } = require('path');
const { copySync } = require('fs-extra');
let page_json = {};
try {
page_json = require(resolve(process.cwd(), `src/pages.js`))();
} catch (error) {
// console.log(error);
throw Error(error);
}
// 待移除路径
if (!global._component_cache_set) {
global._component_cache_set = new Set();
}
// 缓存路径
if (!global._cache_cp_path) {
global._cache_cp_path = new Set();
}
function hasPath(path) {
return global._component_cache_set.has(path);
}
function cachePath(path) {
if (hasPath(path)) return;
global._component_cache_set.add(path);
}
function has_cp_cache_path(path) {
return global._cache_cp_path.has(path);
}
function cache_cp_path (path) {
if (has_cp_cache_path(path)) return;
global._cache_cp_path.add(path);
}
const CAPTURE_PATH = `_need_dispatch_`
const cp_from_target = `src/components/${CAPTURE_PATH}`
const DEST_PATH = '_been_dispatch_'
function componentDispatch (sourcePath, currentFile, callback) {
// console.log(`sourcePath: `, sourcePath);
// console.log(`currentFile: `, currentFile);
// // console.log(sourcePath, currentFile, babelPath);
const index = sourcePath.indexOf(CAPTURE_PATH)
if (index !== -1) {
let is_imported_by_main_package = false;
for (let i = 0; i < page_json.pages.length; i++) {
let { path = '' } = page_json.pages[i];
path = path.split('/')
// console.log(path);
if (path[path.length - 1] && currentFile.indexOf(path[path.length - 1]) !== -1) {
// 说明是主包引用,这个时候不做处理
is_imported_by_main_package = true
break;
}
}
// console.log('is_in_main_package', is_imported_by_main_package);
// 不是主包引用
if (!is_imported_by_main_package) {
// 判断是否已经存在
const prefix = resolve(process.cwd(), 'src/');
const sub_path = currentFile.split(prefix);
if (sub_path[1]) {
// console.log('the_package_is', sub_path[1])
let sub_pub_list = sub_path[1].split(`\\`);
if (sub_pub_list[1]) {
// 判断是否是子包中的名字
let subName = sub_pub_list[1];
let is_imported_by_sub_package = false;
for (let i = 0; i < page_json.subPackages.length; i++) {
const { root: sub_pack_name = '' } = page_json.subPackages[i];
// console.log(sub_pack_name, subName)
if (sub_pack_name == subName) {
is_imported_by_sub_package = true;
break;
}
}
// console.log('is_in_sub_package', is_imported_by_sub_package);
// 确定是子包中的
if (is_imported_by_sub_package) {
// 可以开始copy了。
const end_dir_list = (dirname(sourcePath) || '').split(CAPTURE_PATH);
const end_dir_name = end_dir_list[end_dir_list.length - 1].slice(1);
let basePath = process.cwd();
let cp_from_path = resolve(basePath, `${cp_from_target}/${end_dir_name}`);
let cp_to_path = resolve(basePath, `src/${subName}/${DEST_PATH}/${end_dir_name}`);
const req_file = sourcePath.split(`/${CAPTURE_PATH}/`)
if (req_file[1]) {
sourcePath = `@/${subName}/${DEST_PATH}/${req_file[1]}`;
}
if (has_cp_cache_path(cp_to_path)) {
callback(sourcePath)
return
}
try {
cache_cp_path(cp_to_path);
// 复制路径
copySync(cp_from_path, cp_to_path)
cachePath(resolve(basePath, `src/${subName}/${DEST_PATH}`));
callback(sourcePath)
} catch (error) {
console.log(error)
callback()
}
}
}
}
} else {
callback();
}
}
return sourcePath;
}
// let cache = new Map();
module.exports = function (content, map, meta) {
if (reg.test(content)) {
const match_list = content.match(reg);
// let cache_key = `${sourcePath}~${this.context}`;
// if (cache.get(cache_key)) {
// return content;
// }
// cache.set(cache_key, content);
for (let i = 0; i < match_list.length; i++) {
const sourcePath = match_list[i];
const fn = this.async();
const callback = function (fin_path) {
if (fin_path) {
content = content.replace(sourcePath, `${fin_path}`);
}
if (i >= match_list.length - 1) {
fn(null, content, map, meta);
}
};
componentDispatch(sourcePath, this.context, callback)
}
} else {
return content
}
}这里采用的是loader异步环境[3] ,这么做是为了确保copy文件/文件夹完成再改动请求路径。
这个方案最终的结果是:
- 主包中不再含有对应文件,符合预期
- 分包中文件副本内容没有丢失,符合预期
- 开发者源码没有被改动,符合预期
效果#
那么这里就基本符合我个人预期了,放一下效果图:
这里基于主包30多个组件以及6个分包依赖
优化前:#

可以看到这里主包已经爆掉了
编译时间

优化后:#

可以看到主包降回2M以内。
编译时间:

缺点#
经过上面的效果图对比,你应该发现了几处问题:
- 编译时间,这个主要是消耗在匹配逻辑和
copy文件。 - 分包变大,可能会超出2M
- 总包体积变大,变大的极端为:(分包个数 - 1) * 文件大小。
- 如果主包也有依赖,那么就无效(分包不会有副本,分包请求路径不改动保持为主包文件路径)。
优点#
虽然问题较多,但是实际上还是有价值使用的,比如:
- 临时主包爆了,找不到优化方案,可以尝试这个方案,之后再找替代方案。
- 非极端情况,大部分主包组件都不会被所有分包依赖,所以量不是很大的情况下这种方案的性价比是比较高的。
善后#
这个方案其实还有一个问题,那就是copy的副本会一直保留在分包中,这样就很恶心了,达不到我们“开发者无感知”的要求。
不过这个也好解决,我们可以暴力点,收集copy to的路径,然后监听webpack的生命周期,在done的阶段将文件移除即可。
基于global这个全局变量来缓存copy to的路径
// 待移除路径
if (!global._component_cache_set) {
global._component_cache_set = new Set();
}
// 缓存路径
if (!global._cache_cp_path) {
global._cache_cp_path = new Set();
}
function hasPath(path) {
return global._component_cache_set.has(path);
}
function cachePath(path) {
if (hasPath(path)) return;
global._component_cache_set.add(path);
}
function has_cp_cache_path(path) {
return global._cache_cp_path.has(path);
}
function cache_cp_path (path) {
if (has_cp_cache_path(path)) return;
global._cache_cp_path.add(path);
}
// ...
try {
cache_cp_path(cp_to_path);
// 复制路径
copySync(cp_from_path, cp_to_path)
cachePath(resolve(basePath, `src/${subName}/${DEST_PATH}`));
callback(sourcePath)
} catch (error) {
console.log(error)
callback()
}
// ...然后自定义webpack插件
class RemoveBeenDispatch {
apply (compiler) {
compiler.hooks.done.tap('remove all been dispatch pkg', () => {
console.log(`cache_copy_path`, global._cache_cp_path);
const set = global._component_cache_set;
console.log(`cache copy directory:`, set)
if (set && set?.size > 0) {
const list = Array.from(set);
for (let i = 0; i < list.length; i++) {
const path = list[i];
stat(path, (err, status) => {
if (err) {
console.log(err, path);
} else {
if (status.isDirectory()) {
removeSync(path)
}
}
})
}
set.clear();
}
})
}
} 这回就是“无感知”啦~
总结#
这个方案被驳回了。。。。但确实有一定的可行性。
如果觉得有帮助请务必点个赞,这是对我这个方案的肯定。。。
参考#
- ^normalModuleFactory https://webpack.js.org/api/compiler-hooks/#normalmodulefactory
- ^normalFactory-beforeResolve https://webpack.js.org/api/normalmodulefactory-hooks#beforeresolve
- ^async-loader https://webpack.js.org/api/loaders/#asynchronous-loaders
编辑于 2023-02-18 17:57・IP 属地广东
