为什么需要“分包异步化”?
uni-app 作为一款优秀的跨端框架,在小程序端的生态中占据了重要地位。但随着业务的迭代,我们常常会遇到小程序主包体积超限的问题。官方给出的解决方案是“分包”,但普通的分包只是解决了代码存放的问题。
当我们的分包需要依赖一个较大的公共模块(比如直播、IM、视频等 vendor 模块)时,如果这个 vendor 模块放在主包,会拖慢主包加载速度;如果放在分包,又会造成多个分包重复打包。
最理想的方案是“分包异步化”:将这个公共 vendor 模块作为一个独立的分包,在进入需要它的业务分包时,先异步加载 vendor 分包,成功后再加载业务分包。这样既能瘦身主包,又能实现公共模块的复用。
微信小程序原生已支持分包异步化,然而,uni-app 目前尚未原生支持分包异步化。
目标:就是通过 Webpack 插件,使以下代码在 uni-app 中实现分包异步加载:
require('分包中的js文件?root=分包名').then((res) => {
console.log('加载分包文件成功');
});
了解 require 函数的转换过程
在 Webpack 中,require 函数会经历一个复杂的转换和处理过程,被编译成 __webpack_require__ 函数。转换过程包括:
- 静态分析和依赖收集
Webpack通过解析代码中的require()或import语句,生成抽象语法树(AST)。- 分析 AST,收集所有模块的依赖关系,构建依赖图。
- 模块 ID 分配
- Webpack 为每个模块分配一个唯一的模块 ID(通常是数字或字符串)
- 将
require('./module.js')中的路径转换为对应的模块 ID,即_webpack_require__(chunkId)
- 代码生成与优化:
- 将模块代码打包为
chunk(代码块),生成最终的 bundle 文件。
在 uni-app 中,require 函数会被 Webpack 转换为 __webpack_require__。示例:
// 源代码 (页面.vue)
require('../../subpackage/request?root=分包名').then((res) => {
console.log('异步引入', res);
});
// subpackage/request.js
import { request } from '@/utils/wxapi';
console.log('我是分包中的js文件');
export const getData = () => {
return request({
url: '/request',
method: 'GET',
});
};
编译后代码:
__webpack_require__(/*! ../../subpackage/request */ 58).then(function (res) {
console.log('异步引入', res);
});
request.js 被分配模块 ID 58,并打包进主包的 vendor 文件:
/* 58 */
/*!****************************************!*\
!*** ./src/pages/subpackage/request.js ***!
\****************************************/
/*! no static exports found */
/***/(function(module, exports, __webpack_require__) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.getData = void 0;
var _wxapi = __webpack_require__(/*! @/utils/wxapi */ 16);
console.log("我是分包中的js文件");
// 获取数据
var getData = exports.getData = function getData() {
return (0, _wxapi.request)({
url: '/request',
method: 'GET'
});
};
实现“分包异步化”需要解决两个核心问题:
- 将需要异步加载的模块(如 request.js)单独打包,避免被合并到主包的
vendor文件。 - 将
require('path?root=分包名')转换为异步加载逻辑(如require.async('vendor 路径').then(...))。
也就是以下逻辑:
__webpack_require__(/*! ../../subpackage/request */ 58).then(function (res) {
console.log('异步引入', res);
});
替换成:
require.async(/** request文件单独的vendor包 */'../../subpackage/vendor.js').then(() => {
return Promise.resolve(__webpack_require(/** 单独vendor包下request文件的chunkId */, '具体的模块ID'))
})
将要异步引入的文件单独打包,避免被打包进主包的 vendor
uniapp 小程序打包的 SplitChunks 配置在 @dcloudio/vue-cli-plugin-cli/lib/split-chunks.js ,感兴趣的同学可以自己查看。
为了将要异步引入的文件单独打包,故需要覆盖原有的 SplitChunks 配置,我们可以编写 Webpack plugin 来实现,代码如下:
const PLUGIN_NAME = 'AsyncImportPlugin';
class AsyncImportPlugin {
constructor(options = {}) {
this.options = options;
}
apply(compiler) {
const platform = process.env.UNI_PLATFORM || process.env.VUE_APP_PLATFORM;
const isMP = platform && platform.startsWith('mp-');
if (!isMP) {
console.log(`[${PLUGIN_NAME}] Plugin disabled, not an MP platform.`);
return;
}
this.updateSplitChunksConfig(compiler);
}
updateSplitChunksConfig(compiler) {
if (!compiler.options.optimization) {
compiler.options.optimization = {};
}
const existingSplitChunks = compiler.options.optimization.splitChunks || {};
const existingCacheGroups = existingSplitChunks.cacheGroups || {};
const originalCommonsTest = existingCacheGroups.commons.test || (() => true);
existingCacheGroups.commons.test = (module, chunks) => {
// 增强 commons 的 test 函数,排除指定路径
const resourcePath = module.resource ? module.resource.replace(/\\/g, '/') : '';
const isExcluded = resourcePath && this.options.some((path) => resourcePath.includes(path));
if (isExcluded) {
return false;
}
return originalCommonsTest(module, chunks);
};
const newCacheGroups = this.getSplitChunkConfig();
compiler.options.optimization.splitChunks = {
...existingSplitChunks,
cacheGroups: {
...existingCacheGroups,
...newCacheGroups,
},
};
}
getSplitChunkConfig() {
const pathsArray = this.options;
if (!Array.isArray(pathsArray)) {
console.warn(`[${PLUGIN_NAME}] options should be an array, received:`);
return {};
}
const groups = pathsArray.reduce((acc, path) => {
if (typeof path !== 'string') return acc;
const parts = path.split('/');
if (parts.length < 2) return acc;
const key = `${parts[0]}/${parts[1]}`;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(path);
return acc;
}, {});
const cacheGroups = {};
let priority = 20;
for (const key in groups) {
const paths = groups[key];
const name = `${key}/common/vendor`;
if (name) {
cacheGroups[name] = {
name: name,
test: (module) => {
return module.resource && paths.some((p) => module.resource.replace(/\\/g, '/').includes(p));
},
chunks: 'all',
enforce: true,
priority: 20,
};
priority += 10;
}
}
return cacheGroups;
}
}
module.exports = AsyncImportPlugin;
上面代码中做的事情有:
- 对原有的
commons/vendor分组进行了增强,避免要异步引入的js文件使用了主包的通用方法而被打包进common/vendor里 - 将传入的
要异步引入的js文件进行单独打包,分别打包进自己的所在分包的 common 目录下
对编译后的文件“字符替换”,大功告成?
异步引入的js文件 被单独打包后,剩余的工作就是将编译后的 __webpack_require__ 替换成 require.async 就能实现分包异步化了!即:
__webpack_require__(/*! ../../subpackage/request */ 58).then(function (res) {
console.log('异步引入', res);
});
替换成:
require.async(/** request文件单独的vendor包 */'../../subpackage/vendor.js').then(() => {
return Promise.resolve(__webpack_require(/** 单独vendor包下request文件的chunkId */, '具体的模块ID'))
})
但编译后的文件使用 __webpack_require__ 加载所需的 chunkId 是很常见的,怎样才能知道哪些 __webpack_require__ 是我们要进行替换的呢?
可以在 require 函数里增加注释作为标记,也就是:require(/** 异步引入 */'../../packageName/js 文件'),只要有 /** 异步引入 */ 注释的我们都认为必须替换!
在 Webpack 插件的 emit 钩子里对编译后的文件进行字符替换就大功告成啦,在原有的插件代码上修改:
const MagicString = require('magic-string');
const { RawSource } = require('Webpack-sources');
const PLUGIN_NAME = 'AsyncImportPlugin';
class AsyncImportPlugin {
constructor(options = {}) {
this.options = options;
}
_parseWebpackImport(match) {
try {
const [fullMatch, params, chunkId] = match;
const sourceUrl = new URL(`http://localhost/${params}`); // 使用 URL 对象进行健壮的解析
const packageParams = sourceUrl.searchParams.get('root'); // 例如:'pages/subpackageName'
if (!packageParams) {
return { error: `在 "${params}" 中未找到 "?root=" 参数` };
}
// 'pages/subpackageName' -> 'subpackageName'
const packageName = packageParams.includes('/') ? packageParams.split('/')[1] : packageParams;
return {
fullMatch,
params,
packageParams,
chunkId,
packageName,
};
} catch (e) {
return { error: `解析参数 "${match[1]}" 失败: ${e.message}` };
}
}
/**
* 计算从当前文件到目标 vendor 文件的相对路径
* @param {string} currentFilePath - 当前文件路径
* @param {string} packageName - 包名
* @returns {string} 相对路径
*/
_calculateRelativePath(currentFilePath, packageName) {
// 标准化路径分隔符
const normalizedCurrentPath = currentFilePath.replace(/\\/g, '/');
// 移除可能的查询参数和哈希
const cleanPath = normalizedCurrentPath.split('?')[0].split('#')[0];
// 分割路径并过滤空字符串
const pathSegments = cleanPath.split('/').filter(Boolean);
// 如果路径为空或只有文件名,默认在根目录
if (pathSegments.length <= 1) {
return `pages/${packageName}/common/vendor.js`;
}
// 移除文件名,只保留目录路径
const currentDir = pathSegments.slice(0, -1);
// 计算需要向上回退的层级数
const upLevels = currentDir.length;
// 构建相对路径
const relativePath = upLevels > 0 ? '../'.repeat(upLevels) + `pages/${packageName}/common/vendor.js` : `pages/${packageName}/common/vendor.js`;
return relativePath;
}
/**
* 创建替换用的代码片段
* @param {string} packageName - 解析出的包名
* @param {string} originalParams - 原始的模块路径参数
* @param {string} chunkId - Webpack 的 chunk ID
* @returns {string}
*/
_createReplacementCode(packageName, originalParams, chunkId, currentFilePath) {
const vendorPath = this._calculateRelativePath(currentFilePath, packageName);
return `require.async('${vendorPath}').then(() => {
return Promise.resolve(__webpack_require__(${chunkId}));
})`;
}
apply(compiler) {
const platform = process.env.UNI_PLATFORM || process.env.VUE_APP_PLATFORM;
const isMP = platform && platform.startsWith('mp-');
if (!isMP) {
console.log(`[${PLUGIN_NAME}] Plugin disabled, not an MP platform.`);
return;
}
this.updateSplitChunksConfig(compiler);
compiler.hooks.afterPlugins.tap(PLUGIN_NAME, () => {
this.updateSplitChunksConfig(compiler);
});
compiler.hooks.emit.tapAsync(PLUGIN_NAME, (compilation, callback) => {
console.log(`[${PLUGIN_NAME}] Entered emit hook.`);
for (const assetName in compilation.assets) {
if (assetName.endsWith('.js')) {
const asset = compilation.assets[assetName];
const originalSource = asset.source();
const magicString = new MagicString(originalSource);
let modified = false;
const webpackDynamicImportRegex = /__webpack_require__\(\s*\/\*\*\s*异步引入\s*\*\/\s*\/\*!\s*([^*]+)\s*\*\/\s*(\d+)\s*\)/g;
let match;
while ((match = webpackDynamicImportRegex.exec(originalSource)) !== null) {
const parsed = this._parseWebpackImport(match);
if (parsed.error) {
compilation.errors.push(new Error(`[${PLUGIN_NAME}] ${parsed.error}`));
continue;
}
const { fullMatch, params, packageParams, chunkId, packageName } = parsed;
const replacement = this._createReplacementCode(packageName, params, chunkId, assetName);
modified = true;
magicString.overwrite(match.index, match.index + fullMatch.length, replacement);
}
if (modified) {
// 更新资源内容
compilation.assets[assetName] = new RawSource(magicString.toString());
}
}
}
callback();
});
}
updateSplitChunksConfig(compiler) {
if (!compiler.options.optimization) {
compiler.options.optimization = {};
}
const existingSplitChunks = compiler.options.optimization.splitChunks || {};
const existingCacheGroups = existingSplitChunks.cacheGroups || {};
const originalCommonsTest = existingCacheGroups.commons.test || (() => true);
existingCacheGroups.commons.test = (module, chunks) => {
// 增强 commons 的 test 函数,排除指定路径
const resourcePath = module.resource ? module.resource.replace(/\\/g, '/') : '';
const isExcluded = resourcePath && this.options.some((path) => resourcePath.includes(path));
if (isExcluded) {
return false;
}
return originalCommonsTest(module, chunks);
};
const newCacheGroups = this.getSplitChunkConfig();
compiler.options.optimization.splitChunks = {
...existingSplitChunks,
cacheGroups: {
...existingCacheGroups,
...newCacheGroups,
},
};
}
getSplitChunkConfig() {
const pathsArray = this.options;
if (!Array.isArray(pathsArray)) {
console.warn(`[${PLUGIN_NAME}] options should be an array, received:`);
return {};
}
const groups = pathsArray.reduce((acc, path) => {
if (typeof path !== 'string') return acc;
const parts = path.split('/');
if (parts.length < 2) return acc;
const key = `${parts[0]}/${parts[1]}`;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(path);
return acc;
}, {});
const cacheGroups = {};
let priority = 20;
for (const key in groups) {
const paths = groups[key];
const name = `${key}/common/vendor`;
if (name) {
cacheGroups[name] = {
name: name,
test: (module) => {
return module.resource && paths.some((p) => module.resource.replace(/\\/g, '/').includes(p));
},
chunks: 'all',
enforce: true,
priority: 20,
};
priority += 10;
}
}
return cacheGroups;
}
}
module.exports = AsyncImportPlugin;
在 emit 钩子里对编译后的文件进行替换,正则匹配带有 /** 异步引入 */ 的 __webpack_require__ 函数,将其替换为 require.async 的形式。并获取 __webpack_require__ 函数原有的 chunkId,Promise.resolve 出异步加载的代码。
在 vue.config.js 中配置写好的插件看下实际效果,注意:一定要在pages.json中声明subpackage分包
// vue.config.js
const asyncImportPlugin = require('./asyncImportPlugin.js')
module.exports = {
// webpack相关配置
configureWebpack: {
plugins: [
new asyncImportPlugin([
'pages/subpackage/request', // 要异步引入的js文件
]),
],
},
};
果然成功没那么简单,控制输出了一大串报错信息:Component is not found in path "wx://not-found".(env: macOS,mp,1.06.2409140; lib: 3.8.9)。
查看编译的 js 文件:
(global['webpackJsonp'] = global['webpackJsonp'] || []).push([
['pages/tabbar/index/index'], // chunk 标识
{
51: (function(module,exports,__webpack_require__){
}){} // 模块定义
},
[[[51,'common/runtime','pages/subpackage/common/vendor','common/vendor']]],// 依赖配置
]);
(global['webpackJsonp'] = global['webpackJsonp'] || []).push(...); 这段代码创建了一个全局队列,用于收集所有需要异步加载的代码块。每个 push 操作包含三个关键部分:
[
['chunk-id'], // 代码块标识
{
/* 模块映射 */
}, // 模块定义
[['依赖1', '依赖2']], // 依赖配置
];
Webpack 会重写 webpackJsonp.push 方法,当新代码块被推入队列时,执行安装逻辑:
function webpackJsonpCallback(data) {
const [chunkIds, moreModules, runtime] = data;
// 1. 将模块添加到模块系统
// 2. 检查并执行等待中的依赖
// 3. 标记代码块为已加载
}
上述方案中,我们创建了独立的分包 vendor 文件(如 pages/subpackage/common/vendor.js)。但在 Webpack 的构建系统中,它仍然被识别为主包的一部分依赖。
// Webpack 生成的依赖配置
[['common/runtime', 'common/vendor', 'pages/subpackage/common/vendor']];
会把 pages/subpackage/common/vendor.js 被声明为必需依赖,导致运行时依赖冲突。Webpack 运行时期望在主包中找到此依赖,但实际该文件位于分包中,小程序不会主动加载。
上面报错的原因就是:Webpack尝试加载不存在的资源路径。
解决方案也很简单,直接删除 pages/subpackage/common/vendor 这些依赖声明,插件新增以下代码:
while ((match = webpackDynamicImportRegex.exec(originalSource)) !== null) {
// 原有代码
// 以下为新增代码:
const textsToRemove = [`"${packageParams}/common/vendor",`, `"${packageParams}/common/vendor"`];
for (const text of textsToRemove) {
const index = originalSource.indexOf(text);
if (index !== -1) {
magicString.remove(index, index + text.length);
break;
}
}
}
修改后成功运行~
然而事情没有那么简单,就当我踌躇满志宣告自己成功时,运行后 npm run build 命令却给了我致命打击。
之前运行的命令都是 npm run dev 开发环境,编译后的产物有注释、__webpack_require__等函数名。然而这些特征在生产环境下为了极致的性能和体积,会被优化器(如 Terser)完全清除。
__webpack_require__被重命名为一个或多个单字符函数(如 r, t 等)。- 所有注释,包括用作标记的
/** 异步引入 */,都会被移除以减小文件体积。
到此为止了吗?
上面的方案是行不通了。
还是做不到吗?灵光一闪!突然想到了 __non_webpack_require__。
__non_webpack_require__ 是 Webpack 提供的一个特殊变量,用于在 Webpack 构建环境中访问原生的 Node.js require 函数,而不是被 Webpack 转换后的 __webpack_require__。
const lodash = require('lodash'); // 会被打包进bundle
const fs = __non_webpack_require__('fs'); // 运行时动态require
也就是说__non_webpack_require__会绕过 Webpack 的模块处理机制。以下代码
__non_webpack_require__.async('pages/subpackage').then(() => {
const res = require('../../subpackage/request.js');
console.log('request加载成功', res);
});
编译后:
require.async('pages/subpackage').then(() => {
const res = __webpack_require__(/** ../../subpackage/request.js */, chunkId);
console.log('request加载成功', res);
});
知道这些,就很好处理了,在 emit 钩子里匹配require.async('pages/subpackage')字符获取到分包名,将该路径替换成该分包下异步文件的 vendor 文件路径即可。
完全不需要关注 require 函数名最终被编译成什么。
插件代码:
const MagicString = require('magic-string');
const { RawSource } = require('Webpack-sources'); // Webpack 4 使用 Webpack-sources
const PLUGIN_NAME = 'AsyncImportPlugin';
class AsyncImportPlugin {
constructor(options = {}) {
this.options = options;
}
/**
* 计算从当前文件到目标 vendor 文件的相对路径
* @param {string} currentFilePath - 当前文件路径
* @param {string} packageName - 包名
* @returns {string} 相对路径
*/
_calculateRelativePath(currentFilePath, packageName) {
// 标准化路径分隔符
const normalizedCurrentPath = currentFilePath.replace(/\\/g, '/');
const cleanPath = normalizedCurrentPath.split('?')[0].split('#')[0];
// 分割路径并过滤空字符串
const pathSegments = cleanPath.split('/').filter(Boolean);
// 如果路径为空或只有文件名,默认在根目录
if (pathSegments.length <= 1) {
return `pages/${packageName}/common/vendor.js`;
}
// 移除文件名,只保留目录路径
const currentDir = pathSegments.slice(0, -1);
// 计算需要向上回退的层级数
const upLevels = currentDir.length;
// 构建相对路径
const relativePath = upLevels > 0 ? '../'.repeat(upLevels) + `pages/${packageName}/common/vendor.js` : `pages/${packageName}/common/vendor.js`;
return relativePath;
}
/**
* 创建替换用的代码片段
* @param {string} packageName - 解析出的包名
* @param {string} originalParams - 原始的模块路径参数
* @returns {string}
*/
_createReplacementCode(packageName, currentFilePath) {
const vendorPath = this._calculateRelativePath(currentFilePath, packageName);
return `require.async('${vendorPath}')`;
}
apply(compiler) {
const platform = process.env.UNI_PLATFORM || process.env.VUE_APP_PLATFORM;
const isMP = platform && platform.startsWith('mp-');
if (!isMP) {
console.log(`[${PLUGIN_NAME}] Plugin disabled, not an MP platform.`);
return;
}
// 重新覆盖splitChunk配置
compiler.hooks.emit.tapAsync(PLUGIN_NAME, (compilation, callback) => {
console.log(`[${PLUGIN_NAME}] Entered emit hook.`);
for (const assetName in compilation.assets) {
if (assetName.endsWith('.js')) {
const asset = compilation.assets[assetName];
const originalSource = asset.source()
const magicString = new MagicString(originalSource);
let modified = false;
const webpackDynamicImportRegex = /require\.async\(\s*['"]([^?'"]+)(?:\?([^'"]*))?['"]\s*\)/;
let match;
while ((match = webpackDynamicImportRegex.exec(originalSource)) !== null) {
const [fullMatch, packageParams] = match;
const packageName = packageParams.includes('/') ? packageParams.split('/')[1] : packageParams;
const replacement = this._createReplacementCode(packageName, assetName);
magicString.overwrite(match.index, match.index + fullMatch.length, replacement);
const textsToRemove = [`"${packageParams}/common/vendor",`, `"${packageParams}/common/vendor"`];
for (const text of textsToRemove) {
const index = originalSource.indexOf(text);
if (index !== -1) {
magicString.remove(index, index + text.**length**);
break;
}
}
}
if (!modified) {
modified = true;
}
if (modified) {
// 只有在真正修改了文件时才更新资源
const finalSource = magicString.toString();
compilation.assets[assetName] = new RawSource(finalSource);
}
}
}
callback();
});
}
}
module.exports = AsyncImportPlugin;
尝试运行,发现进入了“无限重新编译”。
原因:在 Webpack 的 watch 模式下 emit 阶段修改了资源内容会触发一次新的编译,在新的编译中,插件再次运行,替换逻辑 this._createReplacementCode 生成的代码中依然包含 require.async(...),插件会再次找到并替换它,导致资源再次被修改... 如此往复,形成无限的重新编译循环。
如何才能实现“执行一次后,再次执行不会产生任何变化”呢?最简单的方法是在替换后的代码中加入一个“标记”,并让正则表达式忽略带有这个标记的代码。
修改如下:
const webpackDynamicImportRegex = /(?<!\/\*--processed--\*\/)\s*require\.async\(\s*['"]([^?'"]+)(?:\?([^'"]*))?['"]\s*\)/g;
while ((match = webpackDynamicImportRegex.exec(originalSource)) !== null) {
const rawReplacement = this._createReplacementCode(packageName, assetName);
const replacement = `/*--processed--*/${rawReplacement}`;
}
这样,我们的正则表达式现在只会匹配那些没有/*--processed--*/的、我们尚未处理过的 require.async。用以下代码就能成功在 uniapp 中实现分包异步化:
__non_webpack_require__.async('pages/subpackage').then(() => {
const res = require('../../subpackage/request.js');
console.log('request加载成功', res);
});
对比最开始的方案,解决了生产环境和开发环境编译产物不一致的问题。但也有致命缺陷:
- 最终产物是错误的
生产环境构建出的 JS 文件里,会包含本不应该存在的
/*--processed--*/注释。违背了生产环境代码应该尽可能小、尽可能干净的原则。
最终方案
上述做法都是在输出阶段(emit)去操作被编译“混淆”后的代码,依赖编译产物,是极其脆弱的方案。最好的做法是:在模块构建阶段介入,通过 Loader 和 AST 来实现函数名的替换。
自定义 babel-loader 插件代码实现:
// babel-plugin-async-wrapper.js
const { declare } = require('@babel/helper-plugin-utils');
const t = require('@babel/types');
const path = require('path');
function calculateRelativePath(currentFilePath, packageName) {
try {
const normalizedCurrentPath = currentFilePath.replace(/\\/g, '/');
const cleanPath = normalizedCurrentPath.split('?')[0].split('#')[0];
const currentDir = path.dirname(cleanPath).replace(/\\/g, '/');
const currentSegments = currentDir.split('/').filter((segment) => segment && segment !== '.');
const pagesIndex = currentSegments.findIndex((segment) => segment === 'pages');
if (pagesIndex === -1) {
return `pages/${packageName}/common/vendor.js`;
}
const upLevels = currentSegments.length - pagesIndex - 1;
if (upLevels === 0) {
return `${packageName}/common/vendor.js`;
} else {
return '../'.repeat(upLevels) + `${packageName}/common/vendor.js`;
}
} catch (error) {
return `pages/${packageName}/common/vendor.js`;
}
}
/**
* 创建新的 AST 节点,表示异步 require 调用
* @param {string} packageName - 从 ?root= 中提取的参数,即 '指定参数'
* @param {string} fullImportPath - 原始的、完整的导入路径
* @returns {t.CallExpression} - 新的 AST 节点
*/
function createAsyncRequireExpression(packageParams, fullImportPath, filaPath) {
const cleanImportPath = fullImportPath.split('?')[0];
const packageName = packageParams.includes('/') ? packageParams.split('/')[1] : packageParams;
const vendorPath = calculateRelativePath(filaPath, packageName);
// 创建 __non_webpack_require__.async(vendorPath)
const asyncRequireCall = t.callExpression(t.memberExpression(t.identifier('__non_webpack_require__'), t.identifier('async')), [t.stringLiteral(vendorPath)]);
// 创建回调函数: () => { return Promise.resolve(require(cleanImportPath)) }
const thenCallback = t.arrowFunctionExpression(
[],
t.blockStatement([t.returnStatement(t.callExpression(t.memberExpression(t.identifier('Promise'), t.identifier('resolve')), [t.callExpression(t.identifier('require'), [t.stringLiteral(cleanImportPath)])]))])
);
return t.callExpression(t.memberExpression(asyncRequireCall, t.identifier('then')), [thenCallback]);
}
/**
* 处理 import 转换的核心逻辑
* @param {import('@babel/core').NodePath} path - Babel 访问器提供的当前节点路径 (即 import() 对应的 CallExpression 路径)
* @param {string} importString - 包含查询参数的导入路径字符串
*/
function transformImport(path, importString, filaPath) {
try {
const rootMatch = importString.match(/[?&]root=([^&]*)/);
if (!rootMatch) return;
const packageName = decodeURIComponent(rootMatch[1]);
if (!packageName) return;
const newExpression = createAsyncRequireExpression(packageName, importString, filaPath);
// 直接替换当前的 import() 节点
path.replaceWith(newExpression);
} catch (error) {
console.error(`[AsyncWrapper] Error processing ${importString}:`, error.message, error.stack);
}
}
module.exports = declare((api) => {
api.assertVersion(7);
return {
name: 'async-wrapper',
pre(file) {
this.currentFilePath = file.opts.filename;
if (!file.code.includes('?root=')) {
this.shouldSkip = true;
}
},
visitor: {
CallExpression(path) {
if (this.shouldSkip || !path.get('callee').isImport()) {
return;
}
const arg = path.get('arguments')[0];
if (arg && arg.isStringLiteral() && arg.node.value.includes('?root=')) {
// 传入 import() 节点路径
transformImport(path, arg.node.value, this.currentFilePath);
}
},
},
};
});
babel 插件做的任务:
- 代码转换:将
import('path?root=xxx')转换为异步加载逻辑 - AST 操作:精确识别和替换特定的语法结构
- 路径计算:生成正确的相对路径引用
具体实现:
// 转换前
import('../../subpackage/request.js?root=pages/subpackage');
// 转换后
__non_webpack_require__.async('pages/subpackage/common/vendor.js').then(() => {
return Promise.resolve(require('../../subpackage/request.js'));
});
最后的 plugin 代码片段:
const Webpack = require('Webpack');
const MagicString = require('magic-string');
const { RawSource } = require('Webpack-sources');
const PLUGIN_NAME = 'AsyncImportPlugin';
class AsyncImportPlugin {
constructor(options = {}) {
this.options = options;
}
normalizePath(path) {
let normalizedPath = path.replace(/^(\.\.\/|\.\/)+/, '').replace('.js', '');
if (!normalizedPath.startsWith('pages/')) {
normalizedPath = 'pages/' + normalizedPath;
}
return normalizedPath;
}
apply(compiler) {
const platform = process.env.UNI_PLATFORM || process.env.VUE_APP_PLATFORM;
const isMP = platform && platform.startsWith('mp-');
if (!isMP) {
console.log(`[${PLUGIN_NAME}] Plugin disabled, not an MP platform.`);
return;
}
// 重新覆盖splitChunk配置
// ......
compiler.hooks.emit.tapAsync(PLUGIN_NAME, (compilation, callback) => {
console.log(`[${PLUGIN_NAME}] Entered emit hook.`);
for (const assetName in compilation.assets) {
if (assetName.endsWith('.js')) {
const asset = compilation.assets[assetName];
const originalSource = asset.source();
const magicString = new MagicString(originalSource);
let modified = false;
const webpackDynamicImportRegex = /require\.async\s*\(\s*(['"])(.+?)\1\s*\)/g;
let match;
while ((match = webpackDynamicImportRegex.exec(originalSource)) !== null) {
const [_1, _2, packageParams] = match;
const bundleName = this.normalizePath(packageParams);
const textsToRemove = [`"${bundleName}",`, `"${bundleName}"`];
for (const text of textsToRemove) {
const index = originalSource.indexOf(text);
if (index !== -1) {
magicString.remove(index, index + text.length);
break;
}
}
if (!modified) {
modified = true;
}
}
if (modified) {
// 只有在真正修改了文件时才更新资源
const finalSource = magicString.toString();
compilation.assets[assetName] = new RawSource(finalSource);
}
}
}
callback();
});
}
}
module.exports = AsyncImportPlugin;
babel 插件和 webpack 插件流程图,如下:
sequenceDiagram
participant SC as 源代码
participant BP as Babel插件
participant WB as Webpack构建
participant WP as Webpack插件
participant FO as 最终产物
SC->>BP: import('path?root=xxx')
BP->>BP: AST转换
BP->>WB: __non_webpack_require__.async(...)
WB->>WB: 模块分析 和 依赖收集
WB->>WP: 触发构建钩子
WP->>WP: 修改splitChunks配置
WP->>WP: 独立打包vendor模块
WB->>WP: emit阶段
WP->>WP: 清理产物依赖项
WP->>FO: 干净的最终产物
在页面中使用为:
const res = await import('../../subpackage/request?root=subpackage/request')
console.log("分包文件加载成功", res)
开源地址 uni-async-import
待完善的点
- 自动收集异步引入的文件
- 分包异步引入组件
- 分包异步化引入文件失败重试