「深入浅出」主流前端框架更新批处理方式
作者?| 👽
來(lái)源?| 前端Sharing
背景
在不同的技術(shù)框架背景下,處理更新的手段各不相同,今天我們來(lái)探討一下,主流的前端框架批量處理的方式,和其內(nèi)部的實(shí)現(xiàn)原理。
通過(guò)今天的學(xué)習(xí),你將收獲這些內(nèi)容:
?主流前端框架的批量更新方式。
?vue 和 react 批量更新的實(shí)現(xiàn)。
?宏任務(wù)和微任務(wù)的特點(diǎn)。
一次 vue 案例
首先來(lái)想一個(gè)問(wèn)題。比如在 vue 中一次更新中。
<template><div>姓名:?{{ name }}年齡:?{{ age }}<button?@click="handleClick"?>點(diǎn)擊</button></div> </template><script> export?default?{data(){return?{age:0,name:''}},methods:{handleClick(){this.name?=?'alien'this.age?=?18}} } </script>如上是一個(gè)非常簡(jiǎn)單的邏輯代碼,點(diǎn)擊按鈕,會(huì)觸發(fā) name 和 age 的更新。那么首先想一個(gè)問(wèn)題就是:
正常情況下,vue 的數(shù)據(jù)層是通過(guò)響應(yīng)式處理的,那么比如 age 和 name 可以理解成做了一層屬性代理,字符串模版 template 里面的屬性 ( name 和 age ) 的 get 會(huì)和組件的渲染 watcher ( vue3.0 里面的 effect )建立起關(guān)聯(lián)。
一次重新賦值會(huì)觸發(fā) set ,那么根據(jù)響應(yīng)式,會(huì)觸發(fā)渲染 watcher 重新執(zhí)行,然后就會(huì)重新更新組件,渲染視圖。
那么暴露的問(wèn)題就是,我們?cè)?handleClick 中,同時(shí)改變了 name 和 age 屬性,那么按照正常情況下,會(huì)分別觸發(fā) name 和 age 的 set,那么如果不做處理,那么會(huì)讓渲染 watcher 執(zhí)行兩次,結(jié)果就是組件會(huì) update 兩次,但是結(jié)果是這樣的嗎?
結(jié)果是:vue 底層通過(guò)批量處理,只讓組件 update 一次。
一次 react 案例
上面介紹了在 vue 中更新批處理的案例之后,我們來(lái)看一下在 react 中的批量更新處理。把上述案例用 react 來(lái)實(shí)現(xiàn)一下:
function?Index(){const?[?age?,?setAge?]?=?React.useState(0)const?[?name,?setName?]?=?React.useState('')return?<div>姓名:?{name}年齡:?{age}<button?onClick={()=>{setAge(18)setName('alien')}}>點(diǎn)擊</button></div> }點(diǎn)擊按鈕,觸發(fā)更新,會(huì)觸發(fā)兩次 useState 的更新函數(shù)。那么 React 的更新流程大致是這樣的。
首先會(huì)找到 fiberRoot 。
然后進(jìn)行調(diào)和流程。執(zhí)行 Index 組件,得到新的 element。
diff fiber,得到 effectList。
執(zhí)行 effect list,得到最新的 dom ,并進(jìn)行渲染繪制。
那么按常理來(lái)說(shuō),Index 組件會(huì)執(zhí)行兩次。可事實(shí)是只執(zhí)行一次 render。
批量處理意義
通過(guò)上面的案例說(shuō)明在主流框架中,對(duì)于更新都采用批處理。一次上下文中的 update 會(huì)被合并成一次更新。那么為什么要進(jìn)行更新批處理呢?
批處理主要是出于對(duì)性能方面的考慮,這里拿 react 為例子,看一下批處理前后的對(duì)比情況:
🌰例子一:假設(shè)沒(méi)有批量更新:
/ ------ js 層面 ------
第一步:發(fā)生點(diǎn)擊事件觸發(fā)一次宏任務(wù)。
第二步:執(zhí)行 setAge ,更新 fiber 狀態(tài)。
第三步:進(jìn)行 render 階段,Index 執(zhí)行,得到新的 element。得到 effectlist.
第四步:進(jìn)行 commit 階段,更新 dom。
第五步:執(zhí)行 setName ,更新 fiber 狀態(tài)。
第六步:重復(fù)執(zhí)行第三步,第四步。
/ ------ 瀏覽器渲染 ------
js 執(zhí)行完畢,渲染真實(shí)的 dom 元素。
我們可以看到如果沒(méi)有批量更新處理,那么會(huì)多走很多步驟,包括 render 階段 ,commit 階段,dom 的更新等,這些都會(huì)造成性能的浪費(fèi),接下來(lái)看一下有批量更新的情況。
🌰例子二:存在批量更新。
/ ------ js 層面 ------
第一步:發(fā)生點(diǎn)擊事件觸發(fā)一次宏任務(wù)。
第二步:setAge 和 setName 批量處理 ,更新 fiber 狀態(tài)。
第三步:進(jìn)行 render 階段,Index 執(zhí)行,得到新的 element。得到 effectlist.
第四步:進(jìn)行 commit 階段,更新 dom。
/ ------ 瀏覽器渲染 ------
js 執(zhí)行完畢,渲染真實(shí)的 dom 元素。
從上面可以直觀看到更新批處理的作用了,本質(zhì)上在 js 的執(zhí)行上下文上優(yōu)化了很多步驟,減少性能開(kāi)銷(xiāo)。
簡(jiǎn)述宏任務(wù)和微任務(wù)
在正式講批量更新之前,先來(lái)溫習(xí)一下宏任務(wù)和微任務(wù),這應(yīng)該算是前端工程師必須掌握的知識(shí)點(diǎn)。
所謂宏任務(wù),我們可以理解成,<script> 標(biāo)簽中主代碼執(zhí)行,一次用戶(hù)交互(比如觸發(fā)了一次點(diǎn)擊事件引起的回調(diào)函數(shù)),定時(shí)器 setInterval ,延時(shí)器 setTimeout 隊(duì)列, MessageChannel 等。這些宏任務(wù)通過(guò) event loop,來(lái)實(shí)現(xiàn)有條不紊的執(zhí)行。
例如在瀏覽器環(huán)境下,宏任務(wù)的執(zhí)行并不會(huì)影響到瀏覽器的渲染和響應(yīng)。我們來(lái)做個(gè)實(shí)驗(yàn)。
function?Index(){const?[?number?,?setNumber?]?=?useState(0)useEffect(()=>{let?timerfunction?run(){timer?=?setTimeout(()?=>?{console.log('----宏任務(wù)執(zhí)行----')run()},?0)}run()return?()?=>?clearTimeout(timer)},[])return?<div><button?onClick={()?=>?setNumber(number?+?1?)}??>點(diǎn)擊{number}</button></div> }如上簡(jiǎn)單的 demo 中,通過(guò)遞歸調(diào)用 run 函數(shù),讓 setTimeout 宏任務(wù)反復(fù)執(zhí)行。
這種情況下 setTimeout 執(zhí)行并不影響點(diǎn)擊事件的執(zhí)行和頁(yè)面的正常渲染。
什么是微任務(wù)呢 ?
那么我們?cè)賮?lái)分析一下微任務(wù),在 js 執(zhí)行過(guò)程中,我們希望一些任務(wù),不阻塞代碼執(zhí)行,又能讓該任務(wù)在此輪 event loop 執(zhí)行完畢,那么就引入了一個(gè)微任務(wù)隊(duì)列的概念了。
微任務(wù)相比宏任務(wù)有如下特點(diǎn):
微任務(wù)在當(dāng)前 js 執(zhí)行完畢后,立即執(zhí)行,會(huì)阻塞瀏覽器的渲染和響應(yīng)。
一次宏任務(wù)完畢后,會(huì)清空微任務(wù)隊(duì)列。
常見(jiàn)的微任務(wù),有 Promise, queueMicrotask ,瀏覽器環(huán)境下的 MutationObserver ,node 環(huán)境下 process.nextTick 等。
我們同樣做個(gè)實(shí)驗(yàn)看一下微任務(wù):
function?Index(){const?[?number?,?setNumber?]?=?useState(0)useEffect(()=>{function?run(){Promise.resolve().then(()=>{run()})}run()},[])return?<div><button?onClick={()?=>?setNumber(number?+?1?)}??>點(diǎn)擊{number}</button></div> }在這種情況下,瀏覽器直接卡死了,沒(méi)有了響應(yīng),證實(shí)了上述的結(jié)論。
微任務(wù)|宏任務(wù)實(shí)現(xiàn)批量更新
講完了宏任務(wù)和微任務(wù),繼續(xù)來(lái)看第一種批量更新的實(shí)現(xiàn),就是基于宏任務(wù) 和 微任務(wù) 來(lái)實(shí)現(xiàn)。
先來(lái)描述一下這種方式,比如每次更新,我們先并不去立即執(zhí)行更新任務(wù),而是先把每一個(gè)更新任務(wù)放入一個(gè)待更新隊(duì)列 updateQueue 里面,然后 js 執(zhí)行完畢,用一個(gè)微任務(wù)統(tǒng)一去批量更新隊(duì)列里面的任務(wù),如果微任務(wù)存在兼容性,那么降級(jí)成一個(gè)宏任務(wù)。這里優(yōu)先采用微任務(wù)的原因就是微任務(wù)的執(zhí)行時(shí)機(jī)要早于下一次宏任務(wù)的執(zhí)行。
典型的案例就是 vue 更新原理,vue.$nextTick原理 ,還有 v18 中 scheduleMicrotask 的更新原理。
以 vue 為例子我們看一下 nextTick 的實(shí)現(xiàn):
runtime-core/src/scheduler.ts
const?p?=?Promise.resolve()? /*?nextTick?實(shí)現(xiàn),用微任務(wù)實(shí)現(xiàn)的?*/ export?function?nextTick(fn?:?()?=>?void):?Promise<void>?{return?fn???p.then(fn)?:?p }可以看到 nextTick 原理,本質(zhì)就是 Promise.resolve() 創(chuàng)建的微任務(wù)。
再看看 react v18 里面的實(shí)現(xiàn)。
react-reconciler/src/ReactFiberWorkLoop/ensureRootIsScheduled
function?ensureRootIsScheduled(root,?currentTime)?{/*?省去沒(méi)有必要的邏輯?*/if?(newCallbackPriority?===?SyncLane)?{/*?支持微任務(wù)?*/if?(supportsMicrotasks)?{/*?通過(guò)微任務(wù)處理?*/scheduleMicrotask(flushSyncCallbacks);}} }接下里看一下 scheduleMicrotask 是如何實(shí)現(xiàn)的。
/*?向下兼容?*/ var?scheduleMicrotask?=?typeof?queueMicrotask?===?'function'???queueMicrotask?:?typeof?Promise?!==?'undefined'???function?(callback)?{return?Promise.resolve(null).then(callback).catch(handleErrorInNextTick); }?:?scheduleTimeout;scheduleMicrotask 也是用的 Promise.resolve ,還有一個(gè) setTimeout 向下兼容的情況。
接下來(lái)模擬一下,這個(gè)方式的實(shí)現(xiàn)。
class?Scheduler?{constructor(){this.callbacks?=?[]/*?微任務(wù)批量處理?*/queueMicrotask(()=>{this.runTask()})}/*?增加任務(wù)?*/addTask(fn){this.callbacks.push(fn)}runTask(){console.log('------合并更新開(kāi)始------')while(this.callbacks.length?>?0){const?cur?=?this.callbacks.shift()cur()}console.log('------合并更新結(jié)束------')console.log('------開(kāi)始更新組件------')} } function?nextTick(cb){const?scheduler?=?new?Scheduler()cb(scheduler.addTask.bind(scheduler)) }/*?模擬一次更新?*/ function?mockOnclick(){nextTick((add)=>{add(function(){console.log('第一次更新')})console.log('----宏任務(wù)邏輯----')add(function(){console.log('第二次更新')})}) }mockOnclick()我們來(lái)模擬一下具體實(shí)現(xiàn)細(xì)節(jié):
通過(guò)一個(gè) Scheduler 調(diào)度器來(lái)完成整個(gè)流程。
通過(guò) addTask 每次向隊(duì)列中放入任務(wù)。
用 queueMicrotask 創(chuàng)建一個(gè)微任務(wù),來(lái)統(tǒng)一處理這些任務(wù)。
mockOnclick 模擬一次更新。我們用 nextTick 來(lái)模擬一下更新函數(shù)的處理邏輯。
可控任務(wù)實(shí)現(xiàn)批量更新
上述介紹了通過(guò)微任務(wù)的方式實(shí)現(xiàn)了批量更新,還有一種方式,通過(guò)攔截把任務(wù)變成可控的,典型的就是 React v17 之前的 batchEventUpdate 批量更新。這種情況的更新來(lái)源于對(duì)事件進(jìn)行攔截,比如 React 的事件系統(tǒng)。
以 React 的事件批量更新為例子,比如我們的 onClick ,onChange 事件都是被 React 的事件系統(tǒng)處理的。外層用一個(gè)統(tǒng)一的處理函數(shù)進(jìn)行攔截。而我們綁定的事件都是在該函數(shù)的執(zhí)行上下文內(nèi)部被調(diào)用的。
那么比如在一次點(diǎn)擊事件中觸發(fā)了多次更新。本質(zhì)上外層在 React 事件系統(tǒng)處理函數(shù)的上下文中,這樣的情況下,就可以通過(guò)一個(gè)開(kāi)關(guān),證明當(dāng)前更新是可控的,可以做批量處理。接下來(lái) React 就用一次就可以了。
來(lái)看一下 React 的底層實(shí)現(xiàn)邏輯:
react-dom/src/events/ReactDOMUpdateBatching.js
export?function?batchedEventUpdates(fn,?a)?{/*?開(kāi)啟批量更新??*/const?prevExecutionContext?=?executionContext;executionContext?|=?EventContext;try?{/*?這里執(zhí)行了的事件處理函數(shù),?比如在一次點(diǎn)擊事件中觸發(fā)setState,那么它將在這個(gè)函數(shù)內(nèi)執(zhí)行?*/return?fn(a);}?finally?{/*?try?里面?return?不會(huì)影響?finally?執(zhí)行??*//*?完成一次事件,批量更新??*/executionContext?=?prevExecutionContext;if?(executionContext?===?NoContext)?{/*?立即執(zhí)行更新。??*/flushSyncCallbackQueue();}} }在 React 事件執(zhí)行之前通過(guò) isBatchingEventUpdates=true 打開(kāi)開(kāi)關(guān),開(kāi)啟事件批量更新,當(dāng)該事件結(jié)束,再通過(guò) isBatchingEventUpdates = false; 關(guān)閉開(kāi)關(guān),然后在 scheduleUpdateOnFiber 中根據(jù)這個(gè)開(kāi)關(guān)來(lái)確定是否進(jìn)行批量更新。
比如一次點(diǎn)擊事件中:
const?[?age?,?setAge?]?=?React.useState(0) const?[?name,?setName?]?=?React.useState('') const?handleClick=()=>{setAge(18)setName('alien') }那么首先 handleClick 是由點(diǎn)擊事件產(chǎn)生的,那么在 React 系統(tǒng)中,先執(zhí)行事件代理函數(shù),然后執(zhí)行 batchedEventUpdates。這個(gè)時(shí)候開(kāi)啟了批量更新的狀態(tài)。
接下來(lái)?setAge 和 setName 在批量狀態(tài)下不會(huì)立即更新。
最后通過(guò) flushSyncCallbackQueue 來(lái)立即處理更新任務(wù)。
接下來(lái)我們模擬一下具體的實(shí)現(xiàn):
<body>??<button?onclick="handleClick()"?>點(diǎn)擊</button> </body> <script>let??batchEventUpdate?=?false?let?callbackQueue?=?[]function?flushSyncCallbackQueue(){console.log('-----執(zhí)行批量更新-------')while(callbackQueue.length?>?0?){const?cur?=?callbackQueue.shift()cur()}console.log('-----批量更新結(jié)束-------')}function?wrapEvent(fn){return?function?(){/*?開(kāi)啟批量更新?tīng)顟B(tài)?*/batchEventUpdate?=?truefn()/*?立即執(zhí)行更新任務(wù)?*/flushSyncCallbackQueue()/*?關(guān)閉批量更新?tīng)顟B(tài)?*/batchEventUpdate?=?false}}function?setState(fn){/*?如果在批量更新?tīng)顟B(tài)下,那么批量更新?*/if(batchEventUpdate){callbackQueue.push(fn)}else{/*?如果沒(méi)有在批量更新條件下,那么直接更新。?*/fn()}}function?handleClick(){setState(()=>{console.log('---更新1---')})console.log('上下文執(zhí)行')setState(()=>{console.log('---更新2---')})}/*?讓?handleClick?變成可控的??*/handleClick?=?wrapEvent(handleClick)</script>分析一下核心流程:
本方式的核心就是讓 handleClick 通過(guò) wrapEvent 變成可控的。首先 wrapEvent 類(lèi)似于事件處理函數(shù),在內(nèi)部通過(guò)開(kāi)關(guān) batchEventUpdate 來(lái)判斷是否開(kāi)啟批量更新?tīng)顟B(tài),最后通過(guò) flushSyncCallbackQueue 來(lái)清空待更新隊(duì)列。
在批量更新條件下,事件會(huì)被放入到更新隊(duì)列中,非批量更新條件下,那么立即執(zhí)行更新任務(wù)。
往期推薦
Redis 緩存擊穿(失效)、緩存穿透、緩存雪崩怎么解決?
如果被問(wèn)到分布式鎖,應(yīng)該怎樣回答?
三分鐘教你用 Scarlet 寫(xiě)一個(gè) WebSocket App
Java 底層知識(shí):什么是?“橋接方法”??
點(diǎn)分享
點(diǎn)收藏
點(diǎn)點(diǎn)贊
點(diǎn)在看
總結(jié)
以上是生活随笔為你收集整理的「深入浅出」主流前端框架更新批处理方式的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 十年探索,云上明灯,re:Invent再
- 下一篇: 拥抱创新二十载,微软“创新杯”持续孵化中