javascript

React, встроенные функции и производительность

  • суббота, 14 октября 2017 г. в 03:14:02
https://habrahabr.ru/company/ruvds/blog/340034/
  • Разработка веб-сайтов
  • ReactJS
  • JavaScript
  • Блог компании RUVDS.com


Когда мне приходится рассказывать о React, или когда я даю первую лекцию учебного курса, показывая всякие интересные вещи, кто-нибудь непременно спросит: «Встроенные функции? Слышал, они медленные».



Этот вопрос появлялся далеко не всегда, но в последние несколько месяцев мне, в роли автора библиотеки и преподавателя, приходится отвечать на него чуть ли не каждый день, иногда — на лекциях, иногда — в твиттере. Честно говоря, я от этого уже устал. К сожалению, я не сразу сообразил, что лучше изложить всё в виде статьи, которая, надеюсь, окажется полезной для тех, кто задаётся вопросами производительности. Собственно говоря — перед вами плод моих трудов.

Что такое «встроенная функция»?


В контексте React то, что называют встроенной функцией (inline function) — это функция, которая определяется в процессе рендеринга. В React есть два значения понятия «рендеринг», которые часто путают. Первое относится к получению элементов React из компонентов (вызов методов render компонентов) в процессе обновления. Второе — это реальное обновление фрагментов страницы путём модификации DOM. Когда я в этой статье говорю о «рендеринге», то имею в виду именно первый вариант.

Вот несколько примеров встроенных функций:

class App extends Component {
  // ...
  render() {
    return (
      <div>
        
        {/* 1. встроенный обработчик событий "компонента DOM" */}
        <button
          onClick={() => {
            this.setState({ clicked: true })
          }}
        >
          Click!
        </button>
        
        {/* 2. "Кастомное событие" или "действие" */}
        <Sidebar onToggle={(isOpen) => {
          this.setState({ sidebarIsOpen: isOpen })
        }}/>
        
        {/* 3. Коллбэк свойства render */}
        <Route
          path="/topic/:id"
          render={({ match }) => (
            <div>
              <h1>{match.params.id}</h1>}
            </div>
          )
        />
      </div>
    )
  }
}

Преждевременная оптимизация — корень всех зол


Прежде чем продолжать, нам нужно поговорить о том, как оптимизировать программы. Спросите любого профессионала в области производительности, и он скажет вам, что преждевременная оптимизация — это зло. Это относится абсолютно ко всем программам. Любой, кто знает толк в оптимизации, может это подтвердить.

Помню выступление моего друга Ральфа Холзманна, посвящённое gzip, которое по-настоящему упрочило во мне эту идею. Он говорил об эксперименте, который провёл с LABjs, старой библиотекой для загрузки скриптов. Можете посмотреть это выступление. То, о чём я тут говорю, происходит в течение примерно двух с половиной минут, начинаясь с 30-й минуты видео.

В то время в LABjs было сделано кое-что странное, направленное на оптимизацию размера готового кода. Вместо использования обычной объектной нотации (obj.foo) там применялось хранение ключей в строках и использование квадратных скобок для доступа к содержимому объектов (obj[stringForFoo]). Причиной подобного было то, что после минификации и сжатия кода с помощью gzip необычно написанный код должен был бы стать меньше, чем код, который написан привычным способом.

Ральф сделал форк этого кода и убрал оптимизацию, переписав его привычным способом, не думая о том, как оптимизировать код для минификации и gzip-сжатия.

Оказалось, что избавление от «оптимизации» позволило сократить размер итогового файла на 5.3%! Очевидно, автор библиотеки писал её сразу в «оптимизированном» виде, не проверяя, даст ли это какие-то преимущества. Без измерений невозможно узнать, улучшает ли что-нибудь некая оптимизация. Кроме того, если оптимизация только ухудшает положение дел, вы об этом тоже не узнаете.

Преждевременная оптимизация не только может значительно увеличить время разработки, ухудшая чистоту кода, она может иметь негативные последствия и привести к проблемам, как это было с LABjs. Если бы автор библиотеки проводил замеры вместо того, чтобы воображать проблемы с производительностью, он сэкономил бы время на разработку, выпустил бы более чистый код, обладающий лучшими характеристиками.

Процитирую тут этот твит: «Меня раздражает, когда люди, развалившись в кресле, рассуждают о том, что некий код будет медленным для решения их задач, не проводя никаких замеров производительности». Я эту точку зрения поддерживаю.

Итак, повторюсь — не занимайтесь преждевременной оптимизацией. А теперь — вернёмся к React.

Почему говорят, что встроенные функции ухудшают производительность?


Встроенные функции считают медленными по двум причинам. Во-первых — это связано с опасениями, касающимися потребления памяти и сборки мусора. Во вторых — из-за shouldComponentUpdate. Разберём эти опасения.

▍Потребление памяти и сборка мусора


Для начала, программисты (и конфигурации estlint) обеспокоены потреблением памяти и нагрузкой на систему от сборки мусора при создании встроенных функций. Это — наследие тех дней, когда стрелочные функции в JS ещё не получили широкого распространения. Если в React-коде, во встроенных конструкциях, часто использовалась команда bind, это, исторически, вело к плохой производительности. Например:

<div>
  {stuff.map(function(thing) {
    <div>{thing.whatever}</div>
  }.bind(this)}
</div>

Проблемы с Function.prototype.bind были исправлены здесь, а стрелочные функции, либо применялись как встроенные возможности языка, либо транспилировались с помощью babel в обычные функции. И так и так мы можем считать, что медленными они не являются.

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

Насколько я знаю, никто пока не привёл исследование своего приложения, указывающее на то, что встроенные функции приводят к проблемам с производительностью. До этого момента не стоит даже об этом говорить, однако, я, в любом случае, поделюсь тут ещё одной идеей.
Если нагрузка на систему от создания встроенной функции достаточно высока для того, чтобы создавать специальное правило eslint для предотвращения этого, с чего бы нам стремиться перемещать эти тяжёлые операции в весьма важный с точки зрения воздействия на скорость работы системы блок инициализации?

class Dashboard extends Component {
  state = { handlingThings: false }
  
  constructor(props) {
    super(props)
    
    this.handleThings = () =>
      this.setState({ handlingThings: true })

    this.handleStuff = () => { /* ... */ }

    // ещё больше нагрузки на систему с bind
    this.handleMoreStuff = this.handleMoreStuff.bind(this)
  }

  handleMoreStuff() { /* ... */ }

  render() {
    return (
      <div>
        {this.state.handlingThings ? (
          <div>
            <button onClick={this.handleStuff}/>
            <button onClick={this.handleMoreStuff}/>
          </div>
        ) : (
          <button onClick={this.handleThings}/>
        )}
      </div>
    )
  }
}

Занимаясь предварительной оптимизацией, мы замедлили инициализацию компонента в три раза. Если бы все обработчики событий были встроенными функциями, первоначальному вызову render надо было бы создать лишь одну функцию. Вместо этого мы создаём три. Причём, никаких замеров производительности не проводилось, поэтому у нас нет причины считать это проблемой.

Однако, опять же, не стоит увлекаться идеей переноса всего, что надо и не надо во встроенные функции. Если, вдохновившись вышеприведённой идеей, некто решит создать eslint-правило, которое будет требовать повсеместного использования встроенных функций для ускорения первоначального рендеринга, то перед нами окажется всё та же вредная преждевременная оптимизация.

▍PureComponent и shouldComponentUpdate


Настоящая суть проблемы кроется в PureComponent и shouldComponentUpdate. Для того, чтобы осмысленно заниматься оптимизацией производительности, нужно понимать две вещи: особенности shouldComponentUpdate, и то, как работает сравнение на строгое равенство в JavaScript. Не понимая этих концепций, можно, пытаясь сделать код быстрее, только всё ухудшить.

Когда вызывают setState, React сравнивает старый элемент с новым (это называется согласованием), а затем использует полученную информацию для обновления элементов реального DOM. Иногда эта операция может происходить довольно медленно, если имеется слишком много элементов, которые надо проверять (что-то вроде большого SVG). В таких случаях React предоставляет обходной путь, который называется shouldComponentUpdate.

class Avatar extends Component {
  shouldComponentUpdate(nextProps, nextState) {
    return stuffChanged(this, nextProps, nextState))
  }
  
  render() {
    return //...
  }
}

Если у компонента задан shouldComponentUpdate, прежде чем React сравнит старый и новый элементы, он обратится к shouldComponentUpdate для того, чтобы узнать о том, изменилось ли что-нибудь. Если в ответ вернётся false, React полностью пропустит операцию сравнения элементов, что сэкономит какое-то время. Если компонент достаточно велик, это может привести к заметному влиянию на производительность.

Самый распространённый способ оптимизации компонента — расширение React.PureComponent вместо React.Component. PureComponent будет сравнивать свойства и состояние в shouldComponentUpdate, в результате, вам не придётся делать это самостоятельно.
class Avatar extends React.PureComponent { ... }

Класс Avatar теперь использует строгое сравнение на равенство при работе со свойствами и состоянием прежде чем запрашивать обновления. Можно ожидать, что это ускорит работу программы.

▍Сравнение на строгое равенство


В JavaScript существует шесть примитивных типов: string, number, boolean, null, undefined, и symbol. Когда выполняют строгое сравнение двух переменных примитивных типов, которые хранят оно и то же значение, получается true. Например:

const one = 1
const uno = 1
one === uno // true

Когда PureComponent сравнивает свойства, он использует строгое сравнение. Это отлично работает для встроенных примитивных значений вроде <Toggler isOpen={true}/>.

Проблема при сравнении свойств возникает для других типов, то есть, извините — единственного типа. Всё остальное в JS — это Object. А как же функции и массивы? На самом деле всё это — объекты. Позволю себе процитировать выдержку из документации MDN: «Функции — это обычные объекты, имеющие дополнительную возможность быть вызванными для исполнения».

Ну что тут скажешь — JS это JS. В любом случае, строгое сравнение разных объектов, если даже они содержат одни и те же значения, вернёт false.

const one = { n: 1 }
const uno = { n: 1 }
one === uno // false
one === one // true

Итак, если вы встраиваете объект в JSX-код, адекватное сравнение свойств в PureComponent окажется невозможным, в результате чего будет произведено более трудоёмкое сравнение элементов React. Это сравнение выяснит лишь то, что компонент не изменился, как результат — потеря времени на двух сравнениях.

// первый рендер
<Avatar user={{ id: ‘ryan’ }}/>

// следующий рендер
<Avatar user={{ id: ‘ryan’ }}/>

// сравнение свойств полагает, что что-то изменилось, так как {} !== {}
// сравнение элементов (согласование) выясняет, что ничего не изменилось

Так как функции — это объекты, и PureComponent выполняет строгую проверку на равенство свойств, сравнение встроенных функций при анализе свойств всегда оканчивается сообщением о том, что они разные, после чего будет осуществлён переход к сравнению элементов в ходе процедуры согласования.

Вы можете заметить, что относится это не только ко встроенным функциям. То же самое можно сказать и об обычных объектах, и о массивах.

Для того, чтобы shouldComponentUpdate делал при сравнении одинаковых функций то, чего мы от него ожидаем, нужно сохранять ссылочную идентичность функций. Для опытных JS-разработчиков это — не такая уж и плохая новость. Но, если учесть то, что Майкл и я узнали после обучения примерно 3500 человек, имеющих различный уровень подготовки, можно отметить, что эта задача оказалась для наших учеников не такой уж и простой. Надо отметить, что и классы ES тут не помогают, поэтому в данной ситуации приходится пользоваться другими возможностями JS:

class Dashboard extends Component {
  constructor(props) {
    super(props)
    
    // Используем bind? Это замедляет инициализацию и, если такое повторяется раз 20,
    // ужасно смотрится.
    // Кроме того, это увеличивает размер пакета.
    this.handleStuff = this.handleStuff.bind(this)

    // _this - это дурной тон.
    var _this = this
    this.handleStuff = function() {
      _this.setState({})
    }
    
    // Если вам доступны классы ES, то, возможно, вы можете использовать и 
    // стрелочные функции (то есть, работаете с babel или с современным браузером).
    // Это не так уж и плохо, но перемещение всех обработчиков в конструктор - это уже
    // не так уж и хорошо.
    this.handleStuff = () => {
      this.setState({})
    }
  }
  
  // так куда лучше, но это пока за пределами JavaScript,
  // поэтому тут можно задаться вопросом о том, как работает комитет TC39 и
  // как он оценивает предложения по языку.
  handleStuff = () => {}
}

Тут надо отметить, что изучение приёмов сохранения ссылочной идентичности функций ведёт к удивительно длинным беседам. У меня нет причин призывать к этому программистов, разве что им захочется выполнить требования их конфигурации eslint. Главное, что мне хотелось показать — это то, что встроенные функции не мешают оптимизации. А теперь поделюсь собственной историей оптимизации производительности.

Как я работал с PureComponent


Когда я впервые узнал о PureRenderMixin (это — конструкция из ранних версий React, которая позже превратилась в PureComponent), я использовал множество измерений и оценил производительность моего приложения. Затем я добавил PureRenderMixin ко всем компонентам. Когда я предпринял измерение производительности оптимизированной версии, то надеялся, что в результате всё будет так замечательно, что я смогу с гордостью всем об этом рассказывать.

Однако, к моему великому удивлению, приложение стало работать медленнее.

Почему? Подумаем об этом. Если у вас есть некий Component, сколько операций сравнения приходится выполнять при работе с ним? А если речь идёт о PureComponent? Ответы, соответственно, заключаются в следующем: «только одно», и «как минимум одно, а иногда — два». Если обычно компонент при обновлении меняется, то PureComponent будет выполнять две операции сравнения вместо одной (свойства и состояние в shouldComponentUpdate, а затем — обычное сравнение элементов). Это означает, что обычно PureComponent будет медленнее, но иногда — быстрее. Очевидно, большинство моих компонентов постоянно менялись, поэтому, в целом, приложение стало работать медленнее. Печально.

Универсального ответа на вопрос: «Как повысить производительность?» нет. Ответ можно найти только в замерах производительности конкретного приложения.

О трёх сценариях использования встроенных функций


В начале материала я показал три типа встроенных функций. Теперь, когда подготовлена некоторая база, поговорим о каждом из них. Но, пожалуйста, помните о том, что PureComponent лучше попридержать до тех пор, пока у вас не будет измерений для того, чтобы оценить выгоды от использования этого механизма.

▍Обработчик событий компонента DOM


<button
  onClick={() => this.setState(…)}
>click</button>

Обычно внутри обработчиков событий для кнопок, полей ввода и других компонентов DOM, не делается ничего кроме вызова setState. Это обычно делает встроенные функции наиболее чистым подходом. Вместо того, чтобы прыгать по файлу в поисках обработчиков событий, их можно найти в коде описания элемента. Сообщество React обычно приветствует подобное.

Компонент button (и любой другой компонент DOM) даже не может быть PureComponent, поэтому тут не нужно беспокоиться о shouldComponentUpdate и о ссылочной идентичности.

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

▍«Кастомное событие» или «действие»


<Sidebar onToggle={(isOpen) => {
  this.setState({ sidebarIsOpen: isOpen })
}}/>

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

Теперь поговорим о событиях вроде вроде onToggle, и о том, почему Sidebar занимается их сравнением. Есть лишь две причины поиска различий в свойствах в shouldComponentUpdate:

  1. Свойство используют для рендеринга.
  2. Свойство используют ради достижения побочного эффекта в componentWillReceiveProps, в componentDidUpdate, или в componentWillUpdate.

Большинство свойств вида on<whatever> не соответствуют этим требованиям. Таким образом, большинство вариантов использования PureComponent ведут к выполнению ненужных сравнений, что принуждает разработчиков поддерживать, без необходимости, ссылочную идентичность обработчиков.

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

Для большинства компонентов я бы посоветовал создать класс PureComponentMinusHandlers и наследоваться от него, вместо того, чтобы наследоваться от PureComponent. Это поможет просто пропустить все проверки функций. Как раз то, что нужно. Ну — почти то, что нужно.

Если вы получите функцию и передадите эту функцию напрямую в другой компонент, он окажется устаревшим. Взгляните на это:

// 1. Приложение передаст свойство форме.
// 2. Форма собирается передать функцию кнопке,
// которая перекрывает свойство, полученное от приложения.
// 3. Приложение собирается выполнить setState после монтирования и передать
// *новое* свойство форме.
// 4. Форма передаёт новую функцию кнопке, перекрывая
// новое свойство.
// 5. Кнопка проигнорирует новую функцию и не сможет 
// обновить обработчик нажатия, её передача будет осуществлена 
// с устаревшими данными.
class App extends React.Component {
  state = { val: "one" }

  componentDidMount() {
    this.setState({ val: "two" })
  }

  render() {
    return <Form value={this.state.val} />
  }
}

const Form = props => (
  <Button
    onClick={() => {
      submit(props.value)
    }}
  />
)

class Button extends React.Component {
  shouldComponentUpdate() {
    //Давайте представим, будто мы сравнили всё, кроме функции.
    return false
  }

  handleClick = () => this.props.onClick()

  render() {
    return (
      <div>
        <button onClick={this.props.onClick}>This one is stale</button>
        <button onClick={() => this.props.onClick()}>This one works</button>
        <button onClick={this.handleClick}>This one works too</button>
      </div>
    )
  }
}

Здесь с этим кодом можно поэкспериментировать.

Итак, если вам нравится идея наследоваться от PureRenderWithoutHandlers, не передавайте ваши обработчики, не участвующие в сравнении, напрямую другим компонентам — их надо каким-то способом обернуть.

Теперь нам либо надо поддерживать ссылочную идентичность, либо избегать ссылочной идентичности! Добро пожаловать в оптимизацию производительности. Как минимум, при таком подходе нагрузка ложится на оптимизированный компонент, а не на код, использующий его.

Должен честно сказать, что это приложение-пример — добавка к материалу, которую я сделал после публикации с подачи Эндрю Кларка. Так может показаться, что я точно знаю, когда надо поддерживать ссылочную целостность, а когда — нет.

▍Свойство render


<Route
  path="/topic/:id"
  render={({ match }) => (
    <div>
      <h1>{match.params.id}</h1>}
    </div>
  )
/>

Свойства render — это шаблон, используемый для создания компонента, который существует для создания и поддержания разделяемого состояния (тут об этом можно почтить подробнее). Содержимое свойства render неизвестно компоненту. Например:

const App = (props) => (
  <div>
    <h1>Welcome, {props.name}</h1>
    <Route path="/" render={() => (
      <div>
        {/*
          props.name находится за пределами Route и оно не передаётся
          как свойство, поэтому Route не соответствует
          идеологии PureComponent, у него
          нет сведений о том, что здесь появится после рендеринга.
        */}
        <h1>Hey, {props.name}, let’s get started!</h1>
      </div>
)}/>
  </div>
)

Это означает, что встроенная функция свойства render не приведёт к проблемам с shouldComponentUpdate. Компонент недостаточно информирован для того, чтобы его можно было бы преобразовать в PureComponent.

Итак, опять же, доказательств медленности свойств render у нас нет. Всё остальное — мысленные эксперименты, не имеющие отношения к реальности.

Итоги


  1. Пишите код так, как привыкли, реализуя в нём ваши идеи.
  2. Проводите замеры производительности для того, чтобы находить узкие места. Здесь можно узнать о том, как это сделать.
  3. Используйте PureComponent и shouldComponentUpdate только при необходимости, пропуская функции, являющиеся свойствами компонента (только если они не используются в перехватчиках событий жизненного цикла для достижения побочных эффектов).

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

Уважаемые читатели! Как вы оптимизируете React-приложения?