React. Обновление узлов и мемоизация
В процессе разработки современных веб-приложений производительность часто становится одним из ключевых аспектов, которые волнуют и разработчиков, и пользователей. Пользователи ожидают молниеносного отклика, а разработчики стремятся создать приложения, которые работают быстро и эффективно.
Одним из мощных инструментов, позволяющих достигнуть высокой производительности в 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
, которые ссылаются на на один и тот же символ - оба являются числами и
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
не реализует никаких, он является своего рода диспатчером по умолчанию и выставляется до тех пор, пока движок не определил более релевантный диспатчер текущему компоненту.
Из оставшихся трех, 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. Разработчик должен понимать, будет ли эффект от мемоизации выше накладных расходов на его её поддержание.
EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru
English version: https://blog.frontend-almanac.com/react-memoization