Автор Рубен Мартинес-младший

К настоящему моменту, информированный читатель, вы наверняка просмотрели, просмотрели или, по крайней мере, добавили в закладки полдюжины статей о самой ожидаемой функции React 16.8: хуках. Вы, вероятно, читали или слышали о том, насколько они замечательны, насколько они ужасны и, возможно, даже насколько они запутаны. Возможно, вы спрашиваете себя "Зачем мне это изучать?" и, вероятно, надеетесь на лучший ответ, чем потому что это новинка. Если бы вы зашли так далеко, что следовали нескольким руководствам по крючкам, вы, возможно, задались вопросом «но почему? Я могу сделать то же самое с помощью классов!»

Укажите @lizandmollie

Если это звучит знакомо, возможно, это потому, что мы проходим через один и тот же цикл каждый раз, когда сталкиваемся с необходимостью изучать что-то новое. Изучение нового может быть трудным для всех, а повторное изучение того, что вы уже знаете, может быть особенно неприятным. Ваша инстинктивная реакция может состоять в том, чтобы сформулировать новые вещи с точки зрения того, что вы уже знаете. Когда я впервые поделился своими знаниями о хуках со своей командой в OkCupid, я нарисовал диаграмму, отображающую методы жизненного цикла компонентов на альтернативы хуков, в результате чего я стал немного похож на этого парня:

Примечание автора: приносим извинения веб-команде OkCupid за то, что стали моими подопытными кроликами

Оказывается, это может быть очень запутанным способом изучения крючков! Многие концепции плохо отображаются или кажутся излишне сложными в одном подходе по сравнению с другим. Вместо того, чтобы продолжать говорить о своих неудачах, я перейду к хорошему. Это не претендует на то, чтобы быть исчерпывающим руководством по хукам, но я надеюсь, что когда вы закончите читать, вы почувствуете достаточно интереса, чтобы написать свой первый компонент с хуками. По моему опыту, это настоящий секрет: он не обязательно будет щелкать, пока вы не начнете писать их для себя. Без дальнейших церемоний, это единственный лучший* подход к изучению зацепок, известный человечеству**.

* Я имею в виду, что все в порядке
** что я лично нашел к моменту публикации

Устал: setState

Проводной: useState

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

Одна из первых вещей, которым мы учимся в React, — это создание компонента с отслеживанием состояния. Вы, как и я, научились писать компонент, расширяя React.Component (или, что более вероятно, используя React.createClass, но мы не говорим о тех темных днях). Вы научились использовать this.setState({ someKey: someValue }) для изменения состояния компонента, помня, что пары ключ/значение, которые вы передаете в setState, перезаписывают старое состояние вашими новыми значениями, а все остальное сливается с ним. О, и не забывая инициализировать объект состояния, чтобы мы не получать ошибок, когда мы пытаемся setState. И, конечно же, не забудьте .bind каждую функцию, которая будет изменять состояние в конструкторе, или не забудьте использовать синтаксис стрелочной функции, который кто-то из вашей команды установил плагин для Babel несколько лет назад.

Забудем обо всем этом на секунду. Давайте наметим, что нам нужно для создания, скажем, простого компонента счетчика кликов с отслеживанием состояния:

  1. Нам нужно знать текущее количество кликов (назовем это currentCount)
  2. Нам нужен способ увеличить количество кликов (назовем это setCurrentCount)

Если мы представим на секунду, что у нас есть эти предварительные условия, мы могли бы написать что-то вроде этого:

import React from "react";

const Counter = () => {
    return (
        <div>
            Current count: {currentCount}
            <button onClick={() => setCurrentCount(currentCount + 1)}>
                Increment
            </button>
        </div>
    );
};

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

import React, { useState } from "react";

const Counter = () => {
    const [currentCount, setCurrentCount] = useState(0);

    return (
        <div>
            Current count: {currentCount}
            <button onClick={() => setCurrentCount(currentCount + 1)}>
                Increment
            </button>
        </div>
    );
};

«Эм, что, черт возьми, только что произошло?!» — спросите вы себя. Остынь, я. Это крючок! Хуки позволяют функциональным компонентам подключаться к функциям, ранее доступным только для компонентов класса, таких как состояние.

Хук useState — это функция, которая принимает один аргумент: начальное состояние (в данном случае 0) и возвращает вам значение и сеттер для этого значения состояния в массиве в указанном порядке. Когда вы вызываете сеттер, React повторно отображает компонент с вашим обновленным значением состояния, как если бы вы вызвали setState.

«Зачем деструктурировать массив?» спросите вы? Что ж, таким образом вы можете назвать значение и установить что угодно, черт возьми. И, конечно же, вы можете использовать хук useState в своем компоненте столько раз, сколько захотите, чтобы вы могли отслеживать несколько частей состояния, если вам нужно, без необходимости конвертировать ваше представление состояния в объект. Мы узнаем больше о возможностях, которые это дает нам, в следующем разделе.

Устал: один объект для хранения состояния

Wired: отдельное состояние для отдельных задач.

Одна из замечательных особенностей useState заключается в том, что представление состояния вашего компонента не должно быть объектом — это может быть число, строка или что угодно (включая объект). Но что это означает для добавления новых свойств состояния? Предположим, позже вы решите, что вам нужно отслеживать другое свойство с отслеживанием состояния. Когда состояние было объектом, это было так же просто, как добавить еще один ключ. Теперь это так же просто, как добавить еще один вызов useState:

import React, { useState } from "react";

const Counter = () => {
    const [currentCount, setCurrentCount] = useState(0);
    const [isClicking, setIsClicking] = useState(false);

    return (
        <div>
            Current count: {currentCount}
            Is clicking: {isClicking}
            <button
                onClick={() => setCurrentCount(currentCount + 1)}
                onMouseDown={() => setIsClicking(true)}
                onMouseUp{() => setIsClicking(false)}>
                Increment
            </button>
        </div>
    );
};

Это также дает нам возможность группировать связанные фрагменты кода вместе, а не группировать потенциально несвязанные изменения состояния в один вызов setState. Например, если бы я хотел переместить некоторые обработчики событий из блока возврата, я мог бы сгруппировать их с наиболее подходящим кодом, например так:

import React, { useState } from "react";

const Counter = () => {
    const [currentCount, setCurrentCount] = useState(0);
    const incrementCounter = () => setCurrentCount(currentCount + 1);

    const [isClicking, setIsClicking] = useState(false);
    const onMouseDown = () => setIsClicking(true);
    const onMouseUp = () => setIsClicking(false)

    return (
        <div>
            Current count: {currentCount}
            Is clicking: {isClicking}
            <button
                onClick={incrementCounter}
                onMouseDown={onMouseDown}
                onMouseUp{onMouseUp}>
                Increment
            </button>
        </div>
    );
};

Круто, правда? В традиционном компоненте класса эти свойства состояния должны были бы жить вместе в одном объекте, а инициализация состояния и функции для его изменения, скорее всего, были бы распределены по всему компоненту, а не сгруппированы вместе со связанной логикой. Для такого простого компонента преимущества могут быть незначительными, но для более крупных компонентов это действительно может повлиять на читабельность вашего компонента.

Усталость: методы жизненного цикла

Wired: данные, которые меняются, когда вам это нужно

Итак, мы узнали, что useState может заменить (и в некотором смысле улучшить) setState. Но как насчет всех других мощных вещей, которые мы можем делать с помощью методов жизненного цикла в компонентах класса? Здесь все может стать немного запутанным для опытных разработчиков React, изучающих крючки.

Методы жизненного цикла — это абстракция, которая заставляет нас думать о том, на каком этапе рендеринга компонента мы находимся. Такие имена, как componentDidMount, componentDidUpdate и componentWillUnmount, кажутся интуитивно понятными тем из нас, кто использует их годами и точно знает, когда и когда. почему они будут бегать - что-то, что может быть очень запутанным для начинающих. Тем не менее, по моему опыту, я обнаружил, что мы обычно используем их в нескольких предсказуемых шаблонах. Поднимите руку, если что-то из этого звучит знакомо:

  1. componentDidMount и componentWillUnmount для подключения/удаления прослушивателей событий или установки/сброса тайм-аута. (Пример: прослушивание прокрутки документа или событий нажатия клавиш для изменения состояния)
  2. componentDidMount и componentDidUpdate для загрузки чего-либо на основе изменения реквизита/состояния. (Пример: загрузка данных при переходе на страницу и перезагрузка при изменении состояния)
  3. componentDidMount и componentDidUpdate для пересчета некоторого свойства DOM на основе изменения реквизита/состояния. (Пример: прокрутка к началу элемента после изменения состояния)

Часто мы используем несколько таких шаблонов одновременно, и эти методы жизненного цикла могут запутаться. Связанная логика по необходимости распределена по нескольким из этих методов, и может быть трудно отследить, что происходит в беспорядке. Но так быть не должно! По сути, большинство этих шаблонов можно упростить до: делать что-то, когда что-то происходит. И, к счастью, у нас есть несколько крючков, которые могут помочь в этом.

Допустим, мы хотим избавиться от кнопки в нашем компоненте-счетчике и вместо этого просто слушать щелчки по документу. Вместо того, чтобы писать методы жизненного цикла для установки и отключения этих обработчиков событий, давайте воспользуемся новым хуком под названием useEffect.

import React, { useState, useEffect } from "react";

const Counter = () => {
    const [currentCount, setCurrentCount] = useState(0);
    const incrementCounter = () => setCurrentCount(currentCount + 1);
    useEffect(() => {
        document.addEventListener("click", incrementCounter);

        return () => {
            document.removeEventListener("click", incrementCounter);
        };
    }, [incrementCounter]);

    const [isClicking, setIsClicking] = useState(false);
    const onMouseDown = () => setIsClicking(true);
    const onMouseUp = () => setIsClicking(false);
    useEffect(() => {
        document.addEventListener("mousedown", onMouseDown);
        document.addEventListener("mouseup", onMouseUp);

        return () => {
            document.removeEventListener("mousedown", onMouseDown);
            document.removeEventListener("mouseup", onMouseUp);
        };
    }, [onMouseDown, onMouseUp]);

    return (
        <div>
            Current count: {currentCount}
            Is clicking: {isClicking}
        </div>
    );
};

Вау, это было много. Давайте сосредоточимся на одном из этих новых блоков useEffect.

useEffect(() => {
    document.addEventListener("click", incrementCounter);

    return () => {
        document.removeEventListener("click", incrementCounter);
    };
});

Этот аккуратный маленький хук может быть сложным для понимания — это может быть проще со старыми добрыми именованными функциями (я скучаю по ним):

useEffect(function setUp() {
    document.addEventListener("click", incrementCounter);

    return function tearDown() {
        document.removeEventListener("click", incrementCounter);
    };
});

Так-то лучше. По сути, мы говорим компоненту запустить нашу функцию setUp() после его рендеринга и очистить после себя с помощью функции tearDown перед следующим рендерингом. Например, если этот компонент отрендерится три раза, он будет запускать функции следующим образом:

  1. render
  2. setUp()
  3. render (x2)
  4. tearDown()
  5. setUp()
  6. render (x3)
  7. tearDown()
  8. setUp()

…и так далее. Однако ради эффективности мы можем опционально передать useEffect второй параметр:

useEffect(function setUp() {
    document.addEventListener("click", incrementCounter);

    return function tearDown() {
        document.removeEventListener("click", incrementCounter);
    };
}, [incrementCounter]);

Этот второй параметр представляет собой список элементов, которые должны вызывать повторный запуск компонентом функций setUp и tearDown. Обычно этот список будет включать внешние переменные, на которые вы ссылаетесь в вызове useEffect, в данном случае incrementCounter. Это позволяет нам избежать расточительных настроек и демонтажа. Более того, это может помочь нам предотвратить повторный запуск эффекта, просто передав ему пустой список значений для изменения. Полезный!

В приведенном выше примере мы useEffect настраиваем некоторые прослушиватели событий документа каждый раз, когда incrementCounter изменяется, но мы можем использовать тот же хук для запуска любого вида побочных эффектов, которые нам нужны, — от подписки и отписки до веб-сокета, обращения к API для обновленные данные при изменении некоторых свойств или любых других действиях, управляемых свойствами или состоянием, которые мы можем захотеть предпринять.

Здесь стоит отметить, что возвращаемое значение tearDown совершенно необязательно. У нас не всегда есть что-то, что мы хотим очистить, но иногда мы это делаем, и теперь мы можем сохранить эту связанную логику вместе. Это также может быть полезным напоминанием об очистке после наших побочных эффектов, которые мы, возможно, ранее забыли очистить в файле componentWillUnmount.

Устали: компонентные методы

Проводной: useCallback

"Но подождите!", вы можете сказать прямо сейчас, "мы определили incrementCounter в функции рендеринга! Это будет переопределяться при каждом рендеринге, поэтому наш хук не будет запускаться каждый раз?» Ну-ну, я не знал, что вы уделяете такое пристальное внимание этому чрезмерно растянутому-тонкому компоненту примера. Но вы абсолютно правы!

import React, { useState, useEffect } from "react";

const Counter = () => {
    const [currentCount, setCurrentCount] = useState(0);
    const incrementCounter = () => setCurrentCount(currentCount + 1);

    useEffect(() => {
        document.addEventListener("click", incrementCounter);

        return () => {
            document.removeEventListener("click", incrementCounter);
        };
    }, [incrementCounter]);

    return (
        <div>
            Current count: {currentCount}
        </div>
    );
};

Поскольку incrementCounter переопределяется при каждом рендеринге, использование его во втором параметре useEffect на самом деле не дает нам особых преимуществ. К счастью, для этого есть крючок!

import React, { useState, useEffect, useCallback } from "react";

const Counter = () => {
    const [currentCount, setCurrentCount] = useState(0);
    const incrementCounter = useCallback(
        () => setCurrentCount(currentCount + 1),
        [setCurrentCount, currentCount],
    );

    useEffect(() => {
        document.addEventListener("click", incrementCounter);

        return () => {
            document.removeEventListener("click", incrementCounter);
        };
    }, [incrementCounter]);

    return (
        <div>
            Current count: {currentCount}
        </div>
    );
};

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

Это особенно полезно, потому что мы все знаем, что передача стрелочных функций в качестве реквизита бесполезна, так как это может привести к расточительному повторному рендерингу. Теперь исправить это так же просто, как обернуть стрелочную функцию в хук useCallback! Это простой способ добиться некоторого повышения производительности и предотвратить ненужный повторный рендеринг.

Устал: выберите заново

Проводной: useMemo

Я был бы упущен, если бы не рассказал о близком родственнике useCallback, удивительно полезном крючке useMemo. Используйте этот хук каждый раз, когда вам приходится вычислять что-то дорогое в блоке рендеринга. Например, если тело вашего компонента выглядит примерно так:

import React from "react";

const MyComponent = ({ someObject }) => {
    const someNumber = Object.keys(someObject)
        .map((key) => someObject[value])
        .filter((value) => value % 2 === 0)
        .reduce((sum, current) => sum + current, 0)
    const array = [...new Array(someNumber)];

    return (
        <div>
            {array.map(() => <span />)}
        </div>
    );
};

(Конечно, это смехотворно надуманный пример, но скажите мне с невозмутимым видом, что где-то в вашей кодовой базе нет чего-то похожего, и я съем свою шляпу.) Вместо этого вы могли бы обернуть этот дорогостоящий расчет в useMemo вот так:

import React, { useMemo } from "react";

const MyComponent = ({ someObject }) => {
    const array = useMemo(() => {
        const someNumber = Object.keys(someObject)
            .map((key) => someObject[value])
            .filter((value) => value % 2 === 0)
            .reduce((sum, current) => sum + current, 0)
        return [...new Array(someNumber)];
    }, [someObject]);

    return (
        <div>
            {array.map(() => <span />)}
        </div>
    );
};

Теперь этот массив будет только пересчитываться, если значение someObject изменится. Это намного эффективнее, чем пересчитывать его при каждом рендеринге (хотя, по общему признанию, все же менее эффективно, чем его полное удаление, потому что это очень плохо™️). В прошлом такие библиотеки, как reselect, давали нам инструменты для получения аналогичных преимуществ в производительности, но теперь вы можете воспользоваться этими преимуществами без необходимости импортировать дополнительную библиотеку.

Устали: компоненты/примеси высшего порядка

Проводные: пользовательские крючки

Еще кое-что. Возвращаясь к нашему примеру со счетчиком (вы думали, что я отпущу наш компонент счетчика — никогда!), что если мы по какой-то непостижимой причине захотим прикрепить обработчики кликов к документу во втором компоненте? Может быть, тот, который генерирует случайное число при нажатии.

import React, { useState, useEffect, useCallback } from "react";

const RandomNumberGenerator = () => {
    const [randomNumber, setRandomNumber] = useState();
    const getRandomNumber = useCallback(
        () => setRandomNumber(4), // guaranteed to be random
        [setRandomNumber],
    );

    useEffect(() => {
        document.addEventListener("click", getRandomNumber);

        return () => {
            document.removeEventListener("click", getRandomNumber);
        };
    }, [getRandomNumber]);

    return (
        <div>
            Random number is: {randomNumber}
        </div>
    );
};

Ну, мы могли бы просто переопределить логику внутри нашего нового компонента, но это неинтересно. Если бы мы чувствовали себя умнее, мы могли бы использовать для этого компонент более высокого порядка или свойство рендеринга. Но именно здесь пользовательские хуки могут действительно проявить себя. Мы можем абстрагировать общую логику в пользовательский хук, который мы могли бы назвать useDocumentClick.

import React, { useState, useEffect, useCallback } from "react";

function useDocumentClick(onDocumentClick) {
    useEffect(() => {
        document.addEventListener("click", onDocumentClick);

        return () => {
            document.removeEventListener("click", onDocumentClick);
        };
    }, [onDocumentClick]);
}

const Counter = () => {
    const [currentCount, setCurrentCount] = useState(0);
    const incrementCounter = useCallback(
        () => setCurrentCount(currentCount + 1),
        [setCurrentCount, currentCount],
    );
    useDocumentClick(incrementCounter);
	
    return (
        <div>
            Current count: {currentCount}
        </div>
    );
};

const RandomNumberGenerator = () => {
    const [randomNumber, setRandomNumber] = useState();
    const getRandomNumber = useCallback(
        () => setRandomNumber(4), // guaranteed to be random
        [setRandomNumber],
    );
    useDocumentClick(getRandomNumber);

    return (
        <div>
            Random number is: {randomNumber}
        </div>
    );
};

Пользовательские хуки можно использовать так же, как и любые другие хуки. Имя вашего пользовательского хука всегда должно начинаться с use, но помимо этого вы можете свободно делать там все, что захотите, включая использование других хуков, таких как useState и useEffect. Вашему компоненту никогда не нужно знать детали реализации хука — только его API. Это может помочь удалить сложную логику состояний или побочных эффектов из ваших компонентов и упростить повторное использование этой логики в будущем. Вы не обязательно захотите делать это для всех своих хуков, но это гораздо более простой подход к повторному использованию логики компонентов, когда вам это нужно.

Например, если вы обнаружите, что довольно часто делаете вызовы API из своих компонентов, вы можете написать хук под названием useAPI:

function useAPI(method, endpoint, data)  {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState(null);
    const [response, setResponse] = useState(null);

    useEffect(async () => {
        try {
            setIsLoading(true);
            setResponse(null);
            setError(null);

            const res = await fetch(endpoint, { method, data });

            setIsLoading(false);
            setResponse(res);
        } catch(err) {
            setIsLoading(false);
            setError(err);
        }
    }, [method, endpoint, data]);

    return {
        response,
        error,
        isLoading,
    };
}

Таким образом, у вас есть один согласованный уровень для взаимодействия с вашим API из компонента. Если вы используете современные технологии, такие как GraphQL и Apollo, как мы начинаем в OkCupid, уже есть несколько отличных проектов с открытым исходным кодом, чтобы предоставить вам несколько таких мощных настраиваемых хуков, а также растущие коллекции других разнообразные крючки.

Слово предупреждения ⚠️

Есть одно важное правило, которое вы должны помнить при использовании хуков: ваши хуки должны объявляться в одном и том же порядке при каждом рендеринге компонента. Это означает следующее: хуки нельзя определять внутри условных операторов, после условных возвратов или в циклах. Хуки всегда должны вызываться на верхнем уровне отступа. Если это кажется странным, то это потому, что по стандартам Javascript это необычное ограничение. Это дополнительное ограничение языка, но необходимое для того, чтобы React мог правильно сохранять состояние. Для получения дополнительной информации о том, почему существует это ограничение, я бы порекомендовал вам прочитать Пост в блоге Дэна Абрамова на Overreacted. Хорошая новость: есть плагин eslint, который поможет вам избежать этой ошибки.

Вы еще не зацепили?

Хуки React действительно могут изменить то, как мы думаем о состоянии и обновлениях состояния в наших компонентах, что может привести к действительно большим возможностям рефакторинга. Хотя может возникнуть соблазн «перевести» наши компоненты 1:1 из классов в хуки, это часто может ограничить преимущества, которые могут предоставить хуки. Я надеюсь, что это руководство помогло пробудить ваш интерес к мощи хуков, а также помогло переосмыслить наше представление о некоторых общих шаблонах в наших компонентах. Возможности оптимизации нашего существующего кода с использованием useCallback и useMemo невозможно переоценить, поскольку они могут обеспечить легкое повышение производительности наших существующих функциональных компонентов. Споры о том, когда более интуитивно понятно использовать хуки, а когда использовать компоненты классов, несомненно, будут бушевать, но я думаю, что, по крайней мере, хуки предоставляют нам некоторые очень мощные новые инструменты в наших поясах инструментов для выражения компонентов с состоянием и даже для оптимизации компонентов без состояния.

Первоначально опубликовано на https://tech.okcupid.com 27 февраля 2019 г.