MERYでフロントエンドエンジニアをしているcomyです。
MERYの記事作成ツールのフロントエンドをNuxt.js × Atomic Designで作り直した話を3回に渡って紹介していこうと思います。今回は、コンポーネント設計の話を中心に紹介していきます。
MERYの記事ができるまで
MERYでは毎日約80記事が公開されており*12019年2月時点、その9割をMERYの公認ライターが書いています。
MERYのミッションである『「好き」に出会える、「好き」を届けられる世界をつくる』ためには、ユーザーが共感できたり、モチベーションが上がるコンテンツを作る必要があります。
記事を書く公認ライターの多くが女子大学生であり、単に情報を発信するのではく、公認ライターそれぞれが思う等身大の「好き」を発信しているのがMERYの特徴です。
MERYの記事は、見出しやテキスト、画像、動画、商品などさまざまなアイテムを組み合わせながら1つの記事を作っており、30個から多いものだと50個以上のアイテムを組み合わせて作られています。
使用しているアイテムの中には一般の方が投稿したInstagramやYouTube等のコンテンツも含まれており、これらは全て投稿者に直接連絡を取り、使用の許諾を得ています。
使用の許諾が得られなかった場合には、コンテンツとして掲載できないため、別のものに差し替える必要があります。
このようにMERYではひとつの記事に非常に長い時間とコストをかけています。
しかし、現在の記事作成ツールは非常にレガシーなシステムとなっており、不具合の改修や機能追加も容易でない状態になっていました。
新しく入ったライターでも比較的短い時間で記事を書けるようにしたり、記事に合った画像を探しやすくしたりするなど、技術の力でコストを下げるために、新しい記事作成ツールを新規に開発することになりました。
これまでのフロントエンドの構成
これまでの記事作成ツールのフロントエンドではjQuery(2系)とビルドツールとしてGruntを使用していました。
記事作成ツールは機能が多く、動的にUIが変化するためゴリゴリのjQueryで組んでいる部分がたくさん存在していました。
また、追加の改修等で生まれたバグもたくさん存在しており、公認ライターたちはそのバグを把握した上で、バグをかわしながら上手く記事を書いているような状態でした。
このような背景もあり、新しい記事作成ツールでは動的UIに適していて破綻しない設計ができる構成が求められていました。
採用したフロントエンドの構成
新しい記事作成ツールでは、フレームワークとしてNuxt.js、画面設計の考え方としてAtomic Designを採用しました。
それぞれの採用の経緯は次の通りです。
Nuxt.jsに関しては、もともと他の社内システムでVue.jsを使っていたのに加え、直近の別プロジェクトですでにNuxt.jsを採用していたこともあり、ある程度知見があったのが、Nuxt.js採用の一番大きな要因です。
記事作成ツールは元々ページ数が少ないため、ルーティングの自動生成の重要性は低く、社内ツールのためSSR(Server Side Rendering)は不要でした。
しかし、ディレクトリ構成が規約としてある程度決められている点や、今後ページが増えてきた時のルーティングのことも考えると、メリットは豊富だったため採用にいたりました。
また。Nuxt.jsに併せてTypeScriptとPrettierも採用しました。
Atomic Designに関しては、現在の記事作成ツールを見ても、同じようなパーツが頻繁に使われており、Atomic Designの思想がコンポーネント指向のフレームワークと相性がよいと思い、Atomic Designを採用しました。
ディレクトリ構成
Nuxt.jsにAtomic Designの考え方を当てはめた時に、どのようなディレクトリ構成にしたのかを紹介します。
Atomic DesignはAtoms/Molecules/Organisms/Templates/Pagesの5つの要素で構成されます。
まずcomponentsディレクトリ配下にatoms/molecules/organismsディレクトリを作り、デフォルトで存在しているpagesディレクトリをそのままAtomic DesignのPagesとして当てはめます。
Templateに関しては、layoutsがその役割を担うようですがあまり活用できていないので使わないということにしました。
├ assets
├ components
│ ├── atoms
│ ├── molecules
│ └── organisms
│
├ layouts
├ middleware
├ pages
├ plugins
├ static
├ store
.
.
コンポーネント設計
Nuxt.jsはディレクトリ構成などに規約があり秩序が生まれるのが魅力的ですが、コンポーネント設計に関しては特にルールが設けられていません。
ある意味柔軟ではありますが、全くルールがないと無秩序になり破綻してしまう可能性が高まります。
いくらAtomic Designの考え方を当てはめたディレクトリ構成にしても、分類を単にコンポーネントの大きさだけで判断したりすれば一気に破綻するのは目に見えています。
Pagesに関してはルーティングが密に関わってくるため分類には困らないと思いますが、
Atoms/Molecules/Organismsの分類に関しては初めにある程度ルールを決めて開発を行っていくと、コンポーネントの責務が明確になり、分類がしやすくなります。
各粒度の分類方針
Atoms/Molecules/Organismsの分類指標を紹介します。
ここでは、現在開発している記事作成ツールの中から、画像検索モーダルを例にして説明します。

Atoms
Atomsは最小単位の要素です。
ボタンやラベル、入力フィールドなどがこれに該当します。
今回のプロジェクトではAtomsに関して次のルールを設けました。
- 他のコンポーネントを含まない
- 再利用性が高い
- 状態(ローカルステート)を持たない
- Vuexアクセス不可
こちらは検索ボタン部分のコンポーネントのコードになります。
<template> <button @click="callback(e)" :class="`button-${type} -${color} -${size}`"> <slot/> </button> </template> <script lang="ts"> import { Component, Prop, Vue } from 'nuxt-property-decorator' @Component export default class Button extends Vue { @Prop() type!: string @Prop() color!: string @Prop() size!: string callback(e: Event) { this.$emit('click', e) } } </script> <style scoped lang="scss"> .button-normal { @include btn-base; // ===================================== // Color Variations // ===================================== &.-pink { border: 1px solid $-color-pink; background-color: $-color-pink; color: $-color-white; } ・ ・ ・ &.-blue { border: 1px solid $-color-blue; background-color: $-color-blue; color: $-color-white; } // ===================================== // Size Variations // ===================================== &.-small { width: auto; min-width: 100px; height: 30px; line-height: 30px; font-size: $-font-size-normal; } ・ ・ ・ &.-large { height: 60px; line-height: 60px; font-size: $-font-size-xlarge; width: 300px; } } .button-icon-plus { @include btn-base; ・ ・ ・ } </style>
Molecules
MoleculesはAtomsを組み合わせて作る要素です。ボタンやラベル、入力フィールドを組み合わせた検索フォームなどがこれに該当します。
今回のプロジェクトではMoleculesに関して次のルールを設けました。
- Atoms コンポーネントで構成される
- 再利用性が高い
- 状態(ローカルステート)を持ってもよい
- Vuexアクセス不可
こちらは検索ボタンと入力フィールドを組み合わせた部分のコンポーネントのコードになります。
<template> <div class="content-searchForm"> <form @submit.prevent="search"> <div class="search-wrapper"> <input value placeholder class="search" type="text" v-model="input.value"> <button-base class="button" type="normal" color="pink" size="midium"> 検索 </button-base> </div> </form> </div> </template> <script lang="ts"> import { Component, Vue, Prop } from 'nuxt-property-decorator' import ButtonBase from '../atoms/Button.vue' @Component({ components: { ButtonBase, }, }) export default class ContentSearchForm extends Vue { @Prop() input?: { value: string } search() { this.$emit('search') } } </script>
MoleculesはOrganismsから渡されたデータをAtomsに渡したり、AtomsのイベントをOrganismsに伝えるパイプ役のような存在となっているのがわかるかと思います。
Organisms
Organismsは単体でも機能する要素です。検索フォームと検索結果を組み合わせた検索モーダルなどがこれに該当します。
今回のプロジェクトではOrganismsに関して次のルールを設けました。
- Atoms/Molecules/他のOrganismsコンポーネントで構成される
- 再利用性が低い
- 状態(ローカルステート)を持ってもよい
- Vuexアクセス可
こちらは検索フォームとプレビューを組み合わせた検索モーダル部分のコンポーネントのコードになります。
<template> <modal modalClass="-search" @closeModal="closeModal"> <p class="modal-header">画像の追加</p> <div class="modal-body"> <content-search-form :input="input" @search="search"> </content-search-form> <component :is="componentType()" :itemData="itemData" @convertImage="convertImage"> </component> </div> </modal> </template> <script lang='ts'> import { Component, Vue, Prop } from 'nuxt-property-decorator' import { Item, Description } from '../../store/article/types' import Modal from '../atoms/Modal.vue' import ContentSearchForm from '../molecules/ContentSearchForm.vue' import WhiteDomainPreview from '../molecules/WhiteDomainPreview.vue' import OwnedPreview from '../molecules/OwnedPreview.vue' import KeywordPreview from '../molecules/KeywordPreview.vue' @Component({ components: { Modal, ContentSearchForm, WhiteDomainPreview, OwnedPreview, KeywordPreview, }, }) export default class SearchImageModal extends Vue { @Prop() itemData?: Item<Description> input = { value: '', type: '', } preview = { value: '', type: '', } closeModal() { // モーダルを閉じる処理 } search() { // 検索ボタンを押した時の処理 // (this.inputをthis.previewにコピーする処理など) } componentType() { switch (this.preview.type) { case 'whiteDomain': return 'white-domain-preview' case 'owned': return 'owned-preview' case 'keyword': return 'keyword-preview' default: return } } convertImage() { // 追加ボタンを押した時の処理 // (Vuexにアクセスしてアイテムを追加する処理など) } @Watch('input.value') changeSearchType() { const owned = this.input.value.includes('example.mery.jp') const instagram = this.input.value.startsWith('https://www.instagram.com/p/') const whiteDomain = this.input.value.startsWith('http') const noText = this.input.value === '' if (owned) { this.input.type = 'owned' } else if (instagram) { this.input.type = 'instagram' } else if (whiteDomain) { this.input.type = 'whiteDomain' } else if (noText) { this.input.type = '' } else { this.input.type = 'keyword' } } } </script> <style scoped lang="scss"> ・ ・ </style>
ここでVuexへのアクセスがはじめて可能になり、追加ボタンを押した時にVuexを通じてアイテムをデータとして追加することができるようになります。
また、プレビュー部分は入力フィールドの内容によって動的にコンポーネントを切り替えるような作りになっています。
コンポーネントの切り替えはv-if
でも可能ですが、切り替えるコンポーネントが増えてくるとコード量が多くなるため:is
を使うことでスリムな記述にすることができます。
全体の構造
Atoms/Molecules/Organismsのルールを紹介したところで、全体的な構造を見てみましょう。
「Atomic Design by Brad Frost」*2Original: http://atomicdesign.bradfrost.com/chapter-2/で使用されている図の一部を用いています。

OrganismsとPagesのみVuexにアクセスができ、そこで取得した値をprops
を使って子コンポーネントに渡し、子コンポーネントで発生したイベントは$emit
によって親コンポーネントに伝わっていきます。
organismsのsearchメソッド
を辿っていくと、Atoms → Molecules → Organismsとイベントが伝わっているのがよくわかるかと思います。
まとめ
今回は、記事作成ツールをNuxt.js × Atomic Designで作り直した際のコンポーネント設計についての紹介をしました。
はじめてAtomic Designを導入したので苦労する場面が多くありましたが、導入したことによってコンポーネントの再利用性は高まったと感じています。
次回は実際のエディター部分の具体的な機能や記事内容を構成するアイテムコンポーネントの中身を紹介していこうと思います。
1. | ↑ | 2019年2月時点 |
2. | ↑ | Original: http://atomicdesign.bradfrost.com/chapter-2/ |