函数是ECMAScript中最有意思的部分之一,在平时的代码中可以说函数是无处不在的。这篇文章会从JavaScript和ES6的角度去讲解函数的前世今生,希望能够将这个前端最重要的知识点之一讲清楚。
函数
在JavaScript中函数实际上也是对象,每一个函数都是function类型的实例,而function和其他引用类型一样,也有属性和方法。函数名就是指向函数对象的指针,而且不一定与函数对象紧密绑定。
函数的定义
在JavaScript中,几乎每一种类型都有两种定义方式:声明式和表达式。函数也不例外,举两个简单的例子感受一下它们的区别吧!
//声明式(最常用)
function a (sum1,sum2){
...
}
//表达式
let a=function(sum1,sum2){
...
};
那么它们的区别在于哪里呢?
声明式
JavaScript引擎在执行任何代码前会先读取函数声明,并在执行上下文中生成函数定义,这个过程也叫函数声明提升,在执行代码的时候JavaScript引擎会先将代码扫描一遍,然后将所有的函数声明提升到源代码树顶部,因此即使函数声明出现在调用之后也不会报错。
表达式
函数的表达式是不会出现“提升”的,必须是等到代码执行到那一行,才会在执行上下文中生成函数定义,且不可以在函数定义前调用它。
当然函数的定义并不只是这两种,还有ES6的箭头函数和Function的new构造函数。
//ES6箭头函数
let a=(sum1,sum2)=>{...};
//Function构造函数
let a =new Function("sum1","sum2","return ...");
相较于其他三种定义方式,new构造函数的写法是较为少见也不推荐的,因为对于上面的这个例子来说,这段代码会被执行两次,一次是将它视为常规的JavaScript代码,一次是解析传递的参数,这样无疑是会影响性能的。
函数名
前面说过因为函数是一个对象,函数名就是指向该函数对象的指针,所以它们和其他包含对象指针的变量具有相同的行为。也就是说一个函数可以有多个函数名!来看一个例子。
function a (s1,s2){
return s1+s2;
}
let b=a;//此处,我认为是一个深拷贝的过程。
console.log(a(1,1));//2
console.log(b(1,1));//2
//切断它们的联系
a=null;
conaole.log(a(1,1));//Uncaught TypeError: a is not a function
conaole.log(b(1,1));//2
可以看到上面的例子中,先设置一个函数名为a的函数,并将其值等于一个变量b(注意,使用不带括号的函数名只会访问函数指针而不会调用它),当a设置为null时,就切断了与原函数的联系,就会报错了。
函数的参数
JavaScript函数的参数与其他的编程语言有所不同的是,它不关注传入函数参数的个数与参数类型!
因为JavaScript函数的参数在JavaScript引擎内部表现为一个数组,函数被调用的时候总会接收一个数组,至于这个数组里面的值是什么类型亦或者传入的参数个数是否超过数组的长度,甚至就是一个空数组!都没关系,我们知道JavaScript的数组就是一个不关注数值类型,且可以不自动增加的东西。 这也是为什么会有不定参数的概念。
不定参数:是ES6新增的扩展操作符,就是在函数命名参数前加三个点(...)表示这是一个不定参数,其包含自命名参数之后传入的所有参数。如(伪代码):function a(sum,...keys){};a(sum,1,2,3,4);在这里不定参数keys就是sum之后传入的所有参数,也就是1,2,3,4。值的注意的是,因为不定参数结果是可变的,所以只能把它作为最后一个参数,如果前面没有命名参数,就会返回一个空数组。如function bb (...keys,name),不定参数作为第一个参数,只会返回一个空数组。
事实上,在使用非箭头函数定义的函数时,都可以在函数内部访问arguments对象,从中取得每一个参数值。如:
function a (s1,s2){
console.log(s1===arguments[0]);
conaole.log(s2===arguments[1]);
}
a("a","b");
//输出结果为:
true
true
arguments是一个类数组对象(但不是Array的实例),可以通过访问数组对象的方式访问其某一个元素,或者获取其对象长度。在非严格模式下,可以arguments对象的值可以随着参数值变化而变化,而在严格模式下就不可以。例如:
function a (s1,s2){
"use strict"
console.log(s1===arguments[0]);
conaole.log(s2===arguments[1]);
s1="c";
s2="v";
console.log(s1===arguments[0]);
conaole.log(s2===arguments[1]);
}
a("a","b")
//输出结果为:
true
true
false//非严格模式下是true
false//非严格模式下是true
而ES6中默认参数的使用就很好的避免了这种奇怪的行为。在ES6中如果一个函数使用了默认参数,那么无论是否显示定义了严格模式,arguments对象的行为都会是按照严格模式下执行。如:
function a (s1,s2="b"){
console.log(s1===arguments[0]);
console.log(s1===arguments[1]);
s1="c";
s2="v";
console.log(s1===arguments[0]);
conaole.log(s2===arguments[1]);
};
a("a");//s2使用了默认参数"b"
//输出结果
true
true
false
false
说到默认参数,在ES5以及之前,实现默认参数的一种常用方式就是检测某个参数是否等于undefined,如果是就意味着没有传入这个参数。
function a (name){
name=(typeof name !== 'undefined') ? name : 'Joan';
return `Queen $(name) `;
}
console.log(a());//'Queen Joan'
在使用默认参数值的时候,只有当函数被调用的时候才会对默认参数求值,且只有在没有传入相应参数的时候才会使用默认参数的值,arguments不反映默认值,只反映传入的参数值。默认参数并不限于原始值与对象类型,也可以使用调用函数返回的值。例如
let getNmuber=['I','II','III','IV'];
let index=0;
function aa(){
//每次调用的时候都递增一次
return getNumber[index++];
}
//传入函数调用之后的结果作为默认参数
function bb (name='Joan',number=getNmuber()){
return ` $(name) Queen $(number)`;
}
console.log(bb());//'Joan Queen I'
console.log(bb());//'Joan Queen II'
console.log(bb());//'Joan Queen III'
在使用默认参数的时候要注意“临时死区” 的问题,临时死区也叫暂时性死区,是指被初始化前的状态。既然默认参数可以是对象,也可以是调用函数,那么JavaScript引擎在计算默认参数的时候,前面定义的参数就会先被初始化,后面定义的参数就可以引用前面定义的参数,但是前面的揪不可以引用后面的,否则就会报错。如
function aa (name='Joan', nikname=name){...}//这样是没问题的
function bb (name=nikname , nikname='Joan'){...}//这样是不行的
函数重载
JavaScript是不支持函数重载的,不像在Java中,一个函数可以被定义两次,只要传入的参数类型或者数量不同就可以了,但是在JavaScript中是不可以这样做的。因为JavaScript没有函数签名,参数是以数组的形式存在,所以,第二个同名函数会覆盖第一个函数。
函数属性与方法
属性
我们前面说过函数是对象,那么对象就会有属性与方法,事实上每一个函数都有两个属性:length和prototype。
length属性保存函数定义的命名参数个数,prototype保存引用类型所有实例方法。
length属性自不必多说,所以我们主要来探讨一下prototype属性。因为prototype是保存引用类型所有实例方法的地方,这就意味着函数中的toString()、valueOf()等方法都是在prototype上,进而由所有实例共享,(联系一下我们在封装一个方法时有时候会将其挂在原型上)
方法
函数的内部还有两个方法:call()和apply()。这两个方法都可以用来设定调用函数时函数内部的this值。
apply()
apply方法接收两个参数:函数内this值和一个参数数组。参数数组可以是一个Array实例也可以是一个arguments对象。
function sum(s1,s2){
return s1+s2;
}
function ss(u1,u2){
return sum.apply(this,arguments);//传入arguments对象
//sum.apply(this,[u1,u2]);//传入数组
}
console.log(ss(10,20));//30
call()
call()方法与apply()方法类似,第一个参数也是this对象,只不过第二个参数必须是逐个传入。
function sum(s1,s2){
return s1+s2;
}
function ss(u1,u2){
return sum.call(this,u1,u2);
}
实际上call/apply方法真正强大的地方不是传参,而是控制函数调用上下文即函数内this值的能力。
window.color='red';
let o={
color:'green'
};
function bb(){
console.log(this.color);
}
bb();//red
bb.call(this);//red
bb.call(window);//red
bb.call(o);//green
上面的例子中可以看到一个全局函数bb(),如果是在全局作用域中调用它,无论在后面是call(window)还是call(this)都是返回全局变量'red',说明这个时候,函数bb中this的值是指向window的,但是如果我们将其切换对象到对象o上,this的值就会发生改变。而这就体现了call/apply方法的好处,你可以任意将对象设置为任意函数的作用域,这样对象可以不必关心方法。
ES5中出于同样的目的定义了另一个新方法bind()。bind方法会创建一个新的函数实例,其this就会被绑定在传给bind()的对象。
window.color='red';
let o={
color:'green'
};
function bb(){
console.log(this.color);
}
let kk=bb.bind(o);
kk();//green
函数的内部
函数的内部都有什么呢?当然是{...}括起来的东西啦!哈哈哈,当然不是啦~
事实上在ES5中函数的内部存在两个特殊对象:arguments和this,然后在ES6中新成员new.target加入。
虽然在ES6中的箭头函数是没有arguments和this的,但是不意味着ES6就讲它们抛弃了。
arguments
arguments对象前面已经有讨论过,它实际上是一个类数组对象,(注意:是“类”数组,说明它不是一个Array)。它包含调用函数时传入的所有参数 (注意:是所有,说明它有别于前面的不定参数!arguments.length>keys.length,因为后者不包含前面的命名参数!)。只有使用function关键字定义函数时才会存在arguments对象,虽然主要用于包含参数,但arguments对象还有一个callee属性,是一个指向arguments对象所在的指针。
来看个例子:
function aa(num){
if(num<=1){
return 1;
}else{
return num*aa(num-1);
}
}
这是一个普通的阶层函数,递归调用求值。但是这个函数逻辑能够正确执行的前提是函数的名称不会改变,逻辑与名称之间紧密耦合。我们知道在前端项目中一般如果要修改或删除某个功能的时候,是推荐代码 “只增不减” 的,就是你要添加新代码去删除或者修改旧代码,而不应该直接在项目中删除源代码。所以我们应该尽可能将逻辑与名称解耦。
//解耦之后的代码
function aa(num){
if(num<=1){
return 1;
}else{
return num*arguments.callee(num-1);
}
}
此时,函数逻辑与名称已经解耦,无论函数叫什么,都可以正确引用这个函数逻辑。
let bb=aa;
aa=function(){
return 0;
};
console.log(bb(5));//120
console.log(aa(5));//0
可以看到,当我将aa重写之后,原函数名aa就不能再使用原来的函数逻辑了。
this
相信每一个学习前端的人对this都不会陌生,这个说简单又不简单,说复杂又不复杂,面试还老爱问的东西。this对象在标准函数与箭头函数(箭头函数没有this绑定,而不是不能使用this)中有不同的行为。
在标准函数中this引用的是把函数当作方法调用的上下文对象,这和作用域以及闭包紧密相关。举个例子
window.color='red';
let aa={
color:'green';
};
function bb(){
console.log(this.color);
}
//全局上下文中调用,this就指向window中的color
bb();//'red'
//将bb赋值给aa之后再调用bb,this就指向aa中的color
aa.bb=bb;
aa.bb();//'green'
所以,在标准函数中this的值会根据调用函数的位置发生改变。但是在闭包中使用this就会让代码变得较为复杂。例如:
window.bbb='Good Gril';
let object={
bbb='Good Boy';
ccc(){
return function(){
return this.bbb;
}
}
};
console.log(object.ccc()());//'Good Gril'
我们看到,在上面的代码中,ccc()方法里面创建了一个匿名函数,并在这个匿名函数里面返回一个字符串,但是返回的这个字符串确是全局变量的字符串,为什么不是匿名函数上层函数ccc的执行上下文里面的'Good Boy'呢?
其实还是要跟函数的作用域链相关,我们知道每一个函数被创建的时候都会有两个内部对象:arguments和this。(箭头函数除外)而内部函数是永远都无法访问外部函数的这两个内部对象的! 但是,如果我们将它们保存到闭包可以访问到的另一个变量中,就可以实现了。例如:
window.bbb='Good Gril';
let object={
bbb='Good Boy';
ccc(){
let that=this;
return function(){
return that.bbb;
}
}
};
console.log(object.ccc()());//'Good Boy'
而在箭头函数中this是通过其作用域链来确定它的值的,如果箭头函数被非箭头函数包含,则this绑定的是最近一层非箭头函数的this,否则就会定位到全局对象window中。
new.target
JavaScript函数有两个不同的内部方法:[[Call]]和[[Construct]]。当函数通过new调用的时候执行的是[[Construct]]函数,会创建一个被称为实例对象的新对象,然后再执行函数体,将this绑定到实例上,这种具有[[Construct]]方法的函数也称之为 构造函数。(当然,不是所有函数都会有[[Construct]]方法的,因此也不是所有函数都可以通过new调用,比如:箭头函数。)如果不是new 调用,就会执行[[Call]]函数,从而直接执行代码中的函数体部分。
在ES5中如果想确定一个函数是否通过new调用,最常用的方法就是instanceof。举个例子:
function aa (name){
if(this instanceof aa){
this.name=name;//如果通过new调用
}else{
console.log('不使用new调用该函数')
}
}
var pp=new aa('Joan');
var oo=aa('Joan');
例子中首先会检查this的值,看它是否为构造函数的实例,如果是会正常执行,不是就打印语句,一般来说这样写是没问题的,但是这个方法并不是最安全可靠的。比如说:
function aa (name){
if(this instanceof aa){
this.name=name;//如果通过new调用
}else{
console.log('不使用new调用该函数')
}
}
var pp=new aa('Joan');
var nn=aa.call (pp,'Joan');
我们会惊讶的发现,第二种写法也是可以正常执行的!这是因为在调用aa.call()时将pp作为传入的第一个参数,相当于在aa函数里将this设为pp实例。对于函数本身,是无法区分是通过call/apply()还是new关键字调用的而获得的aa的实例。
所以ES6为了规避这个潜在的风险,引入了new.target这个元属性。
元属性:是指非对象的属性,其可以提供非对象目标的补充信息(如new)。
当函数的[[Construct]]被调用时,new.target被赋值为new操作符的目标,通常是新创建对象实例,也就是函数体内this的构造函数;而如果是调用[[Call]]方法,new.target的值为undefined.
new.target可以很好的规避前面的call/apply()带来的影响。
function aa (name){
if ( typeof new.target !== 'undefined' ) {
this.name=name;//如果通过new调用
}else{
console.log('不使用new调用该函数')
}
}
var pp=new aa('Joan');
var nn=aa.call (pp,'Joan');//'不使用new调用该函数'
看到这里估计大家也累了吧,歇会吧。剩下的内容我会写在(下)篇,JavaScript中函数的前世今生(下) - 掘金 (juejin.cn) 主要是讨论一下函数的内部属性与方法,以及函数的尾调用和ES6箭头函数。感谢大家支持!同时也欢迎大家批评指正!