Обратная совместимость — одно из ключевых требований к современным UI‑библиотекам, которое стоит в одном ряду с требованиями к удобству использования компонентов и наличию качественной дизайн‑системы. Более того, она должна обеспечивать не только сохранение работоспособности проекта клиента после обновления библиотеки, но и неизменность самого подхода к написанию кода. Последний аспект может бросать определённые вызовы для команды разработки UI‑библиотеки и создавать уникальные сценарии.
Привет, Хабр! На связи Павел Урядышев, главный ИТ‑инженер Platform V UI Kit в СберТехе. В этом материале я расскажу, с какой необычной проблемой обратной совместимости столкнулась наша команда во время подготовки релиза UI‑библиотеки Platform V UI Kit. Это решение для построения интерфейсов любого уровня сложности: от корпоративных приложений до сайтов.
О чём речь
О решении для создания интерфейсов любого уровня сложности: от корпоративных приложений до сайтов. Оно состоит из библиотеки React‑компонентов с самой гибкой дизайн‑токен системой и инструментария по её визуальному конфигурированию. Библиотека доступна в open source на GitVerse. Ключевыми инструментами разработки для нас являются React JS и TypeScript.
Библиотека сохраняет поддержку обратной совместимости в рамках минорной версии. Иначе говоря, библиотека гарантирует, что если пользователь обновит её текущую условную версию «1.10.1» на новую условную версию «1.11.0», то:
библиотека будет доступна для использования без каких‑либо препятствий;
проект пользователя будет корректно работать с уже существующими компонентами библиотеки;
пользователь получит доступ к новой функциональности, добавленной в новой версии;
в коде не возникнут ошибки типизации TypeScript, которые может вызвать библиотека после обновления.
Если для первых трёх пунктов у команды есть чёткие алгоритмы действий, то выполнение последнего пункта временами требует от разработчиков значительных усилий для поиска правильного решения, которое сможет обеспечить обратную совместимость.
Прежде чем перейти к основной части статьи, поясню, что я представлю сокращённый вариант кода, из которого убраны лишние части. Основное внимание уделено коду, который участвует в задаче, и решению проблемы обратной совместимости.
Постановка задачи и исходный код
Наша команда получила очередное задание: добавить кнопку очистки значения в правой части текстового поля InputBase
. Кнопка будет появляться, если пользователь передаст свойство canClear
со значением true
, и если поле не пустое. На данный момент код компонента выглядит так:
InputBase
представляет собой обычное текстовое поле и имеет стандартную реализацию FLUX‑круговорота:
Компонент получает значение через свойство
value
и передаёт его в HTML‑элементinput
;При изменении значения в
input
-е вызывается функцияhandleChange
, которая принимает событие типаReact.ChangeEvent<HTMLInputElement>
и вызывает функцию обратного вызоваonChange
из свойства компонента, передавая в неё два аргумента: новое значение поля в качестве первого и само событие в качестве второго.
Решение задачи
Добавляем функциональность очистки поля
Приступаем к реализации: cперва объявим в интерфейсе InputBaseProps
новое свойство canClear
, которое может являться либо типом boolean
, либо типом undefined
. Затем добавим саму кнопку очистки и показываем её только если свойство компонента canClear
равно true
. Перед тем, как отрисовать кнопку, мы проверяем, что значение поля не пустое. Сразу создадим основу функции, которая будет выполнять очистку поля.
Теперь нам нужно в функции handleClear
вызвать функцию обратного вызова onChange
и передать ей пустую строку в качестве первого аргумента, а также event
самой функции в качестве второго аргумента. После этого, казалось бы, задача будет завершена!
Исправляем ошибки типизации
Мы не можем просто вызвать onChange
в функции handleClear
. Если мы попытаемся это сделать, то возникнет ошибка типизации: «Argument of type 'PointerEvent<HTMLButtonElement>' is not assignable to parameter of type 'ChangeEvent<HTMLInputElement>'». Это ожидаемо, так как ранее инициатором события во время изменения значения был HTML‑элемент input
, и оно имело соответствующий тип React.ChangeEvent<HTMLInputElement>
. Теперь нам нужно учесть, что инициатором события может стать и кнопка удаления, на которую нажмёт пользователь.
Самый простой способ исправить эту ошибку типизации — использовать шаблон TypeScript под названием Union Types, или «Объединение типов», что позволит объединить два типа события в один.
Как видно из 11 и 12 строчек кода, мы объединили типы и теперь аргумент event
в функции обратного вызова onChange
может быть событием типа либо React.ChangeEvent<HTMLInputElement>
, либо React.PointerEvent<HTMLButtonElement>
. Это позволило нам устранить ошибку типизации в функции handleClear
. Давайте проанализируем наше решение и убедимся, что оно сохраняет обратную совместимость.
Всё ли мы учли?
Представим, что клиент использует компонент InputBase
в своём приложении и имеет следующий код:
Код представляет из себя форму для подписки на уведомления, в которой есть поле для ввода email
и кнопка для отправки данных на сервер. В 11 строчке клиент объявляет функцию handleChangeEmail
и передаёт её в свойство onChange
компонента InputBase
. Обратите внимание, что в 15 строчке он использует второй аргумент event
, который имеет тип React.ChangeEvent<HTMLInputElement>
.
Что произойдёт с кодом клиента, если он обновит свою библиотеку с нашими последними изменениями?
Как видно из 28 строчки кода, после обновления библиотеки у клиента появилась ошибка типизации TypeScript, несмотря на то, что он не изменял свой код. Проблема в том, что теперь функция onChange
компонента InputBase
принимает второй аргумент в виде объединённого типа React.ChangeEvent<HTMLInputElement>
и React.PointerEvent<HTMLButtonElement>
. В то же время клиент передаёт в onChange
функцию handleChange
, где аргумент event
соответствует только части типа, а именно React.ChangeEvent<HTMLInputElement>
. Чтобы устранить эту ошибку, пользователю нужно будет пройтись по всем участкам кода своего приложения, где используется этот аргумент, и привести его к новому объединённому типу. На основании этого мы с уверенностью делаем вывод, что решение нарушает обратную совместимость библиотеки.
Ищем новое решение
Как можно избежать этой ошибки типизации? Давайте попробуем сосредоточиться на новом свойстве canClear
. После обновления библиотеки у клиента это свойство в любом случае по умолчанию будет равно undefined, так как он ещё не использовал его. Нам же так или иначе потребуется шаблон «Объединения типов» для второго аргумента event
в функции onChange
, по‑другому нам доработку не реализовать. Таким образом, чтобы сохранить обратную совместимость, нам нужно как‑то определить, что если canClear
равно true
, то мы изменяем тип аргумента event
, а если нет — оставляем его прежним.
В итоге наш вопрос для решения задачи обратной совместимости можно сформулировать так: как изменить тип второго аргумента event
у функции onChange
в зависимости от значения свойства canClear
?
В этом нам может помочь шаблон TypeScript под названием Discriminated Union, или «Дискриминантное объединение». Он позволяет TypeScript различать разные типы, основываясь на значении одного общего для них литерального свойства. Такое общее свойство называется дискриминантом.
В нашем случае дискриминантом будет свойство canClear
компонента InputBase
. Мы будем использовать его значение, чтобы определить тип второго аргумента event в функции onChange
. В частности, если canClear
равно true
, то в аргументе event
мы сможем применить шаблон «Объединения типов» для React.ChangeEvent<HTMLInputElement>
и React.PointerEvent<HTMLButtonElement>
. Попробуем реализовать эту идею.
Для начала переделаем InputBaseProps
с интерфейса на тип с общим дискриминантом canClear
и разными типами для свойства onChange
, в зависимости от значения canClear
.
В этом дискриминантном объединении типов мы объявляем, что если свойство canClear
равно true
, то второй аргумент event
в функции onChange
будет иметь тип либо React.ChangeEvent<HTMLInputElement>
, либо React.PointerEvent<HTMLButtonElement>
. Во всех других случаях event
будет только типа React.ChangeEvent<HTMLInputElement>
.
Снова исправляем ошибки типизации
Однако после этих изменений у нас неожиданно в функции handleClear
в 33 строчке возникает новая ошибка, связанная с типами: «Argument of type 'PointerEvent<HTMLButtonElement>' is not assignable to parameter of type 'ChangeEvent<HTMLInputElement>'».
Ошибка возникает из‑за того, что компилятор TypeScript не может чётко определить тип аргумента event
. Он не знает, какое значение имеет свойство canClear
, поэтому автоматически считает, что event
равен типу React.ChangeEvent<HTMLInputElement>
. Мы же, в свою очередь, передаём event
типа React.PointerEvent<HTMLButtonElement>
, что и вызывает ошибку типизации. По нашей задумке canClear
должен принимать значение true
только в том случае, если event
имеет тип React.PointerEvent<HTMLButtonElement>
.
Чтобы помочь компилятору TypeScript определить правильный тип аргумента event
, мы в функции handleClear
применим шаблон под названием Type Narrowing, или «Сужение типов», который позволяет уточнять тип свойств или переменных на основе выполнения условий. Таким образом, используя общий дискриминант canClear
, мы будем сообщать компилятору TypeScript, что в данном контексте он действительно будет равен true
, и event
может быть равен как типу React.ChangeEvent<HTMLInputElement>
, так и типу React.PointerEvent<HTMLButtonElement>
.
В 34строчке применён условный оператор if
для сужения типов. Мы проверяем, что если свойство canClear
не равно true
, то выходим из функции. Иначе просто вызываем функцию onChange
, и тип аргумента event
автоматически определяется правильно. Также стоит отметить, что из‑за особенностей компилятора TypeScript мы исключили свойства canClear
и onChange
из общей декомпозиции на 27 строке и обращаемся к ним напрямую через аргумент props
.
Мы точно всё учли?
Давайте вернёмся к коду клиента и убедимся, что все ошибки типизации исчезли.
Как мы видим, ошибки действительно нет. Теперь давайте посмотрим, что произойдёт с кодом клиента, если он передаст в компонент InputBase
новое свойство canClear
со значением true
.
Как и ожидалось, возникла новая ошибка типизации, потому что клиент передаёт неправильный тип аргумента event в свойство onChange
, когда canClear
равно true
. Теперь компонент InputBase
будет требовать, чтобы ему передавали объединённый тип React.ChangeEvent<HTMLInputElement>
и React.PointerEvent<HTMLButtonElement>
, если клиенту понадобится использовать новую функциональность очистки значения поля. Это будет обязательным условием для использования новой функциональности и не нарушит обратную совместимость.
Ещё раз убедимся, что предложенное нами решение является корректным. Мы заменили интерфейс InputBaseProps
на тип. Какие могут быть последствия такого изменения? В документации TypeScript указано, что интерфейсы могут взаимодействовать с типами. То есть критических изменений быть не должно. Для наглядности давайте представим ситуацию, в которой клиент решил создать свой собственный компонент Input
, основываясь на нашем компоненте InputBase
, используя для описания свойств интерфейс.
Предположим, что у клиента есть следующий код компонента:
В своём компоненте клиент создал интерфейс InputProps
, который расширяет интерфейс InputBaseProps
из библиотеки @v-uik. Он добавляет новое свойство description
и показывает его под компонентом. Остальные свойства проксируются в компонент InputBase
. Что в таком случае произойдёт с кодом клиента после обновления библиотеки?
После обновления библиотеки у пользователя возникла ошибка TypeScript: «An interface can only extend an object type or intersection of object types with statically known members». Сообщение говорит о том, что пользователь пытается расширить интерфейс с помощью типа, который имеет динамические (не статические) свойства. Проблема заключается в том, что интерфейсы не могут расширять типы, включающие в себя объединённые типы, являющиеся динамическими. Следовательно, такое решение со сменой интерфейса на тип нам не подходит.
Снова ищем решение
Теперь мы понимаем, что нам нужно найти решение, не меняя интерфейс на тип. Если интерфейс не может работать с типами, реализующими дискриминантное объединение, то как решить проблему обратной совместимости по‑другому?
Давайте порассуждаем. Мы знаем, что значение свойства canClear
должно определять, какого типа должен быть второй аргумент у свойства onChange в компоненте InputBase
. Иначе говоря, свойство должно обладать условным типом. При этом вышеописанный механизм должен уметь работать с интерфейсами TypeScript‑а. Чтобы это реализовать, давайте обратимся к очередному шаблону TypeScript под названием Conditional Types, или «Условные типы». Он позволяет, в зависимости от выполнения необходимого условия, присваивать нужный тип для определённого свойства. Предположим, что реализация будет выглядеть так:
Если свойство canClear
равно true
, то свойство onChange
будет иметь обновлённый тип, в котором второй аргумент event
будет являться объединённым типом. В противном случае мы сохраняем предыдущую типизацию для свойства onChange
. Для выполнения этой проверки внедрим в наш интерфейс обобщение (generic), которое будет определять наличие или отсутствие свойства canClear
.
В 4 строчке мы объявляем обобщение (generic) TCanClear
, которое представляет собой булев тип. С 9 строчки мы начинаем проверять его значение: если оно равно true
, то мы решаем, что пользователь задал свойство canClear
со значением true
, и мы можем присвоить свойству onChange
новый объединённый тип; а если нет, то canClear
не задано (равно undefined
) или равно false
, и мы оставляем предыдущий тип для свойства onChange
. Применённый нами шаблон, в котором мы используем обобщение (generic) вместе с условными типами, называется Distributive Conditional Types, или «Распределённые условные типы», и является частным случаем шаблона Conditional Types.
Последнее, что нам осталось, это связать значение свойства canClear
с объявленным нами обобщением (generic) TCanClear
.
В 7 строчке мы присвоили свойству canClear
наше обобщение TCanClear
. Этот способ позволяет компилятору TypeScript автоматически определять тип свойства canClear
в зависимости от контекста присвоенного пользователем значения. В частности, если canClear
будет равно true
, то это значение присвоится обобщению TCanClear
. Это запустит нужную проверку типа для свойства onChange
в 9 строчке. Подход, который мы применили, является очередным шаблоном TypeScript под названием Contextual Typing, или «Контекстная типизация».
Давайте применим наш новый интерфейс в компоненте и посмотрим, как изменился наш код.
В 33 строчке мы столкнулись с новой ошибкой типизации. Сообщение говорит о том, что мы не можем передать второй аргумент event
в функцию onChange
, потому что он не соответствует ожидаемому типу React.ChangeEvent<HTMLInputElement>
. На самом деле, сейчас тип этого аргумента — React.PointerEvent<HTMLButtonElement>
. Как мы можем это исправить?
Во‑первых, нужно понимать, что в текущей реализации компилятор TypeScript не может применить шаблон «Сужение типов». Наличие условного оператора if
в 29 строчке нам никак не поможет. Во‑вторых, обобщение (generic) TCanClear
в нашем компоненте никак не фигурирует. Поэтому нам нужно добавить это обобщение (generic) и изменить тип свойство onChange
на правильное. Давайте выполним эти шаги.
В 21 строчке мы добавили обобщение TCanClear
и передали его в наш интерфейс InputBaseProps
. Предполагая, что пользователь присвоит свойству canClear
значение true
и будет использовать функциональность очистки поля, мы создаём временную переменную _onChange
и задаём ей новый тип с изменённым вторым аргументом event
. Затем в 42 строчке мы просто её вызываем.
А сейчас мы действительно всё учли?
Давайте снова вернёмся к коду первого клиента, который использовал наш компонент в своём приложении, и убедимся, что типизация нашего компонента работает так, как ожидается.
Действительно, ошибок в коде у клиента нет. Давайте теперь клиент попробует передать свойство canClear
в компонент InputBase
.
Ошибка типизации появилась, так как клиент передаёт в свойство onChange
функцию с неправильным типом второго аргумента event
, как и ожидалось. Теперь вернёмся к коду второго клиента и убедимся, что после обновления библиотеки у кода его пользовательского компонента не появились ошибки типизации.
У второго клиента теперь тоже отсутствуют ошибки типизации, связанные с расширением динамических типов интерфейсом. Исходя из этого, мы делаем вывод, что реализованное нами решение не вызывает ошибок TypeScript и полностью соответствует требованиям обратной совместимости.
Заключение
В ходе нашей работы над проектом мы создали решение, которое полностью соответствует принципам обратной совместимости. Это означает, что мы внедрили новую функциональность очистки поля, не нарушая существующий код клиентов. Такое внимание к обратной совместимости очень важно, так как оно позволяет пользователям безболезненно интегрировать обновления и новые возможности в свои приложения с помощью нашей библиотеки.
Одним из ключевых факторов, способствующих сохранению обратной совместимости, стало использование шаблонов TypeScript. Благодаря им мы смогли сделать код более гибким и безопасным, что позволило избежать проблем у клиентов при обновлении библиотеки.
Если вас заинтересовал наш подход и вы хотите ознакомиться с полным кодом нашего компонента, то он доступен на платформе GitVerse.
Комментарии (10)
Alexandroppolus
18.12.2024 12:54Вопрос - точно ли надо в onChange дополнительным параметром передавать event? Да, у вас там был пример, где вызывается
event.preventDefault
, но preventDefault не работает для этого события. Не говоря уж о том, что это управляемый компонент, и value можно менять (или не менять) по своему усмотрению.Обычно используется один вариант из двух: либо в onChange передают только event, чтобы кастомный инпут был по пропсам как нативный, либо передают сразу value, это удобно, например, с обычным useState - можно написать
onChange={setValue}
. Либо ещё делают сразу два события - onChange и onChangeValue, с евентом и строкой соответственно.Если всё-таки прямо нужен event, то всё равно в обработчике onChange абсолютно без надобности PointerEvent. Ну вот реально, зачем мне координаты клика, очистившего поле ввода? Можно вызвать изменение текста программно, у нас для этого используется такая утилита, способная правильно триггернуть нужное событие (которое, в свою очередь, вызовет реактовское onChange):
export const setInputValue = ( inputElement: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null | undefined, value: string, ): void => { if (!inputElement) { return; } const valueSetter = Object?.getOwnPropertyDescriptor(inputElement, 'value')?.set; const prototype = Object.getPrototypeOf(inputElement); const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set; if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { prototypeValueSetter.call(inputElement, value); } else { valueSetter?.call(inputElement, value); } inputElement.dispatchEvent(new Event('input', {bubbles: true})); };
FedExRU Автор
18.12.2024 12:54Спасибо за комментарий) Сейчас постараюсь все разъяснить:
Вопрос - точно ли надо в onChange дополнительным параметром передавать event?
Да, в большинстве случаев такой подход избыточный. API наших полей ввода как раз таки принимает первым аргументом
value
, чтобы само значение можно было быстро и удобно получить без прямого обращения к объекту события.Да, у вас там был пример, где вызывается
event.preventDefault
, но preventDefault не работает для этого события.Тут пример чисто демонстрационный (может быть и не самый удачный), который показывает, что пользователи могут как-то завязаться в своих функциях-обработчиках именно на второй аргумент
event
, который они предварительно затипизируют в соответствии с типами самого компонента. И что если мы как-то рискнем поменять тип второго аргумента, то это уже не будет сохранять обратную совместимость в рамках минорного релиза библиотеки, и нам придется выносить всю доработку в мажорную версию.Если всё-таки прямо нужен event, то всё равно в обработчике onChange абсолютно без надобности PointerEvent.
Изначально, как мы рассчитывали, в рамках компонентной библиотеки все таки есть потребность в разделении сущностей, которая вызвала изменение поля: либо изменено с помощью нативных механизмов поля
input
, либо изменено с помощью стороннего компонента (при нажатии на кнопку очистки поля).Ну вот реально, зачем мне координаты клика, очистившего поле ввода?
Тут как раз, да, речь о том, кто именно поменял значение поля, если прилетел
PointerEvent
, то это кнопка очистки поля.Можно вызвать изменение текста программно, у нас для этого используется такая утилита
Да, это отличное решение! У нас, в свою очередь, тоже есть подобная утилита для генерации событий, привязанных к полю. Мы тоже начали смотреть в эту сторону, чтобы на программном уровне генерировать события, и отходить от разделения типов объекта
event
, чтобы он всегда мог иметь один тип.Надеюсь, смог ответить на ваш вопрос)
artptr86
Я бы посоветовал во всей статье избавиться от термина "шаблон", потому что ничто из перечисленного шаблонами ни в смысле pattern, ни в смысле template, ни даже generic, как можно было бы подумать, не является.
FedExRU Автор
Да, я понимаю про что ты. При написании статьи я долго думал, как подходы TypeScript (условное объединение, и т.д) можно объединить в одно ёмкое название. Паттерн, подход... В итоге я решил предложить вариант "Шаблон", так как он чаще всего мне попадался на глаза во время поисков. Других альтернатив на просторе документации Typescript или русскоязычного коммьюнити я не нашел.
artptr86
Вообще это средства работы с типами. Можно жаргонно назвать их фичами системы типов (type system features). Ну или инструментами. Иначе это звучит как: "Для нахождения суммы чисел мы применим такой шаблон, как сложение". Глупо же, сложение — это не шаблон, а операция. Так и механизмы для работы с типами — в данном случае не шаблоны.
FedExRU Автор
Точно так же, как и операция - это один из механизмов работы с числами.
Проводя аналогию, если простое объединение типов можно было бы назвать "оператором", так как используется один символ `|`, то в случае с распределенными условными типами, где нужно указание дженерика, условных операторов с ключевыми словом `extends` и т.д - уже сложно называть оператором. Думаю, ты меня понял.
Мой тейк, в данном случае, это предложить русскоязычную адаптацию понятия "фича", которую ты упомянул. Использование слова "фича", по сути, является транслитерацией, все равно что вместо "функции обратного вызова" писать "кАлбэк" или вместо "свойства" - "проп".
artptr86
Ну вот нельзя слово feature перевести словом "шаблон" ну никак. Вот "механизм" или хотя бы "возможность" были бы более подходящими. Говорят же "механизм наследования", "механизм обобщённых типов".
Кстати "тейк" и "дженерик" — тоже транслитерации :)
FedExRU Автор
Ну почему нельзя? В англоязычном коммьюнити повсеместно и, достаточно, устойчиво используется слово "Pattern". Почему бы не перевести это, как шаблон? Особо проблем, лично я, тут не вижу. "Механизм" и "возможность" - слишком абстрактные понятия, как по мне, чтобы они давали хоть какой-нибудь намек на то, что они делают. "Шаблон" же дает некий намек на то, что это какая то предзаложенная последовательность действий.
Кстати, я не против транслитерации в лексиконе) В русскоязычной документации это всегда смотрится неуместно, как по мне)
artptr86
Да, но в каком контексте используется это слово? В программировании у него семантика "шаблонное архитектурное решение". Например, шаблон "Стратегия" реализуется посредством наследования классов реализации стратегии от общего интерфейса. Шаблон "Команда" тоже реализуется наследованием реализации от интерфейса. Но смысл в этих шаблонах разный. При этом саму возможность и механизм наследования никто шаблоном не называет.
Так же и в документации Typescript встречается слово pattern, но оно используется, в частности, для описания миксинов. Сам по себе typescript не содержит готовой возможности для работы с миксинами (у него нет ключевого слова mixin или чего-либо подобного), но такой архитектурный подход возможен, и для него естественно использовать слово "шаблон".
В вашем же случае шаблонами можно было бы назвать не сами механизмы "Объединение типов", "Дискриминантное объединение" и т.п., а конкретные архитектурные подходы работы с объектами событий. Конечно, здесь вводить терминологию будет уже сложнее.
FedExRU Автор
В TypeScript нет общепринятой терминологии для этого, что создает путаницу в названиях. Программирование - это обширное понятие, а типизация - всего лишь небольшая его часть, собственно, и сама статья посвящана этой части. Называть это "шаблоном типизации" вполне нормально, и это не звучит слишком вычурно или сложно.
"Шаблон стратегия" или "Шаблон Команда" - это же не про Typescript, это про шаблоны программирования, и да, там официальное название - это Pattern.
Если мы говорим про Typescript - "шаблон" или "шаблон типизации" - звучит вполне логично, потому что это тоже паттерн для реализации нужного итогового поведения в результате типизации (например, компонента).
В зависимости от разных контекстов одинаковые слова имеют разный смысл, и это нормально, если, базово, они будут звучать одинаково.
Пример (очень утрированный): слово "Запрос". Если мы говорим в контексте Web - запрос (query) - это отправка "сообщения" на сервер. В контексте баз данных - это команда с нужными операциями, отправляемая к базе данных. (Запрос к серверу и запрос к базе данных - это все программирование).