エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

開発を止めない段階的フロントエンドリプレイスの実践 (2) 技術編

【デジカルチーム ブログリレー2日目】

こんにちは、デジカルチームでソフトウェアエンジニアをしている穴繁です。

長年開発を続けてきたサービスを運用していると、「そろそろアレもコレも新しくしたいなぁ…でもサービスは止められないし、どう進めたものか…」なんて頭を悩ませることはありませんか?

今回は、まさにそのような状況で私たちが実践した「開発を止めない段階的フロントエンドリプレイス」について、計画・技術・組織という3つの観点からご紹介します。

前回の計画編では、なぜ段階的なリプレイスを選択したのか、そしてその全体像についてお話ししました。

www.m3tech.blog

続く本記事(技術編)では、そのリプレイスを支えた具体的な技術要素や、導入の過程で得られた知見についてお話しします。

デザインシステムを活用したUIリプレイス

段階的なReactへのリプレイスを進める上で、一貫性のあるUIと生産性の向上は重要なテーマです。プロジェクト開始当初、UIコンポーネントの改善に向けて、次のようなモチベーションがありました。

  • 共通コンポーネントのさらなる拡充: React製の共通コンポーネントの整備は進んでいましたが、アプリケーション全体をカバーするためには不十分でした。
  • コンポーネントの標準化の機会: 同じ目的のコンポーネントが複数種類存在し、どれが最新で推奨されるものか不明確な状態でした。
  • 古いライブラリの依存からの脱却: 一部のコンポーネントは古いバージョンのReact Bootstrap (v0.33.1, Bootstrap 3ベース) に依存していました。

これらを達成するため、Radix UIをベースとした独自のReactコンポーネントライブラリdigikar-uiを構築し、段階的に既存コンポーネントを移行していく方針を立てました。

なぜRadix UIを選んだか?

数あるUIライブラリの中からRadix UIを選定した主な理由は、Headless UIである点です。既存デザインの再現や将来的なデザイン変更に高い柔軟性を持っていたことと、WAI-ARIA標準に準拠しUIコンポーネントとして満たすべきアクセシビリティが担保されていたため、コンポーネントのコアロジックとスタイリングの実装に集中できました。また、特定のCSSライブラリに依存しない点も魅力でした。

Radix UIへの移行で直面した課題と解決策

UIライブラリの採用は、初期開発工数の削減や品質担保の面で大きなメリットがありました。一方で、ライブラリ特有の課題に直面することもありました。特に、Radix UIへの移行過程では、ライブラリ側の不具合や、エムスリーデジカル独自の要件との兼ね合いで解決に時間を要するケースがいくつか発生しました。問題解決のためにRadix UIのソースコードを読み込んだり、一時的なパッチワーク対応が必要になったりと、実際の置換作業は想定以上に大変な場面もありました。

ここでは、その中でも特に印象的だった事例をいくつか共有します。*1

Primitive間のバージョン不整合による不具合

Radix UIは当初、@radix-ui/react-dialogのようにPrimitiveごとに個別のパッケージで提供されていました。Renovateなどで特定のPrimitiveのみを更新すると、他のPrimitiveとの依存関係(特に内部で共通利用されている@radix-ui/react-dismissable-layerなど)のバージョンがずれ、予期せぬ不具合が発生するケースがありました。*2 この問題に遭遇して以降、Radix UIのバージョンアップは全てのPrimitiveで同時に行うように運用を改善しました。なお、2025年1月のリリースで全Primitiveが単一のradix-uiパッケージからインポート可能になり、この問題は解消されたようです。個人的には大変助かるアップデートです。

既存ダイアログとの併用時の課題

段階的なアプローチはリスクを分散できる一方で、移行期間中は新旧の実装が共存することになります。これは、段階的アプローチを選択したことによるデメリットともいえ、共存に起因する特有の問題への対応が求められました。私たちのケースでは、Radix UIのダイアログの上に別UIライブラリのダイアログを開いた際に次の問題が発生しました。

  • Radix UIによってbody { pointer-events: none; }が付与されるため、別のUIライブラリのダイアログが操作不能になる。
  • Radix UIダイアログ外をクリックした際のonPointerDownOutsideイベント(Radix UIが提供するProps)が、別UIライブラリダイアログのクリックにも反応し、裏側のRadix UIダイアログが意図せず閉じてしまう。

これらはそれぞれ、CSSでbody { pointer-events: auto !important; }を強制的に適用する、onPointerDownOutsidePropsを無効化することで解決しました。

Apple Pencilでのタッチ操作の問題

エムスリーデジカルはiPad + Safari環境もサポートしていますが、Radix UIのmodal modeでDialogやDropdownコンポーネントがApple Pencilのタッチに反応しないという現象に遭遇しました。*3 根本解決が難しかったため、影響を最小限に抑えつつnon-modalモードを利用することで回避しました。

DropdownのTrigger要素の下部をクリックすると、意図せず一番上のMenuItemがクリックされた判定になる問題がありました。*4 当時は、意図せずMenuItemがクリックされた場合、MenuItemにdata-highlighted属性が付与されない、という挙動の差を利用したややハックな対応で回避しました。

Tooltipの挙動変更への対応

Radix UIのTooltipは、WCAG 2.1の勧告に従い、コンテンツ上にマウスを移動しても表示され続ける挙動がデフォルトでした。*5 しかし、エムスリーデジカルではTooltipが他の操作を阻害するケースがあったため、従来のホバーされたらコンテンツが消える挙動を踏襲する必要がありました。幸い、Radix UIにはdisableHoverableContentというオプションが用意されており、これを利用することで要求を満たすことができました。アクセシビリティ標準とプロダクト固有の要求とのバランスを考慮する事例となりました。

これらの経験から、UIライブラリの移行は単に置き換えれば完了ではなく、プロダクト固有の要件やライブラリの特性を深く理解し、時には地道な調査や工夫が求められることを改めて認識しました。

jscodeshiftによるコンポーネントの一括置換

jscodeshiftは、JavaScriptのコードをプログラム的に変換するためのツールキットです。コードを抽象構文木(AST)として解析し、指定したルールに基づいて変換処理を実行できます。

前述した通り、段階的な移行では、旧実装と新実装が混在することでコードベースが複雑化し、潜在的なリスクが生じることもあります。この課題に対処するため、jscodeshiftを用いて特定の旧コンポーネントを新コンポーネントへ一括置換するアプローチを採用しました。これにより、移行期間中のコードの整合性を高めつつも手作業による置換ミスを防ぐことができます。

具体的な例として、toastコンポーネントの移行があります。旧コンポーネントの呼び出し箇所を新しいtoast関数の形式に自動変換するjscodeshiftスクリプトを作成しました。このスクリプトは、引数の順序や型といったインターフェースの違いを吸収するように実装されています。

この変換を実行したコマンドは次の通りです。後述しますが、当時のコードベースにはFlowによる型注釈が残っていたため、--parser=flow オプションを指定しています。

npm jscodeshift -t ./migrate-toaster.js ./src/**/*.js --parser=flow

また、package.jsonにはjscodeshift本体と、Flowパーサーのバージョンを固定するための resolutions 設定を追加しました。

{
  "devDependencies": {
    "jscodeshift": "0.4.0"
  },
  "resolutions": {
    "**/ast-types": "npm:@gkz/ast-types",
    "**/flow-parser": "0.56.0"
  }
}

実際に使用した変換スクリプト(migrate-toaster.js)は次の通りです。

migrate-toaster.js

/*
このスクリプトによって変換されるExample

Before: this.toaster.pop('info', 'title', 'description', 3000);
After: toast({ status: 'info', title: 'title', description: 'desctiprion', duration: 3000 });

Before: this.toaster.info('title', 'description');
After: toast({ status: 'info', title: 'title', description: 'description' });

Before: this.toaster.error('title', 'description');
After: toast({ status: 'error', title: 'title', description: 'description' });

Before: this.toaster.warning('title', 'description');
After: toast({ status: 'warn', title: 'title', description: 'description' });

Before: this.props.$injections.toaster.pop('info', 'title', 'description', 3000);
After: toast({ status: 'info', title: 'title', description: 'desctiprion', duration: 3000 });

Before: this.props.toaster.pop('info', 'title', 'description', 3000);
After: toast({ status: 'info', title: 'title', description: 'desctiprion', duration: 3000 });
*/

const argumentIndexMapperIfPop = {
  0: 'status',
  1: 'title',
  2: 'description',
  3: 'duration',
};

const argumentIndexMapperOthers = {
  0: 'title',
  1: 'description',
  2: 'duration',
};

const statusMapper = {
  info: 'info',
  error: 'error',
  // errorと間違えてerrorsになっているtoasterがいくつかある (その場合は挙動としては現状infoになっている)
  // この際、errorにマッピングしてしまう
  errors: 'error',
  warning: 'warn',
};

const transform = ({ source, path }, { jscodeshift }) => {
  const j = jscodeshift;
  const root = j(source);
  const imports = root.find(j.ImportDeclaration);

  // this.{}.toaster. を見つける
  const target = root.find(j.CallExpression, {
    callee: {
      object: {
        property: {
          name: 'toaster',
        },
      },
    },
  });

  let shouldAddImport = true;
  const addToastImportStatement = () => {
    const toastImportStatement = `import { toast } from '${'../'.repeat(
      path.split('/').length - 3
    )}digikar-ui/toast';`;
    if (imports.length) {
      // importsの末尾に追加
      j(imports.at(imports.length - 1).get()).insertAfter(toastImportStatement);
    } else {
      // 先頭にimportを追加
      root.get().node.program.body.unshift(toastImportStatement);
    }
    shouldAddImport = false;
  };

  const statusValue = argument => {
    if (statusMapper[argument.value]) {
      return j.literal(statusMapper[argument.value]);
    } else {
      // this.toaster.pop(msgLevel, 'title', 'description')のようなパターンをケア
      return argument;
    }
  };

  return target
    .replaceWith(p => {
      const callee = j.identifier('toast');
      const argumentArray = [];
      const isPop = p.value.callee.property.name === 'pop';
      const argumentIndexMapper = isPop
        ? argumentIndexMapperIfPop
        : argumentIndexMapperOthers;

      if (!isPop) {
        argumentArray.push(
          j.property(
            'init',
            j.identifier('status'),
            j.literal(statusMapper[p.value.callee.property.name])
          )
        );
      }

      for (let i = 0; i < p.value.arguments.length; i++) {
        const argument = p.value.arguments[i];

        if (argument.value !== null) {
          argumentArray.push(
            j.property(
              'init',
              j.identifier(argumentIndexMapper[i]),
              argumentIndexMapper[i] === 'status'
                ? statusValue(argument)
                : argument
            )
          );
        }
      }

      if (shouldAddImport) addToastImportStatement();

      return j.callExpression(callee, [j.objectExpression(argumentArray)]);
    })
    .toSource({ quote: 'single' });
};

export default transform;

DatePicker移行

Radix UIへの移行とは別に、特に移行難易度が高かったのがDatePickerコンポーネントです。歴史的経緯から3種類のDatePickerが既存プロダクトには混在しており、それぞれ微妙に仕様が異なっていました。

これらの既存仕様(無効化/null許容の扱い、直接文字入力、カレンダーの配置、日付範囲指定、クリア機能、和暦表示など)を整理し、デグレを起こさずに利便性を向上させる必要がありました。要求されるカスタマイズ性が高いと予測されたため、特定のライブラリには依存せず、DatePickerは独自実装するという判断に至りました。

実際に作成したDatePickerコンポーネントは以下です。

InputDatePicker

型安全な開発環境への移行

フロントエンド開発全体の生産性と品質を向上させるため、型安全な開発環境への移行にも注力しました。プロジェクト開始当初、次のようなモチベーションを抱えていました。

  • TypeScriptへの完全移行: TypeScriptの導入は進んでいましたが、一部Flowで書かれたJavaScriptファイルが残存していました。前述した通り、この混在状況は、jscodeshiftのようなASTを扱うツールを利用する際にパーサーの指定を複雑にするデメリットもありました。
  • 信頼性の高い型定義の確保: 型定義の作成が進捗していたものの、それがAPIレスポンスを表現するものなのか、あるいはアプリケーション内部のドメインモデルを表現するものなのか、その意図が必ずしも明確ではありませんでした。

これらを達成するため、FlowからTypeScriptへの完全移行と、OpenAPIからの型定義の自動生成という2つの主要な取り組みを進めました。

FlowからTypeScriptへの移行

コードベース全体の型安全性を向上させる第一歩として、Flowで書かれた既存のJavaScriptファイルをTypeScriptへ一括で移行することを決定しました。この移行を効率的に進めるため、flow-to-tsというツールを活用しました。

ただし、ツールの適用だけで移行が完了するわけではありませんでした。例えば、歴史的経緯からJavaScriptファイルとTypeScriptファイルで適用されるESLintルールが異なっていたため、単純な変換だけでは大量のESLintエラーが発生しました。

そこで、次のような手順で移行を進めました。

  1. flow-to-tsの実行: git grep -l @flow | grep .js | xargs flow-to-ts --write --delete-source コマンドでFlowの型定義をTypeScriptの型定義に変換し、ファイル拡張子を .js から .ts へ変更しました。
  2. ESLint自動修正: npm run eslint --fix で自動修正可能なESLintエラーを解消しました。
  3. ESLintエラー抑制: 自動修正できないESLintエラーは、一時的に eslint-disable-next-line コメントで抑制しました。(npm run eslint -f json -o warnings.json でエラー箇所を特定し、スクリプトで抑制コメントを挿入。)
  4. TypeScriptエラー抑制: 同様に、TypeScriptコンパイラが検出したエラーも、suppress-ts-errors などのツールを用いて @ts-expect-error コメントで一時的に抑制しました。

この一括置換により、一時的に any 型やエラー抑制コメント(eslint-disable-next-line, @ts-expect-error)がコードベースに導入されることになりました。しかし、これにより既存のFlowの資産を活かしつつ、短期間で全てのJavaScriptファイルをTypeScript化するという目標を達成できました。この移行は、ESLintルールの統一や、ASTを扱うツールの利用を容易にするなど、その後のリファクタリングや開発効率向上に向けた重要な基盤となりました。

OpenAPI導入と型定義の自動生成

TypeScriptへの移行によりコードレベルでの型安全性の基盤は整いましたが、バックエンドAPIとの連携部分における型の信頼性確保が次の課題でした。そこで、API仕様の記述標準であるOpenAPIを導入し、APIスキーマからフロントエンドで利用する型定義を自動生成するアプローチを採用しました。

  • OpenAPI選定理由: バックエンドはRESTful APIを主体としていたため、GraphQLやgRPCといった他の選択肢と比較して、既存のAPIに導入しやすいOpenAPIを選びました。
  • スキーマの信頼性担保: APIスキーマ定義の信頼性を高めるため、バックエンド(Rails)側で committee-rails を導入し、リクエストとレスポンスがスキーマ定義に準拠しているかをテストで検証するようにしました。これにより、スキーマ定義が常に最新かつ正確であることが保証されます。*6
  • 型定義生成: フロントエンドでの型定義生成には aspida を採用しました。選定にあたっては、enum定義がUnion型として生成されるか、date/date-time型がstringとして扱われるか、Nullableなenum定義が正しくNullable型になるか、といった点を重視しました。
  • カスタマイズ: 生成された型定義をそのまま利用するのではなく、フロントエンドでの扱いやすさを考慮していくつかカスタマイズを加えました。例えば、APIレスポンスのキーがsnake_caseである一方、フロントエンドではcamelCaseで扱いたかったため、型定義生成プロセスやデータ取得処理の中で変換するようにしました。
  • ライブラリロックイン回避: データ取得ライブラリとしてSWRを利用していましたが、aspidaが提供するSWR連携機能(useAspidaSWR)は利用せず、あえて型定義の生成のみに留めました。これは悩ましかったですが、特定のライブラリへの過度な依存を避け、将来的な技術選択の自由度を確保するためです。

現在では大部分のエンドポイントでOpenAPIが定義されており、そこから生成した型定義をフロントエンドで利用しています。一方で、OpenAPIのYAMLを書くのが大変といった声もあったため、今後はTypeSpecの導入も検討しています。

モノレポ化による開発基盤の整備

エムスリーデジカルは、受付・カルテ画面だけでなく医療機関向けの設定画面など複数のアプリケーションをメンテナンスしており、これら全体での開発効率と品質をさらに高めたいという思いがありました。そこで、モノレポ構成を採用することで、次のようなモチベーションの実現を目指しました。

  • コード共通化による開発効率の向上: 複数のアプリケーション間で共通して利用できるコンポーネントやユーティリティ関数を汎用パッケージ化し、開発の重複を削減したいと考えました。
  • CI/CDパイプラインの最適化: 各アプリケーションやパッケージの変更に応じた効率的なCI/CDプロセスを構築し、ビルドやデプロイの時間を短縮したいという目標がありました。

これらのモチベーションを達成するため、pnpm workspacesを利用したモノレポ構成を採用しました。

pnpm workspacesの採用

最終的に、リポジトリは次のような構成になりました。

├── reception-karte     # 受付・カルテ画面(React 17)
├── institution-admin   # 施設向け設定画面(React 18)
├── digikar-ui          # 共通UIコンポーネントライブラリ
├── apis                # APIクライアント
├── utils               # 汎用ユーティリティ関数
├── package.json
└── pnpm-workspace.yaml

この構成により、digikar-uiやapis, utilsといった共通機能を独立したパッケージとして切り出し、各アプリケーションから容易に参照できるようになりました。

特筆すべき点として、reception-karteとinstitution-adminで一時的にReactのバージョンが異なるという課題がありました。これは、digikar-uiパッケージにてpeerDependenciesを活用することで解決できました。

// digikar-ui/package.json
{
  "name": "@digikar/ui",
  // ...
  "peerDependencies": {
    "react": "^17.0.0 || ^18.0.0",
    "react-dom": "^17.0.0 || ^18.0.0"
  }
  // ...
}

なぜpnpmを選んだか?

モノレポ化にあたり、パッケージマネージャーとしてyarn v1からpnpmへ移行しました。

yarn v1を利用していた際、node_modulesやキャッシュを事前削除しないと期待通りにモジュールがインストールされないといった現象に度々遭遇しました。pnpmではnode_modulesの管理において、重複削除や巻き上げといった仕組みを廃止し、シンボリックリンクを活用する全く異なるアプローチが採用されています。この違いにより、yarn v1で発生していた課題が解決されることを期待しました。

モノレポ化により、コードの再利用性の向上、開発ツールの一貫性確保といったメリットが得られ、開発基盤全体の改善に繋がりました。また、pnpmを導入してみて、Catalogs機能は便利だと感じています。Catalogsは、ワークスペース機能の1つで、依存関係のバージョン範囲を再利用可能な定数として定義できます。カタログで定義された定数は、個々のpackage.jsonファイルから参照できます。最近、Renovateでもサポートされ、依存関係の更新が快適になりました。*7

状態管理の移行

フロントエンドアプリケーションの状態管理は、リプレイスにおける重要な検討事項の1つです。私たちは、グローバルな状態管理ライブラリとして Valtio を採用しました。

Valtioを選定した最大の理由は、ReactだけでなくVanilla JSでも実行可能な点です。移行期間中は、リプレイス対象の既存コードのビジネスロジックからも一時的に状態を参照・変更する必要があり、段階的に移行を進める上でこのValtioの特性は大きなメリットでした。

パフォーマンスへの注意点

状態管理の移行時には、意図しない再レンダリングを引き起こし、パフォーマンス悪化に繋がる可能性があるため注意が必要です。Valtioの場合、コンポーネント内で状態を参照する際にはuseSnapshotフックを経由することが推奨されています。これにより、コンポーネントが実際に利用している状態のみにサブスクライブし、不要な再レンダリングを抑制できます。

ただし、コードベースにはまだクラスコンポーネントが残存しており、フックであるuseSnapshotを利用できない場面がありました。このようなケースでは、該当コンポーネントを関数コンポーネントへ移行するなどの対応を事前に検討する必要がありました。

グローバルステートの功罪

グローバルに参照・変更可能な状態は非常に便利ですが、その利用は慎重に行うべきです。大いなる力には大いなる責任が伴うという言葉の通り、安易なグローバルステートの利用は、コンポーネント間の依存関係を複雑にし、予期せぬ副作用を生む可能性があります。

実際に私たちのプロジェクトでも、単にReactコンポーネント間でイベントを伝搬させる目的でValtioのストアが利用されてしまうケースがありました。これは状態管理ライブラリの本来の用途とは異なり、コードの可読性やメンテナンス性を低下させる要因となります。

このようなケースに対しては、Valtioに依存しない汎用的なEventEmitterの仕組みをutilsパッケージに用意し、そちらを利用するように促すことで対応しました。状態管理とイベント伝搬という異なる関心事を適切に分離することが重要です。

品質を担保するテスト戦略

リプレイスプロジェクトにおいて、変更によるデグレを防ぎ、安全にリリースを継続することは重要です。特に、エムスリーデジカルは日々の診療に不可欠なツールであり、ユーザーからの高い信頼性が求められます。この要求に応えるため、複数のテスト戦略を組み合わせました。

VRT(Visual Regression Testing)の導入

デザインシステムの推進に伴い、共通コンポーネントへの変更がアプリケーション全体に予期せぬ視覚的な影響を与えるリスクが高まりました。これを検知するため、Playwrightを用いたVRTを導入しました。

VRTは、変更前後のスクリーンショットを比較し、意図しない見た目の変化がないかを確認するテスト手法です。エムスリーデジカルは、受付・カルテ画面に多くの機能が実装されており、派生するダイアログも多いため、ページ全体で1つのスナップショットを撮るのは現実的ではありませんでした。そこで、ある程度のコンポーネント単位でスナップショットを取得するアプローチを採用しました。

VRTの導入は、視覚的なデグレ防止だけでなく、副次的な効果として正常系の表示確認テストとしても機能しました。また、VRT環境の整備に伴い、MSW(Mock Service Worker)によるAPIモックや、StorybookベースのUIカタログページの導入も進みました。これにより、特定の状態におけるコンポーネントの表示や挙動を単体で確認しやすくなり、開発効率の向上にも繋がりました。

VRT導入の詳細については、過去に次の記事で紹介しています。

www.m3tech.blog

Vitestによるユニットテスト・結合テスト

ユニットテストの拡充のためVitestを採用しました。主に、ビジネスロジック、汎用的なユーティリティ関数、APIリクエスト用のデータ整形処理などがテスト対象です。

また、React Testing Libraryも導入しており、DatePickerのような内部状態やインタラクションが複雑なコンポーネントについては、一部結合テストも記述しています。

Vitestを採用した主な理由は、最終的にビルドツールをwebpackからViteへ移行する計画があったためです。Viteとの親和性が高く、設定を共通化できる点や、Jest互換のAPIによる学習コストの低さなどが魅力でした。In-Source Testingといった機能も有効活用していければと考えています。

QAチームによるテスト

上記に加えて、専門のQAチームによる手動テストや、自動テストツール(mabl)を用いたE2Eテストも実施しています。電子カルテは既存機能数が多く、医療ドメインに関する専門的な知識が必要になる場面もありますが、QAチームにはその分野に精通したメンバーも在籍しており、開発者によるテストだけではカバーしきれないシナリオや、実際のユーザー操作に近い形での品質担保に大きく貢献して頂き、いつも助けられています。

カナリアリリースの導入

エムスリーデジカルは全国7,000以上のクリニックで利用されており、リプレイスのような大規模な変更には慎重さが求められます。

そこで、リプレイスによる変更を安全にリリースするため、シャーディングを活用したカナリアリリース戦略を導入しました。幸いなことに、エムスリーデジカルはインフラレベルで、一定の医療機関数ごとにデータベースやアプリケーションサーバーを含む環境(シャード)が分割されていました。この既存の仕組みを利用し、フロントエンドリソースの配信もシャードごとに制御しています。具体的には、CloudFrontとS3 Bucketをシャードごとに用意し、デプロイ時に特定のシャード(カナリアシャード)にのみ新しいバージョンのフロントエンドリソースを配信しています。

実際のリリースフローは次の手順で進めました。

  1. ブランチ戦略: 通常の機能開発を行うdevelopブランチとは別に、リプレイス作業を集約するfront-developブランチを用意しました。
  2. カナリアリリース: 定期リリースの1週間前に、front-developブランチの内容をmainブランチにマージし、カナリアシャードに対してのみデプロイを実行します。
  3. developブランチへの取り込み: カナリアリリースで問題がないことを確認した後、mainブランチの内容をdevelopブランチに取り込みます。
  4. 全体リリース: 定期リリースのタイミングで、developブランチをmainブランチにマージし、全シャードに対してデプロイを実行します。

このアプローチを選択する上で、Feature Flagの利用も検討しました。しかし、リプレイス作業には段階的と言いつつ大規模な変更やライブラリのバージョンアップなど、単純なFlagによるコード分岐では対応が難しい変更が多く含まれていました。また、Flagがコードベースに残り続ける懸念もありました。

シャーディングを活用したカナリアリリースは、これらの課題に対する有効な手段となりました。もちろん、この運用にも課題はありました。特に、カナリアリリース後にmainブランチの内容をdevelopブランチへ取り込む際にコンフリクトが発生しやすく、その解消には注意が必要でした。また、通常の機能開発とリプレイス作業が並行して進む中で、同じ箇所への変更がそもそも衝突しないように管理する必要があり、プロジェクトマネジメントの難易度は高まりました。

総じて、このカナリアリリース戦略はプロジェクトメンバーの多くの努力によって支えられていましたが、影響範囲を限定しながら段階的に大きな変更をリリースできるという効果は非常に大きく、リプレイスプロジェクトを安全に進める上で不可欠なものでした。

将来的には、フロントエンドだけでなくバックエンドの変更にも同様のアプローチを適用したり、シャーディング構成に依存しない、より柔軟なカナリアリリース戦略を導入することも検討していきたいと考えています。

古いブラウザへの対応

フロントエンド開発において、古いブラウザバージョンのサポートは常に悩みの種なのではないでしょうか。エムスリーデジカルも例外ではなく、主要なターゲットブラウザであるChromeに加えてSafariへの対応も必要でした。特に、Safari 14以前のバージョンでは、利用できるCSS機能やJavaScript構文に制限があり、開発の足枷となる場面がありました。

実際に私たちが直面した問題には、次のようなものがあります。

  • CSSのinset, gapプロパティが利用できない。
  • クラスフィールド構文(例:state = {}, handleClick = () => {})を解釈できず、構文エラーが発生する。(これは一時的に @babel/plugin-proposal-class-properties でトランスパイルすることで回避しました。)

ライブラリのバージョンアップ等が影響して、突然、古いブラウザバージョンで機能が利用できなくなるのはユーザー体験としても良くなさそうです。また、古いバージョンのブラウザはセキュリティ上のリスクもありますので対応を促したいところです。そこで、サポートチームと協議し、Safariのサポート対象を最新2バージョンとすることを決定しました。しかし、決定後も実際には古いバージョンのSafariを利用しているユーザーが一定数存在しているという問題は残ります。

そこで、古いバージョンのSafariを利用しているユーザーに対して段階的に対応する仕組みを導入しました。

  1. 警告表示: まず、サポート対象外となる古いSafariバージョンでアクセスした場合に、ブラウザのアップデートを促す警告メッセージを表示するようにしました。
  2. 機能制限: 一定の告知期間を経た後、警告をダイアログ表示に変更し、最終的にはアプリケーションの利用を制限するようにしました。

この仕組み化により、古いブラウザバージョンへの対応コストを計画的に削減し、新しいCSS機能やJavaScript構文をより積極的に採用できるようになりました。これにより、開発体験が向上し、リプレイス作業全体の推進力にも繋がりました。

まとめ

本記事では、開発を止めない段階的フロントエンドリプレイスの実践シリーズの第2回として、技術的な側面に焦点を当て、移行を支えた具体的なアプローチを紹介しました。

デザインシステムの推進、型安全な開発環境への移行、モノレポ化、状態管理、テスト、カナリアリリース、など、多岐にわたる改善を行いました。これらの取り組みは、単に技術スタックを新しくするだけでなく、開発プロセス全体の効率化と品質向上を目指したものです。そして何より、継続する機能開発をより安全かつ効率的に進める上でも重要な基盤となっています。

リプレイスは、チームメンバーとその時点での最適な設計を考える良い機会であり、個人的にも多くの学びがありました。ここで紹介した各技術選択やアプローチが、同様の課題に直面している方々の参考になれば幸いです。個人的には、UIライブラリの移行やFlowからTypeScriptへの移行でのASTを活用したアプローチは、コードベースの規模が大きいほど効果を発揮すると感じており、おすすめです。

次回、組織編では、プロジェクト推進の裏側について触れます。

We are hiring!

私が所属するデジカルチームでは、クリニックの診療を支えるクラウド型電子カルテであるエムスリーデジカルを開発しています。 開発チームの紹介資料もありますので是非ご覧ください!

speakerdeck.com

また、エムスリーではエンジニアを絶賛募集中です。 興味を持って頂けた方、カジュアル面談や採用へのご応募をお待ちしています。

jobs.m3.com

*1:ただし、これらの問題は移行作業中に遭遇したものであり、最新バージョンでは既に解決されている可能性もあります。実際、この記事の執筆中に解決を確認できたIssueもありました。

*2:https://github.com/radix-ui/primitives/issues/1088

*3:https://github.com/radix-ui/primitives/issues/1027

*4:https://github.com/radix-ui/primitives/issues/1620 現在は解消されている可能性があります。

*5:https://github.com/radix-ui/primitives/issues/620

*6:フロントエンドの話とは逸れますがcommittee-railsは非推奨になったController specでは取り扱えないといった制約があったため、Request specへ移行するといったRSpecの改善もチームで実施しました。

*7:https://github.com/renovatebot/renovate/issues/30079