近期在对我们的控制台做性能优化,这次记录下代码执行方面的性能排查与优化(纯 JS 上的不包含 DOM 操作等优化)。其它的优化点以后有机会再分享。
控制台地址:console.ucloud.cn/
性能问题排查与收集
首先需要排查出需要优化的点,这个我们可以借助 Chrome 的 DevTool 来排查网站中的性能问题。
最好在隐身模式下收集信息,避免一些插件的影响。
Performance
第一种方式可以借助 Performance 面板来采集信息,展开 Main 面板,可以看到代码运行的信息。不过 Performance 面板中内容较多,还包含了渲染、网络、内存等其它的信息,视觉干扰比较严重。虽然很强大但是做纯 JS 性能排查时不推荐使用,今天主要介绍另一种方式。
JavaScript Profiler
还有一种方式是借助 JavaScript Profiler,JavaScript Profiler 默认是隐藏的,需要在 DevTool 右上角的更多按钮(三个点的按钮) => More tools 中打开。
可以看到 JavaScript Profiler 面板较 Performance 面板比起来简单多了,左侧最上方一排按钮可以收集、删除、垃圾回收(可能是用来强制执行 GC 的,不太确定),可以收集多次 Profiler 进行比对。
右侧是 Profiler 的展示区域,上方可以切换展示模式,包括 Chart、Heavy、Tree 三种模式,这里推荐 Chart,最直观,也是最易懂的。
Chart 面板上方为图表,纵轴为 CPU 的使用率,横轴是时间轴,纵轴是调用栈深度。下方为代码执行的时间片段信息,长度较长的时间片段会在页面中造成明显的卡顿,需要重点排查。
在 Chart 面板中,上下滚动会将图形进行放大缩小,左右滚动为滚动时间轴,也可以在图表中进行鼠标圈选和拖动。CMD + f 可以进行搜索,在想要查找对应代码性能的时候比较方便。
通过 JavaScript Profiler 面板可以很方面的排查出性能异常的代码。
比如图中的 n.bootstrap,执行时间为 354.3ms,显然会造成比较严重的卡顿。
还可以顺着时间片段往下深究到底是哪个步骤耗时较长,从上面可以看到其中 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
通过 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:105QuickMenuWrapper/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 的图形。
改造前:
改造后:
经过优化可以看到很多峰值都已经消失了(剩余的是一些目前不太好做的优化点),进入页面和切换产品时也能明显感受到差异。
总结
从上述优化代码中可以看到,大部分的性能问题都是由循环带来的,一个小小的性能问题在经过多次循环后也会带来严重的影响,所以平时代码时很多东西还是需要尽可能注意,比如能尽快结束的代码就尽快结束,没有必要的操作一概省略,该做缓存的做缓存,保持良好的编程习惯,可以让自己的代码哪怕在未知情况下也能保证良好的运行速度。
借助 JavaScript Profiler 和 console.time,性能排查和优化可以做到非常简单,排查到问题点,很容易针对问题去做优化方案。