有過微信小程序開發經驗的朋友應該都知道“雙線程模型”這個概念,本文簡單梳理一下雙線程模型的一些科普知識,學識淺薄,若有錯誤歡迎指正。
我以前就職於「小程序·雲開發」團隊,在對外的一些培訓和技術分享里經常被人問到這樣一個問題:“微信小程序與 Web 網站在技術層面的主要區別是什么?”,在編程語言和范式上,小程序開發與 Web 前端開發非常相似(比如都用 JavaScript 語言、與 HTML/CSS 非常相似的 WXML/WXSS 等),可它卻沒有直接用原生的前端技術。
與 Web 網站相比,以微信為宿主的小程序更需要考慮安全、性能等因素,保障小程序不會對微信App本身產生安全隱患,同時要盡量達到接近原生應用的性能和用戶體驗。這是為什么小程序不直接用瀏覽器的線程模型,非要自己弄一套雙線程模型最主要的兩個原因。
那什么是小程序的雙線程模型呢?
理解一個新概念或技術的最好的方法就是給它一個參照物,所以要搞清楚小程序的線程模型,首先要對瀏覽器的線程模型有一定的了解。
瀏覽器是多進程的
可能每個前端工程師在剛入行的時候都不止一次地被面試官問到“怎么理解前端的單線程?”,因為前端核心技能之一的 JavaScript 語言是單線程的,充分理解並掌握JS單線程的運作方式對一個前端工程師來說是最基本的要求。但是很多初學者容易走入的一個誤區:錯誤地把 “JavaScript 單線程”理解為“瀏覽器單線程”。
事實上,瀏覽器內部架構很復雜,只不過在處理 GUI 渲染線程和 JavaScript 邏輯腳本線程上用了互斥、阻塞的管理模式,讓一些開發者產生了誤解。
以 Chrome 瀏覽器為例,點擊右上角的設置按鈕然后進入“更多工具”->“任務管理器”會看到這樣的彈窗:
能看到Chrome 開啟了多個進程,包括瀏覽器進程、網絡進程、GPU 進程等,這些都是通用的進程。請注意,上圖里有兩個標簽頁進程,Chrome 為每個標簽頁開啟了一個獨立的渲染進程( Renderer Process ),每個進程之間的資源( CPU、內存等)和行為( UI、邏輯等)互不共享,所以即便某個標簽頁崩潰了也不會影響其他標簽頁。
而在每個標簽頁進程中,瀏覽器會把不同的工作交給對應的線程,比如 GUI 渲染線程負責把 HTML 渲染成可視化的 UI;JavaScript 引擎線程負責解析和運行 JavaScript 代碼邏輯;定時觸發器線程負責處理 setTimeout/setInterval 定時器等。
多說一句,這里有一個很容易搞混的地方,其實setTimeout/setInterval 並不是 JavaScript 語言的一部分,而是運行時(最初是瀏覽器,后來 Node.js 也支持)提供的能力。
GUI 渲染線程和 JavaScript 引擎線程是互斥的,JavaScript 在執行期間會阻塞 UI 的渲染,甚至如果腳本執行時間太長會由於頁面長時間無響應然后崩潰,正是 GUI 渲染線程和 JavaScript 引擎線程之間的這種互斥、阻塞的線程管理方式,讓一部分前端開發者以為瀏覽器是單線程的。
那為什么 JavaScript 被設計成單線程的呢?
JavaScript 祖師爺只用了 10 天就創造了這門語言,最初他的想法只是在瀏覽器中提供一些簡單的腳本邏輯用來處理用戶交互、DOM 操作等,所以從設計上必須遵循兩點:
-
語法簡單;
-
運行機制簡單。
在語法上,JavaScript 借鑒了 Java,但是去除了很多復雜的設定,比如類型聲明、模塊體系(后來加入)等。
在運行機制上,JavaScript 並沒有像 Java 那樣提供多線程能力,最主要就是為了避免多線程操作 DOM 造成 UI 沖突。比如存在多個線程同時操作同一個 DOM,瀏覽器該如何判斷最終的 UI 效果是采用哪個線程的結果?這是經典的線程安全(也稱為線程同步)問題,在多線程編程領域有很多解決方案,比如加入鎖機制,但這樣卻又帶來了更多的復雜性,與 JavaScript 簡單易用的設計初衷相違背。
這同時也解釋了為什么 GUI 渲染線程與 JavaScript 引擎線程是互斥的:JavaScript 代碼有修改DOM 的權限。
當 JavaScript 代碼被執行時,GUI 渲染線程會被掛起,等待 JavaScript 引擎線程空閑時再被執行,以免在渲染期間被 JavaScript 重復地修改 DOM 造成不必要的渲染壓力。采用互斥的模式等待 JavaScript 代碼執行完畢后,可以保證渲染是最終的執行結果。所以瀏覽器的空閑(Idle)時長也成了衡量網站性能的重要指標之一,空閑時長多代表 JavaScript 邏輯不密集以及 DOM改動頻率低,這種情況下瀏覽器可以更快速順暢地響應用戶的交互行為,如下圖:
React Fiber就是利用idle時間進行分片任務處理。
后來,HTML5 引入了 Web Worker,提供多線程執行 JavaScript 代碼的能力,但是與其他編程語言不同的是,Worker 線程與主線程並不是平行的,而是一種主從( Master-Slave)多線程模型。
Worker 內的 JavaScript 代碼不能操作 DOM,可以將其理解為線程安全的。要記住這一點,這是后面講小程序雙線程模型一個重要的基礎。
那么為什么微信小程序不直接使用瀏覽器的線程模型呢?這需要從產品和技術兩個角度對比小程序與 Web 網站的差異。
為什么小程序不使用瀏覽器的線程模型
我剛接觸小程序開發時,經常“嫌棄”它跟 Web 相比閹割弱化的能力、跟 Vue 相比簡單到過分的語法等。當時,我幾乎覺得小程序就是微信仗着自己龐大的用戶量搞技術壟斷。
但是,隨着對技術和產品的不斷深入理解,我對小程序的態度也有了轉變,由“嫌棄”變成了敬佩,因為在充分理解了小程序的產品定位后,我發現雙線程模型是在小程序這類產品場景下的最優解。那小程序是一款什么樣的產品呢?
小程序的宿主是微信,但是小程序版本的迭代是獨立的,升級更新不依賴宿主,這一點跟 Web 網站是相同的。也就是說,小程序沿襲了 Web 的某些優勢,但它並不是 Web,目前 Web 相關的技術已經相當全面,能夠承載一些非常龐大的應用程序,比如 3D 地圖、游戲等。
而小程序的定位是小而美、用完就走,不追求在微信中實現全部的 Web 能力,所以和 Web 來比能力上肯定差一些,同時具備一些微信提供的原生能力,比如原生組件、系統級別和微信生態的 API 等等。
另外,“小程序-微信”的關系跟“網站-瀏覽器”的關系不同,前者更接近 CodePen、JSFiddler 這類在線編程平台(課里簡稱平台)中每個程序案例(簡稱案例)與平台的關系。
從技術的角度上,平台最核心的一個考量點是為案例提供足夠能力的前提下,保證案例的邏輯不會危及平台的安全。想象一下,假如你能夠在 CodePen 上編寫一個程序來獲取 CodePen 的私密信息,可能第二天 CodePen 就崩潰然后炒掉所有員工。
在這樣的產品基調下進行技術選型,接下來就是架構師和程序員的工作了。
還是以 CodePen 為例,假如讓你來設計這樣的編程平台,你會用什么技術呢?可能你第一個想到的是用 iframe,因為可以在 iframe 內使用全部 Web 能力。事實上 CodePen 確實用 iframe 來呈現程序的效果,但是並不會把輸入的 JavaScript 代碼完全拷貝到 iframe 內運行,而是代碼會經過一次編譯流程之后才會被注入 iframe 內。這樣做的出發點主要是基於安全的考慮,在編譯過程中將一些危險的代碼剔除;其次這樣做還能在平台中支持更多語言,比如typescript。當然,還有性能,性能問題是 iframe 老生常談的問題了,我就不多說了。
所以,不僅要使用 iframe,還需要引入額外的 JavaScript 編譯器。CodePen 一定要保證每個案例的 JavaScript 代碼是線程安全的,最基本的就是要禁止程序操作CodePen 網站的 DOM ,實現這一點有兩個方法:
-
一個是 Web Worker;
-
另一個是使用 Shadow DOM。
Web Worker 是線程安全的,Worker 內的 JavaScript 代碼無法獲取 Window 和 Document 對象,也就無法操作 DOM。除此之外,由於 Worker 的線程安全特性,Worker 內的代碼運行過程中不會阻塞外層的 GUI 渲染線程,兩者可以並行。
Shadow DOM 是 Web Components 規范的一部分,將 ShadowRoot 的模式設置為 closed
就可以禁止獲取到 ShadowRoot 節點,從而也無法操作其內部的 DOM。
兩者相比,Shadow DOM 的兼容性比 Web Worker 更差,距大規模使用的日期還很遙遠,所以 Web Worker 的方案更現實一點。
這樣就形成了一個簡易的雙線程模型:Worker 線程負責計算,將結果通過 postMessage 傳遞給主線程,主線程負責渲染。
但是這個模型存在比較嚴重的性能問題,Web Worker 非常耗費資源,除去計算消耗以外,與主線程的通信過程對性能的損耗也非常嚴重。
那有沒有辦法實現跟 Web Worker 一樣的線程安全,同時又兼顧性能保證良好的用戶體驗呢?這便是微信小程序采用雙線程模型的主要目的。
安全高效的雙線程模型
雖然前面用了 CodePen 這類編程平台做類比,但小程序與 CodePen 的技術需求並不完全相同,主要區別在於小程序並不需要支持所有的 HTML 標簽,只提供有限的幾類 UI 組件,根據小程序產品定位,我們可以歸納出小程序的主要技術需求可以歸納為下面這樣幾點。(任何新技術或架構都是為了解決特定的問題,所以有必要了解小程序的主要技術需求。)
-
限制 UI 組件類型,只允許聲明指定的幾個組件
小程序在聲明組件時並不是使用原生的 HTML 標簽,而是只能夠通過微信提供的幾種內置基礎組件,當然你也可以自定義組件,但也是通過對內置基礎組件的組合來實現。
-
保證邏輯線程安全,不允許直接操作 UI 組件
小程序更新 UI 的方式與 Vue/React 等 MVVM 框架類似,JavaScript 代碼不能直接操作 DOM(僅做類比,事實上小程序中沒有DOM的概念),而是通過更新狀態( setState )的方式異步更新 UI ,這個過程中會用到 VDOM 和高效的 diff 算法(這兩點並不是我們要討論的內容,你課下可以自己搜索相關資料)。
-
能夠在線更新,不依賴微信
小程序的宿主是微信,如果使用純 Native 實現,那么小程序的版本更新必須依賴微信,跟微信的代碼一起發版,這樣肯定是不行的。如果是純 Web 實現,安全和性能就很難得到保障。
小程序需要既能夠像 Web 一樣將資源托管在雲端,更新獨立;同時又能夠保證足夠好的安全性和性能。所以最終小程序采用了一種混合的架構模式:使用 Webview 渲染 UI、使用類似Web Worker 的獨立線程運行邏輯,這就是雙線程模型。
-
性能需盡量提升,保證用戶體驗
前面提到的基於 Web Worker 的簡易雙線程模型性能是很大的問題,小程序的雙線程模型並不是使用 Web Worker 子線程,而是一個獨立的“主線程”,這樣能夠保證相對較好的性能。
渲染線程和邏輯線程
小程序的雙線程指的就是渲染線程和邏輯線程,這兩個線程分別承擔UI的渲染和執行 JavaScript 代碼的工作。如下圖所示:
渲染線程使用 Webview 進行 UI 的渲染呈現。Webview 是一個完整的類瀏覽器運行環境,本身具備運行 JavaScript 的能力,但是小程序並不是將邏輯腳本放到 Webview 中運行,而是將邏輯層獨立為一個與 Webview 平行的線程,使用客戶端提供的 JavaScript 引擎運行代碼,iOS 的JavaScriptCore、安卓是騰訊 X5 內核提供的 JsCore 環境以及 IDE 工具的 nwjs 。
邏輯線程是一個只能夠運行 JavaScript 的沙箱環境,不提供 DOM 操作相關的 API,所以不能直接操作 UI,只能夠通過 setData 更新數據的方式異步更新 UI。
事件驅動的通信方式
注意上圖渲染線程和邏輯線程之間的通信方式,與 Vue/React 不同的是,小程序的渲染層與邏輯層之間的通信並不是在兩者之間直接傳遞數據或事件,而是由 Native 作為中間媒介進行轉發。
整個過程是典型的事件驅動模式:
-
渲染層(也可以稱為視圖層)通過與用戶的交互觸發特定的事件 event;
-
然后 event 被傳遞給邏輯層;
-
邏輯層繼而通過一系列的邏輯處理、數據請求、接口調用等行為將加工好的數據 data 傳遞給渲染層;
-
最后渲染層將 data 渲染為可視化的 UI。
這種數據驅動 UI 的模式是現在前端編程領域較為推崇的編程范式,如果你是一個超過 5 年開發經驗的前端開發者的話,那么我相信在最初接觸到這種模式的時候肯定有一些不適應,因為在此之前 JavaScript 操作 DOM 幾乎是一種“業內規則”,甚至有不少針對前端入門的圖書、博客和教材都是先從 DOM 操作講起,現在看來這些確實有些不合時宜了。
而這樣邏輯與渲染分離的線程分工模式一方面能夠保證運行在邏輯線程沙箱內的 JavaScript 代碼是線程安全的,另一方面由於渲染線程的計算量非常小從而保證了對用戶交互行為的快速響應,提高了用戶體驗。
總的來說,跟瀏覽器的線程模型相比,小程序的雙線程模型解決了或者說規避了 Web Worker 堪憂的性能同時又實現了與 Web Worker 相同的線程安全,從性能和安全兩個角度實現了提升。可以概括地說,雙線程模式是受限於瀏覽器現有的進程和線程管理模式之下,在小程序這一具體場景之內的一種改進的架構方案。
總結
在我看來,程序員的核心能力和競爭力並不是充分了解某種語言或框架的 API ,而是這些語言和框架底層的原理知識。對一個小程序的開發者來說,在工作中遇到技術難題時的解決方案往往是基於底層原理的(甚至更直白一點,當你找工作面試時,沒人會問你小程序的語法)。
通過了解小程序雙線程模型的背景、設計、通信,希望能夠讓大家更深入地理解小程序的底層架構,如果在后續工作中有類似場景的需求也可以作為借鑒。當然,了解小程序的雙線程模型並不是唯一的目標,這些知識在一定程度上能對日常開發工作產生一些啟示,主要是性能方面:
-
在保證功能的前提下盡量使用結構簡單的 UI;
-
盡量降低 JavaScript 邏輯的復雜度;
-
盡量減少 setData 的調用頻次和攜帶的數據體量。