1. 数据类型
1.2 原始数据类型(值类型)
原始类型存储的都是值,是没有函数可以调用的。
但是'1'.toString() 是可以使用的。其实在这种情况下,'1' 已经不是原始类型了,而是被强制转换成了 String 类型也就是对象类型,所以可以调用 toString 函数。
number 类型是浮点类型的,在使用中会遇到某些 Bug,比如 0.1 + 0.2 !== 0.3。string 类型是不可变的,无论你在 string 类型上调用何种方法,都不会对值有改变。
相关面试题:原始类型有哪几种?null 是对象嘛?
boolean
null
undefined
number
string
symbol
对于 null 来说,他不是个对象类型,虽然 typeof null 会输出 object,但是这只是 JS 存在的一个
悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开
头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object 。虽然现在的内部类型判断代码
已经改变了,但是对于这个 Bug 却是一直流传下来。
1.3 对象数据类型(引用类型)
除了原始类型那么其他的都是对象类型了,Object、Array等
涉及面试题:对象类型和原始类型的不同之处?函数参数是对象会发生什么问题?
不同:
原始类型存储的是值,在栈里
对象类型存储的是指针,指针在栈里,数据在堆里
const a = []
对于常量 a 来说,假设内存地址(指针)为 #001,那么在地址 #001 的位置存放了值 [],
常量 a 存放了地址(指针) #001,再看以下代码
函数参数是对象:
函数传参是传递对象指针的副本,可能会修改原数据
2. typeof 和 instanceof
typeof 是否能正确判断类型?instanceof 能正确判断对象的原理是什么?
typeof 对于原始类型来说,除了 null 都可以显示正确的类型
typeof null ----object
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof 对于对象类型来说,除了函数都会显示 object
typeof function(){} ----function
typeof [] // 'object'
typeof {} // 'object'
所以说 typeof 并不能准确判断变量到底是什么类型
instanceof内部机制是通过原型链来判断的
instanceof判断原始数据类型是不行的
const Person = function() {}
const p1 = new Person()
p1 instanceof Person // true
var str = 'hello world'
str instanceof String // false
var str1 = new String('hello world')
str1 instanceof String // true
准确判断数据类型:Object.prototype.toString.call('1'),通过对象原型上toString方法
Object.prototype.toString.call(console.log)-----"[object Function]"
Object.prototype.toString.call(null)----"[object Null]"
Object.prototype.toString.call('1')-----"[object String]"
Object.prototype.toString.call([])------"[object Array]"
3. 类型转换
在 JS 中类型转换只有三种情况,分别是:
- 转换为布尔值
- 转换为数字
- 转换为字符串
请说出以下代码输出的内容
console.log([] + [])
console.log({} + [])
console.log([] == ![])
console.log(true + false)
答案
第一行代码
// 输出 "" 空字符串
console.log([] + [])
复制代码
这行代码输出的是空字符串"", 包装类型在运算的时候,会先调用valueOf方法,如果valueOf返回的还是包装类型,那么再调用toString方法
// 还是 数组
const val = [].valueOf()
// 数组 toString 默认会将数组各项使用逗号 "," 隔开, 比如 [1,2,3].toSting 变成了"1,2,3",空数组 toString 就是空字符串
const val1 = val.toString() // val1 是空字符串
复制代码
所以上面的代码相当于
console.log("" + "")
第二行代码
// 输出 "[object Object]"
console.log({} + [])
复制代码
和第一题道理一样,对象 {}隐氏转换成了[object Object],然后与""相加
第三行代码
// 输出 true
console.log([] == ![])
对于===, 会严格比较两者的值,但是对于==就不一样了
所以对于上面的代码,看下面一步一步分析
// 这个输出 false
console.log(![])
// 套用上面第三条 将 false 转换为 数值
// 这个输出 0
console.log(Number(false))
// 包装类型与 基本类型 == 先将包装类型通过 valueOf toString 转换为基本类型
// 输出 ""
console.log([].toString())
// 套用第2条, 将空字符串转换为数值、
// 输出 0
console.log(Number(""))
// 所以
console.log(0 == 0)
比如 null == undefined
如果非number与number比较,会将其转换为number
如果比较的双方中由一方是boolean,那么会先将boolean转换为number
第四行代码
// 输出 1
console.log(true + false)
复制代码
两个基本类型相加,如果其中一方是字符,则将其他的转换为字符相加,否则将类型转换为Number,然后相加, Number(true) 是1, Number(false)是0, 所以结果是 1
4.this指向
首先要明白的是,this既不指向函数自身,也不指向函数的词法作用域。他取决于函数在哪里被调用,函数调用时发生的绑定
4.1 调用位置
理解this绑定过程之前,要理解调用位置,调用位置就是函数在代码中被调用的位置,是谁调用了他
4.2 绑定规则
1.默认绑定
function foo(){console.log(this.a)}
var a=1
foo() //1
foo()是直接不带任何修饰符的函数调用,采用默认规则,this绑定在window
另:严格模式下:不能将全局对象用于默认绑定,因此this会绑定到undefined
2.隐式绑定
function foo(){
console.log(this.a);
}
var obj={
a:2,
foo:foo
}
var a=1
obj.foo() //2
注意foo无论是直接在obj中定义还是先定义后添加为引用属性,这个函数严格来说都不属于这个对象,但是是obj调用了这个函数,所以this指向obj
3.显示绑定
call、apply、bind,绑定后this为第一个参数
call(this,...arg) apply(this,arg)
** 4.new绑定**
const c = new foo()
对于 new 的方式来说,this 被永远绑定在了 c 上面,不会被任何方式改变 this
4.3 优先级
new 的方式优先级最高,接下来是 bind 这些函数,然后是 obj.foo() 这种调用方式,最后是 foo 这种调用方式,同时,箭头函数的 this 一旦被绑定,就不会再被任何方式所改变。
4.4 绑定例外
4.4.1隐式丢失
var obj={
a:2,
foo:function (){
console.log(this.a);
}
}
var a=1
var b=obj.foo
b()
//b是对obj.foo的一个引用,因此b()是直接不带任何修饰符的函数调用,采用默认规则
4.4.2被忽略的this
function foo(a,b) {
console.log( "a:" + a + ", b:" + b );
}
// 把数组“展开”成参数
foo.apply( null, [2, 3] ); // a:2, b:3
// 使用 bind(..) 进行柯里化
var bar = foo.bind( null, 2 );
bar( 3 ); // a:2, b:3
//这两种方法都需要传入一个参数当作 this 的绑定对象。如果函数并不关心 this 的话,
你仍然需要传入一个占位值,这时 null 可能是一个不错的选择,就像代码所示的那样,此时this指向全局对象
4.4箭头函数的this
之前介绍的四条规则已经可以包含所有正常的函数,但箭头函数例外,箭头函数本身没有this,所以不能当作构造函数,箭头函数的this根据外层(函数或者全局)作用域来决 定的(只取决包裹箭头函数的第一个普通函数的 this)。
function foo() {
// 返回一个箭头函数
return (a) => {
//this 继承自 foo()
console.log( this.a );
};
}
var obj1 = {
a:2
};
var obj2 = {
a:3
};
var bar = foo.call( obj1 );
bar.call( obj2 ); // 2, 不是 3 !
//foo() 内部创建的箭头函数会捕获调用时 foo() 的 this。由于 foo() 的 this 绑定到 obj1,
bar(引用箭头函数)的 this 也会绑定到 obj1,箭头函数的绑定无法被修改。(new 也不行!)
总结:如何判断this
- 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。 var bar = new foo()
- 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是 指定的对象。 var bar = foo.call(obj2)
- 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。 var bar = obj1.foo()
- 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到 全局对象。 var bar = foo()
5. 闭包
闭包就是函数能够记住并且访问所在的词法作用域,即使是在当前词法作用域之外的地方执行。
最常见的就是:函数 A 内部有一个函数 B,函数 B 可以访问到函数 A 中的变量,那么函数 B 就是闭包。
function A() {
let a = 1
window.B = function () {
console.log(a)
}
}
A()
B() // 1
闭包应用: 私有化数据变量、模块、常驻内存
6. 原型
如果要访问对象中并不存在的一个属性,[[Get]] 操作(参见第 3 章)就会查找对象内部
[[Prototype]] 关联的对象。这个关联关系实际上定义了一条“原型链”(有点像嵌套的作
原型用域链),在查找属性时会对它进行遍历。
所有普通对象都有内置的 Object.prototype,指向原型链的顶端(比如说全局作用域),如
果在原型链中找不到指定的属性就会停止。toString()、valueOf() 和其他一些通用的功能
都存在于 Object.prototype 对象上,因此语言中所有的对象都可以使用它们。
关联两个对象最常用的方法是使用 new 关键词进行函数调用,在调用的 4 个步骤(第 2
章)中会创建一个关联其他对象的新对象。
使用 new 调用函数时会把新对象的 .prototype 属性关联到“其他对象”。带 new 的函数调用
通常被称为“构造函数调用”,尽管它们实际上和传统面向类语言中的类构造函数不一样。
虽然这些 JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但
是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的
[[Prototype]] 链关联的。
出于各种原因,以“继承”结尾的术语(包括“原型继承”)和其他面向对象的术语都无
法帮助你理解 JavaScript 的真实机制(不仅仅是限制我们的思维模式)。
相比之下,“委托”是一个更合适的术语,因为对象之间的关系不是复制而是委托。
面试题:如何理解原型?如何理解原型链?
当我们创建一个对象时 let obj = { age: 25 },我们可以发现能使用很多种函数,
但是我们明明没有定义过它们,对于这种情况你是否有过疑惑?
当我们在浏览器中打印 obj 时你会发现,在 obj 上居然还有一个 __proto__ 属性,
那么看来之前的疑问就和这个属性有关系了。
其实每个 JS 对象都有 __proto__ 属性,这个属性指向了原型。这个属性在现在来说已经不推荐直接
去使用它了,这只是浏览器在早期为了让我们访问到内部属性 [[prototype]] 来实现的一个东西。
原型也是一个对象,并且这个对象中包含了很多函数,所以我们可以得出一个结论:对于 obj 来说,
可以通过 __proto__ 找到一个原型对象,在该对象中定义了很多函数让我们来使用。
Object 是所有对象的爸爸,所有对象都可以通过 __proto__ 找到它
Function 是所有函数的爸爸,所有函数都可以通过 __proto__ 找到它
函数的 prototype 是一个对象
对象的 __proto__ 属性指向原型, __proto__ 将对象和原型连接起来组成了原型链
7. 作用域
作用域是一套规则,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常
严格的规则,确定当前执行的代码对这些标识符的访问权限。如果查找的目的是对变量进行赋值,
那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询
8. 深浅拷贝
8.1 深拷贝
8.1.1 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
- 不能序列化函数
- 不能解决循环引用的对象
- 有这么一个循环引用对象,也不能通过该方法实现深拷贝
8.1.2 实现一个深拷贝
function deepCopy(obj) {
let newObj = Array.isArray(obj) ? [] : {};
for (let i in obj) {
if (obj.hasOwnProperty(i)) {
newObj[i] =
typeof obj[i] === "object" && obj[i] != null
? deepCopy(obj[i])
: obj[i];
}
}
return newObj;
}
8.2 浅拷贝
首先可以通过 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
9. Event Loop
9.1 浏览器的Event Loop
参考:JS事件循环
因为 js 是单线程运行的,在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。 当异步事件执行完毕后,再将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行。任务队列可以分为宏任务对列和微任务对列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。
微任务包括了 promise 的回调、node 中的 process.nextTick 、对 Dom 变化监听的 MutationObserver。
宏任务包括了 script 脚本的执行、setTimeout ,setInterval ,setImmediate 一类的定时事件,还有如 I/O 操作、UI 渲
染等。
9.2 Node 中的 Event Loop
参考:
10. 为什么 0.1 + 0.2 != 0.3
先说原因,因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题。
我们都知道计算机是通过二进制来存储东西的,那么 0.1 在二进制中会表示为
// (0011) 表示循环 0.1 = 2^-4 * 1.10011(0011) 我们可以发现,0.1 在二进制中是无限循环的一些数字,其实不只是 0.1,其实很多十进制小数用二进制表示都是无限循环的。这样其实没什么问题,但是 JS 采用的浮点数标准却会裁剪掉我们的数字。
IEEE 754 双精度版本(64位)将 64 位分为了三段
第一位用来表示符号 接下去的 11 位用来表示指数 其他的位数用来表示有效位,也就是用二进制表示 0.1 中的 10011(0011) 那么这些循环的数字被裁剪了,就会出现精度丢失的问题,也就造成了 0.1 不再是 0.1 了,而是变成了 0.100000000000000002
0.100000000000000002 === 0.1 // true
那么同样的,0.2 在二进制也是无限循环的,被裁剪后也失去了精度变成了 0.200000000000000002
0.200000000000000002 === 0.2 // true
所以这两者相加不等于 0.3 而是 0.300000000000000004
0.1 + 0.2 === 0.30000000000000004 // true
那么可能你又会有一个疑问,既然 0.1 不是 0.1,那为什么 console.log(0.1) 却是正确的呢?
因为在输入内容的时候,二进制被转换为了十进制,十进制又被转换为了字符串,在这个转换的过程中发生了取近似值的过程,所以打印出来的其实是一个近似值,你也可以通过以下代码来验证
console.log(0.100000000000000002) // 0.1
那么说完了为什么,最后来说说怎么解决这个问题吧。其实解决的办法有很多,这里我们选用原生提供的方式来最简单的解决问题
parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true
11. 垃圾回收机制
11.1 新生代算法
新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。
在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。
11.2 老生代算法
老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。
在讲算法前,先来说下什么情况下对象会出现在老生代空间中:
新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。 To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。 老生代中的空间很复杂,有如下几个空间
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不变的对象空间
NEW_SPACE, // 新生代用于 GC 复制算法的空间
OLD_SPACE, // 老生代常驻对象空间
CODE_SPACE, // 老生代代码对象空间
MAP_SPACE, // 老生代 map 对象
LO_SPACE, // 老生代大空间对象
NEW_LO_SPACE, // 新生代大空间对象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情况会先启动标记清除算法:
某一个空间没有分块的时候 空间中被对象超过一定限制 空间不能保证新生代中的对象移动到老生代中 在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行,你可以点击 该博客 详细阅读。
清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
参考: