代数效应
从概念角度来说, 代数效应是一种方法, 可以"隔离函数中的副作用, 从而让函数变为纯函数"。
这样我们在编写程序逻辑时就不用考虑具体的副作用的实现, 也就是"做什么"和"怎么做"是解耦的。
从实现机制角度来说, 代数效应可以看作是一种执行控制, 函数可以在某个需要执行副作用的地方暂停,保存并跳出当前执行栈, 沿调用栈向上找到这个副作用对应的处理函数(handlers), 处理函数执行完毕, 再从之前暂停的地方继续执行。
这有些类似JS中的try-catch, 只是try-catch之后是不可恢复的。
下面我们通过例子来获得一个对代数效应的具体印象。Dan Abramov 在这篇文章中展示了这样的一个例子:
function getName(user) {
let name = user.name;
if (name === null) {
throw new Error('A girl has no 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);
} catch (err) {
console.log("Oops, that didn't work out: ", err);
}
我们把getName函数视作一个副作用, 在上面的代码里, getName抛错后被顶层的try-catch捕获后, JS引擎会退栈, 清除局部变量, 但如果有一种虚构的语法, 让引擎可以在捕获错误之后有机会从抛出错误的地方恢复执行, 那就是代数效应了:
// 依然是Dan的例子
function getName(user) {
let name = user.name;
if (name === null) {
// 这里不用`throw`, 虚构了一个语法`perform`
// 基本上和`throw`类似, `perform`会'抛出'一个副作用请求
// throw new Error('A girl has no name');
name = perform 'ask_name';
// 按照我们虚构的语法, 这里应该会恢复执行, 并且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-catch 改为虚构的 try-handle
try {
makeFriends(arya, gendry);
// } catch (err) {
} handle (effect) {
// 这里就是上面抛出的副作用的处理器
if (effect === 'ask_name') {
// 虚构语法`resume`
// 用`Arya Stark`作为name的值恢复执行
// 这里也可以是一个异步IO请求获取name的值
// 也可以是从浏览器缓存中读取的值
// 这些都是'怎么做', 很自然的, 就实现了'怎么做'和'做什么'的隔离
resume with 'Arya Stark';
}
}
Generator(async/await)
JS中的 generator和async/await都拥有暂停函数执行并恢复的能力, 是否可以用来在JS中实现隔离副作用,也就是代数效应呢?
实际上, redux-saga 就基于generator 实现了副作用分离, 但和上面虚构的那种理想情况不同, generator存在染色"的问题:
generator在暂停当前函数执行后, 控制权是传递到他的调用者中的(在其返回值上调用next()), 若当前的调用者想要在调用栈上向上递交控制权, 那么调用者自身也需要是generator函数。而在虚构的语法中, makeFriends函数并不需要知道getName内通过perform发起了一次副作用请求。
此外, 类似try-catch冒泡的机制, 副作用handler也不需要关注发起副作用请求的函数在调用栈上的层级。
下面我们用generator来改写上面的例子:
// 修改为generator函数
function* getName(user) {
let name = user.name;
if (name === null) {
name = yield 'ask_name';
}
return name;
}
// 控制权在这里, 但我们不想在这一层处理, 提交控制权就需要"染色"
// makeFriends也修改为generator函数
function* makeFriends(user1, user2) {
user1.friendNames.push(yield* getName(user2));
user2.friendNames.push(yield* getName(user1));
}
const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };
// 在顶层获取generator函数控制权
let gen = makeFriends(arya, gendry);
let state = gen.next();
while(!state.done) {
// 处理副作用
if (state.value === 'ask_name') {
// 同样, 这里也可能是异步IO/Mock数据/浏览器缓存读取...
state = gen.next('Arya Stark');
}
}
用async/await来实现:
async function getName(user, effectHandler) {
let name = user.name;
if (name === null) {
name = await effectHandler(user)
}
return name;
}
async function makeFriends(user1, user2) {
user1.friendNames.push(await getName(user2, retrieveUserName));
user2.friendNames.push(await getName(user1, retrieveUserName));
}
const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };
// 处理副作用
async function retrieveUserName(user) {
return 'Arya Stark'
}
makeFriends(arya, gendry);
async/await还需要动态注入副作用的处理函数的wrap, 否则getName耦合副作用handler就失去了代数效应隔离副作用应有的灵活度。
React Suspense
先抛开上面说的代数效应, 接下来看看React.Suspense。
React 16.6 版本引入了Suspense, 这是一个标记组件, 类似Fragment, 没有实际渲染内容。
通过使用Suspense, 他的子组件在渲染阶段如果遇到未就绪的数据, React会暂停子组件的渲染, 转而渲染一个fallback组件(loading), 等到数据就绪后, React可以从之前暂停的位置继续子组件的渲染。
Suspense的机制可以简单描述为:
- 子组件在渲染阶段读取一个数据源
- 若数据未就绪, 子组件抛出一个异常, 异常携带一个
thenable的值 (typeof value.then === 'function') - React 调度器接住这个异常, 取出
thenable值, 建立对这个值的监听, 在他resolve(或reject)后重新渲染
来看一个官方的例子
const resource = fetchProfileData();
function ProfilePage() {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline />
</Suspense>
</Suspense>
);
}
function ProfileDetails() {
// 数据未就绪时, 这里会抛出Promise, React在Promise Resolve后重新渲染
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
function ProfileTimeline() {
const posts = resource.posts.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
事实是, React Suspense的心智模型正是代数效应。
- 请求副作用
perform
上文的例子中,resource.user.read();等价于我们虚构的语法中的perform user, 发起了一个副作用请求, 当然实际还是通过throw抛异常的形式。 - 暂停当前函数执行
React调度器接住了这个异常并暂停了对应组件的渲染, 等价于暂停了当前函数的执行。 - 恢复当前函数执行
Resume
又因为异常值本身是一个Promise, React得以在Promise完成后重启渲染, 虽然这本质上不是函数的恢复执行, 但 "从 React 的角度来看,在 Promise 解决的时候重渲染组件树跟恢复执行没什么区别。只要你的编程模型假定幂等,就可以假装我们可以恢复执行!"
其实开始看到Suspense的实现时, 我觉得挺奇怪的, 抛出一个Promise? 不过相比自己写,Suspense确实能够解决问题, 我觉得最香的就是解决副作用Race Conditions的问题:
function ProfilePage({ user_id }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(user_id).then(u => setUser(u));
}, [id]);
if (user === null) {
return <p>Loading profile...</p>;
}
return (
<h1>{user.name}</h1>
);
}
比如有样的组件, 参数user_id短时间内变个几次, 真实网络环境下就很容易出现当前fetch得到的user和当前的user_id是不匹配的:
- user_id 为 1
- 异步获取 user1
- user_id 为 2
- 异步获取 user2
- 异步获取 user2 完成, 当前user设置为user2
- 异步获取 user1 完成, 当前user设置为user1...
现在从代数效应这个角度再看Suspense的实现, 因为隔离了副作用, 此时组件内状态设置(setUser)是同步的, 就不需要考虑时序的问题了, 这样一下就很容易理解了!
React Fiber
让我们再深入一点, 刚才还说JS内没有实现代数效应, 用generator模拟也会出现染色的问题, 那么React为何就能模拟出这样的效果呢?
JS引擎的执行机制是调用栈+事件循环, 从某个入口函数开始, 后入先出的执行每个进入调用栈的函数, 直到清空调用栈为止,这中间是不会暂停的。React 16之前渲染工作就是依赖JS引擎的调度, 因此当组件树规模大了后,组件间的递归调用会导致调用栈很深, 中间又无法暂停,UI就会卡顿,因此React 16引入了Fiber来解决这个问题。
Fiber架构下,一个组件对应一个Fiber结构, 每个组件渲染完成后就把执行权交还给React调度器,调度器检查当前帧剩余时间,如果时间没到就继续执行下一个Fiber渲染工作, 否则把执行权交还浏览器以防用户感受到卡顿(直接return即可),同时通过postMessage调度下一轮渲染(产生一个task)。
如果把组件渲染看作函数调用,那么组件对应的Fiber可以视作调用栈桢,每个组件走完渲染阶段即是函数调用完成, 但与调用栈不同, Fiber是链表,并且React一次只调度一个Fiber,所以他可以被打断或恢复。
正是基于这种底层模拟,React才拥有了实现类代数效应的能力, 并且这种执行机制几乎就是协程, 所以后续版本的并发渲染也好理解了。
React hook
React Hook 的心智模型也是代数效应(RFC: React Hooks by sebmarkbage · Pull Request #68 · reactjs/rfcs (github.com)), 下次再来看看他从代数效应角度去理解是什么样的吧。😁
参考资源
Algebraic Effects,以及它在React中的应用 - SegmentFault 思否
React Fiber架构如何从JS引擎手中“夺回”调度权 - SegmentFault 思否
通俗易懂的代数效应 — Overreacted
ReactFiber在并发模式下的运行机制 - 掘金 (juejin.cn)
Fiber Principles: Contributing To Fiber · Issue #7942 · facebook/react (github.com)
Suspense for Data Fetching (Experimental)