😇

react-gridsheetの内部構造を大幅に変更しました

2022/02/03に公開

お久しぶりです。前回の記事から大体1年くらい経ちました。
https://zenn.dev/righ/articles/f7540398a2110e

この度は内部構造を大幅に変更してバージョンも0.6.3まで成長したので、その後の変更点などをつらつらと書いていこうと思います。

できるようになったこと

いきなりデータ構造の変更について書いても読むのが辛いと思うのでまずはわかりやすい変更についてお話します。数式サポートはまだです。もし期待していたらごめんなさい。

外部からデータ更新

以前はデータの初期化以降ではユーザの操作のみしかシートのデータを更新する術がなかったので、 changes propを介して実装者が差分を適用できるようにしました。(0.6.0以降)

以下はランダムな値を2秒間隔で適当なセルに格納するサンプルです。

このサンプルのような謎な使い方をする人はいないと思いますが、たとえばリアルタイムで複数の人が更新したときにその差分をシートに適用したり、自分でツールバーのようなものを作ってその操作をシートに適用したりと言ったユースケースを想定しています。

もともと Undo, Redo をサポートするために変更を伴う操作は履歴に残るようになっていますが、外部からのデータ更新も履歴に残るのでUndoで巻き戻ります。

シートの変更差分だけを取得

これまでも options prop に onChange イベントハンドラを指定できましたが、 onChangeDiff イベントハンドラを指定できるようになりました。(0.6.0以降)

onChange イベントハンドラは現在のシート全体の値を参照できますが、 onChangeDiff では変更時の差分だけを参照できます。

さらにこれまではイベントハンドラには matrix や cellOptions というものが渡ってきましたが、0.6.0以降は代わりに table というインスタンスが渡ってきます。
このクラスはデータを取得するためのメソッドをいくつか持っており期待するフォーマットによって使い分けられます。
先程のサンプルでは table.object() によってセルIDをキーとするオブジェクト形式のデータをコンソールに出力しているので興味のある方は CodeSandbox の Console タブを開いてみてください。

これはデータ構造の変更に伴う変化です。

イベントハンドラの種類と受け取る値の追加

セルを選択するたびに発火する onSelect イベントハンドラの追加を追加しました。
また、現在選択中のセルおよび範囲を示す positions 引数を第2引数に渡します。この第2引数は onSelect 以外に onSave, onChange, onChangeDiff にも渡されます。
これにより実装者はユーザがどの部分を選択しているかがわかるようになります。

また、 onChangeDiffNumMatrix という行、列の追加削除時に呼び出されるイベントハンドラも追加しました。行の追加削除の場合は y にその行番号, 列の追加削除の場合は x に列番号が格納されます。ちなみに追加の場合は追加の行(列)数、削除の場合は削除する行(列)数がマイナスで num に格納されます。

コピー(切り取り)&ペーストで値だけでなくスタイルも適用される

以前はコピーの操作では値のみがコピーされましたが、スタイル等もコピーされるようになりました。(0.6.0以降)

これもデータ構造の変更に伴う変化です。ExcelやGoogleスプレッドシートの操作に少し近づきましたね。
ちなみに行(列)の追加時は、その行にはベースとなる行(列)のスタイルが適用されます。

行、列のサイズをまとめてできる

行や列を複数選択して、サイズを変更すると選択したすべての行または列のサイズが変更されます。(0.5.2以降)

また、以前はCSSのリサイズを使って行や列をリサイズしていたのでちょっと不自然な挙動をしている部分がありましたが、マウスイベントを使ってちゃんと実装しました。解説する気力はないので興味のある方はコードをご確認ください。
https://github.com/walkframe/react-gridsheet/blob/v0.6.3/src/components/Resizer.tsx

セルごとに任意の値をもたせられる

詳細は後述しますがデータ構造の変更に伴いセルごとの設定値が増えました。
具体的にはcustomというキーで実装者が任意の値を指定できるというものです。
初期値を指定できるのはまぁ当然ですが、ユーザによるシートに対する入力のタイミングでも変更できなくては意味がないので、パーサーをカスタマイズすることで入力値を使って任意にセルの値を変更できるようにしました。

customキーを使うような良いサンプルを思いつかなかったので、以前の記事でも使ったものを流用して入力された値を使ってstyleキーをいじることでそのセルの背景色を変更するようにしてみました。

適当なカラーコードやカラー名をセルに入力し背景色が変わることを確認してみてください。

パーサークラスの返却値もこれを実現するために修正が入りました。cellの状態が第2引数から渡ってくるのでスプレッドして埋め込んだものを返却するようにします。

class ColoringParser extends Parser {
  public parse(value: string, cell: CellType): CellType {
    const parsed = this._parse(value, cell);
    return {
      ...cell,
      value: parsed,
      style: { ...cell.style, backgroundColor: `${parsed}` }
    };
  }
}

内部の変更

ここからは内部の変更に触れていきます。

Reduxをドロップ

内部で使っていたReduxをドロップして、React Contextで値を一元管理しました。(v0.5.0以降)

Reduxをやめたことで Redux toolkit の createSlice による関数生成が使えなくなってしまいましたが、同じ使用感を実現するためにオレオレ実装を施しました。
とはいってもそれほど複雑なものでなく、ベースとなるコードは以下です。

// マッピング用のグローバル変数
const actions: { [s: string]: CoreAction<any> } = {};

// useReducerに与える関数
export const reducer = <T>(
  store: StoreType,
  action: { type: string; value: T }
): StoreType => {

  // type によってマッピングに登録された Actionインスタンスを取得
  const act: CoreAction<T> | undefined = actions[action.type];
  if (act == null) {
    return store;
  }
  // Actionインスタンスに設定されたreduce関数にペイロードを指定して実行
  return { ...store, ...act.reduce(store, action.value) };
};

export class CoreAction<T> {
  public code = "";

  public reduce(store: StoreType, payload: T): StoreType {
    return store;
  }
  // reducerが期待する入力形式にフォーマットするだけのメソッド
  public call(payload: T): { type: string; value: T } {
    return {
      type: this.code,
      value: payload,
    };
  }
  public bind() {
    // グローバルのactionsマッピングにインスタンス変数を格納
    actions[this.code] = this;
    // callメソッドにインスタンスを紐付けて返却
    return this.call.bind(this);
  }
}

このCoreActionを継承しクラスのインスタンスから bindメソッドを実行することにより、 createSlice で生成したのと同じような関数が出来上がります。

class InitHistoryAction<T extends number> extends CoreAction<T> {
  code = "INIT_HISTORY";
  reduce(store: StoreType, payload: T): StoreType {
    return {
      ...store,
      history: { operations: [], index: -1, size: payload, direction: "FORWARD" },
    };
  }
}
export const initHistory = new InitHistoryAction().bind();

ペイロードの型はジェネリクスで指定すれば生成された関数にも反映されます。
この initHistory は dispatch(initHistory(historySize)); のような感じで実行すればストアに適用されます。普通ですね。

Pythonにおけるメタクラスのようにクラス定義した瞬間に実行できるような仕組みがあればもう少しシンプルになったと思うんですが、現状では多分できなそうなのでこの辺が落とし所でしょう。

https://github.com/walkframe/react-gridsheet/blob/v0.6.3/src/store/actions.ts

データ構造の変更

これがこの中で一番大きな変更です(v0.6.0)。
結論から言えば、値を含む設定値を複数管理できるオブジェクトを各セルがもつ二重配列構造に落ち着きました。

export type CellType = {
  value?: any;
  style?: React.CSSProperties;
  verticalAlign?: string;
  label?: string | Labeling;
  width?: number;
  height?: number;
  renderer?: string;
  parser?: string;
  custom?: any;
};
export type DataType = (CellType | null)[][];

このDataTypeが描画に必要なほぼすべてのデータを持つことになりますが、これだけでは扱いづらいのでこれを内部に持つテーブルクラスを定義しました。

export class UserTable {
  protected data: DataType;
  protected area: AreaType;
  protected parsers: Parsers;
  protected renderers: Renderers;
  // 以下略
}

テーブルクラスはDataTypeの他に、二重配列の値がどこからどこまでを指しているかを示すAreaTypeを持たせています。
このテーブルクラスがシートの描画だけに使われているのであれば二重配列全体を描画するだけでよいのでこんなものは必要ありません。エリアをもたせたのは差分の管理にもこのクラスを使うためです。B2を「a」に書き換えるときの差分を管理するテーブルインスタンスはこんな感じの値を持っています。

{
  data: [[{value: "a"}]],
  area: [2, 2, 2, 2], // top, left, bottom, right
}

先程 onChangeDiff イベントハンドラの第一引数にテーブルが渡ってくると言いましたが、この内部データと領域をインスタンスメソッドが処理することで差分を作り出します。
このように新実装ではデータのやり取りをテーブルに集約することで扱いやすくしています。

さて、二重配列の設定は以下のように列ごとの設定と行ごとの設定、セルごとの設定があります。
[0][0] には全体設定、 [0][x] の座標には列ごとの設定、 [y][0] には行ごとの設定、それ以外には個別のセルの設定が入ります。

全体設定 A列の設定 B列の設定
1行目の設定 A1の設定 B1の設定
2行目の設定 A2の設定 B2の設定

一番最初のサンプルを例に以下のように初期値を与えると

<GridSheet
  initial={matrixIntoCells(
    [
      [undefined, 0, 0, 0, 0],
      [undefined, undefined, 0, 0, 0],
      [undefined, undefined, undefined, 0, 0],
      [undefined, undefined, undefined, undefined, 0],
      [0, undefined, undefined, undefined, undefined]
    ],
    {
      default: {
        style: { fontWeight: "bold" }
      },
      1: {
        style: { color: "red" }
      },
      2: {
        style: { color: "green" }
      },
      3: {
        style: { color: "blue" }
      },
      4: {
        style: { color: "orange" }
      },
      5: {
        style: { color: "purple" }
      },
      A: {
        value: 1,
        style: { backgroundColor: "#ff000050" }
      },
      B: {
        value: 2,
        style: { backgroundColor: "#00ff0050" }
      },
      C: {
        value: 3,
        style: { backgroundColor: "#0000ff50" }
      },
      D: {
        value: 4,
        style: { backgroundColor: "#ffff0050" }
      },
      E: {
        value: 5,
        style: { backgroundColor: "#ff00ff50" }
      }
    }
  )}
  // 以下略
/>

テーブルインスタンスの初期値は以下のようになります。

{
  data: [
    [ // 列の共通設定
      {style: { fontWeight: "bold" }}, // 全体設定
      {value: 1, style: { backgroundColor: "#ff000055" }}, // A列の共通設定
      {value: 2, style: { backgroundColor: "#00ff0055" }}, // B列の共通設定
      {value: 3, style: { backgroundColor: "#0000ff55" }}, // C列の共通設定
      {value: 4, style: { backgroundColor: "#ffff0055" }}, // D列の共通設定
      {value: 5, style: { backgroundColor: "#ff00ff55" }}, // E列の共通設定
    ],
    [
      {style: { color: "red" }}, // 1行目の共通設定
      {value: 1, style: { backgroundColor: "#ff000055", color: "red", fontWeight: "bold" }}, // A1の設定
      {value: 0, style: { backgroundColor: "#00ff0055", color: "red", fontWeight: "bold" }}, // B1の設定
      {value: 0, style: { backgroundColor: "#0000ff55", color: "red", fontWeight: "bold" }}, // C1の設定
      {value: 0, style: { backgroundColor: "#ffff0055", color: "red", fontWeight: "bold" }}, // D1の設定
      {value: 0, style: { backgroundColor: "#ff00ff55", color: "red", fontWeight: "bold" }}, // E1の設定
    ],
    [
      {style: { color: "green" }}, // 2行目の共通設定
      {value: 1, style: { backgroundColor: "#ff000055", color: "green", fontWeight: "bold" }}, // A2の設定
      {value: 2, style: { backgroundColor: "#00ff0055", color: "green", fontWeight: "bold" }}, // B2の設定
      {value: 0, style: { backgroundColor: "#0000ff55", color: "green", fontWeight: "bold" }}, // C2の設定
      {value: 0, style: { backgroundColor: "#ffff0055", color: "green", fontWeight: "bold" }}, // D2の設定
      {value: 0, style: { backgroundColor: "#ff00ff55", color: "green", fontWeight: "bold" }}, // E2の設定
    ],
    [
      {style: { color: "blue" }}, // 3行目の共通設定
      {value: 1, style: { backgroundColor: "#ff000055", color: "blue", fontWeight: "bold" }}, // A3の設定
      {value: 2, style: { backgroundColor: "#00ff0055", color: "blue", fontWeight: "bold" }}, // B3の設定
      {value: 3, style: { backgroundColor: "#0000ff55", color: "blue", fontWeight: "bold" }}, // C3の設定
      {value: 0, style: { backgroundColor: "#ffff0055", color: "blue", fontWeight: "bold" }}, // D3の設定
      {value: 0, style: { backgroundColor: "#ff00ff55", color: "blue", fontWeight: "bold" }}, // E3の設定
    ],
    [
      {style: { color: "orange" }}, // 4行目の共通設定
      {value: 1, style: { backgroundColor: "#ff000055", color: "orange", fontWeight: "bold" }}, // A4の設定
      {value: 2, style: { backgroundColor: "#00ff0055", color: "orange", fontWeight: "bold" }}, // B4の設定
      {value: 3, style: { backgroundColor: "#0000ff55", color: "orange", fontWeight: "bold" }}, // C4の設定
      {value: 4, style: { backgroundColor: "#ffff0055", color: "orange", fontWeight: "bold" }}, // D4の設定
      {value: 0, style: { backgroundColor: "#ff00ff55", color: "orange", fontWeight: "bold" }}, // E4の設定
    ],
    [
      {style: { color: "purple" }}, // 5行目の共通設定
      {value: 1, style: { backgroundColor: "#ff000055", color: "purple", fontWeight: "bold" }}, // A5の設定
      {value: 2, style: { backgroundColor: "#00ff0055", color: "purple", fontWeight: "bold" }}, // B5の設定
      {value: 3, style: { backgroundColor: "#0000ff55", color: "purple", fontWeight: "bold" }}, // C5の設定
      {value: 4, style: { backgroundColor: "#ffff0055", color: "purple", fontWeight: "bold" }}, // D5の設定
      {value: 5, style: { backgroundColor: "#ff00ff55", color: "purple", fontWeight: "bold" }}, // E5の設定
    ],
  ],
  area: [0, 0, 5, 5], // top, left, bottom, right
}

matrixIntoCellsは値の行列を設定オブジェクトに埋め込むための関数で、 値がundefinedの場合はオブジェクト側の値で上書きされます。
data propからこちらに変更になったので使っている人は書き換えが必要です。
各セルの設定は全体設定、行、列の共通設定でマージされたものが初期値として与えられ、以降共通設定がセルに適用されることはありません。ただし、行の場合は 行共通設定.height, 列の場合は 列共通設定.width が常にセルの描画に使われます。

ちなみに内部で扱うときは Table クラスを使っていますが、イベントハンドラで渡ったときにテーブルが破壊されると困るので非破壊メソッドだけを持つ UserTable(Tableのベースクラス)に型を付け替えて渡しています。型を付け替えただけで実体はTableなので呼べちゃうわけですが呼ばないでください...(もっと安全なアップキャスト方法知ってる人いたら教えてほしい)

これまでの構造

(ここは暇な人だけ読んでください。自分用の備忘録みたいな目的もあります。)

このような単純な構造に落ち着きましたが、ここに来るまでに紆余曲折がありました。

実際、今までは「値を格納する二重配列」と「スタイル等の設定値を適用するオブジェクト」によって初期値が与えられ、内部でもこの構造が使われていました。
propsをこの2つに分けたところまでは良かったのですが、内部でもこの構造を維持する必要はありませんでした。
実際、これらが別々に管理されているということとスタイル等の設定値がセルID(例:B2など)をキーとするオブジェクトとしていることで実装コストが高くなってしまいます。
たとえば、セルIDをキーとしてしまうと行(列)の挿入により、オブジェクトに含まれる挿入行以降のキーをすべてずらして登録し直す必要が出てきてしまうのです。A2,A3の設定値があったとして、1行目に1行追加すると、A3の設定値をA4に移動した後、A2の設定値をA3の設定値にずらさないといけません。このように設定をずらす処理が行数x列数の分だけ発生するため実行コストもかかります。オブジェクト形式のデータ構造はスプレッド構文によりデータを更新するのは楽なように思えましたが実装をしてみると適切ではなかったのです。

これを改善するために最初に考えたのはアドレスを管理する二重配列とアドレスをキーとするセルのオブジェクトです。

{
  addressTable: [
    ["1", "2", "3", "4", "5"],
    ["6", "7", "8", "9", "A"],
    ["B", "C", "D", "E", "F"],
    ["G", "H", "I", "J", "K"],
    ["L", "M", "N", "O", "P"],
  ],
  cells: {
    "7": {value: "a"},
    "8": {value: "b"},
    "9": {value: "c"},
    "A": {value: "d"},
  },
  nextAddress: 26,
}

addressTable は一意なアドレスを管理する二重配列で、 cells はこの一意なアドレスをキーとしてセルの値を持ちます。アドレスとは絶対に重複しない一意な連番を36進数化した文字列((100).toString(36) するだけ!簡単!)です。
(A1のような)セルIDをキーにしてしまうとセルIDをずらすコストが生じてしまう反省を生かしてこのようにしました。

この構造のメリットはシートの更新も柔軟かつ容易で、行や列の追加削除にも強いという点です。
たとえば A1を切り取ってD4に貼り付ける場合は {"7": null, "P": {value: "a"}} のような最小限の差分で離れた領域の更新を表現できます。
しかしデメリットもあり、使わなくなったアドレスのキー削除を忘れるとメモリリークを引き起こす恐れがあります。メモリ消費量も現実装の倍くらいになります。

切り取り操作を除けば大抵の操作は連続した領域中に収まりこの実装のメリットはオーバースペックだったのです。
しかしテーブル構造の差分は離れた領域の変更を表現できないので、少し気持ち悪いですが履歴の差分のテーブルは複数になる前提としました。切り取り操作は切り取り元を削除するテーブルと切り取り先にペーストするテーブルの2つが1回の操作差分となります。
ただ、 onChangeDiff イベントハンドラで複数のテーブルが渡ってくると扱いづらいので、2つの領域をカバーするようなテーブルを作ってからイベントハンドラに渡しています。履歴もこうやって1つにしちゃってもいいのかなーと悩んでましたがとりあえず問題が起きるまではこのままで。

おわりに

データ構造が拡張しやすい形になったのでこれからは機能追加を頑張っていこうと思います。

  • 数式の評価
  • セルやシートの保護
  • セルの結合
  • オートフィル

この辺ができてからバージョン1のリリースをするつもりです。
特に数式サポートはパースや評価が結構難しそう。実装イメージはついていますがいつ終わるかは全くわかりません。ちょっとずつすすめるので応援してください。

https://github.com/walkframe/react-gridsheet

記事とは関係ないですが3月くらいから暇になる可能性があるのでReactやGoなどで業務委託のお仕事があったらTwitterのDMやブログからの問い合わせでご連絡くれると喜びます🙏

https://twitter.com/crohaco
https://note.crohaco.net/
ちなみにブログは去年の後半くらいからNext.js製になりました。SSGしたものをレンサバに載せてるだけでVercel使ってない..w

Discussion