乐趣区

Algebraic-Effects以及它在React中的应用

Algebraic Effects 是一个在编程语言研究领域新兴的机制,虽然目前还没有工业语言实现它,但是在 React 社区会经常听到关于它的讨论。React 最近的很多新特性的背后实际上是 Algebraic Effects 的概念。因此,我花了一些时间来了解 Algebraic Effects,希望体悟到 React 团队是如何理解这些新特性的。

Algebraic Effects

每一个 Algebraic Effect 都是一次【程序控制权】的巡回:

【effect 发起者】发起 effect,并暂停执行(暂时交出程序控制权)
-> 沿着调用栈 向上查找对应的 effect handler(类似于 try…catch 的查找方式)
-> effect handler 执行(获得程序控制权)
-> effect handler 执行完毕,【effect 发起者】继续执行(归还程序控制权)

例子(这并不是合法的 JavaScript):

function getName(user) {
  let name = user.name;
  if (name === null) {name = perform 'ask_name'; // perform an effect to get a default name!}
  return name;
}

function makeFriends(user1, user2) {user1.friendNames.add(getName(user2));
  user2.friendNames.add(getName(user1));
}

const arya = {name: null};
const gendry = {name: 'Gendry'};
try {makeFriends(arya, gendry);
} handle (effect) { // effect handler!
  if (effect === 'ask_name') {const defaultName = await getDefaultNameFromServer();
      resume with defaultName; // jump back to the effect issuer, and pass something back!
  }
}

console.log('done!');

注意几点:

  1. effect 发起者不需要知道 effect 是如何执行的(解耦),effect 的执行逻辑由 调用者 来定义。

    这一点与 try…catch 相同,抛出错误的人不需要知道错误是如何被处理的。
    getName可以看成纯函数。易于测试。

  2. effect 执行完以后,会回到 effect 发起处,并提供 effect 的执行结果。

    这一点与 try…catch 不同,try…catch 无法恢复执行。

  3. 中间调用者对 Algebraic Effects 是无感的,比如例子中的makeFriends

Algebraic Effects 与 async / await 的区别

用 async / await 实现上面的例子:

async function getName(user) {
  let name = user.name;
  if (name === null) {name = await getDefaultNameFromServer();
  }
  return name;
}

async function makeFriends(user1, user2) {user1.friendNames.add(await getName(user2));
  user2.friendNames.add(await getName(user1));
}

const arya = {name: null};
const gendry = {name: 'Gendry'};

makeFriends(arya, gendry)
  .then(() => console.log('done!'));

异步性会感染所有上层调用者

可以发现,makeFriends现在变成异步的了。这是因为 异步性会感染所有上层调用者 。如果要将某个同步函数改成 async 函数,是非常困难的,因为它的所有上层调用者都需要修改。
而在前面 Algebraic Effects 的例子中,中间调用者 makeFriends 对 Algebraic Effects 是无感的。只要在某个上层调用者提供了 effect handler 就好。

可复用性的区别

注意另一点,getName直接耦合了副作用方法getDefaultNameFromServer。而在前面 Algebraic Effects 的例子中,副作用的执行逻辑是【在运行时】【通过调用关系】【动态地】决定的。这大大增强了 getName 的可复用性。

在 async / await 的例子中,通过 依赖注入 能够达到与 Algebraic Effects 类似的可复用性。如果 getName 通过依赖注入来得到副作用方法 getDefaultNameFromServer,那么getName 函数在可复用性上,确实与使用 Algebraic Effects 时相同。但是前面所说的【异步性会感染所有上层调用者】的问题依然存在,getNamemakeFriends 都要变成异步的。

Algebraic Effects 与 Generator Functions 的区别

与 async / await 类似,Generator Function 的调用者在调用 Generator Function 时也是有感的。Generator Function 将程序控制权交给它的 直接调用者 ,并且只能由 直接调用者 来恢复执行、提供结果值。

直接调用者 也可以选择将程序控制权沿着执行栈继续向上交。这样的话,直接调用者(下面例子的makeFriends)自己也要变成 Generator Function(被感染,与 async / await 类似),直到遇到能提供【结果值】的调用者(下面例子的main)。

function* getName(user) {
  let name = user.name;
  if (name === null) {name = yield 'ask_name';}
  return name;
}

function* makeFriends(user1, user2) {user1.friendNames.add(yield* getName(user2));
  user2.friendNames.add(yield* getName(user1));
}

async function main() {const arya = { name: null};
  const gendry = {name: 'Gendry'};
  
  let gen = makeFriends(arya, gendry);
  let state = gen.next();
  while(!state.done) {if (state.value === 'ask_name') {state = gen.next(await getDefaultNameFromServer());
      }
  }
}

main().then(()=>console.log('done!'));

可以看出,在可复用性上,getName没有直接耦合副作用方法 getDefaultNameFromServer,而是让某个上层调用者来完成副作用。这一点与使用 Algebraic Effects 时相同。
redux-sagas 就使用 Generator Functions,将副作用的执行从 saga 中抽离出来,saga 只需要 发起副作用。这使得 saga 成为纯函数,易于测试。

但是,依然存在 感染调用者 的问题。

React 中的 Algebraic Effects

在 React Fiber 架构:可控的“调用栈”这篇文章中,我们讨论了 React Fiber 架构是一种可控的执行模型,每个 fiber 执行完自己的工作以后就会将控制权交还给调度器,由调度器来决定什么时候执行下一个 fiber。
虽然 JavaScript 不支持 Algebraic Effects(事实上,支持 Algebraic Effects 的语言屈指可数),但是在 React Fiber 架构的帮助下,React 可以模拟一些很实用的 Algebraic Effects。

Suspend

<Suspend>就是一个例子。当 React 在渲染的过程中遇到尚未就绪的数据时,能够暂停渲染。等到数据就绪的时候再继续:

// cache 相关的 API 来自 React 团队正在开发的 react-cache:// https://github.com/facebook/react/tree/master/packages/react-cache
const cache = createCache();
const UserResource = createResource(fetchUser); // fetchUser is async

const User = (props) => {
    const user = UserResource.read( // synchronously!
        cache,
          props.id
    );
  return <h3>{user.name}</h3>;
}

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <User id={123} />
      </Suspense>
    </div>
  );
}

react-cache 是 React 团队正在开发的工具,将 <Suspense> 用于数据获取的场景,让需要等待数据的组件“暂停”渲染。
目前已经上线的,通过 React.lazy 来暂停渲染的能力,其实也是类似的原理。

理论上 UserResource.read 可以看做发起了一个 Algebraic Effect。User 发出这个 effect 以后,控制权暂时交给了 React(因为 React 是 User 的调用者)。React scheduler 提供了对应的 effect handler,检查 cache 中是否有对应 id 的 user:

- 如果在 cache 中,则立即将控制权交还给 User,并提供对应的 user 数据。- 如果不在 cache 中,则调用 fetchUser 从网络请求对应 id 的 user,在此过程中,渲染暂停,`<Suspense>` 渲染 fallback 视图。得到结果以后,将控制权交还给 User,并提供对应的 user 数据。

实际上 ,它是通过 throw 来模拟 Algebraic Effect 的。如果数据尚未准备好,UserResource.read抛出 一个特殊的 promise。得益于 React Fiber 架构,调用栈并不是 React scheduler -> App -> User,而是:先React scheduler -> App 然后React scheduler -> User。因此 User 组件抛出的错误会被 React scheduler 接住,React scheduler 会将渲染“暂停”在 User 组件。这意味着,App 组件的工作不会丢失。等到 promise 解析到数据以后,从 User fiber 开始重新渲染就好了(相当于控制权直接交还给 User)。

如果直接使用调用栈来管理组件树的渲染(递归渲染),那么 App 组件的渲染工作会因为 User 抛出值而丢失,下次渲染需要从头开始。

Hooks

React 团队将 hooks 都看做 Algebraic Effect。useState 的返回值取决于它的所处“虚拟调用栈”,即它在组件树中的位置,即 fiber。比如一个组件树中有 2 个 Counter 组件,那么这两个 Counter 组件实例所处的上下文是不一样的,因此它们的 useState 返回值是独立的。

参考资料

Algebraic effects, Fibers, Coroutines…
Algebraic Effects for the Rest of Us

退出移动版