先记住这个答案
直接赋值会报 TypeError。在 ESM 里,import { count } from './counter' 建立的是一个指向源模块 count 变量的只读引用。导入方试图写 count = 10 时,引擎会拒绝这个操作,抛出 TypeError: Assignment to constant variable 或类似的错误。这个限制和源模块用 const 还是 let 无关——即使源模块用 let 声明了 count,导入方依然不能赋值。
- 给导入绑定赋值报
TypeError,不管源端是const还是let - 导入绑定是对源模块变量的只读引用
- 修改导入对象的属性不会报错,限制的是绑定不是值
- 和
const的行为类似,但机制不同
看一个最简单的例子
源模块导出一个用 let 声明的变量,导入方尝试直接赋值。
// counter.mjs
export let count = 0
export function increment() {
count++ // 源模块自己可以修改
}
// main.mjs
// import { count, increment } from './counter.mjs'
// console.log(count) // 0
// count = 10 // TypeError: Assignment to constant variable
// increment() // 正常,源模块自己改
// console.log(count) // 1 —— 导入方能读到源模块的修改导入方能读到源模块的变化,但自己不能写入。这是 ESM 的设计约束——数据流是单向的,从导出方流向导入方。
为什么导入绑定必须是只读的
如果导入方也能修改,多个模块同时导入同一个变量时,任何一方都能改它的值,代码的可预测性会非常差。你不知道 count 什么时候被谁改了,调试起来是噩梦。
ESM 的设计选择是:谁声明谁负责修改。源模块声明了 count,只有源模块有权修改。其他模块想改的话,只能通过源模块暴露的函数(比如上面的 increment())间接操作。这和面向对象里的封装思想是一样的——把可变状态和修改它的方法放在一起,外部只能通过接口来操作。
这个限制在很多工具链里是编译期就能发现的。TypeScript 和大多数打包工具在静态分析阶段就会标红 import 绑定的赋值操作,不用等到运行时才报错。
修改导入对象的属性为什么不报错
只读限制的是绑定本身,不是绑定指向的值。
// config.mjs
export const options = { debug: false }
// main.mjs
// import { options } from './config.mjs'
// options = {} // TypeError —— 不能重新赋值
// options.debug = true // 正常 —— 修改的是对象属性,不是绑定这和 const obj = {}; obj.x = 1 的道理一模一样。const 阻止你把 obj 指向别的东西,但不阻止你修改 obj 内部的内容。导入绑定的只读约束也是同理。
虽然技术上可以这样做,但在实际项目里直接修改导入对象的属性不是好实践。其他导入同一模块的文件也会受到影响,而且这种修改很难追踪。如果确实需要修改,应该通过源模块暴露的函数来操作。
容易答错的地方
- 以为报的是
SyntaxError - 有人说"编译期就报错所以是
SyntaxError"。实际上规范定义的是运行时错误TypeError。打包工具和 TypeScript 可能在编译期就拦截了,但那是工具层面的行为,不是语言规范的要求。如果在原生 ESM 环境里运行,报的是TypeError。 - 以为和
const的限制来自同一个机制 const的不可赋值是变量声明层面的约束,import绑定的只读是模块系统层面的约束。两者效果类似,但来源不同。即使源模块用let声明(本身是可赋值的),导入绑定也是只读的——这不是const在起作用,而是 ESM 的模块绑定规则。
面试官还会怎么问?
import * as ns 的命名空间对象可以修改属性吗?
不可以。命名空间对象是不可扩展的(non-extensible),所有属性都是不可配置、不可写的。ns.count = 10 会被静默忽略(非严格模式)或报 TypeError(严格模式,ESM 默认就是严格模式)。
解构导入后赋值会报错吗?
import { count } 的 count 本身就是导入绑定,赋值会报错。但如果你先用局部变量接住它——比如 let local = count——那 local 是普通变量,可以随便赋值。不过这样就断开了和源模块的实时绑定,local 不会跟着源模块变化了。
CommonJS 的 require 导入也是只读的吗?
不是。const { count } = require('./counter') 里的 count 是用 const 声明的局部变量,它是只读的是因为 const,不是因为模块系统。如果换成 let { count } = require('./counter'),count 就可以被重新赋值了——不过改了也只是改了自己的局部变量,和源模块没有关系。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。