Обуздайте большие проекты на React, последовательно применяя эти шаблоны и правила
Уход и обращение с чистыми и простыми компонентами по сравнению с дисплеями и самоуправляемыми компонентами
Недавно я работал в команде над проектом, создав библиотеку компонентов React для использования в ряде связанных корпоративных сред. В рамках этого процесса мы обнаружили, что имеет смысл классифицировать компоненты с точки зрения того, откуда они берут данные.
Мы также разработали ряд правил, которые помогают нам поддерживать согласованность и предсказуемость компонентов.
Типы компонентов
Компоненты React могут получать данные из нескольких мест:
- Компонент получает все свои данные от
props
, или - Он получает некоторые данные из
props
, а некоторые из других источников (context
,state
,app-state
и т. Д.)
Сужая это дальше, мы обнаруживаем, что в наборе компонентов, которые получают свои данные только от props
, некоторые из них могут рассматриваться как чистые компоненты, но у нас также есть компоненты memoisable и не запоминаемые компоненты, и не все запоминаемые компоненты обязательно чистые.
Чистые компоненты
Чистый компонент - это компонент, который всегда отображает один и тот же вывод, если ему предоставлен один и тот же props
. Чистые компоненты не извлекают данные откуда угодно, кроме их props
, они не управляют никакими внутренними state
, и, если props
просты, компонент можно запоминать в убедитесь, что он будет повторно отображаться только в случае props
изменения. Чистые компоненты не имеют побочных эффектов.
Например:
import { memo } from 'react' import { string } from 'prop-types' const Wylde = ({ city }) => ( <div> As pure as {city} snow </div> ) Wylde.propTypes = { city: string.isRequired } export default memo(Wylde)
Что я имею в виду под «простым»?
Функция React memo
принимает чистый компонент в качестве входных данных и возвращает компонент, который кэширован против props
, предотвращая его повторную визуализацию, если предоставленные значения prop
не изменились. с момента последнего рендера. Это хорошая оптимизация, но ее часто используют неправильно.
Проблема с memo
в том, что по умолчанию props
сравниваются с использованием простого ===
равенства. Итак, если ваш prop
является функцией, массивом, объектом или чем-то еще, что не сопоставимо с ===
, тогда memo
все равно разрешит повторный рендеринг компонента.
Добавление примесей
Вы можете предоставить второй аргумент, функцию arePropsEqual
, функции memo
, чтобы переопределить это поведение по умолчанию, но если ваша логика сравнения более сложна, чем ваш код рендеринга, это просто пустая трата времени. Если ваша arePropsEqual
функция игнорирует любые props
, значит, ваш компонент больше не чистый.
В качестве примера возьмем следующий компонент:
import { memo } from 'react' import { string, func } from 'prop-types' const Clicky = ({ label, onClick }) => ( <button onClick={onClick}>{label}</button> ) Clicky.propTypes = { label: string.isRequired, onClick: func.isRequired } const arePropsEqual = (prev, next) => prev.label === next.label export default memo(Clicky, arePropsEqual)
Это будет повторно визуализироваться только в случае изменения label
и игнорировать любые различия в функции onClick
. Это может иметь непредвиденные последствия. Это уже не чистый компонент, потому что, если вы предоставите другую onClick
функцию, вы все равно получите старую, переданную в button
, поскольку компонент не будет повторно визуализироваться.
Точно так же не имеет смысла memoise компонент, который принимает массив, функцию или объект в качестве опоры.
import { memo } from 'react' import { arrayOf, string } from 'prop-types' const Listy = ({ items }) => ( <ul> {items.map(item => ( <li key={item}>{item}</li> )} </ul> ) Listy.propTypes = { items: arrayOf(string).isRequired } const arePropsEqual = (prev, next) => prev.items.reduce((acc, elem, i) => prev.items[i] === next.items[i] ? acc : [...acc, elem], []).length === 0 export default memo(Listy, arePropsEqual)
Если список длиннее нескольких элементов, то компонент Listy
, вероятно, потратит больше времени на сравнение массивов, чем на повторную визуализацию списка. Однако это все еще чистый компонент; просто неэффективный. Списки объектов longs не следует передавать в функцию memoized. Фактически, я утверждаю, что вам просто не следует беспокоиться о запоминании компонента, если какой-либо из его свойств не является логическим, строковым или числовым.
Я применяю следующие категории: Компонент
- ‘simple’, если это мемоизированный чистый компонент,
- ‘display’, если он чистый, но не мемоизированный, в противном случае
- он «самоуправляемый», если он получает данные не из своего
props
.
Компонентные файловые структуры
При создании библиотеки компонентов важно последовательно сгруппировать составные части компонентов. Особенности этой библиотеки зависят от того, является ли компонент простым, отображаемым или самоуправляемым.
Простые компоненты
components/Simple/ index.js index.spec.js Simple.js Simple.spec.js Simple.stories.js config.js utils.js utils.spec.js
index.js
файл простого компонента будет
export { default } from './Simple'
Правило: Избегайте экспорта более одного компонента из одного файла¹.
Файл index.spec.js
проверит, что базовый компонент экспортируется.
import Index from '.' import Simple from './Simple' describe('components/Simple', () => { it('is the same component', () => { expect(Index).toBe(Simple) }) })
Фактический Simple.js
должен быть чистым, мемоизированным компонентом с простым props
.
import { memo } from 'react' import { bool, number, string } from 'prop-types' const Simple = ({ small, count, label }) => small ? ( <div><span>{count}</span></div> ) : ( <div> <label>{label}</label> <span>{count}</span> </div> ) Simple.propTypes = { small: bool, count: number.isRequired, label: string.isRequired } Simple.defaultProps = { small: false } export default memo(Simple)
Обратите внимание, что я по умолчанию small
на false
выше. Причина этого - правило, которое я всегда применяю, которое просто сформулировано как:
Правило: все
boolean
реквизиты должны быть необязательными и по умолчанию равныfalse
.
Это позволяет вам использовать такой компонент, как
<Simple count={c} label={l} />
если вы хотите, чтобы отображались count
и label
, или
<Simple small count={c} label={l} />
если вы хотите просто показать count
.
Simple.spec.js
просто проверяет, что при разумном диапазоне входных данных он будет отображать допустимые выходные данные.
import { render } from '@testing-library/react' import Simple from './Simple' const label = 'some test label' describe.each` small | count ${undefined} | ${0} ${false} | ${1} ${true} | ${2} `('components/Simple/Simple', ({ small, count }) => { let component beforeAll(() => { component = render(<Simple small={small} count={count} label={label} />) }) it('rendered okay', () => { expect(component.toFragment()).toMatchSnapshot() }) })
Simple.stories.js
предоставляет истории из Сборника рассказов, которые демонстрируют компонент в отдельности. Обычно это используется как для документации компонентов, так и для пользовательского приемочного тестирования компонентов по мере их разработки и сопровождения.
Файл config.js
является необязательным и используется для хранения любой конфигурации конкретного компонента, аналогично utils.js
является необязательным и используется для хранения любых чистых функций, которые могут понадобиться компоненту. Простые компоненты не будут иметь hooks
. Storybook stories
компонента, скорее всего, также получит доступ к config
и utils
.
Компоненты дисплея
Структура папок для компонента display по существу такая же, как для простого компонента, только он может включать необязательный файл shapes.js
.
components/Display/ index.js index.spec.js Display.js Display.spec.js Display.stories.js config.js shapes.js utils.js utils.spec.js
В нашем shapes.js
мы просто экспортируем форму отображаемых объектов.
import { string } from 'prop-types' export const thingShape = { id: string.isRequired, text: string.isRequired, title: string.isRequired }
В этом случае Display.js
может быть что-то вроде
import { Fragment } from 'react' import { arrayOf, func, shape } from 'prop-types' import { thingShape } from './shapes' const Display = ({ things, handleClick }) => ( <dl> {things.map(({ title, text, id }) => ( <Fragment key={id}> <dt> <button type="text" onClick={handleClick(id)} data-testid={id} > {title} </button> </dt> <dd>{text}</dd> </Fragment> )} </dl> ) Display.propTypes = { things: arrayOf(shape(thingShape)).isRequired, handleClick: func.isRequired // id => evt => { } } export default Display
Хотя это все еще чистый компонент, его нелегко запоминать из-за того, что он предоставляет массив объектов и функцию как props
.
Как и раньше, мы протестируем это как Display.spec.js
, например:
import { render, screen, fireEvent } from '@testing-library/react' import Display from './Display' const makeThing = (id, title, text) = ({ id, title, text }) const theThings = [ makeThing('1', 'One', 'This is the one'), makeThing('2', 'Two', 'This is number two'), makeThing('3', 'Three', 'The magic number') ] describe.each` things ${[]} ${theThings} `('components/Display/Display', ({ things }) => { const onClick = jest.fn() const handleClick = jest.fn().mockReturnValue(onClick) let component beforeAll(() => { component = render(<Display things={things} handleClick={handleClick} />) const firstButton = screen.getByTestId('1') fireEvent.click(firstButton) }) it('used the click handler', () => { expect(handleClick).toHaveBeenCalled() }) it('rendered okay', () => { expect(component.toFragment()).toMatchSnapshot() }) it('clicking worked as expected', () => { expect(onClick).toHaveBeenCalled() }) })
Опять же, компоненты display являются чистыми компонентами и поэтому не будут иметь никаких перехватчиков, но они могут ссылаться на чистые служебные функции и неизменяемые данные конфигурации. . Компоненты Display будут иметь истории Storybook, которые могут ссылаться на локальные utils
или config
, а локальные utils
- это просто чистые функции, которые легко протестировать.
Самоуправляемые компоненты
Последний класс компонентов не является чистым компонентом. Они могут отслеживать какое-то внутреннее состояние или могут вводить состояние из состояния внешнего приложения (например, из хранилища Redux или контекста React). Они могут использовать хуки вроде useEffect
, которые позволяют компоненту вызывать побочные эффекты при их установке и демонтаже.
components/SelfManaged/ index.js index.spec.js index.stories.js SelfManaged.js SelfManaged.spec.js SelfManaged.stories.js config.js hooks.js hooks.spec.js shapes.js utils.js utils.spec.js wrappers.js
В самоуправляемых компонентах лучше всего разделить их на бит, который выполняет взаимодействие с внешними данными, который затем передает эти данные в чистый компонент. В нашем случае представим, что у нас есть компонент, которому передается id
из Thing
, и при установке он загружает Thing
(с помощью ловушки), передавая загруженные данные в чистый SelfManaged.js
.
Это подводит меня к следующему правилу.
Правило: все данные, которые не указаны как
prop
, должны поступать от ловушки.
Следуя этому правилу, вы сохраняете простоту самого компонента и вытесняете любую сложность из поля зрения. Причина, по которой мы это делаем, заключается в том, что очень часто разработчики, которые работают над компонентами, действительно не хотят думать о том, откуда берутся данные. См. Мою статью Уровни внешнего интерфейса, чтобы узнать об этом подробнее.
Итак, наш index.js
будет примерно таким:
import { string } from 'prop-types' import { useThing } from './hooks' import PureSelfManaged from './SelfManaged' const SelfManaged = ({ id }) => { const thing = useThing(id) return thing ? <PureSelfManaged {...thing} /> : null } SelfManaged.propTypes = { id: string.isRequired } export default SelfManaged
Мы отложим любые подробности того, как useThing
может работать, но предположим, что внутри он загружает некоторые данные из API или что-то асинхронное, поэтому изначально значение thing
равно undefined
или null
, пока оно не будет заполнено фактическими thing
данными.
Вы заметите, что мы передаем thing
данные в PureSelfManaged
компонент как его фактические props
, а не как thing={thing}
. Причина этому двоякая. Во-первых, если свойства thing
просты, мы можем запоминать SelfManaged.js
, а, во-вторых, когда мы составляем истории для SelfManged.stories.js
, мы можем описывать отдельных props
с их собственными элементами управления, а не просто иметь один объект опора
Итак, повторно используя пример shape.js
из компонента отображения выше, мы могли бы определить SelfManaged.js
как:
import { memo } from 'react' import { thingShape } from './shapes' const SelfManaged = ({ id, title, text }) => ( <> <dt> {title} <tt>{id}</tt> </dt> <dd>{text}</dd> </> ) SelfManaged.propTypes = { ...thingShape } export default memo(SelfManaged)
The index.stories.js
, скорее всего, будет использовать загрузчики и декораторы Storybook для предварительной загрузки данных и обертывания компонента в любых контекстных провайдерах, которые могут потребоваться для правильной работы хука useThing
, а также оболочку, заключающую компонент во внешние теги (скажем, в данном случае <dl>…</dl>
), которые компонент может ожидать. SelfManaged.stories.js
предоставит элементы управления для каждого свойства thing
и такую же оболочку. Чтобы оставаться красивым и СУХИМ, вы можете поместить общую оболочку в общий wrappers.js
.
Загрязняющие чистые компоненты
Чистые компоненты становятся нечистыми, если они используют самоуправляемые компоненты внутри, и хотя это часто неизбежно, лучше признать это и рассматривать такие компоненты как самоуправляемые компоненты, особенно при написании для них тестов и рассказов.
Компоненты, чувствительные к дате и времени
Представьте себе компонент, который выделяет date
, потому что он просрочен. Чтобы проверить это, вам необходимо указать значение для today
, иначе ваши тесты моментальных снимков не пройдут, как только дата изменится.
import { string } from 'prop-types' import { useDates } from 'hooks/dates' // omitted for brevity import Date from 'components/Date' // assume this exists const Due = ({ date, today }) => ( const { isBefore } = useDates(today) return <Date date={date} highlight={!isBefore(date)} /> } Due.propTypes = { date: string.isRequired, // Zulu Time string today: string // only supplied in tests } Due.defaultProps = { today: undefined } export default Due
На первый взгляд это выглядит мемоизируемым, но это не так, потому что значение today
предоставляется только в тестах и обычно равно undefined
, что означает, что по умолчанию будет использоваться все, что возвращает new Date()
во время вызова ловушки useDates
. , предполагая, что хук useDates
выглядит примерно так:
const toDate = d => d ? typeof d === 'string' ? new Date(d) : d : new Date() export const useDates = (tDay) => { const today = toDate(tDay) const isBefore = d => toDate(d).getTime() < today.getTime() return { isBefore } }
Итак, еще одно из моих правил
Правило: если он сравнивает дату или время, всегда предоставляйте
date
илиtime
как строку Zulu Time и всегда предоставляйте значение дляtoday
для сравнения.
Когда строки необязательны
Представьте себе простой Button
компонент, который принимает необязательный label
.
import { func, string } from 'prop-types' const Button = ({ label, onClick }) => ( <button type="button" onClick={onClick}>{label}</button> ) Button.propTypes = { label: string, onClick: func.isRequired } Button.defaultProps = { label: undefined } export default Button
Заманчиво сделать метку по умолчанию равной null или ‘’, но это может привести к ошибкам, особенно если вы передаете реквизиты глубже в дерево компонентов.
Правило: значение по умолчанию для необязательной строки
prop
всегда должно бытьundefined
.
В заключении
В небольших проектах вы можете укладывать компоненты в отдельные файлы, пропускать тесты, не документировать компоненты в сборнике рассказов и загромождать компоненты различными хуками, утилитами и конфигурациями, но по мере развития вашего проекта вы пожалеете об этом.
Гораздо лучше практиковаться так, как вы хотите играть, и с самого начала структурировать ваши компоненты последовательным и структурированным образом. Это означает папку для каждого компонента, как index.js
, так и Component.js
файл, даже для тривиальных компонентов.
Типы компонентов
- ‘simple’, если это мемоизированный чистый компонент,
- ‘display’, если он чистый, но не мемоизированный, в противном случае
- он «самоуправляемый», если он получает данные не из своего
props
.
Компонентные правила.
- Избегайте экспорта более одного компонента из одного файла¹
- Все логические
props
должны быть необязательными и по умолчанию равныfalse
. - Все данные, которые не указаны как
prop
, должны поступать от ловушки. - Если он сравнивает даты или время, всегда предоставляйте
date
илиtime
как строку Zulu Time и всегда предоставляйте значение дляtoday
для сравнения. - Значение по умолчанию для необязательной строки
prop
всегда должно бытьundefined
.
⚡
Ссылки
- Https://reactjs.org/docs/react-api.html#reactmemo
- Https://storybook.js.org
- Https://itnext.io/what-are-front-end-service-layers-4dba95db21bb
- Https://www.utctime.net/z-time-now
¹ Исключением из этого правила является группировка компонентов в components/index.js
для использования такого инструмента, как Rollup, при создании пакета.
—
Нравится, но не подписчик? Вы можете поддержать автора, присоединившись через davesag.medium.com.