Виртуальный DOM — это реально оверхед

Раз и навсегда разрушаем миф — 'Виртуальный DOM — это быстро'

Если вы использовали какие-либо JavaScript фреймворки в последние несколько лет, то, скорее всего, слышали фразу «виртуальный DOM — это быстро». Часто, вместе с этим, говорят, что он даже быстрее, чем «реальный» DOM. Это на удивление устойчивый мем — например, люди спрашивают: "А как Svelte может быть быстрым, если он вообще не использует виртуальный DOM?"

Пришло время присмотреться к мифу повнимательнее.

Что такое виртуальный DOM?

Во многих современных фреймворках вы строите своё приложение, создавая функции типа render(), как этот простенький React компонент:

function HelloMessage(props) {
  return (
    <div className="greeting">
      Привет {props.name}
    </div>
  );
}

Можно сделать то же самое и без JSX...

function HelloMessage(props) {
  return React.createElement(
    'div',
    { className: 'greeting' },
    'Привет ',
    props.name
  );
}

... и результат будет тот же — объект, представляющий, как должна выглядеть страница. Этот объект и есть виртуальный DOM. Каждый раз, когда состояние приложения обновляется (например, при изменении свойства name), образуется новое состояние. Работа фреймворка заключается в том, чтобы согласовать новое состояние со старым, выяснить, какие изменения необходимы, и применить их к реальному DOM.

Откуда пошёл этот мем?

Не совсем верно воспринятые утверждения о производительности виртуального DOM относятся ко времени запуска React. В своём основополагающем выступлении Rethinking Best Practices (Переосмысление лучших практик) в 2013 году, бывший член основной команды React Пит Хант рассказал нам следующее:

Он (виртуальный DOM) на самом деле очень быстрый, в первую очередь потому, что большинство операций c DOM медленные. Была проделана большая работа по повышению производительности DOM, но большинство операций с ним всё ещё имеют тенденцию к пропуску кадров.

Pete Hunt at JSConfEU 2013
Скриншот с видео Rethinking Best Practices на JSConfEU 2013

Но, погодите-ка! Действия с виртуальным DOM лишь предвосхищают соответствующие операции в реальном DOM. Единственный случай, когда виртуальный DOM мог бы быть быстрее — это если бы мы сравнивали его с менее эффективным фреймворком (в 2013 году было много всего, что работало сильно медленнее!) Ну, или с пеной у рта доказывали, что альтернатива виртуальному DOM — это некий тривиальный подход, который на самом деле никто никогда бы не применил на практике:

onEveryStateChange(() => {
  document.body.innerHTML = renderMyApp();
});

Позже Пит уточнил...

В React нет никакой магии. При желании, вы можете перейти к прямым DOM манипуляциям и непосредственным вызовам DOM API, выкинув React, точно так же, как вы бы могли уйти с Си на Ассемблер, выкинув из цепочки компилятор Cи. Тем не менее, использование C, Java или JavaScript — это ускорение производительности труда на порядок, потому что вам не нужно беспокоиться ... о специфике платформы. С React вы можете создавать приложения, даже не задумываясь об их производительности, так как они по-умолчанию быстрые.

...но это не та часть выступления, которую запомнили.

Получается... виртуальный DOM тормозной?

Не совсем. Лучше сказать, что обычно виртуальный DOM достаточно быстрый. Да, и то, с некоторыми оговорками.

Первоначально, обещание React состояло в том, что вы можете перерисовывать все приложение целиком при каждом изменении состояния, не беспокоясь о производительности. Но на практике, я не думаю, что это верно. Если бы это было так, не было бы необходимости в оптимизациях, таких как shouldComponentUpdate (для React — это способ узнать, что перерисовку такого-то компонента можно пропустить).

Даже с shouldComponentUpdate обновление виртуального DOM всего вашего приложения за один раз — большая работа. Некоторое время назад команда React представила нечто, называемое React Fiber. Эта технология позволяет разбивать обновление на более мелкие задания. Среди прочего, это означает, что обновления не блокируют основной поток на длительные периоды времени, но это не уменьшает общий объём работы или время, которое занимает всё обновление целиком.

Откуда взялся оверхед?

Совершенно очевидно, что сравнение состояний не даётся даром. Вы не можете применить изменения к реальному DOM без предварительного сравнения нового виртуального DOM с его предыдущим снимком. Вернёмся к нашему первоначальному примеру HelloMessage и предположим, что свойство name изменено с 'world' на 'everybody'.

  1. Оба снимка содержат один элемент. В обоих случаях это <div>, что означает, что мы можем оставить прежний DOM-узел
  2. Переберём все атрибуты старого <div> и нового, чтобы понять, нужно ли что-то менять, добавлять или удалять. В обоих случаях у нас есть один атрибут — className со значением "greeting"
  3. Наконец, заглянем внутрь элемента и увидим, что изменился текст, поэтому решаем, что нам нужно обновить реальный DOM

Как видно в нашем примере, реально только третий шаг имеет значение, поскольку, как и в подавляющем большинстве обновлений, базовая структура приложения остаётся неизменной. Было бы намного эффективнее, если бы мы могли сразу перейти к шагу 3:

if (changed.name) {
  text.data = name;
}

(Практически точно такой же код обновления состояния генерирует Svelte. В отличие от традиционных UI фреймворков, Svelte — это компилятор, который во время сборки узнает, как всё может измениться в вашем приложении, вместо того, чтобы делать эту работу во время работы вашего приложения.)

Дело не только в сравнении состояний

Алгоритмы сравнения, используемые React и другими фреймворками с виртуальным DOM, довольно быстрые. Возможно, куда большие вычисления находятся в самих компонентах. Вы бы не написали такой код ...

function StrawManComponent(props) {
  //тут очень умная и медленная функция расчёта значения
  const value = expensivelyCalculateValue(props.foo);

  return (
    <p>Значение равно {value}</p>
  );
}

... потому что тут вы будете натужно пересчитывать value при каждом обновлении состояния, даже независимо от того, изменилось ли значение props.foo. Но очень часто можно нарваться на выполнение ненужных вычислений и распределений способами, которые кажутся гораздо более безобидными:

function MoreRealisticComponent(props) {
  const [selected, setSelected] = useState(null);

  return (
    <div>
      <p>Выбрано: {selected ? selected.name : 'ничего'}</p>

      <ul>
        {props.items.map(item =>
          <li>
            <button onClick={() => setSelected(item)}>
              {item.name}
            </button>
          </li>
        )}
      </ul>
    </div>
  );
}

Здесь, при каждом изменении состояния, мы генерируем новый массив виртуальных элементов <li>, каждый со своим собственным обработчиком событий. Это делается независимо от того, изменился ли props.items или нет. Если вы не зациклены на производительности, то не станете оптимизировать этот компонент. В этом нет никакого смысла. Всё и так работает достаточно быстро. Но знаете, что будет ещё быстрее? Не делать этого вообще

Опасность неочевидного выполнения ненужной работы, даже если эта работа тривиальна, состоит в том, что ваше приложение в конечном итоге сгинет от 'тысячи маленьких порезов' без чётко видимой большой раны — узкого места, которое можно было бы сразу оптимизировать.

Svelte специально создан так, чтобы не дать вам даже оказаться в такой ситуации.

Зачем тогда фреймворки используют виртуальный DOM?

Важно понимать, что виртуальный DOM — это не 'фича'. Это средство для достижения цели. Цели быть декларативным средством разработки UI управляемых состоянием. Виртуальный DOM ценен тем, что позволяет создавать приложения, не задумываясь о переходе от одного состояния к другом, с производительностью, которая, как правило, достаточно высока. Это означает, что в коде будет меньше утомительных расчётов, меньше ошибок и можно потратить больше времени, решая только творческие задачи.

Но оказывается, что мы можем достичь аналогичной модели программирования без использования виртуального DOM — и это как раз то, для чего появился Svelte.