1. 程式人生 > >ES6函數的拓展

ES6函數的拓展

賦值 函數賦值 執行 嚴格模式 必須 bin 設置 立即執行 數根

1、函數參數的默認值

ES6 之前,不能直接為函數的參數指定默認值,只能采用變通的方法。

function log(x, y) {
  y = y || ‘World‘;
  console.log(x, y);
}

log(‘Hello‘) // Hello World
log(‘Hello‘, ‘China‘) // Hello China
log(‘Hello‘, ‘‘) // Hello World

上面代碼檢查函數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
function Point(x = 0, y = 0) {
  
this.x = x; this.y = y; } const p = new Point(); p // { x: 0, y: 0 }

參數變量是默認聲明的,所以不能用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

x = 100;
foo() // 101

與解構賦值默認值結合使用

參數默認值可以與解構賦值的默認值,結合起來使用。

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就不會生成,從而報錯。通過提供函數參數的默認值,就可以避免這種情況。

如果沒有提供參數,函數foo的參數默認為一個空對象。

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

foo() // undefined 5

如果函數fetch的第二個參數是一個對象,就可以為它的三個屬性設置默認值。這種寫法不能省略第二個參數,如果結合函數參數的默認值,就可以省略第二個參數。這時,就出現了雙重默認值。

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

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

fetch(‘http://example.com‘)
// 報錯

上面代碼中,函數fetch沒有第二個參數時,函數參數的默認值就會生效,然後才是解構賦值的默認值生效,變量method才會取到默認值GET

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

fetch(‘http://example.com‘)
// "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

函數的 length 屬性

指定了默認值以後,函數的length屬性,將返回沒有指定默認值的參數個數。也就是說,指定了默認值後,length屬性將失真。

(function (a) {}).length // 1
(function (a = 5) {}).length // 0
(function (a, b, c = 5) {}).length // 2

這是因為length屬性的含義是,該函數預期傳入的參數個數。某個參數指定默認值以後,預期傳入的參數個數就不包括這個參數了。同理,後文的 rest 參數也不會計入length屬性。

上面代碼中,參數y的默認值等於變量x。調用函數f時,參數形成一個單獨的作用域。在這個作用域裏面,默認值變量x指向第一個參數x,而不是全局變量x,所以輸出是2

var x = 1;

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

f(2) // 2

函數f調用時,參數y = x形成一個單獨的作用域。這個作用域裏面,變量x本身沒有定義,所以指向外層的全局變量x。函數調用時,函數體內部的局部變量x影響不到默認值變量x

let x = 1;

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

f() // 1

如果此時,全局變量x不存在,就會報錯。

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

f() // ReferenceError: x is not defined

下面這樣寫,也會報錯。

上面代碼中,參數x = x形成一個單獨作用域。實際執行的是let x = x,由於暫時性死區的原因,這行代碼會報錯”x 未定義“。

var x = 1;

function foo(x = x) {
  // ...
}

foo() // ReferenceError: x is not defined

如果參數的默認值是一個函數,該函數的作用域也遵守這個規則。

函數bar的參數func的默認值是一個匿名函數,返回值為變量foo。函數參數形成的單獨作用域裏面,並沒有定義變量foo,所以foo指向外層的全局變量foo,因此輸出outer

let foo = ‘outer‘;

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

bar(); // outer

如果寫成下面這樣,就會報錯。

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

bar() // ReferenceError: foo is not defined

函數foo的參數形成一個單獨作用域。這個作用域裏面,首先聲明了變量x,然後聲明了變量yy的默認值是一個匿名函數這個匿名函數內部的變量x,指向同一個作用域的第一個參數x函數foo內部又聲明了一個內部變量x,該變量與第一個參數x由於不是同一個作用域,所以不是同一個變量,因此執行y後,內部變量x和外部全局變量x的值都沒變。

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

foo() // 3
x // 1

如果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

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

下面是一個 rest 參數代替arguments變量的例子。

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

// rest參數的寫法
const sortNumbers = (...numbers) => numbers.sort();

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 做了一點修改,規定只要函數參數使用了默認值、解構賦值、或者擴展運算符,那麽函數內部就不能顯式設定為嚴格模式,否則會報錯。

這樣規定的原因是,函數內部的嚴格模式,同時適用於函數體和函數參數。但是,函數執行的時候,先執行函數參數,然後再執行函數體。這樣就有一個不合理的地方,只有從函數體之中,才能知道參數是否應該以嚴格模式執行,但是參數卻應該先於函數體執行。

兩種方法可以規避這種限制。第一種是設定全局性的嚴格模式,這是合法的。

‘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"

箭頭函數

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" });

箭頭函數可以與變量解構結合使用

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

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

箭頭函數使得表達更加簡潔。

const isEven = n => n % 2 == 0;
const square = n => n * n;

箭頭函數的一個用處是簡化回調函數。

// 正常函數寫法
[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對象的指向是可變的,但是在箭頭函數中,它是固定的。

setTimeout的參數是一個箭頭函數,這個箭頭函數的定義生效是在foo函數生成時,而它的真正執行要等到 100 毫秒後。

箭頭函數導致this總是指向函數定義生效時所在的對象(本例是{id: 42}),所以輸出的是42

foo.call({id:42})call()將foo函數裏面的this 指向了{id:42}這個對象

箭頭函數可以讓setTimeout裏面的this綁定定義時所在的作用域,而不是指向運行時所在的作用域。

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

var id = 21;

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

如果是普通函數,執行時this應該指向全局對象window,這時應該輸出21

foo.call({id:42})等價於 foo({id:42})

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


        var id = 21;

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

Timer函數內部設置了兩個定時器,分別使用了箭頭函數和普通函數。前者的this綁定定義時所在的作用域(即Timer函數)後者的this指向運行時所在的作用域(即全局對象)。所以,3100 毫秒之後,timer.s1被更新了 3 次,而timer.s2一次都沒更新。

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

this指向的固定化,並不是因為箭頭函數內部有綁定this的機制,實際原因是箭頭函數根本沒有自己的this,導致內部的this就是外層代碼塊的this。正是因為它沒有this,所以也就不能用作構造函數。

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

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

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

上面代碼之中,只有一個this,就是函數foothis,所以t1t2t3都輸出同樣的結果。因為所有的內層函數都是箭頭函數,都沒有自己的this,它們的this其實都是最外層foo函數的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,以下三個變量在箭頭函數之中也是不存在的,指向外層函數的對應變量:argumentssupernew.target

箭頭函數內部的變量arguments,其實是函數fooarguments變量。

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

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

由於箭頭函數沒有自己的this,所以當然也就不能用call()apply()bind()這些方法去改變this的指向。

雙冒號運算符

箭頭函數可以綁定this對象,大大減少了顯式綁定this對象的寫法(callapplybind)。但是,箭頭函數並不適用於所有場合,所以現在有一個提案,提出了“函數綁定”(function bind)運算符,用來取代callapplybind調用。

函數綁定運算符是並排的兩個冒號(::),雙冒號左邊是一個對象,右邊是一個函數。該運算符會自動將左邊的對象,作為上下文環境(即this對象),綁定到右邊的函數上面。

foo::bar;
// 等同於
bar.bind(foo);

foo::bar(...arguments);
// 等同於
bar.apply(foo, arguments);

const hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn(obj, key) {
  return obj::hasOwnProperty(key);
}

如果雙冒號左邊為空,右邊是一個對象的方法,則等於將該方法綁定在該對象上面。

var method = obj::obj.foo;
// 等同於
var method = ::obj.foo;

let log = ::console.log;
// 等同於
var log = console.log.bind(console);

尾調用優化

什麽是尾調用

尾調用(Tail Call)是函數式編程的一個重要概念,本身非常簡單,一句話就能說清楚,就是指某個函數的最後一步是調用另一個函數。

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

以下三種情況,都不屬於尾調用。

情況一是調用函數g之後,還有賦值操作,所以不屬於尾調用,即使語義完全一樣。情況二也屬於調用後還有操作,即使寫在一行內。

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

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

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

情況三等同於下面的代碼。

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

尾調用不一定出現在函數尾部,只要是最後一步操作即可。

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

函數mn都屬於尾調用,因為它們都是函數f的最後一步操作。

尾遞歸

函數調用自身,稱為遞歸。如果尾調用自身,就稱為尾遞歸。

遞歸非常耗費內存,因為需要同時保存成千上百個調用幀,很容易發生“棧溢出”錯誤(stack overflow)。但對於尾遞歸來說,由於只存在一個調用幀,所以永遠不會發生“棧溢出”錯誤。

ES6函數的拓展