본문으로 건너뛰기

react-redux 소스코드 해석

무료2017-10-29#JS#react-redux原理#react-redux connect#react-redux Provider#react-redux剖析

react&redux 애플리케이션에서 뷰 업데이트 성능의 핵심 포인트

앞에 쓰는 말

react-redux 는 접착제 같은 것으로, 깊이 이해할 필요가 없는 것처럼 보이지만, 실제로는 데이터 계층 (redux) 과 UI 계층 (react) 의 연결점으로서 그 구현 세부사항은 전체 성능에 결정적인 영향을 미칩니다. 컴포넌트 트리가 함부로 update 하는 비용은 reducer 트리를 몇 번 더 실행하는 비용보다 훨씬 높으므로 그 구현 세부사항을 이해할 필요가 있습니다

react-redux 를 자세히 이해하는 장점 중 하나는 성능에 대한 기본적인 인식을 가질 수 있다는 것입니다. 다음 문제를 고려해 보세요:

dispatch({type: 'UPDATE_MY_DATA', payload: myData})

컴포넌트 트리 어딘가의 이 코드가 가져오는 성능 영향은 무엇일까요? 몇 가지 하위 문제:

  • 1.어떤 reducer 가 재계산되었나요?

  • 2.발생한 뷰 업데이트는 어떤 컴포넌트에서 시작되나요?

  • 3.어떤 컴포넌트의 render 가 호출되었나요?

  • 4.모든 리프 컴포넌트가 diff 의 영향을 받았나요? 왜요?

이러한 문제들에 정확히 답할 수 없다면 성능에 대해 확신이 없을 것입니다

一.역할

먼저, redux 는 단순한 데이터 계층이고 react 는 단순한 UI 계층이며, 둘 사이에는 연관이 없음을 명확히 합니다

왼손과 오른손으로 각각 redux 와 react 를 들고 있다면, 실제 상황은 이렇습니다:

  • redux 는 데이터 구조 (state) 와 각 필드의 계산 방식 (reducer) 을 모두 정했습니다

  • react 는 뷰 설명 (Component) 에 따라 초기 페이지를 렌더링합니다

이런 모습일 수 있습니다:

       redux      |      react

myUniversalState  |  myGreatUI
  human           |    noOneIsHere
    soldier       |
      arm         |
    littleGirl    |
      toy         |
  ape             |    noOneIsHere
    hoho          |
  tree            |    someTrees
  mountain        |    someMountains
  snow            |    flyingSnow

왼쪽 redux 에는 모든 것이 있지만 react 는 알지 못하고 기본 요소만 표시합니다 (데이터가 없음). 일부 컴포넌트는 로컬 state 와 흩어진 props 전달이 있으며, 페이지는 한 프레임의 정적 그림처럼 보이고 컴포넌트 트리는 몇 개의 파이프 로 연결된 큰 틀처럼 보입니다

이제 react-redux 를 추가하는 것을 고려하면 이렇습니다:

             react-redux
       redux     -+-     react

myUniversalState  |  myGreatUI
            HumanContainer
  human          -+-   humans
    soldier       |      soldiers
            ArmContainer
      arm        -+-       arm
    littleGirl    |      littleGirl
      toy         |        toy
            ApeContainer
  ape            -+-   apes
    hoho          |      hoho
           SceneContainer
  tree           -+-   Scene
  mountain        |     someTrees
  snow            |     someMountains
                         flyingSnow

주의: Arm 상호작용이 복잡하여 상위 (HumanContainer) 가 제어하기에 적합하지 않아 중첩된 Container 가 나타났습니다

Container 는 redux 의 state 를 react 에 넘겨주어 초기 데이터를 얻습니다. 그렇다면 뷰를 업데이트하려면 어떻게 해야 할까요?

Arm.dispatch({type: 'FIRST_BLOOD', payload: warData})

누군가 첫 번째 총성을 울려 soldier 가 한 명 쓰러졌습니다 (state change). 그러면 이러한 부분들이 변화합니다:

                react-redux
          redux     -+-     react
myNewUniversalState  |  myUpdatedGreatUI
              HumanContainer
     human          -+-   humans
       soldier       |      soldiers
                     |      diedSoldier
                ArmContainer
         arm        -+-       arm
                     |          inactiveArm

페이지상에 쓰러진 soldier 와 땅에 떨어진 arm 이 나타납니다 (update view). 다른 부분 (ape, scene) 은 모두 정상입니다

위에서 설명한 것이 react-redux 의 역할입니다:

  • state 를 redux 에서 react 로 전달

  • redux state change 후 react view 를 update 하는 책임

추측컨대 구현은 3 부분으로 나뉩니다:

  1. 파이프 로 연결된 큰 틀에 작은 수원들을 하나씩 추가 (Container 를 통해 state 를 props 로 아래 view 에 주입)

  2. 작은 수원들이 물을 뿜어내게 함 (state change 를 감시하고 Container 의 setState 를 통해 아래 view 업데이트)

  3. 작은 수원들이 함부로 뿜어내지 않게 함 (성능 최적화 내장, 캐시된 state, props 비교하여 업데이트 필요성 확인)

二.핵심 구현

소스코드의 핵심 부분은 다음과 같습니다:

// from: src/components/connectAdvanced/Connect.onStateChange
onStateChange() {
  // state change 시 props 재계산
  this.selector.run(this.props)

  // 현재 컴포넌트가 업데이트 불필요 시, 아래 container 에 업데이트 확인 통지
  // 업데이트 필요 시, setState 로 빈 객체 강제 업데이트, 통지를 didUpdate 로 연기
  if (!this.selector.shouldComponentUpdate) {
    this.notifyNestedSubs()
  } else {
    this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
    // Container 아래 view 에 업데이트 통지
//!!! 여기가 redux 와 react 를 연결하는 핵심
    this.setState(dummyState)
  }
}

가장 중요한setState가 여기에 있습니다. dispatch action 후 뷰 업데이트의 비밀은 이렇습니다:

1.dispatch action
2.redux 가 reducer 계산하여 newState 획득
3.redux 가 state change 트리거 (store.subscribe 로 등록한 state 변화 리스너 호출)
4.react-redux 최상위 Container 의 onStateChange 트리거
  1.props 재계산
  2.새 값과 캐시 값 비교, props 변경 여부, 업데이트 필요 여부 확인
  3.필요 시 setState({}) 로 react 강제 업데이트
  4.아래 subscription 에 통지, state change 를 감시하는 아래 Container 의 onStateChange 트리거하여 view 업데이트 필요 여부 확인

3 단계에서, react-redux 가 redux 에 store change 리스너 등록 동작은connect()(myComponent)시에 발생합니다. 실제로 react-redux 는 최상위 Container 만 redux 의 state change 를 직접 감시하고, 아래 Container 는 내부에서 통지를 전달합니다. 다음과 같습니다:

// from: src/utils/Subscription/Subscription.trySubscribe
trySubscribe() {
  if (!this.unsubscribe) {
    // 부모 레벨 옵저버가 없으면 store change 직접 감시
    // 있으면 부모 아래에 추가, 부모로부터 변화 전달
    this.unsubscribe = this.parentSub
      ? this.parentSub.addNestedSub(this.onStateChange)
      : this.store.subscribe(this.onStateChange)
  }
}

여기서 redux 의 state change 를 직접 감시하지 않고 Container 의 state change listener 를 직접 유지하는 것은 순서를 제어 가능하게 하기 위함입니다. 예를 들어 위에서 언급한:

// 업데이트 필요 시, 통지를 didUpdate 로 연기
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate

이렇게 하여listener 트리거 순서가 보장되며, 컴포넌트 트리 계층 순서대로, 먼저 큰 서브트리 업데이트를 통지하고, 큰 서브트리 업데이트 완료 후, 작은 서브트리 업데이트를 통지합니다

업데이트의 전체 과정은 이렇습니다.至于「Container 를 통해 state 를 props 로 아래 view 에 주입하는」단계는 말할 것이 없습니다. 다음과 같습니다:

// from: src/components/connectAdvanced/Connect.render
render() {
  return createElement(WrappedComponent, this.addExtraProps(selector.props))
}

WrappedComponent 가 필요한 state 필드에 따라 props 를 만들어React.createElement을 통해 주입합니다.ContainerInstance.setState({})시, 이render함수가 재호출되고 새로운 props 가 view 에 주입되며, view will receive props...뷰 업데이트가 실제로 시작됩니다

三.기술

순수 함수에 상태 부여

function makeSelectorStateful(sourceSelector, store) {
  // wrap the selector in an object that tracks its results between runs.
  const selector = {
    run: function runComponentSelector(props) {
      try {
        const nextProps = sourceSelector(store.getState(), props)
        if (nextProps !== selector.props || selector.error) {
          selector.shouldComponentUpdate = true
          selector.props = nextProps
          selector.error = null
        }
      } catch (error) {
        selector.shouldComponentUpdate = true
        selector.error = error
      }
    }
  }

  return selector
}

순수 함수를 객체로 감싸면 로컬 상태를 가질 수 있습니다. new Class Instance 와 유사한 역할입니다. 이렇게 하면순수한 부분과 불순한 부분을 분리할 수 있습니다. 순수한 부분은 여전히 순수하고, 불순한 부분은 바깥에 있습니다. class 는 이만큼 깔끔하지 않습니다

기본 파라미터와 객체 구조분해

function connectAdvanced(
  selectorFactory,
  // options object:
  {
    getDisplayName = name => `ConnectAdvanced(${name})`,
    methodName = 'connectAdvanced',
    renderCountProp = undefined,
    shouldHandleStateChanges = true,
    storeKey = 'store',
    withRef = false,
    // additional options are passed through to the selectorFactory
    ...connectOptions
  } = {}
) {
  const selectorFactoryOptions = {
    // 전개 원래대로 복원
    ...connectOptions,
    getDisplayName,
    methodName,
    renderCountProp,
    shouldHandleStateChanges,
    storeKey,
    withRef,
    displayName,
    wrappedComponentName,
    WrappedComponent
  }
}

이렇게 단순화할 수 있습니다:

function f({a = 'a', b = 'b', ...others} = {}) {
    console.log(a, b, others);
    const newOpts = {
      ...others,
      a,
      b,
      s: 's'
    };
    console.log(newOpts);
}
// test
f({a: 1, c: 2, f: 0});
// 출력
// 1 "b" {c: 2, f: 0}
// {c: 2, f: 0, a: 1, b: "b", s: "s"}

여기서 3 가지 es6+ 의 작은 기술을 사용했습니다:

  • 기본 파라미터. 구조분해 시 오른쪽 undefined 로 에러 발생하는 것 방지

  • 객체 구조분해. 나머지 속성을 모두 others 객체에 담음

  • 전개 연산자. others 를 전개하여 속성을 타겟 객체에 병합

기본 파라미터는 es6 기능으로, 말할 것이 없습니다. 객체 구조분해는 Stage 3 proposal 이며, ...others는 그 기본 사용법입니다. 전개 연산자는 객체를 전개하여 타겟 객체에 병합합니다. 복잡하지 않습니다

흥미로운 것은 여기서 객체 구조분해와 전개 연산자를配合하여 사용하여 파라미터에 대해팩킹-복원이 필요한 시나리오를 구현했다는 것입니다. 이 2 가지 기능을 사용하지 않으면 이렇게 해야 할 수 있습니다:

function connectAdvanced(
  selectorFactory,
  connectOpts,
  otherOpts
) {
  const selectorFactoryOptions = extend({},
    otherOpts,
    getDisplayName,
    methodName,
    renderCountProp,
    shouldHandleStateChanges,
    storeKey,
    withRef,
    displayName,
    wrappedComponentName,
    WrappedComponent
  )
}

connectOptsotherOpts를 명확히 구분해야 하며, 구현이 조금 번거롭습니다. 이러한 기술들을 조합하면 코드가 상당히 간결해집니다

또한 1 가지 es6+ 의 작은 기술이 있습니다:

addExtraProps(props) {
  //! 기술 최소 지식을 확보하기 위한 얕은 복사
  //! props 를 얕게 복사, 필요 없는 것을 전달하지 않음.否则影响 GC
  const withExtras = { ...props }
}

참조가 하나 늘면 메모리 누수 위험이 하나 늘납니다. 필요 없는 것은 주지 않아야 합니다 (최소 지식)

파라미터 패턴 매칭

function match(arg, factories, name) {
  for (let i = factories.length - 1; i >= 0; i--) {
    const result = factories[i](arg)
    if (result) return result
  }

  return (dispatch, options) => {
    throw new Error(`Invalid value of type ${typeof arg} for ${name} argument when connecting component ${options.wrappedComponentName}.`)
  }
}

여기서factories는 이렇습니다:

// mapDispatchToProps
[
  whenMapDispatchToPropsIsFunction,
  whenMapDispatchToPropsIsMissing,
  whenMapDispatchToPropsIsObject
]
// mapStateToProps
[
  whenMapStateToPropsIsFunction,
  whenMapStateToPropsIsMissing
]

파라미터의 다양한 상황에 대해 일련의 case 함수를 만들고, 파라미터를 모든 case 에 순차적으로 흐르게 하여, 어느 하나에 매치되면 그 결과를 반환하고, 모두 매치되지 않으면 에러 case 로 들어갑니다

switch-case 와 유사하며, 파라미터의 패턴 매칭에 사용됩니다. 이렇게 하면 다양한 case 가 분해되어 각각책임이 명확해집니다 (각 case 함수의 명명이 매우 정확합니다)

지연 파라미터

function wrapMapToPropsFunc() {
  // 추측 후 즉시 props 를 한 번 계산
  let props = proxy(stateOrDispatch, ownProps)
  // mapToProps 는 function 반환을 지원, 다시 한 번 추측
  if (typeof props === 'function') {
    proxy.mapToProps = props
    proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
    props = proxy(stateOrDispatch, ownProps)
  }
}

여기서 지연 파라미터란:

// 반환값을 파라미터로, props 를 다시 한 번 계산
if (typeof props === 'function') {
  proxy.mapToProps = props
  proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
  props = proxy(stateOrDispatch, ownProps)
}

이 구현은 react-redux 가 직면한 시나리오와 관련이 있습니다. function 반환을 지원하는 것은 주로 컴포넌트 인스턴스 레벨 (기본은 컴포넌트 레벨) 의 세밀한 mapToProps 제어를 지원하기 위함입니다. 이렇게 하면 다른 컴포넌트 인스턴스에 대해 다른 mapToProps 를 줄 수 있어 성능을 더욱 향상시킬 수 있습니다

구현으로 보면,실제 파라미터를 지연시킨 것으로, 파라미터 팩토리를 파라미터로传入하는 것을 지원합니다. 첫 번째에 외부 환경을 팩토리에 전달하고, 팩토리가 환경에 따라 실제 파라미터를 만듭니다. 팩토리라는环节을 추가하여제어 세밀도를 한 레벨 더 세분화했습니다 (컴포넌트 레벨에서 컴포넌트 인스턴스 레벨로, 외부 환경은 컴포넌트 인스턴스 정보)

P.S.지연 파라미터 관련 논의는 https://github.com/reactjs/react-redux/pull/279 참조

四.의문

1.기본 props.dispatch 는 어디서 오는가?

connect()(MyComponent)

connect 에 파라미터를 아무것도 주지 않아도 MyComponent 인스턴스는dispatch라는 prop 을 얻을 수 있습니다. 어디에서 몰래 달아놓은 것일까요?

function whenMapDispatchToPropsIsMissing(mapDispatchToProps) {
  return (!mapDispatchToProps)
    // 여기에 달아놓은 것. mapDispatchToProps 를 주지 않으면 기본적으로 dispatch 를 props 에 달아줌
    ? wrapMapToPropsConstant(dispatch => ({ dispatch }))
    : undefined
}

기본적으로mapDispatchToProps = dispatch => ({ dispatch })가 내장되어 있어 컴포넌트 props 에dispatch가 있습니다.mapDispatchToProps를 지정하면 달아주지 않습니다

2.다중 Container 는 성능 문제에 직면하는가?

이러한 시나리오를 고려:

App
  HomeContainer
    HomePage
      HomePageHeader
        UserContainer
          UserPanel
            LoginContainer
              LoginButton

중첩된 container 가 나타났습니다. HomeContainer 가 주목하는 state 가 변화할 때, 뷰 업데이트를 여러 번 통과할까요? 예를 들어:

HomeContainer update-didUpdate
UserContainer update-didUpdate
LoginContainer update-didUpdate

이렇다면 가벼운 한 번의 dispatch 로 3 개의 서브트리가 업데이트되어 성능이 폭발할 것 같습니다

실제로는그렇지 않습니다. 다중 Container 에 대해 두 번 통과하는 상황은確かに 존재하지만, 여기의 두 번 통과는 뷰 업데이트를指하는 것이 아니라 state change 통지를指합니다

상위 Container 는 didUpdate 후 아래 Container 에 업데이트 확인을 통지하여 작은 서브트리에서 다시 한 번 통과할 수 있습니다. 하지만 큰 서브트리 업데이트 과정에서 아래 Container 에 도달할 때, 작은 서브트리는 이 타이밍에 업데이트를 시작합니다. 큰 서브트리의 didUpdate 후 통지는 아래 Container 에 빈으로 한 번 확인만 하게 하며,실제 업데이트는 없습니다

확인하는 구체적인 비용은 state 와 props 에 대해 각각===비교와 얕은 참조 비교 (이것도 먼저===비교) 를 수행하고, 변화가 없으면 종료합니다. 따라서 각 아래 Container 의성능 비용은 두 개의===비교이며, 문제없습니다. 즉, 중첩된 Container 사용으로 인한 성능 오버헤드를 걱정할 필요 없습니다

五.소스코드 분석

Github 주소:https://github.com/ayqy/react-redux-5.0.6

P.S.주석은 여전히 충분히 상세합니다.

댓글

아직 댓글이 없습니다

댓글 작성