书写 JavaScript 语言时,
是否经常见到这种提示报错 * is not defined?
是否经常出现 undefined?
这些都是因为此时变量的访问是无效或者不可用的,而限定变量的可用性的代码范围的就是这个变量的作用域。那什么是作用域呢?
作用域
编程语言最基本的就是能够储存变量当中的值,并且能在之后对这个值进行访问或修改,而作用域就是变量与函数的可访问范围。
作用域共有两种主要的工作模型,词法作用域(静态作用域)和动态作用域:
- 词法作用域:作用域在定义时确认的,即写代码时将变量和块作用域写在哪里来决定的,JavaScript 使用的就是词法作用域。
- 动态作用域:作用域在运行时确定的,比如 bash、Perl。
- JavaScript 一共有三种作用域:
- 全局作用域:代码最外层。
- 函数作用域:创建一个函数就创建了一个作用域,无论你调用不调用,函数只要创建了,它就有独立的作用域。
- ES6 的块级作用域:ES6 引入了 let 和 const 关键字和 {} 结合从而使 JavaScript 拥有了块级作用域,下面会详细介绍。
全局作用域
最外层是全局作用域,在脚本的任意位置都可以访问到,拥有全局作用域的变量也被称为“全局变量”。
下面看下哪些变量拥有全局作用域:
- 浏览器中,全局作用域中有一个全局对象 window,可以直接使用。
// 获取窗口的文档显示区的高度
window.innerHeight
- 最外层定义的函数和变量
var a = 1
console.log(window.a) // 1 --- var 声明的 a 成为了 window 的属性,为全局变量
function func1 () {
console.log('hello')
}
func1() // hello
window.func1() // hello
- 不使用关键字直接赋值的变量自动声明为拥有全局作用域,挂载在 window 对象上。
b = 2 // 全局变量
function func1 () {
c = 2
}
func1()
console.log(window.b) // 2
console.log(window.c) // 2
省略了关键字的变量,不管是函数外面的 b 还是函数里面的 c 都是全局变量,且挂载在 window上,但是这种省略关键字是不规范和不利于维护的 ,不推荐使用。
变量提升
把上面的 a 代码反过来如下:
console.log(window.a) // 输出 undefined
var a = 1
这个时候 a 在声明之前是可访问的,只是输出了undefined,即为经常提到的“变量提升”。
变量提升:var 关键字声明的变量,无论实际声明的位置在何处,都会被视为声明在当前作用域的顶部(包括在函数和全局作用域)
因为 JS 引擎的工作方式是分为编译和执行两个阶段:
- 先解析代码,获取所有被声明的变量;
- 然后再运行。
所以下面两段代码是等价的:
console.log(a); // 输出undefined
var a =1;
// 等价于
var a;
console.log(a); // 输出undefined
a =1;
- 对于var a = 1,编译器遇到 var a 会在作用域中声明新的变量 a
- 然后编译器为引擎生成运行时所需的代码,处理console.log(a)和 a = 1
- 引擎运行时,从当前的作用域集合中获取变量a(此时是 undefined ) 和给 a赋值1
函数作用域
函数作用域内的变量或者内部函数,作用域都是函数作用域,对外都是封闭的,从外层的作用域无法直接访问函数内部的作用域,否则会报引用错误异常。如下:
function func1 () {
var a = 1;
return a
}
func1() // 1 函数内部是能够访问的
console.log(a) // Uncaught ReferenceError: a is not defined
函数声明
函数声明中,JS 引擎会在代码执行之前获取函数声明,并在执行上下文中生成函数定义。
console.log(add(10, 10)) // 正常返回20
function add (a, b) {
return a + b
}
代码正常运行,函数声明可以在任何代码执行之前先被读取并添加执行上下文,即函数声明提升(和前面的变量声明提升一样)。
函数表达式
函数表达式必须等待代码执行到那一行,才会在执行上下文中生成函数定义
console.log(add(10, 10)) // Uncaught TypeError: add is not a function
var add = function (a, b) {
return a + b
函数表达式 var add = function(){} 是变量声明提升。在这种情况下,add 是一个变量,因此这个变量的声明也将提升到顶部,而变量的赋值依然保留在原来的位置,所以此时的报错是变量 add 类型不对。
函数声明和变量声明
前面提到函数声明提升和变量声明提升,以及使用的现象,下面看一下两者共同使用的例子:
test() // “执行函数声明”
var test = function () {
console.log('执行函数表达式')
}
function test (a, b) {
console.log('执行函数声明')
}
test() // “执行函数表达式”
第一个 test()输出“执行函数声明”,第二个 test() 输出“执行函数表达式”,是因为经历了函数声明提升和变量声明提升(函数提升优先于变量提升),代码等价于:
// 函数声明提升到顶部
function test (a, b) {
console.log('执行函数声明')
}
// 变量提升,变量提升不会覆盖(同名)函数提升,只有变量再次赋值时,才会被覆盖
var test
// 还在原处
test() // “执行函数声明”
test = function () {
console.log('执行函数表达式')
}
test() // “执行函数表达式”
块级作用域
ES6 新增的 let 和 const 作用域是块级作用域,由最近的一对花括号 {} 界定,以 let 为例如下:
{
var a = 1
let b = 2
}
console.log(a) // 1
console.log(b) // Uncaught ReferenceError: b is not defined
在花括号内使用 let 声明的变量,在外部是无法访问的,即块级作用域。
当使用 let 关键字声明的变量提前访问时:
{
console.log(a) // 报错 Uncaught ReferenceError: a is not defined
let a = 1
}
上述之所以报错是因为 let 有“暂时性死区”
暂时性死区:声明变量之前,该变量都是不可用的,只要进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。
上述用代码可以等价理解为:
{
//let a ,暂时性死区开始的地方
console.log(a) // 由于 a = 2 在暂时性死区中,所以报错
a = 1 // 暂时性死区结束的地方
}
const
const 和 let 是一样的,也有“暂时性死区”,只是有以下限制:
- const声明变量的时候必须同时初始化为某个值,且不能重新赋值。
https://back-media.51cto.com/editor?id=702422/h6e90be6-0eG33HhD
赋值为对象的 const 变量不能再赋值其他的引用值,但是对象的键不受限制( Object.freeze()可以完全冻结对象,键值对也不能修改)
const a = 1
a = 1 // Uncaught TypeError: Assignment to constant variable.
// 对象
const obj = {
a: 1,
b: 2
}
obj.b = 3
console.log // 3
var、let、const差别差别
差别 | var | let | const |
作用域 | 函数作用域 | 块作用域 | 块作用域 |
声明 | 同一个作用域可多次声明 | 同一个作用域不可多次声明 | 同一个作用域不可多次声明且要同时赋值,后续不可更改 |
特性 | 变量提升(且不加var是全局变量) | 暂时性死区 | 暂时性死区 |
常见例子:for 循环
for (var i = 0; i< 5; i++) {
setTimeout(function() {
console.log(i)
})
}
// 5 5 5 5 5
for (let i = 0; i< 5; i++) {
setTimeout(function() {
console.log(i)
})
}
// 0 1 2 3 4
var:全局变量,退出循环时迭代变量保存的是循环退出的时候的值,在执行超时回调的时候,所有的 i 都是同一个变量。
let:块作用域,JS 引擎为每个迭代循环声明了一个新的变量,每个超时回调调用的都是不同的变量实例。
// 遍历对象key
for (const key in {a: 1, b: 1}) {
console.log(key) // a b
}
// 遍历数字
for (const val of [1, 2, 3]) {
console.log(val) // 1 2 3
}
eval
eval 由于性能不好、不安全、代码逻辑混乱等各种问题,一般不支持在代码里使用它,但是还是要了解下的,用网友的话就是:可以远离它,但是要了解它
这个方法就是一个完整的 ES 解释器,它接收一个参数, 即一个要执行的 ES(JavaScript)字符串,把对应的字符串解析成 JavaScript 代码并运行(将 json 的字符串解析成为 JSON 对象)。
eval 的简单用法:
- 如果参数是字符串表达式,则对表达式进行求值
- 如果参数是字符串且表示一个或多个 JavaScript 语句,那么就会执行这些语句
- 如果参数不是字符串,参数将原封不动地返回
eval("2 + 2") // 输出 4
eval("console.log('hi')") // 输出 hi
eval(new String("2 + 2")) // String {'2 + 2'}
eval 对作用域的影响
eval 在 JavaScript 中有两种调用方式:直接调用和间接调用。
- 直接调用时:eval 内代码块的作用域绑定到当前作用域,直接使用 eval()。
function testEval () {
eval('var a = 111')
console.log(a) // 111
}
testEval()
console.log(a) // 报错
上面在 testEval 函数内部是可以获取到 a 的,所以 eval 修改了 testEval 函数作用域。
- 间接调用时:eval 内代码块的作用域绑定到全局作用域,使用 window.eval()(IE8兼容性问题),window.execScript(支持IE8及以下的版本),为了解决兼容性问题,也可以在全局赋值给变量,然后在函数内使用。
// 有IE兼容问题
function testEval () {
window.eval('var a = 111')
console.log(a) // 111
}
testEval()
console.log(a) // 111 eval定义的变量绑定到了全局作用域
// 解决兼容性问题
var evalExp = eval
function testEval () {
evalExp('var a = 111')
console.log(a) // 111
}
testEval()
console.log(a) // 111 eval定义的变量绑定到了全局作用域
eval 的变量提升问题
通过 eval() 定义的任何变量和函数都不会被提升,这是因为在解析代码的时候,它们是被包含在一个字符串中的。它们只是在 eval() 执行的时候才会被创建。
下面是let、var和函数的不同效果,如下:
// 函数
sayHi() // error: sayHi is not defined,没有函数声明提升
eval("function sayHi() { console.log('hi'); }");
sayHi() // hi
// var
msg // error: msg is not defined,没有变量声明提升
eval("var msg = 'hello world'")
console.log(msg) // hello world
// let
eval("let msg = 'hello world';console.log(msg)") // // hello world
console.log(msg) // 报错 let 作用域只能是eval内部
作用域链
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。作用域嵌套的查询规则如下:
- 首先,JS 引擎从当前的执行作用域开始查找变量。
- 然后,如果找不到,引擎会在外层嵌套的作用域中继续查找。
- 最后,直到找到该变量,或抵达最外层的全局作用域为止。
这样由多个作用域构成的链表就叫做作用域链。
例如:
var c = 1
function func () {
var b = 2
function add (a) {
return a + b + c
}
return add
}
const addTest = func()
addTest(3) // 6
作用域链为:
执行funcTest()的时候:
- 查找 add 函数作用域,查询是否有 a,有即获取传进作用域的值 3
- 此时获取 a 的值,继续查找 b 的值,查找 add 函数作用域,查询是否有 b,没有
- 查找上层作用域 func,查询是否有 b,有即获取当前作用域的值 2
- 此时获取到 b 的值之后,再查找 c 的值,在add 函数作用域查询不到 c
- 查找上层作用域 func,依然查询不到 c
- 再往上一层作用域查找,即全局作用域,查询 a,查询到则获取作用域的值 1
- 返回 6
闭包
不知道大家有没有注意到,之前说 JavaScript 作用域是在定义时确认的,即在定义的函数外面是访问不到函数里面的变量的,但是上面作用域嵌套的例子中,addTest 却能够访问到函数 func 的内部变量,这就是因为“闭包”的存在。
闭包就是函数内部定义的函数,可以记住并访问所在的作用域,即使函数是在当前词法作用域之外执行,也可以访问内部变量。
var c = 1
function func () {
var b = 2
function add (a) {
return a + b + c
}
return add
}
const addTest = func()
addTest(3) // 6
- 首先,函数 add() 的作用域能够访问func() 的内部作用域
- 执行 func,将内部函数 add 的引用赋值给外部的变量 addTest ,此时 addTest 指针指向的还是 add
- add 依然持有对 func 作用域的引用,而这个引用就叫作闭包
- 在外部执行 addTest,即外部执行 add,通过闭包能访问到定义时的作用域。
使用闭包的时候原函数 func 不会被回收,还被包含在 add 的作用域里,因此会比其他函数占用更多的内存,容易造成内存泄漏。
闭包的使用
如上可知,闭包在代码里随处可见,下面看下使用场景:
回调
如上面所举的 let 循环的例子:
for (let i = 0; i< 5; i++) {
setTimeout(function() {
console.log(i)
})
}
setTimeout的回调函数记住了当前的词法作用域,当循环结束,执行函数的时候,能够访问到当时的作用域的 i。
模块化
// 获取数组中的正序和逆序排列
function arrOperate () {
let errorMsg = '请传入一个数组'
// 正序
function getPositiveArr(arr) {
if (Array.isArray(arr)) {
return arr.sort((a, b) => {
return a - b
})
} else {
throw errorMsg
}
}
// 逆序
function getBackArr(arr) {
if (Array.isArray(arr)) {
return arr.sort((a, b) => {
return b - a
})
} else {
throw errorMsg
}
}
return {
getPositiveArr,
getBackArr
}
}
const arrObj = arrOperate()
arrObj.getPositiveArr([1, 10, 5, 89, 46]) // [1, 5, 10, 46, 89]
arrObj.getBackArr([1, 10, 5, 89, 46]) // [89, 46, 10, 5, 1]
arrObj.getPositiveArr(123) // Uncaught 请传入一个数组
这个模式在 JavaScript 中被称为模块,arrOperate() 返回一个对象,包含对内部函数的引用, 而内部函数getPositiveArr() 和 getBackArr() 函数具有涵盖模块实例内部作用域的闭包,可访问 errorMsg。
总结
作用域决定着变量的可访问范围,代码随处可见,了解作用域,避免使用访问不到的变量,减少文章开头的报错,代码质量直线上升哦。
参考资料
- 《你不知道的 JavaScript》
- 《JaveScript 高级程序设计》
陈晨,微医第一利润中心前端组,一位“生命在于静止”的程序员。