前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
首页程序员面试题库ESM 模块只执行一次的原因
JavaScript模块与语言机制

好几个文件都 import 同一个模块,它的代码会执行几次?

只执行一次。宿主会按解析后的完整 URL 把模块记进一张映射表,第一次导入时加载并执行,之后任何地方再导入同一个 URL,拿到的都是同一个模块实例,顶层代码不会再跑。

前端进阶之旅 · 一题精讲更新于 2026.09.29
JavaScript#模块与语言机制#缓存
先看核心答案读代码示例
理解线索

模块只跑一次靠的是模块映射表

  1. 说明符你写的 "./a.js",先被解析成完整 URL
  2. 模块映射表以 URL 为键存模块实例,命中就直接复用
  3. 模块实例包含导出绑定和执行状态,所有导入方共享

判断是不是同一个模块,看的是解析后的 URL,不是文件内容。同一份代码在打包产物里被复制进两个 chunk,或者从 CDN 和本地各加载一份,就是两个互不相干的实例,单例会变成两份。

核心回答

先记住这个答案

只执行一次。浏览器和 Node.js 都维护一张模块映射表,键是模块解析后的完整 URL。第一次遇到某个 URL 时,宿主加载、链接并执行它,把结果存进表里;之后别的模块再导入同一个 URL,直接复用这个实例,顶层代码不会重跑。所以多个导入方拿到的导出是同一份,模块顶层天然适合放单例。

  • 同一 URL 的模块只执行一次,所有导入方共享一个实例
  • 缓存的键是解析后的完整 URL,不是你写的字符串
  • 加查询参数或路径不同,会被当成另一个模块再执行
  • 模块顶层很适合放单例:连接池、store、事件总线

三个模块导入同一个文件

在 Node.js 里写三个文件:counter.mjs 在顶层打一行日志,a.mjs 和 main.mjs 都导入它。

日志只出现一次JavaScript
// counter.mjs
console.log('counter 执行了')
export const store = { count: 0 }

// a.mjs
import { store } from './counter.mjs'
store.count++

// main.mjs
import './a.mjs'
import { store } from './counter.mjs'
import * as again from './counter.mjs'
console.log(store.count) // 1,a.mjs 改的就是这一份
console.log(again.store === store) // true

// 运行 node main.mjs,「counter 执行了」只打印一次

a.mjs 和 main.mjs 各导入了一次,main.mjs 还用两种写法导入了两次,但日志只有一行。a.mjs 里对 store 的修改,main.mjs 里读得到,因为它们拿到的是同一个对象。

什么情况下会执行第二次

既然缓存按 URL 区分,那 URL 变了,就是另一个模块。

URL 不同就是不同的模块
最常见的是加查询参数:import("./counter.mjs?v=2") 会让 counter.mjs 再执行一次,得到一个全新的 store,和原来那份互不影响。开发时有人故意用这招绕过缓存,重新加载配置。 另外几种情况:同一个库通过 CDN 地址和本地路径各引了一次;项目依赖里装了同一个包的两个版本,路径分别在 node_modules/a/node_modules/x 和 node_modules/x;打包时同一个模块被分进了两个互相独立的产物。这些都会得到两个实例。React 报「Invalid hook call」,有一类原因就是页面里有两份 React。 Node.js 里还有一点:同一个包如果同时被 require 和 import,而包给 CommonJS 和 ESM 配了不同的入口文件,那就是两个文件、两份状态,这就是常说的双包风险。
回答前,多想一步

容易答错的地方

以为动态 import() 每次都会重新执行
import() 和静态 import 共用同一张模块映射表。第一次调用会加载执行,之后再调用同一个 URL,返回的 Promise 直接用缓存的实例解决,不会重跑顶层代码。
以为删掉缓存就能热更新
CommonJS 可以 delete require.cache[path] 让模块重新加载,ESM 没有公开的 API 可以清除模块映射表。Vite 这类工具的热更新是自己给 URL 加时间戳参数,让浏览器当成新模块去加载,旧实例其实还在。
试着用自己的话回答

面试官还会怎么问?

CommonJS 也是只执行一次吗?

也是。require 会把模块按解析后的文件路径缓存在 require.cache 里,第二次 require 直接返回缓存的 module.exports。区别是 CommonJS 的缓存可以手动删,而且缓存的是导出对象,ESM 缓存的是整个模块实例和它的实时绑定。

循环依赖时模块会不会被执行两次?

不会。A 导入 B、B 又导入 A 时,宿主发现 A 已经在映射表里、正在处理中,就不会重新执行 A,而是让 B 直接拿到 A 的绑定。代价是 B 执行时 A 可能还没初始化完,访问 A 里用 let、const 声明的导出会抛 ReferenceError。

为什么说模块适合写单例?

模块顶层只跑一次,导出的对象全局只有一份,不需要自己写 getInstance 这类判断。比如在 db.js 顶层创建数据库连接池并导出,所有业务模块导入的都是同一个池。前提是确保整个应用只加载了这个模块的一份。

从一道题,走向一组知识

把知识连起来

模块与语言机制

import * as ns 拿到的对象是冻结的吗?能改它的属性吗?

多次导入拿到的是同一个模块命名空间对象,而这个对象本身是不能被修改的。

模块与语言机制

导出方改了导出对象的属性,导入方能看到吗?

因为所有导入方共享一个模块实例,一方修改导出对象的属性,其它导入方都能看到。

参考资料

  • JavaScript:模块与语言机制参考资料

示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。

本题目录
  1. 先记住这个答案
  2. 三个模块导入同一个文件
  3. 什么情况下会执行第二次
  4. 容易答错的地方
  5. 面试官还会怎么问
  6. 把知识连起来
读懂,再试着讲出来

先看核心答案,再读代码。最后展开追问,检查自己有没有遗漏边界。

试着回答追问
浏览全部面试题理解原理,也关注真实的使用场景。回到顶部 ↑