深入理解作用域与闭包吧
作用域
作用域 指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限。
或者我们可以将作用域看成是一个封闭的盒子,只让它在这个盒子里面进行操作,也可以称这个盒子为独立作用域。在js中,一个函数要执行时就会在内存里面创建一个独立作用域。
function fn(){
vat a = 1
}
fn()
函数中的变量,只能在函数这个独立作用域中使用。只要跳出这个作用域,就无法访问到该变量。
而且函数执行完毕之后,这个独立作用域会立刻删除。
但是有一种情况下这个封闭的盒子是不会删除的,那就是“闭包”。
作用域类型
全局作用域:全局作用域为程序的最外层作用域,一直存在
函数作用域:只有函数被定义时才会创建,包含在全局作用域内
由于作用域的限制使得,每段独立的执行代码块只能访问自己作用域和外层作用域中的变量,
无法访问到内层作用域的变量。
- 函数内部可以访问全局变量
- 函数外部无法访问局部变量
闭包实现了函数内部的变量访问。
作用域链
当可执行代码内部访问变量时,会先查找本地作用域,如果找到目标变量即返回,否则会去父级作用域继续查找...一直找到全局作用域。我们把这种作用域的嵌套机制,称为 作用域链。
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo(2); // 2 4 12
一共有三层作用域嵌套,分别是:
- 全局作用域
- foo 作用域
- bar 作用域
需要注意,函数参数也在函数作用域中。
词法作用域
作用域共有两种主要的工作模型。
- 词法作用域:最为普遍的,被大多数编程语言所采用,js 通过词法作用域实现。
- 动态作用域:仍有一些编程语言在使用,比如 Bash 脚本、Perl 中的一些模式等。
词法作用域,就意味着函数被定义的时候,它的作用域就已经确定了,和在哪执行无关,
因此词法作用域也被称为 “静态作用域”
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
bar(); // 1
foo 里访问了本地作用域中没有的变量 value 。根据前面说的,引擎为了拿到这个变量就要去 foo 的上层作用域查询,取决于 foo 在定义时的上级作用域即全局作用域,获取全局作用域的 vaule 值为 1。
块级作用域
{} 这个花括号里面的作用域就是块级作用域。 外层作用域无法获取内层作用域 { } 代码块内部声明的变量或函数只在该代码块内部可见,超出该代码块范围就无法访问。在ES6之前,JavaScript中只有函数作用域和全局作用域,没有块级作用域。
ES6 中引入了let和const关键字,使得我们可以在代码块中声明变量,并将其限制在该代码块内部。
function foo() {
var a = 1; if (true) {
let b=2;
const c= 3;
console.log(a); //1
console.1og(b); //2
console.log(c); //3
}
console.log(a); //1
console.log(b); //ReferenceError: b is not defined console.log(c);//ReferenceError:c is not defined }
foo();
在上面的代码中,变量a是使用var关键字声明的,它的作用域是整个函数foo。变量b和c是使用let和const关键字声明的,它们的作用域被限制在if代码块内部。在if代码块内部,我们可以访问变量a、b和c的值,但在if代码块外部,我们只能访问变量a的值,访问变量b和c会报错。
使用let和const声明的变量具有块级作用域,块级作用域可以避免变量污染全局作用域,提高代码的可读性和可维护性。在实际开发中,我们应该尽量使用let和const来声明变量,避免使用var。
函数定义和调用
函数的定义方式
- 函数: 函数声明方式 function fn(){}
- 匿名函数(函数表达式) var fn = function(){}
- 实例化Function new Function()
- var fn = new Function('参数1','参数2','参数3')
- Function 里面参数都必须是字符串格式
- 所有函数都是 Function 的实例(对象)
- 函数也属于对象
高阶函数
一个函数的参数是函数,或者返回值是函数,满足其中一个就是高阶函数
高阶函数是对其他函数进行操作的函数,它接收函数作为参数或将函数作为返回值输出。
- 接收函数作为参数--->回调函数
- 函数作为返回值输出
常见的高阶函数
map()
const arr = [1, 2, 3];
const newArr = arr.map(item => item * 2);
console.log(newArr); //[2, 4, 6]
reduce()
const arr = [1, 2, 3];
let sum = arr.reduce((accumulator, currentValue, currentIndex, array) => {
return accumulator + currentValue;
});
console.log(sum); // 10
const arr = [1, 2, 3];
let sum1 = arr.reduce((accumulator, currentValue, currentIndex, array) => {
return accumulator + currentValue;
}, 10);
console.log(sum1); // 20
sort()
const arr = [1, 20, 10, 5];
let compareNumbers= function (a, b) {
return a - b;
}
const newArr = arr.sort(compareNumbers);
console.log(newArr); // [1, 5, 10, 20]
柯里化函数
把接受多个参数的函数转换成接受一个单一参数的函数。 把函数与传递给他的参数相结合产生出一个新的函数。
柯里化是指这样一个函数(假设叫做createCurry),他接收函数A作为参数,运行后能够返回一个新的函数。并且这个新的函数能够处理函数A的剩余参数。
function curry(fn) {
let len = fn.length;
return function reply() {
let innerLength = arguments.length,
args = Array.prototype.slice.call(arguments);
if (innerLength >= len) { // 递归结束
return fn.apply(null, args)
} else {
return function() {
let innerArgs = Array.prototype.slice.call(arguments),
allArgs = args.concat(innerArgs);
return reply.apply(null, allArgs)
}
}
}
}
function add(num1, num2,num3) {
return num1 + num2 + num3;
}
var curriedAdd = curry(add);
//console.log(curriedAdd(2)(3)(3))//8
//或者
console.log(curriedAdd(2,3,3))//8
柯里化不会调用函数。它只是对函数进行转换,能够将多个参数的函数转换成单参数的函数链
闭包
一个内部函数除了可以访问自身的参数和变量,同时也可以自由访问父函数的参数和变量。
通过函数字面量创建的函数对象包含一个连到外部上下文的链接。
闭包的本质是利用了作用域的机制,来达到外部作用域访问内部作用域的目的。
闭包的作用: 读取函数内部的变量,让这些变量的值始终保持在内存中。
闭包产生的问题:
- 由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。
- 闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。
闭包的几种表现形式
- 返回一个函数
- 作为函数参数传递
- 回调函数
- 非典型闭包IIFE(立即执行函数表达式)
返回一个函数:这种形式的闭包在JavaScript的代码编写中,是非常常见的一种方式。
var a = 1;
function foo(){
var a = 2;
// 这就是闭包
return function(){
console.log(a);
}
}
var bar = foo();
// 输出2,而不是1
bar();
作为函数参数传递:无论通过何种手段将内部函数传递到它所在词法作用域之外,它都会持有对原始作用域的引用,无论在何处执行这个函数,都会产生闭包。
var a = 1;
function foo(){
var a = 2;
function baz(){
console.log(a);
}
bar(baz);
}
function bar(fn){
// 这就是闭包
fn();
}
// 输出2,而不是1
foo();
回调函数:在定时器、事件监听、Ajax请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。
// 定时器
setTimeout(function timeHandler(){
console.log('timer');
},100)
// 事件监听
$('#container').click(function(){
console.log('DOM Listener');
})
IIFE:IIFE(立即执行函数表达式)并不是一个典型的闭包,但它确实创建了一个闭包。
var a = 2;
(function IIFE(){
// 输出2
console.log(a);
})();
闭包的使用场景
防抖节流
防抖和节流是 JavaScript 中常用的两种优化性能的技术,它们都可以通过闭包来实现。
防抖是指在事件被触发 n 秒后再执行回调函数,如果在这 n 秒内又被触发,则重新计时。防抖可以用于优化输入框输入、窗口大小变化等频繁触发事件的场景。以下是一个使用闭包实现防抖的示例:
function debounce(fn, delay) {
let timer;
return function() {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(function() {
fn.apply(context, args);
}, delay);
}
}
节流是指在一定时间间隔内只执行一次回调函数,如果在这个时间间隔内又被触发,则忽略该次触发。节流可以用于优化滚动事件、鼠标移动等高频触发事件的场景。以下是一个使用闭包实现节流的示例:
function throttle(fn, delay) {
let timer;
let lastTime = 0;
return function() {
const context = this;
const args = arguments;
const now = Date.now();
if (now - lastTime >= delay) {
fn.apply(context, args);
lastTime = now;
} else {
clearTimeout(timer);
timer = setTimeout(function() {
fn.apply(context, args);
lastTime = now;
}, delay - (now - lastTime));
}
}
}
柯里化函数
复用参数,在传入参数的基础上生成另一个全新的函数
柯里化的目的在于避免频繁调用具有相同参数函数的同时,又能够轻松的重用
// 假设我们有一个求长方形面积的函数
function getArea(width, height) {
return width * height
}
// 如果我们碰到的长方形的宽老是10
const area1 = getArea(10, 20)
const area2 = getArea(10, 30)
const area3 = getArea(10, 40)
// 我们可以使用闭包柯里化这个计算面积的函数
function getArea(width) {
return height => {
return width * height
}
}
const getTenWidthArea = getArea(10)
// 之后碰到宽度为10的长方形就可以这样计算面积
const area1 = getTenWidthArea(20)
// 而且如果遇到宽度偶尔变化也可以轻松复用
const getTwentyWidthArea = getArea(20)
使用闭包模拟私有方法
在JavaScript中,没有支持声明私有变量,但我们可以使用闭包来模拟私有方法
var Counter = (function() {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
})();
var Counter1 = makeCounter();
var Counter2 = makeCounter();
console.log(Counter1.value()); /* logs 0 */
Counter1.increment();
Counter1.increment();
console.log(Counter1.value()); /* logs 2 */
Counter1.decrement();
console.log(Counter1.value()); /* logs 1 */
console.log(Counter2.value()); /* logs 0 */
上述通过使用闭包来定义公共函数,并令其可以访问私有函数和变量,这种方式也叫模块方式
两个计数器 Counter1 和 Counter2 是维护它们各自的独立性的,每次调用其中一个计数器时,通过改变这个变量的值,会改变这个闭包的词法环境,不会影响另一个闭包中的变量
其他
计数器、延迟调用、回调等闭包的应用,其核心思想还是创建私有变量和延长变量的生命周期
例题
例1 以下代码运行结果是什么,如何改进?
for(var i=1;i<=5;i++){
setTimeout(function timer(){
console.log(i)
}, i*1000)
}
在循环期间依次创建定时器,并且定时器的回调在循环结束后才开始执行
for循环结束后,用var i定义的变量i此时等于6
依次执行五个定时器,都打印变量i,所以结果是打印5次6
第一种改进方法:利用IIFE(立即执行函数表达式)当每次 for 循环时,把此时的i变量传递到定时器中
for(var i = 1; i<=5; i++){
(function fn(j){
setTimeout(function timer(){
console.log(j)
},i*1000)
})(i)
第二种方法:在循环中使用 let i 代替 var i
for(let i=1;i<=5;i++){
setTimeout(function timer(){
console.log(i)
}, i*1000)
}
第三种方法:setTimeout 函数的第三个参数,可以作为定时器执行时的变量进行使用
for(var i=1;i<=5;i++){
setTimeout(function timer(j){
console.log(j)
}, i*1000, i)
}
例2:写出下列的输出结果
function foo() {
var i = 0;
return function () {
console.log(i++);
}
}
var f1 = foo();
var f2 = foo();
f1(); // 0
f1(); // 1
f1(); // 2
f1(); // 3
f2(); // 0
f2(); // 1
f2(); // 2
f2(); // 3
f2(); // 4
闭包可以创建一个独立的环境,每个闭包里面的环境都是独立的,互不干扰。
总结
当函数执行完毕,本作用域内的局部变量会销毁。
闭包的优点:
- 能够读取函数内部的变量
- 让这些变量一直存在于内存中,不会在调用结束后,被垃圾 回收机制回收
闭包的缺点: 由于闭包会使函数中的变量保存在内存中,造成内存泄漏(有一块内存空间被长期占用,而不被释放)内存消耗很大,所以不能滥用闭包
内存泄漏
内存泄漏(Memory leak)程序未能释放已经不再使用的内存,应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。
程序的运行需要内存。只要程序提出要求,操作系统或者运行时就必须供给内存
对于持续运行的服务进程,必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。
在C语言中,因为是手动管理内存,内存泄露是经常出现的事情。
char * buffer;
buffer = (char*) malloc(42);
// Do something with buffer
free(buffer);
上面是 C 语言代码,malloc方法用来申请内存,使用完毕之后,必须自己用free方法释放内存。
大多数语言提供自动内存管理,减轻程序员的负担,这被称为"垃圾回收机制"
垃圾回收机制
执行环境会负责管理代码执行过程中使用的内存
原理:垃圾收集器会定期(周期性)找出那些不在继续使用的变量,然后释放其内存
常见的垃圾回收机制:
- 标记清除
- 引用计数
标记清除
标记清除是 javascript 最常用的垃圾回收机制:
当变量进入执行环境时,就标记这个变量为“进入环境“;进入环境的变量所占用的内存就不能释放当变量离开环境时,则将其标记为“离开环境“。
垃圾回收程序运行的时候,会标记内存中存储的所有变量。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉
在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了
随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存
var m = 0,n = 19 // 把 m,n,add() 标记为进入环境。
add(m, n) // 把 a, b, c标记为进入环境。
console.log(n) // a,b,c标记为离开环境,等待垃圾回收。
function add(a, b) {
a++
var c = a + b
return c
}
引用计数
语言引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放
如果一个值不再需要了,引用数却不为0,垃圾回收机制无法释放这块内存,从而导致内存泄漏
const arr = [1, 2, 3, 4];
console.log('hello world');
上面代码中,数组[1, 2, 3, 4]是一个值,会占用内存。变量arr是仅有的对这个值的引用,因此引用次数为1。尽管后面的代码没有用到arr,它还是会持续占用内存
如果需要这块内存被垃圾回收机制释放,只需要设置如下:
arr = null
通过设置arr为null,就解除了对数组[1,2,3,4]的引用,引用次数变为 0,就被垃圾回收了
内存泄漏的情况
- 未声明的变量会成为全局变量
function foo(arg) {
bar = "this is a hidden global variable";
}
- this 造成的全局变量
function foo() {
this.variable = "potential accidental global";
}
// foo 调用自己,this 指向了全局对象(window)
foo();
上述使用严格模式,可以避免意外的全局变量
- 定时器
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
// 处理 node 和 someResource
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);
如果id为Node的元素从DOM中移除,该定时器仍会存在,同时因为回调函数中包含对someResource的引用,定时器外面的someResource也不会被释放
- 闭包维持函数的局部变量使其得不到释放
function bindEvent() {
var obj = document.createElement('XXX');
var unused = function () {
console.log(obj, '闭包内引用obj obj不会被释放');
};
obj = null; // 解决方法
}
- 没有清理对DOM元素的引用同样造成内存泄露
const refA = document.getElementById('refA');
document.body.removeChild(refA); // dom删除了
console.log(refA, 'refA'); // 但是还存在引用能console出整个div 没有被回收
refA = null;
console.log(refA, 'refA'); // 解除引用
- 事件监听 addEventListener 不及时移除监听会造成内存泄漏
使用事件监听addEventListener监听的时候,在不监听的情况下使用removeEventListener取消对事件监听,否则会造成内存泄漏
总结
闭包的本质是利用了作用域的机制,来达到外部作用域访问内部作用域的目的。 闭包的使用场景非常广泛,然而过度使用会导致闭包内的变量所占用的内存空间无法释放,带来内存泄露的问题。
参考
转载自:https://juejin.cn/post/7225009769094119481