【墙裂推荐】Talking about hooks

4,021 阅读10分钟

从React16.8开始,Hooks API正式被React支持,而就在最近,Vue作者尤雨溪翻译并发布了一篇自己的文章《Vue Function-based API RFC》,并在全文开头强调这是Vue 3.0最重要的RFC,并在文中提到

Function-based API 受 React Hooks 的启发,提供了一个全新的逻辑复用方案。

可以简单的理解为,React 和 Vue 为了解决相同的问题,基于不同的技术实现了相似的API。所以本文也将结合两种框架各自的特点,简单讲讲个人对Hooks的理解。

在未来版本的规划里,React并不如Vue激进,React的文档里专门提到

并没有从 React 中移除 class的计划。

而Vue却采取了不同的升级策略,做好了抛弃大部分历史语法的准备

  • 兼容版本:同时支持新 API 和 2.x 的所有选项;
  • 标准版本:只支持新 API 和部分 2.x 选项。

为什么我们不再需要Class Component?

为了回答这个问题,我们先看看之前和现在的React组件划分产生了哪些变化。

1. 既然本来就有函数组件,开始为什么引入class组件?

早期的React组件可以依据“有没有状态(state)”分为

// 无状态组件
const Welcome = (props) => <h1>Hello, {props.name}</h1>;

// 有状态组件
class Welcome extends React.Component {
    constructor(props) {
        super(props);
        this.state = {name: 'KuaiGou'};
    }
    
    render() {
        return <h1>Hello, {this.state.name}</h1>;
    }
}

虽然class也可以不添加状态,但想要使一个函数组件具有状态,不得不将其转换成class组件。

直观来看,好像造成这种差异是因为在class里,我们能通过this保存和访问“状态(state)”,而函数组件在其作用域内难以维持“状态(state)”,因为再次函数运行会重置其作用域内部变量,这种差异导致了我们“不得不”使用class至今。

看来如何解决函数组件保存state的成了移除class这种“难以理解”的关键。

2. 那Hook是如何保留组件状态的?

这就是我看见Hook API产生的第一个疑问。其实在React里,这并不是问题,熟悉React Fiber的同学应该知道,事实上state是保存到Fiber上的属性memoizedState上的,而并不算是class的this.state上。那状态问题就迎刃而解了,如果函数组件同样访问Fiber上的memoizedState属性,就可以解决这个问题。

基于Fiber架构,解决这个问题非常容易,将memoizedState看作一个普通的变量,那么Hook的原理就容易理解和实现了。

在文章[译] 理解 React Hooks中提到

记住,在 Hooks 的实现中也没有什么“魔术”。就像 Jamie 指出的那样,它像极了这个:

let hooks = null;

export function useHook() {
    hooks.push(hookData);
}

function reactsInternalRenderAComponentMethod(component) {
    hooks = [];
    component();
    let hooksForThisComponent = hooks;
    hooks = null
}

如Fiber一样,React实际上使用链表代替了数组这种数据结构,依次执行Hook,有兴趣的同学可以去看下React源码。

可是,class目前也能良好的支撑业务迭代,到底有什么动力去重新学习Hooks?

3. 为什么我们需要Hooks?

针对这个问题,React文档提到了下面三点:

  • 在组件之间复用状态逻辑很难
  • 复杂组件变得难以理解
  • 难以理解的 class

其实我觉得第三点就是来凑数的,毕竟React推出至今一直用着class,再难用各位也都会了,会者不难难者不会嘛(反正对于刚入前端坑那时候的我来说,没有啥是容易的)。

那就回答下一个问题,目前基于class实现的生命周期函数,是否真的会造成逻辑难以复用?

答案是NO

无论高阶组件或是render props,都提供了很好的方式来达到聚合业务逻辑的目的,业务逻辑并不会被生命周期“分割”。

那到底是哪里引入了复杂度?熟悉套娃的同学...呸

熟悉Ajax、Promise的等异步API的同学可能还记得“回调地狱”。类似的,高阶函数、render props等也极容易造成“嵌套地狱”,结合装饰器、函数式的compose等嵌套起来才是真的爽...一直嵌套一直爽...

但是,无论是什么地狱肯定是不好的,那一起来看最后一个问题。

复杂组件变得难以理解

之所以回避前两个问题,是因为我个人认为,无论是class还是HOC,它们都很好的解决了它们需要解决的问题,虽然生命周期函数将很多业务逻辑拆分的七零八碎,但是HOC却依旧能把它们集合在一起,仅考虑保留生命周期而言,就像Function-based一样(这是后话)。

所以我们换一个思路不难发现,真正的问题是在于它们在抽象业务逻辑的时候貌似引入了不必要的概念,才使得逻辑复用困难和难以理解。

这些概念导致了过多的嵌套,加深了组件层级,层级之间互相牵扯,就像我现在兜里的耳机线一样。

Hook独特之处在于化繁为简。

真正繁琐的是层级与层级之间的关系,我将借用React文档关于自定义Hook的例子说明这个问题

import React, { useState, useEffect } from 'react';
// 通过friendID订阅好友状态
function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

通过useFriendStatus这个自定义的hook,我可以非常轻松的在下面两个组件中实现逻辑的复用

// FriendStatus获取好友状态
function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

// FriendListItem获取好友状态
function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id);

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
};

可是对于熟悉高阶组件的同学来说(不熟悉的同学请看高阶组件)依旧可以轻松的提取一个名叫useFriendStatus的高阶组件


function useFriendStatus(WrappedComponent) {
  return class extends React.Component {
    componentDidMount() {
        ChatAPI.subscribeToFriendStatus(
          this.props.friend.id,
          this.handleStatusChange
        );
    }
     
    componentDidUpdate(prevProps) {
        ChatAPI.unsubscribeFromFriendStatus(
          prevProps.friend.id,
          this.handleStatusChange
        );
        ChatAPI.subscribeToFriendStatus(
          this.props.friend.id,
          this.handleStatusChange
        );
      }

    componentWillUnmount() {
        ChatAPI.unsubscribeFromFriendStatus(
          this.props.friend.id,
          this.handleStatusChange
        );
    }
    
    render() {
      return <WrappedComponent isOnline={isOnline} />;
    }
  }
}

然后,分别套上FriendStatus和FriendListItem依旧可以非常完美的多处复用此逻辑。

需要注意的是,高阶组件等概念还是有很多特点和优点的,hooks并不是万能的,hooks只是hooks而已

需要提到的是,官方文档在这里对比了新旧方案代码量的长度和复杂度,但其实在特定的业务情况下,确实不可避免会出现这种问题,个人认为这是次要矛盾(既然HOC已经封装了复杂度,还纠结里面长不长、复不复杂干啥)。

反而,这容易让刚接触Hooks的同学忽略Hook最大的亮点,先看下面这段熟悉的代码(不熟悉的同学看Promise 对象async 函数)。

// async 
const P1 = new Promise(Something)
const P2 = new Promise(Something)

export default async function () {
  const res1 = await P1;
  //do Something
  const res2 = await P2;
  // ...
  return res2
}

和刚才的Hook对比

// 以及 hook
function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);
  ...
  return isOnline ? 'Online' : 'Offline';
}

像不像...就问你像不像...(模仿团队某成员说话)

写(粘)了这么多代码,简单来说,Hook解决的就是“嵌套地狱”的问题,正如async解决“回调地狱”一样。它们都做到了将原来不同“维度”的代码封装到了同一维度,以达到更直观、透明的将“计算结果”传递下去的目的。

而class不得不借助高阶组件等等概念,解决代码复用等问题,但是由于引入额外的概念(函数)反而使得代码更加复杂,现在的class难以解决这个问题,所以他就被抛弃了。

问题来了,谁可以不引入别的概念,完成逻辑封装?

就是函数本身啊!!还要class干嘛?!

综上,被抛弃和class生命周期函数导致的代码复杂度提升无关,Hook简化生命周期函数只是不过是举手之劳,并不是什么重要的特性。

为什么这里说这个不重要呢,下面将要讲的Function-based会帮助我解答这个问题。

4. 继续保留生命周期函数的Function-based API

Vue在组件化的道路上和React走过了大致相同的道路,这也就是为什么有人问我Vue和React之间有什么区别的时候,鉴于个人水平,我是真的答不上来😂...

话题扯回来,它们都经过了下面几个阶段

  • Mixins
  • 高阶组件 (Higher-order Components)
  • Renderless Components

抛开框架,我们思考下“组件化”这个概念是不是为逻辑抽象服务的,我们提取共有的逻辑,各处复用,各类设计模式最终落到实体我个人认为他就是“组件”。

同时了解Hooks和Function-based的同学,应该不难发现他两巨大的差异,而且具有后发优势的Vue也解决了React Hooks尚未解决的问题(或者React并不认为这是问题,也不打算解决),但这里并不想对两种API的使用做过多的说明,毕竟它们的文档已经写的非常好了。

为了说明它们相似的地方,借用尤大文章中“用组合函数来封装鼠标位置侦听逻辑”的例子

function useMouse() {
  const x = value(0)
  const y = value(0)
  const update = e => {
    x.value = e.pageX
    y.value = e.pageY
  }
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  return { x, y }
}

// 在组件中使用该函数
const Component = {
  setup() {
    const { x, y } = useMouse()
    // 与其它函数配合使用
    const { z } = useOtherLogic()
    return { x, y, z }
  },
  template: `<div>{{ x }} {{ y }} {{ z }}</div>`
}

对比上文的“订阅好友状态”的React版本例子

import React, { useState, useEffect } from 'react';
// 通过friendID订阅好友状态
function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

不难发现,无论是Hooks还是Function-based,都仅仅做了一件事。 那就是想方设法啊,把逻辑用一个函数包了起来,使得代码逻辑表现的非常自然,我相信这就是为什么尤大毫不犹豫抛弃Vue存在多年的选项,直接用Function-based来代替它。

因为函数天生就是用来计算(状态)的。

解释下上面提到的“并不重要的生命周期”,观察上面的代码对比情况,有个非常明显的差异。在React里面被聚合(消失掉)的生命周期函数,具有后发优势的Vue仍旧保留下来了。

容易得出,逻辑有没有被生命周期切分,或者究竟在onMounted里面计算还是在useEffect里计算并不重要,重要的是逻辑本身有没有被切分?

写在最后的话

React、Vue也一直试图推出各种手段简化业务逻辑,但是正如Mixins、HOC、render-props以及本文说到的Hook和Function-based等。虽然它们都不是React或Vue原创的概念,但毫不影响初见时的惊艳,转念又会思考为什么自己没有想到,说到底不就是在外面套一个函数嘛,总结起来就是。

背那么多API,依旧写不好代码。

参考文献

关于我们

快狗打车前端团队专注前端技术分享,定期推送高质量文章,欢迎关注点赞。