LoginSignup
23
11

More than 5 years have passed since last update.

React + TypeScript における ViewComponent の美しい合成技術

Last updated at Posted at 2018-12-13

この記事は第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 で隠して、展開ボタンを表示しています。
スクリーンショット 2018-12-09 2.18.50.png

展開した状態

展開すると、overflow: hidden が消えて内容の全てが表示されます。(スタイルの変化は属性セレクタの変化に連動)
スクリーンショット 2018-12-09 2.18.42.png

責務の関係上、展開状態は Expanderstate に持ってほしいのですが、Expander の中身の内容は利用する側で指定できる必要があります。
利用する側では、表示する内容を展開状態によって切り替えたい場面もあることから、generator という関数を渡し、引数で展開状態を受け取れるインタフェースにしていました。

<Expander
  expandedToggleButton={props.expandedToggleButton}
  generator={(expanded: boolean) => {
    return expanded ? <Foo {...props.foo} /> : <Bar {...props.bar} />;
  }}
/>

展開中なら Foo を表示、閉じていれば Bar を表示、一見問題なく動くように見えます。
しかし、描画性能を上げるため、ExpandershouldComponentUpdate を仕込んだ時に問題は発生しました。

Expander に渡している props に変化が無いと、generator に渡した関数が呼ばれないので、
props.fooprops.bar の値が変化しても、再描画が行われません。

このコードを見て、他の方法に気付いた方もいるかもしれません。
generator 内で使う propsExpander に渡してしまって、Expander 内から generatorprops を渡す方法です。
しかし、関数の引数と戻り値の関係でないと、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 コンポーネントです。

ボタンを押すと "コンテンツ" の部分が開閉するイメージ。
スクリーンショット 2018-12-13 12.32.20.png

これを 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: "#" }} />;

アンカー連携した例。
スクリーンショット 2018-12-13 12.32.53.png

createComponent で作ったコンポーネントをさらに createComponentcontext に使用する場合、
正しく書いてあれば、いくつ入れ子にしてもちゃんと深い階層まで型推論が効いて型が守ってくれます。

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 と同様に toggleButtoncontext で注入できるようにします。

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,
  }),
});

ボタンでアイコンを使った例。
スクリーンショット 2018-12-13 12.31.53.png

6. コンポーネントの要素自体を取り替えられるようにする

Dropdown ではあまり考えられませんが、コンポーネントによっては、コンポーネント自体を形成する要素を取り替えたくなる場合があります。
例えば、階層構造を変化させずに、ある要素をアンカーにしたい場合など。

子要素と違って、コンポーネント自体を形成する要素は必ず1つでなので、型パラメータには TH の短い名前を使います。

// 渡した型パラメータで交差型を使うため ( 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コンポーネント を作る例です。

Dropdown.tsx
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.scss
.dropdown {
  composes: section from "./section/section.scss";
}

.toggle-button {
  composes: toggle-button from "./toggle-button/toggle_button.scss";
}

.contents {
  /*!*/
}
section/section.scss
.section {
  /*!*/
}

.heading {
  /*!*/;
}
toggle-button/toggle_button.scss

// NVCの Button コンポーネントは atom ですが、もっと小さな世界(子要素)を持ちます
.toggle-button {
  /*!*/
}

.symbol-mark {
  /*!*/
}

.content {
  /*!*/
}

表示結果はこちら。
スクリーンショット 2018-12-13 12.32.02.png

出力される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には、まだまだ紹介しきれない素晴らしい機能が標準搭載されているので、今後も様々なノウハウをアウトプットしていければと考えています。

23
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
11