React Hooks (一) 你真的了解Hooks吗?

avatar
大前端 @阿里巴巴
文/深屿

为什么要了解 Hooks 和函数式组件呢?因为它正在变得越来越重要。自从 Hooks 出现,函数式组件功能在不断丰富,函数式组件不再需要强调其无状态特性,当然用Function Component 代替 Stateless Component 的称呼更加合理。用 Class 还是 Hooks?社区也一直存在很多争论,React官方表示“准备让 Hooks 覆盖所有 Class 组件的使用场景,并继续为 Class 组件提供支持”。从官方的角度可以看出来对 Hooks 的推崇,此外React组件从概念上来看一直更像是数据到视图映射的函数 UI = F(data),而 Hooks 拥抱了函数,更加符合声明式和函数式的理念,因此个人认为在未来 Hooks 有更广阔的发展空间。

函数式组件与类组件有何不同

在 Hooks 出现之前,典型的回答是类组件提供了更多的特性(比如生命周期,state等)。或是性能问题,Hooks 会因为在渲染时创建函数而变慢吗? 不会。在现代浏览器中,闭包和类的原始性能只有在极端场景下才会有明显的差别。引用React开发者 Dan Abramov 博客中的解释 “性能主要取决于代码的作用,而不是选择函数式还是类组件。在我们的观察中,尽管优化策略各有略微不同,但性能差异可以忽略不计”。

更根本的原因,心智模型(mental model)上的区别。

函数式组件更加“声明式”

在 Class 组件中,通常的写法是在生命周期检查 props.A 和 state.B,如果改变或者是符合某个条件就触发xxx副作用。在函数式组件中使用 Hooks 的写法是组件有xxx副作用,这个副作用依赖的数据是 props.A 和 state.B。从过去的命令式转变成了声明式的写法,Hooks 提供各种声明式的副作用 API (useEffect, useCallback),使得“生命周期”变成了一个“底层概念”,对开发者是无感知的,因此开发者更能够将精力聚焦在在更高的抽象层次上。

函数式组件更加“函数式”

从字面意义上来看,函数式组件肯定比类组件更“函数式”,从概念上来看,React 组件一直更像是函数,而 Hooks 则拥抱了函数,同时也没有牺牲 React 的精神原则。

React 的本质是能够将声明式的代码映射成命令式的 DOM 操作,将数据映射成可描述的UI对象。为了实现(运行时)对数据变更做出响应,React最初采用基于类(Class)的组件设计,通过引入生命周期的概念,为开发者提供从组件创建到销毁一系列的API。对于类组件,尽管 render 函数然是声明式的,但是类实例本身是可变的(this 是可变的),导致类实例上的数据(props 和 state)并没有和渲染结果绑定在一起,使得数据到UI渲染有时候会变得不可预期。这可能有点抽象,下面的例子有助于大家理解,如下是一个 ProfilePage 的函数式组件,点击 button,3秒后会弹出“Followed xxx”

function ProfilePage(props) { 
    const showMessage = () => alert('Followed ' + props.user); 
    const handleClick = () => setTimeout(showMessage, 3000); 
    return <button onClick={handleClick}>Follow</button> 
}

使用 Class 组件实现功能,代码如下:

class ProfilePage extends React.Component { 
    showMessage = () => alert('Followed ' + this.props.user); 
    handleClick = () => setTimeout(this.showMessage, 3000); 
    render() { 
      return <button onClick={this.handleClick}>Follow</button>; 
    } 
}

假设当前 user 为 Dan,点击 button 并在3s内切换 user 为 Sophie,你会发现函数式组件提示“Followed Dan”,而类组件提示“Followed Sophie”, 显然类组件的提示是错误的,用户触发时确定的状态不该被后续操作所影响。



那么 React 文档中描述的 props 不是不可变(Immutable) 数据吗?为啥在运行时还会发生变化呢?

原因在于,虽然 props 是不可变的,但是 this 在类组件中是可变的,因此 this.props 的调用会导致每次都访问最新的 props。事实上,这就是类组件 this 存在的意义,React 组件本身(this)会随着时间的推移而改变,以便你可以在渲染方法以及生命周期方法中得到最新的实例。我们可以认为 React 在特定的一次渲染中 props 和 state 是不可变的,如果能利用 JavaScript 闭包存储特定渲染数据的话问题将迎刃而解。如下:

class ProfilePage extends React.Component { 
  render() { 
    const props = this.props; 
   
    // 定义在render内部 
    const showMessage = () => { 
       alert('Followed ' + props.user); 
    }; 
    const handleClick = () => { 
       setTimeout(showMessage, 3000); 
    }; 

    return <button onClick={handleClick}>Follow</button>; 
   } 
}

可以看到,我们在 render 方法中定义了各种函数,而不是使用 Class 的方法,以达到获取正确上下文的目的,当然上面的写法更像是函数式组件。对于函数式组件来说当父组件使用不同的 props 来渲染 ProfilePage 时,React 会再次调用 ProfilePage 函数,但是我们点击的事件处理函数,是“属于”具有自己的 user 值的上一次渲染,并且 showMessage 回调函数也能读取到这个值,因此结果是正确的。然而在 Class 组件里,由于组件实例(this)一直是变化的,像上面的例子在 timeout 的回调函数读取 this.props 会捕获已经更新后的数据状态,因此它导致了错误的渲染结果。

在 React 中,我们可以认为UI是数据的一种视觉输出,通过props接收组件外部的数据,使用state管理组件内部的数据(状态),再通过对数据进行加工,对数据改变做出响应,根据用户的操作映射成数据的变化,最后输出视觉模型,即 (props, state) => UI 。函数式组件每次渲染总能捕获正确的数据状态(props 和 state),即每次输入的数据状态相同则渲染的视图相同,更加的“纯”,而类组件依托实例的动态变化和生命周期完成数据状态到视图的渲染,显得不那么“纯”。

为什么需要Hooks

1. 状态逻辑复用很难

React 没有为复用状态逻辑提供原生途径。通常类组件的逻辑复用会使用 HOC (高阶组件)或 render props 的方案,但是此类方案通查需要你重新组织组件结构,且过多的嵌套抽象层组件很容易形成“嵌套地狱”。

HOC - 高阶组件

如下是一个常见 HOC 的用法。使用 connect 连接 store, 使用 withRouter 获取路由参数,这种嵌套的写法可读性和可维护性非常差(ps.才两层嵌套就很难受了),虽然可以使用 compose 组合高阶组件,或者装饰器简化写法,但本质还是 HOC 的嵌套。

const App = withRouter(connect(commentSelector)(WrappedComponent));  
// 优化 可以使用一个 compose 函数组合HOC 
const enhance = compose(withRouter, connect(commentSelector)); 
const App = enhance(WrappedComponent);  

// 优化 使用装饰器  
@connect  
class App extends React.Component {}

每一次 HOC 调用都会产生一个组件实例,多层嵌套会增加React虚拟Dom的深度并且影响性能,此外包裹太多层级之后,可能会带来props属性的覆盖问题。此外,HOC 对于使用者更像是一个黑盒,通查需要看具体的实现来使用。

Reder Props

如下是复用监听 window size 变化的逻辑

<WindowSize> 
  (size) => <OurComponent size={size} /> 
</WindowSize>

然后,如果再想复用监听鼠标位置的逻辑

<WindowSize> 
(size) => ( 
    <Mouse> 
    (position) => <OurComponent size={size} mouse={position} /> 
 </Mouse> ) 
</WindowSize>

到这里可能不会再想复用其他逻辑了,虽然 render props 解决了 hoc 存在的一些问题,比如对使用者黑盒,属性名覆盖等,但是使用 render props 时,如果复用逻辑过多会仍然会导致嵌套过深,形成回调地狱。

Hooks - 为复用状态逻辑提供原生途径

// 复用监听 window size 变化的逻辑 const size = useSize() 
// 复用监听鼠标位置的逻辑 const position = useMouse()

用自定义 Hooks 改写之后,难道不“香”吗,谁还想回头写 HOC 和 render props。自定义 Hooks 复用状态逻辑的方式得到 React 原生的支持,与React组件不同的是,自定义 Hooks 就是一个以 use 开头的函数,因此也更易于测试和复用。除此之外,在“真香”的自定义 Hooks 中也可以使用其他 Hooks。

2. 生命周期

学习成本高

Class 组件拥有很多的生命周期函数,而且不同版本还有差别,学习成本很高。比如 React v16 相比 React v15 生命周期有哪些变化?又比如 componentWillReceiveProps 和 getDerivedStateFromProps 有啥区别?生命周期为开发中提供了很多功能,同时也带来了很高的学习成本,通常需要大量实践才能积累足够的经验。

在函数式组件中使用 useEffect 取代了类组件声明周期的调用,使开发者不再额外关心声明周期的使用,而专注于数据变化的处理并映射成新的视图。此外,使用 Hooks 编写 React 代码时,最好的心里规则不是将生命周期一一映射成 Hooks 的写法,而是认为任何值都可能随时改变,并需要做出相应的处理。

逻辑分散

随着组件复杂度的增加,内部通查会充斥各种状态和副作用,然而很多逻辑都分散在类组件的各生命周期里,而不是聚焦在一起。例如在 componentDidMount 中设置事件监听,之后还需要在 componentWillUnmount 中清除。

componentDidMount() {     
    document.addEventListener('mousedown', this.handleXXX); 
} 
componentWillUnmount() {     
    document.removeEventListener('mousedown', this.handleXXX); 
}

又或者我们通查在 componentDidMount 中获取数据,参数改变又要在 componentWillReceiveProps 重新发起请求。

componentDidMount() { 
   const { code } = this.props; this.getDataSource(code); 
} 
UNSAFE_componentWillReceiveProps(nextProps) { 
  const { code: nextCode } = nextProps; 
  const { code } = this.props; 
  if ( code !== nextCode ) { 
     this.getDataSource(nextCode); 
  } 
}

可以看出在类组件中,相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起,如此很容易产生 bug,并且导致逻辑不一致。使用 Hooks的写法:

// 事件监听 useEffect(() => {     
    document.addEventListener('mousedown', handleXXX); 
      return () => {             
       document.removeEventListener('mousedown', handleXXX); 
      } 
    }, []) // 获取数据 useEffect(() => { getDataSource(code); }, [code])

在 Hooks 中无需按照生命周期划分逻辑,组件中相互关联的部分可以拆分成更小的函数,逻辑更加聚合,代码量更少。

3. Class 问题

令人头疼的 Class

JavaScript 中类的实现比较鸡肋,没有类似Java/C++多继承的概念,因此也很难实现复杂逻辑复用。此外 使用 Class 通常需要去了解 this 的工作方式,然而完全理解 this 关键字的原理以及在代码中如何使用对相当一部分的开发者来说着实不易。在 React 中使用 Class 组件需要时时刻刻考虑事件的绑定,虽然箭头函数能够避免一部分工作。

另一方面,也是绝大多数框架的共识--“对于 UI 框架而言,组合优于继承”。

编译和优化

通常完成相同功能的组件使用 Class 组件的代码量会比函数式组件更多,而编译之后 Class 组件的代码量会比函数式组件大得多,更加难以压缩。此外使用 Class 组件也给前端构建工具在热更新(hot reload)和优化(Prepack )方面带来了一些问题。

Hooks存在问题

不能完全替代Class

像 getSnapshotBeforeUpdate,getDerivedStateFromError 和 componentDidCatch 等生命周期 API,使用 Hooks 不能完全替代。但官方表示“会尽早把它们加进来”,微笑脸?。

复杂组件的乘载能力弱

一个复杂的 Class 业务组件,实例可能挂载非常多的方法,如果用函数式组件 Hooks 的方式来实现的话,如何让组件的业务逻辑看上去清晰明了,如何保证组件后续的可维护性,这确实是比较大的挑战。

上手易用好难

上手容易是指通查使用 Hooks 只需要学习 useState 和 useEffect 就能满足大部分组件的基础功能。但后续使用会遇到较多问题,如Hooks Rules 的约定、Hooks 依赖项的判断,Hooks 闭包问题,Hooks 性能优化等,以上都需要开发者有一定实践经验才能较好的完成。

参考