ES6深入学习(二)关于函数

990 阅读14分钟

一、函数参数的默认值

ES6之前,不能直接为函数的参数指定默认值,只能采用变通的方法。对于函数的命名参数如果不显示传值则默认为undefined

function foo(x,y) {
    x = x || 'Hello';
    y = y || 'World';
    console.log(x,y)
}
foo(0,'Thea');//Hello Thea

这种写法的确定在于,如果参数x或者y赋值了,但是对应的布尔值为false,则该赋值不起作用。这种情况下更安全的选择是通过typeof检查参数类型:

function foo(x,y) {
    debugger
    x = (typeof x !== 'undefined') ? x : 'Hello';
    y = (typeof y !== 'undefined') ? y : 'World';
    console.log(x,y);
}
foo(false,'Thea');//false "Thea"
  • 1.1 ES6中的默认参数值

ES6简化了为形参提供默认值的过程,如果没为参数传入值则提供一个初始值:

function foo(x,y='World'){
    console.log(x,y)
}
foo('I love')//I love World

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

  • 1.2值得注意:

1、参数变量是默认声明的,函数体中不能用let或const再次声明,否则报错

function foo(x,y) {
    let x = 'Hello';//Uncaught SyntaxError
}

2、使用参数默认值时,函数不能有同名参数。

 function foo(x,x,y=3) {
     //...
 }
 //Uncaught SyntaxError: Duplicate parameter name not allowed in this context

3、参数默认值不是传值的,而是每次都重新计算默认值的表达式的值(参数默认值是惰性求值的)初次解析函数声明时不会计算默认值的表达式值,只有当调用foo()函数且不传参数时才会调用。

 let x = 3;
 function foo(,p = x + 1){
     console.log(p)
 }
 foo()//4
 x = 5;
 foo()//6

参数p的默认值是x+1,每次调用函数foo都会重新计算x+1,而不是默认等于4。 正因为默认参数是在函数调用时求值,所以可以使用先定义的参数作为后定义参数的默认值

function foo(x,y=x) {
    return x+y
}
foo(1) //2

4、参数默认值的位置

通常情况下,定义了默认值的参数应该是函数的尾参数。因为这样容易看出到底省略了哪些参数,实际上如果非尾部的参数设置默认值,这个参数是没法省略的,(除非不为其后参数传入值或主动为第二个参数传入undefined才会使用这个默认参数)null没有这个效果,因为null是一个合法值。

  • 1.3函数的lengh属性

默认参数值对arguments对象的影响。 在ES5非严格模式下,函数命名参数的变化会体现在argumnets对象中;

function foo(x,y) {
    console.log(x === arguments[0]); // true
    y = 3;
    console.log(y === arguments[1]); // true
}
foo.length //2

在非严格模式下,命名参数的变化会同步更新到arguments对象中,所以x,y被赋予新值时,最终===全等比较的结果为true。然而在ECMAScript5的严格模式下,取消了arguments随之改变的行为。无论参数如何改变,arguments对象不再随之改变。

在ES6中,如果一个函数使用了默认参数值,无论是否显示定义了严格模式,arguments对象的行为都将与ES5严格模式下保持一致。

function foo(x,y=1){
    console.log(arguments.length);
    console.log(x === arguments[0]);
    console.log(y === arguments[1]);
}
foo(1);//1,true,false
foo(1,2);//2,true,true
foo.length;//1

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

  • 1.4作用域

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

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

参数y的默认值等于变量x,调用函数foo时,参数形成一个单独的作用域,在这个作用域里,默认值变量x指向第一个参数x,而不是全局变量x。

const x = 3;
function foo(y = x) {
    let x = 2;
    console.log(y)
}
foo();//3

调用函数foo时,参数y=x形成一个单独的作用域,这个作用域里x没定义,所以指向外层的全局变量x。函数体内部的局部变量x不会形象默认值变量x。如果此时全局变量x不存在,就会报错。

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

上述代码中x=x形成一个单独的作用域,实际执行的是 let x = x,由于临时死区(与let的行为类似)原因。

若参数的默认值是一个函数,该函数的作用域也遵循这个规则:

let z = 'z-outer';
function foo(func = () => z) {
    let z = 'z-inner';
    console.log(func());
}
foo()//z-outer   z指向外层全局变量z

请看下面一个复杂的例子:

var d = 'd-outer';
function foo(d,y=() => {d = 'd-argumnets';}){
    var d = 'd-inner';
    y();
    console.log(d);
}
foo()//d-inner
d; // d-outer

上述代码中,函数foo的参数形成一个单独作用域,这个作用域里先声明了变量d,然后声明了一个默认值是匿名函数的y。匿名函数内部的变量d指向该参数作用域里的第一个参数d。函数foo内部又声明了一个内部的变量d,这个d与参数作用域里的d不是同一个变量,所以执行匿名函数y后,函数foo内部的d以及全局变量d的值都没有改变。

如果将函数foo内部的var d = 'd-inner';的var 去掉,那么函数foo内部的d就指向第一个参数d,与匿名函数内部的d同一个参数,外层全局变量d仍不受影响。

var d = 'd-outer';
function foo(d,y=() => {d = 'd-argumnets';}){
    d = 'd-inner';
    y();
    console.log(d);
}
foo()//d-argumnets
d; // d-outer

函数参数有自己的作用域和临时死区,其与函数体的作用域是各自独立的,也就是说参数的默认值不可访问函数体内声明的变量。

二、处理无命名参数

JavaScript的函数语法规定,无论函数已定义的命名参数有多少,都不限制调用时传入的实际参数数量,调用时总可以传入任意数量的参数。

ES6引入不定参数,在函数的命名参数前添加三个点(...)就表名这是一个不定参数,rest参数,用于获取函数的多余参数,该参数为一个数组,包含着自它之后传入的所有参数,通过这个数组名即可逐一访问里面的参数。rest参数之后不能再有其他参数,只能作为最后一个参数,且每个函数最多只能声明一个不定参数,否则会报错函数的length也不包括rest参数。

function foo(...arrs) {
    for(let val of arrs){
        console.log(val)
    }
}
foo(1,2,3);//1,2,3

知识点扩展:

for...of 语句创建一个循环来迭代可迭代的对象。在 ES6 中引入的 for...of 循环,以替代 for...in 和 forEach() ,并支持新的迭代协议。for...of 允许你遍历 Arrays(数组), Strings(字符串), Maps(映射), Sets(集合)等可迭代的数据结构等。

用法:

for (variable of iterable) {
    statements
}

variable:每个迭代的属性值被分配给该变量。 iterable:一个具有可枚举属性并且可以迭代的对象。

不定参数的设计初衷是代替JavaScript的arguments对象,起初在ECMAScript4草案中,arguments对象被移除并添加了不定参数的特性,从而可以传入不限制数量的参数,但ECMAScript4从未被标准化,这个想法被搁置下来,直到重新引入了ES6标准,唯一的区别是arguments对象依然存在。如果声明函数时定义了不定参数,则在函数被调用时,arguments对象包含了所有传入函数的参数。

function foo(a,...b){
    console.log(b.length);
    console.log(arguments.length);
}
foo.length;//1
foo(1,2,3,4);//3,4
  • 严格模式

从ES5开始,函数内部可以设定为严格模式。ES6做了一点修改,规定只要函数参数使用了默认值、解构赋值或者扩展运算符,那么函数就不能显式设定为严格模式,否则会报错。这样规定的原因是,函数内部的严格模式,同时适用于函数体和函数参数。但是,函数执行的时候,先执行函数参数,然后再执行函数体。这样就有一个不合理的地方,只有从函数体之中,才能知道参数是否应该以严格模式执行,但是参数却应该先于函数体执行。

有两种方法可以规避这种限制

//1、设定全局性的严格模式
'use strict';
function foo(x,y=x) {
    //statements
}

//2、把函数包在一个无参数的立即执行函数的里面
const doSomething = (function() {
    'use strict';
    return function(...a) {
        for(let val of a){
            console.log(val)
        }
    }
}())
  • name属性

函数的name属性,返回该函数的函数名。注意:函数name属性的值不一定引用同名变量,它只是协助调试用的额外信息,所以不能使用name属性的值来获取对于函数的引用。

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

如果将一个匿名函数赋值给一个变量,ES5 的name属性,会返回空字符串,而 ES6 的name属性会返回实际的函数名。

var foo = function() {}
//ES5
foo.name //'';
//ES6
foo.name //"foo"

如果将一个具名函数赋值给一个变量,则 ES5 和 ES6 的name属性都返回这个具名函数原本的名字。函数表达式有一个名字,这个名字比函数本身被赋值的变量权重高。

let foo = function bar() {};
//ES5、ES6
foo.name //bar

另外还有两个特例:通过bind()函数创建的函数,其名称带有“bound”前缀;通过Function构造函数创建的函数,其名称将是“anonymous”。

var foo = function() {};
console.log(foo.bind().name);//bound foo
console.log(new Function().name);//anonymous
  • 箭头函数

ES6运行使用箭头(=>)定义函数,箭头函数同样也有一个name属性,这与其他函数的规则相同。

var foo = x => x;
//等价于
var foo = function(x){
    return x;
}

如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分,如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并显示地定义一个返回值。由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。

与传统JavaScript函数不同点:

没有this、supper、arguments和new.target绑定:箭头函数的这些值由外围最近一层非箭头函数决定。函数内部的this值不可被改变,在函数的生命周期内始终保持一致。

不能通过new关键字调:箭头函数没有[[Construct]]方法,所以不能被用作构造函数,如果通过new关键字调用箭头函数,程序会抛出错误。由于不能通过new关键字调用箭头函数,因而没有构建原型的需求,所以箭头函数不存在prototype属性

不支持arguments对象:该对象在函数体内不存在。如果要用,可以用命名参数或 rest 参数代替。

不支持重复命名参数:无论在严格还是非严格模式下,箭头函数都不支持重复的命名参数,而在传统函数规定中,只有在严格模式下才不能有重复的命名参数

function foo(){
    setTimeout(() => {
        console.log('id:',this.id);
    },1000)
}
var id = 1;
foo.call({id:2});//id:2

setTimeout的参数是一个箭头函数,这个箭头函数的定义生效是在foo函数生成时,而它的真正执行要等到 1000 毫秒后。如果是普通函数,执行时this应该指向全局对象window,这时应该输出1。但是,箭头函数导致this总是指向函数定义生效时所在的对象(本例是{id: 2})而不是指向运行时所在的作用域,所以输出的是2。

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一次都没更新。

  • 尾调用优化

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

function foo(){
    return bar();//尾调用
}

在ES5的引擎中,尾调用的实现与其他函数调用的实现类似:创建一个新的栈帧(stack frame),将其推入调用栈来表示函数调用,也就是说循环调用中,每一个未用完的栈帧都会被保存在内存中,当调用栈变得过大时,会造成程序问题。

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

//在调用函数g后还有赋值操作,即使语义完全一样
function f(x) {
    let y = g(x);
    return y;
}

//调用后还有操作,即使写在一行内
function f(x) {
    return g(x) + 1;
}

//调用后实际还有一个renturn undefined操作
function f(x) {
    g(x);
}

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

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

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

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

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

function foo(){
    let x = 1,
        y = 2;
    return bar(x+y);
}
foo();

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

//等同于
bar(3)

如果函数g不是尾调用,函数f就需要保存内部变量m和n的值、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。

相关文章:ES6深入学习(一)块级作用域详解(juejin.cn/post/684490…)

如有错误或者建议欢迎指出,我一定快马加鞭的改正~一起学习交流