基於 props 更新 state

單向數據流

所有的 prop 都使得其父子 prop 之間形成了一個單向下行綁定:父級 prop 的更新會向下流動到子組件中,但是反過來則不行。這樣會防止從子組件意外改變父級組件的狀態,從而導致你的應用的數據流向難以理解。

額外的,每次父級組件發生更新時,子組件中所有的 prop 都將會刷新爲最新的值。這意味着你不應該在一個子組件內部改變 prop。

Props 的只讀性

決不能修改自身的 props。所有組件都必須保護它們的 props 不被更改。

State 與 props 類似,但是 state 是私有的,並且完全受控於當前組件。state 允許組件隨用戶操作、網絡響應或者其他變化而動態更改輸出內容。

讓組件在 props 變化時更新 state

在 vue 中我們會這樣做,定義一個本地的 data 屬性並將這個 prop 用作其初始值,然後通過 watch 監控 prop 變化,然後重複賦值給本地的 data 屬性。

props: ['initialCounter'],
data () {
  return {
    counter: this.props.initialCounter
  }
},
watch: {
  initialCounter (newCounter) {
    if (newCounter !== this.counter) {
      this.counter = newCounter
    }
  }
}

在 react 中,從 16.3 版本開始,當 props 變化時,建議使用新的 static getDerivedStateFromProps 生命週期更新 state。創建組件以及每次組件由於 props 或 state 的改變而重新渲染時都會調用該生命週期:

class ExampleComponent extends React.Component {
  // 在構造函數中初始化 state,
  // 或者使用屬性初始化器。
  state = {
    counter: this.props.initialCounter,
  };

  static getDerivedStateFromProps(props, state) {
    if (props.initialCounter !== state.counter) {
      return { counter: props.initialCounter };
    }

    // 返回 null 表示無需更新 state。
    return null;
  }

  handleClick = () => {
    // 點擊之後無法修改 counter 的 bug
    this.setState({counter: this.state.counter + 1})
  }

  render() {
    return (
      <div onClick={this.handleClick}>{this.state.counter}</div>
    );
  }
}

getDerivedStateFromProps 會在調用 render 方法之前調用,並且在初始掛載及後續更新時都會被調用。它應返回一個對象來更新 state,如果返回 null 則不更新任何內容。

請注意,不管原因是什麼,都會在每次渲染前觸發此方法。這與 componentWillReceiveProps 形成對比,後者僅在父組件重新渲染時觸發,而不是在內部調用 setState 時。

基於 props 更新 state,舊的 componentWillReceiveProps 和新的 getDerivedStateFromProps 方法都會給組件增加明顯的複雜性。這通常會導致 bug。

最常見的誤解就是 getDerivedStateFromPropscomponentWillReceiveProps 只會在 props “改變”時纔會調用。實際上只要父級重新渲染時,這兩個生命週期函數就會重新調用,不管 props 有沒有“變化”。所以,在這兩個方法內直接複製 props 到 state 是不安全的。這樣做會導致 state 後沒有正確渲染。

希望以上能解釋清楚爲什麼直接複製 prop 到 state 是一個非常糟糕的想法。

雖然這個設計就有問題,但是這樣的錯誤很常見,(我就犯過這樣的錯誤)。任何數據,都要保證只有一個數據來源,而且避免直接複製它。

讓我們看看一個相關的問題:假如我們只使用 props 中的 counter 屬性更新組件呢?

完全可控的組件

阻止上述問題發生的一個方法是,從組件裏刪除 state。然後傳入在父組件中定義的處理函數進行修改。

class ExampleComponent extends React.Component {
  render() {
    return (
      <div onClick={this.props.handleClick}>
        {this.props.initialCounter}
      </div>
    );
  }
}

雖然 vue 中沒有這個問題,但是建議大家不要在組件裏面修改 props,任何數據,都要保證只有一個數據來源,而且避免直接複製它。都必須保護它們的 props 不被更改。

在一個受控組件中,表單數據是由 React 組件來管理的。另一種替代方案是使用非受控組件,這時表單數據將交由 DOM 節點來處理。

名詞“受控”和“非受控”通常用來指代表單的 inputs,但是也可以用來描述數據頻繁更新的組件。用 props 傳入數據的話,組件可以被認爲是受控(因爲組件被父級傳入的 props 控制)。數據只保存在組件內部的 state 的話,是非受控組件(因爲外部沒辦法直接控制 state)。

總結

派生狀態會導致代碼冗餘,並使組件難以維護。 確保你已熟悉這些簡單的替代方案:

1. 如果你需要執行副作用(例如,數據提取或動畫)以響應 props 中的更改,請改用 componentDidUpdate

class ExampleComponent extends React.Component {
  state = {
    counter: this.props.initialCounter
  }
  handleClick = () => {
    this.setState({ counter: this.state.counter + 1 })
  }
  componentDidUpdate(prevProps) {
    if (this.props.initialCounter !== prevProps.initialCounter) {
      this.setState({ counter: this.props.initialCounter })
    }
  }
  render() {
    return (
      <div onClick={this.handleClick}>
        {this.state.counter}
      </div>
    );
  }
}

你也可以在 componentDidUpdate() 中直接調用 setState(),但請注意它必須被包裹在一個條件語句裏,正如上述的例子那樣進行處理,否則會導致死循環。它還會導致額外的重新渲染,雖然用戶不可見,但會影響組件性能。不要將 props “鏡像”給 state,請考慮直接使用 props。

3. 如果只想在 prop 更改時重新計算某些數據,請使用 memoization 幫助函數代替。

僅在輸入變化時,重新計算 render 需要使用的值————這個技術叫做 memoization 。(也就是Vue中的計算屬性類似)

import memoize from "memoize-one";

class Example extends Component {
  // state 只需要保存當前的 filter 值:
  state = { filterText: "" };

  // 在 list 或者 filter 變化時,重新運行 filter:
  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  );

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

  render() {
    // 計算最新的過濾後的 list。
    // 如果和上次 render 參數一樣,`memoize-one` 會重複使用上一次的值。
    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>
    );
  }
}

在使用 memoization 時,請記住這些約束:
1、大部分情況下, 每個組件內部都要引入 memoized 方法,已免實例之間相互影響。
2、一般情況下,我們會限制 memoization 幫助函數的緩存空間,以免內存泄漏。(上面的例子中,使用 memoize-one 只緩存最後一次的參數和結果)。

4. 如果你想在 prop 更改時“重置”某些 state,請考慮使組件完全受控或使用 key 使組件完全不受控 代替。

1、完全可控的組件

class ExampleComponent extends React.Component {
  render() {
    return (
      <div onClick={this.props.handleClick}>
        {this.props.initialCounter}
      </div>
    );
  }
}

2、有 key 的非可控組件

另外一個選擇是讓組件自己存儲臨時的 state。在這種情況下,組件仍然可以從 prop 接收“初始值”,但是更改之後的值就和 prop 沒關係了:

class ExampleComponent extends React.Component {
  state = {
    counter: this.props.initialCounter
  }
  handleClick = () => {
    this.setState({ counter: this.state.counter + 1 })
  }
  render() {
    return (
      <div onClick={this.handleClick}>
        {this.state.counter}
      </div>
    );
  }
}

// <ExampleComponent initialCounter={this.state.counter} key={this.state.key} />

我們可以使用 key 這個特殊的 React 屬性。當 key 變化時, React 會創建一個新的而不是更新一個既有的組件。

class ExampleComponent extends React.Component {
  state = {
    counter: this.props.initialCounter
  }
  handleClick = () => {
    this.setState({ counter: this.state.counter + 1 })
  }
  render() {
    return (
      <div onClick={this.handleClick}>
        {this.state.counter}
      </div>
    );
  }
}

class App extends React.Component {
  state = {
    counter: 0,
    key: 0
  }
  handleClick = () => {
    this.setState({
      counter: this.state.counter + 1,
      key: this.state.key + 1,
  })
  }
  render() {
    return (
      <div>
        <button onClick={this.handleClick} >點擊</button>
        <ExampleComponent
          initialCounter={this.state.counter}
          key={this.state.key} />
      </div>
    );
  }
}

如果某些情況下 key 不起作用(可能是組件初始化的開銷太大),有兩種方法解決這個問題。

方法1:用 prop 的 ID 重置非受控組件

一個麻煩但是可行的方案是在 getDerivedStateFromProps 觀察 uuid 的變化:

class ExampleComponent extends React.Component {
  // 在構造函數中初始化 state,
  // 或者使用屬性初始化器。
  state = {
    counter: this.props.initialCounter,
    prevPropsUuid: this.props.uuid
  };

  static getDerivedStateFromProps(props, state) {
    // 觀察 uuid 的變化
    if (props.uuid !== state.prevPropsUuid) {
      return {
        counter: props.initialCounter,
        prevPropsUuid: props.uuid
      };
    }

    // 返回 null 表示無需更新 state。
    return null;
  }

  handleClick = () => {
    this.setState({counter: this.state.counter + 1})
  }

  render() {
    return (
      <div onClick={this.handleClick}>={this.state.counter}</div>
    );
  }
}

class App extends React.Component {
  state = {
    counter: 0,
    key: 0
  }
  handleClick = () => {
    this.setState({
      counter: this.state.counter + 1,
      key: this.state.key + 1,
    })
  }
  render() {
    return (
      <div>
        <button onClick={this.handleClick} >點擊</button>
        <ExampleComponent
          initialCounter={this.state.counter}
          uuid={this.state.key} />
      </div>
    );
  }
}

方法2:使用實例方法重置非受控組件

在組件上使用 ref 可以獲取組件實例:

class ExampleComponent extends React.Component {
  // 在構造函數中初始化 state,
  // 或者使用屬性初始化器。
  state = {
    counter: this.props.initialCounter
  };

  handleClick = () => {
    this.setState({ counter: this.state.counter + 1 })
  }

  resetCounter (newCounter) {
    this.setState({ counter: newCounter });
  }

  render() {
    return (
      <div onClick={this.handleClick}>={this.state.counter}</div>
    );
  }
}

class App extends React.Component {
  state = {
    counter: 0,
    key: 0
  }
  componentRef = React.createRef()
  handleClick = () => {
    let newCounter = this.state.counter + 1
    this.setState({ counter: newCounter })
    this.componentRef.current.resetCounter(newCounter)
  }
  render() {
    return (
      <div>
        <button onClick={this.handleClick} >點擊</button>
        <ExampleComponent
          initialCounter={this.state.counter}
          ref={this.componentRef} />
      </div>
    );
  }
}

refs 在某些情況下很有用,比如這個。但通常我們建議謹慎使用。即使是做一個演示,這個命令式的方法也是非理想的,因爲這會導致兩次而不是一次渲染。

概括

回顧一下,設計組件時,重要的是確定組件是受控組件還是非受控組件。

不要直接複製 props 的值到 state 中,而是去實現一個受控的組件,然後在父組件裏合併兩個值。

對於不受控的組件,當你想在 prop 變化(通常是 ID )時重置 state 的話,可以選擇一下幾種方式:

1、建議: 重置內部所有的初始 state,使用 key 屬性
2、選項一:僅更改某些字段,觀察特殊屬性的變化(比如 props.uuid)。
3、選項二:使用 ref 調用實例方法。

最後提一下,上面是 react 的寫法,再介紹一下 vue 的最佳寫法使用語法糖 v-model。

<template>
  <div class="hello">
    <h1 @click="handleClick">{{ value }}</h1>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: ['value'],
  data() {
    return {
      counter: this.value
    };
  },
  methods: {
    handleClick () {
      this.$emit('input', this.value + 1)
    }
  }
}
</script>

// <HelloWorld v-model="initialCounter"/>
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章