JavaScript入门笔记[4]:JS的执行机制

373 阅读5分钟

JS真的是一门神奇的语言吖
不,是你太菜

之前看到了一段鬼打墙一般的代码

console.log("1");
window.setTimeout(()=>{console.log("3");},0);
console.log("2");

凭借着我C语言计算机二级的底子,我机智的认为,三行代码会在控制台输出:
1

3

2

但是并没有,控制台输出的是:
1

2

3

好奇怪,代码明明是逐行执行的,但是结果却像跳过了 setTimeout 为0的代码。

明明都已经是0秒之后执行了,为什么它却没有按时执行呢?所以这个“0秒之后”,肯定不是代码逐行执行的时间间隔

window.setTimeout(()=>{console.log("3");},0);

这行代码等待的0秒,是其它的。最后我也没有猜出来是什么

后来我又看到了类似的“鬼打墙”代码:

let i = 0;
for(i = 0; i<6; i++){
  setTimeout(()=>{
    console.log(i);
  },0);
};

再次凭借着我C语言计算机二级的底子,我机智的认为,三行代码会在控制台输出:
0 1 2 3 4 5

但是现实再次扇了我一巴掌,和上面的 1 2 3 类似, setTimeout 为0的代码像是被跳过了。控制台的输出结果是: 6 6 6 6 6 6

所以是怎样一种神秘的力量,使得循环内部的语句,在循环结束之后才会执行呢?

我大胆猜测,JS肯定不是逐行执行的!!!

一切问题,在于JS的执行机制。

在查找了资料之后,我明白了两件事情:

  1. JS是单线程,JS同时只能执行一个任务,其他任务都必须在后面排队等待。
  2. JS是单线程,但JS引擎不是只有一个线程。JS引擎有多个线程,单个JS脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合。

所以,我大胆猜测,window.setTimeout(()=>{console.log("3");},0);内的console.log("3");其实是不在主线程上运行的,所以才会出现,就算语句的字面理解是“0秒之后”执行,但其实语句是在主线程运行结束之后才执行的。

而相关资料也是这个意思。

程序里面所有的任务,可以分成两类:同步任务(synchronous)和异步任务(asynchronous)。 JS主线程上执行的,是同步任务。只有前一个任务执行完毕,才能执行后一个任务。

异步任务则是没有进入主线程,进入任务队列的任务。异步任务不会被JS 引擎挂起,而是由JS引擎决定任务执行的时机。这样才不会出现由于单线程而导致一个任务阻塞后续任务的进行。

JS引擎在任务队列(task queue)中存放各类当前程序处理的异步任务。(实际上,根据异步任务的类型,存在多个任务队列。)

JS程序会从主线程开始执行所有的同步任务,等到同步任务全部执行完,才会开始执行任务队列里面的异步任务。如果满足异步任务的执行条件,那么异步任务就会重新进入主线程,成为同步任务开始执行。等到第一个符合条件的异步任务执行结束,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。

在程序执行的过程中,JS引擎会不断检查,只要同步任务执行结束,JS引擎就会去检查那些挂起来的异步任务是否可以进入主线程。这种循环检查的机制,就叫做事件循环(Event Loop)。维基百科 对事件循环的定义是:“事件循环是一个程序结构,用于等待和发送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。

异步操作的模式主要有三种有:

  1. 回调函数
  2. 事件监听("onclick")
  3. 发布/订阅

window.setTimeout(()=>{console.log("3");},0);就是一个回调函数,简单理解就是,window.setTimeout(,0);语句被加入了主线程,但是由于它声明了**“我要等0秒(在主线程结束之后)再执行”**,所以console.log("3");才会在主进程console.log("1");console.log("2");执行结束后执行。

那么 6 6 6 6 6 6 的鬼打墙又要怎么解释呢?

let i = 0;
for(i = 0; i<6; i++){
  setTimeout(()=>{
    console.log(i);
  },0);
};

我们可以这么理解:首先,for循环的代码块被放进了主线程,里面的代码包括:

i = 0;
setTimeout(,0);
i++;
if (i>=6) break;

所以每次执行循环的时候,window.setTimeout(,0);语句都会说**“我要等0秒(在主线程结束之后)再执行”**。所以主线程(代码块内),执行了六次 i++;,直到 i>=6,循环结束,主线程才正式结束。

主线程结束之后,**“我要等0秒(在主线程结束之后)再执行”**的window.setTimeout(,0);语句才会挨个执行。

问题来了,主线程结束之后,console.log("i");(回调函数),才会开始调用主线程内的 i,因为i是全局变量,而这个时候的 i 却已经是6了。

所以我们永远都不会得到和循环一起变化的i么?

不,我们其实可以,这样用就行:

let i = 0;
for(i = 0; i<6; i++){
  let j = i;
  setTimeout(()=>{
    console.log(j);
  },0);
};

变量 j 保存了 i 的值,这样在console.log("j");回调函调 j 的时候,就可以得到保留下来的 i 值。

而这样的操作则是不可以的:

let i = 0;
for(i = 0; i<6; i++){
  
  setTimeout(()=>{
    let j = i;
    console.log(j);
  },0);
};

我们的 j 在 setTimeout 执行时,只能获得 i = 6 的值。

另外ES6也提供了另一种方式:

for(let i = 0; i<6; i++){
  
  setTimeout(()=>{
    console.log(j);
  },0);
};

因为let创建的变量 i 的作用域是for循环函数,所以console.log("i");回调函调 i 的时候,就可以得到保留下来的 i 值。

或者也可以在每次循环时,将 i 作为值传入:

let i = 0;
for( i = 0; i<6; i++){
function fn(j){
    setTimeout(()=>{
    console.log(j);
    },0);}
    fn(i);
};