從零開始實現一個React(四):異步的setState

前言

上一篇文章中,咱們實現了diff算法,性能有很是大的改進。可是文章末尾也指出了一個問題:按照目前的實現,每次調用setState都會觸發更新,若是組件內執行這樣一段代碼:html

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

那麼執行這段代碼會致使這個組件被從新渲染100次,這對性能是一個很是大的負擔。前端

真正的React是怎麼作的

React顯然也遇到了這樣的問題,因此針對setState作了一些特別的優化:React會將多個setState的調用合併成一個來執行,這意味着當調用setState時,state並不會當即更新,舉個栗子: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>
        );
    }
}

咱們定義了一個App組件,在組件掛載後,會循環100次,每次讓this.state.num增長1,咱們用真正的React來渲染這個組件,看看結果:git

38770037-3587b81c-403f-11e8-8f99-4f8a4427e205.png

組件渲染的結果是1,而且在控制檯中輸出了100次0,說明每一個循環中,拿到的state仍然是更新以前的。github

這是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組件的渲染結果:
38770164-fbeef622-4040-11e8-9680-958394f9bb9e.png
如今終於能獲得咱們想要的結果了。瀏覽器

因此,這篇文章的目標也明確了,咱們要實現如下兩個功能數據結構

  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方法

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徹底同樣
38770037-3587b81c-403f-11e8-8f99-4f8a4427e205.png
一樣,用第二種方式調用setState:

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

結果也徹底同樣:
38770164-fbeef622-4040-11e8-9680-958394f9bb9e.png

後話

在這篇文章中,咱們又實現了一個很重要的優化:合併短期內的屢次setState,異步更新state。
到這裏咱們已經實現了React的大部分核心功能和優化手段了,因此這篇文章也是這個系列的最後一篇了。

這篇文章的全部代碼都在這裏:https://github.com/hujiulong/...

從零開始實現React系列

React是前端最受歡迎的框架之一,解讀其源碼的文章很是多,可是我想從另外一個角度去解讀React:從零開始實現一個React,從API層面實現React的大部分功能,在這個過程當中去探索爲何有虛擬DOM、diff、爲何setState這樣設計等問題。

整個系列大概會有四篇左右,我每週會更新一到兩篇,我會第一時間在github上更新,有問題須要探討也請在github上回復我~

博客地址: https://github.com/hujiulong/...
關注點star,訂閱點watch

上一篇文章

從零開始實現一個React(三):diff算法

相關文章
相關標籤/搜索