这是我参与8月更文挑战的第9天,活动详情查看8月更文挑战
前言
作用域是个语言无关的概念,你要接触过Lisp或者Scheme等语言,应该对这个概念会非常熟悉。本文将带着大家了解词法作用域和动态作用域是什么,讨论下 JavaScript 的词法作用域,那么现在开始吧。
词法作用域和动态作用域是啥?
作用域,我懂,那“词法”、“动态”这个陌生的前缀是啥玩意呢?
不着急,下面我们慢慢讲解:
事实上,当我们在 JavaScript 语言的范畴里讨论“作用域”这个概念的时候,确实不需要区分它是“词法”还是“动态”,因为我们 JS 的作用域遵循的就是词法作用域模型。
在 JavaScript 中,说“词法作用域”这个概念的时候,其实就是在说 JS 的作用域。
站在语言的层面来看,作用域其实有两种主要的工作模型:
- 词法作用域:也称为静态作用域。这是最普遍的一种作用域模型,也是我们学习的重点
- 动态作用域:相对“冷门”,但确实有一些语言采纳的是动态作用域,如:Bash 脚本、Perl 等
其实作为程序员,不应该只局限于一门编程语言,因为你多了解几本编程语言,能够帮助你学习语言的不一样性,可以了解更多知识。
学习一门新语言,了解不同语言设计、解决问题的不同思路。
词法作用域
举个例子,我们看一下:
let name = '追梦玩家';
function showName() {
console.log(name);
}
function changeName() {
let name = '砖家';
showName();
}
changeName();
// 输出结果是?
输出结果是 "追梦玩家"。这是因为 JS 采取的是词法(静态)作用域,我们来分析下运行这个代码,定位变量的过程:
- showName 函数的函数作用域里面,使用到变量 name,于是会先从自身查找,看是否存在局部变量 name
- 发现当前函数作用域中没有变量 name,于是会根据书写的位置,查找上层作用域(全局作用域),找到了全局变量 name,name 的值是"追梦玩家",所以会打印出"追梦玩家"。
作用域关系示意如下:
运行时的作用域链关系如下:
我们这里作用域的划分,是在书写的过程中(上面例子中也就是在函数定义的时候,块级作用域,也是在代码块定义的时候),根据你把它写在哪个位置来决定的。
像这样划分出来的作用域,遵循的就是词法作用域模型。
简而言之,就是 JavaScript 采用的是词法作用域,函数的作用域,在函数定义的时候就决定了。
动态作用域
let name = '追梦玩家';
function showName() {
console.log(name);
}
function changeName() {
let name = '砖家';
showName();
}
changeName();
// 输出结果是?
上面的代码,在动态作用域机制下,是怎样的呢?
假设 JavaScipt 采用的是动态作用域,我们分析下执行过程:
- showName 函数的函数作用域里面,使用到变量 name,于是会先从自身查找,看是否存在局部变量 name
- 没找到,于是沿着函数调用栈,在调用了 showName 的地方继续找 name。因为 showName 函数,是在 changeName 函数里面调用的,刚好,changeName 里面有一个变量 name,于是这个 name 就会被引用到 showName 里去。
这种情况下,作用域链关系示意如下:
所以如果是动态作用域,那么上面那段代码运行的结果就会是 "砖家" 了。
那我怎么验证动态作用域下,运行的结果会是"砖家" 呢?
我们可以将上面的 js 代码,使用 bash 语言重写一下。因为 bash 就是动态作用域。
name='追梦玩家';
function showName() {
echo $name;
}
function changeName() {
local name='砖家';
showName;
}
changeName;
将上面的脚本存储到 scope.bash,然后进入到对应的目录,运行命令 bash ./scope.bash,看看打印的值是什么?大家可以尝试一下
结果,请看图:
我们总结一下 词法作用域和动态作用域的区别其实在于划分作用域的时机:
- 词法作用域:在代码书写的时候完成划分,作用域链沿着它定义的位置往外延伸
- 动态作用域:在代码运行时完成划分,作用域链沿着它的调用栈往外延伸
另外一种说法:
- 词法作用域:函数的作用域在函数定义的时候就决定了。
- 动态作用域,函数的作用域是在函数调用的时候才决定的。
当然那种更好理解,就记哪种吧。
欺骗/修改词法作用域
不知道大家有没有听说过?如何“欺骗”词法作用域? 这样的问题。
其实“欺骗”就是改变的意思。上面不是说,JS 是在代码书写阶段,就已经对作用域进行划分吗?那我们有什么办法,在运行过程中,把你划分好的作用域修改掉。我们请出 eval 和 with,仅仅是为了拓宽大家的知识面,感兴趣,可以继续往下看:
Tips:不要用 with 和 eval 写代码
事实上, with 和 eval 因为其恼人的副作用(比如对语言性能的拖累、比如我们上面 “横空出世” 的全局变量等等),一直是我们 JS 程序员眼中的过街老鼠。
eval
eval命令的作用是,将字符串当作语句执行。
eval('var a = 1;');
console.log(a); // 1
上面代码将字符串当做 JS 语句运行,生成了变量 a,然后打印出 1。 其实上面代码就相当于
var a = 1;
console.log(a); // 1
我们再来看下这个例子:
function showName(str) {
eval(str);
// 执行了 eval 语句,其实就相当于执行下面的代码
// var name = "砖家";
console.log(name);
}
var name = '追梦玩家';
var str = 'var name = "砖家"';
showName(str); // "砖家"
当我们输出 name 的时候,因为执行了 eval 语句,函数作用域内,已经存在 name 变量,所以就不会继续往上层作用域(全局作用域)去寻找,直接输出 "砖家"。
就是因为执行 eval(str); 的时候,修改了作用域的内容,也就是成功"修改"了词法作用域规则约束下在书写阶段就划分好的作用域。
具体可以看下图,注意执行 eval 前后的变化: 重点:eval 执行后的,白色区域的内容
with
with关键字可以将一个没有或者有多个属性的对象处理为一个完全隔离的词法作用域,对象的属性也会被处理为定义在这个词法作用域中的词法标识符。
with 的典型用法如下:
var obj = { name: '追梦玩家' };
console.log(obj.name); // "追梦玩家"
with(obj){
name = '砖家';
}
console.log(obj.name); // "砖家"
修改成这样的代码,输出结果呢?
var obj = { name: '追梦玩家' };
console.log(obj.name); // "追梦玩家"
with(obj){
age = 18;
}
console.log(obj.age); // undefined
console.log(age); // 18
为什么打印 age,会输出 18 呢?是因为当对象中没有的属性,在 with 创建的作用域中被赋值时,并不会给对象添加属性。但是,被赋值的标识符,会泄露到全局作用域中,所以,打印 age 的结果是 18。
关于 with 更深入的知识,可以查看参考文章。
参考
文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你或者喜欢,欢迎点赞和关注。