React Signals: как устроены сигналы в React

jennypollard

jennypollard

Posted on April 28, 2023

React Signals: как устроены сигналы в React

Следуя трендам, можно было обнаружить для себя React Signals. Возможно, вам попадался такой фрагмент кода:

import { signal } from "@preact/signals-react";

const count = signal(0);

export default function App() {
  return <button onClick={() => count.value++}>{count.value}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Код вызывает удивление — как это сделано? Почему App обновляется при изменении count? Что позволило так сделать? Похоже на хуки, но нет, хуки нельзя использовать вне компонента…

Найдем код @preact/signals-react

Пакеты берутся из npm-реестра, найдем @preact/signals-react на npmjs.com. На странице пакета есть ссылка на репозиторий модуля на GitHub. В репозитории есть несколько директорий: docs, patches, scripts, packages. Первые три нам сейчас не интересны — это документация и какие-то вспомогательные вещи, посмотрим в packages. В packages есть core, preact, react. preact — нас сейчас не интересует, core — нечто очень важное и общее, но нам нужно конкретное — react. Внутри найдем src/index.ts.

С чего начать — код выполняемый при инициализации модуля

Разберемся, что происходит при инициализации модуля, посмотрим, что делает код, выполняемый при импорте модуля. Проигнорируем, для начала, определения функций и внутренних переменных, у нас останется:

const JsxPro: JsxRuntimeModule = jsxRuntime;
const JsxDev: JsxRuntimeModule = jsxRuntimeDev;
React.createElement = WrapJsx(React.createElement);
JsxDev.jsx && /*   */ (JsxDev.jsx = WrapJsx(JsxDev.jsx));
JsxPro.jsx && /*   */ (JsxPro.jsx = WrapJsx(JsxPro.jsx));
JsxDev.jsxs && /*  */ (JsxDev.jsxs = WrapJsx(JsxDev.jsxs));
JsxPro.jsxs && /*  */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));

// Decorate Signals so React renders them as <Text> components.
Object.defineProperties(Signal.prototype, {
    $$typeof: { configurable: true, value: ReactElemType },
    type: { configurable: true, value: ProxyFunctionalComponent(Text) },
    props: {
        configurable: true,
        get() {
            return { data: this };
        },
    },
    ref: { configurable: true, value: null },
});
Enter fullscreen mode Exit fullscreen mode

Последний блок кода (с Object.defineProperties) добавляет всем сигналам возможность рендериться: $$typeof, type, props, ref — свойства всех React-элементов.

Остановимся подробней на первой части, в ней участвуют: jsx, jsxs (из react/jsx-runtime), jsx, jsxs (из react/jsx-dev-runtime), createElement из React и некая функция WrapJsx.

Что такое jsx-runtime?

React-компоненты преобразуются в вызовы React.createElement, jsx или jsxs

Например, такой код:

const Foo = () => (<button>Click Me</button>);

const Bar = () => (
  <div>
    <Foo />
  </div>
);
Enter fullscreen mode Exit fullscreen mode

будет преобразован компилятором в:

import { jsx as _jsx } from "react/jsx-runtime";
const Foo = () =>
    _jsx("button", {
    children: "Click Me"
  });
const Bar = () =>
    _jsx("div", {
    children: _jsx(Foo, {})
  });
Enter fullscreen mode Exit fullscreen mode

В зависимости от настроек js-компилятора (например, babel), вместо React.createElement может быть jsx из react/jsx-runtime, будем считать их эквивалентными.

React.createElement — это функция создающая React-элемент. У нее три аргумента: type, config, children. Первый аргумент type — это тип элемента, может быть:

  • строкой — для хост-элеметов: div, span, main;
  • объектом — в случае экзотичных React-элементов: forwardRef, memo;
  • функцией — для функциональных или класс-компонентов.

Второй аргумент config — это объект, содержащий в себе пропсы элемента, ref и key.

Последний аргумент children — список дочерних элементов. Точно такую же роль выполняют функции jsx, jsxs, jsxDev — создают React-элементы, имеют такие же аргументы.

Вернемся к коду инициализации модуля:

const JsxPro: JsxRuntimeModule = jsxRuntime;
const JsxDev: JsxRuntimeModule = jsxRuntimeDev;
React.createElement = WrapJsx(React.createElement);
JsxDev.jsx && /*   */ (JsxDev.jsx = WrapJsx(JsxDev.jsx));
JsxPro.jsx && /*   */ (JsxPro.jsx = WrapJsx(JsxPro.jsx));
JsxDev.jsxs && /*  */ (JsxDev.jsxs = WrapJsx(JsxDev.jsxs));
JsxPro.jsxs && /*  */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));
Enter fullscreen mode Exit fullscreen mode

Теперь понятно, что модуль переопределяет функции создания React-элементов результатом вызова функции WrapJsx. Каждая функция: jsx, jsxs, jsxDEV, createElment преобразуется с помощью WrapJsx.

Что делает функция-декоратор WrapJsx?

Посмотрим код функции WrapJsx:

function WrapJsx<T>(jsx: T): T {
    if (typeof jsx !== "function") return jsx;

    return function (type: any, props: any, ...rest: any[]) {
        if (typeof type === "function" && !(type instanceof Component)) {
            return jsx.call(jsx, ProxyFunctionalComponent(type), props, ...rest);
        }

        if (type && typeof type === "object") {
            if (type.$$typeof === ReactMemoType) {
                type.type = ProxyFunctionalComponent(type.type);
                return jsx.call(jsx, type, props, ...rest);
            } else if (type.$$typeof === ReactForwardRefType) {
                type.render = ProxyFunctionalComponent(type.render);
                return jsx.call(jsx, type, props, ...rest);
            }
        }

        if (typeof type === "string" && props) {
            for (let i in props) {
                let v = props[i];
                if (i !== "children" && v instanceof Signal) {
                    props[i] = v.value;
                }
            }
        }

        return jsx.call(jsx, type, props, ...rest);
    } as any as T;
}
Enter fullscreen mode Exit fullscreen mode

WrapJsx вызывается с единственным аргументом jsx — оригинальной функцией создания React-элемента и возвращает функцию с такими же как у jsx аргументами.

WrapJsx обрабатывает четыре сценария:

  1. Функциональный компонент или класс-компонент:
if (typeof type === "function" && !(type instanceof Component)) {
    return jsx.call(jsx, ProxyFunctionalComponent(type), props, ...rest);
}
Enter fullscreen mode Exit fullscreen mode

Если создается React-элемент, тип у которого — функция, значит элементу соответствует фукнциональный компонент или класс-компонент. Например, для такого jsx-выражения: <div><Foo>text</Foo></div> под условие выше подходит Foo. Внутри условия вызывается оригинальная функция jsx, но вместо исходного type передается ProxyFunctionalComponent(type), это эквивалентно оборачиванию Foo в ProxyFunctionalComponent:

const Foo = ProxyFunctionalComponent(props => {
    return (
        ...
    );
})
Enter fullscreen mode Exit fullscreen mode
  1. Экзотичные React-элементы:
if (type && typeof type === "object") {
    if (type.$$typeof === ReactMemoType) {
        type.type = ProxyFunctionalComponent(type.type);
        return jsx.call(jsx, type, props, ...rest);
    } else if (type.$$typeof === ReactForwardRefType) {
        type.render = ProxyFunctionalComponent(type.render);
        return jsx.call(jsx, type, props, ...rest);
    }
}
Enter fullscreen mode Exit fullscreen mode

Если тип элемента — объект, значит создается экзотичный React-элемент, их два: memo и forwardRef. Оба эти элемента ссылаются на функциональный компонент, который нужно отрендерить. Элемент memo ссылается на компонент через свойство type, forwardRef ссылается через свойство render. Во фрагменте выше, они также оборачиваются в ProxyFunctionalComponent.

  1. Хост-компоненты.

Хост-компоненты (div, span, main и тд) попадают в третью ветку, в этом случае type — это строка.

if (typeof type === "string" && props) {
    for (let i in props) {
        let v = props[i];
        if (i !== "children" && v instanceof Signal) {
            props[i] = v.value;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Для таких элементов просматривают все пропсы и, если среди них есть экземпляр Сигнала, он заменяется на его значение value.

Таким образом, декоратор WrapJsx оборачивает все пользовательские компоненты в ProxyFunctionalComponent. При каждом обновлении (рендере) пользовательского компонента будет сначала происходить вызов ProxyFunctionalComponent. Так как можно быть уверенным, что это происходит в момент обновления, внутри ProxyFunctionalComponent можно использовать хуки, создавать локальный компоненту стейт, подписываться на события. @preact/signals-react использует эту возможность, чтобы отслеживать обращения к Сигналам внутри компонента и вызывать обновление компонента, когда Сигнал изменяется.

💖 💪 🙅 🚩
jennypollard
jennypollard

Posted on April 28, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related