工厂函数

为什么用工厂函数替代 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);

两者能力完全一致,但在本项目中我们统一使用工厂函数


初始化生命周期(图解)

理解工厂函数的关键在于:创建时初始化状态,调用时执行行为,闭包是连接两者的桥梁。

四阶段生命周期

工厂函数 (Factory Function) 初始化生命周期 const uploadService = createUploadService(db, r2) 1 调用 createUploadService(db, r2) 参数 db、r2 进入函数作用域,成为闭包可捕获的变量 2 闭包作用域 (Closure Scope) — 外部不可直接访问 const log = createLogger(…) 先初始化 — 后续代码可能依赖它 const UPLOAD_DIR = join(…) 常量计算 — 只执行一次 mkdirSync(UPLOAD_DIR) 副作用 — 立即执行的逻辑 执行顺序: 从上到下逐行执行,所有闭包变量在 return 之前完成初始化。 关键点: 参数(db, r2) + 闭包变量(log, UPLOAD_DIR) 共同构成"私有状态", 等价于 class 的 private 属性,但通过词法作用域实现,外部完全无法访问。 3 return { … } — 返回公开 API 对象 async upload(userId, file) 内部可访问 db, r2, log, UPLOAD_DIR async getById(id) 内部可访问 db 方法此时只是定义,并未执行。 每个方法平级,无初始化顺序 — 都独立引用闭包。 4 调用时(惰性执行)— uploadService.upload(userId, file) 方法体内的代码此刻才真正执行: - 通过闭包访问 db → 执行数据库查询 - 通过闭包访问 r2 → 上传文件到 R2 - 通过闭包访问 log → 记录日志 没有 this! 闭包引用是词法绑定的,不管方法 怎么传递、解构、回调,引用永远正确。 创建时初始化状态(阶段 1-3)→ 调用时执行行为(阶段 4)→ 闭包是连接两者的桥梁

createUploadService(db, r2) 为例,调用时按顺序发生 4 件事:

阶段发生时机做什么类比 class
1. 参数接收调用瞬间dbr2 进入函数作用域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 方法”的多阶段跳转。

闭包作用域:谁能访问什么

闭包作用域 (Closure Scope):谁能访问什么 代码视角 (Code View) 1 function createUploadService( 2 db: Database, 3 r2: R2Client 4 ) { 5 const log = createLogger(…) 6 const UPLOAD_DIR = join(…) 7 if (!r2.enabled) 8 mkdirSync(UPLOAD_DIR) 9 return { 10 async upload(userId, file) { 11 db.insert(…) 12 r2.upload(…) 13 log.info(…) 14 }, 15 async getById(id) { 16 db.query(…) 17 }, . 闭包作用域 (Closure Scope) 内存视角 (Memory View) — 调用后的状态 闭包环境 (Closure Environment) 创建时固定,外部不可访问 db Database 实例 r2 R2Client 实例 log Logger 实例 UPLOAD_DIR string 常量 返回对象 (Public API) 调用方唯一能接触到的部分 upload(userId, file) 引用闭包: db, r2, log, UPLOAD_DIR getById(id) 引用闭包: db upload 引用 (db, r2, log, DIR) getById 引用 (db) uploadService.db → undefined(外部无法访问闭包变量)

闭包的核心机制:

调用方能看到什么?             闭包内部有什么?
─────────────────            ─────────────────
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 里的 uploadgetById 是什么顺序初始化的?”

return {
  async upload(userId, file) { ... },  // ← 这两个有先后吗?
  async getById(id) { ... },
};

答案:它们之间没有顺序关系。 原因:

  1. 它们是函数定义(引用),不是立即执行的表达式
  2. JavaScript 构造对象字面量时,会依次求值每个属性的值 — 但函数定义的”求值”只是创建函数引用,没有副作用
  3. 它们不互相依赖,也不依赖对方的初始化结果

可以类比为:你在一张纸上写了两个电话号码,写的顺序无关紧要,关键是打电话时号码要正确。

对比:如果返回值中有非函数属性呢?

return {
  enabled: r2.enabled,                 // ← 立即求值,读取 r2.enabled 的当前值
  publicUrl: config.publicUrl,          // ← 立即求值
  async upload(key, body) { ... },     // ← 函数引用,不执行
};

此时 enabledpublicUrl 是在 return 时立即计算的(快照值),而 upload 仍是惰性的。

工厂函数 vs Class 初始化对比

初始化顺序对比:工厂函数 (Factory Function) vs Class 工厂函数 createXxx(deps) 1 接收参数 db, r2(依赖注入 / DI) 2 初始化闭包 (Closure) 变量(log, 常量, 缓存…) 3 执行初始化副作用 (Side Effects)(mkdirSync 等) 4 return { method1, method2, … } — 创建完成,以下发生在调用时 — 5 service.upload() → 执行方法体 特点 ✓ 无 this — 闭包词法绑定 (Lexical Binding) ✓ 真正私有 — 闭包外不可访问 ✓ 解构安全 — const { upload } = service ✓ 依赖显式 — 参数列表即依赖清单 ✓ 初始化一目了然 — return 前全部完成 Class new Xxx(deps) 1 分配对象 + 原型链 (Prototype Chain) (new) 2 字段初始化器(在 constructor 前!) 3 constructor(deps) 执行 4 方法挂 prototype(共享,非实例私有) — 创建完成,以下发生在调用时 — 5 instance.upload() → this 指向 instance 陷阱 ✗ this 丢失 — const fn = obj.method; fn() ✗ private 是语法糖 — 运行时可绕过 ✗ 解构丢 this — const { upload } = service ✗ 字段 vs constructor 顺序易混淆 ✗ 初始化分散在字段 + constructor + 方法

多处引用时会创建几个实例?

工厂函数 (Factory Function) 的实例化:每次调用 = 新实例 场景 A:多次调用 → 多个独立实例 createLogger(module) 闭包 (Closure) A module = "chat-service" { info(), warn(), error() } 闭包 B module = "task-service" { info(), warn(), error() } 闭包 C module = "r2" { info(), warn(), error() } 3 次调用 = 3 个独立闭包。 每个闭包捕获的 module 值不同,互不干扰。 场景 B:单例 (Singleton) → 模块级调用一次,到处引用同一个 // routes/upload.ts const uploadService = createUploadService(db, r2) // 所有路由 handler 共享这一个实例 app.post("/", … uploadService.upload(…)) createUploadService(db, r2) uploadService 唯一实例(单例) POST /api/upload GET /api/upload/:id 其他引用方… 决定因素:不是工厂函数本身,而是你调用了几次 写法 调用次数 结果 const s = createXxx() 模块级 1 次 单例,所有引用共享 createXxx("a") / createXxx("b") 调用 N 次 N 个独立实例

答案:取决于你调用了几次,而不是引用了几次。

工厂函数本身只是一个普通函数。每调用一次,就创建一个全新的闭包环境,返回一个全新的对象。多次调用 = 多个独立实例,互不干扰。

// 调用 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()NcreateLogger(module) — 每个文件各自创建
单例模块顶层调用一次 const x = createXxx()1createTaskService(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 变量存在(模块级变量 = 进程生命周期),闭包中被引用的 dbr2log 就永远不会被回收。这对 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 应该没事吧?

错。 getSummarydebug 共享同一个闭包环境。只要 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,否则永远存活

实践原则:

  1. 参数中的大对象用完就丢 — 提取需要的字段,不要让方法体直接引用原始大对象
  2. 警惕共享闭包 — 一个方法引用了大对象,所有同级方法都会”连坐”
  3. 模块级单例不用担心dbr2 这类本来就该活到进程退出的依赖,闭包持有它们是正确的
  4. 请求级临时工厂要小心 — 如果在请求内创建工厂,确保请求结束后没有残留引用(如未清理的定时器、事件监听)

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 指向不确定回调、解构赋值、setTimeoutthis 丢失是经典 bug
implements 是运行时幻觉TypeScript 的 class Foo implements Bar 在编译后完全消失,不提供任何运行时保证
继承层级难维护深层 extends 链在重构时牵一发动全身
不利于 tree-shakingclass 方法挂在 prototype 上,打包工具难以剔除未使用的方法
与 React 生态格格不入React 是函数式范式(hooks、函数组件),class 组件已被官方淘汰

工厂函数天然规避了这些问题:没有 this、没有 prototype、没有继承链。


本项目的工厂函数规范

命名

create + 名词 → createTaskService, createR2Client, createLogger
  • 文件名:kebab-case,与工厂函数对应(task-service.tscreateTaskService
  • 类型导出: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(...);            // 调用模块级私有
      ...
    },
  };
}

三个级别对比:

级别位置能访问注入依赖?调用方可见?适用场景
模块级私有工厂函数外部纯转换/工具函数(maskKeytoTask、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 仅用于两种场景:

  1. 自定义 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);
      }
    }
  2. 第三方库要求 — 某些库的 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在工厂内部调用其他工厂
这页有帮助吗?