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.