Привет, Хабр!
В прошлых моих статьях обсуждая useMemo
, мы постоянно упоминали React.memo
. Поэтому в этой статье я решил глубоко погрузиться в понятие, что из себя представляет React.memo
и конечно же изучим его исходники. (Данная статья является расшифровкой двух видео: React.memo это вам не useMemo и Чем отличается SimpleMemoComponent от MemoComponent?)
HOC это вам не hook
Самое главное отличие между useMemo
и memo
. Это то что первый является hook-ом, а второй HOC-ом, а именно Higher-Order Component или же Компонент Высшего Порядка.
hook
Чтобы более наглядно объяснить, рассмотрим визуализацию. Как вы знаете существует виртуальное дерево состоящее из нод. И у ноды могут буть хуки. При первом рендере их соединяют в linked list, или в переводе связанный список, который называют currentHooks.
Потом начинается новый рендер и мы начинаем собирать второй список хуков workInProgressHooks, который берет часть данных из currentHooks. И по окончания строительства workInProgressHooks, currentHooks удаляется и workInProgressHooks переименовывается в currentHooks. И снова все повторяется. И все это происходит в рамках одной ноды.
HOC
С другой стороны Компонент Высшего Порядка это и есть нода, т.е. между нодой родителя и мемоизированного компонента вставляется еще одна нода, которая сравнивает prevProps
с currentProps
и решает судьбу своих детей.
Таким образом это две принципиально разные сущности и обе по разному обрабатываются внутри React. И как следствие исходники memo будут сильно отличаться от исходников useMemo. Давайте перейдем в исходники и удостоверимся, так ли это на самом деле.
Исходники React.memo
Компонент высшего порядка memo находится в пакете react в файле ReactMemo.js. Если убрать все DEV блоки, в таком случае весь код сведется к паре строк
export function memo<Props>(
type: React$ElementType,
compare?: (oldProps: Props, newProps: Props) => boolean,
) {
const elementType = {
$$typeof: REACT_MEMO_TYPE,
type,
compare: compare === undefined ? null : compare,
};
return elementType;
}
Принимает два параметра. Первый type
- это непосредственно компонент, который мы оборачиваем в memo
. Компонент представляет из себя либо класс, либо функцию.
Второй параметр compare
- это функция, необязательный параметр, которая получит предыдущие props
и текущие props
, а в результате должна вернуть boolean
значение. Эта функция аналог shouldComponentUpdate
. В котором вы можете сами прописать правила, когда стоит обновлять компонент, а когда не стоит.
Создание объекта elementType
- это все что делает HOC memo. Хорошо, мы поняли, что memo создает какой-то дополнительный объект, а как понять, что этот объект означает?
Рассмотрим как выглядит виртуальная нода
Для этого давайте рассмотрим следующий код:
const Header = () => {
const title = <h1>test</h1>
console.log('title = ', title);
return title;
};
Как вы думаете что мы увидим в консоли?
Если хотите самостоятельно поразмышлять, тогда не торопитесь скролить вниз
...
..
.
Вот код который вы увидите в консоли:
{
$$typeof: Symbol('react.element'),
type: 'h1',
props: {
children: 'test',
}
// ...
}
Здесь мы видим что $$typeof
равен Symbol('react.element')
- это означает, что нода является html
тегом. Переменная type
хранит в себе имя тега, в нашем случае h1
. Далее мы видим props
внутри которых видим children
c текстом test
. Это тот самый текст который мы передали в h1
.
Структура объекта очень похожа на ту, что мы видели в исходниках React.memo. Давайте тогда создадим MemoComponent и так же выведем console.log
этого компонента.
const Header = () => {
return <h1>test</h1>;
};
console.log(memo(Header));
В консоли мы увидим следующий объект:
{
$$typeof: Symbol('react.memo'),
type: () => { return <h1>test</h1> },
compare: null,
// ...
}
Как вы видите, здесь так же есть это странное поле $$typeof
с двумя знаками доллара. И поле type
присутствует. Именно так и выглядят ноды в виртуальном дереве. Это две абсолютно равноценные ноды. Т.к. у них разные $$typeof
, соответственно и набор переменных может отличаться. В случае тега h1
это props
, ref
, key
и другие. С другой стороны у мемоизированной ноды есть поле compare
.
Если вам интересно какие еще типы нод существую вы можете перейти в пакет shared файл ReactSymbols.js. Где вы первым типом увидите react.element
, который означает любой html
тег. Так же здесь известные всем portal
, fragment
, context
, forward_ref
и другие. Об этом можно даже сделать отдельное видео, чтобы познакомиться со всеми типами.
Что дальше происходит с виртуальной memo нодой
Я решил не останавливаться в изучении React.memo на создании объекта elementType
. И решил найти место, где происходит обработка этого объекта. И кажется, я нашел такое место. Оно находится в пакете react-reconciler в файле ReactFiber.new.js. Там есть такой метод createFiberFromTypeAndProps
. Из названия можно предположить, что здесь мы создаем Fiber
на основании type
и props
. Если вы не знаете, что такое Fiber
, тогда упрощенно представляйте, что это нода в виртуальном дереве. Я постараюсь как-нибудь в будущем написать на эту тему отдельную статью.
export function createFiberFromTypeAndProps(
type: any, // React$ElementType
key: null | string,
pendingProps: any,
owner: null | Fiber,
mode: TypeOfMode,
lanes: Lanes,
): Fiber {
// ...
}
Метод получает первым параметром type
. Я решил добавить console.log(type)
, чтобы разобраться, что из себя представляет type
. Оказалось, что это тот самый elementType
объект ноды, который мы создали ранее в React.memo.
Из-за того что type
является объектом, мы попадаем в следующий if
, который начинается со switch
конструкции, в которую мы передаем $$typeof
, который равен как мы знаем REACT_MEMO_TYPE
(ссылка на код)
function createFiberFromTypeAndProps( /* ... */ ) {
// ...
if (typeof type === 'object' && type !== null) {
switch (type.$$typeof) {
// ...
case REACT_MEMO_TYPE:
fiberTag = MemoComponent;
break;
// ...
}
}
// ...
const fiber = createFiber(fiberTag, pendingProps, key, mode);
fiber.elementType = type;
fiber.type = resolvedType;
fiber.lanes = lanes;
return fiber;
}
Сама case
конструкция, все что она делает - это сохраняет в какой-то fiberTag
значение MemoComponent
. И ниже уже на основании этого fiberTag
, вызывается метод createFiber
и создается fiber
нода.
Хорошо, теперь мы знаем, что существует какой-то fiberTab
равный MemoComponent
. Меня заинтересовало, а какие теги существуют еще. И я перешел по импорту в пакет react-reconciler файл ReactWorkTags.
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // Before we know whether it is function or class
export const Fragment = 7;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const SuspenseComponent = 13;
export const MemoComponent = 14; // <- MEMO COMPONENT
export const SimpleMemoComponent = 15; // <- SIMPLE MEMO COMPONENT
export const LazyComponent = 16;
// ...
Что меня тут заинтересовало, я увидел здесь, как MemoComponent
так и SimpleMemoComponent
. Мне стало интересно, разобраться, а как реакт определяет simple
этот MemoComponent
или не simple
.
Какой memo компонент называется simple?
Чтобы разобраться с понятием simple
или не simple
, я решил снова поискать, а где эти MemoComponent
и SimpleMemoComponent
обрабатываются и я нашел такое место в пакете react-reconciler в файле ReactFiberBeginWork. Сам метод называется beginWork
, с текущим нашим уровнем погружения в React будет немного сложно объяснить, что это означает, я постараюсь сделать отдельную статью, о фазах работы React-а. А пока перейдем к switch
конструкции принимающей tag
как входной параметр. И ниже мы найдем case MemoComponent
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...
switch (workInProgress.tag) {
// ...
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,
updateLanes,
renderLanes,
);
}
// ...
}
// ...
}
Внутри case MemoComponent
есть небольшой DEV блок, который резолвит propTypes, нас этот блок не интересует, поэтому я не добавлял его в код выше. Тогда остается, все что делает этот метод, это вызывает два раза resolveDefaultProps
и все это передается уже в updateMemoComponent
.
Вспоминаем, что такое defaultProps
Для тех кто уже может забыл, что такое defaultProps
, я напомню. Допустим у нас есть компонент котята, который возвращает набор котят, которым передали цвет котенка, кроме последнего. Для этого мы можем добавить компоненту defaultProps
. И указать, если цвет котенка не передан, тогда пусть он будет красным. И это работает.
const Cat = ({ color }) => {
return <p>cat color: {color}</p>
}
Cat.defaultProps = {
color: 'red',
};
const Cats = () => {
return (
<>
<Cat color="green" />
<Cat color="blue" />
<Cat />
</>
)
}
Эту конструкцию вы забыли или возможно никогда не использовали, потому что гораздо проще и понятнее указать дефолтный цвет на уровне JavaScript через знак равно и работать это будет точно так же
const Cat = ({ color = 'red' }) => {
return <p>cat color: {color}</p>
}
const Cats = () => {
return (
<>
<Cat color="green" />
<Cat color="blue" />
<Cat />
</>
)
}
Теперь я думаю вам более понятно, что именно резолвит метод resolveDefaultProps
.
Так в чем же все таки разница между Memo и SimpleMemo?
Если мы еще ниже проскролим метод beginWork
, тогда увидим case
со значением SimpleMemoComponent
. И здесь уже вызывается метод updateSimpleMemoComponent
.
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...
switch (workInProgress.tag) {
// ...
case SimpleMemoComponent: {
return updateSimpleMemoComponent(
current,
workInProgress,
workInProgress.type,
workInProgress.pendingProps,
updateLanes,
renderLanes,
);
}
// ...
}
// ...
}
Это конечно не тоже самое, что updateMemoComponent
, но есть подозрение, что вряд ли уже внутри updateSimpleComponent
будет вызываться функция resolveDefaultProps
. Поэтому первой догадкой отличий MemoComponent
и SimpleMemoComponent
может быть, то что если есть defaultProps
, значит это не SimpleMemoComponent
.
Но давайте меньше догадок и посмотрим метод updateMemoComponent
. Сам метод находится в том же файле.
function updateMemoComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps: any,
updateLanes: Lanes,
renderLanes: Lanes,
): null | Fiber {
if (current === null) {
const type = Component.type;
if (
isSimpleFunctionComponent(type) &&
Component.compare === null &&
// SimpleMemoComponent codepath doesn't resolve outer props either.
Component.defaultProps === undefined
) {
// ...
}
}
// ...
}
В первой строке мы видим проверку на равенство current
и null
, а current
равен null
в случае первого рендера. Тогда выполняется следующий более интересный if
, начнем рассматривать его с конца. Здесь видим проверку на defaultProps
равному undefined
, и даже есть комментарий, что simpleMemoComponent
не резолвит defaultProps
, так что наша предыдущая догадка была абсолютно правильной. Далее идет проверка функции compare
на null
, это значит, что мы не передали свою функцию shouldComponentUpdate
в memo
. И собственно функция проверяющая simple
компонент, который мы обернули в memo
или не simple
.
Давайте посмотрим, что из себя представляет isSimpleFunctionComponent
. Эта функция находится в пакете react-reconciler в файле ReactFiber.new.js
export function isSimpleFunctionComponent(type: any) {
return (
typeof type === 'function' &&
!shouldConstruct(type) &&
type.defaultProps === undefined
);
}
Здесь идет проверка на то что, компонент у нас представлен в виде функции, а не класса и у него нет defaultProps
, тогда этот компонент можно назвать простым.
Метод shouldConstruct
определен немного выше. И все что он проверяет- это наличие флага isReactComponent
function shouldConstruct(Component: Function) {
const prototype = Component.prototype;
return !!(prototype && prototype.isReactComponent);
}
// ...
export function resolveLazyComponentTag(Component: Function): WorkTag {
if (typeof Component === 'function') {
return shouldConstruct(Component) // <- LOOK HERE
? ClassComponent
: FunctionComponent;
} else if (Component !== undefined && Component !== null) {
// ...
}
}
Cудя по вызову shouldConstruct
немного ниже мы можем сделать вывод, если флаг присутствует тогда это компонент написан в виде класса, в ином случае компонент написан в виде функции.
Компонент впервые становится Simple
Мы начали рассматривать метод updateMemoComponent
, и на первом рендере, если ранее обсужденный if
вернет true
. Тогда, все что происходит - это перезаписывание tag
в SimpleMemoComponent
и вызов метода updateSimpleMemoComponent
:
function updateMemoComponent(/* ... */): null | Fiber {
if (current === null) {
const type = Component.type;
if (
isSimpleFunctionComponent(type) &&
Component.compare === null &&
// SimpleMemoComponent codepath doesn't resolve outer props either.
Component.defaultProps === undefined
) {
let resolvedType = type;
workInProgress.tag = SimpleMemoComponent; // <- update tag to SimpleMemo
workInProgress.type = resolvedType;
return updateSimpleMemoComponent(
current,
workInProgress,
resolvedType,
nextProps,
updateLanes,
renderLanes,
);
}
}
// ...
}
Именно в этом месте при первом рендере и решается, станет компонент Simple
или не станет
Промежуточный итог:
Таким образом, мы можем сделать выводы по внутренней градации компонентов в React. Memo
компонент называется простым, когда:
компонент, который обернут в
memo
написан в виде функциикомпонент, который обернут в
memo
не имеетdefaultProps
компонент, который получен после оборачивания
memo
, так же не имеетdefaultProps
в
memo
вторым параметром не передалиcompare
функцию
Ну допустим я использую SimpleMemo, а выгода то какая?
Для того чтобы понять выгоду между MemoComponent
и SimpleMemoComponent
. Давайте для начала доизучаем исходники методов updateMemoComponent
и updateSimpleMemoComponent
.
updateMemoComponent
Начнем с updateMemoComponent
. Я его упрощу, чтобы было легче воспринять код:
function updateMemoComponent(/* ... */): null | Fiber {
// ...
let compare = Component.compare;
compare = compare !== null ? compare : shallowEqual;
if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
const newChild = createWorkInProgress(currentChild, nextProps);
newChild.ref = workInProgress.ref;
newChild.return = workInProgress;
workInProgress.child = newChild;
return newChild;
}
Здесь мы достаем поле compare
из ноды и если это поле не передано, тогда сохраняем функцию shalowEqual
в compare
. И остается лишь передать в compare
prevProps
и nextProps
, а так же удостовериться, что сама DOM нода по какой-то причине не изменилась. В этом случае вызывается функция bailoutOnAlreadyFinishedWork
, которая под капотом чаще всего просто клонирует бывшую ноду. В случае же если if не сработал, создается newChild
, если описывать словами из нашей реальности, просто заново рендериться компонент.
updateSimpleMemoComponent
Рассмотрим упрощенную версию метода updateSimpleMemoComponent
:
function updateSimpleMemoComponent(/* ... */): null | Fiber {
// ...
if (shallowEqual(prevProps, nextProps) && current.ref === workInProgress.ref) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);
}
return updateFunctionComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
);
}
Здесь мы сразу же вызываем shallowEqual
, т.к. наличие функции compare
не возможно в SimpleMemoComponent
и если все хорошо, так же вызовется bailoutOnAlreadyFinishedWork
. Но если что-то идет не так, тогда вызывается функция updateFunctionComponent
.
Сравним результаты
Здесь есть, что сравнить, в случае вызова updateMemoComponent
метод createWorkInProgress
создает какую-то абстрактную ноду, которая может быть классом или функцией, или вдруг еще чем то. С другой стороны в updateSimpleMemoComponent
метод updateFunctionComponent
говорит, я знаю, что мне надо работать непосредственно с функцией и мне надо ее обновить. Т.е. даже из названий можно предположить что updateFunctionComponent
скорей всего вызывается под капотом функции createWorkInProgress
. И чтобы это проверить я так же провел небольшое исследование.
Рассмотрим 2 сценария обновления компонента. Первый с SimpleMemoComponent
. При обновлении компонента, идет вызов beginWork
у которого tag
равен SimpleMemoComponent
и действительно вызывается сначала updateSimpleMemoComponent
и сразу же вызывается updateFunctionComponent
. Благодаря тому, что React знает, что компонент является Simple
, значит компонент, который мы обернули с помощью memo
является функцией и React может сразу же запустить его обновление.
С другой стороны, в случае MemoComponent
происходит вызов beginWork
с тегом MemoComponent
, который заканчивается где-то глубоко в return null. И ведь действительно React не знает, что дальше запускать updateClassComponent
или updateFunctionComponent
. И они решили не городить дополнительные условия, а еще раз запустить beginWork
уже для компонента Child
, с тегом FunctionComponent
который вызовет updateFunctionComponent
. Получается путь немного длиннее.
Я даже перепроверил свою теорию, и добавил console.log
в beginWork
. И действительно, он вызывается на 1 раз меньше в случае SimpleMemoComponent
.
Концовка
Конечно все, что я вам сейчас расписал, это скорее догадки, чем факты. Просто пытаюсь понять как все это работает изнутри.
Существенная ли это экономия использовать SimpleMemoComponent
вместо MemoComponent
или нет, непонятно, да и не очевидно выгодно ли вообще такое использование. В документации, так же я не нашел никаких рекомендаций или упоминаний, как лучше делать. Разве, что по другим причинам рекомендуют перейти с классовых компонентов на функциональные. Поэтому, что-то утверждать о какой-то эфемерной выгоде, достаточно сложно.
И я еще раз напоминаю!
В этой статье, мы снова считаем спички, это скорей всего абсолютно никак не отразится на перфомансе вашего проекта! Но лично для себя я решил не использовать defaultProps
и классовые компоненты. Кто знает, возможно благодаря именно этому на диком западе я буду на долю миллисекунды быстрее чем мои оппоненты))
justboris
Как мне кажется тут наоборот ситуация – все забили на defaultProps потому что есть встроенные в es2015 дефолтные аргументы, а потом уже Реакт подстроился – раз этот паттерн такой популярный, то почему бы не сделать для него в коде happy path
Sin9k Автор
интересная теория, не задумывался о ней) звучит очень правдоподобно)