【JS】模块(一)

由于面试时遇到此类问题无法系统回答,因此想要阅读总结一下相关的部分。 原文链接

mindmap 模块 1. 模块介绍 2. 导入和导出 3. 动态导入

模块(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" 后,会:

    1. 以"模块"模式加载并解析脚本。
    2. 根据 import 语句,继续去获取并解析被导入的文件(以及它们的依赖)。
    3. 全部就绪后再执行脚本。

那我就要问了,与常规脚本相比,模块有什么不同呢?

mindmap 模块核心功能 始终使用"use strict" 在一个模块中,"this"是undefined 模块级作用域 模块代码仅仅在第一次导入的时候被解析 import.meta

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>
  • 模块顶层的 thisundefined(严格模式行为):
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)。

因此,一个模块里在顶层声明的变量/函数(例如 letconstfunction)默认只在该模块内部可见,不会泄露到全局,也不会自动被其他模块看到

如果想让别的模块使用,必须显式地 export 并在对方模块里 import

当你在 HTML 中这样引入两个模块脚本时:

html 复制代码
<!doctype html>
<script type="module" src="user.js"></script>
<script type="module" src="hello.js"></script>

user.jshello.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. 顶层代码(模块文件最外层那部分)只会跑一次,常用于"初始化"。
  2. 被导出的对象/值在各处导入时共享,如果是对象,修改它会被所有导入方看到。
例子 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。

示例:

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 标签影响,资源解析相对"模块文件"而非"页面"。

  • 调试/日志

    js 复制代码
    console.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)。
  • 保持相对顺序

    • 多个模块脚本会按它们在文档中的先后顺序依次执行。
  • 可见完整 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 节点。
  • 非阻塞资源预取/预热 :在模块中发起 preloadpreconnectprefetch 或小数据缓存等。
和其他模式的对比
脚本类型 下载 执行时机 顺序保证 适合场景
普通 <script> 阻塞 立即(阻塞解析) 有(遇到就执行) 必须即时运行且依赖之前脚本
普通 <script defer src> 并行 DOM 解析完成后 有(按出现顺序) 依赖 DOM 完整、需确定顺序
普通 <script async src> 并行 下载就绪立刻 独立、无依赖的外链脚本
<script type="module"> 并行(依赖) DOM 解析完成后 模块化应用主入口
<script async type="module"> 并行(依赖) 依赖就绪立刻 独立的模块功能:统计、广告、监听

注意:表中"并行(依赖)"表示浏览器会并发获取模块及其 import 链上的依赖。

实战建议与注意点
  • 确保真正无依赖 :用 async module 的内联脚本不应依赖页面下方 DOM 或其他脚本的副作用;否则会出现"偶发未定义"的问题。

  • 避免与主模块竞态 :主入口通常用非 asynctype="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.mjshttps://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)在开发时提升体验、在发布前进行打包与优化。【挖坑,后续来填上】

相关推荐
不一样的少年_3 小时前
同事以为要重写,我8行代码让 Vue 2 公共组件跑进 Vue 3
前端·javascript·vue.js
草履虫建模3 小时前
在 RuoYi 中接入 3D「园区驾驶舱」:Vue2 + Three.js + Nginx
运维·开发语言·javascript·spring boot·nginx·spring cloud·微服务
云枫晖3 小时前
JS核心知识-数据转换
前端·javascript
JohnYan4 小时前
工作笔记 - 一个浏览器环境适用的类型转换工具
javascript·后端·设计模式
前端Hardy4 小时前
12个被低估的 CSS 特性,让前端开发效率翻倍!
前端·javascript·css
前端Hardy4 小时前
HTML&CSS:精美的3D折叠卡片悬停效果
前端·javascript·css
nightunderblackcat4 小时前
新手向:中文语言识别的进化之路
前端·javascript·easyui
用户47949283569155 小时前
🤫 你不知道的 JavaScript:`"👦🏻".length` 竟然不是 1?
前端·javascript·面试
xingkongv5 小时前
从“调接口仔”到“业务合伙人”:前端的 DDD 初体验
javascript·前端框架