一, 函数参数默认值中模糊的独立作用域
我在 ES6 入门学习函数拓展这一篇博客中有记录, 当函数的参数使用默认值时, 参数会在初始化过程中产生一个独立的作用域, 初始化完成作用域会消失; 如果不使用参数默认值, 不会产生这个作用域; 产生疑问是因为这段代码:
- var x = 1;
- function foo(x, y = function () {x = 2;}) {
- var x = 3;
- y();
- console.log(x);
- };
- foo();//3
- foo(4);//3
- console.log(x);//1
老实说, 关于这个独立作用域的描述十分抽象, 当我的同事对于这问题述向我提出疑问时, 我发现确实不能很好的给他解释这个问题, 原因很简单, 我也似懂非懂. 对此我做了一些测试, 并尝试去模拟实现这个作用域, 便于说服同事以及我自己.
为什么 var x=3 始终输出 3, 为什么去掉 var 后始终输出 2, 这个独立的作用域到底是怎么回事?
如果你对于这个问题了如指掌, 相关笔试题轻松解答, 这篇文章就不那么重要了; 但如果你对这个作用域跟我一样有一些疑虑, 那可以跟着我的思路来理一理, 那么本文开始.
二, ES6 带来的块级作用域
在改写这段代码前, 有必要先把块级作用域说清楚.
我们都知道, 在 ES6 之前 JavaScript 只存在全局作用域与函数作用域这两类, 更有趣的是当我们使用 var 去声明一个变量或者一个函数, 本质上是在往 Windows 对象上新增属性:
- var name = "听风是风";
- var age = 26;
- Windows.name; //'听风是风'
- Windows.age; //26
这自然是不太好的做法, 我们本想声明几个变量, 结果原本干净的 Windows 对象被弄的一团糟, 为了让变量声明与 Windows 对象不再有牵连, 也是弥补变量提升等一些缺陷, ES6 正式引入了 let 声明.
- delete Windows.name;
- let name = "听风是风";
- Windows.name; //undefined
let 还带来了一个比较重要的概念, 块级作用域, 当我们在一个花括号中使用 let 去声明一个变量, 这个花括号就是一个块级作用域, 块级作用域外无权访问这个变量.
- {
- let x = 1;
- }
- console.log(x)// 报错, x 未声明
当你在这个块级作用域外层再次声明 x 时, 外层作用域中的 x 与块级作用域中的 x 就是不同的两个 x 了, 互不影响:
- let x = 2;
- {
- let x = 1;
- console.log(x); //1
- }
- console.log(x) //2
- var y = 1;
- {
- let y = 2
- }
- console.log(y) //1
但你不可以在同层作用域中使用 let 声明一个变量后再次 var 或者再次 let 相同变量:
- let x = 1;
- var x; // 报错, x 已声明
- let y = 1;
- let y; // 报错, y 已声明
- var z = 1;
- let z; // 报错, z 已声明
块级作用域依旧存在作用域链, 并不是说你变成了块级作用域就六亲不认了, 谁也别想用我块级里面的变量:
- {
- // 父作用域
- let x = 1;
- let y = 1;
- {
- // 子作用域
- console.log(x); //1
- x = 2;
- let y = 2;
- console.log(y); //2
- }
- console.log(x); //2
- console.log(y);//1
- }
上述代码中子作用域中没 let x, 父作用域还是允许子作用域中访问修改自己的 x; 父子作用域中都 let y, 那两个作用域中的 y 就是完全不相关的变量.
最后一点, 很多概念都说, 外 (上) 层作用域是无权访问块级作用域的变量, 这句话其实有歧义, 准确来说, 是无权访问块级作用域中使用了 let 的变量, 我的同事就误会了这点:
- {
- let x = 1;
- var y = 2;
- z = 3;
- }
- console.log(y);//2
- console.log(z);//3
- console.log(x);// 报错, x 未定义
let x 确实产生了一个块级作用域, 但你只能限制外层访问产生块级作用域的 x, 我 y 用的 var,z 直接就全局, 你们抓周树人跟我鲁迅有什么关系? 这点千万要理解清楚.
介绍 let 可能花了点时间, 明明是介绍函数参数默认值的作用域, 怎么聊到 let 了. 这是因为我在给同事说我的推测时, 我发现他对于 let 存在部分误解, 所以在理解我的思路上也花了一些时间.
三, 关于函数参数默认值独立作用域的推测与我的代码模拟思路
1. 改写函数参数
我们都知道, 函数的参数其实等同于在函数内部声明了一个局部变量, 只是这个变量在函数调用时能与传递的参数一一对应进行赋值:
- function fn(x) {
- console.log(x);
- };
- fn(1);
- // 等用于
- function fn() {
- // 函数内部声明了一个变量, 传递的值会赋予给它
- var x = 1;
- };
- fn()
所以第一步, 我将文章开头那段代码中的函数进行改写, 将形参改写进函数内部:
- function foo() {
- var x;
- var y = function () {
- x = 2;
- };
- var x = 3;
- y();
- console.log(x);
- };
2. 模拟形参的独立作用域
改写后有个问题, 此时形参与函数内部代码处于同一层作用域, 这与我们得知的概念不太相符, 概念传达的意思是, 函数参数使用默认值, 会拥有独立的作用域, 所以我们用一个花括号将函数内代码隔离起来:
- function foo() {
- var x;
- var y = function () {
- x = 2;
- };
- {
- var x = 3;
- y();
- console.log(x);
- }
- };
其次, 由文章开头的代码结果我们已经得知, var x =3 这一行代码, 如果带了 var , 函数体内 x 变量就与参数内的 x 互不影响了, 永远输出 3; 如果把 var 去掉呢, 就能继承并修改参数中的变量 x 了, 此时 x 始终输出 2, 这个效果可以自己复制文章开头的原代码测试.
我在上文介绍 let 块级作用域时有提到块级作用域也是有作用域链的; 父子块级作用域, 如果子作用域自己 let 一个父作用域已声明的变量, 那么两者就互不影响, 如果子不声明这个变量, 还是可以继承使用和修改父作用域的此变量. 这个情况不就是示例代码的除去 var 和不除去 var 效果吗, 只是我们还缺个块级作用域才能满足这个条件, 所以我将 var x =3 前面的 var 修改成了 let, 整个代码修改完毕:
- function foo() {
- // 父作用域
- var x;
- var y = function () {
- x = 2;
- };
- {
- // 子块级作用域
- let x = 3;
- y();
- console.log(x);
- }
- };
你肯定要问, 我为什么要把 var 改为 let? 并不是我根据结论强行倒推理, 我在断点时发现了一个问题, 带 var 的情况:
注意观察右边 Scope 的变化, 当断点跑到 var x = 3 时, 显示在 block(块级作用域)下 x 是 undefined, 然后被赋值成了 3, 最后断点跑到 console 时, 也是输出了 block 作用域下的 x, 而且在 block 作用域和 local 作用域中分别存在 2 个变量 x, 如下图:
函数内部明明没用 let, 也就是说, 函数执行时, 隐性创建了一个块级作用域包裹住了函数体内代码. 当我把 var 去掉时, 再看截图:
可以看到, 当去掉 var 时, 整个代码执行完, 全程都不存在 block 作用域, 而且从头到尾都只有 local 作用域下的一个 x.
由此我推断 var 是产生块级作用域的原因, 所以将 x 变量前的 var 改为了 let.
3. 模拟代码测试阶段:
我们最终修改后的代码就是这样:
- var x = 1;
- function foo() {
- var x;
- var y = function () {
- x = 2;
- };
- {
- let x = 3;
- y();
- console.log(x);
- }
- };
- foo(); //3
- foo(4); //3
- console.log(x); //1
带 var 分别输出 3 3 1, 我们把 var 改成了 let, 也是输出 3 3 1. 去 var 输出 2 2 1, 我们把 let 去掉也是输出 2 2 1, 效果一模一样.
我们对比了修改前后, 代码执行时 scope 的变化, 是一模一样的, 可以说模拟还算成功.
4. 最终模拟版本
然后我又发现了一个改写的大问题:
- function fn(x=x){
- };
- fn();// 报错
这段代码是会报错的, 它会提示你, x 未声明就使用了, 这是 let 声明常见的错误. 但是如果按照我前面说的将形参移到函数体内用 var 声明, 那就不会报错了:
- function fn(){
- var x = x;
- };
- fn()// 不报错
- function fn(){
- let x = x;
- };
- fn()// 报错
所以我上面的初始代码改写后的最终版本是这样:
- var x = 1;
- function foo() {
- let x;
- let y = function () {
- x = 2;
- };
- {
- let x = 3;
- y();
- console.log(x);
- }
- };
- foo(); //3
- foo(4); //3
- console.log(x); //1
这是执行效果图, 仔细观察可以发现 scope 变化以及执行结果与没改之前一样, 只是我觉得这样改写更为严谨.
四, 最终结论与个人推测
所以我得到的最终结论是, 并不是函数形参使用了默认值会产生独立的作用域, 而是函数形参使用了默认值时, 会让函数体内的 var 声明隐性产生一个块级作用域, 从而变相导致了函数参数所在作用域被隔离. 不使用参数默认值或函数体内不使用 var 声明不会产生此作用域.
我的改写模拟思路是这样:
第一步, 形参如果用了默认值, 将形参移到函数体内并用 let 声明它们;
第二步, 如果此时没报错, 再用花括号将原本的函数体代码包裹起来, 再将花括号中的 var 声明修改成 let 声明.
- function fn(x, y = x) {
- let x = 1;
- console.log(x);
- };
- // 第一步:
- function fn() {
- let x;
- let y = x;
- let x = 1;
- console.log(x);
- };
比如上述这段代码, 形参移动到函数体内其实你就已经会报错了, x 变量被反复申明了, 所以就没必要再用花括号包裹执行体代码了.
我大概总结出了以下几个规律(可以按照我的思路改写, 方便理解):
1. 当函数形参声明了 x, 函数体内不能使用 let 再次声明 x, 否则会报错, 原因参照函数改写步骤 1.
- var x = 1;
- function fn(x){
- let x =1;// 报错
- };
- fn();
2. 当函数形参声明了 x, 函数体内再次使用 var 声明 x 时, 函数体内会隐性创建一个块级作用域, 这个作用域会包裹执行体代码, 也变相导致参数有了一个独立的作用域, 此时两个 x 互不影响, 原因参照函数改写步骤 2.
- function fn(x =1){
- var x =2;
- console.log(x);//2
- };
- fn();
3. 当函数形参声明了 x, 函数体内未使用 var 或者 let 去声明 x, 函数体内可以直接修改和使用参数 x 的, 此时共用的是同一个变量 x, 块级作用域也存在作用域链.
- var x =2;
- function fn(y = x){
- x =3;
- console.log(y);//2
- };
- fn();
- x//3
4. 当函数形参未声明 x, 但是参数内又有参数默认值使用了 x, 此时会从全局作用域继承 x.
- var x = 1;
- function fn(y=x){
- console.log(y);//1
- };
- fn();
那么到这里, 我大概模拟了函数参数默认值时产生独立作用域的过程, 同时按照我的理解去解释了它. 也许我的推测与底层代码实现有所偏差, 但是这个模拟过程能够很直观的去推测正确的执行结果.
我写这篇文章也是为了两个目的, 第一如果在面试中遇到, 我能更好的解释它, 而不是似懂非懂; 其次, 在日常开发中使用函数参数默认值时, 我能更清晰的写出符合我预期结果的代码, 此时的你应该也能做到这两点了.
本文中所有的代码都是可测的, 若有问题, 或者更好的推测欢迎留言讨论.
那么就写到这里了, 端午节快乐!
来源: https://www.cnblogs.com/echolun/p/10983436.html