先记住这个答案
具名导入 import { add, multiply } from './math' 明确告诉打包工具"我只用了这两个",没提到的导出可以被安全删掉。命名空间导入 import * as math from './math' 把所有导出收进一个对象,看起来全引入了。但现代打包工具(Webpack 5、Rollup、esbuild)能分析代码里 math.add 这样的属性访问,找出哪些属性实际被用到了,对没用到的照样做 tree-shaking。
- 具名导入对 tree-shaking 最友好,意图最明确
- 现代打包工具也能分析
ns.x的用法做 tree-shaking - 命名空间对象是冻结的,不能添加或修改属性
- 两种导入方式拿到的都是实时绑定
具名导入为什么 tree-shaking 效果最好
具名导入的意图是最显式的。你写了 import { add } 就是只用 add,打包工具不需要做任何推断,直接把模块里除了 add 以外的导出标记为"未使用"。
// math.js 导出了 add、multiply、divide 三个函数
// 具名导入只拉 add
import { add } from './math'
console.log(add(1, 2))
// 打包工具确定 multiply 和 divide 没被用到
// 最终产物里不会包含它们的代码这种写法不依赖打包工具的分析能力,任何支持 tree-shaking 的工具都能处理好。
命名空间导入的 tree-shaking 现状
早期的打包工具看到 import * as math 会认为"整个模块都被用了",不敢删任何导出。但现在的主流工具已经能做得更精细了。
import * as math from './math'
// 打包工具分析出只用了 math.add
console.log(math.add(1, 2))
// Webpack 5 / Rollup / esbuild 都能把 multiply 和 divide 删掉打包工具会在整个模块范围内追踪 math 对象上哪些属性被访问过。只要你的用法是静态可分析的(直接 math.add),tree-shaking 就能正常工作。
但如果 math 对象被整体传走了,比如 doSomething(math) 或 const m = math,打包工具没办法确定 doSomething 内部会访问哪些属性,只能保守地保留所有导出。具名导入不存在这个问题——add 是一个独立的绑定,不会因为被传进函数就让其他导出被保留。
命名空间对象的特殊性质
import * as ns 拿到的命名空间对象不是普通对象。规范要求它是冻结的——不可扩展、所有属性不可配置也不可写。在严格模式下(ESM 默认就是严格模式),给它添加属性或修改属性值都会报 TypeError。
命名空间对象上的属性也是实时绑定的。如果源模块用 let 声明了一个导出变量并在后来修改了它的值,通过 ns.count 读到的就是新值。命名空间对象的属性本质上就是对每个具名导出绑定的引用。
容易答错的地方
- 以为命名空间导入完全无法 tree-shaking
- 这是几年前的情况了。现在主流的打包工具都支持对命名空间导入做 tree-shaking,只要用法是静态可分析的。不过具名导入仍然是更好的选择,因为它不依赖打包工具的分析能力,意图更明确。
- 以为两种导入拿到的绑定行为不同
- 不管是
import { count }还是import * as ns然后读ns.count,拿到的都是同一个实时绑定。源模块修改count后,两种方式读到的值都会变。
面试官还会怎么问?
什么时候适合用命名空间导入?
当你需要用到模块的大部分导出,或者导出名和当前模块的变量冲突时,命名空间导入比挨个写具名导入更简洁。比如 import * as path from 'node:path' 然后用 path.join()、path.resolve(),比一个一个列出来更清楚也更方便。另外在写测试时,import * as mod 可以方便地用 mock 工具替换模块的某个导出。
命名空间导入能拿到默认导出吗?
能。默认导出在命名空间对象上的属性名是 default。import * as ns from './a' 之后,ns.default 就是那个默认导出的值。
用解构写法 const { add } = await import('./math') 还有 tree-shaking 效果吗?
动态 import() 返回的命名空间对象本身不受 tree-shaking 影响,因为它是运行时加载的。打包工具会把整个目标模块的代码都打进 chunk。tree-shaking 主要发生在模块内部——目标模块自己依赖的其他模块里没用到的导出可以被删掉,但目标模块的所有导出都会保留。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。