LoginSignup
8

More than 3 years have passed since last update.

Lightning Web Components で npmで公開されているライブラリを利用する

Last updated at Posted at 2020-03-15

JavaScript Webアプリケーション開発におけるライブラリの利用

Lightning Web Components (以下LWC) では、通常のWeb Componentsの開発と同様にJavaScriptでコンポーネントを記述することになります。LWCが提供する機能やSalesforce Platformが提供する機能を利用するときには、ES Modulesで定められているであるimport文を記述して利用を宣言します。

import { LightningElement, wire } from 'lwc';
importgetUserInfofrom'@salesforce/apex/UserInfoController.getUserInfo';

export default class MyElement extends LightningElement {
  @wire(retrieveUserInfo)
  record;
  // ...
}

しかしながら、通常のJavaScript Webアプリケーション開発では、import文の利用の際にはnpmなどのパッケージマネージャが管理するライブラリの機能を呼び出すことを前提としているケースがほとんどです。具体的にはnpmのレジストリに公開されているライブラリのパッケージをnpm installでインストールしておき、そのパッケージの名前をimport文のfrom以下に記載してライブラリを利用します。
(以下は日付時刻ユーティリティライブラリである luxon を使用する例)

import { DateTime } from 'luxon';

/**
 * 現在時刻をISO形式で表示
 */
function showCurrentTime() {
  console.log(DateTime.local().toISO());
}

このようにして、npmのレジストリに登録されているOSSで提供された(正確にはOSSでないものも一部あるかもしれませんが)ライブラリを利用してJavaScript Webアプリケーション開発をするのが昨今では当たり前になっています。

また、Node.jsのアプリケーション開発では、あらかじめいくつかのライブラリが標準で提供されており、これらはnpm installといったセットアップなしに利用できます。

import url from 'url';
import querystring from 'querystring';

/**
 * URLクエリパラメータ中の[code]パラメータの値を取得する
 */
function getCodeInParam(requestUrl) {
  const { query } = url.parse(requestUrl);
  const { code } = querystring.parse(query);
  return code;
}

Node.jsで標準提供されているライブラリのいくつかは、Webブラウザ上でも同様に動作するものが別に提供されています。これらのライブラリの参照は、実際にWebアプリとして動かす際にはバンドラーとよばれるツール(webpack, browserify, rollupなどが有名です)によって透過的に置き換えられるため、実際にこれらがNode.js由来のものかどうかあまり意識せずに標準ライブラリ的に利用されることが多いかと思います。

LWCにおけるモジュール解決と外部ライブラリの利用

さて、LWC (on Lightning Platform) においては、LWC Compilerは独自にモジュールの解決を行うため、npmが前提となった通常のWebアプリケーション開発とは少し異なってきます。そのためこのようなライブラリを利用するには少し工夫が必要です。

公式のドキュメントでは、外部提供のライブラリを利用したいときには静的リソースを利用するようにアナウンスされているようです。具体的には以下のようなコードになります。

import { loadScript, loadStyle } from 'lightning/platformResourceLoader';
import D3 from '@salesforce/resourceUrl/d3';

export default class LibsD3 extends LightningElement {

    d3Initialized = false;

    renderedCallback() {
        if (this.d3Initialized) {
            return;
        }
        this.d3Initialized = true;

        Promise.all([
            loadScript(this, D3 + '/d3.v5.min.js'),
            loadStyle(this, D3 + '/style.css')
        ])
            .then(() => {
                this.initializeD3();
            });
    }
   // ...
}

しかし利用するライブラリが増えるたびに毎回静的リソースをアップデートするのは少々大変ですね。また、せっかくモジュール形式で提供されているライブラリでも、グローバル変数(実際にはLockerが隔離しているので真のグローバルではないのですが)経由での利用になるというのももったいなさを感じます。あと、非同期での呼び出しになるというのも地味につらいところです。ライブラリがすでにロード済みかどうかを見極めて実行しなければいけません。

というわけで、LWC Compiler においても通常のWebアプリ開発の文脈と同じようにnpmやNode.jsなどライブラリに対するimportの利用に対応するのが待ち望まれていますが、おそらく今すぐには厳しいと思いますので、こちらで少し工夫してみます。

「ライブラリ」としてのLWCコンポーネントの活用

LWC Compilerにおいてコンポーネントの外部で提供されるライブラリをimportでインポートする方法が一つあります。それは呼び出されるライブラリをLWCのコンポーネントとして定義しておくことです。たとえば以下のコードはlwc/mortgageディレクトリ内のmortgage.jsファイルでエクスポートされている関数をインポートします。

lwc/miscComponent/miscComponent.js
import { getTermOptions, calculateMonthlyPayment } from 'c/mortgage';

// ...
lwc/mortgage/mortgage.js
function getTermOptions() {
  // ...
}

function calculateMonthlyPayment() {
  // ...
}

export { getTermOptions, calculateMonthlyPayment };

この方法を利用すれば、LWCにおいてもimport文でコンポーネント外部に定義されている機能をインポートして呼び出しできそうです。あとはnpmで提供されているライブラリをLWCコンポーネントに含めて定義してやればよいです。

npmのパッケージを(ライブラリとしての)LWCコンポーネント化する

たとえば先のluxonをよびだすようなライブラリは、こんな感じでLWCコンポーネントを定義しておくことでうまく利用できそうです。

lwc/luxon/luxon.js(呼び出されるモジュールとしてのLWCコンポーネント定義)
// `npm install luxon`した後のnode_modulesディレクトリをコンポーネントのディレクトリ内に(JSファイルのみ)コピーしておく。
// ES Modules対応のパッケージの場合、インポートするファイルへのパス(src/luxon.js)は、
// `./node_modules/luxon/package.json` ファイル内の `module` フィールドに記載されている
import { DateTime } from './node_modules/luxon/src/luxon.js';
export { DateTime };
// 実際は以下のようにしてもよい
// export * from './node_modules/luxon/src/luxon';
lwc/currentDateElement/currentDateElement.js(モジュールを呼び出して利用する側のLWCコンポーネント定義)
import { LightningElement } from 'lwc';
import { DateTime } from 'c/luxon'; // prefixとして`c/`がつく点以外は通常のWebアプリ開発の場合と同様に利用可能

export default CurrentDateElement extends LightningElement {
  get currentTime() {
    return DateTime.local().toISO();
  }
}

しかしながら、こちらの方法には一つ重大な問題点があります。それはLWCによって呼び出されるモジュールはES Moduleとしてエクスポートされていなければいけないということです。

npmに登録されている多くのライブラリは未だES ModulesではなくCommonJSという仕組みでのモジュール定義となっています。そのため、単にライブラリのJSファイルをコピーしておくだけでは、npmに公開されている大多数のパッケージはLWCではインポートできません。

また、たとえ対象のライブラリがES Modules形式で公開されていたとしても、内部で他のnpmライブラリをインポートして依存しているような場合については、LWC Compilerではそのライブラリのモジュールを解決できません。もちろんimportしているライブラリのソースコードを書き換えすればなんとかなるかもしれませんが、通常そこに踏み込むべきではないでしょう。

先のluxonの場合はたまたまluxonがES Modules形式でも公開されているライブラリでありかつ他のパッケージに対して依存関係も持たないライブラリだったため、JavaScriptファイルのコピーのみで動作させることができただけに過ぎなかったのです。

このため、一般的なnpmのライブラリをうまく動作させるには、もう少し工夫が必要です。具体的にはCommonJS形式のライブラリもES Modules形式で解決できるように変換し、さらにライブラリの依存関係を前処理してLWCでもロード可能な形に変換する作業が必要になります。


このような処理を自動的に行ってくれるものとして、rollup.jsというバンドラーが今回利用できるのではないかと考えました。

一応説明しておくと、バンドラーとは、依存関係にある複数のJavaScriptファイル群をWebアプリで利用可能な形に連結してくれるツールで、先にも少し触れたとおりWebpackなどが有名です。その中でもrollup.jsを今回用いる理由は、出力形式としてES Modules形式をサポートしてくれていることです。

Rollup.jsを用いたライブラリのバンドリングとES Module化

以下、ライブラリとして読み込みしたいnpmパッケージモジュールとしてhandlebarsを利用するものとします。こちらは先程のluxonとは異なりCommonJS形式でのみ公開されているライブラリです。

まずrollup.jsに入力として渡すためのJavaScriptファイルを用意し、プロジェクト内のどこかlwc以下ではないディレクトリに配置しておきます。このJavaScriptファイルは単にライブラリをインポートして読み込んでそのままエクスポートするだけのことしかしません。

./path/to/handlebars.js
import handlebars from 'handlebars';
export default handlebars;

次に以下のようにrollup.jsのconfigファイルを記述し、先のファイルを入力として指定します。出力設定であるoutputdirにライブラリとして読み込まれるLWCコンポーネントのディレクトリを指定し、formatにはES Moduleを出力できるよう "es" を指定します。

rollup.config.js
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
import nodeBuiltins from 'rollup-plugin-node-builtins';
import nodeGlobals from 'rollup-plugin-node-globals';
import { terser } from 'rollup-plugin-terser'

export default {
  input: './path/to/handlebars.js',
  output: {
    dir: './force-app/main/default/lwc/handlebars',
    format: 'es',
  },
  plugins: [
    commonjs(),
    nodeBuiltins(),
    nodeGlobals({ buffer: false }),
    nodeResolve(),
    terser(),
  ],
};

最後にこちらの設定ファイルを利用してrollupコマンドを実行します。

$ npx rollup -c 

lwc/handlebarsディレクトリにバンドルされたJSファイルが生成されます。(handlebars.js-meta.xmlファイルは前もって作成しておく必要あり)

image.png

あとはこのライブラリとしてのLWCコンポーネントをインポートするLWCコンポーネントを記述すればOKです。

lwc/templatePreviewComp/templatePreviewComp.js
import { LightningElement } from 'lwc';
import { DateTime } from 'c/luxon';
import handlebars from 'c/handlebars';

export default class TemplatePreviewComp extends LightningElement {
  templateText;

  get previewText() {
    if (this.templateText) {
      try {
        const tpl = handlebars.compile(this.templateText);
        const previewText = tpl({ now: DateTime.local().toISO() });
        return previewText;
      } catch(e) {
        // 
      }
    }
    return '';
  }

  handleChangeTemplateText(e) {
    this.templateText = e.target.value;
  }
}
lwc/templatePreviewComp/templatePreviewComp.html
<template>
  <lightning-card 
    title="Template Previewer"
    icon-name="custom:custom15"
  >
    <div>
      {previewText}
    </div>
    <lightning-textarea
      label="Input Template"
      value={templateText}
      onchange={handleChangeTemplateText}
    >
    </lightning-textarea>  
  </lightning-card>
</template>

image.png

注意点・懸念事項など

とりあえず実用できそうではありますが、いくつか注意点・懸念事項があります。

LWCのソースコードファイルサイズ制約

多分ドキュメントされていないのですが、LWCのソースコードには実はファイルサイズの制限があります。具体的には131,072文字を超えるとError: Value too long for field: Source maximum length is:131072というエラーとなります。

プログラマがエディタを使って記述するソースコードでは適宜ファイル分割すると思いますのでこれほどの長さになることはまれでしょうが、バンドラが生成するファイルは結構な頻度でこの制限を超過します。先のrollupの例でもterserプラグインを使って圧縮していますが、それでも文字数がオーバーする場合があります。

たとえばxml2jsライブラリの場合、普通にバンドルを生成するとファイルサイズの制限を超えてしまいます。

$ ls -la force-app/main/default/lwc/xml2js/*
-rw-r--r--  1 stomita  staff  154969  3 16 06:49 force-app/main/default/lwc/xml2js/xml2js.js
-rw-r--r--  1 stomita  staff     210  3 16 06:25 force-app/main/default/lwc/xml2js/xml2js.js-meta.xml

これを回避する方法として、rollup.jsのCode Splittingの機能をうまく使うことで、1つ1つのファイルサイズを小さくできる可能性があります。

具体的には、rollupのinputとして、利用したいライブラリ本体だけでなく、そのライブラリが依存しているパッケージをリストアップするようにJSファイルを用意しておきます。

xml2jsの場合、以下のようなパッケージ依存関係となっています。

$ npm info xml2js

xml2js@0.4.23 | MIT | deps: 2 | versions: 48
Simple XML to JavaScript object converter.
https://github.com/Leonidas-from-XIV/node-xml2js

...

dependencies:
sax: >=0.6.0        xmlbuilder: ~11.0.0 

...

そのため、xml2js.jsだけでなく、sax.jsおよびxmlbuilder.jsファイルも以下のように用意します。

./path/to/xml2js.js
import xml2js from 'xml2js';
export default xml2js;
./path/to/sax.js
import sax from 'sax';
export default sax;
./path/to/xmlbuilder.js
import xmlbuilder from 'xmlbuilder';
export default xmlbuilder;

あとはrollup.config.jsの設定で入力としてxml2js.jsだけでなく上記のすべてのJSファイルを入力として指定します。

rollup.config.js
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
import nodeBuiltins from 'rollup-plugin-node-builtins';
import nodeGlobals from 'rollup-plugin-node-globals';
import { terser } from 'rollup-plugin-terser'

export default {
  // 依存関係にあるものもすべてrollupの入力とする
  input: [
    './path/to/xml2js.js',
    './path/to/sax.js',
    './path/to/xmlbuilder.js',
  ],
  output: {
    dir: './force-app/main/default/lwc/xml2js',
    format: 'es',
  },
  plugins: [
    commonjs(),
    nodeBuiltins(),
    nodeGlobals({ buffer: false }),
    nodeResolve(),
    terser(),
  ],
};

この結果以下のような出力になりました。各バンドル生成の中で共通で利用されるコードが別ファイルとしてバンドル生成されるため、ファイルサイズが小さくなっているのがわかるかと思います。

$ ls -la force-app/main/default/lwc/xml2js/*
-rw-r--r--  1 stomita  staff    233  3 16 06:46 force-app/main/default/lwc/xml2js/_commonjsHelpers-df867233.js
-rw-r--r--  1 stomita  staff  68645  3 16 06:35 force-app/main/default/lwc/xml2js/index-73cfb859.js
-rw-r--r--  1 stomita  staff  68423  3 16 06:46 force-app/main/default/lwc/xml2js/index-fe3e950c.js
-rw-r--r--  1 stomita  staff  74607  3 16 06:46 force-app/main/default/lwc/xml2js/sax-487c9c03.js
-rw-r--r--  1 stomita  staff     90  3 16 06:46 force-app/main/default/lwc/xml2js/sax.js
-rw-r--r--  1 stomita  staff  11787  3 16 06:46 force-app/main/default/lwc/xml2js/xml2js.js
-rw-r--r--  1 stomita  staff    210  3 16 06:25 force-app/main/default/lwc/xml2js/xml2js.js-meta.xml
-rw-r--r--  1 stomita  staff     97  3 16 06:46 force-app/main/default/lwc/xml2js/xmlbuilder.js

しかしそれでも1ファイルが大きなモジュールになるような場合は、importで使うのはあきらめてバンドルしたものを静的リソースでロードするのがいいのでしょう。

共通ライブラリの重複生成

ライブラリごとにバンドルファイルを生成してLWCコンポーネント化してしまうと、もしその中で共通している処理を含んでいるライブラリがあっても別々のものとして重複して使われてしまいます。

たとえば多くのライブラリでlodashmomentなどのライブラリが内部で使われていることが多いですが、LWCコンポーネントとして別々にしてバンドル化してしまうことで、これらの共通のライブラリはそれぞれ別々のコードとして利用されることになります。これは直ちに不具合といった影響が出るものではないものの、利用者のメモリ容量を不必要に圧迫するものであり、できるだけ避けたいものです。

これを避けるには、利用するnpmのライブラリをすべてまとめたコンポーネントを作成するという方法があります。具体的にはvendorLibのようなLWCコンポーネントをライブラリ用に作成し、以下のようなファイルを用意するようにします。

./path/to/vendorLib.js
import lodash from 'lodash';
import moment from 'moment';
import handlebars from 'handlebars';
...

export { lodash, moment, handlebars, ... };

ライブラリを利用するLWCコンポーネント側では、以下のようにしてc/vendorLibから名前付きでエクスポートされた内容をインポートするように書き換えます。

lwc/myElement/myElement.js
import { LightningElement } from 'lwc';
import { moment } from 'c/vendorLib';
import { handlebars } from 'c/vendorLib';

export default class MyElement extends LightningElement {
  //...
}

もちろんそのままだと生成されるvendorLib.jsのファイルサイズは大きくなってしまいますので、先のCode Splittingのテクニックを併用する必要があります。

Lightning Locker

忘れがちですが、Lightning Platform上のLWCはLightning Locker(旧Locker Service)の上で動作します。つまり通常のブラウザとは違う制約が働いています(特にDOM/BOM周り)。npm上で公開されているライブラリのほとんど、というより99.999999%はLocker Serviceのことなど意識していません。(残り0.000001%はSalesforceが提供しているOSSライブラリかもしれない?)。

つまり、利用したいライブラリがLWCでもちゃんと動くのか、またたとえ動いたとしても今後継続的に動くかどうかは誰も保証してくれません。結局それはアプリケーション開発者側が責任を持って確かめる必要があります。

とはいえ、だからといって車輪の再発明をするよりは大体の場合いいんじゃないでしょうかね、知りませんけど。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8