浏览器模块加载与 Webpack 打包原理

一、原生 ESM:浏览器如何加载模块

1.1 三阶段流程

浏览器处理 ESM 严格按照三个阶段顺序执行:

复制代码
① 构建(Construction)   → 静态分析依赖,下载所有模块,构建模块依赖图
② 实例化(Linking)      → 为所有导出变量分配内存空间(此时无值)
③ 求值(Evaluation)     → 按依赖顺序执行模块代码,填入真实值

关键特性:

  • 遇到 <script type="module"> 等同于 defer,不阻塞 HTML 解析
  • 同一模块只执行一次,多次 import 返回同一实例(幂等性)
  • 模块下载并行,但执行顺序遵循依赖拓扑排序

1.2 Live Binding(动态绑定)

ESM 的 import 不是值拷贝,而是对原始变量内存地址的引用绑定:

javascript 复制代码
// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
increment();
console.log(count); // 1,而不是 0

count 始终指向 counter.js 中那块内存,值变化会实时反映。

1.3 浏览器网络层细节

  • 每个模块对应一个独立 HTTP 请求
  • 响应头必须是合法的 JS MIME 类型(application/javascript)
  • 跨域模块受 CORS 限制,需服务器配置 Access-Control-Allow-Origin
  • 相同 URL 的模块全局只加载一次(浏览器 Module Map 缓存)

二、静态 vs 动态:import 的两种形态

理解这个区别是理解整个模块系统的核心。

2.1 什么是"编译时"与"运行时"

浏览器没有离线的编译阶段,这里的区分是相对时序:

概念 含义
"编译时" / 静态 代码执行之前,JS 引擎解析 AST 阶段
"运行时" / 动态 模块顶层代码真正开始执行之后

2.2 静态 import 语句

javascript 复制代码
// ✅ 合法:路径是字面量,解析 AST 时就能确定
import { foo } from './foo.js'

// ❌ 非法:路径是表达式,解析阶段无法求值
import { foo } from './' + name + '.js'

import 语句在解析 AST 时就被提取,此时代码尚未执行,所以路径必须是静态字符串。这保证了浏览器能在执行任何代码前构建完整的依赖图。

2.3 动态 import() 函数

javascript 复制代码
// 执行到这一行时,才发起请求
const mod = await import('./foo.js')
// 路径可以是任意表达式
const mod = await import(`./locales/${lang}.js`)

import() 本质是一个运行时函数调用,返回 Promise,适合按需加载、懒加载场景。

2.4 总结对比

时机 本质 用途
import 语句 解析 AST 时(执行前) 静态声明 常规依赖
import() 函数 执行到该行时 运行时调用 懒加载、条件加载
Live Binding 的值 求值阶段填入 运行时赋值 ---

一句话:ESM 的依赖关系是静态的,但变量的值是运行时的。


三、ESM vs CommonJS

ESM CommonJS
依赖分析 静态,执行前确定 动态,运行时执行 require()
导出绑定 Live Binding(引用) 值拷贝
循环依赖 可处理(binding 已建立) 可能拿到未完成的对象
异步加载 原生支持 同步阻塞
Tree Shaking 天然支持 难以静态分析

四、Webpack:将模块编译为函数

Webpack 在构建时将所有模块编译成函数,自行实现一套模块加载系统,不依赖浏览器原生 ESM。

4.1 核心结构

javascript 复制代码
(function(modules) {

  var installedModules = {}; // 模块缓存

  function __webpack_require__(moduleId) {
    // 命中缓存直接返回,保证每个模块只执行一次
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    // 创建模块对象并缓存
    var module = installedModules[moduleId] = { exports: {} };

    // 执行模块函数,注入 module、exports、require
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    return module.exports;
  }

  return __webpack_require__('./src/index.js'); // 从入口启动

})({
  './src/index.js': function(module, exports, __webpack_require__) {
    const foo = __webpack_require__('./src/foo.js');
    console.log(foo);
  },
  './src/foo.js': function(module, exports) {
    exports.default = 'hello';
  }
});

4.2 Live Binding 的模拟

Webpack 模拟的是 CJS 语义,导出本质是值拷贝。但为了兼容 ESM 的 Live Binding,Webpack 用 Object.defineProperty 的 getter 做了补丁:

javascript 复制代码
// 你写的 ESM
export let count = 0;

// Webpack 编译后
Object.defineProperty(exports, 'count', {
  get: function() { return count; } // 每次读取都重新取值,模拟引用
});

4.3 缓存机制对比

Webpack 原生 ESM
缓存位置 installedModules 对象(JS 堆内存) 浏览器 Module Map
缓存 Key 模块路径字符串 模块完整 URL
执行次数 只执行一次 只执行一次
缓存清除 不能(除非 HMR 介入) 不能(页面级别)

4.4 HMR 的本质

Hot Module Replacement 正是利用了这套缓存机制:

markdown 复制代码
检测到文件变更
    ↓
删除 installedModules 中对应模块的缓存
    ↓
注入新的模块函数
    ↓
重新执行该模块 → 更新界面

不需要刷新页面,只替换变更的那块缓存。


五、Webpack 异步加载(动态 import)

当你写 import() 时,Webpack 会把对应模块拆成独立的 chunk 文件,运行时按需加载。

5.1 核心机制:JSONP

javascript 复制代码
// 你写的
const mod = await import('./foo.js')

// Webpack 编译后
__webpack_require__.e('chunk-foo')           // 异步加载 chunk
  .then(() => __webpack_require__('./src/foo.js')) // 从缓存同步取模块

5.2 webpack_require.e 的实现

ini 复制代码
__webpack_require__.e = function(chunkId) {
  // 已加载,直接返回
  if (installedChunks[chunkId] === 0) return Promise.resolve();

  // 加载中,返回同一个 Promise(防止重复请求)
  if (installedChunks[chunkId]) return installedChunks[chunkId][2];

  // 首次加载:创建 Promise + 动态插入 <script>
  var promise = new Promise((resolve, reject) => {
    installedChunks[chunkId] = [resolve, reject];
  });
  installedChunks[chunkId][2] = promise;

  var script = document.createElement('script');
  script.src = chunkId + '.bundle.js';
  document.head.appendChild(script);

  return promise;
};

5.3 chunk 文件结构

javascript 复制代码
// chunk-foo.bundle.js
(self["webpackChunk"] = self["webpackChunk"] || []).push([
  ['chunk-foo'],
  {
    './src/foo.js': function(module, exports) {
      exports.default = 'hello'
    }
  }
]);

主 bundle 中拦截了 webpackChunk.push,chunk 文件执行时自动触发:

ini 复制代码
self["webpackChunk"].push = function([chunkIds, modules]) {
  Object.assign(__webpack_modules__, modules); // 注册新模块
  chunkIds.forEach(id => {
    installedChunks[id] = 0;    // 标记已加载
    installedChunks[id][0]();   // resolve Promise
  });
};

5.4 完整时序

javascript 复制代码
import('./foo.js')
    ↓
__webpack_require__.e('chunk-foo')
    ↓
installedChunks 无缓存 → 创建 Promise + 插入 <script> 标签
    ↓
浏览器下载 chunk-foo.bundle.js
    ↓
chunk 执行 → webpackChunk.push() 被拦截
    ↓
模块注册进 __webpack_modules__ → resolve Promise
    ↓
.then(() => __webpack_require__('./src/foo.js'))
    ↓
从 installedModules 缓存同步取出 → 返回模块

5.5 其他细节

  • 防重复请求 :同一 chunk 并发多次 import(),共享同一个 Promise
  • 预加载 :/* webpackPrefetch: true */ 会生成 <link rel="prefetch"> 提前加载资源
  • 错误处理 :script.onerror 触发时 reject Promise,可被 try/catch 捕获

总结

xml 复制代码
原生 ESM
  静态分析依赖 → 并行下载 → 分配内存 → 顺序执行
  Live Binding = 真实内存引用
  import()     = 运行时动态请求

Webpack
  编译阶段:所有模块 → 函数 + installedModules 缓存
  同步加载:__webpack_require__ + 缓存命中
  异步加载:动态插入 <script> + JSONP 回调 + Promise
  HMR:     删缓存 → 注入新函数 → 重执行
相关推荐
福兮说8 小时前
设计稿是 #4A7C6F,页面量出来是 #4B7C6F:HEX、HSL、透明度、canvas 来回转的七个坑
前端·javascript·css·canvas
郑州光合科技余经理9 小时前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程
小呆呆6669 小时前
副业搞起来,小说,漫画,漫剧的成本优化思路
前端·后端·面试
Dovis(誓平步青云)10 小时前
浇水提醒刚弹出又消失,植物状态别只存一个百分比
开发语言·前端·javascript·pdf·ecmascript·电脑
码艺-Alimjan10 小时前
Vben Admin 新增维吾尔语 Vben-Modal的关键坑之一
前端·javascript·vue.js
可乐鸡翅yeah_11 小时前
hls.js 手动自定义 http 请求 loader,修改请求头实战
开发语言·前端·javascript·网络协议·http·ecmascript·m3u8在线
IT_陈寒11 小时前
Vite静态资源导入这个坑我帮你们踩过了
前端·人工智能·后端
广州华水科技11 小时前
大坝安全监测解决方案:单北斗GNSS形变监测系统应用与维护
前端
yivifu12 小时前
中文古籍电子书注释集成
前端·javascript·python·beautifulsoup·epub
默_笙12 小时前
🛴 从散件到整机:DeepAgents 与 Agent 身上预留的那些"插槽"(前置介绍)
前端·javascript