相信絕大部分公司的中臺系統中都存在儀表盤頁面,以 Ant Design Pro 展現的分析頁面爲例,一般儀表盤由不一樣的圖表卡片組成,而且容許做者添加、刪除,編輯卡片以及調整卡片的位置大小等等javascript
圖表卡片支持多種類型的圖表展示,以知足不一樣角色同窗以不一樣的角度觀察指標的變化。可是不管卡片的展示形式有多麼的變幻無窮,背後都須要後端精確的數據予以支撐。html
考慮到卡片是儀表盤的最小單元,彼此之間相互獨立,而且能夠被動態的添加、預覽。因此在頁面最初的設計階段,咱們將儀表盤信息分開存在在兩個實體中:「儀表盤」和「卡片」。儀表盤只存儲它擁有卡片的基本信息,如卡片的ID以及位置和尺寸;而卡片的詳細信息以及查詢工做則交給卡片獨立獲取。前端與後端同窗約定接口時也是以卡片爲中心,咱們爲卡片準備了兩類接口, 爲了便於描述,將接口簡化和語義化:前端
/meta
: 用於請求卡片的元信息,例如配置的指標、維度、圖表類型等/query
: 根據卡片的元信息查詢圖表數據,數據返回以後再進行渲染之因此要將查詢接口與元信息接口拆分開,是由於查詢體中除了元信息之外還要整合諸如全局日期,篩選條件等額外信息java
基於上述的設計,前端的代碼實現很是簡單,咱們採起了一種「自治」的思想:儀表盤組件只負責將卡片組件實例以指定尺寸擺放在指定的位置,而至於這張卡片的加載、查詢、渲染全權由卡片本身負責。基於這個思路,咱們甚至不須要複雜的狀態管理框架(如 Redux 或者 Mobx),僅僅依靠視圖層的 React 就可以實現。代碼實現中藉助了react-refetch 類庫,它以高階組件的形式爲數據加載提供便利,僞代碼以下:react
// CardComponent.js:
import {connect} from 'react-refetch'
@connect(() => {
return {
metaInfoFetch {
url: '/meta',
andThen = () => ({
query: '/query'
})
}
}
})
class Card {
render() {
const queryResult = this.props.query.value
return <Chart data={queryResult} />
}
}
// DashboardComponent.js:
class Dashboard {
return (
<div>
{cards.map(({id, position, size}) =>
<Card id={id} position={position} size={size} />
)}
</div>
)
}
複製代碼
connect 能保證 metaInfoFetch
和 query
順序執行,而且把結果以屬性的形式傳遞進組件中。ios
可是沒想到前端這種「自治」的解決方案卻給產品帶來了災難git
產品上線以後,咱們獲得用戶反饋某些儀表盤頁面打開總時是會進入了「卡死」狀態,即頁面沒法滾動,沒法點擊,甚至瀏覽器也無響應。即便沒有「卡死」,一段時間內頁面的交互也會出現滯後的狀況。在整理出這些有問題的儀表盤以後,咱們發現這些儀表盤都具備一些類似的特徵:1) 卡片數量多 2) 卡片須要渲染的數據量大github
由於容許用戶隨意的任意的配置卡片,因此某些儀表盤的卡片數量能夠達到 20 甚至 30 張以上,算上每張卡片至少須要發出兩個網絡請求,在打開儀表盤的瞬間也就有 40 個以上的網絡請求同時發出,這顯然超出瀏覽器的處理能力的,更況且瀏覽器也不容許同一域下有如此的多並行請求,大部分的請求實際上是處於隊列等待中的;隨着多個卡片查詢結果的返回,這些卡片繼續進入圖表渲染階段,若是卡片是分鐘級別的折線圖的話,考慮到按照 n 個維度拆分的狀況,圖表須要處理 24 × 60 × n 條數據,這也是一筆不小的開銷。因而你看到在同一時間內,不合理的請求發出,衆多的卡片在渲染,再加上其餘須要執行的腳本,CPU 天然就進入了滿負荷的狀態,由於「單線程」的緣故,瀏覽器也就無暇響應用戶的輸入以及渲染頁面了axios
如上圖所示,若是藉助 Chrome 瀏覽器自帶的 Performance 工具觀察整個加載的過程,從標註1和標註2能夠看出 CPU 始終處於滿載的狀態,而且這其中的主要是在執行腳本,幾乎沒有給渲染分配時間,從標註3能夠看出,在這段時間內瀏覽器渲染能力接近 0fps,須要上百毫秒時間來渲染一幀後端
這是事故的現狀,接下來就要解決這個問題。
治理程序性能問題最有效的兩個手段就是經驗和工具。經驗不只僅是指我的曾經碰見過一樣的問題,還包括行業內前人的總結概括等等。絕大部分問題經過頁面的所屬功能以及異常行爲就能判斷出問題可能出在哪裏以及應該如何解決。而對於更復雜的難以經過表象判斷的問題,這個時候就須要藉助於工具分析問題的方方面面,又或者你只是想經過工具驗證你的猜測是否正確而已。
在上一小節的描述的問題中,咱們藉助工具得知是由於高強度的工做形成了 CPU 的滿載。這個時候經驗就可以派上用場了。
在面對 long task (執行時間超過 50ms以上)時,屢試不爽的解決方案是分片(chunk),也就是把長時間連續執行的任務拆分紅短暫的可間隔執行的任務。拆分的好處是可以使得瀏覽器得以有空隙響應其餘的請求,好比用戶的輸入以及頁面的繪製。
在 Nicholas C. Zakas(「JavaScript高級程序設計」原版做者) 十年前發表的一篇博客中,在處理一個佔用時間過長的循環時,他編寫了一個很簡易的分片函數:
function chunk(array, process, context){
setTimeout(function(){
var item = array.shift();
process.call(context, item);
if (array.length > 0){
setTimeout(arguments.callee, 100);
}
}, 100);
}
複製代碼
雖然如今 callee
已經 deprecated 了,setTimeout
也可使用 requestAnimationFrame
代替。可是它背後的思考方式並無發生變化
咱們面臨的困難並非一個真實的 long task,而是無數的碎片任務蜂擁而至形成了 long task 的症候羣。解決思路依然參考上述辦法:要設法給瀏覽器製造喘息的機會。在這個目標之下,咱們選擇的方案是放棄卡片自治的數據加載和渲染方式,而是採用隊列的機制,對須要執行的全部任務作到嚴格的進出控制。這樣可以保證從加載之初就不會給瀏覽器大壓力
退一步說,即便不是由於性能問題,「自治」仍然不是一個好的設計方案:在開發的後期它的問題已經初現端倪:例如用戶須要隨時終止全部的卡片的進程、或者按照某些順序加載,也就是當把它們看成總體時,某些需求很難實現。
這相似於在 React 中是採用 Stateless 仍是 Stateful 的方式的抉擇。當你在考慮到它們共同屬於某個總體時,如父組件和子組件以及子組件之間須要進行相互通訊和影響時,應該把大部分組件設計爲 Stateless,而且把狀態集中在頂部組件集中管理,又或者把狀態都集中在 Flux 的 Store 中。
性能優化在平常工做中其實處於很尷尬的位置。例如你花費三天爲頁面或者 App 開發了一個功能,上線以後你們是有目共睹的。然而若是你花費三天時間告訴你們我進行了一次代碼優化,你們會對你的產出有所懷疑。因此在優化以前最好肯定計劃提高的指標以便量化產出
然而在這個場景裏應該選取哪些指標?一般咱們會將指標劃分爲「業務指標」和「工程指標」:「業務指標」衡量的是產品的運營狀態,例如轉化、留存、GMV等等;而「技術指標」則主要面向的是技術人員,例如 QPS、併發數等等。可是請注意業務指標和工程指標並不是是互斥關係,也並不是是正相關的(試想 onload 或者 DOMContentLoaded 的時間被延長,那麼 Bounce Rate 必定會升高嗎?)
在中臺的業務場景下,咱們並不存在營收或者說是商業化方向的壓力,目前看來只有一條,那就是讓產品變得可用:即頁面可以及時響應用戶的輸入,及時反饋頁面的更新。因此大部分指標都會從工程指標中選取。在前端領域中咱們能夠選取 DOMContentLoaded、SpeedIndex、First Paint、First Contentful Paint、Time to Interactive 等等:
但假設真的選取了以上指標,如何可以準確測量指標?以及測量的結果是否可以正確的反饋工做的成果?這些問題在代碼開發完成以後將會獲得回答,咱們會藉助瀏覽器接口或者工具來複盤這些指標的變化。
回到解決方案中,最後咱們決定使用一個隊列機制嚴格的控制儀表盤的,其實也是卡片的每一步操做: 1) 請求 meta 信息; 2) 查詢報表數據; 3) 渲染卡片
考慮到請求數據和渲染卡片分別是異步和同步操做,準確來講咱們是須要一個異步和同步通吃的隊列機制。實現的方法有不少種,在這裏咱們藉助於 Promise 實現,由於 1) Promise 天生對異步操做有友好支持; 2) Promise 也能夠兼容同步操做; 3) Promise 支持順序執行
咱們將這個隊列類命名爲 PromiseDispatcher,而且提供一個 feed 方法用於塞入須要執行的函數(無需區分異步仍是同步),好比:
const promiseDispatcher = new PromiseDispatcher()
promiseDispatcher.feed(
requestMetaJob,
requestDataJob,
renderChart
)
複製代碼
feed 順序同時也是函數的執行順序
注意 dispatcher 並不具備保留執行函數返回值的功能,好比
promiseDispatcher.feed(
requestMetaJob,
requestDataJob,
renderChart
).then((requestMetaResult, requestDataResult, renderResult) => {
})
複製代碼
不支持以上的使用方法並非由於實現不了,而是從職責上考慮隊列不該該承擔這樣的工做。隊列應該只負責分發而且保證成員執行順序的正確性。若是你還不明白其中的道理,能夠參考 dispatcher 角色在 Flux 架構中的功能
由於篇幅有限,咱們這裏只列舉 PromiseDispatcher
的部分關鍵代碼,整個項目的完整代碼會在本文的稍後給出。隊列的順序執行機制借用數組的 reduce 方法實現:
return tasks.reduce((prevPromise, currentTask()) => {
return prevPromise.then(chainResults =>
currentTask().then(currentResult =>
[ ...chainResults, currentResult ]
)
);
}, Promise.resolve([]))
複製代碼
然而咱們還要兼容同步函數的代碼,因此須要對 currentTask 返回結果是不是 Promise 類型作判斷:
// https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise#answer-27746324
function isPromiseObj(obj) {
return obj && obj.then && _.isFunction(obj.then);
}
return tasks.reduce((prevPromise, currentTask()) => {
return prevPromise.then(chainResults => {
let curPromise = currentTask()
curPromise = !isPromiseObj(curPromise) ? Promise.resolve(curPromise) : curPromise
curPromise.then(currentResult => [ ...chainResults, currentResult ])
});
}, Promise.resolve([]))
複製代碼
須要考慮更復雜的狀況是,有時候僅僅是單個依次執行任務又過於節約了,因此咱們也要容許多個任務「併發」執行。因而咱們決定給容許給 PromiseDispatcher 配置名爲 maxParallelExecuteCount
的參數,用於控制最大可並行的執行個數。針對這個需求,使用 Promise.all
來處理多個併發的異步操做狀況:
import _ from 'lodash'
const { maxParallelExecuteCount = 1 } = this.config;
const chunkedTasks = _.chunk(this.tasks, maxParallelExecuteCount);
return chunkedTasks.reduce((prevPromise, curChunkedTask) => {
return prevPromise.then(prevResult => {
return Promise.all(
curChunkedTask.map(curTask => {
let curPromise = curTask()
curPromise = !isPromiseObj(curPromise) ? Promise.resolve(curPromise) : curPromise
return curPromise
})
).then(curChunkedResults => [ ...chainResults, curChunkedResults ])
})
}, Promise.resolve([]))
複製代碼
由於項目使用 Mobx 的關係,這裏只展現 Mobx 框架下 PromiseDispatcher 與 Mobx 和 組件配合的代碼。相信在其餘的框架下也大同小異,關鍵代碼以下:
// Component App.js:
import { observer, inject } from "mobx-react";
@inject('dashboardStore')
@observer
export default class App extends React.Component {
constructor(props) {
super(props);
}
render() {
const { reports } = this.props.dashboardStore;
return (
<div> {reports.map(({ id, data, loading, rendered }) => { return ( <ChartCard key={id} data={data} loading={loading} rendered={rendered} /> ); })} </div> ); } } // DashboardStore.js: export default class DashboardStore { @observable reports = [...Array(30).keys()].map((item, index) => { return { loading: true, id: index, data: [], rendered: false }; }); constructor() { autorun(() => { this.reports.forEach(report => { const requestMetaJob = () => { report.loading = true; return axios.get("/meta"); }; const requestDataJob = () => { return axios.get("/api").then(() => { report.loading = false; report.data = randomData(); }); }; const initializeRendering = () => { report.rendered = true; }; promiseDispatcher.feed([requestMetaJob, requestDataJob, initializeRendering]); }); }); } } 複製代碼
注意,由於咱們沒法手動調用組件的 API 觸發組件渲染,因此採用標誌位 rendered 被動的觸發卡片的渲染,但這一步仍有有優化的空間,這一步驟能夠忽略,沒必要爲了控制而控制;又或者給它足夠的執行時間。
在組件 <ChartCard />
中只要作簡單的判斷便可:
componentDidUpdate(prevProps) {
if (!prevProps.rendered && this.props.rendered) {
this.renderChart(this.props.data);
}
}
複製代碼
爲了驗證方案,我將本文描述的項目寫成了一個 DEMO,源碼地址見 hh54188/dashboard-optimize,其中未優化源碼文件夾 dashboard-optimize/src/App/, 以及優化以後的源碼文件夾 dashboard-optimize/src/OptimizedApp_Basic/。接下來咱們使用不一樣的工具,測量不一樣方案下的指標變化
須要說明的是 DEMO 並不能準確的模擬出真實的場景,測量的結果可能須要被放大以後纔會接近真實值。例如在本文開頭未優化的儀表盤加載時,經過 Performance 的觀察 CPU 近乎全滿。而下圖中未優化 DEMO 的 CPU 負載仍然有大部分處於閒置狀態
接下來咱們使用不一樣的工具,測量不一樣方案下的指標變化
未優化的儀表盤 Performance 測量結果:
根據圖中的紅線,藍線,綠線咱們分別能得出一些事件指標的發生時機
優化的儀表盤的 Performance 測量的結果
每次的測試數值均可能會有差別,可是整體上看在這個測試工具裏,優化事後的儀表盤的三項指標反而是潰敗的。惟一值得慶幸的事情是,優化事後的儀表盤在初始化以後單幀的渲染效率比未優化的要高,未優化的儀表盤甚至某一幀渲染超過 1 秒
然而若是換一種測量手段呢?好比 API?咱們嘗試使用 PerformanceObserver
,在 html 文件里加入以下代碼
const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
const metricName = entry.name;
const time = Math.round(entry.startTime + entry.duration);
console.log(metricName, time);
}
});
observer.observe({ entryTypes: ["paint"] });
複製代碼
打印的結果以下:
(未優化)
(優化後)
在這個測試手段下咱們獲得了相反的結果(而且未優化的儀表盤的測試結果很是不穩定,從九百毫秒到兩千毫秒都有可能發生)。然而若是此時又經過 tti-polyfill 測試 Time to Interactive 的話,未優化的儀表盤又領先了一大截。
爲何出現這樣的狀況?由於不一樣的工具、API 對指標的測量方式不一樣,以及口徑不一樣
以 TTI 指標爲例,很明顯優化方案下用戶輸入必定會比未優化方案更快的獲得響應,可是爲何未優化方案的測量結果會更好?由於瀏覽器對於 TTI 的理解和咱們不一樣,瀏覽器計算 TTI 的方式是:
首先找到一個接近 TTI 的零界點,好比 FirstContentfulPaint 或者 DomContentLoadedEnd 時機 從臨界點向後查找不包含長任務 (long task) 的而且網絡請求相對平靜的 5 秒鐘窗口期 找到以後,再向前追溯到最後一個長任務的執行結束點,那就是咱們的要找的 TTI
而咱們單純的認爲從用戶點擊頁面或者在頁面輸入開始,到瀏覽器給出反饋爲止,之間的間隔就算 TTI 。因此在優化方案中,由於網絡請求始終在發生,TTI 測量結果異常糟糕。
在實施到真實產品中以後它的確是有效的。可是在文章的最後,咱們沒法用一個恰當的工具測量出的恰當的指標以宣告它,或許這個時候咱們能夠考慮使用更具針對性的業務指標來驗證優化的結果,例如用戶的頁面停留時長,瀏覽器標籤的切換次數以及操做頻率等等,但這些埋點和指標設計都超出本文範圍以外了。指標有時候可以量化咱們的產出,有時候不能,有時候甚至會給出錯誤的指導。做爲工程師仍是不能僅僅依賴外部的反饋,須要對技術有理解、信心和判斷,來作正確的事