10

Algebraic Effects是一个在编程语言研究领域新兴的机制,虽然目前还没有工业语言实现它,但是在React社区会经常听到关于它的讨论。React最近新特性(Suspense和hooks)的背后实际上是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.push(getName(user2));
  user2.friendNames.push(getName(user1));
}

const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };
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的执行逻辑由调用者来定义。“做什么”与“怎么做”相互解耦了

    Algebraic effects can be a very powerful instrument to separate the what from the how in the code.
    这一点与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.push(await getName(user2));
  user2.friendNames.push(await getName(user1));
}

const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };

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通过函数参数来拿到副作用方法),那么getName函数在可复用性上确实与使用Algebraic Effects时相同,并且也是易于测试的(测试的时候注入一个假的getDefaultNameFromServer即可)。但是前面所说的【异步性会感染所有上层调用者】的问题依然存在,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'; // perform an effect to get a default name!
  }
  return name;
}

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

async function main() {
  const arya = { name: null, friendNames: [] };
  const gendry = { name: 'Gendry', friendNames: [] };
  
  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成为纯函数,易于测试:

// 这是一个saga
function* fetchUser(action) {
   try {
      const user = yield call(Api.fetchUser, action.payload.userId);
      yield put({type: "USER_FETCH_SUCCEEDED", user: user});
   } catch (e) {
      yield put({type: "USER_FETCH_FAILED", message: e.message});
   }
}

理论上,确实可以利用generator function的控制权转移来实现Algebraic Effects。但是,无法避免感染调用者的问题,无法像真正的Algebraic Effects那样让调用者无感知。你需要将所有的Functional Component、custom hooks都改造成generator function。并且generator function只能从上次yield的地方恢复执行,而不能恢复到更早的yield状态。详见React Fiber架构:可控的“调用栈”

React中的Algebraic Effects

虽然JavaScript语言不支持Algebraic Effects(事实上,支持Algebraic Effects的语言屈指可数),但是得益于React自己实现的Fiber执行模型,React可以提供接近Algebraic Effects的能力。

React Fiber架构:可控的“调用栈”这篇文章中,我们讨论了React Fiber架构是一种可控的执行模型,每个fiber执行完自己的工作以后就会将控制权交还给调度器,由调度器来决定什么时候执行下一个fiber。

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( // 用同步的方式来编写异步代码!
        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)。
继续渲染的方式:React scheduler从上次暂停的组件开始(即User组件),调用render进行渲染,这次渲染的时候User组件能够从cache立即拿到数据。

得益于“render是纯函数”的契约,重新执行User->render不会担心有副作用;
得益于“React已经教育用户使用useMemo来缓存昂贵计算”,重新执行User->render不会有明显的性能劣势。

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

Hooks

React团队将hooks都看做Algebraic Effect,React调度器提供了各种基本hooks的“effect handler”,在调用render函数的时候为其提供上下文信息。

比如,useState的返回值取决于它的所处“调用上下文”,即它在组件树中的节点,即fiber。比如一个组件树中有2个Counter组件,那么这两个Counter调用useState的时候,所处的上下文是不一样的,因此它们的useState返回值是独立的。useMemo同理。

参考资料

我的知乎回答:如何看待 Crank 这个前端框架?
Algebraic effects, Fibers, Coroutines...
Algebraic Effects for the Rest of Us
Fiber Principles: Contributing To Fiber


csRyan
1.1k 声望198 粉丝

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart doesn't find a perfect rhyme with the head, then your passion means nothing.