作用域概述
作用域规定了数据的可访问范围,在Javascript执行过程中,如果遇到了变量和引用,会根据作用域规则进行查找。理解Javascript的作用域对我们理解代码的执行过程非常重要,可以避免命名冲突、读写冲突等麻烦事。
作用域的类型
JavaScript 的作用域体系可分为以下核心类型,每种类型对应不同的变量生命周期和访问规则:
全局作用域
最外层的作用域,在任何地方都可以访问。在脚本的最顶层(不在任何函数或块内)声明的变量和函数都属于全局作用域。全局变量可以在代码的任何位置被读取或修改,但也容易导致命名冲突和意外的数据污染。
函数作用域
变量在函数内部声明时,仅在该函数内部可见。使用 var
声明的变量具有函数作用域,这意味着它们在整个函数体内有效,但无法在函数外部访问。函数作用域帮助封装变量,避免全局污染,但 var
的变量提升(hoisting)特性可能导致一些意外行为。
块级作用域
由 {}
界定,例如 if
、for
、while
等代码块。使用 let
和 const
声明的变量具有块级作用域,它们只在当前块内有效,外部无法访问。块级作用域解决了 var
的变量提升和泄露问题,使代码更加可预测和安全。
模块作用域
模块作用域是 ES6 模块(import
/export
)引入的作用域机制。在一个模块中声明的变量、函数或类默认仅在该模块内可见,除非显式导出。避免了全局作用域的污染。
作用域的规则
在 JavaScript 中,作用域的规则本质上是一套对变量和函数可用范围的控制系统。这套系统的核心逻辑从词法作用域展开,通过作用域链的层级关系实现变量查找,而特殊的语法(如 eval
和 with
)则像"漏洞"一样打破规则的边界。
词法作用域
一般来说,作用域有两种:静态作用域和动态作用域。他们之间的区别就是静态作用域在写代码的时候就已经形成,而动态作用域会在运行时动态分析形成。现代的语言一般都会选择静态作用域,因为静态作用域可预测性高、性能较高。
而Javascript采用词法作用域,它就是一个静态的作用域,变量的可见范围在代码书写的时候就会确定 在之后的运行时中不会影响作用域中的内容,JS引擎在词法-语法分析阶段会将代码结构化,在这个阶段引擎会划分好作用域,并将对应变量放到对应作用域中,最终会形成一个AST抽象语法树,这样子在运行时JS引擎就可以快速找到每个变量对应的作用域。(大概理解一下就行,源码篇会详细讲)
作用域链
正如我们之前提到的,JavaScript 的作用域是静态作用域,它在代码编写时就已经确定了。因此,当代码书写完成时,作用域结构也就固定下来了。而当代码执行到某个变量时,JavaScript 引擎会通过作用域链查找机制快速定位到该变量。
作用域链是一种嵌套结构,当有函数创建时就会产生作用域链,因为函数会创建函数作用域,而创建环境又是全局作用域。可以类比为俄罗斯套娃。每个套娃都拥有自己的作用域,当我们在某个套娃中需要访问某个变量时,就会从自身开始向上逐层查找,直到找到为止。需要注意的是,一旦找到目标变量,查找就会停止,直接使用最先找到的变量,即使上层套娃中还定义了同名变量,也会被先找到的变量"遮蔽"。
当函数执行完毕之后,会触发JS引擎垃圾回收机制,该函数关联的整条作用域链都会被销毁(但是有特殊情况,下面会讲)。
scss
var a = 111
function outside(){
var a = 222
function inside(){
console.log(a);
}
inside()
}
outside() // 222
在这个例子中,我们在全局作用域中定义了一个变量 a = 111
,同时在 outside
函数的作用域中定义了一个同名变量 a = 222
。我们还在 outside
函数内部定义了一个 inside
函数的作用域,并且该函数需要打印变量 a
。
此时,形成了一个作用域链:inside
作用域 → outside
作用域 → 全局作用域。
当执行到 inside
函数中的 console.log(a)
时,JavaScript 引擎会根据作用域链查找机制来寻找变量 a
。按照这个作用域链,我们可以快速定位到最近的变量 a
,它位于 outside
函数的作用域中。因此,最终输出结果为 222
。
这段代码寻找变量a
的过程如下图:
特殊情况1:变量提升(坏特性)
在JS引擎中,var
和function
的声明会被提升到当前作用域的顶部,此时套用上面的作用域链查找规则来思考变量的寻找过程可能就行不通。例如:
Javascript
var a = 1
function test() {
console.log(a)
var a = 2
}
test() // undefined(非全局的1)
// 等效于
var a = 1
function test(){
var a; // undefined
console.log(a)
a = 2
}
test()
这里test
函数中的变量a
的声明就被提升到了test
函数作用域的顶部。并且由于遮蔽原则,此时打印的结果就会是undefined
。
虽然这段代码可以运行,但是这样子写的代码可预期性是很差的,容易产生未知的覆盖和命名冲突,所以我们最好按照规范来编写代码,将声明置于使用之上,在前端领域中可以通过eslint
库来配置代码规范来强约束代码规范,该库会进行代码的静态扫描,不符合规范就会报错。
或者另外一种解决方案就是使用块级作用域,见特殊情况2。
特殊情况2:let/const和块级作用域(好特性)
块级作用域是ES6引入的概念,并且结合了let
和const
来实现这一概念,这个特性避免了 特殊情况1 带来的问题。
在没有块级作用域前,我们会遇到这些问题:
javascript
if(true){var a=1}
console.log(a) // 1 这里可以访问到if里面声明的变量
for(var i=0;i<10;i++){
setTimeout(()=>{
console.log(i) // 10 10 10 10 10 10 10 10 10 10 不符合预期,我们想要的是间隔1秒打印0~9
},i*1000)
}
// 以上代码相当于:
var a;
var i;
if(true){a = 1}
console.log(a)
for(i=1;i<10;i++){
setTimeout(()=>{
console.log(i)
},i*1000)
}
可以看到,由于var
的声明提升,代码的会朝着不希望的方向发展。这也是为什么ES6要引入块级作用域的主要原因(解决变量提升问题、减少全局变量污染)
在ES6+中使用let/const
声明的变量是不会进行提升的,基于这一特性,就可以实现块级作用域。
现在我们来解决上面所遇到的问题,我们可以通过在代码块"{}
"内使用let/const
来声明变量来创建一个块级作用域:
javascript
if(true){let a = 1}
console.log(a) // 报错:ReferenceError
for(let i=1;i<10;i++){ // 每次循环都是新的块级作用域
setTimeout(()=>{
console.log(i) // 0 1 2 3 4 5 6 7 8 9
},i*1000)
}
可以看到,使用let
不会污染到外部的作用域,可以完美解决上面的问题。
特殊情况3:闭包(看你怎么用)
一般情况下,当一个函数执行完毕之后,该函数的作用域及里面的变量都会被JS引擎的垃圾回收机制销毁。
但是闭包可以让函数保持这个作用域,不被销毁。
创建闭包,并且保持作用域的条件:
- 内部函数作用于持有了外部函数作用域的引用(创建闭包的条件1)
- 并且该内部函数被外部函数所返回(创建闭包的条件2)
- 外部有变量持续引用该内部作用域(保持作用域的条件。非常特殊的一点,重点注意!)
例如:
javascript
var a = 111
function outside(){
var a = 222
return function inside(){ // 满足条件2
a += 1
console.log(a); // 满足条件1
}
}
// 场景 1:直接调用,无持续引用
outside()() // 223(闭包仅在执行时存在,执行后销毁)
outside()() // 223(新闭包,独立作用域)
// 场景 2:外部变量持有引用
const func = outside() // 满足条件3,作用域被保留
func() // 223(修改闭包中的 a)
func() // 224(继续操作同一闭包)
可以看到满足3个条件后,变量a
没有被销毁,可以持续对其进行操作。
基于这一特性,我们可以很方便的实现一些功能,如:
- 私有属性
- 单例模式
- 防抖节流
- 缓存
- 表单正则校验规则复用
- 插件化
- AOP编程
- 发布订阅中心
- .............
特殊情况4:eval和with(坏特性)
eval和with会对作用域进行污染,引发不可预期的结果。平时写业务禁止使用!
引擎无法对eval内的的代码进行词法分析,所以eval内的创建的变量外部是可以使用的,这样子就会污染外部的作用域。例如:
javascript
function test() {
eval('var a = 100')
console.log(a) // 100(正常情况应报错)
}
test()
使用with在非严格模式如果查找不到该对象的属性,就会自动创建全局变量,从而污染作用域。例如:
javascript
const obj = { x: 10 }
function test() {
with(obj) {
console.log(x) // 10(优先查找obj属性)
y = 20 // 若obj无y属性,会泄漏到全局
}
}
test()
console.log(window.y) // 20(意外创建全局变量)