JavaScript调用栈

“栈”在数据结构层面的意义

  1. 栈是后进先出(LIFO:Last in,first out),新添加的/待删除的保存在栈的末尾,称作栈顶,另一端叫做栈底
  2. 对比:队列的先进先出(FIFO),从尾部添加新元素,从顶部移除元素。

什么是调用栈

  1. Call Stack,一种栈结构(数据结构层面的说法)
  2. 别称:执行栈(execution stack)、控制栈(control stack)、运行时栈(run-time stack)、机器栈(machine stack)、栈(stack)。
  3. 几乎所有的计算机程序都依赖于调用栈,高级语言一般将调用栈的细节隐藏在后台。
  4. 调用栈存储有关正在运行的子程序的消息,最常用于存放子程序的返回地址——调用子程序时,主程序必须暂存子程序运行完毕后应该返回到的地址,因此,如果被调用的子程序还要调用其他的子程序,其自身的返回地址就必须存入调用栈,在其自身运行完毕后再行取回。
  5. 在递归程序中,每一层次递归都必须在调用栈上增加一条地址,因此如果程序出现无限递归(或仅仅是过多的递归层次),调用栈就会产生栈溢出。
  6. 调用栈还可以用于存放本地变量(不同子程序变量分离)、参数传递(寄存器不足以容纳子程序的参数)、环境传递(子程序利用主程序的本地变量)

JavaScript的调用栈

JavaScript语言特性

  1. JavaScript是单线程,这意味着它只有一个Call Stack,一次只能做一件事。
  2. 这也意味着不需要处理复杂场景,比如死锁,但是单线程运行起来性能也就很有限。
  3. 使用回调队列

为什么JavaScript是单线程的

  1. JS诞生就是为了提供Web页面上的交互,比如操作DOM,CSS样式等
  2. 如果JS是多线程,可能导致在多线程的并行下,他们需要操作的东西变成临界资源,需要浏览器(或者其他执行环境)来决定哪个线程生效。
  3. 要保证多线程下资源独占,就要引入互斥锁Mutual exclusion(先来的上锁,阻止后来的操作),会带来复杂性

进程process和线程thread

  1. 进程是能拥有资源和独立运行的最小单位,是程序执行的最小单位/载体,任意时刻,cpu(应该是最小的逻辑单位?)总是运行一个进程,其他进程处于非运行状态——代表cpu能处理的单个任务
  2. 进程是操作系统分配资源的最小单位,线程则是程序执行的最小单位,线程是一个进程中代码的不同执行路线,早期的操作系统没有线程。
  3. 进程之间是相互独立的,同一个进程下的各个线程之间共享程序的内存空间和资源
  4. 多进程是说同一个时间里,同一个计算机系统允许两个或以上的进程在运行状态;多线程是指程序包含多个执行流,完成各自的任务
  5. Chrome和许多多线程浏览器不同,一个网页就一个进程(这样一个tab崩溃也不会影响其他,另外,也不存在线程间共享地址空间和资源。),也就是一个网页就有一个渲染引擎实例。

JavaScript调用栈的工作原理

  1. JavaScript的解释器提供了调用栈,在脚本调用多个函数时,跟踪每个函数在完成执行时应该返回控制的点。参考MDN
    1. 调用一个函数时,解析器把该函数添加到栈中并且执行这个函数。形成一个栈帧(Stack Frame)
    2. 任何被这个函数调用的函数会被进一步添加到调用栈中,形成另一个栈帧,并且运行在它们被上个程序调用的位置。
    3. 当函数运行结束后,如果它没有调用其他函数,则解释器将它从堆栈中取出,并在主代码列表中继续执行代码。
    4. 异步函数的回调函数一般都会被添加到运行队列里面,如setTimeout会在到设定的时间后把回调函数放入队列中
    5. 值得一提的是,setTimeoutsetInterval其实有一个最小执行时间,4ms,就算不传第二个参数,也要等到4ms后才会执行。但第一个参数里如果有立即执行的东西,则会按照顺序立即执行,这是一个很有趣的地方。
    6. 如果栈占用的空间比分配给它的空间还大,那么则会导致“堆栈溢出”错误。
  2. 简单版的说法:被调用者形成一个栈帧,一个压在上一个之上;执行返回之后,被推出调用栈,上一个则继续执行;如此循环。

并发和事件循环

  1. 单线程的JS,调用栈是可能被阻塞的
  2. 如果浏览器开始在栈里处理过多的任务,可能会停止响应很长时间,部分浏览器会选择弹出一个错误,来询问用户是否终止网页。
  3. 执行大量代码而且不阻塞UI导致浏览器没响应的解决方法就是:异步回调。

this和调用栈

this是在运行时进行绑定的,不是编写的时候绑定的——所以,this的绑定和函数声明的位置没有任何关系,只取决于函数的调用的时刻,如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
function baz(){
console.log("baz");//当前调用栈是baz-全局作用域,因此当前调用位置是全局作用域
bar();//《--bar的调用位置
}
function bar(){
console.log("bar");//当前调用栈是bar-baz-全局作用域,因此当前调用位置是baz
foo();//《--foo的调用位置
}
function foo(){
console.log("foo");//当前调用栈是foo-bar-baz-全局作用域,因此当前调用位置是bar
}
baz();//《--baz的调用位置是全局作用域

执行上下文和执行栈

执行上下文

  1. Execution Context;(注意,执行栈是execution stack)
  2. 执行上下文又被叫做当前代码的执行环境/作用域
  3. 通常认为JS代码的执行环境有:
    • Global code:默认环境、全局环境,代码首次执行的地方
    • Function code:代码执行进入到函数体当中
    • Eval code:eval函数内部执行的

执行上下文在执行栈里

  1. Execution Context Stack
  2. 底层其实是单线程的JavaScript的栈,所以一次只会发生一件事,其他行为都在栈里排队(这时候我们通常说是“执行栈”)
  3. 加载了JS脚本后在执行栈里发生的事
    1. 默认进入全局执行上下文,有且只有一个执行上下文
    2. 在全局环境里调用了一个函数,程序序列流进入被调用的函数里,创建一个新的执行上下文,并且将其压入执行栈
    3. 如果在当前函数里又调用了另一个函数,会发生和上面一样的事,新的执行上下文被压入现有栈的顶部,一旦执行函数在当前的执行上下文执行完毕,会被从栈的顶部弹出
    4. 每个函数调用都会创建一个新的上下文,包括递归的每一次。
    5. 函数调用结束后,控制权移交给当前栈的下一个上下文

执行上下文的细节——JS解释器的工作描述

创建阶段
  1. 执行代码之前,创建执行上下文
  2. 初始化作用域链
  3. 创建变量对象
  4. 创建参数对象,检查参数上下文,初始化其名称和值并且创建一个引用拷贝
  5. 扫描上下文中的函数声明,对每个被发现的函数,在变量对象里创建一个同名属性(函数在内存里的引用);如果函数名已经存在(变量对象),那么引用值会被覆盖(所以重复声明是覆盖)
  6. 扫描上下文里的变量声明,对每个发现的变量声明,在变量对象里创建一个同名属性,并且初始化为 undefined;如果变量名已经存在(变量对象),就什么都不做,继续扫描
  7. 决定“this”的值
执行阶段
  1. 在上下文中解释/执行函数代码,并在代码逐行执行时给变量赋值
  2. 注意,是这时候才给变量赋值,创建阶段仅仅是创建;但是参数却不是,因为创建阶段完成后,执行流就进入函数里了
  3. 这就是所谓“提升”(Hoisting)的机制。
  4. 另外,需要注意函数在变量之前被扫描,所以同名函数和变量声明有顺序(函数声明会提升的比变量声明更前)。比如下面的例子,foo是函数,而不是变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    (function() {
    console.log(typeof foo); // function pointer
    console.log(typeof bar); // undefined
    var foo = 'hello',
    bar = function() {
    return 'world';
    };
    function foo() {
    return 'hello';
    }
    }());

浏览器里的JavaScript执行环境

  1. GUI渲染线程:
    1. 负责渲染界面的HTML元素
    2. 界面需要重绘Repaint或者由于某种操作引发回流reflow时执行
  2. JavaScript引擎线程
    1. 也就是JS内核,负责解析JS脚本
    2. 比如V8
  3. 定时触发器线程
    1. 比如我们常见的SetTimeOut
    2. 不是由JS引擎计数的,否则因为JS是单线程,会因为自己的阻塞导致计时不准确。
    3. 尽管如此,计时器触发的时候,也只是进入队列,并不一定被JS立即执行,毕竟JS是单线程的
  4. 事件触发线程
    1. 比如我们绑定的各种点击事件
    2. 事件触发,该线程就把事件添加到待处理的队列的队尾等待JS引擎的处理
  5. 异步http请求线程
    1. XMLHttpRequest在连接后,是通过浏览器新开了一个线程请求
    2. 检测到状态变化时,如果有设置回调,就把状态变更事件放到JS引擎的处理队列里等待处理

为什么JS和界面会相互阻塞

JS可以操作DOM,为了避免同时运行的时候出现不可预期的结果,浏览器就设置为这两个线程互斥,当JS引擎执行的时候就挂起GUI线程,将之保存在一个队列里,等JS执行完。这就是为什么JS脚本会阻塞GUI更新——就是故意这么设计的。而一般来说,如果JS执行时间比较长,就会给人一种页面“卡住了”的感觉——其实就是GUI渲染不连贯了。

V8引擎和调用栈

  1. 引擎主要由两个组件组成
    • 堆Heap(内存):内存分配,是无结构的(相比栈),存储了声明的对象、变量
    • 栈Stack(调用栈):代码执行时的堆栈帧,后进先出
  2. 运行时(Runtime)
    1. 通常,JS的运行时不仅有引擎,还有一些执行环境提供的API(比如DOM、AJAX、setTimeout)
    2. 还有事件循环Event Loop,回调队列(本质是回调队列,也叫执行队列Callback Queue),一个JS运行时包含了一个待处理的消息队列,每一个消息和一个函数相关联
  3. 引擎的工作描述:
    1. 栈有足够内存的时候,从队列取出一个消息进行处理(包含了调用和这个消息相关联的函数,以及因而创建了一个初始栈帧)
    2. 当栈再次为空的时候,意味着消息处理结束
    3. 处理队列的时候,涉及的机制就是事件循环,在浏览器里由事件触发线程进行。
    4. 所以流程就是:在事件循环驱动下,从队列里往包含了堆和栈的引擎里去传送处理,进一步和外部环境比如Web Api交互;外部环境的交互又进一步去影响队列。如此循环。
  4. Node也用的V8引擎

错误栈

  1. 调用栈也正是JS代码抛出异常的时候,构建stack trace的方法——基本就是异常发生的时候
  2. 比如Uncaught Error: SessionStack will help you resolve crashesUncaught RangeError: Maximum call stack size exceeded——Blowing the stack,达到最大调用堆栈大小时,比如递归没边界。
  3. 另外,可以通过console.trace()来主动输出当前的堆栈

Error对象

  1. 程序运行出现错误的时候,通常抛出一个Error对象
  2. Error对象可以作为用户自定义错误对象继承的原型
  3. Error.prototype包含下列属性
    1. constructor——指向实例的构造函数
    2. message——错误信息
    3. name——错误的名字
  4. 不同的运行环境下,Error.prototype可能还含有特定属性,比如Node、Chrome、Firefox、Edge、IE 10+、Safari 6+下,还有stack属性,包含了错误的堆栈轨迹——内含自构造函数之后的所有堆栈结构
  5. 抛出错误,涉及try...catch...finally

处理错误堆栈

  1. 对于支持Error.captureStackTrace的运行环境,比如Node,用起来跟console.trace()差不多,就是Error.captureStackTrace(obj)
  2. 第一个参数是object,Error.captureStackTrace捕获堆栈信息后在第一个参数里创建stack属性来存储(通过.stack可以访问)
  3. 第二个可选参数是一个function,如果有第二个参数,将作为堆栈调用的终点。 捕获到的堆栈信息将只显示该函数调用之前的信息——用于隐藏一些不需要关注的,或者不需要展示的内部细节(比如你传入的是某个会出现在堆栈里的栈桢函数,那么输出的堆栈就只包含这个函数之前的信息)。

Session Stack

  1. TODO: 一款产品session stack
  2. 如果JS里出现难以复现和理解的问题,就可以看Session Stack,它会记录所有东西,包括:DOM更改、用户交互、JS异常、堆栈跟踪、网络请求失败、调试信息等;甚至可以以视频的方式来重现问题