有一天,负责脚本错误监控的同事说现网有 localStorage为 null 的错误。这就让人觉得奇怪了,这年代还有不支持 localStorage的浏览器或 webview 环境吗?而且以我们支持最低 IE 11 的浏览器来说,也不至于为 null 才是。caniuse 上看一下,也确实找不到为 null 的理由。(这个目前都还没想通为什么会是 null,有知道的小伙伴倒是可以分享一下,猜测可能是被设置了 writable 后复写为 null 了,但是好像也找不到谁会这么无聊的理由。。。)
既然没有,有人会说那加个兼容吧:
if (window.localStorage) {
window.localStorage.setItem('key', 'value');
如果支持可选链语法的话,写起来就方便一些:
window.localStorage?.setItem('key', 'value');
这其实就是指标不治本了,虽然是解决了这个报错,但是还有其他更多的地方呢?是否也要一处处添加呢?结合之前反馈过的 indexDB 被存满,Mac 上会弹出提醒的问题。我们可以给自己列个小需求来解决以下的问题:
解决 localStorage 为 null 的情况
兼容 node 环境下(SSR)无 localStorage 的问题
给 localStorage 加上监控,统计使用量超过 4.8M 的情况
上报 localStorage 存储值的情况,方便定位是什么值的在写大量数据
localStorage overwrite
如果要给 localStorage 的某个方法加上自己的逻辑,我们很容易想到以下的方法:
const nativeSet = localStorage.setItem;
localStorage.setItem = function(key, value) {
// do something
console.log(`[mySetItem] ${key} = ${value}`);
nativeSet.apply(this, [key, value]);
如果要覆盖整个 localStorage 变量,我们也会容易这么写:
然而会发现没有作用,因为 writable 不为 true,但好在 configurable 是 true,这留下了操作空间:
通过设置 writable 为 true,就可以直接设置 localStorage:
另一个可以作为类比的是 localcation,configurable 为 false,连配置都配置不了了:
但是其实我们有更优雅的办法,localStorage 是 Storage 的实例:
那么我们可以更改原型链上的方法,来达到复写 localStorage 方法的目的:
localStorage polifill
localStorage 的实现是个对象,将 setItem 存储的 key 值存储在 localStorage 自身上。所以可以通过 .
运算符来获取 setItem 存储的 key:
当然了,通过 .
运算符设置值,再通过 getItem 来取值也是行得通的:
不过这对于 length 这个属性是行不通的:
基于以上的特定我们可以实现 localStorage 的垫片,可以在没有 localStorage 的环境和 node 环境上使用:
interface IStorage extends Storage {
[key: string]: any;
class LocalStorage {
static localStorage: LocalStorage;
static createInstance() {
if (typeof window !== 'undefined' && !window.Storage) {
window.Storage = LocalStorage;
} else if (typeof global !== 'undefined' && !global.Storage) {
global.Storage = LocalStorage;
// 也支持多例,更方便单测
return new LocalStorage();
static getInstance() {
// 单例方法
if (!this.localStorage) {
this.localStorage = this.createInstance();
return this.localStorage;
get length() {
return Object.keys(this).length;
set length(num: number) {
// 忽略直接设置的 length 属性
/* do nothing */
getItem(key: string): string | null {
if (this.hasOwnProperty(key)) {
// 转化成 string
return String((this as IStorage)[key]);
return null;
setItem(key: string, val: any) {
(this as IStorage)[key] = String(val);
removeItem(key: string) {
delete (this as IStorage)[key];
clear() {
for (const key of Object.keys(this)) {
delete (this as IStorage)[key];
key(index = 0) {
return Object.keys(this)[index];
这里遇到个问题是,平常写 class,习惯性在定义方法的时候使用箭头函数,以保证箭头函数的 this 绑定当前 class,然后就会发现在遍历属性的时候,会将类方法也遍历出来:
刚开始没有注意到这个问题的时候,是用一个 db 变量来缓存数据的,将数据的存取都变成操作这个 db 变量:
static db: {
[key: string]: string;
} = {};
然后还花了力气用 Proxy 去拦截对象,并且 IE 上不支持 Proxy,虽然有 polyfill,但是也只能实现基本的 get,set:
class LocalStorage {
static localStorage: null | LocalStorage = null;
static db: {
[key: string]: string;
} = {};
static createInstance() {
const instance = new LocalStorage();
const isKeyOfInstance = (prop: string) => (instance.hasOwnProperty(prop) || prop === 'length');
const fullSupport = isSupportProxy ? {
ownKeys() {
return Object.keys(LocalStorage.db);
getOwnPropertyDescriptor() {
return {
enumerable: true,
configurable: true,
} : {};
return <LocalStorage> new ObjectProxy(instance, {
set(target: LocalStorage, prop: TSetKey, value: any) {
if (isKeyOfInstance(prop)) {
instance[prop as TStorageKey] = value;
} else {
instance.setItem(prop, value);
return true;
get(target: LocalStorage, prop: string) {
if (isKeyOfInstance(prop)) {
return instance[prop as TStorageKey];
return instance.getItem(prop);
...fullSupport,
探究为什么会有这样的差异,我们来看一下编译结果:
"use strict";
var LocalStorage = /** @class */ (function () {
function LocalStorage() {
this.getItem = function () { };
LocalStorage.prototype.setItem = function () { };
return LocalStorage;
}());
console.log(Object.keys(new LocalStorage()));
可以看到 setItem 是定义在原型链上的方法,而 getItem 是定义在实例上的。
localStorage calculate
如果是计算整个 localStorage 占用的空间,直接序列化整个 localStorage 计算字符长度,是比较可信的:
static getUsedSize() {
try {
return StorageSize.stringSize(JSON.stringify(localStorage));
} catch (err) {
return -1;
计算字符占用的空间,会有点差距,但是相差不大。对于计算单条数据来说,就要加上 key 和 value:
protected static stringSize(str: string) {
if (typeof Blob !== 'undefined') {
return new Blob([str]).size;
return unescape(encodeURIComponent(str)).length;
static calculate(key: string, value: any) {
return StorageSize.stringSize(key + String(value));
为了避免每次计算,我们定义遍历的方法,只要有遍历过一次,就将所有的值的长度缓存下来。并且在 setItem、removeItem 的时候去更新缓存的长度:
protected static storageEach(callback: (key: string, size: number) => boolean) {
for (const key in localStorage) {
if (!localStorage.hasOwnProperty(key)) {
continue;
if (!StorageSize.sizeCache) {
StorageSize.sizeCache = {};
} else if (typeof StorageSize.sizeCache[key] === 'undefined') {
const storageStr = key + (localStorage[key] || '');
StorageSize.sizeCache[key] = StorageSize.stringSize(storageStr);
if (!callback(key, StorageSize.sizeCache[key])) {
break;
这样我们要获取占用空间最大的前 top 个项的时候,大体逻辑就是这样的:
static getTopSizes(limit = 10): TTopSize {
const topSize: TTopSize = [];
StorageSize.storageEach((key: string, size: number) => {
StorageSize.spliceSize(topSize, key, size, limit);
return true;
return topSize;
那么关键就在于这个 spliceSize 怎么实现了,毕竟对于存储了 n 条数据的 localStorage 来说,这个方法暴力实现会是 O(n!)
的复杂度。
但是这里我们有个有力因子,是我们需要的最终结果是有限的,假设这个数为 limit,那么我们只需要维持一个 limit 长度的数组,将每次的结果跟这有限次项来对比就好了。大于最大,就 push 进去;小于最小,就丢弃:
* @description 往有序限定长度的数组里插入输入,使数组仍然保持有序
* @protected
* @static
* @param {TTopSize} cache 缓存数组
* @param {string} key 存储的 key 值
* @param {number} size 占用空间
* @param {number} limit
* @memberof StorageSize
protected static spliceSize(cache: TTopSize, key: string, size: number, limit: number) {
const len = cache.length;
let toInsert = false;
let spliceIndex = 0;
for (let i = 0; i < len; i++) {
const info = cache[i];
if (info.size < size) {
spliceIndex += 1;
toInsert = true;
// 需要删除的情况是数组满了,并且待插入的数字比原数组的最小数大
if (toInsert && len === limit) {
cache.splice(0, 1);
spliceIndex = Math.max(spliceIndex - 1, 0);
(toInsert || len < limit) && cache.splice(spliceIndex, 0, {
size,
return (toInsert || len < limit);
localStorage monitor
现在我们知道了 localStorage 总占用空间多少,也有能力获取 top 几的项目。那怎么在实际项目上上报和统计这些数据呢?
首先我们 hook localStorage 方法,抽样检查一下使用量,检查使用量是否达到了我们设置的阙值。如果设置错误,也检查一下。并且设置了错误上报的次数,设置了限定:
private setWrapper() {
const nativeSet = Storage.prototype.setItem;
const that = this;
Storage.prototype.setItem = function () {
try {
nativeSet.apply(this, arguments as any);
StorageSize.setSizeWithKey(arguments[0], StorageSize.calculate(arguments[0], arguments[1]));
setTimeout(() => {
// 1% 的抽样概率判断一下使用量
if (Math.random() < that.props.checkProbability!) {
that.usedSizeCheck();
}, 0);
} catch (err) {
that.props.setError!(err);
console.error(err);
that.errorCount ++;
that.usedSizeCheck();
private usedSizeCheck = () => {
const usedSize = StorageSize.getUsedSize();
if (usedSize > LIMIT_SIZE) {
this.props.storageOverSize!();
if (this.errorCount <= this.props.sizeReportLimit!) {
const info: IStorageInfo = {
usedSize,
keysLength: window.localStorage.length,
topInfo: StorageSize.getTopSizes(),
this.props.overSizeReport!(info);
在上报的选择上,选择 monitor、tam 和 weblog。这里会上报是否离线的信息是,大部分前端的上报组件库都有短时间存储本地的需求,如果支持了离线的话(就像文档里的 AlloyReport,在离线编辑的时候,不会上报,而是缓存数据),会积压很多上报数据,这样的存储空间占用是合理且正常的:
overSizeReport = (sizeInfo: IStorageInfo) => {
const msg = JSON.stringify({
type: 'tdocs-storage-plus',
msg: sizeInfo,
isOffline: this.isOffline(),
this.reportTam(msg);
this.reportWeblog(msg);
this.reportTamTopSize(sizeInfo);
以及上报 top 几:
private reportTamTopSize(sizeInfo: IStorageInfo) {
if (!window.aegis || !window.aegis.reportT) {
return;
const from = window.location.origin + window.location.pathname;
window.aegis.reportT({
name: TAMKEYS.StorageUsedSize,
duration: sizeInfo.usedSize / 1024,
from,
window.aegis.reportT({
name: TAMKEYS.StorageStoreLength,
duration: sizeInfo.keysLength,
from,
const { topInfo } = sizeInfo;
topInfo.reverse();
for (let i = 0; i < topInfo.length; i++) {
window.aegis.reportT({
name: `Storage-Size-Top${i + 1}`,
duration: topInfo[i].size / 1024,
ext1: topInfo[i].key,
from,
这样所实现的上报效果如下,可以在 monitor 上查看总容量占用高的次数:
以及在 tam 上查看占用 top 几的情况,这里的耗时其实应该是 KB:
通过 log 我们可以看下都是哪些 key 值,会发现都是一些离线的快照所存储的。这样就能帮助我们发现了解决问题:
其实有时候解决一个具体的小问题很简单,但是如何解决同一类小问题,并且能有效地避免它再次出现,即使有时候是无法避免的问题,也能辅助我们快速定位,这才是难的。这需要抽象和系统地思考问题的共性,轮子的创新点和需要解决的痛点就在这。