본문으로 건너뛰기

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 가 발생하지 않았습니다.

맞습니다. 새로운 props 를 받으면, componentWillReceiveProps 는 호출됩니다. 비록 새로운 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 필요)

주의하세요, 여기서는 props 에 대한 diff 를 수행하지 않습니다:

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 이해 방법

getDerivedStateFromPropscomponentWillReceiveProps 를 대체하기 위한 것으로, stateprops 변화와 연관되어야 하는 시나리오에 대응합니다:

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 가 트리거됩니다.

구체적인 구현은, nextContext 계산 (nextContext = 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 에서 발췌)

getDerivedStateFromPropsnextState 를 계산하는필수环节이 되었습니다:

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 호출, 1 단계 종료
  return finishClassComponent(
    current,
    workInProgress,
    Component,
    true,
    hasContext,
    renderExpirationTime,
  );
}

(react/packages/react-reconciler/src/ReactFiberBeginWork.js 에서 발췌)

따라서첫 렌더링 시에도 호출됩니다. 이것이 componentWillReceiveProps 와의 가장 큰 차이점입니다.

삼.파생 state 실천 원칙

파생 state 를 구현하는 두 가지 방법이 있습니다:

  • getDerivedStateFromProps: props 에서 부분 state 를 파생시켜, 그 반환값은 현재 statemerge 됩니다

  • 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 로 리셋하는 방법을 생각해야 합니다. 많은 방법이 있습니다:

  • EmailInputresetValue() 메서드를 제공하고, 외부에서 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 는 일반적으로 이렇게 단순하지 않으며, 만약 다른 계산과 무관한 props 또는 state 가 업데이트되어도 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 도 피하지 않으며, 외부에 캐시합니다. 이렇게 하면 깔끔하고 신뢰할 수 있습니다.

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성