从异步传染浅谈代数效应

如果你经常使用并且关注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 做了什么,归纳如下:

  1. 暂停函数运行

  2. 返回处理结果并且从暂停位置继续运行

我们可以用当前的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中代数效应无处不在,其目的也是为了分离副作用,让函数组件保持纯净。

相关推荐
qq_417371724 分钟前
vue+天地图+Openlayers
前端·vue.js
闪光桐人25 分钟前
uniapp/vue项目 import 导入文件时提示Module is not installed,‘@/views/xxx‘路径无法追踪
前端·vue.js·webpack·uni-app·vite
貂蝉空大31 分钟前
uni-app 封装下拉选择组件 标红指定项
前端·javascript·uni-app
GoppViper33 分钟前
uniapp js向json中增加另一个json的全部数据,并获取json长度
前端·javascript·前端框架·uni-app·json·uniapp
姚*鸿的博客1 小时前
electron使用npm install出现下载失败的问题
javascript·electron·npm
wp_tao1 小时前
js逆向--npm包管理工具切换国内镜像源
开发语言·javascript·npm
yzhSWJ1 小时前
使用npm link 把一个本地项目变成依赖,引入到另一个项目中
前端·npm·node.js
张志翔的博客1 小时前
2024最新国内镜像源设置(npm、yarn、pnpm)
前端·npm·node.js
瑞金彭于晏1 小时前
Vue CLI项目创建指南:选择预设与包管理器(PNPM vs NPM)
前端·vue.js·npm
HuStoking1 小时前
Flutter 实现骨架屏
前端·flutter