サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Vue.js + ES6: 再帰的なコンポーネントでツリー表示をつくる


再帰的なコンポーネント」は、テンプレートに自分自身を入れ子にして読み込む仕組みです。基本となる構造を階層化して表現できます。公式サイトの「例」に掲げられた 「ツリー表示の例」は、コンポーネントを再帰的に用いたサンプルです。入れ子にしたデータを、ツリー構造でリスト表示してみましょう(図001)。なお、JavaScriptコードの構文は、ECMAScript 2015 (ECMAScript 6)を用います。

図001■ツリー構造で表示したリスト

図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」参照)。そこで、インデックスをその値に用いました。なお、:classv-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 type="text/x-template" id="item-template">
<li>
	<div
		:class="{bold: isFolder}">

		<span v-if="isFolder">[{{isOpen ? '-' : '+'}}]</span>
	</div>

</li>
</script>

<script>要素

Vue.component('tree-item', {

	data() {
		return {
			isOpen: true
		};
	},

});

これで、子のデータをもつ項目に印がつきます(図002)。開いているどうかのプロパティ(isOpen)はデフォルト値をtrueにしたので印ははじめ[-]です。

図002■子のデータをもつ項目に印がつく

図002

子のデータをもつ項目は、クリックで開け閉じできるようにします。項目の要素(<div>)にclickイベントリスナーを、v-on(省略記法@)ディレクティブで定めます。呼び出すのはコンポーネントに以下のように加えたメソッド(toggle())です。クリックするたびに、子の階層が開いているかどうかのプロパティ(isOpen)のブール値を反転します。すると、テンプレートのリスト(<ul>要素)の表示・非表示が、v-showディレクティブにより切り替わるのです。なお、項目が子のデータをもたなければ(isFolderfalse)、やはりv-ifディレクティブで表示されません。

コンポーネントテンプレート

<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>

<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>

<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)の配列に新たな項目がつくられて納められるという流れです。

コンポーネントテンプレート

<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>

<body>要素

<ul id="demo">
	<tree-item

		@add-item="addItem"
	></tree-item>
</ul>

<script>要素

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)の配列に新たな項目を加えるのです。

コンポーネントテンプレート

<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>

<body>要素

<ul id="demo">
	<tree-item

		@make-folder="makeFolder"

	></tree-item>
</ul>

<script>要素

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>

<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.