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

0 阅读5分钟

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

1.1 三阶段流程

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

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

关键特性:

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

1.2 Live Binding(动态绑定)

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

// 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 语句

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

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

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

2.3 动态 import() 函数

// 执行到这一行时,才发起请求
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

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

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

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

4.1 核心结构

(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 做了补丁:

// 你写的 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 正是利用了这套缓存机制:

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

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


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

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

5.1 核心机制:JSONP

// 你写的
const mod = await import('./foo.js')

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

5.2 webpack_require.e 的实现

__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 文件结构

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

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

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

5.4 完整时序

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 捕获

总结

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

Webpack
  编译阶段:所有模块 → 函数 + installedModules 缓存
  同步加载:__webpack_require__ + 缓存命中
  异步加载:动态插入 <script> + JSONP 回调 + Promise
  HMR:     删缓存 → 注入新函数 → 重执行