🏠

TypeScriptでcomponentのscaffoldを作ろう

7 min read

はじめに

ふといつものように React でコンポーネントを作っていたらうちの TL の@kichion毎回同じような構成のファイル作るの面倒だわと言い出し Golang でコンポーネントを scaffold する CLI を作りよった。
大層便利なその CLI はmake scaffold COMPONENT=atoms/buttons NAME=TextButtonみたいなコマンドを実行するとsrc/components/atoms/buttons/TextButtonディレクトリにコンポーネント用・storybook 用の tsx ファイル、css modules 用の css ファイルをよしなに作成してくれおる。
大層感銘を受けた hada だったがフロントエンドのリポジトリに golang のコードが入ってるのもなぁ、どうせなら TypeScript で完結したいと思い、golang で書かれた script を TypeScript で書き直す決意をしたのであった。

記事のターゲット

  • 新しいコンポーネント作ろーっと → 行数少ないこのコンポーネントから構成コピペして持ってこよう → 名前書き換えよう(めんどくさいなぁ)と思ってる人

環境

関係ありそうなとこだけ

package.json
{
  "dependencies": {
    "react": "16.14.0",
    "react-dom": "16.14.0",
  },
  "devDependencies": {
    "@types/node": "14.14.6",
    "ts-node": "9.1.1",
    "typescript": "4.0.5"
  }
}

前提

対象のプロジェクトでは atomic design が採用されており、一つのコンポーネント作るには下記のような構成になっている

src/components/atoms/texts/Txt
├── index.stories.tsx // storybookの定義
├── index.tsx         // componentの定義
└── styles.ts         // styled-componentの定義

それぞれのファイルで下記で示すように共通する書き方があり、これらをすでにあるコンポーネントから毎回コピペで持ってきて名前を差し替えるのが面倒であった。

component

index.tsx
import classnames from 'classnames';
import React from 'react';

import { Div } from './styles';

export type TxtProps = {
  name: string;
};

export const Txt: React.FC<TxtProps> = ({ name }: TxtProps) => (
  <Div>
    <p>Enjoy {name} component life!!</p>
  </Div>
);

storybook

index.stories.tsx
import { storiesOf } from '@storybook/react';
import React from 'react';

import { Txt } from '.';

storiesOf('${path}/${name}', module).add('default', () => <Txt name="name" />);

styled-component

styles.ts
import styled from 'styled-components';

// theme colorとか使うときに呼び出す
// import { ThemeProps } from 'styles/Theme';

export const Div = styled.div``;

いざ実装

TypeScript+node.js を実行できるようにする

ts-node を使うと TypeScript ファイルの実行と node.js の REPL をやってくれるのでインストールする

TypeScript execution and REPL for node.js, with source map support.
https://github.com/TypeStrong/ts-node

Next.js や React を使ってる場合、tsconfig がもしかしたら node.js で使う型を扱えないかもしれないので今回作るスクリプト用の tsconfig を設定する
(今回はルート直下に scripts ディレクトリを作り、そこにスクリプトを作る想定)

scripts/tsconfig.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "isolatedModules": false,
    "types": ["ts-node"]
  },
  "include": ["./**/*.ts"]
}

スクリプトの実装

簡単 3 ステップだよ

  1. ディレクトリとコンポーネント名を引数から受け取る
  2. 作成したいファイルの中身を定義する
  3. ファイルを指定したディレクトリに作成する

コードの全容は一番最後に記載してやす

コマンドの実装

下記のようなコマンドを package.json に登録して

package.json
  "scripts": {
    "scaffold": "ts-node --project scripts/tsconfig.json scripts/component_scaffold/index.ts --"
  },

yarn scaffold --component=atoms/buttons --name=FooButton
するとよしなにコンポーネントを作ってくれる

ポイント

  • ts-node の--projectオプションでどの tsconfig 使うか指定できるよ
  • ts-node の型を script で呼び出したい時は tsconfig のcompilerOptionstypests-nodeを指定しよう
  • ts-node でのカスタム引数の指定する前に--をいれる
    • ts-node --project scripts/tsconfig.json scripts/component_scaffold/index.ts -- --component=atoms/buttons --name=FooButton
  • 引数を受け取るprocess.argvが独特なので気をつけて
  • fsexistsSyncでファイルやディレクトリの存在確認ができるよ
  • fsmkdirSyncでディレクトリの作成ができるよ
  • fswriteFileSyncでファイルの作成ができるよ
    • writeFileSyncはすでにディレクトリがないとエラーを返すので要注意

参考: process.argv の返り値

[
  '/Users/yhada/dev/project-name/node_modules/.bin/ts-node',
  '/Users/yhada/dev/project-name/scripts/component_scaffold/index.ts',
  '--',
  '--component=atoms/texts',
  '--name=Txt'
]

以下、コード全容

スクリプト全容
scripts/component_scaffold/index.ts
import { existsSync, mkdirSync, writeFileSync } from 'fs';

import {
  createComponentTemplate,
  createStorybookTemplate,
  createStyleTemplate
} from './templates';

const getArgValue = (arg: string): { name: string; value: string } => {
  const initStr = arg.slice(0, 2);
  if (initStr !== '--') {
    throw new Error('引数がおかしいでヤンス。「--」で指定してほしいでヤンス');
  }

  const equalIndex = arg.indexOf('=');
  if (equalIndex === -1) {
    throw new Error(
      '引数の指定方法がおかしいでヤンス。「--component=atoms --name=ComponentName」にしてほしいでやんす'
    );
  }
  const name = arg.slice(2, equalIndex);
  if (name.length === 0) {
    throw new Error('引数がないでヤンス');
  }
  if (name !== 'component' && name !== 'name') {
    throw new Error(
      '引数が違うでヤンス。「--component=atoms --name=ComponentName」にしてほしいでやんす'
    );
  }

  const value = arg.slice(equalIndex + 1, arg.length);
  if (value.length === 0) {
    throw new Error('引数の値がないでヤンス');
  }

  return { name, value };
};

const validateArgs = (args: string[]) => {
  if (args.length !== 5) {
    throw new Error(
      '引数の数が正しくないでゲス。--componentと--nameを指定してほしいでゲス'
    );
  }
};

const main = () => {
  try {
    validateArgs(process.argv);
    const pathName = getArgValue(process.argv[3]);
    const componentName = getArgValue(process.argv[4]);

    // HACK: 雑な実装です
    const path = `src/components/${pathName.value}/${componentName.value}`;
    if (!existsSync(path)) {
      mkdirSync(path, {
        recursive: true
      });
    }
    writeFileSync(
      `${path}/index.tsx`,
      createComponentTemplate(componentName.value)
    );
    writeFileSync(`${path}/styles.ts`, createStyleTemplate());
    writeFileSync(
      `${path}/index.stories.tsx`,
      createStorybookTemplate(pathName.value, componentName.value)
    );
  } catch (error) {
    // eslint-disable-next-line no-console
    console.error(error);
  }
};

main();
テンプレート生成部分
// コンポーネントのテンプレート
export const createComponentTemplate = (
  name: string
): string => `// import classnames from 'classnames';
import React from 'react';

import { Div } from './styles';

export type ${name}Props = {
  name: string;
};

export const ${name}: React.FC<${name}Props> = ({ name }: ${name}Props) => (
  <Div>
    <p>Enjoy {name} component life!!</p>
  </Div>
);
`;

// storybookのテンプレート
export const createStorybookTemplate = (
  path: string,
  name: string
): string => `import { storiesOf } from '@storybook/react';
import React from 'react';

import { ${name} } from '.';

storiesOf('${path}/${name}', module).add('default', () => <${name} name="name" />);
`;

// styled-componentのテンプレート
export const createStyleTemplate = (): string => `import styled from 'styled-components';

// import { ThemeProps } from 'styles/Theme';

export const Div = styled.div\`\`;
`;

以上、意外と簡単にできる scaffold のお話でした!