Loading

你不知道的JavaScript——异步编程(上)传统回调模式

异步编程

夏天太热了,我买了几颗柠檬,准备做柠檬茶。

我先烧了开水,把茶泡上了,但是突然想起好像之前不知道从哪里听说的,如果把柠檬或者蜂蜜直接丢进热水里会发苦。我不知道是不是真的,而且就算不是真的,我也不能把一壶滚烫的茶水直接灌进塑料瓶里。

所以我只能先把它放在那,好在我的水壶是智能的,我设置了等它凉到25度的时候就通知我,去刷会儿Bilibili。

不知道过了多久,我的手机上来了一条通知,我的智能水壶告诉我,那壶水已经凉了,但是现在我正在看我的老婆“吃花椒的喵酱”的视频,我把它看完了,才去弄柠檬茶。

这就是异步,而且是纯纯的JavaScript当中的异步,说白了,就是有些任务是可以立即被执行的,而有些任务需要未来才执行(如做柠檬茶需要等待水凉),协调好这些任务之间的关系就是异步编程。

有几个点需要注意:

  1. 我发觉离我能做下一步操作(做柠檬茶)还需要等待一段时间。
  2. 我设定了一个监听器,也就是智能水壶的温度检测,当水凉了它会告诉我去处理(这在JS中叫做回调)
  3. 当它回调我的时候,我并没有立即去执行(做柠檬茶),而是先做完了手头的任务(看王冰冰!!——吃花椒的喵酱),再去执行。也就是说,做柠檬茶被我放到了任务队列中等待。

用JS伪代码来写就是这样

泡茶();

/*
* 要注意的是,`等到水温到达` 这个操作不是同步的,我只是调用这个方法告诉水壶25度提醒我去 `做柠檬茶`
* 然后我立即就可以去执行 `去B站看王冰冰` 了,我并不用在这里等它到25度
*/
等到水温到达(25,做柠檬茶);
去B站看王冰冰$_$();


function 做柠檬茶(){
    放柠檬();
    加蜂蜜();
    灌瓶();
    放到冰箱();
}

这是以前JS经典的异步处理方式,回调函数的方式。

让人失望的是,JS其实并没有异步处理的能力(之前是的),由于发明之初只是为了在Web上给页面添加简单的动效,所以它并不会做那个“等到水温到达多少度”的操作,这个等待操作的术语叫“阻塞”。

在操作系统领域,阻塞是一个系统级的操作,它的权限太高了,如果让网页直接使用,会出现好多安全性问题,没人想浏览个网页还提心吊胆吧。而JS作为一个执行引擎,它只会你给它一个任务,它执行一个任务,仅此而已。

那JS是怎么运行的

先想想,在JS中你何时会有“等待一段时间”的需求。

  1. DOM Listener,你经常会和JS定下这样的约定:等到用户点击了这个按钮就执行xxx函数
  2. setTimeout和setInterval延时执行(重复)任务
  3. Ajax等到向服务器发起的请求响应(或失败)后就执行xxx函数

除此之外没了。

如果JS完全靠自己的话,它是不能胜任上面三个需求的,JS所做的是把这个等待的操作移交给其它东西处理,这个东西就是WebAPIs(注意,这里说的是浏览器中的JS,其他的环境下的JS也有类似的东西)。

WebAPIs是浏览器提供的一系列功能,有时候你需要做一些底层操作,但JS没这个能力,WebAPIs就充当中间人,它提供了一些API去帮助JS去做底层操作。比如上面提到的三种“等待”需求,WebAPIs分别提供了DOM Bindingtimernetwork三个模块进行等待。

下图是JS和WebAPIs协作的一个示意图。

JS有一个堆栈,程序开始执行,主进程先进栈,这里把它看作main函数,它里面包括当前执行的JS文件中写的全部代码,就像其他编程语言一样,这些代码中的函数调用按顺序入栈出栈,与堆栈交互,并且它们可能会与WebAPIs定下一些约定,如等5000毫秒后帮我执行函数cb

例如上面图中的代码,主函数先入栈,然后是log进栈

log执行完毕出栈,setTimeout进栈

浏览器发现这个方法JS不能处理,需要WebAPIs处理,那就将其出栈并移交给WebAPIs,并告诉它你处理完这个就调用cb,然后主线程又继续去干剩下的活,所以下面的log进栈。同时WebAPIs执行等待5000毫秒的操作。

主进程中的函数全部执行完毕,log出栈main出栈。

过了一会儿后,WebAPIs也数完了5000毫秒,按照约定,它得调用cbWebAPIs不会调用JS函数,所以它得把这个cb放到任务队列的末尾。

每当调用栈空了的时候,JS执行引擎便会去轮询任务队列,看看还有啥能做的,这个操作称为事件循环,它每次从队首取出一个事件入栈并执行,这称作一个tick,哈哈像不像时钟周期。

事件循环开始执行下一个任务,也就是cb,log入栈

执行完毕,log出栈,cb出栈。程序结束。

渲染DOM的时机

注意,JS渲染DOM的过程永远在一个任务执行结束后

执行这段代码你只会看到最后一个值显示,是99999。

for(let i=0;i<100000;i++){
    element.innerHTML = i;
}

加个setTimeout可以看到变化的过程。

for(let i=0;i<100000;i++){
    setTimeout(function(){
        element.innerHTML = i;
    },10);
}

以前我可能会解释,因为代码运算太快了所以我们没看到效果,加一个延时就能看到了,但其实原因是上面执行的循环中根本没有刷新UI,因为都在一个任务里。

单线程?

如果你心思缜密,你可能看出来了,丫的JS,tm的是个单线程的玩意儿啊。

因为只有一个调用堆栈,并且还得等它把当前的事儿做完了才会进行事件循环,读取下一个任务。

确实,确实,它确实是单线程的,但是,浏览器不是啊,WebAPIs不是啊,你把一些繁重的,需要等待的,不能即刻完成的任务交给WebAPIs处理了啊,你还是该做啥做啥嘛。

不过限制倒也还是有的,就是因为任一时刻JS只可能在处理一个函数,所以如果有多个回调同时或先后相差很短到达,它们只能被顺序处理,所以说setTimeout之类的计时并不一定准确。

去执行下面这段代码吧,按照上面的理论,文字“一秒过去了”应该在2000次“LOOP”之后被打印,打印操作是个IO操作,所以2000次打印理论上来讲应该明显超过1秒了。在大部分浏览器中,你都能看到这个setTimeout超过一秒后才输出。

setTimeout(function(){
    console.log('一秒过去了');
},1000);
for(var i = 0; i < 2000; i++){
    console.log('LOOP');
}

有的浏览器为了效率,把console.log也设计成异步的了,所以也有可能出现这2000次循环不能阻塞setTimeout的尴尬场景,不过可以在NodeJS环境下运行,能够看到效果。

这有好处也有坏处,和多线程相比,它的效率可能低了些,但是它的粒度更粗,不用你去设计一些复杂的机制来保证共享数据的安全,因为每个操作都是原子的。

竞态 薛定谔的a

那么JS的异步编程中不存在竞态?不是,只是相比多线程而言安全性更高了。

只要两个函数异步的引用了同一个公共资源,就可能产生竞态。

var a = 10;
function foo(){
    a = a * 2;
}
function bar(){
    a = a + 2;
}

ajax(url,foo);
ajax(url,bar);

如上面的代码,ajax是一个用于发起网络请求的函数,第一个参数是请求的URL,第二个参数是回调函数。

观察两个ajax都执行完毕之后,a的值。

如果是第一个先执行完毕,那么最后a的值就是(10 * 2) + 2 = 22,如果是第二个先执行完毕,最后的值就是(10 + 2) * 2 = 24,a变成了薛定谔的a,只要我们没有在两个ajax都执行完毕后观测它,那么它的值就是不确定的......

但通常我们做网络请求时,这种竞态的影响对我们不大

var posts = [];

function readPosts(data){
    for(var item of data){
        posts.push(item)
    }
}

function readPost(data){
    posts.push(data)
}

// 读取多条推文
ajax('http://posts.xxx.com',readPosts);
// 读取一条推文
ajax('http://post.xxx.com/1024921',readPosts);

如上例,竞态条件只影响了posts列表中文章的顺序,通常这顺序错乱是无关紧要的,就算是必要的,我们也能通过一些文章对象中的字段进行重排序。

一些异步设计模式

非交互

消除竞态最简单的办法就是不让它们引用公共资源。。。。。。

var posts = {
    mutiple: [],
    single: []
};
function readPosts(data){
    for(var item of data){
        posts.mutiple.push(item)
    }
}

function readPost(data){
    posts.single.push(data)
}

判断来源

我们也可以用一个方法来接收它们,并判断来源:

function read(data){
    if(data.url == 'http://posts.xxx.com'){
        posts.mutiple.push(item)
    }else{
        posts.single.push(item)
    }
}

多线程里有一种设计模式叫栅栏模式,就是把所有异步任务所返回的资源看作一个人,现在有一个栅栏,只有当所有人都到齐,也就是所有异步任务都执行完毕了再打开栅栏。这本书里没有提到栅栏,说的是“加门”操作,我保留了原书的说法。

var a,b;
function foo(x){
    a = x * 2;
    baz();
}
function bar(y){
    b = y * 2;
    baz();
}

function baz(){
    console.log(a+b);
}

ajax(url1,foo);
ajax(url2,bar);

上面的代码,无论哪个异步操作先执行完成,都会过早地调用baz,这时就会出现ab其中一个是undefined,而只有后完成的异步任务调用的baz会完整的输出两数之和。

解决办法就是在foobar中添加验证代码,判断ab是否都已经定义

function foo(x){
    a = x * 2;
    if(a && b){
        baz();
    }
}
function bar(y){
    b = y * 2;
    if(a && b){
        baz();
    }
}

门闩

另一种经典的多线程模式就是门闩,只有第一名到达的取胜。注意,这个条件隐含了这个设计模式允许不确定性的存在,因为不一定哪个任务先完成。

var a;

function foo(x){
    a = x * 2;
    if(!a){
        baz();
    }
}
function bar(y){
    a = y * 2;
    if(!a){
        baz();
    }
}

function baz(){
    console.log(a);
}

ajax(url1,foo);
ajax(url2,bar);

让步

考虑有一个方法,这个方法里我们需要处理从服务器端返回的很多条数据,这个数据如果特别多的话,比如像几百万条的量,我们的页面会假死掉,所有js的功能都会被阻塞。因为JS始终只有一个任务在运行。

这时,我们可以每次只处理1000条,意思就是分一些机会给其他任务执行。


function handleData(data){
    // 只处理1000条
    var chunk = data.splice(0,1000);
    // 处理数据...省略

    // 还有剩下的要处理吗,有的话就异步处理
    if(data.length > 0){
        setTimeout(function(){handleData(data)},0)
    }
}

ajax(url,handleData);

回调不是一个优雅的解决办法

霍鹅,这书里用了单独的一章来说回调不好。

我这里就简单总结下。

在ES6以前,所有的异步调用都是通过回调来实现的,但是随着JS越来越强大,应用的领域不再仅限于Web端,所以回调的问题慢慢就都暴露出来了,必须寻求一个新的办法。

回调的问题大概如下

continuation

continuation的概念很生涩,很多人都没听过,我也是现查的。

说白了就是一种编码风格,就是把传统的参数-返回值形式的函数调用转变成“当我执行完操作下一步该干什么”的形式。

var a = add(1,2);
var b = square(a,2);

// Use Continuation
var b = add(1,2,square);

这正好就是JS中回调所用的形式嘛,看起来所有函数被串成一个链。

这个写法应用到异步任务上就会出现一些问题,因为这种高级的抽象方式可能不太符合实际任务执行的方式。

如果按照这种模式进行抽象,我们抽象出的很可能是:“早上醒来我要先开车去商店,然后买点牛奶,然后去一下干洗店”,伪代码是这样的:

function 醒来(){
    开车去商店(function(){
        买牛奶(function(){
            去干洗店();
        })
    })
}

嘶,看起来还可以哈,挺干净的,逻辑清晰。但是这是我们构想的情况,现实的情况中往往存在很多“噪声”。

我去商店,发现油箱里剩的油不多了,我只能先去附近最近的加油站加油,等我加完油,到了超市,我发现牛奶卖没了,牛奶没买到,我需要掉头去另一家超市,可是刚来这个城市,我不知道哪里还有超市了,我需要先去手机上检索......

修改我们的伪代码:

function 醒来(){
    开车去商店(function(){
        if(没油了){
            加油(function(){
                买牛奶(function(){
                    if(没有牛奶){
                        function(){
                            // ...... 
                        }
                    }else{
                        去干洗店()
                    }
                })
            })
        }else{
            买牛奶(function(){
                if(没有牛奶){
                    function()
                }
                去干洗店();
            })
        }
    })
}

这段代码对于现代人来说还为时过早,不是一般家庭能够承受的....

太丑了吧,这种写法被称为回调地狱,写到最后,我们已经没法分清哪里是干啥的了,只是在不停的麻木的缝缝补补,添加回调,完全面向运气编程。

如上我展示的只是一个并没有那么恶心的回调地狱的例子,现实中我们处理的异步任务多种多样,很可能比这还恶心。

信任问题

还有一个问题就是信任问题。

还拿上面的例子说,你能保证买牛奶这个方法调用你的回调函数的时机吗?如果是你自己写的,可能能,但是如果你在使用第三方的库,回调函数意味着你完全选择信任第三方库会在正确的时机调用你的回调函数。

想想,如果函数买牛奶调用回调的时机稍早,可能实际上你并没有买到牛奶,只是找到了牛奶的货架它就调用回调了,那么接下来的画面就是,你看到了牛奶的货架然后立马转头兴冲冲的跑出超市,一边跑一边手舞足蹈,有牛奶辣,有牛奶辣!然后你开车去了干洗店。实际上你根本还没拿到牛奶,你拿到牛奶的时机在你的回调中的函数链全部执行完毕之后,显然这时你已经不在超市了。

如果持悲观态度,那么你永远无法保证:

  1. 你的回调被调用的时机
  2. 回调被调用的次数
  3. 传入的参数是否正确
  4. 异常是否被吞没
  5. ...

所以这又给你带来了更多麻烦,你得在本来就很丑的回调地狱中写更多代码来确定操作已经正确完成。

异步还是同步

如果一个API,它有时使用异步,有时使用同步,那么就会造成混乱。

var a = 0;
ajax(url,function(){
    console.log(a);
});
a++;

假设这个ajax函数的设计者自作聪明,它缓存了每一个请求的结果,然后对于缓存中命中的url,它直接同步返回,而对于没命中的url异步去请求,想想这个输出的a是啥?

无法给出一个明确的结果,如果我们的回调被同步调用,那么会输出0,加入我们的回调被异步调用,那么它会在当前任务结束后才会被JS引擎执行,输出的a就是1。

这也是一个回调被提前调用的原因,这样的代码被戏称为Zalgo,一个恶魔,JS社区中经常会调侃这样的代码——不要放出Zalgo!!!

一些好的回调模式

分离回调

很多API提供两个回调函数,一个用于成功通知一个用于失败通知。它解决的问题是让回调中的错误处理更优雅。

ajax(url,function(){
    // 成功
},function(){
    // 失败
});

它显然没法解决回调地狱,只是你不用把成功和失败的逻辑挤在一块,如果第一个回调被调用了,那么此次调用被保证是成功的。第二个回调如果被调用了,它被保证是失败的,一个任务只会触发其中一个回调,不可能出现一个任务既成功又失败。而且如果不提供第二个回调,默认是吞没这个错误。

ES6中的Promise API就是使用的这种模式。

err-first

这种风格的回调函数第一个参数是异常,如果执行失败这个参数就是异常信息,如果执行成功它就会被置空。NodeJS完全使用了这种模式。

open(fileName,function(err,content){
    if(err) return;

    // do something...
})

讲真的,用过NodeJS,我觉得这种模式丑的一批,它让你必须去写一大堆if-else,即使该任务就算失败你也不在乎,那你也要为了成功的逻辑能够正确被执行去编写if-else

永远使用异步

在你自己写代码时,永远不要让Zalgo出现,要么完全同步回调,要么就完全异步回调,当你面临上面的ajax库的缓存需求时,你可以使用一个延时为0的setTimeout,确保回调不在当前任务中执行。

参考

posted @ 2021-07-23 14:42  yudoge  阅读(353)  评论(0编辑  收藏  举报