Atomic Designの実装例 〜Atomic Designを使ったコンポーネント指向のUI開発:Q〜

本稿は(序)(破)(Q)のシリーズの3番目の記事になります。

本稿では前回(破)で説明したAtomic Designを導入する際に工夫した点をふまえた実装例をJSフレームワークを使用する場合使用しない場合の2つの場合について紹介します。

シリーズ(序)ではAtomic Designの概要
シリーズ(破)ではAtomic Designの導入に際して工夫した点

について説明していますので、そちらをご覧ください。

実装例題材

下のサイトテンプレートを題材に実装していきます。

https://html5up.net/prologue

f:id:uggds:20180717000632p:plain

シチュエーションとしては、デザイナーからもらったデザインカンプをエンジニアが実装する想定で説明します。

コンポーネントの分割

エンジニアはデザインカンプをAtomic Designのコンポーネント単位に分割していきます。

コンポーネントの分割フロー図は(破)で以下のように定義しました。

f:id:uggds:20180717004055p:plain

まずは、ページをヘッダーやフッター、大きなセクションで分割します。
分割したコンポーネントはOrganismsになります。

f:id:uggds:20180716214347j:plain

それぞれをさらに分割していきます。 f:id:uggds:20180717001248j:plain f:id:uggds:20180717001410j:plain f:id:uggds:20180717001446j:plain f:id:uggds:20180717001504j:plain f:id:uggds:20180717001532j:plain

※「使いそう」という表現を使っていますが、実際は 「他のページやコンポーネント内で既に繰り返し使っている」または「これから繰り返し使うことがわかっている」状態でなければMoleculesに分類する必要はありません。迷ったらOrganismsにします。
今回、Moleculesに分類するコンポーネントが無くなってしまうためいくつかをMoleculesに分類しました。

実装

コンポーネント分割ができたら実装していきます。

フレームワークを使用する場合

f:id:uggds:20180722233533p:plain

今回実装で使用する主要な技術セットは以下になります。

  • nuxt: ^1.0.0
  • vue-styled-components: ^1.2.3"
  • @storybook/vue: ^3.4.8

筆者の事情によりNuxt.js を使用して実装していきますが、ReactでもVue.jsでも問題ありません。

Nuxt.js はユニバーサルな Vue.js アプリケーションを構築するためのコンポーネント指向のフレームワークです。
ここでは詳しい説明については割愛しますので公式ページを参照ください。

ja.nuxtjs.org

スタイリングにはstyled-componentsを導入しています。
Google TrendでCSS Modulesを抑えて近年急上昇している技術です。

これまでのスタイルリングはcssの定義をclass属性(className属性)とのマッピングによって行ってきましたが、styled-componentsはコンポーネントに「スタイル」の定義も持たせられるようにしたライブラリです。
これにより、コンポーネントをよりカプセル化しやすくなりました。

詳しくは公式ページで。(アイコンが印象的)
www.styled-components.com

Storybook は コンポーネント のスタイルガイドを作成するためのツールです。(破)で説明したように仕分けたコンポーネントは一覧化しないと開発がやりづらくなります。Stroybookはコンポーネントを一覧化するツールです。

https://storybook.js.org/

環境構築

まずは、Nuxtのプロジェクトテンプレートを構築します。

$ npm install -g vue-cli
$ mkdir AtomicDesignProject
$ cd AtomicDesignProject
$ vue init nuxt-community/starter-template src
$ cd src
$ yarn install

起動確認します。

$ yarn run dev

http://localhost:3000/

f:id:uggds:20180718213931p:plain

次にストーリーブックをインストールします。

$ npm i -g @storybook/cli
$ getstorybook

デフォルトではstoriesディレクトリ配下にxxx.story.jsファイルを作成していく設定ですが、vueファイルと同じディレクトリに作成する運用にしたいのと、storyファイルは一律stories.jsにしたいため、.storybook/config.js の中を以下のように書き直します。

 // automatically import all files ending in *.stories.js
-const req = require.context('../stories', true, /.stories.js$/);
+const req = require.context('../components', true, /stories.js$/);
 function loadStories() {
   req.keys().forEach(filename => req(filename));
 }

確認のため、Nuxt.jsプロジェクトを作成した時にサンプルとして作成されるAppLogo.vueファイルのストーリーファイルを作成してみます。

./src
├── README.md
├── assets
├── components
│   ├── AppLogo.vue
│   ├── stories.js     <--- 作成
│   └── README.md
├── layouts
├── middleware
├── node_modules
├── nuxt.config.js
├── package.json
├── pages
├── plugins
├── static
├── store
└── yarn.lock

stories.jsの中身は以下になります。

import { storiesOf } from '@storybook/vue'
import AppLogo from './AppLogo.vue'

storiesOf('AppLogo', module)
  .add('default', () => ({
    components: { AppLogo },
    template: `<AppLogo></AppLogo>`
  }))

起動して確認してみます。

$ yarn run storybook

http://localhost:6006/

f:id:uggds:20180718223730p:plain

左のタブにAppLogoコンポーネントのラベルが表示されています。
キャプチャではわかりませんがタブをクリックするとAppLogoに仕込んであるアニメーションが正常に作動します。

次にstyled-componentsをインストールします。

$ yarn add vue-styled-components

試しにAppLogo.vueをstyled-componentsでスタイリングしてみます。

-  <div class="VueToNuxtLogo">
-    <div class="Triangle Triangle--two"/>
-    <div class="Triangle Triangle--one"/>
-    <div class="Triangle Triangle--three"/>
-    <div class="Triangle Triangle--four"/>
-  </div>
+  <VueToNuxtLogo>
+    <TriangleTwo/>
+    <TriangleOne/>
+    <TriangleThree/>
+    <TriangleFour/>
+  </VueToNuxtLogo>

まずはマークアップ部分ですが、class属性をもつdiv要素ではなく全てコンポーネントのタグにしました。

- <style>
- .VueToNuxtLogo {
-   display: inline-block;
-   animation: turn 2s linear forwards 1s;
-   transform: rotateX(180deg);
-   position: relative;
-   overflow: hidden;
-   height: 180px;
-   width: 245px;
- }
- 
- .Triangle {
-   position: absolute;
-   top: 0;
-   left: 0;
-   width: 0;
-   height: 0;
- }
- 
- .Triangle--one {
-   border-left: 105px solid transparent;
-   border-right: 105px solid transparent;
-   border-bottom: 180px solid #41B883;
- }
- 
- .Triangle--two {
-   top: 30px;
-   left: 35px;
-   animation: goright 0.5s linear forwards 3.5s;
-   border-left: 87.5px solid transparent;
-   border-right: 87.5px solid transparent;
-   border-bottom: 150px solid #3B8070;
- }
- 
- .Triangle--three {
-   top: 60px;
-   left: 35px;
-   animation: goright 0.5s linear forwards 3.5s;
-   border-left: 70px solid transparent;
-   border-right: 70px solid transparent;
-   border-bottom: 120px solid #35495E;
- }
- 
- .Triangle--four {
-   top: 120px;
-   left: 70px;
-   animation: godown 0.5s linear forwards 3s;
-   border-left: 35px solid transparent;
-   border-right: 35px solid transparent;
-   border-bottom: 60px solid #fff;
- }
- 
- @keyframes turn {
-   100% {
-     transform: rotateX(0deg);
-   }
- }
- 
- @keyframes godown {
-   100% {
-     top: 180px;
-   }
- }
- 
- @keyframes goright {
-   100% {
-     left: 70px;
-   }
- }
- </style>
+ <script>
+ import styled from 'vue-styled-components'
+ 
+ const VueToNuxtLogo = styled.div`
+   display: inline-block;
+   animation: turn 2s linear forwards 1s;
+   transform: rotateX(180deg);
+   position: relative;
+   overflow: hidden;
+   height: 180px;
+   width: 245px;
+   @keyframes turn {
+     100% {
+       transform: rotateX(0deg);
+     }
+   }
+ `
+ const goright = `
+   @keyframes goright {
+     100% {
+       left: 70px;
+     }
+   }
+ `
+ const godown = `
+   @keyframes godown {
+     100% {
+       top: 180px;
+     }
+   }
+ `
+ const Triangle = styled.div`
+   position: absolute;
+   top: 0;
+   left: 0;
+   width: 0;
+   height: 0;
+ `
+ const TriangleOne = Triangle.extend`
+   border-left: 105px solid transparent;
+   border-right: 105px solid transparent;
+   border-bottom: 180px solid #41B883;
+ `
+ const TriangleTwo = Triangle.extend`
+   top: 30px;
+   left: 35px;
+   animation: goright 0.5s linear forwards 3.5s;
+   border-left: 87.5px solid transparent;
+   border-right: 87.5px solid transparent;
+   border-bottom: 150px solid #3B8070;
+   ${goright}
+ `
+ const TriangleThree = Triangle.extend`
+   top: 60px;
+   left: 35px;
+   animation: goright 0.5s linear forwards 3.5s;
+   border-left: 70px solid transparent;
+   border-right: 70px solid transparent;
+   border-bottom: 120px solid #35495E;
+   ${goright}
+ `
+ const TriangleFour = Triangle.extend`
+   top: 120px;
+   left: 70px;
+   animation: godown 0.5s linear forwards 3s;
+   border-left: 35px solid transparent;
+   border-right: 35px solid transparent;
+   border-bottom: 60px solid #fff;
+   ${godown}
+ `
+ 
+ export default {
+   components: {
+     VueToNuxtLogo,
+     TriangleOne,
+     TriangleTwo,
+     TriangleThree,
+     TriangleFour
+   }
+ }
+ </script>

次に<style>タグを全て削除し、代わりに<script>タグの中にTriangleOneTriangleFourといったスタイリングされたコンポーネントを作成しています。

styled-componentsを用いることでclass属性に対してスクレイピング的に行っていたスタイリングからコンポーネントに閉じ込める形で定義することができました。

※vue.jsにはスコープ付き CSSというスタイルをコンポーネントに閉じ込める方法がありますが、React でもstyled-componentsは使用することができるため、React <--> Vue に移行する場合に技術的差異が少なくすむ狙いもあってstyled-componentsを採用しています。

説明が長くなりましたが開発環境が整ったので、あとは先ほど仕分けしたAtomic Designコンポーネントを実装していくだけです。

完成後のcomponentsディレクトリは以下のようになりました。

./components/
├── atoms
│   ├── Button
│   │   ├── index.vue
│   │   └── stories.js
│   ├── InputText
│   │   ├── index.vue
│   │   └── stories.js
│   └── TextArea
│       ├── index.vue
│       └── stories.js
├── molecules
│   ├── Card
│   │   ├── index.vue
│   │   └── stories.js
│   ├── Profile
│   │   ├── index.vue
│   │   └── stories.js
│   └── Sns
│       ├── index.vue
│       └── stories.js
└── organisms
    ├── AboutMe
    │   ├── index.vue
    │   └── stories.js
    ├── Contact
    │   ├── index.vue
    │   └── stories.js
    ├── Footer
    │   ├── index.vue
    │   └── stories.js
    ├── Header
    │   ├── index.vue
    │   └── stories.js
    ├── Hero
    │   ├── index.vue
    │   └── stories.js
    ├── Main
    │   ├── index.vue
    │   └── stories.js
    ├── Menu
    │   ├── index.vue
    │   └── stories.js
    ├── MenuItem
    │   ├── index.vue
    │   └── stories.js
    ├── Portfolio
    │   ├── index.vue
    │   └── stories.js
    ├── PortfolioCards
    │   ├── index.vue
    │   └── stories.js
    └── Side
        ├── index.vue
        └── stories.js

Storybookは次のようになりました。
※Storybookにstorybook-addon-vue-infoというアドオンを適用しています。

f:id:uggds:20180719010530p:plain

Nuxt.jsとstyled-componentsとStorybookを使って、スタイルを閉じ込めたコンポーネントとそれらのコンポーネント一覧を作成することができました。

vueファイルとstoryファイルの両方を修正していく運用になります。

フレームワークを使用しない場合はどうする?

ReactやVue.jsといったコンポーネント指向のフレームワークを使用していない場合はこれまで通り、HTMLをマークアップし、class属性に対してCSSでスタイリングをしていくことになるかと思います。

現場によると思いますが、将来的にReactやVue.jsといったフレームワークを使用することになる可能性は十分あります。その場合でもスムーズに移行できるように共通のデザインシステムを使っておくべきかと考えます。

そのためには、Atomic Designを取り入れたCSS設計手法が必要になるのですが、目ぼしいものは見当たりません。
そこで、有名なCSS設計手法の一つであるFLOCSSをベースにAtomic Designの考え方を取り入れたCSS設計を考えました。

FLOCSS とは数あるCSS設計手法のひとつで、OOCSSやSMACSS、BEM、SuitCSSなどのメジャーな設計手法のいいとこどりをした国産の設計手法です。

https://github.com/hiloki/flocss

以下でそのAtomic DesignとFLOCSSを組み合わせたCSS設計手法について説明します。

Atomic Design + FLOCSS (AFLOCSS)

ファイル・ディレクトリ構成

下記のような構成とします。

./css/
├── foundation
├── layout
│   └── template
└── object
    ├── atoms
    ├── molecules
    └── organisms

Foundation

Reset.cssやNormalize.cssなどを用いたブラウザのデフォルトスタイルの初期化や、プロジェクトにおける基本的なスタイルを定義します。 ページの下地としての全体の背景や、基本的なタイポグラフィなどが該当します。 - FLOCSSより引用

Layout

ページを構成するヘッダーやメインのコンテンツエリア、サイドバーやフッターといったプロジェクト共通のコンテナーブロックのスタイルを定義します。 基本的には、ページ単位で唯一の存在である要素となるため、Layoutレイヤーの要素ではIDセレクタを採用することも可能です。 - FLOCSSより引用

Atomic DesignではTemplatesにあたるため、この階層にTemplateファイルを作成します。

Object
FLOCSSではプロジェクトにおける繰り返されるビジュアルパターンをすべてObjectと定義します。 AFLOCSSでのObjectでは、Object配下がAtoms, Molecules, Organismsの3つのレイヤーに分けられます。
Utilityはすべてmodifierで表現できると考えられるため原則なくなります。

命名規則

MindBEMding

BEM(MindBEMding)のシンタックスである、Block、Element、Modifierに分類して構成される規則を採用します。
Modifierの命名の派生パターンとして、JavaScriptで操作されるような「状態」を表すようなModifierについては、SMACSSのStateパターンの命名を拝借し、is-*プレフィックスを付与し、.is-activeというようにすることもできます。 このアイデアを採用する場合の原則として、.is-activeそのものにルールを持たせるのは禁止します。これは .is-activeそのものが持つルールが、 他のモジュールのModifierのスタイルを汚染してしまうのを防ぐためです 。
- FLOCSSより引用

プレフィックス
役割を明確にするためにプレフィックスをつけます。

  • Atoms - .a-*
  • Molecules - .m-*
  • Organisms- .o-*
  • Templates - .t-*

今回の題材ではapp.scssのようなファイルは次のようになりました。

// ==========================================================================
// Foundation
// ==========================================================================

@import "foundation/_reset";
@import "foundation/_base";

// ==========================================================================
// Layout
// ==========================================================================

@import "layout/_template";

// ==========================================================================
// Object
// ==========================================================================

// -----------------------------------------------------------------
// Atoms
// -----------------------------------------------------------------

@import "object/atoms/_button";
@import "object/atoms/_inputText";
@import "object/atoms/_textArea";

// -----------------------------------------------------------------
// Molecules
// -----------------------------------------------------------------

@import "object/project/_card";
@import "object/project/_profile";
@import "object/project/_sns";

// -----------------------------------------------------------------
// Organisms
// -----------------------------------------------------------------

@import "object/utility/_aboutMe";
@import "object/utility/_contact";
@import "object/utility/_footer";
@import "object/utility/_header";
@import "object/utility/_hero";
@import "object/utility/_main";
@import "object/utility/_menu";
@import "object/utility/_menuItem";
@import "object/utility/_portfolio";
@import "object/utility/_portfolioCards";
@import "object/utility/_side";
カスケーディングと詳細度

原則、以下を禁止事項とします。

  • モジュール間のカスケーディング
  • 他のモジュールを親とするセレクタを用いたカスケーディング
  • 同一レイヤーにおけるモジュール間のカスケーディング

そのレイヤーにおいて、特定のモジュールに依存することなく、モジュールとして独立して再利用できるべきであり、混在させることによって他の開発者が予想しない挙動になるべきではないためです。

// Atoms
.a-link .a-button {
  ...
}

// Molecules
.m-card .m-sns {
  ...
}

// Organisms
.o-hero .o-aboutMe {
  ...
}

例外として、レイヤー間におけるカスケーディング、例えば、MoleculesレイヤーがAtomsレイヤーのモジュールを変更することは許容します。
ただし、このような例であってもセレクタのカスケーディングをおこなう必要はなく、Modifierによって拡張することによって解決することができる場合があります。

コンポーネント一覧化

ReactやVue.jsフレームワークを使用しない場合はStorybookが使えません。
そのため、それに代わるコンポーネント一覧ツールが必要になります。

※最新版のStorybook v4.0.0-alpha.14ではフレームワークを使用しないStorybook for HTMLというものがサポートされていますが、sassのようなプリプロセッサとの併用に関するドキュメントが少ないため導入検討中です

そこでsc5というCSSスタイルガイドジェネレーターを使用します。

styleguide.sc5.io

スタイルガイドジェネレータは数多くあるのですが、sc5はCSSに決められたフォーマットでコメントを書くだけでスタイルガイドが生成できるため簡単に始められます。

インストールにはgulpが必要になります。

$ yarn add -D gulp gulp-sass styleguide

gulpfile.jsを作成します。

var gulp = require('gulp');
var styleguide = require('sc5-styleguide');
var sass = require('gulp-sass');
var outputPath = 'output';
 
gulp.task('styleguide:generate', function() {
  return gulp.src('*.scss')
    .pipe(styleguide.generate({
        title: 'My Styleguide',
        server: true,
        rootPath: outputPath,
        overviewPath: 'README.md'
      }))
    .pipe(gulp.dest(outputPath));
});
 
gulp.task('styleguide:applystyles', function() {
  return gulp.src('main.scss')
    .pipe(sass({
      errLogToConsole: true
    }))
    .pipe(styleguide.applyStyles())
    .pipe(gulp.dest(outputPath));
});
 
gulp.task('styleguide', ['styleguide:generate', 'styleguide:applystyles']);

scssファイルに以下のようなコメントを記述します。

// Button
//
// ボタン
//
// default - Default Button
// a-btn--accent - Accent Button
// a-btn--primary - Primary Button
//
// markup:
// <div class="a-btn {$modifiers}">{$modifiers}</div>
//
// Styleguide 1.1.0
.a-btn {
  ...
  &:hover {
    ...
  }
  &--accent {
    ...
  }
  &--primary {
    ...
  }
}

最後にpackage.jsonを修正します。

  "scripts": {
    ...
+    "styleguide": "gulp styleguide"
  },

実行します。

$ yarn run styleguide

http://localhost:3000/
ポート番号はオプションで変えられます。

f:id:uggds:20180722124115p:plain

scssファイルのコメントに記載した番号が階層を表しているので、
下のように採番してAtoms、Molecules、Organismsでカテゴライズしています。

// Atoms
// Styleguide 1.0.0

// Button
// ...
// Styleguide 1.1.0

// Molecules
//
// Styleguide 2.0.0

// Organisms
//
// Styleguide 2.0.0

このようにscssファイルの実装とそのコメントを修正していく運用になります。
スタイルガイドは実装とは別ファイルで管理されることが多くて更新が滞ってしまいがちですが、これなら実装と結びついているため更新が滞ることが少ないかと思います。

(Q)のまとめ

フレームワークを使用する場合と、使用しない場合の両方の実装方法について紹介しました。

やってみて感じたことはAtomic Designのコンポーネントの分け方(特にMoleculesとOrganisms)が定義されていればそれほど迷うことなく実装できると思いました。

デザイナーと協業して開発する方法については今回言及していませんが、一覧をつくっておくと協業がスムーズになります。
破綻する前に少なくても作って置くべきかと思います。

シリーズまとめ

これまでコンポーネント指向でUIを開発してきて、いろいろな手法を検討しましたが、どれもコンポーネントの単位が定まらず破綻していきました。

そのときAtomic Designというメンタルモデルに注目するようになり、この記事にまとめることになりました。

メンタルモデルとは認知心理学の用語で、「あることに出くわしたときに、それをどう解釈/判断し行動するか」について指標となるモデルのこと。

調べていくと実装に適用することは考えられていないことがわかりましたが、
現在の技術(Reactや Vue.js)やアイデア(FLOCSSなど)をベースに考えると十分実装可能な概念だと思いました。

どうしてもオレオレAtomic Designになりがちですが、どの現場でもなるべく使えるように考えたつもりです。どこかの現場の参考になれば幸いです。

今後について

シリーズは実は完結していません。
この方法で数年運用してみても破綻してないか、困ったことはないかを書いて完結になると思っています。

完結編はAtomic Designを使ったコンポーネント指向のUI開発:||(2020年公開予定)でご紹介しようと思います。