Salesforce LWC學習(二十三) Lightning Message Service 淺談

zero.zhang發表於2020-09-05

本篇參考:

https://trailhead.salesforce.com/content/learn/superbadges/superbadge_lwc_specialist

https://developer.salesforce.com/docs/component-library/documentation/en/lwc/lwc.use_message_channel

https://developer.salesforce.com/docs/component-library/bundle/lightning-message-service/documentation

講這個以前先以一個例子作為展開。lwc的 superbadge中有一個功能為 左側 Gallery列表中點選一個圖片,在右側 details會展示這個船的詳細資訊。

 以往我們可能想著,簡單,將這兩部分組成到同一個父元件中,Gallery中的某個item點選以後,傳遞一個事件到父,父進行handler處理以後,將record id 傳遞給右側的元件,右側元件這個reRender一下就搞定了。

先說一下上面的分析能不能實現? 能,而且肯定能。因為事件的傳播以後,父元件是肯定可以監聽到,監聽到處理到,變數繫結到其他的子可以實現。那這樣好不好呢?

老實地說,好與不好不清楚,因為專案上更關注三點:

1. 穩定性

2. 效能

3. 可擴充套件性

這種方式這三點應該都沒有太大的問題,問題在於當需求變動以後,父 元件裡的邏輯將可能會越來越多。父元件我們的初衷可能是套個殼子,讓他們有親戚關係進行簡單的資訊交換,結果爸爸需求可能越來越多最後可能承受不能承受之重。

有沒有其他的方式去實現即使兩個元件沒有關係,但是也可以做到資訊之間的傳遞呢?今天的 Lightning Message Service便可以實現這個需求。

一. Lightning Message Service

Lightning Message Service用於在 VF Page, Aura Component, lwc之間進行跨DOM 通訊。可以在單一的 lightning page或者是多個page之間進行通訊。操作的步驟為釋出訂閱原則。聽到釋出訂閱,大家可能想到 Streaming API 或者是 Platform Event, salesforce針對不同的通訊場景有多種的廣播訂閱模型進行選擇,頁面之間的跨DOM通訊使用 Lightning Message Service。值得注意的是,在 spring 20的時候這個功能還是一個 beta版本,在現在的 summer20已經是一個正式的功能,所以可以放心使用。 Lightning Message Service的特別的細節的介紹以及limitation還請參看上面的連結,接下來講一下具體的使用步驟。

1. 建立 Message Channel

我們在vs code專案的目錄中檢視是否有messageChannels這個目錄,如果不包含就手動建立一下。新建一個以messageChannel-meta.xml 結尾的檔案即可。篇中demo建立的是 BoatMessageChannel.messageChannel-meta.xml。進行相關的資訊填充以後儲存到環境即可。如果曾經有建立過,需要從sandbox或者developer環境匯入下來,執行 sfdx force:source:retrieve -m LightningMessageChannel即可retrieve下來。

 那這個xml應該如何寫呢?這個時候就需要看 salesforce的 metadata api關於 LightningMessageChannel的介紹:https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_lightningmessagechannel.htm

lightning message channel包括以下的幾部分構成:

  • description:lightning message channel的描述資訊;
  • isExposed:標籤指定當前的 lightning message channel是否暴露出來,類似我們做 lwc的metadata xml的配置;
  • masterLabel: lightning message channel的label名稱,這個屬性是一個必填欄位;
  • lightningMessageFields:這個是 lightning message channel的核心屬性,通過這個負載欄位用來宣告廣播訂閱接收的變數資訊。針對這個屬性有兩個子屬性。description用來描述 lightningMessageField的描述資訊,fieldName用來描述當前 lightningMessageField要傳播的欄位的api name。

下面的xml是BoatMessageChannel的全的資訊,以便更好的瞭解 lightning message channel。sample中我們宣告瞭一個fieldName為recordId的名稱是 BoatMessageChannel的 lightning message channel資訊。如果需要傳遞多個變數,只需要多寫幾個 <lightningMessageFields>即可。

<?xml version="1.0" encoding="UTF-8"?>
<LightningMessageChannel xmlns="http://soap.sforce.com/2006/04/metadata">
    <description>This is a sample Lightning Message Channel for the Lightning Web Components Superbadge.</description>
    <isExposed>true</isExposed>
    <lightningMessageFields>
        <description>This is the record Id that changed</description>
        <fieldName>recordId</fieldName>
    </lightningMessageFields>
    <masterLabel>BoatMessageChannel</masterLabel>
</LightningMessageChannel>

2. 定義Lightning Message Service的作用域

廣播訂閱機制一個另外的重要的事情就是作用域的問題,即哪種情況訂閱者可以訂閱到廣播源傳送的訊息。是整個應用級別,還是某些active區域。如果我們在lwc元件間進行廣播訂閱時,一定要寫上@wire(MessageContext)去讓scope特性可用。下圖為訂閱的scope的模型。salesforce預設的訂閱模型的scope範圍是active的,如果我們希望訂閱範圍擴大,需要lwc component頭部引入APPLICATION_SCOPE,這個是在 ‘lightning/messageService’中。專案中沒有要求指定哪種scope,如果有要求即使在Hidden的tab中也可以接收到相關的訂閱訊息並進行什麼處理,可以設定成整個應用級別,篇中demo設定的即應用級別。

 3. 廣播一個message Channel

https://developer.salesforce.com/docs/component-library/documentation/en/lwc/lwc.reference_salesforce_modules

我們在廣播或者訂閱以前都需要先引入我們建立的 message channel,使用 @salesforce/messageChannel進行引用,如果是包裡的內容,需要新增namespace資訊,如果不是包裡的,直接使用channelReference即可。channelReference需要以 __c結尾,這個是強制的要求。

import channelName from '@salesforce/messageChannel/namespace__channelReference';

所以我們篇中的demo中的 messageChannel名稱為BoatMessageChannel,所以引入資訊如下,其中 BOATMC的名字任意起,messageChannel以__c結尾。

import BOATMC from '@salesforce/messageChannel/BoatMessageChannel__c';

引入以後我們進行釋出操作,lightning/messageService包含了 publish方法,我們在釋出以前也需要在頭部以前引入,因為lwc需要強制使用 MessageContext讓scope可用,這裡也一併引入 MessageContext

import { publish, MessageContext } from 'lightning/messageService';

上面的準備工作完成以後,只需要呼叫publish方法即可實現釋出。publish有以下的幾個引數需要傳遞。

  • messageContext: messageContext,這裡預設填寫我們使用wire方法獲取宣告的變數即可。
  • messageChannel:頭部宣告的 messageChannel的引用變數;
  • message:一個序列化的JSON object資訊,根據messageChannel的欄位設定去填充這個欄位即可。

下面有一個簡單的publish程式碼塊進行更好的瞭解。如果我們在 BOATMC中宣告瞭兩個變數,一個是 recordId,一個是recordData,則我們的 publish方法包含這三部分即可。

@wire(MessageContext)
    messageContext;
          
    handleClick() {
        const message = {
            recordId: '001xx000003NGSFAA4',
            recordData: {accountName: 'Burlington Textiles Corp of America'}
        };
        publish(this.messageContext, BOATMC, message);
    }

4. 訂閱 messageChannel

廣播源廣播出去一個以後,訂閱的component如何訂閱呢?如果小夥伴先仔細檢視上面的連線以後發現在 lightning/messageService裡面同樣封裝了一個用來訂閱的方法:subscribe,如果想要取消訂閱,只需要呼叫unsubscribe()即可。

同樣的操作,第一個步驟,需要先引入 MessageContext,這裡不做重複的描述。直接描述一下 subscribe方法,裡面有4個引數。

  • messageContext:描述同上;
  • messageChannel:描述同上;
  • listener:一個函式用來當釋出以後處理message用;
  • subscriberOptions:這個是一個可選操作,當我們指定從APPLICATION級別接收訊息情況下,設定成{scope: APPLICATION_SCOPE},如果APPLICATION級別,頭部需要從lightning/messageService引入APPLICATION_SCOPE。

APPLICATION_SCOPE級別的sample,用於當訂閱以後,呼叫 handleMessage去處理具體訂閱邏輯, message.fieldName即可取出相關的值,比如message.recordId即可以取出 釋出時 recordId這個變數對應的值。

this.subscription = subscribe(
        this.messageContext, 
        BOATMC, 
        (message) => {
            this.handleMessage(message);
        }, 
        { scope: APPLICATION_SCOPE }
);

active級別的sample:只需要將最後一個引數 scope資訊刪除即可。

this.subscription = subscribe(
        this.messageContext, 
        BOATMC, 
        (message) => {
            this.handleMessage(message);
        }
);

unsubscription這裡不在介紹,看一下上面的文件以及引數介紹大家便可以進行正常的學習。

二. 程式碼實現

我們在第一部分已經介紹了 Lightning Message Service的基礎知識以及方法的使用,下面的內容通過demo可以更好的去學習以及理解。

1. 建立 Message Channel,這裡步驟同上面,建立了一個BoatMessageChannel 的 Message Channel,設定了一個 recordId的變數

2. 廣播操作

BoatDataService.cls:這裡對程式碼進行了刪減,只保留了這次demo用到的方法,通過 getBoats獲取船的資料列表資訊3.

public with sharing class BoatDataService {

    public static final String LENGTH_TYPE = 'Length'; 
    public static final String PRICE_TYPE = 'Price'; 
    public static final String TYPE_TYPE = 'Type'; 
    @AuraEnabled(cacheable=true)
    public static List<Boat__c> getBoats(String boatTypeId) {
        // Without an explicit boatTypeId, the full list is desired
        String query = 'SELECT '
                     + 'Name, Description__c, Geolocation__Latitude__s, '
                     + 'Geolocation__Longitude__s, Picture__c, Contact__r.Name, '
                     + 'BoatType__c, BoatType__r.Name, Length__c, Price__c '
                     + 'FROM Boat__c';
        if (String.isNotBlank(boatTypeId)) {
            query += ' WHERE BoatType__c = :boatTypeId';
        }
        query += ' WITH SECURITY_ENFORCED ';
        return Database.query(query);
    }

    @AuraEnabled(cacheable=true)
    public static List<Boat__c> getSimilarBoats(Id boatId, String similarBy) {
        List<Boat__c> similarBoats = new List<Boat__c>();
        List<Boat__c> parentBoat = [SELECT Id, Length__c, Price__c, BoatType__c, BoatType__r.Name
                                    FROM Boat__c
                                    WHERE Id = :boatId 
                                    WITH SECURITY_ENFORCED];
        if (parentBoat.isEmpty()) {
            return similarBoats;
        }
        if (similarBy == LENGTH_TYPE) {
            similarBoats = [
                SELECT Id, Contact__r.Name, Name, BoatType__c, BoatType__r.Name, Length__c, Picture__c, Price__c, Year_Built__c
                FROM Boat__c
                WHERE Id != :parentBoat.get(0).Id
                AND (Length__c >= :parentBoat.get(0).Length__c / 1.2)
                AND (Length__c <= :parentBoat.get(0).Length__c * 1.2)
                WITH SECURITY_ENFORCED
                ORDER BY Length__c, Price__c, Year_Built__c
            ];
        } else if (similarBy == PRICE_TYPE) {
            similarBoats = [
                SELECT Id, Contact__r.Name, Name, BoatType__c, BoatType__r.Name, Length__c, Picture__c, Price__c, Year_Built__c
                FROM Boat__c
                WHERE Id != :parentBoat.get(0).Id
                AND (Price__c >= :parentBoat.get(0).Price__c / 1.2)
                AND (Price__c <= :parentBoat.get(0).Price__c * 1.2)
                WITH SECURITY_ENFORCED
                ORDER BY Price__c, Length__c, Year_Built__c
            ];
        } else if (similarBy == TYPE_TYPE) {
            similarBoats = [
                SELECT Id, Contact__r.Name, Name, BoatType__c, BoatType__r.Name, Length__c, Picture__c, Price__c, Year_Built__c
                FROM Boat__c
                WHERE Id != :parentBoat.get(0).Id
                AND (BoatType__c = :parentBoat.get(0).BoatType__c)
                WITH SECURITY_ENFORCED
                ORDER BY Price__c, Length__c, Year_Built__c
            ];
        }
        return similarBoats;
    }

}

boatSearchResults.html:使用 layout方式展示一些資料

<template>
    <lightning-tabset variant="scoped">
        <lightning-tab label="Gallery">
            <template if:true={boats.data}>
                <div class="slds-scrollable_y">
                    <lightning-layout horizontal-align="center" multiple-rows>
                        <template for:each={boats.data} for:item="boat">
                            <lightning-layout-item key={boat.Id} padding="around-small" size="12" small-device-size="6"
                                medium-device-size="4" large-device-size="3">
                                <c-boat-tile boat={boat} selected-boat-id={selectedBoatId}
                                    onboatselect={updateSelectedTile}></c-boat-tile>
                            </lightning-layout-item>
                        </template>
                    </lightning-layout>
                </div>
            </template>
        </lightning-tab>
    </lightning-tabset>
</template>

boatSearchResults.js:這裡我們可以看到,首先頭部引入了 MessageChannel 以及使用了 MessageContext以及 publish方法,下面的方法中使用了 publish方法去進行了廣播操作

import { LightningElement, wire, api, track } from 'lwc';
import getBoats from '@salesforce/apex/BoatDataService.getBoats';
import { publish, MessageContext } from 'lightning/messageService';
import BoatMC from '@salesforce/messageChannel/BoatMessageChannel__c';

export default class BoatSearchResults extends LightningElement {
    boatTypeId = '';
    @track boats;
    @track draftValues = [];
    selectedBoatId = '';
    isLoading = false;
    error = undefined;
    wiredBoatsResult;

    @wire(MessageContext) messageContext;

    columns = [
        { label: 'Name', fieldName: 'Name', type: 'text', editable: 'true'  },
        { label: 'Length', fieldName: 'Length__c', type: 'number', editable: 'true' },
        { label: 'Price', fieldName: 'Price__c', type: 'currency', editable: 'true' },
        { label: 'Description', fieldName: 'Description__c', type: 'text', editable: 'true' }
    ];

    @api
    searchBoats(boatTypeId) {
        this.isLoading = true;
        this.notifyLoading(this.isLoading);
        this.boatTypeId = boatTypeId;
    }

    @wire(getBoats, { boatTypeId: '$boatTypeId' })
    wiredBoats(result) {
        this.boats = result;
        if (result.error) {
            this.error = result.error;
            this.boats = undefined;
        }
        this.isLoading = false;
        this.notifyLoading(this.isLoading);
    }

    updateSelectedTile(event) {
        this.selectedBoatId = event.detail.boatId;
        this.sendMessageService(this.selectedBoatId);
    }

    notifyLoading(isLoading) {
        if (isLoading) {
            this.dispatchEvent(new CustomEvent('loading'));
        } else {
            this.dispatchEvent(CustomEvent('doneloading'));
        }
    }

     sendMessageService(boatId) { 
        publish(this.messageContext, BoatMC, { recordId : boatId });
    }
}

boatTile.html:展示gallery中的每一個item的UI

<template>
    <div onclick={selectBoat} class={tileClass}>
        <div style={backgroundStyle} class="tile"></div>
        <div class="lower-third">
            <h1 class="slds-truncate slds-text-heading_medium">{boat.Name}</h1>
            <h2 class="slds-truncate slds-text-heading_small">{boat.Contact__r.Name}</h2>
            <div class="slds-text-body_small">
                Price: <lightning-formatted-number maximum-fraction-digits="2" format-style="currency" currency-code="USD" value={boat.Price__c}> </lightning-formatted-number>
            </div>
            <div class="slds-text-body_small"> Length: {boat.Length__c} </div>
            <div class="slds-text-body_small"> Type: {boat.BoatType__r.Name} </div>
        </div>
    </div>
</template>

boatTile.js:item點選以後排程事件,boatSearchResults這個父元件 handle事件,從而實現了廣播的釋出

import { LightningElement, api} from "lwc";
const TILE_WRAPPER_SELECTED_CLASS = "tile-wrapper selected";
const TILE_WRAPPER_UNSELECTED_CLASS = "tile-wrapper";
export default class BoatTile extends LightningElement {
    @api boat;
    @api selectedBoatId;
    get backgroundStyle() {
        return `background-image:url(${this.boat.Picture__c})`;
    }
    get tileClass() {
        return this.selectedBoatId == this.boat.Id ? TILE_WRAPPER_SELECTED_CLASS : TILE_WRAPPER_UNSELECTED_CLASS;
    }
    selectBoat() {
        this.selectedBoatId = !this.selectedBoatId;
        const boatselect = new CustomEvent("boatselect", {
            detail: {
                boatId: this.boat.Id
            }
        });
        this.dispatchEvent(boatselect);
    }
}

3. 訊息訂閱

boatDetailTabs.html:用來展示釋出過來的指定的記錄的詳細資訊

<template>
  <template if:false={wiredRecord.data}>
    <!-- lightning card for the label when wiredRecord has no data goes here  -->
      <lightning-card class= "slds-align_absolute-center no-boat-height">
          <span>{label.labelPleaseSelectABoat}</span>
      </lightning-card>
  </template>
  <template if:true={wiredRecord.data}>
     <!-- lightning card for the content when wiredRecord has data goes here  -->
     <lightning-card>
         <lightning-tabset variant="scoped">
             <lightning-tab label={label.labelDetails}>
                 <lightning-card icon-name={detailsTabIconName} title={boatName}>
                     <lightning-button slot="actions" title={boatName} label={label.labelFullDetails} onclick={navigateToRecordViewPage}></lightning-button>
                     <lightning-record-view-form density="compact"
                          record-id={boatId}
                          object-api-name="Boat__c">
                          <lightning-output-field field-name="BoatType__c" class="slds-form-element_1-col"></lightning-output-field>
                          <lightning-output-field field-name="Length__c" class="slds-form-element_1-col"></lightning-output-field>
                          <lightning-output-field field-name="Price__c" class="slds-form-element_1-col"></lightning-output-field>
                          <lightning-output-field field-name="Description__c" class="slds-form-element_1-col"></lightning-output-field>
                     </lightning-record-view-form>
                 </lightning-card>
             </lightning-tab>
             
         </lightning-tabset>
     </lightning-card>
  </template>
</template>

boatDetailsTabs.js:connectedCallback生命週期函式中進行訂閱操作,訂閱到boatId的值,從而 getRecord展示左側galery選擇的資料的詳情資訊

// Custom Labels Imports
// import labelDetails for Details
// import labelReviews for Reviews
// import labelAddReview for Add_Review
// import labelFullDetails for Full_Details
// import labelPleaseSelectABoat for Please_select_a_boat
// Boat__c Schema Imports
// import BOAT_ID_FIELD for the Boat Id
// import BOAT_NAME_FIELD for the boat Name
import { LightningElement, api,wire } from 'lwc';
import labelDetails from '@salesforce/label/c.Details';
import labelReviews from '@salesforce/label/c.Reviews';
import labelAddReview from '@salesforce/label/c.Add_Review';
import labelFullDetails from '@salesforce/label/c.Full_Details';
import labelPleaseSelectABoat from '@salesforce/label/c.Please_select_a_boat';
import BOAT_ID_FIELD from '@salesforce/schema/Boat__c.Id';
import BOAT_NAME_FIELD from '@salesforce/schema/Boat__c.Name';
import { getRecord,getFieldValue } from 'lightning/uiRecordApi';
import BOATMC from '@salesforce/messageChannel/BoatMessageChannel__c';
import { APPLICATION_SCOPE,MessageContext, subscribe } from 'lightning/messageService';
const BOAT_FIELDS = [BOAT_ID_FIELD, BOAT_NAME_FIELD];
import {NavigationMixin} from 'lightning/navigation';
export default class BoatDetailTabs extends NavigationMixin(LightningElement) {
  @api boatId;

  label = {
    labelDetails,
    labelReviews,
    labelAddReview,
    labelFullDetails,
    labelPleaseSelectABoat,
  };
  
  // Decide when to show or hide the icon
  // returns 'utility:anchor' or null
  get detailsTabIconName() {
    return this.wiredRecord && this.wiredRecord.data ? 'utility:anchor' : null;
   }
  
  // Utilize getFieldValue to extract the boat name from the record wire
  @wire(getRecord,{recordId: '$boatId', fields: BOAT_FIELDS})
  wiredRecord;

  get boatName() {
    return getFieldValue(this.wiredRecord.data, BOAT_NAME_FIELD);
   }
  
  // Private
  subscription = null;
  // Initialize messageContext for Message Service
  @wire(MessageContext)
  messageContext;
  
  // Subscribe to the message channel
  subscribeMC() {
    if(this.subscription) { return; }
    // local boatId must receive the recordId from the message
    this.subscription = subscribe(
        this.messageContext, 
        BOATMC, 
        (message) => {
            this.boatId = message.recordId;
        }, 
        { scope: APPLICATION_SCOPE }
    );
  }
  
  // Calls subscribeMC()
  connectedCallback() { 
    this.subscribeMC();
  }

}

效果展示:當點選左側列表的影像,右側會展示當條的具體資訊。

 總結:篇中程式碼看上去可能有點冗餘,因為superbadege中還有其他功能,所以只是做了簡單的刪減,想要復現這種效果可以在lwc superbadge安裝一下 unmanaged package然後程式碼賦值貼上可以看到效果。篇中只是簡單介紹了一下lightning message service的簡單實用,limitation以及unsubscription這裡不做過多的講解,自行檢視官方文件。篇中有錯誤地方歡迎指出,有不懂歡迎留言。

相關文章