重新認識受控和非受控元件

袋鼠雲數棧UED發表於2022-03-17

作者:霜序

校稿:袋鼠雲數棧前端團隊運營小組

該文章包含如下內容

  • 受控與非受控元件

    • 非受控元件
    • 受控元件
  • 受控和非受控元件邊界
  • 反模式
  • 解決方案

前言

在 HTML 中,表單元素(<input>/<textarea>/<select>),通常自己會維護 state,並根據使用者的輸入進行更新

<form>
  <label>
    名字:
    <input type="text" name="name" />
  </label>
  <input type="submit" value="提交" />
</form>

在這個 HTML 中,我們可以在 input 中隨意的輸入值,如果我們需要獲取到當前 input 所輸入的內容,應該怎麼做呢?

受控與非受控元件

非受控元件(uncontrolled component)

使用非受控元件,不是為每個狀態更新編寫資料處理函式,而是將表單資料交給 DOM 節點來處理,可以使用 Ref 來獲取資料
在非受控元件中,希望能夠賦予表單一個初始值,但是不去控制後續的更新。可以採用defaultValue指定一個預設值

class Form extends Component {
  handleSubmitClick = () => {
    const name = this._name.value;
    // do something with `name`
  }
  render() {
    return (
      <div>
        <input
                    type="text"
                    defaultValue="Bob"
                    ref={input => this._name = input}
                />
        <button onClick={this.handleSubmitClick}>Sign up</button>
      </div>
    );
  }
}

受控元件(controlled component)

在 React 中,可變狀態(mutable state)通常儲存在元件的 state 屬性中,並且只能夠通過setState 來更新

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: 'shuangxu'};
  }
  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          名字:
          <input type="text" value={this.state.value}/>
        </label>
        <input type="submit" value="提交" />
      </form>
    );
  }
}

在上述的程式碼中,在 Input 設定了 value 屬性值,因此顯示的值始終為this.state.value,這使得 state 成為了唯一的資料來源。

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

<input type="text" value={this.state.value} onChange={this.handleChange}/>

如果我們在上面的示例中寫入handleChange 方法,那麼每次按鍵都會執行該方法並且更新 React 的 state,因此表單的值將隨著使用者的輸入而改變

React 元件控制著使用者輸入過程中表單發生的操作並且 state 還是唯一資料來源,被 React 以這種方式控制取值的表單輸入元素叫做受控元件

受控和非受控元件邊界

非受控元件

Input 元件只接收一個defaultValue預設值,呼叫 Input 元件的時候,只需要通過 props 傳遞一個defaultValue 即可

//元件
function Input({defaultValue}){
    return <input defaultValue={defaultValue} />  
}

//呼叫
function Demo(){
    return <Input defaultValue='shuangxu' />
}

受控元件

數值的展示和變更需要由statesetState,元件內部控制 state,並實現自己的 onChange 方法

//元件
function Input() {
    const [value, setValue] = useState('shuangxu')
  return <input value={value} onChange={e=>setValue(e.target.value)} />;
}

//呼叫
function Demo() {
  return <Input />;
}

請問這時 Input 元件是受控還是非受控?如果我們採用之前的寫法更改這個元件以及其呼叫

//元件
function Input({defaultValue}) {
    const [value, setValue] = useState(defaultValue)
  return <input value={value} onChange={e=>setValue(e.target.value)} />;
}

//呼叫
function Demo() {
  return <Input defaultValue='shuangxu' />;
}

此時的 Input 元件本身是一個受控元件,它是由唯一的 state 資料驅動的。但是對於 Demo 來說,我們並沒有 Input 元件的一個資料變更權利,那麼對於 Demo 元件來說,Input 元件就是一個非受控元件。(‼️以非受控元件的方式去呼叫受控元件是一種反模式)

如何修改當前的 Input 和 Demo 元件程式碼,才能夠使得 Input 元件本身也是一個受控元件,並且對於 Demo 元件來說它也是受控的訥?

function Input({value, onChange}){
    return <input value={value} onChange={onChange}
}

function Demo(){
    const [value, setValue] = useState('shuangxu')
    return <Input value={value} onChange={e => setValue(e.target.value)} />

反模式-以非受控元件的方式去呼叫受控元件

雖然受控和非受控通常用來指向表單的 inputs,也能用來描述資料頻繁更新的元件。
通過上一節受控與非受控元件的邊界劃分,我們可以簡單的分類為:

  • 如果使用 props 傳入資料,有對應的資料處理方法,元件對於父級來說認為是可控的
  • 資料只是儲存在元件內部的 state 中,元件對於父級來說是非受控的

⁉️什麼是派生 state

簡單來說,如果一個元件的 state 中的某個資料來自外部,就將該資料稱之為派生狀態。

大部分使用派生 state 導致的問題,不外乎兩個原因:

  • 直接複製 props 到 state
  • 如果 props 和 state 不一致就更新 state

    直接複製 prop 到 state

    ⁉️getDerivedStateFromPropscomponentWillReceiveProps的執行時期

  • 在父級重新渲染時,不管 props 是否有變化,這兩個生命週期都會執行
  • 所以在兩個方法裡面直接複製 props 到 state 是不安全的,會導致 state 沒有正確渲染

    class EmailInput extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        email: this.props.email   //初始值為props中email
      };
    }
    componentWillReceiveProps(nextProps) {
      this.setState({ email: nextProps.email });   //更新時,重新給state賦值
    }
    handleChange = (e) => {
      this.setState({ email: e.target.value });
    };
    render() {
      const { email } = this.state;
      return <input value={email} onChange={this.handleChange} />;
    }
    }

    點選檢視示例

給 Input 設定 props 傳來的初始值,在 Input 輸入時它會修改 state。但是如果父元件重新渲染時,輸入框 Input 的值就會丟失,變成 props 的預設值

即使我們在重置前比較nextProps.email!==this.state.email仍然會導致更新

針對於目前這個小 demo 來說,可以使用shouldComponentUpdate來比較 props 中的 email 是否修改再來決定是否需要重新渲染。但是對於實際應用來說,這種處理方式並不可行,一個元件會接收多個 prop,任何一個 prop 的改變都會導致重新渲染和不正確的狀態重置。加上行內函式和物件 prop,建立一個完全可靠的shouldComponentUpdate會變得越來越難。shouldComponentUpdate這個生命週期更多是用於效能優化,而不是處理派生 state。
截止這裡,講清為什麼不能直接複製 prop 到 state。思考另一個問題,如果只使用 props 中的 email 屬性更新元件訥?

在 props 變化後修改 state

接著上述示例,只使用props.email來更新元件,這樣可以防止修改 state 導致的 bug

class EmailInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      email: this.props.email   //初始值為props中email
    };
  }
  componentWillReceiveProps(nextProps) {
        if(nextProps.email !== this.props.email){
        this.setState({ email: nextProps.email });   //email改變時,重新給state賦值
        }
  }
    //...
}

通過這個改造,元件只有在 props.email 改變時才會重新給 state 賦值,那這樣改造會有問題嗎?

在下列場景中,對擁有兩個相同 email 的賬號進行切換的時,這個輸入框不會重置,因為父元件傳來的 prop 值沒有任何變化
點選檢視示例
這個場景是構建出來的,可能設計奇怪,但是這樣子的錯誤很常見。對於這種反模式來說,有兩種方案可以解決這些問題。關鍵在於,任何資料,都要保證只有一個資料來源,而且避免直接複製它。

解決方案

完全可控的元件

從 EmailInput 元件中刪除 state,直接使用 props 來獲取值,將受控元件的控制權交給父元件。

function EmailInput(props){
    return <input onChange={props.onChange} value={props.email}/>
}

如果想要儲存臨時的值,需要父元件手動執行儲存。

有 key 的非受控元件

讓元件儲存臨時的 email state,email 的初始值仍然是通過 prop 來接受的,但是更改之後的值就和 prop 沒有關係了

function EmailInput(props){
    const [email, setEmail] = useState(props.email)
    return <input value={email} onChange={(e) => setEmail(e.target.value)}/>
}

在之前的切換賬號的示例中,為了在不同頁面切換不同的值,可以使用key這個 React 特殊屬性。當 key 變化時,React 會建立一個新的元件而不是簡單的更新存在的元件(獲取更多)。我們經常使用在渲染動態列表時使用 key 值,這裡也可以使用。

<EmailInput
    email={account.email}
    key={account.id}
/>

點選檢視示例

每次 id 改變的時候,都會重新建立EmailInput,並將其狀態重置為最近 email 值。

可選方案

  1. 使用 key 屬性來做,會使元件整個元件的 state 都重置。可以在getDerivedStateFromPropscomponentWillReceiveProps 來觀察 id 的變化,麻煩但是可行
    點選檢視示例

    class EmailInput extends Component {
      state = {
    email: this.props.email,
    prevId: this.props.id
      };
    
      componentWillReceiveProps(nextProps) {
    const { prevId } = this.state;
    if (nextProps.id !== prevId) {
      this.setState({
        email: nextProps.email,
        prevId: nextProps.id
      });
    }
      }
      // ...
    }
  2. 使用例項方法重置非受控元件
    剛剛兩種方式,均是再有唯一標識值的情況下。如果在沒有合適的key值時,也想要重新建立元件。第一種方案就是生成隨機值或者遞增的值當作key值,另一種就是使用示例方法強制重置內部狀態
    父元件使用ref呼叫這個方法,點選檢視示例

    class EmailInput extends Component {
      state = {
    email: this.props.email
      };
    
      resetEmailForNewUser(newEmail) {
    this.setState({ email: newEmail });
      }
    
      // ...
    }

    那我們如何選

在我們的業務開發中,儘量選擇受控元件,減少使用派生 state,過量的使用 componentWillReceiveProps 可能導致 props 判斷不夠完善,倒是重複渲染死迴圈問題。

在元件庫開發中,例如 Ant Design,將受控與非受控的呼叫方式都開放給使用者,讓使用者自主選擇對應的呼叫方式。比如 Form 元件,我們常使用 getFieldDecorator 和 initialValue 來定義表單項,但是我們根本不關心中間的輸入過程,在最後提交的時候通過 getFieldsValue 或者 validateFields 拿到所有的表單值,這就是非受控的呼叫方式。或者是,我們在只有一個 Input 的時候,我們可以直接繫結 value 和 onChange 事件,這也就是受控的方式呼叫。

總結

在本文中,首先介紹了非受控元件和受控元件的概念。對於受控元件來說,元件控制使用者輸入的過程以及 state 是受控元件唯一的資料來源。

接著介紹了元件的呼叫問題,對於元件呼叫方而言,元件提供方是否為受控元件。對於呼叫方而言,元件受控以及非受控的邊界劃分取決於當前元件對於子元件值的變更是否擁有控制權。

接著介紹了以非受控元件的方式呼叫受控元件這種反模式用法,以及相關示例。不要直接複製 props 到 state,而是使用受控元件。對於不受控的元件,當你想在 prop 變化時重置 state 的話,可以選擇以下幾種方式:

  • 建議: 使用key屬性,重置內部所有的初始 state
  • 選項一:僅更改某些欄位,觀察特殊屬性的變化(具有唯一性的屬性)
  • 選項二:使用 ref 呼叫例項方法

最後總結了一下,應當如何選擇受控元件還是非受控元件。

參考連結

相關文章