Русский
Русский
English
Статистика
Реклама

Typescript

Перевод Рассказ о том, почему в 2021 году лучше выбирать TypeScript, а не JavaScript

15.05.2021 14:20:40 | Автор: admin
Недавно я, используя React Native, занимался разработкой мобильного приложения для медитации Atomic Meditation. Эта программа помогает тем, кто ей пользуется, выработать привычку медитировать, ежедневно уделяя этому занятию какое-то время. В ходе работы у меня появились серьёзные причины приступить к изучению TypeScript и начать пользоваться им вместо JavaScript в проектах среднего и крупного размера.

Прежде чем я начну свой рассказ, мне хотелось бы отметить, что вы сможете разобраться в этой статье, даже если никогда не пользовались React Native. Я буду всё подробно объяснять. А если вы делали какие-нибудь проекты на React, то, читая эту статью, можете считать, что React и React Native это одно и то же.



А теперь расскажу о том, как обычный JavaScript втянул меня в неприятности.

День 1: всё идёт как надо


В React Native есть объект AsyncStorage, который представляет собой хранилище данных типа ключ/значение с асинхронным доступом к значениям по ключам. Он даёт разработчику очень простой механизм для организации постоянного хранения данных на мобильном устройстве пользователя.

Например, воспользоваться им можно так:

AsyncStorage.setItem("@key", value)

AsyncStorage позволяет хранить лишь строковые данные. Поэтому для того чтобы поместить в это хранилище число это число сначала надо конвертировать в строку.

Ниже показано применение React-хука useState для объявления переменной sessionCount и для установки её начального значения в 0. Тут же имеется и функция setSessionCount, которая позволяет менять состояние sessionCount:

const [sessionCount, setSessionCount] = useState(0)

Предположим, пользователь завершил сеанс медитации (я, напомню, занимался разработкой приложения для медитации). В sessionCount хранится общее количество сеансов медитации, завершённых пользователем (я буду теперь называть этого пользователя Anxious Andy беспокойный Энди). Это значит, что нам надо прибавить 1 к значению, хранящемуся в sessionCount. Для этого вызывается функция setSessionCount, в которой и выполняется прибавление 1 к предыдущему значению sessionCount. А потом количество завершённых медитаций нужно сохранить в AsyncStorage в виде строки.

Всё это надо сделать в некоей функции, которую я предлагаю назвать saveData:

// Пользователь завершил сеанс медитацииconst saveData = () => {setSessionCount(prev => {const newSessionCount = prev + 1AsyncStorage.setItem("@my_number", newSessionCount.toString())return newSessionCount})}

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

День 2: затишье перед бурей


Беспокойный Энди получает уведомление, которое напоминает ему о том, что через 5 минут начинается его медитация. Но он не только беспокойный, но ещё и нетерпеливый. Поэтому он тут же идёт к себе в комнату, находит своё рабочее кресло, удобно (но при этом сохраняя ясное сознание) в него садится и открывает программу.

Теперь, когда программа загружается, данные сессии Энди нужно прочитать из хранилища. В React хук useEffect позволяет выполнять функцию-коллбэк при монтировании компонента.

В коллбэке мы асинхронно получаем данные из хранилища, а после этого вызываем функцию setSessionCount(), передавая ей эти данные, то есть 1:

useEffect(() => {AsyncStorage.getItem("@my_number").then(data => setSessionCount(data))}, [])

Беспокойный Энди успешно справляется с ещё одной медитацией. Поэтому к sessionCount надо добавить 1, что позволит сохранить общее число завершённых сеансов медитации.

Новое значение, как и прежде, мы записываем в хранилище:

// Пользователь завершил сеанс медитацииconst saveData = () => {setSessionCount(prev => {const newSessionCount = prev + 1AsyncStorage.setItem("@my_number", newSessionCount.toString())return newSessionCount})}

К настоящему моменту пользователь завершил 2 сеанса медитации.

День 3: буря


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

Он хочет узнать о том, как далеко продвинулся в деле выработки полезной привычки. Поэтому он открывает экран статистики. О, да тут много всего интересного, приговаривает он. Отличная программа!.

Но его любовь к этой программе быстро сходит на нет

Программа сообщает ему о том, что он провёл 11 сеансов медитации. А он-то медитировал всего два раза!


Неправильная статистика по сеансам медитации

Что пошло не так?


В первый день мы записали в sessionCount начальное значение число 0.

Пользователь завершил сеанс медитации поэтому мы добавили к sessionCount 1. Затем мы преобразовали то, что получилось, в строку в 1, после чего записали это в асинхронное хранилище (вспомните оно может хранить только строковые данные).

Во второй день мы загружаем данные из хранилища и записываем в sessionCount загруженное значение. То есть 1 (строку, а не число).

Пользователь завершает сеанс медитации и мы прибавляем к sessionCount 1. А в JavaScript 1 + 1 равняется 11, а не 2.

Мы забыли преобразовать строковые данные, считанные из хранилища, в число.

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

JavaScript позволил нам свободно, не сознавая того, что мы делаем, поменять в ходе выполнения программы тип данных, хранящихся в переменной.

Решить эту и другие подобные проблемы можно с помощью TypeScript.

Что такое TypeScript?


Если вы не знакомы с TypeScript, то знайте, что это, в сущности, то же самое, что и JavaScript, но оснащённое некоторыми полезными возможностями. В частности, переменные не могут менять типы. А если это случится TypeScript выдаст сообщение об ошибке.

Браузеры не могут выполнять TypeScript-код. Поэтому TypeScript-файлы проекта надо транспилировать в JavaScript. На выходе получится несколько JavaScript-файлов (или один большой бандл с JS-кодом проекта).

Использование TypeScript в React Native-проектах


Добавить поддержку TypeScript в существующий React Native-проект очень просто. А именно, надо будет кое-что установить из npm и сделать пару настроек.

Теперь нужно будет лишь переименовать файлы с кодом, например App.js в App.tsx, после чего заработает автоматическая система контроля типов.

После того, как изменено расширение файла, TypeScript разразится гневной тирадой о том, что аргумент типа 'string | null' нельзя назначить параметру типа 'SetStateAction<number>'.


TypeScript предупреждает разработчика о том, что с типами данных что-то не так

Это значит, что мне тут, чтобы избавиться от сообщения об ошибке, надо, во-первых, проверить data на null, а во-вторых преобразовать из строки в число (воспользовавшись parseInt()):

useEffect(() => {AsyncStorage.getItem("@my_number").then(data => {if (data) {setSessionCount(parseInt(data))}})}, [])

Использование TypeScript подталкивает разработчика к написанию более качественного и надёжного кода. Это просто замечательно!

По каким материалам изучать TypeScript?


Я изучал TypeScript по этому видеокурсу канала Net Ninja. И если бы мне надо было бы что-нибудь изучить, то я в первую очередь поинтересовался бы тем, нет ли на этом канале курса по тому, что мне нужно.

Кроме того, официальная документация по TypeScript очень даже хороша.

Итоги


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

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

Используете ли вы TypeScript в своих React-проектах?


Подробнее..

Кэш или стэйт, пробуем React-query

16.05.2021 14:08:59 | Автор: admin
Небо и мореНебо и море

Введение

Популярная библиотека для работы с состоянием веб-приложений на react-js это redux. Однако у нее есть ряд недостатков такие как многословность(даже в связке с redux-toolkit), необходимость выбирать дополнительный слой(redux-thunk, redux-saga, redux-observable). Возникает ощущение, что как-то это все слишком сложнои уже давно появились хуки и в частности хук useContext.. Так что я попробовал другое решение.

Приложение для теста

У меня было простое веб приложение Прогноз погоды написанное с помощью create react app, typescript, redux-toolkit, redux saga. Потом я заменил весь redux на context + react-query. Это очень маленькое, однако рабочее приложение, которым я сам пользуюсь, позволило мне использовать react-query для описания уже существующей логики. Т.е. не делать абстрактный нерабочий проект, который просто раскрывает базовые возможности библиотеки.. В приложении есть выбор городов, получение текущей погоды и прогноза. Т.е. максимум три последовательных запроса к серверу.

Скрины тестового приложенияСкрины тестового приложения

Новый стэйт

Библиотека react-query позволяет работать запросами к серверу, предоставляет доступ данным, позволяет задавать порядок запросов.. Однако для того чтобы с этим работать надо разделить весь стэйт который есть в redux на 2 части. Первая это как раз данные, полученные с сервера. Вторая это все остальное, в моем случае это города выбранные пользователем.

Вторую часть реализовал с помощью react-context. Примерно так:

export const CitiesProvider = ({  children,}: {  children: React.ReactNode;}): JSX.Element => {  const [citiesState, setCitiesState] = useLocalStorage<CitiesState>(    'citiesState',    citiesStateInitValue,  );  const addCity = (id: number) => {    if (citiesState.citiesList.includes(id)) {      return;    }    setCitiesState(      (state: CitiesState): CitiesState => ({        ...state,        citiesList: [...citiesState.citiesList, id],      }),    );  }; // removeCity..,  setCurrentCity..  return (    <СitiesContext.Provider      value={{        currentCity: citiesState.currentCity,        cities: citiesState.citiesList,        addCity,        removeCity,        setCurrentCity,      }}    >      {children}    </СitiesContext.Provider>  );};

Реализация не сложная, методы setCurrentCity, removeCity аналогичны. Также я написал хук, который сохраняет все в localStorage при изменении стэйта и подключил его к провайдеру. В итоге у пользователя есть список выбранных городов, текущий город, и возможность удалять, добавлять, выбирать текущий.

React-query

Для загрузки, хранения, обновления данных с сервера использовал библиотеку react-query. Подключается примерно так:

import { QueryClient, QueryClientProvider } from 'react-query';import { ReactQueryDevtools } from 'react-query/devtools';import { CitiesProvider } from './store/cities/cities-provider';const queryClient = new QueryClient();ReactDOM.render(<React.StrictMode><QueryClientProvider client={queryClient}><CitiesProvider><App />

Простой пример использования:

const queryCities = useQuery('cities', fetchCitiesFunc);const cities = queryCities.data || [];

Первый параметр 'cities' это ключ строка, которая должна быть уникальной для каждого запроса. Второй - это функция, которая возвращает Promise, который резолвит данные или отдает ошибку. Также можно передать третьим параметром объект с настройками.

useQuery возвращает объект UseQueryResult, который содержит данные о состоянии запроса, ошибку или данные

const { isLoading, isIdle, isError, data, error } = useQuery(..

Для выполнения последовательных запросов мне показалось удобным написать отдельный хук

export function useCurrentWeather(): WeatherCache {const { currentCity } = useContext(СitiesContext); // запрашиваем список городовconst queryCities = useQuery('cities', fetchCitiesFunc, {refetchOnWindowFocus: false,staleTime: 1000 * 60 * 1000,});const citiesRu = queryCities.data || [];// ищем идентификатор текущего города..const city = citiesRu.find((city) => {if (city === undefined) return false;const { id: elId } = city;if (currentCity === elId) return true;return false;});const { id: weatherId } = city ?? {}; // запрашиваем текущую погодуconst queryWeatherCity = useQuery(['weatherCity', weatherId],() => fetchWeatherCityApi(weatherId as number),{enabled: !!weatherId,staleTime: 5 * 60 * 1000,},);const { coord } = queryWeatherCity.data ?? {}; // запрашиваем прогноз по координатам из предыд. запросаconst queryForecastCity = useQuery(['forecastCity', coord],() => fetchForecastCityApi(coord as Coord),{enabled: !!coord,staleTime: 5 * 60 * 1000,},);return {city,queryWeatherCity,queryForecastCity,};}

staleTime Время, по истечении которого, данные считаются устаревшими. Устаревшие данные перезапрашиваются автоматически при монтировании нового экземпляра, перефокусировке или переподключении сети. Интересно, что по умолчанию staleTime =0.

enabled: !!weatherId, Эта настройка позволяет выполнять запрос только при определенном условии. Пока условие не будет выполнено useQuery будет возвращать состояние isIdle. Таким образом можно описать последовательность выполнения запросов.

const queryWeatherCity = useQuery(['weatherCity', weatherId],.. 

Ключ может быть как строкой так и массивом, содержащим строку и неограниченное количество сериализуемых объектов, например строка + идентификатор.

Вот так использую этот хук в компоненте:

export function Forecast(): React.ReactElement {const {queryForecastCity: { isFetching, isLoading, isIdle, data: forecast },} = useCurrentWeather();if (isIdle) return <LoadingInfo text="Ожидание загрузки дневного прогноза" />;if (isLoading) return <LoadingInfo text="Загружается дневной прогноз" />;const { daily = [], alerts = [], hourly = [] } = forecast ?? {};const dailyForecastNext = daily.slice(1) || [];return (<><Alerts alerts={alerts} /><HourlyForecast hourlyForecast={hourly} /><DailyForecast dailyForecast={dailyForecastNext} />{isFetching && <LoadingInfo text="Обновляется дневной прогноз" />}</>);}

Есть два разных состояния isLoading это первая загрузка и isFetching - это обновление.

Инструменты разработчика

У React-query есть возможность вывести окошко инструментов разработчика. Оно немного похоже на окно Redux, но появляется в виде фиксированного окошка поверх приложения(можно закрыть и останется только кнопка)

Окно инструментов разработчикаОкно инструментов разработчика

Есть информация о состоянии каждого запроса, также есть кнопки Actions, можно вручную производить перезапрос, очистку, удаление.. Если учитывать, что библиотека никак не модифицирует полученные данные, то многое можно увидеть и просто в инструментах разработчика браузера, в разделе сеть. Но все же эти инструменты существенно расширяют возможности отладки. Подключаются они в одну строчку:

import { ReactQueryDevtools } from 'react-query/devtools';

В документации сказано, что при process.env.NODE_ENV === 'production' , в релизную сборку это не попадет автоматически. У меня в Create React App все корректно.

Другие возможности

Также у react-query есть возможности, которые мне не понадобились, однако я все же опишу некоторые из них, примеры кода будут из документации.

  • useQueries позволяет динамически формировать массив запросов. Это нужно т.к. мы не можем опционально вызывать хуки useQuery.

const userQueries = useQueries(users.map(user => {return {queryKey: ['user', user.id],queryFn: () => fetchUserById(user.id),}})
  • По умолчанию настроен автоматический перезапрос данных, при получении ошибки, 3 попытки. Это можно настроить с помощью конфига retry.

  • Для запросов на создание, обновление, удаление данных есть хук useMutations

const mutation = useMutation(newTodo => axios.post('/todos', newTodo))
  • Можно делать постраничные запросы, для бесконечных запросов есть хук useInfiniteQuery

  • Также есть предзагрузка, инвалидация запросов, оптимистичное обновление и еще много всего, что можно посмотреть в документации.

Заключение

После замены redux-toolkit + redux-saga и context + react-query код мне показался значительно проще и я получил из коробки больший функционал для работы с запросами к серверу. Однако часть с react-context не имеет специальных инструментов отладки и вообще вызывает опасения, но она оказалось совсем небольшой и мне вполне хватило react-devtools. В целом я доволен библиотекой react-query и вообще идея отделения кэша в отдельную сущность кажется мне интересной. Но все же это очень маленькое приложение с несколькими get запросами..

Ссылки

Верстка корректна только для мобильных устройств

Есть ветка с redux

Документация react-query

Подробнее..

Перевод Практическое руководство по TypeScript для разработчиков

18.05.2021 14:13:42 | Автор: admin

Представляю вашему вниманию перевод статьи "Working With TypeScript: A Practical Guide for Developers".


Что такое TypeScript?


TypeScript это популярный статический типизатор (static type checker) или типизированное надмножество (typed superset) для JavaScript, инструмент, разработанный Microsoft и добавляющий систему типов к гибкости и динамическим возможностям JavaScript.


TypeScript развивается как проект с открытым исходным кодом, распространяется под лицензией Apache 2.0, имеет очень активное и высокопрофессиональное сообщество, а также огромное влияние на экосистему JavaScript.


Установка TypeScript


Для того, чтобы начать работу с TypeScript, нужно либо установить специальный интерфейс командной строки (command line interface, CLI), либо воспользоваться официальной онлайн-песочницей или другим похожим инструментом.


Для выполнения кода мы будем использовать Node.js. Устанавливаем его, если он еще не установлен на вашей машине, инициализируем новый Node.js-проект и устанавливаем транспилятор TypeScript:


# Создаем новую директорию для проектаmkdir typescript-intro# Делаем созданную директорию текущейcd typescript-intro# Инициализируем Node.js-проектnpm init -y# Устанавливаем компилятор TypeScriptnpm i typescript

Это установит tsc (компилятор TypeScript) для текущего проекта. Для того, чтобы проверить установку, в директории проекта создаем файл index.ts следующего содержания:


console.log(1)

Затем используем транспилятор для преобразования кода, содержащегося в этом файле, в JavaScript:


# Преобразуем index.ts в index.jsnpx tsc index.ts

Наконец, выполняем скомпилированный код с помощью команды node:


# Вы должны увидеть `1` в терминалеnode index.js

В данном случае транспилятор не делает ничего, кроме копирования кода из одного файла в другой, но это позволяет убедиться, что все установлено и работает правильно.


Обратите внимание: версии TypeScript могут сильно отличаться друг от друга, даже если речь идет о минорных релизах. Поэтому TypeScript лучше устанавливать локально и выполнять с помощью npx, вместо того, чтобы полагаться на глобальную версию.


Определение TypeScript-проекта


Для определения TypeScript-проекта внутри Node.js-проекта, необходимо создать файл tsconfig.json. Присутствие данного файла в директории свидетельствует о том, что мы имеем дело с TypeScript-проектом.


tsconfig.json содержит определенное количество настроек, которые влияют на поведение транспилятора, например, на то, какие файлы следует игнорировать, какой файл является целью компиляции, какие типы импортируются и т.д.


Вы легко можете настроить TypeScript с помощью следующей команды:


# Создаем стандартный tsconfig.jsonnpx tsc --init

Сгенерированный tsconfig.json содержит почти все возможные настройки с кратким описанием каждой из них. К счастью, данный файл содержит хорошие настройки по умолчанию, так что вы можете удалить большую часть закомментированных опций.


Мы еще вернемся к настройкам TypeScript, а сейчас давайте писать код.


Возможности TypeScript


Каждая возможность TypeScript подробно рассматривается в "Карманной книге по TypeScript". Мы сосредоточимся на практической составляющей некоторых из них. Я постараюсь пролить свет на некоторые возможности, которые часто упускаются из вида в литературе, посвященной TypeScript.


Основы типизации


Ключевая идея TypeScript заключается в контроле за динамической природой и гибкостью JavaScript с помощью типов. Давайте рассмотрим эту идею на практике.


В директории проекта создаем файл test.js следующего содержания:


function addOne(age) { return age + 1}const age = 'thirty two'console.log(addOne(age))

Выполняем данный код:


node test.js

  1. Что мы увидим в терминале?
  2. Как вы думаете, правильным ли будет вывод?

В терминале мы увидим thirty two1 без каких-либо предупреждений об очевидной некорректности вывода. Ничего нового: обычное поведение JavaScript.


Но что если мы хотим обеспечить, чтобы функция addOne() принимала только числа? Вы можете добавить в код проверку типа переданного значения с помощью оператора typeof или же вы можете использовать TypeScript, который привнесет в процесс компиляции кода некоторые ограничения.


Заменим содержимое созданного нами ранее index.ts следующим кодом:


function addOne(age: number): number { return age + 1}console.log(addOne(32))console.log(addOne('thirty two'))

Обратите внимание, что мы ограничили принимаемый функцией аргумент и возвращаемое функцией значение типом number.


Преобразуем файл:


npx tsc index.ts

Попытка преобразования проваливается:


index.ts:6:20 - error TS2345: Argument of type 'string' is notassignable to parameter of type 'number'. Аргумент типа 'строка' не может быть присвоен параметру с типом 'число'.

Таким образом, определение типов помогает избежать совершения ошибок, таких как передача в функцию аругментов неправильного типа.


string и number это лишь два из основных типов, поддерживаемых TypeScript. TypeScript поддерживает все примитивные значения JavaScript, включая boolean и symbol.


Кроме того, TypeScript определяет несколько собственных типов, которые не имеют соответствия в JavaScript, но являются очень полезными с точки зрения используемой в данной экосистеме методологии:


  • enum ограниченный набор значений
  • any указывает на то, что переменная/параметр могут быть чем угодно, что, по сути, нивелирует типизацию
  • unknown типобезопасная альтернатива any
  • void указывает на то, что функция ничего не возвращает
  • never указывает на то, что функция выбрасывает исключение или на то, что ее выполнение никогда не заканчивается
  • литеральные типы, конкретизирующие типы number, string или boolean. Это означает, например, что 'Hello World' это string, но string это не 'Hello World' в контексте системы типов. Тоже самое справедливо в отношении false в случае с логическими значениями или для 3 в случае с числами:

// Данная функция принимает не любое число, а только 3 или 4declare function processNumber(s: 3 | 4)declare function processAnyNumber(n: number)const n: number = 10const n2: 3 = 3processNumber(n) // Ошибка: `number` - это не `3 | 4`processAnyNumber(n2) // Работает. 3 - это `number`

Множества


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


Карты (maps)


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


// Создаем ассоциативный типtype User = { id: number username: string name: string}// Создаем объект `user`, соответствующий ассоциативному типуconst user: User = { id: 1, username: 'Superman', name: 'Clark Kent',}

Векторы (vectors)


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


// Создаем ассоциативный типtype User = { id: number username: string name: string}// Создаем несколько объектов `user`, соответствующих ассоциативному типуconst user1: User = { id: 1, username: 'Superman', name: 'Clark Kent',}const user2: User = { id: 2, username: 'WonderWoman', name: 'Diana Prince',}const user3: User = { id: 3, username: 'Spiderman', name: 'Peter Parker',}// Создаем вектор пользователейconst userVector: User[] = [user1, user2, user3]

Кортежи (tuples)


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


// Создаем ассоциативный типtype User = { id: number username: string name: string}// Создаем объект `user`, соответствующий ассоциативному типуconst user1: User = { id: 1, username: 'Superman', name: 'Clark Kent',}// Создаем кортежconst userTuple: [User, number] = [user1, 10]

Объединения (unions)


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


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


Прежде всего, давайте установим node-fetch, чтобы иметь возможность использовать функцию fetch в Node.js:


npm i node-fetch @types/node-fetch

Затем с помощью typeof осуществляем разделение типов:


type User = { id: number username: string name: string email: string}async function fetchFromEmail(email: string) { const res = await fetch('https://jsonplaceholder.typicode.com/users') const parsed: User[] = await res.json() const user = parsed.find((u: User) => u.email === email) if (user) {   return fetchFromId(user.id) } return undefined}function fetchFromId(id: number) { return fetch(`https://jsonplaceholder.typicode.com/users/${id}`)   .then((res) => res.json())   .then((user) => user.address)}function getUserAddress(user: User | string) { if (typeof user === 'string') {   return fetchFromEmail(user) } return fetchFromId(user.id)}getUserAddress('Rey.Padberg@karina.biz').then(console.log).catch(console.error)

Здесь мы в явном виде реализовали предохранитель типов.


К слову, кортежи и объединения можно использовать совместно:


const userTuple: Array<User | number> = [u, 10, 20, u, 30]// Любой элемент может быть либо `User`, либо `number`

Можно определять размер и тип каждого элемента массива:


const userTuple: [User, number] = [u, 10, 20, u, 30]// Ошибка: массив должен состоять из двух элементов с типами `User` и `number`const anotherUserTuple: [User, number] = [u, 10] // Все верно

Предохранители типов (type guards)


Предохранители типов это выражения, выполняющие проверки во время выполнения кода, результат которых может быть использован системой типов для сужения (narrow) области (scope) проверяемого аргумента.


Одним из предохранителей является оператор typeof, который мы использовали в предыдущем примере для сужения области аргумента user.


Существуют и другие предохранители, такие как instanceof, !== и in, полный список можно найти в документации.


Для случаев, когда система типов не может сделать правильный вывод о типе в текущем контексте, мы можем определить пользовательский предохранитель типа через предикат (типизированная функция, возвращающая логическое значение):


// Определяем предохранитель для `user`function isUser(u: unknown): u is User { if (u && typeof u === 'object') {   return 'username' in u && 'currentToken' in u } return false}function getUserAddress(user: User | string) { if (isUser(user)) {   return fetchFromEmail(user) } return fetchFromId(user.id)}

Пользовательские предохранители находятся под полным контролем разработчика, TypeScript не имеет возможности убедиться в их корректности.


Весьма распространенным случаем использования пользовательских предохранителей является влидация внешних данных с помощью JSON-схемы, предоставляемой сторонней библиотекой, такой как Ajv. Обычно, это происходит в веб-приложениях, где тело запроса имеет тип unknown (или any в зависимости от используемого фреймворка), и мы хотим проверить его перед использованием:


import Ajv from 'ajv'const ajv = new Ajv()const validate = ajv.compile({ type: 'object', properties: {   username: { type: 'string' },   currentToken: { type: 'string' }, },})function validateUser(data: unknown): data is User { return validate(data)}

В основе данного механизма лежит синхронизация JSON-схемы с типом. Если мы изменим тип, но не изменим схему, то вполне можем получить неожиданное сужение типа.


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


Исключающие объединения (discriminated unions)


Объединения с общим литеральным полем называются исключающими. При работе с такими типами TypeScript предоставляет неявный предохраитель, позволяя избежать его создания в явном виде:


type Member = { type: 'member' currentProject: string}type Admin = { type: 'admin' projects: string[]}type User = Member | Adminfunction getFirstProject(u: User) { if (u.type === 'member') {   return u.currentProject } return u.projects[0]}

В функции getFirstProject() TypeScript сужает область аргумента без помощи предиката. Попытка получить доступ к массиву projects в первой ветке (блоке if) закончится ошибкой типа.


Валидация во время выполнения


Как было отмечено ранее, в случае с пользовательскими предохранителями, корректность возвращаемого результата обеспечивается разработчиком.


При наличии ошибки в предикате, система типов может получить неверную информацию. Рассмотрим пример:


function validateUser(data: unknown): data is User { return true}

Данный предикат всегда возвращает true, позволяя типизатору сузить тип к тому, чем он на самом деле не является:


const invalidUser = undefinedif (validateUser(invalidUser)) { // Предыдущая инструкция всегда возвращает `true` console.log(invalidUser.name) // Ошибка, возникающая во время выполнения}

Существует несколько библиотек, которые позволяют обеспечить автоматическую синхронизацию между валидацией во время выполнения и соответствующим типом. Одним из самых популярных решений является runtypes, однако мы будем использовать io-ts и fp-ts.


Суть данного подхода состоит в том, что мы определяем форму (или фигуру) типа с помощью примитивов, предоставляемых io-ts; эта форма называется декодером (decoder); мы используем ее для проверки данных, которым мы по какой-либо причине не доверяем:


import * as D from 'io-ts/Decoder';import * as E from 'io-ts/Either';import { pipe } from 'fp-ts/function';// Определяем декодер, представляющий `user`const UserDecoder = D.type({   id: D.number,   username: D.string,   name: D.string   email: D.string});// и используем его в отношении потенциально опасных данныхpipe(   UserDecoder.decode(data),   E.fold(       error => console.log(D.draw(error)),       decodedData => {           // типом `decodedData` является `User`           console.log(decodedData.username)       }   ));

Настройка TypeScript


Поведение транспилятора можно настраивать с помощью файла tsconfig.json, находящегося в корне проекта.


Данный файл содержит набор ключей и значений, отвечающих за 3 вещи:


  1. Структура проекта: какие файлы включаются/исключаются из процесса компиляции, зависимости разных TypeScript-проектов, связь между этими проектами через синонимы (aliases).
  2. Поведение типизатора: выполнять ли проверку на наличие null и undefined в кодовой базе, сохранение const enums и т.п.
  3. Процесс транспиляции.

Пресеты TSConfig


TypeScript может преобразовывать код в ES3 и поддерживает несколько форматов модулей (CommonJS, SystemJS и др.).


Точные настройки зависят от среды выполнения кода. Например, если вашей целью является Node.js 10, вы можете транспилировать код в ES2015 и использовать CommonJS в качестве стратегии разрешения модулей.


Если вы используете последнюю версию Node.js, например, 14 или 15, тогда можете указать в качестве цели ESNext или ES2020 и использовать модульную стратегию ESNext.


Наконец, если вашей целью является браузер и вы не используете сборщик модулей, такой как webpack или parcel, то можете использовать UMD.


К счастью, команда TypeScript разработала хороший набор пресетов, которые вы можете просто импортировать в свой tsconfig.json:


{ "extends": "@tsconfig/node12/tsconfig.json", "include": ["src"]}

Среди наиболее важных настроек, можно отметить следующее:


  • declaration: определяет, должен ли TypeScript генерировать файлы определений (.d.ts) во время транспиляции. Данные файлы, как правило, используются при разработке библиотек
  • noEmitOnError: определяет, должен ли TypeScript прерывать процесс компиляции при возникновении ошибок, связанных с неправильными типами. Рекомендуемым значением данной нстройки является true
  • removeComments: true
  • suppressImplicitAnyIndexErrors: true
  • strict: дополнительные проверки. До тех пор, пока у вас не появится веской причины для отключения данной настройки, она должна иметь значение true
  • noEmitHelpers: при необходимости, TypeScript предоставляет утилиты и полифилы для поддержки возможностей, которых не было в ES3 и ES5. Если значение данной настройки является false, утилиты будут помещены в начало кода, в противном случае, они будут опущены (tslib можно устанавливать отдельно)

Заключение


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


Система типов TypeScript не является идеальной, но это лучшее, что мы имеет на сегодняшний день.




Облачные серверы от Маклауд отлично подходят для сайтов с JavaScript.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Создание приложений на Angular с использованием продвинутых возможностей DI

01.06.2021 10:11:39 | Автор: admin

Меня зовут Андрей, и я занимаюсь разработкой фронтенда на Angular для внутренних продуктов компании. Фреймворк обладает обширными возможностями, одни и те же задачи можно решить огромным количеством способов. Чтобы облегчить свою работу и повысить продуктивность, я задался целью найти универсальный и не сложный подход, который бы упростил проектирование и позволил уменьшить объем кода при сохранении его читаемости. Перепробовав множество различных вариантов и учтя допущенные ошибки, я пришел к архитектуре, которой хочу поделиться в этой статье.

Слоеный пирог приложения

Как известно, приложение на Angular представляет собой дерево компонентов, внедряющих зависимости из модульных или элементных инжекторов. При этом его задачей как клиента является получение от сервера информации, которая преобразуется к нужному виду и отображается в браузере. А действия пользователя на странице вызывают изменение информации и визуального представления. Таким образом, приложение разбивается на три абстрактных уровня или слоя, взаимодействующих друг с другом:

  1. Хранение данных и осуществление операций с данными (слой данных).

  2. Преобразование информации к виду, требуемому для отображения, обработка действий пользователя (слой управления или контроллер).

  3. Визуализация данных и делегация событий (слой представления).

В контексте фреймворка они будут обладать следующими характерными особенностями:

  • элементы слоя представления компоненты;

  • зависимости слоя управления находятся в элементных инжекторах, а слоя данных в модульных;

  • связь между слоями осуществляется средствами системы DI;

  • элементы каждого уровня могут иметь дополнительные зависимости, которые непосредственно к слою не относятся;

  • слои связаны в строгом порядке: сервисы одного уровня не могут зависеть друг от друга, компоненты слоя представления могут внедрять только контроллеры, а контроллеры только сервисы слоя данных.

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

Вообще говоря, под данными, передаваемыми между слоями, имеются в виду произвольные объекты. Однако, в большинстве случаев ими будут Observable, которые идеально подходят к описываемому подходу. Как правило, слой данных отдает Observable с частью состояния приложения. Затем в слое управления с помощью операторов rxjs данные преобразовываются к нужному формату, и в шаблоне компонента осуществляется подписка через async pipe. События на странице связываются с обработчиком в контроллере. Он может иметь сложную логику управления запросами к слою данных и подписывается на Observable, которые возвращают асинхронные команды. Подписка позволяет гибко реагировать на результат выполнения отдельных команд и обрабатывать ошибки, например, открывая всплывающие сообщения. Элементы слоя управления я буду дальше называть контроллерами, хотя они отличаются от таковых в MVC паттерне.

Слой данных

Сервисы слоя данных хранят состояние приложения (бизнес-данные, состояние интерфейса) в удобном для работы с ним виде. В качестве дополнительных зависимостей используются сервисы для работы с данными (например: http клиент и менеджеры состояния). Для непосредственного хранения данных удобно использовать BehaviourSubject в простых случаях, и такие библиотеки как akita, Rxjs или ngxs для более сложных. Однако, на мой взгляд, последние две избыточны при данном подходе. Лучше всего для предлагаемой архитектуры подходит akita. Ее преимуществами являются отсутствие бойлерплейта и возможность переиспользовать стейты обычным наследованием. При этом обновлять стейт можно непосредственно в операторах rxjs запросов, что гораздо удобнее, чем создание экшенов.

@Injectable({providedIn: 'root'})export class HeroState {  private hero = new BehaviorSubject(null);  constructor(private heroService: HeroService) {}  load(id: string) {    return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));  }  save(hero: Hero) {    return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));  }  get hero$(): Observable<Hero> {    return this.hero.asObservable();  }}

Слой управления

Так как каждый сервис слоя относится к конкретному компоненту с его поддеревом, логично назвать сервис контроллером компонента. Благодаря тому, что контроллер компонента находится в элементном инжекторе, в нем можно использовать OnDestroy hook и внедрять те же зависимости, что и в компоненте, например ActivatedRoute. Безусловно, можно не создавать отдельный сервис для контроллера в тех случаях, где это равноценно вынесению кода из компонента.

Помимо зависимостей из слоя данных, в контроллере могут быть внедрены зависимости управляющие визуализацией (например: открытие диалогов, роутер) и помогающие с преобразованием данных (например: FormBuilder).

@Injectable()export class HeroController implements OnDestroy {  private heroSubscription: Subscription;    heroForm = this.fb.group({    id: [],    name: ['', Validators.required],    power: ['', Validators.required]  });  constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }  save() {    this.heroState.save(this.heroForm.value).subscribe();  }  initialize() {    this.route.paramMap.pipe(      map(params => params.get('id')),      switchMap(id => this.heroState.load(id)),    ).subscribe();    this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));  }    ngOnDestroy() {    this.heroSubscription.unsubscribe();  }}

Слой представления

Функцией слоя представления является визуализация и связывание событий с их обработчиками. И то, и другое происходит в шаблоне компонента. При этом класс будет содержать только код, внедряющий зависимости уровня управления. Простые компоненты (в том числе из внешних библиотек), не использующие внедрение, будут относиться к дополнительным зависимостям. Они получают данные через Input поля и делегируют события через Output.

@Component({  selector: 'hero',  template: `    <hero-form [form]="heroController.heroForm"></hero-form>    <button (click)="heroController.save()">Save</button>  `,  providers: [HeroController]})export class HeroComponent {  constructor(public heroController: HeroController) {    this.heroController.initialize();  }}

Повторное использование кода

Часто в процессе разработки приложения часть разметки, поведения и бизнес-логики начинает дублироваться. Обычно эта проблема решается использованием наследования и написанием переиспользуемых компонентов. Разделение на слои описанным выше способом способствует более гибкому выделению абстракций при меньшем количестве кода. Основная идея заключается в том, чтобы внедрять зависимости слоя, который мы собираемся переиспользовать, указывая не конкретные, а абстрактные классы. Тем самым можно выделить две базовые техники: подмена слоя данных и подмена контроллера. В первом случае заранее неизвестно, с какими данными будет работать контроллер. Во втором - что отображается и какой будет реакция на события.

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

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

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

export abstract class EntityState<T> {    abstract get entities$(): Observable<T[]>; // список сущностей    abstract get selectedId$(): Observable<string>; // id выбранного элемента    abstract get selected$(): Observable<T>; // выбранный элемент    abstract select(id: string); // выбрать элемент с указанным id    abstract load(): Observable<T[]> // загрузить список    abstract save(entity: T): Observable<T>; // сохранить сущность}

Теперь создадим компонент для карточки с формой. Так как форма здесь может быть произвольной, будем отображать ее, используя проекцию содержимого. Контроллер компонента внедряет EntityState и использует метод для сохранения данных.

@Injectable()export class EntityCardController {    isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));    constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {    }    save(form: FormGroup) {        this.entityState.save(form.value).subscribe({            next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),            error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })        })    }}

В самом компоненте используем еще один способ внедрения зависимости через директиву @ContentChild.

@Component({    selector: 'entity-card',    template: `        <mat-card>            <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">                <mat-card-title>                    <ng-content select=".header"></ng-content>                </mat-card-title>                <mat-card-content>                    <ng-content></ng-content>                </mat-card-content>                <mat-card-actions>                    <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>                </mat-card-actions>            </ng-container>            <ng-template #notSelected>Select Item</ng-template>        </mat-card>    `,    providers: [EntityCardController]})export class EntityCardComponent {    @ContentChild(EntityFormController) entityFormController: EntityFormController<any>;    constructor(public entityCardController: EntityCardController) {        this.entityCardController.initialize();    }}

Для того чтобы это было возможно, необходимо в провайдерах компонента, который проецируется в entity-card, указать реализацию EntityFormController:

providers: [{ provide: EntityFormController, useClass: HeroFormController }]

Шаблон компонента, использующего эту карточку, будет выглядеть следующим образом:

<entity-card><hero-form></hero-form></entity-card>

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

export interface Entity {    value: string;    label: string;}@Injectable()export abstract class EntityListController<T> {    constructor(protected entityState: EntityState<T>) {}    select(value: string) {        this.entityState.select(value);    }    selected$ = this.entityState.selectedId$;    abstract get entityList$(): Observable<Entity[]>;}

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

@Injectable()export class FilmsListController extends EntityListController<Film> {    entityList$ = this.entityState.entities$.pipe(        map(films => films.map(f => ({ value: f.id, label: f.title })))    )}

Компонент списка использует этот сервис, однако его реализация будет предоставлена внешним компонентом.

@Component({    selector: 'entity-list',    template: `        <mat-selection-list [multiple]="false"                             (selectionChange)="entityListController.select($event.options[0].value)">            <mat-list-option *ngFor="let item of entityListController.entityList$ | async"                             [selected]="item.value === (entityListController.selected$ | async)"                             [value]="item.value">                {{ item.label }}            </mat-list-option>        </mat-selection-list>    `})export class EntityListComponent {    constructor(public entityListController: EntityListController<any>) {}}

Компонент, являющийся абстракцией всей вкладки, включает список сущностей и проецирует содержимое с формой.

@Component({    selector: 'entity-page',    template: `        <mat-sidenav-container>            <mat-sidenav opened mode="side">                <entity-list></entity-list>            </mat-sidenav>            <ng-content></ng-content>        </mat-sidenav-container>    `,})export class EntityPageComponent {}

Использование компонента entity-page:

@Component({    selector: 'film-page',    template: `        <entity-page>            <entity-card>                <span class="header">Film</span>                <film-form></film-form>            </entity-card>        </entity-page>    `,    providers: [        { provide: EntityState, useExisting: FilmsState },        { provide: EntityListController, useClass: FilmsListController }    ]})export class FilmPageComponent {}

Компонент entity-card передается через проекцию содержимого для возможности использования ContentChild.

Послесловие

Описанный подход позволил мне значительно упростить процесс проектирования и ускорить разработку без ущерба качеству и читаемости кода. Он отлично масштабируется к реальным задачам. В примерах были продемонстрированы лишь базовые техники переиспользования. Их комбинация с такими фичами как multi-провайдеры и модификаторы доступа (Optional, Self, SkipSelf, Host) позволяет гибко выделять абстракции в сложных случаях, используя меньше кода, чем обычное переиспользование компонентов.

Подробнее..

Перевод Как мы потерпели неудачу, а затем преуспели в переходе на TypeScript

02.06.2021 18:06:54 | Автор: admin

К старту курса о Fullstack-разработке на Python, где также рассматривается TypeScript, мы перевели статью о миграции в Heap.io компании, которая предоставляет платформу аналитики продуктов, c языка CoffeeScript на TypeScript; TS в Heap.io начали использовать более 4 лет назад. Несмотря на широкое предпочтение TypeScript среди инженеров, миграция была медленной, а чёткого пути к 100 % кода TS не было.


На самом деле, если цель состояла в том, чтобы полностью переключиться на TS, мы двигались в неправильном направлении. Да, код на TS добавлялся, но количество кода на CoffeeScript росло быстрее. TypeScript и CoffeeScript нацелены на один и тот же рантайм Node.js, который, как ожидалось, должен был облегчить переход, но, несмотря на желание большого числа разработчиков перейти на TypeScript, мы не набрали большого импульса и не направлялись к будущему без CoffeeScript.

В начале 2019 года мы возобновили наши усилия по переходу с CoffeeScript на TypeScript. На этот раз мы решили: чтобы вдохнуть жизнь в нашу миграцию, от нас потребуется переосмысление стратегии преобразования существующего кода. Это привело нас к новому набору руководящих принципов. Следуя им, мы превратили то, что казалось трудноразрешимой задачей, в управляемый, хорошо понятный процесс и сумели значительно изменить форму кривых на графике:

Количество строк кода в разработкеКоличество строк кода в разработке

Миграция стека в равной степени касается и технологий, и людей

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

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

Новый опыт разработки должен предлагать очевидное улучшение

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

Мы начали с определения областей кодовой базы, которые в случае преобразования приведут к существенному повышению производительности, зная, что стратегическое преобразование файлов будет привлекательнее произвольного преобразования. Например, наш слой доступа к данным (ORM) покрывает всё, и большинство файлов каким-то образом работают с ORM. Введение типов на уровне доступа к данным было множителем всей работы; почти каждый преобразованный в будущем файл выиграет от хорошо типизированных моделей баз данных и утилит.

Кроме того, мы сделали приоритетом оснастку и конфигурацию. Большинство разработчиков использовали один из нескольких редакторов, поэтому мы создали конфигурации редактора, которые будут работать сразу. Мы также добавили конфигурации отладки, облегчающие установку точек останова и пошаговое выполнение кода.

Наконец, мы сошлись на наборе согласованных правил линтинга, которые позволили нам писать код в едином стиле по всей организации и сделать переход более удобным.

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

Технические барьеры нужно ломать

Когда мы начали анализировать шаблоны внедрения TypeScript, стало ясно, что использование TypeScript для наших инженеров не было простым, им часто приходилось импортировать специальные утилиты (ts-node/register) или создавать промежуточные файлы CoffeeScript, которые не делали ничего, кроме импорта их эквивалентов TypeScript. Короче говоря, история взаимодействия языков существовала, но требовала много бойлерплейта и слишком много проб и ошибок.

Были ли эти трения основным блокирующим миграцию фактором? Трудно сказать, но мы знаем, что путь наименьшего сопротивления даёт большую силу, и, если мы хотим, чтобы люди переключались, нам нужно сделать TypeScript простым и очевидным выбором.

Чтобы добиться этого, мы отдавали приоритет усилиям, которые позволили бы разработчикам писать на TS в любом компоненте или сервисе. Будь то бэкенд, фронтенд, скрипты или задачи devops, мы хотели, чтобы наши инженеры могли писать код в TypeScript и чтобы он просто работал. В итоге мы прописали переменную среды NODE_OPTIONSс -r ts-node/register, чтобы существующие (использующие команду coffee для запуска файлов CoffeeScript) рабочие процессы также продолжали работать.

Преобразование должно быть простым, безопасным и автоматизированным

Миграция на другой язык может быть рискованной: то, что может показаться эквивалентным синтаксисом между CoffeeScript и ES6/TypeScript, на самом деле может вообще не быть эквивалентным. И разработчики могут рассматривать преобразование как хорошую возможность для рефакторинга, а это ещё хуже; переписывание делает рискованную миграцию ещё более рискованной.

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

Мы остановились на процессе из двух этапов это автоматическое преобразование файла CoffeeScript, за которым немедленно следует ручное добавление аннотаций основных типов и изменений, связанных с линтером. Ключевой момент состоял в том, чтобы противостоять искушению рефакторинга кода любым осмысленным способом. Такое преобразование становится механическим применением простых, безопасных правил, не влияющих на поведение во время выполнения.

Для первоначального преобразования мы использовали скрипт, преобразующий файл .coffee в файл .ts. В целях перехода от CoffeeScript к JavaScript ES6 Под капотом работал decaffeinate. Поскольку весь JavaScript ES6 является синтаксически правильным TypeScript, на выходе получался рабочий файл. (Мы обнаружили, что decaffeinate очень зрелый и надёжный инструмент.) В истории Git шаг преобразования представлен одним отдельным коммитом.

Однако работа ещё не была закончена. Мы используем TypeScript в строгом режиме, поэтому была отключена такая функция, как "implicit any". Мы использовали это окно преобразования как возможность создавать аннотации типов для элементов, где вывод типов был невозможен. Также мы избегали использования any в этой фазе, вместо этого выбрав более строгий неизвестный. Цель на этом этапе состояла в том, чтобы внести изменения, которые не приведут к изменению поведения во время выполнения. Мы не занимались никаким рефакторингом, а просто выполняли минимальный объём работы, чтобы привести код в состояние, в котором он компилировался, линтовался и проходил тесты.

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

Этот второй шаг также прошёл отдельным коммитом; такой подход сильно упростил ревью: ревьюер мог легко увидеть, какие изменения были внесены после шага c decafeinate.

Весь процесс был задокументирован в руководстве по преобразованию машинописного текста. Любой разработчик кучи может преобразовать файл и открыть запрос на вытягивание, чтобы он был объединён всего за 5 минут.

#typescript: канал дискуссий и вопросов

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

Для этого мы создали канал #typescript в Slack и гарантировали, что застрявшие разработчики могут выйти из положения. Разработчики, управляющие миграцией, стали отвечать на вопросы и остановились на поиске общих проблем и камней преткновения. Если возникала одна и та же проблема, они знали, что нужно сделать лучше.

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

Отслеживание прогресса

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

Отслеживание усилий во времени оказалось полезным. Таким образом возможно было понять, продолжается ли прогресс в миграции. Для визуализации количества строк мы воспользовались Grafana. Вот ещё одна визуализация, показывающая количество файлов во времени:

Уважающее инженеров руководство

К сожалению, одним из факторов, который в наибольшей степени способствует вашему успеху в подобных проектах находится чуть вне вашего контроля, является готовность руководства предоставить вам пространство для выполнения проекта. Проект миграции был направлен снизу вверх: он начался с того, что я представил план руководителям команд и руководителям инженерных подразделений, и после его утверждения нам предоставили свободу выбора наилучшего способа его реализации. Несмотря на то, что мы говорили о включающем сотни тысяч строк переходе, миграция была на 100 % внутренней.

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

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

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

Ситуации могут быть разными, и если вам не хочется ограничивать себя только одним подходом, владеть языками с разными системами типов, то вы можете обратить внимание на наш курс Fullstack-разработчик на Python, где, кроме Python и TS, студенты также изучают чистый JavaScript.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Ко-вариантность и типы данных

04.06.2021 02:06:03 | Автор: admin

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

Я надеюсь что может у меня получиться объяснить эту тему с другой стороны используя метафоры присвоения в разрезе лямбд.

Зачем вообще эта вариантность нужна ?

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

Ко-вариантность это о типах данных и их контроле со стороны компиляторов. И ровно с этого места надо откатиться и сказать о типах данных и зачем это нам нужно.

Flashback к типам

Типы данных сами по себе тоже не являются сверхважной темой, есть языки в которых тип данных не особенно нужны, например ассемблер, brainfuck, РЕФАЛ.

В том же РЕФАЛ или ассемблере очень легко перепутать к кому типу относиться переменная, и очень легко, например можно допустить что из одной строки я вычту другую строку, просто опечатка, никакого злого умысла.

В языках с поддержкой типов, компилятор увидел бы это опечатку и не дал бы мне скомпилировать программу, но например JS

> 'str-a' - 'str-b'NaN

JS (JavaScript) Спокойно этот код проглатывает, мне скажут что этоне баг, это фича, ок, допустим, тогда я возьму Python

>>> 'str-a' - 'str-b'Traceback (most recent call last):  File "<stdin>", line 1, in <module>TypeError: unsupported operand type(s) for -: 'str' and 'str'

Или Java

jshell> "str-a" - "str-b"|  Error:|  bad operand types for binary operator '-'|    first type:  java.lang.String|    second type: java.lang.String|  "str-a" - "str-b"|  ^---------------^

То есть я клоню к тому, что считать багом или фичей - зависит от создателей языка.

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

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

Еще пример: может быть такой мой сценарий, допустим вчера я написал на Groovy вот такой код

groovy> def fun1( a, b ){groovy>   return a - bgroovy> }groovy> println 'fun1( 5, 2 )='+fun1( 5, 2 )groovy> println "fun1( 'aabc', 'b' )="+fun1( 'aabc', 'b' )groovy> println 'fun1( [1,2,3,4], [2,3] )='+fun1( [1,2,3,4], [2,3] )fun1( 5, 2 )=3fun1( 'aabc', 'b' )=aacfun1( [1,2,3,4], [2,3] )=[1, 4]

А сегодня так на JS в другом проекте

> fun1 = function( a, b ){ return a - b }[Function: fun1]> fun1( 5, 2 )3> fun1( 'aabc', 'b' )NaN> fun1( [1,2,3,4], [2,3] )NaN

И вот таких не совпадений типов данных может быть много и мне действительно надо знать особенности того или иного языка.

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

Речь о типах данных

Вариантность как и ко/контр вариантность - это речь о типах данных и их отношениях между собой.

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

Один из способов избежать - это введение системы типов данных.

Вот пример на языке TypeScript

function sub( x : number, y : number ) {    return x - y;}console.log( sub(5,3) )

Этот код успешно скомпилируется в JS.

А вот этот

function sub( x : number, y : number ) {    return x - y;}console.log( sub("aa","bb") )

Уже не скомпилируется - и это хорошо:

> tsc ./index.tsindex.ts:5:18 - error TS2345: Argument of type 'string' is not assignable   to parameter of type 'number'.5 console.log( sub("aa","bb") )~~~~Found 1 error.

В примере выше функцияsubтребует принимать в качестве аргументов переменные определенного типа, не любые, а именноnumber.

Контроль за типы данных я возлагаю уже компилятору TypeScript (tsc).

Инвариантность

Рассмотрим пока понятие Инвариантность, согласно определению

Инвариант это свойство некоторого класса (множества) математических объектов, остающееся неизменным при преобразованиях определённого типа.

Пусть A множество и G множество отображений из A в A. Отображение f из множества A в множество B называется инвариантом для G, если для любых a A и g G выполняется тождество f(a)=f(g(a)).

Очень невнятное для не посвященных определение, давай те чуть проще:

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

Рассмотрим пример операции присвоения переменной, в JS допускается вот такой код

> fun1 = function( a, b, c ){... let r = b;... if( a ) r = c;... return r + r;... }[Function: fun1]> fun1( 1==1, 2, 3 )6> fun1( 1==1, "aa", "b" )'bb'> fun1( 1==1, 3, "b" )'bb'> fun1( 1!=1, 3, "b" )6> fun1( 1!=1, {x:1}, "b" )'[object Object][object Object]'

В примере переменная r - может быть и типа string и number и объектом, со стороны интерпретатора сказать какого типа данных возвращает функция fun1 нельзя, пока не запустишь программу.

Так же нельзя сказать какого типа будет переменная r. Тип результата и тип переменной r зависит от типов аргументов функции.

Переменная r по факту может иметь два разных типа:

  • В конструкцииlet r = b, переменная r будет иметь такой же тип, как и переменная b.

  • В конструкцииr = c, переменная r будет иметь такой же тип, как и переменная c.

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

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

> fun1 = function( a, b, c ){... if( typeof(b)!=='number' )throw "argument b not number";... if( typeof(c)!=='number' )throw "argument c not number";... let r = b;... if( a ) r = c;... return r + r;... }[Function: fun1]> fun1( true, 1, 2 )4> fun1( true, 'aa', 3 )Thrown: 'argument b not number'

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

Другой же аспект, в том что операция +, - и др при операциях над числами - возвращают числа - это и естьинвариантность(в широком смысле), а вот при над числами и строками или различными типами данных - результат уже менее предсказуем.

В языках со строгой типизацией операция конструкцияlet r = bи следующая за нейr = cне допустима, она может быть допустима если мы укажем типы аргументов.

Пример Typescript:

function fun1( a:boolean, b:number, c:number ){    let r = b;    if( a ) r = c;    return r + r;}function fun2( a:boolean, b:number, c:string ){    let r = b;    if( a ) r = c;    return r + r;}

И результат компиляции

> tsc ./index.ts index.ts:9:13 - error TS2322: Type 'string' is not assignable to type 'number'.9     if( a ) r = c;~Found 1 error.

Здесь в ошибки говориться явно, что переменная типаstringне может быть присвоена переменной типаnumber.

Вариантность- в компиляторах, это проверка допустимости присвоения переменной одного типа значения другого типа.

Инвариантность- это такой случай, когда переменной одного типа присваивается (другая или эта же) переменная этого же типа.

Теперь вернемся к строгому определению:выполняется тождество f(a)=f(g(a))

То есть допустим у нас есть функции TypeScript:

function f(a:number) : number {    return a+a;}function g(a:number) : number {    return a;}console.log( f(1)===f(g(1)) )

Этот код - вот прям сторого соответствует определению.

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

function f(a:number) : number {    return a+a;}function g(a:number) : number {    return a-1;}let r = f(1)r = f(g(1))

а такой код

function f(a:number) : number {    return a+a;}function g(a:number) : string {    return (a-1) + "";}let r = f(1)r = f(g(1))

Уже невалиден (не корректен), так как:

  • функция g возвращает тип string

  • а функция f требует тип number в качестве аргумента

и вот такую ошибку обнаружит компилятор TypeScript.

Первый итог

Вариантностьи другие ее формы, как напримерИн/Ко/Контр вариантность- это качество операции присвоения значения переменной или передачи аргументов в функцию, в которой проверяется типы данных передаваемых/принимаемых в функцию и переменную.

Ко-вариантность

Для объяснения ко-вариантности и контр-вариантности, мне придется прибегнуть не к TypeScript, а к другому языку - Scala, причины я поясню ниже.

Вы наверно уже слышали про ООП и наследование, про различные принципы Solid

Ко-вариантностьобычно объясняют через наследование, и что наследуются все свойства и методы родительского класса - это верно, рассмотрим пару примеров


Ко-вариантностьэто такое качество операции присвоения значения переменной значение переменной другого типа, при котором сохраняются все свойства и операции.

Есть несколько типов чисел и их можно расположить в следующей иерархии:

  1. Натуральные числа N

    • N натуральные числа, включая ноль: {0, 1, 2, 3, }

    • N* натуральные числа без нуля: {1, 2, 3, }

  2. Целые числа Z - обладают знаком (+/-) включают в себя натуральные

  3. Рациональные числа Q - дроби (два целых числа), включают в себя все бесконечное множество Z

  4. Вещественные числа R - это и рациональные и иррациональные числа (например ПИ, e, )

  5. Комплексные числа C - числа вида a+bi, где a,b - вещественные числа, а i - мнимая единица

Давай те рассмотрим более подробно:

Числа мы можем условно расположить согласно такой иерархии

  • any - любой тип данных

    • number - некое число

      • int - целое число

      • double - (приближенное) дробное число

    • string - строка

так мы можем в языке TypeScript написать функции

function sum_of_int( a:int, b:int ) : int { return a+b; }function sum_of_double( a:double, b:double ) : double { return a+b; }function compare_equals( a:number, b:number ) : boolean { a==b }

в случае

let res1 : int = sum_of_int( 1, 2 )

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

Рассмотрим случайко-вариантногоприсваивания

let res1 : number = sum_of_int( 1, 2 )    res1          = sum_of_double( 1.2, 2.3 )

В данном примере res1 - это тип number.

В первом вызове res1 = sum_of_int( 1, 2 ), переменная res1 примет данные типа int, и это корректно, т.к. int это подтип number и по определению сохраняются все свойства и методы класса number

Во втором вызове res1 = sum_of_double( 1.2, 2.3 ) - переменная res1 примет данные типа double и это тоже корректно, так же по определению

О каких же операциях говорят что сохраняются? а все те же, мы все так же как и в первом, так и во втором случае можем выполнить операции проверки на равенство и д.р. для переменной res1:

let res1 : number = sum_of_int( 1, 2 )let res2 : number = sum_of_doube( 1.2, 2.3 )if( compare_equals(res1, res2) ){  ...}

ок, это работает, но компилятор нам нужен чтоб он за нас решал проблемы с типами, рассмотрим еще более выпуклый пример

Допустим у нас есть фигуры: прямоугольник Box и круг Circle

class Box {    width : number    height : number    constructor( w: number, h: number ){        this.width = w;        this.height = h;    }}class Circle {    radius : number    constructor( r: number ){        this.radius = r    }}

И нам надо подсчитать сумму площадей, прямоугольники можно хранить в одном массиве, а круги в другом

let boxs : Box[] = [ new Box(1,1), new Box(2,2) ]let circles : Circle[] = [ new Circle(1), new Circle(2) ]

Мы напишем 2 функции по подсчету площади, одну для прямоугольников, другую для кругов

function areaOfBox( shape:Box ):number { return shape.width * shape.height }function areaOfCircle( shape:Circle ):number { return shape.radius * shape.radius * Math.PI }

Тогда для подсчета общей суммы площадей код будет примерно таким:

boxs.map( areaOfBox ).reduce( (a,b,idx,arr)=>a+b ) + circles.map( areaOfCircle ).reduce( (a,b,idx,arr)=>a+b )

Все выше выглядит ужасно, если вы знакомы с ООП или/и с базовой логикой (родовые, видовые понятия).

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

А по сему можно выделить общее абстрактное понятиеФигураи добавить в это абстрактное метод/свойство - area():number.

interface Shape {    area():number}

Вторым шагом, это указать что классы Box и Circle реализуют интерфейс Shape, и перенести areaOfBox, areaOfCircle как реализацию area.

class Box implements Shape {    width : number    height : number    constructor( w: number, h: number ){        this.width = w;        this.height = h;    }    area():number {        return this.width * this.height    }}class Circle implements Shape {    radius : number    constructor( r: number ){        this.radius = r    }    area():number {        return this.radius * this.radius * Math.PI    }}

Теперь нет необходимости разделять прямоугольники и круги в разные массивы, и писать сложный код

let shapes : Shape[] = [ new Box(1,1), new Box(2,2), new Circle(1), new Circle(2) ]shapes.map( s => s.area() ).reduce( (a,b,idx,arr)=>a+b )

И в данном примере,ко-вариантностьпроявляется в инициализации массива

Массив определен как массив элементов типа Shape, мы инициализируем (т.е. присваиваем начальное значение) элементами другого типа под типа (Box, Circle).

Ключевой момент в том, что Box и Circle реализуют необходимые свойства и методы которые требует интерфейс Shape.

Компилятор отслеживает что присваиваемые значения реализуют заданное соглашение, т.е.

Компилятор по факту отслеживает конструкциюlet a = b, и возможны несколько сценариев:

  1. переменная a и b - одного типа, тогдаинвариантнаяоперация присвоения

  2. переменная a является базовым типом, а переменная b - подтипом переменной a - тогдако-вариантнаяоперация присвоения

  3. переменная a является подтипом переменной b, а переменная b - базовым (родительским) типом - тогда этоконтр-вариантнаяоперация - и обычно компилятор блокирует такое поведение.

  4. между переменными a и b - нет общих связей - и тут компилятор блокирует то же поведение.

И вот пример, по пробуем добавить еще один класс который не реализует интерфейс Shape

class Foo {}let shapes : Shape[] = [ new Box(1,1), new Box(2,2), new Circle(1), new Circle(2), new Foo() ]shapes.map( s => s.area() ).reduce( (a,b,idx,arr)=>a+b )

Результат компиляции - следующая ошибка:

> tsc index.tsindex.ts:31:84 - error TS2741: Property 'area' is missing in type 'Foo' but required in type 'Shape'.31 let shapes : Shape[] = [ new Box(1,1), new Box(2,2), new Circle(1), new Circle(2), new Foo() ]                                                                                    ~~~~~~~~~index.ts:2:5    2     area():number        ~~~~~~~~~~~~~    'area' is declared here.Found 1 error.

Для типа Foo не найдено свойство area, которое определенно в типе Shape.

Тут уместно упомянуть о SOLID

L - LSP - Принцип подстановки Лисков (Liskov substitution principle): объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности выполнения программы. См. такжеконтрактное программирование.

Контр-вариантность

Контр-вариантность, уже сложнее объяснить, для меня примеры с длегатами действовали на нервы, я же разобрался на примере с лямбд.

В качестве примера, возьму язык Scala и подробно попытаюсь его разобрать:

package xyz.cofe.sample.invobject App {  // Функция, на вход String, на выход Boolean, или кратко: (String)=>Boolean   def strCmp(a:String):Boolean = a.contains("1")  // Функция, на вход Int, на выход Boolean, или кратко: (Int)=>Boolean  def intCmp(a:Int):Boolean = a==1  // Функция, на вход String, на выход Boolean, или кратко: (Any)=>Boolean  def anyCmp(a:Any):Boolean = true  def main(args:Array[String]):Unit = {        // Инвариантное присвоение Boolean = Boolean    val call1 : Boolean = strCmp("a")        // Ко-вариантное присвоение Any = Boolean    val call2 : Any = strCmp("b")    // Инвариантное присвоение: (String)=>Boolean = (String)=>Boolean    val cmp1 : (String)=>Boolean = App.strCmp;    // Ко-вариантное присвоение (String)=>Boolean = (Any)=>Boolean    val cmp2 : (String)=>Boolean = App.anyCmp    // Инвариантное присвоение: (String)=>Boolean = (String)=>Boolean    val cmp3 : (Any)=>Boolean = App.anyCmp    // !!!!!!!!!!!!!!!!!!!!!!!    // Тут будет ошибка    // Контр-вариантное присвоение (Any)=>Boolean = (String)=>Boolean    val cmp4 : (Any)=>Boolean = App.strCmp  }}

Что нужно знать о Scala:

  • ТипAny- это базовый тип для всех типов данных

  • ТипInt, Boolean, String- это подтипыAny

  • Лямбды то же являются типами, в смысле типы их аргументов и результатов проверяет компилятор

  • Тип лямбды записывается в следующей форме:(тип_аргументов,через_запятую)=>тип_результата

  • Любой метод с легкостью преобразуется в лямбдупеременная = класс.метод/переменная = объект.метод

  • valв Scala, то же что иconstв JS

В примере мы можем увидеть уже знакомые

Инвариантностьв присвоении переменных:

// Инвариантное присвоение Boolean = Booleanval call1 : Boolean = strCmp("a")// Инвариантное присвоение: (String)=>Boolean = (String)=>Booleanval cmp1 : (String)=>Boolean = App.strCmp;

cmp1 - это переменная содержащая лямбду, при том аргументы и результат которые заданы в определении типа лямбды, полностью совпадают с присваемым значением:

Ожидаемый тип  (String)=>BooleanПрисваемый тип (String)=>Boolean

Ко-вариантность

// Ко-вариантное присвоение Any = Booleanval call2 : Any = strCmp("b")// Ко-вариантное присвоение (String)=>Boolean = (Any)=>Booleanval cmp2 : (String)=>Boolean = App.anyCmp

Если в случае присвоения call2, тут все понятно, то может быть непонятно с cmp2.

Ожидаемый тип  (String) => BooleanПрисваемый тип (Any)    => Boolean

Внезапно отношение String -> к -> Any становится другим - контр-вариантным.

В этом месте, уместно задаться WTF? - Все нормально!

Рассмотрим функции выше

// Функция, на вход String, на выход Boolean, или кратко: (String)=>Boolean def strCmp(a:String):Boolean = a.contains("1")// Функция, на вход String, на выход Boolean, или кратко: (Any)=>Booleandef anyCmp(a:Any):Boolean = true

При вызовеcmp2( "abc" )аргумент"abc"будет передан вanyCmp(a:Any), а по скольку String является под типом Any, то аргумент не дано преобразовывать и можно передать как есть.

Иначе говоря вызовanyCmp( "string" )иanyCmp( 1 ),anyCmp( true )- со стороны проверки типов допустимы операции, по скольку

  • принимаемые аргументы являются подтипами для принимающей стороны, тела функции

  • тип принимаемого аргумента является родительским типом (надтипом) со стороны вызова функции

Т.е. можно при передаче аргументов, действуютко-вариантностьсо стороны принимающей, а со стороны передающейконтр-вариантность.

Еще более наглядно это можно выразить стрелками:

Операция присвоения должна быть ко-вариантна или инвариантна

assign a <- b

А операция вызова функции на оборот - контр-варианта или инвариантна

call a -> b

Этим правилом руководствуются многие компиляторы, и они определяют функции так:

  • Операции передачиаргументовв функции по умолчанию являютсяконтр-вариантны, со стороны вызова функции

  • Операции присвоениярезультатвызова функции по умолчанию являетсяко-вариантны, со стороны вызова функции

Я для себя запомню так

Почему Scala, а не TypeScript

К моему удивлению TypeScript версии 4.2.4 не отрабатывает контр-вариантность в случае функций/лямбд

Вот мой исходник

interface Shape {    area():number}class Box implements Shape {    width : number    height : number    constructor( w: number, h: number ){        this.width = w;        this.height = h;    }    area():number {        return this.width * this.height    }}class Circle implements Shape {    radius : number    constructor( r: number ){        this.radius = r    }    area():number {        return this.radius * this.radius * Math.PI    }}class Foo {}const f1 : (number)=> boolean = a => true;const f2 : (object)=> boolean = a => typeof(a)=='function';const f3 : (any)=>boolean = f1;const f4 : (number)=>boolean = f3;const _f1 : (Box)=>boolean = a => trueconst _f2 : (any)=>boolean = _f1const _f3 : (Shape)=>boolean = _f1

В строкеconst f3 : (any)=>boolean = f1;и вconst _f3 : (Shape)=>boolean = _f1(а так же предыдущей) компилятор по моей логике должен был ругаться, но он этого не делал

user@user-Modern-14-A10RB:03:14:17:~/code/blog/itdocs/code-skill/types:> ./node_modules/.bin/tsc -versionVersion 4.2.4user@user-Modern-14-A10RB:03:16:53:~/code/blog/itdocs/code-skill/types:> ./node_modules/.bin/tsc --strictFunctionTypes index.ts user@user-Modern-14-A10RB:03:18:26:~/code/blog/itdocs/code-skill/types:> ./node_modules/.bin/tsc --alwaysStrict index.ts user@user-Modern-14-A10RB:03:19:04:~/code/blog/itdocs/code-skill/types:

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

Ко-вариантность/Контр-вариантность и типы

Еще одна важная оговорка связанная с типами и ООП.


Вариантностьэто не только про иерархию наследования!


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

Ко-Вариантность - это такое качество операции присвоения, когда целевой тип переменнойсовместимс исходным типом значения.

Контр-вариантность- ровно та же ситуация с противоположным знаком.

Тут надо дать пояснение словасовместимость

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

ООП с наследованием - это всего лишь способ, задать иерархию реальных типов объектов.

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

Например я могу выстроить разные наборы иерархий для одних и тех же сущностей:

Например:

  • Человек (общий класс)

    • Национальность (под класс)

      • Социальный статус (под класс)

или наоборот

  • Человек (общий класс)

    • Пол (под класс)

      • Социальный статус (под класс)

Это я клоню к тому, что для одной и той же сущности может существовать множество способов квалификации.

И один из подходов - эту сложную сушность (как например человек) можно рассматривать с различных сторон - и вот уже эти стороны можно выделить в виде интерфейсов.

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

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

Так, например тот или иной объект может быть не только каким либо под классом, но и реализовывать (через интерфейсы) интересующие нас свойства/методы - именно это я понимаю под словомсовместимость.

Далее можно вести разговор о множественном наследовании, трейтах, и прочих прелястях современных языков, но это уже выходит за рамки темы.

Подробнее..

Да хватит уже писать эти регулярки

08.06.2021 16:18:29 | Автор: admin

Здравствуйте, меня зовут Дмитрий Карловский и раньше я тоже использовал Perl для разработки фронтенда. Только гляньте, каким лаконичным кодом можно распарсить, например, имейл:


/^(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,})|("(?:((?:(?:([\u{1}-\u{8}\u{b}\u{c}\u{e}-\u{1f}\u{21}\u{23}-\u{5b}\u{5d}-\u{7f}])|(\\[\u{1}-\u{9}\u{b}\u{c}\u{e}-\u{7f}]))){0,}))"))@(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,}))$/gsu

Тут, правда, закралось несколько ошибок. Ну ничего, пофиксим в следующем релизе!


Шутки в сторону



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



А с внедрением новых фичей, они теряют и лаконичность:


/(?<слово>(?<буквица>\p{Script=Cyrillic})\p{Script=Cyrillic}+)/gimsu

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


/\t//\ci//\x09//\u0009//\u{9}/u

В JS у нас есть интерполяция строк, но как быть с регулярками?


const text = 'lol;)'// SyntaxError: Invalid regular expression: /^(lol;)){2}$/: Unmatched ')'const regexp = new RegExp( `^(${ text }){2}$` )

Ну, или у нас есть несколько простых регулярок, и мы хотим собрать из них одну сложную:


const VISA = /(?<type>4)\d{12}(?:\d{3})?/const MasterCard = /(?<type>5)[12345]\d{14}/// Invalid regular expression: /(?<type>4)\d{12}(?:\d{3})?|(?<type>5)[12345]\d{14}/: Duplicate capture group nameconst CardNumber = new RegExp( VISA.source + '|' + MasterCard.source )

Короче, писать их сложно, читать невозможно, а рефакторить вообще адски! Какие есть альтернативы?


Свои регулярки с распутным синтаксисом


Полностью своя реализация регулярок на JS. Для примера возьмём XRegExp:


  • API совместимо с нативным.
  • Можно форматировать пробелами.
  • Можно оставлять комментарии.
  • Можно расширять своими плагинами.
  • Нет статической типизации.
  • Отсутствует поддержка IDE.

В общем, всё те же проблемы, что и у нативных регулярок, но втридорога.


Генераторы парсеров


Вы скармливаете им грамматику на специальном DSL, а они выдают вам JS код функции парсинга. Для примера возьмём PEG.js:


  • Наглядный синтаксис.
  • Каждая грамматика вещь в себе и не компонуется с другими.
  • Нет статической типизации генерируемого парсера.
  • Отсутствует поддержка IDE.
  • Минимум 2 кб в ужатопережатом виде на каждую грамматику.

Пример в песочнице.


Это решение более мощное, но со своими косяками. И по воробьям из этой пушки стрелять не будешь.


Билдеры нативных регулярок


Для примера возьмём TypeScript библиотеку $mol_regexp:


  • Строгая статическая типизация.
  • Хорошая интеграция с IDE.
  • Композиция регулярок с именованными группами захвата.
  • Поддержка генерации строки, которая матчится на регулярку.

Это куда более легковесное решение. Давайте попробуем сделать что-то не бесполезное..


Номера банковских карт


Импортируем компоненты билдера


Это либо функции-фабрики регулярок, либо сами регулярки.


const {    char_only, latin_only, decimal_only,    begin, tab, line_end, end,    repeat, repeat_greedy, from,} = $mol_regexp

Ну или так, если вы ещё используете NPM


import { $mol_regexp: {    char_only, decimal_only,    begin, tab, line_end,    repeat, from,} } from 'mol_regexp'

Пишем регулярки для разных типов карт


// /4(?:\d){12,}?(?:(?:\d){3,}?){0,1}/gsuconst VISA = from([    '4',    repeat( decimal_only, 12 ),    [ repeat( decimal_only, 3 ) ],])// /5[12345](?:\d){14,}?/gsuconst MasterCard = from([    '5',    char_only( '12345' ),    repeat( decimal_only, 14 ),])

В фабрику можно передавать:


  • Строку и тогда она будет заэкранирована.
  • Число и оно будет интерпретировано как юникод кодепоинт.
  • Другую регулярку и она будет вставлена как есть.
  • Массив и он будет трактован как последовательность выражений. Вложенный массив уже используется для указания на опциональность вложенной последовательности.
  • Объект означающий захват одного из вариантов с именем соответствующим полю объекта (далее будет пример).

Компонуем в одну регулярку


// /(?:(4(?:\d){12,}?(?:(?:\d){3,}?){0,1})|(5[12345](?:\d){14,}?))/gsuconst CardNumber = from({ VISA, MasterCard })

Строка списка карт


// /^(?:\t){0,}?(?:((?:(4(?:\d){12,}?(?:(?:\d){3,}?){0,1})|(5[12345](?:\d){14,}?))))(?:((?:\r){0,1}\n)|(\r))/gmsuconst CardRow = from(    [ begin, repeat( tab ), {CardNumber}, line_end ],    { multiline: true },)

Сам список карточек


const cards = `    3123456789012    4123456789012    551234567890123    5512345678901234`

Парсим текст регуляркой


for( const token of cards.matchAll( CardRow ) ) {    if( !token.groups ) {        if( !token[0].trim() ) continue        console.log( 'Ошибка номера', token[0].trim() )        continue    }    const type = ''        || token.groups.VISA && 'Карта VISA'        || token.groups.MasterCard && 'MasterCard'    console.log( type, token.groups.CardNumber )}

Тут, правда, есть небольшое отличие от нативного поведения. matchAll с нативными регулярками выдаёт токен лишь для совпавших подстрок, игнорируя весь текст между ними. $mol_regexp же для текста между совпавшими подстроками выдаёт специальный токен. Отличить его можно по отсутствию поля groups. Эта вольность позволяет не просто искать подстроки, а полноценно разбивать весь текст на токены, как во взрослых парсерах.


Результат парсинга


Ошибка номера 3123456789012Карта VISA 4123456789012Ошибка номера 551234567890123MasterCard 5512345678901234

Заценить в песочнице.


E-Mail


Регулярку из начала статьи можно собрать так:


const {    begin, end,    char_only, char_range,    latin_only, slash_back,    repeat_greedy, from,} = $mol_regexp// Логин в виде пути разделённом точкамиconst atom_char = char_only( latin_only, "!#$%&'*+/=?^`{|}~-" )const atom = repeat_greedy( atom_char, 1 )const dot_atom = from([ atom, repeat_greedy([ '.', atom ]) ])// Допустимые символы в закавыченном имени сендбоксаconst name_letter = char_only(    char_range( 0x01, 0x08 ),    0x0b, 0x0c,    char_range( 0x0e, 0x1f ),    0x21,    char_range( 0x23, 0x5b ),    char_range( 0x5d, 0x7f ),)// Экранированные последовательности в имени сендбоксаconst quoted_pair = from([    slash_back,    char_only(        char_range( 0x01, 0x09 ),        0x0b, 0x0c,        char_range( 0x0e, 0x7f ),    )])// Закавыченное имя сендборксаconst name = repeat_greedy({ name_letter, quoted_pair })const quoted_name = from([ '"', {name}, '"' ])// Основные части имейла: доменная и локальнаяconst local_part = from({ dot_atom, quoted_name })const domain = dot_atom// Матчится, если вся строка является имейломconst mail = from([ begin, local_part, '@', {domain}, end ])

Но просто распарсить имейл эка невидаль. Давайте сгенерируем имейл!


//  SyntaxError: Wrong param: dot_atom=foo..barmail.generate({    dot_atom: 'foo..bar',    domain: 'example.org',})

Упс, ерунду сморозил Поправить можно так:


// foo.bar@example.orgmail.generate({    dot_atom: 'foo.bar',    domain: 'example.org',})

Или так:


// "foo..bar"@example.orgmail.generate({    name: 'foo..bar',    domain: 'example.org',})

Погонять в песочнице.


Роуты


Представим, что сеошник поймал вас в тёмном переулке и заставил сделать ему "человекопонятные" урлы вида /snjat-dvushku/s-remontom/v-vihino. Не делайте резких движений, а медленно соберите ему регулярку:


const translit = char_only( latin_only, '-' )const place = repeat_greedy( translit )const action = from({ rent: 'snjat', buy: 'kupit' })const repaired = from( 's-remontom' )const rooms = from({    one_room: 'odnushku',    two_room: 'dvushku',    any_room: 'kvartiru',})const route = from([    begin,    '/', {action}, '-', {rooms},    [ '/', {repaired} ],    [ '/v-', {place} ],    end,])

Теперь подсуньте в неё урл и получите структурированную информацию:


// `/snjat-dvushku/v-vihino`.matchAll(route).next().value.groups{    action: "snjat",    rent: "snjat",    buy: "",    rooms: "dvushku",    one_room: "",    two_room: "dvushku",    any_room: "",    repaired: "",    place: "vihino",}

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


// /kupit-kvartiru/v-moskveroute.generate({    buy: true,    any_room: true,    repaired: false,    place: 'moskve',})

Если задать true, то значение будет взято из самой регулярки. А если false, то будет скипнуто вместе со всем опциональным блоком.


И пока сеошник радостно потирает руки предвкушая первое место в выдаче, незаметно достаньте телефон, вызовите полицию, а сами скройтесь в песочнице.


Как это работает?


Нативные именованные группы, как мы выяснили ранее, не компонуются. Попадётся вам 2 регулярки с одинаковыми именами групп и всё, поехали за костылями. Поэтому при генерации регулярки используются анонимные группы. Но в каждую регулярку просовывается массив groups со списком имён:


// time.source == "((\d{2}):(\d{2}))"// time.groups == [ 'time', 'hours', 'minutes' ]const time = from({    time: [        { hours: repeat( decimal_only, 2 ) },        ':',        { minutes: repeat( decimal_only, 2 ) },    ],)

Наследуемся, переопределям exec и добавляем пост-процессинг результата с формированием в нём объекта groups вида:


{    time: '12:34',    hours: '12,    minutes: '34',}

И всё бы хорошо, да только если скомпоновать с нативной регуляркой, содержащей анонимные группы, но не содержащей имён групп, то всё поедет:


// time.source == "((\d{2}):(\d{2}))"// time.groups == [ 'time', 'minutes' ]const time = wrong_from({    time: [        /(\d{2})/,        ':',        { minutes: repeat( decimal_only, 2 ) },    ],)

{    time: '12:34',    hours: '34,    minutes: undefined,}

Чтобы такого не происходило, при композиции с обычной нативной регуляркой, нужно "замерить" сколько в ней объявлено групп и дать им искусственные имена "0", "1" и тд. Сделать это не сложно достаточно поправить регулярку, чтобы она точно совпала с пустой строкой, и посчитать число возвращённых групп:


new RegExp( '|' + regexp.source ).exec('').length - 1

И всё бы хорошо, да только String..match и String..matchAll клали шуруп на наш чудесный exec. Однако, их можно научить уму разуму, переопределив для регулярки методы Symbol.match и Symbol.matchAll. Например:


*[Symbol.matchAll] (str:string) {    const index = this.lastIndex    this.lastIndex = 0    while ( this.lastIndex < str.length ) {        const found = this.exec(str)        if( !found ) break        yield found    }    this.lastIndex = index}

И всё бы хорошо, да только тайпскрипт всё равно не поймёт, какие в регулярке есть именованные группы:


interface RegExpMatchArray {    groups?: {        [key: string]: string    }}

Что ж, активируем режим обезьянки и поправим это недоразумение:


interface String {    match< RE extends RegExp >( regexp: RE ): ReturnType<        RE[ typeof Symbol.match ]    >    matchAll< RE extends RegExp >( regexp: RE ): ReturnType<        RE[ typeof Symbol.matchAll ]    >}

Теперь TypeScript будет брать типы для groups из переданной регулярки, а не использовать какие-то свои захардкоженные.


Ещё из интересного там есть рекурсивное слияние типов групп, но это уже совсем другая история.


Напутствие



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


Подробнее..

Из Vue 2 на Vue 3 Migration Helper

09.06.2021 20:16:20 | Автор: admin

Предистория

Была у меня курсовая по веб-разработке, делать очередной интернет-магазин как-то не хотелось, и решил я написать помощник миграции из Vue 2 (options-api) в Vue 3 (composition-api) с авторазделением на композиции с помощью алгоритма Косарайю по поиску областей сильной связности

Для тех, кто не в теме, поясню, так выглядит код с options-api:

export default {  data () {    return {      foo: 0,      bar: 'hello',    }  },  watch: {    ...  },  methods: {    log(v) {      console.log(v);    },  },  mounted () {    this.log('Hello');  }}

и примерно так с composition-api:

export default {  setup (props) {    const foo = reactive(0);    const bar = reactive('hello');    watch(...);    const log = (v) => { console.log(v); };    onMounted(() => { log('hello'); });    return {      foo,      bar,      log,    };  }}

Автоматическое разделение на композиции

Дабы не отходить от самой идеи композиций, помимо трансляции кода под новый синтаксис composition-api, было принято решение добавить и возможность разделения монолитного компонента на самостоятельные композиции, и их последующее переиспользование в главном компоненте. Как же это сделать?

Сначала зададимся вопросом, что же такое композиции? Для себя я ответил так:

Композиции это самодостаточная группа блоков кода, отвечающих за один функционал, зависящих только друг от друга. Зависимости тут самое главное!

Блоками кода в нашем случае будем считать: свойства data, методы, вотчеры, хуки, и все то, из чего строится компонент Vue.

Теперь определимся на счёт зависимостей блоков кода между собой. С этим во Vue достаточно просто:

  • Если computed, method, hook, provide свойство внутри себя использует другие свойства, то оно от них и зависит

  • Если на свойство навешен вотчер, то вотчер зависит от наблюдаемого им свойства

  • и так далее :)

data: () => ({  array: ['Hello', 'World'], // block 1}),watch: {  array() { // block 2 (watch handler) depends on block 1    console.log('array changed');  },},computed: {  arrayCount() { // block 3    return this.array.length; // block 3 depends on block 1  },},methods: {  arrayToString() { // block 4    return this.array.join(' '); // block 4 depends on block 1  }},

Допустим, мы смогли пройтись по коду и выделить все-все зависимости свойств между собой. Как всё это делить на композиции?

А теперь абстрагируемся от Vue, проблемы миграции, синтаксиса и т.д. Оставим только сами свойства и их зависимости друг с другом.

Выделим из этого ориентированный граф, где вершинами будут свойства, а ребрами - зависимости между свойствами. А теперь самое интересное!

Алгоритм Косарайю

Алгоритм поискаобластей сильной связностив ориентированном графе. Заключается он в двух проходах в глубину по исходному и транспонированному графам и небольшой магии.

Никогда бы не подумал, что простое переписывание реализации из C на TS может быть таким проблемным :)

Так вот. Применяя данный алгоритм, мы и получим заветные композиции, состоящие из сгруппированных по связям свойств. Если же свойство оказалось одиноким и без пары, мы отнесем его к самому будущему компоненту, если же нет выделим группу в одну композицию, которую будем переиспользовать.

Поиск зависимостей

Примечание: во всех функциях компонента в options-api свойства доступны через this

Здесь немного грусти, поскольку искать зависимости в .js приходится так:

const splitter = /this.[0-9a-zA-Z]{0,}/const splitterThis = 'this.'export const findDepsByString = (  vueExpression: string,  instanceDeps: InstanceDeps): ConnectionsType | undefined => {  return vueExpression    .match(splitter)    ?.map((match) => match.split(splitterThis)[1])    .filter((value) => instanceDeps[value])    .map((value) => value)

Да, просто проходясь регуляркой по строкому представлению функции в поисках всего, что идет после this. :(

Более продвинутый вариант, но такой же костыльный:

export const findDeps = (  vueExpression: Noop,  instanceDeps: InstanceDeps): ConnectionsType | undefined => {  const target = {}  const proxy = new Proxy(target, {  // прокси, который записывает в объект вызываемые им свойства    get(target: any, name) {      target[name] = 'get'      return true    },    set(target: any, name) {      target[name] = 'set'      return true    }  })  try {    vueExpression.bind(proxy)() // вызываем функцию в скоупе прокси    return Object.keys(target) || [] // все свойства которые вызвались при this.  } catch (e) { // при ошибке возвращаемся к первому способу    return findDepsByString(vueExpression.toString(), instanceDeps) || []  }}

При использовании прокси вышло несколько проблем:

  • не работает с анонимными функциями

  • при использовании вызывается сама функция а если вы там пентагон взламываете?

Создание файлов и кода

Вспомним зачем мы тут собрались: миграция.

Используя все вышеописанное, получив разбитые по полочкам свойства, нужно составить новый код в синтаксисе composition-api, то есть собрать строки, которые в конечном счете будут являться содержимыми файлов в проекте.

Для этого надо уметь представлять экземпляры объектов, строк, массивов и всего остального в их естественном, кодовом, виде. Вот эта функция:

const toString = (item: any): string => {  if (Array.isArray(item)) {    // array    const builder: string[] = []    item.forEach((_) => {      builder.push(toString(_)) // wow, it's recursion!    })    return `[${builder.join(',')}]`  }  if (typeof item === 'object' && item !== null) {    // object    const builder: string[] = []    Object.keys(item).forEach((name) => {      builder.push(`${name}: ${toString(item[name])}`) // wow, it's recursion!    })    return `{${builder.join(',')}}`  }  if (typeof item === 'string') {    // string    return `'${item}'`  }  return item // number, float, boolean}// Exampleconsole.log(toString([{ foo: { bar: 'hello', baz: 'hello', }}, 1]);// [{foo:{bar: 'hello',baz: 'hello'}},1]  т.е. то же самое, что и в коде

Про остальной говнокод я тактично промолчу :)

Итоговые строки мы записываем в новые файлы через простой fs.writeFile() в ноде и получаем результат

Пример работы

Собрав всё это в пакет, протестировав и опубликовав, можно наконец увидеть результат работы.

Ставим пакет vue2-to-3 глобально (иначе не будет работать через консоль) и проверяем!

Пример HelloWorld.js:

export default {  name: 'HelloWorld',  data: () => ({    some: 0,    another: 0,    foo: ['potato'],  }),  methods: {    somePlus() {      this.some++;    },    anotherPlus() {      this.another++;    },  },};

Пишем в консоли: migrate ./HelloWorld.js и получаем на выход 3 файла:

// CompositionSome.jsimport { reactive } from 'vue';export const CompositionSome = () => {  const some = reactive(0);  const somePlus = () => { some++ };  return {    some,    somePlus,  };};// CompositionAnother.jsimport { reactive } from 'vue';export const CompositionAnother = () => {  const another = reactive(0);  const anotherPlus = () => { another++ };  return {    another,    anotherPlus,  };};// HelloWorld.jsimport { reactive } from 'vue';import { CompositionSome } from './CompositionSome.js'import { CompositionAnother } from './CompositionAnother.js'export default {  name: 'HelloWorld',  setup() {    const _CompositionSome = CompositionSome();    const _CompositionAnother = CompositionAnother();    const foo = reactive(['potato']);    return {      foo,      some: _CompositionSome.some,      somePlus: _CompositionSome.somePlus,      another: _CompositionAnother.another,      anotherPlus: _CompositionAnother.anotherPlus,    };  },};

Итого

На данный момент все это доступно и работает, но ещё есть некоторые баги со строковым представлением не анонимных функций и путями (в некоторых случаях фатально для linux систем)

В планах запилить миграцию для single-file-components и .ts файлов (сейчас работает только для .js)

Спасибо за внимание!

npm, git

Подробнее..

Темизация. История, причины, реализация

18.06.2021 22:18:25 | Автор: admin

Введение. Причины появления

Когда веб только зарождался единственной его целью было размещение контента (гипертекстовые страницы), чтобы у пользователей из всемирной паутины был к нему доступ. В то время не могло идти и речи о дизайне, ведь зачем нужен дизайн страницам с научными публикациями, разве они станут от этого полезнее (первый сайт). Времена меняются и сегодня во всемирной паутине далеко не только научные публикации. Блоги, сервисы, социальные сети и многое, многое другое. Каждый сайт нуждается в своей индивидуальности, ему необходимо заинтересовывать и привлекать пользователей. Даже научные сайты постепенно это понимают, ведь большинство ученых хотят не просто изучать те или иные аспекты, а доносить их до людей, тем самым повышая свою популярность и ценность своих исследований (пример 15 из 15 научных сайтов списка сделали редизайн в последние 6 лет). Рядовым обывателям не интересен серый сайт с непонятным содержанием. Наука становится доступнее, а сайты преобразуются в приложения с удобным и приятным интерфейсом.

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

Думаю, это крайне полезное улучшение. Его появление было неизбежным и, даже странно, что оно стало популярным так поздно. И бизнес, и разработчики делают свои продукты так, чтобы они были доступны всем, везде и всегда. Поэтому, например, в современных оболочках появились функции оттенки желтого и оттенки синего. Тем не менее, не всех устраивают эти функции, поэтому сервисы тоже думают об удобстве использования и здоровье своих любимых пользователей.

Темная тема для ночного периода это не единственная причина добавления темизации на сайт. Другой важной задачей стоит доступность сервиса. Во все мире 285 млн людей с полной или частичной потерей зрения, в России 218т [ист.], до 2,2 млрд с различными дефектами [ист.] почти треть детей в России заканчивает школу в очках[ист.]. Статистика поражает воображение. Однако, большинство людей не лишено зрения полностью, а имеют лишь небольшие отклонения. Это могут быть цветовые отклонения или качественные. Если для качественных отклонений доступность достигается за счет добавления поддержки разных размеров шрифтов, то для цветовых отличным решением является именно добавление темы.

История развития. Бесконечный путь

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

Дизайн только одна из составляющих каждого сайта. Во время разработки сайт проходит через множество людей разработчиков, аналитиков, тестировщиков, маркетологов. Темизация так или иначе затрагивает каждого из них. И, в первую очередь, конечно же, разработчиков.

Добавление темизации в проект может быть крайне простой задачей, если эта задача ставится на этапах планирования проекта. Несмотря на то, что она стала популярна только в последние годы, сама эта технология совсем не нова. Этот процесс, как и многие другие отлаживался и активно развивался с каждым годом последние 5-10 лет. Сегодня даже страшно представить, как это делали первопроходцы. Нужно было поменять всем элементам классы, оптимизировать это через наследование цветов, обновлять почти весь ДОМ. А это все во временя такого монстра, как IE, снящегося в худших кошмарах бывалым разработчикам, и до появления ES6. Сейчас же, все эти проблемы уже далеки от разработчиков. Многие невероятно трудные процессы под влиянием времени постепенно уходят в былое, оставляя будущим поколениям разработчиков память о тех ужасных временах и прекрасные решения, доведенные во многом до идеала.

JS один из самых динамично развивающихся языков программирования, но в вебе развивается далеко не только он. Добавляются новые возможности и устраняются старые проблемы в таких технологиях, как HTML и CSS. Это, конечно же, невозможно без обновления и самих браузеров. Развитие и популяризация современных браузеров скидывают большой груз с плеч программистов. На этом все эти технологии не останавливаются и уверен, что через годы, о них будут отзываться также, как программисты сейчас отзываются об IE. Все эти обновления дают нам не только упрощение разработки и повышение ее удобства, но и добавляет ряд новых возможностей. Одной из таких возможностей стали переменные в css, впервые появившиеся в браузерах в 2015 году. 2015 год во многом получился знаменательным для веба это исторически важное обновления JS, утверждения стандарта HTTP/2, появление WebAssembly, популяризация минимализма в дизайне и появление ReactJS. Эти и многие другие нововведения нацелены на ускорение сайта, упрощение разработки и повышение удобства взаимодействия с интерфейсом.

Немного из истории css-переменных:

Первое упоминание переменных, которое мне удалось откопать, числится за 2012 годом. В апреле сего года в документации появилось описание новой концепции для css переменные.

Был описан весьма интересный способ создания и использования переменных:

:root {  var-header-color: #06c;}h1 { background-color: var(header-color); }

Однако, до появления этой функциональности в браузерах, должно было пройти значительное время на продумывание и отладку. Так, впервые поддержка css-переменных была добавлена в firefox лишь в 2015 году. Затем, в 2016, к нему присоединились google и safari.

Итоговая реализация отличалась от первоначальной идеи и выглядела следующим, уже привычным нам, образом:

:root {  --header-color: #06c;}h1 { background-color: var(--header-color); }

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

Также, интересно проследить цели добавления переменных. Судя по первым версиям спецификации таковыми были возможность избавиться от дублирования констант и улучшение возможностей для разработки адаптивности. В 2015 году появился пример с использованием для интернационализации. О темизации в том далеком и важном для фронтенда году, речи практически не шло тренд на нее появился лишь в последние несколько лет.

Переменные открыли большой потенциал не только в темизации, но и гибкости дизайна в целом. Раньше для обновления цвета, например кнопки отмены нужно было пройти по всем файлам, найти все классы, которые означают отмену и обновить их цвета. Теперь же, стандартом является создание переменной, а всем элементам, подразумевающим действие отмены, в качества цвета используется непосредственно она. Если однажды, дизайнер решит, что эта кнопка должна быть теперь не красная, а алая он может смело это предложить, не боясь быть закиданным камнями за минорное обновление при всей его сложности.

В параллель спецификации Css развиваются также его пре и постпроцессоры. Их развитие было значительно быстрее, так как им не нужно было описывать спецификацию и продвигать ее во все браузеры. Одним из первых препроцессоров был stylus, созданный в далеком 2011, позднее были созданы sass и less. Они дают ряд преимуществ и возможностей, за счет того, что все сложные функции и модификации во время сборки конвертируются в css. Одной из таких возможностей являются переменные. Но это уже совершенно иные переменные, больше похожие на js, нежели css. В сочетании с миксинами и js можно было настроить темизацию.

Прошло уже 10 лет с появления препроцессора, гигантский отрезок по меркам веба. Произошло множество изменений и дополнений. HTML5, ES6,7,8,9,10. JS обзавелся целым рядом библиотек, отстроив вокруг себя невообразимый по масштабам зверинец. Некоторые из этих библиотек стали стандартом современного веба react, vue и angular, заменив привычный разработчикам HTML на свои альтернативы, основанные на js. JS заменяет и css, сделав такую замечательную технологию, как css in js, дающую те же возможности, но только динамичнее и в привычном формате (порою большой ценой, но это уже совсем другая история). JS захватил веб, а затем перешел на захват всего мира.

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

Проектирование дизайна

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

Так как тема это элемент интерфейса часть работ по планированию возьмут на себя дизайнеры. Подходы к разработке дизайн-систем не стоят на месте. Если раньше дизайн сайта разрабатывали в программах, подобных фотошопу (хотя есть отдельные личности, которые занимаются подобным и сейчас, доводя разработчиков до состояния истинного ужаса). У них была масса минусов, особенно во времена медленных компьютеров и больших идей клиентов. Конечно же, эти программы не канут в лету, они будут использоваться по их основному назначению обработка фотографий, рисование иллюстраций. Их роль получают современные альтернативы, предназначенные в первую очередь для веба Avocode, Zeplin, Figma, Sketch. Удобно, когда основные инструменты, используемые программистом предназначены именно для целей разработки. В таких случаях, развитие инструментов идет в ногу с развитием сфер, для которых они предназначены. Эти инструменты являются отличным тому подтверждением. Когда они появились в них можно было копировать css стили, делать сетки, проверять margin-ы и padding-и. Не прямоугольниками и даже не линейкой, а просто движением мыши. Затем появились переменные, после в мир веба пришел компонентный подход и этот подход появился в данных инструментах. Они следят за тенденциями, делая те или иные утилиты, добавляют наборы инструментов и не останавливаются на всем этом, чудесным образом поспевая за этой, разогнавшейся до невероятных скоростей, машиной.

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

Темизация может выходить и за рамки страницы сайта. Одной из таких возможностей является цвет строки состояния и вкладки в некоторых мобильных браузерах. Для этих элементов также стоит продумать оттенки.

Цветовая гамма

Просматривая дизайн нового проекта, часто можно заметить странный, но весьма популярный способ именования цветов blue200. Конечно же, за подобное можно сказать спасибо дизайнеру, ведь это тоже верный подход, однако для иных целей. Такой способ хорошо подходит, если разработчики будут использовать атомарный css, ставшим в последние годы самым интересным и приятным для разработчиков, но все еще значительно отстающим по использованию от БЭМ-а [ист.]. Однако, ни такой способ именования переменных, ни атомарный css не годятся для сайтов, которые проектируются с учетом темизации. Причин тому много, одна из них заключается в том, что blue200 это всегда светло-синий цвет и для того, чтобы цвет у всех светло-синих кнопок стал темно-синим нужно у всех кнопок поменять его на blue800. Значительно более верным вариантом будет назвать цвет primary-color, потому что такое имя может быть как blue200, так и blue800, но всем участникам разработки будет понятно, что эта переменная означает основной цвет сайта.

colors: {  body: '#ECEFF1',  antiBody: '#263238',  shared: {    primary: '#1565C0',    secondary: '#EF6C00',    error: '#C62828',    default: '#9E9E9E',    disabled: '#E0E0E0',  },},

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

colors: {  ...  text: {    lvl1: '#263238',    lvl3: '#546E7A',    lvl5: '#78909C',    lvl7: '#B0BEC5',    lvl9: '#ECEFF1',  },},

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

Примеры названия переменных:

shared-primary-color,

text-lvl1-color.

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

Теперь, разобравшись с проектированием дизайна в контексте разработки, можно переходить на следующий этап.

Проектирование кода.

Как уже говорилось, на уровне кода есть 3 основных пути проектирования темизации через нативные переменные (с препроцессорами или без), через css in js, через замену файлов стилей. Каждое решение может так или иначе свестись к нативным переменным, но беда заключается в том, что в IE нет их поддержки. Дальше будет описано 2 варианта проектирования темизации с помощью переменных на нативном css и с помощью css in js.

Основные шаги при темизации сайта:

  1. Создание стилей каждой темы (цвета, тени, рамки);

  2. Настройка темы по умолчанию, в зависимости от темы устройства пользователя (в случае с темной и светлой темой);

  3. Настройка манифеста и мета тегов;

  4. Создание стилизованных компонентов;

  5. Настройка смены темы при нажатии на кнопку;

  6. Сохранение выбранной темы на устройстве пользователя.

Третий шаг универсален для любого варианта темизации. Поэтому, сперва кратко о нем.

Манифест это файл, используемый, в первую очередь, для PWA. Однако, его содержимое является альтернативой метатегам и загружается быстрее них. В случае темизации нас интересуют такие ключи, как theme_color и background_color. Мети-теги с этими параметрами также можно добавить в head страницы.

Theme_color цвет темы сайта. Указанный цвет будет использоваться в качестве цвета вкладки и строки состояния на мобильных устройствах на системе Android. У данного функционала крайне скудная поддержка браузеров, однако доля этих браузеров составляет 67%.

caniuse.comcaniuse.com

Background_color цвет, который будет загружаться до загрузки стилей. Поддержка данного атрибута еще более скудная, чем у цвета темы:

caniuse.comcaniuse.com

Переменные

Начать описание этого варианта стоит с поддержки, так как это, пожалуй, его единственный минус.

caniuse.comcaniuse.com

Полное отсутствие поддержки в IE, долгое ее отсутствие в популярных браузерах и в Safari являются не критическими проблемами, но ощутимыми, хоть и соотносятся с фриками, не готовыми обновлять свои браузеры и устройства. Однако, IE все еще используется и даже популярнее Safari (5,87% против 3,62% по данным на 2020г).

Теперь о реализации данного способа.

1. Создание классов dark и light, содержащих переменные темы.

Способ именования переменных описан в разделе Проектирование дизайна.

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

.theme-light {  --body-color: #ECEFF1;  --antiBody-color: #263238;  --shared-primary-color: #1565C0;  --shared-secondary-color: #EF6C00;  --shared-error-color: #C62828;  --shared-default-color: #9E9E9E;  --shared-disabled-color: #E0E0E0;  --text-lvl1-color: #263238;  --text-lvl3-color: #546E7A;  --text-lvl5-color: #78909C;  --text-lvl7-color: #B0BEC5;  --text-lvl9-color: #ECEFF1;}.theme-dark {--body-color: #263238;  --antiBody-color: #ECEFF1;  --shared-primary-color: #90CAF9;  --shared-secondary-color: #FFE0B2;  --shared-error-color: #FFCDD2;  --shared-default-color: #BDBDBD;  --shared-disabled-color: #616161;  --text-lvl1-color: #ECEFF1;  --text-lvl3-color: #B0BEC5;  --text-lvl5-color: #78909C;  --text-lvl7-color: #546E7A;  --text-lvl9-color: #263238;}

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

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

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

Для решения этой задачи есть как минимум 2 корректных подхода

2.1) Настройка темы по умолчанию внутри css

Добавляется новый класс, который устанавливается по умолчанию - .theme-auto

Для этого класса добавляются переменные в зависимости от темы устройства посредством media запросов:

@media (prefers-color-scheme: dark) {body.theme-auto {--background-color: #111;--text-color: #f3f3f3;}}@media (prefers-color-scheme: light) {body.theme-auto {--background-color: #f3f3f3;    --text-color: #111;}}

Плюсы данного способа:

  • отсутствие скриптов

  • быстрое выполнение

Минусы:

  • дублирование кода (переменные повторяются с .theme-dark и .theme-light)

  • для определения темы, выбранной при последнем посещении все-равно потребуется скрипт

2.2) Установка класса по умолчанию с помощью js

У js есть полезная функция отслеживание и проверка правил css. Одним из таких правил, как уже было описано выше, является тема устройства пользователя.

Для проверки темы и добавления класса нужной темы нужно добавить следующий код:

if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {body.classlist.add('theme-dark')} else {body.classlist.add('theme-light')}

Дополнительно вы можете подписаться на изменение темы устройства:

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {    if (e.matches) {        body.classlist.remove('theme-light')        body.classlist.add('theme-dark')    } else {        body.classlist.remove('theme-dark')        body.classlist.add('theme-light')    }});

Плюсы:

  • отсутствие дублирования переменных

Минусы:

  • Чтобы не было прыжков темы данный код должен выполняться на верхнем уровне (head или начало body). То есть он должен выполняться отдельно от основного бандла.

3. Создание стилизованных классов для элементов

./button.css

.button {  color: var(--text-lvl1-color);  background: var(--shared-default-color);  ...  &:disabled {    background: var(--shared-disabled-color);  }}.button-primary {background: var(--shared-primary-color);}.button-secondary {background: var(--shared-secondary-color)}

./appbar.css

.appbar {display: flex;  align-items: center;  padding: 8px 0;  color: var(--text-lvl9-color);  background-color: var(--shared-primary-color);}

4. Настройка смены класса при нажатии на кнопку смены темы

Пожалуй, самый простой пункт. Вам необходимо добавить на кнопку слушатель, который будет:

  • удалять прошлые классы, связанные с темой:

body.classlist.remove('theme-light', 'theme-high')
  • добавлять класс выбранной темы:

body.classlist.add('theme-dark')

5. Сохранение выбранной темы на устройстве пользователя.

Тему можно сохранять как в куки, так и в локальном хранилище. Структура и в первом, и во втором случае будет одинаковая: theme: 'light' | 'dark' | 'rose'

На верхнем уровне сайта нужно добавить получение сохраненной темы и добавление нужного класса тегу body. Например, в случае с локальным хранилищем:

const savedTheme = localStorage.getItem('theme')if (['light', 'dark', 'rose'].includes(savedTheme)) {body.classlist.remove('theme-light', 'theme-dark', 'theme-rose')body.classList.add(`theme-${savedTheme}`)}

То есть, если сохраненная тема одна из настроенных тем, то мы удаляем классы, добавленные по умолчанию, и добавляем класс с сохраненной темой.

Css-in-js

Данный вариант больше всего подходит для приложения, выполняемых на стороне клиента.

В качестве примера будет показана связка React + styled-components + typescript.

1. Создание объектов dark и light, содержащих переменные темы.

Способ именования переменных описан в разделе Проектирование дизайна.

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

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

./App.tsx

import { useState } from 'react'import { ThemeProvider } from 'styled-components'import themes from './theme'const App = () => {const [theme, setTheme] = useState<'light' | 'dark'>('light')const onChangeTheme = (newTheme: 'light' | 'dark') => {setTheme(newTheme)}return (<ThemeProvider theme={themes[theme]}>// ...</ThemeProvide>)}

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

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

Для этого можно настроить тему по умолчанию на верхнем уровне приложения:

useEffect(() => {  if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {    onChangeTheme('dark')  }}, [])

Дополнительно вы можете подписаться на изменение темы устройства:

useEffect(() => {  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {    if (e.matches) {      onChangeTheme('dark')    } else {      onChangeTheme('light')    }  })}, [])

3. Создание стилизованных компонентов

./src/components/atoms/Button/index.tsx - git

import type { ButtonHTMLAttributes } from 'react'import styled from 'styled-components'interface StyledProps extends ButtonHTMLAttributes<HTMLButtonElement> {  fullWidth?: boolean;  color?: 'primary' | 'secondary' | 'default'}const Button = styled.button<StyledProps>(({ fullWidth, color = 'default', theme }) => `  color: ${theme.colors.text.lvl9};  width: ${fullWidth ? '100%' : 'fit-content'};  ...  &:not(:disabled) {    background: ${theme.colors.shared[color]};    cursor: pointer;    &:hover {      opacity: 0.8;    }  }  &:disabled {    background: ${theme.colors.shared.disabled};  }`)export interface Props extends StyledProps {  loading?: boolean;}export default Button

./src/components/atoms/AppBar/index.tsx - git

import styled from 'styled-components'const AppBar = styled.header(({ theme }) => `  display: flex;  align-items: center;  padding: 8px 0;  color: ${theme.colors.text.lvl9};  background-color: ${theme.colors.shared.primary};`)export default AppBar

4. Настройка смены класса при нажатии на кнопку смены темы

Через context api или redux/mobx изменяется имя текущей темы

./App.tsx - git

import { useState } from 'react'import { ThemeProvider } from 'styled-components'import themes from './theme'const App = () => {  const [theme, setTheme] = useState<'light' | 'dark'>('light')  const onChangeTheme = (newTheme: 'light' | 'dark') => {    setTheme(newTheme)  }  return (    <ThemeProvider theme={themes[theme]}>    <ThemeContext.Provider value={{ theme, onChangeTheme }}>    ...</ThemeContext.Provider>    </ThemeProvide>)}

.src/components/molecules/Header/index.tsx - git

import { useContext } from 'react'import Grid from '../../atoms/Grid'import Container from '../../atoms/Conrainer'import Button from '../../atoms/Button'import AppBar from '../../atoms/AppBar'import ThemeContext from '../../../contexts/ThemeContext'const Header: React.FC = () => {  const { theme, onChangeTheme } = useContext(ThemeContext)  return (    <AppBar>      <Container>        <Grid container alignItems="center" justify="space-between" gap={1}>          <h1>            Themization          </h1>          <Button color="secondary" onClick={() => onChangeTheme(theme === 'light' ? 'dark' : 'light')}>            set theme          </Button>        </Grid>      </Container>    </AppBar>  )}export default Header

5. Сохранение выбранной темы на устройстве пользователя.

Тему можно сохранять как в куки, так и в локальном хранилище. Структура и в первом, и во втором случае будет одинаковая: theme: 'light' | 'dark' | 'rose'

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

./App.tsx - git

...function App() {  const [theme, setTheme] = useState<'light' | 'dark'>('light')  const onChangeTheme = (newTheme: 'light' | 'dark') => {    localStorage.setItem('theme', newTheme)    setTheme(newTheme)  }  useEffect(() => {    const savedTheme = localStorage?.getItem('theme') as 'light' | 'dark' | null    if (savedTheme && Object.keys(themes).includes(savedTheme)) setTheme(savedTheme)    else if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {      onChangeTheme('dark')    }  }, [])  useEffect(() => {    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {      if (e.matches) {        onChangeTheme('dark')      } else {        onChangeTheme('light')      }    })  }, [])  return (  ...  )}

Финальный код

Демо

Итоги

Вариантов внедрения темизации много от создания файлов со всеми стилями для каждой темы и их смены при необходимости до css-in-js решений (с нативными css переменными или встроенными в библиотеки решениями). Браузерное api дает возможности для настройки сервиса под каждого конкретного пользователя, считывая и отслеживая тему его устройства.

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

Конечно же, темизация нужна не всем. В любом случае она связана с, пусть и небольшими, но все же усложнениями. Она нужна, например, для приложений и веб-сервисов.

Сервисы Google и apple, банки, соц. сети, редакторы, github и gitlab. Продолжать список можно бесконечно, несмотря на то, что это только начало развития технологии, а дальше больше, лучше и проще.

Подробнее..

Пишем переиспользуемые компоненты, соблюдая SOLID

01.06.2021 12:20:22 | Автор: admin
Всем привет! Меня зовут Рома, я фронтендер в Я.Учебнике. Сегодня расскажу, как избежать дублирования кода и писать качественные переиспользуемые компоненты. Статья написана по мотивам (но только по мотивам!) доклада с Я.Субботника видео есть в конце поста. Если вам интересно разобраться в этой теме, добро пожаловать под кат.

Статья содержит более детальный разбор принципов и подробные примеры из практики, которые не поместились в доклад. Рекомендую прочитать, если вы хотите глубоко погрузиться в тему и узнать, как мы пишем переиспользуемые компоненты. Если же вы хотите познакомиться с миром переиспользуемых компонентов в общих чертах, то, по моему мнению, вам больше подойдёт запись доклада.



Все знают, что дублирование кода это плохо, потому что об этом часто говорят: в книге по вашему первому языку программирования, на курсах по программированию, в книгах, посвящённых написанию качественного кода, таких как Совершенный код и Чистый код.

Давайте разберёмся, почему так сложно избегать дублирования во фронтенде и как правильно писать переиспользуемые компоненты. И помогут нам принципы SOLID.

Почему сложно перестать дублировать код?


Казалось бы, принцип звучит просто. И в то же время легко проверить, соблюдается ли он: если в кодовой базе нет дублирования, значит, всё хорошо. Почему же на практике получается так сложно?

Разберём пример с библиотекой компонентов Я.Учебника. Когда-то давно проект был монолитом. Позже для удобства разработчики решили вынести переиспользуемые компоненты в отдельную библиотеку. Одним из первых туда попал компонент кнопки. Компонент развивался, со временем появились новые умения и настройки для кнопки, увеличилось количество визуальных кастомизаций. Спустя некоторое время компонент стал настолько навороченным, что использовать его для новых задач и расширять дальше стало неудобно.

И вот, в очередной итерации появилась копия компонента Button2. Это произошло очень давно, точных причин появления уже никто и не помнит. Тем не менее, компонент был создан.



Казалось бы, ничего страшного пусть будет две кнопки. В конце концов, это всего лишь кнопка. Однако на самом деле наличие двух кнопок в проекте имело очень неприятные долговременные последствия.

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

Несмотря на то, что в долгосрочной перспективе существование двух компонентов кнопки оказалось болезненным, мы не сразу поняли серьёзность проблемы и успели сделать нечто похожее с иконками. Создали компонент, а когда поняли, что он нам не очень удобен, сделали Icon2, а когда и он оказался неподходящим для новых задач, написали Icon3.

Почти весь набор негативных последствий дублирования кнопки повторился в компонентах иконки. Было немного легче потому, что иконки используются в проекте реже. Хотя, если быть честным, тут всё зависит от фичи. Причём и для кнопки, и для иконки старый компонент не удалялся при создании нового, потому что удаление требовало большого рефакторинга с возможным появлением багов по всему проекту. Что же объединяет случаи с кнопкой и иконкой? Одинаковая схема появления дубликатов в проекте. Нам было сложно переиспользовать текущий компонент, адаптировать его к новым условиям, поэтому мы создавали новый.

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

Компонент для переиспользования должен быть достаточно универсальным и в то же время простым. Работа с ним не должна вызывать боль и напоминать стрельбу из пушки по воробьям. С другой стороны, компонент должен быть достаточно кастомизируемым, чтобы при небольшом изменении сценария не выяснилось, что проще написать Компонент2.

SOLID на пути к переиспользуемым компонентам


Чтобы написать качественные компоненты, нам пригодится набор правил, скрывающихся за аббревиатурой SOLID. Эти правила объясняют, как объединять функции и структуры данных в классы и как классы должны сочетаться друг с другом.

Почему же именно SOLID, а не любой другой набор принципов? Правила SOLID говорят о том, как правильно выстроить архитектуру приложения. Так, чтобы можно было спокойно развивать проект, добавлять новые функции, изменять существующие и при этом не ломать всё вокруг. Когда я попытался описать, каким, по моему мнению, должен быть хороший компонент, то понял, что мои критерии близки к принципам SOLID.

  • S принцип единственной ответственности.
  • O принцип открытости/закрытости.
  • L принцип подстановки Лисков.
  • I принцип разделения интерфейсов.
  • D принцип инверсии зависимостей.

Какие-то из этих принципов хорошо подходят для описания компонентов. Другие же выглядят более притянутыми за уши в контексте фронтенда. Но все вместе они хорошо описывают моё видение качественного компонента.

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

В статье приведены примеры кода на React + TypeScript. Я выбрал React как фреймворк, с которым больше всего работаю. На его месте может быть любой другой фреймворк, который вам нравится или подходит. Вместо TS может быть и чистый JS, но TypeScript позволяет явно описывать контракты в коде, что упрощает разработку и использование сложных компонентов.

Базовое


Принцип open/close


Программные сущности должны быть открыты для расширения и закрыты для изменения. Другими словами, мы должны иметь возможность расширять функционал с помощью нового кода без изменения существующего. Почему это важно? Если каждый раз для добавления нового функционала придётся редактировать кучу существующих модулей, весь проект станет нестабильным. Появится много мест, которые могут сломаться из-за того, что в них постоянно вносятся изменения.

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


Кнопка написана так, что её нельзя изменить без редактирования кода

Чтобы применить другие стили в текущей версии, придётся отредактировать компонент кнопки. Проблема заключается в том, что в компонент не заложена кастомизируемость. Вариант написать глобальные стили рассматривать не будем, так как он ненадёжен. Всё может сломаться при любой правке. Последствия легко представить, если на место кнопки поставить что-то более сложное, например, компонент выбора даты.

Согласно принципу открытости/закрытости мы должны написать код так, чтобы при добавлении нового стиля не пришлось переписывать код кнопки. Всё получится, если часть стилей компонента можно прокинуть снаружи. Для этого заведём проп, в который пробросим нужный класс для описания новых стилей компонента.

// утилита для формирования класса, можно использовать любой аналогimport cx from 'classnames';// добавили новый проп  mixconst Button = ({ children, mix }) => {  return (    <button      className={cx("my-button", mix)}    >      {children}    </button>}

Готово, теперь для кастомизации компонента не нужно править его код.



Этот довольно популярный способ позволяет кастомизировать внешний вид компонента. Его называют миксом, потому что дополнительный класс подмешивается к собственным классам компонента. Отмечу, что проброс класса не единственная возможность стилизовать компонент извне. Можно передавать в компонент объект с CSS-свойствами. Можно использовать CSS-in-JS решения, суть не изменится. Миксы используют многие библиотеки компонентов, например: MaterialUI, Vuetify, PrimeNG и другие.

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

Но у такого подхода есть и минусы. Он даёт очень много свободы, из-за чего могут возникнуть проблемы со специфичностью селекторов. А ещё такой подход нарушает инкапсуляцию. Для того, чтобы сформировать правильный css-селектор, нужно знать внутреннее устройство компонента. А значит, такой код может сломаться при рефакторинге компонента.

Изменчивость компонентов


У компонента есть части, являющиеся его ядром. Если их изменить, мы получим другой компонент. Для кнопки это набор состояний и поведение. Пользователи отличают кнопку от, например, чекбокса благодаря эффекту при наведении и нажатии. Есть общая логика работы: когда пользователь кликает, срабатывает обработчик события. Это ядро компонента, то, что делает кнопку кнопкой. Да, бывают исключения, но в большинстве сценариев использования всё работает именно так.

А ещё в компоненте есть части, которые могут меняться в зависимости от места применения. Стили как раз относятся к этой группе. Может, нам понадобится кнопка другого размера или цвета. С другой обводкой и скруглением или с другим ховер эффектом. Все стили изменяемая часть компонента. Мы не хотим переписывать или создавать новый компонент каждый раз, когда кнопка выглядит по-другому.

То, что часто меняется, должно настраиваться без изменения кода. Иначе мы окажемся в ситуации, когда проще создать новый компонент, чем настраивать и дописывать старый, который оказался недостаточно гибким.

Темизация


Вернёмся к кастомизации визуала компонента на примере кнопки. Следующий способ применение тем. Под темизацией я подразумеваю способность компонента отображаться в нескольких режимах, по-разному в разных местах. Эта интерпретация шире, чем темизация в контексте светлой и тёмной темы.

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

import cx from 'classnames';import b from 'b_';const Button = ({ children, mix, theme }) => (  <button    className={cx(     b("my-button", { theme }), mix)}  >    {children}  </button>)

Темизация позволяет избежать зоопарка стилей, когда, например, у вас в проекте 20 кнопок и все выглядят немного по-разному из-за того, что стили каждой кнопки задаются в месте применения. Применять подход можно для всех новых компонентов, не опасаясь оверинжиниринга. Если вы понимаете, что компонент может выглядеть по-разному, лучше с самого начала явно завести темы. Это упростит дальнейшую разработку компонента.

Но есть и минус способ подходит только для кастомизации визуала и не позволяет влиять на поведение компонента.

Вложенность компонентов


Я перечислил не все способы избежать изменения кода компонента при добавлении новых функций. Другие будут продемонстрированы при разборе остальных принципов. Здесь же мне хотелось бы упомянуть про дочерние компоненты и слоты.

Веб-страница представляет собой древовидную иерархию компонентов. Каждый компонент сам решает, что и как отрисовать. Но это не всегда так. Например, кнопка позволяет указать, какой контент будет отрисован внутри. В React основной инструмент проп children и render-пропы. Во Vue есть более мощная концепция слотов. При написании простых компонентов с использованием этих возможностей не возникает никаких проблем. Но важно не забывать, что даже в сложных компонентах можно использовать пробрасывание части элементов, которые должен отобразить компонент сверху.

Продвинутое


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

Single Responsibility Principle


Принцип единственной ответственности означает, что модуль должен иметь одну и только одну причину для изменения.

Почему это важно? Последствия нарушения принципа включают:
  • Риск при редактировании одной части системы сломать другую.
  • Плохие абстракции. Получаются компоненты, которые умеют выполнять несколько функций, из-за чего сложно понять, что именно должен делать компонент, а что нет.
  • Неудобная работа с компонентами. Очень сложно делать доработки или исправлять баги в компоненте, который делает всё сразу.

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


Один модуль редактируется разными людьми по разным причинам

Допустим, мы положили все стили в один css-файл. Он может редактироваться разными людьми по разным причинам. Получается, что принцип единственной ответственности нарушен. Кто-то может отрефакторить стили, а другой разработчик внесёт правки для новой фичи. Так можно легко что-то сломать.

Давайте подумаем, как может выглядеть темизация с соблюдением SRP. Идеальная картина: у нас есть кнопка и отдельно набор тем для неё. Мы можем применить тему к кнопке и получить темизированную кнопку. Бонусом хотелось бы иметь возможность собрать кнопку с несколькими доступными темами, например, для помещения в библиотеку компонентов.


Желаемая картина. Тема отдельная сущность и может примениться к кнопке

Тема оборачивает кнопку. Такой подход используется в Лего, нашей внутренней библиотеке компонентов. Мы используем HOC (High Order Components), чтобы обернуть базовый компонент и добавить ему новые возможности. Например, возможность отображаться с темой.

HOC функция, которая принимает компонент и возвращает другой компонент. HOC с темой может прокидывать объект со стилями внутрь кнопки. Ниже представлен скорее учебный вариант, в реальной жизни можно использовать более элегантные решения, например, прокидывать в компонент класс, стили которого импортируются в HOC, или использовать CSS-in-JS решения.

Пример простого HOC для темизации кнопки:

const withTheme1 = (Button) =>(props) => {    return (        <Button            {...props}            styles={theme1Styles}        />    )}const Theme1Button = withTheme1(Button);

HOC может применять стили, только если указана определённая тема, а в остальных случаях не делает ничего. Так мы можем собрать кнопку с комплектом тем и активировать нужную, указав проп theme.

Использование нескольких HOCов для сбора кнопки с нужными темами:

import "./styles.css"; // Базовый компонент кнопки. Принимает содержимое кнопки и стилиconst ButtonBase = ({ style, children }) => { console.log("styl123e", style); return <button style={style}>{children}</button>;}; const withTheme1 = (Button) => (props) => { // HOC применяет стили, только если выбрана тема "theme1" if (props.theme === "theme1") {   return <Button {...props} style={{ color: "red" }} />; }  return <Button {...props} />;}; const withTheme2 = (Button) => (props) => { // HOC применяет стили, только если выбрана тема "theme2" if (props.theme === "theme2") {   return <Button {...props} style={{ color: "green" }} />; }  return <Button {...props} />;}; // ф-я для оборачивания компонента в несколько HOCconst compose = (...hocs) => (BaseComponent) => hocs.reduce((Component, nextHOC) => nextHOC(Component), BaseComponent); // собираем кнопку, передав нужный набор темconst Button = compose(withTheme1, withTheme2)(ButtonBase); export default function App() { return (   <div className="App">     <Button theme="theme1">"Red"</Button>     <Button theme="theme2">"Green"</Button>   </div> );}

И тут мы приходим к выводу, что нужно разделить области ответственности. Даже если кажется, что у вас один компонент, подумайте так ли это на самом деле? Возможно, его стоит разделить на несколько слоёв, каждый из которых будет отвечать за конкретную функцию. Почти во всех случаях визуальный слой можно отделить от логики компонента.

Выделение темы в отдельную сущность даёт плюсы к удобству использования компонента: можно поместить кнопку в библиотеку с базовым набором тем и разрешить пользователям писать свои при необходимости; темы можно удобно шарить между проектами. Это позволяет сохранить консистентность интерфейса и не перегружать исходную библиотеку.

Существуют разные варианты реализации разделения на слои. Выше был пример с HOC, но композиция также возможна. Однако я считаю, что в случае с темизацией HOC более уместны, так как тема не является самостоятельным компонентом.

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

Композитные компоненты


Перейдём к более сложным компонентам. Возьмём в качестве примера Select и разберёмся, в чём польза принципа единственной ответственности. Select можно представить как композицию более мелких компонентов.



  • Container связь между остальными компонентами.
  • Field текст для обычного селекта и инпут для компонента CobmoBox, где пользователь что-то вводит.
  • Icon традиционный для селекта значок в поле.
  • Menu компонент, который отображает список элементов для выбора.
  • Item отдельный элемент в меню.

Для соблюдения принципа единственной ответственности нужно вынести все сущности в отдельные компоненты, оставив каждому только одну причину для редактирования. Когда мы распилим файл, возникнет вопрос: как теперь кастомизировать получившийся набор компонентов? Например, если нужно задать тёмную тему для поля, увеличить иконку и изменить цвет меню. Есть два способа решить эту задачу.

Overrides


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

<Select  ...  overrides={{    Field: {      props: {theme: 'dark'}    },    Icon: {      props: {size: 'big'},    },    Menu: {      style: {backgroundColor: '#CCCCCC'}    },  }}/>

Я привёл простой пример, где мы переопределяем пропы. Но override можно рассматривать как глобальный конфиг он настраивает всё, что поддерживает компоненты. Увидеть, как это работает на практике, можно в библиотеке BaseWeb.

Итого, с помощью override можно гибко настраивать композитные компоненты, а ещё такой подход отлично масштабируется. Из минусов: конфиги для сложных компонентов получаются очень большими, а мощь override имеет и обратную сторону. Мы получаем полный контроль над внутренними компонентами, что позволяет делать странные вещи и выставлять невалидные настройки. Также, если вы не используете библиотеки, а хотите реализовать подход самостоятельно, придётся научить компоненты понимать конфиг или написать обёртки, которые будут читать его и настраивать компоненты правильно.

Dependency Inversion Principle


Чтобы разобраться в альтернативе override-конфигам, обратимся к букве D в SOLID. Это принцип инверсии зависимостей. Он утверждает, что код, реализующий верхнеуровневую политику, не должен зависеть от кода, реализующего низкоуровневые детали.

Вернёмся к нашему селекту. Container отвечает за взаимодействие между другими частями компонента. Фактически он представляет собой корень, управляющий рендером остальных блоков. Для этого он должен их импортировать.

Так будет выглядеть корень любого сложного компонента, если не использовать инверсию зависимостей:

import InputField from './InputField';import Icon from './Icon';import Menu from './Menu';import Option from './Option';

Разберём зависимости между компонентами, чтобы понять, что может пойти не так. Сейчас более высокоуровневый Select зависит от низкоуровневого Menu, потому что импортит его в себя. Принцип инверсии зависимостей нарушен. Это создаёт проблемы.
  • Во-первых, при изменении Menu придётся править Select.
  • Во-вторых, если мы захотим использовать другой компонент меню, нам тоже придётся вносить правки в компонент селекта.


Непонятно, что делать, когда понадобится Select с другим меню

Нужно развернуть зависимость. Сделать так, чтобы компонент меню зависел от селекта. Инверсия зависимостей делается через инъекцию зависимостей Select должен принимать компонент меню как один из параметров, пропов. Здесь нам поможет типизация. Мы укажем, какой компонент ожидает Select.

// теперь вместо прямого импорта Select принимает одним из параметров компонент менюconst Select = ({  Menu: React.ComponentType<IMenu>}) => {  return (    ...    <Menu>      ...    </Menu>    ...  )...}

Так мы декларируем, что селекту нужен компонент меню, пропы которого удовлетворяют определённому интерфейсу. Тогда стрелки будут направлены в обратную сторону, как и предписывает принцип DI.


Стрелка развёрнута, так работает инверсия зависимостей

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

Каждый раз прокидывать все зависимости в компонент в месте рендера утомительно, но в библиотеке bem-react есть реестр зависимостей и процесс композиции. С их помощью можно упаковать зависимости и настройки один раз, а дальше просто использовать готовый компонент.

import { compose } from '@bem-react/core'import { withRegistry, Registry } from '@bem-react/di'const selectRegistry = new Registry({ id: cnSelect() })...selectRegistry.fill({    'Trigger': Button,    'Popup': Popup,    'Menu': Menu,    'Icon': Icon,})const Select = compose(    ...    withRegistry(selectRegistry),)(SelectDesktop)

В примере выше показана часть сборки компонента на примере bem-react. Полный код примера и песочницу можно посмотреть в сторибуке yandex UI.

Что мы получаем от использования Dependency Inversion?

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

Мы в Яндекс.Учебнике пишем собственные компоненты, используя DI. Внутренняя библиотека компонентов Лего тоже использует этот подход. Но один существенный минус у него есть гораздо более сложная разработка.

Сложности разработки переиспользуемых компонентов


В чём же сложность разработки переиспользуемых компонентов?

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

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

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

В Учебнике есть внутренняя библиотека компонентов, где находятся образовательные механики часть интерфейса, с которой взаимодействуют дети во время решения заданий. И ещё есть общая библиотека образовательных сервисов. Туда мы выносим компоненты, которые хотим переиспользовать между разными сервисами.

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

Liskov Substitution Principle


Предыдущие принципы были о том, что нужно делать, а последние два будут о том, что нужно не сломать.

Начнём с принципа подстановки Барбары Лисков. Он говорит, что объекты в программе должны быть заменяемыми на экземпляры их подтипов без нарушения правильности выполнения программы.

Мы обычно не пишем компоненты как классы и не используем наследование. Все компоненты взаимозаменяемы из коробки. Это основа современного фронтенда. Не совершать ошибок и поддерживать совместимость помогает строгая типизация.

Как же заменяемость из коробки может сломаться? У компонента есть API. Под API я понимаю совокупность пропов компонента и встроенных во фреймворк механизмов, таких, как механизм обработчика событий. Строгая типизация и линтинг в IDE способны подсветить несовместимость в API, но компонент может взаимодействовать с внешним миром и в обход API:

  • читать и писать что-то в глобальный стор,
  • взаимодействовать с window,
  • взаимодействовать с cookie,
  • читать/писать local storage,
  • делать запросы в сеть.



Всё это небезопасно, потому что компонент зависит от окружения и может сломаться, если перенести его в другое место или в другой проект.

Чтобы соблюдать принцип подстановки Лисков нужно:

  • использовать возможности типизации,
  • избегать взаимодействия в обход API компонента,
  • избегать побочных эффектов.

Как избежать взаимодействия не через API? Вынести всё, от чего зависит компонент, в API и написать обёртку, которая будет пробрасывать данные из внешнего мира в пропы. Например, так:
const Component = () => {   /*      К сожалению, использование хуков приводит к тому, что компонент много знает о своем окружении.      Например, тут он знает о наличии стора и его внутренней структуре.      При переносе в другой проект, где стор отсутствует, код может сломаться.   */   const {userName} = useStore();    // Тут компонент знает о куках, что не очень хорошо для переиспользования (может сломаться, если в другом проекте это не так).   const userToken = getFromCookie();    // Аналогично  доступ к window может стать проблемой при переиспользовании компонента.   const {taskList} = window.ssrData;    const handleTaskUpdate = () => {       // Компонент знает об API сервера. Это допустимо только для верхнеуровневых компонентов.       fetch(...)   }    return <div>{'...'}</div>;  }; /*   Здесь компонент принимает только необходимый ему набор данных.   Его можно легко переиспользовать, потому что только верхнеуровневые компоненты будут знать все детали.*/const Component2 = ({   userName, userToken, onTaskUpdate}) => {   return <div>{'...'}</div>;};

Interface Segregation Principle


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

Нужно передавать в компонент как можно меньшее количество сущностей и не передавать данные, которые им не используются. Большое количество пропов в компоненте повод насторожиться. Скорее всего, он нарушает принципы SOLID.

Где и как переиспользуем?


Мы обсудили принципы, которые помогут в написании качественных компонентов. Теперь разберём, где и как мы их переиспользуем. Это поможет понять, с какими ещё проблемами можно столкнуться.

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

Переиспользование пока не требуется. Вы написали компонент, считаете, что он специфичный и не планируете нигде больше его использовать. Можно не предпринимать дополнительных усилий. А можно сделать несколько простых действий, которые окажутся полезны, если вы всё-таки захотите к нему вернуться. Так, например, можно проверить, что компонент не слишком сильно завязан на окружение, а зависимости вынесены в обёртки. Также можно сделать запас для кастомизации на будущее: добавить темы или возможность изменять внешний вид компонента извне (как в примере с кнопкой) это не займёт много времени.

Переиспользование в том же проекте. Вы написали компонент и почти уверены, что захотите его переиспользовать в другом месте текущего проекта. Здесь актуально всё написанное выше. Только теперь обязательно нужно убрать все зависимости во внешние обёртки и крайне желательно иметь возможность кастомизации извне (темы или миксы). Если компонент содержит много логики, стоит задуматься, везде ли она нужна, или в каких-то местах её стоит модифицировать. Для второго варианта предусмотрите возможность кастомизации. Также здесь важно подумать над структурой компонента и разбить его на части при необходимости.

Переиспользование в похожем стеке. Вы понимаете, что компонент будет полезен в соседнем проекте, у которого тот же стек, что и у вас. Здесь всё сказанное выше становится обязательным. Кроме этого, советую внимательно следить за зависимостями и технологиями. Точно ли соседний проект использует те же версии библиотек, что и вы? Использует ли SASS и TypeScript той же версии?

Отдельно хочу выделить переиспользование в другой среде исполнения, например, в SSR. Решите, действительно ли ваш компонент может и должен уметь рендериться на SSR. Если да, заранее удостоверьтесь, что он рендерится, как ожидается. Помните, что существуют другие рантаймы, например, deno или GraalVM. Учитывайте их особенности, если используете.

Библиотеки компонентов


Если компоненты нужно переиспользовать между несколькими репозиториями и/или проектами, их следует вынести в библиотеку.

Стек


Чем больше технологий используется в проектах, тем сложнее будет решать проблемы совместимости. Лучше всего сократить зоопарк и свести к минимуму количество используемых технологий: фреймворков, языков, версий крупных библиотек. Если же вы понимаете, что вам действительно нужно много технологий, придётся научиться с этим жить. Например, можно использовать обёртки над веб-компонентами, собирать всё в чистый JS или использовать адаптеры для компонентов.

Размер


Если использование простого компонента из вашей библиотеки добавляет пару мегабайт к бандлу это не ок. Такие компоненты не хочется переиспользовать, потому что перспектива написать свою лёгкую версию с нуля кажется оправданной. Решить проблему можно с помощью инструментов контроля размера, например, size-limit.

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

Важно, чтобы модульная библиотека не собиралась в один файл. Также нужно следить за версией JS, в которую собирается библиотека. Если вы собираете библиотеку в ES.NEXT, а проекты в ES5, возникнут проблемы. Ещё нужно правильно настроить сборку для старых версий браузеров и сделать так, чтобы все пользователи библиотеки знали, во что она собирается. Если это слишком сложно, есть альтернатива настроить собственные правила сборки библиотеки в каждом проекте.

Обновление


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

Вынося компоненты в библиотеку, которую использует кто-то ещё, вы теряете лёгкость рефакторинга. Чтобы груз рефакторинга не стал неподъёмным, советую не тащить в библиотеки новые компоненты. Они с высокой вероятностью будут меняться, а значит, придётся тратить много времени на обновление и поддержку совместимости.

Кастомизация и дизайн


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

Витрина компонентов


Упростить взаимодействие с дизайнерами поможет витрина компонентов. Одно из популярных решений для витрины Storybook. С помощью этого или другого подходящего инструмента можно показать компоненты проекта любому человеку не из разработки.

Добавьте в витрину интерактивность у дизайнеров должна быть возможность взаимодействовать с компонентами и видеть, как они отображаются и работают с разными параметрами.

Не забудьте настроить автоматическое обновление витрины при обновлении компонентов. Для этого нужно вынести процесс в CI. Теперь дизайнеры всегда смогут посмотреть, какие готовые компоненты есть в проекте, и воспользоваться ими.



Дизайн-система


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

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

Последний универсальный и очевидный совет: общайтесь и договаривайтесь. Не нужно воспринимать дизайнеров как тех, кто стоит в стороне от разработки и только создаёт и правит макеты. Тесное взаимодействие с ними поможет спроектировать и реализовать качественный интерфейс. В конечном счёте это пойдёт на пользу общему делу и порадует пользователей продукта.

Выводы


Дублирование кода приводит к сложностям в разработке и снижению качества фронтенда. Чтобы избежать последствий, необходимо следить за качеством компонентов, а писать качественные компоненты помогают принципы SOLID.

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

Как видите, задача непростая. Разрабатывать качественные переиспользуемые компоненты сложно и долго. Стоит ли оно того? Я считаю, что каждый ответит на этот вопрос сам. Для небольших проектов накладные расходы могут оказаться слишком высокими. Для проектов, где не планируется длительное развитие, вкладывать усилия в повторное использование кода тоже спорное решение. Однако, сказав сейчас нам это не нужно, легко не заметить, как окажешься в ситуации, где отсутствие переиспользуемых компонентов принесёт массу проблем, которых могло бы и не быть. Поэтому не повторяйте наших ошибок и dont repeat yourself!

Смотреть доклад
Подробнее..

Чем меня не устраивает гексагональная архитектура. Моя имплементация DDD многоуровневая блочная архитектура

15.05.2021 18:08:01 | Автор: admin


* В данной статье примеры будут на TypeScript


Краткое предисловие


Что такое DDD (Domain Driven Design) вопрос обширный, но если в кратце (как Я это понимаю) это про перенос бизнес логики, как она есть, в код, без углубления в технические детали. То есть в идеале, человек, который знает за бизнес процессы, может открыть код и понять, что там происходит (так кстати часто бывает в 1С).


Всё это сопровождается кучей разных рекомендаций по технической реализации вопроса.


Для лучшего понимания статьи советую прочитать материалы, касающиеся DDD.


Гексагональная архитектура это один из подходов реализации DDD.


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


Конкретнее описывать не буду, на хабре полно статей на эту тему, всё давно разжевано и практически переварено.


Вместо этого Я покажу картинку (рис.1):


рис.1


Скажите пожалуйста, что Вам понятно из этой картинки?


Например мне, когда Я первый раз увидел её, было непонятно абсолютно всё.


И, как бы ни было смешно, это первая проблема для меня.


Визуализация должна давать понимание, а не добавлять вопросов.


В ходе изучения всё частично становится на свои места, но вопросы и проблемы остаются.


И тут Я задумался о приложении в общем, развивая идеи вынесенные из DDD в целом и гексагональной архитектуры в частности.


Что мы имеем:


  • Реальная жизнь. Здесь есть бизнес процессы, которые мы должны автоматизировать.
  • Приложение, которое решает проблемы из реальной жизни, которое в свою очередь, не находится в вакууме. У приложения есть:
    1. Пользователи, будь то АПИ, кроны, пользовательские интерфейсы и т.д.
    2. Сам код приложения.
    3. Объекты данных БД, другие АПИ.

Движение идёт сначала сверху вниз, потом обратно, то есть:


  • Субъекты из реальной жизни взаимодействуют с приложением, код приложения взаимодействует с объектами данных, затем получив от них ответ, возвращает его пользователям.

Всё логично.


Теперь углубимся в код приложения.


Как сделать так, чтобы код был понятным, тестируемым, но при этом максимально независимым от внешних объектов данных, таких как БД, АПИ и т.д.?


В ответ на этот вопрос родилась следующая схема (рис.2):


рис.2


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


Ещё одна разница состоит в том, что добавлен уровень бизнес процессов, о котором поговорим ниже.


Многоуровневая блочная архитектура


Пробежимся по схеме.


На рисунке (рис.2), слева, мы видим названия сущностей, справа назначение уровней и их зависимости друг от друга.


Сверху вниз:


  1. Порты уровень взаимодействия, который зависит от уровня бизнес процессов. Уровень отвечает за взаимодействие с приложением, то есть хранит контроллеры. Пользоваться приложением можно только через порты.
  2. Ядро приложения уровень бизнес процессов, является центром всех зависимостей. Всё приложение строится исходя из бизнес процессов.
  3. Домены уровень бизнес логики, который зависит от уровня бизнес процессов. Домены образуются и выстраиваются на основании тех бизнес процессов, которые мы хотим автоматизировать. Домены отвечают за конкретную бизнес логику.
  4. Адаптеры уровень агрегации данных, который зависит от уровня бизнес логики. Сверху получает интерфейсы данных, которые должен реализовать. Отвечает за получение и нормализацию данных из объектов данных.
  5. Объекты данных уровень хранения данных, который не входит в приложение, но т.к. приложение не существует в вакууме, мы должны учитывать их.

Несколько правил


По ходу практики родилось и несколько правил, которые позволяют сохранять чистоту, простоту и универсальность кода:


  1. Бизнес процессы должны возвращать однозначный ответ.
    Например создание клиента, при наличии партнерской программы. Можно сделать бизнес процесс, который создает клиента, а если у него есть партнерский код добавляет его ещё и в партнеры, но это не правильно. Из за подобного подхода ваши бизнес процессы становятся непрозрачными и излишне сложными. Вы должны создать 2 бизнес процесса создание клиента и создание партнера.
  2. Домены не должны общаться на прямую между собой. Всё общение между доменами происходит в бизнес процессах. Иначе домены становятся взаимозависимыми.
  3. Все доменные контроллеры не должны содержать бизнес логики, они лишь вызывают доменные методы.
  4. Доменные методы должны быть реализованы как чистые функции, у них не должно быть внешних зависимостей.
  5. У методов все входящие данные уже должны быть провалидированы, все необходимые параметры должны быть обязательными (тут помогут data-transfer-object-ы или просто DTO-шки).
  6. Для unit тестирования уровня нужен нижестоящий уровень. Инъекция (DI) производится только в нижестоящий уровень, например тестируете домены подменяете адаптеры.

Как происходит разработка, согласно этой схеме


  1. Выделяются бизнес процессы, которые мы хотим автоматизировать, описываем уровень бизнес процессов.
  2. Бизнес процессы разбиваются на цепочки действий, которые связаны с конкретными областями (домены).
  3. Решаем как мы храним данные и с какими внешними сервисами взаимодействуем подбираем адаптеры и источники данных, которые наши адаптеры поддерживают. Например в случае с БД мы решаем хранить наши данные в реляционной базе данных, ищем ORM, которая умеет с ними работать и при этом отвечает нашим требованиям, затем под неё выбираем БД, с которой наша ORM умеет работать. В случае с внешними API, часто придется писать свои адаптеры, но опять таки с оглядкой на домены, потому что у адаптера есть 2 главные задачи: получить данные и отдать их наверх в необходимом домену, адаптированном виде.
  4. Решаем как мы взаимодействуем с приложением, то есть продумываем порты.

Небольшой пример


Мы хотим сделать небольшую CRM, хранить данные хотим в реляционной БД, в качестве ORM используем TypeORM, в качестве БД PostgresSQL.


Будет показан не весь код сервера, а лишь основные моменты, которые Вы сможете применить в своём приложении уже сейчас

Для начала реализуем бизнес процесс создания клиента.


Подготовим структуру папок:


рис.3


Для удобства добавим алиасы:


@clients = src/domains/clients@clientsEnities = src/adapters/typeorm/entities/clients@adapters = src/adapters

Из чего состоит бизнес процесс в самом простом виде:


  • на вход мы получаем данные о клиенте
  • нам нужно сохранить его в БД

После общения с доменным экспертом узнаем, что помимо общих данных клиента, у него могут быть различные контактные данные.


Формируем доменные модели, которые должны реализовать наши адаптеры. В нашем случае это 2 модели: клиент и контактные данные


domains/clients/models/Client.ts

import { Contact } from './Contact';export interface Client {  id: number;  title: string;  contacts?: Contact[];}

domains/clients/models/Contact.ts

import { Client } from './Client';export enum ContactType {  PHONE = 'phone',  EMAIL = 'email',}export interface Contact {  client?: Client;  type: ContactType;  value: string;}

Под них формируем TypeORM enitity


adapters/typeorm/entities/clients/Client.ts

import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';import { Client as ClientModel } from '@clients/models/Client';import { Contact } from './Contact';@Entity({ name: 'clients' })export class Client implements ClientModel {  @PrimaryGeneratedColumn()  id: number;  @Column()  title: string;  @OneToMany((_type) => Contact, (contact) => contact.client)  contacts?: Contact[];}

adapters/typeorm/entities/clients/Contact.ts

import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';import { Contact as ContactModel, ContactType } from '@clients/models/Contact';import { Client } from './Client';@Entity({ name: 'contacts' })export class Contact implements ContactModel {  @PrimaryGeneratedColumn()  id: number;  @Column({ type: 'string' })  type: ContactType;  @Column()  value: string;  @ManyToOne((_type) => Client, (client) => client.contacts, { nullable: false })  client?: Client;}

Сразу объясню почему поля со связями у меня помечены как опциональные: т.к. данные лежат в разных таблицах, их всегда надо дозапрашивать. Можно конечно их сделать обязательными, но если вы где-то забудете дозапросить данные получите ошибку.


Реализуем доменный метод создания клиента и доменный контроллер.


domains/clients/methods/createClient.ts

import { Repository } from 'typeorm';import { Client as ClientModel } from '@clients/models/Client';import { Client } from '@clientsEnities/Client';export async function  createClient(repo: Repository<Client>, clientData: ClientModel) {  const client = await repo.save(clientData);  return client;}

domains/clients/index.ts

import { Connection } from 'typeorm';import { Client } from '@clientsEnities/Client';import { Client as ClientModel } from '@clients/models/Client';import { createClient } from './methods/createClient';export class Clients {  protected _connection: Connection;  constructor(connection: Connection) {    if (!connection) {      throw new Error('No connection!');    }    this._connection = connection;  }  protected getRepository<T>(Entity: any) {    return this._connection.getRepository<T>(Entity);  }  protected getTreeRepository<T>(Entity: any) {    return this._connection.getTreeRepository<T>(Entity);  }  public async createClient(clientData: ClientModel) {    const repo = this.getRepository<Client>(Client);    const client = await createClient(repo, clientData);    return client;  }}

Т.к. TypeORM немного специфичная библиотека, внутрь мы прокидываем (для DI) не конкретные репозитории, а connection, который будем подменять при тестах.


Осталось создать бизнес процесс.


businessProcesses/createClient.ts

import { Client as ClientModel } from '@clients/models/Client';import { Clients } from '@clients';import { db } from '@adapters/typeorm'; // Я складываю TypeORM соединения в объект dbexport function createClient(clientData: ClientModel) {  const clients = new ClientService(db.connection)  const client = await clients.createClient(clientData)  return  client}

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


Что нам даёт данная архитектура?


  1. Понятную и удобную структуру папок и файлов.
  2. Удобное тестирование. Т.к. всё приложение разбито на слои выберете нужный слой, подменяете нижестоящий слой и тестируете.
  3. Удобное логирование. В примере видно, что логирование можно встроить на каждый этап работы приложения от банального замера скорости выполнения конкретного доменного метода (просто обернуть функцию метода функцией оберткой, которая всё замерит), до полного логирования всего бизнес процесса, включая промежуточные результаты.
  4. Удобную валидацию данных. Каждый уровень может проверять критичные для себя данные. Например тот же бизнес процесс создания клиента по хорошему в начале должен создать DTO для модели клиента, который провалидирует входящие данные, затем он должен вызвать доменный метод, который проверит, существует ли уже такой клиент и только потом создаст клиента. Сразу скажу про права доступа Я считаю что права доступа это адаптер, который Вы должны также прокидывать при создании доменного контроллера и внутри в контроллерах проверять права.
  5. Легкое изменение кода. Допустим Я хочу после создания клиента создавать оповещение, то есть хочу обновить бизнес процесс. Захожу в бизнес процесс, в начале добавляю инциализацию домена notifications и после получения результата создания клиента делаю notifications.notifyClient({ client: client.id, type:SUCCESS_REGISTRATION })

На этом всё, надеюсь было интересно, спасибо за внимание!

Подробнее..

Перевод Карманная книга по TypeScript. Часть 1. Основы

29.05.2021 16:20:40 | Автор: admin

С сегодняшнего дня мы начинаем серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript".


Каждое значение в JavaScript при выполнении над ним каких-либо операций ведет себя определенным образом. Это может звучать несколько абстрактно, но, в качестве примера, попробуем выполнить некоторые операции над переменной message:


// Получаем доступ к свойству `toLowerCase`// и вызываем егоmessage.toLowerCase()// Вызываем `message`message()

На первой строке мы получаем доступ к свойству toLowerCase и вызываем его. На второй строке мы пытаемся вызвать message.


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


  • Является ли переменная message вызываемой?


  • Имеет ли она свойство toLowerCase?


  • Если имеет, является ли toLowerCase вызываемым?


  • Если оба этих значения являются вызываемыми, то что они возвращают?



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


Допустим, message была определена следующим образом:


const message = 'Hello World'

Как вы, наверное, догадались, при запуске message.toLowerCase() мы получим ту же строку, только в нижнем регистре.


Что насчет второй строки кода? Если вы знакомы с JS, то знаете, что в этом случае будет выброшено исключение:


TypeError: message is not a function// Ошибка типа: message  это не функция

Было бы здорово, если бы мы имели возможность избегать подобных ошибок.


При запуске нашего кода, способ, с помощью которого движок JS определяет, что делать, заключается в выяснении типа (type) значения каким поведением и возможностями он обладает. На это намекает TypeError она говорит, что строка 'Hello World' не может вызываться как функция.


Для некоторых значений, таких как примитивы string и number, мы можем определить их тип во время выполнения кода (runtime) с помощью оператора typeof. Но для других значений, таких как функции, соответствующий механизм для определения типов во время выполнения отсутствует. Например, рассмотрим следующую функцию:


function fn(x) {return x.flip()}

Читая этот код, мы можем сделать вывод, что функция будет работать только в случае передачи ей объекта с вызываемым свойством flip, но JS не обладает этой информацией. Единственным способом определить, что делает fn с определенным значением, в чистом JS является вызов этой функции. Такой вид поведения затрудняет предсказание поведения кода во время его написания.


В данном случае тип это описание того, какие значения могут передаваться в fn, а какие приведут к возникновению ошибки. JS это язык с динамической (слабой) типизацией мы не знаем, что произойдет, до выполнения кода.


Статическая система типов позволяет определять, что ожидает код до момента его выполнения.


Проверка статических типов


Вернемся к TypeError, которую мы получили, пытаясь вызвать string как функцию. Никто не любит получать ошибки или баги (bugs) при выполнении кода.


Было бы здорово иметь инструмент, помогающий нам выявлять баги перед запуском кода. Это как раз то, что делают инструменты проверки статических типов, подобные TS. Системы статических типов описывают форму и поведение значений. TS использует эту информацию и сообщает нам о том, что, возможно, имеет место несоответствие определенным типам.


const message = 'Hello!'message()// This expression is not callable. Type 'String' has no call signatures. Данное выражение не является вызываемым. Тип 'String' не обладает сигнатурами вызова

При использовании TS, мы получаем ошибку перед выполнением кода (на этапе компиляции).


Ошибки, не являющиеся исключениями


До сих пор мы говорили об ошибках времени выполнения случаях, когда движок JS сообщает нам о том, что произошло нечто с его точки зрения бессмысленое. Спецификация ECMAScript содержит конкретные инструкции относительно того, как должен вести себя код при столкновении с чем-то неожиданным.


Например, спецификация определяет, что при попытке вызвать нечто невызываемое должно быть выброшено исключение. На основании этого, мы можем предположить, что попытка получить доступ к несуществующему свойству объекта также приводит к возникновению ошибки. Однако, вместо этого возвращается undefined:


const user = {name: 'John',age: 30}user.location // undefined

В TS это, как и ожидается, приводит к ошибке:


const user = {name: 'John',age: 30}user.location// Property 'location' does not exist on type '{ name: string; age: number; }'. Свойства 'location' не существует в типе...

Это позволяет перехватывать (catch) многие легальные, т.е. допустимые (с точки зрения спецификации) ошибки.


Например:


  • опечатки

const announcement = Hello World!;// Как быстро вы заметите опечатку?announcement.toLocaleLowercase();announcement.toLocalLowerCase();// Вероятно, мы хотели написать этоannouncement.toLocaleLowerCase();

  • функции, которые не были вызваны

function flipCoin() {// Должно было быть `Math.random()`return Math.random < 0.5;// Operator '<' cannot be applied to types '() => number' and 'number'. Оператор '<' не может быть применен к типам...}

  • или логические ошибки

const value = Math.random() < 0.5 ? a : b;if (value !== a) {// ...} else if (value === b) {// This condition will always return 'false' since the types 'a' and 'b' have no overlap. Данное условие будет всегда возвращать 'false', поскольку типы 'a' и 'b' не пересекаются// Упс, недостижимый участок кода}

Типы, интегрированные в среду разработки


TS защищает нас от совершения ошибок. Как он это делает? Все просто. Поскольку TS обладает информацией о системе типов, используемых в нашем коде, он начинает предполагать (делать вывод относительно того), какое свойство мы хотим использовать. Это означает, что TS показывает сообщения об ошибках и варианты завершения в процессе написания кода. Редактор кода, поддерживающий TS, также может предлагать способы быстрого исправления ошибок, предоставлять средства для автоматического рефакторинга, т.е. для легкой реорганизации кода, а также для полезной навигации, например, для быстрого перехода к определениям переменных или для поиска ссылок на переменную и т.д.


tsc, компилятор TS


Для начала установим tsc:


yarn global add tsc# илиnpm i -g tsc

Создадим файл hello.ts:


// Приветствуем всех собравшихсяconsole.log('Hello World!')

И скомпилируем (преобразуем) его в JS:


tsc hello.ts

Отлично. Мы не получили сообщений об ошибках в терминале, следовательно, компиляция прошла успешно. Заглянем в текущую директорию. Мы видим, что там появился файл hello.js. Этот файл является идентичным по содержанию файлу hello.ts, поскольку в данном случае TS нечего было преобразовывать. Кроме того, компилятор старается сохранять код максимально близким к тому, что написал разработчик.


Теперь попробуем вызвать ошибку. Перепишем hello.ts:


function greet(person, date) {console.log(`Hello, ${person}! Today is ${date}.`)}greet('John')

Если мы снова запустим tsc hello.ts, то получим ошибку:


Expected 2 arguments, but got 1. Ожидалось 2 аргумента, а получен 1

TS сообщает нам о том, что мы забыли передать аргумент в функцию greet, и он прав.


Компиляция с ошибками


Вы могли заметить, что после компиляции кода, содержащего ошибку, файл hello.js все равно обновился. Это объясняется тем, что TS считает вас умнее себя. Это также не мешает работающему JS-коду, при наличии некоторых ошибок, связанных с типами, благополучно работать дальше при постепенном переносе проекта на TS. Однако, если вы хотите, чтобы TS был более строгим, то можете указать флаг --noEmitOnError. Попробуйте снова изменить hello.ts и скомпилировать его с помощью такой команды:


tsc --noEmitOnError hello.ts

Вы увидите, что hello.js больше не обновляется.


Явные типы


Давайте отредактируем код и сообщим TS, что person это string, а date объект Date. Мы также вызовем метод toDateString() на date:


function greet(person: string, date: Date) {console.log(`Hello, ${person}! Today is ${date.toDateString().}`)}

То, что мы сделали, называется добавлением аннотаций типа (type annotations) к person и date для описания того, с какими типами значений может вызываться greet.


После этого TS будет сообщать нам о неправильных вызовах функции, например:


function greet(person: string, date: Date) {console.log(`Hello, ${person}! Today is ${date.toDateString()}.`);}greet('John', Date());// Argument of type 'string' is not assignable to parameter of type 'Date'. Аргумент типа 'string' не может быть присвоен параметру типа 'Date'

Вызов Date() возвращает строку. Для того, чтобы получить объект Date, следует вызвать new Date():


greet('John', new Date());

Во многих случаях нам не нужно явно аннотировать типы, поскольку TS умеет предполагать (infer) тип или делать вывод относительно типа на основе значения:


const msg = 'Hello!'// const msg: string

Удаление типов


Давайте скомпилируем функцию greet в JS с помощью tsc. Вот что мы получаем:


use strict;function greet(person, date) {console.log(Hello  + person + ! Today is  + date.toDateString() + .);}greet(John, new Date());

Обратите внимание на две вещи:


  1. Наши параметры person и date больше не имеют аннотаций типа.


  2. Наша шаблонная строка строка, в которой используются обратные кавычки (символ ```) была преобразована в обычную строку с конкатенациями (+).



Что касается первого пункта, то все дело в том, что аннотации типа не являются частью JS (или ECMAScript, если быть точнее), поэтому для того, чтобы преобразованный JS мог выполняться в браузере, они полностью удаляются из кода, как и любые другие специфичные для TS вещи.


Понижение уровня кода


Процесс, который часто называют понижением уровня кода (downleveling), состоит в преобразовании кода в код более старой версии, например, JS-кода, соответствующего спецификации ECMAScript 2015 (ES6), в код, соответствующий спецификации ECMAScript 3 (ES3). Шаблонные литералы (или шаблонные строки) были представлены в ES6, а TS по умолчанию преобразует код в ES3, поэтому наша шаблонная строка превратилась в обычную строку с объединениями. Для изменения спецификации, которой должен соответствовать компилируемый код, используется флаг --target. Например, команда tsc --target es2015 hello.ts оставит нашу строку неизменной.


Строгость


Строгость проверок, выполняемых TS, определяется несколькими флагами. Флаг --strict или настройка "strict": true в tsconfig.json включает максимальную строгость. Двумя другими главными настройками, определяющими строгость проверок, являются noImplicitAny и strictNullChecks.


  • noImplicitAny когда TS не может сделать точный вывод о типе значения, он присваивает такому значению наиболее мягкий тип any. Данный тип означает, что значением переменной может быть что угодно. Однако, использование данного типа противоречит цели использования TS. Использование флага noImplicitAny или соответствующей настройки приводит к тому, что при обнаружении переменной с неявным типом any выбрасывается исключение


  • strictNullChecks по умолчанию значения null и undefined могут присваиваться любым другим типам. Это может облегчить написание кода в некоторых ситуациях, но также часто приводит к багам, если мы забыли их правильно обработать. Флаг strictNullChecks или соответствующая настройка делает обработку null и undefined более явной и избавляет нас от необходимости беспокоиться о том, что мы забыли их обработать





Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Перевод Карманная книга по TypeScript. Часть 2. Типы на каждый день

30.05.2021 10:22:52 | Автор: admin

image


Мы продолжаем серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript".

Другие части:



Примитивы: string, number и boolean


В JS часто используется 3 примитива: string, number и boolean. Каждый из них имеет соответствующий тип в TS:


  • string представляет строковые значения, например, 'Hello World'
  • number предназначен для чисел, например, 42. JS не различает целые числа и числа с плавающей точкой (или запятой), поэтому не существует таких типов, как int или float только number
  • boolean предназначен для двух значений: true и false

Обратите внимание: типы String, Number и Boolean (начинающиеся с большой буквы) являются легальными и ссылаются на специальные встроенные типы, которые, однако, редко используются в коде. Для типов всегда следует использовать string, number или boolean.


Массивы


Для определения типа массива [1, 2, 3] можно использовать синтаксис number[]; такой синтаксис подходит для любого типа (например, string[] это массив строк и т.д.). Также можно встретить Array<number>, что означает тоже самое. Такой синтаксис, обычно, используется для определения общих типов или дженериков (generics).


Обратите внимание: [number] это другой тип, кортеж (tuple).


any


TS предоставляет специальный тип any, который может использоваться для отключения проверки типов:


let obj: any = { x: 0 }// Ни одна из строк ниже не приведет к возникновению ошибки на этапе компиляции// Использование `any` отключает проверку типов// Использование `any` означает, что вы знакомы со средой выполнения кода лучше, чем `TS`obj.foo()obj()obj.bar = 100obj = 'hello'const n: number = obj

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


noImplicitAny


При отсутствии определения типа и когда TS не может предположить его на основании контекста, неявным типом значение становится any.


Обычно, мы хотим этого избежать, поскольку any является небезопасным с точки зрения системы типов. Установка флага noImplicitAny позволяет квалифицировать любое неявное any как ошибку.


Аннотации типа для переменных


При объявлении переменной с помощью const, let или var опционально можно определить ее тип:


const myName: string = 'John'

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


// В аннотации типа нет необходимости - `myName` будет иметь тип `string`const myName = 'John'

Функции


В JS функции, в основном, используются для работы с данными. TS позволяет определять типы как для входных (input), так и для выходных (output) значений функции.


Аннотации типа параметров


При определении функции можно указать, какие типы параметров она принимает:


function greet(name: string) { console.log(`Hello, ${name.toUpperCase()}!`)}

Вот что произойдет при попытке вызвать функцию с неправильным аргументом:


greet(42)// Argument of type 'number' is not assignable to parameter of type 'string'. Аргумент типа 'number' не может быть присвоен параметру типа 'string'

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


Аннотация типа возвращаемого значения


Также можно аннотировать тип возвращаемого функцией значения:


function getFavouriteNumber(): number { return 26}

Как и в случае с аннотированием переменных, в большинстве случаев TS может автоматически определить тип возвращаемого функцией значения на основе инструкции return.


Анонимные функции


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


Вот пример:


// Аннотации типа отсутствуют, но это не мешает `TS` обнаруживать ошибкиconst names = ['Alice', 'Bob', 'John']// Определение типов на основе контекста вызова функцииnames.forEach(function (s) { console.log(s.toUppercase()) // Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'? Свойства 'toUppercase' не существует в типе 'string'. Вы имели ввиду 'toUpperCase'?})// Определение типов на основе контекста также работает для стрелочных функцийnames.forEach((s) => { console.log(s.toUppercase()) // Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'?})

Несмотря на отсутствие аннотации типа для s, TS использует типы функции forEach, а также предполагаемый тип массива для определения типа s. Этот процесс называется определением типа на основе контекста (contextual typing).


Типы объекта


Объектный тип это любое значение со свойствами. Для его определения мы просто перечисляем все свойства объекта и их типы. Например, так можно определить функцию, принимающую объект с координатами:


function printCoords(pt: { x: number, y: number }) { console.log(`Значение координаты 'x': ${pt.x}`) console.log(`Значение координаты 'y': ${pt.y}`)}printCoords({ x: 3, y: 7 })

Для разделения свойств можно использовать , или ;. Тип свойства является опциональным. Свойство без явно определенного типа будет иметь тип any.


Опциональные свойства


Для определения свойства в качестве опционального используется символ ? после названия свойства:


function printName(obj: { first: string, last?: string }) { // ...}// Обе функции скомпилируются без ошибокprintName({ first: 'John' })printName({ first: 'Jane', last: 'Air' })

В JS при доступе к несуществующему свойству возвращается undefined. По этой причине, при чтении опционального свойства необходимо выполнять проверку на undefined:


function printName(obj: { first: string, last?: string }) { // Ошибка - приложение может сломаться, если аргумент `last` не будет передан в функцию console.log(obj.last.toUpperCase()) // Object is possibly 'undefined'. Потенциальным значением объекта является 'undefined' if (obj.last !== undefined) {   // Теперь все в порядке   console.log(obj.last.toUpperCase()) } // Безопасная альтернатива, использующая современный синтаксис `JS` - оператор опциональной последовательности (`?.`) console.log(obj.last?.toUpperCase())}

Объединения (unions)


Обратите внимание: в литературе, посвященной TS, union, обычно, переводится как объединение, но фактически речь идет об альтернативных типах, объединенных в один тип.


Определение объединения


Объединение это тип, сформированный из 2 и более типов, представляющий значение, которое может иметь один из этих типов. Типы, входящие в объединение, называются членами (members) объединения.


Реализуем функцию, которая может оперировать строками или числами:


function printId(id: number | string) { console.log(`Ваш ID: ${id}`)}// OKprintId(101)// OKprintId('202')// ОшибкаprintId({ myID: 22342 })// Argument of type '{ myID: number }' is not assignable to parameter of type 'string | number'. Type '{ myID: number }' is not assignable to type 'number'. Аргумент типа '{ myID: number }' не может быть присвоен параметру типа 'string | number'. Тип '{ myID: number }' не может быть присвоен типу 'number'

Работа с объединениями


В случае с объединениями, TS позволяет делать только такие вещи, которые являются валидными для каждого члена объединения. Например, если у нас имеется объединение string | number, мы не сможем использовать методы, которые доступны только для string:


function printId(id: number | string) { console.log(id.toUpperCase()) // Property 'toUpperCase' does not exist on type 'string | number'. Property 'toUpperCase' does not exist on type 'number'.}

Решение данной проблемы заключается в сужении (narrowing) объединения. Например, TS знает, что только для string оператор typeof возвращает 'string':


function printId(id: number | string) { if (typeof id === 'string') {   // В этой ветке `id` имеет тип 'string'   console.log(id.toUpperCase()) } else {   // А здесь `id` имеет тип 'number'   console.log(id) }}

Другой способ заключается в использовании функции, такой как Array.isArray:


function welcomePeople(x: string[] | string) { if (Array.isArray(x)) {   // Здесь `x` - это 'string[]'   console.log('Привет, ' + x.join(' и ')) } else {   // Здесь `x` - 'string'   console.log('Добро пожаловать, одинокий странник ' + x) }}

В некоторых случаях все члены объединения будут иметь общие методы. Например, и массивы, и строки имеют метод slice. Если каждый член объединения имеет общее свойство, необходимость в сужении отсутствует:


function getFirstThree(x: number[] | string ) { return x.slice(0, 3)}

Синонимы типов (type aliases)


Что если мы хотим использовать один и тот же тип в нескольких местах? Для этого используются синонимы типов:


type Point = { x: number y: number}// В точности тоже самое, что в приведенном выше примереfunction printCoords(pt: Point) { console.log(`Значение координаты 'x': ${pt.x}`) console.log(`Значение координаты 'y': ${pt.y}`)}printCoords({ x: 3, y: 7 })

Синонимы можно использовать не только для объектных типов, но и для любых других типов, например, для объединений:


type ID = number | string

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


type UserInputSanitizedString = stringfunction sanitizeInput(str: string): UserInputSanitizedString { return sanitize(str)}// Создаем "обезвреженный" инпутlet userInput = sanitizeInput(getInput())// По-прежнему имеем возможность изменять значение переменнойuserInput = 'new input'

Интерфейсы


Определение интерфейса это другой способ определения типа объекта:


interface Point { x: number y: number}function printCoords(pt: Point) { console.log(`Значение координаты 'x': ${pt.x}`) console.log(`Значение координаты 'y': ${pt.y}`)}printCoords({ x: 3, y: 7 })

TS иногда называют структурно типизированной системой типов (structurally typed type system) TS заботит лишь соблюдение структуры значения, передаваемого в функцию printCoords, т.е. содержит ли данное значение ожидаемые свойства.


Разница между синонимами типов и интерфейсами


Синонимы типов и интерфейсы очень похожи. Почти все возможности interface доступны в type. Ключевым отличием между ними является то, что type не может быть повторно открыт для добавления новых свойств, в то время как interface всегда может быть расширен.


Пример расширения интерфейса:


interface Animal { name: string}interface Bear extends Animal { honey: boolean}const bear = getBear()bear.namebear.honey

Пример расширения типа с помощью пересечения (intersection):


type Animal { name: string}type Bear = Animal & { honey: boolean}const bear = getBear()bear.namebear.honey

Пример добавления новых полей в существующий интерфейс:


interface Window { title: string}interface Window { ts: TypeScriptAPI}const src = 'const a = 'Hello World''window.ts.transpileModule(src, {})

Тип не может быть изменен после создания:


type Window = { title: string}type Window = { ts: TypeScriptAPI}// Ошибка: повторяющийся идентификатор 'Window'.

Общее правило: используйте interface до тех пор, пока вам не понадобятся возможности type.


Утверждение типа (type assertion)


В некоторых случаях мы знаем о типе значения больше, чем TS.


Например, когда мы используем document.getElementById, TS знает лишь то, что данный метод возвращает какой-то HTMLElement, но мы знаем, например, что будет возвращен HTMLCanvasElement. В этой ситуации мы можем использовать утверждение типа для определения более конкретного типа:


const myCanvas = document.getElementById('main_canvas') as HTMLCanvasElement

Для утверждения типа можно использовать другой синтаксис (е в TSX-файлах):


const myCanvas = <HTMLCanvasElement>document.getElementById('main_canvas')

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


const x = 'hello' as number// Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.// Преобразование типа 'string' в тип 'number' может быть ошибкой, поскольку эти типы не перекрываются. Если это было сделано намерено, то выражение сначала следует преобразовать в 'unknown'

Иногда это правило может быть слишком консервативным и мешать выполнению более сложных валидных преобразований. В этом случае можно использовать двойное утверждение: сначала привести тип к any (или unknown), затем к нужному типу:


const a = (expr as any) as T

Литеральные типы (literal types)


В дополнение к общим типам string и number, мы можем ссылаться на конкретные строки и числа, находящиеся на определенных позициях.


Вот как TS создает типы для литералов:


let changingString = 'Hello World'changingString = 'Ol Mundo'// Поскольку `changingString` может представлять любую строку, вот// как TS описывает ее в системе типовchangingString // let changingString: stringconst constantString = 'Hello World'// Поскольку `constantString` может представлять только указанную строку, она// имеет такое литеральное представление типаconstantString // const constantString: 'Hello World'

Сами по себе литеральные типы особой ценности не представляют:


let x: 'hello' = 'hello'// OKx = 'hello'// ...x = 'howdy'// Type '"howdy"' is not assignable to type '"hello"'.

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


function printText(s: string, alignment: 'left' | 'right' | 'center') { // ...}printText('Hello World', 'left')printText("G'day, mate", "centre")// Argument of type '"centre"' is not assignable to parameter of type '"left" | "right" | "center"'.

Числовые литеральные типы работают похожим образом:


function compare(a: string, b: string): -1 | 0 | 1 { return a === b ? 0 : a > b ? 1 : -1}

Разумеется, мы можем комбинировать литералы с нелитеральными типами:


interface Options { width: number}function configure(x: Options | 'auto') { // ...}configure({ width: 100 })configure('auto')configure('automatic')// Argument of type '"automatic"' is not assignable to parameter of type 'Options | "auto"'.

Предположения типов литералов


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


const obj = { counter: 0 }if (someCondition) { obj.counter = 1}

TS не будет считать присвоение значения 1 полю, которое раньше имело значение 0, ошибкой. Это объясняется тем, что TS считает, что типом obj.counter является number, а не 0.


Тоже самое справедливо и в отношении строк:


const req = { url: 'https://example.com', method: 'GET' }handleRequest(req.url, req.method)// Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

В приведенном примере предположительный типом req.method является string, а не 'GET'. Поскольку код может быть вычислен между созданием req и вызовом функции handleRequest, которая может присвоить req.method новое значение, например, GUESS, TS считает, что данный код содержит ошибку.


Существует 2 способа решить эту проблему.


  1. Можно утвердить тип на каждой позиции:

// Изменение 1const req = { url: 'https://example.com', method: 'GET' as 'GET' }// Изменение 2handleRequest(req.url, req.method as 'GET')

  1. Для преобразования объекта в литерал можно использовать as const:

const req = { url: 'https://example.com', method: 'GET' } as consthandleRequest(req.url, req.method)

null и undefined


В JS существует два примитивных значения, сигнализирующих об отсутствии значения: null и undefined. TS имеет соответствующие типы. То, как эти типы обрабатываются, зависит от настройки strictNullChecks (см. часть 1).


Оператор утверждения ненулевого значения (non-null assertion operator)


TS предоставляет специальный синтаксис для удаления null и undefined из типа без необходимости выполнения явной проверки. Указание ! после выражения означает, что данное выражение не может быть нулевым, т.е. иметь значение null или undefined:


function liveDangerously(x?: number | undefined) { // Ошибки не возникает console.log(x!.toFixed())}

Перечисления (enums)


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


Редко используемые примитивы


bigint


Данный примитив используется для представления очень больших целых чисел BigInt:


// Создание `bigint` с помощью функции `BigInt`const oneHundred: bigint = BigInt(100)// Создание `bigint` с помощью литерального синтаксисаconst anotherHundred: bigint = 100n

Подробнее о BigInt можно почитать здесь.


symbol


Данный примитив используется для создания глобально уникальных ссылок с помощью функции Symbol():


const firstName = Symbol('name')const secondName = Symbol('name')if (firstName === secondName) { // This condition will always return 'false' since the types 'typeof firstName' and 'typeof secondName' have no overlap. // Символы `firstName` и `lastName` никогда не будут равными}

Подробнее о символах можно почитать здесь.




Облачные серверы от Маклауд идеально подходят для разработки на TypeScript.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Перевод Карманная книга по TypeScript. Часть 4. Подробнее о функциях

08.06.2021 10:08:20 | Автор: admin

image


Я продолжаю серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript".

Другие части:



Функции это основные строительные блоки любого приложения, будь то функции, импортируемые из другого модуля, или методы класса. В TS существует несколько способов описания того, как фукнции вызываются.


Тип функции в форме выражения (function type expressions)


Простейшим способом описания типа функции является выражение. Такие типы похожи на стрелочные функции:


function greeter(fn: (a: string) => void) { fn('Hello, World')}function printToConsole(s: string) { console.log(s)}greeter(printToConsole)

Выражение (a: string) => void означает "функция с одним параметром a типа string, которая ничего не возвращает". Как и в случае с определением функции, если тип параметра не указан, он будет иметь значение any.


Обратите внимание: название параметра является обязательным. Тип функции (string) => void означает "функция с параметром string типа any"!


Разумеется, для типа функции можно использовать синоним:


type GreetFn = (a: string) => voidfunction greeter(fn: GreetFn) { // ...}

Сигнатуры вызова (call signatures)


В JS функции, кроме того, что являются вызываемыми (callable), могут иметь свойства. Однако, тип-выражение не позволяет определять свойства функции. Для описания вызываемой сущности (entity), обладающей некоторыми свойствами, можно использовать сигнатуру вызова (call signature) в объектном типе:


type DescFn = { description: string (someArg: number): boolean}function doSomething(fn: DescFn) { console.log(`Значением, возвращаемым ${fn.description} является ${fn(6)}`)}

Обратите внимание: данный синтаксис немного отличается от типа-выражения функции между параметрами и возвращаемым значением используется : вместо =>.


Сигнатуры конструктора (construct signatures)


Как известно, функции могут вызываться с ключевым словом new. TS считает такие функции конструкторами, поскольку они, как правило, используются для создания объектов. Для определения типов таких функций используется сигнатура конструктора:


type SomeConstructor = { new (s: string): SomeObject}function fn(ctor: SomeConstructor) { return new ctor('Hello!')}

Некоторые объекты, такие, например, как объект Date, могут вызываться как с, так и без new. Сигнатуры вызова и конструктора можно использовать совместно:


interface CallOrConstruct { new (s: string): Date (n?: number): number}

Общие функции или функции-дженерики (generic functions)


Часто тип данных, возвращаемых функцией, зависит от типа передаваемого функции аргумента или же два типа возвращаемых функцией значений зависят друг от друга. Рассмотрим функцию, возвращающую первый элемент массива:


function firstElement(arr: any[]) { return arr[0]}

Функция делают свою работу, но, к сожалению, типом возвращаемого значения является any. Было бы лучше, если бы функция возвращала тип элемента массива.


В TS общие типы или дженерики (generics) используются для описания связи между двумя значениями. Это делается с помощью определения параметра Type в сигнатуре функции:


function firstElement<Type>(arr: Type[]): Type { return arr[0]}

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


// `s` имеет тип `string`const s = firstElement(['a', 'b', 'c'])// `n` имеет тип `number`const n = firstElement([1, 2, 3])

Предположение типа (inference)


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


function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] { return arr.map(func)}// Типом `n` является `string`,// а типом `parsed` - `number[]`const parsed = map(['1', '2', '3'], (n) => parseInt(n))

Обратите внимание, что в приведенном примере TS может сделать вывод относительно типа Input на основе переданного string[], а относительно типа Output на основе возвращаемого number.


Ограничения (constraints)


Ограничение, как следует из названия, используется для ограничения типов, принимаемых параметром типа.


Реализуем функцию, возвращающую самое длинное из двух значений. Для этого нам потребуется свойство length, которое будет числом. Мы ограничим параметр типа типом number с помощью ключевого слова extends:


function longest<Type extends { length: number }>(a: Type, b: Type) { if (a.length >= b.length) {   return a } else {   return b }}// Типом `longerArr` является `number[]`const longerArr = longest([1, 2], [1, 2, 3])// Типом `longerStr` является `string`const longerStr = longest('alice', 'bob')// Ошибка! У чисел нет свойства `length`const notOK = longest(10, 100)// Argument of type 'number' is not assignable to parameter of type '{ length: number }'.// Аргумент типа 'number' не может быть присвоен параметру типа '{ length: number; }'

Мы позволяем TS предполагать тип значения, возвращаемого из функции longest.


Поскольку мы свели Type к { length: number }, то получили доступ к свойству length параметров a и b. Без ограничения типа у нас бы не было такого доступа, потому что значения этих свойств могли бы иметь другой тип без длины.


Типы longerArr и longerStr были выведены на основе аргументов. Запомните, дженерики определяют связь между двумя и более значениями одного типа!


Наконец, как мы и ожидали, вызов longest(10, 100) отклоняется, поскольку тип number не имеет свойства length.


Работа с ограниченными значениями


Вот пример распространенной ошибки, возникающей при работе с ограничениями дженериков:


function minLength<Type extends { length: number }>( obj: Type, min: number): Type { if (obj.length >= min) {   return obj } else {   return { length: min } }}// Type '{ length: number; }' is not assignable to type 'Type'. '{ length: number; }' is assignable to the constraint of type 'Type', but 'Type' could be instantiated with a different subtype of constraint '{ length: number; }'.// Тип '{ length: number; }' не может быть присвоен типу 'Type'. '{ length: number; }' может присваиваться ограничению типа 'Type', но 'Type' может быть инстанцирован с другим подтипом ограничения '{ length: number; }'

На первый взгляд может показаться, что все в порядке Type сведен к { length: number }, и функция возвращает либо Type, либо значение, совпадающее с ограничением. Проблема состоит в том, что функция может вернуть объект, идентичный тому, который ей передается, а не просто объект, совпадающий с ограничением. Если бы во время компиляции не возникло ошибки, мы могли бы написать что-то вроде этого:


// `arr` получает значение `{ length: 6 }`const arr = minLength([1, 2, 3], 6)// и ломает приложение, поскольку массивы// имеют метод `slice`, но не возвращаемый объект!console.log(arr.slice(0))

Определение типа аргументов


Обычно, TS делает правильные выводы относительно типов аргументов в вызове дженерика, но так бывает не всегда. Допустим, мы реализовали такую функцию для объединения двух массивов:


function combine<Type>(arr1: Type[], arr2: Type[]): Type[] { return arr1.concat(arr2)}

При обычном вызове данной функции с несовпадающими по типу массивами возникает ошибка:


const arr = combine([1, 2, 3], ['привет'])// Type 'string' is not assignable to type 'number'.

Однако, мы можем вручную определить Type, и тогда все будет в порядке:


const arr = combine<string | number>([1, 2, 3], ['привет'])

Руководство по написанию хороших функций-дженериков


Используйте параметры типа без ограничений


Рассмотрим две похожие функции:


function firstElement1<Type>(arr: Type[]) { return arr[0]}function firstElement2<Type extends any[]>(arr: Type) { return arr[0]}// a: number (хорошо)const a = fisrtElement1([1, 2, 3])// b: any (плохо)const b = fisrtElement2([1, 2, 3])

Предполагаемым типом значения, возвращаемого функцией firstElement1 является Type, а значения, возвращаемого функцией firstElement2 any. Это объясняется тем, что TS разрешает (resolve) выражение arr[0] с помощью ограничения типа вместо того, чтобы ждать разрешения элемента после вызова функции.


Правило: по-возможности, используйте параметры типа без ограничений.


Используйте минимальное количество параметров типа


Вот еще одна парочка похожих функций:


function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] { return arr.filter(func)}function filter2<Type, Func extends (arg: Type) => boolean>( arr: Type[], func: Func): Type[] { return arr.filter(func)}

Во втором случае мы создаем параметр типа Func, который не связывает значения. Это означает, что при вызове функции придется определять дополнительный аргумент типа без веских на то причин. Это не есть хорошо.


Правило: всегда используйте минимальное количество параметров типа.


Параметры типа должны указываться дважды


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


function greet<Str extends string>(s: Str) { console.log(`Привет, ${s}!`)}greet('народ')

Вот упрощенная версия данной функции:


function greet(s: string) { console.log(`Привет, ${s}!`)}

Запомните, параметры типа предназначены для связывания типов нескольких значений.


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


Опциональные параметры (optional parameters)


Функции в JS могут принимать произвольное количество аргументов. Например, метод toFixed принимает опциональное количество цифр после запятой:


function fn(n: number) { console.log(n.toFixed()) // 0 аргументов console.log(n.toFixed(3)) // 1 аргумент}

Мы можем смоделировать это в TS, пометив параметр как опциональный с помощью ?:


function f(x?: number) { // ...}f() // OKf(10) // OK

Несмотря на то, что тип параметра определен как number, параметр x на самом деле имеет тип number | undefined, поскольку неопределенные параметры в JS получают значение undefined.


Мы также можем указать "дефолтный" параметр (параметр по умолчанию):


function f(x = 10) { // ...}

Теперь в теле функции f параметр x будет иметь тип number, поскольку любой аргумент со значением undefined будет заменен на 10. Обратите внимание: явная передача undefined означает "отсутствующий" аргумент.


declare function f(x?: number): void// OKf()f(10)f(undefined)

Опциональные параметры в функциях обратного вызова


При написании функций, вызывающих "колбеки", легко допустить такую ошибку:


function myForEach(arr: any[], callback: (arg: any, index?: number) => void) { for (let i = 0; i < arr.length; i++) {   callback(arr[i], i) }}

Указав index?, мы хотим, чтобы оба этих вызова были легальными:


myForEach([1, 2, 3], (a) => console.log(a))myForEach([1, 2, 3], (a, i) => console.log(a, i))

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


function myForEach(arr: any[], callback: (arg: any, index?: number) => void) { for (let i = 0; i < arr.length; i++) {   callback(arr[i]) }}

Поэтому попытка вызова такой функции приводит к ошибке:


myForEach([1, 2, 3], (a, i) => { console.log(i.toFixed()) // Object is possibly 'undefined'. // Возможным значением объекта является 'undefined'})

В JS при вызове функции с большим (ударение на первый слог) количеством аргументов, чем указано в определении фукнции, дополнительные параметры просто игнорируются. TS ведет себя аналогичным образом. Функции с меньшим количеством параметров (одного типа) могут заменять функции с большим количеством параметров.


Правило: при написании типа функции для колбека, не указывайте опциональные параметры до тех пор, пока не будете вызывать функцию без передачи этих параметров.


Перегрузка функции (function overload)


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


В TS такую функцию можно реализовать с помощью сигнатур перегрузки (overload signatures). Для этого перед телом функции указывается несколько ее сигнатур:


function makeDate(timestamp: number): Datefunction makeDate(d: number, m: number, y: number): Datefunction makeDate(dOrTimestamp: number, m?: number, y?: number): Date { if (m !== undefined && y !== undefined) {   return new Date(y, m, dOrTimestamp) } else {   return new Date(dOrTimestamp) }}const d1 = makeDate(12345678)const d2 = makeDate(5, 5, 5)const d3 = makeDate(1, 3)// No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.// Нет перегрузки, принимающей 2 аргумента, но существуют перегрузки, ожидающие получения 1 или 3 аргумента

В приведенном примере мы реализовали две перегрузки: одну, принимающую один аргумент, и вторую, принимающую три аргумента. Первые две сигнатуры называются сигнатурами перегрузки.


Затем мы реализовали функцию с совместимой сигнатурой (compatible signature). Функции имеют сигнатуру реализации (implementation signature), но эта сигнатура не может вызываться напрямую. Несмотря на то, что мы написали функцию с двумя опциональными параметрами после обязательного, она не может вызываться с двумя параметрами!


Сигнатуры перегрузки и сигнатура реализации


Предположим, что у нас имеется такой код:


function fn(x: string): voidfunction fn() { // ...}// Мы ожидаем, что функция может вызываться без аргументовfn()// Expected 1 arguments, but got 0.// Ожидалось получение 1 аргумента, а получено 0

Почему в данном случае возникает ошибка? Дело в том, что сигнатура реализации не видна снаружи (за пределами тела функции). Поэтому при написании перегруженной функции всегда нужно указывать две или более сигнатуры перегрузки перед сигнатурой реализации.


Кроме того, сигнатура реализации должна быть совместима с сигнатурами перегрузки. Например, при вызове следующих функций возникают ошибки, поскольку сигнатура реализации не совпадает с сигнатурами перегрузки:


function fn(x: boolean): void// Неправильный тип аргументаfunction fn(x: string): void// This overload signature is not compatible with its implementation signature.// Данная сигнатура перегрузки не совместима с сигнатурой ее реализацииfunction(x: boolean) {}

function fn(x: string): string// Неправильный тип возвращаемого значенияfunction(x: number): boolean// This overload signature is not compatible with its implementation signature.function fn(x: string | number) { return 'упс'}

Правила реализации хороших перегрузок функции


Рассмотрим функцию, возвращающую длину строки или массива:


function len(s: string): numberfunction len(arr: any[]): numberfunction len(x: any) { return x.length}

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


len('') // OKlen([0]) // OKlen(Math.random() > 0.5 ? 'привет' : [0])/*No overload matches this call. Overload 1 of 2, '(s: string): number', gave the following error.   Argument of type 'number[] | "привет"' is not assignable to parameter of type 'string'.     Type 'number[]' is not assignable to type 'string'. Overload 2 of 2, '(arr: any[]): number', gave the following error.   Argument of type 'number[] | "привет"' is not assignable to parameter of type 'any[]'.     Type 'string' is not assignable to type 'any[]'.*//*Ни одна из перегрузок не совпадает с вызовом. Перегрузка 1 из 2, '(s: string): number', возвращает следующую ошибку.   Аргумент типа 'number[] | "привет"' не может быть присвоен параметру типа 'string'.     Тип 'number[]' не может быть присвоен типу 'string'. Перегрузка 2 из 2, '(arr: any[]): number', возвращает следующую ошибку.   Аргумент типа 'number[] | "привет"' не может быть присвоен типу 'any[]'.     Тип 'string' не может быть присвоен типу 'any[]'.*/

Поскольку обе перегрузки имеют одинаковое количество аргументов и один и тот же тип возвращаемого значения, мы можем реализовать такую "неперегруженную" версию данной функции:


function len(x: any[] | string) { return x.length}

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


Правило: по-возможности используйте объединения вместо перегрузок функции.


Определение this в функциях


Рассмотрим пример:


const user = { id: 123, admin: false, becomeAdmin: function() {   this.admin = true }}

TS "понимает", что значением this функции user.becomeAdmin является внешний объект user. В большинстве случаев этого достаточно, но порой нам требуется больше контроля над тем, что представляет собой this. Спецификация JS определяет, что мы не можем использовать this в качестве названия параметра. TS использует это синтаксическое пространство (syntax space), позволяя определять тип this в теле функции:


const db = getDB()const admins = db.filterUsers(function() { return this.admin})

Обратите внимание: в данном случае мы не можем использовать стрелочную функцию.


const db = getDB()const admins = db.filterUsers(() => this.admin)// The containing arrow function captures the global value of 'this'. Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.// Стрелочная функция перехватывает глобальное значение 'this'. Неявным типом элемента является 'any', поскольку тип 'typeof globalThis' не имеет сигнатуры индекса

Другие типы, о которых следует знать


void


void представляет значение, возвращаемое функцией, которая ничего не возвращает. Если в теле функции отсутствует оператор return или после этого оператора не указано возвращаемого значения, предполагаемым типом возвращаемого такой функцией значения будет void:


// Предполагаемым типом является `void`function noop() { return}

В JS функция, которая ничего не возвращает, "неявно" возвращает undefined. Однако, в TS void и undefined это разные вещи.


Обратите внимание: void это не тоже самое, что undefined.


object


Специальный тип object представляет значение, которое не является примитивом (string, number, boolean, symbol, null, undefined). object отличается от типа пустого объекта ({}), а также от глобального типа Object. Скорее всего, вам никогда не потребуется использовать Object.


Правило: object это не Object. Всегда используйте object!


Обратите внимание: в JS функции это объекты: они имеют свойства, Object.prototype в цепочке прототипов, являются instanceof Object, мы можем вызывать на них Object.keys и т.д. По этой причине в TS типом функций является object.


unknown


Тип unknown представляет любое значение. Он похож на тип any, но является более безопасным, поскольку не позволяет ничего делать с неизвестным значением:


function f1(a: any) { a.b() // OK}function f2(a: unknown) { a.b() // Object is of type 'unknown'. // Типом объекта является 'unknown'}

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


function safeParse(s: string): unknown { return JSON.parse(s)}const obj = safeParse(someRandomString)

never


Некоторые функции никогда не возвращают значений:


function fail(msg: string): never { throw new Error(msg)}

Тип never представляет значение, которого не существует. Чаще всего, это означает, что функция выбрасывает исключение или останавливает выполнение программы.


never также появляется, когда TS определяет, что в объединении больше ничего не осталось:


function fn(x: string | number) { if (typeof x === 'string') {   // ... } else if (typeof x === 'number') {   // ... } else {   x // типом `x` является `never`! }}

Function


Глобальный тип Function описывает такие свойства как bind, call, apply и другие, характерные для функций в JS. Он также имеет специальное свойство, позволяющее вызывать значения типа Function такие вызовы возвращают any:


function doSomething(f: Function) { f(1, 2, 3)}

Такой вызов функции называется нетипизированным и его лучше избегать из-за небезопасного возвращаемого типа any.


Если имеется необходимость принимать произвольную функцию без ее последующего вызова, лучше предпочесть более безопасный тип () => void.


Оставшиеся параметры и аргументы


Оставшиеся параметры (rest parameters)


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


Оставшиеся параметры указываются после других параметров с помощью ...:


function multiply(n: number, ...m: number[]) { return m.map((x) => n * x)}// `a` получает значение [10, 20, 30, 40]const a = multiply(10, 1, 2, 3, 4)

В TS неявным типом таких параметров является any[], а не any. Любая аннотация типа для них должна иметь вид Array<T> или T[], или являться кортежем.


Оставшиеся аргументы (rest arguments)


Синтаксис распространения (синонимы: расширение, распаковка) (spread syntax) позволяет передавать произвольное количество элементов массива. Например, метод массива push принимает любое количество аргументов:


const arr1 = [1, 2, 3]const arr2 = [4, 5, 6]arr1.push(...arr2)

Обратите внимание: TS не считает массивы иммутабельными. Это может привести к неожиданному поведению:


// Предполагаемым типом `args` является `number[]` - массив с 0 или более чисел// а не конкретно с 2 числамиconst args = [8, 5]const angle = Math.atan2(...args)// Expected 2 arguments, but got 0 or more.// Ожидалось получение 2 аргументов, а получено 0 или более

Самым простым решением данной проблемы является использование const:


// Предполагаемым типом является кортеж, состоящий из 2 элементовconst args = [8, 5] as const// OKconst angle = Math.atan2(...args)

Деструктуризация параметров (parameter destructuring)


Деструктуризация параметров используется для распаковки объекта, переданного в качестве аргумента, в одну или более локальную переменную в теле функции. В JS это выглядит так:


function sum({ a, b, c }) { console.log(a + b + c)}sum({ a: 10, b: 3, c: 9 })

Аннотация типа для объекта указывается после деструктуризации:


function sum({ a, b, c }: { a: number, b: number, c: number }) { console.log(a + b + c)}

Для краткости можно использовать именованный тип:


type ABC = { a: number, b: number, c: number }function sum({ a, b, c }: ABC) { console.log(a + b + c)}

Возможность присвоения функций переменным


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


Контекстуальная типизация (contextual typing), основанная на void, не запрещает функции что-либо возвращать. Другими словами, функция, типом возвращаемого значения которой является void type vf = () => void, может возвращать любое значение, но это значение будет игнорироваться.


Все приведенные ниже реализации типа () => void являются валидными:


type voidFn = () => voidconst f1: voidFn = () => { return true}const f2: voidFn = () => trueconst f3: voidFn = function() { return true}

Когда возвращаемое любой из этих функций значение присваивается переменной, она будет хранить тип void:


const v1 = f1()const v2 = f2()const v3 = f3()

Поэтому следующий код является валидным, несмотря на то, что Array.prototype.push возвращает число, а Array.prototype.forEach ожидает получить функцию с типом возвращаемого значения void:


const src = [1, 2, 3]const dst = [0]src.forEach((el) => dist.push(el))

Существует один специальный случай, о котором следует помнить: когда литеральное определение функции имеет тип возвращаемого значения void, функция не должна ничего возвращать:


function f2(): void { // Ошибка return true}const f3 = function(): void { // Ошибка return true}



Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Пишем простейший GitHub Action на TypeScript

09.06.2021 16:12:52 | Автор: admin

Недавно я решил немного привести в порядок несколько своих .NET pet-проектов на GitHub, настроить для них нормальный CI/CD через GitHub Actions и вынести всё в отдельный репозиторий, чтобы все скрипты лежали в одном месте. Для этого пришлось немного поизучать документацию, примеры и существующие GitHub Actions, выложенные в Marketplace.

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

Статья в первую очередь рассчитана на начинающих, тех, кто никогда не использовал GitHub Actions, но хотел бы быстро начать. Тем не менее, даже если у вас уже есть подобный опыт, но вы, например, не использовали ncc, то, возможно, и для вас в ней будет что-то полезное.

Краткое введение в GitHub Actions

В основе GitHub Actions лежит понятие Workflow - некий рабочий процесс, который запускается по определённым триггерам. В одном репозитории может быть как один рабочий процесс, так и несколько отдельных, срабатывающий при разных условиях.

Чтобы добавить рабочий процесс, нужно создать в репозитории один или несколько yml-файлов в папке .github/workflows. Простейший файл может выглядеть следующим образом:

name: Helloon: [push]jobs:  Hello:    runs-on: ubuntu-latest    steps:      - name: Checkout        uses: actions/checkout@v2      - name: Hello        run: echo "Hello, GitHub Actions!"

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

Сам рабочий процесс мы можем найти на вкладке Actions в интерфейсе GitHub и посмотреть детальную информацию по нему:

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

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

Сами действия представляют из себя отдельный кусок рабочего процесса, который можно повторно использовать в нескольких проектах. Они могут быть трёх видов: докер контейнер, действие JavaScript, или составные действия (которые по сути просто переиспользуемый набор других действий). Вариант с действием JavaScript мы как раз и рассмотрим в этой статье, но писать его будем на TypeScript для большего удобства.

(Более подробную информацию по возможностям рабочих процессов можно найти в документации)

Пара слов о месте размещения действий в репозитории

Действия можно разместить в репозитории в нескольких местах в зависимости от потребностей:

  • В подпапке .github/actions. Такой способ обычно используется когда вы хотите использовать эти действия из того же репозитория. В этом случае ссылаться на них необходимо по пути без указания ветки или тега:

    steps:  - uses: ./.github/actions/{path}
    
  • В произвольном месте репозитория. Например, можно разместить несколько действий в корне репозитория, каждое в своей подпапке. Такой способ хорошо подходит, если вы хотите сделать что-то вроде личной библиотеки с набором действий, которые собираетесь использовать из разных проектов. В этом случае ссылаться на такие действия нужно по названию репозитория, пути и ветке или тегу:

    steps:  - uses: {owner}/{repo}/{path}@{ref}
    
  • В корне репозитория. Такой способ позволяет разместить в одном репозитории только одно действие, и обычно используется если вы хотите позже опубликовать его в Marketplace. В этом случае ссылаться на такие действия нужно по названию репозитория и ветке или тегу:

    steps:  - uses: {owner}/{repo}@{ref}
    

Создаём действие на TypeScript

В качестве примера создадим очень простое действие, которое просто выводит сообщение Hello, GitHub Actions!. Для разработки действия нам потребуется установленная версия Node.js (я использовал v14.15.5).

Создадим в репозитории папку .github/actions. В ней создадим подпапку hello, в которой будем далее создавать все файлы, относящиеся к нашему действию. Нам потребуется создать всего четыре файла.

Создаём файл action.yml:

name: Hellodescription: Greet someoneruns:  using: node12  main: dist/index.jsinputs:  Name:    description: Who to greet    required: true

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

Также мы указываем, что это именно JavaScript действие, а не докер контейнер и указываем точку входа: файл dist/index.js. Этого файла у нас пока нет, но он будет автоматически создан чуть позже.

Кроме этого мы можем указать в этом файле набор входных и выходных параметров. В данном случае мы передаём в качестве параметра имя для приветствия.

(Более подробную информацию по возможностям файла метаданных можно найти в документации)

Создаём файл package.json:

{  "private": true,  "scripts": {    "build": "npx ncc build ./src/main.ts"  },  "dependencies": {    "@actions/core": "^1.2.7",    "@actions/exec": "^1.0.4",    "@actions/github": "^4.0.0",    "@actions/glob": "^0.1.2",    "@types/node": "^14.14.41",    "@vercel/ncc": "^0.28.3",    "typescript": "^4.2.4"  }}

Это стандартный файл для Node.js. Чтобы не указывать бесполезные атрибуты типа автора, лицензии и т.п. можно просто указать, что пакет private. (При желании можно конечно всё это указать, кто я такой, чтобы вам это запрещать =)

В скриптах мы указываем один единственный скрипт сборки, который запускает утилиту ncc. Эта утилита на вход получает файл src/main.ts и создаёт файл dist/index.js, который является точкой входа для нашего действия. Я вернусь к этой утилите чуть позже.

В качестве зависимостей мы указываем typescript и @types/node для работы TypeScript. Зависимость @vercel/ncc нужна для работы ncc.

Зависимости @actions/* опциональны и являются частью GitHub Actions Toolkit - набора пакетов для разработки действий (я перечислил самые на мой взгляд полезные, но далеко не все):

  • Зависимость @actions/core понадобится вам вероятнее всего. Там содержится базовый функционал по выводу логов, чтению параметров действия и т.п. (документация)

  • Зависимость @actions/exec нужна для запуска других процессов, например dotnet. (документация)

  • Зависимость @actions/github нужна для взаимодействия с GitHub API. (документация)

  • Зависимость @actions/glob нужна для поиска файлов по маске. (документация)

Стоит также отметить, что формально, поскольку мы будем компилировать наше действие в один единственный файл dist/index.js через ncc, все зависимости у нас будут зависимостями времени разработки, т.е. их правильнее помещать не в dependencies, а в devDependencies. Но по большому счёту никакой разницы нет, т.к. эти зависимости вообще не будут использоваться во время выполнения.

Создаём файл tsconfig.json:

{  "compilerOptions": {    "target": "ES6",    "module": "CommonJS",    "moduleResolution": "Node",    "strict": true  }}

Тут всё достаточно просто. Это минимальный файл, с которым всё хорошо работает, включая нормальную подсветку синтаксиса и IntelliSense в Visual Studio Code.

Создаём файл src/main.ts:

import * as core from '@actions/core'async function main() {  try {    const name = core.getInput('Name');    core.info(`Hello, ${name}`);  } catch (error) {    core.setFailed(error.message)  }}main();

В качестве точки входа мы используем синхронную или асинхронную функцию main, которую вызываем в этом же файле. (Название функции также может быть любым).

Тело функции необходимо обернуть в блок try-catch, чтобы в случае любых ошибок сборка в GitHub прерывалась. Без этого она всегда будет считаться успешной.

В данном конкретном случае мы также используем пакет @actions/core для чтения параметров и вывода сообщения в лог.

Собираем действие при помощи ncc

После того, как все файлы созданы нам первым делом необходимо восстановить все пакеты из npm. Для этого нам нужно перейти в папку действия (не в корень репозитория) и выполнить команду:

npm install

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

В качестве альтернативы можно воспользоваться пакетом @vercel/ncc, который позволяет собрать js или ts-файлы в один единственный js-файл, который уже можно закоммитить в репозиторий.

Поскольку мы уже указали скрипт для сборки в файле package.json, нам осталось только запустить команду:

npm run build

В результате мы получим файл dist/index.js, который нужно будет закоммитить в репозиторий вместе с остальными файлами. Папка node_modules при этом может быть спокойно отправлена в .gitignore.

Используем действие

Чтобы протестировать действие, создадим в папке .github/workflows файл рабочего процесса. Для разнообразия сделаем так, чтобы он запускался не по пушу, а вручную из интерфейса:

name: Helloon:  workflow_dispatch:    inputs:      Name:        description: Who to greet        required: true        default: 'GitHub Actions'jobs:  Hello:    runs-on: ubuntu-latest    steps:      - name: Checkout        uses: actions/checkout@v2      - name: Hello        uses: ./.github/actions/hello        with:          Name: ${{ github.event.inputs.Name }}

Здесь настройки workflow_dispatch описывают форму в интерфейсе GitHub в которую пользователь сможет ввести данные. Там у нас будет одно единственное поле для ввода имени для приветствия.

Данные, введённые в форму через событие передаются в действие, которое мы запускаем в рабочем процессе через параметр github.event.inputs.Name.

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

После того, как мы запушим наш рабочий процесс, мы можем перейти в интерфейс GitHub, на странице Actions выбрать наш рабочий процесс и запустить его выполнение, указав параметры:

После того, как мы запустим рабочий процесс, можем зайти в него и посмотреть, что получилось:

Настраиваем GitHooks для автоматической сборки

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

Каждый раз, когда мы будем изменять код действия нам нужно не забыть вызвать команду:

npm run build

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

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

Я для себя себя решил проблему проще и просто добавил гит хук, который автоматически пересобирает все действия в репозитории на каждый коммит. Для этого необходимо создать в репозитории папку .githooks, а в ней файл pre-commit следующего вида:

#!/bin/shfor action in $(find ".github/actions" -name "action.yml"); do    action_path=$(dirname $action)    action_name=$(basename $action_path)    echo "Building \"$action_name\" action..."    pushd "$action_path" >/dev/null    npm run build    git add "dist/index.js"    popd >/dev/null    echodone

Здесь мы находим все файлы action.yml в папке .github/actions и для всех найденных файлов запускаем сборку из их папки. Теперь нам не нужно будет думать о явной пересборке наших действий, она будет делаться автоматически.

Чтобы хуки из папки .githooks запускались, нам необходимо поменять конфигурацию для текущего репозитория:

git config core.hooksPath .githooks

Или можно сделать это глобально (я сделал именно так):

git config --global core.hooksPath .githooks

Заключение

В данной статье я постарался собрать основную информацию, необходимую для того, чтобы быстро сделать первый шаг к GitHub Actions и начать писать свои собственные скрипты автоматизации на TypeScript.

Репозиторий с примером можно найти тут: https://github.com/Chakrygin/hello-github-action

Подробнее..

Перевод Карманная книга по TypeScript. Часть 5. Объектные типы

10.06.2021 12:09:47 | Автор: admin

image


Доброго времени суток, друзья! Мы продолжаем серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript".

Другие части:



В JS обычным способом группировки и передачи данных являются объекты. В TS они представлены объектными типами (object types).


Как мы видели ранее, они могут быть анонимными:


function greet(person: { name: string, age: number }) { return `Привет, ${person.name}!`}

или именоваться с помощью интерфейсов (interfaces):


interface Person { name: string age: number}function greet(person: Person) { return `Привет, ${person.name}!`}

или синонимов типа (type aliases):


type Person { name: string age: number}function greet(person: Person) { return `Привет, ${person.name}!`}

Во всех приведенных примерах наша функция принимает объект, который содержит свойство name (значение которого должно быть типа string) и age (значение которого должно быть типа number).


Модификаторы свойств (property modifiers)


Каждое свойство в объектном типе может определять несколько вещей: сам тип, то, является ли свойство опциональным, и может ли оно изменяться.


Опциональные свойства (optional properties)


Свойства могут быть помечены как опциональные (необязательные) путем добавления вопросительного знака (?) после их названий:


interface PaintOptions { shape: Shape xPos?: number yPos?: number}function paintShape(opts: PaintOptions) { // ...}const shape = getShape()paintShape({ shape })paintShape({ shape, xPos: 100 })paintShape({ shape, yPos: 100 })paintShape({ shape, xPos: 100, yPos: 100 })

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


Мы можем получать значения таких свойств. Однако, при включенной настройке strictNullChecks, мы будем получать сообщения о том, что потенциальными значениями опциональных свойств является undefined:


function paintShape(opts: PaintOptions) { let xPos = opts.xPos               // (property) PaintOptions.xPos?: number | undefined let yPos = opts.yPos               // (property) PaintOptions.yPos?: number | undefined // ...}

В JS при доступе к несуществующему свойству возвращается undefined. Добавим обработку этого значения:


function paintShape(opts: PaintOptions) { let xPos = opts.xPos === undefined ? 0 : opts.xPos   // let xPos: number let yPos = opts.yPos === undefined ? 0 : opts.yPos   // let yPos: number // ...}

Теперь все в порядке. Но для определения "дефолтных" значений (значений по умолчанию) параметров в JS существует специальный синтаксис:


function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) { console.log('x coordinate at', xPos)                               // var xPos: number console.log('y coordinate at', yPos)                               // var yPos: number // ...}

В данном случае мы деструктурировали параметр painShape и указали значения по умолчанию для xPos и yPos. Теперь они присутствуют в теле функции painShape, но являются опциональными при ее вызове.


Обратите внимание: в настоящее время не существует способа поместить аннотацию типа в деструктуризацию, поскольку такой синтаксис будет интерпретирован JS иначе:


function draw({ shape: Shape, xPos: number = 100 /*...*/ }) { render(shape) // Cannot find name 'shape'. Did you mean 'Shape'? // Невозможно найти 'shape'. Возможно, вы имели ввиду 'Shape' render(xPos) // Cannot find name 'xPos'. // Невозможно найти 'xPos'}

shape: Shape означает "возьми значение свойства shape и присвой его локальной переменной Shape". Аналогично xPos: number создает переменную number, значение которой основано на параметре xPos.


Свойства, доступные только для чтения (readonly properties)


Свойства могут быть помечены как доступные только для чтения с помощью ключевого слова readonly. Такие свойства не могут перезаписываться в процессе проверки типов:


interface SomeType { readonly prop: string}function doSomething(obj: SomeType) { // Мы может читать (извлекать значения) из 'obj.prop'. console.log(`prop has the value '${obj.prop}'.`) // Но не можем изменять значение данного свойства obj.prop = 'hello' // Cannot assign to 'prop' because it is a read-only property. // Невозможно присвоить значение 'prop', поскольку оно является доступным только для чтения}

Использование модификатора readonly не делает саму переменную иммутабельной (неизменяемой), это лишь запрещает присваивать ей другие значения:


interface Home { readonly resident: { name: string, age: number }}function visitForBirthday(home: Home) { // Мы можем читать и обновлять свойства 'home.resident'. console.log(`С Днем рождения, ${home.resident.name}!`) home.resident.age++}function evict(home: Home) { // Но мы не можем изменять значение свойства 'resident' home.resident = { // Cannot assign to 'resident' because it is a read-only property.   name: 'Victor the Evictor',   age: 42, }}

readonly сообщает TS, как должны использоваться объекты. При определении совместимости двух типов TS не проверяет, являются ли какие-либо свойства доступными только для чтения. Поэтому такие свойства можно изменять с помощью синонимов:


interface Person { name: string age: number}interface ReadonlyPerson { readonly name: string readonly age: number}let writablePerson: Person = { name: 'John Smith', age: 42}// работаетlet readonlyPerson: ReadonlyPerson = writablePersonconsole.log(readonlyPerson.age) // 42writablePerson.age++console.log(readonlyPerson.age) // 43

Сигнатуры индекса (index signatures)


Иногда мы не знаем названий всех свойств типа, но знаем форму значений.


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


interface StringArray { [index: number]: string}const myArray: StringArray = getStringArray()const secondItem = myArray[1]   // const secondItem: string

В приведенном примере у нас имеется интерфейс StringArray, содержащий сигнатуру индекса. Данная сигнатура указывает на то, что при индексации StringArray с помощью number возвращается string.


Сигнатура индекса типа свойства должна быть строкой или числом.


Несмотря на поддержку обоих типов индексаторов (indexers), тип, возвращаемый из числового индексатора, должен быть подтипом типа, возвращаемого строковым индексатором. Это объясняется тем, что при индексации с помощью number, JS преобразует его в string перед индексацией объекта. Это означает, что индексация с помощью 100 (number) эквивалента индексации с помощью "100" (string), поэтому они должны быть согласованными между собой.


interface Animal { name: string}interface Dog extends Animal { breed: string}// Ошибка: индексация с помощью числовой строки может привести к созданию другого типа Animal!interface NotOkay { [x: number]: Animal // Numeric index type 'Animal' is not assignable to string index type 'Dog'. // Числовой индекс типа 'Animal' не может быть присвоен строковому индексу типа 'Dog' [x: string]: Dog}

В то время, как сигнатуры строкового индекса являются хорошим способом для описания паттерна "словарь", они предопределяют совпадение всех свойств их возвращаемым типам. Это объясняется тем, что строковый индекс определяет возможность доступа к obj.property с помощью obj['property']. В следующем примере тип name не совпадает с типом строкового индекса, поэтому во время проверки возникает ошибка:


interface NumberDictionary { [index: string]: number length: number // ok name: string // Property 'name' of type 'string' is not assignable to string index type 'number'.}

Тем не менее, свойства с разными типами являются валидными в случае, когда сигнатура индекса это объединение типов (union):


interface NumberOrStringDictionary { [index: string]: number | string length: number // ok, `length` - это число name: string // ok, `name` - это строка}

Сигнатуры индекса можно сделать доступными только для чтения для предотвращения их перезаписи:


interface ReadonlyStringArray { readonly [index: number]: string}let myArray: ReadonlyStringArray = getReadOnlyStringArray()myArray[2] = 'John'// Index signature in type 'ReadonlyStringArray' only permits reading.// Сигнатура индекса в типе 'ReadonlyStringArray' допускает только чтение

Расширение типов (extending types)


Что если мы хотим определить тип, который является более конкретной версией другого типа? Например, у нас может быть тип BasicAddress, описывающий поля, необходимые для отправки писем и посылок в США:


interface BasicAddress { name?: string street: string city: string country: string postalCode: string}

В некоторых случаях этого будет достаточно, однако адреса часто имеют литералы. Для таких случаев мы можем определить AddressWithUnit:


interface AddressWithUnit { name?: string unit: string street: string city: string country: string postalCode: string}

Неужели не существует более простого способа добавления дополнительных полей? На самом деле, мы можем просто расширить BasicAddress, добавив к нему новые поля, которые являются уникальными для AddressWithUnit:


interface BasicAddress { name?: string street: string city: string country: string postalCode: string}interface AddressWithUnit extends BasicAddress { unit: string}

Ключевое слово extends позволяет копировать членов именованных типов в другие типы. Оно также указывает на связь между типами.


Интерфейсы также могут расширяться с помощью нескольких типов одновременно:


interface Colorful { color: string}interface Circle { radius: number}interface ColorfulCircle extends Colorful, Circle {}const cc: ColorfulCircle = { color: 'red', radius: 42}

Пересечение типов (intersection types)


interface позволяет создавать новые типы на основе других посредством их расширения. TS также предоставляет другую конструкцию, которая называется пересечением типов или пересекающимися типами и позволяет комбинировать существующие объектные типы. Пересечение типов определяется с помощью оператора &:


interface Colorful { color: string}interface Circle { radius: number}type ColorfulCircle = Colorful & Circle

Пересечение типов Colorful и Circle приводит к возникновению типа, включающего все поля Colorful и Circle:


function draw(circle: Colorful & Circle) { console.log(`Цвет круга: ${circle.color}`) console.log(`Радиус круга: ${circle.radius}`)}// OKdraw({ color: 'blue', radius: 42 })// опечаткаdraw({ color: 'red', raidus: 42 })/*Argument of type '{ color: string, raidus: number }' is not assignable to parameter of type 'Colorful & Circle'. Object literal may only specify known properties, but 'raidus' does not exist in type 'Colorful & Circle'. Did you mean to write 'radius'?*//*Аргумент типа '{ color: string, raidus: number }' не может быть присвоен параметру с типом 'Colorful & Circle'. С помощью литерала объекта могут определяться только известные свойства, а свойства с названием 'raidus' не существует в типе 'Colorful & Circle'. Возможно, вы имели ввиду 'radius'*/

Интерфейс или пересечение типов?


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


Общие объектные типы (generic object types)


Предположим, что у нас имеется тип Box, который может содержать любое значение:


interface Box { contents: any}

Этот код работает, но тип any является небезопасным с точки зрения системы типов. Вместо него мы могли бы использовать unknown, но это будет означать необходимость выполнения предварительных проверок и подверженных ошибкам утверждений типов (type assertions).


interface Box { contents: unknown}let x: Box { contents: 'привет, народ'}// мы можем проверить `x.contents`if (typeof x.contents === 'string') { console.log(x.contents.toLowerCase())}// или можем использовать утверждение типаconsole.log((x.contents as string).toLowerCase())

Более безопасным способом будет определение различных типов Box для каждого типа contents:


interface NumberBox { contents: number}interface StringBox { contents: string}interface BooleanBox { contents: boolean}

Однако, это обуславливает необходимость создания различных функций или перегрузок функции (function overloads) для работы с такими типами:


function setContents(box: StringBox, newContents: string): voidfunction setContents(box: NumberBox, newContents: number): voidfunction setContents(box: BooleanBox, newContents: boolean): voidfunction setContents(box: { contents: any }, newContents: any) { box.contents = newContents}

Слишком много шаблонного кода. Более того, в будущем нам может потребоваться определить новый тип и перегрузку. Так не пойдет.


Для решения данной проблемы мы можем создать общий (generic) тип Box, в котором объявляется параметр типа (type parameter):


interface Box<Type> { contents: Type}

Затем, при ссылке на Box, мы должны определить аргумент типа (type argument) вместо Type:


let box: Box<string>

По сути, Box это шаблон для настоящего типа, в котором Type будет заменен на конкретный тип. Когда TS видит Box<string>, он заменяет все вхождения Type в Box<Type> на string и заканчивает свою работу чем-то вроде { contents: string }. Другими словами, Box<string> работает также, как рассмотренный ранее StringBox.


interface Box<Type> { contents: Type}interface StringBox { contents: string}let boxA: Box<string> = { contents: 'привет' }boxA.contents     // (property) Box<string>.contents: stringlet boxB: StringBox = { contents: 'народ' }boxB.contents     // (property) StringBox.contents: string

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


interface Box<Type> { contents: Type}interface Apple { // ....}// Тоже самое, что '{ contents: Apple }'.type AppleBox = Box<Apple>

Это также означает, что нам не нужны перегрузки функции. Вместо них мы можем использовать общую функцию (generic function):


function setContents<Type>(box: Box<Type>, newContents: Type) { box.contents = newContents}

Синонимы типов также могут быть общими. Вот как мы можем определить общий тип (generic type) Box:


type Box<Type> = { contents: Type}

Поскольку синонимы, в отличие от интерфейсов, могут использоваться для описания любых типов, а не только типов объектов, мы можем использовать их следующим образом:


type OrNull<Type> = Type | nulltype OneOrMany<Type> = Type | Type[]type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>         // type OneOrManyOrNull<Type> = OneOrMany<Type> | nulltype OneOrManyOrNullStrings = OneOrManyOrNull<string>         // type OneOrManyOrNullStrings = OneOrMany<string> | null

Тип Array


Синтаксис number[] или string[] это сокращения для Array<number> и Array<string>, соответственно:


function doSomething(value: Array<string>) { // ...}let myArray: string[] = ['hello', 'world']// оба варианта являются рабочими!doSomething(myArray)doSomething(new Array('hello', 'world'))

Array сам по себе является общим типом:


interface Array<Type> { /**  *  Получает или устанавливает длину массива  */ length: number /**  * Удаляет последний элемент массива и возвращает его  */ pop(): Type | undefined /**  * Добавляет новые элементы в конец массива и возвращает новую длину массива  */ push(...items: Type[]): number // ...}

Современный JS также предоставляет другие общие структуры данных, такие как Map<K, V>, Set<T> и Promise<T>. Указанные структуры могут работать с любым набором типов.


Тип ReadonlyArray


ReadonlyArray это специальный тип, описывающий массив, который не должен изменяться.


function doStuff(values: ReadonlyArray<string>) { // Мы можем читать из `values`... const copy = values.slice() console.log(`Первым значением является ${values[0]}`) // но не можем их изменять values.push('Привет!') // Property 'push' does not exist on type 'readonly string[]'. // Свойства с названием 'push' не существует в типе 'readonly string[]'}

Когда мы создаем функцию, которая возвращает ReadonlyArray, это означает, что мы не собираемся изменять такой массив, а когда мы видим функцию, принимающую ReadonlyArray, это означает, что мы можем передавать такой функции любой массив и не беспокоиться о том, что он может измениться.


В отличие от Array, ReadonlyArray не может использоваться как конструктор:


new ReadonlyArray('red', 'green', 'blue')// 'ReadonlyArray' only refers to a type, but is being used as a value here.// 'ReadonlyArray' всего лишь указывает на тип, поэтому не может использовать в качестве значения

Однако, мы можем присваивать массиву, доступному только для чтения, обычные массивы:


const roArray: ReadonlyArray<string> = ['red', 'green', 'blue']

Для определения массива, доступного только для чтения, также существует сокращенный синтаксис, который выглядит как readonly Type[]:


function doStuff(values: readonly string[]) { // Мы можем читать из `values`... const copy = values.slice() console.log(`The first value is ${values[0]}`) // но не можем их изменять values.push('hello!') // Property 'push' does not exist on type 'readonly string[]'.}

В отличие от модификатора свойств readonly, присваивание между Array и ReadonlyArray является однонаправленным (т.е. только обычный массив может быть присвоен доступному только для чтения массиву):


let x: readonly string[] = []let y: string[] = []x = yy = x// The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.// Тип 'readonly string[]' является доступным только для чтения и не может быть присвоен изменяемому типу 'string[]'

Кортеж (tuple)


Кортеж это еще одна разновидность типа Array с фиксированным количеством элементов определенных типов.


type StrNumPair = [string, number]

StrNumPair это кортеж string и number. StrNumPair описывает массив, первый элемент которого (элемент под индексом 0) имеет тип string, а второй (элемент под индексом 1) number.


function doSomething(pair: [string, number]) { const a = pair[0]     // const a: string const b = pair[1]     // const b: number // ...}doSomething(['hello', 42])

Если мы попытаемся получить элемент по индексу, превосходящему количество элементов, то получим ошибку:


function doSomething(pair: [string, number]) { // ... const c = pair[2] // Tuple type '[string, number]' of length '2' has no element at index '2'. // Кортеж '[string, number]' длиной в 2 элемента не имеет элемента под индексом '2'}

Кортежи можно деструктурировать:


function doSomething(stringHash: [string, number]) { const [inputString, hash] = stringHash console.log(inputString)               // const inputString: string console.log(hash)           // const hash: number}

Рассматриваемый кортеж является эквивалентом такой версии типа Array:


interface StringNumberPair { // Конкретные свойства length: 2 0: string 1: number // Другие поля 'Array<string | number>' slice(start?: number, end?: number): Array<string | number>}

Элементы кортежа могут быть опциональными (?). Такие элементы указываются в самом конце и влияют на тип свойства length:


type Either2dOr3d = [number, number, number?]function setCoords(coord: Either2dOr3d) { const [x, y, z] = coord           // const z: number | undefined console.log(`   Переданы координаты в ${coord.length} направлениях `)                               // (property) length: 2 | 3}

Кортежи также могут содержать оставшиеся элементы (т.е. элементы, оставшиеся не использованными, rest elements), которые должны быть массивом или кортежем:


type StringNumberBooleans = [string, number, ...boolean[]]type StringBooleansNumber = [string, ...boolean[], number]type BooleansStringNumber = [...boolean[], string, number]

...boolean[] означает любое количество элементов типа boolean.


Такие кортежи не имеют определенной длины (length) они имеют лишь набор известных элементов на конкретных позициях:


const a: StringNumberBooleans = ['hello', 1]const b: StringNumberBooleans = ['beautiful', 2, true]const c: StringNumberBooleans = ['world', 3, true, false, true, false, true]

Кортежи сами могут использоваться в качестве оставшихся параметров и аргументов. Например, такой код:


function readButtonInput(...args: [string, number, ...boolean[]]) { const [name, version, ...input] = args // ...}

является эквивалентом следующего:


function readButtonInput(name: string, version: number, ...input: boolean[]) { // ...}

Кортежи, доступные только для чтения (readonly tuple types)


Кортежи, доступные только для чтения, также определяются с помощью модификатора readonly:


function doSomething(pair: readonly [string, number]) { // ...}

Попытка перезаписи элемента такого кортежа приведет к ошибке:


function doSomething(pair: readonly [string, number]) { pair[0] = 'Привет!' // Cannot assign to '0' because it is a read-only property.}

Кортежи предназначены для определения типов иммутабельных массивов, так что хорошей практикой считается делать их доступными только для чтения. Следует отметить, что предполагаемым типом массива с утверждением const является readonly кортеж:


let point = [3, 4] as constfunction distanceFromOrigin([x, y]: [number, number]) { return Math.sqrt(x ** 2 + y ** 2)}distanceFromOrigin(point)/*Argument of type 'readonly [3, 4]' is not assignable to parameter of type '[number, number]'. The type 'readonly [3, 4]' is 'readonly' and cannot be assigned to the mutable type '[number, number]'.*/

В приведенном примере distanceFromOrigin не изменяет элементы переданного массива, но ожидает получения изменяемого кортежа. Поскольку предполагаемым типом point является readonly [3, 4], он несовместим с [number, number], поскольку такой тип не может гарантировать иммутабельности элементов point.




Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Почему мы должны выбросить React и взяться за Angular

15.06.2021 14:10:21 | Автор: admin

Хочу представить перевод довольно интересной статьи Сэма Редмонда, Why We Should Throw Out React and Pick Up Angular. На мой взгляд, статья описывает основные возможности Angular. Она может показаться довольно вызывающей, но постарайтесь отнестись к ней немного с юмором :)

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

  1. Он популярен в основном от того, что вокруг него много шумихи.

  2. Он даёт слишком много свободы. Это приводит к фундаментальным ошибкам на ранней стадии разработки приложения, которые проявляются не сразу.

  3. Использует не оправдано много памяти и не поддаётся оптимизации(not tree-shakable).

  4. Сложность React приложения растёт экспоненциально с ростом размера приложения и это затрудняет его обслуживание.

  5. Нет ничего встроенного (например, обработка форм). По-этому вам нужно написать много кода, что бы это как-то компенсировать или использовать кучу сторонних библиотек.

  6. Обновление вашего приложения до последней версии React часто сопряжено с полным переписыванием этого самого приложения.

Это самые частые проблемы, с которыми я столкнулся, работая с различными проектами на React. В этой статье я хочу поделиться с вами, как Angular решает большую часть вышеперечисленных проблем и позволяет вам сосредоточиться на том, что вы создаёте, а не на том, как вы создаёте своё приложение.

All aboard the hype train

Angular также получает изрядное количество хайпа, так что я не могу сказать, что Angular решает эту проблему. Однако, я не думаю, что Angular получает такое же количество хайпа, как React. Мне кажется, что в основном это связано с ребрендингом, который сделал Google.. Сначала был AngularJs, который был как дымящаяся куча мусора. Но надо отдать должное Google, они решили полностью отремонтировать AngularJs и превратить его в Angular (или Angular 2), что является гигантским улучшением. Впрочем, это улучшение стоило ему популярности, как мне кажется.

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

Я убеждён, что Angular вполне заслуживает того хайпа, который он получает. Разработчики Angular создали невероятно хорошо продуманный фрэймворк, который обладает отличной производительностью и довольно прост в использовании. В этой статье я расскажу о преимуществах от использования Angular и, вероятно, после прочтения вы согласитесь с тем, что Angular заслужил свой хайп.

Вы получаете то, что вам нужно

Одна из больших разниц между Angular и React в простом факте, что Angular является полноценным фреймворком в противовес обычной библиотеке. Как я уже показал в предыдущей статье, React пытается совместить лучшее из библиотеки и фреймворка, но делает это плохо. В отличие от этого, Angular не пытается быть библиотекой. Он идёт ва-банк, чтобы быть фреймворком и делает это хорошо.

С базовой установкой вы получаете почти всё, что вам нужно, для того, чтобы создать любое приложение без необходимости устанавливать сторонние библиотеки (я уже знаю, что вы подумали, но об это позже). С Angular вы получаете надёжный фрэймворк, который позволяет, а не сдерживает вас. Вы больше не должны тратить уйму времени, чтобы писать кучу сложного кода, чтобы обработать и провалидировать форму, потому что это всё обрабатывается внутри фреймворка.

Кроме того вы получаете такую штуку, как Angular CLI, который один из самых мощных инструментов на поясе у Angular.

разработчики, использующие Angular CLIразработчики, использующие Angular CLI

Одна из самых больших проблем с React - это отсутствие стандартов. Если вы изучили одно React приложение, то вы изучили одно React приложение, потому что все они совершенно разные. С другой стороны, если вы изучили одно Angular приложение, то вы изучили все Angular приложения и Angular CLI является основным драйвером, стоящим за этим.

В отличие от React в Angular есть правильные и неправильные способы. Использование Angular CLI обычно всегда гарантирует, что всё будете делать правильно. Давайте возьмём самое начало. Мы хотим создать новое приложение. Как мы это сделаем?

ng new my-app

Да, вот, пожалуй и всё. Запустите эту команду и CLI настроит кучу вещей за вас. Он даже даст вам некоторый выбор, такой как использование линтинга и роутинга, перед тем, как будет создано приложение. Итак, вот что сделает CLI:

  1. Он создаст новую директорию my-app и проинициализирует в ней Angular приложение. А также установит зависимости.

  2. Настраивает инфраструктуру и интегрирует в неё приложение, снабдив вас всем, что нужно для запуска.

  3. Вместе с инфраструктурой вы также получаете встроенную базовую реализацию end-to-end тестов на Protractor, в которую вы можете добавлять тесты, по мере развития вашего приложения.

  4. Angular даёт вам простой в использовании конфигурационный файл (angular.json). В нём вы можете легко настроить, как Angular собирает приложение и даже то, как эта сборка делается средой окружения.

  5. Говоря о среде окружения, Angular имеет в своём составе простую и хорошо типизированную систему управления этой самой средой.

Ещё много есть того, чего я возможн коснусь, но и это уже очень круто, да ещё и прямо из коробки. В React вы такого не получите. Что ещё даёт вам CLI?

Кроме создания самого приложения и каркаса к нему, он будет также управлять приложением за вас. Скажем, у вас уже есть новенькое приложение и вы хотите создать компонент.

ng generate component my-component OR ng g c my-component

Существует два способа как создать компонент и они довольно долгие, по сравнению с короткой командой, что довольно приятно. Эта команда делает следующее:

  1. Создаёт директорию с именем my-component и помещает в него пустой компонент.

  2. Автоматически генерирует unit tests для данного компонента.

  3. Автоматически встраивает ваш компонент в инфраструктуру приложения.

Путь Angular

Я упомянул ранее о правильных и неправильных способах делать разные вещи, а также я упомянул о том, что происходит вокруг создания инфраструктуры. Но как это выглядит на самом деле? Я не буду приводить подробного руководства, а лишь кратко пройдусь по главным концептам. Некоторые примеры вы сможете найти на stackblitz:

https://stackblitz.com/edit/angular-examples-modules

ng g m buttonng g c button

В этом примере у нас есть папка button. В этой папке есть модуль button, компонент button, тестовый файл, файл стилей и HTML файл.

Это было автоматически сгенерировано Angular CLI. Если вы заметили, команды имеют определённый порядок и на это есть причина. Во-первых нам нужен модуль, чтобы включить в него наш компонент button. Затем мы создаём компонент button и CLI автоматически импортирует его в модуль. Затем мы экспортируем наш компонент для того, чтобы мы могли использовать его в других модулях нашего приложения.

Чтобы всё было просто, давайте импортируем это в app.module.ts. Всё, что мы сделаем, это импортируем наш компонент ButtonModule в app.module.ts и потом включим его в раздел imports декоратора @NgModule приложения AppModule.

Вот и всё. Теперь мы можем использовать тэг <app-button></app-button> в app.component.html файле.

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

Доставьте меня из пункта А в пункт Б

Я хотел бы коснуться ещё одной фичи, которую предоставляет вам Angular из коробки. Это роутинг и ленивая загрузка модулей. Я не буду здесь сильно углубляться и приведу небольшой пример. Если вы хотите подробнее разобрать эти фичи, попробуйте это сделать своими руками в Angular.

Как хороший CLI, Angular имеет опции. Одна из них задействует роутинг во время создания инфраструктуры приложения.

ng new my-app --routing

На сегодняшний день вы можете просто выбрать опцию во время установки, без указания флага. Но флаг тоже можно указывать, если вы так больше предпочитаете. Когда вы выбираете routing, Angular создаёт файл роутинга за вас. Он будет служить вам в качестве основного конфигурационного файл. Если вы захотите создать модули для ваших страниц, вы также можете указать флаг routing. CLI тогда создаст конфиг роутинга для этого модуля.

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

const routes: Routes = [   {   path: 'main',   loadChildren: () =>    import('src/app/routes/main/main.module').then((mod) => mod.MainModule)   },];

Если вы прописываете ленивую загрузку, то вы должны использовать синтаксис асинхронного импорта модуля, который хотите загрузить. Это означает, что когда приложение загружается, клиент не должен загружать код для модуля main, каких либо зависимостей или чего-то, что модуль main использует до тех пор, пока пользователю не понадобиться загрузить это. Это сделает начальную загрузку приложения более лёгкой и быстрой и гарантирует, что пользователь не будет ждать, пока не загрузится всё приложение целиком. Самое замечательное, что эта возможность предоставляется из коробки и всё, что вам нужно, это использовать маленький флаг при создании вашего приложения и модулей.

Переиспользование делает вашу жизнь проще

Итак, мы рассмотрели, как Angular использует Modules для организации страниц и компонент и хочу остановиться на этом немного подробнее. Вероятно, что вы уже знаете, что Angular использует TypeScript вместо обычного javascript. Это явное преимущество Angular использует в полной мере.

Полагаю, что вы уже говорите - React тоже использует TypeScript, и будете правы. Но React использует Typescript не в полной мере. Типизация - это только часть того, что делает TypeScript таким продвинутым. Другая часть состоит в том, что вы можете в полной мере использовать классы и декораторы. React дал ясно понять, что движется в сторону функциональных компонент, а не в сторону компонент основанных на классах, что отнимает половину всей мощи TypeScript. Теперь стало намного сложнее переиспользовать функциональность в моём приложении и оно стало более сложным в обслуживании.

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

@my-decorator()export class MyClass {}

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

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

Understanding Angular Ivy: Incremental DOM and Virtual DOM

В предыдущей статье я немного рассказывал о том, что виртуальный DOM это своего рода пожиратель памяти и что невозможно оптимизировать расход этой памяти (tree shaking), поскольку виртуальное дерево создаётся всякий раз заново при перерисовке. Всё изменилось с приходом Angular Ivy. Вы сможете прочитать как работает Ivy в статье Виктора. Я лишь приведу некоторые моменты из неё.

Angular Ivy использует так называемый инкрементный DOM. Идея заключается в том, что каждый компонент ссылается на набор инструкций, которые известны на стадии компиляции. И если некоторые инструкции не используются, то они исключаются из сборки.

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

Помните, как я ранее сказал: Я уже знаю, что вы думаете и мы к этому вернёмся? Давайте разберём это. В том месте статьи я уверен, что вы подумали про себя: это всё прелестно, но что если мне не понадобиться ВСЁ, что Angular предоставляет? Хорошо, ну вы сами вдумайтесь, если вы не используете какую-то часть Angular, она просто не попадёт в сборку! Их технология оптимизации сборки постоянно улучшается и вы получите более стройные билды, в особенности если вы не используете абсолютно всё, что есть в Angular.

Апгрейд - проще простого

Если вы читаете это, то вероятно вы, вы уже проработали каким-то разработчиком ПО. Полагаю, что не нужно объяснять какую боль может причинить переход на новую версию библиотеки или фреймворка. Апгрейды - это неотъемлемая часть жизни и вместе с Angular, они больше не будут болью.

Я долго работал с Angular и видел все апдейты с первого релиза (я даже работал с AngularJs, но предпочитаю не говорить об этом). Безусловно Angular прошёл долгий путь, как и CLI. Где в 2018 году в Angular CLI появилась ещё одна команда - ng update. Вы можете использовать её так:

ng update @angular/core

Дальше происходит магия. Все зависимости ядра Angular обновятся до последней версии. Если ваш код нуждается в обновлении, CLI сделает это за вас и, если нельзя, то скажет, где вам нужно самим вручную обновить свой код. Обновление до последней версии Angular займёт от нескольких секунд до нескольких минут, в то время, как с React это может занять от нескольких часов до нескольких дней (или даже недель).

Это отчасти потому, что Angular CLI такой мощный, а отчасти, что команда разработчиков Angular так выкладывает обновления. Они заботятся не о том, чтобы в релиз влезло как можно больше критических изменений, которые заставят разработчиков полностью переписать свои приложения. Они всегда следят за тем, чтобы критические изменения были обратно совместимыми для одной или двух предыдущих версий, чтобы дать возможность разработчикам внести большие изменения, если необходимо. Я пользуюсь этим постоянно и это мощное средство для обслуживания Angular

Давайте свяжем всё вместе

Хорошо, в этой статье мы о многом поговорили, и мне кажется, что я бы хотел ещё многое сказать. Тем не менее, мне кажется я коснулся всех болевых точек, которые я упоминал в начале статьи.

Используя возможности Angular и Angular CLI, вы сможете сделать ваши приложения более совместимыми. В то время как некоторые приложения могут отличаться настройками, принципы остаются одинаковыми, по-этому порог вхождения в новый проект будет не таким уж высоким. Один раз выучи - применяй везде!

Хорошая совместимость - уменьшение сложности обслуживания. Когда ваш проект растёт, стоимость обслуживания не будет возрастать экспоненциально. Наверное это самая большая проблема в React приложениях, которую превосходно решает Angular. Следуя тому же ходу мысли, мы также получаем всё, что нам нужно прямо из ядра Angular. Обработка форм? Пожалуйста. Роутинг? Пожалуйста. Ленивая загрузка? Пожалуйста. Я мог бы продолжить, но остановлюсь на этом. Даже если вы что-то не используете, то это не войдет в ваш билд, потому что всё, что в ядре Angualr является оптимизируемым деревом (tree shakable), включая рендеринг.

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

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

Подробнее..
Категории: Typescript , React , Reactjs , Angular , Angular2 , Upgrade

Перевод Карманная книга по TypeScript. Часть 6. Манипуляции с типами

15.06.2021 16:19:06 | Автор: admin

image


Мы продолжаем серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript".

Другие части:



Система типов TS позволяет создавать типы на основе других типов.


Простейшей формой таких типов являются дженерики или общие типы (generics). В нашем распоряжении также имеется целый набор операторов типа. Более того, мы можем выражать типы в терминах имеющихся у нас значений.


Дженерики


Создадим функцию identity, которая будет возвращать переданное ей значение:


function identity(arg: number): number { return arg}

Для того, чтобы сделать эту функцию более универсальной, можно использовать тип any:


function identity(arg: any): any { return arg}

Однако, при таком подходе мы не будем знать тип возвращаемого функцией значения.


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


function identity<Type>(arg: Type): Type { return arg}

Мы используем переменную Type как для типа передаваемого функции аргумента, так и для типа возвращаемого функцией значения.


Такие функции называют общими (дженериками), поскольку они могут работать с любыми типами.


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


const output = identity<string>('myStr')   // let output: string

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


Второй способ заключается в делегировании типизации компилятору:


const output = identity('myStr')   // let output: string

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


Работа с переменными типа в дженериках


Что если мы захотим выводить в консоль длину аргумента arg перед его возвращением?


function loggingIdentity<Type>(arg: Type): Type { console.log(arg.length) // Property 'length' does not exist on type 'Type'. // Свойства 'length' не существует в типе 'Type' return arg}

Мы получаем ошибку, поскольку переменные типа указывают на любой (а, значит, все) тип, следовательно, аргумент arg может не иметь свойства length, например, если мы передадим в функцию число.


Изменим сигнатуру функции таким образом, чтобы она работала с массивом Type:


function loggingIdentity<Type>(arg: Type[]): Type[] { console.log(arg.length) return arg}

Теперь наша функция стала дженериком, принимающим параметр Type и аргумент arg, который является массивом Type, и возвращает массив Type. Если мы передадим в функцию массив чисел, то получим массив чисел.


Мы можем сделать тоже самое с помощью такого синтаксиса:


function loggingIdentity<Type>(arg: Array<Type>): Array<Type> { console.log(arg.length) return arg}

Общие типы


Тип общей функции (функции-дженерика) похож на тип обычной функции, в начале которого указывается тип параметра:


function identity<Type>(arg: Type): Type { return arg}const myIdentity: <Type>(arg: Type) => Type = identity

Мы можем использовать другое название для параметра общего типа:


function identity<Type>(arg: Type): Type { return arg}const myIdentity: <Input>(arg: Input) => Input = identity

Мы также можем создавать общие типы в виде сигнатуры вызова типа объектного литерала:


function identity<Type>(arg: Type): Type { return arg}const myIdentity: { <Type>(arg: Type): Type } = identity

Это приводит нас к общему интерфейсу:


interface GenericIdentityFn { <Type>(arg: Type): Type}function identity<Type>(arg: Type): Type { return arg}const myIdentity: GenericIdentityFn = identity

Для того, чтобы сделать общий параметр видимым для всех членов интерфейса, его необходимо указать после названия интерфейса:


interface GenericIdentityFn<Type> { (arg: Type): Type}function identity<Type>(arg: Type): Type { return arg}const myIdentity: GenericIdentityFn<number> = identity

Кроме общих интерфейсов, мы можем создавать общие классы.


Обратите внимание, что общие перечисления (enums) и пространства имен (namespaces) создавать нельзя.


Общие классы


Общий класс имеет такую же форму, что и общий интерфейс:


class GenericNumber<NumType> { zeroValue: NumType add: (x: NumType, y: NumType) => NumType}const myGenericNum = new GenericNumber<number>()myGenericNum.zeroValue = 0myGenericNum.add = (x, y) => x + y

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


const stringNumeric = new GenericNumber<string>()stringNumeric.zeroValue = ''stringNumeric.add = (x, y) => x + yconsole.log(stringNumeric.add(stringNumeric.zeroValue, 'test'))

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


Ограничения дженериков


Иногда возникает необходимость в создании дженерика, работающего с набором типов, когда мы имеем некоторую информацию о возможностях, которыми будет обладать этот набор. В нашем примере loggingIdentity мы хотим получать доступ к свойству length аргумента arg, но компилятор знает, что не каждый тип имеет такое свойство, поэтому не позволяет нам делать так:


function loggingIdentity<Type>(arg: Type): Type { console.log(arg.length) // Property 'length' does not exist on type 'Type'. return arg}

Мы хотим, чтобы функция работала с любым типом, у которого имеется свойство length. Для этого мы должны создать ограничение типа.


Нам необходимо создать интерфейс, описывающий ограничение. В следующем примере мы создаем интерфейс с единственным свойством length и используем его с помощью ключевого слова extends для применения органичения:


interface Lengthwise { length: number}function loggingIdentity<Type extends Lengthwise>(arg: Type): Type { console.log(arg.length) // Теперь мы можем быть увереными в существовании свойства `length` return arg}

Поскольку дженерик был ограничен, он больше не может работать с любым типом:


loggingIdentity(3)// Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.// Аргумент типа 'number' не может быть присвоен параметру типа 'Lengthwise'

Мы должны передавать ему значения, отвечающие всем установленным требованиям:


loggingIdentity({ length: 10, value: 3 })

Использование типов параметров в ограничениях дженериков


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


function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) { return obj[key]}const x = { a: 1, b: 2, c: 3, d: 4 }getProperty(x, 'a')getProperty(x, 'm')// Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.

Использование типов класса в дженериках


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


function create<Type>(c: { new (): Type }): Type { return new c()}

В более сложных случаях может потребоваться использование свойства prototype для вывода и ограничения отношений между функцией-конструктором и стороной экземляров типа класса:


class BeeKeeper { hasMask: boolean}class ZooKeeper { nametag: string}class Animal { numLegs: number}class Bee extends Animal { keeper: BeeKeeper}class Lion extends Animal { keeper: ZooKeeper}function createInstance<A extends Animal>(c: new () => A): A { return new c()}createInstance(Lion).keeper.nametagcreateInstance(Bee).keeper.hasMask

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


Оператор типа keyof


Оператор keyof "берет" объектный тип и возвращает строковое или числовое литеральное объединение его ключей:


type Point = { x: number, y: number }type P = keyof Point // type P = keyof Point

Если типом сигнатуры индекса (index signature) типа является string или number, keyof возвращает эти типы:


type Arrayish = { [n: number]: unknown }type A = keyof Arrayish // type A = numbertype Mapish = { [k: string]: boolean }type M = keyof Mapish // type M = string | number

Обратите внимание, что типом M является string | number. Это объясняется тем, что ключи объекта в JS всегда преобразуются в строку, поэтому obj[0] это всегда тоже самое, что obj['0'].


Типы keyof являются особенно полезными в сочетании со связанными типами (mapped types), которые мы рассмотрим позже.


Оператор типа typeof


JS предоставляет оператор typeof, который можно использовать в контексте выражения:


console.log(typeof 'Привет, народ!') // string

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


const s = 'привет'const n: typeof s // const n: string

В сочетании с другими операторами типа мы можем использовать typeof для реализации нескольких паттернов. Например, давайте начнем с рассмотрения предопределенного типа ReturnType<T>. Он принимает тип функции и производит тип возвращаемого функцией значения:


type Predicate = (x: unknown) => booleantype K = ReturnType<Predicate> // type K = boolean

Если мы попытаемся использовать название функции в качестве типа параметра ReturnType, то получим ошибку:


function f() { return { x: 10, y: 3 }}type P = ReturnType<f>// 'f' refers to a value, but is being used as a type here. Did you mean 'typeof f'?// 'f' является ссылкой на значение, но используется как тип. Возможно, вы имели ввиду 'typeof f'

Запомните: значения и типы это не одно и тоже. Для ссылки на тип значения f следует использовать typeof:


function f() { return { x: 10, y: 3 }}type P = ReturnType<typeof f> // type P = { x: number, y: number }

Ограничения


TS ограничивает виды выражений, на которых можно использовать typeof.


typeof можно использовать только в отношении идентификаторов (названий переменных) или их свойств. Это помогает избежать написания кода, который не выполняется:


// Должны были использовать ReturnType<typeof msgbox>, но вместо этого написалиconst shouldContinue: typeof msgbox('Вы уверены, что хотите продолжить?')// ',' expected

Типы доступа по индексу (indexed access types)


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


type Person = { age: number, name: string, alive: boolean }type Age = Person['age'] // type Age = number

Индексированный тип это обычный тип, так что мы можем использовать объединения, keyof и другие типы:


type I1 = Person['age' | 'name'] // type I1 = string | numbertype I2 = Person[keyof Person] // type I2 = string | number | booleantype AliveOrName = 'alive' | 'name'type I3 = Person[AliveOrName] // type I3 = string | boolean

При попытке доступа к несуществующему свойству возникает ошибка:


type I1 = Person['alve']// Property 'alve' does not exist on type 'Person'.

Другой способ индексации заключается в использовании number для получения типов элементов массива. Мы также можем использовать typeof для перехвата типа элемента:


const MyArray = [ { name: 'Alice', age: 15 }, { name: 'Bob', age: 23 }, { name: 'John', age: 38 },]type Person = typeof MyArray[number]type Person = {   name: string   age: number}type Age = typeof MyArray[number]['age']type Age = number// илиtype Age2 = Person['age']type Age2 = number

Обратите внимание, что мы не можем использовать const, чтобы сослаться на переменную:


const key = 'age'type Age = Person[key]/* Type 'any' cannot be used as an index type. 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?*//* Тип 'any' не может быть использован в качестве типа индекса. 'key' является ссылкой на значение, но используется как тип. Возможно, вы имели ввиду 'typeof key'*/

Однако, в данном случае мы можем использовать синоним типа (type alias):


type key = 'age'type Age = Person[key]

Условные типы (conditional types)


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


interface Animal { live(): void}interface Dog extends Animal { woof(): void}type Example1 = Dog extends Animal ? number : string // type Example1 = numbertype Example2 = RegExp extends Animal ? number : string // type Example2 = string

Условные типы имеют форму, схожую с условными выражениями в JS (условие ? истинноеВыражение : ложноеВыражение).


SomeType extends OtherType ? TrueType : FalseType

Когда тип слева от extends может быть присвоен типу справа от extends, мы получаем тип из первой ветки (истинной), в противном случае, мы получаем тип из второй ветки (ложной).


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


Рассмотрим такую функцию:


interface IdLabel { id: number /* некоторые поля */}interface NameLabel { name: string /* другие поля */}function createLabel(id: number): IdLabelfunction createLabel(name: string): NameLabelfunction createLabel(nameOrId: string | number): IdLabel | NameLabelfunction createLabel(nameOrId: string | number): IdLabel | NameLabel { throw 'не реализовано'}

Перегрузки createLabel описывают одну и ту же функцию, которая делает выбор на основе типов входных данных.


Обратите внимание на следующее:


  1. Если библиотека будет выполнять такую проверку снова и снова, это будет не очень рациональным.
  2. Нам пришлось создать 3 перегрузки: по одной для каждого случая, когда мы уверены в типе (одну для string и одну для number), и еще одну для общего случая (string или number). Количество перегрузок будет увеличиваться пропорционально добавлению новых типов.

Вместо этого, мы можем реализовать такую же логику с помощью условных типов:


type NameOrId<T extends number | string> = T extends number ? IdLabel : NameLabel

Затем мы можем использовать данный тип для избавления от перегрузок:


function createLabel<T extends number | string>(idOrName: T): NameOrId<T> { throw 'не реализовано'}let a = createLabel('typescript') // let a: NameLabellet b = createLabel(2.8) // let b: IdLabellet c = createLabel(Math.random() ? 'hello' : 42) // let c: NameLabel | IdLabel

Ограничения условных типов


Часто проверка в условном типе дает нам некоторую новую информацию. Подобно тому, как сужение с помощью защитников или предохранителей типа (type guards) возвращает более конкретный тип, инстинная ветка условного типа ограничивает дженерики по типу, который мы проверяем.


Рассмотрим такой пример:


type MessageOf<T> = T['message']// Type '"message"' cannot be used to index type 'T'.// Тип '"message"' не может быть использован для индексации типа 'T'

В данном случае возникает ошибка, поскольку TS не знает о существовании у T свойства message. Мы можем ограничить T, и тогда TS перестанет "жаловаться":


type MessageOf<T extends { message: unknown }> = T['message']interface Email { message: string}interface Dog { bark(): void}type EmailMessageContents = MessageOf<Email> // type EmailMessageContents = string

Но что если мы хотим, чтобы MessageOf принимал любой тип, а его "дефолтным" значением был тип never? Мы можем "вынести" ограничение и использовать условный тип:


type MessageOf<T> = T extends { message: unknown } ? T['message'] : neverinterface Email { message: string}interface Dog { bark(): void}type EmailMessageContents = MessageOf<Email> // type EmailMessageContents = stringtype DogMessageContents = MessageOf<Dog> // type DogMessageContents = never

Находясь внутри истинной ветки, TS будет знать, что T имеет свойство message.


В качестве другого примера мы можем создать тип Flatten, который распаковывает типы массива на типы составляющих его элементов, но при этом сохраняет их в изоляции:


type Flatten<T> = T extends any[] ? T[number] : T// Извлекаем тип элементаtype Str = Flatten<string[]> // type Str = string// Сохраняем типtype Num = Flatten<number> // type Num = number

Когда Flatten получает тип массива, он использует доступ по индексу с помощью number для получения типа элемента string[]. В противном случае, он просто возвращает переданный ему тип.


Предположения в условных типах


Мы использовали условные типы для применения ограничений и извлечения типов. Это является настолько распространенной операцией, что существует особая разновидность условных типов.


Условные типы предоставляют возможность делать предположения на основе сравниваемых в истинной ветке типов с помощью ключевого слова infer. Например, мы можем сделать вывод относительно типа элемента во Flatten вместо его получения вручную через доступ по индексу:


type Flatten<Type> = Type extends Array<infer Item> ? Item : Type

В данном случае мы использовали ключевое слово infer для декларативного создания нового дженерика Item вместо извлечения типа элемента T в истинной ветке. Это избавляет нас от необходимости "копаться" и изучать структуру типов, которые нам необходимы.


Мы можем создать несколько вспомогательных синонимов типа (type aliases) с помощью infer. Например, в простых случаях мы можем извлекать возвращаемый тип из функции:


type GetReturnType<Type> = Type extends (...args: never[]) => infer Return ? Return : nevertype Num = GetReturnType<() => number> // type Num = numbertype Str = GetReturnType<(x: string) => string> // type Str = stringtype Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]> // type Bools = boolean[]

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


declare function stringOrNum(x: string): numberdeclare function stringOrNum(x: number): stringdeclare function stringOrNum(x: string | number): string | numbertype T1 = ReturnType<typeof stringOrNum> // type T1 = string | number

Распределенные условные типы (distributive conditional types)


Когда условные типы применяются к дженерикам, они становятся распределенными при получении объединения (union). Рассмотрим следующий пример:


type ToArray<Type> = Type extends any ? Type[] : never

Если мы изолируем объединение в ToArray, условный тип будет применяться к каждому члену объединения.


type ToArray<Type> = Type extends any ? Type[] : nevertype StrArrOrNumArr = ToArray<string | number> // type StrArrOrNumArr = string[] | number[]

Здесь StrOrNumArray распределяется на:


string | number

и применяется к каждому члену объединения:


ToArray<string> | ToArray<number>

что приводит к следующему:


string[] | number[]

Обычно, такое поведение является ожидаемым. Для его изменения можно обернуть каждую сторону extends в квадратные скобки:


type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never// 'StrOrNumArr' больше не является объединениемtype StrOrNumArr = ToArrayNonDist<string | number> // type StrOrNumArr = (string | number)[]

Связанные типы (mapped types)


Связанные типы основаны на синтаксисе сигнатуры доступа по индексу, который используется для определения типов свойств, которые не были определены заранее:


type OnlyBoolsAndHorses = { [key: string]: boolean | Horse}const conforms: OnlyBoolsAndHorses = { del: true, rodney: false,}

Связанный тип это общий тип, использующий объединение, созданное с помощью оператора keyof, для перебора ключей одного типа в целях создания другого:


type OptionsFlags<Type> = { [Property in keyof Type]: boolean}

В приведенном примере OptionsFlag получит все свойства типа Type и изменит их значения на boolean.


type FeatureFlags = { darkMode: () => void newUserProfile: () => void}type FeatureOptions = OptionsFlags<FeatureFlags> // type FeatureOptions = { darkMode: boolean, newUserProfile: boolean }

Модификаторы связывания (mapping modifiers)


Существует два модификатора, которые могут применяться в процессе связывания типов: readonly и ?, отвечающие за иммутабельность (неизменность) и опциональность, соответственно.


Эти модификаторы можно добавлять и удалять с помощью префиксов - или +. Если префикс отсутствует, предполагается +.


// Удаляем атрибуты `readonly` из свойств типаtype CreateMutable<Type> = { -readonly [Property in keyof Type]: Type[Property]}type LockedAccount = { readonly id: string readonly name: string}type UnlockedAccount = CreateMutable<LockedAccount> // type UnlockedAccount = { id: string, name: string }

// Удаляем атрибуты `optional` из свойств типаtype Concrete<Type> = { [Property in keyof Type]-?: Type[Property]}type MaybeUser = { id: string name?: string age?: number}type User = Concrete<MaybeUser> // type User = { id: string, name: string, age: number }

Повторное связывание ключей с помощью as


В TS 4.1 и выше, можно использовать оговорку as для повторного связывания ключей в связанном типе:


type MappedTypeWithNewProperties<Type> = { [Properties in keyof Type as NewKeyType]: Type[Properties]}

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


type Getters<Type> = { [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]}interface Person { name: string age: number location: string}type LazyPerson = Getters<Person> // type LazyPerson = { getName: () => string, getAge: () => number, getLocation: () => string }

Ключи можно фильтровать с помощью never в условном типе:


// Удаляем свойство `kind`type RemoveKindField<Type> = {   [Property in keyof Type as Exclude<Property, 'kind'>]: Type[Property]}interface Circle { kind: 'circle' radius: number}type KindlessCircle = RemoveKindField<Circle> // type KindlessCircle = { radius: number }

Связанные типы хорошо работают с другими возможностями по манипуляции типами, например, с условными типами. В следующем примере условный тип возвращает true или false в зависимости от того, содержит ли объект свойство pii с литерально установленным true:


type ExtractPII<Type> = { [Property in keyof Type]: Type[Property] extends { pii: true } ? true : false}type DBFields = { id: { format: 'incrementing' } name: { type: string, pii: true }}type ObjectsNeedingGDPRDeletion = ExtractPII<DBFields> // type ObjectsNeedingGDPRDeletion = { id: false, name: true }

Типы шаблонных литералов (template literal types)


Типы шаблонных литералов основаны на типах строковых литералов и имеют возможность превращаться в несколько строк через объединения.


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


type World = 'world'type Greeting = `hello ${World}` // type Greeting = 'hello world'

Когда тип используется в интерполированной позиции, он является набором каждого возможного строкого литерала, который может быть представлен каждым членом объединения:


type EmailLocaleIDs = 'welcome_email' | 'email_heading'type FooterLocaleIDs = 'footer_title' | 'footer_sendoff'type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`/* type AllLocaleIDs = 'welcome_email_id' | 'email_heading_id' | 'footer_title_id' | 'footer_sendoff_id'*/

Для каждой интерполированной позиции в шаблонном литерале объединения являются множественными:


type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`type Lang = 'en' | 'ja' | 'pt'type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`/* type LocaleMessageIDs = 'en_welcome_email_id' | 'en_email_heading_id' | 'en_footer_title_id' | 'en_footer_sendoff_id' | 'ja_welcome_email_id' | 'ja_email_heading_id' | 'ja_footer_title_id' | 'ja_footer_sendoff_id' | 'pt_welcome_email_id' | 'pt_email_heading_id' | 'pt_footer_title_id' | 'pt_footer_sendoff_id'*/

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


Строковые объединения в типах


Мощь шаблонных строк в полной мере проявляется при определении новой строки на основе существующей внутри типа.


Например, обычной практикой в JS является расширение объекта на основе его свойства. Создадим определение типа для функции, добавляющей поддержку для функции on, которая позволяет регистрировать изменения значения:


const person = makeWatchedObject({ firstName: 'John', lastName: 'Smith', age: 30,})person.on('firstNameChanged', (newValue) => { console.log(`Имя было изменено на ${newValue}!`)})

Обратите внимание, что on регистрирует событие firstNameChanged, а не просто firstName.


Шаблонные литералы предоставляют способ обработки такой операции внутри системы типов:


type PropEventSource<Type> = {   on(eventName: `${string & keyof Type}Changed`, callback: (newValue: any) => void): void}// Создаем "наблюдаемый объект" с методом `on`,// позволяющим следить за изменениями значений свойствdeclare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>

При передаче неправильного свойства возникает ошибка:


const person = makeWatchedObject({ firstName: 'John', lastName: 'Smith', age: 26})person.on('firstNameChanged', () => {})person.on('firstName', () => {})// Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.// Параметр типа '"firstName"' не может быть присвоен типу...person.on('frstNameChanged', () => {})// Argument of type '"firstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.

Предположения типов с помощью шаблонных литералов


Заметьте, что в последних примерах типы оригинальных значений не использовались повторно. В функции обратного вызова использовался тип any. Типы шаблонных литералов могут предполагаться на основе заменяемых позиций.


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


type PropEventSource<Type> = { on<Key extends string & keyof Type>   // (eventName: `${Key}Changed`, callback: (newValue: Type[Key]) => void ): void}declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>const person = makeWatchedObject({ firstName: 'Jane', lastName: 'Air', age: 26})person.on('firstNameChanged', newName => {                         // (parameter) newName: string   console.log(`Новое имя - ${newName.toUpperCase()}`)})person.on('ageChanged', newAge => {                   // (parameter) newAge: number   if (newAge < 0) {       console.warn('Предупреждение! Отрицательный возраст')   }})

Здесь мы реализовали on в общем методе.


При вызове пользователя со строкой firstNameChanged, TS попытается предположить правильный тип для Key. Для этого TS будет искать совпадения Key с "контентом", находящимся перед Changed, и дойдет до строки firstName. После этого метод on сможет получить тип firstName из оригинального объекта, чем в данном случае является string. Точно также при вызове с ageChanged, TS обнаружит тип для свойства age, которым является number.


Внутренние типы манипуляций со строками (intrisic string manipulation types)


TS предоставляет несколько типов, которые могут использоваться при работе со строками. Эти типы являются встроенными и находятся в файлах .d.ts, создаваемых TS.


  • Uppercase<StringType> переводит каждый символ строки в верхний регистр

type Greeting = 'Hello, world'type ShoutyGreeting = Uppercase<Greeting> // type ShoutyGreeting = 'HELLO, WORLD'type ASCIICacheKey<Str extends string> = `ID-${Uppercase<Str>}`type MainID = ASCIICacheKey<'my_app'> // type MainID = 'ID-MY_APP'

  • Lowercase<StringType> переводит каждый символ в строке в нижний регистр

type Greeting = 'Hello, world'type QuietGreeting = Lowercase<Greeting> // type QuietGreeting = 'hello, world'type ASCIICacheKey<Str extends string> = `id-${Lowercase<Str>}`type MainID = ASCIICacheKey<'MY_APP'> // type MainID = 'id-my_app'

  • Capitilize<StringType> переводит первый символ строки в верхний регистр

type LowercaseGreeting = 'hello, world'type Greeting = Capitalize<LowercaseGreeting> // type Greeting = 'Hello, world'

  • Uncapitilize<StringType> переводит первый символ строки в нижний регистр

type UppercaseGreeting = 'HELLO WORLD'type UncomfortableGreeting = Uncapitalize<UppercaseGreeting> // type UncomfortableGreeting = 'hELLO WORLD'

Вот как эти типы реализованы:


function applyStringMapping(symbol: Symbol, str: string) { switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {   case IntrinsicTypeKind.Uppercase: return str.toUpperCase()   case IntrinsicTypeKind.Lowercase: return str.toLowerCase()   case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1)   case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1) } return str}



Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Перевод Карманная книга по TypeScript. Часть 7. Классы

18.06.2021 12:04:53 | Автор: admin

image


Мы продолжаем серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript".

Другие части:



Члены класса (class members)


Вот пример самого простого класса пустого:


class Point {}

Такой класс бесполезен, поэтому давайте добавим ему несколько членов.


Поля (fields)


Поле это открытое (публичное) и доступное для записи свойство класса:


class Point {x: numbery: number}const pt = new Point()pt.x = 0pt.y = 0

Аннотация типа является опциональной (необязательной), но неявный тип будет иметь значение any.


Поля могут иметь инициализаторы, которые автоматически запускаются при инстанцировании класса:


class Point {x = 0y = 0}const pt = new Point()// Вывод: 0, 0console.log(`${pt.x}, ${pt.y}`)

Как и в случае с const, let и var, инициализатор свойства класса используется для предположения типа этого свойства:


const pt = new Point()pt.x = '0'// Type 'string' is not assignable to type 'number'.// Тип 'string' не может быть присвоен типу 'number'

--strictPropertyInitialization


Настройка strictPropertyInitialization определяет, должны ли поля класса инициализироваться в конструкторе.


class BadGreeter {name: string// Property 'name' has no initializer and is not definitely assigned in the constructor.// Свойство 'name' не имеет инициализатора и ему не присваивается значения в конструкторе}class GoodGreeter {name: stringconstructor() {this.name = 'привет'}}

Обратите внимание, что поля классов должны быть инициализированы в самом конструкторе. TS не анализирует методы, вызываемые в конструкторе, для обнаружения инициализации, поскольку производный класс может перезаписать такие методы, и члены не будут инициализированы.


Если вы намерены инициализировать поле вне конструктора, можете использовать оператор утверждения определения присвоения (definite assignment assertion operator, !):


class OKGreeter {// Не инициализируется, но ошибки не возникаетname!: string}

readonly


Перед названием поля можно указать модификатор readonly. Это запретит присваивать полю значения за пределами конструктора.


class Greeter {readonly name: string = 'народ'constructor(otherName?: string) {if (otherName !== undefined) {this.name = otherName}}err() {this.name = 'не ok'// Cannot assign to 'name' because it is a read-only property.// Невозможно присвоить значение свойству 'name', поскольку оно является доступным только для чтения}}const g = new Greeter()g.name = 'тоже не ok'// Cannot assign to 'name' because it is a read-only property.

Конструкторы (constructors)


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


class Point {x: numbery: number// Обычная сигнатура с дефолтными значениямиconstructor(x = 0, y = 0) {this.x = xthis.y = y}}class Point {// Перегрузкиconstructor(x: number, y: string)constructor(s: string)constructor(xs: any, y?: any) {// ...}}

Однако, между сигнатурами конструктора класса и функции существует несколько отличий:


  • Конструкторы не могут иметь параметров типа это задача возлагается на внешнее определение класса, о чем мы поговорим позже


  • Конструкторы не могут иметь аннотацию возвращаемого типа всегда возвращается тип экземпляра класса



super


Как и в JS, при наличии базового класса в теле конструктора, перед использованием this необходимо вызывать super():


class Base {k = 4}class Derived extends Base {constructor() {// В ES5 выводится неправильное значение, в ES6 выбрасывается исключениеconsole.log(this.k)// 'super' must be called before accessing 'this' in the constructor of a derived class.// Перед доступом к 'this' в конструкторе или производном классе необходимо вызвать 'super'super()}}

В JS легко забыть о необходимости вызова super, в TS почти невозможно.


Методы (methods)


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


class Point {x = 10y = 10scale(n: number): void {this.x *= nthis.y *= n}}

Как видите, TS не добавляет к методам ничего нового.


Обратите внимание, что в теле метода к полям и другим методам по-прежнему следует обращаться через this. Неквалифицированное название (unqualified name) в теле функции всегда будет указывать на лексическое окружение:


let x: number = 0class C {x: string = 'привет'm() {// Здесь мы пытаемся изменить значение переменной `x`, находящейся на первой строке, а не свойство классаx = 'world'// Type 'string' is not assignable to type 'number'.}}

Геттеры/сеттеры


Классы могут иметь акцессоры (вычисляемые свойства, accessors):


class C {_length = 0get length() {return this._length}set length(value) {this._length = value}}

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


  • Если set отсутствует, свойство автоматически становится readonly


  • Параметр типа сеттера предполагается на основе типа, возвращаемого геттером


  • Если параметр сеттера имеет аннотацию типа, она должна совпадать с типом, возвращаемым геттером


  • Геттеры и сеттеры должны иметь одинаковую видимость членов (см. ниже)



Если есть геттер, но нет сеттера, свойство автоматически становится readonly.


Сигнатуры индекса (index signatures)


Классы могут определять сигнатуры индекса. Они работают также, как сигнатуры индекса в других объектных типах:


class MyClass {[s: string]: boolean | ((s: string) => boolean)check(s: string) {return this[s] as boolean}}

Обычно, индексированные данные лучше хранить в другом месте.


Классы и наследование


Как и в других объектно-ориентированных языках, классы в JS могут наследовать членов других классов.


implements


implements используется для проверки соответствия класса определенному interface. При несоответствии класса интерфейсу возникает ошибка:


interface Pingable {ping(): void}class Sonar implements Pingable {ping() {console.log('пинг!')}}class Ball implements Pingable {// Class 'Ball' incorrectly implements interface 'Pingable'. Property 'ping' is missing in type 'Ball' but required in type 'Pingable'.// Класс 'Ball' некорректно реализует интерфейс 'Pingable'. Свойство 'ping' отсутствует в типе 'Ball', но является обязательным в типе 'Pingable'pong() {console.log('понг!')}}

Классы могут реализовывать несколько интерейсов одновременно, например, class C implements A, B {}.


Предостережение


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


interface Checkable {check(name: string): boolean}class NameChecker implements Checkable {check(s) {// Parameter 's' implicitly has an 'any' type.// Неявным типом параметра 's' является 'any'// Обратите внимание, что ошибки не возникаетreturn s.toLowercse() === 'ok'// any}}

В приведенном примере мы, возможно, ожидали, что тип s будет определен на основе name: string в check. Это не так implements не меняет того, как проверяется тело класса или предполагаются его типы.


Также следует помнить о том, что определение в интерфейсе опционального свойства не приводит к созданию такого свойства:


interface A {x: numbery?: number}class C implements A {x = 0}const c = new C()c.y = 10// Property 'y' does not exist on type 'C'.// Свойства с названием 'y' не существует в типе 'C'

extends


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


class Animal {move() {console.log('Moving along!')}}class Dog extends Animal {woof(times: number) {for (let i = 0; i < times; i++) {console.log('woof!')}}}const d = new Dog()// Метод базового классаd.move()// Метод производного классаd.woof(3)

Перезапись методов


Производный класс может перезаписывать свойства и методы базового класса. Для доступа к методам базового класса можно использовать синтаксис super. Поскольку классы в JS это всего лишь объекты для поиска (lookup objects), такого понятия как супер-поле не существует.


TS обеспечивает, чтобы производный класс всегда был подтипом базового класса.


Пример легального способа перезаписи метода:


class Base {greet() {console.log('Привет, народ!')}}class Derived extends Base {greet(name?: string) {if (name === undefined) {super.greet()} else {console.log(`Привет, ${name.toUpperCase()}`)}}}const d = new Derived()d.greet()d.greet('читатель!')

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


// Создаем синоним для производного экземпляра с помощью ссылки на базовый классconst b: Base = d// Все работаетb.greet()

Что если производный класс не будет следовать конракту базового класса?


class Base {greet() {console.log('Привет, народ!')}}class Derived extends Base {// Делаем этот параметр обязательнымgreet(name: string) {// Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'. Type '(name: string) => void' is not assignable to type '() => void'.// Свойство 'greet' в типе 'Derived' не может быть присвоено одноименному свойству в базовом типе 'Base'...console.log(`Привет, ${name.toUpperCase()}`)}}

Если мы скомпилируем этот код, несмотря на ошибку, такой сниппет провалится:


const b: Base = new Derived()// Не работает, поскольку `name` имеет значение `undefined`b.greet()

Порядок инициализации


Порядок инициализации классов может быть неожиданным. Рассмотрим пример:


class Base {name = 'базовый'constructor() {console.log('Меня зовут ' + this.name)}}class Derived extends Base {name = 'производный'}// Вывод: 'базовый', а не 'производный'const d = new Derived()

Что здесь происходит?


Порядок инициализации согласно спецификации следующий:


  • Инициализация полей базового класса


  • Запуск конструктора базового класса


  • Инициализация полей производного класса


  • Запуск конструктора производного класса



Это означает, что конструктор базового класса использует собственное значение name, поскольку поля производного класса в этот момент еще не инициализированы.


Наследование встроенных типов


В ES2015 конструкторы, неявно возвращающие объекты, заменяют значение this для любого вызова super. Для генерируемого конструктора важно перехватывать потенциальное значение, возвращаемое super, и заменять его значением this.


Поэтому подклассы Error, Array и др. могут работать не так, как ожидается. Это объясняется тем, что Error, Array и др. используют new.target из ES6 для определения цепочки прототипов; определить значение new.target в ES5 невозможно. Другие компиляторы, обычно, имеют такие же ограничения.


Для такого подкласса:


class MsgError extends Error {constructor(m: string) {super(m)}sayHello() {return 'Привет ' + this.message}}

вы можете обнаружить, что:


  • методы объектов, возвращаемых при создании подклассов, могут иметь значение undefined, поэтому вызов sayHello завершится ошибкой


  • instanceof сломается между экземплярами подкласса и их экземплярами, поэтому (new MsgError()) instanceof MsgError возвращает false



Для решения данной проблемы можно явно устанавливать прототип сразу после вызова super.


class MsgError extends Error {constructor(m: string) {super(m)// Явно устанавливаем прототипObject.setPrototypeOf(this, MsgError.prototype)}sayHello() {return 'Привет ' + this.message}}

Тем не менее, любой подкласс MsgError также должен будет вручную устанавливать прототип. В среде выполнения, в которой не поддерживается Object.setPrototypeOf, можно использовать __proto__.


Видимость членов (member visibility)


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


public


По умолчанию видимость членов класса имеет значение public. Публичный член доступен везде:


class Greeter {public greet() {console.log('Привет!')}}const g = new Greeter()g.greet()

Поскольку public является дефолтным значением, специально указывать его не обязательно, но это повышает читаемость и улучшает стиль кода.


protected


Защищенные члены видимы только для подклассов класса, в котором они определены.


class Greeter {public greet() {console.log('Привет, ' + this.getName())}protected getName() {return 'народ!'}}class SpecialGreeter extends Greeter {public howdy() {// Здесь защищенный член доступенconsole.log('Здорово, ' + this.getName())}}const g = new SpecialGreeter()g.greet() // OKg.getName()// Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.// Свойство 'getName' является защищенным и доступно только в классе 'Greeter' и его подклассах

Раскрытие защищенных членов


Производные классы должны следовать контракту базового класса, но могут расширять подтипы базового класса дополнительными возможностями. Это включает в себя перевод protected членов в статус public:


class Base {protected m = 10}class Derived extends Base {// Модификатор отсутствует, поэтому значением по умолчанию является `public`m = 15}const d = new Derived()console.log(d.m) // OK

Обратите внимание, что в производной классе для сохранения защищенности члена необходимо повторно указывать модификатор protected.


Доступ к защищенным членам за пределами иерархии классов


Разные языки ООП по-разному подходят к доступу к защищенным членам из базового класса:


class Base {protected x: number = 1}class Derived1 extends Base {protected x: number = 5}class Derived2 extends Base {f1(other: Derived2) {other.x = 10}f2(other: Base) {other.x = 10// Property 'x' is protected and only accessible through an instance of class 'Derived2'. This is an instance of class 'Base'.// Свойство 'x' является защищенным и доступно только через экземпляр класса 'Derived2'. А это  экземпляр класса 'Base'}}

Java, например, считает такой подход легальным, а C# и C++ нет.


TS считает такой подход нелегальным, поскольку доступ к x из Derived2 должен быть легальным только в подклассах Derived2, а Derived1 не является одним из них.


private


Частные члены похожи на защищенные, но не доступны даже в подклассах, т.е. они доступны только в том классе, где они определены.


class Base {private x = 0}const b = new Base()// Снаружи класса доступ получить нельзяconsole.log(b.x)// Property 'x' is private and only accessible within class 'Base'.class Derived extends Base {showX() {// В подклассе доступ получить также нельзяconsole.log(this.x)// Property 'x' is private and only accessible within class 'Base'.}}

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


class Base {private x = 0}class Derived extends Base {// Class 'Derived' incorrectly extends base class 'Base'. Property 'x' is private in type 'Base' but not in type 'Derived'.// Класс 'Derived' неправильно расширяет базовый класс 'Base'. Свойство 'x' является частным в типе 'Base', но не в типе 'Derived'x = 1}

Доступ к защищенным членам между экземплярами


Разные языки ООП также по-разному подходят к предоставлению доступа экземплярам одного класса к защищенным членам друг друга. Такие языки как Java, C#, C++, Swift и PHP разрешают такой доступ, а Ruby нет.


TS разрешает такой доступ:


class A {private x = 10public sameAs(other: A) {// Ошибки не возникаетreturn other.x === this.x}}

Предостережение


Подобно другим аспектам системы типов TS, private и protected оказывают влияние на код только во время проверки типов. Это означает, что конструкции вроде in или простой перебор свойств имеют доступ к частным и защищенным членам:


class MySafe {private secretKey = 12345}// В JS-файле...const s = new MySafe()// Вывод 12345console.log(s.secretKey)

Для реализации настоящих частных членов можно использовать такие механизмы, как замыкания (closures), слабые карты (weak maps) или синтаксис приватных полей класса (private fields, #).


Статические члены (static members)


В классах могут определеяться статические члены. Такие члены не связаны с конкретными экземплярами класса. Они доступны через объект конструктора класса:


class MyClass {static x = 0static printX() {console.log(MyClass.x)}}console.log(MyClass.x)MyClass.printX()

К статическим членам также могут применяться модификаторы public, protected и private:


class MyClass {private static x = 0}console.log(MyClass.x)// Property 'x' is private and only accessible within class 'MyClass'.

Статические члены наследуются:


class Base {static getGreeting() {return 'Привет, народ!'}}class Derived extends Base {myGreeting = Derived.getGreeting()}

Специальные названия статических членов


Изменение прототипа Function считается плохой практикой. Поскольку классы это функции, вызываемые с помощью new, некоторые слова нельзя использовать в качестве названий статических членов. К таким словам относятся, в частности, свойства функций name, length и call:


class S {static name = 'S!'// Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'.// Статическое свойство 'name' вступает в конфликт со встроенным свойством 'Function.name' функции-конструктора 'S'}

Почему не существует статических классов?


В некоторых языках, таких как C# или Java существует такая конструкция, как статический класс (static class).


Существование этих конструкций обусловлено тем, что в названных языках все данные и функции должны находиться внутри классов; в TS такого ограничения не существует, поэтому в статических классах нет никакой необходимости.


Например, нам не нужен синтаксис статического класса, поскольку обычный объект (или функция верхнего уровня) прекрасно справляются с такими задачами:


// Ненужный статический классclass MyStaticClass {static doSomething() {}}// Альтернатива 1function doSomething() {}// Альтернатива 2const MyHelperObject = {dosomething() {},}

Общие классы (generic classes)


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


class Box<Type> {contents: Typeconstructor(value: Type) {this.contents = value}}const b = new Box('Привет!')// const b: Box<string>

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


Параметр типа в статических членах


Следующий код, как ни странно, является НЕлегальным:


class Box<Type> {static defaultValue: Type// Static members cannot reference class type parameters.// Статические члены не могут ссылаться на типы параметров класса}

Запомните, что типы полностью удаляются! Во время выполнения существует только один слот Box.defaultValue. Это означает, что установка Box<string>.defaultValue (если бы это было возможным) изменила бы Box<number>.defaultValue, что не есть хорошо. Поэтому статические члены общих классов не могут ссылаться на параметры типа класса.


Значение this в классах во время выполнения кода


TS не изменяет поведения JS во время выполнения. Обработка this в JS может показаться необычной:


class MyClass {name = 'класс'getName() {return this.name}}const c = new MyClass()const obj = {name: 'объект',getName: c.getName,}// Выводится 'объект', а не 'класс'console.log(obj.getName())

Если кратко, то значение this внутри функции зависит от того, как эта функция вызывается. В приведенном примере, поскольку функция вызывается через ссылку на obj, значением this является obj, а не экземпляр класса.


TS предоставляет некоторые средства для изменения такого поведения.


Стрелочные функции


Если у вас имеется функция, которая часто будет вызываться способом, приводящим к потере контекста, имеет смысл определить такое свойство в виде стрелочной функции:


class MyClass {name = 'класс'getName = () => {return this.name}}const c = new MyClass()const g = c.getName// Выводится 'класс'console.log(g())

Это требует некоторых компромиссов:


  • Значение this будет гарантированно правильным во время выполнения, даже в коде, не прошедшем проверки с помощью TS


  • Будет использоваться больше памяти, поскольку для каждого экземпляра класса будет создаваться новая функция


  • В производном классе нельзя будет использовать super.getName, поскольку отсутствует входная точка для получения метода базового класса в цепочке прототипов



Параметры this


При определении метода или функции начальный параметр под названием this имеет особое значение в TS. Данный параметр удаляется во время компиляции:


// TSfunction fn(this: SomeType, x: number) {/* ... */}// JSfunction fn(x) {/* ... */}

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


class MyClass {name = 'класс'getName(this: MyClass) {return this.name}}const c = new MyClass()// OKc.getName()// Ошибкаconst g = c.getNameconsole.log(g())// The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.// Контекст 'this' типа 'void' не может быть присвоен методу 'this' типа 'MyClass'

Данный подход также сопряжен с несколькими органичениями:


  • Мы все еще имеем возможность вызывать метод неправильно


  • Выделяется только одна функция для каждого определения класса, а не для каждого экземпляра класса


  • Базовые определения методов могут по-прежнему вызываться через super



Типы this


В классах специальный тип this динамически ссылается на тип текущего класса:


class Box {contents: string = ''set(value: string) {// (method) Box.set(value: string): thisthis.contents = valuereturn this}}

Здесь TS предполагает, что типом this является тип, возвращаемый set, а не Box. Создадим подкласс Box:


class ClearableBox extends Box {clear() {this.contents = ''}}const a = new ClearableBox()const b = a.set('привет')// const b: ClearableBox

Мы также можем использовать this в аннотации типа параметра:


class Box {content: string = ''sameAs(other: this) {return other.content === this.content}}

Это отличается от other: Box если у нас имеется производный класс, его метод sameAs будет принимать только другие экземпляры этого производного класса:


class Box {content: string = ''sameAs(other: this) {return other.content === this.content}}class DerivedBox extends Box {otherContent: string = '?'}const base = new Box()const derived = new DerivedBox()derived.sameAs(base)// Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'. Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'.

Основанные на this защитники типа


Мы можем использовать this is Type в качестве возвращаемого типа в методах классов и интерфейсах. В сочетании с сужением типов (например, с помощью инструкции if), тип целевого объекта может быть сведен к более конкретному Type.


class FileSystemObject {isFile(): this is FileRep {return this instanceof FileRep}isDirectory(): this is Directory {return this instanceof Directory}isNetworked(): this is Networked & this {return this.networked}constructor(public path: string, private networked: boolean) {}}class FileRep extends FileSystemObject {constructor(path: string, public content: string) {super(path, false)}}class Directory extends FileSystemObject {children: FileSystemObject[]}interface Networked {host: string}const fso: FileSystemObject = new FileRep('foo/bar.txt', 'foo')if (fso.isFile()) {fso.content// const fso: FileRep} else if (fso.isDirectory()) {fso.children// const fso: Directory} else if (fso.isNetworked()) {fso.host// const fso: Networked & FileSystemObject}

Распространенным случаем использования защитников или предохранителей типа (type guards) на основе this является ленивая валидация определенного поля. В следующем примере мы удаляем undefined из значения, содержащегося в box, когда hasValue проверяется на истинность:


class Box<T> {value?: ThasValue(): this is { value: T } {return this.value !== undefined}}const box = new Box()box.value = 'Gameboy'box.value// (property) Box<unknown>.value?: unknownif (box.hasValue()) {box.value// (property) value: unknown}

Свойства параметров


TS предоставляет специальный синтаксис для преобразования параметров конструктора в свойства класса с аналогичными названиями и значениями. Это называется свойствами параметров (или параметризованными свойствами), такие свойства создаются с помощью добавления модификаторов public, private, protected или readonly к аргументам конструктора. Создаваемые поля получают те же модификаторы:


class Params {constructor(public readonly x: number,protected y: number,private z: number) {// ...}}const a = new Params(1, 2, 3)console.log(a.x)// (property) Params.x: numberconsole.log(a.z)// Property 'z' is private and only accessible within class 'Params'.

Выражения классов (class expressions)


Выражения классов похожи на определения классов. Единственным отличием между ними является то, что выражения классов не нуждаются в названии, мы можем ссылаться на них с помощью любого идентификатора, к которому они привязаны (bound):


const someClass = class<Type> {content: Typeconstructor(value: Type) {this.content = value}}const m = new someClass('Привет, народ!')// const m: someClass<string>

Абстрактные классы и члены


Классы, методы и поля в TS могут быть абстрактными.


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


Абстрактные классы выступают в роли базовых классов для подклассов, которые реализуют абстрактных членов. При отсутствии абстрактных членов класс считается конкретным (concrete).


Рассмотрим пример:


abstract class Base {abstract getName(): stringprintName() {console.log('Привет, ' + this.getName())}}const b = new Base()// Cannot create an instance of an abstract class.// Невозможно создать экземпляр абстрактного класса

Мы не можем инстанцировать Base с помощью new, поскольку он является абстрактным. Вместо этого, мы должны создать производный класс и реализовать всех абстрактных членов:


class Derived extends Base {getName() {return 'народ!'}}const d = new Derived()d.printName()

Обратите внимание: если мы забудем реализовать абстрактных членов, то получим ошибку:


class Derived extends Base {// Non-abstract class 'Derived' does not implement inherited abstract member 'getName' from class 'Base'.// Неабстрактный класс 'Derived' не реализует унаследованный от класса 'Base' абстрактный член 'getName'// Забыли про необходимость реализации абстрактных членов}

Сигнатуры абстрактных конструкций (abstract construct signatures)


Иногда нам требуется конструктор класса, создающий экземпляр класса, производный от некоторого абстрактного класса.


Рассмотрим пример:


function greet(ctor: typeof Base) {const instance = new ctor()// Cannot create an instance of an abstract class.instance.printName()}

TS сообщает нам о том, что мы пытаемся создать экземпляр абстрактного класса. Тем не менее, имея определение greet, мы вполне можем создать абстрактный класс:


// Плохо!greet(Base)

Вместо этого, мы можем написать функцию, которая принимает нечто с сигнатурой конструктора:


function greet(ctor: new () => Base) {const instance = new ctor()instance.printName()}greet(Derived)greet(Base)/*Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.Cannot assign an abstract constructor type to a non-abstract constructor type.*//*Аргумент типа 'typeof Base' не может быть присвоен параметру типа 'new () => Base'.Невозможно присвоить тип абстрактного конструктора типу неабстрактного конструктора*/

Теперь TS правильно указывает нам на то, какой конструктор может быть вызван Derived может, а Base нет.


Отношения между классами


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


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


class Point1 {x = 0y = 0}class Point2 {x = 0y = 0}// OKconst p: Point1 = new Point2()

Также существуют отношения между подтипами, даже при отсутствии явного наследования:


class Person {name: stringage: number}class Employee {name: stringage: numbersalary: number}// OKconst p: Person = new Employee()

Однако, существует одно исключение.


Пустые классы не имеют членов. В структурном отношении такие классы являются супертипами для любых других типов. Так что, если мы создадим пустой класс (не надо этого делать!), вместо него можно будет использовать что угодно:


class Empty {}function fn(x: Empty) {// С `x` можно делать что угодно}// OK!fn(window)fn({})fn(fn)



Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Перевод Карманная книга по TypeScript. Часть 8. Модули

21.06.2021 10:15:25 | Автор: admin

image


Мы продолжаем серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript".

Другие части:



Определение модуля


В TS, как и в ECMAScript2015, любой файл, содержащий import или export верхнего уровня (глобальный), считается модулем.


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


Модули выполняются в собственной области видимости, а не в глобальной. Это означает, что переменные, функции, классы и т.д., объявленные в модуле, недоступны за пределами модуля до тех пор, пока они в явном виде не будут из него экспортированы. Кроме того, перед использованием экспортированных сущностей, их следует импортировать в соответствующий файл.


Не модули


Для начала, давайте разберемся, что TS считает модулем. Спецификация JS определяет, что любой файл без export или await верхнего уровня является скриптом, а не модулем.


Переменные и типы, объявленные в скрипте, являются глобальными (имеют глобальную область видимости), для объединения нескольких файлов на входе в один на выходе следует использовать либо настроку компилятора outFile, либо несколько элементов script в разметке (указанных в правильном порядке).


Если у нас имеется файл, который не содержит import или export, но мы хотим, чтобы этот файл считался модулем, просто добавляем в него такую строку:


export {}

Модули в TS


Существует 3 вещи, на которые следует обращать внимание при работе с модулями в TS:


  • Синтаксис: какой синтаксис я хочу использовать для импорта и экспорта сущностей?
  • Разрешение модулей: каковы отношения между названиями модулей (или их путями) и файлами на диске?
  • Результат: на что должен быть похож код модуля?

Синтаксис


Основной экспорт в файле определяется с помощью export default:


// @filename: hello.tsexport default function helloWorld() {  console.log('Привет, народ!')}

Затем данная функция импортируется следующим образом:


import hello from './hello.js'hello()

В дополнению к экспорту по умолчанию, из файла может экспортироваться несколько переменных и функций с помощью export (без default):


// @filename: maths.tsexport var pi = 3.14export let squareTwo = 1.41export const phi = 1.61export class RandomNumberGenerator {}export function absolute(num: number) {  if (num < 0) return num * -1  return num}

Указанные сущности импортируются так:


import { pi, phi, absolute } from './maths.js'console.log(pi)const absPhi = absolute(phi)  // const absPhi: number

Дополнительный синтаксис импорта


Название импортируемой сущности можно менять с помощью import { old as new }:


import { pi as  } from './maths.js'console.log()        /*          (alias) var : number          import         */

Разные способы импорта можно смешивать:


// @filename: maths.tsexport const pi = 3.14export default class RandomNumberGenerator {}// @filename: app.tsimport RNGen, { pi as  } from './maths.js'RNGen/*  (alias) class RNGen  import RNGen*/console.log()/*  (alias) const : 3.14  import */

Все экспортированные объекты при импорте можно поместить в одно пространство имен с помощью * as name:


// @filename: app.tsimport * as math from './maths.js'console.log(math.pi)const positivePhi = math.absolute(math.phi)  // const positivePhi: number

Файлы можно импортировать без указания переменных:


// @filename: app.tsimport './maths.js'console.log('3.14')

В данном случае import ничего не делает. Тем не менее, весь код из maths.ts вычисляется (оценивается), что может привести к запуску побочных эффектов, влияющих на другие объекты.


Специфичный для TS синтаксис модулей


Типы могут экспортироваться и импортироваться с помощью такого же синтаксиса, что и значения в JS:


// @filename: animal.tsexport type Cat = { breed: string, yearOfBirth: number }export interface Dog {  breeds: string[]  yearOfBirth: number}// @filename: app.tsimport { Cat, Dog } from './animal.js'type Animals = Cat | Dog

TS расширяет синтаксис import с помощью import type, что позволяет импортировать только типы.


// @filename: animal.tsexport type Cat = { breed: string, yearOfBirth: number }// 'createCatName' cannot be used as a value because it was imported using 'import type'.// 'createCatName' не может использоваться в качестве значения, поскольку импортируется с помощью 'import type'export type Dog = { breeds: string[], yearOfBirth: number }export const createCatName = () => 'fluffy'// @filename: valid.tsimport type { Cat, Dog } from './animal.js'export type Animals = Cat | Dog// @filename: app.tsimport type { createCatName } from './animal.js'const name = createCatName()

Такой импорт сообщает транспиляторам, вроде Babel, swc или esbuild, какой импорт может быть безопасно удален.


Синтаксис ES-модулей с поведением CommonJS


Синтаксис ES-модулей в TS напрямую согласуется с CommonJS и require из AMD. Импорт с помощью ES-модулей в большинстве случаев представляет собой тоже самое, что require в указанных окружениях, он позволяет обеспечить полное совпадение TS-файла с результатом CommonJS:


import fs = require('fs')const code = fs.readFileSync('hello.ts', 'utf8')

Синтаксис CommonJS


CommonJS это формат, используемый большинством npm-пакетов. Даже если вы используете только синтаксис ES-модулей, понимание того, как работает CommonJS, поможет вам в отладке приложений.


Экспорт


Идентификаторы экпортируются посредством установки свойства exports глобальной переменной module:


function absolute(num: number) {  if (num < 0) return num * -1  return num}module.exports = {  pi: 3.14,  squareTwo: 1.41,  phi: 1.61,  absolute}

Затем эти файлы импортируются с помощью инструкции require:


const maths = require('maths')maths.pi  // any

В данном случае импорт можно упростить с помощью деструктуризации:


const { squareTwo } = require('maths')squareTwo  // const squareTwo: any

Взаимодействие CommonJS с ES-модулями


Между CommonJS и ES-модулями имеется несовпадение, поскольку ES-модули поддерживают "дефолтный" экспорт только объектов, но не функций. Для преодоления данного несовпадения в TS используется флаг компиляции esModuleInterop.


Настройки, связанные с разрешением модулей


Разрешение модулей это процесс определения файла, указанного в качестве ссылки в строке из инструкции import или require.


TS предоставляет две стратегии разрешения модулей: классическую и Node. Классическая стратегия является стратегией по умолчанию (когда флаг module имеет значение, отличное от commonjs) и включается для обеспечения обратной совместимости. Стратегия Node имитирует работу Node.js в режиме CommonJS с дополнительными проверками для .ts и .d.ts.


Существует большое количество флагов, связанных с разрешением модулей: moduleResolution, baseUrl, paths, rootDirs и др.


Настройки для результатов разрешения модулей


Имеется две настройки, которые влияют на результирующий JS-код:


  • target определяет версию JS, в которую компилируется TS-код
  • module определяет, какой код используется для взаимодействия модулей между собой

То, какую цель (target) использовать, зависит от того, в какой среде будет выполняться код (какие возможности поддерживаются этой средой). Это может включать в себя поддержку старых браузеров, более низкую версию Node.js или специфические ограничения, накладываемые такими средами выполнения, как, например, Electron.


Коммуникация между модулями происходит через загрузчик модулей (module loader), определяемый в настройке module. Во время выполнения загрузчик отвечает за локализацию и установку всех зависимостей модуля перед его выполнением.


Ниже приведено несколько примеров использования синтаксиса ES-модулей с разными настройками module:


import { valueOfPi } from './constants.js'export const twoPi = valueOfPi * 2

ES2020


import { valueOfPi } from './constants.js'export const twoPi = valueOfPi * 2

CommonJS


"use strict";Object.defineProperty(exports, "__esModule", { value: true });exports.twoPi = void 0;const constants_js_1 = require("./constants.js");exports.twoPi = constants_js_1.valueOfPi * 2;

UMD


(function (factory) {  if (typeof module === "object" && typeof module.exports === "object") {    var v = factory(require, exports);    if (v !== undefined) module.exports = v;  }  else if (typeof define === "function" && define.amd) {    define(["require", "exports", "./constants.js"], factory);  }})(function (require, exports) {  "use strict";  Object.defineProperty(exports, "__esModule", { value: true });  exports.twoPi = void 0;  const constants_js_1 = require("./constants.js");  exports.twoPi = constants_js_1.valueOfPi * 2;});

Пространства имен (namespaces)


TS имеет собственный модульный формат, который называется namespaces. Данный синтаксис имеет множество полезных возможностей по созданию сложных файлов определений и по-прежнему активно используется в DefinitelyTyped. Несмотря на то, что namespaces не признаны устаревшими (deprecated), большая часть его возможностей нашла воплощение в ES-модулях, поэтому настоятельно рекомендуется использовать официальный синтаксис.




VPS серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru