Как стать автором
Обновить

Что такое мемоизация? Как и когда использовать мемоизацию в JavaScript и React

JavaScript *Клиентская оптимизация *Функциональное программирование *ReactJS *TypeScript *
Из песочницы
Перевод
Автор оригинала: German Cocca

От автора перевода: Обращения и рекомендации во всей статье кроме 5 пункта идут от лица автора оригинальной статьи

Всем привет! В этой статье мы поговорим о мемоизации (запоминании), методе оптимизации, который может помочь сделать тяжелые вычислительные процессы более эффективными.

Мы начнем с разговора о том, что такое мемоизация (запоминание) и когда его лучше всего реализовать. Позже мы приведем практические примеры для JavaScript и React.


Оглавление статьи

  1. Что такое мемоизация

  2. Как она работает

  3. Пример мемоизации в JavaScript

  4. Пример мемоизации в React
    4.1 Pure components
    4.2 PureComponent class
    4.3 Компонент высшего порядка memo
    4.4 Когда использовать хук useCallback
    4.5 Когда использовать хук useMemo
    4.6 Когда использовать мемоизацию

  5. PS от автора перевода

Что такое мемоизация

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

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

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

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

Как она работает

Концепция мемоизации в JavaScript основана на двух концепциях:

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

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

Пример мемоизации в JavaScript

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

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

Это выглядит примерно так:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, …

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

const fib = n => {
  if (n <= 1) return 1;
  return fib(n - 1) + fib(n - 2);
}

Если вы не знакомы с рекурсией, это просто концепция функции, которая вызывает саму себя, с каким-то базовым кейсом, чтобы избежать бесконечного цикла (в нашем случае, если (n <= 1)).

Если мы вызовем нашу функцию, подобную fib(5), под капотом наша функция будет выполняться следующим образом:

Видите, что мы выполняем fib(0), fib(1), fib(2) и fib(3) несколько раз. Что ж, это именно та проблема, которую помогает решить кэширование результатов вычислений.

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

При использовании мемоизации наша функция будет выглядеть примерно следующим образом:

const fib = (n, memo) => {
    memo = memo || {};

    if (memo[n]) return memo[n];

    if (n <= 1) return 1;
    return memo[n] = fib(n-1, memo) + fib(n-2, memo);
}

Что мы делаем в первую очередь, так это проверяем, получили ли мы объект memo в качестве параметра. Если мы этого не сделали, мы установили его как пустой объект:

memo = memo || {}

Затем мы проверяем, содержат ли ключи переменной memo значение, которое мы получаем в качестве параметра. Если это так, мы просто вернем его. Вот где происходит волшебство. Нет необходимости в дополнительной рекурсии, как только мы сохраним наше значение в memo. =)

if (memo[n]) return memo[n]

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

return memo[n] = fib(n-1, memo) + fib(n-2, memo)

И это все! С помощью двух строк кода мы внедрили запоминание и значительно улучшили производительность нашей функции!

Пример мемоизации в React

В React мы можем оптимизировать наше приложение, избегая ненужного повторного рендеринга компонентов с помощью запоминания. Как я уже упоминал в статье об управлении состоянием в React, компоненты повторно рендерятся из-за двух вещей:

  1. Изменения состояния

  2. Изменение props

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

Но прежде чем мы перейдем к коду, давайте разберем некоторые важные понятия.

Pure components

React поддерживает либо классовые, либо функциональные компоненты.

  1. Functional component - это обычная функция JavaScript, которая возвращает JSX

  2. Class component - это класс JavaScript, который расширяет React.Component и возвращает JSX внутри метода рендеринга.

И что же тогда представляет собой pure component? Что ж, основываясь на концепции чистоты в парадигмах функционального программирования, функция считается чистой, если:

  1. Её возвращаемое значение определяется только его входными значениями

  2. Её возвращаемое значение всегда одинаковое для одинаковых входных значений

Точно так же React Component считается чистым, если он отображает один и тот же вывод для одного и того же состояния и props.

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

// Pure component
export default function PureComponent({name, lastName}) {
  return (
    <div>My name is {name} {lastName}</div>
  )
}

Мы передаем два props, а компонент отображает их. И рендеринг будет всегда одинаковым для одинаковых props.

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

// Impure component
export default function ImpurePureComponent({name, lastName}) {
  return (
    <div>My "impure" name is {name + Math.random()} {lastName + Math.random()}</div>
  )
}

Те же примеры с использованием React.Component:

// Pure component
class PureComponent extends React.Component {
    render() {
      return (
        <div>My "name is {this.props.name} {this.props.lastName}</div>
      )
    }
  }

export default PureComponent;
// Impure component
class ImpurePureComponent extends React.Component {
    render() {
      return (
        <div>My "impure" name is {this.props.name + Math.random()} {this.props.lastName + Math.random()}</div>
      )
    }
  }

export default ImpurePureComponent;

PureComponent Class

Для создания класса PureComponents для реализации меморизации React предоставляет базовый класс PureComponent.

Компоненты класса, расширяющие класс React.PureComponent имеет некоторые улучшения производительности и оптимизации рендеринга. Это связано с тем, что React реализует для них метод shouldComponentUpdate() с неглубоким сравнением props и состояний.

Давайте рассмотрим это на примере. Здесь у нас есть компонент класса, который представляет собой счетчик, с кнопками для изменения этого счетчика, добавляющего или вычитающего числа. У нас также есть дочерний компонент, которому мы передаем "name", представляющий собой строку.

import React from "react";
import Child from "./child";

class Counter extends React.Component {
    constructor(props) {
      super(props);
      this.state = { count: 0 };
    }

    handleIncrement = () => { this.setState(prevState => {
        return { count: prevState.count - 1 };
      })
    };

    handleDecrement = () => { this.setState(prevState => {
        return { count: prevState.count + 1 };
      })
    };

    render() {
      console.log("Parent render");

      return (
        <div className="App">

          <button onClick={this.handleIncrement}>Increment</button>
          <button onClick={this.handleDecrement}>Decrement</button>

          <h2>{this.state.count}</h2>

          <Child name={"Skinny Jack"} />
        </div>
      )
    };
  }

  export default Counter;

Дочерний компонент - это чистый компонент, который просто отображает полученный prop.

import React from "react";

class Child extends React.Component {
    render() {
      console.log("Skinny Jack")
      return (
          <h2>{this.props.name}</h2>
      )
    }
  }

export default Child;

Обратите внимание, что мы добавили логирование в обоих компонентах, так что мы получаем консольные сообщения каждый раз, когда они отображаются. И, говоря об этом, угадайте, что происходит, когда мы нажимаем кнопки увеличения или уменьшения? Наша консоль будет выглядеть следующим образом:

Дочерний компонент выполняет повторный рендеринг, даже если он получает один и тот же prop.

Чтобы внедрить мемоизацию и оптимизировать эту ситуацию, нам необходимо расширить React.PureComponent компонента в нашем дочернем компоненте, например:

import React from "react";

class Child extends React.PureComponent {
    render() {
      console.log("Skinny Jack")
      return (
          <h2>{this.props.name}</h2>
      )
    }
  }

export default Child;

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

Просто начальный рендеринг дочернего компонента и никаких ненужных повторных рендеров, когда prop не изменился. Проще простого. ;)

При этом мы рассмотрели компоненты класса, но в функциональных компонентах мы не можем расширить класс React.PureComponent. Вместо этого React предлагает один HOC (High-Order Component) и два хука для работы с мемоизацией.

Компонент высшего порядка memo

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

import { useState } from 'react';
import Child from "./child";

export default function Counter() {

    const [count, setCount] = useState(0);

    const handleIncrement = () => setCount(count+1);
    const handleDecrement = () => setCount(count-1);

    return (
        <div className="App">
            {console.log('parent')}
            <button onClick={() => handleIncrement()}>Increment</button>
            <button onClick={() => handleDecrement()}>Decrement</button>

            <h2>{count}</h2>

            <Child name={"Skinny Jack"} />
        </div>                    
    )
}
import React from 'react';

export default function Child({name}) {
console.log("Skinny Jack")
  return (
    <div>{name}</div>
  )
}

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

import React from 'react';

export default React.memo(function Child({name}) {
console.log("Skinny Jack");
  return (
    <div>{name}</div>
  )
})

Компонент более высокого порядка или HOC аналогичен функции более высокого порядка в javascript. Функции более высокого порядка - это функции, которые принимают другие функции в качестве аргументов ИЛИ возвращают другие функции. В React HOC берут компонент как prop и манипулируют им с какой-то целью, фактически не изменяя сам компонент. Вы можете представить это как компоненты-оболочки.

В этом случае memo выполняет ту же работу, что и Pure Component, избегая ненужного повторного отображения компонентов, которые он оборачивает.

Когда использовать хук useCallback

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

import { useState } from 'react';
import Child from "./child";

export default function Counter() {

    const [count, setCount] = useState(0);

    const handleIncrement = () => setCount(count+1);
    const handleDecrement = () => setCount(count-1);

    return (
        <div className="App">
            {console.log('parent')}
            <button onClick={() => handleIncrement()}>Increment</button>
            <button onClick={() => handleDecrement()}>Decrement</button>

            <h2>{count}</h2>

            <Child name={console.log('Really Skinny Jack')} />
        </div>                    
    )
}
import React from 'react';

export default React.memo(function Child({name}) {
console.log("Skinny Jack");
  return (
    <>
        {name()}
        <div>Really Skinny Jack</div>
    </>
  )
})

Теперь наш prop - это функция, которая всегда регистрирует одну и ту же строку, и ваша консоль снова будет выглядеть следующим образом:

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

Чтобы справиться с этой проблемой, react предоставляет useCallback хук. Мы можем реализовать его следующим образом:

import { useState, useCallback } from 'react';
import Child from "./child";

export default function Counter() {

    const [count, setCount] = useState(0);

    const handleIncrement = () => setCount(count+1);
    const handleDecrement = () => setCount(count-1);

    return (
        <div className="App">
            {console.log('parent')}
            <button onClick={() => handleIncrement()}>Increment</button>
            <button onClick={() => handleDecrement()}>Decrement</button>

            <h2>{count}</h2>

             <Child name={ useCallback(() => {console.log('Really Skinny Jack')}, [])  } />
        </div>                    
    )
}

И это решает проблему ненужного повторного рендеринга дочерних элементов.

Что делает useCallback, так это удерживает значение функции, несмотря на повторный рендеринг родительского компонента, поэтому дочерний prop останется прежним до тех пор, пока значение функции также остается прежним.

Чтобы использовать его, нам просто нужно useCallback хук вокруг объявляемой нами функции. В массиве, присутствующем в хуке, мы можем объявить переменные, которые будут инициировать изменение значения функции, когда переменная тоже изменится (точно так же, как работает useEffect).

const testingTheTest = useCallback(() => { 
    console.log("Tested");
  }, [a, b, c]);

Когда использовать хук useMemo

useMemo - это хук, очень похожий на useCallback, но вместо кэширования функции useMemo будет кэшировать возвращаемое значение функции.

В этом примере useMemo будет кэшировать число 2:

const num = 1;
const answer = useMemo(() => num + 1, [num]);

В то время как useCallback закеширует () => num + 1

const num = 1;
const answer = useCallback(() => num + 1, [num]);

Вы можете использовать useMemo очень похожим образом на memo HOC. Разница в том, что useMemo - это хук с массивом зависимостей, а memo - это HOC, который принимает в качестве параметра необязательную функцию, использующую props для обновления компонента с условием.

Более того, useMemo кэширует значение, возвращаемое между рендерами, в то время как memo кэширует весь react компонент между рендерами.

Когда использовать мемоизацию

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

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

Я рекомендую эту статью Кента К. Доддса для получения дополнительной информации по этой теме.

PS от автора перевода

Благодарю Вас если вы дочитали до этого момента. Меня зовут Руслан и я работаю Typescript Backend разработчиком. Это мой первый перевод для Хабра, поэтому буду благодарен любым комментариям и замечаниям.

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0 +4
Просмотры 13K
Комментарии Комментарии 6