この記事は食べログ Advent Calendar 2019 3日目の記事です。
はじめまして。食べログのフロントエンドチームに所属している@empitsu88です。
食べログの各種サービスのフロントエンド領域の設計・開発を担当しています。
先日、「食べログ テイクアウト」という新しいサービスをリリースしました。
こちらはiOSアプリですが、管理画面側をNuxt.js + TypeScriptで開発しています。
Nuxt.js + TypeScriptをプロダクトコードに使用するのは初めての試みだったので、メンテナブルなアプリケーションにするにはどうしたらいいか、日々模索しています。
今回はその技術を選定するに至った経緯や、チームで運用しているコーディングガイドラインの一部をご紹介します。
技術スタック
- Nuxt.js
- TypeScript
- Class API
- Sass + scoped CSS
- Atomic Design
それぞれ採用した理由
Nuxt.js
- Vue.jsはReactやAngularよりも学習コストが低そうだと感じたため。
- アプリケーション開発当初はスピード感が求められていたため。
- 制約やルールが決められているFrameWorkを導入することで、プロダクトの保守性を担保したかったため。
- 十分枯れた技術であり、あと数年は持ちそうだと感じたため。
まずReactやAngularを差し置いてVue.jsを採用した理由は、フロントエンドを専門としない、バックエンドエンジニアやネイティブアプリエンジニア、デザイナーもコードベースを触る可能性があったためです。
React/Angularと比較するとVue.jsは直感的に触れるため、複数人のエンジニア・デザイナーでスピード感を以って開発を進めていくには適していると考えました。
ただ、Vue.jsはFrameWorkというよりライブラリに近いため、使う側次第でいかようにも使えてしまうのがデメリットでした。
そこで制約やルールが定められているFrameWorkであるNuxt.jsを導入し保守性を担保しました。
また、Nuxt.jsはすでに書籍も複数冊出版され世間での採用事例も多く、少なくともあと3年くらいは使い続けていけそうだと感じたため導入を決定しました。
TypeScript
堅牢さを保つにはもはやTypeScriptの導入は必須と言っても良い時代です。
サーバーサイドエンジニア、アプリエンジニアも型には馴染みがあるため導入には抵抗がありませんでした。
型を守ればユニットテストの工数を減らせるという嬉しい効果もあります。
Vue.jsではTypeScriptの力を最大限発揮できないというデメリットはありますが、書籍「 実践TypeScript 」の「実践編」を参考にVuexの型定義を実装しています。
型定義を書いてからVuexの実装をすれば、あとはエディタが関数名などをサジェストしてくれるのでそれに従って書いていけば済んじゃいます。
控えめに言って最高です。
Sass
食べログではもともとSass(SCSS記法)を使っていたため、CSSは以下の観点で検討しました。
- Sass(SCSS記法)で書かれた既存の資産を流用できるもの
- 機械的な置換や変換ができるならOK
- デザイナーの学習コストが低いもの
- Sass(SCSS記法)と同じノリで書けると良い
実は、当初はpostCSSの採用を検討していました。
追加のpackageのinstallもほとんど不要ですし、Sassとはsyntaxが少々異なるもののネスト記法(nesting rules)や変数(custom properties )も使えます。
上記の観点を満たしていると思ったのです。
しかし、nesting rulesは&
と文字列を連結させたセレクタの指定ができません。
❌以下のような記述は正しくコンパイルされない
.v-heading {
&--1 {
font-size: 2rem;
}
}
.v-btn {
&#{&}--full {
width: 100%;
}
}
postCSSで動的にセレクタを指定できる機能としては :matches pseudo-class や custom selectors もありますが、「親セレクタの名前と任意の文字列を効率的に連結させる」用途としては使えなさそうです。
いずれにしてもpostCSSだとSassのソースを流用するには書き換えのためのコストがかかりそうだったため、素直にSassを導入しました。
Class API
Nuxt TypeScriptのcomponentsのページではOptions API
Composition API
Class API
3種類のサンプルが紹介されていますが、その中のClass API
を採用しています。
上記ページで言及されているvue-property-decorator のほかにvuex-classも追加でインストールし、出来るだけデコレータを使うようにしています。
どの関数でEmit
が使われているかなど見通しが良くなりますし、特にstoreとの接続の処理はデコレータでクラスコンポーネントの最上部にまとめることで、明示的になると考えたためです。
export default class extends Vue {
@Getter('loading/getIsLoading') isLoading!: boolean
@Action('loading/setIsLoading') setIsLoading!: (payload: {
isLoading: boolean
}) => void
...
@Emit('submit')
onClickSubmitBtn(): void {
// do nothing
}
fetch
やasyncData
の内部では使えないのが悩ましいですが…。
大事にしているポイント
以上が技術スタックと選定した理由となります。
このアプリケーションで技術的な決定をする際は以下の観点を重要視し、迷ったときは判断軸がぶれないようにしています。
- 様々な職種の開発者が複数人でメンテナンスしていくため、壊れにくいアプリケーションにできるかどうか。
- 見た目とロジックを分離できるかどうか。
- -> デザイナーとの分業をしやすいかどうか。
これは言い換えると、上記の「堅牢さ」や「分業のしやすさ」を優先した結果「開発スピード」「コードの簡潔さ」「最新の技術であること」が多少犠牲になるとしても、それを許容するということになります。
もちろん「大幅に工期が遅れてしまう」、「完全に時代遅れの技術スタックを今から採用する」なんてことは避けたいためある程度バランスはとりたいところですが、「このアプリケーションではどんな観点を重視するか」の共通認識をチームで持っておくことで、迅速に判断ができるようになります。
コーディングガイドラインに定めていること
さて、ここからはコーディングガイドラインに定めている一部の内容をご紹介します。
コンポーネントの命名
ファイル名
.vue
ファイルを格納するディレクトリ名は、V + コンポーネント名
をパスカルケースで記載します。
components/
└ atoms/
└ VBtn/
└ index.vue
Vue.jsのスタイルガイドの以下の項目を参考にしています。
単一ファイルコンポーネント のファイル名は、すべてパスカルケース (PascalCase) にするか、すべてケバブケース (kebab-case) にするべきです。
引用元:スタイルガイド - 単一ファイルコンポーネントのファイル名の形式
ルートの App コンポーネントや、Vue が提供する
<transition>
や<component>
のようなビルトインコンポーネントを除き、コンポーネント名は常に複数単語とするべきです。
Base 、 App 、V などの固有のプレフィックスで始まるべきです。
親コンポーネントと密結合した子コンポーネントには、親コンポーネントの名前をプレフィックスとして含むべきです。
テンプレート内でのコンポーネント名の形式
<template>
タグの中ではパスカルケースで統一しています。
<template>
<VBtn></VBtn>
</template>
参考:
パスカルケースには、ケバブケースよりも優れた点がいくつかあります:
引用元:スタイルガイド - テンプレート内でのコンポーネント名の形式
参考にしている語彙
余談ですが、コンポーネント名に使う語彙はVuetifyを参考にしています。
「ボタンはButtonにするか?Btnにするか?」
「ダイアログUIはModalにするか?Dialogにするか?」
などなど命名には迷いがちなのでひとつの指針があると議論の時間を削減できます。
クラスの命名規則
CSSのクラスの命名規則はMindBEMdingを採用しています。
コンポーネント名とMindBEMdingのBlock名は一致させるようにしています。
<ul class="v-menu-list">
<li class="v-menu-list__item"></li>
</ul>
導入した理由
scoped CSSはローカルスコープを生成してくれますが、子要素のルート要素にはスタイルが適用できるため、被りやすいクラス名や要素セレクタをむやみに使うとバグの原因になります。
もともと食べログではMindBEMDing + FLOCSS を使っておりデザイナーが慣れているため、Nuxt.jsのアプリケーションでも踏襲しています。
コンポーネントはAtomic Designに従って分類する
componentの分類にはAtomic Designを採用しています。
分類 | ディレクトリ | 説明 |
---|---|---|
atoms | components/atoms/ | 汎用的に使えるcomponent。 抽象的な機能を提供する。 他のcomponentに依存していなければatoms。 |
molecules | components/molecules/ | 複数のAtomsを組み合わせたcomponent。 |
organisms | components/organisms/ | 他のAtoms/Molecules/Organismsで構成される。 独立して成立するコンテンツを提供する。 |
templates | components/templates/ | pagesの最上位に設置する。同一ページ内で1度しか使えないcomponent。 |
pages | pages/ | 最上位のcomponent。 |
分類の方針は以下の書籍を参考にしています。
参考文献:「Atomic Design ~堅牢で使いやすいUIを効率良く設計する」
導入した理由
- 抽象化されている汎用的なコンポーネントを明確にするため。
- -> atomsが該当します。
- どのコンポーネントならstoreに接続してよいか明確にするため。
- -> 本アプリケーションではpagesのみとしています。
ただし、どこに分類すべきか悩みすぎない=分類に時間をかけすぎないように 気をつけています。
導入した理由から考えると、大事なのは どれがatomsでどれかpagesなのか のみで、悩みがちな moleculesかorganisms どっちすべきか、というのは些末な問題です。
最近では「moleculesを使わない」とか、「Atomic Designをあえて採用せず、『汎用的なコンポーネント』『それ以外』でのみ分ける」などの例も見聞きします。
もしも「コンポーネントをどこに分類すべきか悩むコスト」が今後深刻になってきたら、Atomic Designの使用を再考することも視野に入れています。
storeに接続するのはpages componentのみ
先程の項目で少し触れましたが、Vuexのstoreに接続するのはpagesのコンポーネントのみとしています。
受け取ったデータはpropsとしてtemplates > organisms ...と下位層に流します。
こうすることで、以下のようにコンポーネントの責務を区別することができます。
コンポーネントの種類 | 責務 | storeへの接続 |
---|---|---|
pages | APIから取得する動的なデータに関心がある | ○ |
templates以下 | propsとして与えられたデータを表示することに特化する | × |
この考え方は書籍「Atomic Design ~堅牢で使いやすいUIを効率良く設計する」を参考にしています。
最上位であるpagesコンポーネントでしかstoreに接続できないというのは、emitで下位層からデータを上位にバケツリレーしなければならない回数が増えるという所謂emit地獄を引き起こすリスクはあります。
ですが、現状のアプリケーションはコンポーネントの階層がそこまで深くないというのと、多少冗長になったとしても**「壊れにくさ」を重視したいため**この方針にしています。
子要素のスタイルはなるべく上書きしない
atoms のコンポーネントの内部には、配置を定める関心を分離するためmarginやpositionなどの指定は含めないようにしています。
親側でatomsのコンポーネントを設置しマージンなどを設定する際は、ラッパー要素を使い直接子要素に当てないようにした上でスタイリングします。
⭕OK:
<div class="v-login-content__btn-wrap">
<VBtn>ログイン</VBtn>
</div>
.v-login-content {
&__btn-wrap {
margin: 1.5rem auto 3rem;
}
}
なぜ子コンポーネントに直接スタイルをあてないようにするのかは次項で詳しく説明します。
子コンポーネントのスタイルを上書きする際は必ず詳細度をあげる
色やpaddingなど子コンポーネント自体のスタイルを書き換えたいときは、セレクタを二重に書くなどして詳細度を上げて対応します。
<VBtn class="v-top-content__btn-logout">ログアウト</VBtn>
.v-top-content {
&__btn-logout#{&}__btn-logout {
min-width: 14rem;
&::before {
margin-right: 0.5rem;
vertical-align: middle;
content: url('~assets/images/common/v_icon_logout.svg');
}
}
}
先述した通りscoped CSSは子コンポーネントのルート要素のスタイルの上書きが可能ですが、スタイルの記述順は保証されません。
コンポーネントがレンダリングされる順序によりスタイルの順序が変わるため、
「とある操作をしたときだけデザイン崩れが発生する」
「とある操作ではデザイン崩れは発生しない」
ということが起こりえます。
よってセレクタの記載を工夫して親側のスタイルの詳細度をあげます。
!important
を使うという選択肢もありますが、!important
を使っているスタイル同士の優先付けができないため「まじでどうしようもなかったときの最終手段」としています。
ただ、「子コンポーネントの上書きの際は詳細度がちゃんと上がっているか」を実装者やレビューアーが人力でチェックし続けるのは正直しんどくなってきました。
子コンポーネントのスタイルの上書きを一律禁止にすることも含めて検討しています。
TypeScriptでasでのキャストは極力使わない
tsconfig.json
の設定内容は Nuxt TypeScriptのセットアップ>設定 に記載の内容からほとんど変えていません。
よって厳し目の設定にはなりますが、実際の型定義と異なるのに関わらずas
を使って型を変換するのはNGとしています。
ただし、as
で指定した型と実際の型が一致していることを人間が保証できているならOKです。
⭕OK
await (store as ExStore).dispatch('account/asyncFetchAccount')
APIからGETするJSON、PUT/POSTするJSONにはそれぞれ型を定義する
APIのinterfaceの定義によっては、同じ画面のGET/PUT/POSTの型定義がほとんど同じになる場合があります。
GETではidが存在するが、POSTでは存在しない、程度の差だったり…。
その場合id?
を使って定義を共通化したくなりますが、実際には「GETではidは必要」「POSTでは不要」なのが正しい仕様になるため、
仕様と型定義を一致させるためにそれぞれ個別に定義するようにしています。
❌NG
export interface Item {
id?: number
name: string
price: number
}
⭕OK
export interface ItemForSend {
name: string
price: number
}
export interface ItemFromFetch {
id: number
name: string
price: number
}
delete演算子でオブジェクトのkeyは削除しない
delete演算子は型推論が正常に働かなくなるので使用しないようにしています。
keyを削除したいときは分割代入+スプレッド演算子を使っています。
eslintでno-unused-varsの警告が出てしまいますが、disableを使うのは許容しています。
❌NG
delete item._id
⭕OK
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, ...restItem } = item
子コンポーネントの内部では$routeを参照しない
templates以下の層であるcomponents/
配下のコンポーネントは、独立して存在できることが望ましいです。
templates以下のコンポーネント内部で$route
を参照すると、Vue Routerに依存したつくりになってしまいます。
また、そのままだとstorybook上でエラーを吐いてしまうため、storyファイル上でコンポーネントにVue Routerのインスタンスを渡してやるなどの対応が必要になります。
❌NG
- components/molecules/VMenuItem/index.vue
get isActive(){
return this.$route.params.id === this.id
}
⭕OK
- pageコンポーネントでのみ
$route
を参照し、propsで子コンポーネントに渡す。 -
<nuxt-link>
にactive時のスタイルを当てたいときはnuxt-link-active
クラスや、active-class属性を活用する。
storybookは必ず更新する
storybookは以下の理由で導入しています。
- アプリケーションで使えるコンポーネントをカタログ化してUIデザイナーが参照しやすくするため。
- propsごとに異なる見た目の動作確認を簡便にするため。
メリットを最大限活かすには常にstoryファイルを最新に保つことが重要です。
更新忘れを防ぐため、.vue
ファイルを修正するとPullRequestにstorybookのURLが自動でコメントされる仕組みを入れています。
そのおかげで、propsの追加などstorybook にも反映すべき修正をしたときは実装者もレビュアーも気付きやすくなっています。
以下の記事は大いに参考にさせていただきました。
参考:PRごとにCIでStorybookをビルドしてデザイナーとインタラクションまで作っていく話
アクセス修飾子は必ず記載する
コンポーネントに生やしたメソッドや変数を外から参照する、なんてことは普通しないため、付ける/付けないで動作に影響はないのですが、可読性のために付与しています。
public | template から参照されるメソッド・data |
private | コンポーネントのメソッドからのみ呼ばれるメソッド |
public localValue = 0
public get computedValue():number {
...
}
private calculate(value: number): number {
...
}
静的なclass属性と:class属性は分ける
アプリケーションのコードベースはフロントエンドエンジニアだけでなくデザイナーも触ります。
フロントエンドエンジニアでコンポーネントをざっくり実装→DOM構造やCSSをデザイナーに調整してもらう というフローで開発することが多いため、見た目とロジックはできるだけ分離するようにしています。
class属性も「純粋なスタイリングのためだけのclass」と「動的なclass」をできるだけ別の属性に分けて書くようにしています。
⭕OK:
<li
class="v-menu__item"
:class="{ 'is-active': isItemActive }"
>
❌NG:
<li :class="[
'v-menu__item',
{ 'is-active': isItemActive }
]"
>
classと:classが適切にマージされるというのは、公式ドキュメントのクラスとスタイルのバインディングのページにも載っています。
template内での直接の値(data)の書き換えはしない
見た目とロジックを分離するという観点で、template内にロジックを書くのはできるだけ控えます。
特に、dataを直接書き換える処理はMUSTで避けています。
副作用を生む処理がどこで行われているのかわかりやすくするためです。
❌NG:
<button @click="isOpen = !isOpen">btn</button>
⭕OK:
<button @click="togglePanel">btn</button>
public togglePanel(): void {
this.isOpen = !this.isOpen
}
routerインスタンスを使ったナビゲーションの制御時はnameで指定する
path指定だと末尾のスラッシュありなしに揺れが生じる恐れがあるため、名前付きルートで指定しています。
❌NG:
// 文字列パス
router.push('/')
router.push({ path: 'home' })
<nuxt-link :to="/">Index</nuxt-link>
⭕OK:
// 名前付きルート
router.push({ name: 'index' })
router.push({ name: 'user', params: { userId: '123' } })
<nuxt-link :to="{ name: 'user', params: { userId: 123 }}">User</nuxt-link>
まとめ
一部ではありますが、以上が「食べログ テイクアウト」のアプリケーションで運用しているコーディングガイドラインのご紹介になります。
技術選定にせよ、コーディングガイドラインの策定にせよ、「このアプリケーションで重要視する判断軸はなにか」を明確にしておくとスムーズに判断できるということを実感しています。
よりよいアプリケーションにしていくため、コーディングガイドラインの内容はブラッシュアップし続けていきたいと考えています。
明日は、@itume さんの「そうだ Flutter、やろう。」です。よろしくおねがいします!