关于JavaScript中的作用域
声明:本文引用了《你不知道的JavaScript(上卷)》一书作用域篇章的部分代码示例和文字描述
文章的开始,我们先提出这么几个问题:
- 作用域是什么?
- 作用域的工作模式?
- 作用域有哪几类?
- 什么情况下会产生作用域?
- 作用域有什么用,或者说如何利用?
作用域是什么
作用,标识符被访问或调用, 域,空间、范围的意思,如最简单的代码 var a = '123'
,a变量保存在哪里(此处保存在变量环境
)?后续的操作中如何找到它?
这些问题,需要一套规则来存储变量,以及定义如何查找它们,这套规则就叫 作用域
,而完整的查找链条则被称为作用域链
关于变量环境 + 词法环境,大家有兴趣可以另行查找资料学习
function foo(a) {
var secondName = '铁锤'
sayHellow()
function sayHellow() {
console.log('hello i am ' + firstName + secondName)
console.log(message) // ReferenceError: message not defined
}
}
var firstName = '李'
foo('铁锤')
代码示例中的作用域嵌套气泡图(方的气泡 -_- !)
当执行sayHello()
中的console.log('hello i am ' + firstName + secondName)
, 需要对 firstName + secondName 两个变量执行RHS
查找(相对的,还有LHS
,区别是此时变量是赋值操作的目标(LHS),还是被赋值操作使用的值(RHS))
- firstName: sayHello() 中找不到,则往上/往外查找到foo(),也没找到,继续往上查找,OK,在全局作用域找到了
- secondName: 同上
- message: 同样的逻辑,直到全局作用域都没有找到,抛出
ReferenceError
作用域的工作模式
作用域分两种工作模型,一种是我们常见的词法作用域
,大部分编程语言使用的也是这种(当然包括JavaScript),
- 词法作用域:大部分编程语言使用这种,在词法分析阶段决定,因此可以理解为
静态作用域
- 动态作用域:Bash脚本(如git bash),Perl中的一些模式(如shell命令),
词法作用域
说到词法作用域,可以先说明一下JavaScript的编译3个阶段
- 分词/词法分析:把一行代码分解成代码块(词法单元),如
var a = 2;
会被分解成var, a, =, 2, ;
- 解析/语法分析:将上一个步骤生成的词法单元流转换成一颗逐级嵌套的的树,也就是
AST
抽象语法树(Abstract Syntax tree) - 代码生成:将AST语法树转换成一组机器指令:声明一个叫做a的变量,并为它分配内存,然后把2这个值储存在a中
词法作用域是定义在编译-词法分析阶段的作用域,词法分析的对象是你的源代码,也就是说词法作用域由你写代码时将变量和块作用域写在哪里来决定的,因此词法分析器处理代码时会保持作用域不变(大部分情况如此,除了eval + with)
function foo(a) {
var b = a * 2
function bar(c) {
comsole.log(a, b, c)
}
bar(b*3)
}
foo(2)
// 1、全局作用域:一个标识符:foo
// 2、foo函数作用域:3个标识符: 形参a, b, bar
// 3、bar函数作用域:1个标识符:形参c
欺骗词法(eval +with)
eval
eval函数可以接收一个字符串参数,并把该字符串当做可执行代码进行执行,字符串参数是动态的,所以其执行过程中可做变量声明,或者变量修改,因此eval函数所处的外部函数的作用域有可能会被修改
function foo(str) {
eval(str)
console.log(msg) // 我在eval函数中被声明
}
var msg = '我在全局作用域'
foo('var msg = "我在eval函数中被声明"')
注意:在严格模式中,eval(...)有自己的作用域,并不会影响到所处作用域
function foo(str) {
"use strict"
eval(str)
console.log(msg) // ReferenceError: a is not defined
}
foo('var msg = "尝试修改所处作用域"')
with
with通常被当做重复引用同一个对象的多个属性的快捷方式,可以不用重复引用对象本身
var obj = {a: 1, b: 2, c: 3}
// 正常情况下挨个属性赋值
obj.a = 11
obj.b = 12
obj.c = 13
// 使用with
with(obj) {
a = 11
b = 12
c = 13
}
with可以将传入的对象处理成完全隔离的词法作用域,而它的属性则自动处理为定义在该作用域内的词法标识符,但是这个块内部正常的var 声明并不会被限制在块内部,而是被添加到with执行时所处的作用域中
function foo(obj) {
with(obj) {
a = 2
}
// console.log(a) // foo(o2) 时打印 2,
}
var o1 = {
a: 3
}
var o2 = {
b: 4
}
foo(o1)
console.log(o1.a) // 2
foo(o2)
console.log(o2.a) // undefined
console.log(a) // 2 a被泄漏到了全局作用域(LHS 导致的,严格模式的话,将会阻止在全局作用域声明)
性能
eval可以动态执行JavaScript代码,with可以方便访问对象属性,看起来都是非常棒的特性,但是,JavaScript引擎会在编译阶段进行数项的性能优化,其中有些优化依赖于能够根据代码的词法分析,来预先确定所有的变量和函数的定义位置,并在执行过程中快速找到标识符。因此,如果引擎在代码中发现了eval(...) / with(...),JS引擎出于正确性和严谨性的考虑,它只能认为自己做的优化是无效的( 这两者都可能会改变所处的作用域甚至全局作用域),因此运行效率将会降低。
作用域有哪几类
在JavaScript中作用域分为两类:
- 函数作用域
- 块级作用域
函数作用域
每声明一个函数,JS引擎都会为它创建一个函数作用域,属于这个函数的所有变量都可以在整个函数范围被访问(也包括函数体中嵌套的作用域),当发生变量查找时,从代码所属作用域开始查找,当前作用域查找不到时,逐层往外查找,最终查找到全局作用域的查找逻辑,也就是所谓的 作用域链
隐藏内部实现
在Java中有的属性可以被定义为private(私有属性),它只属于当前对象,并且不希望被外界其他对象访问。这就是最小特权原则
,也叫最小授权
或者 最小暴露
原则,一个对象或者方法的设计,应最小限度地暴露必要内容,比如某个模块或者对象的API 设计
// bad
var eatWhat = '吃什么'
var drinkWhat = '喝什么'
function haveFun() {
letUsHappy()
}
function letUsHappy() {
console.log(eatWhat)
console.log(drinkWhat)
}
// good : 该是我的变量和方法,都处在我自己的函数作用域当中,类似外界无法访问我的私有属性和方法
function haveFun() {
var eatWhat = '吃什么'
var drinkWhat = '喝什么'
function letUsHappy() {
console.log(eatWhat)
console.log(drinkWhat)
}
letUsHappy()
}
避免同名标识符冲突
这个根据作用域链
的查找规则就比较好理解了,也就是说如果当前作用域内已经可以查找到对应的标识符,标识符查找也就不会再往外查找了
var name = '我是全局作用域的名字'
function foo() {
var name = '我是foo 函数内部的名字'
console.log(name)
}
foo()
块作用域
首先块的概念是什么,JavaScript中有哪些块?
if() {} // if块
while() {} // while块
{} // 独立的代码块
for() {} // for循环的迭代块
由此,我们可以粗暴地理解为{...}
就是一个块,不过这并不意味着这里面就有块级作用域了,在ES6let
关键字之前,我们可以认为JavaScript是没有块级作用域的概念的,除了以下几个奇怪的兄弟
// with 块
var obj = {a: 1}
with(obj) {
a = 2 // 此处有块级作用域,外部无法访问a变量
}
// try/catch 的catch分句
try {
// doSomething error
} cathc(error) {
console.log(error) // error只能在该分句内访问
}
console.log(error) // ReferenceError: error not defined
let / const
let / const是es6新增的变量声明方式(保存在词法环境
),用于声明一个局部变量,使用let / const关键字声明的变量会隐式劫持声明所处的块,被声明的变量只能在该块内被访问或者修改,由此就形成了块级作用域
{
let a = '我处在块级作用域'
const b = '我也处在块级作用域'
}
console.log(a) // Uncaught ReferenceError: a is not defined
console.log(b) // Uncaught ReferenceError: b is not defined
const 用于声明一个常量
,该变量的值是不可以修改的,不过当声明的变量的值是引用类型
时,稍微有点怪异
const a = '1'
const b = {name: 'white'}
b.name = 'black' // 正常 b是引用类型,b.name修改的是被引用的值,而没有修改b(内存指针)本身
b = {name: 'white'} // Uncaught TypeError: Assignment to constant variable.
a = '2' // Uncaught TypeError: Assignment to constant variable.
你可能注意到了,上面两份代码示例中,错误类型是不一样的:ReferenceError
vs TypeError
- ReferenceError:reference(引用),这种错误发生在标识符查找出错时,或者尝试去访问一个未定义的变量时(此处需注意
变量提升
,也就是声明提前) - TypeError:变量查找已经完成,但是执行了错误的操作,如调用了不存在的方法
var msg = 123;msg.forEach(...)