设计模式

约35分钟

设计模式是软件开发中经过反复验证的、用于解决特定问题的最佳实践。在 TypeScript 这样一门强大的类型语言中,恰当地运用设计模式,可以极大地提升代码的可读性、可扩展性和可维护性。

控制反转 (IoC) 与 依赖注入 (DI)

Class A中用到了Class B的对象b,一般情况下,需要在A的代码中显式地用 new 建立 B 的对象。

采用依赖注入技术之后,A 的代码只需要定义一个 private 的B对象,不需要直接 new 来获得这个对象,而是通过相关的容器控制程序来将B对象在外部new出来并注入到A类里的引用中。而具体获取的方法、对象被获取时的状态由配置文件(如XML)来指定。

IoC也可以理解为把流程的控制从应用程序转移到框架之中。以前,应用程序掌握整个处理流程;现在,控制权转移到了框架,框架利用一个引擎驱动整个流程的执行,框架会以相应的形式提供一系列的扩展点,应用程序则通过定义扩展的方式实现对流程某个环节的定制,“框架Call应用”。基于MVC的web应用程序就是如此。

控制反转 (Inversion of Control - IoC)

IoC 是一种软件设计原则,其核心思想是“不要来找我,我会在需要的时候去找你”。

在传统编程中,一个对象通常会主动创建或获取它所依赖的其他对象。而在 IoC 模式下,对象创建的控制权被“反转”了,从对象自身转移到了一个外部的**容器(Container)**或框架。这个外部容器负责创建对象、管理它们的生命周期,并将依赖关系注入到需要的对象中。

依赖注入 (Dependency Injection - DI)

DI 是实现 IoC 的一种具体技术。 它指的是一个对象不必在内部构建其依赖,而是通过外部(例如,通过构造函数、属性或方法)“注入”这些依赖。

我们可以创建一个极简的 DI 容器来理解这个过程:

// 1. 定义依赖的抽象(接口)
interface ILogger {
	log(message: string): void
}

// 2. 实现具体的依赖
class ConsoleLogger implements ILogger {
	log(message: string) {
		console.log(`[ConsoleLogger]: ${message}`)
	}
}

// 3. 定义一个依赖于 ILogger 的服务
class UserService {
	// 通过构造函数声明依赖
	constructor(private logger: ILogger) {}

	getUser(id: number) {
		this.logger.log(`Fetching user with id ${id}`)
		return { id, name: 'John Doe' }
	}
}

// 4. 创建一个极简的 DI 容器
class DiContainer {
	private services = new Map<string, any>()

	// 注册服务
	register<T>(token: string, service: T): void {
		this.services.set(token, service)
	}

	// 获取(解析)服务
	resolve<T>(token: string): T {
		if (!this.services.has(token)) {
			throw new Error(`Service not found for token: ${token}`)
		}
		return this.services.get(token)
	}
}

// --- 应用启动和组装 ---
const container = new DiContainer()

// 在“启动”阶段,手动创建实例并注册到容器中
const logger = new ConsoleLogger()
container.register('ILogger', logger)

// 此处手动注入依赖来创建 UserService
const userService = new UserService(container.resolve<ILogger>('ILogger'))
container.register('UserService', userService)

// --- 在应用的任何地方使用 ---
// 我们不再关心 UserService 是如何被创建的,只管从容器中获取
const appService = container.resolve<UserService>('UserService')
appService.getUser(1)

在这个例子中,UserService 不再关心 ILogger 的具体实现是 ConsoleLogger 还是 FileLogger,它只依赖于 ILogger 接口。对象的创建和组装全部由外部的 DiContainer 控制,这就是 IoC 和 DI 的核心。

反射 (Reflection) 与 元数据 (Metadata)

虽然上面的 DI 容器可以工作,但依赖关系需要手动组装。自动化 DI 的“魔法”背后,就是反射和元数据。

反射 是指程序在运行时可以“自我检查”的能力。在 TypeScript 中,通常借助 reflect-metadata 库来实现。元数据 则是附加到代码(如类、方法、属性)上的额外信息,它们可以在运行时被反射机制读取。

装饰器是附加元数据的完美工具。让我们看一个使用反射实现简单验证的例子:

import 'reflect-metadata'

// 定义一个元数据键
const REQUIRED_METADATA_KEY = Symbol('required')

// 1. 创建一个属性装饰器 @required
function required(target: object, propertyKey: string) {
	// 使用 Reflect.defineMetadata 附加元数据
	Reflect.defineMetadata(REQUIRED_METADATA_KEY, true, target, propertyKey)
}

// 2. 创建一个通用的验证函数
function validate(instance: any): boolean {
	const obj = new (instance.constructor as any)()

	for (let prop in obj) {
		// 使用 Reflect.getMetadata 读取元数据
		const isRequired = Reflect.getMetadata(REQUIRED_METADATA_KEY, obj, prop)

		if (
			isRequired &&
			(instance[prop] === undefined || instance[prop] === null)
		) {
			console.error(`Validation failed: Property '${prop}' is required.`)
			return false
		}
	}
	return true
}

// 3. 应用装饰器
class User {
	id: number

	@required
	name: string

	email: string

	constructor(id: number, name?: string, email?: string) {
		this.id = id
		if (name) this.name = name
		if (email) this.email = email
	}
}

// --- 使用 ---
const user1 = new User(1, 'Alice', 'alice@example.com')
console.log('Validating user1:', validate(user1)) // true

const user2 = new User(2, undefined, 'bob@example.com')
console.log('Validating user2:', validate(user2)) // false, 因为 name 是 required 的

在这个例子中,@required 装饰器并不执行任何逻辑,它只做一件事:给 name 属性贴上一个“需要验证”的元数据标签。validate 函数则通过反射机制来读取这些标签,并据此执行验证逻辑。这就是现代框架实现如参数验证、依赖注入等声明式功能的底层原理。

面向切面编程 (AOP)

面向切面编程(Aspect-Oriented Programming, AOP)是一种强大的编程范式,其核心目标是将 横切关注点 (Cross-Cutting Concerns) 与应用程序的核心业务逻辑分离开来,从而提高代码的模块化程度。

横切关注点是那些会影响到多个模块的通用功能,例如日志记录、性能监控、数据缓存、事务管理或权限验证。在没有 AOP 的情况下,这些功能代码会散布(或称“纠缠”)在各个业务模块中,导致代码重复和维护困难。AOP 允许你将这些逻辑提取并封装到一个称为 切面(Aspect) 的独立单元中。

AOP 的核心概念

要深入理解 AOP,必须掌握以下几个核心术语:

  • 连接点 (Join Point): 程序执行过程中的一个明确定义的点。在 AOP 中,这通常是方法的调用或执行。可以将其想象为代码中所有可能插入新逻辑的“时机”或“位置”。

  • 切入点 (Pointcut): 一个或一组连接点的集合。切入点使用表达式或规则来“查询”或“筛选”出我们感兴趣的连接点。例如,一个切入点可以定义为“类 ProductService 中所有以 get 开头的方法”。它回答了“在哪里应用新逻辑”的问题。

  • 通知 (Advice): 在切入点定义的连接点上要执行的具体代码。通知定义了切面“做什么”以及“什么时候做”。常见的通知类型包括:

    • 前置通知 (Before): 在连接点(方法)执行之前运行。
    • 后置通知 (After Returning): 在连接点(方法)成功执行并返回后运行。
    • 异常通知 (After Throwing): 在连接点(方法)抛出异常后运行。
    • 最终通知 (After): 无论连接点(方法)是正常返回还是抛出异常,都会运行(类似于 finally 块)。
    • 环绕通知 (Around): 最强大的通知类型。它“环绕”着连接点,允许你在方法执行前后都添加逻辑,甚至可以完全阻止原始方法的执行。
  • 切面 (Aspect): AOP 的基本模块化单元。一个切面是 切入点 (Pointcut)通知 (Advice) 的组合。它完整地定义了一个横切关注点:在哪里(Pointcut)以及做什么(Advice)。

  • 织入 (Weaving): 将切面应用到目标对象,从而创建一个新的“代理”对象的过程。织入回答了“如何将新逻辑应用到目标代码上”的问题。织入可以在编译时、类加载时或运行时进行。在 TypeScript 中,使用装饰器通常是在 运行时 进行织入。

一个缓存切面

我们通过创建一个 缓存切面 来演示上述概念。这个切面将自动缓存方法的计算结果,对于后续相同的调用,直接返回缓存值,避免重复计算。

// --- 我们的切面实现 ---

// 1. 定义一个简单的缓存存储
const cache = new Map<string, any>()

// 2. 定义我们的“环绕通知”逻辑,并通过装饰器工厂函数来创建装饰器
function Cacheable(
	target: any,
	propertyKey: string,
	descriptor: PropertyDescriptor
) {
	// 保存原始方法,这是我们的“连接点”
	const originalMethod = descriptor.value

	// 3. 定义“环绕通知 (Around Advice)”
	// 我们用一个新函数重写原始方法
	descriptor.value = function (...args: any[]) {
		// 创建缓存键
		const cacheKey = `${propertyKey}:${JSON.stringify(args)}`

		// --- 前置逻辑 (Before) ---
		console.log(`[AOP Advice] Checking cache for key: ${cacheKey}`)
		if (cache.has(cacheKey)) {
			console.log(`[AOP Advice] Cache hit! Returning cached value.`)
			return cache.get(cacheKey) // 如果命中缓存,则不执行原始方法
		}

		console.log(`[AOP Advice] Cache miss. Executing original method...`)

		// --- 执行连接点 (调用原始方法) ---
		const result = originalMethod.apply(this, args)

		// --- 后置逻辑 (After Returning) ---
		console.log(`[AOP Advice] Caching result for key: ${cacheKey}`)
		cache.set(cacheKey, result)

		return result
	}

	return descriptor
}

// --- 客户端代码 ---

class DataService {
	// 4. 应用装饰器。@Cacheable 在这里充当了“切入点”,
	// 它选择了 `fetchDataFromDB` 这个“连接点”来应用我们的缓存切面。
	@Cacheable
	fetchDataFromDB(id: number): { id: number; data: string } {
		// 模拟一个缓慢的数据库查询
		console.log(`--- Executing slow DB query for id: ${id} ---`)
		for (let i = 0; i < 2e8; i++) {} // 耗时操作
		return { id, data: `Some data for ${id}` }
	}
}

// --- 织入 (Weaving) 与执行 ---
// 当 DataService 类被定义时,@Cacheable 装饰器就会执行。
// 这就是“织入”的过程,它在运行时用我们的通知逻辑创建了一个新的 fetchDataFromDB 方法。
console.log('Creating DataService instance...')
const service = new DataService()

console.log('\nFirst call with id=1:')
service.fetchDataFromDB(1) // 应该会执行原始方法

console.log('\nSecond call with id=1:')
service.fetchDataFromDB(1) // 应该会命中缓存,跳过原始方法

console.log('\nFirst call with id=2:')
service.fetchDataFromDB(2) // 应该会执行原始方法,因为 key 不同

示例分析:

  • 连接点 (Join Point): fetchDataFromDB 方法的执行就是我们的连接点。
  • 切入点 (Pointcut): @Cacheable 装饰器扮演了切入点的角色。通过将它应用到 fetchDataFromDB 方法上,我们精确地“选中”了这个连接点。
  • 通知 (Advice): descriptor.value 被赋予的新函数就是我们的 环绕通知。它包含了检查缓存(前置逻辑)、调用原始方法和存储结果(后置逻辑)的完整实现。
  • 切面 (Aspect): cache 存储状态与 Cacheable 装饰器中的通知逻辑共同构成了一个完整的 缓存切面
  • 织入 (Weaving):DataService 类在 JavaScript 引擎中被定义时,@Cacheable 装饰器函数立即执行。它修改了 fetchDataFromDB 的属性描述符,用包含缓存逻辑的新函数替换了原始函数。这个过程就是在运行时发生的 织入

通过这种方式,我们成功地将缓存逻辑从 fetchDataFromDB 的核心业务(模拟数据库查询)中完全剥离,实现了高度的模块化和代码复用。

策略模式 (Strategy Pattern)

策略模式定义了一系列算法,将每一个算法封装起来,并使它们可以相互替换。这种模式让算法的变化独立于使用它的客户。

假设我们需要实现一个支付系统,它需要支持多种支付方式(如支付宝、微信支付)。我们可以使用策略模式来封装每种支付逻辑。

// 1. 定义策略接口
interface IPaymentStrategy {
	pay(amount: number): void
}

// 2. 实现具体的策略
class AliPayStrategy implements IPaymentStrategy {
	pay(amount: number) {
		console.log(`Paid ${amount} using AliPay.`)
	}
}

class WeChatPayStrategy implements IPaymentStrategy {
	pay(amount: number) {
		console.log(`Paid ${amount} using WeChat Pay.`)
	}
}

// 3. 创建上下文 (Context),它持有一个策略对象
class ShoppingCart {
	private paymentStrategy: IPaymentStrategy

	// 允许在运行时设置策略
	setPaymentStrategy(strategy: IPaymentStrategy) {
		this.paymentStrategy = strategy
	}

	checkout(amount: number) {
		if (!this.paymentStrategy) {
			throw new Error('Payment strategy has not been set.')
		}
		// 调用当前策略的 pay 方法
		this.paymentStrategy.pay(amount)
	}
}

// --- 使用 ---
const cart = new ShoppingCart()
const amount = 199.99

// 使用支付宝支付
cart.setPaymentStrategy(new AliPayStrategy())
cart.checkout(amount)

// 切换到微信支付
cart.setPaymentStrategy(new WeChatPayStrategy())
cart.checkout(amount)

ShoppingCart 不关心具体的支付细节,它只委托给当前的 paymentStrategy。这使得添加新的支付方式(如 CardPaymentStrategy)变得非常简单,无需修改 ShoppingCart 的任何代码。

适配器模式 (Adapter Pattern)

适配器模式是一种结构型设计模式,它的核心作用是 将一个类的接口转换成客户希望的另外一个接口。这使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。

你可以把它想象成一个电源适配器。你的笔记本电脑充电器(客户端)需要一个两孔插座(目标接口),但墙上的插座是三孔的(不兼容的接口)。电源适配器作为一个中间层,一头插进三孔插座,另一头提供一个两孔插座,从而解决了不兼容的问题。

何时使用?

  • 当你想使用一个已经存在的类,但它的接口不符合你的需求时。
  • 当你需要统一多个子类或外部库的接口时。
  • 当你无法修改需要适配的类(例如它是第三方库)时。

假设我们的应用程序内部定义了一套标准的日志接口 IAppLogger。现在,我们希望集成一个功能强大但接口完全不同的第三方日志库 ThirdPartyLogger。我们不希望为了这个库而修改应用中所有使用日志的地方,此时适配器模式就是完美的解决方案。

// 1. 目标接口 (Target): 这是我们应用程序所期望的接口
interface IAppLogger {
	log(message: string): void
	error(errorMessage: string): void
}

// 2. 需要适配的类 (Adaptee): 一个虚构的、接口不兼容的第三方日志库
class ThirdPartyLogger {
	// 注意:方法名和参数都与我们的 IAppLogger 不同
	printLog(logData: { timestamp: Date; message: string }): void {
		console.log(
			`[3rd Party] ${logData.timestamp.toISOString()}: ${logData.message}`
		)
	}

	handleError(error: Error): void {
		console.error(`[3rd Party Critical]: ${error.stack}`)
	}
}

// 3. 适配器 (Adapter): 实现目标接口,并在内部包装一个需要适配的对象
class LoggerAdapter implements IAppLogger {
	// 适配器持有一个需要被适配的对象的实例
	constructor(private thirdPartyLogger: ThirdPartyLogger) {}

	// 实现 IAppLogger 的 log 方法
	log(message: string): void {
		console.log("-> Adapter: a call to 'log' is being translated.")
		// 将调用“翻译”成 ThirdPartyLogger 的接口格式
		const logData = {
			timestamp: new Date(),
			message: message,
		}
		this.thirdPartyLogger.printLog(logData)
	}

	// 实现 IAppLogger 的 error 方法
	error(errorMessage: string): void {
		console.log("-> Adapter: a call to 'error' is being translated.")
		// 将调用“翻译”成 ThirdPartyLogger 的接口格式
		const error = new Error(errorMessage)
		this.thirdPartyLogger.handleError(error)
	}
}

// --- 客户端代码 ---
// 客户端代码只依赖于标准接口 IAppLogger,对第三方库和适配器一无所知
class ShippingService {
	constructor(private logger: IAppLogger) {}

	shipItem(itemId: string) {
		this.logger.log(`Preparing to ship item ${itemId}.`)
		// ... 业务逻辑 ...
		if (!itemId) {
			this.logger.error('Shipping failed: Item ID is null or undefined.')
			return
		}
		this.logger.log(`Item ${itemId} shipped successfully.`)
	}
}

// --- 组装和使用 ---
// 创建需要被适配的对象实例
const externalLogger = new ThirdPartyLogger()

// 创建适配器实例,将 externalLogger 包装起来
// 注意:appLogger 的类型是 IAppLogger,客户端代码看到的就是这个标准接口
const appLogger: IAppLogger = new LoggerAdapter(externalLogger)

// 将适配后的 logger 注入到客户端代码中
const shippingService = new ShippingService(appLogger)

// 执行业务逻辑
shippingService.shipItem('ABC-123')
shippingService.shipItem(null)

面向对象编程 (OOP) 与 函数式编程 (FP):两大范式的融合

TypeScript 是一门多范式语言,它并不强制开发者只使用一种编程风格。相反,它优雅地融合了面向对象编程 (OOP) 和函数式编程 (FP) 的优点,让我们可以根据具体场景选择最合适的工具。

面向对象编程 (OOP)

OOP 的核心思想是将现实世界中的事物抽象为 对象 (Objects),每个对象都包含了自身的 数据 (属性) 和可以对这些数据进行操作的 行为 (方法)。它通过封装、继承和多态等机制来组织和管理代码。

核心原则 1:封装 (Encapsulation)

封装是指将对象的数据(属性)和操作这些数据的代码(方法)捆绑在一起,并对对象的内部状态进行保护,只暴露必要的接口供外部访问。在 TypeScript 中,通常通过 class 和访问修饰符(public, private, protected)来实现。

class BankAccount {
	// private 属性,外部无法直接访问,实现了数据隐藏
	private _balance: number = 0

	constructor(initialDeposit: number) {
		this._balance = initialDeposit
	}

	// public 方法,作为外部与内部数据交互的唯一通道
	public deposit(amount: number): void {
		if (amount <= 0) {
			console.error('Deposit amount must be positive.')
			return
		}
		this._balance += amount
	}

	// public 方法
	public withdraw(amount: number): boolean {
		if (amount > this._balance) {
			console.error('Insufficient funds.')
			return false
		}
		this._balance -= amount
		return true
	}

	// public getter,提供对私有数据的只读访问
	public get balance(): number {
		return this._balance
	}
}

const account = new BankAccount(100)
// account._balance = 10000; // 错误: Property '_balance' is private.
account.deposit(50)
console.log(account.balance) // 150

核心原则 2:继承 (Inheritance)

继承允许一个类(子类)获取另一个类(父类)的属性和方法,从而实现代码复用和层次化结构。TypeScript 使用 extends 关键字来实现继承。

// 父类 (Base Class)
abstract class Animal {
	constructor(public name: string) {}

	move(distance: number): void {
		console.log(`${this.name} moved ${distance}m.`)
	}

	// 抽象方法,必须由子类实现
	abstract makeSound(): void
}

// 子类 (Derived Class)
class Dog extends Animal {
	constructor(name: string) {
		super(name) // 调用父类的构造函数
	}

	// 实现父类的抽象方法
	makeSound(): void {
		console.log('Woof! Woof!')
	}

	// 子类特有的方法
	wagTail(): void {
		console.log(`${this.name} is wagging its tail.`)
	}
}

const myDog = new Dog('Buddy')
myDog.move(10) // 继承自 Animal
myDog.makeSound() // 自己实现的
myDog.wagTail() // 自己特有的

抽象 (Abstraction) 通常与继承一起体现。Animal 类被定义为 abstract(抽象类),它定义了一个通用概念,但不能被直接实例化。它强制所有子类必须实现 makeSound 方法,确保了所有“动物”都具备这一行为,但具体实现则留给子类。

核心原则 3:多态 (Polymorphism)

多态的字面意思是“多种形态”。它允许我们使用父类类型的引用来指向子类类型的对象,并调用在子类中被重写的方法,从而在运行时表现出不同的行为。

// 接上例的 Animal 和 Dog
class Cat extends Animal {
	makeSound(): void {
		console.log('Meow!')
	}
}

// 这个函数接受任何 Animal 类型的对象,体现了多态
function triggerAnimalSound(animal: Animal): void {
	console.log(`Triggering sound for ${animal.name}:`)
	animal.makeSound() // 同一个方法调用,根据对象的实际类型产生不同行为
}

const dog: Animal = new Dog('Rex')
const cat: Animal = new Cat('Whiskers')

triggerAnimalSound(dog) // 输出: Woof! Woof!
triggerAnimalSound(cat) // 输出: Meow!

函数式编程 (FP)

FP 的核心思想是将计算过程视为数学函数的求值,并避免使用程序状态以及易变对象。它强调编写“声明式”的代码,而不是“命令式”的代码。

核心概念 1:纯函数 (Pure Functions)

纯函数是 FP 的基石。它必须满足两个条件:

  1. 相同的输入永远产生相同的输出。 (确定性)
  2. 函数执行过程中不产生任何可观察的副作用。 (无副作用),例如修改全局变量、写入文件或数据库、发起网络请求等。
// 纯函数:
function calculatePrice(base: number, taxRate: number): number {
	return base * (1 + taxRate)
}

// 非纯函数(有副作用):
let globalTax = 0.07
function calculatePriceWithSideEffect(base: number): number {
	// 副作用:依赖于外部可变状态 globalTax
	return base * (1 + globalTax)
}

纯函数易于测试、推理和并行化。

核心概念 2:不可变性 (Immutability)

不可变性意味着一个数据结构在创建之后就不能被修改。如果需要修改,应该创建一个新的数据结构,而不是在原地修改旧的。这可以有效避免意料之外的副作用。

const originalCart = [{ item: 'Laptop', price: 1200 }]

// 不可变的方式添加商品 (返回一个新数组)
function addItemImmutable(cart: any[], newItem: any): any[] {
	return [...cart, newItem] // 使用展开语法创建新数组
}

const newCart = addItemImmutable(originalCart, { item: 'Mouse', price: 25 })
console.log(originalCart) // [{ item: 'Laptop', price: 1200 }] (原始数组未被改变)
console.log(newCart) // [{...}, {...}] (这是一个新数组)

// 可变的方式 (直接修改原始数组)
function addItemMutable(cart: any[], newItem: any): void {
	cart.push(newItem)
}

核心概念 3:高阶函数 (Higher-Order Functions)

在 FP 中,函数是“一等公民”,可以像任何其他值一样被传来传去。高阶函数是指满足以下条件之一的函数:

  • 接受一个或多个函数作为参数。
  • 返回一个函数作为结果。

Array.prototype.map, filter, reduce 都是内置的高阶函数。

const numbers = [1, 2, 3, 4, 5]

// `map` 是高阶函数,它接受一个函数 (n) => n * 2 作为参数
const doubled = numbers.map((n) => n * 2) // [2, 4, 6, 8, 10]

// `createMultiplier` 是一个返回新函数的高阶函数
function createMultiplier(factor: number): (n: number) => number {
	return function (n: number): number {
		return n * factor
	}
}

const triple = createMultiplier(3)
console.log(triple(10)) // 30

核心概念 4:函数组合 (Function Composition)

函数组合是指将多个简单的函数组合成一个更复杂的函数。数据流像管道一样在一个函数序列中传递。

const compose =
	<T>(...fns: ((arg: T) => T)[]) =>
	(initialArg: T): T =>
		fns.reduceRight((acc, fn) => fn(acc), initialArg)

const toUpperCase = (s: string): string => s.toUpperCase()
const exclaim = (s: string): string => `${s}!`
const reverse = (s: string): string => s.split('').reverse().join('')

// 组合函数
// 执行顺序是 reverse -> toUpperCase -> exclaim
const shoutAndReverse = compose(exclaim, toUpperCase, reverse)

console.log(shoutAndReverse('hello world')) // "DLROW OLLEH!"```

函数响应式编程 (Functional Reactive Programming - FRP)

函数响应式编程 (FRP) 是一种结合了 函数式编程 (FP)响应式编程 (Reactive Programming) 的高级编程范式。它将系统中的一切都看作是 随时间变化的异步数据流 (Asynchronous Data Streams),并提供了一套强大的工具来创建、组合和转换这些数据流。

可以把它想象成电子表格:单元格 C1 的值被定义为 = A1 + B1。你不需要手动去计算 C1,你只需要声明它与 A1B1 的关系。当 A1B1 的值发生变化时,C1 的值会自动地、响应式地 更新。FRP 将这种强大的声明式思想带入了编程领域,尤其擅长处理复杂的用户交互、网络请求和状态管理。

在 TypeScript/JavaScript 生态中,RxJS 是实现 FRP 的标准库。

FRP 的核心概念

FRP 的世界由几个关键角色构成:

  1. 可观察对象 (Observable): 这是 FRP 的核心。一个 Observable 代表一个 可被调用 (invokable) 的、未来的值或事件的集合。它可以随着时间的推移,推送 (push) 出零个或多个值。这些值可以是任何东西:

    • 来自输入框的按键事件
    • HTTP 请求的响应
    • 定时器触发的信号
    • WebSocket 接收到的消息

    一个数据流可以正常完成,也可以因为错误而终止。

  2. 观察者 (Observer): 一个包含一组回调函数的对象,它知道如何去 响应 Observable 推送过来的值。一个标准的 Observer 至少包含三个方法:

    • next(value): 当 Observable 推送一个新的值时,此方法被调用。
    • error(err): 当 Observable 内部发生错误时,此方法被调用,并且流会就此终止。
    • complete(): 当 Observable 成功完成,不再有新的值推送时,此方法被调用。
  3. 订阅 (Subscription): 这是连接 Observable 和 Observer 的桥梁。当你调用 Observable 的 subscribe() 方法并传入一个 Observer 时,你就创建了一个订阅。这个订阅对象代表了一个正在进行的执行,最重要的是,它提供了一个 unsubscribe() 方法,用于取消订阅、中断执行并释放资源,从而防止内存泄漏。

  4. 操作符 (Operators): 这是 FRP “函数式”一面的体现。操作符是 纯函数,它们以一个 Observable 为输入,返回一个新的、经过转换的 Observable。这允许我们像搭乐高积木一样,用一种声明式的方式将复杂的异步逻辑串联起来。常见的操作符有:

    • 创建操作符: of, fromEvent, interval, ajax
    • 转换操作符: map, scan, pluck
    • 过滤操作符: filter, debounceTime, distinctUntilChanged, take
    • 组合操作符: merge, concat, combineLatest, switchMap

实践:用 RxJS 实现一个智能搜索框

实现一个搜索框,它需要满足以下需求:

  1. 当用户输入时,发起 API 请求获取建议列表。
  2. 为了避免频繁请求,我们只在用户停止输入超过 400 毫秒后才发起请求。
  3. 如果新的输入内容和上一次请求的相同,则不发起请求。
  4. 如果用户在请求还未返回时又输入了新的内容,应取消之前的请求,只处理最新的请求。

用传统的 setTimeoutXMLHttpRequest 来实现会非常复杂,容易产生竞态条件和“回调地狱”。但用 RxJS,代码会变得异常清晰:

import { fromEvent } from 'rxjs'
import {
	map,
	debounceTime,
	distinctUntilChanged,
	switchMap,
	filter,
} from 'rxjs/operators'

// 假设我们有一个模拟的 API 函数
function getSearchResults(query: string): Promise<string[]> {
	console.log(`%cAPI Request for: "${query}"`, 'color: orange')
	return new Promise((resolve) => {
		setTimeout(() => {
			resolve([`${query}-result1`, `${query}-result2`, `${query}-result3`])
		}, 800)
	})
}

// 1. 获取 DOM 元素并创建一个 Observable 数据流
const searchInput = document.getElementById('searchInput')!
const keyup$ = fromEvent(searchInput, 'keyup') // keyup$ 表示这是一个 Observable

// 2. 使用操作符 (Operators) 组合我们的逻辑
const searchResult$ = keyup$.pipe(
	// 将键盘事件 (Event) 映射 (map) 为输入框的值 (string)
	map((event) => (event.target as HTMLInputElement).value),

	// 过滤掉少于 2 个字符的输入
	filter((text) => text.length > 2),

	// 等待 400ms (debounceTime)
	debounceTime(400),

	// 只有当值确实发生变化时才继续 (distinctUntilChanged)
	distinctUntilChanged(),

	// 将输入值切换 (switchMap) 到一个新的 Observable (API 请求)
	// switchMap 会自动取消上一次未完成的请求
	switchMap((query) => getSearchResults(query))
)

// 3. 订阅 (Subscribe) 数据流并定义观察者 (Observer)
console.log('Subscribing to search results...')
const subscription = searchResult$.subscribe({
	next: (results) => {
		// 处理成功获取的结果
		console.log('%cAPI Response:', 'color: green', results)
		// 在这里更新 UI...
	},
	error: (err) => {
		// 处理错误
		console.error('An error occurred:', err)
	},
	complete: () => {
		// 这个流在这种情况下永远不会 complete
		console.log('Stream completed.')
	},
})

// 在组件销毁时,取消订阅
// setTimeout(() => subscription.unsubscribe(), 10000);

示例分析:

  • 声明式代码: 我们没有写“如何”去做(if-else, for循环, setTimeout管理),而是写了“是什么”——数据流的转换规则。整个 pipe 链描述了原始按键事件如何一步步变成最终的搜索结果。
  • 避免回调地狱: 所有的逻辑都在一个线性的管道中处理,代码扁平且易于阅读。
  • 优雅的异步控制: debounceTimedistinctUntilChanged 轻松解决了性能问题,而 switchMap 则完美地处理了复杂的竞态条件,这在传统异步编程中是很难做对的。
  • 关注点分离: 数据流的创建、转换和最终的消费(订阅)被清晰地分离开来。

FRP 是一种思维模式的转变。它提供了一套统一的、功能强大的抽象来处理任何异步或事件驱动的场景,使得开发者能够编写出更健壮、更可维护、更具表现力的代码。

建议更改

上次更新于: 2026-03-03 01:53