javascript函式全解

hard_man發表於2018-11-14

0.0 概述

本文總結了js中函式相關的大部分用法,對函式用法不是特別清晰的同學可以瞭解一下。

1.0 簡介

同其他語言不同的是,js中的函式有2種含義。

普通函式:同其他語言的函式一樣,是用於封裝語句塊,執行多行語句的語法結構。

建構函式:不要把它當作函式,把它當作class,內部可以使用this表示當前物件。

【注】後續程式碼基於ES6&ES7標準,筆者是在nodejs v10.7.0環境下執行(你也可以選擇其他支援ES6的node版本)。

1.1 函式的宣告

雖然普通函式和建構函式,含義有所不同,可是宣告方法卻完全一樣。

1.1.0 函式宣告

function sort(arr) {
    let ret = [...arr];
    let length = ret.length;
    for (let i = 0; i < length; i++) {
        for (let j = i + 1; j < length; j++) {
            if (ret[i] > ret[j]) {
                [ret[j], ret[i]] = [ret[i], ret[j]];
            }
        }
    }
    return ret;
}
複製程式碼

1.1.1 函式表示式

let sort = function (arr) {
    let ret = [...arr];
    ...
    ...
    return ret;
}
複製程式碼

函式表示式和普通函式宣告的區別在於,普通函式宣告會提升,函式表示式不會提升

“提升”的意思是說: 在函式宣告前就可以呼叫這個函式。不必先宣告後呼叫。

js會在執行時,將檔案內所有的函式宣告,都提升到檔案最頂部,這樣你可以在程式碼任意位置訪問這個函式。

而現在根據ES6標準,使用var修飾的函式表示式會提升,使用let修飾的則不會提升。

1.1.2 使用Function建構函式宣告

let sort = new Function("arr", `
    function sort(arr) {
        let ret = [...arr];
        let length = ret.length;
        for (let i = 0; i < length; i++) {
            for (let j = i + 1; j < length; j++) {
                if (ret[i] > ret[j]) {
                    [ret[j], ret[i]] = [ret[i], ret[j]];
                }
            }
        }
        return ret;
    }
 `);
複製程式碼

這種使用Function構造方法建立的函式,同函式宣告產生的函式是完全相同的。

建構函式接收多個字串作為引數,最後一個參數列示函式體,其他參數列示引數名

像上面這個例子和1.1.0中的宣告完全相同。

這種宣告方式,沒有發現有什麼優點,並不推薦使用。

1.2 閉包

閉包,簡單說就是在函式中宣告的函式,也就是巢狀函式。它能夠延長父作用域部分變數的生命週期。

閉包可以直接使用其所在函式的任何變數,這種使用是引用傳遞,而不是值傳遞,這一點很重要。

let f = function generator() {
    let arr = [1, 2, 3, 4, 5, 6, 7];
    let idx = 0;
    return {
        next() {
            if (idx >= arr.length) {
                return { done: true };
            } else {
                return { done: false, value: arr[idx++] };
            }
        }
    }
}
let gen = f();
for (let i = 0; i < 10; i++) {
    console.log(gen.next());
}

複製程式碼

上面的程式碼中,generator函式中的閉包next()可直接訪問並修改所在函式中的變數arridx

一般說來,閉包需要實現尾遞迴優化。

尾遞迴是指,如果一個函式,它的最後一行程式碼是一個閉包的時候,會在函式返回時,釋放父函式的棧空間。

這樣一來,依賴閉包的遞迴函式就不怕棧溢位了(nodejs在64位機器上可達到1萬多層的遞迴才會溢位,有可能是根據記憶體情況動態計算的)。

ES6明確要求支援尾遞迴。

而據網路上資料說,nodejs需要在嚴格模式下,使用--harmony選項,可以開啟尾遞迴。

然而我使用下列程式碼發現,並沒有開啟(nodejs版本為v10.3.0)。


// File: test.js
// Run: node --harmony test.js

"use strict"

function add(n, sum) {
    if (n == 0) {
        console.trace();
        return sum;
    } else {
        return add(n - 1, sum + n);
    }
}
console.log(add(10, 0));
/*
輸出為:
Trace
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:5:11)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
55
*/

複製程式碼

1.3 匿名函式

我們經常在js的程式碼中看見下面這種寫法:

(function(){
	...
	...
	...
})();
複製程式碼

將一個匿名函式直接執行,如果剛接觸js的同學可能覺得這是脫褲子放屁。

但是這個匿名函式的最大作用在於作用域隔離,不汙染全域性作用域。

如果沒有匿名函式包裹,程式碼中宣告的所有變數都會出現在全域性作用域中,造成不必要的變數覆蓋麻煩和效能上的損失。

ES6中這種寫法可以拋棄了,因為ES6引入了塊作用域

{
	...
	...
	...
}

複製程式碼

作用和上面的匿名函式相同。

另外ES6中增加了一種匿名函式的寫法:

//ES6以前的寫法
function Teacher(name){
	this.name = name;
	var self = this;
	setTimeout(function(){
		console.log('Teacher.name = ' + self.name);
	}, 3000);
}

//現在這樣寫
function Student(name){
	this.name = name;
	setTimeout(() => {
		console.log('Student.name = ' + this.name);
	}, 3000);
}

複製程式碼

新的匿名函式的在寫法上有2處不同:

  • 去掉了function關鍵字
  • 在引數列表和函式體之間增加了=>符號

而它也帶來了一個巨大的好處:

匿名函式中的this物件總是指向宣告時所在的作用域的this,不再指向呼叫時候的this物件了。

這樣我們就可以像上面的例子那樣,很直觀地使用this,不用擔心出現任何問題。

所以比較強烈推薦使用新的匿名函式寫法。

1.4 建構函式和this

1.4.1 基本物件導向語法

下面來介紹建構函式,js沒有傳統物件導向的語法,但是它可以使用函式來模擬。

瞭解js物件導向機制之前,可以先看一下,其他標準面嚮物件語言的寫法,比如java,我們宣告一個類。

class Person{
	//建構函式
	Person(String name, int age){
		this.name = name;
		this.age = age;
		Person.count++;
	}
	//屬性
	String name;
	int age;
	//setter&getter方法
	String getName(){
		return this.name;
	}
	void setName(String name){
		this.name = name;
	}
	int getAge(){
		return this.age;
	}
	void setAge(int age){
		this.age = age;
	}
	//靜態變數
	static int count = 0;
	//靜態方法
	public int getInstanceCount(){
		return Person.count;
	}
}
複製程式碼

由此可知,一個類主要包含如下元素:建構函式屬性方法靜態屬性靜態方法

在js中,我們可以使用js的建構函式,來完成js中的物件導向。

js的建構函式就是用來做物件導向宣告(宣告)的。

建構函式的宣告語法同普通函式完全相同。

//建構函式
function Person(name, age){
	//屬性
	this.name = name;
	this.age = age;
	
	//setter&getter
	this.getName = function(){
		return this.name;
	}
	this.setName = function(name){
		this.name = name;
	}
	this.getAge = function(){
		return this.age;
	}
	this.setAge = function(age){
		this.age = age;
	}
	
	Person.count++;
}

//靜態變數
Person.count = 0;

//靜態方法
Person.getInstanceCount = function(){
	return Person.count;
}
複製程式碼

可以發現,建構函式中同普通函式相比,特別的地方在於使用了this,同其他物件導向的語言一樣,this表示當前的例項物件。

把我們用js宣告的類與java的類相對比,二者除了寫法不同之外,上述關鍵元素也都包含了。

1.4.2 prototype

js使用上面的方法宣告瞭類之後,就可以使用new關鍵字來建立物件了。

let person = new Person("kaso", 20);
console.log("person.name=" + person.getName() + ", person.age=" + person.getAge());
//輸出:person.name=kaso, person.age=20
let person1 = new Person("jason", 25);
console.log("person.name=" + person.getName() + ", person.age=" + person.getAge());
//輸出:person.name=jason, person.age=25
複製程式碼

建立物件,訪問屬性,訪問方法,都沒問題,看起來挺好的。

但是當我們執行一下這段程式碼,會發現有些不對:

console.log(person.getName === person1.getName);
//輸出:false
複製程式碼

原來建構函式在執行的時候,會將所有成員方法,為每個物件生成一份copy,而對於類成員函式來說,保留一份copy就足夠了,而不同的物件可以用this來區分。上面的做法很明顯,記憶體被白白消耗了。

基於上述問題,js引入了prototype關鍵字並規定:

儲存在prototype中的方法和變數可以在類的所有物件中共享。

因此,上面的建構函式可以修改成這樣:

function Person(name, age){
	this.name = name;
	this.age = age;
	
	Person.count++;
}

Person.prototype.getName = function(){
	return this.name;
}

Person.prototype.setName = function(name){
	this.name = name;
}

Person.prototype.getAge = function(){
	return this.age;
}

Person.prototype.setAge = function(age){
	this.age = age;
}

Person.count = 0;

Person.getInstanceCount = function(){
	return Person.count;
}

複製程式碼

執行效果和之前的寫法相同,只是這次建立不同的物件時,成員方法不再建立多個副本了。

需要注意的是,成員變數不需要放到prototype中,可以想想為什麼。

1.4.3 apply和call

js函式中繞不過的一個問題就是,方法裡面的this到底指向哪裡?

最官方的說法是:this指向呼叫此方法的物件。

對於類似於java這種物件導向的語言來講,this永遠指向所在類的物件例項。

對於js中也是這樣,如果我們規規矩矩地像上一節介紹的那樣使用,this也會指向所在類的物件例項。

但是,js也提供了更為靈活的語法,它可以讓一個方法被不同的物件呼叫,即使不是同一個類的物件,也就是可以將同一個函式的this,設為不同的值。

這是一個極為靈活的語法,可以完成其他語言類似介面(interface)擴充套件(extension)模版(template)的功能。

實現此功能的方法有2個:applycall,二者實現的功能完全相同,即改變函式的this指向,只是函式傳遞引數方式不同。

call接受可變引數,同函式呼叫一樣,需將引數一一列出。
apply只接受2個引數,第一個就是新的this指向的物件,第二個引數是原引數用陣列儲存起來。
程式碼如下:

let obj = {
	print(a, b, c){
		console.log(`this is obj.print(${a}, ${b}, ${c})`);
	}
}

let obj1 = {
	print(a, b, c){
		console.log(`this is obj1.print(${a}, ${b}, ${c})`);
	}
}

function test(a, b, c){
	this.print(a, b, c);
}

test.apply(obj, [1, 2, 3]);
test.call(obj, 4, 5, 7);

test.apply(obj1, [1, 2, 3]);
test.call(obj1, 4, 5, 7);

/* 輸出:
this is obj.print(1, 2, 3)
this is obj.print(4, 5, 7)
this is obj1.print(1, 2, 3)
this is obj1.print(4, 5, 7)
*/
複製程式碼

1.4.4 繼承

物件導向3大特徵:封裝,繼承,多型,其中最重要的就是繼承,多型也依賴於繼承的實現。可以說實現了繼承,就實現了物件導向。

java中的繼承很簡單:

class Student extends Person{
    ... ...
}
複製程式碼

Student繼承之後自動獲得Person的所有成員變數和成員方法。

因此,我們在實現js繼承的時候,主要就是獲取到父類的成員變數和成員方法。

最簡單的實現就是,將父類的成員變數和方法直接copy到子類中。

這需要做2件事:

  • 為了copy成員方法,可以將Student的prototype指向父類的prototype
  • 為了copy成員屬性,子類建構函式需要呼叫父類建構函式
function Student(name, age){
	Person.call(self, name, age);
}

Student.prototype = Person.prototype;

複製程式碼

上面程式碼可以達到繼承的目的,但是會產生兩個問題

  • 如果我向Student中新增新的成員方法時,會同時加入到父類中
  • 多層次繼承無法實現,即當所呼叫的方法在父類中找不到的時候,不會去父類的父類中去查詢

所以我們不能直接將Person.prototype直接給Student.prototype。

經過思考,一個可行方案是,令子類prototype指向父類的一個物件,即像這樣:

Student.prototype = new Person();
複製程式碼

這樣做,可以解決上面的2個問題。

但是它仍然有些瑕疵:會呼叫2次父類建構函式,造成一定的效能損失。

所以我們的終極繼承方案是這樣的:

function Student(name, age){
	Person.call(self, name, age);
}

function HelpClass(){}
HelpClass.prototype = Person.prototype;
Student.prototype = new HelpClass();
複製程式碼

上面關鍵程式碼的意義在於,用一個空的建構函式代替父類建構函式,這樣呼叫了一個空建構函式的代價會小於呼叫父類建構函式。

另外上述程式碼可以用Object.create函式簡化:

function Student(name, age){
	Person.call(self, name, age);
}

Student.prototype = Object.create(Person.prototype);
複製程式碼

這就是我們最終的繼承方案了。可以寫成下面的通用模式。

function extend(superClass){
	function subClass(){
		superClass.apply(self, arguments);
	}
	subClass.prototype = Object.create(superClass.prototype);
	
	return subClass;
}

let Student = extend(Person);

let s = new Student('jackson', '34');

console.log("s.getName() = " + s.getName() + ", s.getAge() = " + s.getAge());

//輸出為:s.getName() = jackson, s.getAge() = 34

複製程式碼

當然實現一個完整的繼承還需要完善其他諸多功能,在這裡我們已經解決了最根本的問題。

1.5 generator函式和co

generator是ES6中提供的一種非同步程式設計的方案。有點像其他語言(lua, c#)中的協程。

它可以讓程式在不同函式中跳轉,並傳遞資料。

1.5.1 基本用法介紹

看下面的程式碼:

function *generatorFunc(){
   console.log("before yield 1");
   yield 1;
   console.log("before yield 2");
   yield 2;
   console.log("before yield 3");
   let nextTransferValue = yield 3;
   console.log("nextTransferValue = " + nextTransferValue);
}

let g = generatorFunc();
console.log("before next()");
console.log(g.next());
console.log(g.next());
console.log(g.next());
console.log(g.next(1024));
/*輸出:
before next()
before yield 1
{ value: 1, done: false }
before yield 2
{ value: 2, done: false }
before yield 3
{ value: 3, done: false }
nextTransferValue = 1024
{ value: undefined, done: true }
*/
複製程式碼

可以看到generator函式有3要素:

  • 需要在函式名字前面,加上*
  • 需要在函式體中使用 yield
  • 呼叫的時候需要使用 next()函式

另外還有一些其他規則:

  • generator函式內的第一行程式碼,需要在第一個next()執行後執行
  • 函式在執行next()時,停頓在yield處,並返回yield後面的值,yield後的程式碼不再執行。
  • next() 返回的形式是一個物件:{value: XXX, done: false},這個物件中,value表示yield後面的值,done表示是否generator函式已經執行完畢,即所有的yield都執行過了。
  • next() 可以帶引數,表示將此引數傳遞給上一個yield,因為上次執行next()的時候,程式碼停留在上次yield的位置了,再執行next()的時候,會從上次yield的位置繼續執行程式碼,同時可以令yield表示式有返回值。

從上述介紹中可以看出,generator除了在函式中跳轉之外,還可以通過next()來返回不同的值。

瞭解過ES6的同學應該知道,這種next()序列,特別符合迭代器的定義。

因此,我們可以很容易把generator的函式的返回值組裝成陣列,還可以用for..of表示式來遍歷。

function *generatorFunc(){
   yield 1;
   yield 2;
   yield 3;
}

let g = generatorFunc();
for(let i of g){
	console.log(i);
}

/*
輸出:
1
2
3
*/
複製程式碼
function *generatorFunc(){
   yield 1;
   yield 2;
   yield 3;
}

let g = generatorFunc();
console.log(Array.from(g));

/*
輸出:
[1, 2, 3]
*/
複製程式碼

除了上述規則外,generator還有一個語法yield *,它可以連線另一個generator函式,類似於普通函式間呼叫。用於一個generator函式呼叫另一個generator函式,也可用於遞迴。

function *generatorFunc(){
    yield 3;
    yield 4;
    yield 5;
}

function *generatorFunc1(){
    yield 1;
    yield 2;
    yield * generatorFunc();
    yield 6;
}
 
let g = generatorFunc1();
console.log(Array.from(g));

/*
輸出:
[1, 2, 3, 4, 5, 6]
*/

複製程式碼

除了獲取陣列外,我們還可以使用generator的yieldnext特性,來做非同步操作。

js中的非同步操作我們一般使用Promise來實現。

請看下列程式碼及註釋。

let g = null;
function *generatorFunc(){
	//第一個請求,模擬3s後臺操作
    let request1Data = yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("123");
        }, 3000);
    }).then((d) => {
    	 //令函式繼續執行,並把promise返回的資料通過next傳給上一個yield,程式碼會執行到下一個yield
        g.next(d);
    });

	 //輸出第一個請求的結果
    console.log('request1Data = ' + request1Data);

	 //同上,開始第二個請求
    let request2Data = yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("456");
        }, 3000);
    }).then((d) => {
        g.next(d);
    });
	
	 //第二個請求
    console.log('request2Data = ' + request2Data);
 }
 
 g = generatorFunc();
 g.next();
 console.log('completed');
 /*
 輸出:
 completed(馬上輸出)
 request1Data = 123(3s後輸出)
 request2Data = 456(6s後輸出)
 */
複製程式碼

我們換一種寫法:

let g = null;

function *request1(){
    return yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("123");
        }, 3000);
    }).then((d) => {
        g.next(d);
    });
}

function *request2(){
    return yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("456");
        }, 3000);
    }).then((d) => {
        g.next(d);
    });
}

function *generatorFunc(){
    let request1Data = yield *request1();
    console.log('request1Data = ' + request1Data);
    let request2Data = yield *request2();
    console.log('request2Data = ' + request2Data);
 }
 
 g = generatorFunc();
 g.next();
 console.log('completed');
 /*
 輸出同上
 */
複製程式碼

執行結果是相同的,所以我們可以看到,generator函式能夠把非同步操作寫成同步形式,從而避免了回撥地獄的問題。

非同步變成同步,不知道能夠避免多少因為回撥,作用域產生的問題,程式碼邏輯也能急劇簡化。

1.5.2 generator函式的自動執行

雖然我們可以通過generator消除非同步程式碼,但是使用起來還是不太方便的。

需要把generator物件提前宣告儲存,然後還要在非同步的結果處寫next()

經過觀察發現,這些方法的出現都是有規律的,所以可以通過程式碼封裝來將這些操作封裝起來,從而讓generator函式的執行,就像普通函式一樣。

提供這樣功能的是co.js(可以點這裡跳轉),大神寫的外掛,用於generator函式的自動執行,簡單的說它會幫你自動執行next()函式,所以藉助co.js,你只需要編寫yield和非同步函式即可。

使用co.js,上面的非同步程式碼可以寫成這樣:

let co = require('./co');

function *request1(){
    return yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("123");
        }, 3000);
    });
}

function *request2(){
    return yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("456");
        }, 3000);
    });
}

function *generatorFunc(){
    let request1Data = yield *request1();
    console.log('request1Data = ' + request1Data);
    let request2Data = yield *request2();
    console.log('request2Data = ' + request2Data);
 }
 co(generatorFunc);
 console.log('completed');
 /*
 輸出同上
 */
複製程式碼

可以看到,藉助co.js你只需要寫yield就能夠把非同步操作寫成同步呼叫的形式。

注意,請使用promise來進行非同步操作。

1.6 async和await

使用generator + Promise + co.js可以較為方便地實現非同步轉同步。

而js的新標準中,上面的操作已經提供了語法層面的支援,並將非同步轉同步的寫法,簡化成了2個關鍵字:awaitasync

同樣實現上節中的非同步呼叫功能,程式碼如下:


async function request1(){
    return await new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("123");
        }, 3000);
    });
}

async function request2(){
    return await new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("456");
        }, 3000);
    });
}

async function generatorFunc(){
    let request1Data = await request1();
    console.log('request1Data = ' + request1Data);
    let request2Data = await request2();
    console.log('request2Data = ' + request2Data);
 }

 generatorFunc();
 
 console.log('completed');
 
 /*
 輸出同上
 */
複製程式碼

await/async使用規則如下:

  • await只能用在async函式中。
  • await後面可以接任何物件。
  • 如果await後面接的是普通物件(非Promise,非async),則會馬上返回,相當於沒寫await。
  • 如果await後面是Promise物件,await會等待Promise的resolve執行後,才會繼續向下執行,然後await會返回resolve傳遞的引數。
  • 如果await後面是另一個async函式,則會等待另一個async完成後繼續執行。
  • 呼叫一個async函式會返回一個Promise物件,async函式中的返回值相當於呼叫了Promise的resolve方法,async函式中丟擲異常相當於呼叫了Promise的reject方法。
  • 通過上一條規則可知,雖然await/async使用了Promise來執行非同步,但是我們卻可以在使用這兩個個關鍵字的時候,不寫任何的Promise。
  • 另外,如果await後面的表示式可能丟擲異常,則需要在await語句上增加try-catch語句,否則異常會導致程式執行中斷。

await/async本身就是用來做非同步操作轉同步寫法的,它的規則和用法也很明確,只要牢記上面幾點,你就能用好它們。


//丟擲異常的async方法
async function generatorFunc1(){
    console.log("begin generatorFunc1");
    throw 1001;
}

//async方法返回的是Promise物件,使用Promise.catch捕獲異常
generatorFunc1().catch((e) => {
    console.log(`catch error '${e}' in Promise.catch`);
})

//正常帶返回值的async方法
async function generatorFunc2(){
    console.log("begin generatorFunc2");
    return 1002;
}

//async方法返回的是Promise物件,使用Promise.then獲取返回的資料
generatorFunc2().then((data)=>{
    console.log(`data = ${data}`);
})

//await後帶的async方法若丟擲異常,可以在await語句增加try-catch捕獲異常
async function generatorFunc3(){
    console.log("begin generatorFunc3");
    try{
        await generatorFunc1();
    }catch(e){
        console.log(`catch error '${e}' in generatorFunc3`);
    }
}

generatorFunc3();

console.log('completed');
/* 輸出:
begin generatorFunc1
begin generatorFunc2
begin generatorFunc3
begin generatorFunc1
completed
catch error '1001' in Promise.catch
data = 1002
catch error '1001' in generatorFunc3
*/
複製程式碼

--完--

相關文章