客户端存储

174 阅读9分钟

cookie

随着Web应用程序的出现,直接在客户端存储用户信息的需求也随之出现。比如登录信息、个人偏好或者其他数据。

由此出现了cookie,这个规范要求服务器在响应HTTP请求时,通过发送Set-Cookie HTTP头部包含会话信息。例如:

// 这个HTTP响应会设置一个名为“name”,值为“value”的cookie
// 名和值在发送时都会经过URL编码
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value
Other-header: other-header-value

// 浏览器会存储这些会话信息,并在之后的每个请求中都会通过HTTP头部cookie再将它们发回服务器
// 这些发送回服务器的额外信息可用于唯一标识发送请求的客户端
GET /index.jsl HTTP/1.1
Cookie: name=value
Other-header: other-header-value

限制

cookie是与特定域绑定的。设置cookie后,它会与请求一起发送到创建它的域。

这个限制能保证cookie中存储的信息只对被认可的接受者开放,不被其他域访问。

通常,只要遵守以下大致的限制,就不会在任何浏览器中碰到问题:

  • 不超过300个cookie

  • 每个cookie不超过4096字节

  • 每个域不超过20个cookie

  • 每个域不超过81920字节

每个域能设置的cookie总数也是受限的,但不同浏览器的限制不同。

为跨浏览器兼容,最好保证cookie的大小不超过4095字节。这个大小限制适用于一个域的所有cookie,而不是单个cookie

一个字符通常会占1字节。如果使用多字节字符,则每个字符最多可能占4字节

如果cookie总数超过了单个域的上限,浏览器就会删除之前设置的cookie

IEOpera会按照最近最少使用原则删除之前的cookie

Firefox会随机删除之前的cookie,因此为避免不确定的结果,最好不要超出限制

cookie构成

参数名作用备注
名称唯一标识cookie的名称不区分大小写(注意一些服务器软件可能当成区分大小写对待);必须经过URL编码
存储在cookie里的字符串值必须经过URL编码
域(domaincookie有效的域发送到这个域的所有请求都会包含对应的cookie;可能包含子域
路径(path请求URL中包含这个路径才会把cookie发送到服务器
过期时间(expires表示何时删除cookie的时间戳默认浏览器会话结束后删除所有cookie;如果设置了时间,关闭浏览器cookie仍然存在;把过期事件设置为过去时间会立即删除cookie
安全标志(secure)设置之后,只在使用SSL安全连接的情况下才会把cookie发送到服务器cookie中唯一的非名/值对,不需要设置值,只要一个secure就可以了

这些参数在Set-Cookie头部中使用分号加空格隔开,例如:

...
// 设置一个名为“name”的cookie,在2007年1月22日 7:10:24过期,对www.wrox.com及其他wrox.com的子域有效
Set-Cookie: name=value; expires=Mon, 22-Jan-07 07:10:24 GMT; domain=.wrox.com; path=/; secure
...

JS中的cookie

在JS中,因为接口过于简单,只有BOM的document.cookie属性。根据用法不同,该属性的表现迥异

所有名和值都是URL编码的,因此必须使用decodeURIComponent()解码

document.cookie 返回包含页面中所有有效cookie的字符串,以分号分隔

也可以通过document.cookie属性设置新的cookie字符串。这个字符串在被解析后会添加到原有cookie中。

设置document.cookie不会覆盖之前存在的任何cookie,除非设置了已有的cookie

设置时最好使用encodeURIComponent()对名称和值进行编码

document.cookie = encodeURIComponent('name')+'='+encodeURIComponent('echo')+";domain=.wrox.com; path=/";

子cookie

为绕过浏览器对每个域cookie数的限制,有些开发者提出了子cookie的概念。

cookie是在单个cookie存储的小块数据,本质上是使用cookie的值在单个cookie中存储多个名/值对:name=name1=val1&name2=valw&name3=val3,类似于查询字符串

使用子cookie时要注意cookie的大小不要超过限制

注意事项

还有一种叫做HTTP-onlycookie,可以在浏览器设置,也可以在服务器设置,但只能在服务器上读取。因为JS无法取得这种cookie的值

因为所有cookie都会作为请求头部由浏览器发送给服务器,所以在cookie中保存大量信息可能会影响特定域浏览器请求的性能。保存的cookie越大,请求完成的时间就越长。

所以,最好尽可能只通过cookie保存必要信息,以避免性能问题。

注意,不要在cookie中存储重要或敏感的信息。cookie数据不是保存在安全的环境中,因此任何人都可能获得。

Web Storage

cookie的限制及其特性决定了cookie并不是存储大量数据的理想方式。因此,其他客户端存储技术出现了。

Web Storage的目的是解决通过客户端存储不需要频繁发送回服务器的数据时使用cookie的问题

Web Storage规范最新的版本是第二版,这一版规范主要有两个目标:

  • 提供在cookie之外的存储会话数据的途径

  • 提供跨会话持久化存储大量数据的机制

Storage类型

Storage类型用于保存名/值对数据,直至存储空间上限(由浏览器决定)。Storage的实例与其他对象一样,但增加了以下方法:

  • clear() 删除所有值;不在Firefox中实现

  • getItem(name) 取得给定name的值

  • key(index) 取得给定数值位置的名称

  • removeItem(name) 删除给定name的名/值对

  • setItem(name, val) 设置给定name的值

因为每个数据项都作为属性存储在该对象上,所以可以使用点或方括号操作符访问这些属性,通过同样的操作来设置值,也可以使用delete操作符删除属性。

即便如此,通常还是建议使用方法而非属性来执行这些操作,以免意外重写某个已存在的对象成员

Storage类型只能存储字符串。非字符串数据在存储之前会自动转换为字符串,且这种转换不能在获取数据时撤销

第二版定义了两个对象(它们都是Storage的实例):

  • localStorage 永久存储机制

    在修订的HTML5规范里,localStorage对象取代了globalStorage,作为客户端持久存储数据的机制。

    要访问同一个localStorage对象,页面必须来自同一个域(子域不可以),在相同的端口上使用相同的协议

    // 存储数据
    localStorage.setItem('name', 'echo');
    
    // 读取数据
    localStorage.getItem(name);
    
    // 迭代sessionStorage
    for(let key in localStorage) {
        let value = localStorage.getItem(key);
        console.log(`${key}=${value}`);
    }
    
    // 删除数据
    localStorage.removeItem('name');
    
  • sessionStorage 跨会话的存储机制

    数据只会存储到浏览器关闭。

    因为sessionStorage对象与服务器会话紧密相关,所以在运行本地文件时不能使用

    存储在sessionStorage对象中的数据只能由最初存储数据的页面使用,在多页应用程序中的用处有限

    // 存储数据
    sessionStorage.setItem('name', 'echo');
    
    // 读取数据
    sessionStorage.getItem(name);
    
    // 迭代sessionStorage
    for(let key in sessionStorage) {
        let value = sessionStorage.getItem(key);
        console.log(`${key}=${value}`);
    }
    
    // 删除数据
    sessionStorage.removeItem('name');
    

这两种存储API提供了在浏览器中不受页面刷新影响而存储数据的两种方式。2009年之后所有主要供应商发布的浏览器版本在window对象上支持localStoragesessionStorage

两种存储方法的区别在于,存储在localStorage中的数据会保留到通过JS删除或用户清除浏览器缓存

存储事件

每当Storage对象发生变化时,都会在文档上触发storage事件。

这个事件的事件对象有如下4个属性:

  • domain: 存储变化对应的域

  • key 被设置或删除的键

  • newValue 键被设置的新值,若键被删除则为Null

  • oldValue 键变化之前的值

可以监听storage事件:

// storage事件不会区分sessionStorage和localStorage
window.addEventListener('storage', event => {
    console.log(event);
})

限制

Web Storage具体的限制取决于特定的浏览器。一般来说,客户端数据的大小限制是按照每个源(协议、域和端口)来设置的,因此每个源有固定大小的数据存储空间。

分析存储数据的页面的源可以加强这一限制。

不同浏览器给localStoragesessionStorage设置了不同的空间限制,但大多数会限制为每个源5MB

IndexedDB

IndexedDB是浏览器中存储结构化数据的一个方案,是类似于MySQLWeb SQL Database的数据库。

与传统数据库最大的区别在于,IndexedDB使用对象存储而不是表格保存数据

IndexedDB数据库就是在一个公共命名空间下的一组对象存储,类似于NoSQL风格的实现

IndexedDB的设计几乎完全是异步的。为此,大多数操作以请求的形式执行,这些请求会异步执行,产生成功的结果或错误。

绝大多数IndexedDB操作要求添加onerroronsuccess事件处理程序来确定输出

使用

使用IndexedDB数据库的第一步是调用indexedDB.open()方法,并给它传入一个要打开的数据库名称。

如果数据库存在则打开,否则创建这个数据库并打开,然后触发upgradeneeded事件

如果数据库存在,而你指定了一个升级版的版本号,则会立即触发upgradeneeded事件,因而可以在事件处理程序中更新数据库模式

let db, request, version = 1;
request = indexedDB.open('admin', version);
request.onerror = event => {
    // 如果发生错误,打印错误码
    console.log(`Failed to open: ${event.target.errorCode}`);
}
// 如果onsuccess事件处理程序被调用,说明可以通过event.target.result访问数据库实例了
request.onsuccess = event => {
    db = event.target.result;
}

request.onupgradeneeded = event => {
    const db = event.target.result;
    
    // 如果存在则删除当前objectStore。
    if(db.objectStoreNames.contains('users')) {
        db.deleteObjectStore('users')
    }
    
    // 这里keyPath属性表示应该用作键的存储对象的属性名
    db.createObjectStore('users', { keyPath: 'username' })
}

建立了数据库连接之后,下一步就是使用对象存储。

如果数据库版本与期待的不一致,那可能需要创建对象存储。不过,在创建对象存储前,有必要想一想要存储什么类型的数据

数据库的版本决定了数据库模式,包括数据库中的对象存储和这些对象存储的结构

事务

创建了对象存储之后,剩下的所有操作都是通过事务完成的。

事务要通过调用数据库对象的transaction()方法创建。

任何时候,只要想要读取或修改数据,都要通过事务把所有修改操作组织起来

// 最简单的使用
// 如果不指定参数,则对数据库中所有的对象存储有只读权限
let transaction = db.transaction();

// 更具体的方式是指定一个或多个要访问的对象存储的名称
// 这样可以确保在事务期间只加载users对象存储的信息
let transaction = db.transaction('users');

// 如果想要访问多个对象存储,可以给第一个参数传入一个字符串数组
let transaction = db.transaction(['users', 'anotherStore']);

// 要修改访问模式,可以传入第二个参数(readonly, readwrite或versionchange中的一个)
// 对users对象拥有存储读写权限
let transaction = db.transaction('users', 'readwrite');

有了事务的引用,就可以使用objectStore()方法并传入对象存储的名称以访问特定的对象存储。

然后,可以使用

  • add() 添加对象

    当对象存储中已存在同名的键时会导致错误

    可以把add想象成插入新值

    // users是一个用户数据的数组
    for(let user of users) {
        store.add(user);
    }
    

    每次调用add()put()都会创建对象存储的新更新请求。如果想验证请求成功与否,可以把请求对象保存到一个变量,然后为它添加onerroronsuccess事件处理程序:

    // users是一个用户数据的数组
    let request, requests = [];
    
    for(let user of users) {
        request = store.add(user);
        request.onerror = () => { ... }
        request.onsuccess = () => { ... }
        requests.push(request);
    }
    
  • put() 更新对象

    当对象存储中已存在同名的键时会简单地重写该对象。

    可以把put想象成更新值

  • get(key) 取得对象

  • delete(key) 删除对象

  • clear() 删除所有对象

例如

const transaction = db.transaction('users');
const store = transaction.objectStore('users');
const request = store.get('007');
request.onerror = event => console.log('error');
request.onsuccess = event => console.log(event.target.result);

因为一个事务可以完成任意多个请求,所以事务对象本身也有事件处理程序

transaction.onerror = event => {
    // 整个事务被取消
}
// 注意不能通过oncomplete事件处理程序的event对象访问get()请求返回的任何数据
// 因此,仍然需要通过这些请求的onsuccess事件处理程序来获取数据
transaction.oncomplete = event => {
    // 整个事务成功完成
}

通过游标查询

使用事务可以通过一个已知键取得一条记录。

如果想取得多条数据,则需要在事务中创建一个游标

游标是一个指向结果集的指针。

与传统数据库查询不同,游标不会事先收集所有结果。相反,游标指向第一个结果,并在接到指令前不会主动查找下一条数据

创建游标:

// 在对象存储上调用openCursor()方法
const transaction = db.transaction('users');
const store = transaction.objectStore('users');
const request = store.openCursor();
// 在调用onsuccess事件处理程序时,可以通过event.target.result访问对象存储中的下一条记录
request.onsuccess = event => {
    const cursor = event.target.result;
    if(cursor) {
        // cursor.value保存着实际的对象,所以显示它之前需要使用JSON来编码
        console.log(`Key: ${cursor.key}, Value: ${JSON.Stringify(cursor.value)}`);
    }
}
request.onerror = event => { ... }

游标可用于更新个别记录:

如果事务没有修改对象存储的权限,update()delete()都会抛出错误

request.onsuccess = event => {
    const cursor = event.target.result;
    let value, updateRequest, deleteRequest;
    
    if(cursor) {
        value = cursor.value;
        value.password = 'magic'; // 更新数据信息
        updateRequest = cursor.update(value); // 请求保存更新后的对象
        updateRequest.onsuccess = () => {...}
        updateRequest.onerror = () => {...}
        
        // delete()方法用于删除游标位置的记录
        if(cursor.key == 'foo') {
            deleteRequest = cursor.delete(); // 请求删除对象
            deleteRequest.onsuccess = () => {...}
            deleteRequest.onerror = () => {...}
        }
    }
}

默认情况下,每个游标都只会创建一个请求。要创建另一个请求,必须调用下列中的一个方法:

  • continue(key) 移动到结果集中的下一条记录。参数key是可选的。默认移动到下一条,如果指定了key,则游标移动到指定的键

  • advance(count) 游标向前移动指定的count调记录

// 迭代一个对象存储中的所有记录
request.onsuccess = event => {
    const cursor = event.target.result;
    if(cursor) {
        console.log(`Key: ${cursor.key}, Value: ${JSON.Stringify(cursor.value)}`);
        cursor.continue(); //移动到下一条记录
    } else {
        console.log('Done');
    }
}

键范围

使用游标获取数据的方式受到了限制。

使用键范围可以让游标更容易管理。键范围对应IDBKeyRange的实例。

有四种方式指定键范围:

  • 使用only()方法并传入想要获取的键
    // 只获取键为'007'的值
    const onlyRange = IDBKeyRange.only('007');
    
  • 定义结果集的下限
    // 从007记录开始,直到最后
    const lowerRange = IDBKeyRange.lowerBound('007');
    // 从007的下一条记录开始,直到最后
    const lowerRange = IDBKeyRange.lowerBound('007', true);
    
  • 定义结果集的上限
    // 从头开始,到'ace'记录位置
    const upperRange = IDBKeyRange.upperBound('ace');
    // 从头开始,到'ace'的前一条记录为止
    const upperRange = IDBKeyRange.upperBound('ace', true);
    
  • 同时指定上限和下限
    // 从007开始,到ace记录停止
    const boundRnage = IDBKeyRange.bound('007', 'ace');
    // cong 007记录开始,到'ace'的前一条记录停止
    const boundRnage = IDBKeyRnage.bound('007', 'ace', false, true);
    

定义了范围之后,把它传给openCursor()方法,就可以得到位置该范围内的游标

const store = db.transaction('users').objectStore('users');
const range = IDBKeyRange.bound('007', 'ace');
// openCursor(IDBKeyRnage实例, 表示方向的字符串)
// 第一个参数如果传null,则表示默认的键范围是所有值
// 如果对象存储中有重复的记录,可能需要游标跳过那些重复的项。此时可以给第二个参数传入'nextunique'
// 如果第二个参数是'prev'或者'prevunique'则表示从最后一项开始向第一项移动
const request = store.openCursor(range);
request.onsuccess = function(event) {
    const cursor = event.target.result;
    if(cursor) {
        console.log(`Key: ${cursor.key}, Value: ${JSON.Stringify(cursor.value)}`);
        cursor.continue(); //移动到下一条记录
    } else {
        console.log('Done');
    }
}

索引

对某些数据集,可能需要为对象存储指定多个键。

要创建新索引,首先要取得对象存储的引用,例如

const transaction = db.transaction('users');
const store = transaction.objectStore('users');
// createIndex(索引名称,索引属性的名称,包含键unique的options对象)
// 第三个参数中的unique应该必须指定,表示这个键是否在所有记录中唯一
const index = store.createIndex('username', 'username', {unique: true});

// 使用一个已存在的名为'username'的索引
const index = store.index('username');

// 索引非常像对象存储。可以在索引上使用openCursor()方法创建新游标
// 其result.key属性中保存的是索引建,而不是主键
const request = index.openCursor();

// 使用openKeyCursor()方法也可以在索引上创建特殊游标,只返回每条记录的主键
const request = index.openKeyCursor();

// 使用get()方法并传入索引键通过索引取得单条记录
const index = store.index('username');
const request = index.get('oo7');

任何时候,都可以使用IDBIndex对象的下列属性取得索引的相关信息

  • name 索引的名称

  • keyPath 调用createIndex()时传入的属性路径

  • objectStore 索引对应的对象存储

  • unique 表示索引键是否唯一的布尔值

对象存储自身也有一个indexNames属性,保存着与之相关索引的名称。

const transaction = db.transaction('users');
const store = transaction.objectStore('users');
const indexNames = store.indexNames;
for(let indexName in indexNames) {
    const index = store.index(indexName);
    console.log(`Index name: ${index.name}, KeyPath: ${index.keyPath}, Unique: ${index.unique}`);
}
// 删除索引
// 删除索引不会影响对象存储中的数据,所以这个操作没有回调
store.deleteIndex('username');

并发问题

IndexedDB虽然是网页中的异步API,但仍存在并发问题

第一次打开数据库时,添加onversionchange事件处理程序非常重要。

另一个同源标签页将数据库打开到新版本时,将执行此回调

对这个事件最好的回应是立即关闭数据库,以便完成版本升级

let request, database;
request = indexedDB.open('admin', 1);
request.onsuccess = event => {
    database = event.target.result;
    // 应该在每次成功打开数据库后都指定onversionchange事件处理程序
    database.onversionchange = () => database.close();
}

限制

首先,IndexedDB数据库是与页面源(协议,域,端口)绑定的,因此信息不能跨域共享。

其次,每个源都有可以存储的空间限制。如果用度超出配额则会请求用户许可

Firefox还有一个限制:本地文本不能访问IndexedDB数据库