设计模式
设计模式是软件开发中经过反复验证的、用于解决特定问题的最佳实践。在 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 的基石。它必须满足两个条件:
- 相同的输入永远产生相同的输出。 (确定性)
- 函数执行过程中不产生任何可观察的副作用。 (无副作用),例如修改全局变量、写入文件或数据库、发起网络请求等。
// 纯函数:
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,你只需要声明它与 A1 和 B1 的关系。当 A1 或 B1 的值发生变化时,C1
的值会自动地、响应式地
更新。FRP 将这种强大的声明式思想带入了编程领域,尤其擅长处理复杂的用户交互、网络请求和状态管理。
在 TypeScript/JavaScript 生态中,RxJS 是实现 FRP 的标准库。
FRP 的核心概念
FRP 的世界由几个关键角色构成:
-
可观察对象 (Observable): 这是 FRP 的核心。一个 Observable 代表一个 可被调用 (invokable) 的、未来的值或事件的集合。它可以随着时间的推移,推送 (push) 出零个或多个值。这些值可以是任何东西:
- 来自输入框的按键事件
- HTTP 请求的响应
- 定时器触发的信号
- WebSocket 接收到的消息
一个数据流可以正常完成,也可以因为错误而终止。
-
观察者 (Observer): 一个包含一组回调函数的对象,它知道如何去 响应 Observable 推送过来的值。一个标准的 Observer 至少包含三个方法:
next(value): 当 Observable 推送一个新的值时,此方法被调用。error(err): 当 Observable 内部发生错误时,此方法被调用,并且流会就此终止。complete(): 当 Observable 成功完成,不再有新的值推送时,此方法被调用。
-
订阅 (Subscription): 这是连接 Observable 和 Observer 的桥梁。当你调用 Observable 的
subscribe()方法并传入一个 Observer 时,你就创建了一个订阅。这个订阅对象代表了一个正在进行的执行,最重要的是,它提供了一个unsubscribe()方法,用于取消订阅、中断执行并释放资源,从而防止内存泄漏。 -
操作符 (Operators): 这是 FRP “函数式”一面的体现。操作符是 纯函数,它们以一个 Observable 为输入,返回一个新的、经过转换的 Observable。这允许我们像搭乐高积木一样,用一种声明式的方式将复杂的异步逻辑串联起来。常见的操作符有:
- 创建操作符:
of,fromEvent,interval,ajax - 转换操作符:
map,scan,pluck - 过滤操作符:
filter,debounceTime,distinctUntilChanged,take - 组合操作符:
merge,concat,combineLatest,switchMap
- 创建操作符:
实践:用 RxJS 实现一个智能搜索框
实现一个搜索框,它需要满足以下需求:
- 当用户输入时,发起 API 请求获取建议列表。
- 为了避免频繁请求,我们只在用户停止输入超过 400 毫秒后才发起请求。
- 如果新的输入内容和上一次请求的相同,则不发起请求。
- 如果用户在请求还未返回时又输入了新的内容,应取消之前的请求,只处理最新的请求。
用传统的 setTimeout 和 XMLHttpRequest
来实现会非常复杂,容易产生竞态条件和“回调地狱”。但用 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链描述了原始按键事件如何一步步变成最终的搜索结果。 - 避免回调地狱: 所有的逻辑都在一个线性的管道中处理,代码扁平且易于阅读。
- 优雅的异步控制:
debounceTime和distinctUntilChanged轻松解决了性能问题,而switchMap则完美地处理了复杂的竞态条件,这在传统异步编程中是很难做对的。 - 关注点分离: 数据流的创建、转换和最终的消费(订阅)被清晰地分离开来。
FRP 是一种思维模式的转变。它提供了一套统一的、功能强大的抽象来处理任何异步或事件驱动的场景,使得开发者能够编写出更健壮、更可维护、更具表现力的代码。
上次更新于: 2025-12-25 07:43