Привет, Хабр!

В этой статье попробуем разобрать большинство непонятных базовых принципов при взаимодействии с 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, т.к. это жертва во имя гибкости.