🚑 ES Class 相关
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
在阅读这篇关于 ES Class 的文章前,希望你能理解:
- Class 是语法糖 - ES Class 本质上是 prototype 的语法糖,不是全新的概念
- 执行环境的差异 - 不同环境下 Class 的表现可能有所不同
- 继承机制的本质 - 理解原型链和构造函数的执行顺序
- 静态 vs 实例 - 明确静态方法和实例方法的区别和使用场景
- 私有成员的重要性 - 私有字段和方法如何提升代码安全性
- TypeScript 的增强 - TS 在 Class 上增加了类型注解、访问修饰符、抽象类等能力,但最终编译产物还 是 ES Class
想分享的是让你知道 ES Class 的不同标准和实现,虽然感觉很符合直觉,但是仍然从本质上不同的地方。
class 的历史变革算是很坎坷,但是并没有什么意思,class 也更多的是一种让大部分人都很容易理解的规范而已
。包括各种 prototype 封装、语法糖、Hack 之类的讨论也都并不是面向业务和普适的工程化的。
这里更想解释下各种运行开发环境下习以为常的用法,有哪些反直觉或者说不一样的,当然仅仅对于我来说。
后来工作中大量使用了 TypeScript,发现 TS 对 Class 做了很多增强。这篇文章也顺势加入了 TS 相关的内容 ,毕竟现在写 JS 不带 TS 已经很少见了。
虽然看起来相似,但两者有重要区别:
// 类声明(提升)Rectangle // 可以访问(hoisted)class Rectangle {constructor(height, width) {this.height = heightthis.width = width}}// 类表达式(不提升)let Square = class {constructor(side) {this.side = side}}Square // 可以访问RectangleClass // ReferenceError: Cannot access 'RectangleClass' before initialization
在实际项目中,类声明更容易维护,而类表达式在需要动态创建类时很有用。
class Rectangle {constructor(height, width) {this.height = heightthis.width = width}}
一般类里面的关键词有:属性、方法,静态方法。
ES2022 正式引入了私有字段(
# 前缀),之前大家只能靠命名约定(_xxx)来"假装"私有:class User {#password = '123456'; // 私有字段,外部无法访问constructor(name) {this.name = name; // 公共字段}#validate(pwd) { // 私有方法return pwd === this.#password;}login(pwd) {return this.#validate(pwd);}}const u = new User('ubug');u.#password; // SyntaxError: Private field '#password' must be declared in an enclosing class
注意
# 私有字段和 private 关键字不一样,# 是语言层面的实现,不是 TypeScript 特有的。class A {method() {//}method2 = function() {// Unexpected token =// fixed by @babel/plugin-proposal-class-properties// Class field declarations}}A.prototype.method3 = function() {//}
method2 是属性,引用了一个函数;method 和 method3 都是实例方法。TS 里写 Class 最直观的区别就是属性要声明类型:
class Rectangle {width: numberheight: numberconstructor(width: number, height: number) {this.width = widththis.height = height}getArea(): number {return this.width * this.height}}
参数属性(Parameter Properties)是个很省事的写法,构造函数参数加修饰符就能自动声明并赋值:
class Rectangle {constructor(public width: number, public height: number) {}getArea(): number {return this.width * this.height}}
少写几行代码,对于 DTO 之类的类特别好用。
TypeScript 提供了三个访问修饰符:
public、private、protected。class Animal {public name: string // 公开,默认就是这个private age: number // 私有,只有类内部能访问protected species: string // 受保护,子类能访问constructor(name: string, age: number, species: string) {this.name = namethis.age = agethis.species = species}}class Dog extends Animal {constructor(name: string, age: string) {super(name, age, 'Canis lupus familiaris')}getInfo(): string {return `${this.name} (${this.species})` // 可以访问 protected,不能访问 private}}
这里有个容易混淆的点:TS 的
private 和 ES2022 的 # 是两套东西。- TS 的
private是编译期检查,编译后消失,运行时跟普通属性没区别 #是 ES 语言标准,运行时强制私有
class Foo {private bar = 1;#baz = 2;}// 编译为 JS 后:class Foo {constructor() {this.bar = 1; // 运行时完全可访问this.#baz = 2; // 运行时真的私有}}
实际项目中怎么选?说实话大多数时候用 TS 的
private 就够了,毕竟类型检查已经在编译期帮你兜底了,足够用了。class Config {readonly env: stringreadonly version: stringconstructor(env: string, version: string) {this.env = envthis.version = version}}const cfg = new Config('production', '1.0.0')cfg.env = 'dev' // TS Error: Cannot assign to 'env' because it is a read-only property
也可以和参数属性组合使用:
class Config {constructor(public readonly env: string, public readonly version: string) {}}
class Animal {constructor(name) {this.name = name}speak() {console.log(`${this.name} makes a noise.`)}}class Dog extends Animal {speak() {console.log(`${this.name} barks.`)}}
继承的要点:
- 子类的
constructor里必须先调用super()才能使用this extends不仅能继承类,还能继承"具有构造签名的对象"(TS 中)
TS 在继承这块主要增加了类型约束,让父子类的关系更明确:
class Animal {constructor(public name: string) {}speak(): string {return `${this.name} makes a noise.`}}class Dog extends Animal {constructor(name: string, public breed: string) {super(name)}speak(): string {return `${this.name} barks.`}}const d = new Dog('Buddy', 'Golden Retriever')d.speak() // "Buddy barks."d.breed // "Golden Retriever"d.name // "Buddy" — 继承自 Animal
TS 还允许继承内置类型,这在 ES 中是做不到类型安全的:
class MyArray<T> extends Array<T> {first(): T | undefined {return this[0]}last(): T | undefined {return this[this.length - 1]}}const arr = new MyArray<number>(1, 2, 3)arr.first() // 1arr.last() // 3
子类覆盖父类方法时,TS 的规则比 ES 严格得多:
class Base {greet(name: string): string {return `Hello, ${name}`}log(): void {console.log('logging')}}class Derived extends Base {// 参数类型必须兼容(可以更宽松,但不能更严格)greet(name: string | number): string {return `Hello, ${name}`}// 返回类型也必须兼容(可以更具体,但不能更宽泛)// log(): number { return 1; } // Error: 类型不兼容}
简单来说就是里氏替换原则(LSP):凡是能用父类的地方,子类应该也能用,不能"破坏契约"。
prototype properties arrow functions are not present on the object's prototype, they are merely
properties holding a reference to a function.
在 Class 中
this 丢失是经典问题:class Button {constructor(label) {this.label = label}handleClick() {console.log(`Clicked: ${this.label}`)}render() {const btn = document.createElement('button')btn.textContent = this.labelbtn.addEventListener('click', this.handleClick) // this 会丢失!return btn}}
常见解法:
// 方案一:构造函数里 bindconstructor(label) {this.label = label;this.handleClick = this.handleClick.bind(this);}// 方案二:箭头函数属性(Class Fields)handleClick = () => {console.log(`Clicked: ${this.label}`);};// 方案三:事件监听时包装btn.addEventListener('click', () => this.handleClick());
TS 对
this 有专门的类型系统支持:class Handler {info: stringconstructor(info: string) {this.info = info}// this 参数:确保方法只在正确的对象上被调用greet(this: {info: string}): string {return `Hello, ${this.info}`}}const h = new Handler('world')h.greet() // OK// 解构后 this 丢失,TS 能检查出来const {greet} = hgreet() // TS Error: The 'this' context of type 'void' is not assignable to method's 'this'
this 类型在链式调用中特别好用:class QueryBuilder {private query = ''where(condition: string): this {this.query += ` WHERE ${condition}`return this}orderBy(field: string): this {this.query += ` ORDER BY ${field}`return this}build(): string {return this.query}}const q = new QueryBuilder().where('age > 18').orderBy('name').build()
返回
this 而不是具体类名的好处是:子类继承后链式调用返回的还是子类类型,类型不会丢失。这是 TypeScript 独有的,ES 标准没有抽象类:
abstract class Shape {abstract getArea(): number // 子类必须实现describe(): string {return `This shape has an area of ${this.getArea()}`}}class Circle extends Shape {constructor(public radius: number) {super()}getArea(): number {return Math.PI * this.radius ** 2}}// const s = new Shape(); // TS Error: 不能实例化抽象类const c = new Circle(5)c.describe() // "This shape has an area of 78.53981633974483"
抽象类和接口的区别:
- 抽象类可以有实现,接口不能
- 一个类只能继承一个抽象类,但可以实现多个接口
- 抽象类适合"模板方法"模式,接口适合定义"契约"
类可以实现一个或多个接口:
interface Printable {print(): void}interface Loggable {log(message: string): void}class Report implements Printable, Loggable {constructor(public title: string) {}print(): void {console.log(`Printing: ${this.title}`)}log(message: string): void {console.log(`[${this.title}] ${message}`)}}
注意 TS 的
implements 只做编译期检查,不像 Java 那样有运行时的 interface 机制。编译后 JS 里
implements 完全消失。class DataStore<T> {private items: T[] = []add(item: T): void {this.items.push(item)}get(index: number): T | undefined {return this.items[index]}getAll(): T[] {return [...this.items]}}const numberStore = new DataStore<number>()numberStore.add(1)numberStore.add('hello') // TS Error: 类型不匹配const stringStore = new DataStore<string>()stringStore.add('hello')
泛型类在写工具库和基础组件时特别有用,可以避免写一堆
any。回顾一下,ES Class 本质是 prototype 的语法糖,而 TypeScript 在此基础上增加了类型系统、访问修饰符、抽象类、接口实现、泛型等能力。但归根结底,TS 编译后还是产出 ES Class 的代码。
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/es-class)