【Webpack 插件深度实践】从 emit 钩子到 Babel 插件,我是如何实现 uniapp 的分包异步化的

272 阅读10分钟

为什么需要“分包异步化”?

uni-app 作为一款优秀的跨端框架,在小程序端的生态中占据了重要地位。但随着业务的迭代,我们常常会遇到小程序主包体积超限的问题。官方给出的解决方案是“分包”,但普通的分包只是解决了代码存放的问题。

当我们的分包需要依赖一个较大的公共模块(比如直播、IM、视频等 vendor 模块)时,如果这个 vendor 模块放在主包,会拖慢主包加载速度;如果放在分包,又会造成多个分包重复打包。

最理想的方案是“分包异步化”:将这个公共 vendor 模块作为一个独立的分包,在进入需要它的业务分包时,先异步加载 vendor 分包,成功后再加载业务分包。这样既能瘦身主包,又能实现公共模块的复用。

微信小程序原生已支持分包异步化,然而,uni-app 目前尚未原生支持分包异步化。

目标:就是通过 Webpack 插件,使以下代码在 uni-app 中实现分包异步加载:

require('分包中的js文件?root=分包名').then((res) => {
    console.log('加载分包文件成功');
});

了解 require 函数的转换过程

Webpack 中,require 函数会经历一个复杂的转换和处理过程,被编译成 __webpack_require__ 函数。转换过程包括:

  1. 静态分析和依赖收集
  • Webpack 通过解析代码中的 require()import 语句,生成抽象语法树(AST)。
  • 分析 AST,收集所有模块的依赖关系,构建依赖图。
  1. 模块 ID 分配
  • Webpack 为每个模块分配一个唯一的模块 ID(通常是数字或字符串)
  • require('./module.js') 中的路径转换为对应的模块 ID,即 _webpack_require__(chunkId)
  1. 代码生成与优化:
  • 将模块代码打包为 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'
  });
};

实现“分包异步化”需要解决两个核心问题:

  1. 将需要异步加载的模块(如 request.js)单独打包,避免被合并到主包的 vendor 文件。
  2. 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__ 函数原有的 chunkIdPromise.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);
});

对比最开始的方案,解决了生产环境和开发环境编译产物不一致的问题。但也有致命缺陷:

  1. 最终产物是错误的 生产环境构建出的 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

待完善的点

  • 自动收集异步引入的文件
  • 分包异步引入组件
  • 分包异步化引入文件失败重试