Skip to content

两段关于 JavaScript 块级作用域的代码解析

🏷️ JavaScript

之前在掘金看到两段奇怪的 JS 代码,打印的结果匪夷所思。今天正好整理了下,以作备忘。能力有限,如理解有误,望指正。

代码1

javascript
var a = 0;
console.log("1 a:" + a); // 1 a:0
if (true) {
    a = 1;
    function a() { };
    a = 5;
    console.log("2 a:" + a); // 2 a:5
}
console.log("3 a:" + a) // 3 a:1

比较难以理解的是为什么最后打印时 a 的值是 1 而不是 5

参考了如下几篇文章:

个人理解见代码中的备注:

javascript
// 块中的函数定义会在全局定义一个 var a ,但是并不会赋值。
var a = 0; // 这里再次在全局定义了一个 var a ,他会覆盖块级函数定义的 a 。如果没有这个定义,此时 a 的值为 undefined
console.log("1 a:" + a); // 1 a:0
if (true) {
    // 此时 a 的类型是 function,这个是由于函数提升,块级作用域中的函数定义会被提升到块级作用域的顶部
    a = 1; // 将块级作用域中的 a 赋值为 1 (覆盖了之前的 a() 函数)
    function a() { }; // 执行到这里时,会将块级作用域的变量绑定到全局作用域的同名变量。相当于两个变量指向同一个地址
    a = 5; // 继续修改块级作用域的变量 a (如果 a 是一个引用类型,此时全局作用域的 a
 也会同步修改。由于 5 是基本类型,相当于做了一次解绑,两个变量不再指向同一个地址)
    console.log("2 a:" + a); // 2 a:5
}
console.log("3 a:" + a) // 3 a:1

代码2

javascript
var b = 10;
(function b() {
    b = 20;
    console.log(b); // f b() { ... }
})();

下面是摘自知乎的回答:

  1. 函数表达式与函数声明不同,函数名只在该函数内部有效,并且此绑定是常量绑定。
  2. 对于一个常量进行赋值,在 strict 模式下会报错,非 strict 模式下静默失败。
  3. IIFE 中的函数是函数表达式,而不是函数声明。

关于具名的函数表达式特性详见后面的 附1 ECMA-262-3 中关于 Named Function Expression 的说明,简单来说就是:具名函数表达式的函数名是保存在一个 辅助特殊对象 里的,并且是 不可删除的只读的 ,其只在当前作用域可见,在其父作用域中不可见。

个人理解见备注:

javascript
var b = 10;
(function b() {
    b = 20; // 这里会执行,但是并不会修改 b 的值,因为此时 b 是只读的。相当于忽略了 b 的赋值。
    // 比较有意思的是,虽然这一步不会修改 b 的值,但是这个表达式是有返回值的(返回值为 20)。
    // 也就是说如果执行 a = b = 20; 此时 a 的值为 20 。
    console.log(b); // f b() { ... }
})();

附1. ECMA-262-3 in detail. Chapter 5. Functions.

原文:ECMA-262-3 in detail. Chapter 5. Functions.

Feature of Named Function Expression (NFE)

In case FE has a name (named function expression, in abbreviated form NFE) one important feature arises. As we know from definition (and as we saw in the examples above) function expressions do not influence variable object of a context (this means that it’s impossible to call them by name before or after their definition). However, FE can call itself by name in the recursive call:

javascript
(function foo(bar) {

  if (bar) {
    return;
  }

  foo(true); // "foo" name is available

})();

// but from the outside, correctly, is not

foo(); // "foo" is not defined

Where is the name “foo” stored? In the activation object of foo? No, since nobody has defined any “foo” name inside foo function. In the parent variable object of a context which creates foo? Also not, remember the definition — FE does not influence the VO — what is exactly we see when calling foo from the outside. Where then?

Here’s how it works: when the interpreter at the code execution stage meets named FE, before creating FE, it creates auxiliary special object and adds it in front of the current scope chain. Then it creates FE itself at which stage the function gets the [[Scope]] property (as we know from the Chapter 4. Scope chain) — the scope chain of the context which created the function (i.e. in [[Scope]] there is that special object). After that, the name of FE is added to the special object as unique property; value of this property is the reference to the FE. And the last action is removing that special object from the parent scope chain. Let’s see this algorithm on the pseudo-code:

javascript
specialObject = {};

Scope = specialObject + Scope;

foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}

delete Scope[0]; // remove specialObject from the front of scope chain

Thus, from the outside this function name is not available (since it is not present in parent scope), but special object which has been saved in [[Scope]] of a function and there this name is available.

It is necessary to note however, that some implementations, for example Rhino, save this optional name not in the special object but in the activation object of the FE. Implementation from Microsoft — JScript, completely breaking FE rules, keeps this name in the parent variables object and the function becomes available outside.


下面是使用 ChatGPT 翻译的版本(如果感觉难理解可以看看 汤姆大叔翻译的版本):

命名函数表达式(NFE)的特性

如果函数表达式具有名称(即命名函数表达式,简称为NFE),则会出现一个重要的特性。正如我们从定义中知道的(并且在上面的例子中也看到了),函数表达式不会影响上下文的变量对象(这意味着在其定义之前或之后无法通过名称调用它们)。然而,在递归调用中,函数表达式可以通过名称调用自身:

javascript
(function foo(bar) {

  if (bar) {
    return;
  }

  foo(true); // "foo" 名称可用

})();

// 但是从外部来看,是无效的

foo(); // "foo" 未定义

“foo” 名称存储在哪里?在 foo 的激活对象中吗?不是的,因为在 foo 函数内没有定义任何 “foo” 名称。在创建 foo 的上下文的父变量对象中吗?也不是,记住定义-函数表达式不会影响变量对象-这正是我们从外部调用 foo 时所看到的情况。那么在哪里呢?

工作原理如下:当代码执行阶段的解释器遇到命名函数表达式时,在创建函数表达式之前,它会创建 辅助特殊对象将其添加到当前作用域链的前面 。然后,它在函数表达式本身上创建函数,并在此阶段给函数赋予 [[Scope]] 属性(正如我们从 第4章. 作用域链 所知)- 这是创建该函数的上下文的作用域链(也就是 [[Scope]] 中包含该特殊对象)。之后,函数的名称被添加到特殊对象 作为唯一属性;该属性的值是对函数的引用。最后一步是从父作用域链中删除该特殊对象。让我们用伪代码来看这个算法:

javascript
specialObject = {};

Scope = specialObject + Scope;

foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}

delete Scope[0]; // 从作用域链的前面删除特殊对象

因此,从外部来看,这个函数名是 不可用的(因为它不在父作用域中),但是保存在函数的 [[Scope]] 中的特殊对象中,该名称在那里是 可用的

值得注意的是,某些实现(例如 Rhino)将此可选名称保存在 函数表达式的激活对象 而不是特殊对象中。来自 Microsoft 的 JScript 实现完全违反了函数表达式的规则,将此名称保存在 父变量对象 中,从而使该函数在外部可用。