company_banner

Код на React и TypeScript, который работает быстро. Доклад Яндекса

    Евангелисты Svelte и других библиотек любят показывать примеры тормозящих компонентов на React. React и TypeScript дают много возможностей создавать медленный код. После доклада Виктора victor-homyakov вы сможете писать более производительные компоненты без усложнения кода.

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

    О преждевременной оптимизации




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

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



    Но прошли годы и десятилетия. У нас появились новые методологии разработки, появилась концепция MVP. Мы как можно раньше собираем рабочий прототип и отдаем его в тестирование и эксплуатацию. Кроме того, мы можем запускать отдельные компоненты, не дожидаясь всего проекта. Для этого у нас есть и тесты, и Storybook. Но та же самая мантра повторяется до сих пор — не занимайтесь преждевременной оптимизацией, когда бы и на каком бы этапе жизни проекта вы бы ни спросили.



    В результате современный фронтенд имеет то, что имеет: самые медленные инструменты сборки, самые тормознутые интерфейсы, самые большие размеры собранных файлов. Для Single Page-приложений гигантские бандлы в мегабайты никого не удивляют. И папка node_modules — одна из самых жирных во всех проектах. Например, у нас на странице поиска она уже превысила три гигабайта и продолжает расти.



    О чем же будет мой доклад? В первую очередь, наши языки, TypeScript и JavaScript, и наши библиотеки и фреймворки подразумевают, что практически у каждой задачи есть несколько вариантов решений. Все они правильные, все дают нужный результат, но не все одинаково эффективны.

    Видеть эти варианты заранее и выбирать нужные, а не выбирать заведомо плохой, — это не преждевременная оптимизация. Те тривиальные приемы, про которые я расскажу, дают при консистентном использовании до 5% производительности кода. Это по данным реальных проектов, которые переходили со старых стеков на использование React и TypeScript. Первая часть — про React.

    React


    Лишние ререндеры


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


    Источник

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



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

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



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

    Здесь новая стрелочная функция тоже не равна точно такой же предыдущей, и новый результат bind не равен предыдущему. То есть мы опять обманываем вложенные компоненты и заставляем их ререндериться без нужды.


    Источник

    Многие хуки, такие как 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, который не делает ничего, выглядит примерно так. Вот два примера: в функциональном и в классовом стиле.



    Если замерить производительность server-side rendering, то пустая кнопка, классическая кнопка HTML, рендерится 0,9 микросекунды. Если мы ее обернем в пустой HOC, который не делает ничего, то увидим, что это уже добавляет замедление в рендеринг.

    А если мы в этот HOC добавим еще и полезной нагрузки (приведен пример реального HOC из нашего проекта), то увидим, что скорость рендеринга замедлилась еще больше. Почему так происходит?



    При server side rendering и при первом рендеринге на клиенте HOC всегда делает вызов React.createElement. Это довольно сложная функция, которая выполняет довольно много работы внутри самой библиотеки React. Она не может не занимать дополнительного времени.

    Также происходит копирование props. Мы снаружи HOC получили какие-то props и должны сформировать новые props для вложенного в HOC компонента. Это тоже занимает время.



    При ререндере у нас никуда не делся React.createElement. Также HOC добавляет обертку в дереве. Сравнение с предыдущим деревом и обход дерева замедляет работу с ним.



    В итоге на продакшене это может выглядеть как результат угара по HOC. Только половина разметки в дереве — это полезная нагрузка, а оставшаяся половина — это context consumer, context provider и разнообразные HOC.

    То есть React работает с деревом, которое стало в два раза больше, чем без HOC. Он не может не тратить дополнительное время на обработку этого дерева.



    И еще один важный момент. Если мы напишем слишком сложный HOC, то можем наткнуться на полную замену дерева при ререндере вместо update предыдущего. Расскажу про это немножко подробнее.

    switch (workInProgress.tag) {
      case IndeterminateComponent: {
        // …
        return mountIndeterminateComponent(…);
      }
      case FunctionComponent: {
        // …
        return updateFunctionComponent(…);
      }
      case ClassComponent: {
        // …
        return updateClassComponent(…);
      }

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

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



    Если у вас есть выбор, где реализовать вашу функциональность, в HOC или в хуке, то однозначно рекомендую хук. Он по размеру кода меньше, он — всего лишь вызов функции, которая не несет смысла внутри библиотеки React, в то время как в HOC, я уже говорил, React.createElement — сложная вещь. И в HOC добавляются уровни вложенности и прочая ненужная работа. Если можно ее избежать — избегайте.

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



    Про изоморфный код. Евангелисты изоморфизма не очень любят углубляться в детали того, как же их изоморфный код работает на наших серверах и наших клиентах. Проблема в том, что мы контролируем наш бэкенд, можем на нем доставить свежую Node.js, которая понимает последний диалект ECMAScript. В то же время на клиенте до сих пор значительная доля древних браузеров, например Internet Explorer 11 или старые Android: четвертый и немножко новее. Поэтому клиентам до сих пор все равно очень часто нужно отдавать ES5.

    Поэтому никакими полифилами вы не сможете добавить на клиент понимание нового синтаксиса: стрелочных функций, классов, async await и прочих вещей.

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

    Мы бы хотели, когда пишем изоморфный код на TypeScript, так настроить сборку, чтобы наш TypeScript компилировался в максимально свежий диалект для Node.js. Чтобы именно этот скомпилированный код исполнялся на Node.js при server side rendering. И чтобы для браузеров TypeScript компилировался в подходящий диалект, ES5 или чуть более новый, если вы собираете разные версии кода для старых и новых браузеров.



    Если же мы пишем сразу на ECMAScript, то можем нативно писать для Node.js, и в этом случае бонусом будет то, что нам не нужны никакие системы сборки и бандлинга. Мы сразу пишем код, который нативно понимается Node.js. Node.js умеет использовать модульные системы: CommonJS через require, ESM через import. И нам надо только скомпилировать в ES5 для браузеров и собрать в бандлы.

    К сожалению, когда рассматривается изоморфный код, об этом часто забывают. В одном из примеров изоморфного кода, который я видел, код вообще компилировался в ES3, и потребители изоморфного кода из этой библиотеки вынуждены были это терпеть на сервере, сжать зубы и выполнять древний код со всеми полифилами для того, что и так уже было в Node.js.

    TypeScript


    Дизайн языка


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



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

    … Spread operator


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


    Он очень часто используется в коде на React. Но то, что его легко написать, не означает, что его так же легко выполнять.



    Потому что при компиляции такого кода TypeScript запишет в модуль на ES5, во-первых, реализацию метода __assign, а во-вторых, его вызов. То есть фактически воткнет полифил для Object.assign.

    И только при компиляции в более новые диалекты он будет использовать сам Object.assign. Проблема в том, что если вы пишете на TypeScript библиотеки и компилируете их — проверьте, чтобы в каждом скомпилированном модуле не было понатыкано реализаций этих __assign. То есть не заставляйте потребителя с каждым модулем снова и снова получать код реализации __assign. Для этого есть соответствующие настройки компиляции TypeScript.

    И еще одна проблема: Object.assign, если вы знаете, означает клонирование объекта. Клонирование объекта выполняется не за константное время. Чем сложнее объект, чем больше в нем полей, тем больше времени будет занимать клонирование. И с этим связан такой пример фейла.

    Это код, который успешно прошел код-ревью, и оказался в продакшене. Казалось бы, здесь ничего сложного нет, все должно красиво работать.



    Проблема в том, что на каждой итерации мы выполняем клонирование предыдущего объекта. И соответственно, на N+1 итерации мы вынуждены будем склонировать объект, в котором уже N полей.

    Те, кто разбирается в алгоритмах, понимают, что сложность этого алгоритма — O(N2). То есть чем больше исходный массив, тем с квадратичной зависимостью медленнее будет работать такой простенький код. Легко написать, сложно выполнить, как я уже говорил.

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



    Здесь вы сразу заметили квадратичную сложность вместо линейной. И здесь легче увидеть еще одну проблему: при каждой итерации мы выделяем память для нового массива, потом копируем в него содержимое старого массива и освобождаем его память.

    А если массив начинает занимать гигабайт? Представьте: во-первых, постоянно занято 3 ГБ одновременно (1 ГБ — исходный массив, 1 ГБ — предыдущая копия и 1 ГБ — следующая). Во-вторых, на каждой итерации мы копируем из предыдущего расположения массива в следующее 1 ГБ плюс 1 элемент, 1 ГБ плюс 2 элемента и т. д. Ваша задача — заметить такое на код-ревью и не пустить в продакшен.



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

    // 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 и поэтому он строит универсальный код, который в любых случаях работает правильно, но медленно.



    Справедливости ради нужно сказать, что в TSX оптимально компилируется похожий случай, когда есть два объекта props и вы передаете их в компонент таким образом. Здесь будет всего один вызов assign и компилятор понимает, что надо делать эффективно.

    … Rest operator


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


    У нас в коде это чаще всего используется в деструктурировании. Вот один из примеров. Здесь под капотом, чтобы получить объект otherProps, надо выполнить следующую нетривиальную работу: из объекта props скопировать в новый объект otherProps все поля, название которых не равно “prop1”, “prop2” или “prop3”.

    Чувствуете, к чему я клоню? При компиляции в ES5 получается примерно такой код:

    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 и браузерах.



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

    // хотелось бы 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


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



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



    В DevTools это выглядит примерно так. Мы видим, что в памяти накапливаются старые экземпляры компонента Child. И если посмотреть в одном экземпляре, как у него выглядит этот метод, то мы увидим целую цепочку из bind-function-debounced-bind-function-debounced-… и так далее. И в каждом из этих debounced в замыканиях содержатся предыдущие экземпляры Child. Вот вам утечка памяти на ровном месте, когда можно было ее избежать.


    Ссылка со слайда

    Задним числом хотелось бы сказать: перед тем, как вы решили использовать эту библиотеку в продакшене, хотелось бы посмотреть на то, как работает ее код. Одного знания, что ее код вместо одного вызова bind делает такие вещи, как getter и setter, было бы достаточно, чтобы не хотеть ее использовать.

    Мы хотели бы посмотреть на коммиты: как часто они делаются, когда был последний коммит. Хотели бы посмотреть на тесты, насколько вменяемо они написаны. И проанализировать открытые баги — насколько оперативно они исправляются. Этот баг с утечкой памяти, к сожалению, существует до сих пор. Ему уже два года, он скоро пойдет в детский садик, и до сих пор автор не торопится его исправлять.

    Не используйте этот декоратор как минимум до тех пор, пока баг не будет исправлен.

    TL;DR


    Мой рассказ подходит к концу. Вот что я хотел бы еще раз для вас повторить:

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

    У меня все. Вот ссылка на документ со всеми упомянутыми материалами.
    Яндекс
    Как мы делаем Яндекс

    Комментарии 30

      0

      Постоянно сталкиваюсь, что кто-то использует => вместо bind.
      Это прямо как болячка какая-то. И одно дело если экземпляров класса будет 1–10, а другое когда их сильно больше.

        +3
        Самый очевидный минус: каждый конструктор тратит время на создание этой новой функции.

        Бинд тоже создает новый экземпляр функции что легко проверить:


        function myFunc(){};
        var binded = myFunc.bind(window);
        binded === myFunc; // false

        И точно так же перезатирает предыдущее значение. Поэтому не факт что код будет оптимизирован.

          0

          Да, вы правы. bind, конечно создает новую функцию с зафиксированным контекстом, но он скорее всего будет быстрей, потому что во внутренностях движка есть возможность сослаться на уже существующую в прототипе функцию.
          Даже решил немного измерить в попугаях. В Хроме и Файрфоксе bind победил. В Сафари, на удивление, победил вариант со свойствами-стрелками.
          Для Ноды тоже написал небольшой тест. Там bind победил вообще с огромным разрывом по скорости и чуть лучшим расходом памяти.


          И опять вы правы, значение перетрется.
          Но в случае с bind вместо стрелок мы все еще можем получить в наследнике исходный метод через super, а со стрелками не можем, вместо этого словим исключение TypeError: (intermediate value).какаяТоФункция is not a function


          Псевдопример с обращением к super
          class A {
            constructor() {
              this.hi = this.hi.bind(this);
            }
          
            hi() {
              console.log('A');
            }
          }
          
          class B extends A {
            constructor() {
              super();
          
              super.hi();
              this.hi();
            }
          
            hi() {
              console.log('B');
            }
          }
          
          class C {
            hi = () => {
              console.log('C');
            }
          }
          
          class D extends C {
            constructor() {
              super();
          
              super.hi();
              this.hi();
            }
          
            hi() {
              console.log('D');
            }
          }
          
          new B();
          new D();
          
            0

            Т.е. для компонентов которые создаются однократно (app.tsx, landing.tsx userinfo.tsx e.t.c) выгоднее* использовать стрелочную функцию, потому что:


            наследование не предполагается, а создание одной функции быстрее чем создание ее же + создание враппера с контектстом (метод bind)


            *С чисто математической точки зрении т.к. разница будет просто ничтожна, даже на устаревших мобильных (интересно было бы померять). Правда справедливо и в обратную сторону — если вы не создаете 100+ экземпляра компонента вам должно быть все равно с точки зрения CPU. Да, и с точки зрения памяти тоже.


            Т.е. мне кажется совет актуален больше для библиотек.


            Спасибо за замеры и за статью, есть над чем подумать.

              +1

              Полагаю в рамках любого React приложения вы говорите уже даже не о спичках, на фоне пожара, а о булавках. Первый же {...props} перетрёт все ваши старания. А первый же хук просто уничтожит.

                0
                Все не так однозначно. При 3x bind класс начинает проигрывать 3x arrow в chome. К тому же, не знаю как для кого, но для меня писать с bind, особенно большие классы, небезопасно (можно пропустить/забыть сделать bind). Не люблю в общем.

                По поводу наследования, в моей практике, как правило обработчики и публичные методы не переопределются при наследовании. Но если понадобится, придется делать bind, да
                  +1

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


                  Надо вынести определения классов в сетап бенчмарка и измерять или создание экземпляра класса + вызов метода (сценарий "создаём короткоживущий экземпляр, используем один раз и выбрасываем") или только вызов ("долгоживущий экземпляр, вызываем много раз")


                  1. Создание + один вызов метода: https://jsbench.me/6nkjv4vq9u/1
                  2. Вызов метода: https://jsbench.me/6nkjv4vq9u/2

                  Теперь разница в скорости становится заметнее.

                    +4

                    Запустил. У меня даже двукратной разницы не было. Мне кажется любые попытки оптимизации React приложений на этом уровне это бессмысленная трата времени. Потому что если вы и правда хотите что-то на этом выиграть, то другие оптимизации (вроде inline-а компонент, отказа от spread-а, или вообще смены технологии) дадут вам несоизмеримо больше выгоды.

                  0
                  Бинд тоже создает новый экземпляр функции… Поэтому не факт что код будет оптимизирован

                  Bind создаёт "обёртку" вокруг уже существующей функции, то есть N вызовов N "обёрток" всё равно придут выполняться в один экземпляр исходной функции. Она будет вызвана N раз, соберёт N раз информацию о типах, с которыми вызывалась, и если N больше порога (ЕМНИП 10000 по умолчанию в V8 и JavaScriptCore), то функция будет оптимизирована.


                  То, во что превращается arrow method после транспиляции в ES6- (и скорее всего то, что под капотом выполняется в нативно поддерживающих arrow method движках), создаёт N экземпляров независимых друг от друга функций. N вызовов N экземпляров приведут к тому, что каждый экземпляр функции будет вызван только один раз. Оптимизатор к N функциям, вызванным по одному разу, не проявит интереса.

                    0

                    А. Ну т.е. речь идёт больше не о том, что быстрее, а о том, что получит JIT раньше? Ну да, в таком разрезе это имеет больше смысла. Но всё равно кажется что вы занимаетесь чем-то очень странным. К примеру если вы перейдёте на хуки (что имхо неизбежно учитывая политику React), то там вообще всё на замыканиях

                      +1

                      В существующих проектах всё ещё много классовых компонентов, и их надо сопровождать. Разработчики часто пытаются в них заменить bind на стрелочные методы. Если можно не допустить ухудшения производительности в этом месте простым требованием к стилю кода, то почему бы и нет?


                      Ну и кроме классовых компонентов React могут существовать классы в модели данных. Там возможна аналогичная ситуация.

                        +5
                        Если можно не допустить ухудшения производительности в этом месте простым требованием к стилю кода, то почему бы и нет?

                        Это самое интересное. Тут мы переходим на скользую почву субъективных оценок. На мой взгляд потери от constructor + bind сильно выше преимуществ в производительности. В виду того, что код с => сильно проще в написании и поддержке. Куда меньше визуального мусора. А это "условно" можно перевести в ресурсы бизнеса — деньги и время.


                        Но я не смогу не согласиться, что это мнение субъективное и кому-то может показаться, что никакой особой разницы нет. Ведь кому-то и Java код не кажется too verbose.

                  0
                  Фокус с .bind не прокатит, будет возвращен новый экземпляр и бла-бла, вы уже это поняли я вижу.
                  Есть только один вариант устраняющий этот недостаток, это чисто на уровне трансформации итогового кода в бандле преобразовывать
                  myClass.sayHi('Ivan', 'Petrov');
                  

                  в
                  myClass.sayHi.call(myClass, 'Ivan', 'Petrov');
                  
                    0

                    Так у вас не получится пробросить метод в обработчик.

                      0
                      Какой метод? В какой обработчик? Код напишите.
                      Я то вот про это:
                      class MyClass {
                         asd = 1;
                         sayHi(){
                              console.log('asd:', this.asd);
                         }
                      }
                      
                      const myClass = new MyClass();
                      
                      setTimeout(myClass.sayHi.call(myClass), 0); // asd: 1
                      setTimeout(myClass.sayHi, 0); // asd: undefined
                      
                        +2

                        Ну так вы и привели код который не будет работать.


                        setTimeout(myClass.sayHi.call(myClass), 0);

                        Вы пытаетесь undefined передать как обработчик для setTimeout.


                        Метод sayHi объекта myClass вызывается сразу, а потом его результат вы зачем-то передаете в setTimeout как колбэк.
                        Попробуйте поставить таймаут на 1000 миллисекунд, и поймете о чем я говорю.

                          0
                          Ааа, ну тут да, просто функция сразу вызывается и возвращает undefined таймауту для выполнения, вместо ссылки на функцию. Такие функции в качестве аргумента не передашь да. Ну значит увы и ах. И такой метод не катит. Недостатки JS, увы.
                          0
                          class A extends PureComponent {
                             a = 1;
                             b() { console.log(this.a); }
                             render() {
                              return <button onClick={this.b}>click</button>
                             }
                          }

                          Что должен здесь сделать трасформер кода?


                          return <button onClick={this.b.call(this)}>click</button>

                          Очевидно нет. А что тогда?

                      0

                      Какая альтернатива? Я не знаю способа который бы не подождал по функции на инстанс и мог привязать контекст к колбэку.


                      Проблема наследования решается точечно и на моей практике возникает очень редко как и описанная проблема тестирования.

                      0
                      Каждый конструктор тратит время на создание новой функции

                      Я бы убрал из минусов. Просто потому, что выбора у вас всё равно нет. Дело не в =>, а в том что не существует никакого способа привязать this к методу без создания новой функции. Используете класс-компоненты — используете ту или иную версию .bind, будь то =>, .bind, декораторы, whatever.


                      Да и, вспоминая хуки, мы эти лишние функции создаём уже, ну просто, в промышленных масштабах. Из-за замыканий и хуков.


                      Касательно наследования — мне кажется React и наследование столь сильно несовместимы, что любые попытки с этим воевать — зря. Особенно теперь, когда это по сути legacy.


                      Хотя разработчики React и хуков явно говорят в документации, что функции, которые возвращаются из useState и из useReducer, не меняются при ререндерах

                      Небольшой нюанс. Во время рендера к вам вместо setState из useState может, совершенно внезапно, прилететь () => {}. Да-да. Не удивляйтесь. Это react dev tools. Они там древо хуков в своей панели составляют. А теперь представьте себе такой код:


                      const [st, setSt] = useState(whatever);
                      const ref = useRef(setSt);
                      ref.current = setSt;

                      Если у вас в кодовой базе есть такой код, то у меня для вас плохие новости ;) Впрочем про то, почему нельзя мутировать или обращаться к ref value во время рендера можно целую статью сварганить.


                      Но если код получается слишком сложный, то React не понимает, что мы от него хотим, и просто монтирует новое дерево взамен старого

                      Вот это самое интересное. Но увы только "слишком сложный". Полез в код — там муть, надо разбираться в "что такое IndeterminateComponent". Если React может позволить себе на ровном месте выкинуть произвольный компонент из древа и создать его заного, то это чревато куда большими проблемами, чем просто performance.

                        0
                        Каждый конструктор тратит время на создание новой функции
                        Я бы убрал из минусов.

                        Всё-таки в V8 и нативный arrow method, и создание в конструкторе новой функции с нуля this.method = () => {} пока что медленнее, чем bind уже существующей в прототипе функции. V8 мне важен потому, что на нём работает большинство аудитории наших проектов.


                        https://jsbench.me/6nkjv4vq9u/1


                        Скорость выполнения, миллионов операций в секунду (больше — лучше):


                        Browser         Native =>    Transpiled =>    Bind
                        Chrome 87       1.8          24               55
                        ChromeMobile 87 0.18         2.7              4.5
                        Firefox 84      12           12               8
                        Safari 14       1.7          19               11

                        Чтобы лучше прочувствовать эти числа, можно использовать такой подход:


                        В ChromeMobile использование нативного arrow method означает лишние 5-6 микросекунд на создание экземпляра класса. Тысяча экземпляров — это уже 5-6 миллисекунд только на их конструирование, и это уже близко к бюджету в 16 миллисекунд на кадр при 60fps. Получаем ограничение сверху, которое ближе, чем хотелось бы, к реальным количествам экземпляров в реальных приложениях.

                          +1
                          Тысяча экземпляров — это уже 5-6 миллисекунд только на их конструирование

                          Тут скорее вопрос какова доля этих 5-6 микросекунд в общей инициализации проекта? Даже пусть в пределах кадра. Если стоит задача экономии на таком уровне, не проще взять условный Svelte? Или перестроить UI так, чтобы речь не шла о таких нюансах и можно было заняться бизнес-логикой :)

                          0
                          Во время рендера к вам вместо setState из useState может, совершенно внезапно, прилететь () => {}. Да-да. Не удивляйтесь. Это react dev tools. Они там древо хуков в своей панели составляют.

                          А вы могли бы на это завести баг в React Dev Tools?


                          Но увы только "слишком сложный". Полез в код — там муть, надо разбираться в "что такое IndeterminateComponent".

                          Я это увидел при работе с компонентом, завёрнутым в HOC, а внутри HOC использовался хук. И при переносе кусочков кода между HOC и хуком возникал этот эффект. Хорошо, что были unit-тесты, которые сразу же словили проблему с монтированием вместо апдейта. Плохо, что не получилось выделить минимальный кусок кода, в котором бы это воспроизводилось — всё слишком сложно, и времени не хватает, как обычно.


                          Кстати, если бы все разработчики писали для всех своих компонентов тесты на изменение пропсов и ререндер с проверкой, что произошёл именно апдейт, а не монтирование нового дерева, то мы бы скорее всего слышали об этой проблеме чаще.

                            0
                            А вы могли бы на это завести баг в React Dev Tools?

                            Оказалось, что это не баг :) It works as planned. И даже, за флагом, есть специальный валидатор.


                            Я это увидел при работе с компонентом, завёрнутым в HOC, а внутри HOC использовался хук. И при переносе кусочков кода между HOC и хуком возникал этот эффект.

                            Я надеюсь, что дело было всё таки в чём-то другом. Отбрасывать лишние рендеры ещё куда не шло, но демонтировать компоненты просто так это уже какая-то дичь :-(

                              +1
                              Оказалось, что это не баг :) It works as planned. И даже, за флагом, есть специальный валидатор.

                              То есть, правила работы с ref теперь гласят:


                              • чтение и запись ref.current — это сайд-эффекты, поэтому нельзя безопасно перезаписывать и даже просто читать ref.current при рендере
                              • можно сохранять что-то в начальном значении ref
                              • можно читать/писать ref.current внутри useEffect()

                              Если в коде из упомянутого бага запоминать setState так, как требуют правила, то React DevTools не ломают работу нашего кода


                                const [state, setState] = useState("A");
                              
                                // так нельзя
                                // const setStateRef = useRef();
                                // setStateRef.current = setState;
                              
                                // так можно
                                const setStateRef = useRef(setState);

                              Если всё сформулировать так, то я не вижу ничего страшного. Да, React DevTools подменяют useState и подсовывают свой setState, чтобы узнать, как устроены наши хуки, но делают это в соответствии с правилами. И если мы работаем с ref и с полученным setState тоже по правилам, то наш код не сломается от включения DevTools, и мы всё так же можем применять принцип "запомнить первый setState и не перегенерировать зависящие от него функции".

                                0

                                Да, всё так.

                          –1

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


                          При том что года 2 назад сервисом можно было пользоваться.

                            0
                            Добрый день. Подскажите, пожалуйста, номер обращения в поддержку по этой проблеме. Вряд ли эта проблема наблюдается у всех (иначе отзывов было бы сильно больше).
                            0

                            Простите, но ваш код — сферический в вакууме. Есть какие-то замеры в начале, а дальше ожидал больше цифр от цифровой компании.


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

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое