本文目录
图书馆按目录借书:你要《地图学导论》,管理员先确认这本书有没有登记、上一本是否还在馆里;书到手后,你在自己的借阅卡里做笔记,不会直接改馆藏目录本身。
import 看上去只是多写一行路径,实际却触发模块图的解析、链接与求值。每个模块文件有自己的顶层作用域,导出的是绑定,而不是把变量全局共享。
Worker 脚本能 import,普通脚本却不能随便 await
上一章里,模块 Worker 可以这样写:
import { hashText } from './hash.js'
self.onmessage = (event) => {
self.postMessage(hashText(event.data))
}同一个项目里,若 HTML 用经典脚本引入:
<script src="/app.js"></script>app.js 顶层不能写 import,也不能写 top-level await。要让 import 生效,脚本必须以模块方式加载:
<script type="module" src="/app.js"></script>模块脚本默认 defer,会按依赖图执行,并启用严格模式。Node 里若 package.json 写了 "type": "module",.js 文件也按 ES 模块处理;否则 CommonJS 与 ESM 的互操作规则又不同。理解“文件”和“模块实例”的差别,是读懂 VitePress、Vite 和现代前端工程的前提。
先猜输出:导入的是绑定还是值的快照
counter.js:
export let count = 0
export function increment() {
count += 1
}main.js:
import { count, increment } from './counter.js'
console.log('导入后', count)
increment()
console.log('调用后', count)输出:
导入后 0
调用后 1import { count } 建立的是到导出绑定的只读视图,不是把 0 复印一份。导入方不能写 count = 5 去改导出模块里的绑定,但可以通过 increment() 间接改变它。counter.js 里修改 count,只要绑定仍连着,导入方就能看到新值。这与 CommonJS 里常见的“导出对象属性快照”直觉不同,也是迁移代码时容易踩坑的地方。
可以把它记成:导入的是“插座”,不是“插座当前亮着的灯泡的复印件”。灯泡亮度变了,插座读到的仍是同一个电路状态;你不能直接把插座拔下来换另一个灯泡,却可以通过模块导出的函数去操作电路。
默认导出同样对应一个绑定:
export default function createStore() {
return { value: 0 }
}import createStore from './store.js' 拿到的是默认导出绑定的当前值。命名导出与默认导出可以同时存在;导入时名字可以不同,但默认导出在一个模块里只能有一个。
静态 import 在什么时候发生
静态 import 写在顶层,解析阶段就要建立依赖关系:
import { render } from './view.js'
import { loadData } from './data.js'引擎(在浏览器宿主配合下)会:
- 解析模块,收集
import目标。 - 递归获取依赖,构造模块图。
- 实例化:为导入导出分配内存槽位,连接绑定,此时还不运行模块体内的赋值。
- 求值:按依赖顺序执行各模块顶层代码。
因此,顶层 console.log 的顺序遵循依赖图,而不是文件在文件夹里谁先谁后。若 main.js 依赖 a.js 和 b.js,而 b.js 又依赖 a.js,a.js 的顶层日志会先于 b.js,再先于 main.js。
循环依赖时,可能读到尚未求值的导出,出现暂时性的 undefined 或暂时性死区错误——这不是“import 坏了”,而是初始化顺序问题:
// a.js
import { bReady } from './b.js'
export const aReady = 'a'
console.log('a 看到 bReady =', bReady)
// b.js
import { aReady } from './a.js'
export const bReady = 'b'
console.log('b 看到 aReady =', aReady)常见结果是 b 先求值一部分,读到尚未完成的 aReady 为 undefined;随后 a 继续,最终两边绑定都会完成。修复方式通常是抽出第三方依赖、把副作用延后,或改为动态导入打破环。
动态 import 返回 Promise
button.addEventListener('click', async () => {
const { openPanel } = await import('./panel.js')
openPanel()
})动态 import() 在运行到这一行时才去获取模块,返回 Promise。适合按路由、按交互懒加载。第十章里说过,await 暂停的是当前 async 函数,不会冻结整个页面;动态导入只是把“加载哪个文件”推迟到运行时决定。
构建工具会为动态导入生成分包(chunk)。Network 面板里,只有执行到 import() 时才会请求对应文件,这也是现代站点首屏优化的常见手段。
静态导入必须在顶层,且会影响 tree-shaking:构建器能分析出哪些导出从未使用,从而在产物里删除死代码。动态导入的目标通常整包保留,因为运行时才决定是否加载。
模块作用域不是 window 上的变量
// utils.js
const secret = 42
export function getSecret() {
return secret
}secret 不会变成 window.secret。每个模块顶层形成自己的词法环境,外部只能通过显式 export 访问允许暴露的绑定。
模块还可以有副作用:只执行顶层代码、不导出任何东西:
// telemetry.js
console.log('telemetry 初始化')
window.__APP_BOOT__ = performance.now()被静态 import './telemetry.js' 后,这段顶层代码会随模块求值执行一次。副作用模块要谨慎放在依赖图根部,否则可能拖慢或污染全局。
import.meta 提供当前模块的元信息,例如 import.meta.url 在 Worker 和打包场景里常用来解析相对路径:
const worker = new Worker(new URL('./worker.js', import.meta.url))重新导出与聚合入口
export { formatDate } from './date.js'
export * from './math.js'重新导出把别的模块的绑定再暴露出去,不会把函数体复制一份。若多个路径重新导出同一绑定,它们仍指向同一模块实例里的同一槽位。
库作者常用 index.js 聚合导出,让使用者 import { a, b } from 'pkg'。使用者看到的是稳定公共 API,内部文件怎么拆分可以随后演进。
import() 动态加载的模块,在同一页面内通常也只会求值一次;再次 import() 同一 URL,往往复用已完成的模块记录。具体缓存键与打包产物名有关,开发时应以“同一模块实例”来推理状态,而不是假设每次点击都重新执行全部顶层副作用。
顶层 await 会把等待传播给依赖者
// config.js
const response = await fetch('/config.json')
export const config = await response.json()任何静态 import { config } from './config.js' 的模块,都要等 config.js 异步求值完成后才能继续自己的求值。它不是把整个浏览器冻住,却会推迟依赖图中下游模块的启动。
这对读取远程配置很直观;若放在被广泛依赖的基础模块里,可能拖慢整页脚本的可用时间。是否使用,要看初始化关系,而不是因为语法省事。
导入路径不只是字符串拼接
浏览器原生 ESM 要求导入说明符在多数情况下是完整 URL 或带扩展名的相对路径:
import { helper } from './utils.js'开发时我们写 ./utils,打包器会补扩展名、解析别名;但语义上仍是“模块说明符 → 模块记录”。Node 的 package.json 里 exports 字段会进一步限制哪些路径对外可见,防止深层文件被意外引用。
{
"exports": {
".": "./dist/index.js",
"./feature": "./dist/feature.js"
}
}使用者只能 import 'pkg/feature',不能随便伸进包的内部目录。这和闭包里的“私有绑定”在工程层面对应:公共 API 显式导出,内部实现文件不直接暴露。
Vite 的 resolve.alias、TypeScript 的 paths 都在构建或类型检查阶段改写说明符,不改变运行时“一个模块实例一份顶层作用域”的规则。读报错时,要分清是“路径找不到文件”,还是“文件找到了但导出里没有这个名字”。
副作用与纯模块:谁应该在顶层执行
理想情况下,模块顶层只做绑定声明和轻量初始化。把“一加载就请求接口”“一加载就写 localStorage”放在被广泛 import 的模块里,会让整个依赖图在启动时承担这些成本。
// bad-side-effect.js
const theme = localStorage.getItem('theme') ?? 'light'
export const initialTheme = theme若十个模块都静态依赖它,副作用仍只执行一次,但执行时机被提前到任何依赖者求值之前。把读取推迟到函数里:
export function readTheme() {
return localStorage.getItem('theme') ?? 'light'
}调用方在真正需要主题时再读,初始化图更可控。动态 import() 也常用于把“只有点开设置页才需要”的代码整块延后,这与第十章“不要无 await 地启动一切”是同一思路。
读 VitePress 站点时,主题入口、文章数据、客户端增强往往分布在多个模块里,但浏览器最终加载的是构建后的 chunk。开发者的 import 关系决定“谁先初始化”;构建器决定“哪些合并、哪些拆分”。排查白屏或初始化顺序问题时,既要画模块依赖,也要看 Network 里实际下载了哪些文件。
export 也可以出现在表达式中间,但那样不利于静态分析,tree-shaking 也更难做。团队规范里通常要求顶层 export 声明,把公共 API 一眼看清,把实现细节留在未导出的绑定里——这又回到了第二章说的模块级闭包。写库时尤其要避免在顶层导出可变单例,除非文档明确说明其生命周期与副作用。消费方若只需要类型,优先 import type,避免为了几个接口把整个实现模块拉进依赖图。这样构建产物更小,初始化也更可预测。
容易踩的坑:import 不是运行时函数调用
第一种误解是把 import { fn } from './a.js' 当成“运行时再去找函数”。静态导入在模块求值前就要完成链接;函数体当然仍在调用时执行,但依赖关系不是运行到那一行才临时解析。
第二种是“循环 import 一定报错”。常常能加载,却在某个导出尚未初始化时被读取。要在设计阶段避免环,或把共享逻辑抽到第三个模块。
第三种是“动态 import 和静态 import 完全等价,只是写法不同”。动态导入返回 Promise,可放在条件分支;静态导入必须顶层,且影响构建期分析和初始化顺序。
第四种是在非模块脚本里混用 require 思维,假设导出是普通对象属性拷贝。ES 模块导出的是 live binding;测试时要关注绑定是否仍连着,而不是只比对第一次导入瞬间的值。
在 VitePress 或 Vite 项目里,import.meta.glob 可以批量收集模块,本质仍是静态分析加运行时分包。读这类魔法时,回到本章四步:谁依赖谁、何时实例化、何时求值、副作用执行几次。工具链再复杂,语义仍落在这套模型上。
TypeScript 的 import type 只在类型层存在,编译后会被擦除,不会产生运行时的模块依赖。若误把类型导入当成运行时代码,可能以为某个副作用模块已被加载,实际上并没有。区分 import type 与 import 也是模块系统的一部分。
下一步:模块加载完之后,对象何时能被回收
模块顶层作用域、函数词法环境、闭包,其实都在回答“名字在何处可见、何时仍可达”。第二章专门追问过:外层返回后,内层究竟握有什么。模块单例、监听器与缓存会让对象比预期活得更久——下一章从仓库盘点说起,拆开垃圾回收、可达性与弱引用。