В процессе разработки современных веб-приложений производительность часто становится одним из ключевых аспектов, которые волнуют и разработчиков, и пользователей. Пользователи ожидают молниеносного отклика, а разработчики стремятся создать приложения, которые работают быстро и эффективно.
Одним из мощных инструментов, позволяющих достигнуть высокой производительности в React-приложениях, является мемоизация. Мемоизация помогает значительно сократить количество вычислений и, соответственно, обновлений интерфейса, что положительно сказывается на общей скорости и отзывчивости приложения.
В данной статье мы заглянем "под капот" движка React и увидем, как именно происходит обновление узлов. Параллельно рассмотрим и основные принципы мемоизации и её применение в различных типах компонентов.
Что такое мемоизация?
Начнем, пожалуй, с теории. Формально, мемоизацию можно определить как оптимизационную технику, применяемую для увеличения производительности программ за счёт сохранения результатов вызова функции и возвращения сохранённого результата при повторных вызовах с теми же аргументами.
В контексте React мемоизация особенно полезна для предотвращения лишних перерисовок и повышения производительности приложений. Другими словами, она помогает избегать ненужных обновлений компонентов, что делает приложение более реактивным и эффективным. Вторым не менее важным свойством мемоизации является сокращение количества дорогостоящих вычислений. Вместо того, чтобы производить их на каждом рендере компонента, можно "запомнить результат" и пересчитывать его только в случае изменения соответствующих входных параметров.
Где конкретно хранится мемоизированный результат?
Чтобы ответить на этот вопрос, нам понадобится понятие React Fiber. Подробно я описывал эту структуру в статье Детальный React. Реконсиляция, рендеры, Fiber, виртуальное дерево. Если коротко, Fiber - это некая абстрактная сущность движка React, с помощью которой описывается любой обрабатываемый узел React, будь то компонент, хук или host root. У Fiber, кроме прочего, имеются свойства pendingProps
, memoizedProps
и memoizedState
. Именно в этих свойствах и хранится мемоизированный результат. Как именно? Посмотрим далее.
Мемоизируемые компоненты
Хоть принципы мемоизации в React одинаковы для всех сущностей, детали реализации, все таки могут отличаться, в зависимости от типа Fiber. Для понимания процесса нам потребуется пройтись по каждому типу отдельно. И начнем мы с компонентов, как самой "старой" структуры React.
Еще с первых версий React мы привыкли делить компоненты на классовые и функциональные. На самом деле, внутри движка типов компонентов куда больше. Тип компонента является типом Fiber и указывается в свойстве Fiber.tag
. В выше указанной статье я приводил полный список типов для версии React 18.2, где в общей сложности насчитывалось 28 типов . В версии 18.3.1 список немного изменился, теперь их всего 26 штук.
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // Before we know whether it is function or class
export const HostRoot = 3; // Root of a host tree. Could be nested inside another node.
export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
export const HostComponent = 5;
export const HostText = 6;
export const Fragment = 7;
export const Mode = 8;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const Profiler = 12;
export const SuspenseComponent = 13;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const IncompleteClassComponent = 17;
export const DehydratedFragment = 18;
export const SuspenseListComponent = 19;
export const ScopeComponent = 21;
export const OffscreenComponent = 22;
export const LegacyHiddenComponent = 23;
export const CacheComponent = 24;
export const TracingMarkerComponent = 25;
Для понимания процессов мемоизации, из этого списка нам понадобятся только три:
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const MemoComponent = 14;
Когда происходит мемоизация?
Прежде чем перейти непосредственно к типам Fiber, стоит разобраться в какой конкретно момент запускается сам процесс мемоизации. В предыдущей статье я выделил четыре фазы обработки узла. Первая фаза beginопределяет тип Fiber, в зависимости от которого будет запущен нужный жизненный цикл. Узлы, которые еще небыли смонтированы имеют тип IndeterminateComponent
, LazyComponent
или IncompleteClassComponent
. Для таких Fiber будет запущен процесс монтирования. В остальных же случаях реконсиллер попытается обновить существующий узел. Если быть более конкретным, дерево решений принимается функцией beginWork
реконсиллера.
/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L3699
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
...
switch (workInProgress.tag) {
case IndeterminateComponent: {
return mountIndeterminateComponent(
current,
workInProgress,
workInProgress.type,
renderLanes,
);
}
case LazyComponent: {
const elementType = workInProgress.elementType;
return mountLazyComponent(
current,
workInProgress,
elementType,
renderLanes,
);
}
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case ClassComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
case HostComponent:
return updateHostComponent(current, workInProgress, renderLanes);
case HostText:
return updateHostText(current, workInProgress);
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);
case HostPortal:
return updatePortalComponent(current, workInProgress, renderLanes);
case ForwardRef: {
const type = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === type
? unresolvedProps
: resolveDefaultProps(type, unresolvedProps);
return updateForwardRef(
current,
workInProgress,
type,
resolvedProps,
renderLanes,
);
}
case Fragment:
return updateFragment(current, workInProgress, renderLanes);
case Mode:
return updateMode(current, workInProgress, renderLanes);
case Profiler:
return updateProfiler(current, workInProgress, renderLanes);
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes);
case ContextConsumer:
return updateContextConsumer(current, workInProgress, renderLanes);
case MemoComponent: {
const type = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
// Resolve outer props first, then resolve inner props.
let resolvedProps = resolveDefaultProps(type, unresolvedProps);
resolvedProps = resolveDefaultProps(type.type, resolvedProps);
return updateMemoComponent(
current,
workInProgress,
type,
resolvedProps,
renderLanes,
);
}
case SimpleMemoComponent: {
return updateSimpleMemoComponent(
current,
workInProgress,
workInProgress.type,
workInProgress.pendingProps,
renderLanes,
);
}
case IncompleteClassComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return mountIncompleteClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case SuspenseListComponent: {
return updateSuspenseListComponent(current, workInProgress, renderLanes);
}
case ScopeComponent: {
if (enableScopeAPI) {
return updateScopeComponent(current, workInProgress, renderLanes);
}
break;
}
case OffscreenComponent: {
return updateOffscreenComponent(current, workInProgress, renderLanes);
}
case LegacyHiddenComponent: {
if (enableLegacyHidden) {
return updateLegacyHiddenComponent(
current,
workInProgress,
renderLanes,
);
}
break;
}
case CacheComponent: {
if (enableCache) {
return updateCacheComponent(current, workInProgress, renderLanes);
}
break;
}
case TracingMarkerComponent: {
if (enableTransitionTracing) {
return updateTracingMarkerComponent(
current,
workInProgress,
renderLanes,
);
}
break;
}
}
...
}
С точки зрения мемоизации, монитруется компонент или обновляется, большой разницы нет. В конечном итоге, соответствующие методы обновления компонента будут вызваны в любом случае. Разница только в том, будут ли в эти методы переданы предыдущие значения свойств, состояния или зависимостей. Оставлю процесс трансформации монтируемыех типов в обновляемые для следующих публикаций и предлагаю перейти непосредственно к методам обновления конкретных узлов.
ClassComponent
Из наименования понятно, что данный тип присваивается классовым компонентам. На всякий случай, вспомним, как создается классовый компонент.
class MyComponent extends React.Component<{ prop1: string }> {
render() {
return (
<div>
{this.props.prop1}
</div>
);
}
}
В приведенном выше beginWork
обновление классовых компонентов начинается следующим образом.
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
Первое, что тут бросается в глаза, это константа resolvedProps
. Не смотря на аж 5 строк кода, эта константа всего лишь берет свойство Fiber.pendingProps
, которое является ничем иным, как объектом с новыми свойствами компонента. Дабы не оставлять неясности, приведу и код функции resolveDefaultProps
.
/packages/react-reconciler/src/ReactFiberLazyComponent.new.js#L12
export function resolveDefaultProps(Component: any, baseProps: Object): Object {
if (Component && Component.defaultProps) {
// Resolve default props. Taken from ReactElement
const props = assign({}, baseProps);
const defaultProps = Component.defaultProps;
for (const propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
return props;
}
return baseProps;
}
В переводе на человеческий язык, эта функция всего лишь присваивает дефолтные значения свойствам, если таковые были указаны при создании компонента.
class MyComponent extends React.Component<{ color?: string }> {
static defaultProps = {
color: 'blue',
};
render() {
return (
<div>
{this.props.color}
</div>
);
}
}
<MyComponent /> // <div>blue</div>
<MyComponent color="red" /> // <div>red</div>
Сам же механизм обновления вынесен в функцию updateClassComponent
. Приведу её код в сокращенном виде, так как часть функционала в ней отвечает за обработку провайдера контекста и некоторые служебные операции для dev-mode.
/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L1062
function updateClassComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps: any,
renderLanes: Lanes,
) {
...
const instance = workInProgress.stateNode;
let shouldUpdate;
if (instance === null) {
resetSuspendedCurrentOnMountInLegacyMode(current, workInProgress);
// In the initial pass we might need to construct the instance.
constructClassInstance(workInProgress, Component, nextProps);
mountClassInstance(workInProgress, Component, nextProps, renderLanes);
shouldUpdate = true;
} else if (current === null) {
// In a resume, we'll already have an instance we can reuse.
shouldUpdate = resumeMountClassInstance(
workInProgress,
Component,
nextProps,
renderLanes,
);
} else {
shouldUpdate = updateClassInstance(
current,
workInProgress,
Component,
nextProps,
renderLanes,
);
}
const nextUnitOfWork = finishClassComponent(
current,
workInProgress,
Component,
shouldUpdate,
hasContext,
renderLanes,
);
...
return nextUnitOfWork;
}
Первые два if здесь отвечают за процесс монтирования нового компонента. Как я уже говорил выше, отдельно эти процессы разбирать не будем, так как в конечном итоге цикл все равно прийдет к функции обновления и вся работа с мемоизация будет происходить именно там. А именно, в функции updateClassInstance
, её я приведу целиком.
/packages/react-reconciler/src/ReactFiberClassComponent.new.js#L1123
// Invokes the update life-cycles and returns false if it shouldn't rerender.
function updateClassInstance(
current: Fiber,
workInProgress: Fiber,
ctor: any,
newProps: any,
renderLanes: Lanes,
): boolean {
const instance = workInProgress.stateNode;
cloneUpdateQueue(current, workInProgress);
const unresolvedOldProps = workInProgress.memoizedProps;
const oldProps =
workInProgress.type === workInProgress.elementType
? unresolvedOldProps
: resolveDefaultProps(workInProgress.type, unresolvedOldProps);
instance.props = oldProps;
const unresolvedNewProps = workInProgress.pendingProps;
const oldContext = instance.context;
const contextType = ctor.contextType;
let nextContext = emptyContextObject;
if (typeof contextType === 'object' && contextType !== null) {
nextContext = readContext(contextType);
} else if (!disableLegacyContext) {
const nextUnmaskedContext = getUnmaskedContext(workInProgress, ctor, true);
nextContext = getMaskedContext(workInProgress, nextUnmaskedContext);
}
const getDerivedStateFromProps = ctor.getDerivedStateFromProps;
const hasNewLifecycles =
typeof getDerivedStateFromProps === 'function' ||
typeof instance.getSnapshotBeforeUpdate === 'function';
// Note: During these life-cycles, instance.props/instance.state are what
// ever the previously attempted to render - not the "current". However,
// during componentDidUpdate we pass the "current" props.
// In order to support react-lifecycles-compat polyfilled components,
// Unsafe lifecycles should not be invoked for components using the new APIs.
if (
!hasNewLifecycles &&
(typeof instance.UNSAFE_componentWillReceiveProps === 'function' ||
typeof instance.componentWillReceiveProps === 'function')
) {
if (
unresolvedOldProps !== unresolvedNewProps ||
oldContext !== nextContext
) {
callComponentWillReceiveProps(
workInProgress,
instance,
newProps,
nextContext,
);
}
}
resetHasForceUpdateBeforeProcessing();
const oldState = workInProgress.memoizedState;
let newState = (instance.state = oldState);
processUpdateQueue(workInProgress, newProps, instance, renderLanes);
newState = workInProgress.memoizedState;
if (
unresolvedOldProps === unresolvedNewProps &&
oldState === newState &&
!hasContextChanged() &&
!checkHasForceUpdateAfterProcessing() &&
!(
enableLazyContextPropagation &&
current !== null &&
current.dependencies !== null &&
checkIfContextChanged(current.dependencies)
)
) {
// If an update was already in progress, we should schedule an Update
// effect even though we're bailing out, so that cWU/cDU are called.
if (typeof instance.componentDidUpdate === 'function') {
if (
unresolvedOldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.flags |= Update;
}
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
if (
unresolvedOldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.flags |= Snapshot;
}
}
return false;
}
if (typeof getDerivedStateFromProps === 'function') {
applyDerivedStateFromProps(
workInProgress,
ctor,
getDerivedStateFromProps,
newProps,
);
newState = workInProgress.memoizedState;
}
const shouldUpdate =
checkHasForceUpdateAfterProcessing() ||
checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext,
) ||
// TODO: In some cases, we'll end up checking if context has changed twice,
// both before and after `shouldComponentUpdate` has been called. Not ideal,
// but I'm loath to refactor this function. This only happens for memoized
// components so it's not that common.
(enableLazyContextPropagation &&
current !== null &&
current.dependencies !== null &&
checkIfContextChanged(current.dependencies));
if (shouldUpdate) {
// In order to support react-lifecycles-compat polyfilled components,
// Unsafe lifecycles should not be invoked for components using the new APIs.
if (
!hasNewLifecycles &&
(typeof instance.UNSAFE_componentWillUpdate === 'function' ||
typeof instance.componentWillUpdate === 'function')
) {
if (typeof instance.componentWillUpdate === 'function') {
instance.componentWillUpdate(newProps, newState, nextContext);
}
if (typeof instance.UNSAFE_componentWillUpdate === 'function') {
instance.UNSAFE_componentWillUpdate(newProps, newState, nextContext);
}
}
if (typeof instance.componentDidUpdate === 'function') {
workInProgress.flags |= Update;
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
workInProgress.flags |= Snapshot;
}
} else {
// If an update was already in progress, we should schedule an Update
// effect even though we're bailing out, so that cWU/cDU are called.
if (typeof instance.componentDidUpdate === 'function') {
if (
unresolvedOldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.flags |= Update;
}
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
if (
unresolvedOldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.flags |= Snapshot;
}
}
// If shouldComponentUpdate returned false, we should still update the
// memoized props/state to indicate that this work can be reused.
workInProgress.memoizedProps = newProps;
workInProgress.memoizedState = newState;
}
// Update the existing instance's state, props, and context pointers even
// if shouldComponentUpdate returns false.
instance.props = newProps;
instance.state = newState;
instance.context = nextContext;
return shouldUpdate;
}
Выглядит довольно громоздко, но что поделать? Именно здесь и происходит вся магия жизненного цикла классовых компонентов. Давайте разбираться по порядку.
const unresolvedOldProps = workInProgress.memoizedProps;
const oldProps =
workInProgress.type === workInProgress.elementType
? unresolvedOldProps
: resolveDefaultProps(workInProgress.type, unresolvedOldProps);
instance.props = oldProps;
const unresolvedNewProps = workInProgress.pendingProps;
Что такое мы уже видели раньше. Суть этого кода получить две константы: unresolvedOldProps
, который сразу записывается в this.props
экземпляра компонента и unresolvedNewProps
. Эти константы далее будут участвовать в методах жизненного цикла и в самой мемоизации в том числе.
const oldContext = instance.context;
const contextType = ctor.contextType;
let nextContext = emptyContextObject;
if (typeof contextType === 'object' && contextType !== null) {
nextContext = readContext(contextType);
} else if (!disableLegacyContext) {
const nextUnmaskedContext = getUnmaskedContext(workInProgress, ctor, true);
nextContext = getMaskedContext(workInProgress, nextUnmaskedContext);
}
Следующий блок отвечает за резолв привязанного, компоненту, контекста. Подробно эту часть здесь разбираться мы не будем, но переменные oldContext
и nextContext
участвует в методах жизненного цикла на ряду с unresolvedOldProps
и unresolvedNewProps
, поэтому обозначить их стоит.
const getDerivedStateFromProps = ctor.getDerivedStateFromProps;
const hasNewLifecycles =
typeof getDerivedStateFromProps === 'function' ||
typeof instance.getSnapshotBeforeUpdate === 'function';
// Note: During these life-cycles, instance.props/instance.state are what
// ever the previously attempted to render - not the "current". However,
// during componentDidUpdate we pass the "current" props.
// In order to support react-lifecycles-compat polyfilled components,
// Unsafe lifecycles should not be invoked for components using the new APIs.
if (
!hasNewLifecycles &&
(typeof instance.UNSAFE_componentWillReceiveProps === 'function' ||
typeof instance.componentWillReceiveProps === 'function')
) {
if (
unresolvedOldProps !== unresolvedNewProps ||
oldContext !== nextContext
) {
callComponentWillReceiveProps(
workInProgress,
instance,
newProps,
nextContext,
);
}
}
Еще один блок, который я так же не могу оставить без внимания. Прежде чем приступить к следующим этапа жизненного цикла, необходимо сначала выполнить метод componentWillReceiveProps(nextProps), если он был определен в компоненте. А интересно здесь то, что метод будет вызван только в том случае, если компонент не использует новое API, т.е. если в нем нет методов getDerivedStateFromProps(props, state) и getSnapshotBeforeUpdate(prevProps, prevState).
const oldState = workInProgress.memoizedState;
let newState = (instance.state = oldState);
processUpdateQueue(workInProgress, newProps, instance, renderLanes);
newState = workInProgress.memoizedState;
И, наконец четвертый блок. Переменные oldState
и newState
, которые так же участвуют в жизненном цикле компонента. Здесь остановимся чуть поподробнее. Если с oldState
все очевидно, он берется из Fiber.memoizedState
, то вот newState
требует пояснений.
По умолчанию, newState
присваивается значение текущего oldState
. Далее исполняется цикл updateQueue
, инициированный вызовом processUpdateQueue
. Приводить код этой функции здесь не буду, так как он тоже довольно большой и вариативный и только отвлечет нас от темы статьи. Главно, что нам нужно тут понять, что эта функция, в конечно итоге сформирует новый State и запишет его в workInProgress.memoizedState
.
/packages/react-reconciler/src/ReactFiberClassUpdateQueue.new.js#L458
export function processUpdateQueue<State>(
...
// These values may change as we process the queue.
if (firstBaseUpdate !== null) {
...
workInProgress.memoizedState = newState;
}
}
После чего мы и получаем свою переменную newState
нашей update-функции.
На это сбор основной информации о компоненте заканчивается и начинается, собственно, сам процесс обновления.
if (
unresolvedOldProps === unresolvedNewProps &&
oldState === newState &&
!hasContextChanged() &&
!checkHasForceUpdateAfterProcessing() &&
!(
enableLazyContextPropagation &&
current !== null &&
current.dependencies !== null &&
checkIfContextChanged(current.dependencies)
)
) {
// If an update was already in progress, we should schedule an Update
// effect even though we're bailing out, so that cWU/cDU are called.
if (typeof instance.componentDidUpdate === 'function') {
if (
unresolvedOldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.flags |= Update;
}
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
if (
unresolvedOldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.flags |= Snapshot;
}
}
return false;
}
Нет, это пока еще не обновление, как могло показаться на первый взгляд. Прежде чем к нему приступить, реконсиллер, в целях оптимизации исключает лишние обработки. В данном случае, если ссылки на свойства компонента и на его стейт не изменились и не был вызван forceUpdate(), смысла в дальнейшем процессе просто нет и вся функция завершается.
if (typeof getDerivedStateFromProps === 'function') {
applyDerivedStateFromProps(
workInProgress,
ctor,
getDerivedStateFromProps,
newProps,
);
newState = workInProgress.memoizedState;
}
Вот теперь, если всё же что-то в состоянии компонента поменялось, можно переходить к его обновлению. И первым, что необходимо сделать, это вызвать getDerivedStateFromProps(props, state), так как этот меняет меняет State компонента и, соответственно, переменную newState в update-функции.
const shouldUpdate =
checkHasForceUpdateAfterProcessing() ||
checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext,
) ||
// TODO: In some cases, we'll end up checking if context has changed twice,
// both before and after `shouldComponentUpdate` has been called. Not ideal,
// but I'm loath to refactor this function. This only happens for memoized
// components so it's not that common.
(enableLazyContextPropagation &&
current !== null &&
current.dependencies !== null &&
checkIfContextChanged(current.dependencies));
Наконец, все константы и переменные собраны и теперь можно принять решение о дальнейшем продвижении по жизненному циклу, точнее, будет ли перерисован компонент. А перерисован компонент должен быть только в двух случаях, если значения свойств, стейта или контекст отличают от предыдущих или если был вызван forceUpdate(). Последнее определяется функцией checkHasForceUpdateAfterProcessing()
, а вот сравнение значений происходит внутри checkShouldComponentUpdate()
. Остановимся на этой функции поподробнее.
/packages/react-reconciler/src/ReactFiberClassComponent.new.js#L305
function checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext,
) {
const instance = workInProgress.stateNode;
if (typeof instance.shouldComponentUpdate === 'function') {
let shouldUpdate = instance.shouldComponentUpdate(
newProps,
newState,
nextContext,
);
...
return shouldUpdate;
}
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
return (
!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
);
}
return true;
}
Если убрать из функции служебный dev-mode код, она на удивление довольно простая. Функция предполагает всего три сценария:
Компонент имеет метод shouldComponentUpdate(). В этом случае, решение о том, должен ли быть перерисован компонент возлагается полностью на разработчика.
В случае
PureComponent
проводится shallow сравнение свойств и стейта (об это подробнее поговорим чуть ниже).Во всех остальных случаях компонент будет перерисован обязательно.
if (shouldUpdate) {
// In order to support react-lifecycles-compat polyfilled components,
// Unsafe lifecycles should not be invoked for components using the new APIs.
if (
!hasNewLifecycles &&
(typeof instance.UNSAFE_componentWillUpdate === 'function' ||
typeof instance.componentWillUpdate === 'function')
) {
if (typeof instance.componentWillUpdate === 'function') {
instance.componentWillUpdate(newProps, newState, nextContext);
}
if (typeof instance.UNSAFE_componentWillUpdate === 'function') {
instance.UNSAFE_componentWillUpdate(newProps, newState, nextContext);
}
}
if (typeof instance.componentDidUpdate === 'function') {
workInProgress.flags |= Update;
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
workInProgress.flags |= Snapshot;
}
} else {
// If an update was already in progress, we should schedule an Update
// effect even though we're bailing out, so that cWU/cDU are called.
if (typeof instance.componentDidUpdate === 'function') {
if (
unresolvedOldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.flags |= Update;
}
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
if (
unresolvedOldProps !== current.memoizedProps ||
oldState !== current.memoizedState
) {
workInProgress.flags |= Snapshot;
}
}
// If shouldComponentUpdate returned false, we should still update the
// memoized props/state to indicate that this work can be reused.
workInProgress.memoizedProps = newProps;
workInProgress.memoizedState = newState;
}
Последним, ну почти последним, этапом является выполнение оставшихся методов жизненного цикла. В случае, если компонент должен быть перерисован, будут выполнены методы:
componentWillUpdate
(только если не используется новое API)UNSAFE_componentWillUpdate
(только если не используется новое API)componentDidUpdate
getSnapshotBeforeUpdate
В противном же случае эти методы вызваны не будут, будут проставлены только нужные служебные флаги, а свойства и стейт будут записаны в memoizedProps и memoizedState соответсвенно.
// Update the existing instance's state, props, and context pointers even
// if shouldComponentUpdate returns false.
instance.props = newProps;
instance.state = newState;
instance.context = nextContext;
return shouldUpdate;
И, наконец, последний этап. Новые свойства, стейт и значение контекста присваиваются экземпляру класса, после чего функция завершается.
Поверхностное (shallow) сравнение
Это понятие уже прозвучало чуть выше. Давайте разберем его детальнее. Мы говорили о том, что такое сравнение происходит в случае реализации PureComponent. На всякий случай вспомним, что такое PureComponent. Воспользуемся для этого примером выше и слегка его изменим.
class MyComponent extends React.PureComponent<{ color?: string }> {
static defaultProps = {
color: 'blue',
};
render() {
return (
<div>
{this.props.color}
</div>
);
}
}
Как вы видите, разница только в том, от какого родителя мы наследуем наш классовый компонент (в исходном варианты мы наследовали от React.Component
). Именно в таком компоненте и будет применено shallow сравнение.
Так что же такое shallow сравнение? Пожалуй, лучшим ответом на этот вопрос будет код самой функции shallowEqual
, которую мы видели ранее.
/packages/shared/shallowEqual.js#L13
/**
* Performs equality by iterating through keys on an object and returning false
* when any key has values which are not strictly equal between the arguments.
* Returns true when the values of all keys are strictly equal.
*/
function shallowEqual(objA: mixed, objB: mixed): boolean {
if (is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (
!hasOwnProperty.call(objB, currentKey) ||
!is(objA[currentKey], objB[currentKey])
) {
return false;
}
}
return true;
}
Эта утилита условно состоит из двух частей:
Сравнение переменных
objA
иobjB
.Если переменные равны или равны ссылки на объекты, которые они представляют, происходит сравнение каждого из свойств этих объектов.
Само же сравнение осуществляется другой утилитой is()
.
/packages/shared/objectIs.js#L10
/**
* inlined Object.is polyfill to avoid requiring consumers ship their own
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
*/
function is(x: any, y: any) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
);
}
const objectIs: (x: any, y: any) => boolean =
typeof Object.is === 'function' ? Object.is : is;
Которая является ничем иным, как функцией Web API - Object.is(). В случае, если браузер не поддерживыет данный метод Web API, используется встроенный полифил.
Если коротко, два значения счиются одинаковыми, если:
оба равны
undefined
оба равны
null
оба равны
true
, либо оба равныfalse
оба являются строками с одинаковой длиной и одинаковыми символами
оба являются одним и тем же объектом
оба являются
BigInt
с одинаковым числовым значениемоба являются
Symbol
, которые ссылаются на на один и тот же символ-
оба являются числами и
оба равны
+0
оба равны
-0
оба равны
NaN
либо оба не равны нулю или
NaN
и оба имеют одинаковое значение
FunctionComponent
С классовыми компонентами разобрались, давайте посмотрим теперь в функциональные.
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
В самом начале фазы begin инициация обновления функционального компонента ничем не отличается от обновления классового. За исключением самой update-функции.
/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L965
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderLanes,
) {
...
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
);
hasId = checkDidRenderIdHook();
...
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
if (getIsHydrating() && hasId) {
pushMaterializedTreeId(workInProgress);
}
...
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
А именно, ключевым здесь является запуск отдельного flow под названием renderWithHooks
, характерного для функциональных компонентов. Давайте посмотрим, что там происходит.
/packages/react-reconciler/src/ReactFiberHooks.new.js#L374
export function renderWithHooks<Props, SecondArg>(
current: Fiber | null,
workInProgress: Fiber,
Component: (p: Props, arg: SecondArg) => any,
props: Props,
secondArg: SecondArg,
nextRenderLanes: Lanes,
): any {
renderLanes = nextRenderLanes;
currentlyRenderingFiber = workInProgress;
...
workInProgress.memoizedState = null;
workInProgress.updateQueue = null;
workInProgress.lanes = NoLanes;
// The following should have already been reset
// currentHook = null;
// workInProgressHook = null;
// didScheduleRenderPhaseUpdate = false;
// localIdCounter = 0;
// TODO Warn if no hooks are used at all during mount, then some are used during update.
// Currently we will identify the update render as a mount because memoizedState === null.
// This is tricky because it's valid for certain types of components (e.g. React.lazy)
// Using memoizedState to differentiate between mount/update only works if at least one stateful hook is used.
// Non-stateful hooks (e.g. context) don't get added to memoizedState,
// so memoizedState would be null during updates and mounts.
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
let children = Component(props, secondArg);
// Check if there was a render phase update
if (didScheduleRenderPhaseUpdateDuringThisPass) {
// Keep rendering in a loop for as long as render phase updates continue to
// be scheduled. Use a counter to prevent infinite loops.
let numberOfReRenders: number = 0;
do {
didScheduleRenderPhaseUpdateDuringThisPass = false;
localIdCounter = 0;
if (numberOfReRenders >= RE_RENDER_LIMIT) {
throw new Error(
'Too many re-renders. React limits the number of renders to prevent ' +
'an infinite loop.',
);
}
numberOfReRenders += 1;
// Start over from the beginning of the list
currentHook = null;
workInProgressHook = null;
workInProgress.updateQueue = null;
ReactCurrentDispatcher.current = __DEV__
? HooksDispatcherOnRerenderInDEV
: HooksDispatcherOnRerender;
children = Component(props, secondArg);
} while (didScheduleRenderPhaseUpdateDuringThisPass);
}
// We can assume the previous dispatcher is always this one, since we set it
// at the beginning of the render phase and there's no re-entrance.
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
// This check uses currentHook so that it works the same in DEV and prod bundles.
// hookTypesDev could catch more cases (e.g. context) but only in DEV bundles.
const didRenderTooFewHooks =
currentHook !== null && currentHook.next !== null;
renderLanes = NoLanes;
currentlyRenderingFiber = (null: any);
currentHook = null;
workInProgressHook = null;
didScheduleRenderPhaseUpdate = false;
// This is reset by checkDidRenderIdHook
// localIdCounter = 0;
if (didRenderTooFewHooks) {
throw new Error(
'Rendered fewer hooks than expected. This may be caused by an accidental ' +
'early return statement.',
);
}
if (enableLazyContextPropagation) {
if (current !== null) {
if (!checkIfWorkInProgressReceivedUpdate()) {
// If there were no changes to props or state, we need to check if there
// was a context change. We didn't already do this because there's no
// 1:1 correspondence between dependencies and hooks. Although, because
// there almost always is in the common case (`readContext` is an
// internal API), we could compare in there. OTOH, we only hit this case
// if everything else bails out, so on the whole it might be better to
// keep the comparison out of the common path.
const currentDependencies = current.dependencies;
if (
currentDependencies !== null &&
checkIfContextChanged(currentDependencies)
) {
markWorkInProgressReceivedUpdate();
}
}
}
}
return children;
}
Функция может показаться большой и сложной, но по сути, она делает всего две вещи. Первое, определяет диспатчер хуков, который будет отвечать за дальнейшее выполнение самих этих хуков. Формально в React существует всего 4 таких диспатчера:
ContextOnlyDispatcher
HooksDispatcherOnMount
HooksDispatcherOnUpdate
HooksDispatcherOnRender
На самом же деле, ContextOnlyDispatcher
не реализует никаких, он является своего рода диспатчером по умолчанию и выставляется до тех пор, пока движок не определил более релевантный диспатчер текущему компоненту.
Из оставшихся трех, HooksDispatcherOnUpdate
и HooksDispatcherOnRender
на практике ничем не отличаются и оба ведут на одни и те же реализации update-хуков (updateMemo
, updateCallback
и т.д.). Выделение двух разных диспатчеров здесь носит исключительно логический характер, на случай, если разработчикам React когда-нибудь понадобится сделать разные версии хуков под разные фазы. К в случае в HooksDispatcherOnMount
который ведет к отедельным реализациям mount-хуков (mountMemo
, mountCallback
, и т.д.).
Про хуки и их провайдеры поговорим чуть ниже. Пока зафиксируем только тот факт, что у каждого хука имеется две версии: mount и update. И на текущем этапе важно определить, какую версию хуков движок будет исполнять. Делается следующим образом.
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
Где current
- ссылка на Fiber.alternate
. Если она равно null
, значит компонент еще ни раз не был создан. Аналогично, если Fiber.alternate.memoizedState
пустой, то хуки еще в этом компоненте еще ни разу не выполнялись. В обоих случаях будет применен диспатчер HooksDispatcherOnMount
. В противном же случае - HooksDispatcherOnUpdate
.
Второе, что делает функция - создает компонент посредством let children = Component(props, secondArg)
и повторяет этот процесс в цикле, до тех пор, пока в текущей фазе не будут выполнены все запланированные обновления. И да, именно здесь и выбрасывается то самое исключение Too many re-renders. React limits the number of renders to prevent an infinite loop.
, если количество обновлений превысит 25 итераций.
HooksDispatcherOnMount
Код диспатчера выглядит следующим образом
/packages/react-reconciler/src/ReactFiberHooks.new.js#L2427
const HooksDispatcherOnMount: Dispatcher = {
readContext,
useCallback: mountCallback,
useContext: readContext,
useEffect: mountEffect,
useImperativeHandle: mountImperativeHandle,
useLayoutEffect: mountLayoutEffect,
useInsertionEffect: mountInsertionEffect,
useMemo: mountMemo,
useReducer: mountReducer,
useRef: mountRef,
useState: mountState,
useDebugValue: mountDebugValue,
useDeferredValue: mountDeferredValue,
useTransition: mountTransition,
useMutableSource: mountMutableSource,
useSyncExternalStore: mountSyncExternalStore,
useId: mountId,
unstable_isNewReconciler: enableNewReconciler,
};
if (enableCache) {
(HooksDispatcherOnMount: Dispatcher).getCacheSignal = getCacheSignal;
(HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType;
(HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh;
}
Все mount-функции хуков мы рассматривать не будем, важнее понять суть этих функций. Её и посмотрим на примере mountMemo
.
/packages/react-reconciler/src/ReactFiberHooks.new.js#L1899
function mountMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null,
): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
Как можно видеть, код фука до неприличия прост. Сначала определяется ссылка на Fiber. Затем производится вычисление значения, это происходит в пользовательском колбэке nextCreate
. Далее, вычисленное значение и массив зависимостей сохраняются в Fiber.memoizedState
. Вот она, мемоизация!
HooksDispatcherOnUpdate
Теперь заглянем в диспатчер HooksDispatcherOnUpdate
.
/packages/react-reconciler/src/ReactFiberHooks.new.js#L2454
const HooksDispatcherOnUpdate: Dispatcher = {
readContext,
useCallback: updateCallback,
useContext: readContext,
useEffect: updateEffect,
useImperativeHandle: updateImperativeHandle,
useInsertionEffect: updateInsertionEffect,
useLayoutEffect: updateLayoutEffect,
useMemo: updateMemo,
useReducer: updateReducer,
useRef: updateRef,
useState: updateState,
useDebugValue: updateDebugValue,
useDeferredValue: updateDeferredValue,
useTransition: updateTransition,
useMutableSource: updateMutableSource,
useSyncExternalStore: updateSyncExternalStore,
useId: updateId,
unstable_isNewReconciler: enableNewReconciler,
};
if (enableCache) {
(HooksDispatcherOnUpdate: Dispatcher).getCacheSignal = getCacheSignal;
(HooksDispatcherOnUpdate: Dispatcher).getCacheForType = getCacheForType;
(HooksDispatcherOnUpdate: Dispatcher).useCacheRefresh = updateRefresh;
}
Как я и говорил ранее, этот диспатчер отличается от предыдущего тем, что ведет на update-версии тех же самы хуков. И раз уж в предыдущем примере мы смотрели в mountMemo
, в этот раз давайте заглянем в updateMemo
.
/packages/react-reconciler/src/ReactFiberHooks.new.js#L1910
function updateMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null,
): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
// Assume these are defined. If they're not, areHookInputsEqual will warn.
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
А вот тут уже имеет место эффект памяти, который инициировали на этапе монтирования. Теперь, прежде чем приступить к вычислениям, хук возьмет предыдущие зависимости из Fiber.memoizedState[1]
и сравнит их с текущими посредством функции areHookInputsEqual(nextDeps, prevDeps)
.
/packages/react-reconciler/src/ReactFiberHooks.new.js#L327
function areHookInputsEqual(
nextDeps: Array<mixed>,
prevDeps: Array<mixed> | null,
) {
...
if (prevDeps === null) {
if (__DEV__) {
console.error(
'%s received a final argument during this render, but not during ' +
'the previous render. Even though the final argument is optional, ' +
'its type cannot change between renders.',
currentHookNameInDev,
);
}
return false;
}
...
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
Которая в свою очередь, проводит поверхностное сравнение зависимостей с помощью всё того же Object.is.
MemoComponent
Настало время поговорить о третьем интересующим нас типе узла - MemoComponent
. Такой узел можно создать с помощью React.memo.
const MyComponent = React.memo<{ color?: string }>(({ color }) => {
return <div>{color}</div>;
});
Такой компонент будет перерисовываться только в том случае, если изменились переданные ему свойства. Выглядит это следующим образом:
const type = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
// Resolve outer props first, then resolve inner props.
let resolvedProps = resolveDefaultProps(type, unresolvedProps);
resolvedProps = resolveDefaultProps(type.type, resolvedProps);
return updateMemoComponent(
current,
workInProgress,
type,
resolvedProps,
renderLanes,
);
Update-функция для таких компонентов называется updateMemoComponent
. Функция довольно длинная, приведу только ту её часть, которая нам нужна в рамках этой статьи.
/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L452
function updateMemoComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps: any,
renderLanes: Lanes,
): null | Fiber {
...
const currentChild = ((current.child: any): Fiber); // This is always exactly one child
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes,
);
if (!hasScheduledUpdateOrContext) {
// This will be the props with resolved defaultProps,
// unlike current.memoizedProps which will be the unresolved ones.
const prevProps = currentChild.memoizedProps;
// Default to shallow comparison
let compare = Component.compare;
compare = compare !== null ? compare : shallowEqual;
if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
// React DevTools reads this flag.
workInProgress.flags |= PerformedWork;
const newChild = createWorkInProgress(currentChild, nextProps);
newChild.ref = workInProgress.ref;
newChild.return = workInProgress;
workInProgress.child = newChild;
return newChild;
}
Собственно, вся суть этого кода сводится к вызову функции сравнения compare(prevProps, nextProps)
, где compare
может быть как пользовательской функцией (второй аргумент React.memo) или shallowEqual
по умолчанию, её мы уже видели выше.
Заключение
В этой статье мы заглянули "под капот" движка React и посмотрели на механизмы монтирования и обновления узлов. Одним из важнейших механизмов React, участвующим в этих процессах является меомизация. Её мы разобрали на примерах трех типов Fiber: ClassComponent
, FunctionComponent
и MemoComponent
.
И покуда мы теперь лучше понимаем эти процессы, самое время сделать некоторые выводы.
Будь то класс, функция или зависимости хука, React в качестве фукнции-сравнения использует один и тот же подход - метод Object.is() Web API или его полифил.
Каждый хук имеет две версии реализации, mount и update. При чем update, как правило, более сложная функция, так как ей требуется произвести сравнение зависимостей. Разработчик не можем повлиять на то, какую именно версию хука будет использовать движок. Mount-версия сработает только при первом монтировании компонента.
Так как React, при обработке хука сравнивает каждую зависимость отдельно, не стоит добавлять лишние зависимости. Кроме того, сами зависимости хранятся в памяти, большое их количество может сказать потреблении ресурсов. Другими словами,
const value = useMemo(() => {
return a + b
}, [a, b]);
этот код будет чуть более производителен, чем следующий
const value = useMemo(() => {
return a + b
}, [a, b, c]);
Аналогично,
const SOME_CONST = "some value";
const MyComponent = ({ a, b }) => {
// этот хук более производителен
const value1 = useMemo(() => {
return a + SOME_CONST
}, [a]);
// чем этот
const value2 = useMemo(() => {
return b + SOME_CONST
}, [b, SOME_CONST]);
return null;
}
По той же причине стоит адекватно оценивать необходимость в мемоизации в целом, например,
const isEqual = useMemo(() => {
return a === b
}, [a, b]);
Не даст никакого прироста в производительности. Даже наоборот, движок будет вынужден каждый раз сравнивать prevDeps.a === currDeps.a
и prevDeps.b === currDeps.b
. К тому же, в коде появляется дополнительная функция, которая будет потреблять свой ресурс.
Предыдущий тезис касается и React.memo. Разработчик должен понимать, будет ли эффект от мемоизации выше накладных расходов на его её поддержание.
Эту и другие мои статьи, так же, читайте в моем канале:
RU: https://t.me/frontend_almanac_ru
EN: https://t.me/frontend_almanac