Skip to content

1.4 模块系统:import 与 export

本章解决什么问题:前几章的程序都只有一个文件,而 Pi 有九百多个 .ts 文件。这些文件怎么拆、彼此怎么连接?本章讲清 JavaScript / TypeScript 的模块系统——importexport——读完你就能看懂 Pi 任何一个源码文件顶部那一排 import 语句。

前置知识1.2 运行第一个 TypeScript 程序(会用 tsx 运行 .ts 文件)、1.3 npm、package.json 与项目结构(知道 package.jsonnode_modules 是什么)。

学习目标:读完本章后你能

  • 说出模块解决的三个问题:命名隔离、复用、按需加载;
  • 分辨并写出四种导入:命名导入、默认导入、整体导入、类型导入;
  • 解释相对路径导入与包名导入分别去哪里找文件;
  • 用一句话说清 ESM 和 CommonJS 的关系,并知道 Pi 全用 ESM;
  • 看懂 Pi 源码里 import ... from "./agent-loop.ts" 这种带 .ts 后缀的写法。

建立直觉:为什么需要模块

它是什么:模块(Module)就是一个文件,外加两条规则——文件里声明的变量、函数默认只有本文件可见(这个「可见范围」术语上叫作用域,Scope);想给别的文件用,必须用 export 明确导出,用的一方再用 import 明确导入。一出一进,全部写在明面上。

为什么需要它:把大程序拆成小文件后,必须回答三个问题,模块系统正好逐一解决:

  • 命名隔离:两个文件都想叫自己的辅助函数 format,可以吗?可以——各自的 format 互不相扰,只有导出的名字才对外可见;
  • 复用:写好的日志函数想在十个文件里用?导出一次,到处导入,不必复制粘贴;
  • 按需加载:运行入口文件时,只有被 import 链条牵到的文件才会被加载执行,没被引用的代码根本不会进入内存。

没有它会怎样:早年浏览器就是反面教材——多个 <script> 文件共享同一个全局作用域,任何文件都能悄悄覆盖别人的变量:A 文件定义了 format,B 文件也定义 format,后加载的一声不响地赢了,程序在毫不相干的地方坏掉。项目一大,「谁动了我的变量」就成了日常灾难。模块系统把这种隐式共享换成了显式的进出口清单。

📘 概念模块(Module)
一个文件即一个模块:内部名字默认私有,export 是唯一的出口,import 是唯一的入口。一个模块的全部 import 语句加起来,就是它的「依赖清单」——读源码时先看文件顶部的 import 块,等于先看这个文件的说明书。

最小示例:一个四文件小项目

下面的代码就是本章实验 labs/typescript-basics/03-modules 的完整内容:一个迷你待办清单,拆成 4 个文件。先看依赖关系图,再逐个认识每种导入导出写法。

图加载中…

图 1.4-1 03-modules 实验的模块依赖图
阅读顺序:从上往下,箭头表示「谁导入谁」。实线是运行时真实存在的依赖;两条虚线是类型导入——编译成 JavaScript 后这两条线会彻底消失,types.ts 在运行时根本不会被加载。

这张图值得记住的一点是:import 语句让文件之间的依赖变成了一张可以画出来的图。读任何多文件项目(包括 Pi),第一步都是在脑子里恢复这张图。

命名导出与命名导入

tasks.ts 用的是命名导出(Named Export)——在声明前面直接加 export,一个模块可以导出任意多个名字:

ts
// 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 "外面看不到我";
}

导入方用花括号,名字必须与导出名完全一致

ts
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)——一个模块最多只能有一个

ts
// 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,也译命名空间导入):

ts
import * as path from "node:path";

path.join("labs", "typescript-basics", "03-modules");

path 现在是一个对象,模块的每个命名导出都是它的属性。当导出的名字很多、或想让来源一目了然时(path.join 比光秃秃的 join 信息量大),整体导入很好用。

类型导入

import type 是 TypeScript 专属的类型导入(Type-only Import):

ts
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-modules

ESM 与 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。

earendil-works/pi@c13ffe1第 5 行在 GitHub 查看 ↗
pi-agent-core 包声明自己是 ESM 模块。仓库里其余每个 package 的 package.json 第 5 行都是同一句。
🌱 初学者提示循环依赖:知道有这回事就够了
如果 A 导入 B、B 又反过来导入 A,就形成了循环依赖(Circular Dependency)。ESM 不会因此死循环,但先执行的一方可能拿到对方「尚未初始化完成」的值,产生难排查的 undefined 错误。原则只有一条:让依赖尽量保持单向分层,比如本章实验里 types.ts 处在最底层、不导入任何人。现在不必深究,遇到再回来看。

Pi 中哪里用到了它

打开 Pi 的核心文件 packages/agent/src/agent.ts,顶部整块都是本章内容(节选,完整块是第 1–30 行):

ts
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";
earendil-works/pi@c13ffe1第 1–30 行在 GitHub 查看 ↗
agent.ts 的完整导入块:类型导入、包名导入、相对路径导入、再导出同框出现。

逐段对照本章概念(这些名字的含义要到第三部分和 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):

tsconfig.base.json · allowImportingTsExtensions
earendil-works/pi@c13ffe1第 18–19 行在 GitHub 查看 ↗
全仓库允许相对导入写 .ts 后缀,并在编译时自动改写为 .js。本章实验的 tsconfig.json 开了同样的开关,与 Pi 风格保持一致。

再看一个把「再导出」用到极致的文件——packages/agent/src/index.ts,全文 50 行没有一行逻辑代码,全部是导出:

ts
// 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 * 语句,间或有分组注释)
earendil-works/pi@c13ffe1第 1–50 行在 GitHub 查看 ↗
pi-agent-core 的入口文件:用 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

目标:亲手运行本章的四文件项目,并通过三个小改动验证模块系统的规则。

步骤

  1. 进入实验目录 labs/typescript-basics/03-modules,运行 npm install,再运行 npm start
  2. 对照上文的依赖图,把 4 个源文件各自「导出什么、导入什么」读一遍;
  3. 改动一:在 main.tslist 数组里加第 4 条任务,重新 npm start,观察多出的警告行;
  4. 改动二:把 main.ts 里的默认导入 log 连同调用处全部改名为 print,确认程序照常运行;
  5. 改动三:把 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.jsonallowImportingTsExtensions

自测问题

  1. import { format } from "./a.ts"import format from "./a.ts" 有什么本质区别?哪个可以随意改名?
  2. import type 导入的东西在运行时去了哪里?为什么建议「只当类型用就写 import type」?
  3. 看到 from "@earendil-works/pi-ai"from "./types.ts",你分别去哪里找源文件?
  4. Pi 源码的相对导入为什么带 .ts 后缀?这依赖哪个编译选项?

下一章预告2.1 类型入门:基本类型与对象——运行环境和项目骨架都齐了,从下一章起正式进入 TypeScript 的类型世界:stringnumber、对象类型,以及类型标注到底在标注什么。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)