みなさん、お待たせしました。コスパエンジニアのyoshiharu2580です。
フロントエンド業務に就いてもうすぐ半年が経ちます。
まだまだ未熟者ですがこのあたりで少し初心に返るために、下のTrelloのようなタスク管理ツールを作成するチュートリアルを作りました。
詳細な説明は公式ドキュメントに譲るとして、この記事ではとりあえず動かすことを目標とします。
Vue.js, TypeScriptが初めての方でも、なんとなく理解できましたら幸いです。
対象読者
- Vue.jsを触ったことがない人
- TypeScriptを触ったことがない人
- HTML, CSS, JavaScriptが少しわかる人
初期セットアップ
vue-cliで環境を構築します。(公式ドキュメント)
※2019.11.28時点でv 4.0.5
$ yarn global add @vue/cli
$ vue create my-project
# 各項目の、:の後の内容を選択してください。
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, CSS Pre-processors, Lin
ter
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfi
lls, transpiling JSX)? Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported
by default): Sass/SCSS (with node-sass)
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save, Lint and fix on commit
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedica
ted config files
プロジェクトが作成できたら起動してみましょう。
$ cd trello-clone
$ yarn serve
画面が表示されました。とても簡単ですね。
ここでVue.jsとTypeScriptの簡単な概要を説明します。
Vue.jsの基本は、JavaScriptのデータとDOMを紐づけるだけのフレームワークです。
JavaScriptのリアクティブなデータが更新されると、それにあわせてDOMが自動的に更新されます。
まずは見た目を変えるためにデータを変えるということだけ覚えておきましょう。
TypeScriptは型があるJavaScriptです。
TypeScriptを導入することで、開発をわかりやすく安全にし、結果的に開発スピードを上げることができます。
さて、本題に戻ります。
初期セットアップとしてHello.vue
を削除し、App.vue
を以下のようにします。
<template>
<div id="app" />
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
@Component
export default class App extends Vue {}
</script>
まっさらになりましたね。それでは実際に作っていきましょう。
#型定義とリストレンダリング
ここではタスクを表示するものをカード、カードを束ねるものをリストと呼ぶことにします。
今回のアプリの要件は以下の通りです。
- リスト、カードの追加機能
- リスト名、カードのテキストの編集機能
- リスト、カードの削除機能
- リスト、カードの移動機能
機能を持つUIの部品のことをコンポーネントといいます。
Vue.jsなどのコンポーネントベースのライブラリでは、このコンポーネントを組み合わせてUIを作っていきます。
コンポーネントの設計では、まずデータの型の考察から始めます。
要件を満たすには、リスト、カードのデータ型はそれぞれ以下のようになります。
- リスト
- 自身を特定するためにidを持つ
- リスト名を持つ
- カードの配列を持つ
- カード
- 自身を特定するためにidを持つ
- カードのテキストを持つ
src
ディレクトリ配下にtypes.ts
というファイルを作成し、TypeScriptで型を書いてみましょう。
オブジェクトの型定義には基本interface
を使います。
コンポーネント名をそれぞれCard
, List
とするので、ここではバッティングを防ぐためにオブジェクトの型にプレフィックスとしてinterface
の頭文字Iを付けることにします。
export interface IList {
/*
・idは途中で変えないので、readonly修飾子を付ける(値を変えるとエラーが発生)
・数値なのでnumber型
*/
readonly id: number;
name: string; // 文字列なのでstring型
cards: ICard[]; // 配列を定義するには 要素[] とする
}
export interface ICard {
readonly id: number;
text: string;
}
モックデータを返すファクトリ関数を別ファイルに定義します。
関数に型をつける際はかっこの後に: 型名
と書きます。
これで戻り値の構造が型と異なる場合にコンパイルエラーが発生します。安全ですね。
import { IList } from "@/types";
export function createInitialLists(): IList[] {
return [
{
id: 1,
name: "リスト1",
cards: [
{
id: 1,
text: "タスク1"
},
{
id: 2,
text: "タスク2"
}
]
},
{
id: 2,
name: "リスト2",
cards: [
{
id: 3,
text: "タスク3"
},
{
id: 4,
text: "タスク4"
}
]
}
];
}
App.vue
のような、拡張子が.vue
のファイルをSFC(単一ファイルコンポーネント)といい、この中でコンポーネントを定義します。
template
タグ内にHTML、script
タグ内にJavaScript、style
タグ内にcssを記述します。
script
タグの属性にlang="ts"
とすることでTypeScriptが使えるようになります。
Vueのリアクティブなデータの中で最も基本的なものがdata
です。
クラスのプロパティを定義することでdata
を登録することができます。
先ほど作成したcreateInitialLists
を実行した返り値をlists
に代入しましょう。
data
には型を付けなくてもいいですが、付けることをおすすめします。
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
+ import { IList } from "@/types";
+ import { createInitialLists } from "@/initialData.ts";
@Component
export default class App extends Vue {
+ lists: IList[] = createInitialLists();
}
</script>
これでlists
というdata
にモックデータを設定することができました。
次に、カードのコンポーネント名をCard
、リストのコンポーネント名をList
として新しく作成しましょう。
こちらをcomponents
ディレクトリ配下に以下の内容であらかじめ作成しておきます。
<template>
<div />
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
@Component
export default class List extends Vue {}
</script>
<template>
<div />
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
@Component
export default class Card extends Vue {}
</script>
作成したList
コンポーネントをApp.vue
で使えるようにしましょう。
@Component
デコレータのcomponents
オプションにList
を登録すると、template
内でList
というカスタム要素として使用することができます。
リストレンダリング(配列の数だけDOM要素を描画)をするにはv-for
を使います。
リストレンダリングしたい要素の属性にv-for="要素 in 配列"
を追加します。
こうすることで、配列の要素を属性, テキスト, 子要素のそれぞれで使うことができるようになります。
ここではv-for="list in lists"
としましょう。
v-for
とセットでkey
属性も追加します。
:key="一意な値"
とすることでレンダリングを最適化することができます。
ここでは:key="list.id"
として、属性値にリストのidを与えましょう。
:属性名(任意)="値"
をコンポーネントの属性に追加することで、渡された子コンポーネントではその属性名で値を受け取れるようになります。
属性値でJavaScriptの式を使うにはv-bind
ディレクティブ(省略記法は:
)を使います。
List
コンポーネントでlist
のデータを使うために、属性名をわかりやすく値の変数名と同じlist
にして、値にlist
を代入しましょう。
<template>
<div id="app">
+ <List v-for="list in lists" :key="list.id" :list=list />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
+ import List from "@/components/List.vue";
import { IList } from "@/types";
import { createInitialLists } from "@/initialData.ts";
+ @Component({
+ components: {
+ List
+ }
+ })
export default class App extends Vue {
lists: IList[] = createInitialLists();
}
</script>
List.vue
を見ていきましょう。
子コンポーネント内で親から渡されたデータ(これをprops
といいます)を受け取るには、@Prop
デコレータを使って定義します。
@Prop
デコレータの引数にはオプションのオブジェクトを渡すことができます。
そのオプションのtype
プロパティではざっくりとした型を付与することができます。
list
はオブジェクトなのでObject
を指定しましょう。他にはこんな値があります。
required
プロパティは、そのpropsが必須かどうかを指定します。
required: trueにしてpropsが渡されなかった場合はエラーが発生します。 できる限り
required: true`にしましょう。
App.vueでlist
として渡したので、受け取る際の変数名はlist
にします。
変数名の後には型を付与することができます。
<template>
<div>
+ {{ list.name }} <!-- JSの式を二重中括弧で囲うとテキスト展開される(マスタッシュ構文) -->
</div>
</template>
<script lang="ts">
+ import { Component, Vue, Prop } from "vue-property-decorator"; // Propを追加
+ import { IList } from "@/types";
@Component
export default class List extends Vue {
+ @Prop({ type: Object, required: true })
+ list!: IList;
}
</script>
リスト名が表示されました。
list
の後に!
が付いていますね。
これはTypeScriptのNon-null assertion operatorというものです。
まずunion型について説明します。
union型とは複数の型の可能性があるということを表します。
型と型を|
で繋ぐように書き、例えば、string | number
は文字列型か数値型の可能性があるということです。
props
は渡されない可能性があるので、undefined
の可能性があります。
これをオプショナルなプロパティといい、undefined
とのunion型と推論されます。
オプショナルなプロパティがundefined
ではないことを表現するためにNon-null assertion operatorを使います。
@Prop
デコレータのオプションで{ required: true }
としており、親からデータが渡されないとエラーが発生することが担保されているので、!
を変数名の後ろに付けます。
同様にList.vue内でもCard
をリストレンダリングしましょう。
<template>
<div>
{{ list.name }}
+ <Card v-for="card in list.cards" :key="card.id" :card="card" />
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
+ import Card from "@/components/Card.vue";
import { IList } from "@/types";
+ @Component({
+ components: {
+ Card
+ }
+ })
export default class List extends Vue {
@Prop({ type: Object, required: true })
list!: IList;
}
</script>
あわせてCard.vueも変更します。
<template>
+ {{ card.text }}
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
+ import { ICard } from "@/types";
@Component
export default class Card extends Vue {
+ @Prop({ type: Object, required: true })
+ card!: ICard;
}
</script>
リストレンダリングができました。
cssを使ってわかりやすくしましょう。(cssに関しては、各章の終わりの「この時点でのコミット」を参考にしてください。)
この時点でのコミット
#リストとカードの追加機能
次はリストの追加機能です。
フォームにリスト名を入力してEnterキーを押したら、新しくリストが追加されるようにしましょう。
ブラウザでは、ユーザーがDOM要素に対してクリックなどのアクションを起こした際などにイベントが発生します。
このイベントが発生した時に関数を呼び出すことができます。呼び出す側のことをイベントリスナ、呼び出される関数のことを特にイベントハンドラといいます(とします)。
これらを実際にDOM要素に登録するには、v-on
ディレクティブ(省略記法は@
)を使って`@イベント名="イベントハンドラ"とします。
これで、登録したDOM要素でそのイベントが発生したときにイベントハンドラが呼び出されます。
なので、イベントハンドラ内でdata
を更新する処理を書けば、
- ユーザーがアクションを起こすとイベントが発生し、イベントハンドラが呼び出される
- イベントハンドラ内で
data
を更新する - dataが更新されるとVue.jsがDOMを更新する
という流れを作ることができます。
data
を変更する処理は、基本data
が存在するコンポーネント内で定義します。
lists
があるのはApp.vueなのでApp.vueのクラス内のメソッドに定義します。
テキスト入力の際に発生するイベントにはinputイベントとchangeイベントがあります。
inputイベントは入力する度に発生し、changeイベントはinput要素からフォーカスを外した際やEnterキーを押した際などに発生します。
ここではchange
イベントリスナを選択します。
change
イベントリスナにaddList
メソッドをイベントハンドラとして紐づけたものを、inputタグに登録しましょう。
<template>
<div id="app">
<template v-for="list in lists">
<div class="list-container" :key="list.id">
<List :list="list" />
</div>
</template>
+ <input type="text" @change="addList" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import List from "@/components/List.vue";
import { IList } from "@/types";
import { createInitialLists } from "@/initialData.ts";
@Component({
components: {
List
}
})
export default class App extends Vue {
lists: IList[] = createInitialLists();
// 値を返さない関数の返り値の型としてvoid型を付与
+ addList(): void {}
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
ここでリストの型についておさらいしましょう。リストの型は以下の通りです。
export interface IList {
readonly id: number;
name: string;
cards: ICard[];
}
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
これにより、新しくリストを追加するには、
- 作成する度に一意のidが付けられ、
- フォームに入力した値がリスト名になり、
- 空のカードの配列を持った、
新しいリストをlists
に追加すればいいことになります。
まずidです。idは、リストを作成した数+1とします。
この値は更新されるので、listCreatedCount
としてdataに登録します。
初期値としてcreateInitialLists()
の戻り値の配列の要素数である2を代入します。(厳密にはcreateInitialLists().length
などにした方がいいかもしれません。)
次にリスト名についてです。
イベントハンドラをDOM要素に登録した際に@change="addList"
と、addList
に引数を与えていないので、メソッドの第一引数にイベントオブジェクト(発生したイベントの詳細な情報が詰まったオブジェクト)が渡されます。(addList(event): void {}
)
このchangeイベントのイベントオブジェクトに型を付けましょう。
どのイベントにどの型をつければいいかは、TypeScriptの開発元であるMicrosoftのこちらのサイトに載っています。(直接型定義を見ても構いません。GlobalEventHandlersEventMap
型)
見てみると、
型名はEvent
ですね。この型はグローバルに登録されている(tsconfig.json
というTypeScriptの設定ファイルで設定している)ので、import
せずにそのまま使うことができます。
event
に型Event
を付与しましょう。(addList(event: Event }): void {}
)
このイベントオブジェクトの中に、フォームに入力した値が入っています。
イベントオブジェクトのcurrentTarget
プロパティが、イベントリスナが実際に登録されたDOM要素で、この中のvalue
プロパティが目当てのそれです。
しかし、Event
型にはcurrentTarget
プロパティがありません。
型Event
はload
イベントなど、DOM要素以外で発生するイベントの型でも使われているからです。
とりあえず、現状このEvent
型にはcurrentTarget
プロパティがないので、currentTarget: HTMLInputElement;
(HTMLInputElement
はinput要素の型)というプロパティをEvent
型に追加します。(新しく型を作ってもいいと思います。)
あるオブジェクトの型にプロパティを追加するには、そのプロパティを持つオブジェクト型を&
を使って繋げます。
このようにしてできた型をintersection型といいます。
(event: Event & { currentTarget: HTMLInputElement; }
)
それではここまでのコードを見てみましょう。
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import List from "@/components/List.vue";
import { IList } from "@/types";
import { createInitialLists } from "@/initialData";
@Component({
components: {
List
}
})
export default class App extends Vue {
lists: IList[] = createInitialLists();
+ listCreatedCount = 2;
+ addList(event: Event & { currentTarget: HTMLInputElement }): void {
+ const newList = {
+ id: this.listCreatedCount + 1,
+ name: event.currentTarget.value,
+ cards: []
+ };
+ this.lists.push(newList);
// listsに追加されたため、listCreatedCountをインクリメント
+ ++this.listCreatedCount;
// フォームの値をリセットするために空文字を代入
+ event.currentTarget.value = "";
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
リストを追加できました。
同様にカードを追加するためのコードを書いていきましょう。
List.vue
内にカードを追加するinput要素を追加し、input要素のchange
イベントリスナにaddCard
メソッドを登録しましょう。
<template>
<div class="list">
{{ list.name }}
<Card v-for="card in list.cards" :key="card.id" class="card" :card="card" />
+ <input type="text" @change="addCard" />
</div>
</template>
lists
はApp.vue
にあるので、カードを追加する処理はApp.vue
に書きます。
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import List from "@/components/List.vue";
import { IList } from "@/types";
import { createInitialLists } from "@/initialData";
@Component({
components: {
List
}
})
export default class App extends Vue {
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ addCard(): void {}
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
このままではList.vue内でaddCard
を定義していないため、エラーが発生してしまいます。
子コンポーネントで発生したイベントと親コンポーネントで定義したメソッドを紐付けるには、どのようにすればいいのでしょうか。
この場合には以下のようにします。
- 親コンポーネント内で、子コンポーネントのカスタム要素の属性に、独自で定義したカスタムイベントのイベントリスナにイベントハンドラを登録する。
- 子コンポーネント内のイベントハンドラで、そのカスタムイベントを発生(emit)させる。
以上のようにすることで、子コンポーネントで発生したイベントと親コンポーネントで定義したメソッドを紐付けることができます。
まず1から。
HTMLでは大文字は小文字に変換されてしまうため、カスタムイベントのイベントリスナ名は基本ケバブケースにします。
App.vue
でメソッド名をaddCard
としたので、カスタムイベント名をadd-card
としましょうか。(@add-card="addCard"
)
これで親コンポーネント側はOKです。
<template>
<div id="app">
<template v-for="list in lists">
<div class="list-container" :key="list.id">
+ <List :list="list" @add-card="addCard" />
</div>
</template>
<input type="text" @change="addList" />
</div>
</template>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
次に2について。
メソッド名をカスタムイベント名のキャメルケースにし、@Emit
デコレータを付与することで、そのカスタムイベントをemit
することができます。
カスタムイベント名がadd-card
なのでメソッド名をaddCard
とします。
<template>
<div class="list">
{{ list.name }}
<Card v-for="card in list.cards" :key="card.id" class="card" :card="card" />
+ <input type="text" @change="addCard" />
</div>
</template>
<script lang="ts">
+ import { Component, Vue, Prop, Emit } from "vue-property-decorator";
import Card from "@/components/Card.vue";
import { IList } from "@/types";
@Component({
components: {
Card
}
})
export default class List extends Vue {
@Prop({ type: Object, required: true })
list!: IList;
+ @Emit()
+ addCard(event: Event & { currentTarget: HTMLInputElement }): void {}
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
ここで大事なことがあります。
子コンポーネントでemitするメソッドの返り値が、親コンポーネントで受け取れるイベントオブジェクトになるということです。
これでカードを追加するために必要なデータを送ることができます。
カードを追加するために必要なデータは、追加するリストのidとカードのテキストです。
これをまずIAddCardEvent
として型定義しましょう。
型を定義して子コンポーネント側のメソッドの返り値の型、親コンポーネント側メソッドの引数の型として付与すれば、齟齬がなく型安全になります。
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<script lang="ts">
+ export interface IAddCardEvent {
+ listId: number;
+ text: string;
+ }
+ import { Component, Vue, Prop, Emit } from "vue-property-decorator";
import Card from "@/components/Card.vue";
import { IList } from "@/types";
@Component({
components: {
Card
}
})
export default class List extends Vue {
@Prop({ type: Object, required: true })
list!: IList;
@Emit()
+ addCard(event: Event & { currentTarget: HTMLInputElement }): IAddCardEvent {
// 次の処理でリセットしてしまうので変数に格納
+ const text = event.currentTarget.value;
// フォームの値をリセット
+ event.currentTarget.value = "";
// 返す内容が複数あるのでオブジェクトで返す
+ return {
+ listId: this.list.id,
+ text
+ };
}
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
この型をApp.vue
側のメソッドの第一引数に付与しましょう。
これで親コンポーネント側でも、このカスタムイベントのイベントオブジェクトの型がIAddCardEvent
であることが約束されました。
(実際にはここまでする必要は無いかもしれません。将来、わざわざ型定義しなくても推論されるようになるといいですね。)
また、
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import List from "@/components/List.vue";
import { IList } from "@/types";
import { createInitialLists } from "@/initialData";
+ import { IAddCardEvent } from "@/components/List.vue";
@Component({
components: {
List
}
})
export default class App extends Vue {
lists: IList[] = createInitialLists();
listCreatedCount = 2;
+ cardCreatedCount = 4;
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
+ addCard({ listId, text }: IAddCardEvent): void {
+ const list = this.lists.find(list => list.id === listId);
/*
findは見つからなかった場合undefinedを返す可能性があるので、その場合は早期リターンする
(ここではlist: IList | undefined)
*/
+ if (list === undefined) return;
+ const newCard = {
+ id: this.cardCreatedCount + 1,
+ text
+ };
// ここではlist: IList
+ list.cards.push(newCard);
+
+ ++this.cardCreatedCount;
+ }
}
</script>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
これでカードの追加ができるようになりました。
ここでカスタムイベントのおさらいをしておきましょう。
- ユーザーがイベントを発火
- そのイベントに紐づけられた子コンポーネントのイベントハンドラが呼び出される
- その子コンポーネントのイベントハンドラがカスタムイベントをemit(発火)する
- そのカスタムイベントに紐づけられた親コンポーネントのイベントハンドラが呼び出される
という処理の順番になります。