エンジニアのアウトプットについて社内勉強会で発表した

だいたい月に1回ほど福岡拠点のエンジニアが集まってtech talkというイベントをしていて、今回エンジニアのアウトプットというテーマで発表した。

前職はアウトプットすることが企業理念の1つとして入っている会社だった。社員みんなアウトプットを意識している中で私も働き、アウトプットすることで成長してきた部分もあると感じているので、今一度アウトプットの良さについて自分なりに噛み砕いて資料にしてみた。

speakerdeck.com

今のところアウトプットは自己成長を目的にするというのが根底にある思っている。自己成長の効率をより高める手段として、他人に見えやすいようにアウトプットすることでフィードバックをもらったり評価を受けたりすることが良いのではないかという話。

周りの同僚の反応はなかなかよかったが、自分としてはもう少しデザインをよくしたかった。個人的に、エンジニア向けの登壇資料だから字ばかりでも伝われば大丈夫みたいなのは今後はあまりやらないようにしようとしていて、何か発表するならできるだけデザインもこだわって資料を見ただけで伝わるように作っていこうと思っている。デザイナーさんにデザインを教えてもらうぞー。

styled-componentsのThemeProviderをつかってReactのスタイルを管理する

Reactのスタイル管理、どうしていますか。

いろいろな方法があると思いますが、大規模なReactでのフロントエンド開発においてCSSをどう管理するかとても悩みました。私の所属しているチームでは、styled-componentsのThemeProviderを使ってスタイルを管理することにしましたので紹介します。

コード例

例えば簡単なButtonコンポーネントを実装し、propsで背景色を変えたい場面があったとします。

import React from "react";
import styled from "styled-components";

const StyledButton = styled.button`
  background-color: ${props => (props.color === "primary" ? "blue" : "gray")};
`;

export default function Button({ color, children }) {
  // わかりやすいようにpropsを分けて渡しています
  return <StyledButton color={color}>{children}</StyledButton>;
}

この実装だとcolor propsの種類が増えたときに分岐の実装が増えていくため、objectに設定を切り出して書いたりします。

const backgroundColor = {
  default: "gray",
  primary: "blue",
  warning: "orange"
}

const StyledButton = styled.button`
  background-color: ${props => backgroundColor[props.color]};
`;

上の実装でだいぶ変更には強くなります。ただ、もしcolorのprimaryやwarning設定をある一部分だけ違う色に変えたいという場面があったとします。Buttonコンポーネント内で色を固定してしまっているため、propsを追加するか、styled-componentsのcss propsを渡すか、もう一つButtonをラップしたコンポーネントを作るなどの方法を取らねばなりません。

そういった場合にThemeProviderを使って実装すると便利なことがあります。

// ButtonGroup.js(Button.jsを使う側)

import React from "react";
import { ThemeProvider } from "styled-components";
import Button from "./Button";

const theme = {
  button: {
    backgroundColor: {
      default: "gray",
      primary: "blue",
      warning: "orange"
    }
  }
};

export default function ButtonGroup() {
  return (
    <ThemeProvider theme={theme}>
      <Button color="default">Default</Button>
      <Button color="primary">Primary</Button>
      <Button color="warning">Warning</Button>
    </ThemeProvider>
  );
}
// Button.js

import React from "react";
import styled from "styled-components";

// ThemeProvider内のコンポーネントはpropsにthemeがセットされている
const StyledButton = styled.button`
  background-color: ${props => props.theme.button.backgroundColor[props.color]};
`;

export default function Button(props) {
  return <StyledButton {...props} />;
}

Buttonコンポーネントを使う側(ButtonGroup.js)でThemeProviderを使い theme propsで色を設定しているobjectを渡します。ThemeProviderで囲まれた全コンポーネントには theme というpropsが自動で追加されており、 props.theme にはThemeProviderで渡したobjectが入っています。

これでコンポーネントを利用する側からスタイルをカスタムしやすくなりました。同時にButtonコンポーネントではCSSとしてThemeProviderからの値をセットするだけで、背景色自体の管理はしなくなっています。

以下のようにobjectを書き換えれば、一部分だけ設定を変更することができます。

// ButtonGroup.js

import React from "react";
import { ThemeProvider } from "styled-components";
import deepmerge from "deepmerge"
import Button from "./Button";

const defaultTheme = {
  button: {
    backgroundColor: {
      default: "gray",
      primary: "blue",
      warning: "orange"
    }
  }
};
const customTheme = deepmerge(defaultTheme, {
  button: {
    color: {
      primary: "green",
      warning: "red"
    }
  }
});

export default function ButtonGroup() {
  return (
    <ThemeProvider theme={defaultTheme}>
      <div>
        <Button color="default">Default</Button>
        <Button color="primary">Primary</Button>
        <Button color="warning">Warning</Button>
      </div>
      <div>
        <ThemeProvider theme={customTheme}>
          <Button color="default">Default</Button>
          <Button color="primary">Primary</Button>
          <Button color="warning">Warning</Button>
        </ThemeProvider>
      </div>
    </ThemeProvider>
  );
}

ThemeProvider自体はReactのContextの仕組みを使っているので、一番近いところで渡されているThemeProviderの値がセットされるようになっています。カスタムしたい場合は、デフォルトのテーマ設定objectからマージして書き換えたい部分のみ上書きするようにします。

これでcustomThemeを渡した方のみカスタムした背景色が当たるようになりました。

試してみたソースコードはこちらに置いています。

ThemeProviderを利用するメリット

コンポーネントとスタイル管理を切り離して管理できる

上記の例ではButtonコンポーネント内で背景色のスタイル管理をしなくなりました。つまりスタイル変更のためだけにButtonコンポーネントを修正する必要がなくなるということです。背景色だけでなくサイズやテキストの色などもThemeProviderで管理するようにすることですべてコンポーネントを利用する側でスタイル管理ができるようになります。

ただStorybookでコンポーネントカタログを作るときなど、ThemeProviderを利用せずコンポーネントのみで表示させたい場合、 props.theme がセットされないためundefinedになりJavaScriptエラーが発生してしまいます。その場合ReactのdefaultPropsを利用するとよいと思います。

// Button.js

import React from "react";
import styled from "styled-components";
import { defaultTheme } from "./defaultTheme"

const StyledButton = styled.button`
  background-color: ${props => props.theme.button.backgroundColor[props.color]};
`;
StyledButton.defaultProps = {theme: defaultTheme}
 
export default function Button({ color, children }) {
  return <StyledButton color={color}>{children}</StyledButton>;
}

これでThemeProviderを利用しない場合でもdefaultPropsによりデフォルトのテーマ設定で props.theme が渡された状態になります。

CSSの詳細度の戦いになりにくい

コンポーネント内でCSSを設定していた場合、そのCSSを上書きするためには利用するコンポーネント側でCSSの詳細度を考えながら書かなければなりません。ときには !important を使わないといけない悲しい場面もあるかもしれません。

ThemeProviderを利用した場合、セットするthemeはJSのobjectなのでobject自体を上書きして渡してしまえば詳細度の戦いになりません。styled-componentsの仕様として、CSSの値にundefinedを渡せばプロパティ自体出力されなくなるので、無駄なCSSが出力されないというメリットもあります。

TypeScriptでも書ける

https://github.com/styled-components/styled-components/issues/1589#issuecomment-456641381 を参考にtheme props型をつけることができます。@types/styled-components の実装でDefaultThemeのinterfaceを拡張するとthemeに型をつけることができるようになっています。テーマ管理しているobjectのtypeofをとり以下のようにDefaultTheme interfaceに適用します。

import { defaultTheme } from "path/to/defaultTheme";

type ITheme = typeof defaultTheme;

declare module "styled-components" {
  interface DefaultTheme extends ITheme {}
}

少しロジックが入ったりする場合は、styled-componentsのテンプレート文字列全体を関数にして最後にcssを使って文字列を返すような実装もできます。

import React from "react";
import styled, { css } from "styled-components";
import { defaultTheme } from "./defaultTheme"

type Props = {
  color?: "default" | "primary" | "warning";
  disabled?: boolean;
  children: React.ReactNode;
}

const StyledButton = styled.button<Props>`
  ${({theme, color, disabled}) => {
    let backgroundColor = disabled ? "darkgray" : theme.button.backgroundColor[color];
    return css`
      background-color: ${backgroundColor};
    `
  }}
`;
StyledButton.defaultProps = {theme: defaultTheme}


export default function Button(props: Props): React.ReactElement {
  return <StyledButton{...props} />;
}

最後に

デフォルトのテーマ設定objectを別のobjectに切り替えるだけで、ユーザーごとのテーマ切り替えやダークテーマの対応がしやすかったりするメリットもあると思います。

直感的なCSSファイルでの実装ではないため、デザイナーがコードまで修正しているチームの場合修正しにくいデメリットはあると思いますが、エンジニアが管理しているチームでは管理しやすくなると思いますのでぜひ試してみてください。

参照リンク

StorybookをCIでビルドしてPull Requestにコメントさせたらレビュー効率がアップする

最近Reactコンポーネントを実装、修正する機会が多い。コードレビューのときにコード的には大丈夫そうだけど見た目がどんな動きをするか気になるときがあり、レビュー時にStorybookを確認したい場面が増えた。

branchをcheckoutすれば手元で見れるが、自分も修正途中のときに面倒くさい。

現在利用しているCircleCIにはartifactsという成果物を一定期間永続化できる機能がある。

circleci.com

解決方法として、StorybookをビルドしたHTMLファイルをartifactsに置き、index.htmlまでのURLをプルリクエストにコメントするという方法をとった。

ほぼこちらを参考に実装。

qiita.com

手順としては以下。

  • 環境変数からPull Request番号を取得
  • GitHubAPIからPull Requestのターゲット(マージ先)ブランチを取得
  • ターゲットブランチとのgit diffにコンポーネントの修正があるか判断
  • 修正があればStorybookをビルドする
  • GitHub APIを叩いてStorybookまでのURLをPull Requestにコメント

上記のリンクではPull RequestへのコメントはJavaScriptで行っているが、全部shell scriptで書いた。

  • client/bin/build_storybook.sh
#!/bin/sh
set -eu

PULL_REQUEST_ID=$(echo ${CIRCLE_PULL_REQUEST} | awk -F'/' '{print $NF}')
if [ -z "${PULL_REQUEST_ID}" ]; then
  echo "Skip building storybook."
  exit 0
fi

# Get pull request target branch
TARGET_BRANCH=$(curl -H "Authorization: Bearer ${GITHUB_TOKEN}" "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/pulls/${PULL_REQUEST_ID}" | jq '.base.ref' | tr -d '"')

# Check fixed components in git diff
git fetch origin ${TARGET_BRANCH}
COMPONENT_FIXED=0
git diff origin/${TARGET_BRANCH}...HEAD --name-only | grep 'client/components/' || COMPONENT_FIXED=$?

if [ "${COMPONENT_FIXED}" != "0" ]; then
  echo "Skip building storybook because components not fixed."
  exit 0
fi

# build Storybook
$(npm bin)/build-storybook -c client/.storybook -o public/storybook --quiet

# add comment to pull request
COMMENT=":link: [Storybook](https://${CIRCLE_BUILD_NUM}-${REPO_NUMBER}-gh.circle-artifacts.com/0/storybook/index.html)";
curl -X POST \
  -H "Content-type: application/json" -H "Accept: application/json" \
  -H "Authorization: Bearer ${GITHUB_TOKEN}" \
  -d "{ \"body\": \"${COMMENT}\" }" \
  "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/${PULL_REQUEST_ID}/comments"
  • .circleci/config.yml
version: 2.0
jobs:
  build_storybook:
    docker:
      - image: circleci/node:10.13.0-browsers
    resource_class: small
    steps:
      - checkout
      - run:
          name: Build Storybook
          command: |
            npm install
            client/bin/build_storybook.sh
          environment:
            REPO_NUMBER: [CircleCI repository number]
      - store_artifacts:
          path: public/storybook
          destination: storybook
workflows:
  version: 2
  build:
    jobs:
      - build_storybook

ちなみにGitHub Actionsはartifactsはあるもののzipでのダウンロードにしか対応していないので今のところ実現できない。今後に期待。

github.com