请解释什么是变量提升(Hoisting)? 为什么JavaScript语言中存在变量提升机制?
- 变量提升(Hoisting) 是JavaScript中的一个行为,它使得函数声明和变量声明(使用var关键字声明的变量)在代码执行前被提前到其作用域的顶部。
- 这意味着无论声明实际上出现在何处,都会被视为在当前作用域的开始处声明。
- 需要注意的是,仅仅是声明被提升,初始化或赋值仍然会在代码中声明的位置执行。
- 也可以举出具体的例子(口头说明一个简单的)
- 为什么存在变量提升?
- 变量提升的存在主要是由于JavaScript的解释特性决定的。
- 在JavaSript早期版本中,解释器会通过两个阶段处理代码:编译阶段 和 执行阶段 。
- 在编译阶段中,解释器会先读取并处理所有的声明,而在执行阶段,才会处理实际的逻辑代码。
- 这种设计使得在同一作用域内的变量和函数可以在声明之前被引用,从而提供了一定程度的灵活性。
- 然而,这也可能导致代码运行结果不直观,这其实是JavaScript早期设计的一种缺陷。
- 因此现代JavaScript(ES6及以后)引入了let和const关键字,这两者不会发生变量提升,使得代码更加可靠和易于理解。(可能会涉及暂时性死区问题)
变量提升机制有哪些潜在的缺点?它可能会导致哪些具体问题?
- 虽然变量提升(Hoisting)为JavaScript提供了某种编码上的灵活性,但它也带来一些潜在的危险,可能导致代码运行时的不确定性和潜在的错误。
- 比如下面的缺点:
- 变量覆盖问题:在同一作用域内,如果不小心重复声明变量,由于变量提升,后面的声明会覆盖前面的声明(尽管实际赋值不会被提升)。
- 这可能会导致调试时难以发现错误,因为没有直接的错误提示。(在早期的JavaScript开发中经常出现)
- 意外行为:如果某些开发者不了解变量提升机制,可能会误以为变量的赋值也被提升了,这可能导致逻辑上的错误。
- 函数声明的混淆:函数提升意味着函数声明可以在函数定义之前调用。
- 如果在同一作用域内有多个同名函数,可能会导致预期外的函数版本被执行,因为后声明的函数会提升并覆盖之前的版本。
- 可读性和维护性降低:变量提升可能导致代码的逻辑难以理解。
- 因为变量和函数可以在声明之前被引用,这使得阅读代码时难以立即识别变量的定义位置和作用域,增加了追踪变量声明位置的难度。
- 变量覆盖问题:在同一作用域内,如果不小心重复声明变量,由于变量提升,后面的声明会覆盖前面的声明(尽管实际赋值不会被提升)。
如何定义“作用域”?请举例说明不同类型的作用域
- 作用域(Scope) 是编程中一个非常重要的概念,它描述在代码中定义变量的区域,这个区域决定了变量的可访问性和声明周期。
- 简单来说,就是作用域定义了代码块中变量的访问权限。
- 在JavaScript中,作用域控制这变量和函数的可见性及它们可以被访问的部分。
- 在JavaScript中有如下几种常见的作用域类型 :
- 全局作用域(Global Scope):当变量在代码中的任何函数外声明时,它就拥有全局作用域。这意味着任何代码的任何部分都可以访问这些全局变量。全局作用域的变量在页面关闭前一直存在,并且过多的全局变量可能导致命名冲突和维护问题。
- 函数作用域(Function Scope):在函数内部声明的变量具有函数作用域,这意味着这些变量只能在函数内部被访问。函数参数也具有函数作用域。
- 块级作用域(Block Scope):使用let和const声明的变量具有块级作用域,即这些变量仅在其包含的{}块中可见。这是ES6的新增特性,对于管理局部变量非常有用,尤其是在循环和条件语句中。
- 模块作用域(Module Scope):在ES6模块中,顶层声明的变量、函数、类等不是全局的,而是模块内部的。这些声明只在模块内部可见,除非被导出。模块作用域是理解现代JavaScript应用中代码组织的重要概念。
请解释什么是作用域链及其在JavaScript中的作用?
- 作用域链(Scope Chain) 是JavaScript中的一个基本概念,它用于确定当前执行代码的上下文中变量的查找和访问机制。
- 作用域链的构建是基于词法作用域的结构,即变量和函数的可见性由它们在源代码中的位置决定。
- 在JavaScript中,每个执行上下文(如函数执行上下文)都有一个与之关联的作用域链。
- 这个作用域链是一个包含多个环境记录(Environment Record)的列表。
- 当前执行上下文的环境记录在链的最前端,如果当前作用域中没有找到某个变量,解释器就会沿着作用域链向上查找,直到达到全局作用域,如果全局作用域中也没有找到,则会产生一个引用错误。
- 作用域链使得函数可以记住并访问它被定义时的作用域,即使该函数在不同的上下文中被调用。这是闭包(Closures)的核心原理和前提。
闭包是什么?请谈谈你对闭包的理解,以及它在JavaScript中的用途。
- 1.先谈闭包的定义:
- 闭包的定义:一个函数+外层作用域环境就形成了闭包(Closure)。
- 所以在JavaScript中,每当创建一个函数,闭包就会在函数创建的同时被创建出来,只不过我们看要不要再对其严格的定义。
- 2.谈谈自己的理解
- 那么什么是严格的定义呢?
- 一个普通的函数function,如果它可以访问外层作用域的自由变量,那么这个函数就是一个闭包;
- 从广义的角度来说:JavaScript中的所有函数都是闭包;
- 从狭义的角度来说:JavaScript中一个函数,如果访问了外层作用域的变量,那么它是一个闭包;(有访问的操作)
- 那么什么是严格的定义呢?
- 3.为什么需要有闭包呢?
- 如果没有闭包,那么访问外层作用域中的变量就会受到非常多的限制,也非常的不方便。
- 闭包的存在就让我们可以非常自然地访问外层作用域中的变量,不需要通过参数传递进来。
- 如果没有闭包就会造成函数的参数是非常繁多和庞杂的,函数本身会变得非常复杂和难以维护。代码也会非常混乱,可维护性、可扩展性、可读性都会变差。
- 如果没有闭包,那么访问外层作用域中的变量就会受到非常多的限制,也非常的不方便。
- 4.开发中闭包的使用和遇到的问题
- 在开发中闭包确实带来了很大的便利性,但是在不了解它的原理的情况下往往也非常容易造成内存泄漏。
- 比如我们通过一个内层函数引用了外层作用域函数的AO或者ER(环境记录),而之后不再使用该内层函数时,我们需要将其置为null。
- 如果没有进行这样的操作,很容易造成内存泄漏,因为AO或者ER被长期引用着而无法得到释放。
- 这个时候我们应该让开发组员在平时开发的时候就养成良好的编码习惯,并且进行codereiview来保证代码质量。
- 因为内存优化或性能优化不是一蹴而就的,它往往需要我们平时开发时就多注意,一旦遇到了大的性能问题再亡羊补牢,代码优化起来就会非常困难,并且很难重构。
- 在开发中闭包确实带来了很大的便利性,但是在不了解它的原理的情况下往往也非常容易造成内存泄漏。
- 5.可以引导到其他话题,比如React中Hooks闭包的注意事项,Vue3源码、React源码中闭包的使用场景。
能否详细说明什么是执行上下文?它是如何影响JavaScript代码的执行?
- 执行上下文(Execution Context)是JavaScript执行过程中最重要的概念之一
- 它指的是在代码执行时。JavaScript引擎所创建的一种“环境”。
- 执行上下文的类型:
- 全局执行上下文(Global Execution Context):在JavaScript程序运行时创建的默认上下文。它包含全局对象(在浏览器中是对windows对象)和this关键字。
- 函数执行上下文(Function Execution Context):每当一个函数被调用时,会创建一个新的执行上下文。每个函数都有自己的执行上下文,包括函数的局部变量、参数以及内部函数声明。
- 在执行过程中会创建很多的内容,比如VO、Scope Chain、this绑定、或者VE、LE、ER(具体解释它们是做什么的)。
- 执行上下文如何影响JavaSript代码的执行?
- 当JavaScript 引擎开始执行一段代码时,首先会创建一个执行上下文。在这个阶段,变量和函数声明会被提升(Hoisting),即它们会在代码执行前被放入变量对象中。var声明的变量会被初始化为undefined,而let和const变量则不会初始化。
- 在执行阶段,JavaScript引擎会按照代码的顺序逐行执行。变量会被赋予实际的值,函数会被调用。执行上下文在此过程中保持对当前作用域链的跟踪,以便在需要时解析标识符。
- JavaScript引擎使用一个栈来管理多个执行上下文。当全局代码开始执行时,全局执行上下文被推入栈顶。当调用函数时,会为该函数创建一个新的执行上下文,并推入栈顶。函数执行完毕后,其执行上下文会从栈中弹出,控制权返回到之前的上下文。
JavaScript在执行中会产生AO、GO、VO、LE、VE、ER都是什么对象?
- 在JavaScript的执行过程中,AO、GO、VO、LE、VE和ER这些术语分别指代特定的对象或记录类型,它们在执行上下文中起着不同的作用。以下是对这些术语的详细解释:
- AO(Active Object)
- 激活对象(Active Object) 是一个较老的术语,用于表示函数执行上下文中的变量对象(Variable Object,VO)。在现代ECMAScript规范中,AO已被VO取代,但它们本质上指的是同一个概念。
- 作用:它存储了函数内部声明的所有变量、函数声明和参数。每当函数被调用时,都会创建一个新的激活对象。
- GO(Global Object)
- 全局对象(Global Object) 是在全局执行上下文中创建的对象。浏览器环境的全局对象是window,在Node.js环境下是global。
- 作用:GO包含了全局范围内的所有变量和函数声明,此外还包括一些内置对象(如Math、Date)和全局函数(如setTimeout、parseInt)。
- VO(Variable Object)
- 变量对象(Variable Object,VO) 是一个执行上下文中的抽象概念,它包含了函数或全局作用域内声明的变量、函数声明和参数。VO是在执行上下文创建阶段构建的。
- 作用:在全局执行上下文中,VO是全局对象(GO);在函数执行上下文中,VO是激活对象(AO)。
- LE(Lexical Environment)
- 词法环境(Lexical Environment,LE) 是ECMAScript标准中的一个结构,用来存储变量和函数声明的环境。词法环境由两个部分组成:环境记录(Environment Record,ER)和对外部环境的引用(即outer环境)。
- 作用:LE用来跟踪代码执行期间的标识符和它们的绑定。在代码执行过程中,词法环境被用于确定变量和函数在作用域链中的位置。
- VE(Variable Environment)
- 变量环境(Variable Environment,VE) 与词法环境类似,但专门用于追踪var声明的变量。与词法环境不同的是,变量环境仅用于存储var声明,而不包括let和const。
- 作用:在函数的执行上下文中,VE记录了所有var声明的变量,并且这些变量具有函数作用域。
- ER(Environment Record)
- 环境记录(Environment Record,ER) 是词法环境和变量环境的组成部分,用来存储变量、函数声明以及它们的绑定关系。
- 作用:ER包含了关于当前环境中所有标识符(变量名和函数名)及其对应值的记录。ER可以是声明式环境记录(Declarative Environment Record),用于存储普通变量和函数,也可以是对象环境记录(Object Environment Record),用于处理像with语句这样的情况。
‘var’、'let’和’const’用于变量声明,它们之间有何区别?
- 在JavaScript中,var、let和const都是用于声明变量的关键字。
- 但它们在作用域、变量提升(hoisting),以及是否允许重新赋值和重新声明方面由很多的不同。
-
- 作用域(Scope)
- var:声明的变量具有函数作用域,即在整个包含函数内部都可见。如果在函数外部使用var声明变量,该变量具有全局作用域。
- let和const:这两者声明的变量具有块级作用域(block scope),即只在包含它们的代码块(由花括号{}界定)内部可见。
-
- 提升(Hoisting)
- var:变量提升发生在var声明的变量上,这意味着无论在函数的哪个位置声明变量,变量都会被视为在函数顶部声明(但不初始化,如果提前使用,则值为undefined)。
- let和const:虽然技术上也会提升,但它们被限制在一个称为“暂时性死区”(Temporal Dead Zone,TDZ)的区域,直到实际的代码被执行并且变量被声明。在声明之前访问这些变量会导致ReferenceError。
-
- 重新赋值
- var和let:使用这两个关键字声明的变量可以在后续被重新赋值。
- const:声明的变量必须在声明时初始化,并且一旦赋值后不能被重新赋值。尝试改变const变量的值会导致运行时错误。
-
- 重新声明
- var:在相同的作用域内可以多次声明同一个变量,后面的声明会覆盖之前的声明。
- let 和 const:在相同的作用域或块级作用域内不允许重新声明已经存在的变量。尝试这样做会导致语法错误。
请解释JavaScript中的原型概念以及原型链是什么?
- 在JavaScript中,原型(prototype)是每个JavaScript对象都具有的一个内部属性。这个属性是一个指向另一个对象的引用,这个对象被称为“原型对象” 。
- 具体来说,当我们访问一个对象的属性或方法时,JavaScript引擎首先会检查该对象本身是否具有该属性或方法。
- 如果没有找到,它会顺着原型链(Prototype Chain)向上查找,直到找到为止。
- 原型链则是指对象之间通过原型属性所形成的链条,这种机制允许JavaScript对象实现继承和属性查找。
- 也可以回答以下函数的原型,通过new创建出来的对象,会将函数的原型prototype赋值给对象的__proto__
- JavaScript在设计之初受到了多种编程语言的影响,而原型原型链就是由借鉴Self语言的。
为什么JavaScript需要存在原型和原型链,它的目的是什么?
- JavaScript中的原型和原型链设计的主要目的是为了实现继承,而继承是面向对象编程的重要特性之一
- JavaScript是一门支持多范式的编程语言,一方面它支持函数式编程来开发,同时也支持面向对象的形式来开发。
- 而继承是面向对象的一个大特性,一方面可以减少我们编写重复代码,另一方面也是多态的前提。
- JavaScript采用的是基于原型的继承模型,这一模型使得对象之间可以共享属性和方法。
- 原型链提供了一种属性和方法查找机制。
- 当我们访问一个对象的属性或方法时,JavaScript首先会检查对象本身是否具有该属性或方法。
- 如果没有找到,它会顺着原型链向上查找,直到找到为止。
- 这种查找过程通过共享原型对象,实现了对象之间的继承,而无需在每个对象中重复定义属性或方法,从而节省了内存并简化了代码结构。
- 这也是当我们通过new关键字来调用一个函数时,构建函数底层帮助我们完成的操作:
- 在内存中创建一个新的对象
- 将函数的this绑定到新对象
- 这个对象内部的[[prototype]]属性会被赋值为该构造函数的prototype属性
- 构造函数会返回这个新对象
- 其中对象内部的[[prototype]]属性会被赋值为该构造函数的prototype属性就是利用原型设计来完成通过构造函数创建出来的多个对象指向同一个原型。
- 当然,你可以通过回答原型链终极图来进一步阐述整个原型的关系。
- 原型链终极图如下所示:
原型链的终点是什么,如何打印出对象的原型链直至其终点?
- 在JavaScript中,原型链的终点是null
- 当一个对象的__proto__属性为null时,这意味着它已经到达了原型链的终点。
- 通常,所有对象的原型链最终都会追溯到Object.prototype,而Object.protoype.__proto__就是null。
- 因此,null标志着原型链的结束。
- 要在代码中打印出一个对象的原型链直至其终点,可以使用一个循环来逐步访问每一级的原型,并输出响应的对象:
function printPrototypeChain(obj) {
const proto = Object.getPrototypeOf(obj)
console.log(proto)
if (proto) {
printPrototypeChain(proto)
}
}
const info = { name: 'why' }
printPrototypeChain(info)
JavaScript(其他语言中)为什么需要使用this关键字?
- this关键字在JavaScript以及其他面向对象的编程语言(如Java、C++)中存在的主要原因是为了提供一种在对象方法中引用当前对象的方式。
- 这使得我们可以在对象的方法内部访问对象的其他属性或方法,而不需要必须明确写对象名称本身,让我们代码编写起来更加灵活和方便。
- this关键字并不是JavaScript特有的,在几乎所有的面向对象编程语言中,比如Java、C++(Object-C使用的是self,但作用是一样的)
- 它为方法提供了对当前实例的引用,使得方法能够访问或修改实例的成员变量。
- 这种方式增强了代码的灵活性、复用性和可维护性。
- 这是面向对象编程中的一个核心概念,不仅在JavaScript中具有重要意义,在其他语言中也是如此。
JavaScript中,this的绑定规则有哪些?
- 关键点
- this的绑定和定义的位置(编写的位置)没有关系;
- this的绑定和调用方式以及调用的位置有关系;
- this是在运行时被绑定的;
- 在JavaScript中,this的绑定规则可以根据函数的调用方式分为四种主要的规则。
- 默认绑定(Default Binding):
- 当函数在非严格模式下独立调用时,this默认绑定到全局对象(在浏览器中是window对象,在Node.js中是global对象)。
- 如果在严格模式下,this会被绑定为undefined。
- 隐式绑定(Implicit Binding):
- 当函数作为对象的方法被调用时,this绑定到该对象。
- 此时,this的值就是调用方法的对象。
- 显式绑定(Explicit Binding):
- 通过call、apply或bind方法,可以显式地指定this的绑定对象。
- call和apply会立即调用函数并传递this,而bind会返回一个新的函数并绑定指定的this。
- new绑定(New Binding):
- 当通过new关键字调用构造函数时,this绑定到新创建的对象实例。
- 这个新对象由构造函数创建,并作为this的绑定对象。
- 除了这四种主要规则外,还需要注意箭头函数(Arrow Functions)中this的特殊绑定方式:
- 箭头函数不会创建自己的this,它会使用外部作用域的this值,通常是定义箭头函数时所在的上下文。
解释JavaScript中的Proxy对象是什么以及它是如何工作的,用于哪些场景?
- Proxy是ES6引入的一种新特性,允许我们创建一个代理对象来拦截并定制对另一个对象的基本操作,比如获取数据、设置数据、删除数据等。
- Proxy通过一个构造函数创建,接受两个参数:
- target:即我们希望代理的目标对象。
- handler:一个对象,里面包含了一些“捕捉器”(traps),这些捕捉器定义了我们希望拦截的操作,以及我们想要如何处理这些操作。
- 当对代理对象Proxy进行操作时,这些操作会被代理捕捉器拦截,并根据捕捉器的定义进行处理。
- Proxy应用场景非常广泛,我这里举两个具体的例子:
- Vue3源码中的响应式系统:
- Vue3源码中,Vue为了做到监听对象的响应式,必须要监听对象的改变,并且根据改变来更新DOM。
- 在Vue2中Vue使用的是Object.defineProperty,但是Object.defineProperty的设计并不是为了进行对象属性的监听。
- 所以在Vue3中对于对象的监听全面转向了Proxy来代理对象。
- 为公司封装的event-store:
- 我之前为公司封装了一个可以在多个页面、组件共享的库:event-store。
- 该库使用Proxy来代理监听传入的对象,当数据发生改变时,可以通知页面或者组件数据的改变。
- 其他页面或者组件可以根据数据的改变做出如更新页面、网络请求等相应操作。
比较Proxy和Object.defineProperty之间的区别?
- 1.它们从设计的目的来说是不一样的:
- Object.defineProperty在设计时并不是为了做数据监听而创建的。它的设计目的主要是为了提供对对象属性的精细控制。
- Object.defineProperty允许我们详细地定义或修改对象的属性描述符,包括属性的可写性(writable)、可枚举性(enumerable)、可配置性(configurable)等。通过这些描述符,我们可以对对象的属性行为进行更细粒度地控制。
- 而Proxy设计的目的就是为了帮助我们可以代理某一个对象,并且监听和拦截对象的操作。
- 2.在功能上说,确实都可以实现监听的功能:
- Object.defineProperty是ES5引入的,它可以通过设置getter和setter方法来拦截和自定义属性的访问和修改行为,但它仅限于对单个属性进行拦截,无法作用于整个对象。
- 另外它的拦截功能是有限的,不能拦截属性的删除、新增、遍历等操作。
- Proxy是ES6引入的,可以用于创建一个代理对象,全面拦截对另一个对象的操作。
- Proxy提供了多种捕捉器(如get、set、has、deleteProperty等),这些捕捉器覆盖了几乎所有的对象操作。
- 3.在性能方面,不同场景优势是不同的:
- 由于Object.definePeoperty直接操作对象的属性并且不需要引入额外的中间对象,在单一属性的操作上,通常来说性能较好。
- 但它只针对特定属性进行拦截,因此如果监听对象的大量属性或整个对象,代码的复杂度和性能开销会增加。
- Proxy由于引入了代理层,会在每次操作时触发捕捉器,这会带来一定的性能开销。
- 但是,Proxy的灵活性和全面性在很多情况下可以弥补性能上的损耗,尤其是在需要全局监听对象行为的场景中。
- 4.应用场景因不同的需求而不同:
- Object.defineProperty适用于只需拦截和修改某个或某几个属性的访问和赋值操作的场景。
- Object.defineProperty更适合用于简单的属性操作控制,尤其是在不需要动态代理整个对象的情况下。
- 由于Object.defineProperty兼容性好(支持ES5及以上),适合需要兼容旧浏览器的项目。
- Proxy适用于需要拦截和定制对象几乎所有操作的场景,所以Vue3已经全面转向了Proxy。
- Proxy的适用性更广,但由于其较新的特性,在需要兼容旧环境的情况下可能需要Polyfill或降级方案。
- 总之,Obect.defineProperty适合用于简单、局部的属性控制,性能相对较好,兼容性更强;而Proxy则提供了更为强大和灵活的对象操作拦截能力,适合更复杂和动态的场景,但需要权衡性能和兼容性问题。
Map和WeakMap有什么不同?请解释WeakMap的特性
- Map的特点:
- Map是一种键值对集合,可以使用任何类型的值作为键,包括对象和基本类型。
- 它的键是强引用的,这意味着只要Map中存在对某个对象的引用,那么这个对象就不会被垃圾回收机制回收。
- 在Map中,由于键是强引用的,所以即使不在需要某个对象,只要它仍然是Map的键,该对象就不会被回收,可能导致内存泄漏。
- Map是可迭代的,比如我们可以通过for…of循环来迭代
- WeakMap的特点:
- WeakMap也是一种键值对集合,但它的键必须是对象,不能是基本类型。
- WeakMap中的键是弱引用的,也就是说,如果没有其他强引用指向该对象,那么即使这个对象是WeakMap的键,它也会被垃圾回收机制回收。
- 由于WeakMap的键是弱引用的,所以如果某个对象只作为WeakMap的键存在,并且在其他地方没有被引用,那么它会被垃圾回收机制回收,从而自动释放内存。
- 这使得WeakMap非常适合存储那些临时对象。
- WeakMap不可迭代,无法通过for…of或其他迭代方法遍历其内容。
- 这是因为WeakMap的设计初衷是为了允许键被自动回收,如果可以迭代WeakMap,将会打破其弱引用特性。
为什么以及在什么情况下会选择使用WeakMap而不是Map
- 在Vue的源码中有这样一段代码:
type KeyToDepMap = Map<any,Dep>
const tagetMap = new WeakMap<object,KeyToDepMap>
- WeakMap管理target(原对象):
- WeakMap的键(target)是我们原始的对象,比如Vue组件中的数据对象,当一个组件不再使用时,它的响应式数据(原对象)也会被销毁。
- 如果我们使用的是Map,由于Map对键是强引用的,这些原对象将不会被垃圾回收,从而导致内存泄漏。
- 但是,使用WeakMap后,由于它对键(原对象)是弱引用的,当原对象不在被其他地方引用时,它们会被垃圾回收,WeakMap中对于的键值对也会自动被移除,从而避免了内存泄漏。
- WeakMap的键是Map对象:
- 在Vue3的响应式系统中,WeakMap的值是一个Map对象,这个Map用于存储原对象属性与其依赖之间的关系。
- 当原对象销毁时,WeakMap会自动移除相关的键值对,因为WeakMap对键是弱引用的。
- 因此,原对象所对应的Map也会被垃圾回收机制自动销毁。
- 这样做的好处是,所有与该原对象相关的依赖追踪信息会在原对象销毁时自动清理,进一步防止内存泄漏。
解释什么是回调函数,以及它在异步编程中的作用和存在的缺点?
- 回调函数是前端开发中非常重要的一种编程方式
- 因为JavaScript是支持函数式编程的,所以函数可以作为第一公民传递给另外一个函数。
- 那么另外一个函数在合适的时机可以反过来调用这个函数,被调用的这个函数我们就称之为是回调函数。
- 在早期的JavaScript异步编程中,回调函数的应用是非常广泛的:
- 比如说网络请求,用户交互的事件回调、Timer定时器等;
- 我们都不确定事件在什么时候完成,所以我们需要通过回调函数来监听事件的完成,并且执行对应的操作。
- 这样做一方面不会引起我们主线程的阻塞,另一方面可以在合适的时机去执行某些特定函数的代码。
- 但是回调函数也是有缺点的,因为我们经常需要在一个异步回调中,去执行其他的异步操作,而其他的异步操作往往又会有对应的回调函数,这样就会引起回调地狱(Callback Hell)。
- 比如我们在早期的开发中,一个网络请求获取到数据后,我们会根据这个请求立马就发送另外一个请求,并且获取到数据后可能会再发送另外一个请求。
- 也就是多个异步操作需要按照某种特定的顺序执行时,常常会产生回调地狱。
- 这种依赖关系可能会引起回调函数的嵌套,造成我们的代码可读性、可维护性变差。
- 另外回调函数的错误处理机制相对复杂,处理起来也非常麻烦。
如何解决所谓的“回调地狱”问题呢?
- 在早期没有Promise的情况下,解决回调地狱确实是一个比较棘手的问题,但是如果项目不引入解决方案,往往会让代码后期非常复杂,难以维护。
- 所以我在架构项目时,对多异步编程的代码就会制定统一的规范:
- 方案一:函数单独封装
- 当你遇到复杂的嵌套回调时,可以将每个异步的步骤单独抽取成函数,来避免回调地狱。
- 这种方式是将回调函数抽取到外部,单独去调用,这样代码结构会更加扁平化,便于理解和维护。
- 方案二:使用Async.js库
- Async.js是一个非常流行的控制流库,这个库提供了许多的高阶函数来简化异步的操作。
- 比如waterfall,可以按照顺序执行一系列的异步任务,并且可以将结果传递给下一个任务。
- 当然,如果不想引入这种第三方库,我们也可以自己来封装实现。
- 也有其他的解决方案,比如基于事件驱动,当有异步的结果时会发出一个事件,在其他地方来监听事件进行后续的操作,避免回调的嵌套。
- 当然,在Promise、Generator、await、async出现之后,对于异步的处理,变得非常的简单和优雅了。
什么是Promise?引入Promise的原因是什么?
- Promise是一种用于处理异步操作的JavaScript类,可以通过这个类创建出Promise对象。
- 当我们创建一个Promise对象返回给其他人时,相当于给到其他人一个“承诺”,这个承诺会在之后的某个时间点“兑现”或者“拒绝”。
- 因为对于一个Promise来说有三种状态:Pending(等待)、Fulfilled(兑现或者完成)、Rejected(拒绝或者失败)。
- Promise允许我们通过.then()方法处理成功的结果,通过.catch()方法处理失败的结果。
- 这种链式调用极大地提高了异步代码的可读性和维护性。
- 所以Promise引入的核心就是提供一种更加优雅的方式来处理异步操作,避免传统的回调函数复杂性。
- 在Promise引入之前,JavaScript主要依赖回调函数来处理异步操作。
- 这种方式在处理简单异步任务时还算有效,但随着异步操作的复杂性增加,特别是在多个异步任务需要依次或并行执行时,回调函数会导致所谓的“回调地狱”问题。
- 回调地狱使得代码变得难以阅读、调试和维护。
- 虽然之前也有解决方案,但是存在两个问题:
- 解决方案是基于现有的回调函数缺点提出的,其实并不优雅。
- 不同的项目、企业可能采用不同的解决方案,没有统一的标准。
解释什么是生成器(Generator)以及它在异步编程中如何被利用?
- 生成器(Generator)是JavaScript中一种特殊的函数类型,和普通的函数相比,它在定义时是通过function*语法定义。
- 普通的函数只有等到执行完毕或者return或者抛出异常时才会终止。
- 而生成器函数可以控制它的“暂停”和“恢复”执行。
- 生成器函数每次调用这个迭代器的next()方法时,生成器函数会从上次暂停的地方继续执行,直到遇到yield表达式或者函数结束。
- 生成器函数的核心特点是它可以暂停执行,并通过yield关键字将控制权交还给调用者,同时还能保留当前的执行上下文。
- 在异步编程中,生成器可以被用来简化复杂的异步逻辑,在没有引入await、async之前,我们可以借助于Promise+Generator实现await、async的功能(因为await、async是到ES8才成为标准的)
- 可以通过生成器函数来替代async的功能
- 可以通过yield来代替await的功能
- 只是代码的执行需要借助于co库或者我们自己编写代码让生成器函数可以自动执行。
- 尽管在async/await引入之后,生成器在异步编程中的使用有所减少,但它仍然是理解JavaScript异步编程的重要组成部分。
描述async和await的作用,与Promise相比有什么优势和不同?
- async和await是ES2017(ES8)引入的两个关键字,它们目的是让我们的异步代码处理起来更加的优雅,可以把异步代码像同步代码那样去编写,这样可以提高我们代码的可读性、可维护性。
- async用于声明一个异步函数,它本质上是返回一个Promise的对象。
- 无论函数内部是否手动地返回一个Promise对象,都会将返回值包装到一个Promise来处理。
- 如果我们的异步函数的返回值是Promise,Promise.reslove的状态会由Promise决定;
- 如果我们的异步函数的返回值是一个对象并且实现了thenable,那么就会由对象的then方法来决定;
- 如果函数内部发生了异常,那么就会执行Promise的reject操作。
- await只能在async函数内部使用,它用于等待一个Promise的解惑。
- 通常使用await时,后面会跟上一个表达式,这个表达式会返回一个Promise;
- 那么await会等到Promise的状态变成fulfilled状态,之后继续执行异步函数。
- async/await与Promise的优势和不同:
- 编写方便: 在处理多个异步操作时,我们可以通过await关键字轻松实现同步代码的编写方式,编写起来更加方便。
- 代码可读性和可维护性: 使用async/await语法,我们可以像编写同步代码那样来编写异步代码,避免了Promise链式调用的嵌套。
- 错误处理: 在Promise中,错误处理通常通过.catch()方法进行,而在async/await中,我们可以直接使用try…catch来捕获和处理异常。