🔨

kintone と EDITROOM の機能連携の実例解説: 自作プラグインを利用した連携4―開発環境に Svelte を導入する

2022/12/08に公開

目次

はじめに

アップデイティットの毛利です。

前回に引き続き、弊社の「EDITROOM」と kintone の自作プラグインを繋いで動作させるために作業を進めていきます。

前回はこちら:

作業

前回の記事で Vite の導入まで進めました。
今回は、思い切ってフロントエンドフレームワークの1つである Svelte を導入して、UI 実装で楽ができるようにしましょう。

また、今回は Svelte を利用しますが、 Vue でも React でも導入は難しくないでしょう。

本記事中の作業範囲

  1. Vite をベースに Svelte を導入
  2. 設定画面の UI を修正
  3. アプリ画面の UI を修正

なぜフロントエンドフレームワークを導入したいのか

筆者の見解として最も推したい理由は、「長期的なメンテナンス性の高さ」です。

フレームワークごとに御作法はあるものの、基本的には JavaScript に集中して開発が出来ます。これは開発規模が大きくなっていくにつれ、 JavaScript のことだけを考えれば良いことが大きく効いてきます。
加えて、フレームワークの仕組みに則って正しくコンポーネント分轄ができれば、変更や追加をしやすい点も強みになります。
一方、しばしば導入メリットで語られる「レスポンシブ対応が簡単になる」という点は、kintone プラグイン開発の性質上、効果を感じられる機会は少ないでしょう。

もう少し具体的な話をします。
現在、アプリ画面(desktop.js)では、次のような DOM 操作をするコードが存在しています。

const fragment = document.createDocumentFragment();
const elem_p = document.createElement("p");
elem_p.classList.add("kintoneplugin-row");
const elem_button = document.createElement("button");
elem_button.innerHTML = "REST API FIRE";
elem_button.classList.add("kintoneplugin-button-dialog-ok");
elem_p.appendChild(elem_button);
fragment.appendChild(elem_p);
spaceElement.appendChild(fragment);

このコードからどういったタグ構造が生成されるか、ぱっと見で不明です。
各箇所にこのようなコードが散見されるプロジェクトでは、修正作業が入るたびに時間を持っていかれるのは想像に難くないです。

これが、 Svelte を導入した場合、次のような記法になります。

<main>
  <p class="kintoneplugin-row">
    <button disabled={buttonDisabled} on:click={handleClick} class="kintoneplugin-button-dialog-ok">REST API FIRE</button>
  </p>
</main>

こちらは HTML を模した記述になっているため、一目瞭然です。
(ちなみに、React や Vue でも似たような構造で記述が可能です。)

このあたりの不自由さを解決する手段として、フロントエンドフレームワークを導入する。これが、長期的に有益な結果をもたらします。

一方、kintone テンプレートが提供した構成に毛が生えた程度の改修、または作り捨てを前提にしているのであれば、無理に導入する必要もないです。
このあたりの判断は、やりたい規模に沿って判断してください。

なぜ Svelte を?

React、Vue と有名どころがありますが、今回の簡単なケースでは、Svelte でやっても遜色なく、かつバンドルサイズを削減できるメリットが大きいため、採用しました。

実際に手直しを入れる中で感じて欲しいところですが、今回のようにすでに動く html/js の環境があり、かつ「あるフレームワークに依存した特殊なプラグイン」を使って描画したいものがない場合、移植を試みる際には非常に頼もしい存在となります。

React や Vue は、 大規模開発で非常に強力な存在となりますが、今回のように画面が決まっていてかつルーティングするまでもないシンプルなケースでは、Svelte の方がより得意な領域、という話ですね。
もちろん、その道の有識者がチームにいれば、そちらを採用する方が安心でしょう。

開発

1. ライブラリの導入

まずは、 Svelte を導入します。

Svelte は、 Vite をベースとしたテンプレートプロジェクトが存在します。
公式を参考に、テンプレートプロジェクトを呼び出して、どのライブラリが依存しているか確認してみます。

> npm create vite@latest svelte_template_prj -- --template svelte-ts

上記結果の package.json をみると、次のようなプラグインが入ってました。

package.json(svelteの初期テンプレートの方)
{
    ...(中略)
    "devDependencies": {
        "@sveltejs/vite-plugin-svelte": "^1.1.0",
        "@tsconfig/svelte": "^3.0.0",
        "svelte": "^3.52.0",
        "svelte-check": "^2.9.2",
        "svelte-preprocess": "^4.10.7",
        "tslib": "^2.4.0",
        "typescript": "^4.6.4",
        "vite": "^3.2.3"
    }
}

typescript, vite は既に導入されているので、それ以外を入れる必要があるようです。

> npm i -D @sveltejs/vite-plugin-svelte @tsconfig/svelte svelte svelte-check svelte-preprocess tslib

入れ終わったら、次は、 package.json を修正します。
scripts に check というコマンドが存在するため移植します。

package.json(自身のプロジェクトの方)
{
    ...(中略) 
   "scripts": {
        ...(中略)
        "check": "svelte-check --tsconfig ./tsconfig.json"
   },
    ...(中略)
}

次に、 tsconfig.json に設定を追加します。
先頭にコピペして行追加するだけです。

tsconfig.json
{
    "extends": "@tsconfig/svelte/tsconfig.json",
    ...(中略)
}

次に、Vite に Svelte を認識させるために vite.config.ts を修正します。

vite.config.ts
import { svelte } from '@sveltejs/vite-plugin-svelte'
...(中略)

export default defineConfig({
    ...(中略)
    plugins: [svelte()],
})

次に、 svelte.config.js を移植します。
この際、ビルドを通す関係で、拡張子を「js」ではなく「mjs」としてください。

svelte.config.mjs
import sveltePreprocess from "svelte-preprocess";

export default {
  // Consult https://github.com/sveltejs/svelte-preprocess
  // for more information about preprocessors
  preprocess: sveltePreprocess(),
};

次に、 src/vite-env.d.ts を移植します。

src/vite-env.d.ts
/// <reference types="svelte" />
/// <reference types="vite/client" />

まず、この状態で、 Vite のビルドが通るか確認します(npm run build)。
問題なく通ったら、最初の導入は成功しました。ですが、まだ Svelte のファイルを導入できていないので、引き続き進めます。

2. config の UI(html) を Svelte に移植する

次の修正を入れます。

  • svelte に html の元々の構造を移植
  • html と TypeScript を svelte 用に修正.

初学者向け解説

まず、 Svelte のテンプレートに入っている index.html と src/main.ts をみると次のような記述があります。

index.html(from Svelte template)
<!DOCTYPE html>
<html lang="en">
    ...(中略)
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
src/main.ts(from Svelte template)
import './app.css'
import App from './App.svelte'

const app = new App({
  target: document.getElementById('app')
})

export default app

「id=app の div タグ」に対して、「App」という何かが処理をしかけているように見えます。
実際にこの画面が描画された時ののタグ構造をみてみましょう。

先ほどの div タグの直下に svelte で作られた UI が展開されます。
この仕組みを理解すれば、 config.html に svelte を導入するのも簡単ですね。

実装

早速、 svelte ファイルを導入していきましょう。

最初に、アップロードする html ファイルを修正します。
基本的に、 div タグ1つあれば事足りるため、そのように変更します。

static/html/config.html
<div id="config_svelte"></div>

次に、src 側を修正していきます。
ファイル数が少し増えますので、次のようなディレクトリ構成にしていきます。

  • src
    • config.ts
    • desktop.ts
    • [DIR]config
      • ConfigUi.svelte
    • [DIR]desktop
      • (後ほど)

ビルドの起点は config.ts と desktop.ts なので、そこは変えないようにしつつ、それぞれのディレクトリを用意してその中に新規ファイルを追加していきます。

まず、ConfigUi.svelte に対して config.html で元々あったタグを全部移植します。

src/config/ConfigUi.svelte
<script lang="ts">
</script>

<main>
  <section class="settings">
    <h2 class="settings-heading">Settings</h2>
    <form class="js-submit-settings">
      <p class="kintoneplugin-row">
        <label for="message">
          REST API URL:
          <input id="api_url" type="text" class="js-text-message kintoneplugin-input-text" />
        </label>
      </p>
      <p class="kintoneplugin-row">
        <label for="message">
          REST API KEY:
          <input id="api_key" type="text" class="js-text-message kintoneplugin-input-text" />
        </label>
      </p>
      <p class="kintoneplugin-row">
        <button class="kintoneplugin-button-dialog-ok">Save</button>
      </p>
    </form>
  </section>
</main>

<style></style>

次に、 config.ts の処理は一旦消して、Svelte ファイルを描画する指示だけにします。

src/config.ts
import ConfigUi from "./config/ConfigUi.svelte";

const configApp = new ConfigUi({
  target: document.getElementById("config_svelte"),
  props: {
    kintone_plugin_id: `${kintone.$PLUGIN_ID}`,
  },
});

export default configApp;

この3点を修正した後、一度 kintone にビルド結果をアップロードしてみましょう。

ぱっと見た感じ、html で作ったものと見た目が全く変わらないです。
――ですが、F12 で構造をみると、ちゃんと Svelte で作られていることが確認できます。

ただ、まだ移植は未完成で、テキスト入力のところに前回の値が入っていないうえに、「保存」を実行するとエラーになります。
(もちろん、さきほど処理を全消ししたので、当たり前ですが...)

3. config の JS 部を Svelte に移植する

UI の移植は成功したため、次は kintone と疎通している JavaScript の処理の箇所を分離しながら移植していきます。

元々、 config.ts では次のような処理を行っていました。

  1. 設定画面の各入力部に、前回の値を置く。
    • kintone から最新の設定値を取ってくる。
    • データを置くタグを class や id から判定する
    • タグがあれば、値を置く。
  2. 保存のボタンを押したときのイベントを登録。
    • 前述の処理でタグの位置は把握しているので、現在の値を抽出する。
    • それら値をまとめて kintone に保存する。

さて、これらの処理を Svelte のファイルで実施していきます。
Svelte には、 UI 描画時に使用できる TypeScript を記述する領域が用意されています。
この仕組みを使うことで、テンプレートエンジンのような書き方で UI や変数を制御できます。

src/config/ConfigUi.svelte
<script lang="ts">
  import "@shin-chan/kypes";
  export let kintone_plugin_id: string;
  //
  const config = kintone.plugin.app.getConfig(kintone_plugin_id);
  let { api_url, api_key } = config;
  //
  const handleClick = (event: Event) => {
    event.preventDefault();
    kintone.plugin.app.setConfig({ api_url, api_key }, () => {
      alert("The plug-in settings have been saved. Please update the app!");
      window.location.href = "../../flow?app=" + kintone.app.getId();
    });
  };
</script>

<main>
  <section class="settings">
    <h2 class="settings-heading">Settings</h2>
    <form class="js-submit-settings">
      <p class="kintoneplugin-row">
        <label for="message">
          REST API URL:
          <input bind:value={api_url} type="text" class="js-text-message kintoneplugin-input-text" />
        </label>
      </p>
      <p class="kintoneplugin-row">
        <label for="message">
          REST API KEY:
          <input bind:value={api_key} type="text" class="js-text-message kintoneplugin-input-text" />
        </label>
      </p>
      <p class="kintoneplugin-row">
        <button on:click={handleClick} class="kintoneplugin-button-dialog-ok">Save</button>
      </p>
    </form>
  </section>
</main>

<style></style>

前回まで使用していた TypeScript のコードでは、まず、html に該当のタグがあるかどうかを探索する処理が必要でした。
今回は、Svelte でタグも制御しているため、探索の処理は不要になっています。

次に、設定値ですが、「bind:value」を使うことで、 1 つの変数管理で完結させることができています。便利。

最後に、ボタン押下時の保存処理は、addEventListener を使うことなく、「on:click={handleClick}」として関数を紐づけて完了です。

この状態で、再度プラグインをアップロードして、保存の処理まで問題なく動作するのを確認できたら、完成です。

4. desktop.ts を Svelte化する (そして頓挫)

先ほどのやり方を踏襲して、アプリ部の挙動も Svelte 化していきます。

こちらは html 部がなく、UI 構成もすべて JavaScript 領域で構成されているため、UI と処理を分離するため、少し手直しが必要になります。

desktop.ts
import "@shin-chan/kypes";
import DesktopUi from "./desktop/DesktopUi.svelte";

const main = (kintone_plugin_id: String) => {
  kintone.events.on(["app.record.index.show"], (_event) => {
    const spaceElement = kintone.app.getHeaderSpaceElement();
    if (!spaceElement) throw new Error("The header element is unavailable on this page");
    new DesktopUi({
      target: spaceElement,
      props: {
        kintone_plugin_id,
      },
    });
  });
};

((PLUGIN_ID) => main(PLUGIN_ID))(`${kintone.$PLUGIN_ID}`);
desktop/DesktopUi.svelte
<script lang="ts">
</script>

<main>
  <div>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</div>
</main>

<style></style>

DesktopUi.svelte の中身は割愛して、まずはプラグインをアップロードして動作を確認してみたいと思います。
―― > Uncaught SyntaxError: import declarations may only appear at top level of a module

どうやら、ビルド結果が kintone の仕様にそぐわない状態になってしまったようです。

問題1. 分轄した JS は利用できない

さて、お気づきかと思いますが、desktop.js を Svelte 化してビルドしたら index.js というファイルが余計に結果に含まれていることを確認できます。
これのせいです。

Svelte + Vite の組み合わせは非常に優秀で、生成ファイルとして分轄が可能なものは積極的に分轄してくれます。
そのため、通常のブラウザ用に開発した場合はこれで最適解となるのですが、今回はこの優秀さに引っかかってしまいました。

これまでは、config の方だけ svelte ファイルがあったため、Svelte に関する共通箇所がなく、単一の js が吐き出されていました。
今回、desktop を Svelte 化することで、Svelte 用の一部処理が共通のものと認識され、生成ファイルが分轄されていました。

kintone-editroom-plugin     2   63.9ms  32.0ms
dist/out/js/desktop.js              0.56 KiB / gzip: 0.35 KiB
dist/out/js/config.js               1.85 KiB / gzip: 0.94 KiB
dist/out/assets/index.8ca5b40e.js   2.92 KiB / gzip: 1.35 KiB
built in 663ms.

また、static/manifest.json には分轄されたファイルを記述していませんので、そもそもファイルが存在せず動かないのですが、それ以前に kintone 側の制約でコード内部に「import」が残っていることでエラーとなっています。

試しに、vite.config.ts で manualChunks を有効にし、強制的に 1 つの JS になるようバンドルを試みましたが、エラーは改善されませんでした。
(Dynamic Import に書き換える方法も一応試しましたが、前述の制約があるので、効果はありませんでした。)

問題2. 各Outputごとにビルド結果を独立にできない(Viteの仕様)

誤解のないよう何度も書きますが Vite は非常に優秀です。
積極的にコード分割してくれる優秀さがある一方で、今回のように「アウトプットの JS それぞれに完全独立して欲しい」ケースに対しては、適切な解決策を見つけられませんでした。
(有識者がいれば解決策を教えてほしいです..)

一応、強制的に1つの JS に落とし込んでくれるライブラリモードも存在しますが、マルチエンドポイントに未対応なので、今回のケースは対象外でした。

――そのため、力技で解決します。

5. (力技) Vite のビルドプロセスを2つにする

最終手段として、ビルド環境そのものを2つにして、それぞれ config と desktop をビルドします。
そもそもビルドプロセスを別にすれば、それぞれに Svelte は単一となるため、前述の問題をすべて解決します。

ですが、開発環境ごと分離すると非常にややこしくなるので、落としどころを探ってみます。

公式のコマンドヘルプをまず見てみましょう。

> npx vite --help
vite/3.2.4

..(中略)

Options:
  --host [host]           [string] specify hostname
  --port <port>           [number] specify port
  --https                 [boolean] use TLS + HTTP/2
  --open [path]           [boolean | string] open browser on startup
  --cors                  [boolean] enable CORS
  --strictPort            [boolean] exit if specified port is already in use
  --force                 [boolean] force the optimizer to ignore the cache and re-bundle
  -c, --config <file>     [string] use specified config file
  --base <path>           [string] public base path (default: /)
  -l, --logLevel <level>  [string] info | warn | error | silent
  --clearScreen           [boolean] allow/disable clear screen when logging
  -d, --debug [feat]      [string | boolean] show debug logs
  -f, --filter <filter>   [string] filter debug logs
  -m, --mode <mode>       [string] set env mode
  -h, --help              Display this message
  -v, --version           Display version number

-c, --config <file> [string] use specified config file

これを使えば、コンフィグファイルだけを切り分けてビルドがいけそうですね。

vite_config.config.ts
...中略
export default defineConfig({
    ...中略
  build: {
    ...中略
    emptyOutDir: false,
    ...中略
    rollupOptions: {
      input: {
        config: `${path.resolve(root, "src/config.ts")}/`,
      },
      output: {
        format: "module",
        preserveModules: false,
        manualChunks: { config: [`${path.resolve(root, "src/config.ts")}/`] },
        entryFileNames: "js/[name].js",
        chunkFileNames: "js/[name].js",
      },
    },
  },
    ...中略
});
  
...中略

上記の設定にした vite_config.config.ts と、全く同じやり方でコピぺ修正した vite_desktop.config.ts をそれぞれ配置します。
manualChunks は設定なくても良いはずですが、念のためおまじないとしておきます。(おそらくライブラリモードでビルドする方がより望ましいはずですが、今回はこのままいきます。)
public ファイルのコピーが重複するのは、たいしたことではないので一旦無視します。
ビルド先のクリーンは、互いの結果を消しあってしまうので、false にします。

次に、package.json を修正します。
さきほどのファイルをターゲットにそれぞれコマンドを用意・修正します。

package.json
...中略
  "scripts": {
    "start": "node scripts/npm-start.js",
    "develop_vite_config": "vite build -c vite_config.config.ts --watch",
    "develop_vite_desktop": "vite build -c vite_desktop.config.ts --watch",
    "build": "vite build -c vite_config.config.ts && vite build -c vite_desktop.config.ts && kintone-plugin-packer --ppk private.ppk --out dist/plugin.zip dist/out",
    ...中略

次に、npm-start.js も変更を入れます。

npm-start.js
...(中略)
runAll(["develop_vite_config", "develop_vite_desktop", "develop_pack", "upload"], {
...(中略)

無事、aaaa... を表示させることができました。

あらためて、 DesktopUi.svelte を修正していきます。

6. desktop 側の UI を前回状態まで修正

DesktopUi.svelte
<script lang="ts">
  import "@shin-chan/kypes";
  export let kintone_plugin_id: string;
  let buttonDisabled: boolean = false;
  //
  const config = kintone.plugin.app.getConfig(kintone_plugin_id);
  let { api_url, api_key } = config;
  //
  const handleClick = (event: Event) => {
    event.preventDefault();
    buttonDisabled = true;
    //
    const header = { Accept: "application/json", "X-EDITROOM-API-KEY": api_key, "Content-Type": "application/json; charset=UTF-8" };
    const body = JSON.stringify({ docflow_name: "docflow0001", export_type: "html" }, null, 0);
    kintone.proxy(
      api_url,
      "POST",
      header,
      body,
      (body, status, headers) => {
        // success
        console.log(status, JSON.parse(body), headers);
        alert("sent.");
        buttonDisabled = false;
      },
      (error) => {
        // error
        console.log(error); // proxy APIのレスポンスボディ(文字列)を表示
        alert("send error.");
      }
    );
  };
</script>

<main>
  <p class="kintoneplugin-row">
    <button disabled={buttonDisabled} on:click={handleClick} class="kintoneplugin-button-dialog-ok">REST API FIRE</button>
  </p>
</main>

<style></style>

今回、一番手を入れたかった箇所が Svelte の力でかなり改善されました。

表示される内容も、問題なしです。
最終的にプロジェクト構造はこうなりました。

お疲れ様でした。

所感

今回の対応で、フロントエンドフレームワークを導入するメリットを感じることは出来たのではないでしょうか。

単純な HTML ベースの開発ではなく、お好みのフレームワーク、または会社として得意とするフレームワークで開発を進めていけることは、開発効率だけでなくモチベーションの維持にも繋がります。
ぜひトライしていただければと思います。

また、今回のやり方を理解していれば、React や Vue でも同様の手法で導入することは容易でしょう。
ただ、途中ビルドが大ゴケした箇所は、 Vite と kintone の相性問題のため、他のフレームワークでも再現される可能性があります。有識者による解決策が望まれますね。

ちなみに、まだまだ若手の Svelte。kintone プラグイン開発との相性は非常に良いので、ぜひ活用してい欲しいところです。

次は、 React のパターンも紹介できればと思います。

おわりに

再度の紹介となりますが、先日、弊社から「EDITROOM」という BtoB 向けの文書作成クラウドサービスをリリースしました。
特に、定期的に文書を作る人的コストや作業にかかる拘束時間の長さを改善するのに、このサービスが大きな一助になると自負しております。

もし、ご興味がありましたら、トライアルをご利用いただければ幸いです。

次回

https://zenn.dev/update_it_inc/articles/86585308d6d8dd

Discussion