React-Hooks-加持下的函数组件设计

37次阅读

共计 4289 个字符,预计需要花费 11 分钟才能阅读完成。

有了 React Hooks 的加持,妈妈再也不用担心函数组件记不住状态

过去,React 中的函数组件都被称为 无状态函数式组件(stateless functional component),这是因为函数组件没有办法拥有自己的状态,只能根据 Props 来渲染 UI,其性质就相当于是类组件中的 render 函数,虽然结构简单明了,但是作用有限。

但自从 React Hooks 横空出世,函数组件也拥有了保存状态的能力,而且也逐渐能够覆盖到类组件的应用场景,因此可以说 React Hooks 就是未来 React 发展的方向。

React Hooks 解决了什么问题

复杂的组件难以分拆

我们知道组件化的思想就是将一个复杂的页面 / 大组件,按照不同层次,逐渐抽象并拆分成功能更纯粹的小组件,这样一方面可以减少代码耦合,另外一方面也可以更好地复用代码;但实际上,在使用 React 的类组件时,往往难以进一步分拆复杂的组件,这是因为逻辑是有状态的,如果强行分拆,会令代码复杂性急剧上升;如使用 HOC 和 Render Props 等设计模式,这会形成“嵌套地狱”,使我们的代码变得晦涩难懂。

状态逻辑复杂,给单元测试造成障碍

这其实也是上一点的延续:要给一个拥有众多状态逻辑的组件写单元测试,无疑是一件令人崩溃的事情,因为需要编写大量的测试用例来覆盖代码执行路径。

组件生命周期繁复

对于类组件,我们需要在组件提供的生命周期钩子中处理状态的初始化、数据获取、数据更新等操作,处理起来本身逻辑就比较复杂,而且各种“副作用”混在一起也使人头晕目眩,另外还很可能忘记在组件状态变更 / 组件销毁时消除副作用。

React Hooks 就是来解决以上这些问题的

  • 针对状态逻辑分拆复用难的问题:其实并不是 React Hooks 解决的,函数这一形式本身就具有逻辑简单、易复用等特性。
  • 针对组件生命周期繁复的问题:React Hooks 屏蔽了生命周期这一概念,一切的逻辑都是由状态驱动,或者说由数据驱动的,那么理解、处理起来就简单多了。

利用自定义 Hooks 捆绑封装逻辑与相关 state

我认为 React Hooks 的亮点不在于 React 官方提供的那些 API,那些 API 只是一些基础的能力;其亮点还是在于 自定义 Hooks —— 一种封装复用的设计模式。

例如,一个页面上往往有很多状态,这些状态分别有各自的处理逻辑,如果用类组件的话,这些状态和逻辑都会混在一起,不够直观:

class Com extends React.Component {
    state = {
        a: 1,
        b: 2,
        c: 3,
    }
    
    componentDidMount() {handleA()
        handleB()
        handleC()}
}

而使用 React Hooks 后,我们可以把状态和逻辑关联起来,分拆成多个自定义 Hooks,代码结构就会更清晰:

function useA() {const [a, setA] = useState(1)
    useEffect(() => {handleA()
    }, [])
    
    return a
}

function useB() {const [b, setB] = useState(2)
    useEffect(() => {handleB()
    }, [])
    
    return b
}

function useC() {const [c, setC] = useState(3)
    useEffect(() => {handleC()
    }, [])
    
    return c
}

function Com() {const a = useA()
    const b = useB()
    const c = useC()}

我们除了可以利用自定义 Hooks 来拆分业务逻辑外,还可以拆分成复用价值更高的通用逻辑,比如说目前比较流行的 Hooks 库:react-use;另外,React 生态中原来的很多库,也开始提供 Hooks API,如 react-router。

忘记组件生命周期吧

React 提供了大量的组件生命周期钩子,虽然在日常业务开发中,用到的不多,但光是 componentDidUpdate 和 componentWillUnmount 就让人很头痛了,一不留神就忘记处理 props 更新 组件销毁需要处理副作用 的场景,这不仅会留下肉眼可见的 bug,还会留下一些内存泄露的隐患。

类 MVVM 框架讲究的是 数据驱动,而生命周期这种设计模式,就明显更偏向于传统的事件驱动模型;当我们引入 React Hooks 后,数据驱动的特性能够变得更纯粹。

处理 props 更新

下面我们以一个非常典型的列表页面来举个例子:

class List extends Component {
  state = {data: []
  }
  fetchData = (id, authorId) => {// 请求接口}
  componentDidMount() {this.fetchData(this.props.id, this.props.authorId)
    // ... 其它不相关的初始化逻辑
  }
  componentDidUpdate(prevProps) {
    if (
      this.props.id !== prevProps.id ||
      this.props.authorId !== prevProps.authorId // 别漏了!) {this.fetchData(this.props.id, this.props.authorId)
    }
    
    // ... 其它不相关的更新逻辑
  }
  render() {// ...}
}

上面这段代码有 3 个问题:

  • 需要同时在两个生命周期里执行几乎相同的逻辑。
  • 在判断是否需要更新数据的时候,容易漏掉依赖的条件。
  • 每个生命周期钩子里,会散落大量不相关的逻辑代码,违反了高内聚的原则,影响阅读代码的连贯性。

如果改成用 React Hooks 来实现,问题就能得到很大程度上的解决了:

function List({id, authorId}) {const [data, SetData] = useState([])
    const fetchData = (id, authorId) => {}
    useEffect(() => {fetchData(id, authorId)
    }, [id, authorId])
}

改用 React Hooks 后:

  • 我们不需要考虑生命周期,我们只需要把逻辑依赖的状态都丢进依赖列表里,React 会帮我们判断什么时候该执行的。
  • React 官方提供了 eslint 的插件来检查依赖项列表是否完整。
  • 我们可以使用多个 useEffect,或者多个自定义 Hooks 来区分开多个无关联的逻辑代码段,保障 高内聚 特性。

处理副作用

最常见的副作用莫过于绑定 DOM 事件:

class List extends React.Component {handleFunc = () => {}
    componentDidMount() {window.addEventListener('scroll', this.handleFunc)
    }
    componentWillUnmount() {window.removeEventListener('scroll', this.handleFunc)
    }
}

这块也还是会有上述说的,影响高内聚的问题,改成 React Hooks:

function List() {useEffect(() => {window.addEventListener('scroll', this.handleFunc)
    }, () => {window.removeEventListener('scroll', this.handleFunc)
    })
}

而且比较绝的是,除了在组件销毁的时候会触发外,在依赖项变化的时候,也会执行清除上一轮的副作用。

利用 useMemo 做局部性能优化

在使用类组件的时候,我们需要利用 componentShouldUpdate 这个生命周期钩子来判断当前是否需要重新渲染,而改用 React Hooks 后,我们可以利用 useMemo 来判断是否需要重新渲染,达到局部性能优化的效果:

function List(props) => {useEffect(() => {fetchData(props.id)
  }, [props.id])

  return useMemo(() => (// ...), [props.id])
}

在上面这段代码中,我们看到最终渲染的内容是依赖于 props.id,那么只要props.id 不变,即便其它 props 再怎么办,该组件也不会重新渲染。

依靠 useRef 摆脱闭包

在我们刚开始使用 React Hooks 的时候,经常会遇到这样的场景:在某个事件回调中,需要根据当前状态值来决定下一步执行什么操作;但我们发现事件回调中拿到的总是旧的状态值,而不是最新状态值,这是怎么回事呢?

function Counter() {const [count, setCount] = useState(0);

  const log = () => {setCount(count + 1);
    setTimeout(() => {console.log(count);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

/*
    如果我们在三秒内连续点击三次,那么 count 的值最终会变成 3,而随之而来的输出结果是?0
    1
    2
 */

“这是 feature 不是 bug”,哈哈哈,说是 feature 可能也不太准确,因为这不正是 javascript 闭包的特性吗?当我们每次往 setTimeout 里传入回调函数时,这个回调函数都会引用下当前函数作用域(此时 count 的值还未被更新),所以在执行的时候打印出来的就会是旧的状态值。

类组件是怎么实现的?

那为啥类组件中,每次都能取到最新的状态值呢?这是因为我们在类组件中取状态值都是从 this.state 里取的,这相当于是类组件的一个执行上下文,永远都是保持最新的。

借助 useRef 共享修改

通过 useRef 创建的对象,其值只有一份,而且在所有 Rerender 之间共享

听上去,这 useRef 其实跟 this.state 很相似嘛,都是一个可以一直维持的值,那我们就可以用它来维护我们的状态了:

function Counter() {const count = useRef(0);

  const log = () => {
    count.current++;
    setTimeout(() => {console.log(count.current);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

/*
    3
    3
    3
 */

正文完
 0