この記事は第2のドワンゴ Advent Calendar 2018 23日目の記事です。
ドワンゴでニコニコ生放送のWebフロントエンジニアをやっています、 @misuken です。
- 14日目の React + TypeScript における ViewComponent の美しい合成技術
- 18日目の 超簡単に数値を 1,234 1.2万 12.3万 123万 1,234万 1.23億 ... に整形する方法
に続いての投稿です。
今回は AtomicDesign では atom が最小の粒度とされている中、
atom より小さな世界を作り込んだ結果、大きなメリットを得られたというお話をします。
はじめに
この記事で紹介するコードはニコニコ生放送のViewComponent(以降VC)開発で使用している、社内VCライブラリ NicoliveViewComponent(以降NVC) を使って Storybook で表示した物になります。
コードの内容はあくまで記事で紹介するために Story.tsx にまとめて記述したものであるため、本来はコンポーネントのファイルとストーリーのファイル、その他責務の違う物は別のファイルに切り出して export
することなどを念頭にご覧ください。
NVCとは
NVCはアプリケーション依存のコードが含まれておらず、あらゆるアプリケーションで使えるように設計されている汎用的なViewComponentライブラリです。
VCはContainerComponent(以降CC)を含まない領域であるため、Mobx、Redux、Flux等の状態管理ライブラリとも独立しており、API通信も発生せず、外部リソースを使わず、 通信できない環境でも Storybook でコンポーネントの表示を確認することができます。
実際にアプリケーションに組み込む際は、アプリケーションのリポジトリでアプリケーションのVCを構築し、完成したアプリケーションのVCに対して、状態管理ライブラリを使ったCCからpropsを流し込む形になります。
前置き
createComponent に関して
記事の中で多用する createComponent
という関数は前の記事 React + TypeScript における ViewComponent の美しい合成技術 で作り方など詳細な解説を行っています。
仕組みが気になった方は併せてご覧ください。
className に関して
この記事内では className
の適用は行っていませんが、実際には createComponent
を使って全ての要素にユニークな className
を適用します。
こちらも前の記事 React + TypeScript における ViewComponent の美しい合成技術 で className
の適用方法が書いてありますので、 className
の渡し方が気になる方は参考にしてみてください。
この記事の題材
この記事が題材にするのは、NVCの molecules にある Section コンポーネントが持つ Heading(見出し) コンポーネントです。
import { Component as Section } from "./Section";
<Section heading={{ text: "見出し" }}>内容</Section>
<section><h2>見出し</h2>内容</section>
これから、見出しに要求される様々な難題を、createComponent
と atom より小さな世界の作り込みで強化されたコンポーネントを使い、宣言的な記述で柔軟に解決していく様子を紹介します。
仕様変更劇場の開幕
開発者さん、色々と要望を出していきますが、よろしくお願いします。
よろしくお願いします!
※ 補足 ※
実際は企画やカンプの段階でしっかり詰めてから開発するので、
実際の開発でこれから書くような変更の嵐が発生することはありません。
あくまで説明するための読み物です。
ここにセクションを置きたいです
できました!
import { storiesOf } from "@storybook/react";
import * as React from "react";
import * as Section from "./Section";
// 上記の import はここ以降では省略します
// 基本的な Section コンポーネント
const Component = Section.createComponent({
heading: Heading.createComponent(),
});
storiesOf.add("セクション", () => (
<>
<Component heading={{ text: "見出し" }}>
<p>セクションの内容</p>
</Component>
<Component>
<p>見出しが無いとdiv</p>
</Component>
</>
));
表示結果と出力結果
<section>
<h2>見出し</h2>
<p>セクションの内容</p>
</section>
<div>
<p>見出しが無いとdiv</p>
</div>
見出しにアイコンを追加できますか?
できます!
const Component = Section.createComponent({
heading: Heading.createComponent({
// Heading は atom ですが、標準でアイコンの表示に対応しています
symbolMark: Icon14x14,
}),
});
storiesOf.add("見出しにアイコン", () => (
<Component heading={{ text: "見出し" }}>
<p>セクションの内容</p>
</Component>
));
表示結果と出力結果
<section>
<!--
アイコンが追加されるだけでなく、内容をラップする要素(デフォルトはspan)も追加されます。
このspan要素は atom であるHeadingコンポーネントが内部に持つ、
content という特殊なコンポーネントが表現したものです。
-->
<h2><svg /><span>見出し</span></h2>
<p>セクションの内容</p>
</section>
アイコンの位置は後ろにしてほしいんですけど
こんな感じで!
const Component = Section.createComponent({
heading: Heading.createComponent({
symbolMark: Icon14x14,
// 内容に関するオプションを設定できる
contentOption: {
// 内容の位置を指定できる(Beforeは内容を前に配置する指定)
// アイコンの位置指定ではなく内容の位置していをしている理由は後ほど説明します
position: Heading.Content.Position.Before,
},
}),
});
storiesOf.add("見出しのアイコンの位置", () => (
<Component heading={{ text: "見出し" }}>
<p>セクションの内容</p>
</Component>
));
表示結果と出力結果
<section>
<h2><span>見出し</span><svg /></h2>
<p>セクションの内容</p>
</section>
やっぱりアイコンは前で良いので状態によってアイコンを換えたいです
対応しました!
const Component = Section.createComponent({
heading: Heading.createComponent({
// Icon という atom のコンポーネントには、
// 表現するコンポーネントのマッピングを登録できる仕組みがあります。
symbolMark: Icon.createComponent({
// 大きさの違うアイコンを登録
tagNameMap: {
A: Icon14x14,
B: Icon20x20,
},
}),
}),
});
storiesOf.add("見出しアイコン切り替え", () => (
// kind の指定によってどのコンポーネントを使ってアイコンを表現するか指定できます。
// kind に tagNameMap のプロパティ名以外を渡すと型エラーになります。
<>
<Component heading={{ text: `見出しA`, symbolMark: { kind: "A" } }}>
<p>セクションの内容</p>
</Component>
<Component heading={{ text: `見出しB`, symbolMark: { kind: "B" } }}>
<p>セクションの内容</p>
</Component>
</>
));
表示結果と出力結果
<section>
<h2><svg style="width: 14px; height: 14px;" /><span>見出しA</span></h2>
<p>セクションの内容</p>
</section>
<section>
<h2><svg style="width: 20px; height: 20px;" /><span>見出しB</span></h2>
<p>セクションの内容</p>
</section>
やっぱり状態でアイコン換えなくていいので見出しにリンクかけてほしくて、URLがない場合は太字で表示してほしいです
これでどうでしょう?
const Component = Section.createComponent({
heading: Heading.createComponent({
// NVCにはコンポーネントを表す要素自体を他のコンポーネントで表現する仕組みがあります。
// ここの設定では Heading を Anchor で表現するように指定しています。
tagName: Anchor.createComponent({
// Anchor コンポーネントに tagName を指定すると、
// href が undefined だったときに指定要素で表示するようになります。
tagName: "strong",
}),
symbolMark: Icon14x14,
}),
});
storiesOf.add("見出しアンカー", () => (
<>
<Component heading={{ href: "#", text: "見出し" }}>
<p>hrefを渡します</p>
</Component>
<Component heading={{ text: "見出し" }}>
<p>hrefを渡しません</p>
</Component>
</>
));
表示結果と出力結果
<section>
<!--
Headingコンポーネントの機能により、 tagName で h1 〜 h6 要素以外が指定されると、
自動的にrole属性やaria-level属性を付けて見出しとしての役割を維持してくれます。
-->
<a role="heading" href="#" aria-level="2"><svg /><span>見出し</span></a>
<p>hrefを渡します</p>
</section>
<section>
<strong role="heading" aria-level="2"><svg /><span>見出し</span></strong>
<p>hrefを渡しません</p>
</section>
アイコンにリンクが掛かっちゃダメなんですけど
調整しました!
const Component = Section.createComponent({
heading: Heading.createComponent({
symbolMark: Icon14x14,
// <a><svg /><span></span></a> ではなく <h2><svg /><a></a></h2> の構造にするため、
// Headingの持つcontentをAnchorコンポーネントで表現するように指定します。
content: Anchor.createComponent({
tagName: "strong",
}),
contentOption: {
// contentOption の wrapping を指定すると、Headingに渡した text を content でラップするようになります。
// content を表現するコンポーネントが text を受け取るとまずかったり、
// text に対応していないコンポーネントが指定される場合があるため、
// デフォルトでは false 指定になっています。
wrapping: true,
},
}),
});
storiesOf.add("見出しの内容だけアンカー", () => (
// Anchor は content で表現するようになったので、 href も content に渡すことになります。
<>
<Component heading={{ text: "見出し", content: { href: "#" } }}>
<p>hrefを渡します</p>
</Component>
<Component heading={{ text: "見出し" }}>
<p>hrefを渡しません</p>
</Component>
</>
));
表示結果と出力結果
<section>
<h2><svg /><a href="#">見出し</a></h2>
<p>hrefを渡します</p>
</section>
<section>
<h2><svg /><strong>見出し</strong></h2>
<p>hrefを渡しません</p>
</section>
見出しに30時間制の日時って入れられますか?
入れました!
const Component = Section.createComponent({
heading: Heading.createComponent({
symbolMark: Icon14x14,
content: Anchor.createComponent({
// 今度は Anchor の内容を DateTime で表現します。
content: DateTime.createComponent({
// フォーマットと時間制の指定を行います。
// 30時間制というのは1日を6時00分〜29時59分で表現する方法のことです。
format: "見出し %Y-%M-%D(%w) %H:%I:%S",
threshold: 30,
}),
}),
}),
});
/**
* 辞書オブジェクト.
* 静的文言や動的文言の静的な部分をVCで責務を持ち管理するための仕組み。
* VCのスコープで確定する内容を全て記述することで、責務の分散を防ぎます。
*
* CCでインスタンスを生成し、引数で要求されるパラメータを渡すことにより、
* 動的な値と静的な文言の境界を適切に維持することができます。
*
* また、CC側に静的な文言が定義されることがないので、
* VCのStorybookで確認した文言が本番まで反映される一貫性を担保する上でも大きな役割を担っています。
*/
class Dictionary {
public static create() {
// props と同じ構造にします。
// NVCの思想にある「法則性を利用して主観を排除することにより、関数的に物事が決まる」に従った設計です。
return {
heading: {
content: {
content: {
format: ({ text }: { text: string }) => `${text} %Y-%M-%D(%w) %H:%I:%S`,
},
},
},
};
}
}
const dictionary = Dictionary.create();
storiesOf.add("日付のフォーマット", () => (
// 近い将来、createComponent の引数内で content の別名を指定できるようにし、
// props で content よりふさわしい名前を使えるようにする計画があります。
// すると props の渡し方は以下のように改善されます。
// heading={ anchor: { dateTime: {} } }
<Component
heading={{
content: {
href: "#",
content: {
format: dictionary.heading.content.content.format({ text: "見出し" }),
value: new Date("2018-12-24 02:43:00"),
},
},
}}
>
<p>セクションの内容</p>
</Component>
));
表示結果と出力結果
<section>
<!-- DateTimeコンポーネントはtimeタグで表現され、datetime属性を自動的に付けてくれます -->
<h2><svg /><a href="#"><time datetime="2018-12-24 02:43:00">見出し 2018-12-23(Sat) 26:43:00</time></a></h2>
<p>セクションの内容</p>
</section>
曜日は日本語で表示してください
変更しました!
const Component = Section.createComponent({
heading: Heading.createComponent({
symbolMark: Icon14x14,
content: Anchor.createComponent({
content: DateTime.createComponent({
format: "見出し %Y-%M-%D(%w) %H:%I:%S",
threshold: 30,
// 曜日はオブジェクトで対応を指定します
weekdayMap: {
"%w": { 0: "日", 1: "月", 2: "火", 3: "水", 4: "木", 5: "金", 6: "土" },
"%W": { 0: "日曜日", 1: "月曜日", 2: "火曜日", 3: "水曜日", 4: "木曜日", 5: "金曜日", 6: "土曜日" },
},
}),
}),
}),
});
// 使い方は変わらないので省略
表示結果と出力結果
<section>
<h2><svg /><a href="#"><time datetime="2018-12-24 02:43:00">見出し 2018-12-23(日) 26:43:00</time></a></h2>
<p>セクションの内容</p>
</section>
曜日に色を付けられますか?
色を付けました!!
const Component = Section.createComponent({
heading: Heading.createComponent({
symbolMark: Icon14x14,
content: Anchor.createComponent({
content: DateTime.createComponent({
threshold: 30,
weekdayMap: {
"%w": { 0: "日", 1: "月", 2: "火", 3: "水", 4: "木", 5: "金", 6: "土" },
"%W": { 0: "sun", 1: "mon", 2: "tue", 3: "wed", 4: "thu", 5: "fri", 6: "sat" },
},
contentOption: {
// 要素内で表示するテキストのHTML表現を有効にします。
// NVCでは Content がHTML表現に対応しており、
// 有効にすると安全且つよしなに dangerouslySetInnerHTML に内容をセットしてくれるようになります。
html: {
// モードでは、改行の扱いや、HTML表現、テキスト表現、HTML除去などの指定ができます。
mode: DateTime.Content.Html.Mode.Html,
// HTMLはタグ名と属性値で許可するものを厳格に制御することができます。
whitelist: {
span: { "data-day-of-week": /^(sun|mon|tue|wed|thu|fri|sat)$/ },
},
},
},
}),
}),
}),
});
class Dictionary {
public static create() {
return {
heading: {
content: {
content: {
// NVCのテンプレートリテラル用のHTMLエスケープ関数を使って、文字列をHTMLフォーマットに埋め込みます。
format: ({ text }: { text: string }) =>
HtmlUtil.escapeTemplateLiteral`${text} %Y-%M-%D(<span data-day-of-week="%W">%w</span>) %H:%I:%S`,
},
},
},
};
}
}
// 使い方は変わらないので省略
表示結果と出力結果
※CSSの属性セレクタ `[data-day-of-week="sun"] { color: #f00 }` で色を適用<section>
<h2><svg /><a href="#"><time datetime="2018-12-24 02:43:00">見出し 2018-12-23(<span data-day-of-week="sun">日</span>) 26:43:00</time></a></h2>
<p>セクションの内容</p>
</section>
これでOKです!
やったー!
このセクションを前に作っていたドロップダウンに出してください
やっておきます!
// 補足: こういった感じで使えるように Dropdown の export を整えておく必要はあります
Dropdown.createComponent({ contents: Component });
createComponent や content の深イイ話
宣言的に書ける
例の中で様々な要望を受け、対応してきましたが、実装内容を見てみると、 createComponent
は呼んでいるものの、全て宣言的に設定を書いているだけで、処理という処理を書かずに対応できました。
その中で重要な働きをしているのが atom より小さな世界にいる content
と呼ばれる特殊なコンポーネントです。
このコンポーネントが特定の条件下で内容をラップしたり、 createComponent
で指定したコンポーネントに差し替えられることで、通常の atom では実現できない表現の幅を手に入れています。
処理を書かないということはバグが発生するリスクを抑えられますし、ロジックを考えるコストも、レビューするコストも削減することができます。
いつも同じ型が使える
JSXを書いて新しいコンポーネントを作るというのは、塗り絵の枠を描いてから中を塗るようなものです。
それに対して createComponent
は、塗り絵の枠は、すでに用意してあるパーツを接合部に組み合わせることで構築でき、実質的にやることは中を塗る(contextを注入する)こと、といった違いがあります。
何度も同じような枠を描く必要がない上、枠の形が固定されいる部分は、中身の塗る部分(context)を定義して共有するといった、再利用性を高めることにも繋がります。
見出しの境界がずっと見出しであり続ける
今回の記事では省略していた className
を適用する例を見てみましょう。
CSS Modules の機能により、 scss を require
するとセレクタ名にクラス名がマッピングされたオブジェクトが手に入るので、それをコンテキストとして注入します。
const Component = Section.createComponent({
classNames: require("./section.scss"),
heading: Heading.createComponent({
classNames: require("./heading/heading.scss"),
}),
});
.section {}
.heading {}
.heading {}
出力結果はこうなります。
<!--
CSS Modules の設定は localIdentName: "___[local]___[hash:base64:5]" を使っています。
前に ___ を付けている理由は、いざと言うとに [class*="___heading___"] のような部分一致で誤爆を防ぐための工夫です。
-->
<section class="___section___xxxxx">
<h2 class="___heading___xxxx2(section.scss側の定義) ___heading___xxxx1(heading.scss側の定義)">見出し</h2>
<p>セクションの内容</p>
</section>
次に、アイコンを追加して見出し自体をアンカーにした時の例です。
const Component = Section.createComponent({
classNames: require("./section.scss"),
heading: Heading.createComponent({
classNames: require("./heading/heading.scss"),
// 差分はここから下だけ
tagName: Anchor.createComponent({
tagName: "strong",
}),
symbolMark: Icon14x14,
}),
});
.section {}
.heading {
composes: heading from "./heading/heading.scss";
}
.heading {}
.symbol-mark {}
.content {}
h2
が anchor
や strong
に変わろうとも、 class
における section
と heading
の関係に変わりはありません。
見出しの境界の位置が全く動いていないことを意味します。
<section class="___section___xxxxx">
<a role="heading" href="#" aria-level="2" class="___heading___xxxx2 ___heading___xxxx1"><svg class="___symbol-mark___xxxxx"/><span class="___content___xxxxx">見出し</span></a>
<p>hrefを渡します</p>
</section>
<section class="section">
<strong role="heading" href="#" aria-level="2" class="heading"><svg class="symbol-mark"/><span class="content">見出し</span></strong>
<p>hrefを渡しません</p>
</section>
このように足場がしっかり固定されることにより、 section.scss
に定義する見出しのレイアウトに関するスタイルへの影響が無いことも保証されます。
要件的にもあくまで見出し内での変化を要求されているので、見出し内の表現が変わるだけで外部に影響を及ぼさないということは、正しい変更です。
JSXで新しいコンポーネントを作れば、atom要素の createComponent
を使わなくても同じような実装は実現できますが、createComponent
を使うことで、それぞれのコンポーネントに外の世界の事情を持ち込まず、宣言的に最小限の記述で実現できるところにメリットがあります。
NVCにおけるContentの存在
NVCにContentが導入されたから、アプリケーションの構築が一気に楽になりました。
それまでも createComponent
によるコンポーネントの交換やコンテキスト注入は行われていたのですが、Content との組み合わせはの効果は劇的でした。
長くなってきたのでそろそろ締めに向かいますが、
NVCでContent対応しているコンポーネントにどのような物があるのかや、
さらなる活用方法についても紹介しておこうと思います。
Content対応しているコンポーネント
NVCのテキスト系コンポーネントはContentを標準機能として実装しています。
どのコンポーネントも同じインタフェースでContentが使えます。
コンポーネント名 | 機能 |
---|---|
Anchor | window.open に切り替える機能を搭載 |
Button | 外部からの focus 操作の機能を搭載 |
DateTime | 時間制表示の機能を搭載 |
Element | プレーンなテキストコンポーネント |
Heading | role や aria-level への振り替え機能を搭載 |
Label | label要素に対応したテキストコンポーネント |
Number | 数値整形と外部からの値操作機能を搭載 |
Time | 24:00:00 以上の時間の表現が可能 |
アプリケーションのコンポーネントツリーは、枝を辿れば葉に行き着きます。
その葉は atom のコンポーネントです。
これらが molecules で部品として使用されることにより、末端まで強力に表現の幅の広がったアプリケーションを構築できるようになりました。
アイコンと内容の位置の関係
一連のコードの中で、アイコンの位置を指定する際 contentOption
で内容の位置を指定していた理由について説明します。
アイコンの位置を前(Before)や後ろ(After) で指定できたほうが直感的ではあるのですが、実際のところそれだとうまく表現できないパターンが存在します。
内容の位置は5種類の指定に対応しているのですが、アイコンの位置を指定 するタイプだと、内容を aria-label
に表示する指定が表せません。
定数 | 内容の表示方法 | 表示結果 |
---|---|---|
Content.Position.Label | 内容を aria-label に表示 | ■ |
Content.Position.Only | 内容だけ表示 | 見出し |
Content.Position.Before | 内容を前方に表示 | 見出し■ |
Content.Position.After | 内容を後方に表示 | ■見出し |
Content.Position.None | 内容を表示しない | ■ |
aria-label に振り替えられることの強大なメリット
本来 children
に表示されるテキストの内容を aria-label
に振り替える仕組みはとても便利で、GitHubでも使われているツールチップの仕組みを実現できます。
Contentを使ったイメージとしてはこういう流れで文脈を変えずにリッチ化します。
元々のマークアップ
<a>Success: the build was successful</a>
↓
<a><svg />Success: the build was successful</a>
↓
<a aria-label="Success: the build was successful"><svg /></a>
マークアップの意味は変えずに表現方法を変更
視覚的表現をアイコンにし、そのアイコンの説明を aria-label
で補いつつ、 aria-label
の内容をツールチップに利用します。
この表現の幅をテキスト系コンポーネントでインタフェースの変更無く吸収できることは非常に大きな意味を持ちます。
例えば、Anchor コンポーネントを使い <a>アンカー</a>
で実装しておけば、アイコン表現が必要になっても createComponent
の context
の設定でいつでも簡単にインタフェースの変更もデグレのリスクも考えず対応できることが保証されるからです。
文字列表現とアイコン表現で要求が揺れたとしても気にならないですし、 aria-label
化した後は、CSSの変更だけでも他の4パターンを実現できるので、表現方法の責務をデザイナーさんが持ちたい場合にはどのように表示するかをお任せすることもできます。
スタイルの変更は全て scss のみの修正で済むので、機能的なデグレリスクが無いところも安心です。
お任せしたらエンジニアのレビューコストは0です。
まとめ
- atom より小さな世界を作り込んだら、驚くほどのメリットが存在した
-
createComponent
という土台が重要な役割を果たした - 実装には責務の境界を見極めるスキルが必要
Contentはあらゆるアプリケーションの末端で機能するため、それに耐えうる汎用性や中立性が求められますし、高い実装精度も求められます。
中途半端な実装で導入すればアプリケーション全体の使い勝手を悪くする可能性もありますし、保守性も下がります。
その半面、作り込みに成功するとアプリケーション全体が劇的に強化されます。
今までの AtomicDesign には存在していなかった思想ではありますが、atom より小さな世界にも目を向けてみてはいかがでしょうか。
ニコニコ生放送ではNVCを今後も強化し、開発コストの削減、品質の向上、アーキテクチャや思想を確立し、ユーザー様によりよいサービスを提供できるよう頑張ってまいります。
NVCを使って一緒に開発したい!という仲間も大歓迎です。