Javascript 在浏览器中解析大型 JSON 数据并实现快速查询等操作

· · 科技·工程

::::warning[创作声明]

本文没有使用 GenAI 进行或辅助创作,没有使用 GenAI 润色。但使用了 GenAI 校验内容是否具有事实性错误。

::::

在很多工程项目中,我们可能需要处理大型 JSON 数据。如果要在浏览器里直接处理一个数百 MB 的 JSON 文件,直接 fetch 拿到文本后 JSON.parse,轻则页面卡死,重则直接崩溃。因此我们需要找到一种解决方案,可以在浏览器中优雅地解析大型的 JSON 数据。

如果数据是 NDJSON(Newline Delimited JSON,每行一个独立 JSON 对象)类型,且单行数据结构较为简单,我们可以考虑流式读入

::::info[为什么是 NDJSON]

标准 JSON 格式本身不支持真正的流式解析,因为 JSON 的语法允许任意嵌套和空白,必须完整读取整个文档才能验证其结构并构建对象树。

而 NDJSON 的优势很明显,如果对于每个 JSON 片段都有较为简单的结构,则可以独立的、分批的处理。

::::

解析大型 JSON 数据

为什么不能一次性解析?

大型 JSON 文件的问题主要有两个:内存占用高,而且会阻塞 UI 渲染进程导致看起来卡卡的,用户体验极差

NDJSON 格式则非常适合分批处理。每一行都是一个合法的 JSON 对象,行与行之间没有依赖关系。我们可以按块读取数据,逐行解析,然后攒够一批就操作(如写入数据库)。这样既控制了内存占用,也不会长时间阻塞页面,非常完美!

如何实现

浏览器提供了 fetch 配合 ReadableStream 来流式读取响应体。我们不用等待整个文件下载完成,而是边下载边处理。

async function importNDJSON(url, { batchSize = 1000, onProgress } = {}) {  // 每次处理 batchSize 条
  const response = await fetch(url);  // 获取响应
  const reader = response.body.getReader();  // 读取
  const decoder = new TextDecoder('utf-8');  // 解码,都是基本操作
  let buffer = '';  // 存放未处理完的文本
  let batch = [];  // 当前批次解析出来的对象塞到一个列表里
  let total = 0;  // 已经导入的数量

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;

    // stream: true 表示这是流式解码
    buffer += decoder.decode(value, { stream: true });

    // 按换行符切分
    const lines = buffer.split('\n');
    buffer = lines.pop();

    for (const line of lines) {  // 读取每行
      if (!line.trim()) continue;
      try {
        const obj = JSON.parse(line);
        batch.push(obj);

        if (batch.length >= batchSize) {  // 如果到要求了
          await appendToDB(batch);  // 塞入数据库,以下介绍,或者其他操作
          total += batch.length;
          onProgress?.(total);
          batch = [];
          // 让出主线程,避免长时间阻塞 UI
          await new Promise(resolve => setTimeout(resolve, 0));
        }
      } catch (e) {
        // 记录日志之类的,或者其他功能
      }
    }
  }

  // 处理最后剩余的行
  if (buffer.trim()) {
    try {
      batch.push(JSON.parse(buffer));
    } catch (e) {}
  }
  if (batch.length) {
    await appendToDB(batch);
    total += batch.length;
    onProgress?.(total);
  }
}

JSON.parse 只处理单行字符串,因为单行结构较为简单,所以解析开销很低。

到这里你就学会了如何流式读取和解析大型 NDJSON 文件啦!

我们确实可以解析大型 JSON 数据了,这时候肯定又会有人要问了,那这么多的数据占用这么大,怎么存,怎么用,如果直接存回去不是这么多努力就白费了,内存占用照样起飞(如 localStorage 是同步阻塞的,要读取时会占用 UI 渲染进程),而且这么大的数据大概率还不让存(如 localStorage 容量只有几 MB),而且我想用的时候怎么快速查找?

我们很容易想到——数据库

使用数据库实现快速查询等操作

一提到数据库,大家肯定都能想到 MySQL、PostgreSQL 这类关系型数据库,这些数据库通常跑在服务器上,而且擅长处理多用户并发、复杂关联和事务一致性。那有没有在浏览器里可以使用的数据库呢,当然有的,接下来我们介绍 IndexedDB

IndexedDB

IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blob))。该 API 使用索引实现对数据的高性能搜索。虽然 Web 存储在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心。

引用自 MDN

IndexedDB 是一个事务型数据库系统,是运行在本地的浏览器内置数据库,可以实现快速增删改查操作,它与一般的关系型数据库理念相似,但是有一些地方不同:

  1. 不是关系型。即没有强制要求固定的列结构,也没有表与表之间的关联。存进去的是个 Javascript 对象。
  2. 不支持 SQL。

该数据库是异步非阻塞的,不会卡住页面。

所以,你可以把 IndexedDB 理解成一个浏览器里的简化版 NoSQL 数据库

一些概念

  1. 数据库(Database):一个 IndexedDB 实例,通常一个应用使用一个库。
  2. 对象仓库(Object Store):类似表,用来存储同一种类型的对象。
  3. 索引(Index):为对象仓库中的某个字段建立索引,用于快速查询。
  4. 事务(Transaction):所有读写操作都必须在事务中进行,事务是异步的。

使用 IndexedDB

不支持 SQL?那我怎么用?IndexedDB 使用一系列 Javascript 接口调用。你只需要指定数据库模式,打开与数据库的连接,然后检索和更新一系列事务

打开数据库

首先打开数据库,使用以下代码打开数据库:

const request = window.indexedDB.open("xxx", version);

其中 version 表示版本号,他的行为:

onupgradeneeded 事件只在版本号提升时触发一次。它内部的回调函数是唯一允许修改数据库结构的地方。你不能在普通事务中调用 createObjectStorecreateIndex,否则会报错。

如果你要修改当前数据库结构或者新建数据库,要求大于当前已有版本号来开启。

我们要实现:如果没有,新建数据库,否则直接打开,根据无库会触发 onupgradeneeded 事件的特性,我们实现以下代码:

const DB_NAME = 'DB';
const DB_VERSION = 1;

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (e) => {
      const db = e.target.result;
      // A 作为主键
      const store = db.createObjectStore('A', { keyPath: 'pid' });
      // 为 B 建立普通索引
      store.createIndex('B', 'B', { unique: false });
      // 为 C 建立多入口索引
      store.createIndex('C', 'C', { multiEntry: true });
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

createObjectStore(name, options) 可以创建一个名字为 name 的存储仓库(可以理解为表),options 可以填 keyPath,介绍见下,options 留空表示使用 out-of-line 主键,每次插入必须显式提供主键值,且主键不会自动生成。

createIndex(name, keyPath, options) 方法可以创建一个名字为 name,在插入对象的时候使用 keyPath 字段作为索引(这里很容易混淆,这里的索引指的是比如要插入对象 {A: 1},名字可以任意取,但是 keyPath 必须为 A),具有 options 功能的索引。

多入口索引是指将数组内每个标签都作为独立的索引项,在之后可以通过数组内任意值来查找到这个元素。

unique: false 用于允许重复。

写入数据

批量写入是性能的关键。不要每解析一条就开启一个事务,那样开销巨大。正确做法是一个事务中写入多条。

我们使用 db.transaction(storeNames, mode) 来创建一个在 storeNames 存储仓库上的事务,可以是多个存储仓库组成的数组。

mode 有三种取值:

  1. readonly 只读,这是默认模式,如果不指定 mode,就是 readonly
  2. readwrite 读写,可以执行读写操作。
  3. versionchange 版本升级。只能在 onupgradeneeded 事件回调中使用,用来创建/删除对象仓库和索引。不能手动创建这种事务。

有关函数:

  1. objectStore(name) 可以在一个事务上返回可操作的名字为 name 的存储仓库对象,注意 openDB() 函数返回的是数据库实例,这里返回的是存储仓库
  2. put(value, key)存储仓库上插入或更新记录,如果主键不存在,则插入新记录,如果主键已存在,则覆盖原有记录(更新),key 是可选的如果该索引包含 keyPath 字段则不需要。
  3. add(value, key) 与上一个函数的区别是如果存在则抛出错误。
  4. delete(key) 根据主键删除某条数据。
  5. clear() 清空存储仓库所有数据。

事务所有请求完成后会触发 oncomplete 事件,单个请求完成后会触发 onsuccess 事件,错误会触发 onerror 事件。

async function appendToDB(records) {
  const db = await openDB();
  return new Promise((resolve, reject) => {  // resolve, reject 是两个回调函数
    const tx = db.transaction('A', 'readwrite');
    const store = tx.objectStore('B');

    for (const record of records) {
      store.put(record);
    }

    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

查询数据

写入完成后,我们可以通过索引高效查询。

仿照以上,我们可以创建一个只读事务,进行查询。

async function queryByA(A) {
  const db = await openDB();
  const tx = db.transaction('B', 'readonly');
  const store = tx.objectStore('B');
  const index = store.index('A');  // 介绍见下

  return new Promise((resolve, reject) => {
    const request = index.getAll(A);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

有关函数:

  1. get(key) 返回主键 key 的记录,返回一条。
  2. getKey(key) 返回主键 key 的主键值,不存在返回 undefined
  3. getAll(query, count) 获取所有匹配指定条件的记录,返回一个数组,最多 count 个,满足主键值为 query 的数据,两个参数都是可选的。
  4. getAllKeys(query, count) 前两个相加,即返回满足条件的主键数组,两个参数都是可选的。
  5. count(query) 参数是可选的,返回主键值为 query 的记录数量。

游标操作

游标类似 C++ 的迭代器,适合处理大量数据或需要逐条处理的情况。

以这个函数为例讲解游标:

openCursor(query, direction) 参数都是可选的。打开一个游标,按主键顺序遍历记录。query 是主键值,direction 是遍历方向。

遍历方向:

使用成员函数 continue() 来跳转到下一个。

示例代码:

const request = store.openCursor();
request.onsuccess = (event) => {
  const cursor = event.target.result;
  if (cursor) {
    console.log(cursor.key, cursor.value); // 主键和对象
    cursor.continue(); // 继续下一个
  } else {
    console.log('遍历完成');
  }
};

另一个游标函数:openKeyCursor(query, direction) 把上一个的记录换成仅包含主键。

索引操作

索引是对对象仓库中某个字段的映射,可以加速基于该字段的查询。通过 store.index(indexName) 获取索引对象,它提供了与对象仓库类似的查询方法。

索引支持所有对象仓库上的非写入操作。

到这里就对这个数据库介绍的差不多了。相信你已经对 IndexedDB 了如指掌了!快去自己试试吧。

示例项目

需要解析大型 JSON 数据并进行快速查询的场景有很多,这里给出一个笔者使用这个方法完成的项目,欢迎学习和提出建议: