ES6——函数的扩展详解

函数的扩展详解

1、函数参数的默认值

1.1、基本用法

ES6允许为函数的参数设置默认值,即直接写在参数定义的后面。

js 复制代码
function log(x, y = 'World') {
    console.log(x, y);
}

log("Hello");//Hello World

可以看到,ES6的写法比ES5简洁许多,而且非常自然。下面是另一个例子。

js 复制代码
function Point(x = 0, y = 0) {
    this.x = x;
    this.y = y;
}

let p = new Point();
console.log(p);//Point { x: 0, y: 0 }

除了简洁,ES6的写法还有两个好处:首先,阅读代码的人可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本彻底拿掉这个参数,也不会导致以前的代码无法运行。

参数变量是默认声明的,所以不能用let或const再次声明。

js 复制代码
function foo(x = 5) {
    let x = 1;//error
    const x = 2;//error
}

上面的代码中,参数变量x是默认声明的,在函数体中不能用let或const再次声明,否则会报错。

1.2、与解构赋值默认值结合使用

参数默认值可以与解构赋值的默认值结合起来使用。

js 复制代码
function foo({x, y = 5}) {
    console.log(x, y);
}

foo({});//undefined 5
foo({x: 1});//1 5
foo({x: 1, y: 2});//1 2
foo();//typeError

上面的代码使用了对象的解构赋值默认值,而没有使用函数参数的默认值。只有当函数foo的参数是一个对象时,变量x和y才会通过解构赋值而生成。如果函数foo调用时参数不是对象,变量x和y就不会生成,从而报错。只有参数对象没有y属性时,y的默认值5才会生效。

js 复制代码
function fetch(url, {body = '', method = 'GET', headers = {}}) {
    console.log(method);
}

fetch('http://example.com', {});//GET

fetch('http://example.com');//TypeError

上面的代码中,如果函数fetch的第二个参数是一个对象,就可以为它的3个属性设置默认值。上面的写法不能省略第二个参数,如果结合函数参数的默认值,就可以省略第二个参数。这时,就出现了双重默认值。

js 复制代码
function fetch(url, {method = 'GET'} = {}) {
    console.log(method);
}

fetch('http://example.com');//GET

上面的代码中,函数fetch没有第二个参数时,函数参数的默认值就会生效,然后才是解构赋值的默认值生效,变量method取到默认值GET。

区别示例:

js 复制代码
//写法一
function m1({x = 0, y = 0} = {}) {
    return [x, y];
}

//写法二
function m2({x, y} = {x: 0, y: 0}) {
    return [x, y];
}

上面两种写法都对函数的参数设定了默认值,区别是:写法一中函数参数的默认值是空对象,但是设置了对象解构赋值的默认值;写法二中函数参数的默认值是一个有具体属性的函数,但是没有设置对象解构赋值的默认值。

js 复制代码
//写法一
function m1({x = 0, y = 0} = {}) {
    return [x, y];
}

//写法二
function m2({x, y} = {x: 0, y: 0}) {
    return [x, y];
}

//函数没有参数的情况
m1();//[0, 0]
m2();//[0, 0]

//x和y都有值的情况
m1({x: 3, y: 8});//[3, 8]
m2({x: 3, y: 8});//[3, 8]

//x有值,y无值的情况
m1({x: 3});//[3, 0]
m2({x: 3});//[3, undefined]

//x和y都没有值的情况
m1({});// [0, 0]
m2({});// [undefined, undefined]

m1({z: 3});//[0, 0]
m2({z: 3});//[undefined, undefined]

1.3、参数默认值的位置

通常情况下,定义了默认值的参数应该是函数的尾参数。因为这样比较容易看出,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是无法省略的。

js 复制代码
//示例一
function f(x = 1, y) {
    return [x, y];
}

f()// [1, undefined]
f(2)//[2, undefined]
f(, 1) //报错
f(undefined, 1)// [1, 1]

//示例二
function f(x, y = 5, z) {
    return [x, y, x];
}

f()// [undefined, 5, undefined]
f(2)//[1, 5, undefined]
f(1, ,2) //报错
f(1, undefined, 2)// [1, 5, 2]

上面的代码中,有默认值的参数都不是尾参数。这时,无法只省略该参数而不省略其后的参数,除非显式输入undefined。

1.4、函数的Iength属性

指定了默认值以后,函数的length属性将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length属性将失真。

js 复制代码
(function(a){}).length //1
(function(a = 5){}).length //0
(function(a, b, c = 5){}).length // 2

上面的代码中,length属性的返回值等于函数的参数个数减去指定了默认值的参数个数。比如,上面的最后一个函数,定义了3个参数,其中有一个参数c指定了默认值,因此length属性等于3减去1,即2。

这是因为length属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,rest参数也不会计入length属性。

js 复制代码
(function(..args){}).length //0

1.5、作用域

一个需要注意的地方是,如果参数默认值是一个变量,则该变量所处的作用域与其他变量的作用域规则是一样的,即先是当前函数的作用域,然后才是全局作用域。

js 复制代码
var x = 1;

function f(x, y = x) {
    console.log(y);
}

f(2);//2

上面的代码中,参数y的默认值等于x。调用时,由于函数作用域内部的变量x已经生成,所以y等于参数x而不是全局变量x。

如果调用时函数作用域内部的变量x没有生成,结果就会不一样。

js 复制代码
let x = 1;

function f(y = x) {
    let x = 2;
    console.log(y);
}

f();//1

上面的代码中,函数调用时y的默认值变量x尚未在函数内部生成,所以x指向全局变量。

2、rest参数

ES6引入了rest参数(形式为"...变量名"​)​,用于获取函数的多余参数,这样就不需要使用arguments对象了。rest参数搭配的变量是一个数组,该变量将多余的参数放入其中。

js 复制代码
function add(...values) {
    let sum = 0;
    for(let val of values) {
        sum += val;
    }
    return sum;
}

add(2, 5, 3);//10

以上代码中的add函数是一个求和函数,利用rest参数可以向该函数传入任意数目的参数。

下面是一个rest参数代替arguments变量的例子。

js 复制代码
//arguments变量的写法
const sortNumbers = () => 
    Array.prototype.slice.call(arguments).sort();

//rest参数的写法
const sortNumbers = (...numbers) => numbers.sort();

比较上面的两种写法可以发现,rest参数的写法更自然也更简洁。

rest参数中的变量代表一个数组,所以数组特有的方法都可以用于这个变量。下面是一个利用rest参数改写数组push方法的例子。

js 复制代码
function push(array, ...items) {
    items.forEach((item)=>{
        array.push(item);
        console.log(item);
    });
}

var a = [];
push(a, 1, 2, 3, 4);//[ 1, 2, 3, 4 ]
console.log(a);

函数的length属性不包括rest参数。

js 复制代码
console.log(push.length);//1

3、 扩展运算符

3.1、含义

扩展运算符(spread)是三个点(...)。它好比rest参数的逆运算,将一个数组转为用逗号分隔的参数序列。

js 复制代码
console.log(...[1, 2, 3]);//1 2 3
console.log(1, ...[2, 3, 4], 5);//1 2 3 4 5

该运算符主要用于函数调用。

js 复制代码
function push(array, ...items) {
    array.push(...items);
}

function add(x, y) {
    return x + y;
}

let numbers = [4, 38];
console.log(add(...numbers));//42

上面的代码中,array.push(...items)和add(...numbers)这两行都是函数的调用,它们都使用了扩展运算符。该运算符将一个数组变为参数序列。

扩展运算符与正常的函数参数可以结合使用,非常灵活。

js 复制代码
function f(v, w, x, y, z) {}
let args = [0, 1];
f(-1, ...args, 2, ...[3]);

3.2、替代数组的appIy方法

由于扩展运算符可以展开数组,所以不再需要apply方法将数组转为函数的参数了。

js 复制代码
//ES5的写法
function f(x, y, z) {}
let args = [0, 1, 2];
f.apply(null, args);

//ES6的写法
function f(x, y, z) {}
let args = [0, 1, 2];
f(...args);

下面是扩展运算符取代apply方法的一个实际的例子,应用Math.max方法简化求数组最大元素的写法。

js 复制代码
//ES5的写法
Math.max.apply(null, [14, 3, 77])

//ES6的写法
Math.max(...[14, 3, 77])

//等同于
Math.max(14, 3, 77)

上面的代码表示,由于JavaScript不提供求数组最大元素的函数,所以只能套用Math.max函数将数组转为一个参数序列,然后求最大值。有了扩展运算符以后,就可以直接用Math.max了。另一个例子是通过push函数将一个数组添加到另一个数组的尾部。

js 复制代码
//ES5的写法
var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
Array.prototype.push.apply(arr1, arr2);

//ES6的写法
let arr1 = [0, 1, 2];
let arr2 = [3, 4, 5];
arr1.push(...arr2);

上面的ES5写法中,push方法的参数不能是数组,所以只好通过apply方法变通使用push方法。有了扩展运算符,就可以直接将数组传入push方法。

3.3、扩展运算符的应用

合并数组:

js 复制代码
//ES5
[1, 2].concat(more)
//ES6
[1, 2, ...more]

与解构赋值结合:

扩展运算符可以与解构赋值结合起来用于生成数组。

js 复制代码
//ES5
a = list[0], rest = list.slice(1);
//ES6
[a, ...rest] = list;

函数返回值:

JavaScript的函数只能返回一个值,如果需要返回多个值,只能返回数组或对象。扩展运算符提供了解决这个问题的一种变通方法。

js 复制代码
let dateFields = readDateFields(database);
let d = new Date(...dateFields);

上面的代码从数据库取出一行数据,通过扩展运算符直接将其传入构造函数Date。

字符串:

扩展运算符还可以将字符串转为真正的数组。

js 复制代码
[..."hello"]
//['h', 'e', 'l', 'l', 'o']

上面的写法有一个重要的好处,那就是能够正确识别32位的Unicode字符。

类似数组的对象:

任何类似数组的对象都可以用扩展运算符转为真正的数组。

js 复制代码
let nodeList = document.querySelectorAll('div');
let array = [...nodeList];

Map和Set结构,Generator函数:

扩展运算符内部调用的是数据结构的Iterator接口,因此只要具有Iterator接口的对象,都可以使用扩展运算符,比如Map结构。

js 复制代码
let map = new Map([
    [1, 'one'],
    [2, 'two'],
    [3, 'three']
]);

let arr = [...map.keys()];
console.log(arr);//[ 1, 2, 3 ]

Generator函数运行后返回一个遍历器对象,因此也可以使用扩展运算符。

js 复制代码
let go = function*() {
    yield 1;
    yield 2;
    yield 3;
};

console.log([...go()]);//[ 1, 2, 3 ]

上面的代码中,变量go是一个Generator函数,执行后返回的是一个遍历器对象,对这个遍历器对象执行扩展运算符,就会将内部遍历得到的值转为一个数组。

4、name属性

函数的name属性返回该函数的函数名。

js 复制代码
function foo(){}
foo.name//foo

需要注意的是,ES6对这个属性的行为做出了一些修改。如果将一个匿名函数赋值给一个变量,ES5的name属性会返回空字符串,而ES6的name属性会返回实际的函数名。

js 复制代码
let func1 = function (){};

//ES5
func1.name //""

//ES6
func1.name //"func1"

上面的代码中,变量func1等于一个匿名函数,ES5和ES6的name属性返回的值不一样。

如果将一个具名函数赋值给一个变量,则ES5和ES6的name属性都返回这个具名函数原本的名字。

js 复制代码
const bar = function baz(){};

//ES5
bar.name //"baz"

//ES6
bar.name //"baz"

Function构造函数返回的函数实例,name属性的值为"anonymous"。

js 复制代码
(new Function).name //"anonymous"

bind返回的函数,name属性值会加上"bound "前缀。

js 复制代码
function foo() {}
foo.bind({}).name //"bound foo"

(function(){}.bind({}).name //"bound"

5、箭头函数

5.1、基本用法

ES6允许使用"箭头"(=>)定义函数。

js 复制代码
let f = v -> v;

上面的箭头函数等同于:

js 复制代码
let f = function(v) {
	return v;
}

如果箭头函数不需要参数或需要多个参数,就使用圆括号代表参数部分。

js 复制代码
let f = () => 5;
//等价于
let f = function() {
    return 5;
}

let sum = (num1, num2) => num1 + num2;
//等价于
let sum = function(num1, num2) {
    return num1 + num2;
}

如果箭头函数的代码块部分多于一条语句,就要使用大括号将其括起来,并使用return语句返回。

js 复制代码
let sum = (num1, num2) => {return num1 + num2;}

由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号。

js 复制代码
let getTempItem = id => ({id: id, name: "Temp"});

箭头函数可以与变量解构结合使用。

js 复制代码
const full = ({first, last}) => first + ' ' + last;
//等价于
function full(person) {
    return person.first + ' ' + person.last;
}

箭头函数使得表达更加简洁。

js 复制代码
const isEven = n => n % 2 == 0;
const square = n => n * n;

上面的代码只用了两行就定义了两个简单的工具函数。如果不用箭头函数,可能就要占用多行,而且还不如现在这样写醒目。

箭头函数的一个用处是简化回调函数。

js 复制代码
//正常函数
[1, 2, 3].map(function (x) {
    return x * x;
});

//箭头函数
[1, 2, 3].map(x => x * x);

下面是rest参数与箭头函数结合的例子。

js 复制代码
const numbers = (...nums) => nums;

numbers(1, 2, 3, 4, 5);//[ 1, 2, 3, 4, 5 ]

const headAndTail = (head, ...tail) => [head, tail];
console.log(headAndTail(1, 2, 3, 4, 5));//[ 1, [ 2, 3, 4, 5 ] ]

5.2、使用注意点

  1. 函数体内的this对象就是定义时所在的对象,而不是使用时所在的对象。
  2. 不可以当作构造函数。也就是说,不可以使用new命令,否则会抛出一个错误。
  3. 不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用rest参数代替。
  4. 不可以使用yield命令,因此箭头函数不能用作Generator函数。

5.3、嵌套的箭头函数

js 复制代码
let insert = (value) => ({into: (array) => ({after: (afterValue => {
    array.splice(array.indexOf(afterValue) + 1, 0, value);
    return array;
})})});

let x = insert(2).into([1, 3]).after(1);
console.log(x);//[ 1, 2, 3 ]

下面是一个部署管道机制(pipeline)的例子,即前一个函数的输出是后一个函数的输入。

js 复制代码
const pipeline = (...funcs) => 
    val => funcs.reduce((a, b) => b(a), val);

const plus1 = a => a + 1;
const mul2 = a => a * 2;
const addThenMult = pipeline(plus1, mul2);
console.log(addThenMult(5));//12

箭头函数还有一个功能,就是可以很方便地改写λ演算。

js 复制代码
//λ演算
fix = λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))

//ES6写法
let fix = f => (x => f(v => x(x)(v)))
               (x => f(v => x(x)(v)));

上面的两种写法几乎是一一对应的。由于λ演算对于计算机科学非常重要,这使得我们可以用ES6作为替代工具,探索计算机科学。

7、尾调用优化

7.1、什么是尾调用

尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

js 复制代码
function f(x) {
	return g(x);
}

上面的代码中,函数f的最后一步是调用函数g,这就叫尾调用。

js 复制代码
//情况一
function f(x) {
    let y = g(x);
    return  y;
}

//情况二
function f(x) {
    return g(x) + 1;
}

//情况三
function f(x){
    g(x);
}

上面的代码中,情况一是调用函数g之后还有赋值操作,所以不属于尾调用,即使语义完全一样;情况二也属于调用后还有操作,即使写在一行内;情况三等同于下面的代码。

js 复制代码
function f(x) {
	g(x);
	return undefined;
}

尾调用不一定出现在函数尾部,只要是最后一步操作即可。

js 复制代码
function f(x) {
	if(x > 0) {
		return m(x);
	}
	return n(x);
}

上面的代码中,函数m和n都属于尾调用,因为它们都是函数f的最后一步操作。

7.2、尾调用优化

尾调用之所以与其他调用不同,就在于其特殊的调用位置。

我们知道,函数调用会在内存形成一个"调用记录"​,又称"调用帧"(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到A,B的调用帧才会消失。如果函数B内部还调用了函数C,那就还有一个C的调用帧,以此类推。所有的调用帧就形成一个"调用栈"(call stack)。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,直接用内层函数的调用帧取代外层函数的即可。

js 复制代码
function f() {
    let m = 1;
    let n = 2;
    return g(m + n);
}
f();

//等同于
function f() {
    return g(3);j
}
f();

//等同于
g(3);

上面的代码中,如果函数g不是尾调用,函数f就需要保存内部变量m和n的值、g的调用位置等信息。但由于调用g之后,函数f就结束了,所以执行到最后一步,完全可以删除f(x)的调用帧,只保留g(3)的调用帧。

这就叫作"尾调用优化"(Tail Call Optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时调用帧只有一项,这将大大节省内存。这就是"尾调用优化"的意义。

注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行"尾调用优化"​。

7.3、尾递归

函数调用自身称为递归。如果尾调用自身就称为尾递归。

递归非常耗费内存,因为需要同时保存成百上千个调用帧,很容易发生"栈溢出"错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生"栈溢出"错误。

js 复制代码
function factorial(n) {
    if (n === 1)
        return 1;
    return n * factorial(n - 1);
}
console.log(factorial(5));//120

上面的代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度为O(n)。

如果改写成尾递归,只保留一个调用记录,则复杂度为O(1)。

js 复制代码
function factorial(n, total) {
    if (n === 1)
        return total;
    return factorial(n - 1, n * total);
}

console.log(factorial(5, 1));//120

由此可见,​"尾调用优化"对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6也是如此,第一次明确规定,所有ECMAScript的实现,都必须部署"尾调用优化"​。这就是说,在ES6中,只要使用尾递归,就不会发生栈溢出,相对节省内存。

注意,只有开启严格模式,尾调用优化才会生效。一旦启用尾调用优化,func.arguments和func.caller这两个函数内部对象就失去意义了,因为外层的帧会被整个替换掉,这两个对象包含的信息会被移除。严格模式下,这两个对象也是不可用的。

js 复制代码
function restricted() {
    'use strict';
    restricted.caller;//报错
    restricted.arguments;//报错
}
restricted();
相关推荐
有趣的老凌2 小时前
一篇文章带你了解 Agent Skills —— 告别AI“失控”
前端·agent·claude
~ rainbow~2 小时前
前端转型全栈(二)——NestJS 入门指南:从 Angular 开发者视角理解后端架构
前端·javascript·angular.js
恋猫de小郭2 小时前
AGP 9.2 开始,Android 上协程启动和取消速度提升两倍
android·前端·flutter
Ulyanov2 小时前
Python与YAML的优雅交响:从配置管理到数据艺术的完美实践 (一)
开发语言·前端·python·数据可视化
SuperEugene2 小时前
Python 函数与模块化:前端工程化思维完全通用| 基础篇
前端·python·状态模式
萧行之3 小时前
Ubuntu Node.js 版本管理工具 n 完整安装与使用教程
linux·前端
IT 行者9 小时前
Web逆向工程AI工具:JSHook MCP,80+专业工具让Claude变JS逆向大师
开发语言·javascript·ecmascript·逆向
devlei9 小时前
从源码泄露看AI Agent未来:深度对比Claude Code原生实现与OpenClaw开源方案
android·前端·后端