React專題:可變狀態

馬蹄疾發表於2019-01-16

本文是『horseshoe·React專題』系列文章之一,後續會有更多專題推出

來我的 GitHub repo 閱讀完整的專題文章

來我的 個人部落格 獲得無與倫比的閱讀體驗

React使用一個特殊的物件this.state來管理元件內部的狀態。

然後開發者就可以通過描述狀態來控制UI的表達。

如何描述狀態呢?

一般我們會在constructor生命週期鉤子初始化狀態。

import React, { Component } from `react`;

class App extends Component {
    constructor(props) {
        super(props);
        this.state = { name: ``, star: 0 };
    }
}

export default App;
複製程式碼

也可以直接用屬性初始化器的寫法,看起來更加簡潔。

然後通過this.setState()來改變狀態。

import React, { Component } from `react`;

class App extends Component {
    state = { name: ``, star: 0 };
    
    componentDidMount() {
        this.setState({ name: `react`, star: 1 });
    }
}

export default App;
複製程式碼

this.state

首先,改變狀態有特殊的門路

開發者不能直接改變this.state的屬性,而是要通過this.setState方法。

為什麼要這樣設計?

可能是為了更加語義化吧,開發者清楚自己在更新狀態,而不是像Vue那樣改變於無形。

不過別急,我為正在閱讀的你準備了一個炸彈:

猜猜下面例子最終渲染出來的star是多少?

import React, { Component } from `react`;

class App extends Component {
    state = { star: 0 };
    
    componentDidMount() {
        this.state.star = 1000;
        this.setState(prevState => ({ star: prevState.star + 1 }));
    }
    
    // componentDidMount() {
        // this.setState(prevState => ({ star: prevState.star + 1 }));
        // this.state.star = 1000;
    // }
    
    // componentDidMount() {
        // this.state.star = 1000;
        // this.setState({ star: this.state.star + 1 });
    // }
    
    // componentDidMount() {
        // this.setState({ star: this.state.star + 1 });
        // this.state.star = 1000;
    // }
}

export default App;
複製程式碼

答案是1001。

誒,不是說不能直接改變this.state的屬性麼?

聽我講,首先,this.state並不是一個不可變物件,你(非得較勁的話)是可以直接改變它的屬性的。但是它不會觸發render生命週期鉤子,也就不會渲染到UI上。

不過,既然你確實改變了它的值,如果之後呼叫了this.setState()的話,它會在你直接改變的值的基礎上再做更新。

所以呀少年,要想不懵逼,得靠我們自己的程式碼規範。

至於註釋的部分,只是為了說明順序問題。

第一部分註釋渲染出來的star是1001。因為回撥會首先計算star的值,而這時候star的值是1000。

第二部分註釋渲染出來的star是1001。這很好理解。

第三部分註釋渲染出來的star是1。這也好理解,這個時候star的值還是0。

其次,狀態更新會合並處理

大家也看到了,我們可以每次更新部分狀態。

新狀態並不會覆蓋舊狀態,而是將已有的屬性進行合併操作。如果舊狀態沒有該屬性,則新建。

這類似於Object.assign操作。

而且合併是淺合併。

只有第一層的屬性才會合併,更深層的屬性都會覆蓋。

import React, { Component } from `react`;

class App extends Component {
    state = { userInfo: { name: ``, age: 0 } };
    
    componentDidMount() {
        this.setState({ userInfo: { age: 13 } });
    }
}

export default App;
複製程式碼

最後,可以有不是狀態的狀態

如果你需要儲存某種狀態,但是不希望在狀態更新的時候觸發render生命週期鉤子,那麼完全可以直接儲存到例項的屬性上,只要不是this.state的屬性。使用起來還是很自由的。

非同步更新

什麼叫非同步更新?

非同步更新說的直白點就是批量更新。

它不是真正的非同步,只是React有意識的將狀態攢在一起批量更新。

React元件有自己的生命週期,在某兩個生命週期節點之間做的所有的狀態更新,React會將它們合併,而不是立即觸發UI渲染,直到某個節點才會將它們合併的值批量更新。

以下,元件更新之後this.state.star的值是1。

import React, { Component } from `react`;

class App extends Component {
    state = { star: 0 };
    
    componentDidMount() {
        this.setState({ star: this.state.star + 1 });
        this.setState({ star: this.state.star + 1 });
        this.setState({ star: this.state.star + 1 });
    }
}

export default App;
複製程式碼

因為這些狀態改變的操作都是在元件掛載之後、元件更新之前,所以實際上它們並沒有立即生效。

this.state.star的值一直是0,儘管狀態被多次操作,它得到的值一直是1,因此合併之後this.state.star的還是1,並不是我們直覺以為的3。

為什麼要非同步更新?

因為this.setState()會觸發render生命週期鉤子,也就會執行元件的diff演算法。如果每次setState都要走這一套流程,不僅浪費效能,而且是完全沒有必要的。

所以React選擇了在一定階段內批量更新。

還是以生命週期為界,掛載之前的所有setState批量更新,掛載之後到更新之前的所有setState批量更新,每次更新間隙的所有setState批量更新。

非非同步情況

再來看一種情況:

猜猜最終渲染出來的star是多少?

import React, { Component } from `react`;

class App extends Component {
    state = { star: 0 };
    timer = null;
    
    componentDidMount() {
        this.timer = setTimeout(() => {
            this.setState({ num: this.state.star + 1 });
            this.setState({ num: this.state.star + 1 });
            this.setState({ num: this.state.star + 1 });
        }, 5000);
    }
    
    componentWillUnmount() {
        clearTimeout(this.timer);
    }
}

export default App;
複製程式碼

答案是3。

臥槽!

說實話,這裡我也沒想明白。

我在React倉庫的Issues裡提過這個情況,這是React主創之一Dan Abramov的回答:

setState is currently synchronous outside of event handlers. That will likely change in the future.

Dan Abramov所說的event handlers應該指的是React合成事件回撥和生命週期鉤子。

我的理解,因為只有這些方法才能回應事件,所以它們之中的狀態更新是批量的。但是它們之中的非同步程式碼裡有狀態更新操作,React就不會批量更新,而是符合直覺的樣子。

我們看下面的例子,正常的重複setState只會觸發一次更新,但是http請求回撥中的重複setState卻會多次觸發更新,看來非同步的setState不在React掌控之內。

import React, { Component } from `react`;

class App extends Component {
    state = { star: 0 };
    
    componentDidMount() {
        fetch(`https://api.github.com/users/veedrin/repos`)
            .then(res => res.json())
            .then(res => {
                console.log(res);
                this.setState({ star: this.state.star + 1 });
                this.setState({ star: this.state.star + 1 });
                this.setState({ star: this.state.star + 1 });
            });
    }
}

export default App;
複製程式碼

還有一種情況就是原生的事件回撥,比如document上的事件回撥,也不是非同步的。

總結一下:所謂的非同步只是批量更新而已。真正非同步回撥和原生事件回撥中的setState不是批量更新的。

不過,Dan Abramov早就提到過,會在將來的某個版本(可能是17大版本)管理所有的setState,不管是不是在所謂的event handlers之內。

React的設計有一種簡潔之美,從這種對待開發者反饋的態度可見一斑。

回撥

既然this.setState()的設計不符合直覺,React早就為開發者提供瞭解決方案。

this.setState()的引數既可以是一個物件,也可以是一個回撥函式。函式返回的物件就是要更新的狀態。

回撥函式提供了兩個引數,第一個引數就是計算過的state物件,即便這時還沒有渲染,得到的依然是符合直覺的計算過的值。同時,貼心的React還為開發者提供了第二個引數,雖然並沒有什麼卵用。

以下,元件更新之後this.state.star的值是3。

有一個小細節:箭頭函式如果直接返回一個物件,要包裹一層小括號,以區別塊級作用域。

import React, { Component } from `react`;

class App extends Component {
    state = { star: 0 };
    
    componentDidMount() {
        this.setState((prevState, prevProps) => ({ star: prevState.star + 1 }));
        this.setState((prevState, prevProps) => ({ star: prevState.star + 1 }));
        this.setState((prevState, prevProps) => ({ star: prevState.star + 1 }));
    }
}

export default App;
複製程式碼

chaos

總之呢,React更新狀態的設計到處都是坑。

大家對React吐槽最多的點是什麼呢?

圈外人吐槽JSX。

圈內人吐槽this.setState

期盼React給開發者一個不令人困惑的狀態更新API吧。

React專題一覽

什麼是UI

JSX

可變狀態

不可變屬性

生命週期

元件

事件

操作DOM

抽象UI

相關文章