从同步到异步:重新理解 JavaScript 的执行机制
昨天学习了 JavaScript 中同步任务、异步任务、事件循环以及 Promise 的基本使用。本文用几个小例子,把这些知识串起来,帮助自己建立一条清晰的理解路径。
前言
刚开始学 JavaScript 异步时,很容易产生一个疑问:
为什么代码明明写在前面,却不是马上执行?
比如下面这段代码:
js
console.log('start');
setTimeout(() => {
console.log('222');
}, 1000);
console.log('end');
它的输出顺序是:
txt
start
end
222
这说明 JavaScript 并不是简单地"从上到下等每一行都执行完"。当遇到定时器、事件、网络请求这类耗时任务时,它会先把这些任务交出去,继续执行后面的同步代码。
这就是理解异步的入口。
JavaScript 为什么需要异步?
JavaScript 的一个重要特点是:主线程是单线程的。
也就是说,同一时间主线程只能做一件事。下面这种同步代码会按照顺序立即执行:
js
let a = 1;
let b = 2;
let c = 3;
console.log(a + b + c);
同步任务的特点是简单、直接、执行顺序清晰。
但是在真实开发中,很多任务并不是马上就能完成的,比如:
setTimeout定时器- 用户点击、输入等事件
fetch网络请求- 文件读取
- 数据库操作
如果 JavaScript 遇到这些耗时任务时一直等待,页面就会卡住,用户也无法继续操作。所以 JavaScript 采用了异步机制:先执行同步任务,耗时任务完成后再回来处理它们的回调。
JavaScript 的执行机制
可以把 JavaScript 的执行过程理解成三步:
- 主线程先执行所有同步代码。
- 遇到异步任务时,把它交给对应的环境处理,比如浏览器的定时器模块、网络模块。
- 等同步代码执行完以后,再通过事件循环取出可以执行的异步回调。
还是看这个例子:
js
console.log('start');
setTimeout(() => {
console.log('222');
}, 1000);
console.log('end');
执行过程是:
- 打印
start。 - 遇到
setTimeout,注册一个 1 秒后的回调。 - 不等待定时器,继续向下执行。
- 打印
end。 - 大约 1 秒后,同步代码已经执行完,事件循环把定时器回调拿出来执行。
- 打印
222。
所以最终结果是 start -> end -> 222。
Promise:管理异步任务的容器
异步任务多了以后,只靠回调函数会让代码变得很难读。ES6 提供了 Promise,它可以更清晰地表达一个异步任务最终成功还是失败。
一个基本的 Promise 写法如下:
js
const p = new Promise((resolve, reject) => {
console.log('许下诺言');
setTimeout(() => {
reject('网络错误');
}, 2000);
});
p
.then((data) => {
console.log(data);
console.log('成功了');
})
.catch((err) => {
console.log(err);
})
.finally(() => {
console.log('终于到达了这里');
});
这里有几个关键点:
new Promise()需要传入一个函数,这个函数叫executor。executor会立刻执行,它本身是同步执行的。resolve表示异步任务成功,后续会进入.then()。reject表示异步任务失败,后续会进入.catch()。.finally()不关心成功还是失败,最后都会执行。
可以把 Promise 理解成一个"异步任务的状态容器"。
它一开始是等待状态,之后要么成功,要么失败:
txt
pending -> fulfilled
pending -> rejected
成功时调用:
js
resolve(result);
失败时调用:
js
reject(error);
fetch 本身就返回 Promise
网络请求是最常见的异步任务之一。浏览器提供的 fetch 方法,底层返回的就是一个 Promise。
html
<button id="loadBtn">请求数据</button>
<pre id="output"></pre>
<script>
const output = document.querySelector('#output');
const loadBtn = document.querySelector('#loadBtn');
function log(message) {
console.log(message);
output.textContent += `${message}\n`;
}
loadBtn.addEventListener('click', () => {
output.textContent = '';
log('start');
fetch('https://jsonplaceholder.typicode.com/users/1')
.then((res) => {
log('请求完成,开始把响应转成 JSON');
return res.json();
})
.then((user) => {
log(`用户名称:${user.name}`);
log(`用户邮箱:${user.email}`);
})
.catch((err) => {
log(`请求失败:${err.message}`);
})
.finally(() => {
log('finally:不管成功失败都会执行');
});
log('end');
});
</script>
点击按钮后,start 和 end 会先打印出来。请求完成后,才会进入后面的 .then()。
这里也能再次看到异步代码的执行特点:
txt
先执行同步代码
再等待异步任务完成
最后执行异步回调
用 Promise 封装一个 sleep
JavaScript 本身没有内置的 sleep 函数,但可以用 Promise 加 setTimeout 实现一个简单版本:
js
function sleep(t) {
const p = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, t);
});
return p;
}
sleep(2000).then(() => {
console.log('2s 后再做');
});
这段代码的意思是:
- 调用
sleep(2000)。 - 返回一个 Promise。
- 2 秒后调用
resolve()。 - Promise 状态变成成功。
- 执行
.then()中的代码。
这也是 Promise 很实用的地方:它可以把一个异步过程包装成更容易组织的链式调用。
小结
这次学习主要理解了几个点:
- JavaScript 主线程是单线程的,同步任务会优先执行。
- 定时器、事件、网络请求都属于常见异步任务。
- 异步任务不会阻塞后面的同步代码。
- 事件循环会在同步代码执行完成后,再调度异步回调。
- Promise 是 ES6 中管理异步任务的重要机制。
resolve对应成功,.then()会执行。reject对应失败,.catch()会执行。.finally()不管成功失败都会执行。fetch返回 Promise,所以可以使用.then()、.catch()、.finally()。- 可以用 Promise 封装自己的异步工具函数,比如
sleep。
学异步时,最重要的不是死记 API,而是先建立执行顺序的感觉:
txt
同步代码先走
异步任务先挂起
同步执行完后
事件循环再调回调
理解了这条主线,再去学习 async/await、并发请求、错误处理,就会顺很多。