JavaScript-作用域与提升

2024-02-26 javascript

本文编写的 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 "$x\n";
}
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比拟麻烦,本文中不细说。

词法环境和变量环境实质上都是一种词法作用域,都是用来存储和查找标识符(变量,函数等)的值的构造。它们的区别在于,词法环境能够随着代码的执行而扭转,而变量环境则放弃不变。

咱们能够把词法环境了解为一个栈,每当进入一个新的作用域,就会创立一个新的词法环境,并将其压入栈顶。这个新的词法环境蕴含了以后作用域内的标识符和它们的值,以及一个指向内部词法环境的援用。当退出以后作用域时,就会将栈顶的词法环境弹出,复原到上一个词法环境。这样,词法环境就能实现词法作用域的规定,即外部作用域能够拜访内部作用域的标识符,但反之不行。

变量环境则是一个非凡的词法环境,它只蕴含了用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)的机制,容许在任何中央定义函数,而不必思考程序。

参阅文章

  • 解读ECMAScript[1]——执行环境、作用域及闭包,by Eric Zhang
  • 详解JavaScript作用域和作用域链,by Rockky
  • 所有的函式都是閉包:談 JS 中的作用域與 Closure,by Huli
  • 我晓得你懂 hoisting,可是你理解到多深,by Huli
  • ECMAScript® 2015 Language Specification

相关文章