Atomic Designとは?
知っている方は、こちらまで読み飛ばして下さい。
Atomic Designには階層と、それぞれの役割がある
原典によると、
- Atoms(原子)
- Molecules(分子)
- Organisms(生体)
- Templates(テンプレート)
- Pages(ページ)
の5つの階層がある。それぞれ説明すると、
Atoms 原子
現実世界における原子 = それ以上分割できない
ユーザーインタフェースにおける原子 = 分割すると機能しないから、それ以上分割できない
これをみて、「分割できるかどうか」=「粒度」を追い求めてしまうことが多いが、それはあまり意味がない。
大事なのは粒度ではなく、Atomにするメリットを享受できるかどうかにある。
例えば、タイトル
タイトル = 文字サイズが28pxで、下に線が引かれている
といった具合に統一したいなら、原子にすべきだ。
ページ内のタイトルはすべて統一される。
粒度にとらわれない方法
5秒以内に原子ではないと判断できなかったら原子にしておくというやり方がある。
その後、分子を作るときは、既にある原子の中から選べば、最適化される。
Molecules 分子 = 原子の集まり
分子は、レイアウト以外の目的を達成するために作る。
キーボードのキーが原子なら、テンキーは分子で、1~9まであることで数字を打ち込むという目的を達成している。
入力欄の横に、「検索」と書かれたボタンがあるなら、おそらくそれは「検索」を目的としている。
ちなみに、規約を読んでいただくと分かるのだけど、原子以外の4階層で、分子とその他には決定的な違いがあるので、粒度に悩むことはない。
Organisms 生体 = なにかの集合
生体なので、現実世界だと人間とかのことになる。
原典だと、生体は分子・原子・ほかの生体から構成される集合体になるらしい。
ただ当規約では、生体が原子を参照するのはNGにしているから、実質、分子と他の生体の集まりになる1。
リストは、リストアイテムという分子の集合の生体。
ナヴィゲーションバーは、ロゴとメニューと…の集合の生体。
というように、分子を配置=レイアウトする役割を負っている。
ちなみに、粒度については、これまた規約を守ると悩むことはほぼない。
Templates テンプレート = 全体感を決めるファクター
生体を更に組み合わせたページ全体のレイアウトを定義することになる。
テンプレートは、制約であると考えるといい。
ワンカラムレイアウトをテンプレートにしたなら、ページはワンカラムでそこに何か入っているような感じになる。
よくあるランディングページのように、最初に表示される画面の80%をでかい画像で埋め尽くすようにしたいなら、テンプレートはそうなるように作るべきだ。
テンプレートが設けた枠には、生体を埋めていくことになる。
Page ページ
今までの説明で、テンプレート、そこに埋め込む生体、構成する分子、原子は存在する。
ただ、
分子に流し込むデータがない。
⇒ リストとリストアイテムの部品はあるけど、いくつ何を表示するかはわからない
分子に対して、ユーザーが何かアクションを起こしたときの処理がありません。
⇒
よってそれを注入してあげます。
こうやって書くと、あたかも原子から作るように思えるけど、私はページから作ります。
なぜならページが決まらないと、何が必要かわからないからです。
ページの目的がわからなければ、雰囲気もレイアウトもわかりません。
ページのコンテンツにリストデータが入っていなかったら、テーブルなんて作る必要なかったってことになります。
もう既にあったら使えばいいんです。
必要ないなら作らなければいいんです。
規約
とりあえず列挙させてほしい。
細かい説明をこれからしていくが、全体感はこんな感じである。
- TemplatesとOrganismsはスロットによって要素を受け取り、属性によりデータを受け取らないこと
- MoleculesとAtomsは属性によってデータを受け取り、スロットにより要素を受け取らないこと
- Store, Routerにアクセスするのは、Pagesのみにすること
Mixinは利用しないこと- PagesはTemplates・Organism・Moleculesに依存し、Atomsに依存しないこと。
- Templates・Organism・Atomsは他の階層に依存しないこと
- MoleculesはAtomsのみに依存すること
- atomsはグローバルコンポーネントへの登録を避けること
- 同じ階層は相互に依存し合わないこと
- Templatesはネストしてはならない
- 汎用性を意識し過ぎて、手を止めないこと
属性とスロットに関する規約
- TemplatesとOrganismsはスロットによって要素を受け取り、属性によりデータを受け取らないこと。
- MoleculesとAtomsは属性によってデータを受け取り、スロットにより要素を受け取らないこと
属性とかスロット(Slot)と言うとWebに限定されてしまうので、少し汎用的な言い方をしてみると、
属性とは、構造と型を持つデータを注入するインターフェース。
スロットとは、コンポーネントを注入する為のインターフェース。
としておく。
これは、TemplatesとOrganismsにはレイアウトを、MoleculesとAtomsにはデータ構造の描写を担当させるための策だ。
MoleculesとAtomsは自動テストが組める。
属性を受け取っているかをテストして、バインドされていることを確認すればいい。
TemplatesとOrganisms、つまりレイアウトは自動テストができない。
テストを組んでもらうのは自由なのだが、あんまり意味がない。
なぜならDOMの属性や構造のチェックをいくら行っても、その確認自体が想定通りにレイアウトされていることの保証にならない為だ。
なので実際に動かして、目視したりするしかない。
「実際に動かして」を自動化することは、効率化につながるので是非するべきである。
ちなみに画像のキャプチャ(あるいはどこかの時点でのスナップショット)は納品を求められない限り、行う意味はない。
(レビューに使うという意見もあるだろうが、レビューをするなら実際にプロダクトを動かすべきだ。)
余談だが、
「費用対効果が低いからフロントエンドの自動テストをしない」
に対して、少し距離を感じている。
「今の構成だと、テストを個別にできないから、1つのカバレッジを確認する為のテストのコストが大きくなる」
ので自動テストをしないなら理解できる。しかしモジュール化を正しく行えば、
ストアはただの関数だからテストできるし、
コンポーネントも属性とバインドという意味では、入出力のある関数と同じようにテストは可能で、コストはサーバーサイドと変わらないはずだ。(フレームワークがサポートしていないとそもそもできないかもしれない)
実際のテストの例は、近日中に追記する予定なのだが、フロントも自動テストはすべきだと思う。
単純化のための規約
- Store, Routerにアクセスするのは、Pagesのみにすること
Mixinは利用しないこと
タイトルの通り、単純化をするためのものだ。
StoreやRouterからデータの受け渡しをしているのを確認したかったら、Pagesを見ればわかる。
を防ぐ。
ただし注意点として、これはPages以外のコンポーネントが外部接続をしてはいけないわけではないということ。
例えばGoogle Mapのコンポーネントだったら、APIの呼び出しごとカプセル化されていた方が都合がいいのは間違いない。
Mixinで色々共通化したはいいけど、一目で分からなくなった。
と書いたのだが、Mixinの問題はMixinではなく、実装する側にあることに気づかされた。
こちらの記事のように効果的に使う分には、全く問題が無かったためだ。
よって、Mixinは利用しないことは消した。
依存関係の規約
- PagesはTemplates・Organism・Moleculesに依存し、Atomsに依存しないこと。
- Templates・Organism・Atomsは他の階層に依存しないこと
- MoleculesはAtomsのみに依存すること
- atomsはグローバルコンポーネントへの登録を避けること
- 同じ階層は相互に依存し合わないこと
これらを守ると依存関係は図のようになる。
依存関係を簡潔にすることは、影響範囲を減らすのに役に立つ。
例えば、Templateを変えても、Organismに影響は出ないし、Moleculesを変えても、Templatesに影響はしない。
また、データとイベントの向きはこうなる。
Moleculeは目的のためのデータを受け取り、属性を通してAtomに流しこむ。
またAtomからのイベントを集約し、Pagesに伝えることができる。
例えば、入力欄に虫メガネのアイコンが入っている「検索用」のMoleculeがあったとする。
アイコンの代わりに、右にボタンをつけたくなったら、MoleculeにボタンのAtomを追加することになる。
よくあるやり方として、入力欄でEnterキーを押すと検索ができることがある。
その場合、ボタンのClickイベントと入力欄のKeydownのイベントを集約して、一つのイベント(Searchとか)にして、Pagesに通知できる。
Templatesに関する規約
- Templatesはネストしてはならない
これは当たり前な気もするのだけれど、想像してみてほしい。
聖杯レイアウトの中央のメインコンテンツの中に、聖杯レイアウトが入れ子になっている様子を。
ばかげているが、マジレスするとそれをやるなら、元からそういうレイアウトの1つのテンプレートにすべきだ。
マインドに関する規約
- 汎用性を意識し過ぎて、手を止めないこと
確認ですが、世に出るCSS Frameworkを開発しているわけではありませんよね?
なら、あの場合はこの場合はと考えを巡らせるよりは、あとからリファクタリングしたほうが効率的です。
AtomsだからとかMoleculesだからとか考えて、無理に汎用化する必要なんてどこにもないです。
必要になったら追加すればいいんです。
あと、名前の付け方による汎用性もこだわりすぎる必要はないです。
「TodoBoxだとTodo以外使えないし、Cardにしよう」とか考えなくていいです。
Cardをほかのどこかでも使わなければと焦っても意味はないです。
それはそのままデザインの制約にもつながります。
使うときが来たら、名前を変えてあげればいいんです。
コンポーネント名の命名ルール(ちょっと脱線)
namespace(アプリ・システム境界) + ファイル名で登録する。
Vueの場合は、パスカルケースがケバブになり、ちょっと便利。
Atomsはnamespaceを超えて使用したいので、moleculesの中でimportし、ローカルコンポーネントに登録する。
// namespace for Todo App
const namespace = "Td"
import Manage from './pages/Manage';
Vue.component(namespace + 'Manage', Manage);
Snippet(VSCode)
{
"VueComponentRegist": {
"scope": "javascript,typescript",
"prefix": "VCR",
"body": ["import $2 from './$1/$2'", "Vue.component(namespace + '$2', $2)"],
"description": "Register Vue Component."
}
}
実際に作ってみる
ここまでずらっと規約やら説明したので、コーディングに移りたいと思う。
とりあえず、簡単に作れそうなTODOアプリを作ってみる。
簡単なデザイン
初期のデザインは、簡単に変更するために、いきなりコーディングには入らないことが多い。
Excelでもいいが、Officeを用意する余裕はプロトタイピングツールに充てたほうがいい気がする。
ちなみに、おすすめはFigma。
実装
Framework
- Vue.js
- Tailwind CSS
- Webpack
Templates
Templateは、レイアウト制約だと考えている。
次のList Templateの例だと、Filterの位置を右上に制約した。
一方、Action
というのは抽象的な表現で、制約がゆるい。
List
画面幅いっぱいのdiv
の中に、container
を中央配置している。
filter
, items
, action
の3つのスロットを用意し、
それぞれのマージンなどもここで設定し、レイアウトを固定している。
<template>
<div class="w-screen h-screen flex justify-center bg-grey-lighter">
<div class="container mt-20 max-w-sm">
<div class="flex justify-end">
<slot name="filter"></slot>
</div>
<div class="mt-4">
<slot name="items"></slot>
</div>
<div class="mt-6">
<slot name="action"></slot>
</div>
</div>
</div>
</template>
Organisms
ドメインを表現したりする。
リストレンダリングしたり、階層化に使ったりする。
Todo Status
TODOのステータスを表示するエリア。
イメージ通り、ただ枠を作るだけになる。
不要かもしれないが、今何もないだけであって、変更に強いアプリケーションにするためには必要になる。
<template>
<div>
<slot></slot>
</div>
</template>
Todo Item
TODOのアイテムを表示するエリア。
3つのエリアに分かれていて、それぞれのパディングなどを設定している。
<template>
<div class="bg-white rounded mb-4 shadow">
<div class="pl-4 pr-3 pt-6 pb-4">
<slot name="header"></slot>
</div>
<div class="pl-4 pr-3 py-1">
<slot name="content"></slot>
</div>
<div class="pl-4 pr-3 pt-4 pb-3 flex flex-row-reverse">
<slot name="footer"></slot>
</div>
</div>
</template>
Todo Add
TODOアイテムを追加する為の何かを入れるエリアになる。
<template>
<div>
<slot></slot>
</div>
</template>
Molecules
正直、TemplateとOrganismを作っただけだと、中身が無くて、大抵何も表示されないことが多い。
なので、Moleculesあたりから少し楽しくなってくる。
Tags
タグの配列を受け取り、レンダリングし、select
イベントで選択を通知する。
Tag
単体でMoleculeにするのも選択肢としてありうる。
ただ、AtomにするかMoleculeするかは、Atomの粒度によるので、どちらが正解ということもないと考えていい。
例えば、タグ=Text + Boxととらえて、TextとBoxだけでAtomにしたいならそれでもいい。
逆に、タグは1つのAtomと捉えてもいい。
そこに議論と思考の時間を割くのが一番もったいなくて、あとからリファクタリングできることの方が大事。
<template>
<div class="flex">
<x-tag
v-for="(label, index) in labels"
:key="label"
:class="{
// first
'rounded-tl-lg': labels[index - 1] === undefined,
'rounded-bl-lg': labels[index - 1] === undefined,
// last
'rounded-tr-lg': labels[index + 1] === undefined,
'rounded-br-lg': labels[index + 1] === undefined,
}"
style="margin-right: 1px"
:active="label === selected"
:label="label"
@click="$emit('select', label)"
></x-tag>
</div>
</template>
<script>
import Tag from "../atoms/Tag.vue";
export default {
components: {
XTag: Tag
},
model: {
prop: "selected",
event: "select"
},
props: {
labels: {
type: Array,
required: true
},
selected: {
type: String,
default: ""
}
}
};
</script>
Title
ぱっと見、ただのテキストなので、Atomのような気もしそうだが、Moleculeにした。
というのも、MoleculeはAtomの1個以上の集まりと捉えていて、Atom1個でもMoleculeでいい。
例えば、Text系はデフォルトで使う文字の色(黒じゃなく濃いグレー)みたいな統一はAtomでやっているから、Atomに依存することには意味がある。
また、デザイン上ではわからなかったが、このTitleは編集可能にしたため、実はInput
用のAtomも混じっている。
この場合は、changeイベントのみを拾っているが、他のイベント(keypress.enterとか)をchangeとしてemitしてあげたりすると、使う側としては、changeのみを購読すればいいので、楽だしわかりやすくもなる。
<template>
<span>
<x-input
:placeholder="placeholder"
v-if="editable"
:value="title"
@change="$emit('change', $event.target.value)"
></x-input>
<x-text v-else class="font-bold text-lg" :text="title"></x-text>
<x-text class="text-grey-darker text-sm" :text="anchorText"></x-text>
</span>
</template>
<script>
import Input from "../atoms/Input.vue";
import Text from "../atoms/Text.vue";
export default {
components: {
XInput: Input,
XText: Text
},
model: {
prop: "title",
event: "change"
},
props: {
title: {
type: String,
required: true
},
anchorText: {
type: String,
default: ""
},
editable: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: "入力してください。"
}
}
};
</script>
Date Labeled
Title
と同じで、編集可能。
また、どうでもいいこだわりだが、あえて日付は文字列に限定している。
日付のフォーマットを決める責務をPagesかStoreに果たしてもらおうと考えている為だ。
特に日付だけというわけではないのだが、
どう表示したいかはロケールやドメインに依存すると考えているためだ。
<template>
<div class="flex items-end">
<span>
<x-input
v-if="editable"
type="date"
:value="date"
:placeholder="placeholder"
@change="$emit('change', $event.target.value)"
></x-input>
<x-text v-else class="mr-4 text-sm" :text="date"></x-text>
<x-text class="text-grey-darker text-sm" :text="label"></x-text>
</span>
</div>
</template>
<script>
import Input from "../atoms/Input.vue";
import Text from "../atoms/Text";
export default {
components: {
XInput: Input,
XText: Text
},
model: {
prop: "date",
event: "change"
},
props: {
date: {
type: String,
required: true
},
label: {
type: String,
default: ""
},
editable: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: "日付を入力してください。"
}
}
};
</script>
Button
ボタンをAtomに分けていないのは、単なる怠惰だけれども、原理主義的になりすぎるのもいけない。という言い訳。。。
<template>
<button
class="py-2 bg-grey rounded bgcolor-transition font-sans text-white text-sm font-bold tracking-wide"
:class="{
'bg-teal': primary,
'hover:bg-teal-dark': primary,
'bg-red-lighter': danger,
'hover:bg-red-light': danger,
}"
style="min-width: 80px"
@click="$emit('click', $event)"
>{{label}}</button>
</template>
<script>
export default {
props: {
primary: {
type: Boolean,
default: false
},
danger: {
type: Boolean,
default: false
},
label: {
type: String,
required: true
}
}
};
</script>
<style scoped>
.bgcolor-transition {
transition: background-color 0.3s ease 0s;
}
</style>
Area Clickable
<template>
<div
class="border border-dashed border-grey-dark py-4 text-center bg-grey-lighter hover:bg-grey-lightest rounded cursor-pointer bgcolor-transition"
@click="$emit('click', $event)"
>
<x-text class="font-sans text-grey-darker tracking-wide" :text="label"></x-text>
</div>
</template>
<script>
import Text from "../atoms/Text";
export default {
components: {
XText: Text
},
props: {
label: {
type: String,
required: true
}
}
};
</script>
<style>
.bgcolor-transition {
transition: background-color 0.15s ease 0s;
}
</style>
Atoms
主にHTMLのタグやらをそのまま使う階層になる。
Moleculesを作っていると、自然に出てくることが多い。
Atomの粒度は、人それぞれな気もするが、便利と思える粒度で問題ないと思う。
Tag
<template>
<div
class="tag bg-white shadow font-sans flex justify-center items-center cursor-pointer"
:class="{
'border': active,
'border-indigo-dark': active,
}"
@click="$emit('click', $event)"
>{{label}}</div>
</template>
<script>
export default {
props: {
active: {
type: Boolean,
default: false
},
label: {
type: String,
required: true
}
}
};
</script>
<style scoped>
.tag {
width: 49px;
height: 22px;
font-size: 12px;
color: #785e5e;
}
</style>
Text
<template>
<span class="text-grey-darkest">{{text}}</span>
</template>
<script>
export default {
props: {
text: {
type: String,
required: true
}
}
};
</script>
Input
<template>
<!-- normal text input -->
<input
v-if="type === 'text'"
class="text-sm text-grey-darkest border-b appearance-none outline-none focus:border-grey-dark transition"
type="text"
:placeholder="placeholder"
:value="value"
@input="$emit('input', $event)"
@change="$emit('change', $event)"
>
<input
v-else-if="type === 'date'"
class="text-sm text-grey-darkest"
type="datetime-local"
:placeholder="placeholder"
:value="value"
@input="$emit('input', $event)"
@change="$emit('change', $event)"
>
</template>
<script>
export default {
model: {
prop: "value",
event: "change"
},
props: {
value: {
type: null
},
type: {
type: String,
default: "text"
},
placeholder: {
type: String,
default: ""
}
}
};
</script>
<style scoped>
.transition {
transition: border-color 0.2s ease 0s;
}
</style>
Pages
上で、作ったものを組み立てたり、データのハンドリングやイベントハンドリングの処理を書いたり。
もしくは、上で作っているものは中は空っぽでもいいから、先にページを作ることもできる。
そうすると、属性とかイベントハンドリング・スロット名が全体感を見ながら決められるため、その方がよかったりする。
Manage
管理ページなので、Manage。
イベント処理、データ渡し、コンポーネントの配置などすべて行う為、非常に長くなる。
だが、ドメインとしての要素が全体感を通して見れるので、まあまあ可読性はあると思っている。
StoreとかUtilityは分離しているので、いいと思っている。
これはたぶんスキルの問題だが、ComputedやVuexのGettersを使いすぎると、変更検知されすぎて、自分でも動きがわからなくなってバグの原因になったりする。
それなら、多少冗長でも処理の因果関係が追いやすいことを意識して書いている。
<template>
<td-list>
<template v-slot:filter>
<td-todo-status>
<td-tags :labels="status" v-model="selectedStatus" @select="refresh"></td-tags>
</td-todo-status>
</template>
<template v-slot:items>
<td-todo-item v-for="todoItem in todoItems" :key="todoItem.id">
<template v-slot:header>
<td-title :title="todoItem.name"></td-title>
</template>
<template v-slot:content>
<td-date-labeled
:date="todoItem.deadline | formatDate"
:label="todoItem.status === 'WIP' ? 'までに終わらせる' : 'までに終わらせる予定が'"
></td-date-labeled>
<td-date-labeled
class="mt-4"
v-if="todoItem.status === 'DONE'"
:date="todoItem.completionDate | formatDate"
label="に終わった"
></td-date-labeled>
</template>
<template v-slot:footer>
<td-button
v-if="todoItem.status === 'WIP'"
primary
label="DONE"
@click="changeStatus(todoItem.id, 'DONE')"
></td-button>
<td-button
v-if="todoItem.status === 'DONE'"
danger
label="DELETE"
@click="deleteTodo(todoItem.id)"
></td-button>
</template>
</td-todo-item>
</template>
<template v-slot:action>
<td-todo-add v-if="!adding">
<td-area-clickable label="TODOアイテムを追加する" @click="adding = true"></td-area-clickable>
</td-todo-add>
<td-todo-item v-if="adding">
<template v-slot:header>
<td-title v-model="newTodoName" editable anchor-text="を"></td-title>
</template>
<template v-slot:content>
<td-date-labeled v-model="newTodoDeadline" label="までに終わらせる" editable></td-date-labeled>
</template>
<template v-slot:footer>
<td-button primary label="OK" @click="addTodo(newTodoName, newTodoDeadline)"></td-button>
</template>
</td-todo-item>
</template>
</td-list>
</template>
<script>
import todoStore from "../store/todo";
import { formatDate } from "../util";
import moment from "moment";
export default {
data() {
return {
status: ["WIP", "DONE", "ALL"],
selectedStatus: "ALL",
todoItems: [],
adding: false,
newTodoName: "",
newTodoDeadline: ""
};
},
computed: {
targetStatus() {
return this.selectedStatus === "ALL"
? ["WIP", "DONE"]
: [this.selectedStatus];
}
},
filters: {
formatDate
},
methods: {
refresh() {
this.todoItems = todoStore.findAll({
status: this.targetStatus
});
this.newTodoName = "";
this.newTodoDeadline = "";
},
changeStatus(id, status) {
todoStore.update(id, {
status,
// register now as completion date.
completionDate: new Date()
});
this.refresh();
},
deleteTodo(id) {
todoStore.delete(id);
this.refresh();
},
addTodo(name, deadline) {
// TODO validate input value.
todoStore.add({
name,
deadline
});
this.adding = false;
this.refresh();
}
},
created() {
this.refresh();
}
};
</script>
感想
作っていて思ったのは、用語集が欲しい!とうこと。
こういうパーツのことなんて呼べばいいんだろう。。。みたいな命名時の悩みに無駄な時間を割かないよう、ハンドブックみたいなものがあると嬉しいと思った。
あと、やっぱりTailwind CSSは使いやすい。
CSSの知識は必要だが、テーマ機能も簡単だし、さくっと動かせてカスタマイズ性が高いのは非常にありがたかった。
今読んでいる途中だけれど、Refactoring UIと合わせて使うと効果が増す。
実装を考えたデザインができる。
これを作るのに、デザイン含め大体2日くらいかかったが、エンジニア的にストレスなく作れて、楽しかった。
若干作業が単純化するところや、モジュールを分けることで、エディターでファイルを飛び交うのが辛いところは欠点かもしれない。
(Alt + Tab何回押したことやら)
ソースコードはGithubに挙げたので、よかったら見てください。
atomic-design-vue-todo-example
ライセンスは、「勝手にしやがれ!好きにしな!」です。
-
あくまで当規約における話なので、Atomic Designとは関係ないです。 ↩