✨从异步讲起,『函数』和『时间』该作何关系?

2,109 阅读13分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

专栏简介

作为一名 5 年经验的 JavaScript 技能拥有者,笔者时常在想,它的核心是什么?后来我确信答案是:闭包和异步。而函数式编程能完美串联了这两大核心,从高阶函数到函数组合;从无副作用到延迟处理;从函数响应式到事件流,从命令式风格到代码重用。所以,本专栏将从函数式编程角度来再看 JavaScript 精要,欢迎关注!传送门

前文回顾

不知不觉,专栏已经来到第 5 篇~ 😍😍😍

前 4 篇传送门、时间线及概要:

# ✨从历史讲起,JavaScript 基因里写着函数式编程 - 2022年09月19日

=> JavaScript 闭包起源于 1930 年的 lambda 运算;

# ✨从柯里化讲起,一网打尽 JavaScript 重要的高阶函数 - 2022年09月26日

=> 将函数作为参数输入或输出,是封装高级函数的核心思想;

# ✨从纯函数讲起,一窥最深刻的函子 Monad - 2022年10月09日

=> 写无副作用的纯函数不只是为了嘴上说说优雅,而是为了函数的组合、演算简化、及自文档等好处;

# ✨从延迟处理讲起,JavaScript 也能惰性编程? - 2022年10月12日

=> 延迟处理是连接 JavaScript 闭包和异步两大核心的桥梁,JavaScript 真万能,惰性编程一样拿捏;

OK,至于本篇,将从异步讲起,看看「JS 异步」和 「函数式」能擦出什么样的火花?看看异步中的时间与函数该作何关系?

探秘 JS 异步

JavaScript 除了“闭包”这个最经典的设计之外,还有它是“单线程”的设计,一样可奉为最经典!

这里先抛出 3 个经典的问题:

  1. “JavaScript 为什么要是单线程?”
  2. “JavaScript 的单线程,意味着什么?”
  3. “JavaScipt 异步原理是怎么实现的?”

如果你能清晰准确地回答出这3个关于异步老生常谈的经典问题,可以跳过下一小节的释义。

经典 3 问

先浅答一下 JS 异步经典 3 问 ~

  1. “JavaScript 为什么要是单线程?”

答:四字概括,为了:“简单方便”。JavaScript 最初设计只是运行在浏览器的脚本语言,若同一时间要做多件事情便会产生矛盾;不像其它后端语言用“锁”这样一个机制,也为了极致简单,所以 JavaScript 设计是单线程的。

  1. “JavaScript 的单线程,意味着什么?”

答:单线程意味着任务需要排队,任务是一个接一个地执行,前一个执行完毕,才会执行下一个。这就意味着前一个任务的执行会阻塞后续任务的执行。

好比去银行办理业务,目前只有一个人工窗口,前面有个人要办理大额贷款业务,需要填写很多表格,只有等这人把全部表格都填完,整个流程都走完,才能让后面的人接着办业务。

现实中如果发生这样的事,肯定要被投诉,哪有这样设计的?让后面这么多人干等他填表格,并且这个时候窗口服务也是停止的,那效率得多低呀。

所以,正确的做法是,先将这个人挪到一边,让他去填表格,把窗口服务腾出来给后面的人继续办业务,等表格填完了,再回过头来给你办理大额贷款。

将这个比喻映射到 JavaScript 也是同样的逻辑,JavaScript 通过异步来解决单线程阻塞的问题。这也是 与生俱来 就已经设定好了的(和闭包一样,都写在 DNA 里)。

image.png

  1. “JavaScipt 异步原理是怎么实现的?”

答:JS 引擎通过混用 2 种内存数据结构:栈和队列 来实现异步。栈与队列的交互也就是大家所熟知的 JS 事件循环(Event Loop)。

简单来讲:所有同步任务都是在主线程上执行的,形成 执行栈,异步任务的回调消息形成 回调队列。在执行栈中的任务处理完成后,主线程就开始读取任务队列中的任务并执行。按这个规则,不断往复循环。

上一张经典的图:

image.png

这里的 Stack 就相当于是前面所提银行场景中的唯一人工窗口,Stack 里面的任务就是等待办业务的人,遇到办大额贷款、填很多表格的人,则先挪到一边去,然后继续处理后面人的业务。若这人表格全填完了,就把这个消息放到 CallBack queue 里,等 Stack 里为空后,再去拿 callBack queue 的消息,继续为你解决大额贷款。

以上三问,老生常谈,温故知新。

新 3 问

好了,老 3 问只是开始的小结,这里本瓜要问异步新 3 问:

  1. “JavaScript 实现异步有哪几种表现形式?”

  2. “JavaScript 异步和函数式有什么关系?”

  3. “JavaScript 异步真的简单吗?”

在脑袋里面简单过一过你的答案?

。。。。。。

下面来逐一详细解答~~

异步演进

  1. “JavaScript 实现异步有哪几种表现形式?”

答:

① 回调函数

最简单实现异步就是使用回调函数。

打个比方,以打电话给客服为例,你有两种选择:排队等待客服接听 或 选择客服有空时回电给你。

后面一种就是回调 —— CallBack

🌰代码示例:

function success(res){
    console.log("API call successful");
}

function fail(err){
    console.log("API call failed");
}

function callApiFoo(success, fail){
    fetch(url)
      .then(res => success(res))
      .catch(err => fail(err));
};

callApiFoo(success, fail);

回调缺点就是:嵌套调用会形成 回调地狱,加大代码的阅读难度,比如:

callApiFooA((resA)=>{
    callApiFooB((resB)=>{
        callApiFooC((resC)=>{
            console.log(resC);
        }), fail);
    }), fail);
}), fail);

② Promise

为了弥补回调函数的不足,ES6 将异步方案改进为 Promise。

🌰用代码说话,上述“回调地狱”优化为:

function callApiFooA(){
    return fetch(url); // JS fetch method returns a Promise
}

function callApiFooB(resA){
    return fetch(url+'/'+resA.id);  
}

function callApiFooC(resB){
    return fetch(url+'/'+resB.id);  
}

callApiFooA()
    .then(callApiFooB)
    .then(callApiFooC)
    .catch(fail)

Promise 也有缺点,当状态处于 pending 时,不知道程序执行到哪一步了,无法中途取消,这一点前面的文章也提到过。

③ Generator

于是 Generator 生成器函数异步解决方案诞生。

🌰代码变化:

function *makeIterator() {
   let resA = fetch(url)
   yield resA
   let resB = fetch(url+'/'+resA.id)
   yield resB
   let resC = fetch(url+'/'+resB.id)
   yield resC
}
var it = makeIterator()

it.next() // callApiFooA
it.next() // callApiFooB
it.next() // callApiFooC

再后来,ES2017 提出 async await 是 Generator 语法糖,不做赘述。

一般来说,写道 async await ,JS 异步演进就结束了,但,不止于此,还有一种,是本节的亮点,即“响应式”。

④ 响应式

处理多个异步操作数据流是很复杂的,尤其是当它们之间相互依赖时,我们可以用更巧妙地方式将它们组合:响应式处理异步,Observer 登场!

🌰 show me the code:

 function callApiFooA(){
    return fetch(urlA); 
 } 
 
 function callApiFooB(){
    return fetch( urlB );  
 }
 
 function callApiFooC( [resAId, resBId] ){
    return fetch(url +'/'+ resAId +'/'+ resBId);  
 } 
 
 function callApiFooD( resC ){
    return fetch(url +'/'+ resC.id);  
 } 
 
 Observable.from(Promise.all([callApiFooA() , callApiFooB() ])).pipe(
    map(([resA, resB]) => ([resA.id, resB.id])), // <- extract ids
    switchMap((resIds) => Observable.from(callApiFooC( resIds ) )),
    switchMap((resC) => Observable.from(callApiFooD( resC ) )),
    tap((resD) => console.log(resD))
).subscribe();

同步请求 A、B 两个接口,然后把结果作为请求 C 的参数,然后把请求 C 的返回作为请求 D,最后打印请求 D 的结果。

这里用到一些大家可能陌生的新的 api,需稍作解释:

  • Observable.from 将一个 Promises 数组转换为 Observable,它是基于 callApiFooA 和 callApiFooB 的结果数组;
  • map — 从 API 函数 A 和 B 的 Respond 中提取 ID;
  • switchMap — 使用前一个结果的 id 调用 callApiFooC,并返回一个新的 Observable,新 Observable 是 callApiFooC( resIds ) 的返回结果;
  • switchMap — 使用函数 callApiFooC 的结果调用 callApiFooD;
  • tap — 获取先前执行的结果,并将其打印在控制台中;
  • subscribe — 开始监听 observable;

Observable 是多数据值的生产者,它在处理异步数据流方面更加强大和灵活。它在 Angular 等前端框架中被使用。

这样做有何好处?核心好处是分离 创建(发布)  和 调用(订阅消费)

异步与回调的核心意义不正在于此吗?我订阅你的博客,你发布了新内容,于是就通知我这边,好了,这样一来,我也不用干等,只要你发布了新的文章,我就可以按照自己的方式来消费它们。各干各的。并且我消费的方式可以是花里胡哨的,可以坐着看、躺着看、上班看、睡觉前看、拉屎看,与你发布无关。

异步和函数式

  1. “JavaScript 异步和函数式有什么关系?”

有关系吗?

异步是解决单线程设计的堵塞的,函数式是 JavaScript 的基因其中一种。二者似乎没关系?

错,二者有关系,并且关系莫大,粗略分为 3 点:

① 组合特性

在函数式编程中,我们把函数组合当作是重点之一,将函数的声明和函数的组合调用分开。每个函数的功能职责单一,最大范围内保持数据的不变性、数据计算的易追踪。

在异步解决方案中,我们也尽量将对异步操作的先后关系确定清楚,谁和谁一起执行、谁先执行谁后执行、谁等待谁的结果,这些也是在调用过程中有很多操作的地方,与声明隔开。在调用时组合好,数据流沿着时间维度演变。

② 代码可读性

异步从回调地狱到 Promise,到 Generator,到 async await,是为了啥?不就是为了代码读起来更易读吗?

那函数式也是,从无副作用的纯函数,清晰可见地控制输入输出,再到函数组合,演算,也是为了更可读。

可谓:二者志同而道和

image.png

③ 函数响应式编程

有一种编程方式就叫:函数响应式编程,你说二者什么关系?

函数式响应式编程(FRP) 是一种编程范式,它采用函数式编程的基础部件(如map、reduce、filter等),进行响应式编程(异步数据流程编程)。FRP被用于GUI、机器人和音乐方面的编程,旨在通过显式的建模时间来简化这些问题。—— wikipedia

通俗来讲,函数响应式编程是面向离散事件流的,在一个时间轴上会产生一些离散事件,这些事件会依次向下传递。

image.png

如图所示,点击一个按钮事件,随着时间推移,这个点击事件会产生三个不同的结果:

  • 发生错误
  • 事件完成

我们可以定义方法用来:捕获值,捕获错误,捕获点击事件结束。

对应代码上的,就涉及几个基础概念:

  • Observable(可观察对象) :就是点击事件流。

  • Observers(观察者) :就是捕获值/错误/事件结束的方法(其实就是回调函数集合)。

  • Subscription(订阅) :Observable 产生的值都需要通过一个‘监听’把值传给 Observers,这个‘监听’就是 Subscription。

  • Producer(生产者):就是点击事件,是事件的生产者。

--a---b-c---d---X---|->

a b c d 是产生的值
X 是错误
| 是事件结束标志
---> 是时间线

在前端交互非常复杂的系统中,客户端都是基于事件编程的,对事件处理非常多,在这样的场景下, 函数响应式编程可以更加有效率地处理事件流,而无需管理状态。能量强大。

异步与时间

  1. “JavaScript 异步真的简单吗?”

想一想,JavaScript 异步的设计真的就是简单吗?

“给你一段同步代码,有 10 个函数方法调用” 和 “给你一段同步加异步的代码,其中 5 个函数方法是同步、5 个函数方法是异步”,你觉得其中哪个会更易理解?

毫无疑问,控制其它变量,尽量选择有更多同步代码的会更易理解。

为什么?因为异步就代表着先后时间关系,代表着复杂!

在你所有的应用里,最复杂的状态就是时间。当你操作的数据状态改变过程比较直观的时候,是很容易管理的。但是,如果状态随着时间因为响应事件而隐晦的变化,管理这些状态的难度将会成几何级增长。

1666663944950.png

很多情况下我们调试错误发现最终原因是因为异步处理的回调先后关系出错。

所以,异步并不简单。

怎样才简单?这里提供 3 个方法,简单释义:

① 减少时间状态

不喜欢时间是吧,那就异步转同步,减少时间状态,promise 或者 async await 就是一个很好的例子。

② 监听(惰性)

设置监听,就不用管时间啦,这也是另外一种消除时间状态的方法。

我们在 Vue 这种框架中用生命周期、钩子函数、各类监听,正是如此,不用再管具体时间先后,框架已经帮我们限定好了,按照它的规则处理即可。

③ 函数响应式编程

函数响应式编程是更规范、更高级的让异步更简单的方案。

用纯函数、用表达式、用组合、分离 生产者 和 消费者 、用更强大的封装 API,代码各司其职,可以很大程度上提高代码的可读性和维护性。

结语

为什么是异步?因为我们不想浪费因同步等待阻塞的时间。

但是你时间又总给函数带来困惑,异步中,我要沿着时间线不断去追溯你,协调因响应先后不同带来的差异。

状态随着时间发生隐晦的变化,管理这些状态,难度成几何级增长。

代码的可靠性?可预见性?又该从何而得?

时间,时间,请给函数以答案?

。。。。。。

相信你认真看完本篇会有一点想法和答案~~

本篇侧重在理论释义,后续会带来 FRP 实战、以及 JS Monad、RxJS 等进阶内容,敬请期待~


OK,以上便是本篇分享,希望各位工友喜欢~ 欢迎点赞、收藏、评论 🤟

我是掘金安东尼 🤠 100 万人气前端技术博主 💥 INFP 写作人格坚持 1000 日更文 ✍ 关注我,安东尼陪你一起度过漫长编程岁月 🌏

😹 加我微信 ATAR53,拉你入群,定期抽奖、粉丝福利多多。只学习交友、不推文卖课~

😸 我的公众号:掘金安东尼,在上面,不止编程,更多还有生活感悟~

😺 我的 GithubPage: tuaran.github.io,它已经被维护 4 年+ 啦~