見出し画像

"コンポーネント単位"で進めるVueからReactへの移行

みなさんこんにちは!
ワンキャリアでONE CAREER PLUSの開発を担当しています貫(GitHub:@Da-1kun)です!
今回はONE CAREER PLUS開発チームで現在取り組んでいるフロントエンドフレームワークの移行についてご紹介します。



はじめに

ONE CAREER PLUSとは

ONE CAREER PLUSは、ファーストキャリアに限らず、キャリア選択にまつわる情報をオープンにすることで、中長期的なキャリアづくりのサポートを行う転職サイトです。

具体的には「転職体験談」「選考対策」「社員クチコミ」の3種のクチコミにより、「転職活動のオープン化」を目指しています。


取り組み背景

ONE CAREER PLUSではフロントエンドのフレームワークとしてNuxt 2を使用しており、2024年6月30日のEnd of LIfeが近づいているため、React(Next.js or Remix)への移行を進めています。
Nuxt 3へのバージョンアップではなく、Reactへの移行を決めた要因としては以下のような点がありました。

  • Reactのエコシステムの成熟度やシェア率の高さ

  • チーム内にReactでの開発を得意とするエンジニアが揃っていたため、React移行後も開発スピードが落ちなさそうだったこと

  • Nuxt 2からNuxt 3へのアップデートは破壊的な変更を多く含むため、網羅的なコードの修正が必要であり、ある程度の工数が必要であること

補足:ONE CAREER PLUSの開発当初(2021年時点)、社内ではVueの使用が主流であり、導入コストが最も低かったため、Nuxtが採用されました。


どうやって実現しているのか

普段の機能開発と並行して移行を進める必要があり、段階的な方法を探していたところ、「How to Incrementally Migrate From Vue.js 2 to React 18」という記事を見つけ、その記事の手法を参考に現在移行を進めています。
内容としては「ReactコンポーネントをNuxtにマウントする」というもので、Reactコンポーネントへのpropsや関数の受け渡しを可能にするマウント用のラッパーコンポーネントを作成することで実現しています。

参考にした記事の内容そのままでは上手くコンポーネントが描画できない場合があり、いくつか機能の拡張を行いました。

実際に運用しているラッパーコンポーネントは以下の通りです。(ReactWrapper.vue)

template部分

<template>
  <div class="react-wrapper">
    <div ref="container" />
    <div v-if="slotAccessible" ref="childrenSlot" style="display: none">
      <slot />
    </div>
  </div>
</template>

元の記事からslotの引き継ぎ機能を追加しています。(詳細はmethodsを参照)

watch、mount、destroyed部分

watch: {
    $attrs: {
      deep: true,
      handler() {
        this.updateReactComponent();
      }
    },
    $slots: {
      deep: true,
      handler() {
        this.updateReactComponent();
      }
    }
  },
  mounted() {
    this.reactRoot = createRoot(this.$refs.container);
    this.isMounting = true;
    this.updateReactComponent(true);
  },
  destroyed() {
    this.reactRoot?.unmount();
    this.isMounting = false;
  },

watchでReactコンポーネントへ受け渡すattributesやslotを監視し、コンポーネントの状態管理を行っています。

methods部分

methods: {
    async updateReactComponent(fromMounted) {
      try {
        const children = await this.getChildren();
        const props = {
          ...this.$attrs,
          ...(children ? { children } : {})
        };
        if (this.reactRoot && this.isMounting) {
          this.reactRoot.render(createElement(this.component, props));
          if (fromMounted) {
            // 非同期の render よりもさらに後ろにキューを追加する
            setTimeout(() => {
              if (this.onRenderedReactRoot) {
                this.onRenderedReactRoot();
              }
            }, 0);
          }
        }
      } catch {} // 想定される destroyed 時のエラーは何もせず握り潰す
    },
    async getChildren() {
      if (!this.$slots.default) {
        return this.$attrs.children;
      }
      this.slotAccessible = true;
      try {
        return await new Promise((resolve, reject) =>
          this.$nextTick(() => {
            const html = this.$refs.childrenSlot.innerHTML;
            if (html) {
              return resolve(parse(html));
            }
            reject(new Error('maybe component is destroyed'));
          })
        );
      } catch (e) {
        throw e;
      } finally {
        this.slotAccessible = false;
      }
    }
  }

updateReactComponent()では$attrsやslotを引き継いだ状態でのReactコンポーネントの生成を行っています。mount時に実行したい処理がある場合はonRenderedReactRoot()として実行したい関数を引数として渡すことでmount時に任意の処理を発火させることができます。
getChildren()ではslotをReactコンポーネントのchildrenとして引き継ぐため、innerHTMLを「html-react-parser」を用いてJSX.Elementにparseしています。

実際の置き換えイメージ

選考対策一覧ページの詳細検索モーダルコンポーネントは以下のようにReactWrapperコンポーネントを通してReactコンポーネントにて描画しています。

<PageComponent>
  <ReactWrapper
    :component="ModalSearchSelectionReportsComponent"
    :searchQuery="searchQuery"
    :onSearch="handleSearch"
  />
</PageComponent>

<script>
import ModalSearchSelectionReports from 'react-component/ModalSearchSelectionReports';

export default Vue.extend({
  computed: {
    ModalSearchSelectionReportsComponent() {
      return ModalSearchSelectionReports;
    }
  }
});
</script>

ModalSearchSelectionReports.tsx(簡略化したもの)

type Props = {
  searchQuery: SearchQuery;
  onSearch: (searchQuery: SearchQuery) => void;
};

const ModalSearchSelectionReports: FC<Props> = ({
  searchQuery, onSearch
}) => {
  const [localSearchQuery, setlocalSearchQuery] = useState(searchQuery);

  return (
   <Modal
    <Button
      onClick{onSearch}
     >
      この条件で絞り込み
     </Button>
   />
 )
};

export default ModalSearchSelectionReports;

詳細検索モーダルUI


今回の移行方法における制約

今回の移行方法はReactコンポーネントをNuxtにマウントするという特殊な手法を用いているため、フレームワークの恩恵をいくつか受けられなくなるという制約が伴います。

1つ目は、ラッパーコンポーネントで使用できるslotはJSX.Elementへの変換処理が必要なため「文字列」または「静的なHTML Element」のみに限定されます。つまり、propsや関数を引数にとるコンポーネントはReactWrapperのslotとしては使用できません。(マウントするReactコンポーネントの子コンポーネントでのchildrenの使用は可能です)

childrenとして使用できる例

<ReactWrapper :component="ABodyComponent">
   検索結果<span class="total-count">{{totalCount}}</span> 件
 </ReactWrapper>

2つ目は、マウントしたReactコンポーネントのアンカー要素(<a>)からの画面遷移を行う場合、フレームワークのルーティング機能を使用することができないため、ページ描画のための全てのリソースの再読み込みが発生し、画面描画パフォーマンスが落ちてしまいます。(回避策として$router.push()関数をReactコンポーネントに渡すことでアンカー要素を使用しない画面遷移は可能となります。)

3つ目は、マウントされるReactコンポーネントの描画は全てCSR(Client Side Rendering)となります。NuxtによるSSR(Server Side Rendering)は行われません。


工夫したポイント

今回のコンポーネントの置き換えにおいては、置き換え前後でUXやクローラーへの悪影響が出ないよう、置き換えるコンポーネントの順序を工夫しています。具体的な順序としては以下のような順序で進めています。

  1. ログインが必要なページのコンポーネント

    1. クチコミ投稿ページ、マイページ等

  2. 画面遷移を伴わないコンポーネント

    1. 検索モーダルコンポーネント等

  3. ログインしていなくても閲覧が可能なトラフィックの多いページのコンポーネント

    1. クチコミ・記事ページ等

1のコンポーネントから取り掛かっている理由は、ログイン後のページはクローラーによるクローリングの対象外であり、アンカー要素を設置する必要がないため今回の手法における制約の影響を受けないためです。
逆に3のコンポーネントは画面描画のパフォーマンスが大きくUXに影響するかつクローリングの対象であるため、アンカー要素の設定とパフォーマンスの維持が必須となります。そのため3のコンポーネントの置き換えタイミングはSSR用のフレームワークの使用を開始するタイミングとしています。

そのほか移行作業の効率化という観点で、コンポーネントの置き換えの際に必要となるファイルをHygenを使用してコマンドから自動生成しています。


今回の移行方法によるメリット

今回の移行を進める中で感じたメリットは以下の2点となります。

  1. コンポーネント単位で移行を進められるため、既存機能への影響を最小限にしつつ普段の機能開発と並行できる点

  2. CSRで問題ないコンポーネントの移行から始めることで、ReactコンポーネントのSSR用のサーバーを移行初期から用意する必要がなくなり、移行時のインフラコストの増加を抑えることができる点


おわりに

たまたま見つけた記事を参考にした特殊な移行方法でしたが、大きな障害もなく順調に進行しています。現在ではCSRで問題のないコンポーネントの置き換えがもう間もなく終了するため次のSSR用のフレームワークの使用を開始するフェーズに移ろうとしています。先月にはReactの次のメジャーバージョンであるReact 19でのアップデート内容についての情報が出てきたこともあり早く移行を完了させたいなと思います。
ここまで読んでくださりありがとうございました!


▼ワンキャリアのエンジニア組織のことを知りたい方はまずこちら

▼カジュアル面談を希望の方はこちら

▼エンジニア求人票


この記事が参加している募集

#オープン社内報

22,582件

この記事が気に入ったらサポートをしてみませんか?