Массивы в JavaScript: методы, мутации и подводные камни

Содержание

О проверке кода. Примеры не запускались — в отличие от материалов по Node.js, где приводится фактический вывод. Говорим прямо, а не ставим бейдж «проверено» без проверки.

Массив в JavaScript — не массив в классическом смысле: это объект с числовыми ключами и изменяемой длиной. Отсюда и удобство, и все странности.

Мутирует или нет — главный вопрос

Это единственное деление, которое нужно помнить наизусть. Половина багов с массивами — от того, что метод изменил исходник, а этого не ждали.

Два вида методов массива

Меняют исходный массив

Возвращают что угодно, но портят оригинал

  • push / pop — с конца
  • shift / unshift — с начала
  • splice — вырезает изнутри
  • sort — сортирует на месте
  • reverse, fill, copyWithin

Возвращают новый

Исходник не трогают

  • map, filter, reduce
  • slice — копирует кусок
  • concat, flat, join
  • toSorted, toReversed, toSpliced, with (ES2023)
  • Предсказуемы: можно в цепочку

Типичная ошибка:

const prices = [30, 10, 20];
const sorted = prices.sort();   // вернул отсортированный...
prices;                          // ...и ИЗМЕНИЛ исходный: [10, 20, 30]

sort возвращает тот же самый массив, а не копию. Если исходный порядок был нужен — он потерян.

// ✅ безопасно
const sorted = [...prices].sort((a, b) => a - b);   // старый способ
const sorted = prices.toSorted((a, b) => a - b);    // ES2023

sort() без компаратора — главная ловушка языка

[10, 9, 1].sort();     // [1, 10, 9]  ← не опечатка

sort без аргумента приводит элементы к строкам и сравнивает лексикографически. Строка "10" меньше строки "9", потому что символ 1 идёт раньше 9.

[10, 9, 1].sort((a, b) => a - b);   // [1, 9, 10] ✅
[10, 9, 1].sort((a, b) => b - a);   // [10, 9, 1] — по убыванию

Компаратор возвращает число: отрицательное — a раньше, положительное — b раньше, ноль — порядок не важен.

Для строк с кириллицей одного < мало:

['ёж', 'яблоко', 'арбуз'].sort();                        // порядок по кодам символов
['ёж', 'яблоко', 'арбуз'].sort((a, b) => a.localeCompare(b));   // ✅ по алфавиту

localeCompare знает про язык: что ё идёт после е, а не в конце алфавита.

slice и splice: одна буква, разное поведение

const arr = [1, 2, 3, 4, 5];

arr.slice(1, 3);      // [2, 3]  — копия куска, arr цел
arr;                  // [1, 2, 3, 4, 5]

arr.splice(1, 3);     // [2, 3, 4] — вырезал и ВЕРНУЛ вырезанное
arr;                  // [1, 5]    — исходник изменён!
slice(start, end) splice(start, count, ...items)
Исходник не трогает меняет
Возвращает копию куска удалённые элементы
Второй аргумент индекс конца количество
Умеет вставлять нет да

Обратите внимание на второй аргумент: у slice это индекс, у spliceколичество. Ещё одна причина их путать.

at(): последний элемент по-человечески

arr[arr.length - 1];   // старый способ
arr.at(-1);            // ES2022
arr.at(-2);            // предпоследний

Мелочь, но arr.at(-1) читается, а arr[arr.length - 1] — конструкция, которую глаз каждый раз разбирает заново.

Почему нельзя arr[-1]? Потому что массив — это объект, и -1 станет обычным строковым ключом "-1", а не индексом с конца. Вернётся undefined.

Длина изменяемая — и это опасно

const arr = [1, 2, 3];
arr.length = 1;
arr;              // [1] — элементы просто исчезли
arr.length = 3;
arr;              // [1, <2 empty items>] — дыры!

length — не только чтение. Присвоение обрезает массив, и это иногда используют как «быструю очистку» (arr.length = 0).

Опаснее другое — разреженные массивы с дырами:

const sparse = [1, , 3];      // дыра посередине
sparse.length;                // 3
sparse.map((x) => x * 2);     // [2, <1 empty item>, 6] — map ПРОПУСТИЛ дыру
sparse.filter(() => true);    // [1, 3] — filter дыры выбросил

Методы обходятся с дырами по-разному, и запомнить это невозможно. Простое правило: не создавайте дыр. Нужен массив нужной длины — Array.from({ length: 3 }, (_, i) => i).

Копирование поверхностное

const users = [{ name: 'Аня' }];
const copy = [...users];

copy[0].name = 'Борис';
users[0].name;         // 'Борис' — объект-то общий!

[...arr] и slice() копируют массив, но не то, что внутри. Объекты остаются теми же.

const deep = structuredClone(users);   // глубокая копия

Тот же structuredClone, что работает в Node — это метод платформы, а не библиотека. В отличие от JSON.parse(JSON.stringify(...)) он не ломает Date, Map и Set.

Что появилось после 2017

Метод Версия Что делает
flat, flatMap ES2019 разворачивает вложенность
at() ES2022 доступ с конца
toSorted, toReversed, toSpliced, with ES2023 безмутационные пары к старым
findLast, findLastIndex ES2023 поиск с конца
Object.groupBy ES2024 группировка по ключу

Появление toSorted — показательное решение комитета. Исправить sort было нельзя: это сломало бы миллионы сайтов. Поэтому добавили новый метод рядом, а старый оставили как есть. Так язык и живёт: ошибки не исправляют, к ним приделывают правильные варианты.

Что было на этой странице в 2017 году

Первая версия вышла 15 июля 2017 года — большой справочник методов массива с примерами. Он был точным и остаётся точным: push, map, filter за девять лет не изменились.

Чего в нём не было:

  • Деления на мутирующие и немутирующие. Методы шли одним списком по алфавиту, и то, что sort портит исходник, а slice нет, приходилось узнавать на своих багах.
  • toSorted и компании — их просто не существовало до ES2023. Безопасный способ отсортировать был только через копию.
  • at() — тоже не было; писали arr[arr.length - 1].

Ловушка sort() со строками в статье упоминалась. Она пережила девять лет и переживёт ещё столько же: исправить её нельзя, можно только знать.


Смежные темы: что такое JavaScript; ECMAScript 6 — откуда взялись стрелки и деструктуризация; глобальные объекты — про structuredClone; уроки JavaScript.

Частые вопросы

Почему `[10, 9, 1].sort()` даёт `[1, 10, 9]`?
Потому что sort без аргумента сравнивает строки, а не числа: "10" < "9". Для чисел нужен компаратор: sort((a, b) => a - b). Это самая известная ловушка массивов.
Какие методы меняют исходный массив?
push, pop, shift, unshift, splice, sort, reverse, fill. Остальные — map, filter, slice, concat — возвращают новый и исходный не трогают.
Чем `slice` отличается от `splice`?
Одна буква и противоположное поведение. slice копирует кусок и не трогает исходник. splice вырезает из исходника и меняет его. Путаница в названиях — источник постоянных багов.
Как скопировать массив?
[...arr] или arr.slice() — это поверхностная копия: вложенные объекты остаются общими. Для глубокой копии есть structuredClone(arr).
Что такое `toSorted()` и зачем он, если есть `sort()`?
toSorted() возвращает новый отсортированный массив, не трогая исходный. Появился в ES2023 вместе с toReversed, toSpliced и with — как безмутационные пары к старым методам.