はじめに
こんにちは、モチベーションクラウドの開発にフリーのエンジニアとして参画している@HayatoKamonoです。
この記事は、「モチベーションクラウド Advent Calendar 2018」8日目の記事となります。
概要
モチベーションクラウドの開発チームでは2018年10月から改善期間と称して、開発に関するガイドラインやルール作りをはじめとする、様々な改善活動に取り組んでいます。
その改善活動の一環として作成した「コンポーネント設計・実装ガイドライン」を今回は説明を交えながらご紹介して行きたいと思います。
目次
- Componentの粒度に関して
- Container ComponentとPresentational Component
- コンポーネントの共通化に関して
- コンポーネント実装時の細かな決め事
- CSSに関して
- まとめ
Componentの粒度に関して
これまではフロントエンド開発チームの中で「どの粒度でコンポーネントを切るか?」に関して共通の方針が存在していなかったため、人によって実装するコンポーネントの粒度がバラバラでした。
ただでさえ、Vueの場合、単一ファイルコンポーネントでコンポーネント実装を行うと、どうしても1ファイル内のコードが長くなりがちなので、色々な責務を一つのコンポーネントに押し込んでしまうと、何スクロールもしないと1ファイル内のコード全体を読み通すことが出来ないようなコンポーネントが簡単に出来上がってしまいます。
実際に、そのような産物もチラホラ・・・
そこで、フロントエンド開発チームでは、「コンポーネントの粒度」に関しての方針を決め、ガイドラインに入れることにしました。
Atomic Design
コンポーネントはAtomic Designを参考にし、以下の粒度を意識して実装するという方針に決めました。
- atoms
- molecules
- organisms
- templates
- pages
とはいえ、方針だけ決めても実際に運用に乗らなければ意味がありません。
Atomic Designは「実際に運用してみると難しい」というような話は度々、ネット上の記事で見かけたり、実際に聴いたりもします。
私自身、Atomic Designを参考にしてコンポーネント実装を行なった経験が以前ありましたが、自分の経験としても、「このコンポーネントはatoms
なのか、molecules
なのか?」等と判断に迷うことが度々ありました。
また、チームの中にはAtomic Designでのコンポーネント実装を経験している人もいれば、そうでない人もいたので、チームのみんなで「何がどの粒度に該当するのか?」について、サンプルコードや既存のコンポーネントをベースに認識合わせを行なうことにしました。
大事にしたこと
Atomic Designの解釈について大事にしたことは「このチームにおいて、どう解釈するか?」です。なので、チーム内のみんなが納得感を持てれば、それが他のAtomic Designを取り入れている現場の解釈と異なっていてもOKということです。
atoms
- これ以上分割出来ない最小単位の機能を持つ
- (例)formのlabel、button、inputなど
- atomは他のatomを自身の範囲内に含むこともある
molecules
- 意味のある単位でatomを組み合わせて作られた集合体
- (例) フォームのラベル、テキストボックス、ボタンを組み合わせた検索ボックス
- moleculeは他のmoleculeを自身の範囲内に含むこともある
organisms
- 意味のある単位でatom、moleculeを組み合わせて作られた集合体
- (例) ロゴ、検索ボックス、ナビゲーションリンクを組み合わせたヘッダー
- organismsは他のorganismsを自身の範囲内に含むこともある
- 必要に応じてContainer Componentを通じて、VuexのstoreやVue Routerのrouteにアクセスする
templates
- レイアウトに責務を持つ
- 特定のレイアウトを適用したいコンポーネントの中で使われる(pagesレベルのコンポーネントに限定しない)
- 多くの場合、slotsを含む
pages
- atom、molecules, organisms, templatesの集合体
- 単一のURLに対応する
- 必要に応じてContainer Componentを通じて、VuexのstoreやVue Routerのrouteにアクセスする
共通方針
また、全てのレベルのコンポーネントに共通する方針として、コンポーネント自身にCSSのpositionやコンポーネントの外側へのmarginを持たさず、コンポーネントを使う側でそれらは指定するということも共通の認識として持つようにしました。
Container ComponentとPresentational Component
Reactコミュニティーでは、Dan Abramovの「Presentational and Container Components」で有名になった「Container Component」と「Presentational Component」を分けて実装するパターンがお馴染みです。
モチベーションクラウドのフロントエンド開発チームには、私自身も含め、React経験者がチラホラおり、2018年の8月、9月頃から、Container ComponentとPresentational Componentを分けようという動きが生まれました。
しかし、React未経験の開発メンバーにしっかりとコードをもとに、Container ComponentとPresentational Componentの違いを説明・共有出来ていなかったこともあり、Container Componentの認識が人によって異なるという状況が生まれていました。
結果、Container Componentの中にPresentationに関するコードが含まれてしまったりと、結局、ContainerとPresentationalで分離されていないコンポーネントが出来上がってしまうことにもなりました。
そこで、改めて、今回の改善期間の中で作成した「コンポーネント設計・実装ガイドライン」の中に、Container ComponentとPresentational Componentの説明をサンプルコードを交えて組み入れ、再度、認識の擦り合わせを行うようにしました。
目的の共有
そもそも、Container ComponentとPresentational Componentに分けることによって、どんなメリットがあるのかすら共有出来ていなかった為、その辺も含め、ガイドラインに組み込むようにしました。
可読性・保守性の向上
データや振る舞いに関心を持つContainer Componentと、見た目に関心を持つPresentational Componentに分離することで、どこに何が書かれているかが分かりやすくなり、アプリケーションのコードの理解がしやすくなるし、既存コードに機能追加や修正を行なう際には、どこに何を追加・変更すれば良いかが分かりやすくなる。また、1ファイル内のコード量も減るため、コードの見通しも良くなる。
再利用性の向上
Container ComponentとPresentational Componentに分けることで、Presentional Componentが特定のVuex側で持っている状態に依存しなくなり、他の異なるデータソースに差し替えても使い回しが出来るようになる。また、同様にContainer Component自体も同じデータや振る舞いに関心を持つPresentational Componentに対して、使い回しが効くようになる。
テスト容易性の向上
Container ComponentとPresentational Componentを分けることで、Presentational Componentのみを簡単に単体テスト出来るようになったり、リグレッションテストが出来るようになる。
並行作業の容易性向上
Container ComponentとPresentational Componentに分けることで、一人はAPIの繋ぎこみや振る舞いに関するロジックの実装、もう一人はコンポーネントのマークアップやスタイリングを行なうというように、ファイルのコンフリクトを気にせず、並行作業がしやすくなる。
Storybookの追加容易性の向上
Container ComponentとPresentational Componentを分けることで、Vuexのモックを用意するなどせずに、簡単にStorybookにコンポーネントを追加することが出来るようになる。
Presentational Component
特徴
- 見た目に責務を持つ
- VuexのstoreやVue Routerのrouteなどのアプリケーションの状態に依存せず、他のVueアプリケーションにも流用できる。
- 必要なデータがどのように読み込まれるか、また、どのように変更されるかを指定しない
- コンポーネント自身の状態は滅多に持たない。(仮に状態を持つとしても、それはUIに関する状態のみ)
状態を持たせない
Presentational Componentは親からprops
を通して、必要なデータを受け取り、自身を描画するだけのものです。本来のPresentational Componentの特徴としては、UIに関する状態であれば、その状態を持っても良いとあります。
しかし、私たちのチームで別途、作成したVuexのガイドラインでは「パフォーマンスに影響があるなどの特別な理由がない限り、全ての状態をVuexに寄せる」という方針があるため、Presentational Componentが状態を持つことはありません。
仮にVuex側で状態を持たせない方が良い場合は、後述するContainer Component側に持たせます。
サンプルコード
<template>
<div>
<h1 v-if="isOpen">Hello World</h1>
<p>{{ message }}</p>
<button @click="handleClick">toggle</button>
</div>
</template>
<script>
export default {
name: "HelloWorld",
props: {
message: {
type: String,
default: "I am not controlled"
},
isOpen: {
type: Boolean,
default: false
}
},
methods: {
handleClick() {
this.$emit("click");
}
}
};
</script>
Functional Component
いわゆる、Leaf Componentと呼ばれるコンポーネントのように、ループ処理の中で同じコンポーネントが描画されるような場合には、Functional ComponentとしてPresentational Componentを実装することも検討します。
Functional Componentは単なる関数でインスタンスを持たないため、描画コストを少なく抑えることが可能です。
Container Component
特徴
- データや振る舞いに責務を持つ
- データや振る舞いをPresentational Componentや他のContainer Componentに提供する
- VuexのstoreやVue Routerのrouteを参照しても良い(しなくても良い)
- 通常、DOMのマークアップやCSSスタイルを持たない。(仮にDOMを持つとしても、それはラッパー用のdivタグなど)
サンプルコード
// Presentational Component
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 };
コンポーネントの共通化に関して
現状、フロントエンド開発チームが抱える1つの課題として、似たようなコンポーネントが複数存在していたり、同じような処理が複数のコンポーネントに散らばっていたりする状況が多々あるというものです。
この状態に至った背景としては、開発の進め方や開発者同士の連携不足に原因があったり、コンポーネント分割やコンポーネント共通化に関する考え方・方法に対する理解不足に原因があるようでしたが、今回作成した「コンポーネント実装・設計ガイドライン」では、共通化の方法論について実際にサンプルコードをもとに共有・認識の擦り合わせを行なうようにしました。
共通化の手法
共通化を実現する方法としては、「継承」であったり、Vueの「Mixin」がありますが、継承は親と子が密結合な関係になってしまいますし、「Mixin」もまた暗黙の依存関係が生まれてしまいます。(※Reactも以前はMixinをサポートしていましたが、Mixinは暗黙の依存関係を生むため廃止しています。)
そのため、Reactコミュニティーではお馴染みのHigher Order Componentを用いたり、Reactコミュニティーで用いられるRender PropsやRender Childrenパターンと同様のことを実現するVueのScoped Slotsを用いて、データや振る舞いの共通化を行います。
ここでは、Higher Order ComponentとScoped Slotsを用いてContainer Componentを実装する簡易的な例を掲載します。
Higher Order Componentを用いた例
以下は、仮にクライアント側で認証を行うSPAであると仮定した場合に必要になりそうな、クライアント認証用のロジックやデータを提供するHigher Order Componentの例です。
Higher Order Componentは引数にComponentを取り、別の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;
使う側の例
// router.js
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)
}
]
});
Scoped Slotsを用いた例
以下は、マウスの位置情報を提供するContainer Componentの例です。
Container Component側
export default {
name: "MouseMoveTracker",
data() {
return {
mousePosition: {
x: 0,
y: 0
}
};
},
methods: {
handleMouseMove({ clientX, clientY }) {
this.mousePosition.x = clientX;
this.mousePosition.y = clientY;
}
},
mounted() {
this.$refs.wrapper.addEventListener("mousemove", this.handleMouseMove);
},
beforeDestroy() {
this.$refs.wrapper.removeEventListener("mousemove", this.handleMouseMove);
},
render(createElement) {
return createElement(
"div",
{
ref: "wrapper"
},
[
this.$scopedSlots.default({
mousePosition: this.mousePosition
})
]
);
}
};
使う側
<template>
<div id="app">
<MouseMoveTrakcer>
<template slot-scope="{ mousePosition }">
<SampleComponent :position="mousePosition" />
</template>
</MouseMoveTrakcer>
</div>
</template>
<script>
import SampleComponent from "./components/SampleComponent";
import MouseMoveTrakcer from "./components/trackMouseMove";
export default {
name: "App",
components: {
SampleComponent,
MouseMoveTrakcer
}
};
</script>
Container Componentにラップされる側
※ マウスの位置情報に関心があるコンポーネントであると仮定。
<template>
<div>
<p>X: {{ position.x }}</p>
<p>Y: {{ position.y }}</p>
</div>
</template>
<script>
export default {
name: "SampleComponent",
props: {
position: {
type: Object,
required: true
}
}
};
</script>
コンポーネント実装時の細かな決め事
他にも命名規則やデータ型毎の初期値など、コンポーネント実装時のルールとして定めました。
また、これまでは「JavaScript Standard Style」を適用するESLintプラグインは導入していましたが、Vue用のESLintプラグインは導入していなかったため、まずは、ルールが緩め目の「vue/essensial」を導入し、自分たちでガイドラインを決めなくても良いものに関しては、VueのESLintプラグインが提供してくれるルールに乗っかることにしました。
イベントハンドラーとイベント名の命名規則
これまで特にイベントハンドラーやイベント名にルールがなかったため、例えば、人によってはイベントハンドラー名をhandleXXX
のようにhandle
で始めたり、onXXX
のようにon
で始めたりと、名前の付け方がバラバラでした。
そのため、一貫性を持たせコードの見通しを良くするためにも、以下のように命名規則の基本方針を固めました。
イベントハンドラー名
イベントハンドラー名はhandleCancelのようにhandleで始めます。 また、イベントが発生した対象をイベントハンドラ名に含める必要がある場合は、handleModalCloseのように、「handle + 名詞 + 動詞」のように命名します。
methods: {
// Good
handleCancel () {
}
// Good
handleModalClose () {
}
// Good
handleKeyPress () {
}
// Good
handleMouseMove () {
}
// Bad
onCancel () {
}
// Bad
onCloseModal () {
}
// Bad
onPressKey () {
}
// Bad
onMoveMouse () {
}
}
イベント名
emitするイベント名は、命名規則に沿ったメソッド名からhandleを取り除いた文字列とします。
methods: {
handleCancel () {
this.$emit('cancel')
}
handleModalClose () {
this.$emit('modalClose')
}
handleKeyPress () {
this.$emit('keyPress')
}
handleMouseMove () {
this.$emit('mouseMove')
}
}
各データ型の初期値
コンポーネントのdataやpropsで持つデータ型の初期値に関して、個々の開発者によって認識がバラバラだったため、初期値としてより適切な値がある場合はそちらを優先することとしながら、基本的な方針として各データ型の初期値についてまとめることにしました。
データ型 | 初期値 |
---|---|
Boolean | false |
Number | null |
String | null |
Object | {} |
Array | [] |
Function | null |
Date | null |
※ Object型の初期値を{}
としている理由は、後でobjectに追加したkeyの値がリアクティブになるため
propsのガイドライン
これまで、propsの定義に関しては最低限、propの型の指定はされていましたが、人によって、必須のpropにrequired: true
の指定がされていなかったり、default
値の指定がされていなかったりと緩い状態になっていたため、VueのESLint Pluginでチェックが走るものもありますが、以下のように方針を固めました。
必須のpropsにはrequiredをtrueにする
props: {
name: {
type: String,
required: true
}
}
任意のpropsにはdefaultを設定する
props: {
isOpen: {
type: Boolean,
default: false
}
}
データ型を指定する
props: {
name: {
type: String
},
count: {
type: Number
},
isOpen: {
type: Boolean
},
item: {
type: Object
},
mode: {
type: String
},
selectedIds: {
type: Array
},
date: {
type: Date
},
customFunction: {
type: Function
}
}
propsの検証
propsで受け取る値が特定の条件に当てはまる場合は、propsの値にバリデーションを適用します。
以下はバリデーションの一例です。
props: {
name: {
type: String,
validator (value) {
return value.length > 0
}
},
count: {
type: Number,
validator (value) {
return value >= 0
}
},
item: {
type: Object,
validator (obj) {
const EXPECTED_KEY = 0
const EXPECTED_VALUE_TYPE = 1
const expectedPairs = [
['name', 'string'],
['count', 'number']
]
const pairs = Object.entries(obj);
return pairs.every(([KEY, VALUE], index) => {
return KEY === expectedPairs[index][EXPECTED_KEY] && typeof VALUE === expectedPairs[index][EXPECTED_VALUE_TYPE]
})
}
},
mode: {
type: String,
validator (value) {
const modes = ['easy', 'difficult']
return modes.includes(value)
}
},
selectedIds: {
type: Array,
validate (values) {
return values.every(value => typeof value === 'string')
}
},
date: {
type: Date,
validate (value) {
return value >= new Date(2000, 01, 01)
}
}
}
CSSに関して
前提として、モチベーションクラウドのフロントエンド開発においては、styled-components
のようなCSS-in-JS
は使っておらず、Scoped CSS
の環境下でSassを利用し、CSSを書いています。
Scopeが切られた環境ということもあり、フロントエンド開発チームにはBEM記法は行わず、また、他に細かなルールを決めるということもしておらず、個々の開発者にCSSの書き方は委ねるといったスタンスでおりました。
一応、クラス名はclass-name
のようにハイフンで区切ったケバブケースにするという簡単な決まり事はありましたが、やはり、これだけでは、次第に辛いところが出てきました。
そのため、追加で「最低限、これだけは守ろう!」といった方針をガイドラインに組み込むことにしました。
要素型セレクターは使わない
p
やul
など、要素セレクターは使用しません。代わりにclassセレクターを使用します。
BAD
<template>
<div class="sample-component">
<div>
<p>Hello World</p>
</div>
</div>
</template>
<style lang="scss" scoped>
div {
...プロパティを定義
& > p {
...プロパティを定義
}
}
</style>
GOOD
<template>
<div class="sample-component">
<div class="box">
<p class="message">Hello World</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.box {
...プロパティを定義
& > .message {
...プロパティを定義
}
}
</style>
ケバブケースのクラス名を&や変数展開で繋がない
ケバブケースのクラス名を&
や#{}
を用いた変数展開で繋ぐと検索性や可読性を損ねるため、これらをクラス名の連結を目的として使用しません。
BAD
<template>
<div class="sample-component">
<div class="box">
<p class="box-description is-large">
<span class="box-warning>"Alert Message</span>
</p>
</div>
<ul class="product-list">
<li class="product-list-item">A</li>
<li class="product-list-item">B</li>
<li class="product-list-item">C</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
.box {
$box: #{&};
...プロパティを定義
&-description {
...プロパティを定義
&.is-large {
...プロパティを定義
#{$box}-warning {
...プロパティを定義
}
}
}
}
.product-list {
...プロパティを定義
&-item {
...プロパティを定義
}
}
</style>
GOOD
<template>
<div class="sample-component">
<div class="box">
<p class="description is-large">
<span class="warning>"Alert Message</span>
</p>
</div>
<ul class="product-list">
<li class="item">A</li>
<li class="item">B</li>
<li class="item">C</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
.box {
...プロパティを定義
.description {
...プロパティを定義
&.is-large {
...プロパティを定義
.box-warning {
...プロパティを定義
}
}
}
}
.product-list {
...プロパティを定義
.item {
...プロパティを定義
}
}
</style>
まとめ
今回作成した「コンポーネント設計・実装ガイドライン」には、この記事の中では触れていないものもありますが、主要な部分に関しては共有出来たと思います。
ガイドラインは実際に運用出来ないと意味がないので、今後、新たにコードを書く際、既存のコードをリファクタリングする際、コードレビューを行なう際に、こちらのガイドラインで決めたことを適用して行きたいと思います。
また、ガイドラインは育てていくものでもあるので、実際に運用に乗せながら、試行錯誤をしつつ、必要に応じて追加・修正を行なって行きたいと思います。
関連記事
こちらの記事はモチベーションクラウド Advent Calendar 2018に投稿した記事です。
他にも、以下の記事をモチベーションクラウド Advent Calendar 2018に投稿しています。