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

weixin_34253539發表於2018-04-17

前言

上一篇文章中,我們實現了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來渲染這個元件,看看結果:

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

元件渲染的結果是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元件的渲染結果:
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演算法

相關文章