后端面向对象与 TypeScript
本文整合自“产研后端第一次内训-面向对象程序设计”讲义(作者:仙人掌),并补充了 TypeScript 示例。讲义中的 Flutter/Dart 代码截图用于说明代码规范;本文用 TypeScript 重写相关示例。
写代码时先注意这些
命名与注释
- 变量和函数使用
camelCase,例如playerName、getPlayer()。 - 类、接口和类型使用
PascalCase,例如Player、Animal。 - 常量通常使用全大写加下划线,例如
MAX_HP。 - 注释用来解释意图和复杂逻辑,不要逐行复述代码。普通行内说明使用
//,较长说明可以使用/* ... */或文档注释。 - 用
TODO标出尚未完成的功能,用FIXME标出已知问题,并写清具体事项;不要让它们代替真正的修复。
// TODO: 支持玩家切换武器
// FIXME: 离开房间后需要重置当前房间状态结构、日志与测试
- 尽量减少多层嵌套。多个明确的分支可以用
switch表达;把长函数拆成职责清楚的小函数。 - 一行不要塞入太多逻辑,但也不必为了短行把一个表达式拆成许多行;优先保证容易阅读。
- 调试时不要到处留下
console.log。项目中应选用日志框架,在关键流程按需要记录debug、info、warn或错误信息,并输出到合适的位置。 - 功能完成后,在有余力时为关键逻辑补充单元测试,检查实际结果是否符合预期。
npm 包管理器
JavaScript 和 TypeScript 项目常用 npm 管理依赖和运行脚本。讲义提供了两篇入门参考:npm 包管理器文章(CSDN)和知乎专栏。
面向过程与面向对象
面向过程(Procedure Oriented)关注解决问题的步骤:先把任务拆成函数,再按顺序调用这些函数。它开销小、执行直接,适合流程清楚、性能要求高的场景。
面向对象(Object Oriented)把问题中的实体抽象成对象,由对象保存状态并提供行为。它适合围绕业务实体组织较大的程序,便于分工和扩展。使用哪种方式要看问题本身;面向对象并不会自动让程序更快或更简单。
设计面向对象程序时,通常先找出需要操作的对象,再明确对象的属性、行为,以及对象之间的关系(也叫数据建模)。复杂业务中也可以先用 UML 图梳理角色和关系。
类、对象与实例
类描述一类对象共有的属性和方法;根据类创建出的具体对象叫实例。下面用汽车举例:
class Car {
engine: string
constructor(engine: string) {
this.engine = engine
}
display(): void {
console.log(`发动机为:${this.engine}`)
}
}
const car = new Car('Engine 1')
car.display()engine 是属性,display 是方法,new Car(...) 创建了一个 Car 实例。
继承与方法重写
继承表示一个类在已有类的基础上扩展能力。已有类叫父类或基类,新类叫子类或派生类。TypeScript 使用 extends 声明继承;子类可以调用 super 访问父类构造函数或方法,也可以重写方法:
class Printer {
print(): void {
console.log('父类的 print 方法')
}
}
class StringPrinter extends Printer {
override print(): void {
super.print()
console.log('子类的 print 方法')
}
}
const printer: Printer = new StringPrinter()
printer.print()继承适合表达明确的“是一种”关系。若只是想共享少量能力,接口或组合通常更灵活。
封装与访问控制
封装把对象的状态和操作集中在类中,并限制外部随意改动内部状态。外部通过公开方法与对象交互,类可以在这些方法中检查输入并维持自身状态的有效性。
TypeScript 常用以下访问控制修饰符:
public:公开访问;默认访问级别。protected:类自身和子类可以访问。private:只允许定义它的类访问。
class Student {
private grade = 0
constructor(public readonly name: string) {}
setGrade(value: number): void {
if (value < 0 || value > 100) {
throw new Error('成绩必须在 0 到 100 之间')
}
this.grade = value
}
getGrade(): number {
return this.grade
}
}
const student = new Student('小明')
student.setGrade(90)
console.log(student.getGrade())
// student.grade = 101 // 编译错误:grade 是 privatereadonly 属性只能在声明时或构造函数中赋值,之后不能重新赋值。接口中也可以声明只读属性和可选属性,见下文。
多态
多态指不同对象可以响应同一个操作,但各自给出不同实现。调用方使用共同的接口,不必知道对象内部的具体类型:
interface Notifier {
send(message: string): void
}
class ConsoleNotifier implements Notifier {
send(message: string): void {
console.log(message)
}
}
function notify(notifier: Notifier, message: string): void {
notifier.send(message)
}
notify(new ConsoleNotifier(), '任务完成')同一个 send 调用可以由不同通知方式实现,例如控制台、邮件或其他服务。
TypeScript 接口
TypeScript 的 interface 描述对象应具有的属性、方法及其类型。它能检查对象形状是否符合约定,让错误更早出现在编译阶段。接口本身不会生成运行时对象。
描述对象结构
interface User {
id: number
name: string
email: string
}
const user: User = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
}User 规定对象要包含 id、name 和 email,并且类型要相符。
约束类的实现
类使用 implements 表示自己遵守某个接口。编译器会检查类是否提供了接口要求的属性和方法:
interface Animal {
name: string
makeSound(): void
}
class Dog implements Animal {
constructor(public name: string) {}
makeSound(): void {
console.log('Woof!')
}
}
const dog = new Dog('Buddy')
dog.makeSound()描述函数类型
接口也可以约定函数的参数和返回值:
interface Greet {
(name: string): string
}
const sayHello: Greet = (name) => `Hello, ${name}`
console.log(sayHello('Alice'))结构类型与鸭子类型
TypeScript 主要按结构判断类型:对象只要具有所需的属性和方法,就可以传给相应函数,不要求它必须继承某个特定类。这种方式也称为鸭子类型。
interface Point {
x: number
y: number
}
function addPoints(p1: Point, p2: Point): Point {
return { x: p1.x + p2.x, y: p1.y + p2.y }
}
const result = addPoints({ x: 3, y: 4 }, { x: 5, y: 1 })
// addPoints({ x: 1 }, { x: 4, y: 3 }) // 编译错误:缺少 y可选属性与只读属性
在属性名后加 ? 表示该属性可以省略;加 readonly 表示初始化后不能重新赋值:
interface Vehicle {
readonly make: string
model: string
year?: number
}
const vehicle: Vehicle = { make: 'Toyota', model: 'Corolla' }
vehicle.model = 'Camry' // 可以修改
// vehicle.make = 'Honda' // 编译错误:make 是只读属性接口继承
接口可以通过 extends 组合。子接口会包含父接口的要求,并能添加自己的属性:
interface Person {
name: string
}
interface Employee extends Person {
employeeId: number
}
const employee: Employee = { name: 'Alice', employeeId: 12345 }为第三方 JavaScript 库补充类型
有些 JavaScript 库没有内置 TypeScript 类型。可以通过类型声明描述库提供的对象和方法,让 TypeScript 代码获得类型检查。讲义将其列为扩展了解内容;实际使用时优先查找该库维护者提供的类型声明。
面向对象的主要特性
- 封装(Encapsulation):把数据和操作集中在对象中,通过明确的接口访问状态,避免外部代码随意改动内部实现。
- 继承(Inheritance):让子类沿用并扩展父类的能力,适用于明确的类型层级。
- 多态(Polymorphism):不同实现通过同一接口提供相同操作,调用方不必依赖具体实现。
面向对象设计原则:SOLID
这些原则帮助代码保持职责清楚、便于扩展。它们是设计时的参考,不是每段代码都必须强行套用的规则。
- 单一职责原则(SRP, Single Responsibility Principle):一个类或模块聚焦一项职责,避免一个类包办许多互不相关的功能。
- 开闭原则(OCP, Open-Closed Principle):对扩展开放、对修改关闭。新增客户端能力时,尽量通过抽象扩展,而不是反复改动稳定的服务端实现。
- 里氏替换原则(LSP, Liskov Substitution Principle):子类应能替换父类使用,并遵守父类对行为的约定。例如,若所有员工都能参加年会抽奖,新员工也应遵守同一规则。
- 依赖倒置原则(DIP, Dependency Inversion Principle):高层业务依赖抽象,具体实现依赖同一抽象;不要让核心逻辑直接绑定某个底层实现。
- 接口隔离原则(ISP, Interface Segregation Principle):调用方不应依赖用不到的接口。把大而全的接口拆成调用方真正需要的小接口。
综合练习
下面两个作业来自讲义。重点是练习对象建模、接口、继承和设计原则;命令和输出可以自行调整。
T1:龙与魔法(基础)
用 TypeScript 实现一个简单的战斗规则。讲义认为此题的设计空间较大,建议有余力的同学挑战 T2。
角色与规则:
- 玩家可以是战士(
Fighter)、法师(Mage)或龙骑士(Dragoon)。 - 怪物可以是兽人(
Orc)、精灵(Elf)或龙(Dragon);怪物有血量。 - 战士受到兽人攻击时,伤害减半。
- 精灵受到法师攻击时,伤害减半。
- 主程序创建若干玩家和怪物,让双方可以互相攻击,并能观察攻击后的血量等结果。
基础要求:代码风格清楚、注释有用,并能查看攻击后的结果。
选做扩展:
- 给玩家增加护甲,不同类型的护甲提供不同效果。
- 增加武器,例如剑(
Sword)和法杖(Staff);玩家可装备或切换武器。武器有攻击力和伤害类型,例如物理或火焰,也可以设计更细的类型。 - 增加规则:兽人受到物理攻击时伤害减半;精灵受到冰属性攻击时伤害减半;龙免疫物理和魔法攻击,但龙骑士攻击龙时可以造成双倍伤害。
- 使用日志框架记录战斗过程并写入文件。
T2:Adventure 命令行游戏(进阶)
实现一个命令行冒险游戏。玩家要探索城堡中的房间,找到被关押的公主,并带她离开城堡。可以有多个楼层和多个房间;每种房间有自己的出口。
游戏规则:
-
玩家从大厅开始。程序显示当前房间名称、出口数量和出口名称,例如:
Welcome to the lobby. There are 3 exits: east, west and up. Enter your command: -
玩家通过
go east一类的命令选择出口,进入相邻房间后,程序继续显示该房间的信息。 -
城堡中有多个房间,其中一间有怪物,怪物的具体位置未知。玩家进入怪物所在房间时,游戏结束。
-
公主在一间秘密房间里。玩家找到她后会看到对话,并与她一起离开。
-
离开城堡的唯一出口是大厅;找到公主后,玩家还需要找到返回大厅的路线。
-
输入和输出都使用英文。
基础要求:代码风格清楚、注释有用;至少有 3 种不同房间,房间总数至少为 5;公主和怪物的位置每次随机决定。
选做扩展:房间总数由用户输入或配置文件决定;使用日志框架并输出到文件;支持多层城堡。
原讲义参考链接
学习衔接
先学习TypeScript 基础,再用这些概念练习业务建模,然后学习HTTP、API 与爬虫;需求和对象关系可参考UML 建模。