Rollupのビルド時間を10倍高速化することでプレビュー表示を改善した話

はじめに

こんにちは、Developer Experienceチームの @kazuponです。

この記事では、ノーコードエディタでRollupを使ったプレビュー表示機能において、Rollupによるビルド時間を高速化することでプレビューのパフォーマンス改善したのでそれについて紹介します。

プレイドは、KARTEというCX(顧客体験)を向上させるプロダクトを提供しており、Webサイトに訪問したユーザーの行動をリアルタイム解析し、一人ひとりに合わせた体験の提供を可能にします。具体的には、アクション(プロダクト内の用語は接客)を活用して自分たちのWebサイトに訪問したユーザーへポップアップやダイアログを提供し、可視化されたユーザーの行動内容を分析して施策実施をするといった継続的改善できるプロダクトです。

アクションは、アクションテンプレート(プロダクト内の用語は接客テンプレート)を編集してカスタマイズでき、KARTEの管理画面上で編集できます。アクションテンプレートの編集はHTML / CSS / JavaScriptなどWeb技術の知識がなくてもできるよう、「エレメントビルダー」というノーコードなエディタをベータ版(執筆時点)で提供しており、近々正式リリースに向けて開発しています。

エレメントビルダーのシステム構成

まず、この記事の内容を理解できるよう、エレメントビルダーのシステムについて簡単に説明します。

エレメントビルダーのシステム構成は以下の図のようになっています。

エレメントビルダーのシステム構成
(図をクリックすると拡大した図を見ることができます)

図に出現する要素の役割について以下のとおりです。

  • Browser Side (図中1): アクションをGUIで編集できるエディタ機能を持つアプリケーションを実行できる環境です
    • preview (図中1.1): エディタ内で編集したアクションを表示するJavaScriptモジュールです
  • Server Side (図中2):
    • API (図中2.1): ブラウザ側にアクションテンプレートのデータを提供、アクションテンプレートを保存するためのAPIエンドポイントです
    • asset-builder (図中2.2): エディタで編集したアクションをWebサイトに配信して実行できるようにするためのアクションビルド環境です
    • Database (図中2.3): アクションテンプレート (図中2.4) を格納するデータベースです
  • Skypack / esm.sh (図中3): ES Modules形式のJavaScriptモジュールを配信するCDNサービスです
  • Action Storage (図中4): asset-builderで単一のJavaScriptモジュールとしてビルドされたアクションをWebサイトに配信するための格納するストレージです

以降、この構成内容をベースに、エレメントビルダーでアクションを編集するエディタがどのように動作しているのか説明します。そして編集したアクションがWebサイトにどのように配信できるようにしているのか、これらについて簡単に説明します。

アクション編集時のエディタの動作

アクション編集時のエディタの動作について、図を用いて説明します。

エディタ立ち上がり時

以下の図は、ユーザーがエレメントビルダーのエディタにアクセスしたときのフローです。

エディタにアクセス時のフロー
(図をクリックすると拡大した図を見ることができます)

ユーザーがエディタを使い始めるとき(図中1)、ブラウザサイドで動作するエディタはサーバーサイドのデータベースからAPI経由(図中3)でアクションテンプレートをfetch (図中2) します。

アクションテンプレートのフォーマットはJSONで、以下のようなアクションのエントリポイントのコードや、 Svelteで実装されたアクションのコード、そしてアクションの設定やメタ情報などがシリアライズされたものです。

アクションテンプレートの中身
(図をクリックすると拡大した図を見ることができます)

サーバーからアクションテンプレートをfetchした後、エディタ内でブラウザのメモリ内に展開されて、フロントエンド側で状態管理されます。その際、Svelteで実装されたアクションのコードは、Svelte parserによってAST (図中5) に変換 (図中4) され、エディタ側でアクションをノーコードで編集できるようアプリケーションの状態の1つとして使用されます。

その後、以下の図のように、エディタ内でアクションのプレビューのレンダリング処理をします。

アクションのプレビューのフロー
(図をクリックすると拡大した図を見ることができます)

エディタは、previewのインターフェイスを介してアクションのプレビューを開始(図中6)します。previewは内部で持っているaction-compilerを使ってアクションをJavaScriptにコンパイル(図中7)します。 action-compilerは内部でRollupを使っており、previewのインターフェイスを介して渡されたアクションテンプレートをビルドします。

RollupはJavaScriptアプリケーション向けのモジュールバンドラーで、主にWebフロントエンドのアプリケーションをビルドするために利用されます。Rollupでバンドルする際、アクションテンプレートのコードで依存 (import) しているJavaScriptモジュールaction-sdkそしてsvelteは、 Skypack / esm.shからfetch (図中8) して解決します。 その解決は、 rollup-plugin-http-resolveというプレビュー専用に作ったHTTP経由で解決できるRollupプラグインです。通常Rollupを使ったバンドルは、Node.jsのような環境上で行われ、OSのファイルシステムを通してJavaScriptモジュールを読み込みますが、エレメントビルダーのエディタの環境はブラウザ上でバンドルする必要があるため、rollup-plugin-http-resolveによってSkypack / esm.shからJavaScriptモジュールを読み込めるようにしています。

なお、action-compilerでfetchしてきたaction-sdkとsvelteですが、Skypack / esm.shではNPMをバックエンドとして使っているため、実質NPMに登録されているJavaScriptパッケージ(モジュール)です。action-sdkはKARTEのアクションを作るためのSDKで、アクションのコードは、このSDKで公開されている JavaScript API やSvelteコンポーネントによって構成されており依存します。

action-compilerによって、アクションテンプレート(図中9)、そしてfetchしてきたaction-sdkとsvelteを使ってバンドルが完了すると、previewはaction-compilerによってバンドルされたJavaScriptをiframe内でアクションのレンダリング (図中10) し、エディタ側にマウント(図中11)されます。このとき、以降のアクション編集際のプレビューを更新するために、エディタ側とのプレビュー側の間でwindow.addEventListenerによるwindowメッセージのコネクションを張ります。

このエディタの立ち上がりのフローを通して、ユーザーがエディタでアクションを編集できる状態になります。

アクション編集時

エディタが立ち上がった後、エディタでアクションを編集したときのフローは以下の図になります。

アクション編集時のプレビューのフロー
(図をクリックすると拡大した図を見ることができます)

ユーザーによるエディタの操作によってアクションが編集(図中12)されると、エディタはASTを操作(図中13)してNode構造を更新します。その後、エディタはASTからSvelteのコードに変換 (図中14) して、アクションテンプレート側と同期します。

以降は、前に説明したpreview内のaction-compilerのコンパイルによるアクションのプレビューのレンダリングフローと同様のフローになりますが、立ち上がり時のフローと違うのは、action-compilerは依存しているaction-sdkそしてsvelteをfetchしないことです。

これはエディタの立ち上がり時に、rollup-plugin-http-resolve内部で、キャッシュしているため、キャッシュされたものを使う(図中15)ようにしています。また、プレビューに表示されているアクションは、action-compilerによってバンドルされたJavaScriptコードをwindowメッセージによってプレビュー側に渡します。そして、エディタにマウントされたiframe内でそのコードを評価してプレビューを再レンダリング(図中16)するようにしています。

アクションの Web サイトへの配信

エディタで編集したアクションがWebサイトにどのように配信されているのか、以下のフロー図で説明します。

アクションの配信フロー
(図をクリックすると拡大した図を見ることができます)

ユーザーがエディタでアクションを保存(図中17)すると、エディタはASTからSvelteのコードに変換 (図中18) します。それと同時に、アクションテンプレートとの同期とともにサーバーサイドのAPIにアクションテンプレートをHTTP POST (図中19) します。

HTTP POST後、アクションテンプレートをデータベースに書き込み保存(図中20)します。それとともに、asset-builderにbuildリクエスト(図中21)して、action-compilerを使ってAPIから受け取ったアクションテンプレート (図中22)、そしてSkypack / esm.shからfetchしてきたsvelteとaction-sdkを元 (図中23)にJavaScriptコードへコンパイル(図中24)します。このコンパイル処理は、エレメントビルダーのRollupを使ったブラウザサイドと同じです。

ビルド完了後、asset-builderはAction StorageにJavaScriptとして実行可能な状態になったアクションを、Action Storageにアップロード(図中25) します。なお、Action StorageはAmazon S3を使って配信されています。

エディタによるアクションの保存を契機に、アクションはasset-builderを通してJavaScriptにビルドされ、 Webサイトに設置されたKARTE経由で配信できる状態(図中26)になっています。

ビルド要件と最適化

これまでフローの説明で、エレメントビルダーのアクションは、エディタ上のプレビュー、そしてWebサイト向けへの配信に向けてaction-compilerを使ってアクションをビルドしていることが分かったと思います。

プレビュー向け、そして配信向けのアクションは、それぞれの環境でビルド要件として以下が求められます。

  • プレビュー向け
    • エディタ編集のUXを損なわないようアクションのビルド速度を最優先
  • 配信向け
    • 配信先のWebサイトのパフォーマンスが落ちないようアクションの実行速度を最優先

こうしたビルド要件に対して、それぞれの環境でこれまでのエレメントビルダーの開発ではaction-compilerをプレビュー向け、そして配信向けに以下の最適化をしています。

  • プレビュー向け
    • JavaScriptモジュールであるaction-sdkそしてsvelteをSkypack / esm.sh経由にすることでブラウザにロードするpreview自体の不要なコード削減
    • キャッシュによる不要なネットワークトラフィックの削減
    • Rollupで不要なコード変換処理の削減
    • Rollupのminify無効によるCPUコストの削減
    • Svelteハイドレーション機能によるレンダリングコストの削減
    • プレビュー再利用によるiframe生成コストの削減
  • 配信向け
    • ラインタイムレスなフレームワークであるSvelte採用によるアクション配信サイズの削減
    • Rollupのtree-shaking & minifyによるアクション配信サイズの削減
    • CDN配信によるアクションのロードパフォーマンスの高速化

こうしたプレビュー向け、そして配信向けに最適化されたaction-compilerを適切に使い分けてアクションをビルドしています。

エレメントビルダーのプレビュー表示課題

プレビュー表示の遅延

前節での説明で、プレビューと配信のそれぞれの要件に合うよう、ビルドの最適化を行いました。アクションのプレビュー表示はエレメントビルダーの開発プロジェクト初期のときより高速化できるようになりました。

しかしながら、エレメントビルダーのエディタは、PCのスペックが低い等の理由でブラウザのパフォーマンス低下しているような環境では、アクション編集時のプレビュー表示に時間かかることがあります。以下のgif動画は、アクションのボタンをドラッグ & ドロップの操作で移動させたときのものです。

遅延を感じさせるインタラクション

このgif動画では、ドラッグ & ドロップでボタンを移動させた後、プレビューに反映されるまで遅延があります。Web サイトの UX 指標を定義している Core Web Vitals では、インタラクションによるUIの遅延の指標であるINPの数値が、200 ms以下に抑えられているサイトは妥当で良好なサイトであると定義しています。gif動画のケースは、その閾値以内に抑えられていません。このため、改善が必要です。

プレビューの遅延時間の計測方法

エレメントビルダーのプレビューは先に説明したとおり、 iframeによって機能が実装されており、エディタ編集によるアクションのプレビューの反映は、エディタ側とプレビュー側のwindowメッセージパッシングで実現されています。Core Web Vitalsの指標数値INPの計測をDevToolsそしてJavaScriptライブラリで試みましたが、残念ながら計測できませんでした。

そこで、performance.markperformance.measure APIを使ってアクション編集時のプレビューのパフォーマンスを以下の3つの項目を定義して計測しました。

  • action:build: エディタでアクション編集後、action-compilerでアクションのビルドに用した時間
  • action:rendering: ビルドされたアクションをプレビューのiframe内で評価(eval)し、Svelteによってレンダリングに用した時間
  • action:rendered: iframe内でアクションが(再)レンダリングされた後、プレビューのホスト(エディタ) 側でプレビュー表示の後処理に用した時間

これら項目の数値の合計は、エディタ編集によるアクションのプレビューの反映までの時間であり、実質Core Web VitalsのINP (UIの遅延時間) を計測しているのと同じとみなすことができます。エレメントビルダーのプレビューは、これらの計測値を指標とすることで、パフォーマンス改善の目安としています。

以下のキャプチャは、DevToolsのPerformanceパネルで、独自に定義した項目で計測例です。

プレビューの遅延時間の計測
(図をクリックすると拡大した図を見ることができます)

一部計測できていない部分はありますが、Performanceパネルで計測されたframes内容と、計測した項目のタイムラインがほぼ一致しています。独自測定することによって、DevTools上ひと目でプレビューのパフォーマンスを確認できるようにしました。

現状のプレビューの遅延時間

前節で定義した項目を使って、以下の環境、条件の元、プレビューのパフォーマンスを測定しました。

  • マシンスペック:
    • マシンモデル: MacBook Pro 14-inch, 2021
    • チップセット: Apple M1 Max
    • メモリ: 64GB
  • Google Chromeバージョン: 131.0.6778.265 (Official Build) (arm64)
  • Google Chrome DevTools throttling
    • CPU: 4x slowdown
    • Network: Fast 4G

パフォーマンスの計測は上記条件にも書いてありますが、ユーザー近い環境にするため、throttlingを使ってCPUそしてネットワークを低速化して、パフォーマンスを計測しました。

計測した結果、以下の結果になりました。

  • action:build: 平均1,200ms
  • action:rendering: 平均210ms
  • action:rendered: 平均270ms
  • 計: 平均1,680ms

計測結果から、プレビュー表示遅延時間は平均1,680msかかっている事がわかりました。計測結果から分かるように、計測項目の中のaction:buildが専有しています。プレビューのパフォーマンスを改善は、action-compilerのビルド時間削減が、効果ありそうであることが分かりました。

action-compilerの処理内容の分析

action-compilerのビルド時間削減するために、action-compilerの処理内容を分析しました。分析したところ、エディタ編集の都度変わるアクションのコードだけでなく、変更がない以下の静的なJavaScriptモジュールも含めてRollupで毎回バンドルしていました。

  • action-sdk
  • svelte

以下の図はRollupによるバンドルプロセスの概要です。

Rollupのバンドルプロセス

Rollupは、Buildフェーズ(図中27)そしてOutput Generationフェーズ(図中28)、主にこれら2つのフェーズを通して、JavaScriptモジュールをバンドルします。(※RollupはCSSやpngといったアセットもバンドルできます。)

Buildフェーズでは、JavaScriptモジュールのエントリポイントからimport構文を解析してJavaScriptモジュールの依存関係をモジュールグラフ(Module Graph)として構築してきます。このときRollupは解決できた(resolveId)JavaScriptモジュールのコードを読み込み(load)、そのコードをJavaScript parserでASTに変換しモジュールグラフの該当モジュールに保持されます。その後、モジュールグラフそしてASTを元にtree-shakingを行うことで、使われていない不要なコードがバンドルされないよう最適化をします。

Output Generationフェーズは、Buildフェーズを通して構築されたモジュールグラフを元に、実際にコードをレンダリングしてバンドルします。このフェーズはダイナミックインポート(import())している場合は、コード分割(Code Splitting)してチャンク(Chunk)化できるようにようしたり、バンドルするコードへのフッター(footer)コード、ライセンス表記のようなバナー(banner)コードなどを挿入したり、バンドルコードに対するパフォーマンス最適化や生成するコードのカスタマイズなどの処理しているフェーズです。

action-compilerによるアクションのコンパイル処理は、Rollupのこの2つのフェーズによるバンドルに依存しています。アクション編集の都度、action-sdkそしてsvelteといったJavaScriptモジュールは毎回Rollupのバンドルプロセスで処理されてしまっている状態になっています。

Rollupといったモジュールバンドラは、先に説明したJavaScript parserによるコードのAST変換、モジュールグラフベースによるtree-shaking、そしてコードのminifyといったバンドル処理をしますが、これらの処理はCPUコストが高いです。エディタで変更が発生しない静的なJavaScriptモジュール郡に対して、バンドルプロセスを何らかの方法で最適化もしくは回避できると、action-compilerのビルド時間を削減に期待できます。

action-compilerのビルド時間削減案

action-compilerのビルド時間削減は、以下3点の案が考えられます。

  • 案1 action-sdk、svelteの事前AST変換
  • 案2 action-sdk、svelteの事前バンドル
  • 案3ビルド辞めて独自ASTでプレビューをレンダリングする

案1 action-sdk、svelteの事前AST変換

この案は、RollupがプラグインAPIで提供しているloadフックを利用したものです。

loadフックで返すオブジェクトに、読み込んだの文字列のコードやsourcemapだけでなく、ASTも指定すると、Rollup側はJavaScript parserでコードをASTに変換しません。 この案は、loadフックの仕様を利用したものです。

案2 action-sdk、svelteの事前バンドル

この案は、事前に、JavaScriptモジュールであるaction-sdkそしてsvelteをRollupでバンドルし、そのバンドルされたコードをプレビュー側の<script type="module">で事前にロードしておくというものです。

<script type="module">では、事前バンドルされたコードをプレビュー(iframe)側のwindowオブジェクトにロードし、プレビューで実行(レンダリング)されるアクションは、windowオブジェクトにロードされたものを使うようにします。

この案でアクションをプレビューさせる場合は、アクション側のimport { xxx } from '@plaidev/action-sdk'import { yyy } from 'svelte'のようなimport構文を、
const { yyy } = window.__preview__.svelteのようにwindowオブジェクト経由で処理されるようなコードに変換するRollupプラグインが必要になります。

案3 ビルド辞めて独自ASTでプレビューをレンダリングする

この案は、action-compiler(Rollup)によるビルドを辞めて、アクションのコードを独自ASTに変換して、プレビューでレンダリングするというものです。

プレビューの表示がなぜ遅いのか、根本原因としてRollupでビルドしているというのがあります。先に説明したように、RollupといったモジュールバンドラーはCPUコストが高い処理をします。

CPUコストが高いaction-compilerによるビルドを辞めて、代わりにASTベースでアクションをレンダリングすることでプレビューの表示を高速化しようというのが狙いです。

以下の図は、チーム内の議論で使用した図になります。

ASTベースによるプレビューのレンダリングの概要図
(図をクリックすると拡大した図を見ることができます)

ビルド時間削減案の考察と決定

これら案について検討した結果、案2の事前バンドルで進めることになりました。

ビルド時間削減効果が高い案として、案3のaction-compilerによるビルド自体を辞めることで、劇的に改善を期待できますが、案3はプレビューのためにレンダリングの仕組みを作り変える必要があるため、開発コストが高いです。そして、プレビューのアクションと配信されたアクションの差分によるバグを生み出しやすいというリスクがありあます。

残りの案1と案2、どちらの案もaction-compilerのビルド時間削減はできますが、削減効果が高いのは、案2の事前バンドルが高いと考えています。

案1はRollupのJavaScript parserによるAST変換コストは低減できます。しかしながら、依然としてモジュールグラフが構築され、それをベースにRollupのバンドル処理されます。案2は、事前にバンドルしたaction-sdk、svelteをプレビュー側で事前ロードして、Rollupのバンドルプロセス対象にしていないため、案1よりバンドル処理コストの低減を期待できます。

案2はアクションコード内のimport構文をwindowオブジェクト経由で処理するためのRollupプラグインによる変換が必要なりますが、どのみちアクションコードはRollupによってバンドル処理対象となっており、追加分のバンドル処理コストは微々たるものと考えられます。

なお、案3の独自ASTの案ですが、次のエレメントビルダーの要件を見据えて、案を発展させる形で現在私のOSSプロジェクトとして基盤となるものをR&Dの形で進めています。

ビルド時間を削減する実装詳細

action-compilerのビルド時間削減は、action-sdk、svelteの事前バンドルで決まったので実装します。

実装方針

実装方針としては、実装に伴う変更の影響でエディタ側の動作全般に影響させたくはないので、プレビュー内で使われているaction-compilerと別バージョンであるaction-compiler-v2を実装し、JavaScriptモジュール単位でaction-compilerを切り替えられるようにしています。

今回の実装に伴い、エディタにプレビュー機能を提供しているpreviewも別バージョンであるpreview-v2を作り、そのモジュールの中でaction-compiler-v2を使うようにしています。これにより、エディタ側でビルド時間最適化前のプレビュー、ビルド時間最適化後のプレビューを容易に切り替えられるようにしています。

プレビューの切り替え

実装は、エレメントビルダーの開発で、主にSvelte周りをサポートをメインにいっしょにお仕事させて頂いているSvelteコアチームのbaseballyamaさんにしていただきました。なお、今回の最適化案は、baseballyamaさんのアイデアによるものです。

下記のGitHubプルリクエストは、baseballyamaさんにaction-compiler-v2を実装していただいたときのプルリクエストになります。

プレビューのパフォーマンス改善PR

Rollupによる事前バンドル実装

action-sdkとsvelteの事前バンドルをRollupで実装したコードの抜粋を紹介します。

以下は、プレビュー向けと配信向けの環境向けの事前バンドルのRollupのコードです:

import terser from '@rollup/plugin-terser'

/**
 * `action-sdk`と`svelte`の事前バンドル
 * @param {{environment: 'node' | 'browser'}} options
 * @return {import('rollup').RollupOptions}
 */
const buildActionSdk = ({ environment }) => {
  const isNode = environment === 'node'
  const outputFile = isNode ? 'src/action-sdk.node.js' : 'src/action-sdk.browser.js'
  return {
    input: './src/action-sdk.ts',
    output: [
      {
        file: outputFile,
        format: 'esm'
      }
    ],
    plugins: [
      // `src/constants.ts`__IS_RUN_ON_BROWSER__`をBoolean値に置換する
      useActionCompilerReplacePlugin({ environment }),

      /**
       * ... 省略
       */

      // minify
      useTerserPlugin({ environment }),

      {
        name: `action-compiler-v2:action-sdk:${environment}`,
        generateBundle(_, bundle) {
          for (const file of Object.values(bundle)) {
            if (file.type === 'chunk' && file.name === 'action-sdk') {
              file.code = `export default ${JSON.stringify(file.code)};`
            }
          }
        }
      }
    ]
  }
}

/**
 * `__IS_RUN_ON_BROWSER__`の置換
 * @param {{environment: 'node' | 'browser'}} options
 * @return {import('rollup').Plugin}
 */
const useActionCompilerReplacePlugin = ({ environment }) => {
  return {
    name: `action-compiler-v2:replace`,
    transform: {
      order: 'pre',
      handler(code, id) {
        if (id.endsWith('src/constants.ts')) {
          // Node(配信)環境では`src/action-sdk.ts`でimportしたaction-sdkそしてsvelteを
          // `window.__preview__`にバインドする必要はない。
          // なので`__IS_RUN_ON_BROWSER__`フラグをfalseにすることで
          // `if (__IS_RUN_ON_BROWSER__)` 内のコードをバンドルしないようにする
          return code.replace(
            /['"]__IS_RUN_ON_BROWSER__['"]/,
            environment === 'node' ? 'false' : 'true'
          )
        }
        return code
      }
    }
  }
}

/**
 * `terser`プラグインによるminify
 * @param {{environment: 'node' | 'browser'}} options
 * @return {import('rollup').Plugin | undefined}
 */
const useTerserPlugin = ({ environment }) => {
  // Node(配信)環境はデバッグ容易性を優先して minify しない
  return environment === 'node' ? undefined : terser()
}

pluginsオプションは実際のコードでは、様々なRollupプラグインを設定していますが、説明簡略化のため必要部分のみを載せています。

useActionCompilerReplacePlugin関数は、action-compilerのsrc/constants.tsに定義された__IS_RUN_ON_BROWSER__フラグをBoolean値に置換するためのRollupプラグインです。このRollupプラグインの存在する意義は、以降で説明します。

useTerserPlugin関数は、バンドル対象となるコードをminifyするterserプラグインです。プレビュー環境ではaction-compilerのロードパフォーマンスを高めるために、minifyしています。配信(Node)環境では、minifyしません。

buildActionSdk関数は、action-sdkとsvelteの事前バンドルするRollup設定を構成する関数です。関数の引数には、バンドル対象先の環境を指定できるようになっています。'browser'が指定されると、プレビューが動作する環境であるブラウザ向けに事前バンドル(出力先はsrc/action-sdk.browser.js)できるようにしています。'node'の場合は、配信向けの環境であるサーバー上で動作するNode.js向けに事前バンドル(出力先はsrc/action-sdk.node.js)できるようにしています。

buildActionSdkで特筆するRollup設定は、pluginsオプションの最後に指定しているgenerateBundleを使ってコード生成を制御している事前バンドルするためのプラグインの部分です。generateBundleはRollupのOutput generationフェーズで実行されるフックであり、バンドル(生成)するコードに対してカスタマイズできるフックポイントです。このプラグインでは、バンドルするsrc/action-sdk.tsのコード(file.code)をJSON.stringifyで文字列にシリアライズして、export defaultしています。文字列でシリアライズしているのは、プレビュー側の<script type="module">で事前ロードするためです。

buildActionSdkでバンドル対象となるsrc/action-sdk.tsのコードは以下になります:

import * as actionSdk from '@plaidev/karte-action-sdk'
import * as actionSdkHydrate from '@plaidev/karte-action-sdk/hydrate'
import * as actionSdkIcons from '@plaidev/karte-action-sdk/icons'
import * as actionSdkTemplates from '@plaidev/karte-action-sdk/templates'
import * as svelte from 'svelte'
import * as svelteAnimate from 'svelte/animate'
import * as svelteEasing from 'svelte/easing'
import * as svelteInternal from 'svelte/internal'
import * as svelteMotion from 'svelte/motion'
import * as svelteStore from 'svelte/store'
import * as svelteTransition from 'svelte/transition'

import { __IS_RUN_ON_BROWSER__ } from './constants'

export {
  actionSdk,
  actionSdkHydrate,
  actionSdkIcons,
  actionSdkTemplates,
  svelte,
  svelteAnimate,
  svelteEasing,
  svelteInternal,
  svelteMotion,
  svelteStore,
  svelteTransition
}

if (__IS_RUN_ON_BROWSER__) {
  // action-sdk、svelteの内容を window.__preview__ にマッピング
  window.__preview__ = {
    actionSdk: actionSdkHydrate,
    actionSdkHydrate,
    actionSdkIcons,
    actionSdkTemplates,
    svelte,
    svelteAnimate,
    svelteEasing,
    svelteInternal,
    svelteMotion,
    svelteStore,
    svelteTransition
  }
}

src/action-sdk.tsでは事前バンドルするために、action-sdk(@plaidev/karte-action-sdk)、そしてsvelte(svelte)をimport & exportすることでbuildActionSdkによって事前バンドルできます。

このファイルで特筆すべき点としては、if (__IS_RUN_ON_BROWSER__)という条件分岐の部分で、importしたaction-sdk、そしてsvelteをwindow.__preview__にバインドしていることです。

__IS_RUN_ON_BROWSER__フラグの値は、先に説明したuseActionCompilerReplacePluginでプレビュー向け(引数environment'browser'のとき)は、trueに置換されます。このため、buildActionSdkで事前バンドルすると、プレビュー向けには__IS_RUN_ON_BROWSER__フラグの条件分岐内のwindow.__preview__にバインドするコードはバンドルされることになります。これにより、アクションコード内のimport構文を後に説明するRollupプラグインによるコード変換でwindow.__preview__に置換でき、アクションのプレビュー表示を実現できます。

こうして事前バンドルされたsrc/action-sdk.tsは、buildActionSdkによってプレビュー向けそして配信向けに用意されます。それぞれの環境に準ずる形で事前バンドルされたaction-sdk、そしてsvelteをaction-compilerに同包することで、JavaScriptモジュールとして提供しています。以下は、事前バンドルによる提供をイメージしたものです。

プレビュー向けと配信向けに事前バンドルされたaction-sdkとsvelte

アクションコードのimport構文を変換するRollupプラグインの実装

アクションコード内のimport構文を、<script type="module">による事前ロードによってwindow.__preview__にバインドされたaction-sdk、そしてsvelteを利用できるようコード変換するRollupプラグインの実装を紹介します。

以下は、そのコードの抜粋になります:

import LruMap from '../cache/lru-cache'

const importToVariableMap = {
  '@plaidev/karte-action-sdk': 'actionSdk',
  '@plaidev/karte-action-sdk/hydrate': 'actionSdkHydrate',
  '@plaidev/karte-action-sdk/icons': 'actionSdkIcons',
  '@plaidev/karte-action-sdk/templates': 'actionSdkTemplates',
  svelte: 'svelte',
  'svelte/animate': 'svelteAnimate',
  'svelte/easing': 'svelteEasing',
  'svelte/internal': 'svelteInternal',
  'svelte/motion': 'svelteMotion',
  'svelte/store': 'svelteStore',
  'svelte/transition': 'svelteTransition'
}

export const useTransformImports = () => {
  const cache = new LruMap(10)

  /**
   * ... 省略
   */

  const transformImports = code => {
    const cached = cache.get(code)
    if (cached) {
      return cached
    }

    const transformed = code.replace(
      /import\s+([\s\S]+?)\s+from\s+['"](.+?)['"]/gm,
      (match, imports, source) => {
        if (source in importToVariableMap) {
          imports = imports.replace(/\s+as\s+/g, () => ': ').replace(/\s*,?\s*}\s*$/, ' }')
          return `const ${imports} = window.__preview__.${importToVariableMap[source]}`
        }
        return match
      }
    )
    cache.set(code, transformed)

    return transformed
  }

  return { transformImports }
}

useTransformImportsはアクションコード内のimport構文をコード変換するRollupのtransformフックを返す関数です。

import構文は、code.replaceによってwindow.__preview__を介した変数定義のコードに変換するように実装しています。アクションコードでimport構文に指定されるModuleSpecifier (import { xxx } from 'yyy''yyy'の部分)をwindow.__preview__へのバインドは、importToVariableMapで定義されたマップを使って対応させています。

このtransformフックをプレビュー環境上のaction-compilerで使うことで、Rollupでプレビュー向けのアクションとしてバンドルされ、<script type="module">で事前ロードされたaction-sdk、svelteを使ってアクションをプレビューできるようになります。

ビルド時間最適化によるプレビューの遅延時間と考察

事前バンドルによるプレビューを実装したので、こちらと同じ条件・環境で計測しました。

計測した結果、以下の結果になりました。比較のため、ビルド時間最適化前の計測結果も載せてあります。

  • action:build: 平均110ms (最適化前: 平均1,200ms)
  • action:rendering: 平均80ms (最適化前: 平均210ms)
  • action:rendered: 平均270ms (最適化前: 平均270ms)
  • 計: 平均460ms (最適化前: 平均1,680ms)

計測結果から、プレビュー表示遅延時間は平均1,680msから平均460ms、約4倍に近い形の改善が分かりました。action-compilerのビルド時間であるaction:buildは、平均1,200msから平均110ms、約10倍を超える改善が分かりました。 DevToolsのthrottlingによる低速化がない環境では30倍近くまでビルド時間を改善できていることが分かりました。この結果から、action-sdk、そしてsvelteをRollupのビルドプロセス対象にしないことが効果大きいと分かりました。

action:renderingの時間も改善されていますが、これは、action-compilerでビルドされたアクションが、純粋にアクションのコードのみであるからです。

ビルド時間最適化前の従来のaction-compilerでは、ビルド時にsvelteやaction-sdkのコードをアクションのコードといっしょにバンドルしていますが、バンドルされているaction-sdkやsvelteのコードはプレビューの編集の都度、評価(eval)され、そしてレンダリングされていました。

事前バンドルによるビルド時間最適化では、action-sdk、svelteのコードは、<script type="module">による事前ロードですでに評価されており、action-compilerでバンドルされたアクションはそれらのコードが含まれていません。プレビューの編集に都度、評価されるコードはアクションのコードのみです。action:renderingの時間も改善できているのはそのためです。

まとめ

この記事では、action-compiler内部で使われているRollupのビルド時間を削減することでプレビューのパフォーマンスを改善について紹介しました。

前半では、エレメントビルダーのシステム構成、アクションを編集するときのエディタの動作、アクションのWebサイトへの配信の仕組みについて説明しました。また、エレメントビルダーのエディタのビルド要件、それに対するaction-compilerの最適化の取り組みも簡単に説明しました。

中盤では、エレメントビルダーの課題であったプレビューの表示遅延と遅延時間がどのぐらいあるか、計測項目を定義してプレビューのパフォーマンス測定しました。結果としてaction-compilerのビルド時間削減が効果高いと分かり、action-compilerの処理内容を分析しました。分析よりアクションの編集で変わることがない、action-sdkそしてsvelteといったJavaScriptモジュールは、アクションの編集の都度、毎回action-compiler内部で利用しているRollupでバンドル処理されていることが分かりました。

後半では、action-compilerのビルド時間を削減する方法について検討しました。その結果、事前バンドルすることでaction-sdkとsvelteをRollupのバンドルプロセスの対象から外すことで、ビルド時間削減を期待できることが分かりました。事前バンドルの案で実装して、定義した計測項目で計測したところ、Rollupのビルド時間は約10倍削減でき、そしてプレビューの遅延時間は4倍に近い形で改善できました。

今後の展望

エレメントビルダーは正式リリースに向けて、現在開発中ですが、まだまだ課題があります。

action-compilerのビルド時間関連の課題の1つとして、サーバーサイドでのアクションのビルドが時間かかるため、アクションを保存してから待たされるという課題があります。

このプレビューの課題に対して取り組んでいるさなか、昨年2024年末にRolldownのv1 beta.1がリリースされました。RolldownはRollupのインターフェイスの互換性を維持したRust実装された高速なモジュールバンドラで、Viteでも使われます。

action-compiler内部で使われているRollupをRolldownにリプレースすることで、ビルド時間関連の課題が解決できると期待できるため、Rolldownの導入を進める方向です。

Rolldown導入記事を執筆する予定なので、今後のブログ記事にご期待ください。

最後に

自分が所属するDXチームでは、開発者体験の向上やシステムのパフォーマンス向上を目的として活動しているチームです。

プロダクトの開発者体験が向上できるツールは新しいものでも積極的に使ったり、なければOSSでツールやライブラリを作ったりでき、OSS活動しつつも業務にコミットメントできるチーム環境です。

興味がある方は採用ページをご覧ください。自分といっしょに働きたい人、お待ちしております!