JavaScript Webアプリケーション開発におけるライブラリの利用
Lightning Web Components (以下LWC) では、通常のWeb Componentsの開発と同様にJavaScriptでコンポーネントを記述することになります。LWCが提供する機能やSalesforce Platformが提供する機能を利用するときには、ES Modulesで定められているであるimport
文を記述して利用を宣言します。
import { LightningElement, wire } from 'lwc';
import getUserInfo from '@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
ファイルでエクスポートされている関数をインポートします。
import { getTermOptions, calculateMonthlyPayment } from 'c/mortgage';
// ...
function getTermOptions() {
// ...
}
function calculateMonthlyPayment() {
// ...
}
export { getTermOptions, calculateMonthlyPayment };
この方法を利用すれば、LWCにおいてもimport
文でコンポーネント外部に定義されている機能をインポートして呼び出しできそうです。あとはnpmで提供されているライブラリをLWCコンポーネントに含めて定義してやればよいです。
npmのパッケージを(ライブラリとしての)LWCコンポーネント化する
たとえば先のluxon
をよびだすようなライブラリは、こんな感じで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';
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ファイルは単にライブラリをインポートして読み込んでそのままエクスポートするだけのことしかしません。
import handlebars from 'handlebars';
export default handlebars;
次に以下のようにrollup.jsのconfigファイルを記述し、先のファイルを入力として指定します。出力設定であるoutput
のdir
にライブラリとして読み込まれるLWCコンポーネントのディレクトリを指定し、format
にはES Moduleを出力できるよう "es" を指定します。
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
ファイルは前もって作成しておく必要あり)
あとはこのライブラリとしてのLWCコンポーネントをインポートするLWCコンポーネントを記述すればOKです。
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;
}
}
<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>
注意点・懸念事項など
とりあえず実用できそうではありますが、いくつか注意点・懸念事項があります。
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
ファイルも以下のように用意します。
import xml2js from 'xml2js';
export default xml2js;
import sax from 'sax';
export default sax;
import xmlbuilder from 'xmlbuilder';
export default xmlbuilder;
あとはrollup.config.js
の設定で入力としてxml2js.js
だけでなく上記のすべての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コンポーネント化してしまうと、もしその中で共通している処理を含んでいるライブラリがあっても別々のものとして重複して使われてしまいます。
たとえば多くのライブラリでlodash
やmoment
などのライブラリが内部で使われていることが多いですが、LWCコンポーネントとして別々にしてバンドル化してしまうことで、これらの共通のライブラリはそれぞれ別々のコードとして利用されることになります。これは直ちに不具合といった影響が出るものではないものの、利用者のメモリ容量を不必要に圧迫するものであり、できるだけ避けたいものです。
これを避けるには、利用するnpmのライブラリをすべてまとめたコンポーネントを作成するという方法があります。具体的にはvendorLib
のようなLWCコンポーネントをライブラリ用に作成し、以下のようなファイルを用意するようにします。
import lodash from 'lodash';
import moment from 'moment';
import handlebars from 'handlebars';
...
export { lodash, moment, handlebars, ... };
ライブラリを利用するLWCコンポーネント側では、以下のようにしてc/vendorLib
から名前付きでエクスポートされた内容をインポートするように書き換えます。
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でもちゃんと動くのか、またたとえ動いたとしても今後継続的に動くかどうかは誰も保証してくれません。結局それはアプリケーション開発者側が責任を持って確かめる必要があります。
とはいえ、だからといって車輪の再発明をするよりは大体の場合いいんじゃないでしょうかね、知りませんけど。