# 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,所以此时的 thiswindow。另外对箭头函数使用 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
上次更新: 11/22/2019, 10:01:47 AM