深入 PouchDB 源码:浏览器存储 API 兼容性战役中的十个血泪教训
深入 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 和旧版 WebKitSafari ~5遇到 0 会直接崩溃。这就是 PouchDB 嗅探 Android 才把 size 提到 5000000、其余情况一律设为 1 的原因。作者还吐槽 W3C 官方示例用5*1024*1024误导了所有人实际规避弹框的临界值是 50000005 MB即 5 兆字节而非5*1024*10245 MiB5 兆二进制字节但网上博客与 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, doesnt matter as long as its 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被实现为一个Mapindex.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 encodingutf16; // select hex(A); // returns 4100 // notice that the 00 comes after the 41 (i.e. its 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 wont let us do that. but firefox doesnt // have the blob type bug that Chrome does, so thats 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还是base64var blobType api._meta.blobSupport ? blob : base64;见 bulkDocs.js#L63——存储层对应用透明但底下是两套完全不同的编码路径。5. IE 不支持 complex keysCouchDB 是 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; }其中normalizeKey把undefined/NaN/Infinity归一为null、Date 转字符串、对象键排序index.js#L36-L69indexify对字符串做 0/1/2 控制字符的顺序保持替换\u0000→\u0001\u0001等确保词法排序等价于 CouchDB collationindex.js#L71-L89。数字则被编码为带 3 位量级前缀的字符串-Number.MIN_VALUE到Number.MAX_VALUE都能保序index.js#L1-L5。同样的字符串拼接技巧在今天的pouchdb-adapter-idb里依然到处可见写入时doc._doc_id_rev metadata.id :: metadata.revbulkDocs.js#L256读出时再用lastIndexOf(:)拆回_id/_revutils.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, wed 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 的刁钻 bugissue 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捕获异常后才回退到 vuvuzelasafeJsonStringify.js#L1-L10import 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 影响了 IndexedDBIndexedDB 影响了 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_modules下pouchdb-adapter-leveldb、pouchdb-adapter-memory、pouchdb-adapter-websql-core、pouchdb-adapter-idb等适配器并存见 packages/node_modules 目录上层共享同一套 pouchdb-core 核心通过pouchdb-collate统一排序语义。从 IndexedDB 的早期讨论经 LevelDB 与 LevelUP 生态最终汇成 PouchDB——当我看 PouchDB 源码时这个巨大的成就仍让我起鸡皮疙瘩。它足以让你原谅所有古怪的 hack、workaround 和不优雅。PouchDB 居然能跑起来这本身就是一个小小的奇迹。结语兼容性工程的通用方法论回顾这十个案例可以提炼出几条放之四海皆准的工程原则把嗅探降到最低把特性检测用到极致getSize()的 UA 嗅探是少数不得不为之的特例而 Blob 支持、数据库编码等判断全部靠运行时特性检测完成为已知 bug 写注释、留链接parseHex.js头部保留的 Chromium/WebKit bug 编号让十年后的维护者依然知道为什么会有这段看起来多余的代码用平凡的编码技巧替代缺失的平台能力_doc_id_rev字符串拼接、toIndexableString的保序编码都是底层做不到就自己造轮子的典范性能与健壮性分层兜底safeJsonStringify先快后慢的降级策略是深度嵌套问题的标准解法怀疑规范、怀疑自己、怀疑浏览器最后相信测试第 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),仅供参考