прокси-сравнение и прокси-мемоизирование

Введение

Прошло много времени с тех пор, как я начал разрабатывать reactive-react-redux и response-tracked. Эти библиотеки обеспечивают так называемое отслеживание использования состояния для оптимизации рендеринга в React. Этот подход, на мой взгляд, довольно новый, и я приложил немало усилий, чтобы улучшить его производительность.

В последнее время я подумал, что было бы лучше, если бы это можно было использовать более широко. Мне было интересно, можно ли его использовать в ванильном JS. Каким будет API в ванильном JS? Было бы хорошо, если бы это было легко понять. Моя идея закончилась мемоизацией, в основном потому, что основная цель - заменить повторный выбор.

Новая библиотека называется proxy-memoize.

прокси-мемоизация

GitHub: https://github.com/dai-shi/proxy-memoize

Библиотека proxy-memoize предоставляет функцию запоминания. Он примет функцию и вернет мемоизированную функцию.

import memoize from 'proxy-memoize';
const fn = (x) => ({ foo: x.foo });
const memoizedFn = memoize(fn);

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

const unsupportedFn1 = (number) => number * 2;
const unsupportedFn2 = (obj1, obj2) => [obj1.foo, obj2.foo];

Это позволит кэшировать результаты с помощью WeakMap. Мы можем кэшировать столько результатов, сколько захотим, и позволить JS-мусору собирать мусор, когда они перестают быть эффективными.

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

Давайте посмотрим на следующие примеры.

const obj = { a: 1, b: { c: 2, d: 3 } };
// initially affected is empty
console.log(obj.a) // touch "a" property
// affected becomes "a"
console.log(obj.b.c) // touch "b.c" property
// affected becomes "a", "b.c"

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

Давайте посмотрим на пример.

const fn = (obj) => obj.arr.map((x) => x.num);
const memoizedFn = memoize(fn);
const result1 = memoizedFn({
  arr: [
    { num: 1, text: 'hello' },
    { num: 2, text: 'world' },
  ],
})
// affected is "arr[0].num", "arr[1].num" and "arr.length"
const result2 = memoizedFn({
  arr: [
    { num: 1, text: 'hello' },
    { num: 2, text: 'proxy' },
  ],
  extraProp: [1, 2, 3],
})
// affected properties are not change, hence:
result1 === result2 // is true

Отслеживание использования и сравнение затронутых событий выполняется внутренней библиотекой proxy-compare.

прокси-сравнение

GitHub: https://github.com/dai-shi/proxy-compare

Это библиотека, извлеченная из response-tracked, чтобы обеспечить только функцию сравнения с прокси. (На самом деле, версия 2 с отслеживанием реакции будет использовать эту библиотеку как зависимость.)

Библиотека экспортирует две основные функции: createDeepProxy и isDeepChanged.

Это работает следующим образом:

const state = { a: 1, b: 2 };
const affected = new WeakMap();
const proxy = createDeepProxy(state, affected);
proxy.a // touch a property
isDeepChanged(state, { a: 1, b: 22 }, affected) // is false
isDeepChanged(state, { a: 11, b: 2 }, affected) // is true

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

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

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

Как обсуждалось в прошлом сообщении, одним из вариантов является использование useMemo. Если использовать proxy-memoize с useMemo, мы сможем получить такое же преимущество, как и response-tracked.

import memoize from 'proxy-memoize';
const MyContext = createContext();
const Component = () => {
  const [state, dispatch] = useContext(MyContext);
  const render = useMemo(() => memoize(({ firstName, lastName }) => (
    <div>
      First Name: {firstName}
      <input
        value={firstName}
        onChange={(event) => {
          dispatch({ type: 'setFirstName', firstName: event.target.value });
        }}
      (Last Name: {lastName})
      />
    </div>
  )), [dispatch]);
  return render(state);
};
const App = ({ children }) => (
  <MyContext.Provider value={useReducer(reducer, initialState)}>
    {children}
  </MyContext.Provider>
);

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

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

Повторный выбор может быть простой заменой.

import { useDispatch, useSelector } from 'react-redux';
import memoize from 'proxy-memoize';
const Component = ({ id }) => {
  const dispatch = useDispatch();
  const selector = useMemo(() => memoize((state) => ({
    firstName: state.users[id].firstName,
    lastName: state.users[id].lastName,
  })), [id]);
  const { firstName, lastName } = useSelector(selector);
  return (
    <div>
      First Name: {firstName}
      <input
        value={firstName}
        onChange={(event) => {
          dispatch({ type: 'setFirstName', firstName: event.target.value });
        }}
      />
      (Last Name: {lastName})
    </div>
  );
};

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

memoize((state) => state.users.map((user) => user.firstName))

Это будет переоценено только в случае изменения длины users или изменения одного из firstName. Он продолжает возвращать кешированный результат даже при изменении lastName.

Заключительные примечания

На развитие меня вдохновили отношения между MobX и Immer. Я вообще не знаком с их реализациями, но мне кажется, что Immer - это подмножество MobX для более широких вариантов использования. Я хотел создать что-то вроде Immer. Immer позволяет волшебным образом преобразовывать изменяемые операции (запись) в неизменяемые объекты. proxy-memoize позволяет волшебным образом создавать функции выбора (чтения) для неизменяемых объектов.

Первоначально опубликовано на https://blog.axlight.com 29 ноября 2020 г.