前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
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 实时绑定与 CommonJS 区别
JavaScript模块与语言机制

为什么 ESM 导出的变量是实时绑定,CommonJS 不是?

ESM 的 import 拿到的是对源模块导出变量的实时引用,源端改了值,导入方立刻就能读到新值。CommonJS 的 require() 拿到的是 module.exports 在那一刻的值拷贝,之后源端怎么改,导入方都看不到——除非导出的是对象,两边操作的是同一个引用。

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

两种模块系统的导出机制

  1. ESM 实时绑定import 是源模块变量的只读引用,值变了这边跟着变
  2. CJS 值拷贝require() 执行时把 module.exports 的当前值复制过来
  3. CJS 对象引用导出对象时两边指向同一个对象,但机制不同于 ESM 的实时绑定

CJS 里导出对象后两边能看到同一个对象的属性修改,但如果源端把 module.exports.count 换成新值,导入方已经解构出来的 count 不会变——这是引用共享,不是实时绑定。

核心回答

先记住这个答案

ESM 的 import 建立的是一个对源模块导出变量的实时引用。源模块把 count 从 0 改成 1,导入方读 count 立刻就是 1。CommonJS 的 require() 不是这样——它在执行 require 的那一刻,把 module.exports 的值复制了一份。如果导出的是数字、字符串这类基本类型,之后源端怎么改,导入方手上的还是旧值。

  • ESM import 是实时绑定,随时读到源模块的最新值
  • CJS require() 对基本类型是值拷贝,之后的修改看不到
  • CJS 导出对象时两边共享同一个引用,但这不是实时绑定
  • 实时绑定是单向只读的,导入方不能赋值

用代码看清两者的区别

直接看一个 ESM 的例子。源模块导出一个 let 变量,稍后改变它的值,导入方能看到变化。

ESM 实时绑定的效果JavaScript
// counter.mjs
export let count = 0
export function increment() {
  count++
}

// main.mjs
// import { count, increment } from './counter.mjs'
// console.log(count)    // 0
// increment()
// console.log(count)    // 1 —— 实时读到了源模块修改后的值

导入方没有自己的 count 副本,count 始终指向源模块的那个变量。每次读取都是去源模块那边拿当前值。 CommonJS 就不一样了。require() 在执行时把 module.exports 的值复制过来。如果导出的是基本类型,复制的是值本身,后面源端的修改和导入方无关。

为什么两套系统的行为不一样

根本原因在于两者的设计目标不同。

CommonJS 是 Node.js 早期的模块系统,设计上就是同步执行的脚本拼接。require() 就像一个函数调用,执行到这行时同步运行目标模块的代码,然后把 module.exports 的值返回给调用方。返回的是值,不是绑定关系。

ESM 是语言层面的模块标准。引擎在代码执行之前就完成了模块图的解析和链接。import { count } 不是"把 count 的值拷过来",而是"建立一个绑定,指向源模块的 count 变量"。这个绑定在整个生命周期里一直有效。

一个直观的类比:CJS 的 require 像拍照——拍的那一刻是什么就是什么。ESM 的 import 像装了一个监控摄像头——随时看到最新画面。

CJS 导出对象时的特殊情况

CJS 里如果导出的是一个对象,require() 拿到的是对象的引用(不是深拷贝)。两边操作的是同一个对象,所以修改对象的属性确实两边都能看到。

但这和 ESM 的实时绑定有本质区别。CJS 里如果源模块执行 module.exports.data = newObj,把 data 属性指向了一个新对象,之前已经解构出 const { data } = require('./a') 的导入方看到的还是旧对象。因为 data 是在 require 那一刻从对象上拿出来的值(引用),后面属性指向变了,已经拿到手的引用不会自动更新。

ESM 的实时绑定不存在这个问题。只要源模块用 let 声明并修改了导出变量,不管是基本类型还是对象,导入方每次读取都是最新值。

回答前,多想一步

容易答错的地方

以为 CJS 导出对象就等于实时绑定
CJS 导出对象时,两边确实共享同一个引用,但这只是 JavaScript 对象传引用的基本机制,不是实时绑定。实时绑定的意思是"变量本身"的重新赋值也能被感知到,CJS 做不到这一点。
以为 ESM 实时绑定意味着导入方可以写入
ESM 的 import 绑定是只读的。导入方尝试给 count 赋值会直接报 TypeError。只有源模块自己能修改导出的变量,导入方只能读。这是 ESM 的设计约束——保证数据流的方向是单向的。
试着用自己的话回答

面试官还会怎么问?

循环依赖时实时绑定有什么表现?

ESM 处理循环依赖时,被循环引用的模块可能还没执行完,导出的变量处于未初始化状态(TDZ)。如果在那个时间点读取该变量,会报 ReferenceError。但一旦源模块执行完,实时绑定就能正常读到值了。CJS 的循环依赖则是拿到部分完成的 module.exports,不会报错但可能拿到 undefined。

Webpack 打包后还有实时绑定吗?

有。Webpack 会把 ESM 的实时绑定转换成 getter 函数来模拟。比如 import { count } 在打包后变成通过一个 getter 去读源模块变量的当前值,效果和原生 ESM 的实时绑定一致。

CJS 里有没有办法模拟实时绑定?

可以用 getter 实现。不导出值本身,而是导出一个有 getter 的对象,比如 Object.defineProperty(module.exports, 'count', { get() { return count } })。这样导入方每次读 count 都会调用 getter 拿到最新值。不过这种写法很少见,如果需要实时绑定,更好的做法是迁移到 ESM。

从一道题,走向一组知识

把知识连起来

模块与语言机制

用 const 和 let 导出的变量,在实时绑定上有什么区别?

ESM 里用 const 导出的变量不能被源模块重新赋值,用 let 可以,这直接影响实时绑定的实际效果。

模块与语言机制

import * as ns 和具名导入在 tree-shaking 上有什么区别?

import * as ns 拿到的命名空间对象也是实时绑定的视图,ns.count 会跟着源模块的值变化。

放回知识体系中复习JavaScript 进阶知识

参考资料

  • JavaScript modules

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

本题目录
  1. 先记住这个答案
  2. 用代码看清两者的区别
  3. 为什么两套系统的行为不一样
  4. CJS 导出对象时的特殊情况
  5. 容易答错的地方
  6. 面试官还会怎么问
  7. 把知识连起来
读懂,再试着讲出来

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

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