Привет, Хабр!
В этой статье попробуем разобрать большинство непонятных базовых принципов при взаимодействии с ref
. Например чем детально отличается createRef
от useRef
, зачем в этих объектах отдельное свойство current
и многое другое. Одним словом попытаемся открыть много черных ящиков по работе с ref
, из-за которых у вас возможно накопились вопросы. (Данная статья, является расшифровкой видео)
Вспоминаем setRef
Начнем с примера. Допустим у нас есть небольшой класс, где нам нужно работать с ref
. В классовом компоненте, моим любимым способом работы с ref
- является передача именно функции в атрибут ref
:
class App extends Component {
setRef = (ref) => {
this.ref = ref;
};
componentDidMount() {
console.log(this.ref); // div
}
render() {
return <div ref={this.setRef}>test</div>;
}
}
Функция setRef
первым параметром получит ноду этого элемента и у вас есть возможность сохранить ее в this
. Я называю такой метод в своих проектах setRef
, т.к. он визуально мне напоминает классический setter.
Почему такой подход мне нравится, в нем почти отсутствует магия. Вы контролируете почти весь код, разве что кроме момента передачи функции в атрибут ref
и кто-то ее там вызывает, но и это выглядит вполне себе привычно, когда мы хотим получить значение после асинхронной операции с помощью callback.
function someFunction(callback) {
doSomething()
.then((data) => callback(data));
}
Вопросы к createRef
А теперь, давайте сравним с альтернативным подходом. Будем использовать createRef
для создания инстанса ref
и хранить все там же в this
.
class App extends Component {
constructor(props) {
super(props);
this.ref = createRef();
}
componentDidMount() {
console.log(this.ref.current); // div
}
render() {
return <div ref={this.ref}>test</div>;
}
}
Этот код, на мое мнение, немного сложнее предыдущего. Для начала, createRef()
сохраняет что-то неизвестное в this.ref
. После добавления console.log
, мы видим там объект с одним свойством current
равным null
.
this.ref = createRef();
console.log(this.ref); // { current: null }
Далее мы этот объект { current: null }
засовываем в атрибут ref
и уже в componentDidMount
имеем доступ к ноде.
Тут у меня возникает сразу несколько вопросов:
Зачем нам вообще свойство
current
?И если оно есть, почему тогда в
ref
мы не передаемthis.ref.current
?А вообще гарантировано ли существование свойства
current
?
Да и вообще подход передачи объекта в ref
, чтобы его там мутировали, не очень популярен в React, особенно если вы используете redux в своем проекте, где мутирование не приветствуется.
Изучаем исходники createRef
Чтобы во всем этом разобраться, я предлагаю изучить исходники реакта. Начнем с метода createRef()
(ссылка).
export function createRef(): RefObject {
const refObject = {
current: null,
};
if (__DEV__) {
Object.seal(refObject);
}
return refObject;
}
Здесь мы видим, что создается объект с одним свойством current
равным null
, и он же возвращается. Крайне простой метод.
Таким образом, мы можем даже заменить метод createRef
на просто создание объекта со свойством current
. И это будет работать точно так же.
class App extends Component {
constructor(props) {
super(props);
this.ref = { current: null, count: 2 } // createRef();
}
componentDidMount() {
console.log(this.ref.current); // div
console.log(this.ref.count); // 2
}
render() {
return <div ref={this.ref}>test</div>;
}
}
Более того для эксперимента я добавил в этот объект и дополнительное свойство count
и оно не исчезло после прокидывания в ref
.
Изучаем исходники работы атрибута ref
Чтобы разобраться что происходит внутри атрибута ref, мы опять обратимся к исходникам. Я какое-то время подебажил, чтобы найти место где происходит работа с присваиванием ref
. И это место - функция commitAttachRef. Она находится в пакете react-reconciler в файле ReactFiberCommitWork.new.js.
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref; // получаем то что мы передали в атрибут ref
if (ref !== null) { // Если в ref ничего не передавали, то и делать дальше нечего
const instanceToUse = finishedWork.stateNode; // достаем саму ноду
// ...
if (typeof ref === 'function') { // проверка на тип переданного нами ref
// ...
ref(instanceToUse);
} else {
// ...
ref.current = instanceToUse;
}
}
}
Изучив этот код становится понятно, что полный сценарий работы с ref
достаточно примитивный
this.ref = createRef(); // { current: null }
<div ref={this.ref}>test</div>
if (typeof ref !== 'function') {
ref.current = instanceToUse;
}
Исходя из этих знаний, мы можем предположить, что скорей всего проверка на существование свойства current
избыточна. Чтобы уже точно подтвердить это, я полез в типы:
export type RefObject = {|
current: any,
|};
И действительно поле current
не имеет вопросительного знака, а это значит оно обязательное. Поэтому можете смело использовать поле current
без дополнительных проверок.
А что происходит в commitAttachRef при 2-ом рендере?
Код который мы изучили, описывает только первый рендер, давайте разберемся что происходит с ref
на второй и последующие рендеры.
Для этого было решено провести еще один эксперимент. В начале метода commitAttachRef
я добавил console.log
function commitAttachRef(finishedWork: Fiber) {
console.log('commitAttachRef !!!');
// ...
}
А с другой стороны, доработал предыдущий пример с ref
, а именно добавил счетчик внутри div-а
. И описал классический метод incerement
.
class App extends Component {
constructor(props) {
super(props);
this.ref = createRef();
this.state = { counter: 0 };
}
// ...
increment = () => {
this.setState((prevState) => ({
counter: prevState.counter + 1,
}));
}
render() {
return (
<div ref={this.ref}>
<button onClick={this.increment}>+</button>
<span>{this.state.counter}</span>
</div>
);
}
}
В браузере же мы увидим следующую картину:
При первом рендере вызывается console.log('commitAttachRef !!!')
. И дальше мы нажимаем несколько раз на кнопку увеличения счетчика, но метод commitAttachRef
больше не вызывается.
Давайте еще доработаем пример и попробуем вставить значение счетчика в className
этого div-а
return (
<div ref={this.ref} className={`class-${this.state.counter}`}>
<button onClick={this.increment}>+</button>
<span>{this.state.counter}</span>
</div>
);
И поведение в браузере не изменилось от такой доработки. commitAttachRef
по прежнему вызывается лишь 1 раз.
Поэтому было решено еще доработать код, а именно положить кнопку и счетчик рядом с div-ом
, который маунтим, только если число четное.
return (
<>
<button onClick={this.increment}>+</button>
<span>{this.state.counter}</span>
{this.state.counter % 2 === 0 <div ref={this.ref}>test</div>}
</>
)
Перейдем теперь в браузер. И понажимаем снова на кнопку “плюс”. И в консоли видим, каждый раз когда число четное, перед did update вызывается “commitAttachRef !!!”
.
В принципе понять это не сложно. Пока мы меняем атрибуты, нода мутирует в виртуальном дереве и обновлять ref
реакту смысла нет, т.к. ссылка в виртуальном дереве одна и та же, а когда по какой-либо причине происходит Mount / Unmount ноды, ссылка на ноду обновляется и соответственно нужно перезаписать ref
. Таким образом, на первый взгляд метод commitAttachRef
вызывается только если нода полностью меняется, но в действительности это не единственный случай. Рассмотрим для этого другие ситуации.
А что если использовать createRef вместо useRef?
В предыдущих экспериментах мы разбирали примеры использования createRef()
на классах, а что будет если createRef()
использовать в функциональных компонентах?
Поэтому я решил переписать предыдущий пример на хуках. Получилась следующая картина:
const App = () => {
const [counter, setCounter] = useState(0);
const ref = createRef(); // useRef();
const increment = () => setCounter(counter => counter + 1);
useEffect(() => {
console.log("[useEffect] counter = ", counter, ref.current);
}, [counter]);
return (
<div ref={ref}>
<button onClick={increment}>+</button>
<span>{counter}</span>
</div>
);
}
Вместо привычного useRef()
мы подставим createRef()
. И в useEffect
на каждое изменения counter
будем выводить значение counter
и ref
. Перейдем в браузер.
Мы видим, что абсолютно на каждый рендер вызывается метод commitAttachRef
. Хотя ноду, как в предыдущих примерах, мы не меняли. При этом в useEffect
нода вполне себе валидная, и указывает на правильный <div>
Конечно же, если мы заменим createRef
на useRef
, тогда commitAttachRef
будет вызываться только один раз. Чтобы понять почему это так работает нужно изучить исходники обоих методов и сравнить
Изучаем исходники useRef
Исходники createRef
мы уже смотрели, давайте бегло изучим исходники useRef
. Если вы читали мою предыдущую статью “Первое погружение в исходники хуков” вы знаете, что за одним хуком useRef
кроется несколько методов, а именно mountRef
, updateRef
. Они достаточно примитивные.
Первым рассмотрим mountRef:
function mountRef<T>(initialValue: T): {|current: T|} {
const hook = mountWorkInProgressHook();
if (_DEV_) {
// ...
} else {
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
}
}
В mountRef
достается инстанс hook
. И далее видим проверку на dev окружение. И когда мы проскролим весь этот большой блок для дев окружения, мы увидим, что для прод режима будут выполняться всего 3 строки: создать ref
, сохранить его внутри хука и вернуть его.
Метод updateRef еще проще:
function updateRef<T>(initialValue: T): {|current: T|} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
Нужно достать тот же хук из метода updateWorkInProgressHook()
и вернуть сохраненный в нем ref
.
Сравниваем поведение при createRef и useRef
Изучив хук useRef
, мы можем наконец-то разобраться, почему useRef
лучше подходит для функционального компонента, чем createRef
. Перед тем как начнем сравнивать, я хочу ввести кое какие обозначения.
Для того чтобы показать, что создается именно новый объект, я решил ввести обозначение буквами. Если после какого-то действия буква возле объекта остаётся прежней, значит это та же ссылка на объект. Если же буква изменится, значит это полностью новый объект.
А теперь, построим временную сравнительную шкалу для сравнения useRef()
и createRef()
Как мы видим, при первом рендере поведение у обоих методов абсолютно одинаковое, а вот второй рендер уже отличается. В случае useRef()
в current
мы имеем все ту же ноду указывающую на div
, да и сам объект содержащий current
все тот же "a". В случае createRef()
создается как новый объект "c" (в первом рендере был "b"), так и свойство current
снова равняется null
. И как следствие вызывается commitAttachRef
и на 2-ом рендере.
Возникает резонный вопрос. А что именно заставляет вызвать commitAttachRef
? Это то что ссылка на объект изменилась с "b" на "с" или это, потому что при втором рендере current
снова стал null
?
Для разгадки этой тайны, проведем 2 мелких эксперимента:
Эксперимент 1. Сохраняем ту же ссылку на объект и обнуляем current
Рассмотрим следующий код:
const App = () => {
const [counter, setCounter] = useState(0);
const ref = useRef();
// ...
ref.current = null;
return (
<div ref={ref}>
<button onClick={increment>+</button>
<span>{counter}</span>
</div>
)
}
Суть идеи в том, чтобы при 1-ом, 2-ом и последующих рендерах, в атрибут ref
передавать current
равный null
. И посмотреть, будет ли вызываться commitAttachRef
при каждом ренедере.
РЕЗУЛЬТАТ: commitAttachRef
вызывается лишь 1 раз, при маунте компонент
Эксперимент 2. Изменяем ссылку на объект и сохраняем current
Рассмотрим следующий код:
const App = () => {
const [counter, setCounter] = useState(0);
const ref = useRef();
const testRef = { current: ref.current };
// ...
useEffect(() => {
ref.current = testRef.current;
});
return (
<div ref={testRef}>
<button onClick={increment}>+</button>
<span>{counter}</span>
</div>
)
}
Идея заключается в том, чтобы при каждом рендере менялась ссылка на объект передаваемый в атрибут ref
, но при этом приходило валидное значение current
.
РЕЗУЛЬТАТ: commitAttachRef вызывается на каждый рендер
Паттерн единого объекта с мутирующим свойством
Из этого можно сделать вывод, что на вызов commitAttachRef
влияет именно ссылка на объект, который мы послали в атрибут ref
. И сделано это не просто так. Более того можно проследить паттерн, который закладывали React Core разработчики.
useRef
всегда создает объект лишь единожды, при первой инициализации и больше никогда не меняется, что помогает избежать дополнительных вызовов commitAttachRef
. И эту ссылку можно использовать например в useEffect
, и даже eslint, который заставляет нас дописывать в зависимости, все что мы используем внутри, абсолютно не требует дописывать ref
в зависимости, т.к. ссылка всегда одинаковая, хоть current
может и меняться. А это значит, мы не получим дополнительных вызовов useEffect
, если current
изменится, но при желании можем в зависимости и добавить ref.current
и получим дополнительные вызовы useEffect
(но eslint на такое использование ref.current
ругается, т.к. в большинстве случаев, это приведет скорее к багам, чем к осознанной пользе). Получается данный патерн дает нам определенную дополнительную гибкость.
Так же ref
удобно использовать и как props
. И при изменении current
, ваш компонент не будет перерисовываться, если вы этого не хотите. Поэтому конструкция объекта с дополнительным свойством current
, это не просто так исторически сложилось, а осознанный паттерн, которым предлагают пользоваться нам React Core разработчики.
Мысли вслух
Идея данной статьи - это чуть лучше разобраться в том, как работает инструмент, которым мы вкалачиваем гвозди каждый в свой проект. Да, после этой статьи маловероятно, что вы начнете писать свой проект как то иначе, но однозначно станете более уверенно писать привычные вам конструкции, т.к. будете осознавать, что именно происходит под капотом. И конечно, я надеюсь вас немного меньше станет раздражать наличие current
в ref
, т.к. это жертва во имя гибкости.
drch
Первая толковая статья по React за долгое время. Спасибо!
Sin9k Автор
Рад стараться) возможно некоторые другие статьи и видео покажутся вам интересными)