Эта история является частью серии, которая была начата здесь:
День 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 сияет больше всего, а именно «Типы». Если вы хотите получать уведомления, просто подпишитесь.
Спасибо за чтение!