跳到主要內容
黯羽輕揚每天積累一點點

從 componentWillReceiveProps 說起

免費2018-08-26#JS#React Controlled Component#React Anti-Pattern#When to use getDerivedStateFromProps#React受控组件#React反模式

重新理解受控組件與不受控組件

一。對 componentWillReceiveProps 的誤解

componentWillReceiveProps 通常被認為是 propsWillChange,我們確實也通過它來判斷 props change。但實際上,componentWillReceiveProps 在每次 rerender 時都會調用,無論 props 變了沒

class A extends React.Component {
  render() {
    return <div>Hello {this.props.name}</div>;
  }

  componentWillReceiveProps(nextProps) {
    console.log('Running A.componentWillReceiveProps()');
  }
}

class B extends React.Component {
  constructor() {
    super();
    this.state = { counter: 0 };
  }

  render() {
    return <A name="World" />
  }

  componentDidMount() {
    setInterval(() => {
      this.setState({
        counter: this.state.counter + 1
      });
    }, 1000)
  }
}

ReactDOM.render(<B/>, document.getElementById('container'));

上例中,父組件 B 的 state change 引發子組件 ArendercomponentWillReceiveProps 被調用了,但 A 並沒有發生 props change。

沒錯,只要接到了新的 propscomponentWillReceiveProps 就會被調用,即便新 props 與舊的完全一樣:

UNSAFE_componentWillReceiveProps() is invoked before a mounted component receives new props.

Note that if a parent component causes your component to re-render, this method will be called even if props have not changed.

相關實現如下:

updateComponent: function () {
  var willReceive = false;
  var nextContext;

  if (this._context !== nextUnmaskedContext) {
    nextContext = this._processContext(nextUnmaskedContext);
    willReceive = true;
  }

  // Not a simple state update but a props update
  if (prevParentElement !== nextParentElement) {
    willReceive = true;
  }

  if (willReceive && inst.componentWillReceiveProps) {
    inst.componentWillReceiveProps(nextProps, nextContext);
  }
}

(摘自典藏版 ReactDOM v15.6.1)

也就是說,componentWillReceiveProps 的調用時機是:

引發當前組件更新 && (context 發生變化 || 父組件 render 結果發生變化,即當前組件需要 rerender)

注意,這裡並沒有對 propsdiff

React doesn't make an attempt to diff props for user-defined components so it doesn't know whether you've changed them.

因為 props 值沒什麼約束,難以 diff

Oftentimes a prop is a complex object or function that's hard or impossible to diff, so we call it always (and rerender always) when a parent component rerenders.

唯一能保證的是 props change 一定會觸發 componentWillReceiveProps,但反之不然

The only guarantee is that it will be called if props change.

P.S. 更多相關討論見 Documentation for componentWillReceiveProps() is confusing

二。如何理解 getDerivedStateFromProps

getDerivedStateFromProps 是用來替代 componentWillReceiveProps 的,應對 state 需要關聯 props 變化的場景:

getDerivedStateFromProps exists for only one purpose. It enables a component to update its internal state as the result of changes in props.

即允許 props 變化引發 state 變化(稱之為 derived state,即派生 state),雖然多數時候並不需要把 props 值往 state 裡塞,但在一些場景下是不可避免的,比如:

這些場景的特點是與 props 變化有關,需要取新舊 props 進行比較/計算,

componentWillReceiveProps 類似,getDerivedStateFromProps 也不只是在 props change 時才觸發,具體而言,其觸發時機為:

With React 16.4.0 the expected behavior is for getDerivedStateFromProps to fire in all cases before shouldComponentUpdate.

更新流程中,在 shouldComponentUpdate 之前調用。也就是說,只要走進更新流程(無論更新原因是 props change 還是 state change),就會觸發 getDerivedStateFromProps

就具體實現而言,與計算 nextContextnextContext = this._processContext(nextUnmaskedContext))類似,在確定是否需要更新(shouldComponentUpdate)之前,要先計算 nextState

export function applyDerivedStateFromProps(
  workInProgress: Fiber,
  ctor: any,
  getDerivedStateFromProps: (props: any, state: any) => any,
  nextProps: any,
) {
  const prevState = workInProgress.memoizedState;

  const partialState = getDerivedStateFromProps(nextProps, prevState);

  // Merge the partial state and the previous state.
  const memoizedState =
    partialState === null || partialState === undefined
      ? prevState
      : Object.assign({}, prevState, partialState);
  workInProgress.memoizedState = memoizedState;

  // Once the update queue is empty, persist the derived state onto the
  // base state.
  const updateQueue = workInProgress.updateQueue;
  if (updateQueue !== null && workInProgress.expirationTime === NoWork) {
    updateQueue.baseState = memoizedState;
  }
}

(摘自 react/packages/react-reconciler/src/ReactFiberClassComponent.js

getDerivedStateFromProps 成了計算 nextState必要環節

getDerivedStateFromProps is invoked right before calling the render method, both on the initial mount and on subsequent updates.

function mountIndeterminateComponent(
  current,
  workInProgress,
  Component,
  renderExpirationTime,
) {
  workInProgress.tag = ClassComponent;
  workInProgress.memoizedState =
    value.state !== null && value.state !== undefined ? value.state : null;

  const getDerivedStateFromProps = Component.getDerivedStateFromProps;
  if (typeof getDerivedStateFromProps === 'function') {
    applyDerivedStateFromProps(
      workInProgress,
      Component,
      getDerivedStateFromProps,
      props,
    );
  }

  adoptClassInstance(workInProgress, value);
  mountClassInstance(workInProgress, Component, props, renderExpirationTime);
  // 調用 render,第一階段結束
  return finishClassComponent(
    current,
    workInProgress,
    Component,
    true,
    hasContext,
    renderExpirationTime,
  );
}

(摘自 react/packages/react-reconciler/src/ReactFiberBeginWork.js

所以在首次渲染時也會調用,這是與 componentWillReceiveProps 相比最大的區別。

三。派生 state 實踐原則

實現派生 state 有兩種方式:

  • getDerivedStateFromProps:從 props 派生出部分 state,其返回值會被 merge 到當前 state

  • componentWillReceiveProps:在該生命週期函數裡 setState

實際應用中,在兩種常見場景中容易出問題(被稱為 anti-pattern,即反模式):

  • props 變化時無條件更新 state

  • 更新 state 中緩存的 props

componentWillReceiveProps 時無條件更新 state,會導致通過 setState() 手動更新的 state 被覆蓋掉,從而出現非預期的狀態丟失

When the source prop changes, the loading state should always be overridden. Conversely, the state is overridden only when the prop changes and is otherwise managed by the component.

例如(僅以 componentWillReceiveProps 為例,getDerivedStateFromProps 同理):

class EmailInput extends Component {
  state = { email: this.props.email };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  componentWillReceiveProps(nextProps) {
    // This will erase any local state updates!
    // Do not do this.
    this.setState({ email: nextProps.email });
  }
}

上例中,用戶在 input 控件中輸入一串字符(相當於手動更新 state),如果此時父組件更新引發該組件 rerender 了,用戶輸入的內容就被 nextProps.email 覆蓋掉了(見 在線 Demo),出現狀態丟失。

針對這個問題,我們一般會這樣解決:

class EmailInput extends Component {
  state = {
    email: this.props.email
  };

  componentWillReceiveProps(nextProps) {
    // Any time props.email changes, update state.
    if (nextProps.email !== this.props.email) {
      this.setState({
        email: nextProps.email
      });
    }
  }
}

精確限定 props change 到 email,不再無條件重置 state。似乎完美了,真的嗎?

其實還存在一個尷尬的問題,有些時候需要從外部重置 state(比如重置密碼輸入),而限定 state 重置條件之後,來自父組件的 props.email 更新不再無條件傳遞到 input 控件。所以,之前可以利用引發 EmailInput 組件 rerender 把輸入內容重置為 props.email,現在就不靈了。

那麼,需要想辦法從外部把輸入內容重置回 props.email,有很多種方式:

  • EmailInput 提供 resetValue() 方法,外部通過 ref 調用

  • 外部改變 EmailInputkey,強制重新創建一個 EmailInput,從而達到重置回初始狀態的目的

  • key 殺傷力太大(刪除重建,以及組件初始化成本),或者不方便(key 已經有別的作用了)的話,添個 props.myKey 結合 componentWillReceiveProps 實現局部狀態重置

其中,第一種方法只適用於 class 形式的組件,後兩種則沒有這個限制,可根據具體場景靈活選擇。第三種方法略繞,具體操作見 Alternative 1: Reset uncontrolled component with an ID prop

類似的場景之所以容易出問題,根源在於:

when a derived state value is also updated by setState calls, there isn't a single source of truth for the data.

一邊通過 props 計算 state,一邊手動 setState 更新,此時該 state 有兩個來源,違背了組件數據的單一源原則

解決這個問題的關鍵是保證單一數據源,杜絕不必要的拷貝

For any piece of data, you need to pick a single component that owns it as the source of truth, and avoid duplicating it in other components.

所以有兩種方案(砍掉一個數據源即可):

  • 完全去掉 state,這樣就不存在 stateprops 的衝突了

  • 忽略 props change,僅保留第一次傳入的 props 作為默認值

兩種方式都保證了單一數據源(前者是 props,後者是 state),這樣的組件也可以稱之為完全受控組件與完全不受控組件

四。「受控」與「不受控」

組件分為受控組件與不受控組件,同樣,數據也可以這樣理解

受控組件與不受控組件

針對表單輸入控件(<input><textarea><select> 等)提出的概念,語義上的區別在於受控組件的表單數據由 React 組件來處理(受 React 組件控制),而不受控組件的表單數據交由 DOM 機制來處理(不受 React 組件控制)。

受控組件 維護一份自己的狀態,並根據用戶輸入更新這份狀態:

An input form element whose value is controlled by React is called a controlled component. When a user enters data into a controlled component a change event handler is triggered and your code decides whether the input is valid (by re-rendering with the updated value). If you do not re-render then the form element will remain unchanged.

用戶與受控組件交互時,用戶輸入反饋到 UI 與否,取決於 change 事件對應的處理函數(是否需要改變內部狀態,通過 rerender 反饋到 UI),用戶輸入受 React 組件控制,例如:

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};

    this.handleChange = this.handleChange.bind(this);
  }

  handleChange(event) {
    // 在這裡決定是否把輸入反饋到 UI
    this.setState({value: event.target.value});
  }

  render() {
    return (
      <input type="text" value={this.state.value} onChange={this.handleChange} />
    );
  }
}

不受控組件 不維護這樣的狀態,用戶輸入不受 React 組件控制:

An uncontrolled component works like form elements do outside of React. When a user inputs data into a form field (an input box, dropdown, etc) the updated information is reflected without React needing to do anything. However, this also means that you can't force the field to have a certain value.

用戶與不受控組件的交互不受 React 組件控制,輸入會立即反饋到 UI。例如:

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.input = React.createRef();
  }

  handleSubmit(event) {
    // input 的輸入直接反饋到 UI,僅在需要時從 DOM 讀取
    alert('A name was submitted: ' + this.input.current.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" ref={this.input} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

從數據角度看受控與不受控

不受控組件把 DOM 當作數據源:

An uncontrolled component keeps the source of truth in the DOM.

而受控組件把自身維護的 state 當作數據源:

Since the value attribute is set on our form element, the displayed value will always be this.state.value, making the React state the source of truth.

讓程序行為可預測的關鍵在於減少變因,即保證唯一數據源。那麼就有數據源唯一的組件,稱之為完全受控組件完全不受控組件

對應到之前派生 state 的場景,就有了這兩種解決方案:

// (數據)完全受控的組件,不再維護輸入 state,value 完全由外部控制
function EmailInput(props) {
  return <input onChange={props.onChange} value={props.email} />;
}

// (數據)完全不受控的組件,只維護自己的 state,完全不接受來自 props 的任何更新
class EmailInput extends Component {
  state = { email: this.props.defaultEmail };

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }
}

P.S. 注意,「數據受控的組件」與「受控組件」是完全不同的兩個概念,按照受控組件的定義,上例兩種都是受控組件。

所以,在需要複製 props 到 state 的場景,要么考慮把 props 收進來完全作為自己的 state,不再受外界影響(使數據受控):

Instead of trying to "mirror" a prop value in state, make the component controlled

要么把自己的 state 丟掉,完全放棄對數據的控制:

Remove state from our component entirely.

五。緩存計算結果

另一些時候,拷貝 propsstate 是為了緩存計算結果,避免重複計算

例如,常見的列表項按輸入關鍵詞篩選的場景:

class Example extends Component {
  state = {
    filterText: "",
  };

  static getDerivedStateFromProps(props, state) {
    if (
      props.list !== state.prevPropsList ||
      state.prevFilterText !== state.filterText
    ) {
      return {
        prevPropsList: props.list,
        prevFilterText: state.filterText,
        // 緩存 props 結算結果到 state
        filteredList: props.list.filter(item => item.text.includes(state.filterText))
      };
    }
    return null;
  }

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{this.state.filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

能用,但過於複雜了。通過 getDerivedStateFromProps 創造了另一個變因(state.filteredList),這樣 props change 和 state change 都可能影響篩選結果,容易出問題。

事實上,想要避免重複計算的話,並不用緩存一份結果到 state,比如:

class Example extends PureComponent {
  state = {
    filterText: ""
  };

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    const filteredList = this.props.list.filter(
      item => item.text.includes(this.state.filterText)
    )

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

利用 PureComponentrender() 只在 props change 或 state change 時才會再次調用的特性,直接在 render() 裡放心做計算。

看起來很完美,但實際場景的 stateprops 一般不會這麼單一,如果另一個計算無關的 propsstate 更新了也會引發 rerender,產生重複計算。

所以乾脆拋開「不可靠」的 PureComponent,這樣解決:

import memoize from "memoize-one";

class Example extends Component {
  state = { filterText: "" };

  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  );

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    const filteredList = this.filter(this.props.list, this.state.filterText);

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

不把計算結果放到 state 裡,也不避免 rerender,而是緩存到外部,既乾淨又可靠。

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論