HTML5テクニカルノート
Vue.js + ES6: 再帰的なコンポーネントでツリー表示をつくる
- ID: FN1802008
- Technique: HTML5 / ECMAScript 2015
- Library: Vue.js 2.5.13
「再帰的なコンポーネント」は、テンプレートに自分自身を入れ子にして読み込む仕組みです。基本となる構造を階層化して表現できます。公式サイトの「例」に掲げられた 「ツリー表示の例」は、コンポーネントを再帰的に用いたサンプルです。入れ子にしたデータを、ツリー構造でリスト表示してみましょう(図001)。なお、JavaScriptコードの構文は、ECMAScript 2015 (ECMAScript 6)を用います。
図001■ツリー構造で表示したリスト
01 入れ子のデータをツリー表示する
データの基本単位は、ふたつのプロパティを備えるオブジェクトにします。プロパティは、名前のテキスト(name)と、入れ子データの配列(children)です。入れ子をまとめたデータはつぎのように定数(treeData)に納め、Vueアプリケーション(demo)に渡す引数オブジェクトのdataプロパティに加えます(treeData)。アプリケーションは、HTMLドキュメントが読み込み終わったとき(DOMContentLoadedイベント)、vm.$mount()メソッドでHTMLドキュメントの指定した要素(id属性demo)に関連づけられます。
<script>要素const treeData = { name: 'My Tree', children: [ {name: 'hello'}, {name: 'wat'}, { name: 'child folder', children: [ { name: 'child folder', children: [ {name: 'hello'}, {name: 'wat'} ] }, {name: 'hello'}, {name: 'wat'}, { name: 'child folder', children: [ {name: 'hello'}, {name: 'wat'} ] } ] } ] }; const demo = new Vue({ data: { treeData: treeData } }); document.addEventListener('DOMContentLoaded', (event) => demo.$mount('#demo') );
コンポーネントを登録するVue.component()メソッドには、第1引数のid(tree-item)のほか、第2引数のオプションオブジェクトにつぎのように3つのプロパティを加えます。templateに与えたのは、あとで定めるコンポーネントのテンプレートのid属性(item-template)です。propsにはバインディングされるObject型のプロパティ(item)、computedには算出プロパティ(isFolder())を加えました。算出プロパティは、子のデータ(children)をもつかどうかブール(論理)値で返します。
<script>要素Vue.component('tree-item', { template: '#item-template', props: { item: Object }, computed: { isFolder() { return this.item.children && this.item.children.length; } }, });
<body>要素には、つぎのようにリストの大枠をつくります。親の<ul>要素にアプリケーションが関連づけされるid属性(demo)を与えました。子の要素はコンポーネント(tree-item)です。定数(treeData)に納めたデータが、プロパティ(item)にバインディングされます。なお、ディレクティブ:は、v-bindの省略記法です。
<body>要素<ul id="demo"> <tree-item class="item" :item="treeData"> </tree-item> </ul>
コンポーネントのテンプレートは、以下のように<script>要素にtype属性をtext/x-templateとして<head>要素に書き加えてください。id属性は、前掲のVue.component()メソッドを呼び出すときオプションオブジェクトのtemplateに与えた値(item-template)です。バインディングされたプロパティ(item)のテキスト(name)を示し、子のデータ(children)の配列からオブジェクトを順に取り出します。ここでコンポーネント(tree-item)を再帰的に差し込み、v-forディレクティブで取り出したデータ(item)をバインディングすることにより、入れ子のツリー構造がつくられるのです。
v-forディレクティブで配列要素を取り出すとき、ふたつ目の引数(index)にインデックスが得られます(「v-forで配列に要素をマッピングする」参照)。v-forは項目に一意のkeyを与えなければなりません(「キー付きv-for」参照)。そこで、インデックスをその値に用いました。なお、:classはv-bind:classの省略記法で、値(算出プロパティisFolder)がtrueのときクラス(bold)を動的に割り当てます。
<head>要素<script type="text/x-template" id="item-template"> <li> <div :class="{bold: isFolder}"> {{item.name}} </div> <ul> <tree-item class="item" v-for="(child, index) in item.children" :key="index" :item="child"> </tree-item> </ul> </li> </script>
<style>要素にはつぎのコード001のとおり、簡単なCSSを定めました。
コード001■<style>要素に定めたCSS
<style>要素
body {
font-family: Menlo, Consolas, monospace;
color: #444;
}
.item {
cursor: pointer;
}
.bold {
font-weight: bold;
}
ul {
padding-left: 1em;
line-height: 1.5em;
list-style-type: dot;
}
02 ツリーの子を開け閉じする
ツリー表示の子の階層を、マウスクリックで開け閉じできるようにしましょう。先に、子のデータが含まれている項目(フォルダ)に印をつけます。あとで開け閉めしますので、それがわかるように閉じていれば[+]、開いていたら[-]です。開いているどうかのプロパティ(isOpen)は、コンポーネントのdataに以下のように加えます。子のあるなしは算出プロパティ(isFolder)で確かめて、表示・非表示を切り替えるのがv-ifディレクティブです。
コンポーネントテンプレート<script>要素<script type="text/x-template" id="item-template"> <li> <div :class="{bold: isFolder}"> <span v-if="isFolder">[{{isOpen ? '-' : '+'}}]</span> </div> </li> </script>Vue.component('tree-item', { data() { return { isOpen: true }; }, });
これで、子のデータをもつ項目に印がつきます(図002)。開いているどうかのプロパティ(isOpen)はデフォルト値をtrueにしたので印ははじめ[-]です。
図002■子のデータをもつ項目に印がつく
子のデータをもつ項目は、クリックで開け閉じできるようにします。項目の要素(<div>)にclickイベントリスナーを、v-on(省略記法@)ディレクティブで定めます。呼び出すのはコンポーネントに以下のように加えたメソッド(toggle())です。クリックするたびに、子の階層が開いているかどうかのプロパティ(isOpen)のブール値を反転します。すると、テンプレートのリスト(<ul>要素)の表示・非表示が、v-showディレクティブにより切り替わるのです。なお、項目が子のデータをもたなければ(isFolderがfalse)、やはりv-ifディレクティブで表示されません。
コンポーネントテンプレート<script>要素<script type="text/x-template" id="item-template"> <li> <div @click="toggle"> </div> <ul v-show="isOpen" v-if="isFolder"> <tree-item > </tree-item> </ul> </li> </script>Vue.component('tree-item', { methods: { toggle() { if (this.isFolder) { this.isOpen = !this.isOpen; } } } });
これで、子のデータをもつ項目には[+]/[-]の印がつき、マウスクリックで子の階層の開け閉じができるようになりました。ここまでのコンポーネントテンプレートとJavaScriptコードを、いったんつぎのコード002にまとめます。併せて、以下のサンプル001をCodePenに掲げましたので、詳しいコードやその動きはこちらでお確かめください。
コード002■ツリーの子を開け閉じする
コンポーネントテンプレート
<script type="text/x-template" id="item-template">
<li>
<div
:class="{bold: isFolder}"
@click="toggle">
{{item.name}}
<span v-if="isFolder">[{{isOpen ? '-' : '+'}}]</span>
</div>
<ul v-show="isOpen" v-if="isFolder">
<tree-item
class="item"
v-for="(child, index) in item.children"
:key="index"
:item="child">
</tree-item>
</ul>
</li>
</script>
const treeData = {
name: 'My Tree',
children: [
{name: 'hello'},
{name: 'wat'},
{
name: 'child folder',
children: [
{
name: 'child folder',
children: [
{name: 'hello'},
{name: 'wat'}
]
},
{name: 'hello'},
{name: 'wat'},
{
name: 'child folder',
children: [
{name: 'hello'},
{name: 'wat'}
]
}
]
}
]
};
Vue.component('tree-item', {
template: '#item-template',
props: {
item: Object
},
data() {
return {
isOpen: true
};
},
computed: {
isFolder() {
return this.item.children &&
this.item.children.length;
}
},
methods: {
toggle() {
if (this.isFolder) {
this.isOpen = !this.isOpen;
}
}
}
});
const demo = new Vue({
data: {
treeData: treeData
}
});
document.addEventListener('DOMContentLoaded', (event) =>
demo.$mount('#demo')
);
サンプル001■Vue.js + ES6: Tree view base
See the Pen Vue.js + ES6: Tree view base by Fumio Nonaka (@FumioNonaka) on CodePen.
03 項目とフォルダを加える
さらに、項目とフォルダが加えられるようにしましょう。まずは項目です。それぞれの階層の終わりに、つぎのように追加ボタン代わりのテキスト(+)を要素(<li>)に加えます。クリックしたとき(@clickディレクティブ)にハンドラから呼び出されるのは、親アプリケーションにイベントを送るvm.$emit()メソッドで、第1引数がイベント名、第2引数は渡す値です。親はテンプレートでv-on(省略記法@)によりバインディングしたイベントから、自らのメソッド(addItem())を呼び出します。すると、子のデータ(children)の配列に新たな項目がつくられて納められるという流れです。
コンポーネントテンプレート<body>要素<script type="text/x-template" id="item-template"> <li> <ul v-show="isOpen" v-if="isFolder"> <tree-item @add-item="$emit('add-item', $event)" ></tree-item> <li class="add" @click="$emit('add-item', item)">+</li> </ul> </li> </script><script>要素<ul id="demo"> <tree-item @add-item="addItem" ></tree-item> </ul>const demo = new Vue({ methods: { addItem(item) { item.children.push({ name: 'new stuff' }); } } });
フォルダは新たにつくるのではなく、項目をダブルクリックして変換しましょう。イベントリスナーは、つぎのようにv-on(@)にネイティブイベントdblclickを添えて定めます。以下のとおりコンポーネントに加えたのがリスナーメソッド(makeFolder())です。フォルダでないことを確かめたうえで、親アプリケーションに同じ名前のイベント(make-folder)をvm.$emit()メソッドで送ります。なお、メソッド名はキャメルケース、イベント名はケバブケースにしてください(「イベント名」参照)。
ここで気をつけなければならないのは、コンポーネント(tree-item)が入れ子になることです。したがって、親のテンプレートだけでなく、入れ子のコンポーネント(tree-item)にも、イベント(make-folder)をバインディングしなければなりません。そして、親アプリケーションのメソッド(addItem())が、子のデータ(children)の配列に新たな項目を加えるのです。
コンポーネントテンプレート<body>要素<script type="text/x-template" id="item-template"> <li> <div :class="{bold: isFolder}" @click="toggle" @dblclick="makeFolder"> </div> <ul v-show="isOpen" v-if="isFolder"> <tree-item @make-folder="$emit('make-folder', $event)" ></tree-item> </ul> </li> </script><script>要素<ul id="demo"> <tree-item @make-folder="makeFolder" ></tree-item> </ul>Vue.component('tree-item', { methods: { makeFolder() { if (!this.isFolder) { this.$emit('make-folder', this.item); this.isOpen = true; } }, } }); const demo = new Vue({ methods: { makeFolder(item) { Vue.set(item, 'children', []); this.addItem(item); }, } });
これで、追加記号(+)のクリックで項目が加わり、項目をダブルクリックすればフォルダに変わるようになりました。お題にした公式サイトの「ツリー表示の例」と同じ動きです。でき上がったコンポーネントテンプレートと<body>要素の記述、およびJavaScriptコードは、つぎのコード003にまとめました。また、以下のサンプル002をCodePenに掲げます。
コード003■項目とフォルダが加えられるツリー表示のインタフェース
コンポーネントテンプレート
<script type="text/x-template" id="item-template">
<li>
<div
:class="{bold: isFolder}"
@click="toggle"
@dblclick="makeFolder">
{{item.name}}
<span v-if="isFolder">[{{isOpen ? '-' : '+'}}]</span>
</div>
<ul v-show="isOpen" v-if="isFolder">
<tree-item
class="item"
v-for="(child, index) in item.children"
:key="index"
:item="child"
@make-folder="$emit('make-folder', $event)"
@add-item="$emit('add-item', $event)"
></tree-item>
<li class="add" @click="$emit('add-item', item)">+</li>
</ul>
</li>
</script>
const treeData = {
name: 'My Tree',
children: [
{name: 'hello'},
{name: 'wat'},
{
name: 'child folder',
children: [
{
name: 'child folder',
children: [
{name: 'hello'},
{name: 'wat'}
]
},
{name: 'hello'},
{name: 'wat'},
{
name: 'child folder',
children: [
{name: 'hello'},
{name: 'wat'}
]
}
]
}
]
};
Vue.component('tree-item', {
template: '#item-template',
props: {
item: Object
},
data() {
return {
isOpen: true
};
},
computed: {
isFolder() {
return this.item.children &&
this.item.children.length;
}
},
methods: {
toggle() {
if (this.isFolder) {
this.isOpen = !this.isOpen;
}
},
makeFolder() {
if (!this.isFolder) {
this.$emit('make-folder', this.item);
this.isOpen = true;
}
},
}
});
const demo = new Vue({
data: {
treeData: treeData
},
methods: {
makeFolder(item) {
Vue.set(item, 'children', []);
this.addItem(item);
},
addItem(item) {
item.children.push({
name: 'new stuff'
});
}
}
});
document.addEventListener('DOMContentLoaded', (event) =>
demo.$mount('#demo')
);
サンプル002■Vue.js + ES6: Tree view
See the Pen Vue.js + ES6: Tree view by Fumio Nonaka (@FumioNonaka) on CodePen.
作成者: 野中文雄
更新日: 2019年12月17日 公式サイト「ツリー表示の例」の改訂にもとづき、コードと本文説明を修正。サンプルはCodePenに差し替えた。
作成日: 2018年02月28日
Copyright © 2001-2019 Fumio Nonaka. All rights reserved.