函数
1、声明函数
在使用函数前需要先进行声明,代码如下:
js
function sayHello(){
console.log("hello");
}
在上边的例子中,函数在执行完console.log之后就结束了,不过函数还可以返回一些值,作为执行结果供其他代码使用。例如下方代码展示了一个加法函数,用于计算两个数的和,并把和返回,代码如下:
js
function sum(a,b){
return a+b;
}
如果函数没有返回语句,程序也会自动在代码的最后写上return;语句,这样return的后边没有值,而是直接使用了分号,它最后返回的结果就是undefined。
如果要打印一下函数名,例如console.log(sum),则会直接返回函数代码本身,这是因为函数的定义也是表达式,表达式的值就是函数本身。同时函数也有name和length属性,使用sum.name和sum.length分别会返回函数的名字和函数参数的数量,代码如下:
js
sum.name; //"sum"
sum.length;//2
5.2、调用函数
调用一个函数需要使用函数名加上小括号,如果小括号里没有参数,则把小括号留空(不能省略);如果有参数,则可以给参数按顺序赋上实际的值。例如5.1节的示例sayHello(name)函数可以使用下方的代码进行调用,代码如下:
js
sayHello("John");//"hello John"
如果函数有返回值,则可以直接打印出调用结果,或者把结果保存到变量中,例如sum(a,b)函数有返回值,使用返回值的代码如下:
js
console.log(sum(1,2)); //3;
let result= sum(3,4);
console.log(result+10);//17;
3、函数表达式
在JavaScript中,函数是"头等公民"(First-Class Citizens),即函数可以作为表达式赋值给变量,或可以作为参数传递给其他函数,另外函数本身也是对象,它几乎可以用于各种语法结构中,所以使JavaScript的语法变得十分灵活。
函数作为表达式,它返回的值是函数代码本身,代码如下:
js
function square(x){
return x*x;
}
console.log(square);
输出的结果就是声明函数时的代码。这样就可以把函数赋值给一个变量或常量,代码如下:
js
const square=function square(x){return x*x}
在把函数保存到变量中时,推荐使用const将此值定义为常量,防止后面变量被其他值覆盖,在后边小节中提到变量时,若无特殊说明,均代表变量或常量。在上方代码中还可以注意到const定义的变量名与函数名是一样的,为了使代码更简洁,可以省略函数的名字,代码如下:
js
const square=function(x){return x*x}
这种使用变量的形式定义的函数,它的调用方法跟函数一样,只需使用变量名,例如square(5)。省略了名字的函数又叫作匿名函数(Anonymous Function)。单纯的匿名函数在出错的时候,会难以发觉错误究竟出在哪个函数中,所以不推荐使用,而像上述那样使用变量保存函数表达式之后,主流浏览器与Node环境会自动推断出函数的名字,所以不会有此问题。
4、箭头函数
在ES6出现以后,函数的定义又有了新的形式:箭头函数(Arrow Function),它是普通函数的简化形式,由参数列表、=>符号和{}包裹的函数体构成,可以参考下方语法示例:
js
(参数列表)=>{
语句块;
}
来看一个具体的例子,例如定义一个箭头函数并返回两个参数的和,代码如下:
js
const sum=(x,y)=>{return x+y;}
对于只有一个参数的函数,参数的小括号可以省略,代码如下:
js
const increment=a=>{return a+1;}
上方箭头函数中,因为函数体中也只有一条语句,这时可以把大括号和return同时省略,代码如下:
js
const increment=a=>a+1;
这样的形式看起来就简洁多了。需要注意的是,如果箭头函数没有参数,则必须保留小括号,代码如下:
js
const getDefaultSize=()=>10;
另外,如果箭头函数返回的是对象,则代表对象的大括号会与函数体的大括号冲突,这种情况下可以使用小括号包裹对象,来作为返回值,或者使用return语句,代码如下:
js
const createPerson=()=>({name:"W ang"});
const createPerson=()=>{return{name:"W ang"}}
5、可选与默认参数
在JavaScript中,函数的参数都是可选的,可以不传或者只传一部分。这样如果函数不要求传递全部参数,则需要在函数体中对参数进行判断;如果传递了某些参数则进行一些操作;如果传递了其他参数或没有传递参数则进行另一些操作。由于函数的参数是从左向右传递的,右边没传递的参数就会自动成为可选参数。
常见的场景是,一些JavaScript库会把配置项作为最后一个参数,这样在调用的时候,如果需要自定义配置则传递配置项参数,如果不需要就不传递,代码如下:
js
//options为可选参数
function init(arg1,arg2,options){
//初始化操作
if(options){
//使用自定义配置
}
}
init("value1","value2");
init("value1","value2",{prop:"value"});
如果想让可选参数在没有传值的时候使用默认值,则可以在参数列表中直接给它赋值,例如,假设有一个绘制矩形图案的函数,将默认宽度设置为10,将高度设置为5,这样在调用函数的时候,如果没有传递参数,就会使用默认的宽和高进行绘制,代码如下:
js
function drawRect(width= 10,height=5){...}
drawRect(); //全部使用默认值
drawRect(20); //高度使用默认值
drawRect(undefined,15); //宽度使用默认值
这里需要注意的是,JavaScript是根据参数值是否为undefined来判断默认参数是否传递了值的。
6、可变长参数
可变长度参数与可选参数的操作正好相反,给函数传递的参数数量可以多于参数列表中所规定的。常见的console.log()就是接收可变长度参数的例子,它接收多个以逗号分隔的参数,然后在命令行中打印出它们的值,并以空格分开。
要访问传递给参数列表以外的参数值,有两种方式,一种是使用arguments,另一种是使用rest运算符,把可变长度参数放到参数列表的最后,把多余的参数收集起来。
6.1、arguments
在除了箭头函数以外的普通函数中,都会有一个隐式的arguments变量,它是一个类似于数组的数据结构,说它类似,是因为它与数组的结构类似,有length长度属性,并且使用下标访问元素,但是并不具有数组内置的方法,例如map()、push()等。
假设一个函数可接收两个参数:function func(a,b){},如果在调用的时候给它传递了3个参数:func(1,2,3),则arguments保存的值就相当于是[1,2,3],要访问第3个元素,可以直接使用arguments[2]。
定义一个函数,该函数可以根据指定的分隔符把所传递的字符串连接起来,代码如下:
js
function joinStrings(seperator){
let result="";
for(let i=1;i<arguments.length;i++){
if(i>1){
result+=seperator;
}
result+=arguments[i];
}
return result;
}
console.log(joinStrings(",","react","node"));
这里joinStrings()只显式地接收了一个参数:分隔符,但是它仍然需要在分隔符后边接收多个字符串参数,这些参数会保存到arguments中。接着在函数体里循环每个参数,这里把下标为0的排除,因为它是seperator参数的值,然后把字符串拼接成按seperator指定的值分隔的一串字符并返回。
6.2、rest运算符
rest运算符使用...表示,后面加上标识符,用于引用它的值。使用rest运算符定义的参数是一个真正的数组,可以调用数组中的方法。
js
function joinStrings(seperator,...strs){
return strs.join(seperator);
}
console.log(joinStrings(",","react","node"));
strs是一个数组,保存了除seperator以外所有参数的值,因为数组里有join方法用于连接字符串,这里只需把seperator传递给它,这样strs中的所有字符串就会拼接成一个长字符串。
7、回调函数
回调函数是把函数作为另一个函数的参数的形式,这样可以提前在回调函数中写好要执行的代码,并传递给需要回调函数的其他函数,其他函数会在适当的时机调用回调函数,并传递相应的参数。至于回调函数接收什么样的参数,完全依靠接收回调函数的其他函数,所以一般在程序的文档中会写明该函数会给回调函数传递什么参数。例如有一个将用户保存到数据库的函数,代码如下:
js
function addUser(user,callback){
console.log(`保存${user.username}成功!`);
callback(true);
}
addUser({username:"user"},function(success){
if(success){
//成功后的操作
console.log(`添加成功!`);
}
});
addUser()函数的第1个参数是要保存的用户数据,第2个参数是回调函数,函数体里简化了与保存有关的业务代码,只关注回调函数。
在成功地保存了用户数据之后,就调用了callback参数所代表的函数,并给它的参数传递了true表示成功。后面在调用addUser()函数时,第1个参数传递了示例的user对象数据,第2个参数则直接传递了一个匿名函数,函数体中是保存用户成功之后要做的操作,也可以使用箭头函数的形式,代码如下:
js
addUser({username:"user"},(success)=>{
//省略函数体
});
这样把函数传递给addUser()函数之后,里边的callback就相当于letcallback=function(success){},可以直接使用callback变量调用回调函数。代码的输出结果如下:
js
保存user成功!
添加成功!
8、作用域
8.1、全局作用域
在全局作用域中定义的变量、函数等可以在任何地方访问。在JS源代码最外层定义的变量、函数等都是在全局作用域中的,例如下方代码中的变量a和func()函数都定义在全局作用域中,并且在函数中可以访问全局作用域中的a,代码如下:
js
let a= 10;
function func(){console.log(a)}
a; //10
func(); //10
之前提到过最好不要使用var关键字定义变量,这是因为在浏览器环境中,使用var定义的全局变量,同时也会注册到全局对象window中(Node环境下不会),并且在浏览器开发环境中有经常需要使用到第三方库的情况,稍有不慎就会有同名的变量同时被注册到全局变量中,导致互相覆盖而引发问题,代码如下:
js
var x= 10;
globalThis.x; //10;
var x="Hello";
globalThis.x; //"Hello"
上方代码使用var定义了一个全局作用域的变量,并且使用globalThis访问全局变量(globalThis是ES2020中的新特性,用于统一访问全局对象,即在浏览器中是window,而在node中则是global),后面又使用同名变量覆盖了它的值,当再次访问时会发现变量值改变了,后续如果想再做与数字相关的操作,就会有问题。
8.2、局部作用域
在函数中定义变量时,会创建一个局部作用域,在函数外边无法访问函数内部的变量,无论是使用var、let还是const定义的,代码如下:
js
function func(){var x=5};
x; //引用错误:x未定义
局部作用域可以访问全局作用域中的变量和函数,也可以访问父级及以上作用域中的变量和函数,如果有同名的变量或函数,则子作用域会覆盖父作用域中的变量或函数,代码如下:
js
let x=5;
function outerFunc(){
let x=4;
function innerFunc(){
let x=7;
console.log(x);
}
console.log(x);
return innerFunc;
}
let innerFunc=outerFunc();
innerFunc();
console.log(x);
js
4
7
5
首先,代码一开始定义了全局作用域的x,其值为5,而在outerFunc()函数中,定义了同名变量x,它的值为4,这时x的值在outerFunc()中是4,覆盖了全局中的5。后面又在outerFunc()中定义了innerFunc()函数,并且在里边再次覆盖了x的值,变成了7,而7这个值只会在innerFunc()中有效,在innerFunc()大括号结束的时候就会失效,因此在innerFunc()定义的下方打印x的值仍然是4。当outerFunc()结束时,它里边的x也失效了,所以最外边使用console.log(x)时打印出的是全局作用域中的x,其值为5。
在局部作用域中,还有一个块级作用域(Block Scope)的概念。像{}语句块、if语句、循环语句等会形成块级作用域,使用let或const定义的变量具有块级作用域,它们只在定义的大括号语句块中生效,离开大括号之后就不能访问了,代码如下:
js
{
let i=10;
}
console.log(i); //引用错误,i未定义
for(let j=0;j<10;j++){}
console.log(j); //引用错误,j未定义
8.3、提升机制
在JavaScript中,函数和使用var声明的变量有提升(Hoisting)机制,可以先使用后声明。JavaScript编译器会提前检查代码中的函数及var变量,把它们提升到当前作用域的顶部,这样就能保证代码的正常运行了。例如,测试使用var声明的变量的提升机制,代码如下:
js
x=5;
console.log(x);//5
var x;
上边代码中的var x声明被提升到了x=5的上方,作为第一行代码,然后才给x赋值为5,这样打印出来的值就是5。需要注意的是,变量在提升的时候,因为只有声明部分被提升,所以如果在声明变量的同时进行了定义,再在上方访问该变量就会返回undefined,代码如下:
js
console.log(x);//undefined
var x=5;
console.log(x);//5
它相当于如下代码:
js
var x;
console.log(x);//undefined
x=5;
console.log(x);//5
代码中的var x被提升到最顶部,剩下的赋值语句则保持在原位。而如果使用let或者const关键字定义变量,则不能提前使用它们定义的变量,而是会直接抛出异常,代码如下:
js
a=5;
console.log(a);//引用错误,不能在初始化之前访问a
let a;
对于函数,使用function关键字定义的普通函数全部都会被提升到作用域的顶部。例如下方代码中,函数的定义会移动到printValue()上方,代码如下:
js
printValue(); //10
function printValue(){console.log(10)}
但是,对于保存在变量中的函数表达式则不会有提升机制,因为只有声明部分被提升了,而使用函数表达式进行赋值的部分并未被提升,代码如下:
js
printValue();
var printValue=function(){console.log(10)} //类型错误:printValue不是函数
利用函数的提升,可以把函数定义的细节放到代码后边,把函数的调用放到前边,以便关注代码所执行的操作,屏蔽具体的实现细节,这样可以增强代码的可读性。对于变量的提升机制,并不推荐使用,因为这样很难看出来变量是在哪定义的,从而容易引发问题,尤其是当有同名变量和函数名覆盖的时候,最难理解,代码如下:
js
function func(){
return x;
x=5;
function x(){}
var x;
}
console.log(func());
js
function x(){}
可以看到func()函数最后返回的x值为函数x(),而不是5。这是因为function x(){}的定义首先被提升到了func()函数的第1行,var x则按顺序提升到了第2行,由于声明变量x的时候并没有赋值,它不会覆盖掉函数x()的定义,之后就直接运行到return x语句了,返回了函数x(),而x=5并没有机会被执行,代码如下:
js
function func(){
function x(){}
var x;
return x;
x=5;
}
8.4、临时隔离区
使用let关键字定义的变量,不能在初始化之前访问的原因是,它的声明被放到了临时隔离区(Temporal Dead Zone,TDZ)。临时隔离区会在执行块级作用域的第1行代码前生效,在变量初始化完成之后才会把变量从隔离区里释放出来。来看一个例子,代码如下:
js
let a=5;
function test(){
console.log(a);//引用错误,不能在初始化之前访问 'a'
let a=6;
}
test();
之所以称它为临时隔离区,是因为它只短暂地存在于变量初始化的过程中,而不是按代码的位置来判断是否放入隔离区,例如下方示例是可以正常执行的,代码如下:
js
let a=5;
function test(){
const inner=()=>console.log(a);
let a=6;
inner();
}
test(); //6
这是因为在inner()函数调用前,临时隔离区在let a=6这行代码之后就已经结束了,a在test()函数这个作用域中已经成功被初始化为6,再在inner()中就可以访问它的值了。
9、闭包
闭包(Closure)指的是一种语法形式和变量查找机制,在一系列嵌套的函数中,所有内部的函数都可以访问外部函数及全局作用域中定义的变量、对象和函数(以下简称内容)等。按这样的说法,JavaScript中的函数全部都是闭包。因为在全局作用域中定义的函数,可以访问全局作用域的内容,在函数中定义的子函数则可以访问外层函数直到全局作用域中的所有内容。
例如定义一个sayHello()函数,可接收一个人名name作为参数,打印出"你好!",并带上人名,但是打印的代码放到sayHello()的子函数message()中,在sayHello()内部调用message(),代码如下:
js
function sayHello(name){
function message(){
console.log("你好!"+name);
}
message();
}
sayHello("李明"); //你好!李明
上方示例会输出:"你好!李明"。从输出结果看,message()函数成功地访问了sayHello()函数中的name参数的值,这样的结构就形成了一个闭包。
在闭包中,内部的函数可以捕获(Capture)外部函数作用域中的内容,如变量、其他函数等,这样即便把内部函数作为返回值从外部函数中返回再进行调用,它还是可以继续使用外部函数作用域中的变量和函数。通过捕获机制可以避免在多次调用函数时,需要重复向函数传递参数的问题。
假设有一个需求,可以对一个初始数值进行自定义步长的自增操作,如果使用普通函数定义,则需要多次传递初始值,代码如下:
js
function increment(initialValue,step){
return initialValue+step;
}
let result= increment(10,1); //11
result= increment(result,1); //12
result= increment(result,2); //14
示例中对10进行一次步长为1的自增,然后把结果11保存到result变量中,接着又对result进行步长为1的自增操作,此时仍然需要传递一次自增参数,得到结果12后,又把它保存到变量result中,再进行一次步长为2的自增,这一次仍然需要把result作为参数传递给increment()函数,这些调用反复使用result参数和步长值,有很多重复代码,但是如果把代码改成使用闭包的形式,则可以避免这种情况,例如把increment()函数的定义改成闭包的形式,代码如下:
js
function increment(initialValue){
let result= initialValue;
return function by(step){
result+= step;
return result;
};
}
这里的increment()函数接收一个initialValue参数,用于指定初始值,之后对它进行自增操作,然后在increment()函数内部定义一个result变量用于保存自增结果,并返回一个子函数by()。by()函数接收一个step参数,用于指定自增步长,它会把外部函数中result的值加上step的值之后返回。这时调用increment()函数并返回by()函数后,by()函数会捕获result变量的值,使每次调用都能够记住result而不用再次传递了,所以只需传递步长参数,代码如下:
js
const incFiveBy= increment(5);
console.log(incFiveBy(2)); //7
console.log(incFiveBy(4)); //11
从结果可以看到,incFiveBy()中的result值是共享的,可以把它称为状态(State),每次调用incFiveBy()的时候都会修改状态,这个是闭包的用途之一,在多次函数调用之间共享状态。不过,状态值只在同一个闭包内部共享,对于每次创建的新的闭包,它们之间的状态不会互相影响,是各自独立的。例如再对一个数字10进行自定义步长的自增操作,那么它不会影响之前对5的操作,代码如下:
js
const incTenBy= increment(10);
console.log(incTenBy(3)); //13
console.log(incTenBy(5)); //18
console.log(incFiveBy(1)); //12
闭包还有一个用处:定义私有的状态。由于在闭包的外部,无法访问内部作用域,因此可以对内部状态起到保护作用,调用者只能使用闭包暴露出来的函数或对象等对状态进行修改,除此之外就没有其他办法修改内部的状态了。
例如,对于一组数据,允许访问当前元素,并且有向前和向后移动索引的操作,但不允许修改数据的值(可以想象为轮播图或音乐播放器),那么可以通过闭包的形式定义数据和操作数据的函数,然后通过一个对象把这些函数暴露给外界,用以移动索引,代码如下:
js
function data(){
let arr=[1,3,5,7,9];
let index=0;
return{
value(){
return arr[index];
},
next(){
index=++index%arr.length;
},
pre(){
index=(--index+arr.length)%arr.length;
},
};
}
这里使用对象形式返回了3个函数,如果无法理解此段代码也没关系,可以在看完第7章之后回过头来重新研究本示例,现在可重点关注对arr数组的保护。value()函数用于获取当前索引的元素,next()用于向前移动一位索引,超出数组长度后索引会回到0重新开始,pre()则是向前移动一位,超出后会回到最后一位继续向前,代码如下:
js
const myData=data();
console.log(myData.value());//1
myData.next(); //index:1
myData.next(); //index:2
console.log(myData.value());//5
myData.pre(); //index:1
myData.pre(); //index:0
myData.pre(); //index:4
console.log(myData.value());//9
从上述例子可以看出,data()函数的名字并不重要,可以使用匿名函数,但是JavaScript不能直接使用function(){}这样的语句定义匿名函数,而需要把它保存到变量中,并且仍然需要给变量起名字。
要解决这个问题,可以在定义匿名函数的时候就立即调用它,然后使用一个变量保存它的返回结果,这种在定义的同时直接进行调用的函数称为立即执行函数表达式(Immediately Invoked FunctionExpression,IIFE),它的形式是使用()把匿名函数包裹起来,然后在后边使用另一对()调用它,代码如下:
js
const myData=(function(){
let arr=[1,3,5,7,9];
//...省略内部逻辑
})();
这样定义的函数会被立即执行,然后把结果保存到myData中,之后 的调用和上例中一样。很多前端库会以这样的形式提供API,其目的就是防止不同的库之间的作用域互相影响,从而导致某些库的数据被另一些库给覆盖。
使用闭包还能解决一个常见的、由全局作用域引发的问题,代码如下:
js
for(var i=0;i<3;i++){
setTimeout(()=>{
console.log(i);
});
}
setTimeout()用于推迟一段代码的执行,它接收两个参数,第1个是回调函数,第2个是延迟时间,回调函数中的代码会在指定延迟时间之后执行,如果忽略了第2个参数,则会在for循环完成之后立即执行回调函数。
代码中使用循环创建了3个要延迟执行的代码,均为打印i的值。代码的运行结果很容易就会被认为是0 1 2,但实际上是3 3 3。原因在于,使用var定义的变量的作用域是全局的,在for循环结束的时候i的值已经变成了3,那么后边打印i的值就全部都是3了。要解决这个问题可以使用立即执行函数创建一个闭包,通过把i当作参数传递给它来捕获i的值,从而可以打印出0 1 2,代码如下:
js
for(var i=0;i<3;i++){
(function(i){
setTimeout(()=>{
console.log(i);
});
})(i);
}
或者另一个解决方法是直接使用let定义指示变量i,这样它的作用域为块级,每次在for循环开始时会产生一个新的作用域,这样每个setTimeout()中i的值就不会受影响了。
10、高阶函数
如果函数满足以下两点中的任意一点或全部,则这个函数就称为高阶函数(Higher-Order Function):
- 接收另一个函数作为参数。
- 返回一个函数。
对于同时接收函数作为参数并返回新的函数的高阶函数,一般是对参数函数进行增强和组合,然后返回具有新功能的函数。例如,把任一函数所返回的数字结果进行平方运算,代码如下:
js
function square(f){
return(...args)=>f(...args)**2;
}
const sum=(a,b)=>a+b;
const squareOfSum=square(sum);
console.log(squareOfSum(1,2)); //9
代码中的square()函数接收任意一个函数f作为参数,然后在return语句中,返回了一个新的函数,这个函数使用rest运算符接收了一个变长参数args,它的返回值是调用f()函数并进行平方运算的结果。
11、柯里化
柯里化(Currying)是指把一个接收多个参数的函数转化为一系列接收一个参数的子函数的过程。例如,通过汇率计算1美元能兑换多少人民币,可以定义一个函数,接收美元数量和汇率为参数,并返回换算后的结果,使用普通函数实现的代码如下:
js
function usdToCny(amount,rate){
return amount*rate;
}
console.log(usdToCny(1,6.78)); //6.78
console.log(usdToCny(8,6.78)); //54.24
通过观察上例中的代码可以发现,汇率需要在每次调用的时候都传一次,那么除了可以给rate设置默认值外,也可以通过柯里化的形式,实现记住汇率值,代码如下:
js
function convertRate(rate){
return(amount)=>amount*rate;
}
//普通调用
//console.log(convertRate(6.78)(10));//67.8
//记录中间值
const uToC=convertRate(6.78);
console.log(uToC(1)); //6.78
console.log(uToC(8)); //54.24
调用柯里化后的函数时,变成了使用连续的小括号的形式,这样任意一步的调用结果都可以保存起来,然后进行复用。例如把汇率6.78保存到uToC()函数中,之后只需给uToC()函数传递美元数量,就可以计算出能够兑换的人民币的数量了。