做前端工程化这些年,Webpack 几乎是绕不开的一座大山。平时我们用import()写异步加载,浏览器 Network 面板里就会冒出一堆零散的 JS 文件——有人管它们叫异步 chunk,有人直接叫 JS Bundle。但真被问到“Webpack 到底是怎么在异步请求 JS 文件时,让浏览器拿到这些 JS Bundle 的”,能讲清楚的人其实不多。我自己也是踩了好几次坑、把打包产物扒开逐行读过之后才彻底弄明白。这篇文章想把这条链路完整地讲一遍:从动态 import 背后的代码分割原理,到运行时为何要用 script 标签发起异步请求,再到构建产物里那些看起来像天书的加载函数,最后结合我实际排查过的典型问题,给出一份可以直接上手的避坑手册。
1. 动态import与代码分割:异步JS Bundle的起点
1.1 一个看似普通的import()背后
很多人觉得异步加载不就是动态import()吗?写起来确实就一行:
async function loadModule() { const mod = await import('./views/Dashboard'); mod.default.render(); }但这一行代码背后,Webpack 在构建阶段做了好几件事。首先,它会把这个import()调用的模块单独拆成一个 chunk,而不是塞进主 bundle。这也是代码分割的核心逻辑:主入口维护的是初始渲染必需的那部分代码,业务模块被拆成独立文件,等用户真正需要时才去请求。
我在刚开始接触这套机制时有个误解,以为 Webpack 只是简单地把 import 的路径记下来,到运行时再替换成某个 URL。后来读产物代码才发现,Webpack 是在构建时就把整个模块的依赖树分析完了,凡是只在异步分支里被引用的模块,都会被分配到异步 chunk 里。比如 Dashboard 页面引用了 ECharts,而 ECharts 只在这个页面用到,那它不仅会在运行时被异步加载,连 ECharts 本身也会跟着被打进 Dashboard 对应的 JS Bundle,而不是留在主包。这个判断过程靠的是模块图分析,不是靠运行时动态解析,所以import()的参数不能是变量拼接的模板字符串,必须是静态可分析的路径。
还有一个很容易被忽略的点:同一个异步模块被多处动态 import 时,Webpack 会自动做去重,让它们共享同一个 chunk。但如果你希望某些模块始终合并成一个包,就需要在splitChunks里显式配置。这已经属于高级用法了,我在后面讲产物结构时会再提到。
1.2 拆出来的chunk怎么命名?magic comments帮你掌控细节
Webpack 拆出来的异步 chunk 不会叫“chunk1.js”,默认它用的是[id].js这种格式,在开发环境经常看到2.js、5.js。这种命名对浏览器来说没问题,但对你排查问题非常不友好。所以动态 import 里最常用的一个魔术注释是webpackChunkName:
const mod = await import(/* webpackChunkName: "dashboard" */ './views/Dashboard');配置了它之后,输出文件会变成类似dashboard.7f3a4b2c.js的文件名,Network 面板一眼就能认出这个 chunk 属于哪个业务模块。如果两个不同的异步模块都用了同一个 chunkName,Webpack 会警告你命名冲突,并把它们合并成一个 chunk,这点要注意。
另一种常用魔术注释是webpackPrefetch和webpackPreload,我单独拿出来讲,因为它们跟“异步请求 JS 文件”的时机关系太密切了。
1.3 懒加载和预加载:选错方案等于白优化
先说结论:webpackPreload是当前导航一定会用到的资源,webpackPrefetch是未来导航可能用到的资源。
// 浏览器会在空闲时加载这个chunk,适合“路由切换后会用到但当前不一定用到”的场景 const mod = await import(/* webpackPrefetch: true */ './views/Detail'); // 浏览器会在当前页面加载阶段并行加载这个chunk,适合“切换到这个页面时立即需要的场景” const mod = await import(/* webpackPreload: true */ './views/Editor');这俩经常被人混用,实际表现差很多。Preload chunk 会在主 bundle 加载阶段就被请求,它跟首页渲染抢带宽;Prefetch chunk 是浏览器空闲了才去下载,不阻塞首屏。我见过一个项目把首屏弹窗组件设成了webpackPreload,结果弹窗组件体积挺大,直接把首屏白屏时间拖高了近一秒。后来改成webpackPrefetch,并延迟到路由稳定后再触发,体验立刻好转。
理解了这些前置知识,你再看 Webpack 的运行时加载机制,会顺很多。
2. 运行时加载机制:Webpack 究竟怎么让浏览器拿到 JS Bundle
2.1 为什么偏偏用script标签发起异步请求
Webpack 的运行时(runtime)在浏览器端要做的核心事情是:当你的业务代码执行到import()时,它需要把这个异步 JS Bundle 从服务器拉下来,并且让里面的模块能立即被使用。
有人问:为什么不直接用fetch拉回来,然后用eval或者new Function执行?Webpack 选择动态创建<script>标签,是因为它解决了几个关键问题:
- JS Bundle 本质上就是一段可执行脚本,
<script>加载完会自动执行,不需要手动调用eval。 - script 标签天然受浏览器缓存策略管理,同样的 URL 第二次加载会命中缓存,对性能更友好。
- 如果配合
integrity属性,还能做 SRI(Subresource Integrity)校验,防止 CDN 被篡改。 - script 标签跨域行为比 fetch 直观很多,只要服务器返回的 JS 可以被公共访问,加载就不受 CORS 限制。
这里的取舍非常明显:Webpack 要的是一个“加载完就执行、执行完就能用”的脚本,而不是一个需要二次解析的纯文本数据。动态 script 标签就是最匹配这个场景的方案。
2.2 publicPath与jsonpScriptSrc:异步请求的URL是怎么拼出来的
运行时发起异步请求前,必须先算出 JS Bundle 的 URL。这个 URL 由几个部分拼出来:output.publicPath+chunk 文件名。
文件名由output.chunkFilename决定,一般长这样:
// webpack.config.js module.exports = { output: { filename: '[name].[contenthash:8].js', chunkFilename: '[name].[contenthash:8].js', publicPath: '/static/js/', }, };构建时,Webpack 会生成一个__webpack_require__.u函数,它负责根据 chunkId 返回带哈希的完整文件名:
// 构建产物中裁剪出的核心部分 __webpack_require__.u = (chunkId) => { return "dashboard." + "7f3a4b2c" + ".js"; };然后通过jsonpScriptSrc(chunkId)拼出完整地址:
const script = document.createElement('script'); script.src = __webpack_require__.p + __webpack_require__.u(chunkId);__webpack_require__.p就是publicPath的运行时版本。发现问题了吗?如果publicPath配置错了,异步请求就会 404。实例场景:开发时接口用/api,资源用/static/,你部署到服务器子目录后忘了改publicPath,主 HTML 能正常打开(因为入口 script 写在 HTML 里),但异步 chunk 全部请求了域名根路径,于是 Network 面板一片红。这个问题我后面在常见问题里再展开。
Webpack 5 还支持publicPath: 'auto',它会根据当前脚本所在路径自动推断,对于部署在 CDN 域名下的场景很省心。但从源码角度看,它本质上也还是在运行时动态算 URL,拼 URL 的机制没有变。
2.3 installedChunks状态机:加载过程中最关键的一张表
Webpack 运行时内部维护了一张“已安装 chunk 表”,代码里通常叫installedChunks。它记录每个 chunk 当前的加载状态,是整个异步加载机制里最关键的数据结构。状态大概有这几种:
| 状态值 | 含义 |
|---|---|
| 0 | 表示该 chunk 已经加载完成,模块已注册完毕 |
| undefined | 该 chunk 还没开始加载 |
| Promise 对象 | 该 chunk 正在加载中,数组中存放等待它加载完成的回调 |
当代码执行import()对应的加载函数时,运行时会先查这张表:如果状态是 0,说明 chunk 早就加载完了,直接走缓存;如果状态是一个 Promise,说明同一个 chunk 正在被别的模块请求,那就复用一个 Promise,不重复创建 script 标签;只有状态是 undefined 时,才会真正发起一次新的异步请求。
这样设计的好处是:并发模块同时加载同一个异步 chunk 时,浏览器只会发一个请求,不会出现同一个 bundle 被请求十几次的情况。我在做性能优化时,经常通过 Network 面板观察 chunk 请求数量,很多重复请求问题其实都能在这张状态表里找到根源——某个模块在onerror后没有把状态重置干净,就会导致后续加载逻辑判断出错。
2.4 从onload到onerror:加载成功失败的完整闭环
script 标签加载成功后,Webpack 会走onload回调,把installedChunks[chunkId]标记为 0,同时触发全局的 chunk 安装函数,把刚下载完的模块定义挂到模块缓存里。这时候import()的 Promise 才会 resolve。
如果加载失败,比如网络断开、404、CSP 拦截,onerror会触发。Webpack 此时会执行加载错误回调,把 chunk 状态重置为 undefined,保留一个 error 对象,最终让业务代码里import()的 Promise 变成 rejected。在业务代码里你能直接捕获:
async function loadDashboard() { try { const mod = await import('./views/Dashboard'); return mod.default; } catch (err) { // 可以上报错误、提示重试,甚至做优雅降级 } }这里有一个实操建议:异步 chunk 加载失败不要只停留在控制台报错,最好写成用户可感知的重试策略。我一般会在捕获里做一次“延迟 3 秒自动重试”,并配合上报。毕竟 chunk 文件体积大,弱网下失败一次不代表永远失败。
3. 从构建产物看机制实现:实操解析
3.1 先搭一个能复现的最小项目
概念讲得再多,不如亲手读一遍产物。我们搭一个最小的 Webpack 项目,验证异步加载机制。
mkdir webpack-chunk-demo cd webpack-chunk-demo npm init -y npm install webpack webpack-cli --save-dev创建入口文件:
// src/index.js const btn = document.createElement('button'); btn.textContent = '加载Dashboard'; btn.onclick = () => { import('./dashboard').then((mod) => { mod.render(); }); }; document.body.appendChild(btn);创建异步模块:
// src/dashboard.js export function render() { const div = document.createElement('div'); div.innerHTML = 'Dashboard loaded by async chunk'; document.body.appendChild(div); }webpack 配置保持最简:
// webpack.config.js const path = require('path'); module.exports = { mode: 'development', entry: './src/index.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'main.js', chunkFilename: '[name].js', publicPath: '', }, devtool: false, };执行构建:
npx webpack这时候dist目录下会多出一个类似src_dashboard_js.js的文件,这就是我们要求的异步 JS Bundle。
3.2 逐段读懂产物里的加载逻辑
直接打开dist/main.js,我裁剪出最关键的部分来解释(完整代码很长,我只保留核心链路):
// 1. 定义一个记录已加载chunk的状态表 var installedChunks = { "main": 0, }; // 2. 根据chunkId生成文件名 __webpack_require__.u = (chunkId) => { return chunkId + ".js"; }; // 3. 加载与安装chunk的核心方法 __webpack_require__.f = {}; __webpack_require__.e = (chunkId) => { return Promise.all( Object.keys(__webpack_require__.f).reduce((promises, key) => { __webpack_require__.f[key](chunkId, promises); return promises; }, []) ); }; // 4. jsonp方式加载异步chunk __webpack_require__.f.j = (chunkId, promises) => { var installedChunkData = installedChunks[chunkId]; if (installedChunkData !== 0) { if (installedChunkData) { promises.push(installedChunkData[2]); } else { // ... var promise = new Promise((resolve, reject) => { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); promises.push(installedChunkData[2] = promise); var onScriptComplete = (event) => { var chunk = installedChunks[chunkId]; if (chunk !== 0) { if (chunk) { var errorType = event && (event.type === 'load' ? 'missing' : event.type); var realSrc = event && event.target && event.target.src; var error = new Error(...); reject(error); } installedChunks[chunkId] = undefined; } }; var script = document.createElement('script'); script.charset = 'utf-8'; script.timeout = 120; script.src = __webpack_require__.p + __webpack_require__.u(chunkId); script.onerror = onScriptComplete; script.onload = onScriptComplete; document.head.appendChild(script); } } };我来解释这段代码的逻辑,它跟你直觉里的“异步加载”非常不一样:
installedChunks的状态表是所有加载判断的依据。__webpack_require__.e(chunkId)不是真正发请求的函数,它只是把所有注册过的加载器都执行一遍,然后返回合并后的 Promise。这种方式是 Webpack 的扩展点设计,允许同时支持“jsonp 加载”“模块联邦热更新加载”等不同策略。f.j才是真正干活的人。它先查状态表:如果已经加载过,直接跳过;如果是 undefined,就新建一个 Promise,把这个 Promise 的 resolve/reject 存进状态表,然后创建 script 标签去请求。script.onload和script.onerror都指向同一个回调,这个回调会判断加载结果。注意它不会在 onload 时直接把 chunk 标记为 0,因为 JSONP 回调才是真正执行模块安装的地方。
这段代码是 Webpack 5 的简化版本,但它把机制讲得足够清楚:异步请求并不是“用 fetch 拉 JS 再 eval”,而是“生成 script 标签,让浏览器自己加载并执行”。
3.3 模块注册与执行:chunk加载完成后发生什么
script 加载后,真正决定 chunk 能否被业务代码使用的是全局的 JSONP 安装函数。构建产物末尾有一段类似这样的代码:
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => { var chunkIds = data[0]; var moreModules = data[1]; // 把chunk里的模块定义注册到模块缓存中 var moduleId, chunkId, i = 0; for (moduleId in moreModules) { __webpack_require__.m[moduleId] = moreModules[moduleId]; } // 依次标记chunk为已加载 while (i < chunkIds.length) { var chunkId = chunkIds[i++]; if (installedChunks[chunkId]) { installedChunks[chunkId][0](); // resolve } installedChunks[chunkId] = 0; // 标记为已加载 } }; var chunkLoadingGlobal = (self["webpackChunk"] = self["webpackChunk"] || []); chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0)); chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));这个设计很有意思:异步 chunk 文件内容通常长这样:
(self["webpackChunk"] = self["webpackChunk"] || []).push([ ["src_dashboard_js"], { "./src/dashboard.js": (module, exports, __webpack_require__) => { // dashboard模块的代码 }, }, ]);看到没?script 标签加载完 chunk 文件后,文件自身执行时会把模块定义“推入”全局数组self["webpackChunk"],而页面主 bundle 里已经把全局数组的push方法替换成了webpackJsonpCallback。这样一来,异步 chunk 里模块就无缝注册到了主模块系统里,整个过程甚至不需要脚本之间显式通信。这种“全局数组 + 替换 push”的做法就是经典的 JSONP 机制,也是 Webpack 即使把代码拆分到几十个文件,依然能保证模块引用关系不乱的原因。
4. 常见问题与排查技巧实录
4.1 异步chunk加载404:先查publicPath再查部署
这是出现频率最高的一个问题。现象是:页面能打开,主 bundle 能加载,但异步 chunk 全部 404,业务模块根本进不去,控制台报错ChunkLoadError。
排查顺序我已经固定了:先打开 Network 面板,看请求的 chunk URL 长什么样。如果请求的 URL 前缀跟预期不符,那多半是publicPath配置有问题。
我遇到过一个典型例子:项目部署在https://example.com/app/子路径下,但publicPath配成了/,结果异步 chunk 请求跑到https://example.com/根路径下去了。后来把 publicPath 改成:
module.exports = { output: { publicPath: '/app/', }, };问题立刻解决。如果你用的 Webpack 5,不方便写死路径,可以直接设publicPath: 'auto',它会根据当前加载的 script 自动推导。
还有一种隐藏较深的情况:多个入口文件部署在不同目录,但publicPath是全局的。这时候建议用运行时动态 publicPath,比如在入口文件里手动设置:
__webpack_public_path__ = window.__BASE_URL__ || '/default/path/';4.2 CSP、SRI与跨域:看不见的加载拦截
有次我排查一个异步 chunk 加载失败,Network 面板里请求已经成功返回 200,但脚本就是没执行。打开浏览器控制台才发现报错是Refused to load the script because it violates the following Content Security Policy directive: script-src 'self'。
这种情况多半是页面设置了 CSP 安全策略,只允许加载本域脚本。Webpack 异步加载的动态 script 标签是运行时创建的,如果 CSP 里没有把 CDN 域名加入白名单,就会被浏览器拦截。解决办法是在 CSP 配置里加入 chunk 所在域名:
script-src 'self' 'unsafe-inline' https://cdn.example.com;另一个常见坑是 SRI。如果你对静态资源启用了integrity校验,而异步 chunk 正好来自 CDN,CDN 上的文件哈希跟你构建产物的哈希不一致,浏览器会拒绝执行。我遇到过一次,原因是我用的 CDN 做了压缩处理,导致文件内容跟源站不一致,SRI 直接校验失败。排查这类问题,最直接的方法就是看浏览器控制台的安全错误,它会把阻断原因解释得很清楚,别只盯着 Network 面板。
4.3 重复请求与并发加载:如何避免无谓网络开销
Webpack 的installedChunks状态表已经起到了去重作用,但你还是可能看到同一个 chunk 被请求两次。我排查过的案例里,最常见原因是两个独立入口或两个独立构建产物之间的状态表不共享,各自维护各自的 chunk 状态。比如微前端场景下,子应用和主应用各自构建,它们用了同一个 chunk 名,但运行时有两个独立的webpackChunk全局数组,这时候就会出现重复加载。
解决办法有三类:一是让多个入口共享同一个 runtimeChunk,让状态表统一;二是用 webpack 模块联邦的共享机制,让公共模块只加载一次;三是构建时确保异步 chunk 命名全局唯一。具体选哪种取决于你的项目架构,但核心思路都是“让状态表尽量共享,让 chunk 命名尽量唯一”。
4.4 版本更新后老页面加载旧chunk
这个问题在发版后特别容易暴露。现象是:用户打开了老版本页面,还没点击任何按钮;此时团队发布了新版本,构建产物换了新哈希。用户点击按钮触发异步加载,请求的是旧版 chunk 文件名。如果你在发布时把旧文件清理了,这个请求就会 404。
我处理这个问题的标准做法是:发布时保留上一版本的 chunk 文件,至少保留一两个版本,不要立即清理。更稳妥的方案是配置 CDN 回源策略,让旧文件在过期前仍然可以访问。前端层面还可以在import()失败时自动刷新页面,强制用户拿到最新 HTML 和最新资源,但这个方案会影响用户体验,要谨慎使用。
async function loadWithReloadFallback(loader) { try { return await loader(); } catch (err) { const shouldReload = confirm('检测到资源更新,点击确定刷新页面'); if (shouldReload) { window.location.reload(); } throw err; } }这个方法本质上不是最优解,但作为兜底确实能解决一部分用户卡在旧页面的问题。我的建议是:如果不是紧急异常,优先保证旧版本资源可访问,而不是动不动强制刷新。
4.5 排障思路小结:从加载失败到定位根因
我在处理所有异步加载问题时,固定会按下面这个顺序排查,基本能覆盖九成场景:
- 看 Network 面板里 chunk 请求的 URL,确认 publicPath 是否正确。
- 看请求状态,如果是 404,直接检查部署文件和目录结构。
- 看控制台的安全报错,确认 CSP、SRI、跨域策略是否拦截。
- 看 chunk 文件内容,确认是否为有效的
webpackChunk.push([...])格式。 - 看主 bundle 里的
installedChunks状态,确认是否被之前的失败污染。 - 看
import()调用处的业务代码,确认有没有 catch 到错误、要不要做重试。
这个顺序从外到内,从网络层到代码层,不会漏掉最常见的坑。
我自己在实际项目中最常碰到的还是第 1 步的 publicPath 问题,以及第 3 步的安全策略问题。CSP 这类问题尤其隐蔽,因为它不会直接体现在请求上,而是窗口右上角一个不起眼的小报错。所以我的习惯是处理 chunk 加载问题时,永远先开浏览器控制台 Console 面板,而不是只盯着 Network。
关于 Webpack 异步加载 JS Bundle 的机制,最核心的一点是:它把“模块代码”和“模块注册时机”解耦了。业务代码只负责写import(),Webpack 负责在构建时生成 chunk,在运行时通过 script 标签加 JSONP 回调完成模块注册。理解了这条链路,你再去看 bundle 体积优化、路由懒加载、模块联邦这些上层技术,会突然觉得通透很多。
最后分享一个小技巧:遇到任何 Webpack 异步加载问题,先别急着查配置,直接打开构建产物读代码。Webpack 生成的代码虽然是机器代码,但每一行都有明确的职责,从installedChunks到webpackJsonpCallback,沿着调用链往下看,问题会比你想象中更快暴露出来。我也是靠着这个方法,解决过好几个看了半天文档也找不到答案的诡异问题。