👩‍👧‍👦

JavaScriptでネストした配列内オブジェクトの値で要素を並び替える

2022/07/10に公開
2

実装時、調べるのに時間がかかったのでメモです。

[
  {
    "id": 1,
    "state": {
      "displayOrder": 0
    },
  },
  {
    "id": 2,
    "state": {
      "displayOrder": -1
    },
    "children": [
      {
        "id": 6,
        "state": {
          "displayOrder": 1
        }
      },
      {
        "id":7,
        "state": {
          "displayOrder": 0
        }
      },
    ]
  },
  {
    "id": 3,
    "state": {
      "displayOrder": 1
    },
    "children": [
      {
        "id": 8,
        "state": {
          "displayOrder": 1
        }
      },
      {
        "id": 9,
        "state": {
          "displayOrder": null
        }
      },
      {
        "id": 10,
        "state": {
          "displayOrder": 0
        }
      },
    ]
  },
  {
    "id": 4,
    "state": {
      "displayOrder": null
    },
  },
  {
    "id": 5,
    "state": {
      "displayOrder": 2
    },
  },
]

上記のような配列をdisplayOrderの値で昇順に並び替える。ただしdisplayOrderがnull(あるいは空値やundefind、false)だったオブジェクトは最後尾につける。

下記のようにchildren以下も同様に処理する。

[
  {
    "id": 2,
    "state": {
      "displayOrder": -1
    },
    "children": [
      {
        "id": 7,
        "state": {
          "displayOrder": 0
        }
      },
      {
        "id": 6,
        "state": {
          "displayOrder": 1
        }
      }
    ]
  },
  {
    "id": 1,
    "state": {
      "displayOrder": 0
    }
  },
  {
    "id": 3,
    "state": {
      "displayOrder": 1
    },
    "childrenren": [
      {
        "id": 10,
        "state": {
          "displayOrder": 0
        }
      },
      {
        "id": 8,
        "state": {
          "displayOrder": 1
        }
      },
      {
        "id": 9,
        "state": {
          "displayOrder": null
        }
      },
    ]
  },
  {
    "id": 5,
    "state": {
      "displayOrder": 2
    }
  },
  {
    "id": 4,
    "state": {
      "displayOrder": null
    }
  }
]

sort()Number.isFinite()で並び替える

数字の並び順を変更するのにsort()を使用し、値がnull(あるいは空値やundefind、false)かどうかの判定にNumber.isFinite()を使用した関数を作る。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/sort

ちなみにNumber.isFinite()は、厳密には値が有限数であるかどうかを判断するものです。有限数であればtrueを、そうでなければfalseを返します。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Number/isFinite

displayOrderの値が数値であれば比較して昇順に、そうでなければ後ろにソートされるようにしました。

const orderSort = (array) => array.sort((a, b) => {
  const aOrder = a.state.displayOrder
  const bOrder = b.state.displayOrder

  return !Number.isFinite(aOrder) ? 1 : !Number.isFinite(bOrder) ? -1 : aOrder === bOrder ? 0 : aOrder - bOrder;
})

まず親オブジェクトから並び替える

const array = [
  {
    "id": 1,
    "state": {
      "displayOrder": 0
    },
  },
  {
    "id": 2,
    "state": {
      "displayOrder": -1
    },
    "children": [...]
  },
  {
    "id": 3,
    "state": {
      "displayOrder": 1
    },
    "children": [...]
  },
  {
    "id": 4,
    "state": {
      "displayOrder": null
    },
  },
  {
    "id": 5,
    "state": {
      "displayOrder": 2
    },
  },
]

const sortArray = orderSort(array)

console.log(sortArray)
/* [
  {
    "id": 2,
    "state": {
      "displayOrder": -1
    },
    "children": [...]
  },
  {
    "id": 1,
    "state": {
      "displayOrder": 0
    }
  },
  {
    "id": 3,
    "state": {
      "displayOrder": 1
    },
    "children": [...]
  },
  {
    "id": 5,
    "state": {
      "displayOrder": 2
    }
  },
  {
    "id": 4,
    "state": {
      "displayOrder": null
    }
  }
] */

in演算子でプロパティの有無を確認してからchildren以下を並び替える

childrenプロパティを持たないオブジェクトもあるのでin演算子で有無を確認する。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/in

sortArray.map((item) => 'children' in item && orderSort(item.children))

console.log(sortArray)
/*
[
  {
    ...,
    "children": [
      {
        "id": 7,
        "state": {
          "displayOrder": 0
        }
      },
      {
        "id": 6,
        "state": {
          "displayOrder": 1
        }
      }
    ]
  },
  ...,
  {
    ...,
    "children": [
      {
        "id": 10,
        "state": {
          "displayOrder": 0
        }
      },
      {
        "id": 8,
        "state": {
          "displayOrder": 1
        }
      }
      {
        "id": 9,
        "state": {
          "displayOrder": null
        }
      },
    ]
  },
  ...,
  ...
]
*/

新しい配列を作るわけではないのでmap()ではなくforEach()でも同じ結果になる。

sortArray.forEach((item) => 'children' in item && orderSort(item.children))

まとめて書いてみる

const array = [
  {
    "id": 1,
    "state": {
      "displayOrder": 0
    },
  },
  {
    "id": 2,
    "state": {
      "displayOrder": -1
    },
    "children": [
      {
        "id": 6,
        "state": {
          "displayOrder": 1
        }
      },
      {
        "id":7,
        "state": {
          "displayOrder": 0
        }
      },
    ]
  },
  {
    "id": 3,
    "state": {
      "displayOrder": 1
    },
    "children": [
      {
        "id": 8,
        "state": {
          "displayOrder": 1
        }
      },
      {
        "id": 9,
        "state": {
          "displayOrder": null
        }
      },
      {
        "id": 10,
        "state": {
          "displayOrder": 0
        }
      },
    ]
  },
  {
    "id": 4,
    "state": {
      "displayOrder": null
    },
  },
  {
    "id": 5,
    "state": {
      "displayOrder": 2
    },
  },
]

const orderSort = (array) => array.sort((a, b) => {
  const aOrder = a.state.displayOrder
  const bOrder = b.state.displayOrder

  return !Number.isFinite(aOrder) ? 1 : !Number.isFinite(bOrder) ? -1 : aOrder === bOrder ? 0 : aOrder - bOrder;
})

orderSort(array).map((item) => 'children' in item && orderSort(item.children))

console.log(array)
/*
[
  {
    "id": 2,
    "state": {
      "displayOrder": -1
    },
    "children": [
      {
        "id": 7,
        "state": {
          "displayOrder": 0
        }
      },
      {
        "id": 6,
        "state": {
          "displayOrder": 1
        }
      }
    ]
  },
  {
    "id": 1,
    "state": {
      "displayOrder": 0
    }
  },
  {
    "id": 3,
    "state": {
      "displayOrder": 1
    },
    "children": [
      {
        "id": 10,
        "state": {
          "displayOrder": 0
        }
      },
      {
        "id": 8,
        "state": {
          "displayOrder": 1
        }
      }
      {
        "id": 9,
        "state": {
          "displayOrder": null
        }
      },
    ]
  },
  {
    "id": 5,
    "state": {
      "displayOrder": 2
    }
  },
  {
    "id": 4,
    "state": {
      "displayOrder": null
    }
  }
]
*/

以上です。

ネストされた配列の操作は、かなりややこしい印象です。もっと他にスマートな方法があるかもしれません。

コメントいただいた方法を元に書き換えてみる

コメントありがとうございます。うれしいです。
いただいた内容を元に、自分のアイデアと組み合わせて、こんな方法もあるかなと思い書き直してみました。

//null、undefind、false、空値をNumber.MAX_VALUEに変換する関数
const convert = (x) => (Number.isFinite(x) ? x : Number.MAX_VALUE)

//比較して並び替える関数
const compare = (x) => x.sort((a, b) => convert(a.state.displayOrder) - convert(b.state.displayOrder))

//forEach()で実行
compare(array).forEach((item) => item?.children && compare(item.children))

//map()で実行
const sortedArray = compare(array).map((item) => ({
  ...item,
  children: item?.children && compare(item.children),
}))

やっていることは変わらないのですが、よりシンプルになったかなと思います。

分割代入を利用してさらに簡潔にしてみる

分割代入を利用すると、さらに簡潔に処理できることが分かりました。再帰的な処理も含めて書き直してみます。

//null、undefind、false、空値をNumber.MAX_VALUEに変換する関数
const convert = (x) => (Number.isFinite(x) ? x : Number.MAX_VALUE)

//比較して並び替える関数
const compare = (x) => x.sort((a, b) => convert(a.state.displayOrder) - convert(b.state.displayOrder))

// map()で分割代入を利用して並び替えを実行
const deepSort = (array) =>
  compare(array).map(({ children, ...item }) => ({
    ...item,
    ...(children && { children: deepSort(children) }),
  }));

console.log(deepSort(array));

分割代入で{ children, ...item }というオブジェクトを作成。そこにchildrenが存在しなかったら、そのまま並び替え処理を行い、存在した場合はdeepSort()関数で再帰的にソートされた配列を返します。

分割代入をmap()で使用して、一部を抜き出して処理できることを知れたのが一番の収穫でした。大変勉強になりました。

GitHubで編集を提案

Discussion

fj68fj68

はじめまして。
記事を拝見し、私も考えてみました。

in-placeで(元の配列の中身を上書きしながら)ソートするバージョンと、元の配列の中身は変更せずソートされた配列を返すバージョンを書いてみました。

compareFn

const displayOrderCompare = (a, b) => {
  const a_ = a || Number.MAX_VALUE
  const b_ = b || Number.MAX_VALUE
  return a_ - b_
}
const compare = (a, b) =>
  displayOrderCompare(a.state.displayOrder, b.state.displayOrder)

sort in-place

const sort = xs =>
  xs.sort(compare).forEach(x => x.child.?sort(compare))

sort(array)
console.log(array)

sort non in-place

const sort = xs =>
  xs.slice().sort(compare).map(x => ({
    ...x,
    child: x.child.?slice()?.sort(compare),
  }))

const sortedArray = sort(array)
console.log(sortedArray)

基本的にはthktさんのやり方と同じですが、

  1. falsydisplayOrderの値は論理和(||Number.MAX_VALUEにして扱う(意味を考えると本来はInfinityにして扱う方がよいかもしれないが、引き算結果がNaNになってめんどくさいので妥協)
  2. orderSort(array).map((item) => 'child' in item && orderSort(item.child))はソートされたarrayではなくchildの配列を返し直感的ではないので、何も返さないArray.prototype.forEach()を使う。どうせArray.prototype.sort()はin-placeでソートするので値は返さなくていいかな、と
  3. childが存在するかわからないのでオプショナルチェーンを使ってsort()を呼ぶ
  4. ネストしたオブジェクトはArray.prototype.map()スプレッド構文で処理する

あたりを変えてみました。

1.に関して、falsyではなくnullundefinedにしかならないならnull合体演算子(??も使えます

あとは非常に瑣末ですが、childは配列なのでchildrenの方がより実態に則した名前になるかと。

あんまりスマートになりませんでしたが(笑)、一案ということで……

thktthkt

コメントありがとうございます!

オプショナルチェーン初めて知りました。これ使うとスマートな感じがしますね。
map()のスプレッド構文での処理や、in-placeとnon in-placeで分けて考えるのも勉強になりました。

displayOrderCompareでの処理の部分は、今回のdisplayOrder0は数値として扱いたかったので、論理和よりnull合体演算子(これも初めて知りました)がベターな感じですね。

childのご指摘もたしかにchildrenの方がしっくりきますね…。あとで変更しておきます!

ログインするとコメントできます