如果你经常使用并且关注React,你一定会在不少地方见过"代数效应"(algebra effect) 这个抽象概念。可能是翻译和过度学术的缘故,我看了很多文章才大致理解,在这里简单记录一下。
try/catch & try/handle
你一定使用过try...catch 来做异常处理,看下面的例子
TypeScript
function divide(x: number, y: number) {
try {
if (0 === y) {
throw new Error('分母不能为0');
}
return x / y;
} catch (err) {
alert(err);
}
}
console.log(divide(100,0))
上面例子中,我们定义了除法运算,当分母为0时会抛出异常,并且中断try块内代码的执行。在catch块中, 我们可以对错误信息进行打印,弹窗提示等等,告诉调用者/用户参数异常。
这看上去很正常,但是如果你现在有个需求,当用户输入分母为0时,自动把分母调整为1,这样就能计算了,此时你会发现,你无法在catch块中调整分母的值。因为在异常抛出,进入catch块后,try内的调用栈就会被销毁,无法重新回到异常抛出位置继续执行。也就是说,当前的javascript规范中,是没有可以处理并且恢复继续执行错误代码的语法的。
看上去很头疼,但是为了解释代数效应这个概念,我们可以假设,在未来的ESXXXX标准中,引入了新的try...handle语法,那么我们可以将代码写成:
TypeScript
function divide(x: number, y: number) {
try {
if (0 === y) {
return x/(perform y)
}
return x / y;
} handle (val) {
if(0 === val){
// 恢复try内代码运行
resume 1
}
}
}
console.log(divide(100,0))
在上面代码中,我们引入了新的关键字 "perform" "resume" "try...handle"
在try..handle块中,我们可以使用perform关键字来抛出"可以被处理并且修复的异常(类比throw)
perform后,会进入执行handle块中的代码,可以在其中对错误进行修复,并且通过resume关键字,把修复后的值带回到perform的位置,继续执行。 此时我们就可以完成对异常分母值的替换和修复了。
当然了,再次声明,这个语法是虚构的,也许在未来的ES标准中会加入这个语法,但是当前我们不能这么写。
现在我们可以简单的说一下 "代数效应" 在后面的例子中,你会更充分的体会理解这个抽象的概念。
代数其实可以简单的理解为数学中的代数式/操作符号,比如 x+y x/y 等等,而效应可以理解为 效果。 即 "新增代数式带来的效果"
什么意思,比如上面的Divide例子,数学中可以把除法运算表达成 x / y 这样的表达式,但是这个表达式是没有处理分母为0异常的功能的。 此时我们新增一个标识符 # , 我们定义:
#: 当分母为0时,会替换成 1 并且进行除法运算
那么我们新增了之后的表达式,就变成了 x # y 此时虽然还是除法运算,但是新增的#带来了新的效果,让除法运算变得更"强大了"
你也许会问,啊 代数效应就是新增表达式/运算符么 可以这样理解,对于上面的例子,我们新增的 try...handle 就可以理解为 # 我们把分母为0的异常修复过程,通过一个新的运算符的引入进行封装,从而增强了Divide函数的功能,同时在函数的逻辑处理部分,我们也不需要关心这个修复过程如何进行,只需要关注逻辑运算部分,完成了结偶。
你也许会问,你这就是脱了裤子放屁,你直接在参数接受阶段判断一下并且修改不就得了,非得走异常传递这一步么? 你说的对!这个问题确实可以更加简化,但是看下面的例子,如果这个过程掺杂着异步处理,就更能凸显代数效应的作用了!
async/await 如何解决传染问题?
async/await 你肯定用过,声明了async的函数都会被作为"任务"异步处理。但是你有没有想过,这个过程是有传染性的,当外层要使用async函数的结果时,就必须使用await 但是await必须在async函数内,导致外层韩式也必须声明async,作为一个任务,一层一层,导致了async/await传染问题。
如何消除? 我们还是用上面的try...handle 解决
看如下例子,我们写一个函数打印个人信息,其中 姓名 年龄 需要通过远程服务器获取:
TypeScript
/** 模拟异步获取个人信息 */
function fetchRemoteInfo(type: string){
return new Promise((resolve=>{
setTimeout(() => {
if('name' === type){
resolve('李雷')
}else if('age' === type){
resolve(18)
}
}, 2000);
}))
}
async function printPersonInfo(){
const name = await fetchRemoteInfo('name')
const age = await fetchRemoteInfo('age')
console.log(`${name} ${age}岁啦!`)
}
printPersonInfo() // 4s之后,输出 "李雷 18岁啦!"
由于有传染问题,我们不想用async await 把printPersonInfo变成任务,怎么办?
假设未来的ESXXXX标准引入了try handle语法,那么我们的函数可以写成:
TypeScript
/** 模拟异步获取个人信息 */
function fetchRemoteInfo(type: string){
return new Promise((resolve=>{
setTimeout(() => {
if('name' === type){
resolve('李雷')
}else if('age' === type){
resolve(18)
}
}, 2000);
}))
}
function printPersonInfo(){
const name = perform 'name'
const age = perform 'age'
console.log(`${name} ${age}岁啦!`)
}
try{
printPersonInfo()
}handle(type){
fetchRemoteInfo(type).then(val=>{
/** 恢复到perform运行 */
resume val
})
}
上面的例子中,我们在printPersonInfo中,使用perform关键字,此时函数将会被停止(挂起)在handle块中请求数据,并且resume恢复,并且带结果到perform的位置,继续运行,达到了函数停止等待异步结果并且继续运行的目的。
你会发现,由于perfrom关键字的加入,printPersonInfo变得纯粹,没有任何的副作用,达到了逻辑和实现的分离,在编写该函数的过程中,你不需要关心如何请求name age的值,只需要知道,perform会帮你完成这些操作即可!
这就是代数效应,通过引入perfrom关键字,达到了消除异步传染,结偶异步请求的效果!
当然了! 上面都是我们的幻想,因为到目前为止,ES标准还没有加入可以恢复的perform resume关键字!那我们就没办法了吗? 也不是。
我们分析一下perfrom resume 做了什么,归纳如下:
-
暂停函数运行
-
返回处理结果并且从暂停位置继续运行
我们可以用当前的try catch完成或者接近实现这个过程吗? 其实可以
首先 try catch可以暂停函数的运行,但是无法在完成处理之后恢复,因为catch之后,调用栈就会被销毁,我们回不去了,但是我们可以近似的实现这个过程。
首先,要暂停恢复的函数是个纯函数,运行多次,不论每次运行到哪一行 结果一定是一样的,那么我们可不可以在获得结果后缓存这个结果,然后重新运行这个函数,当再次执行到这个异步处理时,直接使用缓存内容,不再请求,这样也就达到了恢复的效果。 具体流程如下图:
我们可以实现一个 runSyncMockRequest函数,用来提供perform操作
TypeScript
/** 同步请求函数 */
export function runSyncMockRequest(syncFunc: (perform: (type: string) => string) => void) {
/** 存储异步函数返回结果 */
const fetchResults: any[] = [];
/** 当前运行到的函数index */
let currentRunningFetchIndex = 0;
/** 实现_fetch函数,fetch为同步函数 */
const _perform = (type: string) => {
/** 本函数会多次运行,如果前面的_fetch函数已经有结果,则直接使用缓存,没有再去调用winodw.fetch请求 */
const fetchResult = fetchResults[currentRunningFetchIndex];
if (fetchResult) {
/** 已经有缓存了 直接返回 */
currentRunningFetchIndex++
return fetchResult
} else {
/** 没有缓存,发请求 */
/** 为了实现同步运行,这里在发出请求后,需要暂停函数运行,等待返回结果
* 如何实现停止 并且 等待?
* 使用抛异常的方式暂停函数运行,抛出当前请求的Promise对象,并且在catch中,设置改Promise对象的then方法
* 在then方法中,把请求回来的结果/失败原因 设置到缓存列表中,并且重新调用传入的syncFunc, 从头开始执行函数
* 遇到已经返回的_fetch 直接返回值,遇到没返回的 重复上面过程,指导函数运行结束
*/
throw fetchRemoteInfo(type).then(val=>{
fetchResults[currentRunningFetchIndex++] = val
})
}
};
/** 注意,try catch 一定要拿到_fetch外面,因为你要通过throw终止syncFunc的运行,而不仅仅是_fetch的运行 */
const runSync = () => {
try {
/** 运行传入的同步函数 */
syncFunc(_perform);
} catch (pendingPromise) {
/** 检查,是否为Primise类型 */
if (pendingPromise instanceof Promise) {
pendingPromise.then(() => {
/** 由于上一步的onRejected和onResolve回调都没有显式返回值,所以只会进入当前的onResolved */
/** 不论上一步成功与否,都重置currentRunningFetchIndex 并且重新执行syncFunc */
currentRunningFetchIndex = 0;
runSync();
});
}
return {};
}
};
runSync();
}
runSyncMockReques函数内部实现了一个_perform函数,_perform会作为参数,传入syncFunc内,在syncFunc内,我们调用_perform函数,就可以同步的获取远程数据。
syncFunc内部可能调用一次或多次_perform函数发起请求,_perform可能被调用一次或者多次,runSyncMockRequest内部包含一个结果缓存fetchResults数组用来保存每次_perfrom远程获得的结果,currentRunningFetchIndex来表示当前调用的是第几个_perform函数。
当_perform执行时,如果发现数组内已经有缓存结果了,直接返回缓存内容,如果没有缓存,则发起远程异步请求,并且将promise对象通过throw的方式抛出,此时整个syncFunc函数都会因为抛出异常被停止执行。
在catch内,接收到请求的promise对象后,调用其then方法,设置在promise对象返回后重置currentRunningFetchIndex并且重新调用syncFunc函数,这就是上面说的,由于没有try handle语法,我们不能在catch处理完之后回到异常抛出的位置,那么我们只能将每次_perfrom的结果都记录下来,并且在每次发起请求之后,重新执行整个函数,之前缓存过的_perfrom函数会直接使用缓存,不会发起请求,这样也达到了恢复现场的功能。
需要注意,如果syncFunc不是纯函数,比如包含输出语句,那么会导致多次输出,需要避免!
这样,我们就简单的实现了同步发起请求的功能,perform就是我们新增的操作符,通过参数传入,实现了新的功能(效果),也是代数效应的体现。
我们可以完善一下上面的函数,在真正的生产环境下,可以通过fetch真实发起请求,并且兼容了请求失败的情况,如下:
TypeScript
type SyncFetch = (input: RequestInfo | URL, init?: RequestInit) => any;
type SyncFetchResult = {
status: 'fulfilled' | 'rejected' | 'pending';
value?: any;
error?: any;
};
/** 同步请求函数 */
export function runSyncRequest(syncFunc: (_fetch: SyncFetch) => void) {
/** 存储异步函数返回结果 */
const fetchResults: SyncFetchResult[] = [];
/** 当前运行到的函数index */
let currentRunningFetchIndex = 0;
/** 实现_fetch函数,fetch为同步函数 */
const _fetch: SyncFetch = (...args) => {
/** 本函数会多次运行,如果前面的_fetch函数已经有结果,则直接使用缓存,没有再去调用winodw.fetch请求 */
const fetchResult = fetchResults[currentRunningFetchIndex];
if (fetchResult) {
/** 已经有缓存了 直接返回 */
currentRunningFetchIndex++;
if (fetchResult.status === 'fulfilled') {
return fetchResult.value;
} else if (fetchResult.status === 'rejected') {
throw new Error(fetchResult.error);
}
} else {
/** 没有缓存,发请求 */
/** 为了实现同步运行,这里在发出请求后,需要暂停函数运行,等待返回结果
* 如何实现停止 并且 等待?
* 使用抛异常的方式暂停函数运行,抛出当前请求的Promise对象,并且在catch中,设置改Promise对象的then方法
* 在then方法中,把请求回来的结果/失败原因 设置到缓存列表中,并且重新调用传入的syncFunc, 从头开始执行函数
* 遇到已经返回的_fetch 直接返回值,遇到没返回的 重复上面过程,指导函数运行结束
*/
const fetchResult = {} as SyncFetchResult;
fetchResults[currentRunningFetchIndex++] = fetchResult;
throw window
.fetch(...args)
.then((res) => res.json())
.then(
(value) => {
/** 处理成功 */
fetchResult.status = 'fulfilled';
fetchResult.value = value;
},
(reason) => {
/** 处理失败 */
fetchResult.status = 'rejected';
fetchResult.error = reason;
},
);
}
};
/** 注意,try catch 一定要拿到_fetch外面,因为你要通过throw终止syncFunc的运行,而不仅仅是_fetch的运行 */
const runSync = () => {
try {
/** 运行传入的同步函数 */
syncFunc(_fetch);
} catch (pendingPromise) {
/** 检查,是否为Primise类型 */
if (pendingPromise instanceof Promise) {
pendingPromise.then(() => {
/** 由于上一步的onRejected和onResolve回调都没有显式返回值,所以只会进入当前的onResolved */
/** 不论上一步成功与否,都重置currentRunningFetchIndex 并且重新执行syncFunc */
currentRunningFetchIndex = 0;
runSync();
});
}
return {};
}
};
runSync();
}
javascript的函数是第一公民,也就是我们可以将函数作为参数传递,这也就让我们可以封装实现很多新的 "运算符" 并且通过参数传入。我们可以将副作用封装,并且通过参数传递进函数,以保证函数运算的纯粹( pure Function )。
代数效应与React
函数式组件是在React未来的趋势。我们知道,React设计初衷,函数式组件是个纯函数,其渲染结果只和其传入的props参数有关。由于原生没有状态,导致单纯的函数组件只能完成无状态的渲染,只能实现简单功能。
引入了状态,或者在函数组件内发起请求,会导致函数组件不纯,所以React引入了hooks。hooks本质也是代数效应的一种体现,通过引入新的操作符/代数,如useState useEffect等等,将副作用从函数内部抽离出去,实现了函数组件可以使用状态,发起请求的效果。同时也保证了函数组件的纯粹。
在函数组件编写的过程中,我们不需要管useState是如何识别组件,如何保存状态,状态保存在哪里了这些问题,只需要简单的使用,设置状态即可。这让函数组件内部,只需要处理业务逻辑,将实现和逻辑完成了结偶和抽离。
Suspense如何实现
我们通常使用React中的Suspense组件进行懒加载和webpack分包处理来优化性能,其使用方法如下:
TypeScript
const LazyComp = lazy(()=>import('../LazyComp'))
function FatherComp(){
...
return <Suspense fallback='loading...'>
<LazyComp/>
</Suspense>
}
通常,Suspense函数需要配合lazy函数使用,fallback是Suspense的必传参数,当LazyComp组件还没有请求回来之前,Suspense组件会展示fallback内容。
lazy函数需要传一个返回Promise类型的函数进去,通常使用import(path)函数来请求并且返回value为待加载组件的promise。
那么其内部是如何实现的呢?看到promise,你也许会想到上面async的例子,没错,其内部原理本质上就是上面的runSyncRequest
思考一个问题,Suspense必须配合lazy使用吗? 或者说只有lazy才能让Suspense显示fallback吗?
其实不然,我们实现一个非lazy的组件:
TypeScript
let isLoad = false;
function UnLazyComp() {
if (!isLoad) {
throw new Promise((resolve) => {
setTimeout(() => {
isLoad = true;
resolve('');
}, 2000);
});
}
return <h2>UN LAZT COMP</h2>;
}
function FatherComp(){
...
return <Suspense fallback='loading...'>
<UnLazyComp/>
</Suspense>
}
在这个组件外部设置一个isLoad标记,当组件第一次加载的时候,抛出一个promise,并且在2s之后resolve,并且修改isLoad,达到仅在第一次渲染时抛出promise的效果,看输出结果:
2s之后...
会发现也达到了lazy的效果,看到这里,你肯定也猜到Suspense的原理了。
Suspense是个类组件,其内部实现了componentDidCatch这个勾子,当其子组件抛出异常的时候,可以在这里拿到异常值,我们可以检测,如果err instanceOf Promise 就修改状态值,显示fallback,并且在该promise被决定之后,修改回状态值并且展示内容。
简单的实现了个MySusupense 和 MyLazy 如下:
TypeScript
import React from 'react';
/** 利用代数效应,自己实现一个Suspense */
interface MySuspenseProps {
fallback: JSX.Element | string;
}
interface MySuspenseStates {
/** 组件是否加载 */
loading: boolean;
}
export function myLazy(fetchFunc: () => Promise<any>) {
let loadedComponent: any = null;
return () => {
if (!loadedComponent) {
throw {
promise: fetchFunc().then((value) => {
loadedComponent = value?.default;
}),
};
}
return <>{loadedComponent()}</>;
};
}
export default class MySuspense extends React.PureComponent<MySuspenseProps, MySuspenseStates> {
constructor(props: MySuspenseProps) {
super(props);
this.state = {
loading: false,
};
}
componentDidCatch(error: any): void {
if (error?.promise instanceof Promise) {
this.setState({
loading: true,
});
error?.promise.then(() => {
this.setState({
loading: false,
});
});
}
}
render() {
const { fallback, children } = this.props;
return <>{this.state.loading ? fallback : children}</>;
}
}
这里解释一下,为什么myLazy中throw的promise需要用对象包一下,因为React中包含了一些错误检测,如果直接抛出promise对象的话,React会以为你在使用Lazy函数,并且没有通过Suspense直接渲染(因为我们在使用我们自己写的Suspense),React会拦住这个promise,并且重新抛出新的错误信息:
XXX组件 suspended while rendering, but no fallback UI was specified. Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.
所以为了规避这个拦截,我们包一层对象,就可以在componentDidCatch中拿到错误信息了!
mylazy中包含了一个loadedComponent作为缓存,类似于上面的fetchResults,当缓存为空,则发起请求,并且throw promise ,此时该函数组件执行被中断,相当于返回了undefined,不会被渲染。
当请求返回,promise被决定后,由于修改了this.state.loading 导致MySusupense及其内部组件被重新渲染,此时 loadedComponent已经被赋值,可以直接返回loadedComponent的内容并且渲染出结果。
通过引入Suspense和lazy,可以让开发者忽略懒加载的过程,也是代数效应的体现。
仔细想想就会发现,React中代数效应无处不在,其目的也是为了分离副作用,让函数组件保持纯净。