【修正模型】4-1 执行上下文(Execution Context)

经过了二十多天,一路上我们从基本的逻辑思考方式到了解 JavaScript 的意义,再从 JavaScript 原生基础语法到浏览器相关 API 的实作,基本上靠着这些内容已经可以完成不少开发专案了。

然而如上章节提到的部分,就在实作的过程中可能会有些执行结果并不如你所预期的,这时你搜寻结果或询问它人时可能会得到提升(hoising)、作用域(scope)或非同步(asynchronous)……诸如此类的名词,在不懂的情况下只能硬着将这些概念解释回你程序码当中的那些错误。

然而其实你可以不必这麽做,因为接下来的第四章中我们将来回顾一下 JavaScript 实际上是怎麽执行的,而在这过程中,你就可以了解到那些概念究竟是怎麽衍伸而来的。

执行上下文(Execution Context)

执行上下文(Execution Context)是指程序在执行时的过程所产生的环境,而对於开发网页中的 JavaScript 来说,就是指在浏览器 上一步一步执行的过程与各个资料当下的状态

而 JavaScript 的执行上下文最主要是 以执行函式(Function)来做为区分,意思即为每当我执行一个函式时就会产生出一个新的环境,其中还包含了一开始负责执行 JavaScript 的主程序 Main(),而主程序与後续呼叫函式时所产生的环境我们可以将其分为:

  • 全域执行上下文(Global Execution Context)
  • 函式执行上下文(Function Execution Context)

全域执行上下文(Global Execution Context)

全域执行上下文意思即为,一开始浏览器执行 JavaScript 时所包裹的 Main() 函式所产生的环境。

也就是说当我们这麽写的时候:

var a = 'test'

在浏览器上执行时其实会被包裹成类似这样的概念:

function Main() {
  var a = 'test'
}

接着执行时就会依据全域执行上下文来依序处理每行程序码。

而在全域执行上下文中大概包含了以下内容:

  • 执行阶段(Execution Phase)
  • 全域物件(Global Object)
  • 变数物件(Variable Object)
  • 范围链(Scope Chain)
  • this

函式执行上下文(Function Execution Context)

而函式执行上下文,则是当我们程序码中 执行函式时就会产生的环境

function add(a, b){
    return a + b
}

add(1, 2) // 执行到此行时产生函式执行上下文

其中全域执行上下文中大概包含了以下内容:

  • 执行阶段(Execution Phase)
  • 全域物件(Global Object)
  • 执行物件(Activation Object)
  • 范围链(Scope Chain)
  • this

基本上与全域执行上下文的内容相同,只差在变数物件(Variable Object)换成执行物件(Activation Object)而已,两者差异在於执行物件多了个 参数(arguments) 的内容物

而接下来,我们将分别介绍上下文中的各个内容,这里也有个针对 Execution Context 的视觉化的专案,可以一边看这篇文章一边比对内容加速理解。


全域物件

第一个我们要来看的是全域物件,全域物件最主要是端看当时执行 JavaScript 的环境,若是在浏览器上执行的话,全域物件会绑定在 Window 物件上面:

GlobalExecutionContext = {
  Window: [[global object]]
}

而我们平时会可以直接透过 console.log() 执行的原因也是因为预设的物件是基於 window 之上,只是在规定中有表明我们可以忽略 window 直接取用:

console.log('Hello, window!') // 省略 window 取用方法

window.console.log('Hello, window!') // 其实实际上是这样

this

接着第二个我们要来看看 this 的部分,this 最主要在上下文中代表 当下执行的程序所属的物件

若是在全域上下文中,由於是全域是由函式 Main 当中因此 this 则指向了 window 中:

GlobalExecutionContext = {
  Window: [[global object]],
  this: window
}

若是函式上下文则可能会受到呼叫的函式而有所影响:

var person = {
	talk: function(text){
      console.log(text);
      console.log(this);
	}
};

person.talk('Hello, this');
FunctionExecutionContext = {
  Window: [[global object]],
  this: { // 指向呼叫的物件
    talk: function(text){
        console.log(text);
        console.log(this);
    }
  }
}

除了上述这种方式之外另外也可以透过 bindcallapply 等等方式来改变 this 的值。

接着我们来看上下文中的范围链(Scope Chain)。

范围链

范围链最主要的功能是当程序在执行的过程中,若无法在当下的作用域(Scope)找到该识别字时,会沿着范围链(Scope Chain)来找值:

范围链例子一:

var a = 1;
var b = 2;

console.log('a', a) // 找到当下作用域中的 a,因此显示 1
console.log('b', b) // 找到当下作用域中的 b,因此显示 2

function A(){
  var a = 3;

  console.log('a', a) // 找到当下作用域中的 a,因此显示 3
  console.log('b', b) // 当下作用域没有 b,但找到外层 main 函式 的 b,因此显示外层 b 的值 2
  
  funcB()
  function funcB(){
    var b = 4;

    console.log('a', a) // 当下作用域没有 a,但找到外层 funcA 函式 的 a,因此显示外层 a 的值 3
    console.log('b', b) // 找到当下作用域中的 b,因此显示 4
  }
}
funcA()

范围链例子二:

var a = 1;
var b = 2;

console.log('a', a) // 找到当下作用域中的 a,因此显示 1
console.log('b', b) // 找到当下作用域中的 b,因此显示 2

function A(){
  var a = 3;

  console.log('a', a) // 找到当下作用域中的 a,因此显示 3
  console.log('b', b) // 当下作用域没有 b,但找到外层 main 函式 的 b,因此显示外层 b 的值 2
  funcB()
}

function funcB(){
  var b = 4;

  console.log('a', a) // 当下作用域没有 a,但找到外层 main 函式 的 a,因此显示外层 a 的值 1
  console.log('b', b) // 找到当下作用域中的 b,因此显示 4
}

funcA()

从上方两个例子的比较中可以发现 funcB 由於编写时的位置不同,因此范围链的根据也不同,最後找到的值也会有差异。

而这种单纯 从编写位置来看作用域方式 我们称其为静态作用域又称之为词法作用域(Lexical scope),好处是我们不用等到执行时就能知道它所要存取的变数究竟是哪个。

在其他语言中,若是从 从执行位置来看作用域方式 则称为动态作用域。

而综合上面的概念後,现在我们知道范围链的概念其实就是 当下自己的作用域跟外层的作用域,因此在上下文中可以这麽表示:

GlobalExecutionContext = {
  ScopeChain: [[self scope]] + [[outer scope]]
}

执行阶段与变数物件

接着是最重要的执行阶段与变数物件部分,会并再一起讲是因为这两者之间有很大的关联。

var text = 'Hello, Execution Context!';

function say(target){
	console.log(target);
}

say(text);

当上方程序码一开始执行时,首先会进入全域执行上下文的创造阶段,因此在 执行阶段(Phase) 中会显示 Creation

GlobalExecutionContext = {
  Phase: 'Creation'
}

接着在 执行阶段(Phase)Creation 的时候,JavaScript 引擎会先做一次宣告的扫描,此时有关於 varletconstfunction 这类型的宣告叙述句(declartion statement)都会一一被扫出来并做以下的动作:

  • 如果是宣告变数(如 var)时,会先在变数物件(或执行物件)中初始化一个 undefined 的值。
  • 如果是宣告函数(如 function)时,会直接在变数物件中塞入该函式整段程序码。

因此以上方范例程序码中的执行阶段会产生像这样子的上下文:

GlobalExecutionContext = {
  Phase: 'Creation',
  VariableObject: {
    text: 'undefined',
    say: function(target){
      console.log(target);
    }
  }
}

而当这段初始化宣告完成的时候,接着就会进入执行阶段(Execution)

GlobalExecutionContext = {
  Phase: 'Execution', // 进入执行阶段
  VariableObject: {
    text: 'undefined',
    say: function(target){
      console.log(target);
    }
  }
}

而在这执行阶段的过程当中,JavaScript 引擎会类似 一行一行 的去解读每句程序码的含意,例如刚刚程序码中在执行阶段的第一行中,它会读到将 text 所属的值赋值为 'Hello, Execution Context!'

var text = 'Hello, Execution Context!';

function say(target){
	console.log(target);
}

say(text);

此时上下文中的变数物件就会变成:

GlobalExecutionContext = {
  Phase: 'Execution', // 进入执行阶段
  VariableObject: {
    text: 'Hello, Execution Context!', // 读到第一行後执行赋值的动作
    say: function(target){
      console.log(target);
    }
  }
}

也因为创造阶段与执行阶段的关系,会造成在宣告之前取值时,会有种称作 提升(hoisting) 的错觉。

变数的提升:

console.log(text) // undefined,因为在 Phase: 'Creation' 时 text 就有 `undefined`值

var text = 'Hello, Execution Context!';

console.log(text) // 'Hello, Execution Context!'

函式的提升:

console.log(say) // ƒ say(target){ console.log(target);} ,因为在 Phase: 'Creation' 时 say 就会直接传入整个函式

function say(target){
	console.log(target);
}

console.log(say) // ƒ say(target){ console.log(target);}

但现在你知道那是因为创造阶段时的初始化之间的差异。

延伸阅读:暂时性死区(Temporal Dead Zone,TDZ)

letconst 在创造阶段时也会有初始化的行为,然而初始化的值并不像是 var 一样是初始化 undefined,而是一个错误黑洞,这个值(ThrowReferenceErrorIfHole)需要经由 V8 拆解 Byte code 後才能看到,若你尝试直接取值则会抛出一个错误。

console.log(a) // Uncaught ReferenceError: a is not defined
let a = ''

所以严格上来说他们还是有「提升」的行为,只是与你想的不太一样而已。

现在我们已经讲完了全域执行上下文(Global Execution Context)的概念了,此时的你应该要知道程序在执行时主要会 以函式为划分 去创造上下文的空间,接着在每个上下文中分为两个步骤:初始化值的创造阶段(Creation)以及负责一行一行解析的执行阶段(Execution)。

最後我们要来看看函式执行上下文(Function Execution Context)中,在执行物件(Activation Object)中的 arguments 是什麽东西。

执行物件(Acativation Object)与 Arguments

执行物件就好比像是全域执行上下文中的变数物件一样,只是不一样的是里面多了个 Arguments,而 Arguments 顾名思义就是跟函式引数有关的内容通通会被放在这里面。

var text = 'Hello, Arguments!';

function say(target){

}

say(text);

当上方程序执行到 say(text); 的时候,此时会创建一个函式执行上下文,里面有着跟全域执行上下文不同的执行物件(Acativation Object),此时在创造阶段的时候除了会将引数(arguments)初始化之外,函式参数(parameters)也会一并被初始化:

FunctionExecutionContext = {
  Phase: 'Creation',
  AcativationObject: {
    target: 'Hello, Arguments!', // 初始化函式参数
    arguments: {
      0: 'Hello, Arguments!' // 初始化函式引数
      length: 1
    }
  },
  this: window
}

因此若我们在程序中这麽写的时候:

var text = 'Hello, Arguments!';

function say(target){
	console.log(target); // ?
  var target = 'another'
  console.log(target); // ?
}

say(text);

say 函式中的第一行 console.log(target); 就会先读到初始化函式参数中的 'Hello, Arguments!',接着才会被第二行的赋值给取代,最後第三行的 console.log(target); 才会读到 'another'

var text = 'Hello, Arguments!';

function say(target){
	console.log(target); // 'Hello, Arguments!'
  var target = 'another'
  console.log(target); // 'another'
}

say(text);

以上就是 JavaScript 在执行时引擎如何透过上下文来看待程序码的方式。

虽然今天已经达成三十天了,不过接下来还是会陆续再补上後续章节的部分 XD,明天我们将继续来看看浏览器主线程中的堆叠与事件循环的机制是如何处理的。


<<:  Android Studio - 心得

>>:  Day28 - 交易所

自动化测试,让你上班拥有一杯咖啡的时间 | Day 1 - 前言

此系列文章会同步发文到个人部落格,有兴趣的读者可以前往观看喔。 嗨!我是卯郁,去年就立志今年要参加...

Unity自主学习(二十三):物件移动(2)

昨天也都了解到Unity脚本侦测按键的程序码是该怎麽做打的 那仔细看Unity官方提供的范例好像有两...

数位 AI 新时代

人的科技文明发展始终来自於人性 在数位的新时代浪潮席卷之下,世界各国不论是个人的发展,还是组织企业团...

予焦啦!检验核心映像档:开机流程、OpenSBI 惯例、ELF 浅谈

本节是以 Golang 上游 1a708bcf1d17171056a42ec1597ca8848c...

DAY 20 『 连接 API 实作 - 天气 APP 』Part2

昨天介绍了如何抓取 API,今天来介绍如何根据 JSON 写一个 struct。 为了接收 API ...