React 架構的演變 - 從同步到異步
寫這篇文章的目的,主要是想弄懂 React 最新的 fiber 架構到底是什么東西,但是看了網上的很多文章,要不模棱兩可,要不就是一頓復制粘貼,根本看不懂,于是開始認真鉆研源碼。鉆研過程中,發現我想得太簡單了,React 源碼的復雜程度遠超我的想象,于是打算分幾個模塊了剖析,今天先講一講 React 的更新策略從同步變為異步的演變過程。
從 setState 說起
React 16 之所以要進行一次大重構,是因為 React 之前的版本有一些不可避免的缺陷,一些更新操作,需要由同步改成異步。所以我們先聊聊 React 15 是如何進行一次 setState 的。
- import React from 'react';
- class App extends React.Component {
- state = { val: 0 }
- componentDidMount() {
- // 第一次調用
- this.setState({ val: this.state.val + 1 });
- console.log('first setState', this.state);
- // 第二次調用
- this.setState({ val: this.state.val + 1 });
- console.log('second setState', this.state);
- // 第三次調用
- this.setState({ val: this.state.val + 1 }, () => {
- console.log('in callback', this.state)
- });
- }
- render() {
- return <div> val: { this.state.val } </div>
- }
- }
- export default App;
}}export default App;
熟悉 React 的同學應該知道,在 React 的生命周期內,多次 setState 會被合并成一次,這里雖然連續進行了三次 setState,state.val 的值實際上只重新計算了一次。
render結果
每次 setState 之后,立即獲取 state 會發現并沒有更新,只有在 setState 的回調函數內才能拿到最新的結果,這點通過我們在控制臺輸出的結果就可以證實。
控制臺輸出
網上有很多文章稱 setState 是『異步操作』,所以導致 setState 之后并不能獲取到最新值,其實這個觀點是錯誤的。setState 是一次同步操作,只是每次操作之后并沒有立即執行,而是將 setState 進行了緩存,mount 流程結束或事件操作結束,才會拿出所有的 state 進行一次計算。如果 setState 脫離了 React 的生命周期或者 React 提供的事件流,setState 之后就能立即拿到結果。
我們修改上面的代碼,將 setState 放入 setTimeout 中,在下一個任務隊列進行執行。
- import React from 'react';
- class App extends React.Component {
- state = { val: 0 }
- componentDidMount() {
- setTimeout(() => {
- // 第一次調用
- this.setState({ val: this.state.val + 1 });
- console.log('first setState', this.state);
- // 第二次調用
- this.setState({ val: this.state.val + 1 });
- console.log('second setState', this.state);
- });
- }
- render() {
- return <div> val: { this.state.val } </div>
- }
- }
- export default App;
可以看到,setState 之后就能立即看到state.val 的值發生了變化。
控制臺輸出
為了更加深入理解 setState,下面簡單講解一下React 15 中 setState 的更新邏輯,下面的代碼是對源碼的一些精簡,并非完整邏輯。
舊版本 setState 源碼分析
setState 的主要邏輯都在 ReactUpdateQueue 中實現,在調用 setState 后,并沒有立即修改 state,而是將傳入的參數放到了組件內部的 _pendingStateQueue 中,之后調用 enqueueUpdate 來進行更新。
- // 對外暴露的 React.Component
- function ReactComponent() {
- this.updater = ReactUpdateQueue;
- }
- // setState 方法掛載到原型鏈上
- ReactComponent.prototype.setState = function (partialState, callback) {
- // 調用 setState 后,會調用內部的 updater.enqueueSetState
- this.updater.enqueueSetState(this, partialState);
- if (callback) {
- this.updater.enqueueCallback(this, callback, 'setState');
- }
- };
- var ReactUpdateQueue = {
- enqueueSetState(component, partialState) {
- // 在組件的 _pendingStateQueue 上暫存新的 state
- if (!component._pendingStateQueue) {
- component._pendingStateQueue = [];
- }
- var queue = component._pendingStateQueue;
- queue.push(partialState);
- enqueueUpdate(component);
- },
- enqueueCallback: function (component, callback, callerName) {
- // 在組件的 _pendingCallbacks 上暫存 callback
- if (component._pendingCallbacks) {
- component._pendingCallbacks.push(callback);
- } else {
- component._pendingCallbacks = [callback];
- }
- enqueueUpdate(component);
- }
- }
enqueueUpdate 首先會通過 batchingStrategy.isBatchingUpdates 判斷當前是否在更新流程,如果不在更新流程,會調用 batchingStrategy.batchedUpdates() 進行更新。如果在流程中,會將待更新的組件放入 dirtyComponents 進行緩存。
- var dirtyComponents = [];
- function enqueueUpdate(component) {
- if (!batchingStrategy.isBatchingUpdates) {
- // 開始進行批量更新
- batchingStrategy.batchedUpdates(enqueueUpdate, component);
- return;
- }
- // 如果在更新流程,則將組件放入臟組件隊列,表示組件待更新
- dirtyComponents.push(component);
- }
batchingStrategy 是 React 進行批處理的一種策略,該策略的實現基于 Transaction,雖然名字和數據庫的事務一樣,但是做的事情卻不一樣。
- class ReactDefaultBatchingStrategyTransaction extends Transaction {
- constructor() {
- this.reinitializeTransaction()
- }
- getTransactionWrappers () {
- return [
- {
- initialize: () => {},
- close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates)
- },
- {
- initialize: () => {},
- close: () => {
- ReactDefaultBatchingStrategy.isBatchingUpdates = false;
- }
- }
- ]
- }
- }
- var transaction = new ReactDefaultBatchingStrategyTransaction();
- var batchingStrategy = {
- // 判斷是否在更新流程中
- isBatchingUpdates: false,
- // 開始進行批量更新
- batchedUpdates: function (callback, component) {
- // 獲取之前的更新狀態
- var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;
- // 將更新狀態修改為 true
- ReactDefaultBatchingStrategy.isBatchingUpdates = true;
- if (alreadyBatchingUpdates) {
- // 如果已經在更新狀態中,等待之前的更新結束
- return callback(callback, component);
- } else {
- // 進行更新
- return transaction.perform(callback, null, component);
- }
- }
- };
Transaction 通過 perform 方法啟動,然后通過擴展的 getTransactionWrappers 獲取一個數組,該數組內存在多個 wrapper 對象,每個對象包含兩個屬性:initialize、close。perform 中會先調用所有的 wrapper.initialize,然后調用傳入的回調,最后調用所有的 wrapper.close。
- class Transaction {
- reinitializeTransaction() {
- this.transactionWrappers = this.getTransactionWrappers();
- }
- perform(method, scope, ...param) {
- this.initializeAll(0);
- var ret = method.call(scope, ...param);
- this.closeAll(0);
- return ret;
- }
- initializeAll(startIndex) {
- var transactionWrappers = this.transactionWrappers;
- for (var i = startIndex; i < transactionWrappers.length; i++) {
- var wrapper = transactionWrappers[i];
- wrapper.initialize.call(this);
- }
- }
- closeAll(startIndex) {
- var transactionWrappers = this.transactionWrappers;
- for (var i = startIndex; i < transactionWrappers.length; i++) {
- var wrapper = transactionWrappers[i];
- wrapper.close.call(this);
- }
- }
- }
transaction.perform
React 源代碼的注釋中,也形象的展示了這一過程。
- /*
- * wrappers (injected at creation time)
- * + +
- * | |
- * +-----------------|--------|--------------+
- * | v | |
- * | +---------------+ | |
- * | +--| wrapper1 |---|----+ |
- * | | +---------------+ v | |
- * | | +-------------+ | |
- * | | +----| wrapper2 |--------+ |
- * | | | +-------------+ | | |
- * | | | | | |
- * | v v v v | wrapper
- * | +---+ +---+ +---------+ +---+ +---+ | invariants
- * perform(anyMethod) | | | | | | | | | | | | maintained
- * +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
- * | | | | | | | | | | | |
- * | | | | | | | | | | | |
- * | | | | | | | | | | | |
- * | +---+ +---+ +---------+ +---+ +---+ |
- * | initialize close |
- * +-----------------------------------------+
- */
我們簡化一下代碼,再重新看一下 setState 的流程。
- // 1. 調用 Component.setState
- ReactComponent.prototype.setState = function (partialState) {
- this.updater.enqueueSetState(this, partialState);
- };
- // 2. 調用 ReactUpdateQueue.enqueueSetState,將 state 值放到 _pendingStateQueue 進行緩存
- var ReactUpdateQueue = {
- enqueueSetState(component, partialState) {
- var queue = component._pendingStateQueue || (component._pendingStateQueue = []);
- queue.push(partialState);
- enqueueUpdate(component);
- }
- }
- // 3. 判斷是否在更新過程中,如果不在就進行更新
- var dirtyComponents = [];
- function enqueueUpdate(component) {
- // 如果之前沒有更新,此時的 isBatchingUpdates 肯定是 false
- if (!batchingStrategy.isBatchingUpdates) {
- // 調用 batchingStrategy.batchedUpdates 進行更新
- batchingStrategy.batchedUpdates(enqueueUpdate, component);
- return;
- }
- dirtyComponents.push(component);
- }
- // 4. 進行更新,更新邏輯放入事務中進行處理
- var batchingStrategy = {
- isBatchingUpdates: false,
- // 注意:此時的 callback 為 enqueueUpdate
- batchedUpdates: function (callback, component) {
- var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;
- ReactDefaultBatchingStrategy.isBatchingUpdates = true;
- if (alreadyBatchingUpdates) {
- // 如果已經在更新狀態中,重新調用 enqueueUpdate,將 component 放入 dirtyComponents
- return callback(callback, component);
- } else {
- // 進行事務操作
- return transaction.perform(callback, null, component);
- }
- }
- };
啟動事務可以拆分成三步來看:
先執行 wrapper 的 initialize,此時的 initialize 都是一些空函數,可以直接跳過;
然后執行 callback(也就是 enqueueUpdate),執行 enqueueUpdate 時,由于已經進入了更新狀態,batchingStrategy.isBatchingUpdates 被修改成了 true,所以最后還是會把 component 放入臟組件隊列,等待更新;
后面執行的兩個 close 方法,第一個方法的 flushBatchedUpdates 是用來進行組件更新的,第二個方法用來修改更新狀態,表示更新已經結束。
- getTransactionWrappers () {
- return [
- {
- initialize: () => {},
- close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates)
- },
- {
- initialize: () => {},
- close: () => {
- ReactDefaultBatchingStrategy.isBatchingUpdates = false;
- }
- }
- ]
- }
flushBatchedUpdates 里面會取出所有的臟組件隊列進行 diff,最后更新到 DOM。
- function flushBatchedUpdates() {
- if (dirtyComponents.length) {
- runBatchedUpdates()
- }
- };
- function runBatchedUpdates() {
- // 省略了一些去重和排序的操作
- for (var i = 0; i < dirtyComponents.length; i++) {
- var component = dirtyComponents[i];
- // 判斷組件是否需要更新,然后進行 diff 操作,最后更新 DOM。
- ReactReconciler.performUpdateIfNecessary(component);
- }
- }
performUpdateIfNecessary() 會調用 Component.updateComponent(),在updateComponent() 中,會從 _pendingStateQueue 中取出所有的值來更新。
- // 獲取最新的 state
- _processPendingState() {
- var inst = this._instance;
- var queue = this._pendingStateQueue;
- var nextState = { ...inst.state };
- for (var i = 0; i < queue.length; i++) {
- var partial = queue[i];
- Object.assign(
- nextState,
- typeof partial === 'function' ? partial(inst, nextState) : partial
- );
- }
- return nextState;
- }
- // 更新組件
- updateComponent(prevParentElement, nextParentElement) {
- var inst = this._instance;
- var prevProps = prevParentElement.props;
- var nextProps = nextParentElement.props;
- var nextState = this._processPendingState();
- var shouldUpdate =
- !shallowEqual(prevProps, nextProps) ||
- !shallowEqual(inst.state, nextState);
- if (shouldUpdate) {
- // diff 、update DOM
- } else {
- inst.props = nextProps;
- inst.state = nextState;
- }
- // 后續的操作包括判斷組件是否需要更新、diff、更新到 DOM
- }
setState 合并原因
按照剛剛講解的邏輯,setState 的時候,batchingStrategy.isBatchingUpdates 為 false 會開啟一個事務,將組件放入臟組件隊列,最后進行更新操作,而且這里都是同步操作。講道理,setState 之后,我們可以立即拿到最新的 state。
然而,事實并非如此,在 React 的生命周期及其事件流中,batchingStrategy.isBatchingUpdates 的值早就被修改成了 true。可以看看下面兩張圖:
Mount
事件調用
在組件 mount 和事件調用的時候,都會調用 batchedUpdates,這個時候已經開始了事務,所以只要不脫離 React,不管多少次 setState 都會把其組件放入臟組件隊列等待更新。一旦脫離 React 的管理,比如在 setTimeout 中,setState 立馬變成單打獨斗。
Concurrent 模式
React 16 引入的 Fiber 架構,就是為了后續的異步渲染能力做鋪墊,雖然架構已經切換,但是異步渲染的能力并沒有正式上線,我們只能在實驗版中使用。異步渲染指的是 Concurrent 模式,下面是官網的介紹:
“Concurrent 模式是 React 的新功能,可幫助應用保持響應,并根據用戶的設備性能和網速進行適當的調整。
優點
除了 Concurrent 模式,React 還提供了另外兩個模式, Legacy 模式依舊是同步更新的方式,可以認為和舊版本保持一致的兼容模式,而 Blocking 模式是一個過渡版本。
模式差異
Concurrent 模式說白就是讓組件更新異步化,切分時間片,渲染之前的調度、diff、更新都只在指定時間片進行,如果超時就暫停放到下個時間片進行,中途給瀏覽器一個喘息的時間。
“瀏覽器是單線程,它將 GUI 描繪,時間器處理,事件處理,JS 執行,遠程資源加載統統放在一起。當做某件事,只有將它做完才能做下一件事。如果有足夠的時間,瀏覽器是會對我們的代碼進行編譯優化(JIT)及進行熱代碼優化,一些 DOM 操作,內部也會對 reflow 進行處理。reflow 是一個性能黑洞,很可能讓頁面的大多數元素進行重新布局。瀏覽器的運作流程: 渲染 -> tasks -> 渲染 -> tasks -> 渲染 -> ....這些 tasks 中有些我們可控,有些不可控,比如 setTimeout 什么時候執行不好說,它總是不準時;資源加載時間不可控。但一些JS我們可以控制,讓它們分派執行,tasks的時長不宜過長,這樣瀏覽器就有時間優化 JS 代碼與修正 reflow !總結一句,就是讓瀏覽器休息好,瀏覽器就能跑得更快。-- by 司徒正美 《React Fiber架構》
模式差異
這里有個 demo,上面是一個🌟圍繞☀️運轉的動畫,下面是 React 定時 setState 更新視圖,同步模式下,每次 setState 都會造成上面的動畫卡頓,而異步模式下的動畫就很流暢。
同步模式:
同步模式
異步模式:
異步模式
如何使用
雖然很多文章都在介紹 Concurrent 模式,但是這個能力并沒有真正上線,想要使用只能安裝實驗版本。也可以直接通過這個 cdn :https://unpkg.com/browse/react@0.0.0-experimental-94c0244ba/ 。
- npm install react@experimental react-dom@experimental
如果要開啟 Concurrent 模式,不能使用之前的 ReactDOM.render,需要替換成 ReactDOM.createRoot,而在實驗版本中,由于 API 不夠穩定, 需要通過 ReactDOM.unstable_createRoot來啟用 Concurrent 模式。
- import ReactDOM from 'react-dom';
- import App from './App';
- ReactDOM.unstable_createRoot(
- document.getElementById('root')
- ).render(<App />);
setState 合并更新
還記得之前 React15 的案例中,setTimeout 中進行 setState ,state.val 的值會立即發生變化。同樣的代碼,我們拿到 Concurrent 模式下運行一次。
- import React from 'react';
- class App extends React.Component {
- state = { val: 0 }
- componentDidMount() {
- setTimeout(() => {
- // 第一次調用
- this.setState({ val: this.state.val + 1 });
- console.log('first setState', this.state);
- // 第二次調用
- this.setState({ val: this.state.val + 1 });
- console.log('second setState', this.state);
- this.setState({ val: this.state.val + 1 }, () => {
- console.log(this.state);
- });
- });
- }
- render() {
- return <div> val: { this.state.val } </div>
- }
- }
- export default App;
控制臺輸出
說明在 Concurrent 模式下,即使脫離了 React 的生命周期,setState 依舊能夠合并更新。主要原因是 Concurrent 模式下,真正的更新操作被移到了下一個事件隊列中,類似于 Vue 的 nextTick。
更新機制變更
我們修改一下 demo,然后看下點擊按鈕之后的調用棧。
- import React from 'react';
- class App extends React.Component {
- state = { val: 0 }
- clickBtn() {
- this.setState({ val: this.state.val + 1 });
- }
- render() {
- return (<div>
- <button onClick={() => {this.clickBtn()}}>click add</button>
- <div>val: { this.state.val }</div>
- </div>)
- }
- }
- export default App;
調用棧
調用棧
onClick 觸發后,進行 setState 操作,然后調用 enquueState 方法,到這里看起來好像和之前的模式一樣,但是后面的操作基本都變了,因為 React 16 中已經沒有了事務一說。
- Component.setState() => enquueState() => scheduleUpdate() => scheduleCallback()
- => requestHostCallback(flushWork) => postMessage()
真正的異步化邏輯就在 requestHostCallback、postMessage 里面,這是 React 內部自己實現的一個調度器:https://github.com/facebook/react/blob/v16.13.1/packages/scheduler/index.js。
- function unstable_scheduleCallback(priorityLevel, calback) {
- var currentTime = getCurrentTime();
- var startTime = currentTime + delay;
- var newTask = {
- id: taskIdCounter++,
- startTime: startTime, // 任務開始時間
- expirationTime: expirationTime, // 任務終止時間
- priorityLevel: priorityLevel, // 調度優先級
- callback: callback, // 回調函數
- };
- if (startTime > currentTime) {
- // 超時處理,將任務放到 taskQueue,下一個時間片執行
- // 源碼中其實是 timerQueue,后續會有個操作將 timerQueue 的 task 轉移到 taskQueue
- push(taskQueue, newTask)
- } else {
- requestHostCallback(flushWork);
- }
- return newTask;
- }
requestHostCallback 的實現依賴于 MessageChannel,但是 MessageChannel 在這里并不是做消息通信用的,而是利用它的異步能力,給瀏覽器一個喘息的機會。說起 MessageChannel,Vue 2.5 的 nextTick 也有使用,但是 2.6 發布時又取消了。
vue@2.5
MessageChannel 會暴露兩個對象,port1 和 port2,port1 發送的消息能被 port2 接收,同樣 port2 發送的消息也能被 port1 接收,只是接收消息的時機會放到下一個 macroTask 中。
- var { port1, port2 } = new MessageChannel();
- // port1 接收 port2 的消息
- port1.onmessage = function (msg) { console.log('MessageChannel exec') }
- // port2 發送消息
- port2.postMessage(null)
- new Promise(r => r()).then(() => console.log('promise exec'))
- setTimeout(() => console.log('setTimeout exec'))
- console.log('start run')
執行結果
可以看到,port1 接收消息的時機比 Promise 所在的 microTask 要晚,但是早于 setTimeout。React 利用這個能力,給了瀏覽器一個喘息的時間,不至于被餓死。
還是之前的案例,同步更新時沒有給瀏覽器任何喘息,造成視圖的卡頓。
同步更新
異步更新時,拆分了時間片,給了瀏覽器充分的時間更新動畫。
異步更新
還是回到代碼層面,看看 React 是如何利用 MessageChannel 的。
- var isMessageLoopRunning = false; // 更新狀態
- var scheduledHostCallback = null; // 全局的回調
- var channel = new MessageChannel();
- var port = channel.port2;
- channel.port1.onmessage = function () {
- if (scheduledHostCallback !== null) {
- var currentTime = getCurrentTime();
- // 重置超時時間
- deadline = currentTime + yieldInterval;
- var hasTimeRemaining = true;
- // 執行 callback
- var hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
- if (!hasMoreWork) {
- // 已經沒有任務了,修改狀態
- isMessageLoopRunning = false;
- scheduledHostCallback = null;
- } else {
- // 還有任務,放到下個任務隊列執行,給瀏覽器喘息的機會
- port.postMessage(null);
- }
- } else {
- isMessageLoopRunning = false;
- }
- };
- requestHostCallback = function (callback) {
- // callback 掛載到 scheduledHostCallback
- scheduledHostCallback = callback;
- if (!isMessageLoopRunning) {
- isMessageLoopRunning = true;
- // 推送消息,下個隊列隊列調用 callback
- port.postMessage(null);
- }
- };
再看看之前傳入的 callback(flushWork),調用 workLoop,取出 taskQueue 中的任務執行。
- // 精簡了相當多的代碼
- function flushWork(hasTimeRemaining, initialTime) {
- return workLoop(hasTimeRemaining, initialTime);
- }
- function workLoop(hasTimeRemaining, initialTime) {
- var currentTime = initialTime;
- // scheduleCallback 進行了 taskQueue 的 push 操作
- // 這里是獲取之前時間片未執行的操作
- currentTask = peek(taskQueue);
- while (currentTask !== null) {
- if (currentTask.expirationTime > currentTime) {
- // 超時需要中斷任務
- break;
- }
- currentTask.callback(); // 執行任務回調
- currentTime = getCurrentTime(); // 重置當前時間
- currentTask = peek(taskQueue); // 獲取新的任務
- }
- // 如果當前任務不為空,表明是超時中斷,返回 true
- if (currentTask !== null) {
- return true;
- } else {
- return false;
- }
- }
可以看出,React 通過 expirationTime 來判斷是否超時,如果超時就把任務放到后面來執行。所以,異步模型中 setTimeout 里面進行 setState,只要當前時間片沒有結束(currentTime 小于 expirationTime),依舊可以將多個 setState 合并成一個。
接下來我們再做一個實驗,在 setTimeout 中連續進行 500 次的 setState,看看最后生效的次數。
- import React from 'react';
- class App extends React.Component {
- state = { val: 0 }
- clickBtn() {
- for (let i = 0; i < 500; i++) {
- setTimeout(() => {
- this.setState({ val: this.state.val + 1 });
- })
- }
- }
- render() {
- return (<div>
- <button onClick={() => {this.clickBtn()}}>click add</button>
- <div>val: { this.state.val }</div>
- </div>)
- }
- }
- export default App;
先看看同步模式下:
同步模式
再看看異步模式下:
異步模式
最后 setState 的次數是 81 次,表明這里的操作在 7 個時間片下進行的。
總結這篇文章前后花費時間比較久,看 React 的源碼確實很痛苦,因為之前沒有了解過,剛開始是看一些文章的分析,但是很多模棱兩可的地方,無奈只能在源碼上進行 debug,而且一次性看了 React 15、16 兩個版本的代碼,感覺腦子都有些不夠用了。
當然這篇文章只是簡單介紹了更新機制從同步到異步的過程,其實 React 16 的更新除了異步之外,在時間片的劃分、任務的優先級上還有很多細節,這些東西放到下篇文章來講,不知不覺又是一個新坑。
本文轉載自微信公眾號「更了不起的前端」,可以通過以下二維碼關注。轉載本文請聯系更了不起的前端公眾號。