在webpack中基于chunk加载external外部依赖
webpack可以通过配置webpack external来声明外部依赖模块,避免直接构建到bundle包里面,常见于构建优化和加载外部SDK等场景。
但是默认的external只是在整个入口文件时提前加载了外部依赖,如果某个external的文件只是某个页面级别的动态chunk文件依赖的,在入口文件提前加载就不太合适了
需要研究一种更合理的机制,可以在加载具体的chunk文件时,才去加载相关的外部external文件,提升页面加载和响应速度
externals的行为
开发者保证运行上下文存在外部模块
在 Webpack 打包后,声明为 externals
的依赖会被视为外部变量引用,它们不会被 Webpack 打进最终的 bundle 中,而是被假设运行时环境已经提供了这些全局变量。
使用 externals
的默认配置格式,例如:
externals: {
react: 'React',
'react-dom': 'ReactDOM',
}
等价于告诉 Webpack:
“当遇到
import React from 'react'
时,不要去打包react
,而是视为全局变量React
已经存在。”
所以 webpack 在打包后的代码中不会去 require('react')
或 __webpack_require__('react')
,而是直接使用:
var React = window.React; // 或 global.React,取决于上下文
开发者 必须 保证在运行你的 bundle 前,全局变量(如 React
)已经存在。通常是在 HTML 中的 <script>
标签加载外部依赖,例如:
<!-- 在打包文件之前引入外部依赖 -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<!-- 然后再加载你 webpack 打包出来的 JS -->
<script src="/dist/main.js"></script>
如果外部依赖未先于你的 bundle 加载,运行时会出现类似:
Uncaught ReferenceError: React is not defined
输出格式决定构建内容
并不是每种格式都会按照window.React方式来寻找外部的external,根据output.libraryTarget
输出的代码会有差异,
当libraryTarget
为window
时
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ "react":
/*!************************!*\
!*** external "React" ***!
\************************/
/***/ ((module) => {
module.exports = window["React"];
/***/ })
当libraryTarget
为umd
时
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(require("React"));
else if(typeof define === 'function' && define.amd)
define(["React"], factory);
else if(typeof exports === 'object')
exports["NovaPlayer"] = factory(require("React"));
else
root["NovaPlayer"] = factory(root["React"]);
})(self, (__WEBPACK_EXTERNAL_MODULE_react__) => {
externals
的 value 可以是一个“构建格式到外部模块名称的映射对象”,种配置方式允许你更精细地指定在不同的模块系统下(如 CommonJS、AMD、UMD、浏览器全局变量)如何访问外部依赖。
比如
externals: {
lodash: {
commonjs: 'lodash',
commonjs2: 'lodash',
amd: 'lodash',
root: '_' // 对应浏览器全局变量 window._(UMD模式下使用)
}
}
键名 | 描述 |
---|---|
commonjs | 当模块使用 CommonJS 规范(如 require('lodash') )时的模块名 |
commonjs2 | 和 commonjs 类似,主要用于 Webpack 自己的打包机制 |
amd | 当使用 AMD(如 define(['lodash'], factory) )加载时的模块名 |
root | 当以全局变量方式访问时(如浏览器直接用 <script> 加载),对应全局变量名(如 _ ) |
umd | 也可以使用 umd 字段直接统一配置 root/commonjs/amd,但一般分开更精细 |
默认行为的缺陷
我们目前的项目是基于AMD模块的运行时项目架构,因此构建的格式是UMD
,同时配置了部分模块为externals,避免重复打包。
从上面的UMD处理的externals来看,当运行时环境为AMD模块时,externals会放在模块的deps数组中
if(typeof define === 'function' && define.amd)
define(["React"], factory);
这就导致在初始化应用时,会先去加载外部的externals模块,再去调用factory函数,执行当前的业务代码。
从externals的原理上来看,这并没有什么问题:在业务代码运行时,已经加载了外部externals模块,这样才可以通过factory函数的参数__WEBPACK_EXTERNAL_MODULE_react__
接收到外部的模块。
前端应用存在很多通过split code拆分chunk的地方,尤其是路由组件,这样可以避免构建出一个很大的bundle包,影响首屏加载速度。
从代码拆分的角度来看,上面这种处理externals的方式,就存在一些问题:当某个external的模块,实际上只在某个动态加载的模块中调用时,在最终构建中,却会将这个external一起挪动到整个应用的deps依赖中,进行了前置加载。
代码拆分本身就是为了优化首屏加载速度,但现在由于需要加载外部模块,又会影响首屏加载速度。
因此,Webpack对于externals的处理,是一种保守策略,只会保证在代码运行时,外部的依赖模块被提前加载好了,并无法精确区分处理是同步代码还是异步chunk里面的external。
期望场景
对于我们的项目来说,由于external的模块很多,都在首页加载时,会拖慢页面整体加载速度。
我们希望可以更精确地管理外部依赖,只有在用到某个chunk时,才加载这个chunk对应的依赖,这样可以大幅减少首页初始化时,需要加载的外部external数量。
按需加载每个chunk的external
对于上述提到的期望场景,一种最简单的实现方案是修改代码编写的方式,比如某个动态加载的页面组件
import someExternal from 'some_external'
export default function Page(){
return <div>page</div>
}
修改为
export default function Page(){
const [external,setExternal] = useState()
useEffect(()=>{
window.require('some_external',function(mod){
setExternal(mod)
})
})
return <div>page</div>
}
这种写法虽然很简单,但是对于开发者来说心智负担太重,且对代码侵入很大,如果后续采用了其他方案,甚至是其他模块方案,所有代码都需要改动,因此首先将这种方案排除掉。我们希望在构建的时候抹平这些差异,让代码开发者无需关注具体的细节。
换个角度想,我们是不是只需要通过某种技术手段,识别到每个chunk依赖的具体external,然后将对应的external从首页加载,延迟到这个chunk加载之前触发,就可以达到我们的目的呢?
要实现这个功能,需要预研和解决以下前置任务:
分析每个chunk的external依赖关系
在webpack构建阶段,收集每个异步chunk实际依赖的external模块,建立chunk与external的映射关系,便于后续按需加载。生成external依赖的加载清单
构建时输出一份external依赖清单,描述每个chunk所需的external模块,供运行时动态加载时参考。改造chunk加载流程
在运行时拦截或扩展webpack的chunk加载逻辑(如__webpack_require__.e
),在加载chunk前,先判断并加载该chunk所需的external模块,确保依赖就绪。
从webpack加载拆分的chunk
webpack在构建时,会自动添加webpack模块管理的一系列运行时代码,其中加载动态chunk是通过__webpack_require__.e()
这个函数来实现的。
这个函数的核心逻辑是:
- 检查chunk是否已加载:通过
installedChunks
对象记录chunk的加载状态 - 创建script标签:动态创建
<script>
标签来加载chunk文件 - 处理加载结果:通过Promise机制处理加载成功或失败
// webpack运行时的chunk加载函数(简化版)
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
// 检查chunk是否已安装
var installedChunkData = installedChunks[chunkId];
if(installedChunkData !== 0) { // 0表示已安装
if(installedChunkData) {
promises.push(installedChunkData[2]); // 返回已有的Promise
} else {
// 创建新的Promise
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
// 创建script标签加载chunk
var script = document.createElement('script');
script.charset = 'utf-8';
script.timeout = 120;
script.src = __webpack_require__.p + chunkId + '.js';
// 处理加载完成
var onScriptComplete = function (event) {
var chunk = installedChunks[chunkId];
if(chunk !== 0) {
if(chunk) {
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
}
installedChunks[chunkId] = undefined;
}
};
script.onerror = script.onload = onScriptComplete;
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
修改动态加载的运行时代码
这种方案的核心是在chunk加载前,先确保其依赖的external模块已经可用,我们需要重写webpack的__webpack_require__.e
函数,在加载chunk前先加载其依赖的external模块。
既然__webpack_require__.e
是一个运行时函数,跟我们的业务代码在一起运行,那么有办法可以对其进行修改。
RuntimeModule
是webpack在运行时代码注入机制中的一个扩展点,允许开发者自定义并向最终的bundle中插入特定的运行时代码。
通过继承RuntimeModule并实现generate方法,可以将自定义的JS逻辑注入到webpack的运行时流程中,从而实现对webpack运行时行为的灵活扩展。
由于我们的项目使用的是AMD模块,因此使用window.require
的写法来加载依赖的external模块,然后再加载原本的chunk模块
const RuntimeModule = require('webpack/lib/RuntimeModule');
const RuntimeGlobals = require('webpack/lib/RuntimeGlobals');
class MyRuntimeModule extends RuntimeModule {
constructor(chunkMaps) {
// 在原有的require ensure运行时模块后执行
super('my-runtime-module', RuntimeModule.STAGE_ATTACH + 1);
this.chunkMaps = chunkMaps;
}
generate() {
// 注入webpack require运行时代码
return Template.asString([
`var chunkIdImportMaps = ${JSON.stringify(this.chunkMaps, null, 2)};`,
`var originalEnsure = ${RuntimeGlobals.ensureChunk};`,
`${RuntimeGlobals.ensureChunk} = function(chunkId) {`,
Template.indent([
`// 在加载chunk之前,先检查并加载该chunk依赖的AMD模块`,
`var chunkDeps = chunkIdImportMaps[chunkId] || [];`,
`// 如果有AMD依赖,先加载它们`,
`if (chunkDeps.length > 0) {`,
Template.indent([
`return new Promise(function(resolve) {`,
Template.indent([
`window.require(chunkDeps, function() {`,
Template.indent([`resolve(originalEnsure(chunkId));`]),
`});`,
]),
`});`,
]),
`}`,
`// 如果没有AMD依赖,直接使用原始的加载函数`,
`return originalEnsure(chunkId);`,
]),
`};`,
]);
}
}
其中的chunkMaps
,假设就是我们收集到的每个chunkId与其依赖的external模块的映射表。
然后,在自定义webpack插件的合适hook中(如compilation.hooks.runtimeRequirementInTree
)插入上面的运行时代码模块
compiler.hooks.compilation.tap('MyRuntimeModulePlugin', (compilation) => {
compilation.hooks.runtimeRequirementInTree
.for(RuntimeGlobals.ensureChunk)
.tap('MyRuntimeModulePlugin', (chunk, set) => {
// 插入自定义的RuntimeModule
compilation.addRuntimeModule(
chunk,
new MyRuntimeModule(chunkExternalMap)
);
});
});
替换原本的external引入方式
external的模块在写法上跟本地模块是一样的,唯一的区别在于其在webpack.config
的配置externals字段里面进行了声明而已
比如某个chunk文件page1.js
import someExternal from 'some_external'
export default function Page(){
return <div>page</div>
}
在上面我们重新修改了动态加载的运行时代码,还需要需要修改AMD模块的引入写法,避免再通过webpack,将external里面的模块再次添加到全局里面
AMD规范里面,require实际上有两种写法
最常见的异步回调写法
require(moduleName,function(module){})
实际上还有一种同步写法,当某个模块已经被加载后,可以通过同步的写法直接获取到这个模块
const module = require(moduleName)
在上面修改chunk加载过程中,使用异步的方式提前加载了依赖的模块,因此现在在原本chunk代码里面,就可以使用同步的方式获取模块
即下面的依赖代码
import someExternal from 'some_external'
会被转成
const someExternal = window.require('some_external')
这一步可以通过一个babel插件来实现
const pkgName = 'some_external'
function() {
return {
visitor: {
ImportDeclaration(path, state) {
const source = path.node.source.value;
if(source !== pkgName) return // 这里替换为具体判断external的逻辑
const specifiers = path.node.specifiers;
if (!specifiers.some(spec => t.isImportSpecifier(spec))) return
// 生成多个const声明
const declarations = specifiers.map(specifier => {
const name = specifier.local.name;
const modulePath = pkgName;
// 生成 const name = window.require('name')
return t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(name),
t.callExpression(
t.memberExpression(
t.identifier('window'),
t.identifier('require')
),
[t.stringLiteral(modulePath)]
)
)
]);
});
// 替换原始import语句为多个const声明
if (declarations.length === 1) {
path.replaceWith(declarations[0]);
} else {
path.replaceWithMultiple(declarations);
}
}
}
};
}
现在,只要加载chunk前,正确加载了chunk依赖的external模块,上述代码就可以正常运行,并且对于开发者而言,这是完全无感知的。
获取某个chunk的外部依赖
我们可以通过webpack插件的一些hooks,获取到其外部依赖some_external
,将其写入一个映射表中
需要注意的是,一个chunk,最终构建的代码是来源这个chunk入口文件,及其部分依赖文件合并在一起的代码,其依赖的external,不仅包括了入口文件的,也包括了其依赖文件依赖的external
要获取某个chunk文件的external外部依赖,本质上就是从其全量依赖中,过滤出那些属于external的依赖而已。
但上面我们已经将源代码通过babel的形式进行了转换,需要通过源码分析的方式,在hooks.parser
阶段提取出window.require
形式的代码
因此,最终的实现代码大概类似下面的过程
const fileImportMaps = {};
// 阶段1:解析模块并记录映射
compiler.hooks.normalModuleFactory.tap('ExternalDepsPlugin', (normalModuleFactory) => {
// 处理导入语句转换
normalModuleFactory.hooks.parser.for('javascript/auto').tap('ExternalDepsPlugin', (parser) => {
parser.hooks.statement.tap('ExternalDepsPlugin', (statement) => {
if (statement.type === 'VariableDeclaration') {
const declarations = statement.declarations || [];
declarations.forEach(declaration => {
// 解析出代码中使用 const xx = window.require('xxx')形式的amd模块
if (declaration.init &&
declaration.init.type === 'CallExpression' &&
declaration.init.callee.type === 'MemberExpression' &&
declaration.init.callee.object.name === 'window' &&
declaration.init.callee.property.name === 'require') {
const args = declaration.init.arguments;
if (args.length > 0 && this.checkAmdModule(args[0].value)) {
const filePath = parser.state.current.resource;
fileImportMaps[filePath] = fileImportMaps[filePath] || [];
fileImportMaps[filePath].push(args[0].value);
}
}
});
}
});
});
});
然后在afterOptimizeChunks
阶段将每个chunk的模块解析出来,分析里面的external模块
compiler.hooks.compilation.tap('ExternalDepsPlugin', (compilation) => {
compilation.hooks.afterOptimizeChunks.tap('ExternalDepsPlugin', (chunks) => {
chunks.forEach((chunk) => {
// 阶段2:收集 Chunk 映射
const modules = compilation.chunkGraph.getChunkModules(chunk);
const entryModules = compilation.chunkGraph.getChunkEntryModulesIterable(chunk);
// 保存在chunk上
chunk.__file_deps_list = [...entryModules, ...modules].map(row => {
return row.resource
}).filter(Boolean)
});
});
compilation.hooks.runtimeRequirementInTree
.for(RuntimeGlobals.ensureChunk)
.tap('ExternalDepsPlugin', (chunk) => {
// 阶段3:生成chunk和file映射关系
const chunkIdImportMaps = {};
let index = 0
compilation.chunks.forEach((chunk) => {
const fileList = chunk.__file_deps_list ?? []
for (const filePath of fileList) {
if (fileImportMaps[filePath]) {
if (!chunkIdImportMaps[chunk.id]) {
chunkIdImportMaps[chunk.id] = new Set();
}
for (const dep of fileImportMaps[filePath]) {
chunkIdImportMaps[chunk.id].add(dep);
}
}
}
index++
})
const chunkIdMaps = Object.keys(chunkIdImportMaps).reduce((acc, id) => {
acc[id] = Array.from(chunkIdImportMaps[id]);
return acc;
}, {});
// 阶段4:修改运行时代码
compilation.addRuntimeModule(
chunk,
new MyRuntimeModule(chunkIdMaps),
);
});
});
})
这样我们就能在构建时准确地分析出每个chunk依赖的external模块,交给第一步运行时代码依赖的chunkIdImportMaps
小结
适用场景
external的使用,与项目的拆包策有关系:
- 如果只有一个bundle包,webpack默认加载external的行为是完全满足要求的
- 如果只有少量的动态加载的chunk,在初始化时加载完所有的外部依赖也是合理的
- 在一个完整的前端应用中,往往都会采取按路由页面动态加载的拆分策略,如果应用的routes较多,则拆分出来的动态chunk也会很多,这个时候,在初始化时加载全量的external,就不是一个最优的方案,尤其是在项目存在很多external,且部分external只会在少数的chunk下使用的场景下。
更优的方案:浏览器ESM
由于历史原因,项目采用了AMD模块管理不同团队之间的公共模块,AMD是比较原始的前端模块化方案了,截止今日(2025年),浏览器原生的ESM模块已经在主流的浏览器稳定运行了多个版本
随着浏览器对 ES Modules(ESM)的全面支持,前端模块化已经进入了一个全新的阶段。ESM 不仅语法简洁、静态可分析,还天然支持按需加载(即动态导入),极大地优化了资源加载和首屏性能。
import xx from 'http://xx.cdn.com/some_external'
这样,在构建时,只需要将对应的外部模块替换为实际的模块地址,就可以实现精确的按需加载。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
