📘 前端工程化实战课程 · 第二章
浏览器原生模块化:ESM 的机制与边界
导读:告别全局变量污染,拥抱浏览器原生的 ES Modules (ESM)。本章不依赖任何构建工具,直接在浏览器中运行模块化代码。我们将深入探究浏览器的静态分析机制、加载时序以及多入口架构,并厘清“浏览器能力”与“工程化需求”的边界。
1. 核心概念:什么是浏览器原生 ESM?
ESM (ECMAScript Modules) 是 JavaScript 官方的模块化标准。现代浏览器(Chrome 61+, Firefox 60+, Safari 10.1+)已完全原生支持,无需任何转译或打包即可直接运行。
1.1 两个关键语法
-
导出 (
export) :暴露变量、函数或类。// utils.js export const PI = 3.14; // 命名导出 export function add(a, b) { ... } export default class User { ... } // 默认导出 -
导入 (
import) :引入其他模块的功能。// app.js import { PI, add } from './utils.js'; // 对应命名导出 import User from './user.js'; // 对应默认导出
1.2 激活方式
在 HTML 中,必须显式声明 type="module":
<!-- ❌ 错误:会被当作普通脚本,报错 "Cannot use import statement outside a module" -->
<script src="app.js"></script>
<!-- ✅ 正确:浏览器启动模块加载器 -->
<script type="module" src="app.js"></script>
2. 深度原理:浏览器是如何加载模块的?
这是理解 ESM 性能特征的关键。浏览器的行为与普通 <script> 标签有本质区别。
2.1 静态分析 (Static Analysis)
核心规则:import 语句必须是静态的。
- ✅
import './a.js'(字符串字面量,路径固定) - ❌
import './' + fileName(动态拼接,禁止) - ❌
if (condition) { import './a.js' }(条件导入,禁止在顶层)
为什么重要?
因为路径是固定的,浏览器可以在不执行任何代码的情况下,仅通过读取文本就能知道这个文件依赖了谁。
2.2 加载时序:预加载扫描 (Preload Scanner)
当浏览器遇到 <script type="module"> 时,发生以下过程:
-
下载入口文件:请求
app.js。 -
暂停执行:绝对不执行
app.js中的任何代码(包括console.log)。 -
递归扫描与预加载:
- 解析
app.js源码,提取所有import路径。 - 发现依赖
store.js和utils.js,立即并行发起请求。 - 拿到
store.js后,继续扫描它的import,发现依赖api.js,继续请求。 - 此过程递归进行,直到构建出完整的依赖树。
- 解析
-
统一执行:只有当依赖树中所有文件都下载成功后,浏览器才按照依赖顺序(先底层,后上层)开始执行代码。
💡 结论 1:ESM 是“全有或全无” (All-or-Nothing)。如果深层依赖中有一个文件 404 或加载失败,整个模块树都不会执行。
💡 结论 2:这也意味着,如果依赖树很深或文件很大,首屏等待时间会变长,因为必须等所有资源就位。
3. 实战特性:作用域与多入口
3.1 严格的作用域隔离
每个模块都有自己独立的作用域。
- 在
app.js中定义的const x = 1,在store.js中完全不可见。 - 它们也不会污染全局
window对象。 - 优势:彻底解决了第一章中的“变量命名冲突”问题。
3.2 多入口支持 (Multiple Entry Points)
一个 HTML 页面可以包含多个独立的模块入口:
<body>
<div id="header"></div>
<div id="footer"></div>
<!-- 入口 A:负责头部逻辑 -->
<script type="module" src="./header.js"></script>
<!-- 入口 B:负责底部逻辑 -->
<script type="module" src="./footer.js"></script>
</body>
- 独立性:
header.js和footer.js互不干扰,拥有各自的作用域。 - 共享缓存:如果两者都
import './utils.js',浏览器只会下载一次utils.js。 - 单例特性:在现代浏览器中,同一 URL 的模块通常只会被求值 (Evaluate) 一次。这意味着如果
utils.js导出了一个对象实例,两个入口引用的是同一个实例。
4. 最佳实践:命名导出 vs 默认导出
在纯浏览器环境下,选择哪种导出方式主要基于语义和未来优化潜力。
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 工具函数库 | 命名导出 (export const fn) | 语义明确:调用者清楚知道引入了什么。 Tree Shaking 基础:虽然浏览器不做 Tree Shaking,但这种写法为将来使用构建工具优化打下了基础(允许移除未使用的函数)。 |
| 单一组件/类 | 默认导出 (export default) | 简洁直观:一个文件主要就导出一个东西(如 Button.vue, User.class),导入时无需纠结名字,可随意重命名。 |
| 混合场景 | 混合使用 | 框架常用模式:默认导出主组件,命名导出辅助 Hooks 或常量。 |
// ✅ 推荐:工具库
export function formatDate() {}
export function parseJSON() {}
// ✅ 推荐:组件
export default function Button() {}
5. 边界探讨:浏览器 ESM 的局限性
既然浏览器原生支持这么好,为什么我们还需要 Webpack/Vite?
注意:本章不深入讲解工具原理,仅列出浏览器做不到的事,作为下一章的引子。
🚫 限制 1:无法解析裸模块 (Bare Specifiers)
- 浏览器:
import React from 'react'❌ 报错。浏览器不知道'react'对应哪个 URL,它只认识相对路径 (./,/,https://)。 - 现状:你必须写
import React from './node_modules/react/index.js'(极其繁琐且不可维护)。
🚫 限制 2:不支持非 JS 资源
- 浏览器:
import './style.css'❌ 报错。JS 引擎无法解析 CSS 语法。 - 现状:CSS 必须通过
<link>标签引入,无法像 JS 一样模块化导入。
🚫 限制 3:网络请求开销
- 浏览器:如果有 100 个模块文件,就会发起 100 次 HTTP 请求。虽然 HTTP/2 支持并发,但在弱网环境下,大量的握手和延迟依然会影响性能。
- 现状:没有合并文件的能力。
🚫 限制 4:不支持新语法 (如 TypeScript/JSX)
- 浏览器:只能运行标准的 JavaScript。
.ts或.jsx文件直接加载会报错。
🔜 预告:正是因为上述限制,工程化工具 (Bundler) 应运而生。下一章,我们将引入构建工具,看看它们如何填补浏览器能力的空白,实现
npm install、TypeScript 支持和自动打包。
6. 本章实验任务
目标:在不使用任何构建工具(无 Webpack/Vite,无 npm 包)的情况下,重构 Todo List。
-
环境准备:
- 必须使用本地服务器 (Live Server /
python -m http.server)。注意:由于 CORS 策略,ESM 模块不能通过file://协议直接打开,必须通过http://访问。
- 必须使用本地服务器 (Live Server /
-
代码拆分:
- 将逻辑拆分为
store.js,utils.js,ui.js,app.js。 - 使用
export/import连接它们。 - 确保
index.html中只引入app.js。
- 将逻辑拆分为
-
观察现象:
- 打开 Chrome DevTools -> Network 面板。
- 观察 Waterfall (瀑布流) :验证是否所有 JS 文件都在执行前被下载了?
- 尝试故意写错一个依赖路径,观察控制台报错,验证“全有或全无”机制。
-
多入口测试:
- 在 HTML 中增加一个
<script type="module" src="./analytics.js">,模拟独立的统计模块,验证它与主程序互不干扰。
- 在 HTML 中增加一个
7. 总结
- ESM 是静态的:浏览器在执行前通过静态分析构建依赖树。
- 加载是阻塞的:所有依赖必须下载完成后,代码才开始执行。
- 作用域是隔离的:模块间变量不可见,天然防冲突。
- 能力是有边界的:浏览器不懂
node_modules,不懂 CSS 导入,不懂 TS。这些是工程化要解决的问题。