先记住这个答案
import * as ns from "./mod.js" 拿到的叫模块命名空间对象。它的属性不能赋值、不能新增、不能删除,这些操作在模块里(严格模式)都会抛 TypeError。但严格来说它不是冻结对象:Object.isSealed(ns) 是 true,Object.isFrozen(ns) 却是 false。原因是它的属性值必须跟着导出模块实时变化,描述符上只能标成可写。另外它只管住命名空间这一层,导出的对象内部仍然可以修改。
- 赋值、增加、删除属性都抛
TypeError - 模块命名空间已密封但未冻结
- 属性值会随导出方实时变化,所以不能是真正冻结
- 导出的对象内部照样能改,限制的只是命名空间这一层
逐个试一下能不能改
在 Node.js 里导入一个模块,它导出了 let count = 0、一个对象 obj 和一个函数 inc。
import * as ns from './counter.mjs'
ns.count = 1 // TypeError: Cannot assign to read only property 'count'
ns.newProp = 1 // TypeError: Cannot add property newProp, object is not extensible
delete ns.count // TypeError: Cannot delete property 'count'
Object.freeze(ns) // TypeError: Cannot redefine property: count
console.log(Object.isSealed(ns), Object.isFrozen(ns)) // true false
console.log(Object.getPrototypeOf(ns)) // null
console.log(Object.getOwnPropertyDescriptor(ns, 'count'))
// { value: 0, writable: true, enumerable: true, configurable: false }
ns.obj.n = 100 // 可以,改的是导出对象的内部前三行说明这一层完全改不了。第四行更有意思,连 Object.freeze 都会失败,因为冻结需要把属性改成不可写,而命名空间对象拒绝任何属性重定义。
最后一行能成功。ns.obj 读出来是一个普通对象,改它的属性和命名空间对象无关,所有导入这个模块的地方都会看到 n 变成了 100。
为什么要设计成这样
命名空间对象不是一个存值的普通对象,更像一扇窗户,每次读属性都透过它去看导出模块里那个变量现在的值。导出方执行 count++,下一次读 ns.count 就是新值。
- 为什么不直接冻结
- 如果真的冻结,属性描述符会标成不可写,这等于对外宣称「这个值永远不变」,但导出方明明会改它。所以规范让描述符显示
writable: true,同时让赋值操作失败。这是一个行为上只读、描述上可写的特殊对象,规范里叫 exotic object。 不允许导入方修改,则是为了保证一个模块的状态只能由它自己改。否则任何导入者都能替换别人的导出,模块之间的边界就没有意义了。
容易答错的地方
- 说「命名空间对象是冻结的」
- 面试里直接这么答不够准确。更好的说法是:它不可扩展、属性不可配置、赋值会失败,效果上像冻结,但
Object.isFrozen返回false,因为属性值是实时绑定的。 - 以为导出的对象也不能改
- 限制只在命名空间这一层。
ns.config.debug = true完全合法,而且会影响所有导入方。想让导出对象不能被改,要在导出模块里自己Object.freeze。 - 以为在非严格模式下赋值只是静默失败
- ES 模块代码永远是严格模式,所以在模块里给命名空间属性赋值一定会抛错。只有在普通脚本里通过动态
import()拿到命名空间,并且在非严格代码里赋值时,才会静默失败。
面试官还会怎么问?
能给命名空间对象的属性打补丁做 mock 吗?
不能直接改。测试里想替换某个导出,要用测试框架提供的模块 mock 能力,比如 Jest 的 jest.mock 或 Vitest 的 vi.mock,它们在模块加载阶段就替换掉整个模块。直接赋值 ns.fn = mockFn 会抛 TypeError。
命名空间对象里有 default 吗?
如果模块有默认导出,命名空间对象上就有一个名为 default 的属性,值就是默认导出。它还有一个 Symbol.toStringTag 属性,值是 "Module",所以在控制台里会显示成 [Module: null prototype]。
用 import * as 会不会影响 tree shaking?
只要是 ns.foo 这种静态属性访问,打包器通常能分析出只用了哪些导出。如果把 ns 整个传给别的函数、或者用 ns[key] 动态访问,打包器就只能保留全部导出。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。