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 是一个事务型数据库系统,是运行在本地的浏览器内置数据库,可以实现快速增删改查操作,它与一般的关系型数据库理念相似,但是有一些地方不同:
- 不是关系型。即没有强制要求固定的列结构,也没有表与表之间的关联。存进去的是个 Javascript 对象。
- 不支持 SQL。
该数据库是异步非阻塞的,不会卡住页面。
所以,你可以把 IndexedDB 理解成一个浏览器里的简化版 NoSQL 数据库。
一些概念
- 数据库(Database):一个 IndexedDB 实例,通常一个应用使用一个库。
- 对象仓库(Object Store):类似表,用来存储同一种类型的对象。
- 索引(Index):为对象仓库中的某个字段建立索引,用于快速查询。
- 事务(Transaction):所有读写操作都必须在事务中进行,事务是异步的。
使用 IndexedDB
不支持 SQL?那我怎么用?IndexedDB 使用一系列 Javascript 接口调用。你只需要指定数据库模式,打开与数据库的连接,然后检索和更新一系列事务。
打开数据库
首先打开数据库,使用以下代码打开数据库:
const request = window.indexedDB.open("xxx", version);
其中 version 表示版本号,他的行为:
- 当前无库:创建,触发
onupgradeneeded事件,从无到有。 - 请求版本 > 现有版本:触发
onupgradeneeded,可执行结构变更。 - 请求版本 < 现有版本:报错
VersionError。 - 请求版本 == 现有版本:直接打开,不触发
onupgradeneeded。
onupgradeneeded 事件只在版本号提升时触发一次。它内部的回调函数是唯一允许修改数据库结构的地方。你不能在普通事务中调用 createObjectStore 或 createIndex,否则会报错。
如果你要修改当前数据库结构或者新建数据库,要求大于当前已有版本号来开启。
我们要实现:如果没有,新建数据库,否则直接打开,根据无库会触发 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 有三种取值:
readonly只读,这是默认模式,如果不指定 mode,就是readonly。readwrite读写,可以执行读写操作。versionchange版本升级。只能在onupgradeneeded事件回调中使用,用来创建/删除对象仓库和索引。不能手动创建这种事务。
有关函数:
objectStore(name)可以在一个事务上返回可操作的名字为name的存储仓库对象,注意openDB()函数返回的是数据库实例,这里返回的是存储仓库。put(value, key)在存储仓库上插入或更新记录,如果主键不存在,则插入新记录,如果主键已存在,则覆盖原有记录(更新),key是可选的如果该索引包含keyPath字段则不需要。add(value, key)与上一个函数的区别是如果存在则抛出错误。delete(key)根据主键删除某条数据。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);
});
}
有关函数:
get(key)返回主键key的记录,返回一条。getKey(key)返回主键key的主键值,不存在返回undefined。getAll(query, count)获取所有匹配指定条件的记录,返回一个数组,最多count个,满足主键值为query的数据,两个参数都是可选的。getAllKeys(query, count)前两个相加,即返回满足条件的主键数组,两个参数都是可选的。count(query)参数是可选的,返回主键值为query的记录数量。
游标操作
游标类似 C++ 的迭代器,适合处理大量数据或需要逐条处理的情况。
以这个函数为例讲解游标:
openCursor(query, direction) 参数都是可选的。打开一个游标,按主键顺序遍历记录。query 是主键值,direction 是遍历方向。
遍历方向:
next(默认):从小到大(主键升序)prev:从大到小nextunique:升序但跳过重复值(与索引配合)prevunique:降序跳过重复
使用成员函数 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 数据并进行快速查询的场景有很多,这里给出一个笔者使用这个方法完成的项目,欢迎学习和提出建议:
- https://github.com/karsl-program/LRJPP