記一次京東Nodejs性能優化,從分析問題到解決問題

一.問題背景

MPM(Market Page Maker)是京東社交電商部的組件化的頁面可視化搭建平臺,於2016年9月份上線,平均每週150+個頁面,目前已經成爲社交電商部的一個核心繫統。系統使用Vue.js作爲組件化的基礎框架,並於2017年5月份上線了Node.js直出服務。MPM的頁面會被運營同學拿到各種渠道投放,整體流量很不穩定,對於流量的暴漲情況要能夠及時處理,這對於開發同學來說是一個比較煩的工作。

前幾天突然收到告警信息,由於運營同學將某個MPM活動頁面投放了外部廣告,直出服務流量大漲,服務器CPU使用率達到了80%以上,於是立馬申請擴容,問題雖解決,但是留給了我們一個問題:直出服務能否優化,這次量級的流量進來之後,是否可以穩定支撐而不需要擴容?

二.分析方法及問題點

由於本次告警問題主要是流量暴漲導致的CPU使用率過大,我們本次重點優化服務的CPU消耗性能。分析CPU消耗的方法有多種,我們選擇其中操作比較簡單的v8-profiler方案:安裝NPM包v8-profiler,在直出服務中添加監控代碼,打包發佈到預發佈環境進行壓測,收集監控數據再進行分析。監控代碼如下:

const profiler = require('v8-profiler');	
const fs = require('fs');	
(function cpuProf() {	
    setTimeout(function () { 	
        console.log('開始收集CPU數據');	
        profiler.startProfiling('CPU profile');	
        setTimeout(function () { 	
            const profile = profiler.stopProfiling();	
            profile.export(function (err, result) {	
                fs.writeFileSync('profile.json', result);	
                profile.delete();	
                console.log('CPU數據收集完成');	
            });	
        }, 1000 * 60 * 5);//監控數據採集5分鐘	
    }, 1000);	
})();

上述代碼會採集服務端5分鐘的CPU消耗數據,並生成一個JSON文件,將此文件下載到本地後,導入到在線分析網址https://www.speedscope.app/ (或者用Chrome DevTool也可以),可以看到火焰圖如下:

640?wx_fmt=png

從火焰圖可以看到函數的調用棧,從上而下就是調用棧,其中橫條長度越長代表這佔用cpu的時間越長。如果某個橫條很長,但是下面又沒有很細小的子調用,一般就表示該調用消耗時間比較長,可以考慮做優化。從圖中我們可以看到,消耗性能的主要有幾個地方:

1)replace函數 2)compile函數 3)parse函數 4)vue渲染

爲了方便後文的分析,我們先了解一下直出服務的處理過程:

步驟處理流程資源消耗類型
1服務收到請求,解析頁面參數CPU計算
2從Redis中讀取頁面數據(PageData)網絡IO
3解析PageDataCPU計算
4組裝後端請求參數CPU計算
5發起後端請求並等待返回網絡IO
6解析後端接口返回的JSON數據CPU計算
7頁面模板構造CPU計算
8組件渲染CPU計算
9吐出頁面HTML網絡IO

三.replace函數調用優化

分析具體的replace函數調用之前,我們先詳細分析一下上面表格的第7步:頁面模板構造。

1.頁面模板構造

由於存在用戶身份判斷(如某些組件僅對新人或者VIP用戶可見)、樓層BI(每個用戶展示的樓層順序都不一樣)等原因,相同頁面對於不同的用戶展示的組件數量、順序都是不一樣(即千人千面),因此頁面的模板是基於各個組件的模板動態構造的。爲方便對組件進行操作,每個組件都有一個div容器,容器構造很簡單,示例代碼如下:

<div id='com_1001'>__vue_com_1001_replace__</div>	
<div id='com_1002'>__vue_com_1002_replace__</div>	
<div id='com_1003'>__vue_com_1003_replace__</div>	
<div id='com_1004'>__vue_com_1004_replace__</div>

其中__vue_com1001_replace__這種是佔位符,需要用相應位置的組件的實際模板來替換。但是這裏有個問題, Vue渲染的時候,使用Render Function進行渲染的,並不是普通的字符串模板或者Vue模板。下面是一段模板編譯後的Render Function:

_c('commontag',{ref:"__uid__replace__str__",attrs:{"uid":"__uid__replace__str__","params":params___uid__replace__str__},inlineTemplate:{render:function(){with(this){return _c('div',[(true)?[(params.transparent != 1)?_c('div',{staticClass:"vueSeparator",style:({'background-color':params.color,  height: params.height + 'px'})}):_c('div',{staticClass:"vueSeparator",style:({height: params.height + 'px'})})]:_e()],2)}},staticRenderFns:[]}})

若使用的是Vue模板,則會在運行時做一次編譯,編譯爲Render Function,比較耗性能,因此官方推薦的做法是在構建時預編譯,並且運行時使用不包含編譯函數的精簡版。目前MPM每個組件存儲到Redis中的也是Render Function,而不是原始的Vue模板。所以現在的問題是,已知子組件編譯後的Render Function,並且知道各個組件的DOM結構形式的容器,能否構造出父組件的Render Function?

答案當然是可以:可以通過字符串操作,構造出父組件的Render Function

我們以下面這段代碼爲例,看看構造過程(爲了簡單處理,我們用了內聯模板):

<ParentComponent>	
    <SubComponent1 inline-template :param="data.sub1">	
        <p>this is SubComponent1{{param.name}}</>	
    </SubComponent1>	
    <SubComponent2 inline-template :param="data.sub2">	
        <p>this is SubComponent2{{param.name}}</>	
    </SubComponent2>	
    <SubComponent3 inline-template :param="data.sub3">	
        <p>this is SubComponent3{{param.name}}</>	
    </SubComponent3>	
</ParentComponent>

上述代碼經過Vue.compile函數編譯處理後,會得到一個包含render和staticRenderFns兩個屬性的對象,我們主要看render屬性,它是一個匿名函數,代碼如下:

function anonymous(	
) {	
with(this){return _c('ParentComponent',[_c('SubComponent1',{attrs:{"param":data.sub1},inlineTemplate:{render:function(){with(this){return _c('p',[_v("this is SubComponent1"+_s(param.name)+"\n\t")])}},staticRenderFns:[]}}),_v(" "),_c('SubComponent2',{attrs:{"param":data.sub2},inlineTemplate:{render:function(){with(this){return _c('p',[_v("this is SubComponent2"+_s(param.name)+"\n\t")])}},staticRenderFns:[]}}),_v(" "),_c('SubComponent3',{attrs:{"param":data.sub3},inlineTemplate:{render:function(){with(this){return _c('p',[_v("this is SubComponent3"+_s(param.name)+"\n\t")])}},staticRenderFns:[]}})],1)}	
}

將上面的代碼再格式化一下:

function anonymous() {	
with(this){return 	
_c('ParentComponent',	
[	
_c('SubComponent1',{attrs:{"param":data.sub1},inlineTemplate:{render:function(){with(this){return _c('p',[_v("this is SubComponent1"+_s(param.name)+"\n\t")])}},staticRenderFns:[]}}),_v(" "),	
_c('SubComponent2',{attrs:{"param":data.sub2},inlineTemplate:{render:function(){with(this){return _c('p',[_v("this is SubComponent2"+_s(param.name)+"\n\t")])}},staticRenderFns:[]}}),_v(" "),	
_c('SubComponent3',{attrs:{"param":data.sub3},inlineTemplate:{render:function(){with(this){return _c('p',[_v("this is SubComponent3"+_s(param.name)+"\n\t")])}},staticRenderFns:[]}})	
],1)}	
}

可以看到上面第4、5、6行代碼,就是子組件的Render Function,他們包裹在一個數組裏。因此,如果知道子組件的Render Function,配合形如下面的模板,就可以反過來構造出父組件的Render Function(當然有一個從字符串到函數的反序列化過程,但是在我們的場景這個不可避免,因爲模板是從Redis中讀取出來的)。

function anonymous() {	
with(this){return 	
_c('ParentComponent',	
[	
__SubComponent1_replace__,	
__SubComponent2_replace__,	
__SubComponent3_replace__	
],1)}	
}

再回到我們的問題,我們已知子組件的Render Function,並且已知父組件的容器,需要構造出父組件的Render Function。現在思路就很清晰了,我們只需要把開頭那段包含佔位符的div容器代碼,

<div id='com_1001'>__vue_com_1001_replace__</div>	
<div id='com_1002'>__vue_com_1002_replace__</div>	
<div id='com_1003'>__vue_com_1003_replace__</div>	
<div id='com_1004'>__vue_com_1004_replace__</div>

使用Vue.compile函數將其編譯成Render Function,處理成字符串後,再通過正則替換其中的子組件的佔位符,變成子組件模板,最後反序列化爲父組件的Render Function即可。整體處理邏輯如下:640?wx_fmt=png

2.問題代碼分析

瞭解了上述處理過程,我們再根據火焰圖中的調用棧,找到replace函數調用的問題代碼:

Object.keys(MPM_COM_STYLE_MAP).forEach(function(comId){	
    var styleKey = MPM_COM_STYLE_MAP[comId];	
    var code = '';	
    if(hideComIds.indexOf(comId)!=-1){	
        code = HIDE_TPL;	
    }else if(loadingComs.indexOf(comId)!=-1){	
        code = LOADING_TPL;	
    }else if(MPM_STYLE_TPL_MAP[styleKey]) {	
        // 第一次replace替換	
        code = MPM_STYLE_TPL_MAP[styleKey].replace(/__uid__replace__str__/g, comId); 	
    } else{	
        console.error('最終替換,發現無模板組件',comId);	
    }	
    if(code) {	
        //第二次replace替換	
        compileTpl = compileTpl.replace(`_v("__vue__${comId}__replace__")`,code);	
    }  	
});

可以看到有兩次replace函數調用,第一次是組件ID替換(即uid替換),第二次是組件模板替換。

先分析第一次replace函數調用。前面提到,每個組件的模板已經編譯爲Render Function並存在Redis中。但是同一個組件在頁面中可能有多個實例,每個實例需要有一個ID來區分,我們稱爲uid(unique ID的意思),uid只有在運行的時候才生成,在編譯的時候是不知道的,因此用了一個佔位符(即下圖中標紅的部分),在直出服務中需要做替換,即上面代碼中的uid替換。下面是一段編譯後的代碼:

640?wx_fmt=png

每個頁面會有很多個組件(數十個甚至上百個),每次替換都是在之前替換的結果之上進行的,形成了循環替換,前面導致告警的那個頁面用到的編譯之後的模版最大的有20+KB,而每次正則替換之後的模版會越來越長,所以這裏耗時較多也就不奇怪了。

從邏輯上講,這段代碼是必不可少的,但是又有性能瓶頸,如何優化?

3.uid替換優化

我們研究發現:對於比較長的字符串,先用字符串的split方法分割成數組,再用數組的join方法將切割的數組合併爲一個字符串,比正則替換的效率要高。此法我們稱爲數組粘合法。以下爲測試代碼:

const exeCount = 10000000;   //執行次數,此處分別換成1W、10W、100W、1000W	
//測試字符串,需要比較長的字符串才能看到效果,下面是從我們的組件模板中摘取的一段	
const str = `_c('ds',{ref:"__uid__replace__str__",attrs:{"uid":"__uid__replace__str__","params":params___uid__replace__str__,"tab-index":"3"},inlineTemplate:{render:function(){with(this){return _c('div',{attrs:{"stylkey":data.styleKey,"pc":data.pc,"actid":data.actid,"areaid":data.areaid}},[_c('ul',{directives:[{name:"getskuad",rawName:"v-getskuad",value:({bindObj:data, appendName:'skuAd', show: params.extend.showAds}),expression:"{bindObj:data, appendName:'skuAd', show: params.extend.showAds}"}],staticClass:"pinlei_g3col"},[(true)?_l((params.fnObj.translate(data.itemList)),function(item,index){return (!params.shownum || index < params.shownum || data.showMore)?_c('li',{class:['pinlei_g3col_col', (params.extend.imgSize == '1' ? 'size_230x230' : 'size_230x320')],attrs:{"index":index}},[_c('div',{staticClass:"pinlei_g3col_img"},[_c('a',{attrs:{"href":params.extend.buttonType == '5' ? addRd(goPingouUrl(item.sUrl),params.ptag) : addRd(item.sUrl,params.ptag)}},[_c('img',{attrs:{"init_src":getImgUrl('//img12.360buyimg.com/mcoss/'+ item.sPicturesUrl),"data-size":"230x230"}})]),((params.extend.sellOut != '0') && (item.dwStock - 0 > 0))?_c('div',{staticClass:"pinlei_g3col_msk"},[_m(0,true)]):_e()]),_c('div',{staticClass:"pinlei_g3col_info"},[_c('div',{class:['pinlei_g3col_t1', 'red', (params.extend.titleHeight == '1' ? 'oneline' : '')]},[_v("\n                                "+_s(item.sProductName)+"\n                            ")]),(!params.fnObj.isBeforeActive(params.extend.beginTime))?_c('div',{staticClass:"pinlei_g3col_price red",style:({color: params.extend.isShowTokenPrice == '1' && item.dwTokenPrice && (Number(item.dwTokenPrice) != 0)?'#888':''})},[_v("\n                                ¥"),_c('b',[_v(_s(item.dwRealTimePrice.split('.')[0]))]),_v("."+_s(item.dwRealTimePrice.split('.')[1])+"\n                            ")]):_e(),(params.fnObj.isBeforeActive(params.extend.beginTime))?_c('div',{staticClass:"pinlei_g3col_price red",style:({color: params.extend.isShowTokenPrice == '1' && item.dwTokenPrice && (Number(item.dwTokenPrice) != 0)?'#888':''})},[_v("\n                                ¥"),_c('b',[_v(_s(params.fnObj.getYushouInt(item, params.extend.priceType)))]),_v(_s(params.fnObj.getYushouDecimal(item, params.extend.priceType))+"\n                            ")]):_e(),(params.extend.isShowTokenPrice == '1')?[_c('div',{staticClass:"pinlei_g3col_token"},[(item.dwTokenPrice && (Number(item.dwTokenPrice) != 0))?_c('div',{staticClass:"pinlei_g3col_token_price"},[_v("專屬價:¥"),_c('b',[_v(_s(parseFloat(item.dwTokenPrice)))])]):_e()])]:_e(),(params.fnObj.isBeforeActive(params.extend.beginTime))?[_c('div',{staticClass:"pinlei_g3col_desc red"},[(item.sBackUpWords[0] && (params.fnObj.getYushouJiaDiff(item,params.extend.priceType) > 0))?[_v("比現在買省"+_s(params.fnObj.getYushouJiaDiff(item,params.extend.priceType))+"元")]:(item.sTag)?[_v(_s(item.sTag.split('|')[0]))]:(params.extend.showAds == '1' && item.skuAd)?[_v(_s(item.skuAd))]:_e()],2)]:_e(),(!params.fnObj.isBeforeActive(params.extend.beginTime))?[_c('div',{staticClass:"pinlei_g3col_desc red"},[(item.sTag)?[_v(_s(item.sTag.split('|')[0]))]:(params.extend.showAds == '1' && item.skuAd)?[_v(_s(item.skuAd))]:_e()],2)]:_e(),(params.fnObj.isBeforeActive(params.extend.beginTime))?[(params.extend.buttonType == '0')?[(params.extend.priceType == '1')?_c('div',{directives:[{name:"addcart",rawName:"v-addcart",value:({skuId: item.ddwSkuId}),expression:"{skuId: item.ddwSkuId}"}],class:{'pinlei_g3col_btn':true, 'blue':params.extend.beginTime, 'red':(!params.extend.beginTime), 'right': item.sBackUpWords[2]},style:(params.extend.priceType == 0?'border-radius: 24px;':'')},[_v("\n                                        "+_s(params.extend.buttonWording)+"\n                                    ")]):_e(),(params.extend.priceType == '0')?_c('div',{directives:[{name:"addcart",rawName:"v-addcart",value:({skuId: item.ddwSkuId}),expression:"{skuId: item.ddwSkuId}"}],class:{'pinlei_g3col_btn':true, 'blue':params.extend.beginTime, 'red':(!params.extend.beginTime), 'right': item.sBackUpWords[2]},style:(params.extend.priceType == 0?'border-radius: 24px;':'')},[_v("\n                                        "+_s(params.extend.buttonWording)+"\n                                    ")]):_e()]:_e(),(params.extend.buttonType == '1')?[_c('a',{attrs:{"href":addRd(item.sUrl,params.ptag)}},[_c('div',{class:{'pinlei_g3col_btn':true, 'blue':params.extend.beginTime, 'red':(!params.extend.beginTime)},style:(params.extend.priceType == 0?'border-radius: 24px;':'')},[_v("\n                                            "+_s(params.extend.buttonWording)+"\n                                        ")])])]:_e(),(params.extend.buttonType == '5')?[_c('a',{attrs:{"href":addRd(goPingouUrl(item.sUrl),params.ptag)}},[_c('div',{class:{'pinlei_g3col_btn':true, 'blue':params.extend.beginTime, 'red':(!params.extend.beginTime)},style:(params.extend.priceType == 0?'border-radius: 24px;':'')},[_v("\n                                            "+_s(params.extend.buttonWording)+"\n                                        ")])])]:_e(),(params.extend.buttonType == '2')?[_c('a',{attrs:{"href":addRd(item.sUrl,params.ptag)}},[_c('div',{class:{'pinlei_g3col_btn':true, 'blue':params.extend.beginTime, 'red':(!params.extend.beginTime)},style:(params.extend.priceType == 0?'border-radius: 24px;':'')},[_v("\n                                            定金"+_s(item.sBackUpWords[1].split('+')[0])+"抵"+_s(parseFloat((item.sBackUpWords[1].split('+')[1] * item.sBackUpWords[1].split('+')[0]).toFixed(2)))+"\n                                        ")])])]:_e(),(params.extend.buttonType == '3')?[_c('div',{directives:[{name:"yuyue",rawName:"v-yuyue",value:({bindObj:data,stop:true, activeId:params.extend.yuyueID,appendTo:item,appendName:'state',msg:[]}),expression:"{bindObj:data,stop:true, activeId:params.extend.yuyueID,appendTo:item,appendName:'state',msg:[]}"}],class:['pinlei_g3col_btn','blue', item.state == 1 ? 'disabled' : ''],style:(params.extend.priceType == 0?'border-radius: 24px;':''),attrs:{"yuyueid":params.extend.yuyueID}},[_v("\n                                        "+_s(params.extend.buttonWording)+"\n                                    ")])]:_e(),(params.extend.buttonType == '4' )?[((params.fnObj.getYushouJiaDiff(item,params.extend.priceType)> 0))?_c('div',{directives:[{name:"skuyuyue",rawName:"v-skuyuyue",value:({bindObj:data,stop:true, skuId:item.ddwSkuId,appendTo:item,ignoreHistory:true,msg:{success: '預約成功,請留意京東JD.COM服務號的活動提醒',exist: '已設置預約,無需再進行設置',systemError: '該商品不是預約活動商品'},actPrice:params.fnObj.getYushouInt(item, params.extend.priceType)+params.fnObj.getYushouDecimal(item, params.extend.priceType),classId:item.classId1+'_'+item.classId2+'_'+item.classId3}),expression:"{bindObj:data,stop:true, skuId:item.ddwSkuId,appendTo:item,ignoreHistory:true,msg:{success: '預約成功,請留意京東JD.COM服務號的活動提醒',exist: '已設置預約,無需再進行設置',systemError: '該商品不是預約活動商品'},actPrice:params.fnObj.getYushouInt(item, params.extend.priceType)+params.fnObj.getYushouDecimal(item, params.extend.priceType),classId:item.classId1+'_'+item.classId2+'_'+item.classId3}"}],class:['pinlei_g3col_btn','blue', item.state == 1 ? 'disabled' : ''],style:(params.extend.priceType == 0?'border-radius: 24px;':'')},[_v("\n                                        "+_s(params.extend.buttonWording)+"\n                                    ")]):_c('div',{directives:[{name:"skuyuyue",rawName:"v-skuyuyue",value:({bindObj:data,stop:true, skuId:item.ddwSkuId,ignoreHistory:true,appendTo:item,msg:{success: '預約成功,請留意京東JD.COM服務號的活動提醒',exist: '已設置預約,無需再進行設置',systemError: '該商品不是預約活動商品'}}),expression:"{bindObj:data,stop:true, skuId:item.ddwSkuId,ignoreHistory:true,appendTo:item,msg:{success: '預約成功,請留意京東JD.COM服務號的活動提醒',exist: '已設置預約,無需再進行設置',systemError: '該商品不是預約活動商品'}}"}],class:['pinlei_g3col_btn','blue', item.state == 1 ? 'disabled' : ''],style:(params.extend.priceType == 0?'border-radius: 24px;':'')},[_v("\n                                        "+_s(params.extend.buttonWording)+"\n                                    ")])]:_e(),(params.extend.buttonType == '6' )?[_c('div',{directives:[{name:"yuyue",rawName:"v-yuyue",value:({bindObj:data,stop:true,noTip:true,activeId:params.extend.yuyueID,appendTo:item,appendName:'state',msg:[]}),expression:"{bindObj:data,stop:true,noTip:true,activeId:params.extend.yuyueID,appendTo:item,appendName:'state',msg:[]}"},{name:"addcart",rawName:"v-addcart",value:({skuId: {skuId: item.ddwSkuId,successTxt:'預約加車成功'}}),expression:"{skuId: {skuId: item.ddwSkuId,successTxt:'預約加車成功'}}"}],class:{'pinlei_g3col_btn':true, 'blue':params.extend.beginTime, 'red':(!params.extend.beginTime), 'left': params.fnObj.getCouponInfo(item.sBackUpWords[2])},style:(params.extend.priceType == 0?'border-radius: 24px;':''),attrs:{"yuyueid":params.extend.yuyueID}},[_v("\n                                        "+_s(params.extend.buttonWording)+"\n                                    ")])]:_e()]:_e(),(!params.fnObj.isBeforeActive(params.extend.beginTime))?[(params.extend.buttonActiveType == '0')?[_c('div',{directives:[{name:"addcart",rawName:"v-addcart",value:({skuId: item.ddwSkuId}),expression:"{skuId: item.ddwSkuId}"}],staticClass:"pinlei_g3col_btn",style:(params.extend.priceType == 0?'border-radius: 24px;background-color: #ea1e54;':'')},[_v("\n                                        "+_s(params.extend.buttonActiveWording)+"\n                                    ")])]:_e(),(params.extend.buttonActiveType == '1')?[_c('a',{attrs:{"href":addRd(item.sUrl,params.ptag)}},[_c('div',{staticClass:"pinlei_g3col_btn red",style:(params.extend.priceType == 0?'border-radius: 24px;background-color: #ea1e54;':'')},[_v("\n                                            "+_s(params.extend.buttonActiveWording)+"\n                                        ")])])]:_e(),(params.extend.buttonActiveType == '2')?[_c('a',{attrs:{"href":addRd(goPingouUrl(item.sUrl),params.ptag)}},[_c('div',{staticClass:"pinlei_g3col_btn red",style:(params.extend.priceType == 0?'border-radius: 24px;background-color: #ea1e54;':'')},[_v("\n                                            "+_s(params.extend.buttonActiveWording)+"\n                                        ")])])]:_e(),(params.extend.buttonActiveType == '4')?[_c('div',{directives:[{name:"addcart",rawName:"v-addcart",value:({skuId: item.ddwSkuId, bindObject: item, bindPropertyName: 'addCartMsg', isPullQuan: true}),expression:"{skuId: item.ddwSkuId, bindObject: item, bindPropertyName: 'addCartMsg', isPullQuan: true}"},{name:"quan",rawName:"v-quan",value:({bindObj:data,key:params.extend.key, level:params.extend.level, num:1, msg:{}, appendTo:item, appendName:'status', ignoreHistory:false, style:2, successUrl:item.successUrl, type:1, coupondes:{value: params.extend.price, gate: params.extend.gate, name: params.extend.name}}),expression:"{bindObj:data,key:params.extend.key, level:params.extend.level, num:1, msg:{}, appendTo:item, appendName:'status', ignoreHistory:false, style:2, successUrl:item.successUrl, type:1, coupondes:{value: params.extend.price, gate: params.extend.gate, name: params.extend.name}}"}],staticClass:"pinlei_g3col_btn",style:(params.extend.priceType == 0?'border-radius: 24px;background-color: #ea1e54;':'')},[_v("\n                                        "+_s(params.extend.buttonActiveWording)+"\n                                    ")])]:_e()]:_e()],2),(params.extend.corner != '0')?[(isRange(params.extend.cornerBegin, params.extend.cornerEnd) && params.extend.cornerDes)?_c('div',{staticClass:"pinlei_g3col_stamp red"},[_v(_s(params.extend.cornerDes))]):(item.sCopyWriting)?_c('div',{staticClass:"pinlei_g3col_stamp red"},[_v(_s(item.sCopyWriting))]):_e()]:_e()],2):_e()}):_e()],2),(params.shownum && data.itemList.length > params.shownum)?[_c('div',{class:'pinlei_more '+ (data.showMore?'pinlei_more_unfold':''),on:{"click":function($event){toggleMore($event)}}},[_v(_s(data.showMore?'收起更多':'展開更多'))])]:_e()],2)}},staticRenderFns:[function(){with(this){return _c('div',{staticClass:"pinlei_g3col_msk_ico"},[_c('div',{staticClass:"pinlei_g3col_msk_text"},[_v("\n                                        已搶光\n                                        "),_c('br'),_v("over\n                                    ")])])}}]}})`	
//正則替換法start	
const timeStart = new Date().getTime();	
for(var i = 0; i < exeCount; i++) {	
    str.replace(/__uid__replace__str__/g, 'com_1001');	
}	
const timeEnd = new Date().getTime();	
console.log('正則替換耗時:', timeEnd - timeStart);	
//正則替換法end	
//數組粘合法start	
const timeStart2 = new Date().getTime();	
const segs = str.split('__uid__replace__str__');	
for(var i = 0; i < exeCount; i++) {	
    segs.join('com_1001');	
}	
const timeEnd2 = new Date().getTime();	
console.log('數組粘貼耗時:', timeEnd2 - timeStart2);	
//數組粘合法end

結果如下:

執行次數正則替換法耗時(ms)數組粘合法耗時(ms)正則替換法耗時/數組粘合法耗時
1W42251.68
10W3621792.01
100W355516232.2
1000W36449186341.95

可以看到數組粘合法的耗時是正則替換法的一半左右。

考慮到我們的場景就是字符串比較大,存在循環替換,且是海量服務的場景,因此上面第一次替換,直接改成數組粘合法即可。

4.組件模板替換優化

問題代碼中的第二次替換,是將容器裏的組件佔位符替換爲子組件的Render Function。即下圖所示:

640?wx_fmt=png

子模板替換優化的替換次數其實是跟組件的數量相關的,即使有150個組件,用數組粘合法也不會有明顯的性能提升,因此需要考慮別的辦法。

我們查了一下vue-template-compiler的源碼(Vue的compile能力也是用此模塊),發現Vue.compile的函數有2個參數,第一個參數是待編譯的Vue模板,第二個參數是一個option對象,包含一個名爲tansformCode鉤子函數(參見資料https://github.com/vuejs/vue/blob/dev/flow/compiler.js#L38-L45 ,此參數並未在官網的文檔中暴露,關於此函數的用處後面可以再寫一篇文章) ,這個鉤子函數接受兩個參數,第一個是ast節點,第二個是編譯該節點之後的render code,而該函數的返回值會就是最終的render code。於是在之前的生成dom函數那裏把com佔位符替換爲一個空的div元素,div元素的id爲之前的佔位符,然後在編譯的時候在transformCode鉤子函數這裏做一個替換,當發現ast節點爲div並且id符合組件佔位符的規則,那麼就返回該組件對應的編譯之後樣式模版。具體代碼如下:

var compileTpl = compiler.compile(`<div>${html}</div>`, {	
            modules: {	
                transformCode: function (el, code) {	
                    if (el.attrsMap && el.attrsMap['id'] && el.attrsMap['id'].match(/__vue__com_\d{4,5}__replace__/)) {	
                        var comId = el.attrsMap['id'].match(/com_\d{4,5}/)[0];	
                        // console.log('--------------------------------', comId);	
                        var styleTemplate  = compiledComTplMap[comId];	
                        // console.log(styleTemplate);	
                        return styleTemplate;	
                    }	
                    return code;	
                }	
            }	
        }).staticRenderFns.toString();

這樣一來就完全省去了第二次字符串替換的操作,於是組件編譯這裏的流程了下面這樣:

640?wx_fmt=png

這兩次優化之後然後重新壓測並收集性能數據,得到的火焰圖如下:

640?wx_fmt=png

可以看到createApp函數裏面原來的那個replace函數的橫條已經消失不見了,說明前面的優化是有效果的,最耗時的操作已經不是replace而是vue的compile方法即模版的編譯。從此次優化前後的服務端壓測的CPU數據也能說明問題:

640?wx_fmt=png

四.compile函數調用優化

compile函數調用,就是前面"組件模板構造"那一節提到的,將組件的容器模板用Vue.compile函數編譯成Render Function,雖然這段容器模板很簡單,但是他是一個很耗性能的操作。而且這是Vue自身提供的能力,似乎已經沒有多大的優化餘地了。有沒有其他優化方法呢?

仔細觀察一下組件容器dom以及編譯之後的代碼,似乎是有規律的。如果組件樹的結構是下面這樣的:

[	
    {id: "com_1001"},	
    {	
        id: "com_1002",	
        child: [	
            {id: "com_1003"},	
            {id: "com_1004"}	
        ]	
    }	
];

拼接之後的html內容大概是下面這樣的:

<div>	
    <div id="com_1001_con"></div>	
    <div id="com_1002_con"></div>	
    <div mpm_edit_child_box tabpid="com_1002" class="childBox">	
        <div id="com_1003_con"></div>	
        <div id="com_1004_con"></div>	
    </div>	
</div>

這裏一般都只是一些簡單的模版,編譯出來大概是這樣的:

with(this) {	
    return _c('div', [	
        _c('div', {attrs: {"id": "com_1001_con"}}),	
        _v(" "),	
        _c('div', {attrs: {"id": "com_1002_con"}}),	
        _v(" "),	
        _c('div', {staticClass: "childBox", attrs: {"mpm_edit_child_box": "", "tabpid": "com_1002"}}, [	
            _c('div', {attrs: {"id": "com_1003_con"}}),	
            _v(" "),	
            _c('div', {attrs: {"id": "com_1004_con"}})	
        ])	
    ])	
}

通過觀察可以發現,這裏都是生成的div元素,div上的屬性都是靜態屬性,由此我們可以自己實現一個簡單的“編譯”函數,不用走vue的編譯:

 function simpleCompile(comList) {	
            function genTree(tree) {	
                var html = '';	
                for (var i = 0, len = tree.length; i < len; i++) {	
                    var node = tree[i];	
                    var comId = node.id;	
                    html += `_c('div',{attrs:{"id":"${comId}_con"}},[`;	
                    html = html + compiledComTplMap[comId] + '])';  //  compiledComTplMap[comId] 該組件對應的編譯後的樣式模版	
                    if (node.child && node.child.length) {	
                        html += `,_c('div',{staticClass:"childBox",attrs:{"mpm_edit_child_box":"","tabpid":"${comId}"}},[` + genTree(node.child) + `])`;	
                    }	
                    html += (i === len - 1)  ? '' : ',';	
                }	
                return html;	
            }	
            return genTree(comList);	
        }

經測試,這樣簡單“編譯”之後生成的代碼跟之前編譯的代碼是一樣的,在預發佈環境測試了多個頁面之後,頁面渲染也沒有問題。去掉Vue模版編譯之後整個組件渲染的邏輯就變成了下面這樣:

640?wx_fmt=png

Vue編譯優化之後收集cpu數據得到的火焰圖如下:

640?wx_fmt=png

從火焰圖可以看出,原來的那個compile函數調用的橫條也消失了,說明優化有效果。再看看壓測的CPU消耗情況:

640?wx_fmt=png

需要提到的是,由於是自己實現了一個簡單版的compile函數,前文中關於compile函數調用優化的代碼,也直接去掉了,當然也到達了優化的效果。

五.其他優化研究

上面2點,各位看官如果有好的思路,歡迎不吝賜教!

六.總結

這次優化總的來說,CPU消耗得到了有效優化,整體提升了大概20%,一方面爲公司節省了資源,另外一方面也減少了因流量暴漲導致我們要擴容的機率,一舉兩得。

❤️ 看完三件事

如果你覺得這篇內容對你挺有啓發,我想邀請你幫我三個小忙:

  1. 點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)

  2. 關注我的博客 https://github.com/SHERlocked93/blog,讓我們成爲長期關係

  3. 關注公衆號「前端下午茶」,持續爲你推送精選好文,也可以加我爲好友,隨時聊騷。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章