Algebraic Effects
看到 Dan Abramov 的一篇博客 Algebraic Effects for the Rest of Us 提到了一个叫 Algebraic Effects 的概念。并举出了一个虚构的语法作为例子。它和之前提到的 Suspense 也有很多相似的地方。
function delay<T>(value: T, time = 1000) {
return new Promise<T>(resolve => {
setTimeout(() => resolve(value), time);
});
}
function process<T>(x: Promise<T> | T) {
console.log(x); // 这里可以正确输出 'value'
}
process(delay('value'));
使用普通的 JavaScript 特性,没有办法在不将 process 变为异步的同时正确输出 'value'
。假设有这样一种语法
function delay<T>(value: T, time = 1000) {
return new Promise<T>(resolve => {
setTimeout(() => resolve(value), time);
});
}
function isPromise<T>(x: Promise<T> | T): x is Promise<T> {
return typeof x.then === 'function';
}
function process<T>(x: Promise<T> | T) {
if (isPromise(x)) {
x = perform x;
}
console.log(x); // 这里可以正确输出 'value'
}
try {
process(delay('value'));
} handle (effect) {
if (isPromise(effect)) {
effect.then(value => {
resume with value;
});
}
}
这里有三个和普通 JavaScript 不同的地方,perform
, try-handle
和 resume
。它的作用和 try-catch
类似,在执行 perform
时会中断当前操作,跳转到最近一层的 try-handle
语句,并把 perform
后面的值传递给 handle
代码块。在运行到 resume
语句时,代码的运行会回到 perform
的地方,并返回 resume
的值。
具体到上面这个例子,代码会在运行到
function process<T>(x: Promise<T> | T) {
if (isPromise(x)) {
x = perform x;
}
console.log(x); // 这里可以正确输出 'value'
}
时中断,并且跳转出去寻找最近的一个 try-handle
语句
try {
process(delay('value'));
} handle (effect) {
if (isPromise(effect)) {
effect.then(value => {
resume with value;
});
}
}
此时 effect
的值就是 perform
传递过来的 x
,也就是 delay('value')
。
try {
process(delay('value'));
} handle (effect) {
if (isPromise(effect)) {
effect.then(value => {
resume with value;
});
}
}
然后在这个 Promise resolve 之后,运行 resume
,并把获取到的值 "value"
返回给原来执行的地方。
function process<T>(x: Promise<T> | T) {
if (isPromise(x)) {
x = perform x;
}
console.log(x); // 这里可以正确输出 'value'
}
此时 x
被赋值为 "value"
,最终正确输出。
「像写同步代码一样写异步代码」,这听起来很像 async-await
语法的描述,但是 async-await
有一个很不方便的场景:当一个函数从普通函数修改为 async 函数时,所有调用它的函数也需要修改为 async 函数。这条修改的链路可能很长,会给重构工作带来巨大的麻烦。而使用 Algebraic Effect,则只需要修改一个函数即可。
Algebraic Effect 的形式看起来和 上一篇文章 的 Suspense 实现有很多相似之处。但是 ALgebraic Effect 的写法显然简洁了很多。
// 需要自己提取 Monad 实现状态的流转
export const schedule = (m: Monad) => (thunk: () => T) => {
const ctx: Context = {
effect: null as Monad_<T>,
trace: [] as T[],
pos: 0,
};
return step();
// 执行流程在 `getValue` 和 `schedule#step` 里面反复横跳
// 执行的逻辑难以阅读
function step(value?: T): Monad_<T> {
const savedContext = context;
ctx.pos = 0;
try {
context = ctx; // 用到了全局变量,数据变化不清楚
return m.of(thunk());
} catch (e) {
const { pos } = ctx;
return m.chain(ctx.effect, (value: T) => {
ctx.trace.length = pos;
ctx.trace[pos] = value;
ctx.pos = pos + 1;
return step(value);
});
} finally {
context = savedContext;
}
}
};
schedule(PromiseM)(() => {
const value = getValue(delay("value"));
const value2 = getValue(delay("value2"));
console.log(value, value2);
});
看到这里大概就能理解,为什么异步加载等功能明明可以让用户自己实现,并且还有 react-query
等库,React 还要自己实现一个 Suspense 了。有了 Suspense 以后,用户完全不需要关心异步操作,以及随之而来的各种状态处理,真的可以做到像写同步代码一样写异步代码。