Skip to content

3-1作用域链+JS垃圾回收机制+闭包 ​

本页对应 第三章JavaScript进阶,共 7 段视频,约 45 分钟。

学习目标 ​

  1. 能区分全局作用域、函数作用域和块级作用域,并判断给定代码中变量在哪些位置可访问、哪些位置会报错。
  2. 能根据嵌套函数代码分析作用域链的变量查找过程,准确推导变量输出结果,并说明“就近原则”及子作用域可访问父作用域、父作用域不可访问子作用域。
  3. 能描述垃圾回收机制与内存生命周期(分配、使用、回收),并判断全局变量、局部变量的回收时机及内存泄漏的含义。
  4. 能用自己的话定义闭包,写出使用闭包实现数据私有和统计函数调用次数的代码,并能借助浏览器调试工具识别闭包结构。
  5. 能分析 var 变量提升的执行过程,预测声明前访问、函数内声明等代码的输出结果,并说明“只提升声明、不提升赋值”。
  6. 能区分函数声明与函数表达式的提升行为,预测声明前调用时的执行结果或报错信息,并解释原因。

知识点 ​

作用域总览 ​

作用域(scope)规定变量能被访问的“范围”,离开这个范围就不能访问。为什么需要它?因为程序里变量很多,必须知道“这个变量在哪里有效”,否则就会出现误用、覆盖和污染。怎么想?先把作用域分成全局作用域和局部作用域,局部里再分函数作用域、块作用域。怎么做?声明变量时优先用 let、const,把变量限制在需要的最小范围。易错点:不要把“能访问”理解成“到处都能访问”,局部变量出了局部就会报错。

函数作用域与块作用域 ​

函数内部声明的变量只能在函数内部访问,函数的形参也属于函数内部的局部变量;不同函数内部的变量不能互相访问。为什么需要?因为函数要封装自己的逻辑,内部变量不应随便被外部改动。怎么做?看下面代码:

html
<script>
  function counter(x, y) {
    const s = x + y
    console.log(s) // 18
  }
  counter(10, 8)
  console.log(s) // 报错
</script>

块作用域是用 {} 包起来的代码块。let、const 会产生块作用域,var 不会。怎么想?只要变量只在这个 {} 里用,就不要放到外面。怎么做?

html
<script>
  {
    let age = 18;
    console.log(age); // 正常
  }
  console.log(age) // 报错

  let flag = true;
  if(flag) {
    let str = 'hello world!'
    console.log(str); // 正常
  }
  console.log(str); // 报错

  for(let t = 1; t <= 6; t++) {
    console.log(t); // 正常
  }
  console.log(t); // 报错
</script>

易错点:var 声明的变量不会产生块作用域;不同代码块之间的变量不能互相访问;推荐使用 let 或 const。

全局作用域 ​

<script> 标签和 .js 文件的最外层就是全局作用域,在这里声明的变量在函数内部也可以被访问。为什么需要?因为有些公共数据确实需要多处使用。怎么想?全局变量越少越好,避免被污染。怎么做?

html
<script>
  const name = '小明'

  function sayHi() {
    console.log('你好' + name)
  }

  const flag = true
  let x = 10

  if(flag) {
    let y = 5
    console.log(x + y) // x 是全局的
  }
</script>

易错点:给 window 动态添加属性默认也是全局的,不推荐;函数中未使用任何关键字声明的变量会成为全局变量,不推荐;要尽可能少声明全局变量。

作用域链 ​

嵌套关系的作用域串联起来形成作用域链,本质是底层的变量查找机制,规则是“就近原则”:先在当前作用域找,找不到再逐级向上找父级作用域,直到全局作用域;都找不到就报错。为什么需要?因为内层函数可能用到外层变量,必须有一套确定的查找顺序。怎么做?看代码:

html
<body>
  <script>
    // 全局作用域
    // let a = 11
    let b = 22
    function f() {
      // let a = 1
      function g() {
        // a = 2
        console.log(a)  // ?
      }
      g()
    }
    f()
  </script>
</body>

调试时可按转写稿思路:若 g 里有 a = 2,输出 2;把 a = 2 注释掉,就向上找 f 里的 a = 1,输出 1;再把 f 里的 a 注释掉,就找全局 a = 11,输出 11;若全局也没有,则报 a is not defined。易错点:子作用域能访问父作用域,父作用域不能访问子作用域;查找只向上,不向下。

垃圾回收机制 ​

垃圾回收机制(GC)会自动回收不再使用的内存。为什么需要?因为内存有限,不用的数据要释放。怎么想?内存生命周期分三步:分配内存、使用内存、回收内存。怎么做?声明变量、函数、对象时系统分配内存;读写时使用内存;不再使用时由垃圾回收器回收。易错点:全局变量一般不会回收,关闭页面才回收;局部变量不用了通常会被自动回收;程序中分配的内存由于某种原因未释放或无法释放,叫内存泄漏。

闭包 ​

闭包可以简单理解为:内层函数 + 外层函数的变量。为什么需要?为了封闭数据、实现数据私有,避免全局变量污染。怎么想?外层函数里定义变量和内层函数,外层函数返回内层函数,外部拿到内层函数后仍能访问外层变量。怎么做?

js
<body>
  <script>
    function outer() {
      let count = 1
      function fn() {
        count++
        console.log(`函数被调用${count}次`)
      }
      return fn
    }
    const re = outer()
    re()
    re()
  </script>
</body>

调试时可在浏览器“检查”里打开 Sources,在 console.log 处打断点,展开 Scope,会看到 Closure(闭包),里面能展开出 count。转写稿还演示了:在控制台写 count = 1000 并不会改掉闭包里的 count,因为它是 outer 内的局部变量;再调用 re(),仍然在原来的计数上累加。易错点:每次调用 outer() 都会重新创建 count;闭包可能造成内存泄漏,因为全局变量 re 一直引用 fn,fn 又引用 count,count 迟迟不释放。

变量提升 ​

var 声明的变量允许在声明之前被访问,这叫变量提升。为什么需要了解?因为初学者常被它制造的 bug 困扰。怎么想?提升到当前作用域最前面,只提升变量声明,不提升变量赋值;let/const 不存在变量提升。怎么做?

html
<body>
  <script>
    var age
    console.log(age) // undefined
    age = 18
    console.log(age) // 18

    function fn() {
      var uname
      console.log(uname) // undefined
      uname = 'andy'
    }
    fn()
  </script>
</body>

易错点:函数声明也会提升,但函数表达式不存在提升;函数表达式在声明前调用会报 FUN is not a function。实际开发中推荐先声明再访问变量,并用 let、const 规避变量提升带来的问题。

函数提升 ​

为什么需要:函数声明写在调用之后也能执行,能让代码组织更灵活。转写稿里演示:先写 function fn(){...},再调用正常;把调用写到声明前面,也能输出,因为函数提升会把声明提到当前作用域最前面。

怎么想:函数提升类似变量提升,但要注意:只提升声明,不提升调用;函数表达式不存在提升。

怎么做:

html
<body>
  <script>
    function fn() {
      console.log('函数提升')
    }
    var fun
    // 1.函数提升提升到当前作用域最前面
    // 2.函数提升只提升声明,不提升调用
    // 3.函数表达式不存在提升的现象
    // 4.函数提升能够使函数的声明调用更灵活
    fn()
    fun()
    fun = function () {
      console.log('函数表达式不存在提升')
    }
    fun()
  </script>
</body>

调试时执行到第二个 fun() 会报错:fun is not a function。原因是 var fun 只提升变量声明,值是 undefined,不能用小括号调用;赋值语句仍留在原位置。

易错点:不要把函数表达式当函数声明用。函数表达式必须先赋值再调用。

arguments对象(了解) ​

为什么需要:调用函数时实参个数可能不固定,需要动态获取所有实参。

怎么想:arguments 是函数内部内置的伪数组变量,只存在于函数中,包含调用时传入的所有实参。

怎么做:

html
<body>
  <script>
    function sum() {
      // arguments 只存在于函数中,是伪数组
      let sum = 0
      for (let i = 0; i < arguments.length; i++) {
        sum += arguments[i]
      }
      console.log(sum)
    }
    sum(1, 2)
    sum(1, 2, 3)
    sum(1, 2, 3, 4)
  </script>
</body>

易错点:arguments 是伪数组,不是真数组;函数外部无法使用。箭头函数不支持 arguments。

剩余参数 ​

为什么需要:形参和实参个数不匹配时,想把多余实参收集起来,并得到真数组。

怎么想:... 放在最末函数形参之前,用于获取多余实参并形成真数组。剩余参数是“收集”,把多个元素凝聚成数组。

怎么做:

html
<body>
  <script>
    function sum(...other) {
      let sum = 0
      other.forEach(function (ele) {
        sum += ele
      })
      console.log(sum)
    }
    sum(1, 2)
    sum(1, 2, 3)
    sum(1, 2, 3, 4)
  </script>
</body>

易错点:剩余参数必须放最后;它是真数组,可直接用 forEach。开发中更提倡用剩余参数替代 arguments。

展开运算符(…) ​

为什么需要:需要把数组元素拆开传给函数,或合并数组。

怎么想:剩余参数是“凝聚”,展开运算符是“拆散”,把数组/对象展开成各个元素。

怎么做:

html
<body>
  <script>
    const arr = [1, 2, 3]
    console.log(...arr)
    console.log(Math.max(...arr))
    console.log(Math.min(...arr))
    const arr1 = [1, 2, 3]
    const arr2 = [4, 5, 6]
    console.log([...arr1, ...arr2])
  </script>
</body>

易错点:展开运算符不修改原数组;求最大值时不能写 Math.max(arr),要写 Math.max(...arr)。

箭头函数 ​

为什么需要:匿名函数场景下写法更简洁。箭头函数属于表达式函数,因此不存在函数提升。

怎么想:只有一个参数可省略小括号;函数体只有一句代码可省略大括号并自动返回;返回对象要用小括号包裹;箭头函数没有 arguments,但可用剩余参数。

怎么做:

html
<body>
  <script>
    const sum = x => x + x
    console.log(sum(5))

    const fn = () => ({ name: '佩奇' })
    console.log(fn())

    const getArgs = (...other) => {
      console.log(other)
    }
    getArgs(1, 2)
  </script>
</body>

易错点:返回对象不能写成 () => {name:'佩奇'},会被当成函数体;没有参数也要写空小括号。

箭头函数中的this ​

为什么需要:普通函数 this 由调用方式决定;箭头函数本身没有 this,只沿用上一层作用域的 this,适合定时器等场景。

怎么想:普通函数看“谁调用”;箭头函数看“外层作用域”。转写稿调试:定时器里用普通函数 this 指向 window,改用箭头函数后可沿用按钮事件里的 this。

怎么做:

html
<body>
  <button class="btn1">点击</button>
  <button class="btn2">5秒后启用</button>
  <script>
    document.querySelector('.btn1').addEventListener('click', () => {
      // 此处不能用 this 指向按钮
      document.querySelector('.btn1').style.color = 'red'
    })

    document.querySelector('.btn2').addEventListener('click', function () {
      this.disabled = true
      setTimeout(() => {
        this.disabled = false
      }, 5000)
    })
  </script>
</body>

易错点:需要事件源 this 时不要随意换成箭头函数;箭头函数不能通过调用方式改变 this。

ES6对象简写 ​

为什么需要:属性名和属性值一致、对象方法写法重复时,简写更清晰。

怎么想:属性名和值相同只写属性名;方法可省略 function。

怎么做:

html
<body>
  用户名: <input type="text" class="username"> <br>
  密 码: <input type="password" class="password"> <br>
  <button>点击</button>
  <script>
    document.querySelector('button').addEventListener('click', function () {
      const username = document.querySelector('.username').value
      const password = document.querySelector('.password').value
      const obj = {
        username,
        password,
        sayHi() {
          console.log('hi~')
        }
      }
      console.log(obj)
    })
  </script>
</body>

易错点:只有属性名和属性值同名时才能简写属性;方法简写仍要保留参数列表和小括号。

课件图示 ​

课件第6页:多一句没有,少一句不行,用最短时间,教会最实用的技术! 1.1 局部作用域 局部

课件第7页:多一句没有,少一句不行,用最短时间,教会最实用的技术! 1.1 局部作用域 局部

课件第10页:多一句没有,少一句不行,用最短时间,教会最实用的技术! 1.2 全局作用域 <s

课件第12页:多一句没有,少一句不行,用最短时间,教会最实用的技术! 作用域 局部作用域 全局

课件第14页:多一句没有,少一句不行,用最短时间,教会最实用的技术! 1.3 作用域链 嵌套关

课件第19页:多一句没有,少一句不行,用最短时间,教会最实用的技术! 1.5 闭包 概念:一个

课件第27页:多一句没有,少一句不行,用最短时间,教会最实用的技术! 2.1 函数提升 函数提

课件第43页:多一句没有,少一句不行,用最短时间,教会最实用的技术! 2.3 箭头函数(重要)

代码示例 ​

示例 1:作用域链查找 + 闭包实现数据私有

js
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>作用域链与闭包</title>
</head>
<body>
  <script>
    // ===== 1. 作用域链:就近原则,逐级向上查找 =====
    // 全局作用域
    let a = 11
    let b = 22

    function f() {
      let a = 1            // f 的局部作用域
      function g() {
        // let a = 2        // 放开这行,结果就变成 2
        console.log(a)     // 1 —— 当前没有就向上找 f
      }
      g()
    }
    f()

    // ===== 2. 闭包 = 内层函数 + 外层函数的变量 =====
    function outer() {
      let count = 1        // 局部变量,外部无法直接访问
      function fn() {
        count++
        console.log(`函数被调用${count}次`)
      }
      return fn            // 注意:返回函数本身,不加小括号
    }

    const re = outer()     // re 拿到了 fn
    re()                   // 函数被调用2次
    re()                   // 函数被调用3次

    // 外部改的是全局 count,碰不到闭包里的局部 count
    var count = 1000
    re()                   // 函数被调用4次(没有被污染成 1001)
  </script>
</body>
</html>

关键行与易错点:

  • let a = 1 在 f 内部、let a = 11 在全局,g 中 console.log(a) 按作用域链就近查找,先找到 f 里的 a,结果是 1;把 f 里的 a 注释掉才会输出 11,全都找不到才报 a is not defined。
  • 子作用域能访问父作用域,父作用域不能访问子作用域——查找是“往上冒泡”,不能往下查。
  • return fn 不能写成 return fn(),否则返回的是执行结果 undefined,re() 就会报 re is not a function。
  • count 是 outer 的局部变量,外部无法直接访问和修改,这就是闭包实现“数据私有”的原因;如果按旧写法把 let count = 1 写在全局,别人一句 count = 1000 就会把统计结果污染成 1001。
  • 示例中 var count = 1000 只是用来演示“外部改不到闭包变量”。若直接写 count = 1000(不加关键字),非严格模式下会隐式创建全局变量,严格模式下直接报错,实际开发中不要这么写。
  • 闭包的副作用:re 是全局变量,一直引用着 fn,fn 又引用着 count,导致 count 迟迟得不到回收,可能造成内存泄漏。

示例 2:arguments / 剩余参数 / 展开运算符 / 箭头函数的 this

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>函数参数与箭头函数 this</title>
</head>
<body>
  <button class="btn1">点击</button>
  <button class="btn2">5秒后启用</button>
  <script>
    // ===== 1. arguments:伪数组,只存在于普通函数中 =====
    function sum1() {
      let sum = 0
      for (let i = 0; i < arguments.length; i++) {
        sum += arguments[i]
      }
      console.log(sum)
    }
    sum1(1, 2)        // 3
    sum1(1, 2, 3, 4)  // 10

    // ===== 2. 剩余参数:收集多余实参,得到真数组 =====
    function sum2(...other) {
      let sum = 0
      other.forEach(function (ele) {
        sum += ele
      })
      console.log(sum)
    }
    sum2(1, 2, 3, 4)  // 10

    // ===== 3. 展开运算符:把数组拆成一个个元素 =====
    const arr = [1, 2, 3]
    console.log(Math.max(...arr))          // 3
    console.log(Math.min(...arr))          // 1
    const arr1 = [1, 2, 3]
    const arr2 = [4, 5, 6]
    console.log([...arr1, ...arr2])        // [1, 2, 3, 4, 5, 6]

    // ===== 4. 箭头函数没有自己的 this,沿用上一层作用域 =====
    document.querySelector('.btn1').addEventListener('click', () => {
      // 这里的 this 是 window,不是按钮,写 this.style.color 无效
      document.querySelector('.btn1').style.color = 'red'
    })

    document.querySelector('.btn2').addEventListener('click', function () {
      this.disabled = true                 // 普通函数,this 是按钮
      setTimeout(() => {
        // 箭头函数沿用外层 function 的 this,仍然是按钮
        this.disabled = false
      }, 5000)
    })

    // ===== 5. 箭头函数没有 arguments,但能用剩余参数 =====
    const fn = (...other) => {
      console.log(other)   // [1, 2]
    }
    fn(1, 2)
  </script>
</body>
</html>

关键行与易错点:

  • arguments 是伪数组,只能用 length 和下标遍历,不能用 forEach 等方法;...other 得到的是真数组,可以直接 forEach。
  • 剩余参数 ...other 必须放在形参列表的最后,写成 function sum(...other, x) 是语法错误。
  • 剩余参数是“凝聚”(把多个实参收集成数组),展开运算符是“拆散”(把数组展开成多个元素),两者都用 ...,但位置和方向相反。
  • Math.max(arr) 会得到 NaN,必须写成 Math.max(...arr)。
  • 箭头函数没有自己的 this,示例 4 中 btn1 用箭头函数就取不到按钮,只能改用 document.querySelector('.btn1') 这种写法,或者换成普通函数。
  • setTimeout 里如果写普通 function,this 指向 window,this.disabled = false 就失效了;用箭头函数才能沿用外层按钮的 this。
  • 箭头函数没有 arguments,在箭头函数里访问 arguments 会向上层作用域查找,容易拿到意料之外的结果,应该用剩余参数。

示例 3:var 变量提升与函数提升

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>变量提升与函数提升</title>
</head>
<body>
  <script>
    // ===== 1. var 变量提升:只提升声明,不提升赋值 =====
    console.log(age)   // undefined(不是报错)
    var age = 18
    console.log(age)   // 18

    // 常见面试题:字符串拼接
    console.log(str + 'world')   // undefinedworld
    var str = 'hello'

    // ===== 2. 函数提升:声明之前也能调用 =====
    fn()               // 函数提升
    function fn() {
      console.log('函数提升')
    }

    // ===== 3. 函数表达式不存在提升(放在最后,报错会中断代码) =====
    fun()              // TypeError: fun is not a function
    var fun = function () {
      console.log('函数表达式不存在提升')
    }
  </script>
</body>
</html>

关键行与易错点:

  • var 变量提升到当前作用域的最前面,且只提升声明、不提升赋值,所以声明前访问得到 undefined 而不是报错;如果提升发生在函数内部,就只提升到该函数的最前面,不会跑到全局。
  • str + 'world' 得到 undefinedworld 是字符串拼接,不是报错——这就是“变量提升”最容易出 bug 的地方。
  • let / const 声明的变量不存在变量提升,声明前访问会直接报“Cannot access before initialization”,所以推荐用 let / const。
  • 函数提升让 fn() 可以写在声明之前;但 var fun = function () {} 是函数表达式,本质是给变量赋值,var fun 只提升为 undefined,undefined() 就会报 fun is not a function。
  • 函数表达式必须先声明再调用;把会报错的代码放在最后,避免中断后续演示。

常见错误 ​

  1. 现象:在 if/for 块外访问块内 let 变量报 ReferenceError;原因:把 var 的块行为套到 let/const 上;修法:明确块作用域边界,需要跨块使用就提前在父级作用域声明。
  2. 现象:函数内未加关键字赋值后,外部也能访问该变量;原因:隐式创建了全局变量;修法:函数内始终使用 let/const/var 显式声明,必要时开启严格模式。
  3. 现象:闭包统计次数时,外部修改同名全局变量导致结果异常;原因:状态放在全局作用域,未利用闭包私有化;修法:把状态放入外层函数,通过返回内层函数暴露受控操作。
  4. 现象:箭头函数绑定事件后 this 不是按钮;原因:箭头函数沿用外层 this,不能按调用者动态绑定;修法:需要事件源 this 时用普通函数,或在箭头函数内显式使用 e.currentTarget。
  5. 现象:在函数表达式声明前调用报 xxx is not a function;原因:函数表达式按变量提升处理,调用时值仍为 undefined;修法:函数表达式先声明后调用,或改用函数声明。
  6. 现象:用 arguments 的箭头函数报错;原因:箭头函数没有自己的 arguments;修法:改用剩余参数 ...args 收集实参。

练习 ​

  1. 阅读代码,写出输出结果并说明变量查找路径:
js
let a = 1;
function outer() {
  let a = 2;
  function inner() {
    console.log(a);
  }
  inner();
}
outer();
  1. 将下面代码改为闭包写法,使 count 只能通过调用返回函数递增,外部不能直接修改:
js
let count = 1;
function fn() {
  count++;
  console.log(count);
}
  1. 判断下列调用哪些会报错,并说明原因:
js
foo();
bar();
function foo() { console.log('foo'); }
var bar = function () { console.log('bar'); };
  1. 补全代码:使用剩余参数求任意数量数字之和,并使用展开运算符求数组 [3, 9, 2, 7] 的最大值。
  2. 为按钮绑定点击事件,要求点击后 2 秒再恢复按钮可点击状态,并正确保留按钮 this。请选择普通函数或箭头函数并说明理由。

原视频对照 ​

#视频时长
1今日内容00:58
2作用域回顾06:05
3作用域链05:14
4垃圾回收机制04:40
5闭包16:43
6var变量提升06:57
7函数提升05:13

小结 ​

  1. 作用域问题优先看“声明位置”和“声明关键字”,不要只看代码块外观。
  2. 变量查找按作用域链就近向上,子作用域可读父级,父级不能反向读子级。
  3. 闭包适合做状态私有化,但要把生命周期和引用关系考虑清楚。
  4. var 的变量提升、函数声明提升、函数表达式不提升,是三类不同表现,排查时报错信息只是入口。
  5. 箭头函数的核心差异在 this 和 arguments,事件回调与定时器中尤其要谨慎选择。
  6. 剩余参数用于“收集”,展开运算符用于“拆开”,二者语法相同但场景相反。
  7. 工程中优先 let/const、先声明后使用、减少全局变量,能规避大部分作用域类错误。