サイトトップ

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

HTML5テクニカルノート

Vue.js + ES6入門 09: アプリケーションをコンポーネントに分ける


コンポーネントは、Vue.jsのコードとHTML要素を使い回しができる部品に切り分けたものです。「Vue.js + ES6入門 08: フィルタをボタンで切り替える」でつくったTodoリストのアプリケーション(サンプル001)から、3つの部分をコンポーネント化してみましょう。

01 ヘッダをコンポーネントに切り分ける

まずコンポーネントに切り分けるのは、項目件数やボタンを含んだリストのヘッダ部分です(図001)。

図001■ヘッダ部分をコンポーネントに切り分ける

図001

コンポーネントはVue.component()メソッドでつくります。第1引数がコンポーネント名の文字列、第2引数はコンポーネントオプションをプロパティに定めたオブジェクトです。なお、コンポーネント名はテンプレートに差し込む要素に用いますので、キャメルケースでなくケバブケースで定めてください(「ファイル名やスタイル名などで複数語を連結する方法(キャメルケース、スネークケース、ケバブケース)」参照)。


Vue.component(コンポーネント名, オプションオブジェクト);

コンポーネントのHTML記述は、第2引数のオブジェクトにtemplateオプションとして文字列で与えます。以下のように、バックティック(``)でテンプレート文字列にすれば、HTMLの記述と同じように半角スペースやタブ、改行などのホワイトスペースで整えられます。

<body>要素

<div id="app" class="container">

	<!-- <p>
		全{{todos.length}}件中残り{{remaining}}件
		<button  @click="archive" class="btn btn-danger btn-sm">断捨離</button>
	</p> -->
	<list-header></list-header>

</div>

<script>要素

Vue.component('list-header', {
	template: `
		<p>
			全{{todos.length}}件中残り{{remaining}}件
			<button  @click="archive" class="btn btn-danger btn-sm">断捨離</button>
		</p>
	`
});

もっとも、親から子コンポーネントにテンプレートを移しただけでは足りません。親のデータ(todos)を子が勝手に参照することは許されないからです。ブラウザのコンソールには、プロパティが定義されていないというエラーが示されます。

ReferenceError: todos is not defined

<script>要素

const app = new Vue({
	data: {

		todos: [
			{text: 'Vue.jsを学ぶ', done: true},
			{text: 'Vue.jsでアプリケーションをつくる', done: false},
		],

	},

}

02 親から子コンポーネントにプロパティを渡す

コンポーネントに分けたとき、大事なのがデータのやり取りです。コンポーネントが使い回せるためには、互いにできるだけ切り離されていなければなりません。データの受け渡しは、決められた約束にしたがう必要があるのです。

子コンポーネントに参照させるプロパティ(todos)は、親のテンプレートにv-bind(省略記法:)ディレクティブで属性としてバインドしなければなりません。

<body>要素

<div id="app" class="container">

	<list-header :todos="todos"></list-header>

</div>

子のコンポーネント(list-header)は受け取るプロパティ名(todos)を、Vue.component()メソッド第2引数のオブジェクトに、propsオプションの配列要素として文字列で加えます(「プロパティを使用した子コンポーネントへのデータの受け渡し」参照)。これで、親から子コンポーネントに、プロパティがひとつ受け渡せました。

<script>要素

Vue.component('list-header', {
	props: ['todos'],

});


けれど、また新たなエラーが示されるでしょう。今度は、算出プロパティ(remaining)です。これも子コンポーネント(list-header)が親から受け取らなければなりません。

ReferenceError: remaining is not defined

<script>要素

Vue.component('list-header', {

	template: `
		<p>
			全{{todos.length}}件中残り{{remaining}}件

		</p>
	`
});
const app = new Vue({

	computed: {
		remaining() {

			return count;
		},

	},

}

受け渡し方は、前述のプロパティ(todos)と同じです。親のテンプレートにv-bind(:)でバインドし、子コンポーネントはオプションオブジェクトのprop配列にプロパティ名(remaining)を要素として加えます。

<body>要素

<div id="app" class="container">

	<list-header :todos="todos" :remaining="remaining"></list-header>

</div>

<script>要素

Vue.component('list-header', {
	props: ['todos', 'remaining'],

});

親から子コンポーネントへのプロパティの受け渡しは、これでできました。けれど、また新たなエラーが示されるでしょう。子コンポーネントは親のメソッド(archive())を直に呼び出すことが許されないからです。

ReferenceError: archive is not defined

<script>要素

Vue.component('list-header', {

	template: `
		<p>

			<button  @click="archive" class="btn btn-danger btn-sm">断捨離</button>
		</p>
	`
});
const app = new Vue({

	methods: {

		archive() {
			this.todos = this.todos.filter((todo) => !todo.done);
		},

	}
});

03 子コンポーネントから親にイベントを送る

子コンポーネントから親のメソッドは呼び出せません。ただし、イベントが送れます。つまり、メソッドそのものは子コンポーネントに加え、メソッドから親にイベントを送ればよいのです。親は子コンポーネントのテンプレートに加えたv-on(省略記法@)ディレクティブでイベントが受け取れます。すると、ディレクティブに定めたメソッドが呼び出されるという仕組みです(「子コンポーネントのイベントを購読する」参照)。

子コンポーネントから親へイベントを送るのはvm.$emit()メソッドです。引数の文字列がイベントとして親に渡されます。メソッドはthisを参照して呼び出すことにご注意ください。


// 子コンポーネント
this.$emit(イベント);

// 親アプリケーション
<コンポーネントセレクタ v-on:イベント="メソッド"></コンポーネントセレクタ>

子コンポーネント(list-header)は、新たに加える親と同名のメソッド(archive())から、つぎのようにvm.$emit()メソッドで親にイベント(archive)を送ります。そして、親のテンプレートはv-on(@)ディレクティブにより受け取ったイベントで、自らのメソッド(archive())を呼び出せばよいのです。

<script>要素

Vue.component('list-header', {

	methods: {
		archive() {
			this.$emit('archive');
		}
	},

});

<body>要素

<div id="app" class="container">

    <list-header

        @archive="archive"
    >
    </list-header>

</div>

これで、アプリケーションはもとどおりに動きます。ここまでの子コンポーネント(list-header)のJavaScriptコードと親のテンプレートのHTML記述を、つぎのコード001にまとめます。

コード001■ヘッダコンポーネントと親のテンプレート

<script>要素

Vue.component('list-header', {
	props: ['todos', 'remaining'],
	methods: {
		archive() {
			this.$emit('archive');
		}
	},
	template: `
		<p>
			全{{todos.length}}件中残り{{remaining}}件
			<button  @click="archive" class="btn btn-danger btn-sm">断捨離</button>
		</p>
	`
});

<body>要素

<div id="app" class="container">
	<h2>Todo</h2>
	<list-header
		:todos="todos"
		:remaining="remaining"
		@archive="archive"
	>
	</list-header>
	<ul class="list-unstyled">
		<li v-for="todo in filteredTodos">
			<label>
				<input type="checkbox" v-model="todo.done">
				<span :class="{'done': todo.done}">{{todo.text}}</span>
			</label>
			<button @click="removeTodo(todo)" class="btn btn-warning btn-sm">削除</button>
		</li>
	</ul>
	<p>
		<input type="text" v-model="todoText" placeholder="add new todo here">
		<button @click="addTodo" class="btn btn-primary btn-sm">追加</button>
	</p>
	<button type="button"
		v-for="(value, key) in filters"
		:class="['btn btn-outline-info btn-sm mr-1', {active: visibility === key}]"
		@click="changeFilter(key)"
	>
		{{ key[0].toUpperCase() + key.substr(1) }}
	</button>
</div>

04 リスト項目をコンポーネントにする

つぎにコンポーネントに切り分けるのは、リスト表示されるTodo項目です(図002)。

図002■Todo項目をコンポーネントに切り分ける

図002

考え方は前項までのヘッダと同じです。まず、親のテンプレートは、つぎのように書き替えます。子コンポーネント(todo-item)が使うプロパティ(todo)はv-bind(:)で渡し、子から送られるイベント(remove-todo)はv-on(@)で受けてメソッド(removeTodo())を呼び出します。

<body>要素

<div id="app" class="container">

	<ul class="list-unstyled">
		<li v-for="todo in filteredTodos">
			<!-- <label>
				<input type="checkbox" v-model="todo.done">
				<span :class="{'done': todo.done}">{{todo.text}}</span>
			</label>
			<button class="btn btn-warning btn-sm" @click="removeTodo(todo)">削除</button> -->
			<todo-item :todo="todo" @remove-todo="removeTodo">
			</todo-item>
		</li>
	</ul>

</div>

子コンポーネント(todo-item)が親から受け取るプロパティ(todo)は、propsオプションの配列に加えてください。また、親にvm.$emit()でイベント(remove-todo)を送ることによって、親のメソッドを呼び出します。

ただし、削除ボタンで項目を除くには、その項目のオブジェクト(todo)も親に渡さなければなりません。そのときは、つぎのコード002のようにvm.$emit()メソッドの第2引数として渡せます。すると、親から呼び出される前掲v-on(@)のハンドラメソッド(removeTodo())が引数に受け取れるのです。また、テンプレートのルートはひとつでないといけないので、<div>要素にまとめました。

コード002■Todo項目コンポーネント

<script>要素

Vue.component('todo-item', {
	props: ['todo'],
	methods: {
		removeTodo: function(todo) {
  			this.$emit('remove-todo', todo);
		}
	},
	template: `
		<div>
			<label>
				<input type="checkbox" v-model="todo.done">
				<span :class="{'done': todo.done}">{{todo.text}}</span>
			</label>
			<button class="btn btn-warning btn-sm" @click="removeTodo(todo)">削除</button>
		</div>
	`
});

05 フィルタボタンをフッタコンポーネントに切り分ける

最後に、フッタ部分のフィルタボタンを、コンポーネントに切り分けます(図003)。手順は、前のふたつのコンポーネントと同じです。

図003■フィルタボタンのフッタをコンポーネントに切り分ける

図003

親のテンプレートは、つぎのようにフッタのコンポーネント(list-footer)に差し替えます。子コンポーネントが参照するデータ(visibility)はv-bind(:)でバインドし、子から送られるイベント(change-filter)はv-on(@)で受け取って、メソッド(changeFilter())を呼び出してください。

<body>要素

<div id="app" class="container">

	<!-- <button type="button"
		v-for="(value, key) in filters"
		:class="['btn btn-outline-info btn-sm mr-1', {active: visibility === key}]"
		@click="changeFilter(key)"
	>
		{{ key[0].toUpperCase() + key.substr(1) }}
	</button> -->
	<list-footer :visibility="visibility" @change-filter="changeFilter">
</div>

フッタコンポーネント(list-footer)の定めは、つぎのJavaScriptコードのとおりです。親から渡されたプロパティ(visibility)はpropsオプションの配列に加え、親のメソッドはイベント(change-filter)を送って呼び出します。メソッドの第2引数(key)は、親のメソッドに渡す値です。フィルタのボタン(<button>)は3つありますので、ひとつのルート(<div>)にまとめることを忘れないでください。

<script>要素

Vue.component('list-footer', {
	props: ['visibility'],
	methods: {
		changeFilter(key) {
			this.$emit('change-filter', key);
		}
	},
	template: `
	<div>
		<button type="button"
			v-for="(value, key) in filters"
			:class="['btn btn-outline-info btn-sm mr-1', {active: visibility === key}]"
			@click="changeFilter(key)">
			{{ key[0].toUpperCase() + key.substr(1) }}
		</button>
	</div>
	`
});

これで、3つのコンポーネントが切り分けられました。HTMLの記述とJavaScriptコードは、それぞれつぎにまとめたコード003のとおりです。併せて、サンプル001をCodePenに掲げます。

コード003■3つのコンポーネントを切り分けたHTMLとJavaScriptの記述全体

<body>要素

<div id="app" class="container">
	<h2>Todo</h2>
	<list-header
		:todos="todos"
		:remaining="remaining"
		@archive="archive"
	>
	</list-header>
	<ul class="list-unstyled">
		<li v-for="todo in filteredTodos">
			<todo-item :todo="todo" @remove-todo="removeTodo">
			</todo-item>
		</li>
	</ul>
	<p>
		<input type="text" v-model="todoText" placeholder="add new todo here">
		<button class="btn btn-primary btn-sm" @click="addTodo">追加</button>
	</p>
	<list-footer :visibility="visibility" @change-filter="changeFilter">
	</list-footer>
</div>

<script>要素

const filters = {
	all(todos) {
		return todos;
	},
	active(todos) {
		return todos.filter((todo) =>
			!todo.done
		);
	},
	completed(todos) {
		return todos.filter((todo) =>
			todo.done
		);
	}
};
Vue.component('list-header', {
	props: ['todos', 'remaining'],
	methods: {
		archive() {
			this.$emit('archive');
		}
	},
	template: `
		<p>
			全{{todos.length}}件中残り{{remaining}}件
			<button class="btn btn-danger btn-sm"  @click="archive">断捨離</button>
		</p>
	`
});
Vue.component('todo-item', {
	props: ['todo'],
	methods: {
		removeTodo(todo) {
			this.$emit('remove-todo', todo);
		}
	},
	template: `
		<div>
			<label>
				<input type="checkbox" v-model="todo.done">
				<span :class="{'done': todo.done}">{{todo.text}}</span>
			</label>
			<button class="btn btn-warning btn-sm" @click="removeTodo(todo)">削除</button>
		</div>
	`
});
Vue.component('list-footer', {
	props: ['visibility'],
	methods: {
		changeFilter(key) {
			this.$emit('change-filter', key);
		}
	},
	template: `
	<div>
		<button type="button"
			v-for="(value, key) in filters"
			:class="['btn btn-outline-info btn-sm mr-1', {active: visibility === key}]"
			@click="changeFilter(key)">
			{{ key[0].toUpperCase() + key.substr(1) }}
		</button>
	</div>
	`
});
const app = new Vue({
	data: {
		todoText: '',
		todos: [
			{text: 'Vue.jsを学ぶ', done: true},
			{text: 'Vue.jsでアプリケーションをつくる', done: false},
		],
		visibility: 'all'
	},
	computed: {
		remaining() {
			const count =
				this.todos.reduce((count, todo) =>
					count = (todo.done) ? count : ++count
				, 0);
			return count;
		},
		filteredTodos() {
			return filters[this.visibility](this.todos);
		}
	},
	methods: {
		addTodo() {
			const newTodo = this.todoText.trim();
			this.todoText = '';
			if (!newTodo) {return;}
			this.todos = [
				...this.todos,
				{text: newTodo, done: false}
			];
		},
		removeTodo(todo) {
			this.todos = this.todos.filter((_todo) => _todo !== todo);
		},
		archive() {
			this.todos = this.todos.filter((todo) => !todo.done);
		},
		changeFilter(visibility) {
			this.visibility = visibility;
		}
	}
});
document.addEventListener('DOMContentLoaded', () =>
	app.$mount('#app')
);

サンプル001■Vue.js + ES6: Separating components from an application

See the Pen Vue.js + ES6: Separating components from an application by Fumio Nonaka (@FumioNonaka) on CodePen.

Vue.js + ES6


作成者: 野中文雄
作成日: 2019年7月16日


Copyright © 2001-2019 Fumio Nonaka.  All rights reserved.