容量問題の解消のためIndexedDBへ
現在個人開発で短編小説を書いて保存しておくためのWEBアプリを開発しています。
短い小説を繰り返し書くことは、小説を書くための練習になるので、そのためのアプリです。
Next.jsのSSG+Recoilのlocalstorageへの永続化によって、自動保存+高速動作できるようにを心掛けています。(高速動作がどれだけ実現できてるかは別問題)
ですが、短編とはいえ練習のために沢山小説を書くとなるとlocalstorageの容量では心許ないと思っていました。
最大限の文字数で書くと、短編4~5本くらいで容量が足りなくなりそうです。
ただ外部DBへの保存だと自動保存の動作は重くなるかと思います。
そこでlocalStorage以外にlocalに保存できる場所はないか調べてみると、IndexedDBとWebSQLがあることを知りました。WebSQLは現在更新がないそうなので、IndexedDBの方が推奨されています。
そのままRecoilの永続化をlocalstorageからIndexedDBへ変更したかったのですが、サクッとそのまま変更といかなかったのと、情報が少なくて苦戦を強いられましたので記事にして共有します。
IndexdDBとは
秘書のGPTさんによると
IndexedDBは、Webブラウザ内で動作するクライアントサイドのデータベースであり、JavaScript APIを通じてアクセスできます。オブジェクトストアにデータを保存し、インデックスを使用して検索やフィルタリングができます。IndexedDBは非同期的に動作し、オフライン動作を可能にし、Webストレージよりも高度な検索とデータ構造をサポートします。しかし、学習コストが高く、データベースの管理とアップグレードにも注意が必要です。
とのことです。
localForageライブラリでIndexdDBを利用する
ちょっと調べてみても素のIndexdDBを操作して永続化するのはしんどそうだったので、ライブラリを探してみるとlocalForageライブラリを見つけました。
localForageはlocalstorageAPIと同じような操作でIndexdDBやWebSQLなどのWEBストレージを利用できます。
またこちらの記事で紹介しているように、わたしはRecoilの永続化にRecoil-persistというモジュールを利用しているのですが、そのGitHubのissueにlocalForageを使ってWebSQLに永続化する例を見つけたので、IndexdDBでも利用できるのではと思いやってみることにしました。
atomの変更
さっそくRecoilで永続化している**atom
**を変更してみます。
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { atom } from "recoil";
import { recoilPersist } from "recoil-persist";
import { draftObject } from "../selector/editorState";
import localforage from "localforage";
import { PersistStorage } from "recoil-persist";
export type draftObjectArray = draftObject[];
//IndexdDBの初期化
localforage.config({
driver: localforage.INDEXEDDB,
name: "drafts",
version: 2,
storeName: "draftObject"
});
const customStorage = (): PersistStorage => {
return {
setItem: async (key: string, value: string) => {
await localforage.setItem(key, value);
},
getItem: async (key: string): Promise<string> => {
const value: string = await localforage.getItem(key);
return value === null ? undefined : value;
}
};
};
const { persistAtom } = recoilPersist({
key: "recoil-persist",
storage: typeof window === "undefined" ? undefined : customStorage()
});
export const drafts = atom({
key: "drafts",
default: [],
effects_UNSTABLE: [persistAtom]
});
localforage.config
でIndexdDBを利用できるようにします。name
プロパティがkey
となってテーブルが作成されます。
Recoil-persistは非同期処理に対応していないためcustomStorage()
で対応しています。isuueにあったコードまんまだと型エラー等がでてしまったので、いろいろ修正して動いた形です。
発生した問題
動作させてみて、DBに保存更新ができており、アプリの動作も異常がなさそうだったのですが色々試してみるといきなりデータが全て消えてしまいました。
再現手順を確認するとどうやらアプリ上で何も情報更新せずにリロードをするとデータが消えます。
cosole.log()
を仕込んで処理の流れを追うと、初期表示→DBからデータを取得してatom
に設定をしたのち今度はatom
のdefault値(今回の場合は[])がDBに保存されていました。
初期表示時にDBのデータを**atom
**にセットできているのと、アプリデータを更新するとそのデータが再度DBに上書きされるので一見普通に動いているように見えましたが、実は初期表示直後にdefault値が上書きされているためatom
の値とDBのデータに相違ができていました。
もちろんそのままリロード、もしくはブラウザを消去すると、次回起動時には持ってくるDB上のデータが空っぽなのでデータが全て消え失せていました。
どうやらDB→atomへのセットと、atom(default値)→DBへのセットが非同期で同時に処理されているようで弱弱エンジニアのわたしは頭を抱えました。
customRecoilPersistの作成
幸いRecoil-Persistモジュール自体は単体のコードで動いており、そのまま自分のソースに移植しても問題なく動作するようだたったので、そこから調査しようと思いました。
またmergeされていないPullRequestにlocalfoageとの互換性を追加したコードがありましたのでそれを使うことにしました。
作成したのが下記になります。
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable prefer-const */
/* eslint-disable no-prototype-builtins */
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-empty-interface */
import { AtomEffect } from "recoil";
type PersistItemValue = string | undefined | null;
export interface PersistStorage {
setItem(key: string, value: string): any;
mergeItem?(key: string, value: string): any;
getItem(key: string): PersistItemValue | Promise<PersistItemValue>;
}
export interface PersistState {}
export interface PersistConfiguration {
key: string;
storage: PersistStorage;
}
interface PendingChanges {
queue: Promise<any> | null;
updates: Partial<PersistState>;
reset: Record<string, boolean>;
}
/**
* Recoil module to persist state to storage
*
* @param config Optional configuration object
* @param config.key Used as key in local storage, defaults to `recoil-persist`
* @param config.storage Local storage to use, defaults to `localStorage`
*/
export const recoilPersist = (config: Partial<PersistConfiguration> = {}): { persistAtom: AtomEffect<any> } => {
if (typeof window === "undefined") {
return {
persistAtom: () => {}
};
}
const {
key = "recoil-persist" as PersistConfiguration["key"],
storage = localStorage as PersistConfiguration["storage"]
} = config;
const pendingChanges: PendingChanges = {
queue: null,
updates: {},
reset: {}
};
**let firstFlug = false; //追加**
const persistAtom: AtomEffect<any> = ({ onSet, node, trigger, setSelf }) => {
if (trigger === "get") {
getState().then((s) => {
if (s.hasOwnProperty(node.key)) {
setSelf(s[node.key]);
**firstFlug = true; //追加**
} else {
**if (!firstFlug) firstFlug = true; //追加**
}
});
}
onSet((newValue, _, isReset) => {
**if (firstFlug) { //追加**
if (isReset) {
pendingChanges.reset[node.key] = true;
delete pendingChanges.updates[node.key];
} else {
pendingChanges.updates[node.key] = newValue;
}
if (!pendingChanges.queue) {
pendingChanges.queue = getState().then((state) => {
if (JSON.stringify(state[node.key]) !== JSON.stringify(newValue)) {
updateState(state, pendingChanges);
}
pendingChanges.queue = null;
pendingChanges.reset = {};
pendingChanges.updates = {};
});
}
}
});
};
const updateState = (state: PersistState, changes: PendingChanges) => {
Object.keys(changes.reset).forEach((key) => {
delete state[key];
});
Object.keys(changes.updates).forEach((key) => {
state[key] = changes.updates[key];
});
setState(state);
};
const parseState = (toParse: PersistItemValue): PersistState => {
if (toParse === null || toParse === undefined) {
return {};
}
try {
return JSON.parse(toParse);
} catch (e) {
console.error(e);
return {};
}
};
const getState = (): Promise<PersistState> => Promise.resolve(storage.getItem(key)).then(parseState);
const setState = (state: PersistState): void => {
try {
if (typeof storage.mergeItem === "function") {
storage.mergeItem(key, JSON.stringify(state));
} else {
storage.setItem(key, JSON.stringify(state));
}
} catch (e) {
console.error(e);
}
};
return { persistAtom };
};
持ってきたコードほぼまんまですが、初期値の上書き問題は解決できていないため、いくつかコードを追加してみました。
色々試したのですがうまくいかず、自分でも微妙だなと思いつつも、唯一想定通りの動作になったものです。
今回の場合初回読み込みのgetState()
関数とonSet()
関数が同時動いているのが問題でした。
if (s.hasOwnProperty(node.key))
は初回読み込み時かつ、DBにデータが存在する場合に動くので、それが終わった後にfirstFlug
を変更しています。
また初回にDBにデータがない場合はfirstFlug
がfalse
だった時にfirstFlug
をture
にしています。
onSet
内部をfirstFlug
がtrue
の時のみ動かすようにしたので、同時に動く初回のみonSet
をスキップします。これによって初期値上書きを回避できました。
ブラウザごとのテストはしていないのでもしかしたらうまく動かないブラウザがあるかもしれません。
コードの問題や、もっとスマートに解決できる方法があったらぜひコメントで教えてください。
localforageとの互換性
もってきたコードにはlocalforageとの互換性が追加されてますので、atom
のコードもすっきり書けます。
IndexdDBの初期化以外は**、localstorage**の時と一緒。
これは素晴らしいですね( *´艸`)
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { atom } from "recoil";
import { recoilPersist } from "../../components/util/customRecoilPersist";
import { draftObject } from "../selector/editorState";
import localforage from "localforage";
export type draftObjectArray = draftObject[];
//IndexdDBの初期化
localforage.config({
driver: localforage.INDEXEDDB,
name: "drafts",
version: 2,
storeName: "draftObject"
});
const { persistAtom } = recoilPersist({
key: "recoil-indexeddb",
storage: typeof window === "undefined" ? undefined : localforage
});
export const drafts = atom({
key: "drafts",
default: [],
effects_UNSTABLE: [persistAtom]
});
おわりに
今回参考にさせて頂いたのは、Recoil-persistモジュールのGitHubでした。
いつも利用しており、非常に便利なものを作っていただいてありがとうございます。
この実装によって、おおよそ初めに入れたい機能を入れたアプリとなりました。
この記事以外にも、コミットを遡って振り返り記事をちょこちょこ書いていこうかと思います。
良かったらアプリも触ってみて、問題を見つけたらコメントにて教えていただけると助かります。