前端模块化发展(6):2015年 ES6 Module(语言层面支持)

165 阅读32分钟

2015年 ES6 Module(语言层面支持)

模块化需求:浏览器端 vs 服务器端

“浏览器模块化” 和 “服务器模块化” 其实目标一致(解决代码拆分、依赖管理、作用域隔离),但是 运行环境完全不同,导致了实现方式和代码写法都有差别。


根本区别:环境不同

特点浏览器端模块化服务器端模块化(Node.js)
加载来源通过 网络请求 加载 .js 文件(HTTP 请求)本地文件系统 读取 .js 文件
加载时机必须 异步(否则浏览器阻塞,用户体验差)可以 同步(本地磁盘速度快,不影响太大)
作用域隔离防止全局污染(早期 <script> 标签都挂 window 上)防止变量污染(每个文件都是独立模块)
依赖关系浏览器需要知道“我依赖了谁”,才能按顺序请求Node.js 直接 require
,同步解析依赖关系
运行结果模块通常挂到全局(早期)或通过模块系统(AMD/ESM)返回模块通过 module.exportsexport 返回

代码层面的对比

服务器端
CommonJS(Node.js)

Node.js 里的模块化是 同步加载,因为读取本地磁盘文件几乎瞬间完成。

// math.js
function add(a, b) {
  return a + b;
}
module.exports = { add };

// app.js
const math = require('./math'); // 同步加载
console.log(math.add(2, 3));

特点:

  • require 会立刻读取文件,执行一次,返回 module.exports
  • 所以它像函数调用一样:“你要谁,就马上去拿”

ES Module(Node.js)
// math.js
export function add(a, b) {
  return a + b;
}

// app.js
import { add } from './math.js';
console.log(add(2, 3));

场景

👉 Node.js 环境
Node.js 本身就是一个 JS 运行环境,它能直接执行 .js 文件,不需要 HTML 来“挂载脚本”。

流程

  1. 你在命令行运行 node app.js(前提是你的 Node.js 开启了 ESM 支持,比如文件扩展名是 .mjspackage.json 设置 "type": "module")。
  2. Node.js 发现这是一个模块脚本,会自己处理 import/export
  3. 直接执行 app.js,过程中自动加载 math.js

特点:

  • 在 Node.js 里,import 会从 文件系统 读取 math.js

浏览器端

浏览器要从服务器获取脚本 → 必须异步,否则会卡住页面。

AMD(Asynchronous Module Definition,2009年)
  • 出现背景:那时浏览器还 不支持原生模块化,只能用 <script> 依次引入 JS 文件,很容易遇到依赖顺序问题。
  • 解决方式:AMD 规范(RequireJS 实现)定义了一个 异步加载模块的方式。

AMD 代码示例:

<script src="require.js"></script>
<script>
  // 定义模块 math.js
  define('math', [], function () {
    return {
      add: function (a, b) { return a + b; }
    };
  });

  // 使用模块
  require(['math'], function (math) {
    console.log(math.add(2, 3));
  });
</script>

特点:

  • define() 用来定义模块。
  • require() 用来异步加载模块。
  • 必须依赖 第三方库(require.js),浏览器本身不认识。

ES Module(ES6 原生模块,2015年)
  • 出现背景:JS 语言规范(ES6)原生支持了模块化,浏览器逐渐内置实现。
  • 解决方式:直接在 <script> 上加 type="module",就能写原生 import/export

ESM 代码示例:

<!-- math.js -->
export function add(a, b) {
  return a + b;
}

<!-- main.js -->
import { add } from './math.js';
console.log(add(2, 3));

<!-- HTML -->
<script type="module" src="main.js"></script>

场景

👉 浏览器环境
浏览器只认识 HTML,它不会直接去执行本地的 main.js,你必须通过 HTML 告诉它:

  • “我要加载这个 JS 文件”
  • 并且声明 type="module",否则浏览器会把 import/export 当成语法错误。

流程

  1. 浏览器打开 HTML。
  2. 遇到 <script type="module">,知道这是一个 ESM 脚本。
  3. 去加载 main.js,里面的 import { add } from './math.js' 再去请求 math.js
  4. 模块化加载完成后执行代码。

特点:

  • 原生支持,不需要第三方库。
  • import 会发起 HTTP 请求,加载 math.js(与Node.js环境下的ES Module语法相同,但运行时的“加载机制”不同)。
  • import/export 静态分析,更适合打包优化。
  • 默认是异步加载。

关系与差别
特性AMD(RequireJS)ES Module(ESM)
年代2009 左右2015(ES6 标准)
依赖需要 require.js浏览器原生支持
语法define() + require()import + export
加载方式异步,运行时解析静态分析,编译时确定依赖
优化能力差,依赖图需运行时维护强,能做 tree-shaking、按需加载
地位过渡方案长远标准
  • AMD 的写法和 **<script type="module">** 完全不同
  • 它们唯一的共同点是:都让浏览器能直接处理模块化,而不是靠 Node.js。
  • 区别在于:
    • AMD 属于 “历史过渡”(依赖第三方库 hack 出来的)。
    • ESM 属于 “正统标准”(语言层面原生支持)。

那么一个完整项目要不要两端都模块化?

  • 后端(Node.js):必须模块化(CommonJS / ES Module)。
  • 前端(浏览器):同样需要模块化(AMD → ES Module)。
  • 大型项目:前后端都需要,只是各自实现方式不同。

👉 可以说:

  • 后端模块化:解决“服务器代码如何组织”。
  • 前端模块化:解决“浏览器如何按需加载脚本”。
  • 两者最终目标一致:让 JS 代码能 拆分 + 组合 + 复用

总结

  • 浏览器模块化:重点在于 异步加载(因为是网络请求)。
  • 服务器模块化:重点在于 同步加载(本地磁盘读取很快)。
  • 趋势:现在 ES Module 统一了前后端(语法相同,但实现机制不同)。

何为前端模块化?

前置思考

在这个时期,前端项目基于 Node.js 跑,但最终是浏览器在展示,那模块化到底由谁来负责?浏览器模块化是不是就是前端模块化?

两个阶段:开发阶段 vs 运行阶段

阶段谁在执行模块化用的是什么说明
开发阶段Node.jsCommonJS(require)、ESM(import)、打包工具(Webpack、Vite、Rollup)我们写的代码先在 Node.js 环境里编译/打包/构建
运行阶段浏览器ES Module(原生)、打包后的单文件(UMD/IIFE)浏览器拿到构建结果,解析运行

👉 所以:

  • Node.js 负责开发阶段(模块化编译、依赖打包、转译)。
  • 浏览器负责运行阶段(最终只运行能识别的 JS 格式)。

为什么要 Node.js?

以前的前端:直接 <script> 引入 JS 文件,浏览器自己运行。
现在的前端工程越来越复杂:

  • 有成千上万的模块依赖(React、Vue、UI库…)。
  • 需要编译(比如 ES6+ 转 ES5、TypeScript 转 JS、Sass 转 CSS)。
  • 需要优化(压缩、Tree-shaking、代码分割)。

👉 浏览器本身不具备这些编译/构建能力,所以必须借助 Node.js 在本地先打包好,再交给浏览器。


浏览器模块化是谁负责的?

情况一:老式项目(无打包)

浏览器自己负责模块化(AMD/ESM)。

  • 直接 <script type="module">,浏览器发起 HTTP 请求加载依赖。
情况二:现代项目(有打包工具)

打包工具(Webpack/Vite)在 Node.js 中运行,把 模块化代码打平成浏览器可执行的格式,比如:

  • IIFE(立即执行函数):适合浏览器直接跑。
  • UMD:兼容浏览器和 Node.js。
  • ESM(保持原生):现代浏览器可直接解析。

👉 也就是说:

  • 开发时的模块化 = 前端工程师用 Node.js 的“开发模块化”(写模块化代码,交给工具处理)。
  • 运行时的模块化 = 浏览器端的“执行模块化”(不管是工具打包结果,还是浏览器自己加载 ESM)。

回到前置思考

浏览器模块化是不是就是前端模块化?

严格说:

  • 前端模块化 = 更宽的概念,包含 “开发时(Node.js 工具处理)” + “运行时(浏览器加载执行)”。
  • 浏览器模块化 = 仅指运行时阶段,浏览器如何加载模块(AMD/ESM)。

所以:

  • 现代前端开发,模块化的重担主要由 Node.js 构建工具承担
  • 浏览器模块化只是最后执行结果的“载体”。

现代前端开发里,既然模块化的重担主要由 Node.js 构建工具承担,那么“浏览器模块化”到底还重要不重要?

现代主流项目(有构建工具)
  • 模块化重担确实主要由 Node.js 工具链承担
    • 我们写的 import/export,其实是写给 **打包工具(Webpack/Vite/Rollup)**看的。
    • 工具会在 Node.js 环境里把这些模块都分析好、打包成一个或几个 bundle 文件。
    • 浏览器拿到的已经是“拼装好的结果”,比如:
      • 一个大文件(IIFE/UMD 格式)
      • 若干 chunk(分包后的代码,仍然用 ESM 语法衔接)
    • 浏览器此时只管执行,不再需要“自己去解决模块化依赖的问题”。

👉 这时候可以说:浏览器模块化退居二线,成了“兜底能力”


无构建/轻量场景
  • 并不是所有项目都用打包工具。比如:
    • 小型 Demo
    • 内部管理工具
    • 原型验证项目
  • 在这些场景下,前端开发者可能直接在浏览器里用:
<script type="module">
  import { add } from './math.js';
  console.log(add(1, 2));
</script>
  • 这里浏览器就会自己承担模块化(原生 ESM),每个 import 都触发一个 HTTP 请求。

👉 所以,浏览器模块化依然存在,并不是被完全抛弃,只是“现代大项目里,大多数工作交给了 Node.js 构建工具”。


回到问题
  • 现代复杂前端项目:主要依赖 Node.js 构建工具来做模块化 → 浏览器只负责运行打包好的结果。
  • 轻量级或无打包项目:浏览器依然可以用 ESM 自己搞定模块化。

所以:
✅ 现在浏览器模块化仍然存在,但在工程化项目里,它不是“核心担当”,而是“备用和兜底”。


打包对于浏览器模块化的影响

前置思考

前面说打包后就不需要浏览器模块化,不打包就需要浏览器模块化,那么“打包”和“不打包”到底解决了什么问题?为什么打包后浏览器就不用再管模块化了?

不打包:浏览器自己负责模块化

运行流程:

  • 你写了 ES Module:
// main.js
import { add } from './math.js';
console.log(add(1, 2));
  • 浏览器遇到 import './math.js' 时,会自己发 HTTP 请求去拉取 math.js 文件。
  • 如果 math.js 里又有 import './util.js',浏览器会再发一个请求去拿 util.js

特点:

  • 浏览器亲自解析依赖关系,发起多个请求
  • 模块多的话,可能会发成百上千个 HTTP 请求(性能差)。
  • 不支持旧浏览器(ESM 是 ES6 以后的功能)。

👉 所以“不打包”就意味着 浏览器要自己去做模块化


打包:Node.js 构建工具负责模块化

运行流程:

  • Webpack/Rollup/Vite 会在 Node.js 里先扫描所有 import/export,生成完整的依赖图。
  • 工具会把所有模块“拼装”成一个或少量几个文件,输出结果可能是:
    • 一个大文件(bundle.js):里面已经把所有依赖合成。
    • 若干 chunk:分包优化,浏览器只需按需加载。

浏览器收到的:

  • 不是一堆零散的模块文件,而是一个(或少量)已经拼好的成品。
  • 浏览器直接执行这些打包好的 JS,不再需要“逐个解析依赖”。

👉 所以“打包”就意味着 模块化已经在构建阶段完成,浏览器不用再操心。


根本区别总结
特性不打包(浏览器模块化)打包(Node.js 构建工具模块化)
依赖解析浏览器运行时解析构建工具编译时解析
HTTP 请求每个 import
都会发请求(很多)
只请求打包好的少量文件
兼容性只支持现代浏览器(支持 ESM 的)可转译成旧浏览器能运行的代码
优化几乎没有优化Tree-shaking、代码分割、压缩、Polyfill 等优化
适用场景小型 demo、原型项目现代大型项目

总结
  • 不打包 = 你点菜,服务员每次都跑去厨房拿一个菜回来,几十个菜就要跑几十趟。
  • 打包 = 厨师在后厨一次性把你点的所有菜拼成套餐,服务员只跑一趟就上齐。

✅ 所以问题答案是:

  • 打包后不需要浏览器模块化,是因为 依赖关系已经在构建阶段处理好了,浏览器拿到的是“合并后的成品”。
  • 不打包时,浏览器必须自己“现场拼装模块”,所以它需要自己负责模块化。

同一个项目(打包 vs 不打包)的真实代码对比

我们用一个极简小项目来对比:

项目结构:

project/
  ├─ math.js
  ├─ util.js
  └─ main.js

不打包(浏览器原生模块化)

代码就是原汁原味的 ES Module:

math.js

import { double } from './util.js';

export function add(a, b) {
  return double(a + b);
}

util.js

export function double(x) {
  return x * 2;
}

main.js

import { add } from './math.js';

console.log(add(2, 3));

HTML

<script type="module" src="./main.js"></script>

👉 执行过程:

  1. 浏览器先加载 main.js
  2. 发现 import { add } from './math.js' → 发请求加载 math.js
  3. math.js 又 import 了 util.js → 再发请求加载 util.js
  4. 全部解析完依赖后,才运行 console.log(add(2, 3))

特点:模块关系是浏览器在运行时自己解析的。


打包后(Webpack/Rollup 输出结果)

构建工具会把依赖图“打平”,通常打包成一个 bundle.js 文件:

bundle.js(简化示意)

// util.js 打包进来
function double(x) {
  return x * 2;
}

// math.js 打包进来
function add(a, b) {
  return double(a + b);
}

// main.js 打包进来
console.log(add(2, 3));

HTML

<script src="./bundle.js"></script>

👉 执行过程:

  • 浏览器只加载一个 bundle.js 文件,直接运行。
  • 模块依赖关系在 打包阶段 已经解决好了,浏览器无需再解析。

特点:模块化逻辑在构建时完成,浏览器只管跑成品。


差异一目了然
对比项不打包(ESM 原生)打包(Webpack 等)
文件数多个文件(math.js、util.js、main.js)一个或少量几个文件(bundle.js)
加载方式浏览器遇到 import 就发请求浏览器一次性加载 bundle
依赖处理浏览器运行时解析Node.js 构建工具编译时解析
代码形态保持模块化语法合并/内联成普通函数调用

✅ 直观理解:

  • 不打包:浏览器像“拼乐高”,自己把小积木(模块文件)拼起来。
  • 打包:Node.js 构建工具提前帮你拼好一个完整的乐高城堡,浏览器只要“直接摆上去”。

项目的运行:不打包 vs 打包

前置思考

打包和不打包,项目分别是如何被运行的?基于Node.js开发项目的时候,通常运行npm run dev,这是干了什么?

不打包的项目(原生 ESM 模块)

1. 文件形态

就是多个 .js 模块文件 + 一个 HTML:

<script type="module" src="./main.js"></script>

浏览器自己去加载依赖。

2. 运行方式

直接在本地起一个静态服务器(不能用 file://,因为跨文件 import 会报错)。

  • python -m http.server
  • npx serve
  • 或者任何 Web 服务器(Apache/Nginx/Node.js Express)

然后访问 http://localhost:3000/ 就能跑起来。

👉 关键点:没有“打包”,浏览器自己负责模块化加载。


打包的项目(Webpack/Vite/Rollup 等)
  1. 文件形态

源代码里用的是模块化写法(import/export),但浏览器最终跑的不是这些源文件,而是打包工具生成的 bundle.js

  1. 开发时的 npm run dev
  • 其实是调用 打包工具自带的开发服务器(比如 webpack-dev-server / vite dev server)。
  • 这个开发服务器会:
    1. 监听文件变化 → 实时重新打包。
    2. 提供 HTTP 服务 → 浏览器访问时返回打包产物。
    3. 支持 HMR(热更新) → 改代码不用刷新页面。

例如 Vite:

npm run dev
# 相当于执行 vite

浏览器访问 http://localhost:5173/,看到的 JS 代码已经是 Vite 处理过的。

生产时的 npm run build
  • 打包工具会输出真正的静态文件(bundle.js、index.html、assets/…)。
  • 这些文件可以部署到任何 Web 服务器上(Nginx、静态空间、CDN)。
  • 浏览器加载时根本不管你的源码模块化逻辑,只执行 bundle.js。

👉 关键点:模块化在 Node.js 里“编译解决”,浏览器只要执行最终产物。


对比总结
项目类型运行方式模块化逻辑在哪里解决?浏览器看到的
不打包静态服务器 + 浏览器原生 type="module"浏览器运行时多个 .js
文件
打包npm run dev
(打包工具 dev server)或 npm run build
+ 部署
Node.js 构建时一个或少量 bundle.js

✅ 换句话说:

  • 不打包:浏览器是“工人”,自己拼装零件。
  • 打包:Node.js 是“工厂”,提前拼好产品,浏览器只负责展示。

为什么常说“打包后是一个或少量的 bundle.js”?

(这里先对打包有个初步的印象,便于整体理解,后续会对打包有专题深入,届时会细细深入。)

前置思考

一个前端项目里应该有很多种类型的文件(比如最常见的.html和.css),可是看你说“不打包是多个 .js 文件,打包后是一个或少量 bundle.js”,怎么只有js文件了?

不打包的项目(浏览器原生加载)

一个典型小项目可能长这样:

index.html
style.css
main.js
utils.js
math.js
logo.png
  • HTML:入口,浏览器直接解析。
  • CSS:通过 <link rel="stylesheet" href="style.css"> 引入,浏览器去请求 style.css
  • JS:如果你用的是 ESM,就写 <script type="module" src="main.js"></script>,浏览器会根据 import 再去请求 utils.jsmath.js
  • 图片:HTML 或 CSS 中写 <img src="logo.png">,浏览器就发一个请求获取图片。

👉 这种模式下,每一个资源文件都是 独立的 HTTP 请求


打包后的项目

假设用 Webpack/Vite 打包,可能变成这样:

dist/
  index.html
  bundle.js
  style.css
  logo.8f4a9.png

为什么看起来只剩下 JS?

  • JS:所有 .js 文件(main.js、utils.js、math.js)会被打包、合并、压缩,变成一个 bundle.js 或少量几个分块的 JS。
  • CSS:构建工具可能会:
    • 单独抽离成一个 style.css(生产模式常见),在 index.html<link> 引入;
    • 或者直接 **内联进 ****bundle.js**,运行时用 <style> 标签插入页面。
  • 图片/字体:会被处理成:
    • 小文件 → Base64 内联到 JS 或 CSS 里(避免额外请求)。
    • 大文件 → 被复制到 dist/,改名加 hash(防缓存),再由 HTML/CSS/JS 引用。

👉 所以打包之后你看到的“只有 bundle.js”,是因为:

  • JS 被合并成一个大文件。
  • CSS 有时被内联到了 JS(所以文件树上看不到独立 CSS)。
  • 图片、字体可能被哈希重命名,看起来“消失了”,其实是挪到 dist/ 里了。

本质差异
方面不打包打包
资源数量多个 .js.css、图片文件,浏览器分别请求少量文件(bundle.js、style.css、打包后的资源)
请求次数多,HTTP/1.1 时性能差少,合并请求,提高性能
文件内容原始代码,结构清晰压缩、混淆、hash,难以直接看懂
模块化支持浏览器负责(ESM / AMD)构建工具负责(Webpack / Vite),浏览器只跑结果

一句话总结
打包 ≠ 只剩 JS,而是:

  • JS 被合并 → bundle.js
  • CSS 可能抽离或内联
  • 图片/字体 可能哈希化或内联
    浏览器最终只管运行打包产物(少量优化后的文件)。

为什么要有 ES6 Module?

2015 年之前,前端模块化一片混乱:

  • 服务器端:CommonJS(Node.js)。
  • 浏览器端:AMD(RequireJS)、CMD(SeaJS)、UMD(兼容方案)。
  • 还夹杂着大量“全局变量污染”的传统写法。

问题:

  • 语法不统一:不同平台要写不同的导入导出语法。
  • 性能不稳定:AMD 异步、CMD 就近依赖、CommonJS 同步。
  • 工具层面 hack 太多:库作者不得不写多个版本或用 UMD。

👉 所以 TC39(JS 标准委员会)在 ES6(2015) 中正式引入了 语言级别的模块化 —— ES Module。


浏览器环境 vs Node.js 环境

浏览器环境:有 <script type="module"> 的版本

<!-- index.html -->
<script type="module" src="main.js"></script>
// main.js
import { add } from './math.js';
console.log(add(2, 3));
// math.js
export function add(a, b) { return a + b; }

场景

👉 浏览器环境
浏览器只认识 HTML,它不会直接去执行本地的 main.js,你必须通过 HTML 告诉它:

  • “我要加载这个 JS 文件”
  • 并且声明 type="module",否则浏览器会把 import/export 当成语法错误。

流程

  1. 浏览器解析 HTML,发现 <script type="module" src="main.js">,浏览器知道这是一个 ESM 脚本(模块脚本)。
  2. 请求 main.js,发现有 import './math.js'
  3. 再请求 math.js,发现有 export
  4. 建立依赖图:
index.html → main.js → math.js
  1. 模块化加载完成后,浏览器确保 math.js 先执行,再执行 main.js
  2. 打印结果 5

特点:

  • ESM 自动是 延迟执行(类似 defer),不会阻塞 HTML 解析。
  • ESM 默认跨文件作用域,不会把变量挂到 window 上。

Node.js环境:没有 <script> 的版本

// math.js
export function add(a, b) {
  return a + b;
}

// app.js
import { add } from './math.js';
console.log(add(2, 3));

场景

👉 Node.js 环境

Node.js 原本只支持 CommonJS,后来逐渐加入 ESM:

  • 早期(Node 12 之前):需要 .mjs 扩展名,或者 package.json 里加 "type": "module"
  • 现在(Node 14+):默认支持 ESM,但依然兼容 CommonJS。

区别于浏览器,Node.js 本身就是一个 JS 运行环境,它能直接执行 .js 文件,不需要 HTML 来“挂载脚本”。

流程

  1. 你在命令行运行 node app.js(前提是你的 Node.js 开启了 ESM 支持,比如文件扩展名是 .mjspackage.json 设置 "type": "module")。
  2. Node.js 发现这是一个模块脚本,会自己处理 import/export
  3. 直接执行 app.js,过程中自动加载 math.js

核心区别

特点浏览器Node.js
启动入口HTMLJS 文件
是否需要 <script>✅ 必须,告诉浏览器要加载哪个脚本❌ 不需要,直接运行 JS
模块化识别type="module" 属性.mjs 扩展名 或 package.json: { "type": "module" }
  • 浏览器跑 JS:入口是 HTML → <script type="module">
  • Node.js 跑 JS:入口就是 JS 文件本身,不需要 HTML。

ESM 的核心机制

浏览器和 Node.js 中,ESM 的执行机制是一致的,核心包含以下流程:

1. 静态解析(编译阶段)

  • ESM 必须在编译阶段 就确定依赖关系。
  • importexport只能写在顶层,不允许放在函数、条件语句中。
// ✅ 合法
import { add } from './math.js';

// ❌ 不允许
if (true) {
  import { add } from './math.js';
}
  • 这样,JS 引擎在代码运行前,就能 构建模块依赖图(Dependency Graph)
  • 方便工具做 Tree-Shaking(按需打包)。

2. 模块加载

  • 浏览器遇到 <script type="module">,会 递归请求依赖
    • main.js → 发现 import './math.js' → 发起网络请求加载 math.js → 如果 math.js 又有依赖,继续请求。
  • Node.js 遇到 .mjspackage.json: { "type": "module" },会按路径解析并加载模块文件。

3. 模块实例化

  • 每个模块只会执行一次,并且会生成一个 模块记录(Module Record)
  • 这个记录里保存了:
    • 导出的绑定(exported bindings)
    • 模块的执行上下文
  • 即使多次 import,拿到的都是 同一个模块实例(单例)

4. 绑定机制(Live Binding)

  • ESM 的变量导出是 绑定(binding),而不是拷贝。
  • 这意味着如果导出值发生变化,导入方能实时看到更新:
// counter.js
export let count = 0;
export function inc() { count++; }

// main.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 ✅ 发生了变化

5. 执行顺序

  • ESM 默认是异步加载,不会阻塞 HTML 解析。
  • 但模块之间的执行顺序严格按照依赖关系:
    • 先解析依赖,再执行。
    • 保证某个模块执行时,它的依赖已加载完成。

ESM 的关键特性

1. 异步加载,避免阻塞

背景问题
  • 早期 <script> 默认是 同步加载 + 阻塞执行
    • HTML 解析到 <script> 时必须停下来,下载并执行完 JS 才继续渲染 DOM。
    • 大量 JS 文件时,页面白屏、加载缓慢。
解决方案
  • ESM 模块加载是异步的
<script type="module" src="main.js"></script>
- 这会触发 **并行下载**依赖文件(`import` 的模块)。
- 等 DOM 树解析完成后再顺序执行(相当于默认 `defer`)。
意义
  • 页面更快可见(HTML 先解析,不被阻塞)。
  • 依赖自动并行加载,不用再手写 <script> 标签顺序。

2. 顶层静态依赖

背景问题
  • CommonJS 里 require()运行时调用,你甚至可以写:
if (Math.random() > 0.5) {
  require('./a.js');
} else {
  require('./b.js');
}

👉 JS 引擎和构建工具在编译时无法预测依赖关系。

解决方案
  • ESM 强制 顶层静态 import
import { add } from './math.js';
- 不能放在 if、循环、函数里(顶层语法限制)。
- JS 引擎在解析阶段就能知道完整依赖图。
意义
  • Tree Shaking:打包时能分析哪些导出没用,直接删除无效代码。
  • 并行加载优化:浏览器能提前下载所有依赖。

3. 严格模式(Strict Mode)

背景问题
  • 传统 JS 容易出现隐式错误,比如:
x = 10;  // 没声明变量,自动变成全局变量
  • 这在大型项目里会导致难以追踪的 bug。
解决方案
  • ESM 自动启用严格模式,即使你没写 "use strict"
    • 禁止隐式全局变量。
    • this 默认是 undefined(而不是 window)。
    • 一些不安全的语法直接报错。
意义
  • 模块内部更安全,避免污染全局作用域。
  • 保证不同模块之间不会无意干扰。

4. 跨平台标准化

背景问题
  • JS 发展早期,模块化没有统一标准:
    • 浏览器有 AMD(RequireJS)、
    • Node.js 有 CommonJS、
    • 还出现过 CMD、UMD 等。
    • 结果就是:写的代码到处都要改,无法通用。
解决方案
  • ESM 被写入 ECMAScript 规范(ES6 起),成为官方标准。
  • 现在浏览器、Node.js、Deno、Bun 都原生支持。
意义
  • 一次编写,多端运行
  • 工具链统一:Rollup、Webpack、Vite 全都以 ESM 为核心。
  • JS 生态终于有了 官方的、长期稳定的模块系统

总结(对前端工程师的认识要求)

  • 异步加载:理解浏览器不再阻塞渲染,利于性能优化。
  • 静态依赖:知道 Tree Shaking 的原理,能写出可优化的代码。
  • 严格模式:写模块时默认就更安全,不要再依赖非严格写法。
  • 跨平台:理解为什么 Node.js、Deno 都选择 ESM,代码可以更容易复用。

ESM vs 之前的模块化

特点CommonJSAMDCMDUMDESM
规范提出20092010201120122015
依赖解析同步异步(依赖前置)异步(依赖就近)兼容写法异步
运行环境Node.js浏览器浏览器通用浏览器 & Node.js
是否标准✅(语言层面)
是否 Tree-shaking

👉 根本差异:ESM 是 ECMAScript 标准的一部分,不再依赖第三方库或工具。


ESM vs CommonJS(Node.js 的老系统)

对比点CommonJSESM
定义社区标准ECMAScript 官方标准
加载方式运行时加载(require)编译时静态分析(import/export)
依赖解析同步(阻塞)异步(并行加载)
导出机制值拷贝Live Binding(实时绑定)
执行环境Node.js浏览器 & Node.js
优化支持不利于 Tree-Shaking天然支持 Tree-Shaking

为什么 ESM 是“最终解”?

  1. 语言层面内建 → 不依赖第三方库。
  2. 跨平台统一 → 同一套写法同时支持浏览器和 Node.js。
  3. 编译时静态分析 → 更适合工具优化(Tree-shaking、打包、代码分割)。
  4. 动态 import → 兼顾异步按需加载的灵活性。

总结一下:
ES6 Module(ESM)是 JavaScript 官方的模块化标准, 其核心创新在于 —— 静态化 + 异步化 + Live Binding,让 JavaScript 模块化第一次成为语言层面的统一标准,它通过 静态分析、独立作用域、标准语法 彻底统一了前端与后端的模块化写法,取代了 CommonJS / AMD / CMD / UMD 等“过渡性方案”。


运行时加载和编译时静态分析

前置思考

为什么我们要区分 运行时加载编译时静态分析

——这直接影响前端的 模块加载效率、打包方式、性能优化

背景:为什么要提出这个区分?

最初(2000s ~ Node.js 出现前后),前端/JS 模块是靠 运行时加载 的:

  • 比如 CommonJSrequire()
    • 代码跑到 require('./math') 时,Node.js 才会去加载 math.js 文件。
    • 加载是 动态 的,不提前知道依赖结构。

问题:

  • 无法在编译阶段(打包之前)做优化,因为依赖关系是 运行时才知道
  • 这会导致 打包臃肿 / Tree-shaking 无法彻底 / 性能差

于是,后来(ES6 → 工程化)引入了 静态分析(编译时处理)

  • ES Module (**import/export**) 必须在代码最顶层声明,不能放到 if/for 里。
  • 这样,打包工具一读源码,就能知道模块依赖关系,不必等到运行时。
  • 好处:能提前优化、裁剪无用代码、并行加载。

什么是“运行时加载”

特点:

  1. 动态:模块在程序运行到某一行时才会被加载。
  2. 无法预测依赖:编译阶段看不出真实依赖(可能依赖是变量拼接的路径)。
  3. 典型代表
    • CommonJS(Node.js 的 require
    • AMD / CMD(前端早期异步模块规范)

例子(Node.js / CommonJS):

// main.js
if (condition) {
  const math = require('./math'); // 运行到这里才加载
  console.log(math.add(1, 2));
}

👉 在打包或编译阶段,根本无法提前确定会不会用 math,只能等程序跑起来再说。

问题:

  • 打包工具没法摇树优化(Tree-shaking)。
  • 浏览器无法并行预加载所有依赖。

什么是“编译时静态分析”

特点:

  1. 静态:依赖在代码编译阶段就能确定。
  2. 可预测:编译器能画出完整的依赖图。
  3. 典型代表
    • ES Module (import/export)

例子(ESM):

// main.js
import { add } from './math.js';
console.log(add(1, 2));

特点:

  • import 必须写在顶层,不能放到 if/for 里。
  • 这样打包工具一眼就能看出 main.js 依赖了 math.js。

好处:

  • 打包工具可以做 Tree-shaking(只引入 add,sub 不会打包)。
  • 浏览器也能在解析 HTML 时就提前请求依赖(HTTP/2 push 或 ES module 原生支持)。

两者的根本差异

特性运行时加载编译时静态分析
加载时机执行到 require/import 时编译/打包阶段就能解析依赖
动态性支持条件加载、路径拼接必须是固定路径、顶层语句
优化难以优化,无法摇树可 Tree-shaking、代码分割
代表CommonJS、AMD、CMDES Module
应用场景Node.js 脚本,灵活加载前端工程化,依赖优化

总结

  • 运行时加载:灵活,但优化困难(CommonJS 的 require)。
  • 编译时静态分析:限制更多,但能让工具提前优化、提升性能(ESM 的 import)。

👉 这就是为什么现代前端框架(React/Vue 等)几乎都基于 ESM,而 Node.js 也开始支持 ESM(代替 CommonJS)。


拆解 ESM 的执行过程——从「JavaScript 代码的生命周期」的角度

前置思考

我们前面提到“ESM必须在编译阶段确定依赖关系,后由JS引擎运行代码”,这里涉及到两个阶段“编译”和“运行”,还有其其他阶段吗?每个阶段是干什么的?又是由谁负责?

总体框架

一个 JS 程序(特别是带模块化的 ESM)从写好到跑起来,一般会经历三个主要阶段:

  1. 加载(Loading / Fetching)
    把 JS 源码文件下载 / 读取到内存。
    👉 浏览器:发 HTTP 请求,获取 .js 文件。
    👉 Node.js:从文件系统读取 .mjs.js 文件。
  2. 编译(Parsing + Compilation)
    JS 引擎(比如 V8)把源码解析成抽象语法树(AST),生成可执行的中间表示(字节码)。
    👉 在 ESM,这一步同时做 依赖解析(Import/Export 静态分析)。
  3. 执行(Execution / Runtime)
    JS 引擎执行字节码(或者即时编译为机器码),运行程序逻辑,产出结果。

细化分工

我们可以更精细地拆成 六个步骤,分别看 浏览器 vs Node.js 干什么。


1. 资源获取(Loading)
  • 浏览器
    • 解析 HTML,发现 <script type="module">
    • 通过 URL 发送 HTTP 请求,获取模块文件。
    • 如果模块里有 import,继续递归发请求。
  • Node.js
    • 通过文件路径(相对 / 绝对 / node_modules),去磁盘查找并读取文件。

👉 负责者:宿主环境(浏览器 / Node.js)


2. 模块依赖图构建(Dependency Graph Building)
  • 发现 import / export,并在解析阶段就确定依赖关系。
  • 形成一个有向无环图(DAG),记录:
    • 每个模块的路径
    • 它依赖的模块
    • 导入/导出的符号

👉 负责者:JS 引擎 + 宿主环境(浏览器 / Node.js)配合

  • 引擎负责语法解析
  • 宿主负责递归加载依赖文件

3. 解析与编译(Parsing + Compilation)
  • JS 引擎(如 V8)把源码解析成 AST(抽象语法树)。
  • AST 转换成 字节码 或 JIT 优化后的机器码。
  • 这一步也会为 importexport 建立符号表,供执行时引用。

👉 负责者:JS 引擎(V8 / SpiderMonkey / JavaScriptCore)。


4. 模块实例化(Instantiation)
  • 给每个模块分配一个「执行上下文」:
    • 变量环境(Variable Environment)
    • 导出绑定(Exported Bindings)
    • 导入绑定(Imported Bindings → 指向依赖模块的导出)
  • 特别之处:ESM 是 Live Binding,即导入变量和原始导出是同一个引用,不是拷贝。

👉 负责者:JS 引擎


5. 执行(Execution)
  • 执行模块的代码,从入口模块开始。
  • 遇到函数调用、表达式求值、DOM API 调用等,逐步运行。
  • 按依赖图的拓扑顺序,保证先执行依赖,再执行使用方。

👉 负责者:JS 引擎


6. 运行时交互(Runtime Interaction)
  • JS 在执行过程中可能与宿主环境交互:
    • 浏览器:DOM API、定时器、事件循环。
    • Node.js:文件系统、网络请求、事件循环。
  • JS 引擎只负责「解释字节码」,而具体的 I/O 交给宿主环境。

👉 负责者:宿主环境(浏览器、Node.js)。


总结为「四层责任分工」

  1. 宿主环境(浏览器/Node.js)
    • 文件加载(HTTP / 文件系统)
    • 提供 API(DOM / FS / Net / Timer)
    • 管理事件循环
  2. 模块加载器(Loader)
    • 解析路径,递归加载依赖
    • 构建依赖图
  3. JS 引擎(V8 等)
    • 解析 → 编译 → 执行
    • 管理作用域、上下文、内存
    • 实现 ESM 的 binding 机制
  4. 运行时(Runtime)
    • 实际跑代码
    • 与宿主环境 API 交互

✅ 所以问题的答案可以总结成一句话:
ESM 模块的生命周期包含 加载 → 编译 → 实例化 → 执行,其中 宿主环境负责加载与 API 提供,JS 引擎负责编译与执行


导出机制:CommonJS vs ESM

CommonJS 的导出机制

在 Node.js 里,每个模块内部有 module.exports(对象),你 require 时,本质是 把这个对象的引用拿出来,然后存到缓存里。

情况一:导出对象
// counter.js
let count = 0;
function inc() { count++; }
module.exports = { count, inc };
// main.js
const c = require('./counter');
console.log(c.count); // 0
c.inc();
console.log(c.count); // 0 ❌ 没变

为什么?因为 count 的值是 数字,被放进 { count: 0 } 里后,require 拿到的是一个「拷贝的数值」,而不是动态绑定。
👉 值拷贝的表现


情况二:导出对象的引用
// counter.js
let count = 0;
function inc() { count++; }
module.exports = { get count() { return count }, inc };
// main.js
const c = require('./counter');
console.log(c.count); // 0
c.inc();
console.log(c.count); // 1 ✅ 动态变化

为什么?因为这里用了 getter,本质还是「通过函数访问模块内部的真实变量」,所以表现得像引用。
👉 但这是我们手动写 getter 实现的,CommonJS 自身不保证这一点


✅ 结论:

  • CommonJS 默认是值拷贝(值按 require 时的快照给你)
  • 但如果你导出对象 / 函数(本身是引用类型),就能间接访问到最新值,看起来像“引用”。
  • 所以说 CommonJS 是值拷贝 + 缓存机制,只是对象导出时能间接模拟“引用”。

ESM 的导出机制

ESM 的规范里明文规定:

  • export 出去的变量是 Live Binding(实时绑定)
  • import 的地方,拿到的是对原始变量的「引用」,不会复制数值。

例子:

// counter.mjs
export let count = 0;
export function inc() { count++; }
// main.mjs
import { count, inc } from './counter.mjs';
console.log(count); // 0
inc();
console.log(count); // 1 ✅ 自动更新

👉 无论是基本类型还是对象,import 绑定的始终是原变量。


差异总结

特性CommonJSESM
导出时机运行时编译时(静态分析)
导出机制基础类型为值拷贝,引用类型会引用传递(缓存 module.exports 对象)Live Binding(实时绑定)
更新同步默认不同步(需要手动 getter 才能看到最新值)自动同步
加载方式同步(require异步(import,浏览器支持并行加载)
  • CommonJS require 时拿到的是 module.exports 的引用,但里面的 基础值是拷贝
  • ESM 是直接绑定变量本身,保证 Live Binding

。 - 彻底统一了前端模块化的写法

模块化演进时间线(重点在 2009–2015)

  1. CommonJS(2009 左右)

    • Node.js 采用的规范。
    • 核心特征:require 同步加载、module.exports 导出。
    • 优势:在服务器端运行快、简单直接。
    • 劣势:不适合浏览器(浏览器加载网络资源不同步)。
  2. AMD(Asynchronous Module Definition,2010 前后,RequireJS 推广)

    • 适合浏览器异步加载模块:
define(['dep1', 'dep2'], function(dep1, dep2) {
  return { foo: dep1.bar() };
});
-   优点:浏览器端不卡顿。
-   缺点:写法繁琐,可读性差。

3. CMD(Common Module Definition,2011 左右,SeaJS 推广)

-   中国团队(阿里玉伯)主导,想改进 AMD。
-   特点:**依赖就近、延迟执行**
define(function(require, exports, module) {
  var $ = require('jquery');
  exports.foo = function() { $('#id').show(); };
});
-   优点:代码更像 CommonJS,语义直观。
-   缺点:需要运行时解析 `require`,性能上不如 AMD。

4. UMD(Universal Module Definition,大约 2012–2013)

-   不是新规范,而是一种 **兼容写法**
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery'], factory);
  } else if (typeof exports === 'object') {
    // CommonJS
    module.exports = factory(require('jquery'));
  } else {
    // 全局变量
    root.myLib = factory(root.jQuery);
  }
}(this, function ($) {
  return { foo: function() {} };
}));
    • 意义:一个库打包一次,同时支持 AMD / CommonJS / 全局变量,避免写多个版本。
  1. ESM(ES6 Module,2015)
    • 官方标准:import / export
    • 最终取代上面所有“民间规范”。

📌 它们的出现顺序

CommonJS → AMD → CMD → UMD → ESM
(CommonJS 在 Node,AMD/CMD 在浏览器,UMD 是折中产物,最终 ESM 统一大局)


学习重点

  • 必须掌握:CommonJS(Node.js 依然广泛使用)、ESM(现代前端标准)。
  • 略知一二:AMD/CMD/UMD(理解历史、读老项目源码、看三方库兼容写法)。
  • 没必要深挖:AMD/CMD 细节现在几乎用不到,UMD 只要知道是兼容模式就够。

👉 换句话说:

  • 面试 & 历史脉络 → 要能说清楚它们的出现原因和区别。
  • 日常开发 → 只需要会用 CommonJS 和 ESM。

模块化的意义与影响

  1. 开发体验:从“脚本拼接” → “模块化编程”。
  2. 依赖管理:能明确写清楚“我依赖谁”。
  3. 生产环境:模块化让工程化工具(Webpack、Rollup、Vite)有了施展空间。
  4. 团队协作:不同人写的代码不会互相污染。

👉 可以说,没有模块化,就不会有后来的工程化。


总结一下:

  • 模块化的出现,是为了解决 代码组织混乱、依赖管理困难、全局污染 的痛点。
  • 发展脉络:无模块 → CommonJS/AMD/CMD → ES6 Module
  • 它让前端从“玩具脚本”真正迈向了“软件工程”。