Эта история является частью серии, которая была начата здесь:

День 0 — «Как запустить TypeScript»

Если вы хотите следовать всем примерам кода, вы должны обратиться к этому, чтобы найти некоторые рекомендации о том, как быстро настроить небольшой проект TypeScript.

Мы продолжаем непосредственно нашу последнюю историю, которая предоставила первую часть предмета о классах. Вы можете найти его здесь, если пропустили.

Общие классы:

Общие классы содержат заполнители для типов в своем объявлении:

class A<T> {
  private _value: T;
  get value(): T { return this._value };
constructor(value: T) {
    this._value = value;
  }
add(anotherValue: T): A<T> {
    if (['number', 'string'].includes(typeof this._value)) {
      this._value = (this._value as any + anotherValue as any) as T;
    } else if (Array.isArray(this._value)) {
      this._value.push(...(anotherValue as any));
    } else {
      throw Error('not supported type');
    }
    return this;
  }
}

Заполнитель для типа появляется рядом с именем класса: A<T> Таким образом, мы можем использовать T во всем объявлении, например, для полей ( private _value: T; ) или методов ( add(anotherValue: T): A<T> ).

В приведенном выше примере объявляется класс с методом add, способным работать с типами string, number или Array.

Мы можем использовать этот метод, как показано здесь:

const a = new A<string>('blue');
a.add(' sea');
console.log(a.value); // 'blue sea'
const b = new A<number>(1);
b.add(2).add(3);
console.log(b.value);  // 6
const c = new A<Array<number>>([0]);
c.add([1]).add([2, 3]);
console.log(c.value); // [0, 1, 2, 3]

Также обратите внимание, что компилятор делает необходимые шаги и заменяет T конкретным значением. Таким образом, add для класса A<string> требует string в качестве аргумента.

Вы должны знать, что все заполнители универсального типа удаляются после компиляции — нет никакого способа получить к ним доступ во время выполнения.

Абстрактные классы:

Возможно, вы слышали о так называемом «шаблоне шаблона», который часто используется для надежного структурирования кода и предотвращения дублирования кода. Абстрактные классы являются хорошим строительным блоком в таких конструкциях. Давайте посмотрим пример:

abstract class ValidationService<T>{
  protected abstract name: string;
  protected abstract validate(v: T): string | undefined;
runValidations(vs: T[]) {
    const issues = vs.map(v => this.validate(v)).filter(s => !!s);
    if (issues.length > 0) {
      console.log(`Validator '${this.name}' returns ${issues}`);
    }
  }
}

Класс объявляется с помощью ключевого слова abstract. Это позволяет классу содержать нереализованные методы и поля:

protected abstract name: string;
protected abstract validate(v: T): string | undefined;

Абстрактный класс никогда не может быть инициализирован, он может быть только расширен другим классом, который в свою очередь может быть инициализирован. Удача вышеупомянутого абстрактного класса заключается в том, что он диктует, какие методы и поля они должны реализовать как минимум.

В конкретном случае реализация name и validate гарантирует, что метод runValidations будет работать для каждого дочернего класса. Более того, этот метод легко разделяется этим шаблоном.

Возможные реализации могут выглядеть так:

class StringValidationService extends ValidationService<string>{
  protected name = 'String';
  protected validate(v: string): string | undefined {
    if ((v ?? '').length === 0) {
      return 'empty string';
    }
  }
}
class NumberValidationService extends ValidationService<number>{
  protected name = 'Number';
  protected validate(v: number): string | undefined {
    if (v < 0) {
      return 'negative number';
    }
  }
}

Оба обеспечиваются только минимальной необходимой реализацией. Компилятор проверяет это! Затем мы можем инициализировать и использовать его, как здесь:

const sv = new StringValidationService();
sv.runValidations(['a', 'b', '', 'd']);  // Validator 'String' returns empty string
const nv = new NumberValidationService();
nv.runValidations([1, 2, 3, -4]); // Validator 'Number' returns negative number

Одно слово осторожности: таким красивым и полезным может показаться этот шаблон, постарайтесь использовать его с здравым смыслом. Если вы пойдете глубже, чем на один уровень наследования, код быстро станет трудным для понимания и поддержки.

Символические поля:

В объявлении класса вы можете иметь динамические имена для полей и методов. Так например

const field = 'prop';
const func = 'fn'
class A {
  [field] = 'some value';
  [func]() {
    console.log('function called');
  };
}

объявляет класс с полем prop и методом fn. Оба имени определены как постоянные переменные. Обратите внимание, им должно быть const. Доступ к этому полю и методу можно получить, как обычно:

const a = new A();
console.log(a.prop);  // 'some value'
a.fn();  // 'function called'

То же самое работает и с символами. Это дает возможность контролировать доступ к свойствам класса очень определенным образом:

const field = Symbol();
const func = Symbol();
class A {
  [field] = 'some value';
  [func]() {
    console.log('function called');
  };
}
const a = new A();
console.log(a[field]);  // 'some value'
a[func]();  // 'function called'

Например, вы можете создать функцию, которая на основе роли пользователя возвращает все символы, для которых у пользователя достаточно высокая авторизация.

Миксины:

Миксин — это скорее объектно-ориентированный паттерн, чем фича. Но функция TypeScript позволяет создавать примеси. Читабельное определение этого паттерна вы можете найти здесь.

На самом деле мы уже использовали примесь в одном из наших примеров декораторов (см. здесь). Основная идея состоит в том, чтобы иметь возможность динамически наследоваться от класса во время выполнения. Давайте посмотрим на пример:

Для удобства мы определили псевдоним типа ConstructorType в первой строке. Этот тип нам уже известен, и он определяет функцию-конструктор. registry — это просто карта от функций конструктора к экземплярам и служит примером приложения. Интересная часть — это метод service. Это принимает функцию-конструктор Fn и возвращает класс, который динамически расширяет последний. Более того, он поставляется с собственным конструктором, который вызывает внутренний метод для регистрации экземпляра в registry.

Вы можете распознать шаблон миксина в том, что класс Fn используется в качестве родительского класса во время выполнения для объявления другого класса.

Мы можем проверить это, запустив:

Обратите внимание, что конструктор A — это конструктор, с помощью которого экземпляр B разрешается в реестре. Это очень полезный паттерн, который часто применяется во фреймворках.

Чтобы завершить приведенный выше пример, мы можем использовать слегка измененную версию service в качестве декоратора класса (здесь без фабрики):

Опять же, это регистрирует A {} на вашей консоли. Обратите внимание, как промежуточный класс B был перемещен в определение декоратора, что сделало код очень читабельным. Интересно, что Fn больше не используется в качестве ключа в реестре, а вместо него используется расширенный класс B: registry.set(B, this)

Помните из главы о классах-декораторах (см. здесь), что класс, возвращаемый классом-декоратором, заменяет объявление декорированного класса. Это означает, что A после декорирования больше не A, а расширенный класс миксина B. Поэтому мы должны использовать B в качестве ключа, а не Fn.

На этом наше путешествие по классам заканчивается. Несмотря на то, что в последнее время классы не получили должного признания из-за чрезмерного использования наследования, что привело к созданию сложного кода, они по-прежнему обеспечивают хороший способ структурирования кодовой базы.

Далее мы рассмотрим главу, в которой TypeScript сияет больше всего, а именно «Типы». Если вы хотите получать уведомления, просто подпишитесь.

Спасибо за чтение!