浏览器模块加载与 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:     删缓存 → 注入新函数 → 重执行
相关推荐
兆子龙2 小时前
React Compiler 来了:少写 useMemo,照样稳
前端·架构
用户5433081441942 小时前
Manifest V3 实战:从补天网站逆向到 Chrome 扩展开发全记录
前端·后端
zhqiok2 小时前
React中类似于Vue中Pinia的轻量级状态管理神器——Zustand
前端
Mintopia2 小时前
促成高端技术方案形成的关键要素与实践路径
前端
摸鱼的春哥4 小时前
春哥的Agent通关秘籍13:实现RAG查询
前端·javascript·后端
明月_清风4 小时前
滚动锁定:用户向上翻看历史时,如何阻止 AI 新消息把它“顶”下去?
前端·javascript
明月_清风4 小时前
当高阶函数遇到 AI:如何自动化生成业务层面的逻辑拦截器
前端·javascript·函数式编程
moshuying14 小时前
别让AI焦虑,偷走你本该有的底气
前端·人工智能
GIS之路15 小时前
ArcPy,一个基于 Python 的 GIS 开发库简介
前端