儀表盤場景的前端優化

李熠發表於2019-01-29

背景

相信絕大部分公司的中臺系統中都存在儀表盤頁面,以 Ant Design Pro 展示的分析頁面為例,通常儀表盤由不同的圖表卡片組成,並且允許作者新增、刪除,編輯卡片以及調整卡片的位置大小等等

儀表盤場景的前端優化

圖表卡片支援多種型別的圖表展現,以滿足不同角色同學以不同的角度觀察指標的變化。但是無論卡片的展現形式有多麼的千變萬化,背後都需要後端精確的資料予以支撐。

考慮到卡片是儀表盤的最小單元,彼此之間相互獨立,並且可以被動態的新增、預覽。所以在頁面最初的設計階段,我們將儀表盤資訊分開存在在兩個實體中:「儀表盤」和「卡片」。儀表盤只儲存它擁有卡片的基本資訊,如卡片的ID以及位置和尺寸;而卡片的詳細資訊以及查詢工作則交給卡片獨立獲取。前端與後端同學約定介面時也是以卡片為中心,我們為卡片準備了兩類介面, 為了便於描述,將介面簡化和語義化:

  • /meta: 用於請求卡片的元資訊,例如配置的指標、維度、圖表型別等
  • /query: 根據卡片的元資訊查詢圖表資料,資料返回之後再進行渲染

之所以要將查詢介面與元資訊介面拆分開,是因為查詢體中除了元資訊以外還要整合諸如全域性日期,篩選條件等額外資訊

基於上述的設計,前端的程式碼實現非常簡單,我們採取了一種「自治」的思想:儀表盤元件只負責將卡片元件例項以指定尺寸擺放在指定的位置,而至於這張卡片的載入、查詢、渲染全權由卡片自己負責。基於這個思路,我們甚至不需要複雜的狀態管理框架(如 Redux 或者 Mobx),僅僅依靠檢視層的 React 就能夠實現。程式碼實現中藉助了react-refetch 類庫,它以高階元件的形式為資料載入提供便利,虛擬碼如下:

// 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 能保證 metaInfoFetchquery 順序執行,並且把結果以屬性的形式傳遞進元件中。

但是沒想到前端這種「自治」的解決方案卻給產品帶來了災難

產品上線之後,我們得到使用者反饋某些儀表盤頁面開啟總時是會進入了「卡死」狀態,即頁面無法滾動,無法點選,甚至瀏覽器也無響應。即使沒有「卡死」,一段時間內頁面的互動也會出現滯後的情況。在整理出這些有問題的儀表盤之後,我們發現這些儀表盤都具有一些相似的特徵:1) 卡片數量多 2) 卡片需要渲染的資料量大

因為允許使用者隨意的任意的配置卡片,所以某些儀表盤的卡片數量可以達到 20 甚至 30 張以上,算上每張卡片至少需要發出兩個網路請求,在開啟儀表盤的瞬間也就有 40 個以上的網路請求同時發出,這顯然超出瀏覽器的處理能力的,更何況瀏覽器也不允許同一域下有如此的多並行請求,大部分的請求其實是處於佇列等待中的;隨著多個卡片查詢結果的返回,這些卡片繼續進入圖表渲染階段,如果卡片是分鐘級別的折線圖的話,考慮到按照 n 個維度拆分的情況,圖表需要處理 24 × 60 × n 條資料,這也是一筆不小的開銷。於是你看到在同一時間內,不合理的請求發出,眾多的卡片在渲染,再加上其他需要執行的指令碼,CPU 自然就進入了滿負荷的狀態,因為「單執行緒」的緣故,瀏覽器也就無暇響應使用者的輸入以及渲染頁面了

儀表盤場景的前端優化

如上圖所示,如果藉助 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 測量結果:

儀表盤場景的前端優化

根據圖中的紅線,藍線,綠線我們分別能得出一些事件指標的發生時機

  • First paint (Green): 443ms
  • DOMContentLoaded (Blue): 1.32s
  • Load (Red): 1.34s

優化的儀表盤的 Performance 測量的結果

儀表盤場景的前端優化

  • First paint (Green): 590ms
  • DOMContentLoaded (Blue): 1.59s
  • Load (Red): 1.59ss

每次的測試數值都可能會有差異,但是總體上看在這個測試工具裡,優化過後的儀表盤的三項指標反而是潰敗的。唯一值得慶幸的事情是,優化過後的儀表盤在初始化之後單幀的渲染效率比未優化的要高,未優化的儀表盤甚至某一幀渲染超過 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"] });
複製程式碼

列印的結果如下:

  • (未優化)

    • first-paint 1349
    • first-contentful-paint 1349
  • (優化後)

    • first-paint 1023
    • first-contentful-paint 1023

在這個測試手段下我們得到了相反的結果(並且未優化的儀表盤的測試結果非常不穩定,從九百毫秒到兩千毫秒都有可能發生)。然而如果此時又通過 tti-polyfill 測試 Time to Interactive 的話,未優化的儀表盤又領先了一大截。

為什麼出現這樣的情況?因為不同的工具、API 對指標的測量方式不同,以及口徑不同

以 TTI 指標為例,很明顯優化方案下使用者輸入一定會比未優化方案更快的得到響應,但是為什麼未優化方案的測量結果會更好?因為瀏覽器對於 TTI 的理解和我們不同,瀏覽器計算 TTI 的方式是:

儀表盤場景的前端優化

首先找到一個接近 TTI 的零界點,比如 FirstContentfulPaint 或者 DomContentLoadedEnd 時機 從臨界點向後查詢不包含長任務 (long task) 的並且網路請求相對平靜的 5 秒鐘視窗期 找到之後,再向前追溯到最後一個長任務的執行結束點,那就是我們的要找的 TTI

而我們單純的認為從使用者點選頁面或者在頁面輸入開始,到瀏覽器給出反饋為止,之間的間隔就算 TTI 。所以在優化方案中,因為網路請求始終在發生,TTI 測量結果異常糟糕。

在實施到真實產品中之後它的確是有效的。但是在文章的最後,我們無法用一個恰當的工具測量出的恰當的指標以宣告它,或許這個時候我們可以考慮使用更具針對性的業務指標來驗證優化的結果,例如使用者的頁面停留時長,瀏覽器標籤的切換次數以及操作頻率等等,但這些埋點和指標設計都超出本文範圍之外了。指標有時候能夠量化我們的產出,有時候不能,有時候甚至會給出錯誤的指導。作為工程師還是不能僅僅依賴外部的反饋,需要對技術有理解、信心和判斷,來做正確的事


本文也釋出於我的知乎專欄前端技術漫遊指南中,也被收入在 知乎技術專欄中,歡迎大家關注

相關文章