从 localStorage 为 null 的问题说起

5,440 阅读6分钟

有一天,负责脚本错误监控的同事说现网有 localStorage为 null 的错误。这就让人觉得奇怪了,这年代还有不支持 localStorage的浏览器或 webview 环境吗?而且以我们支持最低 IE 11 的浏览器来说,也不至于为 null 才是。caniuse 上看一下,也确实找不到为 null 的理由。(这个目前都还没想通为什么会是 null,有知道的小伙伴倒是可以分享一下,猜测可能是被设置了 writable 后复写为 null 了,但是好像也找不到谁会这么无聊的理由。。。)

image-20210530193850547

既然没有,有人会说那加个兼容吧:

if (window.localStorage) {
    window.localStorage.setItem('key', 'value');
}

如果支持可选链语法的话,写起来就方便一些:

window.localStorage?.setItem('key', 'value');

这其实就是指标不治本了,虽然是解决了这个报错,但是还有其他更多的地方呢?是否也要一处处添加呢?结合之前反馈过的 indexDB 被存满,Mac 上会弹出提醒的问题。我们可以给自己列个小需求来解决以下的问题:

  1. 解决 localStorage 为 null 的情况
  2. 兼容 node 环境下(SSR)无 localStorage 的问题
  3. 给 localStorage 加上监控,统计使用量超过 4.8M 的情况
  4. 上报 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 变量,我们也会容易这么写:

image-20210530200727070

然而会发现没有作用,因为 writable 不为 true,但好在 configurable 是 true,这留下了操作空间:

image-20210530205132380

通过设置 writable 为 true,就可以直接设置 localStorage:

image-20210530205333800

另一个可以作为类比的是 localcation,configurable 为 false,连配置都配置不了了:

image-20210530205435870

但是其实我们有更优雅的办法,localStorage 是 Storage 的实例:

image-20210530205650373

那么我们可以更改原型链上的方法,来达到复写 localStorage 方法的目的:

image-20210530205856939

localStorage polifill

localStorage 的实现是个对象,将 setItem 存储的 key 值存储在 localStorage 自身上。所以可以通过 . 运算符来获取 setItem 存储的 key:

image-20210530210245855

当然了,通过 . 运算符设置值,再通过 getItem 来取值也是行得通的:

image-20210530210713709

不过这对于 length 这个属性是行不通的:

image-20210530210848102

基于以上的特定我们可以实现 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,然后就会发现在遍历属性的时候,会将类方法也遍历出来:

image-20210530213734921

刚开始没有注意到这个问题的时候,是用一个 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, {
        key,
        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 上查看总容量占用高的次数:

image-20210530220750344

以及在 tam 上查看占用 top 几的情况,这里的耗时其实应该是 KB:

image-20210530221205836

通过 log 我们可以看下都是哪些 key 值,会发现都是一些离线的快照所存储的。这样就能帮助我们发现了解决问题:

企业微信截图_e2f4df04-9ee8-4e26-b250-eeb3b854f5ad

后记

其实有时候解决一个具体的小问题很简单,但是如何解决同一类小问题,并且能有效地避免它再次出现,即使有时候是无法避免的问题,也能辅助我们快速定位,这才是难的。这需要抽象和系统地思考问题的共性,轮子的创新点和需要解决的痛点就在这。