hygenでテンプレートからReactコンポーネントを生成する

@Panda_Program

hygenでReactコンポーネントを生成する

昨年、React コンポーネントの作成を自動化するためにシェルスクリプトを書きました(「Reactコンポーネントの雛形生成のシェルスクリプトを書いた」)。

その後、「hygen で生成 - 対話形式の Component 雛形 -」 という記事でファイル生成ツール hygen を知り、とても便利だったため本業のプロジェクトに導入しました。

本記事ではコンポーネント生成のために利用しているテンプレートの紹介と使い方、そして導入した利点を紹介します。

hygenのReactコンポーネント用のテンプレートの紹介

hygen とは、マークダウンのような frontmatter とテンプレートエンジン ejs からなるテンプレートを元に新しいファイルを生成するツールです。

以下のコマンドで hygen をインストールし、サンプル用のテンプレートを生成しましょう。

$ npm i -g hygen
$ hygen init self

コマンド実行後、プロジェクトルートに_templatesディレクトリが作成されます。

_templates
└── generator
    ├── help
    │   └── index.ejs.t
    ├── new
    │   └── hello.ejs.t
    └── with-prompt
        ├── hello.ejs.t
        └── prompt.ejs.t

本業のプロジェクトではこれを改変して、以下のような構成にしています。

_templates
└── component
    └── new
        ├── component.ejs.t
        ├── index.ejs.t
        ├── index.stories.ejs.t
        ├── prompt.js
        └── style.module.ejs.t

これで React コンポーネント、Storybook、scss、index.ts を作成できます。

hygenのテンプレートファイル

各ファイルの中身を紹介します。

React コンポーネント

---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.tsx
---
import React from 'react'
import css from './style.module.scss'

type ContainerProps = unknown

export type Props = unknown

export const Component: React.FC<Props> = (props) => (
  <></>
)

const Container: React.FC<ContainerProps> = (props) => {

  return <Component {...props} />
}

Container.displayName = '<%= h.changeCase.pascal(name) %>'

export default Container

冒頭はファイルパスとファイル名の指定です。

---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.tsx
---

dir h.changeCase.lower(name)でファイルパスを指定し、h.changeCase.pascal(name)でファイル名を決定します。

h.changeCase.lowerh.changeCase.pascalは hygen が用意している関数です。他にも多くの種類があるため、 柔軟に対応できます。

変数に格納する値を指定する方法は後述のprompt.jsの箇所で紹介します。

なお、React コンポーネントは「経年劣化に耐える ReactComponent の書き方」 を参考にしています。

またComponent(Presentation Component)とPropsを named export しているのは、下記の Storybook で利用するためです。

Storybook

---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/index.stories.tsx
---
import { Meta, Story } from '@storybook/react'
import React from 'react'

import { Component as <%= h.changeCase.pascal(name) %>, Props } from './<%= h.changeCase.pascal(name) %>'

export default {
  title: '<%= h.changeCase.pascal(name) %>',
} as Meta<Props>

const Template: Story<Props> = ({ ...args }) => <<%= h.changeCase.pascal(name) %> {...args} />

export const Default = Template.bind({})
Default.args = {

}

Storybookのファイルの雛形はStorybook 公式サイト の React + TypeScript の書き方を参考にしています。

ButtonGroup.stories.tsx
import React from 'react';

import { Story, Meta } from '@storybook/react';

import { ButtonGroup, ButtonGroupProps } from '../ButtonGroup';

//👇 Imports the Button stories
import * as ButtonStories from './Button.stories';

export default {
  title: 'ButtonGroup',
  component: ButtonGroup,
} as Meta;

const Template: Story<ButtonGroupProps> = (args) => <ButtonGroup {...args} />;

export const Pair = Template.bind({});
Pair.args = {
  buttons: [{ ...ButtonStories.Primary.args }, { ...ButtonStories.Secondary.args }],
  orientation: 'horizontal',
};

前出のテンプレートファイルと見比べるとほとんど同じことがわかります。

ただ、Propsの型はコンポーネントのファイルから export せず、ComponentPropsで取得する方がスマートかもしれません。

import { Meta, Story } from '@storybook/react'
import React, { ComponentProps } from "react"

import { Component as <%= h.changeCase.pascal(name) %>, Props } from './<%= h.changeCase.pascal(name) %>'

type Props = ComponentProps<typeof <%= h.changeCase.pascal(name) %>>

// ...

scss

---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/style.module.scss
---
@import 'src/assets/scss/lib/color';
@import 'src/assets/scss/lib/variable';
@import 'src/assets/scss/lib/icon';
@import 'src/assets/scss/lib/function';
@import 'src/assets/scss/lib/mixin';

各ファイルで共通の scss ファイルを import しています。

export

---
to: src/components/<%= dir %>/<%= h.changeCase.pascal(name) %>/index.ts
---
export { default } from './<%= h.changeCase.pascal(name) %>'

default export をしていますが、もちろん書き方を変えると named export に変更できます。

prompt.jsで入力値を決める

prompt.js
module.exports = [
  {
    type: 'select',
    name: 'dir',
    message: 'ディレクトリを選択してください',
    choices: ['common', 'features', 'pages'],
  },
  {
    type: 'input',
    name: 'name',
    message: 'コンポーネント名を入力してください',
    validate: (input) => input !== '',
  },
]

対話型 CLI での値の受け取り方をprompt.jsの中で設定します。プロパティnameの値が ejs 内の変数名に対応します。

例えば、typeselectに指定すると、列挙した値から選択することになります。

また、typeinputの場合は自由入力です。空欄は避けたいため、validateにバリデーションを記述します。

hygenのコマンド実行によるファイル生成

src/components/commonディレクトリにLayoutコンポーネントを作成してみましょう。

コマンド$ hygen component newを実行すると選択肢が列挙されます。

ディレクトリ名をchoicesに設定した値から選択します。

$ hygen component new
? ディレクトリを選択してください … 
❯ common
  features
  pages

次に、自由入力でコンポーネント名をLayoutと入力します。

$ hygen component new
✔ ディレクトリを選択してください · common
✔ コンポーネント名を入力してください · Layout

これでファイルが生成されました。

$ hygen component new
✔ ディレクトリを選択してください · common
✔ コンポーネント名を入力してください · Layout

Loaded templates: _templates
       added: src/components/common/Layout/Layout.tsx
       added: src/components/common/Layout/index.ts
       added: src/components/common/Layout/index.stories.tsx
       added: src/components/common/Layout/style.module.scss

記事の最後で生成したファイルの中身を掲載しています。

hygen導入のメリット

Storybook の書き方の統一と作成漏れの回避

Storybook のコンポーネントの書き方は柔軟なため、一通りではありません。しかし、テンプレートを用意すると大人数で開発していても記述方法の統一できます。

また、当然のことですが「Storybook First な開発」 をする際、Storybook コンポーネントは不可欠です。

テンプレートから一気に Storybook 用のファイルを生成すれば、作成漏れはありません。

デザイナーさんとの効果的な協業

全てのデザイナーさんが React に慣れているわけではありません。HTML と CSS をデザイナーさんが記述する場合、React で実装するという心理的なハードルをできるだけ下げたいです。

そこで、本記事で紹介したテンプレートを使うと責任範囲を限定できます。

つまり、デザイナーさんは Presentation Component に JSX を記述し、style.module.scssに scss を書いて Storybook で動作確認をするだけで OK になるのです。

// ...
import css from './style.module.scss'

// ...

export const Component: React.FC<Props> = (props) => (
  <></> // ここに JSX を記述する
)

デザイナーさんは React コンポーネントの Props の渡し方や、Storybook 用のコンポーネントの書き方を覚えずに済みます。実質的に、HTML と CSS(SCSS)の書き方を知っているだけで作業に取り掛かれるからです。

まとめ

IntelliJ IDEA にテンプレートを用いたファイル作成機能はありますが、ファイルは1つずつしか生成できません。

hygen は複数ファイルを同時に生成したい場面で威力を発揮します。例えば JS を利用しないプロジェクトであっても hygen を導入してもいいかもしれません。Class と Interface、Test 用のファイルを同時に生成するといったケースであっても有用だからです。

また、『UNIXという考え方』(Amazon) という書籍の中で「定理8: 過度の拘束的インターフェースは避ける」と書かれています。

一旦、そのアプリケーションをコマンドインタープリタから起動すると、そのアプリケーションが終了するまでコマンドインタープリタとの対話ができなくなる。ユーザーはアプリケーションのユーザーインターフェースの内部に拘束され、拘束を解くための行動を起こさない限り、その拘束から逃れられない。

『UNIXという考え方』(Mike Gancarz 著、芳尾桂 監訳。オーム社)

hygen はとても柔軟なので様々な指定が可能ではあるものの、対話数はできるだけ絞っておくと良いでしょう。

生成したファイル一覧

React コンポーネント

src/components/common/Layout/Layout.tsx
import React from 'react'
import css from './style.module.scss'

type ContainerProps = unknown

export type Props = unknown

export const Component: React.FC<Props> = (props) => (
  <></>
)

const Container: React.FC<ContainerProps> = (props) => {

  return <Component {...props} />
}

Container.displayName = 'Layout'

export default Container

Storybook

src/components/common/Layout/index.stories.tsx
import { Meta, Story } from '@storybook/react'
import React from 'react'

import { Component as Layout, Props } from './Layout'

export default {
  title: 'Layout',
} as Meta<Props>

const Template: Story<Props> = ({ ...args }) => <Layout {...args} />

export const Default = Template.bind({})
Default.args = {

}

scss

src/components/common/Layout/style.module.scss
@import 'src/assets/scss/lib/color';
@import 'src/assets/scss/lib/variable';
@import 'src/assets/scss/lib/icon';
@import 'src/assets/scss/lib/function';
@import 'src/assets/scss/lib/mixin';

index.ts

src/components/common/Layout/index.ts
export { default } from './Layout'

Happy Coding 🎉

パンダのイラスト
パンダ

記事が面白いと思ったらツイートやはてブをお願いします!皆さんの感想が執筆のモチベーションになります。最後まで読んでくれてありがとう。

  • Share on Hatena
  • Share on Twitter
  • Share on Line
  • Copy to clipboard