Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从零开始实现一个React(四):异步的setState #7

Open
hujiulong opened this issue Apr 15, 2018 · 25 comments
Open

从零开始实现一个React(四):异步的setState #7

hujiulong opened this issue Apr 15, 2018 · 25 comments
Labels

Comments

@hujiulong
Copy link
Owner

hujiulong commented Apr 15, 2018

前言

上一篇文章中,我们实现了diff算法,性能有非常大的改进。但是文章末尾也指出了一个问题:按照目前的实现,每次调用setState都会触发更新,如果组件内执行这样一段代码:

for ( let i = 0; i < 100; i++ ) {
    this.setState( { num: this.state.num + 1 } );
}

那么执行这段代码会导致这个组件被重新渲染100次,这对性能是一个非常大的负担。

真正的React是怎么做的

React显然也遇到了这样的问题,所以针对setState做了一些特别的优化:React会将多个setState的调用合并成一个来执行,这意味着当调用setState时,state并不会立即更新,举个栗子:

class App extends Component {
    constructor() {
        super();
        this.state = {
            num: 0
        }
    }
    componentDidMount() {
        for ( let i = 0; i < 100; i++ ) {
            this.setState( { num: this.state.num + 1 } );
            console.log( this.state.num );    // 会输出什么?
        }
    }
    render() {
        return (
            <div className="App">
                <h1>{ this.state.num }</h1>
            </div>
        );
    }
}

我们定义了一个App组件,在组件挂载后,会循环100次,每次让this.state.num增加1,我们用真正的React来渲染这个组件,看看结果:

1

组件渲染的结果是1,并且在控制台中输出了100次0,说明每个循环中,拿到的state仍然是更新之前的。

这是React的优化手段,但是显然它也会在导致一些不符合直觉的问题(就如上面这个例子),所以针对这种情况,React给出了一种解决方案:setState接收的参数还可以是一个函数,在这个函数中可以拿先前的状态,并通过这个函数的返回值得到下一个状态。

我们可以通过这种方式来修正App组件:

componentDidMount() {
    for ( let i = 0; i < 100; i++ ) {
        this.setState( prevState => {
            console.log( prevState.num );
            return {
                num: prevState.num + 1
            }
        } );
    }
}

这种用法是不是很像数组的reduce方法?

现在来看看App组件的渲染结果:
1
现在终于能得到我们想要的结果了。

所以,这篇文章的目标也明确了,我们要实现以下两个功能

  1. 异步更新state,将短时间内的多个setState合并成一个
  2. 为了解决异步更新导致的问题,增加另一种形式的setState:接受一个函数作为参数,在函数中可以得到前一个状态并返回下一个状态

合并setState

回顾一下第二篇文章中对setState的实现:

setState( stateChange ) {
    Object.assign( this.state, stateChange );
    renderComponent( this );
}

这种实现,每次调用setState都会更新state并马上渲染一次。

setState队列

为了合并setState,我们需要一个队列来保存每次setState的数据,然后在一段时间后,清空这个队列并渲染组件。

队列是一种数据结构,它的特点是“先进先出”,可以通过js数组的push和shift方法模拟
然后需要定义一个”入队“的方法,用来将更新添加进队列。

const queue = [];
function enqueueSetState( stateChange, component ) {
    queue.push( {
        stateChange,
        component
    } );
}

然后修改组件的setState方法,不再直接更新state和渲染组件,而是添加进更新队列。

setState( stateChange ) {
    enqueueSetState( stateChange, this );
}

现在队列是有了,怎么清空队列并渲染组件呢?

清空队列

我们定义一个flush方法,它的作用就是清空队列

function flush() {
    let item;
    // 遍历
    while( item = setStateQueue.shift() ) {

        const { stateChange, component } = item;

        // 如果没有prevState,则将当前的state作为初始的prevState
        if ( !component.prevState ) {
            component.prevState = Object.assign( {}, component.state );
        }

        // 如果stateChange是一个方法,也就是setState的第二种形式
        if ( typeof stateChange === 'function' ) {
            Object.assign( component.state, stateChange( component.prevState, component.props ) );
        } else {
            // 如果stateChange是一个对象,则直接合并到setState中
            Object.assign( component.state, stateChange );
        }

        component.prevState = component.state;

    }
}

这只是实现了state的更新,我们还没有渲染组件。渲染组件不能在遍历队列时进行,因为同一个组件可能会多次添加到队列中,我们需要另一个队列保存所有组件,不同之处是,这个队列内不会有重复的组件。

我们在enqueueSetState时,就可以做这件事

const queue = [];
const renderQueue = [];
function enqueueSetState( stateChange, component ) {
    queue.push( {
        stateChange,
        component
    } );
    // 如果renderQueue里没有当前组件,则添加到队列中
    if ( !renderQueue.some( item => item === component ) ) {
        renderQueue.push( component );
    }
}

在flush方法中,我们还需要遍历renderQueue,来渲染每一个组件

function flush() {
    let item, component;
    while( item = queue.shift() ) {
        // ...
    }
    // 渲染每一个组件
    while( component = renderQueue.shift() ) {
        renderComponent( component );
    }

}

延迟执行

现在还有一件最重要的事情:什么时候执行flush方法。
我们需要合并一段时间内所有的setState,也就是在一段时间后才执行flush方法来清空队列,关键是这个“一段时间“怎么决定。

一个比较好的做法是利用js的事件队列机制。

先来看这样一段代码:

setTimeout( () => {
    console.log( 2 );
}, 0 );
Promise.resolve().then( () => console.log( 1 ) );
console.log( 3 );

你可以打开浏览器的调试工具运行一下,它们打印的结果是:

3
1
2

具体的原理可以看阮一峰的这篇文章,这里就不再赘述了。

我们可以利用事件队列,让flush在所有同步任务后执行

function enqueueSetState( stateChange, component ) {
    // 如果queue的长度是0,也就是在上次flush执行之后第一次往队列里添加
    if ( queue.length === 0 ) {
        defer( flush );
    }
    queue.push( {
        stateChange,
        component
    } );
    if ( !renderQueue.some( item => item === component ) ) {
        renderQueue.push( component );
    }
}

定义defer方法,利用刚才题目中出现的Promise.resolve

function defer( fn ) {
    return Promise.resolve().then( fn );
}

这样在一次“事件循环“中,最多只会执行一次flush了,在这个“事件循环”中,所有的setState都会被合并,并只渲染一次组件。

别的延迟执行方法

除了用Promise.resolve().then( fn ),我们也可以用上文中提到的setTimeout( fn, 0 ),setTimeout的时间也可以是别的值,例如16毫秒。

16毫秒的间隔在一秒内大概可以执行60次,也就是60帧,人眼每秒只能捕获60幅画面

另外也可以用requestAnimationFrame或者requestIdleCallback

function defer( fn ) {
    return requestAnimationFrame( fn );
}

试试效果

就试试渲染上文中用React渲染的那两个例子:

class App extends Component {
    constructor() {
        super();
        this.state = {
            num: 0
        }
    }
    componentDidMount() {
        for ( let i = 0; i < 100; i++ ) {
            this.setState( { num: this.state.num + 1 } );
            console.log( this.state.num ); 
        }
    }
    render() {
        return (
            <div className="App">
                <h1>{ this.state.num }</h1>
            </div>
        );
    }
}

效果和React完全一样
1
同样,用第二种方式调用setState:

componentDidMount() {
    for ( let i = 0; i < 100; i++ ) {
        this.setState( prevState => {
            console.log( prevState.num );
            return {
                num: prevState.num + 1
            }
        } );
    }
}

结果也完全一样:
1

后话

在这篇文章中,我们又实现了一个很重要的优化:合并短时间内的多次setState,异步更新state。
到这里我们已经实现了React的大部分核心功能和优化手段了,所以这篇文章也是这个系列的最后一篇了。

这篇文章的所有代码都在这里:https://github.com/hujiulong/simple-react/tree/chapter-4

从零开始实现React系列

React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

整个系列大概会有四篇左右,我每周会更新一到两篇,我会第一时间在github上更新,有问题需要探讨也请在github上回复我~

博客地址: https://github.com/hujiulong/blog
关注点star,订阅点watch

上一篇文章

从零开始实现一个React(三):diff算法

@Sunshine168
Copy link

终于等到这篇~ 板凳一下 setState 之所以是异步 最大原因是为了性能提升吗~

@hujiulong
Copy link
Owner Author

@Sunshine168 是的,不能每次setState都触发更新,所以是异步的。

@hujiulong
Copy link
Owner Author

顺便一提,上面的那个例子react和preact跑的结果不一样,preact在每次setState都会立即更新state(但不会立即渲染),而react的行为就和这篇文章的实现一样

@aszx87410
Copy link

剛好看到這系列文章,寫得很不錯,持續關注!

順便分享一下 React Issue 的這篇,在探討為什麼 setState 是異步的:facebook/react#11527

@hujiulong
Copy link
Owner Author

hujiulong commented Apr 18, 2018

@aszx87410 感谢分享,对于为什么state更新要是异步的我首先也没想太清楚,因为仅从效率考虑,完全可以更新state是同步,渲染是异步的,preact就是这样做的。看完以后感觉清楚了很多。

@liuliuboy
Copy link

请教一下,react路由可以讲讲吗?

@blockmood
Copy link

@hujiulong 程墨 有个专栏讲解了state更新为什么要是异步的,可以去看看,讲的非常清楚。

@p2227
Copy link

p2227 commented Apr 21, 2018

这里面关键还是用了Promise进行异步呢,跟react里面的实现还是不一样吧,有尝试过像react那样用变量锁(isBatchingUpdates)进行异步更新吗

@hujiulong
Copy link
Owner Author

@p2227 没有说和React的实现一样呀,只是从使用的角度去模拟React,细节上会有很大的区别。

@ShawnWu20147
Copy link

如果setState一直被调用(理想情况),那么是不是flush就没有机会被执行?我看code的感觉是 这里的实现中setState是同步的。

@xinre
Copy link

xinre commented Mar 5, 2019

666

@ghost
Copy link

ghost commented Apr 6, 2019

相同组件的一直setState没有被处理。我看preact里的操作是将未更新的state,全部放到_nextState中,然后清空队列的时候,一口气更新

@ghost
Copy link

ghost commented Apr 6, 2019

相同组件的一直setState没有被处理。我看preact里的操作是将未更新的state,全部放到_nextState中,然后清空队列的时候,一口气更新

我看错了,你用两个不同的队列问题,一个负责更新state,一个负责渲染组件

@LuoQaxa
Copy link

LuoQaxa commented Apr 12, 2019

花了两天时间通过模仿代码的方式研读完了四篇文章,收获很大,react的基本原理有了一个大概的认识,谢谢博主!

@myyixian
Copy link

赞一个

@zhuyongbo100
Copy link

谢谢博主。

@sihai00
Copy link

sihai00 commented Aug 6, 2019

谢谢博主!赞

@fengyinchao
Copy link

@hujiulong 期待再加一篇 Fiber 的实现

@leviding
Copy link

期待博主总结 Fiber!

@wuming-lzd
Copy link

wuming-lzd commented Jun 15, 2020

@hujiulong 非常感谢博主的文章,写的非常好,
但是我有一个疑问, renderQueue 这个变量仅仅是用来保存 当前组件component 的,在自定义组件中调用setState()的时候,始终传入的都是当前组件吧,
setState(stateChange, callback) { enqueueSetState(stateChange, this); //this始终都是 component }

并且这个数组的长度始终都是1吧,我看代码中,每次都是进行了过滤,
if (!renderQueue.some(item => item === component)) { renderQueue.push(component); }
要想要在flush中使用的话,在enqueueSetState调用flush的时候,直接传入flush不就可以了,为啥非要使用一个数组来保存呢?
export function enqueueSetState(stateChange, component) { if (setStateQueue.length === 0) { defer(flush(component)); } ... } function flush(component) { return function() { ... renderComponent(component); } }

希望博主能够帮我解答下,非常感谢

@hujiulong
Copy link
Owner Author

@Qingchundejiaobu 并不是每个组件都会有一个renderQueue,只有一个全局的renderQueue

@wuming-lzd
Copy link

wuming-lzd commented Jun 18, 2020

@Qingchundejiaobu 并不是每个组件都会有一个renderQueue,只有一个全局的renderQueue

哦,明白了,我理解错了;非常感谢;
不知道博主会不会写react源码系列

@Harres-hub
Copy link

Harres-hub commented Aug 30, 2020

您好,有个小小的疑问,您在自定义的setState中enqueueSetState之后写了一次renderComponent,但是在后来的flush中又写了一次renderComponent,并且在enqueueSetState中defer去执行flush,那是不是进行了重复两次的renderComponent了呢?是不是可以去掉setState中的renderComponent?也就是说setState=(stateChange)=>{enqueueSetState(stateChange,this)}; @hujiulong

@Nkjv2
Copy link

Nkjv2 commented Mar 13, 2022

给大佬点赞,很早之前看过一次,这次再次重温还是收获良多啊!

@leonyh7
Copy link

leonyh7 commented Mar 13, 2022 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests