文章目录
- 一、数据类型
- 二、ES6
- 三、JavaScript基础
- 1. Map和Object有什么区别?
- 2. Map和WeakMap的区别
- 3. 对JSON的理解
- 4. JavaScript脚本延迟加载的方式有哪些?
- 5. 类(伪)数组是怎么样的?怎么转化为数组
- 6. 原、反、补码,常见的位运算符有哪些?
- 7. 什么是DOM和BOM?
- 8. 详细讲讲encodeURI和encodeURIComponent以及它们的区别
- 9. 详细讲讲Base64编码和解码
- 10. 谈谈你对Ajax的理解,实现一个Ajax请求
- 11. 什么是尾调用(版本的眼泪)?尾调用的好处是什么?
- 12. ES6(ESM)模块与CommonJS模块有什么异同?
- 13. 创建的DOM操作有哪些
- 14. 'use strict'是什么意思?使用它区别是什么?
- 15. 如何判断一个对象是否属于某个类?
- 16. 强类型语言和弱类型语言的区别
- 17. 解释性语言和编译型语言的区别
- 18. for...in和for...of的区别
- 19. 如何使用for...of遍历对象?
- 20. 了解Generator生成器吗?详细讲讲
- 21. 详细说说ajax、fetch、axios的区别?
- 三、原型和原型链
- 四、执行上下文/作用域/闭包
- 五、call/apply/bind
- 六、异步编程
- 七、面向对象
- 八、垃圾回收和内存泄漏
一、数据类型
1. JavaScript用哪些数据类型、它们有什么区别?
JavaScript共有八种数据类型,分别包括5种基本数据类型和3种非基本数据类型。
- 基本数据类型:
Undefined
、Null
、Boolean
、Number
、String
。 - 非基本数据类型:
Object
、Symbol
、BigInt
。
其中Symbol
和BigInt
是ES6新增的数据类型:
Symbol
代表创建后独一无二且不可变的数据类型,它主要是为了解决可能出现的全局变量冲突的问题。BigInt
是一种数字类型的数据,它可以表示任意精度格式的整数,使用BigInt
可以安全地存储和大整数,即使这个数超出了Number的安全整数范围。
区别一:分为原始(基本)数据类型和引用数据类型。
- 原始数据类型:
Undefined
、Null
、Boolean
、Number
、String
。 - 引用数据类型:
Object
。另外还有数组Array
和函数Function
。
原始(基本)数据类型和引用数据类型有一个很明显的区别是:引用类型有自己内置的方法,也可以自定义其他方法来操作数据,而基本数据类型不能像引用类型那样有自己的内置方法对数据进行更多的操作。因此,为了操作基本类型值,ES提供了3个特殊引用类型,也就是基本包装类型:Number、Boolean、String,关于包装类型将在本章第8节详细讲解。
区别二:存储位置不同。
- 原始数据类型直接存储在**栈(stack)**中,往往占据空间小、大小固定、属于被频繁使用数据,所以放在栈中。
- 引用数据类型存储在**堆(heap)**中,往往占据空间大、大小不固定,如果存在栈中将会影响程序运行的性能。因此,引用数据类型在栈中存储了指针,指针指向堆中该实体的起始地址。当解释器寻找引用值时,会先检索其在栈中的地址,再根据地址从堆中获得实体。
扩展知识:堆与栈
堆和栈的概念存在于数据结构和操作系统内存中。
- 在数据结构中:
- 栈:先进后出
- 堆:先进先出
- 在操作系统中分为堆区和栈区:
- 栈区内存由编译器自动分配释放,存放函数的参数值,局部变量的值等。操作方式类似于数据结构中的栈。
- 堆区内存一般由开发者分配释放,若开发者不释放,程序结束时可能由垃圾回收机制回收。
2. 数据类型的检测方式有哪些?详细讲讲
2.1 typeof
// 1.typeof 数组、对象和null都会被视为object 其他类型都判定正确
console.log(typeof {}); // object
console.log(typeof []); // object
console.log(typeof null); // object
console.log(typeof function () {}); // function
console.log(typeof 1); // number
console.log(typeof true); // boolean
console.log(typeof "str"); // string
console.log(typeof undefined); // undefined
console.log(typeof Symbol()); // symbol
console.log(typeof NaN) // number
根据上面的结果可以看到数组、对象和null都会被视为object,其他类型都能判定正确。
2.2 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(function () {} instanceof Object); // true 原型链上找 所以也为true 注意!!!
console.log({} instanceof Object); // true
console.log(null instanceof Object); // false 这是因为null是原型链的尽头 它没有后续原型链 更不可能找到Object类型的原型
根据上面的结果可以看到 instanceof只能正确判断引用类型,基本数据类型无法判定。
需要注意的是:null instanceof Object结果是false
,因为null是原型链的尽头,它没有后续原型链,更不可能找到Object类型的原型。
2.3 constructor
它的原理是:**除了null之外,任何对象都会在其prototype
/__proto__
上有一个constructor
属性,而constructor属性返回一个引用,这个引用指向创建该对象的构造函数,而Number
、Boolean
、String
、Array
都属于构造函数。**因此通过construct和构造函数就能判断类型是否符合。
console.log((2).constructor); // ƒ Number() { [native code] } Number构造函数
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
// console.log((null).constructor === Object); // 会报错 原因是null不存在constructor
// console.log((undefined).constructor === Object); // 会报错 原因是undefined不存在constructor
// console.log((null).constructor); // 会报错
// console.log((undefined).constructor); // 会报错
从上面的结果看到,constructor
除了不能判断null
和undefined
外,其它类型都能判断。
需要注意的是,如果创建的对象的原型被改变了,constructor
就不能用来判断数据类型了。
function Fn() {}
console.log(Fn.prototype.constructor); // f Fn() {}
Fn.prototype = new Array()
console.log(Fn.prototype.constructor); // ƒ Array() { [native code] }
let f = new Fn();
console.log(f.constructor); // ƒ Array() { [native code] }
console.log(f.__proto__); // ƒ Array() { [native code] }
console.log(f.constructor === Fn); // false
console.log(f.constructor === Array); // true
扩展知识:constructor
的两个作用:
- 判断数据类型。
- 对象实例通过construct对象访问它的构造函数。
2.4 Object.prototype.toString.call()
它的原理是:对象原型上的toString
方法会获取当前对象的类型然后返回[object Type]
字符串,由于部分内置对象对toString
重写了,因此需要调用.call()
来利用原本的toString
函数,.call(args)
方法实现让调用call
方法的对象的this
指向传的参数args
。
let a = Object.prototype.toString;
console.log(a.call(2)); // [object Number]
console.log(a.call(2) == Number); // false
console.log(a.call(2) == "[object Number]"); // true
console.log(a.call(true)); // [object Boolean]
console.log(a.call(true) == Boolean); // false
console.log(a.call(true) == "[object Boolean]"); // true
console.log(a.call("str")); // [object String]
console.log(a.call("str") == String); // false
console.log(a.call("str") == "[object String]"); // true
console.log(a.call(new Date())); // [object Date]
console.log(a.call(new Date()) == Date); // false
console.log(a.call(new Date()) == "[object Date]"); // true
console.log(a.call([])); // [object Array]
console.log(a.call(function () {})); // [object function]
console.log(a.call({})); // [object Object]
console.log(a.call(undefined)); // [object undefined]
console.log(a.call(null)); // [object Null]
通过上面代码可以看到,Object.prototype.toString.call()
可以验证任何类型。
2.5 封装一个类型验证的方法
大型项目中往往会使用Object.prototype.toString.call()
封装一个isType
方法来验证类型,封装代码如下:
function isType(data, type) {
const typeObj = {
"[object String]": "string",
"[object Number]": "number",
"[object Boolean]": "boolean",
"[object Null]": "null",
"[object Undefined]": "undefined",
"[object Object]": "object",
"[object Array]": "array",
"[object Function]": "function",
"[object Date]": "date", // Object.prototype.toString.call(new Date())
"[object RegExp]": "regExp",
"[object Map]": "map",
"[object Set]": "set",
"[object HTMLDivElement]": "dom", // document.querySelector('#app')
"[object WeakMap]": "weakMap",
"[object Window]": "window", // Object.prototype.toString.call(window)
"[object Error]": "error", // new Error('1')
"[object Arguments]": "arguments",
};
let name = Object.prototype.toString.call(data); // 借用Object.prototype.toString()获取数据类型
let typeName = typeObj[name] || "未知类型"; // 匹配数据类型
return typeName === type; // 判断该数据类型是否为传入的类型
}
下面我们可以测试一下封装结果:
console.log(
isType({}, "object"), // true
isType([], "array"), // true
isType(new Date(), "object"), // false
isType(new Date(), "date") // true
);
2.6 总结
方法名 | 效果 |
---|---|
typeof | 数组、对象和null 都会被视为object ,其他类型都能判定正确 |
instanceof | 只能正确判断引用类型,基本数据类型无法判定 |
constructor | 除了不能判断null 和undefined 外,其它类型都能判断 |
Object.prototype.toString.call() | 可以判断所有类型,但是返回结果是字符串【最推荐,封装isType 】 |
3. 判断数组的方式有哪些?
let arr = []
Object.prototype.toString.call(arr).slice(8,-1) === 'Array'
或者Object.protoType.toString.call(arr) === '[object Array]'
- 通过原型链判断:
arr.__proto__ === Array.prototype
- 通过
Array.isArray(arr)
- 通过
arr instanceof Array
4. null、undefined、Null有什么区别?
- 含义不同:
undefined
代表的含义是未定义,而null
代表的含义是空对象,NaN
表示不是一个数字,用于指出数字类型中的错误情况,通过执行数学运算没有成功时返回NaN
。。 - 初始化场景不同:通常变量声明了但还没有定义的时候使用
undefined
,null
主要用在初始化一些可能会返回对象的变量,NaN
不用于初始化。 - typeof判断结果不同:
typeof undefined
返回undefined
,typeof null
返回object
,typeof NaN
返回number
。
一般变量声明了但还没定义的时候会返回undefined
。
需要注意的是:
-
使用
null == undefined
返回true
,null === undefined
返回false
。 -
NaN
与自身不相等,NaN == NaN
和NaN === NaN
得到的结果都是false
。
5. 为什么0.1+0.2 !== 0.3,怎么才能让它们相等
大白话:
首先:因此js将数据转为二进制后处理数据【要点一】,0.1
转化为二进制为:0.0001 1001 1001 1001无限循环..
,0.2转化为二进制为:0.001 1001 1001 1001(无限循环)
。
又因为js的Number类型遵循IEEE754标准64位存储【要点二】,IEEE754标准64位内只有52位来表示小数,有很多小数转为二进制后存储无限位数,如果第53位为1的话,只保留52为就会进位【要点三】,从而导致精度丢失【第一次精度丢失】。
而后进行二进制相加的时候,也可能会存在进位的问题,进而导致精度丢失【第二次精度丢失】,最后相加得到的二进制结果转化为数字就会与我们平常相加得到的结果有偏差。
解决方法:
-
将两数转换为整数,在相加后转回小数
let x = (0.1 * 10 + 0.2 * 10) / 10 console.log(x === 0.3) // true
-
使用toFixed方法配合parseFloat方法
console.log(parseFloat((0.1 + 0.2).toFixed(1)) === 0.3) // true
-
根据真实结果减去预测结果是否小于
Number.EPSILON
在ES6中,提供了
Number.EPSILON
属性,它的值为2^-52
。console.log((0.1 + 0.2) - 0.3 < Number.EPSILON) // true
下面详细讲讲:
Number类型遵循的IEEE754 64位标准,也就是双精度浮点数(double)存储,它为每个数值分配64位存储空间,以科学计数法的方式存储。64位分配如下:1位符号位,11位指数位,剩余52位为小数位。
这里以0.1为例:
6. == 操作符的强制转换规则是怎么样的?
==
在比对双方类型不一样时,会进行类型转换。
其中包括:
- string转为number
- boolean转为number
- object转为字符串
[object Object]
判断流程如下:
- 先判断两者类型是否相同,同则比较大小
- 不同进行类型转换
- 先判断是否在比对
null
和undefined
,是的话返回true
- 接着按上述三个点的顺序类型转化
7. 显式类型转换
转为Number
类型:
console.log(Number(undefined)); // NaN
console.log(Number(null)); // 0
console.log(Number(true)); // 1
console.log(Number('ad')); // NaN
console.log(Number('11')); // 11
console.log(Number('11a')); // NaN
console.log(Number('')); // 0
console.log(Number({})); // NaN
console.log(Number({a: 1})); // NaN
console.log(Number([])); // 0
console.log(Number([1, 2])); // NaN
值得注意的是:undefined
转为Number
类型的结果是NaN
转为Boolean
类型:
console.log(Boolean(undefined)); // false
console.log(Boolean(null)); // false
console.log(Boolean(NaN)); // false
console.log(Boolean(false)); // false
console.log(Boolean(+0)); // false
console.log(Boolean(-0)); // false
console.log(Boolean('')); // false
转为String
类型:
console.log(String(undefined)); // 'undefined'
console.log(String(null)); // 'null'
console.log(String(true)); // 'true'
console.log(String(1)); // '1'
console.log(String({})); // '[object Object]'
console.log(String([])); // '
转为Object
类型:
console.log(Object(undefined)); // {}
console.log(Object(null)); // {}
console.log(Object(true)); // {Boolean: true}
console.log(Object(1)); // {Number: 1}
console.log(Object('')); // {String: ''}
console.log(Object([])); // []
console.log(Object({})); // {}
8. 什么是JavaScript中的包装类型?
在JavaScript中,基本类型不像引用类型那样,它是没有属性和方法的,因此为了更便于操作基本类型的值,ECMAScript提供了3个特殊引用类型,也就是包装类型:Boolean
、Number
、String
。
在调用基本类型的方法或者属性时,JavaScript会在后台隐式的将基本类型的值转换为对象,如:
const a = 'abc'
a.length // 3
在访问'abc'.length
时,JavaScript将'abc'
在后台转换为String('abc')
,然后再访问其length
属性。
也可以使用Object
函数显式转换基本类型为包装类型:
let b = 'abc'
let c = Object(b)
// Object显式将基本类型转换为包装类型
console.log(typeof c); // object
console.log(c); // String {'abc'}
// valueof将包装类型转回基本类型
let d = c.valueOf()
console.log(typeof d); // string
console.log(d); // abc
需要注意的是:
let a = new Boolean(false)
console.log(typeof a); // object
console.log(typeof Boolean(false)); // boolean
if (!a) {
console.log('实例化'); // 不输出
}
if(!Boolean(false)){
console.log('非实例化'); // '非实例化'
}
new
过的实例对象被包裹成包装类型后就成了对象,所以其非值为false
,因此判断不成立所以不输出。
9. 隐式类型转换
+
:两边至少有一个string
类型时,两边变量都会被隐式转换为string
,其他情况两边变量都转为number
。
console.log(1 + '23'); // '123'
console.log(2 + true); // 3
console.log('1' + false); // '1false'
console.log(false + true); // 1
- * \
:当变量为对象或者有长度大于1的子孙元素的数组时,变量会被视为NaN
(其实NaN
也算是数字),因此得到的结果为NaN
,其余情况都转为number
类型。
console.log(2 * '23'); // 46
console.log(2 * true); // 2
console.log(2 * {}); // NaN
console.log(2 * []); // 0
console.log(2 * [1]); // 2
console.log(2 * [1, 3]); // NaN
console.log('------------');
console.log(2 - '23'); // -21
console.log(2 - true); // 1
console.log(2 - {}); // NaN
console.log(2 - []); // 2
console.log(2 - [1]); // 1
console.log(2 - [1, 3]); // NaN
console.log(2 - [[1]]); // 1
console.log(2 - [[1, 2]]); // NaN
==
:两边都转为number
,同理{}
或者有长度大于1的子孙元素的数组会被判定为NaN
,计算结果自然为false
。
> <
:两边都是字符串,按字母表顺序比较,其他情况转为数字再比较。
10. 判断一个对象是空对象有哪些方法?
-
静态方法
Object.keys(obj).length == 0
let obj5 = {}; console.log(Object.keys(obj5).length == 0); // true
-
转换为JSON字符串后与
'{}'
比对let obj6 = {}; console.log(JSON.stringify(obj6) == '{}'); // true
11. Object.assign、扩展运算符是深拷贝还是浅拷贝?
let obj1 = {
child1: { a: 1, b: 2 },
};
let obj2 = { ...obj1 };
// 修改
obj2.child1.a = 3;
console.log(obj1); // { child1: { a: 3, b: 2 } }
console.log(obj2); // { child1: { a: 3, b: 2 } }
由此可得:扩展运算符是浅拷贝。
let obj3 = {
child1: { a: 1, b: 2 },
};
let obj4 = Object.assign({}, obj3);
// 修改
obj4.child1.a = 3;
console.log(obj3); // { child1: { a: 3, b: 2 } }
console.log(obj4); // { child1: { a: 3, b: 2 } }
由此可得:Object.assign()
是浅拷贝。
二、ES6
1. 详细说说let、const、var的区别
-
块级作用域:let和const都具有块级作用域(由
{}
包裹的区域),var不存在块级作用域。块级作用域解决了ES5中的两个问题:
- 内层变量覆盖外层变量
- 用于计数的循环变量泄漏为全局变量
-
变量提升:var存在变量提升,即变量和函数的声明会在物理层面移动到代码的最前面,因此可以先使用变量后声明,而let和const都不存在变量提升,即变量必须在声明之后才能使用,否则会报错。
-
重复声明:var定义的变量可以重新声明,新声明的会覆盖旧声明的,let和const定义的变量不允许在块级范围内重新声明。
-
给全局添加属性:var声明的变量会添加为全局对象上的属性,let和const并不会。
let a_let = {} const a_const = {} var a_var = {} console.log(window); // 浏览器环境下的全局对象 node环境下输出会报错 console.log(globalThis); // node环境下的全局对象 浏览器环境下输出得到的是window
结果展示如下:
-
初识值设置:var和let声明的变量可以不设置初始值,而const设置的变量必须设置初始值,否则会报错
结果展示如下:
-
指针指向:let和const都是ES6新增的创建变量语法,let创建的变量可以改变指针指向(可以重新赋值),但const声明的变量不允许改变指针指向,会报错。
// var改变指针指向重新赋值 var a = [1, 2, [3, 4]]; a = { 1: "---------" }; console.log(a); // let改变指针指向重新赋值 let b = [1, 2, [3, 4]]; b = { 1: "---------" }; console.log(b); // const改变指针指向重新赋值 const c = [1, 2, [3, 4]]; c = { 1: "---------" }; console.log(c);
效结果展示如下:
2. new一个箭头函数会怎么样?new的原理
2.1 new一个箭头函数
箭头函数是ES6中提出来的,它没有prototype
,也没有自己的this
指向,更可以使用arguments
参数,所以不能new
一个箭头函数。它会报如下错误:
2.2 new原理
new
的实现步骤(原理)如下:
- 第一步:创建一个空对象,作为将要返回的对象。
- 第二步:将这个空对象的原型指向构造函数的
prototype
属性,也就是将对象的__proto__
属性指向构造函数的prototype
。【让对象能沿着原型链去使用构造函数中prototype
上的方法】 - 第三步:将这个空对象赋值给构造函数内部的
this
关键字,执行构造函数。【让构造器中设置的属性和方法设置在这个对象上】 - 第四步:返回这个对象。
function F() {}
let f = new F()
以F
构造函数为例,上面原理转换为伪代码大概是这样的:
let obj = {}
obj.__proto__ = F.prototype
F.apply(obj, 参数)
return obj
因此,我们可以手搓一个new
方法试试:
let _new = function (F, ...args) {
// let obj = {};
// obj.__proto__ = F.prototype;
let obj = Object.create(F.prototype); // 简写
F.apply(obj, args);
return obj;
};
下面我们通过对比原生的new
和我们手搓的_new
的输出结果以验证手搓的_new
效果如何:
let F = function (val, num) { // 构造函数
this.val = num;
this.num = val;
this.hello = function hello() {};
function hi() {}
};
let _new = function (F, ...args) { // 手搓new方法
// let obj = {};
// obj.__proto__ = F.prototype;
let obj = Object.create(F.prototype); // 简写
F.apply(obj, args);
return obj;
};
let f1 = new F(1, 2);
console.log(new F(1, 2)); // 原生的new的实例化输出结果
console.log(_new(F, 1, 2)); // 手搓的_new的实例化输出结果
上述代码运行结果如下:
可以看到手搓的_new
方法实现效果是和原生new
一样的。
2.3 new function和new class的区别
function
和class
都可以作为构造函数,但它们之间也有不少区别:
-
funtion
定义构造函数存在提升,可以先使用后定义;class
定义构造函数不存在提升,只能先定义后使用,否则会报错。// funtion定义构造函数存在提升,可以先使用后定义 console.log(new F4()); function F4() { this.name = 1; } // class定义构造函数不存在提升,只能先定义后使用 class F5 { constructor() { this.name = 1; } } console.log(new F5());
输出结果:
-
class
不能调用call、apply、bind
改变执行上下文。function F5() { console.log(this.name); } const obj1 = { name: 'Jack', }; F5.call(obj1); // Jack class F6 { constructor() { console.log(this.name); } } const obj2 = { name: 'Jack', }; F6.call(obj2); // Class constructor F6 cannot be invoked without 'new'
-
2.4 function作为构造函数的注意事项
-
function
尽量别有返回值,如果有返回值会根据返回值按如下处理:- 返回值不是对象:无视返回值,输出的实例对象结果是
this
对象。 - 返回值是对象:将
function
当成方法处理,就不再是构造函数了。
function F7() { this.name = "Jack"; this.age = 18; return { name: "AAA" }; } let f7 = new F7(); console.log(f7); // {name: 'AAA'} function F8() { this.name = "Jack"; this.age = 18; console.log(this); // F8 {name: 'Jack', age: 18} 这是this对象 return 1; } let f8 = new F8(); console.log(f8); // F8 {name: 'Jack', age: 18} 这是this对象
- 返回值不是对象:无视返回值,输出的实例对象结果是
-
实例化对象需要加
new
,加new
后构造函数里的this就指向该实例,不加的话指向的是window
function F9(name, age) { this.name = name; this.age = age; } // 加new let f9_1 = new F9("Jack-1", 18); console.log(f9_1.name); // Jack-1 // 不加new let f9_2 = F9("Jack-2", 18); // console.log(f9_2.name); // 报错 console.log(window.name); // Jack-2 在window上 说明this指向window
为了避免第二个注意事项,我们可以在定义构造函数时添加一个判断来处理忘记加new
的情况:
function F9(name, age) {
// 处理漏加new的情况
if (!(this instanceof F9)) {
return new F9(name, age);
}
this.name = name;
this.age = age;
}
测试结果:
// 加new
let f9_1 = new F9("Jack-1", 18);
console.log(f9_1.name); // Jack-1
// 不加new
let f9_2 = F9("Jack-2", 18);
console.log(f9_2.name); // Jack-2
可以看到这种方式是没有问题。
3. 箭头函数和普通函数有什么区别
- 箭头函数比普通函数更简洁。
- 箭头函数没有属于自己的this,只会继承自己作用域的上一层this。
- 箭头函数的this指向永远不会改变,也就是说
call
、apply
、bind
等方法也不能改变。 - 箭头函数不能作为构造函数使用。
- 箭头函数没有
arguments
。 - 箭头函数没有
prototype
。 - 箭头不能用作
Generator
函数,不能使用yeild
关键字。
4. 扩展运算符(spread)的作用及其使用场景
4.1 对象扩展符
- 浅拷贝对象,等同于
Object.assign({}, obj)
- 合并属性,
obj = {...obj1, name:1}
4.2 数组扩展符
-
浅拷贝数组
-
将数组转为参数序列:
Math.max(...arr)
-
合并数组,需要注意扩展运算符必须放在最后面
-
将字符串转为数组
console.log([...'fjkasbfjka']) // ['f', 'j', 'k', 'a','s', 'b', 'f', 'j', 'k', 'a']
-
任何
Iterator
接口的对象,都可以用扩展运算符转为真正的数组,比如伪数组arguments
function fun(a, b, c) { let args = [...arguments] console.log(args); // [1, 2, 'name'] } fun(1, 2, 'name')
5. 剩余运算符(rest)的作用和使用场景
5.1 对象
跟解构赋值相结合,用于对象赋值。需要注意解构单个属性的话,变量名必须跟属性名一一对应。
const obj = { a: 1, b: 2, c: 3 };
const { a, ...newObj } = obj;
console.log(a); // 1
console.log(newObj); // { b: 2, c: 3}
剩余运算符必须放在最后一位。
5.2 数组
-
跟解构赋值相结合,用于数组赋值
const [arr1, ...arr2] = [1, 2, 3, 4, 5] console.log(arr1) // 1 console.log(arr2) // [2, 3, 4, 5, 6] const [a, , c] = [1, 2, 3] console.log(a) // 1 console.log(c) // 3
-
用在函数形参上,将分离的参数整合成一个数组。通常用于处理参数个数不确定的情况
function fun(...args) { console.log(args); // [1, 2, 3] } fun(1, 2, 3); function fun2(a, ...args) { console.log(args); // [ 2, 3] } fun2(1, 2, 3);
5.3 对比扩展运算符spread
扩展运算符使用时可以放在任何位置,而剩余运算符必须放在最后一位。
6. 如何提取高度嵌套对象里的指定属性?
方法一:逐层解构。
const school = {
classes: {
stu: {
nickname: "张三",
age: 18,
},
},
};
let { classes } = school;
let { stu } = classes;
let { nickname } = stu;
console.log(classes); // { stu: { name: '张三', age: 18 } }
console.log(nickname); // 张三
方法二:标准做法,一行代码即可。
let { classes: { stu: { nickname } } } = school;
// console.log(classes) // 会报错 上层是没解构出来的
console.log(nickname); // 张三
三、JavaScript基础
1. Map和Object有什么区别?
Map | Object | |
---|---|---|
键的顺序 | 键值对有顺序,set的顺序 | 无序 |
迭代 | 是iterable 的,可以直接被迭代 | 不是iterable ,不可迭代,需要Object.keys(obj) 或Object.values(obj) 才能使用for...of |
性能 | 在频繁增删键值对的场景表现更好 | 通常使用在不频繁删除键值对的场景 |
size | 键值对个数通过size 属性获取 | 键值对个数只能手动计算 |
键的类型 | 可以是任何值,包括函数、对象或者基本类型 | 必须是String 或者Symbol |
意外的键 | 默认情况下不包含任何键,只包含显示插入的键 | Object 有一个原型,原型链上的键名可能跟自己对象上的键名产生冲突 |
Map
let map = new Map([["name", "张三"]]);
for (let item of map) {
// item是个数组
console.log(item); // [ 'name', '张三' ]
}
for (let [key, value] of map) {
// 解构出键和值
console.log(key + " = " + value); // name = 张三
}
// Map内置forEach 但是item是val
map.forEach((value) => {
console.log(value); // 张三
});
Object
let obj = { name: "张三" };
for (let key in obj) { // for...in 拿到的是键
console.log(key + " = " + obj[key]); // name = 张三
}
for (let key of Object.keys(obj)) {
console.log(key + " = " + obj[key]); // name = 张三
}
for (let value of Object.values(obj)) {
console.log(value); // 张三
}
for (let [key, value] of Object.entries(obj)) {
console.log(key + " = " + value); // name = 张三
}
2. Map和WeakMap的区别
主要的区别:WeakMap
的键必须是对象。而Map
的键可以是任意类型。
WeakMap
的键名所引用的对象都是弱引用【Map是强引用】,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。
也就是说,一旦不再需要某个对象,即使WeakMap
中引用了,WeakMap
中对应的键值对也会自动消失,而不用手动删除引用。
WeakMap
的优势是解决了Map可能会导致内存泄漏的问题,因为,在Map中,数组会一直引用着某个键和值,这种引用使得回收算法不能回收处理它,即使没有其他任何存在的意义了。而WeakMap
不会阻止垃圾回收,直到垃圾回收器移除了键对象的引用,并且任何值都可以被垃圾回收,只要它们的键对象没有被 WeakMap
以外的地方引用。
第二个区别是:WeakMap
的原型上只有delete
、get
、set
、has
这四个方法,也就是只有增删改查的方法。
因为 WeakMap
不允许观察其键的生命周期,所以其键是不可枚举的。没有方法可以获得键的列表。
let obj = { a: 1 };
let wMap = new WeakMap([[obj, "张三"]]);
console.log(wMap.keys()); // 报错
console.log(wMap.values()); // 报错
console.log(wMap.entries()); // 报错
3. 对JSON的理解
JSON
是一种基于文本的轻量级数据交换格式,它可以被任何的编程语言读取和作为数据格式来传递。
在项目开发中,使用JSON
作为前后端数据交换的方式,在前端通过将一个符合JSON
格式的数据解构序列化为JSON
字符串,然后后端通过解析JSON
后生成对应的数据结果,以此实现前后端数据的一个传递。
4. JavaScript脚本延迟加载的方式有哪些?
延迟加载指的是:等页面加载完成之后再加载JavaScript文件,有助于提高页面加载的速度。
- 给
script
脚本添加defer
属性:这个属性会让脚本的加载和文档的解析同步进行,即文档解析完后再执行这个脚本文件,这样的话就能使页面的渲染不被阻塞。 - 将
script
放在文档的底部:让Javascript
脚本尽可能最后加载执行。 - 给
script
脚本添加async
属性:这个属性会让脚本异步加载,不会阻塞页面的解析过程。但是当脚本加载的比文档解析完成更快的时候同样会阻塞,因为加载完脚本会立即执行。 - 动态创建
DOM
的script
标签:对文档的加载事件进行监听,当文档加载完成后再动态创建script
标签来引入js
脚本。 - 设置定时器延迟加载。
5. 类(伪)数组是怎么样的?怎么转化为数组
有length
和index
,但是不能使用数组的那些方法。常见的类数组对象有arguments
和DOM
方法的返回结果
转成数组的方法:
arrLike = Array.from(arrayLike)
arrLike = Array.prototype.slice.call(arrayLike)
:通过调用数组的slice
方法来实现转换。arrLike = Array.prototype.splice.call(arrayLike, 0)
:通过调用数组的splice
方法来实现转换。arrLike = Array.prototype.concat.call([], arrayLike)
:通过调用数组的concat
方法来实现转换。
let arrLike = { 0: "a", 1: "b", 2: "c", length: 3 };
arrLike = Array.prototype.slice.call(arrLike);
console.log(arrLike); // ['a', 'b', 'c']
6. 原、反、补码,常见的位运算符有哪些?
6.1 原、反、补码
一个数在计算机中的二进制表示形式, 叫做这个数的机器数。机器数是带符号的,在计算机用一个数的最高位存放符号, 正数为0
, 负数为1
。
在介绍位运算符前先了解一下原码、反码和补码。
计算机中的有符号数有三种表示方法,即原码、反码和补码【实际上,计算机内参与计算的不是原码,而是补码】。三种表示方法均有符号位和数值位两部分,符号位都是用0表示正,1表示负,而数值位,三种表示方式各不相同。
表示方法 | 正数 | 负数 |
---|---|---|
原码 | 正常二进制数表示,即符号位0 | 正常二进制数表示,即符号位1 |
反码 | 与原码相同 | 符号位保持不变,数值位按位取反 |
补码 | 与原码相同 | 符号位保持不变,数值位按位取反后+1 |
6.2 反码与补码的意义
计算机在使用原码进行加减乘除的运算时,需要根据数字符号位的正负从而执行不同规则的加法,如果采用单一的加法会导致结果出错,如下:
1 + 1 = [00000001]原 + [00000001]原 = [00000010]原 = 2 两正数结果正确
1 - 1 = 1 + (-1) = [00000001]原 + [10000001]原 = [10000010]原 = -2 存在负数结果错误
可以看到,如果用原码表示,让符号位也参与计算,显然对于减法来说,结果是不正确的,这也是为什么计算机不用原码表示一个负数。为了解决这个问题,出现了反码,如下:
1 - 1 = 1 + (-1) = [0000 0001]原 + [1000 0001]原= [0000 0001]反 + [1111 1110]反 = [1111 1111]反 = [1000 0000]原 = -0
发现用反码计算减法, 结果的真值部分是正确的. 而唯一的问题其实就出现在0
这个特殊的数值上. 虽然人们理解上+0
和-0
是一样的, 但是0
带符号是没有任何意义的. 而且会有[0000 0000]
原和[1000 0000]
原两个编码表示0
。
于是补码的出现, 解决了0
的符号以及两个编码的问题:
1-1 = 1 + (-1) = [0000 0001]原 + [1000 0001]原 = [0000 0001]补 + [1111 1111]补 = [0000 0000]补=[0000 0000]原
这样0
用0000 0000]
表示, 而以前出现问题的-0
则不存在了,而且可以用[1000 0000]
表示-128
:
(-1) + (-127) = [1000 0001]原 + [1111 1111]原 = [1111 1111]补 + [1000 0001]补 = [1000 0000]补
综上所述:
反码:简化了计算机计算二进制加减的判断流程(将符号位直接参与运算不再会像原码那些可能存在错误结果的情况),降低了运算的复杂性,但是依旧存在零表示不唯一的问题。
补码:在反码的基础上,解决了零表示不唯一的问题,还增加了一个负数的一个表示范围(计算机字长为8位的原码或反码的表示范围为[-127, +127]
,而补码[-128, 127]
)。
6.2 位运算
运算符 | 描述 | 运算规则 |
---|---|---|
& | 按位与 | 两位都为1时,结果为1 |
| | 按位或 | 两位有至少一位为1时结果为1 |
~ | 按位非(取反) | 0变1,1变0 |
^ | 按位异或 | 两个位相同结果为1,相异为0(同1异0) |
<< | 左移 | 左移若干位,高位丢弃,低位补0 |
>> | 右移 | 右移若干位,正数:左补0右丢弃,负数:左补1,右丢弃 |
7. 什么是DOM和BOM?
DOM:指的是文档对象模型,它是载入到浏览器中的文档模型,以节点树的形式来呈现文档,每个节点代表文档的构成部分,它提供各种HTML
标签以及各种标签交互的API
,如它的核心document
。由于window
包含了document
,换个角度讲,可以理解为BOM
提供了DOM
。
BOM:指的是浏览器对象模型,它把浏览器当成一个对象,这个对象提供了浏览器各种信息以及各种交互方法,BOM
的核心是window
,大多数方法都在window
中,其中包括:
window.screen
对象:包含用户屏幕的信息等。window.location
对象:当前页面的地址信息,并提供浏览器重定向到新的页面的api等。window.history
对象:提供浏览历史的前进后退api等。window.navigator
对象:获取浏览器信息、是否移动端访问等。JavaScript
消息框:alert()
等。JavaScript
计时:setTimeout()
等
另外,window
还是一个全局对象,代码中定义的任何对象,变量和函数都作为全局对象中的一个属性或方法存在,并且DOM
的核心对象document
就属于window
的一个子对象。
8. 详细讲讲encodeURI和encodeURIComponent以及它们的区别
8.1 URI介绍
URI
指的是统一资源标识符(Uniform Resource Locator),它是用来标识和定位互联网上的资源(如网页、图片、文档等)的一种标识方式,它是一个广义的概念,如我们常用的URL
就属于URI
,URL
能够定位给到互联网上的某个资源的位置。它主要有两个子集:
-
URL
:用于标识和定位互联网上的资源的位置。它包括了资源的地址和访问方式,以确保资源能正常被定位和检索。通常由以下6部分组成。- 协议
protocol
:指定了资源的访问方式,常见的协议包括HTTP
、HTTPS
、FTP
、mailto
、file
等。 - 域名(也叫主机名)
Host
:指定了资源所在的服务器或计算机的域名或IP
地址。 - 端口
Post
:端口是可选的,它指定了服务器上用于处理请求的端口号。如果未指定端口,通常会使用默认端口,如 HTTP 的默认端口是 80。 - 路径
Path
:指定了服务器上资源的位置,通常是一个文件路径或目录路径。路径以斜杠/
开头,如/images/pic.jpg
。 - 查询参数
Query
:查询参数允许传递额外的信息给服务器,通常以?
开头,参数之间用&
分隔,如?name=John&age=30
。 - 片段标识符(在
location
对象中是hash
)Fragment
:用于指定资源中的特定位置,如文档内的锚点。
示例
URL
:https://www.example.com:8080/images/pic.jpg?name=John&age=30#section2
- 协议
-
URN
:用于标识资源的名称,而不关心资源的位置或如何访问它。URN 的目的是提供一个唯一的、永久的资源标识符。例如,ISBN(国际标准书号)就是一种 URN,它用于唯一标识图书,而不考虑图书的存储位置或如何获取它。
8.2 什么是encodeURI
encodeURI()
是JavaScript的一个内置函数,用于将字符串中的特殊字符进行编码,以便能够在URL中传递。
其中encodeURI
不会编码的特殊字符包括:【主要就是url
常见字符】
类型 | 包含 |
---|---|
非转义字符 | A-Z、a-z、0-9、_ 、. 、- 、! 、~ 、' 、( 、) |
保留字符 | : 、/ 、? 、= 、& (前面这些属于url 常见字符)、; 、, 、@ 、+ 、$ |
数字符号 | # (这个也属于url 常见字符) |
语法:encodeURI(URI)
:URI
是一个字符串,返回一个新字符串。
8.3 什么是encodeURIComponent
encodeURIComponent()
也是JavaScript的一个内置函数,同样用于将字符串中的特殊字符进行编码,以便能够在URL中传递。与encodeURI()
相比它会编码更多的字符。
它不会编码的特殊字符只有:
类型 | 包含 |
---|---|
非转义字符 | A-Z、a-z、0-9、_ 、. 、- 、! 、~ 、' 、( 、) |
也就是说encodeURIComponent()
会对url
常见字符进行编码。
语法:encodeURIComponent(uriComponent)
:uriComponent
是一个string、number、boolean、null,undefined
或者任何 object
。在编码之前,uriComponent
参数会被转化为字符串。返回新字符串。
下面我们通过代码对比encodeURI
:
let url = "https://www.aaa.com/path?name=zhangsan&age=18#fragment1";
// 输出:https://www.aaa.com/path?name=zhangsan&age=18#fragment1
console.log(encodeURI(url));
// 输出:https%3A%2F%2Fwww.aaa.com%2Fpath%3Fname%3Dzhangsan%26age%3D18%23fragment1
console.log(encodeURIComponent(url));
可以明显看到encodeURIComponent
对ur
l常见字符进行了编码。
8.4 应用场景
1 输入内容编码
假设我们有一个搜索功能,用户可以输入关键字进行搜索。用户输入的关键字可能包含特殊字符,如空格、问号、和号等。为了将关键字作为 URL 参数传递给服务器,我们需要使用 encodeURIComponent
对其进行编码。
const userInput = "JavaScript 2a+ 学习?";
// 编码用户输入
const encodedKeyword = encodeURIComponent(userInput);
// 构建 URL
const searchURL = `https://example.com/search?keyword=${encodedKeyword}`;
console.log(searchURL); // https://example.com/search?keyword=JavaScript%202a%2B%20%E5%AD%A6%E4%B9%A0%3F
2 Ajax请求
在使用 JavaScript 进行 AJAX 请求时,encodeURIComponent
也非常有用。当使用 fetch
或 XMLHttpRequest
发送数据时,特别是发送 POST 请求时,需要确保请求体中的数据是经过编码的。
let data = {
username: "John Doe",
email: "john.doe@example.com",
};
// 将 JavaScript 对象转换为 URL 编码的字符串
const encodedData = Object.keys(data)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
.join("&");
console.log(encodedData); // username=John%20Doe&email=john.doe%40example.com
fetch("https://example.com/submit", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: encodedData,
});
8.5 URI解码
使用decodeURI
和decodeURIComponent
可以解码
// 未编码的str: username=John Doe&email=john.doe@example.com
// 输出:username=John Doe&email=john.doe%40example.com
console.log(decodeURI('username=John%20Doe&email=john.doe%40example.com'));
// 输出:username=John Doe&email=john.doe@example.com
console.log(decodeURIComponent('username=John%20Doe&email=john.doe%40example.com'));
可以看到由于encodeURI
不能编码保留字符@
,因此decodeURI
也不能将@
对应的编码%40
转回@
。
8.6 扩展:内置对象URL
这里扩展一下内置对象URL。
我们可以使用 new URL() 构造函数创建 URL 对象并访问其属性方法,类似于location
,URL 对象还提供了一些方法,例如searchParams
属性可以访问查询字符串参数等。
const url = new URL("https://www.aaa.com/path?name=zhangsan&age=18#fragment1");
console.log(url.protocol); // https: 协议
console.log(url.host); // www.aaa.com 域名
console.log(url.port); // 端口
console.log(url.pathname); // /path 路径
console.log(url.search); // ?name=zhangsan&age=18 query
console.log(url.hash); // #fragment1 hash
console.log(url.origin); // https://www.aaa.com origin
// 获取参数
console.log(url.searchParams.get("name")); // zhangsan
// 下面这种形式也能获取参数
const params = new URLSearchParams(url.search)
console.log(params.get('name')) // zhangsan
9. 详细讲讲Base64编码和解码
由于 Base64
编码后的字符串只包含 ASCII
字符,因此可以安全地传输或存储到不支持二进制数据的地方(如URL、XML等),以避免出现数据传输或存储时的格式问题。
toa()
和 atob()
是 JavaScript 内置的用于 Base64
编码和解码的方法。
9.1 ASCII字符
btoa()
方法用于将字符串转换为 Base64
编码。它接受一个字符串作为参数,返回一个 Base64
编码的字符串。例如:
ASCII字符编码为Base64
const str = 'Hello world'
const encodedStr = btoa(str)
console.log(encodedStr) // SGVsbG8gd29ybGQ=
atob()
方法用于将Base64
的字符串转换为原始字符串,如下:
解码
const encodedStr = 'SGVsbG8gd29ybGQ='
const str = atob(encodedStr)
console.log(str) // Hello world
9.2 非ASCII字符
需要注意的是,btoa()
和 atob()
方法只能处理 ASCII
字符串(包含 128 个字符,其中包括英文字母、数字、标点符号和一些控制字符),如果字符串中包含非 ASCII
字符(如中文文字),需要先将其转换为UTF-8
编码的字节数组,再进行 Base64
编码。例如:
非ASCII字符编码Base64
const str = "你好 世界";
// TextEncoder构造函数接受码位流作为输入,并提供 UTF-8 字节流作为输出
const utf8Bytes = new TextEncoder().encode(str); // 转成utf8编码的字节数组
const encodedStr = btoa(String.fromCharCode(...utf8Bytes)); // 编码为base64编码的ASCII字符串
console.log(encodedStr); // 5L2g5aW9IOS4lueVjA==
解码
const decodedBytes = atob('5L2g5aW9IOS4lueVjA==').split('').map(char => char.charCodeAt(0)) // 转成utf8编码的字节数组
// TextDecoder接受一个ArrayBuffer、TypedArray或包含要解码的编码文本的对象,返回解码后的字符串。
const decodedStr = new TextDecoder().decode(new Uint8Array(decodedBytes)) //
console.log(decodedStr) // 你好 世界
应用场景:
在前端开发中,经常需要将图片或音频等二进制数据转换为 Base64 编码的字符串,以便在网页中直接显示或传输。
下面代码实现了将图片资源转成base64
格式。
async function mediaToBase64(filePath) {
// 将图片资源转成blob格式
const blob = await fetch(filePath).then((res) => res.blob());
// 创建一个Promise对象,将Base64编码后的图片数据存储在变量base64中
const base64 = await new Promise((resolve) => {
// 创建一个FileReader对象,用于将Blob对象中的数据转换为Base64编码的字符串
const reader = new FileReader();
// 当FileReader对象读取完成时触发onload事件
reader.onload = () => {
console.log(reader.result); // data:image/png;base64,base64字符串....
// 将DataURL中的Base64编码字符串取出,并将其存储在变量base64中
resolve(reader.result.split(",")[1]);
};
// 将Blob对象中的数据读取为DataURL
reader.readAsDataURL(blob);
});
return base64; // 返回的是一个promise对象 所以接受结果的时候需要.then拿到res
}
mediaToBase64("./img.png").then((res) => {
console.log(res); // base64字符串....
});
要注意的是,由于 Base64
编码后的字符串通常比原始二进制数据大约33%,因此Base64
格式只适合传输小量数据。
10. 谈谈你对Ajax的理解,实现一个Ajax请求
Ajax
是Asynchronous JavaScript and XML
的缩写,指的是:**通过JavaScript的异步通信向服务器发送异步请求,获取XML
格式的数据,再更新网页的对应部分,而不用刷新整个网页。**这也是它的最大优势,无刷新获取数据。
注意:XML
是可扩展标记语言,已经被JSON取代了。
Ajax
的缺点:
- SEO(搜索引擎优化,search engin optimization)不友好,因为ajax请求得到的数据是动态数据,不是静态数据,源代码中只包含静态数据,所以爬不到这样的数据,对爬虫不友好。
- 存在跨域问题。
- 没有浏览历史,不能回退。
html页面中实现一个
:【js文件中实现的话,要想在浏览器环境下不报错要搭配打包工具,node环境下不需要】
<script>
function sendAjax () {
// 1. 创建xhr对象
const xhr = new XMLHttpRequest()
// 2. 初始化 设置请求方法和url
xhr.open('GET', 'http://127.0.0.1:8000/server')
// 设置请求头
// xhr.responseType = 'json'
// xhr.setRequestHeader('Accept', 'application/json')
// 3. 发送
xhr.send()
// 4. 开启监听
// 0-未初始化 1-open()方法调用完毕 2-send()方法调用完毕 3-服务端返回部分结果 4-服务端返回全部结果
// 开启状态监听
xhr.onreadystatechange = function () {
// 判断(服务端返回了所有的结果)
if (xhr.readyState === 4) {
// 判断响应状态码 200 404 403 401 500
// 2xx 都表示成功
if (xhr.status >= 200 && xhr.status < 300) {
console.log(xhr.status); // 状态码
console.log(xhr.getAllResponseHeaders()); // 所有响应头
console.log(xhr.response); // 响应体【接口返回结果】
}
}
}
// 开启错误监听
xhr.onerror = function () {
console.log('状态码:' + xhr.status);
}
}
sendAjax()
</script>
步骤总结:
- 创建
XMLHttpRequest
对象xhr
- 配置请求方法和请求
url
,xhr.open(method, url)
。 - 发送请求:
xhr.send()
【3,4步顺序可调换】 - 开启监听:
xhr.onreadystatechange = () => {根据xhr.readyState==4和xhr.status == 200判断}
使用Promise
封装:
<script>
const btn = document.getElementsByTagName('button')[0]
const result = document.getElementById('result')
function sendAjax() {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', 'http://127.0.0.1:8000/server')
xhr.onreadystatechange = function () {
if (this.readyState == 4) {
if (this.status >= 200 && this.status < 300) {
resolve(this.response)
} else {
reject(this.status)
}
}
}
xhr.send()
})
}
sendAjax().then(res => {
console.log(res);
})
</script>
11. 什么是尾调用(版本的眼泪)?尾调用的好处是什么?
尾调用指的是:在函数的最后一步return
调用一个函数。
就比如说一个非尾调用的递归函数add
,代码是这样的:
function add(n) {
if(n == 1) return 1
return n + add(n - 1)
}
console.log(add(5)) // 15
console.log(add(10000)) // Maximum call stack size exceeded
而尾调用的写法大概是这样的:
function addTail(n, sum) {
if(n == 1) return sum + n
return addTail(n - 1, sum + n)
}
console.log(addTail(5, 0)) // 15
console.log(addTail(10000, 0)) // Maximum call stack size exceeded【v8和node环境已弃用尾调用】
函数调用时会在内存中生成一个调用栈,非尾调用和尾调用在栈中的执行过程是完全不同的。
非尾调用:函数按调用顺序会依次入栈,然后从栈顶依次执行,也就是说,如果递归足够的深,也就存在超过最大调用堆栈的大小,而导致函数无法运行。
由此可得调用add(5)
入栈过程:5+add(4) 4+add(3) 3+add(2) 2+add(1) add(1)
的顺序入栈。然后从add(1)
依次执行。
尾调用:函数将首个调用的函数入栈,然后递归调用另一个函数时,会将先前入栈的函数出栈,将新调用的函数入栈,也就是说,即使无限递归调用,也不会超出最大调用堆栈的大小。
由此可得上述addTail(5)
的入栈过程为:addTail(4, 5)
入、出栈,addTail(3, 9)
入、出栈,addTail(2, 12)
入、出栈,addTail(1, 14)
入、出栈。
因此可以总结:尾调用可以极大程度上节省在函数调用时的内存。
需要注意的是:只有在严格模式下,才能开启尾调用模式【经测试,v8和node环境已不再支持尾调用】,所以我们可以封装一个蹦床函数来变相实现尾调用。
蹦床函数
function trampoline(f) {
while (f && f instanceof Function) {
f = f();
}
return f;
}
原本的尾调用函数也要做修改,如下:
function addTail(n, sum) {
if (n == 1) return n + sum;
// return addTail(n - 1, n + sum);
return addTail.bind(null, n - 1, n + sum); // bind
}
console.log(trampoline(addTail(10000, 0))); // 输出 50005000
12. ES6(ESM)模块与CommonJS模块有什么异同?
背景:早期JavaScript
模块这一概念,都是通过script
标签引入js
文件代码。当然这写基本简单需求没有什么问题,但当我们的项目越来越庞大时,我们引入的js
文件就会越多,这时就会出现以下问题:
- js文件作用域都是顶层,这会造成变量污染
- js文件多,变得不好维护
- js文件依赖问题,稍微不注意顺序引入错,代码全报错
为了解决上述问题,先是出现CommonJS
,而后在ES6
版本正式加入ESM
。
同:
- 都解决了变量污染问题,如
CommonJS
使用一个变量接收require
的内容,ESM
使用import 自定义名
接受export default {}
的内容 - 都解决代码维护麻烦问题,一个文件里的代码清晰
- 都解决了文件依赖问题,一个文件可以清楚的看到依赖了哪些文件
不同:
- 语法不同
- 导出:
CommonJS
:module.exports.name = value
或exports.name = value
;ESM
:export const name = value
或export default {}
- 导入:
CommonJs
:const name = require('url')
;ESM
:import {name} from 'url'
或import data from 'url'
- 导出:
- 导入的非引用类型值是否可修改:
CommonJS
导入的非引用类型值可修改,ESM
不可修改(只读的)。 - 是否支持树摇(删除未使用的代码(即未引用的模块、变量、函数等)):
CommonJS
不支持树摇,ESM
支持树摇。 - 加载过程:
CommonJS
基于运行时的同步加载,ESM
是基于编译时的异步加载。
13. 创建的DOM操作有哪些
-
DOM节点的获取
getElementById
:按id查询 返回单个html元素,注意因为element后面没加sgetElementsByClassName
:按类名查询,返回html集合【伪数组】,有sgetElementsByTagName
:按标签名查询,返回html集合【伪数组】,有squerySelector
:按css选择器查询,返回单个节点。querySelectorAll
:按css选择器查询,返回节点列表【伪数组】。
-
DOM节点的创建:
createElement('tagName')
、parentNode.appendChild(newNode)
<div id="container"></div> <script> let container = document.getElementById('container') // 创建 let targetNode = document.createElement('span') // 设置内容 targetNode.innerHTML = 'hello world' // 插入 container.appendChild(targetNode) </script>
-
DOM节点的删除:
parentNode.removeChild(targetNode)
-
DOM节点的修改:
targetNode.style.color = 'red'
14. 'use strict’是什么意思?使用它区别是什么?
'use strict'
是ES5新增的严格运行模式,即严格模式,这种模式使得JavaScript在更严格的条件下运行。它的好处是:
- 消除JavaScript语法的不合理、不严谨之处、减少怪异行为。
- 消除代码运行的不安全之处,保证代码运行的安全。
- 提高编译器效率,增加运行速度。
- 为未来新版本的JavaScript做好铺垫。
开启严格模式的方法:
- 为整个脚本开启严格模式:在脚本文件首行加
'use strict'
- 为部分脚本开启严格模式:为部分脚本开启,在要开启部分前面加
'use strict'
- 为函数开启严格模式:函数内首句加
'use strict'
它的主要区别包括:
-
全局this指向:非严格模式下指向全局对象
window
的this
将变成undefined
-
变量声明:变量未声明会报错,非严格模式下是不会报错的,并且会将属性挂载到
window
上。"use strict"; nickname = "张三"; // ReferenceError: nickname is not defined console.log(nickname);
-
对象属性重名:严格模式下,对象内的属性重名会报错,需要注意的是ES6删除了对重名属性的这个限制,即在严格模式下重复的对象字面量属性键不会抛出错误。
-
函数参数:严格模式下,重名参数会报错。
"use strict"; function fun(x, x) { // SyntaxError: Duplicate parameter name not allowed in this context console.log(this); } fun(1, 2);
-
arguments对象:在非严格模式下,修改函数命名参数的值
arguements
对象内对应的值也会修改,但是在严格模式下,arguments
对象相对命名参数是独立的,并不会被修改。function showValue(value) { 'use strict' value = "Foo"; console.log(value); // "Foo" console.log(arguments[0]); // 非严格模式:"Foo" // 严格模式:"Hi" } showValue("Hi");
-
with语句:严格模式下不允许使用
with
语句。'use strict' const obj = { name: "张三", age: 25, }; with (obj) { // SyntaxError: Strict mode code may not include a with statement console.log(name, age); // 张三 25 }
这里扩展一下
with
语句。官方解释是:with
语句扩展一个语句的作用域链【弊大于利,已被弃用】。因此上面的代码如果不是在严格模式下运行的话,会输出
张三 25
。
15. 如何判断一个对象是否属于某个类?
- 方式一:
instanceof
,它会判断此对象的原型链上是否存在该类的原型。 - 方式二:
obj.constructor
,对象的constructor
属性指向对象的构造函数,这种方法不安全,因为constructor
属性可以被改写。 - 方式三:
Object.prototype.toString.call(obj)
打印Class
属性来进行判断。
16. 强类型语言和弱类型语言的区别
强类型语言:是一种总是强制类型定义的语言,要求变量的使用必须严格符合定义,如Java,C++都是强类型语言,一旦变量被指定了某个数据类型,如果不经过强制转换,那么它永远都是这个数据类型。
弱类型语言:如JavaScript,是一种比那里类型可以被忽略的语言。如字符串'12'
和整数3
进行连接得到字符串123
,在相加的过程会自动进行强制类型转换。
对比:强的速度上略逊色于弱,但是强更加严谨,可以有效的避免很多错误。
17. 解释性语言和编译型语言的区别
**编译型语言:**需要通过编译器(compiler)将源代码编译成机器码之后才能执行的语言。
一般需要编译(compiler)、链接(linker)这两个步骤。
编译是把源代码编译成机器码,链接是把各个模块的机器码和依赖库串连起来生成可执行文件。
优点:因为提前编译了,所以运行时不需要编译因此,编译型语言的程序执行效率高,可以脱离语言环境独立运行。另外,编译器一般会有预编译的过程对代码进行优化。
缺点:编译之后如果修改了代码就需要重新编译整个模块,影响了效率。另外不同操作系统之间移植会比较麻烦,因为需要根据运行的操作系统环境编译不同的可执行文件(编译的时候会根据运行环境生成对应机器码)。
解释型语言:解释型语言的程序不需要提前编译,相比编译型语言少了一道工序,它是在运行程序的时候逐行编译成机器码。
优点:有良好的平台兼容性,在任何环境中都可以运行,前提是装了解释器(虚拟机),另外修改代码后可以快速部署,不用像编译型语言一样重新整体编译。
缺点:每次运行时都要重新解释一遍,性能上不如编译型语言。
因此可以总结如下:
区别 | 编译型语言 | 解释型语言 |
---|---|---|
过程 | 先编译一遍,运行时不同编译 | 不用提取编译,运行时逐行编译 |
性能 | 比较高 | 比较低,因此每次运行都需要逐行编译 |
可移植性 | 比较差,因为需要根据os环境编译不同的可执行文件 | 比较好,任何环境可运行,只要装了解释器 |
18. for…in和for…of的区别
for...of
:遍历可迭代对象(含有Iterator的数据结构),包括String
、Map
、Arguments
、Set
、Array
等。
for...in
:以任意顺序迭代一个对象的除Symbo以外的可枚举属性,包括继承的可枚举属性,一般用来遍历对象。
关于for...of
的注意点:
-
不能用来遍历
Object
类型,会报错,因为对象不是含有Iterator
的数据结构。 -
每次迭代返回的变量是可迭代对象的值,但是需要注意的是
Map
返回的是一维数组(可以类比创建map
传的的二维数组)。通常通过数组解构的方法拿到键和值,也可以通过map.keys()
和map.values()
拿到键和值,看代码:let map = new Map([ ["a", 1], ["b", 2], ["c", 3], ]); for (let entry of map) { // 返回的变量一维数组 console.log(entry); // ["a", 1] ["b", 2] ["c", 3] } // 可以通过数组解构拿到键和值 for (let [key, value] of map) { console.log(key, value); // a 1 b 2 c 3 } // 也可以通过map.keys()和map.values()拿到键和值 for (let key of map.keys()) { console.log(key); // a b c } for (let value of map.values()) { console.log(value); // 1 2 3 }
关于for…in的注意点:
for...in
是为了遍历对象的属性而生,虽然也能遍历数组,但是不建议使用,因为它会遍历数组对象的整个原型链,性能比较差,而for...of
和Array.forEach()
并不会。for...in
遍历返回的变量是一个键,如果是数组的话,对应的是索引。for...in
最常用的地方是用于调试,可以更方便的去检查对象属性(通过输出到控制台或其他方式)。需要检查其中的任何键是否为某值的情况时,还是推荐用for ... in
。
let arr = [1, 2, 3, 4, 5];
for (let index in arr) { // index变量是索引
console.log(index); // 0 1 2 3 4
}
let obj = { a: 1, b: 2, c: 3 };
for (let key in obj) { // key变量是键
console.log(key); // a b c
}
下面总结一下for...of
和for...in
的区别:
of
遍历可迭代对象,in
一般遍历Object
,常用于调试,检查对象某个属性。of
性能比较高,不会遍历整个原型链,in
效率很低,因为会遍历整个原型链。of
每次循环返回的变量是value
,in
返回的是key
。
19. 如何使用for…of遍历对象?
给对象添加一个[Symbol.iterator]
属性,并指向一个迭代器。
实现方法一:
let obj = { a: 1, b: 2, c: 3 };
obj[Symbol.iterator] = function () {
let keys = Object.keys(this); // 键数组
let count = 0;
return {
next() {
// 注意 这里的this指向的是next() 因此下面用obj而不是this
if (count < keys.length) {
return { value: obj[keys[count++]], done: false };
} else {
return { value: undefined, done: true };
}
},
};
};
for (let value of obj) {
console.log(value); // 1 2 3
}
实现方法二:Generator
生成器函数实现一个迭代器
obj[Symbol.iterator] = function* () {
let keys = Object.keys(this);
for (let k of keys) {
yield this[k];
}
};
for (let value of obj) {
console.log(value); // 1 2 3
}
20. 了解Generator生成器吗?详细讲讲
Generator
对象是隐藏类 [Iterator
(en-US)] 的子类。,并且它符合可迭代协议和迭代器协议。因此它可以看做是迭代器。Generator
对象并不是全局可用的,Generator
的实例必须从生成器函数返回。下面我们详细讲讲生成器函数。
Generator
函数是ES6引入的,主要用于异步编程,它的最大特点是可以交出函数的执行权,即可暂停函数执行。
它和普通函数的写法不太一样,主要有两个不同:
function
关键字和函数名之间有一个*
,以示区分普通函数和Generator
函数。Generator
函数内部可以使用yield
语句,这也就意味着yield
只能在Generator
函数中使用,其他函数中不可使用。
yield
语句是做什么的呢?
它用于定义不同的内部状态,状态其实就是数据,内部的状态就是函数内部的值,它在不同的时候是不一样的。本质上,整个Generator
函数就是一个封装的异步任务,而yield
命令是异步不同阶段的分界线,所以说,yield
跟return
有点相似,但本质上差别其实是特别大的。讲解完next()
方法后我们会通过代码来理解以上文字。
next()
方法是做什么的?
既然yield
语句划分了异步任务不同阶段的状态,那么如何才能拿到异步任务执行到不同阶段的状态呢?我们需要使用.next()
方法,它是Generator
构造函数的实例方法,它会返回一个包含属性value
和属性done
的对象,你也可以通过向next
方法传一个参数来向生成器传一个值。
这里讲一下这两个属性:
value
:其实就是字面意思值。done
:表示当前函数是否执行完了,true
表示函数已经执行完了,false
表示函数还没执行完。
下面我们通过代码来理解Generator
函数、yield
语句以及.next()
方法。
function* fun() {
yield 1;
yield 2;
yield 3;
return 4;
}
// 这里新建一个fun实例,跟new一个实例其实是一个道理 每个实例都是相互独立的。
let f = fun()
console.log(f.next()); // { value: 1, done: false }
console.log(f.next()); // { value: 2, done: false }
console.log(f.next()); // { value: 3, done: false }
console.log(f.next()); // { value: 4, done: true }
console.log(f.next()); // { value: undefined, done: true }
如果把生成器函数的实例比作一个录音机,yield
语句其实就像录音机上的暂停(pause)键,而next()
方法就像是录音机的继续(play)键。
可以通过向next
方法传一个参数来向生成器传一个值,这个比较难理解,我们通过代码来理解:
function* fun() {
let a = 1;
let b = yield a + 1000;
yield b;
yield ++a;
yield ++a;
}
// 这里新建一个fun实例,跟new一个实例其实是一个道理 每个实例都是相互独立的。
let f = fun();
// 第1个next使函数执行到第2个yield就结束了 因此返回 a + 1000 = 10001
console.log(f.next()); // { value: 1001, done: false }
// 第2个next传入了参数,参数会覆盖上一个yield语句的返回值,因此第2个yield返回的b为传入的参数abc
console.log(f.next('abc')); // { value: 'abc', done: false }
console.log(f.next()); // { value: 2, done: false }
console.log(f.next()); // { value: 3, done: false }
console.log(f.next()); // { value: undefined, done: true }
21. 详细说说ajax、fetch、axios的区别?
三个都用来发送请求。
下面我先用大白话讲解一下这三个东西
ajax:英译过来是Aysnchronous JavaScript And XML
,直译是异步JS
和XML
(XML
类似HTML
,但是设计宗旨就为了传输数据,现已被JSON
代替),解释一下就是说以XML
作为数据传输格式发送JS异步
请求。但实际上ajax是一个一类技术的统称的术语,包括XMLHttpRequest
、JS
、CSS
、DOM
等,它主要实现网页拿到请求数据后不用刷新整个页面也能呈现最新的数据。
下面我们简单封装一个ajax
请求:
const ajaxGet = function (url) {
const xhr = new XMLHttpRequest()
xhr.open('get', url)
xhr.onreadystatechange = () => {
if (xhr.readyState == 4) {
if (xhr.status >= 200 && xhr.status < 400) {
console.log(xhr.response); // 响应结果
}
}
}
xhr.onerror = (error) => {
console.log(error, xhr.status)
}
xhr.send()
}
fetch:它其实就是一个**JS
自带的发送请求的一个api
,拿来跟ajax
对比是完全不合理的,它们完全不是一个概念的东西,适合拿来和fetch
对比的其实是xhr
,也就是上面封装ajax
请求的代码里的XMLHttpRequest
,这两都是JS
自带的发请求的方法,而fetch
是ES6
出现的,自然功能比xhr更强,主要原因就是它是基于Promise
的,它返回一个Promise
,因此可以使用.then(res => )
的方式链式处理请求结果,这不仅提高了代码的可读性,还避免了回调地狱**(xhr
通过xhr.onreadystatechange= () => {}
这样回调的方式监控请求状态,要是想在请求后再发送请求就要在回调函数内再发送请求,这样容易出现回调地狱)的问题。而且**JS
自带,语法也非常简洁,几行代码就能发起一个请求,用起来很方便**,据说大佬都爱用。
它的特点是:
- 使用 promise,不使用回调函数。
- 采用模块化设计,比如 rep、res 等对象分散开来,比较友好。
- 通过数据流对象处理数据,可以提高网站性能。
下面我们简单写个fetch请求的示例:
// get请求
fetch('http://127.0.0.1:8000/get')
.then(res => {
if (!res.ok) {
throw new Error('请求错误!状态码为:', res.status)
}
return res.text()
}).then(data => {
console.log(data);
})
// post请求
fetch('http://127.0.0.1:8000/post', {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
mode: 'no-cors', // 设置cors表示只能发送跨域的请求,no-cors表示跨不跨域都能发
body: JSON.stringify({
name: 'zhangsan',
age: 18
})
}).then(res => {
return res.json()
}).then(data => {
console.log(data);
})
axios:axios是用于网络请求的第三方库,它是一个库。axios利用xhr进行了二次封装的请求库,xhr只是axios中的其中一个请求适配器,axios在nodejs端还有个http的请求适配器;axios = xhr + http;它返回一个Promise
。【项目中比较场景封装的axios】
它的特点:
- 在浏览器环境中创建 XMLHttpRequests;在node.js环境创建 http 请求
- 返回Promise
- 拦截请求和响应
- 自动转换 JSON 数据
- 转换请求数据和响应数据
- 取消请求
它的基础语法是:
// 发送 Get 请求
axios({
method: 'get',
url: '',
params: {} // 查询query使用params
})
// 发送 Post 请求
axios({
method: 'post',
url: '',
data: {} // 请求体body用data
})
下面我们在vue项目中封装一个使用axios实现的请求。
libs/config.js
:配置文件
const serverConfig = {
baseUrl: "http://127.0.0.1:8000", // 请求基础地址,可根据环境自定义
useTokenAuthentication: false, // 是否开启token认证
};
export default serverConfig;
libs/request.js
:封装请求
import axios from "axios"; // 第三方库 需要安装
import serverConfig from "./config";
// 创建axios实例
const apiClient = axios.create({
baseURL: serverConfig.baseUrl, // 基础请求地址
withCredentials: false, // 跨域请求是否需要携带cookie
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
timeout: 10000, // 请求超时时间
});
// 请求拦截
apiClient.interceptors.request.use(
(config) => {
// 请求发送前的处理逻辑 比如token认证,设置各种请求头啥的
// 如果开启token认证
if (serverConfig.useTokenAuthentication) {
// 请求头携带token
config.headers.Authorization = localStorage.getItem("token");
}
return config;
},
(error) => {
// 请求发送失败的处理逻辑
return Promise.reject(error);
}
);
// 响应拦截
apiClient.interceptors.response.use(
(response) => {
// 响应数据处理逻辑,比如判断token是否过期等等
// 代码块
return response;
},
(error) => {
// 响应数据失败的处理逻辑
let message = "";
if (error && error.response) {
switch (error.response.status) {
case 302:
message = "接口重定向了!";
break;
case 400:
message = "参数不正确!";
break;
case 401:
message = "您未登录,或者登录已经超时,请先登录!";
break;
case 403:
message = "您没有权限操作!";
break;
case 404:
message = `请求地址出错: ${error.response.config.url}`;
break;
case 408:
message = "请求超时!";
break;
case 409:
message = "系统已存在相同数据!";
break;
case 500:
message = "服务器内部错误!";
break;
case 501:
message = "服务未实现!";
break;
case 502:
message = "网关错误!";
break;
case 503:
message = "服务不可用!";
break;
case 504:
message = "服务暂时无法访问,请稍后再试!";
break;
case 505:
message = "HTTP 版本不受支持!";
break;
default:
message = "异常问题,请联系管理员!";
break;
}
}
return Promise.reject(message);
}
);
export default apiClient;
/api/index.js
:配置请求接口,这里一个get一个post
import apiClient from "@/libs/request";
let getInfo = (params) => {
return apiClient({
url: "/get",
method: "get",
params, // axios的get请求query用params
});
};
let postInfo = (params) => {
return apiClient({
url: "/post",
method: "post",
data: params, // axios的post请求body用data
});
};
export default {
getInfo,
postInfo,
};
App.vue
:用于测试请求结果
<script>
import api from './api/index.js'
export default {
data() {
return {
isH5: true
}
},
created() {
this.init()
},
methods: {
init() {
api.getInfo().then(res => {
console.log(res.data);
})
api.postInfo({
name: 'zhangsan',
age: '18'
}).then(res => {
console.log(res.data);
})
}
},
}
</script>
结果如下:
总结一部分区别如下:【这三个东西差别真的很大】
Ajax | fetch | axios | |
---|---|---|---|
类型 | 术语,技术的统称 | js内置的api | 第三方库 |
是否使用xhr二次封装 | 是 | 否 | 是 |
是否返回Promise | 否 | 是 | 是 |
三、原型和原型链
1. 谈谈你对原型、原型链的理解
原型:JS
通过使用构造函数来新建一个对象,每个构造函数都有一个prototype
属性,这个属性指向另一个对象,我们通常称它为原型对象,原型对象上的属性和方法都会被构造函数所拥有,也就是说我们把那些不变的方法,直接定义在prototype
原型对象上,这样所有对象的实例都能共享这些属性和方法了。而实例对象都会有一个属性__proto__
,它是一个指针,指向构造函数的prototype
原型对象,也就是因为有__proto__
指针的存在,实例对象才能共享原型对象的属性和方法。
简单来说,原型就是提供所有实例对象共享属性和方法的方式。
需要注意的是,不建议使用__proto__
来获取对象的原型,通常使用ES5新增的方法Object.getPrototypeOf()
方法获取对象的原型。
原型链:原型链是**JS
实现继承的一种机制,每个对象都有一个原型,原型又是一个对象,它包含了一些共享的属性和方法,当访问一个对象的属性和方法时**:
- 在改对象本身查找,有就使用,没有就下一步
- 去原型对象中查找,有就使用,没有就继续查找原型对象的原型
- 直到查找到
Object.prototype
,如果还没找到,那就表示没有这个属性和方法,因为再查找下去到达原型链的尽头null
了。
需要注意的是:Funtion、Object、Array
这些构造函数的__proto__
指针指向的都是Function.prototype
,因为这些构造函数都可以看做是Function
的实例对象。
而Function
的原型对象Function.prototype
的__proto__
指针指向的是Object.prototype
,也就是原型链的顶层(不考虑null的情况下)。
2. 为什么重写构造函数原型后需要将constructor
重新指向构造函数?
function Person(name){
this.name = name
}
// 重写原型
Person.prototype = {
getName: function() {}
}
let p = new Person('zhangsan')
console.log(p.__proto__ === Person.prototype) // true
console.log(p.constructor.prototype === Person.prototype) // false
console.log(p.constructor.prototype === Object.prototype) // true
可以看到重写原型后新建的p
的构造函数不再指向Person
了,因为直接将Person
的原型用对象赋值后,它的构造函数指向对象了,因此第三个输出为true
,我们需要在重写原型时,将Person
原型的constructor
指向Person
即可,如下代码。
function Person(name){
this.name = name
}
// 重写原型
Person.prototype = {
constructor: Person,
getName: function() {}
}
let p = new Person('zhangsan')
console.log(p.__proto__ === Person.prototype) // true
console.log(p.constructor.prototype === Person.prototype) // true
console.log(p.constructor.prototype === Object.prototype) // false
可以看到p
的构造函数不再指向Object
,重新指向Person
。
3. 如何打印出原型链的重点(null)
console.log(Object.prototype.__proto__) // null
4. 如何获取对象非原型链上的属性
利用obj.hasOwnProperty(key)
写一个方法如下:
function getObjKey(obj) {
let res= []
for(let key in obj) {
if(obj.hasOwnProperty(key)) res.push(key + ':' + obj[key])
}
return obj
}
值得注意的是obj.hasOwnProperty(key)
只会在obj
自身查找是否有key
,不会在原型链上查找,而key in obj
的方式会在原型链中查找,下面看代码:
let o = {
name: 18,
fn: function() {}
}
console.log(getObjKey(o)) // {name: 18, fn: ƒ}
console.log('name' in o) // true
console.log(o.hasOwnProperty('name')) // true
console.log('toString' in o) // true !!!!!!
console.log(o.hasOwnProperty('toString')) // false !!!!!
四、执行上下文/作用域/闭包
1. 变量提升
概念:变量提升是当栈内存作用域形成时,JS代码执行前,浏览器会将带有var, function
关键字的变量提前进行声明 declare(值默认就是 undefined),定义 defined(就是赋值操作),这种预先处理的机制就叫做变量提升机制也叫预定义。
带 var 和不带 var 的区别:
-
全局作用域下:带var和不带var都会给
window
设置一个属性。 -
私有作用域下:带var的是私有变量,
window
上不会设置属性,不带var的会一直向上级作用域找,如果有的话就修改值,直到找到window还没有找到,那就在window上设置一个属性。需要注意的是,比如在函数的私有作用域内,只有函数执行一次后,不带var的变量才会触发修改属性或设置新的属性。
var a = b = 12相当于:
b = 12;
var a = b;
function的变量提升优先级比var更高
详细建议查看博客:彻底解决 JS 变量提升| 一题一图,超详细包教包会
经典面试题
- 字节
let a = 0, b = 0;
function fn(a) {
fn = function fn2(b) {
console.log(a, b)
console.log(++a+b)
}
console.log('a', a++)
}
fn(1);
fn(2);
此题第一次输出的结果是:a 1
,第二次输出结果是:2 2 5
。
首先定义全局变量a
,b
都为0
,和一个全局函数fn
,然后执行fn(1)
,输出局部变量aa 1
后a++
,局部变量a
变为2
,又因为fn2
内存在对局部变量a
的引用,因此fn2
存在闭包从而导致局部变量a
并没有被垃圾回收机制回收,另外在执行fn(1)
过后,全局函数fn
被fn2
所覆盖,因此执行fn(2)
时,局部变量b
被赋值2
,局部变量a
因为闭包的原因也是2
,因此先输出2 2
,第二个输出5
。
- 带var和带function重名条件下的变量提升优先级,函数高于变量
console.log(a);
var a=1;
function a(){
console.log(1);
}
// 或
console.log(a);
function a(){
console.log(1);
}
var a=1;
上面两种情况输出都是ƒ a(){ console.log(1);}
,在 var 和 function
同名的变量提升的条件下,函数会先执行。所以输出的结果都是一样的。换一句话说,var 和 function
的变量同名 var
会先进行变量提升,但是在变量提升阶段,函数声明的变量会覆盖 var
的变量提升,所以直接结果总是函数先执行优先。
- 腾讯
console.log(typeof a)
var a=2;
function a() {
console.log(3);
}
console.log(typeof a);
先输出的是function
后输出的是number
。原理和上题一样。
- 字节
var a = 10;
(function () {
console.log(a)
a = 5
console.log(window.a)
var a = 20;
console.log(a)
})()
var b = {
a,
c: b
}
console.log(b.c);
因为函数作用域内的var
变量会当做局部变量,并且存在变量提升;自执行函数不论匿名还是不匿名都不存在变量提升,因此上面的代码可以改写为:
var a = undefined;
var b = undefined;
a = 10;
(function() {
var a = undefined;
console.log(a); // undefined 因为局部变量提升
a = 5;
console.log(window.a) // 10 全局作用域下的var变量会挂到window下
a = 20;
console.log(a) // 20 输出局部变量a
})() // 自执行函数没有变量提升
b = {
a,
c: b
}
console.log(b.c) // undefined 因为b变量提升因为初始化的时候是undefined
- 某大厂
var a = 1;
function foo(a, b) {
console.log(a); // 1
a = 2;
arguments[0] = 3;
var a;
console.log(a, this.a, b); // 3, 1, undefined
}
foo(a);
这题和上题的区别在于,foo
的形参和局部变量都声明了一个同名为a
的变量,需要注意的是形参声明一次之后不会再次声明,因此,第6行代码可以无视,这样就好理解多了,上面代码可以改写为:
// function foo = undefind
var a = undefined;
a = 1
function foo(a, b) {
console.log(a); // 1
a = 2;
a = 3;
console.log(a, window.a, b); // 3, 1, undefined
}
foo(a);
- 非匿名执行函数的变量提升
var a = 10;
(function a(){
console.log(a); // ƒ a(){ console.log(a); a = 20; console.log(a);}
a = 20;
console.log(a); // ƒ a(){ console.log(a); a = 20; console.log(a);}
})()
console.log(a); // 10
为什么会输出这种意想不到的结果?因为非匿名自执行函数的函数名在自己的作用域内变量提升,且修改函数名的值无效,这是非匿名函数和普通函数的差别,因此第4行的代码可以无视。
2. 闭包
闭包是什么:指的是一种允许函数访问并操作该函数外部变量的一种环境。就比如下面这种情况,内部函数f
存在对外部函数fn
的变量n
的引用。
var n = 10
function fn(){
var n =20
function f() {
n++;
console.log(n)
}
f()
return f
}
var x = fn()
x()
x()
console.log(n)
/* 输出
* 21
22
23
10
/
形成闭包的原因:
在ES5中只存在两种作用域————全局作用域和函数作用域,当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链,值得注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。
因此可以总结原因:外部函数虽然已经执行完毕,但内部函数仍然保留了对外部变量的引用,而这些变量并没有被垃圾回收机制释放。【当前作用域存在指向父级作用域的引用】
这里扩展一下堆栈内存:
- 栈内存:存储基本类型值
- 栈内存的释放:一般当函数执行完后函数的私有作用域就会被释放,但也有特殊情况,如函数执行完,但函数的私有作用域内有内容被栈外的变量还在使用,栈内存就不会释放,里面的值也不会被释放;全局下的栈内存只有在页面关闭时才释放
- 堆内存:存储引用类型的指针
- 堆内存的释放:将引用类型的地址变量赋值为null,或没有变量引用这个地址,浏览器就被垃圾回收机制释放掉该地址。
闭包的作用:
- 保护函数的私有变量不受外加干扰,避免全局污染
- 实现函数内的变量、属性私有化
闭包的使用场景【表现形式】:
- 返回一个函数,上面的代码已举例
- 作为函数参数传递【和1统称为高阶函数】
var a = 0
function fn() {
var a = 1
function fn1() {
console.log(a)
}
return fn1
}
function fn2(params) {
var a = 2
params()
}
fn2(fn()) // 1 fn1函数使用fn的变量a形成闭包
- 只要使用了回调函数,实际上就是在使用闭包,如定时器、事件监听、Ajax请求等,回调函数保存了当前作用域以及
window
的作用域。 - IIFE立即执行函数创建闭包,保存了当前作用域以及
window
作用域。
var a = 0;
(function() {
console.log(a)
})()
- 防抖,节流
// 防抖:多次操作只触发最后一次,如输入框响应式搜索
function debounce(fn, time) {
let timer
return function () {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, arguments)
}, time);
}
}
// 节流:多次操作只触发第一次,如点击登录按钮、获取验证码按钮
function throttle(fn, time) {
let timer
return function () {
if (timer) return
timer = setTimeout(() => {
fn.apply(this, arguments)
timer = null
}, time);
}
}
// 使用方法示例:debounce(fn(2, 3), 1000)
- 柯里化:接受多个参数的函数转化为一系列嵌套的单一参数函数。
function add(x) {
return function(y) {
return x + y;
};
}
const curriedAdd = add(2);
console.log(curriedAdd(3)); // 5
闭包可能存在的问题
- 频繁使用闭包,大量变量无法被垃圾回收机制回收从而导致内存消耗过大【内存泄漏】
如何避免内存泄漏问题?
- 定时器、事件监听等在不需要的时候关闭,如vue中在生命周期
beforeDestroy
清除定时器、移除事件监听。
window.removeEventListener('scroll', this.handleScroll);
clearInterval(this.timer);
- 数据使用结束置为
null
,如vue中beforeDestroy
周期时
beforeDestroy() {
// 在组件销毁之前清理数据
this.data = null; // 将数据设置为null,使其在垃圾回收时可以被释放
},
经典面试题
- var变量循环输出问题:为什么输出全是
5
如何让他输出1 2 3 4 5
for(var i = 1; i <= 5; i ++){
setTimeout(function timer(){
console.log(i) // 5 5 5 5 5
}, 0)
}
- 方法一:立即执行函数实现闭包
for(var i = 1; i <= 5; i ++){
(function (j) {
setTimeout(function timer(){
console.log(j) // 1 2 3 4 5
}, 0)
})(i)
}
- 方法二:使用
let
,let
具有块级作用域,形成的5
个私有作用域都是互不干扰的。
for(let i = 1; i <= 5; i ++){
setTimeout(function timer(){
console.log(i) // 1 2 3 4 5
}, 0)
}
- 方法三:给定时器传入第三个参数,定时器会将第三个以及后面的参数都作为第一个参数的传的函数的参数。
for(var i = 1; i <= 5; i ++){
setTimeout(function(i){
console.log(i) // 1 2 3 4 5
}, 0, i)
}
- 字节
var result = [];
var a = 3;
var total = 0;
function foo(a) {
for (var i = 0; i < 3; i++) {
result[i] = function () {
total += i * a;
console.log(total);
}
}
}
foo(1);
result[0]();
result[1]();
result[2]();
此题的输出结果为:3 6 9
,因为foo
形成闭包,total
被外层引用没有被销毁。
3. 谈谈你对作用域和作用域链的理解
在ES6没出之前,只有全局作用域和函数作用域,ES6新增let
和const
指令之后衍生出了块级作用域。
全局作用域:
- 最外层函数和变量拥有全局作用域,过多的全局作用域变量容易污染全局命名空间,导致命名冲突。
- 所有未定义直接赋值的变量拥有全局作用域
window
对象的属性用于全局作用域
函数作用域:
- 函数内部声明的变量具有函数作用域
- 函数作用域是分层的,内层作用域可以访问到外层作用域,反之不行
块级作用域:
let
和const
声明的变量有块级作用域,块级作用域可以在函数中创建也可以在代码块中创建(由{}
包裹的代码片段)let
和const
声明的变量不会有变量提升,也不可以重复声明- 在循环中绑定块级作用域,可以把声明的计数器变量限制在循环内部
作用域链:
在当前作用域中查找所需变量,如果没找到,就向父级作用域查找,直到找到,如果访问到全局作用域对象window
还没找到那就会报未定义的错。
它的作用就是让变量有权访问外层作用域的变量和函数。
4. 谈谈你对执行上下文的理解
类型:
- 全局执行上下文:任何不在函数内部的都是全局执行上下文,它先创建一个
window
对象,然后this
指向它,一个程序中只有一个全局执行上下文。 - 函数执行上下文:当一个函数被调用时,就会为该函数创建一个新的执行上下文,因此函数执行上下文可以有多个
eval
函数执行上下文:eval
函数中的代码有属于它的执行上下文。
执行上下文栈:JS
代码执行时,会先遇到全局执行上下文,因此将它先压入执行上下文栈,每当执行一个函数就会创建一个函数上下文再压入栈中,然后按出栈的顺序先后弹出并执行上下文,直到全局执行上下文执行完毕。
function first() {
seconed()
}
function seconed() {
}
// 执行上下文顺序 先second再first
创建执行上下文分两个阶段:
-
创建阶段:
- this绑定:
- 全局:指向
window
, - 函数内:函数被引用对象调用那就指向该对象,否则指向
window
或undefined
- 全局:指向
- 创建词法环境:是一种有标识符——变量映射的数据结构,标识符是变量/函数名,变量是对象或原始数据的引用,词法环境内部有两个组件:
- 环境记录器:存储变量和函数声明的实际位置
- 外部环境的引用:可以访问父级作用域
- this绑定:
-
执行阶段:完成对变量的分配后,执行代码
简而言之:在执行JS
之前,先解析代码,解析的时候创建全局执行上下文环境,先把代码中即将执行的变量和函数声明拿出来,变量赋值undefined
函数声明好可使用,这一步ok后才正式执行程序。
不过创建全局上下文和函数上下文还有点差别:
- 全局:变量定义,函数声明
- 函数:变量定义,函数声明,
this
,arguements
五、call/apply/bind
1. call()、apply()、bind()之间的区别
call()
和apply()
它们作用一模一样,都实现改变调用函数的this
指向并同时执行该函数,区别在于传入参数的形式的不同。
call
传入的参数不固定,第一个参数是this
指向,从第二个开始后面的每个参数都依次传入调用的函数。apply
只接受两个参数,第一个参数与call
相同,是this
指向,第二个参数为带下标的集合,这个集合可以是数组也可以是伪数组,apply
方法把这个集合的所有元素作为参数传递给调用的函数。
bind()
方法不会执行函数,但是也能改变函数内部this指向,它的语法是:fu.bind(thisArg, arg1,arg2...)
,第一个参数是函数运行时指定的this的值,后面的参数都是函数运行时的传参,返回由指定this值和初始化采纳数改造的原函数的拷贝。
下面看bind()
的示例:
let o = {
name: 'andy'
}
function fn() {
console.log(this)
}
let f = fn.bind(o) // 此时函数f的this指向对象o
f() // {name: 'andy'}
// 返回的是原函数改变this之后产生的新函数
function fn(a, b) {
console.log(this)
console.log(a + b)
}
let f = fn.bind(o, 1, 2) // 传参
f() // {name: 'andy'} 3
下面总结一下
bind | apply | call | |
---|---|---|---|
是否执行调用的函数 | 否 | 是 | 是 |
参数 | (this指向,参1,参2…) | (this指向,[参数数组]) | (this指向,参1,参2…) |
用途 | 改变定时器内部的this指向等 | 跟数组有关系,比如借助于数学对象实现数组最大值最小值 | 经常用做继承 |
使用bind改变定时器指向,实现点击按钮后三秒后禁用按钮:
let btn = document.querySelector('button')
btn.onclick = function() {
this.disabled = true // 这个this指向btn
let that = this
setTimeout(function() {
// this.disabled = false // 定时器函数里面的this指向的是window
// that.disable = false 可行的方法
this.disabled = false
}.bind(this), 3000)
}
使用apply实现求数组最大值最小值:
let arr = [15, 6, 12, 13, 166666]
console.log(Math.max.apply(this, arr)) // 166666
console.log(Math.min.apply(this, arr)) // 6
2. 手撕call、apply及bind方法
call()
实现思路:
- 上下文不传默认为
window
- 做防止
Function.prototype.myCall()
直接调用的判断 - 把
this
方法(this
指向调用bind()
的方法)挂载到上下文对象上,需要用Symbol
声明一个对象属性 - 执行上下文刚挂载的函数,保存返回结果
result
后,删除挂载的方法 - 返回结果
result
Function.prototype.myCall = function (context = window, ...args) {
if (this == Function.prototype) {
return undefined; // 防止Function.prototype.myCall()直接调用
}
const fn = Symbol("fn");
// this指向的是要被执行的函数,也就是call的第一个参数
// 其实就是把this挂载到上下文对象上,然后执行它,执行完拿到返回结果后,删除挂载的函数
context[fn] = this;
const result = context[fn](...args);
delete context[fn];
return result;
};
// 示例
const obj = {
name: "zhangsan",
age: 18,
};
let fun = function (mes, sym) {
console.log(mes + sym, this.age + "岁的" + this.name);
};
fun.myCall(obj, "Hello", "!"); // Hello! 18岁的zhangsan
apply()
:更call()
的实现思路一模一样,只是参数变数组
Function.prototype.myApply = function (context = window, args = []) {
if (this == Function.prototype) {
return undefined; // 防止Function.prototype.myApply()直接调用
}
const fn = Symbol("fn");
context[fn] = this;
const result = context[fn](...args);
delete context[fn];
return result;
};
// 示例
const obj = {
name: "zhangsan",
age: 18,
};
let fun = function (mes, sym) {
console.log(mes + sym, this.age + "岁的" + this.name);
};
fun.myApply(obj, ["Hello", "!"]); // Hello! 18岁的zhangsan
bind()
:实现思路:
- 初始化上下文不传默认为
window
- 做防止
Function.prototype.myBind()
直接调用的判断 - 保存原函数
- 返回一个新函数,新函数返回传的参数整合了原函数的参数
原函数.apply()
方法
Function.prototype.myBind = function (context = window, ...args) {
if (this == Function.prototype) {
return undefined; // 防止Function.prototype.myBind()直接调用
}
// 拿到原函数
const fn = this;
// 返回一个新的函数,函数的参数整合了原函数+新函数
return function (...newArgs) {
return fn.apply(context, args.concat(newArgs));
};
};
// 示例
const obj = {
name: "zhangsan",
age: 18,
};
let fun = function (mes, sym) {
console.log(mes + sym, this.age + "岁的" + this.name);
};
const f = fun.myBind(obj); // 返回新函数
f("Hello", "!"); // Hello! 18岁的zhangsan
六、异步编程
1. 异步编程的实现方式有哪些?详细讲讲
1. 回调函数
这是异步编程最基本的方式,看如下代码:
function getUserInfo(userId, callback) {
setTimeout(() => { // 实现异步
const userInfo = {
id: userId,
name: '张三',
age: 18
};
callback(userInfo);
}, 0);
}
getUserInfo(123, (userInfo) => {
console.log(userInfo);
});
console.log('请求已发送');
上述代码通过回调函数结合定时器的方式实现异步,先是输出请求已发送
再输出{id: 123, name: '张三', age: 18}
。但是回调函数存在一个比较严重的问题——回调地狱(回调多层嵌套,并且每层都需要处理成功和失败的情况),如果每次都需要用到前一个异步请求的结果时,就需要接着嵌套回调,代码就会变得很复杂,可读性和可维护性会很差,而且每次异步任务伴随着失败的可能,因此每个回调内还需要进行失败判断,为了解决这些问题,因此衍生出了Promise
。
回调地狱:
fs.readFile('1.json', (err, data) => {
fs.readFile('2.json', (err, data) => {
fs.readFile('3.json', (err, data) => {
fs.readFile('4.json', (err, data) => {
});
});
});
});
另外说一下,回调函数是借鉴发布-订阅模式的思想实现的,如nodejs中的http、fs、stream模块都是继承于events模块,events模块提供事件监听和触发方法,其思想就是发布-订阅模式。
回调还有其他的缺点:如不能用try{}catch(err){}
捕获错误。
2. Promise
ES6
新增的Promise
对象通过链式调用方式很好的解决了回调函数的多层问题,同时增加了错误冒泡后一站式处理的api,并增加代码可读性和可维护性,从而解决了回调地狱的问题。
Promise
对象的三个状态:
- Pending:初识状态,即未兑现也未拒绝,也可以认为是进行中
- Resolved:已完成、已兑现(也叫fulfilled)
- Rejected:已拒绝
如果一个Promise
已经被兑现或拒绝,即不再处于待定状态,则称之为已敲定(settled)
Promise
对象的三个过程:
Pending
->Resolved
Pending
->Rejected
流程图如下:
Promise
对象的两个特点:
- 对象的状态不受外界影响。
Promise
对象代表一个异步操作,只有异步操作的结果可以决定当前是哪一种状态,任何其他操作都无法改变这个状态,这也是Promise
这个名字的由来——“承诺” - 一旦状态改变就不会再改变,任何时候都可以得到这个结果。如果改变已经发生了,你再对promise对象添加回调函数,也会立即得到这个结果。
Promise
对象的缺点:
- 无法取消
Promise
,一旦新建它就会立即执行,无法中途取消。 - 如果不设置回调函数,Promise内部抛出的错误就不会反应到外部。
- 当处于
Pending
状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
Promise
的基本用法:
const promise = new Promise(function (resolve, reject) {
let tag = true
if (tag) { // 异步操作执行成功
resolve('resolve')
} else { // 执行失败
reject('reject')
}
})
promise.then(res => {
console.log(res);
}).catch((err) => {
console.log(err);
})
Promise
实例方法:
Promise.prototype.then()
:Promise
兑现时执行,可选返回一个Promise
或者不返Promise.prototype.catch()
:捕获Promise
兑现失败。Promise.prototype.finally()
:就相当于Promise
对象的兜底方法,可以用于避免在then和catch中重复编写代码。接收一个回调函数作为参数,回调函数无参数,无返回值。
Promise
静态方法:
Promise.all()
:接受一个Promise
可迭代对象(如Promise
数组)作为输入,并返回一个Promise
,如果可迭代对象内的Promise
全部兑现才返回兑现成功的Promise
,否则返回兑现失败的Promise
。当有多个异步事件,并且有其他事件需要在这多个异步事件完成后才能执行且不考虑它们的完成顺序时使用。Promise.allSettled()
:将一个Promise
可迭代对象作为输入,并返回一个单独的Promise
。当所有输入的Promise
都已敲定时(包括传入空的可迭代对象时),返回一个兑现1结果是一个对象的Promise
,这个对象包含输入的每个Promise
敲定结果的状态信息。在你有多个不依赖于彼此成功完成的异步任务时,或者你总是想知道每个promise
的结果时使用。Promise.any()
:将一个 Promise 可迭代对象作为输入,并返回一个Promise
。当输入的任何一个 Promise 兑现时,这个返回的 Promise 将会兑现,并返回第一个兑现的值。当所有输入Promise
都被拒绝(包括传递了空的可迭代对象)时,它会以一个包含拒绝原因数组的AggregateError
拒绝。它的关注点是是否有Promise
兑现,有就返回兑现,更all
的区别可以对比数组的实例方法some
和every
。Promise.race()
:接受一个Promise
可迭代对象作为输入,并返回一个Promise
。这个返回的Promise
会根据最快敲定的Promise
的结果而敲定,也就是说最快的那个兑现那么race
返回的Promise
对象也兑现。当你想要第一个异步任务完成时,但不关心它的最终状态(即它既可以成功也可以失败)时,它就非常有用。Promise.resolve()
:传入该Promise
对象的兑现成功的结果,返回一个已兑现(resolved)的Promise
对象,兑现结果为给定的参数。Promise.reject()
:传入该Promise
对象的拒绝原因,返回一个已拒绝(rejected)的Promise
对象,拒绝原因为给定的参数。
多个异步事件串行:
promise1
.then((res) => {
console.log(res); // { name: '张三' }
return new Promise((resolve, reject) => {
resolve({ ...res, id: 1 });
});
})
.then((res) => {
console.log(res); // { name: '张三', id: 1 }
return new Promise((resolve, reject) => {
resolve({ ...res, age: 18 });
});
})
.then((res) => {
console.log(res); // { name: '张三', id: 1, age: 18 }
});
如果不考虑顺序的话可以用Promise.all()
。
3. Generator
详细可看第二张第20题。
这里扩展一下生成器实现机制——协程:
协程是一种比线程更加轻量级的存在,协程处在线程的环境中,一个线程可以存在多个协程,可以将协程理解为线程中的一个个任务。不像进程和线程,协程并不受操作系统的管理,而是被具体的应用程序代码所控制。
一个线程一次只能执行一个协程。比如当前执行 A 协程,另外还有一个 B 协程,如果想要执行 B 的任务,就必须在 A 协程中将 JS 线程的控制权转交给 B协程
,那么现在 B 执行,A 就相当于处于暂停的状态。
4. async await
最后我们来讲讲async/await,终于讲到这儿了!!!
async/await是ES7提出的关于异步的终极解决方案。我看网上关于async/await是谁的语法糖这块有两个版本:
- 第一个版本说async/await是Generator的语法糖
- 第二个版本说async/await是Promise的语法糖
其实,这两种说法都没有错。
关于async/await是Generator的语法糖: 所谓Generator语法糖,表明的就是aysnc/await实现的就是generator实现的功能。但是async/await比generator要好用。因为generator执行yield设下的断点采用的方式就是不断的调用iterator方法,这是个手动调用的过程。针对generator的这个缺点,后面提出了co这个库函数来自动执行next,相比于之前的方案,这种方式确实有了进步,但是仍然麻烦。而async配合await得到的就是断点执行后的结果。因此async/await比generator使用更普遍。
总结下来,async函数对 Generator函数的改进,主要体现在以下三点:
- 内置执行器:Generator函数的执行必须靠执行器,因为不能一次性执行完成,所以之后才有了开源的 co函数库。但是,async函数和正常的函数一样执行,也不用 co函数库,也不用使用 next方法,而 async函数自带执行器,会自动执行。
- 适用性更好:co函数库有条件约束,yield命令后面只能是 Thunk函数或 Promise对象,但是 async函数的 await关键词后面,可以不受约束。
- 可读性更好:async和 await,比起使用 *号和 yield,语义更清晰明了。
关于async/await是Promise的语法糖: 如果不使用async/await的话,Promise就需要通过链式调用来依次执行then之后的代码:
javascript复制代码function counter(n){
return new Promise((resolve, reject) => {
resolve(n + 1);
});
}
function adder(a, b){
return new Promise((resolve, reject) => {
resolve(a + b);
});
}
function delay(a){
return new Promise((resolve, reject) => {
setTimeout(() => resolve(a), 1000);
});
}
// 链式调用写法
function callAll(){
counter(1)
.then((val) => adder(val, 3))
.then((val) => delay(val))
.then(console.log);
}
callAll();//5
虽然相比于回调地狱来说,链式调用确实顺眼多了。但是其呈现仍然略繁琐了一些。 而async/await的出现,就使得我们可以通过同步代码来达到异步的效果:
javascript复制代码async function callAll(){
const count = await counter(1);
const sum = await adder(count, 3);
console.log(await delay(sum));
}
callAll();// 5
由此可见,Promise搭配async/await的使用才是正解!
总结
- promise让异步执行看起来更清晰明了,通过then让异步执行结果分离出来。
- async/await其实是基于Promise的。async函数其实是把promise包装了一下。使用async函数可以让代码简洁很多,不需要promise一样需要些then,不需要写匿名函数处理promise的resolve值,也不需要定义多余的data变量,还避免了嵌套代码。
- async函数是Generator函数的语法糖。async函数的返回值是 promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。同时,我们还可以用await来替代then方法指定下一步的操作。
- 感觉Promise+async的操作最为常见。因为Generator的常用功能可以直接由async来体现呀~
async/await如何捕获异常:
async function fn() {
try{
let a = await Promise.reject('error')
}catch(error) {
console.error(error)
}
}
async/await相对于Promise的优势:
- 代码读起来更清晰,Promise虽然摆脱了回调地狱,但then的链式调用也会带来额外的阅读负担
- Promise传递中间值麻烦,而async/await几乎是同步的写法,非常优雅
- 错误处理友好,async/await可以使用成熟的try/catch,Promise的错误捕获就非常冗余
- 调试友好,Promise调试不方便,由于没有代码块,你不能在一个返回表达式的箭头函数中设置断点,如果你在一个.then代码块中使用调试器的步进(step-over)功能,调试器并不会进入后续的.then代码中,因为调试器只能跟踪同步代码中的每一步。
参考博客:
详解JS的四种异步解决方案:回调函数、Promise、Generator、async/await(干货满满)
(2.4w字,建议收藏)😇原生JS灵魂之问(下), 冲刺🚀进阶最后一公里(附个人成长经验分享)
2. 详细说说Event Loop
JS
虽然是单线程的,但是JS
运行的宿主环境(浏览器)不是单线程的,因此为了防止一些比较耗时的任务(如网络请求、定时器、事件监听等)同步执行导致阻塞,浏览器为这些任务开辟了另外的线程,这些线程主要把任务回调放到一个任务队列中,等待主线程依次执行,这样就实现了JS
的单线程异步。主线程运行JS
时,会生成一个执行栈(用于管理线程上函数调用关系的数据结构),当执行栈的同步代码执行完毕,系统就会不断从任务队列中读取事件,这个过程是不断循环的,因此称为Event Loop
。
在了解Event Loop
前需要先了解宏任务和微任务:
宏任务,其实就是常规任务,即任务队列中等待被主线程执行的事件,是由浏览器宿主发起的任务,例如:
- script(可以理解为外层主程序同步代码)
- setTimeout,setInterval,requestAnimationFrame
- I/O(如文件I/O,网络请求I/O)
- 渲染事件(解析DOM,布局,绘制等)
- 用户交互事件(鼠标点击、页面滚动等)
宏任务通常比较耗时,被放到宏任务队列里,遵循先进先出原则。
由于宏任务比较耗时,而有的异步任务的回调实时性比较高,如果和宏任务一样加入到宏任务队列中等待执行,那会造成严重的卡顿现象,因此有了微任务的概念:由JS
引擎发起,需要异步执行的函数。
在执行JS
脚本,创建全局上下文的时候,JS
引擎就会创建一个微任务队列,在执行当前宏任务时,产生的微任务都会保存到微任务队列里,当当前宏任务的主函数执行结束之后,宏任务会检查微任务队列,如果存在微任务会一一执行,直至清空微任务队列,然后再进行下一个宏任务的执行。常见的微任务有:
Promise
:Promise
对象内的代码是同步的,但它的那些api是微任务的,如.then()
、.catch()
,以Promise
为基础开发的技术也是微任务,如fetch()
。MutationObserver
:监听DOM树被修改,DOM节点的变化是微任务。- V8的垃圾回收过程也是微任务。
下面我们讲讲事件循环Event Loop
。
主线程运行JS
时,会生成一个执行栈,用于管理主线程上函数调用关系,当执行栈内的同步任务执行完毕,系统就会不断从任务队列中读取事件,读取事件是一个不断循环的过程,在这个过程中,它会先执行宏任务队列中第一个宏任务,执行过程中遇到微任务会加到微任务队列,待这个宏任务执行完成后,将微任务队列中的微任务依次执行,执行结束后才进行读取下一个事件,反反复复执行这个过程,这就是事件循环Event Loop
。
我们通过几道代码题来熟系事件循环:
console.log('1')
setTimeout(function() {
console.log('2')
}, 0);
async function test () {
console.log(‘3')
let a = await '4'
console.log(a)
}
test()
new Promise(function(resolve) {
console.log('5')
resolve()
}).then(function() {
console.log('6')
})
console.log('7')
上述代码的执行流程如下:
- 第一个宏任务(主进程)开始执行,先输出
1
- setTimeout加入到宏任务队列
- 执行test(),先输出
3
,然后将await加入到微任务队列 - Promise初始入参是同步代码,因此先输出
5
,然后将.then加入到微任务队列 - 输出
7
,当前宏任务执行结束,开始执行微任务, - 执行await,输出
4
,再执行.then输出6
,微任务执行完,执行下一个宏任务 - 执行setTimeout,输出
2
new Promise((resolve, reject) => {
console.log(1)
new Promise((resolve, reject) => {
console.log(2)
setTimeout(() => {
resolve(3)
console.log(4);
});
}).then(data => {
setTimeout(() => {
console.log(5);
},);
console.log(data);
})
setTimeout(() => {
resolve(6)
console.log(7);
});
}).then(data => {
console.log(data);
setTimeout(() => {
console.log(8);
});
console.log(9);
})
上述代码的执行流程:
- 第一个宏任务(主进程)开始执行,Promise初始入参是同步代码,因此先输出
1
- 而后又遇到Promise,依旧是初始入参,输出
2
- 而后遇到setTimeout,它是宏任务,加入到宏任务队列,另外resolve在seTimeout的回调内,因此当前这个Promise的.then是归属于seTimeout这个宏任务的微任务,因此也不会立即执行
- 接着又遇到setTimeout,加入宏任务队列,函数体里有resolve,因此首个Promise的.then属于当前这个宏任务setTimeout的微任务,也不会立即执行。
- 第一个宏任务(主进程)执行结束,微任务队列没有任务,宏任务队列出列执行第一个seTimeout,
resolve
的.then加入到微任务队列,先输出4
,再执行微任务.then,.then里先将宏任务setTimeout加入宏任务队列,再输出3
- 第二个宏任务执行结束,执行第三个宏任务setTimeout,和上个定时一样,先输出
7
再输出6
,再将宏任务setTimeout加入到宏任务队列后输出9
- 再执行下一个宏任务,也是setTimeout,输出
5
- 执行最后一个宏任务,输出
8
3. 并发和并行有什么区别
- 并发:宏观概念,在一段时间内通过任务间的切换完成了某几个任务,这种情况就称之为并发。
- 并行:微观概念,假设CPU中存在两个核心,那么这个CPU能同时执行两个任务,这种情况就称之为并发。