「深入淺出」主流前端框架更新批處理方式
一 背景
大家好,我是 alien ,一提到更新,是前端框架中一個老生常談的問題,這些知識也是在面試中,面試官比較喜歡問的,那麼在不同的技術框架背景下,處理更新的手段各不相同,今天我們來探討一下,主流的前端框架批量處理的方式,和其內部的實現原理。
通過今天的學習,你將收穫這些內容:
-
主流前端框架的批量更新方式。
-
vue 和 react 批量更新的實現。
-
宏任務和微任務的特點。
1 一次 vue 案例
首先來想一個問題。比如在 vue 中一次更新中。
<template>
<div>
姓名: {{ name }}
年齡: {{ age }}
<button @click="handleClick" >點擊</button>
</div>
</template>
<script>
export default {
data(){
return {
age:0,
name:''
}
},
methods:{
handleClick(){
this.name = 'alien'
this.age = 18
}
}
}
</script>
如上是一個非常簡單的邏輯代碼,點擊按鈕,會觸發 name
和 age
的更新。那麼首先想一個問題就是:
-
正常情況下,vue 的數據層是通過響應式處理的,那麼比如 age 和 name 可以理解成做了一層屬性代理,字符串模版 template 裏面的屬性 ( name 和 age ) 的 get 會和組件的渲染 watcher ( vue3.0 裏面的 effect )建立起關聯。
-
一次重新賦值會觸發 set ,那麼根據響應式,會觸發渲染 watcher 重新執行,然後就會重新更新組件,渲染視圖。
那麼暴露的問題就是,我們在 handleClick
中,同時改變了 name 和 age 屬性,那麼按照正常情況下,會分別觸發 name 和 age 的 set,那麼如果不做處理,那麼會讓渲染 watcher 執行兩次,結果就是組件會 update 兩次,但是結果是這樣的嗎?
結果是:vue 底層通過批量處理,只讓組件 update 一次。
2 一次 react 案例
上面介紹了在 vue 中更新批處理的案例之後,我們來看一下在 react 中的批量更新處理。把上述案例用 react 來實現一下:
function Index(){
const [ age , setAge ] = React.useState(0)
const [ name, setName ] = React.useState('')
return <div>
姓名: {name}
年齡: {age}
<button onClick={()=>{
setAge(18)
setName('alien')
}}
>點擊</button>
</div>
}
點擊按鈕,觸發更新,會觸發兩次 useState 的更新函數。那麼 React 的更新流程大致是這樣的。
-
首先會找到 fiberRoot 。
-
然後進行調和流程。執行 Index 組件,得到新的 element。
-
diff fiber,得到 effectList。
-
執行 effect list,得到最新的 dom ,並進行渲染繪製。
那麼按常理來說,Index 組件會執行兩次。可事實是隻執行一次 render。
3 批量處理意義
通過上面的案例說明在主流框架中,對於更新都採用批處理。一次上下文中的 update 會被合併成一次更新。那麼爲什麼要進行更新批處理呢?
批處理主要是出於對性能方面的考慮,這裏拿 react 爲例子,看一下批處理前後的對比情況:
🌰例子一:假設沒有批量更新:
/ ------ js 層面 ------
-
第一步:發生點擊事件觸發一次宏任務。
-
第二步:執行 setAge ,更新 fiber 狀態。
-
第三步:進行 render 階段,Index 執行,得到新的 element。得到 effectlist.
-
第四步:進行 commit 階段,更新 dom。
-
第五步:執行 setName ,更新 fiber 狀態。
-
第六步:重複執行第三步,第四步。
/ ------ 瀏覽器渲染 ------
- js 執行完畢,渲染真實的 dom 元素。
我們可以看到如果沒有批量更新處理,那麼會多走很多步驟,包括 render 階段 ,commit 階段,dom 的更新等,這些都會造成性能的浪費,接下來看一下有批量更新的情況。
🌰例子二:存在批量更新。
/ ------ js 層面 ------
-
第一步:發生點擊事件觸發一次宏任務。
-
第二步:setAge 和 setName 批量處理 ,更新 fiber 狀態。
-
第三步:進行 render 階段,Index 執行,得到新的 element。得到 effectlist.
-
第四步:進行 commit 階段,更新 dom。
/ ------ 瀏覽器渲染 ------
- js 執行完畢,渲染真實的 dom 元素。
從上面可以直觀看到更新批處理的作用了,本質上在 js 的執行上下文上優化了很多步驟,減少性能開銷。
二 簡述宏任務和微任務
在正式講批量更新之前,先來溫習一下宏任務和微任務,這應該算是前端工程師必須掌握的知識點。
所謂宏任務,我們可以理解成,<script>
標籤中主代碼執行,一次用戶交互(比如觸發了一次點擊事件引起的回調函數),定時器 setInterval
,延時器 setTimeout
隊列, MessageChannel
等。這些宏任務通過 event loop,來實現有條不紊的執行。
例如在瀏覽器環境下,宏任務的執行並不會影響到瀏覽器的渲染和響應。我們來做個實驗。
function Index(){
const [ number , setNumber ] = useState(0)
useEffect(()=>{
let timer
function run(){
timer = setTimeout(() => {
console.log('----宏任務執行----')
run()
}, 0)
}
run()
return () => clearTimeout(timer)
},[])
return <div>
<button onClick={() => setNumber(number + 1 )} >點擊{number}</button>
</div>
}
如上簡單的 demo 中,通過遞歸調用 run 函數,讓 setTimeout 宏任務反覆執行。
這種情況下 setTimeout 執行並不影響點擊事件的執行和頁面的正常渲染。
什麼是微任務呢 ?
那麼我們再來分析一下微任務,在 js 執行過程中,我們希望一些任務,不阻塞代碼執行,又能讓該任務在此輪 event loop 執行完畢,那麼就引入了一個微任務隊列的概念了。
微任務相比宏任務有如下特點:
-
微任務在當前 js 執行完畢後,立即執行,會阻塞瀏覽器的渲染和響應。
-
一次宏任務完畢後,會清空微任務隊列。
常見的微任務,有 Promise
, queueMicrotask
,瀏覽器環境下的 MutationObserver
,node 環境下 process.nextTick
等。
我們同樣做個實驗看一下微任務:
function Index(){
const [ number , setNumber ] = useState(0)
useEffect(()=>{
function run(){
Promise.resolve().then(()=>{
run()
})
}
run()
},[])
return <div>
<button onClick={() => setNumber(number + 1 )} >點擊{number}</button>
</div>
}
- 在這種情況下,瀏覽器直接卡死了,沒有了響應,證實了上述的結論。
三 微任務|宏任務實現批量更新
講完了宏任務和微任務,繼續來看第一種批量更新的實現,就是基於宏任務 和 微任務 來實現。
先來描述一下這種方式,比如每次更新,我們先並不去立即執行更新任務,而是先把每一個更新任務放入一個待更新隊列 updateQueue
裏面,然後 js 執行完畢,用一個微任務統一去批量更新隊列裏面的任務,如果微任務存在兼容性,那麼降級成一個宏任務。這裏優先採用微任務的原因就是微任務的執行時機要早於下一次宏任務的執行。
典型的案例就是 vue 更新原理,vue.$nextTick
原理 ,還有 v18 中 scheduleMicrotask
的更新原理。
以 vue 爲例子我們看一下 nextTick 的實現:
runtime-core/src/scheduler.ts
const p = Promise.resolve()
/* nextTick 實現,用微任務實現的 */
export function nextTick(fn?: () => void): Promise<void> {
return fn ? p.then(fn) : p
}
- 可以看到 nextTick 原理,本質就是
Promise.resolve()
創建的微任務。
再看看 react v18 裏面的實現。
react-reconciler/src/ReactFiberWorkLoop/ensureRootIsScheduled
function ensureRootIsScheduled(root, currentTime) {
/* 省去沒有必要的邏輯 */
if (newCallbackPriority === SyncLane) {
/* 支持微任務 */
if (supportsMicrotasks) {
/* 通過微任務處理 */
scheduleMicrotask(flushSyncCallbacks);
}
}
}
接下里看一下 scheduleMicrotask
是如何實現的。
/* 向下兼容 */
var scheduleMicrotask = typeof queueMicrotask === 'function' ? queueMicrotask : typeof Promise !== 'undefined' ? function (callback) {
return Promise.resolve(null).then(callback).catch(handleErrorInNextTick);
} : scheduleTimeout;
scheduleMicrotask 也是用的 Promise.resolve ,還有一個 setTimeout 向下兼容的情況。
大致實現流程圖如下所示:
4.jpeg
接下來模擬一下,這個方式的實現。
class Scheduler {
constructor(){
this.callbacks = []
/* 微任務批量處理 */
queueMicrotask(()=>{
this.runTask()
})
}
/* 增加任務 */
addTask(fn){
this.callbacks.push(fn)
}
runTask(){
console.log('------合併更新開始------')
while(this.callbacks.length > 0){
const cur = this.callbacks.shift()
cur()
}
console.log('------合併更新結束------')
console.log('------開始更新組件------')
}
}
function nextTick(cb){
const scheduler = new Scheduler()
cb(scheduler.addTask.bind(scheduler))
}
/* 模擬一次更新 */
function mockOnclick(){
nextTick((add)=>{
add(function(){
console.log('第一次更新')
})
console.log('----宏任務邏輯----')
add(function(){
console.log('第二次更新')
})
})
}
mockOnclick()
我們來模擬一下具體實現細節:
-
通過一個 Scheduler 調度器來完成整個流程。
-
通過 addTask 每次向隊列中放入任務。
-
用 queueMicrotask 創建一個微任務,來統一處理這些任務。
-
mockOnclick 模擬一次更新。我們用 nextTick 來模擬一下更新函數的處理邏輯。
看一下打印效果:
3.jpeg
四 可控任務實現批量更新
上述介紹了通過微任務的方式實現了批量更新,還有一種方式,通過攔截把任務變成可控的,典型的就是 React v17 之前的 batchEventUpdate 批量更新。這種情況的更新來源於對事件進行攔截,比如 React 的事件系統。
以 React 的事件批量更新爲例子,比如我們的 onClick ,onChange 事件都是被 React 的事件系統處理的。外層用一個統一的處理函數進行攔截。而我們綁定的事件都是在該函數的執行上下文內部被調用的。
那麼比如在一次點擊事件中觸發了多次更新。本質上外層在 React 事件系統處理函數的上下文中,這樣的情況下,就可以通過一個開關,證明當前更新是可控的,可以做批量處理。接下來 React 就用一次就可以了。
來看一下 React 的底層實現邏輯:
react-dom/src/events/ReactDOMUpdateBatching.js
export function batchedEventUpdates(fn, a) {
/* 開啓批量更新 */
const prevExecutionContext = executionContext;
executionContext |= EventContext;
try {
/* 這裏執行了的事件處理函數, 比如在一次點擊事件中觸發setState,那麼它將在這個函數內執行 */
return fn(a);
} finally {
/* try 裏面 return 不會影響 finally 執行 */
/* 完成一次事件,批量更新 */
executionContext = prevExecutionContext;
if (executionContext === NoContext) {
/* 立即執行更新。 */
flushSyncCallbackQueue();
}
}
}
在 React 事件執行之前通過 isBatchingEventUpdates=true
打開開關,開啓事件批量更新,當該事件結束,再通過 isBatchingEventUpdates = false;
關閉開關,然後在 scheduleUpdateOnFiber 中根據這個開關來確定是否進行批量更新。
比如一次點擊事件中:
const [ age , setAge ] = React.useState(0)
const [ name, setName ] = React.useState('')
const handleClick=()=>{
setAge(18)
setName('alien')
}
-
那麼首先 handleClick 是由點擊事件產生的,那麼在 React 系統中,先執行事件代理函數,然後執行
batchedEventUpdates
。這個時候開啓了批量更新的狀態。 -
接下來 setAge 和 setName 在批量狀態下不會立即更新。
-
最後通過
flushSyncCallbackQueue
來立即處理更新任務。
我們用一幅流程圖來描述一下原理。
5.jpeg
接下來我們模擬一下具體的實現:
<body>
<button onclick="handleClick()" >點擊</button>
</body>
<script>
let batchEventUpdate = false
let callbackQueue = []
function flushSyncCallbackQueue(){
console.log('-----執行批量更新-------')
while(callbackQueue.length > 0 ){
const cur = callbackQueue.shift()
cur()
}
console.log('-----批量更新結束-------')
}
function wrapEvent(fn){
return function (){
/* 開啓批量更新狀態 */
batchEventUpdate = true
fn()
/* 立即執行更新任務 */
flushSyncCallbackQueue()
/* 關閉批量更新狀態 */
batchEventUpdate = false
}
}
function setState(fn){
/* 如果在批量更新狀態下,那麼批量更新 */
if(batchEventUpdate){
callbackQueue.push(fn)
}else{
/* 如果沒有在批量更新條件下,那麼直接更新。 */
fn()
}
}
function handleClick(){
setState(()=>{
console.log('---更新1---')
})
console.log('上下文執行')
setState(()=>{
console.log('---更新2---')
})
}
/* 讓 handleClick 變成可控的 */
handleClick = wrapEvent(handleClick)
</script>
打印結果:
6.jpg
分析一下核心流程:
-
本方式的核心就是讓 handleClick 通過 wrapEvent 變成可控的。首先 wrapEvent 類似於事件處理函數,在內部通過開關 batchEventUpdate 來判斷是否開啓批量更新狀態,最後通過 flushSyncCallbackQueue 來清空待更新隊列。
-
在批量更新條件下,事件會被放入到更新隊列中,非批量更新條件下,那麼立即執行更新任務。
五 總結
本章節介紹了主流框架實現更新批處理的方式。送人玫瑰🌹,手有餘香,希望看完覺的有收穫的同學,可以給筆者點贊 ➕ 關注一波 ,以此鼓勵我繼續創作前端硬文。
參考資料
- React 進階實踐指南
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/KXmsf8qb-QvkQPICHq_Pqg