一千个读者,有一千个哈姆雷特。html
我将会从函数的执行机制、鲁棒性、函数式编程、设计模式等方面,全面阐述如何编写高质量的函数。前端
如何编写高质量的函数,这是一个很难回答的问题,不一样人心中对高质量有本身的见解,这里我将全面的阐述我我的对如何编写高质量函数的一些见解。见解可能不够全面,也可能会有一些错误的看法,欢迎一块儿讨论,就像过日子的人,小吵小闹总会不经意的出现,一颗包容的心莫过因而最好的 best practice
。git
写博客趋向意识流(胡诌流),按着我内心想的去写,不会去详细的说明某一个知识点,若是须要详细讨论,能够在文末加我微信细聊。程序员
下面开始吧,我打算用三篇文章来完成 如何编写高质量的函数 这个系列。github
三篇文章我将从如下几个方面去阐述,如何编写高质量的函数。面试
V8
友好的函数是一种什么 style
下面开始胡诌啦:小声 BB
,能够先点个赞鼓励一下么(给一波精神上的鼓励)。编程
PS: 写过文章的小伙伴都知道,写一篇好文很不容易,很消耗精力,而写完博客后,最大的精神知足莫过于小伙伴的一个确定的赞。 哎,其实这个系列已经写好了,原本想一篇文章发完的,可是看了下,
2
万字,太多了,仍是分三篇吧。设计模式
本篇只说第一节 函数
,擒贼先擒王,下面咱们来盘一盘函数的七七八八,往 XXX
上盘😂。数组
函数二字,表明着一切皆有可能。bash
咱们想一下:咱们用的函数究竟离咱们有多远。就像打麻将同样,你以为你能像雀神那样,想摸啥就来啥么(夸张修辞手法)。
每天和函数打交道,有没有想过函数出现的目的是什么?咱们再深刻想一下,函数的执行机制是什么?下面咱们就来简单的分析一下。
函数是迄今为止发明出来的用于节约空间和提升性能的最重要的手段。
PS: 注意,没有之一。
有句话说的好,知己知彼,百战不殆。想要胜利,必定要很是的了解敌人。JS
确定不是敌人啦,可是要想掌握 JS
的函数,要更轻松的编写高质量的函数,那就要去掌握在 JS
中,函数的执行机制。
怎么去解释函数的执行机制呢?
我我的认为,不少前端或者其余编程语言的开发者,对计算机的一些底层原理不太清楚,好比编译原理,计算机组成原理等。
我来模仿一个前端面试题:输入一个 url
后,会发生什么?(哈哈哈哈哈隔)。来出一个面试题:
执行一个函数,会发生什么?
参考下面代码:
function say() {
let str = 'hello world'
console.log(str)
}
复制代码
是否是发现很酷,这道面试题要是交给你,你能答出多少呢?
中断
5
分钟想一下。
好了,中断结束。若是让我来答,我大体会这样说:
首先我要建立一个函数,打住。若是你学过 C++
,你可能不会这样说,你会这样说,我要先开辟一个堆内存。
因此,我会从建立函数到执行函数以及其底层实现,这三个层次进行分析:
函数不是无缘无故产生的,你要去建立一个函数,而建立函数的时候,究竟发生了什么呢?
答案以下:
第一步:我要开辟一个新的堆内存
为何呢?由于每一个字母都是要存储空间的,只要有数据,就必定得有存储数据的地方。而计算机组成原理中,堆容许程序在运行时动态地申请某个大小的内存空间,因此你能够在程序运行的时候,为函数申请内存。
第二步:我建立一个函数
say
,把这个函数体中的代码放在这个堆内存中。
想一下函数体是以什么样的形式放在堆内存中的?很明显,是以字符串的形式。 为何呢?咱们来看一下 say
函数体的代码是什么,以下:
let str = 'hello world'
console.log(str)
复制代码
你以为这些语句以什么形式的结构放在堆内存中比较好呢,不用考虑也是字符串,由于没有规律。若是是对象的话,因为有规律,能够按照键值对的形式存储在堆内存中。而没规律的一般都是变成字符串的形式。
第三步:在当前上下文中声明
say
函数(变量),函数声明和定义会提高到最前面
注意有个关键的点,就是当前上下文,咱们能够理解为上下文堆栈(栈),say
是放在堆栈(栈)中的,同时它的右边还有一个堆内存地址,用来指向堆中的函数体的。
PS: 建议去学习一下数据结构,栈中的一块一块的,咱们称为帧。你能够把栈理解中 DOM 树,帧理解为节点,每一帧( 节点 )都有本身的名字和内容。
第四步:把开辟的堆内存地址赋值给函数名
say
这里一个关键点就是,把堆内存地址赋值给函数名 say
。
我特地在白板上画了一个简单的示意图:
结合上图 say
右边的存储,再去理解上面的四个步骤,是否是有点感悟了呢。
这里我忽然想提一个简单的知识,就是赋值这个操做,好比我把堆内存地址赋值给函数名 say
,那么这意味着什么呢?
有不少人可能不明白,其实这里很简单,这一步赋值操做,从计算机组成原理角度看,内存分为好几个区域,好比代码区域,栈区域,堆区域等。
理解这几个区域一个最关键的点,就是要明白,每个存储空间的内存地址都是不同。也就是说,赋值(引用类型)的操做就是将堆区域的某一个地址,经过总线管道流入(复制)到对应栈区域的某一个地址中,从而使栈区域的某一个地址内的存储空间中有了引用堆区域数据的地址,这里业界叫句柄,说白了就是指针。只不过在高级语言中,把指针给隐藏了,直接有变量代替指针。
因此一个简单的赋值,其在计算机底层实现上,都是很复杂的,这里,也许经过汇编语言,你能够更好的去理解赋值的真正含义,好比 1 + 1
用汇编语言编写,就是下面代码:
start:
mov ax, 1
mov bx, 1
add ax, bx
end start;
复制代码
从上面代码中,咱们能够看到,把 1
赋值给 ax
,使用到了 mov
指令。而 mov
是 move
移动的缩写,这也证实了,在赋值这个操做上,其实本质上是数据或者数据的句柄在一张地址表中的流动。
PS: 因此若是是值类型,那就是直接把数据,流(移动)到指定内存地址的存储空间中。
建立函数就先说到这了,其实我已经说得很是详细了,从计算机底层去解释一些最基础的现象。
执行函数这个步骤,也很是重要,执行函数到底是什么样的过程,如今我就用我我的的总结去解释这个过程。
思考一个点。
咱们知道,函数体的代码是在保存在堆内存中的,并且是字符串形式。那么若是咱们要执行堆内存中的代码,首先要作的就是讲字符串变成真正的 JS
代码,这个是比较容易理解的,就像数据传输中的序列化和反序列化。
思考题一:为何会存在序列化和反序列化?你们能够自行思考一下,有些越简单的道理,背后越是有着非凡的思想
JS
代码如何将字符串变成 JS
代码,这里有一个前置知识,就是:
每个函数调用,都会在函数上下文堆栈中建立帧。
栈是什么?
栈是一个基本的数据结构,这里我就不解释了,小伙伴不懂的先去百度一下看看。
为何函数执行要在栈中执行呢?
最关键的一点就是,栈是先进后出的数据结构,咱们想一下,被也就意味着能够很好的保存和恢复调用现场。为何?咱们来看一段代码:
function f1() {
let b = 1;
function f2() {
cnsole.log(b)
}
return f2
}
let fun = f1()
fun()
复制代码
这里先不解释,继续往下看。
函数上下文堆栈是什么?
咱们能够这样去理解,函数上下文堆栈是一个数据结构,无论它是什么,若是学过 C++
或者 C
的,能够理解成是一个 struct
(结构体)。这个结构体负责管理函数执行已经关闭变量做用域。函数上下文堆栈在程序运行时就会产生,而且一开始加入到栈里面的是全局上下文帧,位于栈底。
首先要明白一点:
执行函数(函数调用)是在栈上完成的 。
这也就是为何 JS
函数能够递归。由于栈的先进后出的数据结构,赋予了其递归能力。
继续往下看,函数执行大体有如下步骤:
第一步:会造成一个供代码执行的环境,也是一个栈内存
这里,咱们先思考几个问题:
第二步:将存储的字符串复制一份到新开辟的栈内存中,使其变为真正的
JS
代码
这步很好理解,
第三步:先对形参进行赋值,再进行变量提高,好比将
var
function
变量提高。
第四步:在这个新开辟的做用域中自上而下执行
思考题:为何是自上而下执行呢?
将执行结果返回给当前调用的函数
思考题:将执行结果返回给当前调用的函数,其背后是如何实现的呢?
这里为何要谈谈底层实现呢,由于有还有一些知识点我没有提到,好比前面的一些思考,这里我想统一提一下。
函数在执行的时候,都会造成一个全新的私有做用域,也叫私有栈内存。
目的有以下几点:
第一点:把原有堆内存中存储的字符串变成真正的 JS
代码
第二点: 保护该栈内存的私有变量不受外界的干扰
函数执行的这种保护机制,在计算机中称之为 闭包 。
可能有人不明白,咋就私有了呢?
没问题,咱们能够反推。假设不是私有栈内存的,那么当你执行一个递归时,基本就完了,由于一个函数上下文堆栈中,有不少相同的 JS
代码,好比局部变量等,若是不私有化,那岂不乱套了,因此假设矛盾,私有栈内存成立。
首先,你要明白 JS
的栈内存是系统自动分配的,大小固定。想想,若是自动适应的话,那就基本不存在除死循环这种状况以外的的栈溢出了。
这个确实挺让人好奇的,为何呢?我举个例子,你每天写 return
语句,那你知道 return
的底层实现吗?你每天都在写子程序,那你知道子程序的底层的一些真相吗?
咱们来看一张图:
上图显示了一次函数调用的栈结构,从结构中咱们能够看到,内部有哪些东西,好比实参,局部变量,返回地址。
看下面代码:
function f1() {
return 'hello godkun'
}
let result = f1()
f2(result)
复制代码
上面这行代码的底层含义就是,f()
函数在私有栈内存中执行完后,使用 return
后,将 return
后的执行结果传递给 EAX
(累加寄存器),经常使用于函数返回值。对寄存器不了解的能够自行搜索学习一下,这里就再也不说了。这里我说一下 Return Addr
,Addr
主要目的是让子程序可以屡次被调用。
看下面代码:
function main() {
say()
// TODO:
say()
}
复制代码
上面代码,在 main
函数中进行了屡次调用子程序 say
,在底层实现上面,是经过在栈结构中保存一个 Addr
用来保存函数的起始运行地址,当第一个 say
函数运行完之后,Addr
就会指向起始运行地址,以备后面屡次调用子程序。
JS
引擎是如何执行函数上面我从不少方面分析了函数执行的机制,可能有点难懂。如今我来简要分析一下,JS
引擎是如何执行函数的。
这里我就不造轮子了,有一篇博客写的很是好,我发自心里的认为我写不出来比这还好的博客了。就算写出来,我感受也不必了。可是这篇博客写的过于归纳,不少细节没有提到,这里我要在此篇博客的基础上分析不少很重要的细节。
博客地址:
下面我开始分析,代码以下:
//定义一个全局变量 x
var x = 1
function A(y) {
//定义一个局部变量 x
var x = 2
function B(z) {
//定义一个内部函数 B
console.log(x + y + z)
}
//返回函数B的引用
return B
}
//执行A,返回B
var C = A(1)
//执行函数B
C(1)
复制代码
PS: 建议你们先看一下博客,知道一些基本概念,而后再看个人分析。
下面开始分析:
执行
A
函数时
JS
引擎构造的 ESCstack
结构以下:
简称 A
图:
执行
B
函数时
JS
引擎构造的 ESCstack
结构以下:
简称 B
图:
下面开始最为关键的我的感悟 show time
。
核心看下面代码:
EC(B) = {
[scope]:AO(A),
var AO(B) = {
z:1,
arguments:[],
this:window
},
scopeChain:<AO(B),B[[scope]]> } 复制代码
这是在执行 B函数
时,建立的 B
函数的执行环境(一个对象结构)。里面有一个 AO(B)
,这是 B
函数的活动对象。
那AO(B)
的目的是什么?其实 AO(B)
就是每一个链表的节点其指向的内容。
同时,这里还定义了 [scope]
属性,咱们能够理解为指针,[scope]
指向了 AO(A)
,而 AO(A)
就是函数 A
的活动对象。
函数活动对象保存着 局部变量、参数数组、this
属性。这也是为何你能够在函数内部使用 this
和 arguments
的缘由。
scopeChain
是做用域链,熟悉数据结构的同窗确定知道我想说什么了,其实函数做用域链本质就是链表,执行哪一个函数,那链表就初始化为哪一个函数的做用域,而后把当前指向的函数活动对象放到 scopeChain
链表的表头中。
好比执行 B
函数,那 B
的链表看起来就是 AO(B) --> AO(A)
可是别忘了,A
函数也是有本身的链表的,为 AO(A) --> VO(G)。因此整个链表就串起来来,B
的链表(做用域)就是:AO(B) --> AO(A) --> VO(G)
链表是一个闭环,由于查了一圈,回到本身的时候,若是还没找到,那就返回 undefined
。
思考题:你们能够思考一下 [scope] 和 [[scope]] 的命名方式,为何是这种形式。
A
函数的 ECS
咱们能看到什么咱们能看到,JS
语言是静态做用域语言,在执行函数以前,整个程序的做用域链就同样肯定好了,从 A
图中的函数 B
的 B[[scope]]
就能够看到做用域链已经肯定好了。不像 lisp
那种在运行时才能肯定做用域。
执行环境的数据结构是栈结构,其实本质上是给一个数组增长一些属性和方法。
执行环境能够用 ECStack
去表示,能够理解成 ECSack = []
这种形式。
栈(执行环境)专门用来存放各类数据,好比最经典的就是保存函数执行时的各类子数据结构。
好比 A
函数的执行环境是 EC(A)
。当执行函数 A
的时候,至关于 ECStack.push[A]
,当属于 A
的那些东西被放入到栈中的时候,都会被包裹成一个私有栈内存。
私有栈是怎么造成的,这里就要牵扯到汇编语言了,从汇编语言角度去看,会发现一个栈的内存分配,栈结构的各类变换,都是有底层标准去控制的。
因此咱们再联系一下,日常咱们所说的上下文环境啊,context
等,其实和我上面解释的执行环境并无什么区别,这样去理解,是否是发现对上下文环境之类的专有名词理解的更为深入了呢。
由于再跳,本质仍是同样的,计算机行业底层标准是肯定的。
this
this
为何在运行时才能肯定
咱们看上面两张图中的红色箭头,箭头处的信息很是很是重要。
看 A
图,执行 A
函数时,只有 A
函数有 this
属性,执行 B
函数时,只有 B
函数有 this
属性,这也就证明了 this
只有在运行时才会存在。
this
的指向真相
咱们看一下 this
的指向,A
函数调用的时候,属性 this
的属性是 window
,而 经过 var C = A(1)
调用 A
函数后,A
函数的执行环境已经 pop
出栈了。此时执行 C()
就是在执行 B
函数,EC(B)
已经在栈顶了,this
属性值是 window
全局变量。
经过 A
图 和 B
图的比较,直接展现 this
的本质。看清真相,this
也不过如此。
听不懂不要紧,听我娓娓道来。
经过
A
图 和B
图的比较,直接秒杀 做用域 的全部用法
看 A
图,执行 A
函数时,B
函数的做用域是建立 A
函数的活动对象 AO(A)
。做用域就是一个属性,一个属于 A函数的执行环境中的属性,它的名字叫作 [scope]
。
[scope]
指向的是一个函数活动对象,其实这里最核心的一点,就是你们要把这个函数对象当成一个做用域,但最好理解成一个链表节点。
若是你能理解成链表节点的话,那你就不会再对为何会有做用域链这个东西感到陌生,不会再对做用域和做用域链的区别而感到困惑。直接秒杀了做用域相关的全部问题。
PS:
B
执行B
函数时,只有B
函数有this
属性,这也就交叉证明了this
只有在运行时才会存在。
首先经过比较 A
图和 B
图的 scopeChain
,咱们能够肯定的是:
做用域链本质就是链表,执行哪一个函数,那链表就初始化为哪一个函数的做用域,而后将该函数的 [scope]
放在表头,造成闭环链表,做用域链的查找,就是经过链表查找的,若是走了一圈还没找到,那就返回 undefined
。
做用域链也是很 easy
的。
我决定再举一个例子,这是一个常常被问的面试题,看下面代码:
第一个程序以下:
function kun() {
var result = []
for (var i = 0; i < 10; i++) {
result[i] = function() {
return i
}
}
return result
}
let r = kun()
r.forEach(fn => {
console.log('fn',fn())
})
复制代码
第二个程序以下:
function kun() {
var result = []
for (var i = 0; i < 10; i++) {
result[i] = (function(n) {
return function() {
return n
}
})(i)
}
return result
}
let r = kun()
r.forEach(fn => {
console.log('fn', fn())
})
复制代码
上面两个程序会输出什么结果?并分析一下其原理。
输出结果你们应该都知道了,结果分别是以下截图:
第一个程序,输出 10
个 10
:
第二个程序,输出 0
到 9
:
那么问题来了,其内部的原理机制你知道吗?
coder
只能答到当即调用,闭包。coder
能够答到做用域相关知识。coder
(大佬级别) 能够从核心底层缘由来分析。下面我来展现一下从核心底层缘由来分析,是一种什么样的 style
。
代码以下:
function kun() {
var result = []
for (var i = 0; i < 10; i++) {
result[i] = function() {
return i
}
}
return result
}
let r = kun()
r.forEach(fn => {
console.log('fn',fn())
})
复制代码
如何去分析,首先咱们要明白一点,只有函数在执行的时候,函数的执行环境才会生成。那依据这个规则,咱们能够知道在完成 r = kun()
的时候,kun
函数只执行了一次,生成了对应的 AO(kun)
。咱们能够看一下 AO(kun)
有什么,以下:
AO(kun):{
i = 10;
kun = function(){...};
kun[[scope]] = this;
}
复制代码
这时,在执行 kun()
以后,i
的值已是 10
了。OK
,下面最关键的一点要来了,请注意,kun
函数只执行了一次,也就意味着:
在 kun
函数的 AO(kun)
中的 i
属性是 10
。
咱们继续分析, kun
函数的做用域链以下:
AO(kun)
--> VO(G)
并且 kun
函数已经从栈顶被删除了,之只留下了 AO(kun)
,注意一点:
这里的 AO(kun)
表示一个节点,这个节点有指针和数据,其中指针指向了 VO(G)
,数据就是 kun
函数的活动对象。
因此下面问题来了,当去一次执行 result
中的数组的时候,会发生什么现象?注意一点:
result
数组中的每个函数其做用域都已经肯定了,上面也提到过,JS
是静态做用域语言,其在程序声明阶段,全部的做用域都将肯定。
因此知道这点之后,那么 result
数组中每个函数其做用域链都是以下:
AO(result[i]) --> AO(kun) --> VO(G)
复制代码
所以 result
中的每个函数执行时,其 i
的值都是沿着这条做用域链去查找的,并且因为 kun
函数只执行了一次,致使了 i
值是最后的结果,也就是 10
。因此输出结果就是 10
个 10
。
总结一下,就是 result
数组中的 10
个函数在声明后,总共拥有了 10
个链表(做用域链),都是 AO(result[i]) --> AO(kun) --> VO(G)
这种形式,可是 10
个做用域链中的 AO(kun)
都是同样的。因此致使了,输出结果是 10
个 10
。
经过上面的解释,其实已经从一个至关底层的视角去分析了,须要注意的关键点,我也都提了出来,你们再好好研究下吧。
下面咱们来分析输出 0
到 9
的结果。
代码以下:
function kun() {
var result = []
for (var i = 0; i < 10; i++) {
result[i] = (function(n) {
return function() {
return n
}
})(i)
}
return result
}
let r = kun()
r.forEach(fn => {
console.log('fn', fn())
})
复制代码
经过分析 输出结果为 10
个 10
的状况,你们应该有所收获了,或者找到一些感受了,那输出 0
到 9
结果的状况,该怎么去分析呢?且听我娓娓道来。
首先和上面不同了,在声明函数 kun
的时候,就已经执行了 10
次匿名函数了。还记得只要执行函数,就会生成函数执行环境么。也就意味着,在 ECS
栈中,有 10
个 EC(kun)
执行环境,分别对应的是 result
数组中的 10
个函数。
具体展现状况,我来用伪代码表达一下:
ECSack = [
EC(kun) = {
[scope]: VO(G)
AO(kun) = {
i: 0,
result[0] = function() {...// return i},
arguments:[],
this: window
},
scopeChain:<AO(kun), kun[[scope]]>
},
// .....
EC(kun) = [
[scope]: VO(G)
AO(kun) = {
i: 9,
result[9] = function() {...// return i},
arguments:[],
this: window
},
scopeChain:<AO(kun), kun[[scope]]>
]
]
复制代码
上面简单的用结构化的语言表示了 kun
函数在声明时的内部状况,首先有两点要注意。
第一点:每个 EC(kun)
中的 AO(kun)
中的 i
属性值都是不同的,好比经过上面结构化表示,能够看到:
result[0]
函数的父执行环境是 EC(kun)
,这个 VO(kun)
里面的 i
值 是 0
。result[9]
函数的父执行环境是 EC(kun)
,这个 VO(kun)
里面的 i
值 是 9
。第二点:关于做用域链,也就是 scopeChain
,result
中的函数的 链表形式仍然是下面这种形式
AO(result[i]) --> AO(kun) --> VO(G)
复制代码
但不同的是,对应节点的存储地址不同了,至关因而 10
个新的 AO(kun)
。而每个 AO(kun)
的节点内容中的 i
值是不同的。
因此总结下就是:
执行 result
数组中的 10
个函数时,走了 10
个不一样的链表,同时每一个链表的 AO(kun)
节点是不同的。每一个 AO(kun)
节点中的 i
值也是不同的。
因此输出的结果最后显示为 0
到 9
。
是否是发现从底层去分析和理解的话,不少问题其实都有一个很合理,或者让阅读者能够接受的答案。
敲山震虎篇的知识难度有点大,费了我很多脑子,经过对底层实现原理的分析,咱们能够更加深入的去理解函数的执行机制。
深入的理解了函数的执行机制,咱们才能更流畅的写出高质量的函数。
如何减小做用域链(链表)的查找
好比咱们看不少库,想 JQ
等,都会在当即执行函数的最外面传一个 window
参数。这样作的目的是由于,window
是全局对象,经过传参,避免了查找整个做用域链。提升了函数的执行效率,看法了写出了高质量的函数。
如何防止栈溢出 咱们知道,每一次执行函数,都会建立函数的执行环境,也就意味着占用一些栈内存,而栈内存大小是固定的,若是写了很大的递归函数,大家就会形成栈内存溢出,引起错误。
我以为,咱们要去努力的达成这样一个成就:
作到当我在手写一个函数时,我心中很是清楚的知道我正在写的每一行代码,其在内存中是怎么表现的,或者说其在底层是如何执行的,从而达到 眼中有码,心中无码 的境界。
若是能作到这样的话,那还怕写不出高质量的函数吗?
后续会有其余两篇博客,分别是基础篇和高级篇,能够关注个人掘金博客或者 github
来获取后续的系列文章更新通知。
掘金系列技术文章汇总以下,以为不错的话,点个 star 鼓励一下。
我是源码终结者,欢迎技术交流。
也能够进 前端狂想录群 你们一块儿头脑风暴。有想加的,由于人满了,能够先加我好友,我来邀请你进群。
今天是一个开心的节日,既是吃汤圆猜灯谜的元宵节,也是程序员通宵的节日(猿宵节)。
虽然 20
号了,但啥也别说了,祝各位首富来年元宵节快乐(嘿嘿)。
最后:尊重原创,转载请注明出处哈😋