使用 Hooks 优化 React 组件

提醒:本文最后更新于 1559 天前,文中所描述的信息可能已发生改变,请谨慎使用。

Featured Image

需求描述

由于我所在的业务是资讯内容类业务,因而在业务中会经常碰到如下场景:有一个内容列表,列表中需要按照一定的规则插入广告。除了获取广告数据,广告展现和点击后需要有打点上报逻辑。正常来说我们会这么写:

import React from 'react';
export default class extends React.Component {
  state = {newsData: [], adData: []};
  constructor() { this.getNewsData(); }
  getNewsData() {
    const newsData = [...];
    this.setState({newsData});
    this.getAdData(newsData.length / 2);  //根据新闻数和插入规则换算广告请求数
  }
  getAdData() {
    const adData = [...];
    this.setState({adData});
  }
  render() {
    const {newsData, adData} = this.state;
    const comps = [];
    for(let i = 0; i < newsData.length; i++) {
      // 根据插入规则判断当前新闻卡片后是否要插入广告
      comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
      if(i % 2) { comps.push(<AdCard {...adData[i/2]} key={`ad-${i}`} />); }
    }
    return (<div>{comps}</div>);
  }
}

class AdCard extends React.Component {
  componentDidMount() {
    observe(this.dom, () => {});
  }
  onClick = () => {};
  onMouseUp = () => {};
  onMouseDown = () => {};
  
  getDOM = dom => this.dom = dom;
  render() {
    return <div 
      ref={this.getDOM} 
      onMouseUp={this.onMouseUp} 
      onMouseDown={this.onMouseDown}
      onClick={this.onClick}
    >{this.props.title}</div>
  }
}

逻辑非常的简单,getNewsData() 拿到资讯列表数据之后计算需要请求的广告数调用 getAdData() 请求广告数据,最后根据插入规则将资讯和内容渲染到列表中。广告使用自定义组件渲染,使用 Intersection Observe API 实现广告曝光打点,监听 DOM 对应的点击时间实现广告点击打点。

如果说只有一个组件是这样的还好说,但是从上图可以看出,我们有大量的内容+广告混排场景。整体的逻辑和刚才说的都是一样的,唯一的区别是不同的列表对应不一样的显现形式。在这种情况下如何设计一个既能将通用逻辑提取,又能满足各个模块的自定义需求的通用模块就成了我们必须考虑的事情了。

React 组件设计模式

在具体讨论方案之前,我们先简单的了解一下常见的 React 组件设计模式。基本上分为以下几种方案:

其中 Context 模式多用来在多层嵌套组件中进行跨组件的数据传递,针对我们当前组件层级不多的情况用处不是非常大,这里就不多表。我们来看看剩下的几个模式各自有什么优缺点,最终来评估下是否能应用到我们的场景中。

组合组件

组合组件是通过模块化组件构建应用的模式,它是 React 模块化开发的基础。除去普通的按照正常的业务功能进行模块拆分,还有就是将配置和逻辑进行解耦的组合组件方式。例如下面的组合方式就是利用类似 Vue 的 slot 方式将配置通过子组件的形式与 <Modal /> 组件进行组合,是的组件配置更优雅。

<Modal>
  <Modal.Title>Modal Title</Modal.Title>
  <Modal.Content>Modal Content</Modal.Content>
  <Modal.Footer> <button>OK</button> </Modal.Footer>
</Modal>

又如下面的下拉选择组件,通过将 <Select/><Option> 进行组合,即达到了组件化配置的目的,又达到了通用方法的复用。同时将点击操作在 <Select/> 组件中直接传递下去方便了点击后直接修改选择状态。

export default function(props) {
  return React.Children.map(props.children, child => 
    React.cloneElement(child, {onClick() { console.log('click') }}
  ));
}

<Select>
  <Option>Click Me!</Option>
  <Option>Click Me!</Option>
</Select>

继承模式

继承模式是使用类继承的方式对组件代码进行复用。在面向对象编程模式中,继承是一种非常简单且通用的代码抽象复用方式。如果大部分逻辑相同,只是一些细节不一致,只要简单的将不一致的地方抽成成员方法,继承的时候复写该成员方法即可达到简单的组件复用。

不过我们知道 JS 中的集成本质上还是通过原型链实现的语法糖,所以在一些场景使用上没有其它语言的继承那么方便,例如无法直接实现多继承,多继承后的跨层级方法调用比较麻烦,适合简单的逻辑复用。另外通过继承方式会将父类中的所有方法都继承过来,不小心的话非常容易继承到不需要的功能。

容器组件和展示组件

展示组件和容器组件是将数据逻辑和渲染逻辑进行拆分从而降低组件复杂度的模式。使用容器组件可以把最开始的代码改写成如下的形式。这样做最大的好处是渲染层可以抽离成无状态组件,它不需要关心数据的获取逻辑,直接通过 props 获取数据渲染即可,针对展示组件能实现很好的复用。

class NewsList extends React.Component {
  state = {newsData: [], adData: []};
  constructor() { this.getNewsData(); }
  getNewsData() { this.getAdData(newsData.length / 2) }
  getAdData() {}
  render() { return <List news={this.state.newsData} ad={this.state.adData} /> }
}

function List({news, ad}) {
  const {newsData, adData} = this.state;
  const comps = [];
  for(let i = 0; i < newsData.length; i++) {
    comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
    if(i % 2) { comps.push(<AdCard {...adData[i/2]} key={`ad-${i}`} />); }
  }
  return (<div>{comps}</div>);
}

但是我们也可以看到即使我们把渲染逻辑拆分出去了,本身组件的数据逻辑还是非常的复杂,没有做到很好的拆分。同时容器组件和展示组件存在耦合关系,所以无法很好的对逻辑组件进行复用。

Render Props

术语 “render prop” 是指一种在 React 组件之间使用一个值为函数的 prop 共享代码的简单技术 via: Render Props

它的本质实际上是通过一个函数 prop 将数据传递到其它组件的方式,所以按照这个逻辑我们又可以将刚才的代码简单的改写一下。

class NewsList extends React.Component {
  state = {newsData: [], adData: []};
  constructor() { this.getNewsData(); }
  getNewsData() { this.getAdData(newsData.length / 2) }
  getAdData() {}
  render() { return this.props.render(this.state) }
}
function List({news, ad}) {
  const {newsData, adData} = this.state;
  const comps = [];
  for(let i = 0; i < newsData.length; i++) {
    comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
    if(i % 2) { comps.push(<AdCard {...adData[i/2]} key={`ad-${i}`} />); }
  }
  return (<div>{comps}</div>);
}

<NewsList render={({newsData, adData}) => <List news={newsData} ad={adData} />

可以看到,通过一个函数调用我们将数据逻辑和渲染逻辑进行解耦,解决了之前数据逻辑无法复用的问题。不过通过函数回调的形式将数据传入,如果想要把逻辑拆分(例如资讯数据获取与广告数据获取逻辑拆分)会变得比较麻烦,让我想起了被 callback 支配的恐惧。

同时由于 render 的值为一个匿名函数,每次渲染 <NewsList /> 的时候都会重新生成,而这个匿名函数执行的时候会返回一个 <List /> 组件,这个本质上每次执行也是一个“新”的组件。所以 Render Props 使用不当的话会非常容易造成不必要的重复渲染。

HoC 组件

React 里还有一种使用比较广泛的组件模式就是 HoC 高阶组件设计模式。它是一种基于 React 的组合特性而形成的设计模式,它的本质是参数为组件,返回值为新组件的函数。我们来看看刚才的代码使用 HoC 组件修改后会变成什么样子。

function withNews(Comp) {
  return class extends React.Component {
    state = {newsData: []};
    constructor() { this.getNewsData(); }
    render() { return <Comp {...this.props} news={this.state.newsData} /> }
  }
}
function withAd(Comp) {
  return class extends React.Component {
    state = {adData: []};
    componentWillReceiveProps(nextProps) {
      if(this.props.news.length) { this.getAdData(); }
    }
    render() { return <Comp {...this.props} ad={this.state.adData} /> }
  }
} 
const ListWithNewsAndAd = withAd(withNews(List));

可以看到这次改动最激动的地方在于我们第一次把数据逻辑进行了拆分,这也是高阶组件的魅力,它不局限于 UI 复用,使得代码复用更加自由(当然 Render Props 也是可以实现的)。

当然这种模式也并不是完美的,它也有它的缺点。我们可以看到它的本质是通过 props 在高阶组件中将多个数据传入到子组件中,非常类似 mixin 的形式。所以它也会有 mixin 的缺点,那就是属性名冲突的问题。由于不同的高阶组件由不同的开发者开发,内部会传递什么样的属性名到子组件中就成了未知数。同时多层组件的嵌套导致组件层级过多,在性能和调试上都会带来问题。

初版实现

了解完这些设计模式之后,我们再回头来看看我们的需求。通过观察了解不同的组件中的共同部分之后,我们可以将这种类型的组件抽象为如下描述“在一个内容列表中按照一定规则插入一定数量的和内容一致的一定样式的广告组件”。在这段描述中存在着三个不定因素:

  • 一定规则:不同的组件插入广告的逻辑是不一样的
  • 一定数量:不同的组件由于资讯内容的不同,插入逻辑的不同导致需要的广告数量也是不一样的
  • 一定样式:不同的组件由于资讯内容样式不同所以广告的样式自然也不相同

除却以上三个因素之外,广告其它的逻辑广告数据的获取以及广告的曝光和点击打点等都是通用的。最后我们将广告组件的逻辑顺着之前了解的设计模式抽离成三个部分:

  • 广告数据的获取:<Mediav.Provider />
  • 广告模块的渲染:<Mediav.Item /> Base 模块
  • 广告模块的插入:由具体业务处理
import React from 'react';
import Mediav from '@q/mediav';
export default class extends React.Component {
  state = {newsData: []};
  constructor() { this.getNewsData(); }
  render() {
    const comps = [];
    for(let i = 0; i < newsData.length; i++) {
      comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
      if(i % 2) { comps.push(<AdCard key={`ad-${i}`} />); }
    }
    return (<Mediav.Provider id="xxx">{comps}</Mediav.Provider>);
  }
}
class AdCard extends Mediav.Item {
  render() {
    if(!this.props.type) { return null; }
    const {title} = this.props;
    return (<div ref={this.getDOM} onClick={this.onClick} 
      onMouseUp={this.onMouseUp} onMouseDown={this.onMouseDown}
    >{title}</div>);
  }
}

通过容器组件 <Mediav.Provider /> 对数据获取逻辑进行封装,通过遍历子组件找到 <Mediav.Item /> 组件的示例个数来告知需要请求的广告数量。请求到广告后通过 Props 注入的形式传入到渲染组件中。而渲染组件 <AdCard /> 继承自 <Mediav.Item />,一方面能告诉容器组件它是广告组件的插槽,同时还能抽离广告曝光打点和点击打点等通用逻辑进行复用。在用户自定义的 <AdCard /> 组件中,我们可以自定义不同模块的广告组件的渲染样式,最终完成了一套广告组件的渲染。

不过这样实现还是有一些不足的地方。广告曝光检测需要依赖原生 DOM,而 Ref 使用 forwardRef() 在组件间传递稍微有点复杂,所以最后采用了继承模式进行公共方法的抽离。子组件继承后自行绑定父类的一些方法即可,在这点上理解起来有点晦涩,看起来总像是绑定了一些“不存在”的方法。

React Hooks

针对上面提出的问题,有没有什么方法可以解决呢?最终我想到了 Hooks 的方案,通过使用 Hooks 改写后能完美的解决这个问题。我们先简单的了解下什么是 Hooks,它允许我们在不编写 class 的情况下使用 state 和 React 生命周期等相关特性。

const {useState, useEffect} = React;

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const interval = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(interval);
  });
  
  return <span>{count}</span>;
}

ReactDOM.render(<App />, document.getElementById('app'));

可以看到,它使用 useState 提供了 state,使用 useEffect 来做一些需要在声明周期中执行的方法。使用 useEffect 代理了原来生命周期的概念后,让代码理解起来更加简单。

当然这不是 Hooks 厉害的地方,它最厉害的地方是支持自定义 Hooks,通过自定义 Hooks 你能对逻辑进行统一的封装。针对一个数据获取的逻辑,我们需要定义 state,然后在初始化的时候去获取数据,当 id 发生变化后我们需要重新获取数据。

class User extends React.Component {
  state = {
    user: {}
  }

  constructor(...args) {
    super(...args);
    const {name} = this.props;
    this.getUserInfo(name)
  }

  componentWillReceiveProps(nextProps) {
    if(this.props.name === nextProps.name) {
      return;
    }
    this.getUserInfo(nextProps.name);
  }

  async getUserInfo(name) {
    const user = await fetch(url, {name});
    this.setState({user});
  }

  render() {
    return <div>{this.state.user.name}</div>
  }
}

可以看到我们获取用户信息的这个逻辑要实现需要在组件的各种地方写逻辑,代码一多之后非常容易造成需要各种跳行来查看某个数据逻辑的流程。而通过自定义 Hooks 我们能够将实现这个业务逻辑的代码全部整合到一处,最终达到业务逻辑的复用。

function useUserInfo(name) {
  const [user, setUser] = useState({});
  useEffect(() => {
    fetch(url, {name}).then(user => setUser(user));
  }, [name]);
  return user;
}

function User({name}) {
  const user = useUserInfo(name);
  return <div>{user.name}</div>
}

我们可以从下面的视频中一窥 Hooks 的魅力,同颜色的表示是同一个业务逻辑,最终同颜色的代码都被归置到一处实现了逻辑的解耦。

via: https://twitter.com/prchdk/status/1056960391543062528

使用 Hooks 改进

那 Hooks 是否能应用于我们的业务场景中呢?通过我们之前的分析我们知道,实际上我们的目的就是为了抽离出广告数据获取以及广告的曝光和点击打点这两个通用的业务逻辑出来。所以 Hooks 针对逻辑的封装正好可以为我们所用。

import {useState, useEffect, useRef} from 'react';
import {useFetchMediav, useMediavEvent} from '@q/mediav';

function App() {
  const [newsData, setNewsData] = useState([]);
  const [adData] = useFetchMediav({id: "xxx", length: newsData.length / 2});
  useEffect(() => {
    const newsData = [...];
    setNewsData(newsData);
  }, []);

  const comps = [];
  for(let i = 0; i < newsData.length; i++) {
    comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
    if(i % 2) { comps.push(<AdCard data={adData[Math.floor(i/2)]} key={`ad-${i}`} />); }
  }
  return (<div>{comp}</div>);
}

function AdCard({data}) {
  const ref = useRef(null);
  const bind = useMediavEvent(ref, data);
  return (<div className="gg" ref={ref} {...bind}>{data.title}</div>);
}

使用 useFetchMediav() 获取广告数据,通过 props 传入到 <AdCard /> 组件中,通过 useMediavEvent() 获取打点相关的方法,并绑定到对应的元素上。使用 Hooks 修改之后的代码不仅复用性提高了,整体代码的逻辑也变的更加可阅读起来。

后记

当然 Hooks 本身也不是没有缺点。为了在无状态的函数组件中创造去有状态的 Hooks,势必是需要通过副作用将每个 Hooks 缓存在组件中的。而我们没有指定 id 之类的东西,React 是如何区分每一个 Hooks 的呢?答案就是通过调用顺序。内部通过数组(链表?)根据调用顺序依次记录。为了遵守这个规则,Hooks 要求我们不能在 if 等会动态执行的地方进行 Hooks 的定义,因为这样有可能会导致 Hooks 执行顺序发生变化。其次 useEffect() 合并了多个生命周期,某些 Effect 需要在哪些生命周期执行以及如何控制其仅在这些生命周期执行,这些都对开发者带来了更大的挑战。稍微处理不当的话,很可能会造成页面的性能问题。

参考资料:

Avatar
怡红公子 擅长前端和 Node.js 服务端方向。热爱开源时常在 Github 上活跃,也是博客爱好者,喜欢将所学内容总结成文章分享给他人。

0 评论