工厂函数
为什么用工厂函数替代 class,以及如何在项目中使用
什么是工厂函数
工厂函数是一个普通函数,调用后返回一个对象。对象上挂载了方法和属性,等价于 class 实例的能力,但不用 new、不用
class。
// 工厂函数
function createCounter(initial: number) {
let count = initial;
return {
increment() {
count++;
},
get() {
return count;
},
};
}
const counter = createCounter(0);
counter.increment();
counter.get(); // 1
等价的 class 写法:
class Counter {
private count: number;
constructor(initial: number) {
this.count = initial;
}
increment() {
this.count++;
}
get() {
return this.count;
}
}
const counter = new Counter(0);
两者能力完全一致,但在本项目中我们统一使用工厂函数。
初始化生命周期(图解)
理解工厂函数的关键在于:创建时初始化状态,调用时执行行为,闭包是连接两者的桥梁。
四阶段生命周期
以 createUploadService(db, r2) 为例,调用时按顺序发生 4 件事:
| 阶段 | 发生时机 | 做什么 | 类比 class |
|---|---|---|---|
| 1. 参数接收 | 调用瞬间 | db、r2 进入函数作用域 | constructor(db, r2) 的参数 |
| 2. 闭包初始化 | return 之前 | 逐行执行:createLogger() → 常量计算 → mkdirSync() 等 | 字段初始化器 + constructor 体 |
| 3. 返回对象 | return 语句 | 创建方法对象,每个方法”记住”闭包中的变量 | 实例创建完成 |
| 4. 方法调用 | 使用方调用时 | service.upload() 才真正执行方法体 | instance.method() |
阶段 2 细节 — 执行顺序:
function createUploadService(db: Database, r2: R2Client) {
// ① 最先执行 — 后续的 upload() 方法需要 log
const log = createLogger("upload-service");
// ② 然后执行 — 可以依赖 ① 的结果
const UPLOAD_DIR = join(process.cwd(), "uploads");
// ③ 条件性副作用 — 可以引用参数(r2) + 前面的变量(UPLOAD_DIR)
if (!r2.enabled) {
mkdirSync(UPLOAD_DIR, { recursive: true });
}
// ④ 最后执行 — 闭包变量全部就绪后才构造返回对象
return {
async upload(userId, file) {
/* 可以访问 db, r2, log, UPLOAD_DIR */
},
async getById(id) {
/* 可以访问 db */
},
};
}
规则很简单:从上到下,逐行执行。不存在 class 那种”字段初始化器 → constructor → prototype 方法”的多阶段跳转。
闭包作用域:谁能访问什么
闭包的核心机制:
调用方能看到什么? 闭包内部有什么?
───────────────── ─────────────────
uploadService.upload() ✓ db ← 参数捕获
uploadService.getById() ✓ r2 ← 参数捕获
uploadService.db ✗ log ← 内部创建
uploadService.log ✗ UPLOAD_DIR ← 内部创建
- 返回对象中的方法 — 公开 API,调用方可以使用
- 闭包变量(参数 + 内部 const/let)— 完全私有,外部无法访问、无法篡改
- 方法之间共享同一个闭包 —
upload()和getById()引用的db是同一个实例
这比 class 的 private 更安全:TypeScript 的 private 在编译后消失,运行时仍可通过 (instance as any).db
访问。闭包变量是语言层面的真正私有,没有任何方式可以从外部触及。
返回对象中的方法:为什么没有初始化顺序?
新手常见疑问:“return 里的 upload 和 getById 是什么顺序初始化的?”
return {
async upload(userId, file) { ... }, // ← 这两个有先后吗?
async getById(id) { ... },
};
答案:它们之间没有顺序关系。 原因:
- 它们是函数定义(引用),不是立即执行的表达式
- JavaScript 构造对象字面量时,会依次求值每个属性的值 — 但函数定义的”求值”只是创建函数引用,没有副作用
- 它们不互相依赖,也不依赖对方的初始化结果
可以类比为:你在一张纸上写了两个电话号码,写的顺序无关紧要,关键是打电话时号码要正确。
对比:如果返回值中有非函数属性呢?
return {
enabled: r2.enabled, // ← 立即求值,读取 r2.enabled 的当前值
publicUrl: config.publicUrl, // ← 立即求值
async upload(key, body) { ... }, // ← 函数引用,不执行
};
此时 enabled 和 publicUrl 是在 return 时立即计算的(快照值),而 upload 仍是惰性的。
工厂函数 vs Class 初始化对比
多处引用时会创建几个实例?
答案:取决于你调用了几次,而不是引用了几次。
工厂函数本身只是一个普通函数。每调用一次,就创建一个全新的闭包环境,返回一个全新的对象。多次调用 = 多个独立实例,互不干扰。
// 调用 3 次 → 3 个独立实例,各自的 module 不同
const logA = createLogger("chat-service");
const logB = createLogger("task-service");
const logC = createLogger("r2");
// logA、logB、logC 各有独立的闭包,互不影响
但在实际项目中,大多数 service 只在模块顶层调用一次,然后到处引用同一个变量:
// routes/upload.ts — 在这里把依赖"接线"
const uploadService = createUploadService(db, r2); // ← 只调用一次
// 下面的所有 handler 共享同一个 uploadService
app.post("/", handler(() => uploadService.upload(...)));
app.get("/:id", handler(() => uploadService.getById(...)));
这就是模块级单例模式 — 利用 ES Module 的特性:模块只执行一次,所以顶层的 const 变量天然是单例。
总结:
| 模式 | 写法 | 实例数 | 本项目何时使用 |
|---|---|---|---|
| 多实例 | 每次需要时调用 createXxx() | N | createLogger(module) — 每个文件各自创建 |
| 单例 | 模块顶层调用一次 const x = createXxx() | 1 | createTaskService(db) — 路由文件顶层一次 |
| 组合 | 工厂内部调用其他工厂 | 跟随外层 | createApiClient() 内部调用多个 createXxxModule() |
常见误区:
“我在 A 文件 import 了
taskService,在 B 文件也 import 了taskService,会不会创建两个?”
不会。import 只是引用,不是调用。ES Module 保证同一个模块只执行一次,所以 A 和 B 拿到的是同一个 taskService 实例。
测试友好性
工厂函数 + 依赖注入让单元测试变得非常简单 — 直接传入 mock 依赖即可,不需要任何 mock 库的黑魔法。
被测代码:
// services/upload-service.ts
export function createUploadService(db: Database, r2: R2Client) {
return {
async upload(userId: string, file: File) {
// ... 使用 db 和 r2
},
};
}
测试代码:
// upload-service.test.ts
import { createUploadService } from "./upload-service";
test("upload stores file in R2 when enabled", async () => {
// 直接构造 mock — 不需要 jest.mock()、不需要 DI 容器
const mockDb = {
insert: () => ({ values: () => ({ returning: () => [{ id: "abc" }] }) }),
query: { upload: { findFirst: () => null } },
};
const mockR2 = {
enabled: true,
publicUrl: "https://cdn.example.com",
upload: async (key: string) => `https://cdn.example.com/${key}`,
};
const service = createUploadService(mockDb as any, mockR2);
const result = await service.upload("user-1", fakeFile);
expect(result.url).toContain("https://cdn.example.com");
});
test("upload falls back to local when R2 disabled", async () => {
const mockR2 = { enabled: false, publicUrl: "", upload: async () => "" };
const service = createUploadService(mockDb as any, mockR2);
// ... 验证本地存储逻辑
});
对比 class + 全局单例的测试痛点:
// 如果 upload-service.ts 内部直接 import { r2 } from "@/lib/r2"
// 测试时你就不得不:
jest.mock("@/lib/r2", () => ({ r2: { enabled: true, upload: jest.fn() } }));
// 问题:
// - mock 路径写错不报错,静默失败
// - mock 是全局的,测试之间互相污染
// - 重置 mock 状态容易遗漏
工厂函数的方式:谁依赖什么写在参数里,测试时原样传入替代品,零魔法。
常见错误
1. 忘记调用 — 传了工厂而不是实例
// ✗ 错误 — 传的是函数本身,不是调用结果
const uploadService = createUploadService;
uploadService.upload(...); // TypeError: uploadService.upload is not a function
// ✓ 正确 — 别忘了 ()
const uploadService = createUploadService(db, r2);
TypeScript 通常能捕获这个错误(类型不匹配),但在动态传递时容易遗漏。
2. 在请求/循环内反复创建
// ✗ 错误 — 每次请求都创建新实例,浪费资源
app.post("/", async (c) => {
const service = createUploadService(db, r2); // 每次请求都 new S3Client
return service.upload(...);
});
// ✓ 正确 — 模块顶层创建一次
const uploadService = createUploadService(db, r2);
app.post("/", async (c) => {
return uploadService.upload(...);
});
工厂函数里可能有昂贵的初始化(创建客户端连接、读取配置、创建目录)。除非你明确需要多实例(如
createLogger),否则应该在模块顶层调用一次。
3. 闭包与 GC:意外持有大对象
GC 基础:什么时候回收
JavaScript 的垃圾回收基于可达性(reachability) — 从根对象(全局变量、调用栈)出发,沿引用链能到达的对象都不会被回收。闭包的特殊之处在于:返回的方法持有对闭包作用域的引用,只要方法还活着,它引用的变量就活着。
全局/模块作用域
└─ uploadService(返回的对象) ← 根可达,不会被 GC
├─ upload() ──引用──→ db, r2, log ← 被方法引用,不会被 GC
└─ getById() ──引用──→ db ← 同上
只要 uploadService 变量存在(模块级变量 = 进程生命周期),闭包中被引用的 db、r2、log
就永远不会被回收。这对 service 单例来说完全正常 — 它们本来就该活到进程退出。
问题出现在:闭包无意中持有了不该长期存活的大对象。
V8 的闭包优化:不是所有变量都会被保留
V8(Node.js 的引擎)会做闭包变量分析:只把方法体内实际引用的变量放进闭包环境,未引用的变量不会被捕获。
export function createProcessor(rawData: Buffer) {
const parsed = parse(rawData);
return {
query() { return parsed.find(...); },
};
}
这里 query() 只引用了 parsed,没有引用 rawData。V8 分析后:
parsed→ 放入闭包环境 → 被query()持有 → 不会被 GC(预期行为)rawData→ 未被任何返回方法引用 → 不放入闭包 →createProcessor执行完毕后即可被 GC
所以上面的代码是安全的, 即使 rawData 是 500MB 的 Buffer。
陷阱 1:方法体内无意引用了大对象
export function createProcessor(rawData: Buffer) {
const parsed = parse(rawData);
return {
query() {
console.log(rawData.length); // ← 仅仅这一行 debug 日志
return parsed.find(...);
},
};
}
加了一行 console.log(rawData.length) 后,query() 引用了
rawData,V8 必须将其放入闭包。结果:500MB 的 rawData 和 parsed 同时被永久持有,内存翻倍。
修复前: parsed (10MB) ← 闭包只持有 parsed
修复后: parsed (10MB) + rawData (500MB) ← 闭包同时持有两者
修复方式 — 用完就释放:
export function createProcessor(rawData: Buffer) {
const size = rawData.length; // ← 提取需要的信息
const parsed = parse(rawData);
// rawData 不再被任何方法引用 → 可被 GC
return {
query() {
console.log(size); // ← 引用的是 number,不是 Buffer
return parsed.find(...);
},
};
}
陷阱 2:共享闭包作用域 — 一个方法引用 = 全部保留
这是最隐蔽的陷阱。 V8 的闭包优化是以整个闭包作用域为单位的,不是按方法单独分析:
export function createAnalyzer(rawData: Buffer) {
const parsed = parse(rawData);
const summary = summarize(parsed);
return {
// getSummary 只需要 summary(很小)
getSummary() {
return summary;
},
// debug 引用了 rawData(很大)
debug() {
console.log(rawData.slice(0, 100));
},
};
}
你可能以为:我只调用 getSummary(),从不调用 debug(),rawData 应该没事吧?
错。 getSummary 和 debug 共享同一个闭包环境。只要 debug 引用了 rawData,V8 就必须在闭包中保留 rawData
— 即使你从未调用 debug()。因为 V8 无法在运行时预测你将来会不会调用它。
闭包环境(所有方法共享):
rawData ← debug() 引用了它,所以整个闭包都要保留
parsed ← debug() 或 getSummary() 都可能用
summary ← getSummary() 引用
两个方法共享上面这个闭包 → rawData 无法被 GC
修复方式 — 隔离重量级引用:
export function createAnalyzer(rawData: Buffer) {
const parsed = parse(rawData);
const summary = summarize(parsed);
// 把 debug 需要的信息提前提取出来
const debugPreview = rawData.slice(0, 100);
// rawData 不再被任何方法引用
return {
getSummary() {
return summary;
},
debug() {
console.log(debugPreview);
}, // 引用的是 100 字节,不是整个 Buffer
};
}
陷阱 3:eval 破坏 V8 优化
如果方法体内使用了 eval(),V8 无法静态分析变量引用,会放弃优化,保留闭包中的所有变量:
export function createProcessor(rawData: Buffer, config: Config) {
const parsed = parse(rawData);
return {
query(expr: string) {
return eval(expr); // ← V8: 不知道 expr 会访问什么,全保留
},
};
}
// rawData、config、parsed 全部被保留,即使 eval 可能根本不用它们
本项目的 erasableSyntaxOnly + strict 模式下不会出现 eval,但知道这个原理有助于理解 V8 的闭包机制。
总结:闭包 GC 心智模型
工厂函数调用
│
├─ 参数 + 闭包变量 ─── V8 分析 ───→ 被返回方法引用?
│ │
│ 是 ──→ 放入闭包环境(随返回对象生死)
│ 否 ──→ 不放入闭包(函数执行完即可 GC)
│
└─ 返回对象 ─── 被谁引用?
│
模块级变量 ──→ 进程生命周期(service 单例正常)
局部变量 ──→ 离开作用域后,返回对象 + 闭包一起被 GC
事件监听器 ──→ 直到 removeListener,否则永远存活
实践原则:
- 参数中的大对象用完就丢 — 提取需要的字段,不要让方法体直接引用原始大对象
- 警惕共享闭包 — 一个方法引用了大对象,所有同级方法都会”连坐”
- 模块级单例不用担心 —
db、r2这类本来就该活到进程退出的依赖,闭包持有它们是正确的 - 请求级临时工厂要小心 — 如果在请求内创建工厂,确保请求结束后没有残留引用(如未清理的定时器、事件监听)
4. 返回对象中方法互调
// ✗ 不工作 — 对象字面量中 this 不指向自身
return {
getAll() { return db.query(...); },
getFirst() { return this.getAll()[0]; }, // this 是 undefined(严格模式)
};
// ✓ 方案 A — 提取为闭包内的普通函数
function getAll() { return db.query(...); }
return {
getAll,
getFirst() { return getAll()[0]; },
};
// ✓ 方案 B — 先构造对象再返回
const self = {
getAll() { return db.query(...); },
getFirst() { return self.getAll()[0]; },
};
return self;
为什么不用 class
| 问题 | 说明 |
|---|---|
this 指向不确定 | 回调、解构赋值、setTimeout 中 this 丢失是经典 bug |
implements 是运行时幻觉 | TypeScript 的 class Foo implements Bar 在编译后完全消失,不提供任何运行时保证 |
| 继承层级难维护 | 深层 extends 链在重构时牵一发动全身 |
| 不利于 tree-shaking | class 方法挂在 prototype 上,打包工具难以剔除未使用的方法 |
| 与 React 生态格格不入 | React 是函数式范式(hooks、函数组件),class 组件已被官方淘汰 |
工厂函数天然规避了这些问题:没有 this、没有 prototype、没有继承链。
本项目的工厂函数规范
命名
create + 名词 → createTaskService, createR2Client, createLogger
- 文件名:
kebab-case,与工厂函数对应(task-service.ts→createTaskService) - 类型导出:
ReturnType<typeof createXxx>取返回类型
export function createTaskService(db: Database) { ... }
export type TaskService = ReturnType<typeof createTaskService>;
依赖注入
依赖通过参数传入,不从模块顶层直接 import 单例:
// 好 — 依赖显式、可测试
export function createUploadService(db: Database, r2: R2Client) {
return { ... };
}
// 不好 — 隐式依赖全局单例,无法替换
import { db } from "@/db";
import { r2 } from "@/lib/r2";
export function createUploadService() {
// 内部直接用 db 和 r2 …
}
显式依赖的好处:
- 一眼看出这个模块需要什么
- 测试时可以传入 mock
- 不依赖模块加载顺序
组装点
依赖的组装发生在路由文件(或入口文件),而非 service 内部:
// routes/upload.ts — 在这里把依赖"接线"
import { createR2Client } from "@/lib/r2";
import { db } from "@/db";
import { createUploadService } from "@/services/upload-service";
const uploadService = createUploadService(db, createR2Client());
私有函数与公有函数
工厂函数中有三个放置辅助逻辑的位置,每个位置的可见性和能力不同:
// ① 模块级私有 — 纯工具函数,不依赖注入的依赖
function maskKey(raw: string): string {
if (raw.length <= 8) return "••••••••";
return `${raw.slice(0, 4)}••••${raw.slice(-4)}`;
}
export function createApiKeyService(repo: ApiKeyRepository, encryption: KeyEncryption) {
// ② 闭包级私有 — 需要访问注入的依赖(repo, encryption 等)
async function assertExists(userId: string, provider: string) {
const row = await repo.findByProvider(userId, provider);
if (!row) throw AppError.notFound("API key not found");
return row;
}
// ③ 公有方法 — 返回对象中暴露给调用方
return {
async list(userId: string) { ... },
async remove(userId: string, provider: string) {
await assertExists(userId, provider); // 调用闭包级私有
const masked = maskKey(...); // 调用模块级私有
...
},
};
}
三个级别对比:
| 级别 | 位置 | 能访问注入依赖? | 调用方可见? | 适用场景 |
|---|---|---|---|---|
| 模块级私有 | 工厂函数外部 | 否 | 否 | 纯转换/工具函数(maskKey、toTask、mapper 函数) |
| 闭包级私有 | 工厂函数内部、return 之前 | 是 | 否 | 需要依赖的内部逻辑(assertOwnership、复杂编排的子步骤) |
| 公有方法 | return 的对象中 | 是(通过闭包) | 是 | 对外暴露的 API |
选择原则:
- 不需要依赖 → 模块级(可被同文件其他工厂复用,也便于单独测试)
- 需要依赖但不该暴露 → 闭包级
- 需要暴露给调用方 → 公有方法
闭包级私有的典型用途:
export function createTaskOrchestrator(db: Database, jobQueue: JobQueue) {
const taskService = createTaskService(db);
const creditService = createCreditService(db);
// 子步骤提取 — 降低公有方法的复杂度,同时访问多个闭包依赖
function scheduleTitle(taskId: string, messages: ZapvolMessage[], writer: StreamWriter): void {
// 访问 titleService(闭包依赖);参数只传写事件所需的窄接口,
// 避免长寿 Promise 闭包意外 retain 整个 ZapvolContext
}
async function finalizeExecution(taskId: string, userId: string, ...): Promise<void> {
// 访问 taskService、creditService、jobQueue(闭包依赖)
}
return {
async execute(taskId, user, body) {
// 公有方法保持简洁,委托给闭包级私有函数
scheduleTitle(taskId, messages, writer);
await finalizeExecution(taskId, user.id, ...);
},
};
}
与 class private 的对比:
| class private | 闭包级私有 | |
|---|---|---|
| 运行时隐私 | 假的((obj as any).method() 可访问) | 真的(语言层面不可访问) |
this 绑定 | 需要注意 | 无 this,普通函数调用 |
| 可测试性 | 通常需要绕过 private 或改为 protected | 无法直接测试,但可通过公有方法间接测试 |
项目中的真实案例
1. 领域服务(最常见)
接收 db 作为依赖,返回业务方法集合:
// services/chat-service.ts
export function createChatService(db: Database) {
return {
async list(userId: string): Promise<Chat[]> { ... },
async get(userId: string, chatId: string): Promise<ChatDetail> { ... },
async create(userId: string, data: CreateChat): Promise<Chat> { ... },
async delete(userId: string, chatId: string): Promise<void> { ... },
};
}
export type ChatService = ReturnType<typeof createChatService>;
同类:createTaskService(db)、createModelService(db)、createCreditService(db)
2. 基础设施客户端
封装外部资源连接:
// lib/r2.ts
export function createR2Client(config: R2Config | null = getConfigFromEnv()) {
if (!config) {
return { enabled: false as const, ... };
}
const client = new S3Client({ ... });
return {
enabled: true as const,
async upload(key: string, body: Buffer): Promise<string> { ... },
};
}
同类:createLogger(module)、createResumableStreamContext(options)
3. API 客户端模块
前端 API 调用按领域拆分,每个模块也是工厂函数:
// packages/app/src/api/modules/chat.ts
export function createChatModule(request: RequestFn) {
return {
listChats: () => request<Chat[]>("/api/chats"),
getChat: (id: string) => request<ChatDetail>(`/api/chats/${id}`),
createChat: (data: CreateChat) => request<Chat>("/api/chats", { method: "POST", body: data }),
};
}
最终在 createApiClient 中组装:
// packages/app/src/api/client.ts
export function createApiClient(baseURL = "") {
const request = createRequest(baseURL);
return {
chat: createChatModule(request),
task: createTaskModule(request),
user: createUserModule(request),
// ...
};
}
4. 跨平台实现
同一个 contract 接口,Web 和 Desktop 各有一个工厂函数实现:
// Web — 通过 HTTP 调用
export function createWebChatService(api: ApiClient): ChatService {
return {
list: () => api.chat.listChats(),
// ...
};
}
// Desktop — 通过 Electron IPC 调用
export function createDesktopChatService(): ChatService {
return {
list: () => window.electron.invoke("chat:list"),
// ...
};
}
两者返回相同的 ChatService 类型,上层组件无需关心底层传输方式。
什么时候仍然用 class
本项目中 class 仅用于两种场景:
-
自定义 Error — 继承
Error是唯一合理使用extends的地方export class AppError extends Error { constructor( public code: string, public status: number, message: string, ) { super(message); } static notFound(msg = "Not found") { return new AppError("NOT_FOUND", 404, msg); } } -
第三方库要求 — 某些库的 API 设计需要 class 实例(如 sandbox 接口),此时遵从库的约定
除此之外,所有业务代码、服务、工具类一律使用工厂函数。
速查对照表
| 你想要…… | class 写法 | 工厂函数写法 |
|---|---|---|
| 创建实例 | new TaskService(db) | createTaskService(db) |
| 定义方法 | class { method() {} } | return { method() {} } |
| 私有状态 | private count = 0 | 闭包变量 let count = 0 |
| 类型导出 | class TaskService 本身就是类型 | type TaskService = ReturnType<typeof createTaskService> |
| 接口实现 | class Foo implements Bar | 函数返回值满足 Bar 类型即可(结构化类型) |
| 组合能力 | 继承 extends Base | 在工厂内部调用其他工厂 |