Dexie.js実践編：ブラウザで録音→ブラウザ内にデータ保存



## 今回実現したいこと
- ブラウザで録音する
- ブラウザ内で録音データを保存する


あくまでもクライアント側で保存することを目指します。
ブラウザ上で保存する場合、**localStorage** や **sessionStorage** のような WEB Storage API を使い簡単に実装できますが、大量のデータは扱えず(約5MB）で、且つ文字列しか保存できないため音声データはそのままでは保存できません。

リサーチの結果、画像データや音声データなどのBlobやファイルもそのままの値で保存でき、大量のデータをブラウザ上に保存することができる**IndexedDB**に保存することにしました。

IndexedDBをそのまま扱うのは初学者にはハードル高そう・・・ということで、よりシンプルな構文で扱うことのできる、ラッパーライブラリDexie.jsを使って実装しました。

https://dexie.org/


## 実行環境
- React
- [Dexie.js](https://dexie.org/)
- [react-media-recorder](https://www.npmjs.com/package/react-media-recorder)

## 完成DEMO
[完成DEMO](https://codesandbox.io/embed/dexiejs-react-recorder-mrst4h?fontsize=14&hidenavigation=1&theme=dark)は、以下にてご確認下さい。
録音データは、`Add`ボタンでIndexedDBへ保存ができます。
IndexedDBに保存した音声データはリロード後も保存されます。
DownLoad(.wav)から、録音データの直ダウンロードも可能です

https://codesandbox.io/embed/dexiejs-react-recorder-mrst4h?fontsize=14&hidenavigation=1&theme=dark


## 解説

### 1\. データベースの準備　
`db.js`ファイルを作成し、データベースを定義します。

```react:db.js
import Dexie from "dexie";

// IndexedDB を定義
export const db = new Dexie("DemoDB");
db.version(1).stores({
  voicelist: "++id, name, voice" // Primary key and indexed props
});
```

### 2\. IndexedDBにデータを追加
db.voicelist.add({dataObject...}) でデータを追加します。

今回録音に使用したライブラリ[react-media-recorder](https://www.npmjs.com/package/react-media-recorder)では、`mediaBlobUrl`で録音データにアクセスできますが、`mediaBlobUrl`は、Blob URLを返しているため、**Blob URL → Blob** に変換してからデータに追加します。

```react:AddVoiceData.jsx
import { db } from "../db";
import { useReactMediaRecorder } from "react-media-recorder";
import { useEffect, useState } from "react";
import "regenerator-runtime";
import axios from "axios";

export function AddVoiceData() {
  const [name, setName] = useState("");
  const [dbStatus, setDbStatus] = useState("");
  const [voice, setVoice] = useState("");

  async function addVoiceData() {
    try {
      // DBにデータを追加
      const id = await db.voicelist.add({
        name,
        voice
      });
      setDbStatus(`Voicedata ${name} successfully added. Got id ${id}`);
      setName("");
    } catch (error) {
      setDbStatus(`Failed to add ${name}: ${error}`);
    }
  }

  // react-media-recorderのhooksを使用　録音関連
  const {
    status,
    startRecording,
    stopRecording,
    mediaBlobUrl
  } = useReactMediaRecorder({
    video: false,
    audio: true,
    echoCancellation: true
  });

  // 音声データ Blob URL → Blobに変換する
  useEffect(() => {
    axios
      .get(mediaBlobUrl, {
        responseType: "blob"
      })
      .then(({ data }) => {
        setVoice(data);
        console.log(data); // → Blob
      })
      .catch((err) => {
        console.error(err);
      });
  }, [mediaBlobUrl]);

  console.log(voice);
  console.log("url", mediaBlobUrl);

  return (
    <>
      <h2>
        🎤 Recording：
        <a href="https://www.npmjs.com/package/react-media-recorder">
          react-media-recorder
        </a>
      </h2>
      <div>
        <div style={{ display: "block", margin: "1.5rem 0" }}>
          <audio src={mediaBlobUrl} controls></audio>
        </div>
        <div>
          <div
            style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
          >
            <p class="status">{status}</p>
            <div>
              <button
                onClick={() => {
                  startRecording();
                }}
              >
                Record Start
              </button>
              <button
                onClick={() => {
                  stopRecording();
                }}
              >
                Record Stop
              </button>
            </div>
            <div>
              <a download href={mediaBlobUrl}>
                Download(.wav)
              </a>
            </div>
          </div>
        </div>
      </div>
      <h2>
        🧰 Add to IndexedDB：
        <a href="https://dexie.org/docs/Tutorial/React#2-install-dependencies">
          Dexie.js
        </a>
      </h2>
      <p class="status">{dbStatus}</p>
      Name:
      <input
        type="text"
        value={name}
        onChange={(ev) => setName(ev.target.value)}
      />
      <button onClick={addVoiceData}>Add</button>
    </>
  );
}
```


### 3\. データを取得するコンポーネント
React用のHooksである、useLiveQuery を使って、コンポーネントの中でDBのデータを読み込みます。

ブラウザからデータを取得する際には、追加時と逆で、**Blob → Blob URL** に変換します。
変換には、[URL.createObjectURL()](https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL_static)を使います。

```react:GetVoiceData.jsx
import { db } from "../db";
import { useLiveQuery } from "dexie-react-hooks";

export function GetVoiceData() {
  // DBからデータを取得
  const IndexedData = useLiveQuery(async () => {
    const allVoiceList = await db.voicelist.toArray();
    // // 音声データ Blob → Blob URLに変換する
    const convertVoicesData = allVoiceList.map((list) => {
      if (list.voice) {
        const url = URL.createObjectURL(list.voice);
        return { ...list, voiceUrl: url };
      } else {
        return { ...list, voiceUrl: "" };
      }
    });

    console.log(convertVoicesData);
    return convertVoicesData;
  });

  return (
    <>
      <h2>🎧 Get data from IndexedDB</h2>
      <ul>
        {IndexedData?.map((data) => (
          <li key={data.id}>
            {data.name}
            <audio controls src={data.voiceUrl}></audio>
          </li>
        ))}
      </ul>
    </>
  );
}
```


### 4\. テーブル内のデータ全てを削除するコンポーネント
`table.clear()`を使ってテーブル内のデータを一括削除します。

```react:ClearDatabaseButton.jsx
import { db } from "../db";
import React from "react";

export function ClearDatabaseButton() {
  return (
    <button
      onClick={() => {
        db.transaction("rw", db.tables, async () => {
          await Promise.all(db.tables.map((table) => table.clear()));
        });
      }}
    >
      Clear Database
    </button>
  );
}
```

## 参考サイト

[Get started with Dexie in React](https://dexie.org/docs/Tutorial/React)

[【React】Dexie.jsでIndexedDBを使ってみました](https://launchcart.jp/blog/%E3%80%90react%E3%80%91dexie-js%E3%81%A7indexeddb%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%BE%E3%81%97%E3%81%9F/)


## 個人開発アプリに組み込む

DEMOと同様の実装を組み込んで、音声クイズアプリを制作しました。
ぜひミュートを解除して、音出して遊んでみてください🎶

https://voicequize.vercel.app/menu