create-react-app + antd-mobile + TypeScript環境へのStorybook導入

前提

  • create-react-app
  • TypeScript
  • antd-mobile(antdもほぼ同じ方法で対応可能)

Storybookの導入

$ npm i -g @storybook/cli
$ getstorybook

追加されたstoriesフォルダは /src 以下に移動する

TypeScriptの型定義を追加

$ yarn add -D @types/storybook__react @types/storybook__addon-actions

configの追加、変更

.storybook/webpack.config.js を追加する

const genDefaultConfig = require('@storybook/react/dist/server/config/defaults/webpack.config.js')
const tsImportPluginFactory = require('ts-import-plugin')

module.exports = (baseConfig, env) => {
  const config = genDefaultConfig(baseConfig, env)

  config.module.rules.push({
    test: /\.tsx?$/,
    exclude: /node_modules/,
    include: [/src/],
    loader: 'ts-loader',
    options: {
      getCustomTransformers: () => ({
        before: [
          tsImportPluginFactory({
            libraryName: 'antd-mobile',
            libraryDirectory: 'lib',
            style: 'css',
          }),
        ],
      }),
    },
  })
  config.resolve.extensions.push('.ts', '.tsx')

  return config
}

.storybook/config.jsを書き換え

import { configure } from '@storybook/react'

const req = require.context('../src/stories', true, /.stories.tsx$/)
function loadStories() {
  req.keys().forEach(filename => req(filename))
}

configure(loadStories, module)

storiesを.tsxに置き換え

src/stories/index.stories.tsx

import { action } from '@storybook/addon-actions'
import { storiesOf } from '@storybook/react'
import { Button } from 'antd-mobile'
import * as React from 'react'

storiesOf('Button', module)
  .add('with text', () => (
    <Button onClick={action('clicked')}>Hello Button</Button>
  ))
  .add('with some emoji', () => (
    <Button type="primary" onClick={action('clicked')}>
      <span role="img" aria-label="so cool">
        😀 😎 👍 💯
      </span>
    </Button>
  ))

動作確認

$ yarn storybook

モバイル端末のプレビューを追加する

storybook/addons/viewport at master · storybooks/storybook

addons/viewportを使う

$ yarn add -D @storybook/addon-viewport

.storybook/addons.js

import '@storybook/addon-actions/register'
import '@storybook/addon-links/register'
import '@storybook/addon-viewport/register' // 追加

これでモバイル端末サイズでの確認が簡単にできるようになる

f:id:nishaya:20180915191713p:plain

antd-mobileのthemeを変更している場合

.storybook/webpack.config.js が少し複雑になる

const genDefaultConfig = require('@storybook/react/dist/server/config/defaults/webpack.config.js')
const tsImportPluginFactory = require('ts-import-plugin')
const modifyVars = require('../antdTheme')
const path = require('path')

module.exports = (baseConfig, env) => {
  const config = genDefaultConfig(baseConfig, env)

  config.module.rules.push({
    test: /\.tsx?$/,
    exclude: /node_modules/,
    include: [/src/],
    loader: 'ts-loader',
    options: {
      getCustomTransformers: () => ({
        before: [
          tsImportPluginFactory({
            libraryName: 'antd-mobile',
            libraryDirectory: 'lib',
            style: true,
          }),
        ],
      }),
    },
  })
  config.resolve.extensions.push('.ts', '.tsx')

  config.module.rules.push({
    test: /\.less$/,
    use: [
      {
        loader: 'style-loader',
      },
      { loader: 'css-loader' },
      {
        loader: 'less-loader',
        options: { modifyVars },
      },
    ],
    include: path.resolve(__dirname, '../src/', '../node_modules/'),
  })

  return config
}

Ant Design Mobleをcreate-react-app(TypeScript)のプロジェクトで使う

Ant Design Mobileをcreate-react-app(w/ TypeScript)のプロジェクトで使う方法

create-react-appのセットアップに関しては別記事にまとめた

nomusiclife.hatenablog.jp

antd-mobileを追加

$ yarn add antd-mobile

モジュール化されたantd-mobileを利用可能にする

react-app-rewiredでCRAのconfigに変更を加える

$ yarn add -D react-app-rewired react-app-rewire-less ts-import-plugin
$ touch config-overrides.js

config-overrides.js

const { getLoader } = require('react-app-rewired')
const tsImportPluginFactory = require('ts-import-plugin')
const rewireLess = require('react-app-rewire-less')

module.exports = function override(config, env) {
  const tsLoader = getLoader(
    config.module.rules,
    rule =>
      rule.loader &&
      typeof rule.loader === 'string' &&
      rule.loader.includes('ts-loader'),
  )

  tsLoader.options = {
    getCustomTransformers: () => ({
      before: [
        tsImportPluginFactory({
          libraryDirectory: 'lib',
          libraryName: 'antd-mobile',
          style: true,
        }),
      ],
    }),
  }

  config = rewireLess.withLoaderOptions({
    javascriptEnabled: true,
    modifyVars: {}, // https://mobile.ant.design/docs/react/customize-theme
  })(config, env)
  return config
}

react-app-rewiredを有効化するにはnpm scriptsも変更する必要がある

package.json

  "scripts": {
    "start": "react-app-rewired start --scripts-version react-scripts-ts",
    "build": "react-app-rewired build --scripts-version react-scripts-ts",
    "test": "react-app-rewired test --env=jsdom --scripts-version react-scripts-ts",

上記の設定を行うことにより、antd-mobileのからimportするだけでcssも含めてモジュール化されたコンポーネントが利用可能になる

import { Button } from 'antd-mobile'

テーマの変更

Customize Theme - Ant Design

テーマの変更を行う場合は、上で作成したconfig-overrides.jsのmodifyVarsにkey-valueで設定する

以下はプライマリカラーを変更する例

  config = rewireLess.withLoaderOptions({
    javascriptEnabled: true,
    modifyVars: { 'brand-primary': 'red' }, // https://mobile.ant.design/docs/react/customize-theme
  })(config, env)
  return config

設定できる全項目は下記のページから確認できる

github.com

SPA開発時の環境構築

SPAプロジェクトの立ち上げ時にやっていること

create-react-app w/ TypeScript

create-react-appのTypeScript版ボイラープレートをセットアップする

$ npm install -g create-react-app
$ cd project_dir
$ create-react-app . --scripts-version=react-scripts-ts

TSLintの設定変更

別記事にまとめた

nomusiclife.hatenablog.jp

コミット前にTSLint(と、Prettier)

okonet/lint-staged: 🚫💩 — Run linters on git staged files

  • コミット前のファイルにTSLintを適用し、自動修正が可能なものは修正してそのままコミット、修正が不可能ならコミットを中止する
  • ついでにjsonやmdにPrettierを適用する
$ yarn add -D lint-staged husky prettier

package.json

  "scripts": {
    "start": "react-scripts-ts start",
    "build": "react-scripts-ts build",
    "test": "react-scripts-ts test --env=jsdom",
    "eject": "react-scripts-ts eject",
    "precommit": "lint-staged"
  },
  "lint-staged": {
    "*.{js,json,md}": [
      "prettier --write",
      "git add"
    ],
    "*.{ts,tsx}": [
      "tslint --fix",
      "git add"
    ]
  },

ts(x)やmdをコミットすると以下のようにタスクが実行され、ファイルがコミットされる f:id:nishaya:20180915102333p:plain

Dockerでローカル開発用のMySQL DBを用意する

ローカルで開発を行う際、プロジェクト毎に独立したDBを使いたいのでDockerで立てる

config/database.yml

default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  ? password
  host: 127.0.0.1
  port: 13306

docker-compose.yml

version: "3"
services:
  db:
    image: mysql:5.7
    volumes:
      - db:/var/lib/mysql
    ports:
      - 13306:3306
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"

volumes:
  ? db
  • $ docker-compose up で立てて 127.0.0.1:13306 として使う
  • GUIクライアントからも接続可

オンライン同期可能なシーケンサーを作った話 後編

今回作ったもの

DEMO
ソースコード

詳しい話は前編を参照

nomusiclife.hatenablog.jp

Firebase

個人開発の強い味方、Firebase。
HostingやAuthも使っているが、今回はデータの保存と同期のため、Cloud Firebaseを重点的に使った。

Cloud Firestore

Firebaseには従来からあるRealtime Databaseと
新しく追加されたCloud Firestoreという2つのデータベースがあり、今回は後者のCloud Firestoreを使った。

Realtime DatabaseとCloud Firestoreの違いは下記にまとまっている

データベースを選択: Cloud Firestore または Realtime Database  |  Firebase

Snapshot Listener

Cloud Firestoreではドキュメントへの変更を検知するために onSnapshot でスナップショットリスナーのコールバックを登録することができる。

const doc = firebase.firestore().doc(`/sessions/${sessionId}`)
doc.onSnapshot((changed: firebase.firestore.DocumentSnapshot) => {
  const data = changed.data() as Session
  actions.updateSession(data) // Reduxのstoreを更新
})

コールバックは以下の条件で発火する。

  • リッスンしているドキュメントに対してリモートで変更が行われる
  • ドキュメントをローカルで更新する

これが、Firestoreにおける同期やオフライン対応の基本となる。

データフローの変更

当初は、ローカルで行った操作の結果をリモートに送信する。つまり、Redux Stateにて行われた変更をCloud Firestoreに同期する想定だった。

f:id:nishaya:20180911133711p:plain

しかし、Snapshot listenerがローカルへの変更に対し即時に発火することがわかったのでフローを変更し、

  1. Firestoreドキュメントを変更
  2. ドキュメントのonSnapShotコールバックが発火
  3. コールバックからReduxのactionをdispatch
  4. Redux stateの変更をmapStateToPropsでpresentational componentに伝播させる
  5. リモートのCloud Firestore DBへの同期を行う

とした。

f:id:nishaya:20180911133725p:plain

DBへの書き込みは非同期で行われ、クライアントはドキュメントの更新が成功したものとして動作するため、楽観的UIの実現が可能になる。

追加実装なしでマルチクライアント同期を手に入れる

上記の変更により、マルチクライアント間の同期を追加実装なしで手に入れることができた。

f:id:nishaya:20180911133737p:plain

ドキュメントを変更したクライアントから、変更内容がFirestore DBに同期されると、同じドキュメントをlistenしている別のクライアントでもonSnapShotコールバックが発火し(7)、Redux stateの変更(8)を経てpresentational componentに変更内容が伝わる。

ただし、書き込みしすぎに注意

Firestoreでは、ドキュメントへの書き込みに1秒に2回までという制限がありスライダー等、連続的変化を伴うUIによる値の変化を逐次ドキュメントへのupdateで反映するとすぐにrate limitに達してしまう。

f:id:nishaya:20180911133931p:plain

そのため、UIへの反映とドキュメントの更新タイミングは、

  • 変更を適度に間引く
  • 一定期間中の変更を1回のupdateにまとめる
  • スライダーを動かしている最中の変更はRedux Stateに直接反映し、スライダーが止まったときの値のみをドキュメントに反映

といった工夫が必要になる。

基本的な戦略としては、永続化される必要のない変更をRedux Stateに直接渡し、永続化が必要な変更のみをドキュメントに反映させることになる。

TypeScript

TypeScriptは create-react-app-typescript を使用して導入した。

User-Defined Type Guard

シンセのプリセットごとに表示させるコンポーネントを切り替えるため、実行時型チェック用にUser-Defined Type Guardを用意した。

Advanced Types · TypeScript

export type SynthPreset = OscSynthPreset | FmSynthPreset | DrumsSynthPreset

export interface BaseSynthPreset {
  type: SynthPresetType
}

export function isOscSynthPreset(v: any): v is OscSynthPreset {
  return v.type === 'osc'
}

export interface OscSynthPreset extends BaseSynthPreset {
  type: 'osc'
  oscillator: OscillatorType
  aeg: ADSR
  cutoff: number
  resonance: number
}

所感

  • マルチクライアント間の同期を前提とする場合、クライアント内のデータフローを再考する必要がある
    • Firebase Cloud Firestoreは近道の一つ
    • APIがGraphQLであれば、Apollo Clientを使って同じようなパターンの実装ができそう
  • TypeScriptはいいぞ
    • これ以降に作ったプロダクトは全てTypeScriptを使うようになったのだが、それについてはまた別の機会に書く

TSLint 普段使っている最小限の設定

プライベートで何か作ったりするときの TSLint config についてまとめておく。

tslint.json

{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts",
      "coverage/lcov-report/*.js"
    ]
  },
  "rulesDirectory": ["tslint-plugin-prettier"],
  "rules": {
    "no-default-export": true,
    "interface-name": false,
    "ordered-imports": true,
    "jsx-no-lambda": false,
    "object-literal-sort-keys": false,
    "prettier": true
  }
}

rules

create-react-app のデフォルトからあまり触っていないが、以下の rules を弄っている。

  • no-default-export: named export を強制するため true にしている
  • interface-name: 大文字 I から始まる interface 名を禁止
  • ordered-imports: インポート順をアルファベット順にして探しやすいように true
  • jsx-no-lambda: Query Component pattern などで使うので false
  • object-literal-sort-keys: object の key はアルファベット順よりも重要度で並べたり、分類ごとにまとめたりしたいので false
  • prettier: tslint-plugin-prettier をインストール($ yarn add -D tslint-plugin-prettier)し "rulesDirectory": ["tslint-plugin-prettier"] を追加して true にするとプロジェクトの .prettierrc(.json) が効くようになる

メンバーの多いプロジェクトでは、さらに必要に応じてrulesを追加したり無効化したりする

Visual Studio CodeでReact Appを開発する際の準備

Visual Studio Codeを使ってReact Appの開発を行う際に、
最低限追加しておきたいextensionsと設定をまとめておく

前提

プロジェクトでは以下を使用するものとする

  • create-react-app
  • TypeScript
  • styled-components

extensions

TSLint

TSlintの結果をインライン表示する。

settingsに以下を追加すると(修正可能なものは)ファイル保存時に自動的に修正されるようになる

"tslint.autoFixOnSave": true,

Prettier - Code formatter

Prettierによるコードフォーマットを行う。

  • formatOnSave: 保存時にフォーマット
  • requireConfig: プロジェクトに .prettierrc(.json) が存在する場合のみフォーマットを行う
"editor.formatOnSave": true,
"prettier.requireConfig": true,

Jest

コードの変更によって影響を受けたテストを即時実行できるようにする

vscode-styled-components

styled-componentsCSSをハイライトする

settings

ロケールの変更

TypeScriptの警告は日本語だと読みづらく、検索効率も悪いため英語にしておく

Visual Studio Code の表示言語を英語に戻した

CLI "code" のインストール

以下に記載された手順により code で起動できるようになる

Running Visual Studio Code on macOS

$ cd project_dir
$ code .

以上

思いついたら追記するが、他は好みで導入するのがよいと思っている