本章介绍js
函数,函数是js
程序的一个基本组成部分,也是几乎所有编程语言共有的特性.其他语言中所说子例程(subroutine)或过程(procedure),就是函数.
函数是一个js
代码块,定义之后,可以被执行或调用任意多次.js
函数是参数化的,即函数定义可包含一组标识符,称为参数或形参(parameter),这些形参类似函数体内定义的局部变量.函数调用会为这些形参提供值或实参(argument).函数通常会使用实参的值计算自己的返回值,这个返回值会成为函数调用表达式的值.除了实参,每个调用还有另外一个值,即调用上下文(invocation context),也就是this关键字
如果把函数赋值给一个对象的属性,则可以称其为该对象的方法.如果函数是在一个对象上被调用或通过一个对象被调用,这个对象就是函数的调用上下文或this值.设计用来初始化一个新对象的函数称为构造函数(constructor).构造函数在6.2节介绍过,第九章还会再介绍
js
中的函数是对象,可以通过程序来操作,比如,js
可以把函数赋值给变量,然后再传递给其他函数.因为函数是对象,所以可以在函数上设置属性,甚至可以调用函数方法.
js
函数可以嵌套定义在其他函数里,内嵌的函数可以访问定义在函数作用域的任何变量.这意味着js
函数是闭包(closure),基于闭包可以实现重要且强大的编程技巧.
在js
中定义函数最直观的方式就是使用function关键字,这个关键字可以用作声明或表达式.ES6定义了一种新的方式,可以不通过function关键字定义函数,即"箭头函数".箭头函数的语法特别简洁,很适合把函数作为参数传给另一个函数.接下来将分别介绍函数声明,函数表达式和箭头函数3种定义的方式.涉及函数形参的一些函数定义语法的细节将在8.3节讨论
在对象字面量和类定义中,有一个定义方法的快捷语法,这个快捷语法在6.10.5节介绍过,等价于使用函数定义表达式并使用基本的name:value对象字面量语法,将其赋值给对象的属性,另一种特殊情况是在对象字面量中使用关键字get和set定义特殊的获取和设置方法.这种函数定义语法在6.10.6节介绍过.
注意,也可以使用Function()构造函数定义新函数,而这是8.7.7节的主题.另外,js
也定义了一些特殊函数.function* 用于定义生成器函数(参见12章),而async function用于定义异步函数(参见13章)
函数声明由function关键字后跟如下组件构成.
js
语句,这些语句构成函数体,会在函数被调用时执行.下面是几个函数声明的例子:
// 打印对象o的每个属性的名字和值,返回undefined
function printProps(o){
for(let p in o){
console.log(`${p}:${o[p]}`)
}
}
// 计算笛卡尔坐标点(x1,y1)和(x2,y2)之间的距离
function distance(x1,y1,x2,y2){
let dx = x2 -x1;
let dy = y2 -y1;
return Math.sqrt(dx*dx+dy*dy);
}
// 计算阶乘的递归方程
function factorial(x){
if(x <= 1){
return 1;
}
return x * factorial(x-1)
}
要理解函数声明,关键是理解函数的名字变成了一个变量,这个变量的值就是函数本身.函数声明语句会被"提升"到包含脚本,函数或代码块的顶部 ,因此调用以这种方式定义的函数时,调用代码可以出现在函数定义代码之前.对此,另一种表述方式是:在一个js
代码块中声明的所有函数在该块的任何地方都有定义,而且他们会在js
解释器开始执行该块中任何代码之前被定义.
前面看到的distance()和factorial()函数用来执行计算并得到一个值,他们都使用return把该值返回给调用者.return语句导致函数停止执行并将其表达式(如果有)的值返回给调用者.如果return语句没有关联的表达式,则函数的返回值是undefined.
printProps()函数不一样:他的任务是输出对象的属性的名字和值.此时并不需要返回值,而且函数也不包含return语句.对printProps()函数调用的值始终是undefined.如果函数并不包含return语句,那么就简单的执行函数体内的每个语句,直到最后向调用者返回undefined.
在ES6之前,函数声明只能出现在js
文件或者其他函数的顶部,虽然有些实现弱化了这个限制,但严格来讲在循环体,条件或其他语句块中定义函数都不合法.不过在ES6的严格模式下,函数声明可以出现在语句块中,不过,在语句块中定义的函数只在该块中有定义.对块的外部不可见.
函数表达式看起来很像函数声明,但他们出现在复杂表达式或语句的上下文中,而且函数名是可选的.以下是几个函数表达式的示例:
// 这个函数表达式定义一个对参数求平凡的函数
// 我们把他赋值给了一个变量
const square = function(x){return x*x};
// 函数表达式可以包含名字,这对递归有用
const f = function fact(x){if(x<1)return 1;else return x*fact(x-1)};
// 函数表达式也可以用作其他函数的参数
[3,2,1].sort(function(a,b){return a-b});
// 函数表达式也可以定义完立即调用:
let tensquared = (function(x){return x*x}(10));//100
注意,函数名对定义为表达式的函数而言是可选的,前面看到的多数函数表达式都没有名字.**函数声明实际上会声明一个变量,然后把函数对象赋值给它.**而函数表达式不会声明变量,至于要把新定义的函数赋值给一个常量还是变量取决于你,这样方便以后多次引用.最佳实践是使用const把函数表达式赋值给常量,以防止意外又给他赋予新值而重写函数.
如果需要引用自身,也可以带函数名,比如前面的阶乘函数.如果函数表达式包含名字,则该函数的局部作用一种也会包含一个改名字与函数对象的绑定.实际上,函数名就变成了函数体内的一个局部变量.多数定义为表达式的函数都不需要名字,这让定义更简洁(尽管达不到要介绍的箭头表达式的简洁程度)
使用函数声明定义函数f()与创建一个函数表达式再将其赋值给变量f有一个重要的区别.
在使用声明形式时,先创建好函数对象,然后再运行包含他们的代码,而且函数的定义会被提升到顶部,因此在定义函数的语句之前就可以调用他们.
但对于定义为表达式的函数就不一样了,这些函数在定义他们的表达式实际被求值以前是不存在的,不仅如此,要调用函数要求必须可以引用函数,在把函数表达式赋值给变量之前是无法引用函数的,因此定义为表达式的函数不能在定义之前调用
在ES6中,我们可以使用一种特别简洁的语法来定义函数,叫做"箭头函数".这种语法可以唤醒我们对数学符号的记忆,他使用"箭头"分割函数的参数和函数体.因为箭头函数是表达式而不是语句,所以不必使用function关键字,而且也不需要函数名.箭头函数的一般形式是圆括号中逗号分割的参数列表,后跟箭头=>,再跟包含在花括号中的 函数体:
const sum = (x,y)=>{return x+y}
但是箭头函数还支持一种更简洁的语法.如果函数体只有一个return语句,那么可以省略return关键字,语句末尾的分号以及花括号,将函数体写成一个表达式,他的值将被返回:
const sum = (x,y)=>x+y;
更进一步,如果箭头函数只有一个参数,也可以省略包围参数列表的圆括号:
const polynomial = x => x*x + 2*x + 3
不过要注意的是,对于没有参数的箭头函数必须把圆括号写出来:
const constantFunc = () => 42;
还要注意,在写箭头函数前,不能在函数参数和箭头之间方换行符.否则,就会出现类似const polynomial = x这样的代码,而这行代码本身一天有效的复制语句.
另外,如果箭头函数的函数体是一个return语句,但要返回的表达式是对象的字面量,那必须把这个对象字面量放在一对圆括号中,以避免解释器分不清花括号到底是函数体的花括号还是对象字面量的花括号:
const f = x => {return {value:x}}
const g = x =>({value:x})
const h = x =>{value:x}// 错误写法
const i = x =>{v:x,w:x}// 错误写法
上面代码的第三行,函数h()是有歧义的:本来像作为对象字面量的代码可能被解析为标签语句,因而会创建一个返回undefined的函数.而在第4行,更复杂的对象字面量并不是有效语句,这个不合法代码会导致语法错误.
箭头函数的简洁语法让他们非常适合作为值传给其他函数,而这在使用map(),filter()和reduce()(7.8.1节)等数组方法是非常常见的:
// 得到一个过滤掉null元素的数组
let filtered = [1,null,2,3].filter(x=>x!=null) // filtered == [1,2,3]
// 求数值的平方
let squares = [1,2,3,4].map(x=>x*x);// squares == [1,4,9,16]
相比以其他方式定义的函数,箭头函数有一个及其重要的区别:他们从定义自己的环境继承this关键字,而不是想以其他方式定义的函数那样定义自己的调用上下文.这是箭头函数的一个重要且非常有用的特性,本章还会提及到这个特性.箭头函数与其他函数还有一个区别,就是他们没有prototype属性,这意味着箭头函数不能作为新类的构造函数(9.2节)
在js
中,函数可以嵌套在其他函数中,例如:
function hypotenuse(a,b){
function square(x){return x*x};
return Math.sqrt(square(a)+square(b))
}
关于嵌套函数,最重要的是理解他们的变量作用域规则:他们可以访问自己的函数(或更外层的函数)的参数和变量.例如,在上面的代码中,内部函数square()可以读写外部函数hypotenuse()定义的参数a和b.嵌套函数的这种作用域是非常重要的,将在8.6节详细介绍.
构成函数体的js
代码不在定义函数的时候执行,而在调用函数的时候执行,js
函数可以通过5中方法来调用:
js
语言特性隐式调用(与常规函数调用不同)函数是通过调用表达式(4.5节)被作为函数或方法调用的.调用表达式包括求值为函数对象的函数表达式,后跟一对圆括号,圆括号中是逗号分隔的零或多个参数表达式列表.
如果函数表达式是属性访问表达式,即函数是对象的属性或数组的元素,那么他是一个方法调用表达式.这种情况会在后面的例子中解释.下面这段代码包含了几个常规的函数调用表达式:
printprops({x:1});
let total = distance(0,0,2,1)+distance(2,1,3,5)
let probatbility = factorial(5)/factorial(13);
在一次调用中,每个(位于括号中的)实参表达式都会被求值,求值结果会变成函数的实参.换句话说,这些值会被赋予函数定义中的命名形参.在函数体内,对形参的引用求值为对应的实参值.
对常规函数调用来说,函数的返回值会变成调用表达式的值,如果函数由于解释器到达末尾而返回,则返回值是undefined.如果函数由于解释器执行到return语句而返回则返回值是return后面表达式的值;如果return语句没有值就是undefined.
条件式调用
在es2020中,可以在函数表达式后面,左圆括号插入?.,从而只在函数不是null或undefined时调用函数.换句话说,表达式f?.(x)等价于(在没有副效应的前提下):
(f!==null&&f!==undefined)?f(x):undefined
关于条件式调用语法的完整细节,可以参考4.5.1节
**对于非严格模式下的函数调用,调用上下文(this值)是全局对象.但在严格模式下,调用上下文是undefined.**要注意的是,使用箭头语法定义的函数又有不同:他们总是继承自身定义所在环境的this的值.
做要为函数(而非方法)来调用的函数通常不会在定义中使用this关键字.但是可以在这些函数中使用this关键字来确定是不是处于严格模式:
// 定义并调用函数,以确定当前是不是严格模式
const strict = (function(){return !this;}())
递归函数与调用栈
递归函数是调用自己的函数,就像本章开头看到的factorial()函数一样.某些算法,比如涉及与树相关的数据结构的算法,利用递归可以非常简洁的实现.但在写递归函数时,一定要考虑内存的限制.如果函数A调用函数B,而函数B调用函数C,js
解释器需要记住全部3个函数的执行上下文.当函数C完成时,解释器需要知道在哪里恢复函数B,而当函数B完成时,他需要知道在哪里恢复函数A.
可以把这些函数的依次执行想象成一个调用栈.当函数调用另一个函数是,就会有一个新执行上下文被推导这个调用栈上面.如果函数递归调用自身100次,那么这个栈上就会被推入100个对象,而后这100个对象又会被弹出.这个调用栈会占用内存空间.对于现代的硬件能力而言,写一个调用自己几百次的递归函数通常没什么问题.但如果函数调用自己达到上万次,很可能导致"最大调用栈溢出"(Maximum call-stack size exceeded)错误
方法其实就是js
的函数,只不过他保存为对象的属性而已,如果有一个函数f和一个对象o,那么可以像下面这个给o定义一个名为m的方法:o.m=f
对象o有了方法m()后,就可以这样调用:o.m()
如果m()期待两个参数,可以这样调用:o.m(x,y)
这个例子中的代码是调用表达式,包括函数表达式o.m和两个参数表达式x和y.这个函数表达式本身是个属性访问表达式,这意味着函数在这里是作为方法而非常函数被调用的.
方法调用的参数和返回值与常规函数调用的处理方式完全一样.但方法调用与函数调用有一个重要的区别:调用上下文.
属性访问表达式又两部分构成:对象(这里的o)和属性名(m).在像这样的方法调用表达中,对象o会成为调用上下文,而函数体可以通过关键字this引用这个对象.下面看具体一个例子:
let calculator= {// 对象字面量
operand1:1,
operand2:2,
and(){// 对这个函数使用了方法简写语法
//注意这里使用this关键字引用包含了对象
this.result = this.operand1+this.operand2
}
}
calculator.add()//1+1
calculator.result //2
多数方法调用使用点号进行属性访问,但是使用方括号的属性访问表达式也可以实现方法调用.比如,下面这两个例子都是方法调用:
o["m"](x,y);// 对o.m(x,y)的另一种写法
a[0](z)//也是一种方法调用(假设a[0]是函数)
方法调用也可能涉及更复杂的属性访问表达式:
customer.surname.toUpperCase()//调用customer.surname方法
f().m() // 在f()的返回值上调用m()
方法和this关键字的面向对象编程范式的核心.任何用作方法的函数实际上都会隐式收到一个参数,**即调用他的对象.通常,方法会在对象上执行某些操作,而方法调用语法是表达函数操作对象这一事实的直观方式.**比如下面这两行代码:
rect.setSize(width,height);
setReactSize(rect,width,height);
上面两行代码中的函数是假象的,这两个调用实际上会对(假想的)rect对象执行相同的操作.但第一行的方法调用语法更清晰的传达出了对象rect才是这个操作的焦点
方法调用链
如果方法返回对象,那么基于这个方法调用的返回值还可以继续调用其他方法,这样就会得到表现为一个表达式的一系列方法调用(或方法调用链).比如,在使用基于契约的异步操作(参考13章)时,以下代码结构很常见:
// 依次运行三个异常操作,最后处理错误
doStepOne().then(doStepTwo).then(doStepThree).catch(handleErrors);
如果你写的方法没有返回值,可以考虑让他返回this,如果能在自己的API中统一这么做,那就可以支持一种被称为方法调用链(method channing)的编程风格,这样只要给对象命名,之后就可以连续嗲用这个对象的方法:
new Square().x(1000).y(1000).size(50).outline('red').fill('blue').draw();
注意,this是个关键字,不是变量也不是属性名,js
语法不允许this赋值
this关键字不具有变量那样的作用域机制,除了箭头函数,嵌套函数不会继承包含函数的this值.如果嵌套函数被当做方法来调用,那他的this值就是调用他的对象.如果嵌套函数(不是箭头函数)被当做函数来调用.,则他的this值要么是全局对象(非严格模式),要么是undefined(严格模式).这里有一个常见的错误,就是对于定义在方法中的嵌套函数,如果将其作为函数来调用,以为可以使用this获得这个方法的调用的上下文.一下代码演示了这个问题:
let o = {// 对象
m:function(){
let self = this // 对象的方法m
this === o // true:this是对象o
f(); // 调用嵌套函数f()
function f(){// 嵌套函数
this === o // false:this是全局对象或undefined
self === o // true:self是外部的this值
}
}
}
o.m();// 在对象上调用方法m
在嵌套函数f()内部,this关键字不等于对象o.这被广泛认为是js
语法的一个缺陷,所以了解这个问题很重要.上面的代码演示了一个常见的技巧.在方法m中,我们把this值赋给了变量self,然后在嵌套函数f中.就可以使用self而非this来引用包含对象
在ES6及之后的版本中,解决这个问题的另一技巧是吧嵌套函数f转换为箭头函数,因为箭头函数可以继承this值:
const f = ()=>{
this === o // true // 因为箭头函数继承this
}
函数表达式不像函数声明语句那样会被提升,因此为了让上面的代码有效,需要将这个函数f的定义放到方法m中调用函数f的代码之前.
还有一个技巧是调用嵌套函数的bind()方法,以定义一个在指定对象上被隐式调用的新函数:
const f = (function(){
this === o //true // 因为我们把这个函数绑定到外部的this
}).bind(this)
8.7.5节将更详细的介绍bind().
如果函数或方法调用前面加上一个关键字new,那他就是构造函数调用(构造函数调用在4.6节和6.2.2节介绍过,第九章还会更详细的介绍).构造函数调用与常规函数和方法调用的区别在于参数处理,调用上下文和返回值.
如果构造函数调用在圆括号中包含参数列表,则其中的参数表达式会被求值,并以与函数和方法调用相同的方式传给函数.不过,假设没有参数列表,构造函数调用时其实也可以省略空圆括号.例如,下面这两行代码是等价的:
o = new Object();
o = new Object;
构造函数调用会创建一个新的对象,这个对象继承构造函数的prototype属性指定的对象.构造函数就是为初始化对象而设计的,这个新创建的对象会被用作函数的调用上下文,因此在构造函数中可以通过this关键字引用这个新对象.
let o = {
name:1234,
m: function() {
console.log(this,this.name)
return 1;
}
}
o.m()
// this:{name: 1234, m: ƒ} thi.name:1234
// 返回 1
new o.m()
// this:m{} this.name:undefined
// 返回 m{}
尽管构造函数看起来像一个方法调用,但它依然会使用new
出来的新对象作为调用上下文。也就是说,在表达式new o.m()
中,调用上下文并不是o
。
注意,即使构造函数调用看起来像方法调用,这个新对象也仍然会被用作调用上下文.换句话说在表达式new o.m()中,o不会用作调用上下文m才是.
构造函数正常情况下不使用return关键字,而是初始化新对象并在到达函数体末尾时隐式返回这个对象.此时,这个新对象就是构造函数调用表达式的值.但是如果函数显示的使用了return语句返回某个对象,那该对象就会变成调用表达式的值.如果构造使用return但没有返回值,或者返回的是一个原始值,则这个返回值会被忽略,仍然以新创建的对象作为调用表达式的值.
js
函数是对象,与其他js
对象一样,js
函数也有方法.其中有两个方法—call(),apply(),可用来间接调用函数.这两个方法允许我们指定调用时候的this值,这意味着可以将任意函数作为任意对象的方法来调用,即使这个函数实际上并不是该对象的方法.这两个方法都支指定调用参数.其中,call()方法使用自己的参数列表作为函数的形参,而apply()方法则期待数组值作为参数.8.7.4节会详细介绍call()和apply()方法.
有一些js
语言特性看起来不像函数调用,但实际上会导致某些函数被调用.要特别关注会别隐式调用的函数,因为这些函数涉及的bug,副效应和性能问题都比常规函数更难排查.因为只看代码可能无法知晓什么时候会调用这些函数.
以下是可能导致隐式函数调用的一些语言特性:
js
函数定义不会指定函数形参的类型,函数调用也不对传入的实参进行任何类型检查.事实上,js
函数调用链传入实参的个数都不检查.接下来几节介绍函数调用时传入的实参少于或多余声明的形参时会发生什么.同时,这几节也演示了如果显示测试函数实参的类型,以确保不会以不适合的实参调用参数.
当调用函数时传入的实参少于声明的形参时,额外的形参会获得默认值,通常是undefined.有时候,函数定义也需要声明一些可选参数.下面看一个例子:
// 把对象o的可枚举属性名放到数组a中,返回a
// 如果不传a,则创建一个数组
function getPropertyNames(o,a){
if(a===undefined)a=[];//如果是undefined,创建一个新数组
for(let property in o) a.push(property);
return a;
}
// 调用getPropertyNames()时可以传递一个参数,也可以传递两个参数
let o = {x:1},p = {y:2,z:3};// 两个用于测试的对象
let a = getPropertyNames(o);// a == ["x"];o的属性保存在新数组中
getPropertyNames(p,a) // a==["x","y","z"];//把p的属性也放到a中
这个函数的第一行也可以不使用if语句,而是像下面这样约定俗成地使用**||**:
a = a || [];
4.10.2节介绍过||操作符在其第一个参数是真值时返回第一个参数,否则返回第二个参数,在这里,如果调用函数时传入任何对象作为第二个参数,函数都会使用这个对象,但如果没有传第二个参数(或者传入null或其他假值),则会使用新创建的空数组.
注意,在设置有可选参数的函数时,一定要把可选参数放在参数列表最后,这样在调用时才可以省略.调用函数的程序不可能不传第一个参数而只传递第二个参数,他必须在第一个参数的位置显示的传undefined.
在ES6及更高的版本中,可以在函数形参列表中直接为每个参数定义默认值.语法是在形参名后面加上等号和默认值,这样在没有给该形参传值时就会使用这个默认值:
// 把对象o的可枚举属性名放到数组a中,返回a
// 如果不传a,则创建一个新数组
function getPropertyNames(o,a=[]){
for(let property in o)a.push(property);
return a;
}
函数形参默认值表达式会在函数调用时求值,不会在定义时候求值.因此每次调用getPropertyNames()函数时如果只传递一个参数,都创建并传入一个新的空数组.(python中每次调用都共享同一个默认值).如果形参默认是常量(或类似[],{}这样的字面量),函数是最容易理解的.但这不是必须的,也可以使用变量或函数调用计算形参的默认值.对此有一种有意思的情形,即如果函数有多个形参,则可以使用前面参数的值来定义后面参数的默认值:
// 这个函数返回一个表达式表示矩形尺寸的对象
// 如果只提供width,则height就是他的两倍
const rectangle = (width,height = width * 2)=>({width,height})
以上代码演示了形参默认值也可以在箭头函数中使用,同样,对于方法简写函数,以及其他各种形式的函数定义也都是一样的.
形参默认值让我们可以编写用少于形参个数的实参来调用的函数.剩余形参(rest parameter)的作用恰好相反:他让我们能够编写在调用时传入比形参多任意数量的实参的函数.下面是一个示例函数,接受一个或多个实参,返回其中最大的一个:
function max(first=-Infinity,...rest){
let maxValue = first;//假设第一个参数是最大的
// 遍历其他参数,寻找更大的数值
for(let n of rest){
if(v>maxValue){
maxValue = n;
}
}
// 返回最大值
}
max(1,10,100,2,3,1000,4,5,6) // 1000
剩余参数前面有三个点,而且必须是函数声明中最后一个参数.在调用有剩余形参的函数时,传入的实参首先会赋值到非剩余形参,然后所有剩余的实参(也是剩余参数)或保存在一个数组中赋值给剩余形参.最后一点很重要:在函数体内,剩余形参的值始终是数组.数组有可能为空,但剩余形参永远不可能是undefined(相应地,也要记住,永远不要给剩余形参定义默认值.这样即没有用,也不合法)
类似前面例子中那样可以接受任意数量实参的函数称为可变参数函数(variadic function),可变参数数量函数(variable arity function)或变长函数(vararg function)本书使用最通俗的"变长函数(vararg)",这个称呼可以追溯到C编程语言诞生的时期.
一定要分清楚在函数定义中用于定义剩余参数的…和8.3.4节介绍的扩展操作符…后者可以在函数调用中使用.
剩余形参是ES6引入js
的,ES6之前,变长函数是基于Arguments对象实现的,也就是说,在任何函数体内,标识符arguments引用该次调用的Arguments对象,Arguments对象是一个类数组对象(参见7.9节),他允许通过数值而非名字取得传给函数的参数值.下面是之前展示的max()参数,使用Arguments对象重写了一下,没有使用剩余参数:
function max(x){
let maxValue = -Infinity;
// 遍历arguments,查找并记住最大的数值
for(let i=0;imaxValue)maxValue = arguments[i]
}
return maxValue;
}
max(1,2,3,4,5,6,66,1000)// 1000
Arguments对象可以追溯到js
诞生之初,也有一些奇怪的历史包袱,导致他效率低且难优化,特别是在严格模式下.阅读代码的时候,我们还是可以看到Arguments的身影,但在新写的代码中应该避免使用它.在重构代码时,如果碰到了使用arguments的函数,通常可以将其替换为…args剩余参数.由于Arguments对象属于历史遗留问题,在严格模式下,arguments会别当成保留字,因此不能使用这个名字来声明函数形参或局部变量.
在期待单个值的上下文中,扩展操作符…用于展开或"拓展"数组(或任何可迭代对象,如字符串)的元素.在7.1.2节,我们已经看到过如何对数组字面量使用扩展操作符.这个操作符同样可以用在函数调用中:
let numbers = [5,2,10,-1,9,100,1];
Math.min(...numbers) // -1
注意,从求值并产生一个值的角度说,…并不是真正的操作符.应该说,他是一种可以针对数组字面量或函数使用的特殊js
语法.
如果在函数定义(而非函数调用)是使用同样的…语法,那么会产生与扩展操作符相反的作用.如8.3.2节所示,在函数定义中使用…可以将多个函数实参收集到一个数组中.剩余形参和拓展操作符经常同时出现,如以下函数所示,他接受一个函数实参并返该函数的可测量版本,以用于测试:
// 这个函数接受一个函数并返回一个包装后的版本
function timed(f){
return function(...args){// 把实参收集到一个剩余形参数组的args中
console.log(`Enterign function ${f.name}`)
let startTime = Date.now()
try{
// 把收集到的实参传给包装后的函数
return f(...args);//把args扩展回原来的形式
}finally{
// 在返回被包装的返回值之前,打印经过的时间
console.log(`Entering ${f.name} after ${Date.now()-startTime}ms`)
}
}
}
// 以简单粗暴的方式计算1到n的数值之和
function benchmark(n){
let sum = 0;
for(let i=1;i<=n;i++)sum += i;
return sum
}
timed(benchmark)(10000000) //5000050000
调用函数时如果传入一个实参列表,则所有参数值都被赋给函数定义时声明的形参.函数调用的这个初始化阶段非常类似变量赋值.因此对于函数使用解构赋值技术(参见3.10.3节)并不奇怪.
如果我们定义了一个函数,他的形参名包含在方括号中,那说明这个函数期待对每对方括号都传入一个数组值.作为调用过程的一部分,我们传入的数组实参会被解构赋值为单独的命名参数.
例如,假如要用数组来表示两个数值的2D向量,数组的一个元素是X坐标,第二个元素是Y元素.基于这个简单的数据结构,可以像下面这样写一个把它们相加的函数:
function vectorAdd(v1,v2){
return [
v1[0]+v2[0],
v1[1]+v2[1]
]
}
vevtorAdd([1,2],[3,4])// [4,6]
如果换成把两个向量实参解构为命名更清晰的形参,以上代码就更好理解了:
function verctorAdd([x1,y1],[x2,y2]){// 把两个参数解构赋值为4个形参
return [x1+x2,y1+y2];
}
vectorAdd([1,2],[3,4])//[4,6]
类似的,如果定义的函数需要一个对象实参,也可以把传入的对象解构赋值给形参.还以前面的向量计算为例,但这次假设我们要求传入一个有x和y参数的对象:
// 标量乘以向量{x,y}
function vectorMultiplly({x,y},scalar){
return {x:x*scalar,y:y*scalar};
}
vectorMultiply({x:1,y:2},2)// {x:2,y:4}
这个例子把一个对象实参解构为两个形参,由于形参名与对象的属性名一致,所以相当清晰.但是,如果需要吧解构的属性赋值给不同的名字,那代码会更加长也更不好理解.下面是一个向量加法的例子,用基于对象的向量实现:
function vectorAdd({x:x1,y:y1},{x:x2,y:y2}){// 把两个对象展开
return {x:x1+x2,y:y1+y2};
}
对于像{x:x1,y:y1}这样的解构语法,关键是记住哪些是属性名,哪些是实参名,无论是解构赋值还是还是结构函数调用, 要记住的是声明的函数或参数都位于对象字面量中期待值的位置.因此,属性名始终在冒号左侧,而形参(或变量)名则在冒号右侧.
在解构赋值中也可以为形参定义默认值.下面是针对2D或3D向量乘法的例子:
// 用标量乘以向量{x,y}或{x,y,z}
function vectorMultiply({x,y,z=0},scalar){
return {x:x*scalar,y:y*scalar,z:z*scalar};
}
vectorMultiply({x:1,y:2},2)// {x:2,y:4,z:0}
有些语法,python允许函数的调用者在调用函数时使用name=value的形式指定实参,在可选参数很多或参数列表太长的时候无法记住顺序时这种方法是有用的.
js
并不直接支持这种调用方式,但可以通过把对象参数解构为函数参数来模拟.比如,有一个函数从指定数组中把指定个数的元素复制到另一个数组中,参数中可选地指定每个数组的起始索引值.此时至少涉及5个参数,其中有的有默认值,而调用者又很难记住这些参数的顺序.为此可以像下面这样定义arrcopy()函数:
function arraycopy({from,to=from,n=from.length,formIndex=0,toIndex=0}){
let valuesToCopy = from.slice(fromIndex,fromIndex+n);
to.splice(toIndex,0,...valuesToCopy);
return to;
}
let a = [1,2,3,4,5],b =[9,8,7,6,5];
arraycopy({from:a,n:3,to:b,toIndex:4})// [9, 8, 7, 6, 1, 2, 3, 5]
解构数组时,可以为被展开数组中的额外元素定义一个剩余形参.注意,位于方括号中的剩余形参与函数真正的剩余形参完全不同:
// 这个函数期待一个数组参数.数组的前两个元素
// 会展开赋值给x和y,而剩下的所有元素则会保存在
// coords数组中,第一个数组之后的参数则会保存到rest数组中
function f([x,y,...coords],..rest){
return [x+y,...rest,...coords];//注意:这里是扩展操作符
}
f([1,2,3,4],5,6)//[3,5,6,3,4]
在ES2018中,解构对象也可以使用剩余形参,此时剩余形参的值是一个对象,包含所有未被解构的属性.对象剩余形参经常与对象扩展操作一起使用,后者也是ES2018中新增的特性:
// 用标量乘以向量{x,y}或{x,y,z}其他属性保持不变
function vectorMultiply({x,y,z=0,...props},scalar){
return {x:x*scalar,y:y*scalar,z:z*scalar,...props}
}
vectorMultiply({x:1,y:2,w:-1},2)// {x:2,y:4,z:0,w:-1};
最后要记住一点,除了解构作为参数的对象和数组,也可以解构对象的数组,有数组属性的对象,以及又对象属性的对象,无论层次嵌套多深.比如,有关图形的代码中,圆形可以表示为包含x,y,radius和color属性的对象,其中color属性本身是一个包含红,绿,蓝组件的数组,.那么定义的函数可以只接受一个圆形对象,但可以将该对象解构为6个具体的参数:
function drawCircle({x,y,radius,color:[r,g,a]}){
// 未实现
}
如果函数参数解构的复杂程度超过了这个示例,我个人觉得代码会变得更难理解,而不是更容易理解.有时,通过对象属性和数组索引来获取值反倒更清晰
js
方法的参数没有预定义的类型,在调用传参时也没有类型检查.可以用描述性强的名字作为函数参数,同时通过在注释中解释函数的参数来解决这个问题(此外,可以选择17.8节介绍的一种语言扩展,在js
之上引入类型检查能力).
正如3.9节所介绍的,js
会按需执行任意的类型转换.因此如果你的函数接受字符参数,而调用时传的是其他类型的值,则这个值在函数先把它当成字符串使用时,会尝试将他转换为字符串.所有原始类型的值都是可以转换为字符串,所有对象都有toString()方法(尽管有些不一定真正有用),因此这种情况下永远不会出错.
不过也非没有例外.仍以前面的arraycopy()方法为例.该方法期待一个或多个数组参数,如果参数类型不对就会失败.除非你写的是个私有函数,只会在自己的代码内部调用,否则就必须增加像下面这样用于检查参数类型的代码.对函数来说,在发现传入的值不对时立即失败,一定好过先执行逻辑再以出错告终,而且前者比后者更清晰.下面看一个执行类型检查的函数示例:
// 返回可迭代对象a中所有元素之和.
// a的元素必须全部是数值
function sum(a){
let total =0 ;
for(let element fo a){// 如果a不是可迭代对象则抛出TypeError
if(typeof element !== "number"){
throw new TypeError("sum():elements must be numbers")
total += element;
}
return total
}
}
sum([1,2,3]) // 6
sum(1,2,3) // TypeError 1不是可迭代对象
sum([1,2,"3"])// TypeError 元素2不是数值
函数最重要的特性在于可以定义和调用他们.函数定义和调用是js
和大多数语言的语法特性.但在js
中,函数不仅仅是语法,也是值.这意味着可以把函数赋值给变量,保存为对象的属性或者数组的元素,作为参数传给其他函数,等等.
要理解函数即使js
数据又是js
语法到底意味着什么,可以看看下面的例子:
function square(x){return x*x;}
这个定义创建了一个新函数对象并把他赋值给变量square.函数的名字其实不重要,因为他就是引用函数对象的一个变量名.把函数赋值给另有一个变量同样可以调用:
let s = square;// s也引用了与square相同的函数对象
square(4) // 16
s(4) // 16
除了变量,也可以把函数赋值给对象的属性.如前所诉,这时候把函数称为"方法":
let o = {square:function(x){return x*x}};// 对象字面量
let y = o.square(16) // y == 256
函数甚至可以没有名字,比如可以把匿名函数作为一个数组元素:
let a = [x=>x*x,20];//数组字面量
a[0](a[1])// 400
上面最后一行代码的语法,看起来有点奇怪,但仍然是合法的函数调用表达式.
为了更好的理解把函数作为值有多大用处,可以想一想Array.sort()方法.这个方法可以作为对数组元素进行排序.因为可能的排序方式有很多种(按数值排序,按字母排序,按日期排序,还有升序,降序,等等),所以sort()方法可选地接受一个函数作为参数,并根据这个函数的返回值决定如何排序.这个函数的任务非常简单:对于传给他的两个值,他要返回一个表示哪个值在排序后的数组中排在前面的位置.这个函数参数让Array.sort()变得非常通用,而且无比灵活.可以通过把他任何类型的数据按照任何可以想象出来的方式进行排序.具体例子可以参数7.8.6节
下面实例8-1演示了把函数当做值可以做什么事.这个例子可能不好理解,可以参考注释.
实例8-1函数作为值
//这里定义了几个简单函数
function add(x,y){return x+y};
function subtract(x,y){return x-y};
function multiply(x,y){return x*y};
function divide(x,y){return x/y};
// 这个函数接受前面定义的任意一个函数
// 作为参数,然后再用两个操作数调用他
function operate(operator,operand1,operand2){
return operator(operand1,operand2);
}
// 可以像这样调用这个函数,计算(2+3)+(4*5)的值
let i = operate(add,operate(add,2,3),operate(multiply,4,5))
// 为演示方便,我们又一次实现了这些简单函数
// 这次把它们放到了对象字面量中
const operators = {
add:(x,y)=>x+y,
subtract:(x,y)=>x-y,
multiply:(x,y)=>x*y,
divide:(x,y)=>x/y,
pow:Math.pow // 预定义的函数也没问题
}
// 这个函数只接受操作的名字,然后在对象中查询
// 这个名字,然后再使用传入的操作数调用它
// 注意这里调用操作函数的语法
function operatte2(operation,operand1,operand2){
if(typeof opertors[operation]==="function"){
return operators[opertion](operand1,operand2);
}else throw "unknown operator";
}
operate2("add","hello",operate("add"," ","world"))// "hello world"
operate2("pow",10,2)// 100
函数在js
中并不是原始值,而是一种特殊的对象.这意味着函数也可以有属性.如果一个函数需要一个"静态"变量,且这个变量的值需要在函数每次调用是都能访问到,则通常把这个变量定义函数自身的一个属性.
比如,假设我要写一个每次调用都返回唯一整数的函数,那么每次调用都不能返回相同的值.为保证这一点,函数需要记录自己已经返回过的值,这个信息必须在每次调用时都能访问到.可以把这个信息保存一个全局变量中,但其实没有这个必要,因为这个属性只有函数自己会用到,更好的方式是吧这个信息保存在函数对象的一个属性中.下面就是一个每次调用都返回唯一整数值的函数实现:
// 初始化函数对象的计时器counter属性
// 函数声明会被提升,因此我们可以在函数声明之前在这里就给他赋值
uniqueIntger.counter = 0;
// 这个函数每次被调用是都返回一个不同的整数
// 他使用自身的属性记住下一个要返回什么值
function uniqueInteger(){
return uniqueInteger.counter++;// Return and increment counter property
}
uniqueInteger()// 0
uniqueInteger()// 1
再看一个例子,下面的factorial()函数使用了自身的属性来缓存之前计算的结果(函数将自身作为一个数组):
// 计算阶乘并把结果缓存到函数本身的属性中
function factorial(n)[
if(Number.isInteger(n)&&n>0){// 仅限于正整数
if(!(n in factorial)){
factorial[n] = n * factorial(n-1);// 返回缓存获得结果
}else{
return NaN;//如果输入有错误
}
}
]
factorial[1] = 1;// 初始化缓存,保存最基础的值
factorial(6) // 720 = 1
factorial[5]// 120// 上面的调用缓存了这个结果.
在函数体内声明的变量在函数外部不可见.为此,有时候可以把函数用作临时的命名空间,这样可以保证在其中定义的变量不会污染全局命名空间.
假设有一段js
代码,你想在几个不同的js
程序中使用它(或在客户端js
中,在不同网页中使用他们),在假设这段代码跟多数代码定义一样定义了存储中间结果的变量.
问题来了:这段代码可能被很多程序用到,我们不知道那些程序创建的变量会不会跟这段代码中的变量冲突.解决方案就是把这段代码放到一个函数中,然后调用这个函数.这样,原本可能会定义在全局的变量就变成函数的局部变量:
function chunkNamespace(){
// 要复用的代码放在这里
// 在这里定义的任何变量都是函数的局部变量
// 不会污染全局命名空间
}
chunkNamespace();//别忘了调用这个函数
以上代码只定义了一个全局变量,即函数chunkNamespace.如果就连定义一个属性也嫌多,那可以在一个表达式中定义并调用匿名函数
(function(){// 将chunkNamespace()函数重写为一个无名表达式
// 要复用的代码放在这里
}())// 函数定义结束后立即调用它
在一个表达式中定义并调用匿名的技术非常常用,因此甚至有了别称,叫"立即调用函数表达式"(immediately invoked funciton expression).注意前面代码中括号的使用.位于function关键子前面的左开括号是必须的,因为如果没有他,js
解释会把function关键字作为函数声明语句来解释.有了这对括号,解释会把它正确地识别为函数定义表达式.而且开头的括号也便于程序员知道这是个定义后立即调用的函数,而不是为后面使用而定义的函数.
函数作为命名空间真正的用武之地,.还是在命名空间中定义一个或多个函数,而这些函数又使用该命名空间中的变量,最后这个函数又作为命名空间函数的返回值从内部传递出来,类似这样的函数又称为"闭包"(closure),也是下一节的主题.
与多数现代编程语言一眼,js
使用词法作用域(lexical scoping).这意味着函数执行时使用的是定义函数时生效的变量作用域,而不是调用函数是生效的变量作用域.
为了实现词法作用域,
js
函数对象的内部状态不仅要包含函数代码!!!,还有包括对函数定义所在作用域的引用!!!,
这种函数对象与作用域(即一组变量绑定)组合前解析函数变量的机制,在计算机科学文献中被称作闭包(closure).
严格来讲,所有js
函数都是闭包.但由于多数函数调用与函数定义都在同一作用域内,所以闭包的存在无关紧要.闭包真正值得关注的时候,是定义函数与调用函数的作用域不同时候,最常见的情形就是一个函数返回来了在他内部定义的嵌套函数.很多强大的编程技术都是建立在这种嵌套函数闭包之上的,因此嵌套函数碧波啊在js
程序中也变得比较常见.乍一接触闭包难免不好理解,但只有真正理解了才能用好他们.
要了解闭包,第一步需要先回顾嵌套函数的词法作用域规则,来看下面的代码:
let scope = "global scope";// 全局变量
function checkscope(){
let scope = "local scope";// 局部变量
function f(){return scope;}// 返回当作用域中的值
return f();
}
checkscope()// local scope
checkscope()函数声明了一个局部变量,然后定义了一个返回该变量的值的函数并调用了该代码.很显然,调用checkscope()应该返回"local scope".现在,我们稍微改一改代码.你知道下面的代码返回什么?
let scope = "global scope"// 全局函数
function checkscope(){
let scope = "local scope";// 局部变量
function f(){return scope};// 返回当前作用域中的值
return f;
}
let s = checkscope()();// 这里返回什么?
在上面的代码中,我们把checkscope()中的一对圆括号转义到了外部.
转移前调用的是嵌套函数并返回结果,而现在checkscope()返回的是嵌套函数,
当我们在定义他的函数外部(通过最后一行代码的第二对圆括号)调用这个嵌套函数会发生什么?
还记得词法作用域的基本规则吧:js
函数是使用定义他们的作用域来执行的.在定义嵌套函数f()的作用域,变量scope绑定的值是"local scope",该绑定在f执行时仍然有效,无论他在哪里执行.因此前面代码示例的最后一行返回"local scope",而非"global scope".简言之,这正是闭包惊人且强大的本质:他们会捕捉自身定义所在外部函数的局部变量(及参数)绑定.
在8.4.1节,我们定义了一个uniqueInteger()函数,该函数使用一个函数自身的属性来跟踪要返回的下一个值.该方法有一个缺点,就是容易被错误或恶意代码重置计算器,或者把计数器设置为非整数,导致uniqueInteger()函数违反自己契约的"唯一"(unique)或"整数"(Integer)规则.闭包可以捕获一次函数调用的局部变量,可以将这个变量作为私有状态.为此,可以像下面这样改写uniqueInteger()函数,使用立即调用函数表达式定义一个命名空间,再通过一个闭包利用该命名空间来保证自己的状态私有:
let uniqueInteger = (function(){// 定义并调用
let counter = 0;//下面函数的私有状态
return function(){
return counter++;
}
}())
uniqueInteger()// 0
uniqueInteger()// 1
为了理解这段代码,必须仔细看几遍.咋一看,第一行代码像一条赋值语句,把一个函数赋值给变量uniqueInteger.实际上(如第一行开头的左圆括号所提示的),这行代码定义并调用了一个函数,因此真正赋值给uniqueInteger的是这个函数的返回值.在仔细看一下函数体,你会发现他的返回值是另一个函数.换句话说,这个嵌套的函数最终被赋值给了uniqueInteger.这个嵌套函数有访问其作用域中的变量,而且可以使用定义在外部函数中的变量counter.外部函数一旦返回,就没有别的代码能够看到变量counter了,此时内部函数拥有对他的专有访问权.
类似counter这样的私有变量并非只能有一个闭包独享.同一个外部函数中玩完全可以定义两个或更多嵌套函数,而他们共享相同的作用域.来看下面的代码:
function counter(){
let n = 0;
return {
count:function(){return n++}
reset:function(){n=0;}
}
}
let c = counter(),d = counter();// 创建两个计数器
c.count() // 0
d.count() // 0//他们两个分别计数
c.reset()// 0
c.count()// 0
d.count() //
这个counter()函数返回了一个"计数器"对象,该对象有两个方法:count()和reset().前者返回下一个整数,后者重置内部转态,首先要理解的是,这两个方法都有权访问私有变量n.其次是要知道,每次调用counter()都会创建一个新作用域(与之前调用创建的作用域相互独立),还有作用中域中的一个新私有变量.因此如果调用两次counter(),就会得到拥有两个不同私有变量的计数器对象.在一个计数器上调用count()或reset()不会影响另一计数器.
有一点需要指出的是,可以将这种闭包技术与属性获取方法和设置方法组合使用.下面这个counter()函数是6.10.6节中代码的变体,但它使用了闭包保存私有状态而非依赖常规对象属性:
function counter(n){// 函数参数n是私有变量
return {
// 属性获取方法,返回递增后的私有计算器值
get count(){return n++;}
// 属性设置方法,不允许n的值减少
set count(m){
if(m>n)n =m;
else throw Error("count can only be set to a larger value")
}
}
}
let c = counter(1000);
c.count // 1000
c.count // 1001
c.count = 2000;
c.count // 2000
c.count = 2000 // Error
注意这个版本的counter()函数没有声明局部变量,只使用自己的参数n保存供
属性访问器方法共享的私有状态.这样可以让counter()的调用者指定私有变量的初始值.
实例8-2基于前面介绍的闭包技术实现了一个通用的共享私有状态的函数,例子中的函数addPrivateProperty()定义了一个私有变量和两个分别用于获取或设置该变量值的嵌套函数,而且将这两个嵌套函数作为方法添加到了调用时指定的对象.
8-2示例
// 这个方法按照指定的名字为对象o添加属性访问器方法
// 方法命名为get和set
function addPrivateProperty(o,name,predicate){
let value;//这是属性值
// 获取方法简单的返回属性值
o[`get${name}`]= function(){return value};
// 设置方法保存值或在断言失败时抛出异常
o[`set${name}`]=function(v){
if(predicate && !preduicate(v)){
throw new TypeError(`set${name}:invalid value ${v}`)
}else{
value = v;
}
}
}
// 下面的代码演示了如果使用addPrivateProperty()方法
let o = {}// 先创建一个空对象
// 添加属性访问器方法getName()和setName()
// 确保只能设置字符串值
addPrivateProperty(o,"Name",x=>typeof x === "string");
o.setName("Frank")// 设置属性的值
o.getName() // Frank
o.setName(0) // !TypeError:尝试设置一个错误类型的值
前面的几个例子都是在相同作用域中定义两个闭包,共享访问相同的私有变量或变量.这个技术很重要,但同样重要的是应该知道什么情况下闭包会意外地共享访问不该别共享的变量.比如下面的代码:
// 这个函数返回一个始终返回v的函数
function constfunc(v){ return ()=>v;}
// 创建一个常量函数的数组
let funcs = [];
for(var i = 0;i<10;i++){
funcs[i] = consfunc(i);
}
// 索引5对应的函数返回数值5
funcs[5]() // 5
在编写这种循环创建多个闭包的代码时,一个常见的错误是把循环转移到定义闭包的函数中.比如下面的代码:
// 返回一个函数数组,其中的函数返回数值0-9
function constfuncs(){
let funcs = []
for(var i =0;i<10;i++){
funcs[i] = ()=>i
}
return funcs;
}
let funcs = constfuncs();
funcs[5]() // 10 ?? 为什么不是5
以上代码创建了10个闭包并将他们保存在一个数组中.而闭包全部是在同一个函数调用中定义的,因此他们可以共享i.当constfuncs()返回后,变量i的值是10,全部10个闭包共享这个值,所以,返回的函数数组中的所有函数都返回相同的值,这句对不是我们想要的结果.关键是要记住,与闭包关联的作用域是"活的".嵌套函数不会创建作用域的私有副本或变量绑定的静态快照.
从根本上说,这里的问题在于通过var声明的变量在整个函数作用域内都有定义.代码中的for循环使用var i声明循环变量,此时变量i的作用域是整个函数体,而不是更小的循环体.这段代码演示了ES5及之前版本的代码中常见一类的错误,而ES6增加的块级作用域变量解决了这个问题.只要把这个的var替换成let或const,问题马上就会消失.因为let和const是块级作用域的标志,这意味着每次循环都会定义一个与其他循环不同的独立作用域,而每个作用域中都有自己独立的i绑定.
// 经典闭包案例
let div = document.querySelectorAll("div");
function cll(index){return ()=> console.log(index)}
for (var index = 0; index < 10; index++) {
div[index].addEventListener("click",cll(index))
}
在写闭包的时候,要注意:this是js
关键字,不是变量,如前所述,箭头函数继承包含他们的函数中的this值,但使用function定义的函数并非如此.因此如果你要写的闭包需要使用其包含函数的this值,那应该在返回闭包之前使用箭头函数或调用bind(),也可以把外部的this值赋给你的闭包将继承的变量:
const self = this // 让嵌套函数可以访问外面的this值
前面已经介绍了,函数在js
中也是一种值.对函数使用typeof操作符会返回字符串"function",但函数实际上是一种特殊的js
对象.由于函数是对象,因此他们也有属性和方法.甚至还有一个Function()构造函数可以用来创建新函数对象.接下来几节将介绍函数和length,name和prototype属性,讨论call(),apply()和toString()方法,以及Function()构造函数.
函数只有一个只读的length属性,表示函数的元素(arity),即函数在参数列表中声明的形参个数.这个值通常表示调用函数是应该传入的参数个数.如果函数有剩余参数,则这个剩余形参不包含在length属性中.
function a(a,b){}
a.length // 2
函数有一个只读的name属性,表示定义函数时使用的名字(如果是用名字定义的),如果是未命名的函数,表示在第一次创建这个函数时赋给该函数的变量名或属性名.这个属性主要用于记录调试或排错消息.
除了箭头函数,所有函数都有一个prototype属性,这个属性,引用一个被称为原型对象的对象.每个函数都有自己的原型对象.当函数被作为构造函数使用时,新创建的对象从这个原型对象继承属性.原型和prototype属性在6.2.3节讨论,第九章还会再次介绍.
call()和apply()允许简洁调用(参见8.2.4节)一个函数,就像这个函数是某个其他对象的方法一样.call()和apply()的第一个参数都是要在其上调用这个函数的对象,也就是函数的调用上下文,在函数体内他会变成this关键字的值.要把函数f()作为对象o的方法进行调用(不传递参数),可以使用call()或apply():
f.call(o);
f.apply(o);
这两行代码都类似于下面的代码(假设o并没有属性m):
o.m =f ;// 把f作为o的一个临时方法
o.m()// 调用他,不传参数
delete o.m// 删除这个临时方法
我们知道,箭头函数从定义他的上下文继承this值,这个this值不能通过call()和apply()重写.如果对箭头函数调用这两个方法,那第一个参数实际上会被忽略.
除了作为调用上下文传给call()的第一参数,后续的所有参数都会传给被调用的函数(调用箭头函数时不会忽略这些参数).比如,要将函数f()作为对象o的方法进行调用,并同时给函数f()传递两个参数,可以这样写:
f.call(o,1,2)
apply()方法与call()类似,只不过要传给函数的参数需要以数组的形式提供:
f.apply(o,[1,2])
如果函数定义时可以接收多个参数,则使用apply()方法可以在调用这个函数时把任意长度的数组内容传给他.在ES6及之后的版本中可以使用扩展操作符,但我们也有可能看到使用apply()的ES5代码.例如,在不使用拓展操作符的情况下,想找到一个数值数组中的最大值,可以使用apply()方法把数组的元素传给Math.max()函数:
let bigger = Math.max.apply(Math,arrayOfNUmbers);
下面定义的trace()函数与8.3.4节定义的timed()函数类似,但trace()函数操作的是方法而不函数,他使用了apply()方法而不是扩展操作符,这样一来,就可以用于包装方法(新方法)相同的参数和this值调用被包装的方法(原始方法):
// 将新对象o的方法m替换成了另一个版本
// 新版本在调用原始方法前,后会打印日志
function trace(o,m){
let original = o[m];// 在闭包中记住原始方法
o[m] = function(...args){//定义新方法
console.log(new Date(),"Enterign:",m);// 打印消息
let result = original.apply(this,args);// 调用原始方法
console.log(new Date(),"Exiting:",m);// 打印消息
return result;// 返回结果
}
}
let a = {name:"test",you(num){
console.log("啊啊噶",num);
return num
}}
// 加工这个方法
trace(a,"you")
// 调用加工后的代码
a.you()
// Thu Mar 16 2023 10:40:30 GMT+0800 (中国标准时间) // 'Enterign:' 'you'
//啊啊噶 undefined
//Thu Mar 16 2023 10:40:30 GMT+0800 (中国标准时间) 'Exiting:' 'you'
bind()方法的主要目的是把函数绑定到对象.如果在函数f上调用bind()方法传入对象o,则这个的方法会返回一个新函数.如果作为函数来调用这个新函数,就会像f是o的对象的方法一样调用原始函数.传给这个新函数的所有参数都会传给原始函数.例如:
function f(y){
return this.x + y;
}// 这个函数需要绑定
let o = {x:1};// 要绑定的对象
let g = f.bind(o);// 调用g(x)会在o上调用f()
g(2) // 3
let p = {x:10,g};// 作为这个对象的方法调用g()
p.g(2) // 3 g仍然绑定到o上,而非p
箭头函数从定义他们的环境中继承this值(上下文),且这个值不能被bind()覆盖,因此如果前面代码中的函数f()是以箭头函数定义,则绑定不会起作用.不过,由于调用bind()最常见的目的是让非箭头函数变得像箭头函数,因此这个关于绑定箭头函数的限制在实践中通常不是问题.
事实上,除了把函数绑定到对象,bind()方法还会做其他事情,比如,bind()也可以执行"部分应用",即在第一个参数之后传给bind()的参数也会随着this值一起被绑定.部分应用是函数式编程中的一个常用技术,有时候也被称为柯里化(currying),下面是几个使用bind()方法实现部分应用的例子:
let sum = (x,y) => x,y;// 返回2个参数之和
let succ = sum.bind(null,1)// 把第一个参数绑定为1
succ(2) // 3 x绑定到1,2会传给参数y
function f(y,z){return this.x + y +z}
let g = f.bind({x:1},2);// 绑定this和y
g(3) // 6 this.x绑定到1,y绑定到2,z是3
bind()返回函数的name属性由单词"bound"和调用bind()的函数的name属性构成.
与所有js
对象一样,函数也有toString()方法,ECAMScript规范要求这个方法返回一个符合函数声明语句的字符串.实践中,这个方法的多数(不是全部)实现都返回 函数完整的源代码.内置函数返回的字符串中通常包含[“native code”],表示函数体
因为函数是对象,所以就有一个Function()构造函数可以用来创建新函数:
const f = new Function("x","y","return x*y");
这行代码创建了一个新函数,差不多相当于使用如下函数语法
const f = function(x,y){return x*y}
Function()构造函数可以接受任意多个字符参数,其中最后一个参数是函数体的文本.这个函数体文本中可以包含任意js
语句,相互以分号分隔传给这个构造函数的其他字符串都用于指定新函数的参数名.如果新函数没有参数,可以只给构造函数传一个字符串(也就是函数体)/
注意,Functioon()构造函数不接受任何指定新函数名字的参数.与函数字面量一样Function()构造函数创建的也是匿名函数.
要理解Function()构造函数,需要理解一下几点:
Function()函数允许在运行时动态创建和编译js
函数
Function()构造函数每次被调用时都会解析函数体并创建一个新函数对象.如果在循环中或者被频繁调用的函数中出现了对他的调用,可能会影响程序性能.相对而言,出现在循环体中的嵌套函数和函数表达式不会每次都被重新编译.
最后,也是关于Function()非常重要的一点,就是他创建的函数不使用词法作用域,而是始终编译为如同顶级函数一样,如下
let scope = "global"
function constuctFunction(){
let scope = "local"
return new Function("return scope")// 不会捕获局部作用域
}
// 这行代码返回"global",因为"Function"
// 构造函数返回的函数不使用局部作用域
constructFunction()()// "global"
最好将Function()构造函数作为在自己私有作用域中定义新变量好函数的eval()(参见4.12.2节)的全局作用域版本,我们自己写的代码中可能用不到这个构造函数.
js
并不是Lisp或Haskell那样的函数式编程语言,但js
可以把函数作为对象来操作意味着可以在js
中使用函数式编程技巧.像map()和reduce()这样的数组方法就特别适合函数式边城风格.接下来几节将介绍js
中的函数式编程技巧,其中的内容并不是想告诉到家这些技巧是推荐的编程风格,只是想一探一下js
函数的强大功能
假设有一个数值数组,我们希望计算这些数值的平均值和标准差.如果使用非函数风格的代码,可能会这样写:
let data = [1,1,3,5,5];// 这是数值数组
// 平均值等于所有元素之和除以元素个数
let total = 0;
for(let i=0;i
而使用数组方法map()和reduce()(这两个方法的介绍参考7.8.1节),可像下面这样简洁的函数式风格实现同样的代码:
// 首先,定义两个简单的函数
const sum = (x,y)=>x+y
const square = x => x*y
// 然后,使用数组方法计算平均值和标准差
let data =[1,1,3,5,5];
let mean = data.reduce(sum)/data.length;// 3
let deviations = data.map(x=>x-mean);
let stddev = Math.sqrt(deviations.map(square).reduce(sum)/(data.length-1))
新版本的代码看起来与第一版本差别很大,但仍然调用对象上的方法,因此还可以看出一些面向对的痕迹.下面我们来定义map()和reduce()方法的函数版:
const map = function(a,...args){return a.map(...args)};
const reduce = function(a,...args){return a.reduce(...args);};
定义了map()和reduce()函数之后,计算平均值和标准差的代码就变成了这样
const sum = (x,y)=>x+y;
const square = x =>x+y;
let data = [1,1,3,5,5];
let mean = reduce(data,sum)/data.length
let deviations = map(data,x=>x-mean);
let stddev = Math.sqrt(
reduce(
map(deviations,square)
,sum)/(data.length-1)
)
)
stddev // 2
高级函数就是操作函数的函数,他接受一个或多个函数作为函数并**返回一个新函数.**例如:
// 这个高阶函数返回一个新函数
function not(f){
return function(...args){
let result = f.apply(this,args);// 新函数调用f
return !result;// 对结果求逻辑非
}
}
const even = x => x%2 === 0;// 确定数值是不是偶数
const odd = not(even) //确定数值是不是计数的新函数
[1,1,3,5,5].every(odd) // true 数组的所有元素都是奇数
这个not()函数就是一个高阶函数,因为他接受一个函数参数并返回一个新函数.再比如下面的mapper()函数.这个函数接受一个函数并返回一个新函数,新函数使用传入的函数把一个数组映射为另一个数组.这个函数使用了前面定义的map()函数,理解这两个函数之间的区别非常重要:
// 返回一个函数,这个函数接受一个数组并对
// 每个元素应用f,返回每个返回值的数组
// 比较一下这个函数与前面的map()函数
function mapper(f){
return a => map(a,f);
}
const increment = x => x+1;
const incrementAll = mapper(increment);
incrementAll([1,2,3]) // [2,3,4]
下面是一个更通用的例子,这个高阶函数接受两个函数f和g,返回一个计算f(g())的新函数:
// 返回一个计算f(g(...))的新函数
// 返回的函数h会把他接受的所有参数传给g,
// 在把g的返回值传给f,然后返回f的返回值
// f和g被调用时都使用与h被调用时相同的this值
function compose(f,g){
return function(...args){
// 这里对f使用call是因为只给他传一个值
// 而对g使用apply是因为正在传一个值的数组
return f.call(this,g.apply(this,args));
}
}
const sum = (x,y)=>x+y;
const square = x=>x*x;;
compose(square,sum)(2,3) // 25,平方和
后面两节中定义的partial()和memoize()函数是两个更重要的高阶函数.
函数f的bind()方法(参见8.7.5节)返回一个新函数,这个新函数在指定的上下文中以指定的参数调用f.我们说他把这个函数绑定到了一个对象并部分应用了参数,bind()方法在左侧部分应用参数,即传给bind()的参数会放在传给原始函数的参数列表的开头.但是也有可能在右侧部分应用参数:
// 传给这个函数的参数会传到左侧
function partialLeft(f,...outerArgs){
return function(...innerArgs){// 返回这个函数
let args = [...outerArgs,...innerArgs];// 构建参数列表
return f.apply(this,args);// 然后通过它调用f
}
}
// 传给这个函数的参数会传到右侧
function partialRight(f,...outerArgs){
return function(...innerArgs){// 返回这个函数
let args = [...innerArgs,...outerArgs];// 构建参数列表
return f.apply(this,args);// 然后通过它调用f
}
}
// 这个函数的参数列表作为一个模板,这个参数列表中的undefined值
// 会被来自内部参数列表的值填充
function partial(f,...outerArgs){
// 3,4
return function(...innerArgs)[
// undefined,2
let args = [...outerArgs];// 外部参数模板的局部副本
let innerIndex=0;// 下一个是那个内部参数
// 循环遍历args,用内部参数填充undefined的值
for(let i=0;i
这些部分应用函数允许在已经定义的函数基础上轻松定义有意思的函数.例如,下面的代码通过组合与部分应用定义not()函数:
const not = paritlLeft(compose,x=>!x);
const even = x=> x%2 === 0;
const odd = not(even)
const isNumber = not(isNaN);
odd(3)&&isNumber(2)// ture
还可以使用组合和部分应用以函数方式重新计算平均值和标准差:
// sum()和square()函数在前面定义过,下面是更多函数
const product = (x,y)=>x*y;
const neg = partial(product,-1);
const sqrt = partial(Math.pow,undefined,.5);
const reciprocal = partial(Math.pow,undefined,neg(1));
// 现在计算平均值和标准差
let data = [1,1,3,5,5];// 数据
let mean = product(reduce(data,sum),reciprocal(data.length));
let stddev = sqrt(product(reduce(map(data,compose(square,parital(sum,neg(mean)))),sum),reciprocal(sum(data.lenght,neg(1)))))
[mean,stddev] // [3,2]
注意这里计算平均值和标准差的代码完全是函数调用,没有操作符,圆括号的数量已经多到让js
看起来Lisp了,同样这不是推荐的js
编程风格.但通过这个例子我们可以知道js
中的函数如何实现多层嵌套.
在8.4.1节,我们定义了一个缓存自己之前结果的阶乘函数.在函数式编程中,这种缓存被称为函数记忆(memoization).下面的代码展示了高阶函数memoize()可以接受一个函数参数,然后返回这个函数的记忆版:
// 返回f的记忆版
// 只适用于f的参数都有完全不同的字符表示的情况
function memoize(f){
const cache = new Map();//cache保存在这个闭包中
return function(..args){
// 创建参数的字符串版,用作缓存键
let key = args.length + args.join("+");
if(cache.has(key)){
return ache.get(key);
}else{
let result = f.apply(this,args);
cache.set(key,result);
return result;
}
}
}
这个memoize()函数创建了一个新对象作为缓存使用,并将这个对象赋值给一个局部变量,从而让它(在闭包中)成为被返回的函数的私有变量.返回的函数将其参数数组转换为字符串,并使用该字符串作为缓存对象的属性.如果缓存存在某个值,就直接返回该值;否则,就调用指定的函数计算这些参数的值,然后缓存这个值,最后返回这个值,下面是使用这个memoize()的例子:
// 使用欧几里得算法返回了两个整数的最大公约数:
function gcd(a,b){
if(a