概要
Vue.jsをTypeScript(+vue-class-component)で書いていく場合に本当に必要最小限な文法・考え方を説明した記事です。
教科書的な内容では公式のリファレンスが最も優れているので、この記事では自分が経験してきた内容をなるべく盛り込んで
現場で使える知識を説明することを目指しています
※ 本記事はBASE株式会社内で行ったフロントエンドハンズオンの資料の一部をまとめ直したものです
対象読者
- フロント・サーバーサイド問わず、エンジニアで新たにVue+TSでの開発をはじめた方
- すでにVue+TSで開発しているが、もう一度基礎をおさらいしておきたい方
触れない内容
- webpackの設定などビルドに関連する話
- Store(Vuex)の扱い方
- Nuxtなどの他のライブラリの話
最初に
Vueの思想
VueのSFC(Single File Component)は以下のような構成になっています
<template></template>
<script lang="ts"></script>
<style lang="scss" module></style>
他では見たことのない形だと思いますが、一つのファイルのなかにHTML/JS/CSSのすべてを書くことが出来ます。
そして、templateの中からscriptタグの中の変数やメソッドにアクセスが可能です
これは従来のjQueryなどを使ってWebを作る際の考え方から大きな変化をもたらします。
jQueryの時代はHTML(DOM)の管理をすべてエンジニアがJSで記述するようになっていましたが
Vueにおいては、エンジニアはデータの流れだけを管理し、それを画面に変換するルールをtemplateに書くという明確な役割分担があります
概念的には以下のような感じです。
const template = (script) => virtualDOM;
const virtualDOM = template(script)
document.innerHTML = virtualDOM
- scriptが画面に表示するデータを管理し
- template側はそのscriptの情報からHTMLを出力するルールを持ち
- 結果をレンダリングする
という流れは意識しておくといいかと思います。
Vue + TS
以下の例ではすべてvue-class-componentを使用してコードを書いています
vue-class-componentはVueをTSで快適に書くためのライブラリです。
Vueの公式はJSでサンプルが書かれていますが、vue-class-componentのREADMEを読むとJS->TSへの書き換え方が説明されているので併せてそちらも参照してください
テンプレートについて
まずvueにおけるテンプレートの話をします。
テンプレートとはSFCにおける、<template>
タグで囲まれた領域もしくは、new Vue()でJSからVueコンポーネントを作る際のオプションとして渡すtemplateフィールドを指します
new Vue({
template : '<div>hoge</div>' // <- これ
})
mustache記法
HTMLの地の文のところから、scriptのデータにアクセスするときに使う記法です。
mustache(マスタシュ) はヒゲという英単語で、中括弧が{{}}ヒゲっぽいところから付けられています
これはVue独自の用語ではなく多言語に対応した汎用テンプレートエンジンであるMustache由来のものです
<div>
{{data}}
</div>
{{}}で囲まれた領域はJSの式として評価されます
そして評価された値がそのまま挿入されます。
<span>{{1 + 1}}</span> // => <span>2</span>
filter
このmustacheや次のv-bindと組み合わせて使う便利な機能にfilterがあります
filterはデータを変換して文字列に変える、という単純な機能ではありますが、JS側のデータからUIへの意識を切り離す事ができます。
よくあるのは、金額を3桁区切りで表示したい、といった場合にデータとして3桁区切りの文字列として持ってしまうパターンです
{
price: 10000,
priceText: '10,000円'
}
金額という概念は一つしかないのに、それを2つにわけて持つことは常に二箇所のデータを意識してプログラムを書く必要が生まれ、
どんどんコードが汚れていってしまう原因になります。filterを使うとその辺がスッキリします
<div>税抜き:{{price | format}}</div>
<div>税込み:{{priceIncludingTax | format}}</div>
データの実体が一箇所にまとまっていて管理しやすいのが感じられるのではないかと思います
v-bind
vueで書かれたテンプレートの中にはv-
ではじまる属性がよく出てくると思います
これらは ディレクティブ と呼ばれ、普通のHTMLでは出来ない拡張をもたらします。
その中でおそらく一番書く回数が多いのがこのv-bindです。DOMの属性にJSの実行結果を割り当てます。
以下に公式のサンプル(の一部)を転載します
<!-- 属性を束縛 -->
<img v-bind:src="imageSrc">
<!-- 省略記法 -->
<img :src="imageSrc">
<!-- インライン文字列連結 -->
<img :src="'/path/to/images/' + fileName">
<!-- クラスバインディング -->
<div :class="{ red: isRed }"></div>
<div :class="[classA, classB]"></div>
<div :class="[classA, { classB: isB, classC: isC }]">
<!-- スタイルバインディング -->
<div :style="{ fontSize: size + 'px' }"></div>
<div :style="[styleObjectA, styleObjectB]"></div>
<!-- 属性のオブジェクトのバインディング -->
<div v-bind="{ id: someProp, 'other-attr': otherProp }"></div>
<!-- prop バインディング。"prop" は my-component 内で宣言される必要があります -->
<my-component :prop="someThing"></my-component>
いっぱい出てきてパニックになるかもしれませんが覚えないといけないことは
-
:
はv-bind:
の省略記法 :
で始まる属性の値(””で囲まれてる部分)はJSの式として評価される-
:hoge="1+1"
はhoge="2"
と等しい
という基本原則です。あとは書いていくとすぐに慣れます
v-on
v-bindと双璧を成す機能がこのv-onです。
これはDOMに対してイベントリスナーを簡単に記述ができるものです
v-bindの:
に対してv-onは@
と省略できるのでこちらを見ることのほうが多いと思います。
<template>
<div>
<button @click="increment">{{count}}回目</button>
<button @click="(e) => log(e)">ログにイベントを出力する</button>
</div>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import Vue from "vue"
@Component({
name: 'sampleContainer',
})
export default class sampleContainer extends Vue {
count = 0
increment() {
this.count++
}
log(e:Event) {
console.log(e)
}
}
</script>
例のごとく、""の中はJSとして実行されます。
"increment"
のように関数そのものを渡すこともできますし、
"(e) => log(e)"
のように、アロー関数を記述することも可能です。
関数そのものを指定した場合、イベントオブジェクトが自動的に第一引数として渡されます。
上記のコードの例では
<button @click="(e) => log(e)">
を <button @click="log">
と書いても全く同じ動作をします。
またイベント修飾子を使うことで、preventDefaultやstopPropagationなどの定番処理を簡単に実行できます
v-ifとv-for
これに関してはほかの言語などと同じなので特に詳しく触れることはしません。多分公式ドキュメントを一通り読んだほうがいいです。
かいつまんでいうと、v-ifでは要素の表示の有無をコントロールできます
v-forでは配列などのiterableなオブジェクトを繰り返し表示することが出来ます
またv-forではループのインデックスを受け取る書き方も可能です
<li v-for="(item, index) in items">
{{ index }} - {{ item.message }}
</li>
v-ifとv-show
v-ifと近いものとしてv-showというディレクティブがありますが、公式の解説を見て使い分けができるならしてください。
よくわからないならv-showは一旦忘れてv-ifで統一するのが無難だと思います
算出プロパティ
算出プロパティは結果が効率よくキャッシュされるメソッドのようなものです。
swiftやC#などのプロパティを触ったことがある方はそれをイメージしていただければそれです。
メソッドとは違って、普通のフィールドのように扱うことが出来ます。(参照時にカッコを付けて呼び出さない)
テンプレートに埋め込む値の変換などはこの算出プロパティを経由してから埋め込むとテンプレートの中にロジックがあまり染み出さずキレイに書くことが出来ます。
get computedMsg () {
return 'computed ' + this.msg
}
set computedMsg(newValue) {
this.msg = newValue
}
onClick() {
this.$emit('input', this.computedMsg)
}
v-model
フォームの値をバインディングするための便利機能としてv-modelがあります。
input要素などユーザーが値を入力するコンポーネントと一緒に利用すると、双方向バインディングが可能になります。
ただし、これを利用する場合にはこのリファレンスの記述を理解して、本当に必要なときだけ使うことを推奨します
例えば以下のコードはおかしいです
<input v-model="value" @change="changeValue">
このコードは↓のコードと等価です
<input :value="value
@change="value = $event"
@change="changeValue"
>
v-modelはv-bindとv-onを1行で短く書ける機能ですが、上のコードではchangeに対するイベントハンドリングが2つ書かれていることになります
実際の開発で多くの場面で、入力値をそのまま変数に代入するv-modelの機能では不十分で
v-onでカスタムしたイベントを使いたくなるので、そこまでv-modelは多用されるものでもないと思います
もしv-modelを使いたい場合はこの記事を参考にして、算出プロパティのsetを併用すると良いと思います
<input v-model="value">
<script>
innerValue = "" // _valueだとvueの仕様によりリアクティブにならないので注意
get value() {
return this.innerValue
}
set value(newValue:any) {
this.innerValue = newValue // もしくはeventのemitやバリデーション処理などもろもろ。
}
</script>
こちらはJSですが以前にv-modelの書き方の記事も別で書いたのでどうぞ
https://qiita.com/simezi9/items/c27d69f17d2d08722b3a
Vueのリアクティブについて
Vueではフィールドの変更は自動的に監視してUIに反映されますが、この仕様に一定の制約があります
オブジェクトの要素の追加を追尾できない(≠要素の変更) という仕様です。これらは次のメジャーバージョンアップであるVue3.0で解消される予定ですが、現在は気にしておく必要があります。
ArrayやObject({})やMap/Setといった内部にさらにネストしたデータを持つオブジェクトを利用する場合に問題になります。
Arrayはこちらを参考にして、Objectはこちらを参考にして対処可能です
Map/Setに関しては、一定のワークアラウンドが必要なので最初はあまり使わないほうが良いかもしれません。
コンポーネント
コンポーネントの思想
Vueに限らず、現代のフロントエンドのフレームワークは内部に閉じたコンポーネントを作りそれを組み合わせることを志向しています。
これはWeb Compontentsという技術の思想に影響されています。
普段我々が使うdiv
などのHTMLタグを独自定義できるようにしようという考え方です。
(youtubeやgoogle mapsはweb componentsを多用して作られています。開発者ツールでDOMを見てみるとカスタムタグがいろいろ見えて楽しいです)
HTMLタグとして機能をカプセル化することで、より高度で複雑なWebアプリを作っていくことが出来ます。
VueではそれをSFCによって実現しますが、特に念頭に入れておくべきはHTMLを拡張してコンポーネントを作っているという意識です。
作ったコンポーネントが標準のHTMLにあっても違和感がないか?と考えることは使いやすいコンポーネントを作る上で必ず役に立ちます。
VueのSFC
VueではSFCとしてコンポーネントを増やしていきます。そして作ったSFCは他のSFCの中から利用することが出来ます。
# こういう構造を作っていく
<parent-component>
<child-component>
<grandchild-component/>
</child-component>
</parent-component>
コンポーネントは閉じた世界になっているため、その内部に直接アクセスすることはできません。
そのため、作ったコンポーネントを利用するためにはプロパティとイベントを使って情報をやり取りします。
コンポーネントとイベントハンドリング
親コンポーネント側で、子コンポーネントから送られてくるイベントを受け取るには先程解説したv-onを使います
子コンポーネントがイベントを発火するには$emitを使います。
以下のように書きます
<template>
<div>
<button @click="onClick">how are you?</button>
</div>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import Vue from "vue"
@Component({
name: 'AppButton',
})
export default class AppButton extends Vue {
onClick(){
this.$emit("click", "how are you?") // イベントを送り出す
}
}
</script>
<template>
<div>
<app-button @click="log"></app-button>
</div>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import Vue from "vue"
import AppButton from './AppButton.vue';
@Component({
name: 'sampleContainer',
components: {AppButton}, // コンポーネント登録を忘れない。UpperCamelCase -> kebab-caseに変換されて、templateの中でタグとして使えるようになる。
})
export default class sampleContainer extends Vue {
log(e:Event) {
console.log(e)
}
}
</script>
this.$emit("click", "how are you?")
ですが、$emitは第一引数が送り出すイベント名です。このイベント名をv-onでキャッチすることが出来ます。
第二引数は送り出す値です。通常のHTMLではEventオブジェクトが送られてきますが、自作のコンポーネントではなんでも送れます。
イベント名もclickやchangeなど、標準のイベントに縛られず、どんなイベント名でも送り出すことができます。
ただし、基本的には標準のHTMLで用意されているイベントの名前にそろえておくほうが使いやすいと思います。
イベント名が難しくなってくる場合には:
で区切ったイベント名にするというVueの慣習があります。
<some-component @change:hoge="hoge" @change:fuga="fuga" />
this.$emit('change:hoge', hoge)
コンポーネントとプロパティ
イベントを使うことで子から親への情報伝達が可能になりましたが、次は親から子に情報を渡す方法です。
propsとよばれる機能を使います。
まず、子コンポーネント側にデータを受け取るためのインターフェイスを用意します
<template>
<input type="text" :value="value">
</template>
<script lang="ts">
import Component from 'vue-class-component'
import Vue from "vue"
@Component({
name: 'AppInput',
props: {
value: {
type:String,
required:true
}
}
})
export default class AppInput extends Vue {}
</script>
props
というフィールドが定義されているのがわかります。
ここの定義の仕方はいくつか書き方がありますが、そこは公式ドキュメントを参照してください。
propsで定義されたフィールドは、の中ではそのまま変数として利用できます。
scriptの中からはthis.$props.hoge
という形でアクセスが出来ます。
親コンポーネントからはこの定義したpropsにHTMLのattributeとして値を渡します
<template>
<div>
<app-input value="123"></app-input>
</div>
</template>
もちろんv-bindと組み合わせて:value="value"
のように書くことも出来ます。
propsの型がbooleanである場合には値を省略すると自動でtrueが渡ります
// 下の二行は意味として同じ
<app-input :required="true" />
<app-input required />
ここまでがpropsの基本です。
ただし上のコードではinputに入力した値が正しく動きません。
なぜならAppInputに対して入力したイベントが親コンポーネントに対して伝えられていないからです。
これを先程のemitを足してあげることでコンポーネントの動きを修正してみましょう
<template>
<input type="text" :value="value" @change="(e) => $emit('change', e.target.value)">
</template>
<script lang="ts">
import Component from 'vue-class-component'
import Vue from "vue"
@Component({
name: 'AppInput',
props: {
value: {
type:String,
required:true
}
}
})
export default class AppInput extends Vue {}
</script>
<template>
<div>
<app-input :value="value" @change="(val) => { value = val }"></app-input>
</div>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import Vue from "vue"
import AppInput from './AppInput.vue';
@Component({
name: 'sampleContainer',
components: {AppInput},
})
export default class sampleContainer extends Vue {
value = ''
}
</script>
<style lang="scss" module>
</style>
changeイベントの処理を追加することで、SampleContainerまで値を渡すことができるようになりました。
こうして出来上がったAppInput.vueは、propsにのみ動作を依存していて内部で状態を持っていない(= 副作用がない)ため可用性が高く使いやすいです。
このような外部から渡すプロパティのみで一意に描画されるようなコンポーネントを一部でpresentational componentと呼ぶこともあります。
それに対してSampleContainer.vueのようなハブになるコンポーネントをcontainer componentと呼びます
このpresentational componentとcontainer componentの2つを意識しながら設計するのが現時点でのベストプラクティスだと思います。
ここでは深く扱わないので気になる方は別で調べていただければと思います
コンポーネントとslot
先程のプロパティを使うことで、親から子に対して値を渡すことは可能になりました。
しかしこれだけだと、例えば標準のul/li
のような関係性をカスタムコンポーネントで作ることが出来ません。
そのために用意されているのがslotです。
slotを使うと、子コンポーネントの中の一部領域の描画を親から指定することが可能です。
<template>
<ul>
<slot></slot>
</ul>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import Vue from "vue"
@Component({
name: 'AppList',
})
export default class AppList extends Vue {
}
</script>
<style lang="scss" module>
</style>
<template>
<div>
<app-list>
<li>a</li>
<li>b</li>
<li>c</li>
</app-list>
</div>
</template>
<script lang="ts">
import Component from 'vue-class-component'
import Vue from "vue"
import AppInput from './AppInput.vue';
import AppList from './AppList.vue';
@Component({
name: 'sampleContainer',
components: {AppList, AppInput},
})
export default class sampleContainer extends Vue {
value = ''
}
</script>
<style lang="scss" module>
</style>
<app-list>
の中に入れ子にした3つのli
がslotの部分に描画されることがわかります。
名前つきslot
一つのコンポーネントの中に複数のslot領域を用意する場合には、slotに名前をつけることで配信することが出来ます
<ul>
<slot name="header"></slot>
<slot name="body"></slot>
</ul>
<app-list>
<template #header></template> # Vue2.6~の書き方。コチラだけで書いてください
<template slot="body"></template> # 以前の書き方。既存コードに一部残っているけど減らす方向で
</app-list>
名前付きslotはVue2.6で書き方が大きく変わったためネットのサンプルなども古いままのものが多いのでそこだけ気をつけてください。
ここで出てきたtemplateタグはタグのブロックを作るために用意された要素で、実際にはDOMとして描画されない要素です。
slotに限らず、複数の要素をまとめたいけどdivでブロックを入れたりしたくない、というときにどこでも使うことが出来ます
参考:v-forでのtemplate
※余談:VueのtemplateはHTMLの仕様を先取りして取り入れているものです
slotかpropsか
ここまで紹介したとおり、子コンポーネントに外部から情報を与える方法はslotとpropsの二通りあります。
どちらを使うか迷う場面も多いかと思いますが、とにかくHTMLとして自然なのはどちらかを意識するのがいいと思います
基本的に標準のHTMLはslotのような考え方で構成されています
// propsを多用するとこうなるが、HTMLはこうなってはいない
<div>
<some-heavy-component :props1="" :props2="" :props3="" :props4="" />
</div>
// slotを使うほうがHTMLっぽい感じになる
<div>
<some-list :value="">
<some-list-item>
<span>{{value}}</span>
</some-list-item>
</some-list>
</div>
もちろん時と場合によるので、そのあたりは書いていきながら感覚を身に着けていく感じだと思います。
またslotではslotから親にアクセスするための手段としてスコープ付きスロットが用意されています。
考え方が少しむずかしいのでここでは触れませんが、ある程度Vueに馴染んできたら一度公式などに目を通してみることをおすすめします
以前私も記事を書いていますのでよろしければどうぞ(slotの記法が少し古いですが考え方は今でも同じ)
https://qiita.com/simezi9/items/702e2bd066eec352ef72
インスタンスライフサイクル
各コンポーネントのインスタンスにはライフサイクルと呼ばれるものがあります。
そのライフサイクルで処理を書くことでコンポーネントの初期化、APIアクセス、リソースの開放などを実現することが出来ます。
iOSやAndroidのネイティブアプリを開発したことのある人にはおそらくお馴染みの概念ではないかと思います。
それなりに種類があってすべてを見るのは公式の画像を見るのが一番わかり易いです。
さしあたっては利用頻度が特に高いmounted
だけ覚えておけば大体なんとかなります。
mountedはコンポーネントのインスタンスがDOMにアタッチされた直後に実行されます。
初期化にAPI通信が必要な場合や、初期処理などをmountedの中で書きます。
async mounted() {
const response = await axios.get('/hoge')
this.data = response
}
cssの書き方
SFCではCSSも<style></style>
タグの内側に書いていきます
この書き方の最大の問題はCSSのスコープです。CSSはグローバルスコープしか持たないために各コンポーネントで思い思いにコードを書くと簡単に衝突してこわれてしまいます。
これを回避する方法が用意されています。
1つはVueが独自に用意しているscoped cssでもう1つはcss modulesです
どちらを使ってもいいのですが、混乱を避けるためにどちらか一方に統一したほうがいいです。
比較記事:https://www.netguru.com/codestories/vue.js-scoped-styles-vs-css-modules
またclassはなるべくシンプルに書くようにするのがいいです
https://qiita.com/simezi9/items/ff9c79c222c49940a553
css modules
CSS Modulesで書いたサンプルは以下のようになります
<template>
<div :class="$style.message"> {{ message }} </div> // classをv-bindの記法で指定 & $style.message
</template>
<script>
export default {
data: () => { message : "message"}
}
</script>
<style lang="scss" module> // moduleの指定
.message {
font-size: 16px;
}
</style>
特徴的なのは<style module>
の指定と、template内の$style
です。
CSSModuleで書かれたクラスセレクタの名前はビルド時に自動で変換されて$style
という変数の中にバインドされます。
その$styleの値をtemplateやJSから利用することができるようになっています。
実際に上のサンプルをビルドして出力すると以下のようなコードになっています
// ブラウザ上での表示
<div class="message_2vi2URMV"> message </div>
.message_2vi2URMV {
font-size: 16px
}
これによってセレクタの衝突を避けることが出来るようになっています
またJavascriptからこれらの値にアクセス刷ることも可能です
scoped css
以下のようにscopedという属性をつけるとscoped cssとなります。
<style lang="scss" scoped></style>
css moduleはスタイルがJSの変数に格納されて、それをバインドしましたがscoped cssは普通のcssと同じ感覚で記述できます
しかしcssのスコープ自体はSFCの中に閉じられています
<template>
<h1 class="heading">見出し</h1>
</template>
<style lang="scss" scoped>
.heading {
font-weight: bold;
}
</style>
こちらはCSSModulesと違ってdata属性を利用することで擬似的なスコープを実現しています
スタイルガイド
Vueではある程度従うべき標準のスタイルガイドが用意されています。
ぜひ一読しておくといいかと思います
https://jp.vuejs.org/v2/style-guide/