Dexie.js で遊ぶ
このスクラップについて
Solid プロジェクトに影響を受けて、テーマは何でも良いのでユーザーがデータを所有できる Web アプリを作りたいと考えている。
Solid では Pod と呼ばれるデータストアを使うが、広く普及している Google Drive や Dropbox を使おうと考えている。
データ形式についても Solid では RDF を使うが、こちらも JSON Schema を使おうと考えている。
クライアントサイドでデータを扱うにあたり、Dexie.js が便利そうだったのでこのスクラップでは Dexie.js の基本的な使い方を学ぶ過程を記録していこうと思う。
プロジェクトのセットアップ
まずは Vite で良いか。
npm create vite@latest
✔ Project name: … hello-dexie
✔ Select a framework: › React
✔ Select a variant: › TypeScript
cd hello-dexie
npm install
npm run dev
Dexie.js のインストール
npm install dexie
npm install dexie-react-hooks
db.ts の作成
touch src/db.ts
db.ts のコーディング
import Dexie, { EntityTable } from "dexie";
type Friend = {
id: number;
name: string;
age: number;
};
const db = new Dexie("FriendsDatabase") as Dexie & {
friends: EntityTable<Friend, "id">;
};
db.version(1).stores({
friends: "++id, name, age",
});
export type { Friend };
export { db };
App.tsx のコーディング
import "./App.css";
import { db, Friend } from "./db";
import { useLiveQuery } from "dexie-react-hooks";
function App() {
const friends = useLiveQuery(() => db.friends.toArray());
const addFriend = () => {
db.friends.add({
name: "John Doe",
age: 30,
});
};
return (
<>
<h1>Hello Dexie.js</h1>
<button onClick={addFriend}>Add friend</button>
<ul>
{friends?.map((friend) => (
<li key={friend.id}>
{friend.name} is {friend.age} years old
</li>
))}
</ul>
</>
);
}
export default App;
Add Friend ボタンを押すと 30 才の John Doe が増えていく
リロードしてもデータがしっかり保存されている。
削除を試してみる
import "./App.css";
import { db } from "./db";
import { useLiveQuery } from "dexie-react-hooks";
function App() {
const friends = useLiveQuery(() => db.friends.toArray());
const addFriend = () => {
db.friends.add({
name: "John Doe",
age: 30,
});
};
const deleteFriend = (id: number) => {
db.friends.delete(id);
};
return (
<>
<h1>Hello Dexie.js</h1>
<button onClick={addFriend}>Add friend</button>
<ul>
{friends?.map((friend) => (
<li key={friend.id}>
{friend.name} is {friend.age} years old
<button onClick={() => deleteFriend(friend.id)}>Delete</button>
</li>
))}
</ul>
</>
);
}
export default App;
Delete ボタンを押すと John Doe が減っていく
エクスポートを試したい
npm install dexie-export-import
import { exportDB } from "dexie-export-import";
import "./App.css";
import { db } from "./db";
import { useLiveQuery } from "dexie-react-hooks";
function App() {
const friends = useLiveQuery(() => db.friends.toArray());
const addFriend = () => {
db.friends.add({
name: "John Doe",
age: 30,
});
};
const deleteFriend = (id: number) => {
db.friends.delete(id);
};
const exportDatabase = async () => {
const blob = await exportDB(db, { prettyJson: true });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "dexie-database.json";
a.click();
};
return (
<>
<h1>Hello Dexie.js</h1>
<button onClick={addFriend}>Add friend</button>
<ul>
{friends?.map((friend) => (
<li key={friend.id}>
{friend.name} is {friend.age} years old
<button onClick={() => deleteFriend(friend.id)}>Delete</button>
</li>
))}
</ul>
<button onClick={exportDatabase}>Export</button>
</>
);
}
export default App;
エクスポートされた JSON
{
"formatName": "dexie",
"formatVersion": 1,
"data": {
"databaseName": "FriendsDatabase",
"databaseVersion": 1,
"tables": [
{
"name": "friends",
"schema": "++id,name,age",
"rowCount": 1
}
],
"data": [{
"tableName": "friends",
"inbound": true,
"rows": [
{
"name": "John Doe",
"age": 30,
"id": 5
}
]
}]
}
}
なかなか悪くない。
できればアプリ固有のフォーマットにしたいがそれはそれで手間がかかりそうだ。
Hono のサービスワーカーを試したい
面白そうなので試してみよう。
サービスワーカーを試す
tsconfig.app.json に WebWorker を追加する、あとはそのままで良さそう。
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
main.tsx の変更
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
function register() {
navigator.serviceWorker
.register("/sw.ts", { scope: "/sw", type: "module" })
.then(
function () {
console.log("Register Service Worker: Success");
},
function () {
console.log("Register Service Worker: Error");
}
);
}
function start() {
navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const registration of registrations) {
console.log("Unregister Service Worker");
registration.unregister();
}
register();
});
}
start();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
sw.ts の作成
npm i hono
touch src/sw.ts
import { Hono } from "hono";
import { handle } from "hono/service-worker";
declare const self: ServiceWorkerGlobalScope;
const app = new Hono().basePath("/sw").get("/", (c) => c.text("Hello World!"));
self.addEventListener("fetch", handle(app));
テストボタンの追加
const test = async () => {
const response = await fetch("/sw");
console.log(await response.text());
};
return (
<>
<h1>Hello Dexie.js</h1>
<button onClick={addFriend}>Add friend</button>
<ul>
{friends?.map((friend) => (
<li key={friend.id}>
{friend.name} is {friend.age} years old
<button onClick={() => deleteFriend(friend.id)}>Delete</button>
</li>
))}
</ul>
<button onClick={exportDatabase}>Export</button>
<button onClick={test}>Test</button>
</>
);
エラー発生
The script has an unsupported MIME type ('text/html').
サービスワーカーの登録時に発生しているようだ。
ChatGPT に聞いてみる
解決方法
vite-plugin-pwa を使うか下記の設定を追加すれば良さそう。
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
input: {
main: 'index.html',
sw: 'src/sw.ts',
},
},
},
});
設定追加はダメだった。
素直に vite-plugin-pwa を使う
npm install vite-plugin-pwa --save-dev
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
srcDir: "src",
filename: "sw.ts",
strategies: "injectManifest",
injectRegister: false,
manifest: false,
devOptions: {
enabled: true,
type: "module",
},
}),
],
});
navigator.serviceWorker
.register("/dev-sw.js?dev-sw", { scope: "/sw", type: "module" })
こちらの記事に助けられた、ありがとう!
ただアクセスはできない
/sw にアクセスしても普通に HTML ファイルが表示されてしまう、先はもう少しありそうだ。
scope を "/" にしたらできた
でもなんか納得いかない。
理由がわかった
サービスワーカーのスコープというのは、fetch などの URL ではなくて、現在表示しているページの URL を意味しているようだ。
なので http://localhost:5173/sw/test とかにアクセスすると動作するようになる。
ということは scope は /
にしておけということなのね。
永続化ロジックをサービスワーカー側に移す
npm i valibot @hono/valibot-validator
import { vValidator } from "@hono/valibot-validator";
import { Hono } from "hono";
import { handle } from "hono/service-worker";
import * as v from "valibot";
import { db } from "./db";
declare const self: ServiceWorkerGlobalScope;
const friendSchema = v.object({
name: v.string(),
age: v.pipe(v.number(), v.integer()),
});
const app = new Hono()
.basePath("/sw")
.post("/friends", vValidator("json", friendSchema), async (c) => {
const friend = c.req.valid("json");
const key = await db.friends.add(friend);
c.status(201);
c.header("Location", "/sw/friends/" + key);
return c.body(null);
});
self.addEventListener("fetch", handle(app));
現状複雑になったようにしか見えないし、なんか遅くなるケースもあるような気がするが、ロジックを分離できるのはとても素晴らしい気がするので、この方法を採用していきたい。
次は Hono RPC を使ってみよう
そうなると app.ts を作った方が良さそうだな。
app.ts の作成
touch src/app.ts
import { vValidator } from "@hono/valibot-validator";
import { Hono } from "hono";
import * as v from "valibot";
import { db } from "./db";
const friendSchema = v.object({
name: v.string(),
age: v.pipe(v.number(), v.integer()),
});
export const app = new Hono()
.basePath("/sw")
.post("/friends", vValidator("json", friendSchema), async (c) => {
const friend = c.req.valid("json");
const key = await db.friends.add(friend);
c.status(201);
c.header("Location", "/sw/friends/" + key);
return c.body(null);
});
export type App = typeof app;
import { handle } from "hono/service-worker";
import { app } from "./app";
declare const self: ServiceWorkerGlobalScope;
self.addEventListener("fetch", handle(app));
Hono RPC を使う
const addFriend = async () => {
const client = hc<App>("/");
await client.sw.friends.$post({
json: {
name: "John Doe",
age: 30,
},
});
};
コード補完がバリバリ効くのでとても快適だ。
Delete の実装
import { vValidator } from "@hono/valibot-validator";
import { Hono } from "hono";
import * as v from "valibot";
import { db } from "./db";
const friendJsonSchema = v.object({
name: v.string(),
age: v.pipe(v.number(), v.integer()),
});
const friendParamSchema = v.object({
friendId: v.pipe(v.pipe(v.string(), v.transform(Number)), v.integer()),
});
export const app = new Hono()
.basePath("/sw")
.post("/friends", vValidator("json", friendJsonSchema), async (c) => {
const friend = c.req.valid("json");
const friendId = await db.friends.add(friend);
c.status(201);
return c.json({ id: friendId });
})
.delete(
"/friends/:friendId",
vValidator("param", friendParamSchema),
async (c) => {
const { friendId } = c.req.valid("param");
await db.friends.delete(friendId);
c.status(204);
return c.body(null);
}
);
export type App = typeof app;
どうもサービスワーカーのリロードにはタブを閉じて開く必要があるようだ。