Next.js アプリケーションの共通コンポーネント開発

こんにちは。レシピサービス開発部のkaorun343です。クックパッドではスマートフォン向けページにおける開発者体験向上のために、レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話にて紹介したとおり、Next.jsとGraphQLを用いたモダンな環境へと移行を進めています。例えばモバイル端末からのアクセスでURLがトップページの / であれば Rails、レシピ詳細ページの /recipe/:id であれば Next.js アプリにルーティングされるようになっています。現在ではレシピ詳細ページだけではなく検索結果ページやつくれぽ詳細ページ、MYフォルダページなどもNext.jsアプリケーションに置き換わっています。今回はその移行により生じた課題と取り組み方、それから併せて実施したモノレポ環境整備について紹介します。

共通コンポーネントの導入背景

cookpad.com では上述の通りページによってホストするアプリケーションが異なる一方で、ヘッダーやサイドメニュー、フッターといった全てのページに共通して表示されているコンポーネントがあります。このような、Next.js と Rails アプリの両方から利用される UI コンポーネントのことをこの記事では "共通コンポーネント" と呼ぶこととします。

クックパッドではハロウィンやクリスマスにあわせてヘッダーやフッターのデザインを変更する施策を定期的におこなっています。したがって共通コンポーネントに変更を加えたいとき、2つのアプリケーションのその両方の実装を編集しなければなりません。さらにこの2つのアプリケーションは使用している言語が異なるため、実装担当者にとってはさらなるコストになっています。 そこで実装を一度で終えられる仕組みを導入しました。

トップページとレシピ詳細ページのサイドメニュー(共通コンポーネントの例)

実装方法

結論としては、共通コンポーネントを以下の方針で実装することにしました。

  • Reactコンポーネントとして実装し、Next.jsでは通常のReactコンポーネントとして利用する。
  • 少ない労力でRails上で表示するために、上記のReactコンポーネントをウェブコンポーネントでラップする。

developer.mozilla.org

Reactによる共通コンポーネント作成

共通コンポーネントはReactで作ることにしました。この方針には2つ理由があります。

1つめは、Next.js上では今まで通り通常のReactコンポーネントとして使うためです。Vue.jsやSvelteなど他のパッケージを使わないため余計なコードが含まれなくなり、バンドルサイズが増加したり表示が遅くなったりする心配がありません。加えて、Next.js上で容易にSSRすることができます。

2つめは、Next.js アプリに実装済みのコンポーネントを共通コンポーネントとして再実装する際にその実装をほぼそのまま使えるためです。実際、Next.js用に実装したコードを少し修正するだけで済みました。

Storybookによる開発環境

共通コンポーネントの開発に際してはStorybookを導入しました。クックパッドではプルリクエストごとにステージング環境を作れる基盤があるので、このStorybookもプルリクエストで確認できるようにしました。これで変更内容をレビュアーが容易に確認できます。

また、表示の確認だけではなくテストにも使っています。Storybookの中で事前にpropsを渡したりcontextのproviderで包んだりしているので、テストの実装を綺麗にできます。

import { MyReactComponent } from './MyReactComponent'
import { MyProvider } from '~/contexts/MyContext'

export default {
  component: MyReactComponent,
  decorators: [
    (Story) => (
      <MyProvider>
        <Story />
      </MyProvider>
    )
  ],
}

export const Default = {}

export const Prop1 = {
  args: {
    prop1: 'prop-1',
  },
}
import { composeStory } from '@storybook/testing-react'
import { axe, toHaveNoViolations } from 'jest-axe'
import * as stories from './MyReactComponent.stories'

expect.extend(toHaveNoViolations)

const storyNames = ['Default', 'Prop1']

describe('MyReactComponent', () => {
  describe.each(storyNames)('%s', (storyName) => {
    const Story = composeStory(stories[storyName], stories.default)

    it('スナップショットテスト', () => {
      const { container } = render(<Story />)
      expect(container.firstChild).toMatchSnapshot()
    })

    it('アクセシビリティチェック', () => {
      const { container } = render(<Story />)
      expect(await axe(container)).toHaveNoViolations()
    })
  })

  describe('Prop1', () => {
    const Story = composeStory(stories.Prop1, stories.default)

    // 個別にテストしたいことを書く
  })
})

こちらの記事を参考にしました。 zenn.dev

リソース取得のためのAPIサーバー

Next.js版ではAPIサーバーとの通信に専用のGraphQLサーバーを使っています。共通コンポーネントの実装にあたっては、専用のGraphQLサーバーを用意するとサーバーの実装やCI/CD環境を構築する手間がかかることや、Next.jsのGraphQLサーバーと機能が重複していることから、Next.jsと同じGraphQLサーバーを利用することにしました。

ウェブコンポーネント作成

Rails アプリに直接 React コンポーネントをマウントしようとすると Rails アプリ側にたくさん JS のコードを書く必要があってつらいです。加えてRails側にReactコンポーネントを変換するための環境を構築しなければなりません。また、共通コンポーネントが内包するリセット用 CSS などをRailsアプリに影響させたくないといった課題がありました。 そこでRailsアプリケーションに導入する際は、Reactコンポーネントをウェブコンポーネントで包むことにしました。

ウェブコンポーネントは、再利用可能なカスタム要素を作成し、ウェブアプリの中で利用するための、一連のテクノロジーです。コードの他の部分から独立した、カプセル化された機能を使って実現します。 ウェブコンポーネント | MDN より

ウェブコンポーネントは複数の技術要素から成り立っています。カスタムエレメントが前者を、Shadow DOM が後者を解決してくれます。

カスタムエレメント

Reactコンポーネントをマウントする場合、自力でマウント対象の要素を探し、マウントしなければなりません。一方でカスタム要素にすることでそれを利用する側ではカスタム要素のタグを書くだけで済みます。

Shadow DOM

ReactコンポーネントをShadow DOMの子要素にマウントすることにしました。 共通コンポーネントはNext.jsアプリケーションと同じリセットCSSをベースにスタイリングしています。もしShadow DOMがなければRailsアプリケーションも同じリセットCSSを導入するか、共通コンポーネントのスタイリングそれぞれにリセットCSSを含めなければいけません。一方で Shadow DOMを使えばリセットCSSをShadow DOMの一番最初に追加するだけで済みます。

実装例

ウェブコンポーネント版の共通コンポーネントパッケージでは、Reactコンポーネントを表示するカスタムエレメントを提供します。

export class MyCustomElement extends HTMLElement {
  connectedCallback() {
    const mountPoint = document.createElement('div')
    // シャドウルートをカスタムエレメントに追加
    this.attatchShadow({ mode: 'closed' }).appendChild(mountPoint)
    // Reactコンポーネントのルートオブジェクトを作成
    const root = createRoot(mountPoint)
    const prop1 = this.getAttribute('prop1')

    // ウェブコンポーネント用のEmotionの設定
    const emotionCache = createCache({
      key: 'my-custom-element',
      container: mountPoint,
      prepend: true,
      speedy: true,
    })

    // Reactコンポーネントのレンダリング
    root.render(
      <CacheProvider value={emotionCache}>
        <GlobalStyles />
        <MyReactComponent prop1={prop1} />
      </CacheProvider>
    )
  }
}

共通コンポーネント(ウェブコンポーネント)を利用する側では、まずカスタムエレメントを定義します。そしてHTMLにそのカスタムエレメントのタグ名を記述します。

import { MyCustomElement } from '@cookpad/shared-components'

customElements.define('my-custom-element', MyCustomElement)
<my-custom-element prop1="prop-1"></my-custom-element>

モノレポ環境整備

モノレポ環境整備を整備する前の状況

共通コンポーネントはNext.jsとGraphQLサーバーのリポジトリに存在します。開発当初はモノレポ環境にしていなかったため、それぞれのパッケージが独立していました。ゆえに共通コンポーネントをNext.jsで使うためには一度npmパッケージとして公開するか、もしくは yarn link 機能を使わなければならず手間がかかっていました。加えてCIジョブもそれぞれのパッケージごとに作っていたので、今後新たにパッケージを増やすとそのたびにCIジョブも作らなければいけませんでした。そこで共通コンポーネントの実装と並行して、モノレポ環境を整備していきました。

Yarn Workspacesの導入

ワークスペース機能を使って共通コンポーネントを直接参照できるようにしました。Next.jsに共通コンポーネントパッケージをインストールするときはYarn workspacesを利用し、別リポジトリにあるRailsにインストールするときは社内のプライベートnpmレジストリに公開してから使っています。

yarnpkg.com

Turborepoの導入

Turborepoはモノレポ管理ツールと呼ばれるものです。設定を比較的簡単に書けるのが特徴です。また他のモノレポでは複数の実行環境に対応しているものもありますが、TurborepoはNode.js用の管理ツールです。対象のリポジトリはJavaScript(Node.js)だけからなるモノレポなので、Turborepoを採用することにしました。 Turborepoを導入することで、CIのジョブを1つにまとめることができました。また、変更があったパッケージとそれに依存するパッケージのみ必要なタスクを実行してくれるので、ジョブの実行時間が短くなりました。例えば共通コンポーネントパッケージに変更があった場合は、Next.jsのパッケージもテストなどを実行してくれる一方でGraphQLサーバーについてはタスクの実行をスキップしてくれます。

turbo.build

さいごに

今回はレシピサービスの共通コンポーネント導入とモノレポ環境整備について紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を引き続き進めていきます。この取り組みを一緒に進めてくれる仲間を募集していますので、興味のある方はぜひご連絡ください。

cookpad.careers