React HooksのTypeScriptによる型付け

比較的よく使うuseState/useReducer/useContextのTypeScriptによる型付け

Hooksの詳細についてはこちらを参照
Hooks API Reference – React

useState

import * as React from 'react'

export const UseStateExample = () => {
  const [count, setCount] = React.useState<number>(0)

  return (
    <div>
      <div>count: {count}</div>
      <div>
        <button onClick={() => setCount(count + 1)}>++</button>
      </div>
    </div>
  )
}

// 複数の値を1つのstateにまとめる場合
interface State {
  count: number
  message: string
}

const initialState: State = {
  count: 0,
  message: 'hi',
}

export const ComplexStateExample = () => {
  const [state, setState] = React.useState<State>(initialState)
  const { count, message } = state

  return (
    <div>
      <h1>{message}</h1>
      <div>count: {count}</div>
      <div>
        <button
          onClick={() =>
            setState({
              ...state,
              count: count + 1,
            })
          }
        >
          ++
        </button>
      </div>
    </div>
  )
}

useReducer

import * as React from 'react'

interface State {
  count: number
}

interface IncrementAction {
  type: 'increment'
}
interface ChangeAction {
  type: 'change'
  number: number
}
interface ResetAction {
  type: 'reset'
}
type Action = IncrementAction | ChangeAction | ResetAction

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'change':
      return { count: action.number }
    case 'reset':
      return { count: 0 }
    default:
      return { ...state }
  }
}

const initialState: State = {
  count: 0,
}

export const UseReducerExample = () => {
  const [state, dispatch] = React.useReducer(reducer, initialState)

  return (
    <div>
      <div>count: {state.count}</div>
      <div>
        <button onClick={() => dispatch({ type: 'increment' })}>++</button>
        <button onClick={() => dispatch({ type: 'change', number: 5 })}>
          to 5
        </button>
        <button onClick={() => dispatch({ type: 'reset' })}>reset</button>
      </div>
    </div>
  )
}

useContext

import * as React from 'react'

interface ExampleContextValue {
  message: string
  count: number
}

const initialContextValue: ExampleContextValue = {
  message: 'hello',
  count: 100,
}

const ExampleContext = React.createContext<ExampleContextValue>(
  initialContextValue,
)

export const UseContextExample = () => {
  const { message, count } = React.useContext(ExampleContext)
  return (
    <div>
      {message}: {count}
    </div>
  )
}

NetlifyでSPAのfallbackを行う

/ 以外でリロードすると404になってしまうのを防ぐため、
netlify.tomlにfallbackの設定を追加した。

Redirects | Netlify

netlify.toml

# COMMENT: This a rule for Single Page Applications
[[*redirects*]]
  from = "/*"
  to = "/index.html"
  status = 200

おまけ

Visual Studio Codeでtomlを扱いやすくするためにBetter TOMLを導入した

Better TOML - Visual Studio Marketplace

Google Cloud BuildでSPAのビルドとFirebase Hostingへのデプロイを自動化する

この記事はSpeee Advent Calendar 201819日目の記事です。
前日はyuta_kobayashiによる雑談の効用についてのお話でした。

tech.speee.jp

今日はSPAの継続的デリバリーを手軽に実現できる、
Google Cloud Buildのお話です。

やること

最近、ちょっとしたSPAのデプロイ先としてFirebase Hostingを使うことが多くなってきたが、ローカルでFirebaseプロジェクトの設定をしたり、ビルドやデプロイを手動で実行するのが面倒なので最初にCloud Buildを設定してビルドとデプロイを自動化するようにしている。

  • GitHubリポジトリのmasterブランチにコードをpushしたら
  • Cloud BuildでSPAをビルドして
  • Firebase Hostingにデプロイする

以上を自動化する。

参考ドキュメント

ビルドトリガーを使用したビルドの自動化  |  Cloud Build  |  Google Cloud

準備

  • Firebaseで新規プロジェクトを作成
  • アプリケーションはcreate-react-appで適当に作っておく

Firebaseのセットアップ

firebase-cli を使ってセットアップする

// Firebase CLIでログイン
$ firebase login

// SPAのプロジェクトディレクトリでfirebaseを初期化
$ firebase init

// hostingを選択
? **Which Firebase CLI features do you want to setup for this folder? Press Space**
❯◉ Hosting: Configure and deploy Firebase Hosting sites

// buildディレクトリの内容をdeployする
? **What do you want to use as your public directory?** (public) build

// SPAの設定
? **Configure as a single-page app (rewrite all urls to /index.html)?** (y/N) y

デプロイの確認

念のため、Firebase Hostingにローカルからデプロイできることを確認しておく

$ yarn build
$ firebase deploy

Cloud Buildの設定

APIの有効化

コンソールにアクセスしてCloud Build APIを有効にする

FirebaseはGCPと統合するとBlazeプラン(従量)に上がってしまうため、Firebaseのプロジェクトと別にプロジェクトを作ってからCloud Build APIの有効化を行うとよい。

設定

ビルドトリガーの作成

「トリガーの作成」からビルドトリガーを作成する。

f:id:nishaya:20181010141637p:plain

cloudbuild.yamlの作成

cloudbuild.yamlを追加する。

ひとまずビルドの動作確認のため、yarn installyarn build の設定のみ書いてcommitし、masterにpushする

steps:
  - name: 'gcr.io/cloud-builders/yarn'
    args: ['install']
  - name: 'gcr.io/cloud-builders/yarn'
    args: ['build']

pushして確認

cloudbuild.yamlをcommit、pushしてトリガーの確認を行う。

Cloud Buildのビルド履歴から、ビルドの結果が閲覧できる。 各ビルドステップがグリーンになっていればトリガーが発動し、ビルドが成功している。

f:id:nishaya:20181010141707p:plain

Firebaseデプロイのビルドステップを追加する

ここを参考にした

github.com

Firebaseのイメージを作成してgcr.ioにsubmitしておく

$ git clone https://github.com/GoogleCloudPlatform/cloud-builders-community.git
$ cd cloud-builders-community/firebase/
$ gcloud builds submit --config cloudbuild.yaml .

Firebaseトークンの取得

firebase-cliを使ってCI用のトークンを取得する

$ firebase login:ci
...
✔  Success! Use this token to login on a CI server:

[GENERATED_TOKEN] // 生成されたトークンが表示される

// 後で使うのでexportしておく
$ export FIREBASE_TOKEN=[GENERATED_TOKEN]

KMSでトークンを暗号化

KMSの有効化

https://console.cloud.google.com/security/kms の「セットアップ」でAPIを有効にする

Cloud BuildのサービスアカウントにKMS復号の権限を追加

f:id:nishaya:20181010143112p:plain

トークンの暗号化

$ gcloud auth login
$ gcloud config set project [PROJECT_ID]
$ gcloud kms keyrings create cloudbuilder --location global
$ gcloud kms keys create firebase-token --location global --keyring cloudbuilder --purpose encryption
$ echo -n $FIREBASE_TOKEN | gcloud kms encrypt \
  --plaintext-file=- \
  --ciphertext-file=- \
  --location=global \
  --keyring=cloudbuilder \
  --key=firebase-token | base64
[ENCRYPTED_TOKEN]

表示されたトークンをコピーしておく

cloudbuild.yamlにステップを追加

steps:
  - name: 'gcr.io/cloud-builders/yarn'
    args: ['install']
  - name: 'gcr.io/cloud-builders/yarn'
    args: ['build']
  - name: 'gcr.io/$PROJECT_ID/firebase'
    args: ['deploy', '--project=$PROJECT_ID']
    secretEnv: ['FIREBASE_TOKEN']
secrets:
# kmsKeyNameの中では$PROJECT_IDが使えないので直接書く必要あり
- kmsKeyName: 'projects/[PROJECT_ID]/locations/global/keyRings/cloudbuilder/cryptoKeys/firebase-token'
  secretEnv:
    FIREBASE_TOKEN: '[ENCRYPTED_TOKEN]' # ここにさきほどコピーしたトークンをペースト

余談: create-react-appの環境変数

ビルドトリガーにはsubstitution(代入変数)という形で変数を定義できるが、そのまま環境変数としてロードされるわけではないので env を使ってsubstitution -> 環境変数への変換を行う必要がある

f:id:nishaya:20181010143250p:plain

ビルドトリガーで上記のようにsubstitutionを設定し、cloudbuild.yamlで下記のようにして利用する

steps:
  - name: 'gcr.io/cloud-builders/yarn'
    args: ['install']
  - name: 'gcr.io/cloud-builders/yarn'
    args: ['build']
    env: # substitutionをenvに渡す
      - 'REACT_APP_GOOGLE_MAPS_API_KEY=$_GOOGLE_MAPS_API_KEY'
...
substitutions:
  _GOOGLE_MAPS_API_KEY: your-api-key

確認

以上の設定を行い、cloudbuild.yamlをcommitしてGitHubにpushすればビルドが開始される

f:id:nishaya:20181010143230p:plain

まとめ

現在実運用しているプロジェクトでは、複数のビルドトリガーを用意し、
devブランチへのpushを開発環境に即反映、releaseブランチにpushしたら本番にデプロイといった運用をしている。

ビルドトリガーによって別の cloudbuild.yaml を設定することもできるため、環境に応じて異なるビルドステップを設定することも可能だ。

最近はGitHub Actionsが登場し、同じようなことが実現できるようになったので、そちらも近いうちに試してみたい。


明日はiida-hayatoによるハッカソンの話
お楽しみに!

TypeScriptで書いたCloud FunctionsをCloud Buildでビルド&デプロイする際の設定

何?

Cloud FunctionsをTypeScriptで書き、かつデプロイを自動化したい人向けの情報

前提

Cloud FunctionsをCloud Buildでデプロイする方法については id:selmertsx が以下のエントリで詳しく書いてくれている

selmertsx.hatenablog.com

TypeScriptで書いたコードをビルドしてからデプロイするための設定

cloudbuild.yaml

steps:
  - name: 'gcr.io/cloud-builders/npm'
    args: ['install']
  - name: 'gcr.io/cloud-builders/npm'
    args: ['run', 'build']
  - name: 'gcr.io/cloud-builders/gcloud'
    args:
    - beta
    - functions
    - deploy
    - test
    - --entry-point=test
    - --trigger-http
    - --stage-bucket=${_BUCKET_NAME}
substitutions:
    _BUCKET_NAME: your-bucket

—stage-bucket に渡すbucket名はデフォルトではsubstitutionsに設定した値になるが、Cloud Buildのトリガーに代入変数が設定されていればそちらが使われる。

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