仿 react-redux connect 实现自定义装饰器开发

708 阅读4分钟

什么是装饰器(Decorator)?

注:装饰器只能用于类和类的方法,不能用于函数,因为存在函数提升。

import React from 'react'
import {connect} from 'react-redux'

@connect()
export default class Demo extends Components{
  //...
}

@connect就是装饰器(Decorator),如果熟悉react-redux就一定会知道 @connect的另一种写法。

import React from 'react'
import {connect} from 'react-redux'
class Demo extends Components{
  //...
}
export default connect()(Demo)

所以显而易见装饰器的本质是函数,一个被柯里化的高阶组件(HOC)。

function connect (){
  //...
  return function (WrappedComponent){
    //...
    return <WrappedComponent/>
  }
}

什么是高阶组件(HOC)?

概念:高阶组件是参数为组件,返回值为新组件的函数

高阶组件能做什么?

我们都知道自定义Hook可以将组件逻辑提取到可重用的函数中,从而使不同的组件更好的复用逻辑。那类组件又要如何做到逻辑复用呢?答案是高阶组件。

合并静态方法

静态方法:类内部的方法都会被子类继承,但是使用静态方法定义的不会被子类继承,也不会初始化到实例对象中

有时,需要在React组件上面定义静态方法,但当我们给一个组件添加一个HOC时,原来的组件会被一个container的组件包裹。这意味着新的组件不会有原来组件任何静态方法。那么参考react-redux源码。

import hoistStatics from 'hoist-non-react-statics';
//...
function wrapWithConnect(WrappedComponent) {
   //...
  if (forwardRef) {
    const _forwarded = React.forwardRef(function forwardConnectRef(props,ref) {
      return <Connect {...props} reactReduxForwardedRef={ref} />
    })

    const forwarded = _forwarded as ConnectedWrapperComponent
    forwarded.displayName = displayName
    forwarded.WrappedComponent = WrappedComponent
    return hoistStatics(forwarded, WrappedComponent)
  }

  return hoistStatics(Connect, WrappedComponent)
}

不难发现react-redux是使用hoist-non-react-statics合并了静态方法。那我们来看看hoist-non-react-statics做了什么。

// ...

function getStatics(component) {
  // React v16.11 and below
  if (reactIs.isMemo(component)) {
    return MEMO_STATICS;
  } // React v16.12 and above


  // memo 组件与 forwardRef z组件静态属性及方法 || react class 组件静态属性及方法
  return TYPE_STATICS[component['$$typeof']] || REACT_STATICS;
}

// ...

export default function hoistNonReactStatics(targetComponent, sourceComponent, excludelist) {
    if (typeof sourceComponent !== 'string') { // don't hoist over string (html) components

        if (objectPrototype) {
            const inheritedComponent = getPrototypeOf(sourceComponent);
            if (inheritedComponent && inheritedComponent !== objectPrototype) {
                hoistNonReactStatics(targetComponent, inheritedComponent, excludelist);
            }
        }

       // 获取所有原型属性名 Object.getOwnPropertyNames
        let keys = getOwnPropertyNames(sourceComponent);

       // 获取对象自身的所有 Symbol 属性的数组 Object.getOwnPropertySymbols
        if (getOwnPropertySymbols) {
            keys = keys.concat(getOwnPropertySymbols(sourceComponent));
        }

       // 获取目标组件与来源组件的静态方法(注意:这里返回的静态方法指的是定义好的不参与合并的方法及属性)
        const targetStatics = getStatics(targetComponent);
        const sourceStatics = getStatics(sourceComponent);

        for (let i = 0; i < keys.length; ++i) {
            const key = keys[i];
           
           // 排除过滤列表、 来源组件及目标组件中不需要参与合并的方法及属性
            if (!KNOWN_STATICS[key] &&
                !(excludelist && excludelist[key]) &&
                !(sourceStatics && sourceStatics[key]) &&
                !(targetStatics && targetStatics[key])
            ) {
               // 获取对象自有属性对应的属性描述符 Object.getOwnPropertyDescriptor
                const descriptor = getOwnPropertyDescriptor(sourceComponent, key);
                try { // Avoid failures from read-only properties

                   // 合并方法及属性 Object.defineProperty
                    defineProperty(targetComponent, key, descriptor);
                } catch (e) {}
            }
        }
    }

    return targetComponent;
};

业务案例

以商城业务出发,必然有各种不同的商品列表页,但有着相同的加购逻辑。在函数式组件中,我们可以用自定义Hook实现逻辑的抽离和复用,在类组件中就需要用到高阶组件。

// 高阶组件
import React from 'react'
function addCartHOC (WrappedComponent){
  return class AddCart extends Components{
    
    constructor(props) {
      super(props);
    }
    
    /**
      * 加购方法
      */
    handleAddCart = ()=>{
      ...
    }
    
    render(){
      return <WrappedComponent {...this.props} onAddCart={this.handleAddCart}/>
    }
  }
}

那么我们就可以在页面组件中这样使用

// 页面组件
import React from 'react'
class Page extends Components {
  constructor(props) {
    super(props);
  }
  
  // 静态方法
  static staticMethod (){
    
  }
  
  render(){
    const { onAddCart } = this.props
    return (
       <div>
        <button onClick={onAddCart}>加购</button>
      </div>
    )
  }
}
export default addCartHOC(Page)

而我们也可以进一步改进我们的高阶组件,实现装饰器并合并静态方法。

// 装饰器
import React from 'react'import hoistStatics from 'hoist-non-react-statics';

function addCart() {
  return function addCartHOC(WrappedComponent) {
    return class AddCart extends Components {

      constructor(props) {
        super(props);
      }

      /**
       * 加购方法
       */
      handleAddCart = () => {
        //...
      }

      render() {
        return <WrappedComponent {...this.props} onAddCart={this.handleAddCart}/>
      }
    }
    
    // 合并静态方法
    return hoistStatics(AddCart,WrappedComponent)
  }
}

那么我们在页面组件上就可以使用更简洁的使用高阶函数

// 页面组件
import React from 'react'

@addCart
export default class Page extends Components {
  constructor(props) {
    super(props);
  }
  
  render(){
    const { onAddCart } = this.props
    return (
       <div>
        <button onClick={onAddCart}>加购</button>
      </div>
    )
  }
}

Taro 中使用

当我们使用上面开发的装饰器addCart,复用至页面组件时会发现小程序的生命周期,例如:onLoadonShow(即Taro页面组件的生命周期 componentDidShow )等无法触发。

那是因为Taro 处理生命周期的逻辑,如 onShow的流程是由小程序触发 onShow,找到页面组件实例,通过ref调页面组件实例用上面的 componentDidShow 方法。那么结合Taroreact-redux源码来看具体实现。

  • Taro中通过props传入reactReduxForwardedRef,

    // packages/taro-runtime/src/dsl/react.ts
    import { isFunction, ensure, EMPTY_OBJ } from '@tarojs/shared'
    
    //......
    
    const h = R.createElement // 这里对于 react 就是 react.createElement
    
    //.......
    
    // 是否为类组件由于 react-redux 是由函数式组件作为高阶函数返回的
    const refs = isReactComponent ? { ref: inject } : {
      forwardedRef: inject,
      // react-redux 高阶组件的 ref 参数
      reactReduxForwardedRef: inject
    }
    
    // .......
    
    // 通过 props 传入组件
    h(component, {
      ...this.props,
      ...refs
    })
    
  • react-reduxconnect方法中

    function connect(mapStateToProps, mapDispatchToProps, mergeProps, option) {
    
      // ......
    
      const wrapWithConnect = (WrappedComponent) => {
    
        // ......
    
        function ConnectFunction() {
          // ......
          const renderedWrappedComponent = useMemo(() => {
            return (
              // @ts-ignore
              <WrappedComponent
                {...actualChildProps}
                ref={reactReduxForwardedRef}
              />
            )
          }, [ reactReduxForwardedRef, WrappedComponent, actualChildProps ]
    
          // If React sees the exact same element reference as last time, it bails out of re-rendering
          // that child, same as if it was wrapped in React.memo() or returned false from shouldComponentUpdate.
          const renderedChild = useMemo(() => {
            if (shouldHandleStateChanges) {
              return (
                <ContextToUse.Provider value={overriddenContextValue}>
                  {renderedWrappedComponent}
                </ContextToUse.Provider>
              )
            }
            return renderedWrappedComponent
          }, [ ContextToUse, renderedWrappedComponent, overriddenContextValue ])
    
          return renderedChild
    
        }
    
        //.......
    
        const _Connect = React.memo(ConnectFunction)
    
        //.......
    
        const Connect = _Connect
    
        if (forwardRef) {
          const _forwarded = React.forwardRef(function forwardConnectRef(
            props,
            ref
          ) {
            // @ts-ignore
            return <Connect {...props} reactReduxForwardedRef={ref} />
          })
    
          const forwarded = _forwarded as ConnectedWrapperComponent
          forwarded.displayName = displayName
          forwarded.WrappedComponent = WrappedComponent
          return hoistStatics(forwarded, WrappedComponent)
        }
    
        return hoistStatics(Connect, WrappedComponent)
     }
    
    // ......
    
    return wrapWithConnect
    }
    
    

    其中 <WrappedComponent {...actualChildProps} ref={reactReduxForwardedRef} />Taro中通过props传入的reactReduxForwardedRef相对应实现了小程序生命周期事件的转发与触发。

装饰器实现修改

那么我们修改装饰器代码

// 装饰器
import React, { Component } from 'react'
import hoistStatics from 'hoist-non-react-statics'

export function addCart() {
  return function addCartHOC(WrappedComponent) {
    class AddCart extends Component {
      constructor(props) {
        console.log('constructor', { props })
        super(props)
      }

      /**
       * 加购方法
       */
      handleAddCart = () => {
        //...
      }

      render() {
        const { wrappedComponentRef } = this.props
        // 这里考虑到页面可能使用 @connect 装饰器, 需要兼容故增加 reactReduxForwardedRef 传参
        return <WrappedComponent {...this.props} ref={wrappedComponentRef} reactReduxForwardedRef={wrappedComponentRef}  onAddCart={this.handleAddCart} />
      }
    }

    const HoistComponent = hoistStatics(AddCart, WrappedComponent)

    return React.forwardRef((props, ref) => <HoistComponent {...props} wrappedComponentRef={ref} />)

  }
}

对应的我们页面组件则不需要改变

// 页面组件
import React from 'react'

@addCart
export default class Page extends Components {
  constructor(props) {
    super(props);
  }
  
  render(){
    const { onAddCart } = this.props
    return (
       <div>
        <button onClick={onAddCart}>加购</button>
      </div>
    )
  }
}

最后

装饰器是可以传递参数的与react-reduxconnect方法类似,只是太简单了没什么好写的。熟悉vue的可以把高阶函数当成vue里的mixin