Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS task到底是怎么运行的 #4

Open
rhinel opened this issue Aug 13, 2018 · 6 comments

Comments

Projects
None yet
2 participants
@rhinel
Copy link
Owner

commented Aug 13, 2018

现在前端构建的应用越来越复杂,引用的轮子越来越多,分工精细化后,应用开发者理应不关注背后逻辑是怎么发生的,但是目前所有的轮子及功能能否友好的相处还依然未达到生产的稳定要求。

为了提高代码的稳定性及以后写代码少遇点坑,我觉得有必要明白多一点为什么。

前置知识

下面这些内容应用开发者基本不会涉及到,但是我觉得有必要搞清楚。

JS 环境、堆栈的概念

  • H&S:堆栈,Heap & Stack
  • VO:变量对象,Variable Object
  • AO:活动对象,Active Object
  • SC:作用域链,Scope Chain
  • EC:执行环境(或执行上下文),Execution Context
  • ECS:执行环境栈,Execution Context Stack

首先简述这几个概念是什么意思,从哪来的,为啥需要这个东西、干啥用。

H&S (堆栈)

单说堆栈的概念,大家都知道,JS中堆栈是具体干啥的呢。

堆(Heap)JS有ObjectArrayUndefinedNullBooleanNumberString 7种基本类型,而这些类型的具体数据则是存储在堆内存中,存储规则以队列的形式,先进先出,早期数据基本先于后期入堆数据被内存回收。这些数据均是可以被直接访问具体值的。

栈(Stack)JS是有动态类型的,因此变量本身是没有具体定义类型,那么变量只剩下一个指针,所有这些指针均存储在栈内存中,存储以定义队列的形式,后进先出,因为先入栈指针可能被后入栈指针指向,而后入栈指针在没有引用或者执行环境则被内存回收。

VO(变量对象)/ AO(活动对象)

变量对象(Variable object)是说JS的执行上下文中都有个对象用来存放执行上下文中可被访问但是不能被delete的函数标示符、形参、变量声明等。它们会被挂在这个对象上,对象的属性对应它们的名字对象属性的值对应它们的值但这个对象是规范上或者说是引擎实现上的不可在JS环境中访问到活动对象。

激活对象(Activation object)有了变量对象存每个上下文中的东西,但是它什么时候能被访问到呢?就是每进入一个执行上下文时,这个执行上下文儿中的变量对象就被激活,也就是该上下文中的函数标示符、形参、变量声明等就可以被访问到了。

SC(作用域链)

作用域链也是指针对象存储在栈中,这个大家可能就不好理解了,域本身是一个范围,怎么就是对象呢。如果不用空间的概念来思考,而是用该空间内具有物来思考,就好理解了。

作用域链实际上是指:是按照作用域解析顺序,从顶级作用域开始解析到该上下文,该上下文可引用的变量的链表。而变量的作用域则是反向指:该变量挂在哪些上下文对象的链表上。

需要说明的是,函数的[[Scope]]在代码被解析时,就被建立,不会改变,取决于文本环境,直到代码片段被出栈。而在函数调用时,则初始化当前ScopeScope = AO&VO + [[Scope]]其中,AO始终在Scope的最前端。

EC(执行上下文)

每次当控制器转到ECMAScript可执行代码的时候,就会进入到一个执行上下文。

那什么是可执行代码呢?

可执行代码的类型

全局代码(Global code)这种类型的代码是在"程序"级处理的:例如加载外部的js文件或者本地<script></script>标签内的代码。全局代码不包括任何function体内的代码。 这个是默认的代码运行环境,一旦代码被载入,引擎最先进入的就是这个环境。

函数代码(Function code)任何一个函数体内的代码,但是需要注意的是,具体的函数体内的代码是不包括内部函数的代码。

Eval代码(Eval code)eval内部的代码。

也就是说,控制器开始执行上述代码的时候,就进入了一个执行场景,这个执行场景就是EC,目的是告诉你,这个段代码,有什么要求,有什么东西可以用,代码是给谁执行的。

这个好理解,做任务先看说明嘛。那么,这个EC是怎么初始化出来的呢?

EC建立的细节

当函数被调用,但未执行任何其内部代码之前,控制器干了三件事:

  • 初始化当前块级作用域链

Scope = AO&VO + [[Scope]]

  • 扫描代码片段,创建变量,函数和参数

初始化函数的形参,并创建引用的复制。
寻找直接函数声明,值为undefined(ES6浏览器规定),加入VO。
寻找直接声明变量var,值为undefined,加入VO。
过程中如果变量已经被声明,则跳过不操作。constlet则不在此时加入作用域,反而会去除VO中的同名声明。

  • 引入this

根据调用情况,处理this的引用。箭头函数的this在定义时决定,不可修改。

到这里大概明白了变量为啥会提升了吧。

ECS(执行环境栈)

准备了那么多,终于可以执行了,但是执行前,先了解一下过程中是怎么存储调用栈的?为什么报错了有的时候能够获取调用栈,有的时候却不行?

据说是MDN上的例子:

function foo(i) {
  if (i < 0) return;
  console.log('begin:' + i);
  foo(i - 1);
  console.log('end:' + i);
}
foo(2);

// 输出:

// begin:2
// begin:1
// begin:0
// end:0
// end:1
// end:2

执行栈即是函数在执行时存储调用过程的栈,同样的,采取调用形式进行队列,先进后出的方式。

image

当控制器执行函数时,首先它将默认进入全局执行上下文,然后控制器主进程的时序(执行权)将进入被调用的函数,并创建一个新的执行上下文,并将新创建的上下文压入执行栈的顶部。

如果你在当前函数内部调用的其他函数,相同的事情会在此上演。代码的执行流程进入内部函数,创建一个新的执行上下文并把它压入执行栈的顶部。浏览器总会执行位于栈顶的执行上下文,一旦当前上下文函数执行结束,它将被从栈顶弹出,并将上下文控制权交给当前的栈。这样,堆栈中的上下文就会被依次执行并且弹出堆栈,直到回到全局的上下文。

也就是说,你在嵌套多层的函数调用时,将会保存调用函数的执行栈,及相应上下文。同时,如果被调用的函数耗时非常久,那么单线程的JS将会卡住。如果一段JS函数存在死循环调用,那么内存将会被该执行栈吃个干净。

说到这些问题,不得不讲下面的内容:事件循环。

JS 事件循环

地球人都知道,JS是单线程,那么JS继续执行异步代码的方式大家也很清楚,通过事件回调函数。但是具体是怎么个回事。函数执行为啥有时候会有点不符合预期,必须要了解一下了。

JS运行是通过主线程轮询一个事件队列,这个队列中放置着需要执行的函数,而这个过程就是事件循环。

那么事件队列又是咋回事呢?

事件队列

一个 JavaScript 运行时包含了一个待处理的消息队列。每一个消息都有一个为了处理这个消息相关联的函数。

在事件循环时,runtime (运行时)总是从最先进入队列的一个消息开始处理队列中的消息。正因如此,这个消息就会被移出队列,并将其作为输入参数调用与之关联的函数。为了使用这个函数,调用一个函数总是会为其创造一个新的栈帧( stack frame),一如既往。

函数的处理会一直进行直到执行栈再次为空;然后事件循环(event loop)将会处理队列中的下一个消息(如果还有的话)。

image

举个栗子就好理解了:食堂大家都在排队打饭,A(函数)拿出饭盒放到台面上(栈),开始选菜(执行),选到一半或者选完,突然说:“哎,我上铺兄弟让我帮带一份”,边说着又拿出一个饭盒(调用函数)放到台面上(栈),选完菜,到后面的B……

除了一开始的解释即执行的过程,那么响应过程呢?

事件广播

在浏览器端,通过document的Event接口表示在DOM中发生的任何事件(浏览器事件触发线程)。
在nodejs中,则通过EventEmitter对象来广播及绑定事件。
还有一个特殊对象,XMLHttpRequest响应请求状态(http线程)。
H5新增的:MessageChannelpostMessage事件。
也可以自定义个对象定义事件绑定及触发机制(类似于集合调用)。

这里没有放入MutationObserver,因为MutationObserver不属于同步事件触发,而是一个异步回调。

上面的栗子有些特殊情况,比如:某日教职工交代打饭大妈:“哎,今天领导视察,非要吃食堂,等下领导来了我喊一声,你给领导打一份饭”。

过了一会……

“领导驾到!”,教职工喊道,打饭大妈一个激灵,赶紧帮眼前的同学打好饭,一看没人排队了,赶紧打了一份大餐找了个人给领导端了过去……

除了突然触发的事件,还有定时触发的情况,那么就要说另一个计时器了。

计时器

由于JS是单线程的,定时计数则不是由JS自身完成的,通过setTimeout, setInterval, setImmediate,进入计时器,再由计时器触发执行(浏览器定时触发线程)。

继续上面的栗子:每天三餐外加宵夜……就是定时器任务……

那么上面这些函数调用执行执行时,是如何在主线程中顺序执行呢?

任务和微任务

W3C规定:

...
3. Run: Run the selected task.
...
6. Microtasks: Perform a microtask checkpoint.
7. Update the rendering.

一次事件循环包括:执行tasks,检查Microtasks队列并执行,执行UI渲染(如果需要)。

tasks任务包括:函数、加入队列的事件回调。

Microtasks任务包括:Promise.thenMutationObserver回调、process.nextTick

也就是说,一段代码会被分为tasks部分和Microtasks部分,执行完后,对该段代码内的UI变更进行处理(不包含内部为了执行代码立即进行的重绘),很明显,Microtasks就是为了实现异步操作而设计的。

同时W3C又做了如下规定:

Another example of why a browser might skip updating the rendering is to ensure certain tasks are executed immediately after each other, with only microtask checkpoints interleaved (and without, e.g., animation frame callbacks interleaved). For example, a user agent might wish to coalesce callbacks together, with no intermediate rendering updates. However, when are no constraints on resources, there must not be an arbitrary permanent user agent limit on the update rate and animation frame callback rate (i.e., high refresh rate displays and/or low latency applications).

If necessary, update the rendering or user interface of any Document or browsing context to reflect the current state.

第一段说,可以跳过UI渲染继续执行:例如:setImmediate,第二段说,浏览器可能在JS运行时强制渲染:获取offset等参数时;这些按下不表,有兴趣可以关注W3C原文,或看这篇解析,这里要说的是代码的划分执行顺序:

栗子:

function t1 () {
  console.log(1)
  var observer = new MutationObserver(() => console.log('ob'))
  observer.observe($0, { attributes: true })
  $0.style.height = '200px'
  console.log(2)
}

function t2() {
  console.log(3)
  Promise.resolve().then(() => console.log('ps'))
  console.log(4)
}

t1()
t2()

// 输出:
// 1
// 2
// 3
// 4
// ob
// ps
// undefined

上面这个栗子说明,MutationObserverPromise执行后,回调均被放置在本次事件循环tasks之后的Microtasks队列里,并依次执行。

栗子2:

function t3() {
  console.log('5')
  setTimeout(() => console.log('st'), 0)
  console.log('6')
}

t3()

// 输出:
// 5
// 6
// undefined
// st

这个就很明显了, setTimeout被放到了下一轮事件循环(由计时器线程触发)。

到上面为止,基本上都还符合预期,下面说一个特殊情况。

async/await

大家都喜欢用new API,好用简便,不用回调不用一直next(),但是这个栗子1:

async function t1 (fun) {
  console.log(1)
  console.log(2)
  await Promise.resolve().then(() => console.log(3))
  console.log(4)

  if (fun) {
    return fun()
  }
}

t1(function() {
  console.log(5)
})

console.log('end')

// 输出:
// 1
// 2
// end
// 3
// 4
// 5
// undefined

喵喵喵??和说好都不一样呢,怎么后面的先执行了?

其实这种情况在前端代码里很常见,同步函数和普通函数混用时,如果没搞清楚就可能出现这个问题。

W3C是这么说的:

If, while a compound microtask is running, the user agent is required to execute a compound microtask subtask to run a series of steps, the user agent must run the following steps:

  1. Let parent be the event loop’s currently running task (the currently running compound microtask).
  2. Let subtask be a new task that consists of running the given series of steps. The task source of such a microtask is the microtask task source. This is a compound microtask subtask.
  3. Set the event loop’s currently running task to subtask.
  4. Run subtask.
  5. Set the event loop’s currently running task back to parent.

由于Promise.then属于Microtasks,async/await则将这之后全部代码推入了Microtasks队列变成子任务执行,并转移执行权,达到不阻塞的效果。

事实是这样吗?再看一个栗子2:

async function t1 () {
  console.log(1)
  console.log(2)
  await Promise.resolve().then(() => console.log('t1p'))
  console.log(3)
  console.log(4)
}

async function t2() {
  console.log(5)
  console.log(6)
  await Promise.resolve().then(() => console.log('t2p'))
  console.log(7)
  console.log(8)
}

t1()
t2()

console.log('end')

// 输出:
// 1
// 2
// 5
// 6
// end
// t1p
// t2p
// 3
// 4
// 7
// 8
// undefined

惊不惊喜?意不意外?async/await不仅将Promise.then扔到Microtasks,Microtasks中执行一个task后,后续非Microtasks代码则被当成另外个子任务重新排队了,并再次交换执行权……

再看一个栗子3:

async function t1 () {
  console.log(1)
  console.log(2)
  await Promise.resolve().then(() => console.log('t1p'))
  await console.log(3)
  console.log(4)
}

async function t2() {
  console.log(5)
  console.log(6)
  await Promise.resolve().then(() => console.log('t2p'))
  console.log(7)
  console.log(8)
}

t1()
t2()

console.log('end')

// 输出:
// 1
// 2
// 5
// 6
// end
// t1p
// t2p
// 3
// 7
// 8
// 4
// undefined

执行权就如击鼓传花般……,同时两个同步函数利用Microtasks在内部保持同步执行的情况下,形成了一个微妙的“双线程同步执行”的情况。

再复杂一点,栗子4:

async function t1 () {
  console.log(1)
  console.log(2)
  await new Promise(resolve => {
    setTimeout(() => {
      console.log('t1p')
      resolve()
    }, 1000)
  })
  await console.log(3)
  console.log(4)
}

async function t2() {
  console.log(5)
  console.log(6)
  await Promise.resolve().then(() => console.log('t2p'))
  console.log(7)
  console.log(8)
}

t1()
t2()

console.log('end')

// 输出:
// 1
// 2
// 5
// 6
// end
// t2p
// 7
// 8
// undefined
// t1p
// 3
// 4

这次发现,由于Microtasks没有待执行任务,该此事件循环结束。但由于函数进入了计时器线程,响应的执行栈被缓存下来,因此被加入下次事件循环后,Microtasks任务队列也被加入了下次事件循环。

这个问题到这里基本上满足调试需求了,不过还没讨论浏览器差异……

结合上面这些栗子,再来思考一个问题:

async 函数是什么?一句话,它就是 Generator 函数的语法糖。

是不是觉得豁然开朗了?

这里还有一个很有意思的探讨:Tasks, microtasks, queues and schedules,里面讨论了DOM事件触发及浏览器差异,有兴趣可以了解看看。

开启多线程

to be Continued

参考:

深入理解JavaScript执行上下文、函数堆栈、提升的概念

javascript 执行环境,变量对象,作用域链

ES6入门

并发模型与事件循环

event

node 事件机制

理解javascript定时器中的单线程

通过了解渲染过程来提高页面性能

Mutation Observer API

@rhinel rhinel changed the title macrotask与microtask JS task到底是怎么运行的 Aug 13, 2018

@rhinel

This comment has been minimized.

Copy link
Owner Author

commented Nov 4, 2018

通过回答JS函数作用域以及变量提升问题。让我对作用域有了更深入的理解,同时认真了解浏览器和规定的差异情况,推荐补充至。

@rhinel

This comment has been minimized.

Copy link
Owner Author

commented Nov 16, 2018

Chrome 提交了优化ECMAScript编辑性更改
1、await 后面不一定会创建新的微任务,取决于await 后面是立即返回还是promise。
2、await 执行之后不会强制创建新的微任务,而是继续执行。

以上两种修改会导致同步调用两个async函数时,执行权交换顺序会发生改变。
但是会提高性能。

来自 -- Faster async functions and promises

@rhinel

This comment has been minimized.

Copy link
Owner Author

commented Dec 5, 2018

8张图让你一步步看清 async/await 和 promise 的执行顺序,文章中讨论发现,Chrome的编辑性更改已经在Chrome 72版中实现,并且babel最新版也按照该规范进行编译。

而我的文章中讨论未举例顺序执行:await后面直接返回promise和直接promise微任务的情况(该情况则是Chrome编辑性更改中影响的执行权交换顺序),因此推荐补充至。

@xianshenglu

This comment has been minimized.

Copy link

commented Dec 27, 2018

这次 await 规范的变更分析可以看我这个,不过是英文的,xianshenglu/blog#60 ,或者你直接去看这个决议 tc39/ecma262#1250

@rhinel

This comment has been minimized.

Copy link
Owner Author

commented Feb 23, 2019

@rhinel

This comment has been minimized.

Copy link
Owner Author

commented Mar 14, 2019

通过回答JS函数作用域以及变量提升问题。让我对作用域有了更深入的理解,同时认真了解浏览器和规定的差异情况,推荐补充至。

继续补充关于ES6默认参数作用域的奇特问题,A question about JS parameter scopes, and closures over them vs function scopes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.