2020.05.13

LWCのリアクティブプロパティと再レンダリングの関係は複雑だった(第2回)

はじめに

みなさん、こんにちは。
今回は、テラスカイ亀山田伏が実際の開発で直面した事例をもとに調査した「LWCのリアクティブプロパティと再レンダリングの複雑な関係」の第2回となります。
みなさん、第1回については読んでいただけたでしょうか?
第1回:事例その1、どうしたら値は変更されたとみなされるのか?
第2回:事例その2、値が変更されても再レンダリングされないこともある?
第3回:複雑な関係をまとめてみた、これで使いこなせるのか?
突然ですが、ここでみなさんにお知らせがあります。
実はリアクティブプロパティについて、Spring'20 で少し仕様が変わっています。
Spring '20 よりも前は、項目を @track でデコレートする必要がありました。
つまり、@track でデコレートしていないプロパティの値が変更された場合でも、再レンダリングされるようになったということです。
画面に値が表示されない!ちゃんとセットしてるのに、なぜ?!と、さんざん調べて、結局は、単なるデコレータの記載忘れだった、、、こんな体験をしたことがある方にとっては朗報ではないでしょうか。
私にとっては、今回の記事について当初考えていた内容から見直しが必要になるという悪い面もありましたが(笑)

仕様変更についてもう少し

プロパティの値が変更された場合に再レンダリングされるようになりましたが、インスタンスの内部の変更については反応しません。そのようなケースにおいては、依然として @track デコレータは必要となりますので、ご注意ください。
項目にオブジェクトまたは配列が含まれる場合、追跡される変更の深度には制限があります。オブジェクトのプロパティまたは配列の要素の変更を監視するようにフレームワークに指示するには、項目を @track でデコレートします。

本題に入る前に

今回、実際にLWCの開発の中で直面した事例を紹介するにあたり、説明の都合上、事象を再現できる簡単な事例に置き換えました。
そのため、実際に開発している方には共感できないような事例になっているかもしれませんが、ご容赦ください。

また、今回ご紹介しているコードについては、Lightning Web Components Playgroundで動作させることが可能なものとなっていますので、実際の動作を確認しながら読み進めていただくとイメージがしやすいと思います。
Lightning Web Components Playgroundはこちら
PlaygroundのDeveloperGuidはこちら

参考記事

もし、LWCについての前提知識に不安がある方は、基本的なことを記載した過去の記事もありますので、参考にしてください。
Lightning Web ComponentsとHappyになれる方法(基礎編)

~事例その2、値が変更されても再レンダリングされないこともある?~

今回は、西暦を入力する画面に対して、干支を表示するコンポーネントを追加するという事例を元に、調査結果を紹介していきます。

現状を確認してみましょう

<template>
    <div class="app slds-p-around_x-large">
        <lightning-layout vertical-align="end" pull-to-boundary="small">
            <lightning-layout-item padding="around-small">
                <lightning-input data-input="year" label="西暦" pattern="[1-9][0-9]*"></lightning-input>
            </lightning-layout-item>
            <lightning-layout-item padding="around-small">
                <lightning-button label="干支表示" variant="brand" onclick={handleClick}></lightning-button>
            </lightning-layout-item>
        </lightning-layout>
    </div>
</template>
app.html
import { LightningElement } from 'lwc';

export default class App extends LightningElement {
    year = '';

    handleClick() {
        const element = this.template.querySelector('[data-input=year]');
        this.year = element.value;
    }
}
app.js
「干支表示」ボタンのイベントハンドラには、西暦の入力値を取得して、app.jsの「year」プロパティに設定する処理が実装されている状態です。

干支を表示するコンポーネントを作成してみましょう

<template>
    <div class="slds-m-around_small">
        干支:{eto}
    </div>
</template>
child.html
import { LightningElement, api } from 'lwc';

const etoDef = ['さる', 'とり', 'いぬ', 'いのしし', 'ねずみ', 'うし', 'とら', 'うさぎ', 'たつ', 'へび', 'うま', 'ひつじ'];

export default class Child extends LightningElement {
    @api
    year = '';

    eto = '';

    renderedCallback() {
        console.debug('renderedCallback');
        this.eto = this.convertYearToEto(this.year);
    }

    convertYearToEto(year) {
        return year ? etoDef[Number(year) % 12] : '';
   }
}
child.js
child.html には、干支が格納されるプロパティ「eto」をバインドしています。
child.js には、西暦を受け取る公開プロパティ「year」と、干支を格納するプロパティ「eto」、そして、西暦を干支に変換するための convertYearToEto(year) 関数を定義しました。
そして、convertYearToEto(year) 関数を使用して「eto」プロパティに値を設定する処理を renderedCallback() に実装してみました。

なぜ、renderedCallback() メソッドに実装してみたのか

公開プロパティはリアクティブであるため、「year」の値が変更されると、コンポーネントは再レンダリングされます。一方、renderedCallback() は、コンポーネントのレンダリング後に実行されるライフサイクルフックになります。
従って、
「year」の値変更 → 再レンダリング → renderedCallback() で「eto」を設定 → 再レンダリング
のような動作をすると想定したわけです。

appコンポーネントに追加して試してみましょう

<template>
    <div class="app slds-p-around_x-large">
        <lightning-layout vertical-align="end" pull-to-boundary="small">
            <lightning-layout-item padding="around-small">
                <lightning-input data-input="year" label="西暦" pattern="[1-9][0-9]*"></lightning-input>
            </lightning-layout-item>
            <lightning-layout-item padding="around-small">
                <lightning-button label="干支表示" variant="brand" onclick={handleClick}></lightning-button>
            </lightning-layout-item>
        </lightning-layout>
        <!-- 追加 始 -->
        <c-child year={year}></c-child>
        <!-- 追加 終 -->
    </div>
</template>
app.html
画面が表示された時点で、child.js の renderedCallback() メソッドに実装したデバッグログにより、ログが1行表示されていると思います。
これは、初期表示後に renderedCallback() メソッド が実行されたためです。

では、西暦に「2020」と入力して、「干支表示」ボタンを押してみてください。

結果はどうでしたか?
話の流れから何となく想像がついていることかと思いますが、干支は表示されなかったですよね。

なぜなのでしょうか?

結論としては、リアクティブプロパティの値が変更されても、HTMLにバインドしていない場合は、再レンダリングされないことが原因でした。

ログを確認してみると、ボタンを押した後で renderedCallback() メソッドが実行されていないことがわかります。

確認してみましょう

HTMLへバインドしていれば再レンダリングされることを確認するために、child.js の「year」プロパティをHTMLに追加してみましょう。
<template>
    <div class="slds-m-around_small">
        干支:{eto}
    </div>
    <!-- 追加 始 -->
    {year}
    <!-- 追加 終 -->
</template>
child.html
先ほどと同様に、西暦に「2020」と入力して「干支表示」ボタンを押してみましょう。
今度は、「ねずみ」と表示されたはずです。
HTMLにバインドしていれば、「year」プロパティが変更された際に再レンダリングされるということが分かりました。

さて、ここでログを確認してみると、ボタンを押した後に、renderedCallback() メソッドが2回実行されています。

renderedCallback() メソッドが2回実行されたのは

「year」の値変更 → 再レンダリング → renderedCallback() で「eto」を設定 → 再レンダリング
という動作を想定していましたが、再レンダリング処理の後には renderedCallback() が実行されることを忘れてはいけません。
実際は、
「year」の値変更 → 再レンダリング → renderedCallback() で「eto」を設定 → 再レンダリング → renderedCallback() で「eto」を設定 → 「eto」の値に変更がないため再レンダリングされず、処理終了
という動作になります。

2回目の renderedCallback() メソッドの処理で「eto」プロパティに設定される値が変わらないため、処理が止まりましたが、もし、「eto」プロパティに設定される値が毎回異なるような実装であった場合、無限ループが発生することになります。renderedCallback() メソッド内で、プロパティの値を変更するような処理は記載しない方が良いでしょう。
このことは、Developer Guide にも記載されています。
renderedCallback() 内のリアクティブプロパティまたは項目を更新しないでください。無限ループが発生する可能性があります。
ここまでの内容で、公開プロパティの値が変更されても、HTMLにバインドされていないと再レンダリングされないことが分かりました。これは、今回のタイトルにもなっている事象ではありますが、HTMLにバインドしていない値が変更された際に再レンダリングされないこと自体は基本的に問題ではありません。(画面に表示しない項目の値が変わったとしても再表示する必要はありませんよね。)

それよりも、今回のケースのように画面に表示している項目が、画面にバインドしていない公開プロパティの値の影響を受ける場合に、どのような実装をすれば再レンダリングされるのかが重要になります。

次に試したのは

「year」プロパティのセッター内で「eto」プロパティに値を設定する実装を試してみました。

先ほど child.html に「year」プロパティをバインドした修正は元に戻してください。
そして、「year」プロパティのセッター/ゲッターを用意し、セッター内で、「eto」プロパティの値を設定するようにします。
また、renderedCallback() メソッド内の「eto」プロパティの設定処理は不要となるためコメント化します。
import { LightningElement, api, track } from 'lwc';

const etoDef = ['さる', 'とり', 'いぬ', 'いのしし', 'ねずみ', 'うし', 'とら', 'うさぎ', 'たつ', 'へび', 'うま', 'ひつじ'];

export default class Child extends LightningElement {
    /** 修正 始 */
    @api
    set year(value) {
        this._year = value;
        this.eto = this.convertYearToEto(this._year);
    }
    get year() {
        return this._year;
    }
    _year;
    /** 修正 終 */

    eto = '';

    renderedCallback() {
        /** 修正 始 */
        // this.eto = this.convertYearToEto(this.year);
        /** 修正 終 */
        console.debug('renderedCallback');
    }

    convertYearToEto(year) {
        return year ? etoDef[Number(year) % 12] : '';
   }
}
child.js

干支は表示されましたか?

おそらく想定通りの動きになったと思います。

もっと良い実装方法はないのでしょうか

先ほどの事例では、受け取る値と、設定する値が1対1になっていたため、「year」プロパティのセッター内で直接「eto」プロパティに設定する処理を実装してもさほど違和感はなかったかも知れません。

もし、複数の値を受け取って、1つの値に設定するようなケースの場合はどうでしょうか?
例えば、「定価」、「割引率(%)」、「消費税(%)」の3つの値を受け取り、画面には「金額」を表示するようなケースを考えてみます。

複数の値を受け取る際に、セッターの実行順序は保証されません。
そのため、セッター内で「金額」を設定する処理を実装するとすれば、「定価」、「割引率(%)」、「消費税(%)」のセッターそれぞれに「金額」を設定する処理が必要になります。
「定価」、「割引率(%)」、「消費税(%)」の順でセッターが実行された場合であれば、「消費税(%)」のセッターで実施された「金額」設定処理で正しい値が設定されることになりますが、途中である「定価」、「割引率(%)」のセッターが動作した際には「金額」に中途半端な状態での値が設定されていることとなります。

なんとなく気持ちわるいと感じませんか?

実は、もっと良い実装方法があるんです。

もっと良い実装方法とは

公開プロパティについてセッター/ゲッターを用意するのではなく、画面に表示するプロパティをゲッターにして、ゲッター内で必要な公開プロパティ値を参照するだけで実現できます。

干支を表示するコンポーネントの例に戻ります。
最後に修正した「year」プロパティのセッター/ゲッターを元に戻し、「eto」プロパティをゲッターのみに修正します。
※ renderedCallback() メソッド内の「eto」プロパティの設定処理のコメントはそのままです。
import { LightningElement, api, track } from 'lwc';

const etoDef = ['さる', 'とり', 'いぬ', 'いのしし', 'ねずみ', 'うし', 'とら', 'うさぎ', 'たつ', 'へび', 'うま', 'ひつじ'];

export default class Child extends LightningElement {
    @api
    year;

    /** 修正 始 */
    get eto() {
        return this.convertYearToEto(this.year);
    }
    /** 修正 終 */

    renderedCallback() {
        // this.eto = this.convertYearToEto(this.year);
        console.debug('renderedCallback');
    }

    convertYearToEto(year) {
        return year ? etoDef[Number(year) % 12] : '';
   }
}
child.js
今度の実装においても、干支が表示されることが確認できたでしょうか。

実はこの動作については、Developer Guide にも記載されています。
項目の値が変更され、その項目がテンプレートで使用されているか、テンプレートで使用されているプロパティの getter で使用されている場合、コンポーネントは再表示され、新しい値を表示します。
ここでの項目とは、プライベートプロパティのことですが、同様に公開プロパティについても画面で使用されているプロパティの getter で使用されている場合には、値が変更されると再レンダリングが行われます。
この方法で、先ほどの複数の値を受け取るケースについて実装したコードも参考までに記載しておきます。
<template>
    <div class="app slds-p-around_x-large">
        <lightning-layout>
            <lightning-layout-item size="3">
                <lightning-input data-input="price" type='number' label='定価'></lightning-input>
            </lightning-layout-item>
        </lightning-layout>
        <lightning-slider data-input="discountRate" label="割引率(%)" min="0" max="90" step="5" value={discountRate}></lightning-slider>
        <lightning-slider data-input="taxRate" label="消費税(%)" min="0" max="15" step="1" value={taxRate}></lightning-slider>
        <lightning-button label="金額表示" variant="brand" onclick={handleClick}></lightning-button>
        <div class="slds-p-vertical_small">
            <c-child price={price} discount-rate={discountRate} tax-rate={taxRate}></c-child>
        </div>
    </div>
</template>
app.html
import { LightningElement, track } from 'lwc';

export default class App extends LightningElement {
    @track
    price = 0;
    @track
    discountRate = 0;
    @track
    taxRate = 10;

    handleClick() {
        this.price = Number(this.template.querySelector('[data-input=price]').value);
        this.discountRate = Number(this.template.querySelector('[data-input=discountRate]').value);
        this.taxRate = Number(this.template.querySelector('[data-input=taxRate]').value);
    }
}
app.js
<template>
    <div class="slds-hidden">{updTime}</div>
    <div>
        金額:{totalPrice}
    </div>
</template>
child.html
import { LightningElement, api } from 'lwc';

export default class Child extends LightningElement {
    @api
    price = 0;

    @api
    discountRate = 0;

    @api
    taxRate = 0;

    get totalPrice() {
        return this.price * (100 - this.discountRate) /100 * (100 + this.taxRate) /100;
    }
}
child.js
「各セッター内で金額を設定する」という実装方法よりも、「金額のゲッター内で必要な値を参照して値を決定する」という実装方法の方が、本来の役割と処理内容が一致しており、違和感なく少しスッキリしたと感じられるのではないでしょうか。

おわりに

いかがでしたか?

今回も最後までお読みいただき、ありがとうございました。

次回は、
第3回:複雑な関係をまとめてみた、これで使いこなせるのか?
を予定しています!
71 件
     
  • banner
  • banner

関連する記事