ニコニコ漫画モバイルサイトをリメイクしてみた

トリスタinsideに投稿された記事の再掲載です。

フロントエンドチームのsotaです! 2019年新卒としてドワンゴに入社し、トリスタでフロントエンド開発を行っています。 新人研修の一環として、ニコニコ漫画モバイルサイトをReactでSPA(Single Page Application)として再実装しました。 APIや仕様の策定なども行ったので、その様子を振り返ります。

やったこと

今回の研修で行なったことは以下のとおりです。

  • 仕様の策定(画面遷移図・ページ一覧・ワイヤーフレーム)
  • API設計 + モックサーバの構築
  • SPA実装

仕様の策定

現行のサイトを参考に、ページとURLをマッピングした画面遷移図を作成しました。

f:id:bookwalker_developers:20210930224142p:plain

また、各ページの役割やページの大まかな構成を確認するためのワイヤーフレームとページ一覧も作成しました。 プロダクトの全体像を可視化し整理することで、APIやクエリパラメータなどの情報も扱いやすくなります。

API 設計とモックサーバの構築

上記で洗い出したコンテンツや機能要件を提供するために、APIサーバとの通信に必要なインタフェースを設計し、モックサーバをNode.js + TypeScriptで構築しました。 使用したツールはSwaggerです。 このツールを使うことで、読みやすいAPIドキュメントの自動生成や、JSONやYAMLを返すモックAPIサーバとして役立てることができます。

Swagger Editorを使えば構文チェックやアウトプットを常に確認できるので便利です。

f:id:bookwalker_developers:20210930224158p:plain

実装にあたっては、OpenAPI (Swagger) 超入門という記事がとても参考になりました。

SPA 実装

Reactを用いたSPA実装では、トリスタで普段から使われている以下の技術スタックで挑みました。

  • React + MobX
  • TypeScript
  • styled-components
  • Storybook
  • Jest
  • ESLint + Prettier + stylelint

コンポーネント設計はAtomic Designを使用しています。 これは、UIコンポーネントの粒度を役割や再利用性を基準に、 atoms / molecules / organisms / templates / pagesに分けて管理する手法です。 今回は個々のpagesに対してもAtomic Designを適用しました。 つまり、ディレクトリ構造は以下のようになります。

./src

- atoms // 全ページ共通の atoms
- melecules
- organisms
- templates
- pages
  - A page
    |- atoms // A ページ固有の atoms
    |- molecules
    ~

テストに関しては、StorybookとStoryShotsを用いたUIの構造テストと、 Jestを用いたMobXストア及びヘルパー関数のテストをそれぞれ行いました。

これらはDrone CIで自動化され、テスト結果は都度Slackに通知されます。 f:id:bookwalker_developers:20210930224146p:plain

学び

1. Atomic Design + Storybook

開発が進むにつれて数が増えるページとコンポーネントは、Atomic Designによって見やすくスケーラブルに管理できました。 特にStorybookとの相性は抜群で、カテゴライズされたコンポーネントは見つけやすく、変更しやすく、UIテストも容易でした。

Storybookのstoriesを作成する際は、decoratorsがとても役立ちました。 例えば、React RouterのLinkを使用したコンポーネントのstoriesを作成する場合、コンポーネントはBrowserRouterなどのRouterでネストされる必要があります。 しかし、コンポーネントを毎回Routerでラップするのは手間です。

const stories = storiesOf('atoms/LinkButton', module);
storeis.add('default', () => (
  <Router>
    <LinkButton />
  </Router>
));

そこで下記のようなヘルパー関数を別に用意し、 addDecorator()を使ってdecoratorsとして組み込めば、storiesをRouterでネストできます。

const addRouter = storyFn => {
  return <Router>{storyFn()}</Router>;
};

storiesOf('atoms/LinkButton', module).addDecorator(addRouter);

同様に、React Context.Providerでラップされたコンポーネントに対して、storeのvalueをstoiresに提供したい場合にもこの方法は有効です。

const addProvider = <T extends any>(
  context: React.Context<T>,
  contextValue: T
): DecoratorFunction<any> => storyFn => {
  const { Provider } = context;
  return <Provider value={contextValue}>{storyFn()}</Provider>;
};

const stories = storiesOf('molecules/ComicItem', module);
stories.addDecorator(addProvider(ComicStoreContext, comicStore));

ContextオブジェクトとProviderで渡すvalueの型を使用時に設定するため、ジェネリクスを使って型の決定を遅延させます。 型付けされたContextオブジェクトから今回必要なProviderのみを取り出し、propsにvalueを渡して完成です。

2. Hooks API + TypeScript + MobX

コンポーネントの実装では、React Hooks APIを使って無駄な再定義やレンダリングを防ぎ、パフォーマンスの高さを意識しました。 stateで重くなりがちなコンポーネントは、MobXがstoreをもつことで解決できました。 TypeScriptによる静的型付けは、型チェックによってミスを大きく減らせるだけでなく、APIサーバが返す複雑なオブジェクトも型のおかげで安全に扱うことができました。

難しかった実装は、IntersectionObserver (ターゲットとなる要素とビューポイントの交差を監視できるAPI)を使って、任意の位置でfecthを発火させる機能です。

type Props = {
  callback: () => void;
  children: React.ReactNode;
};

const ChildIntersectionObserver = (props: Props) => {
  const { children, callback } = props;
  const { current: intersectionObserver } = React.useRef<IntersectionObserver>(
    new IntersectionObserver(() => callback())
  );
  const childRef = React.useRef<HTMLDivElement | null>(null);

  React.useEffect(() => {
    if (childRef.current != null) {
      intersectionObserver.observe(childRef.current);
    }
  }, [childRef.current]);

  React.useEffect(() => {
    return () => intersectionObserver.disconnect();
  }, []);

  return <div ref={childRef}>{children}</div>;
};

refからchildrenノードを取得後、intersectionObserver.observe(childRef.current)で監視します。 これよりビューポイントがchildrenと交差したときに、 propsから受け取ったcallback関数を発火させることができます。 しかし、このままでは交差開始時と終了時に計2回callback関数が実行されます。

そこで、IntersectionObserverオブジェクトのオプション: threshold(targetがどのくらいの割合で見えたらcallback関数を実行するか)を1(100%)に設定します。

const OBSERVER_RATIO = 1;
const { current: intersectionObserver } = React.useRef<IntersectionObserver>(
  new IntersectionObserver(
    ([entry]) => {
      if (entry.intersectionRatio >= OBSERVER_RATIO) {
        callback();
        intersectionObserver.unobserve(entry.target);
      }
    },
    { threshold: OBSERVER_RATIO }
  )
);

そして、現在のターゲットの交差度を示すentry.intersectionRatioが条件を満たせばcallback関数が実行されるよう、if文によって制御します。 これよりターゲットが完全に見えたときのみcallback関数を実行させることができました。

3. Pull Request とコードレビュー

変更に対して最小単位でプルリクエストを作成することが、スピードと質に繋がるということを学びました。 レビュー後にミスを見つけて勝手に変更を追加したり、 プルリクエストのサイズが適切ではないとレビューが困難になり、開発の遅れに繋がります。 今後も、説明が必要な箇所にはコメントを残す、適切な粒度を保つことなどを意識して、 レビューする側に配慮した開発を行いたいです。

まとめ

本記事では、ニコニコ漫画のモバイルサイトをSPAとして、仕様の策定からリメイクする様子を振り返りました。 サービスを初期段階から作り直すことで、開発手法がスピードに直結することや技術への理解がプロダクトの質を高めることを実感しました。 良いプロダクトを作れるよう、引き続き個々の技術をしっかり学んで行きたいです。