由于面试时遇到此类问题无法系统回答,因此想要阅读总结一下相关的部分。 原文链接
模块(Module)介绍
模块本质上是独立文件,通过 export
暴露、import
引入,实现按需加载与功能共享。
-
模块要加
type="module"
因为 ES 模块有一些特殊语法和行为(比如
import
/export
)。要让浏览器把某个脚本当作模块来处理,就必须在<script>
标签上写type="module"
。 -
写法示例
在 HTML 里这样写:
html<script type="module"> import { sayHi } from './say.js'; document.body.innerHTML = sayHi('World'); </script>
或者把模块放到单独的文件:
html<script type="module" src="./index.js"></script>
-
浏览器会做什么
浏览器看到
type="module"
后,会:- 以"模块"模式加载并解析脚本。
- 根据
import
语句,继续去获取并解析被导入的文件(以及它们的依赖)。 - 全部就绪后再执行脚本。
那我就要问了,与常规脚本相比,模块有什么不同呢?
1.始终使用"use strict"
模块无需写 "use strict"
,默认就是严格模式。下面说说严格模式的影响。
1.1未声明变量的赋值
- 普通脚本(非严格模式)可能"静默"创建全局变量:
html
<!-- index.html -->
<script>
// 非严格模式(默认)
x = 1; // 不报错(在部分环境下会隐式创建全局变量)
console.log(window.x); // 1
</script>
- 模块脚本(严格模式自动开启)会直接报错:
html
<!-- index.html -->
<script type="module">
x = 1; // ReferenceError: x is not defined
</script>
严格模式下,给未声明的变量赋值是非法的。模块总是严格模式,所以会抛出错误。
1.2 this的值
- 普通脚本顶层的
this
指向window
:
html
<script>
console.log(this === window); // true
</script>
- 模块顶层的
this
是undefined
(严格模式行为):
html
<script type="module">
console.log(this); // undefined
</script>
1.3 重复定义同名参数
- 非严格模式函数允许同名参数:
html
<script>
function f(a, a) { return a; }
console.log(f(1, 2)); // 2(不推荐,但不报错)
</script>
- 模块(严格模式)中会报语法错误:
html
<script type="module">
// SyntaxError: Duplicate parameter name not allowed in this context
function f(a, a) { return a; }
</script>
2.模块级作用域
每个 ES 模块都有自己的顶级作用域(top-level scope)。
因此,一个模块里在顶层声明的变量/函数(例如 let
、const
、function
)默认只在该模块内部可见,不会泄露到全局,也不会自动被其他模块看到。
如果想让别的模块使用,必须显式地 export
并在对方模块里 import
。
当你在 HTML 中这样引入两个模块脚本时:
html
<!doctype html>
<script type="module" src="user.js"></script>
<script type="module" src="hello.js"></script>
user.js
和 hello.js
是两个独立模块,各自有独立顶级作用域。
在 hello.js
里直接使用 user
(由 user.js
顶层声明)会报错:ReferenceError: user is not defined
,因为 user
并没有被导出,也没有被导入。
正确做法: 用 import/export 传递数据
在 user.js
中显式导出:
js
// user.js
export const user = "John";
在 hello.js
中显式导入并使用:
js
// hello.js
import { user } from "./user.js";
document.body.innerHTML = user; // John
这样浏览器会按照依赖关系加载并把 user
绑定到 hello.js
的本地作用域中。
即便写在同一 HTML 页面上,只要是 type="module"
,它们顶层依旧隔离:
html
<script type="module">
// 仅在当前模块可见
let user = "John";
</script>
<script type="module">
alert(user); // Error: user is not defined
</script>
关于把变量挂到 window(不推荐)
- 你可以强行把值放到全局窗口对象上:
js
// 任意脚本中
window.user = "John";
这样所有脚本(无论是否模块)都能访问 window.user
。
但不建议这样做:会造成全局命名污染、增加耦合、难以维护和测试。模块系统本身就是为了解决这些问题。
3.模块代码仅在第一次导入时被解析
同一个模块无论被导入多少次,代码只会在第一次导入时执行一次。
执行后的"导出值"(变量、对象、函数等)会被缓存,并在后续所有 import
中共享同一份引用。
这带来两个直接效果:
- 顶层代码(模块文件最外层那部分)只会跑一次,常用于"初始化"。
- 被导出的对象/值在各处导入时共享,如果是对象,修改它会被所有导入方看到。
例子 1:副作用只发生一次
js
// alert.js
alert("Module is evaluated!");
js
// 1.js
import "./alert.js"; // 弹一次
js
// 2.js
import "./alert.js"; // 不再弹,因为 alert.js 已经执行过
- 第一次导入会执行顶层代码并产生副作用(弹窗)。
- 之后的导入用的是缓存结果,不再重复执行。
提示:顶层代码适合做"一次性初始化"。要多次调用的功能,用函数导出。
例子 2:导出对象是"同一个实例"
js
// admin.js
export let admin = { name: "John" };
js
// 1.js
import { admin } from "./admin.js";
admin.name = "Pete";
js
// 2.js
import { admin } from "./admin.js";
alert(admin.name); // "Pete"
admin
对象在首次导入时创建并缓存。- 之后无论在哪个文件导入,拿到的都是"同一对象的引用"。
- 因此在 A 文件修改属性,B 文件能看到变化。
我们可以把模块想象成一个"单例服务",首次导入时做配置,后续任何地方使用时都是配置后的版本。
js
// admin.js
export let config = {}; // 导出一个可写的配置对象
export function sayHi() {
alert(`Ready to serve, ${config.user}!`);
}
js
// init.js (应用最先运行的入口)
import { config } from "./admin.js";
config.user = "Pete"; // 只配一次,全局生效
js
// another.js (应用中任意后续文件)
import { sayHi } from "./admin.js";
sayHi(); // Ready to serve, Pete
config
在首次导入时被设置好。- 之后任意地方调用
sayHi
会读取到同一个config
。
4.import.meta
import.meta
是每个 ES 模块里都可用的一个只读对象,用来提供"当前模块自身"的元信息(metadata)。
它是标准的一部分,但里面到底有哪些字段,会随运行环境而变化(浏览器、Node.js、打包器等各有扩展)。
浏览器中的 import.meta
-
import.meta.url
:当前模块的完整 URL。- 外链模块文件:就是这个 JS 文件的 URL(可能是
https://.../foo.js
,也可能是blob:
、data:
、file:
等协议)。 - HTML 中的内联
type="module"
脚本:是当前 HTML 页面的 URL。
- 外链模块文件:就是这个 JS 文件的 URL(可能是
示例:
html
<!doctype html>
<script type="module" src="/scripts/main.js"></script>
<script type="module">
// 这是内联模块脚本
console.log(import.meta.url); // => 当前 HTML 页面的 URL,如 https://example.com/index.html
</script>
js
// /scripts/main.js
console.log(import.meta.url);
// => https://example.com/scripts/main.js
小提示:在浏览器里,
new URL('./asset.png', import.meta.url)
很常用,用于从当前模块出发构造相对资源路径的绝对 URL。
典型用法
-
构造资源绝对路径
js// imageLoader.js const imgUrl = new URL('./images/logo.png', import.meta.url).href; const img = new Image(); img.src = imgUrl; document.body.appendChild(img);
这样不受页面当前位置或
base
标签影响,资源解析相对"模块文件"而非"页面"。 -
调试/日志
jsconsole.log('Loaded module from:', import.meta.url);
-
区分运行环境(基于实现提供的额外字段)
- 浏览器通常只保证
url
。 - 某些打包器会注入自定义字段(如
import.meta.env
),可用于读取构建注入的环境变量。 - 在 Node.js 里有更多可用能力(自行查阅)。
- 浏览器通常只保证
5.在一个模块中,"this"是"undefined"
严格模式的影响下,模块脚本的顶级this是全局对象。
html
<script> alert(this); // window </script>
<script type="module"> alert(this); // undefined </script>
浏览器特定功能
模块脚本是延迟的
在浏览器里,凡是使用 <script type="module">
的脚本,都会自动采用与 defer
相同的加载与执行策略。你无需手动写 defer
,效果等价。
具体规则
-
并行下载
- 外链模块脚本:
<script type="module" src="...">
会与其他资源并行下载,不阻塞 HTML 解析。
- 外链模块脚本:
-
等文档就绪再执行
- 即使脚本很小、下载很快,也会等到整个 HTML 解析完成 后才执行(类似
defer
)。
- 即使脚本很小、下载很快,也会等到整个 HTML 解析完成 后才执行(类似
-
保持相对顺序
- 多个模块脚本会按它们在文档中的先后顺序依次执行。
-
可见完整 DOM
- 因为执行时机在文档解析完成之后,模块脚本能"看到"其下方的元素。
对比示例
html
<!-- 模块脚本:延迟执行 -->
<script type="module">
alert(typeof button); // "object" ------ 能访问到下面的 #button
</script>
<!-- 常规脚本:立即执行,阻塞解析 -->
<script>
alert(typeof button); // "undefined" ------ 此时 #button 还没解析到
</script>
<button id="button">Button</button>
-
实际输出顺序会先看到
"undefined"
,再看到"object"
:- 常规脚本先执行(即时、阻塞),
- 模块脚本等 DOM 全部解析完后才执行。
实际影响与最佳实践
-
页面会先显示,JS 再接管
- 使用模块时,HTML 会先渲染出来,模块代码稍后执行。
- 用户可能在应用"还没完全就绪"时看到页面。
-
建议措施
- 加载指示:在关键区域放置"loading"或骨架屏,待模块完成初始化后再移除。
- 禁用未就绪交互 :初始给按钮加
disabled
或用半透明样式,初始化完成后启用。 - 避免闪烁(FOUC) :通过初始样式隐藏/占位,或在模块准备好后再显示动态区域。
- 拆分初始化 :把"一次性初始化"放在模块顶层或
DOMContentLoaded
/requestIdleCallback
中,按需懒加载非关键模块。
async
也适用于"内联"的模块脚本
在传统(非模块)脚本里,async
只对"外链脚本"生效:
外链 <script async src="...">
下载时不阻塞 HTML,下载完成后立刻执行(不保证顺序,也不等 DOM 就绪)。但对"内联脚本"无效,因为没有下载阶段可异步。
对 ES 模块脚本(type="module"
),async
还可以用于"内联脚本"。原因是:即便是内联模块脚本,它也可能包含 import
,浏览器需要异步去获取依赖。因此内联模块脚本具备可"异步执行"的意义。
行为规则(内联 module + async)
-
并行获取依赖 :遇到内联
<script async type="module">
,浏览器会解析其中的import
,并发去获取依赖(如./analytics.js
)。 -
依赖就绪立刻执行:一旦所有依赖加载与解析完成,脚本就会立刻执行。
- 不等待 HTML 解析完成(不等 DOM ready)。
- 不等待其他脚本(顺序不保证)。
-
完全独立:它像"独立任务",适合不依赖 DOM 结构、不依赖其他脚本初始化的功能。
示例与适用场景
示例代码:
html
<!-- 获取完依赖(analytics.js)就执行;不等文档、不等其它脚本 -->
<script async type="module">
import { counter } from './analytics.js';
counter.count();
</script>
非常适合:
- 统计/埋点:页面一出现就尽早发送曝光、PV/UV、性能指标。
- 广告初始化:尽快拉取广告 SDK 并渲染位。
- 全局事件监听:如页面可见性、网络状态、性能观察等,不依赖具体 DOM 节点。
- 非阻塞资源预取/预热 :在模块中发起
preload
、preconnect
、prefetch
或小数据缓存等。
和其他模式的对比
脚本类型 | 下载 | 执行时机 | 顺序保证 | 适合场景 |
---|---|---|---|---|
普通 <script> |
阻塞 | 立即(阻塞解析) | 有(遇到就执行) | 必须即时运行且依赖之前脚本 |
普通 <script defer src> |
并行 | DOM 解析完成后 | 有(按出现顺序) | 依赖 DOM 完整、需确定顺序 |
普通 <script async src> |
并行 | 下载就绪立刻 | 无 | 独立、无依赖的外链脚本 |
<script type="module"> |
并行(依赖) | DOM 解析完成后 | 有 | 模块化应用主入口 |
<script async type="module"> |
并行(依赖) | 依赖就绪立刻 | 无 | 独立的模块功能:统计、广告、监听 |
注意:表中"并行(依赖)"表示浏览器会并发获取模块及其 import
链上的依赖。
实战建议与注意点
-
确保真正无依赖 :用
async module
的内联脚本不应依赖页面下方 DOM 或其他脚本的副作用;否则会出现"偶发未定义"的问题。 -
避免与主模块竞态 :主入口通常用非
async
的type="module"
,而旁路功能用async module
,避免彼此初始化顺序耦合。 -
错误处理:为网络失败添加兜底,比如:
js<script async type="module"> import('./analytics.js') .then(({ counter }) => counter.count()) .catch(err => console.warn('analytics failed:', err)); </script>
-
性能收益:能更早启动关键但独立的逻辑,同时不阻塞页面解析与渲染。
外部脚本
当你用 <script type="module" src="...">
引入"外部模块脚本"时,它与普通引入外部脚本有两点关键差异。
相同 src
的模块脚本只执行一次
- 规则:同一页面里,如果多处引用了相同 URL 的模块脚本,浏览器只会获取并执行一次,其它位置复用缓存的模块实例和导出结果。
- 原因 :ESM 有"模块实例缓存"。首次导入(或
<script type="module" src>
)会评估模块并缓存其导出;后续相同 URL 的再次导入不会重复执行顶层代码。
示例:
html
<!-- my.js 只会被获取/执行一次 -->
<script type="module" src="/my.js"></script>
<script type="module" src="/my.js"></script>
- 如果
my.js
有顶层副作用(比如日志或初始化),它只会发生一次。 - 多处需要同一功能时,不用担心重复执行引发的副作用或性能浪费。
注意:不同的 URL(即便内容相同)被视为不同模块;包含查询串或哈希变化也会被认为是不同地址,例如
/my.js?v=1
与/my.js?v=2
。
跨源模块脚本必须满足 CORS
-
规则:从不同源加载的模块脚本需要远端服务器正确返回 CORS 允许头,例如:
Access-Control-Allow-Origin: *
或Access-Control-Allow-Origin: https://your-site.com
-
否则:脚本会被浏览器拦截,模块无法加载或执行。
示例:
html
<!-- another-site.com 必须返回允许的 CORS 头,否则加载失败 -->
<script type="module" src="https://another-site.com/their.js"></script>
为什么模块需要 CORS?
- ESM 的获取过程走"模块获取算法",默认是"CORS 模式"以保证安全性与可追踪依赖。
- 这与传统
<script src>
(非模块)默认是"无 CORS"的宽松策略不同。
实战要点
-
若你控制不了对方服务器,可以考虑:
- 通过你自己的后端代理该资源并添加合规的 CORS 头;
- 或在构建阶段将第三方脚本打包进来,避免运行时跨源请求。
-
若目标是同源但不同端口/子域,也属于跨源,同样需要 CORS。
不允许裸露模块
- 浏览器要求 :
import
里的模块说明符必须是 URL 可解析的路径。 - 不允许裸模块 :
import 'react'
、import { sayHi } from 'sayHi'
在浏览器中会报错。 - 允许的形式 :
./foo.js
、../utils/index.js
、/assets/mod.mjs
、https://cdn.example.com/pkg/v1/index.js
无效(裸模块):
js
import { sayHi } from 'sayHi'; // ❌ Error:在浏览器中无效
有效(带路径):
js
import { sayHi } from './sayHi.js'; // ✅ 相对路径
import { sum } from '../lib/math/index.mjs';// ✅ 相对路径(上级目录)
import _ from '/vendor/lodash-es/lodash.js';// ✅ 站点根路径
import React from 'https://esm.sh/react'; // ✅ 绝对 URL(CDN)
为什么浏览器不支持裸模块?
- 浏览器的模块加载器是基于 URL 的。遇到
import
时,需要立刻把说明符解析成一个实际资源的 URL 去获取。 - 裸模块名(如
react
)并不是 URL,浏览器没有内置的"模块解析算法"(比如 Node 的node_modules
查找规则)来把它映射到某个文件。 - 因此,浏览器原生不认识"裸模块"。
兼容老浏览器
旧时的浏览器不理解
type="module"
。未知类型的脚本会被忽略。对此,我们可以使用nomodule
特性来提供一个后备。也就是使用nomodule
来代替type="module"
构建工具
在真实项目里,很少直接把浏览器原生 ES 模块以"原汁原味"的方式上线。大多数团队会使用构建工具(如 Webpack、Rollup、Vite、Parcel)在开发时提升体验、在发布前进行打包与优化。【挖坑,后续来填上】