В этой статье мы увидим, как JavaScript усложняет нам жизнь, добавляя методы массива, которые изменяют исходный массив как часть языка. Но это еще не все мрак и гибель. К концу статьи мы напишем несколько функций, которые исправят эти проблемы, и вы сможете начать использовать эти функции в своем коде уже сегодня.
Мутации массива в JavaScript
Массивы в JavaScript — это просто объекты, а это значит, что они могут быть изменены. Фактически, многие из встроенных методов массива изменяют сам массив. Это может означать, что золотое правило сверху нарушается, просто используя один из встроенных методов.
Вот пример, показывающий, как это потенциально может вызвать некоторые проблемы:
const numbers = [1,2,3]; const countdown = numbers.reverse();
Этот код выглядит нормально. У нас есть вызванный массив, numbersи мы хотим, чтобы был вызван другой массив, countdownкоторый перечисляет числа в обратном порядке. И вроде работает. Если вы проверите значение countdownпеременной, мы этого и ожидаем:
countdown << [3,2,1]
Но досадный побочный эффект операции заключается в том, что reverse()метод также изменил numbersмассив, что совсем не то, что мы хотели:
numbers << [3,2,1]
Хуже того, обе переменные ссылаются на один и тот же массив, поэтому любые изменения, которые мы впоследствии вносим в одну, повлияют на другую. Например, если мы используем Array.prototype.push()метод для добавления значения 0в конец countdownмассива, он будет делать то же самое с numbersмассивом (потому что они оба ссылаются на один и тот же массив):
countdown.push(0) << 4 countdown << [3,2,1,0] numbers << [3,2,1,0]
Именно такие побочные эффекты могут остаться незамеченными — особенно в глубине большого приложения — и вызвать некоторые ошибки, которые очень сложно отследить.
И reverseэто не единственный метод массива, который вызывает такого рода неприятности с мутациями. Вот список методов массива, которые изменяют вызываемый массив:
- Array.prototype.pop ()
- Array.prototype.push ()
- Array.prototype.shift ()
- Array.prototype.unshift ()
- Array.prototype.reverse ()
- Array.prototype.sort ()
- Array.prototype.splice ()
Немного сбивает с толку, у массивов также есть некоторые методы, которые не изменяют исходный массив, а вместо этого возвращают новый массив:
- Array.prototype.slice ()
- Array.prototype.concat ()
- Array.prototype.map ()
- Array.prototype.filter ()
Эти методы вернут новый массив на основе выполненной ими операции. Например, этот map()метод можно использовать для удвоения всех чисел в массиве:
const numbers = [1,2,3]; const evens = numbers.map(number => number * 2); << [2,4,6]
Теперь, если мы проверим numbersмассив, мы увидим, что вызов метода не повлиял на него:
numbers << [1,2,3]
Кажется, нет никаких причин, по которым одни методы изменяют массив, а другие нет (хотя тенденция последних дополнений состоит в том, чтобы сделать их немутантными), поэтому может быть трудно вспомнить, какие из них делают.
У Ruby есть хорошее решение этой проблемы в том, что он использует нотацию взрыва. Любой метод, который вызывает постоянное изменение вызывающего его объекта, завершается взрывом, поэтому [1,2,3].reverse!массив будет перевернут, тогда как [1,2,3].reverseвернет новый массив с перевернутыми элементами.
Давайте исправим этот беспорядок!
Итак, теперь, когда мы установили, что мутации могут быть потенциально опасными и что их вызывает множество методов массива, давайте посмотрим, как мы можем избежать их использования.
Оказывается, не так уж и сложно написать некоторые функции, которые делают то же самое, что и методы изменения, но возвращают новый объект массива вместо изменения исходного массива.
Поскольку мы не собираемся патчить обезьяну Array.prototype, эти функции всегда будут принимать сам массив в качестве первого параметра.
Pop
Начнем с написания новой pop функции, которая возвращает копию исходного массива, но без последнего элемента. Обратите внимание, что Array.prototype.pop()возвращает значение, которое было извлечено из конца массива:
const pop = array => array.slice(0,-1);
Эта функция используется Array.prototype.slice()для возврата копии массива, но с удаленным последним элементом (второй аргумент −1 означает остановку нарезки на 1 место до конца ). Мы можем увидеть, как это работает, на примере ниже:
const food = ['🍏','🍌','🥕','🍩']; pop(food) << ['🍏','🍌','🥕']
Push
Затем давайте создадим push()функцию, которая вернет новый массив, но с новым элементом, добавленным в конец:
const push = (array, value) => [...array,value];
Это использует оператор распространения для создания копии массива, а затем просто добавляет значение, указанное в качестве второго аргумента, в конец нового массива, как можно увидеть в примере ниже:
const food = ['🍏','🍌','🥕','🍩']; push(food,'🍆') << ['🍏','🍌','🥕','🍩','🍆']
Shift и Unshift
Аналогичным образом мы можем написать замены для Array.prototype.shift()и Array.prototype.unshift():
const shift = array => array.slice(1);
Для нашей shift()функции мы просто отрезаем первый элемент массива вместо последнего, как показано в примере ниже:
const food = ['🍏','🍌','🥕','🍩']; shift(food) << ['🍌','🥕','🍩']
Наш unshift()метод вернет новый массив с новым значением, добавленным в начало массива:
const unshift = (array,value) => [value,...array];
Оператор распространения позволяет нам размещать значения внутри массива в любом порядке, поэтому мы просто помещаем новое значение перед копией исходного массива. Мы можем увидеть, как это работает, на примере ниже:
const food = ['🍏','🍌','🥕','🍩']; unshift(food,'🍆') << ['🍆','🍏','🍌','🥕','🍩']
Reverse
Теперь давайте приступим к написанию замены для Array.prototype.reverse()метода, который будет возвращать копию массива в обратном порядке, вместо того, чтобы изменять исходный массив:
const reverse = array => [...array].reverse();
Этот метод по-прежнему использует Array.prototype.reverse()метод, но применяется к копии исходного массива, которую мы делаем с помощью оператора распространения. Нет ничего плохого в том, чтобы изменять объект сразу после его создания, что мы и делаем здесь. Мы видим, что это работает, на примере ниже:
const food = ['🍏','🍌','🥕','🍩']; reverse(food) << ['🍩','🥕','🍌','🍏']
Splice
Наконец, займемся Array.prototype.splice(). Это очень общая функция, поэтому мы не будем полностью переписывать то, что она делает (хотя это было бы интересным упражнением, которое стоит попробовать. (Подсказка: используйте оператор распространения и splice().) Вместо этого мы сосредоточимся на двух основных применениях. for slice: удаление элементов из массива и вставка элементов в массив.
Начнем с функции, которая вернет новый массив, но с удаленным элементом с заданным индексом:
const remove = (array, index) => [...array.slice(0, index),...array.slice(index + 1)];
Это используется Array.prototype.slice()для разделения массива на две половины — по обе стороны от элемента, который мы хотим удалить. Первый срез возвращает новый массив, который копирует все элементы из начала исходного массива и идет вверх до индекса, предоставленного в качестве аргумента (но не включает его). Второй срез возвращает массив, который копирует все элементы из элемента сразу после элемента, который мы хотим удалить, до конца исходного массива. Затем мы объединяем их вместе в новый массив с помощью оператора распространения.
Мы можем проверить, что это работает, попытавшись удалить элемент с индексом 2 в foodмассиве ниже:
const food = ['🍏','🍌','🥕','🍩']; remove(food,2) << ['🍏','🍌','🍩']
Наконец, давайте напишем функцию, которая вернет новый массив с новым значением, вставленным по определенному индексу:
const insert = (array,index,value) => [...array.slice(0, index), value, ...array.slice(index)];
Это работает аналогично remove()функции: она создает два фрагмента массива, но на этот раз включает элемент по указанному индексу. Когда мы соединяем два фрагмента вместе, мы вставляем значение, указанное в качестве аргумента между ними обоими.
Мы можем проверить, что это работает, попытавшись вставить эмодзи в виде кекса в середину нашего foodмассива:
const food = ['🍏','🍌','🥕','🍩'] insert(food,2,'🧁') << ['🍏','🍌','🧁','🥕','🍩']
Теперь у нас есть набор функций, которые преобразуют массивы без изменения исходного массива. Я сохранил их все в одном месте на CodePen, поэтому не стесняйтесь копировать их и использовать в своих проектах. Вы можете разместить их в именах, превратив их в методы одного объекта или просто использовать их как есть, когда это необходимо.
Этого должно хватить для большинства операций с массивами, но если вам нужно выполнить другую операцию, просто запомните золотое правило: сначала сделайте копию исходного массива, используя оператор распространения, а затем немедленно примените к этой копии любые методы изменения.
Заключение
В этой статье мы рассмотрели, как JavaScript усложняет нам жизнь, включая методы массива, которые изменяют исходный массив как часть языка, а затем мы написали наши собственные неизменяемые версии этих функций.