🚀

Dexie.js + Nextjs+Editor.jsでよくあるAutoSaveやる

2022/10/13に公開

IndexedDBとは?

https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API/Basic_Terminology
IndexedDBを扱う上で気にかける必要があるポイント

IndexedDB は同一オリジンポリシーに従っています。そのため、ドメイン内の保存データにはアクセスできますが、異なるドメイン間のデータにはアクセスできません。

全文検索。この API には、 SQL の LIKE 演算子に相当するものがありません。

また、以下のような条件でブラウザーがデータベースを消去することがあるので注意が必要です。
ユーザーが消去を要求した場合。
多くのブラウザーには、Cookie、ブックマーク、保存されたパスワード、IndexedDB データなど、特定のウェブサイトに保存されたすべてのデータを消去できる設定があります。
ブラウザーがプライベートブラウジングモードになっている場合。一部のブラウザーには、「プライベートブラウジング」 (Firefox) または「シークレット」 (Chrome) モードがあります。セッションの終了時に、ブラウザーはデータベースを消去します。
ディスクまたはクォータの容量の上限に達した場合。
データが破損した場合。
この機能に対して互換性のない変更が行われた場合。

すべてのオブジェクトストアには、そのデータベース内で一意となる名前が必要です。

書き込みトランザクションのスコープが重ならない限り、ひとつのデータベース接続で同時に複数のアクティブなトランザクションが存在できます。

オブジェクトストアは キージェネレーター、キーパス、明示的に指定した値の、3 種類の生成源のいずれかからキーを得られます。

https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API/Using_IndexedDB

データベースが存在しない場合に open 操作でデータベースが作成

しかしデータベースを開く場合は、エラーイベントが発生する一般的な状況があります。もっとも多いであろう問題は、データベースを作成する許可をユーザーがウェブアプリに与えなかったことです。

ブラウザーは ウェブアプリが初めてストレージ用に IndexedDB を開こうとしたときに、ユーザーへプロンプトを表示します。ユーザーはアクセスを許可または拒否できます。またブラウザーのプライバシーモードでの IndexedDB ストレージは、匿名のセッションを閉じるまでの間だけメモリー上に存在します

トランザクションで適切なスコープおよびモードを使用すると、データアクセスを高速化できます。ヒントを 2 つ紹介します。
スコープを定義するときは、必要なオブジェクトストアのみ指定します。これにより、同時にスコープが重なり合うことなく、複数のトランザクションを実行できます。
readwrite トランザクションモードは、必要な場合に限り指定します。readonly トランザクションはスコープが重なっても複数同時に実行できますが、readwrite トランザクションはオブジェクトストアに対して 1 個しか実行できません。

データベースであらゆるトランザクションが終了したときに、常に一貫性がある状態を保つように注意するべきです。

データベースのトランザクションと unload イベントを紐づけるべきではありません。ブラウザーを閉じることで unload イベントが発生した場合、unload イベントハンドラーで作成したトランザクションは完了しません。

https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria#storage_limits

ブラウザーのストレージの最大容量は動的であり、ハードドライブのサイズに応じて変わります。グローバルリミットはディスクの空き量量の 50% に決められます。

また、グループリミットというもうひとつの制限もあります。これは、グローバルリミットの 20% として定義されます。それぞれのオリジンは、グループ (オリジンのグループ) の一部です。グループは、eTLD+1 ドメインごとに 1 つ作られます。

使用可能なディスク領域がすべて埋まったときは、クォータマネージャーが LRU ポリシーに基づいてデータの削除処理を始めます。もっとも過去に使用されたオリジンのデータが始めに削除され、上限に達しなくなるなるまで削除を繰り返します。

Dexie.js セットアップ

上記のようにIndexedDBはローカルストレージなどに比べて、容量的な制限が結構大きめなのと、KVSっぽく使えそうだけれどDBの生成トランザクション管理、エラーハンドリングなど自前でやると結構大変そう。
Dexieであれば、オブジェクトストアの定義から、tsでいい感じに書けるので今回採用。
ReactでのSetupは下記参照。
https://dexie.org/docs/Tutorial/React
Ts使う時には下記参照。
https://dexie.org/docs/Typescript

今回のユースケース

  • wysiwygで書きかけのドキュメントがあるときに復旧できること
  • ログインユーザーとノートを書く対象ごとに下書きを管理すること
  • 下書きは最後に保存された下書きのみ反映対象とすること

実装

ドキュメント通りにセットアップ

src/db.ts
import Dexie, { Table } from "dexie";

import { Project, ProjectNote } from "@models/Project";
import { User } from "@models/User";

export interface NoteDraft {
  id?: number;
  userId: User["id"];
  projectId: Project["id"];
  body: ProjectNote["body"];
  createdAt: number;
}

export class SubClassedDexie extends Dexie {
  NoteDrafts!: Table<NoteDraft>;

  constructor() {
    super("sample"); // db name
    this.version(1).stores({
      noteDrafts:
        "++id, [userId+projectId], userId, bundleId, createdAt", // indexes
    });
  }
}

export const db = new SubClassedDexie();

interfaceでスキーマらしきものを定義して、Dexieのサブクラスでストア作成とインデックス貼る。
単純にキーに貼りたい時はキー名のみで、複合にしたい場合には、[key1+key2]のような形で定義。

薄いレポジトリ入れる

Dexie.jsのドキュメント見ていただくとわかる通り、素直にクエリを書けるではなかったので薄いレポジトリ入れた。

ProjectNoteDraftRepository.ts
import { IndexableType, PromiseExtended } from "dexie";

import { Project } from "@models/Project";
import { User } from "@models/User";
import { db, NoteDraft } from "db";

export default class ProjectNoteDraftRepository {
  static async findLatest(
    userId: User["id"],
    projectId: Project["id"]
  ): Promise<NoteDraft | null> {
    const result = await db.noteDrafts
      .where({ userId, projectId })
      .reverse()
      .sortBy("createdAt");
    if (result.length) {
      return result[0];
    }
    return null;
  }

  static add(draft: NoteDraft): PromiseExtended<IndexableType> {
    return db.noteDrafts.add(draft);
  }

  static update(
    draftId: IndexableType,
    draft: NoteDraft
  ): PromiseExtended<number> {
    return db.noteDrafts.update(draftId, draft);
  }

  static clearProjectUserDrafts(
    userId: User["id"],
    projectId: Project["id"]
  ): PromiseExtended<number> {
    return db.noteDrafts.where({ userId, projectId }).delete();
  }
}

ormと似ていて、テーブルに対してはorderBy、帰ってきたcollectionに対してはsortBy的なメソッドの違いや、見たかぎりsortのasc,descがなかったりしてトリッキーな書き方が一部必要だった。

component

ProjectNoteEditor.tsx
const ArticleEditor = dynamic(
  () => import("@components/ArticleEditor/ArticleEditor"),
  { ssr: false }
);

type ProjectNoteEditorProps = {
  cardProps?: CardProps;
  projectId: Project["id"];
  onSubmit?: (note: ProjectNote) => void;
};
const ProjectNoteRepository = new ProjectNoteRepository();
const ProjectNoteEditor = ({
  cardProps,
  projectId,
  onSubmit,
}: ProjectNoteEditorProps) => {
  const { t, locale } = useLocale();
  const { user } = useLoginUser();
  const [body, setBody] = useState<OutputData | null>(null);
  const [loading, setLoading] = useState(false);
  const { enqueueSnackbar } = useSnackbar();
  const { saved } = useSnackbarOptions();
  const editorRef = useRef<EditorJS | null>(null);
  const [draftId, setDraftId] = useState<IndexableType | null>(null);
  const [lastDraft, setLastDraft] = useState<TrainingBundleNoteDraft | null>(
    null
  );
  const [draftApplied, setDraftApplied] = useState(false);

  const handleInitialize = (editor: EditorJS) => {
    editorRef.current = editor;
  };
  const handleSave = (output: OutputData) => {
    setBody((prevBody) => {
      if (prevBody === null) {
        return output;
      }
      return { ...prevBody, ...output };
    });
  };
  useEffect(() => {
    if (!user || !projectId) return;
    ProjectNoteDraftRepository.findLatest(user.id, projectId).then(
      (ld) => {
        setLastDraft(ld);
      }
    );
  }, [user]);
  useEffect(() => {
    const saveDraft = async () => {
      if (body === null || !body.blocks.length) return;
      const draft: NoteDraft = {
        userId: user.id,
        projectId,
        body,
        createdAt: new Date().getTime(),
      };
      if (!draftId) {
        const id = await ProjectNoteDraftRepository.add(draft);
        setDraftId(id);
      } else {
        await ProjectNoteDraftRepository.update(draftId, draft);
      }
    };
    saveDraft();
  }, [body]);
  const handleSubmit = () => {
    if (body === null) return;
    const form: CreateNoteForm = { userId: user.id, body };
    const { success } = createNoteForm(locale).safeParse(form);
    if (success) {
      setLoading(true);
      ProjectNoteRepository
        .create(projectId, form)
        .then((res) => {
          enqueueSnackbar(...saved);
          setLoading(false);
          editorRef.current?.clear();
          if (onSubmit && isProjectNote(res.data)) {
            onSubmit(res.data);
          }
          ProjectNoteDraftRepository.clearProjectUserDrafts(
            user.id,
            projectId
          );
        })
        .catch((e) => {
          if (Axios.isAxiosError(e)) {
            const { responseMessage } = axiosErrorHandler(e);
            enqueueSnackbar(responseMessage, { variant: "error" });
            setLoading(false);
          }
        });
    }
  };
  const renderDraft = () => {
    if (lastDraft?.body) {
      editorRef.current
        ?.render(lastDraft?.body)
        .then(() => setBody(lastDraft?.body))
        .then(() => {
          setLastDraft(null);
          setDraftApplied(true);
        });
    }
  };
  return (
    <Card {...cardProps}>
      <CardHeader
        avatar={<Avatar src={user?.picture || ""} />}
        title={user?.name}
      />
      <CardContent>
        <ArticleEditor
          placeholder={t.WRITE_SOMETHING}
          onSave={handleSave}
          minHeight={200}
          onInitialize={handleInitialize}
        />
      </CardContent>
      <Divider />
      <CardActions sx={{ justifyContent: "end" }}>
        {lastDraft && !draftApplied && editorRef.current && (
          <Button onClick={renderDraft}>{t.APPLY_UNSAVED_CONTENT}</Button>
        )}
        <LoadingButton
          variant="contained"
          disableElevation
          onClick={handleSubmit}
          disabled={loading || !body}
          loading={loading}
        >
          {t.SAVE}
        </LoadingButton>
      </CardActions>
    </Card>
  );
};

ProjectNoteEditor.defaultProps = {
  cardProps: {},
  onSubmit: () => {},
};
export default ProjectNoteEditor;
  • 初回レンダリングの時に、ログインユーザーの書きかけ記事があれば、下書き反映ボタン表示
  • 下書き反映ボタンクリックで下書き反映
  • ドキュメントの更新があるたびに、ローカルに保存・更新
  • バックエンドに送信した後で、クリア

以上

Discussion