본문으로 건너뛰기

React 컴포넌트 간 로직复用

무료2019-05-26#Front-End#react reuse logic between components#react逻辑复用#react cross-cutting concerns#react and AOP#render props and high order components

Mixin 에서 시작하여……

시작하며

React 에서, 컴포넌트는 코드复用의 주요 유닛이며, 조합에 기반한 컴포넌트复用 메커니즘 은 매우 우아합니다. 그러나 더 세밀한 로직 (상태 로직, 동작 로직 등) 에 대해서는, 复用하는 것이 그리 쉽지 않습니다:

Components are the primary unit of code reuse in React, but it's not always obvious how to share the state or behavior that one component encapsulates to other components that need that same state.

(Use HOCs For Cross-Cutting Concerns 에서 발췌)

상태 로직을 분리하여 复用 가능한 함수나 컴포넌트로 추출하는 것은 어렵습니다:

However, we often can't break complex components down any further because the logic is stateful and can't be extracted to a function or another component.

왜냐하면, 지금까지 일관되게, 간단하고 직접적인 컴포넌트 동작 확장 방식이 부족했기 때문입니다:

React doesn't offer a way to "attach" reusable behavior to a component (for example, connecting it to a store).

(It's hard to reuse stateful logic between components 에서 발췌)

잠깐, HOC 는 확장 방식이 아닙니까, 심지어 Mixin 도 있잖아요?

엄밀히 말하면, Mixin, Render Props, HOC 등의 방안은 모두 기존 (컴포넌트 메커니즘의) 게임 규칙 하에서 탐색된 상위 패턴일 뿐입니다:

To be clear, mixins is an escape hatch to work around reusability limitations in the system. It's not idiomatic React.

(Proposal for porting React's Mixin APIs to a generic primitive 에서 발췌)

HOCs are not part of the React API, per se. They are a pattern that emerges from React's compositional nature.

(Higher-Order Components 에서 발췌)

컴포넌트 간 로직复用의 문제를 근본부터很好地에 해결하는 방법은 없었습니다……Hooks 가 무대에 등장할 때까지

P.S.Mixin 은 하층 해결 방안처럼 보입니다 (React 는 내부 서포트를 제공). 실제로는mixin() 툴 함수를 내장했을 뿐이며, 유일한 특수한 점은 충돌 처리 전략입니다:

A class can use multiple mixins, but no two mixins can define the same method. Two mixins can, however, implement the same lifecycle method. In this case, each implementation will be invoked one after another.

一。탐색

컴포넌트 급 이하의 세밀한 로직 (예를 들어 횡단적 관심사 를 처리하는) 를 더욱 复用하기 위해, 다양한 방안이 탐색되었습니다:

대략적인 과정은 다음과 같습니다:

이론 기초방안결함
그대로 이식OOP 复用 패턴을 차용Mixin컴포넌트 복잡도가 급상승하여 이해가 어려움
선언형은 명령형보다 우월하며, 조합은 상속보다 우월함Higher-Order Components, Render Props다중 추상으로 인해 Wrapper Hell 발생
함수형 사상을 차용Hooks작성 방식의 제한, 학습 비용 등

二.Mixin

Mixins allow code to be shared between multiple React components. They are pretty similar to mixins in Python or traits in PHP.

Mixin 방안의 출현은 OOP 직감에서 비롯되었으며, React 자체는 어느 정도 함수형의 맛이 있지만, 사용자의 습관에 영합하기 위해, 초기에는 React.createClass() API 만을 제공하여 컴포넌트를 정의했습니다:

React is influenced by functional programming but it came into the field that was dominated by object-oriented libraries. It was hard for engineers both inside and outside of Facebook to give up on the patterns they were used to.

당연히, (클래스) 상속이 직관적인 시도가 되었습니다. JavaScript 의 프로토타입 기반 확장 모드에서는, 상속과 유사한 Mixin 방안이首选이 되었습니다:

// Mixin 정의
var Mixin1 = {
  getMessage: function() {
    return 'hello world';
  }
};
var Mixin2 = {
  componentDidMount: function() {
    console.log('Mixin2.componentDidMount()');
  }
};

// Mixin 을 사용하여 기존 컴포넌트를增强
var MyComponent = React.createClass({
  mixins: [Mixin1, Mixin2],
  render: function() {
    return <div>{this.getMessage()}</div>;
  }
});

(상고 문서 react/docs/docs/mixins.md 에서 발췌)

Mixin 은 주로 라이프사이클 로직과 상태 로직의复用 문제를 해결하는 데 사용됩니다:

It tries to be smart and"merges"lifecycle hooks. If both the component and the several mixins it uses define the componentDidMount lifecycle hook, React will intelligently merge them so that each method will be called. Similarly, several mixins can contribute to the getInitialState result.

외부에서 컴포넌트의 라이프사이클을 확장하는 것을 허용하며, Flux 등의 패턴에서 특히 중요합니다:

It's absolutely necessary that any component extension mechanism has the access to the component's lifecycle.

결함

그러나 많은 결함이 존재합니다:

  • 컴포넌트와 Mixin 사이에 암묵적 의존 관계가 존재 (Mixin 은 빈번히 컴포넌트의 특정 메서드에 의존하지만, 컴포넌트를 정의할 때에는 이 의존 관계를 알 수 없음)

  • 여러 Mixin 간에 충돌이 발생할 수 있음 (예를 들어 같은state 필드를 정의)

  • Mixin 은 더 많은 상태를 추가하는 경향이 있으며, 이는 애플리케이션의 예측 가능성을 저하시킵니다 (The more state in your application, the harder it is to reason about it.), 복잡도가 급증합니다

암묵적 의존 관계로 인해 의존 관계가 불투명해지고, 유지 비용과 이해 비용이 급속히 상승합니다:

  • 컴포넌트 동작을 빠르게 이해하기 어렵고, 모든 의존 Mixin 의 확장 동작 및 그 사이의 상호 영향을 완전히 이해해야 함

  • 컴포넌트 자신의 메서드와state 필드는 쉽게 삭제·변경할 수 없음. Mixin 이それに 의존하는지 여부를 확정하기 어렵기 때문

  • Mixin 도 유지가 어려움. Mixin 로직은 마지막에 평평하게 합병되므로, 하나의 Mixin 의 입출력을搞清楚하기 어려움

틀림없이, 이러한 문제들은 치명적입니다

따라서, React v0.13.0 은 Mixin 을 포기 하고 (상속), 대신 HOC (조합) 로 향했습니다:

Idiomatic React reusable code should primarily be implemented in terms of composition and not inheritance.

(Mixin 방안이 존재하는 문제를 고려하지 않음) 단순히 기능으로 보면, Mixin 도 HOC 와 유사한 확장을 완료할 수 있습니다. 예를 들어:

var SetIntervalMixin = {
  componentWillMount: function() {
    this.intervals = [];
  },
  setInterval: function() {
    this.intervals.push(setInterval.apply(null, arguments));
  },
  componentWillUnmount: function() {
    this.intervals.forEach(clearInterval);
  }
};

// React v15.5.0 이하의 React.createClass 와 동치
var createReactClass = require('create-react-class');

var TickTock = createReactClass({
  mixins: [SetIntervalMixin], // Mixin 사용
  getInitialState: function() {
    return {seconds: 0};
  },
  componentDidMount: function() {
    this.setInterval(this.tick, 1000); // Mixin 의 메서드 호출
  },
  tick: function() {
    this.setState({seconds: this.state.seconds + 1});
  },
  render: function() {
    return (
      <p>
        React has been running for {this.state.seconds} seconds.
      </p>
    );
  }
});

ReactDOM.render(
  <TickTock />,
  document.getElementById('example')
);

(Mixins 에서 발췌)

P.S.[React v15.5.0] 는 정식으로React.createClass() API 를 폐기하고, create-react-class 로 이전하며, 내장 Mixin 도 함께 역사가 되었습니다. 자세한 내용은 React v15.5.0 참조

三.Higher-Order Components

Mixin 이후, HOC 가 중임을 맡아, 컴포넌트 간 로직复用의 권장 방안이 되었습니다:

A higher-order component (HOC) is an advanced technique in React for reusing component logic.

그러나, HOC 는 신인이 아닙니다. React.createClass() 시대부터 이미 존재했습니다. HOC 는 컴포넌트 조합 메커니즘 위에 구축되어 있으며, 완전히 상위 패턴으로, 특수한 서포트에 의존하지 않습니다

형식적으로는 고계 함수와 유사하며, 컴포넌트를 1 층 감싸서 동작을 확장합니다:

Concretely, A higher-order component is a function that takes a component and returns a new component.

예를 들어:

// 고계 컴포넌트 정의
var Enhance = ComposedComponent => class extends Component {
  constructor() {
    this.state = { data: null };
  }
  componentDidMount() {
    this.setState({ data: 'Hello' });
  }
  render() {
    return <ComposedComponent {...this.props} data={this.state.data} />;
  }
};

class MyComponent {
  render() {
    if (!this.data) return <div>Waiting...</div>;
    return <div>{this.data}</div>;
  }
}
// 고계 컴포넌트를 사용하여 일반 컴포넌트를增强하여,进而 로직复用을 실현
export default Enhance(MyComponent);

이론상, 컴포넌트 타입 파라미터를 받아들이고 컴포넌트를 반환하는 함수는 모두 고계 컴포넌트입니다 ((Component, ...args) => Component). 그러나 조합을 용이하게 하기 위해, Component => Component 형태의 HOC 를 권장하며, [편함수 적용](/articles/기초구문-haskell 노트 1/#articleHeader11) 을 통해 다른 파라미터를传入합니다. 예를 들어:

// React Redux 의`connect`
const ConnectedComment = connect(commentSelector, commentActions)(CommentList);

Mixin 과 비교

HOC 모드에서는, 외층 컴포넌트는 Props 를 통해 내층 컴포넌트의 상태에 영향을 주며, 직접 그 State 를 변경하는 것이 아닙니다:

Instead of managing the component's internal state, it wraps the component and passes some additional props to it.

게다가, 复用 가능한 상태 로직에 대해서는, 이 상태는 상태를 가진 고계 컴포넌트 중에서만 유지되며 (State 를 확장하는 것도 컴포넌트 스코프를 가지는 것에 상당), 충돌과 상호 간섭의 문제는 존재하지 않습니다:

// 이 함수는 컴포넌트를 받아들이고...
function withSubscription(WrappedComponent, selectData) {
  // ...그리고 다른 컴포넌트를 반환...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ...서브스크립션을 처리...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // ...새로운 데이터로 랩된 컴포넌트를 렌더링!
      // 추가 props 도 전달하는 것에 주의
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

가장 중요한 것은, Mixin 의평평하게 하기 + 합병 과는 달리, HOC 는 천연의 계층 구조 (컴포넌트 트리 구조) 를 가지며, 이 분해는 복잡도를 대폭 저감합니다:

This way wrapper's lifecycle hooks work without any special merging behavior, by the virtue of simple component nesting!

결함

HOC 는それほど 많은 치명적인 문제는 없지만, 몇 가지 작은 결함도 존재합니다:

  • 확장성의 제한: HOC 는 Mixin 을 완전히 대체할 수 없음

  • Ref 전달 문제: Ref 가 차단됨

  • Wrapper Hell: HOC 가 범람하여, Wrapper Hell 발생

확장 능력의 제한

일부 시나리오에서는, Mixin 은 가능하지만 HOC 는 불가능합니다. 예를 들어 PureRenderMixin:

PureRenderMixin implements shouldComponentUpdate, in which it compares the current props and state with the next ones and returns false if the equalities pass.

HOC 는 외부에서 자식 컴포넌트의 State 에 액세스할 수 없고, 동시에shouldComponentUpdate를 통해 불필요한 업데이트를 필터링할 수 없기 때문입니다. 따라서, React 는 ES6 Class 를 서포트한 후에 React.PureComponent 를 제공하여 이 문제를 해결했습니다

Ref 전달 문제

Ref 의 전달 문제는 층층 포장 아래에서 매우 성가시며, 함수 Ref 는 일부를 완화할 수 있습니다 (HOC 가 노드의 생성과 파기를 알 수 있도록). 따라서 나중에React.forwardRef API 가 생겼습니다:

function logProps(Component) {
  class LogProps extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('old props:', prevProps);
      console.log('new props:', this.props);
    }

    render() {
      const {forwardedRef, ...rest} = this.props;

      // 커스텀 props"forwardedRef" 를 ref 로 할당
      return <Component ref={forwardedRef} {...rest} />;
    }
  }

  // React.forwardRef 에 의해 제공되는 두 번째 파라미터"ref" 에 주의
  // 이를 일반 props 로서 LogProps 에 전달할 수 있습니다. 예를 들어"forwardedRef"
  // 그리고 그것을 Component 에 연결할 수 있습니다
  return React.forwardRef((props, ref) => {
    return <LogProps {...props} forwardedRef={ref} />;
  });
}

(Forwarding refs in higher-order components 에서 발췌)

Wrapper Hell

1 층 감싸서 해결할 수 없는 문제는 없습니다. 있다면, 2 층 감싸면……

Wrapper Hell 문제가 바로 뒤따릅니다:

You will likely find a"wrapper hell"of components surrounded by layers of providers, consumers, higher-order components, render props, and other abstractions.

다중 추상도 복잡도와 이해 비용을 증가시킵니다. 이것이 가장 중요한 결함이며, HOC 모드에서는很好的인 해결 방법이 없습니다

四.Render Props

HOC 와 마찬가지로, Render Props 도ずっと 존재해 온 원로급 패턴입니다:

The term"render prop"refers to a technique for sharing code between React components using a prop whose value is a function.

예를 들어 커서 위치 관련 렌더링 로직을 추출하여 复用하고, Render Props 패턴을 통해 复用 가능 컴포넌트와 타겟 컴포넌트를 조합합니다:

class Mouse extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>

        {/*
          Instead of providing a static representation of what <Mouse> renders,
          use the`render`prop to dynamically determine what to render.
        */}
        {this.props.render(this.state)}
      </div>
    );
  }
}

즉, 컴포넌트의 일부 렌더링 로직은 외부에서 Props 를 통해 제공되며, 나머지 변하지 않는 부분은 复用 가능 합니다

HOC 와 비교

기술적으로, 둘은 모두 컴포넌트 조합 메커니즘에 기반하며, Render Props 는 HOC 와 같은 확장 능력을 가집니다

Render Props 라고 불리지만, 렌더링 로직의 复用에만 사용되는 것은 아닙니다:

In fact, any prop that is a function that a component uses to know what to render is technically a"render prop".

(Using Props Other Than render 에서 발췌)

이 모드에서는, 컴포넌트는render() 를 통해 조합되는 것을 나타내며, HOC 모드에서 Wrapper 의render() 를 통해 조합 관계를 확립하는 것과 유사합니다

형식적으로, 둘은 매우 유사하며,同样 1 층의「Wrapper」를 생성합니다 (EComponentRP):

// HOC 정의
const HOC = Component => WrappedComponent;
// HOC 사용
const Component;
const EComponent = HOC(Component);
<EComponent />

// Render Props 정의
const RP = ComponentWithSpecialProps;
// Render Props 사용
const Component;
<RP specialRender={() => <Component />} />

더욱 재미있는 것은, Render Props 와 HOC 는 상호 변환까지 가능합니다:

function RP2HOC(RP) {
  return Component => {
    return class extends React.Component {
      static displayName = "RP2HOC";
      render() {
        return (
          <RP
            specialRender={renderOptions => (
              <Component {...this.props} renderOptions={renderOptions} />
            )}
          />
        );
      }
    };
  };
}
// 용법
const HOC = RP2HOC(RP);
const EComponent = HOC(Component);

function HOC2RP(HOC) {
  const RP = class extends React.Component {
    static displayName = "HOC2RP";
    render() {
      return this.props.specialRender();
    }
  };
  return HOC(RP);
}
// 용법
const RP = HOC2RP(HOC);
<RP specialRender={() => <Component />} />

온라인 Demo: https://codesandbox.io/embed/hocandrenderprops-0v72k

P.S. 뷰 내용은 완전히 같지만, 컴포넌트 트리 구조는 크게 다릅니다:

[caption id="attachment_1950" align="alignnone" width="625"]react hoc to render props react hoc to render props[/caption]

React DevTools 를 통해https://0v72k.codesandbox.io/ 를查看할 수 있습니다

五.Hooks

HOC, Render Props, 컴포넌트 조합, Ref 전달……코드复用 왜 이리 복잡한가?

근본적인 이유는, 세밀한 코드复用은 컴포넌트 复用과 함께 묶여서는 안 된다 는 것입니다:

Components are more powerful, but they have to render some UI. This makes them inconvenient for sharing non-visual logic. This is how we end up with complex patterns like render props and higher-order components.

HOC, Render Props 등의 컴포넌트 조합에 기반한 방안은, 复用하고 싶은 로직을 먼저 컴포넌트에 패키지하고, 컴포넌트 复用 메커니즘을 이용하여 로직 复用을 실현���는 것에 상당합니다. 당연히 컴포넌트 复用에 제한받으며, 따라서 확장 능력이 제한되는, Ref 가 차단되는, Wrapper Hell……등의 문제가 발생합니다

그렇다면, 간단하고 직접적인 코드 复用 방식이 있을까요?

함수입니다. 复用 가능 로직을 함수로 추출하는 것이 가장 직접적이고, 비용이 최소인 코드 复用 방식이어야 합니다:

Functions seem to be a perfect mechanism for code reuse. Moving logic between functions takes the least amount of effort.

그러나, 상태 로직에 대해서는,仍然 몇 가지 추상 패턴 (예를 들어 Observable) 을 통해서만 复用을 실현할 수 있습니다:

However, functions can't have local React state inside them. You can't extract behavior like"watch window size and update the state"or"animate a value over time"from a class component without restructuring your code or introducing an abstraction like Observables.

이것이 Hooks 의思路입니다: 함수를 최소의 코드 复用 유닛으로 하고, 동시에 몇 가지 패턴을 내장하여 상태 로직의 复用을 간소화합니다

예를 들어:

function MyResponsiveComponent() {
  const width = useWindowWidth(); // 우리의 커스텀 Hook
  return (
    <p>Window width is {width}</p>
  );
}

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  });

  return width;
}

([Making Sense of React Hooks](https://medium.com/ @dan_abramov/making-sense-of-react-hooks-fdbde8803889) 에서 발췌. 온라인 Demo 는https://codesandbox.io/embed/reac-conf-2018-dan-abramov-hooks-example-mess-around-o5zcu 참조)

선언형 상태 로직 (const width = useWindowWidth()), 시맨틱스가 매우 자연스럽습니다

다른 방안과 비교

위의 다른 방안과 비교하여, Hooks 는 컴포넌트 내 로직 复用이 컴포넌트 复用과 함께 묶이지 않도록 하며, 진짜로 하층에서 (컴포넌트 간의) 세밀한 로직의 复用 문제를 해결하려고 시도하고 있습니다

게다가, 이 선언형 로직 复用 방안은, 컴포넌트 간의 명시적 데이터 흐름과 조합 사상을 더욱 컴포넌트 내로 연장 하여, React 의 이념에 부합합니다:

Hooks apply the React philosophy (explicit data flow and composition) inside a component, rather than just between the components.

결함

Hooks 도 완벽하지는 않으며, 현재 시점에서는, 그 결점은 다음과 같습니다:

  • 추가의 학습 비용 (Functional Component 와 Class Component 사이의 혼란)

  • 작성 방식에 제한이 있음 (조건, 루프 중에 나타날 수 없음), 그리고 작성 방식의 제한은 리팩토링 비용을 증가

  • PureComponent, React.memo 의 얕은 비교의 퍼포먼스 최적화 효과를 파괴 (최신의propsstate 를 취득하기 위해, 매번render() 마다 이벤트 처리 함수를 재생성해야 함)

  • 클로저シーン 에서 오래된state, props 값을 참조할 가능성

  • 내부 구현이 직관적이지 않음 (가변의 글로벌 상태에 의존하며, 그다지「순수」하지 않게 됨)

  • React.memoshouldComponentUpdate 를 완전히 대체할 수 없음 (state change 를 취득할 수 없으므로, props change 만针对)

  • useState API 설계가 그다지 완벽하지 않음

(Drawbacks 에서 발췌)

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성