超軽量Reactive UIフレームワークVanJSをつかったkintoneカスタマイズ

## この記事が対象としている人

* kintoneのJSカスタマイズについての基本的な知識がある人
* ReactやVueなどのリアクティブなUIライブラリについて基本的な知識がある人
* Vanilla JSで軽いJSカスタマイズを書くこともあるけど、そういうときにReactとかは厳しいし、かといってJQuery使うのも嫌だし、なんかいいライブラリないかな…と思っている人

## VanJS

下記の記事で、VanJSというライブラリについて知りました（著者に感謝）。

https://zenn.dev/ryo_kawamata/articles/1ad6e51eed13ae

VanJSは超軽量で外部ライブラリに依存しない、unopinionatedなリアクティブなUIフレームワークで、Vanilla JSとDOMをベースにしています、とのことです（公式ページより）。

公式ページのHello Worldを読むと、JSXを使わずに`div`、`p`といったDOM要素を返す関数ですっきりコンポーネントを書けることが分かります。

```js
// 再利用可能なコンポーネントは単純なVanilla Javascriptの関数として書けます。
// Reactの規約に沿って最初の文字を大文字にしています。
const Hello = () => div(
  p("👋Hello"),
  ul(
    li("🗺️World"),
    li(a({href: "https://vanjs.org/"}, "🍦VanJS")),
  ),
)

van.add(document.body, Hello())
// document.body.appendChild(Hello())と書くこともできます。
```

下記のコードは、Reactの入門記事でよくありそうなシンプルなカウンターのサンプルです。

```js
const Counter = () => {
  const counter = van.state(0)
  return span(
    "❤️ ", counter, " ",
    button({onclick: () => ++counter.val}, "👍"),
    button({onclick: () => --counter.val}, "👎"),
  )
}

van.add(document.body, Counter())
```

「へー、宣言的なUIをVanilla JSでこんなにシンプルに書けるんだ。kintoneカスタマイズでも使えるかも」と思った（自分のような）方は、続きを読んでみていただければと思います。

## kintoneカスタマイズのサンプル

宣言的に書くうれしさのあるサンプルを考えたら、けっこう長いものになってしまった…

* カスタマイズのベースはアプリテンプレートの「日報アプリ」
* 一覧画面に開始日／終了日の入力フィールドを表示
* 日付が入力した開始日〜終了日のレコードを取得して表示
* 日付入力やレコードの表示にはkintone UI Componentを使用
* ページネーションつき

というカスタマイズを、一覧のカスタマイズビューに実装します。

![image1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102400/c48e9727-8d88-0fc5-1988-7e07ab204f4d.gif)

## カスタマイズの準備

* 「日報」アプリを新規作成します。
* カスタマイズ形式の一覧「DateRange」を作成します。
  * HTMLに`<div id="root" />` と入力します。
* JavaScriptカスタマイズに下記を設定します。
  * https://unpkg.com/@kintone/rest-api-client@latest/umd/KintoneRestAPIClient.js
  * https://unpkg.com/kintone-ui-component/umd/kuc.min.js
  * `van-1.1.0.nomodule.min.js`
    * https://vanjs.org/start#download-table からダウンロードして設定します。
      * ここでは`.nomodule`が付くファイルを使用してください。
        * kintoneカスタマイズを`type="module"`で動作させることができないためです。
          * できるようにしてほしいです → Cybozuさん
  * `list-query.js`
    * ここで作成するカスタマイズです。

## カスタマイズのコード

```js
// list-query.js

const { span, div, a } = van.tags
const LIMIT = 10

kintone.events.on('app.record.index.show', (event) => {
  if (event.viewName !== 'DateRange') {
    return event
  }

  const fromDate = van.state('')
  const toDate = van.state('')

  const fromDatePicker = new Kuc.DatePicker({
    label: '開始日',
    className: 'horizontal',
    onchange: (e) => (fromDate.val = e.detail.value),
  })
  const toDatePicker = new Kuc.DatePicker({
    label: '終了日',
    className: 'horizontal',
    onchange: (e) => (toDate.val = e.detail.value),
  })

  van.add(
    kintone.app.getHeaderMenuSpaceElement(),
    div({ style: `display: flex; gap: 16px; margin-left: 16px` }, fromDatePicker, toDatePicker)
  )

  const currentRecords = van.state([])
  const totalCount = van.state(0)
  const offset = van.state(0)
  const isLoading = van.state(false)

  van.derive(async () => {
    try {
      isLoading.val = true

      const conditions = [fromDate.val && `日付 >= "${fromDate.val}"`, toDate.val && `日付 <= "${toDate.val}"`].filter(
        Boolean
      )

      if (conditions.length === 0) {
        currentRecords.val = []
        offset.val = 0
        totalCount.val = 0
        return
      }

      const query = `${conditions.join(' and ')} limit ${LIMIT} offset ${offset.val}`
      const result = await new KintoneRestAPIClient().record.getRecords({
        app: kintone.app.getId(),
        query,
        totalCount: true,
      })
      currentRecords.val = result.records
      totalCount.val = result.totalCount
    } finally {
      isLoading.val = false
    }
  })

  const tableData = van.derive(() =>
    currentRecords.val.length > 0
      ? currentRecords.val.map((record) => recordToData(record))
      : [
          {
            日付: 'データなし',
            ドロップダウン: '',
            文字列__複数行_: '',
            文字列__複数行__0: '',
          },
        ]
  )

  const columns = [
    { title: '日付', field: '日付' },
    { title: '部署', field: 'ドロップダウン' },
    { title: '業務内容', field: '文字列__複数行_' },
    { title: '所感、学び', field: '文字列__複数行__0' },
  ]

  const Table = () =>
    new Kuc.ReadOnlyTable({
      actionButton: false,
      columns,
      data: tableData.val,
      style: { marginLeft: '16px' },
      rowsPerPage: LIMIT,
    })

  const Pagenation = () => {
    return totalCount.val > 0
      ? div(
          { style: `display: flex; gap: 8px;` },
          offset.val > 0 ? a({ onclick: () => (offset.val -= LIMIT) }, '<') : '<',
          offset.val < totalCount.val - LIMIT ? a({ onclick: () => (offset.val += LIMIT) }, '>') : '>',
          span(`${offset.val} - ${Math.min(totalCount.val, offset.val + LIMIT)}（${totalCount.val}件中）`)
        )
      : ''
  }

  van.add(document.getElementById('root'), div({ style: 'margin-left: 16px;' }, Pagenation, Table))

  const spinner = new Kuc.Spinner()

  van.derive(() => {
    if (isLoading.val) spinner.open()
    else spinner.close()
  })

  return event
})

const recordToData = (record) => {
  return Object.fromEntries(Object.entries(record).map(([code, field]) => [code, field.value]))
}

addStyle(`
    [class^="kuc-date-picker"][class$="__group"] {
      flex-direction: row;
      align-items: center;
    }
    
    [class^="kuc-date-picker"][class$="__group__label"] {
      padding: 0 8px 0 0;
    }
  `)

function addStyle(styleText) {
  const style = document.createElement('style')
  style.textContent = styleText
  document.head.appendChild(style)
}

```

…長いですね。すみません。要点を軽く説明してみます。

```js

  const fromDate = van.state('')
  const toDate = van.state('')

  const fromDatePicker = new Kuc.DatePicker({
    label: '開始日',
    className: 'horizontal',
    onchange: (e) => (fromDate.val = e.detail.value),
  })
  const toDatePicker = new Kuc.DatePicker({
    label: '終了日',
    className: 'horizontal',
    onchange: (e) => (toDate.val = e.detail.value),
  })

  van.add(
    kintone.app.getHeaderMenuSpaceElement(),
    div({ style: `display: flex; gap: 16px; margin-left: 16px` }, fromDatePicker, toDatePicker)
  )
```

ここで、開始日と終了日を格納するStateを用意しています。

`fromDatePicker`、`toDatePicker`はkintone UI ComponentのDatePickerを利用していますが、VanJSで通常のDOM要素のように扱えています。

DatePickerの`onchange`で入力値をStateに格納しています。Stateの読み書きは`val`プロパティから行うのがVanJSのお作法です。

```js

  const currentRecords = van.state([])
  const totalCount = van.state(0)
  const offset = van.state(0)
  const isLoading = van.state(false)

  van.derive(async () => {
    try {
      isLoading.val = true

      const conditions = [fromDate.val && `日付 >= "${fromDate.val}"`, toDate.val && `日付 <= "${toDate.val}"`].filter(
        Boolean
      )

      if (conditions.length === 0) {
        currentRecords.val = []
        offset.val = 0
        totalCount.val = 0
        return
      }

      const query = `${conditions.join(' and ')} limit ${LIMIT} offset ${offset.val}`
      const result = await new KintoneRestAPIClient().record.getRecords({
        app: kintone.app.getId(),
        query,
        totalCount: true,
      })
      currentRecords.val = result.records
      totalCount.val = result.totalCount
    } finally {
      isLoading.val = false
    }
  })
```

入力した開始日、終了日からクエリを作成してレコードを取得している部分です。

`van.derive`のところは、Reactの`useEffect`を連想した方も多いかと思いますが、関数をStateにBindするには、`van.derive`に渡す必要があります。

これで、`fromDate`、`toDate`が更新されるたびにクエリを作成してレコードを取得しなおし、レコードと件数をStateに格納されます。

あとは、Stateに格納されたレコードの値をkintone UI ComponentのReadOnlyTableを使って表示しています。

### `van.derive`について補足

自分は`van.derive`の理解に少し時間が掛かったので、ちょっと補足を。

さきほど記載したカウンターのサンプルで、`counter`を2倍にした`doubledCounter`という変数を定義するとします。

```js
const {button, div, span} = van.tags

const Counter = () => {
  const counter = van.state(0)
  
  // NG
  // const doubledCounter = counter * 2
  // GOOD
  const doubledCounter = van.derive(() => counter.val * 2)
  
  return div(
    span(
      "❤️ ", counter, " ",
      button({onclick: () => ++counter.val}, "👍"),
      button({onclick: () => --counter.val}, "👎"),
    ),
    span(
      "❤️ x 2 ", doubledCounter
    )
  )
}

van.add(document.body, Counter())
```

Reactだと、コンポーネント関数は再レンダリングのたびに実行されるので、`const doubledCounter = counter * 2`と書けるのですが、VanJSではこれはNGです。

以下、公式ドキュメントの`van.derive`の[説明](https://vanjs.org/tutorial#api-derive)を意訳してみました。「派生（derived）state」という概念に留意してください。

```txt
`van.derive`は、派生関数fに基づいた「派生（derived）Stateオブジェクト」を生成します。
　
「派生Stateオブジェクト」の`val`は、常にfの結果と同期しています。つまり、その依存性が変化するたびに、同期的にfが呼び出されて「派生Stateオブジェクト」の`val`が更新されます。
```

上記のサンプルでは、`counter.val`が変化するたびに、

* `() => counter.val * 2`が実行され、派生Stateの`doubledCounter`が更新される
* `doubledCounter`とbindされているspanが更新される

という動作になります。

## まとめ

いまのところ、VanJSの実戦投入例はまだひとつだけなのですが、今後、適用できるユースケースがあれば、積極的に使っていきたいと思っています。