「2021」高频前端面试题汇总之JavaScript篇(上)
前端面试里面 JS面试应该占据了至少50%的比例了,这是对于基本功的考验,虽然现在用的框架越来越简化了我们手动写js了,但是所有的框架根基还是js,在面试的过程中很多面试官不会问你**框架会用吗?而是问你为什么这么实现?怎么去实现的呢?那么如果我们不会JS,JS的基本功不扎实,那么我们就没有办法回答这些问题,所以下面就收集了有关JS的面试题。
JS面试必备课程:《 再学JavaScript ES(6-11)全版本语法大全 》
一、数据类型
1. JavaScript有哪些数据类型,它们的区别
JavaScript共有七种基本数据类型,分别是 Undefined、Null、Boolean、Number、String,还有在 ES6 中新增的 Symbol 和 BigInt 类型:
- Symbol 代表创建后独一无二且不可变的数据类型,它的出现我认为主要是为了解决可能出现的全局变量冲突的问题。
- BigInt 是一种数字类型的数据,它可以表示任意精度格式的整数,使用 BigInt 可以安全地存储和操作大整数,即使这个数已经超出了 Number 能够表示的安全整数范围。
这些数据可以分为原始数据类型和引用数据类型:
- 栈:原始数据类型(Undefined、Null、Boolean、Number、String)
- 堆:引用数据类型(对象、数组和函数)
两种类型的区别是:存储位置不同。
- 原始数据类型直接存储在栈(stack)中的简单数据段,占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储。
- 引用数据类型存储在堆(heap)中的对象,占据空间大、大小不固定。如果存储在栈中,将会影响程序运行的性能;引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
堆和栈的概念存在于数据结构中和操作系统内存中:
- 在数据结构中,栈中数据的存取方式为先进后出。
- 堆是一个优先队列,是按优先级来进行排序的,优先级可以按照大小来规定。完全二叉树是堆的一种实现方式。
在操作系统中,内存被分为栈区和堆区:
- 栈区内存由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
- 堆区内存一般由程序员分配释放,若程序员不释放,程序结束时可能由垃圾回收机制回收。
2. 数据类型检测的方式有哪些
(1)typeof
console.log(typeof 2); // number
console.log(typeof true); // boolean
console.log(typeof 'str'); // string
console.log(typeof []); // object
console.log(typeof function(){}); // function
console.log(typeof {}); // object
console.log(typeof undefined); // undefined
console.log(typeof null); // object
其中数组、对象、null都会被判断为object,其他判断都正确。 (2)instanceof instanceof
可以正确判断对象的类型,因为其内部的机制是通过判断在其原型链中能否找到该类型的原型。
console.log(2 instanceof Number); // false
console.log(true instanceof Boolean); // false
console.log('str' instanceof String); // false
console.log([] instanceof Array); // true
console.log(function(){} instanceof Function); // true
console.log({} instanceof Object); // true
有上面可以看到,instanceof
只能正确判断引用数据类型,而不能判断基本数据类型。
instanceof
运算符用来测试一个对象在其原型链中是否存在一个构造函数的 prototype
属性
(3) constructor
console.log((2).constructor === Number); // true
console.log((true).constructor === Boolean); // true
console.log(('str').constructor === String); // true
console.log(([]).constructor === Array); // true
console.log((function() {}).constructor === Function); // true
console.log(({}).constructor === Object); // true
constructor
有两个作用,一是判断数据的类型,二是对象实例通过 constrcutor
对象访问它的构造函数。
需要注意的是,如果创建一个对象来改变它的原型,constructor
就不能用来判断数据类型了:
function Fn(){};
Fn.prototype = new Array();
var f = new Fn();
console.log(f.constructor===Fn); // false
console.log(f.constructor===Array); // true
(4)Object.prototype.toString.call() Object.prototype.toString.call()
使用 Object 对象的原型方法 toString ,判断数据类型
var a = Object.prototype.toString;
console.log(a.call(2));
console.log(a.call(true));
console.log(a.call('str'));
console.log(a.call([]));
console.log(a.call(function(){}));
console.log(a.call({}));
console.log(a.call(undefined));
console.log(a.call(null));
同样是检测对象obj调用toString方法,obj.toString()的结果和Object.prototype.toString.call(obj)的结果不一样,这是为什么?
这是因为toString为Object的原型方法,而Array ,function等类型作为Object的实例,都重写了toString方法。不同的对象类型调用toString方法时,根据原型链的知识,调用的是对应的重写之后的toString方法(function类型返回内容为函数体的字符串,Array类型返回元素组成的字符串…),而不会去调用Object上原型toString方法(返回对象的具体类型),所以采用obj.toString()不能得到其对象类型,只能将obj转换为字符串类型;因此,在想要得到对象的具体类型时,应该调用Object上原型toString方法。
3. 判断数组的方式有哪些
- 通过Object.prototype.toString.call()做判断
Object.prototype.toString.call().slice(8,-1) === 'Array';
- 通过原型链来判断
obj.__proto__ === Array.prototype;
- 通过es6 Array.isArrray()做判断
Array.isArrray(obj);
- 通过instanceof做判断
obj instanceof Array
- 通过Array.prototype.isPrototypeOf
Array.prototype.isPrototypeOf(obj)
4. null和undefined区别
首先 Undefined 和 Null 都是基本数据类型,这两个基本数据类型分别都只有一个值,就是 undefined 和 null。
undefined 代表的含义是未定义,null 代表的含义是空对象。一般变量声明了但还没有定义的时候会返回 undefined,null主要用于赋值给一些可能会返回对象的变量,作为初始化。
undefined 在 js 中不是一个保留字,这意味着可以使用 undefined 来作为一个变量名,这样的做法是非常危险的,它会影响对 undefined 值的判断。但是可以通过一些方法获得安全的 undefined 值,比如说 void 0。
当对两种类型使用 typeof 进行判断的时候,Null 类型化会返回 “object”,这是一个历史遗留的问题。当使用双等号对两种类型的值进行比较时会返回 true,使用三个等号时会返回 false。
5. type null 的结果是什么,为什么
type null 的结果是Object
在 JavaScript 第一个版本中,所有值都存储在 32 位的单元中,每个单元包含一个小的 类型标签(1-3 bits) 以及当前要存储值的真实数据。类型标签存储在每个单元的低位中,共有五种数据类型:
000: object - 当前存储的数据指向一个对象。
1: int - 当前存储的数据是一个 31 位的有符号整数。
010: double - 当前存储的数据指向一个双精度的浮点数。
100: string - 当前存储的数据指向一个字符串。
110: boolean - 当前存储的数据是布尔值。
如果最低位是 1,则类型标签标志位的长度只有一位;如果最低位是 0,则类型标签标志位的长度占三位,为存储其他四种数据类型提供了额外两个 bit 的长度。
有两种特殊数据类型:
- undefined的值是 (-2)(一个超出整数范围的数字)
- null 的值是机器码 NULL 指针(null 指针的值全是 0)
那也就是说null的类型标签也是000,和Object的类型标签一样,所以会被判定为Object。
6. intanceof 操作符的实现原理及实现
instanceof 运算符用于判断构造函数的 prototype 属性是否出现在对象的原型链中的任何位置。
function myInstanceof(left, right) {
let proto = Object.getPrototypeOf(left), // 获取对象的原型
prototype = right.prototype; // 获取构造函数的 prototype 对象
// 判断构造函数的 prototype 对象是否在对象的原型链上
while (true) {
if (!proto) return false;
if (proto === prototype) return true;
proto = Object.getPrototypeOf(proto);
}
}
7. 为什么0.1+0.2 ! == 0.3,如何让其相等
在开发过程中遇到类似这样的问题:
let n1 = 0.1, n2 = 0.2
let n3 = n1 + n2 //0.30000000000000004
这不是想要的结果,要想等于0.3,就要把它进行转化:
n3.toFixed(2) // 注意,toFixed为四舍五入
toFixed(num)
方法可把 Number 四舍五入为指定小数位数的数字。 那为什么会出现这样的结果呢?
计算机是通过二进制的方式存储数据的,所以计算机计算0.1+0.2的时候,实际上是计算的两个数的二进制的和。0.1的二进制是0.0001100110011001100...
(1100循环),0.2的二进制是:0.00110011001100...
(1100循环),这两个数的二进制都是无限循环的数。那JavaScript是如何处理无限循环的二进制小数呢?
JavaScript中所用的数字包括整数和小数,但是只有一种类型——Number,它的实现遵循IEEE 754标准,使用64位固定长度来表示,也就是标准的double双精度浮点数。在二进制科学表示法中,双精度浮点数的小数部分最多只能保留52位,再加上前面的1,其实就是保留53位有效数字,剩余的舍去,遵从“0舍1入”的原则。
根据这个原则,0.1和0.2的二进制数相加,再转化为十进制数就是:0.30000000000000004
。
下面看一下双精度数是如何保存的:
- 第一部分(蓝色):用来存储符号位(sign),用来区分正负数,0表示正数,占用1位
- 第二部分(绿色):用来存储指数(exponent),占用11位
- 第三部分(红色):用来存储小数(fraction),占用52位
对于0.1,它的二进制为:
0.00011001100110011001100110011001100110011001100110011001 10011...
转为科学计数法(科学计数法的结果就是浮点数):
1.1001100110011001100110011001100110011001100110011001*2^-4
可以看出0.1的符号位为0,指数位为-4,小数位为:
1001100110011001100110011001100110011001100110011001
那么问题又来了,指数位是负数,该如何保存呢?
IEEE标准规定了一个偏移量,对于指数部分,每次都加这个偏移量进行保存,这样即使指数是负数,那么加上这个偏移量也就是正数了。由于JavaScript的数字是双精度数,这里就以双精度数为例,它的指数部分为11位,能表示的范围就是0~2047,IEEE固定双精度数的偏移量为1023。
- 当指数位不全是0也不全是1时(规格化的数值),IEEE规定,阶码计算公式为 e-Bias。 此时e最小值是1,则1-1023= -1022,e最大值是2046,则2046-1023=1023,可以看到,这种情况下取值范围是
-1022~1013
。 - 当指数位全部是0的时候(非规格化的数值),IEEE规定,阶码的计算公式为1-Bias,即1-1023= -1022。
- 当指数位全部是1的时候(特殊值),IEEE规定这个浮点数可用来表示3个特殊值,分别是正无穷,负无穷,NaN。 具体的,小数位不为0的时候表示NaN;小数位为0时,当符号位s=0时表示正无穷,s=1时候表示负无穷。
对于上面的0.1的指数位为-4,-4+1023 = 1019 转化为二进制就是:1111111011
.
所以,0.1表示为:
0 1111111011 1001100110011001100110011001100110011001100110011001
说了这么多,是时候该最开始的问题了,如何实现0.1+0.2=0.3呢?
对于这个问题,一个直接的解决方法就是设置一个误差范围,通常称为“机器精度”。对JavaScript来说,这个值通常为2,在ES6中,提供了Number.EPSILON
属性,而它的值就是2,只要判断0.1+0.2-0.3
是否小于Number.EPSILON
,如果小于,就可以判断为0.1+0.2 ===0.3
function numberepsilon(arg1,arg2){
return Math.abs(arg1 - arg2) < Number.EPSILON;
}
console.log(numberepsilon(0.1 + 0.2, 0.3)); // true
8. JavaScript为什么要进行变量提升,它导致了什么问题?
变量提升的表现是,无论在函数中何处位置声明的变量,好像都被提升到了函数的首部,可以在变量声明前访问到而不会报错。
造成变量声明提升的本质原因是 js 引擎在代码执行前有一个解析的过程,创建了执行上下文,初始化了一些代码执行时需要用到的对象。当访问一个变量时,会到当前执行上下文中的作用域链中去查找,而作用域链的首端指向的是当前执行上下文的变量对象,这个变量对象是执行上下文的一个属性,它包含了函数的形参、所有的函数和变量声明,这个对象的是在代码解析的时候创建的。
首先要知道,JS在拿到一个变量或者一个函数的时候,会有两步操作,即解析和执行。
- 在解析阶段,JS会检查语法,并对函数进行预编译。解析的时候会先创建一个全局执行上下文环境,先把代码中即将执行的变量、函数声明都拿出来,变量先赋值为undefined,函数先声明好可使用。在一个函数执行之前,也会创建一个函数执行上下文环境,跟全局执行上下文类似,不过函数执行上下文会多出this、arguments和函数的参数。
- 全局上下文:变量定义,函数声明
- 函数上下文:变量定义,函数声明,this,arguments
- 在执行阶段,就是按照代码的顺序依次执行。
那为什么会进行变量提升呢?主要有以下两个原因:
- 提高性能
- 容错性更好
(1)提高性能 在JS代码执行之前,会进行语法检查和预编译,并且这一操作只进行一次。这么做就是为了提高性能,如果没有这一步,那么每次执行代码前都必须重新解析一遍该变量(函数),而这是没有必要的,因为变量(函数)的代码并不会改变,解析一遍就够了。
在解析的过程中,还会为函数生成预编译代码。在预编译时,会统计声明了哪些变量、创建了哪些函数,并对函数的代码进行压缩,去除注释、不必要的空白等。这样做的好处就是每次执行函数时都可以直接为该函数分配栈空间(不需要再解析一遍去获取代码中声明了哪些变量,创建了哪些函数),并且因为代码压缩的原因,代码执行也更快了。 (2)容错性更好 变量提升可以在一定程度上提高JS的容错性,看下面的代码:
a = 1;
var a;
console.log(a);
如果没有变量提升,这两行代码就会报错,但是因为有了变量提升,这段代码就可以正常执行。
虽然,在可以开发过程中,可以完全避免这样写,但是有时代码很复杂的时候。可能因为疏忽而先使用后定义了,这样也不会影响正常使用。由于变量提升的存在,而会正常运行。
总结:
- 解析和预编译过程中的声明提升可以提高性能,让函数可以在执行时预先为变量分配栈空间
- 声明提升还可以提高JS代码的容错性,使一些不规范的代码也可以正常执行
变量提升虽然有一些优点,但是他也会造成一定的问题,在ES6中提出了let、const来定义变量,它们就没有变量提升的机制。 下面来看一下变量提升可能会导致的问题:
var tmp = new Date();
function fn(){
console.log(tmp);
if(false){
var tmp = 'hello world';
}
}
fn(); // undefined
在这个函数中,原本是要打印出外层的tmp变量,但是因为变量提升的问题,内层定义的tmp被提到函数内部的最顶部,相当于覆盖了外层的tmp,所以打印结果为undefined。
var tmp = 'hello world';
for (var i = 0; i < tmp.length; i++) {
console.log(tmp[i]);
}
console.log(i); // 11
由于遍历时定义的i会变量提升成为一个全局变量,在函数结束之后不会被销毁,所以打印出来11。
9. typeof NaN 的结果是什么?
NaN 意指“不是一个数字”(not a number),NaN 是一个“警戒值”(sentinel value,有特殊用途的常规值),用于指出 数字类型中的错误情况,即“执行数学运算没有成功,这是失败后返回的结果”。
typeof NaN; // "number"
复制代码
NaN 是一个特殊值,它和自身不相等,是唯一一个非自反(自反,reflexive,即 x === x 不成立)的值。而 NaN != NaN 为 true。
10. isNaN 和 Number.isNaN 函数的区别?
- 函数 isNaN 接收参数后,会尝试将这个参数转换为数值,任何不能被转换为数值的的值都会返回 true,因此非数字值传入也会返回 true ,会影响 NaN 的判断。
- 函数 Number.isNaN 会首先判断传入参数是否为数字,如果是数字再继续判断是否为 NaN ,这种方法对于 NaN 的判断更为准确。
11. == 操作符的强制类型转换规则?
- 字符串和数字之间的相等比较,将字符串转换为数字之后再进行比较。
- 其他类型和布尔类型之间的相等比较,先将布尔值转换为数字后,再应用其他规则进行比较。
- null 和 undefined 之间的相等比较,结果为真。其他值和它们进行比较都返回假值。
- 对象和非对象之间的相等比较,对象先调用 ToPrimitive 抽象操作后,再进行比较。
- 如果一个操作值为 NaN ,则相等比较返回 false( NaN 本身也不等于 NaN )。
- 如果两个操作值都是对象,则比较它们是不是指向同一个对象。如果两个操作数都指向同一个对象,则相等操作符返回true,否则,返回 false。
12. 其他值到字符串的转换规则?
- Null 和 Undefined 类型 ,null 转换为 “null”,undefined 转换为 “undefined”,
- Boolean 类型,true 转换为 “true”,false 转换为 “false”。
- Number 类型的值直接转换,不过那些极小和极大的数字会使用指数形式。
- Symbol 类型的值直接转换,但是只允许显式强制类型转换,使用隐式强制类型转换会产生错误。
- 对普通对象来说,除非自行定义 toString() 方法,否则会调用 toString()(Object.prototype.toString())来返回内部属性 [[Class]] 的值,如”[object Object]”。如果对象有自己的 toString() 方法,字符串化时就会调用该方法并使用其返回值。
13. 其他值到数字值的转换规则?
- Undefined 类型的值转换为 NaN。
- Null 类型的值转换为 0。
- Boolean 类型的值,true 转换为 1,false 转换为 0。
- String 类型的值转换如同使用 Number() 函数进行转换,如果包含非数字值则转换为 NaN,空字符串为 0。
- Symbol 类型的值不能转换为数字,会报错。
- 对象(包括数组)会首先被转换为相应的基本类型值,如果返回的是非数字的基本类型值,则再遵循以上规则将其强制转换为数字。
为了将值转换为相应的基本类型值,抽象操作 ToPrimitive 会首先(通过内部操作 DefaultValue)检查该值是否有valueOf()方法。如果有并且返回基本类型值,就使用该值进行强制类型转换。如果没有就使用 toString() 的返回值(如果存在)来进行强制类型转换。
如果 valueOf() 和 toString() 均不返回基本类型值,会产生 TypeError 错误。
14. 其他值到布尔类型的值的转换规则?
以下这些是假值: • undefined • null • false • +0、-0 和 NaN • “”
假值的布尔强制类型转换结果为 false。从逻辑上说,假值列表以外的都应该是真值。
15. || 和 && 操作符的返回值?
|| 和 && 首先会对第一个操作数执行条件判断,如果其不是布尔值就先进行 ToBoolean 强制类型转换,然后再执行条件 判断。
- 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数的值,如果为 false 就返回第二个操作数的值。
- && 则相反,如果条件判断结果为 true 就返回第二个操作数的值,如果为 false 就返回第一个操作数的值。
|| 和 && 返回它们其中一个操作数的值,而非条件判断的结果
16. Object.is() 与比较操作符 ===
、==
的区别?
- 使用双等号进行相等判断时,如果两边的类型不一致,则会进行强制类型转化后再进行比较。
- 使用三等号进行相等判断时,如果两边的类型不一致时,不会做强制类型准换,直接返回 false。
- 使用 Object.is 来进行相等判断时,一般情况下和三等号的判断相同,它处理了一些特殊的情况,比如 -0 和 +0 不再相等,两个 NaN 认定为是相等的。
17. 什么是 JavaScript 中的包装类型?
在 JavaScript 中,基本类型是没有属性和方法的,但是为了便于操作基本类型的值,在调用基本类型的属性或方法时 JavaScript 会在后台隐式地将基本类型的值转换为对象,如:
const a = "abc";
a.length; // 3
a.toUpperCase(); // "ABC"
在访问'abc'.length
时,JavaScript 将'abc'
在后台转换成String('abc')
,然后再访问其length
属性。
JavaScript也可以使用Object
函数显式地将基本类型转换为包装类型:
var a = 'abc'
Object(a) // String {"abc"}
也可以使用valueOf
方法将包装类型倒转成基本类型:
var a = 'abc'
var b = Object(a)
var c = b.valueOf() // 'abc'
看看如下代码会打印出什么:
var a = new Boolean( false );
if (!a) {
console.log( "Oops" ); // never runs
}
答案是什么都不会打印,因为虽然包裹的基本类型是false
,但是false
被包裹成包装类型后就成了对象,所以其非值为false
,所以循环体中的内容不会运行。
18. JavaScript 中如何进行隐式类型转换?
首先要介绍ToPrimitive
方法,这是 JavaScript 中每个值隐含的自带的方法,用来将值 (无论是基本类型值还是对象)转换为基本类型值。如果值为基本类型,则直接返回值本身;如果值为对象,其看起来大概是这样:
/**
* @obj 需要转换的对象
* @type 期望的结果类型
*/
ToPrimitive(obj,type)
type
的值为number
或者string
。 (1)当type
为number
时规则如下:
- 调用
obj
的valueOf
方法,如果为原始值,则返回,否则下一步; - 调用
obj
的toString
方法,后续同上; - 抛出
TypeError
异常。
(2)当type
为string
时规则如下:
- 调用
obj
的toString
方法,如果为原始值,则返回,否则下一步; - 调用
obj
的valueOf
方法,后续同上; - 抛出
TypeError
异常。
可以看出两者的主要区别在于调用toString
和valueOf
的先后顺序。默认情况下:
- 如果对象为 Date 对象,则
type
默认为string
; - 其他情况下,
type
默认为number
。
总结上面的规则,对于 Date 以外的对象,转换为基本类型的大概规则可以概括为一个函数:
var objToNumber = value => Number(value.valueOf().toString())
objToNumber([]) === 0
objToNumber({}) === NaN
而 JavaScript 中的隐式类型转换主要发生在+、-、*、/
以及==、>、<
这些运算符之间。而这些运算符只能操作基本类型值,所以在进行这些运算前的第一步就是将两边的值用ToPrimitive
转换成基本类型,再进行操作。
以下是基本类型的值在不同操作符的情况下隐式转换的规则 (对于对象,其会被ToPrimitive
转换成基本类型,所以最终还是要应用基本类型转换规则):
+
操作符 当+
操作符的两边有至少一个string
类型变量时,两边的变量都会被隐式转换为字符串;其他情况下两边的变量都会被转换为数字。
1 + '23' // '123'
1 + false // 1
1 + Symbol() // Uncaught TypeError: Cannot convert a Symbol value to a number
'1' + false // '1false'
false + true // 1
-
、*
、\
操作符 这三个操作符是为数字操作而设计的,所以操作符两边的变量都会被转换成数字,注意NaN
也是一个数字
1 * '23' // 23
1 * false // 0
1 / 'aa' // NaN
- 对于
==
操作符
操作符两边的值都尽量转成number
:
3 == true // false, 3 转为number为3,true转为number为1
'0' == false //true, '0'转为number为0,false转为number为0
'0' == 0 // '0'转为number为0
- 对于
<
和>
比较符
如果两边都是字符串,则比较字母表顺序:
'ca' < 'bd' // false
'a' < 'b' // true
其他情况下,转换为数字再比较:
'12' < 13 // true
false > -1 // true
以上说的是基本类型的隐式转换,而对象会被ToPrimitive
转换为基本类型再进行转换:
var a = {}
a > 2 // false
其对比过程如下:
a.valueOf() // {}, 上面提到过,ToPrimitive默认type为number,所以先valueOf,结果还是个对象,下一步
a.toString() // "[object Object]",现在是一个字符串了
Number(a.toString()) // NaN,根据上面 < 和 > 操作符的规则,要转换成数字
NaN > 2 //false,得出比较结果
又比如:
var a = {name:'Jack'}
var b = {age: 18}
a + b // "[object Object][object Object]"
运算过程如下:
a.valueOf() // {},上面提到过,ToPrimitive默认type为number,所以先valueOf,结果还是个对象,下一步
a.toString() // "[object Object]"
b.valueOf() // 同理
b.toString() // "[object Object]"
a + b // "[object Object][object Object]"
19. +
操作符什么时候用于字符串的拼接?
根据 ES5 规范 11.6.1 节,如果某个操作数是字符串或者能够通过以下步骤转换为字符串的话,+ 将进行拼接操作。如果其中一个操作数是对象(包括数组),则首先对其调用 ToPrimitive 抽象操作,该抽象操作再调用 [[DefaultValue]],以数字作为上下文。如果不能转换为字符串,则会将其转换为数字类型来进行计算。
简单来说就是,如果 + 的其中一个操作数是字符串(或者通过以上步骤最终得到字符串),则执行字符串拼接,否则执行数字加法。
那么对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字。
二、ES6
1. let、const、var的区别
(1)块级作用域 块作用域由 { }
包括,let和const具有块级作用域,var不存在块级作用域。 块级作用域解决了ES5中的两个问题:
- 内层变量可能覆盖外层变量
- 用来计数的循环变量泄露为全局变量
(2)变量提升 var存在变量提升,let和const不存在变量提升,即在变量只能在声明之后使用,否在会报错。 (3)给全局添加属性 浏览器的全局对对象是window,Node的全局对象是global。var声明的变量为全局变量,同时会将该变量添加为全局对象的属性,但是let和const就不会。 (4)重复声明 var声明变量时,可以重复声明变量,const和let不能重复声明。 (5)暂时性死区 在代码块内,使用let、const命令声明变量之前,该变量都是不可用的。这在语法上,称为暂时性死区。 (6)初始值设置 在变量声明时,var 和 let 可以不用设置初始值。而const声明变量必须设置初始值。 (7)指针指向 let和const都是ES6新增的用于创建变量的语法。 let创建的变量是可以更改指针指向(可以重新赋值)。但const声明的变量是不允许改变指针的指向。
总结:
区别 | var | let | const |
---|---|---|---|
是否有块级作用域 | × | ✔️ | ✔️ |
是否存在变量提升 | ✔️ | × | × |
是否添加全局属性 | ✔️ | × | × |
能否重复声明变量 | ✔️ | × | × |
是否存在暂时性死区 | × | ✔️ | ✔️ |
是否必须设置初始值 | × | × | ✔️ |
能否改变指针指向 | ✔️ | × |
2. const对象的属性可以修改吗
const保证的并不是变量的值不得改动,而是变量指向的那个内存地址不能改动。对于基本类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。
但对于引用类型的数据(主要是对象和数组),变量指向数据的内存地址,保存的只是一个指针,const只能保证这个指针是固定不变的,至于它指向的数据结构是不是可变的,就完全不能控制了。
3. 如果new一个箭头函数的会怎么样
箭头函数是ES6中的提出来的,它没有prototype,也没有自己的this指向,更不可以使用arguments参数,所以不能New一个箭头函数。 new操作符的实现步骤如下:
- 创建一个对象
- 将构造函数的作用域赋给新对象(也就是将对象的__proto__属性指向构造函数的prototype属性)
- 指向构造函数中的代码,构造函数中的this指向该对象(也就是为这个对象添加属性和方法)
- 返回新的对象
4. 箭头函数与普通函数的区别
(1)箭头函数比普通函数更加简洁
- 如果没有参数,就直接写一个空括号即可
- 如果只有一个参数,可以省去参数的括号
- 如果有多个参数,用逗号分割
- 如果函数体的返回值只有一句,可以省略大括号
- 如果函数体不需要返回值,且只有一句话,可以给这个语句前面加一个void关键字。最常见的就是调用一个函数:
let fn = () => void doesNotReturn();
(2)箭头函数没有自己的this 箭头函数不会创建自己的this, 所以它没有自己的this,它只会在自己作用域的上一层继承this。所以箭头函数中this的指向在它在定义时已经确定了,之后不会改变。 (3)箭头函数继承来的this指向永远不会改变
var id = 'GLOBAL';
var obj = {
id: 'OBJ',
a: function(){
console.log(this.id);
},
b: () => {
console.log(this.id);
}
};
obj.a(); // 'OBJ'
obj.b(); // 'GLOBAL'
new obj.a() // 报错,不能作为构造函数
new obj.b()
对象obj的方法b是使用箭头函数定义的,这个函数中的this就永远指向它定义时所处的全局执行环境中的this,即便这个函数是作为对象obj的方法调用,this依旧指向Window对象。 这里需要注意,定义对象的大括号{}
是无法形成一个单独的执行环境的,它依旧是处于全局执行环境中。 (4)call()、apply()、bind()等方法不能改变箭头函数中this的指向
var id = 'Global';
let fun1 = () => {
console.log(this.id)
};
fun1(); // 'Global'
fun1.call({id: 'Obj'}); // 'Global'
fun1.apply({id: 'Obj'}); // 'Global'
fun1.bind({id: 'Obj'})(); // 'Global'
(5)箭头函数不能作为构造函数使用 构造函数在new的步骤在上面已经说过了,实际上第二步就是将函数中的this指向该对象。 但是由于箭头函数时没有自己的this的,且this指向外层的执行环境,且不能改变指向,所以不能当做构造函数使用。 (6)箭头函数没有自己的arguments 箭头函数没有自己的arguments对象。在箭头函数中访问arguments实际上获得的是它外层函数的arguments值。 (7)箭头函数没有prototype (8)箭头函数不能用作Generator函数,不能使用yeild关键字
5. 箭头函数的this指向哪⾥?
箭头函数不同于传统JavaScript中的函数,箭头函数并没有属于⾃⼰的this,它的所谓的this是捕获其所在上下⽂的 this 值,作为⾃⼰的 this 值,并且由于没有属于⾃⼰的this,⽽箭头函数是不会被new调⽤的,这个所谓的this也不会被改变.
可以⽤Babel理解⼀下箭头函数:
// ES6
const obj = {
getArrow() {
return () => {
console.log(this === obj);
};
}
}
转化后:
// ES5,由 Babel 转译
var obj = {
getArrow: function getArrow() {
var _this = this;
return function () {
console.log(_this === obj);
};
}
};
6. 扩展运算符的作用及使用场景
(1)对象扩展运算符 对象的扩展运算符(…)用于取出参数对象中的所有可遍历属性,拷贝到当前对象之中。
let bar = { a: 1, b: 2 };
let baz = { ...bar }; // { a: 1, b: 2 }
上述方法实际上等价于:
let bar = { a: 1, b: 2 };
let baz = Object.assign({}, bar); // { a: 1, b: 2 }
Object.assign
方法用于对象的合并,将源对象(source)
的所有可枚举属性,复制到目标对象(target)
。 Object.assign
方法的第一个参数是目标对象,后面的参数都是源对象。(如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性)。 同样,如果用户自定义的属性,放在扩展运算符后面,则扩展运算符内部的同名属性会被覆盖掉。
let bar = {a: 1, b: 2};
let baz = {...bar, ...{a:2, b: 4}}; // {a: 2, b: 4}
利用上述特性就可以很方便的修改对象的部分属性。在redux
中的reducer
函数规定必须是一个纯函数,reducer
中的state
对象要求不能直接修改,可以通过扩展运算符把修改路径的对象都复制一遍,然后产生一个新的对象返回。
这里有点需要注意的是扩展运算符对对象实例的拷贝属于一种浅拷贝。 (2)数组扩展运算符 数组的扩展运算符可以将一个数组转为用逗号分隔的参数序列,且每次只能展开一层数组。
console.log(...[1, 2, 3])
// 1 2 3
console.log(...[1, [2, 3, 4], 5])
// 1 [2, 3, 4] 5
下面是数组的扩展运算符的应用:
- 将数组转换为参数序列
function add(x, y) {
return x + y;
}
const numbers = [1, 2];
add(...numbers) // 3
- 复制数组
const arr1 = [1, 2];
const arr2 = [...arr1];
要记住:扩展运算符(…)用于取出参数对象中的所有可遍历属性,拷贝到当前对象之中,这里参数对象是个数组,数组里面的所有对象都是基础数据类型,将所有基础数据类型重新拷贝到新的数组中。
- 合并数组
如果想在数组内合并数组,可以这样:
const arr1 = ['two', 'three'];
const arr2 = ['one', ...arr1, 'four', 'five'];
// ["one", "two", "three", "four", "five"]
- 扩展运算符与解构赋值结合起来,用于生成数组
const [first, ...rest] = [1, 2, 3, 4, 5];
first // 1
rest // [2, 3, 4, 5]
需要注意:
如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。
const [...rest, last] = [1, 2, 3, 4, 5];
// 报错
const [first, ...rest, last] = [1, 2, 3, 4, 5];
// 报错
- 将字符串转为真正的数组
[...'hello']
// [ "h", "e", "l", "l", "o" ]
- 任何 Iterator 接口的对象(参阅 Iterator 一章),都可以用扩展运算符转为真正的数组
比较常见的应用是可以将某些数据结构转为数组,比如:
// arguments对象
function foo() {
const args = [...arguments];
}
用于替换es5
中的Array.prototype.slice.call(arguments)
写法。
- 使用
Math
函数
const numbers = [9, 4, 7, 1];
Math.min(...numbers); // 1
Math.max(...numbers); // 9
三、JavaScript基础
1. new操作符的实现原理
new操作符的执行过程: (1)首先创建了一个新的空对象 (2)设置原型,将对象的原型设置为函数的 prototype 对象。 (3)让函数的 this 指向这个对象,执行构造函数的代码(为这个新对象添加属性) (4)判断函数的返回值类型,如果是值类型,返回创建的对象。如果是引用类型,就返回这个引用类型的对象。
具体实现:
function objectFactory() {
let newObject = null,
constructor = Array.prototype.shift.call(arguments),
result = null;
// 参数判断
if (typeof constructor !== "function") {
console.error("type error");
return;
}
// 新建一个空对象,对象的原型为构造函数的 prototype 对象
newObject = Object.create(constructor.prototype);
// 将 this 指向新建对象,并执行函数
result = constructor.apply(newObject, arguments);
// 判断返回对象
let flag = result && (typeof result === "object" || typeof result === "function");
// 判断返回结果
return flag ? result : newObject;
}
// 使用方法
objectFactory(构造函数, 初始化参数);
2. map和Object的区别
Map | Object | |
---|---|---|
意外的键 | Map默认情况不包含任何键。只包含显式插入的键。 | Object 有一个原型, 原型链上的键名有可能和你自己在对象上的设置的键名产生冲突。 |
键的类型 | Map的键可以是任意值,包括函数、对象或任意基本类型。 | Object 的键必须是 String 或是Symbol。 |
键的顺序 | Map 中的 key 是有序的。因此,当迭代的时候, Map 对象以插入的顺序返回键值。 | Object 的键是无序的 |
Size | Map 的键值对个数可以轻易地通过size 属性获取 | Object 的键值对个数只能手动计算 |
迭代 | Map 是 iterable 的,所以可以直接被迭代。 | 迭代Object需要以某种方式获取它的键然后才能迭代。 |
性能 | 在频繁增删键值对的场景下表现更好。 | 在频繁添加和删除键值对的场景下未作出优化。 |
3. map和weakMap的区别
(1)Map map本质上就是键值对的集合,但是普通的Object中的键值对中的键只能是字符串。而ES6提供的Map数据结构类似于对象,但是它的键不限制范围,可以是任意类型,是一种更加完善的Hash结构。 如果Map的键是一个原始数据类型,只要两个键严格相同,就视为是同一个键。 实际上Map是一个数组,它的每一个数据也都是一个数组,其形式如下:
const map = [
["name","张三"],
["age",18],
]
Map数据结构有以下操作方法:
- size:
map.size
返回Map结构的成员总数。 - set(key,value):设置键名key对应的键值value,然后返回整个Map结构,如果key已经有值,则键值会被更新,否则就新生成该键。(因为返回的是当前Map对象,所以可以链式调用)
- get(key):该方法读取key对应的键值,如果找不到key,返回undefined。
- has(key):该方法返回一个布尔值,表示某个键是否在当前Map对象中。
- delete(key):该方法删除某个键,返回true,如果删除失败,返回false。
- clear():map.clear()清除所有成员,没有返回值。
Map结构原生提供是三个遍历器生成函数和一个遍历方法
- keys():返回键名的遍历器。
- values():返回键值的遍历器。
- entries():返回所有成员的遍历器。
- forEach():遍历Map的所有成员。
const map = new Map([
["foo",1],
["bar",2],
])
for(let key of map.keys()){
console.log(key); // foo bar
}
for(let value of map.values()){
console.log(value); // 1 2
}
for(let items of map.entries()){
console.log(items); // ["foo",1] ["bar",2]
}
map.forEach( (value,key,map) => {
console.log(key,value); // foo 1 bar 2
})
(2)WeakMap WeakMap 对象也是一组键值对的集合,其中的键是弱引用的。其键必须是对象,原始数据类型不能作为key值,而值可以是任意的。
该对象也有以下几种方法:
- set(key,value):设置键名key对应的键值value,然后返回整个Map结构,如果key已经有值,则键值会被更新,否则就新生成该键。(因为返回的是当前Map对象,所以可以链式调用)
- get(key):该方法读取key对应的键值,如果找不到key,返回undefined。
- has(key):该方法返回一个布尔值,表示某个键是否在当前Map对象中。
- delete(key):该方法删除某个键,返回true,如果删除失败,返回false。
其clear()方法已经被弃用,所以可以通过创建一个空的WeakMap并替换原对象来实现清除。
WeakMap的设计目的在于,有时想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。一旦不再需要这两个对象,就必须手动删除这个引用,否则垃圾回收机制就不会释放对象占用的内存。
而WeakMap的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。
总结:
- Map 数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。
- WeakMap 结构与 Map 结构类似,也是用于生成键值对的集合。但是 WeakMap 只接受对象作为键名( null 除外),不接受其他类型的值作为键名。而且 WeakMap 的键名所指向的对象,不计入垃圾回收机制。
4. JavaScript有哪些内置的对象
全局的对象( global objects )或称标准内置对象,不要和 “全局对象(global object)” 混淆。这里说的全局的对象是说在 全局作用域里的对象。全局作用域中的其他对象可以由用户的脚本创建或由宿主程序提供。
标准内置对象的分类:
(1)值属性,这些全局属性返回一个简单值,这些值没有自己的属性和方法。 例如 Infinity、NaN、undefined、null 字面量
(2)函数属性,全局函数可以直接调用,不需要在调用时指定所属对象,执行结束后会将结果直接返回给调用者。 例如 eval()、parseFloat()、parseInt() 等
(3)基本对象,基本对象是定义或使用其他对象的基础。基本对象包括一般对象、函数对象和错误对象。 例如 Object、Function、Boolean、Symbol、Error 等
(4)数字和日期对象,用来表示数字、日期和执行数学计算的对象。 例如 Number、Math、Date
(5)字符串,用来表示和操作字符串的对象。 例如 String、RegExp
(6)可索引的集合对象,这些对象表示按照索引值来排序的数据集合,包括数组和类型数组,以及类数组结构的对象。例如 Array
(7)使用键的集合对象,这些集合对象在存储数据时会使用到键,支持按照插入顺序来迭代元素。 例如 Map、Set、WeakMap、WeakSet
(8)矢量集合,SIMD 矢量集合中的数据会被组织为一个数据序列。 例如 SIMD 等
(9)结构化数据,这些对象用来表示和操作结构化的缓冲区数据,或使用 JSON 编码的数据。 例如 JSON 等
(10)控制抽象对象 例如 Promise、Generator 等
(11)反射 例如 Reflect、Proxy
(12)国际化,为了支持多语言处理而加入 ECMAScript 的对象。 例如 Intl、Intl.Collator 等
(13)WebAssembly
(14)其他 例如 arguments
总结: js 中的内置对象主要指的是在程序执行前存在全局作用域里的由 js 定义的一些全局值属性、函数和用来实例化其他对象的构造函数对象。一般经常用到的如全局变量值 NaN、undefined,全局函数如 parseInt()、parseFloat() 用来实例化对象的构造函数如 Date、Object 等,还有提供数学计算的单体内置对象如 Math 对象。
5. 常用的正则表达式有哪些?
// (1)匹配 16 进制颜色值
var regex = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})/g;
// (2)匹配日期,如 yyyy-mm-dd 格式
var regex = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/;
// (3)匹配 qq 号
var regex = /^[1-9][0-9]{4,10}$/g;
// (4)手机号码正则
var regex = /^1[34578]\d{9}$/g;
// (5)用户名正则
var regex = /^[a-zA-Z\$][a-zA-Z0-9_\$]{4,16}$/;
6. 对JSON的理解
JSON 是一种基于文本的轻量级的数据交换格式。它可以被任何的编程语言读取和作为数据格式来传递。
在项目开发中,使用 JSON 作为前后端数据交换的方式。在前端通过将一个符合 JSON 格式的数据结构序列化为 JSON 字符串,然后将它传递到后端,后端通过 JSON 格式的字符串解析后生成对应的数据结构,以此来实现前后端数据的一个传递。
因为 JSON 的语法是基于 js 的,因此很容易将 JSON 和 js 中的对象弄混,但是应该注意的是 JSON 和 js 中的对象不是一回事,JSON 中对象格式更加严格,比如说在 JSON 中属性值不能为函数,不能出现 NaN 这样的属性值等,因此大多数的 js 对象是不符合 JSON 对象的格式的。
在 js 中提供了两个函数来实现 js 数据结构和 JSON 格式的转换处理,
- JSON.stringify 函数,通过传入一个符合 JSON 格式的数据结构,将其转换为一个 JSON 字符串。如果传入的数据结构不符合 JSON 格式,那么在序列化的时候会对这些值进行对应的特殊处理,使其符合规范。在前端向后端发送数据时,可以调用这个函数将数据对象转化为 JSON 格式的字符串。
- JSON.parse() 函数,这个函数用来将 JSON 格式的字符串转换为一个 js 数据结构,如果传入的字符串不是标准的 JSON 格式的字符串的话,将会抛出错误。当从后端接收到 JSON 格式的字符串时,可以通过这个方法来将其解析为一个 js 数据结构,以此来进行数据的访问。
7. JavaScript脚本延迟加载的方式有哪些?
相关知识点: js 延迟加载,也就是等页面加载完成之后再加载 JavaScript 文件。 js 延迟加载有助于提高页面加载速度。
一般有以下几种方式:
- defer 属性: 给 js 脚本添加 defer 属性,这个属性会让脚本的加载与文档的解析同步解析,然后在文档解析完成后再执行这个脚本文件,这样的话就能使页面的渲染不被阻塞。多个设置了 defer 属性的脚本按规范来说最后是顺序执行的,但是在一些浏览器中可能不是这样。
- async 属性: 给 js 脚本添加 async 属性,这个属性会使脚本异步加载,不会阻塞页面的解析过程,但是当脚本加载完成后立即执行 js 脚本,这个时候如果文档没有解析完成的话同样会阻塞。多个 async 属性的脚本的执行顺序是不可预测的,一般不会按照代码的顺序依次执行。
- 动态创建 DOM 方式: 动态创建 DOM 标签的方式,可以对文档的加载事件进行监听,当文档加载完成后再动态的创建 script 标签来引入 js 脚本。
- 使用 setTimeout 延迟方法: 设置一个定时器来延迟加载js脚本文件
- 让 JS 最后加载: 将 js 脚本放在文档的底部,来使 js 脚本尽可能的在最后来加载执行。
8. JavaScript 类数组对象的定义?
一个拥有 length 属性和若干索引属性的对象就可以被称为类数组对象,类数组对象和数组类似,但是不能调用数组的方法。 常见的类数组对象有 arguments 和 DOM 方法的返回结果,还有一个函数也可以被看作是类数组对象,因为它含有 length 属性值,代表可接收的参数个数。
常见的类数组转换为数组的方法有这样几种: (1)通过 call 调用数组的 slice 方法来实现转换
Array.prototype.slice.call(arrayLike);
(2)通过 call 调用数组的 splice 方法来实现转换
Array.prototype.splice.call(arrayLike, 0);
(3)通过 apply 调用数组的 concat 方法来实现转换
Array.prototype.concat.apply([], arrayLike);
(4)通过 Array.from 方法来实现转换
Array.from(arrayLike);
9. 数组和对象有哪些原生方法,列举一下?
- 数组和字符串的转换方法:toString()、toLocalString()、join() 其中 join() 方法可以指定转换为字符串时的分隔符。
- 数组尾部操作的方法 pop() 和 push(),push 方法可以传入多个参数。
- 数组首部操作的方法 shift() 和 unshift() 重排序的方法 reverse() 和 sort(),sort() 方法可以传入一个函数来进行比较,传入前后两个值,如果返回值为正数,则交换两个参数的位置。
- 数组连接的方法 concat() ,返回的是拼接好的数组,不影响原数组。
- 数组截取办法 slice(),用于截取数组中的一部分返回,不影响原数组。
- 数组插入方法 splice(),影响原数组查找特定项的索引的方法,indexOf() 和 lastIndexOf() 迭代方法 every()、some()、filter()、map() 和 forEach() 方法
- 数组归并方法 reduce() 和 reduceRight() 方法
10. Unicode、UTF-8、UTF-16、UTF-32有什么区别?
(1)Unicode
在说Unicode
之前需要先了解一下ASCII
码: ASCII 码(American Standard Code for Information Interchange
)称为美国标准信息交换码。
- 它是基于拉丁字母的一套电脑编码系统。
- 它定义了一个用于代表常见字符的字典。
- 它包含了”A-Z”(包含大小写),数据”0-9″ 以及一些常见的符号。
- 它是专门为英语而设计的,有128个编码,对其他语言无能为力
ASCII
码可以表示的编码有限,要想表示其他语言的编码,还是要使用Unicode
来表示,可以说Unicode
是ASCII
的超集。
Unicode
全称 Unicode Translation Format
,又叫做统一码、万国码、单一码。Unicode
是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。 Unicode的实现的方式: Unicode
的实现方式(也就是编码方式)有很多种,常见的是UTF-8、UTF-16、UTF-32和USC-2。
(2)UTF-8
UTF-8
是使用最广泛的Unicode
编码方式,它是一种可变长的编码方式,可以是1—4个字节不等,它可以完全兼容ASCII
码的128个字符。 注意: UTF-8
是一种编码方式,Unicode
是一个字符集合。 UTF-8
的编码规则:
- 对于单字节的符号,字节的第一位为0,后面的7位为这个字符的
Unicode
编码,因此对于英文字母,它的Unicode
编码和ACSII
编码一样。 - 对于n字节的符号,第一个字节的前n位都是1,第n+1位设为0,后面字节的前两位一律设为10,剩下的没有提及的二进制位,全部为这个符号的
Unicode
码 。
来看一下具体的Unicode
编号范围与对应的UTF-8
二进制格式 :
编码范围(编号对应的十进制数) | 二进制格式 |
---|---|
0x00—0x7F (0-127) | 0xxxxxxx |
0x80—0x7FF (128-2047) | 110xxxxx 10xxxxxx |
0x800—0xFFFF (2048-65535) | 1110xxxx 10xxxxxx 10xxxxxx |
0x10000—0x10FFFF (65536以上) | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
那该如何通过具体的Unicode
编码,进行具体的UTF-8
编码呢? 步骤:
- 找到该
Unicode
编码的所在的编号范围,进而找到与之对应的二进制格式 - 将
Unicode
编码转换为二进制数(去掉最高位的0) - 将二进制数从右往左一次填入二进制格式的
X
中,如果有X
未填,就设为0
来看一个实际的例子: “马” 字的Unicode
编码是:0x9A6C
,整数编号是39532
(1)首选确定了该字符在第三个范围内,它的格式是 1110xxxx 10xxxxxx 10xxxxxx
(2)39532对应的二进制数为1001 1010 0110 1100
(3)将二进制数填入X中,结果是:11101001 10101001 10101100
(3)UTF-16
1. 平面的概念 在了解UTF-16
之前,先看一下平面的概念: Unicode
编码中有很多很多的字符,它并不是一次性定义的,而是分区进行定义的,每个区存放65536(2)个字符,这称为一个平面,目前总共有17 个平面。
最前面的一个平面称为基本平面,它的码点从0 — 2-1,写成16进制就是U+0000 — U+FFFF
,那剩下的16个平面就是辅助平面,码点范围是 U+10000—U+10FFFF
。 2. UTF-16 概念: UTF-16
也是Unicode
编码集的一种编码形式,把Unicode
字符集的抽象码位映射为16位长的整数(即码元)的序列,用于数据存储或传递。Unicode
字符的码位需要1个或者2个16位长的码元来表示,因此UTF-16
也是用变长字节表示的。 3. UTF-16 编码规则:
- 编号在
U+0000—U+FFFF
的字符(常用字符集),直接用两个字节表示。 - 编号在
U+10000—U+10FFFF
之间的字符,需要用四个字节表示。
4. 编码识别 那么问题来了,当遇到两个字节时,怎么知道是把它当做一个字符还是和后面的两个字节一起当做一个字符呢?
UTF-16
编码肯定也考虑到了这个问题,在基本平面内,从 U+D800 — U+DFFF
是一个空段,也就是说这个区间的码点不对应任何的字符,因此这些空段就可以用来映射辅助平面的字符。
辅助平面共有 2 个字符位,因此表示这些字符至少需要 20 个二进制位。UTF-16
将这 20 个二进制位分成两半,前 10 位映射在 U+D800 — U+DBFF
,称为高位(H),后 10 位映射在 U+DC00 — U+DFFF
,称为低位(L)。这就相当于,将一个辅助平面的字符拆成了两个基本平面的字符来表示。
因此,当遇到两个字节时,发现它的码点在 U+D800 —U+DBFF
之间,就可以知道,它后面的两个字节的码点应该在 U+DC00 — U+DFFF
之间,这四个字节必须放在一起进行解读。 5. 举例说明 以 “?” 字为例,它的 Unicode
码点为 0x21800
,该码点超出了基本平面的范围,因此需要用四个字节来表示,步骤如下:
- 首先计算超出部分的结果:
0x21800 - 0x10000
- 将上面的计算结果转为20位的二进制数,不足20位就在前面补0,结果为:
0001000110 0000000000
- 将得到的两个10位二进制数分别对应到两个区间中
U+D800
对应的二进制数为1101100000000000
, 将0001000110
填充在它的后10 个二进制位,得到1101100001000110
,转成 16 进制数为0xD846
。同理,低位为0xDC00
,所以这个字的UTF-16
编码为0xD846 0xDC00
(4) UTF-32
UTF-32
就是字符所对应编号的整数二进制形式,每个字符占四个字节,这个是直接进行转换的。该编码方式占用的储存空间较多,所以使用较少。
比如“马” 字的Unicode编号是:U+9A6C
,整数编号是39532
,直接转化为二进制:1001 1010 0110 1100
,这就是它的UTF-32编码。
(5)总结
简单的了解完这几个概念,那就来简单回答一下最开始的那个问题: Unicode、UTF-8、UTF-16、UTF-32有什么区别?
Unicode
是编码字符集(字符集),而UTF-8
、UTF-16
、UTF-32
是字符集编码(编码规则)UTF-16
使用变长码元序列的编码方式,相较于定长码元序列的UTF-32
算法更复杂,甚至比同样是变长码元序列的UTF-8
也更为复杂,因为其引入了独特的代理对这样的代理机制UTF-8
需要判断每个字节中的开头标志信息,所以如果某个字节在传送过程中出错了,就会导致后面的字节也会解析出错;而UTF-16
不会判断开头标志,即使错也只会错一个字符,所以容错能力教强- 如果字符内容全部英文或英文与其他文字混合,但英文占绝大部分,那么用
UTF-8
就比UTF-16
节省了很多空间;而如果字符内容全部是中文这样类似的字符或者混合字符中中文占绝大多数,那么UTF-16
就占优势了,可以节省很多空间
11. 常见的位运算符有哪些?其计算规则是什么?
现代计算机中数据都是以二进制的形式存储的,即0、1两种状态,计算机对二进制数据进行的运算加减乘除等都是叫位运算,即将符号位共同参与运算的运算。
常见的位运算有以下几种:
运算符 | 描述 | 运算规则 |
---|---|---|
& |
与 | 两个位都为1时,结果才为1 |
` | ` | 或 |
^ |
异或 | 两个位相同为0,相异为1 |
~ |
取反 | 0变1,1变0 |
<< |
左移 | 各二进制位全部左移若干位,高位丢弃,低位补0 |
>> |
右移 | 各二进制位全部右移若干位,正数左补0,负数左补1,右边丢弃 |
1. 按位与运算符(&)
定义: 参加运算的两个数据按二进制位进行“与”运算。 运算规则:
0 & 0 = 0
0 & 1 = 0
1 & 0 = 0
1 & 1 = 1
总结:两位同时为1,结果才为1,否则结果为0。 例如:3&5 即:
0000 0011
0000 0101
= 0000 0001
因此 3&5 的值为1。 注意:负数按补码形式参加按位与运算。 用途: (1)判断奇偶 只要根据最未位是0还是1来决定,为0就是偶数,为1就是奇数。因此可以用if ((i & 1) == 0)
代替if (i % 2 == 0)
来判断a是不是偶数。 (2)清零 如果想将一个单元清零,即使其全部二进制位为0,只要与一个各位都为零的数值相与,结果为零。
2. 按位或运算符(|)
定义: 参加运算的两个对象按二进制位进行“或”运算。 运算规则:
0 | 0 = 0
0 | 1 = 1
1 | 0 = 1
1 | 1 = 1
总结:参加运算的两个对象只要有一个为1,其值为1。 例如:3|5即:
0000 0011
0000 0101
= 0000 0111
因此,3|5的值为7。 注意:负数按补码形式参加按位或运算。
3. 异或运算符(^)
定义: 参加运算的两个数据按二进制位进行“异或”运算。 运算规则:
0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0
总结:参加运算的两个对象,如果两个相应位相同为0,相异为1。 例如:3|5即:
0000 0011
0000 0101
= 0000 0110
因此,3^5的值为6。 异或运算的性质:
- 交换律:
(a^b)^c == a^(b^c)
- 结合律:
(a + b)^c == a^b + b^c
- 对于任何数x,都有
x^x=0,x^0=x
- 自反性:
a^b^b=a^0=a
;
4. 取反运算符 (~)
定义: 参加运算的一个数据按二进制进行“取反”运算。 运算规则:
~ 1 = 0
~ 0 = 1
总结:对一个二进制数按位取反,即将0变1,1变0。 例如:~6 即:
0000 0110
= 1111 1001
在计算机中,正数用原码表示,负数使用补码存储,首先看最高位,最高位1表示负数,0表示正数。此计算机二进制码为负数,最高位为符号位。 当发现按位取反为负数时,就直接取其补码,变为十进制:
0000 0110
= 1111 1001
反码:1000 0110
补码:1000 0111
因此,~6的值为-7。
5. 左移运算符(<<)
定义: 将一个运算对象的各二进制位全部左移若干位,左边的二进制位丢弃,右边补0。 设 a=1010 1110,a = a<< 2 将a的二进制位左移2位、右补0,即得a=1011 1000。 若左移时舍弃的高位不包含1,则每左移一位,相当于该数乘以2。
6. 右移运算符(>>)
定义: 将一个数的各二进制位全部右移若干位,正数左补0,负数左补1,右边丢弃。 例如:a=a>>2 将a的二进制位右移2位,左补0 或者 左补1得看被移数是正还是负。 操作数每右移一位,相当于该数除以2。
7. 原码、补码、反码
上面提到了补码、反码等知识,这里就补充一下。 计算机中的有符号数有三种表示方法,即原码、反码和补码。三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”,而数值位,三种表示方法各不相同。 (1)原码 原码就是一个数的二进制数。 例如:10的原码为0000 1010 (2)反码
- 正数的反码与原码相同,如:10 反码为 0000 1010
- 负数的反码为除符号位,按位取反,即0变1,1变0。
例如:-10
原码:1000 1010
反码:1111 0101
(3)补码
- 正数的补码与原码相同,如:10 补码为 0000 1010
- 负数的补码是原码除符号位外的所有位取反即0变1,1变0,然后加1,也就是反码加1。
例如:-10
原码:1000 1010
反码:1111 0101
补码:1111 0110
12. 为什么函数的 arguments 参数是类数组而不是数组?如何遍历类数组?
因为arguments
是一个对象,它的属性是从 0 开始依次递增的数字,还有callee
和length
等属性,与数组非常相似;但是它们却没有数组常见的方法属性,如forEach
, reduce
等,所以叫它们类数组。
要遍历类数组,有三个方法: (1)将数组的方法应用到类数组上,这时候就可以使用call
和apply
方法,如:
function foo(){
Array.prototype.forEach.call(arguments, a => console.log(a))
}
(2)使用Array.from方法将类数组转化成数组:
function foo(){
const arrArgs = Array.from(arguments)
arrArgs.forEach(a => console.log(a))
}
(3)使用展开运算符将类数组转化成数组
function foo(){
const arrArgs = [...arguments]
arrArgs.forEach(a => console.log(a))
}
13. 什么是 DOM 和 BOM?
- DOM 指的是文档对象模型,它指的是把文档当做一个对象来对待,这个对象主要定义了处理网页内容的方法和接口。
- BOM 指的是浏览器对象模型,它指的是把浏览器当做一个对象来对待,这个对象主要定义了与浏览器进行交互的法和接口。BOM的核心是 window,而 window 对象具有双重角色,它既是通过 js 访问浏览器窗口的一个接口,又是一个 Global(全局)对象。这意味着在网页中定义的任何对象,变量和函数,都作为全局对象的一个属性或者方法存在。window 对象含有 location 对象、navigator 对象、screen 对象等子对象,并且 DOM 的最根本的对象 document 对象也是 BOM 的 window 对象的子对象。
14. 对类数组对象的理解,如何转化为数组
一个拥有 length 属性和若干索引属性的对象就可以被称为类数组对象,类数组对象和数组类似,但是不能调用数组的方法。
常见的类数组对象有 arguments 和 DOM 方法的返回结果,还有一个函数也可以被看作是类数组对象,因为它含有 length属性值,代表可接收的参数个数。
常见的类数组转换为数组的方法有这样几种:
- 通过 call 调用数组的 slice 方法来实现转换
Array.prototype.slice.call(arrayLike);
- 通过 call 调用数组的 splice 方法来实现转换
Array.prototype.splice.call(arrayLike, 0);
复制代码
- 通过 apply 调用数组的 concat 方法来实现转换
Array.prototype.concat.apply([], arrayLike);
- 通过 Array.from 方法来实现转换
Array.from(arrayLike);
15. escape,encodeURI,encodeURIComponent 的区别?
encodeURI 是对整个 URI 进行转义,将 URI 中的非法字符转换为合法字符,所以对于一些在 URI 中有特殊意义的字符不会进行转义。
encodeURIComponent 是对 URI 的组成部分进行转义,所以一些特殊字符也会得到转义。 escape 和 encodeURI 的作用相同,不过它们对于 unicode 编码为 0xff 之外字符的时候会有区别,escape 是直接在字符的 unicode 编码前加上 %u,而 encodeURI 首先会将字符转换为 UTF-8 的格式,再在每个字节前加上 %。
16. 对AJAX的理解,实现一个AJAX请求
AJAX是 Asynchronous JavaScript and XML 的缩写,指的是通过 JavaScript 的 异步通信,从服务器获取 XML 文档从中提取数据,再更新当前网页的对应部分,而不用刷新整个网页。
创建AJAX请求的步骤:
- 创建一个 XMLHttpRequest 对象。
- 在这个对象上使用 open 方法创建一个 HTTP 请求,open 方法所需要的参数是请求的方法、请求的地址、是否异步和用户的认证信息。
- 在发起请求前,可以为这个对象添加一些信息和监听函数。比如说可以通过 setRequestHeader 方法来为请求添加头信息。还可以为这个对象添加一个状态监听函数。一个 XMLHttpRequest 对象一共有 5 个状态,当它的状态变化时会触发onreadystatechange 事件,可以通过设置监听函数,来处理请求成功后的结果。当对象的 readyState 变为 4 的时候,代表服务器返回的数据接收完成,这个时候可以通过判断请求的状态,如果状态是 2xx 或者 304 的话则代表返回正常。这个时候就可以通过 response 中的数据来对页面进行更新了。
- 当对象的属性和监听函数设置完成后,最后调用 sent 方法来向服务器发起请求,可以传入参数作为发送的数据体。
const SERVER_URL = "/server";
let xhr = new XMLHttpRequest();
// 创建 Http 请求
xhr.open("GET", SERVER_URL, true);
// 设置状态监听函数
xhr.onreadystatechange = function() {
if (this.readyState !== 4) return;
// 当请求成功时
if (this.status === 200) {
handle(this.response);
} else {
console.error(this.statusText);
}
};
// 设置请求失败时的监听函数
xhr.onerror = function() {
console.error(this.statusText);
};
// 设置请求头信息
xhr.responseType = "json";
xhr.setRequestHeader("Accept", "application/json");
// 发送 Http 请求
xhr.send(null);
使用Promise封装AJAX:
// promise 封装实现:
function getJSON(url) {
// 创建一个 promise 对象
let promise = new Promise(function(resolve, reject) {
let xhr = new XMLHttpRequest();
// 新建一个 http 请求
xhr.open("GET", url, true);
// 设置状态的监听函数
xhr.onreadystatechange = function() {
if (this.readyState !== 4) return;
// 当请求成功或失败时,改变 promise 的状态
if (this.status === 200) {
resolve(this.response);
} else {
reject(new Error(this.statusText));
}
};
// 设置错误监听函数
xhr.onerror = function() {
reject(new Error(this.statusText));
};
// 设置响应的数据类型
xhr.responseType = "json";
// 设置请求头信息
xhr.setRequestHeader("Accept", "application/json");
// 发送 http 请求
xhr.send(null);
});
return promise;
}
四、事件机制
1. 事件是什么?事件模型?
事件是用户操作网页时发生的交互动作,比如 click/move, 事件除了用户触发的动作外,还可以是文档加载,窗口滚动和大小调整。事件被封装成一个 event 对象,包含了该事件发生时的所有相关信息( event 的属性)以及可以对事件进行的操作( event 的方法)。
事件是用户操作网页时发生的交互动作或者网页本身的一些操作,现代浏览器一共有三种事件模型:
- DOM0 级事件模型,这种模型不会传播,所以没有事件流的概念,但是现在有的浏览器支持以冒泡的方式实现,它可以在网页中直接定义监听函数,也可以通过 js 属性来指定监听函数。这种方式是所有浏览器都兼容的。
- IE 事件模型,在该事件模型中,一次事件共有两个过程,事件处理阶段,和事件冒泡阶段。事件处理阶段会首先执行目标元素绑定的监听事件。然后是事件冒泡阶段,冒泡指的是事件从目标元素冒泡到 document,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。这种模型通过attachEvent 来添加监听函数,可以添加多个监听函数,会按顺序依次执行。
- DOM2 级事件模型,在该事件模型中,一次事件共有三个过程,第一个过程是事件捕获阶段。捕获指的是事件从 document 一直向下传播到目标元素,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。后面两个阶段和 IE 事件模型的两个阶段相同。这种事件模型,事件绑定的函数是addEventListener,其中第三个参数可以指定事件是否在捕获阶段执行。
2. 如何阻止事件冒泡
- 普通浏览器使用:event.stopPropagation()
- IE浏览器使用:event.cancelBubble = true;
3. 对事件委托的理解
(1)事件委托的概念
事件委托本质上是利用了浏览器事件冒泡的机制。因为事件在冒泡过程中会上传到父节点,并且父节点可以通过事件对象获取到目标节点,因此可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件,这种方式称为事件代理。
使用事件代理可以不必要为每一个子元素都绑定一个监听事件,这样减少了内存上的消耗。并且使用事件代理还可以实现事件的动态绑定,比如说新增了一个子节点,并不需要单独地为它添加一个监听事件,它所发生的事件会交给父元素中的监听函数来处理。
(2)事件委托的特点
- 减少内存消耗
试想一下,如果有一个列表,列表之中有大量的列表项,需要在点击列表项的时候响应一个事件;
<ul id="list">
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
......
<li>item n</li>
</ul>
// ...... 代表中间还有未知数个 li
如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的,效率上需要消耗很多性能; 因此,比较好的方法就是把这个点击事件绑定到他的父层,也就是 ul
上,然后在执行事件的时候再去匹配判断目标元素; 所以事件委托可以减少大量的内存消耗,节约效率。
- 动态绑定事件
比如上述的例子中列表项就几个,给每个列表项都绑定了事件;在很多时候,需要通过 AJAX 或者用户操作动态的增加或者去除列表项元素,那么在每一次改变的时候都需要重新给新增的元素绑定事件,给即将删去的元素解绑事件;如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配的;所以使用事件在动态绑定事件的情况下是可以减少很多重复工作的。
// 来实现把 #list 下的 li 元素的事件代理委托到它的父层元素也就是 #list 上:
// 给父层元素绑定事件
document.getElementById('list').addEventListener('click', function (e) {
// 兼容性处理
var event = e || window.event;
var target = event.target || event.srcElement;
// 判断是否匹配目标元素
if (target.nodeName.toLocaleLowerCase === 'li') {
console.log('the content is: ', target.innerHTML);
}
});
在上述代码中, target 元素则是在 #list 元素之下具体被点击的元素,然后通过判断 target 的一些属性(比如:nodeName,id 等等)可以更精确地匹配到某一类 #list li 元素之上;
(3)局限性
当然,事件委托也是有一定局限性的; 比如 focus、blur 之类的事件本身没有事件冒泡机制,所以无法委托; mousemove、mouseout 这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的;
当然事件委托不是只有优点,它也是有缺点的,事件委托会影响页面性能,主要影响因素有:
- 元素中,绑定事件委托的次数;
- 点击的最底层元素,到绑定事件元素之间的
DOM
层数;
所以,在必须使用事件委托的地方,可以如下的处理:
- 只在必须的地方,使用事件委托,比如:
ajax
的局部刷新区域 - 尽量的减少绑定的层级,不在
body
元素上,进行绑定 - 减少绑定的次数,如果可以,那么把多个事件的绑定,合并到一次事件委托中去,由这个事件委托的回调,来进行分发。
4. 事件委托的使用场景
有一个场景就是要给页面的所有的a标签添加click事件,代码如下:
document.addEventListener("click", function(e) {
if (e.target.nodeName == "A")
console.log("a");
}, false);
但是这些a标签可能包含一些像span、img等元素,如果点击到了这些a标签中的元素,就不会触发click事件,因为事件绑定上在a标签元素上,而触发这些内部的元素时,e.target指向的是触发click事件的元素(span、img等其他元素)。
这种情况下就可以使用事件委托来处理,将事件绑定在a标签的内部元素上,当点击它的时候,就会逐级向上查找,知道找到a标签为止,代码如下:
document.addEventListener("click", function(e) {
var node = e.target;
while (node.parentNode.nodeName != "BODY") {
if (node.nodeName == "A") {
console.log("a");
break;
}
node = node.parentNode;
}
}, false);
5. 同步和异步的区别
- 同步指的是当一个进程在执行某个请求的时候,如果这个请求需要等待一段时间才能返回,那么这个进程会一直等待下去,直到消息返回为止再继续向下执行。
- 异步指的是当一个进程在执行某个请求的时候,如果这个请求需要等待一段时间才能返回,这个时候进程会继续往下执行,不会阻塞等待消息的返回,当消息返回时系统再通知进程进行处理。
6. 对事件循环的理解
因为 js 是单线程运行的,在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当异步事件执行完毕后,再将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行。任务队列可以分为宏任务对列和微任务对列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。
7. 宏任务和微任务分别有哪些
- 微任务包括: promise 的回调、node 中的 process.nextTick 、对 Dom 变化监听的 MutationObserver。
- 宏任务包括: script 脚本的执行、setTimeout ,setInterval ,setImmediate 一类的定时事件,还有如 I/O 操作、UI 渲染等。
五、原型与原型链
1. 对原型、原型链的理解
在JavaScript中使用构造函数来新建一个对象的,每一个构造函数的内部都有一个 prototype 属性值,这个属性值是一个对象,这个对象包含了可以由该构造函数的所有实例共享的属性和方法。当使用构造函数新建一个对象后,在这个对象的内部将包含一个指针,这个指针指向构造函数的 prototype 属性对应的值,在 ES5 中这个指针被称为对象的原型。一般来说不应该能够获取到这个值的,但是现在浏览器中都实现了 proto 属性来访问这个属性,但是最好不要使用这个属性,因为它不是规范中规定的。ES5 中新增了一个 Object.getPrototypeOf() 方法,可以通过这个方法来获取对象的原型。
当访问一个对象的属性时,如果这个对象内部不存在这个属性,那么它就会去它的原型对象里找这个属性,这个原型对象又会有自己的原型,于是就这样一直找下去,也就是原型链的概念。原型链的尽头一般来说都是 Object.prototype 所以这就是新建的对象为什么能够使用 toString() 等方法的原因。
特点: JavaScript 对象是通过引用来传递的,创建的每个新对象实体中并没有一份属于自己的原型副本。当修改原型时,与之相关的对象也会继承这一改变。
2. 原型修改、重写
function Person(name) {
this.name = name
}
// 修改原型
Person.prototype.getName = function() {}
var p = new Person('hello')
console.log(p.__proto__ === Person.prototype) // true
console.log(p.__proto__ === p.constructor.prototype) // true
// 重写原型
Person.prototype = {
getName: function() {}
}
var p = new Person('hello')
console.log(p.__proto__ === Person.prototype) // true
console.log(p.__proto__ === p.constructor.prototype) // false
可以看到修改原型的时候p的构造函数不是指向Person了,因为直接给Person的原型对象直接用对象赋值时,它的构造函数指向的了根构造函数Object,所以这时候p.constructor === Object
,而不是p.constructor === Person
。要想成立,就要用constructor指回来:
Person.prototype = {
getName: function() {}
}
var p = new Person('hello')
p.constructor = Person
console.log(p.__proto__ === Person.prototype) // true
console.log(p.__proto__ === p.constructor.prototype) // true
3. 原型链指向
p.__proto__ // Person.prototype
Person.prototype.__proto__ // Object.prototype
p.__proto__.__proto__ //Object.prototype
p.__proto__.constructor.prototype.__proto__ // Object.prototype
Person.prototype.constructor.prototype.__proto__ // Object.prototype
p1.__proto__.constructor // Person
Person.prototype.constructor // Person
4. 原型链的终点是什么?如何打印出原型链的终点?
由于Object
是构造函数,原型链终点是Object.prototype.__proto__
,而Object.prototype.__proto__=== null // true
,所以,原型链的终点是null
原型链上的所有原型都是对象,所有的对象最终都是由Object
构造的,而Object.prototype
的下一级是Object.prototype.__proto__
。
5. 如何获得对象非原型链上的属性?
使用后hasOwnProperty()
方法来判断属性是否属于原型链的属性:
function iterate(obj){
var res=[];
for(var key in obj){
if(obj.hasOwnProperty(key))
res.push(key+': '+obj[key]);
}
return res;
}
6. 下面代码的输出结果
function Person(name) {
this.name = name
}
var p2 = new Person('king');
console.log(p2.__proto__) //Person.prototype
console.log(p2.__proto__.__proto__) //Object.prototype
console.log(p2.__proto__.__proto__.__proto__) // null
console.log(p2.__proto__.__proto__.__proto__.__proto__)//null后面没有了,报错
console.log(p2.__proto__.__proto__.__proto__.__proto__.__proto__)//null后面没有了,报错
console.log(p2.constructor)//Person
console.log(p2.prototype)//undefined p2是实例,没有prototype属性
console.log(Person.constructor)//Function 一个空函数
console.log(Person.prototype)//打印出Person.prototype这个对象里所有的方法和属性
console.log(Person.prototype.constructor)//Person
console.log(Person.prototype.__proto__)// Object.prototype
console.log(Person.__proto__) //Function.prototype
console.log(Function.prototype.__proto__)//Object.prototype
console.log(Function.__proto__)//Function.prototype
console.log(Object.__proto__)//Function.prototype
console.log(Object.prototype.__proto__)//null
7. 下面代码的输出结果
function Dog() {
this.name = 'puppy'
}
Dog.prototype.bark = () => {
console.log('woof!woof!')
}
const dog = new Dog()
console.log(Dog.prototype.constructor === Dog && dog.constructor === Dog && dog instanceof Dog)
打印出:true
因为constructor是prototype上的属性,所以dog.constructor实际上就是指向Dog.prototype.constructor;constructor属性指向构造函数。instanceof而实际检测的是类型是否在实例的原型链上。
constructor是prototype上的属性,这一点很容易被忽略掉。constructor和instanceof 的作用是不同的,感性地来说,constructor的限制比较严格,它只能严格对比对象的构造函数是不是指定的值;而instanceof比较松散,只要检测的类型在原型链上,就会返回true。
8. 下面代码的输出结果
function Dog() {
this.name = 'puppy'
}
Dog.prototype.bark = () => {
console.log('woof!woof!')
}
function BigDog() {}
BigDog.prototype = new Dog()
const bigDog = new BigDog()
console.log(bigDog.constructor === BigDog)
打印出:false
因为bigDog的原型是Dog的实例,所以访问bigDog.constructor时实际访问的是Dog.prototype.constructor,也就是Dog。所以 bigDog.constructor === Dog,这样才会打印出true。
这个例子证明了单纯用constructor来判断实例的类型是有隐患的,因为bigDog.constructor === BigDog是false,就说明无法用constructor来准确判断bigDog一个实例的类型。
可以这样修改:
function Dog() {
this.name = 'puppy'
}
Dog.prototype.bark = () => {
console.log('woof!woof!')
}
function BigDog() {}
BigDog.prototype = new Dog()
// 修复constructor的指向
Object.defineProperty(BigDog.prototype, "constructor", {
value: BigDog,
enumerable: false,
})
const bigDog = new BigDog()
console.log(bigDog.constructor === BigDog)
这段代码通过显式地更改BigDog.prototype的指向来修正上面提到的隐患,那为什么要这么大费周章地使用Object.defineProperty,而不是直接这样做呢:
BigDog.prototype.constructor = BigDog
是因为不希望 constructor 这个属性被 for…in 遍历到,所以用 defineProperty 来控制访问权限。
for(let i in bigDog){console.log(i)} // 会出现constructor属性
如果用上面两个问题去测试,就会发现ES6中的class本质上只是原型链的语法糖。
class Cat {
constructor(props) {
this.name = 'puppy'
}
}
class BigCat extends Cat {
constructor(props) {
super(props)
}
}
const bigCat = new BigCat()
console.log(bigCat.constructor === BigCat) //true
for(let i in bigCat){console.log(i)} // 不会出现constructor属性
六、执行上下文/作用域链/闭包
1. 对闭包的理解
闭包是指有权访问另一个函数作用域中变量的函数,创建闭包的最常见的方式就是在一个函数内创建另一个函数,创建的函数可以访问到当前函数的局部变量。
闭包有两个常用的用途;
- 闭包的第一个用途是使我们在函数外部能够访问到函数内部的变量。通过使用闭包,可以通过在外部调用闭包函数,从而在外部访问到函数内部的变量,可以使用这种方法来创建私有变量。
- 闭包的另一个用途是使已经运行结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收。
其实闭包的本质就是作用域链的一个特殊的应用,只要了解了作用域链的创建过程,就能够理解闭包的实现原理。
2. 对作用域、作用域链的理解
1)全局作用域和函数作用域
(1)全局作用域
- 最外层函数和最外层函数外面定义的变量拥有全局作用域
- 所有未定义直接赋值的变量自动声明为全局作用域
- 所有window对象的属性拥有全局作用域
- 全局作用域有很大的弊端,过多的全局作用域变量会污染全局命名空间,容易引起命名冲突。
(2)函数作用域
- 函数作用域声明在函数内部的变零,一般只有固定的代码片段可以访问到
- 作用域是分层的,内层作用域可以访问外层作用域,反之不行
2)块级作用域
- 使用ES6中新增的let和const指令可以声明块级作用域,块级作用域可以在函数中创建也可以在一个代码块中的创建(由
{ }
包裹的代码片段) - let和const声明的变量不会有变量提升,也不可以重复声明
- 在循环中比较适合绑定块级作用域,这样就可以把声明的计数器变量限制在循环内部。
作用域链: 在当前作用域中查找所需变量,但是该作用域没有这个变量,那这个变量就是自由变量。如果在自己作用域找不到该变量就去父级作用域查找,依次向上级作用域查找,直到访问到window对象就被终止,这一层层的关系就是作用域链。
作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,可以访问到外层环境的变量和函数。
作用域链的本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。作用域链的前端始终都是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象。
当查找一个变量时,如果当前执行环境中没有找到,可以沿着作用域链向后查找。
3. 对执行上下文的理解
1. 执行上下文类型
(1)全局执行上下文 任何不在函数内部的都是全局执行上下文,它首先会创建一个全局的window对象,并且设置this的值等于这个全局对象,一个程序中只有一个全局执行上下文。
(2)函数执行上下文 当一个函数被调用时,就会为该函数创建一个新的执行上下文,函数的上下文可以有任意多个。
(3)eval
函数执行上下文 执行在eval函数中的代码会有属于他自己的执行上下文,不过eval函数不常使用,不做介绍。
2. 执行上下文栈
- JavaScript引擎使用执行上下文栈来管理执行上下文
- 当JavaScript执行代码时,首先遇到全局代码,会创建一个全局执行上下文并且压入执行栈中,每当遇到一个函数调用,就会为该函数创建一个新的执行上下文并压入栈顶,引擎会执行位于执行上下文栈顶的函数,当函数执行完成之后,执行上下文从栈中弹出,继续执行下一个上下文。当所有的代码都执行完毕之后,从栈中弹出全局执行上下文。
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
//执行顺序
//先执行second(),在执行first()
3. 创建执行上下文
创建执行上下文有两个阶段:创建阶段和执行阶段 1)创建阶段 (1)this绑定
- 在全局执行上下文中,this指向全局对象(window对象)
- 在函数执行上下文中,this指向取决于函数如何调用。如果它被一个引用对象调用,那么 this 会被设置成那个对象,否则 this 的值被设置为全局对象或者 undefined
(2)创建词法环境组件
- 词法环境是一种有标识符——变量映射的数据结构,标识符是指变量/函数名,变量是对实际对象或原始数据的引用。
- 词法环境的内部有两个组件: 加粗样式:环境记录器:用来储存变量个函数声明的实际位置 外部环境的引用:可以访问父级作用域
(3)创建变量环境组件
- 变量环境也是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系。
2)执行阶段 此阶段会完成对变量的分配,最后执行完代码。 简单来说执行上下文就是指: 在执行一点JS代码之前,需要先解析代码。解析的时候会先创建一个全局执行上下文环境,先把代码中即将执行的变量、函数声明都拿出来,变量先赋值为undefined,函数先声明好可使用。这一步执行完了,才开始正式的执行程序。 在一个函数执行之前,也会创建一个函数执行上下文环境,跟全局执行上下文类似,不过函数执行上下文会多出this、arguments和函数的参数。
- 全局上下文:变量定义,函数声明
- 函数上下文:变量定义,函数声明,
this
,arguments
七、this/call/apply/bind
1. 谈谈对this的理解
this 是执行上下文中的一个属性,它指向最后一次调用这个方法的对象。在实际开发中,this 的指向可以通过四种调用模式来判断。
- 第一种是函数调用模式,当一个函数不是一个对象的属性时,直接作为函数来调用时,this 指向全局对象。
- 第二种是方法调用模式,如果一个函数作为一个对象的方法来调用时,this 指向这个对象。
- 第三种是构造器调用模式,如果一个函数用 new 调用时,函数执行前会新创建一个对象,this 指向这个新创建的对象。
- 第四种是 apply 、 call 和 bind 调用模式,这三个方法都可以显示的指定调用函数的 this 指向。其中 apply 方法接收两个参数:一个是 this 绑定的对象,一个是参数数组。call 方法接收的参数,第一个是 this 绑定的对象,后面的其余参数是传入函数执行的参数。也就是说,在使用 call() 方法时,传递给函数的参数必须逐个列举出来。bind 方法通过传入一个对象,返回一个 this 绑定了传入对象的新函数。这个函数的 this 指向除了使用 new 时会被改变,其他情况下都不会改变。
这四种方式,使用构造器调用模式的优先级最高,然后是 apply 、 call 和 bind 调用模式,然后是方法调用模式,然后是函数调用模式。
2. call() 和 apply() 的区别?
它们的作用一模一样,区别仅在于传入参数的形式的不同。
- apply 接受两个参数,第一个参数指定了函数体内 this 对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组,也可以为类数组,apply 方法把这个集合中的元素作为参数传递给被调用的函数。
- call 传入的参数数量不固定,跟 apply 相同的是,第一个参数也是代表函数体内的 this 指向,从第二个参数开始往后,每个参数被依次传入函数。
3. 实现call、apply 及 bind 函数
(1)call 函数的实现步骤:
- 判断调用对象是否为函数,即使是定义在函数的原型上的,但是可能出现使用 call 等方式调用的情况。
- 判断传入上下文对象是否存在,如果不存在,则设置为 window 。
- 处理传入的参数,截取第一个参数后的所有参数。
- 将函数作为上下文对象的一个属性。
- 使用上下文对象来调用这个方法,并保存返回结果。
- 删除刚才新增的属性。
- 返回结果。
Function.prototype.myCall = function(context) {
// 判断调用对象
if (typeof this !== "function") {
console.error("type error");
}
// 获取参数
let args = [...arguments].slice(1),
result = null;
// 判断 context 是否传入,如果未传入则设置为 window
context = context || window;
// 将调用函数设为对象的方法
context.fn = this;
// 调用函数
result = context.fn(...args);
// 将属性删除
delete context.fn;
return result;
};
(2)apply 函数的实现步骤:
- 判断调用对象是否为函数,即使是定义在函数的原型上的,但是可能出现使用 call 等方式调用的情况。
- 判断传入上下文对象是否存在,如果不存在,则设置为 window 。
- 将函数作为上下文对象的一个属性。
- 判断参数值是否传入
- 使用上下文对象来调用这个方法,并保存返回结果。
- 删除刚才新增的属性
- 返回结果
Function.prototype.myApply = function(context) {
// 判断调用对象是否为函数
if (typeof this !== "function") {
throw new TypeError("Error");
}
let result = null;
// 判断 context 是否存在,如果未传入则为 window
context = context || window;
// 将函数设为对象的方法
context.fn = this;
// 调用方法
if (arguments[1]) {
result = context.fn(...arguments[1]);
} else {
result = context.fn();
}
// 将属性删除
delete context.fn;
return result;
};
(3)bind 函数的实现步骤:
- 判断调用对象是否为函数,即使是定义在函数的原型上的,但是可能出现使用 call 等方式调用的情况。
- 保存当前函数的引用,获取其余传入参数值。
- 创建一个函数返回
- 函数内部使用 apply 来绑定函数调用,需要判断函数作为构造函数的情况,这个时候需要传入当前函数的 this 给 apply 调用,其余情况都传入指定的上下文对象。
Function.prototype.myBind = function(context) {
// 判断调用对象是否为函数
if (typeof this !== "function") {
throw new TypeError("Error");
}
// 获取参数
var args = [...arguments].slice(1),
fn = this;
return function Fn() {
// 根据调用方式,传入不同绑定值
return fn.apply(
this instanceof Fn ? this : context,
args.concat(...arguments)
);
};
};