探索小程式底層架構原理

2022-12-14 12:00:15

雙執行緒架構

在這之前,我們先來思考一個問題,小程式在架構上為什麼會選擇雙執行緒?

為什麼是雙執行緒?

載入及渲染效能

小程式的設計之初就是要求快速,這裡的快指的是載入以及渲染。

目前主流的渲染方式有以下3種:

  • Web技術渲染
  • Native技術渲染
  • Hybrid技術渲染(同時使用了webview和原生來渲染)

從小程式的定位來講,它就不可能用純原生技術來進行開發,因為那樣它的編譯以及發版都得跟隨微信,所以需要像Web技術那樣,有一份隨時可更新的資源包放在遠端,通過下載到本地,動態執行後即可渲染出介面。

但如果用純web技術來開發的話,會有一個很致命的缺點那就是在 Web 技術中,UI渲染跟 JavaScript 的指令碼執行都在一個單執行緒中執行,這就容易導致一些邏輯任務搶佔UI渲染的資源,這也就跟設計之初要求的相違背了。

因此微信小程式選擇了Hybrid 技術,介面主要由成熟的 Web 技術渲染,輔之以大量的介面提供豐富的使用者端原生能力。同時,每個小程式頁面都是用不同的WebView去渲染,這樣可以提供更好的互動體驗,更貼近原生體驗,也避免了單個WebView的任務過於繁重。

微信小程式是以webview渲染為主,原生渲染為輔的混合渲染方式

管控安全

由於web技術的靈活開放特點,如果基於純web技術來渲染小程式的話,勢必會存在一些不可控因素和安全風險。

為了解決安全管控的問題,小程式從設計上就阻止了開發者去使用一些瀏覽器提供的開放性api,比如說跳轉頁面、操作DOM等等。如果把這些東西一個一個地去加入到黑名單,那麼勢必會陷入一個非常糟糕的迴圈,因為瀏覽器的介面也非常豐富,那麼就很容易遺漏一些危險的介面,而且就算是禁用掉了所有的介面,也防不住瀏覽器核心的下次更新。

所以要徹底解決這個問題,必須提供一個沙箱環境來執行開發者的JavaScript 程式碼。這個沙箱環境只提供純 JavaScript 的解釋執行環境,沒有任何瀏覽器相關介面。那麼像HTML5中的ServiceWorkerWebWorker特性就符合這樣的條件,這兩者都是啟用另一執行緒來執行 javaScript

這就是小程式雙執行緒模型的由來:

  • 渲染層: 介面渲染相關的任務全都在 WebView 執行緒裡執行,通過邏輯層程式碼去控制渲染哪些介面。一個小程式存在多個介面,所以渲染層存在多個 WebView。

  • 邏輯層: 建立一個單獨的執行緒去執行 JavaScript,在這個環境下執行的都是有關小程式業務邏輯的程式碼。

雙執行緒模型

小程式的架構模型有別與傳統web單執行緒架構,小程式為雙執行緒架構。

微信小程式的渲染層與邏輯層分別由兩個執行緒管理,渲染層的介面使用 webview 進行渲染;邏輯層採用 JSCore執行JavaScript程式碼。

webview渲染執行緒

如何找到渲染層?

  1. 我們可以通過偵錯微信開發者工具:微信開發者工具 ->偵錯 ->偵錯微信開發者工具

  1. 然後我們會再看到一個偵錯介面,看起來跟我們平時用的瀏覽器偵錯介面幾乎一摸一樣

但這並不是小程式的渲染層,而是開發者工具的結構。但我們在裡面可以發現有一些webview標籤,在第一個webview上的src屬性看著是不是有點眼熟,沒猜錯的話它就是我們當前小程式開啟頁面的路徑。所以這個webview才是小程式真正的渲染層。這裡你會發現它裡面並不只有一個webview,其實裡面包含著檢視層的webview業務邏輯層webview開發者工具的webview

開發者工具的邏輯層跑在webview中主要是為了模擬真機上的雙執行緒

  1. 開啟渲染層一探究竟

通過showdevTools方法來開啟偵錯此webview介面的偵錯程式

document.querySelectorAll('webview')[0].showDevTools(true)

這裡我們看到的才真正是小程式的渲染層,也就是小程式程式碼編譯後的樣子,我們會發現這裡的標籤都與我們開發時寫的不一樣,都統一加了wx-字首。瞭解過webComponent的同學相信一眼就能看出他們非常相似,但小程式並沒有直接使用webComponent,而是自行搭建了一套元件系統Exparser

Exparser的元件模型與WebComponents標準中的Shadow DOM高度相似。Exparser會維護整個頁面的節點樹相關資訊,包括節點的屬性、事件繫結等,相當於一個簡化版的Shadow DOM實現。

為什麼不直接使用webComponent,而是選擇自行搭建一套元件系統?

點選檢視 - 管控與安全:web技術可以通過指令碼獲取修改頁面敏感內容或者隨意跳轉其它頁面
- 能力有限:會限制小程式的表現形式
- 標籤眾多:增加理解成本

JSCore邏輯執行緒

邏輯層我們直接在小程式開發者工具的偵錯程式中輸入document就能看到

小程式將所有業務程式碼置於同一個執行緒中執行,在小程式開發者工具中邏輯執行緒同樣是跑在一個webview中;webview中的appservice.html除了引入業務程式碼js之外,還有後臺服務內嵌的一些基礎功能程式碼。

編譯原理

瞭解完小程式的雙執行緒架構,我們再來看一下小程式的程式碼是如何編譯執行的,微信開者工具模擬器執行的程式碼是經過本地預處理、本地編譯,而微信使用者端執行的程式碼是額外經過伺服器編譯的。這裡我們還是以微信開發者工具為例來探索一番。

在開發者工具輸入openVendor(),會幫我們開啟微信開發者工具的WeappVendor資料夾

在這裡我們我們會看到一些wxvpkg檔案,這是小程式的各個版本的基礎庫檔案,還有兩個值得我們注意的檔案:wccwcsc,這兩個檔案是小程式的編譯器,分別用來編譯wxmlwxss檔案。

編譯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

點選檢視 - 雙執行緒,需要動態注入資料

編譯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 * 真實裝置寬

渲染流程

上面瞭解完wxmlwxss的編譯過程,我們再來整體瞭解一下頁面的渲染流程。

先來了解渲染層模版

從上面的渲染層webview我們可以找到這兩個webview

第一個index/indexwebview我們上面說了它就是對應我們的小程式的渲染層,也就是真正的小程式頁面。

那麼下面這個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已經初始化完成。

整體渲染流程

瞭解了上面這個重要過程,我們就可以將整個流程串聯起來了

  1. 開啟小程式,建立檢視層頁的webview時,此時會初始化渲染層webview,並且會將該web view地址設定為instanceframe.html,也就是我們的渲染層模版
  2. 然後進入頁面/index/index,等instanceframewebview初始化完成,會將頁面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")
}
  1. 此時通過history.pushState方法修改webview的src但是webview並不會傳送頁面請求,並且將呼叫$gwx為生成一個generateFun方法,前面我們瞭解到該方法是用來生成虛擬dom的
  2. 然後會判斷該方法存在時,通過document.dispatchEvent 派發發自定義事件generateFuncReady 將generateFunc當作引數傳遞給底層渲染庫
  3. 然後在底層渲染庫WAWebview.js中會監聽自定義事件generateFuncReady ,然後通過 WeixinJSBridge 通知 JS 邏輯層檢視已經準備好()

  1. 最後 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內多次賦值,也只會渲染一次。

原文首發地址點這裡,歡迎大家關注公眾號 「前端南玖」,如果你想進前端交流群一起學習,請點這裡

我是南玖,我們下期見!!!