Open27

Dexie.js で遊ぶ

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

https://solidproject.org/

Solid プロジェクトに影響を受けて、テーマは何でも良いのでユーザーがデータを所有できる Web アプリを作りたいと考えている。

Solid では Pod と呼ばれるデータストアを使うが、広く普及している Google Drive や Dropbox を使おうと考えている。

データ形式についても Solid では RDF を使うが、こちらも JSON Schema を使おうと考えている。

クライアントサイドでデータを扱うにあたり、Dexie.js が便利そうだったのでこのスクラップでは Dexie.js の基本的な使い方を学ぶ過程を記録していこうと思う。

https://dexie.org/

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロジェクトのセットアップ

まずは 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
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

db.ts のコーディング

src/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 };
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

App.tsx のコーディング

src/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 が増えていく

リロードしてもデータがしっかり保存されている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

削除を試してみる

src/App.tsx
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 が減っていく

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

エクスポートを試したい

コマンド
npm install dexie-export-import
src/App.tsx
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;
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

エクスポートされた JSON

dexie-database.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
        }
      ]
    }]
  }
}

なかなか悪くない。

できればアプリ固有のフォーマットにしたいがそれはそれで手間がかかりそうだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

サービスワーカーを試す

tsconfig.app.json に WebWorker を追加する、あとはそのままで良さそう。

tsconfig.app.json
{
  "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"]
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

main.tsx の変更

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>
);
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

sw.ts の作成

コマンド
npm i hono
touch src/sw.ts
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));
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テストボタンの追加

src/App.tsx(一部)
  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>
    </>
  );
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

エラー発生

The script has an unsupported MIME type ('text/html').

サービスワーカーの登録時に発生しているようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

解決方法

vite-plugin-pwa を使うか下記の設定を追加すれば良さそう。

vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        main: 'index.html',
        sw: 'src/sw.ts',
      },
    },
  },
});
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

素直に vite-plugin-pwa を使う

コマンド
npm install vite-plugin-pwa --save-dev
vite.config.js
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",
      },
    }),
  ],
});
src/main.tsx(一部)
  navigator.serviceWorker
    .register("/dev-sw.js?dev-sw", { scope: "/sw", type: "module" })

こちらの記事に助けられた、ありがとう!

https://zenn.dev/matazou/articles/030c830ef12ba9

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ただアクセスはできない

/sw にアクセスしても普通に HTML ファイルが表示されてしまう、先はもう少しありそうだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

理由がわかった

https://qiita.com/nhiroki/items/eb16b802101153352bba#serviceworker-のスコープについて

サービスワーカーのスコープというのは、fetch などの URL ではなくて、現在表示しているページの URL を意味しているようだ。

なので http://localhost:5173/sw/test とかにアクセスすると動作するようになる。

ということは scope は / にしておけということなのね。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

永続化ロジックをサービスワーカー側に移す

コマンド
npm i valibot @hono/valibot-validator
src/sw.ts
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));

現状複雑になったようにしか見えないし、なんか遅くなるケースもあるような気がするが、ロジックを分離できるのはとても素晴らしい気がするので、この方法を採用していきたい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

app.ts の作成

コマンド
touch src/app.ts
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;
src/sw.ts
import { handle } from "hono/service-worker";
import { app } from "./app";

declare const self: ServiceWorkerGlobalScope;

self.addEventListener("fetch", handle(app));
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Hono RPC を使う

src/App.tsx(一部)
  const addFriend = async () => {
    const client = hc<App>("/");
    await client.sw.friends.$post({
      json: {
        name: "John Doe",
        age: 30,
      },
    });
  };

コード補完がバリバリ効くのでとても快適だ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Delete の実装

src/app.ts
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;

どうもサービスワーカーのリロードにはタブを閉じて開く必要があるようだ。