— Здравствуйте, меня зовут Виктор, я один из разработчиков страницы поиска Яндекса. На ней каждый день сотни миллионов пользователей вводят свои запросы, получают страницу со ссылками или сразу с правильными ответами. Из-за такого количества запросов нам очень важно, чтобы наш код работал оптимально. И, конечно, я сразу должен затронуть тему преждевременной оптимизации кода.
О преждевременной оптимизации




React
Лишние ререндеры
Самая большая проблема в React — это лишние ререндеры. Вообще, библиотека React была создана с упором на то, чтобы как можно чаще и безболезненнее ререндерить всё дерево компонентов. При этом сами компоненты должны отсекать лишние ререндеры там, где пропсы у них не изменились.

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



Многие хуки, такие как useState и useReducer, возвращают из себя какие-то функции. В данном случае setCount. И очень просто на лету сгенерировать стрелочную функцию, использующую setCount, чтобы передать ее во вложенный компонент.
Мы знаем из предыдущего примера, что эта новая функция заставит вложенный компонент перерендериться. Хотя разработчики React и хуков явно говорят в документации, что функции, которые возвращаются из useState и из useReducer, не меняются при ререндерах. То есть вы можете получить самую первую функцию, запомнить ее и не перегенерировать свои функции и пропсы при новых вызовах useState. Это очень важно, это часто забывают. Если вы пишете свои хуки, тоже обратите на это внимание, чтобы ваши функции, возвращаемые из хуков, удовлетворяли этому же требованию, чтобы можно было запомнить первую функцию и потом ее переиспользовать, не заставляя ререндериться вложенные компоненты.
const Foo = () => ( <Consumer>{({foo, update}) => (...)}</Consumer> ); const Bar = () => ( <Consumer>{({bar, update}) => (...)}</Consumer> ); const App = () => ( <Provider value={...}> <Foo /> <Bar /> </Provider> );
Про контекст. Предположим, у нас небольшое приложение или жесткое ограничение на размер файлов, которые скачиваются на клиент, и мы не хотим втаскивать тяжелую библиотеку типа Redux или других библиотек для управления состоянием — то есть мы считаем их слишком тяжелыми или медленными. Тогда мы можем использовать контекст, чтобы прокинуть свойства до глубоко вложенных компонентов.
Минимальный пример выглядит примерно так. При этом мы можем захотеть сэкономить и вместо двух разных контекстов завести один, в котором хранятся все нужные нам свойства.

В этом есть две потенциальных проблемы. Первая: внутри Context Provider при изменении контекста может перерендериться все, что в него вложено, то есть непосредственно все, что вложено внутри Provider, — и те компоненты, которые зависят от контекста, и те, которые не зависят. Очень важно, когда вы пишете такие вещи с использованием контекста, сразу же проверить, чтобы такого не было.
Советуют при этом делать так: выносить провайдер контекста в отдельный компонент, внутри которого не будет ничего кроме children, и уже в этот компонент оборачивать компоненты, куда дальше передавать контекст.

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

Разработчиками React и контекста предусмотрен способ, как это предотвратить. Есть битовые маски. При задании контекста мы указываем функцию, которая указывает в битовой маске, что именно изменилось в контексте. И в конкретном Context Consumer мы можем указать битовую маску, которая будет фильтровать изменения и ререндерить вложенный компонент, только если изменились те биты, которые нам нужны.

Пакет, который называется Why Did You Render, — это однозначный must have для всех, кто борется с лишними ререндерами. Он лежит в NPM, ставится довольно легко и в режиме разработчика позволяет в консоли Developer Tools браузера отследить все компоненты, которые перерендериваются, хотя фактически содержимое props и state у них не изменилось. Вот пример скриншота. Это тот же антипаттерн, когда мы генерируем на каждый рендер новый объект в атрибуте style. При этом в консоли выведется предупреждение, что props фактически не изменились, а изменились только по ссылке, и вы этого ререндера могли избежать. Если подвести итог, что у нас есть для борьбы с лишними ререндерами:

- Пакет Why Did You Render. Это must have в любом проекте, у любого разработчика на React.
- В Developer Tools браузера Chrome можно включить опцию Paint flashing. Тогда он будет подсвечивать те области экрана, которые перерисовались. Вы визуально заметите, что и как часто у вас ререндерится.
- Самое убойное средство — это в каждый рендер вставить console.log. Это позволяет оценить, сколько вообще у вас ререндеров: и нужных, и ненужных.
- И еще одна вещь: часто забываемый второй параметр в React.memo. Это функция, которая позволит вручную написать код сравнения props с предыдущими и самому возвращать true/false, то есть дополнительно к сравнению по ссылке сравнивать какое-то содержимое. Функция аналогична методу shouldComponentUpdate для классовых компонентов.
HTML-комментарии
Следующий интересный момент — комментарии в HTML-коде, который сгенерирован на сервере.
ReactDOMServer.renderToString( <div>{someVar}bar</div> ); <div data-reactroot="">foo<!-- -->bar</div>
В местах склейки статического текста и текста из JavaScript’овых переменных React вставляет HTML-комментарий. Это сделано, чтобы безболезненно гидрировать такие места на клиенте.
ReactDOMServer.renderToString( <div>{`${someVar}bar`}</div> ); <div data-reactroot="">foobar</div>
Если вам нужно удалить такой комментарий, то вы склеиваете строки в JS-коде и вставляете в JSX всю склеенную строку, как в этом примере. Почему это важно?

Представьте, что вы разрабатываете интернет-магазин или список товаров. В строке диапазона цен товара получается целых четыре комментария в местах склейки. Если вы видите на странице список из 100 товаров, то у вас отрендерятся три килобайта HTML-комментариев.
То есть при server-side rendering мы вынуждены потратить лишние ресурсы процессора, лишнюю память и лишнее время на то, чтобы их отрендерить. Мы должны передать на клиент эту лишнюю разметку, а браузер должен эти три килобайта распарсить. И пока страница будет открыта, браузер будет держать их в памяти, потому что они присутствуют в дереве DOM документа.
То есть очень важно в горячих местах понимать, почему и откуда приходят эти комментарии, и при необходимости вырезать их за счет способа, который я показал.
HOC
function withEmptyFc(WrappedComponent) { return props => <WrappedComponent {...props} />; } function withEmptyCc(WrappedComponent) { class EmptyHoc extends React.Component { render() { return <WrappedComponent {...this.props} />; } } return EmptyHoc; }
Про HOC. Сегодня на Я.Субботнике уже рассказывали про него. Пустой минимальный HOC, который не делает ничего, выглядит примерно так. Вот два примера: в функциональном и в классовом стиле.





switch (workInProgress.tag) { case IndeterminateComponent: { // … return mountIndeterminateComponent(…); } case FunctionComponent: { // … return updateFunctionComponent(…); } case ClassComponent: { // … return updateClassComponent(…); }
При ререндере React смотрит: если у нас функциональный компонент или классовый, он производит update, то есть берет новое и старое дерево, сравнивает их, находит между ними минимальный дифф и только эти изменения внедряет в старое дерево. Но если код получается слишком сложный, то React не понимает, что мы от него хотим, и просто монтирует новое дерево взамен старого. Это можно заметить, если у вас есть компоненты, которые при монтировании выполняют какую-то работу — запросы на бэкенд, генерирование uids и т. п. Так что следите за этим.

Изоморфный код


TypeScript
Дизайн языка
Мы плавно перешли к TypeScript. Сначала очень важно упомянуть про дизайн языка. Агрессивная оптимизация производительности скомпилированных программ и система типов, которая позволяет на этапе компиляции доказать, что ваша программа корректна, — это все не является целями дизайна TypeScript. Не является приоритетом при его дальнейшем развитии.

… Spread operator
О чем я хотел бы сказать в первую очередь, это оператор Spread.



Бывает еще вот такой фейл при использовании spread с массивами.


// TS: res = {...obj, a: 1}; // компилируется в ES5: res = __assign(__assign({}, obj), {a: 1}); // хотелось бы: res = __assign({}, obj, {a: 1}); // или res = __assign({}, obj); res.a = 1;
Если же порядок поменяется, это будет означать уже два вложенных вызова assign. Хотя мы хотели бы один вызов или вообще запись поля “a” в объект результата. Почему так происходит? Напоминаю, что генерация оптимального кода — не цель написания и развития языка TypeScript. Он просто обязан учитывать гипотетические крайние случаи: например, когда в объекте есть getter и поэтому он строит универсальный код, который в любых случаях работает правильно, но медленно.

… Rest operator
Двоюродный родственник Spread-оператора — это Rest. Те же три точечки, но по-другому.

var blackList = ['prop1', 'prop2', 'prop3']; var otherProps = {}; // Цикл по всем полям for (var p in props) if ( hasOwnProperty(props, p) && // Вложенный цикл — поиск в массиве indexOf(p) blackList.indexOf(p) < 0 ) otherProps[p] = props[p];
Мы итерируемся по всем полям исходного объекта и внутри выполняем поиск каждого объекта по массиву, который происходит за время, зависящее от размера массива blackList. То есть мы можем получить квадратичную сложность на, казалось бы, простой операции деструктурирования. Чем сложнее деструктурирование, чем больше полей в нем упоминается, тем медленнее оно будет работать, с квадратичной зависимостью.
Нативная поддержка Rest в новых Node.js и новых браузерах не спасает. Вот пример бенчмарка (к сожалению, сейчас сайт jsperf.com лежит), который показывает, что даже примитивная реализация Rest с помощью вспомогательных функций чаще всего работает не медленнее, а даже быстрее нативного кода, который сейчас реализован в Node.js и браузерах.

// хотелось бы ES5: Component.prototype.fn1 = function(path) { utils.fn2.apply(utils, arguments); };
Мы бы хотели, чтобы TypeScript понимал такие кейсы и генерировал вызов apply, передавая в него arguments.
// получаем замедление в ES5: Component.prototype.fn1 = function(path) { var vars = []; for (var _i = 1; _i < arguments.length; _i++) { vars[_i - 1] = arguments[_i]; } utils.fn2.apply(utils, __spreadArrays([path], vars)); };
Но опять же, TypeScript действует максимально надежно и медленно. Он копирует часть аргументов в промежуточный массив. Потом создает еще один массив из первого аргумента и сливает их в один новый массив, делая кучу ненужной работы и замедляя ваш код.
Если вы пишете библиотеки, смотрите на скомпилированный код внимательно. Такие случаи желательно расписать руками максимально эффективно, вместо того чтобы надеяться на компилятор TypeScript.
=> вместо bind
В относительно свежих диалектах языка появилась интересная фича — стрелочная функция в методах классов. Выглядит это примерно так.


Под капотом такая конструкция означает вот что: в конструкторе объекта создается поле onClick, где записывается стрелочная функция, привязанная к контексту. То есть в прототипе метод onClick не существует!

- Самый очевидный минус: каждый конструктор тратит время на создание этой новой функции.
- Ее код не шарится между экземплярами. Он существует в стольких же экземплярах, сколько у вас создано экземпляров MyComponent.
- Вместо N вызовов одной функции вы получаете по одному вызову N функций в каждом из независимых экземпляров. То есть оптимизатор на такую функцию внимания не обращает, не хочет ее инлайнить или оптимизировать. Она выполняется медленно.
Это только минусы в производительности. Но я еще не закончил.

- Если в классе-потомке мы создадим метод onClick, он будет затерт в конструкторе предка.
- Если мы все-таки как-то создадим метод, то все равно не сможем вызвать super.onClick, потому что на прототипе метода не существует.
- Хоть как-то переопределить onClick в классе-потомке, опять же, можно только через стрелочную функцию.
Это еще не все минусы.

Так как в прототипе метод не существует, то писать тесты на него, использовать mock и spy невозможно. Надо вручную ловить создание конкретного экземпляра, и только на конкретном экземпляре можно будет как-то шпионить за этим методом.
Не используйте стрелочные функции для методов. Это единственный совет, который можно дать.
@?boundMethod вместо bind
Хорошо, тогда разработчики говорят: у нас есть декораторы. В частности, такой интересный декоратор @?boundMethod, который вместо нас магически привязывает контекст к нашему методу.
import {boundMethod} from 'autobind-decorator'; class Component { @boundMethod method(): number { return this.value; } }
Выглядит красиво, но под капотом этот декоратор делает следующие вещи:
const boundFn = fn.bind(this); Object.defineProperty(this, key, { get() { return boundFn; }, set(value) { fn = value; delete this[key]; } });
Он все равно вызывает bind. И в придачу определяет getter и setter с именем вашего метода. Можно сразу сказать, что getter и setter никогда не работали быстрее, чем обычное чтение и запись поля.
Плюс здесь есть setter, который выполняет подозрительную работу. Плюс все равно вызывается bind. То есть это по производительности никак не лучше, не быстрее, чем если мы просто напишем bind. Это уже не хочется использовать там, где важна скорость работы кода.
class Base extends Component { @boundMethod method() {} } class Child extends Base { method = debounce(super.method, 100); }
Кроме того, очень легко выстрелить себе в ногу и организовать утечку памяти, всего лишь вызвав в классе-потомке debounce для нашего метода.


Задним числом хотелось бы сказать: перед тем, как вы решили использовать эту библиотеку в продакшене, хотелось бы посмотреть на то, как работает ее код. Одного знания, что ее код вместо одного вызова bind делает такие вещи, как getter и setter, было бы достаточно, чтобы не хотеть ее использовать.
Мы хотели бы посмотреть на коммиты: как часто они делаются, когда был последний коммит. Хотели бы посмотреть на тесты, насколько вменяемо они написаны. И проанализировать открытые баги — насколько оперативно они исправляются. Этот баг с утечкой памяти, к сожалению, существует до сих пор. Ему уже два года, он скоро пойдет в детский садик, и до сих пор автор не торопится его исправлять.
Не используйте этот декоратор как минимум до тех пор, пока баг не будет исправлен.
TL;DR
Мой рассказ подходит к концу. Вот что я хотел бы еще раз для вас повторить:
- Если вы заранее думаете над кодом и не выбираете заведомо неудачные варианты, которые работают плохо и медленно, это не преждевременная оптимизация. Это, наоборот, хорошо.
- Когда вы делаете осознанный выбор, «скорость или красота кода», — это тоже хорошо, если ваш выбор осознан.
- Очень плохо, если вы в принципе не умеете делать выбор, потому что не видите разных вариантов решения или не знаете, как писать производительный код, или не понимаете разницу в скорости работы вашего кода.
У меня все. Вот ссылка на документ со всеми упомянутыми материалами.