iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 10
1
Modern Web

I Want To Know React系列 第 10

I Want To Know React - State 內部運作原理

回顧 State

在上兩個章節:初探 StateClass Component State 語法中,我們了解了 state 的概念與用法,先來重新複習一下吧!

State 代表 React component 的狀態,會是一個完全由創建的 component 維護 / 控制的 JavaScript object,常用於存放 component 自己需要的資料。

操作 State 分為兩個階段:

  • 初始 state:在 class component 中初始 state 的語法是在 constructor 中賦值,並將 this.state 賦值(assign)為任意的 JavaScript object 即可。
  • 更新 state:Class component 更新 state 的語法則是使用 React 的 setState API。

到目前為止,這個段落回顧了 state 的基本介紹與使用方式,接著就深入的抽絲剝繭吧!

正確使用 State

接下來將會說明使用 state 的正確方式,有些內容在上一篇中已經介紹過,但在這段落中,我們將會補充更多的細節。

必須用 setState 修改 state

React 的 state 必須要用 setState 這個 API 才可以修改。除了初始化以外,直接賦值與修改 state 是錯誤的行為,如下所示:

// Wrong
this.state.comment = 'Hello';

// Correct
this.setState({comment: 'Hello'});

也許會有讀者好奇為何 React 必須要呼叫 setState 才能以更新 state 的。這是因為 React 無法自動偵測 State object 何時被改動與賦值,因此才規定一定要使用 React 提供的 API 來觸發更新。

  • ?小提醒:Angular 與 Vue 用特殊方法偵測到資料改動,因此不需要類似的 setter API。
    • Angular 使用 ZoneJS,在非同步事件結束時,會去自動偵測是否有資料改動,因此不需要 setter API。
    • Vue 則使用 Observer 的方式,先訂閱物件,當物件被變動時就會自動被通知,因此不需要 setter API。

React 可能會非同步更新 state

另一個需要注意的地方是,setState 可能會非同步的更新 state。這是因為 React 因為要增進效能,所以會把一段時間內的多個 setState 批次(Batch)執行。也就是,this.setState 後立即拿 this.state 取得的值有機會是舊的。

再繼續延伸下去,因為 state 有機會被當作子層 component 的 props 傳入(換句話說就是上層 component 的 state 可能會等於下層 component 的 props),因此相當於 props 的內容也是有機會被非同步更新的,這代表 this.props 也不能直接拿來 setState

這也是為何我們必須在需要當前 state 或 props 的值時使用 setState updater 參數的原因:

// Wrong
this.setState({
  counter: this.state.counter + this.props.increment,
});

// Correct
this.setState((state, props) => ({
  counter: state.counter + props.increment,
}));

新的 state 會與舊的 state 合併

最後,更新 state 時,新的 state object 中的屬性會與舊的 state object 的屬性淺層合併(Shallow merge),意思是只要把要更新的資料帶入新的 state object 就好,不需要把沒有更動的資料也一併帶入。舉例來說:

const fetchPosts = () => { /* ... */ };
const fetchComments = () => { /* ... */ };

class Articles extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      posts: [],
      comments: [],
    };
  }

  componentDidMount() {
    fetchPosts().then((response) => {
      this.setState({
        posts: response.posts,
      });
    });

    fetchComments().then((response) => {
      this.setState({
        comments: response.comments,
      });
    });
  }
}

範例中,更新 this.setState({ posts }) 時會把 posts 的內容合併為舊有 state object,而 comments 屬性則原封不動,反之亦然。

必須知道的是,由於 setState 只會淺層合併(Shallow merge)posts 內不會再進行合併,因此就算原本的 posts 內有內容也會被完全覆蓋為 response.posts

State 內部運作步驟

以上,我們已經學習完 state 的基本知識與正確用法了,是時候鑽進內部運作步驟段落了!

我們將以時鐘 component 的例子來分析 state 的內部運作。

  1. 首先,開發者編寫了一段 React 程式來實作一個小時鐘。

    class Clock extends React.Component {
      constructor(props) {
        super(props);
        this.state = { date: new Date() };
      }
    
      componentDidMount() {
        this.timerID = setInterval(
          () => this.tick(),
          1000
        );
      }
    
      componentWillUnmount() {
        clearInterval(this.timerID);
      }
    
      tick() {
        this.setState((state, props) => ({
          date: new Date(state.date.getTime() + 1000)
        }));
      }
    
      render() {
        return (
          <div>
            <h1>Hello, world!</h1>
            <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
          </div>
        );
      }
    }
    
    ReactDOM.render(
      <Clock />,
      document.getElementById('root')
    );
    
    • ?小提醒:this.setState 中使用 new Date(state.date.getTime() + 1000) 而非 new Date() 只是為了方便說明機制而已。如果往後有做時鐘的需求,還是用 new Date() 來取得最正確的時間比較好。
  2. 接著,在 bundle 時期 Babel 會先把 JSX 轉譯成 React.createElement()

    ReactDOM.render(React.createElement(Clock, null), document.getElementById('root'));
    
  3. 執行階段,Clock React element 創建出來後被傳進 ReactDOM.render() 執行。

  4. React 接著呼叫 Clock component 的 constructor

    constructor 中會去初始化 this.statedate 內容為目前的時間,以讓 React 知道 Clock 要顯示的初始狀態為何,以及讓 component 知道之後的狀態要基於什麼資料更新。

    constructor(props) {
      super(props);
      this.state = { date: new Date() };
    }
    
  5. Clock 初始化後,React 便會去執行 Clock component 的 render 函式,建制出 <div>...</div> React element。其中,this.state.date 的內容因為有被放在 expression 中,所以也會是 React element 的一部分。

    render() {
      return (
        <div>
          <h1>Hello, world!</h1>
          <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
        </div>
      );
    }
    
  6. React 會把 Virtual DOM snapshot 產出來,試圖跟上一個的 Virtual DOM snapshot 做比較,但因為這是第一次產出 Virtual DOM snapshot,並沒有上一刻 Virtual DOM snapshot 可以做比較,所以 React 就會把所有的內容都 render 到實際的畫面(DOM)上。

  7. Clock 被成功渲染到 DOM 上之後,React 就會呼叫 lifecycle 方法componentDidMount,在這個函式中,Clock component 會 setInterval,讓程式每一秒都觸發一次 tick()

    componentDidMount() {
      this.timerID = setInterval(
        () => this.tick(),
        1000
      );
    }
    
  8. 在每秒觸發一次的 tick() 中,Clock component 會去執行 setStatesetState 中使用的是 function 參數,React 會將當前的 state 與 props 傳到這個 function 中,而 Clock component 就會把 state 更新為 目前時間 + 1000 毫秒(就相當於當前時間)作為新的狀態。

    tick() {
      this.setState((state, props) => ({
        date: new Date(state.date.getTime() + 1000)
      }));
    }
    
  9. React setState 後,知道了 state 已改變,因此重新觸發一次 render

    同樣的,Virtual DOM snapshot 會被產生出來,並跟上一個的 Virtual DOM snapshot 做比較。

    這次,React 發現 snapshot 中只有 h2 的內容:{this.state.date.toLocaleTimeString()} 改動了,因此就只將這部分的內容更新到 DOM 上。如下圖所示:

    react-clock

  10. 最後,如果某刻 Clock component 要被移除時,React 就會呼叫 lifecycle 方法componentWillUnmount 把之前設定的 setInterval 清掉。

    componentWillUnmount() {
      clearInterval(this.timerID);
    }
    

有興趣的讀者也可以到 CodePen 中親自試試。

State 特性

現在,對於 React 內部是怎麼處理 state 的已經有了進階的認識了,最後,就再近一步說明一些 state 的特性與要留心的地方。

State 是被封裝的

State 是封裝在創建自己的 React component instance 中的。任何的其他 Element(不論是上層、下層、還是同層)都無權改變它也不需要知道它的存在。

為了證明 state 是封裝的,稍微修改一下 State 內部運作步驟 的時鐘範例。我們把三個 Clock React component instance 包裹進新的 component App 中再交給 React render,來看看狀態是否為各個 React component instance 獨立所有吧:

function App() {
  return (
    <div>
      <Clock />
      <Clock />
      <Clock />
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));

範例中創建了三個 Clock 的 React component instance,從畫面上可以看到,每個 Clock component instance state 的累加都是獨立的,不會出現任何 Clock element 的 state 一次被加了兩秒或三秒的問題,這也證明了 state 的獨立性與封裝性。

讀者也可以從 CodePen 查看結果。

React App 是單向資料流(Unidirectional data flow)

因為 state 的封裝性,一個 component 也無法知道另一個 component 是有狀態的(Stateful)還是無狀態的(Stateless)。對於 React 來說,component 是否有 state 只是實作細節而已,這也代表一個 stateless 的 component 中可以有一個 stateful 的 component,反之亦然。

具下面的例子來說:

// Inside a class component
<FormattedDate date={this.state.date} />
<FormattedDate date={new Date()} />
// Will never know where the props come from, it just know the data just came.
function FormattedDate(props) {
  return <h2>It is {props.date.toLocaleTimeString()}.</h2>;
}

FormattedDate 永遠不知道資料是來自 state 還是來自任意的變數,它只會知道有資料傳入了而已。

下層的 component 只單純接收上層 component 傳下去的值,且上層 component 的 state 也只能影響到它之下的 component。這種資料由上往下(top-down)的概念就像水流一樣,我們把它稱為單向資料流(Unidirectional data flow)。

可以想像 React 元件樹充滿了資料。資料是由樹的上方往下流入,每次多了一個 state 都像是多了一個額外的資料流分支,這個分支也會是樹的一部分,可以變成下層 component 的 props 繼續往下傳遞資料。

每次改動 state 都會觸發重新渲染(Re-render)

setState 執行後,React 會重新觸發該 component 的 render 函式以渲染出新的畫面,這代表每次的狀態改動都會重新執行 render

相較於其他框架都是在資料面上做變更偵測,React 則選擇做畫面的變更偵測(Virtual DOM)。這是因為 React 認為資料面的變更偵測遠比畫面層面的偵測來得複雜,使用偏向畫面向的偵測可以讓程式變得可測試,也能有效的減少 bug。更詳細的說明可參考 Peter Hunt 的演講

不過每次都會觸發 render 也代表開發時需要更注意 setState 的次數以及 render 的效能。不當的操作 setStaterender 將對效能有顯著的負面影響。

小結

在這個章節中,我們學習到了正確使用 state 的方式,包括:

  • 必須用 setState 修改 state
  • React 可能會非同步更新 state
  • 新的 state 會與舊的 state 合併

另外,我們也學習了 state 的內部運作原理以及其特性:

  • State 是被封裝的
  • React App 是單向資料流(Unidirectional data flow)
  • 每次改動 state 都會觸發重新渲染(Re-render)

下個章節中,我們將開始介紹 component 的 Lifecycle。

參考資料


上一篇
I Want To Know React - Class Component State 語法
下一篇
I Want To Know React - 初探 Lifecycle
系列文
I Want To Know React30

1 則留言

0
Dylan
iT邦新手 5 級 ‧ 2020-11-08 16:08:01

這邊是不是應該這樣

// Wrong
this.setState({
  counter: this.state.counter + this.props.increment,
});

// Correct
this.setState((state, props) => ({
  counter: state.counter + props.increment,
}));

感謝您的勘誤,已修正!/images/emoticon/emoticon35.gif

我要留言

立即登入留言