[Vue.js] Hygenで作るコンポーネントジェネレーター

コンポーネント作るのにテストだのStorybookだの関連するファイルも作ったり中身をいちいち書くのもメンドクセ(´ω`)
って思った人向けの道具について。


Hygenはあらかじめ作っておいた設定をベースにファイル等を自動生成する、Vagrantみたいなやつ。
インストール方法とかは親切な説明が用意してあるからこれを見れば良い。

インストールしたらジェネレータを作るコマンドを叩く:

hygen init self
hygen generator new component

init selfで
_templates/component/new/hello.ejs.tが作成される。
名前被り防ぐため_templatesから.hygenに変更しとく。

プロジェクトルートに.hygen.jsファイルを作って:

module.exports = {
  templates: `${__dirname}/.hygen`,
}

Hygenのテンプレートディレクトリについての設定を加える。
デフォルトのままで問題なければ変更の下りは不要である。

Vueコンポーネント用テンプレートの作成

やること:

  • 関数型のコンポーネントはJSファイル、そうでなければVueファイルを作る
  • 関数型かどうかでデフォルトの内容を変える
  • Storybookのstories.jsファイルを作る
  • test.jsファイルを作る

hello.ejs.tのファイル名をindex.ejs.tにして、
中身をVueファイル作るための内容に変える。

---
to: components/<%= name %>/index.vue
---
<template>
  <div></div>
</template>

<script>
export default {
  name: '<%= name %>',
  props: {
    select: {
      Type: String,
      default: 'foo'
    }
  }
};
</script>
<style lang="scss" scoped>
</style>

これでコマンドを叩くと、

hygen component new --name List

components/List/index.vueが生成される。

対話型プロンプトの追加

VueファイルかJSファイルかを指定するのに、オプションで --type vue としてもいいが、指定するものが増えたらいちいち書くのも面倒になってくるのが人の性。
なんとHygenならNuxtとかインストールするときの選択肢表示してくれるみたいなやつも導入できるので、これを利用しない手はない。

.hygen/component/new/prompt.jsを作成して、
コンポーネント名の入力と関数型にするかどうかの質問を追加。

module.exports = [
  {
    type: 'input',
    name: 'name',
    message: 'コンポーネントの名前(最初は大文字にする)',
    validate: str => /^[A-Z]/.test(str)
  },
  {
    type: 'toggle',
    name: 'functional',
    message: '関数型コンポーネントにする?',
    enabled: 'Yes',
    disabled: 'No'
  }
]

設定内容は依存先のプロンプトランナーenquirerのドキュメント参照。

これでユーザーが入力した内容を変数で受け取れるようになる。
テンプレートでの変数名はnameと同じものになる。

Hygenが出力するファイルを変えるのは単純に拡張子を変えればいいので、
脳死で三項演算すればおk。

---
to: "components/<%= name %>/index.<%= functional ? 'js' : 'vue' %>"
---

テンプレート出力内容の切り替え

関数型かどうかで中身を変えるのは、ejsの文法に従って作る。

<% if(functional) { -%>
export const <%= name %> = {
  name: '<%= name %>',
  functional: true,
  render(h) {
    return h('div', { staticClass: '<%= name.toLowerCase() %>' });
  }
};
export default <%= name %>;
<% } else { -%>
<template>
  <div></div>
</template>

<script>
export default {
  name: '<%= name %>',
  props: {
    select: {
      type: String,
      default: 'foo'
    }
  }
};
</script>
<style lang="scss" scoped>
</style>
<% } -%>

無駄な改行を防ぐため、if~else文のタグを<% -%>にしておくこと。

もし関数型もvueファイルにするならtemplateだけ分岐すれば良い。

<% if(functional) { -%>
<template functional>
  <div></div>
</template>
<% } else { -%>
<template>
  <div></div>
</template>
<% } -%>

ファイル生成の切り替え

to:nullを指定すると、そのテンプレートからファイルが生成されなくなる。

---
to: "<%= stories ? `components/${name}/index.stories.js` : null %>"
---

プロンプトにStorybookについての質問を追加して、

  {
    type: 'toggle',
    name: 'stories',
    message: 'Storybookファイルは必要?',
    enabled: 'Yes',
    disabled: 'No',
    initial: 'Yes'
  }

実行するとStorybookの質問に応じてファイル生成が切り替わる。
テスト用のファイルも同じ方法で出来る。

ヘルパーの利用

Hygenのテンプレート作成中に何かしら欲しい機能が出てきたら、ヘルパーを利用することができる。

自作する場合は、.hygen.jsファイルにhelpersを追加する。

module.exports = {
  templates: `${__dirname}/.hygen`,
   helpers: {
     sample: () => 'Sample Helper.'
  }
}

これをテンプレートで使うときは、h.ヘルパー関数名という構文になる。

<%= h.sample() %>

プロンプトからの変数も渡すことができる。

<%= h.sample(name) %>

内蔵されているヘルパーも充実している。

// example:
// <%= h.inflection.pluralize(name) %>
// <%= h.capitalize(message) %>
pluralize( str, plural )
singularize( str, singular )
inflect( str, count, singular, plural )
camelize( str, low_first_letter )
underscore( str, all_upper_case )
humanize( str, low_first_letter )
capitalize( str )
dasherize( str )
titleize( str )
demodulize( str )
tableize( str )
classify( str )
foreign_key( str, drop_id_ubar )
ordinalize( str )
transform( str, arr )

inflectionについてはライブラリのドキュメントに使い方が載っている。

キャメルケースへの変更など、ChangeCaseもヘルパー用意されている。

// example: <%= h.changeCase.camel(name) %>
camel( str )
constant( str )
dot( str )
header( str )
isLower( str )
isUpper( str )
lower( str )
lcFirst( str )
no( str )
param( str )
pascal( str )
path( str )
sentence( str )
snake( str )
swap( str )
title( str )
upper( str )

コンポーネント名をクラス名として流用するとき、コンポーネント名がキャメルケースだったら、全部小文字にしたうえでハイフンで区切るみたいなことをしたくなると思うが、そういうのもヘルパー使えば一発でできる。

export const <%= name %> = {
  name: '<%= name %>',
  functional: true,
  render(h) {
    return h('div', { staticClass: '<%= h.changeCase.paramCase(name) %>' });
  }
};
export default <%= name %>;

複雑な処理をするプロンプトの作成

prompt.jsを削除して、代わりにindex.jsを作成する。
同じ内容で書き換えるとindex.jsは次のようになる。

module.exports = {
  prompt: async ({ inquirer }) => {
    const questions = [
      {
        type: 'input',
        name: 'name',
        message: 'コンポーネントの名前(最初は大文字にする)',
        validate: str => /^[A-Z]/.test(str)
      },
      {
        type: 'toggle',
        name: 'functional',
        message: '関数型コンポーネントにする?',
        enabled: 'Yes',
        disabled: 'No'
      },
      {
        type: 'toggle',
        name: 'stories',
        message: 'Storybookファイルは必要?',
        enabled: 'Yes',
        disabled: 'No',
        initial: 'Yes'
      }
    ];

    return inquirer
      .prompt(questions)
      .then(answers => {
        const questions = [];

        return inquirer.prompt(questions).then(nextAnswers => Object.assign({}, answers, nextAnswers))
      })
  }
}

importするコンポーネントファイルを選択する

コンポーネントのimportするところ

import Icon from '@/components/Icon';

export default {
  name: 'App',
  components: { Icon },
}

これを自動生成に含める方法について。

コンポーネントをAtomicDesignベースにディレクトリ分けして作ったとする。

components/atoms/
components/molecules/
components/organisms/

index.jsに分類を選択させるプロンプトを追加すれば、生成時にディレクトリ分けができるようになる。

{
  type: 'select',
  name: 'atomic',
  message: 'コンポーネントの分類',
  choices: ['Atoms', 'Molecules', 'Organisms']
}

で、このうちMoleculesOrganismsが選択された場合に、importするコンポーネントを選択できるようにする。

ここまでの設定でコンポーネントの自動生成をすると、コンポーネントが全てディレクトリベースCompnentName/index.vueになるので、Node.jsのfsを使ってリストを作ることができる。

const getComponents = () => {
  const dirs = ['atoms', 'organisms', 'molecules'];
  const capitalize = txt => txt.charAt(0).toUpperCase() + txt.slice(1);
  let components = [];

  dirs.forEach(dir => {
    const dirpath  = process.cwd() + '/components/' + dir;
    fs.readdirSync(dirpath).forEach(file => {
      const name = capitalize(dir) + '/' + file;
      components.push({
        name,
        value: name
      });
    });
  });

  return components;
}

で、prompt.thenのところに分岐を追加する。

return inquirer
  .prompt(questions)
  .then(answers => {
    const { atomic } = answers;
    const questions = [];

    if (atomic !== 'Atoms') {
      questions.push({
        type: 'MultiSelect',
        name: 'importComponents',
        message: 'importするコンポーネントを選択',
        choices: getComponents()
      });
    } else {
      answers.importComponents = null;
    }

    return inquirer.prompt(questions).then(nextAnswers => Object.assign({}, answers, nextAnswers))
  })

これでAtoms以外を選択した時にマルチセレクトが表示される。

マルチセレクトで選択したものは配列になって渡される。
中身は ['Atoms/Foo'] になってるので、ディレクトリを小文字にしたりコンポーネント名だけ抽出したりという処理にはヘルパーを作って対応する。

  helpers: {
    componentName: c => c.split('/')[1],
    componentPath: c => c.split('/')[0].toLowerCase() + '/' + c.split('/')[1]
  }

あとはこれらをテンプレート側で利用する。

<template>
  <div class="<%= h.changeCase.paramCase(name) %>"></div>
</template>

<script>
<% importComponents && importComponents.forEach(c => { -%>
import <%= h.componentName(c) %> from '@/components/<%= h.componentPath(c) %>/';
<% }) -%>
export default {
  name: '<%= name %>',
<% if (importComponents) { -%>
  components: { <%= importComponents.map(c => h.componentName(c)).join(', ') -%> },
<% } -%>
  props: {
    select: {
      Type: String,
      default: 'foo'
    }
  }
};
</script>
<style lang="scss" scoped>
</style>

参考リンク

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください