所以,Hook 究竟是什么?

在现在这个节点再回头来讨论 Hook,讨论 Algebraic Effect 以及 Monad 似乎已经有些晚了。

这些年过去,随着 Hook 的语法逐渐成为主流,React 兴起时代的 Component 似乎已经成了遥远的回忆。在当年那个节点,我们是怎么看待 Hook 的呢?到了当下这个时间,我们又该如何评价 Hook 呢?

在这 React Hook 系列文章的第一篇文章中,还是让我从 Hook 的概念讲起吧,这样也许能帮助大家以及我自己更好在日常开发过程中理解 Hook。

为什么需要 Hook?

在 Class 形式的组件语法已经被广泛使用 & 理解的情况下,为什么 React 团队要推出 Hook 语法呢?

React 团队对 Hook 的介绍中已经说的很明白了:zh-hans.legacy.reactjs.org/docs/hooks-...

简单来讲,我认为 Hook 的引入主要在于两点:

  • 拆分:Class 形式使得组件内的逻辑拆分变得困难。由于业务本身的复杂性,组件的生命周期中不可避免的会糅杂许多并不关联的逻辑。这些逻辑可能散落在不同的生命周期中,导致组件逻辑变得复杂而不清晰;
  • 复用:Class 形式对于组件状态的管理使得内部逻辑的复用变得困难,不同组件之间的逻辑组合很难直接在组件层面实现。

而 Hook 语法的函数形式很好的解决了上述两个问题。那么,究其本质而言,Hook 语法是从何处来的呢?

Algebraic Effect

React Hooks 是 Algebraic Effect 吗?

是,但不完全是。React 团队从 Algebraic Effect 中汲取了灵感,尽管 JavaScript 中并未提供 Algebraic Effect 的机制,但是 Hooks 一定程度上与 Algebraic Effect 的目标以及形式相似。

Algebraic Effect 是什么?

当 React 宣布了新的 Hook 语法之后,有关于 Algebraic Effect 的讨论声音也多了许多。(还记得当年许多讨论 Algebraic Effect 的文章我都还看过,只不过还没有整理成文章就都消散于回忆的烟尘中了...)

如果要从 Category Theory 的角度来详细介绍 Algebraic Effect 的话势必要引用许多艰涩的文章。这里我还是从前端开发的视角来描述我对于 Algebraic Effect 的理解吧。

Algebraic Effect 是什么:

Algebraic Effect 是用来处理副作用的一种手段,它将不纯的副作用操作上抛给 Handler 进行处理。

Algebraic effects are an approach to computational effects based on a premise that impure behaviour arises from a set of operations such as get & set for mutable store, read & print for interactive input & output, or raise for exceptions. This naturally gives rise to handlers not only of exceptions, but of any other effect, yielding a novel concept that, amongst others, can capture stream redirection, backtracking, co-operative multi-threading, and delimited continuations.

An Introduction to Algebraic Effects and Handlers

如果转成 JavaScript 语法的话,Algebraic Effect 一定程度上与 try/catch 语法相类似:

js 复制代码
try {
  doSomeThings();
  throw Error('No!!!');  // 抛出异常,将执行交给 error handler
  doSomeOtherThings();
} catch (e) {
  console.error(e);
}

实际上,异常本身也是一种副作用,不同的是 try/catch 语法并没有提供 continuation,也就是当 try/catch 语法捕获到异常时,程序无法从异常抛出的点继续执行。

而 Algebraic Effect 的语法可能像是这样:

js 复制代码
handle {
  doSomeThings();
  perform 'Print' 'Message!!!';  // 将 Print 的副作用上抛给 handler 执行
  doSomeOtherThings();
} with (effect, params, continuation) {
  if (effect.type === 'Print') {
    console.log('Handler:', params[0]);  // 处理副作用,打印消息
    continuation();  // 继续之前的执行
  }
}

上述代码使用了 perform/handle/with 的语法,当程序会通过 perform 将副作用(print)上抛给 handler,并且由 handler 来完成这一副作用的实际执行。continuation 代表着上述程序的后续逻辑,调用 continuation() 则会继续之前程序的执行。

而从 continuation 的角度来讲,Algebraic Effect 实际上更像是 yield 语法:从某个特定的节点交出控制权,当处理完成后继续接下来的执行。

我们可以通过 Algebraic Effect 来处理更多不同的副作用,比如处理异步请求等:

js 复制代码
function main() {
  doSomeThings();
  const userInfo = perform 'FetchFromDatabase';
  const result = perform 'WriteToStorage' userInfo;
  doSomeOtherThings();
}

handle {
  main();
} with(effect, params, continuation) {
  if (effect === 'FetchFromDatabase') {
    fetch('http://some-url/user/info').then((userInfo) => {
      continuation(userInfo);
    });
  }
  if (effect === 'WriteToStorage') {
    window.localStorage.setItem(params[0].id, params[0].name);
    continuation(userInfo);
  }
}

在这个例子中,我将 main 函数与需要 Algebraic Effect 的部分拆分开,这样可以更清晰看出它的作用。在 main 中我们实现了一个读取数据库并写入本地存储的逻辑,其中的副作用(网络请求 & 写入存储)将会上抛到 handlers 中,由 handlers 进行统一的处理,从而将副作用进行了收拢。

举个🌰

如果想真正体验 Algebraic Effect 能力的话,我们可以使用 Eff 库来做到这一点:

Eff is a functional programming language based on algebraic effect handlers. This means that Eff provides handlers of not only exceptions, but of any computational effect, allowing you to redirect output, wrap state modifications in transactions, schedule asynchronous threads, and much much more...

www.eff-lang.org/

Eff 是一个基于 Algebraic Effect 实现的编程语言。让我们看看一个真实的 Algebraic Effect 示例是长什么样子的:

js 复制代码
handle
    perform (Print "A");
    perform (Print "B");
    perform (Print "C");
    perform (Print "D")
with
| effect (Print msg) k ->
    perform (Print ("I see you tried to print " ^ msg ^ ". Not so fast!\n"))
;;

在这个例子中,Eff 的 handle/with 语法实际上与上一节中的语法非常相似。Eff 通过 perform 将打印这一副作用进行上抛。在 handler 中则捕获了这一副作用,执行了具体的打印操作。

对应到 React Hook

从上面的例子来看,实际上 Algebraic Effect 的作用已经同 React Hook 非常相似了。以 useEffect 为例,一个请求异步数据的示例像这样:

js 复制代码
function App() {
  return <UserPanel />;
}

function UserPanel() {
  const [name, setName] = useState<string>('');
  
  useEffect(() => {
    fetch('http://some-url/user/info').then(userInfo => {
      console.log('userInfo', userInfo);
      setName(userInfo.name);
    });
  }, []);

  return (
    <div>
      {`Hello ${name}!`}
    </div>
  );
}

在 useEffect 中,我们封装了 userInfo 的网络请求。当数据请求完成之后,name 将被更新到 state 中,并触发 React 的重新渲染。

如果将 React 的整个渲染过程看作为一个大函数的执行,那么我们可以将这一过程类比到 Algebraic Effect 的执行过程:

  1. 第一次渲染:App 组件渲染,随后执行 UserPanel 组件渲染,并触发 useEffect 执行;
  2. useEffect 与数据库交互,完成副作用,更新 state 并触发第二次渲染;
  3. 第二次渲染:UserPanel 组件渲染,并继续 Div 渲染。

在这里 useEffect 也就类似于我们之前所用到的 handlers。

如果要细究起来,React 的整个执行流程与 Algebraic Effect 并不相同,尤其是在 continuation 相关的执行中。在第一次渲染中,实际上 React 会完成整个树的渲染过程,App -> UserPanel -> div。而 Algebraic Effect 的 continuation 会在副作用执行结束之后才继续。

但是可以看到,React 团队试图通过 Hook 做到的事情与 Algebraic Effect 的理念是相似的。

Monad

React Hooks 是 Monad 吗?

不是。尽管 React Hook 与 Monad 的其中一部分目标相似,都能够处理各种副作用。但是从形式的定义上来看,两者相差较多,并不能混为一谈。

Monad 是什么?

如果想要了解 Functor、Applicative Functor 以及 Monad,看这篇文章是最为便捷的:www.adit.io/posts/2013-...

或者如果你对 Haskell 感兴趣的话,可以从它的语法开始学起。不过我还是在这里努力解释一下我的理解。

如果要理解 Monad,首先应该理解 Functor (函子)的概念。Functor 类似于一种上下文的封装,并且定义了对一个封装在上下文中的对象中执行函数的方法,例如:

js 复制代码
Maybe = Just<number> | Nothing // 将任意的数字封装在上下文中
// Maybe 的对象例如: Just<3>  | Just<5> | Nothing

Fmap = (Maybe, number => number): Maybe // Fmap 方法将一个包装在 Maybe 上下文中
// Fmap(Just<3>, (a) => a + 2) ==> Just<5>
// Fmap(Nothing, (a) => a * 2) ==> Nothing

看这个图片就对 Functor 的 map 方法一目了然了:

那么 Applicative Functor 是什么呢?Applicative Functor 定义了另一种不同形式的方法<*>,它将函数封装在上下文中,并应用在另一个封装到上下文中的对象里:

js 复制代码
<*> = (Maybe, Maybe<(number) => number>): Maybe
// Just<3> <*> Just<(a => a + 3)> ==> Just<6>

而 Monad 则定义了另一种不同的方法。它将一种特殊形式的函数应用到封装的对象上,它的入参为普通的对象而返回值为一个封装到上下文中的对象:

js 复制代码
>>= = (Maybe, number => Maybe): Maybe

Half = (a => Just<a / 2>) // 将数据对半相除的方法
// Just<16> >>= Half >>= Half ==> Just<4>

就像这样。具体的定义可以参看 Haskell 的语法,其中的类型定义会更加清晰一些:

js 复制代码
data Maybe a = Nothing | Just a

instance Functor Maybe where
    fmap func (Just val) = Just (func val)
    fmap func Nothing = Nothing

instance Applicative Maybe where
    pure = Just
    Nothing <*> _ = Nothing
    (Just f) <*> something = fmap f something

instance Monad Maybe where
    Nothing >>= func = Nothing
    Just val >>= func  = func val

那这些是做什么的呢?

(在 Haskell 中 Monad 同时也是 Applicative Functor,但是实际上从范畴论的角度来讲,两者并不完全统一。不过这里还是只讲 Monad 的部分。)

上面抽象的"上下文"概念也许令人费解,我们可以将它类比到 JavaScript 中的 Promise 来看待这些定义。需要注意的是,由于 Promise 实现中的具体边界 case,实际上 Promise 并不严格符合 Monad 的定义。感兴趣的话可以看这两个话题讨论:

我们可以将 then 方法视作为 Monad 的 >>= 方法:

js 复制代码
// Promise 示例:
Promise.delay(50).then(() => Promise.delay(50)) // 返回 Promise

// 对比 Monad 方法:
>>= = (Maybe, number => Maybe): Maybe
Just<16> >>= (a => Just<a / 2>) // 返回 Just<8>

可以观察到两者在形式上呈现出的相似性。而 Monad 的一个重要作用就是为了隔离副作用。类似于 Promise 将异步请求包裹到其中那样,Monad 在纯函数式语言中能够帮助程序实现"不纯"的逻辑。例如 Haskell 中提供了 IO Monad:

js 复制代码
-- >> 方法是 >>= 的一个变种,与 >>= 不同的是 >> 会将不需要的返回值进行丢弃
main :: IO ()
main =
  putStrLn "Hello, world!" >> -- 打印字符串
  putStrLn "What is your name, user?" >> -- 打印字符串
  getLine >>= (\name ->  -- 获取输入
    putStrLn ("Nice to meet you, " ++ name ++ "!"))  -- 随后基于输入进行打印

通过 Monad,程序实现了一个具有输入输出副作用的逻辑。

需要注意的是,与不纯的 JavaScript 不同,在这段 Haskell 代码中,并没有任何副作用真正被执行 。IO Monad 将需要的副作用进行了封装,实际上,上述代码只是生成了如何进行副作用的一个描述,最终返回的内容是:IO<如何处理这一系列副作用>

那么,Hook 呢?

了解了 Monad 的一系列定义之后,让我们回过头再来看看 React Hook。

让我们看一个自定义 Hook 组合的例子:

js 复制代码
// useCounter.ts
import { useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  
  return { count, increment, decrement };
}

// useLocalStorage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage(key: string, initialValue: any) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });
  
  const setValue = (value: any) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue];
}

// usePersistentCounter.ts
import { useCounter } from './useCounter';
import { useLocalStorage } from './useLocalStorage';

export function usePersistentCounter(key: string, initialValue = 0) {
  const [storedCount, setStoredCount] = useLocalStorage(key, initialValue);
  const { count, increment, decrement } = useCounter(storedCount);
  
  // 当计数器值变化时,更新本地存储
  const persistentIncrement = () => {
    increment();
    setStoredCount(count + 1);
  };
  
  const persistentDecrement = () => {
    decrement();
    setStoredCount(count - 1);
  };
  
  return { count, increment: persistentIncrement, decrement: persistentDecrement };
}

代码本身没有太多可说的地方,开发者可以任意使用 React 内置的 hook 或者自定义的 hook 进行组合。

但是从形式上来讲,这一组合并没有提供类似于"上下文封装"的概念。从自定义 hook 中的返回内容将会直接用在 React 组件的渲染中。同时,Hook 的组合过程也无法满足之前我们所提到的 Monad 的定义:

js 复制代码
>>= = (Maybe, number => Maybe): Maybe

因此,从形式上来讲,虽然 Hook 与 Monad 一样可以用来处理副作用,但是两者并不相似。

结论

经过这冗长的讨论,我们暂时走到了一个结论的面前:

React Hook 的想法从 Algebraic Effect 中来,但是与 Monad 的概念并不匹配。React Hook 对 React 渲染中的副作用进行了封装,从而保证其余的组件逻辑是"纯"的。

参考资料

相关推荐
拉不动的猪11 分钟前
electron的主进程与渲染进程之间的通信
前端·javascript·面试
软件技术NINI35 分钟前
html css 网页制作成品——HTML+CSS非遗文化扎染网页设计(5页)附源码
前端·css·html
fangcaojushi36 分钟前
npm常用的命令
前端·npm·node.js
阿丽塔~1 小时前
新手小白 react-useEffect 使用场景
前端·react.js·前端框架
鱼樱前端1 小时前
Rollup 在前端工程化中的核心应用解析-重新认识下Rollup
前端·javascript
m0_740154671 小时前
SpringMVC 请求和响应
java·服务器·前端
加减法原则1 小时前
探索 RAG(检索增强生成)
前端
禁止摆烂_才浅2 小时前
前端开发小技巧 - 【CSS】- 表单控件的 placeholder 如何控制换行显示?
前端·css·html
烂蜻蜓2 小时前
深度解读 C 语言运算符:编程运算的核心工具
java·c语言·前端
PsG喵喵2 小时前
用 Pinia 点燃 Vue 3 应用:状态管理革新之旅
前端·javascript·vue.js