この記事は第2のドワンゴ Advent Calendar 2018 14日目の記事です。
ドワンゴでニコニコ生放送のWebフロントエンジニアをやっています、 @misuken です。
昨年の React + TypeScript + CSS Modules によるコンポーネント指向フロントエンド開発の流れと知見 に引き続き、今年一年で最も恩恵を受けた技術について紹介したいと思います。
昨年の記事をベースに、何が問題でどこをどのように改良したのか紹介していきますので、必要に応じて見返して頂けるとより深い理解を得られるかと思います。
昨年時点での問題点
昨年作成した Dropdown コンポーネントは、展開する内容を任意のコンポーネントにする目的を達成できるだけのスペックを持っているのですが、使いやすさや書き味の面で問題になる場面がありました。
例えば、サイトヘッダーでドロップダウンを使うと仮定して、
階層の深い場所のコンポーネントを任意の物に置き換えるとしましょう。
<SiteHeader
dropdown={{
classNames: require("./dropdown.scss"),
...props.dropdown,
contents: {
...props.dropdown.contents,
Component: Foo,
}
}}
/>
この場合、2階層くらい上のコンポーネントから差し替えようとするだけで、
親から子へ渡す props
と、この階層から注入したいコンポーネントを合成するために面倒な記述が必要になってきます。
階層が深ければ深いほど、渡す値が多いほど、これらの記述は辛くなっていきます。
以下のように props
を直接更新しようとするのはより悪手なうえ、途中に省略可能なプロパティが挟まっていると undefined
の分岐が必要になり、結局は煩雑になります。
props.dropdown.contents.Component = Foo
このように、昨年時点のコンポーネントは利用シーンによって使い勝手を大きく左右されてしまう問題を抱えていました。
関数を渡す方法で解決を図った結果
問題を解決する方法として一般的に取られそうな方法は、props
にJSXを返す関数を渡すというものがあります。
ただ、その方法でも問題に直面した例を紹介します。
ニコニコ生放送では一切のアプリケーション依存を含まない、汎用的な社内ViewComponentライブラリ NicoliveViewComponent 通称NVC を使い、PCwebやSPwebの開発を行っています。
そのコンポーネントの一つに、内容が収まらない場合に展開状態の切り替えボタンが表示される Expander というコンポーネントがあります。
展開していない状態
左は ABCDE
右は AB
を表示しており、左は CDE
が収まらない高さなので、overflow: hidden
で隠して、展開ボタンを表示しています。
展開した状態
展開すると、overflow: hidden
が消えて内容の全てが表示されます。(スタイルの変化は属性セレクタの変化に連動)
責務の関係上、展開状態は Expander
の state
に持ってほしいのですが、Expander
の中身の内容は利用する側で指定できる必要があります。
利用する側では、表示する内容を展開状態によって切り替えたい場面もあることから、generator
という関数を渡し、引数で展開状態を受け取れるインタフェースにしていました。
<Expander
expandedToggleButton={props.expandedToggleButton}
generator={(expanded: boolean) => {
return expanded ? <Foo {...props.foo} /> : <Bar {...props.bar} />;
}}
/>
展開中なら Foo
を表示、閉じていれば Bar
を表示、一見問題なく動くように見えます。
しかし、描画性能を上げるため、Expander
に shouldComponentUpdate
を仕込んだ時に問題は発生しました。
Expander
に渡している props
に変化が無いと、generator
に渡した関数が呼ばれないので、
props.foo
や props.bar
の値が変化しても、再描画が行われません。
このコードを見て、他の方法に気付いた方もいるかもしれません。
generator
内で使う props
も Expander
に渡してしまって、Expander
内から generator
に props
を渡す方法です。
しかし、関数の引数と戻り値の関係でないと、Generics を介した TypeScript の型推論は使えないので、 callbackProps
の型は any
で受けざるを得なくなります。
<Expander
expandedToggleButton={props.expandedToggleButton}
callbackProps={props} // ここで渡す props と下の props が同一であることを Expander 側では保証できない
generator={(props: {foo: FooProps, bar: BarProps}, expanded: boolean) => {
return expanded ? <Foo {...props.foo} /> : <Bar {...props.bar} />;
}}
/>
他の回避策としては Expander
に本来不要な余計なプロパティを渡して更新する手も無くはないのですが、
せっかく関心の分離を行って責務を分けたのに、それによって本来不要な処理の追加が必要になるのは避けたいものです。
ニコニコ生放送の開発では、細部まで型で保証され、簡潔で無駄の無いコードでアプリケーションが作られる世界を目指しているので、現状ではこのような問題が起きない実装方法が確立されています。
問題を根本的に解決する方法
深い階層のコンポーネントを差し替えにくい問題は以前からあったものの、根本解決のための時間が取れないことと、一応はなんとかなっていたのでそのまま開発を続けていました。
しかし、4月頃に開発に取り掛かったアプリケーションにおいて、明らかにこの問題を解決しないと非効率な場面に直面したため、問題を根本的に解決できるのではないか?と以前から頭の中にあったアイデアを試してみることにしました。
コンポーネントを動的に生成する
通常、コンポーネントというのは、 StatelessComponent(FunctionalComponent)
や React.Component
を継承したクラスを定義しますが、それらを関数でラップしておいて、コンポーネントを使う側で引数を渡し、予め依存を注入したコンポーネントを得る方法を考えました。
ニコニコ生放送では、これを createComponent という関数名にしています。
この方法の利点は、TypeScript の Generics と型推論を利用し、引数に渡した型を戻り値の型に利用できるところにあります。
コンポーネントを合成するだけでなく、型の合成も同時に行います。
以下は簡単なイメージです。
書き方はパターン化されているので、あとで順を追って説明します。
// 関数コンポーネントの場合
export function createComponent<T>(context: { foo: React.ComponentType<T> }): React.StatelessComponent<Props<{ foo: T }>> {
// applyDisplayName は displayName を付与して return してくれるNVCの便利関数です。
// devTools で見る時に便利なので付けるべきですが、ここから先のコードでは省略します。
return applyDisplayName("Foo", (props: Props<{ foo: T }>) => {
// render内は省略
});
}
// クラスコンポーネントの場合
export function createComponent<T>(context: { foo: React.ComponentType<T> }): React.Component<Props<{ foo: T }>> {
return class Foo extends React.Component<Props<{ foo: T }>> {
render() {
// render内は省略
}
};
}
原理としては以下のように、Generics 指定したパラメータ T
を関数の引数の一部に指定すると、
関数を呼び出した時に引数に渡した値の該当部分の型が T
に割り当てられて型解決が行われるという仕組みを利用したものです。
function foo<T extends any>(obj: { v: T }): { a: T, b: number } {
return { a: obj.v, b: 1 };
}
foo({ v: 1 }); // 戻り値の型は { a: number, b: number }
foo({ v: "1" }); // 戻り値の型は { a: string, b: number }
foo({ v: true }); // 戻り値の型は { a: boolean, b: number }
createComponent化の手順
ここでは昨年のコード(少しだけ整形と整理したもの)を使って、実際に改善する手順を解説していきます。
以下が昨年に題材として書いた Dropdown コンポーネントです。
これを createComponent 化していきます。
※ ここ以降に現れる Element
Button
Anchor
Section
などは NVC で提供しているコンポーネントです
import * as React from "react";
import { Button, Element } from "NVC";
export interface DropdownProps<T extends Element.Props = Element.Props> extends Element.Props {
classNames?: DropdownClassNames;
toggleButton?: Button.Props;
contents?: T & { Component?: React.ComponentType<T> };
}
export interface DropdownClassNames {
dropdown?: string;
toggleButton?: string;
contents?: string;
}
export const Dropdown: React.StatelessComponent<DropdownProps> = (props: DropdownProps) => {
const classNames = props.classNames || {};
const { toggleButton, contents, ...rest } = props;
const { Component, ...contentsRest }: { Component?: React.ComponentType<Element.Props> } = contents || {};
return (
<Element.Component tagName="div" className={classNames.dropdown} {...rest}>
{toggleButton && <Button.Component className={classNames.toggleButton} {...toggleButton} />}
{Component && <Component className={classNames.contents} {...contentsRest} />}
</Element.Component>
);
};
1.関数でラップする
既存のコンポーネントを createComponent
という関数名でラップし、戻り値に React.ComponentType
型を指定します。
厳密には React.StatelessComponent
型ですが、今の所開発していてそれと React.Component
を区別することは無いので、 ComponentClass<P> | StatelessComponent<P>
である React.ComponentType
を指定しています。
export function createComponent(): React.ComponentType<DropdownProps> {
return (props: DropdownProps) => {
// コンポーネント内は省略
};
};
2. context で注入したコンポーネントを使用するように切り替える
createComponent
関数は、常に context
という引数を1つだけ持つようにします。
そこに渡したコンポーネントの Props 型が戻り値に適用されるよう、関係箇所を書き換えます。
// T は使わず、 "プロパティ名 + Props" の ContentsProps に変更
// ContentsProps はより様々なコンポーネントを受け付けて良いので、 { className?: string } を持つ型を許可
// extends には最低限縛る必要のある型を記述すればOKです
// 【 変更前 】
// export interface DropdownProps<T extends Element.Props = Element.Props> extends Element.Props {
export interface DropdownProps<ContentsProps extends { className?: string } = Element.Props> extends Element.Props {
classNames?: DropdownClassNames;
toggleButton?: Button.Props;
// コンポーネント自体を受け付けることをやめます
// 【 変更前 】
// contents?: T & { Component?: React.ComponentType<T> };
contents?: ContentsProps;
}
// 〜〜〜 省略 〜〜〜
// Generics に ContentsProps の指定を追加します。
// 引数の context オブジェクトに、注入を許可するコンポーネント定義を追加します。
// ContentsProps が引数の型で割り当てられて、戻り値の型に反映されるよう繋げます。
// 【 変更前 】
// export function createComponent(): React.ComponentType<DropdownProps> {
export function createComponent<ContentsProps extends { className?: string }>(context: {
contents: React.ComponentType<ContentsProps>;
}): React.ComponentType<DropdownProps<ContentsProps>> {
// 受け取る props にも引数で割り当てられる型を追加します
// 【 変更前 】
// return (props: DropdownProps) => {
return (props: DropdownProps<ContentsProps>) => {
const classNames = props.classNames || {};
const { toggleButton, contents, ...rest } = props;
// props から Component を取り除く必要がなくなったので、この行は削除
// 【 変更前 】
// const { Component, ...contentsRest }: { Component?: React.ComponentType<Element.Props> } = contents || {};
return (
<Element.Component tagName="div" className={classNames.dropdown} {...rest}>
{toggleButton && <Button.Component className={classNames.toggleButton} {...toggleButton} />}
{/* context で注入したコンポーネントを使うように切り替え */}
{/* 【 変更前 】 */}
{/*{Component && <Component className={classNames.contents} {...contentsRest} />}*/}
{contents && <context.contents className={classNames.contents} {...contents} />}
</Element.Component>
);
};
}
この段階での使用例は以下のようになります。
const WithAnchor = createComponent({ contents: Anchor.Component });
const WithElement = createComponent({ contents: Element.Component });
// Anchor と連携した場合は href が使えます
export const withAnchor = <WithAnchor contents={{ href: "#" }} />;
// Element と連携した場合は href は使えないので型エラーになります
export const withElement = <WithElement contents={{ href: "#" }} />;
createComponent
で作ったコンポーネントをさらに createComponent
の context
に使用する場合、
正しく書いてあれば、いくつ入れ子にしてもちゃんと深い階層まで型推論が効いて型が守ってくれます。
3. context を省略できるようにする
今回の Dorpdown のようなコンポーネントでは contents
を省略するような場面はないので本来はここまでする必要はありませんが、例として context
を省略できるようにします。
簡単に createComponent()
と呼び出せると、Storybookで扱いやすかったり、とりあえず挙動を確認したい時の使い勝手が向上します。
export function createComponent<ContentsProps extends { className?: string } = Element.Props>(
// context を省略可能に変更
// 【 変更前 】
// context: {
// contents: React.ComponentType<ContentsProps>;
// }
context: {
contents?: React.ComponentType<ContentsProps>;
} = {},
): React.ComponentType<DropdownProps<ContentsProps>> {
// context 省略時に使用するコンポーネントを指定
// デフォルトで使用するコンポーネントの Props は、Genericsのデフォルト型パラメータと一致するようにする必要があります
const ContentsComponent = context.contents || Element.Component;
// 【 リファクタ 】 関数の引数で分割代入を利用し、記述を簡素化。 classNames のように初期値の指定もできます
return ({ classNames = {}, toggleButton, contents, ...props }: DropdownProps<ContentsProps>) => {
return (
<Element.Component tagName="div" className={classNames.dropdown} {...props}>
{toggleButton && <Button.Component className={classNames.toggleButton} {...toggleButton} />}
{/* 解決済みのコンポーネントを使うように切り替え */}
{/* 【 変更前 】 */}
{/*{contents && <context.contents className={classNames.contents} {...contents} />}*/}
{contents && <ContentsComponent className={classNames.contents} {...contents} />}
</Element.Component>
);
};
}
context
を省略可能にすることで次のように書けるようになります。
const Dropdown = createComponent();
4. classNamesを注入できるようにする
CSS Modulesを使用し、 require("./dropdown.scss")
で読み込んだクラス名がマッピングされたオブジェクトを context
から注入できるようにします。
これにより、同じコンポーネントを複数の場所で使うとき、簡単に個別のスタイルが適用可能になります。
// 〜〜〜 省略 〜〜〜
export function createComponent<ContentsProps extends { className?: string } = Element.Props>(
context: {
// context にクラス名オブジェクトを追加
classNames?: DropdownClassNames;
contents?: React.ComponentType<ContentsProps>;
} = {},
): React.ComponentType<DropdownProps<ContentsProps>> {
const ContentsComponent = context.contents || Element.Component;
return ({
// props の classNames が undefined なら context.classNames を、
// context.classNames が undefined なら空オブジェクトを使う優先順を指定。
classNames = context.classNames || {},
toggleButton,
contents,
...props
}: DropdownProps<ContentsProps>) => {
// 〜〜〜 省略 〜〜〜
scss の違うコンポーネントを簡単に作れるようになりました。
export const DropdownA = createComponent({ classNames: require("./dropdown-a.scss") });
export const DropdownB = createComponent({ classNames: require("./dropdown-b.scss") });
5. トグルボタンも context から注入できるようにする
contents
と同様に toggleButton
も context
で注入できるようにします。
export interface DropdownProps<
// トグルボタンの型パラメータを追加
ToggleButtonProps extends Button.Props = Button.Props,
ContentsProps extends { className?: string } = Element.Props
> extends Element.Props {
// 〜〜〜 省略 〜〜〜
export function createComponent<
// トグルボタンの型パラメータを追加
ToggleButtonProps extends Button.Props = Button.Props,
ContentsProps extends { className?: string } = Element.Props
>(
context: {
classNames?: DropdownClassNames;
// トグルボタンの注入を許可
toggleButton?: React.ComponentType<ToggleButtonProps>;
contents?: React.ComponentType<ContentsProps>;
} = {},
// トグルボタンの型パラメータを追加
): React.ComponentType<DropdownProps<ToggleButtonProps, ContentsProps>> {
// トグルボタンコンポーネント省略時の解決
const ToggleButtonComponent = context.toggleButton || Button.Component;
const ContentsComponent = context.contents || Element.Component;
return ({
classNames = context.classNames || {},
toggleButton,
contents,
...props
// トグルボタンの型パラメータを追加
}: DropdownProps<ToggleButtonProps, ContentsProps>) => {
return (
<Element.Component tagName="div" className={classNames.dropdown} {...props}>
{/*解決済みのコンポーネントを使うように切り替え*/}
{toggleButton && <ToggleButtonComponent className={classNames.toggleButton} {...toggleButton} />}
{contents && <ContentsComponent className={classNames.contents} {...contents} />}
</Element.Component>
);
};
}
NVCでは Button
にアイコンを表示できる仕組みが標準搭載されているので、
ボタンを取り替えられるようにすることで、細かな表現の違いを利用する側で簡単に実現できるようになります。
export const Dropdown = createComponent({
classNames: require("./dropdown.scss"),
toggleButton: Button.createComponent({
classNames: require("./toggle-button/toggle_button.scss"), // アイコンのクラス名はボタンの classNames で指定できます
symbolMark: FooIcon,
}),
});
6. コンポーネントの要素自体を取り替えられるようにする
Dropdown ではあまり考えられませんが、コンポーネントによっては、コンポーネント自体を形成する要素を取り替えたくなる場合があります。
例えば、階層構造を変化させずに、ある要素をアンカーにしたい場合など。
子要素と違って、コンポーネント自体を形成する要素は必ず1つでなので、型パラメータには T
と H
の短い名前を使います。
// 渡した型パラメータで交差型を使うため ( X<T> = T & {} ) 、 interface から type へ変更
// interface では型エラーになるので type にする必要があります。
export type DropdownProps<
ToggleButtonProps extends Button.Props = Button.Props,
ContentsProps extends { className?: string } = Element.Props,
// コンポーネント自身の型パラメータを追加
T extends { className?: string } = Element.Props,
// コンポーネント自身のHTML要素の型パラメータを追加
H extends HTMLElement = HTMLElement
// 自身の T にHTML要素の型と子要素の型を結合
> = T &
React.HTMLAttributes<H> & {
classNames?: DropdownClassNames;
toggleButton?: ToggleButtonProps;
contents?: ContentsProps;
};
export interface DropdownClassNames {
dropdown?: string;
toggleButton?: string;
contents?: string;
}
export function createComponent<
ToggleButtonProps extends Button.Props = Button.Props,
ContentsProps extends { className?: string } = Element.Props,
// コンポーネント自身の型パラメータを追加
T extends { className?: string } = Element.Props,
// コンポーネント自身のHTML要素の型パラメータを追加
H extends HTMLElement = HTMLElement
>(
context: {
// 自分自身のコンポーネントは tagName で指定します。
// HTMLAttributes 型を介することで H の部分に該当する型を推論で抽出することができます。
tagName?: React.ComponentType<T & React.HTMLAttributes<H>>;
classNames?: DropdownClassNames;
toggleButton?: React.ComponentType<ToggleButtonProps>;
contents?: React.ComponentType<ContentsProps>;
} = {},
// T と H を追加
): React.ComponentType<DropdownProps<ToggleButtonProps, ContentsProps, T, H>> {
// このコンポーネント自身の優先順を設定
// NVCではElementのcontextにHTMLタグ名を指定できるので、そこでデフォルトのタグ名を指定します。
// 省略時のデフォルトも div にしてあるので、本当は指定しなくても大丈夫ではあります。
const TagName = context.tagName || Element.createComponent({ tagName: "div" });
const ToggleButton = context.toggleButton || Button.Component;
const Contents = context.contents || Element.Component;
return ({
classNames = context.classNames || {},
toggleButton,
contents,
...props
// ここだけ T と書かずに Element.Props なのは、1行上の ...props で
// TS2700: Rest types may only be created from object types. が発生するため。
// TS3.2からは解決してくれるようなので T と書けるはず。
// TS3.1以前は型パラメータの値を分割代入の rest に指定すると、
// 問題なさそうなのに型が通らないという厄介なハマりどころがありました。
}: DropdownProps<ToggleButtonProps, ContentsProps, Element.Props, H>) => {
return (
// Element.Component から TagName に切り替え
<TagName className={classNames.dropdown} {...props}>
{toggleButton && <ToggleButton className={classNames.toggleButton} {...toggleButton} />}
{contents && <Contents className={classNames.contents} {...contents} />}
</TagName>
);
};
}
この、HTMLElement
を継承した H
まで正確に型を通すことは非常に重要です。
違うHTML要素のコンポーネントに変更しようとした際、onClick
等のコールバックの引数にある event
オブジェクトの target
が持つ HTMLElement
を継承した型の部分で不一致を起こし、とても意味のわかりにくいエラーが発生することがあるためです。
HTML要素の型を手抜きすると、あとで痛い目に遭うので気を付けましょう。
7. 完成したコンポーネントの使い方
これで context
によってコンポーネントの内部を全て自由に組み替えられるようになりました。
全ての context
を指定した例が以下。
セクション化された見出し付きの Dropdownコンポーネント を作る例です。
export const Dropdown = createComponent({
classNames: require("./dropdown.scss"),
// NVCのSectionコンポーネント (<section><h2><h2>{ここに children が入る}</section>) を組み込む例。
// tagName の指定でDropdownコンポーネントの一番外側の要素だけを交換することができます。
tagName: Section.createComponent({
classNames: require("./section/section.scss"),
heading: Section.Heading.createComponent(),
}),
toggleButton: Button.createComponent({
classNames: require("./toggle-button/toggle_button.scss"),
symbolMark: FooIcon,
}),
// 今回は例なので Anchor を使用していますが、
// 本当は XxxMenu みたいなコンポーネントを作って注入します。
contents: Anchor.createComponent(),
});
.dropdown {
composes: section from "./section/section.scss";
}
.toggle-button {
composes: toggle-button from "./toggle-button/toggle_button.scss";
}
.contents {
/*!*/
}
.section {
/*!*/
}
.heading {
/*!*/;
}
// NVCの Button コンポーネントは atom ですが、もっと小さな世界(子要素)を持ちます
.toggle-button {
/*!*/
}
.symbol-mark {
/*!*/
}
.content {
/*!*/
}
出力されるHTMLはこうなります。
<section className="___dropdown___224nI ___section___1QaNx">
<h2 className="___heading___1H0uW">見出し</h2>
<button className="___toggle-button___1HAUC ___toggle-button___3GEkj">
<svg className="___symbol-mark___3tW-9"></svg>
<span className="___content___hdx8n">ボタン</span>
</button>
<a className="___contents___1TDcs">コンテンツ</a>
</section>
もしも、tagName
を指定しない場合に出力されるHTMLはこうなります。
<div className="___dropdown___224nI">
<button className="___toggle-button___1HAUC ___toggle-button___3GEkj">
<svg className="___symbol-mark___3tW-9"></svg>
<span className="___content___hdx8n">ボタン</span>
</button>
<a className="___contents___1TDcs">コンテンツ</a>
</div>
createComponent
で記述した構造がそのままHTMLに表れていることが見て取れると思います。
使う際に見通しがよく、可読性や書き味も良く、さらに型の記述を一切しなくても推論で守られるところが createComponent
の優れているポイントです。
8. 合成したコンポーネントの型を得る
createComponent
で合成したコンポーネントのProps型を手で書こうとすると大変面倒なうえ、構造を間違えて記述する原因にもなります。
そこで、 合成したコンポーネント自体が持つProps型を抽出します。
export type DropdownProps = ExtractProps<typeof Dropdown>;
ExtractProps
は NVC に用意してあるコンポーネントの型抽出用の型です。
条件分岐型の使い方は TypeScript 2.8 の Conditional Types について の記事が参考になります。
// T は React.ComponentType<any> と互換性がある場合だけ受け付ける
export type ExtractProps<T extends React.ComponentType<any>> =
// T が React.ComponentType<any> と互換性がある場合は P に該当する部分の型を返す
// T が React.ComponentType<any> と互換性が無い場合は never 型を返す
T extends React.ComponentType<infer P> ? P : never;
これで createComponent
で合成した型と確実に一致する Props 型を手に入れることができました。
コンポーネントから直接型を抽出すると、実際に ContainerComponent から props
を渡すときに構造がわかりにくくなることがあるので、Storybook で表示する際の props
をサンプルとして見られるようにするなど、ちょっとした工夫で使いやすくすることをオススメします。
ドキュメントなどでカバーしようとすると、不整合の原因となったり、コードと同期を取るコストが以外と馬鹿にならないので、createComponent
の成果物と自動的に連動できる手段を選ぶのがベターです。
tips
いくつか createComponent
を作る時のヒントを書いておきます。
createComponent化の目安
createComponentはとても便利な仕組みではありますが、バカ正直に全てのコンポーネントで対応したほうが良いというわけではありません。
単純なコンポーネントのほうがシンプルですし、 Generics の型パラメータが少ないほうが良いに越したことはありません。
以下の目安に合わせてcreateComponent化するかどうかを検討すると良いでしょう。
- ライブラリ的なコンポーネント
- 全ての要素を注入可能にする
- アプリケーションのコンポーネント
- 必要性が生じた要素を注入可能にする
- 見た目が複数パターンあるだけで構造や機能が変わらないコンポーネント
-
classNames
だけ注入可能にする
-
静的な値と動的な値の区別
createComponentを導入して解ってきたこととして、以下の方向に寄せていくとキレイな世界に近づいていきます。
- 静的な値は
context
へ - 動的な値は
props
へ - 静動両方の性質を持つ値は両方受け付けるようにする
- 「静的 < 動的」の優先度で適用されるようにする
- 静的な値は初期値のような振る舞いをする
静的な値を context
に増やすことで props
から渡す値が減り、動的に扱う値が減ることでバグのリスクも減らすことができます。
コンポーネントを作成するスコープで関心のある context
を注入することで、
コンポーネントを使う側では使う側で関心のある props
だけに集中できるためです。
いわゆるカリー化の概念でスコープごとに新たに発生する依存(文脈)を過不足無く閉じ込め、それをツリー構造にすることで、全ての段階で複雑度が最も低い状態を実現できます。
スコープごとの単位で複雑度が高いものも存在しますが、それはそのスコープ自体が複雑な責務を持っているだけで、その複雑さが外に漏れ出さないことこそが重要です。
createComponentとクラス名の関係
createComponentでクラス名を注入するときに大前提となることがあります。
-
className
は固定にする(一度 render したら途中で更新しない) - 状態の変化は全て
aria-*
とdata-*
で表現する
className
を状態によって更新しようとすると複雑度が一気に増したり、デザイナーさんがスタイルを当てる作業が難しくなったりするので避けます。
そして、以下の状態を満たしていると良好な状態と言えます。
- 原則1コンポーネントごとに scss を用意する
- HTMLのclass属性は次のようになるのが望ましい(CSS Modules の
composes
を利用)- 根の要素はクラス名1つ
- 枝の要素はクラス名2つ
- 葉の要素はクラス名1つ
- tagName を使用した要素は上記に1つ追加される
※ CSS Modules の composes
には順序不定問題があるので、CSS propertyの上書きが発生しないよう注意する必要があります
終わりに
今回は createComponent を導入する前に発生した問題と、createComponent 導入の具体的な手順などを紹介しました。
すでに似たようなことをやっている方もいらっしゃると思いますが、型の記述パターンや、名前付け、contextの利用方法など、細部で参考になる部分もあったのではないでしょうか。
ニコニコ生放送では、4月からcreateComponentを部分的に導入し、再利用性が向上したため、新たに作成するコンポーネントは原則としてcreateComponentに対応した形で実装することになりました。
それと同時に、NVCの主要コンポーネントも徐々にcreateComponent化を実施。
11月頃には網羅的なcreateComponent対応に加えてcontextをより効果的に扱うための大幅な機能強化を行い、今回の記事に含まれるButtonコンポーネントのsymbolMarkなどの仕組みが標準化されました。
これらの取り組みを終え、現在のニコニコ生放送では非常に多くのメリットを得られています。
-
高品質で使い勝手が良く変更に強いコンポーネントを高速に作れるようになった
- コンポーネント開発の旨味を最大限引き出せている
-
設定ファイルを書くような感覚でコンポーネントを組み立てられるようになった
- 書き味が最高なのでスラスラ書ける
-
記述パターンが確立されてレビューコストが軽減された
- 組み合わせるだけで作れるコンポーネントはバグが混入する余地がほとんどない
-
パターンや構造が確立されたことによりデザイナーさんの負担が軽減された
- スタイル当てはscssのみの更新で全て完結できる
-
再利用性が高まり、少数精鋭になるので、凝集度の高いコンポーネント作りに専念できる好循環になった
- 無理のないDRYを実現でき、1つの機能強化が全体で利用できる
この他にも、createComponentの技術をStory用のStoreに応用し、Storybookの高機能化&高品質化や、Story側の再利用性向上など、ViewComponent開発の全体を飲み込む勢いで活用し始めています。
社内ViewComponentライブラリのNVCには、まだまだ紹介しきれない素晴らしい機能が標準搭載されているので、今後も様々なノウハウをアウトプットしていければと考えています。