函数参数默认值的作用域问题

2,846

本篇是对ECMAScript 6 入门函数参数默认值一章中的作用域一节的学习总结,并且寻找了一些相关问题,同时还注意到 Babel 的一个转译问题。

参数默认值与作用域

本节内容参考:

在 ECMA-262 中的9.2.12 FunctionDeclarationInstantiation(func, argumentsList)章节有相关说明。

当解析一个 JS 函数执行上下文的时候,会创建一个新的Environment Record(之后简称 ER),并且绑定这个 ER 中每个实例化了的形参(这里的实例化应该是指在执行函数的时候,形参才能有值,有值之后代表实例化了)。同时在函数体中的每个声明也被实例化了。

  • 形参没有任何默认值的情况下,会在与参数相同的 ER 中实例化函数体声明 。也就是说函数体内的声明将与形参在同一 ER 中实例化。

    • 函数有形参,形参会被添加到函数的作用域中,并且形参不会被重新定义(用var 声明与形参同名的变量会被忽略)

      function fun(arg1, arg2) {
        var arg1; // 声明被忽略
        var arg2 = "hello"; // var arg2 声明被忽略,arg2 = "hello" 被执行
        console.log(arg1, arg2);
      }
      fun(1, 2); // 1 "hello"
      
    • ES6 的 letconst 会因为作用域内重复声明而报错

      function fun(arg) {
        let arg;
      }
      fun(); // SyntaxError: Identifier 'arg' has already been declared
      
    • 多说一种情况,如果函数内声明一个和形参同名的函数

      ES6 之前,函数的执行可以分为 3 个阶段(ES6 之后情况变得复杂,尚未了解):

      • 准备。包括形参变量创建函数体内的预解析(var声明和函数声明提升,也就是 Hoisting)函数声明创建
      • 装载,也就是填充数据。装载顺序为 函数参数 > 函数声明,而在函数声明装载时,如果函数体内有个和参数名相同的函数声明,那么这个函数就会覆盖形参
      • 执行,略
      function fun(arg) {
        console.log(arg);
        function arg() {
          //...
        }
      }
      fun(1); // [Function: arg]
      
      • 准备阶段,创建形参变量 arg,函数体预解析,创建函数声明
      • 装载阶段,先将形参的值 1 赋值给argarg = 1,函数体内存在一个函数声明function arg(){},所以将函数申明赋值给 arg,也就是 arg = function(){}

PS:上面几种情况只是通过表现和结果进行总结,并没有严格按照规范进行分析。如有不对,请不吝赐教。

  • 在执行函数时,如果函数形参存在默认值,第二个 ER 会被建立,这个作用域是针对函数体内的声明,所以【函数体内的声明】与【形参和本身的函数声明】不在同一作用域

    因此一个定义在全局环境的、带有默认参数的函数声明,在运行时共产生至少 3 个作用域,如下图:

    参数默认值存在时的作用域

    • 形参的 ER 中的变量只能读取形参 ER 中的变量或者函数外的变量,而函数体内的变量可以读取函数体内、形参以及外部的变量

    • 函数体内可以修改 ER 里定义的形参的值,但是不能重新定义形参

      独立作用域

      • var 声明的变量显示为 Block,并不是代表它是块级作用域,而仅仅是为了区分形参的 ER 和函数体的 ER

一个疑问

var x = 20;
function fun(x = 1) {
  debugger;
  var x = 10;
  console.log(x);
}
fun(2);

scope

按我的理解,既然形参作用域和函数体作用域不共享,那么函数体作用域(图中 Block)中使用 var 声明的变量为什么会有一个初始值,并且和形参实例化的值相同?

希望有前辈可以答疑解惑。

分析几个小例子:

  • 参数形成单独作用域

    let x = 1;
    function fun(x, y = x) {
      console.log(y);
    }
    fun(2);
    
    • 参数 y 的默认值等于变量 x
    • 调用函数 fun 时,参数形成一个单独的作用域
    • 在这个作用域中,默认值变量指向第一个参数 x,而不是全局环境的 x
  • 有默认值的形参创建的作用域也会沿着作用域链查找变量

    function fun(y = x) {
      let x = 2;
      console.log(y);
    }
    fun(); // ReferenceError: x is not defined
    
    • 调用函数 fun 时,参数 y=x 形成一个单独的作用域
    • 在这个作用域里,没有定义 x,所以沿着作用域链在全局寻找变量 x
    • 由于全局环境中也没有定义变量 x,所以会报错
    • 函数调用时,函数体内部的局部变量 x 影响不到参数默认值变量 x
  • 避免暂时性死区(TDZ

    let x = 1;
    function fun(x = x) {}
    fun(); // Uncaught ReferenceError: x is not defined
    
    • 参数 x = x 形成一个单独作用域
    • 在这个作用域中,执行的是 let x = x,这就是形成暂时性死区的原因

如果参数的默认值是一个函数,该函数的作用域也遵守上面的规则

let foo = "outer";
function bar(func = () => foo) {
  let foo = "inner";
  console.log(func());
}
bar(); // outer
  • 函数 bar 的参数 func 的默认值是一个匿名函数,返回值为变量 foo
  • 形参形成的单独作用域里,并没有定义变量 foo,所以指向外层的全局变量 foo

一个 Babel 问题

本节内容参考:

在阮一峰老师的 ECMAScript 6 入门中,有这样一个例子,本身其实是对复杂的形参默认值的展示,但是发现其经过 Babel 转译后的表现与转译前不同。

  • ES6

    var x = 1;
    function foo(
      x,
      y = function() {
        x = 2;
      }
    ) {
      var x = 3;
      y();
      console.log(x);
    }
    
    foo(); // 3
    
    • 由于参数有默认值,所以函数的参数形成一个单独的作用域
    • y 的默认值是一个匿名函数,函数内的变量 x 指向同一作用域的第一个参数 x
    • 函数体内也声明了一个内部变量 x,该变量与第一个参数 x 由于不是同一作用域,所以不是同一个变量
    • 执行 y 后,内部变量和外部变量 x 的值都没变
  • 转译成 ES5 后(Babel@7.3.0)发现与原来的结果不同了。原因是转译后形参和函数体的作用域没有做隔离

    "use strict";
    var x = 1;
    function foo(x) {
      var y =
        arguments.length > 1 && arguments[1] !== undefined
          ? arguments[1]
          : function() {
              x = 2;
            };
      var x = 3;
      y();
      console.log(x);
    }
    foo(); // 2
    
  • 基于 Babel 基础上修改

    function foo(x) {
      var y =
        arguments.length > 1 && arguments[1] !== undefined
          ? arguments[1]
          : function() {
              x = 2;
            };
      return function() {
        var x = 3;
        y();
        console.log(x);
      }.call(this, x, y);
    }
    

也许 Babel 出于某些考虑并没有修改,但是从结果上看,转译的代码与原来的结果的确不一致了。

总结

其实在分析这个问题的时候,自己还是很吃力,并不能从 ECMAScript® 2015 Language Specification 中分析原因,也就是无法从根本上解释完整的运行原理。更多的是从其他人的理解中参悟。

这个问题其实在业务场景中很少出现,研究意义大于实用意义。

参考