
做前端工程化这些年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 里最常用的一个魔术注释是webpackChunkNameconst mod await import(/* webpackChunkName: dashboard */ ./views/Dashboard);配置了它之后输出文件会变成类似dashboard.7f3a4b2c.js的文件名Network 面板一眼就能认出这个 chunk 属于哪个业务模块。如果两个不同的异步模块都用了同一个 chunkNameWebpack 会警告你命名冲突并把它们合并成一个 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 Bundle2.1 为什么偏偏用script标签发起异步请求Webpack 的运行时runtime在浏览器端要做的核心事情是当你的业务代码执行到import()时它需要把这个异步 JS Bundle 从服务器拉下来并且让里面的模块能立即被使用。有人问为什么不直接用fetch拉回来然后用eval或者new Function执行Webpack 选择动态创建script标签是因为它解决了几个关键问题JS Bundle 本质上就是一段可执行脚本script加载完会自动执行不需要手动调用eval。script 标签天然受浏览器缓存策略管理同样的 URL 第二次加载会命中缓存对性能更友好。如果配合integrity属性还能做 SRISubresource Integrity校验防止 CDN 被篡改。script 标签跨域行为比 fetch 直观很多只要服务器返回的 JS 可以被公共访问加载就不受 CORS 限制。这里的取舍非常明显Webpack 要的是一个“加载完就执行、执行完就能用”的脚本而不是一个需要二次解析的纯文本数据。动态 script 标签就是最匹配这个场景的方案。2.2 publicPath与jsonpScriptSrc异步请求的URL是怎么拼出来的运行时发起异步请求前必须先算出 JS Bundle 的 URL。这个 URL 由几个部分拼出来output.publicPathchunk 文件名。文件名由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 正好来自 CDNCDN 上的文件哈希跟你构建产物的哈希不一致浏览器会拒绝执行。我遇到过一次原因是我用的 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沿着调用链往下看问题会比你想象中更快暴露出来。我也是靠着这个方法解决过好几个看了半天文档也找不到答案的诡异问题。