kintone hack & show+case unlimited 2022 - シン・kintone 検索カスタマイズの技術面の説明

 kintone hack & show+case unlimited 2022 にて発表させてもらった シン・kintone 検索カスタマイズ の技術的な説明エントリです。

 ソースコードプラグインを公開してよ、という声が聞こえてきそうですが、何だかんだのコード量でメンテナンスやサポートが必要な内容になったので、ひとまずポイントを共有させてもらおうと思います。

 機能やスタイルを絞れば実装も難しくないので、参考にして頂ければ幸いです。

最大のポイント - IndexedDB の活用

 IndexedDB を知っていたことと、これを検索に使ってみようというアイディアが今回の hack ポイントです。

 IndexedDB API - Web APIs | MDN

 ブラウザにデータを保存する方法のひとつで、JavaScript でハンドリングできるので、kintone との相性が良いです。

 IndexedDB に事前取り込みしておくことによって、"高速な検索が可能になる"、"API のコール数を圧倒的に減らすことができる" 等のメリットがあります。セキュリティに関する考察含めて kintone hack 予選スライドで説明していますので、詳細はこちらをご覧ください。

その他の技術スタックや利用したライブラリ等

package.jsondependenciesdevDependencies を載せておきます。

"devDependencies": {
  "@cybozu/eslint-config": "^17.0.3",
  "@jgoz/esbuild-plugin-typecheck": "^2.0.0",
  "@kintone/dts-gen": "^6.1.10",
  "@types/autosuggest-highlight": "^3.2.0",
  "@types/lodash.debounce": "^4.0.7",
  "@types/luxon": "^3.0.1",
  "@types/node": "^18.8.2",
  "@types/react": "^18.0.21",
  "@types/react-dom": "^18.0.6",
  "esbuild": "^0.15.10",
  "eslint": "^8.24.0",
  "prettier": "^2.7.1",
  "typescript": "^4.8.4"
},
"dependencies": {
  "@emotion/react": "^11.10.4",
  "@kintone/rest-api-client": "^3.1.12",
  "autosuggest-highlight": "^3.3.4",
  "lodash.debounce": "^4.0.8",
  "luxon": "^3.0.4",
  "react": "^18.2.0",
  "react-dom": "^18.2.0"
}

簡単に用途等を説明します。

React, TypeScript

 まず、今回ベースに React & TypeScript を使いました。スタイルは Emotion 経由であてました。

 トランスパイルやファイルバンドルのためのビルダーは esbuild を使ってみました。型チェックには esbuild-plugin-typecheck というプラグインがあったので、これを使いました。

@kintone/rest-api-client

 実は今回は内包されている型定義を使うためだけに利用しています。Chrome Extension での利用も想定したいたのと、API の呼び出しは GET メソッドしか使わないので、必要な API 呼び出しメソッドは自前で fetch ベースで準備し直しました。あとは、rest-api-client がカバーしてない User API を使う可能性もあったためです。

lodash.debounce

 検索キーワード入力に合わせた検索ロジックの発火を、文字の入力単位ではなく、単語単位で起こす役割を担ってくれます。

autosuggest-highlight

 検索データに対してハイライトする部分をこれで実現しています。キーワードと一致した部分を今回は太字にして、どの部分がマッチしたのか分かりやすくしています。こんな感じです👇

ESLint, Prettier 関連

 文字通りです。@cybozu/eslint-config をベースに prettier 含めて設定しました。

ビルドの実行ファイル

import { build } from 'esbuild';
import { typecheckPlugin } from '@jgoz/esbuild-plugin-typecheck';

await build({
  entryPoints: ['./src/ts/customize.tsx'],
  outfile: './dist/js/customize.js',
  plugins: [typecheckPlugin()],
  bundle: true,
  minify: false,
  target: 'es2020',
  jsx: "automatic",
  jsxImportSource: "@emotion/react",
  watch: {
    onRebuild(error, result) {
      if (error) {
        console.error('watch build failed:', error);
      } else {
        console.log('watch build succeeded:', result);
      }
    },
  }
}).then(result => {
  console.log('watching...')
}).catch(() => process.exit(1));

コードの説明

 ポイントになる部分のコードをピックアップして説明したいと思います。

IndexedDB からのレコード取得

 IndexedDB のレコード取得は、カーソルスタイルです。順次レコード取得をして、途中で中断するようなことはできません。IDBCursor - Web APIs | MDN のページを見ながら、最終的に次のようなメソッドを準備しました。

 カーソルごとにレコードを都度 callback で取得できるようにしつつ、全件取得の結果を Promise 経由でメソッドの返り値とするようにしました。

export const getStoreRecordsWithCursor = <T extends IDBRecord>(
  db: IDBDatabase,
  storeName: string,
  direction: IDBCursorDirection,
  callback?: (arg: any) => any
) => {
  const transaction = db.transaction(storeName, 'readonly');
  const store = transaction.objectStore(storeName);
  const request = store.openCursor(null, direction);
  let results: T[] = [];
  return new Promise<T[]>(resolve => {
    request.onsuccess = event => {
      const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
      if (cursor) {
        Promise.resolve()
          .then(() => {
            if (callback !== undefined) {
              return Promise.resolve(callback(cursor)).then(result => {
                if (result) {
                  results = [...results, result];
                }
                return results;
              });
            }
            results = [...results, cursor.value];
            return results;
          })
          .then(() => cursor.continue());
      } else {
        resolve(results);
      }
    };
  });
};

全件取得からのフィルタリング

 検索条件にマッチしたレコードを抽出する部分です。

 先程の getStoreRecordsWithCursor を使います。カーソルでレコードを都度取得する方法をとりました。

 matchingMethod は次の 3 つのメソッド名そのものです。

  • includes: 中間一致
  • startsWith: 前方一致
  • endsWith: 後方一致
type MatchingMethod = 'includes' | 'startsWith' | 'endsWith';

const defaultFilter = (
  input: string,  // 検索キーワード
  record: IDBRecord,  // カーソルで取得した IndexedDB レコード
  searchableFields: string[],  // 検索対象フィールドのフィールドコード(配列)
  matchingMethod: MatchingMethod  // 検索時の照合方法
) => {
  const codes = searchableFields.filter(code => {
    const value = record[code];
    return typeof value === 'string' && value?.toLowerCase()[matchingMethod](input?.toLowerCase());
  });
  return codes.length > 0 ? { ...record, $label: codes } : null;
};

export const searchRecords = (
  db: IDBDatabase,
  input: string,
  searchableFields: string[],
  matchingMethod: MatchingMethod,
  callback?: (arg: any) => void
) => {
  return getStoreRecordsWithCursor(db, RECORDS_OBJECT_NAME, 'prev', (cursor: IDBCursorWithValue) => {
    const { value: record } = cursor;
    const ret = defaultFilter(input, record, searchableFields, matchingMethod);

    if (callback && ret) {
      callback(ret);
    }
    return ret;
  }).then(records => {
    return records;
  });
};

debounce 処理

 検索時にはキーボードで入力した 1 文字単位(タイプした単位)で React の onChange に引っかかってしまいますが、検索(レコード取得と絞込)は入力された単語単位でメソッドを呼び出したいところです(ボタンを押して検索を実行させる、という動きにはしたくないのは勿論のこと)。

 そこで登場するのが、debouncethrottle による処理です。lodash にこれらがあって、今回は debounce がマッチしたので、採用しました。

const [results, setResults] = React.useState<IDBRecord[]>([]);

// 盛大に中略

const debouncedSearchRecords = debounce(value => {
  searchRecords(inputValue, value, searchableFields, filterTypeIndex, matchingMethod).then(records => {
    setResults(records);
  });
}, 700);

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const { value } = e.target;
  debouncedSearchRecords(value);
};

まとめ

 ポイントの部分は書き出せたと思うので、今回はこのくらいにさせて頂きたいと思います。どなたかの参考になれば、幸いです。

最後に

 最後までお読み頂き、ありがとうございます。

免責

 こちらは個人の意見で、所属する企業や団体は関係ありません。