🌤️

ノーコードプロダクトのReact化の工夫

2024/08/16に公開

はじめに

こんにちは!サイボウズ株式会社でkintone開発を行っている かげ です。

弊社が開発しているkintoneは、プログラミングの知識がなくても、ノーコードで業務のシステム化や効率化を実現するアプリがつくれるクラウドサービスです。

https://kintone.cybozu.co.jp

kintoneは初回リリースから10年以上経ちましたが、そのフロントエンドに使用されるClosure Libraryが今月(2024/08)EOLを迎えます。EOLを迎えたフレームワークを使用し続けることは、リスクにもつながりますし、今後より良くに、お客様への安定的なサービスの提供を維持するため、Reactへの刷新活動を行っています。
現在開発段階ではありますが、そこで工夫した点をご紹介したいと思います。この記事では、kintoneをご利用されるお客様がよく使用されるレコード一覧、レコード詳細画面を取り上げていきます。他にも画面はありますが、今回はご紹介だけにしたいと思います。

画面のご紹介

レコードとは、作成したkintoneアプリでの最小のデータのまとまりを意味します。
下図のレコード一覧の1行が1レコードに相当します。


レコード一覧

他、レコード詳細、編集、追加画面では、1画面が1レコードに相当します。


レコード詳細


レコード編集


レコード追加

ディレクトリ設計

まず、結論からお話すると、Feature型 / Layer型のハイブリッド を採用しました。レコード一覧画面を例に、採用に至った経緯をご説明します。

画面の構成に着目すると、赤枠それぞれで完結する機能を保持していることがわかりました。ノイズになってしまうので赤枠はごく一部を記載しています。


レコード一覧画面の機能単位の配置

それぞれの機能を以下のようなディレクトリ設計にしました。

src
    ├── pages / ページコンポーネント
    │   ├── list
    │   │   └─ index.tsx
    │   ├── show
    │   │   └─ index.tsx
    │   ├── edit
    │   │   └─ index.tsx
    │   └── add
    │      └─ index.tsx
    │
    ├── features / 機能コンポーネント
    │   ├── AppTitle
    │   │   ├─ index.ts
    │   │   ├─ components
    │   │   │ ├─ AppTitle.tsx
    │   │   │ ├─ AppTitle.test.tsx
    │   │   │ └─ AppTitle.css
    │   │   ├─ hooks
    │   │   │ ├─ useGetAppTitle.ts
    │   │   │ └─ useGetAppTitle.test.ts
    │   │   ├─ functions
    │   │   │ ├─ convertToAppTitle.ts
    │   │   │ └─ convertToAppTitle.test.ts
    │   │   └─ gateways
    │   │     └─ getAppTitle.ts
    │   ├── BreadcrumbList
    │   │   └─ ...
    │   └── RecordFilter
    │       └─ ...
    │
    └── repositories / 通信レイヤー
        ├─ getApp.ts
        ├─ getRecords.ts
        └─ ...

関心事の分離

今回は大きく pages, features, repositories の役割をご紹介します。

1. pages / ページコンポーネント

詳しいルーティング方法については割愛しますが、リクエストを受け取ると、当該の画面 = src/pages/xxx/index.tsx が呼び出されるようにしています。

この大元となるページコンポーネントは、各機能 = features配下のコンポーネントを呼び出すだけになるようにしています。

2. features / 機能コンポーネント

各機能を実現するためのパーツを集積しています。それぞれの責務を明白にするために、以下のような子階層を設けています。

  • components/
    • DOMを表現するものとそのテスト。ただし、分離を伴うようなロジックは保持しない。
  • hooks/
    • 状態を保持するものとそのテスト。
  • gateways/
    • サーバーサイドから受け取ったデータ構造をこの機能向けに加工したものとそのテスト。
    • 後述 3. にて通信をしているので、ここではあくまでその呼び出しと変換処理がメイン。
  • functions/
    • 上記のうち、状態管理や通信など伴わない関数に分離したものとそのテスト。

3. repositories / 通信レイヤー

REST API であるサーバーサイドとの送受信およびそれらのI/Fが集積されています。また、受け取ったJSONをTypeScriptのオブジェクトに変換することも行なっています。
各機能コンポーネントは、ここのimportをNGとして、features配下のgatewaysを一度経由するようにしています。

一見無駄な経路のように見えますが、将来的にサーバーサイドのインターフェースに変更が生じたときに、修正範囲を最小限にできるといった効果が期待されます。

importの静的検査

上述で制約を入れましたが、あくまで暗黙的なものであるため、exportすればどこからでも呼び出すことができます。そのため、意図しないところで使われるといった負債を負う可能性があります。そこで活躍するのがこちらです。
https://www.npmjs.com/package/eslint-plugin-strict-dependencies

これにより暗黙的な制約をコーディング時点で検知することができます。今回は、以下のような設定をいれました。

  1. 必要なものだけを公開するようにしたいため、features/xxx配下の資産をimportする場合は、 features/xxx/index.tsを使用する
  2. 循環参照を防止するため、features/xxx/index.tsの資産は、pages配下のコンポーネントのみでimport可能にする
  3. サーバーサイドの層を隠蔽するため、repositories/xxxの資産は、features/xxx/geteways配下のみでimport可能にする
Eslintのサンプルコード
.eslintrc.cjs
const fs = require('fs');
const path = require('path');

// src/features 直下のフォルダ名の配列を作る
const featureNames = fs
  .readdirSync(
    path.resolve(__dirname, 'src/features'),
    { withFileTypes: true }
  )
  .map((entry) => entry.name);

module.exports = {
  root: true,
  plugins: ['strict-dependencies'],
  rules: {
    'strict-dependencies/strict-dependencies': [
      'error',
      [
        // 1
        ...featureNames.map((name) => ({
          module: `src/features/${name}/**/*`,
          allowReferenceFrom: [`src/features/${name}/**/*`],
          allowSameModule: true
        })),
        // 2
        ...featureNames.map((name) => ({
          module: `src/features/${name}`,
          allowReferenceFrom: [`src/pages/**/*`],
          allowSameModule: true
        })),
        // 3
        {
          module: 'src/repositories/**/*',
          allowReferenceFrom: [
            'src/features/**/gateways/**/*.ts',
          ],
          allowSameModule: true
        },
      ],
    ],
  }
};

純粋なLayer型にすると、関心事の認識がしずらい、exportファイルの影響範囲に制約を入れずらいといったことがあったので、主にFeature型、部分的にLayer型を採用するようにしました。ただ、これが完成というわけでもないので、適宜見直しは行なっています。

フィールドの型

kintoneでは、お客様の業務合わせたアプリを作成いただけますが、それを構成するパーツとして、29種類ものフィールドを提供しています。


フィールドの種類

注意したいのが、単一なのか複数なのかといった値の持ち方の違いや最大値、最小値などの属性がそれぞれのフィールドで異なっている点です。今回は、文字列(1行)= SINGLE_LINE_TEXT とチェックボックス=MULTIPLE_CHECKを例に紹介したいと思います。

型定義 / 検証

サーバーサイドから受信したJSONが意図した型であることの検証に、zodを使用しています。
https://github.com/colinhacks/zod

これにより、型の妥当性検証のみならず、型の変換までやってくれます。通信レイヤーでこの検証をするには、以下のような実装をします。

src/repositories/schema.ts
import { z } from 'zod';

export const recordSchema = z.object({
  id: z.string(),
  fieldList: z.record(z.string(), fieldSchema),
});

const fieldSchema = z.discriminatedUnion('type', [
  singleLineTextSchema,
  multipleCheckSchema,
  ...
]);

const singleLineTextSchema = z.object({
  type: z.literal('SINGLE_LINE_TEXT'),
  label: z.string(),
  value: z.string(),
  properties: z.object({
    defaultValue: z.string(),
    required: z.boolean(),
    noLabel: z.boolean(),
    min: z.number().nullable(),
    max: z.number().nullable(),
  })
});

const multipleCheckSchema = z.object({
  type: z.literal('MULTIPLE_CHECK'),
  label: z.string(),
  values: z.array(z.string()),
  properties: z.object({
    defaultValue: z.array(z.string()),
    required: z.boolean(),
    noLabel: z.boolean(),
  })
});

このように、フィールド毎のスキーマを定義し、parse関数を使用することで変換ができます。以下は、recordSchemaを使った パラメータrecordIdに紐つくレコードを取得する関数の例です。

src/repositories/getRecord.ts
import { recordSchema } from '../../cybozu-data/schema';

export const getRecord = async (id: string) => {
  const res = await fetch(`/api/getRecord?recordId=${recordId}`);
  return recordSchema.parse(res); // 型エラーがあると、ここでスローされる
}

フィールドの分岐

次に、前節での通信レイヤーで受け取ったデータ/型を使った入力コンポーネントを例に紹介します。
まず準備として、通信レイヤーの隠蔽やこの機能で使用したい型を定義します。

通信レイヤーの隠蔽 サンプルコード
src/features/AppForm/gateways/getRecord.ts
import { getRecord as getRecordAPI } from '../../../repositories/getRecord';
import { AppField } from '../types/types';
import { convertToAppField } from '../functions/convertToAppField';

export const getRecord = async (recordId: string) => {
  const apiRecord = await getRecordAPI(recordId);
  return convertToRecord(apiRecord);
}
この機能で使用する型 サンプルコード
src/features/AppForm/types/types.ts
export type FieldType = 
  | 'SINGLE_LINE_TEXT'
  | 'MULTPLE_CHECK'
  ...
;

type SingleLineTextField = {
  type: 'SINGLE_LINE_TEXT';
  label: string;
  value: string;
  properties: {
    defaultValue: string;
    required: boolean;
    min: number | null;
    max: number | null;
  };
  style: {
    width: number;
  }
}

type MultipleCheckField = {
  type: 'MULTPLE_CHECK';
  label: string;
  values: {
    value: string;
  }[];
  properties: {
    defaultValues: {
      value: string;
    }[];
    required: boolean;
  };
  style: {
    width: number;
  }
}

type AppFieldAny = 
  | SingleLineTextField
  | MultipleCheckField
  ...
;

export type AppField<T extends FieldType = any> = 
  Extract<AppFieldAny, { type: T }>

export type AppRecord = {
  id: string;
  fieldList: Record<string, AppField>;
}

準備ができたところで、レコード編集画面とAppRecordの構造に着目すると、アプリを構成するフィールドを順番に表示するようにしたいと思います。


レコード編集画面とコンポーネントの対応

src/features/AppForm/components/AppForm.tsx
import { FC } from 'react';
// ../gateways/getRecord を使ってレコードの状態を保持するhooks
import { useGetRecord } from '../hooks/useGetRecord'; 
import { InputFormField } from './InputFormField';

type Props = {
  recordId: string;
};

// レコードを表示するコンポーネント
export const AppForm: FC<Props> = ({ recordId }) => {
  const { appRecord } = useGetRecord(recordId);

  return (
    <div style={'なんかスタイル'}>
      {Object.values(appRecord.fieldList).map((field) => (
        <div key={field.id} style={'なんかスタイル'}>
          <div><span>{field.label}</span></div>
          <InputFormField field={field} />
        </div>
      ))}
    </>
  )
};

src/features/AppForm/components/InputFormField.tsx
import { FC } from 'react';
// 入力された値の状態を保持するためのhooks
import { useUpdateFieldValue } from '../hooks/useUpdateFieldValue'; 
import {
  MultipleCheckInputForm,
  SingleLineTextInputForm,
} from './InputFormAnyField';

type Props = {
  field: AppField;
};

// フィールドに値を入力するコンポーネント
export const InputFormField: FC<Props> = ({field}) => {
  const { updateFieldValue } = useUpdateFieldValue();

  switch(field.type) {
    case 'MULTPLE_CHECK': {
      return (
        <MultipleCheckInputForm 
          field={field} updateFieldValue={updateFieldValue}>
      );
    }
    case 'SINGLE_LINE_TEXT': {
      return (
        <SingleLineTextInputForm 
          field={field} updateFieldValue={updateFieldValue}>
      );
    }
    default {
       return <div>未実装やで: {field.type}</div> // 最終的に不要
    }
  }
};

fieldTypeを条件にそれぞれのフィールドのコンポーネントへ委譲するようにします。次の例では複数のフィールドの実装を全てInputFormAnyField.tsxにまとめていますが、実際はフィールドごとにファイルを分けています。ファイルの数がものすごく増えますが、ファイルを別々に分ける方が、テストの管理が容易になるうえコードを変更した時の競合リスクを減らせていいと思います。

src/features/AppForm/components/InputFormAnyField.tsx
import { FC } from 'react';
import { AppField, AppFieldValue } from '../types/types';
import { MultipleCheck, SingleLineText } from 'kintone-ui';

type InputFormFC<T extends FieldType> = FC<{
  field: AppField<T>;
  updateFieldValue: (fieldId: string, value: AppFieldValue<T>) => void;
}>;

export const MultipleCheckInputForm: InputFormFC<'MULTPLE_CHECK'> = ({
  field,
  updateFieldValue,
}) => {
  return (
    <MultipleCheck
      values={field.values}
      onChange={(v) => {
        updateFieldValue(field.id, v);
      }}
      style={field.style}
    />
  );
}

export const SingleLineTextInputForm: InputFormFC<'SINGLE_LINE_TEXT'> = ({
  field,
  updateFieldValue,
}) => {
  const [message, setMessage] = useState<string | null>(null);

  return (
    <SingleLineText
      values={field.values}
      onChange={(v) => {
        if(v.properties.min && v.properties.min < v.length) {
          setMessage('最小文字数を下回っています');
        }
        if(v.properties.max && v.properties.max > v.length) {
          setMessage('最大文字数を上回っています');
        }

        updateFieldValue(field.id, v);
      }}
      style={field.style}
      message={message}
    />
  );
}

新規のフィールドを実装する場合は、同じように専用のファイルを追加する要領です。また、フィールド毎の機能追加もフィールド単位のファイルに実装していくイメージになります。機能が増えると、1ファイルで収まらないことはよくある話なので、適宜分離するようにしましょう。今回の例だと、SingleLineTextInputForm のバリデーション部分はテストしやすくするためにも分離しておくと良さそうです。

まとめ

kintone FEの工夫を一部紹介しましたが、いかがだったでしょうか?
この記事向けに、複雑性を解消したコードになっていますが、実際はもっとプロパティがあり、構造がより複雑になっています。例えば、フィールドをネストできるグループフィールドやレコードを持たせるテーブルフィールドもあります。

kintone開発にご興味を持っていただければ幸いです。

GitHubで編集を提案
サイボウズ フロントエンド

Discussion