# 前言
最近被一道面试题给难住了,其实就是说不清楚为什么是这个答案,有时候可能屏幕前的你,也会有这个疑惑,所以打算来补一补基础-作用域。
先上题目:
var Fn = function () {
console.log(Fn);
}
Fn();
var obj = {
fn2 : function () {
console.log(fn2);
}
}
obj.fn2();
我的答案认为两个都是打印Function,其实基础扎实的小伙伴估计明白我错哪了。
话不多说,开始我们的正题吧🤭
# 作用域
# 什么是作用域
- 任何语言都有作用域的概念,那有些语言作用域是动态的,有些语言作用域是静态的,我个人理解JavaScript作用域是静态的,为什么这么说,下面我会说明白的。
- 作用域可以理解成:定义了一组明确的规则,它定义如何在某些位置存储变量,以及如何在稍后找到这些变量。
那么,就有人问了,作用域规则在哪里,如何被设置呢?
官方给出解释:点这里
那么我在这里就不咬文嚼字了,那么我们要探究的就是静态的问题了🤭
# 静态作用域与动态作用域
因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。
而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。
让我们认真看个例子就能明白之间的区别:
var x = 10;
function fn() {
console.log(x);
}
fn()
function show(fun) {
var x = 20;
fun()
}
show(fn);
假设JavaScript采用静态作用域,让我们分析下执行过程:
执行fn函数,先从fn函数内部查找是否有局部变量x,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。
假设JavaScript采用静态作用域,让我们分析下执行过程:
执行 fn函数,依然是从 fn 函数内部查找是否有局部变量 x。如果没有,就从调用函数的作用域,也就是 show函数内部查找 x变量,所以结果会打印 2。
实际JavaScript打印的结果就是1,从结果上说明JavaScript是静态作用域。
为了更好的理解,通过画一张简单图来理解静态作用域:
这样子就很好理解这个关系了,Fn函数在自己的作用域中找变量x,根据变量查找规则,如果没有的话,会去上一级的作用域查找,也就是全局作用域,看是否存在变量x,有的话就取这个值,没有的话就返回undefined。
**一旦找到第一个匹配,作用域查询就停止了。**相同的标识符名称可以在嵌套作用域的多个层中被指定,这称为“遮蔽(shadowing)”(内部的标识符“遮蔽”了外部的标识符)。
上述这个查询的过程,叫做作用域查询,它总是从当前被执行的最内侧的作用域开始,向外/向上不断查找,直到第一个匹配才停止。
# 作用域分类
# 全局作用域
在代码任何地方都能访问到的对象拥有全局作用域,更深入的了解可以结合全局执行上下文。比如: JavaScript的全局对象 函数 变量都能在全局访问到。
# 3种情形会拥有全局作用域
# 最外层函数以及最外层定义的变量属于全局作用域
var demo = 1; //全局变量
let fn = () => {
alert(demo)
let inner = () => alert(demo);
}
fn(); //1
inner() //ReferenceError
# 在任何位置不使用var声明的变量属于全局作用域
var demo = 1; //全局变量
let fn = () => {
demo1 = '未使用var定义'
alert(demo)
let inner = () => alert(demo1);
}
fn(); //1
console.log(window.demo1); //未使用var定义
# 所以window对象的属性属于全局作用域
# 局部作用域/函数作用域
和全局作用域相反,函数作用域一般只在函数的代码片段内可访问到,外部不能进行变量访问。在函数内部定义的变量存在于函数作用域中,其生命周期随着函数的执行结束而结束。例如:
let name = '李四'
let getName = () => {
var name = '张三';
alert(name); //张三
}
console.log(name); //李四
# 块级作用域
在ES6中提出块级作用域概念,它的用途就是:变量的声明应该距离使用的地方越近越好。并最大限度的本地化。避免污染。
块作用域由 { } 包括,let const可以形成块级作用域,也就是俗称的暂时性死区。具体的在这里就不详细的介绍了,感兴趣的可以了解下之前的文章-JavaScript执行上下文-执行栈 这里面讲了为什么let const 会存在暂时性死区,原理是什么?
# 动态作用域
与词法作用域不同于在定义时确定,动态作用域在执行时确定,其生存周期到代码片段执行为止。动态变量存在于动态作用域中,任何给定的绑定的值,在确定调用其函数之前,都是不可知的。
从某种程度上来说,这会修改作用域,(也就是欺骗)词法作用域。在你的代码中建议不要使用它们,这是因为在某些方面: 欺骗词法作用域会导致更低下的性能。
# eval
JavaScript中的eval(..)
函数接收一个字符串作为参数值,并将这个字符串的内容看作是好像它已经被实际编写在程序的那个位置上。
在eval(..)
被执行的后续代码行中,引擎 将不会“知道”或“关心”前面的代码是被动态翻译的,而且因此修改了词法作用域环境。引擎 将会像它一直做的那样,简单地进行词法作用域查询。
考虑下面代码:
var b = 2;
function demo(str, a) {
eval(str); // 欺骗词法作用域
console.log(a, b);
}
demo("var b = 12;", 1); // 1, 12
在eval(..)
调用的位置上,字符串"var b = 12"
被看作是一直就存在第2行的代码。因为这个代码恰巧声明了一个新的变量b
,它就修改了现存的demo(..)
的词法作用域。事实上,就像上面提到的那样,这个代码实际上在demo(..)
内部创建了变量b
,它遮蔽了声明在外部(全局)作用域中的b
。
当console.log(..)
调用发生时,它会在demo(..)
的作用域中找到a
和b
,而且绝不会找到外部的b
。这样,我们就打印出"1, 12"而不是一般情况下的"1, 2"。
假设:eval(..)
执行的代码字符串包含一个或多个声明(变量或函数)的话,这个动作就会修改这个eval(..)
所在的词法作用域。技术上讲,eval(..)
可以通过种种技巧(超出了我们这里的讨论范围)被“间接”调用,而使它在全局作用域的上下文中执行,如此修改全局作用域。但不论那种情况,eval(..)
都可以在运行时修改一个编写时的词法作用域。
注意: 当eval(..)
被用于一个操作它自己的词法作用域的strict模式程序时,在eval(..)
内部做出的声明不会实际上修改包围它的作用域。
var b = 2;
function demo(str, a) {
'use strict'
eval(str); // 欺骗词法作用域不生效
console.log(a, b);
}
demo("var b = 12;", 1); // 1, 2
在JavaScript中还有其他的工具拥有与eval(..)
非常类似的效果。setTimeout(..)
和setInterval(..)
可以 为它们各自的第一个参数值接收一个字符串,其内容将会被eval
为一个动态生成的函数的代码。这种老旧的,遗产行为早就被废弃了。别这么做!
new Function(..)
函数构造器类似地为它的 最后 一个参数值接收一个代码字符串,来把它转换为一个动态生成的函数(前面的参数值,如果有的话,将作为新函数的命名参数)。这种函数构造器语法要比eval(..)
稍稍安全一些,但在你的代码中它仍然应当被避免。
在你的代码中动态生成代码的用例少的不可思议,因为在性能上的倒退使得这种能力几乎总是得不偿失。
# with
MDN最新规范不建议使用,所以接下来我们了解下with语句就行。
with
语句接收一个对象,这个对象有0个或多个属性,并 将这个对象视为好像它是一个完全隔离的词法作用域,因此这个对象的属性被视为在这个“作用域”中词法定义的标识符。
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 -- 哦,全局作用域被泄漏了!
在这个代码示例中,创建了两个对象o1
和o2
。一个有a
属性,而另一个没有。foo(..)
函数接收一个对象引用obj
作为参数值,并在这个引用上调用with (obj) {..}
。在with
块儿内部,我们制造了一个变量a
的看似是普通词法引用的东西,并将值2
赋予它。
当我们传入o1
时,赋值a = 2
找到属性o1.a
并赋予它值2
,正如在后续的console.log(o1.a)
语句反应的那样。然而,当我们传入o2
,因为它没有a
属性,没有这样的属性被创建,所以o2.a
还是undefined
。
但是之后我们注意到一个特别的副作用,赋值a = 2
创建了一个全局变量a
。这怎么可能?
注意: 尽管一个with
块儿将一个对象视为一个词法作用域,但是在with
块儿内部的一个普通var
声明将不会归于这个with
块儿的作用域,而是归于包含它的函数作用域。
with
语句实际上是从你传递给它的对象中凭空制造了一个 全新的词法作用域。
以这种方式理解的话,当我们传入o1
时with
语句声明的“作用域”就是o1
,而且这个“作用域”拥有一个对应于o1.a
属性的“标识符”。但当我们使用o2
作为“作用域”时,它里面没有这样的a
“标识符”,于是就会出现undefined
“作用域”o2
中没有,foo(..)
的作用域中也没有,甚至连全局作作用域中都没有找到标识符a
,所以当a = 2
被执行时,其结果就是自动全局变量被创建(因为我们没有在strict模式下)。
with
在运行时将一个对象和它的属性转换为一个带有“标识符”的“作用域”,这个奇怪想法有些烧脑。但是对于我们看到的结果来说,这是我能给出的最清晰的解释。
# 性能
通过在运行时修改,或创建新的词法作用域,eval(..)
和with
都可以欺骗编写时定义的词法作用域。
JavaScript 引擎 在编译阶段期行许多性能优化工作。其中的一些优化原理都归结为实质上在进行词法分析时可以静态地分析代码,并提前决定所有的变量和函数声明都在什么位置,这样在执行期间就可以少花些力气来解析标识符。
但如果 引擎 在代码中找到一个eval(..)
或with
,它实质上就不得不 假定 自己知道的所有的标识符的位置可能是不合法的,因为它不可能在词法分析时就知道你将会向eval(..)
传递什么样的代码来修改词法作用域,或者你可能会向with
传递的对象有什么样的内容来创建一个新的将被查询的词法作用域。
换句话说,悲观地看,如果eval(..)
或with
出现,那么它 将 做的几乎所有的优化都会变得没有意义,所以它就会简单地根本不做任何优化。
你的代码几乎肯定会趋于运行的更慢,只因为你在代码的任何地方引入了一个了eval(..)
或with
。无论 引擎 将在努力限制这些悲观臆测的副作用上表现得多么聪明,都没有任何办法可以绕过这个事实:没有优化,代码就运行的更慢。
# 结论
- 作用域是一组规则,它决定了一个变量(标识符)在哪里和如何被查找。
- 作用域是由编写时函数被声明的位置的决策定义的,并不是说函数在哪里执行,哪里就开始生成作用域,这点理解很重要,这也时区分静态作用域和动态作用域区别的一个方法。
- 查找一个变量时,都从当前执行中的 作用域 开始,如果有需要(也就是,它们在这里没能找到它们要找的东西),它们会在嵌套的 作用域 中一路向上,一次一个作用域(层)地查找这个标识符,直到它们到达全局作用域(顶层)并停止,既可能找到也可能没找到。
- eval(…) 和 with 都可以 '欺骗' 词法作用域,前者可以通过对一个拥有一个或多个声明的“代码”字符串进行求值,来(在运行时)修改现存的词法作用域。后者实质上是通过将一个对象引用看作一个“作用域”,并将这个对象的属性看作作用域中的标识符,(同样,也是在运行时)创建一个全新的词法作用域。
- 以上两种机制的缺点也很明显,它们压制了引擎在作用域查询上进行编译期优化的能力,因为引擎不得不悲观的假定这样子的优化不合理,这两种机制会使代码运行的更慢!!! 建议不使用它们