preloader
心得

Frontend Engineer Tune Performance | 網站產品提升效能的前端經驗分享談

Frontend Engineer Tune Performance | 網站產品提升效能的前端經驗分享談

現在服務的公司,因為某天客戶反映某產品的使用體驗速度很慢,所以開啟了兩階段的提升效能專案。歷時兩個月,終於在五月結束前完成,六月端午節連假開始前順利上線,收到客戶的回饋內容很多是稱讚,老闆也很滿意這次的成果。

成果

提升效能第一階段的成果是原先總秒數 11 ~ 16 秒左右的情況,縮短到 6 ~ 10 秒完成後,使用者即能開始使用。第二階段的成果是,再縮短到 1 ~ 5 秒內,使用者即能開始使用,若有些尚未完成取得的資料,則會顯示在該項內容旁,顯示視覺的等待效果。

心得

這兩階段的專案,我善用自己的執行力、講究細節和耐心的特質,持續改進解決方法和使用者體驗,歷經多次不厭其煩的反覆驗證現有功能是否能繼續正常運作,終於順利推出。為什麼要不厭其煩地的驗證? 因為這產品具備豐富的功能,持續改進解決方法的過程,很可能在某次的修改,意外改變其他原本的運作邏輯,導致原本正常運作的功能,變成不能使用,這是修改程式碼的過程中,容易犯的錯誤。如果部署到正式環境才發生問題並造成客戶的困擾,將嚴重影響到公司的形象。另外,同事們和老闆給我一些使用者體驗的建議,我照做後體驗變得更棒,讓業務和客服部門向客戶教學時更為順利。

釋出的程式碼多少產生新的 bug ,但影響範圍不大,且都有替代方案提供給客戶,讓他們繼續完成日常工作。

過程中遭遇到的困難

原本程式碼沒有註解說明,根本不知道用途是什麼,例如日期變數用成兩個,date, 和 date2,誰會知道用途? 前人開發者可能有些自以為是,不寫註解說明,目的想讓程式碼內容自己說明用途(self-explained code)。其他開發者看到只能傻眼,因為變數命名取得很糟。 git blame commit 歷史看不出用途,從 commit 歷史內容的前後文,也看不出用途,也沒留文件,令我閱讀和修改時感到痛苦。

一個頁面大約有16個功能會間接和直接使用同一個函式,去取得最新資料,但都沒有註解說明。間接的使用方式會透過跨頁面的共用函式取得最新資料,若修改共用函式,很容易讓兩階段的程式碼修改範圍持續擴大,很難取捨要停在哪些檔案就好。取捨的難題,也包含可能要讓極少數、很少使用者正在用且不重要的功能產生bug,但提供替代方式給客戶完成日常作業,或者是堅持修到很完美,釋出時程會一延再延。

程式碼庫有一個時好時壞的 Singleton 服務,用以跨頁面傳遞共用資料,常常你認為它應該要拿到最新的資料,但印出它持有的變數資訊卻不是。而且它搭配 ionic 的事件訂閱機制,讓資料傳遞流程是複雜和不明確的順序。

問題情境

客戶反應使用時常需要等待很久,才看得到資料,而且每次切換分頁都會重新取得最新資料,又是一次漫長的等待。經過研究發現,是客戶因為有大量某類型的資料, request 取得這類型大量資料時會花很久時間,花很久時間的原因,我們初步判斷是某雲端資料庫當遇到大量查詢時,會令後進來的 request 排隊等待。由於現階段無法更進一步判斷根本原因,而且因為要替後端工程師們爭取更多時間找出原因,客戶也很希望趕快改進,所以共識是暫時繞過問題,由前端改變取得資料的方式,並請後端提供新的API串接。

畫面所需要的資訊有三種: A, B, C。 A, B, C 是三種不同類型和用途的資料,A, B 在資料結構上是同一層,B 底下有 C,必須要先取得 B 的資料,才能知道 B 底下有哪些 C 類型的資料。在施行第一階段前, A, B, C 三類型的資料是一次取得,後端發現是 B 和 C 造成取得 A 資料時速度緩慢。

第一階段的改進方式

由一個 request 拆成兩個 request 平行查詢,A 類型的資料自己一個 request AA,B、C類型則是另一個 request BB,並且 request BB 使用新的 API,而且需要兩個 request AA 和 BB 的回應內容組合起來,給畫面使用。釐清多達十幾次呼叫取得最新資料函式的情境,簡化呼叫方式並且寫上註解說明,而且修改相關的跨頁面共用函式。改成新方式查詢後,後端回應速度提升很多,從大量資料的客戶從原本平均 12 秒到最差情況 1 分鐘,縮短到 5 ~ 10 秒。

新的查詢方式令程式碼故障,所以更進一步調整程式碼結構,簡化流程邏輯和善用 Angular 和 Ionic 的 lifecycle hook。因為頁面採取 Segment/Tab 的分頁 UX ,寫很多條件判斷的程式碼是依照使用者當前在哪個分頁,而呈現哪些資料,但其實在新的結構上就不必要全部都這樣做,所以重新組織執行流程,也移除已不再用到的程式碼。

用 rxjs 的 BehaviorSubject 和 Observable 自製新的 Singleton 服務,搭配 Angular 的 Dependancy injection ,替換掉原先時好時壞的 Singleton 資料服務。原本時好時壞的 Singleton 服務,也有使用 Angular 的 Dependancy injection ,但沒有使用 BehaviorSubject 和 Observable ,沒有保證能跨頁面/元件同步到最新的資訊,所以原先搭配使用 ionic 的事件訂閱元件。原先方式很難除錯,有夠麻煩。

而且減少每次換頁時的查詢最新資料 request 次數,確保在大多數使用者的常用情境時仍使用正確資料。我自行製作該從前端快取資料層的判斷時機邏輯,例如日期資訊沒有改變時、快取資料層已有相同資料時,……等等,則直接讀取快取層資料並顯示在畫面上,就不向後端發送 request; 若改變日期資訊時,一律清除前端的快取層,並向後端取得最新資料。

第二階段的改進方式

將取得 B, C 的第一階段 request BB,再拆成一個 request 3B 和 多個 request 3C,用分頁方式取得 C 資料,所以會有多個平行發出的 request 3C。因為會有一些等待完成時間,以及組織成完整資料,所以做一些視覺等待效果,讓使用者知道哪些暫時不能操作,哪些已能開始操作。

分頁查詢的概念: request 3B 用途是取得所有 B 類型的資料,得出總共幾筆的 B 類型資料,再依照每頁必須有幾筆 B 類型資料的條件,同時去使用多個 request 3C 查詢,依序取得每一頁裡每一筆 B 類型底下的所有 C 類型資料。 request 3B 回應內容裡的每個 B 都有獨一無二的 key 值,但不含 B 底下的所有 C 類型資料; request 3C 回應內容裡有 B 類型的 key 值和 B 底下的所有 C 類型資料。最後,用 B 類型的 key 值,將 reqeust 3B 和多個 request 3C 的回應內容,組合起來,形成完整的內容。概念範例程式碼可參考stackoverflow: Using RxJS for unknown number of consequtive HTTP Requests

const results$ = makeApiCall(0, 150).pipe(
  switchMap(firstResponse => {
    const pageCount = Math.ceil(firstResponse.maxCount / firstResponse.limit);
    const pageOffsets = Array(pageCount - 1).fill(0).map((_, i) => (i + 1) * firstResponse.limit);

    return concat(
      of(firstResponse),
      from(pageOffsets).pipe(
        mergeMap(offset => makeApiCall(offset, firstResponse.limit), MAX_CONCURRENT_CALLS)
      )
    );
  }),
  scan((acc, cur) => acc.concat(cur.results), [])
);

改成這新的查詢方式後,又有一些功能故障,所以我又修復了受影響的函式,例如需要暫時跳過一些尚未準備好的 C 類型的資料,直到它準備好、也微調寫入前端的資料快取層時機、切換日期後再切換分頁時,新分頁顯示過期的內容。