「深入浅出」主流前端框架更新批处理方式
作者?| 👽
來源?| 前端Sharing
背景
在不同的技術框架背景下,處理更新的手段各不相同,今天我們來探討一下,主流的前端框架批量處理的方式,和其內部的實現原理。
通過今天的學習,你將收獲這些內容:
?主流前端框架的批量更新方式。
?vue 和 react 批量更新的實現。
?宏任務和微任務的特點。
一次 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 一次。
一次 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。
批量處理意義
通過上面的案例說明在主流框架中,對于更新都采用批處理。一次上下文中的 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?timerfunction?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 向下兼容的情況。
接下來模擬一下,這個方式的實現。
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 來模擬一下更新函數的處理邏輯。
可控任務實現批量更新
上述介紹了通過微任務的方式實現了批量更新,還有一種方式,通過攔截把任務變成可控的,典型的就是 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 來立即處理更新任務。
接下來我們模擬一下具體的實現:
<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?=?truefn()/*?立即執行更新任務?*/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>分析一下核心流程:
本方式的核心就是讓 handleClick 通過 wrapEvent 變成可控的。首先 wrapEvent 類似于事件處理函數,在內部通過開關 batchEventUpdate 來判斷是否開啟批量更新狀態,最后通過 flushSyncCallbackQueue 來清空待更新隊列。
在批量更新條件下,事件會被放入到更新隊列中,非批量更新條件下,那么立即執行更新任務。
往期推薦
Redis 緩存擊穿(失效)、緩存穿透、緩存雪崩怎么解決?
如果被問到分布式鎖,應該怎樣回答?
三分鐘教你用 Scarlet 寫一個 WebSocket App
Java 底層知識:什么是?“橋接方法”??
點分享
點收藏
點點贊
點在看
總結
以上是生活随笔為你收集整理的「深入浅出」主流前端框架更新批处理方式的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 十年探索,云上明灯,re:Invent再
- 下一篇: Web 前端自学很苦?来,手把手教你,拿