前端知识汇总【ES系列之6函数的拓展、箭头函数】

转自:https://gitee.com/hongjilin/hongs-study-notes/tree/master

函数的拓展

对于JS来说函数部分是重中之重的基础,相对而言篇幅占比也会较大

Ⅰ- 概括总结

Ⅰ- 参数默认值: 为函数参数指定默认值

  • 形式: function Func(x = 1, y = 2) {}
  • 参数赋值: 惰性求值(函数调用后才求值)
  • 参数位置: 尾参数
  • 参数作用域: 函数作用域
  • 声明方式: 默认声明, 不能用constlet再次声明
  • length: 返回没有指定默认值的参数个数
  • 与解构赋值默认值结合: function Func({ x = 1, y = 2 } = {}) {}
  • 应用
    1. 指定某个参数不得省略, 省略即抛出错误: function Func(x = throwMissing()) {}
    2. 将参数默认值设为 undefined , 表明此参数可省略: Func(undefined, 1)

Ⅱ - 箭头函数(=>): 函数简写 -->重点

  • 无参数: () => {}
  • 单个参数: x => {}
  • 多个参数: (x, y) => {}
  • 解构参数: ({x, y}) => {}
  • 嵌套使用: ** 部署管道机制 ** -->不懂的详见下方
  • this指向固定化
    • 并非因为内部有绑定 [ this ] 的机制, 而是根本没有自己的 [ this ] , 导致内部的 [ this ] 就是外层代码块的 [ this ]
    • 因为没有 [ this ] , 因此不能用作构造函数

Ⅲ - rest/spread参数(…): 返回函数多余参数

  • 形式: 以数组的形式存在, 之后不能再有其他参数
  • 作用: 代替Arguments对象
  • length: 返回没有指定默认值的参数个数但不包括rest/spread参数

Ⅳ - 严格模式: 在严格条件下运行JS

  • 应用: 只要函数参数使用默认值、解构赋值、扩展运算符, 那么函数内部就不能显式设定为严格模式

Ⅴ - name属性: 返回函数的函数名

  • 将匿名函数赋值给变量: 空字符串(ES5)、变量名(ES6)
  • 将具名函数赋值给变量: 函数名(ES5和ES6)
  • bind返回的函数: bound 函数名(ES5和ES6)
  • Function构造函数返回的函数实例: anonymous(ES5和ES6)

Ⅵ - 尾调用优化: 只保留内层函数的调用帧

  • 尾调用
    • 定义: 某个函数的最后一步是调用另一个函数
    • 形式: function f(x) { return g(x); }
  • 尾递归
    • 定义: 函数尾调用自身
    • 作用: 只要使用尾递归就不会发生栈溢出, 相对节省内存
    • 实现: 把所有用到的内部变量改写成函数的参数并使用参数默认值

Ⅶ - 箭头函数常见误区的正解

  1. 函数体内的 [ this ] 是定义时所在的对象而不是使用时所在的对象
  2. 可让 [ this ] 指向固定化, 这种特性很有利于封装回调函数
  3. 不可当作构造函数, 因此箭头函数不可使用new命令
  4. 不可使用yield命令, 因此箭头函数不能用作Generator函数
  5. 不可使用Arguments对象, 此对象在函数体内不存在(可用rest/spread参数代替)
  6. 返回对象时必须在对象外面加上括号

Ⅱ - 函数参数的默认值

① 基本用法

ES6 之前, 不能直接为函数的参数指定默认值, 只能采用变通的方法.

function log(x, y) {
 y = y || 'World'; //[或],当y为undefined时,将其赋值
 console.log(x, y);
}
log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello World  -->参数`y`等于空字符, 结果被改为默认值

上面代码检查函数log的参数y有没有赋值, 如果没有, 则指定默认值为World. 这种写法的缺点在于, 如果参数y赋值了, 但是对应的布尔值为false, 则该赋值不起作用. 就像上面代码的最后一行, 参数y等于空字符, 结果被改为默认值.

为了避免这个问题, 通常需要先判断一下参数y是否被赋值, 如果没有, 再等于默认值.

if (typeof y === 'undefined') {  y = 'World'; }

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

function log(x, y = 'World') {  console.log(x, y);}
log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello

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

function Point(x = 0, y = 0) {
 this.x = x;
 this.y = y;
}
const p = new Point();
p // { x: 0, y: 0 }

除了简洁 , ES6 的写法还有两个好处:

  • 首先, 阅读代码的人, 可以立刻意识到哪些参数是可以省略的, 不用查看函数体或文档;
  • 其次, 有利于将来的代码优化, 即使未来的版本在对外接口中, 彻底拿掉这个参数, 也不会导致以前的代码无法运行.

参数变量是默认声明的, 所以不能用letconst再次声明,否则会报错.

function foo(x = 5) {
 let x = 1; // error
 const x = 2; // error
}

使用参数默认值时, 函数不能有同名参数.

// 不报错
function foo(x, x, y) {}

// 报错
function foo(x, x, y = 1) {}
// SyntaxError: Duplicate parameter name not allowed in this context

另外, 一个容易忽略的地方是, 参数默认值不是传值的, 而是每次都重新计算默认值表达式的值. 也就是说, 参数默认值是惰性求值的.

let x = 99;
function foo(p = x + 1) { console.log(p);}
foo() // 100
foo() // 100
x = 100;
foo() // 101

上面代码中, 参数p的默认值是x + 1. 这时, 每次调用函数foo, 都会重新计算x + 1, 而不是默认p等于 100.

② 与解构赋值默认值结合使用

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

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: Cannot read property 'x' of undefined

上面代码只使用了对象的解构赋值默认值, 没有使用函数参数的默认值. 只有当函数foo的参数是一个对象时, 变量xy才会通过解构赋值生成. 如果函数foo调用时没提供参数, 变量xy就不会生成, 从而报错. 通过提供函数参数的默认值, 就可以避免这种情况.

function foo({x, y = 5} = {}){console.log(x, y);}
foo() // undefined 5

上面代码指定, 如果没有提供参数, 函数foo的参数默认为一个空对象.

下面是另一个解构赋值默认值的栗子.

function fetch(url, { body = '', method = 'GET', headers = {} }) {
console.log(method);
 }

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

fetch('http://example.com')
// 报错

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

function fetch(url, { body = '', method = 'GET', headers = {} } = {}) {
console.log(method);
 }

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

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

作为练习, 请问下面两种写法有什么差别?

// 写法一
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]
③ 参数默认值的位置

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

// 例一
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, z];}

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

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

如果传入 undefined , 将触发该参数等于默认值, null 则没有这个效果.

function foo(x = 5, y = 6) { console.log(x, y); }
foo(undefined, null)
// 5 null

上面代码中, x参数对应 undefined , 结果触发了默认值, y参数等于 null , 就没有触发默认值.

④ 函数的 length 属性

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

(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 ]属性.

(function(...args) {}).length // 0

如果设置了默认值的参数不是尾参数, 那么[ length ]属性也不再计入后面的参数了.

(function (a = 0, b, c) {}).length // 0
(function (a, b = 1, c) {}).length // 1
⑤ 作用域

一旦设置了参数的默认值, 函数进行声明初始化时, 参数会形成一个单独的作用域(context). 等到初始化结束, 这个作用域就会消失. 这种语法行为, 在不设置参数默认值时, 是不会出现的.

var x = 1;
function f(x, y = x) { console.log(y); }
f(2) // 2

上面代码中, 参数y的默认值等于变量x. 调用函数 [ f ] 时, 参数形成一个单独的作用域. 在这个作用域里面, 默认值变量x指向第一个参数x, 而不是全局变量x, 所以输出是2.

再看下面的栗子.

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

上面代码中, 函数 [ f ] 调用时, 参数y = x形成一个单独的作用域. 这个作用域里面, 变量x本身没有定义, 所以指向外层的全局变量x. 函数调用时, 函数体内部的局部变量x影响不到默认值变量x.

如果此时, 全局变量x不存在, 就会报错.

function f(y = x) {
 let x = 2;
 console.log(y);
}
f() // ReferenceError: x is not defined

下面这样写, 也会报错.

var x = 1;
function foo(x = x) {
 // ...
}
foo() // ReferenceError: x is not defined

上面代码中, 参数x = x形成一个单独作用域. 实际执行的是let x = x, 由于暂时性死区的原因, 这行代码会报错”x 未定义“.

如果参数的默认值是一个函数, 该函数的作用域也遵守这个规则. 请看下面的栗子.

let foo = 'outer';

function bar(func = () => foo) {
 let foo = 'inner';
 console.log(func());
}

bar(); // outer

上面代码中, 函数bar的参数func的默认值是一个匿名函数, 返回值为变量foo. 函数参数形成的单独作用域里面, 并没有定义变量foo, 所以foo指向外层的全局变量foo, 因此输出outer.

如果写成下面这样, 就会报错.

function bar(func = () => foo) {
 let foo = 'inner';
 console.log(func());
}

bar() // ReferenceError: foo is not defined

上面代码中, 匿名函数里面的foo指向函数外层, 但是函数外层并没有声明变量foo, 所以就报错了.

下面是一个更复杂的栗子.

var x = 1;
function foo(x, y = function() { x = 2; }) {
 var x = 3;
 y();
 console.log(x);
}

foo() // 3
//x == 1

上面代码中, 函数foo的参数形成一个单独作用域. 这个作用域里面, 首先声明了变量x, 然后声明了变量y, y的默认值是一个匿名函数. 这个匿名函数内部的变量x, 指向同一个作用域的第一个参数x. 函数foo内部又声明了一个内部变量x, 该变量与第一个参数x由于不是同一个作用域, 所以不是同一个变量, 因此执行y后, 内部变量x和外部全局变量x的值都没变.

如果将var x = 3var去除, 函数foo的内部变量x就指向第一个参数x, 与匿名函数内部的x是一致的, 所以最后输出的就是2, 而外层的全局变量x依然不受影响.

var x = 1;
function foo(x, y = function() { x = 2; }) {
 x = 3;
 y();
 console.log(x);
}

foo() // 2
//x== 1
⑥ 应用

利用参数默认值, 可以指定某一个参数不得省略, 如果省略就抛出一个错误.

function throwIfMissing() { throw new Error('Missing parameter'); }

function foo(mustBeProvided = throwIfMissing()) {  return mustBeProvided; }

foo()
// Error: Missing parameter

上面代码的foo函数, 如果调用的时候没有参数, 就会调用默认值throwIfMissing函数, 从而抛出一个错误.

从上面代码还可以看到, 参数mustBeProvided的默认值等于throwIfMissing函数的运行结果(注意函数名throwIfMissing之后有一对圆括号), 这表明参数的默认值不是在定义时执行, 而是在运行时执行. 如果参数已经赋值, 默认值中的函数就不会运行.

另外, 可以将参数默认值设为 undefined , 表明这个参数是可以省略的.

function foo(optional = undefined) { ··· }

Ⅲ - 箭头函数 (重点)

ES6最常见用法,这个必须要会

① 基本用法

ES6 允许使用“箭头”(=>)定义函数.

var f = v => v;

// 等同于
var f = function (v) {
 return v;
};

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

var f = () => 5;
// 等同于
var f = function () { return 5 };

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

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

var sum = (num1, num2) => { return num1 + num2; }

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

// 报错
let getTempItem = id => { id: id, name: "Temp" };

// 不报错
let getTempItem = id => ({ id: id, name: "Temp" });

下面是一种特殊情况, 虽然可以运行, 但会得到错误的结果.

let foo = () => { a: 1 };
foo() // undefined

上面代码中, 原始意图是返回一个对象{ a: 1 }, 但是由于引擎认为大括号是代码块, 所以执行了一行语句a: 1. 这时, a可以被解释为语句的标签, 因此实际执行的语句是1;, 然后函数就结束了, 没有返回值.

如果箭头函数只有一行语句, 且不需要返回值, 可以采用下面的写法, 就不用写大括号了.

let fn = () => void doesNotReturn();

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

const full = ({ first, last }) => first + ' ' + last;

// 等同于
function full(person) {
 return person.first + ' ' + person.last;
}

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

const isEven = n => n % 2 === 0; //类型 boolean
const square = n => n * n;  //类型 number

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

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

// 正常函数写法
[1,2,3].map(function (x) {
 return x * x;
});

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

另一个栗子是

// 正常函数写法
var result = values.sort(function (a, b) {
 return a - b;
});

// 箭头函数写法
var result = values.sort((a, b) => a - b);

下面是 rest 参数与箭头函数结合的栗子(个人觉得很好用).

const numbers = (...nums) => nums;

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

const headAndTail = (head, ...tail) => [head, tail];

headAndTail(1, 2, 3, 4, 5)
// [1,[2,3,4,5]]
② 使用注意点

箭头函数有几个使用注意点.

(1)函数体内的 [ this ] 对象, 就是定义时所在的对象, 而不是使用时所在的对象.

(2)不可以当作构造函数, 也就是说, 不可以使用new命令, 否则会抛出一个错误.

(3)不可以使用arguments对象, 该对象在函数体内不存在. 如果要用, 可以用 rest 参数代替.

(4)不可以使用yield命令, 因此箭头函数不能用作 Generator 函数. -->此类型函数在后方知识点会给出详解

以下是详解举栗

上面四点中, 第一点尤其值得注意. [this]对象的指向是可变的, 但是在箭头函数中, 它是固定的.

function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}

var id = 21;

foo.call({ id: 42 }); // id: 42

上面代码中, setTimeout()的参数是一个箭头函数, 这个箭头函数的定义生效是在foo函数生成时, 而它的真正执行要等到 100 毫秒后. 如果是普通函数, 执行时 [ this ] 应该指向全局对象window, 这时应该输出21. 但是, 箭头函数导致 [ this ] 总是指向函数定义生效时所在的对象(本例是{id: 42}), 所以打印出来的是42.

箭头函数可以让setTimeout里面的 [ this ] , 绑定定义时所在的作用域, 而不是指向运行时所在的作用域. 下面是另一个栗子.

function Timer() {
this.s1 = 0;
this.s2 = 0;
// 箭头函数
setInterval(() => this.s1++, 1000);
// 普通函数
setInterval(function () {
this.s2++;
}, 1000);
}

var timer = new Timer();

setTimeout(() => console.log('s1: ', timer.s1), 3100);
setTimeout(() => console.log('s2: ', timer.s2), 3100);
// s1: 3
// s2: 0

上面代码中, Timer函数内部设置了两个定时器, 分别使用了箭头函数和普通函数. 前者的 [ this ] 绑定定义时所在的作用域(即Timer函数), 后者的 [ this ] 指向运行时所在的作用域(即全局对象). 所以, 3100 毫秒之后, timer.s1被更新了 3 次, 而timer.s2一次都没更新.

箭头函数可以让[this指向]固定化, 这种特性很有利于封装回调函数. 下面是一个栗子 , DOM 事件的回调函数封装在一个对象里面.

var handler = {
id: '123456',

init: function() {
document.addEventListener('click',
event => this.doSomething(event.type), false);
},

doSomething: function(type) {
console.log('Handling ' + type  + ' for ' + this.id);
}
};

上面代码的init方法中, 使用了箭头函数, 这导致这个箭头函数里面的 [ this ] , 总是指向handler对象. 否则, 回调函数运行时, this.doSomething这一行会报错, 因为此时 [ this ] 指向document对象.

[ this ] 指向的固定化, 并不是因为箭头函数内部有绑定 [ this ] 的机制, 实际原因是箭头函数根本没有自己的 [ this ] , 导致内部的 [ this ] 就是外层代码块的 [ this ] . 正是因为它没有 [ this ] , 所以也就不能用作构造函数.

所以, 箭头函数转成 ES5 的代码如下.

// ES6
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}

// ES5
function foo() {
var _this = this;

setTimeout(function () {
console.log('id:', _this.id);
}, 100);
}

上面代码中, 转换后的 ES5 版本清楚地说明了, 箭头函数里面根本没有自己的 [ this ] , 而是引用外层的 [ this ] .

请问下面的代码之中有几个 [ this ] ?

function foo() {
return () => {
return () => {
return () => {
  console.log('id:', this.id);
};
};
};
}

var f = foo.call({id: 1});

var t1 = f.call({id: 2})()(); // id: 1
var t2 = f().call({id: 3})(); // id: 1
var t3 = f()().call({id: 4}); // id: 1

上面代码之中, 只有一个 [ this ] , 就是函数foo的 [ this ] , 所以t1t2t3都输出同样的结果. 因为所有的内层函数都是箭头函数, 都没有自己的 [ this ] , 它们的 [ this ] 其实都是最外层foo函数的 [ this ] .

除了 [ this ] , 以下三个变量在箭头函数之中也是不存在的, 指向外层函数的对应变量: argumentssupernew.target.

function foo() {
setTimeout(() => {
console.log('args:', arguments);
}, 100);
}

foo(2, 4, 6, 8)
// args: [2, 4, 6, 8]

上面代码中, 箭头函数内部的变量arguments, 其实是函数fooarguments变量.

另外, 由于箭头函数没有自己的 [ this ] , 所以当然也就不能用call()apply()bind()这些方法去改变 [ this ] 的指向.

(function() {
return [
(() => this.x).bind({ x: 'inner' })()
];
}).call({ x: 'outer' });
// ['outer']

上面代码中, 箭头函数没有自己的 [ this ] , 所以bind方法无效, 内部的 [ this ] 指向外部的 [ this ] .

长期以来 , JavaScript 语言的 [ this ] 对象一直是一个令人头痛的问题, 在对象方法中使用 [ this ] , 必须非常小心. 箭头函数'绑定[this]', 很大程度上解决了这个困扰.

③ 不适用场合

由于箭头函数使得 [ this ] 从“动态”变成“静态”, 下面两个场合不应该使用箭头函数.

第一个场合是定义对象的方法, 且该方法内部包括 [ this ] .

const cat = {
 lives: 9,
 jumps: () => { this.lives--;}
}

上面代码中, cat.jumps()方法是一个箭头函数, 这是错误的. 调用cat.jumps()时, 如果是普通函数, 该方法内部的 [ this ] 指向cat;如果写成上面那样的箭头函数, 使得 [ this ] 指向全局对象, 因此不会得到预期结果. 这是因为对象不构成单独的作用域, 导致jumps箭头函数定义时的作用域就是全局作用域.

第二个场合是需要动态 [ this ] 的时候, 也不应使用箭头函数.

var button = document.getElementById('press');
button.addEventListener('click', () => {
 this.classList.toggle('on');
});

上面代码运行时, 点击按钮会报错, 因为button的监听函数是一个箭头函数, 导致里面的 [ this ] 就是全局对象. 如果改成普通函数, [ this ] 就会动态指向被点击的按钮对象.

另外, 如果函数体很复杂, 有许多行, 或者函数内部有大量的读写操作, 不单纯是为了计算值, 这时也不应该使用箭头函数, 而是要使用普通函数, 这样可以提高代码可读性

④ 嵌套的箭头函数

箭头函数内部, 还可以再使用箭头函数. 下面是一个 ES5 语法的多重嵌套函数.

function insert(value) {
 return {into: function (array) {
   return {after: function (afterValue) {
     array.splice(array.indexOf(afterValue) + 1, 0, value);
     return array;
   }};
 }};
}

insert(2).into([1, 3]).after(1); //[1, 2, 3]

上面这个函数, 可以使用箭头函数改写.

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

insert(2).into([1, 3]).after(1); //[1, 2, 3]
a) 部署管道机制 (pipeline)

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

const pipeline = (...funcs) =>
 val => funcs.reduce((a, b) => b(a), val);

const plus1 = a => a + 1;
const mult2 = a => a * 2;
const addThenMult = pipeline(plus1, mult2);

addThenMult(5)
// 12

如果觉得上面的写法可读性比较差, 也可以采用下面的写法.

const plus1 = a => a + 1;
const mult2 = a => a * 2;

mult2(plus1(5))
// 12

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

// λ演算的写法
fix = λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))

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

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

b) 高阶函数

在我的理解中,实际上高阶函数本质上就与 [ 部署管道机制 ] 殊途同归,此处列出是为了更好做对比,防止以后遇到混淆

所谓高阶函数:就是一个函数就可以接收另一个函数作为参数, 或者是返回一个函数–>常见的高阶函数有map、reduce、filter、sort等

var ADD =function add(a) {
return function(b) { return a+b }
}
调用: ADD(2)(3)即可获得结果

map

map接受一个函数作为参数, 不改变原来的数组, 只是返回一个全新的数组
var arr = [1,2,3,4,5]
var arr1 = arr.map(item => item = 2)// 输出[1,1,1,1,1]

reduce

reduce也是返回一个全新的数组. reduce接受一个函数作为参数, 这个函数要有两个形参, 代表数组中的前两项 , reduce会将这个函数的结果与数组中的第三项再次组成这个函数的两个形参以此类推进行累积操作
var arr = [1,2,3,4,5]
var arr2 = arr.reduce((a,b)=> a+b)
console.log(arr2) // 15

filter

filter返回过滤后的数组. filter也接收一个函数作为参数, 这个函数将作用于数组中的每个元素, 根据该函数每次执行后返回的布尔值来保留结果, 如果是true就保留, 如果是false就过滤掉(这点与map要区分)
var arr = [1,2,3,4,5]
var arr3 = arr.filter(item => item % 2 == 0)
console.log(arr3)// [2,4]
c) 函数柯里化

此处列出是因为此知识点常与箭头函数搭配使用,而很多同学其实有在用却都不懂这个概念(大多数教程都不会刻意去普及概念),所以我觉得在此处列出,会对很多同学有所帮助,也能形成关联性更强的知识体系

截取自网上的正解图例

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GlNOK3eL-1655694899175)(ES全系列详细学习笔记中的图片/image-20210415161137977.png)]

关键就是理解柯里化, 其实可以把它理解成, 柯里化后, 将第一个参数变量存在函数里面了(闭包), 然后本来需要n个参数的函数可以变成只需要剩下的(n - 1个)参数就可以调用, 比如

let add = x => y => x + y
let add2 = add(2)
------------ 一般调用 ------------------------
//本来完成 add 这个操作, 应该是这样调用
let add = (x, y) => x + y
add(2,3)
------------- 柯里化后调用  ---------------------
// 而现在 add2 函数完成同样操作只需要一个参数, 这在函数式编程中广泛应用. 
let add = x => y => x + y
let add2 = add(2)
//详细解释一下, 就是 add2 函数 等价于 有了 x 这个闭包变量的 y => x + y 函数,并且此时 x = 2 , 所以此时调用
add2(3) === 2 + 3
d) 从 ES6 高阶箭头函数理解函数柯里化以及 [ 部署管道机制 ]
  1. 首先看到了这样的一个栗子:
let add = a => b => a + b
  1. 以上是一个很简单的相加函数, 把它转化成 ES5 的写法如下
function add(a) {
    return function(b) { return a + b }
}
var add3 = add(3) //add3表示一个指向函数的变量 可以当成函数调用名来用
add3(4) === 3 + 4 //true
  1. 再简化一下, 可以写成如下形式:
let add = function(a) {
  var param = a;
  var innerFun = function(b) { return param + b; }
  return innerFun;
}
  1. 虽然好像没什么意义, 但是很显然上述使用了闭包, 而且该函数的返回值是一个函数. 其实, 这就是高阶函数的定义: 以函数为参数或者返回值是函数的函数.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gQYMxRc3-1655694899193)(ES全系列详细学习笔记中的图片/image-20210415160945789.png)]

Ⅳ - rest 参数 (常用)

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

function add(...values) {
 let sum = 0;
 for (var val of values) {
   sum += val;
 }
 return sum;
}

add(2, 5, 3) // 10

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

下面是一个 rest 参数代替arguments变量的栗子.

// arguments变量的写法
function sortNumbers() {
 return Array.prototype.slice.call(arguments).sort();
}

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

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

arguments对象不是数组, 而是一个类似数组的对象. 所以为了使用数组的方法, 必须使用Array.prototype.slice.call先将其转为数组. rest 参数就不存在这个问题, 它就是一个真正的数组, 数组特有的方法都可以使用. 下面是一个利用 rest 参数改写数组push方法的栗子.

function push(array, ...items) {
 items.forEach(function(item) {
   array.push(item);
   console.log(item);
 });
}

var a = [];
push(a, 1, 2, 3)

注意 , rest 参数之后不能再有其他参数(即只能是最后一个参数), 否则会报错.

// 报错
function f(a, ...b, c) {
 // ...
}

函数的length属性, 不包括 rest 参数.

(function(a) {}).length  // 1
(function(...a) {}).length  // 0
(function(a, ...b) {}).length  // 1

Ⅴ - 严格模式

从 ES5 开始, 函数内部可以设定为严格模式.

function doSomething(a, b) {
 'use strict';
 // code
}

ES2016 做了一点修改, 规定只要函数参数使用了默认值、解构赋值、或者扩展运算符, 那么函数内部就不能显式设定为严格模式, 否则会报错.

// 报错
function doSomething(a, b = a) {
 'use strict';
 // code
}

// 报错
const doSomething = function ({a, b}) {
 'use strict';
 // code
};

// 报错
const doSomething = (...a) => {
 'use strict';
 // code
};

const obj = {
 // 报错
 doSomething({a, b}) {
   'use strict';
   // code
 }
};

这样规定的原因是, 函数内部的严格模式, 同时适用于函数体和函数参数. 但是, 函数执行的时候, 先执行函数参数, 然后再执行函数体. 这样就有一个不合理的地方, 只有从函数体之中, 才能知道参数是否应该以严格模式执行, 但是参数却应该先于函数体执行.

// 报错
function doSomething(value = 070) {
 'use strict';
 return value;
}

上面代码中, 参数value的默认值是八进制数070, 但是严格模式下不能用前缀0表示八进制, 所以应该报错. 但是实际上 , JavaScript 引擎会先成功执行value = 070, 然后进入函数体内部, 发现需要用严格模式执行, 这时才会报错.

虽然可以先解析函数体代码, 再执行参数代码, 但是这样无疑就增加了复杂性. 因此, 标准索性禁止了这种用法, 只要参数使用了默认值、解构赋值、或者扩展运算符, 就不能显式指定严格模式.

两种方法可以规避这种限制. 第一种是设定全局性的严格模式, 这是合法的.

'use strict';

function doSomething(a, b = a) {
 // code
}

第二种是把函数包在一个无参数的立即执行函数里面.

const doSomething = (function () {
 'use strict';
 return function(value = 42) {
   return value;
 };
}());

Ⅵ - name 属性

函数的name属性, 返回该函数的函数名.

function foo() {}
foo.name // "foo"

这个属性早就被浏览器广泛支持, 但是直到 ES6 , 才将其写入了标准.

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

var f = function () {};

// ES5
f.name // ""

// ES6
f.name // "f"

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

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

const bar = function baz() {};

// ES5
bar.name // "baz"

// ES6
bar.name // "baz"

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

(new Function).name // "anonymous"

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

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

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

Ⅶ - 尾调用优化

此处如果看不懂可以暂时跳过或者粗略看下,此部分一般情况不会用到:

尾调用优化默认关闭,各大浏览器(除了safari)根本就没部署尾调用优化;

① 什么是尾调用?

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

function f(x){
 return g(x);
}

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

以下三种情况, 都不属于尾调用.

// 情况一
function f(x){
 let y = g(x);
 return y;
}

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

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

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

function f(x){
 g(x);
 return undefined;
}

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

function f(x) {
 if (x > 0) {
   return m(x)
 }
 return n(x);
}

上面代码中, 函数mn都属于尾调用, 因为它们都是函数 [ f ] 的最后一步操作

② 尾调用优化

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

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

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

function f() {
 let m = 1;
 let n = 2;
 return g(m + n);
}
f();

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

// 等同于
g(3);

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

这就叫做[尾调用优化 (Tail call optimization)]:即只保留内层函数的调用帧. 如果所有函数都是尾调用, 那么完全可以做到每次执行时, 调用帧只有一项, 这将大大节省内存. 这就是“尾调用优化”的意义.

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

function addOne(a){
 var one = 1;
 function inner(b){
   return b + one;
 }
 return inner(a);
}

上面的函数不会进行尾调用优化, 因为内层函数inner用到了外层函数addOne的内部变量one.

③ 尾递归

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

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

function factorial(n) {
 if (n === 1) return 1;
 return n * factorial(n - 1);
}

factorial(5) // 120

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

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

function factorial(n, total) {
 if (n === 1) return total;
 return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

还有一个比较著名的栗子, 就是计算 Fibonacci 数列, 也能充分说明尾递归优化的重要性.

非尾递归的 Fibonacci 数列实现如下.

function Fibonacci (n) {
 if ( n <= 1 ) {return 1};
 return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Fibonacci(10) // 89
Fibonacci(100) // 超时
Fibonacci(500) // 超时

尾递归优化过的 Fibonacci 数列实现如下.

function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
 if( n <= 1 ) {return ac2};
 return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}

Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity

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

④ 递归函数的改写

尾递归的实现, 往往需要改写递归函数, 确保最后一步只调用自身. 做到这一点的方法, 就是把所有用到的内部变量改写成函数的参数. 比如上面的栗子, 阶乘函数 factorial 需要用到一个中间变量total, 那就把这个中间变量改写成函数的参数. 这样做的缺点就是不太直观, 第一眼很难看出来 : 为什么计算5的阶乘, 需要传入两个参数51

两个方法可以解决这个问题. 方法一是在尾递归函数之外, 再提供一个正常形式的函数.

function tailFactorial(n, total) {
 if (n === 1) return total;
 return tailFactorial(n - 1, n * total);
}

function factorial(n) { return tailFactorial(n, 1); }

factorial(5) // 120

上面代码通过一个正常形式的阶乘函数factorial, 调用尾递归函数tailFactorial, 看起来就正常多了.

函数式编程有一个概念, 叫做柯里化 (currying), 意思是将多参数的函数转换成单参数的形式. 这里也可以使用柯里化. -->不懂的看上方[④ 嵌套的箭头函数中的函数柯里化](#④ 嵌套的箭头函数 )

function currying(fn, n) {
 return function (m) {
   return fn.call(this, m, n);
 };
}

function tailFactorial(n, total) {
 if (n === 1) return total;
 return tailFactorial(n - 1, n * total);
}

const factorial = currying(tailFactorial, 1);

factorial(5) // 120

上面代码通过柯里化, 将尾递归函数tailFactorial变为只接受一个参数的factorial.

第二种方法就简单多了, 就是采用 ES6 的函数默认值.

function factorial(n, total = 1) {
 if (n === 1) return total;
 return factorial(n - 1, n * total);
}

factorial(5) // 120

上面代码中, 参数total有默认值1, 所以调用时不用提供这个值.

总结一下, 递归本质上是一种循环操作. 纯粹的函数式编程语言没有循环操作命令, 所有的循环都用递归实现, 这就是为什么尾递归对这些语言极其重要. 对于其他支持“尾调用优化”的语言(比如 Lua , ES6), 只需要知道循环可以用递归代替, 而一旦使用递归, 就最好使用尾递归.

⑤ 严格模式

ES6 的尾调用优化只在严格模式下开启, 正常模式是无效的.

这是因为在正常模式下, 函数内部有两个变量, 可以跟踪函数的调用栈.

  • func.arguments: 返回调用时函数的参数.
  • func.caller: 返回调用当前函数的那个函数.

尾调用优化发生时, 函数的调用栈会改写, 因此上面两个变量就会失真. 严格模式禁用这两个变量, 所以尾调用模式仅在严格模式下生效.

function restricted() {
 'use strict';
 restricted.caller;    // 报错
 restricted.arguments; // 报错
}
restricted();
⑥ 利用 循环 替换 尾递归 优化的实现

尾递归优化只在严格模式下生效, 那么正常模式下, 或者那些不支持该功能的环境中, 有没有办法也使用尾递归优化呢?回答是可以的, 就是自己实现尾递归优化.

它的原理非常简单. 尾递归之所以需要优化, 原因是调用栈太多, 造成溢出, 那么只要减少调用栈, 就不会溢出. 怎么做可以减少调用栈呢?就是采用 [循环] 换掉 [递归].

下面是一个正常的递归函数.

function sum(x, y) {
 if (y > 0) return sum(x + 1, y - 1);
  else  return x;
}

sum(1, 100000)
// Uncaught RangeError: Maximum call stack size exceeded(…)
// 未捕获的RangeError:最大调用堆栈大小超过(…)  

上面代码中, sum是一个递归函数, 参数x是需要累加的值, 参数y控制递归次数. 一旦指定sum递归 100000 次, 就会报错, 提示超出调用栈的最大次数.

a) 蹦床函数

蹦床函数(trampoline)可以将递归执行转为循环执行.

function trampoline(f) {
 while (f && f instanceof Function) { f = f();}
 return f;
}

上面就是蹦床函数的一个实现, 它接受一个函数f作为参数. 只要f执行后返回一个函数, 就继续执行.

注意:这里是返回一个函数, 然后执行该函数, 而不是函数里面调用函数, 这样就避免了递归执行, 从而就消除了调用栈过大的问题.

然后, 要做的就是将原来的递归函数, 改写为每一步返回另一个函数.

function sum(x, y) {
 if (y > 0)  return sum.bind(null, x + 1, y - 1);
  else return x;
}

上面代码中, sum函数的每次执行, 都会返回自身的另一个版本.

现在, 使用蹦床函数执行sum, 就不会发生调用栈溢出.

trampoline(sum(1, 100000))
// 100001
b) 真正的尾递归优化

蹦床函数并不是真正的尾递归优化, 下面的实现才是.

function tco(f) {
 var value;
 var active = false;
 var accumulated = [];

 return function accumulator() {
   accumulated.push(arguments);
   if (!active) {
     active = true;
     while (accumulated.length) {
       value = f.apply(this, accumulated.shift());
     }
     active = false;
     return value;
   }
 };
}

var sum = tco(function(x, y) {
 if (y > 0) return sum(x + 1, y - 1)
 else return x
});

sum(1, 100000)
// 100001

上面代码中, tco函数是尾递归优化的实现, 它的奥妙就在于状态变量active. 默认情况下, 这个变量是不激活的. 一旦进入尾递归优化的过程, 这个变量就激活了. 然后, 每一轮递归sum返回的都是 undefined , 所以就避免了递归执行;而accumulated数组存放每一轮sum执行的参数, 总是有值的, 这就保证了accumulator函数内部的while循环总是会执行. 这样就很巧妙地将“递归”改成了“循环”, 而后一轮的参数会取代前一轮的参数, 保证了调用栈只有一层.

⑦ 尾调用优化默认关闭

看到这想必一定很好奇, 既然尾调用优化如此高效, 为何都默认关闭了这个特性呢?答案分为两方面:

  1. ** 隐式优化问题 **: 由于引擎消除尾递归是隐式的, 函数是否符合尾调用而被消除了尾递归很难被程序员自己辨别;
  2. ** 调用栈丢失问题 **: 尾调用优化要求除掉尾调用执行时的调用堆栈, 这将导致执行流中的堆栈信息丢失.

Chrome下使用尾递归写法的方法依旧出现调用栈溢出的原因在于:

  1. 直接原因: 各大浏览器(除了safari)根本就没部署尾调用优化;
  2. 根本原因: 尾调用优化依旧有隐式优化和调用栈丢失的问题;

既然尾调用优化是默认关闭的, 是不是说尾调用没什么用了呢?

其实不然, 尾调用是函数式编程一个重要的概念, 合理的应用尾调用可以大大提高我们代码的可读性和可维护性, 相比带来的一点性能损失, 写更优雅更易读的代码更为的重要

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值