Lightning レコードページにLWCを詰め込みすぎた開発者の後悔

A. lightning record pages で実際にやらかした判断

あるプロジェクトで、Lightning Record Pages(LRP)にLWCをどんどん配置していく判断をしました。当時は、各LWCが独立した機能を提供し、それぞれが自身のデータを取得・表示・更新する「疎結合なマイクロサービス的アプローチ」が素晴らしいと考えていたのです。

特に、商談オブジェクトのレコードページで顕著でした。営業担当者からは「顧客情報」「競合情報」「関連プロダクトの一覧」「過去の商談履歴」「契約関連ドキュメント」など、様々な情報を一画面で参照・編集したいという要望が山のように上がっていました。

私達開発者は、それらの要望を個別のLWCとして実装し、LRP上の異なるセクションやタブに配置していきました。例えば、「競合情報」はカスタムオブジェクトの関連リストをよりリッチに表示するLWC、「契約関連ドキュメント」は外部ストレージとの連携を見越したLWC、といった具合です。

複数のLWCがページを重くした

当初の想定は「必要なLWCだけを配置すれば、その分の処理だけが走る」というものでした。しかし、蓋を開けてみれば、多くのLWCがそれぞれwireサービスを使って独自のApexメソッドを呼び出し、必要なデータを取得していました。

結果として、一つの商談レコードページをロードする際に、裏側では同時に10個以上のApexメソッドが走り、それぞれがSOQLクエリを発行し、Governor Limitsの制約と戦いながらデータを取得していました。ページのロード時間はみるみるうちに悪化し、時には10秒を超えることもありました。開発環境やテスト環境ではデータ量が少ないため気づきにくかったのですが、本番環境に移行して大量のデータが入ると、顕在化したのです。

当時の私は、「それぞれのコンポーネントが自律的に動くべき」という思想に囚われすぎていました。LWCがレコードページに配置されると、そのページがロードされるたびにすべてのLWCが初期化され、connectedCallbackwireメソッドが発動することを軽視していました。

コンポーネント間のデータ連携が複雑に

さらに問題になったのは、異なるLWC間で同じレコードのデータを参照したり、更新したりするケースです。例えば、一つのLWCで商談の特定の項目を更新した後、別のLWCがその更新された値を即座に反映して表示する必要がありました。

最初は、親コンポーネントから子コンポーネントへデータを渡す(@apiプロパティ)、または子コンポーネントから親へイベントを上げる(カスタムイベント)という基本的なパターンで対応しようとしました。しかし、LRP上に横並びに配置されたLWCは、互いに「親」でも「子」でもない関係です。

そこで、Lightning Message Service (LMS) を導入しました。LMSは「Publish-Subscribe」モデルで、特定のチャネルにメッセージをPublishし、SubscribeしているLWCがそのメッセージを受け取って処理を行うというものです。これにより、LWC間の疎結合なデータ連携が可能になりました。

// subscriberLwc.js
import { subscribe, unsubscribe, MessageContext } from 'lightning/messageService';
import RECORD_UPDATE_CHANNEL from '@salesforce/messageChannel/RecordUpdateChannel__c'; // Custom Message Channel

export default class SubscriberLwc extends LightningElement {
    @wire(MessageContext)
    messageContext;
    subscription = null;
    updatedValue;

    connectedCallback() {
        this.subscribeToMessageChannel();
    }

    subscribeToMessageChannel() {
        if (!this.subscription) {
            this.subscription = subscribe(
                this.messageContext,
                RECORD_UPDATE_CHANNEL,
                (message) => this.handleMessage(message)
            );
        }
    }

    handleMessage(message) {
        if (message.recordId === this.recordId) { // 特定のレコードの更新のみ反応
            this.updatedValue = message.newValue;
            // UIの更新など
        }
    }

    disconnectedCallback() {
        this.unsubscribeToMessageChannel();
    }

    unsubscribeToMessageChannel() {
        unsubscribe(this.subscription);
        this.subscription = null;
    }
}

このLMSの導入自体は成功でしたが、多数のLWCがそれぞれ異なるチャネルをPublish/Subscribeするようになると、今度は「どのLWCが」「どのタイミングで」「何を」Publishし、それを「どのLWCが」Subscribeして「どう処理するのか」という全体の流れを把握するのが非常に困難になりました。デバッグ時も、イベントの発生源を特定するのに苦労しました。

当時は、LMSを使いこなせばどんな複雑な連携でも解決できると信じていました。しかし、実際には、コンポーネント間の依存関係が物理的に見えにくくなり、むしろ保守性が低下してしまったのです。

今なら別の選択をする

もし今同じ状況に直面したら、まずLRPに配置するLWCの数を極限まで減らす努力をします。特に、同じオブジェクトのデータを参照・更新するLWCが複数ある場合は、それらを統合した「単一の親LWC」を作成することを検討します。

この親LWCが、必要なすべてのデータを一度にApex経由で取得し、そのデータを子LWC(これも親LWC内部にネストされる)に渡す形にするでしょう。子LWCは自身のデータ取得ロジックを持たず、親から渡されたプロパティのみで動作する「dumb component」に徹します。

これにより、Apexコールを最小限に抑え、LRPのロードパフォーマンスを劇的に改善できるはずです。LMSのようなイベント駆動型アプローチは、異なるオブジェクト間や完全に独立した機能間で、本当に疎結合が求められる場合にのみ限定的に利用するでしょう。

あの時、「LWCでなんでもできる」という開発者の全能感に酔っていた部分があったと反省しています。レコードページはあくまでレコード情報を表示する場であり、そこに高度なアプリケーションロジックや多数のデータ取得処理を分散させすぎると、全体のパフォーマンスと保守性が損なわれるという学びを得ました。これは当時の自分向けのメモだ。

コメント