2021-09-28

SmartHR UIを読む

今回はSmartHR社がOSSで公開しているSmartHR UIのコードを読んでいく
React+TSなので勉強にはもってこいな構成

当然React+TSの構成がビルドできる環境が必要なので、ここで作成した環境を一部削ったものを流用してみていく

まずは導入

READMEに書かれているのでやっていく

npm install smarthr-ui react react-dom styled-components

パッケージを入れたらエントリポイントのファイルにREADMEのコードをコピペ

import React from 'react'
import ReactDOM from 'react-dom'
import { createTheme, ThemeProvider, PrimaryButton } from 'smarthr-ui'

const theme = createTheme({})

const App: React.FC<Record<string, unknown>> = () => (
  <ThemeProvider theme={theme}>
    <PrimaryButton>Hello World</PrimaryButton>
  </ThemeProvider>
)

ReactDOM.render(<App />, document.getElementById('app'))

これでwebpack-dev-serverを起動すると、Hello Worldと書かれたボタンが表示された

いよいよ内部をみていくのだが、その前にソースコードがどうやって管理されているのか見ていく

package.jsonからわかること

npmパッケージはとりあえずpackage.jsonを見れば色々とわかるので見ていく

とりあえずこんなところだろうか

各コンポーネントはどうやって管理されているのか

↑で見た通り、src/index.tsがキーファイルとなるが、見ての通り単なるexportがまとめられたファイルなので、このファイルそのものにはあまり意味はない。
ということで、ここでは機能がシンプルそうな、PrymaryButtonを一例としてみてみる

PrymaryButtonfrom './components/Button' からexportされている
./components/Buttonディレクトリなので、その配下の index.ts から読み込まれることになる
(このindex.js|tsが使われるの当たり前に使ってるんだけど、MDNとかの資料でそれっぽい解説が見つけられていないので、どなたかご存知でしたら教えて欲しい)

src/components/Button/index.tsもexportをまとめたものなので、実態はそれぞれのファイルに存在しており、PrimaryButtonはPrimaryButton.tsxに実装が書かれている

これで各コンポーネントの実態までたどり着くことができた

PrimaryButtonコンポーネント

PrimaryButtonコンポーネントを上から順番に実装をみていく

PrimaryButtonの定義

PrimaryButtonの定義は以下のようになっている

export const PrimaryButton: VFC<ButtonProps> = ({ type = 'button', className = '', ...props }) => {
  const theme = useTheme()
  const { primaryButton } = useClassNames()

  return (
    <PrimaryStyleButton
      {...props}
      themes={theme}
      type={type}
      className={`${className} ${primaryButton.wrapper}`}
    />
  )
}

PrimaryButton.displayName = 'PrimaryButton'
  • VFCはFCよりも厳密な型らしい(https://qiita.com/tttocklll/items/c78aa33856ded870e843)

    • FCもVFCももう使わなくてもいい説があったりするけど、実際のところどうなんでしょ
  • PrimaryButtonは引数としてButtonPropsを受け取ることができる
    ButtonPropsはBaseButtonからimportされており、定義は

    export type ButtonProps = BaseProps &
      Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof BaseProps>

    という感じで、BasePropsはtype定義で { size?: 'default' | 's'; children?: React.ReactNode; ... } みたいなよくある定義
    それにOmitしたものがandで合体している
    Omit<Type, Keys>(https://www.typescriptlang.org/docs/handbook/utility-types.html#omittype-keys)は「Typeから全てのプロパティを選択し、Keysを削除して型を構築する」というもの

    Typeは React.ButtonHTMLAttributes<HTMLButtonElement> であり、多分定義はこれ => https://github.com/DefinitelyTyped/DefinitelyTyped/blob/43c391bdf71cb86f5a5a9006f99c0661b30b82b0/types/react/index.d.ts#L2053
    そこからBasePropsのkeyを除外している
    結局どういうことかというと「React.ButtonHTMLAttributesから自分で定義したBasePropsと重複しているプロパティを除外し、BaseProps & で除外したプロパティを付与(すなわち自分の定義でオーバーライド)している」ということのようだ
    ただ、React.ButtonHTMLAttributes自体には重複項目はなく、継承先のHTMLAttributes(https://github.com/DefinitelyTyped/DefinitelyTyped/blob/43c391bdf71cb86f5a5a9006f99c0661b30b82b0/types/react/index.d.ts#L1821)の定義を上書きしているものと思われる

    最小のコードだとこう

    type a = { abc: string; hoge: number }
    type b = { abc?: number; fuga: boolean }
    type c = b & Omit<a, keyof b>
    
    // fugaとhogeは必須
    // abcは任意で、追加する場合はnumberである必要がある
    // Omitを使うことで、aのabcの定義を、bのabcの定義で上書きできていることがわかる
    const d: c = { fuga: true, hoge: 123, abc: 123 }

    なお、コンポーネントの引数で指定されている type = 'button' の定義は React.ButtonHTMLAttributes<HTMLButtonElement>.type?: "submit" | "reset" | "button" | undefined ということで「任意のプロパティで、追加時は値がsubmit, reset, buttonのいずれかである必要がある」ということでした(React.ButtonHTMLAttributes定義を持っている)

  • useThemeは独自のフックのようだ
    定義元をみると↓のようになっている

    export type Theme = CreatedTheme
    
    export const useTheme = () => {
      const theme = useContext(ThemeContext)
      return theme
    }

    CreatedThemeはここにあるとおり型の定義
    useContextはReactの組み込みフックで、それにThemeContextという React.createContext したContextオブジェクトを渡している↓

    export const ThemeContext = React.createContext<CreatedTheme>(createTheme())

    前述した通り、CreatedThemeは型の定義で、createThemeでオブジェクトを返している
    まとめるとこの部分は「スタイルの情報がまとめられたオブジェクト(createThemeで生成される)を、React.createContextを使ってContextオブジェクト化し、それをuseContextを使ってthemeという一定の範囲内のグローバルなstate(実態はContextオブジェクト)を取得し、それをuseThemeという関数を呼ぶことでどこからでも利用できるようにしている」という感じだろうか
    ちなみに、contextでよくあるproviderを使った利用方法もちゃんと提供されている

    長くなったが、 const theme = useTheme() でやっていることは「スタイル情報をもつContextオブジェクト」を取得している(ぱっと見は単なるオブジェクト)

  • useClassNamesはここにあり、useMemoされたものが返されている
    とりあえずPrimaryButtonの部分にだけ注目すると

    export const useClassNames = () => {
      const generatePrimaryButton = useClassNameGenerator(PrimaryButton.displayName || 'PrimaryButton')
      return useMemo(
        () => ({
          primaryButton: {
            wrapper: generatePrimaryButton(),
          },
        }),
        [generatePrimaryButton]
      )
    }

    定義はこんな感じで、まずuseClassNameGeneratorを使って何かを作成している
    useClassNameGeneratorはここにあり、内部的にはuseCallbackしたものを返している
    useCallbackはメモ化されたコールバック関数を返すフックで、よくあるのはReact.memoしたコンポーネントに関数をpropsとして渡したい時に利用する
    ここではuseClassNameGeneratorに渡されるcomponentNameという引数が依存しているため、componentNameが更新されない限りは関数は再生成されないことになる

    流れをまとめると

    • PrimaryButton.displayNameが変更される
    • useCallback(useClassNameGenerator)によって関数が再生成される
    • 関数が再生成されるのでgeneratePrimaryButtonの参照が変わる
    • useMemoはgeneratePrimaryButtonに依存しているため、第一引数の関数が再実行され、その結果が返される

    となりそう
    そして返ってくるのは { primaryButton: { wrapper: ~~~ } } というオブジェクトなので、展開して受け取っている const { primaryButton } = useClassNames()
    なお、generatePrimaryButtonはこの関数なので、partNameを引数に渡すことができる(オプショナル)

  • 最後にPrimaryStyleButtonというコンポーネントを返しているが、これはstyled-componentによって生成されたコンポーネントで、↓のようになっている

    const PrimaryStyleButton = styled(BaseButton)`
      ${primaryStyle}
      &[disabled] {
        ${disabledStyle}
      }
    `

    styled-componentの使い方よくわからないので、ここでは割愛して別の記事で色々探ってみる
    とにかくスタイルが適用されたコンポーネントという認識で良さそう

READMEのコードのReact.FC<Record<string, unknown>>について

React.FCはよく import { FC } from 'react' として読み込まれている関数コンポーネントを指すTSの型
React.FCは↑でも書かれているように React.FC<~~~> とジェネリクスで書くことができ、引数にどのような属性値が含まれるかを定義することができる

では Record<string, unknown> の部分は何かというと https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeystype これのことっぽい
Record<K, T> はKがプロパティとなり、T型を持つオブジェクトタイプの型を構築するというもの
例: const hoge: Record<string, number> = { "aaa": 123, "bbb": 999 }

ここではAppコンポーネントの引数はstring型のキーを持ち、unknown型を持つ型となる
要するに以下のようにPropsとして何でも渡すことができるという意味になる
<App title="aic" hoge={1234} test={true} test2={[]} />

一言

  • ReactHooksややこしいので、頭の整理も兼ねて一つ記事にしてもいいかもしれない
  • styled-componentの使い方調べよう

参考文献