先记住这个答案
ESM 中导出声明会为变量创建实时绑定,无论 const 或 let,导入方读取的都是绑定对应的当前值。const 仅禁止导出模块内重新赋值,let 允许,但两者在导入端均为只读,无法对导入变量赋值。若导出的是对象,const 导出仍可通过修改对象属性让导入端看到变化。因此选择 let 还是 const 主要取决于导出模块是否需要重新绑定变量,实时绑定本身不受影响。
- 实时绑定对 const 与 let 均成立,无差异
- 差异仅在导出模块内能否重新绑定
- 导入端始终只读,不可重新赋值
同一绑定的两种声明
ESM 的导出并非拷贝值,而是注册一个绑定。当模块顶层执行 let count = 0 后 export { count },导入方读取 count 时访问的是该模块作用域内的名字。若后续模块内执行 count = 1,导入方再次读取会得到 1。const 声明的绑定同样被实时跟踪,只是 count 被禁止重新赋值。所以实时绑定与关键字无关,它是模块解析器为每个导出名称建立的一个活引用。
导入方得到的标识符在词法上等价于一个 const,无论原导出是 const 还是 let。因此尝试对导入变量赋值会触发 TypeError,因为模块命名空间对象的属性不可写。但若导出的是对象,导入方可修改其属性,因为那改变的是对象内容而非绑定。设计上,绑定所有权唯一归属导出模块,外部只能观察或间接操作。
计数器模块与配置常量
假设一个在线状态模块需要将当前连接数暴露给多个组件,连接数在建立/断开时变化。用 let 导出该计数,并在内部通过函数更新。每次调用更新后,导入方立即看到新值,因为实时绑定指向同一个 count。若改为 const 导出,模块内部无法重新赋值,只能改用对象 { value: 0 } 并修改属性,这会让调用方必须访问 state.value,增加额外层级且可能无意中改写整个对象。
选择 let 导出原始数字可避免包裹对象,且模块内所有赋值操作都经过受控函数,外部无法直接改写。但需警惕 let 允许模块内任何位置重新绑定,多人协作时可能被误改。若业务要求数值本身不变,只是通过增减派生新值,则应该用 const 导出不可变数据(如状态快照),用新变量保存每次变化。具体应由数据是否需要原地更新来决定。
失效条件与转换代价
实时绑定在模块链接完成后即生效,但若构建工具(如 webpack)将模块打包成非原生 ESM 并启用 namespace 重导出,一些打包配置可能生成快照拷贝而非实时绑定,导致 let 更新不可见。另外,动态 import() 返回的命名空间对象并非冻结后属性值就不再变化——值仍实时更新。但若导入方缓存在函数外部读取的变量,则不会自动更新,需要每次通过命名空间读取。
另一个边界:如果导出的是 let 但模块在初始化后从不执行赋值语句,实时绑定效果与静态值无异;若涉及循环依赖,let 绑定在初始化阶段可能还未完成赋值,导入方在顶层读取可能拿到 undefined。本题不深入循环依赖,但选择 const 导出对象时也要防范循环引用中对象方法提前调用。处理此类情况需推迟读取时机(如放入函数体内),并确保导出前完成必要赋值,代价是增加代码顺序约束和测试复杂度。
容易答错的地方
- const 导出不是实时绑定
- 错误认为 const 导出时导入方看到初始化值后不再变化。事实上 const 只是禁止导出模块重新绑定,但若导出对象并修改其属性,导入方仍能看到新值,实时绑定机制本身与 const 无关。
- 导入 let 变量可以重新赋值
- 有人以为导入的 let 像普通变量那样可改,实际导入标识符是只读视图,无论原导出是 let 还是 const,赋值都会抛出 TypeError。需要修改只能修改对象属性或由导出模块提供 setter。
面试官还会怎么问?
导出 const 对象时修改属性,导入方是否实时看到?
会。实时绑定跟踪的是绑定而非值。对象属性修改发生在对象内部,不改变绑定本身,因此导入方通过同一引用能看到变化。这也适用于数组的 push 等方法。
如果要导出一个可变的数字,用 let 还是 const?
应该用 let,因为数字是原始值,修改绑定是唯一更新途径。若用 const 则无法重新赋值,只能换成对象包裹或改用其他设计。let 导出在实时绑定下表现正常。
为什么 import 的变量不能赋值,但导出的 let 可以?
模块设计将绑定所有权限定在导出模块内。导入端获得的是只读视图,防止外部模块直接变更他人状态,从而维护封装。若需变更,必须通过导出方提供的函数或对象方法间接操作。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。