本文内容来源于网络、网课以及作者自己的归纳总结,js系列文章共计四篇,知识点比较多也比较杂,文章仅作简单的总结,不作过多深入分析。系列文章将持续更新。如有不妥之处,请在评论区友好指出~
一、浏览器工作原理
1、根据网址查询代码,并下载
当我们访问一个网站时,假设访问的是一个IP地址。那么浏览器会根据IP地址找到对应的服务器,并将对应的网站index.html代码下载下来
index.html一般关联着许许多多css代码和js代码,这个时候,也会被浏览器所下载下来
于是,浏览器呈现一个页面所需要的代码已经准备完毕,接下来就要解析这些代码了
2、根据代码渲染页面
浏览器渲染页面需要用到浏览器内核
我们常说的浏览器内核一般是指浏览器的排版引擎(浏览器引擎、页面渲染引擎)
浏览器内核,或者说浏览器引擎解析代码的过程如下:
1、首先html代码会被解析(parser),生成DOM树结构
2、在解析html代码的过程中,如果遇到js代码,会停止解析html,转而去执行js代码(这是因为js代码可以操作DOM,而我们不想要整个DOM树构建出来后,再去解析js频繁修改DOM树,这样子十分消耗性能)
3、css代码也进行解析(parser),生成一套style Rules。并最终与DOM树结合起来(Attachment)形成的渲染树(Render Tree)
4、布局Render Tree对每个节点进行布局处理,确定在屏幕上的位置。最后通过遍历渲染树将每个节点绘制出来
补充:浏览器内核
- Gocko:早期被Netscape 和Firefox浏览器使用
- Trident:微软开发,被应用于IE4-IE11浏览器使用,但是Edge浏览器已经转向Blink
- Webkit:苹果基于KHTML开发、开源,用于Safari、Google Chrome往前的版本也在使用
- Blink : Webkit的一个分支,Google开发,目前应用于Google Chrome、Edge、Opera
二、v8引擎的工作原理
注意:第二三章节核心在于理解js代码的执行过程,为理解闭包及作用域链做铺垫,其中涉及到了GO,VO,AO等概念,篇幅较长。可选择性阅读
在浏览器渲染页面的过程中,js是怎么执行的呢?
解析js代码是需要依靠js引擎的。这是因为,高级编程语言都是需要转成最终的机器指令来执行的(最终要能够被CPU所识别)
v8引擎解析js代码的过程如下:
1、首先js代码会被解析器解析(parse,经过词法分析、语法分析),形成AST树(抽象语法树)
2、Ignition是一个解释器,会将AST转换成ByteCode(字节码)
3、字节码转换为2进制代码的机器代码,最终能够被CPU执行
js代码在v8引擎中的执行流程
结合上方的v8引擎解析js代码的过程,我们详细来看代码的执行过程:
1、在js代码被解析成AST树时,会创建一个GlobalObject对象(也被称为GO)
2、这个
GlobalObject对象包含了常见的一些全局变量/函数/对象
var GlobalObject = {
String,
Date,
Number,
setTimeout等等,
window: GlobalObject //其中window属性指向GlobalObject
}
window对象在这里相当于GlobalObject对象,拥有与其相同的属性
- 3、在这个阶段(即js代码被解析成AST树过程中),我们下方的代码也会被解析。因为是全局代码,所以会被加入到GlobalObject对象中
// 示例代码如下
var name = 'xiaoming'
var num1 = 20
var num2 = 30
var result = num1 + num2
因为这些变量仅仅是定义了,尚未被执行,所以属性值还没有被赋值给变量,属性值暂时为undefined
var GlobalObject = {
// ...
window: GlobalObject,
name: undefined,
num1: undefined,
num2: undefined,
result: undefined
}
- 4、当到了运行代码阶段,v8引擎为了执行代码,会创建一个执行上下文栈(ECStack)
为了全局的代码能够正常的执行,又需要创建全局执行上下文(Global Execution Context GEC)
注:全局执行上下文维护着一个VO对象(Variable Object),指向GO
- 5、开始执行代码,
name、num1、num2、result的值undefined会被依次替换掉
var GlobalObject = {
// ...
window: GlobalObject,
name: 'xiaoming',
num1: 20,
num2: 30,
result:50
}
6、于是不难得出,在num1执行前,如果打印num1,console.log(num1),那么VO找到GO,GO找到的num1肯定是undefined
上边的代码只有变量的定义,如果要执行函数了,具体的过程是怎么样的呢?
v8引擎执行函数的过程(涉及函数作用域)
假设执行以下这么一段代码
var name = 'xiaoming',
function foo(num) {
console.log(m)
var m = 20
var n = 30
console.log('v8执行函数')
}
foo(123) //调用
//v8在调用函数时,执行函数
- 1、编译时:在代码解析成AST树时,创建一个GlobalObject对象
//GO
var GlobalObject = {
//...
window: GlobalObject,
name: undefined,
foo: 0x100 // 内存地址
}
执行遇到函数时,会开辟一块存储函数的空间,所以GO里的foo指向一个内存地址(如图)
//0x100
[[scope]]: parent scope //父级作用域
函数执行体(代码块)
- 2、即将执行时:在执行函数的过程中,会创建一个函数执行上下文(FEC),并将其放入函数调用栈中
函数执行上下文中包含一个VO,指向AO
//此时的AO
{
num: undefined,
m: undefined,
n: undefined
}
- 3、开始执行函数
将参数123赋值给VO中的num
执行console.log(m), 此时m仍为undefined,输出undefined
执行var m = 20,将VO中的m赋值为20,
执行var n = 30,将VO中的n赋值为30
执行console.log('v8执行函数'),输出v8执行函数
- 4、函数执行完成后,函数执行上下文(FEC)会弹出调用栈,并自行销毁。此时VO没有谁引用它,也被销毁了
思考:当我们在函数中访问该函数中未定义的变量,它们是怎么沿着作用域链一层一层地寻找的呢?
补充:常见的js引擎
SpiderMonkey: 第一款JavaScript引擎,由Brendan Eich开发(也就是JavaScript作者);
Chakra: 微软开发,用于IT浏览器;
JavaScriptCore: WebKit中的JavaScript引擎,Apple公司开发;
V8: Google开发的强大JavaScript引擎,也帮助Chrome从众多浏览器中脱颖而出;
三、作用域链
当我们查找一个变量时,真实的查找路径是沿着作用域链进行查找的
假设有如下代码
var num = 100
function foo() {
function bar() {
console.log(num)
}
bar()
}
foo()
bar函数中并没有定义num,但最终会输出100。原因就是有作用域链的存在
案例分析
foo函数里边定义了bar函数,并在bar函数中打印了name。name是如何被匹配到的?
var name = 'xiaoming'
function foo(num) {
console.log(m)
var m = 10
var n = 20
function bar() {
console.log(name)
}
bar()
}
foo(123)
具体执行过程
- 1、编译阶段
创建全局对象GO,创建foo、bar的函数存储空间
- 2、即将执行阶段
创建foo的函数执行上下文,其中mun : undefined m : undefined n : undefined bar : 内存地址
- 3、执行阶段
将foo的函数执行上下文放入调用栈中,依次执行其中的代码
a、将num赋值为123;
b、执行console.log(m),打印m = undefined
c、执行var m = 10 var n = 20,为VO中的m、n赋值
d、执行bar,创建一个bar的函数执行上下文
- 4、执行bar,沿着作用域链逐层查找
当bar的函数执行上下文在执行console.log(name)时,没有找到name(因为其AO对象是没有name变量的)
它会沿着scope chain找到foo的VO,从而匹配到foo的AO,但是依然没有找到
继续沿着scope chain找到全局对象的VO,从而匹配到GO,最终在里边找到了name,输出name
最后,bar函数执行完毕弹出执行栈,AO(bar)失去了引用而销毁掉;foo函数同理;代码执行完毕,全局执行上下文弹出执行栈,执行结束
如果你耐心看完了以上内容,可能觉得毫无收获,那么请看看以下题目。先自行推理答案,再试图代入以上分析模式,看看得出来的结果是否一致
案例一
var message = 'hello global'
function foo() {
console.log(message);
}
function bar() {
var message = 'hello bar'
foo()
}
bar()
你以为结果是hello bar?,其实正确结果输出:hello global
代码的具体执行过程:
- 1、编译阶段
// 创建一个全局对象GO
var GO = {
window: GO,
message: undefined,
foo: 0xa00,
bar:0xb00
}
开辟分别存储foo函数、bar函数的内存空间
重点:因为定义阶段两个函数同处于一个作用域。其父级作用域都是GO
- 2、执行代码
当执行bar函数中的foo函数时,执行console.log(message),先在foo自身的AO查找,没找到,沿着作用域链查找。直接匹配到了GO
案例二
var n = 100
function foo() {
n = 200
}
foo()
console.log(n)
1、编译过程:
GO:{ n: undefined, foo: 0x100 }
//0x100
[scope]:GO
[代码块]
2、执行过程
执行var n = 100
GO:{ n: 100, foo: 0x100 }
执行foo(), 在foo的AO查找n,没有找到n。沿着作用域链找到父级作用域GO,找到n,并将n改为200
执行console.log(n),输出200
案例三
function foo() {
console.log(n)
var n = 200
console.log(n)
}
var n = 100
foo()
1、编译过程
GO: { foo:0x100, n: undefined }
//0x100
[scope]:parent scope
[代码块]
//foo的AO
n: undefined
2、执行过程
执行var n = 100
GO: { foo:0x100, n: 100 }
执行foo(),执行console.log(n),查询自身的AO,存在n并输出undefined
执行var n = 200,为AO中的n赋值200 AO: { n: 200 },执行console.log(n),根据自身AO,输出200
案例四
function foo() {
var a = b = 100
}
foo()
console.log(b)
console.log(a)
特别注意:
var a = b = 100将会转换为var a = 100 b = 100
1、编译阶段
GO: { foo: 0x100, b: undefined }
//0x100
[scope]:parent scope
[代码块]
AO: { a: undefined }
2、执行阶段
因为函数中的b = 100相当于是在全局中定义的,所以此时GO
GO: { foo: 0x100, b: 100 }
执行foo(),执行var a = b = 100
AO: { a: 100 }
全局执行console.log(b),在GO中找到,输出100;全局执行console.log(a),GO没有,报错
四、内存管理
不管是什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要手动的管理内存(如C、C++),某些编程语言可以自动管理内存(如Java、Python、JavaScript)
js的内存管理
JavaScript会在定义变量时为我们分配内存
1、对于基本数据类型的分配会在执行时,直接在栈空间进行分配
2、js对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用
垃圾回收机制GC
程序运行需要内存,对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃。
而垃圾回收是一种自动内存管理机制,用于检测和清除不再使用的(不可达)对象,以释放内存空间。当一个对象不再被引用时,垃圾回收器会将其标记为垃圾,然后在适当的时候清除这些垃圾对象,并将内存回收给系统以供其他对象使用。
JavaScript的运行环境js引擎内置了内存垃圾回收器(Garbage Collection,简称GC),帮助我们实现垃圾自动回收
最常见的有两种垃圾回收策略:标记清除算法、引用计数算法
4.1 标记清除算法
通过算法标记出内存中所有不再使用的对象,然后清除这些对象,后续空出来的内存空间可以分配给其他对象使用
具体实现步骤:
- 1、标记
垃圾回收器在运行时对内存中所有的对象进行遍历并添加一个标记 0(假设0代表垃圾)。然后从根对象开始递归地遍历所有的对象的引用关系,能够被访问到的对象将其标记改为“非垃圾”的标记 1 。这样一来,所有非垃圾的对象被会被标记出来
- 2、清除
垃圾回收器遍历内存中所有的对象,对于那些标记为垃圾的对象(标记为0),它们占据的空间会被立即回收。那些可达的的对象(标记为1)则被保存中内存中
优势:简单有效;能够处理循环引用
弊端
执行标记清除算法进行垃圾回收后,由于剩余的可达对象的位置是不变的,内存的连续性遭到了破坏,出现了内存碎片(如图)
假设现在新增一个对象,占据的一定的内存空间。为了尽可能的利用好空闲的内存空间,我们需要分配到合适的位置存放。而碎片化的内存空间会给降低代码的执行效率,分配速度较慢。
另外还有一种算法:标记整理算法,该算法的整理阶段将存活状态的对象向内存的一端移动,清除阶段清除了另一端的垃圾对象,确保了内存的连续性
4.2 引用计数算法
该算法通过记录每个对象被引用的次数来确定是否进行回收,当一个对象没有任何引用时(被引用次数为0)时被认定为垃圾对象
具体实现步骤:为每个对象添加一个引用计数器
1、当对象被创建时,计数器+1
2、对象被引用,计数器+1;断开引用时,计数器-1
当对象的计数器为0时,说明改对象没用任何引用,可以被回收
const obj = {} //引用计数器:1
let other = obj //引用计数器:2
other = null // 断开,引用计数器:1
obj = null // 断开,引用计数器:0,回收
优势:实时回收,简单高效
弊端
1、每个对象都要维护一个引用计数器,增加了额外的开销
2、处理不了循环引用
function foo () {
let objA = {name: 'A'};
let objB = {name: 'B'};
objA.B = objB;
objB.A = objA;
}
foo()
当foo函数执行完毕,执行栈中的foo函数弹出,objA和objB也就失去了对引用对象的引用。objA和objB却相互引用着,导致两者身上的引用计数始终为1(如图)
4.3 分代式垃圾回收
现代浏览器大多采用标记清除算法进行垃圾回收,但是高频率收都要遍历整个根对象的话,开销未免太大
为了提高垃圾回收的效率和性能,V8引擎使用了分代式的垃圾回收机制。
具体实现步骤:
将内存中的对象划分为新生代和老生代,二者分别采用不同的算法来优化代码回收效率
新生代:存放存活时间较短的对象,新创建的对象会被归入新生代,新生代通常只有1-8M的容量。采用了Scavenge算法中的Chenney算法进行垃圾回收
老生代:存放存活时间较长的对象,经过多次新生代的垃圾回收后对象会被放入老生代中。采用了标记-整理-清除算法
老生代垃圾回收
老生代中存放的对象一般较大且存活时间较长,这里使用的算法比较简单,就是标记清除算法的优化版本。在垃圾回收时对内存空间进行了整理,避免了大量不连续的内存碎片的问题。
新生代垃圾回收
新生代的内存空间被划分为大小相等的两个空间:To空间(处于空闲状态)、From空间(处于使用状态)
新创建的对象会被放入From空间,当该空间快被存满时就会触发垃圾回收机制:
1、标记:对From空间中的对象进行标记
2、复制:将标记的存活对象复制到To空间,并进行排序。使To空间成为连续的块
3、清除:清除From空间中的垃圾对象
4、交换:From空间与To空间进行角色交互,即From空间变为To空间,To空间变为From空间
新生代在满足一些条件后可以晋升为老生代:
1、当一个对象长期存在于To空间中,意味着其经过了多次垃圾回收后依旧存活,属于长期存活的对象,即可将该对象放入老生代
2、当新创建的对象占用的内存空间大于25%时,为避免From空间被快速填满,将会直接将其放入老生代
V8对垃圾回收的优化还不止于此,为提升用户的体验,解决全停顿问题,它提出了增量标记、三色标记法、惰性清理、并发、并行等优化方法,限于篇幅,本文不做展开,请自行查阅
五、理解闭包
5.1 闭包的形成
要理解闭包是什么,我们得先知道它是怎么形成的。下文将通过示例代码的执行流程,一步步剖析其闭包形成原因
示例
// 简单的闭包案例
function foo() {
var name = 'foo'
function bar() {
console.log("bar", name)
}
return bar
}
var fn = foo()
fn()
1、编译阶段:
定义foo函数和变量fn(如图)
GO: {
window:GO,
foo: 0xa00,
fn: undefined
}
2、执行阶段 : 执行var fn = foo():
调用了foo函数,创建foo函数执行上下文, 并创建AO对象
//foo的AO
AO: {
name: undefined,
bar: 0xb00
}
开始执行foo(),执行var name = 'foo' 为name赋值
bar函数是定义不调用,直接跳过
执行return bar, 即直接将地址赋值给了变量fn
//foo的AO
AO: {
name: 'foo',
bar: 0xb00
}
GO: {
window:GO,
foo: 0xa00,
fn: 0xb00
}
从图中可以看到,GO中的fn指向bar函数对象,而bar函数对象的父级作用域指向了foo的AO对象
3、执行阶段:执行 fn()
重要:执行完var fn = foo()后,将foo的执行上下文弹出栈。按理来说此时foo的AO对象应该销毁掉。但是因为它身上还维系着一条引用关系,导致其不会被垃圾回收器回收
因为fn在GO中表现为0xb00,即意味着执行的是bar函数
创建bar函数的执行上下文,放入调用栈中,并创建对应的AO对象
AO:{
//bar函数内部并无内容,为空
}
开始执行bar()
执行console.log('bar', name),直接打印输出bar,遇到name,就要到上层作用域中查找,bar的上层作用域是foo的AO对象,找到了name: foo,输出foo。
至此,代码执行完毕,闭包也形成了。内存中foo的AO对象将一直存在着
闭包:bar函数+name自由变量的组合
5.2 闭包的定义
维基百科的定义:
1、闭包(closure),又称词法闭包或函数闭包
2、只在支持头等函数的编程语言中,实现词法绑定的一种技术
3、闭包在实现上是一个结构体,它存储了一个函数和一个关联的环境
4、闭包跟函数最大的区别在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它照样能运行
注:在js中,函数是一等公民,意味着函数可以作为另外一个函数的参数,也可以作为另外一个函数的返回值来使用
简单的理解
要理解闭包,首先必须理解JavaScript的变量作用域
JavaScript语言的特殊之处,就在于函数内部可以直接读取全局变量。在函数外部无法读取函数内的局部变量,但是闭包可以帮助我们从外部读取函数局部的变量。
基于此的理解为:闭包就是能够读取其他函数内部变量的函数。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。
结合上方的案例来说明:一般而言,全局变量fn(即函数bar)不可能访问到foo中定义的变量name,但是因为闭包的存在我们能够实现从函数外部访问另一函数的内部
由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题
5.3 内存泄露
容易造成内存泄露的情况:
1、闭包引起的内存泄露
2、被遗忘的定时器(没有清除定时器)
3、意外定义的全局变量
4、for循环出现死循环
滥用闭包很容易引发内存泄漏,如下模拟极端场景的内存泄露
function createFnArray() {
// 占据的空间为4M
var arr = new Array(1024 * 1024).fill(1)
return function() {
console.log(arr.length);
return arr
// 形成闭包,每一次调用内存中都保留着4m空间的createFnArray的AO对象
}
}
var arrarFns = []
const fn = createFnArray()
// 运行100次,并将结果存进数组中
for(var i = 0; i < 100; i++) {
arrarFns.push(fn())
// 此时内存中约占用了400M空间
}
运行代码,在浏览器控制台的Performance中选择Memory
可以看到,代码运行占用了大量的内存空间
setTimeout(() => {
arrarFns = null
}, 1000);
1秒钟后,释放内存
在控制台的内存占用曲线可以看到明显的断层
六、函数式编程
函数式编程与面向对象
1、函数式编程具备高扩展性和复用性。缺点是函数过多难以管理,逻辑不清晰
2、涉及到业务逻辑的代码,最好使用面向对象。面向对象非常擅长组织逻辑
3、绝大部分前端框架都使用了函数式,很大原因是函数式编程可以使用Tree-shaking
Tree-shaking的本质是通过文档流的引入判断是否使用了某个方法,但是面向对象方案无法记录
function Class1() {}
Class1.prototype.f1 = () => { }
Class1.prototype.f2 = () => { }
new Class1().fn1()
//但是通过webpack打包后,f2也被打包进去了
6.1 纯函数
函数式编程中有一个非常重要的概念叫纯函数,JavaScript符合函数式编程的范式,所以也有纯函数的概念
定义:在程序设计中,若一个函数符合以下条件,那么这个函数被称为纯函数
1、函数在相同的输入值时,需产生相同的输出
2、函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关
3、该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容
简单理解:
1、确定的输入,一定会产生确定的输出;
2、函数在执行过程中,不能产生副作用
副作用?
在计算机科学中,引入了医学的概念副作用,表示在执行一个函数时,除了返回函数值之外,还对调用函数产生了附加的影响,比如修改了全局变量,修改了参数或者改变了外部的存储(简单理解:函数依赖于外部的状态)
例如:用户输入,外部配置,数据库
副作用往往是产生bug的“温床“
案例说明
1、slice和splice
//slice是纯函数
//给slice传入一个start/end,那么对于同一个数组来说,它会给我们返回确定的值
//slice本身不会修改原来的数组
var names = ["abc", "cba", "nba", "dna"]
var newName1 = names.slice(0, 3)
//splice不是纯函数
//splice在执行时,有修改掉调用数组对象本身,修改的这个操作就产生了副作用
var newName2 = names.splice(2)
console.log(names)
2、foo和bar
// foo是一个纯函数
function foo(num1, num2) {
return num1 * 2 + num2 * num2
}
//bar不是一个纯函数
//bar执行过程中修改了全局编程name
var name = 'abc'
function bar() {
console.log('bar其他的代码执行')
name = 'cba'
}
bar()
纯函数的优势
保证了函数的纯度,只是单纯实现了自己的业务逻辑,需要关心传入的内容是如何获得的或者依赖其他的外部变量是否已经发生了修改
6.2 柯里化
柯里化也是属于函数式编程里面一个非常重要的概念
定义:
柯里化(Currying),是把接收多个参数的函数,变成接受一个单一参数(最初函数的第一参数)的函数,并且返回接受余下的参数,而且返回结果是新函数的技术
柯里化声称:“如果你固定某些参数,你将得到接受余下参数的一个函数”
简单理解:
只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数,这个过程就称之为柯里化
案例
function foo(m, n, x, y) {
return m + n + x + y
}
foo(20,30,40,50)
//柯里化
function bar(m) {
return function (n) {
return function (x) {
return function(y) {
return m + n + x + y
}
}
}
}
bar(20)(30)(40)(50)
最终,我们将foo函数(本来需要接收四个参数),变成只接收一个参数的函数
是否觉得上方的柯里化太过繁琐,其实也是可以简写的
var baz = m => n => x => y => m + n + x + y
var res = baz(20)(30)(40)(50)
console.log(res) //输出140
为什么要有柯里化?
1、让函数的职责单一
在函数式编程中,我们其实希望一个函数处理的问题尽可能的单一,而不是将一大堆处理过程交给一个函数来处理
那么我们是否就可以将每次传入的参数在单一的函数中进行处理,处理完后在下一个函数中再使用处理后的结果
比如有如下需求:add有三个参数,分别在不同的函数中进行不同的操作后,再进行相加
//正常的函数编写
function add(x, y, z) {
x = x + 2
y = y * 2
z = z ** 2
return x + y + z
}
console.log(add(20,30,40)); //1682
//柯里化
function foo(x) {
x = x + 2
return function(y) {
y = y * 2
return function(z) {
z = z ** 2
return x + y + z
}
}
}
console.log(foo(20)(30)(40)); //1682
2、柯里化的复用
我们来看下边的两个案例
案例一
makeAdder函数要求我们传入一个num(并且如果我们有需要的话,可以在这里对num进行一些修改)
之后使用返回的函数时,就不需要再继续传入num了
function makeAdder(num) {
return function(count) {
return num + count
}
}
//假设需要多次使用5去加另外一个数
var add5 = makeAdder(5)
add5(10)
add10(100)
案例二
日志打印:当前时间 + 类型 + 说明
封装一个日志打印的函数,一般的实现方法如下
function log(date, type, message) {
console.log('[${date.getHours()} : ${date.getMinutes()}][${type}]:[${message}]')
}
log(new Date(), "DEBUG", "查询到轮播图的bug")
log(new Date(), "DEBUG", "查询到菜单的bug")
log(new Date(), "DEBUG", "查询到数据的bug")
这样的写法未免太过繁琐,存在大量重复的代码
//柯里化
var log = date => type => message => {
console.log('[${date.getHours()} : ${date.getMinutes()}][${type}]:[${message}]')
}
//时间是固定的(即每次都是打印当下时间)
var nowLog = log(new Date())
newLog("DEBUG")("查询到轮播图的错误")
newLog("FETURE")("新增了添加用户的功能")
//如果类型type否是相同的,还可以进一步优化
var newAndDebug = log(new Date())("DEBUG")
newAndDebug("查询到轮播图的bug")
newAndDebug("查询到轮播图的bug")
newAndDebug("查询到轮播图的bug")
6.3 手写柯里化
实现功能:传入一个普通函数,将其转化成柯里化函数
1、基本结构:传入一个函数,返回一个函数
function lmCurrying(fn) {
function curried(...args) {
return fn(...args);
}
return curried;
}
function add(x, y, z) {
return x + y + z;
}
//调用
var curryAdd = lmCurrying(add);
curryAdd(20, 30, 50);
2、curried参数处理
上边的代码中,使用者传入一个函数,最终返回curried函数
但是调用函数的人使用curried函数时,传参的方式可能是不同的
//调用
var curryAdd = lmCurrying(add)
curryAdd(20, 30, 40)
curryAdd(20, 30), 40)
curryAdd(20)(30)(40)
当一次性传入三个参数时,就可以简单地这样子编写代码
function lmCurrying(fn) {
function curried(...args) {
fn(...args)
}
return curried
}
3、当已接收参数个数,与传入的函数需要的参数个数一致时,直接调用即可
function lmCurrying(fn) {
function curried(...args) {
if (args.length >= fn.length) {
return fn(...args)
}
}
return curried
}
注:fn.length可以获取函数本身的参数个数
4、考虑调用方式的不同
比如使用者这样子调用curryAdd(20)(30)(40),这个时候就需要递归调用curried函数了
function lmCurrying(fn) {
function curried(...args) {
if(args.length >= fn.length) {
return fn(...args)
} else {
//当传入参数小于原函数的参数个数时
function curried2(...args2) {
return curried(...args, ...args2) //递归调用
}
return curried2
}
}
return curried
}
5、测试代码
function lmCurrying(fn) {
function curried(...args) {
if(args.length >= fn.length) {
return fn(...args)
} else {
//当传入参数小于原函数的参数个数时
function curried2(...args2) {
return curried(...args, ...args2) //递归调用
}
return curried2
}
}
return curried
}
function add(x, y, z) {
return x + y + z
}
var newFn = lmCurrying(add)
console.log(newFn(20)(30)(40)); //90
console.log(newFn(20, 30)(40)); //90
console.log(newFn(20, 30, 40)); //90
6.4 组合函数
组合(Compose)函数是在js开发过程中一种对函数的使用技巧、模式
比如我们需要获取某一个数据,执行函数fn1和fn2,这两个函数时依次执行的,如果我们每次都需要进行两个函数的调用,操作上就会显得重复,那么是否可以将fn1和fn2组成起来,自动依次调用呢
//一般的调用方式
function double(x) {
return x * 2
}
function square(y) {
return y ** 2
}
var count = 10
//需求:对count进行乘以2,再进行平方
var res = square(dobule(count))
组合函数的写法
function composeFn(fn1, fn2) {
return function(count) {
return fn2(fn1(count))
}
}
var newFn = composeFn(dobule, square)
6.5 高阶函数
高阶函数(Higher-order function)是一个接收函数作为参数或者将函数作为返回输出的函数
例如:filter 、forEach、every是高阶函数(参数是函数);bind也是一个高阶函数(返回值是函数)
意义:高阶函数是用来抽象通用的问题,帮我们屏蔽细节,让我们只关注目标
实现forEach
Array.prototype.myForEach = function(fn) {
const arr = this //隐式调用,this就是调用该函数的数组
for(let i in arr) {
fn(arr[i], i, arr)
}
}
const arr = [1,23,4,5,6,7]
arr.myForEach((item) => {
console.log(item)
})
实现filter
Array.prototype.myFilter = function(fn) {
const arr = this
let newArr = []
for(let key in arr) {
if(fn(arr[key])) {
newArr.push(arr[key])
}
}
return newArr
}
const arr = [3,4,5,6,66,2,123,6,77,23,23,32]
const res = arr.filter((item) => {
return item > 20
})
console.log(res)
实现once
只调用一次的函数,返回值也是一个函数
应用场景:支付时,多次点击也只支付一次
function once(fn) {
let done = false //闭包
return function(arg) {
if(!done) {
done = true
fn(arg)
}
}
}
const pay = once((monkey) => {
console.log(monkey)
})
pay(888)
pay(888)
pay(888)
pay(888) //只打印一次888
注:这里其实应用了闭包
实现reduce
arr.reduce((pre, current, index, arr) => {return pre + current}, init)
可以接收第二个参数作为第一个pre,不传则将arr的第一个参数作为pre
案例分析
需求一:将routes转化为对象
const routes = [
{ path: "/", component: "home" },
{ path: "/about", component: "about" },
]
//即{'/' :'home'}的格式
routes.reduce((pre, current) => {
pre[current.path] = current.component
return pre //pre将作为下一次的pre
}, {})
需求二:累加金额
const goods = [
{ type: "good", price: 10 },
{ type: "good", price: 20 },
{ type: "good", price: 30 },
]
goods.reduce((pre, current) => {
if(current.type === 'good') {
pre += current.price
}
return pre
}, 0)
手写实现
Array.prototype.myreduce = function (fn, init) {
for (i = 0; i < this.length; i++) {
init = fn.call(this, init, this[i], i, this);
}
return init;
};
七、防抖节流
防抖节流的概念其实最早并不是出现在软件工程中,防抖是出现在电子元件中,节流出现在流体流动中。
JavaScript是事件驱动的,大量的操作会触发事件,加入到事件队列中处理。而对于某些频繁的事件处理会造成性能的损耗,我们就可以通过防抖和节流来限制事件频繁的发生。
7.1 防抖基本理解
在开发中,我们经常使用一些库来提供防抖节流的方法
//lodash
//说明:传递一个想要进行防抖处理的函数与触发时间,返回一个处理好的防抖函数
const newFn = lodash.debounce(fn, 1000)
btn.onclick = newFn
防抖案例理解:假设有一个搜索框,用户往里边输入内容时,该搜索框下方会帮用户匹配可能想要搜索的内容
搜索:<input type="text">
问题:但是如果用户每输入一个单词,都触发一次处理程序去联想可能的内容。假设用户输入:helloworld,就触发了10次处理程序,十分消耗性能
解决思路:规定一个时间(如500ms),用户每输入一个单词后延迟500ms后才触发处理程序。
注:(每次输入一个新单词,都会刷新这个500ms的延迟时间)
防抖的应用场景
1、输入框中频繁输入内容,搜索或者提交信息
2、频繁的点击按钮,触发某个事件
3、监听浏览器滚动事件,完成某些特定操作
4、用户缩放浏览器的resize事件
防抖代码案例
<!--输入框案例-->
<body>
搜索:<input type="text">
<script>
const inputEl = document.querySelector("input")
let counter = 0
inputEl.addEventListener('input', () => {
console.log(`发送了第${++counter}次网络请求`);
})
</script>
</body>
在输入框中输入12345,结果触发了5次事件发生了第1、2、3、4、5次网络请求
<!--借助第三方库进行防抖-->
<body>
搜索:<input type="text">
<script src="https://cdn.jsdelivr.net/npm/underscore@1.13.1/underscore-umd-min.js"></script>
<script>
const inputEl = document.querySelector("input")
let counter = 0
const fn = function() {
console.log(`发送了第${++counter}次网络请求`);
}
// 使用防抖函数
const newFn = _.debounce(fn, 1000)
inputEl.addEventListener('input', newFn)
</script>
</body>
1秒内输入内容12345,最后只有发送一次网络请求。实现了防抖的效果
7.2 节流的基本理解
防抖与节流的区别:防抖是从触发事件停止时,开始计时去触发处理程序;而节流是按照固定的频率触发处理程序
理解:假设宣讲会后,讲师进行答疑
情况一:一分钟内,无论有多少同学提问,都只回答一个问题(节流)
情况二:一分钟内,如果有同学提问那么回答问题。超过一分钟无人提问,那么答疑环节结束(防抖)
节流案例理解:
1、当事件被触发时,会执行这个事件的响应函数。如果事件被频繁触发,那么节流函数会按照一定的频率来执行函数;不管这中间有多少次触发这个事件,执行函数的频率总是固定的
2、在飞机大战游戏中,假设按一次空格键发射一颗子弹。但是用户在1s内按了10次空格键发射10颗子弹,显然不正常。所以一般会使用节流函数来进行处理,不论1s内按了多少次空格键,都只发射一颗
节流的应用场景
1、监听页面的滚动事件
2、鼠标移动事件
3、用户频繁点击按钮的操作
4、游戏中的一些设计
节流的案例
<body>
搜索:<input type="text">
<script src="https://cdn.jsdelivr.net/npm/underscore@1.13.1/underscore-umd-min.js"></script>
<script>
const inputEl = document.querySelector("input")
let counter = 0
const fn = function() {
console.log(`发送了第${++counter}次网络请求`);
}
// 节流处理
const newFn = _.throttle(fn, 1500)
inputEl.addEventListener('input', newFn)
</script>
</body>
在输入框中持续输入内容,每1.5秒会发送一次网络请求
7.3 手写防抖函数
希望实现的效果
let counter = 0
function fn() {
console.log(`第${++counter}次触发`)
}
//调用
let newFn = debounce(fn, 1000)
input.oninput = newFn
//触发input事件时,调用的是经过防抖处理的newFn
1、基本结构
所以,我们要实现的就是debounce函数
function debounce(fn, delay) {}
最后结果是要返回一个函数
function debounce(fn, delay) {
const _debounce = function() {
}
reutrn _debounce
}
传进来的fn函数,能够被执行
function debounce(fn, delay) {
const _debounde = function() {
fn()
}
}
//调用
let newFn = debounce(fn, 1000)
input.oninput = newFn
//输入时,每输入一个值就会触发一次
为了实现防抖的延迟执行效果,我们可以加入定时器控制
function debounce(fn, delay) {
const _debounde = function() {
setTimeout(() => {
fn()
}, delay)
}
}
问题:但是这样子做所有的输入都会在自身被输入后的delay时间后被执行
解决办法:定义一个清除定时器的变量,每一次进行输入时,把上一次的定时器给清除掉
//防抖函数基本结构
function debounce(fn, delay) {
// 1.定义一个变量,保存上一次的定时器
let timer = null
//2.真正执行的函数
const _debounce = function() {
//取消上一次的定时器
if(timer) clearTimeout(timer)
timer = setTimeout(() => {
//外部传入的真正要执行的函数
fn()
}, delay)
}
return _debounce
}
//测试代码
const input = document.querySelector('input')
let counter = 0
let fn = function() {
console.log(`第${++counter}次触发`);
}
let newFn = debounce(fn, 1000)
input.oninput = newFn
这样一来,我们就基本实现了防抖函数的基本结构
2、this与参数
上边的debounce函数已经基本实现了防抖功能,但是this与参数传递仍有一些问题
//默认情况下,事件处理程序中的this和event参数
const input = document.querySelector("input")
input.addEventListener("input", function(event) {
console.log(this, event);
})
//输出:<input type="text"> InputEvent {...}
但是,在上方我们自己封装的debounce中,打印this和event参数却不是我们想要的结果
let counter = 0
function fn(event) {
console.log(`第${++counter}次触发`, this, event)
}
//调用
let newFn = debounce(fn, 1000)
input.oninput = newFn
//触发input事件时,输出:window undefined
显然是不对的
//解决
function debounce(fn, delay) {
let timer = null
return function(...args) {
if(timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
const input = document.querySelector('input')
let counter = 0
let fn = function(event) {
console.log(`第${++counter}次触发`, this, event);
}
let newFn = debounce(fn, 1000)
input.oninput = newFn
3、第三个参数:立即执行
我们想要实现这么一个功能,往debounce中输入一个布尔值,来决定是否立刻触发一次事件处理程序
为什么需要这么一个参数?
//假设用户输入第一个单词,立马就触发一次事件处理程序
搜索:<input type="text">
后面用户可能持续在输入,但一直都不会触发第二次程序。那么第一次已触发的事件处理程序可以提升一点用户的体验
所以,我们可以为debounce函数增加第三个参数,当这个参数为true时,用户第一次输入立马执行事件处理程序,而不进行防抖
function debounce(fn, delay, immediate = false) {
let timer = null
return function(...args) {
if(timer) clearTimeout(timer)
if(immediate) { //如果为true立即执行
fn.apply(this, args)
immediate = false
} else {
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
}
//效果
let newFn = debounce(fn, 1000, true)
input.addEventListener('input', newFn)
//当用户输入第一个字母时,就会立即触发一次
以上的防抖函数已经可以解决大部分的应用场景。
如果想要用户输入一段内容后,停顿了一些时间。用户在输入第二段内容的时候,第二段内容的第一个字母也能实现立即执行事件处理程序,可以对代码进行如下改变
//最终效果
function debounce(fn, delay, immediate = false) {
let timer = null
let isInvoke = false
return function(...args) {
if(timer) clearTimeout(timer)
if(immediate && !isInvoke) {
fn.apply(this, args)
isInvoke = true
} else {
timer = setTimeout(() => {
fn.apply(this, args)
isInvoke = false
}, delay)
}
}
}
4、取消功能
把本次的防抖功能取消掉
为什么需要这一功能?比如搜索框中,我们利用防抖来给用户提供智能联想。但是有些用户目的明确,快速输入内容并点击搜索,此时根本来不及进行一个防抖的时间还在计算当中,同时也因为用户已经不需要这一次的智能联想了,所以可以在用户点击搜索按钮时,把这个防抖功能取消掉,提升性能
function debounce(fn, delay, immediate = false) {
let timer = null
let isInvoke = false
let _debounce = function(...args) {
if(timer) clearTimeout(timer)
if(immediate && !isInvoke) {
fn.apply(this, args)
isInvoke = true
} else {
timer = setTimeout(() => {
fn.apply(this, args)
isInvoke = false
}, delay)
}
}
//取消功能
//_debounce本身也是一个对象,往其上添加一个方法
_debounce.cancel = function() {
if(timer) clearTimeout(timer)
timer = null
isInvoke = false
}
return _debounce
}
// 测试取消功能
const btn = document.querySelector("button")
btn.onclick = newFn.cancel
7.4 手写节流函数
节流函数的期望的实现效果:
1、第一个输入的内容会立马执行一次事件处理程序
2、后续内容输入会根据时间周期来触发事件处理程序
3、最后一次事件周期,不论内容输入是否达到这个周期,都要执行一次
const remainTime = interval - (nowTime - lastTime)
// remainTime表示用户停止输入后离周期触发时的间隔时间
//interval时间周期
//nowTime每次触发事件的时间戳
//lasttime初始时间
if(remainTime <= 0) {
//说明一个周期过去了,该触发事件处理程序了
}
1、基本结构
function throttle(fn, interval) {
//1.记录上一次开始的时间
let lastTime = 0
// 2.事件触发时,真正执行的函数
const _throttle = function() {
//2.1 获取当前事件触发时的时间
let nowTime = new Date().getTime()
// 2.2 计算出还剩多少时间去触发函数
let remainTime = interval - (nowTime - lastTime)
if(remainTime <= 0) {
fn() //触发函数
lastTime = nowTime
}
}
return _throttle
}
这个也是实现了第一次输入时,会立即触发一次
2、控制开始与结尾
我们可以自定义第一次是否触发或者最后一次输入没有达到周期是否触发
如何控制第一次不触发呢?
可以让lastTime = nowTime,只有当lastTime过了nowTime时间后,才执行第一次事件处理程序
function throttle(fn, interval, options = { leading: true, trailing: false }) {
const { leading , trailing } = options
let lastTime = 0
const _throttle = function() {
const nowTime = new Date().getTime()
if(!lastTime && !leading) lastTimg = nowTime //判断是否第一次输入?是怎让其等于当前时间
const remainTime = interval - (nowTime - lastTime)
if(remainTime <= 0) {
fn()
lastTime = nowTime
}
}
return _throttle
}
//节流
let newFn = throttle(fn, 1000, { leading: false})
inputEl.oninput = newFn
效果:在时间周期内,输入内容不会立马触发事件处理程序
trailing的实现比较麻烦,后续再作补充
3、this和参数
function throttle(fn, interval, options = { leading: true }) {
let lastTime = 0
let { leading } = options
const _throttle = function(...args) {
//获取当前时间戳
let nowTime = new Date().getTime()
//判断是否为第一次输入,并且传进来的leading为false
if(!lastTime && !leading) lastTime = nowTime
let remainTime = interval - (nowTime - lastTime)
if(remainTime <= 0) {
fn.apply(this, args)
lastTime = nowTime
}
}
return _throttle
}
//测试
function fn(event) {
console.log(`第${++counter}次触发`, this, args)
}
let newFn = throttle(fn, 2000, { leading: false })
inputEl.oninput = newFn
节流的实现方式二
<body>
<input type="text" id="input">
</body>
<script>
const throttle = (fn, delay) => {
let timer = null;
return function (...args) {
if (timer) return;
timer = setTimeout(() => {
fn.call(this, ...args);
timer = null;
}, delay);
};
};
const inputDom = document.getElementById("input")
inputDom.oninput = throttle(function(e) {
console.log(e.target.value)
}, 500)
</script>
