Можно ли улучшить производительность JSON.parse ()?

JSON.parse - это медленный способ создания копии объекта. Но может ли это улучшить производительность нашего кода?

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

Проблема

Создание копии объекта - обычная практика в JS. Вероятно, вы делали это при создании редюсеров в Redux или где-либо еще. В настоящее время наиболее распространенный синтаксис для этого:

const objA = { name: 'Jack', surname: 'Sparrow' };
const objB = { ...objA };

использование на практике:

function dataReducer(state = { name: '', surname: '' }, action) {
  switch (action.type) {
    case ACTION_TYPES.SET_NAME:
      return {
        ...state,
        name: action.name,
      };
    case ACTION_TYPES.SET_SURNAME:
      return {
        ...state,
        surname: action.surname,
      };
    default:
      return state;
  }
}

но это можно сделать разными способами (не считая различных библиотек)

const objC = Object.assign({}, objA);
const objD = JSON.parse(JSON.stringify(objA));

Если вы проверите, сколько времени потребуется для копирования 1⁰⁹ объекта с помощью этих методов, вы получите следующие результаты (каждый раз, когда мы копируем objA):

test with spread: 14 ms. 
test with Object.assign: 36 ms. 
test with JSON.parse: 702 ms.

Ясно, что JSON.parse - самый медленный из них, и с некоторым отрывом. Почему вам даже стоит подумать об использовании его вместо распространения?

Не столь очевидное поведение двигателя V8

Все сводится к тому, как V8 оптимизирует функции. Каждый раз, когда выполняется функция, V8 сравнивает переданный ему объект с IC (встроенный кэш), и если Shape этого объекта хранится внутри одного из «кешей», то V8 может следовать «быстрому пути».

Итак, если у вас есть такая функция

function test(obj) {
  let result = '';
  for (let i = 0; i < N; i += 1) {
    result += obj.a + obj.b;
  }
  return result;
}

вы можете запускать его с несколькими объектами одинаковой формы и иметь отличную производительность

const jack = { name: 'Jack', surname: 'Sparrow' };
const frodo = { name: 'Frodo', surname: 'Baggins' };
const charles = { name: 'Charles', surname: 'Xavier' };
test(jack);
test(frodo);
test(charles);

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

Давайте проверим, какие формы получаются при использовании каждого из трех методов копирования:

Выполните приведенный ниже код, используя d8 --allow-natives-syntax index.js, чтобы получить доступ к внутренним методам V8, таким как %HaveSameMap()

//testSpread.js
const objA = { name: 'Jack', surname: 'Sparrow' };
const objB = { ...objA };
console.log(%HaveSameMap(objA, objB)); // false
//testAssign.js
const objA = { name: 'Jack', surname: 'Sparrow' };
const objC = Object.assign({}, objA);
console.log(%HaveSameMap(objA, objC)); // false
//testParse.js
const objA = { name: 'Jack', surname: 'Sparrow' };
const objD = JSON.parse(JSON.stringify(objA));
console.log(%HaveSameMap(objA, objD)); // true

Как видите, только JSON.parse(JSON.stringify(objA)) создает объект, имеющий ту же форму, что и objA.

Стоимость немономорфных функций

Вот наша функция

function test(obj) {
  let result = '';
  // Any costly operation
  for (let i = 0; i < N; i += 1) {
    result += obj.name + obj.surname;
  }
  return result;
}

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

const jack = { name: 'Jack', surname: 'Sparrow' };
const frodo = { name: 'Frodo', surname: 'Baggins' };
const charles = { name: 'Charles', surname: 'Xavier' };
const legolas = { name: 'Legolas', surname: 'Thranduilion' };
const indiana = { name: 'Indiana', surname: 'Jones' };
for (let i = 0; i < N; i += 1) {
  test(JSON.parse(JSON.stringify(jack)));
  test(JSON.parse(JSON.stringify(frodo)));
  test(JSON.parse(JSON.stringify(charles)));
  test(JSON.parse(JSON.stringify(legolas)));
  test(JSON.parse(JSON.stringify(indiana)));
}
for (let i = 0; i < N; i += 1) {
  test({ ...jack });
  test({ ...frodo });
  test({ ...charles });
  test({ ...legolas });
  test({ ...indiana });
}

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

Если вы установите N на 10000 и запустите этот цикл, результат может вас удивить:

test with PARSE: 2522 ms. 
test with spread: 10046 ms.

Какие? Спред в 4 раза медленнее, чем JSON.parse? Если это странно, помни, что я сказал раньше

V8 отметит эту функцию как мономорфную и оптимизирует ее код

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

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

Заключение

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

Я не говорю, что нужно заменять все операторы распространения на JSON.parse, так как это снизит производительность вашего приложения. Я считаю, что иногда снижение производительности одного элемента может значительно улучшить производительность другого.

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

Первоначально опубликовано на https://erdem.pl.