he-treeで大規模ツリービューを開発した話

GMOメイクショップ コアグループ フロントエンドエンジニアの原田です。業務でVue3向けツリービューライブラリ phphe/he-treeを使い、ドラッグドロップ対応の大規模なツリービューを開発したので、その実装の流れをご紹介します。

課題

次世代ECの管理画面にはショップ内に表示するカテゴリの 項目・順序・階層を設定できるツリー状のカテゴリ設定ページがあります。 各カテゴリはドラッグドロップで並び替えでき、5階層までの階層構造にできます。

リニューアル前のカテゴリ設定ページ

この画面は元々vue-draggableと独自コンポーネントを用いて実装されていましたが、深刻なパフォーマンス問題に直面していました。実は、ここに表示されるカテゴリは利用するショップ様によっては数千件を超えることがあります。元々の実装では、その多数のカテゴリをページ内に描画した際、描画遅延やメモリ不足が頻発していました。

一向にページが表示されず、しばらく待つとOUT OF MEMORYも発生する

結果として多数のカテゴリがあるとほとんど操作できない状況であることから、やむを得ず旧管理画面を使って頂く状況が続きました。この状況を解決すべく、カテゴリ一覧ページのパフォーマンス最適化対応を行いました。

対応の検討

まず既存コンポーネントを改修し改善できないかを検討します。しかしながら既存コンポーネントは、ストアに依存した処理の密結合、vue-draggableを遅延読み込みと併用するとスタイルに大幅な崩れがあり上手く動作しない等の課題がありました。これら課題解消のために、一度まっさらな状態に戻し、外部のよくメンテナンスされたライブラリに頼ることが策と考え、置き換えを行いました。

ライブラリ選定

類似ライブラリはいくつかありますが、下記4点からこのライブラリを選定しました。

選定のポイント

1 vue3を想定して開発されているか

Vue2時代のライブラリはVue3の破壊的変更により基本的には動かない事が多いです。 必然的にVue3対応と明記されているライブラリを調査しました。

2 ドラッグドロップに単一ライブラリで対応しているか

vue-draggableとの併用ができるTreeViewライブラリも存在しますが、ハックな書き方になりがちで、両ライブラリのサポート外にもなります。 はじめから統合されているライブラリがベターと考えました。

3 遅延描画に対応しているか

今回一番注力すべき点は、描画時間短縮・メモリ不足エラーの解消でした。 フロントエンドの描画負荷軽減には 遅延読み込み・遅延描画。画面に入るまでは要素を描画/生成しないという戦略がよく知られています。 遅延描画ができない場合、数千件の項目を一気に表示することになります。遅延描画無しでは難しいと考えてこの対応を前提としました。

4 十分なドキュメントとデモが存在しているか

いくつか類似ライブラリを見た所、動きそうではあるもののドキュメントがあまり無いものが散見されました。 he-treeはドキュメントがある程度揃っており、実装しやすいと考えました。

類似ライブラリ vue3-treeview

he-treeによく似たライブラリに vue3-treeviewというものもありました。 しかし、こちらは "非同期読み込み" には対応しているものの、"遅延描画"に対応していませんでした。

vue3-treeviewでは 項目が複数ある場合に、"特定のツリーを開こうとした際に別途サーバーから要素を取得する"という遅延読み込みに対応しています。 今回の要件は全項目を一括取得、画面内に入るまでは描画をしない、全項目をドラッグドロップ可能というものだったため、仕様が異なり採用を見送りました。

解決策

出来上がったもの

he-treeを使いリニューアルした後のカテゴリ設定画面

he-treeでのリニューアル後は要素数が数千件と多い場合であってもパフォーマンスが向上し、最大120秒ほどかかっていた所が10秒前後まで縮まりました。以前と同様、並び替え時には移動可能かどうかのバリデーションも行えています。 ライブラリのドラッグドロップがうまく実装されているのか、リニューアル前よりもスムーズに階層構造を設定できるという良い副効果もありました。

工夫点

移動可能条件設定

このカテゴリページはシンプルに見えて、複数の移動可能条件がありました。それらを全てhe-treeでも動くように移植しました。 he-treeではやや特殊な書き方が必要だったため、その実装例を共有します。

その1: ツリーにできない要素を移動不可にする

仕様により "すべての商品"というカテゴリは他の要素の子にすることができず、この要素に子を持つこともできません。 each-droppable関数statdragContextを使い "すべての商品"を掴んでいる場合は 移動不可(false)を返すようにしました。

クリックでソースコード表示

import { dragContext } from '@he-tree/vue'

/** カテゴリオブジェクト */
type CategoryNode = {
  id: number
  name: string
  children: CategoryNode[]
}

const CATEGORY_ALL_ITEM = 'all-item' as const

/** すべての商品であるかどうかを判定 */
const isAllItemCategory = (node: CategoryNode) => {
  return node.id !== CATEGORY_ALL_ITEM
}

/**
  * ドラッグして並び替えを行いドロップする直前の、ドロップ可能かの判定。
  * @param futureStat - 何もバリデーションなく移動に成功した場合の状態(未来)を指す引数
*/
const handleEachDroppable = (futureStat: Stat<CategoryNode>): boolean => {
  // その1: すべての商品を他のカテゴリの子要素にはできない
  // dragContext.startInfo.dragNode.data にドラッグ中のデータが入ります
  const draggingCategory: CategoryNode = dragContext.startInfo.dragNode.data
  // 掴んでいるのがすべての商品 かつ 移動後の階層が1階層目以外であれば移動不可
  if (isAllItemCategory(draggingCategory)) return futureStat.level < 1

  // その1: すべての商品に子要素を持つことはできない
  // dragContextとなっているカテゴリの移動完了後に親要素となる要素情報がfutureStatに入る
  const parentCategory: CategoryNode = futureStat.data
  // 親要素がすべての商品になっている場合は子要素を追加しようとしたことになり、移動不可
  if (isAllItemCategory(parentCategory)) return false
  return true
}
    <!-- カテゴリ一覧ツリー -->
    <draggable-tree
      v-model="categoryList"
      virtualization
      tree-line
      :default-open="false"
      :each-droppable="isReorderDroppable"
    >

その2: 同名の要素を同一階層に置けないようにする

同名のカテゴリは見分けが付かなくなるため、同一階層に含められないという仕様があります。 同じくeach-droppableコールバックを利用して、移動可能条件を実装しました。また、直下のみroot-droppableというコールバックが呼ばれるのでそちらにも似た処理を実装しました。 注意点として、これらは同一階層内の並び替えでも呼ばれるため、同一階層内だった場合の例外処理を忘れず行いましょう。

クリックでソースコード表示

import { dragContext } from '@he-tree/vue'

/** カテゴリオブジェクト */
type CategoryNode = {
  id: number
  name: string
  children: CategoryNode[]
}

const CATEGORY_ALL_ITEM = 'all-item' as const

/** すべての商品であるかどうかを判定 */
const isAllItemCategory = (node: CategoryNode) => {
  return node.id !== CATEGORY_ALL_ITEM 
}

/**
  * ドラッグして並び替えを行いドロップする直前の、ドロップ可能かの判定。
  * @param futureStat - 何もバリデーションなく移動に成功した場合の状態(未来)を指す引数
*/
const handleEachDroppable = (futureStat: Stat<CategoryNode>): boolean => {
  // その1: すべての商品を他のカテゴリの子要素にはできない
  // dragContext.startInfo.dragNode.data にドラッグ中のデータが入ります
  const draggingCategory: CategoryNode = dragContext.startInfo.dragNode.data
  // 掴んでいるのがすべての商品 かつ 移動後の階層が1階層目以外であれば移動不可
  if (isAllItemCategory(draggingCategory)) return futureStat.level < 1

  // その1: すべての商品に子要素を持つことはできない
  // dragContextとなっているカテゴリの移動完了後に親要素となる要素情報がfutureStatに入る
  const parentCategory: CategoryNode = futureStat.data
  // 親要素がすべての商品になっている場合は子要素を追加しようとしたことになり、移動不可
  if (isAllItemCategory(parentCategory)) return false

  // その2: 同名のカテゴリを含む親要素の子要素にはできない
  // 新しく親になる予定の要素の子階層 (つまりドラッグ先と同一階層)の要素名配列を取得
  const newParentChildren = parentCategory.children.map((e) => e.name)
  // 移動元と移動先が同じ親要素の場合は許可する
  if (dragContext.startInfo.dragNode.parent?.data.name=== parentCategory.name) return true
  // 同名のカテゴリを含む親要素の子要素にはできない
  if (newParentChildren.includes(draggingCategory.name)) return false
  return true
}

 /**
  * カテゴリを並び替えドロップしようとしたとき(直下)
  * このコールバックではstatを取れない
  */
  const isReorderRootDroppable = (): boolean => {
    // 既に直下にあるカテゴリを移動しようとしているなら常時並び替え可能
    if (dragContext.startInfo.dragNode.level === 1) return true
    // その2: 直下にドラッグ中の要素と同名のカテゴリを含む場合ドロップできない
    const draggingName = dragContext.startInfo.dragNode.data.name
    // draggable-treeに渡しているオブジェクトの1層目を直接参照して含まれていないか判定
    // NOTE: he-treeの仕様かバグか、稀にundefinedが含まれるので除去
    const currentChildren = categoryList.value.map((t) => t.name).filter((t) => t !== undefined)
    if (currentChildren.includes(draggingName)) return false
    return true
  }
    <!-- カテゴリ一覧ツリー -->
    <draggable-tree
      v-model="categoryList"
      virtualization
      tree-line
      :default-open="false"
      :each-droppable="isReorderDroppable"
      :root-droppable="isReorderRootDroppable"
    >

折り畳み状態の保持

ツリービューは要素数が多いため、折り畳みできるのが一般的だと思います。he-treeも折り畳みをサポートしており、各ノード要素のstat.openという変数を操作すると折り畳み状態を書き換えできます。しかし、この折り畳み状態はデフォルトだと一時的なもので、ツリーの再代入を行うと消えてしまいます。stat-handler関数と外部のマップオブジェクトを使って1つずつキャッシュすることで、ツリーの再代入時も状態を保持できるようにしました。

クリックでソースコード表示

/** ツリー開閉状態を保持するcomposable */
export const useTreeViewFoldCache = () => {
  const openStateCache = new Map<number, boolean>()

  /**
   * categoryTreeの描画時に1つずつ呼ばれる状態代入処理
   * 更新完了&再代入時、既に保持している開閉状態を復元する
   */
  const onCreateNodeStat = (stat: Stat<CategoryNode>) => {
    // キーがない場合はfalseで初期化
    if (!openStateCache.has(stat.data.id)) {
      openStateCache.set(stat.data.id, false)
    }
    stat.open = openStateCache.get(stat.data.id) || false
    return stat
  }
  /** 対象のstatの開閉状態を代入する */
  const updateNodeStat = (id: number, isOpen: boolean) => {
    openStateCache.set(id, isOpen)
  }
  /** ノード変更時に呼ばれる状態保存処理 */
  const onUpdateNodeStat = (stat: Stat<CategoryNode>) => {
    updateNodeStat(stat.data.id, stat.open)
  }
  return {
    onCreateNodeStat,
    onUpdateNodeStat,
    updateNodeStat,
  }
}
    <!-- カテゴリ一覧ツリー -->
    <draggable-tree
      v-model="categoryList"
      virtualization
      tree-line
      :default-open="false"
      :each-droppable="isReorderDroppable"
      :stat-handler="onCreateNodeStat"
      @open:node="onUpdateNodeStat"
      @close:node="onUpdateNodeStat"
    >

パフォーマンスの最適化

実装後このライブラリの遅延読み込みで、大幅に早くはなったものの、それでも読み込み時に数秒のフリーズが発生していました。 原因を探るため、実装後の画面内コンポーネントを極限まで削ってみたところ、v-tooltipが数千件発生しているのが一番の要因であることがわかりました。 仕様相談の上、一旦ツールチップを外すことで読み込み時間が数十秒ほど短縮され、大幅にパフォーマンスが向上しました。※最新版のVuetifyでは解消している可能性有り。

大量のv-tooltipが生成されている様子(現在は再現せず)

ドラッグドロップ以外の手段での並び替えとの併用

表示ができていても、数千件もカテゴリがあるとドラッグドロップだけでの操作は困難です。 そこで、移動元と移動先を指定して並び替えるモーダルを実装し、併用可能な仕様としました。

カテゴリ設定画面内に別途実装した並び替えモーダルの画面例

ツリービューで管理しているオブジェクトはネスト構造であるのに対し、モーダルはフラットなリストを利用するため相互に変換できる仕組みを作成しました。

困った点

ドラッグ可能判定の書き方に癖有り

先ほどのコードの通り、ドロップ可能判定処理がやや複雑に感じました。dragContextとfutureStatを使ったバリデーションはあまり見ない構造のため、保守がし辛いかもしれません。 一方で、このライブラリで提供されているstatオブジェクトは様々なフィールドを持つため、より良い他の書き方ができる可能性がありそうです。

バリデーションエラーを表示できない

今回の実装では移動を"させない"バリデーション処理になっていますが、移動をした後にバリデーションエラーを表示する場合は、別の実装方法を考える必要があります。

遅延描画させるためには スクロールする要素が必要

このライブラリの場合、スクロール位置が規定位置に達したら描画するという遅延描画のため、予め固定の高さを指定したラッパー要素を設置する必要がありました。 そのため、元のデザインからの変更がやや必要となりました。

まとめ

  • he-treeは 遅延描画・ドラッグドロップに対応したツリービューライブラリです。
  • ドキュメント記載のeach-droppableやroot-droppable等のコールバック処理を使うことで独自のバリデーションを実装できます。
  • 大量のデータの表示でパフォーマンスに問題がある場合は、遅延描画や、表示するオブジェクトを減らすことが効果的です。
  • ライブラリの採用は、よく試してみてから検討しましょう。

参考

github.com

hetree.phphe.com