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!');
注意几点:
-
effect 发起者不需要知道 effect 是如何执行的(解耦),effect 的执行逻辑由 调用者 来定义。
这一点与 try…catch 相同,抛出错误的人不需要知道错误是如何被处理的。
getName
可以看成纯函数。易于测试。 -
effect 执行完以后,会回到 effect 发起处,并提供 effect 的执行结果。
这一点与 try…catch 不同,try…catch 无法恢复执行。
- 中间调用者对 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 时相同。但是前面所说的【异步性会感染所有上层调用者】的问题依然存在,getName
和 makeFriends
都要变成异步的。
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