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()
可直接訪問並修改所在函式中的變數arr
和idx
。
一般說來,閉包需要實現尾遞迴優化。
尾遞迴是指,如果一個函式,它的最後一行程式碼是一個閉包的時候,會在函式返回時,釋放父函式的棧空間。
這樣一來,依賴閉包的遞迴函式就不怕棧溢位了(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個:apply
和call
,二者實現的功能完全相同,即改變函式的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的yield
和next
特性,來做非同步操作。
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個關鍵字:await
和async
。
同樣實現上節中的非同步呼叫功能,程式碼如下:
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
*/
複製程式碼
--完--