1.4 模块系统:import 与 export
本章解决什么问题:前几章的程序都只有一个文件,而 Pi 有九百多个
.ts文件。这些文件怎么拆、彼此怎么连接?本章讲清 JavaScript / TypeScript 的模块系统——import与export——读完你就能看懂 Pi 任何一个源码文件顶部那一排 import 语句。前置知识:1.2 运行第一个 TypeScript 程序(会用
tsx运行.ts文件)、1.3 npm、package.json 与项目结构(知道package.json和node_modules是什么)。学习目标:读完本章后你能
- 说出模块解决的三个问题:命名隔离、复用、按需加载;
- 分辨并写出四种导入:命名导入、默认导入、整体导入、类型导入;
- 解释相对路径导入与包名导入分别去哪里找文件;
- 用一句话说清 ESM 和 CommonJS 的关系,并知道 Pi 全用 ESM;
- 看懂 Pi 源码里
import ... from "./agent-loop.ts"这种带.ts后缀的写法。
建立直觉:为什么需要模块
它是什么:模块(Module)就是一个文件,外加两条规则——文件里声明的变量、函数默认只有本文件可见(这个「可见范围」术语上叫作用域,Scope);想给别的文件用,必须用 export 明确导出,用的一方再用 import 明确导入。一出一进,全部写在明面上。
为什么需要它:把大程序拆成小文件后,必须回答三个问题,模块系统正好逐一解决:
- 命名隔离:两个文件都想叫自己的辅助函数
format,可以吗?可以——各自的format互不相扰,只有导出的名字才对外可见; - 复用:写好的日志函数想在十个文件里用?导出一次,到处导入,不必复制粘贴;
- 按需加载:运行入口文件时,只有被 import 链条牵到的文件才会被加载执行,没被引用的代码根本不会进入内存。
没有它会怎样:早年浏览器就是反面教材——多个 <script> 文件共享同一个全局作用域,任何文件都能悄悄覆盖别人的变量:A 文件定义了 format,B 文件也定义 format,后加载的一声不响地赢了,程序在毫不相干的地方坏掉。项目一大,「谁动了我的变量」就成了日常灾难。模块系统把这种隐式共享换成了显式的进出口清单。
最小示例:一个四文件小项目
下面的代码就是本章实验 labs/typescript-basics/03-modules 的完整内容:一个迷你待办清单,拆成 4 个文件。先看依赖关系图,再逐个认识每种导入导出写法。
图 1.4-1 03-modules 实验的模块依赖图
阅读顺序:从上往下,箭头表示「谁导入谁」。实线是运行时真实存在的依赖;两条虚线是类型导入——编译成 JavaScript 后这两条线会彻底消失,types.ts 在运行时根本不会被加载。
这张图值得记住的一点是:import 语句让文件之间的依赖变成了一张可以画出来的图。读任何多文件项目(包括 Pi),第一步都是在脑子里恢复这张图。
命名导出与命名导入
tasks.ts 用的是命名导出(Named Export)——在声明前面直接加 export,一个模块可以导出任意多个名字:
// src/tasks.ts(节选)
import type { Task } from "./types.ts";
export const MAX_TASKS = 3;
export function createTask(title: string): Task {
return { title, done: false };
}
// 这个函数没有 export:它是模块的「私有」实现,别的文件导入不到它
function neverExported(): string {
return "外面看不到我";
}导入方用花括号,名字必须与导出名完全一致:
import { MAX_TASKS, createTask, finishTask, formatTask } from "./tasks.ts";如果试图导入没有导出的 neverExported,TypeScript 编译器会在编译期报 error TS2459: Module '"./tasks.ts"' declares 'neverExported' locally, but it is not exported.;就算跳过类型检查直接运行,Node.js 加载模块时也会当场拒绝。私有就是私有,这是模块系统的硬规则,不是君子协定。
默认导出与默认导入
logger.ts 用的是默认导出(Default Export)——一个模块最多只能有一个:
// src/logger.ts
export default function log(line: string): void {
console.log(`[清单] ${line}`);
}导入方不写花括号,且名字随便起:import log from "./logger.ts" 和 import print from "./logger.ts" 都合法,拿到的是同一个函数。对比记忆:命名导入认名字,默认导入认文件。默认导出适合「这个模块就是这一件东西」的场景。Pi 的核心库源码几乎只用命名导出——名字统一,全局可搜;默认导出集中出现在一类文件里:每个扩展(Extension,第七部分详讲)文件把自己的入口函数 export default 出去,packages/coding-agent/examples/extensions/ 目录下 85 个 .ts 文件里有 78 个是这种写法(其余是配套的辅助模块和测试)——「一个文件就是一件东西」的典型。
整体导入
有时想把一个模块的全部命名导出打包成一个对象使用,这叫整体导入(Namespace Import,也译命名空间导入):
import * as path from "node:path";
path.join("labs", "typescript-basics", "03-modules");path 现在是一个对象,模块的每个命名导出都是它的属性。当导出的名字很多、或想让来源一目了然时(path.join 比光秃秃的 join 信息量大),整体导入很好用。
类型导入
import type 是 TypeScript 专属的类型导入(Type-only Import):
import type { Task } from "./types.ts";它声明「这一行只为类型而来」。回忆 1.1 的结论——类型在编译时被抹掉——所以这一行编译后整行消失,types.ts 在运行时不会被加载。普通 import { Task } 通常也能用,但工具就得自己猜这个导入运行时要不要保留,在逐文件编译的场景下猜错会引发运行时错误。习惯很简单:只当类型用的导入,一律写 import type。Pi 源码严格遵守这个习惯,下文马上能看到。
相对路径导入与包名导入
from 后面的字符串叫模块说明符(Module Specifier),它的开头决定了去哪里找模块:
图 1.4-2 模块说明符的两类去向
阅读顺序:从上往下看两次分叉。左边是「自己项目里的文件」,右边是「外部世界」:要么是 Node.js 自带的内置模块,要么是 1.3 章装进 node_modules 的包。Pi 源码里三种形态都大量出现,下一节的真实代码会同时展示。
相对路径导入(./ 是「当前目录」,../ 是「上一层目录」)指向自己项目里的文件;包名导入(术语叫裸说明符,Bare Specifier)指向外部依赖。看到 import 先看开头有没有 ./,就能立刻分清「项目内」还是「项目外」——这个习惯读 Pi 源码时极其有用。
把四种导入拼在一起,就是实验入口 main.ts 的顶部;运行 npm start(等价于 tsx src/main.ts)的真实输出是:
[清单] [ ] 读完 1.4 模块系统
[清单] [ ] 跑通 03-modules 实验
[清单] [x] 安装 Node.js
[清单] 实验目录:labs/typescript-basics/03-modulesESM 与 CommonJS:一句话历史
上面这套 import / export 语法的正式名字是 ESM(ECMAScript Modules,ECMAScript 模块),2015 年才进入语言标准。而 Node.js 2009 年诞生时语言还没有模块标准,只好自创了一套 CommonJS:用 require("...") 导入、module.exports = ... 导出。此后十年两套系统长期并存,你在旧教程和旧 npm 包里看到 require,那就是 CommonJS。
Node.js 用 package.json 里的 "type": "module" 字段作为开关:写了它,这个 package 里的 .js / .ts 文件就按 ESM 解析。Pi 的仓库根和所有 package 的 package.json 全部写着 "type": "module"——Pi 是纯 ESM 项目,本书也只教 ESM。
Pi 中哪里用到了它
打开 Pi 的核心文件 packages/agent/src/agent.ts,顶部整块都是本章内容(节选,完整块是第 1–30 行):
import type {
ImageContent,
Message,
Model,
// …(省略:另外 3 个类型名)
Transport,
} from "@earendil-works/pi-ai";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type {
AfterToolCallContext,
AfterToolCallResult,
AgentContext,
// …(省略:另外 12 个类型名)
} from "./types.ts";
export type { QueueMode } from "./types.ts";runAgentLoop逐段对照本章概念(这些名字的含义要到第三部分和 6.3 pi-agent-core 才展开,此刻只看导入形态):
- 第 1–9 行:
import type { ... } from "@earendil-works/pi-ai"——包名导入 + 类型导入。@earendil-works/pi-ai不是从 npm 网上装的外部包,而是同一个 monorepo 里的兄弟 package(packages/ai,见 4.3 package 地图);但从本文件的视角看,它就是一个普通包名。只导入类型,所以运行时这行不存在。 - 第 10–11 行:
from "./agent-loop.ts"、from "./stream-fn.ts"——相对路径导入 + 命名导入,导入的是同目录两个文件里真实的函数,运行时要用。 - 第 12–28 行:又一个
import type,从同目录types.ts导入 15 个类型名。Pi 把「值的导入」和「类型的导入」分得清清楚楚,一眼就能看出哪些依赖会活到运行时。 - 第 30 行:
export type { QueueMode } from "./types.ts"——再导出(Re-export):从别的模块导入并立刻导出,相当于替types.ts转发这个类型。
细心的读者会发现相对导入写了 .ts 后缀:"./agent-loop.ts" 而不是 "./agent-loop"。这是 TypeScript 5.0 之后 allowImportingTsExtensions 选项开启时的风格——「磁盘上文件叫什么,import 就写什么」。Pi 在全仓库统一的编译配置里打开了它(配套的 rewriteRelativeImportExtensions 负责在编译产物里把 .ts 改写成 .js):
allowImportingTsExtensions再看一个把「再导出」用到极致的文件——packages/agent/src/index.ts,全文 50 行没有一行逻辑代码,全部是导出:
// Core Agent
export { uuidv7 } from "@earendil-works/pi-ai";
export * from "./agent.ts";
// Loop functions
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
// …(省略:另外 44 行——更多 export 与 export * 语句,间或有分组注释)export * from "./agent.ts" 的意思是「把 agent.ts 的全部命名导出原样再导出」。这种只做汇总的文件俗称 barrel(桶)文件,它是整个 package 的门面:外部使用者只需要 import { ... } from "@earendil-works/pi-agent-core" 这一个包名,不必关心实现文件藏在哪个子目录。前面 agent.ts 里那句 @earendil-works/pi-ai 的包名导入,进的正是 packages/ai 自己的这类入口。模块系统由此形成两层:文件之间用相对路径导入,package 之间用包名导入,而入口文件是两层的接缝。
实践任务
labs/typescript-basics/03-modules目标:亲手运行本章的四文件项目,并通过三个小改动验证模块系统的规则。
步骤:
- 进入实验目录
labs/typescript-basics/03-modules,运行npm install,再运行npm start; - 对照上文的依赖图,把 4 个源文件各自「导出什么、导入什么」读一遍;
- 改动一:在
main.ts的list数组里加第 4 条任务,重新npm start,观察多出的警告行; - 改动二:把
main.ts里的默认导入log连同调用处全部改名为print,确认程序照常运行; - 改动三:把
tasks.ts里没有导出的neverExported加进main.ts的命名导入并调用它,观察报错。
预期现象:第 1 步输出 4 行,与实验目录的 expected-output.txt 一致(也就是上文「最小示例」末尾展示的输出)。第 3 步输出的第一行会多出 [清单] 清单太长,先专注最重要的几件事。第 5 步运行时报 SyntaxError: The requested module './tasks.ts' does not provide an export named 'neverExported'。
如何判断成功:三个改动的现象都与预期一致,且你能解释:为什么默认导入可以随意改名,而命名导入不行。
常见错误:
Cannot find module './tasks':相对导入漏写了.ts后缀——本实验和 Pi 一样使用带后缀的风格;- 改完
log却报log is not defined:导入行改了名,调用处没改全; - 在浏览器教程里见过
require(...)于是照写:那是 CommonJS,本项目是"type": "module"的 ESM 项目,只认import。
对应源码位置:packages/agent/src/agent.ts 第 1–30 行(导入块)、packages/agent/src/index.ts(barrel 门面)。
本章小结
- 模块 = 文件 + 私有作用域 + 显式进出口;它同时解决命名隔离、复用、按需加载三个问题。
- 四种导入:命名导入认名字、默认导入认文件、整体导入打包成对象、类型导入编译后整行消失。
- 模块说明符开头决定去向:
./与../是项目内文件;其余是包名——node:前缀是 Node.js 内置模块,否则去node_modules找。 - ESM(
import/export)是 2015 年起的语言标准;CommonJS(require)是 Node.js 早年的自创方案。Pi 所有 package 都声明"type": "module",是纯 ESM 项目。 - Pi 的两层模块结构:文件间用带
.ts后缀的相对导入(allowImportingTsExtensions风格),package 间用包名导入,每个 package 用 barrel 式入口文件汇总门面。
关键术语:模块(Module)、作用域(Scope)、命名导出/导入(Named Export / Import)、默认导出(Default Export)、整体导入(Namespace Import)、类型导入(Type-only Import)、模块说明符(Module Specifier)、包名导入(Bare Specifier)、再导出(Re-export)、ESM(ECMAScript Modules)、CommonJS、循环依赖(Circular Dependency)
关键源码索引:packages/agent/src/agent.ts 第 1–30 行(四种导入同框);packages/agent/src/index.ts(barrel 入口);packages/agent/package.json 的 "type": "module";tsconfig.base.json 的 allowImportingTsExtensions
自测问题:
import { format } from "./a.ts"和import format from "./a.ts"有什么本质区别?哪个可以随意改名?import type导入的东西在运行时去了哪里?为什么建议「只当类型用就写 import type」?- 看到
from "@earendil-works/pi-ai"和from "./types.ts",你分别去哪里找源文件? - Pi 源码的相对导入为什么带
.ts后缀?这依赖哪个编译选项?
下一章预告:2.1 类型入门:基本类型与对象——运行环境和项目骨架都齐了,从下一章起正式进入 TypeScript 的类型世界:string、number、对象类型,以及类型标注到底在标注什么。