什么是异步?
“异步”,从字面来讲,好像是在不同的(异)的ways上do something,那首先想到的词可能是“一边…一边…”,比如‘小明一边吃雪糕一边写作业’,这完全没毛病,雪糕吃完了,作业也写完了,这就是异步?那就大错特错了!
其实同步和异步,无论如何,做事情的时候都是只有一条流水线(单线程),
同步和异步的差别就在于这条流水线上各个流程的执行顺序不同。
最基础的异步是setTimeout和setInterval函数,很常见,但是很少人有人知道其实这就是异步,因为它们可以控制js的执行顺序。我们也可以简单地理解为:可以改变程序正常执行顺序的操作就可以看成是异步操作。
异步产生根源
根源----“Javascript是单线程” 即一次只能做一件事。
单线程设计原因:js渲染在浏览器上,包含了许多与用户的交互,如果是多线程,那么试想一个场景:一个线程在某个DOM上添加内容,而另一个线程删除这个DOM,那么浏览器要如何反应呢?这就乱套了。
单线程下所有的任务都是需要排队的,而这些任务分为两种:
-
同步任务 -- 在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务
-
异步任务 -- 不进入
主线程
、而进入任务队列
(task queue)的任务,只有任务队
列通知主线程
,某个异步任务可以执行了,该任务才会进入主线程
执行。
综上同步执行其实也是一种只有主线程的异步执行。一个视频关于异步操作是如何被执行的,讲得非常好《what the hack is event loop》。
不同的异步操作添加到任务队列的时机
不同,如 onclick, setTimeout, ajax 处理的方式都不同。这些异步操作是由浏览器内核的 webcore 来执行的。
webcore 包含上面提到的3种 webAPI:
-
DOM Binding、
-
timer、
-
network
onclick 由浏览器内核的 DOM Binding 模块来处理,当事件触发的时候,回调函数会立即添加到任务队列中。
setTimeout 会由浏览器内核的 timer 模块来进行延时处理,当时间到达的时候,才会将回调函数添加到任务队列中。
ajax 则会由浏览器内核的 network 模块来处理,在网络请求完成返回之后,才将回调添加到任务队列中。
JavaScript 异步进化史
callback --> Promise --> Generator函数 --> async/await
最基础的异步调用方式是callback,它将回调函数 callback 传给异步 API,由浏览器或 Node 在异步完成后,通知 JS 引擎调用 callback。对于简单的异步操作,用 callback 实现,是够用的。但随着负责交互页面和 Node 出现,callback 方案的弊端开始浮现出来。 Promise 规范孕育而生,并被纳入 ES6 的规范中。后来 ES7 又在 Promise 的基础上将 async 函数纳入标准。
为什么要用异步操作
JS 是单线程的语言,所谓“单线程”就是一根筋,对于拿到的程序,一行一行的执行,上面的执行为完成,就傻傻的等着。例如
var i, t = Date.now()
for (i = 0; i < 100000000; i++) {
}
console.log(Date.now() - t) // 250 (chrome浏览器)
上面的程序花费 250ms 的时间执行完成,执行过程中就会有卡顿,其他的事儿就先撂一边不管了。
执行程序这样没有问题,但是对于 JS 最初使用的环境 ———— 浏览器客户端 ———— 就不一样了。因此在浏览器端运行的 js ,可能会有大量的网络请求,而一个网络资源啥时候返回,这个时间是不可预估的。这种情况也要傻傻的等着、卡顿着、啥都不做吗?———— 那肯定不行。
因此,JS 对于这种场景就设计了异步 ———— 即,发起一个网络请求,就先不管这边了,先干其他事儿,网络请求啥时候返回结果,到时候再说。这样就能保证一个网页的流程运行。
异步的实现原理
先看一段比较常见的代码
var ajax = $.ajax({
url: '/data/data1.json',
success: function () {
console.log('success')
}
})
上面代码中$.ajax()需要传入两个参数进去,
-
url — 请求的路由
-
success – 是一个函数。这个函数传递过去不会立即执行,而是等着请求成功之后才能执行。
success对于这种传递过去不执行,等出来结果之后再执行的函数,叫做callback,即回调函数。
再看一段更加能说明回调函数的 nodejs 代码。和上面代码基本一样,唯一区别就是:上面代码时网络请求,而下面代码时 IO 操作。
var fs = require('fs')
fs.readFile('data1.json', (err, data) => {
console.log(data.toString())
})
从上面两个 demo 看来, 实现异步的最核心原理,就是将callback作为参数传递给异步执行函数,当有结果返回之后再触发 callback执行
,就是如此简单!
常用的异步操作
开发中比较常用的异步操作有:
-
网络请求,如ajax http.get
-
IO 操作,如readFile readdir
-
定时函数,如setTimeout setInterval
如何实现异步
可以通过
-
回调函数、
-
事件监听
-
发布/订阅
-
Promise、
-
Generator、
-
Async/Await等来实现异步。
1. 回调函数(CallBack)
这是异步编程最基本的方法。 所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。它的英语名字 callback,直译过来就是”重新调用”。 读取文件进行处理,是这样写的。
fs.readFile('/etc/passwd', function (err, data) {
if (err) throw err;
console.log(data);
});
上面代码中,readFile函数的第二个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了 /etc/passwd 这个文件以后,回调函数才会执行。
回调本身是比较好用的,但是随着Javascript越来越成熟,对于异步编程领域的发展,回调已经不够用了,体现在以下几点:
-
大脑处理程序是顺序的,对于复杂的回调函数会不易理解,我们需要一种更同步、更顺序的方式来表达异步。
-
回调一般会把控制权交给第三方,从而带来信任问题
-
调用回调过早
-
调用回调过晚(或未调用)
-
调用回调次数过多或过少
-
未能传递所需的环境和参数
-
吞掉可能出现的错误和异常
-
回调地狱
假设我们有一个 getData 方法,用于异步获取数据,第一个参数为请求的 url 地址,第二个参数是回调函数,如下:
function getData (url, callBack) {
// 模拟发送网络请求
setTimeout(() => {
// 假设 res 就是返回的数据
var res = {
url: url,
data: Math.random()
}
// 执行回调,将数据作为参数传递
callBack(res)
}, 1000)
}
我们预先设定一个场景,假设我们要请求三次服务器,每一次的请求依赖上一次请求的结果,如下:
\[object Object\]
``通过上面的代码可以看出,第一次请求的 url 地址为:`/page/1?param=123`,
返回结果为 `res1`。
第二个请求的 url 地址为:`/page/2?param=${res1.data}`,
依赖第一次请求的`res1.data`,返回结果为`res2`。
第三次请求的 url地址为:`/page/3?param=${res2.data}`,
依赖第二次请求的 `res2.data`,返回结果为 `res3`。
由于后续请求依赖前一个请求的结果,所以我们只能把下一次请求写到上一次请求的回调
函数内部,这样就形成了常说的:回调地狱。``
2. 事件监听
在DOM监听中比较常见。
3. 发布/订阅
也就是常说的观察者模式。
观察者模式是软件设计模式的一种。在此种模式中,一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。这通常透过呼叫各观察者所提供的方法来实现。此种模式通常被用来实时事件处理系统。
一些前端MVVM框架就是用的观察者模式实现是双向绑定
发布/订阅模式(Pub/Sub)是一种消息模式,它有 两个参与者 : 发布者和订阅者 。发布者向 某个信道发布一条消息,订阅者绑定这个信道,当有消息发布至信道时就会 接收到一个通知。最重要的一点是, 发布者和订阅者是完全解耦的,彼此并不知晓对方 的存在。两者仅仅共享一个信道名称。
理解起来很简单: 我去书报亭订了一份报纸,当他把报纸送给我了,我就去领了看.
这里,我就变成了 订阅者 ,报亭就是 发布者 ,当报纸送到的时候(状态发生改变,通知订阅者),我就去领了看(做一些操作)
一个发布者应该有三个主要的方法:
-
订阅
-
发布
-
退订
example
订阅:
var PubSub = {};
var eventObj = {};
PubSub.subscribe = function(event, fn) {
eventObj\[event\] = fn;
}
发布:
PubSub.publish = function(event) {
if (eventObj\[event\]) {
eventObj\[event\]();
}
}
退订:
PubSub.off = function(event, fn) {
if (eventObj\[event\]) {
eventObj\[event\] = null;
}
}
我们来整理一下代码用闭包隐藏eventObj这个对象:
var PubSub = (function() {
var eventObj = {};
return {
subscribe: function(event, fn) {
eventObj\[event\] = fn;
},
publish: function(event) {
if (eventObj\[event\]) eventObj\[event\]();
},
off: function(event) {
if (eventObj\[event\]) delete eventObj\[event\];
}
}
}());
执行:
PubSub.subscribe('event', function() {
console.log('event release');
});
PubSub.publish('event'); // 'event release'
OK it work!!
这绝对是最简单无脑的观察者模式的实现了,你以为这就完了吗?
这样…这个一个事件只能绑定一个操作,并且取消订阅把整个事件都删除掉了,这样就不是很好了,我们应该写一个支持一个事件绑定多个操作的,并且退订时是退订一个事件上的一个操作,而不是删除整个事件
再来:
一个事件绑定多个操作,我们应该用一个数组把操作保存起来,发布时按订阅顺序执行,退订时删除对应的数组元素就好.
var PubSub = (function() {
var queue = {};
var subscribe = function(event, fn) {
if (!queue\[event\]) queue\[event\] = \[\];
queue\[event\].push(fn);
}
var publish = function(event) {
var eventQueue = queue\[event\],
len = eventQueue.length;
if (eventQueue) {
eventQueue.forEach(function(item, index) {
item();
});
}
}
var off = function(event, fn) {
var eventQueue = queue\[event\];
if (eventQueue) {
queue\[event\] = eventQueue.filter(function(item) {
return item !== fn;
});
}
}
return {
subscribe: on,
publish: emit,
off: off
}
}());
以上就是一个简单的观察者模式的实现了.
example:
function first() {
console.log('event a publish first');
}
PubSub.subscribe('a', first);
PubSub.subscribe('a', function() {
console.log('event a publish second');
});
PubSub.publish('a'); // event a emit first, event a emit second
PubSub.off('a', first);
PubSub.publish('a'); //event a emit second
4. Promise
Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大
所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。
-
首先 Promise 和异步接口签订一个协议,
-
成功时,调用resolve函数通知 Promise,
-
异常时,调用reject通知 Promise。
-
另一方面 Promise 和 callback 也签订一个协议,由 Promise 在将来返回可信任的值给then和catch中注册的 callback。
// 创建一个 Promise 实例(异步接口和 Promise 签订协议)
var promise = new Promise(function (resolve,reject) {
ajax('url',resolve,reject);
});
// 调用实例的 then catch 方法 (成功回调、异常回调与 Promise 签订协议)
promise.then(function(value) {
// success
}).catch(function (error) {
// error
})
Promise 是个非常不错的中介,它只返回可信的信息给 callback。它对第三方异步库的结果进行了一些加工,保证了 callback 一定会被异步调用,且只会被调用一次。
var promise1 = new Promise(function (resolve) {
// 可能由于某些原因导致同步调用
resolve('B');
});
// promise依旧会异步执行
promise1.then(function(value){
console.log(value)
});
console.log('A');
// A B (先 A 后 B)
var promise2 = new Promise(function (resolve) {
// 成功回调被通知了2次
setTimeout(function(){
resolve();
},0)
});
// promise只会调用一次
promise2.then(function(){
console.log('A')
});
// A (只有一个)
var promise3 = new Promise(function (resolve,reject) {
// 成功回调先被通知,又通知了失败回调
setTimeout(function(){
resolve();
reject();
},0)
});
// promise只会调用成功回调
promise3.then(function(){
console.log('A')
}).catch(function(){
console.log('B')
});
// A(只有A)
介绍完 Promise 的特性后,来看看它如何利用链式调用,解决异步代码可读性的问题的。
var fetch = function(url){
// 返回一个新的 Promise 实例
return new Promise(function (resolve,reject) {
ajax(url,resolve,reject);
});
}
A();
fetch('url1').then(function(){
B();
// 返回一个新的 Promise 实例
return fetch('url2');
}).catch(function(){
// 异常的时候也可以返回一个新的 Promise 实例
return fetch('url2');
// 使用链式写法调用这个新的 Promise 实例的 then 方法
}).then(function() {
C();
// 继续返回一个新的 Promise 实例...
})
// A B C ...
function getDataAsync (url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
var res = {
url: url,
data: Math.random()
}
resolve(res)
}, 1000)
})
}
getDataAsync('/page/1?param=123')
.then(res1 => {
console.log(res1)
return getDataAsync(`/page/2?param=${res1.data}`)
})
.then(res2 => {
console.log(res2)
return getDataAsync(`/page/3?param=${res2.data}`)
})
.then(res3 => {
console.log(res3)
})
如此反复,不断返回一个 Promise 对象,再采用链式调用的方式不断地调用。使 Promise 摆脱了 callback 层层嵌套的问题和异步代码“非线性”执行的问题。
then 方法返回一个新的 Promise 对象,then 方法的链式调用避免了 CallBack 回调地狱。
但也并不是完美,比如我们要添加很多 then 语句, 每一个 then 还是要写一个回调。
如果场景再复杂一点,比如后边的每一个请求依赖前面所有请求的结果,而不仅仅依赖上一次请求的结果,那会更复杂。
Promise 的最大问题是代码冗余,原来的任务被Promise 包装了一下,不管什么操作,一眼看去都是一堆 then,原来的语义变得很不清楚。
5. Generator的方式
ECMAScript 6 (简称 ES6 )作为下一代 JavaScript 语言,将 JavaScript 异步编程带入了一个全新的阶段。关于异步编程可以查看下图:
而下面这种连续的执行过程叫做同步的。Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。
除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:
-
函数体内外的数据交换
-
错误处理机制。
next 方法返回值的 value 属性,是 Generator 函数向外输出数据;
next 方法还可以接受参数,这是向 Generator 函数体内输入数据。
如下例:
- 特性1:暂停执行与恢复执行
function* gen(x){
var y = yield x + 2;
return y;
}
也就是通过yield来暂停执行,通过next来恢复执行
- 特性2:函数体内外的数据交换
function* gen(x){
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }
通过调用next方法获取到的value代表函数体向外输出的数据,而调用next方法传入的参数本身代表向Generator传入数据。
- 特性3:错误处理机制
function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}
var g = gen(1);
g.next();
g.throw('出错了');
// 出错了
上面代码的最后一行,Generator 函数体外,使用指针对象的 throw 方法抛出的错误,可以被函数体内的 try … catch 代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。
下面是Generator处理实际任务的一个例子:
var fetch = require('node-fetch');
function* gen(){
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}
具体的执行过程如下:
var g = gen();
var result = g.next();
result.value.then(function(data){
return data.json();
}).then(function(data){
g.next(data);
});
thunk函数
编译器的”传名调用”实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。
function f(m){
return m * 2;
}
f(x + 5);
// 等同于
var thunk = function () {
return x + 5;
};
function f(thunk){
return thunk() * 2;
}
上面代码中,函数 f 的参数 x + 5 被一个函数替换了
。凡是用到原参数的地方,对 Thunk 函数求值即可。这就是 Thunk 函数的定义,它是”传名调用”的一种实现策略,用来替换某个表达式。
JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数
。
thunk函数的实现机制还是通过闭包来完成的。其调用分为三步,
-
首先是传入一个函数,
-
接着是传入该函数的所有除了callback以外的参数,
-
最后是传入回调函数callback。
function thunkify(fn){ //第一步:传入函数 return function(){ //第二步:传入除了callback以外的参数 var args = new Array(arguments.length); var ctx = this; for(var i = 0; i < args.length; ++i) { args[i] = arguments[i]; } return function(done){ //第三步:传入回调函数 var called; args.push(function(){ if (called) return; //回调函数只会运行一次 called = true; done.apply(null, arguments); }); try { fn.apply(ctx, args); } catch (err) { done(err); } } } };
thunk与Generator强强联手将程序执行权交还给Generator函数
-
Generator的yield返回的必须是thunkify的函数才能递归
Thunk 函数有什么用?回答是以前确实没什么用,但是 ES6 有了 Generator 函数,Thunk 函数现在可以用于 Generator 函数的自动流程管理。以读取文件为例。下面的 Generator 函数封装了两个异步操作。
var fs = require('fs');
var thunkify = require('thunkify');
var readFile = thunkify(fs.readFile);
var gen = function* (){
var r1 = yield readFile('/etc/fstab');
//1.交出执行权
console.log(r1.toString());
var r2 = yield readFile('/etc/shells');
//1.交出执行权
console.log(r2.toString());
};
上面代码中,yield 命令用于将程序的执行权移出 Generator 函数,那么就需要一种方法,将执行权再交还给 Generator 函数。这种方法就是 Thunk 函数,因为它可以在回调函数里,将执行权交还给 Generator 函数。为了便于理解,我们先看如何手动执行上面这个 Generator 函数。
* * *
var g = gen();
var r1 = g.next();
//2.查看这里的程序你可以清楚的看到,这里是将同一个回调函数反复的传入到
g.next返回的value中。
但是这个返回的value必须是thunkify过后的函数,这样它只会接
受一个参数,那么就满足这里的定义了
r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
//2.value必须是thunkify的函数才会只接受一个callback参数
if (err) throw err;
g.next(data);
});
});
* * *
上面代码中,变量 g 是 Generator 函数的内部指针,表示目前执行到哪一步。next 方法负责将指针移动到下一步,并返回该步的信息(value 属性和 done 属性)。
仔细查看上面的代码,可以发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入 next 方法的 value 属性。这使得我们可以用`递归`来自动完成这个过程。
使用thunkify来自动执行Generator函数从而将执行权交还给Generator
function run(fn) {
var gen = fn();
//获取到generator内部指针,这里的next就是thunk函数的回调函数
function next(err, data) {
var result = gen.next(data);
//获取generator内部状态
if (result.done) return;
result.value(next);
//Gnerator的value必须是thunkify函数,此时才会只接受一个回调函数
}
next();
}
run(gen);
下面是一个读取多个文件的例子:
var gen = function* (){
var f1 = yield readFile('fileA');
var f2 = yield readFile('fileB');
// ...
var fn = yield readFile('fileN');
};
run(gen);
上面代码中,函数 gen 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。
Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。
6. async/await对于异步的终极解决方案
async 函数就是 Generator 函数的语法糖。
前文有一个 Generator 函数,依次读取两个文件。
var fs = require('fs');
var readFile = function (fileName){
return new Promise(function (resolve, reject){
fs.readFile(fileName, function(error, data){
if (error) reject(error);
resolve(data);
});
});
};
var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
写成 async 函数,就是下面这样。
var asyncReadFile = async function (){
var f1 = await readFile('/etc/fstab');
var f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
一比较就会发现,async 函数就是将 Generator 函数的星号( * )替换成 async,将 yield 替换成 await,仅此而已。
async函数的优点
- 内置执行器。 Generator 函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行。
var result = asyncReadFile();
-
更好的语义。 async 和 await,比起星号和 yield,语义更清楚了。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。
-
更广的适用性。 co 函数库约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
async的用法
同 Generator 函数一样,async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数
。当函数执行的时候,一旦遇到 await 就会先返回,等到触发的异步操作完成,再接着执行函数体内后面的语句。
下面的例子,指定多少毫秒后输出一个值。
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function asyncPrint(value, ms) {
await timeout(ms);
//遇到await了,所有先返回,得到异步操作完成,执行后面的代码
console.log(value)
}
asyncPrint('hello world', 50);
上面代码指定50毫秒以后,输出”hello world”。
async自动执行器的实现
//genF是Generator函数
function spawn(genF) {
//返回promise和co一样,但是co只能是promise和thunk函数
return new Promise(function(resolve, reject) {
var gen = genF();
//得到Generator内部指针
function step(nextF) {
try {
var next = nextF();
//next获取到第一个await返回的结果
} catch(e) {
return reject(e);
}
if(next.done) {
return resolve(next.value);
}
//如果done为true那么我们直接resolve
Promise.resolve(next.value).then(function(v) {
//第一个await返回的对象的value表示结果{value:'',done:false}
step(function() { return gen.next(v); });
//调用gen.next()获取到下一个await的结果并传入上一次的await调
用后得到的value
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() { return gen.next(undefined); });
//首次执行的时候传入第一个await的data为undefined
});
}
async注意点
- await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try…catch 代码块中。
async function myFunction() {
try {
await somethingThatReturnsAPromise();
} catch (err) {
console.log(err);
}
}
// 另一种写法
async function myFunction() {
await somethingThatReturnsAPromise().catch(function (err){
console.log(err);
});
}
- await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。
async function dbFuc(db) {
let docs = \[{}, {}, {}\];
// 报错
docs.forEach(function (doc) {
await db.post(doc);
});
}
上面代码会报错,因为 await 用在普通函数之中了。但是,如果将 forEach 方法的参数改成 async 函数,也有问题。
async function dbFuc(db) {
let docs = \[{}, {}, {}\];
// 可能得到错误结果
docs.forEach(async function (doc) {
await db.post(doc);
});
}
上面代码可能不会正常工作,原因是这时三个 db.post 操作将是并发执行,也就是同时执行,而不是继发执行。正确的写法是采用 for 循环。
async function dbFuc(db) {
let docs = \[{}, {}, {}\];
for (let doc of docs) {
await db.post(doc);
}
}
参考资料
-
js异步处理(一)——理解异步
-
JavaScript 异步进化史
-
深入理解 JavaScript 异步系列(1)——基础
-
JS异步处理方案总结
-
JavaScript实现的发布/订阅(Pub/Sub)模式
-
async 函数的含义和用法
-
js中的同步和异步的个人理解