はじめに
こんにちは、モチベーションクラウドの開発にフリーのエンジニアとして参画している@HayatoKamonoです。
この記事は、「モチベーションクラウド Advent Calendar 2018」15日目の記事となります。
概要
モチベーションクラウドのフロントエンド開発では、JavaScriptのフレームワークとしてVueを採用しています。
私がモチベーションクラウドの開発にジョインしたのは2018年7月です。
それまで私はReactを使ったフロントエンド開発を2、3年ほど行なってはいたものの、Vue自体は未経験の状態でした。
今回は、そんなVue未経験だった私が、この5ヶ月弱の間に、実務や日々の学習を通して蓄積してきたVueのパターンや小技、また、Reactコミュニティーから拝借したパターンなどを簡易的なサンプルコードとともに、共有させて頂きたいと思います。
v-modelのカスタマイズ
<some-component v-model="isExpanded" />
v-model
はデフォルトでは以下のように書き換え可能です。
<some-component @input="someFunction" :value="isExpanded" />
しかし、このデフォルトの挙動を以下のようにカスタマイズすることも可能です。
model: {
prop: "isExpanded",
event: "toggle"
},
props: {
isExpanded: {
type: Boolean,
default: false
}
},
フォーム系のコンポーネントであれば、v-modelに紐づくprop名とevent名をそのまま、value
とinput
にしても良いですが、フォーム系"以外"のコンポーネントの場合、そのコンポーネントの振る舞いにより適したprop名とevent名を、上記の例のように割り当ててあげるのが良いと思います。
$once('hook:beforeDestroy')
<script>
export default {
name: "SampleComponent",
created () {
this.someEventHandler = () => {
console.log("実際の開発ではイベントは間引こう!");
};
document.addEventListener("mousemove", this.someEventHandler);
},
beforeDestroy () {
document.removeEventListener("mousemove", this.someEventHandler);
}
};
</script>
時折、このようにcreated
のタイミングで何らかのイベントに特定の処理を紐付け、beforeDestory
のタイミングで同じイベントに紐づけておいた処理を除去したいこともあるかと思います。
こういった場合は以下のように書くと、よりシンプルにコードを書くことが可能です。
<script>
export default {
name: "SampleComponent",
created() {
const eventHandler = () => {
console.log("実際の開発ではイベントは間引こう!");
};
document.addEventListener("mousemove", eventHandler);
this.$once("hook:beforeDestroy", () => {
document.removeEventListener("mousemove", eventHandler);
});
}
};
</script>
ポイントはcreated
メソッドの中で、一度だけ実行される$onceメソッドを呼び出し、Vueのhook:beforeDestroy
イベントに対して、beforeDestory
メソッドの中で行いたい処理を記述するということです。
watchプロパティーのimmediate: true
Vueでは特定のprop
の値が変化した時に、何らかの処理を実行したい場合、watch
プロパティーを使用します。
しかし、watch
プロパティーは普通に使うと、監視対象のprop
の値が変化した時にのみ、監視対象のprop
に紐づけたメソッドが実行されます。
watch: {
isOpen () {
this.count = this.count + 1
}
}
例えば、この場合は親コンポーネントから受け取るprop
であるisOpen
の値が変化する時にのみ、このコンポーネント自身が持つcount
がインクリメントされます。
watch: {
isOpen: {
immediate: true,
handler() {
this.count = this.count + 1;
}
}
}
しかし、このように書き換えることで、このコンポーネント自身が最初にマウントされたタイミングにも、count
がインクリメントされるようになります。
Render Function
多くの場合、Vueでは<template>
タグを使用していれば事が足りますが、render function
というVNode
をプログマティックに生成するAPIを使う事で、描画部分のコードをより簡潔に書けたり、柔軟に処理を行えたりします。
<template>
<p :style="{ color: 'red' }">Hello World</p>
</template>
<script>
export default {
name: "HelloWorld"
};
</script>
上記は単純に'Hello World'と赤文字で表示するだけの簡単な例です。
<script>
export default {
name: "HelloWorld",
render(createElement) {
return createElement("p",
{ style: { color: 'red' } },
"Hello World"
);
}
};
</script>
先ほどのコードをrender function
を使って書くとこのようになります。
render(createElement) {
return createElement("p",
{ style: { color: 'red' } },
"Hello World"
);
}
render function
の引数にはcreateElement
という関数が渡ってきます。このcreateElement
を使って、VNodeを生成します。
createElement
の第一引数には要素名やコンポーネント名を渡し、第2引数にはprops
やclass
などの設定オブジェクトを任意で渡します。そして、第3引数には、子要素になるVNodeや文字列を渡します。
<script>
export default {
name: "HelloWorld",
props: {
level: {
type: Number,
default: 1,
validator(value) {
return value > 1 && value <= 6;
}
}
},
render(createElement) {
return createElement(
`h${this.level}`,
{ style: { color: "red" } },
"Hello World"
);
}
};
</script>
render function
を使えば、上記の例のように、親からlevel
というprop
を通して、1〜6の見出しレベルを受け取り、その受け取った見出しレベルに対応するHタグを動的にcreateElement
の第一引数に渡してあげることも可能になります。
これを<template>
タグを用いて行おうとすると、<template>
タグの中で対応させたい見出しレベルの数だけ条件分岐を行わなければなりませんが、render function
を使えば、簡潔にコードを書く事が出来るようになります。
Functional Wrapper Component
条件毎に異なるコンポーネントを描画したいような場合は、Functional Component
でラップして条件に対応したコンポーネントを描画してあげると、コードがクリーンになります。
以下は配列の中にデータがある場合は、データがある場合のコンポーネントを描画し、データがない場合はデータが無い場合のフォールバック用コンポーネントを描画するという簡易的な例です。
<template>
<div id="app">
<smart-item-list :items="items" />
</div>
</template>
<script>
import SmartItemList from "./components/SmartItemList";
export default {
name: "App",
components: {
SmartItemList
},
data() {
return {
items: [{ id: 1, name: "apple" }, { id: 2, name: "banana" }]
};
}
};
</script>
ここでは単に、SmartItemList
コンポーネントのitems prop
に2つのオブジェクトを持つ配列を渡しているだけです。
<script>
import ItemList from "./ItemList";
import EmptyData from "./EmptyData";
export default {
functional: true,
props: {
items: {
type: Array,
default() {
return [];
}
}
},
render(createElement, { props }) {
const Component = props.items.length > 0 ? ItemList : EmptyData;
return createElement(Component, {
props: {
items: props.items
}
});
}
};
</script>
ここでは、親から受け取ったitems
配列の中のオブジェクトの数をチェックして、配列の中身が無い場合は、EmptyData
コンポーネントを描画し、配列の中身がある場合は、ItemList
コンポーネントを描画しています。
<template>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script>
export default {
name: "ItemList",
props: {
items: {
type: Array,
default() {
return [];
}
}
}
};
</script>
データがある場合は上記のコンポーネントが描画されます。
<template>
<div>No Data...</div>
</template>
<script>
export default {
name: "EmptyData"
};
</script>
データが無い場合は、'No Data...'と表示されるだけのコンポーネントが描画されます。
Error Boundary
ReactではError Boundaryという、子孫コンポーネントでエラーが発生した際にクラッシュしたUIを表示させる代わりに、フォールバックのUIを表示させる手法が存在します。
それをVueで実現する為には、VueのerrorCaptured
フックを使用します。
以下はその簡易的な例です。まずは、Error Boundaryの使い方から見ていきます。
<template>
<div id="app">
<ul>
<template v-for="item in items">
<error-boundary :fallback="fallbackItem" :key="item.id">
<dummy-item :item="item" />
</error-boundary>
</template>
</ul>
</div>
</template>
<script>
import ErrorBoundary from "./components/ErrorBoundary";
import DummyItem from "./components/DummyItem";
import FallbackItem from "./components/FallbackItem";
export default {
name: "App",
components: {
ErrorBoundary,
DummyItem,
FallbackItem
},
data() {
return {
items: [
{ id: 1, name: "apple" },
{ id: 2, name: "banana" },
{ id: 3, name: null }
]
};
},
computed: {
fallbackItem() {
return FallbackItem;
}
}
};
</script>
ここでは、エラーが発生しうるコンポーネントをErrorBoundaryと名付けたコンポーネントでラップしています。ErrorBoundaryコンポーネントのpropsであるfallback
には、エラーが発生した際に代わりに表示させたいフォールバック用のコンポーネントを渡しています。
props
を通して、フォールバック用のコンポーネントを設定出来るようにすることで、ErrorBoundary
コンポーネントの利用者がエラー発生時に表示させたいコンポーネントを自由に選べるようになります。
<script>
export default {
name: "ErrorBoundary",
props: {
fallback: {
type: Object
}
},
data() {
return {
hasError: false
};
},
errorCaptured() {
this.hasError = true;
},
render(createElement) {
return this.hasError
? createElement(this.fallback)
: this.$slots.default[0];
}
};
</script>
次に、ErrorBoundaryコンポーネントを見ていきます。ここでやっていることは単に、子孫コンポーネントで発生したエラーを、errorCaptured
メソッドで捕獲してあげて、エラーであった場合はフォールバック用のコンポーネントを描画し、そうでない場合は、自身のslotsコンテンツを描画してあげています。
<template>
<li>{{ item.name.toUpperCase() }}</li>
</template>
<script>
export default {
name: "DummyItem",
props: {
item: {
type: Object
}
}
};
</script>
このコンポーネントがエラーが発生しうるコンポーネントです。親コンポーネントからpropsを通して受け取るitemオブジェクトのnameプロパティーは文字列であるため、<template>
の中では例として、item.name.toUpperCase()
を実行して文字列を大文字に変換しています。
しかし、APIから取得したデータに異常値が含まれている場合などを想定した場合、itemオブジェクトのname
プロパティーが欠損していたり、null
であるかもしれません。
そういった場合に、文字列ではないデータ型に今回のようにtoUppserCase
メソッドを実行すると、レンダリングエラーが発生してしまいます。
<template functional>
<li>nah...</li>
</template>
<script>
export default {
name: "FallbackItem"
};
</script>
エラーが発生した場合は、ErrorBoundaryコンポーネントのpropsのfallback
に渡していた、FallbackItemコンポーネントが代わりに表示されます。
Higher Order Component
Higher Order Componentはデータや振る舞いを共通化したい時に利用する、Reactではお馴染みのパターンですが、Vueでもrender function
を使えば可能です。
Higher Order Componentは、引数にComponentを取り、別のComponentを返す高階関数です。
以下は、仮にクライアント側で認証を行うSPAであると仮定した場合に必要になりそうな、クライアント認証用のロジックやデータを提供するHigher Order Componentの例です。
Higher Order Component側
const requireAuth = WrappedComponent => {
return {
name: `${WrappedComponent.name}-protected`,
computed: {
isAuthenticated() {
return this.$store.state.isAuthenticated;
}
},
created() {
// JWTトークンが存在、または、失効しているかどうかをチェック
// トークンが無い、または、失効していたらログインページへリダイレクト
},
render(createElement) {
return createElement(WrappedComponent, {
props: {
isAuthenticated: this.isAuthenticated
}
});
}
};
};
export default requireAuth;
使う側の例
export default new Router({
mode: "history",
base: process.env.BASE_URL,
routes: [
{
path: "/",
name: "home",
component: requireAuth(HomePage)
},
{
path: "/about",
name: "about",
component: requireAuth(AboutPage)
}
]
});
クライアント認証のロジックを適用したいコンポーネントをrequireAuth
関数の引数に渡してあげれば、引数に渡されたコンポーネントはクライアント認証のロジックを持つようになります。
Container Component、Presentational Component
Reactコミュニティーではお馴染みのパターンの1つに、データと振る舞いに関心を持つContainer Componentと、描画に関心を持つPresentational Componentを分けて実装するというものがあります。
render function
とHigher Order Componentのパターンを使えば、Vueでも同じことが実現可能です。
// Presentational Componentをimport
import SamplePage from "./SamplePage.vue";
/*
以下の`connect`は、Presentational Componentを引数に取り、そのコンポーネントが関心を持つ、
VuexのmoduleのデータとVue Routerのメソッドへのアクセスを与えたContainer Componentを返す高階関数
*/
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
computed: {
count() {
return this.$store.state.count;
}
},
methods: {
handlePageChange({ to }) {
this.$router.push(to);
}
},
render(createElement) {
return createElement(WrappedComponent, {
props: {
count: this.count
},
on: {
pageChange: this.onChangePage
}
});
}
};
};
/*
次の2行は以下のコードをContainerの説明の為に、より明示的にしたもの
export default connect(SamplePage);
*/
const SamplePageContainer = connect(SamplePage);
export default SamplePageContainer;
export { SamplePage };
Renderless Component(Scoped Slots)
VueにはHigher Order Componentの他にも、データやロジックを共通化する方法として、Scoped Slotsを利用したものがあります。
Scoped SlotsはReactコミュニティーでお馴染みのRender ChildrenやRender Propsパターンのようなものです。
以下はScoped Slotsを用いたContainer Componentの例です。
<script>
export default {
name: "DataProvider",
props: {
url: {
type: String
}
},
created() {
fetch(this.url)
.then(response => response.json())
.then(json => (this.data = json))
.catch(console.error);
},
data() {
return {
data: []
};
},
render(createElement) {
return this.$scopedSlots.default({
data: this.data
})[0];
}
};
</script>
上記のコードでは例として、propsで渡ってきたURLにGETリクエストを行い、成功時に返って来たデータを呼び出し元にscoped slotsを通して渡しています。
this.$scopedSlots.default()
自体はVNodesを含んだ配列を返す為、このメソッドの結果をそのまま、render function
の中でreturnしてあげれば、return createElement('div', [this.$scopedSlots.default()]
のように、何らかのDOM要素でラップしてあげる必要もありません。
<template>
<div id="app">
<data-provider url="https://jsonplaceholder.typicode.com/todos">
<template slot-scope="{ data: todos }">
<ul>
<li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
</ul>
</template>
</data-provider>
</div>
</template>
<script>
import DataProvider from "./components/DataProvider";
export default {
name: "App",
components: {
DataProvider
}
};
</script>
呼び出し元では、DataProviderコンポーネントの中でfetchしたデータをslot-scope
を通して受け取り、受け取ったデータをslotコンテンツに渡して描画してあげています。
このようなアプローチを取ると、DataProviderコンポーネントが持っているロジックを使い回すことができますし、DataProviderコンポーネントに内包されるコンテンツは差し替え可能になります。
Provide / Inject
VueにはPlugin開発向け、コンポーネントライブラリ開発向けのAPIとしてprovide & inject
APIが用意されています。
通常、コンポーネントから他のコンポーネントへデータを受け渡す際は、「親コンポーネントからその子コンポーネントへ」、「その子コンポーネントからその子コンポーネントへ」といった具合に、バケツリレーのようにデータの受け渡しを行なわなければなりません。
しかし、provide & inject
APIを使えば、親コンポーネントから孫コンポーネントへデータを直接受け渡すことも可能です。
ReactではContext APIがこれに当たります。
<template>
<div>
<h1>Parent</h1>
<child />
</div>
</template>
<script>
import Child from "./Child.vue";
export default {
name: "Parent",
components: { Child },
data() {
return {
// provide対象のデータをreactiveにする為には、このように別オブジェクトで内包する必要がある
sharedState: {
message: "Hello World"
}
};
},
mounted() {
// ここでは、provide対象のデータが更新された時にリアクティブになっていることを単に確認しているだけ
setTimeout(() => {
this.sharedState.message = "Hello Everyone";
}, 1000);
},
provide() {
return {
providedState: this.sharedState
};
}
};
</script>
providedState
という名前でthis.sharedState
を、この後、孫コンポーネントで受け取る事が可能になる。
<template>
<div>
<h1>Child</h1>
<grand-child />
</div>
</template>
<script>
import GrandChild from "./GrandChild.vue";
export default {
name: "Child",
components: { GrandChild }
};
</script>
ご覧にのように、GrandChildコンポーネントにはprops
でバケツリレーでデータを受け渡してはいない。しかし、この後、孫コンポーネントでは、先ほど親コンポーネントでprovideの対象としたデータを受け取る事が出来る。
<template>
<div>
<h1>Grand Child</h1>
<p>{{ message }}</p>
</div>
</template>
<script>
export default {
name: "GrandChild",
inject: ["providedState"],
computed: {
message() {
return this.providedState.message;
}
}
};
</script>
上記のように上位階層のコンポーネントにおいて、provide
で公開されたデータを、inject
で受け取る事が可能になる。
provide & inject
はバケツリレーの回数が多い場合に便利。
ただし、注意点としては、おそらく、ReactのContext APIが辿ったように、VueでもこのAPIは仕様が変更になることが予想されるのと、また、Presentational Componentの中で直接、VuexのStoreを参照している時と同じように、provide側とinject側で強い依存関係が生まれてしまうので、Higher Order Componentなどを用いて、抽象化してあげた方がAPI仕様の変更にも強く、また、疎結合にもなるので、そういった対応が必要になります。