昨天晚上非常幸运可以连上袁老师,跟袁老师学习交流了一下。其实我这个人比较社恐,所以交流过程中我全程都比较紧张,而且我这个人的语言组织能力是比较差的那种。昨天我抽的题目是 73,其实这个问题乍一看是比较简单:js 作用域与闭包。但是突然问到我这样一个问题的时候,当时脑海里浮现了一些东西,但是有点乱,没有组织出好的语言。今天回到家仔细整理一下思路,写下这篇文章加深一下理解和印象。
关于作用域
关于作用域问题,我当时想到是作用域链,js 查找一个变量会首先在当前环境下找,如果没有就像外层查找,如果没有再去外层的外层,直到找到全局作用域,如果还没有找到,那就报错(Uncaught ReferenceError: xxx is not defined
)。
但是后面仔细想想才发现,这个问题应该是问作用域的种类(如果要是问 js 作用域有哪几种、或者作用域类型有哪些这样问就更好了)。提到作用域的种类,我立马直接能想到的就是最常用的三种:全局、函数和块级。
后面翻阅 MDN 后会发现还漏了一种:模块作用域。
这四种作用域比较简单,我就不赘述了。如果仔细看袁老师的视频或者翻翻其他文章(JS 的 9 种作用域,你能说出几种?)会发现其实作用域还有好几种,分别是catch、with、script、eval。
根据百度百科关于作用域的定义:作用域(scope),程序设计概念,通常来说,一段程序代码中所用到的变量并不总是有效/可用的,而限定这个变量的可用性的代码范围就是这个变量的作用域 。也就是说,其实作用域就是一个隔离的区域,在这个区域中声明的变量,在区域外是不可访问的,对变量起着隔离作用。
我个人将这些作用域分成两种:通用型作用域 和特殊型作用域 。通用型作用域指的是可以对任意方式声明的变量起到隔离作用,当然特殊型作用域就是只对特殊的声明方式或者特殊的变量起到隔离作用。分类如下:
- 通用型作用域:全局、函数、模块
- 特殊型作用域:块级、catch、with、script、eval
接下来我们来聊一聊剩下的那四种不太注意的作用域。
关于 try catch 作用域
其实准确来说是catch 作用域 ,因为它仅仅指 catch 中的那个 error 能在当前花括号里面才能访问到,这有点类似于函数的参数,如下:
js
try {
throw 123
} catch (error) {
console.log(error) // 123
}
console.log(error) // Uncaught ReferenceError: error is not defined
catch 拿到的 '参数'
,来自于 try 中的 throw,也就是 try 中扔什么,它就收到什么。但是这个和前面的四个普通作用域有一些区别,这个作用域仅仅限制 error,并不能限制其他声明的变量,比如:
js
try {
throw 123
} catch (error) {
var a = 1
}
console.log(a) // 1
这种情况下在 catch 内部声明的 a 变量,在 catch 外部实际上是可以访问到的。所以catch 作用域只作用于 error,不过根据 error 的这种限制特性,它可以作为模拟 ES6 块级作用域的一种解决方案。
关于 eval 作用域
eval 的规则比较乱,我总结了以下三条:
- 在 eval 中使用 var、function 定义的变量可以泄漏到当前作用域;而使用 let、const 定义的不会泄漏
js
var a = 1
const fn = () => {
eval('var a = 2')
console.log(a) // 2
}
fn()
eval('var a = 3')
{
eval('var a = 4')
}
console.log(a) // 4
js
var a = 1
eval('let a = 2')
eval('const a = 3')
console.log(a) // 1
- 如果间接调用 eval 的话,var 声明的变量会泄漏到全局
js
var a = 1
const fn = () => {
const e = eval
e('var a = 2')
console.log(a) // 2
}
fn()
console.log(a) // 2
- 关于 eval 返回值:eval 会将传入的内容作为表达式计算,返回最后一行表达式的计算结果
js
const result1 = eval('1; 2; 3')
const result2 = eval('let a = 1; let b = 1; a + b')
console.log(result1) // 3
console.log(result2) // 2
那么结合作用域的概念,eval 作用域只作用于 let、const 声明的变量。
关于 with 作用域
js
const obj = { a: 1 }
with (obj) {
console.log(a) // 1
a = 2
}
console.log(obj.a) // 2
with 作用域就是将对象拨开一层,在 with 作用域中,可以直接通过对象的属性名对对象属性进行读写。所以 with 作用域只作用于被剥开的对象的那些属性。
关于 script 作用域
html
<script>
let a = 1
const b = 2
var c = 3
</script>
<script>
console.log(a, b, c) // 1 2 3
</script>
我们知道所有的 script 标签都是加载到同一个全局环境中执行 的,所以 script 之间以及 script 和全局之间顶层作用域 都是互通 的。所以我个人感觉把 script 称为一种作用域不是很合理,因为它并没给任何一种变量提供隔离作用。
关于闭包
关于这个问题我相信大家都有很多答案,可以讲出很多东西,但是我昨天竟然一时间没组织出来。我们先来看看 AI 生成的答案,我觉得讲得很好:
- 闭包是函数和其周围状态(词法环境)的引用(函数内部可以访问到外部函数的变量)
- 闭包可以用来模拟私有变量
- 闭包可以用来模拟块级作用域
接下来我来讲讲我昨天没有组织出来的内容,也就是我自己的理解内容:
首先我们知道 C 语言中,在函数中声明的局部变量 ,在函数执行完毕后会立即被销毁,即使通过取地址操作将指针 return 出去也不行。这也是为什么 C 语言中没有闭包这个概念的原因。
那么在 js 中,如果函数内部的局部变量被函数外部引用到的话,那么这些局部变量即使函数执行完毕后也不会被销毁,而是会一直存在,直到外界不再持有它的引用。那么这些本应该在函数运行结束后就立即销毁却实际没有销毁的局部变量,就称之为闭包。也正是 js 具有通过引用外传,延长局部变量存活时间的这种特性,所以才强调闭包这个概念。
关于那道题
html
<html>
<body>
<div id="app"></div>
<script>
const createHandler = () => {
const arr = new Array(1e7).fill(7)
return () => {
console.log(arr)
}
}
let handler = createHandler()
const dom = document.querySelector('#app')
dom.addEventListener('click', handler)
dom.remove()
handler = null
</script>
</body>
</html>
问:以上程序有没有内存泄漏?
其实这个问题主要的卡点在哪里呢?对于我来说,卡点在于 dom.remove()
操作到底有没有移除 click
事件监听 。其实当时我也不知道,但是当时我突然想起了我很久以前看过的八股文:js 内存泄漏常见的几种方式(意外的全局变量、遗忘的定时器、滥用闭包和未移除的 dom 事件)
所以当时我犹豫了一下,觉得 dom.remove()
操作没有移除 click
事件监听,事件监听应该需要手动移除,不过事实确实如此。接下来我们直观感受一下:
我们可以通过 getEventListeners 这个 API 在控制台上查看 dom 元素有哪些事件监听:
当手动移除之后,getEventListeners 的结果就是 {}
了。接下来再多绕一下,其实也很简单了:
html
<html lang="en">
<body>
<div id="app"></div>
<script>
const createHandler = () => {
const arr = new Array(1e7).fill(7)
return () => {
console.log(arr)
}
}
let handler = createHandler()
const btn = document.createElement('button')
document.body.appendChild(btn)
btn.addEventListener('click', handler)
btn.remove()
handler = null
</script>
</body>
</html>
但如果是这种情况,相信大家都能一眼就看出来有没有内存泄漏(答案是有)。
js
const fn = () => {
const arr = new Array(1e7).fill(7)
return () => {
console.log(arr)
}
}
let fn1 = fn()
let fn2 = fn1
fn1 = null
直观感受内存释放
最后我们来直观感受一下,a = null
这个操作到底有没有释放内存。主要用到 performance.memory
这个 API,代码如下:
html
<html>
<body>
<script>
let arr = new Array(1e7).fill(123456789)
console.log(`${Math.ceil(performance.memory.usedJSHeapSize / 1024)} KB`)
setTimeout(() => {
arr = null
console.log('手动释放内存')
}, 3000)
setInterval(() => {
console.log(`${Math.ceil(performance.memory.usedJSHeapSize / 1024)} KB`)
}, 2000)
</script>
</body>
</html>
可以发现,手动释放内存后,js 并不会立即进行内存释放,而是等一小段时间之后再回收内存,这一块跟垃圾回收器的运行机制有关。
以上内容是我个人的理解,可能会有一些纰漏,欢迎各位大佬指正。