HTML5テクニカルノート
Vue.js + ES6入門 09: アプリケーションをコンポーネントに分ける
- ID: FN1907001
- Technique: HTML5 / ECMAScript 2015
- Library: Vue.js 2.6.10
コンポーネントは、Vue.jsのコードとHTML要素を使い回しができる部品に切り分けたものです。「Vue.js + ES6入門 08: フィルタをボタンで切り替える」でつくったTodoリストのアプリケーション(サンプル001)から、3つの部分をコンポーネント化してみましょう。
01 ヘッダをコンポーネントに切り分ける
まずコンポーネントに切り分けるのは、項目件数やボタンを含んだリストのヘッダ部分です(図001)。
図001■ヘッダ部分をコンポーネントに切り分ける
コンポーネントはVue.component()
メソッドでつくります。第1引数がコンポーネント名の文字列、第2引数はコンポーネントオプションをプロパティに定めたオブジェクトです。なお、コンポーネント名はテンプレートに差し込む要素に用いますので、キャメルケースでなくケバブケースで定めてください(「ファイル名やスタイル名などで複数語を連結する方法(キャメルケース、スネークケース、ケバブケース)」参照)。
Vue.component(コンポーネント名, オプションオブジェクト);
コンポーネントのHTML記述は、第2引数のオブジェクトにtemplate
オプションとして文字列で与えます。以下のように、バックティック(``
)でテンプレート文字列にすれば、HTMLの記述と同じように半角スペースやタブ、改行などのホワイトスペースで整えられます。
<body>要素<script>要素<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>
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>要素<script>要素<div id="app" class="container"> <list-header :todos="todos" :remaining="remaining"></list-header> </div>
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>要素<body>要素Vue.component('list-header', { methods: { archive() { this.$emit('archive'); } }, });
<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>
`
});
<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項目をコンポーネントに切り分ける
考え方は前項までのヘッダと同じです。まず、親のテンプレートは、つぎのように書き替えます。子コンポーネント(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■フィルタボタンのフッタをコンポーネントに切り分ける
親のテンプレートは、つぎのようにフッタのコンポーネント(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>
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
- Vue.js + ES6入門 01: Vue.jsを始める
- Vue.js + ES6入門 02: 要素のclass属性を動的に変える
- Vue.js + ES6入門 03: データから動的にリストをつくる
- Vue.js + ES6入門 04: フィールドに入力したテキストを動的に項目として加える
- Vue.js + ES6入門 05: 項目を数えて表示する
- Vue.js + ES6入門 06: 項目を調べてデータから削除する
- Vue.js + ES6入門 07: 表示する項目をフィルタで切り替える
- Vue.js + ES6入門 08: フィルタをボタンで切り替える
- Vue.js + ES6入門 09: アプリケーションをコンポーネントに分ける
- Vue.js + ES6入門 10: コンポーネントの応用とデータのチェック
- Vue.js + ES6入門 11: データチェックの応用とkey属性
- Vue.js + ES6入門 12: ローカルコンポーネントを定める
作成者: 野中文雄
作成日: 2019年7月16日
Copyright © 2001-2019 Fumio Nonaka. All rights reserved.