IndexedDB,使用 React 在浏览器中实现离线和无服务器数据库

2025-06-08

IndexedDB,使用 React 在浏览器中实现离线和无服务器数据库

几个月前,我在公司偶然发现了这个继承的新应用程序。

这个应用程序有一些很棒且令人印象深刻的功能,我学到了很多 — — 并且努力了☠️ — — 很多我不太了解的功能 — — 或者根本没有听说过!

其中一个很酷的功能是使用 IndexedDB 进行存储。

说实话,我花了不少时间才搞清楚数据到底存储在哪里。我以为每次都会被拉取,或者被某种我理解不了的服务器魔法缓存了。然而,深入研究代码后,我发现💡它使用 indexedDB 将数据存储在浏览器中,从而节省了大量不必要的请求。

那么什么是索引数据库?

IndexedDB 是一个低级 API,用于客户端存储大量结构化数据,包括文件/blob。

关键概念

  • 异步,因此它不会阻塞你的主线程操作⚡。
  • noSQL 数据库,这使得它非常灵活 — — 但也很危险☢️。
  • 让您离线访问数据。
  • 可以存储大量数据(比其他网络存储(如localStoragesessionStorage )更多)。

好的,一旦我对 IndexedDB 有了更好的了解,我想通过一个简单的实现来练习一下,而不是不得不用我继承的实际应用程序的超级复杂的代码库来痛苦地尝试它。

在 React App 中的实现

这是一个低级 API,因此它的实现有点奇怪,但别害怕,练习一下就明白了。虽然有一些第三方应用库可以处理它——比如 Dexie——但我还是想尝试一下原始 API。

这里使用的所有方法都会返回一个Promise,这样我们就能在组件中获取数据库中正在发生的事情的响应。如果你愿意,你不必等待 Promise 被解决,这没问题,实际上你会在大多数页面中看到它的实现。我喜欢使用 Promise 的方法,因为它能更好地理解正在发生的事情👀,但这种方法当然会阻塞主线程,直到 Promise 被解决。

初始化数据库

第一步,声明数据库并在用户浏览器中打开(或初始化)它

// db.ts

let request: IDBOpenDBRequest;
let db: IDBDatabase;
let version = 1;

export interface User {
  id: string;
  name: string;
  email: string;
}

export enum Stores {
  Users = 'users',
}

export const initDB = (): Promise<boolean> => {
  return new Promise((resolve) => {
    // open the connection
    request = indexedDB.open('myDB');

    request.onupgradeneeded = () => {
      db = request.result;

      // if the data object store doesn't exist, create it
      if (!db.objectStoreNames.contains(Stores.Users)) {
        console.log('Creating users store');
        db.createObjectStore(Stores.Users, { keyPath: 'id' });
      }
      // no need to resolve here
    };

    request.onsuccess = () => {
      db = request.result;
      version = db.version;
      console.log('request.onsuccess - initDB', version);
      resolve(true);
    };

    request.onerror = () => {
      resolve(false);
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

这种initDB方法基本上会打开连接myDB- 我们可以有多个数据库,这是标识它们的标签 - 然后我们将附加两个监听器request

onupgradeneeded仅当我们 a) 创建新数据库 b) 我们使用例如更新新连接的版本时,才会触发监听indexedDB.opn('myDB', version + 1)

如果没有发生任何错误,监听onsuccess器将被触发。这里通常是resolve我们承诺的地方。
onerror很容易理解。如果我们的方法正确,我们很少会听到这个监听器的声音。然而,在构建我们的应用时,它会非常有用。

现在,我们的组件将显示这个超级简单的 UI

import { useState } from 'react';
import { initDB } from '../lib/db';

export default function Home() {
  const [isDBReady, setIsDBReady] = useState<boolean>(false);

  const handleInitDB = async () => {
    const status = await initDB();
    setIsDBReady(status);
  };

  return (
    <main style={{ textAlign: 'center', marginTop: '3rem' }}>
      <h1>IndexedDB</h1>
      {!isDBReady ? (
        <button onClick={handleInitDB}>Init DB</button>
      ) : (
        <h2>DB is ready</h2>
      )}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

初始化之前的第一个用户界面

如果我们调用,handleInitDB数据库就会被创建,我们将能够在我们的开发工具/应用程序/IndexedDB选项卡中看到它:

开发工具 - 索引数据库表

太棒了!数据库已经启动并运行了。即使用户刷新或断开连接,数据库仍将保留。

初始化就绪

顺便说一句,造型由你决定😂。

添加数据

目前我们有一个空的数据库。我们现在要向其中添加数据。

export const addData = <T>(storeName: string, data: T): Promise<T|string|null> => {
  return new Promise((resolve) => {
    request = indexedDB.open('myDB', version);

    request.onsuccess = () => {
      console.log('request.onsuccess - addData', data);
      db = request.result;
      const tx = db.transaction(storeName, 'readwrite');
      const store = tx.objectStore(storeName);
      store.add(data);
      resolve(data);
    };

    request.onerror = () => {
      const error = request.error?.message
      if (error) {
        resolve(error);
      } else {
        resolve('Unknown error');
      }
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

这里有什么新鲜事?

不再需要onupgradeneeded了。我们没有对商店版本进行任何更改,因此不再需要此监听器。

然而,onsuccess监听器发生了变化。这次我们将使用 方法来写入数据库transaction,这将使我们能够使用add方法来最终传递我们的用户data

<T>在这里,Typescript 通过接受通用类型(在本例中为接口)帮助我们避免传递数据时的错误并保持数据库的完整性User

最后,在我们的onerror我不想为此浪费太多时间,它将解决一个string,但它可能是Exception或类似的东西。

在我们的组件中,我们将添加这个简单的表单来添加用户。

// Home component
  const handleAddUser = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const target = e.target as typeof e.target & {
      name: { value: string };
      email: { value: string };
    };

    const name = target.name.value;
    const email = target.email.value;
    // we must pass an Id since it's our primary key declared in our createObjectStoreMethod  { keyPath: 'id' }
    const id = Date.now();

    if (name.trim() === '' || email.trim() === '') {
      alert('Please enter a valid name and email');
      return;
    }

    try {
      const res = await addData(Stores.Users, { name, email, id });
    } catch (err: unknown) {
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError('Something went wrong');
      }
    }
  };

// ...

{!isDBReady ? (
  <button onClick={handleInitDB}>Init DB</button>
) : (
  <>
    <h2>DB is ready</h2>
    {/* add user form */}
    <form onSubmit={handleAddUser}>
      <input type="text" name="name" placeholder="Name" />
      <input type="email" name="email" placeholder="Email" />
      <button type="submit">Add User</button>
    </form>
    {error && <p style={{ color: 'red' }}>{error}</p>}
  </>
)}
Enter fullscreen mode Exit fullscreen mode

添加用户表单

如果我们添加用户并转到我们的应用程序(不要忘记刷新数据库中的陈旧数据),我们将在 IDB 中看到最新的输入。

IDB 数据

检索数据

好的,我们快到了。

现在我们将获取商店数据以便显示。让我们声明一个方法来获取所选商店的所有数据。

export const getStoreData = <T>(storeName: Stores): Promise<T[]> => {
  return new Promise((resolve) => {
    request = indexedDB.open('myDB');

    request.onsuccess = () => {
      console.log('request.onsuccess - getAllData');
      db = request.result;
      const tx = db.transaction(storeName, 'readonly');
      const store = tx.objectStore(storeName);
      const res = store.getAll();
      res.onsuccess = () => {
        resolve(res.result);
      };
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

在我们的 jsx 组件中

// ourComponent.tsx
const [users, setUsers] = useState<User[]|[]>([]);

// declare this async method
const handleGetUsers = async () => {
  const users = await getStoreData<User>(Stores.Users);
  setUsers(users);
};

// jsx
return (
  // ... rest
  {users.length > 0 && (
  <table>
    <thead>
      <tr>
        <th>Name</th>
        <th>Email</th>
        <th>ID</th>
      </tr>
    </thead>
    <tbody>
      {users.map((user) => (
        <tr key={user.id}>
          <td>{user.name}</td>
          <td>{user.email}</td>
          <td>{user.id}</td>
        </tr>
      ))}
    </tbody>
  </table>
  )}
  // rest
)
Enter fullscreen mode Exit fullscreen mode

我们在表格中看到了数据,而且这些数据会保留在浏览器中👏。

用户表

最后,为了使其更加动态,我们可以在不执行所有这些按钮的情况下进行任何这些流程,因为我们收到了承诺:

  const handleAddUser = async (e: React.FormEvent<HTMLFormElement>) => {
    // ...
    try {
      const res = await addData(Stores.Users, { name, email, id });
      // refetch users after creating data
      handleGetUsers();
    } catch (err: unknown) {
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError('Something went wrong');
      }
    }
  };
Enter fullscreen mode Exit fullscreen mode

删除行

最后,我们将了解如何从数据库中删除数据。这很简单,但是,我们必须记住,哪个是我们的Key Path(即唯一标识符或主键),用于指定所选商店的条目。在本例中,我们声明了id该标识符

// db.ts/initDb
db.createObjectStore(Stores.Users, { keyPath: 'id' });
Enter fullscreen mode Exit fullscreen mode

您也可以通过在开发工具中检查您的商店来直接验证

确定您的关键路径

对于更高级的键,您可以使用其他键路径,但我们不会在这里介绍这些情况,因此使用 id 作为 KP 是预期的选择。

删除选定行的方法将接受 storeName(在本例中为“users”)和 id 作为参数。

export const deleteData = (storeName: string, key: string): Promise<boolean> => {
  return new Promise((resolve) => {
    // again open the connection
    request = indexedDB.open('myDB', version);

    request.onsuccess = () => {
      console.log('request.onsuccess - deleteData', key);
      db = request.result;
      const tx = db.transaction(storeName, 'readwrite');
      const store = tx.objectStore(storeName);
      const res = store.delete(key);

      // add listeners that will resolve the Promise
      res.onsuccess = () => {
        resolve(true);
      };
      res.onerror = () => {
        resolve(false);
      }
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

承诺应该始终用块来处理try/catch

并在组件中有一个用于删除用户的处理程序,当然还有一个用于在表中选择所需元素的按钮

  const handleRemoveUser = async (id: string) => {
    try {
      await deleteData(Stores.Users, 'foo');
      // refetch users after deleting data
      handleGetUsers();
    } catch (err: unknown) {
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError('Something went wrong deleting the user');
      }
    }
  };

  // ...

  return (
    // ...
    {users.length > 0 && (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
            <th>ID</th>
            // header
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.id}</td>
              // here the button
              <td>
                <button onClick={() => handleRemoveUser(user.id)}>Delete</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    )}
  );
}
Enter fullscreen mode Exit fullscreen mode

单击“删除”按钮🪄后,该元素应该消失。

结论

说实话,我觉得这个 API 很奇怪,而且感觉缺少实现示例(至少对于 React 来说)和文档——当然,除了一些很棒的文档之外——而且在我看来,它应该只在少数特定情况下使用。有更多选择总是好的,如果你也是这种情况,那就继续吧,我在一个拥有数千名用户的应用程序中见证了一个真实的生产用例,而且我必须承认,IDB 是给我带来最少麻烦的功能。这个工具已经推出好几年了,所以它足够稳定,可以安全使用。


如果您想检查完整的代码,这里是repo

来源

MDN

照片来自Unsplash上的Catgirlmutant

额外的

一种有趣的承诺链方法

链接:https://dev.to/esponges/indexeddb-your-offline-and-serverless-db-in-your-browser-with-react-3hm7
PREV
Next.js 的 8 个顶级可定制 UI 库
NEXT
Python 转 .exe 如何将 .py 转为 .exe?分步指南。自动将 .py 转为 .exe