你不知道的JavaScript(中) - 閱讀筆記

wq93發表於2019-03-10

你不知道的JavaScript(中)

① 型別和語法

一. 型別

JS有七種內建型別:null,undefined,boolean,number,string,objectsymbol,可以使用typeof運算子來檢視

變數沒有型別,但它們持有的值有型別,型別定義了值的行為特徵

很多開發人員將undefinedundeclared混為一談,但在Js中它們是兩碼事.undefined是值的一種,而undeclared則表示變數還沒有被宣告過

遺憾的是,JS將它們混為一談,在我們試圖訪問"undeclared"變數時這樣報錯:ReferenceErroeL a is not defined, 並且typeof對undefinedundeclared變數都返回"undefined"

然而,通過typeof的安全防範機制(阻止報錯)來檢查undeclared變數,有時是個不錯的辦法

二. 值

2.1 陣列

如果字串鍵值能夠被強制型別轉換為十進位制數字的話,它就會被當作數字的索引來處理

var a = []
a['13'] = 42
a.length; // 14
複製程式碼

2.2 字串

JS中字串是不可變的,而陣列是可變的

字串不可變是指字串的成員函式不會改變其原始值,而是建立並返回一個新的字串. 而陣列的成員函式都是在其原始值上進行操作的

許多陣列函式用來處理字串很方便. 雖然字串沒有這些函式,但可以通過"借用"陣列的非變更方法來處理字串

可惜我們無法"借用"陣列的可變更成員函式,因為字串是不可變的,變通辦法是將字串轉換成陣列待處理完再轉換成字串

2.3 數字

JavaScript數字型別是基於IEEE754標準來實現的,該標準通常也被稱為"浮點數",JS使用的是"雙精度"格式(即64位二進位制)

0.1+0.2 === 0.3
0.1+0.2 === 0.3 //false
複製程式碼

簡單的來說,二進位制浮點數中的0.1和0.2並不是十分精準,它們相加的結果並非剛好等於0.3,而是一個比較接近的數字0.30000000000000004,所以條件判斷的結果是false

整數檢測

要檢測一個值是否是整數,可以使用ES6中的Number.isInteger(...)方法

Number.isInteger(42) //true
Number.isInteger(42.000) //true
Number.isInteger(42.3) //false
複製程式碼

2.4 特殊的數值

undefined型別只有一個值,即是undefined

null型別也只有一個值,即是null

  • null指空值
  • undefined指沒有值

或者

  • undefined指從未賦值
  • null指曾賦過值,但是目前沒有值

null是一個特殊關鍵字,不是識別符號,我們不能將其當作變數來使用和賦值. 然而undefined卻是一個識別符號,可以當作變數來使用和賦值

void運算子

表示式 void __沒有返回值,因此返回結果是undefined,void並不改變表示式的結果,只是讓表示式不返回值

var a = 42
console.log(void a , a) // undefined 42
複製程式碼

如果要將程式碼中的值設為undefined,就可以使用viod

特殊的數字(NaN)

NaN是一個"警戒值",用於指出數字型別中錯誤情況,即"執行數學運算沒有成功,這是失敗後返回的結果"

NaN唯一一個非自反

NaN是一個特殊值,是唯一一個非自反(自反,即X===X不成立)

特殊的等式

由於NaN和自身不相等,所以必須使用ES6的Number.isNaN(...)

ES6中新加入了一個工具Object.is(...)來判斷兩個值是否絕對相等

var a = 2 / "foo"
var b = -3 * 0

Object.is(a, NaN) // true
Object.is(0, -0)  // true
Object.is(b, 0) // true
複製程式碼

2.5 值和引用

簡單值(基本型別值)總是通過值的方式來賦值/傳遞,包括null,undefined,字串,數字,布林值ES6中的symbol

複合值--物件(包括陣列和封裝物件)和函式,則總是通過引用複製的方式來賦值/傳遞

我們無法自行決定使用值複製還是引用複製,一切由值的型別來決定

小結

JS中的陣列是通過數字索引的一組任意型別的值. 字串和陣列類似,但它們的行為特徵不同,在將字元作為陣列來處理需要特別小心. JS中的數字包括"整數"和"浮點數"

基本型別中定義了幾個特殊的值

null型別只有一個值null,undefined型別也只有一個值undefined . 所有變數在賦值之前預設值都是undefined.

void運算子返回undefined

數字型別有幾個特殊值,包括NaN(invalid number),+Infinity,-Infinity和 -0

簡單標量基本型別值(字串和數字等)通過值複製來賦值/傳遞,而複合值(物件等)通過值引用來賦值/傳遞. JS中的引用和其他語言的引用/指標不同,它們不能指向別的變數/引用,只能指向值

三. 原生函式

  • String()
  • Number()
  • Boolean()
  • Array()
  • Object()
  • Function()
  • RegExp()
  • Date()
  • Error()
  • Symbol()

內部屬性[[Class]] - 精準檢查值型別

Object.prototype.toString(...)來檢視一個複合值的型別

Object.prototype.toString.call([1,2,3]) // "[object Array]"
Object.prototype.toString.call(/regx-literal/i) // "[object RegExp]"
複製程式碼

由於基本型別值沒有.length.toString()這樣的屬性和方法,需要通過封裝物件才能訪問,此時JavaScript會自動為基本型別值包裝成一個封裝物件

原生函式作為建構函式

Array建構函式只帶一個數字引數的時候,該引數會作為陣列的預設長度,而非只充當陣列中一個元素

Date(...)和Error(...)

Date(...)主要用來獲取當前的Unix時間戳(從1970年1月1日開始計算),該值可以通過日期物件的getTime()來獲得

Es5引入一個靜態函式Date.now()來獲取當前時間戳

所有的函式(包括內建函式Number,Array等)都可以呼叫Function.prototype中的apply(...),call(...)bind(...)

小結

JavaScript為基本資料型別值提供了封裝物件,稱為原生函式(如String,Number,Boolean等)

它們為基本資料型別提供了該子型別所特有的方法和屬性

對於簡單標量基本型別值,比如abc,如果要訪問它的length屬性或String.prototype方法,JS引擎會自動對該值進行封裝來實現對這些屬性和方法的訪問

四. 強制型別轉換

JS中的強制型別轉換總是返回標量基本型別值,如字串,數字和布林值,不會返回物件和函式

然而在JS中通常將它們統稱為強制型別轉換,分為"隱式強制型別轉換"和"顯式強制型別轉換"

JSON字串化

undefined,functionsymbol和包含迴圈引用的物件都不符合JSON結構標準,其他支援JSON的語言無法處理它們

JSON.stringify(..)在物件中遇到undefined,functionsymbol時會自動將其忽略,在陣列中則會返回null(以保證單元位置不變)

JSON.stringify(undefined) // undefined
JSON.stringify(function(){}) // undefined
JSON.stringify(
    [1,undefined,function(){},4]
)  // "[1,null,null,4]"
JSON.stringify({
    a:2,b:function(){}
}) // "{"a":2}"
複製程式碼
實用功能

如果replace是一個陣列,那麼它必須是一個字串陣列,其中包含序列化要處理的物件的屬性名稱,除此之外其他的屬性則被忽略

var a = {
    b:42,
    c:"42",
    d:[1,2,3]
}
JSON.stringify(a,["b","c"]) //"{"b":42,"c":"42"}
JSON.stringify(a,(k,v)=>{
    if(k !== "c") return v
})
// "{"b":42,"d":[1,2,3]}"
複製程式碼

JSON.stringify還有一個可選引數space,用來指定輸出的縮排格式. space為正整數時是指定每一級縮排的字元數,它還可以是字串,此時最前面的十個字串被用於每一級的縮排

JSON.stringify(...)並不是強制型別轉換

  1. 字串,數字,布林值和null的JSON.stringify(...)規則與ToString基本相同
  2. 如果傳給JSON.stringify(...)的物件定義了toJSON()方法,那麼該方法會在字串化前呼叫,以便轉換成較為安全的JSON值

4.1 ToNumber

其中true轉換為1,false轉換為0.undefined轉換為NaN,null轉換為0

將值轉換為相應的基本型別值

首先檢查該值是否有valueOf()方法. 如果有並且返回基本型別值,就使用該值進行強制型別轉換. 如果沒有就使用toString()的返回值來進行強制型別轉換

如果valueOf()toString()均不返回基本型別值,會產生TypeError錯誤

ES5開始,使用Object.create(null)建立的物件[[Prototype]]屬性為null,並且沒有valueOf()toString()方法,因此無法進行強制型別轉換

4.2 ToBoolean

JavaScript中的值可以分為以下兩類:

  1. 可以被強制型別轉換為false的值
  2. 其他(被強制轉換為true的值)

以下假值的布林強制型別轉換結果為false:

  • undefined
  • null
  • false
  • +0, -0 和 NaN
  • ""

假值列表以外的都是真值

4.3 顯式強制型別轉換

字串和數字之間的顯式轉換

String(....)遵循前面講過的ToString規則,將值轉換為字串的基本型別. Number(...)遵循前面講過的ToNumber規則,將值轉換成數字的基本型別

一元運算 + 被普通認為是顯式強制型別轉換

日期顯示轉換為數字
var timestamp = +new Date()

// 不過最好還是使用ES5中新加入的靜態方法Date.now()
var timestamp = Date.now()
複製程式碼
~運算子

~x大致等同於-(x+1)

~ 和 indexOf( )一起可以將結果強制型別轉換

var a = "Hello World"
~a.indexOf("lo") // -4 <--真值
if(~a.indexOf("lo")) {
    // 找到匹配
}
~a.indexOf("ol")  // 0 <-- 假值
複製程式碼

-(x+1)推斷~ -1的結果應該是-0,然而實際上結果是0,因為它是字位操作而非樹形運算

顯式解析數字字串
var a = "42"
var b = "42px"
Number(a) // 42
parseInt(a) // 42

Number(b) // NaN
parseInt(b) // 42
複製程式碼

解析允許字串中含有非數字字元,解析按從左到右的順序,如果遇到非數字字元就停止. 而轉換不允許出現非數字字串,否則會失敗並返回NaN

例外 : parseInt(1/0, 19) // 18

parseInt(1/0, 19)實際上是parseInt("Infinity", 19). 第一個字元是"I",以19為基數時值為18. 第二個字元"n"不是一個有效的數字字元,解析到此為止

顯示轉換為布林值

建議使用Boolean(a)和!!a 來進行顯式強制型別轉換

4.4 隱式強制型別轉換

隱式強制型別轉換指的是那些隱蔽的強制型別轉換

var a = [1,2]
var b = [3,4]
a + b // "1,23,4"
複製程式碼

因陣列的valueOf()操作無法得到簡單基本型別值,於是它轉而呼叫toString(). 因此上例中的兩個陣列變成了"1,2"和"3,4". + 將它們拼接了

var a = 42
var b = a + ""
b // "42
複製程式碼

根據ToPrimitive抽象操作規則,a + ""會對a 呼叫valueOf()方法,然後通過ToString抽象操作將返回值轉換為字串. 而String(a)則是直接呼叫ToString()

隱式強制型別轉換為布林值

下面的情況會發生布林值隱式強制型別轉換:

  1. if(...)語句中的條件判斷表示式
  2. for(.. ; .. ; ..)語句中的條件判斷表示式(第二個)
  3. while(..)和do..while(..)迴圈中的條件判斷表示式
  4. ? : 中的條件判斷表示式
  5. 邏輯運算子 || (邏輯或) 和 && (邏輯與) 左邊的運算元(作為條件判斷表示式)
|| 和 &&

&& 和 || 運算子的返回值並不一定是布林型別,而是兩個運算元其中一個的值

|| 和 &&首先會對第一個運算元執行條件判斷,如果其不是布林值就先進行ToBoolean強制型別轉換,然後再執行條件判斷

4.5 寬鬆相等和嚴格相等

正確的解釋是:" == "允許在相等比較中進行強制型別轉換,而" === "不允許

  • 用法:

    如果兩個值的型別不同,我們就需要考慮有沒有強制型別轉換的必要,有就用==,沒有就用===,不用在乎效能

抽象相等==

"=="在比較兩個不同型別的值時會發生隱式強制型別轉換,會將其中之一或者兩者的轉換為相同的型別後再進行比較

a. 字串和數字之間的相等比較
var a = 42
var b = "42"
a === b  // fasle
a == b  // true
複製程式碼
  1. 如果Type(x)是數字,Type(y)是字串,則返回x==ToNumber(y)的結果
  2. 如果Type(x)是字串,Type(y)是數字,則返回ToNumber(x)==y 的結果
b. 其他型別和布林型別之間的相等比較
var a = '42'
var b = true
a == b //false
複製程式碼
  1. 如果Type(x)是布林型別,則返回ToNumber(x) == y的結果
  2. 如果Type(y)是布林型別,則返回x == ToNumber(y)的結果
var x = "42"
var y = false
x == y  // false
複製程式碼

解析: Type(y)是布林值,所以ToNumber(y)將false強制型別轉換為0,然後"42" == 0 再變成42 == 0,結果是fasle

建議無論什麼情況下不用使用 == true 和 == false

c.null和undefined之間的相等比較
  1. 如果x為null,y為undefined,則結果為true
  2. 如果x為undefined,y為null,則結果為true

在 == 中null和undefined相等,除此之外其他值都不存在這種情況

var a = null
var b 
a == b // true
a == null // true
b == null // true

a == false // false
b == false // false
a == ""
b == ""
a == 0
b == 0
複製程式碼
d. 物件和非物件之間相等比較
  1. 如果Type(x)是字串或者數字,Type(y)是物件,則返回x==ToPrimitive(y)的結果
  2. 如果Type(x)是物件,Type(y)是字串或者數字,則返回ToPrimitive(x)==y的結果
比較少見的情況

見中卷P84

使用建議
  • 如果兩邊的值中有true或者false,千萬不要使用"=="
  • 如果兩邊的值中有[],""或者0,儘量不要使用"=="

4.6 抽象比較

如果比較雙方都是字串,則按字母順序來進行比較

var a = ["42"]
var b = ["043"]
a < b // false
複製程式碼

解析: ToPrimitive返回的是字串,所以這裡比較的是"42"和"043"兩個字串,它們分別以"4"和"0"開頭.

var a = {b:42} 
var b = {b:43}
複製程式碼

解析: 因為a是[object object],b是[object object],所以按字母順序進行比較

小結

JS的資料型別之間的轉換,即強制型別轉換: 包括顯式和隱式

顯式強制型別轉換明確告訴我們哪裡發生了型別轉換,有助於提高程式碼可讀性和可維護性

隱式強制型別轉換則沒有那麼明顯,是其他操作的副作用

五. 語法

5.1 語句和表示式

  • 語句 : 語句相當於句子,完整表達某個意思的一組詞
  • 表示式: 表示式相對於短語,JS中表示式可以返回一個結果值
var a,b
a = if(true) {
    b = 4 + 38
}
複製程式碼

上面這段程式碼無法執行,因為語法不允許我們獲得語句的結果值並將其賦值給另一個變數

ES7規範有一項"do表示式":

var a,b
a = do {
	if(true) {
    	b = 4 + 38
	}
}
a // 42
複製程式碼

其目的是將語句當作表示式來處理(語句中可以包含其他語句),從而不需要將語句封裝為函式再呼叫return來返回值

表示式的副作用
一元運算子

++在前面時,如++a,它的副作用產生在表示式返回結果值之前,而a++的副作用則產生在之後

delete運算子

delete用來刪除物件中的屬性和陣列中的單元

如果操作成功,delete返回true,否則返回false. 其副作用是屬性被從物件中刪除(或單元從array中刪除)

上下文規則
if(a) {
    //...
}
else if(b) {
	//...        
}
else {
    //...
}
複製程式碼

事實上JS沒有else if,但ifelse只包含單條語句的時候可以省略程式碼塊的 { }

5.2 運算子優先順序

運算子 說明
.[ ] ( ) 欄位訪問、陣列索引、函式呼叫和表示式分組
++ -- - ~ ! delete new typeof void 一元運算子、返回資料型別、物件建立、未定義的值
* / % 相乘、相除、求餘數
+ - + 相加、相減、字串串聯
<< >> >>> 移位
< <= > >= instanceof 小於、小於或等於、大於、大於或等於、是否為特定類的例項
== != === !== 相等、不相等、全等,不全等
& 按位“與”
^ 按位“異或”
| 按位“或”
&& 邏輯“與”
|| 邏輯“或”
?: 條件運算
= OP= 賦值、賦值運算(如 += 和 &=)
, 多個計算

5.5 函式引數

function foo(a = 42,b = a + 1) {
    console.log(
    	arguments.length, a , b,
        arguments[0], arguments[1]
    )
}
foo()    // 0 42 43 undefined undefined
foo(10)  // 1 10 11 10 undefined
foo(10,undefined )  // 2 10 11 10 undefined
foo(10,null )  // 2 10 11 10 undefined
複製程式碼

雖然引數a和b都有預設值,但是函式不帶引數時, arguments陣列為空

相反,如果向函式傳遞undefined值,則arguments陣列中會出現一個值為undefined的單元,而不是預設值

5.6 try..finally

function foo() {
    try {
        return 42
    }
    finally {
        console.log("hello")
    }
    console.log("never runs")
}
console.log(foo())
// hello
// 42
複製程式碼

這裡return 42先執行,並將foo()函式的返回值設定為42. 然後try執行完畢,接著執行finally. 最後foo()函式執行完畢.

function foo() {
    try {
        throw 42
    }
    finally {
        console.log("hello")
    }
    console.log("never runs")
}
console.log(foo())
// hello
// Uncaught Exception: 42
複製程式碼

如果finally中丟擲異常,函式就會在此終止. 如果此前try中已經有return設定了返回值,則該值會被丟棄

小結

語句和表示式在英語中都能找到類比---語句就像英文中的句子,而表示式就像短語. 表示式可以是簡單獨立的,否則可能會產生副作用

JS在語法規則上是語義規則. 例如, { } 在不同情況下的意思不盡相同, 可以是語句塊,物件常量,解構賦值(ES6)或者命名函式引數 (ES6)

ASI(自動分號插入)是JS引擎的程式碼解析糾錯機制,它會在需要的地方自動插入分號來糾正解析錯誤. 問題在於這是否意味著大多數的分號都不是必要的,或者由於分號缺失導致的錯誤是否都可以交給JS引擎來處理

混合環境

JavaScript程式幾乎總是在宿主環境中執行

在建立帶有id屬性的DOM元素時也會建立同名的全域性變數


② 非同步和效能

一. 非同步: 現在與將來

1.1 非同步控制檯

並沒有什麼規範或一組需求指定console.*方法族如何工作--它們並不是JavaScript的一部分,而是由宿主環境新增到JavaScript中的

在某些條件下,某些瀏覽器的console.log(....)並不會把傳入的內容立即輸出,在許多程式中,I/O是非常低速的阻塞部分

如果在除錯的過程中遇到物件在console.log(....)語句之後被修改,可你卻看到了意料之外的結果,要意識到這可能是這種I/O的非同步化造成的

1.2 事件迴圈

程式通常分成很多小塊,在事件迴圈佇列中一個接一個地執行. 嚴格地說,和你的程式不直接相關的其他事件也可能會插入到佇列中

1.3 並行執行緒

非同步是關於現在和未來的時間限制,而並行是關於能夠同時發生的事情

多執行緒程式設計是非常複雜的. 因為如果不通過特殊的步驟來防止這種中斷和交錯執行的話,可能會得到出乎意料的,不確定的行為.

JavaScript從不跨執行緒共享資料,這一味著不需要考慮這一層次的不確定性. 但是這並不意味著JavaScript總是確定性的

`示例程式碼`
var a = 20
function foo() {
    a = a + 1 
}
function bar() {
    a = a * 2
}
// ajax非同步請求的回撥
ajax('/get',foo)
ajax('/get2',bar)
複製程式碼

在JS的特性中,這種函式順序的不確定性就是通常所說的競態條件,foo()bar()相互競爭,看誰先執行.

完整性,由於JS的單執行緒特性,foo()(以及bar())中的程式碼具有原子性. 一旦foo()開始進行,它的所有程式碼都會在bar()中的任意程式碼進行之前完成,或者相反,這稱為完整執行特性

1.4 併發

setTimeout(..0)

基本的意思是: 把這個函式插入到當前事件迴圈佇列的結尾處

嚴格來說,setTimeout(..0)並不直接把專案插入到事件迴圈佇列. 定時器會在有機會的時候插入事件. 兩個連續的setTimeout(..0)呼叫不能保證會按照呼叫順序處理

....

小結

實際上,JavaScript程式總是至少分為兩個塊: 第一個塊現在執行;下一個塊將來執行,以響應某個事件. 儘管程式是一塊一塊執行的. 但是所有這些塊共享對程式作用域和狀態的訪問,所以對狀態的修改都是在之前累積的修改之上進行的.

一旦有事件需要執行,事件迴圈就會執行,直到佇列清空. 事件迴圈的每一輪稱為一個tick. 使用者互動,IO和定時器會向事件佇列中加入事件

任何時刻,一次只能從佇列中處理一個事件. 執行事件的時候,可能直接或間接地引發一個或多個後續事件

併發是指兩個或多個事件鏈隨時間發展交替執行,以至於從更高的層次來看,就像是同時在執行(儘管在任意時刻只處理一個事件)

通常需要對這些併發執行的"程式"進行某種形式的互動協調,比如需要確保執行或者需要防止競態出現. 這些"程式"也可以通過把自身分割為更小的塊,以便其他"程式"插入進來.

二. 回撥

回撥是編寫和處理JS程式非同步邏輯的最常用方式

巢狀回撥常常稱為回撥地獄,有時也稱為毀滅金字塔

2.3 回撥的信任問題

  • 呼叫回撥過早(在追蹤之前)
  • 呼叫回撥過晚(或者沒有呼叫)
  • 呼叫回撥的次數太多或太少
  • 沒有把所需的環境/引數成功傳給你的回撥函式
  • 吞掉可能出現的錯誤或異常

2.5 總結

回撥函式是JS非同步的基本單元

第一,大腦對於事件的計劃方式是線性的,阻塞的,單執行緒的語義,但是回撥錶達非同步流程的方式是非線性的,非順序的,這使得正確推導這樣的程式碼難度很大. 難於理解的程式碼是壞程式碼,會導致壞bug

我們需要一種更同步,更順序,更阻塞的方式來表達非同步,就像我們的大腦一樣

第二,也是更重要的一點,回撥會受到控制反轉的影響,因為回撥暗中把控制權交給第三方(通常是不受你控制的第三方工具!)來呼叫你程式碼中的continuation. 這種控制轉移導致一系列麻煩的信任問題,比如回撥被呼叫的次數是否會超出預期

可以發明一些特定邏輯來解決這些信任問題,但是其難度高於應有水平,可能會產生更笨重,更難維護的程式碼,並且缺少足夠的保護,其中的損害要直到你受到bug的影響才會被發現

我們需要一個通用的方案來解決這些信任問題. 不管我們建立多少回撥,這一方案都應可以複用,且沒有重複程式碼的開銷

我們需要比回撥更好的機制. 到目前為止,回撥提供了很好的服務,但是未來的JS需要更高階,功能更強大的非同步模式

三. Promise

通過回撥錶達程式非同步和管理併發的兩個主要缺陷: 缺乏順序性和可信任性

一旦Promise決議,它就永遠保持在這個狀態.此時它就成為了不變值(immutablevalue),可以根據需求多次檢視

Promise決議後就是外部不可變的值,我們可以安全地把這個值傳遞給第三方,並確信它不會被有意無意地修改.

3.1 Promise"事件"

程式碼:

function foo(x) {
    // 開始做一些可能耗時的工作
    
    //構造並返回一個promise
    return new Promise((resolve,reject)=>{
        // 最終呼叫resolve(...)或者reject(...)
    })
}
複製程式碼

這些是promise的決議函式. resolve(...)通常標識完成,而reject(...)則標識拒絕

3.2 具有then方法的鴨子型別

識別Promise就是定義某種稱為thenable的東西,將其定義為任何具有then(...)方法的物件和函式

thenable值的鴨子型別檢測就大致類似於:

if(
	p !== null &&
    (
    	typeof p === 'object' ||
        typeof p === 'function'
    )&&
    typeof p.then === 'function'
){
    // 假定這是一個thenable
}else{
	// 不是thenable
}
複製程式碼

如果有任何其他程式碼無意或者惡意地給Object.prototypeArray.prototype或者其他原生原型新增then(..),

你無法控制也無法預測,並且,如果指定的是不呼叫起引數作為回撥的函式,那麼如果有Promise決議到這樣的值,就會永遠掛住!

3.3 Promise的信任問題

回撥的信任問題
  • 呼叫回撥過早(在追蹤之前)
  • 呼叫回撥過晚(或者沒有呼叫)
  • 呼叫回撥的次數太多或太少
  • 沒有把所需的環境/引數成功傳給你的回撥函式
  • 吞掉可能出現的錯誤或異常
3.3.1 呼叫過早

在這類問題中,一個任務有時同步完成,有時非同步完成,這可能會導致競態條件

對一個Promise呼叫then(..)的時候,因為即使這個Promise已經決議,提供給then(..)的回撥也總會被非同步呼叫

3.3.2 呼叫過晚

Promisethen(..)註冊的觀察就會被自動排程. 可以確信,這些被排程的回撥在下一個非同步事件點上一定會被觸發. 也就是說, 一個Promise決議後,這個Promise上所有的通過then(...)註冊的回撥都會在下一個非同步時機點上 依次被立刻呼叫.

p.then(function() {
    p.then(function() {
        console.log("C")
    })
    console.log("A")
})
p.then(function() {
        console.log("B")
})
// A B C
// 這裡,"C"無法打斷或搶佔"B",這就是Promise的運作方式
複製程式碼
3.3.3 回撥未呼叫

沒有任何東西能阻止Promise像你通知它的決議. 如果你對一個Promise註冊了一個完成回撥和一個拒絕回撥,那麼Promise在決議時總會呼叫其中的一個

3.3.4 呼叫次數過少或過多

由於Promise只能被決議一次,所以任何通過then(..)註冊的回撥就只會呼叫一次

當然,如果你把同一個回撥註冊了不止一次(如: p.then(...); p.then(...)),那它被呼叫的次數就會和註冊次數相同. 響應函式只會被呼叫一次.

3.3.5 未能傳遞引數/環境值

Promise至多隻能有一個決議值(完成或拒絕)

如果使用多個引數呼叫resolve(..)或者reject(..),第一個引數之後的所以引數都會被忽略.

JS中的函式總是保持其定義所在的作用域的閉包

3.3.6 吞掉錯誤或異常(重要)

如果Promise的建立過程中或在檢視其決議結果過程中的任何時間點上出現一個JS異常錯誤,比如一個TypeErrorReferenceError,那這個異常就會被捕捉,並且會使這個Promise被拒絕

var p = new Promise(function(resolve,reject){
    foo.bar() // foo未定義,會報錯
    resolve(42) // 永遠不會到這裡
})
p.then(\
    function fulfilled() {
        // 永遠不會到達這裡
    },
    function rejected(err) {
        // err將會是一個TypeError異常物件來自foo.bar()這一行
    }
)
複製程式碼

因為其有效解決了另外一個潛在的Zalgo風險,即出錯可能會引起同步響應,而不出錯則會是非同步的. Promise甚至把JS異常也變成了非同步行為,進而極大降低了競態條件出現的可能.

var p = new Promise(function(resolve,reject){
    resolve(42)
})
p.then(
	 function fulfilled(msg) {
        foo.bar()
        console.log(msg) // 永遠不會到達這裡
    },
    function rejected(err) {
        // 永遠不會到達這裡
    }
)
複製程式碼

這看起來foo.bar()這一行產生的異常被吞掉了,實際上不是這樣的,實際上是我們沒有偵聽到它

p.then(..)呼叫本身返回了另一個Promise,正是這個Promise將會因這個TypeError異常而拒絕

問: 為什麼它不是簡單地呼叫我們定義的錯誤處理函式?

答: 如果這樣的話就違背了Promise的一條基本原則, Promise一旦決議就不可再變. p已經完成為值42,所以之後檢視p的決議是,並不能因為出錯就把p再變成一個拒絕

3.3.7 是可信任的promise嗎

如果向Promise.resolve(..)傳遞一個非Promise或非thenable的立即值,就會得到一個用這個值填充的promise.

var p1 = new Promise(function(resolve,reject){
    resolve(42)
})
var p2 = Promise.resolve(42)
// 以上兩個promise的行為完全是一致的
複製程式碼

而如果向Promise.resolve(...)傳遞一個真正的Promise,就會返回一個Promise

Promise.resolve(...)可以接受任何thenable,將其解封為它的非thenable值. 從Promise.resolve(...)得到的是一個真正的Promise,是一個可以信任的值. 如果你傳入的已經是真正的Promise,那麼你得到的就是本身,所以通過Promise.resolve(...)過濾來獲得可信任性完全沒有壞處.

對於用Promise.resolve(...)為所有函式的返回值都封裝一層. 另一個好處是,這樣做容易把函式呼叫規範為定義良好的非同步任務. 如果一個函式有時會返回一個立即值,有時會返回Promise,那麼Promise.resolve(...)就能保證總會返回一個Promose結果

3.3.8 建立信任

Promise這種模式通過可信任的語義把回撥作為引數傳遞,使得這種行為更可靠更合理.通過把回撥的控制反轉反轉回來,我們把控制權放到一個可信任的系統(Promise)中,這種系統的設計目的就是為了使非同步編碼更清晰

3.4 鏈式流

鏈式流得於實現關鍵在於以下兩個Promise固有行為特徵:

  1. 每次對promise呼叫then(..),它都會建立並返回一個新的Promise,我們可以將其連結起來
  2. 不管從then(..)呼叫的完成回撥返回值是什麼,它都會自動設定為被連結Promise(第一點中的)的完成
function delay(time) {
    return new Promise(function (resolve, reject) {
        setTimeout(resolve, time)
    })
}

delay(100) // 步驟1
    .then(function STEP2() {
        console.log('step2 after 100ms')
        return delay(200)
    })
    .then(function STEP3() {
        console.log('step3 after another 200ms')
    })
    .then(function STEP4() {
        console.log('step4')
        return delay(50)
    })
    .then(function STEP5() {
        console.log('step5 after another 50ms')
    })
複製程式碼

嚴格來說: 這個互動過程中有兩個promise: 200ms延遲promise和第二個then(..)連結到的那個連結promise.

Promise機制已經自動把它們的狀態合併在一起,可以把return delay(200)看作是建立了一個promise,並用其替換了前面返回的連結promise

從本質來說,這使得錯誤可以繼續沿著Promise鏈傳播下去,直到遇到顯示定義的拒絕處理函式

總結
  • 呼叫Promisethen(..)會自動建立一個新的Promise從呼叫返回
  • 在完成或拒絕處理函式的內部,如果返回一個值或丟擲一個異常,新返回的Promise就相應地決議
  • 如果完成或拒絕處理函式返回一個Promise,它將會被展開,這樣一來,不管它的決議值是什麼,都會成為當前then(..)返回的連結Promise的決議值
決議,完成以及拒絕

決議(resolve),完成(fulfill)和拒絕(reject)

3.5 錯誤處理

錯誤處理最自然的形式就是同步的try...catch結構

任何Promise鏈的最後一步,不管是什麼,總是存在著未被檢視的Promise中出現未捕獲錯誤的可能性

3.5.2 處理未捕獲的情況(未實現)

瀏覽器有一個特有的功能:

它們可以跟蹤並瞭解所有物件被丟棄以及被垃圾回收的時機. 所以,瀏覽器可以追蹤Promise物件. 如果在它被垃圾回收的時候其中拒絕,瀏覽器就能確保這是一個真正未被捕獲的錯誤,進而可以確定應該將其報告到開發者終端.

3.5.3 成功的坑
  • 預設情況下,Promise在下一個任務或時間迴圈tick上報告所有拒絕麼如果在這個時間點上該Promise上還沒有註冊錯誤處理函式
  • 如果想要一個被拒絕的Promise在檢視之前的某個時間段被保持被拒絕狀態,可以呼叫defer(..),這個函式優先順序高於該Promise的自動錯誤報告
var p = Promise.reject('Oops').defer()
foo(42)
.then(
    function fulfilled(){
        return p
    },
    function rejected(err) {
        // 處理foo(...)錯誤
    }
)
// 呼叫defer(),這樣就不會有全域性報告出現. 為了便於連結,defer()只是返回這同一個promise
複製程式碼

預設情況下,所有的錯誤要麼被處理要麼被報告,呼叫defer()的危險是,如果defer()了一個Promise,但之後沒有成功檢視或處理它的拒絕結果,這樣就有可能存在未被捕獲的情況

3.6 Promise模式

3.6.1 Promise.all([..])

Promise.all([...])需要一個引數,是一個陣列,通常由Promise例項組成. 從Promise.all([..])呼叫返回的promise會收到一個完成訊息. 這是一個由所有傳入promise的完成訊息組成的陣列,與指定的順序一致(與完成順序無關)

  • 嚴格說來,傳給Promise.all([..])的陣列中的值可以是Promise, thenable,甚至是立即值.

  • 就本質而言,列表中的每個值都會通過Promise.resolve(..)過濾,以確保要等待的是一個真正的Promise,所以立即值會規範化為為這個值構建的Promise.

  • 如果陣列是空的,主Promise就會立即完成

  1. Promise.all([..])返回的主promise在且僅在所有成員promise都完成後才會完成.
  2. 如果這些promise中有任何一個被拒絕的話,主Promise.all([..])promise就會立即被拒絕,並拋棄來自其他所有promise的全部結果
  3. 永遠要記住為每個promise關聯一個拒絕/錯誤處理函式,特別是從Promise.all([..])返回的那一個
3.6.2 Promise.race([..])

Promise.race([..])也接受單個陣列引數. 這個陣列由一個或多個Promise,thenable或 立即值組成. 但是立即值之間的競爭在實踐中沒有太大的意義

  • Promise.all([..])類似,一旦有任何一個Promise決議為完成,Promise.race([..])就會完成;一旦有任何一個Promise決議為拒絕,它就會拒絕
  • 如果你傳入一個空陣列,主race([..])Promise永遠不會決議,而不是立即決議

3.7 Promise API概述

3.7.1 new Promise(..)構造器

有啟示的構造器Promise(..)必須和new一起使用,並且必須提供一個函式回撥. 這個回撥是同步的或立即呼叫的

var p = new Promise(function(resolve,reject){
    // resolve(..) 用於決議/完成這個promise
    // reject(..) 用於拒絕這個promise
})
複製程式碼
  1. reject(..)就是拒絕這個promise;但resolve(..)即可能完成promise,也可能拒絕,要根據傳入引數而定.
  2. 如果傳給resolve(..)的是一個非Promise,非thenable的立即值,這個promise就會用這個值完成
  3. 如果傳給resolve(..)的是一個真正的promise或thenable值,這個值就會被遞迴展開,並且promise將取用其最終決議值或狀態
3.7.2 Promise.resolve(...)Promise.reject(...)

Promise.resolve(...)建立一個已完成的Promise的快捷方式

Promise.reject(...)建立一個已被拒絕的Promise的快捷方式

var p1 = new Promise(function(resolve,reject){
    reject('Oops')
})
var p2 = Promise.reject("Oops")
// 以上兩個promise是等價的
複製程式碼
3.7.3 then(...)catch(...)
  1. then(..)接受一個或者兩個引數;第一個用於完成回撥,第二個用於拒絕回撥. 如果兩者中的任何一個被省略或者作為非函式值傳入的話,就會替換為相應的預設回撥. 預設完成回撥只是把訊息傳遞下去,而預設拒絕回撥則只是重新丟擲其接收到的出錯原因.
  2. catch(..)只接受一個拒絕回撥作為引數,並自動替換預設完成回撥. 它等價於then(null,...)
p.then(fulfilled)
p.then(fulfilled,rejected)
p.catch(rejected); // 等價於.then(null,rejected)
複製程式碼

then(..)和catch(..)也會建立並返回一個新的promise,這個promise可以用於實現Promise鏈式流程控制

3.7.4 Promise.all(...)Promise.race(...)

詳情見3.6

Promise.all([..])傳入空陣列,它會立即完成,但Promise.race([..])會掛住,且永遠不會決議

3.8 Promise侷限性

3.8.1 順序錯誤處理

由於一個Promise鏈僅僅是連結到一起成員Promise,沒有把整個鏈標識為一個個個體的實體,這意味著沒有外部方法可以用於觀察可能發生的錯誤

我們可以在Promise鏈中註冊一個拒絕錯誤處理函式,對於鏈中任何位置出現的任何錯誤,這個處理函式都會得到通知:p.catch(handleErrors);

但是,如果鏈中的任何一個步驟事實上進行了自身的錯誤處理,那麼handleErrors(..)就不會得到通知. 完全不能得到錯誤通知也是一個缺陷. 基本上,這等同於try..catch存在的侷限:

try...catch可能捕獲一個異常並簡單地吞掉它. 所以這不是Promise獨有的侷限性,但可能是我們希望繞過的陷阱

3.8.2 單一值

Promise只能有一個完成值或一個拒絕理由.

3.8.3 單決議

Promise最本質的特徵是:

一個promise只能被決議一次(無論完成還是拒絕)

3.8.4 慣性
3.8.5 無法取消的Promise

一旦建立了一個Promise併為其註冊了完成和拒絕處理函式,如果出現某種情況使得這個任務掛起的話,你也沒有辦法從外部停止它的程式

3.8.6 Promise的效能

Promise使所有一切都成為非同步的了,即有一些立即完成的步驟仍然會延遲到任務的下一步. 這意味著一個Promise任務序列可能比完全通過回撥連線的同樣的任務序列執行的稍慢一點.

Promise稍慢一些,但作為交換,你得到的是大量內建的可信任,對Zalgo的避免及可組合性.

請使用它!

四. ES6生成器(generator)

生成器是一類特殊的函式,可以一次或多次啟動和停止,並不一定非得要完成

var x = 1
function *foo() {
    x++
    yield
    console.log('x':x)
}
function bar() {
    x++
}

// 構造一個迭代器it來控制這個生成器
var it = foo()
// 這裡啟動foo()
it.next()
x // 2
bar()
x // 3
it.next() // x:3
複製程式碼

解析:

  1. it = foo()運算並沒有執行生成器*foo(),而只是構造一個迭代器,這個迭代器會控制它的執行
  2. 第一個it.next()啟動了生成器 *foo(),而並執行了 *foo()第一行的x++
  3. *foo() 在yield語句處暫停,在這一點上第一個it.next()呼叫結束. 此時 *foo()仍然在執行並且是活躍的,但處於暫停狀態
  4. 我們檢視x的值,此時是2
  5. 我們呼叫bar(),它通過x++再次遞增x
  6. 我們再次檢視x的值是3
  7. 最後的it.next()呼叫從暫停處恢復了生成器*foo()的執行,並執行console.log(..)語句,這句語句使用當前x的值是3

4.1 打破完整執行

4.1.1 輸入和輸出
function *foo(x,y) {
    return x * y
}
var it = foo(6,7)
var res = it.next()
res.valur // 42
複製程式碼

我們只是建立一個迭代器物件,把它賦給一個變數it,用於控制生成器*foo(..). 然後呼叫it.next(),指示生成器 *foo(..)從當前位置開始繼續執行,停在下一個yield處或者直到生成器結束

這個next(..)呼叫的結果是一個物件,它有一個value屬性,持有*foo(..)返回的值. 換句話說,yield會導致生成器在執行過程中傳送出一個值,這有點類似於中間的return

根據你的視角不同,yieldnext(...)呼叫有一個不匹配. 一般來說,需要的next(..)呼叫要比yield語句多一個

因為第一個next(..)總是啟動一個生成器,並執行到第一個yield處. 不過,是第二個next(..)呼叫完第一個被暫停的yield表示式,第三個next(..)呼叫完成第二個yield,以此類推

訊息是雙向傳遞的---yield...作為一個表示式可以發出訊息響應next(..)呼叫,next(..)也可以向暫停的yield表示式傳送值

程式碼:

function *foo(x) {
    var y = x * (yield "hello") // yield一個值
    return y
}
var it = foo(6)
var res = it.next() // 第一個next(),並不傳入任何東西
res.value; 			// "hello"
res = it.next(7) 	// 	向等待的yield傳入7
res.value			// 42
複製程式碼

yield.. 和 next(..)這一對組合起來,在生成器中的執行過程中構成一個雙向訊息傳遞的系統

注意

var res = it.next()	// 第一個next(),並不會傳入任何東西
res.value; 			// "hello"
res = it.next(7) 	// 	向等待的yield傳入7
res.value			// 42 
複製程式碼

​ 我們並沒有向第一個next()呼叫和傳送值,這是有意為之. 只有暫停的yield才能接受一個通過next(..)傳遞的值,而在生成器的起始處我們呼叫第一個next()時,還沒有暫停的yield來接受這樣一個值. 規範和所有相容瀏覽器都會默默丟棄傳遞第一個next()的任何東西. 傳值過去仍然不是個好思路,因為你建立了沉默無效程式碼,這會讓人迷惑. 因此,啟動生成器時一定要用不帶引數的next()

如果你的生成器中沒有return的話---在生成器中和普通函式中一樣,return當然不是必需的---總有一個假定的/隱式的return(也就是return undefined),它會在預設情況下回答最後的it.next(7)提出的問題.

4.1.2 多個迭代器

每次構建一個迭代器,實際上就隱式構建了生成器的一個例項,通過這個迭代器來控制的是這個生成器例項.

同一個生成器的多個例項可以同時執行,他們甚至可以彼此互動:

function *foo() {
    var x = yield 2
    z++
    var y = yield(x * z)
    console.log(x,y,z)
}
var z = 1
var it1 = foo()
var it2 = foo()
var val1 = it.next().value	// 2 <--yield 2 
var val2 = it.next().value	// 2 <--yield 2
var1 = it1.next(val2 * 10 ).value	//40 <-- x:20 , z:2
var2 = it2.next(val1 * 10 ).value	//600 <-- x:200 , z:3

it1.next(val2/2) // y:300
				// 20 300 3
it2.next(val1/4) // y:10
				//200 10 3
複製程式碼

執行流程:

​ (1) *foo()的兩個例項同時啟動,兩個next()分別從yield2語句得到值2

​ (2) val2 * 10也就是2 * 10,傳送到第一個生成器例項it1,因此x得到的值20. z從1增加到2,然後20 * 2通過yield發出,將val1設定40

​ (3) val15也就是40 * 5,傳送到第二個生成器例項it2,因此x得到值200. z再次從2遞增到3,然後2003通過yield發出,將val2設定為600

​ (4) val2/2也就是600/2,傳送到第一個生成器例項it1,因此y得到值300,然後列印出x y z的值分別是20 300 3

​ (5) val1/4也就是40/4,傳送到第二個生成器例項it2,因此y得到值10,然後列印出x y z的值分別為200 10 3

4.3 同步錯誤處理

yield暫停也使得生成器能夠捕獲錯誤

function *main(){
  var x = yield "hello world"
  yield x.toLowerCase() // 引發一個異常
}
var it = main()
it.next().value // hello world
try{
  it.next(42)
}catch(err) {
  console.error(err) // TypeError
}
複製程式碼

4.4 生成器+Promise

ES6中最完美的世界就是生成器和Promise的結合

迭代器應該對這個promise做什麼呢?

它應該偵聽這個promise的決議,然後要麼使用完成訊息恢復生成器執行,要麼向生成器丟擲一個帶有拒絕原因的錯誤.

獲取Promise和生成器最大效用的最自然的方法就是yield出來一個Promise,然後通過這個Promise來控制生成器的迭代器.

程式碼示例:

function foo(x,y) {
  return request("http://some.url.1/?x="+ x + "&y="+ y)
}
function *main() {
  try{
    var text = yield foo(11,31)
    console.log(text)
  }catch(e) {
    console.log(e)
  }
}
複製程式碼

在生成器內部,不管什麼值yield出來,都只是一個透明的實現細節,所以我們甚至沒有意識到其發生,也不需要關心,接下來實現接收和連線yield出來的promise,使它能夠在決議之後恢復生成器.先從手工實現開始:

var it = main();
var p = it.next().value;
// 等待promise p決議
p.then(function(text){
  it.next(text)
},function(err){
  it.throw(err)
})
複製程式碼
async...await

生成器+promise的語法糖

如果,你await了一個Promise,async函式就會自動獲知要做什麼,它會暫停這個函式,直到Promise決議

生成器中的Promise併發

最簡單的方法:

function *foo() {
  // 讓兩個請求"並行"
  var p1 = request("http://some.url.1")
  var p2 = request("http://some.url.2")
  
  // 等待兩個Promise都決議
  var r1 = yield p1
  var r2 = yield p2
  
  var v3 = yield request(
  	"http://some.url.3/?v="+ r1 + "," + r2
  )
  console.log(r3)
}
// 工具函式run
run(foo)
複製程式碼

p1和p2都會併發執行,無論完成順序如何,兩者都要全部完成,然後才會發出r3 = yield request...Ajax請求

當然,我們也可以使用Promise.all([...])完成

function *foo() {
  // 讓兩個請求"並行"
  var results = yield Promise.all([
    request("http://some.url.1"),
    request("http://some.url.2")
  ])
  
  // 等待兩個Promise都決議
  var r1 = results[0]
  var r2 = results[1]
  
  var v3 = yield request(
  	"http://some.url.3/?v="+ r1 + "," + r2
  )
  console.log(r3)
}
// 工具函式run
run(foo)
複製程式碼

.....華麗的略過線.....

小結

​ 生成器是ES6的一個新的函式型別,它並不像普通函式那樣總是執行到結束. 取而代之的是,生成器可以執行當中暫停,並且將來再從暫停的地方恢復執行.

​ 這種交替的暫停和恢復是合作性的而不是搶戰式的,這意味著生成器具有獨一無二的能力來暫停自身,這是通過關鍵字yield實現的. 不過,只有控制生成器的迭代器具有恢復生成器的能力(通過next(..))

​ yield/next(..)這一對不只是一種控制機制,實際上也是一種雙向訊息機制. yield..表示式本質上是暫停下來等待某個值,接下來的next(...)呼叫會向被暫停的yield表示式傳回一個值(或者是隱式的undefined)

​ 在非同步控制流程方面,生成器的關鍵優點是:

​ 生成器內部的程式碼是自然的同步/順序方式表達任務的一系列步驟. 其技巧在於,我們把可能的非同步隱藏在了關鍵字yield的後面,把非同步移動到控制生成器的迭代器的程式碼部分.

換句話說,生成器為非同步程式碼保持了順序,同步,阻塞的程式碼模式,這使大腦可以更自然地追蹤程式碼,解決了基於回撥的非同步的兩個關鍵字缺陷之一.


原文地址: 傳送門

Github歡迎Star: wq93


相關文章