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

В этом посте я сделаю еще один шаг в своей серии статей о производительности, и мы рассмотрим прием оптимизации в React с использованием мемоизации для повышения производительности!

Совет. Используйте Bit для организации совместного использования и повторного использования компонентов JS / React между вашей командой и / или в ваших проектах. Также обратите внимание на пользовательский интерфейс Bit - он позволяет легко обнаруживать общие компоненты.

Мемоизация

Для начала, кто не знает, мемоизация. Мемоизация - это процесс кэширования результата ввода, линейно связанного с его выводом, поэтому, когда ввод запрашивается, вывод возвращается из кеша без каких-либо вычислений.

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

В основном мемоизация применяется в таких дорогостоящих вычислениях, как:

  • Рекурсивные функции (например, система Фибоначчи, факториал,…)
  • Вычисления объектов Game Engine
  • Объектное моделирование
  • Алгоритмы машинного обучения
  • Физика игрового движка
  • Рендеринг GUI (графический интерфейс пользователя)
  • так далее

Давайте продемонстрируем, как работает мемоизация. Допустим, у нас есть такая дорогая функция:

function longOp(input) {
    var now = Date.now()
    var end = now + 3000
    while(now < end) {
        now = Date.now()
    }
    return input * 90
}

Для выполнения функции и возврата результата требуется 3s. Давайте представим, что 3secounds очень большие. Теперь, если мы запустим функцию longOp примерно 10 раз в нашем приложении, это означает, что это займет у нашего приложения 30 секунд !!! запустить и выполнить.

longOp(9) // 3s
longOp(5) // 3s
longOp(9) // 3s
longOp(7) // 3s
longOp(9) // 3s
longOp(9) // 3s
longOp(9) // 3s
longOp(5) // 3s
longOp(7) // 3s
longOp(5) // 3s
// Total: 30s

Обратите внимание, что мы вызвали функцию longOp с вводом 9 5 раз, с 7 дважды и с 5 три раза. Видя, что функция дорогая, было бы неразумно повторно запускать функцию с 9 после первоначального запуска с тем же значением. Мы должны кэшировать первое значение и возвращать кешированное значение, когда ввод повторяется снова.

Итак, теперь начальная 9 будет работать в течение 3 с, но другие последующие запуски с 9 будут работать быстрее, например, 0,1 с.

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

function longOp(input) {
    if (!longOp.cache) {
        longOp.cache = {}
    }
    if (!longOp.cache[input]) {
        var now = Date.now()
        var end = now + 3000
        while (now < end) {
            now = Date.now()
        }
        return longOp.cache[input] = input * 90
    }
    return longOp.cache[input]
}

Сначала функция проверяет, создан ли объект кеша, если нет, то создает объект кеша. Затем он проверяет, находится ли ввод уже в объекте кеша, если нет, он запускает дорогостоящую операцию и после этого сохраняет результат в объекте кеша с вводом в качестве ключа. если есть попадание в кеш, он просто возвращает значение ключа ввода из объекта кеша.

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

Давайте проверим это:

// ...
// warm up the function
for (var i = 0; i < 10; i++) {
    longOp(0)
}
C.time('')
log(longOp(9))
C.timeEnd('')
C.time('')
log(longOp(9))
C.timeEnd('')

Сначала подогрели функцию. Поскольку мы используем Nodejs, он построен на движке v8, JS от Google. Один из приемов оптимизации, который он (v8) использует для ускорения выполнения нашего JS-кода, - это спекулятивная оптимизация. Спекулятивная оптимизация - это процесс, в котором движок JS может угадывать или предсказывать будущие типы параметров, передаваемых функции / методу, поскольку он динамически типизирован, т.е. его тип данных определяется во время выполнения. В JavaScript есть спецификация создания компиляторов и интерпретаторов.

Если мы напишем этот код:

function add(a, b) {
    return a + b
}

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

3 + 5 = 8
2 + 5 = 7
6 + 90 = 96

Это может быть конкатенация строк или слов

"Nnamdi" + " " + "Chidume" = Nnamdi Chidume

При всем этом JS-движок будет пытаться передать наш код всем типам добавления в другие, чтобы получить результат. И с этим возникает огромное узкое место в производительности.

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

В нашей функции добавления, если мы вызываем ее с такими типами чисел:

add(9,56)
add(45,56)
add(8,3)

v8 видит, что числовые типы встречаются трижды, поэтому теперь он может предположить или сделать вывод, что следующие iputs будут иметь числовые типы, поэтому он сгенерирует машинный код, который будет иметь дело только с числами.

Чтобы понять это (спекулятивная оптимизация в версии 8), обратите внимание на мою статью по этой теме.

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

C.time('')
log(longOp(9))
C.timeEnd('')
C.time('')
log(longOp(9))
C.timeEnd('')

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

$ node longop
810
: 3106.225ms
810
: 51.036ms

Видеть!! более половины предыдущего времени !! Это огромный прирост производительности.

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

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

В React

Совет: при работе с компонентами пользовательского интерфейса используйте Бит, чтобы легко повторно использовать и синхронизировать их между вашими приложениями. Вы можете написать компонент один раз и поделиться им со всеми своими проектами и приложениями, синхронизируя изменения между ними! "Учить больше"

Давайте посмотрим на этот компонент:

class AppComponent {
    constructor(props) {
        super(props)
        this.state = {
            input: 0
        }
    }
    longOp = input => {
        // simulating expensive operation
        console.log('starting')
        var now = Date.now()
        var end = now + 1000
        while (now < end) {
            now = Date.now()
        }
        return input * 90
    }
    handleInput = evt => {
        console.log('handling input', evt)
        this.setState({ input: evt.target.value })
    }
    render() {
        return ( 
            <div>
                <input onChange = {this.handleInput} /> 
                <h2> { this.longOp(this.state.input) } </h2> 
            </div>
        )
    }
}

Всякий раз, когда мы вводим число в элемент ввода, вход состояния устанавливается с помощью функции setState, эта функция запускает повторный рендеринг компонента. Вызывается метод рендеринга, где выполняется longOp с переданным ему входным свойством состояния. LongOp будет работать под окном 10 секунд, это приведет к тому, что наше приложение буквально зависнет, например, останется без ответа в течение 10 секунд !!! Это огромное узкое место в производительности. Это не тот случай, когда наше приложение выполняет ненужные повторные отрисовки, которые официально называются потраченными впустую отрисовками. Компонент можно сделать чистым с помощью компонента React.PureComponent.

class AppComponent extends React.PureComponent {
    constructor(props) {
        super(props)
        this.state = {
            input: 0
        }
    }
    longOp = input => {
        // simulating expensive operation
        console.log('starting')
        var now = Date.now()
        var end = now + 1000
        while (now < end) {
            now = Date.now()
        }
        return input * 90
    }
    handleInput = evt => {
        console.log('handling input', evt)
        this.setState({ input: evt.target.value })
    }
    render() {
        return ( 
            <div>
                <input onChange = {this.handleInput} /> 
                <h2> { this.longOp(this.state.input) } </h2> 
            </div>
        )
    }
}

но для каждого необходимого запуска / рендеринга триггеров React.PureComponent все равно требуется 10 секунд.

Если мы введем 4, longOp будет работать в течение 10 мс. Введите 5, longOp будет работать в течение 10 мс. Введите 5, longOp не будет запускаться, потому что предыдущее значение было 5. Введите 4, longOp будет работать в течение 10 мс (этого не должно быть, потому что функция уже видела ввод ранее). Введите 5, longOp будет работать в течение 10 мс (этого не должно быть, потому что функция уже видела ввод ранее).

Чистота теперь больше не имеет значения, потому что на рендеринг по-прежнему уходит огромное количество времени !!. Операция longOp не должна выполняться на последующих входах исходного ввода, например 5, не должна запускать longOp после исходного ввода.

Если вы используете оба компонента в своем браузере, вы испытаете серьезное замедление работы вашего браузера !! Будьте осторожны при запуске.

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

function memoize(fn) {
    return function () {
        var args =
Array.prototype.slice.call(arguments)
        fn.cache = fn.cache || {};
        return fn.cache[args] ? fn.cache[args] : (fn.cache[args] = fn.apply(this,args))
    }
}

В наших компонентах React мы назовем это передачей метода, который мы хотим запомнить:

class AppComponent {
    constructor(props) {
        super(props)
        this.state = {
            input: 0
        }
    }
    longOp = memoize((input) => {
        // simulating expensive operation
        console.log('starting')
        var now = Date.now()
        var end = now + 1000
        while (now < end) {
            now = Date.now()
        }
        return input * 90
    })
    handleInput = evt => {
        console.log('handling input', evt)
        this.setState({ input: evt.target.value })
    }
    render() {
        return ( 
            <div>
                <input onChange = {this.handleInput} /> 
                <h2> { this.longOp(this.state.input) } </h2> 
            </div>
        )
    }
}

Мы запомнили метод longOp, передав его в качестве параметра функции memoize. Когда ввод передается функции, он сохраняется в кеше и выполняется дорогостоящая операция, при последующем вводе того же значения дорогостоящая операция пропускается, а результат возвращается из кеша.

Теперь наш компонент будет медленным при вводе ввода, но при повторном вводе того же ввода DOM обновится почти мгновенно.

Переписав тот же компонент в функциональный компонент, он будет выглядеть так:

function longOp(input) {
    // simulating expensive operation
    console.log('starting')
    var now = Date.now()
    var end = now + 1000
    while (now < end) {
        now = Date.now()
    }
    return input * 90
}
function handleInput(evt) {
    return evt.target.value
}

function App() {
    let [state, setState] = useState(0)
    return ( 
        <div>
            <input onChange={(evt)=>setState(handleInput(evt))} />
            <h2> { longOp(state) } </h2>  
        </div>
    );
}
export default App;

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

const longOp = memoize((input) => {
    // simulating expensive operation
    console.log('starting')
    var now = Date.now()
    var end = now + 1000
    while (now < end) {
        now = Date.now()
    }
    return input * 90
})
// ...

Как обычно, первоначальный ввод будет медленным, но будет быстрее при повторном вводе.

Использование useMemo

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

const memoizedFunc = useMemo(()=>longOp(input),[input])

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

Адаптация useMemo к нашему варианту использования:

function longOp(input) {
    // simulating expensive operation
    console.log('starting')
    var now = Date.now()
    var end = now + 1000
    while (now < end) {
        now = Date.now()
    }
    return input * 90
}
function handleInput(evt) {
    return evt.target.value
}

function App() {
    let [state, setState] = useState(0)
    let memoizedLongOp = useMemo(()=> longOp(input), [input])
    return ( 
        <div>
            <input onChange={(evt)=>setState(handleInput(evt))} />
            <h2> { memoizedLongOp(state) } </h2>  
        </div>
    );
}
export default App;

НО, с этой нашей проблемой все еще существует проблема, такая же, как мы видели ранее с React.PureComponent. Мемоизация, которую предоставляет React, отличается от нашей собственной реализации.

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

Если мы вводим 8, выполняется longOp (10 мс). если мы введем 8, longOp не будет запущен. Ура!! 1, если мы вводим 5, longOp запускается, если мы вводим 8, longOp запускается, чего не должно быть, потому что функция уже увидела ввод, она должна была обойти дорогостоящий запуск 10ms и вернуть результат, вычисленный ранее из кеш.

Видите ли ... именно здесь React предоставляет функции мемоизации, а их реализации терпят неудачу.

Но все зависит от разработчика, его дизайна и реализации.

Заключение

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

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

Если у вас есть какие-либо вопросы относительно этого или чего-либо, что я должен добавить, исправить или удалить, не стесняйтесь комментировать ниже и спрашивать / делиться чем угодно! Спасибо 👍

Учить больше