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

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的学习专栏
分享对于计算机科学的学习和思考,只发布有价值的文章: 对于那些网上已经有完整资料,且相关资料已经整...

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart...

1.1k 声望
181 粉丝
0 条评论
推荐阅读
手写一个Parser - 代码简单而功能强大的Pratt Parsing
在编译的流程中,一个很重要的步骤是语法分析(又称解析,Parsing)。解析器(Parser)负责将Token流转化为抽象语法树(AST)。这篇文章介绍一种Parser的实现算法:Pratt Parsing,又称Top Down Operator Precede...

csRyan阅读 2.9k

在前端使用 JS 进行分类汇总
最近遇到一些同学在问 JS 中进行数据统计的问题。虽然数据统计一般会在数据库中进行,但是后端遇到需要使用程序来进行统计的情况也非常多。.NET 就为了对内存数据和数据库数据进行统一地数据处理,发明了 LINQ (L...

边城17阅读 1.8k

封面图
涨姿势了,有意思的气泡 Loading 效果
今日,群友提问,如何实现这么一个 Loading 效果:这个确实有点意思,但是这是 CSS 能够完成的?没错,这个效果中的核心气泡效果,其实借助 CSS 中的滤镜,能够比较轻松的实现,就是所需的元素可能多点。参考我们...

chokcoco18阅读 2k评论 2

你可能不需要JS!CSS实现一个计时器
CSS现在可不仅仅只是改一个颜色这么简单,还可以做很多交互,比如做一个功能齐全的计时器?样式上并不复杂,主要是几个交互的地方数字时钟的变化开始、暂停操作重置操作如何仅使用 CSS 来实现这样的功能呢?一起...

XboxYan20阅读 1.5k评论 1

封面图
「彻底弄懂」this全面解析
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在 哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this就是记录的其中一个属性,会在 函数执行的过程中用到...

wuwhs17阅读 2.3k

封面图
用了那么久的 SVG,你还没有入门吗?
其实在大部分的项目中都有 直接 或 间接 使用到 SVG 和 Canvas,但是在大多数时候我们只是选择 简单了解 或 直接跳过,这有问题吗?没有问题,毕竟砖还是要搬的!

熊的猫16阅读 1.5k评论 2

封面图
学会这些 Web API 使你的开发效率翻倍
随着浏览器的日益壮大,浏览器自带的功能也随着增多,在 Web 开发过程中,我们经常会使用一些 Web API 增加我们的开发效率。本篇文章主要选取了一些有趣且有用的 Web API 进行介绍,并且 API 可以在线运行预览。C...

九旬12阅读 1.5k

封面图

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart...

1.1k 声望
181 粉丝
宣传栏