本篇是对ECMAScript 6 入门函数参数默认值一章中的作用域一节的学习总结,并且寻找了一些相关问题,同时还注意到 Babel 的一个转译问题。
参数默认值与作用域
本节内容参考:
- 关于 es6 函数参数默认值的理解问题?中dablwow80的回答
- ECMAScript 6 入门
- 参数默认值引起的第三作用域
- ECMAScript® 2015 Language Specification
- js 变量声明 函数声明 变量赋值的实现机制疑惑? - 极光的回答 - 知乎
- 函数执行顺序
在 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 的
let
和const
会因为作用域内重复声明而报错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 赋值给
arg
,arg = 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);
按我的理解,既然形参作用域和函数体作用域不共享,那么函数体作用域(图中 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 中分析原因,也就是无法从根本上解释完整的运行原理。更多的是从其他人的理解中参悟。
这个问题其实在业务场景中很少出现,研究意义大于实用意义。