JavaScript 作用域和作用域链详解

0 阅读8分钟

在前面的文章中,我们提到 JavaScript 在创建执行上下文时会进行以下操作:

  1. 生成变量对象(VO)
  2. 确定作用域(Scope)
  3. 确定 this 的值

那么,这里提到的作用域到底是什么呢?今天我们就来深入了解一下这个重要概念。

什么是作用域?

举个例子

想象一下,你家里有客厅、卧室、厨房等不同的房间。每个房间里的东西,只有在那个房间里才能使用。比如,你把钥匙放在卧室的桌子上,那么只有在卧室里你才能找到这把钥匙,在客厅是找不到的。

在 JavaScript 中,作用域就像是这些房间,它决定了变量在哪里可以被访问和使用。

让我们通过一个简单的例子来理解:

function myFunction() {
  let localVar = '函数内部变量'
}
myFunction()
console.log(localVar) // Uncaught ReferenceError: localVar is not defined

从上面的例子可以看出,变量 localVar 就像是放在函数这个"房间"里的东西,在函数外面是访问不到的,所以会报错。

简单来说,作用域就是变量的"活动范围"。它的主要作用是:

  • 隔离变量:不同作用域的变量互不干扰
  • 保护变量:防止变量被意外修改
  • 避免命名冲突:不同作用域可以有同名变量

JavaScript 中的作用域类型

在 ES6 之前,JavaScript 只有两种作用域:

  • 全局作用域:整个程序都能访问
  • 函数作用域:只在函数内部能访问

ES6 新增了块级作用域,通过 letconst 关键字实现。

三种作用域详解

1. 全局作用域

全局作用域就像是家里的客厅,所有人都可以进入和使用。在代码中任何地方都能访问到的变量就拥有全局作用域。

以下几种情况会创建全局作用域:

最外层定义的变量和函数

const globalVariable = '我是最外层变量' 
function outerFunction() {
  // 最外层函数
  const innerVariable = '内层变量'
  function nestedFunction() {
    //内层函数
    console.log(innerVariable)
  }
  nestedFunction()
}
console.log(globalVariable) // 我是最外层变量
outerFunction() // 内层变量
console.log(innerVariable) // innerVariable is not defined
nestedFunction() // nestedFunction is not defined

忘记用 var/let/const 声明的变量(不推荐)

(function testFunction() {
  globalVar = '未定义直接赋值的变量'
  let localVar2 = '内层变量2'
})()
console.log(globalVar) // 未定义直接赋值的变量
console.log(localVar2) // localVar2 is not defined

window 对象的属性

浏览器中,window 对象的属性都拥有全局作用域,比如 window.namewindow.location 等。

全局作用域的问题

全局作用域就像是把所有东西都放在客厅里,时间长了就会很乱:

// 开发者A写的代码中
let userInfo = { id: 100 }

// 开发者B写的代码中
let userInfo = { active: true }

这就是为什么 jQuery、Zepto 等库都会把代码包在 (function(){...})() 中,就像给自己的代码建了一个独立的房间,避免和其他代码产生冲突。

2. 函数作用域

函数作用域就像是你的卧室,只有你自己能进入,外人无法访问里面的东西。

function performTask() {
  const userName = 'zhangsan'
  function displayName() {
    console.log(userName)
  }
  displayName()
}
console.log(userName) // userName is not defined
displayName() // displayName is not defined

重要特性:作用域的层级关系

内层作用域可以访问外层作用域的变量,但外层不能访问内层的变量

这就像是:

  • 你在卧室里可以去客厅拿东西
  • 但客厅里的人不能进入你的卧室拿东西

用气泡框来理解作用域层级:

作用域层级.png

最后输出的结果为 2、4、12

  • 气泡 1 是全局作用域,有标识符 foo
  • 气泡 2 是作用域 foo,有标识符 a、bar、b
  • 气泡 3 是作用域 bar,仅有标识符 c

注意:ES6 之前的"坑"

在 ES6 之前,ifforwhile 等语句的大括号 {} 不会创建新的作用域

if (true) {
  // 'if' 条件语句块不会创建一个新的作用域
  var username = 'Hammad' // username 依然在全局作用域中
}
console.log(username) // logs 'Hammad'

这经常让初学者感到困惑,也容易产生 bug。所以 ES6 引入了块级作用域来解决这个问题。

3. 块级作用域 - ES6 的"新房间"

块级作用域是 ES6 带来的新特性,通过 letconst 关键字创建。现在 {} 真的可以创建独立的作用域了!

什么时候会创建块级作用域?

  • 函数内部
  • 任何用 {} 包裹的代码块

块级作用域的特点:

不会变量提升

letconst 不像 var 那样会提升到顶部,必须先声明再使用。

function getColor(flag) {
  if (flag) {
    const color = 'blue'
    return color
  } else {
    // color 在此处不可用
    return null
  }
  // color 在此处不可用
}

不允许重复声明

同一个作用域内,不能用 let 重复声明同名变量:

var counter = 30
let counter = 40 // Uncaught SyntaxError: Identifier 'counter' has already been declared

但在不同的作用域内可以有同名变量:

var counter = 30
// 不会抛出错误
if (condition) {
  let counter = 40
  // 其他代码
}

解决循环中的经典问题

块级作用域最大的用处之一就是解决循环中的变量问题。看这个经典的例子:

<button>测试1</button>
<button>测试2</button>
<button>测试3</button>
const buttons = document.getElementsByTagName('button')
for (var index = 0; index < buttons.length; index++) {
  buttons[index].onclick = function () {
    console.log('第' + (index + 1) + '个')
  }
}

期望效果:点击第几个按钮就显示"第几个" 实际效果:点击任何按钮都显示"第 4 个"

原因var i 是全局变量,循环结束后 i 的值是 3,所以所有按钮的点击事件都使用这个值。

解决方案:用 let 声明 i

for (let index = 0; index < buttons.length; index++) {
  buttons[index].onclick = function () {
    console.log('第' + (index + 1) + '个')
  }
}

作用域链 - 变量的"寻找路径"

作用域链就是 JavaScript 引擎查找自由变量的路径。

什么是自由变量?

自由变量就是在当前作用域中没有定义,但需要使用的变量。

就像你在卧室里找不到钥匙,就需要到客厅去找一样。

const num = 100
function myFunc() {
  const localNum = 200
  console.log(num) // 这里的 num 在这里就是一个自由变量
  console.log(localNum)
}
myFunc()

什么是作用域链?

当在当前作用域找不到变量时,JavaScript 会:

  1. 先在当前作用域找
  2. 找不到就去父级作用域找
  3. 还找不到继续往上找
  4. 直到全局作用域
  5. 如果全局作用域也没有,就报错

这种一层层向上查找的路径,就叫做作用域链

const globalVar = 100
function outerFunc() {
  const outerVar = 200
  function innerFunc() {
    const innerVar = 300
    console.log(globalVar) // 自由变量 100,顺作用域链向父作用域找
    console.log(outerVar) // 自由变量 200,顺作用域链向父作用域找
    console.log(innerVar) // 300 本作用域的变量
  }
  innerFunc()
}
outerFunc()

重要概念:静态作用域

这里有个非常重要的概念需要理解:

let value = 10
function testFn() {
  console.log(value)
}
function execute(callback) {
  let value = 20
  ;(function () {
    callback() // 10,而不是 20
  })()
}
execute(testFn)

函数 testFn 中的变量 value 应该从哪里取值?

答案是:从创建 testFn 函数时的作用域中取值,而不是调用时的作用域!

这就是 JavaScript 的静态作用域(也叫词法作用域):

  • 作用域在写代码时就确定了
  • 不是在运行时确定的

再来看一个例子:

const meal = 'rice'
const consume = function () {
  console.log(`eat ${meal}`)
}
;(function () {
  const meal = 'noodle'
  consume() // eat rice
})()

结果是 eat rice,因为 consume 函数是在全局作用域中创建的,所以它使用全局的 meal 变量。

如果我们把函数的创建位置改一下:

const meal = 'rice'
;(function () {
  const meal = 'noodle'
  const consume = function () {
    console.log(`eat ${meal}`)
  }
  consume() // eat noodle
})()

这时结果是 eat noodle,因为 consume 函数是在立即执行函数内部创建的,所以使用的是内部的 meal 变量。

作用域 vs 执行上下文

很多人容易混淆这两个概念,其实它们是完全不同的:

作用域(Scope)

  • 什么时候确定:写代码时就确定了
  • 会不会变化:不会变化,是静态的
  • 主要作用:决定变量的访问范围

执行上下文(Execution Context)

  • 什么时候确定:代码运行时才确定
  • 会不会变化:会变化,是动态的
  • 主要作用:决定 this 的指向、变量的值等

简单记忆

  • 作用域:在哪里能找到变量(位置)
  • 执行上下文:变量的具体值是什么(内容)

总结

作用域的核心要点

  1. 作用域是变量的活动范围,决定了变量在哪里可以被访问
  2. 三种类型:全局作用域、函数作用域、块级作用域(ES6+)
  3. 层级关系:内层可以访问外层,外层不能访问内层
  4. 静态特性:在写代码时就确定,不会因为调用位置而改变

作用域链的核心要点

  1. 查找机制:从当前作用域开始,逐层向上查找变量
  2. 查找顺序:当前作用域 → 父级作用域 → ... → 全局作用域
  3. 静态绑定:查找路径在函数创建时就确定了

希望这篇文章能帮助你更好地理解 JavaScript 的作用域机制!如果有任何疑问,欢迎在评论区讨论。