JavaScript学习笔记(四)单线程和异步

1. 异步

JavaScript只在一个线程上运行,如果所有的任务都是同步(synchronous)任务,当有一个中间环节的任务需要等待一段时间才能执行完成时(例如ajax请求、静态资源加载等),会造成页面卡死。因此,这类需要等待的任务要通过一定的方式放在异步(asynchronous)任务队列中,并设置回调函数来处理异步任务执行完成之后进行何种操作(回调函数在主线程执行)。

JavaScript引擎采用事件循环(Event Loop)机制来判断异步任务是否执行完毕以回到主线程继续执行其回调函数,只要同步任务执行完成,就去检查异步任务是不是执行完成,并不断重复这个过程直到异步队列清空。

在ES6之前,异步操作最常用的就是回调函数的形式。看下面这个简单的加载脚本的例子:

function loadScript(src) {
    let script = document.createElement('script')
    document.head.appendChild(script)
    script.src = src
}
function test() {
    console.log('test')
}

loadScript(/* url */)
test()

虽然是先调用了loadScript方法加载某个脚本,但并不会立刻执行脚本的加载而是将其放在异步任务队列,然后继续执行test方法,因此控制台会先打印出"test";此时同步任务执行完成,这是如果异步任务(即加载脚本)也执行完成了,再去执行脚本内容。

如果想先执行脚本内容,再执行test,就需要将后续执行的任务(也就是test方法)设置为脚本加载完成这个事件的回调函数:

function loadScript(src, callback) {
    let script = document.createElement('script')
    document.head.appendChild(script)
    script.src = src
    script.onload = function() {
        callback()
    }
}
function test() {
    console.log('test')
}

loadScript(/* url */, test)

异步任务队列中的脚本加载任务执行完成后,通过onload事件触发监听函数,监听函数回到主线程继续执行,并调用传入的test方法。这样整个流程就变为了我们所需要的先加载脚本后调用test方法。

这种方式虽然简单,但当执行多个异步任务时,需在回调函数中嵌套回调函数,使得函数调用语句的结构复杂,难以阅读和维护,也就是所谓的“回调地狱”。

2. Promise对象

ES6针对这一问题,将Promise纳入了标准。还是看上面加载脚本的例子,这次我们按顺序依次加载多个脚本:

function loadScript() {
    return new Promise((resolve, reject) => {
        let script = document.createElement('script')
        document.head.appendChild(script)
        script.onload = () => {
            resolve(src)
        }
        script.onerror = (error) => {
            reject(error)
        }
    })
}

loadScript('./1.js')
    .then(() => {
        return loadScript('./2.js')
    }, (error) => {
        console.log(error)
    })

Promise构造函数接收一个函数作为参数,这个函数的两个参数resolvereject也是两个函数(JavaScript提供),用来改变Promise实例的状态(status)和结果(result):

  • Promise实例刚被创建时,状态为挂起(pending),结果为undefined
  • 一旦调用了resolve方法,其状态就变为成功(fulfilled),其结果变为resolve方法的参数(比如上面例子中的src参数)
  • 一旦调用了reject方法,其状态就变为失败(rejected),其结果变为reject方法的参数(比如上面例子中的error参数,这个参数是script这个DOM元素的onerror事件的事件对象)

单看一个脚本的加载过程,和之前loadScript(/* url *, test/)的执行过程并没有什么差异,那为什么要在函数内部创建一个看起来比较复杂的Promise实例并将其作为返回值返回呢?

关键就在于还需要继续加载第二个脚本。由于函数返回的是一个Promise对象,因此就可以链式地使用Promise原型上的方法,比如上面例子中的then方法。then方法定义在Promise.prototype上,其作用是根据调用它的Promise对象(也就是loadScript函数调用之后返回的Promise对象)的状态执行不同的回调函数。

then方法接收两个函数作为参数:

  • 第一个参数为调用then方法的对象的状态为成功时的回调函数,并且会把对象的结果传入回调函数
  • 第二个参数为调用then方法的对象的状态为失败时的回调函数,同样也会把对象的结果(错误信息)传入回调函数进行相应的错误处理

如果还要继续调用then方法进行后续的异步任务操作,当前对象的then方法的第一个函数参数中还要返回一个Promise对象,比如上面例子中的return loadScript('./2.js')

需要注意的一点是,promise对象的改变是单向且唯一的,只能从挂起变为成功或失败,或者从成功变为成功或失败,而一旦状态变为了失败,其状态就被冻结,对then方法的调用就无效了。

这时新的问题又出现了,如果在每一步的then方法中都定义一个处理错误的函数会比较麻烦,那么有没有更简洁的处理方法呢?显然是有的:

loadScript('./1.js')
    .then(() => {
        return loadScript('./2.js')
    })
    .then(() => {
        return loadScript('./3.js')
    })
    .catch((error) => {
        console.log(error)
    })

Promise.prototype上还定义了一个catch方法用来捕获promise实例的状态变为失败时的错误信息。要注意的是,这个catch方法不是try ... catch语句的catch,因此它不能捕获throw抛出的错误。

通过Promise,可以极大的简化多个按顺序执行的异步任务的函数调用语句结构,并且链式调用的形式也很容易理清程序执行的流程,可读性和维护性都优于通过回调函数执行异步任务的形式。

3. 定时器

异步任务涉及两个常用的定时器setTimeoutsetInterval,作用是向异步任务队列添加定时任务。二者用法相似,区别就是前者只执行一次,而后者重复执行多次。

需要注意的是,由于是向异步任务队列添加任务,因此即便指定的延迟时间为0,也是在同步任务执行完成后再执行。这一特性常用来改变函数执行的流程。

3.1 setTimeout

setTimeout用来指定一段语句或函数在多少毫秒后执行,并返回一个整数值作为定时器的标识(用于清除定时器)。
setTimeout的一个重要的应用就是节流(throttle)和防抖(debounce)。

  • 节流:降低频繁触发的事件的监听函数中某个操作的频率。比如监听鼠标拖拽事件并获取元素的位置,当拖拽速度很快时,drag事件会频繁触发,如果每次都去获取元素的位置,很容易造成卡顿。优化的思路就是不论拖拽速度有多快,都以固定的时间间隔去获取元素位置信息
const div = document.getElementById('div')
let timer = null
div.addEventListener('drag', event => {
    if (timer) {
    // 如果定时器存在,说明上一次的定时任务尚未执行,直接返回
        return
    }
    // 如果定时器不存在,说明上一次的定时任务执行完成,需要设置下一次的定时任务
    timer = setTimeout(() => {
        console.log(event.offsetX, event.offsetY)
        // 当前定时任务被触发后,清除定时器
        clearTimeout(timer)
    }, 100)
})
// 这样就实现了无论拖拽速率多快,都是每隔100ms获取一次元素位置
  • 防抖:降低触发事件的频率,比如文本输入框输入内容时,监听keyup事件,当按键弹起式就会触发文本输入框的change事件,从而频繁地触发change事件的监听函数造成抖动,优化思路就是,在keyup事件触发之后,如果指定的延迟时间内又触发了keyup事件,则取消上一次的定时任务并重新设置定时任务;只有在keyup事件触发之后的指定时间延迟之内没有再次触发keyup事件时,定时任务才执行并触发其他事件以进行响应的操作。
const input = document.getElementById('input')
let timer = null
input.addEventListener('keyup', () => {
    if (timer) {
    // 如果timer有值,说明上一次的定时任务还没执行,这就代表在指定的时间延迟之内,用户又输入了文字
    // 这就需要清除上一次的定时任务
        clearTimeout(timer)
    }
    
    // 然后再为当前的keyup事件设置定时任务
    timer = setTimeout(() => {
        // 模拟触发change事件
        console.log(input.value)
        
        // 定时任务执行后清除定时器
        clearTimeout(timer)
    }, 500)
})
//这样就实现了不管输入文字的速率如何,都是以指定的间隔触发一次change事件

注意节流和防抖的区别:

  • 节流:每次事件触发,不一定总会添加定时任务,而是按照指定的时间间隔(频率)添加任务
  • 防抖:每次事件触发,都会添加新的定时任务,但只有在满足一定的条件(延迟之间)之后,才去执行定时任务;否则直接取消定时任务

3.2 setInterval

用法与setTimeout类似,即setInterval(func, delay),以指定的延迟时间delay重复执行func函数。

你可能感兴趣的:(JavaScript学习笔记(四)单线程和异步)