主题
3-1作用域链+JS垃圾回收机制+闭包
本页对应 第三章JavaScript进阶,共 7 段视频,约 45 分钟。
学习目标
- 能区分全局作用域、函数作用域和块级作用域,并判断给定代码中变量在哪些位置可访问、哪些位置会报错。
- 能根据嵌套函数代码分析作用域链的变量查找过程,准确推导变量输出结果,并说明“就近原则”及子作用域可访问父作用域、父作用域不可访问子作用域。
- 能描述垃圾回收机制与内存生命周期(分配、使用、回收),并判断全局变量、局部变量的回收时机及内存泄漏的含义。
- 能用自己的话定义闭包,写出使用闭包实现数据私有和统计函数调用次数的代码,并能借助浏览器调试工具识别闭包结构。
- 能分析
var变量提升的执行过程,预测声明前访问、函数内声明等代码的输出结果,并说明“只提升声明、不提升赋值”。 - 能区分函数声明与函数表达式的提升行为,预测声明前调用时的执行结果或报错信息,并解释原因。
知识点
作用域总览
作用域(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>易错点:只有属性名和属性值同名时才能简写属性;方法简写仍要保留参数列表和小括号。
课件图示








代码示例
示例 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。 - 函数表达式必须先声明再调用;把会报错的代码放在最后,避免中断后续演示。
常见错误
- 现象:在
if/for块外访问块内let变量报ReferenceError;原因:把var的块行为套到let/const上;修法:明确块作用域边界,需要跨块使用就提前在父级作用域声明。 - 现象:函数内未加关键字赋值后,外部也能访问该变量;原因:隐式创建了全局变量;修法:函数内始终使用
let/const/var显式声明,必要时开启严格模式。 - 现象:闭包统计次数时,外部修改同名全局变量导致结果异常;原因:状态放在全局作用域,未利用闭包私有化;修法:把状态放入外层函数,通过返回内层函数暴露受控操作。
- 现象:箭头函数绑定事件后
this不是按钮;原因:箭头函数沿用外层this,不能按调用者动态绑定;修法:需要事件源this时用普通函数,或在箭头函数内显式使用e.currentTarget。 - 现象:在函数表达式声明前调用报
xxx is not a function;原因:函数表达式按变量提升处理,调用时值仍为undefined;修法:函数表达式先声明后调用,或改用函数声明。 - 现象:用
arguments的箭头函数报错;原因:箭头函数没有自己的arguments;修法:改用剩余参数...args收集实参。
练习
- 阅读代码,写出输出结果并说明变量查找路径:
js
let a = 1;
function outer() {
let a = 2;
function inner() {
console.log(a);
}
inner();
}
outer();- 将下面代码改为闭包写法,使
count只能通过调用返回函数递增,外部不能直接修改:
js
let count = 1;
function fn() {
count++;
console.log(count);
}- 判断下列调用哪些会报错,并说明原因:
js
foo();
bar();
function foo() { console.log('foo'); }
var bar = function () { console.log('bar'); };- 补全代码:使用剩余参数求任意数量数字之和,并使用展开运算符求数组
[3, 9, 2, 7]的最大值。 - 为按钮绑定点击事件,要求点击后 2 秒再恢复按钮可点击状态,并正确保留按钮
this。请选择普通函数或箭头函数并说明理由。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 今日内容 | 00:58 |
| 2 | 作用域回顾 | 06:05 |
| 3 | 作用域链 | 05:14 |
| 4 | 垃圾回收机制 | 04:40 |
| 5 | 闭包 | 16:43 |
| 6 | var变量提升 | 06:57 |
| 7 | 函数提升 | 05:13 |
小结
- 作用域问题优先看“声明位置”和“声明关键字”,不要只看代码块外观。
- 变量查找按作用域链就近向上,子作用域可读父级,父级不能反向读子级。
- 闭包适合做状态私有化,但要把生命周期和引用关系考虑清楚。
var的变量提升、函数声明提升、函数表达式不提升,是三类不同表现,排查时报错信息只是入口。- 箭头函数的核心差异在
this和arguments,事件回调与定时器中尤其要谨慎选择。 - 剩余参数用于“收集”,展开运算符用于“拆开”,二者语法相同但场景相反。
- 工程中优先
let/const、先声明后使用、减少全局变量,能规避大部分作用域类错误。