在我看来,React 的格局,是和别处前端框架不同的。如果说 Vue、Angular 之流还只是对 HTML 的增强,React 已经是一个独立于 Web 前端之外的视觉层工具,只是碰巧支持了 HTML 渲染而已。
React 的整个体系完全游离传统 Web 前端之外。这一点体现于 React 在 JavaScript 的环境中硬造出一套函数式编程的理念,也体现于它在一门不支持中断抛出的语言里实现了代数效应。
那么,什么是代数效应?
什么是代数效应
代数
首先来明确下,什么是代数。一个常见的代数如下所示:
其最简单的含义,就是把数字代进去参与公式计算。
效应
接着再来明确什么叫效应。编程语境下的「效应」,和我们口语中常说的「XXX 效应」不同。
口语中「XXX 效应」一般是指 XXX 现象,如「多普勒效应」、「马太效应」等。而编程语境中的「效应」,是指程序执行的操作。
代数效应
由此可以得出「代数效应」的意思。
它指的不是「数学公式产生的现象」(我读到这个词的第一反应),而是「将程序执行的操作像代数一样,代入到另一块操作中」。
以实际代码为例,首先设定如下:
- 我们规定用
perform
关键词表示此处需要代入效应。代码执行到此处,将程序中断,告诉另一处需要传一个效应回来; - 另一处接收到请求后,执行所需的逻辑,通过
resume
将效应执行的结果返回到中断处,再恢复程序; - 为了便于捕获
perform
请求,我们令代数效应有形似try… catch
的结构,令其为try… handle
。这样只要在try
里包裹的代入效应请求,都能被外层接收并处理。
由此可写出代数效应的实际例子:
try {
const userId = perform 'user_id'; // 中断程序,要求代入 'user_id' 效应
} handle (effect) {
// 处理以上请求
if (effect === 'user_id') {
const resp = await fetch('userId');
const userId = await resp.text();
resume userId; // 执行效应,将结果代入原逻辑,并恢复程序运行
}
}
代数效应的作用
假设有这样一个需求,从数据库中读取 userName,基本代码如下:
const getUserName = (userId) => doSthToGetUserName(userId);
const main = () => {
const userName = getUserName(123);
console.log(userName);
};
根据具体场景,该逻辑会使用不同的方法实现 getUserName
:
- 从 localStorage 直接获取
- 发送请求从远程获取
避免由内而外的层层污染
上面的例子,当处于同步环境中,即从 localStorage 里获取,写成那样是没问题的。
但如果是异步环境,即 userName
从网络请求获取,就得用上 async/await
:
const getUserName = async (userId) => (await axios.get(`/db/${userId}`)).data;
众所周知,async/await
具有传染性。既然 getUserName
是异步函数,调用它的 main
函数也要写成异步的
const main = async () => {
const userName = await getUserName(123);
console.log(userName);
};
于是,传统写法会造成 async/await
的污染从内部层层传递到最外层。
代数效应可以解决这一问题——
对于同步函数,获取 userName
时可等同于:「中断 → 执行 getUserName
→ 恢复 」;对于异步函数,由于本身不会中断流程,必须要显式使用 await
中断,上层因此也需要做异步中断,这也是 async/await
污染性的来源。
由于代数效应的逻辑本身就已经是「中断函数,接收到 resume
后恢复」,无需再使用各种方法中断流程,也就避免了 async/await
污染性,同时能让同异步写成相同代码结构。
用代数效应改写上面逻辑如下:
const getUserName = (userId) => {
try {
perform ({ userId }); // 中断,等待获取 userId
} handle (effect) {
// -- 对于同步 ------
resume localStorage.get(effect.userId); // 立刻恢复
// -- 对于异步 ------
axios.get(`/db/${effect.userId}`).then(resp=>{
resume resp.data; // 请求完成后恢复
})
}
};
// main 仍保持原来写法
const main = () => {
const userName = getUserName(123);
console.log(userName);
};
避免由外而内的层层传递
还是上面的例子。对于这种需求,为了灵活性,我们常将 getUserName
作为依赖注入到程序中,从而在不同场景下实现不同的逻辑。
即写成如下形式(不考虑 async/await
污染):
// 同步环境将注入的依赖
const implGetUserName = (userId) => localStorage.get(userId);
// 异步环境将注入的依赖
const implGetUserNameAsync = (userId) => axios.get(`/db/${userId}`);
// 内层接受注入的依赖
const getUserName = (userId, implGetUserName) => implGetUserName(userId);
// 外层接受注入的依赖,并传递给内层
const main = (implGetUserName) => {
const userName = getUserName(userId, implGetUserName);
console.log(userName);
};
对于一个层级较深的结构,要注入依赖,要经过多层传递,观感和维护性都很差。
考虑到代数效应的特点是将某处要实现的逻辑(效应)抛至外层,由外层处理完后再返回,其性质和依赖注入十分相似。可以用代数效应改写如下:
const getUserName = (userId) => {
const userName = perform ({ userId }); // 等待外层的注入依赖执行完回传
return userName;
};
const main = () => {
const userName = getUserName(123);
console.log(userName);
};
// 由于代数效应类似 try…catch 的特性,只要在调用 main 的地方的外层包裹 try…handle 就行
try {
main();
} handle (effect) {
// -- 同步时注入的依赖 ------
resume localStorage.get(effect.userId);
// -- 异步时注入的依赖 ------
axios.get(`/db/${effect.userId}`).then(resp=>{
resume resp.data;
})
}
由此,实现了依赖注入,同时避免了依赖层层传递的问题。
React 与代数效应
Fiber
代数效应一个特点就是代码的可中断性。
为实践代数效应,React 在最近几个版本里将内部实现换成了 Fiber:将框架的操作打碎、原子化,从而可以模拟出中断。
我们知道 ES6 引入了 Generator 特性,真正在引擎层面实现了中断,但 React 并没有采用这一方案,因为 Generator 也存在污染性传递,不能满足代数效应的另一个特点:可以跨层级将中断抛出去。而原子化的 Fiber 实现这一点很容易。
代数效应的应用
最明显的代数效应例子就是 <Suspense />
。当内层任一组件处于 loading 状态,都能触发最外层 Suspense
进入 fallback 状态。同时保证了组件渲染可以随时恢复,这是与 ErrorBoundary
或曰 try…catch
完全不同的。
另一个例子就是 hooks。
例如 useState
:函数组件本身并没有能力保存 state 的状态,但每次使用时都能拿到一个 stateful 的值,这就是因为在调用 useState 时进行了中断,将效应抛出给 React,由它获取到 state 值后,代入回组件函数。
又例如 useContext
:从内层组件可以随时获取到最外层的 context value,而无需层层传递。这已经很明显了,不多说。
结语
代数效应就聊到这。还觉得 React 只是一个普通的前端视觉库吗?