この記事は第2のドワンゴ Advent Calendar 2017 22日目の記事です。
ドワンゴでニコニコ生放送のWebフロントエンジニアをやっています、 @misuken です。
はじめに
ここ1年半くらいは、主に ViewComponent(VC) と ContainerComponent(CC) 周りのアーキテクチャ設計、コンポーネント設計、実装を担当しています。
生放送のHTML5プレーヤーなどを開発してきました。
第1回 ニコ動/ニコ生 HTML5化への奮闘~ドワンゴ流動画配信サービスのつくりかた~
今回はニコニコ生放送のViewComponent周りがどのように作られているのかを紹介したいと思います。
内容としては大体こんな感じです。
- VCを中心とした設計から実装の流れ
- CSS Modulesを使いつつコンポーネントとデザインを柔軟に組み合わせられる仕組み
- ニコニコ生放送のコンポーネント開発で得られた知見
また、同じチームで隣の席の @ln-north さんが ドワンゴ AdventCalendar 2017の17日目の記事で触れた部分に関して、追加で書き足しました。
使用しているフレームワーク等
- React
- React Storybook
- TypeScript
- MobX (CCの状態管理)
- CSS Modules
- SASS
今回はVCの話なので、状態管理を行うContainerComponent(CC)であるMobXの話はしません。
参考
ニコニコ生放送のWebフロントは VC CC デザイン の三本柱で、それぞれの領域でかなり先進的な取り組みを行ってきました。
今回は @misuken がメインとしている VC を紹介しますが。
CC は @kondei さんの ニコニコ生放送の watch ページを MobX で作り直している話 を
デザインは @ln-north さんの コンポーネント指向フロントエンド開発におけるデザイナーの参画について を
合わせて読んでみることをオススメします。
また、2018年でこの記事からさらに進化したVCの技術情報が以下にまとまっていますので、是非併せてご覧ください。
- 14日目 React + TypeScript における ViewComponent の美しい合成技術
- 18日目 超簡単に数値を 1,234 1.2万 12.3万 123万 1,234万 1.23億 ... に整形する方法
- 23日目 AtomicDesign の atom より小さな世界の扉を開く
- 24日目 LayeredAtomicDesign でコンポーネントの粒度問題を解決する
- 25日目 LayeredAtomicDesign を用いた実践的な設計と仮実装の流れ
2020年3月に LayeredAtomicDesign から粒度と概念の軸をより明確にした画期的なコンポーネント分類手法
BCD Design によるコンポーネントの分類 を公開しました。
Organisms を使用しない新しいパラダイムです。
題材について
今回この記事を書くにあたり、 何か良い題材は無いかなと思いながら、記事投稿ページを開いていました。
そこで目に留まったのがこれです。
Qiitaさんのページを例にしたらわかりやすいのではと思い、ページ上部の「ホーム」と「コミュニティ」の部分を題材とさせて頂くことにしました。
もし、このコンポーネントを作るとしたら、どのように作っていくかを書いていきます。
特記事項
- TypeScript のimport等、省略している記述があります
- コードはあくまでイメージを伝えるものなのでそのまま動くコードではありません
- コード上に出てくるElementコンポーネントはariaやdataの反映やclassNamesのomitを良しなに処理してくれる汎用コンポーネントと考えて下さい
- ディレクトリ構造は例なので AtomicDesign のディレクトリも使っていません
ページ上部のコンポーネントを作成する
これは何なのか?
設計を始める時はデザイナーさんからカンプをもらって、コンポーネントの抽出を行います。
カンプは雑だったとしても構成に変化が無ければ、変更はcssやCCから渡すpropsの内容の変更で収まるため、VC的な手戻りは発生しません。
VCはUIとHTMLの構成、CCは状態管理とイベントハンドリング、デザイナーはデザイン、と責務がしっかり分かれることで、お互いに干渉し合うことなく明確なゴールに向かって作業をすすめることができます。
構成内容の分析と実現方法の洗い出し
カンプから要素を抽出する
機能的なところを分析しながら、名前を割り出してみます。
- ホームとコミュニティはホバーすると三角アイコンの色が白に変わる
- ホームとコミュニティはクリックするとリンクの一覧が表示される
- ホームとコミュニティはボタンと言えそう
- リンクの一覧はメニューと言えそう
- メニューはいくつかに分類されている
- メニューの項目はアンカーになっている
- メニューの項目にはアイコンとテキストが表示されている
- メニュー表示中は三角アイコンは白のまま固定される
- メニュー表示中にボタンをクリックするとメニューが閉じる
- ボタンはトグルボタンと言えそう
- メニュー表示中にメニュー外をクリックするとメニューが閉じる
要素としてはこれくらいのようです。
要素の名前の確認
名前付けはとても慎重に行う必要があるので、説明文にして他人に説明したとして違和感がないか検証してみます。
「ホームやコミュニティのトグルボタンをクリックするとメニューが開きます」
「メニューの項目をクリックするとリンク先に移動します」
「メニューを開いている時にトグルボタンかメニュー外をクリックするとメニューが閉じます」
問題無さそう。
コンポーネントの名前の確認
トグルボタンとメニューの組み合わせ自体に名前をつける必要があります。
一般的にこのような機能はドロップダウンメニューと呼ばれていそうです。
「コミュニティのドロップダウンメニューを開いて下さい」
問題無し。
機能の実現方法の確認
アイコンとホバーは視覚的な意味しか持っていないので、css側で解決する想定にします。
実際にはこの段階でデザイナーさんとcss解決で良いか握っておきます。
ドロップダウンメニューのコンポーネント設計
ここまでの分析を元に雑に設計を行うとこのようになります。
<HomeDropdownMenu>
<ToggleButton/>
<HomeMenu/>
</HomeDropdownMenu>
<CommunityDropdownMenu>
<ToggleButton/>
<CommunityMenu/>
</CommunityDropdownMenu>
冗長な部分を取り除く
先ほどの設計は間違ってはいないものの、このままだとメニューの種類が増える度にドロップダウンメニューごとコンポーネントの複製が発生します。
ドロップダウンメニューの機能的な部分を複製すると保守コストが上がるので、メニューの部分だけ差し替えられるようにします。
冗長な部分を取り除くために「◯◯の××」(HomeのDropdownMenuやMenu)における「◯◯」(Home)の部分を抽象化します。
<DropdownMenu>
<ToggleButton/>
<Menu/>
</DropdownMenu>
DropdownMenu を利用する箇所で Menu の位置に HomeMenu や CommunityMenu を差し込めれば良いということになります。
無駄な制約を取り除く
まだ微妙なところがあります。
ドロップダウンメニューは◯◯メニューという名前ではあるが、それ自体がメニューなのか?
メニューは子要素のことではないのか?
DropdownMenu を Dropdown にします。
<Dropdown>
<ToggleButton/>
<Menu/>
</Dropdown>
TypeScriptの型で言うところの Dropdown<T extends Menu>
のような感じになりました。
概念に含まれていない文脈を取り除く
まだ疑問の残るところがあります。
Dropdown という名称には一切メニューという概念が含まれていないが、
子要素にメニューを内包しているのはおかしくないか?
Dropdown という名称のみから推測すると、何をドロップダウンするのか何も規定していないのではないか?
トグルボタンの操作で何らかの内容を表示できれば良いのではないか?
<Dropdown>
<ToggleButton/>
<Contents/>
</Dropdown>
Contents
には何が入っても良いこととします。
TypeScriptの型で言うところの Dropdown<T extends Element>
のような感じです。
これで構成的にはキレイになりましたが、もう少し整理できるところがあります。
ボタンの無駄も省く
ToggleButton
というのは、押しっぱなしを表現できるボタンであればよいので、HTML5では aria-pressed
属性が使えます。
Button
コンポーネントの他にわざわざ ToggleButton
コンポーネントを作る必要はありません。
<Dropdown>
<Button aria-pressed="true|false"/>
<Contents/>
</Dropdown>
Dropdown は依存が取り除かれたため、どこでも使える汎用コンポーネントになりました。
このように記述量が最少で済む設計段階で十分に時間を掛けて構成、名前、状態の種類を詰めておきます。
設計に比べて実装後のコード量は圧倒的に多いので、後から変更があると修正コストが大きくなります。
汎用コンポーネント Dropdown の実装
ここまでの設計をコードに起こすとこうなります。
lib
dropdown
dropdown.scss
Dropdown.tsx
export interface DropdownProps<T extends ElementProps> extends ElementProps {
// CSS Modules で require する scss の型
classNames?: DropdownClassNames;
// 役割としてはトグルボタンなので名前は toggleButton だけど、型としては Button で良い
toggleButton?: ButtonProps;
// コンポーネントと props がセットになった型を使うことにより、外部から好きなコンポーネントを注入できる
contents?: T & { Component?: React.Component<T> | React.StatelessComponent<T> };
}
// 自分自身と管轄下の子要素の名前を並べる
export interface DropdownClassNames {
dropdown?: string;
toggleButton?: string;
contents?: string;
}
export const Dropdown: React.StatelessComponent<DropdownProps> = (props: DropdownProps) => {
const classNames = props.classNames || {};
const { toggleButton = undefined, contents = undefined, ...rest } = props;
const { Component = undefined, ...contentsRest } = contents || {};
return (
// div ではなく Element コンポーネントを使うのは、 ariaSet や dataSet をオブジェクトで流し込めるようにしているため
// また、classNames を最終的に omit したり、全体を法則的に記述可能なインタフェースにするための役割もある
<Element tagName="div" className={classNames.dropdown} {...rest}>
{
toggleButton &&
<Button className={classNames.toggleButton} {...toggleButton}/>
}
{
Component &&
<Component className={classNames.contents} {...contentsRest}/>
}
</Element>
);
};
汎用コンポーネントの scss ファイルには機能的なスタイルのみを定義します。 1
.dropdown-base {
position: relative;
}
.toggle-button-base {
/*!*/
}
.contents-base {
// どこで使う場合でもフローティングすることは決まっている
position: absolute;
}
汎用コンポーネントのクラスセレクタ名には -base
を付加します。
こうしておくと、後々 composes した結果をインスペクタで開いて見る時に良い感じに分類されて表示されるので非常に見やすくなります。
汎用コンポーネントの scss は直接 require することは無いので、 ClassNames の型と一致しなくて問題ありません。
汎用コンポーネントの scss ファイルにおける注意点
汎用コンポーネントの scss ファイルには一切デザイン的な内容を記述してはいけません。
デザインはアプリケーションで利用される場所ごとに違うため、不用意な定義が大変面倒な問題を引き起こします。
逆に機能的なスタイルはベースに定義されていないと困ります。
例えばデータグリッドなら、どんなデザインであっても、スクロールするために必要な機能的なスタイルは同じです。
もしも利用箇所ごとに機能的なスタイルを定義するとバグを誘発します。
cssのリセットに関しても注意が必要で、アプリケーションに依存したリセットを行うのは危険です。
共通のスタイルを当てる目的ではなく、ブラウザがデフォルトで付けているスタイルを消す目的以外では使わないほうが良いでしょう。
メニューの中身の設計
<ul>
<li>
<ul>
<li><a>ユーザー一覧</a></li>
<li><a>Organization一覧</a></li>
<li><a>アドベントカレンダー</a></li>
</ul>
</li>
<li>
<ul>
<li><a>コミュニティガイドライン</a></li>
</ul>
</li>
<li>
<ul>
<li><a>コミュニティガイドライン</a></li>
<li><a>良い記事を書くために</a></li>
</ul>
</li>
</ul>
上はがっつり組みすぎなので、もう少し簡素にして必要な属性を付け足すと以下のようになります。
全ての要素がセレクタで識別可能、記述に無駄がなく、説明的であるところが重要です。 2
<div class="community-menu">
<div class="main-menu" role="list">
<a class="item" href="#" role="listitem" data-id="user-list">ユーザー一覧</a>
<a class="item" href="#" role="listitem" data-id="organization-list">Organization一覧</a>
<a class="item" href="#" role="listitem" data-id="advent-calendar">アドベントカレンダー</a>
</div>
<div class="service-menu" role="list">
<a class="item" href="#" role="listitem" data-id="qiitadon">Qiitadon (β)</a>
</div>
<div class="help-menu" role="list">
<a class="item" href="#" role="listitem" data-id="community-guideline">コミュニティガイドライン</a>
<a class="item" href="#" role="listitem" data-id="article-guideline">良い記事を書くために</a>
</div>
</div>
汎用コンポーネント AnchorList の実装
main-menu
と service-menu
と help-menu
は概念としてはメニューではあるものの、実態としては汎用なアンカーリストです。
内容を消して見るとよくわかります。
今回は「項目をクリックする」という意味を忠実に表現するため、 role を使って a を項目自体にしてみました。
<div class="anchor-list" role="list">
<a class="item" href="#" role="listitem" data-id=""></a>
<a class="item" href="#" role="listitem" data-id=""></a>
<a class="item" href="#" role="listitem" data-id=""></a>
</div>
これをコードに起こすとこうなります。
lib
dropdown
dropdown.scss
Dropdown.tsx
anchor-list
anchor_list.scss
AnchorList.tsx
export interface AnchorListProps extends ElementProps {
classNames?: AnchorListClassNames;
items?: AnchorProps[] ;
}
export interface AnchorListClassNames {
anchorList?: string;
item?: string;
}
export const AnchorList: React.StatelessComponent<AnchorListProps> = (props: AnchorListProps) => {
const classNames = props.classNames || {};
const { items = undefined, ...rest } = props;
// role を menu と meunitem にしたい場合は利用する場所から props に role を流し込めば上書きできる
// role はビュー的な値なので、CCからではなく、親コンポーネントで最初にこの要素が menu と確定するところからDIする
return (
<Element tagName="div" role="list" className={classNames.anchorList} {...rest}>
{(items || []).map(item => <Anchor role="listitem" className={classNames.item} {...item}/>) }
</Element>
);
};
.anchor-list-base {
/*!*/
}
.item-base {
/*!*/
}
アプリケーションコンポーネント CommunityMenu の実装
メニューの一つ一つは AnchorList コンポーネントになったので、CommunityMenu の設計はこうなります。
<div class="community-menu">
<AnchorList class="main-menu"/>
<AnchorList class="service-menu"/>
<AnchorList class="help-menu"/>
</div>
CommunityMenu はその名の通り、Community の部分がアプリケーションに依存しています。
つまりアプリケーションのコンポーネントです。
アプリケーションのコンポーネントは、具体的なデザインの当たった classNames を読み込んで使います。
汎用コンポーネントに対してDIを行っていると捉えることができます。
CSS Modules を使ってコンポーネントとデザインの分離とデザイン差し替えの柔軟性を実現する肝がこの className と classNames の取り扱いにあります。
慣れるまでは複雑に感じますが、className と classNames は composes と合わせて使うことで、法則的で美しい螺旋階段のような形になり、コンポーネントのツリー構造に対して完璧に対応します。
デフォルトで classNames を適用しつつ、外部からも全てのクラス名が書き換え可能になっています。
lib
dropdown
dropdown.scss
Dropdown.tsx
anchor-list
anchor_list.scss
AnchorList.tsx
app
community-menu
anchor-list
anchor_list.scss
community_menu.scss
CommunityMenu.tsx
// このコンポーネントのクラス名群を読み込む
const defaultClassNames: CommunityMenuClassNames = require("./community_menu.scss");
// アンカーリストのクラス名群を読み込む
// community-menu/anchor-list/anchor_list.scss から読んでいるので、
// community-menu 専用のアンカーリストのデザインを適用できる。
const anchorListClassNames: AnchorListClassNames = require("./anchor-list/anchor_list.scss");
// CommunityMenu 自体は div なのでHTML属性を渡せるように ElementProps を継承
export interface CommunityMenuProps extends ElementProps {
classNames?: CommunityMenuClassNames;
// 個々のメニューは AnchorList である
mainMenu?: AnchorListProps;
serviceMenu?: AnchorListProps;
mainMenu?: AnchorListProps;
}
export interface CommunityMenuClassNames {
communityMenu?: string;
mainMenu?: string;
serviceMenu?: string;
mainMenu?: string;
}
export const CommunityMenu: React.StatelessComponent<CommunityMenuProps> = (props: CommunityMenuProps) => {
// 外からの指定を優先
// これにより、外部から任意のデザインと対応しているクラス名に差し替えることができる
const classNames = props.classNames || defaultClassNames;
// 余計なプロパティをHTML要素に流し込むと警告が出たりするので、
// 子要素のプロパティとこのコンポーネント自体のプロパティを分離する
const { mainMenu = undefined, serviceMenu = undefined, helpMenu = undefined, ...rest } = props;
return (
// className が外部から指定されている場合は rest.className が存在するため、後勝ちで上書きされる
// 外部から指定されていなければ、 classNames.communityMenu が使用される
<Element tagName="div" className={classNames.communityMenu} {...rest}>
{
mainMenu &&
// className や classNames の指定が外からあれば {...xxxMenu} で上書きされる
<AnchorList className={classNames.mainMenu} classNames={anchorListClassNames} {...mainMenu}/>
}
{
serviceMenu &&
<AnchorList className={classNames.serviceMenu} classNames={anchorListClassNames} {...serviceMenu}/>
}
{
helpMenu &&
<AnchorList className={classNames.helpMenu} classNames={anchorListClassNames} {...helpMenu}/>
}
</Element>
);
};
複数箇所で別デザインの CommunityMenu を使う場合は、利用箇所で任意の calassNames を require し props を書き換えて流し込むだけです。 3
CommunityMenu で require している2枚の scss は次の通りです。
コンポーネント内のデザインのスタイルと、配置に関するレイアウトのスタイルも適切に切り分けられている点に注目してください。 4
.anchor-list {
// 汎用コンポーネントのスタイルを適用
composes: anchor-list-base from "../../../lib/anchor-list/anchor_list.scss";
// ここに anchor-list 内のデザイン的なスタイルを書く
// skin 的な scss を composes すればデザインの共有もできる
}
.item {
composes: item-base from "../../../lib/anchor-list/anchor_list.scss";
// ここに item 内のデザイン的なスタイルを書く
// skin 的な scss を composes すればデザインの共有もできる
// item を anchor-list に配置する時のレイアウトのスタイルもここに書く
&[data-id="user-list"] {
// 特定の項目に対する定義はこう書ける
// 項目ごとのアイコン出したりとか
}
}
.community-menu {
/*!*/
}
// 内容の違う3つのメニューを抽象化して扱うためのクラス
.menu {
// アプリケーションコンポーネントの anchor-list を適用
// menu を使うと anchor-list と anchor-list-base も含んだマルチクラスになる
composes: anchor-list from "./anchor-list/anchor_list.scss";
// ここにアンカーリストの外側の(レイアウトに関する)スタイルを記述する
// CommunityMenu に AnchorList を配置するときに依存したスタイルのみを書く
// skin 的な scss を composes すればデザインの共有もできる
// .menu の後続の .menu つまり、メニューとメニューの間を意図していることが読み取れる
& + & {
// 境界線
border-top: solid 1px #ccc;
}
}
.main-menu {
// main-menu を使うと menu と anchor-list と anchor-list-base も含んだマルチクラスになる
composes: menu;
}
.service-menu {
composes: menu;
}
.help-menu {
composes: menu;
}
composes の挙動
composes は CSS Modules の機能で、composes した scss ファイルを require すると、以下のようにマルチクラス化された値を持ったオブジェクトが手に入ります。
この値をコンポーネントの className に渡すことにより、HTMLタグのclass属性にマルチクラスが設定されます。
const classNames = require("./community_menu.scss");
console.log(classNames);
// 出力結果 (*部分はハッシュ値)
// {
// communityMenu: "community-menu___*****",
// menu: "menu___***** anchor-list___***** anchor-list-base___*****",
// mainMenu: "community-menu___***** menu___***** anchor-list___***** anchor-list-base___*****",
// serviceMenu: "service-menu___***** menu___***** anchor-list___***** anchor-list-base___*****",
// helpMenu: "help-menu___***** menu___***** anchor-list___***** anchor-list-base___*****"
// }
クラス名の衝突回避
*****
は css-loader の機能で、ファイルパスとクラスセレクタから生成されたハッシュ値です。
これにより、コード上で短いクラス名を使っていても、グローバル空間でクラス名が衝突することを回避できます。
ドロップダウンコンポーネントの利用
ホームとコミュニティのドロップダウン式メニューを配置してみます。
ここは例なので適当に GlobalHeader という名前にしました。
lib
dropdown
dropdown.scss
Dropdown.tsx
anchor-list
anchor_list.scss
AnchorList.tsx
app
community-menu
anchor-list
anchor_list.scss
community_menu.scss
CommunityMenu.tsx
home-menu
〜 省略 〜
global-header
dropdown
dropdown.scss
global_header.scss
GlobalHeader.tsx
const defaultClassNames: GlobalHeaderClassNames = require("./global_header.scss");
const dropdownClassNames: DropdownClassNames = require("./dropdown.scss");
// Dropdownコンポーネントを要素の外側のクリックを検知できるHOCと連携
// props に onOutsideClick が生える
export const OutsideClickableDropdown = OutsideClick.attach(Dropdown);
export interface GlobalHeaderProps extends ElementProps {
classNames?: GlobalHeaderClassNames;
// OutsideClickProps は { onOutsideClick: (event) => void } 型
dropdownHomeMenu?: DropdownProps<HomeMenuProps> & OutsideClickProps;
dropdownCommunityMenu?: DropdownProps<CommunityMenuProps> & OutsideClickProps;
}
export interface GlobalHeaderClassNames {
globalHeader?: string;
dropdownHomeMenu?: string;
dropdownCommunityMenu?: string;
}
export const GlobalHeader: React.StatelessComponent<GlobalHeaderProps> = (props: GlobalHeaderProps) => {
const classNames = this.props.classNames || defaultClassNames;
const { dropdownHomeMenu = undefined, dropdownCommunityMenu = undefined, ...rest } = this.props;
// メニューのコンポーネントはここで確定する
// props の破壊的変更の副作用には注意が必要
if (dropdownHomeMenu && dropdownHomeMenu.contents && !dropdownHomeMenu.contents.Component) {
dropdownHomeMenu.contents.Component = HomeMenu;
}
if (dropdownCommunityMenu && dropdownCommunityMenu.contents && !dropdownCommunityMenu.contents.Component) {
dropdownCommunityMenu.contents.Component = CommunityMenu;
}
return (
<Element tagName="header" className={classNames.globalHeader} {...rest}>
{
dropdownHomeMenu &&
<OutsideClickableDropdown className={classNames.dropdownHomeMenu} classNames={dropdownClassNames} {...dropdownHomeMenu}/>
}
{
dropdownCommunityMenu &&
<OutsideClickableDropdown className={classNames.dropdownCommunityMenu} classNames={dropdownClassNames} {...dropdownCommunityMenu}/>
}
</Element>
);
};
// GlobalMenu に依存した dropdown のデザインを定義する
.dropdown {
composes: dropdown-base from "../../../lib/dropdown/dropdown.scss";
}
.toggle-button {
composes: toggle-button-base from "../../../lib/dropdown/dropdown.scss";
}
.contents {
composes: contents-base from "../../../lib/dropdown/dropdown.scss";
}
.global-header {
/*!*/
}
.dropdown-home-menu {
composes: dropdown from "./dropdown/dropdown.scss";
// レイアウトのスタイルを書く
}
.dropdown-community-menu {
composes: dropdown from "./dropdown/dropdown.scss";
// レイアウトのスタイルを書く
}
コンポーネントがネストしても、これまでとほとんど同じ記述で実装できることがわかります。
以上でVC部分は一通り完成した感じになります。
HOCの活用
Reactには Higher Order Components(HOC) という便利な仕組みがあります。
ReactのHigher Order Components詳解
ニコニコ生放送では AtomicDesign に attachments という独自概念を足してHOCを運用しています。
attachments には Xxx機能実装コンポーネント = Xxx.attach(コンポーネント)
という形式でインタフェースを実装したHOCがいくつも用意されていて、利用箇所で必要に応じて機能を足して使えるようになっています。
補足
propsのバケツリレーについて
ニコニコ生放送では props をフラットにはせず構造化する方法を取っています。
propsのバケツリレーについては賛否あると思いますが、以下のメリットがあるため、敢えて使用しています。
- 最小限のインタフェースの組み合わせで全体を構築できる(再利用性が高い)
- 仕様変更時の影響範囲が小さくて済む
- 責務の集中する場所が発生しない
- ビューの責務をVC内に閉じやすい
- スプレッド形式
{...props}
が使えると props 受け渡しの手間が掛からない - 余計なプロパティのomitもうまくやると手間が掛からない
他の方法は、インタフェース数が増大したり、コンポーネントの責務が外部に漏れ出すなどのデメリットがあり、採用に至りませんでした。
これに関連してネストしたpropsのshouldComponentUpdate
でのチューニングはコンポーネントの基底クラスを用意して JSON.stringify
比較をしています。
しかし、 shouldComponentUpdate
で JSON.stringify
比較することはアンチパターンでもあります。
propsが大きくなるほどパフォーマンスが悪化したり、ReactElement
を渡すと循環参照のエラーを吐くといったものです。
そのため、propsが大きくなるようなコメントのリストはpropsに渡さない仕組みにしたり、今回の例のように children
でReactElement
を渡す代わりに T & { Component?: React.Component<T> | React.StatelessComponent<T> }
を渡して末端で組み立てるなど、幾つかの工夫を行っています。
JSON.stringify
比較はパフォーマンス検証の時間が取れない中でのコスパを優先して取った手段であるため、よりパフォーマンスを上げられる方法がある場所に関しては、別の方法でチューニングしていく方針です。
ニコニコ生放送の開発で意識&実践していること
汎用アプリケーションとアプリケーション別のリポジトリを分割
ニコニコ生放送では汎用コンポーネントのみで構成された NicoliveViewComponent(NVC) 、HTML5プレーヤーのVC、それのCC、視聴ページのVC、それのCCというように、リポジトリが分割されています。
NVCはアプリケーションへの依存が取り除かれたVCであるため、あらゆるプロジェクトで利用が可能です。
NVC以外のアプリケーションVCは、機能的な部分をNVCのコンポーネントで実現し、アプリケーションに依存した構成やデザインを新たに実装する形になっています。
同じインタフェースのコンポーネントは作らない
ニコニコ生放送内には基本的にButtonコンポーネントはNVCに存在する一つしかありません。
XxxButton というコンポーネントもありません。
インタフェースの変わらないコンポーネントは抽象化したら同じものになるため、作るのが無駄だからです。
デザインを確定させるためだけのコンポーネントも className や classNames を上位のコンポーネントから注入できる仕組みでもあるため、必要ありません。
構成や型が違うときだけコンポーネントを増やすので、コンポーネント数が爆発するようなこともなく開発を進められています。
法則の重要性
今回のコードを見ても解る通り、コンポーネントのツリーはかなり法則的に実装することができます。
ニコニコ生放送では、tsxやscssの実装、ディレクトリ構造、各種命名に至るまで、法則を重視しています。
ルールは誰かが決めて覚えるものですが、法則は元から存在しているものなので、見抜いたらずっと使えます。
法則は書くときもレビューするときもコストを下げてくれます。
全体を法則的に記述可能な方法を見抜くことで、AtomicDesignにおけるOrganism単位のHTML設計から、直接コンポーネントの実装を自動生成するツールもチーム内で一部実用化することができました。
HTML設計を状態、CodeGeneratorを関数、出力をソースコードとする考え方で、状態を写像するWebフロントの仕組みと似ています。
複数コンポーネントのtsxファイル、scssファイル、ストーリーブック用のサンプルデータ等を一気に生成できます。
アプリケーションのVCはCCの状態を写像する関数であり、アプリケーションというものは汎用VCの機能的なコンポーネントをどのように構成するか決める骨格と言えます。
VCにはドメインロジックも入らないことから、アプリケーションを実装するための材料はHTML設計の段階で大部分が整っており、その情報を関数的にコードに変換できれば人が介入する場所を減らせるというわけです。
これは、将来的にブラウザ版Sketchのような物に進化できることを意味していると考えています。
ドラッグ・アンド・ドロップで視覚的にアプリケーションの構成を決め、そこからHTML設計を抽出、コンポーネントを自動生成するといったことが可能なはずです。
人が介入すべきポイントは、機材(機能的なVC)をどのようにケーブルでつなぎ(アプリケーション構成)、どのような設定を行うか(VCでDIする静的コンテキスト)です。
このようにして最小限の手数で外部からの入力(CCからの動的なprops)を受け付ける機器(アプリケーションVC)を作れる将来が来ると予想しています。
依存関係の重要性
依存関係は常に単方向であることが重要です。
今回の設計でも全てのコンポーネントやscssは単方向になっています。
子は親を知らず、抽象は具象を知らず。
依存関係が単方向でなくなるとアプリケーションの負債を爆発的に増やすので、長期的な目線で見ると、これが一番重要かもしれません。
単方向を維持するために依存関係逆転の原則(DIP)をよく使っています。
TypeScriptのコードみならず、scss内でもcomposesを駆使して使うなど徹底しています。
依存関係が単方向で、型のある言語で書いていれば、コードが汚くてもリファクタは簡単に行なえます。
責務分担の重要性
VCの責務は大体以下になります。
- HTMLの構成
- UIの状態の管理
- 静的なリストの並び順
- 文言の管理
- VCのストーリーブックの管理
VCで変更したい欲求を持つ関心事は全てVC内で完結できるようにします。
CCやデザイナーさんに依頼(コストが高い遠隔操作)する必要性をなくし、VC、CC、デザイナーそれぞれがゴールに向かって全力で走れるよう責務の境界は細かいところまで手を抜かずに管理しています。
エンジニアとデザイナーが忙しく、非効率な双方の領域の作業を手伝い、結果として双方のコストを増大させるようなことが起きないようにしています。
HTMLの構成
今回の実装例で紹介したようなHTML設計の部分です。
ドメインの状態管理や、デザインの概念は含めず、VCの責務だけに徹します。
UIの状態の管理
例えば開閉するUIの場合、開閉状態はVC内のstateで保持していれば成立することもありますが、連動して他の部分に影響を与えたいという要求が来ることもあります。
汎用コンポーネントではそういう要求に応えるため、外部から渡されていたらその値を使い、渡されていなければ自身のstateを使うというようなハイブリッドな実装にすることで使い勝手を向上しています。
静的なリストの並び順
今回の実装例では端折りましたが、メニューの項目の並び順は本当はVCでDI的に管理するものになります。
本来はメニューの項目が持つ data-id
の識別子を利用して、VC側のアプリケーションコンポーネントからリストの順序の定義をDIで注入し、制御するべきです。
そうしないと、VCのストーリーブックで確認したときと、CC結合した時で並び順が違うトラブルが発生したりします。
メニューの項目とは違って、新着順のような動的なリストの順序はCC側の責務になります。
文言の管理
文言はVCがアプリケーションコンポーネント単位で辞書クラスを作って公開しています。
CCはその辞書を使って文字列を取り出し、propsに設定します。
// 辞書クラス
export class Dictionary {
static get get() {
return {
user: {
name: (params: { value: string } ) => `${params.value}さん`;
}
};
}
}
// CCで使う時の例
export function generateProps() {
return {
user: {
name: Dictionary.get.user.name({ value: "太郎" }) // 太郎さん
}
};
}
メソッドで取り出せるようになっているので、静的な文言はもちろん、動的な文言の埋め込み方もVCが管理することができます。
同じコンポーネントを、ある場所では静的な文言、ある場所では動的な文言、と使い分けたい場合でも、辞書を使うかの判断はCC側に委ねることができます。
もしも文言をCC側で管理すると、VC単体でストーリーブックを表示する際にダミー文言が必要になります。
その場合は以下のような問題が発生し、無駄なコストに繋がります。
- VCのストーリーブックを企画者に見せても文言の確認が取れない
- VCのストーリーブックではデザインが当てにくい
- CCと結合して表示された文言でデザインが崩れることに気付いて手戻りすることがある
文言をVCで管理することで、CCは文言を意識することから開放されます。
そして、VCのストーリーブックで確認した見た目は、CC結合後も保証されるので、見た目の企画確認もVCだけで済ませることができます。
VCのストーリーブックの管理
ニコニコ生放送では本番で発生する全ての表示状態をVCのストーリーブック上で確認できるようにしています。
個々のパーツ単体はもちろん、AtomicDesignのtemplateレベルでも完全に再現します。
ほとんどのUIは簡易Storeで実際に動作し、UIで実現できない表示はknobsで状態を再現することができます。
モックサーバー等も不要。VC単体で npm start
を実行すれば誰でも手元で動かせる状態になります。
他にも、PRごとにdroneでストーリーブックを用意するなど、理想的な開発環境を整えています。
また、ストーリーのサンプルデータは、各コンポーネントごとに用意してあり、大きなコンポーネントは小さなコンポーネントのサンプルデータを組み合わせて作るようになっているため、 templateレベルのコンポーネントでもサンプルデータの生成や管理にコストがかかりません。
さらに、このサンプルデータはCCを開発する時の props の雛形として利用できるので、CC開発の効率化にも一役買っています。
インタフェースを少なく保つ
大きくて複雑なものを、単純な同じことの繰り返しで作れるメリットは想像以上に大きいものです。
似て非なるインタフェースを作らず、小さく使い勝手の良いインタフェースの組み合わせで対応できるように設計すると、使う人の負担が減ります。
CCの開発者はVCが要求する props を生成することがゴールになりますが、たくさんコンポーネントがあったとしても、いつもの型の組み合わせで作れます。
隣の席でCCを担当している @kondei さんからは「型がいつも同じだから楽」と言っています。
最近は「新しく作ったコンポーネントはあれとあれとあれ組み合わせて作ったからお願い」と言うだけでサクサク作業が進みます。
キレイなHTMLを出力する
Reactを使うと XxxContainer や XxxWrapper のようなフレームワークに依存したHTML要素が存在しがちですが、フレームワークに依存してHTML構造が変化することはおかしいので、無駄な要素が出力されないように意識しています。
また、状態の変化は全て aria
や data
属性に反映し、 class
は静的で変化しないようにしています。
class
の更新で対応しようとすると、クラスの付け替え処理が面倒で、複合状態を表現する時にはさらに煩雑になります。
意味的にもクラスの変化でデザインが変わるよりも、HTMLの文脈の変化によってデザインが変わるほうが正しいですし、複合状態も属性セレクタで対応したほうがスマートに書けます。
「要素の状態(data)が変わっているのであって、要素の分類(class)が変わっているわけではない」
要素の状態が変わった際に、要素の分類に変化に反映させるというのは、デザインを変えたいという潜在的な意図があるからで、厳密にはHTMLがデザインを意識(依存)していることになります。
状態が変化した時にデザインをどうするかはコンポーネントには無関係なので、デザイナーさんがscss内の属性セレクタの付け外しで制御できるようにするのが正しい責務分担になります。
おまけ
CSS Modulesを使いつつ子孫要素を操作したい
CSS Modules を使うとクラス名にハッシュ値が付くので、別ファイルに定義された子孫要素に対する指定ができなくなります。
壊れないCSSにするにはそれでよいのですが、稀にどうしても子孫要素に対する指定を行いたくなる場合があります。
そういうときは、webpack の css-loader の localIdentName に ___[local]___[hash:base64:5]
と書き、部分一致セレクタで [class*="___title___"]
と書くと任意の子孫要素に安全にマッチできます。
CSS Modules に慣れていない場合や、ユーザースタイルシートで何とかしたい時には有効な手段です。
ただし、セレクタへのマッチングのパフォーマンスは悪いので全部この方法で記述するのは避けましょう。
さらにおまけ
dwangoアドベントカレンダー17日目 コンポーネント指向フロントエンド開発におけるデザイナーの参画について の中で、隣の席にいる @ln-north さんから触れられた点があったので追加で書くことにしました。
コンポーネントの粒度分割について
Atomic Design を採用する上で必ず迷うのが粒度をどのように分類するか。
atom は良いとして、molecule と organism の分類に関しては最初かなり悩みます。
Atomic Design の思想は使えば何かが解決するというものではなく、使うことで一定の目安ができたり、依存方向について考えるきっかけになる点が良いところだと思っています。
少なくとも molecule から organism を参照することはあり得ないわけですし、同じ粒度の物がまとまっていたほうが人は理解がし易くなります。
現状の生放送での運用は以下のような感じです。
HTML5プレーヤー作成時に試行錯誤しながら比較的キレイに整理できたのがこの分類でした。
カテゴリ | 説明 |
---|---|
atom | ボタン等の単一要素 |
molecule | atom と organism 以外 |
organism | template 直下に配置する要素 |
template | 1つのページまたは1つのアプリケーション |
- プレーヤー自体は template で1つのアプリケーション
- 視聴ページの template ではプレーヤーを organism レベルの物として扱う
- プレーヤーと視聴ページは別リポジトリで、それぞれが1つのアプリケーション
しかし、この分類には欠点があります。
template 直下に配置というように、template 内での配置場所が変わると分類が変わり、ディレクトリを移動することになってしまうからです。
プレーヤーを作った後、視聴ページを作りながら分かってきたコツとしては、suffixを粒度の目安にすることです。
- Button Anchor は atom
- XxxField XxxForm XxxList は molecule
- XxxSection XxxPanel XxxWidget は organism
という具合に、suffixごとに粒度と対応させることで、何がどの粒度に存在しているか、依存して良い方向に関しても容易に理解できるようになります。
ちなみに、同じ粒度に属する場合も、依存関係が単方向であれば使って問題ありません。
organism から直接 atom 使うのも問題ありません。
実際に分類がしっくり来るか調べる時は、空のディレクトリを大量に作って名前付けし、ツリーを移動させたり名前を変えたりして試すと早く済ませられます。
物が多いほど精度も上がるので、作るアプリケーションのスケールを超えるくらいのボリュームで実験すると良いでしょう。
エンジニアとデザイナーの責務の境界
エンジニアとデザイナーはどこを責務の境界にすると最も効率よく作業できるか。
これに関しては、コードとHTML(クラス名含む)はエンジニア、cssをデザイナーという境界が理想と考えています。
デザイナーがスクリプトを書かなくて済む点でも合理的です。
別々の言語で書かれた複数のシステムをデザインする場合に、デザイナーが全ての言語を覚えるのも非効率です。
これらの弊害は境界がズレていることを表します。
複雑さが増す世界で最大限の成果を生むには、専門家が専門的な部分に集中し、正しい境界で連携コストを極限まで下げるほうが筋が良いと思います。
HTMLは誰が書くのか
よくテーマになる話でもありますが、元々HTMLはデザインを知らないべきもので、デザインが無くてもHTMLは完結しますし、アプリケーションとしても成立します。(デザインがどんなにボロボロでも目的は達成できる)
デザインの有無でHTMLを記述する人が変わるというのは本質的には不自然であることがわかります。
cssは完成したHTMLの要素の分類(class)と状態(data等の属性)に対して視覚的な表現を付加するものなので、エンジニアとデザイナーは分類と状態をインタフェースとして共有すれば良いことになります。
エンジニアはアプリケーションの要素に対して文脈的な分類名と、それらが持ち得る状態を公開することで、デザイナーはそこだけに集中して作業することができます。
実際、HTML設計の段階で合意することにより、手戻り等の連携コストはほぼゼロの状態で作業できています。
現実的な作業の流れ
エンジニアの作業
- HTML設計を行ってデザイナーと共有する(HTMLに詳しくデザインの概念を混入しなければデザイナーでも良い)
- コンポーネントを作る
- scssに空のセレクタを用意する
- そのコンポーネントが管轄するクラス名は全て書く
- コンポーネントに反映して各要素にクラス名があたっていることを確認する
- ストーリーブックにストーリーを追加する
- knobsを使って各状態と構成を表現できるようにする
デザイナーの作業
- ストーリーを見ながらセレクタの中身を埋める
- composesを使ってデザインの共通部分を整理する
クラス名はすでに全てのコンポーネントと連携しているので、デザイナーはセレクタの中身を埋めるだけで済みます。
レビュー
エンジニアのPullRequestではデザイナーが設計段階で予定していたクラス名が存在しているか、ストーリーブックで各表示状態を再現できるかを確認すれば終わりです。
デザイナーのPullRequestはエンジニアも目を通しますが、注意点は2つだけ。
-
.ts
や.tsx
が変更されていないか -
.scss
内にイレギュラーな記述がないか
.ts
や .tsx
がいじられていなければ、コード的なデグレは100%起きません。
ストーリーブックで見た目が完成している場合、イレギュラーな記述さえ無ければ概ね正しく実装されている証拠になります。
細かくレビューする必要がありません。
また、ブラウザ依存による不具合のほとんどは JavaScript 側ではなく css 側で発生します。
そのため、エンジニアが scss をいじらなければ、エンジニアは毎回ブラウザ別の動作確認をする必要がありません。
面倒くさいと思うことが無くなる理想的な境界がここにあります。
終わりに
すごく長くなってしまいましたが、今回はVCに関する設計から実装の流れ、そして広く浅くニコニコ生放送の開発で実践されていることを紹介しました。
ニコニコ生放送というサービスは、視聴ページとプレーヤーにものすごい量の要素や状態が存在します。
これまで、レガシーで冗長なコードに阻まれ、ほんの少しの修正でも開発者やデザイナーに負担がのしかかっていた超複雑なシステムをどうやって安定的に開発するか、保守しやすい状態を保つかは大きな課題でした。
しかし、この1年半の間にHTML5化に伴い様々な試行錯誤をした結果、現在はわりとエンジニア、デザイナー、企画にとって理想に近い開発体制を作ることができ、ここでは紹介しきれない非常に多くの知見を得ることができました。
@ln-north さんもこの間の記事で書きたいことの20%くらいしか書けてないと言っています。
CCの MobX に関しては、 @kondei さんが書いた ニコニコ生放送の watch ページを MobX で作り直している話 からさらなるアーキテクチャの改善を経て非常に洗練された形に進化しました。
大量の状態をどのようなStore構成で管理しているのか、興味のある方も多いのではないでしょうか。
VCとCCが安定しているため、SSR(サーバーサイドレンダリング)も比較的スムーズに導入することができました。
このあたりの知見もいずれ公開されることでしょう。
今後の課題としては、現状の法則性の高いコンパクトなインタフェースを維持したままパフォーマンス改善を行うことや、より多くのコンポーネントを汎用化し開発速度と品質を上げることなど、低コストでクオリティの高い成果物を生み出していくことなどがあります。
ユーザーの皆様が少しでも快適に楽しんで頂けるよう、Webフロントチームは頑張っていきますので、これからもどうぞよろしくお願い致します。
-
scss 内の
/*!*/
はビルド時に空のセレクターが除去されて composes で問題になることがあるので、空でもセレクターを維持するために入れているコメントです。 ↩ -
ここは
role="menu"
とrole="menuitem"
のほうが適切のような気もしますが内容を単純化するためrole="list"
とrole="listitem"
を使っています ↩ -
require した scss を一般的な styles ではなく classNames という変数にしているのは、オブジェクト自体に style の概念が一切含まれていないこと、値がクラス名群であること、コンポーネントが style に依存していないことを明示するためです。 ↩
-
境界線の表示は、対象要素全てに適用してから先頭を打ち消すといった
:first-child
や:last-child
を使った方法でも実現できますが、要求は「間に線を入れる」なので、文脈に沿った記述を使ったほうが、要求とコードに乖離が無く、表現豊かにコードを記述することができます。 ↩