(备忘录)JavaScript底层逻辑

220 阅读1小时+

1. 堆栈内存及闭包作用域

1.1 JS中的数据类型以及区别

JS的基本数据类型和引用数据类型

  • 基本数据类型:undefined、null、boolean、number、string、
  • 引用数据类型:object、array、function
  • 后续新增基础数据类型:symbol、bigInt

区别

  • a. 声明变量时不同的内存分配:

    1)原始值:存储在栈(stack)中的简单数据段,也就是说,它们的值直接存储在变量访问的位置

    这是因为:这些原始类型占据的空间是固定的,所以可将他们存储在较小的内存区域 – 栈中。这样存储便于迅速查寻变量的值。

    2)引用值:存储在堆(heap)中的对象,也就是说,存储在变量处的值是一个指针(point),指向存储对象的内存地址。

    这是因为:引用值的大小会改变,所以不能把它放在栈中,否则会降低变量查寻的速度。相反,放在变量的栈空间中的值是该对象存储在堆中的地址。 地址的大小是固定的,所以把它存储在栈中对变量性能无任何负面影响。

  • b. 不同的内存分配机制也带来了不同的访问机制

    1)在JavaScript中是不允许直接访问保存在堆内存中的对象的,所以在访问一个对象时,首先得到的是这个对象在堆内存中的地址,然后再按照这个地址去获得这个对象中的值,这就是传说中的按引用访问

    2)而原始类型的值则是可以直接访问到的。也就是说这两个变量都指向了堆内存中的同一个对象,他们中任何一个作出的改变都会反映在另一个身上。

    这里要理解的一点就是,复制对象时并不会在堆内存中新生成一个一模一样的对象,只是多了一个保存指向这个对象指针的变量罢了。多了一个指针

  • c. 复制变量时的不同

    1)原始值:在将一个保存着原始值的变量复制给另一个变量时,会将原始值的副本赋值给新变量,此后这两个变量是完全独立的,他们只是拥有相同的value而已。

    2)引用值:在将一个保存着对象内存地址的变量复制给另一个变量时,会把这个内存地址赋值给新变量,也就是说这两个变量都指向了堆内存中的同一个对象,他们中任何一个作出的改变都会反映在另一个身上。

  • d 参数传递的不同(把实参复制给形参的过程

首先我们应该明确一点:ECMAScript中所有函数的参数都是按值来传递的。但是为什么涉及到原始类型与引用类型的值时仍然有区别呢?还不就是因为内存分配时的差别。  

1)原始值:只是把变量里的值传递给参数,之后参数和这个变量互不影响。

2)引用值:对象变量它里面的值是这个对象在堆内存中的内存地址。因此它传递的值也就是这个内存地址,这也就是为什么函数内部对这个参数的修改会体现在外部的原因了,因为它们都指向同一个对象。

1.2 JS堆栈内存的运行机制

  • macro-task(宏任务):包括整体代码script(同步宏任务),setTimeout、setInterval(异步宏任务)
  • micro-task(微任务):Promise,process.nextTick,ajax请求(异步微任务)

执行机制与事件循环详解

主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在”任务队列”中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取”任务队列”,依次执行那些事件所对应的回调函数。

那怎么知道主线程执行栈为执行完毕?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。

第一轮事件循环:主线程执行js整段代码(宏任务),将ajax、setTimeout、promise等回调函数注册到Event Queue,并区分宏任务和微任务。主线程提取并执行Event Queue 中的ajax、promise等所有微任务,并注册微任务中的异步任务到Event Queue。

第二轮事件循环:主线程提取Event Queue 中的第一个宏任务(通常是setTimeout)。主线程执行setTimeout宏任务,并注册setTimeout代码中的异步任务到Event Queue(如果有)。执行Event Queue中的所有微任务,并注册微任务中的异步任务到Event Queue(如果有)。

类似的循环:宏任务每执行完一个,就清空一次事件队列中的微任务。

注意:事件队列中分“宏任务队列”和“微任务队列”,每执行一次任务都可能注册新的宏任务或微任务到相应的任务队列中,只要遵循“每执行一个宏任务,就会清空一次事件队列中的所有微任务”这一循环规则,就不会弄乱。

一句话概括:任务队列中有宏任务跟微任务,整体代码作为第一个宏任务执行,顺序执行代码,遇到宏任务就放进去宏任务队列,遇到微任务就放进去微任务队列,注意:每执行完一个宏任务就清空一次微任务队列,这就是一轮事件循环,然后执行第二个宏任务,如此往返。

变量提升机制

  • 为什么会发生提升?

首先我们需要知道一点编译原理的知识。JavaScript的源代码在运行的时候,会经过两个阶段:编译和执行。而且,编译阶段往往就在执行阶段的前几微秒甚至更短的时间内。

  1. 编译

  2. 词法解析:这个阶段会先把字符组成的字符串解析成一个个有意义的代码块,这些代码块也被称为词法单元。如 var a = 1; 会被解析成: var 、a、=、1、; ,空格是否会被解析成词法单元,就要看空格在这个语言中有意义。

  3. 语法解析:这个阶段会把词法解析生成的词法单元流解析成有元素逐级嵌套所组成的程序语法树,AST。至于AST的结构是怎样的,这里有几个网站,大家可以去试试: 网站1网站2

  4. 代码生成:这个阶段就是将语法解析阶段生成的AST转译成可执行的代码。

  • 变量提升

LHS查询:在编译的过程中,先将标识符和函数声明给提升到其对应的作用域的顶端。标识符解析的时候,会进行LHS查询,在LHS查询的时候,如果标识符一直找不到声明的位置,那么最终就会在全局环境生成一个全局变量。

LHS : 指的是赋值操作的左端。

RHS查询:说到LHS查询,就不得不提对应的RHS查询,相信大家已经看出RHS的意思了把,它指的是赋值操作的源头。

RHS查询的时候,如果找不到对应的标识符,就会抛出一个异常:ReferenceError。

  • 函数提升

函数提升和变量提升还有点不一样。函数提升,只会提升函数声明,而不会提升函数表达式。

在JavaScript中函数的创建方式有三种:函数声明、函数表达式(函数字面量)、函数构造法(动态的,匿名的)。只有函数声明创建的函数会执行函数提升,字面量定义的函数(实质为变量+匿名函数)也会执行变量提升。

  • JavaScript变量声明提升
    • 在JavaScript中,函数声明与变量声明经常被JavaScript引擎隐式地提升到当前作用域的顶部
    • 声明语句中的赋值部分并不会被提升,只有名称被提升
    • 函数声明的优先级高于变量,如果变量名跟函数名相同且未赋值,则函数声明会覆盖变量声明
    • 如果函数有多个同名参数,那么最后一个参数(即使没有定义)会覆盖前面的同名参数

1.3 作用域和作用域链

前置知识

  • 执行环境:

定义了变量和函数有权访问的其他数据,决定了他们各自的行为。每个执行环境都有与之对应的变量对象(variable object),此对象保存着环境中定义的所有变量和函数。我们无法通过代码来访问变量对象,但是解析器在处理数据时会在后台使用到它。

  • 全局执行环境:

最外围的一个执行环境,根据ECMAscript实现所在的宿主环境不同,表示执行环境的对象也不同。在web浏览器中,我们可以认为它是window对象,因此所有的全局变量和函数都是作为window对象的属性和方法创建的。代码载入浏览器时,全局环境被创建,应用程序退出,如关闭网页或者浏览器时,全局执行环境被销毁。

  • 函数执行环境

每个函数都有自己的执行环境,当执行流进入一个函数时,函数的环境就被推入一个环境栈中,当函数执行完毕后,栈将其环境弹出,把控制权返回给之前的执行环境。

作用域

作用域是在运行时代码中的某些特定部分中变量,函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。

我们可以这样理解:作用域就是一个独立的地盘,让变量不会外泄、暴露出去。

也就是说作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。

作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行。

  • 在Java、C等语言中,作用域为for语句、if语句或{}内的一块区域,称为作用域;
  • 而在 JavaScript 中,作用域为function(){}内的区域,称为函数作用域。

值得注意的是:块语句(大括号“{}”中间的语句),如 if 和 switch 条件语句或 for 和 while 循环语句,不像函数,它们不会创建一个新的作用域。在块语句中定义的变量将保留在它们已经存在的作用域中。

if (true) {
    // 'if' 条件语句块不会创建一个新的作用域
    var name = 'Hammad'; // name 依然在全局作用域中
}
console.log(name); // logs 'Hammad'

作用域链:

  • 全局函数无法查看局部函数的内部细节,但局部函数可以查看其上层的函数细节,直至全局细节

  • 如果当前作用域没有找到属性或方法,会向上层作用域查找,直至全局函数,这种形式就是作用域链

在ES5中,js只有两种形式的作用域:全局作用域和函数作用域。 ES6中,新增了一个块级作用域(最近的大括号涵盖的范围),但是仅限于let, const方式申明的变量。

- with语句

说到作用域链,不得不说with语句。with语句主要用来临时扩展作用域链,将语句中的对象添加到作用域的头部。看下面代码:

person={name:"yhb",age:22,height:175,wife:{name:"lwy",age:21}};
with(person.wife){
    console.log(name);
}

with语句将person.wife添加到当前作用域链的头部,所以输出的就是:“lwy"。with语句结束后,作用域链恢复正常。

块级作用域:

  1. 声明变量不会提升到代码块顶部
  2. 禁止重复声明
  3. 循环中的绑定块作用域的妙用(解决 var i 的问题)

****作用域与执行上下文:

我们知道 JavaScript 属于解释型语言,JavaScript 的执行分为:解释和执行两个阶段,这两个阶段所做的事并不一样:

  1. 解释阶段:

    • 词法分析
    • 语法分析
    • 作用域规则确定
  2. 执行阶段:

    • 创建执行上下文
    • 执行函数代码
    • 垃圾回收

JavaScript 解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。执行上下文最明显的就是 this 的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。

作用域和执行上下文之间最大的区别是:

  • 执行上下文在运行时确定,随时可能改变
  • 作用域在定义时就确定,并且不会改变

一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)。

同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。

1.4 闭包的两大作用:保存/保护

闭包:指有权访问另一个函数作用域中变量的函数,创建闭包的最常见的方式就是在一个函数内创建另一个函数,通过另一个函数访问这个函数的局部变量,利用闭包可以突破作用链域

使用闭包主要是为了设计私有的方法和变量。闭包的优点是可以避免全局变量的污染,缺点是闭包会常驻内存,会增大内存使用量,使用不当很容易造成内存泄露。在js中,函数即闭包,只有函数才会产生作用域的概念

闭包的特性:

  • 函数内再嵌套函数
  • 内部函数可以引用外层的参数和变量
  • 参数和变量不会被垃圾回收机制回收

闭包的作用:

  • 保护:保护私有变量不让外界干扰,与外界没有必然联系

  • 保存:形成一个不销毁的私有作用域,私有栈内存里面的东西会保存下来;以后它里面的东西还能被调用到

很多人认为,只有形成不销毁的栈内存才是闭包,或者简单的说是一个函数返回一个函数就是闭包。但,函数执行会形成一个全新的私有作用域,保护里面的变量不受外界干扰,这种保护机制就是闭包。从函数保护自己的私有变量不受外界干扰那一刻,闭包就形成了。

那么,使用闭包的两个好处/作用:保护和保存。

在阅读jQuery的源码时发现,它使用了自执行匿名函数把自己代码使用闭包包裹起来,不受外界方法的干扰,这样在自己开发过程中命名的方法不会和jQuery里面定义的方法冲突,利用的就是闭包的保护作用。

在tab选项卡这个需求里面,同样可以使用闭包形成的不销毁作用域/栈内存把变量存储起来,用户再点击时触发的方法通过作用域链查找到对应的索引变量,从而实现选项卡的正确切换,这个利用的是闭包的保存作用。

缺点:形成一个不被释放的私有上下文会消耗我们的栈内存。所以说我们项目中应该尽可能减少对闭包的使用。

1.5 JS编辑机制:VO/AO/GO

  • ECStack:Execution Context Stack 执行环境栈
  • EC:Execution Context 执行环境(执行上下文)
  • VO:Variable Object 变量对象
  • AO:Activation Object 活动对象 (函数的叫做AO,可以理解为VO的一个分支)
  • Scope:作用域,创建的函数的时候就赋予的
  • Scope Chain:作用域链
  • GO:global object 全局对象

首先,浏览器在执行代码时,会开辟出一块专门执行代码的栈内存空间:执行栈(Execution Context Stack )。

然后,进栈把执行上下文(EC)压入执行栈里进行代码执行;执行完以后,没有用了,离开执行栈:出栈

再来,在全局的执行上下文(EC(G))会有一个全局对象Global Object在浏览器中会指向window。

再来,在EC(G)中有一个变量存储对象VO;在函数执行上下文EC(某函数)中是活动对象AO存储变量。

函数创建和执行时会发生什么呢?

 function fn(){     
     console.log('公号ID:zhaoxiajingjing'); 
 } 
fn();  // ① 
fn();  // ②

函数创建时,会初始化它的户口所在地——当前函数的作用域:FN[scope]:VO(G)。即:函数FN的作用域是全局的变量存储对象。

函数执行时,每一次一个函数执行都会创建一个全新的函数执行上下文EC(FN)

①、EC(FN)

②、AO里面存着函数里面需要的东西

  • 初始化THIS指向
  • 初始化作用域链(寻根):scopeChain:<当前执行上下文的变量对象, 户口所在地执行上下文的变量对象>
  • arguments实参集合
  • 形参赋值、变量提升.......

把EC(G)压缩到ECStack的栈底后,函数进栈执行,当EC(FN)

①中代码执行完了,它没有被外界占用的东西,就会出栈等着销毁了。EC(FN)

②也执行完出栈后,EC(G)会重新调回来继续执行。

img

1.6 V8内存管理、垃圾收集、引用记数、标记清除、标记整理和增量标记

[谷歌等浏览器是“基于引用查找”来进行垃圾回收的]

开辟的堆内存,浏览器自己默认会在空闲的时候,查找所有内存的引用,把那些不被引用的内存释放掉 开辟的栈内存(上下文) 一般在代码执行完都会出栈释放,如果遇到.上下文中的东西被外部占用,则不会释放

[IE等浏览器是“基于计数器”机制来进行内存管理的]

创建的内存被引用一次,则计数1 ,在被引用一次,计数2…移除引用减去1… 当减为零的时候,浏览器会把内存释放掉=>真实项目中,某些情况导致计数规则会出现一些问题,造成很多内存不能被释放掉,产生”内存泄漏”; 查找引用的方式如果形成相互引用,也会导致“内存泄漏”

标记清除(mark and sweep)

  • 这是JavaScript最常见的垃圾回收方式,当变量进入执行环境的时候,比如函数中声明一个变量,垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”
  • 垃圾回收器会在运行的时候给存储在内存中的所有变量加上标记,然后去掉环境中的变量以及被环境中变量所引用的变量(闭包),在这些完成之后仍存在标记的就是要删除的变量了

引用计数(reference counting)

在低版本IE中经常会出现内存泄露,很多时候就是因为其采用引用计数方式进行垃圾回收。引用计数的策略是跟踪记录每个值被使用的次数,当声明了一个 变量并将一个引用类型赋值给该变量的时候这个值的引用次数就加1,如果该变量的值变成了另外一个,则这个值得引用次数减1,当这个值的引用次数变为0的时 候,说明没有变量在使用,这个值没法被访问了,因此可以将其占用的空间回收,这样垃圾回收器会在运行的时候清理掉引用次数为0的值占用的空间

为什么基本数据类型存储在栈中,引用数据类型存储在堆中?

JavaScript引擎需要用栈来维护程序执行期间的上下文的状态,如果栈空间大了的话,所有数据都存放在栈空间里面,会影响到上下文切换的效率,进而影响整个程序的执行效率。

V8内存管理

堆空间存储的数据比较复杂,大致可以划分为下面 5 个区域:代码区(Code Space)、Map 区(Map Space)、大对象区(Large Object Space)、新生代(New Space)、老生代(Old Space)。这里主要讨论新生代和老生代的内存回收算法。

新生代内存是临时分配的内存,存活时间段,老生代内存是常驻内存,存活时间长。

2337921942-b409f804c910fcbc_fix732.png

新生代内存回收---Scavenge 算法

新生代中用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域(from),一半是空闲区域 (to)。

新的对象会首先被分配到 from 空间,当进行垃圾回收的时候,会先将 from 空间中的存活的对象复制到 to 空间进行保存,对未存活的对象的空间进行回收。复制完成后, from 空间和 to 空间进行调换,to 空间会变成新的 from 空间,原来的 from 空间则变成 to 空间。这种算法称之为 ”Scavenge“。

2111774847-4da2d349eca32052_fix732.png

新生代内存回收频率很高,速度也很快,但是空间利用率很低,因为有一半的内存空间处于"闲置"状态。

老生代内存回收

新生代中多次进行回收仍然存活的对象会被转移到空间较大的老生代内存中,这种现象称为晋升。以下两种情况

  1. 在垃圾回收过程中,发现某个对象之前被清理过,那么将会晋升到老生代的内存空间中
  2. 在 from 空间和 to 空间进行反转的过程中,如果 to 空间中的使用量已经超过了 25% ,那么就讲 from 中的对象直接晋升到老生代内存空间中。

因为老生代空间较大,如果仍然用 Scavenge 算法来频繁复制对象,那么性能开销就太大了。

标记-清除(Mark-Sweep)

老生代采用的是”标记清除“来回收未存活的对象。

分为标记和清除两个阶段。标记阶段会遍历堆中所有的对象,并对存活的对象进行标记,清除阶段则是对未标记的对象进行清除。

2375356670-9026026f15a2a5ae_fix732.png

标记清除不会对内存一分为二,所以不会浪费空间。但是经过标记清除之后的内存空间会生产很多不连续的碎片空间,这种不连续的碎片空间中,在遇到较大的对象时可能会由于空间不足而导致无法存储。

标记-整理(Mark-Compact)

为了解决内存碎片的问题,需要使用另外一种算法 - 标记-整理(Mark-Compact) 。标记整理对待未存活对象不是立即回收,而是将存活对象移动到一边,然后直接清掉端边界以外的内存。

84708567-69b09f34c7000a10_fix732.png

增量标记

为了避免出现JavaScript应用程序与垃圾回收器看到的不一致的情况,进行垃圾回收的时候,都需要将正在运行的程序停下来,等待垃圾回收执行完成之后再回复程序的执行,这种现象称为“全停顿”。如果需要回收的数据过多,那么全停顿的时候就会比较长,会影响其他程序的正常执行。

3040689593-869e2ea7f5e589cb_fix732.png

为了避免垃圾回收时间过长影响其他程序的执行,V8将标记过程分成一个个小的子标记过程,同时让垃圾回收和JavaScript应用逻辑代码交替执行,直到标记阶段完成。我们称这个过程为增量标记算法。

2104141232-40f44da5e5d9e501_fix732.png

通俗理解,就是把垃圾回收这个大的任务分成一个个小任务,穿插在 JavaScript任务中间执行,这个过程其实跟 React Fiber 的设计思路类似。

1.7 JS高级编程技巧:惰性函数/柯里化函数/高阶函数

惰性函数

通过浏览器兼容场景引出惰性函数

(1) 一般情况处理浏览器兼容的事件绑定 JS中的事件绑定 DOM2事件绑定:

①=>新版浏览器中: [元素].addEventListener([TYPE],[FUNC])

②=>老版浏览器中:[元素].attachEvent([on+TYPE],[FUNC])

③都不支持 [元素].onTYPE=[FUNC]

属性 in 对象:检测对象中是否存在这个属性

function handleEvent(element, type, func) {
    if ('addEventListener' in element) {
        element.addEventListener(type, func);
    } else if ('attachEvent' in element) {
        element.attachEvent('on' + type, func);
    } else {
        element['on' + type] = func;
    }
} 
​
handleEvent(document.body, 'click', function () {
    console.log('BODY点击');
});
​
handleEvent(document.documentElement, 'mouseenter', function () {
    console.log('HTML进入');
}); 

基于惰性思想代码优化 以上代码处理浏览器兼容的事件绑定是没有问题的,但是呢?会有性能上的消耗,每一次执行handleEvent()都要去判断一下当前兼不兼容,handleEvent调10000次每一次进来都要判断一下兼不兼容,每一次都把一件事情重复做不好。

这样是没有必要的:因为第一次已经知道浏览器兼不兼容,第二次就不用考虑了。

惰性思想就是为了解决这种事情的。惰性思想:,能够执行一次搞定的,绝对不会重复干两次。通过这样提高代码的运行速度和简洁性,那么该怎么样基于惰性思想解决这种事情?

/* 惰性思想:懒,能够执行一次搞定的,绝对不会重复干两次 */
function handleEvent(element, type, func) {
    if ('addEventListener' in element) {
        handleEvent = function (element, type, func) {
            element.addEventListener(type, func);
        };
    } else if ('attachEvent' in element) {
        handleEvent = function (element, type, func) {
            element.attachEvent('on' + type, func);
        };
    } else {
        handleEvent = function (element, type, func) {
            element['on' + type] = func;
        };
    }
    // 第一次执行重写方法后,需要执行一次才能保证第一次事件也绑定了
    handleEvent(element, type, func);
}

handleEvent(document.body, 'click', function () {
    console.log('BODY点击');
});
handleEvent(document.documentElement, 'mouseenter', function () {
    console.log('HTML进入');
}); 

代码原理:一个函数执行,把函数重新赋值,赋值一个新的函数。大函数执行形成一个私有上下文,在私有上下文里边创建一个小函数,把小函数地址给了当前这个大函数,相当于当前上下文中的某个东西被外面的作用域占用了,不能释放。

形成这样的特点:这个函数再次执行,它的上级上下文,就是第一次执行的上下文.func方法重构就是利用这个闭包思想。

第一次执行形成一个不释放的闭包。执行方法=>条件成立=>方法重写.第一次执行重写方法后,需要执行一次才能保证第一次事件也绑定了。

当第二次及以后执行的时候就不再执行外边的大函数,而是执行里边重写后的handlEvent(),你会发现兼容处理和兼容判断只发生在第一次大的handleEvent()函数里。以后再次执行的时候直接使用重写后的小方法了,就不再考虑兼容判断了.

函数柯里化思想

柯理化函数编程思想:利用闭包的保存机制,事先把一些信息存储起来(存储到不释放的上下文中),这样可以供下级上下文中调用 => 我们把这种预先存储的思想叫做柯理化函数编程思想。

柯里化思想的应用:bind,redux,包括它的中间件的源码,jQuery中的很多源码,高阶组件,真实项目中等还有很多也是用柯里化实现的。

实现函数fn,让其具有如下功能:

let res = fn(1,2)(3);
console.log(res); //=>6  1+2+3
/*代码详解*/
function fn(...outerArgs) {
    // outerArgs = [1,2]
    return function anonymous(...innerArgs) {
        // innerArgs = [3]
        let args = outerArgs.concat(innerArgs);
        return args.reduce((sum, item) => {
            return sum + item;
        }, 0);
    }
}
let res = fn(1, 2)(3);
console.log(res); //=>6  1+2+3

补充: reduce

// reduce依次遍历数组中的每一项,每一次遍历都会触发回调函数执行
//   => n:如果reduce不传递第二个参数,第一次获取的是数组第一项,其余每一次获取的值是上一次回调函数处理的结果(传递第二个参数,第一次获取的是第二个实参信息)
//   => m:依次遍历的数组每一项
let arr = [10, 20, 30, 40];
let total = arr.reduce((n, m) => {
    // 第一次: n=10 m=20
    // 第二次:n=30 m=30
    // 第三次:n=60 m=40
    console.log(n, m);
    return n + m;//100
});
console.log(total);

arr.reduce(callback,[initialValue])
callback (执行数组中每个值的函数,包含四个参数)

reduce参数解析

  • 1、previousValue (上一次调用回调返回的值,或者是提供的初始值(initialValue))
  • 2、currentValue (数组中当前被处理的元素)
  • 3、index (当前元素在数组中的索引)
  • 4、array (调用 reduce 的数组)

initialValue (作为第一次调用 callback 的第一个参数。)

var arr = [1, 2, 3, 4];
var sum = arr.reduce(function(prev, cur, index, arr) {
    console.log(prev, cur, index);
    return prev + cur;
})
console.log(arr, sum);

2. 面向对象(OOP)和this处理

2.1 单例设计模式

单例模式:限制类实例化次数只能一次,一个类只有一个实例,并提供一个访问它的全局访问点。

单例模式是创建型设计模式的一种。针对全局仅需一个对象的场景,如线程池、全局缓存、window 对象等。

模式特点:

  1. 类只有一个实例
  2. 全局可访问该实例
  3. 自行实例化(主动实例化)
  4. 可推迟初始化,即延迟执行(与静态类/对象的区别)

单例模式:构造函数每次创建对象,只有一个被创建.

单纯写个单例模式并不难,利用闭包,可以私有化变量.

var single = (function(){
    var demo;
    return function(name){
        if(demo){
            return demo;
        }
        this.name = name;
        demo = this;
    }
})

但是想要把任何函数都变成单例模式的函数,需要利用一下工具函数: (惰性单例模式)

function getSigleFn(fn){
    var result ;
    return function(){
        if(result){
            return result
        }
       result = fn.apply(this, arguments)//只有第一次执行;会走这里.这个arguements是指单例以后返回的函数的参数
       return result;
    }
}

这两个函数的写法总结:

1,都是利用了闭包保存要返回的值,并判断返回值有没有值,有就返回,没有就说明是第一次执行

2,始终要弄清楚你要的结果是啥,第一个要的是事例对象,第二个要的是一个新函数.

惰性单例模式

惰性单例,意图解决:需要时才创建类实例对象。对于懒加载的性能优化,想必前端开发者并不陌生。惰性单例也是解决 “按需加载” 的问题。

需求:页面弹窗提示,多次调用,都只有一个弹窗对象,只是展示信息内容不同。

开发这样一个全局弹窗对象,我们可以应用单例模式。为了提升它的性能,我们可以让它在我们需要调用时再去生成实例,创建 DOM 节点。

let getSingleton = function(fn) {
    var result;
    return function() {
        return result || (result = fn.apply(this, arguments)); // 确定this上下文并传递参数
    }
}
let createAlertMessage = function(html) {
    var div = document.createElement('div');
    div.innerHTML = html;
    div.style.display = 'none';
    document.body.appendChild(div);
    return div;
}
​
let createSingleAlertMessage = getSingleton(createAlertMessage);
document.body.addEventListener('click', function(){
    // 多次点击只会产生一个弹窗
    let alertMessage = createSingleAlertMessage('您的知识需要付费充值!');
    alertMessage.style.display = 'block';
})

代码中演示是一个通用的 “惰性单例” 的创建方式,如果还需要 createLoginLayer 登录框, createFrame Frame框, 都可以调用 getSingleton(...) 生成对应实例对象的方法。

2.2 Constructor构造函数模式

function Car(model,year,miles){
  this.model = model;
  this.year = year;
  this.miles = miles;
}

//原型函数
Car.prototype.toString = function(){
  return this.model + "has done" + this.miles +"miles";
}

//创建实例化对象
var civio = new Car("Honda Civio",2009,20000);
var mondeo= new Car("Ford Mondeo",2009,5000);

console.log(civio.toString())
console.log(mondeo.toString())

2.3 类和实例

思考:JavaScript如何实现一个类,怎么实例化这个类?

  • 构造函数法(this + prototype) -- 用 new 关键字 生成实例对象

  • 缺点:用到了 this 和 prototype,编写复杂,可读性差

function Mobile(name, price){
    this.name = name;
    this.price = price;
}
Mobile.prototype.sell = function(){
    alert(this.name + ",售价 $" + this.price);
}
var iPhone7 = new Mobile("iPhone7", 1000);
iPhone7.sell();
  • Object.create 法 -- 用 Object.create() 生成实例对象
  • 缺点:不能实现私有属性和私有方法,实例对象之间也不能共享数据
var Person = {
    firstname: "Mark",
    lastname: "Yun",
    age: 25,
    introduce: function(){
        alert('I am ' + Person.firstname + ' ' + Person.lastname);
    }
};
​
var person = Object.create(Person);
person.introduce();
​
// Object.create 要求 IE9+,低版本浏览器可以自行部署:
if (!Object.create) {
    Object.create = function (o) {
        function F() {}
        F.prototype = o;
        return new F();
    };
}
  • 极简主义法(消除 this 和 prototype) -- 调用 createNew() 得到实例对象

  • 优点:容易理解,结构清晰优雅,符合传统的"面向对象编程"的构造

var Cat = {
    age: 3, // 共享数据 -- 定义在类对象内,createNew() 外
    createNew: function () {
        var cat = {};
        // var cat = Animal.createNew(); // 继承 Animal 类
        cat.name = "小咪";
        var sound = "喵喵喵"; // 私有属性--定义在 createNew() 内,输出对象外
        cat.makeSound = function () {
            alert(sound);  // 暴露私有属性
        };
        cat.changeAge = function(num){
            Cat.age = num; // 修改共享数据
        };
        return cat; // 输出对象
    }
};
​
var cat = Cat.createNew();
cat.makeSound();
  • ES6 语法糖 class -- 用 new 关键字 生成实例对象
class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return '(' + this.x + ', ' + this.y + ')';
    }
}
​
var point = new Point(2, 3);

javascript创建对象的几种方式?

javascript创建对象简单的说,无非就是使用内置对象或各种自定义对象,当然还可以用JSON;但写法有很多种,也能混合使用

  • 对象字面量的方式
person= {
    firstname:"Mark",
    lastname:"Yun",
    age:25,
    eyecolor:"black"
};
  • 用function来模拟无参的构造函数
function Person(){}

var person=new Person();//定义一个function,如果使用new"实例化",该function可以看作是一个Class
person.name="Mark";
person.age="25";

person.work=function(){
    alert(person.name+" hello...");
}

person.work();
  • 用function来模拟参构造函数来实现(用this关键字定义构造的上下文属性)
function Pet(name,age,hobby){
    this.name=name;  //this作用域:当前对象
    this.age=age;
    this.hobby=hobby;
    
    this.eat=function(){
        alert("我叫"+this.name+",我喜欢"+this.hobby+",是个程序员");
    }
}
var maidou =new Pet("麦兜",25,"coding");//实例化、创建对象
maidou.eat();//调用eat方法
  • 用工厂方式来创建(内置对象)
var wcDog =new Object();

wcDog.name="旺财";
wcDog.age=3;

wcDog.work=function(){
    alert("我是"+wcDog.name+",汪汪汪......");
}
wcDog.work();
  • 用原型方式来创建
function Dog(){}

Dog.prototype.name="旺财";
Dog.prototype.eat=function(){
    alert(this.name+"是个吃货");
}

var wangcai =new Dog();
wangcai.eat();
  • 用混合方式来创建
function Car(name,price){
    this.name=name;
    this.price=price; 
}
Car.prototype.sell=function(){
    alert("我是"+this.name+",我现在卖"+this.price+"万元");
}

var camry =new Car("凯美瑞",27);
camry.sell(); 
  • Object.create
var p = { name:'smyhvae' };
var obj3 = Object.create(p);  //此方法创建的对象,是用原型链连接的
  • 行为委托
// Object.create()  polyfill
if (typeof Object.create !== "function") {
    Object.create = function (proto, propertiesObject) {
        if (!(proto === null || typeof proto === "object" || typeof proto === "function")) {
            throw TypeError('Argument must be an object, or null');
        }
        var temp = new Object();
        temp.__proto__ = proto;
        if(typeof propertiesObject ==="object")
            Object.defineProperties(temp,propertiesObject);
        return temp;
    };
}

思考:Js存在类吗?本质是什么?

class继承是一种较为常用的继承方式,但是实质上,js中并没有类的概念,class实际上还是函数的语法糖

class Person {}
Person instanceof Function // true

2.4 原型和原型链

  • 原型:

    • JavaScript的所有对象中都包含了一个 __proto__内部属性,这个属性所对应的就是该对象的原型
    • JavaScript的函数对象,除了原型 __proto__之外,还预置了 prototype 属性
    • 当函数对象作为构造函数创建实例时,该 prototype 属性值将被作为实例对象的原型 __proto __。
  • 原型链:

    • 当一个对象调用的属性/方法自身不存在时,就会去自己 [proto] 关联的前辈 prototype 对象上去找
    • 如果没找到,就会去该 prototype 原型__proto__ 关联的前辈 prototype 去找。依次类推,直到找到属性/方法或 undefined 为止。从而形成了所谓的“原型链”
  • 关系:instance.constructor.prototype = instance.__proto__

  • 原型特点:

    • JavaScript对象是通过引用来传递的,当修改原型时,与之相关的对象也会继承这一改变

PS:任何一个函数,如果在前面加了new,那就是构造函数。

总结

下面首先要看几个概念:

__proto__作为不同对象之间的桥梁,用来指向创建它的构造函数的原型对象的

image.png 每个对象的__proto__都是指向它的构造函数的原型对象prototype

person1.__proto__ === Person.prototype

构造函数是一个函数对象,是通过 Function构造器产生的

Person.__proto__ === Function.prototype

原型对象本身是一个普通对象,而普通对象的构造函数都是Object

Person.prototype.__proto__ === Object.prototype

刚刚上面说了,所有的构造器都是函数对象,函数对象都是 Function构造产生的

Object.__proto__ === Function.prototype

Object的原型对象也有__proto__属性指向nullnull是原型链的顶端

Object.prototype.__proto__ === null

总结

  • 一切对象都是继承自Object对象,Object 对象直接继承根源对象null
  • 一切的函数对象(包括 Object 对象),都是继承自 Function 对象
  • Object 对象直接继承自 Function 对象
  • Function对象的__proto__会指向自己的原型对象,最终还是继承自Object对象

New运算符的实现机制

  • 创建实例对象,this 变量引用该对象,同时还继承了构造函数的原型

  • 属性和方法被加入到 this 引用的对象中

  • 新创建的对象由 this 所引用,并且最后隐式的返回 this

    var obj  = {};
    obj.__proto__ = Base.prototype;
    Base.call(obj);
    

new操作符做了这些事:

  • 它创建了一个全新的对象
  • 它会被执行ptotype,就是_proto__链接
  • 它使this指向新创建的对象
  • 通过new创建的每个对象将最终被Prototype链接到这个函数的prototype对象上
  • 如果函数没有返回对象类型Object(包含Functoin, Array, Date, RegExg, Error),那么new表达式中的函数调用将返回该对象引用
// objectFactory(name, 'cxk', '18')
function objectFactory() {
  const obj = new Object();
  const Constructor = [].shift.call(arguments);
  obj.__proto__ = Constructor.prototype;
  const ret = Constructor.apply(obj, arguments);
  return typeof ret === "object" ? ret : obj;
}

2.5 call/apply/bind

call

call做了什么:

  • 将函数设为对象的属性
  • 执行&删除这个函数
  • 指定this到函数并传入给定参数执行函数
  • 如果不传入参数,默认指向为 window
// 模拟 call bar.mycall(null);
//实现一个call方法:
Function.prototype.myCall = function(context) {
  //此处没有考虑context非object情况
  context.fn = this;
  let args = [];
  for (let i = 1, len = arguments.length; i < len; i++) {
    args.push(arguments[i]);
  }
  context.fn(...args);
  let result = context.fn(...args);
  delete context.fn;
  return result;
};

apply

apply原理与call很相似,不多赘述

// 模拟 apply
Function.prototype.myapply = function(context, arr) {
  var context = Object(context) || window;
  context.fn = this;
​
  var result;
  if (!arr) {
    result = context.fn();
  } else {
    var args = [];
    for (var i = 0, len = arr.length; i < len; i++) {
      args.push("arr[" + i + "]");
    }
    result = eval("context.fn(" + args + ")");
  }

  delete context.fn;
  return result;
};

bind

实现bind要做什么

  • 返回一个函数,绑定this,传递预置参数
  • bind返回的函数可以作为构造函数使用。故作为构造函数时应使得this失效,但是传入的参数依然有效
// 实现1:
// mdn的实现
if (!Function.prototype.bind) {
  Function.prototype.bind = function(oThis) {
    if (typeof this !== 'function') {
      // closest thing possible to the ECMAScript 5
      // internal IsCallable function
      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
    }
​
    var aArgs   = Array.prototype.slice.call(arguments, 1),
        fToBind = this,
        fNOP    = function() {},
        fBound  = function() {
          // this instanceof fBound === true时,说明返回的fBound被当做new的构造函数调用
          return fToBind.apply(this instanceof fBound
                 ? this
                 : oThis,
                 // 获取调用时(fBound)的传参.bind 返回的函数入参往往是这么传递的
                 aArgs.concat(Array.prototype.slice.call(arguments)));
        };
​
    // 维护原型关系
    if (this.prototype) {
      // Function.prototype doesn't have a prototype property
      fNOP.prototype = this.prototype; 
    }
    // 下行的代码使fBound.prototype是fNOP的实例,因此
    // 返回的fBound若作为new的构造函数,new生成的新对象作为this传入fBound,新对象的__proto__就是fNOP的实例
    fBound.prototype = new fNOP();
​
    return fBound;
  };
}
----------------------------------------------------------------
// 实现2:
Function.prototype.myBind = function (context) {
    // 判断调用对象是否为函数
    if (typeof this !== "function") {
        throw new TypeError("Error");
    }
​
    // 获取参数
    const args = [...arguments].slice(1),
          fn = this;
​
    return function Fn() {
​
        // 根据调用方式,传入不同绑定值
        return fn.apply(this instanceof Fn ? new fn(...arguments) : context, args.concat(...arguments)); 
    }
}

2.6 JS中的四大数据类型检测方案

1. typeof

语法:typeof [val] 返回当前值对应的数据类型(STRING)

优势:检测基本类型值还是很准确的,而且操作起来方便

略势: typeof null =>"object" 【01开头存储的是对象。】 typeof 检测数组/对象/正则等都是 "object",所以无法对对象数据类型细分

所有的值在内存中都是按照二进制存储的
/*
* 检测数据类型1:typeof
*   返回结果都是字符串
*   字符串中包含了对应的数据类型  "number"/"string"/"boolean"/"undefined"/"symbol"/"object"/"function"
* 
* 【局限性】
*    typeof null => "object"   null不是对象,它是空对象指针
*    检测数据或者正则等特殊的对象,返回结果都是"object",所以无法基于typeof判断是数据还是正则
*/
console.log(typeof []);  // =>"object"
console.log(typeof typeof []);  // =>"string"

function fn(){}
console.log(typeof fn);  // 'function'
console.log(typeof null);  // 'object'
let arr = [10,20];
console.log(typeof arr);  // 'object' 

2. instanceof 语法:[val] instanceof 类, 通过检测这个值是否属于这个类,从而验证是否为这个类型

优势:对于数组、正则、对象可以细分一下

略势: 基本数据类型无法基于它来进行检测 检测的原理:只要在当前实例的__proto__出现这个类,检测结果都是TRUR

let arr = [10,20];
console.log(arr instanceof Array);  // true
console.log(arr instanceof Object);  // true
let m = 10;
console.log(m instanceof Number);  // false
m = new Number(10);
console.log(m instanceof Number);  // true 

 function P(){}
P.prototype = Array.prototype;
let p = new P;
console.log(p instanceof Array);  // true

let arr = [10];
console.log(arr instanceof Array);  // true
console.log(Array[Symbol.hasInstance](arr)); // true
// + 基于 “实例 instanceof 类” 检测的时候,浏览器是这样处理的 “类[Symbol.hasInstance](实例)”
// + Function.prototype[Symbol.hasInstance] 值是一个函数
// + Symbol.hasInstance 方法执行原理
//   + 根据原型链(__proto__)查找机制,一层层查找该实例的原型链上是否存在这个类的原型(prototype):因为实例的__proto__都指向所属类的原型prototype
//   + arr.__proto__ === Array.prototype  => arr instanceof Array => ture
//   + arr.__proto__.__proto__ === Object.prototype  => arr instanceof Object => true

let obj = {};
// console.log(arr instanceof obj); // 报错
// 因为obj是一个对象,没办法调用 Function.prototype 上的方法(函数才可以调用) 

3. constructor

和instanceof类似,也是非专业检测数据类型的,但是可以这样处理一下

语法:[val].constructor === 类

优势:相对于instanceof来讲,基本类型也可以处理,而且因为获取实例的constructor实际上获取的是直接所属的类, 所以在检测准确性上比instanceof还好一点

略势:constructor是可以随意被改动的

let arr = [10];
let obj = {};
// 在constructor不改的情况下,可以检测是是普通对象还是数组
console.log(arr.constructor === Array);  // true
console.log(arr.constructor === Object);  // false
console.log(obj.constructor === Object);  // true */

/* function Person(){}
Person.prototype = Array.prototype;  // 一旦原型重定向了,constructor也改了,也就不准确了
let p = new Person;
console.log(p.constructor === Array); // true

let m = 10;
console.log(m.constructor === Number); // true 

4. Object.prototype.toString.call([val])

最强大的检测方案 在其它数据类型的内置类原型上有toString,但是都是用来转换为字符串的, 只有Object基类原型上的toString是用来检测数据类型的。

[11, 22].toString()的结果:去掉逗号后面空格的"11,22"。

obj.toString(): obj这个实例调用Object.prototype.toString执行,方法执行里面的THIS是当前操作的实例OBJ,此方法就是检测实例THIS的数据类型的,返回结果:"[object 所属的类]"。

Object.prototype.toString.call([val]):基于call强制改变方法中的this是[val],就相当于在检测val的数据类型 <=> ({}).toString.call([val])

({}).toString拿到 Object.prototype.toString。{} 可以是语句块,也可以是 对象字面量。

//------------Object.prototype.toString.call([value])-----------------
let valType = {},
toString = valType.toString;
console.log(toString.call([10]));  // '[object Array]'
console.log(toString.call(10));  // '[object Number]'
console.log(toString.call(30n));  // '[object BigInt]'
console.log(toString.call(null));  // '[object Null]'

function Person(){}
let p = new Person;
console.log(toString.call(p));  // [object Object] 
// 说明使用 对象.构造函数 只对内置类有效,对自己定义的类无效,想要实现检测p的类型是  "[object Person]",需要给 Person类中添加 [Symbol.toStringTag] 属性

class QQ{
  // 只要获取实例的 [Symbol.toStringTag] 属性值,则调用这个方法
  get [Symbol.toStringTag](){
    return 'QQ'
  }
}
let q = new QQ;
console.log(toString.call(q));  // '[object QQ]'

2.7 JS中的四大继承方案(寄生组合)

是什么

继承(inheritance)是面向对象软件技术当中的一个概念。

如果一个类别B“继承自”另一个类别A,就把这个B称为“A的子类”,而把A称为“B的父类别”也可以称“A是B的超类”

  • 优点

继承可以使得子类具有父类别的各种属性和方法,而不需要再次编写相同的代码

在子类别继承父类别的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能

虽然JavaScript并不是真正的面向对象语言,但它天生的灵活性,使应用场景更加丰富

关于继承,我们举个形象的例子:

// 定义一个类(Class)叫汽车,汽车的属性包括颜色、轮胎、品牌、速度、排气量等
class Car{
    constructor(color,speed){
        this.color = color
        this.speed = speed
        // ...
    }
}

由汽车这个类可以派生出“轿车”和“货车”两个类,在汽车的基础属性上,为轿车添加一个后备厢、给货车添加一个大货箱

// 货车
class Truck extends Car{
    constructor(color,speed){
        super(color,speed)
        this.Container = true // 货箱
    }
}

这样轿车和货车就是不一样的,但是二者都属于汽车这个类,汽车、轿车继承了汽车的属性,而不需要再次在“轿车”中定义汽车已经有的属性

在“轿车”继承“汽车”的同时,也可以重新定义汽车的某些属性,并重写或覆盖某些属性和方法,使其获得与“汽车”这个父类不同的属性和方法

class Truck extends Car{
    constructor(color,speed){
        super(color,speed)
        this.color = "black" //覆盖
        this.Container = true // 货箱
    }
}

从这个例子中就能详细说明汽车、轿车以及卡车之间的继承关系

  • 实现方式

下面给出JavaScripy常见的继承方式:

  • 原型链继承
  • 构造函数继承(借助 call)
  • 组合继承
  • 原型式继承
  • 寄生式继承
  • 寄生组合式继承
  1. 原型链继承

原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针

// 举个例子
 function Parent() {
    this.name = 'parent1';
    this.play = [1, 2, 3]
  }
  function Child() {
    this.type = 'child2';
  }
  Child.prototype = new Parent();
  console.log(new Child())

上面代码看似没问题,实际存在潜在问题

var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);
console.log(s1.play, s2.play); // [1,2,3,4]

改变s1play属性,会发现s2也跟着发生变化了,这是因为两个实例使用的是同一个原型对象,内存空间是共享的

  1. 构造函数继承

借助 call调用Parent函数

function Parent(){
    this.name = 'parent1';
}
​
Parent.prototype.getName = function () {
    return this.name;
}
​
function Child(){
    Parent1.call(this);
    this.type = 'child'
}
​
let child = new Child();
console.log(child);  // 没问题
console.log(child.getName());  // 会报错

可以看到,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法

相比第一种原型链继承方式,父类的引用属性不会被共享,优化了第一种继承方式的弊端,但是只能继承父类的实例属性和方法,不能继承原型属性或者方法

  1. 组合继承

前面我们讲到两种继承方式,各有优缺点。组合继承则将前两种方式继承起来

function Parent3 () {
    this.name = 'parent3';
    this.play = [1, 2, 3];
}
​
Parent3.prototype.getName = function () {
    return this.name;
}
function Child3() {
    // 第二次调用 Parent3()
    Parent3.call(this);
    this.type = 'child3';
}
​
// 第一次调用 Parent3()
Child3.prototype = new Parent3();
// 手动挂上构造器,指向自己的构造函数
Child3.prototype.constructor = Child3;
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play);  // 不互相影响
console.log(s3.getName()); // 正常输出'parent3'
console.log(s4.getName()); // 正常输出'parent3'

这种方式看起来就没什么问题,方式一和方式二的问题都解决了,但是从上面代码我们也可以看到Parent3 执行了两次,造成了多构造一次的性能开销

  1. 原型式继承

这里主要借助Object.create方法实现普通对象的继承

同样举个例子

let parent4 = {
    name: "parent4",
    friends: ["p1", "p2", "p3"],
    getName: function() {
      return this.name;
    }
  };
​
  let person4 = Object.create(parent4);
  person4.name = "tom";
  person4.friends.push("jerry");
​
  let person5 = Object.create(parent4);
  person5.friends.push("lucy");
​
  console.log(person4.name); // tom
  console.log(person4.name === person4.getName()); // true
  console.log(person5.name); // parent4
  console.log(person4.friends); // ["p1", "p2", "p3","jerry","lucy"]
  console.log(person5.friends); // ["p1", "p2", "p3","jerry","lucy"]

缺点也很明显:因为Object.create方法实现的是浅拷贝,多个实例的引用类型属性指向相同的内存,存在篡改的可能

  1. 寄生式继承

寄生式继承在上面继承基础上进行优化,利用这个浅拷贝的能力再进行增强,添加一些方法

let parent5 = {
    name: "parent5",
    friends: ["p1", "p2", "p3"],
    getName: function() {
        return this.name;
    }
};
​
function clone(original) {
    let clone = Object.create(original);
    clone.getFriends = function() {
        return this.friends;
    };
    return clone;
}
​
let person5 = clone(parent5);
​
console.log(person5.getName()); // parent5
console.log(person5.getFriends()); // ["p1", "p2", "p3"]

其优缺点也很明显,跟上面讲的原型式继承一样

  1. (重要) 寄生组合式继承

寄生组合式继承,借助解决普通对象的继承问题的Object.create 方法,在亲全面几种继承方式的优缺点基础上进行改造,这也是所有继承方式里面相对最优的继承方式

function clone (parent, child) {
    // 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
    child.prototype = Object.create(parent.prototype);
    child.prototype.constructor = child;
}
​
function Parent6() {
    this.name = 'parent6';
    this.play = [1, 2, 3];
}
Parent6.prototype.getName = function () {
    return this.name;
}
function Child6() {
    Parent6.call(this);
    this.friends = 'child5';
}
​
clone(Parent6, Child6);
​
Child6.prototype.getFriends = function () {
    return this.friends;
}
​
let person6 = new Child6(); 
console.log(person6); //{friends:"child5",name:"child5",play:[1,2,3],__proto__:Parent6}
console.log(person6.getName()); // parent6
console.log(person6.getFriends()); // child5

可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题

文章一开头,我们是使用ES6 中的extends关键字直接实现 JavaScript的继承

class Person {
  constructor(name) {
    this.name = name
  }
  // 原型方法
  // 即 Person.prototype.getName = function() { }
  // 下面可以简写为 getName() {...}
  getName = function () {
    console.log('Person:', this.name)
  }
}
class Gamer extends Person {
  constructor(name, age) {
    // 子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
    super(name)
    this.age = age
  }
}
const asuna = new Gamer('Asuna', 20)
asuna.getName() // 成功访问到父类的方法

利用babel工具进行转换,我们会发现extends实际采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方式

三、总结

下面以一张图作为总结:

image.png

通过Object.create 来划分不同的继承方式,最后的寄生式组合继承方式是通过组合继承改造之后的最优继承方式,而 extends 的语法糖和寄生组合继承的方式基本类似

2.8 this

函数的 this 关键字在 JavaScript 中的表现略有不同,此外,在严格模式和非严格模式之间也会有一些差别

在绝大多数情况下,函数的调用方式决定了 this 的值(运行时绑定)

this 关键字是函数运行时自动生成的一个内部对象,只能在函数内部使用,总指向调用它的对象

举个例子:

function baz() {
    // 当前调用栈是:baz
    // 因此,当前调用位置是全局作用域
    
    console.log( "baz" );
    bar(); // <-- bar的调用位置
}
​
function bar() {
    // 当前调用栈是:baz --> bar
    // 因此,当前调用位置在baz中
    
    console.log( "bar" );
    foo(); // <-- foo的调用位置
}
​
function foo() {
    // 当前调用栈是:baz --> bar --> foo
    // 因此,当前调用位置在bar中
    
    console.log( "foo" );
}
​
baz(); // <-- baz的调用位置

同时,this在函数执行过程中,this一旦被确定了,就不可以再更改

var a = 10;
var obj = {
  a: 20
}
​
function fn() {
  this = obj; // 修改this,运行后会报错
  console.log(this.a);
}
​
fn();

** 绑定规则**

根据不同的使用场合,this有不同的值,主要分为下面几种情况:

  • 默认绑定
  • 隐式绑定
  • new绑定
  • 显示绑定
  1. 默认绑定

全局环境中定义person函数,内部使用this关键字

var name = 'Jenny';
function person() {
    return this.name;
}
console.log(person());  //Jenny

上述代码输出Jenny,原因是调用函数的对象在游览器中位window,因此this指向window,所以输出Jenny

注意:严格模式下,不能将全局对象用于默认绑定,this会绑定到undefined,只有函数运行在非严格模式下,默认绑定才能绑定到全局对象

  1. 隐式绑定

函数还可以作为某个对象的方法调用,这时this就指这个上级对象

function test() {
  console.log(this.x);
}

var obj = {};
obj.x = 1;
obj.m = test;

obj.m(); // 1

这个函数中包含多个对象,尽管这个函数是被最外层的对象所调用,this指向的也只是它上一级的对象

var o = {
    a:10,
    b:{
        fn:function(){
            console.log(this.a); //undefined
        }
    }
}
o.b.fn();

上述代码中,this的上一级对象为bb内部并没有a变量的定义,所以输出undefined

这里再举一种特殊情况

var o = {
    a:10,
    b:{
        a:12,
        fn:function(){
            console.log(this.a); //undefined
            console.log(this); //window
        }
    }
}
var j = o.b.fn;
j();

此时this指向的是window,这里的大家需要记住,this永远指向的是最后调用它的对象,虽然fn是对象b的方法,但是fn赋值给j时候并没有执行,所以最终指向window

  1. new绑定

通过构建函数new关键字生成一个实例对象,此时this指向这个实例对象

function test() {
 this.x = 1;
}

var obj = new test();
obj.x // 1

上述代码之所以能过输出1,是因为new关键字改变了this的指向

这里再列举一些特殊情况:

new过程遇到return一个对象,此时this指向为返回的对象

function fn()  
{  
    this.user = 'xxx';  
    return {};  
}
var a = new fn();  
console.log(a.user); //undefined

如果返回一个简单类型的时候,则this指向实例对象

function fn()  
{  
    this.user = 'xxx';  
    return 1;
}
var a = new fn;  
console.log(a.user); //xxx

注意的是null虽然也是对象,但是此时new仍然指向实例对象

function fn()  
{  
    this.user = 'xxx';  
    return null;
}
var a = new fn;  
console.log(a.user); //xxx
  1. 显示绑定

apply()、call()、bind()是函数的一个方法,作用是改变函数的调用对象。它的第一个参数就表示改变后的调用这个函数的对象。因此,这时this指的就是这第一个参数

var x = 0;
function test() {
 console.log(this.x);
}
​
var obj = {};
obj.x = 1;
obj.m = test;
obj.m.apply(obj) // 1

箭头函数

在 ES6 的语法中还提供了箭头函语法,让我们在代码书写时就能确定 this 的指向(编译时绑定)

举个例子:

const obj = {
  sayThis: () => {
    console.log(this);
  }
};

obj.sayThis(); // window 因为 JavaScript 没有块作用域,所以在定义 sayThis 的时候,里面的 this 就绑到 window 上去了

const globalSay = obj.sayThis;
globalSay(); // window 浏览器中的 global 对象

虽然箭头函数的this能够在编译的时候就确定了this的指向,但也需要注意一些潜在的坑

下面举个例子:

绑定事件监听

const button = document.getElementById('mngb');
button.addEventListener('click', ()=> {
    console.log(this === window) // true
    this.innerHTML = 'clicked button'
})

上述可以看到,我们其实是想要this为点击的button,但此时this指向了window

包括在原型上添加方法时候,此时this指向window

Cat.prototype.sayName = () => {
    console.log(this === window) //true
    return this.name
}
const cat = new Cat('mm');
cat.sayName()

同样的,箭头函数不能作为构建函数

优先级

  • 隐式绑定 VS 显式绑定
function foo() {
    console.log( this.a );
}
​
var obj1 = {
    a: 2,
    foo: foo
};
​
var obj2 = {
    a: 3,
    foo: foo
};
​
obj1.foo(); // 2
obj2.foo(); // 3
​
obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2

显然,显示绑定的优先级更高

  • new绑定 VS 隐式绑定
function foo(something) {
    this.a = something;
}

var obj1 = {
    foo: foo
};

var obj2 = {};

obj1.foo( 2 );
console.log( obj1.a ); // 2

obj1.foo.call( obj2, 3 );
console.log( obj2.a ); // 3

var bar = new obj1.foo( 4 );
console.log( obj1.a ); // 2
console.log( bar.a ); // 4

可以看到,new绑定的优先级>隐式绑定

  • new绑定 VS 显式绑定

因为newapply、call无法一起使用,但硬绑定也是显式绑定的一种,可以替换测试

function foo(something) {
    this.a = something;
}

var obj1 = {};

var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2

var baz = new bar( 3 );
console.log( obj1.a ); // 2
console.log( baz.a ); // 3
bar`被绑定到obj1上,但是`new bar(3)` 并没有像我们预计的那样把`obj1.a`修改为3。但是,`new`修改了绑定调用`bar()`中的`this

我们可认为new绑定优先级>显式绑定

综上,new绑定优先级 > 显示绑定优先级 > 隐式绑定优先级 > 默认绑定优先级

一共五种情况:

  1. 事件绑定
/*
* THIS1:给元素的某个事件行为绑定方法,事件触发,方法执行,此时方法中的THIS一般都是当前元素本身
*/
//=>DOM0
 btn.onclick = function anonymous() {
    console.log(this); //=>元素
 };
//=>DOM2
 btn.addEventListener('click', function anonymous() {
    console.log(this);  //=>元素
 }, false);
 
 btn.attachEvent('onclick',function anonymous(){
    // <= IE8浏览器中的DOM2事件绑定
    console.log(this); //=>window
 });
​
 function fn() {    
    console.log(this);
 }
 btn.onclick = fn.bind(window); //=>fn.bind(window)首先会返回一个匿名函数(AM),把AM绑定给事件;点击触发执行AM,AM中的THIS是元素,但是会在AM中执行FN,FN中的THIS是预先指定的WINDOW
  1. 函数执行(包括自执行函数)
/*
* THIS2:普通函数执行,它里面的THIS是谁,取决于方法执行前面是否有“点”,有的话,“点”前面是谁THIS就是谁,没有THIS指向WINDOW(严格模式下是UNDEFINED) 
*/
function fn() {
    console.log(this);
}
let obj = {
    name: 'OBJ',
    fn: fn
}; 
fn();
obj.fn();
console.log(obj.hasOwnProperty('name')); //=>hasOwnProperty方法中的this:obj  TRUE
console.log(obj.__proto__.hasOwnProperty('name')); //=>hasOwnProperty方法中的this:obj.__proto__(Object.prototype)  FALSE
console.log(Object.prototype.hasOwnProperty.call(obj, 'name')); //<=> obj.hasOwnProperty('name')

/*
* hasOwnProperty:用来检测某个属性名是否属于当前对象的私有属性  
* in是用来检测是否为其属性(不论私有还是公有) 
* 
* Object.prototype.hasOwnProperty=function hasOwnProperty(){}
*/
console.log(obj.hasOwnProperty('name')); //=>true
console.log(obj.hasOwnProperty('toString')); //=>false
console.log('toString' in obj); //=>true

  1. new构造函数
/*
* THIS3:构造函数执行(new xxx),函数中的this是当前类的实例
*/
function Fn() {
    console.log(this);
    //=>this.xxx=xxx 是给当前实例设置私有属性
}
let f = new Fn; 

  1. 箭头函数
  2. call/apply/bind

2.9 防抖节流

防抖在连续的事件,只需触发一次回调的场景有:

  • 搜索框搜索输入。只需用户最后一次输入完,再发送请求
  • 手机号、邮箱验证输入检测
  • 窗口大小resize。只需窗口调整完成后,计算窗口大小。防止重复渲染。

节流在间隔一段时间执行一次回调的场景有:

  • 滚动加载,加载更多或滚到底部监听
  • 搜索框,搜索联想功能

注意:有些时候需要两者的结合版本

3. DOM/BOM及事件处理机制

3.1 DOM/BOM的核心操作

BOM

一、是什么

BOM (Browser Object Model),浏览器对象模型,提供了独立于内容与浏览器窗口进行交互的对象

其作用就是跟浏览器做一些交互效果,比如如何进行页面的后退,前进,刷新,浏览器的窗口发生变化,滚动条的滚动,以及获取客户的一些信息如:浏览器品牌版本,屏幕分辨率

浏览器的全部内容可以看成DOM,整个浏览器可以看成BOM。区别如下:

img

二、window

Bom的核心对象是window,它表示浏览器的一个实例

在浏览器中,window对象有双重角色,即是浏览器窗口的一个接口,又是全局对象

因此所有在全局作用域中声明的变量、函数都会变成window对象的属性和方法

var name = 'js每日一题';
function lookName(){
  alert(this.name);
}

console.log(window.name);  //js每日一题
lookName();                //js每日一题
window.lookName();         //js每日一题

关于窗口控制方法如下:

  • moveBy(x,y):从当前位置水平移动窗体x个像素,垂直移动窗体y个像素,x为负数,将向左移动窗体,y为负数,将向上移动窗体
  • moveTo(x,y):移动窗体左上角到相对于屏幕左上角的(x,y)点
  • resizeBy(w,h):相对窗体当前的大小,宽度调整w个像素,高度调整h个像素。如果参数为负值,将缩小窗体,反之扩大窗体
  • resizeTo(w,h):把窗体宽度调整为w个像素,高度调整为h个像素
  • scrollTo(x,y):如果有滚动条,将横向滚动条移动到相对于窗体宽度为x个像素的位置,将纵向滚动条移动到相对于窗体高度为y个像素的位置
  • scrollBy(x,y): 如果有滚动条,将横向滚动条向左移动x个像素,将纵向滚动条向下移动y个像素

window.open() 既可以导航到一个特定的url,也可以打开一个新的浏览器窗口

如果 window.open() 传递了第二个参数,且该参数是已有窗口或者框架的名称,那么就会在目标窗口加载第一个参数指定的URL

window.open('htttp://www.vue3js.cn','topFrame')
==> < a href=" " target="topFrame"></ a>

window.open() 会返回新窗口的引用,也就是新窗口的 window 对象

const myWin = window.open('http://www.vue3js.cn','myWin')

window.close() 仅用于通过 window.open() 打开的窗口

新创建的 window 对象有一个 opener 属性,该属性指向打开他的原始窗口对象

三、location

url地址如下:

http://foouser:barpassword@www.wrox.com:80/WileyCDA/?q=javascript#contents

location属性描述如下:

属性名例子说明
hash"#contents"utl中#后面的字符,没有则返回空串
hostwww.wrox.com:80服务器名称和端口号
hostnamewww.wrox.com域名,不带端口号
hrefwww.wrox.com:80/WileyCDA/?q…完整url
pathname"/WileyCDA/"服务器下面的文件路径
port80url的端口号,没有则为空
protocolhttp:使用的协议
search?q=javascripturl的查询字符串,通常为?后面的内容

除了 hash之外,只要修改location的一个属性,就会导致页面重新加载新URL

location.reload(),此方法可以重新刷新当前页面。这个方法会根据最有效的方式刷新页面,如果页面自上一次请求以来没有改变过,页面就会从浏览器缓存中重新加载

如果要强制从服务器中重新加载,传递一个参数true即可

四、navigator

navigator 对象主要用来获取浏览器的属性,区分浏览器类型。属性较多,且兼容性比较复杂

下表列出了navigator对象接口定义的属性和方法:

img

68747470733a2f2f7374617469632e7675652d6a732e636f6d2f37343039363632302d383038392d313165622d616239302d6439616538313462323430642e706e67.png

五、screen

保存的纯粹是客户端能力信息,也就是浏览器窗口外面的客户端显示器的信息,比如像素宽度和像素高度

image.png

六、history

history对象主要用来操作浏览器URL的历史记录,可以通过参数向前,向后,或者向指定URL跳转

常用的属性如下:

  • history.go()

接收一个整数数字或者字符串参数:向最近的一个记录中包含指定字符串的页面跳转,

history.go('maixaofei.com')

当参数为整数数字的时候,正数表示向前跳转指定的页面,负数为向后跳转指定的页面

history.go(3) //向前跳转三个记录
history.go(-1) //向后跳转一个记录
  • history.forward():向前跳转一个页面
  • history.back():向后跳转一个页面
  • history.length:获取历史记录数

DOM

什么是 Window 对象? 什么是 Document 对象?

  • Window 对象表示当前浏览器的窗口,是JavaScript的顶级对象。
  • 我们创建的所有对象、函数、变量都是 Window 对象的成员。
  • Window 对象的方法和属性是在全局范围内有效的。
  • Document 对象是 HTML 文档的根节点与所有其他节点(元素节点,文本节点,属性节点, 注释节点)
  • Document 对象使我们可以通过脚本对 HTML 页面中的所有元素进行访问
  • Document 对象是 Window 对象的一部分,可通过 window.document 属性对其进行访问

介绍 DOM 的发展

  • DOM:文档对象模型(Document Object Model),定义了访问HTML和XML文档的标准,与编程语言及平台无关
  • DOM0:提供了查询和操作Web文档的内容API。未形成标准,实现混乱。如:document.forms['login']
  • DOM1:W3C提出标准化的DOM,简化了对文档中任意部分的访问和操作。如:JavaScript中的Document对象
  • DOM2:原来DOM基础上扩充了鼠标事件等细分模块,增加了对CSS的支持。如:getComputedStyle(elem, pseudo)
  • DOM3:增加了XPath模块和加载与保存(Load and Save)模块。如:XPathEvaluator

介绍DOM0,DOM2,DOM3事件处理方式区别

  • DOM0级事件处理方式:

    • btn.onclick = func;
    • btn.onclick = null;
  • DOM2级事件处理方式:

    • btn.addEventListener('click', func, false);
    • btn.removeEventListener('click', func, false);
    • btn.attachEvent("onclick", func);
    • btn.detachEvent("onclick", func);
  • DOM3级事件处理方式:

    • eventUtil.addListener(input, "textInput", func);
    • eventUtil 是自定义对象,textInput 是DOM3级事件

事件的三个阶段

  • 捕获、目标、冒泡

  • 说明:捕获阶段,事件依次传递的顺序是:window --> document --> html--> body --> 父元素、子元素、目标元素。

PS1:第一个接收到事件的对象是 window(有人会说body,有人会说html,这都是错误的)。

PS2:JS中涉及到DOM对象时,有两个对象最常用:windowdoucument。它们俩也是最先获取到事件的。

介绍事件“捕获”和“冒泡”执行顺序和事件的执行次数?

  • 按照W3C标准的事件:首是进入捕获阶段,直到达到目标元素,再进入冒泡阶段

  • 事件执行次数(DOM2-addEventListener):元素上绑定事件的个数

    • 注意1:前提是事件被确实触发
    • 注意2:事件绑定几次就算几个事件,即使类型和功能完全一样也不会“覆盖”
  • 事件执行顺序:判断的关键是否目标元素

    • 非目标元素:根据W3C的标准执行:捕获->目标元素->冒泡(不依据事件绑定顺序)
    • 目标元素:依据事件绑定顺序:先绑定的事件先执行(不依据捕获冒泡标准)
    • 最终顺序:父元素捕获->目标元素事件1->目标元素事件2->子元素捕获->子元素冒泡->父元素冒泡
    • 注意:子元素事件执行前提 事件确实“落”到子元素布局区域上,而不是简单的具有嵌套关系

在一个DOM上同时绑定两个点击事件:一个用捕获,一个用冒泡。事件会执行几次,先执行冒泡还是捕获?

  • 该DOM上的事件如果被触发,会执行两次(执行次数等于绑定次数)
  • 如果该DOM是目标元素,则按事件绑定顺序执行,不区分冒泡/捕获
  • 如果该DOM是处于事件流中的非目标元素,则先执行捕获,后执行冒泡

事件的代理/委托

  • 事件委托是指将事件绑定目标元素的到父元素上,利用冒泡机制触发该事件

    • 优点:

      • 可以减少事件注册,节省大量内存占用
      • 可以将事件应用于动态添加的子元素上
    • 缺点: 使用不当会造成事件在不应该触发时触发

    • 示例:

ulEl.addEventListener('click', function(e){
    var target = event.target || event.srcElement;
    if(!!target && target.nodeName.toUpperCase() === "LI"){
        console.log(target.innerHTML);
    }
}, false);

IE与火狐的事件机制有什么区别? 如何阻止冒泡?

  • IE只有事件冒泡,不支持事件捕获;火狐同时支持件冒泡和事件捕获

IE的事件处理和W3C的事件处理有哪些区别?

  • 绑定事件

    • W3C: targetEl.addEventListener('click', handler, false);
    • IE: targetEl.attachEvent('onclick', handler);
  • 删除事件

    • W3C: targetEl.removeEventListener('click', handler, false);
    • IE: targetEl.detachEvent(event, handler);
  • 事件对象

    • W3C: var e = arguments.callee.caller.arguments[0]
    • IE: window.event
  • 事件目标

    • W3C: e.target
    • IE: window.event.srcElement
  • 阻止事件默认行为

    • W3C: e.preventDefault()
    • IE: window.event.returnValue = false
  • 阻止事件传播

    • W3C: e.stopPropagation()
    • IE: window.event.cancelBubble = true

W3C事件的 target 与 currentTarget 的区别?

  • target 只会出现在事件流的目标阶段
  • currentTarget 可能出现在事件流的任何阶段
  • 当事件流处在目标阶段时,二者的指向相同
  • 当事件流处于捕获或冒泡阶段时:currentTarget 指向当前事件活动的对象(一般为父级)

自定义事件

var myEvent = new Event('clickTest');
element.addEventListener('clickTest', function () {
    console.log('smyhvae');
});
​
//元素注册事件
element.dispatchEvent(myEvent); //注意,参数是写事件对象 myEvent,不是写 事件名 clickTest

上面这个事件是定义完了之后,就直接自动触发了。在正常的业务中,这个事件一般是和别的事件结合用的。比如延时器设置按钮的动作:

var myEvent = new Event('clickTest');
​
element.addEventListener('clickTest', function () {
    console.log('smyhvae');
});
​
setTimeout(function () {
    element.dispatchEvent(myEvent); //注意,参数是写事件对象 myEvent,不是写 事件名 clickTest
}, 1000);

如何派发事件(dispatchEvent)?(如何进行事件广播?)

  • W3C: 使用 dispatchEvent 方法
  • IE: 使用 fireEvent 方法
var fireEvent = function(element, event){
    if (document.createEventObject){
        var mockEvent = document.createEventObject();
        return element.fireEvent('on' + event, mockEvent)
    }else{
        var mockEvent = document.createEvent('HTMLEvents');
        mockEvent.initEvent(event, true, true);
        return !element.dispatchEvent(mockEvent);
    }
}

(重点)手写事件监听器

 // event(事件)工具集,来源:github.com/markyun
    markyun.Event = {
        // 页面加载完成后
        readyEvent : function(fn) {
            if (fn==null) {
                fn=document;
            }
            var oldonload = window.onload;
            if (typeof window.onload != 'function') {
                window.onload = fn;
            } else {
                window.onload = function() {
                    oldonload();
                    fn();
                };
            }
        },
        // 视能力分别使用dom0||dom2||IE方式 来绑定事件
        // 参数: 操作的元素,事件名称 ,事件处理程序
        addEvent : function(element, type, handler) {
            if (element.addEventListener) {
                //事件类型、需要执行的函数、是否捕捉
                element.addEventListener(type, handler, false);
            } else if (element.attachEvent) {
                element.attachEvent('on' + type, function() {
                    handler.call(element);
                });
            } else {
                element['on' + type] = handler;
            }
        },
        // 移除事件
        removeEvent : function(element, type, handler) {
            if (element.removeEventListener) {
                element.removeEventListener(type, handler, false);
            } else if (element.datachEvent) {
                element.detachEvent('on' + type, handler);
            } else {
                element['on' + type] = null;
            }
        },
        // 阻止事件 (主要是事件冒泡,因为IE不支持事件捕获)
        stopPropagation : function(ev) {
            if (ev.stopPropagation) {
                ev.stopPropagation();
            } else {
                ev.cancelBubble = true;
            }
        },
        // 取消事件的默认行为
        preventDefault : function(event) {
            if (event.preventDefault) {
                event.preventDefault();
            } else {
                event.returnValue = false;
            }
        },
        // 获取事件目标
        getTarget : function(event) {
            return event.target || event.srcElement;
        },
        // 获取event对象的引用,取到事件的所有信息,确保随时能使用event;
        getEvent : function(e) {
            var ev = e || window.event;
            if (!ev) {
                var c = this.getEvent.caller;
                while (c) {
                    ev = c.arguments[0];
                    if (ev && Event == ev.constructor) {
                        break;
                    }
                    c = c.caller;
                }
            }
            return ev;
        }
    };

阻止冒泡

box3.onclick = function (event) {
    alert("child");
    //阻止冒泡
    event = event || window.event;

    if (event && event.stopPropagation) {
        event.stopPropagation();
    } else {
        event.cancelBubble = true;
    }
}

阻止默认事件

<!-- 
    A标签的默认行为1: 页面跳转
    A标签的默认行为2: 锚点定位
--><!-- 锚点定位 -->
<a href="http://www.zhufengpeixun.cn/#free_course">珠峰培训</a><!-- target="_black"    新打开一个标签页显示 -->
<a href="https://www.baidu.com/" target="_black">百度</a><!-- 点击不跳转 但是会刷新页面 -->
<a href="">按钮1</a><!-- 
    阻止它的默认行为
    1、href="javascript:;"
    
    2、点击A标签:先触发 click行为,然后再去执行 href的跳转
    如果给 A标签绑定一个点击事件 则会先执行点击事件 再去执行 href跳转
    
    3、return false 也可以阻止默认行为
    返回一个 false,相当于结束后面即将执行的步骤
-->
<a href="javascript:;">按钮2</a><a href="https://www.baidu.com/" id="qqq">按钮3</a><script>
    qqq.onclick = function (ev) {
        // => 返回一个 false,相当于结束后面即将执行的步骤
        return false
    }
​
    qqq.onclick = function (ev) {
        ev.preventDefault()
    }
</script>
<input type="text" id="cardInp">
<script>
    //=>键盘事件对象
    //code & key: 存储的都是按键,code更细致
    //keyCode & which: 存储的是键盘按键对应的码值
    // 方向键:37 38 39 40 => 左上右下
    //  空格SPACE 32
    //  回车ENTER 13
    //  回退BACK 8
    //  删除DEL 46
    //  SHIFT 16
    //  CTRL 17
    //  ALT 18
​
    cardInp.onkeydown = cardInp.onkeyup = function (ev) {
        // console.log(ev.keyCode);
        let val = this.value
        let reg = /[^0-9X]/g
        this.value = val.replace(reg, "")
​
        // => 超过 18位禁止输入
        if (this.value.length >= 18) {
            let arr = [8, 13, 37, 38, 39, 40, 46]
            if (!arr.includes(ev.keyCode)) {
                // return false
                ev.preventDefault()
            }
        }
        // => 按 enter弹出输入的内容
        if (ev.keyCode === 13) {
            alert(this.keyCode)
        }
    }
</script>

3.2 浏览器底层渲染机制和DOM的回流重绘

11.png

浏览器渲染机制

描述浏览器的渲染过程,DOM树和渲染树的区别?

  • 浏览器的渲染过程:

    • 解析HTML构建 DOM(DOM树),并行请求 css/image/js
    • CSS 文件下载完成,开始构建 CSSOM(CSS树)
    • CSSOM 构建结束后,和 DOM 一起生成 Render Tree(渲染树)
    • 布局(Layout):计算出每个节点在屏幕中的位置
    • 显示(Painting):通过显卡把页面画到屏幕上
  • DOM树 和 渲染树 的区别:

    • DOM树与HTML标签一一对应,包括head和隐藏元素
    • 渲染树不包括head和隐藏元素,大段文本的每一个行都是独立节点,每一个节点都有对应的css属性

DOM的回流重绘

重绘和回流(重排)的区别和关系?

  • 重绘:当渲染树中的元素外观(如:颜色)发生改变,不影响布局时,产生重绘。如outline,visibility,color,background-color等。
  • 回流:当渲染树中的元素的布局(如:尺寸、位置、隐藏/状态状态)发生改变时,产生重绘回流。
    • 如添加或删除可见NOM元素
    • 页面一开始渲染时(无法避免)
    • 浏览器窗口大小发生变化
    • 内容发生变化(换图)
    • 元素尺寸发生变化
  • 注意:JS获取Layout属性值(如:offsetLeft、scrollTop、getComputedStyle等)也会引起回流。因为浏览器需要通过回流计算最新值
  • 回流必将引起重绘,而重绘不一定会引起回流

如何最小化重绘(repaint)和回流(reflow)?

  • 需要要对元素进行复杂的操作时,可以先隐藏(display:"none"),操作完成后再显示
  • 需要创建多个DOM节点时,使用DocumentFragment创建完后一次性的加入document
  • 缓存Layout属性值,如:var left = elem.offsetLeft; 这样,多次使用 left 只产生一次回流
  • 尽量避免用table布局(table元素一旦触发回流就会导致table里所有的其它元素回流)
  • 避免使用css表达式(expression),因为每次调用都会重新计算值(包括加载页面)
  • 尽量使用 css 属性简写,如:用 border 代替 border-width, border-style, border-color
  • 批量修改元素样式:elem.className 和 elem.style.cssText 代替 elem.style.xxx

如果避免回流?

  • 使用虚拟DOM
  • 分离读写操作
  • 样式集中变化
  • 缓存布局信息
  • 元素批量更改:createDocumentFragment
  • 动画效果应用到position属性为absolute上,脱离文档流
  • GPU加速:transform、opacity、filters(但可能引起占用大量内存)
  • 避免table布局和使用CSS的JS表达式

3.3 事件对象

  1. 什么是事件?

浏览器赋予元素天生默认的一些行为,不论是否绑定相关的方法,只要行为操作进行了,那么一定会触发相关的事件行为

  1. 什么是事件绑定?

给元素的某一个事件行为绑定方法,目的是行为触发后可以做点自己想做的事情

  1. 事件绑定 DOM0事件绑定/DOM2事件绑定
【DOM0事件绑定】
元素.onxxx=function(){}
元素.onxxx=null;  
​
原理:给DOM元素对象的某一个私有事件属性赋值函数值,当用户触发这个事件行为,JS引擎会帮助我们把之前绑定的方法执行的
        =>1.不是所有的事件类型都支持这种方式,元素有哪些onxxx事件属性,才能给其绑定方法(例如:DOMContentLoaded事件就不支持这种绑定方案)
        =>2.只能给当前元素的某一个事件行为绑定一个方法(绑定多个也只能识别最后一个)
        
【DOM2事件绑定】
元素.addEventListener([事件类型],[方法],[传播模式])
元素.removeEventListener([事件类型],[方法],[传播模式])
​
function anonymous(){
    console.log('ok');
}
box.addEventListener('click',anonymous,false);
box.removeEventListener('click',anonymous,false);
原理:基于原型链查找机制,找到EventTarget.prototype上的addEventListener方法执行,它是基于浏览器事件池机制完成事件绑定的
​

image.png

  1. 事件对象

当前元素的某个事件行为被触发,不仅会把绑定的方法执行,还会给绑定的方法传递一个实参,这个实参就是事件对象;事件对象就是用来存储当前行为操作相关信息的对象;(MosueEvent/KeyboardEvent/Event/TouchEvent...) =>事件对象和在哪个方法中拿到的没关系,它记录的是当前操作的信息

给元素的事件行为绑定方法,当事件行为触发,方法会被执行,不仅被执行,而且还会把当前操作的相关信息传递给这个函数 => “事件对象”

—如果是鼠标操作,获取的是MouseEvent类的实例 => 鼠标事件对象

鼠标事件对象 -> MouseEvent.prototype -> UIEvent.prototype -> Event.prototype -> Object.prototype

—如果是键盘操作,获取的是KeyboardEvent类的实例 =>键盘事件对象

—除了以上还有:普通事件对象(Event)、手指事件对象(TouchEvent)等

鼠标事件对象

  • clientX/clientY: 当前鼠标触发点距离当前窗口左上角的X/Y轴坐标
  • pageX/pageY: 触发点距离当前页面左上角的X/Y轴坐标
  • type: 触发事件的类型
  • target: 事件源(操作的是哪个元素,哪个元素就是事件源),在不兼容的浏览器中可以使用srcElement获取,也代表的是事件源
  • preventDefault(): 用来阻止默认行为的方法,不兼容的浏览器中用ev. returnValue=false也可以阻止默认行为
  • stopPropagation(): 阻止冒泡传播,不兼容的浏览器中用ev.cancelBubble=true也可以阻止默认行为

image.png

let obj = null; .
box. addEventListener('click', function (ev) {
	console.1og(ev);
	obj = ev;
});
box . addEventListener( 'click', function (ev) {
	console.1og(ev === obj); //=>true
});
document . body.onclick = function (ev) {
	console.log(ev === obj); //=>true
}

事件对象和函数以及给谁绑定的事件没啥必然关系,它存储的是当前本次操作的相关信息,操作一次只能有一份信息,所以在哪个方法中获取的信息都是一样的;第二次操作,存储的信息会把上一次操作存储的信息替换掉…;

每一次事件触发,浏览器都会这样处理一下

  • 捕获到当前操作的行为(把操作信息获取到),通过创建MouseEvent等类的实例,得到事件对象EV
  • 通知所有绑定的方法(符合执行条件的)开始执行,并且把EV当做实参传递给每个方法,所以在每个方法中得到的事件对象其实都是一个
  • 后面再重新触发这个事件行为,会重新获取本次操作的信息,用新的信息替换老的信息,然后继续之前的步骤…

3.4 事件传播机制的核心运行机制

1、捕获阶段:从最外层向最里层事件源依次进行查找(目的:是为冒泡阶段事先计算好传播的层级路径) =>CAPTURING_PHASE:1

2、目标阶段:当前元素的相关事件行为触发 =>AT_TARGET:2

3、冒泡传播:触发当前元素的某一个事件行为,不仅它的这个行为被触发了,而且它所有的祖先元素(一直到window)相关的事件行为都会被依次触发(从内到外的顺序) =>BUBBLING_PHASE:3 (Event.prototype)

image.png

<div id="outer">
	<div id="inner">
		<div id="center"></div>
	</div>
</div>
<script>
	// 四次输出的 ev事件相同
	// ev.stopPropagation() 可以阻止事件冒泡
	document.body.onclick = function (ev) {
		console.log("body", ev);
	}
	outer.onclick = function (ev) {
		console.log("outer", ev);
	}
	inner.onclick = function (ev) {
		console.log("inner", ev);
	}
	center.onclick = function (ev) {
		console.log("center", ev);
		// 阻止事件冒泡
		// ev.stopPropagation()
	}
</script>

image.png

3.5 发布订阅设计模式

一、什么是发布订阅模式?   

发布订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象(发布者)的状态发生改变时,所有依赖它的对象(订阅者)都将得到通知。

二、发布订阅模式的身影   

在前端的开发中,发布订阅模式无处不在:

  • addEventListener事件监听;
  • Vue框架的数据双向绑定(数据劫持 + 发布订阅模式);
  • NodeJS中的EventEmitter模块。

三、实现   

JavaScript主要通过注册回调函数的方式实现发布订阅模式,核心点如下:

  • 创建一个对象来维护订阅事件中的回调函数;
  • on方法提供向订阅事件中注册回调函数的功能;
  • emit方法则是执行订阅事件中的回调函数;
  • once方法则是只触发一次订阅事件方法;
  • remove方法删除订阅事件中的回调函数。
  function Event () {
    if (!(this instanceof Event)) {
      return new Event() // 确保用户采用构造函数的方式创建对象
    }
    this.events = {} // 用于维护订阅事件的回调函数
  }
// on方法的实现相对比较简单,主要给相应的订阅事件创建数组来保存订阅者的回调函数:
Event.prototype.on = function (name, fn, ctx) {
    const events = this.events
    events[name] || (events[name] = [])
    events[name].push({
      fn,
      ctx
    })
    return this // 支持链式调用
}

// emit方法,执行相应订阅事件中的回调函数,需要注意回调函数this的绑定以及传入参数的问题:
Event.prototype.emit = function (name, ...data) {
    // 发布者传递的参数 这里采用了ES6中的剩余参数, 在ES5中可以采用arguments
    const callbacks = this.events[name] || []
    for (let i = 0, max = callbacks.length; i < max; i++) {
      const { fn, ctx } = callbacks[i]
      fn.apply(ctx, data) // 绑定指定的this
    }

    return this
}

// 对于once方法,需要在执行相应订阅事件的回调函数的同时,将其注册的回调函数销毁掉,从而实现只通知一次的效果:
  Event.prototype.once = function (name, fn, ctx) {
    const self = this
    function listener (...data) {
      self.remove(name, listener) // 自动销毁
      fn.apply(ctx, data)
    }
    listener._ = fn // 用于手动销毁
    return this.on(name, listener, ctx)
  }

// 最后就是销毁的方法:
  Event.prototype.remove = function (name, targetFn) {
    const callbacks = this.events[name] || []
    const liveCallbacks = []

    for (let i = 0, max = callbacks.length; i < max; i++) {
      const { fn } = callbacks[i]
      if (fn !== targetFn && targetFn !== fn._) {
        liveCallbacks.push(callbacks[i])
      }
    }

    if (liveCallbacks.length !== 0) {
      this.events[name] = liveCallbacks
      return this
    }
    delete this.events[name]
    return this
  }


四、总结

优点:

  • 模块之间的解耦;
  • 异步编程中代替传入回调函数的方式。

缺点:

  • 创建订阅者需要消耗一定的内存和时间;
  • 过度使用订阅发布模式,会使对象与对象之间的关系深埋背后,导致程序难以跟踪维护和理解。