深入理解Hook

92 阅读13分钟

没有Hook之前的问题

在React引入Hook之前,我们主要使用 类组件(Class Components) 来构建带有状态和生命周期逻辑的UI。虽然类组件功能强大,但在实际开发中,尤其是随着应用变得越来越复杂,它们逐渐暴露出了一些痛点。如果你有React Class Component开发经验的话,你应该能明白这些问题。

  • 逻辑复用和共享困难

    在类组件中,如果你想复用一些状态逻辑(比如数据获取、订阅事件等),通常会用到高阶组件(Higher-Order Components, HOCs) 或 渲染属性(Render Props)模式。虽然这些模式能实现逻辑复用,但它们往往会引入额外的组件嵌套层级,导致 “Wrapper Hell”(包装器地狱),使得组件树变得非常深,调试起来也比较困难。

  • 复杂组件难以理解和维护

    一个组件中可能包含很多不相关的逻辑,例如,componentDidMount 方法里可能既有数据获取的逻辑,又有事件监听的逻辑,而 componentWillUnmount 里又需要清除这些监听。相关的逻辑被分散在不同的生命周期方法中,使得一个组件的某个功能点(比如一个订阅功能)的完整逻辑,需要跳跃式地在多个方法中查找,这让代码变得难以阅读和理解。反之,一个生命周期方法里又可能包含多个不相关的逻辑。( 这同样也是Vue2的痛点 )

  • 对 this 的理解和绑定

    在JavaScript中,this 的指向是一个出了名的“老大难”问题,尤其是在类组件中,你需要时刻注意方法中的 this 绑定,否则就可能出现运行时错误。比如,总是需要在构造函数中重写下方法this指向,this.a = this.a.bind(this);

Hook是什么

Hook是React框架提出的一套特殊的 API

额?我们可以对此细化下,在宏观层面上给出Hook的几个特点:

  1. 具有副作用,但是Hook提供一套API解决方案。
  2. 在代码范式方面,使得代码的行为逻辑具有组合的特点。
  3. Hook对流水线来说是参与者,可以对流水线产生影响,所以说hook思想中,与流水线是紧耦合关系。

副作用

React Hook中的副作用

首先,React是非常崇尚函数式编程的,所谓函数式编程有如下三个特点:

  1. 函数是一等公民。

    这表明函数是一种数据,具有和其他数据类型(字符串、对象、数字)一样的地位

  2. 追求存粹,隔离副作用

    相同的输入,永远产生相同的输出,并且不与外界发生任何交互(没有副作用)

  3. 不可变性

    数据一旦被创建,就不应该被修改。如果你想改变数据,你应该创建一个新的数据副本,并在副本上进行修改

  4. 声明式编程而非命令式

    只告诉计算机我想要什么,而不去关注具体步骤。

那么React推崇的范式有没有副作用呢?随意关注以下几个常用的React API:

  • useState:

    • const [count, setCount] = useState(0);
    • 当你调用 useState(0) 时,第一次它返回 [0, function],但当你调用 setCount(1) 之后,下一次组件渲染时,你再次调用 useState(0)(是的,React 内部会忽略这个初始值),它返回的却是 [1, function]。
    • 输入相同(都是 0),输出不同。它不是纯函数。  它的核心职责就是引入和管理一个可变的“状态”,这本身就是一种副作用。
  • useEffect:

    • useEffect(() => { document.title = 'New Title'; }, []);
    • 这个 Hook 的名字就已经“自首”了:Effect 就是“副作用”的意思。它的回调函数里充满了副作用,比如操作 DOM、发起网络请求、设置定时器等。
    • 它就是为了执行副作用而生的。它不是纯函数。
  • useContext:

    • const theme = useContext(ThemeContext);
    • 它的返回值完全取决于外部的 Provider 提供了什么值,而不是它的输入参数。
    • 依赖于外部“隐藏”的状态。它不是纯函数。

既然 Hook 都不是纯函数,那 React 还谈什么“函数式编程”呢?这不是自相矛盾吗?

这正是 React 设计的精妙之处。React 的目标不是创建一个完全没有副作用的乌托邦(因为任何有用的 UI 应用都必须有副作用),而是:

尽可能地让你的组件渲染逻辑本身保持纯净,然后提供一套专门的、可控的 API (也就是 Hooks) 来将那些“不纯”的副作用隔离出去。

所以,在副作用方面,React的哲学是:

  1. 输入相同,输出的JSX必须相同。 React官网: 保持组件存粹
  2. 组件是纯函数的

和事件驱动的区别

一个比较能反应出是否理解Hook的问题:Hook思想和事件驱动有什么区别?

首先,需要明确的一点是,这两种并不是排斥的关系,这里主要是梳理一下区别而已,因为Hook钩子,很容易想到生命周期,生命周期又很容易和事件扯上关系。

事件驱动,是软件架构风格,也叫过程调用风格,是系统间或模块间通信机制。

Hook是组件内部逻辑组织和管理机制。

事件驱动有以下几种特点,这些特点和Hook完全不一样:

  1. 关注点在于事后的响应。
  2. 发布者和订阅者之间是松耦合的。
  3. 对于流水线来说是无副作用。
  4. 同时也是无状态的,因为根本拿不到上下文。

Hook那么对应的就有以下特点:

  1. 关注点在于如何管理内部的状态和逻辑。
  2. Hook API 与 React 内部流水线紧耦合。你不能在任意js函数地方调用Hook API吧,比如在React之外调用useState?
  3. 在业务上解耦业务逻辑与类组件生命周期。
  4. 能拿到完整上下文。这是第二点的必然结果。

组合

说到组合,在软件工程设计领域,有一个设计原则----“组合优于继承”。这正是Hook API比class API好用的地方。

继承

核心思想:  强调“is-a”的关系。一个类(子类)从另一个类(父类/基类)那里继承所有的属性和方法。子类拥有父类的所有特性,并且可以添加自己的新特性或覆盖父类的行为。

优点:

  1. 代码复用:  这是最直接的优点。父类中实现的功能,子类可以直接使用,无需重新编写。
  2. 易于理解(简单场景):  对于简单的“is-a”关系,继承的语义非常直观。例如,“狗是一种动物”,“轿车是一种汽车”。
  3. 多态性:  在面向对象编程中,可以通过父类引用指向子类对象,实现多态,这在某些设计模式中非常有用。

缺点(这就是组合优于继承的元婴):

  1. 紧耦合(Tight Coupling):  这是最大的问题。子类和父类之间形成了强烈的依赖关系。父类的任何改变(即使是内部实现细节的改变),都可能影响到所有子类,导致子类行为异常或需要修改。这被称为 “脆弱的基类问题”(Fragile Base Class Problem)

    • 举例:  假设 Animal 类有一个 move() 方法,Dog 继承了它。如果后来 Animal 的 move() 方法内部实现改变了(比如从走路变成了飞行,虽然这不合理,但作为例子),那么所有继承 Animal 的子类(包括 Dog)都会受到影响,即使 Dog 应该只会在地上跑。
  2. 继承层次过深(Hierarchy Depth):  随着功能的增加,继承链可能会变得非常深,导致类结构复杂,难以理解和维护。一个类的行为可能分散在多层父类中,追踪起来很困难。

  3. 功能僵化/缺乏灵活性:

    • “白色盒子复用”:  继承是一种“白色盒子复用”,子类可以看到父类的内部实现细节。这使得子类过于依赖父类的实现,限制了父类修改的自由度。
    • 运行时行为难以改变:  一旦继承关系确定,子类的行为在运行时就很难动态改变。你不能在运行时“换掉”一个父类。
    • 单继承限制:  大多数面向对象语言(如Java、C#、JavaScript的类)只支持单继承,这意味着一个子类只能有一个直接父类。如果你想让一个类同时拥有多个不相关的父类的能力,继承就无能为力了(例如,一个对象既能飞又能游,你很难用单继承来优雅实现)。
  4. 过度暴露内部实现:  子类会继承父类的所有公共和受保护成员,即使子类不需要这些成员,或者这些成员的实现细节不适合子类。这可能导致不必要的复杂性。

  5. 难以测试:  子类依赖于父类的实现,使得对子类的单元测试变得复杂,因为你需要同时考虑父类的行为。

组合

核心思想:  强调“has-a”的关系。一个对象(容器对象)包含(或“拥有”)另一个或多个对象的实例,并通过调用这些被包含对象的方法来完成功能。

优点:

  1. 松耦合(Loose Coupling):  容器对象和它包含的对象之间是松散耦合的。它们通过接口或公共方法进行交互,而不是通过继承的内部实现细节。这意味着你可以独立地修改或替换被包含的对象,而不会影响容器对象。

    • 举例:  Car 类“拥有”一个 Engine 对象。如果我想把汽油引擎换成电动引擎,我只需要替换 Car 拥有那个 Engine 实例,而不需要改变 Car 类本身的继承关系。
  2. 高灵活性和运行时行为改变:

    • “黑色盒子复用”:  组合是一种“黑色盒子复用”,容器对象只关心被包含对象的公共接口,不关心其内部实现。
    • 动态行为:  你可以在运行时动态地组合不同的对象,或者替换掉被包含的对象,从而改变容器对象的行为。这使得系统更加灵活和适应变化。
    • 多功能组合:  一个对象可以轻松地组合多个不同功能的对象,实现更复杂的行为,而没有单继承的限制。例如,一个 Robot 可以“拥有”一个 Arm 对象和一个 Leg 对象,分别负责抓取和移动。
  3. 更好的代码复用(粒度更细):  通过组合,你可以创建小而独立的、职责单一的组件(或对象),这些组件更容易在不同的上下文和场景下复用。

  4. 易于测试:  每个被组合的部件都可以独立测试,因为它们之间的依赖关系很弱。

  5. 清晰的职责:  每个被组合的对象都专注于完成自己的特定职责,使得代码结构更清晰,更容易理解。

缺点:

  1. 可能需要更多的“胶水代码”(Delegation Overhead):  容器对象可能需要编写一些代码来将请求“委托”给它所包含的对象。例如,如果 Car 有一个 start() 方法,它可能需要调用 engine.start()。这在某些情况下可能显得有些冗余,但通常可以通过良好的设计模式(如策略模式、装饰器模式)来缓解。
  2. 接口管理:  如果一个对象组合了太多其他对象,并且需要通过它们来完成很多任务,那么管理这些被组合对象的接口可能会变得复杂。

流水线参与者

这正是Hook的另一个特点。同样也是事件驱动做不到的事情。

想象一下,React在渲染一个组件时,都会依次去执行一个Hook函数链表,就像是一个流水线一样的东西。我们说Hook是流水线的参与者,是因为Hook可以影响到流水线,具体表现为:

一个 Hook 的输出可以作为另一个 Hook 的输入或依赖。这体现了 Hook 的可组合性

function MyDependentComponent() {
  // 1. useState 影响 useEffect 的依赖和逻辑
  const [count, setCount] = useState(0); // Hook 1: 提供状态 'count'

  useEffect(() => {
    // Hook 2: 依赖 'count'。当 'count' 变化时,这个副作用会重新执行。
    // 这里的 'count' 就是由 Hook 1 提供的。
    console.log('Count is now:', count);
  }, [count]); // 'count' 是 useEffect 的依赖

  // 2. useState 影响另一个 useState 的初始值或更新逻辑
  const [doubleCount, setDoubleCount] = useState(() => count * 2); // Hook 3: 初始值依赖 'count'

  // 3. 自定义 Hook 内部的 Hook 相互影响,然后输出给外部
  const { mouseX, mouseY } = useMousePosition(); // Hook 4: 这是一个自定义 Hook
  // 假设 useMousePosition 内部是这样的:
  // function useMousePosition() {
  //   const [x, setX] = useState(0); // 内部 Hook 1
  //   const [y, setY] = useState(0); // 内部 Hook 2
  //   useEffect(() => { // 内部 Hook 3
  //     // 监听鼠标移动,并更新 x, y
  //   }, []);
  //   return { x, y };
  // }
  // 这里的内部 Hook 1 和 Hook 2 的状态,会影响内部 Hook 3 的副作用(因为它会更新这些状态)。
  // 最终,这个自定义 Hook 的输出 (mouseX, mouseY) 又会影响 MyDependentComponent 的渲染。

  return (
    <div>
      <p>Count: {count}</p>
      <p>Double Count: {doubleCount}</p>
      <p>Mouse X: {mouseX}, Mouse Y: {mouseY}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

这种可组合的管道式思想,可能和函数式编程有关,FP的另一个特点就是函数组合,为此还提出了柯里化、高阶函数等工具。与此类似的还有webpack中loader和plugin操作,同样是流水线管道式的模式。以下总结了下在webpack等基建工具中与Hook类似的点:

  1. “钩取”(Hook into)一个既有的流程或系统:  在不改变原有核心流程的情况下,插入自定义的逻辑。vite插件中各种钩子函数,webpack plugin各种钩子函数等。

  2. 可组合性:  你可以链式地使用多个 Loader。例如:css-loader!sass-loader!style-loader。

    • sass-loader 把 Sass 代码转换成 CSS。
    • css-loader 处理 CSS 中的 @import 和 url()。
    • style-loader 把 CSS 注入到 HTML 的  标签中。
    • 每个 Loader 都是一个独立的、功能单一的转换单元,它们可以像积木一样被组合起来,形成一个完整的转换流程。这和 React 中组合 useState、useEffect 或者自定义 Hook 来构建复杂行为非常相似。
  3. 关注点分离:  每个 Loader 都有明确的单一职责。babel-loader 只负责 Babel 转译,ts-loader 只负责 TypeScript 编译,file-loader 只负责文件输出等等。

  4. 函数式:  Loader 本质上就是接收源代码字符串,返回转换后的源代码字符串的纯函数(或者至少是表现得像纯函数)。还得是函数式编程(FP)

  5. 声明式:  Plugin 的配置也是声明式的,你告诉 Webpack 你需要哪些 Plugin,以及它们的配置参数。

结尾

所以说Hook可以说是一个比较新的技术名词,2018年发布嘛,但是,也可以说是一个比较老的技术,因为完全没有脱离之前软件工程设计领域的各种设计原则和编程范式。如果面试时硬要扯Hook,你可以多聊聊函数式编程、组合优于继承等熟悉的名词嘛,😍👌