React 架构的演变 - 从同步到异步


title: React 架构的演变 - 从同步到异步html

date: 2020/09/23前端

categories:vue

  • 前端

tags:react

  • 前端框架
  • JavaScript
  • React

写这篇文章的目的,主要是想弄懂 React 最新的 fiber 架构究竟是什么东西,可是看了网上的不少文章,要不模棱两可,要不就是一顿复制粘贴,根本看不懂,因而开始认真钻研源码。钻研过程当中,发现我想得太简单了,React 源码的复杂程度远超个人想象,因而打算分几个模块了剖析,今天先讲一讲 React 的更新策略从同步变为异步的演变过程。git

从 setState 提及

React 16 之因此要进行一次大重构,是由于 React 以前的版本有一些不可避免的缺陷,一些更新操做,须要由同步改为异步。因此咱们先聊聊 React 15 是如何进行一次 setState 的。github

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;

熟悉 React 的同窗应该知道,在 React 的生命周期内,屡次 setState 会被合并成一次,这里虽然连续进行了三次 setState,state.val 的值实际上只从新计算了一次。数据库

render结果

每次 setState 以后,当即获取 state 会发现并无更新,只有在 setState 的回调函数内才能拿到最新的结果,这点经过咱们在控制台输出的结果就能够证明。npm

控制台输出

网上有不少文章称 setState 是『异步操做』,因此致使 setState 以后并不能获取到最新值,其实这个观点是错误的。setState 是一次同步操做,只是每次操做以后并无当即执行,而是将 setState 进行了缓存,mount 流程结束或事件操做结束,才会拿出全部的 state 进行一次计算。若是 setState 脱离了 React 的生命周期或者 React 提供的事件流,setState 以后就能当即拿到结果。数组

咱们修改上面的代码,将 setState 放入 setTimeout 中,在下一个任务队列进行执行。promise

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 对象,每一个对象包含两个属性:initializeclose。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);

}

}

};

启动事务能够拆分红三步来看:

  1. 先执行 wrapper 的 initialize,此时的 initialize 都是一些空函数,能够直接跳过;
  2. 而后执行 callback(也就是 enqueueUpdate),执行 enqueueUpdate 时,因为已经进入了更新状态,batchingStrategy.isBatchingUpdates 被修改为了 true,因此最后仍是会把 component 放入脏组件队列,等待更新;
  3. 后面执行的两个 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.isBatchingUpdatesfalse 会开启一个事务,将组件放入脏组件队列,最后进行更新操做,并且这里都是同步操做。讲道理,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()

真正的异步化逻辑就在 requestHostCallbackpostMessage 里面,这是 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 会暴露两个对象,port1port2port1 发送的消息能被 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 次,代表这里的操做在 81 个时间片下进行的,每一个时间片更新了一次。

总结

这篇文章先后花费时间比较久,看 React 的源码确实很痛苦,由于以前没有了解过,刚开始是看一些文章的分析,可是不少模棱两可的地方,无奈只能在源码上进行 debug,并且一次性看了 React 1五、16 两个版本的代码,感受脑子都有些不够用了。

固然这篇文章只是简单介绍了更新机制从同步到异步的过程,其实 React 16 的更新除了异步以外,在时间片的划分、任务的优先级上还有不少细节,这些东西放到下篇文章来说,不知不觉又是一个新坑。

image