一、JS引擎的工作原理
先引入几个概念:执行环境栈
、执行环境
、全局对象
、变量对象
、活动对象
、作用域
和作用域链
然后贴一段代码:
1 | var x = 1; //定义一个全局变量 x |
下面我们从全局初始化
、执行函数A
、执行函数B
三个阶段来分析JS引擎对这段代码的处理过程
1.全局初始化
JS引擎在进入一段可执行代码时,会完成三项初始化工作:
- 首先,创建一个全局对象,该对象全局只存在一份,会伴随应用程序的整个生命周期,它的属性在应用程序的各个地方均可访问。我们平时经常用到的一些对象,如Math、String、Date、document等都是它的属性。由于这个全局对象不能通过名字直接访问,因此还有另外一个属性window,并将window指向了自身,这样就可以通过window访问这个全局对象了。用伪代码模拟全局对象的大体结构如下:
1 | var globalObject = { |
- 然后,JS引擎需要构建一个执行环境栈,同时创建一个全局执行环境,并将全局执行环境压入到执行环境栈中。执行环境栈的主要作用是保证应用程序能够按照正确的顺序执行。在JavaScript中,每个函数都会有自己的执行环境,当执行一个函数的时候,改函数的执行环境就会压入到执行环境栈的栈顶,并获得执行权,当函数执行完毕,函数的执行环境从栈顶移除,并将执行权交给之前的执行环境。用伪代码来模拟执行环境栈和执行环境的关系如下:
1 | var ECStack = []; //定义一个执行环境栈,类似于数组 |
- 最后,JS引擎需要创建一个与全局执行环境相关联的全局变量对象,并把全局变量对象指向全局对象,全局变量对象不仅包含全局对象的原有属性,还包括我们在全局定义的变量和函数,如变量x、函数A。于此同时,在定义函数A的时候,会为函数A添加一个scope属性,指向函数A定义时所处的环境,即全局变量对象。在JavaScript中,每个函数在定义的时候,都会创建一个与之关联的scope属性,scope总是指向定义函数时所在的环境(记住这句话,很重要,很关键)。此时执行环境栈的结构如下:
1 | ECStack = [ //执行环境栈 |
2.执行函数A
当执行进入A(1) 时,JS引擎需要完成以下工作:
- 首先,JS引擎会创建函数A的执行环境,然后将函数A的执行环境压入到执行环境栈的栈顶并获得执行权。此时执行环境栈中有两个执行环境,分别是全局执行环境和函数A执行环境。
- 然后,创建函数A执行环境的作用域链,在JavaScript中,每个执行环境都会有自己的作用域链,用于标识符的解析,当执行环境被创建时,它的作用域链就初始化为当前运行函数的scope属性所包含的对象。
- 接着,JS引擎会创建一个与当前函数执行环境相关联的活动对象,这里的活动对象扮演着变量对象的角色,只是在函数中的叫法不同而已(你可以认为变量对象是一个总的概念,而活动对象是它的一个分支)。活动对象包含函数的形参、arguments对象,this以及局部定义的变量和函数。然后该活动对象会被加入到作用域链的顶端。需要注意的是,在定义函数B的时候,JS引擎同样也会为B添加了一个scope属性,并将scope指向了定义函数B时所在的环境,定义函数B的环境就是A的活动对象AO, 而AO位于链表的前端,由于链表具有首尾相连的特点,因此函数B的scope指向了A的整个作用域链。 我们再看看此时的ECStack结构:
1 | ECStack = [ //执行环境栈 |
3.执行函数B
函数A被执行以后,返回了B的引用,并赋值给了变量C,执行 C(1) 就相当于执行B(1),JS引擎需要完成以下工作:
- 首先,创建函数B的执行环境,并加入到执行环境栈的栈顶获得执行权(当函数A返回后,A的执行环境就会从栈中被删除,只留下全局执行环境)。
- 然后,创建函数B执行环境的作用域链,初始化为函数B的scope所包含的对象,即包含了A的作用域链。
- 最后,创建函数B执行环境相关联的活动对象。此时ECStack将会变成这样:
1 | ECStack = [ //执行环境栈 |
当函数B执行“x+y+z”时,需要对x、y、z 三个标识符进行一一解析,解析过程遵守变量查找规则:先查找自己的活动对象中是否存在该属性,如果存在,则停止查找并返回;如果不存在,继续沿着其作用域链从顶端依次查找,直到找到为止,如果整个作用域链上都未找到该变量,则返回“undefined”。从上面的分析可以看出函数B的作用域链是这样的:
1 | AO(B)->AO(A)->VO(G) |
因此,变量x会在AO(A)中被找到,而不会查找VO(G)中的x,变量y也会在AO(A)中被找到,变量z 在自身的AO(B)中就找到了。所以执行结果:2+1+1=4.
二、理解闭包
在JavaScript中,一个函数可以定义在另一个函数内部。内嵌函数的引用环境包含自身的局部变量和参数、外套函数的局部变量和参数,以及全局对象的属性。
一个内嵌函数可以访问外套函数的应用环境,当内嵌函数运行与外套函数的作用域内时,满足这个要求很简单。但是在JavaScript中,函数还可以作为参数和返回值,这时,送内嵌函数的定义到调用它的代码,引用环境发生了改变。如果还要访问原来的引用环境,就必须以某种方式将内嵌函数的引用环境与外套函数的引用环境绑定在一起,这个绑定的过程即为闭包的创建过程。
函数的局部变量存在于函数调用时与执行环境相关联的活动对象中,如果没有闭包的存在,外套函数返回内嵌函数后,外套函数的执行环境会从执行环境栈中移除,返回的内嵌函数所能应用的外套函数的局部变量也将随之消失。
1 | function createClosure() { |
实际上闭包并不只是在函数返回是才创建的,任何闭包都是随同函数定义时一起创建的,有人的地方就有江湖,有函数的地方就有闭包。在JavaScript中,每个函数在定义的时候,都会创建一个与之关联的scope属性,scope总是指向定义函数时所在的环境。我们可以将函数的scope属性看成函数的闭包,所以闭包无处不在,无时不有。
尝试说出以下代码的执行结果:
1 | function f(fn, x) { //定义一个全局函数f |
1.执行函数f
当执行进入f(h, 0)时,JS引擎需要完成以下工作:
- 首先,创建函数f的执行环境,并加入到执行环境栈的栈顶获得执行权。
- 然后,创建函数f执行环境的作用域链,初始化为函数f的scope所包含的对象。
- 接着,创建与函数f执行环境相关联的活动对象,该活动对象包含函数f的形参fn和x、arguments对象,this以及局部定义的函数g。注意,在定义函数g的时候,会为函数g添加一个scope属性,指向函数g定义时所处的环境即函数f的活动对象。此时的ECStack结构如下:
1 | ECStack = [ //执行环境栈 |
2.第二次执行函数f
由于x为0,满足x < 1的条件,所以执行进入f(g, 1),JS引擎需要完成以下工作:
- 首先,创建函数f的执行环境(该执行环境与进入f(h, 0)时创建的执行环境完全不同),并加入到执行环境栈的栈顶获得执行权(当执行f(h, 0)返回后,f的执行环境就会从栈中被删除,只留下全局执行环境)。
- 然后,创建函数f执行环境的作用域链,初始化为函数f的scope所包含的对象。
- 接着,创建与函数f执行环境相关联的活动对象,该活动对象包含函数f的形参fn和x、arguments对象,this以及局部定义的函数g。注意,在定义函数g的时候,会为函数g添加一个scope属性,指向函数g定义时所处的环境即函数f的活动对象。此时的ECStack结构如下:
1 | ECStack = [ //执行环境栈 |
3.执行函数g
由于x为1,不满足x < 1的条件,所以执行进入fn(),即执行局部函数g,JS引擎需要完成以下工作:
- 首先,创建函数g的执行环境,并加入到执行环境栈的栈顶获得执行权(当执行f(g, 1)返回后,f的执行环境就会从栈中被删除,只留下全局执行环境)。
- 然后,创建函数g执行环境的作用域链,初始化为函数g的scope所包含的对象,此时函数g的scope所指向的对象为执行f(h, 0)时所创建的活动对象。
1 | AO(f): { //创建函数f的活动对象 |
- 接着,创建与函数g执行环境相关联的活动对象。
- 最后,执行console.log(x),打印0。
闭包虽然是在函数定义时就创建了,但并不意味着其中的变量就会停留在那一刻。只要与闭包关联的函数不立马执行,程序的执行权仍在闭包的创建者手中,闭包中的值就可能会发生改变。举个栗子:
1 | var list = document.createElement('ul); |
编写这段代码的原意是想要创建5个li元素,在每个li元素上单击时,控制台会打印出元素所对应的编号。但是实际上,所有li元素的打印结果都是”item 6 is Clicked.“。原因是事件处理函数的闭包记住了变量i,但记住的并不是创建闭包时的值,变量i的值会随着循环的执行而改变为6,而这就是元素单击时实践处理函数读取到的值。
三、函数式编程
在JavaScript中,函数是一等值。何谓一等?一等,是编程语言中值的通用修饰词,只要某个值满足一下三个条件,就能被成为一等值:
- 可以作为参数传递给函数
- 可以作为函数的返回值
- 可以赋值给变量
众所周知 JavaScript 是一种拥有很多共享状态的动态语言,慢慢的,代码就会积累足够的复杂性,变得笨拙难以维护。当我们在设计应用程序的时候,我们应该考虑是否遵守了以下的设计原则。
- 可扩展性–我是否需要不断地重构代码来支持额外的功能?
- 易模块化–如果我更改了一个文件,另一个文件是否会受到影响?
- 可重用性–是否有很多重复的代码?
- 可测试性–给这些函数添加单元测试是否让我纠结?
- 易推理性–我写的代码是否非结构化严重并难以推理?
什么是函数式编程?就是打心眼里承认函数是一等公民。
简单来说,函数式编程是一种强调以函数使用为主的软件开发风格。函数式编程的目的是使用函数来抽象作用在数据之上的控制流和操作,从而在系统中消除副作用并减少对状态的改变。
举个栗子:
现在的需求就是输出在网页上输出 “Hello World”。
一般的初学者会这样写:
1 | document.querySelector('#msg').innerHTML = '<h1>Hello World</h1>' |
这样写很简单,但是所有都是写死的,不能复用,如果想改变消息的格式、内容等就需要重写整个表达式,所以可能有经验的前端开发者会这么写:
1 | function printMessage(elementId, format, message) { |
这样确实有所改进,但是仍然不是一段可重用的代码,如果是要将文本写入文件,而不是插入到HTML中,或者我想重复的显示 Hello World。
那么作为一个函数式编程的开发者会怎么写这段代码呢?
1 | const printMessage = compose(addToDom, h1, echo) |
其中h1、echo、addToDom和compose都是函数,compose函数尤为关键,它的每个参数都是函数,自右向左执行参数,下一个函数接收上一个函数的执行结果作为参数。
那么我们为什么要写成这样呢?看起来多了很多函数。
其实我们是将程序分解为一些更可重用、更可靠且更易于理解的部分,然后再将他们组合起来,形成一个更易推理的程序整体。
好,我们现在再改变一下需求,现在我们需要将文本重复三遍,打印到控制台。
1 | var printMessaage = compose(console.log, repeat(3), echo) |
可以看到我们更改了需求并没有去修改内部逻辑,只是重组了一下函数而已。
为了充分理解函数式编程,我们先来看下几个基本概念。
- 声明式编程
- 不可变数据
- 纯函数
- 高阶函数
- lambda 表达式
- 组合函数
- point free
- 柯里化
- 部分应用(偏函数)
1.声明式编程
函数式编程属于声明是编程范式:这种范式会描述一系列的操作,但并不会暴露它们是如何实现的或是数据流如何传过它们。
我们所熟知的 SQL 语句就是一种很典型的声明式编程,它由一个个描述查询结果应该是什么样的断言组成,对数据检索的内部机制进行了抽象。
我们再来看一组代码再来对比一下命令式编程和声明式编程。
1 | // 命令式方式 |
可以看到命令式很具体的告诉计算机如何执行某个任务,而声明式是将程序的描述与求值分离开来。它关注如何用各种表达式来描述程序逻辑,而不一定要指明其控制流或状态关系的变化。
为什么我们要去掉代码循环呢?循环是一种重要的命令控制结构,但很难重用,并且很难插入其他操作中。而函数式编程旨在尽可能的提高代码的无状态性和不变性。要做到这一点,就要学会使用无副作用的函数–也称纯函数。
2.不可变数据
不可变数据其实是函数式编程相关的重要概念。相对的,函数式编程中认为可变性是万恶之源。简而言之可变状态会让程序的运行变得不可预测,代码可读性差,难以维护。
在 JS 中,当函数入参是对象类型的数据时,我们拿到的其实是个引用,所以即使在函数内部我们也是可以修改对象内部的属性,这种情景依然会产生副作用。
所以这个时候就需要引入 Immutable 的概念。 Immutable 即 unchangeable, Immutable data在初始化创建后就不能被修改了,每次对于 Immutable data 的操作都会返回一个新的 Immutable data。 所以并不会对原来的状态形成改变(当然不是简单的深拷贝再修改)。
创建不可变数据的主要实现思路就是:一次更新过程中,不应该改变原有对象,只需要新创建一个对象用来承载新的数据状态。
举个栗子
1 | const student1 = { |
这样,我们达到了想要的效果:根据参数,产生了一个新对象,并正确赋值,最重要的就是并没有改变原对象。
3.纯函数
纯函数指没有副作用的函数,相同的输入有相同的输出。
常常这些情况会产生副作用。
- 改变一个函数参数的原始值
- 读取作用域外的其他变量
- 改变作用域外的其他变量
- 处理用户输入
- 抛出一个异常
- 屏幕打印或记录日志
- 访问浏览器的Cookie
- 发起一个网络请求
- DOM查询/操作
举个栗子
1 | var tax = 20; |
这个函数是不纯的,它读取了外部的变量tax,可能会觉得这段代码没有什么问题,但是我们要知道这种依赖外部变量来进行的计算,计算结果很难预测,你也有可能在其他地方修改了tax的值,导致你 calculateTax出来的值不是你预期的。
所以纯函数有如下特性:
- 变量都只在函数作用域内获取, 作为的函数的参数传入
- 不会产生副作用, 不会改变被传入的数据或者其他数据
- 相同的输入保证相同的输出
但是在我们平时的开发中,有一些副作用是难以避免的,与外部的存储系统或 DOM 交互等,我们可以通过将其从主逻辑中分离出来,使他们易于管理。
现在我们有一个小需求:通过id找到学生的记录并渲染在浏览器(在写程序的时候要想到可能也会写到控制台,数据库或者文件,所以要想如何让自己的代码能重用)中。
1 | // 命令式代码 |
上面代码中curry函数的主要作用是将多参数函数转换成单参数函数,即柯里化。
可以看到函数式代码通过较少这些函数的长度,将 showStudent 编写为小函数的组合。这个程序还不够完美,但是已经可以展现出相比于命令式的很多优势了。
- 灵活。有三个可重用的组件
- 声明式的风格,给高阶步骤提供了一个清晰视图,增强了代码的可读性
- 另外是将纯函数与不纯的行为分离出来。
我们看到纯函数的输出结果是一致的,可预测的,相同的输入会有相同的返回值,这个其实也被称为引用透明。
引用透明是定义一个纯函数较为正确的方法。纯度在这个意义上表示一个函数的参数和返回值之间映射的纯的关系。如果一个函数对于相同的输入始终产生相同的结果,那么我们就说它是引用透明。
4.高阶函数
所谓高阶函数是指可一把函数作为参数,或者是可以将函数作为返回值的函数。
对于程序的编写,高阶函数比普通函数要灵活的多,除了通常意义的函数调用返回外,还形成了一种后续传递风格的结果接收方式,而非单一的返回值形式,后续传递风格的程序编写将函数的业务重点从返回值转移到了回调函数中。
举个栗子
1 | function foo(x, bar){ |
对于相同的foo()函数,传入的bar的参数不同,则可以得到不同的结果。
高阶函数在JS中的应用比比皆是,其中ECMAScript5中提供的一些数组方法就是典型的高阶函数,比如:forEach()、map()、reduce()、reduceRight()、filter()、every()、some()等。
5.lambda表达式
lambda 表达式其实是一个匿名函数,使用箭头清晰的表示输入输出的映射关系,JavaScript 中使用箭头函数来实现。
- 可选类型声明:不需要声明参数类型,编译器可以统一识别参数值。
- 可选的参数圆括号:一个参数无需定义圆括号,但多个参数需要定义圆括号。
- 可选的大括号:如果主体包含了一个语句,就不需要使用大括号。
- 可选的返回关键字:如果主体只有一个表达式返回值则编译器会自动返回值,大括号需要指定明表达式返回了一个数值。
1 | const multiply = x => x * x |
6.组合函数
组合,是函数式编程的核心之一,通过组合小的、确定的函数,来创建更大的软件组件和功能,能够生成更加容易组织、理解、调试、扩展、测试和维护的代码。
函数的组合就是将已被分解的简单任务组合成复杂任务的过程。
举个栗子:
现在我们有这样一个需求:给你一个字符串,将这个字符串转化成大写,然后逆序。
你可能会这样写:
1 | var str = 'function program' |
可能看到这里你并没有觉得有什么不对的,但是现在产品又突发奇想,改了下需求,把字符串大写之后,把每个字符拆开之后组装成一个数组,比如 ’aaa‘ 最终会变成 [A, A, A]。
那么这个时候我们就需要更改我们之前我们封装的函数。这就修改了以前封装的代码,其实在设计模式里面就是破坏了开闭原则。
那么我们如果把最开始的需求代码写成这个样子,以函数式编程的方式来写。
1 | var str = 'function program' |
那么当我们需求变化的时候,我们根本不需要修改之前封装过的东西。
1 | var str = 'function program' |
可以看到当变更需求的时候,我们没有打破以前封装的代码,只是新增了函数功能,然后把函数进行重新组合。
突然产品一拍脑袋,又想改一下需求,把字符串大写之后,再翻转,再转成数组。
要是你按照以前的思考,没有进行抽象,你肯定心理一万只草泥马在奔腾,但是如果你抽象了,你完全可以不慌。
1 | var str = 'function program' |
发现并没有更换你之前封装的代码,只是更换了函数的组合方式。可以看到,组合的方式是真的就是抽象单一功能的函数,然后再组成复杂功能。这种方式既锻炼了你的抽象能力,也给维护带来巨大的方便。
注意:要传给组合函数的函数是有规范的,首先函数的执行是从最后一个参数开始执行,一直执行到第一个,必须只有一个形参,而且函数的返回值是下一个函数的实参。
如果compose
函数接收的函数数量是固定的,那么它实现起来就很简单。
只接收两个函数作为参数:
1 | function compose(f, g) { |
只接收三个函数作为参数:
1 | function compose(f, g, m) { |
但是compose
函数接收的参数通常不是固定的,我们可以用rest
形式来接收参数:
1 | function compose(...fns) { |
现在我们只需要考虑如果将函数数组从右向左执行,可以使用数组的reduceRight
方法来实现:
1 | function compose(...fns) { |
compose
函数的数据流是从右至左,最右侧的函数最先执行,最左侧的函数最后执行;
那么从左向右的数据流,即最左侧的函数最先执行,最右侧的函数最后执行,称之为什么?——pipeline(管道)
管道(pipeline)
的实现同compose
的实现方式很类似,因为二者的区别仅仅是数据流的方向不同而已。
对比compose
函数的实现,仅需将reduceRight
替换为reduce
即可:
1 | function compose(...fns) { |
7.point-free
在函数式编程的世界中,有这样一种很流行的编程风格。这种风格被称为 tacit programming,也被称作为 point-free,point 表示的就是形参,意思大概就是没有形参的编程风格。
1 | // 这就是有参的,因为word这个形参 |
有参的函数的目的是得到一个数据,而 pointfree 的函数的目的是得到另一个函数。
那这 pointfree 有什么用? 它可以让我们把注意力集中在函数上,参数命名的麻烦肯定是省了,代码也更简洁优雅。 需要注意的是,一个 pointfree 的函数可能是由众多非 pointfree 的函数组成的,也就是说底层的基础函数大都是有参的,pointfree 体现在用基础函数组合而成的高级函数上,这些高级函数往往可以作为我们的业务函数,通过组合不同的基础函数构成我们的复制的业务逻辑。
可以说 pointfree 使我们的编程看起来更美,更具有声明式,这种风格算是函数式编程里面的一种追求,一种标准,我们可以尽量的写成 pointfree,但是不要过度的使用,任何模式的过度使用都是不对的。
8.柯里化
在计算机科学,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
柯里化后的函数正式组合函数参数的要求,柯里化的作用就是解决基础函数如果是多参数函数,就不能作为参数传递给组合函数的问题。
柯里化函数可以使我们更好的去追求 pointfree,让我们代码写得更优美!
举个栗子来理解柯里化。
比如你有一间店铺,并且你想给你顾客打个九个优惠,现在我们需要计算优惠了多少钱
1 | function discount(price, discount) { |
你可以预见,从长远来看,我们会发现自己每天都在计算打九折的优惠
1 | const price1 = discount(1500, 0.10); // $150 |
我们可以将 discount 函数柯里化,这样我们就不用总是每次计算时都输入这 0.10 的折扣。
1 | // 这个就是一个柯里化函数,将本来两个参数的 discount ,转化为每次接收单个参数完成求职 |
同样地,有些至尊vip客户,我们需要为他们提供 20% 的折扣。 可以使用我们的柯里化的discount函数:
1 | const twentyPercentDiscount = discountCurry(0.2); |
这就是柯里化,下面举个栗子说明柯里化在函数式编程里的应用
假设现在我们有这么一个需求:给定的一个字符串,先翻转,然后转大写,找是否有JD,如果有那么就输出 yes,否则就输出 no。
1 | function stringToUpper(str) { |
我们很容易就写出了这四个函数,现在我们想通过组合函数的方式来实现 pointfree,但是我们的 find 函数要接受两个参数,不符合组合函数参数的规定,这个时候我们像前面一个例子一样,把 find 函数柯里化一下,然后再进行组合:
1 | // 柯里化 find 函数 |
对于JavaScript而言,我们通常所说的柯里化函数与数学和计算机科学中的柯里化的概念并不一样。
在数学和计算机科学中的柯里化函数,一次只能传递一个参数;
而JavaScript中的柯里化函数,可以传递一个或多个参数。
举个栗子:
1 | //普通函数 |
对于已经柯里化后的 _fn 函数来说,当接收的参数数量与原函数的形参数量相同时,执行原函数; 当接收的参数数量小于原函数的形参数量时,返回一个函数用于接收剩余的参数,直至接收的参数数量与形参数量一致,执行原函数。
那么如何实现 curry 函数呢?
回想之前我们对于柯里化的定义,接收一部分参数,返回一个函数接收剩余参数,接收足够参数后,执行原函数。
我们已经知道了,当柯里化函数接收到足够参数后,就会执行原函数,那么我们如何去确定何时达到足够的参数呢?
我们有两种思路:
- 通过函数的 length 属性,获取函数的形参个数,形参的个数就是所需的参数个数;
- 在调用柯里化工具函数时,手动指定所需的参数个数。
我们将这两点结合以下,实现一个简单 curry 函数:
1 | /** |
9.部分应用(偏函数)
部分应用是一种通过将函数的不可变参数子集,初始化为固定值来创建更小元数函数的操作。简单来说,如果存在一个具有五个参数的函数,给出三个参数后,就会得到一个两个参数的函数。
部分应用与柯里化类似,都是用来减少函数参数的手段。
1 | function debug(type, firstArg, secondArg) { |
debug方法封装了我们平时用 console 对象调试的时候各种方法,本来是要传三个参数,我们通过部分应用的封装之后,我们只需要根据需要调用不同的方法,传必须的参数就可以了。
因为部分应用也可以减少参数,所以他在我们进行编写组合函数的时候也占有一席之地,而且可以更快传递需要的参数,留下为了 compose 传递的参数,这里是跟柯里化比较,因为柯里化按照定义的话,一次函数调用只能传一个参数,如果有四五个参数就需要:
1 | function add(a, b, c, d) { |
用部分应用就可以
1 | // 使用部分应用的方式使 add 转化为一个一元函数 |