雙執行緒架構
在這之前,我們先來思考一個問題,小程式在架構上為什麼會選擇雙執行緒?
為什麼是雙執行緒?
載入及渲染效能
小程式的設計之初就是要求快速,這裡的快指的是載入以及渲染。
目前主流的渲染方式有以下3種:
- Web技術渲染
- Native技術渲染
- Hybrid技術渲染(同時使用了webview和原生來渲染)
從小程式的定位來講,它就不可能用純原生技術來進行開發,因為那樣它的編譯以及發版都得跟隨微信,所以需要像Web技術那樣,有一份隨時可更新的資源包放在遠端,透過下載到本地,動態執行後即可渲染出介面。
但如果用純web
技術來開發的話,會有一個很致命的缺點那就是在 Web 技術中,UI渲染跟 JavaScript 的指令碼執行都在一個單執行緒中執行,這就容易導致一些邏輯任務搶佔UI渲染的資源,這也就跟設計之初要求的快相違背了。
因此微信小程式選擇了Hybrid 技術,介面主要由成熟的 Web 技術渲染,輔之以大量的介面提供豐富的客戶端原生能力。同時,每個小程式頁面都是用不同的WebView去渲染,這樣可以提供更好的互動體驗,更貼近原生體驗,也避免了單個WebView的任務過於繁重。
微信小程式是以webview渲染為主,原生渲染為輔的混合渲染方式
管控安全
由於web技術的靈活開放特點,如果基於純web技術來渲染小程式的話,勢必會存在一些不可控因素和安全風險。
為了解決安全管控的問題,小程式從設計上就阻止了開發者去使用一些瀏覽器提供的開放性api,比如說跳轉頁面、操作DOM等等。如果把這些東西一個一個地去加入到黑名單,那麼勢必會陷入一個非常糟糕的迴圈,因為瀏覽器的介面也非常豐富,那麼就很容易遺漏一些危險的介面,而且就算是禁用掉了所有的介面,也防不住瀏覽器核心的下次更新。
所以要徹底解決這個問題,必須提供一個沙箱環境來執行開發者的JavaScript
程式碼。這個沙箱環境只提供純 JavaScript 的解釋執行環境,沒有任何瀏覽器相關介面。那麼像HTML5
中的ServiceWorker
、WebWorker
特性就符合這樣的條件,這兩者都是啟用另一執行緒來執行 javaScript
。
這就是小程式雙執行緒模型的由來:
- 渲染層: 介面渲染相關的任務全都在 WebView 執行緒裡執行,透過邏輯層程式碼去控制渲染哪些介面。一個小程式存在多個介面,所以渲染層存在多個 WebView。
- 邏輯層: 建立一個單獨的執行緒去執行 JavaScript,在這個環境下執行的都是有關小程式業務邏輯的程式碼。
雙執行緒模型
小程式的架構模型有別與傳統web單執行緒架構,小程式為雙執行緒架構。
微信小程式的渲染層與邏輯層分別由兩個執行緒管理,渲染層的介面使用 webview
進行渲染;邏輯層採用 JSCore
執行JavaScript
程式碼。
webview渲染執行緒
如何找到渲染層?
- 我們可以透過除錯微信開發者工具:
微信開發者工具 ->除錯 ->除錯微信開發者工具
- 然後我們會再看到一個除錯介面,看起來跟我們平時用的瀏覽器除錯介面幾乎一摸一樣
但這並不是小程式的渲染層,而是開發者工具的結構。但我們在裡面可以發現有一些webview
標籤,在第一個webview
上的src屬性看著是不是有點眼熟,沒猜錯的話它就是我們當前小程式開啟頁面的路徑。所以這個webview
才是小程式真正的渲染層。這裡你會發現它裡面並不只有一個webview
,其實裡面包含著檢視層的webview
,業務邏輯層webview
,開發者工具的webview
開發者工具的邏輯層跑在webview
中主要是為了模擬真機上的雙執行緒
- 開啟渲染層一探究竟
透過showdevTools
方法來開啟除錯此webview介面的偵錯程式
document.querySelectorAll('webview')[0].showDevTools(true)
這裡我們看到的才真正是小程式的渲染層,也就是小程式程式碼編譯後的樣子,我們會發現這裡的標籤都與我們開發時寫的不一樣,都統一加了wx-
字首。瞭解過webComponent
的同學相信一眼就能看出他們非常相似,但小程式並沒有直接使用webComponent
,而是自行搭建了一套元件系統Exparser
。
Exparser
的元件模型與WebComponents
標準中的Shadow DOM
高度相似。Exparser
會維護整個頁面的節點樹相關資訊,包括節點的屬性、事件繫結等,相當於一個簡化版的Shadow DOM
實現。
為什麼不直接使用webComponent
,而是選擇自行搭建一套元件系統?
<details>
<summary>點選檢視</summary>
- 管控與安全:web技術可以透過指令碼獲取修改頁面敏感內容或者隨意跳轉其它頁面<br/>
- 能力有限:會限制小程式的表現形式<br/>
- 標籤眾多:增加理解成本 </details>
JSCore邏輯執行緒
邏輯層我們直接在小程式開發者工具的偵錯程式中輸入document
就能看到
小程式將所有業務程式碼置於同一個執行緒中執行,在小程式開發者工具中邏輯執行緒同樣是跑在一個webview中;webview中的appservice.html除了引入業務程式碼js之外,還有後臺服務內嵌的一些基礎功能程式碼。
編譯原理
瞭解完小程式的雙執行緒架構,我們再來看一下小程式的程式碼是如何編譯執行的,微信開者工具模擬器執行的程式碼是經過本地預處理、本地編譯,而微信客戶端執行的程式碼是額外經過伺服器編譯的。這裡我們還是以微信開發者工具為例來探索一番。
在開發者工具輸入openVendor()
,會幫我們開啟微信開發者工具的WeappVendor
資料夾
在這裡我們我們會看到一些wxvpkg
檔案,這是小程式的各個版本的基礎庫檔案,還有兩個值得我們注意的檔案:wcc
、wcsc
,這兩個檔案是小程式的編譯器,分別用來編譯wxml
和wxss
檔案。
編譯wxml
這裡我們可以將開發者工具中的wcc
編譯器複製一份出來,嘗試去用它編譯一下wxml
檔案,看看最後的產物是什麼?
我們在終端執行一下以下命令
./wcc -b index.wxml >> wxml_output.js
然後它會在當前目錄下生成一個wxml_output.js
檔案,檔案中有一個非常重要的方法$gwx
,該方法會返回一個函式。該函式的具體作用我們可以嘗試執行一下看看結果。
我們開啟渲染層webview
搜尋一下該方法(為了方便檢視,這裡會用個小專案來演示)
從這裡我們可以看到該方法會傳入一個小程式頁面的路徑,返回的依然是一個函式
var decodeName = decodeURI("./index/index.wxml")
var generateFunc = $gwx(decodeName)
我們嘗試按這裡流程執行一下$gwx
返回的函式,看看返回的內容是什麼?
<!--compiler-test/index.wxml-->
<view class="qd_container" >
<text name="title">wxml編譯</text>
<view >{{ name }}</view>
</view>
const func = $gwx(decodeURI('index.wxml'))
console.log(func())
沒錯,這個函式正是用來生成Virtual DOM
思考:為什麼$gwx
不直接生成Virtual DOM
?
<details>
<summary>點選檢視</summary>
- 雙執行緒,需要動態注入資料
</details>
編譯wxss
我們同樣可以用微信開發者工具中的wcsc
來編譯一下wxss
檔案。
(大家認為這裡應該是會生成css
檔案還是js
檔案呢?)
我們在終端執行一下以下命令來編譯wxss檔案
./wcsc -js index.wxss >> wxss_output.js
相比之前的wcc
編譯wxml
檔案來說,這次的編譯相對來說比較簡單,它主要完成了以下內容:
- rpx單位的換算,轉換成px
- 提供
setCssToHead
方法將轉換好的css新增到head中
rpx動態適配
小程式提供rpx
單位來適配各種尺寸的裝置
比如:
/*index.wxss */
.qd_container {
width: 100rpx;
background: skyblue;
border: 1rpx solid salmon;
}
.qd_reader {
font-size: 20rpx;
color: #191919;
font-weight: 400;
}
經過編譯之後會生成setCssToHead
方法並執行
setCssToHead([".",[1],"qd_container { width: ",[0,100],"; background: skyblue; border: ",[0,1]," solid salmon; }\n.",[1],"qd_reader { font-size: ",[0,20],"; color: #191919; font-weight: 400; }\n",])( typeof __wxAppSuffixCode__ == "undefined"? undefined : __wxAppSuffixCode__ );
裡面會呼叫transformRPX
方法將rpx
轉成px
var transformRPX = window.__transformRpx__ || function(number, newDeviceWidth) {
if ( number === 0 ) return 0;
number = number / BASE_DEVICE_WIDTH * ( newDeviceWidth || deviceWidth );
number = Math.floor(number + eps);
if (number === 0) {
if (deviceDPR === 1 || !isIOS) {
return 1;
} else {
return 0.5;
}
}
return number;
}
// 主要公式
number = number / BASE_DEVICE_WIDTH * (newDeviceWidth || deviceWidth);
number = Math.floor(number + eps); //為了精確
// rpx值 / 基礎裝置寬750 * 真實裝置寬
渲染流程
上面瞭解完wxml
與wxss
的編譯過程,我們再來整體瞭解一下頁面的渲染流程。
先來了解渲染層模版
從上面的渲染層webview
我們可以找到這兩個webview
第一個index/index
webview我們上面說了它就是對應我們的小程式的渲染層,也就是真正的小程式頁面。
那麼下面這個instanceframe.html
是什麼呢?
這個webview其實是小程式渲染模版,開啟檢視一番
它其實就是提前注入了一些頁面所需要的公共檔案,以及紅框內的一些頁面獨立的檔案佔位符,這些佔位符會等小程式對應頁面檔案編譯完成後注入進來。
如何保證程式碼的注入是在渲染層webview的初始化之後執行?
在剛剛渲染模版webview
的下方有這樣一段指令碼:
if (document.readyState === 'complete') {
alert("DOCUMENT_READY")
} else {
const fn = () => {
alert("DOCUMENT_READY")
window.removeEventListener('load', fn)
}
window.addEventListener('load', fn)
}
很明顯,這裡在頁面初始化完成後,透過alert
來進行通知。此時的native/nw.js
會攔截這個alert
,從而知道此時的webview已經初始化完成。
整體渲染流程
瞭解了上面這個重要過程,我們就可以將整個流程串聯起來了
- 開啟小程式,建立檢視層頁的webview時,此時會初始化渲染層
webview
,並且會將該web view地址設定為instanceframe.html
,也就是我們的渲染層模版 - 然後進入頁面
/index/index
,等instanceframe
webview初始化完成,會將頁面index/index
編譯好的程式碼注入進來並執行
// 將webview src路徑修改為頁面路徑
history.pushState('', '', 'http://127.0.0.1:26444/__pageframe__/index/index')
/*
...
這裡還有一些 wx config及wxss編譯後的程式碼
*/
// 這裡是
var decodeName = decodeURI("./index/index.wxml")
var generateFunc = $gwx(decodeName)
if (decodeName === './__wx__/functional-page.wxml') {
generateFunc = function () {
return {
tag: 'wx-page',
children: [],
}
}
}
if (generateFunc) {
var CE = (typeof __global === 'object') ? (window.CustomEvent || __global.CustomEvent) : window.CustomEvent;
document.dispatchEvent(new CE("generateFuncReady", {
detail: {
generateFunc: generateFunc
}
}))
__global.timing.addPoint('PAGEFRAME_GENERATE_FUNC_READY', Date.now())
} else {
document.body.innerText = decodeName + " not found"
console.error(decodeName + " not found")
}
- 此時透過
history.pushState
方法修改webview的src但是webview並不會傳送頁面請求,並且將呼叫$gwx
為生成一個generateFun
方法,前面我們瞭解到該方法是用來生成虛擬dom的 - 然後會判斷該方法存在時,透過
document.dispatchEvent
派發發自定義事件generateFuncReady
將generateFunc當作引數傳遞給底層渲染庫 - 然後在底層渲染庫
WAWebview.js
中會監聽自定義事件generateFuncReady
,然後透過 WeixinJSBridge 通知 JS 邏輯層檢視已經準備好()
- 最後 JS 邏輯層將資料給 Webview 渲染層,
WAWebview.js
在透過virtual dom
生成真實dom過程中,它會掛載到頁面的document.body
上,至此一個頁面的渲染流程就結束了
資料更新
小程式的檢視層目前使用 WebView 作為渲染載體,而邏輯層是由獨立的 JavascriptCore 作為執行環境。
在架構上,WebView 和 JS Core 都是獨立的模組,並不具備資料直接共享的通道。所以在更新資料時必須呼叫setData
來通知渲染層做更新。
setData
- 邏輯層虛擬 DOM 樹的遍歷和更新,觸發元件生命週期和 observer 等;
- 將 data 從邏輯層傳輸到檢視層;
- 檢視層虛擬 DOM 樹的更新、真實 DOM 元素的更新並觸發頁面渲染更新。
這裡第二步由於WebView 和 JS Core 都是獨立的模組,資料傳輸是透過 evaluateJavascript
實現的,還會有額外 JS 指令碼解析和執行的耗時因此資料到達渲染層是非同步的。
因此切記
- 不要頻繁的去setData
- 不要每次 setData 都傳遞大量新資料(單次stringify後不超過256kb)
- 不要對後臺態頁面進行setData,會搶佔正在執行的前臺頁面的資源
與Vue對比(再來看看Vue)
整體來講,小程式身上或多或少都有著vue的影子...(模版檔案,data,指令,虛擬dom,生命週期等)
但在資料更新這裡,小程式卻與Vue表現的截然不同。
1.頁面更新DOM是同步的還是非同步的?
2.既然更新DOM是個同步的過程,為什麼Vue中還會有nextTick鉤子?
mounted() {
this.name = '前端南玖'
console.log('sync',this.$refs.title.innerText) // 舊文案
// 新文案
Promise.resolve().then(() => {
console.log('微任務',this.$refs.title.innerText)
})
setTimeout(() => {
console.log('宏任務',this.$refs.title.innerText)
}, 0)
this.$nextTick(() => {
console.log('nextTick',this.$refs.title.innerText)
})
}
這裡推薦閱讀這篇瞭解更多:Vue非同步更新機制以及$nextTick原理
然而小程式卻沒有這個佇列概念,頻繁呼叫,檢視會一直更新,阻塞使用者互動,引發效能問題。
而Vue 每次賦值操作並不會直接更新檢視,而是快取到一個資料更新佇列中,非同步更新,再觸發渲染,在同一個tick
內多次賦值,也只會渲染一次。
原文首發地址點這裡,歡迎大家關注公眾號 「前端南玖」,如果你想進前端交流群一起學習,請點這裡
我是南玖,我們下期見!!!