スタートアップCTOがNuxtJSとTypeScriptのコーディングレギュレーションを紹介!一本進んだ堅牢開発を解説します😎

スタートアップCTOがNuxtJSとTypeScriptのコーディングレギュレーションを紹介!一本進んだ堅牢開発を解説します😎

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは!

最近は NuxtJS & TypeScript によるフロントエンド開発プロジェクトが増えてきました。フロントエンドの TypeScript 開発は今やデファクトスタンダードと言えるでしょう。

TypeScript は堅牢な反面、きちんとした開発レギュレーションの整備・コーディングの設計を行わなければ、堅牢なのが災いし、却って開発効率の低下を招いてしまいます。

これから TypeScript 開発を始めるチームの方々、テックリードの方は是非本記事を参考にフロントエンド開発へ TypeScript 導入を検討してみてください!

想定する読者

  • テックリードをしているヒト
  • これから TypeScript を導入しようとしている開発チームのヒト
  • スタートアップ企業のCTO

はじめに

本記事では NuxtJS と TypeScript を紹介します

NextJS(ReactJS)を採用している場合でも、活用できるような内容に努めたいと思いますが、紹介するソースコードが一部 NuxtJS(VueJS)となっていますのでご了承ください。

なぜ NuxtJS(VueJS)?

様々な意見ありますが、ReactJS と VueJS のプロジェクトを数多く行ってきた私たちの経験上、ReactJS より VueJS の方が JSX を記述しないため技術的敷居が低いと感じるためです。

例えば HP などの WEB 制作系エンジニア(HTML/CSS コーダー、ワードプレス)にとって、VueJS の HTML は非常に馴染みやすいものだと言えます。

TypeScript は敷居が高い?

前述の WEB 制作系エンジニアにとって、「型」の概念はあまり馴染みないものです。TypeScript を導入せずとも堅牢にフロントエンドを実装する方法はありますので、「TypeScript の導入」がゴールにならないように注意してください。

TypeScript の導入の目的はあくまでも、「開発効率向上」です、これを見失わないようにしましょう。

レギュレーション概要

  1. ディレクトリ規約
  2. コーディング規約
  3. Husky による Lint &ビルド疎通チェック

※ 後ほど README.md も貼り付けますので、是非プロジェクトに引用してください

ディレクトリ規約

私たちが普段採用しているディレクトリの規約について紹介します。

NuxtJS においてデフォルトで生成されるディレクトリについては特に補足しませんが、私たち独自のカスタム内容については後述で補足します。

├── assets
│   ├── img 画像アセット
│   └── styles SCSSファイル
│      └── global.scss ノーマライズCSS的なグローバルに適用したいスタイル情報
│      └── common.scss 各ページで@importするSCSS、foundation以下のSCSSを全て@import
│      └── foundation
│         └── _fonts.scss フォントのインポート
│         └── _mixin.scss Mixin定義
│         └── _variables.scss 変数定義
├── components
│   ├── button 汎用的なボタンUI
│   ├── card 汎用的なカードUI
│   ├── list 汎用的なリストUI
│   ├── container 各ページで使用する汎用的なレイアウト
│   ├── datadisplay データに依存したUI
│   ├── layouts ヘッダーやフッターなどのレイアウトComponent
│   ├── modal モーダルウィンドウUI
│   └── navigation パンクズやページヘッダーなどのナビゲーションUI
├── definitions
│   └── index.ts 汎用的なTypescriptのtype定義
├── global.d.ts Typescript型情報のグローバル定義
├── jest.config.js
├── layouts
│   ├── default.vue 全ページ共通のレイアウト
│   ├── error.vue エラーページ共通のレイアウト
│   └── maintenance.vue メンテナンスページのレイアウト
├── middleware
│   └── authenticated.ts 認証必須なページの設置するミドルウェア
│   └── unAuthenticated.ts 非認証必須なページの設置するミドルウェア
├── mockdata
│   └── user.ts 開発時に使用するモックデータ
├── nuxt.config.js
├── package.json
├── pages 
├── plugins NuxtJSのプラグイン
├── services 本プロダクトでのみ使用する共通処理
├── static
├── store
├── stylelint.config.yml
├── test Jestのテストファイルを格納
├── tsconfig.json
├── utils 他のプロダクトでも共通利用可能な汎用的な共通処理(Emailのバリデーション等)

/assets

Webpack を通して画像を圧縮可能(設定次第)なため、基本的には以下の開発ケースを除き、当ディレクトリへ画像ファイル等を保持します。

NuxtJS を Lambda で SSR させたい場合、こちらの AWS インフラ構成で構築しているケースが想定されます。(Lambda から APIGateway へ SSR 結果を返却するアーキテクチャー)

この場合、Lambda から APIGateway へレスポンス可能なデータサイズが決まっていたり、Lambda へデプロイ可能な ZIP パッケージサイズの制約などで、開発中ハマる危険性が非常に高いです。

CloudFront と S3 などの単なる SPA 構成であれば問題ないですが、上記の場合は/assets ディレクトリへの画像保持に注意が必要です。開発が進みプロジェクトが肥大するのを見越して、アセット系専用の静的ストレージ導入を検討する必要があります。(CI/CD で静的ストレージへアップロードする処理実行を推奨)

/components

components ディレクトリでは、原則 Store と通信は行いません。コンポーネントは、Props を受け取り表示を行うシンプルな構造を目指します。

ただし、以下のディレクトリのみ各ページでの汎用的な表示を前提とし、Store との通信を許可します。(Props に強く依存しない独立した Vue ファイルを格納)

  • /components/container

/definitions

汎用的な型定義を格納します。例えば、型定義が提供されていないUIフレームワークなどで有用です。以下は、AntDesign の Result UI の status Propsの例です。

// /definitions/index.ts
export enum AntDesignResultStatus {
  'success' = 'success',
  'error' = 'error',
  'info' = 'info',
  'warning' = 'warning',
}

/middleware

middleware には、基本的に各 Vue ファイルでマウント前に共通で実行したい処理を設置します。

middleware には幅広いユースケースがありますが、例えば以下のような認証状態確認ミドルウェアは多くのプロジェクトでよく採用されている実装だと思います。

authenticated.ts

import { Context, Middleware } from '@nuxt/types'
import { getUserId, isAuthenticated } from '~/services/authService'

const myMiddleware: Middleware = async (context: Context) => {
  const { store } = context
  // ユーザーが認証されていない場合はログインページへリダイレクト
  if (!isAuthenticated) return redirect('/login')

  const promise: Promise<Function>[] = []

  // 認証状態かつ現在ログイン中のユーザー情報を未取得の場合は取得
  if (
    isAuthenticated &&
    (store.getters['user/getAbout'] === null ||
      store.getters['user/getAbout'] === undefined)
  )
    promise.push(
      store.dispatch('user/getUserById', {
        Id: await getUserId(),
      })
    )

  // その他認証状態で実行したい処理あればpromise.push

  // 認証状態で実行必須の処理を実行
  await Promise.all(promise)
}

export default myMiddleware

/mockdata

/mockdata ディレクトリーには、API から取得& Store に保持する想定の JS オブジェクトを作成します。例えば、ログイン中のユーザーに関するモックデータは以下のようになります。

// /mockdata/user.ts
import { User, UserStatus } from '~/definitions';
import moment from 'moment';
import { v4 as uuid } from 'uuid';
export const user: User = {
  Id: uuid(),
  Status: UserStatus.InActive,
  LastLoggedInAt: moment().format(),
  CreatedAt: moment().format(),
  UpdatedAt: moment().format()
}

上記のモックデータを Store で取得する処理は以下のような実装となります。API 取得を見越して、400ms ほどスリープを入れています。

// /store/user.ts
import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators'
import { User, GetUserByIdVariables } from '~/definitions';
import * as mockData from '~/mockdata/user';
@Module({
  name: 'user',
  stateFactory: true,
  namespaced: true,
})
export default class UserModule extends VuexModule {
  user?: User | null = null

  get getUser() {
    return this.user
  }

  @Mutation
  setUser(user: User): void {
    this.user = user
  }

  @Action
  async getUserById(
    variables: GetUserByIdVariables
  ): Promise<void> {
    return await new Promise((resolve) => {
      setTimeout(() => {
        this.setUser(mockData.user)
        resolve()
      }, 400)
    })
  }
}

フロントエンドコーディング時、API が既に用意されているケースはあまり多くないので、私たちは通常、上記のような Store を先に作成してからフロントエンドのコーディングを行います。

それによって、API 結合作業は Store の各アクションを API リクエストに差し替えるだけとなり、ほぼ手戻りなく開発することが可能となります。

コーディング規約

基本的に公式ドキュメントの内容に沿ってコーディングするのが大原則となりますが、以下の私たち独自のコーディング規約について紹介をします。

  • プログラミングレギュレーション
  • CSS レギュレーション

プログラミングレギュレーション

  • Lodash を使用した関数型・イミュータブルプログラミング
  • Nuxt Property Decorator を使用し Store、Vue それぞれ class スタイルでの実装
  • Vue ファイルに TODO を作らない

Lodash を使用した関数型・イミュータブルプログラミング

// Bad
let subPriceOfCategory = 0;
products.forEach((item)=>{
  if(item.category === 1) {
    subPriceOfCategory+ = item.price;
  }
});

// Good
import _ from 'lodash';
const products: number = _.chain(products)
                    .filter(item => item.category === 1)
                    .sumBy('price')
                    .value()

Nuxt Property Decoratorを使用し Store、Vue それぞれclass スタイルでの実装

こちらの記事に、Store、Vue ファイルの Class 実装について紹介していますのでぜひ参考にしてみてください。以下の記事では vue-property-decorator での実装を行っていますが、nuxt-property-decorator でもほぼ同じ構文で作成可能です。

Vue ファイルで TODO を作らない

Vue ファイルは、原則として Store からデータを取得し表示、ロジックや SDK の処理を要する場合はサービスの処理を呼び出しておきます。

// VueファイルJS部分の実装例
<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import { Context } from '@nuxt/types'
import PageHeader from '~/components/navigation/PageHeader.vue'
import { productStore } from '~/store'
import { isAuthenticated, getUserId } from '~/services/authService'

@Component({
  components: {
    PageHeader
  },
  async asyncData(context: Context) {
    const { store, route } = context
    const query = { category: route.query.category }
    const promise: Promise<Function>[] = []
    if(await isAuthenticated()) promise.push(
      store.dispatch('userStore/addUserSearchHistory', {
        UserId: await getUserId(),
        Query: query
      }))
    promise.push(store.dispatch('productStore/listProduct', {
        Query: query,
        Limit: 30
      }))
    await Promise.all(promise)
  }
})
export default class Index extends Vue {
  head(){
    return {
      title: '商品一覧',
    }
  }

  get isLoading() {
    return productStore.getIsLoading
  }

  get totalCount() {
    return productStore.getTotalCount
  }

  get productList() {
    return productStore.getProductList
  }
}
</script>

上記の例では、API が開発中の段階ではauthService及びproductStoreTODOを持つことになります。

// authService.ts
import { v4 as uuid } from 'uuid'
export const getUserId = async (): Promise<string> => {
  // TODO 認証トークンからuserId(sub)を取得
  return await new Promise((resolve) => {
    setTimeout(() => {
      resolve(uuid())
    }, 400)
  })
}

export const isAuthenticated = async (): Promise<boolean> => {
  // TODO 認証状態を取得
  return await new Promise((resolve) => {
    setTimeout(() => {
      resolve(true)
    }, 400)
  })
}

こうすることで、Vue ファイルへ手を入れずに API 結合を行うことが可能となり、手戻りを最小に抑えることが可能となります。

CSS レギュレーション

  • SCSS 実装
  • CSS クラス名は BEM を採用
  • Scoped CSS による CSS モジュール実装
  • クラスのネストは最大 3 階層
  • カラーコード、マージン、フォントサイズ、ブレイクポイントは全て変数管理
  • !important の禁止

この中で特に重要なのは、BEM によるクラスの命名CSS モジュールです。従来の WEB 制作等の場合、グローバルにクラス名の衝突を避けるために CSS クラス名の先頭に Prefix を付与するなどして対策する必要がありましたが、CSS モジュールを使用すれば心配いりません。

また、CSS は複雑になりがちな CSS のクラス名も、BEM によるクラスの命名を行うことで、誰にでも扱いやすいクラス名を作成することが可能です。

// Bad
.block .block__elem { color: #042; }
div.block__elem { color: #042; }

// Good
.block__elem { color: #042; }

Husky による Lint &ビルド疎通チェック

Husky は、git push などのイベントで任意の処理を実行できる npm モジュールです。これを使用して、私たちはgit commit時に以下の処理を実行しています。

  • パッケージ初期化及びインストール
  • yarn lint
  • yarn build

以下に、私たちが使用しているgit commit時に実行するスクリプトを紹介します。

.husky/pre-commit

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo "============================================================="
echo "================== 動作チェックプロセス開始 ================="
echo "=============================================================\n"

echo "1. npmパッケージの初期化開始 🍻🍻🍻"
rm -rf "$(dirname "$0")/../node_modules"
yarn install
echo "npmパッケージの初期化正常終了 ✅\n\n"

echo "2. ビルド試験開始 🚀🚀🚀"
rm -rf "$(dirname "$0")/../dist"
yarn build:dev
echo "✅ ビルド正常終了 ✅\n\n"

echo "3. lint開始 👩‍🔬👩‍🔬👩‍🔬"
yarn lint
echo "lint正常終了 ✅\n\n"

まとめ

TypeScript 導入時は、本記事で紹介したようなベース環境の整備を行うことでデグレートの防止・開発効率の向上を図ることができます。これからますます勢いを増していくフロントエンドの TypeScript 開発にぜひぜひ取り組んでみてください。

フロントエンド開発に関するノウハウをブログを通してどんどん公開していきたいと思いますので、今後ともよろしくお願いいたします。

開発相談はお気軽にお問い合わせください。