分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。
分类: Web开发
2019-08-26 18:31:57
本文首发于 vivo互联网技术 微信公众号
链接:
作者:杨昆
一千个读者,有一千个哈姆雷特。
此系列文章将会从函数的执行机制、鲁棒性、函数式编程、设计模式等方面,全面阐述如何通过 JavaScript 编写高质量的函数。
如何编写高质量的函数,这是一个很难回答的问题,不同人心中对高质量有自己的看法,这里我将全面的阐述我个人对如何编写高质量函数的一些看法。看法可能不够全面,也可能会有一些错误的见解,欢迎一起讨论,就像过日子的人,小吵小闹总会不经意的出现,一颗包容的心莫过于是最好的 best practice 。
我打算用几篇文章来完成《如何通过 JavaScript 编写高质量的函数》 这个系列。
主要从以下几个方面进行阐述:
本篇只说第一节 函数 ,擒贼先擒王,下面我们来盘一盘函数的七七八八。
函数是迄今为止发明出来的用于节约空间和提高性能的最重要的手段。
PS: 注意,没有之一。
有句话说的好,知己知彼,百战不殆。想要胜利,一定要非常的了解敌人。JS 肯定不是敌人啦,但是要想掌握 JS 的函数,要更轻松的编写高质量的函数,那就要去掌握在 JS 中,函数的执行机制。
怎么去解释函数的执行机制呢?
先来模仿一道前端面试题:输入一个 url 后,会发生什么?
执行一个函数,会发生什么?
参考下面代码:
这道面试题要是交给你,你能答出多少呢?
如果让我来答,我大致会这样说:
首先我会创建一个函数。如果你学过 C++ ,可能会说我要先开辟一个堆内存。
所以,我会从创建函数到执行函数以及其底层实现,这三个层次进行分析。
函数不是平白无故产生的,需要创建。创建函数时会发生什么呢?
第一步:我要开辟一个新的堆内存
每个字母都是要存储空间的,只要有数据,就一定得有存储数据的地方。而计算机组成原理中,堆允许程序在运行时动态地申请某个大小的内存空间,所以你可以在程序运行的时候,为函数申请内存。
第二步:我创建一个函数 say ,把这个函数体中的代码放在这个堆内存中。
这些语句以字符串的形式放在堆内存中比较好,因为没有规律。如果是对象,由于有规律,可以按照键值对的形式存储在堆内存中。而没规律的通常都是变成字符串的形式。
第三步:在当前上下文中声明 say 函数(变量),函数声明和定义会提升到最前面
注意,当前上下文,我们可以理解为上下文堆栈(栈),say 是放在堆栈(栈)中的,同时它的右边还有一个堆内存地址,用来指向堆中的函数体的。
PS: 建议去学习一下数据结构,栈中的一块一块的,我们称为帧。你可以把栈理解中 DOM 树,帧理解为节点,每一帧( 节点 )都有自己的名字和内容。
第四步:把开辟的堆内存地址赋值给函数名 say
结合上图 say 右边的存储,再去理解上面的四个步骤,是不是有点感悟了呢。
从上面代码中,我们可以看到,把 1 赋值给 ax ,使用到了 mov 指令。而 mov 是 move 移动的缩写,这也证明了,在赋值这个操作上,本质上是数据或者数据的句柄在一张地址表中的流动。
PS: 所以如果是值类型,那就是直接把数据,流(移动)到指定内存地址的存储空间中。
思考一个点。
我们知道,函数体的代码是在保存在堆内存中的,而且是字符串形式。那么如果我们要执行堆内存中的代码,首先要做的就是讲字符串变成真正的 JS 代码,这个是比较容易理解的,就像数据传输中的序列化和反序列化。
思考题一:为什么会存在序列化和反序列化?大家可以自行思考一下,有些越简单的道理,背后越是有着非凡的思想
为什么函数执行要在栈中执行呢?
函数上下文堆栈是什么?
函数上下文堆栈是一个数据结构,如果学过 C++ 或者 C 的,可以理解成是一个 struct (结构体)。这个结构体负责管理函数执行已经关闭变量作用域。函数上下文堆栈在程序运行时产生,并且一开始加入到栈里面的是全局上下文帧,位于栈底。
首先要明白一点:
第一步:会形成一个供代码执行的环境,也是一个栈内存
这里,我们先思考几个问题:
第二步:将存储的字符串复制一份到新开辟的栈内存中,使其变为真正的 JS 代码
第三步:先对形参进行赋值,再进行变量提升,比如将 var function 变量提升。
第四步:在这个新开辟的作用域中自上而下执行
思考题:为什么是自上而下执行呢?
将执行结果返回给当前调用的函数
思考题:将执行结果返回给当前调用的函数,其背后是如何实现的呢?
可能有人不明白,咋就私有了呢?
没问题,我们可以反推。假设不是私有栈内存的,那么在执行一个递归时,基本就结束了,因为一个函数上下文堆栈中,有很多相同的 JS 代码,比如局部变量等,如果不私有化,那岂不乱套了?所以假设矛盾,私有栈内存成立。
首先,JS 的栈内存是系统自动分配的,大小固定。如果自动适应的话,那就基本不存在除死循环这种情况之外的栈溢出了。
如上,在 main 函数中进行多次调用子程序 say ,在底层实现上面,是通过在栈结构中保存一个 Addr 用来保存函数的起始运行地址,当第一个 say 函数运行完以后,Addr 就会指向起始运行地址,以备后面多次调用子程序。
执行 A 函数时
JS 引擎构造的 ESCstack 结构如下:
简称 A 图:
执行 B 函数时
JS 引擎构造的 ESCstack 结构如下:
简称 B 图:
1.局部变量是如何被保存起来的
核心代码:
这是在执行 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]] 为什么以这种形式命名?
2.通过 A 函数的 ECS 我们能看到什么
我们能看到,JS 语言是静态作用域语言,在执行函数之前,整个程序的作用域链就确定了,从 A 图中的函数 B 的 B[[scope]] 就可以看到作用域链已经确定。不像 lisp 那种在运行时才能确定作用域。
3.执行环境,上下文环境是一种什么样的存在
执行环境的数据结构是栈结构,其实本质上是给一个数组增加一些属性和方法。
执行环境可以用 ECStack 表示,可以理解成 ECSack = [] 这种形式。
栈(执行环境)专门用来存放各种数据,比如最经典的就是保存函数执行时的各种子数据结构。比如 A 函数的执行环境是 EC(A)。当执行函数 A 的时候,相当于 ECStack.push[A] ,当属于 A 的东西被放入到栈中,都会被包裹成一个私有栈内存。
私有栈是怎么形成的?从汇编语言角度去看,一个栈的内存分配,栈结构的各种变换,都是有底层标准去控制的。
4.开启上帝模式看穿 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 的本质。
5.作用域的本质是链表中的一个节点
通过 A 图 和 B 图的比较,直接秒杀 作用域 的所有用法
看 A 图,执行 A 函数时,B 函数的作用域是创建 A 函数的活动对象 AO(A) 。作用域就是一个属性,一个属于 A 函数的执行环境中的属性,它的名字叫做 [scope] 。
[scope] 指向的是一个函数活动对象,核心点是把这个函数对象当成一个作用域,最好理解成一个链表节点。
PS: B 执行 B 函数时,只有 B 函数有 this 属性,这也就交叉证实了 this 只有在运行时才会存在。
6.作用域链的本质就是链表
首先通过比较 A 图和 B 图的 scopeChain ,可以确定的是:
作用域链本质就是链表,执行哪个函数,链表就初始化为哪个函数的作用域,然后将该函数的 [scope] 放在表头,形成闭环链表。作用域链是通过链表查找的,如果走了一圈还没找到,那就返回 undefined 。
五、用一道面试题让你更上一层楼(走火入魔)
再举一个例子,这是一道经常被问的面试题,看下面代码:
第一个程序如下:
第二个程序如下:
输出结果大家应该都知道了,结果分别是如下截图:
第一个程序,输出 10 个 10 :
第二个程序,输出 0 到 9 :
那么问题来了,其内部的原理机制你知道吗?
下面从核心底层原因来分析 。
代码如下:
只有函数在执行的时候,函数的执行环境才会生成。依据这个规则,在完成 r = kun() 的时候,kun 函数只执行了一次,生成了对应的 AO(kun) 。如下:
这时,在执行 kun() 之后,i 的值已经是 10 了。请注意,kun 函数只执行了一次,也就意味着:
在 kun 函数的 AO(kun) 中的 i 属性是 10 。
继续分享, kun 函数的作用域链如下:
AO(kun) --> VO(G)
而且 kun 函数已经从栈顶被删除了,只留下 AO(kun) 。
注意:这里的 AO(kun) 表示一个节点。这个节点有指针和数据,其中指针指向了 VO(G) ,数据就是 kun 函数的活动对象。
那么,当一次执行 result 中的数组的时候,会发生什么现象?
注意:result 数组中的每一个函数其作用域都已经确定了,而 JS 是静态作用域语言,其在程序声明阶段,所有的作用域都将确定。
那么 ,result 数组中每一个函数其作用域链如下:
因此 result 中的每一个函数执行时,其 i 的值都是沿着这条作用域链去查找的,而且由于 kun 函数只执行了一次,导致了 i 值是最后的结果,也就是 10 。所以输出结果就是 10 个 10 。
总结一下,就是 result 数组中的 10 个函数在声明后,总共拥有了 10 个链表(作用域链),都是 AO(result[i]) --> AO(kun) --> VO(G)这种形式,但是 10 个作用域链中的 AO(kun) 都是一样的。所以导致了,输出结果是 10 个 10 。
下面我们来分析输出 0 到 9 的结果。
2.分析输出0到9
代码如下:
首先,在声明函数 kun 的时候,就已经执行了 10 次匿名函数。函数在执行时将生成执行环境,也就意味着,在 ECS 栈中,有 10 个 EC(kun) 执行环境,分别对应result 数组中的 10 个函数。
下面通过伪代码来展示
上面简单的用结构化的语言表示了 kun 函数在声明时的内部情况,需要注意一下两点:
第一点:每一个 EC(kun) 中的 AO(kun) 中的 i 属性值都是不一样的,比如通过上面结构化表示,可以看到:
result[0] 函数的父执行环境是 EC(kun) ,这个 VO(kun) 里面的 i 值 是 0 。
result[9] 函数的父执行环境是 EC(kun) ,这个 VO(kun) 里面的 i 值 是 9 。
记住 AO(kun) 是一段存储空间。
第二点:关于作用域链,也就是 scopeChain ,result 中的函数的 链表形式仍然是下面这种形式:
但不一样的是,对应节点的存储地址不一样,相当于是 10 个新的 AO(kun) 。而每一个 AO(kun) 的节点内容中的 i 值是不一样的。
所以总结就是:执行 result 数组中的 10 个函数时,运行10 个不同的链表,同时每个链表的 AO(kun) 节点是不一样的。每个 AO(kun) 节点中的 i 值也是不一样的。
所以输出的结果最后显示为 0 到 9 。
通过对底层实现原理的分析,我们可以更加深刻的去理解函数的执行机制,从而写出高质量的函数。
如何减少作用域链(链表)的查找
比如很多库,像JQ 等,都会在立即执行函数的最外面传一个 window 参数。这样做的目的是因为,window 是全局对象,通过传参,避免了查找整个作用域链,提高了函数的执行效率。
如何防止栈溢出 我们知道,每一次执行函数,都会创建函数的执行环境,也就意味着占用一些栈内存,而栈内存大小是固定的,如果写了很大的递归函数,你们就会造成栈内存溢出,引发错误。
我觉得,我们要去努力的达成这样一个成就:
做到当我在手写一个函数时,我心中非常清楚的知道我正在写的每一行代码,其在内存中是怎么表现的,或者说其在底层是如何执行的,从而达到 眼中有码,心中无码 的境界。
如果能做到这样的话,那还怕写不出高质量的函数吗?