LoginSignup
0

More than 1 year has passed since last update.

Reactのコンポーネントを生成するCLIアプリをNode.jsの標準機能のみで作ってみた話

Last updated at Posted at 2021-12-01

この記事はTDU CPSLab Advent Calendar 2021 - Adventarの1日目の記事です

前の記事はありません、次の記事はこちらから。

追記

研究室で共有したところ、
Typescriptでのリファクタリングをnaruminchoが早速してくれました!!!

シンプルに自分が知らなかったNode.jsの機能使っていたり、
Typescriptの型定義の工夫も学べるかと思いますので、
是非こちらから御一読ください!!

はじめに

amplify codegen

(生成されるファイル達)
appsync.gif

かっこいい〜!!!

codegenってお前。。genってめっちゃかっこいいじゃん。。
generateだよね?それをgenってなんか良くない?

俺もgenしたいな!!
なんかこうバシッとgenしたいな!!

...という良くわかんない動機があって、
普段私が手動で作っていたReactコンポーネントをgenするCLI(脳筋)を作るに至り、
そのご紹介をしたいと思います。
compcodegen.gif

以下、回避運動ですが。
筆者は(ガチで)雰囲気でプログラミングしているので書き方がダサい・デフォルトでそういう機能がある・実現できる便利なパッケージがあるなどが起きていましたら申し訳ありません。追記しますので、コメント欄でボソッと教えてくださると嬉しいです。...記事は作ってみた記事的に供養として残させていただけたら嬉しいです。

目次

  • 開発環境
  • どんなことができるの?
  • 実装内容
    • 入力受け取り処理の作成
    • コード生成処理の作成
    • package.jsonに登録

開発環境

実現目標的に筆者の土台は

  • create-react-appで生成されたプロジェクト
  • 及びそれを駆動させているNode.js環境
  • yarnを使っている

という感じですが。
もちろん、Node.jsが入っていればそれだけで大丈夫です。

どんなことができるの?

用意されているコマンドと、その挙動の対応は以下の通りです。

yarn page:codegen      → pageフォルダ配下にコンポーネントを生成する
yarn layout:codegen  → layoutフォルダ配下にコンポーネントを生成する
yarn comp:codegen      → componentフォルダ配下にコンポーネントを生成する

そもそもの前提としまして、私は再利用可能なコンポーネントが集まるcomponentフォルダ・ページ毎のUIを整形するlayoutフォルダ・APIとの通信を行うpageフォルダといった具合に階層構造で分けております。なので上記のような3つのコマンドが定義されております。

続いて、以下の質問を答えることでコードを生成します。

yarn comp:codegen

yarn run v1.22.11
$ node ./scripts/comp-codegen.mjs
Code-gen : Create Presentational Component
? What file path  ex) common/Button > common/Button
? What class name  ex) Button > Button
? Create story(Y/n) > Y
{ filepath: 'common/Button', classname: 'Button', story: true }
Start Code Generate...
Succeded!!
✨  Done in 11.76s.

生成されるコードはStorybookの定義ファイルとReactコンポーネントのファイルです。
上記の例ではcomponents/common/Buttonフォルダ内に生成されます。

実装内容

大きな流れは以下の通りです。

  • ユーザの入力を対話的に受け取る処理の作成
  • ファイル生成を行う処理の作成
  • package.jsonに登録

特筆したいところは、何か便利なフレームワークやパッケージの導入はせずに、Node.jsの標準的な機能のみで作成したいという欲求がありました。そこで参考記事を探していたところ、napoporitataso様の個人ブログの記事に出会うことができ、参考にさせていただきました。本当にありがとうございます!

(自分初めて記事投稿するのでよくわかってないのですが、こういうのってご本人様に連絡差し上げたほうが良いのですかね?)

入力受け取り処理の作成

コードは以下のような感じ。

cli.mjs
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import * as readline from 'readline';
import * as process from 'process';

const reader = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

// ④で使用
const getAnswer = (msg) =>
  new Promise((res) => reader.question('? ' + msg + ' > ', res)); 

// ① 複数の質問を引数に取る
export const cli = async (questions = []) => {
  const answers = {};
  let answer, question;
  let name, type, message; // ② 各質問の構成要素
  let result;

  // ③ それぞれ質問をしていく
  for (question of questions) {
    result = '';
    ({ name, type, message } = question);

    // ④ ユーザの入力を待ち受ける
    result = await getAnswer(message);

    // ⑤ typeに合わせて簡単に値のチェックを行う
    switch (type) {
      case 'text':
        if (result !== '') {
          answer = result;
        } else {
          console.log('Invalid value');
        }
        break;

      case 'number':
        if (result !== '' && parseInt(result)) {
          answer = parseInt(result);
        } else {
          console.log('Invalid value');
        }
        break;

      case 'boolean':
        if (result.toLowerCase() === 'y' || result.toLowerCase() === 'n') {
          switch (result.toLowerCase()) {
            case 'y':
              answer = true;
              break;

            case 'n':
              answer = false;
              break;
          }
        } else {
          console.log('Invalid value');
        }
        break;
    }
    // ⑥ answersに追加していく
    answers[name] = answer;
  }

  // ⑦ 返す
  return answers;
};

ざっくり解説
① 複数の質問を受け取り、その答えを返却する関数、cliをエクスポートしてます。

② それぞれの質問は name,type,message の三要素の存在を保証します。例えばコンポーネント作成の際は以下のようなquestionsが入ってきます。

const questions = [
  {
    type: 'text',
    name: 'filepath',
    message: 'What file path  ex) common/Button',
  },
  {
    type: 'text',
    name: 'classname',
    message: 'What class name  ex) Button',
  },
  {
    type: 'boolean',
    name: 'story',
    message: 'Create story(Y/n)',
  },
];

③ 上記のquestions配列をforで回して聞いていきます。

④ getAnswer関数でユーザからの入力を待ち受けます。こちらの関数のほうはめちゃくちゃ参考記事様の方を参考させていただきました。

⑤ questionsのtypeを参照して、簡単にですが値のチェックをしています。numberは使ってないから要らないんですけどもね。。。

⑥ 値チェックが通過したらanswersに格納されていきます。

⑦ 全ての質問が終わったらanswersを返して終了です。

ファイル生成処理の作成

コードは以下のような感じ。コンポーネントを作る時のスクリプトを代表して書きます。

comp-codegen.mjs
import * as fs from 'fs';
import * as process from 'process';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import { cli } from './cli.mjs';

const questions = /*上で書いたやつと同じなので省略*/;

const __dirname = dirname(fileURLToPath(import.meta.url));

const main = async () => {
  console.log('Code-gen : Create Presentational Component');

  // ① 対話処理を使用
  const answers = await cli(questions);
  console.log(answers);

  console.log('Start Code Generate...');
  const from_dir = path.join(__dirname, '../scripts/template/component/');
  const to_dir = path.join(
    __dirname,
    '../src/components/' + answers['filepath'] + '/'
  );

  // ② フォルダ生成
  fs.mkdirSync(to_dir, { recursive: true });

  // ③ テンプレート生成(脳筋ポイント)
  let readcomp = fs.readFileSync(from_dir + 'index.tsx', 'utf-8');
  readcomp = readcomp.replace(/COMPONENTTEMPLATE/g, answers['classname']);
  fs.writeFileSync(to_dir + 'index.tsx', readcomp);

  // ④ ストーリーテンプレート生成(脳筋ポイント2)
  if (answers['story']) {
    let readstory = fs.readFileSync(
      from_dir + 'componenttemplate.stories.tsx',
      'utf-8'
    );
    readstory = readstory.replace(/COMPONENTFILEPATH/g, answers['filepath']);
    readstory = readstory.replace(/COMPONENTTEMPLATE/g, answers['classname']);
    fs.writeFileSync(
      to_dir + answers['classname'].toLowerCase() + '.stories.tsx',
      readstory
    );
  }
  console.log('Succeded!!');
  process.exit();
};

(async () => {
  await main();
})();

ざっくり解説
① 先ほど紹介した対話的にユーザの入力を取得する関数を使っています。

② 質問で取得した「ファイルパス」の情報に従ってフォルダを作成しています。このmkdirSyncですが、Syncじゃない方も存在します。

③ 作成したフォルダの中にReactコンポーネントを記述するindex.tsxを生成しています。そしてここからが脳筋(だと思っている)ポイントです。やっていることは以下の通りです。

  1. 予め../scripts/template/componentに用意されたindex.tsxの記述内容を読み込む。
  2. 正規表現を使ってCOMPONENTTEMPLATEという文字列を質問で取得した「クラス名」で置き換える。
  3. 書き出す

つまり、テンプレート用のファイルが用意されており、それを複製しているような感じなんです。手動コピペを機械的にコピペできるようにしただけ..。もっといい感じの方法あるかなぁという気持ちがあります。テンプレートがどんな感じなのかは折り畳みで書いておきます。

index.tsxのテンプレート
index.tsx
import React from 'react';

export const COMPONENTTEMPLATE = (): JSX.Element => {
  return <div></div>;
};


④ 作成したフォルダの中にStorybookの定義ファイルを生成しています。やってることは③と全く同じです。
Storybookのテンプレート
componenttemplate.stories.tsx
import React from 'react';
// also exported from '@storybook/react' if you can deal with breaking changes in 6.1
import { Story, Meta } from '@storybook/react/types-6-0';

import { COMPONENTTEMPLATE } from '.';

export default {
  title: 'component/COMPONENTFILEPATH',
  component: COMPONENTTEMPLATE,
  argTypes: {
    backgroundColor: { control: 'color' },
  },
} as Meta;

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

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

package.jsonに登録

あとはpackage.jsonのscriptsの中で、以下のように追記すればOKです!

package.json
{
  "scripts": {
    ~省略~,
    "page:codegen": "node ./scripts/page-codegen.mjs",
    "layout:codegen": "node ./scripts/layout-codegen.mjs",
    "comp:codegen": "node ./scripts/comp-codegen.mjs"
  },
}

そんなこんなで

いつも手動でファイル作っていたのですが、cliで作れるようになりました。
compcodegen.gif

まとめ

今回はよくわかんないモチベーションから非常に泥臭い感じの自己満なcliが完成しました。
特に予め用意したファイル読み込んで書き出す処理とか、なんかもっといいのないかなって思っております。

まあでも、こういうちょっとした自動化するのはなんか楽しいです。楽しいのでヨシ!

補足ですが、簡単にcliの作り方を調べた際に、もっとカラフルであったりアニメーションを付けたりできる高機能なcliアプリを作るためのフレームワークやライブラリがありました。npmに登録してってやる方はそういったもの使われた方が良い感じかと思います。

初めての記事作成で、お目汚し失礼しました。
記事書くの楽しかったのでまた書きたいと思います。

参考記事

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
0