Lightning Web Components のコンポーネントUIツリーを動的に構築する方法について

## TL;DR

- Lightning Web Components では動的にコンポーネント(DOM要素)の作成およびツリーへの追加が（現状）できない
- あらかじめ動的作成できるコンポーネントのテンプレートを用意しておき、UIツリーの設定データを食わせて描画させるという力技がある
- いくつか懸念事項・制約はあるがまあ頑張れる範囲ではないかと考える

## Lightning Web Componentsでの動的要素作成について

通常のSPAなWebアプリを開発していると、スクリプト内で動的にUI要素を作成して画面に追加する、といったニーズがしばしば発生します。

通常のWeb Componentsであれば DOMと同様の扱いとなるはずなので、以下のような標準のDOM APIで操作すればOKです。

```js
const el = document.createElement('my-awesome-element');
el.attr1 = 'attribute1';
el.attr2 = 'attribute2';
containerEl.appendChild(el);
```

しかしながらLightning Platform上のLightning Web Componentsは様々な制約があり、それができないようになっています。

- https://salesforce.stackexchange.com/questions/243876/dynamic-component-creation-in-lightning-web-components

回避方法としては、そこだけLightning Aura Componentを使う（Auraでは`$A.createComponent`で動的作成可能）、あるいはあらかじめ追加を想定しているコンポーネント定義をテンプレートに埋め込んでおいて`if:true`等にフラグを指定して表示/非表示を切り替える、などと言った方法が取れますが、今更Auraを使うのも厳しいですし、テンプレートに埋め込む方法は柔軟さに欠けます。

## テンプレート列挙によるDynamicElementの実現

ここで、Lightning PlatformでのLightning Web Componentsでも、あらかじめテンプレート上に定義として存在するコンポーネントであれば描画可能なので、これを利用してすべてのコンポーネントのテンプレートファイルをあらかじめ用意しておくことで動的にコンポーネントツリーを描画する`DynamicElement`コンポーネントを作成します。かなり力技ではありますが、一度これをやってしまえば後は再利用可能なので、がんばります。


```ts:dynamicElement.js
import { LightningElement, api } from 'lwc';
// HTML要素のテンプレート定義HTMLファイルのインポート
import div from './templates/html/div.html';
import span from './templates/html/span.html'
// ...以下、HTML要素のテンプレート定義のインポートが続く

import lightningButton from './templates/lightning/lightning-button.html';
import lightningCard from './templates/lightning/lightning-card.html';
// ...以下、`lightning-*` コンポーネントのテンプレート定義が続く

import defaultTemplate from './dynamicElement.html';

/**
 * タグ名 -> テンプレート定義 へのマップ
 */
const TEMPLATES = {
    div,
    span,
    // ...以下、HTML要素のテンプレート定義が続く

    'lightning-button': lightningButton,
    'lightning-card': lightningCard,
    // ...以下、`lightning-*` コンポーネントのテンプレート定義が続く

};

/**
 *
 */
export default class DynamicElement extends LightningElement {
    /**
     * タグ名
     */
    @api
    tag;

    /**
     * 属性値をマップで保持する。
     * キー名は属性名をDOMプロパティ名に変換(`kebab-case`から`camelCase`化)して保持
     */
    @api
    attrs = {};

    /**
     * 子要素となるUI要素の設定を配列で保持
     */
    @api
    children = [];

    render() {
        console.log('render()', this.tag);
        const template = TEMPLATES[this.tag];
        return template || defaultTemplate;
    }
}
```

各テンプレートは、取りうる属性値をすべて列挙し、`attrs`のマップ情報から参照するようにします。
さらに子要素を取り得る要素については、再帰的に`<c-dynamic-element>`を子要素配列についてループして埋め込むことで描画するようにしています。

たとえば`<div>`要素の場合のテンプレートであれば、以下のようになります（属性値の割当はサボっています）

```html:div.html
<template>
    <div
        class={attrs.class}
        style={attrs.style}
        onclick={attrs.onclick}
        ondblclick={attrs.ondblclick}
    >
        <template for:each={children} for:item="c">
            <c-dynamic-element
                key={c.key}
                type={c.type}
                attrs={c.attrs}
                children={c.children}
            >
            </c-dynamic-element>
        </template>
    </div>
</template>
```

`<lightning-button>`の場合はこうなります。

```html:lightningButton.html
<template>
    <lightning-button
        class={attrs.className}
        variant={attrs.variant}
        label={attrs.label}
        title={attrs.title}
        value={attrs.value}
        disabled={attrs.disabled}
        onclick={attrs.onclick}
    >
    </lightning-button>
</template>
```

このようにして定義した`DynamicElement`コンポーネントは、例えば以下のようにして利用します。

```ts:dynamicElementTestApp.js
import { LightningElement, track } from 'lwc';

/**
 *
 */
export default class DynamicElementTestApp extends LightningElement {
  @track
  rootNode = {
    tag: 'lightning-card',
    attrs: {
      title: 'Dynamic Element Test',
      iconName: 'custom:custom14',
    },
    children: [],
  };

  activeContainerKey = null;

  getActiveContainer() {
    console.log('container=>', this.activeContainerKey);
    if (!this.activeContainerKey) {
      return this.rootNode;
    }
    const container = this.findByKey(this.activeContainerKey) || this.rootNode;
    console.log(
      'container',
      container.tag,
      container.key,
      container.attrs.style,
    );
    return container;
  }

  createNode(tag, attrs = {}, children = []) {
    const key = Math.random()
      .toString(16)
      .substring(2);
    return { key, tag, attrs, children };
  }

  query(filterFn, node = this.rootNode) {
    const nodes = [];
    if (filterFn(node)) {
      nodes.push(node);
    }
    for (const cn of node.children) {
      const ns = this.query(filterFn, cn);
      nodes.push(...ns);
    }
    return nodes;
  }

  find(filterFn, node = this.rootNode) {
    if (filterFn(node)) {
      return node;
    }
    for (const cn of node.children) {
      const n = this.find(filterFn, cn);
      if (n) {
        return n;
      }
    }
    return undefined;
  }

  findByKey(key, node = this.rootNode) {
    return this.find(n => n.key === key, node);
  }

  findByTag(tag, node = this.rootNode) {
    return this.find(n => n.tag === tag, node);
  }

  remove(rejectFn, node = this.rootNode) {
    const children = node.children.filter(n => !rejectFn(n));
    if (children.length !== node.children.length) {
      node.children = children;
    }
    for (const cn of node.children) {
      this.remove(rejectFn, cn);
    }
  }

  focusContainer(key, e) {
    console.log('focus container', key);
    if (e) {
      e.stopPropagation();
    }
    this.activeContainerKey = key;
    const containers = this.query(n => n.tag === 'div');
    for (const ct of containers) {
      ct.attrs.style = undefined;
    }
    const focusContainer = this.getActiveContainer();
    focusContainer.attrs.style = 'border: 1px solid blue';
  }

  addIcon() {
    console.log('add icon');
    console.log(this.rootNode);
    const node = this.createNode('lightning-icon', {
      iconName: 'utility:success',
      style: 'cursor: pointer',
    });
    node.attrs.onclick = this.toggleIcon.bind(this, node.key);
    const container = this.getActiveContainer();
    container.children.push(node);
  }

  addContainer() {
    const currentContainer = this.getActiveContainer();
    const newContainer = this.createNode('div', {
      class: 'slds-m-left_large slds-p-around_small',
      style: 'min-height: 10px',
    });
    newContainer.attrs.onclick = this.focusContainer.bind(
      this,
      newContainer.key,
    );
    currentContainer.children.push(newContainer);
    this.focusContainer(newContainer.key);
  }

  toggleIcon(key) {
    console.log('toggle icon', key);
    const icon = this.findByKey(key);
    if (icon) {
      icon.attrs.variant =
        icon.attrs.variant === 'success' ? undefined : 'success';
    }
  }

  removeSelectedIcons() {
    this.remove(
      n => n.tag === 'lightning-icon' && n.attrs.variant === 'success',
    );
  }

  removeContainer() {
    this.remove(n => n.key === this.activeContainerKey);
  }
}
```

```html:dynamicElementTestApp.html
<template>
  <c-dynamic-element
    tag={rootNode.tag}
    attrs={rootNode.attrs}
    children={rootNode.children}
  ></c-dynamic-element>
  <div>
    <button onclick={addIcon}>Add Icon</button>
    <button onclick={removeSelectedIcons}>Remove Selected Icons</button>
    <button onclick={addContainer}>Add Container</button>
    <button onclick={removeContainer}>Remove Container</button>
  </div>
</template>
```

※ UIツリーの操作の際のノード検索はかなり無駄なことやっていますが、そこはサンプルということで適宜ご理解いただければと思います。

これを配備すると、以下のような感じでLightning Web Componentsを含むUIを動的に構成できます。

![DynamicElementTest.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6495/332022e1-33da-86a4-b1f4-671f6101fb3e.gif)

## (追記)ソースコード

現在手に入るLWCの仕様、およびHTML要素のIDLからテンプレートを生成したDynamicElementコンポーネントのソースコードをこちらにおいておきます。
かなり`force:source:push`が重いですし、配備後初回の起動がかなり遅いかんじになりますが、コンポーネント自体はサクサク動きます。

https://github.com/stomita/lwc-dynamic-element

重さを嫌う場合は適宜`import`しているテンプレートを減らしておけばよいのではないかと思います。

## 懸念事項・制約など

### 外部提供のコンポーネントは動的作成できない

今回の方法として、あらかじめ動的に構築する対象となるコンポーネント（DOM要素も含む）を全てテンプレートで列挙しておくという戦略をとっています。つまりアプリケーションの開発時の段階で内部で使う可能性のあるコンポーネントはすべて既知である必要があります。

たとえば標準のアプリケーションビルダーのように表示するコンポーネントをユーザに追加させるような仕組みをもつアプリケーションを作ろうとした時に、画面内に配置可能なコンポーネントはカスタムコンポーネントも含めできるだけ多くのものを対象としたいと思われるかもしれません。しかし以上の性質により、たとえば3rd Partyが提供しているコンポーネントを内部に含めるといったことは難しくなります。


### CSSの期待するツリー構造と一致しないため正常にスタイリングされない場合がある

たとえばSalesforce Lightning Design System (SLDS)が適用するスタイルシートに合わせてUIツリーを構築した際に、CSSの定義上では直接の子要素として配置すべきものが、間に余分なコンポーネント要素`c-dynamic-element`が挟まってしまい、そのため正常にスタイリングされない、といったことが考えられます。

CSS側に手を入れられる場合はなんとかなる可能性もありますが、SLDSのような既製のものを使っているときには注意が必要です。そのような場合には、それぞれのテンプレートの中でツリーを展開する際に複数階層を一度にインライン化するなどで対応できるかもしれませんが、テンプレートが複雑になるのを許容する必要があります。

## 補足： さらなる可能性、React LWC Renderer

`DynamicElement`のような動的UIツリー生成の仕組みを用いることで、Lightning Web ComponentsのUI定義がシンプルなネストされたJavaScriptオブジェクトのツリーで定義および操作ができるようになりました。

Lightning Web Componentsのバインディングの仕組みにより、オブジェクトツリー内の属性値変更を行うとすぐさまUIに反映されます。

つまり、このオブジェクトツリーは一種の「仮想DOM」のようなものとして利用ができるということです。

Reactでは通常React DOMというレンダラーが用意されており、仮想DOMの差分計算結果を現実のDOMに対して適用していましたが、この仮想DOM => UIへのマッピングはカスタムレンダラーという仕組みで変更する事ができます。これにより、Reactで開発されたアプリをWebに限らないさまざまなプラットフォームに展開できます。カスタムレンダラーの最も著名な例としては`react-native`があり、これによりiOS/AndroidといったモバイルのネイティブアプリをReactで開発ができるようになっています。

過去に自分は [react-lightning](https://github.com/stomita/react-lightning) という Aura Componentに対するReactカスタムレンダラーを書いていたことがあります。正直こちらはあまり安定しなかったのですが、Web ComponentsベースのLightning Web Components が発表されたことでLightningのコンポーネントも通常のReact DOMであつかえるようになるのではないかと期待していたこともあり、開発をストップしていました。しかしながら先に述べたようにLightning Web Componentsは標準のWeb Components とは異なる動きをしますので、React DOMなどでは対応できません。

よって、ここでもし「React LWC Renderer」のようなカスタムレンダラーを用意し、そのレンダラーが上記の「仮想DOM」（Reactの管理する「仮想DOM」とは別のレイヤーのものではありますが）の更新を行ったならば、Reactを使ってLightning Web Componentsのアプリ開発が簡単にできることになります。

正直 Lightning Web Components の Web Components対応がちゃんとしていればこのような努力は必要ないはずなのですが、このようなレンダラーの仕組みをうまく使うことで、ReactによるLWCアプリ開発ももしかしたら現実的になるかもしれません。