React 0.13 представил использование простых классов Javascript для создания компонентов, 0.14 представил «функциональные компоненты без состояния» в качестве еще одного метода их определения. Ни у одного из них нет встроенного метода для обмена поведением, как у нас с параметром mixins, переданным в createClass. Итак, теперь мы можем рассмотреть примеси как шаблон для обмена поведением и, если необходимо, придумаем что-нибудь получше. Если они не все плохие, нам нужно выяснить, как добавить их к двум новым параметрам определения компонентов.
Проблема
Статья Дэна Абромова для среды от марта 2015 года доказывает необходимость избегать примесей.
Для меня (перефразируя в крайности) выявлены ключевые проблемы:
- миксины могут непреднамеренно скрывать/распространять источник поведения
- миксины могут непреднамеренно скрывать/распространять источник какого-либо состояния компонента
- миксины очень похожи на автономные определения компонентов, но они не действуют как они. См.: объединение некоторых методов жизненного цикла, а также слияние типов свойств и объектов начального состояния.
Миксины упрощают абстрагирование поведения и управления состоянием наших компонентов. Однако такая абстракция очень сложна, и мы можем случайно заставить читателей нашего кода сыграть в разочаровывающую игру в прятки. Компоненты могут стать лоскутным одеялом из различных методов, реквизитов по умолчанию и начального состояния. Конечно, этой проблемы можно избежать с помощью Discipline™, но, возможно, есть другой подход, который поощряет сохранение связанных моделей поведения вместе.
Об этом мы и поговорим: об альтернативных способах совместного использования поведения между компонентами React. Посмотрим, найдем ли мы что-нибудь полезное.
Предлагаемые в настоящее время решения: вложенность и компоненты более высокого порядка
К функциям высшего порядка относятся: «функции, которые принимают другие функции в качестве входных данных или возвращают функцию в качестве выходных данных». Пока что определение «компонентов более высокого порядка» выглядит следующим образом: функции, которые принимают компонент React в качестве входных данных и возвращают компонент React в качестве выходных данных.
Это может выглядеть примерно так:
const Loadable = (Component) => (props) => { if (props.loading) { return <div>... loading</div> } else { return <Component { ...props } /> } } const SomeComponent = (props) => ( <div>{ props.data }</div> ) const SomeOtherComponent = (props) => ( <div>{ props.otherData }</div> ) const LoadableComponent = Loadable(SomeComponent) const AnotherLoadableComponent = Loadable(SomeOtherComponent)
Или, вы знаете:
const SomeComponent = Loadable( (props) => <div>{ props.data }</div> ) const SomeOtherComponent = Loadable( (props) => <div>{ props.otherData }</div> )
Тот же результат может быть достигнут с вложением:
const Loadable = (props) => { if (props.loading) { return <div>... loading</div> } else { return props.children } } const SomeComponent = (props) => ( <Loadable> <div>{ props.data }</div> </Loadable> ) const SomeOtherComponent = (props) => ( <Loadable> <div>{ props.otherData }</div> </Loadable> )
Последний пример может подсказать вам, что это не совсем новая идея. Вы, наверное, видели такие примеры раньше, скажем, в React Redux и React CSS Transition Group.
Первые два примера выше не так распространены и могут выглядеть немного странно. Зачем нам это делать? Давайте исследовать.
Что может случиться с последним примером, если мы хотим передать компонент, отвечающий за отрисовку состояния загрузки, а не просто печатать… загрузка? Мы могли бы передать компонент, который хотим отобразить, в качестве реквизита в Loadable:
const Loadable = ({ loading, loadingState, children }) => ( loading ? loadingState : children ) const SomeComponent = (props) => ( <Loadable loadingState={ <div>... loading (so much has changed!)</div> } > <div>{ props.data }</div> </Loadable> )
Что насчет этого:
const Loadable = (LoadingComp, ContentComp) => (props) => { if (props.loading) { return <LoadingComp { ...props } /> } else { return <ContentComp { ...props } /> } } const SomeComponent = Loadable( (props) => <div>... loading { props.resourceName }</div>, (props) => <div>{ props.data }</div> )
Что, если несколько компонентов используют одно и то же состояние загрузки?
Вложение
const Loadable = ({ loading, loadingState, children }) => ( loading ? loadingState : children ) const ResourceNameLoadingState = ({ resourceName }) => ( <div>... loading { resourceName }</div> ) const SomeComponent = (props) => ( <Loadable loadingState={ <ResourceNameLoadingState /> }> <div>{ props.data }</div> </Loadable> ) const AnotherComponent = (props) => ( <Loadable loadingState={ <ResourceNameLoadingState /> }> <div> <h1>I'm special</h1> { props.differentData } </div> </Loadable> )
Компонент высшего порядка
const Loadable = (LoadingComp, ContentComp) => (props) => { if (props.loading) { return <LoadingComp { ...props } /> } else { return <ContentComp { ...props } /> } } const ResourceNameLoadingState = ({ resourceName }) => ( <div>... loading { resourceName }</div> ) const SomeComponent = Loadable( ResourceNameLoadingState, (props) => <div>{ props.data }</div> ) const AnotherComponent = Loadable( ResourceNameLoadingState, (props) => <div>{ props.differentData }</div> )
Первый из последних двух примеров мне более читабелен. Что-то, связанное с загрузкой, заключается в обертывании рендеринга моего компонента, и он принимает состояние загрузки. Я так понимаю, он, вероятно, будет отображать loadingState во время загрузки компонента.
Второй пример более неясен. Возможно, это связано с тем, что в первом примере мы получаем больше информации из имени свойства loadingState. «Первый аргумент загружаемой функции» менее информативен.
В этом случае мы могли бы предпочесть:
<WrappingComponent> <my-component-markup /> </WrappingComponent>
… версия. Нам нужно имя для них… что-то связанное с «гнездованием». Матрешки?
Другой пример: трансформация реквизита
Давайте подумаем о другом примере. Что, если нам нужно последовательно преобразовать свойства, переданные компоненту. Допустим, мы хотим отфильтровать реквизиты данных, которые можно передать нескольким компонентам списка.
const filterBySearchTerm = (searchTermString, collection) => { const searchTermRgx = new RegExp( queryString.split('').join('.*'), 'gi' ) return collection.filter( (item) => item.match(searchTermRgx) ) } const SearchFilterable = (Component) => (props) => { const filteredData = filterBySearchTerm( props.searchTerm, props.data ) return <Component { ...props } data={ filteredData } /> } const SomeListComponent = SearchFilterable( (props = { data: [] }) => ( <ul>{ data.map((datum) => <li>{ datum }</i>) }</ul> ) )
Это проблема, с которой мы столкнулись недавно. У нас было три независимых компонента списка, в которых нужно было выполнять нечеткий поиск с использованием одних и тех же входных данных.
Давайте пойдем немного дальше: что, если в дополнение к поисковому фильтру нам нужен еще и фильтр по категориям?
// imagine that SearchFilterable has been refactored // to match the Regex against say a `name` key. const fitlerByCategory = (category, collection) => ( collection.filter((item) => item.category === category) ) const CategoryFilterable = (Component) => (props) { const filteredData = fitlerByCategory( props.categoryFilter, props.data ) return <Component { ...props } data={ filteredData } /> } const SomeListComponent = CategoryFilterable(SearchFilterable( (props = { data: [] }) => ( <ul>{ data.map((datum) => <li>{ datum }</i>) }</ul> ) ))
Хм, это довольно шатко. Однако это начинает выглядеть как обычная проблема: funcA(funcB(params)). Это композиция функций. Посмотрим, поможет ли это.
const compose = (firstFunc, ...remainingFuncs) => ( remainingFuncs.reduce((a, b) => (arg) => a(b(arg)), firstFunc) )
Нам нужно, чтобы все функции, которые мы передаем для компоновки, принимали только один аргумент, а также возвращаемое значение одной функции будет использоваться в качестве входных данных для следующей. Поэтому наши функции должны принимать компонент в качестве единственного аргумента и возвращать новый компонент. Похоже, это именно то, что они уже делают :).
С композицией это может выглядеть так:
const SomeListComponent = compose( CategoryFilterable, SearchFilterable )((props = { data: [] }) => ( <ul>{ data.map((datum) => <li>{ datum }</i>) }</ul> )) const SomeOtherListComponent = compose( CategoryFilterable, SearchFilterable )((props = { data: [] }) => ( <ol>{ data.map((datum) => <li>{ datum }</i>) }</ol> ))
Из-за того, как работает композиция функций, мы можем сделать это даже немного более общим:
const SearchAndCatFilterable = compose( CategoryFilterable, SearchFilterable ) const SomeListComponent = SearchAndCatFilterable( (props = { data: [] }) => ( <ul>{ data.map((datum) => <li>{ datum }</i>) }</ul> ) ) const SomeOtherListComponent = SearchAndCatFilterable( (props = { data: [] }) => ( <ol>{ data.map((datum) => <li>{ datum }</i>) }</ol> ) )
Я думаю, это в пути. Но его все равно нет…
Кстати: меня попросили привести пример i18n… Я придумал https://gist.github.com/bradparker/11efef2fb9ac1a3b508e. Это не идеально (это очень просто), но, я думаю, иллюстрирует, что нет ничего невозможного по своей сути ни в одном из подходов к совместному использованию кода; нам просто нужно выбрать тот, который нам нравится больше всего. Или выбрать тот набор, который лучше всего решает наши конкретные задачи.
Давайте еще раз вернемся к первым двум вопросам. Устраняет ли это кровотечение поведения между миксинами и исходным компонентом? Устраняет ли это кровотечение управления состоянием?
В приведенных выше примерах один компонент-оболочка не может вызывать методы любого другого компонента, они могут взаимодействовать только через реквизиты. То же самое относится и к состоянию: ни один компонент не может напрямую обновлять состояние любого другого компонента. Тем не менее, они могут возиться с реквизитом, когда он передается по наследству. Об этом легче или сложнее рассуждать? Я не уверен.
Последний вопрос касается миксинов, которые «очень похожи» на определения компонентов. Во всех приведенных выше примерах они являются определениями компонентов. Их можно сделать с помощью createClass, как классы ES6, как функции, без разницы.
Здесь у меня нет ответа, но я надеюсь, что изучил некоторые варианты. Надеюсь, вам тоже понравилось читать.