网站

JavaScript 异步编程

回调函数 - JavaScript | MDN

并发模型与事件循环 | MDN

引入

JS 代码默认是一行一行执行的,前一行没执行完,后一行就得等着。

但有些操作比较耗时,比如请求网络数据、读取文件、等待用户点击。如果这些操作也让代码干等着,整个页面就会卡住。

为了解决这个问题,JS 引入了异步编程的概念:耗时操作可以在后台执行,不阻塞后续代码。

正文

同步

定义

同步是指代码按照书写顺序,一行一行地执行,每一行都必须等上一行执行完才能开始。

console.log("第一步");
console.log("第二步");
console.log("第三步");

输出结果:

第一步
第二步
第三步

三行代码依次执行,每行都等上一行完成后才开始。

再看一个例子:

let a = 1;
let b = 2;
let c = a + b;
console.log(c);

c 的值必须等 a + b 算完之后才能得到。这就是同步。

同步的特点:简单直观,代码从上往下按顺序走,容易理解和调试。

异步

定义

异步是指某些操作不会阻塞后续代码的执行,而是在后台运行,完成后再回来通知结果。

console.log("开始");
 
setTimeout(function () {
  console.log("定时器执行");
}, 2000);
 
console.log("结束");

输出结果:

开始
结束
定时器执行

执行流程:

  1. 打印 "开始"
  2. 设置一个 2 秒后执行的定时器,不会在这里等待
  3. 继续往下走,打印 "结束"
  4. 2 秒后,定时器回调函数执行,打印 "定时器执行"

异步的特点:耗时操作不会卡住后面的代码。

同步和异步的对比

对比同步异步
执行顺序严格按顺序不阻塞后续代码
是否等待等上一行完成后台执行,完成后回调
代码理解简单直观需要注意执行顺序
适用场景简单计算网络请求、定时器、用户事件

回调函数

定义

回调函数是作为参数传给另一个函数的函数,在特定时机被调用。

function doTask(callback) {
  console.log("正在执行任务...");
  callback();
}
 
doTask(function () {
  console.log("任务完成");
});

输出:

正在执行任务...
任务完成

doTask() 接收一个函数作为参数,任务做完后调用这个函数。这个被传入的函数就是回调函数。

回调函数的常见场景

定时器回调:

setTimeout(function () {
  console.log("2 秒后执行");
}, 2000);

事件回调:

const btn = document.querySelector("#btn");
 
btn.addEventListener("click", function () {
  console.log("按钮被点击了");
});

网络请求回调:

fetch("/api/data")
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    console.log(data);
  });

常见的异步操作

异步操作说明
setTimeout()延迟执行
setInterval()定时重复执行
网络请求fetch()XMLHttpRequest
文件读取FileReader
用户事件clickinputkeydown
图片加载img.onload

回调地狱

当多个异步操作需要按顺序执行时,回调函数会一层套一层,代码变得很难读。

setTimeout(function () {
  console.log("第一步完成");
 
  setTimeout(function () {
    console.log("第二步完成");
 
    setTimeout(function () {
      console.log("第三步完成");
 
      setTimeout(function () {
        console.log("第四步完成");
      }, 1000);
    }, 1000);
  }, 1000);
}, 1000);

这段代码的问题:

  • 缩进越来越深,像金字塔
  • 很难看清执行顺序
  • 修改和维护都很困难
  • 错误处理很麻烦

这就是回调地狱(Callback Hell)。

模拟一个回调地狱场景

function getUser(callback) {
  setTimeout(function () {
    callback({ name: "Tom" });
  }, 1000);
}
 
function getOrders(user, callback) {
  setTimeout(function () {
    callback(["订单 1", "订单 2"]);
  }, 1000);
}
 
function getOrderDetail(order, callback) {
  setTimeout(function () {
    callback({ name: order, price: 99 });
  }, 1000);
}

调用时:

getUser(function (user) {
  console.log("用户:" + user.name);
 
  getOrders(user, function (orders) {
    console.log("订单:" + orders);
 
    getOrderDetail(orders[0], function (detail) {
      console.log("订单详情:" + detail.name + ",价格:" + detail.price);
    });
  });
});

每一步都依赖上一步的结果,回调嵌套越来越深。

解决回调地狱的思路

为了解决回调地狱的问题,JS 后来引入了更好的方案:

  • Promise:用链式调用代替嵌套回调
  • async/await:让异步代码看起来像同步代码
getUser()
  .then(function (user) {
    return getOrders(user);
  })
  .then(function (orders) {
    return getOrderDetail(orders[0]);
  })
  .then(function (detail) {
    console.log(detail);
  });

用 Promise 改写后,代码从左到右平铺,不再层层嵌套。

常见写法

写法作用
setTimeout(fn, time)延迟执行回调
element.addEventListener(event, fn)事件回调
fetch(url).then(fn)网络请求回调
function task(cb) { cb(); }自定义回调

特点

  • 同步代码按顺序执行,容易理解
  • 异步代码不会阻塞主线程
  • 回调函数是最基础的异步处理方式
  • 回调嵌套过多会导致回调地狱
  • Promise 和 async/await 是更好的异步方案

注意事项

  • 异步代码的执行顺序和书写顺序可能不同
  • 不要在异步回调外面直接使用异步结果
  • 回调地狱不是语法错误,是代码结构问题
  • 回调函数本身是同步传入的,只是执行时机由异步操作决定
let data = null;
 
setTimeout(function () {
  data = "异步结果";
}, 1000);
 
console.log(data);

这里 console.log(data) 会输出 null,因为定时器还没执行,data 还没被赋值。

正确做法是在回调里使用数据:

setTimeout(function () {
  data = "异步结果";
  console.log(data);
}, 1000);

理解

可以把同步理解为排队买奶茶,每个人必须等前面的人买完才能轮到自己。

异步就是先下单拿个号,去做别的事情,做好了叫号来取。

回调函数就是那个”叫号”的动作,任务做完了,通过回调通知你结果。

回调地狱就是连续排了好几次队,每次都要等上一次排完,越等越烦。

引出

为了解决回调地狱的问题,ES6 引入了 JS Promise,用链式调用来替代层层嵌套。

理解 Promise 之后,还可以继续学习 JS async await,它能让异步代码写起来更像同步代码。

JS 定时器 是最常见的异步操作之一,可以结合定时器加深对异步执行顺序的理解。