【面试】JavaScript手撕面试题总结
防抖和节流
防抖(deounce)
定义
防抖指只有在n秒内没有再次触发某个函数时,才会真正执行这个函数;若n秒内再次触发此函数,则重新计算时间。
应用场景
- 输入框中频繁的输入内容,搜索或者提交信息;
- 频繁的点击按钮,触发某个事件;
- 监听浏览器滚动事件完成操作;
- 用户缩放浏览器的resize事件(浏览器窗口大小发生变化时触发)
代码实现
1 | function debounce(func, wait) { |
节流(throttle)
定义
节流指一定时间内回调函数只会执行一次,即执行函数的频率是固定的。
类似于10 分钟一趟的公交车,不管10 分钟内有多少人在公交站等,10 分钟一到就会按时发车。
应用场景
- 监听页面的滚动事件;
- 鼠标移动事件;
- 用户频繁点击按钮操作;
- 游戏中的一些设计(如飞船发射导弹的频率)
代码实现
-
使用时间戳写法,事件会立即执行,停止触发后没有办法再次执行
1
2
3
4
5
6
7
8
9
10function throttled1(fn, delay = 500) {
let oldtime = Date.now()
return function (...args) {
let newtime = Date.now()
if (newtime - oldtime >= delay) {
fn.apply(null, args)
oldtime = Date.now()
}
}
} -
使用定时器写法,
delay
毫秒后第一次执行,第二次事件停止触发后依然会再一次执行1
2
3
4
5
6
7
8
9
10
11function throttled2(fn, delay = 500) {
let timer = null
return function (...args) {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay);
}
}
} -
可以将时间戳写法的特性与定时器写法的特性相结合,实现一个更加精确的节流。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17function throttled(fn, delay) {
let timer = null
let starttime = Date.now()
return function () {
let curTime = Date.now() // 当前时间
let remaining = delay - (curTime - starttime) // 从上一次到现在,还剩下多少多余时间
let context = this
let args = arguments
clearTimeout(timer)
if (remaining <= 0) {
fn.apply(context, args)
starttime = Date.now()
} else {
timer = setTimeout(fn, remaining);
}
}
}
防抖和节流的区别
相同点:
- 都可以通过使用
setTimeout
实现 - 目的都是,降低回调执行频率,节省计算资源
不同点:
-
函数防抖,在一段连续操作结束后,处理回调,利用
clearTimeout
和setTimeout
实现函数节流,在一段连续操作中,每一段时间只执行一次,频率较高的事件中使用来提高性能
-
函数防抖关注一定时间连续触发的事件,只在最后执行一次,而函数节流一段时间内只执行一次
深拷贝和浅拷贝
数据类型存储
JavaScript中存在两大数据类型:
- 基本类型
- 引用类型
基本类型数据保存在在栈内存中
引用类型数据保存在堆内存中(引用数据类型的变量是一个指向堆内存中实际对象的引用,存在栈中)
深拷贝
概念:深拷贝开辟一个新的栈,两个对象属性完全相同,但是对应两个不同的地址,修改一个对象的属性,不会改变另一个对象的属性。
常见的深拷贝方式:
- _.cloneDeep()
- jQuery.extend()
- JSON.stringify()
- 手写循环递归
_.cloneDeep()
1 | const _ = require('lodash'); |
jQuery.extend()
1 | const $ = require('jquery'); |
JSON.stringify()
1 | const obj2=JSON.parse(JSON.stringify(obj1)); // JSON.parse() 方法将数据转换为 JavaScript 对象 |
但是这种方式存在弊端,会忽略undefined
、symbol
和函数
1 | const obj = { |
循环递归
1 | function deepClone(obj, hash = new WeakMap()) { |
浅拷贝
-
浅拷贝,指的是创建新的数据,这个数据有着原始数据属性值的一份精确拷贝
-
如果属性是基本类型,拷贝的就是基本类型的值;如果属性是引用类型,拷贝的就是内存地址。
即浅拷贝是拷贝一层,深层次的引用类型则共享内存地址。
下面简单实现一个浅拷贝
1 | function shallowClone(obj) { |
在JavaScript
中,存在浅拷贝的现象有:
Object.assign
Array.prototype.slice()
,Array.prototype.concat()
- 使用拓展运算符实现的复制
Object.assign
Object.assign()
方法将属性从一个或多个源对象复制到目标对象,返回修改后的对象。
1 | const target = { a: 1, b: 2 }; |
slice()
Array.slice()
方法从数组中返回选定的元素,作为一个新数组。
1 | var arr=['aa','bb','cc','dd','ee','ff']; |
concat()
concat()
方法用于连接两个或多个数组。
- 该方法不会更改现有数组,而是返回一个新数组,其中包含已连接数组的值。
1 | var sedan = ["S60", "S90"]; |
拓展运算符
扩展运算符(...args)
是ES6的语法,用于取出参数对象的所有可遍历属性,然后拷贝到当前对象之中。
1 | let person = {name: "Amy", age: 15} |
拓展运算符特殊用法
数组
由于数组是特殊的对象,所以对象的扩展运算符也可以用于数组。
1 | let foo = { ...['a', 'b', 'c'] }; |
空对象
如果扩展运算符后面是一个空对象,则没有任何效果。
1 | let a = {...{}, a: 1} |
Number类型、Boolen类型、undefined、null
如果扩展运算符后面是上面这几种类型,都会返回一个空对象,因为它们没有自身属性。
1 | // 等同于 {...Object(1)} |
字符串
如果扩展运算符后面是字符串,它会自动转成一个类似数组的对象
1 | {...'hello'} |
对象的合并
1 | let age = {age: 15} |
注意事项
自定义的属性和拓展运算符对象里面属性的相同的时候:
自定义的属性在拓展运算符后面,则拓展运算符对象内部同名的属性将被覆盖掉。
1 | let person = {name: "Amy", age: 15}; |
自定义的属性在拓展运算度前面,则变成设置新对象默认属性值。
1 | let person = {name: "Amy", age: 15}; |
深拷贝和浅拷贝的区别
从上图发现,浅拷贝和深拷贝都创建出一个新的对象,但在复制对象属性的时候,行为就不一样。
-
浅拷贝
只复制属性指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存,修改对象属性会影响原对象。1
2
3
4
5
6
7
8
9
10
11// 浅拷贝
const obj1 = {
name : 'init',
arr : [1,[2,3],4],
};
const obj3=shallowClone(obj1) // 一个浅拷贝方法
obj3.name = "update";
obj3.arr[1] = [5,6,7] ; // 新旧对象还是共享同一块内存
console.log('obj1',obj1) // obj1 { name: 'init', arr: [ 1, [ 5, 6, 7 ], 4 ] }
console.log('obj3',obj3) // obj3 { name: 'update', arr: [ 1, [ 5, 6, 7 ], 4 ] } -
深拷贝`会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。
1
2
3
4
5
6
7
8
9
10
11// 深拷贝
const obj1 = {
name : 'init',
arr : [1,[2,3],4],
};
const obj4=deepClone(obj1) // 一个深拷贝方法
obj4.name = "update";
obj4.arr[1] = [5,6,7] ; // 新对象跟原对象不共享内存
console.log('obj1',obj1) // obj1 { name: 'init', arr: [ 1, [ 2, 3 ], 4 ] }
console.log('obj4',obj4) // obj4 { name: 'update', arr: [ 1, [ 5, 6, 7 ], 4 ] }
前提为拷贝类型为引用类型的情况下:
- 浅拷贝是拷贝一层,属性为对象时,浅拷贝是复制,两个对象指向同一个地址
- 深拷贝是递归拷贝深层次,属性为对象时,深拷贝是新开栈,两个对象指向不同的地址
原型和原型链
原型
概念:JavaScript
常被描述为一种基于原型的语言——每个对象拥有一个原型对象。
-
当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。
-
准确地说,这些属性和方法定义在Object的构造器函数(constructor functions)之上的
prototype
属性上,而非实例对象本身。
下面举个例子:
函数可以有属性, 每个函数都有一个特殊的属性叫作原型prototype
1 | function doSomething(){} |
控制台输出
1 | { |
上面这个对象,就是大家常说的原型对象。
可以看到,原型对象有一个自有属性constructor
,这个属性指向该函数,如下图关系展示:
原型链
概念:原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain),它解释了为何一个对象会拥有定义在其他对象中的属性和方法。
在对象实例和它的构造器之间建立一个链接(它是
__proto__
属性,是从构造函数的prototype
属性派生的),之后通过上溯原型链,在构造器中找到这些属性和方法。
下面举个例子:
1 | function Person(name) { |
根据代码,得到下图:
下面分析一下:
- 构造函数
Person
存在原型对象Person.prototype
- 构造函数生成实例对象
person
,person
的__proto__
指向构造函数Person
原型对象 Person.prototype.__proto__
指向内置对象,因为Person.prototype
是个对象,默认是由Object
函数作为类创建的,而Object.prototype
为内置对象Person.__proto__
指向内置匿名函数anonymous
,因为 Person 是个函数对象,默认由 Function 作为类创建Function.prototype
和Function.__proto__
同时指向内置匿名函数anonymous
,这样原型链的终点就是null
总结
__proto__
作为不同对象之间的桥梁,用来指向创建它的构造函数的原型对象。
每个对象的__proto__
都是指向它的构造函数的原型对象prototype
的
1 | person1.__proto__ === Person.prototype |
构造函数是一个函数对象,是通过 Function
构造器产生的
1 | Person.__proto__ === Function.prototype |
原型对象本身是一个普通对象,而普通对象的构造函数都是Object
1 | Person.prototype.__proto__ === Object.prototype |
刚刚上面说了,所有的构造器都是函数对象,函数对象都是 Function
构造产生的
1 | Object.__proto__ === Function.prototype |
Object
的原型对象也有__proto__
属性指向null
,null
是原型链的顶端
1 | Object.prototype.__proto__ === null |
下面作出总结:
- 一切对象都是继承自
Object
对象,Object
对象直接继承根源对象null
- 一切的函数对象(包括
Object
对象),都是继承自Function
对象 Object
对象直接继承自Function
对象Function
对象的__proto__
会指向自己的原型对象,最终还是继承自Object
对象
继承
在编写代码时,有些对象会有方法(函数),如果把这些方法都放在构造函数中声明就会导致内存的浪费。
如下,通过调用构造函数的方式来创建对象,Person
是 p1
、p2
的构造函数。所有的 Person
对象都有 say
方法,并且功能相似,但是他们占据了不同的内存,会导致内存浪费(内存泄露)。
1 | function Person() { |
于是,我们就需要用到继承。
通过某种方式让一个对象可以访问到另一个对象中的属性和方法,我们把这种方式称之为继承。
实现方式
主流通常有 8 种方式:
- 原型链继承
- 借用构造函数继承
- 组合模式继承
- 共享原型继承
- 原型式继承
- 寄生式继承
- 寄生组合式继承
- ES6 中 class 的继承(新)
原型链继承
通过实例化一个新的函数,子类的原型指向了父类的实例,子类就可以调用其父类原型对象上的私有属性和公有方法。(本质就是重写了原型对象)
代码示例:
1 | function Parent() { |
需要注意的问题
1)别忘记默认的类型
我们知道,所有的引用类型都继承了 Object,而这个继承也是通过原型链实现的。所以所有的对象都拥有 Object 具有的一些默认的方法。如:hasOwnProperty()
、propertyIsEnumerable()
、toLocaleString()
、toString()
和 valueOf()
。
2)确定原型和实例的关系
可以通过两种方式来确定原型和实例之间的关系。
- 第一种:使用
instanceof
操作符,只要用这个操作符来测试实例与原型链中出现过的构造函数,结果就会返回true
。 - 第二种:使用
isPrototypeOf()
方法。同样,只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型,因此isPrototypeOf()
方法也会返回true
。
还是上面的代码,尝试打印一些比对关系:
1 | console.log(c instanceof Object); //true |
3)子类要在继承后定义新方法
因为,原型链继承实质上是重写原型对象。所以,如果在继承前就在子类的 prototype 上定义了一些方法和属性,那么继承后,子类的这些属性和方法将会被覆盖。
4)不能使用对象字面量创建原型方法
这个的原理跟上一条的实际上是一样的。当你使用对象字面量创建原型方法重写原型的时候,实质上相当于重写了原型链,所以原来的原型链就被切断了。
代码示例:
1 | function Parent() { |
5)注意父类包含引用类型的情况
代码示例:
1 | function Parent() { |
上述代码执行后,输出结果为:
1 | "c1" |
这个例子中的 Parent 构造函数定义了一个 hobbies 属性,该属性包含一个数组(引用类型值)。Parent 的每个实例都会有各自包含自己数组的 hobbies 属性。当 Child 通过原型链继承了 Parent 之后,Child.prototype 就变成了 Parent 的一个实例,因此它也拥有了一个它自己的 hobbies 属性 —— 就跟专门创建了一个 Child.prototype.hobbies 属性一样。但结果是什么呢?结果是 Child 的所有实例都会共享这一个 hobbies 属性。而我们对 c1.hobbies 的修改能够通过 c2.hobbies 反映出来。也就是说,这样的修改会影响各个实例。
原型链继承的优点
- 简单,易实现
- 父类新增原型方法/原型属性,子类都能访问
原型链继承的缺点
- 无法实现多继承
- 引用类型的值会被实例共享
- 子类型无法给超类型传递参数
鉴于这些缺点,实践中很少会单独使用原型链继承。
借用构造函数继承(对象冒充)
在解决原型链继承中包含引用类型值所带来问题的过程中,开发人员开始使用一种叫做借用构造函数(constructor stealing)的技术。
这种技术的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数。
1 | function Parent(name) { |
上述代码执行后,输出结果为:
1 | ["sing", "dance", "rap", "coding"] // false |
借用构造函数的基本思想就是利用 call
或者 apply
把父类中通过 this
指定的属性和方法复制(借用)到子类创建的实例中。
因为 this
对象是在运行时基于函数的执行环境绑定的。也就是说,在全局中,this
等于 window
,而当函数被作为某个对象的方法调用时,this
等于那个对象。call
、apply
方法可以用来代替另一个对象调用一个方法。call
、apply
方法可将一个函数的对象上下文从初始的上下文改变为由 thisObj 指定的新对象。
所以,这个借用构造函数就是,new
对象的时候(注意,new
操作符与直接调用是不同的,以函数的方式直接调用的时候,this
指向 window
,new
创建的时候,this
指向创建的这个实例),创建了一个新的实例对象,并且执行 Child 里面的代码,而 Child 里面用 call
调用了 Parent,也就是说把 this
指向改成了指向新的实例,所以就会把 Parent 里面的 this
相关属性和方法赋值到新的实例上,而不是赋值到 Child 上面。所有实例中就拥有了父类定义的这些 this
的属性和方法。
借用构造函数的优点
- 解决了引用类型的值被实例共享的问题
- 可以向超类传递参数
- 可以实现多继承(call 若干个超类)
借用构造函数的缺点
- 不能继承超类原型上的属性和方法
- 无法实现函数复用,由于 call 有多个父类实例的副本,性能损耗。
- 原型链丢失
组合模式继承
组合继承(combination inheritance),有时候也叫做伪经典继承。是将原型链继承和借用构造函数继承的技术组合到一块,从而发挥二者之长的一种继承模式。
1 | function Parent(name){ |
这种继承方式看起来似乎没有问题,但是它却调用了 2 次超类型构造函数:一次在子类构造函数内,另一次是将子类的原型指向父类构造的实例,导致生成了 2 次 name 和 hobbies,只不过实例屏蔽了原型上的console.log(c1)
。虽然达成了目的,却不是我们最想要的。
这个问题将在寄生组合式继承里得到解决。
共享原型继承
这种方式下子类和父类共享一个原型。
1 | function Parent(){} |
共享原型继承的优点
- 简单
共享原型继承的缺点
- 只能继承父类原型属性方法,不能继承构造函数属性方法
- 与原型链继承一样,存在引用类型问题
原型式继承
这种继承方式普遍用于基于当前已有对象创建新对象。
在 ES5 之前实现方法:
1 | function createAnother(o) { |
ES5 新增了 Object.create()
方法规范化了原型式继承。调用方法为:Object.create(o)
,如下代码所示:
1 | // 用法一:创建一个纯洁的对象:对象什么属性都没有 |
寄生式继承
寄生式继承是原型式继承的加强版,它结合原型式继承和工厂模式,创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后返回对象。
1 | function createAnother(origin) { |
在上述例子中,createAnother 函数接收了一个参数,也就是将要被继承的对象。
o2 是基于 o1 创建的一个新对象,新对象不仅具有 o1 的所有属性和方法,还有自己的 sayHi() 方法。
简单而言,寄生式继承在产生了这个继承父类的对象之后,为这个对象添加了一些增强方法。
寄生式继承的优点
没啥优点
寄生式继承的缺点
原型式继承有的缺点它都有,只是外面装个壳,就演化成了另一种继承模式。
寄生组合式继承
顾名思义,寄生式 + 组合式。它是寄生式继承的加强版。这也是为了避免组合继承中无可避免地要调用两次父类构造函数的最佳方案。
开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。
基本写法:
1 | function inheritPrototype(SubType, SuperType) { |
兼容写法:
1 | function object(o) { |
本质是子类的原型继承自父类的原型,申明一个用于继承原型的 inheritPrototype 方法,通过这个方法我们能够将子类的原型指向超类的原型,从而避免超类二次实例化。
实例代码:
1 | function Parent(name) { |
这也是目前最完美的继承方案,它与 ES6 的 class 的实现方式最为接近。
寄生组合式继承的优点
堪称完美
寄生组合式继承的缺点
代码多
class 继承(ES6)
ES6 中,通过 class
关键字来定义类,子类可以通过 extends
继承父类。
代码示例:
1 | class Parent{ |
要点:
constructor
为构造函数,即使未定义也会自动创建。- 在父类构造函数内
this
定义的都是实例属性和方法,其他方法包括constructor
、getHobbies
都是原型方法。 static
关键字定义的静态方法都必须通过类名调用,其this
指向调用者而并非实例。- 通过
extends
可以继承父类的所有原型属性及static
类方法,子类constructor
调用super
父类构造函数实现实例属性和方法的继承。
对比:
- ES5 的继承,实质是先创造子类的实例对象
this
,然后再将父类的方法添加到this
上面(Parent.apply(this)
)。 - ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到
this
上面(所以必须先调用super
方法),然后再用子类的构造函数修改this
。
总结
通过Object.create
来划分不同的继承方式,最后的寄生式组合继承方式是通过组合继承改造之后的最优继承方式,而 extends
的语法糖和寄生组合继承的方式基本类似。
参考文章:
作用域和作用域链
作用域
概念:作用域是在程序运行时代码中的某些特定部分中变量、函数和对象的可访问性。
-
从使用方面来解释,作用域就是变量的使用范围,也就是在代码的哪些部分可以访问这个变量,哪些部分无法访问到这个变量,换句话说就是这个变量在程序的哪些区域可见。
-
从存储上来解释的话,作用域本质上是一个对象, 作用域中的变量可以理解为是该对象的成员。
总结:作用域就是代码的执行环境,全局作用域就是全局执行环境,局部作用域就是函数的执行环境,它们都是栈内存
作用域分类
作用域又分为全局作用域和局部作用域。在ES6之前,局部作用域只包含了函数作用域,ES6的到来为我们提供了 块级作用域(由一对花括号包裹),可以通过新增命令let和const来实现;而对于全局作用域这里有一个小细节需要注意一下:
- 在 Web 浏览器中,全局作用域被认为是
window
对象,因此所有全局变量和函数都是作为window
对象的属性和方法创建的。- 在 Node环境中,全局作用域是
global
对象。
全局作用域很好理解,现在我们再来解释一下局部作用域吧,先来看看函数作用域,所谓函数作用域,顾名思义就是由函数定义产生出来的作用域,代码示例:
1 | function fun1(){ |
我们再来看看块级作用域,ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数(局部)作用域。块语句( {}
中间的语句),如 if
和 switch
条件语句, for
和 while
循环语句,不同于函数,它们不会创建一个新的作用域;但是ES6及之后的版本,块语句也会创建一个新的作用域, 块级作用域可通过新增命令let和const声明,所声明的变量在指定块的作用域外无法被访问。块级作用域在如下情况被创建:
- 在一个函数内部
- 在一个代码块(由一对花括号包裹)内部
let 声明的语法与 var 的语法一致。基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中 (注意:块级作用域并不影响var声明的变量)。 但是使用let时有几点需要注意:
- 声明变量不会提升到代码块顶部,即不存在变量提升
- 禁止重复声明同一变量
- for循环语句中()内部,即圆括号之内会建立一个隐藏的作用域,该作用域不属于for后边的{}中,并且只有for后边的{}产生的块作用域能够访问这个隐藏的作用域,这就使循环中 绑定块作用域有了妙用
这里分别演示一下ES5和ES6版本的代码,ES5:
1 | if(true) { |
ES6:
1 | for (let i = 0; i < 10; i++) { |
1 | if (true) { |
作用域链(scope chain)
概念:多个作用域对象连续引用形成的链式结构。
使用方面解释:当在Javascript中使用一个变量的时候,首先Javascript引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域,如果在全局作用域里仍然找不到该变量,它就会直接报错。
存储方面解释:作用域链在JS内部中是以数组的形式存储的,数组的第一个索引对应的是函数本身的执行期上下文,也就是当前执行的代码所在环境的变量对象,下一个索引对应的空间存储的是该对象的外部执行环境,依次类推,一直到全局执行环境
代码示例:
1 | var a = 100 |
再来看个栗子:
1 | var a = 10 |
由于变量的查找是沿着作用域链来实现的,所以也称作用域链为变量查找的机制。是不是很好理解,这里再来补充一点作用域的作用
- 作用域最为重要的一点是安全。变量只能在特定的区域内才能被访问,外部环境不能访问内部环境的任何变量和函数,即可以向上搜索,但不可以向下搜索, 有了作用域我们就可以避免在程序其它位置意外对某个变量做出修改导致程序发生事故。
- 作用域能够减轻命名的压力。我们可以在不同的作用域内定义相同的变量名,并且这些变量名不会产生冲突。
闭包
定义:函数创建和函数执行不在同一个作用域下就会形成闭包。(来自《你不知道的javaScrip》)
变量的作用域
要理解闭包,首先必须理解Javascript特殊的变量作用域。
变量的作用域无非就是两种:全局变量和局部变量。
Javascript语言的特殊之处,就在于函数内部可以直接读取全局变量。
1 | var n=999; |
另一方面,在函数外部自然无法读取函数内的局部变量。
1 | function f1(){ |
这里有一个地方需要注意,函数内部声明变量的时候,一定要使用var命令。如果不用的话,你实际上声明了一个全局变量!
1 | function f1(){ |
如何从外部读取局部变量?
在使用JS进行编程的时候,我们有时候需要在函数外部得到函数内的局部变量,可是我们前面已经说了,正常情况下这是不可能的。
嘿嘿,既然都说了是正常情况,那肯定有“不正常情况”啦!那就是在函数的内部,再定义一个函数。
1 | function f1(){ |
在上面的代码中,函数fun2就被包括在函数fun1内部,这时fun1内部的所有局部变量,对fun2都是可见的。但是反过来就不行,fun2内部的局部变量,对fun1都是不可见的。这就是Javascript语言所特有的链式作用域结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。总结:父对象的所有变量,对子对象都是可见的,反之则不成立。
这样我们就能够访问到函数fun1内部的变量了,可是问题来了,我们无法从外部调用函数fun2,那我们应该怎样才能够在外部访问fun2呢,我们只需要将fun2作为fun1的返回值返回,这样我们不就能在fun1外部访问到fun1内部的变量了吗,如下:
1 | function f1(){ |
闭包的概念
上一节代码中的f2函数,就是闭包。
关于闭包的概念,在前面我们已经说了官方给出的定义,官方给的定义虽然比较准确一点,但是对于初学者来说晦涩难懂,这里来说说我对闭包的理解,我的理解是,闭包就是能够读取其他函数内部变量的函数。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。
前面我们虽然也说了闭包不是一个函数,但是为了方便理解和学习我们通常可以将闭包称为一个函数,但是我们也要时刻在心里记住闭包不是一个函数,而是函数和声明该函数的词法环境的组合,这个环境包含了这个闭包创建时所能访问的所有局部变量。记住,它是一个组合!组合!
闭包的用途
想要使用闭包,必须知道它的结构,也是它的产生条件:
- 一个函数,里面有一些变量和另一个函数
- 外部函数里面的函数使用了外部函数的变量
- 外部函数最后把它里面的那个函数用return抛出去
以及闭包的作用:
- 在函数外部可以读取函数内部的变量
- 让这些变量的值始终保持在内存中
怎么来理解这句话呢?请看下面的代码。
1 | var n = 1; |
在这段代码中,result实际上就是闭包fun2函数。它一共运行了两次,第一次的值是999,第二次的值是1000。这证明了函数fun1中的局部变量n一直保存在内存中,并没有在fun1调用执行完之后被自动清除。
发生这样的情况原因就在于fun1是fun2的父函数,而fun2又通过fun1的return语句被赋给了一个全局变量,这导致fun2始终在内存中,而fun2的存在依赖于fun1,因此fun1也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收。
另外需要注意的地方是"nAdd=function(){n+=1}"这一行,首先在nAdd前面没有使用var关键字,因此nAdd是一个全局变量,而不是局部变量。其次,nAdd的值是一个匿名函数,而这个匿名函数本身也是一个闭包,所以nAdd可以在函数外部对函数内部的局部变量进行操作。
使用闭包的注意点
-
由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,可能导致内存泄露。解决方法是,在退出函数之前,将不需要的局部变量赋值为null。
-
闭包会在父函数外部改变父函数内部变量的值。如果你把父函数当作对象使用,把闭包当作它的公用方法,把内部变量当作它的私有属性,这时一定要注意,不要随便改变父函数内部变量的值。
-
当定义一个函数时,它实际上保存一个作用域链。当调用这个函数时,它创建一个新的对象来存储它的局部变量,并将这个对象添加至保存的那个作用域链上,同时创建一个新的更长的表示函数调用作用域的“链”。对于嵌套函数来讲,事情变得更加有趣,每次调用外部函数时,内部函数又会重新定义一遍。因为每次调用外部函数的时候,作用域链都是不同的。内部函数在每次定义的时候都有微妙的差别——在每次调用外部函数时,内部函数的代码都是相同的,但是关联这段代码的作用域链不相同,用闭包概念来说,也就是产生了新的闭包
更多Web前端面试题,请前往此大佬博客😋