JS 常考进阶面试题
在这一章节中我们继续来了解 JS 的一些常考和容易混乱的基础知识点。
== vs ===
涉及面试题:== 和 === 有什么区别?
对于 ==
来说,如果对比双方的类型不一样的话,就会进行类型转换,这也就用到了我们上一章节讲的内容。
假如我们需要对比 x
和 y
是否相同,就会进行如下判断流程:
- 首先会判断两者类型是否相同。相同的话就是比大小了
- 类型不相同的话,那么就会进行类型转换
- 会先判断是否在对比
null
和undefined
,是的话就会返回true
判断两者类型是否为
string
和number
,是的话就会将字符串转换为number
1 == '1' ↓ 1 == 1
判断其中一方是否为
boolean
,是的话就会把boolean
转为number
再进行判断'1' == true ↓ '1' == 1 ↓ 1 == 1
判断其中一方是否为
object
且另一方为string
、number
或者symbol
,是的话就会把object
转为原始类型再进行判断'1' == { name: 'yck' } ↓ '1' == '[object Object]'
思考题:看完了上面的步骤,对于 [] == ![] 你是否能正确写出答案呢?
如果你觉得记忆步骤太麻烦的话,我还提供了流程图供大家使用:
当然了,这个流程图并没有将所有的情况都列举出来,我这里只将常用到的情况列举了,如果你想了解更多的内容可以参考 标准文档。
对于 ===
来说就简单多了,就是判断两者类型和值是否相同。
闭包
涉及面试题:什么是闭包?要理解闭包,首先必须理解Javascript特殊的变量作用域。
变量的作用域无非就是两种:全局变量和局部变量。
Javascript语言的特殊之处,就在于函数内部可以直接读取全局变量。
而闭包却是能够读取其他函数内部变量的函数。所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。
闭包的特点
1.函数嵌套函数
2.函数内部可以引用外部的参数和变量
3.参数和变量不会被垃圾回收机制回收
因此闭包常会被用于
1可以储存一个可以长期驻扎在内存中的变量
2.避免全局变量的污染
3.保证私有成员的存在
那闭包又因为什么原因不被回收呢
简单来说,js引擎的工作分两个阶段,
一个是语法检查阶段,
一个是运行阶段。而运行阶段又分预解析和执行两个阶段。
在预解析阶段,先会创建执行上下文,执行上下文又包括变量对象、变量对象的作用域链和this指向的创建 。
创建执行上下文后,会对变量对象的属性进行填充。
进入执行代码阶段,此时执行上下文有个Scope属性
该属性作为一个作用域链包含有该函数被定义时所有外层的变量对象的引用
js解析器逐行读取并执行代码时
当我们需要查询外部作用域的变量时,其实就是沿着作用域链,依次在这些变量对象里遍历标志符,直到最后的全局变量对象。
基于js的垃圾回收机制:在Javascript中,如果一个对象不再被引用,那么这个对象就会被GC回收。如果两个对象互相引用,而不再被第3者所引用,那么这两个互相引用的对象也会被回收。因为函数a被b引用,b又被a外的c引用,所以定义了闭包的函数虽然销毁了,但是其变量对象依然被绑定在函数上,只有仍被引用,变量会继续保存在内存中,这就是为什么函数a执行后不会被回收的原因。
变量对象VO:var声明的变量、function声明的函数,及当前函数的形参
作用域链:当前变量对象+所有父级作用域 [[scope]]
this值:在进入执行上下文后不再改变
PS:作用域链其实就是一个变量对象的链,函数的变量对象称之为active object,简称AO。函数创建后就有静态的[[scope]]属性,直到函数销毁)
创建执行上下文后,会对变量对象的属性进行填充。所谓属性,就是var、function声明的标志符及函数形参名,至于属性对应的值:变量值为undefined,函数值为函数定义,形参值为实参,没有传入实参则为undefined。
三、闭包的微观世界
如果要更加深入的了解闭包以及函数a和嵌套函数b的关系,我们需要引入另外几个概念:函数的执行环境(excution context)、活动对象(call object)、作用域(scope)、作用域链(scope chain)。以函数a从定义到执行的过程为例阐述这几个概念。
3 当定义函数a的时候,js解释器会将函数a的作用域链(scope chain)设置为定义a时a所在的“环境”,如果a是一个全局函数,则scope chain中只有window对象。
4 当执行函数a的时候,a会进入相应的执行环境(excution context)。
5 在创建执行环境的过程中,首先会为a添加一个scope属性,即a的作用域,其值就为第1步中的scope chain。即a.scope=a的作用域链。
6 然后执行环境会创建一个活动对象(call object)。活动对象也是一个拥有属性的对象,但它不具有原型而且不能通过JavaScript代码直接访问。创建完活动对象后,把活动对象添加到a的作用域链的最顶端。此时a的作用域链包含了两个对象: a的活动对象和window对象。
7 下一步是在活动对象上添加一个arguments属性,它保存着调用函数a时所传递的参数。
8 最后把所有函数a的形参和内部的函数b的引用也添加到a的活动对象上。在这一步中,完成了函数b的的定义,因此如同第3步,函数b的作用域链被设置为b所被定义的环境,即a的作用域。
到此,整个函数a从定义到执行的步骤就完成了。此时a返回函数b的引用给c,函数b的作用域链又包含了对函数a的活动对象的引用,也就是说b可以访问到a中定义的所有变量和函数。函数b被c引用,函数b又依赖函数a,因此函数a在返回后不会被GC回收。
当函数b执行的时候亦会像以上步骤一样。因此,执行时b的作用域链包含了3个对象:b的活动对象、a的活动对象和window对象,如下图所示:
如图所示,当在函数b中访问一个变量的时候,搜索顺序是:
9 先搜索自身的活动对象,如果存在则返回,如果不存在将继续搜索函数a的活动对象,依次查找,直到找到为止。
10 如果函数b存在prototype原型对象,则在查找完自身的活动对象后先查找自身的原型对象,再继续查找。这就是Javascript中的变量查找机制。
11 如果整个作用域链上都无法找到,则返回undefined。
小结,本段中提到了两个重要的词语:函数的定义与执行。文中提到函数的作用域是在定义函数时候就已经确定,而不是在执行的时候确定(参看步骤1和3)。用一段代码来说明这个问题:
· 假设函数h的作用域是在执行alert(h())确定的,那么此时h的作用域链是:h的活动对象->alert的活动对象->window对象。这段代码中变量h指向了f中的那个匿名函数(由g返回)。
· 假设函数h的作用域是在定义时确定的,就是说h指向的那个匿名函数在定义的时候就已经确定了作用域。那么在执行的时候,h的作用域链为:h的活动对象->f的活动对象->window对象。
如果第一种假设成立,那输出值就是undefined;如果第二种假设成立,输出值则为1。
运行结果证明了第2个假设是正确的,说明函数的作用域确实是在定义这个函数的时候就已经确定了。
(转载请注明出处:http://www.felixwoo.com/archi...
四、闭包的应用场景
12 保护函数内的变量安全。以最开始的例子为例,函数a中i只有函数b才能访问,而无法通过其他途径访问到,因此保护了i的安全性。
13 在内存中维持一个变量。依然如前例,由于闭包,函数a中i的一直存在于内存中,因此每次执行c(),都会给i自加1。
14 通过保护变量的安全实现JS私有属性和私有方法(不能被外部访问)推荐阅读:http://javascript.crockford.c...
五、Javascript的垃圾回收机制
在Javascript中,如果一个对象不再被引用,那么这个对象就会被GC回收。如果两个对象互相引用,而不再被第3者所引用,那么这两个互相引用的对象也会被回收。因为函数a被b引用,b又被a外的c引用,这就是为什么函数a执行后不会被回收的原因。
在 JS 中,闭包存在的意义就是让我们可以间接访问函数内部的变量。
原型
涉及面试题:如何理解原型?如何理解原型链?
1.每个对象都有__proto__属性
,该属性指向其构造函数的原型对象, __proto__
将对象和其原型对象连接起来组成原型链
2.在调用实例的方法和属性时,如果在实例对象上找不到,就会往原型对象上找
3.构造函数的prototype属性
也指向实例的原型对象
4.原型对象的constructor属性
指向构造函数。
继承
说到继承,最容易想到的是ES6的extends
,当然如果只回答这个肯定不合格,我们要从函数和原型链的角度上实现继承,下面我们一步步地、递进地实现一个合格的继承
实现一个方法可以从而实现对父类的属性和方法的继承,解决代码冗余重复的问题
一. 原型链继承
原型链继承的原理很简单,
直接让子类的原型对象指向父类实例,
Child.prototype=new Parent()
当子类实例找不到对应的属性和方法时,就会往它的原型对象,也就是父类实例上找,
从而实现对父类的属性和方法的继承
原型继承的缺点:
1.由于所有Child实例原型都指向同一个Parent实例, 因此对某个Child实例的父类引用类型变量修改会影响所有的Child实例
2.在创建子类实例时无法向父类构造传参, 即没有实现super()的功能
二. 构造函数继承
构造函数继承,即在子类的构造函数中执行父类的构造函数,并为其绑定子类的this,
让父类的构造函数把成员属性和方法都挂到子类的this上去;
在Child的构造函数中执行
Parent.apply(this, arguments);
这样既能避免实例之间共享一个原型实例,又能向父类构造方法传参;
js继承的方式继承不到父类原型上的属性和方法
构造函数继承的缺点:
1.继承不到父类原型上的属性和方法
三. 组合式继承
既然原型链继承和构造函数继承各有互补的优缺点, 那么我们为什么不组合起来使用呢, 所以就有了综合二者的组合式继承
Child.prototype=new Parent() Child.prototype.constructor=Child //相当于在Child的构造函数中给Parent绑定this
组合式继承的缺点:
1.每次创建子类实例都执行了两次构造函数(Parent.call()和new Parent()),虽然这并不影响对父类的继承,但子类创建实例时,原型中会存在两份相同的属性和方法,这并不优雅
四. 寄生式组合继承
为了解决组合式继承中构造函数被执行两次的问题,
我们将指向父类实例改为指向父类原型, 减去一次构造函数的执行
到这里我们就完成了ES5环境下的继承的实现,这种继承方式称为寄生组合式继承。
Function.prototype.extend = function (supClass) {
// 创建一个中间替代类,防止多次执行父类(超类)的构造函数
function F() { }
// 将父类的原型赋值给这个中间替代类
F.prototype = supClass.prototype;
// 将原子类的原型保存
var proto = subClass.prototype;
// 将子类的原型设置为中间替代类的实例对象
subClass.prototype = new F();
// 将原子类的原型复制到子类原型上,合并超类原型和子类原型的属性方法
// Object.assign(subClass.prototype,proto);
var names = Object.getOwnPropertyNames(proto);
for (var i = 0; i < names.length; i++) {
var desc = Object.getOwnPropertyDescriptor(proto, names[i]);
Object.defineProperty(subClass.prototype, names[i], desc);
}
// 设置子类的构造函数时自身的构造函数,以防止因为设置原型而覆盖构造函数
subClass.prototype.constructor = subClass;
// 给子类的原型中添加一个属性,可以快捷的调用到父类的原型方法
subClass.prototype.superClass = supClass.prototype;
// 如果父类的原型构造函数指向的不是父类构造函数,重新指向
if (supClass.prototype.constructor !== supClass) {
supClass.prototype.constructor = supClass;
}
}
function Ball(_a) {
this.superClass.constructor.call(this, _a);
}
Ball.prototype.play = function () {
this.superClass.play.call(this);//执行超类的play方法
console.log("end");
}
Object.defineProperty(Ball.prototype, "d", {
value: 20
})
Ball.extend(Box);
var b=new Ball(10);
console.log(b);
是目前最成熟的继承方式,babel对ES6继承的转化也是使用了寄生组合式继承
我们回顾一下实现过程:
- 原型链继承,通过把子类实例的原型指向父类实例来继承父类的属性和方法;但缺陷在于,对子类实例继承的引用类型的修改会影响到所有的实例对象以及无法向父类的构造方法传参。
- 因此我们引入了构造函数继承, 通过在子类构造函数中调用父类构造函数并传入子类this来获取父类的属性和方法,但缺陷在于,构造函数继承不能继承到父类原型链上的属性和方法。
- 综合了两种继承的优点,提出了组合式继承,但组合式继承也引入了新的问题,它每次创建子类实例都执行了两次父类构造方法,
我们通过将子类原型指向父类实例改为子类原型指向父类原型的浅拷贝来解决这一问题,也就是最终实现 —— 寄生组合式继承
深浅拷贝
涉及面试题:什么是浅拷贝?如何实现浅拷贝?什么是深拷贝?如何实现深拷贝?
在上一章节中,我们了解了对象类型在赋值的过程中其实是复制了地址,从而会导致改变了一方其他也都被改变的情况。通常在开发中我们不希望出现这样的问题,我们可以使用浅拷贝来解决这个情况。
let a = {
age: 1
}
let b = a
a.age = 2
console.log(b.age) // 2
浅拷贝 展开运算符 ...
来实现浅拷贝和Object.assign({}, a)
首先可以通过 Object.assign
来解决这个问题,很多人认为这个函数是用来深拷贝的。其实并不是,Object.assign
只会拷贝所有的属性值到新的对象中,如果属性值是对象的话,拷贝的是地址,所以并不是深拷贝。
let a = {
age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1
另外我们还可以通过展开运算符 ...
来实现浅拷贝
let a = {
age: 1
}
let b = { ...a }
a.age = 2
console.log(b.age) // 1
通常浅拷贝就能解决大部分问题了,但是当我们遇到如下情况就可能需要使用到深拷贝了
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = { ...a }
a.jobs.first = 'native'
console.log(b.jobs.first) // native
浅拷贝只解决了第一层的问题,如果接下去的值中还有对象的话,那么就又回到最开始的话题了,两者享有相同的地址。要解决这个问题,我们就得使用深拷贝了。
深拷贝
这个问题通常可以通过 JSON.parse(JSON.stringify(object))
来解决。
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first) // FE
但是该方法也是有局限性的:
- 会忽略
undefined
- 会忽略
symbol
- 不能序列化函数
- 不能解决循环引用的对象
- 在遇到函数、
undefined
或者symbol
的时候,该对象也不能正常的序列化 - 原型链如何处理
- DOM 如何处理
- Date
- Reg
- ES6类
- null
- boolen
- array
- string
- number
实现一个深拷贝是很困难的,需要我们考虑好多种边界情况,比如原型链如何处理、DOM 如何处理等等,所以这里我们实现的深拷贝只是简易版,并且我其实更推荐使用 lodash 的深拷贝函数。
function deepClone(obj) {
function isObject(o) {
return (typeof o === 'object' || typeof o === 'function') && o !== null
}
if (!isObject(obj)) {
throw new Error('非对象')
}
let isArray = Array.isArray(obj)
let newObj = isArray ? [...obj] : { ...obj }
Reflect.ownKeys(newObj).forEach(key => {
newObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
})
return newObj
}
let obj = {
a: [1, 2, 3],
b: {
c: 2,
d: 3
}
}
let newObj = deepClone(obj)
newObj.b.c = 1
console.log(obj.b.c) // 2
谢大师
class Box {
static ARG = ["a", "b"]
constructor(a1, b1) {
this.a = a1
this.b = b1
}
play() {
console.log(this.a1 + this.b1)
}
}
var obj = {
a:1,
b:"a",
c:false,
d:{
e:undefined,
f:null,
g:[1, 2, 3, 4, 5],
h:new Date(),
i:/^[a-z]{2,4}$/gi,
j:new Box(4, 5),
k:{}
}
}
Object.defineProperties(obj.d.k, {
l:{ value:10 },
m:{
configurable:true,
writable:true,
value:20
},
n:{
enumerable:true,
value:function() {
console.log("aaaa")
}
},
o:{
value:new Image()
}
})
function cloneObject(target, source) {
var names = Object.getOwnPropertyNames(source)
for (let i = 0 i < names.length i++) {
var desc = Object.getOwnPropertyDescriptor(source, names[i])
if (typeof desc.value === "object" && desc.value !== null) {
var obj
if (desc.value instanceof HTMLElement) {
obj = document.createElement(desc.value.nodeName)
} else {
switch (desc.value.constructor) {
case Box:
obj = new desc.value.constructor(desc.value[Box.ARG[0]], desc.value[Box.ARG[1]])
break
case RegExp:
obj = new desc.value.constructor(desc.value.source,desc.value.flags)
break
default :
obj = new desc.value.constructor()
}
}
cloneObject(obj, desc.value)
Object.defineProperty(target, names[i], {
value:obj,
enumerable:desc.enumerable,
writable:desc.writable,
configurable:desc.configurable
})
} else {
Object.defineProperty(target, names[i], desc)
}
}
return target
}
var obj1 = cloneObject({}, obj)
obj.d.k.m = 100
console.log(obj1)
new 操作符调用构造函数具体做了什么?
如下:
•创建一个新的对象;
•将构造函数的 this 指向这个新对象;
•为这个对象添加属性、方法等;
•最终返回新对象;