Atomic Designで実現する効率的なLWC開発

LWC開発における設計手法について、Atomic Designを用いたやり方を実際のコード付きでご紹介します。

はじめに

みなさんはSalesforceでLightning Web Component(以下LWC)を用いた開発を行ったことはありますでしょうか?
私はほぼ毎日LWCで開発を行っています。

さて、開発を行っていると、「このボタン、前にも同じようなものを作った気がする」「UIを変更することになったけど、変更する箇所が多くて大変」といった場面を経験された方もいらっしゃるのではないでしょうか?
上記のような問題を解決する方法が共通化再利用です。

共通化されたLWCはあらゆる箇所で再利用することができ、変更に強く非常に保守性が高いコードになります。
今回は「共通化」・「再利用」に焦点を当てた設計手法の一つであるAtomic Designについて紹介していきます。

Atomic Designとは?

Atomic DesignはLWCやReactといったコンポーネント指向プログラミングにおけるデザイン・フレームワークで、どんな単位でUIをコンポーネント化すればよいかを示してくれる考え方になります。

Atomic Designにおいてコンポーネントは以下5つの階層に分かれます。小さな要素を組み合わせ大きな要素を作成してくため、小さな要素は再利用する前提で設計していきます。
階層名 定義 例・イメージ
Pages Templatesに実際のコンテンツを流し込んだもの
カプセル化や再利用がされない「最大の要素」
実際のWebページ
Templates 具体的な内容を持たない状態で上記階層のコンポーネントを配置した「ページの雛型」 デザインラフ
Organisms
(有機体)
コンポーネントが1つの機能として成立しており、「独立して存在ができる要素」 入力フォームなど
Molecules
(分子)
Atoms層のコンポーネントを複数組み合わせた「最大限抽象化した要素」 検索フォームなど
Atoms
(原子)
これ以上コンポーネントの分割ができない「最小の要素」 ボタンや入力欄など

Atomic Designのメリット・デメリット

メリット

1.再利用ができること
  特にAtomsとMoleculesについては業務ロジックを持たないため、あらゆる箇所で再利用をすることができます。
  同じコードを何回も書く必要がなく、開発スピードの向上が期待できるでしょう。

2.デザインの統一ができること
  共通のコンポーネントを再利用するため、
  「画面ごとにボタンのデザインが異なっている」といったことがなくなります。
  すべての画面においてデザインの統一をすることができます。
  また、デザインを修正する場合は一か所のコンポーネントのみ修正すればすべての画面で反映されるため改修にかかる時間が短縮されます。

3.テストが実施しやすいこと
  一つの LWC に業務ロジック・表示状態の管理・エラーチェックなどをすべて詰め込むと、テストは非常に複雑になります。
  Atomic Designによってこれらを Atoms / Molecules / Organisms へ適切に分割でき、どの層をテストすべきかが明確になる ため、テスト観点も整理しやすくなります。
階層 テスト内容
Atoms
Molecules
単純なイベントの発火有無や表示の確認
Organisms 業務ロジックが正常に動作しているか確認
Pages Apex呼び出しが正常に動作しているか確認

デメリット

1.コンポーネント分類があいまいなこと
  コンポーネントをどの階層に分類するか迷うことが多くあります。
  例えば「単純なアイコン付きボタンはAtomsかMoleculesか」など、認識が個人間でぶれてしまうと、粒度が不ぞろいになり却って管理がしづらくなってしまうことがあります。
 ・対策
  プロジェクトチーム内で粒度の認識を合わせ、明確に定義することによって対策ができます。

2.ファイル数が圧倒的に増えること
  コンポーネントを分割するという仕組み上、ファイル数(LWCの数)が非常に多くなり、どのコンポーネントがどういった意味を持つか判別しづらくなることがあります。
  結果、すでにある機能を再度作成してしまうようなことが起こってしまします。
 ・対策
  命名規則を徹底させることである程度対策ができます。
  本記事で紹介したように、コンポーネントにプレフィックスを持たせることによって、視覚的にどの階層に位置するコンポーネントかを判別させたり、何の機能を持つかを明確にすると車輪の再発明を防ぐことができます。

実際のコード例

実際にLWCでAtomic Designを適用した例を見ていきましょう。
今回は、「取引先のレコードページに配置する、取引先責任者を一括で作成するLWC」を題材とします。

作成したLWCのレイアウト

赤枠で囲んでいる箇所が今回作成したLWCです。
機能としては大きく以下となります。
・項目の入力:
 各行に「姓」「名」「メール」などの取引先責任者項目を入力できます。
・行の動的追加、削除:
 右下にある+ボタンを押すことで、入力行を追加できます。
 各行の右にあるごみ箱ボタンを押すことで、行を削除できます。
・一括保存:
 複数の行へ入力後、保存ボタンを押下すると現在入力されているすべての行データを取引先責任者レコードとして保存します。

フォルダ構成

余談になりますが、LWCのコンポーネントフォルダは必ず「/lwc」の直下に配置されている必要があります。
例えば「/lwc」の直下に「atoms」フォルダを作成し、その中でLWCを配置するとデプロイ時エラーとなります。
そのため、各コンポーネントに対してプレフィックスを付けておくと管理がしやすくなります。今回は階層名をプレフィックスとしています。

では、PagesからAtomsまで実際にコードを見ていきましょう。

Pages

<template>
    <c-templates_sobject-creator
        record-id={recordId}
        icon="standard:contact"
        title="取引先責任者一括作成"
        sobject-name="Contact"
        columns={columns}
        onsave={handleSave}
    ></c-templates_sobject-creator>
</template>
pages_contactCreator.html
import { LightningElement, api } from 'lwc';
// レコードの保存処理で使用するApexメソッドのインポート;

export default class Pages_contactCreator extends LightningElement {
    @api recordId;

    columns = [
        { field: 'LastName', label: '', type: 'text', placeHolder: '山田' },
        { field: 'FirstName', label: '', type: 'text', placeHolder: '太郎' },
        { field: 'Email', label: 'メール', type: 'email', placeHolder: 'xxx@example.com' },
        { field: 'Title', label: '役職', type: 'text', placeHolder: '部長' },
        { field: 'MobilePhone', label: '携帯電話', type: 'phone', placeHolder: '000-1234-5678' }
    ];

    async handleSave(event) {
        // レコードの保存処理(省略)
    }
pages_contactCreator.js
Pagesでは、Templatesに対して値(具体的な設定)を与えることを記載しています。
今回は取引先責任者のアイコンや項目情報を与えています。
必要に応じてApexの呼び出し処理なども記載していきます。

Templates

<template>
    <div class="slds-p-vertical_large">
        <div class="slds-box">
                <!-- ヘッダー -->
                <p class="slds-text-heading_medium slds-p-bottom_small">
                    <lightning-icon icon-name={icon} class="slds-m-horizontal_small"></lightning-icon>
                    {title}
                </p>

                <!-- 入力欄 -->
                <c-organisms_table
                    record-id={recordId}
                    columns={columns}
                    sobject-api-name={sobjectName}
                ></c-organisms_table>

            <p class="slds-p-top_medium slds-p-left_small">※一度に作成可能な上限は10件までとなります。</p>
        </div>
    </div>
</template>
templates_sobjectCreator.html
import { LightningElement, api } from 'lwc';

export default class Templates_sobjectCreator extends LightningElement {
    @api recordId;
    @api icon;
    @api title;
    @api sobjectName;
    @api columns;
}
templates_sobjectCreator.js
Pagesから渡された情報をもとにしてレイアウトを構築します。
sobjectNameやcolumnsの値を差し替えるだけで、商談の一括作成など他オブジェクトバージョンのコンポーネントを手軽に作成することができます。

Organisms

<template>
    <!-- 入力欄 -->
    <template for:each={records} for:item="rec" for:index="index">
        <div key={rec._key}>
            <c-molecules_table-row
                record={rec}
                columns={columns}
                index={index}
                onupdate={handleRowUpdate}
                ondelete={handleRowDelete}>
            </c-molecules_table-row>
        </div>
    </template>

    <!-- ボタンエリア -->
    <div class="slds-m-top_small slds-grid slds-grid_align-spread">
        <div class="slds-col slds-size_1-of-2">
            <!-- 行追加ボタン -->
            <template lwc:if={hasMoreRows}>
                <c-atoms_button
                    has-icon="true"
                    icon="utility:add"
                    onbutton_click={handleRowAdd}
                ></c-atoms_button>
            </template>
        </div>

        <!-- 保存ボタン -->
        <div class="slds-col slds-size_1-of-2 slds-grid slds-grid_align-end">
            <c-atoms_button
                has-icon="false"
                label="保存"
                variant="brand"
                onbutton_click={handleSave}
            ></c-atoms_button>
        </div>
    </div>
</template>
organisms_table.html
import { LightningElement, api } from 'lwc';

export default class Organisms_table extends LightningElement {
    @api recordId;
    @api columns;
    @api sobjectApiName;

    records = [];
    keyIndex = 0;

    get hasMoreRows(){
        return this.records.length < 10;
    }

    connectedCallback(){
        this.createBlankRow();
    }

    // 行の追加・削除処理など(中略)

    async handleSave() {
        this.dispatchEvent(new CustomEvent('save', {
            detail: {
                parentId: this.recordId,
                sobjectApiName: this.sobjectApiName,
                records: this.records
            },
            bubbles: true,
            composed: true
        }));
    }
}
organisms_table.js
Organismsはコアになる機能を取り扱います。
今回の場合は画面上に表示する行データの追加・削除など管理を行います。
また、保存処理のようなApex呼び出しが必要になる箇所はOrganismsでは実行せず、Pagesへカスタムイベントをディスパッチするところまでを行います。

Molecules

<template>
    <div class="slds-box slds-m-vertical_x-small">
        <p>No.{viewIndex}</p>
        <div class="slds-grid slds-wrap slds-grid_vertical-align-center">
            <!-- 入力欄 -->
            <template for:each={columns} for:item="col" for:index="colIndex">
                <div key={col.fieldName} class="slds-col slds-size_1-of-6 slds-p-horizontal_small">
                    <c-atoms_input
                        label={col.label}
                        type={col.type}
                        placeholder={col.placeHolder}
                        field={col.field}
                        oninput_blur={handleBlur}>
                    </c-atoms_input>
                </div>
            </template>

            <!-- 削除ボタン -->
            <div class="slds-p-top_large slds-p-left_xx-large">
                <c-atoms_button
                    has-icon="true"
                    icon="utility:delete"
                    onbutton_click={handleDelete}
                ></c-atoms_button>
            </div>
        </div>
    </div>
</template>
molecules_tabelRow.html
import { LightningElement, api } from 'lwc';

export default class Molecules_tableRow extends LightningElement {
    @api record;
    @api columns;
    @api index;

    get viewIndex(){
        return this.index + 1;
    }

    handleBlur(event) {
        const newRecord = { ...this.record, [event.detail.label]: event.detail.value };

        this.dispatchEvent(new CustomEvent('update', {
            detail: {
                index: this.index,
                value: newRecord
            }
        }));
    }

    handleDelete() {
        this.dispatchEvent(new CustomEvent('delete', {
            detail: { index: this.index }
        }));
    }
}
molecules_tabelRow.js
Moleculesは意味のある一つの単位を取り扱います。
今回は、inputやbuttonなどのAtomsを組み合わせて「入力欄と削除ボタンを含めたテーブルの一行」を構成しています。

Atoms

<template>
    <template lwc:if={hasIcon}>
        <lightning-button-icon
            icon-name={icon}
            alternative-text={label}
            onclick={handleClick}>
        </lightning-button-icon>
    </template>

    <template lwc:else>
        <lightning-button
            variant={variant}
            label={label}
            onclick={handleClick}>
        </lightning-button>
    </template>
</template>
atoms_button.html
import { LightningElement, api } from 'lwc';

const ICON_TYPE_ADD = 'utility:add';
const VARIANT_TYPE_DEFAULT = 'default';

export default class Atoms_button extends LightningElement {
    _hasIcon = false;
    @api
    get hasIcon() {
        return this._hasIcon;
    }
    set hasIcon(value) {
        this._hasIcon= (value === 'true');
    }

    @api icon = ICON_TYPE_ADD;
    @api variant = VARIANT_TYPE_DEFAULT;
    @api label;

    handleClick() {
        this.dispatchEvent(new CustomEvent('button_click'));
    }
}
atoms_button.js
<template>
    <lightning-input
        type={type}
        label={label}
        value={value}
        placeholder={placeholder}
        onblur={handleBlur}>
    </lightning-input>
</template>
atoms_input.html
import { LightningElement, api } from 'lwc';

const INPUT_TYPE_TEXT = 'text';

export default class Atoms_input extends LightningElement {
    @api label;
    @api value;
    @api placeholder;
    @api type = INPUT_TYPE_TEXT;
    @api field;

    handleBlur(event) {
        this.dispatchEvent(new CustomEvent('input_blur', {
            detail: {
                value: event.target.value,
                label: this.field
            }
        }));
    }
}
atoms_input.js
見た目と基本的なイベント発火のみを担う最も汎用的なコンポーネントです。
buttonでは渡す値によってアイコンのボタンにするか、通常のボタンにするかを切り替えています。
今回は記載しておりませんが、色やサイズなどをCSSで設定することも多くあります。

LWCでAtomic Designを取り入れるときのおすすめ

Atomic Designは理想的な設計手法ではありますが、コンポーネントの切り出し方に迷いが生じやすいかったり、チームメンバー全員が設計思想をある程度理解するための学習コストがかかるといったハードルがあります。
そこで、SalesforceのLWC開発における特性を活かした、導入ハードルを下げるためのアプローチをいくつかご紹介します。

標準コンポーネントを使う

LWCには、もともとlightning-input・lightning-button・lightning-datatableなどSalesforceが提供するコンポーネントが豊富に用意されています。下記のような条件がない場合は、標準コンポーネントをそのままAtomsの代わりとして使っていくと良いでしょう。
・SLDSなど用意された機能ではできないレイアウトを使用する場合
・標準にはない独自のイベント処理を追加したい場合

まず標準コンポーネントで実装可能か判定し、どうしても難しい場合に限りAtomsを追加する流れを推奨します。

階層を簡略化する

小規模・中規模なプロジェクトでは、5階層すべてを厳密に設計するとなるとコストが高くなってしまいます。

「全く同じレイアウトのページを複数作成する」といった要件が少ない場合は、Templatesの階層に分けるメリットが薄くなることもあります。

そのため、以下のようにTemplatesを除いた階層で管理することも有効です。
・Atoms
・Molecules
・Organisms
・Pages

また、OrganismsとPagesをまとめて業務ロジック用に特化させたコンポーネントを作ることも手段の一つになるかもしれません。

おわりに

いかがでしたでしょうか。

LWCの強みは共通化して再利用できることにあると考えています。
今回は再利用可能なコンポーネントを作るための指標として、Atomic Designを紹介しました。

本記事がLWC開発を行っている方の助けになれば幸いです。

最後までお付き合いいただきありがとうございました。