iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

JavaScriptにおける配列の破壊的操作とその代替策

はじめに

こんにちは!

6月よりiimonでフロントエンドエンジニアを担当しているかねにわと申します!

これまでの現場ではあまり集中的にプログラムを組んだ経験がなく、ましてやフロントエンドという領域もほとんど初めての世界なのですが、 実際にプロダクトのソースコードを触っていく中で、ひとまとまりになったデータを扱うために配列の操作をする機会が多いと感じていました。

スプレッド構文

はじめは正直特に深いことも考えずに配列要素の抽出やコピーなどといった操作をしてしまっていましたが、

const fuga = {...hoge}; // fuga, hogeは配列

といった見慣れない記法が度々目につき、先輩からもなるべくこの書き方をするように勧められます。

よく分からず調べてみると、これはスプレッド構文と呼ばれる記法で、

「配列やオブジェクトといった反復可能オブジェクトを、別の要素や関数呼び出しに展開する」

といったそのままの説明がなされており、その時点ではこの記法を使う目的が正直のところピンときていませんでした。

先輩に尋ねてみると、「それは元の配列に影響を及ぼしたくないから」といった答えがあり、ようやく理解に至ります。

考えてみると、オブジェクト型である配列(Array)は、文字列や数値といったプリミティヴ型とは異なり、配列を他の変数にそのままコピーした場合、コピー元と同じ参照が渡されるために、コピー元とコピー先の配列はどちらも同じ実体を共有する性質があります(参照渡し)。

そのため、ここでコピー先変数の配列要素を変更した場合、コピー元の配列要素も同様に変更されてしまいます。

const hoge = ['test', 'test2']
const fuga = hoge;
fuga[0] = 'aiueo'
console.log(hoge)// [‘aiueo’, ‘test2’] ←コピー元も変わってしまう
console.log(fuga)// [‘aiueo’, ‘test2’]

ここで、先ほど言及したスプレッド構文を使うことで、元の配列に影響を及ぼさずに新しい配列をコピーすることが出来るというわけです。

const hoge = ['test', 'test2']
const fuga = [...hoge]; // スプレッド構文で代替
fuga[0] = 'aiueo'
console.log(hoge) // [‘test’, ‘test2’]  ←コピー元は変わらず
console.log(fuga) // [‘aiueo’, ‘test2’]
配列の破壊的メソッド

さて、こうした元の配列に影響を及ぼしてしまうという観点を踏まえた上で、今度は配列操作に使われるメソッドに目を向けてみましょう。

JavaScriptの配列オブジェクトには、配列を操作するための多様なメソッドが用意されています(配列の末尾に要素追加するpush()や要素を全連結した文字列を返すjoin() etc...)。

これらのメソッドを記事冒頭の私のように、何も考えず使用してしまうのは危険といえます。

以下のコードをご覧ください。

const hoge = [1, 2, 3, 4]
const fuga = hoge.reverse();
console.log(hoge) // [4,3,2,1] ←逆順になってしまっている
console.log(fuga) // [4,3,2,1]

hogeの配列要素をreverse()を使用して逆順にし、変数fugaに格納しています。

ここでコンソールを確認してみると、fugaにはもちろん逆順になった配列が格納されていますが、元のhoge要素も同様に逆順になってしまっています。

加えて、hogeとfugaは同様のインスタンスを参照しているため、ここでfugaに新たな変更を加えた場合、コピー元であるhogeにも同様に変更が加えられてしまいます。

上記でreverse()を使用した際、元の配列も変更されてしまうのは、このメソッドが文字通り元の配列の値を変えてしまうという性質に起因するのですが、こうした性質をもつメソッドを俗に破壊的メソッドと呼びます。

この性質を理解した上でブロックスコープ内などで一時的なものとして使用するならまだしも、不用意に使ってしまうことで、意図しない配列の変更によるバグを引き起こす危険性があります。

とりわけReactにおいては、破壊的メソッドを使用することでstateの変更を感知できなくなり、プログラムが正常に更新、動作しないといった決して無視できない問題も存在するようです(この辺りも機会があれば調べてみようと思います)。

zenn.dev

ここで、JavaScriptにおける破壊的メソッドと呼ばれるメソッドの一覧を確認してみましょう。

配列の破壊的操作 | TypeScript入門『サバイバルTypeScript』より引用)

先ほど例にあげたreverse()やpush()など、比較的よく目にするメソッドが勢揃いです。

これらは全て、そのまま使ってしまうと操作対象の配列を直接変更することになってしまうので慎重に扱う必要があります。

破壊的操作の回避策

では、そうはいってもこれらのメソッドを非破壊的に扱うにはどうしたらいいのでしょうか。

結論から述べてしまうと、記事冒頭でも触れた

スプレッド構文を使う

ということです。

・・・これでは記事が終わってしまうので、もう少し詳しく説明します。

配列の末尾の要素を取り除いた上で返却するpop()を例にとって見てみましょう。

const hoge = [1, 2, 3]
const fuga = hoge.pop()
console.log(hoge) // [1, 2] ←操作対象の要素に影響
console.log(fuga) // 3

当然のことながら、pop()の操作対象であるhogeの要素から末尾の要素が消えてしまっています。

ここで、以下のようにスプレッド構文を用いることで元の配列hogeを破壊することなく、末尾の要素を取り除いた配列を新たに生成することができます。

const hoge = [1, 2, 3]
const fuga = [...hoge] // スプレッド構文を使ってコピー
fuga.pop()
console.log(hoge) // [1, 2, 3] ←元の配列は無傷
console.log(fuga) // [1, 2]

(スプレッド構文以外にも、既存の配列を変更せずに新しい配列を返すconcat()を使うことで、同様に非破壊な操作を実現することができます)

const hoge = [1, 2, 3]
const fuga = hoge.concat() // concat()で代替
fuga.pop()
console.log(hoge) // [1, 2, 3] ←元の配列は無傷
console.log(fuga) // [1, 2]

では、無条件にスプレッド構文を乱用すればいいのかといえば、この記法にも後述する注意点が存在します。

スプレッド構文の落とし穴!?

配列を非破壊的に扱うためには、スプレッド構文で新しく配列を展開すれば元の配列を汚すことなく操作できると述べました。

しかし、スプレッド構文には認識しておくべき重要な性質があります。それは、

スプレッド構文はシャローコピーである

という点です。

正直、私はこの点をしばらくは認識できていませんでした。

オブジェクトのコピーにおいては、シャローコピーとディープコピーという2つの方法が存在します。

前者は文字通り浅いコピーとして、オブジェクトのアドレスをコピーするのに対し、後者は配列オブジェクトの保持する値も複製して、完全に独立した別の配列オブジェクトを新たに生成します。

そして、スプレッド構文はここでいうシャローコピーにあたるのです!

(参考) tech.iimon.co.jp

スプレッド構文の問題の挙動を具体的に見ていきましょう。

const hoge = [1, [2, 3], [4, 5]]
const fuga = [...hoge]
fuga[0] = 99
fuga[1][0] = 99
fuga[2][1] = 99

console.log(hoge) // [1, [99 , 3], [4, 99]] ← 中の配列に影響が
console.log(fuga) // [99, [99 , 3], [4, 99]]

多重配列であるhogeをfugaにスプレッド構文でコピーし、コピー先の方でそれぞれ要素を変更しています。

スプレッド構文を使用しているので、コピー元hogeの内容には影響がないものと思いきや、よく見るとhogeの第1要素は 変化がないものの、第2、3要素の配列の内容が変わってしまっています

スプレッド構文はシャローコピーであると先ほど述べました。

ここでスプレッド構文は、配列の1層目まではコピーするが2層目以降は完全なコピーを行わないという特徴的な挙動をします。

そのため、多重配列である第2、3要素はhogeとfugaで同じメモリを参照しているため、この点において変更が共有されてしまうというわけです。

これは、ネストされたオブジェクトのコピーにおいても同様です。

このため、二重配列やネストオブジェクトのコピーをする際は、スプレッド構文を盲信するのではなくよく注意して操作をするべきでしょう。

ネストされた要素も含めた完全なディープコピーを作成する場合、

JSON.parse(JSON.stringify(obj))structuredClone()を使うという選択もあります。

(上記問題点を考慮しながらスプレッド構文を使うよりは、はじめからstructuredClone()を使ってしまえばいいと思ってしまうのですが実際どうなのでしょうか。。。)

→追記:JestのJSDOMでは現状対応していないといった問題があるようです!

蛇足ですが、シャローコピーとディープコピーの違いについて調べていくなかで、前者は単純なアドレスのコピー、つまり参照渡しを指すというような書かれ方をしている解説が一般的ですが、上記の挙動を踏まえると多層構造を持つ配列(オブジェクト)をスプレッド構文によってコピーする場合の説明としては、1層目のみディープコピーを行い、2層目以降はシャローコピーを行うといった方が正確かと思いました。

まとめ

配列とそのメソッドの操作については、配列がオブジェクト型という性質をもつ以上、意図しない配列の変更によるバグを防ぐために、常に元の配列の不変性(immutability)を意識する必要があると認識しました。

また、コピーするに際し基本的にはスプレッド構文で対応できますが、配列が多層構造を持つ場合、JSON.parse()やstructuredClone()によるディープコピーを行う必要があるでしょう。

ここまでご覧いただきありがとうございます。

弊社ではエンジニアを募集しております。

ご興味がありましたらカジュアル面談も可能ですので、是非ご応募ください!

Wantedly / Green

参考文献・記事

配列ステートの操作には要注意|React Hooks と TypeScript でつくる Todo PWA ~ 入門 React ハンズオン

配列の破壊的操作 | TypeScript入門『サバイバルTypeScript』

JavaScriptの変数と配列と参照について JavaScript 配列のコピー - かもメモ

structuredClone() グローバル関数 - Web API | MDN

シャローコピーとディープコピーの違い - iimon TECH BLOG

外村将大『独習JavaScript 新版』, 2023, 翔泳社