聊聊代数效应(Algebraic Effects)

2021-05-08 · 技术 · 约 16 分钟读完

我知道代数,也知道效应。可代数效应是什么鬼?

在我看来,React 的格局,是和别处前端框架不同的。如果说 Vue、Angular 之流还只是对 HTML 的增强,React 已经是一个独立于 Web 前端之外的视觉层工具,只是碰巧支持了 HTML 渲染而已。

React 的整个体系完全游离传统 Web 前端之外。这一点体现于 React 在 JavaScript 的环境中硬造出一套函数式编程的理念,也体现于它在一门不支持中断抛出的语言里实现了代数效应。

那么,什么是代数效应?

什么是代数效应

代数

首先来明确下,什么是代数。一个常见的代数如下所示:

2x+1=52x + 1 = 5

其最简单的含义,就是把进去参与公式计算。

效应

接着再来明确什么叫效应。编程语境下的「效应」,和我们口语中常说的「XXX 效应」不同。

口语中「XXX 效应」一般是指 XXX 现象,如「多普勒效应」、「马太效应」等。而编程语境中的「效应」,是指程序执行的操作。

代数效应

由此可以得出「代数效应」的意思。

它指的不是「数学公式产生的现象」(我读到这个词的第一反应),而是「将程序执行的操作像代数一样,代入到另一块操作中」。

以实际代码为例,首先设定如下:

  • 我们规定用 perform 关键词表示此处需要代入效应。代码执行到此处,将程序中断,告诉另一处需要传一个效应回来;
  • 另一处接收到请求后,执行所需的逻辑,通过 resume 将效应执行的结果返回到中断处,再恢复程序;
  • 为了便于捕获 perform 请求,我们令代数效应有形似 try… catch 的结构,令其为 try… handle。这样只要在 try 里包裹的代入效应请求,都能被外层接收并处理。

由此可写出代数效应的实际例子:

try {
  const userId = perform 'user_id'; // 中断程序,要求代入 'user_id' 效应
} handle (effect) {
  // 处理以上请求
  if (effect === 'user_id') {
    const resp = await fetch('userId');
    const userId = await resp.text();
    resume userId; // 执行效应,将结果代入原逻辑,并恢复程序运行
  }
}

代数效应的作用

假设有这样一个需求,从数据库中读取 userName,基本代码如下:

const getUserName = (userId) => doSthToGetUserName(userId);

const main = () => {
  const userName = getUserName(123);
  console.log(userName);
};

根据具体场景,该逻辑会使用不同的方法实现 getUserName

  • 从 localStorage 直接获取
  • 发送请求从远程获取

避免由内而外的层层污染

上面的例子,当处于同步环境中,即从 localStorage 里获取,写成那样是没问题的。

但如果是异步环境,即 userName 从网络请求获取,就得用上 async/await

const getUserName = async (userId) => (await axios.get(`/db/${userId}`)).data;

众所周知,async/await 具有传染性。既然 getUserName 是异步函数,调用它的 main 函数也要写成异步的

const main = async () => {
  const userName = await getUserName(123);
  console.log(userName);
};

于是,传统写法会造成 async/await 的污染从内部层层传递到最外层。

代数效应可以解决这一问题——

对于同步函数,获取 userName 时可等同于:「中断 → 执行 getUserName → 恢复 」;对于异步函数,由于本身不会中断流程,必须要显式使用 await 中断,上层因此也需要做异步中断,这也是 async/await 污染性的来源。

由于代数效应的逻辑本身就已经是「中断函数,接收到 resume 后恢复」,无需再使用各种方法中断流程,也就避免了 async/await 污染性,同时能让同异步写成相同代码结构。

用代数效应改写上面逻辑如下:

const getUserName = (userId) => {
  try {
    perform ({ userId }); // 中断,等待获取 userId
  } handle (effect) {
    // -- 对于同步 ------
    resume localStorage.get(effect.userId); // 立刻恢复

    // -- 对于异步 ------
    axios.get(`/db/${effect.userId}`).then(resp=>{
      resume resp.data; // 请求完成后恢复
    })
  }
};

// main 仍保持原来写法
const main = () => {
  const userName = getUserName(123);
  console.log(userName);
};

避免由外而内的层层传递

还是上面的例子。对于这种需求,为了灵活性,我们常将 getUserName 作为依赖注入到程序中,从而在不同场景下实现不同的逻辑。

即写成如下形式(不考虑 async/await 污染):

// 同步环境将注入的依赖
const implGetUserName = (userId) => localStorage.get(userId);
// 异步环境将注入的依赖
const implGetUserNameAsync = (userId) => axios.get(`/db/${userId}`);

// 内层接受注入的依赖
const getUserName = (userId, implGetUserName) => implGetUserName(userId);

// 外层接受注入的依赖,并传递给内层
const main = (implGetUserName) => {
  const userName = getUserName(userId, implGetUserName);
  console.log(userName);
};

对于一个层级较深的结构,要注入依赖,要经过多层传递,观感和维护性都很差。

考虑到代数效应的特点是将某处要实现的逻辑(效应)抛至外层,由外层处理完后再返回,其性质和依赖注入十分相似。可以用代数效应改写如下:

const getUserName = (userId) => {
  const userName = perform ({ userId }); // 等待外层的注入依赖执行完回传
  return userName;
};

const main = () => {
  const userName = getUserName(123);
  console.log(userName);
};

// 由于代数效应类似 try…catch 的特性,只要在调用 main 的地方的外层包裹 try…handle 就行
try {
  main();
} handle (effect) {
  // -- 同步时注入的依赖 ------
  resume localStorage.get(effect.userId);

  // -- 异步时注入的依赖 ------
  axios.get(`/db/${effect.userId}`).then(resp=>{
    resume resp.data;
  })
}

由此,实现了依赖注入,同时避免了依赖层层传递的问题。

React 与代数效应

Fiber

代数效应一个特点就是代码的可中断性。

为实践代数效应,React 在最近几个版本里将内部实现换成了 Fiber:将框架的操作打碎、原子化,从而可以模拟出中断。

我们知道 ES6 引入了 Generator 特性,真正在引擎层面实现了中断,但 React 并没有采用这一方案,因为 Generator 也存在污染性传递,不能满足代数效应的另一个特点:可以跨层级将中断抛出去。而原子化的 Fiber 实现这一点很容易。

代数效应的应用

最明显的代数效应例子就是 <Suspense />。当内层任一组件处于 loading 状态,都能触发最外层 Suspense 进入 fallback 状态。同时保证了组件渲染可以随时恢复,这是与 ErrorBoundary 或曰 try…catch 完全不同的。

另一个例子就是 hooks。

例如 useState:函数组件本身并没有能力保存 state 的状态,但每次使用时都能拿到一个 stateful 的值,这就是因为在调用 useState 时进行了中断,将效应抛出给 React,由它获取到 state 值后,代入回组件函数。

又例如 useContext:从内层组件可以随时获取到最外层的 context value,而无需层层传递。这已经很明显了,不多说。

结语

代数效应就聊到这。还觉得 React 只是一个普通的前端视觉库吗?