深入 PouchDB 源码:浏览器存储 API 兼容性战役中的十个血泪教训
2026/9/21 16:26:38 网站建设 项目流程

深入 PouchDB 源码:浏览器存储 API 兼容性战役中的十个血泪教训

【免费下载链接】pouchdb:kangaroo: - PouchDB is a pocket-sized database.项目地址: https://gitcode.com/gh_mirrors/po/pouchdb

在 CouchDB 兼容的客户端数据库中,PouchDB 的价值不仅在于"在浏览器里跑 CouchDB",更在于它必须在 Web SQL、IndexedDB、LocalStorage 乃至 Node.js 的 LevelDB 之间反复横跳,把五花八门的浏览器差异一一抹平。本文源自 PouchDB 核心维护者 Nolan Lawson 的实战笔记《10 things I learned from reading (and writing) the PouchDB source》,我们将以这篇文档为主线,结合当前仓库中packages/node_modules下的适配器源码,逐条剖析 Web SQL 与 IndexedDB 的十个"坑",并展示 PouchDB 是如何用 user-agent 嗅探、特性检测、字符串拼接键、非递归 JSON 序列化等"土办法"化解它们的。读完本文,你将理解浏览器存储 API 的底层行为差异,也能掌握跨端存储兼容性工程的具体套路。

背景:作者于 2013 年底加入 PouchDB 项目时,PouchDB 已相当成熟(首个提交距今已四年)。他的目标集中在提升性能与浏览器兼容性——而"浏览器兼容性"正是 Web 世界里那个 Android 生态闻之色变的"碎片化"难题。下文涉及 LocalStorage、Web SQL、IndexedDB 三种存储 API,若读者不熟悉可先阅读 浏览器存储概览 了解 PouchDB 视角下的存储适配器分层。


1. 没有人说得清 Web SQL 的 "estimated size" 到底是什么意思

打开 Web SQL 数据库时需要使用openDatabase(),最后一个参数是所谓的estimated size(预估大小):

var db = openDatabase('documents', '1.0', 'some description', 5000000);

当年 PouchDB 是这样设置它的(文档原文):

function getSize(opts) { /* ... */ var isAndroid = /Android/.test(window.navigator.userAgent); return isAndroid ? 5000000 : 1; }

User-agent 嗅探!没错,这确实不够优雅。但理由很现实:

  • 现代 Chrome 与 Android 4.4+上,这个 size 会被直接忽略,浏览器自行根据磁盘剩余空间设定上限;
  • Android < 4.4上,它是一个硬性上限:传 5000000 就永远只有 5 MB;
  • Safari/iOS上则更微妙:传大于 5000000 的值,应用首次加载就会弹出烦人的容量确认框(见下图),极易吓跑用户;传小于 5000000 的值,数据库涨到 5 MB 时会再次弹框;而 iOS 7.1 还有一个 bug——弹框次数耗尽后不再出现,于是容量被永久钉死在 10 MB,想存更多就必须在一开始就要得更多;
  • 传 0 到 5000000 之间的值,Safari/iOS 会把它当作"何时弹框"的提示;PouchDB 的自动化测试跑在 Selenium 下无法点击"OK"按钮,所以理想值是 0;
  • PhantomJS 和旧版 WebKit(Safari ~5)遇到 0 会直接崩溃。

这就是 PouchDB 嗅探 Android 才把 size 提到 5000000、其余情况一律设为 1 的原因。作者还吐槽 W3C 官方示例用5*1024*1024误导了所有人:实际规避弹框的临界值是 5000000(5 MB,即 5 兆字节),而非5*1024*1024(5 MiB,5 兆二进制字节),但网上博客与 Stack Overflow 到处流传着错误的1024*1024写法。

今天仓库里的源码印证了这段历史packages/node_modules/pouchdb-adapter-websql-core/src/utils.js中的getSize()(utils.js#L164-L179)保留了几乎相同的逻辑,并补充了关键注释:

function getSize(opts) { if ('size' in opts) { // triggers immediate popup in iOS, fixes #2347 // e.g. 5000001 asks for 5 MB, 10000001 asks for 10 MB, return opts.size * 1000000; } // In iOS, doesn't matter as long as it's <= 5000000. // Except that if you request too much, our tests fail // because of the native "do you accept?" popup. // In Android <=4.3, this value is actually used as an // honest-to-god ceiling for data, so we need to // set it to a decently high number. var isAndroid = typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent); return isAndroid ? 5000000 : 1; // in PhantomJS, if you use 0 it will crash }

可见后来的代码还增加了对opts.size显式配置的支持(单位按 1e6 换算),而5000000 : 1的兜底策略与当年的实现一脉相承。该值最终被传入openDatabase,见 pouchdb-adapter-websql-core/src/index.js#L126-L146。

2. IE 的 IndexedDB 存在竞态条件

微软的 IndexedDB 实现速度很快——比 Chrome 慢一点,但远快于 Firefox。然而为了这个速度他们显然走了捷径:IE10 与 IE11 存在多个令人头疼的竞态条件。

因此 PouchDB 源码中常见这类防御性代码(文档原文):

//Close open request for "name" database to fix ie delay. if (IdbPouch.openReqList[name] && IdbPouch.openReqList[name].result) { IdbPouch.openReqList[name].result.close(); }

以及把所有 "open" 和 "destroy" 操作串行化的任务队列:

taskQueue.queue.push({ action: function (thisCallback) { destroy(name, opts, thisCallback); }, callback: callback });

还有按名称缓存所有数据库的cachedDBs——因为 IE 不允许同时打开两个同名的数据库:

var cached = cachedDBs[name]; if (cached) { idb = cached.idb; /* ... */ }

这些经验在今天仓库的pouchdb-adapter-idb中依旧可见:openReqList被实现为一个Map(index.js#L54),在打开请求完成后从列表中移除(index.js#L629-L659);串行化打开/销毁的机制则被提炼为独立的 taskQueue.js 模块,通过enqueueTask对外暴露(index.js#L48)。作者对 IE 团队的态度是"功过相抵"——他们响应 bug 报告相当迅速。

3. Web SQL 中的二进制数据一团糟

Web SQL 规范制定时,Blob 和 ArrayBuffer 都还没有标准化。SQLite 本身支持二进制 BLOB 类型,但要往 Web SQL 里存二进制,只能用老办法:传 JavaScript 二进制字符串。这带来两个棘手问题:

  • \u0000被当作字符串终止符:WebKit 与 Chromium 都存在这个 bug——插入和排序没问题,但读出来时数据会被截断。由于 BLOB 必须以二进制字符串插入,任何含 0 字节的二进制数据都会被截断。唯一的绕法是SELECT HEX(columnName),用十六进制字符串取回完整数据;
  • HEX() 也有问题:Safari < 7.1 与 iOS < 8 把所有字符串强制转成 UTF-16,导致同样的十六进制串在 UTF-8 浏览器(Chrome/Opera/Android 及新版 Safari/iOS)与 UTF-16 浏览器(早期 Safari/iOS)里必须用不同方式解析。

于是有了文档中这段"好玩"的代码:

function parseHexString(str, encoding) { var result = ''; var charWidth = encoding === 'UTF-8' ? 2 : 4; for (var i = 0, len = str.length; i < len; i += charWidth) { var substring = str.substring(i, i + charWidth); if (charWidth === 4) { // UTF-16, twiddle the bits substring = substring.substring(2, 4) + substring.substring(0, 2); } result += String.fromCharCode(parseInt(substring, 16)); } result = encoding === 'UTF-8' ? decodeUtf8(result) : result; return result; }

(作者自嘲 "twiddle the bits" 注释不准确,正确的术语是 nibble-swizzling,即高低字节交换。)

判断数据库是 UTF-8 还是 UTF-16 则靠特性检测——直接查询dbid及其十六进制形式,比较长度:

function checkDbEncoding(tx) { // check db encoding - utf-8 (chrome, opera) or utf-16 (safari)? tx.executeSql('SELECT dbid, hex(dbid) AS hexId FROM ' + META_STORE, [], function (tx, result) { var id = result.rows.item(0).dbid; var hexId = result.rows.item(0).hexId; encoding = (hexId.length === id.length * 2) ? 'UTF-8' : 'UTF-16'; } ); }

由于是特性检测,Safari 7.1+ 与 iOS 8+ 上可以"自动正常工作"。作者还预告:PouchDB 3.1.0 起对大二进制附件不再 hex 化(性能太差),改为剔除\u0000字符并在取回时还原。

这段历史在今天被整理成了一个独立模块parseHex.js,头部注释直接引用了当年的两个 bug 链接(Chromium 422690 与 WebKit 137637),并把 UTF-8/UTF-16 拆成两个函数以换取微小的性能提升:

// Example: // pragma encoding=utf16; // select hex('A'); // returns '4100' // notice that the 00 comes after the 41 (i.e. it's swizzled) function parseHexUtf16(str, start, end) { var result = ''; while (start < end) { // UTF-16, so swizzle the bytes result += String.fromCharCode( (hexToInt(str.charCodeAt(start + 2)) << 12) | (hexToInt(str.charCodeAt(start + 3)) << 8) | (hexToInt(str.charCodeAt(start)) << 4) | hexToInt(str.charCodeAt(start + 1))); start += 4; } return result; }

源码注释里那句 "Parsing hex strings. Yeah." 隔着十年依然能读出当年的无奈。

4. IndexedDB 里的二进制数据同样一团糟

作为 Web SQL 的"时髦弟弟",IndexedDB 理应原生支持 Blob。但现实是:Chrome 直到 v37 才支持 Blob,而苹果(在修复 IndexedDB 更基础的问题之前)也明确不打算支持。这些情况下,PouchDB 退而求其次,把 Blob 存成 base64 字符串,并用特性检测来判定:

try { var blob = utils.createBlob([''], {type: 'image/png'}); txn.objectStore(DETECT_BLOB_SUPPORT_STORE).put(blob, 'key'); txn.oncomplete = function () { /* ... */ blobSupport = true; /* ... */ }; } catch (err) { blobSupport = false; /* ... */ }

然而事情没这么简单:Chrome v37 虽然实现了 Blob,却实现错了——取回时返回错误的 MIME 类型。所以 v37 需要单独检测这种"坏支持",v38 起才能与其他浏览器一视同仁:

var storedBlob = e.target.result; var url = URL.createObjectURL(storedBlob); utils.ajax({ url: url, cache: true, binary: true }, function (err, res) { if (err && err.status === 405) { // firefox won't let us do that. but firefox doesn't // have the blob type bug that Chrome does, so that's ok blobSupport = true; } else { blobSupport = !!(res && res.type === 'image/png'); } });

Firefox 在这里也有个小 bug,好在 nightly 版已修复。于是出现了荒诞的一幕:PouchDB 需要为 Chrome v36、v37、v38 各准备一种策略,而 Android 上冻结的各代 Chromium 内核,意味着这三种变体还将在野外长期共存。

今天的pouchdb-adapter-idb仍保留了完整的检测管线:checkBlobSupport(txn, DETECT_BLOB_SUPPORT_STORE, 'key')(index.js#L784-L790),并把结果记录在元信息里,后续写入时据此决定附件格式是'blob'还是'base64'

var blobType = api._meta.blobSupport ? 'blob' : 'base64';

(见 bulkDocs.js#L63)——存储层对应用透明,但底下是两套完全不同的编码路径。

5. IE 不支持 complex keys

CouchDB 是 NoSQL 的元老,顺理成章地影响了 IndexedDB 的设计。CouchDB 一个强大而微妙的功能是complex keys:视图的 key 可以是任意 JSON 值,而不只是字符串。经典用例是把博文及其评论放进同一个视图:

function(doc) { if (doc.type == "post") { map([doc._id, 0], doc); } else if (doc.type == "comment") { map([doc.post, 1], doc); } }

key 是一个"字符串 + 整数"的数组,排序时先按字符串、再按整数。这个特性确实写进了 IndexedDB 规范,对"要在 IndexedDB 上重写 CouchDB"的 PouchDB 而言简直完美。然而 IE 不支持 complex keys,所以源码里出现的是这种"伪复合键":

docInfo.data._doc_id_rev = docInfo.data._id + "::" + docInfo.data._rev; var seqStore = txn.objectStore(BY_SEQ_STORE); var index = seqStore.index('_doc_id_rev');

查询时则用边界范围:

var start = docId + "::"; var end = docId + "::~"; var index = seqStore.index('_doc_id_rev'); var range = global.IDBKeyRange.bound(start, end, false, false); var seqCursor = index.openCursor(range);

_id_rev"::"拼成一个字符串——故意选"~"(ASCII 0x7E)作为结束边界,因为任何合法字符都排在它之前。这是有意为之,不是失误。

这条设计还深刻影响了持久化 map/reduce:既然不能指望底层数据库按多字段排序,PouchDB 干脆发明了toIndexableString()——把任意 JSON 对象编码成一条按 CouchDB collation 顺序排列的大字符串。

这段设计今天完整地活在pouchdb-collate包中toIndexableString先把 key 规范化(index.js#L116-L120):

// convert the given key to a string that would be appropriate // for lexical sorting, e.g. within a database, where the // sorting is the same given by the collate() function. function toIndexableString(key) { var zero = '\u0000'; key = normalizeKey(key); return collationIndex(key) + SEP + indexify(key) + zero; }

其中normalizeKeyundefined/NaN/Infinity归一为null、Date 转字符串、对象键排序(index.js#L36-L69);indexify对字符串做 0/1/2 控制字符的顺序保持替换(\u0000\u0001\u0001等),确保词法排序等价于 CouchDB collation(index.js#L71-L89)。数字则被编码为带 3 位量级前缀的字符串,-Number.MIN_VALUENumber.MAX_VALUE都能保序(index.js#L1-L5)。

同样的字符串拼接技巧在今天的pouchdb-adapter-idb里依然到处可见:写入时doc._doc_id_rev = metadata.id + '::' + metadata.rev(bulkDocs.js#L256),读出时再用lastIndexOf(':')拆回_id/_rev(utils.js#L57-L65),并且在docIdRevIndex上建立了unique: true的唯一索引(index.js#L89)。allDocs、changes 等模块均复用了这个索引做范围游标(allDocs.js#L106、changes.js#L206)。

6. 反向迭代时 start > end 会抛错(其实是个误会)

文档中附带了一段更新说明:作者后来承认自己误解了 IndexedDB 规范——其实把IDBKeyRange的 start 和 end 对调就能在所有浏览器里反向迭代(PouchDB 据此修复,见 issue 3488)。但在当时,这个"符合规范的 bug"在 Firefox、IE、Chrome 三大浏览器中忠实复现:

try { if (start && end) { keyRange = global.IDBKeyRange.bound(start, end, false, !inclusiveEnd); } else if (start) { /* ... */ } } catch (e) { if (e.name === "DataError" && e.code === 0) { // data error, start is less than end return callback(null, { total_rows : totalRows, offset : opts.skip, rows : [] }); } else { return callback(errors.error(errors.IDB_ERROR, e.name, e.message)); } }

IndexedDB 对任何 start 大于 end 的IDBKeyRange都会抛错,即使你正在反向迭代。当时的绕法是手动检查结束键:

if (manualDescEnd) { if (inclusiveEnd && doc.key < manualDescEnd) { return; } else if (!inclusiveEnd && doc.key <= manualDescEnd) { return; } }

代价很小:只是多取一个多余的键而已。这个案例也提醒我们:面对"浏览器都这样"的行为,先怀疑自己对规范的理解,再怀疑浏览器。

7. IndexedDB 与 Web SQL 对回调"严防死守"

在 IndexedDB 和 Web SQL 中,想在事务里用 Promise 甚至再调用一个回调都是奢望:一旦控制权交还事件循环,事务就自动关闭。所以用户侧的 PouchDB API 可以优雅地 Promise 化(得益于 Calvin Metcalf 的 lie 库),但 PouchDB 内部代码是彻底的"回调地狱"。

文档展示了当时 IndexedDB 适配器(约 400 行)与 Web SQL 适配器(约 400 行)的缩影:

verifyAttachments(function (err) { if (err) { return callback(err); } /* ... */ });

以及这种山寨版Promise.all()

function checkDoneWritingDocs() { if (++numDocsWritten === docInfos.length) { complete(); } }

如果需要调用 FileReader 这类外部回调 API,还必须小心翼翼地挪到事务之外,于是出现preprocessAttachments()这类前置处理函数:

preprocessAttachments(function () { db.transaction(function (txn) { /* ... */ }); });

作者的结论很实在:"如果我们没有大量的集成测试,我们几乎不敢相信这些代码能跑。" 这正是 tests/integration 下数百个测试文件存在的意义——从 test.basics.js 到 test.attachments.js,每一个行为都被浏览器矩阵反复验证。

8. 递归是把双刃剑

先看文档引用的这段代码:

// Unfortunately, the metadata has to be stringified // when it is put into the database, because otherwise // IndexedDB can throw errors for deeply-nested objects. // Originally we just used JSON.parse/JSON.stringify; now // we use this custom vuvuzela library that avoids recursion. // If we could do it all over again, we'd probably use a // format for the revision trees other than JSON. function encodeMetadata(metadata, winningRev, deleted) { var storedObject = {data: vuvuzela.stringify(metadata)}; storedObject.winningRev = winningRev; storedObject.deletedOrLocal = deleted ? '1' : '0'; storedObject.id = metadata.id; return storedObject; }

这是一个影响所有浏览器甚至 Node.js 的刁钻 bug(issue 2543):任何接受对象作为输入的原生函数,如JSON.stringify()或 IndexedDB 的put(),对传入对象的嵌套深度都有硬性上限

var object = { enhance: { enhance: { enhance: { /* and so on */ } } } };

上限值随可用内存浮动,一旦触顶就会抛 "too much recursion" 或 "maximum call stack" 错误,用户得到的是一个崩溃的 PouchDB。深层嵌套的来源正是文档的 revision tree——每次文档更新都在树上叠一个节点,深度会无限增长。

解法是作者与 Calvin 合写的一个名字滑稽的非递归 JSON 库 vuvuzela。它比原生方法慢,但在"绝不能崩溃"的场景里是救命稻草。今天这个策略被保留在pouchdb-json包里:优先用原生JSON.stringify,捕获异常后才回退到 vuvuzela(safeJsonStringify.js#L1-L10):

import vuvuzela from 'vuvuzela'; function safeJsonStringify(json) { try { return JSON.stringify(json); } catch (e) { /* istanbul ignore next */ return vuvuzela.stringify(json); } }

对称的 safeJsonParse.js 处理解析方向。这也是"性能与健壮性二选一"的典型工程决策:先快,崩了再慢而稳地兜底。

9. IndexedDB 中 unique index 抛约束错误,keyPath 却不抛

这是又一个反直觉的设计,让作者大为意外。SQLite/Web SQL 中,主键与唯一索引基本等价——重复插入都会报约束错误:

CREATE TABLE employees (id PRIMARY KEY UNIQUE, name); CREATE TABLE employees (id, name); CREATE UNIQUE INDEX id_index ON employees (id);

但 IndexedDB 中,带主键(keyPath)的 object store 插入重复键不会报错,而是静默覆盖原记录——put()本质是 upsert。唯一索引则截然不同,重复插入确实会抛错。也就是说以下两种写法并不等价

db.createObjectStore('employees', {keyPath : 'id'}); db.createObjectStore('employees').createIndex('id', 'id', {unique: true});

(文末作者补充:若想用 keyPath 也拿到约束错误,可以用add()代替put()。)

这对数据库设计的影响是结构性的:选用哪种模式,直接决定了"重复写入是覆盖还是报错"。PouchDB 之所以坚持用唯一索引而非裸 keyPath 来约束_doc_id_rev(见第 5 节的createIndex('_doc_id_rev', '_doc_id_rev', {unique: true})),正是为了在写入重复的 doc_id/rev 时能可靠地检测冲突,从而支撑 CouchDB 的 MVCC 修订模型。读者在实现自己的 IndexedDB 层时,务必先想清楚自己要的是 upsert 语义还是冲突检测语义。

10. CouchDB 影响了 IndexedDB,IndexedDB 影响了 LevelDB,然后呢?

数据库设计从来不是在真空中进行的。文档梳理了这条血脉:

  • Web SQL最初受Google Gears启发——后者在 2008 年一度有望成为移动 Web 存储标准;两者都离不开SQLite,而 SQLite 创始人 Richard Hipp 坦言 SQLite 深受PostgreSQL影响;
  • 尽管Web SQL 规范最终被废弃,它深刻影响了后辈 IndexedDB:两者共享异步结构、自动关闭的事务和几乎逐字复制的安全模型;Mozilla 与 Apple 还各自(独立地!)把 IndexedDB 实现建在 SQLite 之上;
  • 更妙的是,IndexedDB 早期讨论中就能看到CouchDB 的影子——complex keys、start/end key 迭代、类文档数据模型皆源于此。IndexedDB 设计者 Nikunj Mehta 早在 2009 年就说:"有些人觉得 [IndexedDB] 很适合做一个 JavaScript 版 CouchDB。" 某种意义上,这就是 PouchDB 最早的理念宣言;
  • Google 又用 LevelDB 实现了 IndexedDB 规范,LevelDB 借由 LevelUP 项目在 Node.js 生态中声名鹊起。PouchDB 也顺势搭上了 LevelUP 的船,在 Node.js 端用 LevelDB 实现了近乎完整的 CouchDB HTTP API(即 PouchDB Server)。

这条链条在今天的仓库中依然清晰可辨:packages/node_modulespouchdb-adapter-leveldbpouchdb-adapter-memorypouchdb-adapter-websql-corepouchdb-adapter-idb等适配器并存(见 packages/node_modules 目录),上层共享同一套 pouchdb-core 核心,通过pouchdb-collate统一排序语义。从 IndexedDB 的早期讨论,经 LevelDB 与 LevelUP 生态,最终汇成 PouchDB——"当我看 PouchDB 源码时,这个巨大的成就仍让我起鸡皮疙瘩。它足以让你原谅所有古怪的 hack、workaround 和不优雅。PouchDB 居然能跑起来,这本身就是一个小小的奇迹。"


结语:兼容性工程的通用方法论

回顾这十个案例,可以提炼出几条放之四海皆准的工程原则:

  1. 把嗅探降到最低,把特性检测用到极致getSize()的 UA 嗅探是少数不得不为之的特例,而 Blob 支持、数据库编码等判断全部靠运行时特性检测完成;
  2. 为已知 bug 写注释、留链接parseHex.js头部保留的 Chromium/WebKit bug 编号,让十年后的维护者依然知道"为什么会有这段看起来多余的代码";
  3. 用平凡的编码技巧替代缺失的平台能力_doc_id_rev字符串拼接、toIndexableString的保序编码,都是"底层做不到,就自己造轮子"的典范;
  4. 性能与健壮性分层兜底safeJsonStringify先快后慢的降级策略,是深度嵌套问题的标准解法;
  5. 怀疑规范、怀疑自己、怀疑浏览器,最后相信测试:第 6 条的反转说明连核心维护者都会误读规范,而 tests/integration 的庞大测试矩阵才是 PouchDB 能在如此多的浏览器上存活下来的真正底牌。

对于今天仍在浏览器存储领域耕耘的开发者,这些来自 2014 年的教训并未过时——IndexedDB 的怪癖依然存在,新的存储 API(如 OPFS、Storage Buckets)也正在孕育自己的 quirks。读懂 PouchDB 当年如何驯服这些怪癖,就是为下一场兼容性战役做的最好准备。

【免费下载链接】pouchdb:kangaroo: - PouchDB is a pocket-sized database.项目地址: https://gitcode.com/gh_mirrors/po/pouchdb

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询