相关文章推荐
性感的小蝌蚪  ·  EasyCV ...·  1 年前    · 
3,385

有一天,负责脚本错误监控的同事说现网有 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 值,会发现都是一些离线的快照所存储的。这样就能帮助我们发现了解决问题:

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