cover
2019年11月14日 - 2023年7月31日

JavaScript 代码性能优化 - 从排查到处理

近期在对我们的控制台做性能优化,这次记录下代码执行方面的性能排查与优化(纯 JS 上的不包含 DOM 操作等优化)。其它的优化点以后有机会再分享。

控制台地址:https://console.ucloud.cn/

性能问题排查与收集

首先需要排查出需要优化的点,这个我们可以借助 Chrome 的 DevTool 来排查网站中的性能问题。

最好在隐身模式下收集信息,避免一些插件的影响。

Performance

第一种方式可以借助 Performance 面板来采集信息,展开 Main 面板,可以看到代码运行的信息。不过 Performance 面板中内容较多,还包含了渲染、网络、内存等其它的信息,视觉干扰比较严重。虽然很强大但是做纯 JS 性能排查时不推荐使用,今天主要介绍另一种方式。

picture 5

JavaScript Profiler

还有一种方式是借助 JavaScript Profiler,JavaScript Profiler 默认是隐藏的,需要在 DevTool 右上角的更多按钮(三个点的按钮) => More tools 中打开。

picture 6

可以看到 JavaScript Profiler 面板较 Performance 面板比起来简单多了,左侧最上方一排按钮可以收集、删除、垃圾回收(可能是用来强制执行 GC 的,不太确定),可以收集多次 Profiler 进行比对。

右侧是 Profiler 的展示区域,上方可以切换展示模式,包括 Chart、Heavy、Tree 三种模式,这里推荐 Chart,最直观,也是最易懂的。

Chart 面板上方为图表,纵轴为 CPU 的使用率,横轴是时间轴,纵轴是调用栈深度。下方为代码执行的时间片段信息,长度较长的时间片段会在页面中造成明显的卡顿,需要重点排查。

在 Chart 面板中,上下滚动会将图形进行放大缩小,左右滚动为滚动时间轴,也可以在图表中进行鼠标圈选和拖动。CMD + f 可以进行搜索,在想要查找对应代码性能的时候比较方便。

通过 JavaScript Profiler 面板可以很方面的排查出性能异常的代码。

picture 7

比如图中的 n.bootstrap,执行时间为 354.3ms,显然会造成比较严重的卡顿。

picture 9

还可以顺着时间片段往下深究到底是哪个步骤耗时较长,从上面可以看到其中 l.initState 耗时 173ms,下面是几个 forEach,显然是这里的循环性能消耗比较大,点击时间片段会跳转到 source 面板的对应代码中,排查起来非常方便。

借助 JavaScript Profiler,我们可以将所有时间较长、可能有性能问题的代码全部整理出来,放到代办列表中,等待进一步排查。

console.time

借助 Profiler 进行问题代码整理很方便,但是在实际调优过程中却有点麻烦,因为每次调试都需要执行一次收集,收集完了还需要找到当前调试的点,无形中会浪费很多时间,所以实际调优过程中我们会选择其他的方式,比如计算出时间戳差值然后 log 出来,不过其实有更方便的方式 - console.time。

const doSomething = () => {
    return new Array((Math.random() * 100000) | 0).fill(null).map((v, i) => {
        return i * i;
    });
};
// start a time log
console.time('time log name');
doSomething();
// log time
console.timeLog('time log name', 1);
doSomething();
// log time
console.timeLog('time log name', 2);
doSomething();
// log time and end timer
console.timeEnd('time log name', 'end');

console.time 目前大部分浏览器已经支持,通过 console.time 可以很方便的打印出一段代码的执行时间。

  • console.time 接收一个参数标识并开启一个 timer,随后可使用这个 timer 的标识来执行 timeLog 和 timeEnd
  • timeLog 接收 1-n 个参数,第一个为 timer 标识,其后的为可选参数,执行后会打印出当前 timer 的差时,以及传入的其它可选参数
  • timeEnd 和 timeLog 类似,不同的是不会接受多余可选参数并会在执行后关闭这个 timer
  • 不能同时启用多个同样标识的 timer
  • 一个 timer 结束后,可以再次开启一个同名 timer

picture 10

通过 console.time 我们可以直观的看到一段代码的执行时长,每次改动后页面刷新就能看到 log,从而看到改动后的影响。

性能问题整理和优化

借助 JavaScript Profiler,从控制台中排查出多处性能优化点。(以下时间为本地调试并开着 DevTool 时的数据,比实际情况较高)

名称 位置 单次耗时 首次执行次数 切换执行次数
initState route.extend.js:148 200ms - 400ms 1 0
initRegionHash s_region.js:217 50ms - 110ms 1 0
getMenu s_top_menu.js:53 0 - 40ms 4 3
initRegion s_region.js:105, QuickMenuWrapper/index.jsx:72 70ms - 200ms 1 0
getProducts s_globalAction.js:73 40ms - 80ms 1 2
getNav s_userinfo:58 40ms - 200ms 2 0
extendProductTrans s_translateLoader.js:114 40ms - 120ms 1 1
filterStorageMenu QuickMenu.jsx:198 4ms - 10ms 1 0
filterTopNavShow EditPanel.jsx:224 0 - 20ms 7 3

根据列出的排查的点,具体排除性能问题。下面列一些比较典型的问题点。

拆分循环中的任务

var localeFilesHandle = function (files) {
    var result = [];
    var reg = /[^\/\\\:\*\"\<\>\|\?\.]+(?=\.json)/;
    _.each(files, function (file, i) {
        // some code
    });
    return result;
};

var loadFilesHandle = function (files) {
    var result = [];
    var reg = /[^\/\\\:\*\"\<\>\|\?\.]+(?=\.json)/;
    _.each(files, function (file, i) {
        // some code
    });
    return result;
};

self.initState = function (data, common) {
    console.time('initState');
    // some code
    _.each(filterDatas, function (state, name) {
        var route = _.extend({}, common, state);
        var loadFiles = loadFilesHandle(route['files']);
        var localeFiles = localeFilesHandle(route['files']);

        route['loadfiles'] = _.union(route['common_files'] || [], loadFiles);
        route['localeFiles'] = localeFiles;
        routes[name] = route;
        $stateProvider.state(name, route);
    });
    // some code
    console.timeEnd('initState');
};

initState 中,filterDatas 为一个近 1000 个 key 的路由 map,初始化是需要去 ui-router 中注册路由信息,$stateProvider.state 是没办法省略了,但是 两个 files 可以延后化处理,在拉取文件时再去获取文件列表。

self.initState = function (data, common) {
    console.time('initState');
    // some code
    //添加路由到state
    _.each(filterDatas, function (state, name) {
        var route = _.extend({}, common, state);
        routes[name] = route;
        $stateProvider.state(name, route);
    });
    // some code
    console.timeEnd('initState');
};

// when load files
!toState.loadfiles &&
    (toState.loadfiles = _.union(toState['common_files'] || [], $UStateExtend.loadFilesHandle(toState['files'])));
!toState.localeFiles && (toState.localeFiles = $UStateExtend.localeFilesHandle(toState['files']));

经过减少迭代中的任务,initState 速度提升了 30% - 40%。

理清逻辑

var bitMaps = {
    // map info
};
function getUserRights(bits, key) {
    var map = {};
    _.each(bitMaps, function (val, key) {
        map[key.toUpperCase()] = val;
    });
    return map && map[(key || '').toUpperCase()] != null ? !!+bits.charAt(map[(key || '').toUpperCase()]) : false;
}

getUserRights 中可以看到每次都会去对 bitMaps 做一次遍历,而 bitMaps 本身不会有任何变化,所以这里其实只需要在初始化时做一次遍历就可以了,或者在初次遍历后做好缓存。

var _bitMaps = {
    // map info
};
var bitMaps = {};
_.each(_bitMaps, function (value, key) {
    bitMaps[key.toUpperCase()] = value;
});

function getUserRights(bits, key) {
    key = (key || '').toUpperCase();
    return bitMaps[key] != null ? !!+bits.charAt(bitMaps[key]) : false;
}

经过上述改动,getUserRights 的效率提升了 90+%,而上述很多性能问题点中都多次调用了 getUserRights,所以这点改动就能带来明显的性能提升。

善用位运算

var buildRegionBitMaps = function (bit, rBit) {
    var result;
    if (!bit || !rBit) {
        return '';
    }
    var zoneBit = (bit + '').split('');
    var regionBit = (rBit + '').split('');
    var forList = zoneBit.length > regionBit.length ? zoneBit : regionBit;
    var diffList = zoneBit.length > regionBit.length ? regionBit : zoneBit;
    var resultList = [];
    _.each(forList, function (v, i) {
        resultList.push(parseInt(v) || parseInt(diffList[i] || 0));
    });
    result = resultList.join('');
    return result;
};
var initRegionsHash = function (data) {
    // some code
    _.each(data, function (o) {
        if (!regionsHash[o['Region']]) {
            regionsHash[o['Region']] = [];
            regionsHash['regionBits'][o['Region']] = o['BitMaps'];
            regionsList.push(o['Region']);
        }
        regionsHash['regionBits'][o['Region']] = buildRegionBitMaps(
            o['BitMaps'],
            regionsHash['regionBits'][o['Region']]
        );
        regionsHash[o['Region']].push(o);
    });
    // some code
};

buildRegionBitMaps 是将两个 512 位长(看当前代码,长度未必固定)的权限位二进制字符串进行合并,计算出实际的权限,目前的代码将二进制字符串拆解为数组,然后遍历去计算出每一位的权限,效率较低。initRegionsHash 中会调用多次 buildRegionBitMaps,导致这里的性能问题被放大。

这里可以使用位运算来方便的计算出权限,效率会比数组遍历高很多。

var buildRegionBitMaps = function (bit, rBit) {
    if (!bit || !rBit) {
        return '';
    }
    var result = '';
    var longBit, shortBit, shortBitLength;
    if (bit.length > rBit.length) {
        longBit = bit;
        shortBit = rBit;
    } else {
        longBit = rBit;
        shortBit = bit;
    }
    shortBitLength = shortBit.length;
    var i = 0;
    var limit = 30;
    var remainder = shortBitLength % 30;
    var mergeLength = shortBitLength - remainder;
    var mergeString = (s, e) =>
        (parseInt('1' + longBit.substring(s, e), 2) | parseInt('1' + shortBit.substring(s, e), 2))
            .toString(2)
            .substring(1);
    for (; i < mergeLength; ) {
        var n = i + limit;
        result += mergeString(i, n);
        i = n;
    }
    if (remainder) {
        result += mergeString(mergeLength, shortBitLength);
    }
    return result + longBit.slice(shortBitLength);
};

通过上述改动,initRegionHash 运行时间被优化到 2ms - 8ms,提升 90+%。注意 JavaScript 中位运算基于 32 位,超过 32 位溢出,所以上面拆解为 30 位的字符串进行合并。

减少重复任务

function () {
    currentTrans = {};
    angular.forEach(products, function (product, index) {
        setLoaded(product['name'],options.key,true);
        currentTrans = extendProduct(product['name'],options.key, CNlan);
    });
    currentTrans = extendProduct(Loader.cname||'common',options.key, CNlan);
    if($rootScope.reviseTrans){
        currentTrans = Loader.changeTrans($rootScope.reviseNoticeSet,currentTrans);
    }
    deferred.resolve(currentTrans[options.key]);
}

上述代码被用来进行产品语言的合并,products 中是路由对应的产品名,会有重复,其中 common 的语言较大,有 1W 多个 key,所以合并时耗时较为严重。

function () {
    console.time('extendTrans');
    currentTrans = {};
    var productNameList = _.union(_.map(products, product => product.name));
    var cname = Loader.cname || 'common';
    angular.forEach(productNameList, function(productName, index) {
        setLoaded(productName, options.key, true);
        if (productName === cname || productName === 'common') return;
        extendProduct(productName, options.key, CNlan);
    });
    extendProduct('common', options.key, CNlan);
    cname !== 'common' && extendProduct(cname, options.key, CNlan);
    if ($rootScope.reviseTrans) {
        currentTrans = Loader.changeTrans($rootScope.reviseNoticeSet, currentTrans);
    }
    deferred.resolve(currentTrans[options.key]);
    console.timeEnd('extendTrans');
}

这边将 product 中的产品名去重减少合并次数,然后将 common 和 cname 对应的语言合并从遍历中剔除,在最后做合并来减少合并次数,减少前期合并的数据量。经过改动后 extendTrans 速度提高了 70+%。

尽早退出

user.getNav = function () {
    var result = [];
    if (_.isEmpty($rootScope.USER)) {
        return result;
    }
    _.each(modules, function (list) {
        var show = true;
        if (list.isAdmin === true) {
            show = $rootScope.USER.Admin == 1;
        }
        var authBitKey = list.bitKey ? regionService.getUserRights(list.bitKey.toUpperCase()) : show;
        var item = _.extend({}, list, {
            show: show,
            authBitKey: authBitKey
        });
        if (item.isUserNav === true) {
            result.push(item);
        }
    });
    return result;
};

getNav 中的 modules 为路由,上面也提到过,路由较多有近千,而在这里的遍历中调用了 getUseRights,导致性能损失严重,并且又一个非常严重的问题是,大部分的数据会被 isUserNav 筛除掉。

user.getNav = function () {
    var result = [];
    if (_.isEmpty($rootScope.USER)) {
        return result;
    }
    console.time(`getNav`);

    _.each(modules, function (list) {
        if (list.isUserNav !== true) return;

        var show = true;
        if (list.isAdmin === true) {
            show = $rootScope.USER.Admin == 1;
        }
        var authBitKey = list.bitKey ? regionService.getUserRights(list.bitKey.toUpperCase()) : show;
        var item = _.extend({}, list, {
            show: show,
            authBitKey: authBitKey
        });
        result.push(item);
    });
    console.timeEnd(`getNav`);
    return result;
};

通过将判断提前,尽早结束无意义的代码,和之前对 getUserRights 所做的优化,getNav 的速度提高了 99%。

善用 lazy

renderMenuList = () => {
    const { translateLoadingSuccess, topMenu } = this.props;

    if (!translateLoadingSuccess) {
        return null;
    }

    return topMenu
        .filter(item => {
            const filterTopNavShow = this.$filter('filterTopNavShow')(item);
            return filterTopNavShow > 0;
        })
        .map((item = [], i) => {
            const title = `INDEX_TOP_${(item[0] || {}).type}`.toUpperCase();
            return (
                <div className='uc-nav__edit-panel-item' key={i}>
                    <div className='uc-nav__edit-panel-item-title'>{formatMessage({ id: title })}</div>
                    <div className='uc-nav__edit-panel-item-content'>
                        <Row gutter={12}>{this.renderMenuProdList(item)}</Row>
                    </div>
                </div>
            );
        });
};

上述代码在控制台的一个菜单编辑面板中,这个面板只有用户点击了编辑才会出现,但是现有逻辑导致这块数据会经常,一进页面会执行 7 次 filterTopNavShow,并且还会重新渲染。

renderMenuList = () => {
    const { translateLoadingSuccess, topMenu, mode } = this.props;

    if (!translateLoadingSuccess) {
        return null;
    }
    if (mode !== 'edit' && this._lazyRender) return null;
    this._lazyRender = false;

    const menuList = topMenu
        .filter(item => {
            const filterTopNavShow = this.$filter('filterTopNavShow')(item);
            return filterTopNavShow > 0;
        })
        .map((item = [], i) => {
            const title = `INDEX_TOP_${(item[0] || {}).type}`.toUpperCase();
            return (
                <div className='uc-nav__edit-panel-item' key={i}>
                    <div className='uc-nav__edit-panel-item-title'>{formatMessage({ id: title })}</div>
                    <div className='uc-nav__edit-panel-item-content'>
                        <Row gutter={12}>{this.renderMenuProdList(item)}</Row>
                    </div>
                </div>
            );
        });
    return menuList;
};

这边简单的通过添加一个 _lazyRender 字段,将渲染和计算延迟到初次打开时再去做,避免了页面初始化时的不必要操作。

成果

先看下改造前后的时间对比

名称 单次耗时 优化效果
initState 200ms - 400ms 120ms - 300ms,减少 30%-40%
initRegionHash 50ms - 110ms 2ms - 8ms,减少 90%
getMenu 0 - 40ms 0ms - 8ms,减少 80%
initRegion 70ms - 200ms 3ms - 10ms,减少 90%
getProducts 40ms - 80ms 3ms - 10ms,减少 90%
getNav 40ms - 200ms 0ms - 2ms,减少 99%
extendProductTrans 40ms - 120ms 10ms - 40ms 减少 70%
filterStorageMenu 4ms - 10ms 0ms - 2ms,减少 80%
filterTopNavShow 0 - 20ms 初次加载不再执行,展开执行

对比还是比较明显的,大部分时间都控制在了 10ms 以内。

可以再看一下改造前后的 Profiler 的图形。

改造前: picture 11

改造后: picture 12

经过优化可以看到很多峰值都已经消失了(剩余的是一些目前不太好做的优化点),进入页面和切换产品时也能明显感受到差异。

总结

从上述优化代码中可以看到,大部分的性能问题都是由循环带来的,一个小小的性能问题在经过多次循环后也会带来严重的影响,所以平时代码时很多东西还是需要尽可能注意,比如能尽快结束的代码就尽快结束,没有必要的操作一概省略,该做缓存的做缓存,保持良好的编程习惯,可以让自己的代码哪怕在未知情况下也能保证良好的运行速度。

借助 JavaScript Profiler 和 console.time,性能排查和优化可以做到非常简单,排查到问题点,很容易针对问题去做优化方案。