今天(指两天)学习 JS 异步编程
MDN 这里的教程不太行,翻译很机翻,一看最后修改时间 12 月 8 号。
Github主题风格实在是看不清粗体
1. 回调函数callback
MDN 教程说这是一个老派的异步方法
意思就是把 A 函数作为参数传入另一个 B 函数,这个A函数就是回调函数。
为什么叫 callback 呢,因为就像打电话,你打电话给A函数( call ),交给他B函数这个任务,让 A 在某个时间点去执行 B,当你看到 B 被执行了,你就知道“哦, B 函数被执行了”,这就像是 A 函数打电话回来( callback )给你一样。。
function hello() {
console.log("hello");
};
function world() {
console.log("world");
};
setTimeout(hello, 1000);
world();
// 先输出"world",后输出"hello"。
// 网上很多例子会把setTimeout放在另一个函数里,这样没什么问题,但是第一次看容易搞混,容易搞错哪个是回调函数。
这里也有个难以理解的点,这次翻《js高级程序设计》都没看懂,就是 callback 和异步有什么关系,答案是没关系,不管是《JS高级程序设计》还是 MDN,里面的例子中真正和异步有关系的是那个 setTimeout 和 xhr.onload ,你把setTimeout(B, 1000)换成一个function() {sleep(1000); B();} 的函数,后者是没有异步效果的。
也就是说,如果 A 函数不是异步的,那么回调函数 B 其实没什么用。如果 A 函数是异步的,那么你需要 B 函数来告诉你什么时候 A 完成了。
至于回调函数,真的就只是一个概念而已,一个作为参数的函数。
MDN 教程说回调函数是一个异步方法这个说法有问题,只是这个异步方法中会用到回调函数罢了。
《JS高级程序设计》就聪明点,没说 callback 是异步,而是说早期的异步需要 callback 函数表明完成,但是这句话在我理解异步和 callback 没有关系前是完全get不到它想表示的点的。
2. setTimeout & setInterval
MDN 教程开始教这两个函数,并用setInterval写了个秒表。
2.1. setTimeout 递归 和 setInterval 的区别
- setTimeout 的代码块不算入时间,先执行代码块,再开始计算时间。比如说设置间隔 100ms,代码运行 40ms,那么每两次运行之间相隔 140ms。
- setInterval 的代码块也算入时间。比如设置间隔 100ms,代码运行 40ms,那么每两次调用之间还是相隔 100ms。(注意是调用不是运行,如果这个代码块要运行 1000ms,那么第一个函数运行完后,任务队列里就会堆着 10 个这个函数等着被处理)
2.2 举例详细说明区别
举个例子:
setInterval(loop,1000);
function loop(){
alert("1");
}
①先过几秒,故意不点 alert 的确定按钮,再点的时候会发现下一个 alert 立马出来了,因为在你等待的时候,setInterval 不管你前一个函数运行完没有,照旧调用下一个函数。然后呢,因为你 alert 没运行完,这个函数就会放在调用栈后面,等你把 alert 运行完,立刻调用这个函数。
(注:是函数被放在调用栈后面,不是 alert 被放在调用栈后面,如果这个 loop 函数的 alert 之前还有一小段要执行 1 秒,那么即使你故意等了100年不点,当你点完第一个 alert 后,你会需要等 1 秒才会看到下一个 alert。 只是此时调用栈里堆满了无数个函数,永远都点不完,即使你取消了 setInterval 也没用,那些函数已经在队列里了)
setInterval(loop,1000);
function loop(){
A(); //假设这个函数要花 1 秒执行完
alert("1");
}
②但是如果使用setTimeout的话
function loop(){
alert("1");
setTimeout(loop,1000);
}
loop();
这次如果你第一个 alert 出来后光速点确定,你要等 1 秒。
即使你第一个alert出来后等 100 年后点确定,你还是要等 1 秒。并且调用栈里一个函数都没有。
也就是说,setTimeout 递归要等代码块运行完,才会设置下一个 setTimeout,当然这个看代码逻辑就能知道,只是如果不专门说出来,我就不会注意到。
而 setInterval 无论你代码块要运行多久,都是无情的调用机器,反正到时间往任务队列里 callback 加就完事了。
3. 因此当你的代码块运行时间比较长超过了 setInverval 设置的时间,你又不想让调用栈里堆满了这个函数造成什么错误的时候,就选 setTimeout。
4. clearTimeout 和 clearInterval 是可以互相用的,比如clearTimeout(setInterval(A, 100)),但是为了不让人误解,还是用对应的比较好。
(注:以上皆指是 setTimeout 递归由于代码逻辑造成的行为,单独一个 setTimeout 的话,是不会等主线程之后的,比如说
setTimeout(()=>{console.log("1")}, 1000)
for (let i = 0; i < 5000; i++){
console.log("1");
}
//下面的打印要好几秒,但是打印完5000个1后,2会立刻出现
//因为这不是递归,setTimeout 会在1秒后把()=>{console.log("1")}插入任务队列
//等到这好几秒主线程完成后,调用任务队列立刻打印2
//假如我们主线程只花400ms,那么会在600ms后打印2
//也就是说,不递归的 setTimeout 和 setInterval 的区别就真的只是一个运行一次,一个运行多次的区别。
setTimeout函数本身也是无情的调用机器,只要到达时间,就会把 callback 推进任务队列,至于任务队列何时被推入执行栈那就是 JS 的问题了。至于原因,是因为 setTimeout 和 setInterval 是属于 WebAPI,和 JS 是独立的,等到了时间,就把 setXXX 函数里的 callback 函数推入到宏任务队列里。 也就是说 setXXX 本身并不进入队列,进入队列的是 callback。 )
3. 用 requestAnimationFrame 绘制动画
3.1. 以前的技巧
- 以前播放动画是由setInterval或者setTimeout做的,但是由于刚才讲的原因,它们并不适合按帧数播放动画。
- 对于setInterval来说,首先为了让动画播放流畅,时间必须设置的比较短;但是同时又不能太短,以免代码运行时间超过间隔时间引起错误;而且如果切换到后台,浏览器不渲染了,但是 setInterval 仍然会无情的执行绘制,一下子积攒一大堆绘制。setTimeout 就更不用说了,每次运行完才会调用下一个函数,更加不稳定
- 不仅如此,如果了解事件队列,就知道如果此时用户进行别的操作,就会占用主线程,那么函数的运行又会产生影响。
- 以上问题会造成什么效果呢,由于js 代码运行和屏幕刷新与否是无关的,因此屏幕刷新的时候你并不知道js 代码运行了几次相关函数,可能这一帧恰好在函数运行到99%的时候刷新了,没看到运行结果,下次一下运行两次
3.2. requestAnimationFrame 的出现
- 而 requestAnimationFrame 就是为了解决这个问题。
- 这个方法是知道屏幕刷新率的,会在屏幕刷新的时候调用绘制函数,而不是根据设定的间隔,因此保证每次刷新的时候最多运行一次。并让帧率尽量贴近屏幕刷新率(一般是60hz)
3.3. requestAnimationFrame的使用
- 除了最基础的应用外,requestAnimationFrame 的参数函数还包括另一个参数,是这么用的
// 这个timestamp参数是自动传入的,不用手动传入
function loop(timeStamp) {
if (!startTime){
let startTime = timeStamp;
}
currentTime = timeStamp - startTime;
//接下来可以根据 currentTime 做一些处理了,表示的是当前离动画开始的时间,单位为 ms
//比如说第几秒要画个什么之类的
requestAnimationFrame(loop);
}
做了一个旋转动画,用 currentTime%360 当做旋转的角度。(当然简单的动画最好用 CSS 动画)
4. Promise
4.1 Promise里函数的简写用法
这和promise没关系,但是很有用。
function(a) {b = a + 1; return myFunction(b);} // 一般的匿名函数
a => {b = a + 1; return myFunction(b);} // 一般的箭头函数
a => myFunction(a + 1); // 和上一句一样,但是省略了return,省略了中括号
4.2 Promise 和事件监听器的区别
许多函数可以产生promise对象,产生的对象后加.then(),then里的回调会在promise对象成功后调用。这有点像事件监听器,除了① promise 只会被调用一次(最显著的差别)② promise 的事件即使比较早发生了,但是此时没有添加.then,之后才添加回调,这个时候回调仍然有用,仍然会被调用。
4.3 Promise的使用
以 fetch 为例
- fetch("url")产生一个 promise 对象,这个对象代表了一个薛定谔的状态,当成功或者失败后,调用
.then里的回调函数。 .then调用完后返回的依然是一个 promise 对象,依旧可以接下一个.then或者.catch,或者用一个变量储存之后再用。
4.4 Promise 的状态
- 刚创建的时候,这个薛定谔的状态叫做 pending。
- 确定的状态叫做 resolved,resolved 又分为 fullfilled 和 rejected。
(注:fetch 本身比较特殊,基本上不会拒绝即使地址名写错了拿不到想要的资源,如果是本文之后 4.7 讲的自定义 promise 的reject() 是会报错可以被 catch)
(注:如果是 rejected,会报错,会被 catch 抓住,上文的 fetch 不会报错指的不是 rejected 不会报错,而是指不会变成 rejected 状态)
4.5 多个 Promise一起
- 使用静态方法 Promise.all(),参数为一个 Promise 数组,只有当数组里所有的 Promise 都成功,才会成功(返回一个数组包含所有成功函数的返回值)。如果其中有任何一个拒绝了,那整个也会拒绝。(只是拒绝,并不表示不会 resolved)
Promise([a,b,c]).then(values => xxx);
// 其中 values 是[a的result, b的result, c的result]
4.6 finally
在 promise 链最后加上.finally(),会在 promise 链结束后运行,无论是否实现或者拒绝。
4.7 自定义 promise
即new Promise(),参数为匿名函数。
那么我们需要手动定义实现和拒绝,通过 resolve() 和 reject() 实现(都在匿名函数里,都要写,且顺序不能错)。
(注:Promise 自带异步,当你 new 了一个 promise 对象,那这就是个异步进程,会被添加到任务队列而不是主线程,不管里面有没有异步函数(setTimeout等))
//关于参数的问题,如果你只写一个reject,那么这个reject实际上是resolve,必须写两个参数,才能用 reject
let myPromise = new Promise(
//只用resolve,只传一个参数没问题
function (resolve) {
setTimeout(function () { resolve("success"); }, 1000);
//不写 setTimeout ,这依旧是异步,因为 promise 就是异步
}
)
myPromise.then((message) => alert(message)) // alert("success")
let myPromise = new Promise(
//要用reject的话,要传两个参数,用第二个
function (resolve, reject) {
setTimeout(function () { reject("fail"); }, 1000);
}
)
myPromise.then((message) => alert(message)).catch(err => console.log(err)) // 报错,通过 catch 抓取