# JS 基础考点
# 数据类型
# 基本数据类型
涉及面试题:原始(基本)类型有哪几种?null 是对象嘛?
在 JS 中,存在着 6 种原始值,分别是:
- boolean
- null
- undefined
- number
- string
- symbol
首先原始类型存储的都是值,是没有函数可以调用的,比如 undefined.toString()
此时你肯定会有疑问,这不对呀,明明 '1'.toString() 是可以使用的。其实在这种情况下,'1' 已经不是原始类型了,而是被强制转换成了 String 类型也就是对象类型,所以可以调用 toString 函数。
除了会在必要的情况下强转类型以外,原始类型还有一些坑。
其中 JS 的 number 类型是浮点类型的,在使用中会遇到某些 Bug,比如 0.1 + 0.2 !== 0.3,涉及到二进制转换的精度问题。string 类型是不可变的,无论你在 string 类型上调用何种方法,都不会对值有改变。
另外对于 null 来说,很多人会认为他是个对象类型,其实这是错误的。虽然 typeof null 会输出 object,但是这只是 JS 存在的一个悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object 。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。
# 引用数据类型
涉及面试题:引用类型和原始类型的不同之处?函数参数是引用数据类型时的问题?
在 JS 中,除了原始类型那么其他的都是引用类型了。引用类型和原始类型不同的是,原始类型存储的是值,引用类型存储的是地址(指针)。当你创建了一个对象的时候,计算机会在内存中帮我们开辟一个空间来存放值,但是我们需要找到这个空间,这个空间会拥有一个地址(指针)。
const a = []
对于常量 a 来说,假设内存地址(指针)为 #001,那么在地址 #001 的位置存放了值 [],常量 a 存放了地址(指针) #001,再看以下代码
const a = [];
const b = a;
b.push(1);
当我们将变量赋值给另外一个变量时,复制的是原本变量的地址(指针),也就是说当前变量 b 存放的地址(指针)也是 #001,当我们进行数据修改的时候,就会修改存放在地址(指针) #001 上的值,也就导致了两个变量的值都发生了改变。
# 类型转换
类型转换只有三种情况:
- 转为布尔值
- 转为数字
- 转为字符串
原始值 | 转换目标 | 结果 |
---|---|---|
undefined null NaN '' 0 和-0 | 布尔值 | false |
除以上几种情况 | 布尔值 | true |
number | 字符串 | 5=>'5' |
boolean 函数 Symbol | 字符串 | 'true' |
数组 | 字符串 | [1,2]=>'1,2' |
对象 | 字符串 | '[object object]' |
string | 数字 | '5'=>5 'a'=>NaN |
null | 数字 | 0 |
| 数组 | 数字 | 空数组为 0,存在一个元素且为数字转成数字,其他情况 NaN |
| 除了数组的引用类型 | 数字 | NaN | | Symbol | 数字 | 抛错 |
# 对象转原始类型
对象在转换类型的时候,会调用内置的 [[ToPrimitive]] 函数,对于该函数来说,算法逻辑一般来说如下:
如果已经是原始类型了,那就不需要转换了 调用 x.valueOf(),如果转换为基础类型,就返回转换的值 调用 x.toString(),如果转换为基础类型,就返回转换的值 如果都没有返回原始类型,就会报错 当然你也可以重写 Symbol.toPrimitive ,该方法在转原始类型时调用优先级最高。
let a = {
valueOf() {
return 0;
},
toString() {
return "1";
},
[Symbol.toPrimitive]() {
return 2;
},
};
1 + a; // => 3
相关面试题:下面代码中 a 在什么情况下会打印 1?
var a = ?;
if(a == 1 && a == 2 && a == 3){
console.log(1);
}
答案解析: 因为==会进行隐式类型转换 所以我们重写 toString 方法就可以了
var a = {
i: 1,
toString() {
return a.i++;
},
};
if (a == 1 && a == 2 && a == 3) {
console.log(1);
}
# typeof vs instanceof
涉及面试题:typeof 是否能正确判断类型?instanceof 能正确判断对象的原理是什么?
typeof 对于原始类型来说,除了 null 都可以显示正确的类型
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof 对于对象来说,除了函数都会显示 object,所以说 typeof 并不能准确判断变量到底是什么类型
typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'
如果我们想判断一个对象的正确类型,这时候可以考虑使用 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
# this
涉及面试题:如何正确判断 this?箭头函数的 this 是什么?
this 是很多人会混淆的概念,但是其实它一点都不难,只是网上很多文章把简单的东西说复杂了。
我们先来看几个函数调用的场景
function foo() {
console.log(this.a);
}
var a = 1;
foo();
const obj = {
a: 2,
foo: foo,
};
obj.foo();
const c = new foo();
接下来我们一个个分析上面几个场景
- 对于直接调用
foo
来说,不管foo
函数被放在了什么地方,this
一定是window
- 对于
obj.foo()
来说,我们只需要记住,谁调用了函数,谁就是this
,所以在这个场景下foo
函数中的this
就是obj
对象 - 对于
new
的方式来说,this
被永远绑定在了c
上面,不会被任何方式改变this
说完了以上几种情况,其实很多代码中的 this 应该就没什么问题了,下面让我们看看箭头函数中的 this
function a() {
return () => {
return () => {
console.log(this);
};
};
}
console.log(a()()());
首先箭头函数其实是没有 this
的,箭头函数中的 this 只取决包裹箭头函数的第一个普通函数的 this
。在这个例子中,因为包裹箭头函数的第一个普通函数是 a
,所以此时的 this
是 window
。另外对箭头函数使用 bind
这类函数是无效的。
# 进一步理解如何改变 this 指向
最后种情况也就是 bind
,apply
,call
这些改变上下文的 API 了,对于这些函数来说,this
取决于第一个参数,如果第一个参数为空,那么就是 window
。
以上就是 this
的规则了,但是可能会发生多个规则同时出现的情况,这时候不同的规则之间会根据优先级最高的来决定 this
最终指向哪里。
首先,new
的方式优先级最高,接下来是 bind
这些函数,然后是 obj.foo()
这种调用方式,最后是 foo
这种调用方式,同时,箭头函数的 this
一旦被绑定,就不会再被任何方式所改变。
# 闭包
涉及面试题:什么是闭包?
闭包的定义其实很简单:函数 A 内部有一个函数 B,函数 B 可以访问到函数 A 中的变量,那么函数 B 就是闭包。
function A() {
let a = 1;
window.B = function() {
console.log(a);
};
}
A();
B(); // 1
很多人对于闭包的解释可能是函数嵌套了函数,然后返回一个函数。其实这个解释是不完整的,就比如我上面这个例子就可以反驳这个观点。
在 JS 中,闭包存在的意义就是让我们可以间接访问函数内部的变量。
面试题:修改代码,输出 1,2,3,4,5
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
首先因为 setTimeout 是个异步函数,所以会先把循环全部执行完毕,这时候 i 就是 6 了,所以会输出一堆 6。
解决办法有三种,第一种是使用闭包的方式
for (var i = 1; i <= 5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
在上述代码中,我们首先使用了立即执行函数将 i 传入函数内部,这个时候值就被固定在了参数 j 上面不会改变,当下次执行 timer 这个闭包的时候,就可以使用外部函数的变量 j,从而达到目的。
第二种就是使用 setTimeout 的第三个参数,这个参数会被当成 timer 函数的参数传入。
for (var i = 1; i <= 5; i++) {
setTimeout(
function timer(j) {
console.log(j);
},
i * 1000,
i,
);
}
第三种就是使用 let 定义 i 了来解决问题了,这个也是最为推荐的方式
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
# 深浅拷贝
涉及面试题:什么是浅拷贝?如何实现浅拷贝?什么是深拷贝?如何实现深拷贝?
我们了解了对象类型在赋值的过程中其实是复制了地址,从而会导致改变了一方其他也都被改变的情况。通常在开发中我们不希望出现这样的问题,我们可以使用浅拷贝来解决这个情况。
let a = {
age: 1,
};
let b = a;
a.age = 2;
console.log(b.age); // 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
浅拷贝只解决了第一层的问题,如果接下去的值中还有对象的话,那么就又回到最开始的话题了,两者享有相同的地址。要解决这个问题,我们就得使用深拷贝了。
# 深拷贝
这个问题通常可以通过 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
- 不能序列化函数
- 不能解决循环引用的对象
但是在通常情况下,复杂数据都是可以序列化的,所以这个函数可以解决大部分问题。
当然你可能想自己来实现一个深拷贝,但是其实实现一个深拷贝是很困难的,需要我们考虑好多种边界情况,比如原型链如何处理、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
# 进阶知识点
# call apply 和 bind 函数
涉及面试题:call、apply 及 bind 函数三者的区别及内部实现是怎么样的?
- 三者都会将函数的 this 指向 第一个参数
- call 和 apply 返回函数执行的结果, bind 返回的是修改过 this 指向的一个函数
- 参数形式不同, apply 后续参数为数组
Function.prototype.myCall = function(context) {
// 思路: 让context可以执行该函数,转变为给context添加该函数,运行后删除即可
var context = context || window;
// getValue.call(obj, 'bufan');
context.fn = this; // this指向调用者 也就是该函数
// 参数
var args = [...arguments].slice(1);
var result = context.fn(...args);
delete context.fn;
return result;
};
以下是对实现的分析:
首先 context
为可选参数,如果不传的话默认上下文为 window
接下来给 context
创建一个 fn
属性,并将值设置为需要调用的函数
因为 call
可以传入多个参数作为调用函数的参数,所以需要将参数剥离出来
然后调用函数并将对象上的函数删除
以上就是实现 call
的思路,apply
的实现也类似,区别在于对参数的处理
Function.prototype.myApply = function(context) {
var context = context || window;
context.fn = this;
// 后续参数
var result;
if (arguments[1]) {
result = context.fn(...arguments[1]);
} else {
result = context.fn();
}
delete context.fn;
return result;
};
bind
的实现对比其他两个函数略微地复杂了一点,因为 bind 需要返回一个函数,需要判断一些边界问题
// 会创建一个新函数。
// 当这个新函数被调用时,bind() 的第一个参数将作为它运行时的 this,
// 之后的一序列参数将会在传递的实参前传入作为它的参数。(来自于 MDN )
Function.prototype.myBind = function(context) {
if (typeof this !== "function") {
throw new TypeError("error");
}
var _this = this;
// 参数
var args = [...arguments].slice(1);
// 返回一个函数
return function F() {
// 所以需要判断 new F()
// 在构造器内
// instanceof 可以判断 F 是作为构造器使用(true) 还是 普通函数使用(false)
if (this instanceof F) {
return new _this(...args, ...arguments);
} else {
return _this.apply(context, args.concat(...arguments));
}
};
};
以下是对实现的分析:
bind
返回了一个函数,对于函数来说有两种方式调用,一种是直接调用,一种是通过 new
的方式,我们先来说直接调用的方式
对于直接调用来说,这里选择了 apply
的方式实现,但是对于参数需要注意以下情况:因为 bind
可以实现类似这样的代码 f.bind(obj, 1)(2)
,所以我们需要将两边的参数拼接起来,于是就有了这样的实现 args.concat(...arguments)
最后来说通过 new
的方式,在之前的章节中我们学习过如何判断 this
,对于 new
的情况来说,不会被任何方式改变 this
,所以对于这种情况我们需要忽略传入的 this
# new
涉及面试题:new 的原理是什么?通过 new 的方式创建对象和通过字面量创建有什么区别?
在调用 new 的过程中会发生以上四件事情:
- 新生成了一个对象
- 链接到原型
- 绑定 this
- 返回新对象
根据以上几个过程,我们也可以试着来自己实现一个 new
function myNew(fn) {
var obj = {};
if (fn.prototype !== null) {
obj.__proto__ = fn.prototype;
}
// var args = [...arguments].slice(1);
var result = fn.apply(obj, Array.prototype.slice(arguments, 1));
if (typeof result === "object" || (typeof result === "function" && result !== null)) {
return result;
}
return obj;
}
对于对象来说,其实都是通过 new
产生的,无论是 function Foo()
还是 let a = { b : 1 }
。
对于创建一个对象来说,更推荐使用字面量的方式创建对象(无论性能上还是可读性)。因为你使用 new Object()
的方式创建对象需要通过作用域链一层层找到 Object
,但是你使用字面量的方式就没这个问题。
function Foo() {}
// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 };
// 这个字面量内部也是使用了 new Object()
# instanceof 的原理
涉及面试题:instanceof 的原理是什么?
instanceof 可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype。
我们也可以试着实现一下 instanceof
function myInstanceof(left, right) {
let prototype = right.prototype;
let left = left.__proto__;
while (true) {
if (left === null || left === undefined) return false;
if (prototype === left) return true;
left = left.__proto__;
}
}
以下是对实现的分析:
首先获取类型的原型 然后获得对象的原型 然后一直循环判断对象的原型是否等于类型的原型,直到对象原型为 null,因为原型链最终为 null
# 为什么 0.1 + 0.2 != 0.3
涉及面试题:为什么 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