localStorage 是 HTML5 规范中作为持久化保存客户端数据的方案,localStorage 可以用于数据缓存,日志存储等应用场景。由于 localStorage 本身的一些特性:
受同源策略的限制
存储空间一般在 5MB 左右
键值对最终的存储形式为字符串
使用好 localStorage 并没有那么简单,本文主要介绍其使用的一些最佳实践。
由于浏览器对于新特性支持的速度和用户浏览器的版本不同,在使用 localStorage 之前,需要先通过嗅探操作判断当前环境是否支持:
function isLocalStorageUsable() {
const localStorageTestKey = '__localStorage_support_test';
const localStorageTestValue = 'test';
let isSupport = false;
try {
localStorage.setItem(localStorageTestKey, localStorageTestValue);
if (localStorage.getItem(localStorageTestKey) === localStorageTestValue) {
isSupport = true;
}
localStorage.removeItem(localStorageTestKey);
return isSupport;
} catch(e) {
return isSupport;
}
}
读写操作虽然可以用来验证当前浏览器是否支持 localStorage 特性,但是并非支持 localStorage 的浏览器一定可以进行写操作,前面已经提到「浏览器给 localStorage 分配的存储空间是有限的」,当存储的内容已经到达上限,则无法再进行写操作。
try {
localStorage.setItem(localStorageTestKey, localStorageTestValue);
if (localStorage.getItem(localStorageTestKey) === localStorageTestValue) {
isSupport = true;
}
localStorage.removeItem(localStorageTestKey);
return isSupport;
} catch(e) {
if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
console.warn('localStorage 存储已达上限!')
} else {
console.warn('当前浏览器不支持 localStorage!');
}
return isSupport;
}
在调用 localStorage 相关方法时,都要确保当前浏览器支持 localStorage 特性,这里可以利用值缓存来避免多次调用该方法造成的性能损耗:
// 类的实例方法
ready() {
if (this.isSupport === null) {
this.isSupport = isLocalStorageUsable();
}
if (this.isSupport) {
return Promise.resolve();
}
return Promise.reject();
}
通过定义上述的 ready 方法,使得嗅探方法具备「惰性执行」的特性。
当将对象直接作为键值对传入 localStorage 时,会隐式调用 toString 方法:
// 最终存储的键值为 key:[object Object] value: [object Object]
localStorage.setItem({}, {});
如果不注意键名的类型,可能会出现因键名重复而造成数据丢失的问题。
当 localStorage 的 key 为对象时,应该给予适当的警告提示,一定程度上避免低级错误导致的 Bug:
function normalizeKey(key) {
if (typeof key !== 'string') {
console.warn(`${key} used as a key, but it is not a string.`);
key = String(key);
}
return key;
}
对于 value,如果这样处理,则使得存储的值没有什么意义,所以需要根据数据的类型进行序列化和反序列化处理。
当调用 setItem 方法向 localStorage 中存储值时,需要对存储的值进行统一的序列化处理:
// 类的实例方法
setItem(key, value) {
key = normalizeKey(key);
return this.ready().then(() => {
if (value === undefined) {
value = null;
}
serialize(value, (error, valueString) => {
if (error) {
return Promise.reject(error);
}
try {
// 可能会因超出最大存储空间,存储失败。
localStorage.setItem(key, valueString);
return Promise.resolve();
} catch(e) {
return Promise.reject(e);
}
})
})
}
一般情况下,存储的都是 JSON 格式的数据,利用 JSON.stringify 进行序列化处理:
function serialize(value, callback) {
try {
const valueString = JSON.stringify(value);
callback(null, valueString);
} catch(e) {
callback(e);
}
}
这里需要对 JSON.stringify 方法进行异常捕获,「当序列化的对象存在循环引用时,该方法会抛出异常」。反序列化处理时,同样需要对 JSON.parse 进行异常捕获,比较常见的错误:
JSON.parse('undefined');
// VM20179:1 Uncaught SyntaxError: Unexpected token u in JSON at position 0
针对这种情况,可以在 setItem 方法中做一层过滤处理:
if (value === undefined) {
value = null;
}
但是也不能完全避免掉非法的 JSON 字符串,所以仍需要利用 try/catch 捕获异常。
如果业务需求比较复杂,那么需要通过判断存储值的具体类型来进行特定的序列化处理:
const toString = Object.prototype.toString;
function serialize(value, callback) {
const valueType = toString.call(value).replace(/^\[object\s(\w+?)\]$/g, '$1');
switch(valueType) {
case 'Blob':
const fileReader = new FileReader();
fileReader.onload = function() {
// 需要标记该值的类型
var str =
BLOB_TYPE_PREFIX +
value.type +
'~' +
bufferToString(this.result);
callback(null, SERIALIZED_MARKER + TYPE_BLOB + str);
}
fileReader.readAsArrayBuffer(value);
break;
default:
try {
const valueString = JSON.stringify(value);
callback(null, valueString);
} catch(e) {
callback(e);
}
}
}
这里增加了 Blob 类型的存储需求,需要利用 FileReader + ArrayBuffer 将其序列化,另外需要标识符来告诉反序列化过程中该值的类型。
JSON.stringify 方法在执行的过程中(运行时)需要分析对象的结构以及键值对的类型,这在处理复杂嵌套对象时是非常耗时的。
优化的手段实际上就是将这部分耗时的工作提到编译阶段,举个例子:
const testObj = {
firstName: 'Matteo',
lastName: 'Collina',
age: 32
}
function stringify({ firstName, lastName, age }) {
return `"{"firstName":"${firstName}","lastName":"${lastName}","age":${age}}"`
}
针对上述例子,可以在运行时之前确定该对象的键值对以及类型,可以通过 benchmark.js 得到相关的基准测试数据:
const benchmark = require('benchmark');
const fastjson = require('fast-json-stringify');
const suite = new benchmark.Suite();
const testObj = {
firstName: 'Matteo',
lastName: 'Collina',
age: 32
}
function stringify({ firstName, lastName, age }) {
return `"{"firstName":"${firstName}","lastName":"${lastName}","age":${age}}"`
}
suite.add('JSON.stringify obj', function () {
JSON.stringify(testObj)
})
suite.add('fast-json-stringify obj', function () {
stringify(testObj)
})
suite.on('cycle', (e) => console.log(e.target.toString()))
.on('complete', function() {
console.log(`Fastest is ${this.filter('fastest').map('name')}`);
})
suite.run()
得到的测试结果表明:无论是每秒执行测试代码的次数还是相对最快速度的统计误差,优化方案表现得更好。
# 测试结果
JSON.stringify obj x 1,695,618 ops/sec ±0.62% (90 runs sampled)
fast-json-stringify obj x 787,253,287 ops/sec ±0.36% (92 runs sampled)
Fastest is fast-json-stringify obj
上述例子不具备通用性,在实际的业务开发中,可以利用自定义 JSON Schema 来生成特定的 stringify 方法,有成熟的开源框架供选择:
fast-json-stringify
slow-json-stringify(来自程序员的调侃)
localStorage 是受同源策略限制的,这种隔离级别相当于是应用级别的,但是在实际的业务开发过程中,这种隔离级别部分场景是覆盖不到的。
向 localStorage 中存储键值对时,主要有以下特点:
键值对个数可以通过 length 属性获取
键值对通过键名来索引
键值对按照添加的时间倒序排列
如果当前应用中各个模块都有采用 localStorage 存储数据的需求,那么怎么从模块级别去隔离呢?
因为键值对都是通过键名来索引,所以可以为键名加上命名空间来区分:
const keyPrefix = name + '/';
localStorage.setItem(keyPrefix + key, value);
在多存储模块的情况下,直接调用 clear 方法会出现误删其它存储模块数据的问题,引入命名空间之后,可以避免这样的情况:
function clear(keyPrefix) {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.indexOf(keyPrefix) === 0) {
localStorage.removeItem(key);
}
})
}
最后,总结本文提到的最佳实践:
利用 try/catch 嗅探浏览器兼容性,但是要注意超出存储上限的情况。
针对 localStorage 键值都是字符串的特性,采用统一的序列化和反序列化方法。
针对 JSON.stringify 方法,可以采用约定 JSON Schema 的方式,将对象结构的分析提前到编译阶段来优化执行效率。
引入命令空间来增强多模块的管理。
localForage.js 源码
你点的每个赞,我都认真当成了喜欢