函数是一等公民(一级对象)—— 在JavaScript中,函数与其他对象共存,并且能够像任何其他对象一样地使用。函数可以通过字面量创建,可以赋值给变量,可以作为函数参数进行传递,甚至可以作为返回值从函数中返回。
对象能做的任何一件事,函数也都能做。函数也是对象,唯一的特殊之处在于它是可调用的。
今天就来系统学一下JavaScript中跟函数相关的知识点吧!📣('ᴗ' )و
一、函数调用前
在JavaScript代码运行之前,有一步编译的过程,传统编译过程是:词法分析 --> 语法分析 --> 代码生成。
- 词法分析:把我们写的代码字符串分解成(对编程语言来说)有意义的代码块,比如:
var a = 2
// 这一条语句有四个部分:var、a、=、2
// 这里var是关键字,a是变量名,=是操作符,2是具体数值
- 语法分析:把词法单元转化成AST(抽象语法树)
- 代码生成:把AST转换成最终可以执行的代码 而JavaScript编译是在传统编译的基础之上,增加了一些额外的处理,例如,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。但是JavaScript引擎不会有很多时间用来编译,因为编译过程往往发生在代码执行之前的几微秒,在这短短的几微秒时间之内,JavaScript引擎用尽了各种办法(比如延迟编译/实施重编译)来保证性能最佳。
总而言之,任何JavaScript代码在执行前都要进行编译,用于将我们写的代码转换成最终引擎能执行的代码。(这里联想到了Vue的模板编译过程:解析模板字符串为AST语法树、优化语法树、生成可执行代码,用于把我们写的模板代码转换成js代码~总之编译是为了解析我们写的代码用于最终的执行。)
我们知道作用域是用于查找变量的一套规则,作用域的嵌套形成了作用域链。在编译阶段,作用域就被确定了,其中函数无论在哪儿被调用,也无论它如何被调用,它的作用域都只由函数被声明时所处的位置决定。JavaScript中有eval和with两种方式可以“欺骗”作用域,但是不推荐使用(它可能会使编译阶段的优化毫无意义)。JavaScript中有三种作用域:函数作用域、块作用域(ES6新增)、全局作用域。
引擎、编译器和作用域的关系是很紧密的:
| 😆 | 引擎 | 编译器 | 作用域 |
|---|---|---|---|
| JavaScript代码 | 从头到尾负责整个JavaScript程序的编译及执行过程 | 负责语法分析及代码生成等脏活累活 | 负责收集并维护由所有声明的变量组成的一系列查询,并确定当前执行的代码对这些变量的访问权限 |
| var a = 2 | 这里有两件事:1、var a 这个就让编译器处理吧!2、a = 2 等运行的时候再说 | 1、要声明一个a,先问问作用域有没有a存在吧!没有的话我再声明;2、生成引擎运行时需要的代码 | 有问题尽管问我 |
在编译阶段除了代码转换还有啥别的事情发生呢?编译阶段中的有一部分工作是找到所有的声明,并用合适的作用域将它们关联起来。因此,编译阶段包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
所以,这就导致了我们常常听到的——变量和函数(声明)提升,所谓提升,就是指变量和函数的声明从它们在代码中出现的位置被“移动”到了所在作用域的最上面。
PS:变量提升仅作用于var关键字声明的变量 & 函数先会被提升,然后再是变量。
PPS:这里的作用域指的是词法作用域,也就是我们在写代码的时候就已经确定的作用域。与之相对的一个概念是动态作用域,其实JavaScript只有词法作用域,但是this的执行机制看起来很像动态作用域。词法作用域和动态作用域的主要区别在于:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的(this也是!);词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。JavaScript中,作用域在编译阶段确定,但是作用域链是在执行上下文的创建阶段完全生成的。
二、函数调用时
函数调用时,会开始创建对应的执行上下文,所谓执行上下文,就是当前代码的执行环境/作用域,执行上下文包括了变量对象、作用域链以及 this 的指向。执行上下文和作用域链的概念蛮好理解,就不赘述了。这里主要了解一下JavaScript中独具特色的this和闭包。
1、this
1.1、WHY
为什么要用this?
function introduce(context){
console.log('大家好,我是' + context.name)
}
const Hah = {
name: 'Hah'
}
const Tom = {
name: 'Tom'
}
introduce(Hah) // 大家好,我是Hah
introduce(Tom) // 大家好,我是Tom
从上面的代码中可以看到,如果我们想在不同的上下文对象中复用某一个函数,我们必须显式地传入一个上下文对象参数,随着我们的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱。
而使用this之后:
function introduce(){
console.log('大家好,我是' + this.name)
}
const Hah = {
name: 'Hah'
}
const Tom = {
name: 'Tom'
}
introduce.call(Hah) // 大家好,我是Hah
introduce.call(Tom) // 大家好,我是Tom
this提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API设计得更加简洁并且易于复用。
1.2、WHAT
了解了为什么之后,我们需要消除一下对this的误解:
- 误解1:this指向自身🙅
- 误解2: this指向函数的作用域🙅
我们不该拘泥于“this”的字面意思从而对this产生一些误解,排除了这些错误理解之后,我们来看看this到底是一种什么样的机制。
总结来说:this是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
1.3、HOW
this的指向问题分哪些情况呢?
(1)、默认绑定——绑定至全局对象window
观察以下代码:
在代码中,foo()是直接使用不带任何修饰的函数引用进行调用的,因此其内部this为默认绑定规则,即独立函数调用时其this会指向全局对象,用之前执行上下文的角度来说,独立函数调用时它处于全局执行上下文。需要注意的是,如果使用严格模式(strict mode),不能将全局对象用于默认绑定,因此this会绑定到undefined。
(2)、隐式绑定
观察以下代码:
可以看到foo是单独声明的,之后被当作引用属性添加到了obj中。从声明方式上来看,foo函数严格来不属于obj对象,然而,obj.foo()这种调用方式会使用obj执行上下文来引用foo函数,可以说函数foo被调用时obj对象“拥有”或者“包含”它。当函数引用有执行上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。因为调用foo()时this被绑定到obj,因此this.a和obj.a是一样的。
为什么称之为隐式绑定呢?因为我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接绑定到这个对象上。
ps: 对象属性引用链中只有上一层或者说最后一层在调用位置中起作用,这条规则能使我们在嵌套调用中定位this的指向。举个例子:
(3)、隐式丢失
观察以下代码:
看起来bar是obj.foo的一个引用,但是实际上,它引用的是foo函数本身,因此此时的bar()其实是一个不带任何修饰的函数调用,符合默认绑定规则,因此应用了默认绑定。类似情况还有函数作为参数传递,也属于一种隐式赋值。
(4)、显式绑定
不同于隐式绑定,如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?此时可以使用函数的call/apply/bind方法(使用方式:f.call()/f.apply()/f.bind())。
这三个方法是如何工作的呢?它们的第一个参数是一个对象,是给this准备的,接着在调用函数时将this绑定到该对象。因为可以直接指定this的绑定对象,因此我们称之为显式绑定。总结来说,他们都是用来改变相关函数 this 指向的,call/apply 是直接进行相关函数调用;bind 不会执行相关函数,而是返回一个新的函数,这个新的函数已经自动绑定了新的 this 指向,手动调用即可。
(5)、new绑定
我们知道,使用new来调用函数时,会自动执行下面的操作: (1)创建(或者说构造)一个全新的对象; (2)这个新对象会被执行[[Prototype]]连接; (3)这个新对象会绑定到函数调用的this; (4)如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。 使用new来调用函数时,会构造一个新对象并把它绑定到函数调用中的this上。
(6)、优先级
结论:new绑定>显式绑定(call、apply、bind)>隐式绑定>默认绑定
因此可以按照下面的顺序来进行判断: (1)函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。 (2)函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。 (3)函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是那个上下文对象。 (4)如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到window。
1.4、箭头函数
之前介绍的绑定规则已经可以包含所有正常的函数,但是ES6中新增了一种特殊函数类型:箭头函数。箭头函数并不是使用function关键字定义的,而是使用操作符=>定义的。箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。
具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到什么),这其实和ES6之前代码中的self = this机制一样。
1.5、小结
this 的指向是在调用函数时根据执行上下文所动态确定的。
要判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置,可以按照下面的顺序来进行判断:
(1)函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。
(2)函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。
(3)函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是那个上下文对象。
(4)如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到window。
箭头函数是个例外,它会继承外层函数调用的this绑定(无论this绑定到什么)。
2、闭包
初学JavaScript时,我对闭包的理解就是一个函数嵌套函数,内部函数可以访问外部函数中的变量啥的,too young too simple~
《你不知道的JavaScript》中作者是这样描述闭包的:闭包是JavaScript中一个近乎神话的概念,闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境。
道格拉斯形容闭包“是编程语言史上迄今为止最重要的发现,JavaScript因它而神奇。离开闭包,JavaScript就没有了灵魂。”
原来闭包在JavaScript中的地位如此重要啊,今天就好好认识和拥抱一下神奇的闭包吧!
2.1、什么是闭包?
《你不知道的JavaScript》中,作者是这么定义的:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
还有一种比较容易理解的闭包定义:函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包。
这里很重要的一点是“内层函数在全局环境下可访问”,也就是说,如果我们只是写了一段函数嵌套函数的代码不能严格算是闭包或者说它体现不出来闭包的特点(普通的作用域链就足够解释了),只有当内部函数在它所在的作用域之外被访问时,闭包的特性才凸显出来,最常见的做法是把内部函数当作参数返回。(函数也是值,可以随意传来传去嘛~)
举个🌰:
function outer(){
var a = 2
function inner(){
console.log(a)
}
return inner // 这就产生了闭包
}
var func = outer()
func() // 2
2.2、闭包的特殊之处
从刚刚这个例子说起:
function outer(){
var a = 2
function inner(){
console.log(a)
}
return inner // 这就产生了闭包
}
var func = outer()
func() // 2
我们知道,当函数被调用之后,其整个内部作用域都会被销毁,即我们无法访问函数内部的东西了。但是,上面这段代码,我们执行了outer()之后,再执行func(),居然访问到了outer函数内部的变量a!这就是闭包的特殊之处了!闭包居然可以阻止作用域被销毁!
复习一遍:正常情况下外界是无法访问函数内部变量的,函数执行完之后,上下文即被销毁。但是在(外层)函数中,如果我们返回了另一个函数,且这个返回的函数使用了(外层)函数内的变量,外界因而便能够通过这个返回的函数获取原(外层)函数内部的变量值,这就是闭包的基本原理。
2.3、看一些闭包的例子吧
(1)、循环和闭包
这里必然要看一下这道经典的面试题:
for (var i=1; i<=5; i++) {
setTimeout(()=>{
console.log(i)
}, 1000)
}
// 会输出5个6
简简单单几行代码,涉及到了作用域、闭包、异步等等JavaScript中重要的概念。如果对这些了解不够的朋友可能会觉得代码会输出1、2、3、4、5,但结果并不是。为什么呢?
首先setTimeout是一个异步任务,它并不会立即执行;其次,这一段代码是在一个作用域内,也就是说,这里只有一个i;所以,同步任务执行完毕之后,等要到执行setTimeout中的代码逻辑的时候,i变成6了,自然会输出5个6~
如果我们就是想输出1、2、3、4、5呢?
- 改进1:IIFE(立即执行函数)
for (var i=1; i<=5; i++) {
((i)=>{
setTimeout(()=>{
console.log(i)
}, 1000)
})(i)
}
//通过IIFE形成闭包,保留对i的引用,输出12345没毛病
- 改进2:利用let
for (var i=1; i<=5; i++) {
let j = i
setTimeout(()=>{
console.log(j)
}, 1000)
}
// 这个有点厉害了哈,let劫持了块作用域!
- 更厉害的来了
for (let i=1; i<=5; i++) {
setTimeout(()=>{
console.log(i)
}, 1000)
}
// 输出12345
🐂啊!let 块作用域和闭包联手便可天下无敌!读完代码神清气爽^^
"不知道你是什么情况,反正这个功能让我成为了一名快乐的JavaScript程序员。"
(2)、模块
模块可谓是利用闭包的强大威力实现的我们使用最多的一种代码模式了。
模块模式需要具备两个必要条件:
- 必须有外部的封闭函数,该函数必须至少被调用一次;
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。(一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。)
这两个条件也就是说只有利用了闭包的特别之处,才算是实现了模块模式。在实际开发中,我们一直在使用模块模式做封装、引入其他功能,了解了闭包之后,我们会发现原来我们的代码中到处都有闭包存在啊,现在我们能够用它来做一些有用的事啦!
3、调用栈和尾递归
当一个函数被调用的时候,引擎会把函数扔进一个叫做“函数栈”的地方,但是为什么要用栈而不用其他的数据结构呢?
我们知道栈是一种先进后出的数据结构,所以使用栈的意义其实非常简单——保持入口环境。结合一段简单代码来展示一下:
function main() {
//...
foo1()
//...
foo2()
//...
return
}
main()
上面是一段简单的示例代码,我们现在简单在大脑里面模拟一下这个main函数调用的整个过程:
- 首先建立一个函数栈
- main函数调用,将main函数压进函数栈里面
- 做完了一些操作以后,调用foo1函数,foo1函数入栈
- foo1函数返回并出栈
- 做完一些操作以后,调用foo2 函数,foo2函数入栈
- foo2函数返回并出栈
- 做完余下的操作以后,main函数返回并出栈
上面这个过程说明了函数栈的作用——第4和第6步,让foo1和foo2函数执行完了以后能够在回到main函数调用foo1和foo2原来的地方。这就是栈这种“后入先出”的数据结构的意义所在。
我们来看一个采用递归方式对fibonacci数列求和的例子:
const fibonacci = n => {
if (n === 0) return 0
if (n === 1) return 1
return fibonacci(n - 1) + fibonacci(n - 2)
}
递归非常耗费内存,也很容易发生“栈溢出”错误,一种常用的优化方式是:
const fibonacciTail = (n, a = 0, b = 1) => {
if (n === 0) return a
return fibonacciTail(n - 1, b, a + b)
}
这种递归方式称为尾递归——递归函数的最后一步是调用它自身,尾递归之所以能形成优化,是因为全部执行过程中不会在调用栈上增加新的堆栈帧,而是直接更新调用栈,进而永远不会发生“栈溢出”错误。前面我们说了函数调用栈会记录入口环境,尾递归的情况下,保持这个函数的入口环境没意义,所以可以把这个函数的调用栈给优化掉。
PS:从ES6开始,所有ES的实现,必须部署“尾调用优化”,所以这里简单了解一下。
三、函数调用后
看一下JavaScript的垃圾回收机制🗑
1、内存管理
内存管理就是在需要的时候为系统动态地分配内存,然后释放那些不再使用的对象的内存。
我们知道内存空间可以分为栈空间和堆空间,其中
- 栈空间:由操作系统自动分配释放,存放函数的参数值,局部变量的值等,其操作方式类似于数据结构中的栈。
- 堆空间:一般由开发者分配释放,这部分空间就要考虑垃圾回收的问题。
在JavaScript中,一般来讲,基本数据类型保存在栈内存当中,引用类型保存在堆内存当中。
2、浏览器的垃圾回收机制
对于分配内存和读写内存的行为,所有语言都较为一致,但释放内存空间在不同语言之间有差异。JavaScript依赖宿主浏览器的垃圾回收机制释放内存。虽然大多说情况下我们不用操心释放内存的事,但也应该对于内存管理和垃圾回收机制有一定的理解。我们必须清楚WHAT、WHY、HOW这些问题,才能在遇到问题时找一个合适的解决方法。
浏览器的垃圾回收机制主要有两种:
-
标记清除 (从12年起,所有的现代浏览器都是基于“标记清除”的回收算法):当一个对象不能再被访问到时,对该对象进行标记,等下一轮垃圾回收事件发生时,这些对象就会被清除。 这里的“不能再被访问”怎么判断呢?从根元素(window对象)开始,递归判断所有子变量,所有不能从根元素到达的都被认为是垃圾。eg: 一般情况下(没有闭包),当函数执行完时,内部的变量都是无法被其他代码访问的,所以它就被标记为“无法被访问”。所以标记清除的难点在于如何判断一个对象不能再被访问,像setInterval、DOM事件、闭包都无法被标记为无法被访问,所以不能被回收,从而导致内存泄露。
-
引用计数:一个对象只要显式或隐式(比如Prototype)访问另一个对象,就可以说它引用了另一个对象。当一个对象被引用的次数为 0 时,该对象就可以被回收。这里的问题在于“循环引用”,如果对象 a 的属性引用了 b,而 b 的属性引用了 a,由于引擎只有在变量的引用次数为 0 的情况下才会回收,这里 a 和 b 的引用次数至少有 1,所以就算它们所在的函数执行完了,这两个变量还是无法被回收掉。
3、内存泄漏
内存泄漏指的是不再被需要的内存,由于某种原因,没有被归还给操作系统或者进入可用内存池。
前面提到闭包,借助闭包可以保护某些数据变量的内存块在闭包存活,始终不被垃圾回收机制回收。因此,闭包使用不当,极可能引发内存泄漏,需要格外注意。
举个🌰:
function foo(){
let value = 123
function bar() { console.log(value) }
return bar
}
let bar = foo()
这种情况下,变量value将会一直保存在内存中,如果加上:
bar = null
随着 bar 不再被引用,value 也会被清除。
还有一些可能会导致内存泄漏的情况:
- 全局变量
- 一些被遗忘的定时器或者回调
- DOM的引用
- console输出带有引用的对象
4、我们能做些什么
尽管浏览器的垃圾回收机制很方便,但是他们有一套自己的方案,其中之一就是不确定性。换句话说,垃圾回收是不可预测的,我们不可能知道一个回收器什么时候会被执行。这意味着程序在某些情况下会使用比实际需求还要多的内存。
只有开发者才能弄清楚,是否一段内存可以被回收。这就需要我们了解内存管理和垃圾回收机制,在实际开发中才知道尽量避免这些会导致内存泄漏的情况或者手动去释放内存。