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を見れば色々とわかるので見ていく
- https://github.com/kufu/smarthr-ui/blob/6875fbc4bb65da7ec324de2933e951226f6f436e/package.json#L81-L83
- ここから対象としているNode.jsのバージョンは12系以上
- https://github.com/kufu/smarthr-ui/blob/6875fbc4bb65da7ec324de2933e951226f6f436e/package.json#L84-L87
- パッケージをインストールした時にnode_modulesへコピーされるのはlibとesmディレクトリのみ
- https://qiita.com/masato_makino/items/656f4fbb1595cbcdc23d
- https://github.com/kufu/smarthr-ui/blob/6875fbc4bb65da7ec324de2933e951226f6f436e/package.json#L101-L102
- mainもmoduleも両方提供されているので、commonjs形式でもECMA modulesでもどちらでも利用することができる
- 前に書いた記事も参考になるかもしれない
- しかし、指定されている
lib/index.js
やesm/index.js
というファイルは存在しない - これはリリース時のビルドで作成している(後述)
- https://github.com/kufu/smarthr-ui/blob/6875fbc4bb65da7ec324de2933e951226f6f436e/package.json#L138
- 型定義ファイルも提供されているので、TSプロジェクトにも安心して導入することができる
- ちなみにtypesとtypingsは同じ意味なので、どちらで書いても問題ない
- https://github.com/kufu/smarthr-ui/blob/6875fbc4bb65da7ec324de2933e951226f6f436e/package.json#L137
- sideEffectsがfalseとなっているので、副作用はないパッケージであることがわかる
- 副作用とは、インポートなどをしただけでグローバルスコープに影響があるようなコードのこと
- webpackの公式サイトではポリフィルなどが紹介されている
- https://webpack.js.org/guides/tree-shaking/
- https://github.com/kufu/smarthr-ui/blob/6875fbc4bb65da7ec324de2933e951226f6f436e/package.json#L112
npm run build
コマンド一つでbuild:~~~
という定義のコマンドを並列実行(run-pによって)できるようにしているrun-p
コマンドはnpm-run-all
パッケージが提供しているrun-p build:*
によってlibとesmがそれぞれtsc -p tsconfig.esm.build.json
tsc -p tsconfig.build.json
というコマンドで生成されている- ベースとなる
tsconfig.json
を継承し、一部オーバーライドしたtsconfig.esm.build.json
とtsconfig.build.json
を使っている - tscはsrcディレクトリ内の.tsファイルを.jsへ変換するので、src/index.tsなどが変換対象として選択されているということになる
とりあえずこんなところだろうか
各コンポーネントはどうやって管理されているのか
↑で見た通り、src/index.tsがキーファイルとなるが、見ての通り単なるexportがまとめられたファイルなので、このファイルそのものにはあまり意味はない。
ということで、ここでは機能がシンプルそうな、PrymaryButtonを一例としてみてみる
PrymaryButton
は from './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の使い方調べよう