作者|陳天忱
編輯|賈亞寧
本文由 InfoQ 整理自騰訊 CSIG 在線教育部前端高級開發工程師陳天忱在 GMTC 全球大前端技術大會(深圳站)2021 的分享《騰訊課堂小程序開發實踐》。
你好,我是陳天忱,來自騰訊 CSIG 在線教育部。我所在的團隊主要負責騰訊課堂平臺的開發和維護,我從加入團隊以來就圍繞著小程序做了很多探索和優化,目前也是騰訊課堂小程序的負責人。
我本次分享的內容分為五個部分,首先我們從整體的角度來看一下騰訊課堂小程序的技術演進過程,接著會分別從開發體驗、性能優化以及監控體系三個角度分享一些實踐和經驗,最后進行一下總結。
騰訊課堂小程序的技術演進路線
在我剛進入團隊的時候,騰訊課堂小程序的工具鏈還處在比較原始的階段。除了在編碼層面利用了 web 比較成熟的 scss、postcss、lint、typescript 結合 gulp 做一些語法層面的編譯以外,在測試、構建 npm、上傳、設置體驗版、發布等階段都是依賴的小程序開發者工具和管理后臺,人工手動操作來完成的。
石器時代
在這個階段,大部分都是簡單地利用一些現有的工具,我們稱之為騰訊課堂小程序的石器時代。
這個階段存在幾個明顯的問題:
構建和上傳依賴人工操作,有可能會因為流程操作失誤而導致現網事故;
由于發布流程的不規范,需求并行時經常會出現發布撞車的情況,導致體驗版相互覆蓋造成預發布驗證成本。
為了解決人工操作帶來的隱患,我們從零開始基于小程序提供的命令行工具打造了小程序 CI。讓源碼編譯、構建 npm、上傳、生成開發版 / 體驗版二維碼、自動化測試等流程在 CI 流水中自動流轉。
同時我們也將小程序 CI 與企業微信打通,將小程序的構建進度和小程序二維碼實時同步過去,而且也支持通過企業微信主動觸發小程序構建,解決了在測試過程中因為開發版二維碼過期而中斷測試的問題。
為了解決發布流程不規范的問題,我們將小程序的發布也接入到了業務發布平臺。在發布平臺上進行 CheckList、CodeReview、發布評審、發布環境管理、發布靜態資源等流程的流轉,確保需求發布的質量、合規和有序。
工業時代
解決了開發流程中的問題之后,我們將更多的精力放到了小程序的研發效能與性能上。開發和構建階段我們打造跨端的公共模塊,通過 kbone 進行同構開發,利用云開發來輔助首屏性能優化,以及代替部分后臺的開發,在構建方面將構建工具從 gulp 遷移到 webpack,能對構建常務進行更細致的優化。
在發布之后,通過完善監控告警,將發布質量做到可視化的體現,并能夠對出現的問題得到及時的接收和感知,減少用戶的反饋。
近現代
到這里我們可以看到整個技術演進的過程,它涵蓋了小程序開發、構建、測試、部署發布以及監控,形成了小程序的 DevOps 開發模式,這其中具體是怎么做的呢?
DevOps
構造爽的開發體驗
首先是開發階段,我們想要形成一個可以稱之為爽的開發體驗,并不僅僅是指 coding 階段,還需要覆蓋到測試以及發布階段:
編碼階段——業務邏輯跨端可復用
測試階段——改動持續集成、測試與開發解藕
發布階段——規范化、流程化、可追溯
為此我們分別通過打造跨端可復用的公共模塊、小程序 CI、統一業務發布平臺來提升開發體驗。
提升開發體驗
跨端公共模塊
這里從一個實際場景出發,我們之前有這樣一個需求:產品希望在各端的課程詳情頁有一個提示當前此機構正在直播的課程,可以引導用戶跳轉到直播間聽老師講解課程的細節。
公共模塊
梳理一下這個需求的流程,發現其實還是挺簡單的:
詳情頁渲染完成后 -> 調用接口拉取直播間數據 -> 渲染引導模塊 -> 用戶點擊跳轉直播
可以看到,業務的主邏輯在各端都是一樣的,但如果去看這些邏輯的細節就會發現其實各端需要的是不一樣的實現,比如發起請求的 api 在瀏覽器和在小程序中是不一樣的;提示的疲勞度控制需要用到本地的緩存能力,瀏覽器和小程序的 api 也是不一樣的;然后在業務上,直播間在三端的頁面地址也是不一樣的。
各端邏輯的不同
通過 ifelse 或者 switch 的方式,在運行時判斷當前的執行環境,然后調用不同的分支邏輯當然是能夠實現需求的,但是這種方式會讓一個端同時存在三端的邏輯,這樣的邏輯多了之后,會造成比較明顯的代碼冗余,而在小程序端由于有 2M 的包大小限制,對于代碼冗余是比較敏感的。
要解決代碼冗余的問題,大家會很自然地想到構建時注入一個環境變量,通過 tree-shaking 的能力在不同端構建出對應端所需要用到的邏輯。但這個方案對于構建工具有著一定的要求,而在實際的工作場景中,新老項目往往由于歷史的原因,不僅僅是源碼,在構建上的技術棧也是有很多歷史包袱的,比如 gulp、fis、webpack 等,如果要全部統一起來成本和風險都會比較大。
所以我們需要一個能夠跨端復用,按需打包,而且不依賴項目構建體系的公共模塊。
我們基于 git submodule 的方式從組件、業務、工具三個維出發,每個維度根據具體的邏輯按照執行環境將其拆分成同構目錄 (isomorph)、瀏覽器目錄 (lib)、小程序目錄 (wx),各個項目將 lib 目錄或者 wx 目錄作為引用的入口,而入口文件會繼承或者透傳導出 isomorph 目錄下的邏輯,對環境有依賴的特殊邏輯則在 lib 目錄和 wx 目錄下分別實現。
在開發階段通過路徑別名來統一引用路徑,例如小程序的項目中設置tsconfig.json的paths為"ke-modules/*": "submodules/ke-modules/*/wx",這樣就可以統一業務層面的代碼邏輯。在構建階段submodule會通過ts單獨編譯成js,如果是h5和PC的項目就只會將isomorph和lib目錄構建到產物中,小程序的項目就只將isomorph和wx目錄構建到產物中。
公共模塊的編譯階段
這樣就確保了在保證兼容性的基礎上不會產生冗余的代碼,以此來滿足我們之前提出的幾個需求。
小程序 CI/CD 建設
我們搭建小程序的 CI/CD 的起因,是由于開發者工具中很多人工操作帶來的一系列問題,比如:
在構建過程中,很容易漏掉構建 npm 依賴
在上傳時的版本信息和版本號也不規范
不同需求的體驗版需要管理后臺切換,需求并行非常不友好
開發版二維碼需要開發者實時提供,影響測試進度要解決以上這些問題就需要從自動化和流程控制來入手。
我們先是基于小程序官方提供的一個命令行工具進行了封裝和擴展,支持小程序的npm 構建、上傳、獲取二維碼、自動獲取版本號、版本信息等功能,并作為小程序 CI 流水線中的核心插件。
CI 流水線支持通過 git hook、OpenAPI、手動的方式觸發執行。在流水線的流轉執行中,完成代碼拉取、分支檢查、版本號迭代及版本信息更新、小程序代碼包上傳、開發 / 體驗版二維碼獲取,同時歸檔小程序產物、sourcemap 等文件便于對性能和錯誤的分析。
同時通過 CI 的插件與企業微信機器人打通,將構建進度以及構建后的小程序二維碼同步到企業微信中,同時也支持通過 @企業微信機器人以 openAPI 的形式主動觸發流水線執行,讓產品和測試都可以實時獲取最新的小程序二維碼進行測試和體驗。
在發布階段,與 web 項目一樣接入統一的業務發布平臺,在發布平臺上對發布流程進行規范,確保發布之前的CheckList、CodeReview、發布評審等流程正確執行。發布開始對發布環境進行管理,設置門禁,確保同一時間只會有一個需求處在發布流程中,避免多需求并行出現的發布混亂問題,發布完成并現網觀察沒有問題之后再將環境釋放給下一個需求。
小程序 CI/CD 的流程建設
建設了這樣一套 CI/CD 的流程之后,之前遇到的問題就都得到了解決。
通過在構建過程中獲取依賴的 npm 信息來判斷是否需要更新及構建 npm,并自動執行;
上傳時根據 Angular 的 git commit 規范,自動迭代 major、minor、patch 的版本號,更新 changelog;
CI 使用機器人賬號上傳小程序,通過業務發布平臺對小程序的發布環境進行管理,避免發布沖突;
提供企業微信觸發小程序構建的能力,測試和產品可實時出發構建并獲取最新二維碼。
這是我們在 CI/CD 上面的一些實踐經驗,以及在開發體驗上面的一些處理方案。
小程序性能優化
小程序的啟動方式分為冷啟動和熱啟動,而小程序的性能瓶頸大部分也都集中在冷啟動這一階段。
小程序的冷啟動
冷啟動階段分為如上幾個步驟,其中環境初始化對于開發者來說是個黑盒,目前還無法介入,而下載代碼包和加載代碼包的耗時,主要與小程序代碼包的體積正相關,數據拉取需要開發者對請求時機進行優化,頁面渲染則需要優化渲染策略。
體積優化
業務代碼的體積優化需要通過構建來解決,以一個項目的常規結構來看,我們一般會將一些有可能復用的模塊放置到公共模塊中。如下圖所示,引用關系如果只進行編譯的話,根據小程序的規則,公共模塊和組件的大小都會被計算到主包中,我們希望通過構建來優化產物結構,避免主包太大的問題。
主包太大
再者,隨著需求的迭代,可能某一個組件的引用就丟失了,這種情況在小程序的規則下,依然會被計算在主包里面,可以看下面這張圖。我們希望能通過構建將未使用的模塊或者組件進行過濾。
未使用模塊未過濾
另外,如果某一個分包與主包引用了同一個模塊,這時候將這個模塊計算到主包中是 OK 的,但如果這個分包是一個獨立分包的情況下,再去引用主包的模塊,是有可能報錯的。上面這種情況需要通過構建的方式將模塊復制一份放到獨立分包下面才能保證小程序的正確執行。
獨立分包引用報錯
我們面對的上面三個問題有一個核心思路是需要在構建的過程中,針對小程序的規則進行依賴分析。下面是我們對比目前市面上比較成熟的構建工具,從四個維度進行了分析:
支持組件的依賴分析
根據對比的結果,webpack 對于需求的支持還是比較成熟的,我們最終決定選擇 webpack 作為小程序的構建工具,但是 webpack 也不支持小程序的組件,這一點就需要我們自己進行支持了。
以 app.js 作為入口文件,根據小程序的配置規則找到對應的 json 文件,逐層遞歸就可以將整個小程序所使用到的頁面和組件分析出來,并將所有的頁面和組件都作為 webpack 的 entry,就可以獲取到小程序中 js 模塊的引用信息了。
通過 plugin 對引用信息根據一定策略進行計算 chunk:
分包處理
例如,某一個頁面引用了一個模塊,先判斷模塊是否在分包內,如果在分包內則按照常規方案打包;不在分包內則判斷引用它的分包是否為獨立分包。如果是獨立分包則復制一份 (新建一個 chunk);是普通分包則收集是否被多個分包引用。若不是,則將模塊移動到分包下 (新建一個 chunk,并將原來的刪除)。
計算完 chunk 之后就可以通過 webpack 的 load 去處理另外的資源文件,包括 css、image、font,提取靜態資源文件,替換引用路徑。
處理資源文件
處理完成后的效果也相當明顯,我們的主包從 1900 多 kb 優化到了 900 多 kb,優化幅度達到 50%,總包的一些體積也優化了 27%。
體積優化效果
優化的體積主要來自以下三個方面:
模塊下沉到了分包
對未使用到的組件和模塊進行了過濾
靜態資源文件上到 CDN
我們的優化在實際的啟動耗時上也有比較顯著的效果,主包下載耗時優化了 43%,js 的注入耗時優化了 18%。
啟動耗時優化效果
兼容小程序的 SDK 方案
另一方面,當小程序需要通過 npm 的形式使用一個比較復雜的 SDK 時,由于小程序的 npm 包需要單獨構建一次,無法做到編譯時按需打包,這也會遇到體積較大的問題。
在我們的實際業務場景中就有這樣的問題,騰訊課堂作為在線教育業務,有個核心能力是直播互動,就是用戶在線上上課的過程中聊天、舉手、連麥、抽獎等形式的交互行為。
為了讓這個核心能力能夠達到跨端跨業務的復用效果,我們團隊開發了一個直播互動的 SDK,對外拋出簡單的 API,內部設計了接口層、適配層、通道層、策略層,結構非常清晰,使用起來也很方便,初始化后開發只需要監聽或請求對應的命令字即可,無需關心內部的轉化,并且能夠利用 ts 的類型推斷能力直接拿到通道返回的數據類型。
改造前架構及使用方式
但是當我們在小程序端進行接入時,遇到了幾個問題:
為了支持跨端跨業務,SDK 內置了所有功能的邏輯,在小程序端使用會造成大量的包體積浪費
針對 web 設計,不兼容小程序;單獨維護一個小程序的版本成本比較大
不同項目和業務對 SDK 的迭代會導致版本管理混亂
要解決上面這些問題,我們必須對 SDK 進行升級改造。
我們改造的方案是進行插件化處理,將接入層(業務層)和適配層作為 SDK 的內核抽離出來,并添加了 pluginAdaptor 對插件進行適配管理,將策略層和通道連接層的邏輯進行抽象處理,制訂好對應的規范,根據抽象類和業務需求實現對應的策略插件和通道插件。
插件化改造
在業務中的改造非常簡單,只需要初始化之前注冊當前場景和功能所需要的插件,后續在使用上與之前完全一致,業務的改造成本非常低。
改造后使用方式
兼容性上通過 rollup 打包,在構建時注入不同的環境變量,輸出對應端所需要用到的 bundle。
rollup 打包
插件化改造之后,好處就顯而易見了:
按需引入,運行時它的體積是最小的,改造前后 SDK 運行時體積從 384KB 減少到 42KB,優化了近 90%
多包結構迭代比較清晰,內核和抽象通道也很穩定,各個插件可以進行單獨的版本迭代
跨端復用能力得到了擴展,統一維護
運行時的請求優化
請求的優化也是小程序性能優化中很重要的一環,在冷啟動和頁面跳轉的過程中,我們分別對請求時機以及弱網阻塞兩種情況進行了優化。
請求時機上,可以利用小程序的全局 app 實例將數據請求的時機提前到頁面加載之前,進一步利用小程序的數據預加載能力,將首屏數據的請求時機提前到啟動小程序時:
請求優化
頁面加載前發起請求的流程如下,在 onLaunch 或者頁面跳轉時就直接發起下一個頁面的請求,并將請求的 Promise 掛載在 app 實例上,當頁面加載完成出發 onLoad 的時候則直接通過 app 上的 Promise 返回進行渲染,根據我們的統計平均可以優化 100ms的耗時,而且相對靜態的數據可以通過本地緩存的方式,在二次加載此頁面時通過緩存數據渲染,達到秒開的效果。
在頁面加載前發起請求的流程
而數據預拉取則類似于 web 的服務端渲染,在啟動小程序時通過云函數根據啟動參數調用業務后臺的服務獲取數據并返回給小程序,小程序啟動后就可以直接使用預拉取的數據進行渲染,預拉取成功可以平均優化 90% 的首屏數據請求耗時。
數據預拉取流程
除了上面常規情況的請求優化,我們還注意到小程序有一個網絡使用限制,最大的并發限制是 10 個,這就會造成隱患。因為在小程序加載和用戶交互的過程中會產生很多的上報請求,例如 PV 上報、錯誤日志等,在弱網的情況下,很容易出現上報請求響應慢而阻塞了業務請求的發送導致超時,而我們也確實收到了類似情況的反饋。
為了優化弱網情況下存在的隱患,我們對請求隊列進行了優化,通過設置請求池與等待隊列,并劫持 wx.request,在發送請求時對請求的 url 進行優先級排序,將業務請求設置為高優先級的請求,上報請求的優先級降低。當請求通道相對緊張時會將高優先級的請求優先發送,低優先級的請求在請求通道空閑時再進行補發。
弱網下的請求排隊
在實際運行過程中的邏輯如下圖:
弱網下的請求排隊運行邏輯
渲染優化
視圖渲染和更新可以優化的方向在于,將非首屏和非核心模塊數據延后更新,因為setData 更新視圖數據太大會增加通信和解析的時間。以我們最復雜的課程詳情頁為例,因為模塊比較多,導致頁面比較長,一般會有 5~7 屏的內容。如果需要等到頁面的所有數據全部出完再開始更新試圖,那么白屏時間和視圖更新時間都會比較長,比較合理的渲染策略應該是首屏優先,分步渲染。
首屏優先,分步渲染
但由于小程序的雙線程模式,通過 setData 的方式更新視圖是同步更新邏輯層數據,異步更新視圖層數據,所以并不能簡單地在處理完一部分數據后調用 setData 再繼續處理其余的數據,甚至通過 Promise 也做不到分步渲染,而使用 setData 的回調或者 setTimeout 的方式又會出現邏輯嵌套的問題,降低代碼的可讀性和可維護性。
針對這個問題,我們的解決方案是基于 setTimeout 根據 Promise 的表示封裝了一個 PromiseMacro 的類,這樣我們就可以向使用 Promise 一樣通過 then 方法將小程序的渲染拆分成多個步驟達到漸進式渲染的效果。
漸進式渲染
通過分步渲染的方式,可以將我們首屏渲染的起始時間從 230ms 提前到 90ms,達到減少用戶等待焦慮,提升用戶體驗的效果。
質量保證的監控體系
一個產品的質量不僅僅是靠好的產品設計和代碼質量,還有很大一部分需要通過收集操作和性能日志,為技術優化提供方案,而對現網報錯的比例及數量進行監控,能讓我們及時響應并修復。之前我們的小程序上報也依賴了好幾個上報系統來完成:
通過 BadJS 來收集前端報錯和操作日志
通過 Wang 進行測速上報
通過 Monitor 進行打點監控告警
通過 Tdw 進行產品需求上報
通過不同的系統進行上報會存在一些問題: