Первое погружение в исходники хуков (задел на будущие статьи)

    Привет, Хабр!

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

    Поиск исходников хуков

    Для этого мы откроем репозиторий React-а в Github. Он представляет из себя монорепозиторий, где все известные нам пакеты лежат в packages. Ниже на скрине я выделил те самые пакеты, которые мы используем в ежедневной разработке:

    В ней мы видим те самые репозитории, которые мы как пользователи импортируем себе в проект. Например мы видим пакет react-dom. Из которого мы импортируем метод render, для вставки React приложения в HTML.

    import React from "react";
    import ReactDOM from "react-dom";
    
    import App from "./App";
    
    ReactDOM.render(<App />, document.getElementById("root"));

    Хуки же мы импортируем из пакета React. Соответственно такой package так же присутствует. Зайдем в него и откроем index.js файл.

    export {
      ...
      useCallback,
      useContext,
      useEffect,
      ...
      __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
      ...
    } from './src/React';

    И действительно, здесь мы видим, как экспортируются хуки useCallback, useContext, useEffect. Из смешного, мне еще понравилась экспортируемая переменная "SECRET INTERNALS DO NOT USE OR YOU WILL BE FIRED". Видимо у них так же есть технический долг, за который еще и уволить могут))) Экспортируется же все это из файла packages/react/src/React.js. Этот файл тоже занимается экспортом хуков из соседнего файла packages/react/src/ReactHooks.js. Именно в этом файле мы можем уже найти кое какую реализацию хуков:

    export function useCallback<T>(
      callback: T,
      deps: Array<mixed> | void | null,
    ): T {
      const dispatcher = resolveDispatcher();
      return dispatcher.useCallback(callback, deps);
    }

    И действительно здесь useCallback получает те самые 2 параметра callback и deps, но это нам не сильно помогло, так как мы видим еще один слой абстракции в виде dispatcher и уже из него вызываем опять метод useCallback.  Сам метод resolveDispatcher находится вверху этого файла:

    import ReactCurrentDispatcher from './ReactCurrentDispatcher';
    
    function resolveDispatcher() {
      const dispatcher = ReactCurrentDispatcher.current;
      invariant(
        dispatcher !== null,
        'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
          ' one of the following reasons:\n' +
          '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
          '2. You might be breaking the Rules of Hooks\n' +
          '3. You might have more than one copy of React in the same app\n' +
          'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
      );
      return dispatcher;
    }

    Все что он делает это извлекает какой-то dispatcher из переменной ReactCurrentDispatcher, который как мы видим импортится из соседнего файла с таким же именем. А если вас заинтересовал метод invariant, вот в документации про него рассказывают. А мы рассмотри файл packages/react/src/ReactCurrentDispatcher.js.

    import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
    
    /**
     * Keeps track of the current dispatcher.
     */
    const ReactCurrentDispatcher = {
      /**
       * @internal
       * @type {ReactComponent}
       */
      current: (null: null | Dispatcher),
    };

    И здесь нас ждет легкое разочарование, так как по факту этот current является просто свойством объекта и явно понять кто сетит значение в него достаточно сложно. Но есть подсказка. Тип данных которые сетятся в current имеет значение Dispatcher. И он импортится из соседнего пакета react-reconcilier.

    Поэтому я перешел в пакет react-reconcilier и поискал по имени подходящий нам файл. И кажется таким файлом является packages/react-reconcilier/src/ReactFiberHooks.new.js. Использовав поиск по странице внутри и действительно обнаружился тот самый ReactCurrentDispatcher

    import ReactSharedInternals from 'shared/ReactSharedInternals';
    
    const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;

    Я проследил цепочку создания объекта ReactSharedInternals, чтобы понять действительно ли это тот самый ReactCurrentDispatcher. И обнаружил в файле packages/shared/ReactSharedInternals.js следующую картину :

    import * as React from 'react';
    
    const ReactSharedInternals =
      React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
    
    export default ReactSharedInternals;

    Да ReactSharedInternals это та самая смешная переменная "SECRET INTERNALS DO NOT USE OR YOU WILL BE FIRED". И действительно если вы проследите эти исходники, то убедитесь, что она в себя включает, тот самый ReactCurrentDispatcher

    Давайте лучше попробуем разобраться, что происходит с переменной ReactCurrentDispatcher в рамках файла packages/react-reconcilier/src/ReactFiberHooks.new.js.

    if (__DEV__) {
      if (current !== null && current.memoizedState !== null) {
        ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
      } else if (hookTypesDev !== null) {
        // This dispatcher handles an edge case where a component is updating,
        // but no stateful hooks have been used.
        // We want to match the production code behavior (which will use HooksDispatcherOnMount),
        // but with the extra DEV validation to ensure hooks ordering hasn't changed.
        // This dispatcher does that.
        ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
      } else {
        ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
      }
    } else {
      ReactCurrentDispatcher.current =
        current === null || current.memoizedState === null
          ? HooksDispatcherOnMount
          : HooksDispatcherOnUpdate;
    }

    В переменную ReactCurrentDispatcher.current присваивается много разных значений. Большинство значений имеют приставку InDEV, да и они все находятся в одном if блоке с проверкой на __DEV__ окружение. Поэтому лучше рассмотрим else секцию. Там мы видим тернарный оператор, который в зависимости от переменной ReactCurrentDispatcher.current присвоит HooksDispatcherOnMount или HooksDispatcherOnUpdate. Если мы перейдем посмотреть, что же такое HooksDispatcherOnMount мы увидим объект у которого методы совпадают с названиями хуков, но значения присваиваемые в эти свойства отличаются (ссылка на объект):

    const HooksDispatcherOnMount: Dispatcher = {
      useCallback: mountCallback,
      useContext: readContext,
      useEffect: mountEffect,
      ...
    };

    Немного ниже мы найдем HooksDispatcherOnUpdate, где свойства так же совпадают с именами хуков, а значения снова отличаются (ссылка на объект):

    const HooksDispatcherOnUpdate: Dispatcher = {
      useCallback: updateCallback,
      useContext: readContext,
      useEffect: updateEffect,
      ...
    };

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

    Таким образом хотя бы из названий переменных мы можем предположить, утрированную версию как это работает на самом деле:

    Допустим у нас есть простейший компонент с одним хуком useEffect. И когда мы первый раз рендерим этот компонент, подставляется HooksDispatcherOnMount.useEffect и соответственно вызывается метод mountEffect. Далее, при следующем рендере компонента, подставляется уже HooksDispatcherOnUpdate.useEffect и соответственно вызывается метод updateEffect.

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

    Изучаем useCallback

    Мы достаточно долго искали где хранятся исходники хуков, давайте уже рассмотрим саму функцию mountCallback.

    function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
      const hook = mountWorkInProgressHook();
      const nextDeps = deps === undefined ? null : deps;
      hook.memoizedState = [callback, nextDeps];
      return callback;
    }

    Она по прежнему принимает 2 параметра callback и deps. В следующей строке из метода mountWorkInProgressHook() мы получаем какой-то hook, рассмотрим его немного позже. А пока перейдем к следующей строке, здесь если мы не прислали deps, значение превращается в null вместо undefined. И далее в вышеупомянутый hook в свойство memoizedState массивом сохраняются присланные параметры callback и deps. И в последней строке уже просто возвращается callback.

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

    Давайте теперь рассмотрим функцию updateCallback:

    function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
      const hook = updateWorkInProgressHook();
      const nextDeps = deps === undefined ? null : deps;
      const prevState = hook.memoizedState;
      if (prevState !== null) {
        if (nextDeps !== null) {
          const prevDeps: Array<mixed> | null = prevState[1];
          if (areHookInputsEqual(nextDeps, prevDeps)) {
            return prevState[0];
          }
        }
      }
      hook.memoizedState = [callback, nextDeps];
      return callback;
    }

    Она конечно же, так же принимает 2 параметра callback и deps. В следующей строке, уже из метода updateWorkInProgressHook() получаем hook, подозреваю, что это тот самый инстанс хука, с которым мы взаимодействовали в функции mountCallback. Далее снова превращаем undefined зависимости в null.

    И следующая строка уже более интересная. В функции mountCallback мы сохраняли callback и deps в свойство memoizedState объекта hook:

    function mountCallback<T>(...): T {
      ...
      hook.memoizedState = [callback, nextDeps];
      ...
    }

    А сейчас извлекаем значения и далее проверяем сохранили ли мы в него что-нибудь ранее prevState !== null. В следующей строке nextDeps !== null проверяем, прислали ли нам зависимости в текущей итерации . И если все условия соблюдены, наконец то можем извлечь зависимости с одной из предыдущих итераций const prevDeps = prevState[1] и сравнить его значение с зависимостями текущей итерации areHookInputsEqual(nextDeps, prevDeps). И если зависимости по какому то правилу сравнения совпадают, значит можно вернуть функцию из предыдущих итераций return prevState[0].

    А если же хоть одно условие не выполнилось, тогда просто перезаписываем данные hook.memoizedState = [callback, nextDeps] . И возвращаем немемоизированный callback, а присланный в текущей итерации.

    Думаю общую идею как работает хук useCallback вы уловили. Более того, мы ее обсуждали ранее в выпуске “Что вы знаете о useCallback?”. Где мы писали свою версию имплементации useCallback. Но для меня как всегда интересны детали. А в этом хуке мы не раскрыли, что же такое этот объект hook, и как на самом деле сравниваются зависимости.

    Изучаем функцию сравнения зависимостей

    Начнем с простого, функция areHookInputsEqual сравнивает зависимости, находится в этом же файле:

    function areHookInputsEqual(
      nextDeps: Array<mixed>,
      prevDeps: Array<mixed> | null,
    ) {
      ...
    
      if (prevDeps === null) {
        return false;
      }
    
      ...
      
      for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
        if (is(nextDeps[i], prevDeps[i])) {
          continue;
        }
        return false;
      }
      return true;
    }


    Она принимает 2 массива зависимостей. И точками (...) я сократил блоки для дев окружения (__DEV__). И самое интересное. Мы видим for, который итерирует элементы массива, до того момента, пока хотя бы в одном из массивов зависимостей не закончатся элементы. Таким образом следующие зависимости могут будут равны:

    [user, book, author] === [user, book]

    Поэтому лучше не экспериментировать с динамической длинной зависимостей, а всегда удерживать место под объект:

    // плохо
    const deps = [props.user, props.book]
    
    if (hasAuthor) {
      deps.push(props.author);
    }
    
    // [user, book, author] === [user, book]
    
    // хорошо
    
    const deps = [props.user, props.book, hasAuthor ? props.author : null];
    
    // [user, book, author] !== [user, book, null]

    Далее функцией is(nextDeps[i], prevDeps[i]) сравниваем значения элементов в массивах, если они равны то идем к следующей итерации. Здесь из интересного, то что мы сравниваем i-ый элемент одного массива с i-ым элементов второго, а это значит, что важно сохранять порядок элементов в массивах между рендерами, иначе вы потеряете мемоизацию:

    [user, book, author] !== [user, author, book]

    Осталось только посмотреть, что из себя представляет функция is. Она импортируется из packages/shared/objectIs.js:

    function is(x: any, y: any) {
      return (
        (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y)
      );
    }
    
    const objectIs: (x: any, y: any) => boolean =
      typeof Object.is === 'function' ? Object.is : is;
    
    export default objectIs;

    Здесь мы видим, что для сравнения используется браузерное API Object.is (MDN) и если вдруг по какой то причине, такой метод не существует, подставляется полифил.

    Какие еще выводы можно сделать из увиденного, по факту в массив зависимостей мы привыкли добавлять в основном какие-то props верхнего уровня.

    useEffect(() => {
      ...
    }, [props.user]);

    Но судя по коду, нам никто не мешает добавить в зависимости, какое-то глубокое свойство например props.book.author.id, или если вы не уверены в существовании объекта, использовать амперсанды или вообще тернарный оператор:

    useEffect(() => {
      ...
    }, [
      props.book.author.id,
      props.selectedBooks && props.selectedBooks.id,
      props.book.isFavorite ? props.book : null,
    ]);


    Но еще из любопытного, можно вставлять и совсем не props, например ref или же вообще какой-нибудь window.location.pathname:

    useEffect(() => {
      ...
    }, [
      scrollRef.current,
      window.location.pathname,
    ]);

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

    Изучаем mountWorkInProgressHook()

    Осталось только исследовать последний момент, это методы mountWorkInProgressHook(), updateWorkInProgressHook(). Рассмотрим внутренности первого метода:

    function mountWorkInProgressHook(): Hook {
      const hook: Hook = {
        memoizedState: null,
    
        baseState: null,
        baseQueue: null,
        queue: null,
    
        next: null,
      };
    
      if (workInProgressHook === null) {
        // This is the first hook in the list
        currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
      } else {
        // Append to the end of the list
        workInProgressHook = workInProgressHook.next = hook;
      }
      return workInProgressHook;
    }

    Здесь мы видим создание самого объекта hook, первое свойство объекта memoizedState нам уже известно, там мы храним данные между рендерами. С остальными полями нам еще предстоит познакомиться в будущем.

    В следующих строках мелькает какая-то глобальная переменная workInProgressHook. Она инициализируются вверху файла и изначально равна null.

    // Hooks are stored as a linked list on the fiber's memoizedState field. The
    // current hook list is the list that belongs to the current fiber. The
    // work-in-progress hook list is a new list that will be added to the
    // work-in-progress fiber.
    let currentHook: Hook | null = null;
    let workInProgressHook: Hook | null = null;

    Далее (см. код ниже) мы видим, если workInProgressHook все еще равен null, тогда сперва мы вновь созданный объект hook присваиваем в workInProgressHook. И тот же hook сохраняем в переменную currentlyRenderingFiber.memoizedState. И осталось вернуть тот самый workInProgressHook:

    function mountWorkInProgressHook(): Hook {
      ...
    
      if (workInProgressHook === null) {
        workInProgressHook = hook;
        currentlyRenderingFiber.memoizedState = hook;
      } else {
        ...
      }
    
      return workInProgressHook;
    }

    Для второго хука, глобальная переменная workInProgressHook уже равен не null, а ссылка на предыдущий хук. И вновь созданный объект hook сохраняется уже в поле workInProgressHook.next. И далее перезаписывается значение переменной workInProgressHook.

    function mountWorkInProgressHook(): Hook {
      ...
    
      if (workInProgressHook === null) {
        ...
      } else {
        workInProgressHook.next = hook;
        workInProgressHook = hook;
      }
      
      return workInProgressHook;
    }

    Из этой информации уже вырисовывается определенная картина. При использовании 8 хуков список выглядит примерно следующим образом:

    Таким образом из 8 хуков строится длинная цепочка, где currentlyRenderingFiber.memoizedState указывает на первый хук, а workInProgressHook указывает на последний хук. Называется такой список Linked List.

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

    Итоги

    Этой статьей я хотел показать вам, где хранится код от хуков. Показать, что между рендарами мы работаем с одним и тем же инстансом хука (или его клоном), который создается с помощью mountWorkInProgressHook() и потом возвращается на каждой итерации из updateWorkInProgressHook(). И как видите там нет никакой магии, местами даже код крайне примитивный.

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

    Чао!

    Комментарии 1

      0
      Но еще из любопытного, можно вставлять и совсем не props, например ref или же вообще какой-нибудь window.location.pathname:

      Не делайте так, если не хотите долгого неочевидного дебага!
      Чтение чего либо, кроме props, state и context в теле компонента — это сайдэффект и должно происходить в use(Layout)Effect.


      Во первых, ваш компонент может не знать об изменении значения произвольной рефки (например, если она спускается пропсом глубже) или компонент может не подписываться на событие popState и соответственно не отреагировать на изменение window.location. Это приведет к stalled эффекту — использованию протухших значений из замыкания.
      Во вторых, такой код сломается при серверном рендеринге.
      Любое чтение из "окружающего мира" (ref.current это тоже зачастую кусочек окружающего мира) надо делать в эффектах.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое