Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React 循序渐进的用高阶组件改进代码 #3

Open
bouquetrender opened this issue May 9, 2018 · 0 comments
Open

React 循序渐进的用高阶组件改进代码 #3

bouquetrender opened this issue May 9, 2018 · 0 comments
Labels

Comments

@bouquetrender
Copy link
Owner

bouquetrender commented May 9, 2018

高阶组件(HOC)本质是高阶函数,传入参数变成 React 组件返回一个新组件所以称为高阶组件,核心在于抽离公共逻辑。本文用友好的方式去演示如何将普通的组件代码修改成 HOC。在个人的应用场景中,只要有多个组件或者功能逻辑可复用的话那么做成 HOC 是比较好的选择。或者常用的 React-Redux 中 connent 连接函数就是一个 HOC。

为了展示更加简短,下面这段代码是删减后的 TodoList 组件,App 组件负责定义 todo 参数与组件渲染。TodoList 组件负责渲染 TodoList 整体结构。TodoItem 组件负责渲染单条 Todo 数据。需要做的是用 HOC 和 Compose 去改进它。

function TodoList ({todos, isloading}) {
  if (isloading) return <div><p>Loading ...</p></div>;
  if (!todos) return null;
  if (!todos.length) return <div><p>You have no Todos.</p></div>;

  return (
    <div>
      { todos.map(todoItem => <TodoItem key={todoItem.id} todo={todoItem} />) }
    </div>
  )
}

function TodoItem ({todo}) {
  return <div>todo: {todo.content}</div>
}

class App extends React.Component {
  state = {
    todo: [
      {id: 1, content: 'eat some cake'},
      {id: 2, content: 'drink some juice'}
      {id: 3, content: 'take a break'}
    ],
    isloading: false
  }
  render() {
    let {isloading, todo} = this.state
    return (
      <div>
        <TodoList isloading={isloading} todos={todo}></TodoList>
      </div>
    );
  }
}

以上是基础代码,先看 TodoList 这个函数,定义一个函数 withTodosNull,目的是分离 if (!todos) 这个判断是否存在 todos 的逻辑做成 HOC。HOC 名称一般以 with 开头。

function withTodosNull(Comp) {
  return function (props) {
    return !props.todos
      ? null
      : <Comp {...props} />
  }
}

以上就是一个简单 HOC,可以利用箭头函数对其进行更简略的写法,然后将判断 todos 数组是否存在、是否为空、是否在加载状态这三个逻辑判断都改为 HOC:

const withTodosNull = Comp => (props) => {
  return !props.todos
    ? null
    : <Comp {...props} />
}

const withTodosEmpty = Comp => (props) => {
  return !props.todos.length
    ? <div><p>You have no Todos.</p></div>
    : <Comp {...props} />
}

const withLoadingIndicator = Comp => ({isloading, ...others}) => {
  return isloading
    ? <div><p>Loading todos ...</p></div>
    : <Comp {...others} />
}

由于这里每个 HOC 返回的是 React 组件,匿名返回函数中接收的参数是 props ,所以在 withLoadingIndicator 函数中返回的匿名函数,可以用结构赋值和扩展运算符的方式接收变量。初步改造完成后代码就变成了这样:

const withTodosNull = Comp => (props) => {
  // ...
}
const withTodosEmpty = Comp => (props) => {
  // ...
}
const withLoadingIndicator = Comp => ({isloading, ...others}) => {
  // ...
}
const TodoList = ({todos, isloading}) => {
  // ...
}

const HOC1 = withTodosEmpty(TodoList);
const HOC2 = withTodosNull(HOC1);
const HOC3 = withLoadingIndicator(HOC2);

class App extends React.Component {
  //...
}

在将基础组件传值给 HOC 处理的时候,由于有多个 HOC 所以需要将组件逐个传递,这样的做法非常繁琐,另一种可行的方法就是将其改进为:

const HOC1 = withTodosEmpty(TodoList);
const HOC2 = withTodosNull(HOC1);
const HOC3 = withLoadingIndicator(HOC2);
// ↓ refactor ↓
const HOC3 = withLoadingIndicator(withTodosNull(withTodosEmpty(TodoList)));

但如果需要用到更多的 HOC,那这种嵌套阅读性几乎为0。更好的解决方法是使用 compose 组合函数。这里简单写写关于 compose 的内容,也当做是一个简短的笔记。使用 compose 的目的在于将多个函数组合成一个函数,直接调用一次即可完成多次调用,这样就显得代码不会变的繁琐。一个简单的 compose 函数如下:

const compose = function(f1, f2) {
    return function(x) {
        return f1(f2(x));
    };
}

然后写两个简短的函数利用 compose 组合并尝试调用

const toLowerCase = x => x.toLowerCase()
const outStr = x => `out: ${x}`
const $ = compose(toLowerCase, outStr)
console.log($('CAKE')) // -> out: cake

这个函数缺点在于只能接收2个函数作为参数,如果组合需要多个函数就得把 compose 改进一下通过 while 遍历执行传入的多个函数就可以了:

function compose(...fns) {
  const start = fns.length - 1;
  return function(...args) {
    let i = start;
    let result = fns[start].apply(this, args);
    while (i--) result = fns[i].call(this, result);
    return result;
  }
};

这里将 compose 用变量函数的形式引入到刚刚的组件代码中,就可将繁琐的步骤移除。

// compose
const compose = (...fns) => {
  // ...
}

// Components
const TodoList = ({todos, isloading}) => {
  // ...
}
const TodoItem = ({todo}) => {
  // ...
}

// HOC
const withTodosNull = Comp => (props) => {
  // ...
}
const withTodosEmpty = Comp => (props) => {
  // ...
}
const withLoadingIndicator = Comp => ({isloading, ...others}) => {
  // ...
}
const todosWithHOC = compose(
  withTodosEmpty,
  withTodosNull,
  withLoadingIndicator
)

// render App
const TodosRender = todosWithHOC(TodoList)
class App extends React.Component {
  state = {
    //...
  }
  render() {
    let {isloading, todo} = this.state
    return (
      <div>
        <TodosRender isloading={isloading} todos={todo}></TodosRender>
      </div>
    );
  }
}

改到这里基本就用 HOC 去分离出了所有逻辑代码。接下来重点就是组件的可复用性。先看 withTodosNull 高阶组件里的代码判断渲染逻辑:

const withTodosNull = (Comp) => (props) => {
  return !props.todos
    ? null
    : <Comp {...props} />
}

这个判断是否为 null 逻辑不仅仅是 TodoList 这个组件用到,其他组件参数如果为 null 也不执行的话那么这段逻辑是可以复用的,所以可以将 withTodosNull 这个 HOC 拆分为 isTodosNullwithMaybeNull

const isTodosNull = (props) => !props.todos;
const withMaybeNull = isNull => Comp => (props) => {
  return isNull(props)
    ? null
    : <Comp {...props} />
}

// 在往 compose 传参时将这个 isTodosNull 函数当做参数传入 withMaybeNull 既可
const todosWithHOC = compose(
  withTodosEmpty,
  withMaybeNull(isTodosNull),
  withLoadingIndicator
)

这里我将原来的 !props.todos 单独分离了出来作为一个函数 isTodosNull,这样 withMaybeNull 里就不是判断 props.todos 这个固定值,其他组件也可传入不同的函数参数进行判断,withMaybeNull 就变成任何组件都可以用的 HOC。

修改好 withMaybeNull 后,可以看到另外的两个 withTodosEmptywithLoadingIndicator HOC组件有一个共同点,它们都是根据判断数据不同的情况从而特定展示信息组件或者传入的组件。所以可以根据这个逻辑来定义这样一个公共 HOC:

const withEither = (conditionalRenderFn, EitherCom) => (Comp) => (props) => {
  return conditionalRenderFn(props)
    ? <EitherCom />
    : <Comp {...props} />
}

withEither HOC第一个参数是条件判断方法,第二个参数是条件判断方法成立后返回的组件(EitherCom),否则返回原组件(Comp)。HOC 写好后就可以修改代码了:

// Condition
const isTodosNull = (props) => !props.todos;
const isTodosEmpty = (props) => !props.todos.length;
const isTodosLoading = (props) => props.isloading;

// EitherComp
const emptyTodos = () => {return <div><p>You have no Todos.</p></div>}
const loadingTodos = () => {return <div><p>Loading todos ...</p></div>}

// HOC
const withMaybeNull = isNull => Comp => (props) => {
  //...
}
const withEither = (conditionalRenderFn, EitherCom) => (Comp) => (props) => {
  // ...
}
const todosWithHOC = compose(
  withEither(isTodosEmpty, emptyTodos),
  withMaybeNull(isTodosNull),
  withEither(isTodosLoading, loadingTodos)
)

// Components
const TodoList = (props) => {
  // ...
}
const TodoItem = (props) => {
  // ...
}

// render App
const TodosRender = todosWithHOC(TodoList)
class App extends Component {
  //...
}

可以看到,withTodosEmptywithLoadingIndicator HOC都已经移除,在 compose 组合函数中也替换为了新写的 withEither HOC函数,传入判断函数和判断方法成立后返回的组件既可,到这里代码的整体改进全部完成。完整代码可以在Github TodoList Demo查看。

在实际项目开发中,我个人是比较困难去做到对每一个组件都进行非常细微的拆分和重构处理,但如果写组件的时候多注意多使用这样的方式去写抽离共用逻辑的话,那么维护起来至少比普通组件要方便。更希望是能通过这种简单的思路能引起平时写组件代码时对复用结构的思考。还有一种常用到的 HOC 形式:

const makeProvider = (data) => (Comp) => {
  return class extends Component {
    static childContextTypes = {
      data: PropTypes.any.isRequired
    }
    constructor(props) {
      super(props)
    }
    getChildContext() {
      return { data }
    }
    render() {
      return <Comp {...this.props} />
    }
  }
}
const CompTest = // ...
const withProviderComp = makeProvider({ name: 'Ruby' })(CompTest)
export default withProviderComp

在这个例子中,makeProvider 这个 HOC 接收一个任意类型的数据和组件作为参数并返回一个匿名类,目的在于使得 CompTest 下的所有子组件都可以通过 this.context.data 获取到传给 makeProvider 的参数。这里只是为了演示用,官网并不推荐使用 Context。

关于写 HOC 需要注意的可以阅读官网文档,包括为了在 Chrome 调试方便添加 displayName,禁止在 render 中使用高阶函数等等。其实在这篇文章中写的 HOC 为了简约代码是返回了一个匿名函数,实际开发中更多写的是一个 Class 类除非是木偶组件,或者将 HOC 单独放在一个 React 文件中导出引用。

额外内容

一个小 Tips 是关于 React-Redux 的,下面是一个非常简单的例子,获取day和设置day的函数并用 connect 将这两个函数和 CompTest 关联上:

export default class CompTest extends Component {
  //...
}

const mapStateToProps = state => {
  return {
    day: state.IndexReducers.get("day")
  };
}

const mapDispatchToProps = dispatch => {
  return {
    setDay: data => {
      dispatch(setDayActions(data));
    }
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(CompTest);

利用 ES7 的新语法 Decorator 后,可以将这段代码改成下面这样显得更加简易:

@connect(
  state => {
    return {
      day: state.IndexReducers.day
    };
  },
  dispatch => {
    return {
      setDay: data => {
        dispatch(setDayActions(data));
      }
    };
  }
)
export default class CompTest extends Component {
  //...
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant