Збереження чистоти компонентів

Деякі функції JavaScript є чистими. Чисті функції виконують лише обчислення і не більше. Пишучи свої компоненти лише як чисті функції, ви можете уникнути цілого класу незручних помилок і непередбачуваної поведінки в міру зростання вашої кодової бази. Однак, щоб отримати ці переваги, вам слід дотримуватися кількох правил.

  • Що таке чистота і як вона допомагає уникнути помилок
  • Як зберегти компоненти чистими, тримаючи зміни поза фазою рендерингу
  • Як використовувати суворий режим для пошуку помилок у ваших компонентах

Чистота: Компоненти у вигляді формул

У комп'ютерних науках (і особливо у світі функціонального програмування) чиста функція - це функція з такими характеристиками:

  • Вона займається власними справами. Вона не змінює жодних об'єктів або змінних, які існували до її виклику.
  • Ті самі входи, той самий вихід. При однакових входах чиста функція завжди повинна повертати однаковий результат.

Ви, можливо, вже знайомі з одним прикладом чистих функцій: формули у математиці.

Погляньте на цю математичну формулу: y = 2x .

Якщо x = 2 потім y = 4 . Завжди.

.

Якщо x = 3. потім y = 6 . Завжди.

.

Якщо x = 3. , y іноді не буде 9. або -1 або 2.5 залежно від часу доби або стану фондового ринку.

Якщо y. = 2x та x = 3. , y завжди буде завжди буде 6 .

Якби ми зробили це функцією JavaScript, це виглядало б так:

function double(number) {
  return 2 * number;
}

У наведеному вище прикладі double є чистою функцією. Якщо ви передасте їй 3, вона поверне 6. Завжди.

React розроблений навколо цієї концепції. React припускає, що кожен компонент, який ви пишете, є чистою функцією. Це означає, що React-компоненти, які ви пишете, повинні завжди повертати однаковий JSX при однакових вхідних даних:

function Recipe({ drinkers }) {
  return (
    <ol>    
      <li>Boil {drinkers} cups of water.</li>
      <li>Add {drinkers} spoons of tea and {0.5 * drinkers} spoons of spice.</li>
      <li>Add {0.5 * drinkers} cups of milk to boil and sugar to taste.</li>
    </ol>
  );
}

export default function App() {
  return (
    <section>
      <h1>Spiced Chai Recipe</h1>
      <h2>For two</h2>
      <Recipe drinkers={2} />
      <h2>For a gathering</h2>
      <Recipe drinkers={4} />
    </section>
  );
}

Якщо ви передасте drinkers={2} до Recipe, він поверне JSX, що містить 2 cups of water. Завжди.

Якщо ви передасте drinkers={4}, він поверне JSX, що містить 4 cups of water. Завжди.

Як математична формула.

Ви можете уявити ваші компоненти як рецепти: якщо ви будете дотримуватися їх і не додавати нові інгредієнти в процесі приготування, ви отримаєте ту саму страву щоразу. Ця "страва" - це JSX, який компонент подає для реагування на render.

.

Побічні ефекти: (не)передбачувані наслідки

Процес рендерингу React завжди має бути чистим. Компоненти повинні лише повертати свій JSX, а не змінювати будь-які об'єкти чи змінні, що існували до рендерингу - це зробить їх нечистими!

Ось компонент, який порушує це правило:

let guest = 0;

function Cup() {
  // Bad: changing a preexisting variable!
  guest = guest + 1;
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup />
      <Cup />
      <Cup />
    </>
  );
}

Цей компонент читає і записує змінну guest, оголошену поза ним. Це означає, що виклик цього компонента декілька разів призведе до різних JSX! І більше того, якщо інші компоненти читатимуть guest, вони також видаватимуть різні JSX, залежно від того, коли їх було відрендерено! Це не передбачувано.

Повертаючись до нашої формули y = 2x , тепер навіть якщо x = 2 , ми не можемо вважати, що y = 4 . Наші тести можуть провалитися, користувачі будуть спантеличені, літаки падатимуть з неба - ви бачите, як це може призвести до заплутаних помилок!

.

Ви можете виправити цей компонент, передавши guest як проп замість:

function Cup({ guest }) {
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup guest={1} />
      <Cup guest={2} />
      <Cup guest={3} />
    </>
  );
}

Тепер ваш компонент чистий, оскільки JSX, який він повертає, залежить лише від пропу guest.

Загалом, вам не слід очікувати, що ваші компоненти буде відрендерено у певному порядку. Не має значення, якщо ви викликаєте y = 2x до або після y. = 5x : обидві формули рендеритимуться незалежно одна від одної. Так само кожен компонент під час рендерингу повинен "думати сам за себе", а не намагатися координувати свої дії з іншими або залежати від них. Рендеринг схожий на шкільний іспит: кожен компонент повинен обчислювати JSX самостійно!

Виявлення нечистих обчислень за допомогою StrictMode

Хоча ви, можливо, ще не використовували їх усі, в React є три типи вхідних даних, які ви можете читати під час рендерингу: пропси, стан та контекст. Ви завжди повинні розглядати ці вхідні дані як доступні лише для читання.

Коли ви хочете змінити щось у відповідь на введення користувача, вам слід встановити стан замість запису у змінну. Вам ніколи не слід змінювати попередні змінні або об'єкти під час рендерингу вашого компонента.

React пропонує "Суворий режим", в якому він викликає функцію кожного компонента двічі під час розробки. Двічі викликаючи функції компонента, суворий режим допомагає знайти компоненти, які порушують ці правила.

Помітьте, як оригінальний приклад відображав "Гість #2", "Гість #4" та "Гість #6" замість "Гість #1", "Гість #2" та "Гість #3". Початкова функція була нечистою, тому її виклик двічі порушував її роботу. Але виправлена чиста версія працює, навіть якщо функцію викликати двічі. Чисті функції лише обчислюють, тому їх подвійний виклик нічого не змінить - так само, як подвійний виклик double(2) не змінює значення, що повертається, і розв'язання y = 2x двічі не змінює того, що y це. Ті самі входи, ті самі виходи. Завжди.

Суворий режим не впливає на виробництво, тому він не сповільнить роботу програми для ваших користувачів. Щоб увімкнути суворий режим, ви можете обгорнути ваш кореневий компонент у <React.StrictMode>. Деякі фреймворки роблять це за замовчуванням.

Локальна мутація: Маленький секрет вашого компонента

У наведеному вище прикладі проблема полягала у тому, що компонент змінив змінну , яка існувала раніше , під час рендерингу. Це часто називають "мутацією", щоб звучало трохи страшніше. Чисті функції не змінюють змінні за межами області видимості функції або об'єкти, створені до виклику - це робить їх нечистими!

Втім, цілком нормально змінювати змінні та об'єкти, які ви щойно створили під час рендерингу. У цьому прикладі ви створюєте масив [], присвоюєте його змінній cups, а потім заштовхуєте до нього дюжину горнят:

function Cup({ guest }) {
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaGathering() {
  let cups = [];
  for (let i = 1; i <= 12; i++) {
    cups.push(<Cup key={i} guest={i} />);
  }
  return cups;
}

Якби змінна cups або масив [] були створені поза функцією TeaGathering, це було б великою проблемою! Ви б змінили об'єкт , що існував раніше , виштовхуючи елементи у цей масив.

Втім, це нормально, оскільки ви створили їх під час того ж рендеру, всередині TeaGathering. Жоден код за межами TeaGathering ніколи не дізнається, що це сталося. Це називається "локальною мутацією" - це як маленький секрет вашого компонента.

Де ви можете викликати побічні ефекти

Хоча функціональне програмування значною мірою покладається на чистоту, у певний момент, десь, щось має змінитися. У цьому і є сенс програмування! Ці зміни - оновлення екрану, запуск анімації, зміна даних - називаються побічними ефектами. Це те, що відбувається "збоку", а не під час візуалізації.

У React побічні ефекти зазвичай знаходяться всередині обробників подій. Обробники подій - це функції, які React запускає, коли ви виконуєте певну дію - наприклад, коли ви натискаєте кнопку. Навіть якщо обробники подій визначені всередині вашого компонента, вони не запускаються під часрендерингу! Тому обробники подій не повинні бути чистими.

Якщо ви вичерпали всі інші варіанти і не можете знайти потрібний обробник події для вашого побічного ефекту, ви все ще можете приєднати його до повернутого JSX за допомогою виклику useEffect у вашому компоненті. Це скаже React виконати його пізніше, після рендерингу, коли побічні ефекти будуть дозволені. Втім, цей підхід має бути вашим останнім засобом.

За можливості намагайтеся виражати свою логіку лише за допомогою рендерингу. Ви будете здивовані, як далеко це може вас завести!

Чому React дбає про чистоту?

Написання чистих функцій потребує певної практики та дисципліни. Але це також відкриває чудові можливості:

  • Ваші компоненти можуть працювати в іншому середовищі - наприклад, на сервері! Оскільки вони повертають однаковий результат на однакові вхідні дані, один компонент може обслуговувати багато запитів користувачів.
  • Ви можете покращити продуктивність, пропускаючи рендеринг компонентів, вхідні дані яких не змінилися. Це безпечно, оскільки чисті функції завжди повертають однакові результати, тому їх можна кешувати.
  • Якщо деякі дані змінюються посеред рендерингу глибокого дерева компонентів, React може перезапустити рендеринг, не витрачаючи час на завершення застарілого рендерингу. Чистота дозволяє безпечно зупинити обчислення в будь-який момент.

Кожна нова функція React, яку ми створюємо, використовує переваги чистоти. Від отримання даних до анімації та продуктивності, підтримка чистоти компонентів розкриває силу парадигми React.

  • Компонент має бути чистим, тобто:
    • Займається своїми справами. Не повинен змінювати об'єкти або змінні, які існували до рендерингу.
    • Ті самі входи, той самий вихід. При однакових входах компонент завжди має повертати однаковий JSX.
  • Рендеринг може відбутися у будь-який момент, тому компоненти не повинні залежати від послідовності рендерингу один одного.
  • Вам не слід змінювати жодних вхідних даних, які ваші компоненти використовують для рендерингу. Це стосується пропсів, стану та контексту. Щоб оновити екран, "встановіть" стан замість того, щоб мутувати вже існуючі об'єкти.
  • Намагайтеся виражати логіку вашого компонента у JSX, який ви повертаєте. Коли вам потрібно "змінити щось", ви зазвичай хочете зробити це в обробнику події. В крайньому випадку, ви можете useEffect.
  • Написання чистих функцій потребує трохи практики, але це відкриває можливості парадигми React.

Виправити непрацюючий годинник

Цей компонент намагається встановити клас CSS <h1> на "night" у період з опівночі до шостої години ранку, і на "day" в інший час. Однак це не працює. Чи можете ви виправити цей компонент?

Ви можете перевірити, чи працює ваш розв'язок, тимчасово змінивши часовий пояс комп'ютера. Коли поточний час між північчю та шостою ранку, годинник повинен мати інвертовані кольори!

Рендерінг - це обчислення, він не повинен намагатися "робити" щось. Чи можете ви висловити ту саму ідею інакше?

export default function Clock({ time }) {
  let hours = time.getHours();
  if (hours >= 0 && hours <= 6) {
    document.getElementById('time').className = 'night';
  } else {
    document.getElementById('time').className = 'day';
  }
  return (
    <h1 id="time">
      {time.toLocaleTimeString()}
    </h1>
  );
}
import { useState, useEffect } from 'react';
import Clock from './Clock.js';

function useTime() {
  const [time, setTime] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return time;
}

export default function App() {
  const time = useTime();
  return (
    <Clock time={time} />
  );
}
body > * {
  width: 100%;
  height: 100%;
}
.day {
  background: #fff;
  color: #222;
}
.night {
  background: #222;
  color: #fff;
}

Ви можете виправити цей компонент, обчисливши className і включивши його у виведення рендеру:

export default function Clock({ time }) {
  let hours = time.getHours();
  let className;
  if (hours >= 0 && hours <= 6) {
    className = 'night';
  } else {
    className = 'day';
  }
  return (
    <h1 className={className}>
      {time.toLocaleTimeString()}
    </h1>
  );
}
import { useState, useEffect } from 'react';
import Clock from './Clock.js';

function useTime() {
  const [time, setTime] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return time;
}

export default function App() {
  const time = useTime();
  return (
    <Clock time={time} />
  );
}
body > * {
  width: 100%;
  height: 100%;
}
.day {
  background: #fff;
  color: #222;
}
.night {
  background: #222;
  color: #fff;
}

У цьому прикладі побічний ефект (модифікація DOM) взагалі не був потрібен. Потрібно було лише повернути JSX.

.

Виправити несправний профіль

Два компоненти Profile рендеряться поруч з різними даними. Натисніть "Згорнути" у першому профілі, а потім "Розгорнути". Ви помітите, що обидва профілі тепер показують одну і ту ж особу. Це помилка.

Знайдіть причину помилки та виправте її.

Вразливий код знаходиться у Profile.js. Переконайтеся, що ви прочитали все зверху донизу!

.
import Panel from './Panel.js';
import { getImageUrl } from './utils.js';

let currentPerson;

export default function Profile({ person }) {
  currentPerson = person;
  return (
    <Panel>
      <Header />
      <Avatar />
    </Panel>
  )
}

function Header() {
  return <h1>{currentPerson.name}</h1>;
}

function Avatar() {
  return (
    <img
      className="avatar"
      src={getImageUrl(currentPerson)}
      alt={currentPerson.name}
      width={50}
      height={50}
    />
  );
}
import { useState } from 'react';

export default function Panel({ children }) {
  const [open, setOpen] = useState(true);
  return (
    <section className="panel">
      <button onClick={() => setOpen(!open)}>
        {open ? 'Collapse' : 'Expand'}
      </button>
      {open && children}
    </section>
  );
}
import Profile from './Profile.js';

export default function App() {
  return (
    <>
      <Profile person={{
        imageId: 'lrWQx8l',
        name: 'Subrahmanyan Chandrasekhar',
      }} />
      <Profile person={{
        imageId: 'MK3eW3A',
        name: 'Creola Katherine Johnson',
      }} />
    </>
  )
}
export function getImageUrl(person, size = 's') {
  return (
    'https://i.imgur.com/' +
    person.imageId +
    size +
    '.jpg'
  );
}
.avatar { margin: 5px; border-radius: 50%; }
.panel {
  border: 1px solid #aaa;
  border-radius: 6px;
  margin-top: 20px;
  padding: 10px;
  width: 200px;
}
h1 { margin: 5px; font-size: 18px; }

Проблема у тому, що компонент Profile записує до змінної з назвою currentPerson, а компоненти Header та Avatar читають з неї. Це робить усі три компоненти нечистими і важко передбачуваними.

Щоб виправити помилку, видаліть змінну currentPerson. Замість цього передайте всю інформацію з Profile до Header та Avatar через пропси. Вам потрібно буде додати проп person до обох компонентів і передати його вниз.

import Panel from './Panel.js';
import { getImageUrl } from './utils.js';

export default function Profile({ person }) {
  return (
    <Panel>
      <Header person={person} />
      <Avatar person={person} />
    </Panel>
  )
}

function Header({ person }) {
  return <h1>{person.name}</h1>;
}

function Avatar({ person }) {
  return (
    <img
      className="avatar"
      src={getImageUrl(person)}
      alt={person.name}
      width={50}
      height={50}
    />
  );
}
import { useState } from 'react';

export default function Panel({ children }) {
  const [open, setOpen] = useState(true);
  return (
    <section className="panel">
      <button onClick={() => setOpen(!open)}>
        {open ? 'Collapse' : 'Expand'}
      </button>
      {open && children}
    </section>
  );
}
import Profile from './Profile.js';

export default function App() {
  return (
    <>
      <Profile person={{
        imageId: 'lrWQx8l',
        name: 'Subrahmanyan Chandrasekhar',
      }} />
      <Profile person={{
        imageId: 'MK3eW3A',
        name: 'Creola Katherine Johnson',
      }} />
    </>
  );
}
export function getImageUrl(person, size = 's') {
  return (
    'https://i.imgur.com/' +
    person.imageId +
    size +
    '.jpg'
  );
}
.avatar { margin: 5px; border-radius: 50%; }
.panel {
  border: 1px solid #aaa;
  border-radius: 6px;
  margin-top: 20px;
  padding: 10px;
  width: 200px;
}
h1 { margin: 5px; font-size: 18px; }

Пам'ятайте, що React не гарантує, що функції компонентів будуть виконуватися в певному порядку, тому ви не можете взаємодіяти між ними, встановлюючи змінні. Вся взаємодія повинна відбуватися через пропси.

Виправлено непрацюючий лоток історії

Генеральний директор вашої компанії просить вас додати "історії" до вашого застосунку для онлайн-годинника, і ви не можете відмовити. Ви написали компонент StoryTray, який приймає список історій, за яким слідує заповнювач "Створити історію".

Ви реалізували заповнювач "Створити історію", вставивши ще одну фейкову історію в кінець масиву stories, який ви отримали як проп. Але чомусь "Створити історію" з'являється більше одного разу. Виправте цю проблему.

export default function StoryTray({ stories }) {
  stories.push({
    id: 'create',
    label: 'Create Story'
  });

  return (
    <ul>
      {stories.map(story => (
        <li key={story.id}>
          {story.label}
        </li>
      ))}
    </ul>
  );
}
import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';

let initialStories = [
  {id: 0, label: "Ankit's Story" },
  {id: 1, label: "Taylor's Story" },
];

export default function App() {
  let [stories, setStories] = useState([...initialStories])
  let time = useTime();

  // HACK: Prevent the memory from growing forever while you read docs.
  // We're breaking our own rules here.
  if (stories.length > 100) {
    stories.length = 100;
  }

  return (
    <div
      style={{
        width: '100%',
        height: '100%',
        textAlign: 'center',
      }}
    >
      <h2>It is {time.toLocaleTimeString()} now.</h2>
      <StoryTray stories={stories} />
    </div>
  );
}

function useTime() {
  const [time, setTime] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return time;
}
ul {
  margin: 0;
  list-style-type: none;
}

li {
  border: 1px solid #aaa;
  border-radius: 6px;
  float: left;
  margin: 5px;
  margin-bottom: 20px;
  padding: 5px;
  width: 70px;
  height: 100px;
}
{
  "hardReloadOnChange": true
}

Помітьте, що кожного разу, коли годинник оновлюється, "Створити історію" додається двічі. Це натяк на те, що під час рендерингу відбувається мутація - суворий режим викликає компоненти двічі, щоб зробити ці проблеми більш помітними.

StoryTray функція не є чистою. Викликаючи push на отриманому масиві stories (проп!), вона мутує об'єкт, який було створено до початку рендерингу StoryTray. Це робить його баговим і дуже важко передбачуваним.

Найпростіше рішення - не торкатися масиву взагалі, а рендерити "Створити історію" окремо:

export default function StoryTray({ stories }) {
  return (
    <ul>
      {stories.map(story => (
        <li key={story.id}>
          {story.label}
        </li>
      ))}
      <li>Create Story</li>
    </ul>
  );
}
import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';

let initialStories = [
  {id: 0, label: "Ankit's Story" },
  {id: 1, label: "Taylor's Story" },
];

export default function App() {
  let [stories, setStories] = useState([...initialStories])
  let time = useTime();

  // HACK: Prevent the memory from growing forever while you read docs.
  // We're breaking our own rules here.
  if (stories.length > 100) {
    stories.length = 100;
  }

  return (
    <div
      style={{
        width: '100%',
        height: '100%',
        textAlign: 'center',
      }}
    >
      <h2>It is {time.toLocaleTimeString()} now.</h2>
      <StoryTray stories={stories} />
    </div>
  );
}

function useTime() {
  const [time, setTime] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return time;
}
ul {
  margin: 0;
  list-style-type: none;
}

li {
  border: 1px solid #aaa;
  border-radius: 6px;
  float: left;
  margin: 5px;
  margin-bottom: 20px;
  padding: 5px;
  width: 70px;
  height: 100px;
}

Альтернативно, ви можете створити новий масив (шляхом копіювання існуючого) перед тим, як додати до нього елемент:

export default function StoryTray({ stories }) {
  // Copy the array!
  let storiesToDisplay = stories.slice();

  // Does not affect the original array:
  storiesToDisplay.push({
    id: 'create',
    label: 'Create Story'
  });

  return (
    <ul>
      {storiesToDisplay.map(story => (
        <li key={story.id}>
          {story.label}
        </li>
      ))}
    </ul>
  );
}
import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';

let initialStories = [
  {id: 0, label: "Ankit's Story" },
  {id: 1, label: "Taylor's Story" },
];

export default function App() {
  let [stories, setStories] = useState([...initialStories])
  let time = useTime();

  // HACK: Prevent the memory from growing forever while you read docs.
  // We're breaking our own rules here.
  if (stories.length > 100) {
    stories.length = 100;
  }

  return (
    <div
      style={{
        width: '100%',
        height: '100%',
        textAlign: 'center',
      }}
    >
      <h2>It is {time.toLocaleTimeString()} now.</h2>
      <StoryTray stories={stories} />
    </div>
  );
}

function useTime() {
  const [time, setTime] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return time;
}
ul {
  margin: 0;
  list-style-type: none;
}

li {
  border: 1px solid #aaa;
  border-radius: 6px;
  float: left;
  margin: 5px;
  margin-bottom: 20px;
  padding: 5px;
  width: 70px;
  height: 100px;
}

Це збереже вашу мутацію локальною, а функцію рендерингу чистою. Втім, вам все одно слід бути обережними: наприклад, якщо ви спробуєте змінити будь-який з існуючих елементів масиву, вам доведеться клонувати і ці елементи також.

Корисно запам'ятати, які операції над масивами мутують їх, а які ні. Наприклад, push, pop, reverse і sort мутують вихідний масив, а slice, filter і map створять новий.