【ES6】函数的扩展

1. 函数参数的默认值

  在ES6之前不能为函数的参数制定默认值,只能用变通的方法( y = y || '默认值';)。但这种方法在参数的布尔值为false时(如空字符串)会误把默认值赋上。所以对应的还需要加上一个if语句判定是否是undefined来避免这个问题。而ES6允许直接使用“=”来设置默认值。这样做有三个优点:

  • 简洁;
  • 阅读起来很容易找到哪些参数是可以省略的,而不需要读文档或是看函数体;
  • 有利于代码未来的优化,哪怕在未来版本的对外接口中彻底拿掉这个参数,也不会导致以前的代码无法运行。

  函数的默认值声明中还有三个需要注意的问题:

✨参数变量是默认声明的,所以不能在函数体内用 let 或 const 重复声明;

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

✨参数默认值不是传值的,而是每次都计算表达式的值。也就是说是惰性求值的。

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

foo() // 100

x = 100;
foo() // 101

与解构赋值结合使用

  有时会用到较为复杂的双重默认值的情况。双重默认值指的是:一个是解构赋值的默认值,一个是函数参数的默认值。比如下面这个例子两个函数的区别:

// 设置了解构默认值,同时设置函数参数的默认值是一个空对象
function m1({x = 0, y = 0} = {}) {
  return [x, y];
}

// 没有设置结构默认值,设置函数参数的默认值是x,y都为0的对象
function m2({x, y} = { x: 0, y: 0 }) {
  return [x, y];
}

//运行结果
// 函数没有参数的情况,即undefined
m1() // [0, 0]
m2() // [0, 0]

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

// x 和 y 都无值的情况
m1({}) // [0, 0] (取到了解构赋值的默认值)
m2({}) // [undefined, undefined]  (没有解构赋值的默认值)

参数默认值的位置和函数的length属性

  参数默认值的位置应该放在函数的末尾。因为这样才能起到省略参数的作用。除非显式的将前面的参数置为undefined。

  函数的length属性的含义是该函数预期传入的参数个数。也就是说设置了默认值的参数,以及rest属性都不会计入length。需要注意的是,如果设置了默认值的参数不是尾参数的话,在他后面的参数也不会计入length。

作用域及默认值的应用

  一旦设置了默认值,函数参数初始化的时候,参数部分会形成一个单独的作用域(与函数体没有关系)。注意这种语法特点在没有设置默认值的时候是不会出现的。

  弄清楚作用域问题的重点在于分辨参数究竟指向的哪个作用域的值。下面几个例子用于重点理解这一块:

var x = 1;

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

f(2) // 2

  由于参数部分是一个单独的作用域,参数声明中的 y = x 指向的是参数部分的x,而不是全局环境的x,所以输出的是2。

let x = 1;

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

f() // 1

  参数声明中的 y = x 将 x 的值赋给 y ,由于参数作用域没有找到 x 的值,所以在全局作用域寻找,找到全局作用域中的 x 值为1。如果去掉全局作用域中的 let 声明,将会报错。

  不仅仅是赋值,函数也是如此。如果函数的 return 部分在参数作用域中找不到,就在全局作用域中寻找(而不是被函数体内的赋值影响)。

  下面是一个更为复杂的例子,涉及到全局、参数、函数内部三个作用域。

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

foo() // 3
x // 1

  在这个函数 foo 中,参数作用域中匿名函数 y 指向前一个参数 x 。在函数内部又声明了一个变量 x ,该变量和参数 x 不在同一个作用域,所以不是同一个变量。而后函数 y 执行,对外部(全局)和内部(函数体)的 x 都没有影响。

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

foo() // 2
x // 1

  将var去除后,内部变量就指向参数 x 了。这是函数 y 执行改变了内部(既是函数体又是参数作用域)的 x 的值,但不影响外部的值。

  参数默认值可以用于在缺少参数的时候抛出错误

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

//给函数参数设置默认值为抛出错误的函数,如果没有参数则使用默认值,默认值抛出错误
function foo(mustBeProvided = throwIfMissing()) {
  return mustBeProvided;
}

foo()
// Error: Missing parameter
由此也可以看出,参数部分的函数是惰性求值的。即在定义时是不执行的,在运行时才执行。

2. rest参数,严格模式和name属性

✨rest参数用于获取函数多余的参数。rest参数搭配的变量是一个数组,将多余的参数放到这个数组中。rest参数后面不能接其他参数,否则会报错。

只要参数使用了默认值、解构赋值、或者扩展运算符,就不能显式在函数内部指定严格模式。因为在函数执行时,是先执行函数参数,再执行函数体的。如果在函数体内设定严格模式,而此时参数已经设置了默认值,这显然是不合理的。

✨name属性返回函数的名称。这个属性有一些特殊情况:

  • 对于匿名函数,ES5返回空字符串,ES6返回实际的函数名;
  • Function构造函数返回的函数实例,name属性的值为 anonymous ;
  • bind返回的函数,name属性值会加上 bound 前缀。

3. 箭头函数

基本用法

  ES6可以使用箭头符号(⇒)来定义函数。具体使用方法如下:

  1. 如果不需要参数有多个参数,用圆括号代表参数部分;
  2. 代码块部分语句超过一句,则要用大括号包裹起来,并用return语句返回;
  3. 直接返回对象,要在外面包裹圆括号,避免被错认为代码块;
  4. 可以结合解构赋值使用,箭头函数可以简化回调函数,可以与rest参数结合使用。

注意点

✨千万要注意箭头函数的this指向问题!!!是指向定义时所在的对象,而不是执行时所在的对象。

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

var id = 21;

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

  由于this对象的指向是可变的,但是在箭头函数中他是固定的。在上面的例子中,setTimeout函数是在外层函数foo执行时被定义的(此时id=42),所以他的this指向的是foo(定义的作用域)的id,而不是全局环境的id21(执行的作用域)。

  箭头函数的这个特性(this指向固定化)有利于回调函数的封装(这样就不会this变为指向document而导致函数执行错误)。

this指向固定化的本质原因是箭头函数内部没有自己的this,导致内部的this就是外层代码的this。因此,也不能使用call( ),apply( ),bind( )方法改变this的指向。同时它还没有arguments、super、new.target三个变量,所以只能取得外层代码的对应变量。

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

// ES5
function foo() {
  **var _this = this;  //可以看出箭头函数内部是没有自己的this的**

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

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

✨不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。

✨不可以使用yield命令,因此箭头函数不能用作 Generator 函数。

不适用的场合

  基于箭头函数this从“动态”变为“静态”,故也有一些场合不适合使用箭头函数:

  • 定义对象的方法,且对象内部包含this

    由于对象不构成单独的作用域,所以对象方法中定义的作用域为全局作用域,而不是指向这个对象。

  • 需要动态的this

    比如需要动态指向被点击的按钮对象 button.addEventListener ,监听函数如果使用回调函数,则会导致this固定指向全局对象。

  • 函数复杂有多行

    为了增强可读性,不建议使用箭头函数。

4. 尾调用优化

尾调用:某个函数的最后一步是调用另一个函数。

注意点:

  • 如果调用函数之后还有赋值操作(如let y = g(x); return y;),不属于尾调用;
  • 尾调用不一定要在函数的最后一行,只要是最后一步操作就可以(如if语句的结尾)。

尾调用优化

  函数会在内存中形成一个“调用记录”,又叫**“调用帧”**(call frame),保存调用位置和内部变量等信息。如果函数A中调用了函数B,那么函数A之上还会形成一个函数B的调用帧,直到B运行结束并将结果返回A,B的调用帧才会消失。所有的调用帧形成了一个“调用栈”(call stack)。

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

  只保留内层函数的调用帧,就叫做“尾调用优化”。其意义就是每次调用只有一层调用帧,大大节约了内存。目前只有 Safari 浏览器支持尾调用优化。

尾递归

  如果尾调用自身,就称为尾递归。递归非常消耗内存,需要同时保存很多个调用帧,容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,故不会发生栈溢出。

//阶乘的常规写法
function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

//阶乘的尾递归写法
function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

  ES6 第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。

递归函数的改写

  为了实现尾递归,需要改写递归函数,使得最后一步是调用自身。实现的方法就是把所有用到的内部变量改写为函数的参数。如上面尾递归的阶乘函数。但改写后出现了一个问题,就是代码的可读性变差了。这里提供了两种解决办法:

  1. 通过一个正常的函数调用尾递归函数(或使用柯里化)

    function tailFactorial(n, total) {  //参数total使得可读性降低
      if (n === 1) return total;
      return tailFactorial(n - 1, n * total);
    }
    
    //1. 使用一个正常的函数来调用尾递归
    function factorial(n) {   
      return tailFactorial(n, 1);
    }
    
    //2. 柯里化:将多参数的函数转化为单参数的函数
    function currying(fn, n) {
      return function (m) {
        return fn.call(this, m, n);
      };
    }
    
    const factorial = currying(tailFactorial, 1);
  2. 采用函数默认值

    function factorial(n, total = 1) {  //total有默认值,只需要输入n一个参数
      if (n === 1) return total;
      return factorial(n - 1, n * total);
    }

  ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。正常模式之下,只能自己实现尾调用优化。

尾递归优化的实现

核心思想:用循环替代递归。

  1. 蹦床函数(trampoline)

    function trampoline(f) {
      while (f && f instanceof Function) {
        f = f();
      }
      return f;
    }
    
    function sum(x, y) {
      if (y > 0) {
        return sum.bind(null, x + 1, y - 1);
      } else {
        return x;
      }
    }

      在蹦床函数中,只要f执行后返回一个函数他就继续执行。这里把在函数中调用函数转化为了执行函数返回的函数。也就是将“递归”转换为了“循环”。但缺点是需要改写原函数,将调用改为返回自身的另一个版本。

  2. tco()函数

    function tco(f) {
      var value;
      var active = false;
      var accumulated = [];
    
      return function accumulator() {
    2   accumulated.push(arguments);    //accumulator的实参就是sum的实参,
    2                                   //也就是说把[1, 100000]传入了accumulated
    4        //虽然没能进入if语句,但sum(也就是accumulator)执行了第一句push,
    4        //所以实参被修改成了变化后的实参,while循环继续,
    4        //也就是说f.apply每执行一次就会往accumulate中push进新的实参,直到运行结束
        if (!active) {
          active = true;
          while (accumulated.length) {
    3        value = f.apply(this, accumulated.shift());  //执行了sum函数,
    3                                                     //判断y(>0)的值后返回一个sum
    3        //sum的参数发生了改变(x+1, y-1),执行sum相当于执行accumulator
    3        //但由于还在while循环里,所以执行这个被返回的sum得到undefined,被赋给value
          }
          active = false;
    5     return value;   //直到外层匿名函数f(sum)返回x,value才被返回出来
        }
      };
    }
    
    var sum = tco(function(x, y) {   
    1                      //将tco函数的返回值赋给sum,
    1                      //tco函数的返回值是accumulator函数,
    1                      //也就是说执行sum(1, 100000)实际上是在执行accumulator(1, 100000)
      if (y > 0) {
        return sum(x + 1, y - 1)
      }
      else {
        return x
      }
    });
    
    sum(1, 100000)
    // 100001

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

5. 函数参数的尾逗号、toString( )及catch参数的省略

  ES2017 允许函数的最后一个参数有尾逗号(trailing comma)。

  toString()方法返回函数代码本身,以前会省略注释和空格。修改后的toString()方法,明确要求返回一模一样的原始代码。

  JavaScript 语言的try...catch结构,以前明确要求catch命令后面必须跟参数,接受try代码块抛出的错误对象。ES2019 做出了改变,允许catch语句省略参数。

posted @ 2020-07-25 15:29  HermionePeng  阅读(153)  评论(0编辑  收藏  举报