JavaScript の配列メソッドには、呼び出すと元の配列そのものを書き換えてしまうものがあります。sort()・reverse()・splice() がその代表で、「並び替えた結果を別の変数に入れたつもりが、元の配列まで並び変わっていた」という事故は珍しくありません。ES2023 で追加された toSorted() / toReversed() / toSpliced() / with() は、これらの非破壊版として用意されたメソッドです。この記事では何が変わるのか、戻り値の違いはどこか、従来の [...arr].sort() という書き方と比べてどうかを解説します。
目次
元の配列を書き換えるメソッドが起こす事故
まずは sort() の挙動を確認しておきます。sort() は並び替えた新しい配列を返すように見えますが、実際には元の配列を並び替えたうえで、その同じ配列を返しているだけです。
const scores = [ 30, 10, 20 ];
const sorted = scores.sort( ( a, b ) => a - b );
console.log( sorted ); // [ 10, 20, 30 ]
console.log( scores ); // [ 10, 20, 30 ] ← 元の配列も並び変わっている
console.log( sorted === scores ); // true ← そもそも同じ配列
sorted === scores が true になっているところが要点です。新しい配列は1つも作られていません。reverse() も同様で、splice() にいたっては元の配列から要素を取り除き、取り除いた要素の配列を返します。
この性質が特に問題になるのが、配列を他の場所と共有しているときです。関数の引数として受け取った配列をその場で sort() すると、呼び出し元の配列まで並び変わります。
// 「表示用に並び替えるだけ」のつもりの関数
function renderRanking( items ) {
// ここで引数の配列そのものを書き換えてしまっている
const sorted = items.sort( ( a, b ) => b.score - a.score );
return sorted.map( ( item ) => item.name ).join( ', ' );
}
const players = [
{ name: 'A', score: 10 },
{ name: 'B', score: 30 },
{ name: 'C', score: 20 },
];
renderRanking( players );
console.log( players[ 0 ].name ); // 'B' ← 呼び出し元の並び順が変わっている
React などで状態として持っている配列を直接 sort() するのも、よくあるつまずきどころです。状態の配列を書き換えてから同じ配列をセットしても、参照が変わっていないため「変化なし」と判断されて再描画されないことがあります。toSorted() なら別の配列が返るので、参照が変わり、意図どおりに更新されます。
// 状態の配列を直接書き換えてしまう例(再描画されないことがある)
setItems( items.sort( ( a, b ) => a.price - b.price ) );
// 新しい配列を渡す(参照が変わるので状態の変化として扱われる)
setItems( items.toSorted( ( a, b ) => a.price - b.price ) );
破壊的メソッドと非破壊メソッドの対応
ES2023 で追加された4つは、既存の破壊的な操作と一対一で対応しています。名前も to を付けただけなので覚えやすい作りです。
| 元の配列を書き換える書き方 | 非破壊版 | 内容 |
|---|---|---|
sort() | toSorted() | 並び替えた新しい配列を返す |
reverse() | toReversed() | 逆順にした新しい配列を返す |
splice() | toSpliced() | 削除・挿入を反映した新しい配列を返す |
arr[ i ] = value | with( i, value ) | 1要素だけ差し替えた新しい配列を返す |
4つとも元の配列には一切手を触れず、新しい配列を返す点が共通しています。引数の意味は対応する破壊的メソッドとまったく同じなので、今まで sort() に渡していた比較関数はそのまま toSorted() にも使えます。
toSorted() で並び替えた配列を作る
toSorted( compareFn ) は sort() と同じく比較関数を受け取り、並び替えた新しい配列を返します。
const scores = [ 30, 10, 20 ];
const asc = scores.toSorted( ( a, b ) => a - b );
console.log( asc ); // [ 10, 20, 30 ]
console.log( scores ); // [ 30, 10, 20 ] ← 元のまま
// オブジェクトの配列も同じように書ける
const players = [
{ name: 'A', score: 10 },
{ name: 'B', score: 30 },
{ name: 'C', score: 20 },
];
const ranking = players.toSorted( ( a, b ) => b.score - a.score );
console.log( ranking.map( ( p ) => p.name ) ); // [ 'B', 'C', 'A' ]
比較関数を省くと文字列として比較される
ここは sort() から変わっていない部分なので注意が必要です。比較関数を渡さないと、要素はいったん文字列に変換されてから文字コード順に並べられます。数値の配列をそのまま渡すと、直感と違う結果になります。
console.log( [ 10, 9, 1 ].toSorted() );
// [ 1, 10, 9 ] ← '1' < '10' < '9' として比較されている
console.log( [ 10, 9, 1 ].toSorted( ( a, b ) => a - b ) );
// [ 1, 9, 10 ] ← 比較関数を渡せば数値として並ぶ
「非破壊になったのだから賢くなっているはず」と期待してしまいがちですが、変わったのは元の配列を書き換えるかどうかだけです。数値を並べるときは必ず比較関数を渡してください。文字列の配列を日本語や大文字小文字を考慮して並べたいときは、( a, b ) => a.localeCompare( b ) のように比較関数の中で localeCompare() を使います。
toReversed() で逆順の配列を作る
toReversed() は引数を取りません。要素の順序を逆にした新しい配列を返します。
const logs = [ '古い', '中くらい', '新しい' ];
const newest = logs.toReversed();
console.log( newest ); // [ '新しい', '中くらい', '古い' ]
console.log( logs ); // [ '古い', '中くらい', '新しい' ] ← 元のまま
reverse() は「一覧を新しい順に表示したいだけ」といった場面で気軽に使われがちですが、そのたびに元データの並びを壊しています。表示のための並べ替えは toReversed() に置き換えておくと、同じ配列を別の場所でも使い回せます。
toSpliced() で削除・挿入した配列を作る
toSpliced( start, deleteCount, ...items ) の引数は splice() と同じです。start の位置から deleteCount 個を取り除き、そこに items を挿入した結果を返します。
const items = [ 'a', 'b', 'c', 'd' ];
// インデックス1から2個削除
console.log( items.toSpliced( 1, 2 ) ); // [ 'a', 'd' ]
// 削除したところに別の要素を差し込む
console.log( items.toSpliced( 1, 2, 'X', 'Y' ) ); // [ 'a', 'X', 'Y', 'd' ]
// 削除数0なら、削除せずに挿入だけ
console.log( items.toSpliced( 2, 0, 'NEW' ) ); // [ 'a', 'b', 'NEW', 'c', 'd' ]
// start は負の数で末尾から数えられる
console.log( items.toSpliced( -1, 1 ) ); // [ 'a', 'b', 'c' ]
console.log( items ); // [ 'a', 'b', 'c', 'd' ] ← 元のまま
splice() とは戻り値が違う
この2つでいちばん間違えやすいのが戻り値です。splice() が返すのは削除した要素の配列ですが、toSpliced() が返すのは削除・挿入を反映したあとの新しい配列です。名前が似ているぶん、機械的に置き換えると結果がまったく違うものになります。
const a = [ 'a', 'b', 'c', 'd' ];
const removed = a.splice( 1, 2 );
console.log( removed ); // [ 'b', 'c' ] ← 削除された要素
console.log( a ); // [ 'a', 'd' ] ← 元の配列が短くなった
const b = [ 'a', 'b', 'c', 'd' ];
const result = b.toSpliced( 1, 2 );
console.log( result ); // [ 'a', 'd' ] ← 削除後の配列そのもの
console.log( b ); // [ 'a', 'b', 'c', 'd' ] ← 元のまま
削除した要素のほうが必要な場合は、toSpliced() では取れません。slice( start, start + deleteCount ) で取り出すか、素直に splice() を使ってください。
with() で1要素だけ差し替える
with( index, value ) は、指定したインデックスの要素だけを value に差し替えた新しい配列を返します。arr[ 1 ] = 'B' という代入の非破壊版だと考えると分かりやすいはずです。
const letters = [ 'a', 'b', 'c' ];
console.log( letters.with( 1, 'B' ) ); // [ 'a', 'B', 'c' ]
console.log( letters ); // [ 'a', 'b', 'c' ] ← 元のまま
// 負のインデックスは末尾から数える(-1 が最後の要素)
console.log( letters.with( -1, 'C' ) ); // [ 'a', 'b', 'C' ]
// 戻り値も配列なので、そのまま繋げて書ける
console.log( letters.with( 0, 'A' ).with( 2, 'C' ) ); // [ 'A', 'b', 'C' ]
オブジェクトの配列で「1件だけ更新する」処理も短く書けます。従来は map() でインデックスを比較する書き方が定番でしたが、位置が分かっているなら with() のほうが素直です。
const todos = [
{ id: 1, title: '買い物', done: false },
{ id: 2, title: '掃除', done: false },
];
const index = todos.findIndex( ( todo ) => todo.id === 2 );
// 従来の書き方
const next1 = todos.map( ( todo, i ) =>
i === index ? { ...todo, done: true } : todo
);
// with() を使った書き方
const next2 = todos.with( index, { ...todos[ index ], done: true } );
範囲外のインデックスは RangeError になる
with() は、存在しないインデックスを指定すると RangeError を投げます。代入 arr[ 10 ] = 'x' なら配列が勝手に伸びて途中が空になりますが、with() はエラーで止まるので挙動が大きく違います。
const letters = [ 'a', 'b', 'c' ];
letters.with( 3, 'd' ); // RangeError: Invalid index
letters.with( -4, 'z' ); // RangeError: Invalid index(-4 は先頭より前)
特に findIndex() の結果をそのまま渡すときは気をつけてください。見つからなかった場合の戻り値は -1 で、これは with() にとっては「最後の要素」という有効なインデックスです。エラーにもならず、意図しない要素が静かに差し替わります。if ( index !== -1 ) の確認を挟んでおくのが安全です。
[…arr].sort() との違い
これらのメソッドが無かった頃は、いったんコピーを作ってから破壊的メソッドを呼ぶのが定番でした。[ ...arr ].sort() や arr.slice().reverse() という書き方です。
const scores = [ 30, 10, 20 ];
// 従来の書き方(コピーしてから破壊的メソッドを呼ぶ)
const asc1 = [ ...scores ].sort( ( a, b ) => a - b );
const desc1 = scores.slice().reverse();
// ES2023 以降の書き方
const asc2 = scores.toSorted( ( a, b ) => a - b );
const desc2 = scores.toReversed();
結果は同じです。違いは読みやすさで、[ ... ] や .slice() がコピーのためだけに置かれていることを読み手が理解する必要がなくなります。裏を返せば、コピーを忘れた scores.sort() と、意図してコピーした [ ...scores ].sort() は見た目が近く、レビューでも見落としやすいということです。toSorted() なら名前そのものが「元を壊さない」と主張しているので、取り違えが起きません。
コピーはどちらも浅いコピー
ここは誤解されやすいところです。toSorted() が作るのは浅いコピー(shallow copy)で、これは [ ...arr ] や slice() と同じです。新しく作られるのは配列そのものだけで、中に入っているオブジェクトは元の配列と共有され続けます。
const players = [
{ name: 'A', score: 10 },
{ name: 'B', score: 30 },
];
const sorted = players.toSorted( ( a, b ) => b.score - a.score );
console.log( sorted === players ); // false ← 配列は別物
console.log( sorted[ 0 ] === players[ 1 ] ); // true ← 中身は同じオブジェクト
// コピー側の要素を書き換えると、元の配列からも見える
sorted[ 0 ].score = 999;
console.log( players[ 1 ].score ); // 999
つまり toSorted() が守ってくれるのは「配列の並び順と要素数」までで、要素の中身までは守りません。要素のプロパティを書き換えるときは、これまでどおり { ...todo, done: true } のように新しいオブジェクトを作る必要があります。前述の with() の例で { ...todos[ index ], done: true } と書いていたのはこのためです。
もうひとつ細かい違いとして、これらのメソッドは疎な配列(途中に空きがある配列)の空き部分を保持しません。[ 1, , 3 ].toReversed() は [ 3, undefined, 1 ] のように、空きが undefined として埋められた配列になります。空き要素を残したまま扱いたい場面はまれですが、slice() は空きを保持するので、置き換えるときに結果が変わる可能性がある点だけ覚えておいてください。
メソッドが存在しないとエラーになるとき
arr.toSorted is not a function や arr.with is not a function という TypeError が出る場合、実行している環境がこれらのメソッドに対応していません。書き方の問題ではなく環境の問題なので、コードを直しても解決しません。
対応している環境
4つとも ES2023 で追加された比較的新しいメソッドです。主要なブラウザーの最近のバージョンと、Node.js は 20 以降で使えます。逆に言えば、少し前のブラウザーや Node.js 18 以前では利用できません。業務でサポート範囲が決まっている場合は、MDN の各ページにあるブラウザー互換性の表で、対象環境が含まれているかを確認してから使ってください。
ビルドツールを通している場合も油断はできません。Babel などのトランスパイラーは基本的に構文を変換するもので、新しいメソッドそのものを補ってくれるわけではありません。設定でポリフィルを組み込んでいなければ、変換後のコードでも toSorted() の呼び出しはそのまま残ります。
ポリフィルを入れるか、従来の書き方に戻す
古い環境も対象に含めるなら、方法は2つです。ひとつは core-js のようなポリフィルのライブラリーを導入して、足りないメソッドを補うこと。ビルド設定で対象ブラウザーを指定しておけば、必要なぶんだけ読み込まれます。
もうひとつは、そもそも [ ...arr ].sort() の形に書き換えてしまうことです。前述のとおり結果は同じですし、こちらは対応環境を気にする必要がありません。読みやすさのためだけに導入して動かなくなるくらいなら、素直に従来の書き方を選ぶのも十分に現実的な判断です。
// toSorted() / toReversed() が使えない環境での書き換え
const asc = [ ...scores ].sort( ( a, b ) => a - b );
const reversed = [ ...scores ].reverse();
// toSpliced( 1, 2, 'X' ) 相当
const spliced = [ ...items ];
spliced.splice( 1, 2, 'X' );
// with( 1, 'B' ) 相当
const replaced = letters.map( ( v, i ) => ( i === 1 ? 'B' : v ) );
自前で Array.prototype にメソッドを追加して代用するのは避けてください。仕様と少しでも挙動が違うと、将来その環境が本来の実装を持ったときに食い違いが起き、原因の分かりにくい不具合になります。
まとめ
sort()・reverse()・splice() は元の配列を書き換えるメソッドで、配列を共有している場面では思わぬ場所に影響が出ます。ES2023 で追加された toSorted() / toReversed() / toSpliced() / with() は、いずれも元の配列に触れず新しい配列を返す非破壊版です。引数の意味は対応する破壊的メソッドと同じなので、置き換えはほとんど名前を変えるだけで済みます。
ただし注意点が3つあります。toSpliced() は削除した要素ではなく削除後の配列を返すこと。with() は範囲外のインデックスで RangeError になり、-1 は末尾として有効なインデックスになること。そして toSorted() も比較関数を省けば文字列として比較されるという、sort() と同じ落とし穴があることです。
作られるのは浅いコピーなので、要素のオブジェクト自体は元の配列と共有されたままという点も忘れないでください。中身まで書き換えたいならスプレッド構文で新しいオブジェクトを作る、という従来の作法は変わりません。対応環境が心配なときは [ ...arr ].sort() に戻せばよいだけなので、使えるプロジェクトから少しずつ置き換えていくのがおすすめです。