⛓️

Vue3で作っているVisualforceページでLWCを使う

2024/10/18に公開

はじめに

Salesforce上でリッチな画面を開発することになった場合、ReactやVueなどの主要なフロントエンドフレームワークを使うか、Lightning コンポーネントフレームワーク(LWCやAura)を使うか、異なるアプローチがあり、悩むことは多いのではないでしょうか。

この記事ではその 両方 を使った画面の開発方法の一例として、Vue3とLWCを組み合わせて使う実装方法を紹介します。
使っている仕組み自体は、Vue3に限定されるものではないので、Reactなどそれ以外のフレームワークでも応用できるかと思います。

そもそもなぜ両方使うのか?

通常、近しい位置づけのフレームワークを複数組み合わせて使う開発方法は「混ぜるな危険」であり、なるべくなら避けるべきだと考えています。

現代においては、React, Vue, Svelte等のフロントエンドフレームワークを使うのが一般的かつ最適な場合が多いでしょう。TypeScriptによる型の恩恵を受け、ローカルでサクサク開発でき、パフォーマンスやセキュリティについてもフレームワークに沿うことで一定の担保が可能です。私たちが開発・運用している「Cariot(キャリオット)」というサービスでは、ReactとVueの両方を使い分けて開発しているのですが、Salesforceプラットフォーム上で動かすアプリケーションについては、Vueを採用するシーンが多いです。

一方で、LWCの方が向いているケースもあります。Lightning Experience / Lightning Design SystemのスタイルにLook & Feelを統一したい場合や、標準コンポーネントで画面の大半の要素が実現できる場合です。例えば日付選択用のカレンダーやレコードのようなピッカーは、同じものをスクラッチでSLDSを使って作ると大変(不毛)ですが、LWCを使えばタグ1つで実現ができます。Salesforce側がアップデートされた時に、その恩恵を受けられるのもメリットです。

Salesforceプラットフォーム上で画面を開発する場合においては、上記のようにそれぞれ強みがあります。
今回取り上げるのは、独自の要素が多いのでメインをVueで開発し、一部のピッカー系だけ見た目を揃えるのにLWCを使う、という画面でした。混ぜるというよりはアクセントとして使うような使い方、といったほうがよいかもしれません。

仕組み

組み合わせ方として、Vueアプリケーションの中でLWCを使う方法と、LWCにVueアプリケーションを読み込む方法の2つがありますが、今回は前者の方法です。

全体としては次のようなイメージになります。

Lightning OutとLMSについて簡単に紹介します。

Lightning Out

Lightning Outは、Visualforce(に限りませんが)上からLightningコンポーネントを使うための仕掛けです。

https://developer.salesforce.com/docs/atlas.ja-jp.lightning.meta/lightning/components_visualforce.htm

詳しい説明は上記に譲りますが、諸々のお膳立てをしてあげると、Visualforce上のJavaScriptで次のようなコードを書くことで、要素ID app の位置に myComponent が追加できます。

$Lightning.createComponent("c:myComponent", {}, "app"); 

Lightning Message Service

画面全体としての動きを実現するのに、VueとLWCの間のインタラクションは必須です。
そのためにLightning Message Service (LMS)を使います。

LMSは、Salesforceプラットフォーム上でPub-Sub型の通信を行うための機能です。

実装例

それぞれの部品の実装サンプルです。

Visualforceページ

sample.page
<apex:page docType="html-5.0"
  controller="SampleController"
  applyHtmlTag="false"
  applyBodyTag="false"
  showHeader="false"
  standardStyleSheets="false"
>
  <apex:slds />

  <!-- Lightning Out 読み込み -->
  <apex:includeLightning />

  <html lang="ja">
    <head>
      <meta charset="UTF-8" />
      <link rel="icon" href="/favicon.ico" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>Sample</title>
    </head>

    <body class="slds-scope">
      <!-- LMSメッセージチャネルトークンを取得 -->
      <script>
        var channel = "{!$MessageChannel.sample__c}";
      </script>

      <!-- Vueアプリケーション(静的リソース) -->
      <div id="app"></div>
      <script
        type="module"
        crossorigin="true"
        src="{!URLFOR($Resource.Sample, '/index.js')}"
      ></script>
    </body>
  </html>
</apex:page>

Vue, Lightning Out, LMS を使ったアプリケーションページです。
今回の構成であれば、他のページでもほとんど同じようなマークアップになるはずです。

Vueアプリケーションのメイン

main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { SForce } from './@types/lms'

import App from './App.vue'

const appStart = async () => {
  const pinia = createPinia()

  const app = createApp(App)

  app.use(pinia)

  app.config.globalProperties.window = window
  app.provide<string>('channel', app.config.globalProperties.window?.channel)
  app.provide<any>('$Lightning', app.config.globalProperties.window?.$Lightning)
  app.provide<SForce>('sforce', app.config.globalProperties.window?.sforce)

  app.mount('#app')
}

await appStart()

特筆するのは globalProperties の利用です。Vueではコンポーネントの外部からのグローバルな変数に関しては globalProperties を使って取得ができます。
1つ目の channel は Visualforce 側で取得したメッセージチャネルトークンです。 $Lightning, sforce はどこから来ているのかがわかりにくいですが、Lightning Out を使うと自動的に注入されるものです。

app.provide は他のコンポーネントからこれらのデータを利用するためのVueの仕組みです。
VueコンポーネントからLWCが使えるように、ここで必要なデータを提供するようにしています。

https://ja.vuejs.org/guide/components/provide-inject

また @type/lms は Vue 側で LMS に関する型の恩恵を受けるために、以下のように宣言してます( any を使って手を抜いていますが、 message の方はメッセージチャネルのものと揃えておくのがよいでしょう)

@types/lms.ts
export type SForce = {
  one: {
    publish: (messageChannelToken: string, message: any) => void
    subscribe: (messageChannelToken: string, listener: any, subscriberOptions?: any) => void
  }
}

Vueコンポーネント

LWCを組み込んだVueコンポーネントは以下のような構成になります。

今回はサンプルのため、LWCを挿入するための div 要素しかありませんが、ここにVueの作法に沿ってマークアップしていくことで、VueとLWCを組み合わせた画面の構成が可能です。

Sample1.vue
<template>
  <div id="lwc-container"></div>
</template>

<script lang="ts" setup>
import { onMounted, inject } from 'vue'
import { SForce } from '../@types/lms'

const $Lightning = inject<any>('$Lightning')
const sforce = inject<SForce>('sforce')
const channel = inject<string>('channel')

onMounted(async () => {
  if (!$Lightning) return

  $Lightning.use('c:SampleOutApp', function () {
    $Lightning.createComponent(
      "c:sampleComponent",
      {
        lmsChannel: channel,
        lmsPublish: sforce.one.publish,
        lmsSubscribe: sforce.one.subscribe
      },
      'lwc-container',
      function (cmp) {
        console.log('success: ' + cmp)
      }
    )
  })

  sforce.one.subscribe(channel, (message) => {
    // メッセージ受信時の処理
  })
})
</script>

inject を使って main.ts で提供したデータを注入しています。
あとはそれらを使って、 $Lightning.createComponent により、LWCを挿し込んでいます。

パフォーマンスの観点で何度もコンポーネントの作成が行われないように onMounted ライフサイクルの中でコンポーネントの生成は済ませてしまいましょう。
なおこの時、 v-if バインディングを使って要素を非表示にしている場合、コンポーネント作成先の要素が見つからないためエラーになります。 v-show を使って要素自体は存在させておくなど、対応が必要になります。

LWC

sampleComponent.js
import { LightningElement, api, track } from "lwc";

export default class SampleComponent extends LightningElement {
  @api lmsChannel;
  @api lmsPublish;
  @api lmsSubscribe;

  connectedCallback() {
    if (this.lmsChannel && this.lmsSubscribe) {
      this.lmsSubscribe(this.lmsChannel, (message) => {
        // メッセージ受信時の処理
      });
    }
  }
}

createComponent 時に渡したLMS通信用の各データを使って、メッセージ通信を行います。

なお注意点として、今回のサンプルはVisualforceとLWCで同じメッセージチャネルを使っているため、publishした側でもそのメッセージをsubscribeすることになります。
そのため、処理すべきメッセージ以外は読み捨てるような、識別が必要になるので注意してください。

おわりに

Vue3とLWCの両方を組み合わせて、1つのVFページを開発する方法の紹介でした。

技術的には、それぞれのフレームワークが提供している部品を組み合わせて実現できました。
今回のコードでは、LMSのメッセージ処理などの泥臭いロジックは割愛していますが、実際のプロダクトコードではその部分の比重が増えるでしょう。
また、LWCを使うということは、ローカルで全てが完結するVueの開発者体験を損なう形にもなるので、あくまで最低限の利用に留めておいたほうがよいかもしれません(オープンソースLWCを使えばその限りではないかもしれませんが…)

Cariot開発チーム

Discussion