本文编写的 JavaScript 代码示范均运用 node v18.19.1,遵从 ES6 规范。

Scope 效果域

什么是效果域呢?我的了解是:“变量的效果域便是该变量可拜访的范围,函数目标同理”,效果域的效果是防止不同层级中的变量发生冲突。

JS 中首要分为两种效果域:大局效果域(global scope)和部分效果域(local scope)。

在 JS 中,部分效果域相似于“私家房间”,其间的变量只能在特定的区域内拜访。当咱们在部分效果域中声明变量时,它只能在该代码块、函数或条件语句中拜访。部分效果域中的变量会受到外部代码干扰,例如:

function myFunction() {
  var localVariable = "我在部分效果域中";
  console.log(localVariable);
}
myFunction();
console.log(localVariable);

在这段代码中,localVariable在部分效果域中声明,这意味着它只能在myFunction代码块内拜访,尝试在效果域之外运用该变量会抛出ReferenceError: localVariable is not defined的报错。

而大局效果域中中声明的变量能够在代码的任何地方拜访。它能够类比为一个“公共广场”,一切人都能够看到和拜访其间的内容。在大局效果域中声明的变量通常是在任何函数或代码块之外界说的。例如:

var globalVariable = "我在大局效果域中";
function myFunction() {
  console.log(globalVariable);
}
myFunction();
console.log(globalVariable);

在这个比方中,globalVariable在大局效果域中声明,myFunction中也能够直接拜访它。由于myFunction函数中并没有对globalVariable显示地做出声明,也没有把其当作一个参数,同时满意这两个条件,咱们就能够把globalVariable叫做自由变量(free variable)。

仍是在这个比方中,myFunction中运用了globalVariable,但当时效果域中并没有声明该变量,此时它就会向上一级效果域(这儿是大局效果域)寻觅该变量,假如在上一级没有找到,就向再上一级寻觅,直到找到所需变量,或许抛出is not defined报错。这种

xxx-scope -> ... -> global scope

的查询方法,会形成一条效果域链(scope chain)。

和 prototype chain 有些相似之处~

Block Scope 块级效果域

ES6 之前,JS 中只要大局/部分效果域,这会导致一些潜在的问题,如循环变量走漏:

for (var i = 0; i < 3; i  ) {
  setTimeout(function() {
    console.log(i); // Outputs: 3, 3, 3
  }, 100);
}

在上面的代码中,运用varfor循环中声明的变量i被提高到函数效果域,其值在循环的一切迭代中共享。这经常导致意外行为,特别是在处理像setTimeout这样的异步操作时。这对开发者来说很不方便,也不利于编写完善的代码。

为了解决此类问题,ES6 中新增了let&const关键字以及块级效果域(block scope)。

有了新的语法之后,咱们就能够对上面的比方做出改进:

for (let j = 0; j < 3; j  ) {
  setTimeout(function() {
    console.log(j); // Outputs: 0, 1, 2
  }, 100);
}

咱们运用let,变量j的效果域就被约束在for循环的块内,确保每次迭代都为j创立一个新的词法环境。这能够防止与变量提高和异步操作等问题。

因此,在实践开发进程中,咱们一般推荐只运用let&const,不运用var,这能够最大程度防止咱们代码出现 bug。

Static/Lexical Scope 静态效果域

运转以下代码,会得到什么成果呢?

var x = 'global';
function foo() {
    console.log(x);
}
function bar() {
    var x = 'local';
    foo();
}
bar();

答案是global,这倒不难了解,依照前面说的,foo()函数被调用,发现函数效果域中没有x变量,就沿着效果域链向上寻觅,在大局效果域中找到后就输出global。但在有些言语中会得到不同的输出成果。

以 Perl 言语为例,完成相同功能的代码,会得到不同的输出:

你能够运用该 站点 在线运转以上代码并调查输出成果。

our $x = 'global';
sub foo {
    print "$xn";
}
sub bar {
    local $x = 'local';
    foo();
}
bar(); # output: local

原因是这两种言语对效果域的界说不同。从本质上来讲,效果域便是一套规矩,这套规矩用来管理引擎怎么在当时效果域以及嵌套的子效果域中依据标识符称号查找变量。

常见效果域有静态效果域(static scope)和动态效果域(dynamic scope),前者在词法剖析阶段就现已决议,后者则是在代码履行进程中进行动态的区分,比方函数的效果域是在函数被调用时才决议。

JS 选用的是静态效果域规矩,咱们在编写代码就现已决议了其效果域层级。静态效果域也叫做词法效果域(Lexical Scope),这个称号更加直白。

假如你对什么是“词法剖析”抱有疑问,能够参看我之前的文章:JavaScript 履行原理

Hoisting 提高

讲完效果域,咱们能够来说说提高(hoisting)了。

hoisting 是指将变量、函数或类的声明移动到它们所在的效果域的顶部,这允许开发者在代码中运用变量或函数时无需关怀它们的声明方位。这儿“移动”并不精确,但暂时依照这样了解也无妨。

这是一个最简单的比方,咱们在声明ping()之前调用了它,但这不会导致报错:

ping();
function ping() {
    console.log('pong');
}

不抛出报错的原因便是 JS 引擎在运转时将ping()的声明“移动”到了函数调用之前,也便是提高了这个函数声明。

为什么需求 hoisting 呢?在 Twitter 某位用户的问询中,Brendan Eich 回答了这个问题:

Function declaration hoisting is for mutual recursion & generally to avoid painful bottom-up ML-like order.

在咱们编写 JS 时,有时会遇到需求编写两个函数相互调用的状况,假如没有提高,处理这种状况就会变得繁琐。Brendan 不希望在 JS 中看到相似 ML 的自下而上的编程次序。

提高规矩

假如你只想知道 Hoisting 规矩,而对其原理不感兴趣,只需看完本末节。

这是变量提高的简单演示,运转代码会输出undefined而非ReferenceError: a is not defined

console.log(a) // output: undefined
var a = 1;

JS 引擎会提高变量声明操作,而不会提高变量赋值操作。以上代码等效于:

var a;
console.log(a) // output: undefined
a = 1;

再来看这段代码,运转代码输出2而非1

function test(v){
    console.log(v);
    var v = 1;
}
test(2); // output: 2

函数效果域中的变量也会提高,但由于咱们调用test()时传入了参数v,所以在函数内代码运转之前会有一个隐性的函数声明 赋值操作,var v = 1;的声明操作也会提高,但由于v=2的赋值操作更先履行,所以会输出2。以上代码等效于:

function test(v){
    var v;
    var v;
    v = 2;
    console.log(v);
    v = 1;
}
test(2); // output: 2

最终来看这段代码,运转代码输出[Function: a]而非undefined

console.log(a); // output: [Function: a]
var a;
function a(){};

调换2、3行的声明次序会得到相同成果。道理很简单,函数声明提高优先级 > 变量声明提高,无需过多解说。

对以上三个示例做总结,能够得到以下 JS 中关于提高的三条规矩:

  • 变量、函数声明操作都会提高;
  • 赋值操作不提高;
  • 函数声明操作优先级 > 变量声明优先级。

Execution Context 履行上下文

在介绍 hoisting 完成原理之前,有必要先了解 JS 的履行上下文。

ES6 的履行上下文是指运转 JS 代码时的代码环境和相关信息。履行上下文包括三个部分:

  • 词法环境(lexical environment)
  • 变量环境(variable environment)
  • this 绑定(this binding)

词法环境是一个存储标识符(变量,函数,类等)和它们的值的结构。词法环境有两个组成部分:环境记载(environment record)和外部环境引证(outer environment reference)。环境记载是一个存储当时效果域内的标识符和它们的值的目标;外部环境引证则是一个指向包含效果域的词法环境的指针。

变量环境是一个与词法环境相似的结构,但是它只存储var声明的变量。在 ES6 之前,变量环境和词法环境是相同的,但是在 ES6 中引入了let&const关键字,变量环境和词法环境也有或许不同。

this绑定是一个确认当时履行上下文中的this值的进程。this值取决于函数的调用方法,例如普通函数调用,方法调用,结构函数调用,箭头函数调用等。

this比较费事,本文中不细说。

词法环境和变量环境本质上都是一种词法效果域,都是用来存储和查找标识符(变量,函数等)的值的结构。它们的差异在于,词法环境能够跟着代码的履行而改动,而变量环境则保持不变。

JavaScript 效果域与提高

咱们能够把词法环境了解为一个栈,每当进入一个新的效果域,就会创立一个新的词法环境,并将其压入栈顶。这个新的词法环境包含了当时效果域内的标识符和它们的值,以及一个指向外部词法环境的引证。当退出当时效果域时,就会将栈顶的词法环境弹出,康复到上一个词法环境。这样,词法环境就能完成词法效果域的规矩,即内部效果域能够拜访外部效果域的标识符,但反之不行。

变量环境则是一个特别的词法环境,它只包含了用var声明的变量和函数声明。变量环境在履行上下文创立时就确认了,不会跟着代码的履行而改动。这意味着,用var声明的变量和函数声明会被提高到它们所在的履行上下文的顶部,而不受块级效果域的约束。这也是为什么在 ES6 之前,JS 只要函数效果域,而没有块级效果域的原因。

ES6 引入了letconst关键字,它们创立的标识符只存在于词法环境中,而不在变量环境中。这样,就能够完成块级效果域,以及暂时性死区(TDZ)的特性。

下面是一个比方,说明了词法环境和变量环境的差异:

// 大局代码
var a = 1; // 在大局履行上下文的变量环境和词法环境中
let b = 2; // 只在大局履行上下文的词法环境中
function foo() {
  // 进入foo函数的履行上下文
  var c = 3; // 在foo函数的履行上下文的变量环境和词法环境中
  let d = 4; // 只在foo函数的履行上下文的词法环境中
  console.log(a, b, c, d); // 1, 2, 3, 4
  if (true) {
    // 进入块级效果域
    var e = 5; // 在foo函数的履行上下文的变量环境和词法环境中
    let f = 6; // 只在块级效果域的词法环境中
    console.log(a, b, c, d, e, f); // 1, 2, 3, 4, 5, 6
  }
  // 退出块级效果域
  console.log(a, b, c, d, e); // 1, 2, 3, 4, 5
  console.log(f); // ReferenceError: f is not defined
}
// 退出foo函数的履行上下文
foo();
console.log(a, b); // 1, 2
console.log(c, d, e, f); // ReferenceError: c is not defined

到这儿应该就能了解词法环境和变量环境是什么了,假如仍是感觉疑惑,不清楚这俩环境究竟是什么,能够看看 Variable Environment vs lexical environment 这篇问答,里边解说得更具体一些。

作业原理

通过前面这么多衬托,我感觉 Hoisting 的完成原理现已比较清楚。其实解说履行上下文的时候就现已算是在解说 Hositing 作业原理了。

咱们能够把 JS 履行区分为以下几个过程,但要点放在提高操作上:

  1. 创立大局履行上下文,并将其压入履行栈。
  2. 对大局代码进行扫描,将var声明的变量添加到大局履行上下文的变量环境中,并赋值为undefined。将函数声明添加到大局履行上下文的词法环境中,并赋值为函数目标。对于letconst声明的变量,不会被提高,而是在大局履行上下文的词法环境中创立一个未初始化的绑定,直到它们被赋值为止。这便是暂时性死区(TDZ)的概念,即在变量被赋值之前,不能被拜访或运用。
  3. 开端履行大局代码,依照次序逐行履行。假如遇到函数调用,就创立一个函数履行上下文,并将其压入履行栈。
  4. 对函数代码进行扫描,将var声明的变量添加到函数履行上下文的变量环境中,并赋值为undefined。将函数声明添加到函数履行上下文的词法环境中,并赋值为函数目标。对于letconst声明的变量,相同不会被提高,而是在函数履行上下文的词法环境中创立一个未初始化的绑定,直到它们被赋值为止。
  5. 开端履行函数代码,依照次序逐行履行。假如遇到函数调用,就重复过程3和4。假如遇到return语句,就回来函数的成果,并将函数履行上下文从履行栈中弹出。
  6. 当大局代码履行结束,就将大局履行上下文从履行栈中弹出,程序结束。

流程如此,具体到代码中,把自己幻想成 JS 引擎,依照上面的履行流程剖析即可。假如感兴趣,能够试着剖析以下代码,对应的输出也现已给在每行代码后面了:

console.log(a); // undefined
console.log(b); // ReferenceError: Cannot access 'b' before initialization
console.log(c()); // 3
console.log(d()); // TypeError: d is not a function
var a = 1;
let b = 2;
function c() {
  return 3;
}
var d = function() {
  return 4;
};

补充

文中有些概念并不清楚,但直接解说又会影响连贯性,于是摘出来放在这儿。

ML-like Order

ML 是一种通用的函数式编程言语,具有可扩展的类型系统。它支撑多态类型推断,这简直消除了指定变量类型的担负,并极大地促进了代码的重用。ML 尽管没有得到广泛的运用,但它对其他言语产生了很大的影响,比方 Haskell、Rust、Scala 等。

下面是一个用 Standard ML 编写的阶乘函数的比方:

fun factorial n =
    if n = 0 then 1 else n * factorial (n-1)

这个函数必须在调用它的地方之前界说,否则会报错。

ML-like Order 是指 ML 言语中的函数界说次序,它是自下而上的,也便是说,一个函数必须在它被调用之前界说。这样的次序有时会导致一些不方便,比方前面讲到的函数相互递归的情形,ML 就需求运用特别的 fun 和 and 关键字,这种函数则会被称为互递归函数。比方判断一个自然数是奇数仍是偶数:

fun isOdd n = if n = 0 then false else isEven (n-1)
and isEven n = if n = 0 then true else isOdd (n-1)

为了防止这种状况,一些其他的言语(比方 JS)选用了函数声明提高(FDs hoisting)的机制,允许在任何地方界说函数,而不用考虑次序。

参看文章