传统的Web应用架构是客户端包含HTML、CSS和JavaScript,服务器端包含一个数据库。而通过强大的HTML5 API可以实现客户端数据库。这些不是通过网络访问服务器端数据库的客户端API,而是真正存储在用户电脑上的客户端数据库,通过浏览器中的JavaScript代码可以直接访问的。
Web存储API可以认为是一种简单的数据库,用于将简单的键/值对形式的数据持久化下来。但是,除此之外,还有两个真正的客户端数据库API。其中一个叫Web SQL数据库,它是支持基本SQL査询的简单关系数据库。Chrome、Safari和Opera已经实现了该API,但是Firefox和IE还没有,并且看起来也不打算实现了。官方标准中关于此API的工作已经停止了,此功能齐全的SQL数据库或许永远也不会成为官方标准,哪怕是作为Web平台非官方的交互特性恐怕也不大可能。
目前官方标准已经将注意力转移到了另一种数据库API,叫做:IndexedDB,介绍关于此API的详细细节还为时过早,但是Firefox 4和Chrome 11已经实现了此API,同时,本节也包含了一些例子,展示了IndexedDB API中一些最重要的特性。
IndexedDB是一个对象数据库,而不是关系数据库,它比支持SQL査询的数据库简单多了。但是,它要比Web存储API支持的键/值对存储更强大、更高效、更健壮。与Web存储和文件系统API—样,IndexedDB数据库的作用域也是限制在包含它们的文档源中:两个同源的Web页面互相之间可以访问对方的数据,但是非同源的页面则不行。
每个源可以有任意数目的IndexedDB数据库。但是每个数据库的名字在该源下必须是唯一的。在IndexedDB API中,一个数据库其实就是一个命名对象存储区(object store)的集合。顾名思义,对象存储区自然存储的是对象(也可以存储任意可以复制的值)。每个对象都必须有一个键(key),通过该键实现在存储区中进行该对象的存储和获取。键必须是唯一的——同一个存储区中的两个对象不能有同样的键一并且它们必须是按照自然顺序存储,以便于査询。JavaScript中的字符串、数字和日期对象都可以作为该键。当把一个对象存储到IndexedDB数据库中时,IndexedDB数据库可以为该对象自动生成一个唯一的键。不过,通常情况下,存储一个对象的时候,该对象就已经包含一个属性,该属性适合用做键。这种情况下,在创建一个对象存储的时候,可以为该属性指定一条“键路径”。从概念上来说,键路径其实就是一个值,用于告诉数据库如何从一个对象中抽取出该对象的键。
除了通过键值从一个对象存储区中获取对象以外,可能还想要能够基于该对象中的其他属性值进行査询。要实现该功能,可以通过在对象存储区上定义索引。(之所以叫“IndexedDB”就是因为可以在对象存储区上创建索引)。每一个索引就等于是为存储的对象定义了次键。这些索引通常都不是唯一的,多个对象也可能匹配一个键值。因此,当通过索引在对象存储区中进行査询的时候,通常需要使用游标(cursor),它定义一个用于一次一个地获取流査询结果的API。在当需要在对象存储区(或者索引中)査询一定范围的键的时候还可以使用游标,IndexedDB API包含一个用于描述键值范围(上限和/或下限,开区间或者闭区间)的对象。
IndexedDB提供原子性的保证:对数据库的査询和更新都是包含在一个事务 (transaction)中,以此来确保这些操作要么是一起成功,要么是一起失败,并且永远不会让数据库出现更新到一半的情况。IndexedDB中的事务要比很多数据库API中的事务简单得多:后面会再次介绍它们。
从概念上来说,IndexedDB API非常简单。要査询或者更新数据库,首先打开该数据库(通过指定名字)。然后,创建一个事务对象,并使用该对象在数据库中通过指定名字査询对象存储区。最后,调用对象存储区的
get()
方法来查询对象或者调用put()
方法来存储新的对象。(或者如果要避免覆盖已存在对象的情况,可以调用add()
方法)。如果想要査询表示键值范围的对象,通过创建一个IDBRange对象,并将其传递给对象存储区的openCursor()
方法。或者,如果想要使用次键进行査询的话,通过査询对象存储区中的命名索引,然后调用索引对象上的get()
方法或者openCursor()
方法。
然而,这种概念简易性还是比较复杂的,IndexedDB API必须要是异步的,这样能够实现让Web应用使用这些API的同时又不阻塞浏览器的UI主线程。(IndexedDB标准定义了一个给Worker线程使用的同步版本的API。)创建事务以及査询对象存储区和索引是比较简单的同步操作。但是,打开数据库、通过
put()
方法更新对象存储区、通过get()
方法或openCursor()
。査询对象存储区或者索引,这些操作都是异步的。这些异步方法都会立即返回一个request对象。当请求成功或者失败的时候,浏览器会在该request对象上出触发对应的success事件或者error事件,与此同时,还可以通过onsuccess属性和onerror属性来定义事件处理程序。在onsuccess处理程序中,可以通过request对象的result属性来获取操作的结果。
异步API中一个比较方便的特性就是它简化了事务管理。使用IndexedDB API的时候,通常是先打开数据库。这是一个异步的操作,因此它会触onsucccess事件处理程序。在该处理程序中,创建一个事务对象,然后使用该事务对象来査询对象存储区或者使用的存储区。之后,调用该对象存储区上的
get()
方法和put()
方法。所有这些操作都是异步的,因此不会立马有结果,但是,通过调用get()
方法和put()
方法生成的请求会自动和事务对象关联。如果需要的话,可以通过调用事务对象的abort()
方法来撤销事务中所有挂起的操作(也可以撤销已经完成的操作)。在许多其他的数据库API中,事务对象都需要调用commit。方法来完成事务。然而,在IndexedDB中,在创建该事务对象的原始onsuccess事件处理程序退出,并且浏览器返回到事件循环中以及事务中所有挂起的操作都完成之后,就会提交事务(不需要在它们的回调函数中开始新的操作)。这听起来貌似很复杂,事实上,实践起来非常容易。尽管,在査询对象存储区的时候,IndexedDB API强制要求创建事务对象,但是,通常情况下,不必考虑太多事务问题。
最后,还有一种特殊的事务,它是IndexedDB API中很重要的一部分。通过IndexedDB API创建一个新的数据库是很容易的:只需要选个名字然后要求打开该数据库。不过,新的数据库是完全空的,除非将一个或多个对象存储区(索引也可以)添加到该数据库中,否则该数据库只是摆设,毫无用处。创建对象存储区和索引只能在request对象的onsuccess事件处理程序中完成,request对象是调用数据库对象的
setVersion()
方法返回的。setVersion()
方法用于指定数据库的版本号一通常都是这么用的,每次更改数据库结构的时候就更新该版本号。但是,更重要的是,调用setVersion()
方法会隐式地开始一类特殊的事务,在该事务中,允许调用数据库对象的createObjectStore()
方法和对象数据区的createIndex()
方法。
对IndexedDB有了一定认识,现在应该能够看懂下例了。该例使用IndexedDB来创建和査询一个数据库,该数据库包含美国邮政编码和城市的映射信息。它展示大部分(但非全部)IndexDB基础特性。下例代码很长,为了能够有助于理解,添加了很多的注释。
例:存储美国邮政编码的IndexedDB数据库 <!DOCTYPE html> <html> <head> <title>Zipcode Database</title> <script> // IndexedDB的实现仍然使用API前缀 var indexedDB = window.indexedDB || // 使用标准的DB API window.mozIndexedDB || // 或者Firefox早期版本的IndexedDB window.webkitIndexedDB; // 或者Chrome的早期版本 // 这两个API,Firefox没有前缀 var IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction; var IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange; // 使用此函数,以日志的形式记录发生的数据库错误 function logerr(e) { console.log("IndexedDB error" + e.code + ": " + e.message); } // 此函数异步地获取数据库对象(需要的时候,用于创建和初始化数据库), // 然后将其传递给f()函数 function withDB(f) { var request = indexedDB.open("zipcodes"); // 获取存储邮政编码的数据库 request.onerror = logerr; // 以日志的方式记录发生的错误 request.onsuccess = function() { // 或者完成的时候调用此回调函数 var db = request.result; // request对象的result值就表示该数据库 // 即便该数据库不存在,也总能够打开它 // 通过检査版本号来确定数据库是否已经创建或者初始化 // 如果还没有,就做相应的创建或者初始化的工作 // 如果db已经存在了,那么只需要将它传递给回调函数f()就可以了 if (db.version ===== "1") f(db); // 如果db已经初始化了,就直接将它传递给f()函数 > else initdb(db,f); // 否则,先初始化db } } // 给定一个邮政编码,査询该邮政编码属于哪个城市, // 并将该城市名异步传递给指定的回调函数 function lookupCity(zip, callback) { withDB(function(db) { // 为本次査询创建一个事务对象 var transaction = db.transaction(["zipcodes"], // 所需的对象存储区 IDBTransaction.READ_ONLY, // 没有更新 0); // 没有超时 // 从事务中获取对象存储区 var objects = transaction.objectStore("zipcodes"); // 査询和指定的邮政编码的键匹配的对象 // 上述代码是同步的,但是这里的是异步的 var request = objects.get(zip); request.onerror = logerr; // 以日志形式记录发生的错误 request.onsuccess = function() { // 将结果传递给此函数 // result对象可以通过request.result属性获取 var object = request.result; if (object) // 如果査询到了,就将城市和州名传递给回调函数 callback(object.city + ", " + object.state); else // 否则,告诉回调函数,失败了 callback("Unknown zip code"); } }); } // 给定城市名(区分大小写),来査询对应的邮政编码 // 然后挨个将结果异步地传递给指定的回调函数 function lookupZipcodes(city, callback) { withDB(function(db) { // 和上述的情况一致,创建一个事务并获取对象存储区 var transaction = db.transaction(["zipcodes"], IDBTransaction.READ_ONLY, 0); var store = transaction.objectStore("zipcodes"); // 这次,从对象存储区中获取城市索引 var index = store.index("cities"); // 此次査询可能会返回很多结果,因此,必须使用游标对象来获取它们 // 要创建一个游标,需要一个表示键值范围的range对象 var range = new IDBKeyRange.only(city); // 传递一个单键给only()方法获取一个range // 对象 // 上述所有的操作都是同步的 // 现在,请求一个游标,它会以异步的方式返回 var request = index.openCursor(range); // 获取该游标 request.onerror logerr; // 记录错误 request.onsuccess function() { // 将游标传递给此函数 // 此事件处理程序会调用多次, // 每次有匹配查询的记录会调用一次, // 然后当标识操作结束的null游标出现的时候,也会调用一次 var cursor = request.result // 通过request.result获取游标 if(!cursor) return; // 如果没有游标就说明没有结果了 var object = cursor.value // 获取匹配的数据项 callback(object); // 将其传递给回调函数 cursor.continue(); // 继续请求下一个匹配的数据项 }; }); } // 下面展示的,document中的onchange回调函数会用到此方法 // 此方法査询数据库并展示査询到的结果 function displayCity(zip) { lookupCity(zip, function(s) { document.getElementById('city').value = s; }); } // 这是下面的文档中使用的另一个onchange回调函数 // 它査询数据库并展示査询到的结果 function displayZipcodes(city) { var output = document.getElementById("zipcodes"); output.innerHTML = "Matching zipcodes:"; lookupZipcodes(city, function(o) { var div = document.createElement("div"); var text = o.zipcode + ": " + o.city + ", " + o.state; div.appendChild(document.createTextNode(text)); output.appendChild(div); }); } // 建立数据库的结构,并用相应的数据填充它, // 然后将该数据库传递给f()函数 // 如果数据库还未初始化,withDB()函数会调用此函数 // 这也是此程序中最巧妙的部分 function initdb(db, f) { // 第一次运行此应用的时候, // 下载邮政编码数据并将它们存储到数据库中,需要花一些时间 // 因此在下载过程中,有必要给出提示 var statusline = document.createElement("div"); statusline.style.cssText = "position:fixed; left:0px; top:0px; width:100%/" + "color:white; background-color: black; font: bold 18pt sans-serif;" + "padding: 10px; "; document.body.appendChild(statusline); function status(msg) { statusline.innerHTML = msg.toString(); }; status("Initializing zipcode database"); // 只有在setVersion请求的onsuccess处理程序中才能定义或者修改IndexedDB数据库的结构 var request = db.setVersion("1"); // 试着更新数据库的版本号 request.onerror = status; // 失败的话,显示状态 request.onsuccess = function() { // 否则,调用此函数 // 这里邮政编码数据库只包含一个对象存储区 // 该存储区包含如下形式的对象:{ // zipcode:"02134", // 发送到Zoom // city:"Allston", // state:"MA", // latitude:"42.355147", // longitude:"-71.13164" // } // // 使用对象的"zipcode"属性作为数据库的键 // 同时,使用城市名来创建索引 // 创建一个对象存储区,并为该存储区指定一个名字 // 同时也为包含指定该存储区中键字段属性名的键路径的一个可选对象指定名字 // (如果省略键路径,IndexedDB会定义它自己的唯一的整型键) var store = db.createObjectStore("zipcodes", // 存储区名字 { keyPath: "zipcode"}); // 通过城市名以及邮政编码来索引对象存储区 // 使用此方法,表示键路径的字符串要直接传递过去, // 并且是作为必需的参数而不是可选对象的一部分 store.createIndex("cities", "city"); // 现在,需要下载邮政编码数据,将它们解析成对象, // 并将这些对象存储到之前创建的对象存储区中 // // 包含原始数据的文件内容格式如下: // // 02130,Jamaica P1ain,MA,42.309998,-71.11171 // 02131,Ros1indale,MA,42.284678,-71.13052 // 02132,West Roxbury,MA,42.279432,-71.1598 // 02133,Boston,MA,42.338947,-70.919635 // 02134,A11ston,MA,42.355147,-71.13164 // // 令人吃惊的是,美国邮政服务居然没有将这些数据开放 // 因此,这里使用了统计出来的过期的邮政编码数据 // 这些数据均来自 // http://mappinghacks.com/2008/04/28/civicspace-zip-code-database // 使用XMLHttpRequest下载这些数据 // 但在获取到数据后,使用新的XHR2 onl oad事件和onprogress事件来处理 var xhr = new XMLHttpRequest(); // 下载数据所需的xHR对象 xhr.open("GET", "zipcodes.csv"); // 利用HTTP GET方法获取此URL指定的内容 xhr.send(); // 直接获取 xhr.onerror.status; // 显示错误状态 var lastChar 0,numlines 0; // 已经处理的数量 // 获取数据后,批量处理数据库文件 xhr.onprogress = xhr.onload = function(e) { // 一个函数同时作为两个事件处理程序 // 在接收数据的lastChar和lastNevdine力间处理数据页(需要査询newlines, // 因此不需要处理部分记录项) var lastNewline = xhr.responseText.lastIndexOf("\n"); if (lastNewline > lastChar) { var chunk = xhr.responseText.substring(lastChar, lastNewline) lastChar = lastNewline + 1; // 记录下次从哪里开菇 // 将新的数据块分割成单独的行 var lines = chunk.split("\n"); numlines += lines.length; // 为了将邮政编码数据库存储到数据库中, // 这里需要事务对象 // 在该此函数返回, // 浏览器返回事件循环时,向数据库提交所有使用该对象进行的所有数据库插入操作 // 要创建事务对象,需要指定要使用的对象存储区 // 并且告诉该对象存储区, // 需要对数据库进行写操作而不只是读操作: var transaction = db.transaction( ["zipcodes"], // 对象存储区 IDBTransaction.READ_WRITE); // 从事务中获取对象存储区 var store = transaction.objectStore("zipcodes"); // 现在,循环邮政编码文件中的每行数据 // 为它们创建相应的对象,并将对象添加到对象存储区中 for(var i = 0; i < lines.length; i++) { var fields = lines[i].split(","); // 以逗号分割的值 var record = { // 要存储的对象 zipcode: fields[0], // 所有属性都是字符串 city: fields[1], state: fields[2], latitude: fields[3], longitude: fields[4] }; // IndexedDB API最好的部分就是对象存储区*真的*非常简单 // 下面就是在数据库中添加一条记录的方式: store.put(record); // 或者使用add()方法避免覆盖 } status("Initializing zipcode database: loaded " + numlines + " records."); } if (e.type == "load") { // 如果这是最后的载入事件, // 就将所有的邮政编码数据发送给数据库 // 但是,由于刚刚处理了4万条数据,可能它还在处理中 // 因此这里做个简单的査询 // 当此査询成功时,就能够得知数据库已经就绪了 // 然后就可以将状态条移除, // 最后调用此前传递给withDB()函数的f()函数 lookupCity("02134", function(s) { // 奥尔斯顿,马萨诸塞州 document.body.removeChild(statusline); withDB(f); }); } } } } </script> </head> <body> <p>Enter a zip code to find its city:</p> Zipcode: <input onchange="displayCity(this.value)"></input> City:<output id="city"></output> </div> <div> <p>Enter a city name (case sensitive, without state) to find cities and their zipcodes:</p> City: <input onchange="displayZipcodes(this.value)"></input> <div id="zipcodes"></div> </div> <p><i>This example is only known to work in Firefox 4 and Chrome 11.</i></p> <p><i>Your first query may take a very long time to complete.</i></p> <p><i>You may need to start Chrome with --unlimited-quota-for-indexeddb</i></p> </body> </html>