Возможности JavaScript и TypeScript последних лет. Часть 2

от автора

Hello, world!

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

В первой части мы говорили о возможностях JS, во второй поговорим о возможностях TS.

Это вторая часть.

Вот ссылка на первую часть.

Обратите внимание: названия многих возможностей — это также ссылки на соответствующие разделы документации TypeScript.

Руководства, шпаргалки, вопросы и другие материалы по JavaScript, TypeScript, React, Next.js, Node.js, Express, Prisma, GraphQL, Docker и другим технологиям, а также Блог по веб-разработке.

TypeScript

Основы (контекст для дальнейшего изложения)

Дженерики / Generics: позволяют определять (передавать) параметры типов (type parameters). Это позволяет типам быть одновременно общими и типобезопасными (typesafe). Дженерики следует использовать вместо any или unknown везде, где это возможно.

// Без дженериков: function getFirstUnsafe(list: any[]): any {   return list[0]; }  const firstUnsafe = getFirstUnsafe(['test']); // any  // С дженериками: function getFirst<Type>(list: Type[]): Type {   return list[0]; }  const first = getFirst<string>(['test']); // string  // В данном случае параметр типа может быть опущен, поскольку тип автоматически выводится (inferred) из аргумента const firstInferred = getFirst(['test']); // string  // Параметр типа может ограничиваться с помощью ключевого слова `extends` class List<T extends string | number> {   private list: T[] = [];    get(key: number): T {     return this.list[key];   }    push(value: T): void {     this.list.push(value);   } }  const list = new List<string>(); list.push(9); // TypeError: Argument of type 'number' is not assignable to parameter of type 'string'. const booleanList = new List<boolean>(); // TypeError: Type 'boolean' does not satisfy the constraint 'string | number'.

До TS4 (возможности, о которых многие не знают)

Утилиты типов / Utility types: позволяют легко создавать типы на основе других типов.

interface Test {   name: string;   age: number; }  // `Partial` делает все свойства опциональными type TestPartial = Partial<Test>; // { name?: string | undefined; age?: number | undefined; }  // `Required` делает все свойства обязательными type TestRequired = Required<TestPartial>; // { name: string; age: number; }  // `Readonly` делает все свойства доступными только для чтения type TestReadonly = Readonly<Test>; // { readonly name: string; readonly age: string }  // `Record` облегчает типизацию объектов. Является более предпочтительным способом, чем использование сигнатур доступа по индексу (index signatures) const config: Record<string, boolean> = { option: false, anotherOption: true };  // `Pick` извлекает указанные свойства type TestLess = Pick<Test, 'name'>; // { name: string; } type TestBoth = Pick<Test, 'name' | 'age'>; // { name: string; age: string; }  // `Omit` игнорирует указанные свойства type TestFewer = Omit<Test, 'name'>; // { age: string; } type TestNone = Omit<Test, 'name' | 'age'>; // {}  // `Parameters` извлекает типы параметров функции function doSmth(value: string, anotherValue: number): string {   return 'test'; } type Params = Parameters<typeof doSmth>; // [value: string, anotherValue: number]  // `ReturnType` извлекает тип значения, возвращаемого функцией type Return = ReturnType<typeof doSmth>; // string  // Существует много других утилит

Условные типы / Conditional types: позволяют определять типы условно на основе совпадения/расширения других типов. Читаются как тернарные операторы в JS.

// Извлекает тип из массива или возвращает переданный тип type Flatten<T> = T extends any[] ? T[number] : T;  // Извлекает тип элемента type Str = Flatten<string[]>; //string  // Возвращает сам тип type Num = Flatten<number>; // number

Вывод типов с помощью условных типов: некоторые дженерики могут быть выведены на основе кода. Для реализации условий на основе выводимых типов используется ключевое слово extends. Оно позволяет определять временные (temporary) типы:

// Перепишем последний пример type FlattenOld<T> = T extends any[] ? T[number] : T;  // Вместо индексации массива, мы можем просто вывести из него тип `Item` type Flatten<T> = T extends (infer Item)[] ? Item : T;  // Что если мы хотим написать тип, извлекающий тип, возвращаемый функцией, или `undefined`? type GetReturnType<Type> = Type extends (...args: any[]) => infer Return ? Return : undefined;  type Num = GetReturnType<() => number>; // number  type Str = GetReturnType<(x: string) => string>; // string  type Bools = GetReturnType<(a: boolean, b: boolean) => void>; // undefined

Необязательные и прочие (rest) элементы кортежа: опциональные элементы кортежа обозначаются с помощью ?, прочие — с помощью ...:.

// Предположим, что длина кортежа может быть от 1 до 3 const list: [number, number?, boolean?] = []; list[0] // number list[1] // number | undefined list[2] // boolean | undefined list[3] // TypeError: Tuple type '[number, (number | undefined)?, (boolean | undefined)?]' of length '3' has no element at index '3'.  // Кортежи можно создавать на основе других типов // Оператор `rest` можно использовать, например, для добавления элемента определенного типа в начало массива function padStart<T extends any[]>(arr: T, pad: string): [string, ...T] {   return [pad, ...arr]; }  const padded = padStart([1, 2], 'test'); // [string, number, number]

Абстрактные классы / Abstract classes: абстрактные классы и абстрактные методы классов обозначаются с помощью ключевого слова abstract. Такие классы (методы) не могут инстанцироваться напрямую.

abstract class Animal {   abstract makeSound(): void;    move(): void {     console.log('Гуляет...');   } }  // Абстрактные методы должны быть реализованы при расширении класса class Cat extends Animal {} // CompileError: Non-abstract class 'Cat' does not implement inherited abstract member 'makeSound' from class 'Animal'  class Dog extends Animal {   makeSound() {     console.log('Гав!');   } }  // Абстрактные классы не могут инстанцироваться (как интерфейсы), а абстрактные методы не могут вызываться напрямую new Animal(); // CompileError: Cannot create an instance of an abstract class  const dog = new Dog().makeSound(); // Гав!

Сигнатуры конструктора / Construct signatures: позволяют определять типы конструкторов классов за пределами классов. В большинстве случаев вместо сигнатур конструкторов используются абстрактные классы.

interface MyInterface {   name: string; }  interface ConstructsMyInterface {   new(name: string): MyInterface; }  class Test implements MyInterface {   name: string;   constructor(name: string) {     this.name = name;   } }  class AnotherTest {   age: number; }  function makeObj(n: ConstructsMyInterface) {     return new n('hello!'); }  const obj = makeObj(Test); // Test const anotherObj = makeObj(AnotherTest); // TypeError: Argument of type 'typeof AnotherTest' is not assignable to parameter of type 'ConstructsMyInterface'.

Утилита типа ConstructorParameters: извлекает типы параметров конструктора класса (но не тип самого класса).

interface MyInterface {   name: string; }  interface ConstructsMyInterface {   new(name: string): MyInterface; }  class Test implements MyInterface {   name: string;   constructor(name: string) {     this.name = name;   } }  function makeObj(test: ConstructsMyInterface, ...args: ConstructorParameters<ConstructsMyInterface>) {   return new test(...args); }  makeObj(Test); // TypeError: Expected 2 arguments, but got 1. const obj = makeObj(Test, 'test'); // Test

TS4.0

Типы вариативных кортежей / Variadic tuple types: прочие (rest) элементы кортежей могут быть общими (generic). Разрешается использование нескольких прочих элементов.

// Что если нам нужна функция, комбинирующая 2 кортежа неизвестной длины? // Как определить возвращаемый тип?  // Раньше: // Приходилось писать перегрузки (overloads) declare function concat(arr1: [], arr2: []): []; declare function concat<A>(arr1: [A], arr2: []): [A]; declare function concat<A, B>(arr1: [A], arr2: [B]): [A, B]; declare function concat<A, B, C>(arr1: [A], arr2: [B, C]): [A, B, C]; declare function concat<A, B, C, D>(arr1: [A], arr2: [B, C, D]): [A, B, C, D]; declare function concat<A, B>(arr1: [A, B], arr2: []): [A, B]; declare function concat<A, B, C>(arr1: [A, B], arr2: [C]): [A, B, C]; declare function concat<A, B, C, D>(arr1: [A, B], arr2: [C, D]): [A, B, C, D]; declare function concat<A, B, C, D, E>(arr1: [A, B], arr2: [C, D, E]): [A, B, C, D, E]; declare function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C]; declare function concat<A, B, C, D>(arr1: [A, B, C], arr2: [D]): [A, B, C, D]; declare function concat<A, B, C, D, E>(arr1: [A, B, C], arr2: [D, E]): [A, B, C, D, E]; declare function concat<A, B, C, D, E, F>(arr1: [A, B, C], arr2: [D, E, F]): [A, B, C, D, E, F]; // Согласитесь, что выглядит это не очень хорошо  // Также можно было комбинировать типы declare function concatBetter<T, U>(arr1: T[], arr2: U[]): (T | U)[]; // Но это приводило к типу (T | U)[]  // Сейчас: // Тип вариативного кортежа позволяет легко комбинировать типы с сохранением информации о длине кортежа declare function concatNew<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U];  const tuple = concatNew([23, 'hey', false] as [number, string, boolean], [5, 99, 20] as [number, number, number]); console.log(tuple[0]); // 23 const element: number = tuple[1]; // TypeError: Type 'string' is not assignable to type 'number'. console.log(tuple[6]); // TypeError: Tuple type '[23, "hey", false, 5, 99, 20]' of length '6' has no element at index '6'.

Помеченные элементы кортежа / Labeled tuple elements: элементы кортежа могут быть именованными, например [start: number, end: number]. Если один элемент является именованным, то остальные элементы также должны быть именованными.

type Foo = [first: number, second?: string, ...rest: any[]];  declare function someFunc(...args: Foo);

Вывод типа свойства класса из конструктора: при установке свойства в конструкторе тип свойства выводится автоматически.

class Animal {   // Раньше тип объявляемого свойства должен быть определяться вручную   name;    constructor(name: string) {     this.name = name;     console.log(this.name); // string   } }

Поддержка тега deprecated JSDoc:

/** @deprecated message */ type Test = string;  const test: Test = 'dfadsf'; // TypeError: 'Test' is deprecated.

TS4.1

Типы шаблонных литералов / Template literal types: позволяют определять сложные строковые типы, например, путем комбинации нескольких строковых литералов.

type VerticalDirection = 'top' | 'bottom'; type HorizontalDirection = 'left' | 'right'; type Direction = `${VerticalDirection} ${HorizontalDirection}`;  const dir1: Direction = 'top left'; const dir2: Direction = 'left'; // TypeError: Type '"left"' is not assignable to type '"top left" | "top right" | "bottom left" | "bottom right"'. const dir3: Direction = 'left top'; // TypeError: Type '"left top"' is not assignable to type '"top left" | "top right" | "bottom left" | "bottom right"'.  // Комбинироваться также могут дженерики и утилиты типов declare function makeId<T extends string, U extends string>(first: T, second: U): `${Capitalize<T>}-${Lowercase<U>}`;

// Предположим, что мы хотим, чтобы ключи объекта начинались с нижнего подчеркивания const obj = { value1: 0, value2: 1, value3: 3 }; const newObj: { [Property in keyof typeof obj as `_${Property}`]: number }; // { _value1: number; _value2: number; _value3: number; }

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

type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;  type P1 = Awaited<string>; // string type P2 = Awaited<Promise<string>>; // string type P3 = Awaited<Promise<Promise<string>>>; // string

Поддержка тега see JSDoc:

const originalValue = 1; /**   * Копия другого значения   * @see originalValue   */ const value = originalValue;

explainFiles: при использовании флага CLI --explainFiles или установке одноименной настройки в файле tsconfig.json, TS сообщает, какие файлы и почему компилируются. Может быть полезным для отладки. Обратите внимание: для уменьшения вывода (output) в больших и сложных проектах можно, например, использовать команду tsc --explainFiles | less.

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

const [_first, second] = [3, 5]; console.log(second);  // или даже короче const [_, value] = [3, 5]; console.log(value);

TS4.3

Разделение типов аксессоров: при определении аксессоров get/set тип записи/set может быть отделен от типа чтения/get. Это позволяет сеттерам принимать значения разных типов.

class Test {   private _value: number;    get value(): number {     return this._value;   }    set value(value: number | string) {     if (typeof value === 'number') {       this._value = value;       return;     }     this._value = parseInt(value, 10);   } }

override: индикатор перезаписи наследуемого класса. Используется для обеспечения типобезопасности в сложных паттернах наследования. Вместо ключевого слова override можно использовать одноименный декоратор.

class Parent {   getName(): string {     return 'name';   } }  class NewParent {   getFirstName(): string {     return 'name';   } }  class Test extends Parent {   override getName(): string {     return 'test';   } }  class NewTest extends NewParent {   override getName(): string { // TypeError: This member cannot have an 'override' modifier because it is not declared in the base class 'NewParent'.     return 'test';   } }

Статические сигнатуры доступа по индексу / Static index signatures:

// Раньше: class Test {}  Test.test = ''; // TypeError: Property 'test' does not exist on type 'typeof Test'.  // Сейчас: class NewTest {   static [key: string]: string; }  NewTest.test = '';

Поддержка тега link JSDoc:

const originalValue = 1; /**   * Копия {@link originalValue}   */ const value = originalValue;

TS4.4

exactOptionalPropertyTypes: использование флага CLI --exactOptionalPropertyTypes или установка одноименной настройки в файле tsconfig.json запрещает неявную неопределенность поля — вместо property?: string следует использовать property: string | undefined.

class Test {   name?: string;   age: number | undefined; }  const test = new Test(); test.name = undefined; // TypeError: Type 'undefined' is not assignable to type 'string' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target. test.age = undefined; console.log(test.age); // undefined

TS4.5

Утилита типа Awaited: извлекает тип значения бесконечно вложенных промисов. Это также улучшает вывод типов для Promise.all().

type P1 = Awaited<string>; // string type P2 = Awaited<Promise<string>>; // string type P3 = Awaited<Promise<Promise<string>>>; // string

Модификатор type в именованном импорте: индикатор того, что значение требуется только для проверки типов и может быть удалено при компиляции.

// Раньше: // Импорт значений и типов приходилось разделять во избежание импорта типов после компиляции import { something } from './file'; import type { SomeType } from './file';  // Сейчас: // Значения и типы могут импортироваться с помощью одной инструкции import { something, type SomeType } from './file';

Утверждения const / const assertions: позволяют корректно типизировать константы как литеральные типы. Это может использоваться во многих случаях и существенно повышает точность типизации. Это также делает объекты и массивы readonly, что предотвращает их мутации.

// Раньше: const obj = { name: 'foo', value: 9, toggle: false }; // { name: string; value: number; toggle: boolean; } // Полю может присваиваться любое значение соответствующего типа obj.name = 'bar';  const tuple = ['name', 4, true]; // (string | number | boolean)[] // Длина кортежа и тип каждого элемента неизвестны // Могут присваиваться любые значения соответствующих типов tuple[0] = 0; tuple[3] = 0;  // Сейчас: const objNew = { name: 'foo', value: 9, toggle: false } as const; // { readonly name: "foo"; readonly value: 9; readonly toggle: false; } // Значения полей доступны только для чтения (не могут модифицироваться) objNew.name = 'bar'; // TypeError: Cannot assign to 'name' because it is a read-only property.  const tupleNew = ['name', 4, true] as const; // readonly ["name", 4, true] // Длина кортежа и тип каждого элемента теперь известны tupleNew[0] = 0; // TypeError: Cannot assign to '0' because it is a read-only property. tupleNew[3] = 0; // TypeError: Index signature in type 'readonly ["name", 4, true]' only permits reading.

Автозавершение методов классов:

TS4.6

Улучшение вывода типов при доступе по индексу: более точный вывод типов при доступе по ключу в рамках одного объекта.

interface AllowedTypes {   'number': number;   'string': string;   'boolean': boolean; }  //  `UnionRecord` определяет типы значений полей с помощью `AllowedTypes` type UnionRecord<AllowedKeys extends keyof AllowedTypes> = { [Key in AllowedKeys]: {   kind: Key;   value: AllowedTypes[Key];   logValue: (value: AllowedTypes[Key]) => void; } }[AllowedKeys];  // `logValue` принимает только значения типа `UnionRecord` function processRecord<Key extends keyof AllowedTypes>(record: UnionRecord<Key>) {   record.logValue(record.value); }  processRecord({   kind: 'string',   value: 'hello!',   // `value` может иметь тип `string | number | boolean`,   // но в данном случае правильно выводится тип `string`   logValue: value => {     console.log(value.toUpperCase());   } });

Флаг CLI --generateTrace: указывает TS генерировать файл, содержащий подробности проверки типов и процесса компиляции. Может быть полезным для оптимизации сложных типов.

TS4.7

Поддержка модулей ES в Node.js: для типобезопасного использования модулей ES вместо модулей CommonJS предназначена следующая настройка, устанавливаемая в файле tsconfig.json:

{   "compilerOptions": {     "module": "es2020"   } }

Поле type файла package.json: вместо указанной выше настройки можно определить следующее поле в файле package.json:

"type": "module"

Выражения инстанцирования / Instantiation expressions: позволяют определять параметры типов при ссылке на значения. Это позволяет конкретизировать (narrow) общие типы без создания оберток.

class List<T> {   private list: T[] = [];    get(key: number): T {     return this.list[key];   }    push(value: T): void {     this.list.push(value);   } }  function makeList<T>(items: T[]): List<T> {   const list = new List<T>();   items.forEach(item => list.push(item));   return list; }  // Предположим, что мы хотим определить функцию, создающую список // элементов определенного типа  // Раньше: // Требовалось создавать функцию-обертку и передавать ей аргумент с указанием типа function makeStringList(text: string[]) {   return makeList(text); }  // Сейчас: // Можно использовать выражение инстанцирования const makeNumberList = makeList<number>;

extends и infer: при выводе переменных типов в условных типах, они могут конкретизироваться/ограничиваться с помощью ключевого слова extends.

// Предположим, что мы хотим извлекать тип первого элемента массива только в случае, // если такой элемент является строкой // Для этого можно применить условные типы  // Раньше: type FirstIfStringOld<T> =   T extends [infer S, ...unknown[]]     ? S extends string ? S : never     : never;  // Вместо 2 вложенных условных типов можно использовать 1 type FirstIfString<T> =   T extends [string, ...unknown[]]     // Извлекаем первый тип из типа `T`     ? T[0]     : never; // Но код все равно выглядит не очень хорошо  // Сейчас: type FirstIfStringNew<T> =   T extends [infer S extends string, ...unknown[]]     ? S     : never; // Обратите внимание: типизация работает как раньше, но код стал чище  type A = FirstIfStringNew<[string, number, number]>; // string type B = FirstIfStringNew<["hello", number, number]>; // "hello" type C = FirstIfStringNew<["hello" | "world", boolean]>; // "hello" | "world" type D = FirstIfStringNew<[boolean, number, string]>; // never

Опциональные аннотации вариативности для параметров типов: дженерики могут вести себя по-разному при проверке на совпадение (match), например, разрешение наследования выполняется в обратном порядке для геттеров и сеттеров. Это может быть определено в явном виде для ясности.

// Предположим, что у нас имеется интерфейс, расширяющий другой интерфейс interface Animal {   animalStuff: any; }  interface Dog extends Animal {   dogStuff: any; }  // А также общий "геттер" и "сеттер". type Getter<T> = () => T;  type Setter<T> = (value: T) => void;  // Если мы хотим выяснить, совпадают ли Getter<T1> и Getter<T2> или Setter<T1> и Setter<T2>, // нам следует учитывать ковариантность (covariance) function useAnimalGetter(getter: Getter<Animal>) {   getter(); }  // Теперь мы можем передать `Getter` в функцию useAnimalGetter((() => ({ animalStuff: 0 }) as Animal)); // Это работает  // Что если мы хотим использовать `Getter`, возвращающий `Dog`? useAnimalGetter((() => ({ animalStuff: 0, dogStuff: 0 }) as Dog)); // Это работает, поскольку `Dog` - это также `Animal`  function useDogGetter(getter: Getter<Dog>) {   getter(); }  // Если мы попытаемся сделать тоже самое для функции `useDogGetter`, // то получим другое поведение useDogGetter((() => ({ animalStuff: 0 }) as Animal)); // TypeError: Property 'dogStuff' is missing in type 'Animal' but required in type 'Dog'. // Это не работает, поскольку ожидается `Dog`, а не просто `Animal`  useDogGetter((() => ({ animalStuff: 0, dogStuff: 0 }) as Dog)); // Однако, это работает  // Можно предположить, что сеттеры работает как геттеры, но это не так function setAnimalSetter(setter: Setter<Animal>, value: Animal) {   setter(value); }  // Если мы передадим `Setter` такого же типа, все будет хорошо setAnimalSetter((value: Animal) => {}, { animalStuff: 0 });  function setDogSetter(setter: Setter<Dog>, value: Dog) {   setter(value); }  // И здесь setDogSetter((value: Dog) => {}, { animalStuff: 0, dogStuff: 0 });  // Но если мы передадим `Dog Setter` в функцию `setAnimalSetter`, // поведение будет противоположным (reversed) `Getter` setAnimalSetter((value: Dog) => {}, { animalStuff: 0, dogStuff: 0 }); // TypeError: Argument of type '(value: Dog) => void' is not assignable to parameter of type 'Setter<Animal>'.  // Обходной маневр выглядит несколько иначе setDogSetter((value: Animal) => {}, { animalStuff: 0, dogStuff: 0 });  // Сейчас: // Не является обязательным, но повышает читаемость кода type GetterNew<out T> = () => T; type SetterNew<in T> = (value: T) => void;

Кастомизация разрешения модулей: настройка moduleSuffixes позволяет указывать кастомные суффиксы файлов (например, .ios) при работе в специфических окружениях для правильного разрешения импортов.

{   "compilerOptions": {     "moduleSuffixes": [".ios", ".native", ""]   } }

import * as foo from './foo'; // Сначала проверяется ./foo.ios.ts, затем ./foo.native.ts и, наконец, ./foo.ts

Переход к определению источника / Go to source definition: новый пункт меню в редакторе кода. Он похож на «Перейти к определению» (Go to definition), но «предпочитает» файлы .ts и .js вместо определений типов (.d.ts).

TS4.9

Оператор satisfies: позволяет проверять совместимость значения с типом без присвоения типа. Это делает вывод типов более точным при сохранении совместимости.

// Раньше: // Предположим, что у нас есть объект, в котором хранятся разные элементы и их цвета const obj = {   fireTruck: [255, 0, 0],   bush: '#00ff00',   ocean: [0, 0, 255] } // { fireTruck: number[]; bush: string; ocean: number[]; }  const rgb1 = obj.fireTruck[0]; // number const hex = obj.bush; // string  // Допустим, мы хотим ограничить типы значений объекта // Для этого можно применить утилиту типа `Record` const oldObj: Record<string, [number, number, number] | string> = {   fireTruck: [255, 0, 0],   bush: '#00ff00',   ocean: [0, 0, 255] } // Record<string, [number, number, number] | string> // Но это приводит к потере типизации свойств const oldRgb1 = oldObj.fireTruck[0]; // string | number const oldHex = oldObj.bush; // string | number  // Сейчас: // Оператор `satisfies` позволяет проверять совместимость значения с типом без присвоения типа const newObj = {   fireTruck: [255, 0, 0],   bush: '#00ff00',   ocean: [0, 0, 255] } satisfies Record<string, [number, number, number] | string> // { fireTruck: [number, number, number]; bush: string; ocean: [number, number, number]; }  // Типизация свойств сохраняется // Более того, массив становится кортежем const newRgb1 = newObj.fireTruck[0]; // number const newRgb4 = newObj.fireTruck[3]; // TypeError: Tuple type '[number, number, number]' of length '3' has no element at index '3'. const newHex = newObj.bush; // string

Новые команды: в редакторе кода появились команды «Удалить неиспользуемые импорты» (Remove unused imports) и «Сортировать импорты» (Sort imports), облегчающие управления импортами.

На этом перевод второй части, посвященной возможностям TS, завершен.

С возможностями TS5, можно ознакомиться здесь.

Надеюсь, вы узнали что-то новое и не зря потратили время.

Happy coding!


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


ссылка на оригинал статьи https://habr.com/ru/articles/723332/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *