Обуздайте большие проекты на React, последовательно применяя эти шаблоны и правила

Уход и обращение с чистыми и простыми компонентами по сравнению с дисплеями и самоуправляемыми компонентами

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

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

Типы компонентов

Компоненты React могут получать данные из нескольких мест:

  1. Компонент получает все свои данные от props, или
  2. Он получает некоторые данные из 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. Фактически, я утверждаю, что вам просто не следует беспокоиться о запоминании компонента, если какой-либо из его свойств не является логическим, строковым или числовым.

Я применяю следующие категории: Компонент

  1. simple’, если это мемоизированный чистый компонент,
  2. display’, если он чистый, но не мемоизированный, в противном случае
  3. он «самоуправляемый», если он получает данные не из своего 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 файл, даже для тривиальных компонентов.

Типы компонентов

  1. simple’, если это мемоизированный чистый компонент,
  2. display’, если он чистый, но не мемоизированный, в противном случае
  3. он «самоуправляемый», если он получает данные не из своего props.

Компонентные правила.

  1. Избегайте экспорта более одного компонента из одного файла¹
  2. Все логические props должны быть необязательными и по умолчанию равны false.
  3. Все данные, которые не указаны как prop, должны поступать от ловушки.
  4. Если он сравнивает даты или время, всегда предоставляйте date или time как строку Zulu Time и всегда предоставляйте значение для today для сравнения.
  5. Значение по умолчанию для необязательной строки prop всегда должно быть undefined.

Ссылки

¹ Исключением из этого правила является группировка компонентов в components/index.js для использования такого инструмента, как Rollup, при создании пакета.

Нравится, но не подписчик? Вы можете поддержать автора, присоединившись через davesag.medium.com.