30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

standard-version と commitlint で npm パッケージのリリース管理を省力化する

standard-versionというライブラリを使うことで、リリース管理に伴う作業のいくつかを自動化できる。
具体的には以下の内容。

  • package.jsonversionフィールドの値の更新
  • CHANGELOG.mdの更新
  • 更新したpackage.jsonCHANGELOG.mdのコミット
  • Git のタグを打つ

これらを手作業で行うのは不毛だなと以前から思っていたので、standard-versionを導入した。

この記事ではstandard-versionの基本的な使い方を紹介する。
また、このライブラリを使うためにはコミットメッセージが重要になるので、コミットメッセージをチェックするためのcommitlintについても紹介する。

この記事を書くにあたり、以下のバージョンで動作確認をした。

  • standard-version@6.0.1
  • @commitlint/config-conventional@8.0.0
  • @commitlint/cli@8.0.0

standard-version を導入する

適当なプロジェクトを作って、standard-versionが何をしてくれるのかを見ていく。

空行だけのindex.txtと、以下の内容のpackage.jsonを用意する。

{
  "name": "sample",
  "version": "1.0.0",
  "scripts": {
    "release": "standard-version"
  },
  "devDependencies": {
    "standard-version": "^6.0.1"
  }
}

$ yarnしてstandard-versionをインストールした状態で、以下のようにコミットする。

$ git commit -m "feat: initial commit"

そして、$ yarn run release --first-releaseを実行する。

すると、以下の内容のCHANGELOG.mdが生成され、コミットされる。

# Changelog

All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.

## 1.0.0 (2019-07-10)


### Features

* initial commit 03cbea8

さらに、直近のコミットに対してv1.0.0というタグが打たれている。
なので現時点でのコミット履歴は以下の通り。

8d5cd91 (HEAD -> master, tag: v1.0.0) chore(release): 1.0.0
03cbea8 feat: initial commit

さらにコミットを重ねるためにindex.txtを更新する。

diff --git a/index.txt b/index.txt
index e69de29..e2e3369 100644
--- a/index.txt
+++ b/index.txt
@@ -0,0 +1 @@
+patch のテスト。

コミットとreleaseを行う。

$ git commit -m "fix: update index.txt"
$ yarn run release

すると、CHANGELOG.mdpackage.jsonが更新され、コミットされる。

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8b6241..19a4565 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
 
 All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-versio
n) for commit guidelines.
 
+### [1.0.1](///compare/v1.0.0...v1.0.1) (2019-07-10)
+
+
+### Bug Fixes
+
+* update index.txt f981c9c
+
+
+
 ## 1.0.0 (2019-07-10)
 
 
diff --git a/package.json b/package.json
index b2941ae..31a47e3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "sample",
-  "version": "1.0.0",
+  "version": "1.0.1",
   "scripts": {
     "release": "standard-version"
   },

タグも打たれ、コミット履歴は以下のようになる。

$ git log --oneline
15db18f (HEAD -> master, tag: v1.0.1) chore(release): 1.0.1
f981c9c fix: update index.txt
8d5cd91 (tag: v1.0.0) chore(release): 1.0.0
03cbea8 feat: initial commit

standard-version がやっていること

standard-versionを実行すると、以下の4つを行う。

  1. package.jsonのバージョンを更新する
  2. CHANGELOG.mdを更新する
  3. package.jsonCHANGELOG.mdをコミットする
  4. タグをつける

だが、--first-releaseオプションを付けると1のバージョンアップは行わず、現行のpackage.json#versionの値のまま2以降を行う。
そのため、CHANGELOG.mdを最初から作るときにのみ--first-releaseオプションを付け、二回目以降、あるいは既にCHANGELOG.mdが存在する場合は、このオプションは付けない。

バージョンアップと CHANGELOG.md 生成のルール

コミットメッセージに接頭辞をつけることで、その接頭辞に応じてバージョンアップが行われる。
例えばfeatはマイナーアップデート、fixはパッチアップデートになる。
また、feat!のように接頭辞の後ろにエクスクラメーションマークをつけると、それは破壊的変更を含むことを意味し、メジャーアップデートになる。
choredocsでコミットした内容はCHANGELOG.mdには反映されない。

そのため、上述のサンプルの続きとして次のようなコミットをして$ yarn run releaseすると、バージョンは2.0.0になる。
CHANGELOG.mdにはfe90828についてのみ記載され、choreである1f1e208については何も書かれない。

fe90828 feat!: new feature
1f1e208 chore: library install
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19a4565..48f573b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
 
 All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
 
+## [2.0.0](///compare/v1.0.1...v2.0.0) (2019-07-10)
+
+
+### Features
+
+* new feature fe90828
+
+
+### BREAKING CHANGES
+
+* new feature
+
+
+
 ### [1.0.1](///compare/v1.0.0...v1.0.1) (2019-07-10)

コミットのルールは Conventional Commits に準拠している。

www.conventionalcommits.org

Git のリモートリポジトリが登録されていれば、CHANGELOG.mdに記述されるコミット番号(上記のfe90828など)に、リモートリポジトリ上の当該コミットにリンクが張られる。

公式ドキュメントでは、プルリクエストのマージはSquash and Mergeで行うことを推奨している。
そうすると、CHANGELOG.mdの各項目に、当該プルリクエストへのリンクが自動的に生成される。

各種オプション

--dry-runオプション

--dry-runオプションをつけると、実際には何の変更も行うことなく、releaseしたときに何が行われるのかを確認することが出来る。

-tオプション

デフォルトでは、生成されるタグはバージョンにvという接頭辞がついたものになる。
この接頭辞は、-tオプションの引数で指定することが出来る。空文字を指定すれば(-t '')、接頭辞はつかずにバージョン番号のみのタグになる。

--release-asオプション

接頭辞に応じてバージョンアップしていくことは前述したが、--release-asオプションを使えば、次のバージョンを自分で指定することが出来る。

例として$ yarn run release --release-as 4.2.2 -t fooを実行すると、次のバージョンは4.2.2になり、foo4.2.2というタグが打たれる。

commitlint

standard-versionでのリリース管理は、コミットメッセージが重要になる。
コミットメッセージが正しくなければ上手く運用することは出来ない。

コミットメッセージが Conventional Commits に準拠しているか確認できるツールとして、commitlintがある。
commitlintはその名の通りコミットメッセージを対象とした Lint であり、コミットメッセージがルールに沿っているかチェックすることが出来る。この記事ではデフォルトのまま使うが、ルールの編集も当然行える。

ライブラリのインストールと、設定ファイルの出力を行う。

$ yarn add -D @commitlint/{config-conventional,cli}
$ echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

npm scripts"commitlint": "commitlint"を追加する。

$ yarn run commitlint --from=コミットIDを実行すると、指定したコミットののコミットから直近のコミットまでを、チェックする。

例えば、以下のようなコミットログになっているとする。

1d35a35 (HEAD -> master) feat: good commit 2
03b72d7 fix: good commit
b3cf4bc bad commit
a7eba0d feat: commitlint を導入した。

この状態で$ yarn run commitlint --from=b3cf4bcとすると、何もエラーは出ない。
$ yarn run commitlint --from=a7eba0dとすると、b3cf4bcもチェックの対象になり、エラーが出る。

⧗   input: bad commit
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings

コミットIDの代わりにタグを指定することも出来るし、--from=masterのようにブランチを指定することも可能。

React Hooks + Redux Hooks + TypeScript で SPA を構築する(追記あり)

2020/05/31 追記

勉強や経験を重ねた結果、この記事を執筆した時より知識が増え、コードの書き方にも変化があります。
サンプルアプリも同様で、以下のプロダクトのコードのほうが、今の自分の考えが反映されていると思います。

github.com

追記終わり

2019/07/14 追記

ディスカウント後の価格みたいな導出項目はselector (reselect)を使うとよいのでは https://redux.js.org/recipes/computing-derived-data - YonmanHasse のブックマーク / はてなブックマーク

というコメントを頂き、確かに便利そうだったので導入した。
それに合わせてこの記事の内容もアップデートした。

追記終わり

タイトルに書いた組み合わせで SPA を作るときにどのような設計にするのか、現時点での考えを記録しておく。
チュートリアルの次の一歩というか、現実的なアプリを構築する際の基本となる考え方。
状態管理とどう向き合うかが、主要なテーマになっている。

サンプルアプリ

具体的なコードがないと話を進められないので、サンプルアプリを作成した。

github.com

ページは2つあり、「会員種別選択ページ」と「商品選択ページ」からなる。

まず「会員種別選択ページ」で、「プレミアム会員」か「通常会員」かを選ぶ。
「プレミアム会員」の場合は「認証コード」が必要で、正しいコードを入れないと先に進めない。

次に「商品選択ページ」で商品を選ぶ。
「プレミアム会員」だと、10%ディスカウントされるだけでなく、選べる商品も増える。
商品を1つ以上選択すると「購入する」ボタンを押せる。

それ以上の細かい説明については、必要に応じて行っていく。

使っている主なライブラリのバージョンは以下の通り。

  • react@16.8.6
  • react-dom@16.8.6
  • redux@4.0.1
  • redux-thunk@2.3.0
  • react-redux@7.1.0
  • reselect@4.0.0
  • react-router-dom@5.0.1
  • typescript@3.5.2

APIについては、モックサーバーを作るのは面倒だったのでPromise.resolveで擬似的に再現している。

全体像

原則としてReact Hooksを使い、クラスコンポーネントは使わない。
業務でそこそこの規模の SPA を開発しているが、クラスコンポーネントが必要になったことはほぼ無い。

ドメインモデルに相当するものはReduxで管理し、ビューに関する状態はuseStateで管理する。
ちなみに、useReducerは使い所が難しいと思っている。
Reduxを使うのは大仰に思えるケースでuseReducerを使ったことがあるが、却って複雑になって苦しむことになった。Async Actionの対応、ReduxにおけるcombineReducersに相当するものの用意、そういったことを自分で行う必要がある。そしてそうすると、型定義がどんどん面倒になってくる。
無駄に遠回りしているような気がしてくるし、素直にReduxを使っておくのが身のためだと思う。自前で仕組みを用意すると他の開発者が理解しづらくなる、というデメリットもある。
簡単なログインフォームくらいであればuseStateで済むし、useReducerを使うべきケースというのはかなり限定的ではないだろうか。

コンテナコンポーネントは、ルートコンポーネントであるsrc/components/App.tsxのみ。
このサンプルの規模なら当然そうなるが、規模が大きくなっても、コンテナコンポーネントは出来るだけ少なくしたい。

もちろん、コンテナコンポーネントはいくつあってもいいし、コンポーネントツリーのどこにあってもよい。
Reduxの公式ドキュメントにも次のように書かれている。

Emphasizing “one container component at the top” in Redux examples was a mistake. Don't take this as a maxim.

Should I only connect my top component, or can I connect multiple components in my tree?

しかし個人的には、コンポーネントは出来るだけコンテナコンポーネントではなくプレゼンテーションコンポーネントにしておきたい。
コンポーネントを純粋関数にしておいたほうが保守が楽だし、再利用性やテストのしやすさという観点から見ても、Reduxとは接続せずに単にpropsを受け取るだけにしておきたい。
もちろん、「単一のコンテナコンポーネント」に拘泥すると却って煩雑になってしまう(propsバケツリレーの多発など)ので、必要に応じて複数のコンテナコンポーネントを作ってはいくが。

react-reduxv7.1で導入されたuseSelectoruseDispatchを使っている。
connectを使うパターンに比べて、かなり直感的にコンポーネントとstoreを接続できる。

TypeScript はとにかく素晴らしい。
SPA 開発の技術選定の記事に対して、「とにかく TypeScript を入れろ」という力強い意見をいくつか頂いた。

素直にそれに従ってみたが、大正解だった。
VSCode の補完機能が優秀だし、ちゃんと型を書いておけば、間違ったコードを書いた時にエラーを出してくれる。これは本当に精神的に楽になる。脳内メモリは有限なので、「自分の頭で考え、注意を向けておかないといけない要素」を減らせるのはかなり助かる。
まだ TypeScript には習熟していないのだが、それでも既に大きな恩恵を受けている。

状態管理

Reduxに関連するコードはsrc/storeディレクトリに入れてある。

ドメインでファイルを分割し、同じドメインのAction CreatorReducerは一箇所にまとめている。いわゆる「Ducks パターン」。
このパターンを採用したことにそれほど深い理由はない。単に一箇所にまとめたほうが見通しがよいから。
今後「Re-ducks パターン」などを調べて、そっちのほうがよさそうであればそれを採用する予定。

このサンプルではmemberproductsという2つのドメインがあり、それをsrc/store/index.tsで一つにまとめている。

各ドメインのファイルでは、次の内容を定義している。例はsrc/store/products.tsから紹介している。

  • ドメインオブジェクトの型定義
    • 例)Product
  • stateの型定義
    • 例)ProductsState
  • ActionTypesの型定義(enum型)
    • 例)ActionTypes
  • Action Creatorが発行するActionの型定義
    • Actionを継承する形で定義する
    • 例)UpdateSelectedProductIdsAction
  • そのドメインのActionの集合
    • Reducerの引数の型に使っている
    • 例)ProductsActions
  • Action Creatorの型定義
    • 例)UpdateSelectedProductIds
  • stateの初期値
    • 例)initialState
  • Action Creator
    • 例)updateSelectedProductIds
  • Reducer
    • 例)products
  • stateを元に値を計算して返すビジネスロジック(導出項目)
    • 例)extractPurchasableProductList
  • 引数としてstateの情報を必要とするが、Actionを発行するわけではない関数(導出項目?)
    • 例)requestPurchase
  • 引数としてstateを受け取って導出項目を返すセレクタ関数
    • 例)productsDerivedDataSelector

最後の3つについては、後述の「ビジネスロジック」のセクションで詳しく説明する。

Async Actionの対応はredux-thunkで行っている。

腐敗防止層

Action Creatorは腐敗防止層としての役割も担っている。
具体的には以下の作業を行っている。
src/store/products.tsfetchProductListを例に説明していく。

  • 必要な値を取捨選択する
    • fetchProductsApimerchant_idを返すが、フロント側ではこの値は必要ないので保存しない。
  • プロパティ名の調整
    • APIからの返り値はスネークケースであることも多いので、ここでローワーキャメルケースに変換する
    • fetchProductsApiはプロダクトの名前をなぜかnamaeというローマ字で返しているので、nameに変換する
  • データ構造などの調整
    • namaeの例は命名が適切ではないというケースだが、それ以外にも、構造がおかしい、バックエンドの実装を見ないと絶対に意味が分からないようなマジックワードを返している、などのケースも有り得るので、その調整もここで行う。

Action CreatorはAPIからのレスポンスを吸収しているが、APIへのリクエストのための調整も、一箇所に留める。
src/api.tspurchaseApiがそれにあたる。フロントエンドが持っているデータを、APIが求める形式に変換する。

APIとの調整はこれらの箇所に封じ込め、それ以外の箇所ではAPIとの調整を意識しないで済むようにする。
どのレイヤーで対応するか予め決めておき、APIの腐敗がフロントエンド全体に漏れ出すのを防ぐ。

ビジネスロジック

Reduxを使う場合、ビジネスロジックはどこに書けばよいのか。
ネットで調べてみたが、特に決まってなさそうである。
Action Creatorという意見が多い印象だけど、Reducerに書いているケースもある。

Reducer派が掲げる最大の理由は恐らく、ビジネスロジックがstateを必要とするから、というものだと思う。
Action Creatorで使える値は、ビューから渡された引数のみ。
だから、ビジネスロジックがたくさんの値を必要とする場合、Action Creatorの引数が増えてしまうし、Action Creatorに渡すためにビューがデータを持たないといけない。
Action Creatorの引数として渡すためだけにビューが多くのデータを持つのは、違和感を覚えてしまう。
Reducerなら、そういう面倒なことをせずにstateにアクセスできる。

しかし、Reducerがロジックを持つのは、本来の責務から逸脱しているように思えてしまう。
それに、ドメインを跨ぐ場合、例えばproductsReducermemberstateを必要とする場合などは、結局そのための対応が必要になる。

結論としては、Reducerにはビジネスロジックを入れないようにした。
また、上記の「ドメインを跨ぐ場合」については、ReducerAction Creatorもそういうケースには対応しないようにした。ただこれは、意図してそうしたわけではなく、結果的にそうなった。

意図したのは、「余計な値をstateに保存しないようにする」ということ。
例えば、各商品の価格は保存するが、ディスカウント後の価格は保存しない。
両方を保存してしまうと、値の二重管理のようになってしまう。一方の値が更新されたのにもう一方の値は古いまま、のような事故が起きかねない。
元の価格さえ分かっていればディスカウント後の価格も定まるのだから、わざわざ保存しないようにする。

この「ディスカウント後の価格」のように、他の値から算出される値を「導出項目」という。

では、導出項目のように、stateに保存すべきではない値を使いたい場合は、どのようにすればいいのか。
言い換えれば、目的の値を算出するためのロジックをどこに書き、どのようにそれを呼び出すのか。
これは結構悩ましいというか、現時点では私は答えを持っていない。
いくつかの選択肢が思い浮かぶ。

  1. Reducerでロジックを持つ
    • これはさっき否定した。
  2. 値を必要とするコンポーネントのなかで計算する
    • 再利用性が乏しく、ロジックが複数の場所に散らばってしまう。
    • ビジネスロジックがビューに露出してしまうのは望ましくない。
  3. Vuexgetterのようなレイヤーを作る
    • stateとは別にgetterを作って、その中にロジックを書くイメージ。
    • だがこれは、余計な複雑さを抱え込むことになるし、型をつけるのも大変になる。
  4. ロジックは決められた場所に書き、呼び出しは自由に行う
    • 単なる関数として定義して、それを任意の場所で呼び出す。
    • 副作用のない純粋関数であることを徹底すれば、破綻しにくいように思う。
  5. 引数としてstateを受け取り、算出した値を返すセレクタ関数を定義する
    • 4の派生形というか、バラバラに書いていた処理を1つの関数にまとめたもの。

結局4で行くことにした。積極的に選んだというより、思いついたなかで一番マシに思えたから。
置き場所は、sr/store/直下の各ドメインのファイル。
src/store/products.tsextractPurchasableProductListcalculateTotalPriceがこれにあたる。

「引数としてstateの情報を必要とするが、Actionを発行するわけではない関数」も、同じように扱う。
src/store/products.tsrequestPurchaseがこれにあたる。

5を使う形に書き換えた。具体的には、src/store/products.tsproductsDerivedDataSelectorというセレクタ関数を定義した。
サンプルではreselectというライブラリを使っているが、設計という観点から見たときに重要なのはそこではなく、「stateを引数として受け取り、導出項目のセットを返す関数」を定義してそれを利用することが本質。

requestPurchaseproductsDerivedDataSelectorの返り値に含めるようにした。
関数を「導出項目」のように扱うのには違和感があるが、requestPurchaseだけ扱いを変えると余計な複雑さが生まれそうなので、まとめることにした。

コンポーネント

ルートコンポーネントはsrc/components/App.tsx
このコンポーネントが、Redux Hooksを使ったstoreとの接続や、react-routerによるルーティングなどを、行っている。
もっと規模が大きくなってきたら、ルーティングについては分離させたほうがいいと思う。

productsDerivedDataSelectorによる導出項目の取得も、ここで行っている。

他には、useFetchApiを定義している。
これは、コンポーネントのマウント時にAPIを叩くためのCustom Hooks
マウントした時にのみデータ取得のためのAPIを叩く、という本当によくある処理。その処理を全てのコンポーネントで書くのは無駄なので、useFetchApiとしてまとめた。
ルーティングのためにURLを定数として持っているので、それを使うことで綺麗に書けたと思う。

全てのコンポーネントはFunctional Componentであり、前述のようにReact Hooksを使っている。useStateuseEffectがあれば、基本的な機能は実装できるはず。

参考資料