今日も8時間睡眠
888文字のブログです

【長文】Vue.jsを使ってリストをツリーに変換するツールを作った軌跡

2021/07/05 注記:以下の内容は古いです。リストからツリーを作成するツールをVueで作ろう に、Vue3 で説明している記事を載せています。

🗼

いわゆる猫本を使って、Vue.jsをちょっと勉強したので、簡単なツールを作ってみました。機能的には簡単ですが、自分の能力的には難問でした。その悪戦苦闘した顛末を書いていきます。ツール自体は、list2treeで公開しています。

mio シーアンドアール研究所 2018/05/29

やりたかったこと

作りたかったものは、リストから、テキストベースのツリーを作るツールです。テキストベースのツリーというのは、こういうものです。

root
├── item-1
│   ├── item-2
│   └── item-3
└── item-4
    └── item-5

プログラミング関連のサイトでは、ディレクトリの構成を書きたくなることがありますが、まさにそういうときに使うことを想定したツールです。実際のディレクトリ構成をツリー形式で書きたい場合は、treeコマンドでもできるのですが、架空の構成を書きたいときとかには使えるんじゃないかと思います。

別のサイト「なかけんのHugoノート」を作っている途中で(まだたいした中身がないですが)、このページみたいな記事を書きたいときに欲しいなと思ったので作ってみることにしました。

第一の案:テキストエリア

どうやって作っていくかを考えていきます。リストの情報を入力するところと、ツリーの情報を出力するところを作ればおしまいです。ツリーは、Mustache構文を使えばいいとして、問題はリストのほうです。リストの情報をどうやって取得して、どうやって処理してツリーの作成まで持ち込むか、を考えなくてはいけません。

はじめは、特に何も考えずに、テキストエリアでいいのではないか、と思っていました。ユーザーがテキストエリア内で、マークダウン形式でリストを入力する、JavaScript側でそれをHTMLに変換して、さらにツリーに変換する、という流れで考えていました。

こうすると、リストの入れ子があったとしても、その解析はHTMLに変換した時点で終わっているはずです。なので、ツリーに変換するときの構造解析も簡略化できるだろう、ともくろんでいました。

まだ何ができて何ができないのかわからない状態なので、復習がてら、作れそうな部分を作っていきました。例えば、テキストエリアで、タブを押したときに、フォーカスを外すのではなく、タブ文字が入るようにする、という部分などを作成していました。

<textarea v-model="message"
        v-on:keydown.tab.prevent="onTab($event)"
        ></textarea>

preventを使って、フォーカスを外すのを止めます。

onTab: function(event) {
    var targetTextarea = event.target;
    var targetText = targetTextarea.value;

    var cursorPosition = targetTextarea.selectionStart;
    var cursorLeft = targetText.substr(0, cursorPosition);
    var cursorRight = targetText.substr(cursorPosition, targetText.length);

    this.message = cursorLeft + "\t" + cursorRight;
    this.$nextTick(function() {
        targetTextarea.selectionEnd = cursorPosition + 1;
    });
}

カーソルキーの場所を探して、タブ文字を入れます。$nextTickを使って、再描画後に、カーソルの位置を自然な場所に来るようにしています。

こうやって必要そうなパーツを作りつつ、マークダウン形式のテキストをHTMLに変換する方法を調べました。いくつか方法はありましたが、Marked.jsが一番便利そうでした。parserなども用意されていて、HTMLタグに変換される前の状態をいじれそうだということもわかり、うまくいきそうな気配はありました。

しかし、進めていくと、この方法はちょっと難しそうだな、という気がしてきました。

例えば、テキストエリアに、リスト以外の内容が入力されてしまったらどうしよう、という問題があります。ulタグ、olタグ以外を無視するという方法もあるだろうし、ぜんぶ変換するけどリストだけツリーに変えるという方法もあるでしょう。ただ、どちらにしろ、簡単ではありません。リスト以外のことで悩むのは本質的ではないと思い始めました。

また、結局、階層の深いリストが入力されたときに、それをツリーに変換するのがめんどくさそうに思えてきました。というか、ここに至るまでに、前述の問題によって力尽きそうな気配がしてきました。マズい兆候です。

ツリーが2つ以上入力されたら困ってしまうことにも気づいてしまいました。テキストエリアは自由過ぎて、扱いづらいな、という考えに至りました。

第二の案:コンテントエディタブル

テキストエリアで頑張るのはつらそうだという気がしてきたので、何かほかにいいアイデアはないかなとあたりを見回してみました。すると、普段使っていた、Dynalistに目が留まりました。Dynalistは、アウトライナーと呼ばれるツールです。リンク先の画像を見てみたほうがわかりやすいと思いますが、ざっくりいうと、階層付きの箇条書きでメモが取れるツールです。

Dynalistはとてもいいツールで、これはこれでいつか記事を書きたいくらいなのですが、「階層付きの箇条書きが入力できる」って、まさに今自分がやりたいことじゃないか、と思ったんですよ。盲点でした。list2treeを作るためのアイデアをDynalistで整理している時点で気づけよって感じですけども。

で、Dynalistでは、どうやって階層付きのリストを処理してるんだろうと思って調べてみると、contenteditable属性というものを使っていたんですね。その名の通り、要素を編集できるようにする属性です。

<div contenteditable="true">編集できるdiv</div>

このように書くと、要素が編集できるようになります。inputでもtextareaでもないのに、気持ち悪いですね。まぁでもこれを使えば、うまくいきそうな予感がしてきます。

配列の形にしたリストデータを、次のようにして描画します。(item.valueの前にある波かっこは、本当は半角スペースなしで続けて書きます。)

<div contenteditable="true"
        v-for="(item, index) in list" v-bind:key="item.id"
        v-bind:style="{marginLeft: (item.tabindex * 20 + 20) + 'px'}"
        >{ { item.value }}</div>

tabindexには、別のところで階層レベルを入れておきます。marginLeftの部分で、margin-leftが設定され、深さが定義されたような見た目になります。しかも、cssで、display: list-item;と設定すれば、見た目は完全にリストです。こうして、見た目がリストのdivが出来上がります。

これに、イベントごとにやりたい処理を追加していきました。タブ・シフトタブで階層を上下する、上下キーでフォーカスを移動する、エンターキーで新規要素を追加する。ここら辺は、問題なくいけました。

タブ・シフトタブは、階層を上下するだけなら、margin-leftの値を変えるだけです。実際には、上下の階層が2個以上離れないような処理を入れていますが、それほど難しい処理はしていません。タブキーだけを押したのか、シフトキーとあわせて押したのかは、次のようにして区別します。

v-on:keydown.tab.exact.prevent="onTab(item,index)"
v-on:keydown.tab.shift.prevent="onTabShift(item,index)"

exactをつければ、タブキーだけを押したときだけ呼び出すことができます。

上下キーでフォーカスをするには、this.$refsを使って要素を選び、focus()を使ってフォーカスするようにしています。各div要素に、v-bind:ref="'list' + item.id"と設定しておき、

this.$refs['list' + this.list[index + 1].id][0].focus();

と書けば、次の要素にフォーカスがうつるようになります。複数の中から呼び出す場合、$refsは配列形式を使うんですね。

エンターキーの処理は、リストに新しい要素を追加するだけです。

なんだか順調な気がしていました。コンテントエディタブルを使うこの方法だと、リスト以外を入力されたらどうしよう、みたいな問題はありません。そもそもリスト以外、入力できないですからね。HTMLタグを入れても、そのまま出力されます。リストの処理だけに集中できます。なんだか完成が見えてきた感じです。

ただ、意外に盲点だったのが、入力です。contenteditableといいつつ、要素を編集するようにすると、ちょっと問題が起こってしまいました。

コンテントエディタブルの罠

キーを押したときのイベントをいろいろ追加しつつ、リストを触ってみたりしていたのですが、要素を編集したときに、挙動がおかしいことに気づきました。要素を編集したとき、文字を入力するたびに、カーソルが先頭に行ってしまう、という現象が起きていました。

v-on:inputを使うことで、divの要素を編集するたびに、リストは更新され、ツリーにも反映されるようになっています。しかし、カーソルが毎回先頭に行くと、ろくに編集ができません。

当初、原因がわからなかったのですが、Using v-model with contenteditable block - Get Help - Vue Forumとか、Vue.js で contenteditable を v-model 風に使う - Qiitaとかを見てみると、原因がわかりました。カーソルが先頭に行ってしまうのはこういう流れかららしいです。

  • inputイベントにより、リアクティブなリストが更新される
  • リストにバインドされているdiv要素たちが再描画される
  • contenteditabledivを編集中に再描画すると、カーソルが先頭に移動する
  • ろくに編集できない

先ほど挙げたQiitaのページでは、編集用のcontenteditabledivと、出力用のdivとに分ける案が使われています。しかし、今回のツールでは、タブ・シフトタブで階層移動できるようにもしたいため、編集用のdivが再描画されないと困ってしまうんですね。エンターキーで新規追加するときにも再描画したいです。inputのときだけ、再描画しないようにする、なんて処理は難しそうです。

他にも探していたところ、[Vue.js]contenteditable属性を使って100マス計算アプリをつくる | Black Everyday Companyを見つけました。こちらのページでは

v-modelでdataオブジェクトにバインドできないので、blurイベントを利用している。

と書かれています。このように、blurイベント、つまり、フォーカスが外れたときのイベントで対応する、というのが現実的な解のようです。リアルタイムでツリーには反映されませんが、フォーカスを移動したり、リスト以外のところをクリックしたり、新規追加したときに、リストとツリーが更新されるようになります。この方針で作っていくことにしました。

ツリーをよく見てみる

ここまでは、リストの見た目とかリストに対するイベントの処理について書いてきましたが、ツリーのロジックの部分もなんとかしないといけません。メインの部分ですね。

ツリーを作るのに、参考になるページを検索してみました。ディレクトリ構造を表示するときに使われることから、特殊なデータ構造でないことはわかります。なので、どこかに有用な記事が落ちてるんじゃないか、と予想していました。

いろいろ探してみたところ、僕の検索の仕方が悪いのか、だいたいツリー構造(二分木とか)が引っかかってしまいました。関連してそうな質問が見つかっても、「treeコマンドを使えばいいじゃない」で終わっていることが多く、自分で作るしかなさそうでした。

そこで、ツリーをもう一度よく見てみました。

root
├── item-1
├── item-2
│   ├── item-3
│   └── item-4
│       └── item-5
└── item-6
    └── item-7

ツリーを作る際、線の部分は、4パターンあることがわかります。

  • ├──
  • └──
  • │ と空白3つ
  • 空白4つ

線の表現方法は他にもありえます(4文字じゃなくて2文字で表現するとか、別の罫線を使うとか)が、ここでは、上の4パターンで考えます。

1つ目と2つ目は、要素のすぐ左側に来る線です。この違いは、自分が、兄弟の中で最後の弟なのかどうか、ということです。言い換えると、自分の親から考えた場合、自分が最後の子供かどうか、ということですね。

3つ目と4つ目は、さらにその左に来る線です。上のツリーの例で言うと、3つ目の例は、item-3, 4, 5に現れていて、4つ目の例は、item-5, 7に現れています。わかりにくいですが、空白4つは、item-5では、左から2つ目に、item-7では、左から1つ目に現れています。

3つ目になるか、4つ目になるか、この違いは少し難しいですが、キーとなるのは、自分の親が、最後の子供かどうか、つまり、自分の親の親から考えた場合、自分の親が最後の子供かどうかによって変わってくることが分かります。この部分は、後でもう少し詳しく見ることにします。

ここまで見たことから、自分から見て親がどれか親から見て最後の子供がどれか、この2つを判別する必要がありそうです。

ツリーへの変換を考える

先ほど見たように、リストのデータから、自分から見て親がどれか、親から見て最後の子供がどれか、を判別する必要がありそうなので、これをどう処理するかを考えてみます。

各要素に対して、「親はどれかな」「最後の子供はどれかな」と一つ一つ検索して処理していくと、すごく時間がかかってしまいそうです。要素の数は、百個とか一万個とかのオーダーになることは考えにくいですが、シンプルにできるならシンプルにしたいですね。

今回は(現時点では)、次のように処理をしています。まず、リストに対して、1番目から順番に見ていきます。ルートを0番目にしているので、その直下からスタートします。1行前のリストと比べて

  • 同じ階層だったら、親は同じ
  • 一つ下の階層だったら、1行前が親
  • それ以外は、別のロジック

とします。1つ目と2つ目は簡単ですね。問題は、それ以外の場合です。先ほどのツリー

root
├── item-1
├── item-2
│   ├── item-3
│   └── item-4
│       └── item-5
└── item-6
    └── item-7

でいうと、item-6のときのような場合にどうやって処理するかは、それほど単純ではありません。今回は、各世代の情報を記憶しておく、という処理を入れました。第0世代はroot、第1世代はitem-1, 2, 6、というような感じで、上から処理していくときに、世代の情報を記憶するようにしました。こうすることで、「item-6は第1世代だから、親は第0世代の要素だ」と簡単に認識できるようになります。

また、「最後の子供がどれか」というのは、各要素に親の情報を持っていることを利用します。上から順番に作業をするときに、「自分の親の最後の子供は自分だ」という情報を更新していきます。1周すれば、各要素に対して、「最後の子供」に正しい要素がセットされるようになります。

こうして、要素を上から1周するだけで、「自分の親」と「自分の最後の子供」の情報が取得できるようになりました。これを元に、罫線の処理をしていけば終了です。

罫線は、インデントの部分と、要素のすぐ左の部分とに分けて考えます。インデントを考えるのは、第2世代以降だけでOKですね。インデントは1行前の要素の一部分を使いまわします。たとえば

│   └── item-4

の次の行を処理する場合は、左側の「| と空白3つ」を使いまわします。次に、item-4の子供item-5を追加することを考えます。item-5の親item-4は、その親item-2の最後の子供なので、「空白4つ」を追加します。item-5は、その親の最後の子供なので「└── 」を付け加えて、

│       └── item-5

となります。もし、item-5の親item-4が、その親item-2の最後の子供でない場合は、item-4の世代(第1世代)がまだ続くということなので、罫線が変わって

│   │   └── item-5

となります。また、item-5が、その親item-4の最後の子供でない場合は、item-5の世代(第2世代)がまだ続くということなので、

│       ├── item-5

となります。

こうした処理を行っていくと、1周し終わった後にはツリーが完成します。親と最後の子の情報の更新で1周、ツリーの作成で1周。要素数をnとすると、O(n)のオーダーで処理が終わります。当初は、各要素に対して、親がどれかを都度探す、などと考えていたのですが、やはり、それだと時間がかかり過ぎてしまいます。

アイテムの削除ではまったこと

こうして、ツリーの作成まで終わった後、いったんリリースしていました。アイテム(ツリーのノード)を削除する機能も必要な気がしていましたが、なぞの現象が起こっていたので、はじめは削除機能をつけずにリリースしていました。

なぞの現象というのは、こういう現象です。

root
├── item-1
├── item-2
└── item-3

上のようになっているとき、item-2を空欄にしてさらにバックスペースキーを押したときに、リストから削除する処理を追加していました。つまり、

onBackspace: function (item, index) {
    if (this.$refs['list' + this.list[index].id][0].innerHTML != '') return;
    this.list.splice(index, 1);
}

のように書いていたわけです。しかし、こうすると、削除した後に、ツリーが

root
├── item-1
└── 

となってしまい、item-2が削除されるだけでなく、item-3のクリアも実行されてしまいました。なぜこのようなことが起こるのか、しばらくわからなかったのですが、よく考えるとこれは当然の結果でした。

item-2を空欄してさらにバックスペースを押したとき、上に書いた処理onBackspaceが実行され、item-2がリストから削除されます。そのとき、リストの削除に伴って、divが再描画されます。そこで、もともとitem-2にあったフォーカスが外れるので、blurイベントが発火されてしまうんですね。

onBlurでは、

onBlur: function (e, item, index) {
    let newItem = this.list[index];
    newItem.value = e.target.innerText;
    this.$set(this.list, index, newItem);
}

と書いており、このイベントはitem-2に対して発火されたので、e.target.innerTextには、DOMに残っているitem-2の内容、つまり、空欄が設定されるんですね。その結果、本当はitem-3の内容を表示したかったのに、削除前の内容で上書きされてしまっていたのでした。

これに対処するのは難しそうだと思っていたのですが、実は対処法はシンプルでした。リストから要素を削除してから描画するとblurイベントが起こってしまって困るのだから、要素の削除の前に、blurイベントを強制的に起こしてしまえばいいんですね。要素をリストから削除する前に

this.$refs['list' + this.list[index - 1].id][0].focus();

を実行して、blurイベントを起こすことで対応しました。

イベントが起こるとき、どのイベントがどういう順番で発生していくのか、あまりイメージできていなかったので、こうしたところでも詰まってしまいました。

おわりに

作る前は、「すぐできるだろ」と思っていましたが、やはり予想と違ってだいぶ大変でした。仕事後に作りつつ、1週間くらいかかりました。でも、かなり理解が深まったのと、実際に自分で使いたいものができたので満足です。

mio シーアンドアール研究所 2018/05/29
(8809文字)