Published on

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

Authors
  • avatar
    Name
    Jenny Pollard
    Twitter

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

import { signal } from '@preact/signals-react'

const count = signal(0)

export default function App() {
  return <button onClick={() => count.value++}>{count.value}</button>
}

Код вызывает удивление — как это сделано? Почему 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 },
});

Последний блок кода (с 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>
)

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

import { jsx as _jsx } from 'react/jsx-runtime'
const Foo = () =>
  _jsx('button', {
    children: 'Click Me',
  })
const Bar = () =>
  _jsx('div', {
    children: _jsx(Foo, {}),
  })

В зависимости от настроек 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));

Теперь понятно, что модуль переопределяет функции создания 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;
}

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

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

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

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

const Foo = ProxyFunctionalComponent(props => {
	return (
		...
	);
})
  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)
  }
}

Если тип элемента — объект, значит создается экзотичный 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
    }
  }
}

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

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