侧边栏

在webpack中基于chunk加载external外部依赖

发布于 | 分类于 前端/前端工程

webpack可以通过配置webpack external来声明外部依赖模块,避免直接构建到bundle包里面,常见于构建优化和加载外部SDK等场景。

但是默认的external只是在整个入口文件时提前加载了外部依赖,如果某个external的文件只是某个页面级别的动态chunk文件依赖的,在入口文件提前加载就不太合适了

需要研究一种更合理的机制,可以在加载具体的chunk文件时,才去加载相关的外部external文件,提升页面加载和响应速度

externals的行为

开发者保证运行上下文存在外部模块

在 Webpack 打包后,声明为 externals 的依赖会被视为外部变量引用,它们不会被 Webpack 打进最终的 bundle 中,而是被假设运行时环境已经提供了这些全局变量。

使用 externals 的默认配置格式,例如:

js
externals: {
  react: 'React',
  'react-dom': 'ReactDOM',
}

等价于告诉 Webpack:

“当遇到 import React from 'react' 时,不要去打包 react,而是视为全局变量 React 已经存在。”

所以 webpack 在打包后的代码中不会去 require('react')__webpack_require__('react'),而是直接使用:

js
var React = window.React; // 或 global.React,取决于上下文

开发者 必须 保证在运行你的 bundle 前,全局变量(如 React已经存在。通常是在 HTML 中的 <script> 标签加载外部依赖,例如:

html
<!-- 在打包文件之前引入外部依赖 -->
<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输出的代码会有差异,

libraryTargetwindow

js
/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ({
/***/ "react":
/*!************************!*\
  !*** external "React" ***!
  \************************/
/***/ ((module) => {
module.exports = window["React"];
/***/ })

libraryTargetumd

js
(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、浏览器全局变量)如何访问外部依赖。

比如

js
externals: {
  lodash: {
    commonjs: 'lodash',
    commonjs2: 'lodash',
    amd: 'lodash',
    root: '_' // 对应浏览器全局变量 window._(UMD模式下使用)
  }
}
键名描述
commonjs当模块使用 CommonJS 规范(如 require('lodash'))时的模块名
commonjs2commonjs 类似,主要用于 Webpack 自己的打包机制
amd当使用 AMD(如 define(['lodash'], factory))加载时的模块名
root当以全局变量方式访问时(如浏览器直接用 <script> 加载),对应全局变量名(如 _
umd也可以使用 umd 字段直接统一配置 root/commonjs/amd,但一般分开更精细

默认行为的缺陷

我们目前的项目是基于AMD模块的运行时项目架构,因此构建的格式是UMD,同时配置了部分模块为externals,避免重复打包。

从上面的UMD处理的externals来看,当运行时环境为AMD模块时,externals会放在模块的deps数组中

js
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

对于上述提到的期望场景,一种最简单的实现方案是修改代码编写的方式,比如某个动态加载的页面组件

jsx
import someExternal from 'some_external'
export default function Page(){
  return <div>page</div>
}

修改为

jsx
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加载之前触发,就可以达到我们的目的呢?

要实现这个功能,需要预研和解决以下前置任务:

  1. 分析每个chunk的external依赖关系
    在webpack构建阶段,收集每个异步chunk实际依赖的external模块,建立chunk与external的映射关系,便于后续按需加载。

  2. 生成external依赖的加载清单
    构建时输出一份external依赖清单,描述每个chunk所需的external模块,供运行时动态加载时参考。

  3. 改造chunk加载流程
    在运行时拦截或扩展webpack的chunk加载逻辑(如__webpack_require__.e),在加载chunk前,先判断并加载该chunk所需的external模块,确保依赖就绪。

从webpack加载拆分的chunk

webpack在构建时,会自动添加webpack模块管理的一系列运行时代码,其中加载动态chunk是通过__webpack_require__.e()这个函数来实现的。

这个函数的核心逻辑是:

  1. 检查chunk是否已加载:通过installedChunks对象记录chunk的加载状态
  2. 创建script标签:动态创建<script>标签来加载chunk文件
  3. 处理加载结果:通过Promise机制处理加载成功或失败
js
// 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模块

js
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)插入上面的运行时代码模块

js
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

jsx
import someExternal from 'some_external'
export default function Page(){
  return <div>page</div>
}

在上面我们重新修改了动态加载的运行时代码,还需要需要修改AMD模块的引入写法,避免再通过webpack,将external里面的模块再次添加到全局里面

AMD规范里面,require实际上有两种写法

最常见的异步回调写法

js
require(moduleName,function(module){})

实际上还有一种同步写法,当某个模块已经被加载后,可以通过同步的写法直接获取到这个模块

js
const module = require(moduleName)

在上面修改chunk加载过程中,使用异步的方式提前加载了依赖的模块,因此现在在原本chunk代码里面,就可以使用同步的方式获取模块

即下面的依赖代码

js
import someExternal from 'some_external'

会被转成

js
const someExternal = window.require('some_external')

这一步可以通过一个babel插件来实现

js
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形式的代码

因此,最终的实现代码大概类似下面的过程

js
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模块

js
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 不仅语法简洁、静态可分析,还天然支持按需加载(即动态导入),极大地优化了资源加载和首屏性能。

js
import xx from 'http://xx.cdn.com/some_external'

这样,在构建时,只需要将对应的外部模块替换为实际的模块地址,就可以实现精确的按需加载。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。