浏览器模块加载与 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:     删缓存 → 注入新函数 → 重执行
相关推荐
阿赛工作室5 分钟前
Vue中onBeforeUnmount不触发的解决方案
前端·javascript·vue.js
码王吴彦祖6 分钟前
顶象 AC 纯算法迁移实战:从补环境到纯算的完整拆解
java·前端·算法
小叶lr19 分钟前
jenkins打包前端样式丢失/与本地不一致问题
运维·前端·jenkins
浩星25 分钟前
electron系列1:Electron不是玩具,为什么桌面应用需要它?
前端·javascript·electron
ZC跨境爬虫43 分钟前
Scrapy工作空间搭建与目录结构解析:从初始化到基础配置全流程
前端·爬虫·python·scrapy·自动化
小村儿1 小时前
连载04-最重要的Skill---一起吃透 Claude Code,告别 AI coding 迷茫
前端·后端·ai编程
_院长大人_1 小时前
Vue + ECharts 实现价格趋势分析图
前端·vue.js·echarts
IT_陈寒2 小时前
Vite的alias配置把我整不会了,原来是这个坑
前端·人工智能·后端
万物得其道者成2 小时前
Cursor 提效实战:我的前端 Prompt、审查 SKILL、MCP 接入完整方法
前端·prompt
酒鼎2 小时前
学习笔记(12-02)事件循环 - 实战案例 —⭐
前端·javascript