JavaScript面试基础(一)
今天开一个新的分类《JavaScript 面试基础》,用来记录复习JavaScript基础的面试内容。本部分把JavaScript的核心知识进行整理,主要涉及一些常问的问题,都是基础、原理相关或者API相关的。在这个系列中没有与框架相关的东西,也没有手写题(这两部分另外单独做整理)。
注
这个春天有点冷,BOSS直拒上联系了50多家公司,也就一个面试,希望三月底之前能找到工作。
数据类型
分类
JavaScript有两种数据类型:基本数据类型和引用数据类型。
基本数据类型有7种:number、string、undefined、null、boolean、symbol、bigint。基本数据类型之外的都是引用数据类型。
基本数据类型和引用数据类型存储在内存中的位置不同:
基本数据类型存储在栈中
引用类型的引用存储在栈中,引用指向的对象数据存储于堆中
深浅拷贝
有了上面的知识储备后,就不难理解浅拷贝和深拷贝了。
浅拷贝相较于原数据会用新的引用存储数据,不过如果源对象里面有引用数据类型,则会直接把引用拷贝过去。这样操作的话就会出现修改源对象内部引用数据类型的属性的值,会引起新对象数据的变化。深拷贝则是完全复制,为源对象内部嵌套的引用数据类型重新在新对象中开辟空间存储。
比较常见的几种浅拷贝的方式:
...拓展运算符Object.create、Object.assignArray的concat、slice
深拷贝的实现一般会以手写题的形式考察,特别要注意循环引用的处理。
类型转换
JavaScript中存在显式和隐式两种类型转换。
显式类型转换
常见的显式类型转换有这种:Number()、parseInt()、String()、Boolean()。
Number()返回NaN的几种情况:
传不能被解析成数字的字符串
传
undefined(注意:Number(null) === 0)传引用数据类型
数组:如果长度为
1,对里面的值进行解析;长度大于1解析为NaN其他对象:解析为
NaN
parseInt函数逐个解析字符,遇到不能转换的字符就停下来,如:
const a = parseInt("123abc"); // 123String()解析字符串的规则如下:
// 数值:转为相应的字符串
String(1); // "1"
//字符串:转换后还是原来的值
String("a"); // "a"
//布尔值:true转为字符串"true",false转为字符串"false"
String(true); // "true"
//undefined:转为字符串"undefined"
String(undefined); // "undefined"
//null:转为字符串"null"
String(null); // "null"
//对象
String({ a: 1 }); // "[object Object]"
String([1, 2, 3]); // "1,2,3"Boolean()转为false的几种传参:false、NaN、undefined、""、0、null。除此之外的其他的传参数都会转为true。
隐式类型转换
在下面的场景会出现隐式类型转换:
if、while中的条件会自动转为布尔值运算(比较运算、算数运算)且运算符两侧数据不一致
自动转为boolean的规则:
false、NaN、undefined、""、+0、-0、null会被转为false上面情形之外的会被转为
true
在最前面提到的运算引起的类型转换,一般会转换成数值或字符串。转为字符串,一般会先将复合类型的值转为原始类型的值,再将原始类型的值转为字符串。以下的示例涵盖了自动转string的规则:
"5" + 1; // '51'
"5" + true; // "5true"
"5" + false; // "5false"
"5" + {}; // "5[object Object]"
"5" + []; // "5"
"5" + function () {}; // "5function (){}"
"5" + undefined; // "5undefined"
"5" + null; // "5null"当然,对象会有自定义valueOf的情况,这时候的对象会优先调用valueOf获取值进行计算:
function MyNumberType(n) {
this.number = n;
}
MyNumberType.prototype.valueOf = function () {
return this.number;
};
const object1 = new MyNumberType(4);
console.log(object1 + "1"); // 41自动转number类型一般在使用-、*、/等运算的时候会发生,下面的示例展示了常见的转换:
"5" - "2"; // 3
"5" * "2"; // 10
true - 1; // 0
false - 1; // -1
"1" - 1; // 0
"5" * []; // 0
false / "5"; // 0
"abc" - 1; // NaN
null + 1; // 1
undefined + 1; // NaN提示
这里要稍微提一下==和===的区别(东西不多,不过就内容而言跟类型转换有关系,所以放在这里):
相等操作符
==会做类型转换,再进行值的比较,全等运算符===不会做类型转换===不做类型转换,意味着如果操作数为两个对象,实际比较的是两个引用是否相等
原型和原型链
JavaScript是一门基于原型的编程语言。尽管我们可以用它来进行面向对象编程,但改变不了其基于原型的本质。
MDN对原型链提供了十分详尽的描述:
每个实例对象(object)都有一个私有属性(称之为 proto)指向它的构造函数的原型对象(prototype)。该原型对象也有一个自己的原型对象(proto),层层向上直到一个对象的原型对象为
null。根据定义,null没有原型,并作为这个原型链中的最后一个环节。
因此,当我们访问一个对象的属性,如果在当前对象中找不到,JS会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。
在对象实例和它的构造器之间建立一个链接(__proto__属性),之后通过上溯原型链,在构造器中找到这些属性和方法。
注意
在Object.prototype.proto - JavaScript | MDN中我们可以看到该属性的标注为“已弃用”,也给出了警告提示:
当
Object.prototype.__proto__已被大多数浏览器厂商所支持的今天,其存在和确切行为仅在 ECMAScript 2015 规范中被标准化为传统功能,以确保 Web 浏览器的兼容性。为了更好的支持,建议只使用Object.getPrototypeOf()。
所以在面试手写模拟instanceof的时候,我们获取目标对象原型对象使用Object.getPrototypeOf()获取比较好。
有几个结论:
一切对象都是继承自
Object对象,Object对象直接继承根源对象null一切的函数对象(包括
Object对象),都是继承自Function对象Object对象直接继承自Function对象(所有的构造器都是函数对象,函数对象都是Function构造产生的)Function对象的__proto__会指向自己的原型对象,最终还是继承自Object对象
typeof 与 instanceof
谈到数据类型必然就离不开这两个关键字。前者用于对基本数据类型的判别,后者借助原型链判断复杂数据类型。
提示
在这里顺便提一下Object.prototype.toString,这个属性也是常见的判别引用数据类型的方法。如:
Object.prototype.toString.call(null); // "[object Null]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(“abc”);// "[object String]"
Object.prototype.toString.call(123);// "[object Number]"
Object.prototype.toString.call(true);// "[object Boolean]"对于typeof关键字而言,有一些值得注意的特殊情况:
typeof null === 'object'typeof undefined === 'undefined'对于引用数据类型而言,
Function是可以被typeof识别出来的
instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,用法如下:
object instanceof constructor其中object为实例对象,constructor为构造函数。
值得一提的是,instanceof的模拟实现在面试题中也是比较常见的,具体实现我打算放在手写题整理的文档里探讨。
继承
面向对象编程(OOP)是如今多种编程语言所实现的一种编程范式,在面向对象编程中有三个主要概念:封装、继承、多态。实际上,JavaScript是基于原型的,本身不提供 class 的实现,即便是在 ES2015/ES6 中引入了 class 关键字,但那也只是语法糖。不过,这似乎并不影响我们使用JavaScript来面向对象。
下面将介绍几种比较常见的JavaScript继承方式。
原型链继承
构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针。
如:
function Father() {
this.name = 'xiaoming'
this.info = {}
}
function Child() {
}
Child.prototype = new Parent();不过原型链继承也有缺点,就上面的用例而言,Child通过new实例化出来的对象的原型对象是共用的。
构造函数继承
通过使用call调用父类构造函数,进行父类属性的继承:
function Father(){
this.name = 'xiaoming';
}
function Child(){
Father.call(this);
}
let child = new Child();其实上面的Child等价于:
function Child(){
this.name = 'xiaoming';
}不过这种继承子类没办法继承父类方法。如:
function Father(){
this.name = 'xiaoming';
}
Father.prototype.getName = function () {
return this.name;
}
function Child(){
Father.call(this);
}
let child = new Child();
child.getName() // 报错组合继承
上面两种方法进行取长补短,形成了组合继承的思路。如:
function Father(){
this.name = 'xiaoming';
}
Father.prototype.getName = function () {
return this.name;
}
function Child(){
Father.call(this);
}
Child.prototype = new Father();
Child.prototype.constructor = Child;
let child = new Child();
child.getName() // xiaoming寄生组合式继承
这种方法是对上面组合继承的优化,父类prototype上的方法继承采用下面的方式:使用Object.create浅拷贝一份父类的prototype给子类。如:
function Father(){
this.name = 'xiaoming';
}
Father.prototype.getName = function () {
return this.name;
}
function Child(){
Father.call(this);
}
child.prototype = Object.create(Father.prototype);
Child.prototype.constructor = Child;
let child = new Child();
child.getName() // xiaoming语法糖:class
ES6增加了class关键字,在这之后我们写类的继承只需要这样:
class Father {
constructor(name) {
this.name = "xiaoming";
}
getName() {
console.log(this.name);
}
}
class Child extends Father {}常用的API
提示
下面的内容会对常用的JavaScript对象的一部分API进行列举,对于一些容易混淆的API会辅以用例进行描述(内容基本是偷MDN的)。这样以后不管是面试还是开发,就不用去到处百度了,直接看这篇文章就可以。
Array类型
数组基本操作可以归纳为 增、删、改、查,需要留意的是,操作会不会对原数组产生改变。
新增操作:
push():在数组末尾新增一条数据,是对原数组的更改unshift():在数组开头新增一条数据,是对原数组的更改splice():通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。此方法会改变原数组。用例如下:const months = ['Jan', 'March', 'April', 'June']; months.splice(1, 0, 'Feb'); // Inserts at index 1 console.log(months); // Expected output: Array ["Jan", "Feb", "March", "April", "June"] months.splice(4, 1, 'May'); // Replaces 1 element at index 4 console.log(months); // Expected output: Array ["Jan", "Feb", "March", "April", "May"]concat():用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。
删除操作:
pop():在数组末尾删除一条数据,是对原数组的更改shift():在数组开头新删除一条数据,是对原数组的更改splice():前面的新增操作已经描述过基本用法,只传前两个参数可以对元素进行删除。slice():返回一个新的数组对象,这一对象是一个由begin和end决定的原数组的浅拷贝(包括begin,不包括end)。原始数组不会被改变。用例如下:const animals = ['ant', 'bison', 'camel', 'duck', 'elephant']; console.log(animals.slice(2)); // Expected output: Array ["camel", "duck", "elephant"] console.log(animals.slice(2, 4)); // Expected output: Array ["camel", "duck"] console.log(animals.slice(1, 5)); // Expected output: Array ["bison", "camel", "duck", "elephant"] console.log(animals.slice(-2)); // Expected output: Array ["duck", "elephant"] console.log(animals.slice(2, -1)); // Expected output: Array ["camel", "duck"] console.log(animals.slice()); // Expected output: Array ["ant", "bison", "camel", "duck", "elephant"]
修改操作比较常用的API是slice,或者直接根据索引进行修改。
查找操作不会对数组进行改变,也不会返回新的数组。常见的查找操作API有以下几个:
indexOf():返回在数组中可以找到给定元素的第一个索引,如果不存在,则返回 -1。includes():用来判断一个数组是否包含一个指定的值,根据情况,如果包含则返回true,否则返回false。find():返回数组中满足提供的测试函数的第一个元素的值。否则返回undefined。如:const array1 = [5, 12, 8, 130, 44]; const found = array1.find(element => element > 10); console.log(found); // Expected output: 12findIndex():返回数组中满足提供的测试函数的第一个元素的索引。若没有找到对应元素则返回 -1。如:const array1 = [5, 12, 8, 130, 44]; const isLargeNumber = (element) => element > 13; console.log(array1.findIndex(isLargeNumber)); // Expected output: 3
当然,对于数组,我们更常用到的是数组的遍历(因为有的时候后端会给一些逆天数据要我们自己处理)。遍历方法大致有下面几种:
forEach:就是for循环遍历,不过有些限制(async/await无效、没法中途中断)map:返回一个新数组,新数组的每一项都是旧数组每一项处理过后的结果,用来处理的函数由我们自己制定。语法大致如下:const newArr = oldArr.map(function(element, index, array) { /* … */ }, thisArg)reduce:对数组的每一项进行遍历,不过这种是有目的性的遍历,最终返回的结果其实是我们自己根据现实需要去设计的。鉴于这个方法比较高级,特地贴出它的完整参数列表:callbackFn一个“reducer”函数,包含四个参数:
previousValue:上一次调用callbackFn时的返回值。在第一次调用时,若指定了初始值initialValue,其值则为initialValue,否则为数组索引为 0 的元素array[0]。currentValue:数组中正在处理的元素。在第一次调用时,若指定了初始值initialValue,其值则为数组索引为 0 的元素array[0],否则为array[1]。currentIndex:数组中正在处理的元素的索引。若指定了初始值initialValue,则起始索引号为 0,否则从索引 1 起始。array:用于遍历的数组。
initialValue可选- 作为第一次调用
callback函数时参数 previousValue 的值。若指定了初始值initialValue,则currentValue则将使用数组第一个元素;否则previousValue将使用数组第一个元素,而currentValue将使用数组第二个元素。
- 作为第一次调用
注
说实话,
reduce的用途非常多,以至于我没办法说清楚它用于什么场景比较合适。如果要近似的去描述,那么我认为它的作用是得出一个特定的值,这个值依赖于原数组的每一项的值以及迭代的逻辑。举个例子:
const numArr = [1,2,3,4,5] const sum = numArr.reduce((pre,cur) => pre + cur,0)
除了上述操作外,还有一些比较常用的API:
Array.from:对一个类似数组或可迭代对象创建一个新的,浅拷贝的数组实例。如:console.log(Array.from('foo')); // Expected output: Array ["f", "o", "o"] console.log(Array.from([1, 2, 3], x => x + x)); // Expected output: Array [2, 4, 6]Array.isArray():判断传入的值是否为Array类型。Array.prototype.flat():拍平数组的方法,参数为嵌套层数。(顺带一提,数组扁平化是比较常见的面试手写题)Array.prototype.some(): 为数组中的每一个元素执行一次callback函数,直到找到一个使得callback返回一个“真值”(即可转换为布尔值true的值)。如果找到了这样一个值,some()将会立即返回true。否则,some()返回false。callback只会在那些”有值“的索引上被调用,不会在那些被删除或从来未被赋值的索引上调用。
String类型
首先要明确一点:字符串的特点是一旦创建了,就不可变,所以对字符串的更改都是体现为创建字符串的一个副本。
我们一般会使用+和模版字符串语法进行字符串的添加操作,不过String类型也提供了concat方法,如:
let hello = 'Hello, '
console.log(hello.concat('Kevin', '. Have a nice day.'))
// Hello, Kevin. Have a nice day.
let greetList = ['Hello', ' ', 'Venkat', '!']
"".concat(...greetList) // "Hello Venkat!"
"".concat({}) // [object Object]
"".concat([]) // ""
"".concat(null) // "null"
"".concat(true) // "true"
"".concat(4, 5) // "45"对于字符串内部内容的删除,String提供了以下方法:
slice():提取某个字符串的一部分,并返回一个新的字符串,且不会改动原字符串。const str = 'The quick brown fox jumps over the lazy dog.'; console.log(str.slice(31)); // Expected output: "the lazy dog." console.log(str.slice(4, 19)); // Expected output: "quick brown fox" console.log(str.slice(-4)); // Expected output: "dog." console.log(str.slice(-9, -5)); // Expected output: "lazy"substr():返回一个字符串中从指定位置开始到指定字符数的字符。注意
尽管
String.prototype.substr(…)没有严格被废弃 (as in "removed from the Web standards"), 但它被认作是遗留的函数并且可以的话应该避免使用。它并非 JavaScript 核心语言的一部分,未来将可能会被移除掉。如果可以的话,使用substring()替代它。var str = "abcdefghij"; console.log("(1,2): " + str.substr(1,2)); // (1,2): bc console.log("(-3,2): " + str.substr(-3,2)); // (-3,2): hi console.log("(-3): " + str.substr(-3)); // (-3): hij console.log("(1): " + str.substr(1)); // (1): bcdefghij console.log("(-20, 2): " + str.substr(-20,2)); // (-20, 2): ab console.log("(20, 2): " + str.substr(20,2)); // (20, 2):substring():返回一个字符串在开始索引到结束索引之间的一个子集,或从开始索引直到字符串的末尾的一个子集。如:var anyString = "Mozilla"; // 输出 "Moz" console.log(anyString.substring(0,3)); console.log(anyString.substring(3,0)); console.log(anyString.substring(3,-3)); console.log(anyString.substring(3,NaN)); console.log(anyString.substring(-2,3)); console.log(anyString.substring(NaN,3)); // 输出 "lla" console.log(anyString.substring(4,7)); console.log(anyString.substring(7,4)); // 输出 "" console.log(anyString.substring(4,4)); // 输出 "Mozill" console.log(anyString.substring(0,6)); // 输出 "Mozilla" console.log(anyString.substring(0,7)); console.log(anyString.substring(0,10));
当我们想更改字符串中的某一项或者对其特定的子字符串进行操作时,可以使用String提供的以下方法:
trim()、trimLeft()、trimRight():删除前、后或前后所有空格符,再返回新的字符串。repeat():接收一个整数参数,表示要将字符串复制多少次,然后返回拼接所有副本后的结果。padStart()、padEnd():提示
这两个方法如果不传第二个参数会默认用空格填充。
padStart()用另一个字符串填充当前字符串(如果需要的话,会重复多次),以便产生的字符串达到给定的长度。从当前字符串的左侧开始填充。const str1 = '5'; console.log(str1.padStart(2, '0')); // Expected output: "05" const fullNumber = '2034399002125581'; const last4Digits = fullNumber.slice(-4); const maskedNumber = last4Digits.padStart(fullNumber.length, '*'); console.log(maskedNumber); // Expected output: "************5581"padEnd()方法会用另一个字符串填充当前字符串(如果需要的话则重复填充),返回填充后达到指定长度的字符串。从当前字符串的末尾(右侧)开始填充。const str1 = 'Breaded Mushrooms'; console.log(str1.padEnd(25, '.')); // Expected output: "Breaded Mushrooms........" const str2 = '200'; console.log(str2.padEnd(5)); // Expected output: "200 "
toLowerCase()、toUpperCase():进行大小写转化
当我们想对字符串进行搜索来判断子字符串是否存在,或是获取第一个匹配值的索引,可能会使用到下面几个API:
chatAt():从一个字符串中返回指定的字符,相当于用索引直接访问。如:var anyString = "Brave new world"; console.log("The character at index 0 is '" + anyString.charAt(0) + "'"); console.log("The character at index 1 is '" + anyString.charAt(1) + "'"); console.log("The character at index 2 is '" + anyString.charAt(2) + "'"); console.log("The character at index 3 is '" + anyString.charAt(3) + "'"); console.log("The character at index 4 is '" + anyString.charAt(4) + "'"); console.log("The character at index 999 is '" + anyString.charAt(999) + "'"); // 输出内容: //The character at index 0 is 'B' //The character at index 1 is 'r' //The character at index 2 is 'a' //The character at index 3 is 'v' //The character at index 4 is 'e' //The character at index 999 is ''indexOf():从字符串开头去搜索传入的字符串,并返回位置(如果没找到,则返回 -1 )提示
这个方法其实有两个参数,第二个参数可选,表示搜索开始的位置,不过一般不会用到。如果想了解可以看String.prototype.indexOf() - JavaScript | MDN。
startsWith():用来判断当前字符串是否以另外一个给定的子字符串开头,并根据判断结果返回true或false。如:const str1 = 'Saturday night plans'; console.log(str1.startsWith('Sat')); // Expected output: true console.log(str1.startsWith('Sat', 3)); // Expected output: falseincludes():执行区分大小写的搜索,以确定是否可以在另一个字符串中找到一个字符串,并根据情况返回true或false。如:const sentence = 'The quick brown fox jumps over the lazy dog.'; const word = 'fox'; console.log(`The word "${word}" ${sentence.includes(word) ? 'is' : 'is not'} in the sentence`); // Expected output: "The word "fox" is in the sentence"该方法与
indexOf同样有第二个参数,一般用不着。
String和正则一起配合使用时,会用到下面的API:
注
整理这一块内容时,才发现自己正则确实不大行。以后有空整理一下正则相关的知识,以及比较常见的正则表达式。
match():检索返回一个字符串匹配正则表达式的结果,如:const paragraph = 'The quick brown fox jumps over the lazy dog. It barked.'; const regex = /[A-Z]/g; const found = paragraph.match(regex); console.log(found); // Expected output: Array ["T", "I"]可以看到,
match根据正则对目标字符串的字串进行匹配,返回匹配到的结果的数组。search():接收一个参数,可以是一个正则表达式字符串,也可以是一个RegExp对象,找到则返回匹配索引,否则返回-1。这个方法通常用来判断字符串中是否有符合正则规则的字串。replace():接收两个参数,第一个参数为匹配的内容,第二个参数为替换的元素(可用函数)。如:const p = 'The quick brown fox jumps over the lazy dog. If the dog reacted, was it really lazy?'; console.log(p.replace('dog', 'monkey')); // Expected output: "The quick brown fox jumps over the lazy monkey. If the dog reacted, was it really lazy?"const regex = /Dog/i; console.log(p.replace(regex, 'ferret')); // Expected output: "The quick brown fox jumps over the lazy ferret. If the dog reacted, was it really lazy?"
除了以上这些,比较常用的还有`split`,这个方法可以将字符串拆分成数组,可以自己指定分割符,如:
```js
let str = "12+23+34"
let arr = str.split("+") // [12,23,34]Function类型
这个类型主要涉及到的API只有三个:call、apply 、bind。
三个方法调用函数的共性都是可以在第一个参数手动指定this(如果传 null 或 undefined 时会自动替换为指向全局对象)。
call和apply作用一样,不过call的第一个参数以后的参数列表是传入待执行函数的参数列表,而apply仅有两个参数,第二个参数为传入待执行函数的参数列表数组。
bind返回值为一个新的函数,调用这个函数时,内部的this就是我们手动指定的第一个参数。
这三个在面试中都会考手写题,虽然这里不给出实现,不过可以简单聊下call和apply的思路:在第一个参数传入的this对象(如果传 null 或 undefined 时会自动替换为指向全局对象)中插入一个函数,用this.xxx()执行函数,执行完后清理掉就行了。这里插入的对象属性为了防止对原对象进行污染,可以使用Symbol。
能够实现call和apply,bind的实现就很简单了。
可以发现,打印的结果都是第一次绑定的对象的值。
:::
面试经常问的几种类型
Set和Map
Set是一种叫做集合的数据结构,Map是一种叫做字典的数据结构;集合是以[value,value]的形式存储元素,字典是以[key,value]的形式存储元素。
Set
Set是es6新增的数据结构,类似于数组,但是成员的值都是唯一的,没有重复的值,我们一般称为集合。
在 Set中,元素是唯一的,而Set又是可迭代的,初始化时可以传入一个可迭代对象,所有元素将不重复地被添加到新的 Set 中。我们可以利用这种特性进行数组和字符串的去重,如:
// 数组
let arr = [3, 5, 2, 2, 5, 5];
let unique = [...new Set(arr)]; // [3, 5, 2]
// 字符串
let str = "352255";
let unique = [...new Set(str)].join(""); // "352"Set类型的基本操作有:
add():新增一个集合元素delete():删除指定的集合元素,返回删除结果(boolean类型)has():判断Set中是否有指定元素,有则返回true,没有返回falseclear():清除所有成员,没有返回值
Set类型遍历的方法,有如下:
keys():返回键名的遍历器values():返回键值的遍历器entries():返回键值对的遍历器forEach():使用回调函数遍历每个成员
Map
Map类型是键值对的有序列表,而键和值都可以是任意类型。Map比较常见的用途就是当成哈希表来实现缓存。
提示
下面是从MDN上摘取的Object和Map两种数据结构的对比:
Object 和 Map 类似的是,它们都允许你按键存取一个值、删除键、检测一个键是否绑定了值。因此(并且也没有其他内建的替代方式了)过去我们一直都把对象当成 Map 使用。
不过 Map 和 Object 有一些重要的区别,在下列情况中使用 Map 会是更好的选择:
| Map | Object | |
|---|---|---|
| 意外的键 | Map 默认情况不包含任何键。只包含显式插入的键。 | 一个 Object 有一个原型,原型链上的键名有可能和你自己在对象上的设置的键名产生冲突。备注:虽然可以用 Object.create(null) 来创建一个没有原型的对象,但是这种用法不太常见。 |
| 键的类型 | 一个 Map 的键可以是任意值,包括函数、对象或任意基本类型。 | 一个 Object 的键必须是一个 String 或是 Symbol。 |
| 键的顺序 | Map 中的键是有序的。因此,当迭代的时候,一个 Map 对象以插入的顺序返回键值。 | 虽然 Object 的键目前是有序的,但并不总是这样,而且这个顺序是复杂的。因此,最好不要依赖属性的顺序。自 ECMAScript 2015 规范以来,对象的属性被定义为是有序的;ECMAScript 2020 则额外定义了继承属性的顺序。参见 OrdinaryOwnPropertyKeys 和 EnumerateObjectProperties 抽象规范说明。但是,请注意没有可以迭代对象所有属性的机制,每一种机制只包含了属性的不同子集。( for-in 仅包含了以字符串为键的属性;Object.keys 仅包含了对象自身的、可枚举的、以字符串为键的属性;Object.getOwnPropertyNames 包含了所有以字符串为键的属性,即使是不可枚举的;Object.getOwnPropertySymbols 与前者类似,但其包含的是以 Symbol 为键的属性,等等。) |
| Size | Map 的键值对个数可以轻易地通过 size 属性获取。 | Object 的键值对个数只能手动计算。 |
| 迭代 | Map 是 可迭代的 的,所以可以直接被迭代。 | Object 没有实现 迭代协议,所以使用 JavaSctipt 的 for...of 表达式并不能直接迭代对象。备注: -对象可以实现迭代协议,或者你可以使用 Object.keys 或 Object.entries。- for...in 表达式允许你迭代一个对象的可枚举属性。 |
| 性能 | 在频繁增删键值对的场景下表现更好。 | 在频繁添加和删除键值对的场景下未作出优化。 |
| 序列化和解析 | 没有元素的序列化和解析的支持。(但是你可以使用携带 replacer 参数的 JSON.stringify() 创建一个自己的对 Map 的序列化和解析支持。参见 Stack Overflow 上的提问:How do you JSON.stringify an ES6 Map?) | 原生的由 Object 到 JSON 的序列化支持,使用 JSON.stringify()。原生的由 JSON 到 Object 的解析支持,使用 JSON.parse()。 |
Map 结构的实例针对增删改查有以下属性和操作方法:
size:返回 Map 结构的成员总数set():设置键名key对应的键值为value,然后返回整个 Map 结构get():get方法读取key对应的键值,如果找不到key,返回undefinedhas():has方法返回一个布尔值,表示某个键是否在当前 Map 对象之中delete():delete方法删除某个键,返回true。如果删除失败,返回falseclear():clear方法清除所有成员,没有返回值
Map的遍历API和Set一样,就不重复写了。
WeakSet和WeakMap
这两个其实在业务中用的少,或许在写Infra有的时候要用来做优化?不过面试还是会面,因为面试官也看八股。
因为用的少,所以写的很敷衍,这里大部分都是复制粘贴的内容。阮一峰老师的ES6 入门教程中可以看到比较详尽的内容。
WeakSet
相较于Set,WeakSet有以下特点:
WeakSet只能是对象的集合,而不能像Set那样,可以是任何类型的任意值。WeakSet持弱引用:集合中对象的引用为弱引用。如果没有其他的对WeakSet中对象的引用,那么这些对象会被当成垃圾回收掉。(所谓弱引用,即在垃圾回收中WeakSet的引用说了不算,即便WeakSet还存在引用,垃圾回收该清理掉还是会清理掉)在
API中WeakSet与Set有两个区别:- 没有遍历操作的
API - 没有
size属性
- 没有遍历操作的
因为其弱引用的特性,可以用来检测循环引用,如:
// 对 传入的 subject 对象 内部存储的所有内容执行回调
function execRecursively(fn, subject, _refs = new WeakSet()) {
// 避免无限递归
if (_refs.has(subject)) {
return;
}
fn(subject);
if (typeof subject === "object") {
_refs.add(subject);
for (const key in subject) {
execRecursively(fn, subject[key], _refs);
}
}
}
const foo = {
foo: "Foo",
bar: {
bar: "Bar",
},
};
foo.bar.baz = foo; // 循环引用!
execRecursively((obj) => console.log(obj), foo);WeakMap
WeakMap结构与Map结构类似,也是用于生成键值对的集合。
在API中WeakMap与Map有两个区别:
- 没有遍历操作的
API - 没有
clear清空方法
WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名,如:
const map = new WeakMap();
map.set(1, 2)
// TypeError: 1 is not an object!
map.set(Symbol(), 2)
// TypeError: Invalid value used as weak map key
map.set(null, 2)
// TypeError: Invalid value used as weak map keyWeakMap的键名所指向的对象,一旦不再需要,里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。
注意:WeakMap 弱引用的只是键名,而不是键值。键值依然是正常引用,如:
const wm = new WeakMap();
let key = {};
let obj = {foo: 1};
wm.set(key, obj);
obj = null;
wm.get(key)
// Object {foo: 1}Proxy和Reflect
创作中...
Promise和Generator
创作中...
其他问题
一些零碎的问题就放在这里了。
小数的精度损失问题
老生常谈的问题:为何 0.1+0.2不等于0.3。
这个问题问题的根源在于当我们将0.1转为二进制(乘2取整法),会发现是一个无限循环的表示方式,而JavaScript遵循国际 IEEE 754 标准,将数字存储为双精度浮点数,存储二进制时小数点的偏移量最大为52位。这种情况会造成二进制的舍入操作(0舍1入),当再转换为十进制时就造成了计算误差。
具体过程可以看看下面的计算过程:
// 0.1 和 0.2 都转化成二进制后再进行运算
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111
// 转成十进制正好是 0.30000000000000004解决方法:
使用第三方库:
Math.js、BigDecimal.jsparseFloat(1.4000000000000001.toPrecision(12)) === 1.4
运行机制
这部分内容包含一些JavaScript常见的运行机制,也是尽可能的去简述,务求够用就行,对于具体怎么实现不做探讨。
垃圾回收(GC)
注
这部分内容因为自己不大会,所以找了万能的ChatGPT来写。更为详尽的东西可以看以下文章:
基本机制
JavaScript的垃圾回收机制用于检测和清除不再使用的对象,以释放内存空间。这种机制的优点就是无需手动释放内存,可以帮助开发人员避免内存泄漏和其他相关问题。
具体来说,它通过以下两个步骤实现:
标记清除:垃圾回收器会定期检查内存中的所有变量和对象,标记那些不再被引用的变量和对象,然后将其清除。
引用计数:垃圾回收器还会跟踪每个变量和对象被引用的次数。如果一个对象没有任何引用,那么垃圾回收器会立即清除它。但是,这种方法容易造成循环引用的问题,导致内存泄漏。
V8的垃圾回收
V8是一款JavaScript引擎,下面是V8的垃圾回收机制的简述:
分代垃圾回收:V8将内存分为新生代和老生代两个部分。新生代中的对象生命周期较短,而老生代中的对象生命周期较长。V8采用不同的垃圾回收算法对新生代和老生代进行回收。
标记-清除算法:V8采用标记-清除算法对老生代进行垃圾回收。该算法分为标记和清除两个阶段。首先,V8会标记所有仍然在使用的对象,然后清除所有未被标记的对象。
增量标记算法:为了避免长时间的垃圾回收导致的程序卡顿,V8采用了增量标记算法。该算法将标记阶段分为多个小阶段,每个小阶段执行完毕后就会让程序继续执行,从而减少了程序的停顿时间。
空间复制算法:V8采用空间复制算法对新生代进行垃圾回收。该算法将新生代内存空间分为两个相等的部分,每次只使用其中的一半空间,当其中一半空间被占满后,就将其中还存活的对象复制到另一半空间中,然后清除之前使用的空间。
对象晋升:当一个对象在新生代中经历了多次垃圾回收后仍然存活,就会被晋升到老生代中。
内存泄露
虽然JavaScript自动做了垃圾回收的工作,但有的时候还是会出现内存泄漏。下面是比较常见的内存泄漏场景以及解决方案:
循环引用 循环引用是指两个或多个对象相互引用,形成闭环,导致垃圾回收器无法回收这些对象的内存。解决方法是使用
WeakMap或WeakSet来存储对象引用,这些容器可以在对象不再被使用时自动删除引用。DOM对象 在使用JavaScript操作DOM对象时,如果没有正确地释放对象引用,就会导致内存泄漏。解决方法是在不需要使用DOM对象时,手动将其引用设置为null,使其成为垃圾回收的对象。定时器 在使用定时器时,如果不及时清除定时器,就会导致内存泄漏。解决方法是在不需要使用定时器时,手动清除定时器。
闭包 闭包是指函数中包含对外部变量的引用,导致这些变量无法被垃圾回收器回收。解决方法是在不需要使用闭包时,手动将其引用设置为
null。全局变量 全局变量会一直存在于内存中,直到程序结束。解决方法是使用模块化的方式来管理变量,将变量封装在模块内部,避免污染全局命名空间。
大量数据 在处理大量数据时,需要注意及时释放不再使用的数据,避免内存泄漏。解决方法是使用分页或滚动加载等方式,减少一次性加载大量数据的情况。
闭包
JavaScript的闭包机制是指函数可以访问其定义时所在的作用域中的变量,即使函数在定义时已经离开了该作用域。这个特性可以用于创建私有变量和函数,以及实现模块化。
任何闭包的使用场景都离不开这两点:
- 创建私有变量
- 延长变量的生命周期
一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的
常见的闭包应用有:函数柯里化、防抖/节流、私有变量创建等。这些场景在面试也是比较经常考手写实现的。
不过结合上面的垃圾回收部分的内容,我们可以知道,在创建闭包时所在词法环境的引用不会被GC清理掉。如果没有及时释放,就会导致垃圾回收器无法回收这些对象,造成内存泄漏,进而影响程序的性能和稳定性。
EventLoop机制
因为JavaScript是单线程的,单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务,前一个任务如果耗时非常长,后一个任务将一直没法执行。JavaScript中利用EventLoop机制解决了这个问题。
提示
在之前写的文章JavaScript的异步编程 | kira的小站 (kirazz1.top)有聊过这部分内容,所以把里面相关的东西直接复制过来了。
在JavaScript中,把任务分为同步任务和异步任务。在执行同步任务的过程中,会产生一部分事件(如定时器创建,I/O等),这部分事件如果继续往下同步执行,就会阻塞后面代码的执行。为了解决这个问题,EventLoop机制引入异步任务的概念,当前代码块执行完实际上是不包括代码本身产生的事件的,这些事件放后面在进行处理(具体处理流程见下面)
异步任务包括宏任务和微任务:
常见的微任务:
Promise.then,Object.observe,MutationObserver,process.nextTick(Node 环境)。常见的宏任务:
setTimeout,ajax,dom事件,setImmediate(Node 环境),requestAnimationFrame。
整个EventLoop的处理流程如下:
同步任务执行完毕后会开始从调用栈中去执行异步任务
优先执行微任务队列,当微任务队列清空后才会去执行宏任务
每次单个宏任务执行完毕后会去检查微任务队列是否为空,如果不为空会按照先入先出的原则执行微任务(微任务中也可以产生异步任务),待微任务队列清空后再执行下一个宏任务,如此循环往复。
围绕这个机制,会出现很多的场景很多的问题。不管是面试还是生产编写代码,基本都逃不开异步编程。关于面试题,详细的知识点整理已经放在JavaScript的异步编程 | kira的小站 (kirazz1.top)了。
执行上下文
执行上下文是JavaScript中一个抽象的概念,它代表了JavaScript代码执行时的环境。每当JavaScript代码执行时,就会创建一个新的执行上下文。执行上下文包括三个部分:变量对象、作用域链、this指向。其中,变量对象是当前环境中声明的变量、函数和形参的属性,作用域链是当前环境与外部环境的链接,this指向是当前环境的this值。
基本概念
执行上下文的类型分为三种:
- 全局执行上下文:只有一个,浏览器中的全局对象就是
window对象,this指向这个全局对象 - 函数执行上下文:存在无数个,只有在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文
Eval函数执行上下文: 指的是运行在eval函数中的代码,很少用而且不建议使用
关于执行上下文的访问,有以下几个注意点:
全局上下文(的变量)能被其他任何上下文访问
每次调用函数创建一个新的上下文,会创建一个私有作用域,函数内部声明的任何变量都不能在当前函数作用域外部直接访问
执行栈是JavaScript中的一个数据结构,它用来存储执行上下文。当JavaScript代码执行时,执行栈会不断地将当前执行上下文压入栈中,并在执行完毕后将其弹出。JavaScript代码的执行顺序遵循“后进先出”的原则,也就是说,当前执行的上下文在执行完毕后,才会继续执行上一个上下文。
执行上下文的生命周期包括三个阶段:创建阶段 → 执行阶段 → 销毁阶段。
提示
以下内容来自ChatGPT的解答:
创建阶段:在执行一个函数或者全局代码时,
JavaScript引擎会先创建一个执行上下文对象。在创建阶段,引擎会完成以下操作:创建一个变量对象(
Variable Object):用于存储函数声明、函数参数、变量声明等信息。初始化作用域链(
Scope Chain):作用域链是一个指向变量对象的链表,用于处理变量的查找。确定 this 指向:
this的值取决于函数的调用方式。创建并初始化变量:在变量对象中,对函数声明进行初始化,并将参数名和参数值一一对应。
执行阶段:在完成创建阶段后,
JavaScript引擎会开始执行函数代码。在执行阶段,引擎会逐行解释代码,并对变量进行赋值、函数调用等操作。销毁阶段:当函数执行完毕后,
JavaScript引擎会销毁当前的执行上下文对象。在销毁阶段,引擎会完成以下操作:释放变量空间:将变量对象和作用域链从内存中删除,释放空间。
回收函数的调用栈:将当前函数的调用栈弹出,回到上一层函数的执行上下文中。
执行垃圾回收:如果当前执行上下文中有一些变量已经没有被引用,
JavaScript引擎会将它们从内存中删除,以便回收空间。
作用域和作用域链
概念
首先明确一点,JavaScript遵循词法作用域。所谓词法作用域,即变量的作用域是在代码中定义时就已经确定了,而不是在运行时动态确定的。
JavaScript的作用域分以下三种:
全局作用域:运行所有代码的默认作用域
函数作用域:函数体内的作用域
块级作用域:使用
let和const声明的变量仅在当前代码块内可被访问
当在Javascript中使用一个变量的时候,首先Javascript引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。
一个简单的用例:
const a = "xiaoming"
function b() {
const c = "xiaohong"
console.log(a) // xiaoming
}
function d() {
console.log(c) // Uncaught ReferenceError: c is not defined
}
b()
d()var、let和const
在ES6之前,声明变量是用var。使用var很自由,可以写出类似下面的代码:
console.log(num) // undefined
var num = 20 // 20
console.log(num)
var num = 30
console.log(num) // 30
function a() {
var num = 40
console.log(num)
}
a() // 40
console.log(num) // 30上面的代码存在几个现象:
变量提升:如果定义晚于使用,会先执行定义。
比如:
console.log(num) // undefined var num = 20 // 20等价于:
var num; console.log(num) // undefined num = 20 // 20允许重复声明,后面的声明会覆盖之前的。
在函数中使用
var声明变量时候,该变量是局部的(外界访问不到);不使用var声明直接使用,则会通过作用域链去找这个变量。
不过,现在时代变了,大家都在使用let和const。使用了let和const,一些骚操作就被禁止了:
不存在变量提升,只允许先声明后使用,不然报错
声明后,即创建块级作用域,存在暂时性死区的现象:
var a ='xiaoming' if (a === 'xiaoming') { a = 'xiaohong' // 报错 let a; }上面代码虽然花括号外层存在
a,但是由于后面使用let声明了a,这个区域就不让先声明后使用了。不允许在相同作用域中重复声明变量
说完新特性与var的本质区别,我们再来探讨一下const和let的特性。
首先,let声明的变量可以被重新赋值,而const声明的是常量,不可变。
其次,const声明的常量不可变,针对的是其字面量。而引用数据类型的字面量是其引用,可以把它理解为一个地址。如果直接对其进行修改(如重新赋值)是不允许的,不过修改这个地址指向的数据是没有问题的。如:
const a = { b: 123 }
a.b = 456 // 这样是可以的
a = { b: 456 } //会报错this指向
提示
关于this的一切,可以在这里看到:this - JavaScript | MDN。
基本规则
注意
这部分说的函数,都是不包括箭头函数的,箭头函数的情形放后面单独探讨。
在绝大多数情况下,函数的调用方式决定了
this的值(运行时绑定)。this不能在执行期间被赋值,并且在每次函数被调用时this的值也可能会不同。
this 关键字是函数运行时自动生成的一个内部对象,只能在函数内部使用,总指向调用它的对象。
基本规则很简单,即:谁调用,指向谁。
window.a = 123;
function b() {
console.log(this.a)
}
b() // 123
const ctx = {
a: 456,
b
}
ctx.b() // 456在执行过程中,不能使用赋值的操作改变this:
var a = 10;
var ctx = {
a: 20
}
function b() {
this = ctx;
console.log(this.a);
}
b(); // 报错如果需要改变函数执行的this,可以使用bind、call和apply方法。
箭头函数
ES6提供了箭头函数,让我们在代码书写时就能确定 this 的指向(编译时绑定)。
MDN对箭头函数的表述:
箭头函数表达式的语法比函数表达式更简洁,并且没有自己的
this,arguments,super或new.target。箭头函数表达式更适用于那些本来需要匿名函数的地方,并且它不能用作构造函数。
箭头函数不提供自身的 this 绑定(this 的值将保持为闭合词法上下文的值)。可以看下面的用例:
function a() {
this.b = 123
this.c = () => console.log(this.b)
}
const _a = new a()
_a.c() // 123
const d = _a.c
d() // 123可以看到,箭头函数的this指向一经确定就不会变了。在React类组件中,使用箭头函数可以保证我们使用的this永远指向组件实例。
