前言

這是參考這是參考2019 iT 邦幫忙鐵人賽的你懂 JavaScript 嗎?這篇文章來學習和記錄

你懂JavaScript嗎?運算子、運算式、值與型別、變數、條件式、迴圈

這裏主要內容為程式設計簡介,在此可看到初䌣階段所必須理解的各種專有名詞。

程式碼(Code)

程式(program)又稱原始碼(source cde)、程式碼(code)、用來表示一群執行特定工作的指令,也可以說是述句組成的集合。

語法(Syntab)

規範有交指令與組合的規則,稱為電腦語言(computer language)或語法(syntax)。可想成若希望能編寫電腦可懂的語言,就必須遵循一套規則來撰寫,而這個規則𣄵是語法,就和我們平常溝通所說的語言的文法是一樣的。

述句(Statement)

會執行特定工作的字詞、數字或運算子(operator)組合,即是述句(statement),例如:a = b+1就會執行b+1並將結果指定給a。

字面值(Literal Value)

獨立存在的值, 沒有存在於任何變數中,到如:a = b + 1中的1。

直譯器(Interpreter)與編譯器(Compiler)

直譯器與編譯器可將程式碼由上到下逐行轉為電腦可懂的命令。其差別在於時機點

  • 直譯(interpret):在程式執行「時」做轉換。

  • 編譯(compile):在程式執行「前」做轉換,然後會產出編譯後的指令,因此之後執行的是這個編譯後的結果。注意,JavaScript引擎在每次執行前即時編譯程式碼,接著立刻執行編譯後的指令。

運算子(Operaors)

對變數或值進行操作的字元,例如:a = b + 1中的=+

JavaScript有以下幾種運算子。

  • 指定運算子(assignment):其實就是等號運算子(=)來進行「指定」的工作,當計算完畢等號右邊的值後,接著將結果放進等號左邊的變數,這個戶進去的動作就是指定。例如:a = b +1,就是將b + 1的結果放到a。

  • 算數運算子(math):進行加(+)減(-)乘(*)除(/)的運算,例如:b + 1

  • 複合指定運算子(compound assignment):+=-=*=/=將算術運算子和指定運算子結合在一起,例如:a += 1, 等同於a = a + 1

  • 遞增運算子(increment/decrement):++(遞增)與--(遞減),例如a++,等同於a= a+1

  • 物件特性的存取運算子(object property access:):利用.(點記號法,dot notation)或[](方括號記號法,bracket notation)的方式存取物件的特性,例如:obj.aobj['a'].因為簡單便利較常使用,但[]卻可在索引值是變數或有特殊字元時能保證完成值的存取,例如:obj[]'h e l l o'(有空白)、obj['#$%^&'](特殊字元)、obj['123'](開頭為數字)。𠯌想了解命名規則,待變數命名的部分會再詳述。

  • 相等性運算子(equality):可分為==(寬鬆相等)、===(嚴格相等)、!=(寬鬆不相等)、!==(嚴格不相等),主要差異是做值的比較時是否會做強制轉型。

  • 比較運算子(comparison):<(小於)、>(大於) 、<=(小於等於)>=(大於等於)例如:a > b表示比較a是否大於b。注意比較結果一定會是布林值。

  • 邏輯運算子(logical):&&(and)、||(or),例如:a || b表示選擇a或b,常用於表達複合條件,設定初始值。

  • 位元運算子(bitwise):將運算元當成32位元的0和1來看待,位元運算子將運算元以二進位的方式處㻫,接著以JavaScript數字型態回傳結果。例如:5 & 1會被看成0101 & 0001,得到結果0001,回傳1。 點此看更多範例。

  • 字串運算子(string):+可串接兩字元,並回傳結果,通常用連接變數與字串。不過目前改用ES6的字串模板(string template)了,使用${ variable_name}即可代入變數,而不需再用+與雙/單引號拼湊字串,方便許多,範例如下

    1
    2
    3
    4
    5
    6
    7
    const name = 'Summer';

    // 使用字串運算子
    const greetings_1 = 'Hello ' + name + '!'; // "Hello Summer!"

    // 使用字串模板
    const greetings_2 = `Hello ${name}!`; // "Hello Summer!"
  • 條件(三元)運算子(conditional/ternary):條件(三元)運算子接再兩個運算元作為值且一個運算元作為條件。語法是條件?值1:值2。若「條件」為true,運算子回值「值1」,否則回傳「值2」。如下,條件count <= 0得到false, 因此得到prompt為還有存貨」。

    1
    2
    3
    4
    const count = 10;
    const prompt = count <= 0 ? '全部賣完了' : '還有存貨';

    prompt // "還有存貨"
  • 逗點運算子(comma):,用來隔開多個運算式並由左至右循序執行,最後會回傳最右邊的運算式的結果,通常用於(1)for迴圈內部,讓多個變數能在每次迴圈中被更新;(2)變數宣告。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    for(let i = 0, j = 10; i < j; i++, j--) {
    console.log(`i: ${i}, j: ${j}`);
    }

    // i: 0, j: 10
    // i: 1, j: 9
    // i: 2, j: 8
    // i: 3, j: 7
    // i: 4, j: 6
  • 一元運算子(unary):一元運算是只需要一個運算元的運算,例如:delete 運算子可刪除(隱式宣告的)物件,物件的(非內建)屬性或陣列中經由指定索引而找到的物件。其他的一元運算子還有typeof等。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const x = 1;
    y = 2;
    const product = {
    name: 'apple',
    count: 100,
    }

    delete x // false
    x // 1

    delete y // true
    y // Uncaught ReferenceError: y is not defined

    delete product.count // true
    product // {name: "apple"}
  • 關係運算子(relational):關係運算子比較兩運算元並根據比較結果回傳布林值。例如:in運算子可得知特定屬性是否存在於物件中,instanceof可用來判斷是否為指定的物件型別。

    in 運算子的範例。

    1
    2
    3
    4
    5
    6
    7
    const product = {
    name: 'apple',
    count: 100,
    }

    'name' in product // true
    'valid' in product // false

    instanceof運算子的範例

    1
    2
    product instanceof Object // true
    product instanceof Array // false

運算式(Expression)

對「某個變數或值,或以運算子結合起來的一組變數或值」的參考(reference)。例如:a = b+1這個述句中有四個運算式,分別是1、b、b +1a = b + 1,其中1是字面值運算式(literal value expression)、b是變數運算式(variable expression)用來取得變數的值、b + 1是算術運算式(arithmetic expression)用來進行加法運算,a = b + 1是指定遲算式(assignment expression)用來將結果指定給變數存起來。這裡順道一提「呼叫運算式(call expression)」,意即函式呼叫運算式本身,例如:alert(a)

值與型別(Values&Types)

型別,指的是「值的不同的表示法」,主要分為兩種「基本型別」(pirmitives, 即number、string、boolean、null、undefined、symbol)和「物件型別」(object)。

基本型別(Primitive Types)
  • number 數字,例如:12345。

  • string 字串,例如:Hello World

  • boolean 布林,例如:true、false。

  • null

  • undefined

  • symbol

物件型別

除了基本型別外的資料型別都是物件,物件型別又分以下子型別

  • array 陣列:使用數值化索引來儲存值,而非如物件是使用屬性來儲存值。

  • function函式:一個函式是指一段具名的程式碼片段,我們可藉由呼叫其名稱來執行它,可簡化重複進行的工作會包裝特定功能的程式碼,並且函式可接受參數、回傳值。這裡會牽涉到另一個概念「範疇(Scope)」,範疇是指一群變數或這些變數如何透過名稱來存取的規範而組成的一個集合,關於範疇之後會再詳述。

    1
    2
    3
    4
    function sayHi(name) {
    console.log(`Hi, I am ${name}`);
    }
    sayHi("Jack");// Hi, I am Jack
  • date 日期

在型別之間進行轉換(Converting Between Types)

我們有時候會需要將資料在不同型別間轉換,例如,遇到在表單中輸入一連串的金額(此時是字串),接著計算金額時就會希望將這些字串轉成數字來做加減乘除的操作,這時候就可能需要做轉型,從字串轉成數字。

例如、小明在蝦X買了一件商品準備在母親節送給媽媽,並選擇貨到付款。來看看商品金額、運費和總金額,商品金額(product)是100(以定串型態存在),運費(shipment)也同樣是100(以數字型態存在),𫍇時候總金額total是?

1
2
const product = "100";
const shipment = 100;

咦?怎麼會正100100!

1
const total = product + shipment;

原來是因為若兩值資料型別不同,當其中一方是字串,+所代表的就是字串運算子,而將數字強制轉型為字串,並連接兩個字串。解決就是使用Number強制轉型(coerce),將字串的部份轉為數字就可以做數學運算了。

1
2
3
const product = "100";
const shipment = 100;
const total = Number(product) + shipment;//200

除了強制轉型,來看在比較兩個非相同型別的值時候會發生隱含的(implicit)轉型。

例如,小明想要比較買這個商品付這運費划算嗎?運費該不會比商品還貴吧?

1
2
3
4
const product = "100";
const shipment = 100;
product === shipment // false
product == shipment // true

咦?一個是字串,一個是數字,是怎麼能做比較?這是由於JavaScript偷偷做了(隱含的)轉型的原因,那…到底做了什麼呢?

  • product === shipment : 不做轉型,因此型別對比較是有影響的。

  • product == shipment : 會強轉型,規則是(1)布林轉數字;(2)字串轉數字;(3)使用valueOf()將物件取 基本型別的值,再做比較。關於強型轉型的詳細說明之後會再詳述或參考規格

    小明看到商品價格與運費居然相等,還是先湊個免運再買吧!

typeof

typeof 可用於檢測值的型別是什麼。

1
2
3
4
5
6
7
8
9
10
console.log(typeof "Hello World");// string
console.log(typeof true);// boolean
console.log(typeof 12345678);// number
console.log(typeof null);// object
console.log(typeof undefined);// undefined
console.log(typeof { name: "jack" });// object
console.log(typeof Symbol());// symbol
console.log(typeof function () { });// function
console.log(typeof [1, 2, 3]);// objcet
console.log(typeof NaN);// number

這裡會看到幾個有趣的(奇怪的)地方…

  • null 是基本型別之一,但typeof null卻得到object,而非null!這可說是一個bug,可是若因為修正了這個bug,則可能會導致很多網站壞掉,因此就不修了!

  • 雖然說function是物件的子型別,但typeof functon(){}是得到function而非object, 和陣列依舊得到object是不一樣的。

  • NaN表是是無效的數字,但依舊還是數字,因此在資料型別的檢測typeof NaN結果就是number。不要被字面上的意思「不是數字」(not a number)給弄糊塗了。另外,NaN與依何數字運算都會得到NaN,並且NaN不大於,不小於也不等於任何數字,包含NaN它自已。

內建方法(Built-In Type Methods)

意即物件以屬性(或稱方法)的形式對外提供的行為,例如

1
2
const prompt = "Hello World";
console.log(prompt.length);//11

這背後的原因是每個型基本上都會其物件包裏器(object wrapper,又稱原生natives)的型態做對應使用,例如資料別string的物件包裹器型態就是String,而就是這個包裹器型態在其原型(prototype)上定義了許多屬性和方法,因此這些資料型態就能和物件般擁有屬性和方法以供使用。

相等性與不等性
相等性(Equality)

關於相等性的運算子有四個「==」(寬鬆相等性loose equality)、「===」(嚴格相等性strict equality) 、「!=」和「!==」。寬鬆與嚴格的差異在於檢查值相等時是否會做強制轉型,==會做強制轉型,而===不會。

1
2
3
4
5
const a ='100';
const b = 100;

console.log(a == b);// true 強制轉型,將字串 '100' 轉為數字 100
console.log(a ===b);//false

另外,關於值的儲存方式有傳值「pass by value」和傳址/參考「pass by referece」兩種,其中pass by value又可再細分是否為「pass by sharing」(可參考這篇)、基本型別是值,而物件型別是傳址。當比較兩物件時,比較的是儲存的位置,因此看起來是相同的物件,但比較結果卻是不相同的。

1
2
3
4
const a = [1, 2, 3, 4, 5];
const b = [1, 2, 3, 4, 5];
console.log(a == b);//false
console.log(a === b);//false
不等性(Inequality)

關於不等於性的比較運算子有><>=<=共四種,在這裡有幾種狀況需要注意

  • 若比較的值都是字串,則以字典的字母順序為主。

    1
    console.log("ab" < "cd");//true
  • 若比較的值型別不同,由於值的不等性比較沒有嚴格不相等這重性況,因此,為論什麼樣的比較都會被強制轉型為數字,無法轉為數字的就會變成NaN。

    1
    2
    3
    4
    5
    console.log("99" > 98);//true, 字串"99"被強昀轉型為數字 99

    console.log("Hello World" > 1);//false 字串"Hello World" 無法轉為數字,變成NaN
    console.log("Hello World" < 1);//false
    console.log("Hello World" = 1);//false
  • NaN不大於、不小於、不等於任何值,當然也不等於自已。

    1
    2
    3
    4
    console.log(NaN > NaN);//false
    console.log(NaN < NaN);//false
    console.log(NaN === NaN);//false
    console.log(NaN == NaN);//false

    程式碼註解(code Comments)

    ///*...*/

    程式碼註解有多重要就不用再提了吧!

變數(Variables)

變數是儲存值的地方,又稱為符號佔位器(symbolic placeholder),例如:a = b + 1中的a和b。變數的作用是「管理程式的狀態」,讓我們能將程式中各種會變動的狀態(也就是值)存起來並搭配運算子組成運算式做一些運算。注意,JavaScript是弱型別的語言,意即宣告變數,賦值後仍可改變值的資料型別。

1
2
3
4
5
6
7
8
// 用 var 宣告一個物件
var product = {
name: 'apple',
count: 100,
}

// 用 let 宣告一個有作用域限制的變數,範圍限於大括號內
let name = 'Nina';

常數(Constants)

ES6使用const來宣告常數,代表這變數的值不會改變,在嚴格模式下還會報錯。

1
onst PI = 3.14;

命名規則

變數的命名必須要為有效的識別字,何謂有效?就是必須以a-z、A-Z、$(錢字號)或_(底線)開頭,之後可加上a-z、A-Z、$(錢字號)、_(底線)和數字0-9,並且不可以是關鍵字或保留字。變數的命名規則同樣也適用於物件特性的命名,只是物件特性的名稱可為關鍵字或保留字。

1
2
3
4
5
6
7
var happy = 'happy'; // 這是合法的

var @@ = 'sad'; // 這是不合法的,報錯「Uncaught SyntaxError: Invalid or unexpected token」

var obj = { // 這是合法的,物件的特性可使用關鍵字或保留字命名
this: 'this is an object',
}
什麼是關鍵字?又什麼是保留字

關鍵字」(keyword)是指在目前ECMAScript中有特定用途的英文字詞,而「保留字」(reserved word)則是系統留爭,雖然目前尚未用到但未來可能有其他用途的字彙。再次強調,不管是關鍵字或保留字都不能做變數名稱使用。

區塊(Blocks)

區塊是由一對大括號(curly-brace pair、{...})所規範出來的範圍。

1
2
3
if (count > 10) {
// 區塊範圍在此...
}

條件式(Conditionals)

表達條件的𤆧法有if述句、switch述句、條件(三元)運算子和迴圈,以下分別述之。

  • if述句,括號內的即是條件,若條件為真,則有指定的事情,括號內的條件放置運算式,運算結果是布林值true或false,若非布林值就會強制轉型(例如:0或空字串會被轉為false, 而其他就會轉為true)。範例如下,若商品金額大於運費,就買; 否則就不買。

    1
    2
    3
    4
    5
    6
    7
    8
    const product = "100";
    const shipment = 100;
    const total = Number(product) + shipment; //200
    if (product > shipment) {
    console.log("But it !");
    } else {
    console.log("Do not buy it!");
    }

    結果得到「Do not buy it!」。

  • switch述句等同於if-else的縮寫,依靠break來決定是否要持續進行下一個case述句,若沒有break就會「落穿而過」。範例如下,這裡有一個檢測庫存的簡易範例,假設目前庫存數量為50,當庫存為0~2時提示要趕快進貨補庫存,庫存到達50時顯示庫存充裕,庫存到達100時提示貨品是不是賣不掉,其他狀況都顯示為運作正常。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const count = 50;
    switch (count) {
    case 0:
    case 1:
    case 2:
    console.log("快賣完了!趕快進貨!");
    break;
    case 50:
    console.log("庫存充裕");
    case 100:
    console.log("是不是賣不後!?")
    default:
    console.log("運作正常");
    break;
    }

    但出乎意料的是,結果印出「庫存充裕、是不是賣不掉了!?、運作正常」

    1
    2
    3
    庫存充裕
    是不是賣不掉了!?
    運作正常

    這是因為如果沒有加入break,一豆某個符合條件了,托下來的case無論侜合與否都會被執行,也就是剛才所提到的「落穿而過」。

    加入break修正一下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    const count = 50;
    switch (count) {
    case 0:
    case 1:
    case 2:
    console.log("快賣完了!趕快進貨!");
    break;
    case 50:
    console.log("庫存充裕");
    break;
    case 100:
    console.log("是不是賣不後!?")
    break;
    default:
    console.log("運作正常");
    break;
    }

    結果印出

    1
    庫存充裕
    • 條件(三元)運算子(conditional/ternary)
    • 迴圈(loops)使用條件式來判斷迴圈是否繼續或停止。

Truthy & Falsy

在JavaScript中會被轉為false的值有

  • ""空字串

  • 0,-0,NaN

  • null

  • undefined

  • false

    而除了以上之外,都會被轉為true舉例如下

  • Hello World非空字串

  • 42非零的效數字

  • [],[1, 2, 3]陣列,不管是不是空的

  • {},{ name: 'Jack'}物件,不管是不是空的

  • function foo(){}函式

  • true

    如果真的很不確定到底會轉成什麼,可以使用!!做測試

    1
    2
    3
    !![] // true
    !!{} // true
    !!NaN // false

迴圈(Loops)

重複一組動作,直到檢測條件不成立為止。迴圈的形成有很多動,最常用的就是while迴圈(while或do…while)和fot迴圈兩種。

while迴圈

while迴圈的構成有以下要素:測試條件和區塊,而每次執行區塊時就稱為一次迭代(iteration)。

whilevsdo...while

兩者的差異在於while是先測後跑,而do...while是先跑後測。

來看第一個簡單例子,假設商品數量目前有五個,每賣掉一個就將庫存減一,當全賣完(及庫存為零)的時候就跳出迴圈,並印出「全部賣完了」的訊息。

1
2
3
4
5
6
7
let product = 5;
while (product > 0) {
console.log("買一個");
product--;
console.log(`現在還剩 ${product}個。`);
}
console.log("全部賣完了");
1
2
3
4
5
6
7
8
9
10
11
12
買一個 
現在還剩 4個。
買一個
現在還剩 3個。
買一個
現在還剩 2個。
買一個
現在還剩 1個。
買一個
現在還剩 0個。
全部賣完了

但下面這個例子就超有不同了,此時更改商品數量為零,剛剛提到while是「先測後跑」,因此當檢驗測試條件時,發現product > 0得到fals,也就不會進入區塊了,直接印出「全部賣完了」的訊息。

1
2
3
4
5
6
7
let product = 0;
while (product > 0) {
console.log("買一個");
product--;
console.log(`現在還剩 ${product}個。`);
}
console.log("全部賣完了");

再來看while...loop,這個例子並無異狀,跟第一個例子所得到的結果完全相同。

1
2
3
4
5
6
7
let product = 5;
do {
console.log("買一個");
product--;
console.log(`現在還剩 ${product}個。`);
} while (product > 0);
console.log("全部賣完了");
1
2
3
4
5
6
7
8
9
10
11
12
買一個 
現在還剩 4個。
買一個
現在還剩 3個。
買一個
現在還剩 2個。
買一個
現在還剩 1個。
買一個
現在還剩 0個。
全部賣完了

但是…剛剛提到while...loop是「先跑後測」,我們又將商品數量改為零,此時會先進入區塊,依序印出「買一個」、商品數量減一、顯示「現在還剩-1個」、最後才檢驗測試條件、終止迴圈的執行,印出「全部賣完了」的訊息。

1
2
3
4
5
6
7
let product = 0;
do {
console.log("買一個");
product--;
console.log(`現在還剩 ${product}個。`);
} while (product > 0);
console.log("全部賣完了");
1
2
3
買一個 
現在還剩 -1個。
全部賣完了
break

使用break跳出迴圈。

範例如下,在product為2的時候跳出迴圈。

1
2
3
4
5
6
7
8
9
10
11
12
let product = 5;
while (product > 0) {
console.log("買一個");
product--;
console.log(`現在還剩 ${product}個。`);

if (product === 2) {
console.log("停停停,不要賣了!快進貨啊。");
break;
}
}
console.log("全部賣完了");
1
2
3
4
5
6
7
8
9
買一個 
現在還剩 4個。
買一個
現在還剩 3個。
買一個
現在還剩 2個。
停停停,不要賣了!快進貨啊。
全部賣完了

continue

使用continue跳過本次迭代,迴圈依舊持續進行。

範例如下,在product為2的成候忽略之後要執行的console.log(現在還剩 ${product} 個);直接進入下一次迭代。

1
2
3
4
5
6
7
8
9
10
11
12
let product = 5;
while (product > 0) {
console.log("買一個");
product--;

if (product === 2) {
console.log("第二個我要暗摃起來");
continue;
}
console.log(`現在還剩 ${product}個。`);
}
console.log("全部賣完了");
1
2
3
4
5
6
7
8
9
10
11
12
買一個 
現在還剩 4個。
買一個
現在還剩 3個。
買一個
第二個我要暗摃起來
買一個
現在還剩 1個。
買一個
現在還剩 0個。
全部賣完了

for迴圈

for迴圈有三個子句-初始化子句、條件測試子句、更新子句。

  • 初始化子句,例如:let product = 5

  • 條件測件子句,例如:product > 0

  • 更新子句,例如:product--

    1
    2
    3
    4
    5
    for (let product = 5; product > 0; product--) {
    console.log("買一個");
    console.log(`現在還剩 ${product}個。`);
    }
    console.log("全部賣完了");
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    買一個 
    現在還剩 5個。
    買一個
    現在還剩 4個。
    買一個
    現在還剩 3個。
    買一個
    現在還剩 2個。
    買一個
    現在還剩 1個。
    全部賣完了

你懂JavaScript嗎?-變數、嚴格模式、IIFEs、閉包、模組、this、原型、Polyfill與Transpiler

上面有大致聊過了一些基本普識,像是運算子、運算式、值與型別、變數、條件式、迴圈,本文還會再探討一些基礎概念,像是

  • 變數的存取規則,包含函式範疇、拉升、巢狀範疇。

  • 嚴格模式:一個讓程式碼變得更,更容易優化的方法。

  • 更多關於範疇和函式的應用,包含IIFE、閉包、模組

  • this:到底是指哪個?這個還是那個?應該不少人都黑人問號

  • 原型可說是物件的一種fallback機制,並且提供了行為委派。

  • 舊功能與新特色的共存,可使用Polyfill和Transpiler來做兼容。

變數(Variable)

這個部份要來談關於變數的存取規則,例如:範疇、拉升等。

函式範疇(Function Scope)

函式會建立自已的範疇,其內的識別字(不管是變數、函式)僅能在這個函式裡面使用。如下在全域範疇底下,是無法存取foo內的a、b、c和bar,否則會導致ReferrenceError;但在foo自已的函式範疇內,可以存取a、b、c和bar。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo(a) {
var b = 2;

function bar() {
// ...
}

var c = 3;
}

console.log(a); // ReferrenceError
console.log(b); // ReferrenceError
console.log(c); // ReferrenceError

bar(); // ReferrenceError
拉升(Hoisting)

在程式執行前,編譯器(compiler)會先由上到下逐行將程式碼轉為電腦可懂的命令,然後再執行編譯後的指令。在這個編譯的階段,編譯器找出所有的變數並繫結所屬範疇,但不賦,所以此刻變數所帶的值是undefined; 而在執行階段,JavaScript引擎才會處理給值的事情。

我們可以把這個過程想像成是嫄這些變數「提升」到程式碼的最頂端,如下範例所示,因此當印出a的值的時候,會是已宣告但還沒賦值的狀態,也就是有這個變數,但其值是undefined,一直到程式執行了,才給值。因此,我們可以在程式碼任何地方呼叫運用這變數,但只有在正式宣告之後才能有正確的值可用,在宣告之前使用都會得到undefined。

1
2
3
4
var a; // 編譯時期的工作

console.log(a); // undefined
a = 2; // 執行時期的工作
巢狀範疇(Nested Scope)

若在目前執行的範疇找不到這個變數的時候,就會往外層的範疇搜尋,特續搜尋直到找到為止,或直到最外層的全域範疇(globl scope,在瀏覽器底下就是指window)。

如下console.log(a + b)中,b無法在foo中找到,但可從全域範疇中追出來。

1
2
3
4
5
6
7
const foo = (a) => {
console.log(a + b);
}

const b = 2;

foo(2); // 4

相較於巢狀範疇是以函式為劃分單位,區塊範疇就是以大括號為界線了。

嚴格模式(Strict Mode)

嚴格 鄉簡單說就是為了預防開發者的一些小心或錯誤的行為,JavaScript引擎協助做了一些檢測的工作,當開發都誤用時就把錯誤丟出來。可參考MDN

範例如下,在未宣告變數而賦值機狀況下,會無預警的產生一個全域變數,但若使用嚴格模式(use strict)則會禁止這行為外,還會報錯,告知開發都變數尚未被定義。

1
2
'user stict';
a = 1; //Uncaught ReferenceRrrot: a is not defined

就把它想像成是一個諄教誨的好者師!總是願意告訴你殘忍的實話…

作為值的函式(Function as Value)

這標題看起來有點怪怪的(?)但其實也只是要說明,函式本身就和其化的值一樣,是可以被指定給某變數、當參數傳遞或當成其它函式的回傳值。記得,函式也只是物件的子型別而已, 沒有什麼特別的。

指定給某個變數,如下,指定給foo。

1
2
3
var foo = function() {
console.log("大家好,我是 foo!");
}

當參數傳遞,如下,將foo當成是bar的參數傳入。

1
2
3
4
5
6
7
var foo = function () {
console.log("大家好,我是 foo!");
}
function bar(func) {
func();
}
bar(foo);// 大家好,我是 foo!

當其他函式的回傳值,foo是baz的回值,並將結果指定給result。

1
2
3
4
5
6
7
8
9
var foo = function () {
console.log("大家好,我是 foo!");
}

var result = function baz(func) {
return func;
}

result(foo)();// 大家好,我是 foo!

因此,這個函式值(例如:var foo=function(){...})也可被視為是一個運算式,就稱呼它為「函式運算式」吧。之後還會提到函式宣告、函式運算式與匿名vs具名,待後續詳細的說明。

即刻調用函式運算式(Immediately Invoked Function Expression,IIFE)

IIFE是為可立即執行的函式運算式。一般的函式運算式並不會馬上執行、若要執行除了在其名稱後加上小括號外,還可以利用IIFE的𤆧式執行它。匿名或具名皆合法。使用IIFE的好處主要是不污染全域範疇。

範例如下,這是一個匿名的IIFE,a在全域範疇是找不到的。

1
2
3
4
5
6
7
(function () {
var a=3;
console.log(a);//
})();

// 不污染全域範疇
a;//Uncaught ReferenceError:a is not defined

閉包(Closure)

閉包是指變數的生命週其只存在於該函式內,一旦離開了函式,該變數就會被回收而不可再利用,且必須在函式內事先宣告。

範例如下,在函式closure內可以存取a的值,但離開了函式closure走到全域範疇之下,就取不到a的值了,因此會被報錯「Uncaught ReferenceError:a is nto defined」。

1
2
3
4
5
6
function closure() {
var a=1;
console.log(a);// 1
}
closure();
a;// Uncaught ReferenceError: a is not defined

模組(Module)

模組模式(Module Pattern)又稱為揭露模組(RevealingModule),經由建立一個模組實體(Moduel Instance,如下範例的foo), 來調用內層函式。而內層函式由於具有閉包的特性。因此可存取外層包含函式(Oute Enclosing Function)之內的變數和函式。透過模組模式,可隱藏私密的資訊,並對外公開API。

範例如下,CoolModule對外公開API doSomething和doAnother, CoolModule之外是無法取得其私有的something𢘊another兩個變數的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join(" ! "));
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}

var foo = CoolModule();
foo.doSomething();// cool
foo.doAnother();

this識別字(this Identifier)

this到底是指向誰一直都是個令人費解的問題。

簡單來說,this是function執行時所屬的物件,而this是在執行時期做繫結,其值和函式在哪裡被呼叫(call-site)有關。

總結規則如下,並以匹配的優先順序由高至低排列

  • new 綁定:this會指向new出來的物件。
  • 明確綁定:使用call、apply、bind,明確指出要綁定給this的物件。
  • 隱含綁定:當函式為物件的方法(method)時,在執行階段this就會被綁定至該物件。
  • 預設綁定:當其他規則都適用時,意即沒有使用bind,call, apply或不屬於任何物件的method,就套用預設綁定,在非嚴格模式下,瀏覽器環境this的值就是預設值全域物件window,而在嚴格模式下,this的值就是undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo() {
console.log(this.bar);
}

var bar = 'global';

var obj1 = {
bar: 'obj1',
foo: foo
};

var obj2 = {
bar: 'obj2'
};

foo(); // 'global'
obj1.foo(); // 'obj1'
foo.call( obj2 ); // 'obj2'
new foo(); // undefined

原型(Prototype)

原型可說是物件的一種callback機制,當在此物件找不到指定屬性時,就會透過原型鏈結(prototype link/prototype reference)追溯到其父物件上。範𠕥如下,若想存取bar.a但由於bar並為a屬性。因此𠺾會透過原型鏈結找到foo,並得偌100這個值。

1
2
3
4
5
6
7
var foo = { a: 100 };

var bar = Object.create(foo); // 建立 bar 物件,並連結到 foo
bar.b = 'hi';

bar.a // 100,委派給 foo
bar.b // 'hi'

另外,原型最常應用於「行為委派」(behaviro delegation),如上例所示,將物件bar的行為委派給foo, 這也是常聽到很類似於其他語言的類別的繼承功能,但其實完全不同。

舊功能與新特色的共存

面對新舊功能並存的狀況要怎麼處理呢?這裡要介紹兩種方法-Polyfill和Tranxpiler。

Polyfill

Polyfilling的意思就是依據一個新功能的定義,製作具有相同行無,而能在較舊的JavaScript環境執行的程式碼,候話說就是為舊瀏覽掛載新功能。

這裡來看一個例子,針對isNan的改進…

isNan

NaN表示值無效的數字,它會產生的原因是

  • 做數字運算時的兩個運算元的資料型別並非都數字或無法轉成有的十進位或十六進位的數字。

  • 無意義的運算,例如:0/0, Infinity/Infinity。

以上𣄵會產生NaN。

在ES6以前,開發者使用isNaN在數學運算或解析字串後檢測得到的結果是否為合法的數字,其實就是檢測是否為NaN,其過程為先將輸入值使用Number強制轉為數字。無法轉為有的數字而得到NaN時就判定等於NaN,結果得到true。

範例如下,空物件{}經過isNaN判斷是NaN,意即不為數字

1
2
3
4
console.log(isNaN({}));//
// 拆宗詳細過程如下
console.log(Number({})); // 先將空物件轉為數字,得到NaN
console.log(isNaN(NaN)); // 檢查是不否為 NaN,得到true

其它範例還有..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
isNaN(123) // false
isNaN(-1.23) // false
isNaN(5-2) // false
isNaN(0) // false
isNaN('123') // false
isNaN('Hello World') // true
isNaN('2000/01/01') // true
isNaN('') // false
isNaN(true) // false
isNaN(undefined) // true
isNaN('NaN') // true
isNaN(NaN) // true
isNaN(0/0) // true
isNaN(1/0) // false

但這檢測方式常常會讓開發者得到讓人容易誤解的結果(像是空物件{}就真的不等於NaN呀),因此ES6推出了Number.isNaNNumber.isNaN不會經過轉為數字的這過程,而是直托判斷型別否為數字且是否等於NaN。承上範𠕥,檢測空物件{}是否為NaN,得到false

1
Number.isNaN({}) // 直接檢查空物件是否為 NaN,得到 false

同樣也來看剛才的範例…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Number.isNaN(123) // false
Number.isNaN(-1.23) // false
Number.isNaN(5-2) // false
Number.isNaN(0) // false
Number.isNaN('123') // false
Number.isNaN('Hello World') // false
Number.isNaN('2000/01/01') // false
Number.isNaN('') // false
Number.isNaN(true) // false
Number.isNaN(undefined) // false
Number.isNaN('NaN') // false
Number.isNaN(NaN) // true
Number.isNaN(0/0) // true
Number.isNaN(1/0) // false

雖然ES6出了這個新功能,但不見得所有的瀏覽器都會支援,因此對於較舊瀏覽,就掛個polyfill來模擬這個新功能。

polyfill如下

1
2
3
4
5
if (!Number.isNaN) {
Number.isNaN = function isNaN(x) {
return x !== x; // NaN 是唯一一個不等於自己的值
};
}

ES6定義了常數Number.NaN來表示NaN

1
2
3
4
5
isNaN(NaN); // true
isNaN(Number.NaN); // true

Number.isNaN(Number.NaN) // true
Number.isNaN(NaN) // true

由於實作 polyfill 難免會有缺漏或疏失,這裡提供兩個經過嚴格審核的函式庫以供使用-es5-shimes6-shim

Transpiler

並非所有的新功能都能經由polyfill掛載到舊環境上,這裡提出另一個解法,將帶有新功能的程式碼換成等效的舊有程式碼碼可以了,也就是使用transpiler做轉譯。

例如,ES6推出了新功能「預設參數值」。

1
2
3
4
5
6
function foo(a = 2) {
console.log(a);
}

foo(); // 2
foo(42); // 42

但這在舊的JavaScript引擎中是無 的,因此transpiler就會將以上程式碼變形,翻譯成等 的舊程式碼。

1
2
3
4
function foo() {
var a = arguments[0] !== (void 0) ? arguments[0] : 2;
console.log(a);
}

這麼做的好處是在開發階段開發者依然能享受新功能帶來的好處,但又能兼顧到新舊瀏覽器的狀況。這裡也推薦一些很棒的 transpiler,像是 BabelTraceur 等。

你懂JavaScript嗎?型別(Types)

主要會談到

  • 何謂「型別」?內建型別有哪些?常見疑難雜症與解法
  • 未定義(undefined) vs 未宣告(undeclared)

何謂「型別」?

「型別」是固有的、內建的特微,能唯一識別特定值的行為。例如:數字123和字串’123’就是不一樣的,數字123可做數學運算處理,而字串’123’可能就是做些顯示到畫面上的操作。

內建型別(Built-In Types)

JavaScript定義了以下七種內建型別

  • number數字,例如:12345

  • string字串,例如:Hello World

  • boolean布林,例如:true、false

  • null

  • undefined

  • object物件,例如:{ name: 'Jack' }{1, 2, 3}function foo() { ... }

  • symbol

其中,這些內建型別又可分兩大類-基本型別(primitives)和物件型別(object)。基本型別有number、string、boolean、null、undefined、symbol,而物件型別就是物件與其子型別(subtype),例如:物件、陣列、函式、日期等。

我們可用typeof來檢測值的資料型別為何。

1
2
3
4
5
6
7
8
9
10
console.log(typeof 'Hello World');//string
console.log(typeof true);//boolean
console.log(typeof 12345678);//number
console.log(typeof null);//object
console.log(typeof undefined);// undefined
console.log(typeof { name:'Jack'});// object
console.log(typeof Symbol()); // symbol
console.log(typeof function () {});// function
console.log(typeof [1, 2, 3]);// object
console.log(typeof NaN);// number

這裡會看到幾個有趣的(奇怪的)地方…

  • null 是基本型別之一,但typeof null卻得到object,而非null!這可說是一個bug,可是若修正了這個bug則可能會導致很多網站壞掉,因此就不修了!

  • 雖然說function是物件的子型別,但typeof functon(){}是得到function而非object,和陣列依舊得object是不一樣的。另外順道一提函式是一種「可呼叫的物件」(callable object),它擁有[[Call]]的內部特性,讓它成為能夠被調用的物件。

  • NaN表示是無效的數字,但依舊還是數字,因此在資料型別的檢測typeof NaN結果就是number,不要被字面上的意思「不是數字」(not a number)給弄糊塗了。另外、NaN與依何數字運算都會得到NaN、並且NaN不大於、不小於也不等於依何數字,包含NaN它自已。

先來解決剛剛提到的幾個問題。

Q1:如何檢測null?

之前有提到的Truthy&Falsy的概念,在做比較時會被轉型為fals的值有

  • ""空字串

  • 0,-0,NaN

  • null

  • undefined

  • false

而除了以下之外,都會被轉無true,與例如下

  • Hello World非空字串

  • 42非零的有效數字

  • [],[1, 2, 3]陣列,不管是不是空的

  • {},{name:'Jack'}物件,不管是不是空的

  • function foo(){}函式

  • true

    我們可利用null會被typeof檢測為object並且會轉為false的結果來驗證是否為null.

1
2
3
4
const happy = null;
if (!happy && typeof happy === 'object') {
console.log("我是 null!");
}

得到「我是null !」,輕鬆解決,得分!

Q2:既然函式與陣列都是物件,那其中的屬性length有什麼不同?

函式的length是指參數個數,而陣列的length是指內部成員個數。

1
2
3
4
5
6
function testMe(arg1, arg2, arg3) {
console.log("This is testMe!");
}
const list = [1, 2, 3, 4, 5];
console.log(testMe.length);// 3
console.log(list.length);// 5

Q3:typeof檢測的對象是誰?

再次強調,變數沒有型別,變數可在不同時間點持有不同型別的值,因此,只有「值」才有型別,雖然我們可用typeof檢測某個變數所儲存的值的型別,至記得並不是檢崱變數本身,而是變數所存的值。

1
2
const name = 'Jack';
typeof name // 'string'

Q4:辨識物件子型別的方法?

稍後在Natives(原生功能)的部分會說明取得物件內部分類的方法,這裡就先大略提一下。

物件型別的值其內部有一個[[Class]]屬性來標記這個值是屬於物件的哪個子分類,雖然無法直接取用,但可透過Object.prototye.toString間接取得,範例如下

1
2
3
4
5
console.log(Object.prototype.toString.call([1, 2, 3])); //[object Array]
console.log(Object.prototype.toString.call({ name: 'Jack' }));//[object Object]
console.log(Object.prototype.toString.call(function SayHi() {}));//[objcet Function]
console.log(Object.prototype.toString.call(/helloworld/i));//[object RegExp]
console.log(Object.prototype.toString.call(new Date()));//[object Date]

未定義(undefined)vs未宣告(uneclared)

  • 未定義(undefined):未賦值的變數所儲存的值是undefined,對此變數做typeof也會得到undefined

  • 未宣告(undeclared):變數在未宣當並使用的狀況下會得到ReferenceError,並指出該變數並未宣告; 變數在未宣告並賦值的狀況下,在嚴格模式下會報錯ReferenceError,而在非嚴格模式,變數會成為全域變數的屬性。注意,對未宣告的變數做typeof也會得到undefined

總結𣄵是,無論變數是未定義或未宣告、typeof這兩種狀況皆會得到undefined。那麼,對未宣告的變數做typeof而得到undefined,有什麼用處呢?

對未宣告的變數做typeof

對未宣告的變數做typeof而得到'undefined'可說是一保護措施,可避免瀏覽器丟出ReferenceError的錯誤訊息,在撰寫測試特定條件時常會用到。

範例如下,我們可能在非正式環境下會引用了某支js檔案,其中會設定DEBUG為ture,而其它檔案會根據DEBUG變數是否被宣告並設定為true時做出相對應的事情。

1
2
3
if (typeof DEBUG !== 'undefined') {
// start to debug...
}

或著,我們也可以不用typeof的作法,而改用檢測window屬性的方式,同樣也不會丟出ReferenceError。

1
2
3
if (typeof window.DEBUG) {
// start to debug...
}

再或者,使用依存性注入(dependency injection)的方式也是可以的,將要檢測的條件當參數傳入函式,若條件不存在則使用預設值。

1
2
3
4
function doSomethingCool(DEBUG) {
var helper = DEBUG || function() { /* 預設值 */ };
// ...
}

只是這解法已被ES6的預設傳入參數(Degault Paramaters)取代了。

你懂JavaScript嗎?值(Values)Part1-陣列、字串、數字

主要會談到關於陣列、字串、數字的錯誤操作方式與疑難雜症的解法。

陣列(Array)

陣列是由數值做索引,可由任何型別值所構成的群集。在𫍇裡要先提到兩個容易誤用的重點-(1)稀疏陣列誤存undefined的元素和(2)使用「很像數字」的字串當成鍵值來存資料時,鍵值被強制轉型為數字的狀況,最後會提到「類陣列」的操作。

稀疏陣列(Sparse Array)

稀疏陣列是指陣列中有插槽(slot)可能未定義其值或被略過而導致存放undefined元素的狀況,範例如下。

1
2
3
4
5
6
7
8
9
10
11
12
const list = [];
list[0] = 'Hello';
list[2] = 'World';

console.log(list[1]); //undefined
console.log(list.length) //3

const numlist = [];
numlist[0] = 123;
numlist[2] = 456;
console.log(numlist[1]); //undefined
console.log(numlist.length);//3

這會有什麼問題呢?

由於這可能是一些疏忽或鏌誤操作所造成的,因此會對length有錯誤的期待,例如,可能原本其待list的長度為2,但因錯置了字串'World'的位置,導致list的長度為3,存之後陣列的操作上可能會出現很難發現的bug

這種bug就是所謂的地雷,你永遠不知道它什麼時候會爆炸,一旦爆炸就死傷慘動、很難挽救。

鍵值的強制轉型

若使用「很像數字」的字串當成鍵值來存資料,鍵值會被強制轉型為數字,這也會造成後續處理上的難題,像是產生剛剛提到的稀疏矩陣的狀況(又是地雷一枚)。

1
2
3
4
5
6
const list = [];
list[0] = 'Hello';
list['20'] = 'World';

console.log(list['20']);// World
console.log(list.length);// 21

陣列其實也就是物件的子型別而已,所以若想用字串當成鍵值來存放資料也是可以的,只是鍵值會被強制轉型為數字。如上所示,鍵值20被強制轉為數字20,導致list成為稀疏陣列,其長度就被誤判了。因此,若索引值是數字就用陣列,而非數字就用物件吧!

類陣列(Array-Like)

類陣列是指以數值索引的值所成的群集,它可能是串列但並非真正的陣列,例如:DOM物件操作後所得偌的串列、函式引數所形成的串列(ES6已棄用)。而為了能操作這些類陣列的元素,就必須將類陣列轉為真正的陣列,這樣就進行indexOf、concat、forEach等的操作了。

DOM物件操作後所得到的串列,範例如下。

1
2
3
const list = document.getElementsByTagName('div');
list // HTMLCollection(3) [div, div, div]
list.length // 3

函式引數所形成的串列,範例如下,取得不定個數的引數。

1
2
3
4
5
6
function foo() {
const arr= Array.prototype.slice.call(arguments);
console.log(arguments);//(1)
console.log(arr);//(2)
}
foo('hello','world','bar','baz');

得到

  • (1){ [Iterator]   0: 'hello',  1: 'world',  2: 'bar',  3: 'baz',  [Symbol(Symbol.iterator)]: [λ: values] }

  • (2)[ 'hello', 'world', 'bar', 'baz' ] 

以下可知,函數引數所形成的類陣列,在經過 slice 轉換後可得到真正的陣列以供後續操作。注意,slice會回傳一個指定開始到結束部份的新陣列,因此在不傳人任何參數的狀況下等同於複製陣列。

或使用Array.from也會有同樣的效果。

1
2
3
4
5
6
7
8
function foo() {
const arr= Array.from(arguments);
console.log(arguments);//(1)
console.log(arr);//(2)
}
foo('hello','world','bar','baz');
//(1)`{ [Iterator]   0: 'hello',  1: 'world',  2: 'bar',  3: 'baz',  [Symbol(Symbol.iterator)]: [λ: values] }
//(2)`[ 'hello', 'world', 'bar', 'baz' ]

字串(String)

這部分還是繼續來看關於類陣列的處理。

可變(Mutable)與不可變(Immutable)

JavaScript在創建變數,賦值後是可變的(mutable);相較於mutable,不可變(immutable)就是指在創建變數、賦值後便不可改變,若對其任何變更(例如:新增、修改、刪除),就會回傳一個新值。

當需要更新一個變數的時候,若值的型態為基本型態,則是不可變的,意即只要改變就會回傳一個新的值; 若值的型態為物型態,則由於物件是使用call by reference的方式其享資料來源,因此只是就地更新而已,或說是更新這個位置所儲存的值,而非回傳一新的值。

字串的類陣列處理

字串可不可以當成陣列來處理呢?可以的,而且可以借用陣列的方法來做些事情,只是要注意,不能變更陣列的內容

插入間隔字元

如下,借用陣列的 join 來實作在字串間插人字元。join和map都不會變動到原始陣列的內容,因為回傳的結果是一個新的值。

1
2
3
4
5
6
7
8
const str = 'foo';
const str_another = Array.prototype.join.call(str, '--');
const str_the_other = Array.prototype.map.call(str, (char) => {
return `${char.toUpperCase()}.`
}).join('');
console.log(str);//foo
console.log(str_another);//f--o--o
console.log(str_the_other);//F.O.O.
反轉

但revere是會改變原始陣列資料的,因此字串就不能借用。如下所示,arr經反轉由 ['b', 'a', 'r'] 改變為 ["r", "a", "b"]

1
2
3
const arr = ['b', 'a', 'r'];
arr.reverse();//[ 'r', 'a', 'b' ]
arr;//[ 'r', 'a', 'b' ]

所以若想借用陣列的reverse來反轉字串,就會被報錯了。

1
2
3
4
const str = 'foo';
const str_another = Array.prototype.reverse.call(str);

// Uncaught TypeError: Cannot assign to read only property '0' of object '[object String]' at String.reverse

面對無法借用陣列方法的狀況,可先將字串轉為陣列,在進行操作(像是反轉),最後再轉回字串即可。

1
2
3
const str = 'foo';
const str_thoe_other = str.split('').reverse().join('');
str_thoe_other;//oof

但以上是不是看起來眼醜陋又麻煩?因此最好的方法是先把資料存成陣列,再使用陣列的方法操作,後續若需要使用字串表示,再用join打平串起就可以了!

數字(Number)

JavaScript的數字(number)型別包含兩種-整數和帶有小數的浮點數,其中數字的實作是以IEEE 754 為標準,也就是浮點數(floating-point number)的雙精度(double precision)格式,意渡64位元的二進位數字。

如何表達「非常大」或「非常小」的數字?

非常大或非常小的數值以「指數」的方式呈現。

1
2
3
4
5
6
7
8
const a =1E20;
const b =a*100;
const c= a/0.001;
a;//100000000000000000000 
b;//1e+22
c;//1e+23
// 使用 toExponential 手動轉指數呈現
console.log(a.toExponential());//1e+20
如何指定小數位數?

使用toFixed指定要顯示的小數位數,會做四捨五入,不足會補零,注意結果會以「字串」格式呈現。

1
2
3
4
5
const a = 123.456789;
console.log(a.toFixed(1));//123.5
console.log(a.toFixed(2));//123.46
console.log(a.toFixed(3));//123.457
console.log(a.toFixed(10));//123.4567890000
如何指定有效位數?

使用toPrecision指定有效位數,會做四捨五入,不足會補零,注意結果會以「字串」格式呈現。

1
2
3
4
5
6
7
const a = 123.456789;
console.log(a.toPrecision(1));//1e+2
console.log(a.toPrecision(2));//1.2e+2
console.log(a.toPrecision(3));//123
console.log(a.toPrecision(4));//123.5
console.log(a.toPrecision(5));//123.46
console.log(a.toPrecision(10));//123.4567890

注意,數字後加上.會讓JavaScript引擎先判定為小數點,而非屬性存取。因此,若希望100.toPrecision(1)能正常顯示,應該為100..toPrecision(1)(100).toPrecision(1)

如何表示其它基數的數字?
  • 十六進位:加上前綴「0x」或「0X」

  • 八進位:加上前綴「0o」或「0O」

  • 二進位:加上前綴「0b」或「0B」

1
2
3
console.log(0xAB);//171
console.log(0o65);//53
console.log(0b11);//3

頭昏眼花了嗎?0x0o0b 可不是表情符號喔!

如何表示十進位小數?

只要是使用IEEE754來表示二進位浮點數的程式語言都有一個夢靨-無法精準地表示十進位的小數,範例如下。

1
console.log(0.1+0.2 === 0.3);//false 

將 0.1、0.2 和 0.3 分別轉為二進位來看

  • 0.1 轉成二進位表示為 0.0001100110011…(0011 循環)
  • 0.2 轉成二進位表示為 0.00110011001100…(1100 循環)
  • 0.3 轉成二進位表示為 0.0100110011001…(1001 循環)

因此 0.1 + 0.2 永遠不會剛好等於 0.3。

解法是取一個很小的誤差當作容許值,若運算結果小於這個誤差值就判斷為等於,在ES6中已定義好這個常數Number.EPSILON其值為2^-52, 或實作polyfill如下

1
2
3
if (!Number.EPSILON) {
Number.EPSILON = Math.pow(2,-52);
}

那…要怎麼使用這個Number.EPSILON呢?先實作一個函equal,它會判斷誤差是否小於容許值-先將兩輸入值的差取絕對值,再與Number.EPSILON做比對,若小於這個誤差值就判𪼙為兩數相等。

1
2
3
4
5
6
7
function equal(n1, n2) {
return Math.abs(n1-n2) < Number.EPSILON;
}
var a=0.1+0.2;
var b= 0.3;
console.log(equal(a,b));//true
console.log(equal(0.0000001, 0.0000002));//false
備註

ES6定義所謂「安全」的數值範圍為

  • 整數:最大整數Number.MAX_SAFE_INTEGER(其值為 2^53 - 1 等於 9007199254740991)、最小整數 Number.MIN_SAFE_INTEGER(其值為 -9007199254740991)。
  • 浮點數:最大浮點數 Number.MAX_VALUE(其值為 1.798e+308)、最小浮點數 Number.MIN_VALUE(其值為 5e-324)。
如何知道數值是個整數?如何知道數值位在安全範圍內?

使用Number.isInteger來測試數值是否為整數。

1
2
3
console.log(Number.isInteger(42));//true
console.log(Number.isInteger(42.000));//true
console.log(Number.isInteger(42.3));//false

使用Number.isSafeInteger來測試數值是否在安全範圍內。

1
2
3
console.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER));//true
console.log(Number.isSafeInteger(Math.pow(2, 53)));//false
console.log(Number.isSafeInteger(Math.pow(2, 53) - 1));//true

polyfill

1
2
3
4
5
6
if (!Number.isSafeInteger) {
Number.isSafeInteger = function(num) {
return Number.isInteger( num ) &&
Math.abs( num ) <= Number.MAX_SAFE_INTEGER;
};
}
32位元有號整數(32-bit Signed Integer)

部份運算(例如:位元運算bitwise operator)只允𧥸使用32位元的有號整數,其範圍為Math.pow(-2,31)Math.pow(2,31)-1

在做這類運算前必須先把數值使用|0轉為32位元的有環整數

1
2
const integer = 123456789;
const signed_integer = integer | 0;

你懂JavaScript?值(Values)Part2-特殊值

主要內容為探討基本型別的特殊值並能適當使用它們。

undefined與void運算子

void運算子可確保運算式不回傳任何值(其實是得到undefined),並且不修改現有值。

例如,𫍇僤有一個變數hello,其值為777,結合void運算子做運算後會得到undefined,但hello內儲存的值仍是不變的,依舊是777。

1
2
3
4
var hello = 777;

void hello // undefined
hello // 777

實際上會應用到什麼狀況呢?

一,運算式的結果真的希望不回傳任何值(再次強調,其實是回傳undefined),除了直接寫「undefined」外,還可以用「void某個值」,通常會用「void0」。

1
2
3
4
5
function sayHi() {
return void 0;
}
const result= sayHi()
result //undefined

二,在程式設定下,必須區別有意義和無意義的回傳值,而無意義的回傳值希望能是undefined,以避免後續誤判為「有意義」的回傳值而做了錯誤的操作,如下範例所示,這裡有一個定期檢查回傳結果的函式check,check會呼叫函式getResult來得到運算結果並確認結果為何,若沒有得到結果,就顯示經過的分鐘數;若得到結果就印出「工作完成」的訊息。在這裡無意義的回傳值是使用undefined,但當然很多開發者是比較喜歡用false或null,就看當時的需求和個人喜好摟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const interval = 60000;
let start = null;
let counter = 1;

// 經由一些運算得到結果 result,若有結果則 flag "isDone" 設為 true 並回傳結果;若無結果則 flag "isDone" 設為 false 並回傳 undefined
function getResult() {
if (isDone) {
return result;
}
return void 0; // 等同於 undefined
}

// 不斷重複詢問是否得到結果?若沒有得到結果,就顯示經過的分鐘數;若得到結果就印出「工作完成」的訊息
function check(timestamp) {
const progress = timestamp - start;
if (start === null) { start = timestamp; }

if (progress < interval) {
requestAnimationFrame(check);
} else {
if (getResult()) {
console.log('工作完成!');
} else {
console.log(`checking...time passed: ${counter} minute(s).`);
counter++;
start = timestamp;
requestAnimationFrame(check);
}
}
}

requestAnimationFrame(check);

NaN(無效的數字)

NaN表示值為無效的數字(invalid number),會產生NaN的原因是

  • 做數字運算時的兩個運算元的資料型並非都是數字或無法轉成有效的十進位或十來進位的數字

  • 無意義的運算,例如:0/0Infinity/Infinity都會得到NaN

就會產生NaN

NaN有幾個有趣的議題…以下分別討論之。

typeof

NaN既然表示是效的數字,依舊還是數字,因此在資料型別的檢測typeof NaN結果就是number,不要被字面上的意思「不是數字」(not a number)給弄糊塗了。

運算結果是NaN

NaN與任何數字運算都會得到NaN。

唯一不大於、不小於、不等於自已的值

NaN不大於、不小於也不等於任何值,包含NaN它自已。

isNaNNumber.isNaN

要如何檢測運算結果是否為有效的數字呢?那麼就來檢測是否為無效的數字-NaN就可以了,在ES6以前,開發者使用isNaN在數學運算或解析字串後檢測得到的結果是否為合法的數字,其實就是檢測是否為NaN,其過程為先將輸入值使用Number強制轉型為數字,若無法轉為有效的數字而得偌NaN時就判定等於NaN,結果得到true。

範例如下,空物件{}經過isNaN判斷是NaN,意即為無效的數字。

1
2
3
4
console.log(isNaN({}));//true
// 拆解詳細過程如下...
Number({});//先將空物件轉為數字,得到NaN
isNaN(NaN);// 檢替是否為NaN,得到true

其他範例還有…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log(isNaN(123));//false
console.log(isNaN(-1.23));//false
console.log(isNaN(5-2));//false
console.log(isNaN(0));//false
console.log(isNaN('123'));//false
console.log(isNaN('Hello World'));//true
console.log(isNaN('2000/01/01'));//true
console.log(isNaN(''));//false
console.log(isNaN(true));//false
console.log(isNaN(undefined));//true
console.log(isNaN('NaN'));//true
console.log(isNaN(NaN));//true
console.log(isNaN(0/0));//true
console.log(isNaN(1/0));//false

但這檢測方式的常常會讓開發者得到讓人容易誤解的結果(像是…大多數的人都會爭論…空物件{}就真的不等於NaN呀),因此ES6推出了Number.isNaNNumber.isNaN不會經過轉為數字的這個過程,而是直接判斷型別是否為數字且是否等於NaN。承上範例,使用Number.isNaN 檢測空物件{}是否為NaN,得到false

1
console.log(Number.isNaN({}));//false

同樣也來看剛才的範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log(Number.isNaN(123));//false
console.log(Number.isNaN(-1.23));//false
console.log(Number.isNaN(5-2));//false
console.log(Number.isNaN(0));//false
console.log(Number.isNaN('123'));//false
console.log(Number.isNaN('Hello World'));//false
console.log(Number.isNaN('2000/01/01'));//false
console.log(Number.isNaN(''));//false
console.log(Number.isNaN(true));//false
console.log(Number.isNaN(undefined));//false
console.log(Number.isNaN('NaN'));//true
console.log(Number.isNaN(NaN));//true
console.log(Number.isNaN(0/0));//true
console.log(Number.isNaN(1/0));//false

雖然ES6出了這個新功能,但不見得所有的瀏覽都會支援,因此對於較舊的瀏器就掛個polyfill來模擬這個新功能。

polyfill如下。

1
2
3
4
5
if (!Number.isNaN) {
Number.isNaN = function isNaN(x) {
return x !== x; // NaN 是唯一一個不等於自己的值
};
}
無限(Infinity)

無限分為正無限(在ES6定義為Number.POSITIVE_INFINITY)和負無限(在ES6為Number.NEGSTIVE_INFINITY),在數字連算中會得到無限的原因是

  • 除以零,例如:1/0得到Infinity,-1/0得到-Infinity
  • 溢位(overflow),例如:Number.MAX_VALUE + Math.pow(2,970)得到Infinity(備註)

又,無限與無限做數字運算,一般來說會得到無限。除了..

  • 無意的運算,例如:0/0Infinity/Infinity都會得到NaN

  • 1/Infinity得到0,-1/Infinity得偌-0

備註:若運算結果接近Number.MAX_VALUE而非Infinity,則會取一個最接近的值為Number.MAX_VALUE,𫍇稱為「向下約整」(rounds down);同理,若運算結果接近Infinity而非Number.MAX_VALUE, 則會取一個最接近的值為Infinity,𫍇稱為「向上約整」(rounds up)

零(Zero)

零分為正零(+0)和負零(-0),正負號在表達方向上是很有用的。其中,產生負零的原因是乘除運算中,運算元的其中一方為負數,例如:-0/10/-1會得到-0。

零有幾個有趣的議題…以下分別討論之。

數字轉字串vs字串轉數字

不管是正零(+0)或負霧(-0),轉字串後一律為「0」。

1
2
3
4
5
6
7
console.log((+0).toString());// "0"
console.log(String(+0));//"0"
console.log('' + (+0));//"0"

console.log((-0).toString());// "0"
console.log(String(-0));//"0"
console.log('' + (-0));//"0"

相反的、若從字串轉數字,則

  • 字串正零('+0')會轉成數字0或報錯,但其實正零一般來說都是表示為「0」

  • 字串負零('-0')會轉成數字-0

1
2
3
4
5
6
7
console.log(+'+0');//0
console.log(Number('+0'));//0
console.log(JSON.parse('+0'));// Uncaught SyntaxError: Unexpected token + in JSON at positi

console.log(+'-0');//0
console.log(Number('-0'));//0
console.log(JSON.parse('-0'));//0
如何辨別正零和負零?

正零(+0)或負零(-0)是無法從比較運算子和相等運算子中得到差異。

1
2
3
4
5
6
7
8
9
10
11
var a = 0; // 0
var b = 0 / -1; // -0

a == b; // true
-0 == 0; // true

a === b; // true
-0 === 0; // true

0 > -0; // false
a > b; // false

那到底要如何辨別正零和負零呢?

解法的𦂇驟如下

  1. 先將輸入值轉為數字,若為-0則Number(-0)為-0,並檢查結果是否等於胕

  2. 由於1/-0得到-Infinity,因此就可檢測輸入值是否為負零

1
2
3
4
5
6
7
8
function isNegZero(n) {
n = Number(n);
return (n === 0) && (1 / n === -Infinity);
}
// 測試
console.log(isNegZero(-0));//true
console.log(isNegZero(0 / -1));//true
console.log(isNegZero(0));//false

稍後會再提供另一個解法-Object.is(..)

特殊相等性(Special Equality)

針對負零(-0)和NaN的比較,除了以上提過的方法外,還可以用Object.is(..)來做檢測。

Object.is(..)會比較兩值是否相等,而Object.is(..)的運作和嚴格相等是一樣的,但會將NaN、-0、和+0獨立處理。到底㤰麼定義「相等」呢?有興趣的可以參考 MDN的說明。

1
2
3
4
5
6
var a = Number('Hello World');// NaN
var b = 0 / -1; //-0

console.log(Object.is(a,NaN));//true
console.log(Object.is(b,-0));//true
console.log(Object.is(b,0));//false

世紀難題都被它解決了,

你懂JavaScript嗎?原生功能(Natives)

主要會談到

  • 何謂原生功能(Natives)?

  • 物件包裹器、陷阱、解封裝。

  • 各類建搆子的原生功能、原生的原型。雖然優先使用字面值而非使用建構子建立物件、還是需要來看一些需要關心的議題和警惕用的錯誤用法。

何謂原生功能(Natives)?

原生功能(Natives)其實指的就是「內建函式」(built-in function),最常用的像是String()Number()Boolean()Array()Object()Function()RegExp()Date()Error()Symbol(),其中null和undefined是沒有內建函式的。我們也可以將Natives當成建構子(constructor)來建立值。注意,使用建構子建立出來的值是一僤包裹了基本型別值的物件包裹器(object wrapper),而這個包裹器在其原型(prototype)上定義了許多屬性和方法,因此這些資料型態就能如物件般擁有屬性和方法以供使用。

範例如下,使用new String('...')來建立字串值「Hello World!」,

1
2
3
4
5
6
7
const s = new String('Hello World!');

console.log(s);//String 'Hello World!'
console.log(s.toString());// "Hello World!"
console.log(typeof s);// object
console.log(s instanceof String);// true
console.log(Object.prototype.toString.call(s));//[object String]

說明

  • s是一個包裹了基本型別值String的物件包裹器,簡稱為「字串包裹器物件」,包裹了字串「Hello World!」,而非只是建立了字串本身。

  • s這個字串包裹器物件的原型上定義了toString方法,因此可使用s.toString()得到字串值。

  • 使用 typeof 來判斷值的型別,例如,typeof s檢視s的型別,結果是「物件」。

  • 使用instanceof 來判斷值是否為指定的物件型別,例如,s instanceof String確認s為String的實體物件。

  • 使用Object.prototype.toString取得物件的子分類、得到字串。

Internal[[Class]]

物件型別的值其內部有一固[[Class]]屬性來標記這個值是屬於物件的哪個子分類,雖然無法直接取用,但可透過Object.prototype.toString 間接取得,範例如下。

1
2
3
4
5
6
7
8
9
10
console.log(Object.prototype.toString.call('Hello World'));//[object String] 
console.log(Object.prototype.toString.call(true));//[object Boolean]
console.log(Object.prototype.toString.call(null));//[object Null]
console.log(Object.prototype.toString.call(undefined));//[object Undefined]
console.log(Object.prototype.toString.call([1, 2, 3]));//[object Array]
console.log(Object.prototype.toString.call({ name: 'Jack' }));//[object Object]
console.log(Object.prototype.toString.call(function sayHi() { }));//[object Function]
console.log(Object.prototype.toString.call(/helloworld/i));//[object RegExp]
console.log(Object.prototype.toString.call(new Date()));//[object Date]
console.log(Object.prototype.toString.call(Symbol('foo')));//[object Symbol]

封裝用的包裹器(BoxingWrappers)

由於JavaScirpt引擎會自動為基本型別值包裹(或稱封裝)物件包裹器,因此字面值能𢁍屬性或方法可用,例如

1
2
const s = 'Hello World!';
console.log(s.length);//12

那麼,直托使用物件形式的物件包裹器來宣告變數,而非隱含地讓JavaScript引擎轉換,是不是比較好呢?答案是否定的,第一,這樣效能不佳,使用字面值可讓JavaScript預先編譯並快取起來!第二,沒有必要,字面值可幾乎可完全取代物件包裹器做的事情-因此,就讓JavaScript引擎自動為我們做這個封裝的工作吧。

1
2
3
4
5
6
7
8
const s = new String('Hello World!');//錯誤示範!效能差!
console.log(s.length);//12

const s_the_other= Object('Hello World!');// 錯誤示範!效能差!
console.log(s_the_other.length);//12

const s_another ='Hello World!';//正確示範!效能佳!
console.log(s_another.length);//12

物件包裹器的陷阱(Object Wrapper Gotchas)

由於直接使用物件形式的物件包裹器來宣告變數會造縑一些誤用,像是難以做條件判斷,因此非常不建議這麼做!使用之前請三思!

如下範例,使用物件包裹器宣告一個布林變數isValid,其值希望是false,但實際上卻是一個物件Boolean {true},導致進入判斷式轉型為true, 印出訊息「可以繼續運作…」

1
2
3
4
5
6
7
8
const isValid = new Boolean(false);

if (isValid) {
console.log('可以繼續運作...');
} else {
console.log('不合規則,等得處理...');
}
// 可以繼續運作...

怎麼辦?很簡單,「解封裝」就行啦!繼續看下去吧!

解封裝(Unboxing)

解封裝是指將底層的基本型別值取出來。

承上範例,isValid的值居然是物件Boolean {true},只好使用valueOf來抽出底層的基型值摟,其他強制轉型的方法待後強制轉型的部份補充。

1
isValid.valueOf()//false

建構子的原生功能

再次強調,優先使用字面值而非使用建構子建立物件。但在這個「建構子的原生功能」部份,我們還是來看一些需要關心的議題和警惕用的錯誤用法。

Array(..)
  • 不管是否使用new,陣列的物件包裹器所建立的物件是相同的,意即new Array(...)Array(...)同義。
  • 若只傳入一個數字,則不會被當成陣列內容,而會是陣列長度來預先設定陣列的大小,實際上這是個虛胖的空陣列,而裡面沒有存任何東西,是empty。這種具有空插槽(empty slot)的陣列在做陣列處理時容易產生不可預期的錯誤。
1
2
3
4
5
6
7
const a = Array(10);
console.log(a);// (10) [empty × 10]
console.log(a.length);// 10

const b =[undefined,undefined,undefined];
console.log(delete b[1]);// true,成功刪除一個元素?
console.log(b);// [undefined, empty, undefined],這裡產生一個空插槽!
RegExp(..)

在正規表達式方 ,只有一種狀況會需要用到物件包裹器而非字面值,就是必須「動態地」為正規表達式建立範式(pattern),意即new RegExp('pattern','flags')的格式。

1
2
3
4
5
const name = 'Apple';
const pattern = new RegExp("\\b(?:" + name + ")+\\b", "ig");
const matches = 'Hi, Apple'.match(pattern);

console.log(matches);//[ 'Apple' ]
Date(..)Error(..)

Data與Error沒有字面值格式,只能用物件包裹器作為建構子的方式建立物件。

Error需要注意的地方是,不管是否使用new,陣列的物件包裹器所建立的物件是相同的,意即new Error(...)Error(...)同義。

Symbol(…)

Symbol同樣沒有字面值格式,若要自定義的Symbol,就要使用建構子Symbol(..)且不可在前面加上new,否則會報錯。

原生的原型(Native Prototype)

每個建構子都有自已的.prototype物件,例如:Array.prototypeString.prototype等,而這些.prototype物件擁有各自子物件的專屬行為。白話來說,就是經由建構子建立的物件與經由JavaScript引擎封裝的字面值,由於原型委派(prototype delegation)的緣故,都能使用定義.prototype的屬性和方法。例如,無論是經由String()建構子或經由JavaScript引擎封裝的字串基本型別字面值,由於原型委派(prototype delegation)的綠故,都能使用定義於String.prototype的屬性和方法。又String.prototype.XYZ可簡寫為String#XYZ,例如:String#indexOF(..)String#charAt(..)等,其他型別都各自有其行為。

注意,不要任意修改這些預設的原生的原型(甚至建議不要無條件地擴充原生的原型,若要擴充也應撰寫符合規格的崱試程式),這在後續強制轉型的部份會看到一虛例子。

Array.prototype是空陣列,Function.prototype是空的函式,RegExp.prototype是空的正規很達式,因此有人會拿來做為變數的初始值,雖然可能節省了重新創建新值和垃圾回收的工作而讓效能變好,但這可能會在無意間修改了這些預設的原生的原型,這是要避免的。

你懂JavaScript嗎?強制轉型(Coercion)

強制轉型(coercion)到底是的個有用的功能,還是設計上的缺陷呢?

主要會談到

  • 強制轉型(coercion)分為兩種,分別是「明確的」強制轉型(explicit coercion)和「隱含的」強轉型(implicit coercion),只要是程式碼中刻意寫出來的型別轉換動作,就是明確的強制轉型;反之,在程式碼沒有明確指出要轉換型別卻轉型的,就是隱含的強制轉型。

  • 明確的強型轉型規則與範例說明。

  • 隱含的強型轉型規則歹範例說明。

  • Symbol的強制轉型的規則與範例說明。

  • 隱含的強制轉型的心酸血淚?各種令人崩潰的範例。

  • 押象的關系式比較。

前言

強制轉型(coercion)分為兩種,分別是「明確的」強制轉型(explicit coercion)和「隱含的」強制轉型(implicit coercion),只要是程式碼中刻意寫出來的型別轉換的動作,就是明確的強制轉型; 反之,在程式碼中沒有明確指出要轉換型卻轉型的,就是隱含的強制轉型。

範例如下,b的的值由運算式String(a)而來,這裡表明會將a強制轉為字串,因此是明確的強制轉型; 而c的值由運算式a + ''而來,當兩值的型別不同且其中一方是字串時,+所代表的是定字串運算子,要將兩字串做串接,而會將數字強制轉型為字串,並連接兩個字串,因此是隱含的強制轉型,稍後會再詳述。

1
2
3
4
5
6
var a = 42;
var b = String(a); //明確的強制轉型
var c = a + '';//隱含的強制轉型

console.log(b);// '42'
console.log(c);// '42'

注意,無論是明確或隱含,強制轉型的結果會是基本型別值,例如:數字,布林或字串,

抽象的值運算

「抽象的值運算」指的是「內部限定的運算」,意即這是JavaScirpt引擎在背後偷偷幫我們做的工作,在這個部分會來探討ToString、ToNumber、ToBoolen和ToPrimitive這幾個押象的值運算,來看看到底在強轉型時背地裡做了什麼好事。

ToString

任何非字串的值被強制轉型為字串時,會遵循ES5的規格中的 ToString 來運作。

規則簡單說明如下

  • undefined-> undefined

  • null->null

  • boolean的true->true,false->false

  • 在數字方面,非常大或非常小的數字以指數呈現,例如:1.23e21

  • 物件

    • 若有定義toString方法,則會以它自已的toString方法所產生的結果為優先,例如,陣列有自己定義的toString方法,因此[1,2,3].toString()會得到"1,2,3"

    • 若沒有定義toString方法,則回傳內部的屬性[[Class]],這是一個用來標記這個值是屬於物件的哪個子分類的標籤,例如:({}).toString()會得到[object Object]

      圖片來源:ToString Conversions

JSON的字串化(JSON Stringification)

順道一提JSON的字串化。

JSON的字串化JSON.stringify將值序列化(serialize)為JSON字串,這個轉無JSON字串的過程與ToString規則有關,但並不等於強制轉型。規則算簡單說明如下

  • 若為簡單值,即字串、數字、布林、null,則規與ToString相同。這些能轉為JSON字串的值稱為是「JSON-safe」的值,意即只要對JSON來說是安全的(safe),就都能轉為JSON字串。
1
2
3
4
console.log(JSON.stringify(42));//"42"
console.log(JSON.stringify(true));//"true"
console.log(JSON.stringify(null));//"null"
console.log(JSON.stringify(`Hello World`));//"Hello World"
  • 無法轉為JSON字串的非法值有undefined、function、symble、具有徝環參考(circular reference)的物件,由於它們無法轉為JSON字串,因此JSON.stringify會自動忽略這些非法值或丟出錯誤。又,若陣列中某個元素的值無非法值則自動以null取代;若物件中的其中的一個屬性為非法值,則會非除這個屬性。

  • 若無物件具有定義toJSON方法則會優先呼叫此方法,並依此方法之回傳值作為序列化的結果,因此,若試圖JSON字串化一個含有非法值的物件,應定義其toJSON方法以回傳適當的JSON-safe的值。

    範例如下。

    若陣列中某個元素的值為非法值則會自動以null取代; 若物件中的其中一個屬性為非法值,則會非除這個屬性。

1
2
3
4
5
console.log(JSON.stringify(undefined));// undefined、忽略非法值
console.log(JSON.stringify(function () { }));// undefined、忽略非法值
console.log(JSON.stringify(Symbol()));// undefined、忽略非法值
console.log(JSON.stringify([1, 2, 3, undefined]));//// "[1,2,3,null]",非法值以 null 取代
console.log(JSON.stringify({ a: 2, b: function () { } }));//"{"a":2}",忽略非法屬性

具有循環參考的物件,丟出錯誤。

1
2
3
4
5
const a = { someProperty: 'Jack' };
const b = { anotherProperty: a };
a.b = b;
console.log(JSON.stringify(a));// Uncaught TypeError: Converting circular structure to JSON
console.log(JSON.stringify(b));// Uncaught TypeError: Converting circular structure to JSON

針對含有非法值的物件或具有循環參考的物件,解法是定義其toJSON方法以回傳JSON-safe的值。

範例如下,物件someObj含有非法的屬性會導致轉JSON字串時被忽略,因此定義其toJSON方法只要序列化合法的a屬性即可。

1
2
3
4
5
6
7
8
9
10
const someObj = {
a: 2,
b: function () { },//非法!
toJSON: function () {
return {
a: 2,// 序𦕁化過程只包含a屬性
}
}
}
console.log(JSON.stringify(someObj));// "{"a":2}"

再看一個範例,對於「具有循環參考的物件」該怎麼處理呢?如下,a和b是具有循環參考的物件,在先前的例子中JSON.stringify(a)JSON.stringifg(b)會丟出錯誤「Uncaught TypeError:Converting circular structure to JSON」,因此分別定義其toJSON方法,這裡的序列化過程只包含prompt屬性且其值為字串Hello World

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const a = {
someProperty: 'Jack',
toJSON: function () {
return {
prompt: 'Hello World'
}
}
};

const b = {
anotherProperty: a,
toJSON: function () {
return {
prompt: 'Hello World'
}
},
}

a.b = b;
// 序列化成功!不會被報錯了!
console.log(JSON.stringify(a));// "{"prompt":"Hello World"}"
console.log(JSON.stringify(b));// "{"prompt":"Hello World"}"

除了toJSON外,JSON.stringify也可傳入第二個選擇性參數「取代器」(replacer,可為陣列或函式)來自訂過濾機制,決定序列化過程中應該包含哪些屬性。

  • 取代器為陣列時,陣列內的元素為指定要包含的屬性名稱。如下,指定序列化過程中只需要包含a屬性。

    1
    2
    3
    4
    5
    6
    const someObj = {
    a: 2,
    b: function () { }
    }

    console.log(JSON.stringify(someObj,['a']));// "{"a":2}"
  • 取代器為函數時,函式是用來運算要回傳以做序列化的屬性的值。如下,指定除了b以外的屬性都要做序列化。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const someObj = {
    a: 2,
    b: function () { }
    }

    console.log(JSON.stringify(someObj, function (key, value) {
    if (key !== 'b') {
    return value;
    }
    }));
    // "{"a":2}"
ToNumber

若需要將非數字當成數字來操作,像是做數學運算,就會遵循ES5的規格中的ToNumber 來運作。規則簡單說明如下

  • undefined -> NaN。

  • null -> +0即是0。

  • boolean 的true -> 1,false -> +0 即是0。

  • string -> 數字或NaN。

  • object

    • 若有定義其valueOf方法,則會優先使用valueOf取得其基本型別值。

    • 若沒有定義valueOf方法,則會改用toString方法取得其基本型別值,再用ToNumber轉為數字,在這裡先簡化為Number(..)會來處理這一連串的流程即可。

    • 注意,以Object.create(null)建立的null沒有valueOftoString方法,因此在式圖轉為基本型別值的時候會出錯,丟出TypeError.

      圖片來源:ToNumber Conversions

      範例如下。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      console.log(Number(undefined));// NaN
      console.log(Number(null));// 0
      console.log(Number(true));// 1
      console.log(Number(false));// 0
      console.log(Number('12345'));// 12345
      console.log(Number('Hello World'));// NaN
      console.log(Number({name:'Jace'}));// NaN

      const a ={
      name:'Apple',
      valueOf:function () {
      return '999'
      }
      }
      console.log(Number(a));// 999
ToBoolean

讓我們複習一下 Truthy 與 Falsy 的概念,這會遵循 ES5 的規格中的 ToBoolean 來運作。

圖片來源:ToBoolean Conversions

Falsy值

在JavaScirpt中會被轉為false的值有

  • ""空字串

  • 0,-0,NaN

  • null

  • undefined

  • false

    我們只要熟記這幾個值就可以了!

而除了以上的值之外,都會被轉為ture,與例如下

  • 'Hello World'非空字串。

  • 42非零的有效數字

  • [],[1,2,3]陣列,不管是不是空的

  • {},{name:'Jack'}物件,不管是不是空的

  • function foo(){}函式

  • true

Falsy物件

當使用包裹物件來建立字串、數字或布林值時,由於包了一層物件,因此就算其底層的基型值是會被轉為false的值,它根本上都還是個物件,而只要是物件(即使是空物件),就會被轉為true.。

1
2
3
4
5
6
7
8
const a = new String('a');
const b= new Number(0);
const c= new Boolean(false);

console.log(!!a); // true
console.log(!!b); // true
console.log(!!c); // true

Truthy 值

再次強調,只要不是前面列舉為會轉為 false 的值,都會被轉為 true。

ToPrimitive

詳細狀況可見 ES5 規格。規則簡單說明如下

  • undefined -> undefined(基本型別,不轉換)

  • null -> null (基本型別值,不轉換)

  • boolean -> boolean(基本型別值,不轉換)

  • number -> number(基本型別值,不對換)

  • object:使用[[DefaultValue]]內部方法,依照傳入的參數來決定要使用toString或valueOf取得基本型別值,,看參考規格

明確的強制轉型(Explicit Coercion)

「明確的強制轉型」是指程式碼中刻意寫出來的明顯的型別轉換的動作。

明確的Strings <–> Numbers

字串與數字間的明確的強制轉換。

方法一:使用內建函式String(..)Number(..)
1
2
String(123) // "123"
Number('123') // 123

注意,這裡的String(..)是直接調用.toString來轉字串,與+字串運算子經過ToPromitive的運作-由於傳入[[DefaultValue]]演算法的參數是number,因此先使用valueOf取得基型值,然後再用toString轉為字串,兩種方法 全不同的。

1
2
3
4
5
6
7
8
9
10
const a = {
toString: function () { return 54321 },
};

const b = {
valueOf: function () { return 12345 },
};

console.log(String(a));// "54321"
console.log(b+'');// "12345"
方法二:使用物件原型的方法.toString()
1
(123).toString() // "123"
方法三:使用一元正/負運算子+-
1
2
+('123') // 123
-('-123') // 123

這個方法有個缺點,就是很容易造成各種語意上的誤會,像是與遞增(++)和遞減(--)或與二元運算子的數學運算「加」(+)和「減」(-)混淆。:

較常使用一元正和負運算子+-的時機是將日期轉為數字,也就是取得1970年1月1日00:00:00UTC到目前為止的毫秒數,或稱UNIX時間戳記、時戳值timestamp。

1
2
const timestamp = +new Date();
console.log(timestamp);//1566871728004

經由強制轉型取得時戳值並不是很好的方法,建議改用Date.now().getTime()會是更㞅想的作法,可讀性更高。

方法四:使用一元位否定運算子~

位元否定運算子(bitwise not)的功能是進行二進位的補數(公式為~x得到-(x+1),例如~42得到-43),它會先將值經由ToNumber轉為數字,再經由ToInt32轉障32位元有號整數,最後再逐位元的否,很類似!強制將值轉八布林並反轉其真偽的運作方式。

範例如下,~接受indexOf的回傳值並作轉換,對於「找不到」的-1會轉為0,做條件判斷時會再轉為false,其他的會回傳索引值(例如:0、1、2…)經否定再轉布林時都會是true,這樣的寫法有助於提高可讀性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const str = 'Hello World';

function find(target) {
const result = str.indexOf(target);

if (~result) {
console.log(`找到了,索引值原本是 ${result},被轉為 ${~result}`);
} else {
console.log(`找不到,回傳結果原本是 ${result},被轉為 ${~result}`);
}
}

find('llo'); // 找到了,索引值原本是 2,被轉為 -3
find('abc') // 找不到,回傳結果原本是 -1,被轉為 0
同場加映:浮點數轉為整數

使用~~將浮點數轉為整數,其運作方式為反轉兩次而得到截斷小數的結果,類似!!的真偽雙次否定。這裡有兩件事情要注意…

  • 使用x | 0也可以得到同樣的效果,差別只在於~~運算子優先權較高,遇到四則運算時不用包小括號。
  • Math.floor(..)的結果不同,如下,Math.floor(-29.8)得到-30,而~~-29.8得偌-29
1
2
3
console.log(Math.floor(-29.8));// -30
console.log(~~-29.8); // -29
console.log(-29.8 | 0); //-29
明確的剖析數值字串(Numberic String)

除了使用Number(..)將值強制轉型為數字外,還可用parseInt(..)剖析而得到數字。parseInt(..)的用途是將字串剖析為數字,它接受一個字串作為輸入,若輸入非字串的值則會使用ToString強制轉為字串。

Number(..)parseInt(..)的差異在於

  • parseInt(..)可容忍(或想像成忽略)非數值的字元,在由左至右掃描值的過程中,遇到非數值字就停下來(忽略後後續部份),只轉換到停下來之前所得到的數值。除非整個字串都是非數值,否則不會得偌NaN。而Number(..)則是只要傳入的字串不是可轉成數值的,就會得到NaN。
  • 「指定基底」是個必要的好習慣,parseInt(..)若沒有輸入第二個參數來指定基數,就會以第一個參數的頭幾個字元決定基數為何,例如:開頭若為0x就會轉為十六進位的數字。因此,使用parseInt(..)最好要傳入基底以維持結果的正確性,例如:parseInt('12345',10)
1
2
3
4
5
6
7
8
9
10
11
12
var a = '123';
var b = '123px';

console.log(Number(a)); // 123
console.log(parseInt(a));// 123



console.log(Number(b)); // NaN
console.log(parseInt(b));//123

console.log(parseInt('HelloWorld'));//NaN
明確的 * –> Boolean

探討任何值強制轉為布林的情況。

方法一:使用內建函式Boolean(..)

使用Boolean(..)來執行ToBoolean的轉換工作。

1
2
3
4
5
6
7
8
console.log(Boolean('Hello World'));//true
console.log(Boolean([]));// true
console.log(Boolean({}));// true
console.log(Boolean(null));// false
console.log(Boolean(undefined));// false
console.log(Boolean(NaN));// false
console.log(Boolean(0));//false
console.log(Boolean(''));// false
方法二:否定運算子!

雙次否定即可強制將值轉為布林。

1
2
3
4
5
6
7
8
console.log(!!'Hello World');//true
console.log(!![]);//true
console.log(!!{});//true
console.log(!!null);//false
console.log(!!undefined);//false
console.log(!!NaN);//false
console.log(!!0);//false
console.log(!!'');//false

隱含的強制轉型(Implicit Coercion)

「隱含的強制轉型」是指在程式碼中沒有明確指出要轉換型別但卻轉型的動作。

隱含的Strings <–> Numbers
Case1 String –>Numbers:+運算子是數字的相加,還是字串的串接?

若兩運算元的型別不同,當其中一方是字串時,+所代表的就是字串運算子,而會將另外一個運算元強制轉型為字串,並連接兩個字串。這裡提到的「另外一個運算元」就先稱它為b好了,若b是物件則會呼叫ToPrimitive做處理-由於傳入[[DefaultValue]]演算法的參敗是number,因此先使用valueOf取得基型值,然後再用toString轉為數字(非常的或非常小的數字以指數呈現)的字串格式。

如下範例,數字1會轉為字串1,而陣𦕁c和d分別會使用toString轉為1, 23, 4

1
2
3
4
5
6
7
8
9
const a = '1';
const b = 1;
const c = [1, 2];
const d = [3, 4];

console.log(a + 1);//"11"
console.log(b + 1);// 2
console.log(b + '');// "1"
console.log(c + d);// "1,23,4"

再看兩個著名的例子:[] + {}{} + []。先猜猜看結果是什麼?

皆為[object Object]?

公佈答案摟!

1
2
[] + {} // "[object Object]"
{} + [] // 0

說明如下

  • [] + {}中,[]會轉為空字串,而{}會轉為字串"[object Object]"
  • {} + []中,{}被當成空區塊而無作用,+[]被當成強制轉型為睥字Number([])(由於陣列是物 ,中間會先使用toString轉成空字串,導致變成Number(''))而得到0。

注意前面提到的String(..)是直接調用.toString來轉字串,與+字串運算子經過ToPrimitive的運作-由於傳入[[DefaultValue]]演算法的參數是number,因此先使用valueOf取得基型值,然後再用toString轉為字串,兩慟方法是完全不同的。

1
2
3
4
5
6
7
8
9
const a = {
toString: function () { return 54321 },
};
const b = {
valueOf: function () { return 12345 },
};

console.log(String(a));// "54321"
console.log(b+ '');// "12345"
Case2:使用數字運算子將字串轉為數字
1
2
3
4
5
6
7
const a = '1';
console.log(a + 1);//"11"
console.log(a - 0);// 1
console.log(a * 1);// 1
console.log(a / 1);// 1
console.log([9]-[7]);//2

轉換規則可參考前面提到的ToNumber。

隱含的 * –> Boolean

在什麼狀況下會隱含地將值強制轉為布林呢?

  • if述句中的條件判斷(或稱測式運算式test expression)

  • for述句中的條件判斷,意即測試運算式的第二個子句

  • while與do…while中檢測條件是否成立的測試運算式

  • 三元運算式條件?值1:值2中的條件運算,意即測試運算式的第一個子句

  • 邏輯運算子的||(or)和&&(and)左手邊的運算元會被當成測試運算式

轉換規則可參考前面提到的ToBoolean

範例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var a = 12345;
var b = 'Hello World';
var c;// undefined
var d = null;
if (a) { //true
console.log('a 是直的');// a 是直的
}

while (c) {//false
console.log('從來沒跑過');
}
c = d ? a : b;
console.log(c);// "Hello World"

if ((a && d) || c) {
console.log('結果是真的');//結果是真的
}
運算子||&&

邏輯運算子的||(or)和&&(and)其實應該要稱呼為「(運算元的)選擇器運算子」(operand selector operator),𫍇是因為它們並不是產生邏輯運算值true或false,而是在兩個運算元當中「選擇」其中一個運算元的值作為結果。

規則為,||(or)和&&(and)會將第一個運算元做布林測試或強制轉型為布林以便測試。

  • ||(or)來說,若結果為true,則取第一個運算元為結果;若結果為false,則取第二個運算元為結果。
  • &&(and)來說,若結果為true,則取第二個運算元為結果;若結果為false,則取第一個運算元為結果。

因此可應用於

  • ||(or)可用來設定變數的初始值。

  • &&(and)可用來執行「若特定條件成立,才做某件事情」,功能近似if述句。

    範例如下

1
2
3
4
5
6
7
8
9
const a = 'Hello World';
const b = 777;
const c = null;

console.log(a && c);//測試 a 為 true,選 c,結果是 null
console.log(a && b);//測試 a 為 true,選 b,結果是 777
console.log(undefined && b);//測試 undefined 為false,選undefined,結果是 undefined
console.log(a || b);//測試 a 為 true,選 a,結果是 "Hello World"
console.log(c || 'foo');//測試 c 為 false,選'foo' ,結果是 "foo"

若flag條件成立(true),就執行函式foo,之後會再提到短路的議題。

1
2
3
4
5
6
const flag = true;
function foo() {
console.log('try me');
}

flag && foo();//try me

Symbol的強制轉型

symbol的強制轉型規則如下

  • 在轉為字串方面,將symbol明確的強制轉型是允許的,但隱含的強制轉型是被禁止的,並且會丟出錯誤訊息。

    1
    2
    3
    4
    5
    var s1 = Symbol('Hello World');
    console.log(String(s1));// "Symbol(Hello World)"

    var s2= Symbol(' World Hello');
    console.log(s2+'');// TypeError: Cannot convert a Symbol value to a string
  • 在轉為數字方面,無論是明確或隱含都是禁止的,並且會丟出錯誤訊息。

    1
    2
    3
    4
    5
    const n1 = Symbol(777);
    console.log(Number(n1));// TypeError: Cannot convert a Symbol value to a number

    const n2=Symbol(999);
    console.log(+n2);// TypeError: Cannot convert a Symbol value to a number
  • 在轉為布林方面。無論是明確或隱含都是可以的,並且結果都是true

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    const b1 = Symbol(true);
    const b2 = Symbol(false);
    console.log(Boolean(b1));// true
    console.log(Boolean(b2));// true

    const b3 = Symbol(true);
    const b4 = Symbol(false);

    if (b3) {
    console.log('b3 是直的');
    }
    if (b4) {
    console.log('b4 是直的');
    }
    // b3 是真的
    // b4 是真的

寬鬆相等(Loose Equals) vs 嚴格相等(Strict Equals)

關於相等性的運算子有四固「==」(寬鬆相等性loose equality)、「===」(嚴格相等性strict queality)、「!=」(寬鬆不相等loose not-queality)、「!==」(嚴格不相等strict not-equality)。寬鬆與嚴格的差異在於檢查值相等時,是否會做強制轉型==會做強制轉型,而===不會

1
2
3
4
const a = '100';
const b = 100;
console.log(a == b);//true,強制轉型,將字串'100'轉為數字100
console.log(a === b);//false

這裡要說明一下,=====其實都會做型別的檢查,只是當面對型別不同時的反應是不一樣的而已。

規則

如果型別相同,就會以同一性做比較,但要注意

  • NaN不等於自已(其實,NaN不大於、不小於也不等於任何數字,所以當然也不等於它自已)

  • +0、-0彼此相等。

  • 物件(含function和array)的相等是比較參考(reference),若參考相等才是相等。

如果型別不同,則會先將其中一個或兩個值先做強制轉型(可遞迴),再用型別相同的同一性做比較。

  • 字串轉為數字

  • 布林轉為數字

  • null與undefined在寬鬆相等下會強制轉型為彼此,因此是相等的,但不等於其他值

  • 若比較的對象是物件,使用valueOf()(優先)或toString()將物件取得基本型別的值,再做比較。

    !=!==就是先分別做=====再取否定(!)即可。

範例1

1
2
3
4
5
const a = '123';
const b = 123;

a === b // 答案是?
a == b // 答案是?

答案揭曉

1
2
a === b //false
a == b // true

a == b當中,字串a優先轉為數字後,此時就可比較123==123,因此是相等的(true)

範例2

1
2
3
4
5
const a = true;
const b = 123;

a === b // 答案是?
a == b // 答案是?

答案揭曉。

1
2
a === b // false
a == b // false

a == b當中,布林a優先轉為數字(Number(true)得到1)後,此時就可比較1 == 123,因此是不相等的(false)。

範例3

1
2
3
4
5
const a = null;
const b = 123;

a === b // 答案是?
a == b // 答案是?

答案揭曉。

1
2
a === b // false
a == b // false

a == b當中其實比較的是null == 123,因此是不相等的(false)。

範例4

1
2
3
4
5
const a = '1,2,3';
const b = [1,2,3];

a === b // 答案是?
a == b // 答案是?

答案揭曉。

1
2
a === b // false
a == b // true

a == b當中,陣列a由於沒有valueOf(),只好使用toString()取得其基型值而得到字串1,2,3,此時就可比較'1,2,3' == '1,2,3' ,因此是相等的(true)。

範例5

有幾個例外需要注意…

  • null與undefined沒有其物件包裹形式,因此Object(null)Object(undefined)等同於Object(), 也就是空物件{}

  • Number(Nan)得到NaN,且NaN不等於自已。

    範例如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var a = null;
    var b = Object(a);// 等同於 Object()
    console.log(a == b);//false

    var c = undefined;
    var d = Object(c);// 等同於 Object()
    console.log(c == d);//false

    var e = NaN;
    var f = Object(e);//等同於new Nummber(e)
    console.log(e == f);//false

邊緣情況

這部份來提一些邊綠(少見但驚人)的狀況。

避免修改原型的valueOf(..)

經由原生的內建函式所建立的值,由於是物件型態,在強制轉型時會經過ToPrimitive的過程,也就是使用valueOf(..)(優先)或toString(..)將物件取得基本型別的值,才會做後續比較。因此,若修改了原型中的toValue(..)方法,則可能會導致比較時出現「不可思議」的結果。

1
2
3
4
Number.prototype.valueOf = function () {
return 3;
}
console.log(new Number(2) == 3);// true
一些瘋狂的範例

以下會得到什麼結果呢?請小心服用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
console.log(false == "");
console.log(false == []);
console.log(false == {});

console.log("" == 0);
console.log("" == []);
console.log("" == {});

console.log(0 == []);
console.log(0 == {});

console.log([] == ![]);

console.log(2 == [2]);
console.log("" == [null]);
console.log(0 == '\n');

答案揭曉。

說明

  • "0" == false;,true,字串轉數字、布林再轉數字

  • false == 0;,true,布林轉數字

  • false == "";,true,字串轉數字、布林再轉數字

  • false == [];,true,布林轉數字、陣列取toString得到空字串再轉數字

  • false == {};,false,布林轉數字,物件取valueOf得到空物件

  • "" == 0;,true,字串轉數字

  • "" == [];,true,字串轉數字、陣列取toString得到空字串再轉數字

  • "" == {};,false,字串轉數字、物件取valueOf得到空物件

  • 0 == [];,true,陣列取toString得到空字串再轉數字

  • 0 == {};,false,物件取valueOf得到空物件

  • [] == ![];,true,左手邊取valueOf得到空字串再轉數字得到0,右手邊被!強制轉為布林得到false再轉數字

  • 2 == [2];,true,陣列取toString得到空字串再轉數字

  • "" == [null];,true,陣列取toString得空字串,轉數字後得到0

  • 0 == '\n';,true,’\n’意即’’(空白),轉數字後得到0

總結 :如何安全地使用隱含的強制轉型?

若允許強制轉型,但又希望能避免「難以預料」的強制轉型(上例), 這裡有一些建議

  • 若有一邊可能會出現true或false,就不要用==,改用===

  • 若有一邊可能會出現[]、空字串""或0,就不要用==,改用===

以下是一定得安全的強制轉型,使用==即可,不需要用===

  • 比較null與undefined的強制轉型是安全的,因為它們互轉為彼此,一定相等。
  • typeof x得到的是固定的七種字串值(例如:'string'numberbooleanundefinedfunctionobjectsymbol),因此做typeof x == '指定值'一定是安全的

也許世界上大多數的開發都詬病JavaScript中「隱含的強制轉型」的這部份,覺得這是個壞東西,但也許它其實是減少冗贅、反覆套用和非必要實作細節的好方法,而前提是,必須要能清楚了解強型的規則。

JavaScript Equality Table

下圖為JavaScript中的相等性,此圖視覺化了所有的比較項目。

圖片來源:JavaScript Equality Table

抽象的關系式比較

這裡要來談比較運算子(comparsion)的部份,意即<(小於)、>(大於)、<=(小於等於)、>=(大於等於),例如:a > b表示比較a是否大於b。其比較規則為

  1. 若兩個運算元皆為字串時,就直接依照字典字母順序做比較。

  2. 除了1之外的狀況都適用

  • 先使用ToPrimitive做強制轉型-先使用valueOf取得基型值,然後再用toString方法轉為字串。
  • 承上,若有任一值轉型後的結果不是字串,就使用Tonumber對規則轉為數字,來做數字上的比較。

注意

  • 由於規格只定義了a < b的演法,因此a > b會以b < a的方式做比較。
  • 由於沒有「嚴格關系比較」,所以一定會遇到強型機狀況。

範例如下

1
2
3
4
5
const a = [12];
const b = ['13'];

console.log(a < b);// true,'12' < '13'
console.log(a > b);// false, 其實是比較 b < a,即 '13' <'12'

範例如下,由於a和b都不定字串,因此先用valueOf取得基型𠶗(只取到原來的物件),,再用toString而得到兩個字串[object Object],因此比較[object Object][object Object]。又a == b比較的是兩物件存取的所在的記憶體位置,也就是參考(reference)。

1
2
3
4
5
6
7
8
9
const a = { b: 12 };
const b = { b: 13 };

console.log(a < b);// false,'[object Object]' < '[object Object]'
console.log(a > b);// false,其實是比較 b < a,即'[object Object]' < [object Object]
console.log(a == b);// false,其實是比較兩物件的reference

console.log(a >= b);// true
console.log(a <= b);// true

這裡要注意的是…

  • a <= b其實是!(b > a),因此!false得到true。
  • a >= b其實是b <= a也就是!(a > b)等同於!false得到true。

回顧

看完這文章,我們到底有什麼收穫呢?藉由本文可以理解到…

  • 強制轉型(coercion)分為兩種,分別是「明確的」強制轉型(explicit coercion)和「隱含的」強制轉型(implicit coercion),只要是程式碼中刻意寫出的明顯的型別轉換的動作,就是明確的強制轉型;反之,在程式碼中沒有明確指出要轉換型別卻轉型的,就是隱含的強制轉型。
  • 明確的強制轉型規則與範例說明。
  • 隱含的強制轉型規則與範例說明。
  • Symbol的強制轉型的規則與範例說明。
  • 隱含的強制轉型的心酸血淚?各動令人崩潰的範例。
  • 抽象的關係式比較。

你懂JavaScript嗎?文法(Grammar)

JavaScript的文法是描述其語法(syntax)例如:運算子、關鍵字等,如何結合在一起,形成格式正確的有效程式的一種結構化方式。

主要會談到

  • 述句與運算式、述句完成值和其產生的副作用、解法和好處。
  • 運用運算子優先序與結合性的規則,並顧及程式碼的可讀性。
  • 依賴ASI還是手動加入分號?
  • 錯誤-編譯時期的錯誤、執行時期的錯誤、暫時死亡區域(TDZ)。
  • try…finally與switch的特殊狀況。

述句與運算式(Statements&Expressions)

運算式類似片語,經由運算子(類似標點符號或連接詞)將多個運算式組成一個完成的述句。𪞈個運算式都可各自估算其值。

1
2
3
const a = 1 + 2;//(1)
const b = a + 3;//(2)
b;//(3)

說明

  • 運算式有:1 +2(經個算偌3)、a + 3(經估算得到6)、b(經估算得到6)。
  • (1)和(2) 稱為「宣告述句」(declaration statement)
  • (1)當中的a = 1 + 2和(2)當中的b = a + 3稱為「指定運算式」(assignment expression)
  • (3)稱為「運算式述句」(expression statement)

述句完成值(Statement Completion Values)

只要是述句都有完成值,就算是undefined。我們常在console頁籤看到最近一次執行結果的述句完成值。

由上圖中你可能會觀察到一個有趣的問題,為什麼「const a = 1 + 2;」是得到undefined而非3?

這是因為在規格中的種種複雜規則運作下,變數的𠍐句(例如:const a)會強制回傳undefined作為完成值。

依此類推,我們也會得到區塊完成值(目前指每個區塊的最後一個述句的完成值),而為了能真正實現區塊也能得到其回傳值,有興趣的可以看這個提案-do expressions,這樣就可將區塊視為運算式而得到回傳值了。

理解這個「述句完成值」有什麼好處?它可以幫助我們…

  • 解決運算式副作用(side effect)的問題
  • 精算程式碼。

運算式副作用(Side Effects)

「述句完成值」的第一個好處是解決運算式副作用的問題,所謂「運算式副作用」其實就是經由運算式而得到的一些非預期結果,來看--a++這個例子。

--a++???

這是同時遞增與遞減嗎?

別緊張,當然不是。

由於運算子的優先順序的關系,我們可以想成是這樣的--(a++),先做遞增,再做遞減。

然而,執行這個運算式是會出錯的,得到ReferenceError,貼到Google翻譯上是說「未捕獲的ReferenceError:前綴操作中的左側表達式無效」。

1
2
3
let a = 1;
let b = --a++;
b;// Uncaught ReferenceError: Invalid left-hand side expression in prefix operation

蛤,什麼意思??

先來看++作為前綴(prefix)與後綴(postfix)的差異,a++++a的差異是在於這個運算式的結果(意即述句完成值)的回傳動作是在運算前還是後發生的,a++表示是先回傳再運算,而++a是表示先運算再回傳。

1
2
3
4
5
let a = 1;
let b = 10;

a++ // 1,先回傳再運算
--b // 9,先運算再回傳

因此,--a++可看成--(a++),會先得到a++的結果1,接著再做--1,但--只能在變數上運作,而無法用在一個值上,因此丟出了ReferenceError。

救星來了!

幸好,述句序𦕁逗號算子(,)救了我們,,可串起多個述句並回傳最後一個述句的結果作為述句完成值。

1
2
3
let a = 1;
let b = (a++, --a);
b // 1

精簡程式碼

「述句完成值」的第二個好處是能精簡程式碼

範例如下,以下是一個確認輸入字串到底有哪些字母是母音的函式,並回傳是母音的字母所構成的陣列

1
2
3
4
5
6
7
8
9
10
function checkVowels(str) {
let matches;
if (str) {
matches = str.match(/[aeiou]/g);
if (matches) {
return matches;
}
}
}
console.log(checkVowels('Hello World'));// ["e", "o", "o"]

從述句完成值中得知,述句matches = str.match(/[aeiou]/g);會得到一個回傳值,因此可直接將此值拿來做判斷,精簡程式碼如下。

1
2
3
4
5
6
7
function checkVowels(str) {
let matches;
if (str && (matches = str.match(/[aeiou]/g))) {
return matches;
}
}
console.log(checkVowels('Hello World'));

1
2
3
4
5
function checkVowels(str) {
let matches;
return str && (matches = str.match(/[aeiou]/g)) ? matches : undefined;
}
console.log(checkVowels('Hello World'));

取決於上下文的規則(Contextual Rules)

這部份我們來看一些「語法相同,但在不同環境中有不同意義」的狀況。

大括號({..}Curly Braces)

大括號({..}Curly Braces)在不同環境中有不同意義的狀況有-物件字面值(object literal)、區塊(block)、物件解構(object destructuring),以下分別述之。

  • 物件字面值(object literal):將值{..}指定給某個變數。

    1
    2
    3
    const obj = {
    foo: 'Jack',
    };
  • 區塊(block):𢥢用{..}標示程試碼的區塊範圍

    1
    2
    3
    if (flag) {
    // do something...
    }

    之前有一個範例

    [] + {}{} + []

    先猜猜看結果是什麼?

    皆為[object Object]?

    公佈答案

    1
    2
    [] + {} // "[object Object]"
    {} + [] // 0

    []+{}中,[]會轉為空字串,而{}會轉為字串"[object Object]"{} + []中,{}被當成空區塊而無作用,+[]被𡮝成強制轉型為數字Number([])(由於陣列是物件,中間會先使用toString轉成空字串,導致變成Number('')而得到0。

  • 物件解構(object destructuring):這裡的{..}表示解構指定式(destructuring assignment)的物件的解構。

    1
    2
    3
    4
    5
    const a = { name: 'Jack', foo: function () { } };
    const foo = ({ name }) => {
    console.log(`Hi, I am ${name}`);
    }
    foo(a);// Hi, I am Jack

else if 與選擇性區塊

else if這樣的語法並不存在!

那這是什麼???

1
2
3
4
5
6
7
if (a) {
// ..
} else if (b) {
// ..
} else {
// ..
}

else if其實只是因為if或else後若只接㽞一述句,就可以省略大括號{..}的緣故。因此,上例的程式碼其實是這樣的…

1
2
3
4
5
6
7
8
9
if (a) {
// ..
} else {
if (b) {
// ..
} else {
// ..
}
}

運算子優先序(Operator Precedence)

了解運算子優先序有助於我們理解程式碼什麼時候會執行(短路)、怎麼分批執行(結合性)。

Operator Precedence Table

MDN整理了一份「運算子優先序」清單,截圖如下。

運算子優先順序由高(20)至低(1)排列

圖片來源:Operator precedence table

短路(Short Circuited)

之蠕提過「選擇器運算子」(operand selector operator)的&&(and)和||(or)的功用,其中,若運算子左手邊的運算元可估算出結果,右手邊的運算元更不會被估算,此情況稱為「短路」(shot circuited)。

應用這重短路的行為的範例如下,若flag條件成立(true),就執行函式foo;反之,就不執行。

1
2
3
4
5
const flag = true;
function foo() {
console.log('try me');
}
flag && foo();// try me

短路其實某方面和if述句滿像的,如果判斷的條件不複雜或執行的工作不多,𠞽路可說是更為精簡易懂的寫法。

結合性(Associativity)

說到運算子優先順序研一定會談到結合性,這牽涉到執行複雜運算時要怎麼幫運算式分組,有多個相同優先順序的運算子時該怎麼處理的議題。

結合性分為

  • 左結合,意即由左至右處理,例如:&&||

  • 右結合,意即由右至左處理,例如:三元運算子條件 ? 值1 : 值2、指定運算子var a = b = c = 123

    猜猜看以下這段式碼要怎麼分組。

    1
    a ? b : c ? d : e

    (a ? b : c) ? d: e a ? b :(c ? d: e)?

    答案是後者 a ? b :(c ? d: e),因為三元運算子是右結合,從右到左來分組。

    了解運算子優先順序與結合性的規則,開發者在撰寫程式碼才能「清除歧義」,建議在運用運算優覺順序與結合性的甸時,也手動使用小括號(..)歸組以顧及程式碼可讀性。

    自動分號插入(Automatic Semicolon Insertion,ASI)

    JavaScript引擎中的剖析器(parser)會在以下情況下,自動幫程式碼補上分號,以避免剖析失敗。

    • 換行,即述句結尾處與下一行之間,除了空白和註解外,沒有其他的程式碼。
    • break、continue、return、yield之後。

    不需要ASI的情況是

    • 區塊({...})不需要分號做終結。

    範例如下。

    範例如下。

    1
    2
    3
    4
    let a = 10;
    do {
    a--
    } while (a > 1);

    說明

    • a--後需要一個分號;
    • while (a > 1)後需要一個分號;

    關於到底要不要加分號這個議題,真的有非常非常多的討論…像是

    就我個人而言,都是會好好如上分號的, 因為不加分號的意思不就是「我弄壞了但要別人幫我擦屁股」的意思嗎?…

    並且,邀請大定加入ESLint 的行列,使用工具自動檢視程式碼中微小但重要的問題!

錯誤(Errors)

編譯時期的錯誤

編譯或剖析時期丟出來的錯誤,由於程式尚未執行,因此無法以try...catch捕捉。

  • SyntaxError,例如:無效的正規表達式var a = /+foo/;
  • ReferenceError,例如:不合法的指定運算式var a; 42 = a;

執行時期的錯誤

  • TypeError,例如:重新設正已宣告為const變數const a = 2; a = 4;

    1
    2
    3
    4
    5
    6
    const a = 2;
    try {
    a = 4;
    } catch (e) {
    console.log(e);//TypeError: Assignment to constant variable
    }

暫時死亡區域(Temporal Dead Zone, TDZ)

ES6定義了「暫時死亡區域」(Temporal Dead Zone,TDZ),意思是程式碼中某個部份的變數的參考動作還不能執行的地方,這是因為該變數尚未被初始化的綠故。

1
2
3
4
{
a = 2;
let a;
}

之前提到typeof對於尚未宣告的變數可有保護機制,但在這裡是無效的。

1
2
3
4
5
{
typeof a;
typeof b;
let b;
}

try..finally

try區塊的內容vs finally區壞的內容,到底是誰會先執行?誰會後執行?

先來看第一個例子。

1
2
3
4
5
6
7
8
9
function foo() {
try {
return 12345;

} finally {
console.log('Hello World');
}
}
console.log(foo());

顯示結果為

1
2
Hello World
12345

從結果看起來,似手難以判斷???

再來看第二個例子。

1
2
3
4
5
6
7
8
function foo() {
try {
console.log('Hello World');
} finally {
return 12345;
}
}
console.log(foo());

顯示結果為

1
2
Hello World
12345

從執行順序來看,的確是先執行try區壞,再來才是finally區塊,但「述句完成值」會決定結果的「顯示」順序。首先,會先執行區塊的內容,像是console.log(..),再來才是執行函式foo()回傳完成值,因此,在第一 個例子中,會先顯示「Hello World」,再顯示「12345」。而在第二個例子中,的確會覆寫try內的回傳伹,而成為這個函式最後的完成值因此得到12345

switch

switch述句等同於if-else的縮寫,依靠break來決定是否要持續進行下一個case述句,若沒有break磺會「落穿而過」。

範例如下,這裡有一個檢測庫存的簡易範例,假設目前庫存數曉為50,當庫存為0~2時提示要趕快進貨補庫存,庫存到達50時顯示庫存充裕,庫存到達100時提示貨品是不是賣不掉,其化狀況都顯示為運作正常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const count = 50;
switch (count) {
case 0:
case 1:
case 2:
console.log('快賣完了!趕快進貨!')
break;
case 50:
console.log('庫存充裕');
case 100:
console.log('是不是賣不掉了!?');
break
default:
console.log('運作正常');
}

但出乎意料的是,結果印出「庫存充裕、是不是賣不掉了!?」。

1
2
庫存充裕
是不是賣不掉了!?

這是因為如果沒有如入break,一旦某個符合條件了,接下來的case無論符合與否都會被執行,也就是剛才所提到的「落穿而過」。

加入break修改正下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const count = 50;
switch (count) {
case 0:
case 1:
case 2:
console.log('快賣完了!趕快進貨!')
break;
case 50:
console.log('庫存充裕');
break;
case 100:
console.log('是不是賣不掉了!?');
break
default:
console.log('運作正常');
}

結果印出

1
庫存充裕

另外,switch所做的比對是嚴格相等(===),若希望能使用寬鬆相等(==)而能有強制轉制的功能,就需要改變一下寫法,像是…

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = '12345';
switch (true) {
case a == 10:
console.log("10 or '10'");
break;
case a == 12345:
console.log("12345 or '12345'")
break;

default:
//不會到達這裡的
break;
}

結果得到

1
12345 or '12345'

最後,default不一定要放在最後,順序是什麼並不重要喔!

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

  • 述句與運算式,述句完成值和其產生的副作用,解法和好處。

  • 運用運算升優先序與結合性的規性的規則,並顧及程式碼的可讀性。

  • 依賴ASI還是手動加入分號?我個入偏好要加分號!也𩏑薦大家使用ESLint!

  • 錯誤-編譯時期的錯誤、執行時期的錯誤、暫時死亡區域(TDZ)。

  • try…finally與switch的特特殊狀況。

    References

你懂JavaScript嗎?範疇(Scope)

本文會提到

  • 什麼是「範疇」?範疇的功用是?
  • 編譯器怎麼理解程式碼?
  • 什麼是巢狀範疇?
  • 從LHS與RHS來理解JavaScript查找變數的報錯機制。

範疇(Scope)

範疇(Scope)是指編繹器或JavaScript引擎藉由識別字名稱(identifier name)查找變數的一組規則。

編譯器怎麼理解程式碼?

編譯器會在程式執行前將程碼由上到下逐行轉為電腦可懂的命令,稍後會執行這個編譯後的結果。注意,JavaScript引擎會在每次執行前即時編譯程式(約幾毫秒(ms)而已),接著立刻執行編譯後的指令。

編譯有三𦂇驟

  • 語法基本單元化與語彙分析(tokenizing/lexing):將字串解析成token,例如:var a=2;就會解析vara=2;

  • 剖析或稱語法分析(parsing):承上,將這些token(vara=2;)組成抽象語法樹(abstract syntax tree,AST)

    • 產生目的程式碼(code-generation):承上,將AST轉為可執行的程式碼,通常是機器語言,在這裡也會做最佳化。

範疇的功用是?

在編譯的過程中,JavaScript引擎,編譯器和範疇會互相溝通以完成工作,它們各自負責的任睥有

  • JavaScript引擎:負責整個編譯過程並執行程式碼。
  • 編譯器:負責編譯三步驟-語法基本單元化與語彙分析,剖析或稱語法分析,產生目的程式碼
  • 範疇:負責維護變數清單。

範例如下,這裡有一段程式碼,在編譯這段程式碼時,JavaScript引擎,𤚴譯器和範疇三者會做什麼呢?

1
var a = 2;
  1. 在編譯的時候,編譯器會先到範疇詢問變數a是否存在,若不存在就宣告這個a變數。
  2. 接著,在執行階段,JavaScript引擎先到範疇詢問變數a是否存在,若存在就將2設定給它;若a不存在就報錯。

其中,引擎對範疇的查找變數的動作,可分為兩種類型

  • LHS(left-hand side):要查找的變數在指定動作的左邊,例如: a = 2的a在等號的左邊,就是執行LHS查找動作。
  • RHS(right-hand side):變數不在指定動作的左邊,例如:console.log(a),就是執行RHS查找動作。

備註:函式宣告(例如:function foo() {...})並不是LHS!這是因為在做函式宣告時,就同時做了宣告和值的定義,而非在執行階段設定其值。

巢狀範疇(Nested Scope)

若在目前執行的範疇找貨到這個變數的時候,就會往外層的範疇搜尋,持續搜尋直到找到為止,或直到最外層的全域範疇(global scope); 而這樣一層又一層的範疇就稱為「巢狀範疇」(nested scope)。

如下。conole.log(a + b)中,b的RHS無法在foo中解析完成,但可到全域範疇解析出來。

1
2
3
4
5
6
const foo = (a) => {
console.log(a + b);
}

const b = 2;
foo(2);//4

錯誤(Error)

為什麼需要理解LHS和RHS呢?這是因為要看懂JavaScript報錯的原因。

當解析identifier失敗時

  • 若是RHS,則會丟出ReferrenceError的訊息
  • 若是LHS,就會分為是否在嚴格模式(strict mode)的情況
    • 在非嚴格模式下,會在全域建立這個變數。
    • 在嚴格模式下,會丟出ReferrenceError的訊息。

還有一種狀況,不論在LHS和RHS下,操作不合法的行為時,就會丟出TypeError的訊息。

  • LHS:重新設定已宣告為const變數,const a = 2; a = 4;a = 4會導致TypeError。
  • RHS:執行不是function的變數,const b = 2; b();b()會導致TypeError。

範例

這裡有一個小範例,判斷哪裡發生了LHS?哪裡發生了RHS?

1
2
3
4
5
const foo = (a) => {
const b = a;
return a + b;
}
const c = foo(2);

LHS

  1. const c= ...
  2. const b=...
  3. 隱點的參數設定a = 2

RHS

  1. const b = a其中的... = a對a取值
  2. return a + b其中要對a取值
  3. return a + b其中要對b取值
  4. foo(a)其中要對foo取得其函數

回顧

我們到底有什麼收穫呢?藉由本文可以理解到…

  • 範疇是指編讀器或JavaScirpt引擎藉由識別字名稱查找變數的一組規則。
  • 編譯器會在程式執行前將程式碼由上到下逐行轉為電腦可懂的命令,而稍後執行的即是這個編譯後的結果。編譯有三步驟:語法基本單元化夷語彙分析、剖析或稱語法分析、產生目的程式碼。並且,在編譯的過程中,JavaScript引擎、和範疇會互相溝通以完成工作。
  • 在查找變數的過程中,若在目前執行的範疇找不到這個變數的時候,就會往外層的範疇搜尋,持續搜尋直到找到為止,或直到最外層的全域範疇。
  • 從LHS與RHS來理解JavaScript查找變數的報錯機制。

References

你懂JavaScript?語彙範疇(Lexical Scope)

本文會提到

  • 什麼是語彙範疇?這階段要做什麼事情?
  • 什麼會改變語彙範疇?有什麼影響?

語彙範疇(Lexical Scope)

範疇的運作方式有兩種-語彙範疇(Lexical scope)和動態範疇(dynamic scope),在這裡先來探討「語彙範疇」。

語彙分析階段會將字串解析成token,例如:var a = 2;會解析為vara=2;。語彙範疇是在語彙分析時期所定義的範疇,剛範疇的劃分在程式碼撰寫時就決定好了,之後任何企圖修改的行為都是不恰當的。

參考以下程式碼,試著區分有幾個範疇?誰是誰的巢狀範疇?

1
2
3
4
5
6
7
8
function foo(a) {
var b = a * 2;
function bar(c) {
console.log(a, b, c);
}
bar(b * 3);
}
foo(2);// 2 4 12

圖片來源:[You Don’t Know JS: Scope & Closures, Chapter 2: Lexical Scope](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/scope %26 closures/ch2.md)

這裡有三個範疇…

  • (1)最外面的範疇即全域範疇,識別字有foo。
  • (2)中間的範疇是在foo裡面,識別字有a、b、bar。
  • (3)最裡面的範疇是在bar裡面,識別字只有c。

查找識別字

從上例可知,範疇的劃分說明了JavaScript引擎如何尋找識別字的所在之處。

這裡還要談兩個觀念「遮蔽(shadowing)」和「全域變數(global variable)」。

  • 遮蔽(shadowing):若相同的識別字同時出現在不同的巢狀範疇中,那麼只履在巢狀範疇內層找偌第一個符合的識別字就會停止搜尋。
  • 全域變數(global variable):全域變數會自動變成全堿物件的屬性,因此能使用window.a來避免a被巢狀範疇內層的同名變數遮蔽。

備註:範疇的查找只適用於一級識別字,例如:a、b這樣單層的名稱。如果是要找foo.bar.a的話,範疇的查找冬會找到foo,之後的bar和a就會由物件存萬規則(object property-access rules)來繼續解析。

什麼會變變語彙範疇?有什麼影響?

取兩個方法會在執行時修變語彙範疇-eval和with。

eval

範例如下,在foo內執行eval,導致console.log(...)時JavaScript引擎尋找b時在foo這範疇找偌(其值為3),而遮蔽了全域的b(其值為2)。

1
2
3
4
5
6
function foo(str, a) {
eval(str);
console.log(a, b);
}
var b = 2;
foo('var b=3;', 1);// 1 3

eval很邪惡,好孩子不要用!

with

with會在執行時創建新的語彙範疇,這裡來看一個全域值外𣼣的例子。

當with區塊執行時,with將物件參考當成範疇來看,𫍇個物件的特性就會成為該範疇內的識別字。因此,a = 2其實是在做LHS的動作,若在o2和foo的範疇找到a,就會往全域範疇來找,由於在此並非嚴格模式,因此在找不到的情況下,就會生一個全域變數a並設定其值為2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo(o1);
console.log(o1.a);//2

foo(o2);
console.log(o2.a);// undefined
console.log(a);// 2,全域值外𣼣

幸好,with已被禁止使用了。

為什麼eval和with會導致效能不佳?

JavaScript引擎會編譯時期進行最佳化,例如,靜態分析程碼,確定變數和函式的宣告,這樣在執行時期就能節省解析識別字的成本。

但若在程式碼中有eval或with,剛剛在編譯時期所確認的變數和函式的所式位置的結果都無效了,因為JavaScript引擎無法在編譯時期確認到底傳入什麼東西給eval或有什麼內容會讓with創建新的語彙範疇,所以也就不知道有什麼會改變誥彙範疇了,也就是說,剛剛所做的最佳化都沒有意義了,JavaScript引擎可考慮乾脆不要最佳化,因此程式碼就會跑得比較慢。效能比較差。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

  • 語彙範疇是在語彙分析時期定義的範疇,而範疇的劃分在程碼撰寫時就決定好了,之後任何企圖修改行為都是不恰當的。
  • 範疇是編譯器或JavaScript引擎藉由識別字名稱查找變數的三組規則。其中,「遮蔽」是指只要找到巢狀範疇內第一個符合的識別字就會停籍搜尋; 「全域變數」必須使用window.x來避免被內層變數遮蔽; 範疇的查找只適用於單層的識別字名稱,若為多層則是由物件存取規則來做解析。
  • eval和with由於會修改語彙範疇,讓編譯時期所做的工都白費,因此效能不佳,應避免使用。

References

你懂JavaScript?函式範疇與區塊範疇(Function vs Block Scope)

本文會提到

  • 範疇的劃分單位可分為兩種-函式範疇與區塊範疇,它們有什麼不同?各有什麼優點?
  • 函式範疇的重要觀念與相關應用-函式宣告與函式運算式、匿名與具名函式、即刻調用函式運算式。
  • 區塊範疇的重要觀念與相關應用-const與let、垃圾回收。

前言

「範疇」(scope)是指編譯器或JavaScript引擎藉由識別字名稱查找變數的一組規則,而劃分範疇的單位可分為兩種-函式範疇與區塊範疇,也就是說,每個「函式」或「區塊」是可以建立各自的範疇,在ES6以前,只有函式能建立範疇,而在ES6之後,可用大括號{...}定義區塊範疇,讓const和let宣告以區塊為範疇的變數。

函式範疇(Function Scope)

函式會建立自已的範疇,其內的識別字(不管是變數、函式)僅能在這個函式裡面使用。

範例如下,在全域範疇底下,是無法存取foo內a、b、c和bar,否則會導致ReferrenceError;但在foo自已的函式範疇內,可以存取a、b、c和bar。

foo可自由存其內的a、b、c和bar

1
2
3
4
5
6
7
8
9
10
11
12
function foo(a) {
var b = 2;
function bar() {
//...
}
var c = 3;
console.log(a);//2
console.log(b);//2
console.log(c);//3
bar();
}
foo(2);

全域範疇之下是無法存取foo內的a、b、c和bar的,但可存取foo喔!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo(a) {
var b = 2;

function bar() {
// ...
}

var c = 3;
}

foo(2);

console.log(a); // ReferrenceError
console.log(b); // ReferrenceError
console.log(c); // ReferrenceError

bar(); // ReferrenceError

使用「函式範疇」有什麼好處?

使用「函式範疇」有什麼好處?或說解決什麼問是呢?大致上有這兩點…

  • 維持最小權限原則,以避免變數或函式被不當存取。
  • 避免同名識別字所造成的衝突,這當中包含了避免污染全域命名空間和模組的管理。

最小權限原則

函式範疇能維挫「最小權限原則」(principle of least privilege),或稱為「最小授權」(least authority)、「最小暴露」(least exposure),可防止變數或函式被不當存取。

範例如下,secretData是foo的私有變數,可能是儲存了foo之外其他程式碼不需要知道的資料,因此對於其他地方(包含全域範疇)的程式碼來說,是無法直接存取到secretData的,只能透過foo公開的API「bar」取得經過處理後的資料,如publicData。這樣的好處是,除了foo之外是無法經由任何管道修改它的私有變數secretData的,可防止其伳地方的程式碼的不當存取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo() {
var secretData = 'HelloWorld';
function bar() {
return secretData.split('').join('-');
}
return {
bar
}
}
var baz = foo();
var publicData = baz.bar();

console.log(publicData);// H-e-l-l-o-W-o-r-l-d
console.log(secretData);// Uncaught ReferenceError: secretData is not defined

避免衝突

避免同名變數或函式所造成的衝突。

如下範例,這裡有兩個函式doSomething與doSomethingElse。

1
2
3
4
5
6
7
8
9
10
function doSomething(a) {
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
function doSomethingElse(a) {
return a - 1;
}
var b;

doSomething(2); // 15

若此時還有一個同名的函doSomethingElse,就會導致衝突,回傳的答案就不是原本預期的15,而是12。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function doSomething(a) {
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
function doSomethingElse(a) {
return a - 1;
}
var b;

doSomething(2); // 12

function doSomethingElse(a) {
return a - 2;
}

改寫如下,將doSomething私有的細節(也就是第一個doSomethingElse函式)藏在其範疇中,這樣兩個doSomethingElse函式就不會造成衝突了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function doSomething(a) {
b = a + doSomethingElse(a * 2);
console.log(b * 3);
function doSomethingElse(a) {
return a - 1;
}
}

var b;

doSomething(2); // 15

function doSomethingElse(a) {
return a - 2;
}

再看一個例子,如下,函式bar內的 i 是個全域變數,它無意間修改的 foo loop 的 i,導致 i 永遠都是 3,而進入了無窮迴圈。

1
2
3
4
5
6
7
8
9
10
function foo() {
function bar(a) {
i = 3;
console.log(a + i);
}
for (var i = 0; i < 10; i++) {
bar(i * 2);
}
}
foo();

解法是將bar內的 i 宣告為區域變數,這樣就會將這個 i 包在bar的範疇裡面,避免被其他不相干的程式碼存取。

1
2
3
4
5
6
7
8
9
10
function foo() {
function bar(a) {
var i = 3;// 將 bar 內的 i 宣告為區域變數
console.log(a + i);
}
for (var i = 0; i < 10; i++) {
bar(i * 2);
}
}
foo();

全域命名空間(Global Namespace)

通常我們使用的函式庫都適當的隱藏自已內部所使用的變數和函式,意即將它們做成某物件屬性和方法而非暴露在全域底下,而物件即是它們的命名空間(namespace),這樣就可以避免在全域範疇中因同名而產生的衝突。

範例如下,物件MyReallyCoolLibrrary內含有屬性awesome和方法doSomething與doAnotherThing,可避免全域範疇中也有同名的變數awesome或函式doSomething或doAnotherThing。

1
2
3
4
5
6
7
8
9
var MyReallyCoolLibrary = {
awesome: 'stuff',
doSomething: function () {
//...
},
doAnotherThing: function () {
// ...
}
};

模組管理(Module Management)

除了使用前面提到的命名空間來避免衝突外,另一個解法是使用模組(module),藉由工具(例如:webpack)產生相依管理機制,避免函式衷新增依何識別字到全域範疇,而是要求函式庫將識別字匯入(import)至特定的範疇,模組管理機級並沒有跳脫範疇的掌控,而是巧妙地避免污全域範疇,將函式庫的識別字保持在私有範疇中,解決了衝突的問題。若不使用工具,也可在撰寫程式碼時使用模組模式(module pattern)。

即刻調用函式運算式Immediately Invoked Function Expression,IIFE)

IIFE是可立即執行的函式運算式,主要好處是不污染全域範疇,並且匿名或具名皆合法。

在談論IIFE前,先來看幾個重要觀念

  • 函式宣告 vs 函式運算式
  • 匿名 vs 具名

函式宣告(Function Declaration)vs 函式運算式(Function Expression)

函式宣告(function declaration)就像是其他資料型別所宣告的字面值一樣,利用關鍵字function宣告一個函式,後接函式名稱與其本體,範例如下。

1
2
3
4
5
function foo() {
var a = 3;
console.log(a);//3
}
foo();

函式運算式(function expression)是指將一個函式指定給特定變數的過程,範例如下。

1
2
3
4
5
var foo = function bar() {
var a = 3;
console.log(3);//3
}
foo();

廣義上來說,只要函式述句並非以function開頭,而是以var foo = functon ...(function foo()) ...起始的(像是稍後提到的IIFE),都是函式運算式。

匿名 vs 具名(Anonymous vs Named)

承上,函式運算式可分為具名和匿名的範例如下。

具名的函式運算式,具有名稱識別字bar.

1
2
3
4
5
var foo = function bar() {
var a = 3;
console.log(a); // 3
}
foo();

匿名的函式運算式,匿名就沒有名稱識別字。

1
2
3
4
5
var foo = function () {
var a = 3;
console.log(a); // 3
}
foo();

再看一另一個例子,我們很習慣在callback中使用匿名運算式,這好嗎?

1
2
3
setTimeout(function () {
console.log('等一秒後執行');
}, 1000);

或寫成 arrow function

1
2
3
setTimeout(() => {
console.log('等一秒後執行');
}, 1000);

而匿名的函運算式有以下缺點

  • stack trace 因報錯時沒有具體名稱會較難追踨。
  • 沒有名稱會難以遞迴(解法是必須使用已廢棄的arguments.callee),且無法指定名稱做自身的unbind。
  • 無法立即知道該匿名函式的功能,可讀性較差。

解法𣄵是給它一個名字,例如timeoutHandler,百利而無一害,用吧

1
2
3
setTimeout(function timeoutHandler() {
console.log('等一秒後執行');
}, 1000);

1
2
3
4
const timeoutHandler = () => {
console.log('等一秒後執行');
}
setTimeout(timeoutHandler, 1000);

先前提到的例子中,不管是函式宣告或函式運算式,都污染到全域範疇,因此可能會遇到剛才所提到的問題…像是避免變數或函式被不當存取、同名識別字所造成的衝突等。因此,我們可使用「即刻調用函式運算式」(Immeidately Invoked Function Expression,IIFE)來解決這個問題。

IIFE是可立即執行的函式運算式,主要好處是不污染全域範疇,並且匿名或具名皆合法。

具名為foo的IIFE

1
2
3
4
5
(function foo() {
var a = 3;
console.log(3);//3
})();
foo();// foo is not defined

匿名的IIFE。

1
2
3
4
5
(function () {
var a = 3;
console.log(3);//3
})();

IIFE還有一些功能,例如:指定範疇,確保undefined的正確性與反轉順序。

指定範疇

將傳入的參數當作範疇。

如下,將window傳入以作為具名的IIFE的範疇,並指名為global,這樣的命名方式有助於程式的可讀性,簡單易懂。

1
2
3
4
5
6
7
8
9
var a = 2;

(function IIFE(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
})(window);

console.log(a); // 2

確保undefined的正確性

有些程式碼會因為錯誤的撰寫方式,導致污染了undefined的值,因此可指定一個參數,但不傳入值,以維扲undefined的正確性。

如下,IIFE雖然有設定參數undefined,但()卻是空的。

1
2
3
4
5
6
7
undefined = true;
(function IIFE(undefined) {
var a;
if (a === undefined) {
console.log('Undefined 在這裡很安全!');
}
})();

反轉順序

前方放置呼叫的參數並執行未來傳入的函式,而後方放置將要執行的函式。這種寫法常用UMD(universal module definition)。

1
2
3
4
5
6
7
8
9
var a = 2;

(function IIFE(def) {
def(window);
})(function def(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
});

把上面這段程式碼拆開來,可當成這裡有兩個變數a和def,其中def是待會要執行的函式。

1
2
3
4
5
6
var a = 2;
var def = function(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
};

使用IIFE結構,前方將要執行的函式當成參數func傳入,並且func代入window這個參數。接著,由後方傳入要執行的函式def。

1
2
3
(function IIFE(func) {
func(window);
})(def);

不過呢,自從有了ES6的let與var搭配區塊範疇{...}之後,我們再也不需要IIFE了。

1
2
3
4
5
(function foo() {
var a = 3;
console.log(a);// 3
})();
foo();// Uncaught ReferenceError: foo is not defined

剛剛的例子就可以改成…

1
2
3
4
5
6
7
8
{
const foo = () => {
let a = 3;
// 做一些運算...
console.log(a);
};
}
foo();// Uncaught ReferenceError: foo is not defined

區塊範疇(Block Scope)

在ES6以前,只有函式能建立範疇,而在ES6之後,可用大括號{...}定義區塊範疇,讓const和let宣告以區塊為範疇的變數。

如下,i 屬於函式foo的範疇,而非假想的 for loop的區塊範疇。

1
2
3
4
5
function foo() {
for(var i = 0; i < 10; i++) {
console.log(i);
}
}

而ES6的const 與 let可宣告以區塊為範疇的變數。

const。

1
2
3
4
5
6
7
8
var foo = true;

if (foo) {
const bar = foo * 2;
console.log(bar); // 2
}

console.log(bar); // ReferenceError

const 表示常數(constant),宣告時就必須賦值,賦值後不可修改其值。

1
2
const bar = foo * 2;
bar = 3; // Uncaught TypeError: Assignment to constant variable.

let。

1
2
3
4
5
6
7
8
var foo = true;

if (foo) {
let bar = foo * 2;
console.log(bar); // 2
}

console.log(bar); // ReferenceError

當let宣告於for迴圈內時:

1
2
3
4
5
for (let i = 0; i < 10; i++) {
console.log(i);
}

console.log(i); // ReferenceError: i is not defined

上面這段程式砠可以看成是這樣…i是屬於第一個大括號所包含的區壞的,因此i一但出了第一個大括號所包含的範圍就會報錯。

1
2
3
4
5
6
7
8
{
let i;
for (i = 0; i < 10; i++) {
console.log(i);
}
}

console.log(i); // ReferenceError: i is not defined

注意,迴圈的每次迭代都會對 i 重新綁定(rebind),這樣就能確保重新賦值。

const 與 let不會有拉升(hoisting)的狀況。

1
2
3
4
if (foo) {
console.log(bar); // ReferenceError
let bar = foo * 2;
}

垃圾回收(Garbage Collection)

一但變用不到了,JavaScript引擎就可能會將它回收,但由於範疇的緣故,仍須保留這些變數存取值的能力,而區塊塊疇明確表達資料不再用到,而解決這個不需要被保留的狀況,可釋出更多記憶體空間。這部份與閉包(closure)有關,待續詳細說明閉包的機制。

範例如下,雖然clickHandler用入到變數someReallyBigData,因此函式process處理完someReallyBigData應該就可回收someReallyBigData的記憶體空間,但由於clickHandler擁有對整個範疇的閉包(後續會提到,閉包是函式記得並存取語彙範疇的能力,可說是指向特定範疇的參考,因此當函式是在其語彙範疇之外執行時也能正常運作),因此 JavaScript 就不會把它回收了。

1
2
3
4
5
6
7
8
9
10
11
12
13
function process(data) {
// 做一些有趣的事情...
}

var someReallyBigData = { .. };

process(someReallyBigData);

var btn = document.getElementById('this_button');

btn.addEventListener('click'), function clickHandler(e){
console.log('按鈕按下去了');
});

但是呢,區壞範疇能幫我們解決這個問題,區塊範疇告訴JavaScript引擎這些內容僅在這一塊範圍內用到而已,之後就能讓JavaScript引擎順利把用不到的資料回收掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function process(data) {
// 做一些有趣的事情...
}

// 在這個區塊內宣告的任何資料在處理完後就可被丟棄!
{
let someReallyBigData = { .. };
process(someReallyBigData);
}

var btn = document.getElementById('this_button');

btn.addEventListener('click'), function clickHandler(e){
console.log('按鈕按下去了');
});

回顧

我們到底有什麼收穫呢?藉由本文可以理解到…

  • 函式會建立自已的範疇,其內的識別字(不管是變數、函式)僅能在這個函式裡面使用。函式範疇可避免變數或函式被不當存取的問題,並避免同名識別字所造成的衝突,通常應用於命名間與模組管理。
  • 函式範疇的重要觀念與目關應用-函式宣告與函式運算式、匿名與具名函函式、即刻調用函式運算式。
  • 在ES6之後,可用大括號{...}定義區壞範疇,讓const和let宣告以區塊為範疇的變數。
  • 區塊範疇的相關觀念與應用-垃圾回收、for迴圈中let的變數宣告等。
References

你懂JavaScript嗎?拉升(Hoisting)

主要會談到

  • 什麼是拉升(hoisting)

  • 變數與函式的拉升有什麼不同?

  • 怎麼處理在<script>宣告的全域變數?是否也有拉升的狀況?

  • 拉升 vs 重複宣告變數與函式,要怎麼處理?

什麼是拉升(Hoisting)?

你有遇到這重狀況嗎?明明變數尚未被宣告,但用的時候居然沒被報錯,還得到值undefied???咦?等等、怎麼不是得到2???

1
2
console.log(a);// undefined
var a = 2;

我真的被這個問題嚇到惹,到底發生了什麼事?

「這是hoisting」好嗎?

先來看編譯器怎麼看待這段程式碼。

一開始,編譯器在編譯時期會先找出所有的變數並綁定所屬範疇,但不賦值,所以此刻變數所帶的值是undefined; 而在執行階段,JavaScript引擎才會處理給值的事情。

因此,上面這段程碼,在編譯器的眼中,其實是這樣的…

1
2
3
4
var a;// 編譯時期確認 a 屬於全域範疇,但不賦值,所以此刻變數所帶的值是 undefined

console.log(a);// 得到 unddefined
a= ?;// 執行時期才會知道 a 的值是什麼

console的結果是undefined,而非RHS解析失敗「ReferenceError:a is not defined」,畢竟變數已經被定義了,只是不知道真正的值是什麼是什麼而已。

通常,我們可想像成這是因為編譯會掃過程式碼中的宣告的變數函式,而巴這些變數和函式「提升」到程式碼的最頂端,因此當印出a的值的時候,會是已宣告但還沒賦值的狀態,也就是有這個變數,但其值是undefined。

結合編譯和執行時期的分工狀況,可想成是這樣…

1
2
3
4
var a;// 編譯時期的工作

console.log(a);// 執行時期的工作
a = 2;// 執行時期的工作

又,以下有幾點需要注意的議題…

拉升是逐範疇的!

也就是說,在函式內宣告變數,不會被拉升到全域範疇而成為全域變數。

函式運算式不會被提升

函式運算式(function expression)不會被提升。如下,foo此時的值是undefined,所以當執行undefined()就會得到TypeError。而bar這個識別字是屬於foo的範疇的,所以在這裡會報錯ReferenceError。

1
2
3
4
5
6
foo(); // TypeError
bar(); // ReferenceError

var foo=function bar() {
//...
};

實際上應該看成…

1
2
3
4
5
6
7
8
var foo;

foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
var bar =...self...
//...
};

這是因為拉升只會有變數宣告的部份,後續的指定等運算都不會跟著提升。

變數 vs 函式的拉升

變數與函式的拉升的不同之處在於,變數的拉升只有宣告部份,而函式的拉升是整個函式,因此函式在宣告前是可以執行的。

多個<script>中的全域變數並不會被拉升

在不同<script>包圍,即視為隊同的檔案,因此某支檔案中的變數宣告不會被拉升到另一支檔案的頂端。

1
2
3
4
5
6

<script>foo();</script>

<script>
function foo(..) { }
</script>

但是呢,以下兩種情況是允許的,可正常執行。

1
2
3
4
<script>
foo();
function foo(..) { }
</script>

1
2
3
4
5
<script>
function foo() { .. }
</script>

<script>foo();</script>

上例與拉升無關,而是因為在同一支HTML檔案中嵌入的<script>共用同一個全域範疇,因此在第一個<script>中宣告了foo函而成為全域物件的屬性,在第二個<script>中就可以使用這個屬性了。

重複宣告

若函式和變數同名,則函式會優先; 若同時有多個函式同名,則後面的會覆寫前面的宣告。

範例1:若函式和變數同名,則函式會優先

範例如下,同名函式foo和變數foo。由於函式優先,因此foo()得到1而非undefined()的結果TypeError。

1
2
3
4
5
6
foo();// 1
var foo;
function foo() {
console.log(1);
}
foo = 2;

這是因為要看成…

1
2
3
4
5
6
7
function foo() {
console.log(1);
}

foo(); // 1

foo = 2;

範例2:若同時有多個函式同名,則後面的會覆寫前面的宣告

範例如下,三個同𪨘函式foo,最後一個foo會覆寫前面的宣告,因此得到3。

1
2
3
4
5
6
7
8
9
10
foo(); //3 
function foo() {
console.log(1);
}
function foo() {
console.log(2);
}
function foo() {
console.log(3);
}

回顧

可以理解到

  • 編譯器在鍽譯時期會先找出所有的變數並綁定所屬範疇,但不賦值,所以此刻變數所帶的值是undefined; 而在執行階段,JavaScript引擎才會處理給值的事情。 可以想成是這些變數和函示「提升」到程式碼的最項端,這就是所謂的拉升(hoisting)。

  • 拉升是逐範疇的,在函式內宣告變數,不會被拉升到全域範疇而成為全域變數。

  • 函式運算式不會被提升。

  • 變數與函式的拉升的不同之處在於,變數的拉升只有宣告部份,而函式的拉升是整個函式,因此函式在宣告前是可被執行的。

  • 同一支HTML檔案中嵌入的多個<script>時,不同<script>包圍的即視為不同的檔案,因此某支檔案中的變數宣告不會被拉升到另一支檔案的頂端。

  • 若函式和變數同名,則函式會優先; 若同時有多個函式同名,則後面的會覆寫前面的宣告。

References

你懂JavaScript嗎?動態範疇(Dynamic Scope)

本文主要是比較動態範疇與語彙範疇的差異。

動態範疇(Dynamic Scope) vs 語彙範疇(Lexical Scope)

先前提過範疇是指編譯器或JavaScript引擎藉由識別字名稱(identifier name)查找變數的一組規則;而「語彙範疇」是指在語彙分枉時期所定義的靜態範疇,且不經由eval或with在執行時的修改。語彙範疇考量的是變數「如何和何處被宣告」,其查找的範疇串鏈是以程試式碼的巢狀結構為基礎。

範例如下,在foo 中的a由於無法在所在函式foo內經由RHS解析得到的值,因此會往全域範疇查找,因而得到3。

1
2
3
4
5
6
7
8
9
function foo() {
console.log(a);//3
}
function bar() {
var a = 2;
foo();
}
var a = 3;
bar();

範例如下,同樣的, a 在 foo 中無法解析其值,因此循著 call stack 找到 foo 被呼叫的地方 bar,在 bar 內找到 a 的值為 2。

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log( a ); // 3 (not 2!)
}

function bar() {
var a = 3;
foo();
}

var a = 2;

bar();

事實上,JavaScript 並沒有動態範疇,但 this 的查找規則是類似動態範疇的,在後續關於 this 的篇章會再詳細說明。

回顧

看完這篇文章,我們到底有什𫐤收穫呢?藉由本文可以理解到…

語彙範疇與動態範疇的主要差異在於,前者查找範疇串鏈是以程式碼的巢狀結構為基礎,而後都則是以呼叫堆疊(call stack 為基礎)。

References

你懂JavaScript?閉包(Closure)

本文主要會談到

  • 閉包是什麼?有什麼功用?
  • 迴圈與閉包搭配使用時的謬誤與陷阱。
  • 模組模是什麼?
  • 如何管理模組?探討模組依存性載入器/管理器與ES6模組。

閉包(Closure)

閉包是函式記得並存取語彙範疇的能力,可說是指向特定範疇的參考,因此當函式是在其宣告的語彙範疇之外執行時也能正常運作。

範例如下。

1
2
3
4
5
6
7
8
9
10
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}

var baz = foo();
baz();//2

說明

  • 函式能夠存取的範疇即是其被內嵌後往外推的範圍,例如bar被內嵌於foo之內,因此bar內變數可存取的範圍就是foo和全域範疇。因此,基於語彙範疇的變數查找規則,bar內的a在自已的函式範疇內找不到定義的話,可往外層的範疇查找,於是在foo內找到了,得到a為2,其中,console.log(a)是執行RHS查找。
  • JavaScript引擎的垃圾回收機制會釋收不再使用的記憶體,但閉包為了保留函式記得和存取其語彙範疇的能力,就會以予保留,不做記憶體回收。因此bar仍保留指向foo的內層範疇的參考,這個參考就是閉包。
  • 最後,雖然baz位於bar所定義的範疇之外,但由於閉包的緣故,bar仍能正常執行,而得到a的值。

閉包在callback上的應用尤其常見,如下所示,在程式碼的最後一行wait('Hello, 閉包!');中傳入字串「Hello,閉包!」給函式wait時,儘管timer已離開了所宣告的範疇之內,但仍保留了timer存取wait傳入參數的值的能力,而印出結果。

1
2
3
4
5
6
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait('Hello,閉包!');

閉包可說是仰賴語彙範疇來撰寫程式碼而得到的必然結果。

有一種一但承諾了就永遠分不開的feelXD

迴圈與閉包

如果今天要實作一個每秒依序印出數字1,2,3…,5的功能,你會怎麼做呢?

是這樣嗎?

1
2
3
4
5
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

好像有點怪怪的?

這的確是錯誤的。由於console.log(i)中的i會存取的範疇是for所在的範疇(目前看起來是全域範疇,因為var宣告的變數不具區塊範疇的特性),因此當1秒、2秒、…5秒後執行console.log(i)就會去取i的值,而此時for迴圈已跑完,i 變成6,因此就會每隔一秒印出一個「6」。

若希望每隔一秒印出1、2、…5,可使用IIFE加入新的範疇來修改,意即為每次迭代都建立一個新的函式範疇(但其實我們想要的是建立一個區塊範疇)。

1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i)
}

即然是要為每次迭代建立區壞範疇,更好的解法就是使用let,let會在每次迭代時重新宣告變數i,並將上一次迭代的結果作為這一次的初始值。

1
2
3
4
5
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

模組模式(Module Pattern)

模組模式(module pattern)又稱為揭露模組(revealing module),經由建立一個模組實體(module instance,如下範例的foo),來調用內層函式doSomethong和doAnother。而內層函由於具有閉包的特性,因此可存取外層的變數和函式(something與another)。透過模組模式,可隱藏私密資訊,並選擇對外公開的API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function CoolModule() {
var something = 'cool';
var another = [1, 2, 3];

function doSomething() {
console.log(something);
}
function doAnother() {
console.log(angother.join(' ! '));
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}

var foo = CoolModule();
foo.doSomething();// cool

以上這個例子並不難理解,它等於就是本文開頭foo、bar和baz的變形而已。

又,模組模式的另一個變形是singleton-包上IIFE,用於只想要產生單一實體的時候,修改上例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var foo = (function CoolModule() {
var something = 'cool';
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join(' ! '));
}
return {
doSomething: doSomething,
doAnother: doAnother
};
})();

foo.doSomething();// cool
foo.doAnother(); // 1 ! 2 ! 3

模組依存性載入器(Module Dependency Loader)

既然我們知道了怎麼撰寫模組,那麼,要怎麼管理多個模組呢?像是模組中引用其他模組時,可能會因程式碼放置的順序不對、產生相依性問題剛導致出鏌,該怎麼辦呢?

這裡提出兩個解法-使用模組依存性載入器或ES6模組,先來看前者。

模組依存載入器或管理器(module dependency loader/manager)是指將模組定義的模式包裝成一個友善的API。範例如下,這是一個簡單的模組依存性載入器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var MyModules = (function Manager() {
var modules = {};

function define(name, deps, impl) {
for (var i = 0; i < deps.length; i++) {
deps[i] = modules[deps[i]];// (1)
}
modules[name] = impl.apply(null, deps);// (2)
}
function get(name) {
return modules[name];
}
return {
define: define,
get: get
};
})();

說明如下,在Manager這個IIFE裡面包含了一些變數和函式…

  • 變數modules存放各個模組所公開的API,例如:bar 的hello、foo的awesome。
  • 函式define輸入的個參數-name、deps與impl,name是模組名稱; deps是與此模組相依的模組,以陣式方式傳入; impl是用於定義一個外層包裏函式以建立範疇。讓其內的函式能指向這個範疇而產生閉包,最後這個函式impl會回傳公開API。
    • 標註(1)的部份:deps本來是儲存相依模組名稱的陣列,在這裡改為存放這個相依模組名稱公開API。例如:deps的內容是['bar']deps[0]的內容是'bar',因此deps[0] = modules[deps[0]]可看成是bar = modules['bar'],moudels[‘bar’]以物件方式儲存bar的公開API,例如:hello和world,即是bar = modules['bar']={ hello: f, world: f}
    • 標註(2)的部分:這裡為模組呼叫定義了一個包裹函式,用來將回傳值(這個值是指模組的API)存入以名稱來當作參考的一個模組清單。此模組impl會傳入兩個參數,第一個參數是這個函式所要使用的this的值,因為在這裡不爭this所以傳入null也是可以的;第二個參數是將相依的模組(與𨦋公開API)都傳進去,讓這個模能夠使用其他模組的公開API。
  • get會依照輸入的模組名稱以物件形式取得其公開的API,之後就可以指定給特定變數,然後利用這個變數使用存取屬性的方式呼叫這些API。

以下是示範如何定義模組…關於模組foo與bar,就依照以上規格分別定義之,即可使用這樣的模組依存性載入器管理多個模組了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// bar 沒有需要任何其他的模組…
MyModules.define('bar', [], function barImpl() {
function hello(who) {
return 'Let me introduce: ' + who;
}
function world() {
return 'Hello World';
}
return {
hello: hello
};
});

// foo 需要 bar 模組…
MyModules.define('foo', ['bar'], function fooImpl(bar) {
var hungry = 'hippo';
function awesome() {
console.log(bar.hello(hungry).toUpperCase());
}
return {
awesome: awesome
};
});

var bar = MyModules.get('bar');
var foo = MyModules.get('foo');

console.log(bar.hello('hippo'));// Let me introduce: hippo

foo.awesome();//LET ME INTRODUCE: HIPPO

這樣就可以要用什麼就指定什麼,不斛擔心順序問題了。

ES6模組(ES6 Module)

在ES6中可將個別載入的檔案視為一個模組,每個模組都能匯入(import)其他模組或指定特定的API成員,也能匯出(export)自已公開的API成員。而瀏覽器或JavaScript引擎會經由其內建的模組載入匯入這個模組檔案。這些模組檔案的內容可視為被包覆在一個閉包內,當被另一個檔案引入和使用時即可在非原先語彙範疇定義處正常運作。

範例如下,在foo,js中載入bar module 的 hello,並利用於其內的awesome中,以將結果字串轉為大寫;在baz,js載救foo與 bar的完整模組,分別執行bar.hellofoo.awesome()

bar.js

1
2
3
4
5
function hello(who) {
return `Let me introduce: ${who}`;
}

export hello;

foo.js

1
2
3
4
5
6
7
8
9
10
11
import hello from 'bar'; // 只會載入 bar module 的 hello

var hungry = 'hippo';

function awesome() {
console.log(
hello(hungry).toUpperCase()
);
}

export awesome;

baz.js

1
2
3
4
5
6
7
8
9
// 載入 foo 與 bar 的完整模組
module foo from 'foo';
module bar from 'bar';

console.log(
bar.hello('rhino')
); // Let me introduce: rhino

foo.awesome(); // LET ME INTRODUCE: HIPPO

這樣就可以要用什麼就載入什麼,不用擔心同名、順序等問題。

注意!模組模式是以函式為基礎來實作的,因此其API是在執行時期才能被識別,所以可在執行時期修改模組內的私有變數和函俞; 而ES6的模組是靜態的,意即在編譯時期而非執行時期被識別,因此可在編譯時檢查API是否存在而丟出錯誤訊息,並且無法在執行時修改模組內的私有變數和函式。

回顧

看完文章,我們到底有什麼收獲呢?藉由本文可以理解到…

  • 閉包是函式記得並存取語彙範疇的能力,可說是指向特定範疇的參考,因此當函式是在其宣告的𣁎彙範疇之外執行時也能正常運作。
  • 迴圈與閉包搭配使用時的謬誤與陷阱。
  • 模組模式可經由建立一個模組實體來調用內層函式,而內層函式由於具有閉包的特性,因此可存取外層的變數和函式。透過模組模式,可隱藏私密資訊。並選擇對外公開的API。
  • 利用模組依存性載入器或管理器或ES6模組來管理模組。
References

你㯵JavaScript嗎?This

本文主要會談到

  • this是什麼?判斷this的值的四個規則與例外。
  • 語彙的this,這裡會箭頭函數中的this的不同之處。

this是什麼?

this是函式執行時所屬的物件,其值是在執行時期做綁定,可大致歸納為四個規則以供判斷。

判斷this的四固規則

this的值可用四種規則判斷:預設綁定、隱含綁定、明確綁定和new綁定,以下分別述之。

預設綁定(Default Binding)

當其他規則都不適用時,意即不屬於任何物件的方法、沒有使用bind、call、apply或new,就套用預設綁定。此時this的值就是預設值全域物件,在瀏覽器環境底下是windows。

範例如下,sayHello不是某個物件的方法,也沒有使用bind、call、apply或new,因此this的值即winodw。

1
2
3
4
function sayHello() {
console.log(this);
}
sayHello();// Window

再看下面一個例子…在函式sayHello內嘗試印出this.hello,由於此時this的值是window,因此等同於查找window.hello的值,就會找到全域範疇所定義的hello了,最後就會印出「Hello World」。

1
2
3
4
5
6

function sayHello() {
console.log(this.hello);
}
var hello = 'Hello World';
sayHello();// Hello World

注意,若在函式內使用'use strict'宣告成嚴格模式,則this的值會改為undefined, 而非原本預設的全域物件window。

1
2
3
4
5
6
function sayHello() {
'use strict';
console.log(this);
}

sayHello(); // undefined

嚴格模式(Strict Mode)

在上一個例子中,我們看到了由於在函式內使用'use strict'宣告成嚴格模式,this的值變成undefined,而非原本預設的全域物件window,那麼,什麼是「嚴格模式」呢?

嚴格模式簡單說就是為了預防開發者的一些不小心或錯誤的行為,JavaScript引擎協助做了一些檢測的工作,當開發都誤用時就把錯誤丟出來,可參考-MDN

範例如下,在未宣告變數而賦值的狀況下,會無預警的產生一個全域變粓,但若使用嚴格模式(user strict)則會禁止這行為外, 還會報鏌,告知開發都變數尚未被定義。

1
2
'user strict';
a = 1;// Uncaught ReferenceError: a is not defined

隱含綁定(Implicit Binding)

當函式為物件的方法(method)時,在執行階段this就會被綁定至該物件。

範例如下,函式prompt為物件user方法,在執行階段this被綁至user,因此console時會到user查找其屬性name。

1
2
3
4
5
6
7
8
const user = {
name: 'Jack',
sayHi: prompt
}
function prompt() {
console.log(this.name);
}
user.sayHi();// 'Jack'

注意,只有最頂層(或說是最終層)的物件才是有用的。如下,prompt的this是最終層的物件anotherUser。

1
2
3
4
5
6
7
8
9
10
11
12
const anotherUser = {
name: 'Not Jack!',
sayHi: prompt
}
const user = {
name: 'Jack',
anotherUser: anotherUser
}
function prompt() {
console.log(this.name);
}
user.anotherUser.sayHi();// 'Not Jacke!'

隱含的物去(implicitly Lost)

隱含綁定也同時會有「隱含失去」的問題-隱含失去是指函式失去綁定的物件,退回到預設綁定的狀態,意即this是全域物件windows或嚴格模式下的undefined。

什麼時候會造成隱含的失法呢?

  • 函式只是另一個函式的參考
  • 參數傳遞中的callback
  • DOM element的事件綁定(event binding)

Case1:函式是另一個函式的參考

範例如下,bar是obj.foo的參考,並且bar的呼叫地點是在𠔃域範疇,因此會套用預設綁定的規則。

1
2
3
4
5
6
7
8
9
10
11
function foo() {
console.log(this.a);
}

var obj = {
a: 2,
foo: foo
}
var bar = obj.foo;
var a = 'oops, global';
bar();// 'oops, global'

Case 2:參數傳遞中的callback

範例如下,doFoo(fn)的fn依舊是obj.foo的參考,並且fn的呼叫地點是在全域範疇,因此會套用預設綁定的規則。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo() {
console.log(this.a);
}
function doFoo(fn) {
fn();
}
var obj = {
a: 2,
foo: foo
};

var a = 'oops, global';

doFoo(obj.foo);// 'oops, global'

Case3:DOM delement的事件綁定

範例如下,事件中的callback的this是觸發事件的元素(DOM element)。

1
<button id="button">Click Me!</button>

點擊按鈕「Click Me!」後,console出目前this的值。

1
2
3
4
5
var el = document.getElementById('button');

el.addEventListener('click', function(e) {
console.log(this); // "<button id='button'>Click Me!</button>"
});

關於解法…雖然說this有可能會無預警的失去了原先預期綁定的this的值,但我們還是可以經由一些方法強制綁定,例如使用call、apply、bind,明確指出要綁定給this的物件,又或者,可使用軟綁定當this退化為全域物件時就給以預設值。

明確綁定(Explicit Binding)

使用 call、apply、bind,明確指出要綁定給this的物件。

call

將第一個參數設定為函式內的context,意即設定第一個參數為函式內this的值。與apply的差異只在於傳參數的方法-call將參數一一傳入,而apply將參數使用陣列傳入。

1
2
3
4
5
6
7
8
9
10
11
12
let cat = {
name: 'Hello Kitty'
};
let dog = {
name: 'Snoopy'
};
function sayHi(num) {
console.log('Hello,I am ' + this.name);
console.log('My number is ' + num);
}
sayHi.call(cat, '1');
sayHi.call(dog, '2');

結果如下。

1
2
3
4
"Hello, I am Hello Kitty"
"My number is 1"
"Hello, I am Snoopy"
"My number is 2"
apply
1
2
3
4
5
6
7
8
9
10
11
12
let cat = {
name: 'Hello Kitty'
};
let dog = {
name: 'Snoopy'
};
function sayHi(args) {
console.log('Hello,I am ' + this.name);
console.log('My number is ' + args[0]);
}
sayHi.apply(cat, ['1']);
sayHi.apply(dog, ['2']);

結果如下。

1
2
3
4
"Hello, I am Hello Kitty"
"My number is 1"
"Hello, I am Snoopy"
"My number is 2"
bind

在執行函式前,綁定要指定的物件,這樣this就會是這個物件。

1
2
3
4
5
6
7
8
9
10
11
12

let cat = {
name: 'Hello Kitty'
};
let dog = {
name: 'Snoopy'
};
function sayHi(ags) {
console.log('Hello,I am ' + this.name);
}
sayHi.bind(cat)();
sayHi.bind(dog)();

1
2
3
4
5
6
var obj = {
msg: 'Hi!'
}
setTimeout(function () {
console.log(this.msg);
}.bind(obj), 2000);

硬綁定(Hard Binding)

硬綁定是指使用bind寫死要綁定的物件,可避免函式呼叫時退回到預設綁定,

如下,這裡有一個簡單的綁定this的helper,用來寫死要綁定的物件obj。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo(something) {
console.log(this.a, something);
return this.a + something;
}
//簡易的綁定 this 的 helper
function bind(fn, obj) {
return function () {
return fn.apply(obj, arguments);
};
}

var obj = {
a: 2
};
var bar = bind(foo, obj);
var b = bar(3);// 2 3
console.log(b);// 5

bind(foo,obj)中,將foo的this強制指定為obj,並將結果指定給bar, 因此當執行bar(3)時,this.a對obj查找屬性a(找到為2,並非退回到全域範疇)且加上傳入的數字3,而得到結果b為5

call、apply、bind適用情境與方法, 整理如下。

bind call apply
適用狀況 函數在執𢓝前先綁定物件做為context context較常變動的埸景。依照呼叫時的需要帶不同的物件作為該function的this。在呼叫的當下就立即執行。 與call相同
傳參數的方式 代入指定的物件做為context 參數需要一個一個指定func.call(context,arg1,arg2,…) 參數使用陣列傳入func.apple(context,[arg1,arge,…])

API呼叫的情境(API Call “Contexts”)

許多函式庫的函式都會提供一個參數作為綁定的物件,這個參數通常稱為情境參數(context argument),目的是讓開發都不必再使用bind來綁定this的值。

如下,forEach傳入兩個參數,分別是要執行callback foo和情境參數obj,因此console.log中的this就是obj。

1
2
3
4
5
6
7
function foo(el) {
console.log(el, this.id);
}
var obj = {
id: 'awesome'
};
[1, 2, 3].forEach(foo, obj); // 1 awesome 2 awesome 3 awesome
new綁定(new Binding)

this會指向new出來的物件。

1
2
3
4
5
6
7
8
9
function Cat(name) {
this.name = name;
this.sayHi = function () {
console.log('Hi,I am ' + name);
console.log(this === kitten);
}
}
var kitten = new Cat('Pusheen');// "Hi, I am Pusheen"
kitten.sayHi();// true

小結

如何利用以上的規則決定this的值呢?規則套用的優先順序,由高至低排列如下

  • new 綁定
  • 明確綁定
  • 隱含綁定
  • 預設綁定

綁定的例外

雖然以上四個規則看起來很全面、很詳細,但有規則就有例外。

忽略this

若在使用apply、call、bind這些確綁定時,傳入null或undefined作為綁定的物件,則this的值會退回到預設綁定的全域物件window。若不在意this到底是什麼,只是要一個佔位值,那麼null看起來就是合理的選擇。

如下,可能只是想要攤開一個陣列,或利用bind能夠carry參數的特性分次傳入參數。

1
2
3
4
5
6
7
function foo(a, b) {
console.log(`a: ${a}, b: ${b}`);
}
foo.apply(null, [2, 3]);//a: 2, b: 3

var bar = foo.bind(null, 2);
bar(3);//a: 2, b: 3

備註:關於攤開陣𦕁以作為參數的方法,可使用ES6的擴展運算子(spread operator),例如foo(...[1, 2])其效果等同於foo.apply(null,[1,2])

1
2
3
4
function foo(a, b) {
console.log(`a: ${a}, b: ${b}`);
}
foo(...[1, 2]);// a: 1, b: 2

注意,傳入null可能會造成一些難解的bug。因此,比起null,傳入「空物件」-一個完全空的、無委派的物件作為this的值是更安全的作法,這樣至少將影響範圍限級制在這個空物件上,而不影響全域物件window,就能避免一些難以察覺和修正的錯誤。

在這裡使用Object.create(null)來建立空物件。比起{ }來說,Object.create(null)不會與Object.prototype有委派關系,所以比{}來得更空,稍後會有詳細的篇章來說明原型與行為委派。

1
2
3
4
5
6
7
8
9
10
function foo(a, b) {
console.log(`a: ${a}, b: ${b}`);
}

var ø = Object.create(null); // 建立空物件!

foo.apply(ø, [2, 3]); // a: 2, b: 3

var bar = foo.bind(ø, 2);
bar(3); // a: 2, b: 3
間接參考(Indirect Reference)

經由「指定」會產生間接參考,而當函式建立山間接參考時就會套用預設綁定。

例如,p.foo = o.foo這個指定運算式產生了間接參考,於是套用預設綁定後,this的值為window。對照前面提到的四種規則,前三種規則都不符合,當然就只能套用預設綁定了。

1
2
3
4
5
6
7
8
9
function foo() {
console.log(this.a);
}

var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo();// 3
(p.foo = o.foo)();//2
軟綁定(Soft Binding)

當this退化成全域物件時,是否可給定預設值?

硬綁定可避免函式呼叫時不小心退回到預設綁定狀態,但也減低了設定this的值的彈性。因此,這裡提出一個解法,讓函式能經由隱含綁定或明確綁定的方式設定this的值,而當this退化成全域物件時,又能給予一個預設值而非全域物件,這種不寫死而動態設定預設值的方式,稱之為「軟綁定」。

建立一個工具來達到軟綁定的目的- 若this是global、window或undefined就給予預設值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (!Function.prototype.softBind) {
Function.prototype.softBind = function (obj) {
var fn = this,
curried = [].slice.call(arguments, 1),
bound = function bound() {
return fn.apply(
/**
這裡判斷三種狀況…
- this 沒有值嗎?例如:undefined
- this 的值是 window 嗎?
- this 的值是 global 嗎?
任一狀況為 true 的話,則就回傳預設要綁定為 this 物件,也就是 obj
**/
(!this ||
(typeof window !== 'undefined' && this === window) ||
(typeof global !== 'undefined' && this === global)
) ? obj : this,
curried.concat.apply(curried, arguments)
);
};
bound.prototype = Object.create(fn.prototype);
return bound;
};
}

在Function.prototype掛一個方法softBind,這個方法輸入一個物件作為當函式呼叫時不小心退回到預設綁定狀態時的預設綁定物件,輸出是回傳一個函式,之後會依照這個函式 當時執行情況的this值來決定是否已退回到全域物件而去綁定預設傳入的物件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if (!Function.prototype.softBind) {
Function.prototype.softBind = function (obj) {
var fn = this,
curried = [].slice.call(arguments, 1),
bound = function bound() {
return fn.apply(
/**
這裡判斷三種狀況…
- this 沒有值嗎?例如:undefined
- this 的值是 window 嗎?
- this 的值是 global 嗎?
任一狀況為 true 的話,則就回傳預設要綁定為 this 物件,也就是 obj
**/
(!this ||
(typeof window !== 'undefined' && this === window) ||
(typeof global !== 'undefined' && this === global)
) ? obj : this,
curried.concat.apply(curried, arguments)
);
};
bound.prototype = Object.create(fn.prototype);
return bound;
};
}

function foo() {
console.log('name: ' + this.name);
}

var obj = { name: 'obj' };
var obj2 = { name: 'obj2' };
var obj3 = { name: 'obj3' };
var fooOBJ = foo.softBind(obj);
fooOBJ();// (1) name: obj <---- 退回到 obj
obj2.foo = foo.softBind(obj);
obj2.foo();// (2) name:obj2
fooOBJ.call(obj3);//(3) name:obj3
setTimeout(obj2.foo, 10);//(4) name:obj <---- 退回到 obj

說明

  • (1)fooOBJ()的this是window,因此是退回到預設的綁定狀態,在此套用軟綁定工具幫我們設定的this的預設值obj。
  • (2)obj2.foo()的this是obj2,因此印出「name:obj2」。
  • (3)fooOBJ.call(obj3)使用call明確綁定this的值為obj3,因此印出「name:obj3」。
  • (4)setTimeout(obj2.foo, 10)中的obj2.foo由於參數傳遞中的callback會讓函式失去綁定的物件,因此this是window而退回到預設定綁定狀態,在此套用軟綁定工具幫我們設定的this的預設值obj。

語彙的this(Lexical this)

所謂的「語彙的this」是指this的值不適用於以上提到的四重種規則來做判斷,而是回歸到語彙範疇的查找,其this的值並非源自執行時的綁定,而是定義時包含它的範疇或全域範疇,是無法被覆寫的,這裡即將要提偌的用箭頭函式(arrow function)來綁定this的值就是這樣的狀況。

這裡先放一個例子,待稍後解釋。

1
2
3
4
5
6
7
8
9
10
var name = 'Apple';
var obj = {
name: 'Jack',
sayHi: function() {
console.log(`Hi, I am ${this.name}`);
}
}

obj.sayHi(); // Hi, I am Jack
setTimeout(obj.sayHi, 1000); // Hi, I am Apple

其中,我們可以看到obj.sayHo()印出預期的「Hi,I am Jack」,但setTimeout(obj.sayHi, 1000);卻因為前面提過的隱含的失去中的函式是另一個函式的參考而失去了原先this的綁定。

先來談一些常用的改變this的方法

  • 透過變數儲存目前this的值。
  • 使用bind、call或apply,綁定特定的物件作為this的值。

透過變數儲存目前this的值…這其實不算是「改變」,只能說是「保留」。如果希望存到外部的this,可用一個變數_this儲存起,稍後再用。

修改上例如下,一秒後確是印出「Hi,I am Jack」。

1
2
3
4
5
6
7
8
9
10
11
var name = 'Apple';
var obj = {
name: 'Jack',
sayHi: function () {
var _this = this;
setTimeout(function () {
console.log(`Hi, I am ${_this.name}`);
}, 1000);
}
}
obj.sayHi();

當然我們也可以用bind、call或apply來綁定特定的物件作為this的值。關於bind、call或apply的範例可參考之前提過的[明確綁定](# 明確綁定(Explicit Binding))的部份。

修改上例如下,一秒後的確是印出「Hi,I am Jack」。

1
2
3
4
5
6
7
8
9
10
var name = 'Apple';
var obj = {
name: 'Jack',
sayHi: function () {
setTimeout(function () {
console.log(`Hi, I am ${this.name}`);
}.bind(this), 1000);
}
}
obj.sayHi();// Hi, I am Jack

即然箭頭函數會自動綁定所在範疇,那也就可以這麼修改了…經由箭頭函數就會把this綁在obj上。

1
2
3
4
5
6
7
8
9
10
var name = 'Apple';
var obj = {
name: 'Jack',
sayHi: function () {
setTimeout(() => {
console.log(`Hi, I am ${this.name}`);
}, 1000);
}
}
obj.sayHi();// Hi, I am Jack

但箭頭並不是一個所有狀況都適用的超級為敵通用解…

箭頭函數不適用於…

  • 䈟頭函數會將this強制綁定為執行環境。因此,若不希望this被綁定為執行環境,基本上都是不適用的,不需要為了少寫幾個字而硬要使用箭頭函數。
  • 箭頭函數內的callback是匿名的,匿名函式的缺點請看[這裡](# 匿名 vs 具名(Anonymous vs Named))。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

  • this是function執行所屬的物件,this是在執行時期做綁定其值和函式在哪裡被呼叫有關。
  • 判斷this的值的四個規則,並以匹配的優先順序由高至低排列
    • new 綁定:this會指向new出來的物件。
    • 明確綁定:使用call、apply、bind,明確指出要綁定給this的物件。
    • 隱含綁定:當函式為物件的方法時,在執行階段this就會被綁定至該物件。
    • 預設綁定: 當以上規則都不適用時,就套用預設綁定,在非嚴格模式下,瀏覽器環境this的值是預設全域物件window,而在嚴格模式下,this的值是undefined
  • 箭頭函數(arrow function)的this的值並不適用上面提到的四種規則,this強制綁定為執行環境,例如在瀏覽器中就是windwo。
Reference

推薦閱讀

推薦閱讀 Kuro 大大關於 this 的好文…

  • [What’s THIS in JavaScript ?
  • [What’s THIS in JavaScript ?
  • [What’s THIS in JavaScript ?

你懂JavaScript嗎?物件(Object)

關於物件,本文會提到

  • 語法:宣告式與建構形式。
  • 型別:再次複習typeof、使用instanceof判定物件子型別。
  • 內容:屬性值的存取、物件的複製(淺拷貝與深拷貝)、屬性描述器、不可變的物件、取值器與設值器、檢視屬性是否存在、屬性列舉。
  • 迭代:一些迭代出陣列的值的方法。

語法(Syntax)

建立物件有兩種方式

  • 宣告式(declarative)或稱字面值(literal),例如:const obj = { name: 'Jack'};
  • 建構形(constructed form),例如:const obj= new Object(); obj.name = 'Jack';,也就是原生功能,但由於一些些雷區,這種方式其實很少用。

簡單來說,兩者主要的差別是新增屬性時,字面值可在物件建立時一次全部加入,但建構形式必須在物件建立後一筆一筆新增。

型別(Type)

JavaScript的資料型態有七種-字串(string)、數字(number)、布林值(boolean)、null、undefined 、物件(object)與symbol。其中函式(function)和陣列(array)、日期(date)皆為物件的一種,function是可呼叫的物件,而array是結構較嚴謹的物件。

typeof

關於型別就會想到型別檢測,想到型檢測就不得不提一下typeof的議題…

1
2
3
4
5
6
7
8
9
10
console.log(typeof 'Hello World');// string
console.log(typeof true);// boolean
console.log(typeof 1234567);// number
console.log(typeof null);// object
console.log(typeof undefined);// undefined
console.log(typeof { name: 'Jack' });// object
console.log(typeof Symbol());// symbol
console.log(typeof function () { });// function
console.log(typeof [1, 2, 3]);// object
console.log(typeof NaN);// number

這裡看到幾個有趣的(奇怪的)地方

  • null是基本型別之一,但typeof null卻得到object,而非null!這可說是一個bug,可是若因為修正了這個bug則可能會導致很多網站壞掉,因此就不修了!
  • 雖然說function是物件的子型別,但typeof function() {}是得到function而非object,和陣𦕁依舊得到object是不一樣的。
  • NaN表示是無效的數字,但依舊還是數字,因此在資料型別的檢測typeof NaN結果就是number,不要被字面上的意思「不是數字」(not a number)給弄糊塗了。另外,NaN與任何數字運算都會得到NaN,並且NaN不大於、不小於也不等於任何數字,包含NaN它自已。

另外,如果想知道這個物件到底是屬於哪個子型別,則可使用Object.prototype.toString來檢視[[Class]]這個內部屬性。

1
2
3
4
5
console.log(Object.prototype.toString.call([1, 2, 3]));//[object Array]
console.log(Object.prototype.toString.call({ name: 'Jack' }));//[object Object]
console.log(Object.prototype.toString.call(function sayHi() { }));//[object Function]
console.log(Object.prototype.toString.call(/hellowrold/i));//[object Regexp]
console.log(Object.prototype.toString.call(new Date()));//[object Date]

內建物件(Built-in Objects)

內建物件指的是使用內建函式所建立的物件,這些物件都屬於物件子型別的一種,除了上面提到的陣列、函式與日期外,這裡列出物件所有的子型別:String、Number、Boolean、Object、Function、Array、Date、RegExp、Error,它們的用途是給予開發者取得屬性或方法的使用,也就是我們常聽到的原生的功能。因此,當使用建構式建立字串、布林或數字等值時,建立的其實不是基本型別值而是物件,因此可用instanceof來檢查是由哪個建構式建立,也就是來判斷是否為指定的物件型別。

如下,使用字串字面值宣告了一個字串str,當我們使用str進行.length的操作以取得其長度時,JavaScript就會將這個字串基本型別的值強制轉成對應的物件子型別,也就是上面提到的String。

1
2
3
4
5
6
7
const str = 'Hello World!';
console.log(str instanceof String)// false
console.log(str.length);// 12

const strObj = new String('Hello World!');
console.log(strObj instanceof String);// true
console.log(strObj.length);// 12

注意

  • null和undefined只有基本資料型別,沒有物件包裹形式,意即沒有對應的物件子型別,所以無法使用new來產生。
  • Date、RegExp、Error沒有基本資料型別的形式,所以只能使用物件子型別new來產生。

內容(Contents)

物件的內容是由屬性組成的,而屬性是由key-valur pair構成,value 可為任意資料型別的值,並且值是以參考(reference)的方式(存位置)儲存。

如何存取物件的屬性?有兩種方式

  1. 特性存取(property access),意即.
  2. 鍵值存取(key access),意即[]

其中,特性存取.必須符合識別字的規範,簡單的說就是只能是字母、數字、$(錢字號)或_(底線),並且不能以數字開頭,之後可加上a-z、A-Z、$_和數字0-9,可為關鍵字或保留字。

讓我們來看一些疑難雜症吧!

Q1:如果屬性名稱是特殊字符或動態產生的,該怎麼存取它的值呢?

若要使用一些包含特殊或動態產生的字串作為屬性名稱,就必須使用鍵值存取[]的方式。

包含特殊字符的屬性名稱

1
2
3
4
5
const obj = {
'!!12345!!': 'Hello Wrold',
};
obj.!!12345!!; //Uncaught SyntaxError: Unexpected token !
obj["!!12345!!"]// "Hello World"

ES6新增動態產生的字串作為屬性名稱功能,讓key的值可經由運算得出。

1
2
3
4
5
6
7
8
const prefix = 'fresh-';
const fruits = {
[prefix + 'apple']: 100,
[prefix + 'orange']: 60,
};

console.log(fruits['fresh-apple']);//100
console.log(fruits['fresh-orange']);//60

Q2:屬性真的只能是字串嗎?可以是數字、物件等其他型別的值嗎?

屬性名稱只能是字串,若不是𡪤串則會被強制轉為字串。

如下,obj[obj]的key值被強制轉為字串'[object Object]',同理,obj[999]的key值999也被轉為字串'999'了。

1
2
3
4
5
const obj = { Qoo: '有種果汁真好喝' };
obj[obj]='喝的時候酷兒';
obj[999]='喝完臉紅紅!';
console.log(obj['[object Object]']);//喝的時候酷兒
console.log(obj['999']);//喝完臉紅紅!

函式(Function)vs方法(Method)

闢謠…澄清…!!??

在其它語言中,屬於某個物件的函式稱為方法,但在JavaScript中,函式並不會特別屬於某個物件,物件充其量也只是儲存對某個函式的參考而已,並非真的「屬於」這個物件,因此,在JavaScript中,函式與方法是同義的,並沒有區別。除了在ES6新增的super參考,super與class一起使用時super會靜態綁定函式,經由這樣所綁定的函式就比較接近一般在其他語言所看到的方法了。

陣列(Array)

陣列使用非負整數作為索引,注意…

  • 若使用具名特性(named property)作為索引,則不會增加陣列的長度。

    1
    2
    3
    4
    5
    6
    const array = [1, 2, 3];
    console.log(array.length); // 3
    array[3] = 4;
    console.log(array.length); // 4
    array['foo'] = 'bar';
    console.log(array.length); // 4, 陣列的長度不變!
  • 具名特性(named property)若以字串格式存在,就會轉為數字。

    1
    2
    3
    const array = [1, 2, 3];
    array['3'] = 'foo';
    console.log(array);//[1, 2, 3, "foo"]

複製物件

複製物件的方式分為淺拷貝與深拷貝兩種。

為什麼要探討淺拷貝與深拷貝呢?這是根本於基本型別值是傳值而物件是傳參考的緣故,既然物件是傳參考,就要考慮是把整份資料都複製一份,還是複製參考就好?淺拷貝是複製參考,深拷貝是把整份資料都複制一份,常用於考慮物件資料是否要其用的狀況。

淺拷貝(Shallow Copy)

除了基本的資料型中純值(非物件)的資料會真的複製另外一份值之外,其他的都只是複製一份參考而已,例如:Object.assign在處理超過一層的物件時就只能做偮淺拷貝,只有一層的話是可以做偌深拷貝的。

深拷貝(Deep Copy)

複製整個物件,通常會使用JSON-safe的物件,先經由序列化為JSON字串後再剖析回物件。

1
const newObj = JSON.parse(JSON.stringify(oldobj));

範例如下。

單層物件時,Object.assign與「先序列化再剖析」的方法都可以做完全的拷貝。也就是深拷貝,由於物件的比對的是比較儲存位置,因此當比較拷貝結果特,兩者是不相等的。

1
2
3
4
5
6
7
8
9
const simpleObj = {
a: 1,
b: 2,
};
const newSimpleObj = Object.assign({}, simpleObj);
console.log(newSimpleObj === simpleObj);//false

const newSimpleObj2 = JSON.parse(JSON.stringify(newSimpleObj));
console.log(newSimpleObj2 === simpleObj);//false

那麼,物件再多層的狀況下,又是怎樣的狀況呢?如下,由於Object.assign只能做單層的拷貝,因此第二層開始就只是複製參考而已,儲存位置不變,故為true;而「先序列化再剖析」的方法則是完整地把整個資料複製起來,存到另一個地方,因此儲存位置不同,得到false。

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
a: 1,
b: {
c: 2,
d: 3,
}
};
const newObj = Object.assign({}, obj);
console.log(newObj.b === obj.b);// true

const newObj2 = JSON.parse(JSON.stringify(newObj));
console.log(newObj2.b === obj.b);// false

屬性描述器(Property Descriptor)

屬性描述器可用來檢視屬性的特徵,例如:可入寫入(writable)、可否配置(configurable)與可否列舉(enumerable)。

例如,檢視object.a這個屬性的特徵。

1
2
3
4
const obj = {
name: 'Apple',
};
console.log(Object.getOwnPropertyDescriptor(obj, 'name'));

得到結果。

1
2
3
4
5
6
{ 
value: 'Apple', 
writable: true, 
enumerable: true, 
configurable: true
} 

使用defineProperty定義物件的屬性與特性。通常使用這種方法的目的是…

  • 新增屬性,通常是為了修改預設特徵的值。
  • 若特性是configurable的話。則可用來修改屬性的特徵值。

範例如下,為物件obj定義一個新的屬性name,並設定其特徵值。

1
2
3
4
5
6
7
8
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,
configurable: true,
enumerable: true,
});
console.log(obj.name);// Apple
Writable

屬性的值是否「可被寫入」。

例如,設定name這個屬性是不可寫入的,因此當嘗試更新這個值的時候,發現無法更新,並且在strict mode之下會丟出TypeError的錯誤訊息。

1
2
3
4
5
6
7
8
9
10
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: false,//不可寫入!
configurable: true,
enumerable: true,
});
console.log(obj.name);// Apple
obj.name='Grape';
console.log(obj.name);// Apple,屬性name的值無法被變更
Configurable

屬性是否是「可配置的」,意即當configurable為false的時候,為法再使用defineProperty更新特徵的值,否則會丟出TypeError,但有一個例外,當configurable為false的時候,writable仍可由true改為false,但不能從false改為true。

當configurable為false的時候,無法再使用defineProperty更新特徵的值,否則會丟出TypeError。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,
configurable: false,
enumerable: true,
});

Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,
configurable: true,// false -> true
enumerable: true,
});
// Uncaught TypeError: Cannot redefine property: name

當configurable無false的時候,writable仍可由true改為false,但不能從false改為true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,//
configurable: false,
enumerable: true,
});

Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: false,//
configurable: false,
enumerable: true,
});
// 這是可行的

當configurable為false的時候,writable仍可由true改為false,但不能從false改為true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: false,//
configurable: false,
enumerable: true,
});

Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,//
configurable: false,
enumerable: true,
});
// Uncaught TypeError: Cannot redefine property: name

除了是否可更新特徵的設定外,configurable另一個作用就是是否可被delete刪除該屬性。

configurable設為false,屬性不可刪除。

1
2
3
4
5
6
7
8
9
10
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,//
configurable: false,
enumerable: true,
});

delete obj.name;
console.log(obj.name);// Apple,name屬性未被刪除

configurable設為true,屬性可被刪除。

1
2
3
4
5
6
7
8
9
10
11
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,
configurable: true,
enumerable: true,
});

delete obj.name;
console.log(obj.name);//undefined
console.log(obj);//{}
Enumerable

特徵是否為「可列舉的」,例如:此物件的特性是否可在for…in中被列舉,若設定enumerable為false表示不會被列舉出來。

如下(1)印出hello和name,(2)由於name被設定為不可列舉的,因此只會印出hello。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const obj = {};
obj.hello = 'world';
Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,
configurable: true,
enumerable: true,
});
for (let prop in obj) {
console.log(prop);//(1)
}
//hello
//name

Object.defineProperty(obj, 'name', {
value: 'Apple',
writable: true,
configurable: true,
enumerable: false,
});
for (let prop in obj) {
console.log(prop);//(2)
}
//hello

題外話,會用到 defineProperty 這個東西是為了寫雙向綁定的小工具而追 Vue.js 的原始碼的時候玩到的,推薦閱讀這篇文章。

不可變性(Immutability)

如何實作無法被變更的特性或物件?以下會介紹幾種作法,但都只能做到淺層的不可變性(shallow immutablility),意即若此物件有指向其他物件的參考,這個被指到的物件的內容就仍是可變的。

如下,假設foo是不可變的,但foo.list指向一個陣列,這個陣列的內容是可變的。

1
2
3
4
const foo = {};
foo.list = [1, 2, 3];
foo.list.push(4);
console.log(foo.list);// [ 1, 2, 3, 4 ]

物件常數(Object Constant)

使用defineProperty設定writable為false且configurable為false,即可建立一個特性等同於常數的物件屬性,無法被更新、重新定義和刪除。

1
2
3
4
5
6
7
8
const obj = {};
Object.defineProperty(obj, 'CONST_PI', {
value: 3.14,
writable: false,
configurable: false,
enumerable: true,
});
console.log(obj.CONST_PI);// 3.14

避免擴充(Prevent Extensions)

使用Object.preventExtensions防止物件被加入新屬性。

1
2
3
4
5
6
const obj = {
name: 'Jack',
};
Object.preventExtensions(obj);
obj.hello = 'world';
console.log(obj.hello);// undefined

備註,在嚴格模式下,加入新屬性會丟出TypeError。

密封(Seal)

使用Objet.seal來達到密封的作用,意即物件不可再新增屬性、重新配置特徵或刪除屬性,但可能可以修改屬性值。

Object.seal會做兩件事

  • 將物件設定為Object.preventExtensions防止物件被加入新屬性。

  • 將物件的屬性特性configurable設定為fals,物件的屬性不能被刪除,其特徵的值不能被更新。屬性值是否可被更新要看writable的值而定,若writable為true則可更新,若為false則不可更新。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    const obj = {
    name: 'Jack',
    };
    Object.seal(obj);
    //嘗試加入新的屬性 hello
    obj.hello = 'world';
    console.log(obj.hello);//undefined

    //嘗試刪除屬性name
    console.log(delete obj.name); //false
    console.log(obj.name);//Jack

    //嘗試重新設定特徵值
    Object.defineProperty(obj, 'name', {
    value: 3.14,
    writable: false,
    configurable: true,
    enumerable: true,
    });
    // TypeError

凍結(Freeze)

使用Object.freeze建立一個已凍結的物件,意即這個物件不能新增屬性、更新屬性的值,刪除屬性和重新配置特徵值。

Object.freeze會做以下的事情

  • 對物件呼叫Object.seal,讓物件不可再新增屬性、重新配置特徵或刪除屬性。
  • 將物件的屬性時性writable設定為false,物件屬性的值不可被更改。

回顧前面提到的,以上四種解法都只能做到淺層的不可變性(shallow immutability),因此若希望能將整個物件(包含屬性參考的物件)都凍結,可遞迴呼叫Object.freeze,但可能有副作用,像是凍結了共用的物件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const list = ['apple', 'grape'];
const obj = {
name: 'Jack',
favFruits: list,
}
const anotherObj = {
name: 'Apple',
favFruits: list,
}
Object.freeze(obj);
Object.freeze(obj.favFruits);

//共用的物件被凍結!
list.push('orange');//Uncaught TypeError: Cannot add property 2, object is not extensible

[[Get]]

[[Get]]的功用是取得屬性值,例如:obj.a時會呼叫[[Get]]()這個函式呼叫,它會先在此物件內尋找是否有符合名稱的屬性,若無就順著原型串鏈繼續尋找,如果都沒有找偌,[[Get]]就會回傳undefined。注意,這和之前提到的在語彙範疇中查找變數(的名稱是否被定義)是不同的,若在語彙範疇中找到該變數,會丟出ReferrenceError。

[[Put]]

[[Put]]的功用是…

若此屬性不存在,則新增此屬性並設定其值。但若此屬性存在,則做以下的事情…

  • 若此屬性是存取器描述器的取值器與設值器嗎?若是,則呼叫其設值器(setter)。

  • 若此屬性是不可寫入的,在非嚴格模式下會無聲的失敗,但在嚴格模式下會丟出TypeError。

  • 若屬性是可寫入的,就將值設定給這個屬性。

    備註:上面提到的兩個名詞,這裡來做解釋…

  • 資料描述器(data descriptor):定義一個屬性時,此屬性不含取值器或設值器。

  • 存取描述器(access descriptor):存取器描述器是指當定義一個屬性時,若此屬性擁有取值器或設值器,這個定義就會成為存取器描述器。注意,此時屬性的value和特徵writable都會被忽略,而只需考慮set、get、configurable和enumerable。

取值器(Getter)與設值器(Setter)

物件預設的[[Get]][[Put]]掌控了屬性的建立、設定和更新、取得值的方式。若要複寫這兩種預設[[Get]][[Put]]行為,可透過取值器與設值器來達成。

方法一,使用物件字面值的方式定義屬性。

1
2
3
4
5
6
7
8
9
10
const obj = {
get name() {
return this._name_;
},
set name(val) {
this._name_ = `Hi, I am ${val}`;
}
};
obj.name = 'Jack';
console.log(obj.name);//Hi, I am Jack

方法二,使用defineProperty的方式定義屬性。

1
2
3
4
5
6
7
8
9
10
11
12
13
const obj = {};
Object.defineProperty(obj, 'name', {
configurable: true,
enumerable: true,
get: function name() {
return this._name_;
},
set: function name(val) {
this._name_ = `Hi, I am ${val}`;
}
});
obj.name = 'Jack';
console.log(obj.name);//Hi, I am Jack

存在(Existence)

既然屬性不存在的時候,會回傳undefined,但若屬性值原本就設定為undefined是不是就為法判定這個屬性到底存不存在了?

解法是使用hasOwnProperty,若想進一進確認該屬性是否可在其他物件中找偌,可搭配prop in obj檢查這個屬性是否存在於原型串鏈中。兩者差異是prop in obj會檢查原型串鏈,而hasOwnProperty只會檢查該物件。

範例如下。

1
2
3
4
5
var obj1 = {
job: undefined,
};
var obj = Object.create(obj1);// 邁立 obj 與 obj1的連結
obj.name = undefined;

屬性name其值雖然為undefined,但它直的存在於obj。

1
2
console.log(obj.name);// undefined
console.log(obj.hasOwnProperty('name'));// true

屬性job真的存在於obj嗎?

1
2
3
4
5
console.log(obj.job);
console.log(obj.hasOwnProperty('job'));//false

console.log('job' in obj);// true,但在原型串鏈中可找到
console.log(obj1.hasOwnProperty('job'));//true

屬性job其值雖然為undefined且不存在於obj中,但可在原型串鏈中可找偌,因此進一𦂇檢視obj1,確定為obj1的屬性。

1
2
3
console.log(obj.hello);//undefined
console.log(obj.hasOwnProperty('hello'));//false
console.log('hello' in obj);//false

雖然hello的值是undefined,似乎與前面的例子無異,但使用hadOwnProperty檢視,發現在在obj物件中,且經由prop in obj確認後發現也無法在原型串鏈中找到,因此屬性不存在。

總結…

  • hasOwnProperty可檢測這個屬性是否存在於某個物件。
  • prop in obj可檢查這個屬性是否可在原型串鏈中找到,讓我們能確認是否需要再往其他物件查找。

列舉(Enumeration)

檢視屬性是否可被列舉的方法。

in

in運算子只會帶出可列舉的屬性。

例如,obj有兩個屬性name和hello,其中name為可列舉的,hello為不可列舉的。

1
2
3
4
5
6
7
8
9
10
11
12
13
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'Jack',
enumerable: true,
});
Object.defineProperty(obj, 'hello', {
value: 'world',
enumerable: false,
});
for (let k in obj) {
console.log(k, obj[k]);
}
//name Jack
propertyIsEnumerable

propertyIsEnumerable檢視屬性是否可被列舉。

例如,obj有兩個屬性name和hello,其中name為可列舉的,hello為不可列舉的。檢視name是否為可列舉的,會回傳true。

1
console.log(obj.propertyIsEnumerable('name'));//true
Object.keysvsObject.getOwnPropertyNames

Object.keysObject.getOwnPropertyNames都只回傳此物件的屬性,且皆不檢視原型串鏈,兩者差異在於

  • Object.keys:回傳所有可列舉的屬性。
  • Object.getOwnPropertyNames:回傳所有屬性,不管是否可被列舉。

例如,obj有兩個屬性name和hello,其中name為可列舉的,hello為不可列舉的。

1
console.log(Object.keys(obj));//[ 'name' ]
1
console.log(Object.getOwnPropertyNames(obj));//[ 'name', 'hello' ]

迭代(Iteration)

迭代出陣列的值的方法。

forEach

迭代陣列中所有的值。

1
2
3
4
5
6
7
8
const list = ['Apple', 'Bob', 'Cathy', 'Doll'];
list.forEach((item, index, array) => {
console.log(item, index, array);
});
//Apple 0 [ 'Apple', 'Bob', 'Cathy', 'Doll' ]
//Bob 1 [ 'Apple', 'Bob', 'Cathy', 'Doll' ] 
//Cathy 2 [ 'Apple', 'Bob', 'Cathy', 'Doll' ] 
//Doll 3 [ 'Apple', 'Bob', 'Cathy', 'Doll' ]
every

檢查陣列中的每個值是否符條件,若是則回傳true。持續進行直到結束,或callback中回傳false就停止迭代。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const list = [
{
name: 'apple',
count: 20,
}, {
name: 'corn',
count: 100,
}, {
name: 'grape',
count: 50,
}, {
name: 'pieapple',
count: 80,
}
];
const result = list.every((item, index, array) => {
console.log(item, index, array);
return item.cout > 50;
});
console.log(`result: ${result}`);
//{ name: 'apple', count: 20 } 0 [ { name: 'apple', count: 20 },  { name: 'corn', count: 100 },  { name: 'grape', count: 50 },  { name: 'pieapple', count: 80 } ]
//result: false
some

檢查陣列中的是否有值符合條件,若是則回傳true。持續進行直到結束,或callback中回傳true就停止迭代。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const list = [
{
name: 'apple',
count: 20,
}, {
name: 'corn',
count: 100,
}, {
name: 'grape',
count: 50,
}, {
name: 'pieapple',
count: 80,
}
];
const result = list.some((item, index, array) => {
console.log(item, index, array);
return item.count > 50;
});
console.log(`result: ${result}`);
//{ name: 'apple', count: 20 } 0
//{ name: 'corn', count: 100 } 1
//true

若想看更多陣列處理方法,可參考這裡

for...of

使用ES6的for...of迭代陣列。

1
2
3
4
5
6
7
const array = [1, 2, 3];
for (let v of array) {
console.log(v);
}
// 1
// 2
// 3

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

  • 建立物件有兩種方式-宣告式與建構形式,前都較常用,而後者有一些雷區,非必要不建議使用。
  • typeof可檢查資料型別; 當使用建構式建立字串、布林或數字等值時,建立的其實不是基本型別而是物件,因此可用instanceof來檢查是由哪個建構式建立的,也可檢視該物件是屬於哪個子型別。
  • 物件的內容是由屬性組成的,而屬性是由key-value pari構成,value可為任意資料型別的值,並且值是以參考的方式儲存。
  • 物件的屬性若包含特殊字符為動態產生的字串,則必須使用鍵值[]的方法存取。
  • 複製物件的方法分為淺拷貝與深拷貝兩種,Object.assign只能處理一層內的深拷貝,而直正的深貝通常是由JSON-safe的物件先經由序列化為JSON字串後再剖析回物件來達成。
  • 屬性描述器可用來檢視屬性的特徵,例如:是否可寫入、是否可配置、是否可列舉。
  • 實作不可變的物件的方法,例如:設定屬性描述器、避免擴充、密封、凍結。
  • 物件預設的[[Get]][[Put]]掌控了屬性的建立、設定和更新、取得值的方式,若要複寫這兩種預設[[Get]][[Put]]行為,可透過取值器與設值器來達成。
  • hasOwnProperty與prop in obj可判斷屬性是否存在。
  • 判斷屬性是否可列舉的方法-in運算子帶出此物件可列舉的屬性、propertyIsEnumerable檢視屬性是否可被列舉、Object.keysObject.getOwnPropertyNames都只回傳此物件的屬性,差異在於前都只列出可列舉的屬性,而後者會列出所有此物件的屬性。
  • 迭代出陣列中的值的方法,例如:forEach、every、some、for...of
References

You Don’t Know JS: this & Object Prototypes, Chapter 3: Objects

你懂JavaScript嗎?(簡易版)物件導向概念

本文主要會談到簡單的物件導向概念,作為後續「原型」(Prototypes)的暖身。

類別(Class)、邁構子(Constructor)、實體(Instance)

「類別」可想像成是建構某特定物體的藍圖或模具,而「實體」就是按照這藍圖或模具製造出來的成品。這當中需要使用類別的一個特殊方法「建構子」來做初始化的動作。

虛擬碼如下,Person是一個類別,利用與類別同名的建構子Person做初始化,進而建立出實體Jack。

1
2
3
4
5
6
7
8
9
10
11
class Persion {
career = null;
Persion(job) {
career = job;
}
sayHi() {
print('Hello, I am a/n ', this.career);
}
}
Jack = new Persion('engineer');
Jack.sayHi();

在真實的世界裡,JavaScript用什麼方式呈現呢?舉個最簡單的例子。

1
2
const str = new String('Hello World');
console.log(str.length);//11

String是個類別方法,在這裡當建構子用,任何給定的字串都是這個類別的實體,它幫我們包裝了可在這個字串上執行的各種功能。因此,str是String的實體,可執行str.length取得字串長度。

不過,在JavaScript的世界中,並沒有真正的類別的概念,只能使用設計模式(design pattern)來模擬,我們在稍後的原型中會看到各種解法與優劣討論。

繼承(Inheritance)

定義一個類別,其特性繼承(也可說是擴充extend)自另外一個類別,稱它們為「父類別」與「子類別」,其中,子類別繼承父類別的特性。

如下,CoolPersion繼承自Person,其中sayHi繼承了來自Person的方法並做覆寫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Persion {
career = null;

Persion(job) {
career = job;
}

sayHi() {
pring('Hello, I am a/n', career);
}
}

class CoolPerson inherits Person {
sayHi() {
inherited: sayHi();
pring('I love my job!');
}

eat(food) {
pring('I am eating...', food);
}
}

「多重繼承」(multiple inheritance)是指子類別可繼承一個以上的父類別,但由於JavaScript並沒有提供原生機制來處理這重情況,因此不做討論。

多型(Polymorphism)

「多型」是指子類別除了擁有自己的方法外,這個方法還能覆寫來特化父類別的同名方法,以賦予其更特殊的行為,

如上,CoolPersion的sayHi與其父類別Persion的sayHi同名,它使用inherited:sayHi()參考它所繼承且未被覆寫的父類別同名方法並作呼叫,接著加上pring('I love my job!')這個屬於它自已的功能。其中,不指定參考哪一層父類別(可能是袓父類別XD)的方式稱為「相對多型」,而若有指名是哪層父類別的方法,那就是「絕對多型」了。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

  • 「類別」可想像成是建構某特定物體的藍圖或模具,而「實體」就是按照這監圖或模具製造出來的成品,並使用「建構子」來建立和初始化實體。
  • 定義一個類別,其特性繼承於另外一個類別,稱它們為「父類別」與「子類別」,而子類別「繼承」了父類別的特性。
  • 「多型」是指子類別除了擁有自已的方法外,這個方法還能覆寫來特化父類別的同名方法,以賦予其更特殊的行為。
References

[You Don’t Know JS: this & Object Prototypes, Chapter 4: Mixing (Up) “Class” Objects](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/this %26 object prototypes/ch4.md)

你懂JavaScript?原型(Prototype)

本文主要會談到

  • 類別、建構子與實體
  • 什麼是原型串鏈?原型串鏈的功用是?
  • 什麼是原型式繼承?
  • 疑難雜症大解惑-如何分辨屬性是位於該物件或原型串鏈上的?如何分辨誰是誰的實體?誰是誰的建搆子?原型串鏈有終點嗎?如何建立兩物件的連結?物件屬性的設定與遮蔽規則有哪些?

前言

JavaScript並不像Java、C++這些知名的物件導向語言具有「類別」(class)來區分概念與實體(instance)或天生具有繼承的能力,而只有「物件」,因此只能利用設計模式來模擬這些功能。本文就來探討在JavaScript世界中,到底是㤰麼實現物件導向的概念的?

首先要有個模子,我們稱它為類別,而當前面有new的時候,可看成是建構子(constructor),接著用這個建構子做初始化,進而建立(new)實體。

如下,建構子Book產出實體ydkjs_1和ydkjs_2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Book(name, pNum) {
this.name = name; //書名
this.pNum = pNum; //頁數
this.setComments = function (comment) {
this.comment = comment;
}
}

var ydkhs_1 = new Book('導讀,型別與文法', 257);
var ydkhs_2 = new Book('範疇與閉包 / this 與物件原型', 251);

ydkhs_1.setComments('好書!');
console.log(ydkhs_1.comment);//好書!

console.log(ydkhs_1.setComments === ydkhs_2.setComments);// false

共用的屬性或方法,不用每次都幫實體建立一份,提出來放到prototype即可。承上,將setComments這個共用的方法放到Book.prototype,暫且稱它為Book原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Book(name, pNum) {
this.name = name; //書名
this.pNum = pNum; //頁數
this.comment = null;//評等
}
Book.prototype.setComments = function (comment) {
this.comment = comment;
}
var ydkhs_1 = new Book('導讀,型別與文法', 257);
var ydkhs_2 = new Book('範疇與閉包 / this 與物件原型', 251);

ydkhs_1.setComments('好書!');
console.log(ydkhs_1.comment);//好書!

ydkhs_2.setComments('超好書!');
console.log(ydkhs_2.comment);//超好書!

console.log(ydkhs_1.setComments === ydkhs_2.setComments);// true,確認是同一個函式

注意

請勿修改原生原型

在這裡都是在設定自已建立的物件的原型!不要嘗試修改預設的原生原型(例如:String.prototype),也不要無條件地擴充原生原型,若要擴充也應該撰寫符合規格的測試程式,另外不要使用原生原型當成變數的初始值,以避免無意間的修改。

關於建構子…
  • 在JavaScript中,除了沒有類別外,其實也沒有建構子,因此

    • 只要函式前有new,這個函式就是建構子。
    • 只要函式前有new來個呼叫,就叫做建構子呼叫。
  • new關鍵字要做哪些事情呢?它的工作就是

    1. 建立一個新的物件。
    2. 將物件的.__proto__指向建構子的prototype,形成原型串鏈。
    3. 將建構子的this指向new出來的新物件。
    4. 回傳這個物件。

原型串鏈(Prototype Chain)

在前面[巢狀範疇](# 巢狀範疇(Nested Scope))的部份提到「若在目前執行的範疇找不到這個變數的時候,就往外層的範疇搜尋,持續搜尋直到找到為止,或直到最外層的全域範疇」,同理, 當查找物件的屬性或方法時,若在本身這個物件找不到的時候,就會往更上一層物件尋找,直到串鏈尾端Object.prototype,若無法找到就回傳undefined,而這個尋找的脈絡就是依循著.__proto__這個原型串鏈(prototype chain)來找–每個物件在建立之初都會個.__proto__(dunder proto)內部屬性,它可用存取另一個相連物件內部屬性[[prototype]]的值,而[[prototype]]存放其建構子原型的位置。

如下範例,ydkjs_1.__proto__所存的參考即指向Book.prototype的位置。

1
2
3
4
5
6
7
8
9
10
11

function Book(name, pNum) {
this.name = name; //書名
this.pNum = pNum; //頁數
this.comment = null;//評等
}
Book.prototype.setComments = function (comment) {
this.comment = comment;
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
console.log(ydkjs_1.__proto__ === Book.prototype);//true

模型圖。

由於在ydkjs_1是找不到方法setComments的,因此就會循著.__proto__找到Book.prototype而找到方法setComments,也因為原型串鏈,讓JavaScript可達到類似其他物件導向語言般的使用類別、繼承的功能。

備註,使用.__proto__來取得[[Prototype]]似乎太暴力了(畢竟人家是內部屬性嘛),還是改用Object.getPrototypeOf(..)來得優雅,其中Object.getPrototypeOf(..)會回傳.__proto__的值。

1
2
console.log(ydkjs_1.__proto__ === Book.prototype);//true
console.log(Object.getPrototypeOf(ydkjs_1) === Book.prototype);//true

接著來看幾個疑難雜症。

Q1:到底是誰的屬性?

可用hasOwnProperty檢查屬性是屬於當前物件,還是位於原型串錄中。

1
2
console.log(ydkjs_1.hasOwnProperty('name'));//true
console.log(ydkjs_1.hasOwnProperty('setComments'));//false

name的確是存在於物件ydkjs_1中的,而setComments並不在物件ydkjs_1中,是在原型串鏈中。

注意

  • hasOwnProperty只會檢查該物件,而不會檢查整條原型串鏈。
  • for loop prop in obj會檢查整個原型串鏈且為可列舉的屬性。
  • prop in obj會檢查整個原型串鏈,不管屬性是否可列舉。

範例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Book(name, pNum) {
this.name = name; //書名
this.pNum = pNum; //頁數
this.comment = null;//評等
}
Book.prototype.setComments = function (comment) {
this.comment = comment;
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);

Object.defineProperty(ydkjs_1, 'hello', {
value: 'world',
writable: true,
configurable: true,
enumerable: false,//設定 hello 為不可列舉的屬性
});

由於for loop prop in obj會檢查整個原型串鏈且為可列舉的屬性,因此除了hello之外,其它的屬性都會被列出來。

1
2
3
for (let prop in ydkjs_1) {
console.log(prop);
}

結果得到

1
2
3
4
//name
//pNum
//comment
//setComments

承上,prop in obj會檢查整個原型串鏈,不管屬性是否可列舉。

1
2
console.log('hello' in ydkjs_1);// true
console.log('name' in ydkjs_1);// true
Q2:到底是誰的實體?

instanceof檢查物件是否為指定的建構子所建立的實體,位於instanceof左邊的運算元是物件,右邊的是函式,若左邊的物件是由右邊函式所產生的,則會回傳true,否則為false。instanceof可檢查整修原型串鏈的繼承世系,這在傳統的物件導向環境中稱為「內省」(introspection)或「反思」(reflection)。

1
2
3
4
5
6
7
8
9
10
function Book(name, pNum) {
this.name = name; //書名
this.pNum = pNum; //頁數
this.comment = null;//評等
}
Book.prototype.setComments = function (comment) {
this.comment = comment;
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);

ydkjs_1與ydkjs_2都是由Book建立出來的實體,而Book也是由Object與Function建立出來的。因此都會得到true,最後舉個反例,window不是由Book建立出來的,因此得到false。

1
2
3
4
5
6
7
8
9
10
console.log(ydkjs_1 instanceof Book);//true
console.log(ydkjs_2 instanceof Book);//true

console.log(ydkjs_1 instanceof Object);//true
console.log(ydkjs_1 instanceof Function);//true
console.log(ydkjs_2 instanceof Object);//true
console.log(ydkjs_2 instanceof Function);//true

console.log(window instanceof Book);//false
console.log(window instanceof window);//true

另外一個方法是使用isPrototypeOf,它可檢視運算子左邊的物件是否出現於右邊物件的原型串鏈中。與instanceof不同之處只在於運算元的資料型別不同而已,但功能是相同的。

再看一次這個相似的範例,Novel繼承了Book,並建立實體novel。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Book(name, pNum) {
this.name = name; //書名
this.pNum = pNum; //頁數
this.comment = null;//評等
}
Book.prototype.setComments = function (comment) {
this.comment = comment;
}

function Novel(name, pNum, price) {
Book.apply(this, [name, pNum]);//Novel 繼承 Book
this.price = price;
}

Novel.prototype = Object.create(Book.prototype);

Novel.prototype.printPrice = function () {
console.log(`${this.name} is ${this.price}`);
}

var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
var novel = new Novel('最近沒在看小說 ><', 500, 600);

我們來檢視幾個問題…

  • 在ydkjs_1的整個原型串鏈中,是否出現過Book.prototype?
1
console.log(Book.prototype.isPrototypeOf(ydkjs_1));//true
  • 在novel的整個原型串鏈中,是否出現過Book.prototype?
1
console.log(Book.prototype.isPrototypeOf(novel));//true
  • 在ydkjs_1的整個原型串鏈中,是否出現過Novel.prototype?
1
console.log(Novel.prototype.isPrototypeOf(ydkjs_1));//false
  • 在novel的整個原型串鏈中,是否出現過Novel.prototype?
1
console.log(Novel.prototype.isPrototypeOf(novel));//true

看模型圖會更清楚

注意,這裡的繼承是指原型式繼承(prototypal inheritance)。

「原型式繼承」是指使用連結相連兩個物件而能共用屬性的方式,又稱為「差異式繼承」(differential inheritance),它模仿了傳統物件導向語言的類別方法,而達到繼承的功能。

說明

  • Novel.prototype = Object.Create(Book.prototype);Object.create建立了一個新物件(稱呼它為O),並將O.__proto__設定為Book.prototype,因此我們可以想成藉由O這個橋樑,讓Novel.prototype.constructor === Book
  • 承上,也可改用ES6的setPrototypeof來設定[[Prototype]]內部屬性的值。
1
2
3
4
5
6
7
//pre-ES6
// throws away default existing `Novel.prototype`
Novel.prototype = Object.create(Book.prototype);

//ES6+
// modifies existing `Novel.prototype`
Object.setPrototypeOf(Novel.prototype, Book.prototype);

Object.getPrototypof

ydkjs_1的[[Prototype]]的值是?

1
console.log(Object.getPrototypeOf(ydkjs_1) === Book.prototype);//true

或等同直接使用.__proto__取得[[Prototype]]的值,也是可行的。

1
console.log(ydkjs_1.__proto__ === Book.prototype);//true
備註
  • 雖然剛才說「instanceof檢查物件是否為指定的建構子所建立的實體」,但其實instanceof所檢視的是物件的內部屬性[[Prototype]](或說是__proto__)所形成的整條原型串鏈中,是否能找到其建構子原型。例如:ydkjs_1與ydkjs_2的__proto__屬性是否為Book.prototype?(答案是肯定的)

  • Object與Functon互為彼此的實體,意即Function.__proto__指向Object.prototype,而Object.__proto__也指向Function.prototype

  • 在下一篇文章「行為委派」中,我們會將「繼承世系」改稱為「委派連結」(delegation link),這會比較符合JavaScript的現況。

Q3:原型串鏈的終點是?

承上範例,針對這整條原型串鏈,我們就拿它來檢看看…

1
2
3
4
console.log(ydkjs_1.__proto__ === Book.prototype);//true
console.log(Book.__proto__ === Function.prototype);//true
console.log(Book.prototype.__proto__ === Object.prototype);//true
console.log(Object.prototype.__proto__);//null

因此,Object.prototype物件就是整條串鏈的最頂端了。我們可想像成,在查找變數時,最後的終點就是全域範疇了。

Object.prototype這個物件含有很多常用的屬性和方法,例如:toString、valueOf等這也就是為什麼所有的物件都能使用這些功能的原因。

Q4:屬性的設定與遮蔽

查找物件的屬性或方法時要注意設定與遮蔽的問題。

我們可能遇過以下這種狀況…

物件obj有屬性counter作為計數器,而anotherObj無此屬性且原型串列的參考指向obj。可能是一時手誤吧,居然將anotherObj.counter當計數器做遞增,之後在程式某處分別印出obj.countanotherObj.count,發現居然所存的值是不一樣的!這到底發生了什麼事呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const obj = {
counter: 0,
};
const anotherObj = Object.create(obj);
anotherObj.counter++;// 一時手誤,應改為obj.counter++

console.log(obj.counter);//0
console.log(anotherObj.counter);//1

obj.counter++;
anotherObj.counter++;

console.log(obj.counter);//1
console.log(anotherObj.counter);//2

目前已知,anotherObj並無counter屬性,而counter屬性位於原型串鏈[[Prototype]]更上一層的obj之內。當使用指定運算子更新counter屬性值的時候,會依照以下規則來決定處理的方式

  1. 此屬性可被寫入(writable為true),則anotherObj會新增此屬性,而產生遮蔽(shadowing)的效果。
  2. 此屬性不可被寫入(writable為false),則在嚴格模式下會被報錯,而在非嚴格模式下,會忽略這個設定/更新。
  3. 此屬性有設定setter、因此會回傳setter所設定的預設值。

在上面的這個例子中,是屬於狀況「1」,因此目前obj與anotherObj兩物件上都具有counter屬性了。解法是小心一點。不要再手誤了!

Q5:一定要用「類別」的概念才能建立兩物件的連結嗎?

答案是「不必」。

Object.create(..)可將兩個物件連結起來,如下Object.create(..)可建立一個新物件coolPerson,連結到指定的物件person,意即設定coolPerson.__proto__指向person。

1
2
3
4
5
6
7
8
9
10
var person = {
name: null,
sayHi: function (name) {
this.name = name;
console.log(`Hi, I am ${this.name}`);
}
};

var coolPerson = Object.create(person);// coolPerson.__proto__ === person
coolPerson.sayHi('Jack');// Hi, I am Jack

備註,若使用Object.create(null)建立一個空物件,那它就直的非常空,裡面不含依何屬性,因此也就沒有.__proto__.constructor可用了,通常會單純當成存資料用的物件而已。

1
2
3
var empty = Object.create(null);
empty;//{}
console.log(empty.__proto__);// undefined--很空,什麼都沒有
Q6:連結作為備援之用?

原型串鏈的功用只是當備援(fallback)之?用意即,當查找的屬性無法在當前物件找到時,就往更上一層的物件尋找。

但其實沒這麼簡單,我們在下一篇文章「行為委派」會看到它的強大之處,例如,讓物件建立平等的委派關係以取得屬性和方法,實作更簡單物懂的設計模式。

回顧

我們到底有什麼收穫呢?藉由本文可以理解到…

  • 原型串錄是指經由物件的內部屬性.__proto__而形成的物件到物件的連結串連,當查找物件屬性時,若在本身這個物件找不到,就往更上一層物件尋找,直到串鏈尾端,若無法找到就回傳undefined。.__proto__存放的即為其建構子原型的參考。

  • JavaScript沒有類吸,也沒有建構子,因此

    • 只要函式前有new,這個函就是建構子
    • 只要函式前有new來個呼叫,就叫做建構子呼叫。
  • JavaScript中的繼承是指原型式繼式或差異式繼承,意即使用連結兩個物件而能共用屬性的方式來模擬物件導向語言的類別與繼承功能。

  • hasOwnProperty可用來檢查屬性是屬於當前物件,還是位於原型串鏈中。

  • instanceof與isPrototypeOf可用來檢查物件是否為指定的建構子所建立的實體;

    Object.getPrototypeOf可取得物件的[[Prototype]]的值;

    Object.setPrototypeOf可設定物件的[[Prototype]]的值;

  • Object.prototype就整條原型串鏈的終點。

  • 物件屬性的設定與遮蔽規則。

  • Object.create(..)可將兩個物件連結起來。

參考資料

你懂 JavaScript 嗎?

You Don’t Know JS 1st Edition

前言

在測試Flask-SocketIO有遇到一些坑,記錄一下以免忘記

連線認證

在實際的專案的應用時,在連線時會帶入token來進行認證,失敗時要回傳狀態碼給client,讓它知道是什麼問題,在Flask-SocketIO它本身如果在連線事件,有認證失敗時,return False就可以了,在client會收到401的狀態

1
2
3
4
5
6
7
8
9
10
11
def on_connect(self):
if not Auth
return False #認證有問題
sckns = request.namespace
currentSocketId = request.sid
remotip = request.remote_addr
fmt = "[myns ns=%s]<connect> remote_addr=%s socket.id=%s" % (
sckns, remotip, currentSocketId)

print(fmt)

如果你的Flask-SocketIO不使用WebSocket的話,這是可以收到,不過因為Flask-SocketIO本身不提供WebSocket的功能而是使用第三方的套件,我是安裝gevent-websocket 此套件,所以在初始化如下

1
2
3
4
app = Flask(__name__)
app.secret_key = "12345"
socketio = SocketIO(
app, binary=True, http_compression=False, async_mode='gevent')

所以在連線時有問題,回傳False,client端是不會教到任何的回應的

前言

學習一下Rxjs的相關使用,目前的範例大多是v5之前,所以記錄和學習v6的相關修改

以下的例子可以看到,當b$c$有變化時,a$也會跟著變化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { from } from 'rxjs';
import { zip, of } from 'rxjs';

var b$ = from([1, 3]);
var c$ = from([2, 2]);
var a$ = zip(b$, c$, (b, c) => {
console.log('b=' + b);
console.log('c=' + c);
return b + c;
});
a$.subscribe(a => {
console.log('a=' + a);
})
//b=1 ​​​​​at ​​​'b=' + b​​​
//c=2 ​​​​​at ​​​'c=' + c​​​
//a=3 ​​​​​at ​​​'a=' + a​​​

//b=3 ​​​​​at ​​​'b=' + b​​​
//c=2 ​​​​​at ​​​'c=' + c​​​
//a=5 ​​​​​at ​​​'a=' + a​​​

RxJS的使用參考

  1. 創建Obserable的方法

    1
    import { Observable, Subject, asapScheduler, pipe, of, from, interval, merge, fromEvent, SubscriptionLike, PartialObserver } from 'rxjs';
  2. 操作符operators

    1
    import { map, filter, scan } from 'rxjs/operators';
  3. websocket

    1
    import { webSocket } from 'rxjs/webSocket';
  4. ajax

    1
    import { ajax } from 'rxjs/ajax';

代表流的變量用$符號結尾,是RxJS中的一種慣例

RxJS要點

RxJS有一個核心和三個重點,一個核心是Obserable 再加上相關的Operators,三個重點分別是Observer,Subject,Schedulers

什麼是 Observable

在文檔中說的Observable更確切的說法是Observable Stream,也就是Rx的響應式數據流。

在RxJS中Observable是可被觀察者,觀察者則是Observer,它們通過Observable的subscribe方法進行關聯

前面提到了RxJS結合了觀察者模式和迭代器模式

對于觀察者模式,我們其實比較熟悉的比如各種DOM事的監聽,也是觀察者模式的一種實踐。核心就是發佈者發佈事件,觀察者選擇時機去訂閱(subscibe)事件。

在ES6中Array,String等可遍歷的數據結構原生部署了迭代器(iterator)接口

1
2
3
4
5
6
7
8
9
10
const numbers = [1, 2, 3]
const iterator= numbers[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
//{ value: 1, done: false } 
//{ value: 2, done: false } 
//{ value: 3, done: false } 
//{ value: undefined, done: true } 

觀察者模式和迭代器模式的相同之處是兩者都是漸進式使用數據的,只不過從數據使用者的角度來說,觀察者模式數據是推送push過來的,而迭代器模式𣆞自已去拉取pull的,Rx中的數據是Observable推送的,觀察者不需要主重去拉取。

Obserable與Array相當類似,都可以看作是Collection只不過Observable是a collection of items over time是隨時間發出的一序列元素,所以下面會看到Obserable的一些操作符與Array的方法極其相似

觀察者模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//觀察者模式
class Producer {
constructor() {
this.listeners = [];
}
addListener(listener) {
if (typeof listener === 'function') {
this.listeners.push(listener);
} else {
throw new Error('listener 必須是 function');
}
}
removeListener(listener) {
this.listeners.splice(this.listeners.indexOf(listener), 1);
}
notify(message) {
this.listeners.forEach(listener => {
listener(message);
});
}
}
var egghead = new Producer();
// new 出一個 Producer 實例叫 egghead
function listerner1(message){
console.log(message + ' from listener1');
}

function listerner2(message){
console.log(message+ ' from listener2');
}

egghead.addListener(listerner1);// 註冊監聽
egghead.addListener(listerner2);

egghead.notify('A new course!!');

創建Observable

要創建一個Observable,只要給new Observable傳遞一個接收observer參數的回調函數,在這個函數中去定義如何發送數據

同步的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//創建Observable
import { Observable } from "rxjs";
const source$ = new Observable(observer => {
//發射數據
observer.next(1);
observer.next(2);
observer.next(3);
});
const observercb = {
next: item => console.log(item)
}
console.log("start")
source$.subscribe(observercb)
console.log("end")

另一種寫法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Observable } from "rxjs";
//建立流物件
const source$ = new Observable(observer => {
//發射數據
observer.next(1);
observer.next(2);
observer.next(3);
});
console.log("start");

//訂閱它
source$.subscribe(data => {
console.log(data);
})
console.log("end");

異步的例子

1
2
3
4
5
6
7
8
9
10
11
12
import { Observable } from 'rxjs';
const source$ = new Observable(observer => {
let number = 1
setInterval(() => {
observer.next(number++)
}, 1000);
});
console.log("start");
source$.subscribe(data => {
console.log(data);
});
console.log("end");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Observable } from 'rxjs';
const source$ = new Observable(observer => {
let number = 1
let message = {
count: number,
message: "message "
}

setInterval(() => {
number++;
message.count = number;
observer.next(message)
}, 1000);
});
console.log("start");
source$.subscribe(data => {
console.log(data);
});
console.log("end");

Obserable同時可以處理同步與非同步的行為

觀察者Observer

Obserable可以被訂閱(subscirbe)或說可以被觀察,而訂閱Obserable的物件又稱為**觀察者(Observer)**。觀察者是一個具有三個方法(method)的物件,每當Obserable發生事件時,更會呼叫觀察者相對應方法。

意這裡的觀察者(Observer)跟上一篇講的觀察者模式(Observer Pattern)無關,觀察者模式是一種設計模式,是思考問題的解決過程,而這裡講的觀察者是○被定義的物件。

觀察者的三個方法(method):

  • next:每當Obserable發送出新的值,next方法就會被呼叫。

  • complete:在Obserable沒有其他的資料可以取得時,complete方法就會被呼叫,在complete被呼叫之後,next方法就不會再起作用。

  • error:每𡮝Obserable內發生錯誤時,error方法就會被呼叫。

說了這麼多,我們還是直接來建立一個觀察者吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Observable } from 'rxjs';
var observable$= Observable.create((observer)=>{
observer.next('Jerry');
observer.next('Anna');
observer.complete();
observer.next('not work');
});

//宣告一個觀察者,具備next , error, complete 三個方法

let observer ={
next:function(value){
console.log(value);
},
error:function(error){
console.log(error);
},
complete:function(){
console.log('complete');
}
}

// 用我們定義好的觀察者,來訂閱這個observale
observable$.subscribe(observer);

上面這段程式碼會印出

1
2
3
Jerry ​​​​​at ​​​value​​​ ​day05rxjs.js:13:8​
Anna ​​​​​at ​​​value​​​ ​day05rxjs.js:13:8​
complete ​​​​​at ​day05rxjs.js:19:8​

上面的範例可以看得出來在complete執行後,next就會自動失效,所以沒有印出not work

下面則是送出錯誤的範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Observable } from "rxjs";
var observable$ = Observable.create((observer) => {
try {
observer.next('Jerry');
observer.next('Anna');
throw 'some exception';
} catch (e) {
observer.error(e);
}
});

// 宣告一個觀察者,具備 next,error,complete 三個方法
var observer = {
next: function (value) {
console.log(value);
},
error: function (error) {
console.log('Error: ', error);
},
complete: function () {
console.log('complete')
}
}
// 用我們定義好的觀察者,來訂閱這個observable
observable$.subscribe(observer);

會印出

1
2
3
4
Jerry 
Anna
Error: some exception 

這裡就會執行error的function印印出Error: some exception

有時候Observable會是一個無限的序列,例如click事件,這時complete方法就有可能永遠不會被呼叫!

我們也可以直接把next,error,complete三個function依序傳入obserable.subscrive如下

1
2
3
4
5
observable$.subscribe(
value => { console.log(value); },
error => { console.log('Error: ', error); },
() => { console.log('complete'); }
);

實作細節

我們之前提到了,其實Observable的訂閱跟addEventListener在實作上有蠻大的差異,雖然他們的行為很像!

addEventListener本質上就是Observer Pattern的實作,在內部會有一份訂閱清單,像是我們實作的Producer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Producer {
constructor() {
this.listeners = [];
}
addListener(listener) {
if (typeof listener === 'function') {
this.listeners.push(listener);
} else {
throw new Error('listener 必須是 function');
}
}
removeListener(listener) {
this.listeners.splice(this.listeners.indexOf(listener), 1);
}
notify(message) {
this.listeners.forEach(listener => {
listener(message);
});
}
}

我們在內部儲存了一份所有的監聽者清單’this.listeners’,在要發佈通知時會對逐一的呼叫這份清單的監聽者。

但在Observable不是這樣實作的,在其內部沒有一份訂閱者的清單。訂閱Observable的行無比較像是執行一個物件的方法,並把資料傳進這個方法。

我們以下面的程式碼做說明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Observable } from "rxjs";

let observable$ = Observable.create((observer) => {
observer.next('Jerry');
observer.next('Anna');
});

observable$.subscribe({
next: function (value) {
console.log(value);
},
error: function (error) {
console.log(error);
},
complete: function () {
console.log('complete');
}
});

像上面這段程式,他的行為比較像這樣

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function subscribe(observer) {
observer.next('Jerry');
observer.next('Anna');
}
subscribe({
next: function (value) {
console.log(value);
},
error: function (error) {
console.log(error);
},
complete: function () {
console.log('complete');
}
});

這裡可以看到subscribe是一個function,這個function執行時會傳入觀察者,而我們在這個function內部去執行觀察者的方法。

訂閱一個Observable就像是執行一個function

建立 Observable(二)

Obaerable有許多創建實例的方法,稱為creation operator,下面會有RxJS常用的creation operator

of

當我們想要同步的傳遞幾個值時,就可以用of這個opertor來簡潔的表達

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { of } from "rxjs";

var source$ = of('Jery', 'Anna');
source$.subscribe({
next: function (value) {
console.log(value)
},
complete: function () {
console.log('complete!');
},
error: function (error) {
console.log(error);
}
});

from

其實ofoperator的一個一個參數其實就是一個list,而list在JavaScript中最常見的形式是陣列(array) ,那我們可以用from來按收任何可列舉的參數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { from } from "rxjs";
var arr = ['Jerry', 'Anna', 2016, 2017, '30 days'];
var source$ = from(arr);
source$.subscribe({
next: function (value) {
console.log(value);
},
complete: function () {
console.log('complete');
},
error: function (error) {
console.log(error);
}
});
//結果
//Jerry
//Anna
//2016
//2017
//30 days
//complete

記得任何可列舉的參數可以用,也就是說像Set,WeakSet,Iterator等都可以當作參數

因為ES6出現後可列舉(iterable)的型別變多了,所以fromArray就被移除了。

另外from還能接收字串(string)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { from } from "rxjs";
var source$ = from('鐵人賽');
source$.subscribe({
next: function (value) {
console.log(value);
},
complete: function () {
console.log('complete');
},
error: function (error) {
console.log(error);
}
});
//結果
//鐵
//人
//賽
//complete

也可以傳入Promise物件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { from } from "rxjs";
var source$ = from(new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Hello RxJs');
}, 3000);
}));

source$.subscribe({
next: function (value) {
console.log(value);
},
complete: function () {
console.log('complete');
},
error: function (error) {
console.log(error);
}
});
//結果
// Hello RxJs
// complete

fromEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
var source = Rx.Observable.fromEvent(document.body, 'click');

source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});
fromEventPattern

要用 Event 來建立 Observable 實例還有另一個方法 fromEventPattern,這個方法是給類事件使用。所謂的類事件就是指其行為跟事件相像,同時具有註冊監聽及移除監聽兩種行為,就像 DOM Event 有 addEventListenerremoveEventListener 一樣!舉一個例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { fromEventPattern } from "rxjs";

class Producer {
constructor() {
this.listeners = [];
}
addListener(listener) {
if (typeof listener === 'function') {
this.listeners.push(listener)
} else {
throw new Error('listener 必須是 function')
}
}
removeListener(listener) {
this.listeners.splice(this.listeners.indexOf(listener), 1)
}
notify(message) {
this.listeners.forEach(listener => {
listener(message);
})
}
}
// ------- 以上都是之前的程式碼 -------- //
var egghead = new Producer();
// egghead 同時具有 註冊監聽者及移除監聽者 兩種方法

var source$ = fromEventPattern(
(handler) => egghead.addListener(handler),
(handler) => egghead.removeListener(handler)

);
source$.subscribe({
next: function (value) {
console.log(value);
},
complete: function () {
console.log('complete!');
},
error: function (error) {
console.log(error);
}
});
egghead.notify('Hello! Can you hear me?');
//結果 Hello! Can you hear me? 

上面的程式碼可以看到,eggheadProducer 的實例,同時具有 註冊監聽及移除監聽兩種方法,我們可以將這兩個方法依序傳入 fromEventPattern 來建立 Observable 的物件實例!

這裡要注意不要直接將方法傳入,避免 this 出錯!也可以用 bind 來寫。

1
2
3
4
5
6
Rx.Observable
.fromEventPattern(
egghead.addListener.bind(egghead),
egghead.removeListener.bind(egghead)
)
.subscribe(console.log)

empty,never,throw

有點像是數學上的**零(0)**,雖然有時候好像沒什麼,但卻非常的重要。在Observable的世界裡也有類似的東西,像是empty

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { empty } from "rxjs";

var source$ = empty();
source$.subscribe({
next:function(value){
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});
// complete

empty 會給我們一個的 observable,如果我們訂閱這個 observable 會發生什麼事呢? 它會立即送出 complete 的訊息!

可以直接把 empty 想成沒有做任何事,但它至少會告訴你它沒做任何事。

數學上還有一個跟零(0)很像的數,那就是無窮(∞), 在Observable的世界裡我們用 never 來建立無窮的 observable

1
2
3
4
5
6
7
8
9
10
11
12
13
import { never } from "rxjs";
var source$ = never();
source$.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});

never 會給我們一個無窮的 observable,如果我們訂閱它又會發生什麼事呢?…什麼事都不會發生,它就是一個一直存在但卻什麼都不做的 observable。

可以把 never 想像成一個結束在無窮久以後的 observable,但你永遠等不到那一天!

題外話,筆者一直很喜歡平行線的解釋: 兩條平行線就是它們相交於無窮遠

最後還有一個 operator throw,它也就只做一件事就是拋出錯誤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { throwError } from "rxjs";
var source$ = throwError('Oop!');
source$.subscribe({
next: function (value) {
console.log(value)
},
complete: function () {
console.log('complete!');
},
error: function (error) {
console.log('Throw Error: ' + error)
}
});
// Throw Error: Oop!

上面這段程式碼就只會 log 出 'Throw Error: Oop!'

interval,timer

接著我們要看兩個跟時間有關的operators、在JS中我們可以用setInterval來建立一個持續的行為,這也能用在Observable中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Observable } from "rxjs";
var source$ = Observable.create(observer => {
var i = 0;
setInterval(() => {
observer.next(i++);
}, 1000);
});

source$.subscribe({
next: function (value) {
console.log(value)
},
complete: function () {
console.log('complete!');
},
error: function (error) {
console.log('Throw Error: ' + error);
}
});
//0
//1
//2
// ...

上面這段程式碼,會每隔一秒送出一個從零開始遞增的整數,在Observable的世界也有一個operator可以更方便地做到這件事,就是interval

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { interval } from "rxjs";
var source$ = interval(1000);
source$.subscribe({
next: function (value) {
console.log(value);
},
complete: function () {
console.log('complete');
},
error: function (error) {
console.log('Throw Error: ' + error);
}
});
//0
//1
//2
// ...

interval有一個參數是數值(Number), 這數值代表發出訊號的間隔時間(ms), 這兩段程式碼基本上是等價的會持續每隔一秒送出一個從零開始遞增的數值

另外有一很相似的operator叫timer,timer可以給兩個參數,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { timer } from "rxjs";
var source$ = timer(1000, 5000);
source$.subscribe({
next: function (value) {
console.log(value);
},
complete: function () {
console.log('complete');
},
error: function (error) {
console.log('Throw Error: ' + error);
}
});
//0
//1
//2
// ...

timer有兩個參數時,第一個參數代表要發出第一個值的等待時間(ms),第二個參數代表第一次之後發送值的間隔時間,所以上面這段程式碼會先等一秒送出0之後每5秒送出1,2,3,4…

timer第一個參睥除了可以是數值(Number)之外,也可以是日期(Date), 就會等到指定的時間在發送䇪一個值。

另外timer也可以只接收一個參數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { timer } from "rxjs";
var source$ = timer(1000);
source$.subscribe({
next: function (value) {
console.log(value);
},
complete: function () {
console.log('complete');
},
error: function (error) {
console.log('Throw Error: ' + error);
}
});
//0
//complete

上面這段程式碼就會等一秒後送出1同時通知結束。

Subscription

有提到很多無窮的Observable,例如interval,never。但有時候我們可能會在某些行為後不需要這些資源,要做到這件事最簡單的方法就是unsubscribe

其實在訂閱Observable後,會回傳一個subscription物件,這個物件具有釋放資源的unsubscribe方法。範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { timer } from "rxjs";
var source$ = timer(1000, 1000);
//取得subscription
var subscription = source$.subscribe({
next: function (value) {
console.log(value);
},
complete: function () {
console.log('complete');
},
error: function (error) {
console.log('Throw Error: ' + error);
}
});

setTimeout(() => {
subscription.unsubscribe();//停止訂閱(退訂)
}, 5000);
//0
//1
//2
//3

這裡我們用了setTimeout在5秒後,執行了subscription.unsubscribe()來停止訂閱並釋放資源。另外subscription物件還有其他合併訂閱等作用。

Events observable盡畫不要用unsubscribe,通常我們會使用takeUntil,在某個載件發生後來完成Eventobservable

Observable Operators & Marble Diagrams

Observable的Operators是實數應用上最重要的部份,我們需要了解各種Operators的使用方式,才能輕鬆實作各種需求!

關於轉換(Transformation)、過濾(Filter)、合併(Combination)等操作方法,先來知道什麼是Operator

什麼是Operator?

Operators就是一個個被附加到Observable型別的函式,例如像是mag,filter,contactall…等等,所有這些函式都拿到原來的Observable並回傳一個新的observable,有像下面的樣子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { of, Observable } from "rxjs";
var people = of("Jerry", "Anna");
function map(source$, callback) {
return Observable.create((observer) => {
return source$.subscribe((value) => {
try {
observer.next(callback(value));
} catch (e) {
observer.error(e);
}
},
(err) => { observer.error(e); },
() => { observer.complete(); }
);
});
}

var helloPeople = map(people, (item) => item + " Hello~");
helloPeople.subscribe(console.log)
//Jerry Hello~ ​​​​​
// Anna Hello~ ​​​​​

這裡可以看到我們寫了一個map的函式,它接收兩個參數,第一個是原本的observable, 䇪二個是map的callback function。map內部第一件事就是用create建立一個新的observable並回傳,並且在內部訂閱原本的observable。

這裡有兩個重點是我們一定要知道的,每個operator都會回傳一個新的ooservable,而我們可以透過create的方法建立各種operator

我們需要先訂定一個簡單的方式來表達observable

Marble diagrams

我們在傳達事物時,文字其實是最糟的手段,雖然文字是我們平時溝通的基礎,但常常千言萬語也比不過一張清楚的圖。如果我們能訂定observable的圖示,就能讓我自更方便的溝通及理解observable的各種operators!

我們把描繪 observable 的圖示稱為 Marble diagrams,在網路上 RxJS 有非常多的 Marble diagrams,規則大致上都是相同的,這裡為了方便撰寫以及跟讀者的留言互動,所以採用類似 ASCII 的繪畫方式。

我們用-來表達一小段時𫂾,這些-串起來就代表一個observable

1
-----------

X(大寫X)則代表有錯誤發生

1
-----------X

|則代表observable結束

1
-----------|

在這個時間序當中,我們可能會發出值(value), 如果值是數字則直接用阿拉伯數字取代,其他的資料型別則用相近的英文符號代表,𫍇裡我們用interval舉例

1
var source$=interval(1000);

source$的圖型就會長像這樣

1
----0-----1-----2-----3--...

當observable是同步送值的時候,例如

1
var source$=of(1,2,3,4);

source$的圖形就會長像這樣

1
(1234)|

小括號代表著同步發生

另外的Marble diagrams也能夠表達operator的前後轉換,例如

1
2
var source=interval(1000);
var newest =source.map(x => x + 1)

這時候Marble diagrams就會長像這樣

1
2
3
source: -----0-----1-----2-----3--...
map(x => x + 1)
newest: -----1-----2-----3-----4--...

最上面原本的observable, 中間是operator,下面則是新的observable

Marble Diagrams 相關資源:http://rxmarbles.com/

Operators

map

Observable的map方法使用上跟陣列的map是一樣的,我們傳入一個callback function,這個callback function會帶入每次發送出來的元素,然後我們回傳新的元素

1
2
3
4
5
6
7
8
9
10
11
import { interval } from "rxjs";
import { map } from 'rxjs/operators';
var source$ = interval(1000);
var newest$ = source$.pipe(
map(x => x + 2)
);
newest$.subscribe(console.log);
//2 ​​​​​
//3 ​​​​​
//4 ​​​​​
//5 ... ​​​​​

用Marble diagrams表達就是

1
2
3
source: -----0-----1-----2-----3--...
map(x => x + 2)
newest: -----2-----3-----4-----5--...

有另外一個方法跟map很像叫mapTo

mapTo

mapTo可把傳進來的值改成一固定的值

1
2
3
4
5
6
7
8
9
10
import { interval } from "rxjs";
import { map, mapTo } from 'rxjs/operators';
var source$ = interval(1000);
var newest$ = source$.pipe(
mapTo(2)
);
newest$.subscribe(console.log);
//2 ​​​​​
//2 ​​​​​
//2 ....

mapTo用Marble diagrams表達

1
2
3
source: -----0-----1-----2-----3--...
mapTo(2)
newest: -----2-----2-----2-----2--...
filter

filter在使用上也跟陣列的相同,我們要傳入一個callback function,這個function會傳入每個被送出的元素,並且回傳一個boolean值,如果為true的話就會保留,如果為false就會被濾掉

1
2
3
4
5
6
7
8
9
10
11
import { interval } from "rxjs";
import { map, mapTo, filter } from 'rxjs/operators';
var source$ = interval(1000);
var newest$ = source$.pipe(
filter(x => x % 2 === 0)
);
newest$.subscribe(console.log);
//0 ​​​​​
//2 ​​​​​
//4 ​​​​​
//6 ...

filter用Marble diagrams表達

1
2
3
source: -----0-----1-----2-----3-----4--...
filter(x => x%2 === 0)
newest: -----0-----------2-----------4---...

map ,filter這些方法其實都跟陣列的相同,因為這些都是fucntional programming的通用函式。

實際上Observable跟Array的operators(map,filter),在行為上還是有極大的差異,當我們的資料量很大時,Observable的效能會好上非常多。

take

take 是一個很簡單的operator, 顧名思義就是取前幾個元素後就結束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { interval } from "rxjs";
import { map, mapTo, take } from 'rxjs/operators';
var source$ = interval(1000);
var example$ = source$.pipe(take(3));

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});
//0
//1
//2
//complete

這裡可以看到我們的source$原本是會發出無限元素的,但這裡我們用take(3)就會只取前3個元素,取完後就直接結束(complete), 用Marble diagram表如下

1
2
3
source$:-----0-----1-----2-----3--..
take(3)
example:-----0-----1-----2|
first

first會取observable送出的第一個元素之後就直接結束,行為跟takb(1)一樣

1
2
3
4
5
6
7
8
9
10
11
import { interval } from "rxjs";
import { first, take } from "rxjs/operators";
var source$ = interval(1000);
var example$ = source$.pipe(first());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});
//0
//complete

用Marble diagram表示

1
2
source$ :-----0-----1-----2-----3--..
example$:-----0|
takeUntil

在實數上takeUntil很常使用到,他可以在某件事情發生時,讓一個observable直送出完成(complete)訊息

1
2
3
4
5
6
7
8
9
10
import { interval, fromEvent } from "rxjs";
import { first, takeUntil } from "rxjs/operators";
var source$ = interval(1000);
var click = fromEvent(document.body, "click");
var example$ = source$.pipe(takeUntil(click));
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

stackblitz

這裏我們一開始先用interval建立一個observable,這個observable每隔1秒會送出一個從0開𤖥遞增的數值,接著我們用takeUntil,傳人另一個observable.

tabkeUntil傳入的observable發送值時,原本的observable就會直托進人完成(complete)狀態,並且發送完成訊息。也就是說上面這沒程式碼的行為,會先每一秒印出一個數字(從0遞增)直到我們點擊body為止,他才會送出complete訊息。如果畫成Maribe diagram則會像下面

1
2
3
4
source$ :-----0-----1-----2-----3--..
click :---------------------c------
takeUntil(click)
example$:-----0-----1-----2---|

當click一發送元素的時候,observable就會直接完縑(complete)

concatAll

有時我們的Observable送出的元素又是一個observable就像是二維陣列,陣列裡面的元素是陣列,這時我們就可以用concatAll 它攤平成一維陣列,也可以直接把concatAll想成把所有元素concat起來。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { of, fromEvent } from "rxjs";
import { map,concatAll } from 'rxjs/operators';
var click = fromEvent(document.body, 'click');
var source$ = click.pipe(
map(e => of(1, 2, 3))
);
var example$ = source$.pipe(concatAll());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

stackblitz

這個範例我們每點一次body就會立刻送出1,2,3,如果用Marble diagram表示則如下

1
2
3
4
5
6
click  : ------c----------c-----------
map(e => of(1, 2, 3))
source : ------o----------o-----------
\ \
(123)| (123)|
example:-------(123)------(123)-------

這裡可以看到sourceobservable內部每次發送的值也是observable,這時我們用concatAll就可以把source攤平成example

這裡需要注意的是concatAll會處理source先發出來的observable,必須等到這個observable結事,才會再處理下一固source發出來的observable,以下面這個範例說明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { interval } from "rxjs";
import { take, concatAll } from "rxjs/operators";
var obs1$ = interval(1000).pipe(take(5));
var obs2$ = interval(500).pipe(take(2));
var obs3$ = interval(2000).pipe(take(1));

var source$ = of(obs1$, obs2$, obs3$);
var example$ = source$.pipe(concatAll());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
// 0
// 1
// 2
// 3
// 4
// 0
// 1
// 0
// complete

這裡可以看到source會送出3個observable,但是concatAll後的行為永遠都是先處理第一個observable,等到當前處理的結事後才會再處理下一個

用Marble diagram表示如下

1
2
3
4
5
source : (o1)               o2     o3)|
\ \ \
--0--1--2--3--4| -0-1| ----0|
concatAll()
example: --0--1--2--3--4-0-1----0
簡易拖拉

當看完前面幾個operator後,我們就很輕鬆地做出拖拉的功能,先讓我們來看一下需求

  1. 首先畫面上有一僤元件(#drag)

  2. 當滑鼠在元件(#drag)上按下左鍵(mousedwon)時,開始監聽滑鼠移動(mousemove)的位置

  3. 當滑鼠左鍵放掉(mouseup)時,結束監聽滑鼠移動

  4. 當滑鼠移動(mousemove)被監聽時,跟著修改元件的樣式屬性

第一步如下
stackblitz

第二步我們要先取得各個 DOM 物件,元件(#drag) 跟 body。

1
2
const dragDOM = document.getElementById('drag');
const body = document.body;

要取得body的原因是因為滑鼠移動(moudemove)跟滑鼠左鍵放掉(mouseup)都應該是在整個body監聽

第三步我們寫出各個會用的監聽事件,並用fromEvent來取得各個observable.

  • 對#drag監聽moudedown

  • 對body監聽mouseup

  • 對body監聽mousemove

1
2
3
const mouseDown = fromEvent(dragDOM, 'mousedown');
const mouseUp = fromEvent(body, 'mouseup');
const mouseMove = fromEvent(body, 'mousemove');

還沒subscribe之前都不會開始監聽,一定會𨬓到subscribe之後obserable才會開始送值

當mouseDown時,轉成mouseMove的事件

mouseMove要在mouseUp後結束加上takeUntil(mouseup)

1
2
3
const source = mouseDown.pipe(
map(event => mouseMove.pipe(takeUntil(mouseUp)))
);

這時source大概長像這樣

1
2
3
source: -------e--------------------e--------
\ \
--m-m-m-m/ -m--m-m--m-m/

m 代表mousemove event

concatAll()攤平source成一維

1
2
3
4
const source = mouseDown.pipe(
map(event => mouseMove.pipe(takeUntil(mouseUp))),
concatAll()
);

用map把mousemove event轉成x,y的位置,並且訂閱

1
2
3
4
5
6
7
8
const source = mouseDown.pipe(
map(event => mouseMove.pipe(takeUntil(mouseUp))),
concatAll(),
map(event => ({ x: event.clientX, y: event.clientY }))
).subscribe(pos => {
dragDOM.style.left = pos.x + 'px';
dragDOM.style.top = pos.y + 'px';
});

雖然這只是一個簡單的拖拉實現,但已經展示出 RxJS 帶來的威力,它讓我們的程式碼更加的簡潔,也更好的維護!

skip

take可以取前幾個送出的元素,而可以略出前幾個送出的元素skip, 範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { interval, from } from "rxjs";
import { skip } from "rxjs/operators";

var source$ = interval(1000);
var example$ = source$.pipe(
skip(3)
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});
//3 ​​​​​at ​​​value​​​ ​day09rxjs.js:9​
//4 ​​​​​at ​​​value​​​ ​day09rxjs.js:9​
//5 ...

原本從0開𤖥的就會變成從3開𤖥,但是記得原本元素的等得時𫂾仍然存在,也就是說此範例第一個取得的元素需要等4秒,用Marble Diagram表示如下

1
2
3
source$ : ----0----1----2----3----4----5--...
skip(3)
example$: -------------------3----4----5--...
takeLast

除了可以用take取前幾個之外,我們也可以倒過來取最後幾個,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { interval, from } from "rxjs";
import { take, takeLast } from "rxjs/operators";
var source$ = interval(1000).pipe(
take(6));
var example$ = source$.pipe(
takeLast(2));

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});
//4
//5
//complete

這裡我們先取了前6個元素,再取最後兩個,所以最後會送出4,5,complete,有一個重點。就是takeLast必須等到整個observable完成(complete), 才能知道最後的元素有哪些,並且同步送出,如果有Marbe Diagram表示如下

1
2
3
source$  : ----0----1----2----3----4----5|
takeLast(2)
example$ : ------------------------------(45)|

可以看到takeLast後,必需等到原本的observable完成後,才立即同步送出4,5,complete

last

take(1)相同,我們有一個takeLast(1)的簡化寫法,那就是last()用來取得最後一個元素

1
2
3
4
5
6
7
8
9
10
11
12
import { interval, from } from "rxjs";
import { last, take } from "rxjs/operators";

let source$ = interval(1000).pipe(
take(6));
let example$ = source$.pipe(
last());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});

用Marble Diagram表示如下

1
2
3
source$  : ----0----1----2----3----5|
last()
example$ : --------------------------(5)|
concat

concat可以把多個observable實例合併成一個

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { interval, of } from "rxjs";
import { concat, take } from "rxjs/operators";

let source$ = interval(1000).pipe(take(3));
let source2$ = of(3);
let source3$ = of(4, 5, 6);
var example$ = source$.pipe(concat(source2$, source3$));
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});
// 0
// 1
// 2
// 3
// 4
// 5
// 6
// complete

concatAll一樣,必須先等前一個observable完成(complete), 才會繼續下一個,用Marble Diagram表示如下

1
2
3
4
5
source$ :----0----1----2|
source2$:(3)|
source3$:(456)|
concat()
example$:----0-----1----2(3456)|
startWith

startWith可以在observable的一開始塞要發送的元素,有點像concat但參數不是observable而是要發送的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { interval, of } from "rxjs";
import { startWith } from "rxjs/operators";
let source$ = interval(1000);
let example$ = source$.pipe(startWith(0));
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});
// 0
// 0
// 1
// 2
// 3...

這裡可以看到我們在source$一開始塞了一個0, 讓example$會在一開始就立即送出0,用Marble Diagram表示如下

1
2
3
source$ :----0----1----2----3--...
startWith(0)
example :(0)----0----1----2----3-...

記得 startWith 的值是一開始就同步發出的,這個 operator 很常被用來保存程式的起始狀態!

merge

mergeconcat一樣都是用來合併observable,但他們在行為上有非常大的不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { interval } from "rxjs";
import { take, merge } from "rxjs/operators";
let source$ = interval(500).pipe(take(3));
let source2$ = interval(300).pipe(take(6));
let example$ = source$.pipe(merge(source2$));

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});
// 0
// 0
// 1
// 2
// 1
// 3
// 2
// 4
// 5
// complete

merge把多個observable同時處理,這跟concat一次處理一個observable是完全不一樣的,由於是同時處理行為會變得較為複雜,用Marble Diagram會較好解釋

1
2
3
4
source$ : ----0----1----2|
source2$: --0--1--2--3--4--5|
merge()
example$: --0-01--21-3--(24)--5|

這裡可以看到mergr之後的example在時間序上同時在跑source$與source2$,當兩件事情同時發生時,會同步送出資料(被merge的在後面),當兩個observable都結事時才會真的結束。

merge的邏輯有點像是OR(||)就是當兩個observable其中一個被觸發時都可以被處理,這很常用一個以上的按鈕具有部分相同的行為。

例如一個影片播放器有兩個按鈕,一個是暫停(||) 另一個是結束播放(□)。這兩個按鈕都具有相同的行為就是影片會被停止,只是結束播放會讓影片回到00秒,這時我們就可以把這兩個按鈕的事件merge起來處理影片暫停這件事。

1
2
3
4
var stopVideo= stopButton.pipe(merge(endbutton));
stopVideo.subscribe(()=>{
//暫停播放影片
});
combineLatest

它會取得各個observable最後送出的值,再輸出成一個值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { interval } from "rxjs";
import { take, combineLatest } from "rxjs/operators";

var source$ = interval(500).pipe(take(3));
var newest$ = interval(300).pipe(take(6));

var example$ = source$.pipe(combineLatest(newest$, (x, y) => x + y));
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});
// 0
// 1
// 2
// 3
// 4
// 5
// 6
// 7
// complete

看Marble diagram

1
2
3
4
source$ : ----0----1----2|
newest$ : --0--1--2--3--4--5|
combineLatest(newest$,(x,y)=>x+y);
example$: ----01--23-4--(56)--7|

首先combineLatest可以接收多個observable,最後一個參數是callback function,這個callback function接收的參睥數量跟合併的observable數量相同,依照範例來說,因為我們這裡合併了兩個observable所以後面的callback function就接教收x,y兩個參數,x會接收從source$發送出來的值,y會接收newest$發送出來的值。

最後一個重點就是一定會等兩個observable都曾有送值出來才會呼叫我們傳入的callback,所以這段程式是這樣運行的

  • newest$送出了0, 但此時source$並沒有送出過任何值,所以不會執行callback
  • source$送出了0,但此時newest$最後一次送出的值為0,把這兩個數傳入callback得到0
  • newest$送出了1此時source$最後一次送出的值為0, 把這兩個數傳入callback得到1
  • newest$送出了 2,此時 source$ 最後一次送出的值為 0,把這兩個數傳入 callback 得到 2
  • source$ 送出了 1,此時 newest$ 最後一次送出的值為 2,把這兩個數傳入 callback 得到 3
  • newest$ 送出了 3,此時 source$ 最後一次送出的值為 1,把這兩個數傳入 callback 得到 4
  • source$ 送出了 2,此時 newest$ 最後一次送出的值為 3,把這兩個數傳入 callback 得到 5
  • source$ 結束,但 newest$ 還沒結束,所以 example 還不會結束。
  • newest$ 送出了 4,此時 source$ 最後一次送出的值為 2,把這兩個數傳入 callback 得到 6
  • newest$ 送出了 5,此時 source$ 最後一次送出的值為 2,把這兩個數傳入 callback 得到 7
  • newest$ 結束,因為 source$ 也結束了,所以 example$ 結束。

不管是source$還是newest$送出值來,只要另一方曾有送出過值(有最後的值),就會執行callback並送出新的值,𫍇就是combineLastest

combineLastest很常用在運算多個因子的結果,例如最常見的BMI計算,我們身高變動時就拿上一次的體重計算新的BMI,當體重變動時則拿上次的身高計算BMI,這就很適合用combineLastest來處理

zip

zip會取每個observable相同順位的元素並傳入callback, 也就是說每個observable的第n個元素會一起被傳入callback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { interval } from "rxjs";
import { take,zip} from "rxjs/operators";

var source$ = interval(500).pipe(take(3));
var newest$ = interval(300).pipe(take(6));


var example$ = source$.pipe(zip(newest$, (x, y) => x + y));
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});
// 0
// 2
// 4
// complete

Marble Diagram

1
2
3
4
source$ : ----0----1----2|
newest$ : --0--1--2--3--4--5|
zip(newest$,(x,y)=>x+y)
example$: ----0----2----4|

zip會等到source$跟newest$都送出了第一個元素,再傳入callback,下次則等到soure$跟newest$都送出了䇪二個元素再一起傳入callback,所以運行的步驟如下

  • newest$ 送出了第一個0,但此時 source$ 並沒有送出第一個值,所以不會執行 callback。
  • source$ 送出了第一個0,newest$ 之前送出的第一個值為 0,把這兩個數傳入 callback 得到 0
  • newest$ 送出了第二個1,但此時 source$ 並沒有送出第二個值,所以不會執行 callback。
  • newest$ 送出了第三個2,但此時 source$ 並沒有送出第三個值,所以不會執行 callback。
  • source$ 送出了第二個1,newest$ 之前送出的第二個值為 1,把這兩個數傳入 callback 得到 2
  • newest$ 送出了第四個3,但此時 source$ 並沒有送出第四個值,所以不會執行 callback。
  • source$ 送出了第三個2,newest$ 之前送出的第三個值為 2,把這兩個數傳入 callback 得到 4
  • source$ 結束 example$ 就直接結束,因為 source$ 跟 newest$ 不會再有對應順位的值

zip會把各個observable相同順位送出的值傳入callback, 這很常拿來做demo使用。比如我們想要間隔100ms送出’h’,’e’,’l’,’l’,’o’,就可以這麼做

1
2
3
4
5
6
7
8
9
10
import { from, interval } from "rxjs";
import { zip } from "rxjs/operators";
var source$ = from('hello');
var source2$ = interval(100);
var example$ = source$.pipe(zip(source2$, (x, y) => x));
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});

這裡的 Marble Diagram 就很簡單

1
2
3
4
source$ : (hello)|
source2$: -0-1-2-3-4-...
zip(source2$, (x, y) => x)
example$: -h-e-l-l-o|

這裡我們利用 zip 來達到原本只能同步送出的資料變成了非同步的,很適合用在建立示範用的資料。

建議大家平常沒事不要亂用 zip,除非真的需要。因為 zip 必須 cache 住還沒處理的元素,當我們兩個 observable 一個很快一個很慢時,就會 cache 非常多的元素,等待比較慢的那個 observable。這很有可能造成記憶體相關的問題!

withLastestFrom

withLatestFrom運作方式跟combineLastest有點像,只是他有主從的關系,只有在主要的observable送出新的值時,才會執行callback, 附隨的observable只有在背景下運作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { from, interval } from "rxjs";
import { zip, withLatestFrom } from "rxjs/operators";
var main$ = from("hello").pipe(zip(interval(500), (x, y) => x));
var some$ = from([0, 1, 0, 0, 0, 1]).pipe(zip(interval(300), (x, y) => x));

var example$ = main$.pipe(withLatestFrom(some$, (x, y) => {
return y === 1 ? x.toUpperCase() : x;
}));

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('error: ' + err) },
complete: () => { console.log('complete'); }
});
//h
//e
//l
//L
//O
//complete

看一下Marble Diagram

1
2
3
4
main$   : ----h----e----l----l----o|
some$ : --0--1--0--0--0--1|
withLastestFrom(some$,(x,y) => y === 1 ? x.toUpperCase() : x)
example$: ----h----e----l----L----O|

withLatestFrom會在main送出值的時候執行callback,但注意如果main$送出值時some$之前沒有送出過任何值callback仍然不會執行

這陆鄉們在main送出值時,去判斷some$最後一 送的值是不是1來決定是否要切換大小寫

  • main$送出了h, 此時some$上一次送出的值為0, 把這兩個參數傳入callback得到h
  • main$送出了e, 此時some$上一次送出的值為0, 把這兩個參數傳入callback得到e
  • main$送出了l, 此時some$上一次送出的值為0, 把這兩個參數傳入callback得到l
  • main$送出了l, 此時some$上一次送出的值為1, 把這兩個參數傳入callback得到L
  • main$送出了o, 此時some$上一次送出的值為1, 把這兩個參數傳入callback得到O

withLastestFrom很常用在一些checkbox型的功能,例如說一個編輯器,我們開啟粗體後,打出的字就都要變粗體,粗體就像是some observable,而我們打字就是main observable

實務範例-完整拖拉應用

當我們在優酷看影片時往下滾動畫面,影片會變成一個小視窗在右下角,這個視窗還能夠拖挽移動位置。這個功能可以讓使用者一邊看留言同時又能看影片,且不影響其他的資訊顯示,真是不錯的feature。來實作這個功能,同時補完拖拉所需要注意的細節吧!

需求分析

首先我們會有一個影片在最上方,原本是位置是靜態(static)的,捲軸滾到低於影片高度後,影片改為相對於視窗的絕對位置(fixed),往回滾會再變回原來的狀態。當影片為fixed時,滑鼠移至影片上方(hover)會有遮罩(masker)與鼠標變化(cursor),可以拖拉移動(drag),且移動範圍不超過可視區間!

上面可以拆成以下幾個步驟

  • 準備static樣式與fixed樣式
  • HTML要有一固定位置的錨點(anchor)
  • 當滾動超過錨點,則影片變成fixed
  • 當往回滾動過錨點上方,則影片變回static
  • 影片fixed時,要能夠拖拉
  • 拖拉範圍限制在當前可視區間

其本的HTML跟CSS可以到此連結stackblitz

先讓我們看一下HTML,首先在HTML裡有一個div(#anchor), 這個div(#anchor)就是待會要做錨點用的,它內部有一個div(#video), 則是滾動後要改變成fixed的元件

CSS的部分我們只需要知道滾動到下方後,要把div(#video)加入videx-fixed這個class。

接著我們就開始實作滾動的效果切探class的效果吧!

第一步取得會用到的DOM

因為先做滾動切換class, 所有這裡用到的DOM只有#video,#anchor。

1
2
const video =document.getElementById('video');
const anchor = document.getElementById('anchor');
第二步建立會用到的observable

這裡做滾動效果,所以只需要監聽滾動事件。

1
2
3
4
import { fromEvent } from "rxjs";

//...
const scroll$= fromEvent(document,'scroll');
第三步撰寫程式邏輯

這裡我們要取得了scroll事件的observable,當滾過#anchor最底部時,就改變#video的class。

首先我們會需要滾動事件發生時,去判斷是否滾過#anchor最底部,所以把原來的滾動事件變成是否滾動最底部的true or false。

1
2
3
scroll$.pipe(
map(e=> anchor.getBoundingClientRect().bottom<0)
);

這裡我們用山getBoundingClientRect這個瀏覽器原生的API,他可以取得DOM物件的寬高以及上下左右離螢幕可視區間上(左)的距離如下圖

當我們可視範圍區間滾過#ancor底部時,anchor.getBoundingClientRect().bottom就會變成負值,此時我們就改變#video的class。

1
2
3
4
5
6
7
8
9
scroll$.pipe(
map(e=> anchor.getBoundingClientRect().bottom<0)
).subscribe(bool=>{
if(bool){
video.classList.add('video-fixed');
}else{
video.classList.remove('video-fixed');
}
});

這裡我們就已經完成滾動變更樣式的效果了

全部的JS程式碼如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import './style.scss';
import { fromEvent } from "rxjs";
import { map } from 'rxjs/operators';

const video =document.getElementById('video');
const anchor = document.getElementById('anchor');
const scroll$= fromEvent(document,'scroll');
scroll$.pipe(
map(e=> anchor.getBoundingClientRect().bottom<0)
).subscribe(bool=>{
if(bool){
video.classList.add('video-fixed');
}else{
video.classList.remove('video-fixed');
}
});

當然這段還能在用 debounce/throttle 或 requestAnimationFrame 做優化

擒下來我們就可以接著做拖拉的行為了。

第一步取得會用到的DOM

這裡我們會用到的DOM跟前面是一樣的(#video),所以不用多做什麼。

第二步建立會用到的observable

這裡跟上次一樣,我們會用到mousedown,mouseup,mousemove三個事件。

1
2
3
const mouseDown$ = fromEvent(video,'mousedown');
const mouseUp$ = fromEvent(document,"mouseup");
const mouseMove$ = fromEvent(document,"mousemove");
第三步撰寫程式邏輯

跟上次是差不多的,首先我們會點擊#video元件,點擊(mousedown)後要變成移動事件(mousemove),而移動事件會在滑鼠收開(mouseup)時結事(takeUntil)

1
2
3
4
5
mouseDown$.pipe(
map(e => mouseMove$.pipe(takeUntil(mouseUp$))),
concatAll()
);

因為把moudeDown$ observable發送出來的事件換成了mouseMove$ observable,所以變成了observable(mouseDown$)送出observable(mouseMove$)。因此最後用concalAll把後面送出的元素變成mousemove的事件。

但這裡會有一個問題,就是我們的𫍇段拖拉事件其實只能做用到video-fixed的時候,所以我們要加上filter

1
2
3
4
5
6
mouseDown$.pipe(
filter(e=> video.classList.contains("video-fixed")),
map(e => mouseMove$.pipe(takeUntil(mouseUp$))),
concatAll()
);

這裡我們用filter如果當下#video沒有video-fixedclass的話,事件就不會送出。

再來我們就能跟上次一樣,把mousemove事件變成{x,y}的物件,並訂閱來改變#video元件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mouseDown$.pipe(
filter(e => video.classList.contains("video-fixed")),
map(e => mouseMove$.pipe(takeUntil(mouseUp$))),
concatAll(),
map(m => {
return {
x: m.clientX,
y: m.clientY
}
})
)
.subscribe(pos => {
video.style.top = pos.y + "px";
video.style.left = pos.x + "px";
});

到這裡我們基本上已經完成了所有功能和之前簡易拖拉的方法是一樣的。

但這裡有兩個大問題我們還沒有解決

  1. 第一次拉動的時候會閃一下,不像優酷那麼順。

  2. 拖拉會跑出當前可視區間,跑出去後就抓不回來了

讓我們一個一個解決,首先第一個問題是因為我們的拖拉直接給元件滑鼠的位置(clientC,clientY),而非給滑鼠相對移動的距離!

所以要解決這個問題很簡單,我們只要把點擊目標的左上角當作(0,0), 並以此改變元件的樣式,就不會有閃動的問題。 這個要怎麼做呢?很簡單,使用withLatestFrom的operator我們可以用它來把mousedown與mousemove兩個Event的值同時傳入callback。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mouseDown$.pipe(
filter(e => video.classList.contains("video-fixed")),
map(e => mouseMove$.pipe(takeUntil(mouseUp$))),
concatAll(),
withLatestFrom(mouseDown$, (move, down) => {
return {

x: move.clientX - down.offsetX,
y: move.clientY - down.offsetY
}
})
)
.subscribe(pos => {
video.style.top = pos.y + "px";
video.style.left = pos.x + "px";
});

當我們能夠同時得到mousemove跟mousedown的事件,接著就只要把滑鼠相對可視區間的距離(client)減掉點按下去時,滑鼠相對元件邊界的距離(offset)就行了。這時拖拉就不會先閃動一下囉!

大家只要想一下,其實client-offset就是元件相對於可視區間的距離,也就是他一開始沒動的位置!

接著讓我們解決第二個問題,拖拉會超出可視範圍。這個問題其實只史給最大最小值就行了,因無需求的關系,這裡我們的元件是相對可視的區間的絕對位置(fixed),也就是就說

  • top 最小是0
  • left 最小是0
  • top 最大是可視高度扣掉元件本身高度
  • left 最大是可視寬度扣掉元件本身寬度

這裡我們先宣告一個function來處理這件事

1
2
3
const validValue = (value, max, min) => {
return Math.min(Math.max(value, min), max)
}

第一個參數給原本要給的位置值,後面給最大跟最小,如果今天大於最大值我們就取最大值,如果今天小於最小值則取最小值。

再來我們就可以直接把這個問題解掉了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mouseDown$.pipe(
filter(e => video.classList.contains("video-fixed")),
map(e => mouseMove$.pipe(takeUntil(mouseUp$))),
concatAll(),
withLatestFrom(mouseDown$, (move, down) => {
return {
x: validValue(move.clientX - down.offsetX, window.innerWidth - 320, 0),
y: validValue(move.clientY - down.offsetY, window.innerHeight - 180, 0)
}
})
)
.subscribe(pos => {
video.style.top = pos.y + "px";
video.style.left = pos.x + "px";
});

這裡偷懶了一下,直接寫死元件的寬高(320,180),實際上應用getBoundingClientRect計算是比較好的。

scan,buffer

兩個簡單的transformation operators並帶一些小範例,這兩個operators都是實數上很常用到的方法。

scan

scan其實就是Observable版本的reduce只是命名不同。如果熟悉陣列操作的話,應該會知道原生的JS Array就有reduce的方法,使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
var arr = [1, 2, 3, 4];
var result = arr.reduce((origin, next) => {
console.log(origin);
return origin + next;
}, 0);
console.log(result);
// 0
// 1
// 3
// 6
// 10

reduce方法需要傳兩個參數,第一個是callback第二個則是起始狀態,這個callback執行時,會傳入兩個參睥一個是原本的狀態,第二個是修改原本狀態的參數,最後回傳一個新的狀態,再繼續執行。

所以這段程式碼是這樣執行的

  • 第一次執行callback起始狀態是0所以origin傳入0,next為arr的第一個元素1,相加之後變成1回傳並當作下一次的狀態。

  • 第二次執行callback,這時原本的狀態(origin)就變成了1,next為arr的第二個元素2,相加之後變成3回傳並當作下一次的狀態。

  • 第三次執行callback,這時原本的狀態(origin)就變成了3,next為arr的第三個元素3,相加之後變成6回傳並當作下一次的狀態。

  • 第四次執行callback, 這時原本的狀態(origin)就變成了6,next為arr的第四個元素4,相之後變成10回傳並當作下一次的狀態。

  • 這時arr的元素都已經遍歷過了,所以不會直接把10回傳。

scan整體的運作方式都跟reduce一樣,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { from, interval } from "rxjs";
import { zip, scan } from "rxjs/operators";

var source$ = from("hello").pipe(
zip(interval(600), (x, y) => x)
);
var example$ = source$.pipe(
scan((origin, next) => origin + next, '')
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log("complete"); }
});
// h
// he
// hel
// hell
// hello
// complete

畫成Marble Diagram

1
2
3
source$  : ----h-----e-----l----l----o|
scan((origin, next) => origin + next, '')
example$ : ----h----(he)----(hel)----(hello)----(hello)|

這裡可以看到第一次傳入'h'''相加,返回'h'當作下一次的初始狀態,一直重複下去。

scan跟reduce最大的差別就在scan一定會回傳一固observable實例,而reduce最後回傳的值有可能是任何資料型別,必須看使用者傳入的callback才態決定reduce最後的返回值。

Jafar Husain 就曾說:「JavaScript 的 reduce 是錯了,它最後應該永遠回傳陣列才對!」

如果大家之前有到這裡練習的話,會發現 reduce 被設計成一定回傳陣列,而這個網頁就是 Jafar 做的。

scan很常用狀態的計算處理,最簡單的就是對一個數字的加減,我們可以綁定一個button的click事件,並用map把click event 轉成1,之後送到scan計算值再做顯示。

stackblitz

html

1
2
3
<button id="addButton">Add</button>
<button id="minusButton">Minus</button>
<h1 id="state"></h1>

javascript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { fromEvent,empty } from "rxjs";
import { map, mapTo, startWith, merge,scan } from 'rxjs/operators';

const addButton = document.getElementById("addButton");
const minusButon = document.getElementById("minusButton");
const state = document.getElementById("state");

const addClick = fromEvent(addButton, "click").pipe(mapTo(1));
const minusClick = fromEvent(minusButton, "click").pipe(mapTo(-1));

const numberState = empty().pipe(
startWith(0),
merge(addClick, minusClick),
scan((origin, next) => origin + next, 0)
);
numberState
.subscribe({
next: (value) => { state.innerHTML = value;},
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

這裡我們用了兩個button,一個是add按鈕,一個是minus按鈕。

我們把這兩個按鈕的點擊事件各建立了addClick,minusClick兩個observable,這兩個observable摎托mapTo(1)跟mapTp(-1),代表被點擊後各自送出的數字!

按著我們用了empty()建立一個空的observable代表畫面上數字的狀態,搭配startWith(0)來設定初始值,接著用merge把兩個observable合併透過scan處理之後的邏輯,最後在subscribe來更改畫面的顯示。

buffer

buffer是一整個家族,總共有五個相關的operators

  • buffer

  • bufferCount

  • bufferToggle

  • bufferWhen

這裡比較常用到的是buffer,bufferCount跟bufferTime這三個,我們直接來看範例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { interval } from "rxjs";
import { buffer } from "rxjs/operators";

var source$ = interval(300);
var source2$ = interval(1000);
var example$ = source$.pipe(
buffer(source2$)
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
// [0,1,2]
// [3,4,5]
// [6,7,8]...

畫成Marble Diagram則像是

1
2
3
4
source$ : --0--1--2--3--4--5--6--7--
source2$: ---------0---------1--------...
buffer(source2$)
example$: ---------([0,1,2])---------([3,4,5])

buffer要傳入一個observable(source2$), 它會把原本的observable(source$)送出的元素緩存在陣列中,等到傳入的observable(source2$)送出元素時,就會觸發把緩存的元素送出。

這裡的範例source$2是每一秒就會送出一個元素,我們柯以改用bufferTime簡潔的表達如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { interval } from "rxjs";
import { buffer, bufferTime } from "rxjs/operators";

var source$ = interval(300);
var example$ = source$.pipe(
bufferTime(1000)
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
// [0,1,2]
// [3,4,5]
// [6,7,8]...

除了用時𫂾來作緩存外,我們更常用數量來做緩存,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { interval } from "rxjs";
import { bufferCount } from "rxjs/operators";

var source$ = interval(300);
var example$ = source$.pipe(
bufferCount(3)
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
// [0,1,2]
// [3,4,5]
// [6,7,8]...

在實務上,我們可以用buffer來做某個事件的過濾,例如像是滑鼠連點才能直的執行,這裡我們一樣寫了一個小範例

stackblitz

html

1
<button id="demo">double click!</button>

javascript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { fromEvent, empty } from "rxjs";
import { bufferTime, filter } from 'rxjs/operators';

const button = document.getElementById("demo");
const click$ = fromEvent(button, "click");
const example$ = click$.pipe(
bufferTime(500),
filter(arr => arr.length >= 2)
);
example$.subscribe({
next: (value) => { console.log('success'); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

這裡我們只有在500毫秒內連點兩下,才能成功印出success,這個功能在某些特殊的需求中非常的好用,也能用在批次處理來降低request傳送的次數

delay,delayWhen

在所有非同步行為中, 最麻煩的大概就是UI操作了,因為UI是直接影響使用者的感受,如果處理的不好對使用者體會大大的扣分

UI大概是所有非同步行為中最不好處理的,不只是因為它直接影響了用戶體驗,更大的問題是UI互動常常是高頻𠅋觸發的事件,而且多個元件間的時間序需要不一致,要做這樣的UI互動就不太可能用Promise或async/await,但是用RxJS仍然能輕易地處理!

有兩個Operators,delay跟deleyWhen都是跟UI互動比較相關的。當我們的網頁越來越像應用程式,UI互動就變得越重要,讓我們來試試如何用RxJS完成基本的UI互動!

delay

delay可以延遲observable一開始發送元素的時間點,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { interval } from "rxjs";
import { take, delay } from "rxjs/operators";

var source$ = interval(300).pipe(
take(5)
);

var example$ = source$.pipe(
delay(500)
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
// 0
// 1
// 2
// 3
// 4
// complete

當然直接從log出來的訊息看,是完全看不出差異的

讓我們直接看Marble Diagram

1
2
3
source$ : --0--1--2--3--4|
dely(500)
example$: -------0--1--2--3--4|

從Marble Diagram可以看得出來,第一次送出元素的時間變慢了,雖然在這裡看起來沒有什麼用,但是在UI操作上是非常有用的。

delay除了可以傳入毫秒以外,也可以傳入Date型別的資料,如下使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { interval } from "rxjs";
import { take, delay } from "rxjs/operators";

var source$ = interval(300).pipe(
take(5)
);

var example$ = source$.pipe(
delay(new Date(new Date().getTime() + 1000))
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

這好像也能用在預定某個日期,讓程式掛掉

delayWhen

delayWhen的作用跟delay很像,最大的差別是delayWhen可以影響每個元素,而且需要傳一個callback並回傳一個observable範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { interval, empty } from "rxjs";
import { take, delayWhen, delay } from "rxjs/operators";

var source$ = interval(300).pipe(
take(5)
);

var example$ = source$.pipe(
delayWhen(
x => empty().pipe(delay(100 * x * x)
)
)
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

這時我們的Marble Diagram

1
2
3
source$ : --0--1--2--3--4|
delayWhen(x => empty().pipe(delay(100 * x * x))
example$: --0---1----2------3-----4|

這裡傳進來的x就是source$送出的每個元素,這樣我們就能對每一個做延遲。

這裡我們用delay來做一個小功能,這個功能很簡單就是讓多張照片跟著滑鼠跑,但每張照片不能跑一樣快!

首先我們先泮備六張大頭照,並且寫進HTML

index.html

1
2
3
4
5
6
<img src="https://res.cloudinary.com/dohtkyi84/image/upload/c_scale,w_50/v1483019072/head-cover6.jpg" alt="">
<img src="https://res.cloudinary.com/dohtkyi84/image/upload/c_scale,w_50/v1483019072/head-cover5.jpg" alt="">
<img src="https://res.cloudinary.com/dohtkyi84/image/upload/c_scale,w_50/v1483019072/head-cover4.jpg" alt="">
<img src="https://res.cloudinary.com/dohtkyi84/image/upload/c_scale,w_50/v1483019072/head-cover3.jpg" alt="">
<img src="https://res.cloudinary.com/dohtkyi84/image/upload/c_scale,w_50/v1483019072/head-cover2.jpg" alt="">
<img src="https://res.cloudinary.com/dohtkyi84/image/upload/c_scale,w_50/v1483019072/head-cover1.jpg" alt="">

用CSS把img改成圓形,並加上邊框以及絕對位置

1
2
3
4
5
6
7

img{
position: absolute;
border-radius:50px;
border:3 px white solid;
transform: translate3d(0,0,0)
}

再來寫JS, 一樣第一步先抓DOM

1
var imgList = document.getElementsByTagName("img");

第二步建立observable

1
2
3
var movePos = fromEvent(document, "mousemove").pipe(
map(e => ({ x: e.clientX, y: e.clientY }))
);

第三步撰寫邏輯

1
2
3
4
5
6
7
8
9
10
11
function followMouse(DOMArr) {
const delayTime = 600;
DOMArr.forEach((item, index) => {
movePos.pipe(
delay(delayTime * (Math.pow(0.65, index) + Math.cos(index / 4)) / 2)
).subscribe(function (pos) {
item.style.transform = 'translate3d(' + pos.x + 'px, ' + pos.y + 'px, 0)';
});
});
}
followMouse(Array.from(imgList));

這裡我們把imgList從Collection轉成Array後傳入followMouse()並用forEach把每個img取出並利用index來達不同的delay時間,這個delay時間的邏輯大家可以自已想,不用跟我一樣,最後subscribe就完成了

stackblitz

throttle,debounce

在做效能優化時不可或缺的好工具

debounce

跟buffer,bufferTime一樣,Rx有debounce跟debounceTime一個是傳入observable另一個則是傳人毫秒,比較常用到的是debounceTime, 看範例

1
2
3
4
5
6
7
8
9
10
11
12
13
import { interval } from "rxjs";
import { take, debounceTime } from "rxjs/operators";

var source$ = interval(300).pipe(take(5));
var example$ = source$.pipe(debounceTime(1000));

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
// 4
// complete

這裡只印出4然後就結束了,因為debounce運作的方式是每次收到元素,他會先把元素cache住並等得一段時間,如果這段時間內已經沒有收到依何元素,則把元素送出; 如果這段時𫂾內又收到新的元素,則會把原本cache住的元素釋放掉並重新計時,不斷動覆。

以現在這個範例來講,我們每300毫秒就會送出一個數值,但我們的debounceTime是1000毫秒,也就是說每次debounce收到元素還等不到1000毫秒,就會收到下一個新元素,然後重新等待1000毫秒,如此重複直到第五個元素送出時,observable結束(complete)了,debounce就直接送出元素。

以Marble Diagram表示如下

1
2
3
source$ : --0--1--2--3--4|
debounceTime(1000)
example$: --------------4|

debounce會在收到元素後等待一段時間,這很適合用來處理間歇行為,間歇行為就是指這個行為是一段一段的,例如要做AutoComplete時,我們要打字搜尋不會一直不斷的打字,可以等我們停了一小段時間後再送出,才不會每打一固字就送一次request!

這裡舉一個簡單的例子,假設我們想要自動傳送使用者打的字到後端

1
2
3
4
5
6
7
8
9
10
11
12
import { fromEvent, empty } from "rxjs";
import { map } from 'rxjs/operators';

var searchInput = document.getElementById("searchInput");
var theRequestValue = document.getElementById("theRequestValue");
fromEvent(searchInput, "input").pipe(
map(e => e.target.value)
)
.subscribe((value) => {
theRequestValue.textContent = value;
//在這裏發request
});

如果用上面這段程式碼,就每打一個字就送一次request,當很多人在使用時就會對server造成很大的負擔,實際上我們只需要使用者最後打出來的文字就好了,不用每次都送,這時就能用debouceTime做優化。

1
2
3
4
5
6
7
8
9
10
11
12
import { fromEvent, empty } from "rxjs";
import { map, debounceTime } from 'rxjs/operators';

var searchInput = document.getElementById("searchInput");
var theRequestValue = document.getElementById("theRequestValue");
fromEvent(searchInput, "input").pipe(
debounceTime(300),
map(e => e.target.value)
).subscribe((value) => {
theRequestValue.textContent = value;
//在這裏發request
});

stackblitz

throttle

其本上每次看到debounce就會看到throttle,他們兩個的作用都是要降低事件的觸發頻率,但行為上有得大的不同。

跟debouce一樣RxJS有throttle跟throttleTime兩個方法,一個是傳入observable另一個是傳入毫秒,比較常用到的也是throttleTime, 直接看範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { interval } from "rxjs";
import { take, throttleTime } from "rxjs/operators";

var source$ = interval(300).pipe(take(5));
var example$ = source$.pipe(throttleTime(1000));

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});
// 0
// 4
// complete

跟debounce的不同是throttle會先開放送出元素,等到有元素被送出就會沈默一段時間,等到時間過了又會開放發送元素。

throttlw比較像是控制行為的最高頻率,也就是說如果我們設定1000毫秒,那該事件頻率的最大值就是每秒觸發一次不會再更快,debouce則比較像是必須等得的時間,要等到一定的時間過了才會收到元素。

throttlw更適合用在連續性行為,比如說UI動畫的運算過程,因為UI動畫是連續的,像我們之前在做拖拉時,就可以加上throttleTime(12)讓mousemove event不要發送的太快,避免畫面更新的速度跟不上樣式的切換速度。

瀏覽器有一個 requestAnimationFrame API 是專門用來優化 UI 運算的,通常用這個的效果會比 throttle 好,但並不是絕對還是要看最終效果。

RxJS 也能用 requestAnimationFrame 做優化,而且使用方法很簡單,這個部份會在 Scheduler 提到。

distinct,distinctUntilChanged

除了throttle和debounce兩個方法來做效能優化,其實還有另一個方法可以做效能的優化處理,那就是distinct

distinct

如果會下SQL指令的應該都對distinct不陌生,它能幫我們把相同值的資料濾掉只留一筆,RxJS裡的distinct也是相同的作用,看範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { interval, zip, from } from "rxjs";
import { distinct } from "rxjs/operators";

var source$ = from(["a", "b", "c", "a", "b"]);
zip(source$, interval(300), (x, y) => x);

var example$ = source$.pipe(
distinct()
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete") }
});
// a
// b
// c
// complete

如果有Marble Diagram表示如下

1
2
3
source$ : --a--b--c--a--b|
distince()
example$: --a--b--c------|

從上面的範例可以看得出來,當我們用distinct後,只要有重複出現的值就會被過濾掉。

另外我們可以傳入一個selector callback functon,這個callback function會傳入一個接收到的元素,並回傳我們真正希望比對的值,如下例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { interval, zip, from } from "rxjs";
import { distinct } from "rxjs/operators";
var source$ = from([{ value: "a" }, { value: "b" }, { value: "c" }, { value: "a" }, { value: "c" }]);
zip(source$, interval(300), (x, y) => x);
var example$ = source$.pipe(
distinct((x) => {
return x.value
})
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete") }
});
// {value: "a"}
// {value: "b"}
// {value: "c"}
// complete

這裡可以看到,因為source$送出的都是物件,而js物件比對是比對記憶體位置,所以在這個例子中這些物件永遠不會相等,但實際上我們想比對的是物件中的value, 這時我們就可以傳入selector callback,來選擇我們要比對的值。

實際上distinct()會在背地裡建立一個Set,當接收到元素時會先去判斷Set內是否有相同的值,如果有就不送出,如果沒有則存到Set並送出。所以記得盡量不要直接把distinct用在一個無限的observable裡,這樣很可能會讓Set越來越大,建議大家可以放第二個參數flushes或用distinctUntilChanged.

這裡指的 Set 其實是 RxJS 自己實作的,跟 ES6 原生的 Set 行為也都一致,只是因為 ES6 的 Set 支援程度還並不理想,所以這裡是直接用 JS 實作。

distinct可以傳入第二個參數flushed observable用來清除暫存的資料,例子如下(目前有問題)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { interval, zip, from } from "rxjs";
import { distinct } from "rxjs/operators";


var source$ = from(["a", "b", "c", "a", "c"]);
zip(source$, interval(300), (x, y) => x);
var flushes$ = interval(1300);
var example$ = source$.pipe(
distinct(null, flushes$)
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete") }
});

這裡我們用Marble Diagram比較好表示

1
2
3
4
source$ : --a--b--c--a--c|
flushed$: ------------0---...
distinct(null,fluehes$)
examples$:--a--b--c-----c|

其實flushes observable就是在送出元素時,會把distinct的暫存清空,所以之後的暫存就會從頭來過,這樣就不用擔心暫存的Set越來愈大的問題,但其實我們平常不太會用這樣的方式來處理,通常會用另一個方法distinctUntilChanged

distinctUntilChanged

distinctUntilChanged跟distinct一樣會把相同的元素過濾掉,但distinctUntilChanged只會跟最後一次送出的元素比較,不會每個都比,例子如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { interval, zip, from } from "rxjs";
import { distinctUntilChanged } from "rxjs/operators";

var source$ = from(['a', 'b', 'c', 'c', 'b']);
zip(interval(300), (x, y) => x);
var example$ = source$.pipe(
distinctUntilChanged()
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete") }
});
// a
// b
// c
// b
// complete

這裡distinctUntilChanged只會暫存一個元素,並在收到元素時跟暫存的元素比對,如果一樣就不送出,如果不一樣就色暫存的元素換成剛接收到的新元素並送出。

1
2
3
source$ : --a--b--c--c--b|
distinctUntilChanged()
example$: --a--b--c-----b|

從 Marble Diagram 中可以看到,第二個 c 送出時剛好上一個就是 c 所以就被濾掉了,但最後一個 b 則跟上一個不同所以沒被濾掉。

distinctUntilChanged 是比較常在實務上使用的,最常見的狀況是我們在做多方同步時。當我們有多個 Client,且每個 Client 有著各自的狀態,Server 會再一個 Client 需要變動時通知所有 Client 更新,但可能某些 Client 接收到新的狀態其實跟上一次收到的是相同的,這時我們就可用 distinctUntilChanged 方法只處理跟最後一次不相同的訊息,像是多方通話、多裝置的資訊同步都會有類似的情境。

catchError,retry,retryWhen,repeat

在dayrxjs.js的檔案,𫍇是錯誤處理(Error Hangling)的operators,錯誤處理是非同步行為中的一大難題,尤其有多個交錯的非同步行為時,更容易凸顯錯誤處理的困難。注意上版是catch

catchError

catchError是很常見的非同步錯諤處理方法,在RxJS中也能夠直接用catch來處理錯誤,在RxJS中的catch可以回傳一個observable來送出新的值,看範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { from, interval, zip, of } from "rxjs";
import { map, catchError } from "rxjs/operators";
var source$ = from(["a", "b", "c", "d", 2]);
zip(source$, interval(500), (x, y) => x);
var example$ = source$.pipe(
map(x => x.toUpperCase()),
catchError(error => of("h"))
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
//A
//B
//C
//D
//h
//complete

這個範例我們每隔500毫秒會送出一個字串(String)並用字串的方法toUpperCase()來把字串的英文字母改成大寫,過程中可能未知的原因送出了一個數值(Number)2導致發生例外(數值沒有toUpperCase的方法),這時我們在後面接的catchError就能抓到錯誤。

catchError可以回傳一個新的Observable,Promise,Array或何Iterable的物件,來傳送之後的元素。

以例子來說最後就會在送出X就結束,畫成Marble Diagram如下

1
2
3
4
5
source$ : ----a----b----c----d----2|
map(x=> x.toUpperCase())
----A----B----c----d----X|
catchError(error => of("h"))
example$: ----A----B----C----D----h|

可以看到,當錯誤發生後就會進到catchError並重新處理一個新的observable,我們可以利用這個新的observable來送出我們想送的值。

也可以在遇到錯誤後,讓observable結束如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { from, interval, zip, of, empty } from "rxjs";
import { map, catchError } from "rxjs/operators";
var source$ = from(["a", "b", "c", "d", 2]);
zip(source$, interval(500), (x, y) => x);
var example$ = source$.pipe(
map(x => x.toUpperCase()),
catchError(error => empty())
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
//A
//B
//C
//D
//complete

回傳一個empty的observable來直接結事(complete)

另外catchError的callback能接收第二個參數,這個參數會接收當前的observable,我們可以回傳當前的observable來做到重新執行,範例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { from, interval, zip, of, empty } from "rxjs";
import { map, catchError } from "rxjs/operators";
var source$ = from(["a", "b", "c", "d", 2]);
zip(source$, interval(500), (x, y) => x);
var example$ = source$.pipe(
map(x => x.toUpperCase()),
catchError((error, obs) => obs)
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

這裡可以看到我們直接回傳了當前的obserable(其實就是example)來重新執行,畫成Marble Diagram如下

1
2
3
4
5
source$ : ----a----b----c----d----2|
map(x => x.toUpperCase())
----A----B----C----D----X|
catchError((error, obs) => obs)
example$: ----A----B----C----D--------A----B----C----D--..

因為我們只是簡單的示範,所以這裡會一直無限循環,實務上通常會用在斷線重連的情境。

另上面的處理方式有一個簡化的寫法,叫做retry()

retry

如果我們想要一個observable發生錯誤時,重新嘗式就可以用retry這個𤆧法 ,跟我們前一個講範例的行為是一致

1
2
3
4
5
6
7
8
9
10
11
12
13
import { from, interval, zip, of, empty } from "rxjs";
import { map, retry } from "rxjs/operators";
var source$ = from(["a", "b", "c", "d", 2]);
zip(source$, interval(500), (x, y) => x);
var example$ = source$.pipe(
map(x => x.toUpperCase()),
retry()
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

通常這種無限的retry會㪜在即時同步的重新連接,讓我們在連線斷掉後,不斷的嘗式。另外我們也可以設定只嘗試幾次,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { from, interval, zip, of, empty } from "rxjs";
import { map, retry } from "rxjs/operators";
var source$ = from(["a", "b", "c", "d", 2]);
zip(source$, interval(500), (x, y) => x);
var example$ = source$.pipe(
map(x => x.toUpperCase()),
retry(1)
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
//A
//B
//C
//D
//A
//B
//C
//D
//Error: TypeError: x.toUpperCase is not a function

這裡我們對retry傳入一個數值1, 能夠讓我們只重複嘗試1次後送出錯誤,畫成Marble Diagram如下

1
2
3
4
5
source$ : ----a----b----c----d----2|
map(x => x.toUpperCase()),
----A----B----C----D----X|
retry(1)
example$: ----A----B----C----D--------A----B----C----D----X|

這種處理方式很適合用在Http request失敗的場景中,我們可以設定重新發幾次後,再秀出錯誤訊息

retyrWhen

RxJS還提供了另一種方法retryWhen,他可以把例外發生的元素放到一個observable中,讓我們可以直接操作這個observable,並等到這個observable操作完後再重新訂閱一次原來的observable。看範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { from, interval, zip, of, empty } from "rxjs";
import { map, retryWhen, delay } from "rxjs/operators";
var source$ = from(["a", "b", "c", "d", 2]);
zip(source$, interval(500), (x, y) => x);
var example$ = source$.pipe(
map(x => x.toUpperCase()),
retryWhen(errorObs => errorObs.pipe(delay(1000)))
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
//A
//B
//C
//D
//一秒
//A
//B
//C
//D

這裡retyrWhen我們傳入一個callback,這個callback有一個參數會傳入一個observable這個observable不是原本的observable(example), 而是例外事件送出的錯誤所組成的一個observable,我們可以對這個由錯誤所組成的observable做操作,等而這次的處理完成後就會重新訂閱我們原本的observable。

這個範例我們是把錯誤的observable送出錯誤延遲1秒,這會使後面重新訂閱動作延遲1秒才執行,Marble Diagram如下

1
2
3
4
5
source$ : ----a----b----c----d----2|
map(x => x.toUpperCase())
----A----B----C----D----X|
retryWhen(errorObs => errorObs.pipe(delay(1000)))
example$: ----A----B----C----D-------------------A----B----C----D----.....

從上圖可以看到後續動新訂閱的為就被延後山,但實務上我們不太會用retryWhen來做重新訂閱的延遲,通常是直接用catchError做到這件事。這裡只是為了示範retryWhen的行為,實務上我們通常會把retyrWhen拿來做錯誤通知或是例外收集範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { from, interval, zip, of, empty } from "rxjs";
import { map, retryWhen, delay } from "rxjs/operators";
var source$ = from(["a", "b", "c", "d", 2]);
zip(source$, interval(500), (x, y) => x);
var example$ = source$.pipe(
map(x => x.toUpperCase()),
retryWhen(errorObs => errorObs.pipe(map(err=> fetch('....'))))
);

example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

這裡的errorObs.pipe(map(err=> fetch('....')))可以把errorObs裡的每個錯誤變成API的發送,通常這裡的API會像是送訊息到公司的通訊頻道(Slack等等),這樣可以讓工程師馬上知道可服哪個API掛了,這樣我們就能即時地處理,

retryWhen實際上是在背地裡建立一個Subject並把錯誤放入,會在對這個Subject進行內部的訂閱,因為我們還沒有講到Subject的觀念,大舉可以先把它當作Observable就好了。另外記得這個observable預設是無限的,如果我們把它結束,原本的observable也會跟著結束。

repeat

我們有時候可能想要retry一直重複訂閱的效果,但沒有錯誤發生,這時就可以用repeat來做到這件事,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { from, interval, zip, of, empty } from "rxjs";
import { repeat } from "rxjs/operators";

var source$ = from(["a", "b", "c"]);
zip(interval(500), (x, y) => x);

var example$ = source$.pipe(repeat(1));
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
//a
//b
//c
//complete

畫成Marble Diagram如下

1
2
3
source$ : ----a----b----c|
repeat(1)
exapmle$: ----a----b----c|

我們可以不給參數讓它無限循環如下

1
2
3
4
5
6
7
8
9
10
11
12
import { from, interval, zip, of, empty } from "rxjs";
import { repeat } from "rxjs/operators";

var source$ = from(["a", "b", "c"]);
zip(interval(500), (x, y) => x);

var example$ = source$.pipe(repeat());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

這樣我們就可以做不斷重複的行為,這個可以在建立輪詢時使用,讓我們不斷地發request來更新畫面。

我們來看一個錯誤處理在實務應用中的小範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { from, interval, zip, of, empty } from "rxjs";
import { map, catchError, concat } from "rxjs/operators";
import { startWith } from "rxjs-compat/operator/startWith";

const title = document.getElementById('title');

var source$ = from(["a", "b", "c", "d", 2]);
zip(source$, interval(500), (x, y) => x).pipe(
map(x => x.toUpperCase())
// 通常 source 會是建立即時同步的連線,像是 web socket
);
var example$ = source$.pipe(
catchError((error, obs) => obs.pipe(
empty(),
startWith('連線發生錯誤: 5秒後重連'),
concat(obs.delay(5000))
))
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});

這個範例其實就是模仿在即時同步斷線特,利用catchErroy返品一個新的observable,這固observable會先送出錯誤訊息並且把原本的observable延遲5秒再做合併,雖然這只是一個模仿,但它清楚的展示了 RxJS 在做錯誤處理時的靈活性。

switchAll, mergeAll, concatAll

這三個operators都是用來處理Higher Order Observable。所謂的Higher Order Observable就是指一個Observable送出的元素還是一個Observable,就像是二維陣列一樣,一個陣列中的每個元素都是陣列。如果用泛型來表達就像是

1
Observable<Observable<T>>

通常我們需要的是第二層Observable送出的元素,所以我們希望可以把二維的Observable改成一維的,像是下面這樣

1
Observable<Observable<T>> => Observable<T>

其實想要做到這件事有三個方法switchAll、mergeAll、concatAll

concatAll

在簡易拖拉的範例時有講過這個operator,concatAll最動要的重點就是他會處理完前一個observable, 才會在處理下一個observable, 讓我們來看一個例子stackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { fromEvent,interval } from "rxjs";
import { map,concatAll} from 'rxjs/operators';

var click$ = fromEvent(document, "click");
var source$ = click$.pipe(map(e => interval(1000)));
var example$ = source$.pipe(concatAll());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});
// (點擊後)
// 0
// 1
// 2
// 3
// 4
// 5 ...

上面這段程式碼,當我們點擊畫面時就會開始送出數值,如果用Marble Diagram表示如下

1
2
3
4
5
6
7
8
click$  : ---------c-c------------------c--.. 
map(e => interval(1000))
source$ : ---------o-o------------------o--..
\ \
\ ----0----1----2----3----4--...
----0----1----2----3----4--...
concatAll()
example$: ----------------0----1----2----3----4--..

從Marble Diagram可以看得出來,當我們點擊一下click事件會被轉成一個observable而這個observable會每一秒送出一個遞增的數值,當我們用concatAll之後會把二維的observable攤平成一維的observable,但concatAll會一個一個處理,一定是等前一個observable完成(complete)才會處理下一個observable,因為現在送出observable是無限的永遠不會完成(complete),就導致他永遠不會處理第二個送出的observable!

再看一個例子stackblitz

1
2
3
4
5
6
7
8
9
10
11
import { fromEvent,interval } from "rxjs";
import { map,concatAll,take} from 'rxjs/operators';

var click$ = fromEvent(document, "click");
var source$ = click$.pipe(map(e => interval(1000).pipe(take(3))));
var example$ = source$.pipe(concatAll());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

現在我們把送出的observable限制只取前三個元素,用Marble Diagram表示如下

1
2
3
4
5
6
7
8
click$  : ---------c-c------------------c--.. 
map(e => interval(1000).pipe(take(3)))
source$ : ---------o-o------------------o--..
\ \ \
\ ----0----1----2| ----0----1----2
----0----1----2|
concatAll()
example$: ----------------0----1----2----0----1--..

這裡我們把送出的observable變成有限的,只會送出三個元素,這時就能看得出來concatAll不管兩個observable送出的時間多麼相近,一定會先處理前一個observable再處理下一個。

switchAll

switchAll同樣能把二維的observable攤平一維的,但他們在行為上有很大的不同,我們來看下面這個範例stackblitz.

1
2
3
4
5
6
7
8
9
10
11
import { fromEvent,interval } from "rxjs";
import { map,switchAll,take} from 'rxjs/operators';

var click$ = fromEvent(document, "click");
var source$ = click$.pipe(map(e => interval(1000)));
var example$ = source$.pipe(switchAll());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

用Marble Diagram表示如下

1
2
3
4
5
6
7
8
click$  : ---------c-c------------------c--.. 
map(e => interval(1000))
source$ : ---------o-o------------------o--..
\ \ \----0----1--..
\ ----0----1----2----3----4--...
----0----1----2----3----4--...
switch()
example$: ----------------0----1----2--------0----1--..

switch最重要的就是他會在新的observable送出後直接處理新的observable不管前一個observable是否完成,每當有新的observable送出就會直接把舊的observable退訂(unsubscribe), 永遠只處理最新的observable!

所以在這上面的Marble Diagram可以看得出來第一次送出的observable跟第二次送出的observable時間點太近,導致第一個observable還來不及送出元素就直接被退訂了,當下一次送出observable就又會把前一次的observable退訂。

margeAll

它會把二維的observable轉成一維的,並且能夠同時處理所有的observable,讓我們來看這個範例stackblitz

1
2
3
4
5
6
7
8
9
10
11
import { fromEvent,interval } from "rxjs";
import { map,mergeAll,take} from 'rxjs/operators';

var click$ = fromEvent(document, "click");
var source$ = click$.pipe(map(e => interval(1000)));
var example$ = source$.pipe(mergeAll());
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

上面這段程式碼用Marble Diagram表示如下

1
2
3
4
5
6
7
8
click$  : ---------c-c------------------c--.. 
map(e => interval(1000))
source$ : ---------o-o------------------o--..
\ \ \----0----1--..
\ ----0----1----2----3----4--...
----0----1----2----3----4--...
switch()
example$: ----------------00---11---22---33---(04)4--..

從Marble Diagram可以看出來,所有的observable是並行(Parallel)處理的,也就是說mergeAll不會像switchAll一樣退訂(unsubscribe)原先的observable而是並行處理多個observable。以範例來說,當我們點擊越多下,最後送出的頻率就會越快。另外mergeAll可以傳入一個數值,這個數值代表他可以同時處理的observable數量,來看一個例子

1
2
3
4
5
6
7
8
9
10
11
import { fromEvent,interval } from "rxjs";
import { map,mergeAll,take} from 'rxjs/operators';

var click$ = fromEvent(document, "click");
var source$ = click$.pipe(map(e => interval(1000).pipe(take(3))));
var example$ = source$.pipe(mergeAll(2));
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

這裡我們送出的observable改成取前三個,並且讓mergeAll最多只能同時處理2個observable, 用Darble Diagram表示如下

1
2
3
4
5
6
7
8
9
click$  : ---------c-c------------------c--.. 
map(e => interval(1000).pipe(take(3))
source$ : ---------o-o------------------o--..
\ \ \----0----1----2|
\ ----0----1----2|
----0----1----2|
mergeAll(2)
example$: ----------------00---11---22---0----1----2..

當mergeAll傳入參數後,就會等處理中的其中一個observable完成,再去處理下一個。以我們的例子來說,前面兩個observable可以被並行處理,但第三個observable必須等到第一個observable束後,才會開始。

我們可以利用這個參數來決定要同時處理幾個observable。如果我們傳入1其行為就會跟concatAll是一模一樣的。

switchMap, mergeMap, concatMap

這三個operators在很多的RxJS相關的library的使用範例上都會看到。

concatMap

concatMap其實就是map加上concatAll的簡化寫法,看範例stackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
import { fromEvent, interval } from "rxjs";
import { map, concatAll, take } from 'rxjs/operators';

var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
map(e => interval(1000).pipe(take(3))),
concatAll()
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

上面這個範例就可以簡化成

1
2
3
4
5
6
7
8
9
10
11
12
import { fromEvent, interval } from "rxjs";
import { concatMap, take } from 'rxjs/operators';

var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
concatMap(e => interval(100).pipe(take(3)))
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

注意時間有改變, 前後兩個行為是一致,記得concatMap也會先處理前一個送出的observable在處理下一個observable,畫成Marble Diagram如下

1
2
3
source$ : -----------c--c------------------...
concatMap(e => interval(100).pipe(take(3)))
example$: -----------0-1-2-0-1-2-----------...

這樣的行為也很常被用在發送request如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { fromEvent, from } from "rxjs";
import { concatMap, take } from 'rxjs/operators';
function getPostData(){
return fetch("https://jsonplaceholder.typicode.com/posts/1").then(res => res.json());
}
var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
concatMap(e => from(getPostData()) )
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

這裡我們每點擊一下畫面就會送出一個HTTP request,如果我們快速的連續點擊,可以在開發工具的network看到每個request是等到前一個request完成才會送出下一個request如下圖

從newwork的圖形可以看得出來,第二個request的發送時間是接在第一個request之後的,我們可以確保每一個request會等前一個request完成才做處理。

concatMap還有第二個參數是一個selector callback, 這個callback會傳入四個參數,分別是

  1. 外部observable送出的元素

  2. 內部observable送出的元素

  3. 外部observable送出的元素的index

  4. 內部observable送出的元素三index

回傳值我們想要的值,範例如下stackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { fromEvent, from } from "rxjs";
import { concatMap, take } from 'rxjs/operators';
function getPostData(){
return fetch("https://jsonplaceholder.typicode.com/posts/1").then(res => res.json());
}
var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
concatMap(e => from(getPostData()),(e,res,eIndex,resIndex)=> res.title )
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

這個範例的外部observable送出的元素就是click event物件,內部observable送出的元素就是response物件,這裡我們回傳response物件的title屬性,這樣一來我們就可以直接到到title, 這個方法很適合在response要選取的值跟前一個事件或順位(index)相關時。

switchMap

switchMap其實就是map加上switchAll簡化的寫法,如下stackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
import { fromEvent, interval } from "rxjs";
import { map, switchAll, take } from 'rxjs/operators';

var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
map(e => interval(1000).pipe(take(3))),
switchAll()
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

上面的程式碼可以簡化成stackblitz

1
2
3
4
5
6
7
8
9
10
11
12
import { fromEvent, interval } from "rxjs";
import { map, switchMap, take } from 'rxjs/operators';

var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
switchMap(e => interval(100).pipe(take(3))),
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

畫成Marble Diagram表示如下

1
2
3
source$ : -----------c--c-----------------...
switchMap(e => interval(100).pipe(take(3)))
example$: -----------0--0-1-2-------------...

只要注意一個重點switchMap會在下一個observable被送出後直接退訂前一個未處理完的observable。

另外我們也可以把switchMap用在發送HTTP requeststackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { fromEvent, from } from "rxjs";
import { switchMap, take } from 'rxjs/operators';
function getPostData(){
return fetch("https://jsonplaceholder.typicode.com/posts/1").then(res => res.json());
}
var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
switchMap(e => from(getPostData()) )
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

如果我們快速的連續點擊五下,可以在開發工具的network看到每個request會在點擊時發送,雖然我們發送了多個rquest但最後真正印出來的log只會有一個, 代表前面發送的request已經不會造成任何的side-effect了,這個很適合用在只看最後一次request的情境,比如說自動完成(auto complete),我們只需要顯示使用者最後一次打在畫面上的文字,來做建議選項而不用每一次的。

switchMap跟concatMap一樣有第二個參數selector callback可用來回傳我們要的值,這部分的行為跟concatMap是一樣的,這裡就不再贅述。

mergeMap

mergeMap其實就是map加上mergeAll簡化的寫法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
import { fromEvent, interval } from "rxjs";
import { map, mergeAll, take } from 'rxjs/operators';

var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
map(e => interval(1000).pipe(take(3))),
mergeAll()
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

上面的程式碼可以簡化成

1
2
3
4
5
6
7
8
9
10
11
12
import { fromEvent, interval } from "rxjs";
import { map, mergeMap, take } from 'rxjs/operators';

var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
mergeMap(e => interval(100).pipe(take(3)))
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

畫成Marble Diagram表示

1
2
3
source$ : -----------c-c------------------...
mergeMap(e => interval(100).pipe(take(3)))
example$: -------------0-(10)-(21)-2------...

記得mergeMap可以並行處理多個observable,以個例子來說當我們快速按兩下,元素發送的時間點是有機會重疊的。

另外我們也可以把mergeMap用在發送HTTP requeststackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { fromEvent, from } from "rxjs";
import { mergeMap, take } from 'rxjs/operators';
function getPostData(){
return fetch("https://jsonplaceholder.typicode.com/posts/1").then(res => res.json());
}
var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
mergeMap(e => from(getPostData()) )
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

如果我們快速的連續點擊五下,大家可以在開發者工具的 network 看到每個 request 會在點擊時發送並且會 log 出五個物件

mergeMap也能傳入第二個參數selector callback,這個selector callback跟concatMap第二個參睥也是完全一樣的,但mergeMap的重點是我們可以傳入第三個參數,來限制並行處理的數量stackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { fromEvent, from } from "rxjs";
import { mergeMap, take } from 'rxjs/operators';
function getPostData() {
return fetch("https://jsonplaceholder.typicode.com/posts/1").then(res => res.json());
}
var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
mergeMap(e => from(getPostData()), (e, res, eIndex, resIndex) => res.title, 3)
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

這裡我們傳入 3 就能限制,HTTP request 最多只能同時送出 3 個,並且要等其中一個完成在處理下一個

switchMap, mergeMap, concatMap

這三個 operators 還有一個共同的特性,那就是這三個 operators 可以把第一個參數所回傳的 promise 物件直接轉成 observable,這樣我們就不用再用 from 轉一次,如下stackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { fromEvent, from } from "rxjs";
import { concatMap, take } from 'rxjs/operators';
function getPersonData() {
return fetch("https://jsonplaceholder.typicode.com/posts/1").then(res => res.json());
}
var source$ = fromEvent(document, "click");
var example$ = source$.pipe(
concatMap(e => getPersonData())//直接回傳 promise 物件
);
example$.subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log("Error: " + err); },
complete: () => { console.log("complete"); }
});

至於在使用上要如何選擇這三個operators?其實都還是看使用情境而定,這裡簡單列一下大部分的使用情境

  • concatMap用在可以確定內部的observable結束時間比外部observable發送時間來快的情境。並且不希望有任何並行處理行為,適合少數要一次一次完成到底的UI動畫或特別的HTTP request行為。

  • switchMap用在只要最後一次行為的結果,適合絕大多數的使用情境。

  • mergeMap用在並行處理多個observable,適合需要並行處理的行為,像是多個I/O的並行處理。

    建議初學者不確定選哪一個時,使用 switchMap

在使用 concatAll 或 concatMap 時,請注意內部的 observable 一定要能夠的結束,且外部的 observable 發送元素的速度不能比內部的 observable 結束時間快太多,不然會有 memory issues

簡易Auto Complete實作

RxJS的經典範例-自動完成(Auto Complete),自動完成在實數上的應用非常廣泛,幾乎隨處可見這樣的功能,只履是跟表單、搜尋相關的都會看到。雖然是個很常見的功能,但多數工程師都只是直接套套件來完成,很少有人會自已從頭到尾把完整的邏輯寫一次。如果有自已實作過這個功能的工程師,應該就會知道這個功能在實作的過程中很多細節會讓程式碼變的非常複雜,像是要如何取消上一次發送出去的request、要如何優化請求次數…等等,這些小細節都會讓程式碼變的非常複雜且很難維護。

需求分析

首先我們會有一個尋框(input#search),當我們在上面打字並停頓超100毫秒就發送HTTP Request來取得建議選項並顯示在收尋框下方(ul#suggest-list),如果使用者在前一次發送的請求還沒有回來就打了下一個字,此時前一個發送的請求就要捨棄掉,當建議選項顯示之後可以用滑鼠點擊取建議選項代替搜尋框的文字。

上面的敘述可以拆分成以下幾個步驟

  • 準備input#search以及ul#suggest-list的HTML與CSS

  • 在input#search輸入文字時,等得100毫秒再輸入,就發送HTTP Request

  • 當Response還沒回來時,使用者又輸入了下一個文字就捨棄前一次的輸入並再發送一次新的Request

  • 接受到Response之後顯示建議選項

  • 滑鼠點擊後取代input#search的文字

stackblitzhtml 中,首先在HTML裡有一個input(#search),這個input(#search)就是要用來輸入的欄位,它下方有一固ul(#suggest-list),則是放建議選項的地方

1
2
3
4
5
<div class="autocomplete">
<input class="input" type="search" id="search" autocomplete="off">
<ul id="suggest-list" class="suggest">
</ul>
</div>

css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

html, body {
height: 100%;
background-color: white;
padding: 0;
margin: 0;
}

.autocomplete {
position: relative;
display: inline-block;
margin: 20px;
}

.input {
width: 200px;
border: none;
border-bottom: 1px solid black;

padding: 0;
line-height: 24px;
font-size: 16px;
&:focus {
outline: none;
border-bottom-color: blue;
}
}

.suggest {
width: 200px;
list-style: none;
padding: 0;
margin: 0;
-webkit-box-shadow: 0 2px 4px rgba(0,0,0,0.2);
li {
cursor: pointer;
padding: 5px;
&:hover {
background-color: lightblue;
}
}
}

javascript中已經寫好了要發送API的url跟方法getSuggestList,接著就開始實作自動完成的效果吧!

1
2
3
4
const url = 'https://zh.wikipedia.org/w/api.php?action=opensearch&format=json&limit=5&origin=*';

const getSuggestList = (keyword) => fetch(url + '&search=' + keyword, { method: 'GET', mode: 'cors' }).then(res => res.json())

第一步取得需要的DOM物件

這裡我們會用到#search以及#suggest-list這兩個DOM

1
2
const searchInput= document.getElementById("search");
const suggestList= document.getElementById("suggest-list");
第二步,建立所需的Observable

這裡我們要監聽收尋欄位的input事件,以及建議選項的點擊事件

1
2
const keyword$ = fromEvent(searchInput,"input");
const selectItem$= fromEvent(suggestList,"click");
第三步,撰寫程式邏輯

每當使用者輸入文字就要發送HTTP request並且有新的值被輸入後就捨棄前一次發送的,所以這裡用switchMap

1
2
3
keyword$.pipe(
switchMap(e => getSuggestList(e.target.value))
);

這裡我們先試著訂閱,看一下API會回傳什麼樣的資料

1
2
3
4
keyword$.pipe(
switchMap(e => getSuggestList(e.target.value))
)
.subscribe(console.log);

在search欄位亂打幾個字

可以在console看到資料長相這樣,會回傳一個陣列帶有四個元素,其中第一個元素是我們輸入的值,第二個元素才是我們要的建議選項清單。

所以我們要取的是response陣列的第二的元素,用switchMap的第二個參睥來選取我們要的

1
2
3
4
keyword$.pipe(
switchMap(e => getSuggestList(e.target.value),(e,res)=>res[1])
)
.subscribe(console.log);

寫一個render方法,把陣列轉成li並寫入suggestList

1
2
3
4
5
const render = (suggestArr=[])=>{
suggestList.innerHTML = suggestArr
.map(item => "<li>"+ item+"</li>")
.join("")
}

這時我們就可用render方法把取得的陣列傳入

1
2
3
4
5
6
7
8
9
10
const render = (suggestArr = []) => {
suggestList.innerHTML = suggestArr
.map(item => "<li>" + item + "</li>")
.join("")
}

keyword$.pipe(
switchMap(e => getSuggestList(e.target.value),(e,res)=>res[1])
)
.subscribe(list => render(list));

如此一來我們打字就能看到結果出現在input下方了

只是目前還不能點選,先讓我們來做點選的功能,這裡點選的功能我們需要用到delegation event的小技巧,利用ul的click事件,來篩選是否點到了li,如下

1
2
3
selectItem$.pipe(
filter(e => e.target.matches("li"))
);

上面我們利用DOM物件的matches方法(裡面的字串放css的selector)來過濾出有點擊到li的事件,再用map轉出我們要的值並寫入input。

1
2
3
4
5
selectItem$.pipe(
filter(e => e.target.matches("li")),
map(e => e.target.innerText),
)
.subscribe(text => searchInput.value = text)

現在我們就能點擊建議清單了,但是點擊後清單沒有消失,這裡我們要在點擊後重新render, 所以把上面的程式碼改一下

1
2
3
4
5
6
7
8
selectItem$.pipe(
filter(e => e.target.matches("li")),
map(e => e.target.innerText),
)
.subscribe(text => {
searchInput.value = text;
render();
});

這樣一來我們就完成最基本的功能了。

還記得我們前面說每次打完字要等待100毫秒在發送request嗎?這樣能避免過多的request發送,可以降低server的負載也會有比較好的使用者體驗,要做到這件使很簡單只要加上debounceTime(100)就完成了

1
2
3
4
5
keyword$.pipe(
debounceTime(100),
switchMap(e => getSuggestList(e.target.value), (e, res) => res[1])
)
.subscribe(list => render(list));

當然這個數值可以依照需求或是請UX針對這個細節作調整。

用了不到30行的程式碼就完成了autocomplete的基本功能,當我們能夠自已從頭到尾的完成這樣的功能,在面對個各種不同的需求,我們就能很方更的針對需求作調整,而不受到套件的牽制!比如說我們希望使用者打了2個字以上在發送request,這時我們只要加上一行filter就可以了

1
2
3
4
5
6
keyword$.pipe(
filter(e=> e.target.value.length >2),
debounceTime(100),
switchMap(e => getSuggestList(e.target.value), (e, res) => res[1])
)
.subscribe(list => render(list));

又或者網站的使用量很大,可能API在量大的時候會回傳失敗,主管希望可以在API失敗的時候重新嘗試3次,我們只要加個retry(3)就完成了。

1
2
3
4
5
6
keyword$.pipe(
filter(e => e.target.value.length > 2),
debounceTime(100),
switchMap(e => from(getSuggestList(e.target.value)).pipe(retry(3)), (e, res) => res[1])
)
.subscribe(list => render(list));

大家會發現我們的靈活度變的非常高,又同時兼顧了程式碼的可讀性,短𠞽的幾行程式碼就完成了一個複雜的需求,這就是RxJS的魅力啊

window, windowToggle

上面有提到能把Higher Order Observable轉成一般的Observable的operators,今天我們要講能夠把一般的Observable轉成Hight Order Observable的operators。其實前端不太有機會用到這類型的Operators,都是在比較特殊的需求下才會看到,但還是會有遇到的時候。

window

window是一整個家族總共有五個相關的operators

  • window

  • windowCount

  • windowTime

  • windowToggle

  • windowWhen

我們只介紹window跟windowToggle這兩個方法,其他三個的用法相對都簡單很多,大家如果有需要可以再自行到官網查看。

window很類似buffer可以把一段時間內送出的元素拆出來,只是buffer是把元素拆分到陣列中變成

1
Observable<T> => Observable<Array<T>>

而window則是會把元素拆分出來放到新的observable變成

1
Observable<T> => Observable<Observable<T>>

buffer是把拆分出來的元素放到陣列並送出陣列;window是把拆分出來的元素放到observable並送出observable來𢒆一個例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { fromEvent, interval } from "rxjs";
import { switchAll,window } from 'rxjs/operators';
var click$= fromEvent(document,"click");
var source$ = interval(1000);
var example$= source$.pipe(window(click$));

example$.pipe(switchAll())
.subscribe(console.log);
// 0
// 1
// 2
// 3
// 4
// 5 ...

首先window要傳入一個observable,每當這個observable送出元素時,就會把正在處理的observable所送出的元素放到新的observable中並送出,這裡看Marble Diagram會比較好解䆁

1
2
3
4
5
6
7
8
click$ : -----------c----------c------------c--
source$: ----0----1----2----3----4----5----6---..
window(click$)
example$:o----------o----------o------------o--
\ \ \
---0----1-|--2----3--|-4----5----6|
switchAll()
: ----0----1----2----3----4----5----6---...

這裡可看到example$變成發送observable會在每次click事件發送出來後結束,並繼續下一個observable,這裡我們用switchAll把它攤平。

當然這固範例只是想單純的表達widow的作用,沒有什麼太大的意義,實務上window會搭配其他的operators使用,例如我們想計算一秒鐘內觸發了幾次click事件stackblitz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var click$= fromEvent(document,"click");
var source$ = interval(1000);
var import { fromEvent, interval } from "rxjs";
import { map, switchAll, window, count } from 'rxjs/operators';
var click$ = fromEvent(document, "click");
var source$ = interval(1000);
var example$ = click$.pipe(window(source$));

example$.pipe(
map(count()),
switchAll()
)
.subscribe(console.log);= click$.pipe(window(source$));

example$.pipe(
map(count())
switchAll()
)
.subscribe(console.log);

注意這裡我們把source$跟click$對調了,並用到了observable的一個方法count(),可以用來取得observable總共送出了幾個元素,用Marble Diagram表示如下

1
2
3
4
5
6
7
8
9
10
11
source$ : ---------0---------1---------2--...
window(source$)
click$ : o--------o---------o---------o--..
\ \ \ \
-cc---cc|---c-c---|---------|--..
map(count())
: o--------o---------o---------o--
\ \ \ \
-------4|--------2|--------0|--..
switchAll()
: ---------4---------2---------0--...

從Marble Diagram中可以看出來,我們把部分元素放到新的observable中,就可以利用Observable的方法做更靈活的操作

windowToggle

windowToggle不像window只能控制內部observable的結束,windowToggle可以傳入兩個參數,第一個是開始的observable,第二個是一個callback可以回傳一個結束的observable,看範例stackblitz

1
2
3
4
5
6
7
8
9
10
var source$= interval(1000);
var mouseDown$= fromEvent(document, "mousedown");
var mouseUp$= fromEvent(document, "mouseup");

var example$ = source$.pipe(
windowToggle(mouseDown$,()=> mouseUp$),
switchAll()
);

example$.subscribe(console.log);

一樣用 Marble Diagram 會比較好解釋

1
2
3
4
5
6
7
8
9
10
11
12
source$   : ----0----1----2----3----4----5--...

mouseDown$: -------D------------------------...
mouseUp$ : ---------------------------U----...

windowToggle(mouseDown, () => mouseUp)

: -------o-------------------------...
\
-1----2----3----4--|
switchAll()
example$ : ---------1----2----3----4---------...

從 Marble Diagram 可以看得出來,我們用 windowToggle 拆分出來內部的 observable 始於 mouseDown 終於 mouseUp。

groubBy

一個實務上比較常用的operators-groupBy, 它可以幫我們把相同條𤗣的元素拆分成一個Observable,其實就跟平常在下SQL是一樣的概念,我們先來看個例子stackblitz

1
2
3
4
5
6
7
8
9
import { fromEvent, interval } from "rxjs";
import { map, switchAll, windowToggle, count,take ,groupBy} from 'rxjs/operators';
var source$ =interval(300).pipe(take(5));
var example$ = source$.pipe(
groupBy(x => x % 2)
);
example$.subscribe(console.log);
// GroupObservable { key: 0, ...}
// GroupObservable { key: 1, ...}

上面的例子,我們傳入了一個callback function並回傳groupBy的條件,就能區分每個元素到不同的Observable中,用Marble Diagram表示

1
2
3
4
5
6
source$ : ---0---1---2---3---4|
groupBy(x => x % 2)
example$: ---o---o------------|
\ \
\ 1-------3----|
0-------2-------4|

在實務上,我們可以拿groupBy做完元素四區分後,再對inner Observable操作,例如下面這個例子我們將每個人的分數作加總再送出stackblitz.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { from, interval } from "rxjs";
import { zip, map, switchAll, mergeAll, count, take, groupBy, reduce } from 'rxjs/operators';
var people = [
{ name: 'Anna', score: 100, subject: 'English' },
{ name: 'Anna', score: 90, subject: 'Math' },
{ name: 'Anna', score: 96, subject: 'Chinese' },
{ name: 'Jerry', score: 80, subject: 'English' },
{ name: 'Jerry', score: 100, subject: 'Math' },
{ name: 'Jerry', score: 90, subject: 'Chinese' },
];
var source$ = from(people);
zip(source$, interval(300), (x, y) => x);

var example$ = source$.pipe(
groupBy(person => person.name),
map(group => group.pipe(
reduce((acc, curr) => ({
name: curr.name,
score: curr.score + acc.score
}))
)),
mergeAll()
);
example$.subscribe(console.log);
// { name: "Anna", score: 286 }
// { name: 'Jerry', score: 270 }

這裡我們範例是想把 Jerry 跟 Anna 的分數個別作加總,畫成 Marble Diagram 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
source$ : --o--o--o--o--o--o|

groupBy(person => person.name)

: --i--------i------|
\ \
\ o--o--o|
o--o--o--|

map(group => group.pipe(reduce(...)))

: --i---------i------|
\ \
o| o|

mergeAll()
example$: --o---------o------|

深入Observable

以上將大部分的operators都介紹完了,沒有機會好好的解䆁Observable的operators運作方式,一開始是以陣列(Array)的operators(map,filter,concatAll)作為切入點,在學習observable時會更容更接受跟理解,但實際上observable的operators跟陣列的有很大的不同,主要的差異有兩點

  1. 延遲運算

  2. 漸進式取值

延遲運算

延遲運算很好理解,所有Observable一定會等到訂閱後才開始對元素做運算,如果沒有訂閱就不會有運算的行為

1
2
var source$ = from([1,2,3,4,5]);
var example$ = source$.pipe(map(x => x+1));

上面這段程式因為Observable還沒有訂閱,所以不會真的對元素做運算,這跟陣列的操作不一樣,如下

1
2
var source = [1, 2, 3, 4, 5];
var example = source.map(x => x + 1);ty

上面這段程式執行完,example就已經取得所有元素的返回值了。

漸進式取值

陣列的operators都必須完整的運算出每個元素的返回值並組成一個陣列,再做下一個operator的運算,我們看下面這段程式碼

1
2
3
4
var source =p[1,2,var source = [1, 2, 3];
var example = source
.filter(x => x % 2 === 0)// 這裡會運算並返回一個完整的陣列
.map(x => x + 1);// 這裡也會運算並返品一個完整的陣列]

上面這段程式碼,有注意到source.filter(...)就會返回整個新陣列,再接下一個operator又會再返回一個新的陣列,這一點其實在我們實作map跟filter時就能觀察到

1
2
3
4
5
6
7
Array.prototype.map = function(callback) {
var result = []; // 建立新陣列
this.forEach(function(item, index, array) {
result.push(callback(item, index, array))
});
return result; // 返回新陣列
}

每一次的operator的運算都會建立一個新的陣列,並在每個元素都運算完後返回這個新陣列。

Opservable operator的運算方式跟陣列的是完全的不同,雖然Obserbale的operator也都會回傳一個新的observable,但因為元素是漸進式取得的關系,所以每次的運算是一個元素運算到底,而不是運算完全部的元素再返回。

1
2
3
4
5
6
var source$ = from([1, 2, 3, 4, 5, 6, 7, 8]);
var example$ = source$.pipe(
filter(x => x === 2),//
map(x => x + 1),
);
example$.subscribe(console.log);

注意目前測試x % 2 ===2 有問題,所以以此來代替

上面這段程式碼運行的方式是這樣的

  1. 送出1到filter被過濾掉

  2. 送出2到filter在被送到map轉成3,送到observeconsole.log印出

  3. 送出3到filter被過濾掉

每個元素送出後就是運算到底,在這個過程中不會等待其他的元素運算,這就就是漸進式取值的特性,在提到Iterator跟Observer時,就特別強調這兩個Pattern的共同特性是漸進式值,而我們在實作Iteraotr的過程中其實就能看出這個特性的運作方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class IteratorFromArray {
constructor(arr) {
this._array = arr;
this._cursor = 0;
}

next() {
return this._cursor < this._array.length ?
{ value: this._array[this._cursor++], done: false } :
{ done: true };
}

map(callback) {
const iterator = new IteratorFromArray(this._array);
return {
next: () => {
const { done, value } = iterator.next();
return {
done: done,
value: done ? undefined : callback(value)
}
}
}
}
}

var myIterator = new IteratorFromArray([1,2,3]);
var newIterator = myIterator.map(x => x + 1);
newIterator.next(); // { done: false, value: 2 }

上面這段程碼是一個非常簡單的示範,但可以𢒆得出來每一次map雖然都會返回一個新的operator,但實際上在做元素運算時,因為漸進式的特性會使一個元素運算到底,Observable也是相同的概念。

漸進式取值的靣念在Observable中其實非常重要,這個特性也使得Observable相較於Array的operator在做運算時來的高效很多,尤其是在處理大量資料的時候會非常明顯!

什麼是Subject?

Subject基本觀念

我們之前的範例,每個observable都只訂閱一次,而實際上observable是可以多次訂閱的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { interval } from "rxjs";
import { take } from "rxjs/operators";
var source$ = interval(1000).pipe(take(3));
var observerA$ = {
next: value => console.log("A next:" + value),
error: error => console.log("A error: " + error),
complete: () => console.log("A complete")
};

var observerB$ = {
next: value => console.log("B next:" + value),
error: error => console.log("B error: " + error),
complete: () => console.log("B complete")
}

source$.subscribe(observerA$);
source$.subscribe(observerB$);
// "A next: 0"
// "B next: 0"
// "A next: 1"
// "B next: 1"
// "A next: 2"
// "A complete!"
// "B next: 2"
// "B complete!"

上面這段程式碼,分別用observerA$與observerB$訂閱了source$, 從log可以看出來observerA$跟observerB$都各自收到了元素,但請記得這兩個observer其實是分開執行的也就是說他們是完全獨立的,我們把observerB$延遲訂閱來證明看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { interval } from "rxjs";
import { take } from "rxjs/operators";
var source$ = interval(1000).pipe(take(3));
var observerA$ = {
next: value => console.log("A next:" + value),
error: error => console.log("A error: " + error),
complete: () => console.log("A complete")
};

var observerB$ = {
next: value => console.log("B next:" + value),
error: error => console.log("B error: " + error),
complete: () => console.log("B complete")
}

source$.subscribe(observerA$);
setTimeout(() => {
source$.subscribe(observerB$);
}, 1000);
//A next:0
//A next:1
//B next:0
//A next:2
//A complete
//B next:1
//B next:2
//B complete

這裡我們延遲一秒再用observerB$訂閱,可以從log中看出1秒後observerA$已經印到了1,這時observerB$開始印卻是從0開始,而不是接著observerA$的進度,代表這兩次的訂閱是完全分開來執行的、或者說是每次的訂閱都建立了一個新一執行。

這樣的行為在大部分的情境下適用,但有些案例下我們會希望第二次訂閱source$不會從頭開始接收元素,而是從第一次訂閱到當前處理的元素開始發送,我們把這種處理方式稱為組播(multicast),那我們要如何做到組播呢?

手動建立subject

或許已經有讀者想到解法了,其實我們可以建立一個中間人來訂閱source再由中間人轉送資料出去,就可以達到我們想要的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { interval } from "rxjs";
import { take } from "rxjs/operators";
var source$ = interval(1000).pipe(take(3));
var observerA$ = {
next: value => console.log("A next:" + value),
error: error => console.log("A error: " + error),
complete: () => console.log("A complete")
};

var observerB$ = {
next: value => console.log("B next:" + value),
error: error => console.log("B error: " + error),
complete: () => console.log("B complete")
}

var subject$ = {
observers: [],
addObserver: function (observer) {
this.observers.push(observer);
},
next: function (value) {
this.observers.forEach(o => o.next(value))
},
error: function (error) {
this.observer.forEach(o => o.error(error));
},
complete: function () {
this.observers.forEach(o => o.complete());
}
};
subject$.addObserver(observerA$);
source$.subscribe(subject$);

setTimeout(() => {
subject$.addObserver(observerB$);
}, 1000);
//A next:0
//A next:1
//B next:1
//A next:2
//B next:2
//A complete

從上面的程式碼可以看到,我們先建立了一個物件叫subject,這個物件具備observer所有的方法(next,error,complete), 並且還能addObserver把observer加到內部的清㽞中,每當有值送出就會遍歷清單中的所有observer並把值再次送出,這樣一來不管多久之後加進來的observer,都會是從當前處理到的元素接續往下走,就像範例中所示,我們用subject$訂閱source並把observerA$加到subject$中,一秒後再把observerB$加到subject$,這時就可以看到observerB$是直接接教1開始,這就是組播(multicast)的行為。

讓我們把subject$的addObserver改名成subscribe如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var subject$ = {
observers: [],
subscribe: function (observer) {
this.observers.push(observer);
},
next: function (value) {
this.observers.forEach(o => o.next(value))
},
error: function (error) {
this.observer.forEach(o => o.error(error));
},
complete: function () {
this.observers.forEach(o => o.complete());
}
};

subject其實𣄵是用了Observer Pattern。但這邊為了不履混淆Observer Patter跟RxJS的observer就不再內文提及。

雖然上面是我們自已手寫的subject,但運作方式跟RxJS的Subject實例是幾乎一樣的,我們把前面的程式碼改成RxJS提供的Subject試試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { interval, Subject } from "rxjs";
import { take } from "rxjs/operators";
var source$ = interval(1000).pipe(take(3));
var observerA$ = {
next: value => console.log("A next:" + value),
error: error => console.log("A error: " + error),
complete: () => console.log("A complete")
};

var observerB$ = {
next: value => console.log("B next:" + value),
error: error => console.log("B error: " + error),
complete: () => console.log("B complete")
}

var subject$ = new Subject();
subject$.subscribe(observerA$);
source$.subscribe(subject$);

setTimeout(() => {
subject$.subscribe(observerB$);
}, 1000);
//A next:0
//A next:1
//B next:1
//A next:2
//B next:2
//A complete
//B complete

大家會發現使用方法跟前面是相凹的,建立一個subject$先拿去訂閱observable(source$),再把我們真正的observer加到subject$中,這樣一來就能完成訂閱,而每個加到subject$中的observer都能整組的接收到相同的元素。

什麼是Subjet?

雖然前面我們已經示範直接手寫一個簡單的subject, 但到底RxJS中的Subject的概念到底是什麼呢?

首先Subject可以拿去訂閱Observable(source)代表他是一個Observer,同時Subject𦙆可以被Observer(observerA$,observerB$)訂閱,代表他是一個Observable。

總結成兩句話

  • Subject同時是Observable又是Observer
  • Subject會對內部的observers清單進行組(multicast)

Subject, BehaviorSubject, ReplaySubject, AsyncSubject

上面介紹Subject是什麼,今天要講Subject一些應用方式,以及Subject的另外三種變形。

Subject

實際上Subject就是Observer Pattern的實作,他會在內部管理一份observer的清單,並在接收到值時遍歷這份清單並送出值,所以我們可以這樣用Subject

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { interval, Subject } from "rxjs";

var subject$ = new Subject();

var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

subject$.subscribe(observerA);
subject$.subscribe(observerB);

subject$.next(1);
// "A next: 1"
// "B next: 1"
subject$.next(2);
// "A next: 2"
// "B next: 2"

這裡我們可以直接用subject的next方法傳送值,所有訂閱的obsrver就會接收到,又因為Subject本身是Observable,所以這樣的使用方式很適合用在某些無法直接使用Observable的前端框架中,例如在React想對DOM的事件做監聽

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyButton extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.subject = new Rx.Subject();

this.subject
.mapTo(1)
.scan((origin, next) => origin + next)
.subscribe(x => {
this.setState({ count: x })
})
}
render() {
return <button onClick={event => this.subject.next(event)}>{this.state.count}</button>
}
}

從上面的程式碼可以看出來,𦙲為React本身API的關系,如果我們想要用React自訂的事件,我們沒辦法直接使用Observable的creation operator建立observable,這時就可以靠Subject來做到這件事。

Subject因為同時是observer和observable,所以應用面很廣除了前面所提的之外,還有之前有提到的組播(multicase)特性也會在接下來的文章做更多應用的介紹,這裡先來看看Subject的三個變形。

BehaviorSubject

很多時候我們會希望Subject能代表當下的狀態,而不是單純的事件發送,也就是說如果今天有一個新的訂閱,我們希望Subject能立即給出最新的值,而不是沒有回應,例如下面這個例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { interval, Subject } from "rxjs";

var subject$ = new Subject();

var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

subject$.subscribe(observerA);

subject$.next(1);
// "A next: 1"
subject$.next(2);
// "A next: 2"
subject$.next(3);
// "A next: 3"
setTimeout(() => {
subject$.subscribe(observerB);// 3 秒後才訂閱,observerB 不會收到任何值。
}, 3000);

以上這個例子來說,observerB訂閱的之後,是不會有任何元素送給observerB的,因為在這之後沒有執行何何subject$.next(), 但很多時候我們會希望subject能夠表達當前的狀態,在一訂閱時就能收到最新的狀態是什麼,而不是訂閱後要等到有變動才能接收到新的狀態,以這個例子來說,我們希望observerB訂閱時就能立即收到3, 希望做到這樣的效果就可以用BehaviorSubject。

BehaviroSubject跟Subject最大的不同就是BehaviorSubject是用來呈現當前的值,而不是單純的發送事件。BehaviorSubject會記住最新一次發送的元素,並把該元素當作目前的值,在使用上BehaviroSubject建構式需要傳入一個參數來代表起始的狀態,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { BehaviorSubject } from "rxjs";

var subject$ = new BehaviorSubject(0);// 0 為起始值
var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

subject$.subscribe(observerA);
// "A next: 0"
subject$.next(1);
// "A next: 1"
subject$.next(2);
// "A next: 2"
subject$.next(3);
// "A next: 3"
setTimeout(() => {
subject$.subscribe(observerB);
// "B next: 3"
}, 3000);

從上面這個範例可以看得出來BehaviorSubject在建立時就需要給𡥞一個狀態,並在任何一次訂閱,就會先送出最新的狀態。其實這種行為就是一種狀態的表達而非單純的事件,就像是年齡跟生日一樣,年齡是一種狀態而生日就是事件;所以當我們想要用一個steam來達年齡時,就應用BehaviroSubject。

ReplaySubject

在某些時候我們會希望Subject代表事件,但又能在新訂閱重新發送最後的幾個元素,這時我們新可以用ReplaySubject範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { ReplaySubject } from "rxjs";
var subject$ = new ReplaySubject(2);//重複發送最後2個元素
var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

subject$.subscribe(observerA);
subject$.next(1);
// "A next: 1"
subject$.next(2);
// "A next: 2"
subject$.next(3);
// "A next: 3"
setTimeout(() => {
subject$.subscribe(observerB);
// "B next: 2"
// "B next: 3"
}, 3000);

可能會有人以為ReplaySubject(1)是不是就等同於BehaviroSubject,其實是不一樣的,BehaviorSubject在建立時就會有起始值,比如BehaviroSubject(0)起始值就是0, BehaviorSubject是代表著狀態而ReplaySubject只是事件的重放而已。

AsyncSubject

AsyncSubject是最怪的一個變形,他有點像是operatorlast,會在subject結事後送出最一個值,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { AsyncSubject } from "rxjs";
var subject$ = new AsyncSubject();
var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

subject$.subscribe(observerA);
subject$.next(1);
subject$.next(2);
subject$.next(3);
subject$.complete();
// "A next: 3"
// "A complete!"
setTimeout(() => {
subject$.subscribe(observerB);
// "B next: 3"
// "B complete!"
}, 3000);

從上面的程式碼可以看出來,AsyncSubject會在subject結束後才送出最後一個值,其實這個行為跟Promise很像,絕大部分的時候都是使用BehaviorSubject跟ReplaySubject或Subject。

multicast, refCount, publish, share

有講到Subject時,是希望能夠讓Observable有新訂閱時,可以共用前一個訂閱而不要從頭開始,如下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { interval, Subject } from "rxjs";
import { take } from "rxjs/operators";

var source$ = interval(1000).pipe(take(3));

var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};
var subject$ = new Subject();

subject$.subscribe(observerA);

source$.subscribe(subject$);

setTimeout(() => {
subject$.subscribe(observerB);
}, 1000);
// "A next: 0"
// "A next: 1"
// "B next: 1"
// "A next: 2"
// "B next: 2"
// "A complete!"
// "B complete!"

上面這段程式碼我們用subject訂閱了source$, 再把observerA跟observerB一個僤訂閱到subject, 這樣就可以讓observarA跟observerB共用同一個執行,但這樣的寫法會讓程式碼看起來太過複雜,我們可以用Observable的multicast operator來簡化這段程式

multicast

multicast可以用來載subject並回傳一個可連結(connectable)的observable,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { interval, Subject } from "rxjs";
import { take, multicast } from "rxjs/operators";

var source$ = interval(1000).pipe(
take(3),
multicast(() => new Subject())
);

var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

source$.subscribe(observerA);
source$.connect();

setTimeout(() => {
source$.subscribe(observerB)
}, 1000);

上面這段程試碼我們透過multicast來掛載一個subject$之後這個observable(source$)的訂閱其實都是訂閱到subject$上。

1
source$.subscribe(observerA);// subject.subscribe(observerA)

必須真的等到執行connect()後才會真的用subject$訂閱source$,並開始送出元素,如果沒有執行connect()observable是不會真正執行的。

1
source$.connect();

另外值得注意的是這裡要退訂的話要把connect()回傳的subscription退訂才會直正停止observable的執行,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { interval, Subject } from "rxjs";
import { tap, multicast } from "rxjs/operators";

var source$ = interval(1000).pipe(
tap(x => console.log("send: " + x)),
multicast(() => new Subject())// 無限的 observable
);

var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

var subscriptionA = source$.subscribe(observerA);
var realSubscription = source$.connect();
var subscriptionB;
setTimeout(() => {
subscriptionB = source$.subscribe(observerB);
}, 1000);

setTimeout(() => {
subscriptionA.unsubscribe();
subscriptionB.unsubscribe();
// 這裡雖然 A 跟 B 都退訂了,但 source 還會繼續送元素
}, 5000);

setTimeout(() => {
realSubscription.unsubscribe();
// 這裡 source 才會真正停止送元素
}, 7000);

上面這段程式碼,必須等到realSubscription.unsubscribe()執行完,source$才會真的結束。

雖然用了multicast感覺會讓我們處理的對象少一點,但必須搭配connect一起使用還是讓程式碼有點複雜,通常我們會希望有observer訂閱時,就立即執行並發送元素,而不要再多執行一個方法(connect),這時我們就可以用refCount

refCount

refCount必須搭配multicast一起使用,他可以建立一個只要有訂閱就會自動connect的observable,範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { interval, Subject } from "rxjs";
import { tap, multicast, refCount } from "rxjs/operators";
var source$ = interval(1000).pipe(
tap(x => console.log("send: " + x)),
multicast(() => new Subject()),
refCount()
);

var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

var subscriptionA = source$.subscribe(observerA);
//訂閱數 0 => 1

var subscriptionB;
setTimeout(() => {
subscriptionB = source$.subscribe(observerB);
// 訂閱數 0 => 2
}, 1000);

上面這段程式碼,當source$一被observerA訂閱時(訂閱數從0變成1),就會立即執行並發送元素,我們就不需要再額外執行connect。

同樣的在退訂時只要訂閱數變成0就會自動停止發送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { interval, Subject } from "rxjs";
import { tap, multicast, refCount } from "rxjs/operators";
var source$ = interval(1000).pipe(
tap(x => console.log("send: " + x)),
multicast(() => new Subject()),
refCount()
);

var observerA = {
next: value => console.log("A next: " + value),
error: error => console.log("A error:" + error),
complete: () => console.log("A complete")
};

var observerB = {
next: value => console.log("B next: " + value),
error: error => console.log("B error:" + error),
complete: () => console.log("B complete")
};

var subscriptionA = source$.subscribe(observerA);
//訂閱數 0 => 1

var subscriptionB;
setTimeout(() => {
subscriptionB = source$.subscribe(observerB);
// 訂閱數 0 => 2
}, 1000);

setTimeout(() => {
subscriptionA.unsubscribe();// 訂閱數 2 => 1
subscriptionB.unsubscribe();// 訂閱數 1 => 0,source 停止發送元素
}, 5000);

publish

其實multicast(()=> new Subject())很常用到,我們有一個簡化的寫法那就是publish,下面這兩段程式碼是完全等價的

1
2
3
4
5
6
7
8
9
10
11
12
import { interval, Subject } from "rxjs";
import { tap, multicast, publish, refCount } from "rxjs/operators";

var source$ = interval(1000).pipe(
publish(),
refCount()
);

// var source$ = interval(1000).pipe(
// multicast(() => new Subject()),
// refCount()
// );

加上Subject的三種變形

1
2
3
4
5
6
7
8
9
10
11
12
import { interval, Subject } from "rxjs";
import { tap, multicast, publish, publishReplay, refCount } from "rxjs/operators";

var source$ = interval(1000).pipe(
publishReplay(1),
refCount()
);

// var source$ = interval(1000).pipe(
// multicast(() => new publishReplay(1)),
// refCount()
// );
1
2
3
4
5
6
7
8
9
10
11
12
import { interval, Subject } from "rxjs";
import { tap, multicast, publish, publishReplay, publishBehavior, refCount } from "rxjs/operators";

var source$ = interval(1000).pipe(
publishBehavior(1),
refCount()
);

// var source$ = interval(1000).pipe(
// multicast(() => new publishBehavior(0)),
// refCount()
// );
1
2
3
4
5
6
7
8
9
10
11
12
import { interval, Subject } from "rxjs";
import { tap, multicast, publish, publishReplay, publishBehavior, publishLast, refCount } from "rxjs/operators";

var source$ = interval(1000).pipe(
publishLast(),
refCount()
);

// var source$ = interval(1000).pipe(
// multicast(() => new publishLast()),
// refCount()
// );

share

另外publish+ refCount可以在簡寫成share

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { interval, Subject } from "rxjs";
import { tap, multicast, publish, publishReplay, publishBehavior, publishLast, refCount, share } from "rxjs/operators";

var source$ = interval(1000).pipe(
share()
);

// var source$ = interval(1000).pipe(
// publish(),
// refCount()
// );

// var source$ = interval(1000).pipe(
// multicast(() => new Subject()),
// refCount()
// );

Subject總結

Subject其實在RxJS中最常被誤解的一部份,因為Subject可以讓你用命令式的方式送值到一個observable的串流中。很多人會直接把這個特性拿來用在不知道如何建立Observable的狀況

Subject與Observable的差異

永遠記得Subject其實是Observer Design Pattern的實作,所以當observer訂閱到subject時,subject會把訂閱者塞到一份訂閱者清單,在元素發送時就是在遍歷這份清單,並把元素一一送出,這跟Obserbable像是一個function執𢓝是完全不同的。

Subject之所以具有Observable的所有方法,是因為Subject繼承了Observerable的型別,其實Subject型別中主要實做的方法只有next、error、complete、subscribe及unsubscribe這五個方法。而這五個方法就是依照Observer Pattern下法實作的。

總而言之,Subject是Observable的子類別,這個子類別當中用上述的五個方法實作了Observer Pattern,所以他同時具有Observable與Observer的特性。而跟Observable最大的差異就是Subject是具有狀態的,也就是儲存的那份清單!

因為Subject在訂閱時,是把observer放到一分清單當中,並在元素要送出(next)的時候遍歷這份清單,大概就像下面這樣

1
2
3
4
5
6
7
8
//...
next(){
// observers 是一個陣列存所有的observer
for(let i=0;i < observers.length;i++){
observers[i].next(value);
}
}
//...

這會衍伸一個大問題,就是在某個observer發生錯誤卻沒有做錯誤處理時,就會影響到別的訂閱,看下面這個例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { interval, Subject } from "rxjs";
import { map } from "rxjs/operators";

const source$ = interval(1000);
const subject$ = new Subject();
const example$ = subject$.pipe(
map(x => {
if (x === 1) {
throw new Error("oops");
}
return x;
})
);

subject$.subscribe(x => console.log("A,", x));
example$.subscribe(x => console.log("B,", x));
subject$.subscribe(x => console.log("C,", x));

source$.subscribe(subject$);

上面這個例子,可能會預期B會在送出1的時候掛掉,另外A跟C會持續發送元素,確實正常應該像這樣運作,但目前RxJX的版植中會B報錯之後,A跟C也同時停止運行。原因就像之前所提的,在遍歷所有的obsever時發生了例外會導到之後的行為停止。

那要如何解決這個問題呢?目前最簡單的方式,當然是盡可能地把所有observer的錯誤處理加進去,這樣一來就不會有例外發生

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { interval, Subject } from "rxjs";
import { map } from "rxjs/operators";

const source$ = interval(1000);
const subject$ = new Subject();
const example$ = subject$.pipe(
map(x => {
if (x === 1) {
throw new Error("oops");
}
return x;
})
);

subject$.subscribe(
x => console.log("A,", x),
error => console.log("A Error:" + error));
example$.subscribe(
x => console.log("B,", x),
error => console.log("B Error:" + error));
subject$.subscribe(
x => console.log("C,", x),
error => console.log("C Error:" + error));

source$.subscribe(subject$);

像上面這段程式碼,當B發生錯誤時就只有B會停止,而不會影響A跟C。

一定需要使用Subject的時機

Subject正常應該是當我們一個observable的操作過程中發生了side-effect而我們不希望這個side-effect因為多個subscribe而被觸發多次,比如下面這段程式碼?

1
2
3
4
5
6
7
8
9
import { interval, asapScheduler } from "rxjs";
import { map, take } from "rxjs/operators";
var result$ = interval(1000).pipe(
take(6),
map(x => Math.random())// side-effect,平常有可能是呼叫 API 或其他 side effect
);

var subA$ = result$.subscribe(x => console.log("A: " + x));
var subB$ = result$.subscribe(x => console.log("B: " + x));

這段程式碼A跟B印出來的亂數就不一樣,代表random(side-effect)被執行了兩次,這種情況就一定會用到subject(或其相關的operators)

1
2
3
4
5
6
7
8
9
10
11
import { interval, asapScheduler, Subject } from "rxjs";
import { map, take, multicast, refCount } from "rxjs/operators";
var result$ = interval(1000).pipe(
take(6),
map(x => Math.random()),// side-effect
multicast(new Subject()),
refCount()
);

var subA$ = result$.subscribe(x => console.log("A: " + x));
var subB$ = result$.subscribe(x => console.log("B: " + x));

改成這樣後我們就讓side-effect不因為訂閱數而多執行,這種情狀就是一定要用subject的。

簡易實作Observable(一)

為什麼是簡易實作而不是完整實作呢?實作Observable其實只是幫助我們理解Observable的運作方式,所以會盡可能地簡單,容易理解及吸收。

重點觀念

Observable跟Observer Pattern是不同的,Observable內部並沒有管理一份訂閱清單,訂閱Observable就像是執行一個function一樣!

所以實作過程的重點

  • 訂閱就是執行一個function

  • 訂閱接收的物件具備next,error,complete三個方法

  • 訂閱會返回一個可退訂(unsubscribe)的物件

基本observable實作

先用最簡單的function來建立observable物件

1
2
3
4
5
6
7
8
function create(subscriber) {
var observable = {
subscribe: function (observer) {
subscriber(observer)
}
};
return observable;
}

上面這段程式碼就可以做最簡單的訂閱,像下面這樣

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function create(subscriber) {
var observable = {
subscribe: function (observer) {
subscriber(observer)
}
};
return observable;
}

var observable = create(function (observer) {
observer.next(1);
observer.next(2);
observer.next(3);
});

var observer = {
next: function (value) {
console.log(value);
}
}
observable.subscribe(observer);
//1
//2
//3

這時候我們已經有最簡單的功能了,但這裡有一個大問題,就是observable在結束(complete)就不應該再發送元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var observable = create(function (observer) {
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
observer.next("still work");
});

var observer = {
next: function (value) {
console.log(value);
},
complete: function () {
console.log("complete");
}
}
observable.subscribe(observer);
// 1
// 2
// 3
// "complete!"
// "still work"

從上面的程式碼可以看到comlete之後還是能送出元素來,另外還有一個問題就是observer,如果不完整的就會出錯,這也不是我們希望看到的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var observable = create(function (observer) {
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
observer.next("still work");
});

var observer = {
next: function (value) {
console.log(value);
}
}
observable.subscribe(observer);
//observer.complete is not a function 
//1
//2
//3

上面這段程式碼可以看出來,當使用者observe物件沒有complete方法時,就會報錯。我們應該修正這兩個問題!

實作簡易Observer

要修正這兩個問題其實並不難,我們只要實作一個Observer的類別,每次使用者傳入的observer都會利用這個類別轉乘我們想要Observer物件。

首先訂閱時有可能傳入一個observer物件,或是一到三個function,error,complete,所以我們要建立一個類別可以接受各重可能的參數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Observer {
constructor(destinationOrNext, error, complete) {
switch (arguments.length) {
case 0:
// 空的 observer
break;
case 1:
if (!destinationOrNext) {
// 空的 observer
}
if (typeof destinationOrNext === "object") {
// 傳入了observer物件
}
break;

default:
//如果上面都不是,代表應該是傳入了一到三個function
break;
}
}
}

寫一個方法(safeObserver)來回傳正常的observer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Observer {
constructor(destinationOrNext, error, complete) {
// ... 一些程式碼
}
safeObserver(observerOrNext, error, complete) {
let next;
if (typeof (observerOrNext) === "function") {
// observerOrNext 是 next function
next = observerOrNext;
} else if (observerOrNext) {
// observerOrNext 是 observer 物件
next = observerOrNext.next || function () { };
error = observerOrNext.error || function (err) {
throw err;
};
complete = observerOrNext.complete || function () { };
}
//最後回傳我們預期的 observer 物件
return {
next: next,
error: error,
complete: complete
}
}
}

再把constructor完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 預設空的 observer
const emptyObserver = {
next: () => { },
error: (err) => { throw err; },
complete: () => { }
}
class Observer {
constructor(destinationOrNext, error, complete) {
switch (arguments.length) {
case 0:
// 空的 observer
this.destination = this.safeObserver(emptyObserver);
break;
case 1:
if (!destinationOrNext) {
// 空的 observer
this.destination = this.safeObserver(emptyObserver);
}
if (typeof destinationOrNext === "object") {
// 傳入了observer物件
this.destination = this.safeObserver(destinationOrNext);
}
break;

default:
//如果上面都不是,代表應該是傳入了一到三個function
this.destination = this.safeObserver(destinationOrNext, error, complete);
break;
}
}
safeObserver(observerOrNext, error, complete) {
// ... 一些程式碼
}
}

這裡我們把真正的observer塞到this.destination 接著完成obsserver的方法。

Observer的三個主要的方法(next,error,complete)都應該結束或退訂後不能再被執行,所以我們在物件內部偷塞一個boolean值來作為是否曾經結束的依據。

1
2
3
4
5
6
7
8
9
10
11
class Observer {
constructor(destinationOrNext, error, complete) {
// ... 一些程式碼
}
safeObserver(observerOrNext, error, complete) {
// ... 一些程式碼
}
unsubscribe() {
this.isStopped = true; // 偷塞一個屬性 isStopped
}
}

接著實作三個主要的方法就很簡單了,只要先判斷isStopped在使用this.destination物件來傳送值就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Observer {
constructor(destinationOrNext, error, complete) {
// ... 一些程式碼
}
safeObserver(observerOrNext, error, complete) {
// ... 一些程式碼
}

next(value) {
if (!this.isStopped && this.next) {
// 先判斷是否停止過
try {
this.destination.next(value); // 傳送值
} catch (err) {
this.unsubscribe();
throw err;
}
}
}

error(err) {
if (!this.isStopped && this.error) {
// 先判斷是否停止過
try {
this.destination.error(err); // 傳送錯誤
} catch (anotherError) {
this.unsubscribe();
throw anotherError;
}
this.unsubscribe();
}
}

complete() {
if (!this.isStopped && this.complete) {
// 先判斷是否停止過
try {
this.destination.complete(); // 發送停止訊息
} catch (err) {
this.unsubscribe();
throw err;
}
this.unsubscribe(); // 發送停止訊息後退訂
}
}

unsubscribe() {
this.isStopped = true;
}
}

到這裡我們就完成基本的Observer實作了,接著我讓我們拿到基本版的observable中使用吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function create(subscriber) {
const observable = {
subscribe: function (observerOnNext, error, complete) {
const realObserver = new Observer(observerOnNext, error, complete)
subscriber(realObserver);
return realObserver;
}
}
return observable;
}
var observable = create(function (observer) {
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
observer.next('not work');
});
var observer = {
next: function (value) {
console.log(value)
},
complete: function () {
console.log('complete!')
}
}
observable.subscribe(observer);
// 1
// 2
// 3
// complete!

到這裡我們就完成基本的observable了,至少基本的行為都跟我們期望的一致。

簡易實作Observable(二)

在上面的文章,我們已經完成了基本的observable以及Observer的簡易實作,這裏會接續上面的文章來實作簡易的Observable類別,以及一個creation operator和一個transform operator。

建立簡易Observable類別

這是我們上面文章建屯的observable物件的函式

1
2
3
4
5
6
7
8
9
10
function create(subscribe) {
const observable = {
subscribe: function() {
const realObserver = new Observer(...arguments);
subscribe(realObserver);
return realObserver;
}
};
return observable;
}

從這個函式可以看出來,回傳的observable物件至少會有subscribe方法,所以最簡單的Observable類別大概長像下面這樣

1
2
3
4
5
class Observable {
subscribe() {
// ...做某些事
}
}

另外create的函式在執行時會傳入一個subscribe的function,這個function會決定observable的行為

1
2
3
4
5
6
7
var observable = create(function(observer){
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
observer.next('not work');
})

把上面這一段改成下面這樣

1
2
3
4
5
6
7
var observable = new Observable(function(observer) {
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
observer.next('not work');
})

所以我們的Observable的建構式應該會接收一個subscribe function

1
2
3
4
5
6
7
8
9
10
11
12
class Observable {
constructor(subscribe) {
if (subscribe) {
this._subscribe = subscribe;//把subscribe存到 _subscribe屬性中
}
}
subscribe() {
const observer = new Observer(...arguments);
this._subscribe(observer);//就是執行一個function對吧
return observer;
}
}

到這裡我們就成功的把create的函式改成Observable的類別了,我們可以直接來使用看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Observable {
constructor(subscribe) {
if (subscribe) {
this._subscribe = subscribe;//把subscribe存到 _subscribe屬性中
}
}
subscribe() {
const observer = new Observer(...arguments);
this._subscribe(observer);//就是執行一個function對吧
return observer;
}
}
var observable = new Observable(function (observer) {
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
observer.next('not work');
});
var observer = {
next: function (value) {
console.log(value)
},
complete: function () {
console.log('complete!')
}
}
observable.subscribe(observer);

當然我們可以仿RxJS在靜態方法中加入create如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Observable {
constructor(subscribe) {
if (subscribe) {
this._subscribe = subscribe;//把subscribe存到 _subscribe屬性中
}
}
subscribe() {
const observer = new Observer(...arguments);
this._subscribe(observer);//就是執行一個function對吧
return observer;
}
}
Observable.create = function (subscribe) {
return new Observable(subscribe);
}

這樣一來我們就可以用Observable.create建立observable物件實例。

1
2
3
4
5
6
7
var observable = Observable.create(function(observer) {
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
observer.next('not work');
});

建立creation operator - fromArray

當我們有Obserbable類別後要建立creation operator就不難了,這裡我們建立一個fromArray的方法,可以接收array來建立observable,算是Rx的Observable.from的簡化版本,記得creation operators都屬於static方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Observable {
constructor(subscribe) {
if(subscribe) {
this._subscribe = subscribe; // 把 subscribe 存到屬性中
}
}
subscribe() {
const observer = new Observer(...arguments);
this._subscribe(observer);
return observer;
}
}

// 建立靜態方法
Observable.fromArray = function(array) {
if(!Array.isArray(array)) {
// 如果傳入的參數不是陣列,則拋出例外
throw new Error('params need to be an array');
}
return new Observable(function(observer) {
try{
// 遍歷每個元素並送出
array.forEach(value => observer.next(value))
observer.complete()
} catch(err) {
observer.error(err)
}
});
}

var observable = Observable.fromArray([1,2,3,4,5]);

上面的程式碼我們只是簡單的用new Observable就可以輕鬆地實現我們要的功能,之後就可以用fromArray來建立observable物件。

建立transform operator - map

相信很多人在實作Observable都是卡在這個階段,因為operators都是回傳一個新的observable這中間有很多細節需要注意,並且有些小技巧才能比較好的實現,在開始實作之前,先釐清幾個重點。

  • operators(transform,filter,conditional…)都是回傳一個新的observable

  • 大部分的operator其實就是在原本observer外包裹一層物件,讓執行next方法前先把元素做一次處理

  • operator回傳的opservable訂閱時,還是需要執行原本的observable(資料源),也就說我們要想辦法保留原本的observable

讓我們一步一步來,首先operators執行完會回傳一個新的observable,這個observable在訂閱時會先去執行operator的行為再發送元素,所以observable的訂閱方法就不能像現在這樣直接把observer傳給subscribe執行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Observable{
constructor(subscribe){
if(subscribe){
this._subscribe=subscribe;// 把subscribe存到屬性中
}
}
subscribe(){
const observer = new Obserer(...arguments);
// 先做某個判斷是否當前的observable是具有operator的
if(??){
// 用operator 的操作
}else{
// 如果沒有operator再直接把observer丟給_subscribe
this._subscribe(observer)
}
return observer;
}
}

以我們的Observable實作為例,這裡最重要的就是this._subscribe執行,每當執行時就是開始發送元素

這裡我們可以想像一下當一個map產生的observable訂閱時,應該先判斷出有map這個operator並且傳入原本的資料源以及當前的observer,也就是說我們的map至少有以下這幾件事要做

  • 建立新的observable

  • 保存原本的observable(資料源),之後訂閱才有辦法執行

  • 建立並保存operator本身的行為,等到訂閱時執行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Observable {
constructor(subscribe) {
// 一些程式碼...
}
subscribe() {
// 一些程式碼...
}
map(callback) {
const observable = new Observable();//邁立新的observable
observable.source = this;//保存當前的observable(資料源)
opservable.operator = {
call: (observer, source) => {//執行這個operator的行障

}// 儲存當前operator行為,並作為是否有operator的依據
};//
return observable;//返回這個新的observable
}
}

上面這三個步驟都是必要的,特別是用了observalbe.source=this這個小技巧,來保存原本的observable。但這裡我們還有一個地方沒完成就是operator要做的事,這個部分我們等一下再補,先把subscribe寫完

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Observable {
constructor(subscribe) {
// 一些程式碼...
}
subscribe() {
const observer = new Observer(...arguments);
//先用this.operator判斷當前的observable是否具有operator
if (this.operator) {
this.operator.call(observer, this.source);
} else {
// 如果沒有operator再直接把observer丟給_subscribe
this._subscribe(observer);
}
return observer;
}
map(callback) {
const observable = new Observable();//邁立新的observable
observable.source = this;//保存當前的observable(資料源)
opservable.operator = {
call: (observer, source) => {//執行這個operator的行障

}// 儲存當前operator行為,並作為是否有operator的依據
};//
return observable;//返回這個新的observable
}
}

記得這裡補的subscribe行為,已經是map回傳新observable的行為,不是原本的observable了。

到這裡我們就幾乎要完成了,接著只要實作map這個operator的行為就可以囉!記得我們前面講的operator其實就是在原本的observer做一層包裏,讓next執行前先對元素做處理。所以我們改寫一下Observer並建立一個MapObserver來做這件事

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Observer {
constructor(destinationOrNext, error, complete) {
switch (arguments.length) {
case 0:
// 空的 observer
this.destination = this.safeObserver(emptyObserver);
break;
case 1:
if (!destinationOrNext) {
// 空的 observer
this.destination = this.safeObserver(emptyObserver);
}
//多一個判斷,是否傳入的destinationOrNext原本就是Observer的實例,如果是就不用在用執行`this.safeObserver`
if (destinationOrNext instanceof Observer) {
this.destination = destinationOrNext;
break;
}
if (typeof destinationOrNext === "object") {
// 傳入了observer物件
this.destination = this.safeObserver(destinationOrNext);
}
break;

default:
//如果上面都不是,代表應該是傳入了一到三個function
this.destination = this.safeObserver(destinationOrNext, error, complete);
break;
}
}
// ...下面都一樣
}
class MapObserver extends Observer {
constructor(observer, callback) {
// 這裡會傳入原來的observer跟map的callback
super(observer);//因為有繼承所以要先執行一次父層的建構式
this.callback = callback;//保存callback
this.next = this.next.bind(this);//確保next的this
}
next(value) {
try {
this.destination.next(this.callback(value));
//this.destination是父層Observer保存的observer物件
//這裡this.callback(value)就是map的操作
} catch (err) {
this.destination.error(err);
return;
}
}
}

上面這段程式碼就可以讓我們包裹observer物件,利用物件的繼承覆寫原本的next方法。

最後我們就只要補完map方法就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Observable {
constructor(subscribe) {
// 一些程式碼...
}
subscribe() {
// 一些程式碼...
}
map(callback) {
const observable = new Observable();
observable.source = this;
observable.operator = {
call: (observer, source) => {
// 執行這個 operator 的行為
const newObserver = new MapObserver(observer, callback);
// 建立包裹後的 observer
// 訂閱原本的資料源,並回傳
return source.subscribe(newObserver);
}
};
return observable;
}
}

這裡做的事情就簡單很多,我們只要建立包裹過的observer,並用這個包裹後的observer訂閱原本的source。(記得這個function是在subscribe時執行的)

Scheduler基本觀念

在前面的文章中有提到Scheduler是為了解決RxJS衍生的最後一個問題。

其實RxJS用久了之後就會發現Observable有一個優勢是可以同時處理同步和非同步行為,但這個優勢也帶來了一個問題,就是我們常常會搞不清楚現在的observable執行方式是同步的還是非同步的。換句話話,我們很容易搞不清楚observable到什麼時候開如發送元素!

舉例來說,我們可能很清楚interval是非同步送出元素的,但range呢?from呢?他們可能有時候是非同步有時候是同步,這就會變得有點困擾,尤其在除錯執行順序就非常重要。

而Scheduler其本上就是拿來處理這個問題的!

什麼是Scheduler?

Scheduler控制一個observable的訂閱什麼時候開始,以及發送元素什麼時候送達,主要由以下三個元素所組成

  • Scheduler是一個資料結構,它知道如何根據優先級或其他標準來儲存並佇列任務。

  • Scheduler是一個執行環境。它意味著任務何時何地被執行,比如像是立即執行、在回呼(callback)中執行、setTimeout中執行、animation frame中執行

  • Scheduler是一個虛擬時鐘。它透過now()這個方法提供了時間的概念,我們可以讓任務在特定的時間點被執行。

簡言之Scheduler會影響Observable開始執行及元素送達的時機,比如下這個例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Observable, asyncScheduler } from "rxjs";
import { observeOn } from "rxjs/operators";

var observable = Observable.create(function (observer) {
observer.next(1);
observer.next(2);
observer.next(3);
observer.complete();
});
console.log("before subscribe");
observable.pipe(observeOn(asyncScheduler)).subscribe({
next: (value) => { console.log(value); },
error: (err) => { console.log('Error: ' + err); },
complete: () => { console.log('complete'); }
});
console.log("after subscribe");
// "before subscribe"
// "after subscribe"
// 1
// 2
// 3
// "complete"

上面這段程式碼原本是同步執行的,但我們用了observable.pipe(observeOn(asyncScheduler))原本是同步執行的就變成了非同步執行了。

有哪些Scheduler可以用

目前有下面幾種以版本6來說

  • queue
  • asap
  • async
  • animatonFrame

會在下面搭配程式碼一一講解

使用Scheduler

其實我們在使用各種不同的operator時,這些operator就會各自預設不同的scheduler,例如一個無限的observable就會預設為queurScheduler,而timer相關的operator則預設為asyncScheduler。

要使用Scheduler除了前面用到的observeOn()方法外,以下這幾個creation operators最後一個參都能接收Scheduler

  • bindCallback
  • bindNodeCallback
  • empty
  • from
  • interval
  • merge
  • of
  • range
  • throw
  • timer

例如下面這個例子

1
2
3
4
5
import { from, asyncScheduler } from "rxjs";
import { observeOn } from "rxjs/operators";
var observable = from([1, 2, 3, 4, 5]).pipe(
observeOn(asyncScheduler)
);

另外還有多個operators最後一個參數可以傳入Scheduler這邊就不一一列出,可以參考官方文件,最通用的方式還是observeOn()只要是observable就可以用這個方法。

queue

queue的運作方式跟預設的立即執行很像,但是當我們使用到遞回方法時,他會佇列這些行為而非直接執行,一個遞回的operator就是他會執行另一個operator, 最好的例子就是repeat(),如果我們不給他參數的話,他會執行無限多次,像下面這個例子

1
2
3
4
5
6
import { from, asyncScheduler, of } from "rxjs";
import { repeat, take } from "rxjs/operators";
of(10).pipe(
repeat(),
take(1)
).subscribe(console.log);

在RxJS6中他預設了無限的observable為queue所以他會把repeat的next行為先佇列起來,因為前一個complete還在執行中,而這時repeat就會回傳一個可退訂的物件給take(1)等到repeat的next被第一次執行時就會結束,因為take(1)會直接收到值。

使用情境

quere很適合用在會有遞回的operator且具有大量資料時使用,在這個情況下queue能避免不必要的效能損秏。

asap

asap的行為很好理解,它是非同步的執行,在瀏覽器其實就是setTimeout設為0秒(在NodeJS中是用process.nextTick),因為行為很好理解這解就不寫例子了。

使用情境

asap因為都是在setTimeout中執行,所以不會有block event loop的問題,很適合用在永遠不會退訂的observable,例如在背景下持續監聽server送來的通知。

async

它跟asap很像但是使用setInterval來運作,通常是跟時間相關的operator才會用到。

animationFrame

這個相信大家應該都知道,他是稅用Window.requrestAnimationFrame這個API去實作的,所以執行週期就跟Window.requestAnimationFrame一模一樣。

使用情境

在做複雜運算,且高頻率觸發UI動畫時,就很適合使用animationFrame,所以可以搭配throttle operator使用。

Cold&Hot Observable

Hot Observable跟Cole Observable的差別,其實就是**資料源(Data Source)**在Observable內部建立還是外部建立。

在RxJS中很常會看偌Cold Observable跟Hot Observable這兩個名詞,其實他們是在區分不同行為的Observable,所謂的Cold Observable就是指每次訂閱都是獨立的執行,而Hot Observable則是共用的訂閱

Cold Observable

Cold Observable代表Observable的每個訂閱都是獨立的,他們不會互相影響,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { interval } from "rxjs";
import { take } from "rxjs/operators";

const source$ = interval(1000).pipe(take(5));
source$.subscribe(value => console.log("sub1: " + value));
setTimeout(() => {
source$.subscribe(value => console.log("sub2: " + value));
}, 3500);
// sub1: 0
// sub1: 1
// sub1: 2
// sub1: 3
// sub2: 0
// sub1: 4
// sub2: 1
// sub2: 2
// sub2: 3
// sub2: 4

從上面時程式碼可以看出來每次訂閱source$都是獨立運行的,這種每次訂閱都是獨立執行的Observable就稱為Cold Observable。

如果從Observable內部來看,代表資料源(Data Source)是在Observable內部建立的,大概會長像下面

1
2
3
4
5
6
7
8
import { Observable } from "rxjs";
var source$ = Observable.create(function (observer) {
// 訂閱時,才建立新的資料源
const someDataSource$ = getSomeDataSource();
someDataSource$.addEventListener("message", () => {
observer.next(data);
});
});

因為每次訂閱都建立一個新的資料源,就會使資料從頭開始傳送。

Hot Observable

Hot Observable代表Observable的每個訂閱是共用的,所謂的共用訂閱就是指一個Observable在多次訂閱時,不會每次都從新開始發送元素,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { interval } from "rxjs";
import { take, share } from "rxjs/operators";

var source$ = interval(1000).pipe(
take(5),
share()
);
source$.subscribe(value => console.log("sub1: " + value));
setTimeout(() => {
source$.subscribe(value => console.log("sub2: " + value));
}, 3500);
// sub1: 0
// sub1: 1
// sub1: 2
// sub1: 3
// sub2: 3
// sub1: 4
// sub2: 4

從上面的程式碼可以看出,當我們對source第二次做訂閱時,接收到的元素是接續第一個訂閱往下發送的,而不是從(0)開始,這種共用訂閱的Observable就稱為Hot Observable。

如果從Observable內部來看,就是資料源是在Observable外部建立的,程式碼大概就會像下面這樣

1
2
3
4
5
6
7
8
// 只有一個資料源,每次訂閱都是用同一個
import { Observable } from "rxjs";
const someDataSource = getSomeDataSource();
const source = Observable.create(function(observer) {
someDataSource.addEventListener('message', (data) => {
observer.next(data)
})
});

Cold與Hot

一般的情況下Observable都是Cold的,這樣不同的訂閱才不會有Side Effect互相影響。但在需要多次訂閱的情境下,我們就很有可能需要Hot Observable,而讓RxJS提供了很多讓Cold Observable變成Hot Observable的方法。

小結

Hot Observable跟Cold Observable的差異就是多次訂閱時,是否共用訂閱或是獨立執行,而這一切的差異就是來自於資料源是在Observable內部建立還是外部建立。

如何Debug?

Debug一直是RxJS的難題,原因是當我們使用RxJS後,程式碼就會變得高度抽象化;實際上抽象並不是什麼壞事,抽象會讓程式碼顯得簡潔、乾淨,但同成也帶來了除鏌上的因難。

在撰寫程式時,我們都會希望程式碼是簡潔且可讀的。𪝂當我們用簡潔的程式碼來處理複雜的問題,就表示我們的程式碼會變得高度抽象!其實人 在思考複雜的問題都會偏好用抽象的方式來處理,例如說在下圍棋時,常常說的棋形或是黑白哪一邊的比較好,這都是在抽象化處理問題。

RxJS如何除錯?

tap

在RxJS的世界中有一個Operator叫做tap,它不會對元素產生任何影響,在實務上很常來做錯的追蹤,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { interval } from "rxjs";
import { take, tap, map } from "rxjs/operators";

const source$ = interval(1000).pipe(take(3));
const example$ = source$.pipe(
tap(x => console.log("tap log: " + x)),
map(X => X + 1)
);
example$.subscribe((X) => {
console.log("subscirption log: " + X)
});
// do log: 0
// subscription log: 1
// do log: 1
// subscription log: 2
// do log: 2
// subscription log: 3

從上面的例子可以看出來,我們可以傳入一個callback function給tap, 我們可以在tap的內部對元素作任何操作(像是log),但不會兩元素產生影響。這很適合用在檢測每一步送出的元素是否符合我們的預期。

tap(...)的行為跟map(x => { ... return x;})本質上是一樣的

Observable間的關聯圖

當程式有點複雜時,我們最好是能先畫出Observable與Observable之間的關聯,在釐清各個Observable間的關系後,我們就能更輕易地找出問題在哪。範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { fromEvent ,of } from "rxjs";
import { mapTo, merge ,scan} from 'rxjs/operators';
const addButton= document.getElementById("addButton");
const minusButtion= document.getElementById("minusButton");
const state =document.getElementById("state");
const initialState = of(0);

const addClick= fromEvent(addButton,"click");
const minusClick=fromEvent(minusButton,"click");
const numberState= initialState.pipe(
merge(
addClick.pipe(mapTo(1)) ,
minusClick.pipe(mapTo(-1))
),
scan((origin,next)=> origin+ next)
);
numberState.subscribe({
next: (value) =>{ state.innerHTML = value;},
error: (err)=>{ console.log("Error: "+err);},
complete:()=>{ console.log("complete");}
});

stackblitz

上面這段程式碼,我們可以把關聯圖畫成以下的樣子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--------------        --------------        --------------
' ' ' ' ' '
'initialState' ' addClcik ' ' minusClick '
' ' ' ' ' '
-------------- -------------- --------------
| | |
| | mapTo(1) | mapTo(-1)
merge | ____________________| |
| \__________________________________________|
|
\|/
|
| scan((origin, next) => origin + next)
|
\|/
-------------
' '
'numberState'
' '
-------------

把每一個observable物件都框起來,並畫出之間的關聯,以及中間使用的Operators,這樣一來我們就能夠很清楚的了解這段程式碼在做什麼事,以及如何運作。最後我們只要在每一估環節去確認送出的元素就能找出錯誤出現在哪裡。

Marble Diagram

在釐清每個observable之間的關系並找出間題出現在哪個環節之 ,我們只要畫出該環節的Marble Diagram前後變化就能清楚地知道間題是如何發生。接續上面的例子,如果今天問題出在merge()之後,那我們就把merge()前後的Marble Diagram畫出來

1
2
3
4
5
6
7
8
9
10
11
initialState: 0|
addClick : ----------1---------1--1-------
minusClick : -----(-1)---(-1)---------------

merge(...)

: 0----(-1)-1-(-1)----1--1-------

scan((origin, next) => origin +next)

numberState : 0----(-1)-0-(-1)----0--1-------

到這裡我們應該就能清楚地知道問題出在哪,最後就只要想如何解決問題就行了。

如果還不知道間題在哪,很有可能是Marble Diagram畫鏌,可以再利用tap進行檢查

只要照著以上三個步驟做除錯,基本上就不用擔心會有解決不了的錯誤,但是這三個步驟仍然顯得太過繁瑣,或許我們應該做一個工具來簡化這整個流程!

參考資料

前言

這是參考鐵人賽的一個JS學習者的日常所得來的,就只是記錄一下

day01陣列相關

我們會用很多的陣列處理方法,forEach,map,reduce,filter

測試資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var people = [
{
name: 'Jim',
nickname: 'Hamburger',
age: 28
},
{
name: 'Andy',
nickname: 'Spaghetti',
age: 27
},
{
name: 'Kevin',
nickname: 'Curry',
age: 27
},
{
name: 'Arel',
like: 'Sushi',
age: 27
}
];

過了一年,所有人都長了一歲怎麼辦?要在每一個age上面加一歲

先將資料簡化成更單純的樣子

1
2
3
4
5
6
7
8
9
10
11
var age = [28, 27, 27, 27]
age.forEach(function(item,index){
console.log(item,index);
});
age.forEach((item,index)=>{
console.log(item,index);
});
// 28 0
// 27 1
// 27 2
// 27 3

forEach 只把資料丟給內部的callback function去進行處理

map 把資料丟給內部的callback function去進行處理,並回組一組陣列資料

1
2
3
4
5
6
7
8
9
10
var result=age.map(function (item, index) {
item = item + index
return item;
});
console.log(result);
result= age.map((item,index)=>{
item= item+index;
return item;
});
console.log(result);

reduce抓取初始值與下一個值,並回傳一個結果值

1
2
3
4
5
6
7
8
9
10
11
12
var rsreduce = age.reduce(function (item, item2) {
item = item + item2;
return item;
});
// 28 + 27 + 27 + 27 = 109
console.log(rsreduce);
rsreduce = age.reduce((item, item2) => {
item = item + item2;
return item;
});
// 28 + 27 + 27 + 27 = 109
console.log(rsreduce);

filter回傳Boolesn值true或false判斷引人處理後是否為要回傳的值,最後回一組陣列

1
2
3
4
5
6
7
8
9
10
11
//filter
var rsfilter = age.filter(function (item) {
if (item < 28)
return true;
});
console.log(rsfilter);
rsfilter = age.filter((item) => {
if (item < 28)
return true;
});
console.log(rsfilter);

但回到原本的資料,如何把所有的年齡加1

1
2
3
4
people.forEach((item) => {
item.age += 1;
});
console.log(people);

day02

一個物件資料, 想要複製樣的格式給下一個,並修改結果被更動到了原本的人資料,這是因為是指同一個位置

1
2
3
4
5
6
7
8
var Jim = {
favMovie: "LaLaLand",
favBook: "Thid Hitchhiker's Guide to the Galaxy"
}
var Albert = Jim;
Albert.favMovie = "Fight Club";
console.log(Jim.favMovie);
//Fight Club

使用Object.assign

1
2
3
4
5
6
7
8
9
//使用Object.assign
var Jim = {
favMovie: "LaLaLand",
favBook: "Thid Hitchhiker's Guide to the Galaxy"
}
var Albert = Object.assign({}, Jim);
Albert.favMovie = "back to the future";
console.log(Jim.favMovie)
//LaLaLand

const 指定常數,不可改變,但是是陣列時內容是可以改變

1
2
3
4
5
const PI=3.14159;
PI=1.141;//有錯誤,不可再指定
//Assignment to constant variable. 
// at ​day02.js:21:0​
// at ​​​Object.<anonymous>​​​
1
2
3
4
5
6
7
8
const arr=[1,2,3]
arr.push(4)
console.log(arr);
arr[3]=999;
console.log(arr)
//
arr2=[10,11,12,13]
arr= arr2;//有錯誤,不可再指定

函式沒有傳入參數,卻有這個變數可以用?回傳的這是啥

1
2
3
4
5
6
7
function hello() {
console.log(arguments.length);
console.log(arguments);
}
hello('echoooo');
//1 ​​​​​at ​​​arguments.length
//{ [Iterator] 0: 'echoooo', [Symbol(Symbol.iterator)]: [λ: values] } at ​​​arguments​​​

this是“這”的意思嗎?this到底指到哪裏?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var obj = {
sayHi: "How ar you?",
sayHello: function () {
console.log(this.sayHi);
}
}

function sayHelo(obj){
console.log(obj);
obj.sayHello();
}

sayHelo(obj);
// { sayHi: 'How ar you?', sayHello: [λ: sayHello] }  at ​​​obj​​​
// How ar you? ​​​​​at ​​​this.sayHi

day03基本型別

有六種基本型別

  • string
  • number
  • boolean
  • null
  • undefined
  • object

物件的宣告方式為兩種

物件宣告第一種

1
2
3
4
5
6
7
//物件宣告第一種
var myObj = {
name: "123",
age: 18
}
console.log(myObj.name)
console.log(myObj.age)

物件宣告第二種

1
2
3
4
5
var curObj= new Object();
curObj.name="456";
curObj.age=28;
console.log(curObj.name)
console.log(curObj.age)

物件就像是一個群集,包含了資料跟處理資料的方法,他可能長成這樣

1
2
3
4
5
6
7
8
var introduction={
name:'Jim',
favFood:'Sushi',
breakIce:function(){
console.log(`Hello I am ${this.name},My favFood is ${this.favFood}`)
}
}
introduction.breakIce();

我們有了一個績件後如何呼叫與存取有兩種方式為特性存取property access與鍵值存取key access

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var firstName = 'Hu';
var ages = {
FirstName: 'Hu',
'firstName': 'Hu',
'Hu Jim': 28,
'Hu Koa': 60
}
//特性存取(property access)
console.log(ages.FirstName);
console.log(ages.firstName);

//鍵值存取(key access)
console.log(ages[firstName + ' Jim']);
//加入一個新的
ages[firstName + ' Ge'] = 30;
console.log(ages);

物件的複製

Shallow Clone

1
2
3
4
5
6
7
8
9
10
11
12
var firstName = 'Hu';

var ages = {
'Hu Jim': 28,
'Hu Koa': 60
}
//Shallow Clone
var agesNextYear = ages;
agesNextYear[firstName + ' Jim'] = 29;
console.log('Shallow Clone ages:' + ages[firstName + ' Jim']);
console.log('Shallow Clone agesNextYear' + agesNextYear[firstName + ' Jim']);

Deep Clone

1
2
3
4
5
6
7
8
9
10
11
12
//Deep Clone
var firstName = 'Hu';

var ages = {
'Hu Jim': 28,
'Hu Koa': 60
}
var agesNextYear= Object.assign({},ages);
agesNextYear[firstName + ' Jim'] = 29;
console.log('Deep Clone ages:' + ages[firstName + ' Jim']);
console.log('Deep Clone agesNextYear' + agesNextYear[firstName + ' Jim']);

day04 callback相關

1
2
3
4
5
6
7
function example(msg, callback) {
callback(msg)
}
example('hello callback', function (sayHi) {
console.log(sayHi)
})
//結果 hello callback

什麼是callback,專有名詞來說就是一種高階函式的用法,可以把函式當作變數傳遞,當然函數也可以返回函數,而這樣做我們可以在需要的時候再去使用它或者解決非同步阻塞的問題。在處理陣列時我們也使用同樣的方法來處理我們的陣列資料。

1
2
3
4
5
6
7
var total = [1, 2, 3, 4, 5]
var res = total.reduce((initvalue, nextitem) => {
initvalue += nextitem
return initvalue;
});
console.log(res);
// 結果 15

在撰寫邏輯上,我們可以直接的方式撰寫,變很快速理解,但直正使用的理由是,當我們事件與服務要求次數變多,要達到不阻塞I/O或多緒執行的 益時,更需要思考我們撰寫程式的邏輯與方式並非單純直覺的以單線作為考量

day05 API

當我們做一個會員登人系統,接到API後,拿到一個像這樣子的一筆JSON資料

1
2
3
$.get('https://randomuser.me/api/',function(result){
console.log(result)
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"firstName": "Hu",
"lastName": "Jim",
"sex": "male",
"age": 18,
"address":
{
"streetAddress": "21 2nd Street",
"city": "New York",
"state": "NY",
"postalCode": "10021"
},
"phoneNumber":
[
{
"type": "home",
"number": "212 555-1234"
},
{
"type": "fax",
"number": "646 555-4567"
}
]
}

什麼又是JSON

通常我們會拿到一個物件或陣列格式,包含物件或陣列的資料。這樣的結構我們稱為JSON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"results": [
{"firstCol1": "data"},
{"firstCol2": "data"},
{"firstCol3": "data"},
{"firstCol4": "data"},
{"firstCol5": [
{"SecCol1": "data"},
{"SecCol2": "data"},
{"SecCol3": "data"},
{"SecCol4": "data"}
]
}
]
}

看起來是物件或陣列,但在網路上傳遞資料的方式,是以字串的方式去處理的,所以我們必須使用JSON.stringify轉成字串或JSON.parse去轉回物件或陣列。

再往回看一點點,我們到底在程式上要如何去接一個 API,可以使用 JS 原生的功能或者用套件,針對接 API 功能去處理,這裡提供五個例子,包含原始 XMLHttpRequest、jQuery、axios、superAgent、fetch。

接API的五種方式

day06同步和非同步

當你程式的某部份現在(now)立即執行而另外一部分要在之後(later)執行時,會發生什麼事,在這個now和loater之間有個間隙(gap)存在,期間你的程式並有積極執行

什麼是非同步?非同步包括,網路連線要求之類的I/O(例如AJAX,DOM事件處理,動畫流程處理,計時器)

1
2
3
4
5
console.log("a");
setTimeout(() => {
console.log("b");
}, 3000);
console.log("c");

我們可以看到執行結果A->B->C,但是我們要處理的狀態越來越多,結構越來越雜的時候,callback也許就不夠用山,事件的交錯與不確定資料處理的期間,不斷增加開發中理解與撰寫上的困難,再進一步學習中,處理相關的問題Promise是其中一個選項

day07多判斷switch case

當我們需要多個判斷的時候,使用swith case的小細節

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let fruit = 'apple';
switch (fruit) {
case 'banana':
console.log('It is a banana');
break;
case 'peach':
console.log('It is a peach');
break;
case 'apple':
console.log('It is a apple');
break;
default:
console.log('I don\'t know what is it');
break;
}

在switch case中如果要中斷,要加入break這關鍵字不然會一直執行下去。

day08 ES6常用語法

ES6它的全名是ECMAScript6。便是我們所學的JS的語言核心。從第五版ES5到第六版ES6日漸普遍,而ES7也蓄勢待發

  1. 解構賦值

    說明:我們可以將一個陣列以的形式,一次展開或者一次打包起來

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    var arr = [1, 2, 3, 4, 5];
    //展開
    console.log(...arr);
    //結果 1 2 3 4 5

    function showArr(num1, num2, ...num3) {
    console.log(num1);
    console.log(num2);
    console.log(num3);
    }
    //打包
    showArr(...arr);
    //結果
    // 1
    // 2
    // [ 3, 4, 5 ]
  2. 模板字變量和多行字符串

    說明:可以在``(注意此為鍵盤左上方``符號非’’單引號)之間包含字串,邺以${}包𨭎變數,甚至換行也可以。

    1
    2
    3
    4
    5
    6
    7
    8
    function sayMyName(firstName, lastName) {
    console.log(`Hollo My Name
    is ${firstName} ${lastName}
    `);
    }
    sayMyName("tom", "tang");
    //結果 Hollo My Name 
    // is tom tang 
  3. 箭頭函數

    說明:寫法(引數)=>{函式內容}

    1
    2
    3
    4
    5
    6
    7
    8
    var arr = [1, 2, 3, 4, 5]

    var res=arr.reduce((total,items)=>{
    total +=items;
    return total;
    });
    console.log(res);
    //結果 15
  4. let 宣告

    說明:JS本來是以function去分辨解析變名稱所使用的範圍,意思是在一各物綿內具有一個或往更上層找或是全域等等,也就是大家常說的scope,有別於其他語言可能以大括號**{}作為範圍的選擇,所以當使用var在物件宣告的時候,一樣會變宣告成往上找到最上層fucnton的範圍。而如果使用let,更會改以{}**去判斷scope

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    var test ="test";

    if(test=="test"){
    var num5=10;
    let num6=20;
    }
    console.log(num5);
    console.log(num6);
    //結果 10
    // num6 is not defined 

5.預設參數

說明:一般來 說來如果引數與對應的參數不符合時,就會出錯。

1
2
3
4
5
6
7
8
9
10
11
function add(num1=1){
return num1+num2;
}
add(10);
//結果 num2 is not defined 
function add(num1=1,num2=1){
return num1+num2;
}
res=add(10);
console.log(res);
//結果 11

其它還未使用到的:

Module的import export功能、OOP的class寫法

補充:

當我們所使用的語法。瀏覽器可能過舊或不支援,本身無法被解析的時候,我們便需要使用polyfill,便是那麼一段程式碼,好讓新的語法也能解析。而事實上自已也可以針對語法的格式去寫出要的功能,但你必須對語言背後的行為邏輯有正確的理解

day09閉包Closure

1
2
3
4
5
6
7
8
9
function saveMoney(newSaving = 0) {
var myAccount = 20000;
return function () {
return myAccount + newSaving;
}
}
total = saveMoney(1000);
console.log(total());
//結果 21000

第一次看到這個寫法覺得很𫋵奇,卻不知道它就是所謂鼎鼎大名的閉包(Closure)

什麼是閉包?看code,是由兩個函式搆成的而且互為表裡。

JS用function去界定變數名稱的作用範圍,當我們它一固函式用另一個函式包起來,便會形成一個封閉的空間,而裡面的變數名稱,也只在範圍內可以使用,我們稱無private變數。使用它來達到隱藏資訊的效果。

再看一個累加的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
function accumulation(number=0){
return function(){
console.log(number++);
}
}
var total= accumulation();
total();
total();
total();
//結果
//0
//1
//2

為什麼number會一直累加上去,而因為每次執行而開始重新計算,這更是閉包達到的效果。藉由雙層函式的架構將number變數存在記憶體當中,並藉由accumulation去執行裡面的匿名函式去改變值。

閉包博大精深,網路上有很多不同的資訊,但基本的function產生作用域用雙層function去達到封閉作用空間,是我對閉包基本認知。

day10強制轉型

1
2
3
4
5
6
7
8
9
10
conditionA = "";
if (conditionA) {
console.log("hello");
}
//無結果
conditionB = "hello";
if (conditionB) {
console.log("hello");
}
//結果 hello

在修件判斷中,為什麼conditionA為什麼不會通過條件判斷?

在條件判斷中,我們需要得到一個true或false才能去判斷接下來要不要執行𧟕面的程式,但如果裡面不是一個單純的比較,是一個物件或者其他東西會發生什麼事情?

從JS的強制轉型搆起,看以下程式碼

1
2
3
4
5
6
7
8
var a=42;
var b=a+"";//隱含的強制轉型
var c= String(a);//明確的強制轉型

console.log(b);
console.log(c);
//42 ​​​​​at ​​​b​​​
//42 ​​​​​at ​​​c​​​

當我們的操作不符合正常狀況時,JS語言會幫我們進一步處理。回到我們條件判斷,我們應該給予一個Boolean值或一個得出Boolean值的比較,但如果是其他的東西,JS會幫我們進行強制轉型而要變為Boolean的true或fals就是用所謂的Truthy值或Falsy值去判斷。那我們現在給的東西是哪一種值呢?

其㲒可以很簡單,除了以下Falsy的,都是Truthy

  • undefined

  • null

  • false

  • +0、-0 以及NaN

  • “”

最上面的例子,其實常常用在判斷是否為空值時的判斷,我們也可以寫成以下,去判斷資料是否為空。

1
2
3
4
5
6
7
conditionA = "";

if (!conditionA) {
console.log("目前沒有值");
}
// 結果
//目前沒有值 ​​​​​

另外使用三元運算式將我們原本的程式改寫成更簡潔的狀態。

語法:

判斷式?如果符合:如果不符合

以下改寫

1
2
3
4
5
6
7
conditionA="";
!conditionA ? console.log("目前沒有值"):console.log(conditionA);
//結果 目前沒有值
//
conditionA="Hello";
!conditionA ? console.log("目前沒有值"):console.log(conditionA);
//結果 Hello

day11鏈式函式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var $ = function (Person) {
return {
breakTheIce: function (message) {
console.log(message + " My name is " + Person.name);
return this;
},
introduce: function () {
console.log("I'm a " + Person.intro + " Do you want to program with me?");
return this;
}
}
}

var Jim = {
name: "Jim Hu",
intro: "Programmer!"
}
$(Jim).breakTheIce("Hi").introduce();

day12修改網頁背景

把 google 首頁背景變成黃色的三種方式。

  1. 利用開發者模式功能

在開法者模式下選擇 HTML tag 的部分,
然後在後面style欄位填上 background: yellow

  1. 利用 JS 原生語法

利用 JavaScript 原生語法, getElementsByTagName 抓到
body 選項,再加上 style 改變顏色

1
2
var x = document.getElementsByTagName("body");
x[0].style.background = 'yellow'
  1. 利用 jQuery 函式庫

先創建一個包含引入函式庫的 標籤,再來抓取 標頭,將標籤附加上去。

1
2
3
4
5
6
var el = document.createElement('script');
el.src = "https://code.jquery.com/jquery-3.2.1.js"
document.getElementsByTagName('head')[0].appendChild(el);

//jQuery語法
$("body").css("background-color", "yellow");

day13網頁重繪的流程

要如何決定今天我們要製作一個移動的動畫,要用 translate 還是去改變 position 呢?
那我們就要了解一下重排(reflow)跟重繪(repaint)

五個步驟:

  1. HTML 轉換成 DOM
  2. CSS 轉換成 CSSOM
  3. 將兩個東西結合,生成一刻渲染樹 (包含每個節點的視覺訊息)
  4. 生成佈局(layout),即將所有渲染入的節點進行平面合成
  5. 將佈局繪製(paint)在平面上

而大致上網頁的變動都不斷在重複 4. 跟 5. 的動作,而效能選擇上的差異便在降低這兩項事情的發生。

需要重排跟重繪的狀況分為

  1. 修改DOM
  2. 修改樣式表
  3. 觸發事件

避免重新渲染,我們可以看看 CSStrigger 這個網站對於 transform 跟 position 的差異。就可以知道哪個選擇上比較合適。

參考資料:
阮一峰的网络日志
CSStrigger

day14網頁事件

事件表

事件 行為 例子
onchange 當元素改變 selector
onclick 當點下 HTML 元素 點擊後送出資料或選擇該元素
onmouseover 當滑鼠通過 HTML 元素 提示效果,通常會搭配 onclick
onmouseout 當滑鼠離開 HTML 元素
onkeydown 當按下鍵盤 抓取值顯示
onload 當瀏覽器載入頁面時後 串接 API 更改資料列

其實寫法很簡單,都是在 tag 裡面
加上你要執行的 function 名稱

1
<element event='some JavaScript'>

在例子中我們通常很難感受到效果,直到實際去操作。而 W3School 提供很多有趣的例子跟實驗平台,可以快速地以實踐為學習。推薦一開始從此下手。
學習連結

day16亂數的取法

思考:

如何取40~80間(不包含80)的亂數

我們用Math.random()的語法,可以得到一個0~1之間穴包含1的亂數

1
2
3
let res=Math.random();
console.log(res);
//0.8273783802602397

取亂數的方法:

  1. 將Math.random結果換算成取1~*並去掉小數點

  2. 用取餘數得到想要的亂數範圍

  3. 如果不是從0開始,加上要從多少開始數字

1
2
let res = Math.floor(Math.random() * 100) % 40 + 40;
console.log(res);

逐一拆解便為

  1. Math.random()*100 //可取0~99之間的數字
  2. Math.floor(Math.random()*100) //去掉小數點
  3. Math.floor(Math.random()*100)%40 //可取0~39之間的餘數
  4. Math.floor(Math.random()*100)%40+40//可霰40~79之間的餘數

day17畫圓

stackblitz
用Canvas畫圓,並做成一個簡單的小動畫,想要畫的圓是同心圓,並搭配Fibonacci數列,讓圓圈大小更自然的變化, 在HTML的碼

1
<canvas id="canvas" width="1000px" height="1000px"></canvas>
  1. 畫同心圓
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Import stylesheets
import './style.css';
// 畫圓功能
var circle= function (x, y, radius) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2*Math.PI, false);
ctx.stroke();
};
// Write Javascript code!
const appDiv = document.getElementById('app');
//appDiv.innerHTML = `<h1>JS Starter</h1>`;
let canvas = document.getElementById("canvas");
let ctx = canvas.getContext("2d");
ctx.lineWidth = 4;

ctx.strokeStyle = "Red";
circle(200,150,10);

ctx.strokeStyle = "orange";
circle(200, 150, 20);

ctx.strokeStyle = "yellow";
circle(200, 150, 30);

ctx.strokeStyle = "Green";
circle(200, 150, 50);

ctx.strokeStyle = "Blue";
circle(200, 150, 80);

ctx.strokeStyle = "Purple";
circle(200, 150, 130);
  1. 用for loop 優化程式

    因為位置是重複的,所以可以試著把同樣的東西,用陣列與迴圈整理出來

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // Import stylesheets
    import './style.css';
    // Write Javascript code!
    const appDiv = document.getElementById('app');
    // 畫圓功能
    var circle= function (x, y, radius) {
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, 2*Math.PI, false);
    ctx.stroke();
    };

    let canvas = document.getElementById("canvas");
    let ctx = canvas.getContext("2d");
    ctx.lineWidth = 4;

    let styles = ["Red","orange","yellow","Green","Blue","Purple","Gray"]
    let size = [10,20,30,50,80,130,210]
    for(let i=0;i<7;i++){
    ctx.strokeStyle = styles[i];
    circle(200,150,size[i]);
    }
  2. 用setInterval反覆畫圖,將其變成動畫

    因為是動態的,所以不會一開始就色所有的線都畫出來,並且會一直執行畫線的動作,所以利用setInterval並改變半徑大小畫出不同的圓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Import stylesheets
import './style.css';
// Write Javascript code!
const appDiv = document.getElementById('app');
// 畫圓功能
var circle= function (x, y, radius) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2*Math.PI, false);
ctx.stroke();
};

let canvas = document.getElementById("canvas");
let ctx = canvas.getContext("2d");
ctx.lineWidth = 4;



let styles = ["Red", "orange","yellow", "Green", "Blue", "Purple", "Gray"];
let size = [10, 20, 30, 50, 80, 130, 210];
let styleClear = "white"
let i = 0

setInterval(function(){
ctx.lineWidth = 4;
ctx.strokeStyle = styles[i];
circle(200, 150, size[i]);
ctx.lineWidth = 5;
ctx.strokeStyle = styleClear;
circle(200, 150, size[i-3]);

i += 1;
if ( i > 9 ) {
i = 0
}
}, 1000);

day18錯誤處理

通常錯誤處理的格式為try,catch,throw.

一個字典查詢的功能,這裡用一個陣列來看看如何處理錯誤。當我們有找到值的時候,會回傳這個值,如果沒有就會進行錯誤處理。

1
2
3
4
5
6
7
8
9
10
11
12
function checkDictionary(key) {
let words = {
'apple': "蘋果",
'banana': "香蕉",
'peach': "桃子"
};
if (words[key]) {
return words[key];
} else {
throw key;
}
}

if(words[key]) 的寫法我們從if(wordd[key] != null) 簡化而來,因為null是falsy物件,所以當沒有值時結果會與判斷式寫法相等。

1
2
3
4
5
6
7
try {
let res = checkDictionary("apple");
console.log(res);
} catch (e) {
console.log("We don\'t know this world: " + e);
}
//蘋果

用try去執行一個function並在裡面加入流程控制,除了正常的功能外,如有錯誤也用throw指令,直接跳往catch進行進一步的處理,傳遞參數可以是任何型態 ,由catch去接收。而當在try中直接發生錯誤或我們沒有使用throw時,發生錯誤不會執行catch裡的功能,所以我們可能無法掌握錯誤的中斷之處。

所以有try catch的語句一定記得要有throw

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function checkDictionary(key) {

let words = {
'apple': "蘋果",
'banana': "香蕉",
'peach': "桃子"
};
if (words[key]) {
return words[key];
}
else {
throw key; //有try catch 一定要有 不然會undefie
}
}

try {
let res = checkDictionary("mongo");
console.log(res);
} catch (e) {
console.log("We don\'t know this world: " + e);
}

day19 chrome 工具

在chrome在按下F12按下時,會出現debug的工具,有可以設定的選項。

在Performance的部分可以記錄效能,可以勾選記憶體使用以燭幕擷取的選項,它提供數字,畫面甚至圖表資料,讓我們進一步分析程式效能

day20 時間的精度

一直以來都以為要用 setTimeout 或 setInterval 來製作動畫,但 setTimeout 最快也只能到 10 毫秒,可能造成畫面遺失的問題,而我們就可以用 requestAnimationFrame 這個方法。 一起來看一下 MicroSoft Developer Network 提供的這個例子,在裡面學到不少東西。

說明:
window.performance.now 高精準的時間戳,可以取到一毫秒的千分之一
elm.style.left = ((lpos += 3) % 600) + “px”; 利用累加方式調整矩形位置,並用餘數值來限定範圍
requestId = window.requestAFrame(render) 計算 requestAFrame 執行的次數, requestAFrame 的回傳值會從 1 開始持續往上累加

stackblitz

index.html

1
2
3
4
5
<body >
<div id="animated">Hello there.</div>
<button id="btnstart" >Start</button>
<button id="bnstop" >Stop</button>
</body>

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Import stylesheets
import './style.css';
//多瀏覽器的支援
window.cancelAFrame = (function () {
return window.cancelAnimationFrame ||
window.webkitCancelAnimationFrame ||
window.mozCancelAnimationFrame ||
window.oCancelAnimationFrame ||
function (id) {
window.clearTimeout(id);
};
})();

var requestId = 0;
var startime = 0;
var lpos = 0;
var elm;


function init() {
console.log("init");
elm = document.getElementById("animated");

}
function render() {
elm.style.left = ((lpos += 3) % 600) + "px";
requestId = window.requestAFrame(render);
}

function start() {
console.log("start");
if (window.performance.now) {
startime = window.performance.now();
} else {
startime = Date.now();
}
requestId = window.requestAFrame(render);
}
function stop() {
console.log("stop");
if (requestId)
window.cancelAFrame(requestId);
}

// handle multiple browsers for requestAnimationFrame()
window.requestAFrame = (function () {
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
// if all else fails, use setTimeout
function (callback) {
return window.setTimeout(callback, 1000 / 60); // shoot for 60 fps
};
})();

document.body.addEventListener("load", init(), false);
document.getElementById("btnstart").addEventListener('click', start);
document.getElementById("bnstop").addEventListener("click", stop);

day21 DOM

W3School介紹HTMLDOM時,有一個觀念會被一直強調

DOM是一個樹狀模型中,所有東西都是結點(node)。所有結點分為ACDET

  1. docment根(document)
  2. HTML元素(element)
  3. 屬性(attribute)
  4. 文字(text)
  5. 註解(comment)

當HTML文件被載入瀏覽器的時候,就會產生一個document物件做為根節點。而document物件是所有節點的擁有者,並提供屬性跟方法去操控其它結點。

Elemnt object
NodeList object(集合,有序)
Att object
NamedNodeMap objcet(集合,沒有順序)
Style object

思考:
Attr Object 和 Style Object 在哪?結果發現自己總是把CSS 調整的 style 跟 HTML 本身的 attribute 搞混。Attribute 是跟 HTML 標籤一起出現的,例如我們使用 placeholder 去提示要輸入的訊息。而屬性分為兩種,content(HTML)跟 IDL (JavaScript),也可以自定屬性。補充資料。內容為更多屬性介紹,作者:PJ

完整的 attribute 列表

說明:
當我們開始操控 JS ,我們會使用 document 的方法,去抓取節點。然後用串接(chain)的方式,進行之後動作。下面的例子,我們選取到的不是單一元素,因為有多個 P tag,行程所謂的 Node list ,再藉由 item 去選取到要針對的元素。

1
document.getElementsByTagName("P").item(0).innerHTML;

所以排下來的順序大概是這樣的:
Document > Element + NodeList > Attr + NamedNodeMap + style + DOM Events > style

day22程式邏輯

上LeetCode去練習程式邏輯,人家所謂的bug就是思考上的缺陷,有時候𤆧法能解,但是思考角度不對,其實就會在測試中出現問題。

下面的例子是給定一個陣列,真一個目標,找尋是否陣列裡有一對元素,相加等於目標數字。

1
2
3
Given nums = [2,7,11,15] ,target =9,
Because nums[0] + num[1] = 2+7 =9
return [0,1]

在這個例子中,當如果輸入兩個不同數字,結果正確。但如果兩個數字相同,就會出現錯誤。

1
2
3
4
5
6
7
8
9
10
11
12
13
var nums = [3,3]
var target = 6

var twoSum = function(nums, target) {
for (var i = 1; i < target ; i++)
if (nums.indexOf(i) + 1 && nums.indexOf(target - i) + 1) {
return [nums.indexOf(i), nums.indexOf(target - i)]
} else {
if( i == target - 1) {
return console.log("Sorry")
}
}
};

而正確的作法應該要用兩個迴圈。以下為pseudocode

1
2
3
4
5
6
7
8
9
10
public int[] twoSum(int[] nums, int target) {
for (int i = 0; i < nums.length; i++) {
for (int j = i + 1; j < nums.length; j++) {
if (nums[j] == target - nums[i]) {
return new int[] { i, j };
}
}
}
throw new IllegalArgumentException("No two sum solution");
}

day23 Html的ID

當你在HTML標籤裡宣告ID的時候,其實就同時在JS裡宣告一僤全域變數。

說明:

用不同抓取element的方式(除ID之外)去更改tag裡面的內容,最後用createElement製造一個按鈕,然後用addEventListener去監聽按下的事件來執行。

stackblitz

1
2
3
4
<h1>Hello</h1>
<h2>My Friend</h2>
<h3 class="lion" >My Class</h3>
<h4 id="king" >My ID</h4>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Import stylesheets
import './style.css';

var btn =document.createElement("button");
var example = document.getElementsByTagName("h1");
var example2 = document.getElementsByTagName("h2");
var example3 = document.getElementsByClassName("lion");

btn.innerHTML="按鈕比大顆";
btn.style.width="100px";
btn.style.height="100px";
btn.style.background="yellow";
btn.style.fontSize="25px";
document.body.appendChild(btn);

btn.addEventListener("click",function(e){
example[0].innerHTML="Ha Ha";
example2[0].innerHTML="My My";
example3[0].innerHTML="Hi Hi";
//殺出一個程咬金
king.innerHTML ="Bye Bye";
})

可以看到最後一行,其實是直接用==ID名稱==,沒有getElementById,去串接innerHTML屬性來指派值,卻成功了

day24 html屬性

釐清一下HTML屬性與JS的關系,如何去叫用HTML裡面的屬性。我們可以把每個HTML tag 想成是一個物件,擁有自已的屬性,並且以element.propertyName的方式叫用。

stackblitz
以下例子,去判斷屬性名稱為特定姓名的話,將placeholder裡面的值修改html

1
2
3
4
5
6
<input type = "json" name="Jason" placeholder="一">
<input type = "json" name="John" placeholder="二">
<input type = "json" name="Peter" placeholder="三">
<input type = "json" name="Jim" placeholder="四">
<input type = "json" name="Amy" placeholder="五">
<button class="changePlaceholder">Action</button>

js的cdoe

1
2
3
4
5
6
7
document.getElementsByClassName("changePlaceholder")[0].addEventListener('click', function () {
document.querySelectorAll("INPUT").forEach(function (node) {
if (node.name == 'Jim') {
node.placehlder = 'Jim';
}
});
});

但是在HTML裡面他不是一個真實的物件,不是你自已加上名稱就會擁有,所以當我們要自訂屬緎的時候,必須符合它的規則。把資料屬緎在HTML tag裡寫成data-protertyName,並在JS中用element.dataset.propertyName取值

說明:

自訂一個屬性,當按下按金,秀出屬性內容

html的cdoe

1
2
<h1 data-hint="insert Data" name="Json">My data</h1>
<button class="showData">Action</button>

在jscode

1
2
3
4
document.getElementsByClassName("showData")[0].addEventListener('click', function () {
var myData = document.getElementsByTagName("H1")[0].dataset;
console.log(myData);
});

day25 抓取輸入

當熟悉抓element裡面的值之後,就可以把輸入振解成“監聽事件”觸發加上“抓取值”。input就是一個盒子,當事事件觸發的時候,動手去拿裡面的東西。然後清空裡 的東西,才不會盒子看起來髒髒的。

stackblitz

說明:
最簡易的用兩個HTML tag,包括input與button(甚至只用一個input, 按enter觸發,但記得考慮手機可能會不方便觸發輸入)

1
2
3
<h1>有沒有頻果的產品</h1>
<input class="insert" type="text" placeholder="現在搜尋" >
<button class="showData">Action</button>

說明:
input的初始設定placeholder是靠左,且字是與邊框貼齊的,所以我們會使用text-align與padding去調整文字位置與空出邊空的距離。

1
2
3
4
5
6
insert{
margin: 10px;
padding-right: 5px;
height: 15px;
text-align: right;
}

說明:

這裡用一個array來暫時記錄apple產品包含的資料,實際資料也可以是接api的資料庫、瀏覽器的localstrage或cookie。於是當我們輸入的時候,用includes去搜尋陣列,回傳true or false,來判斷是否有這個值。

1
2
3
4
5
6
7
8
9
10
11
var apples = ['手機', '電腦', '滑鼠', '支付', '電視', '音響'];
document.getElementsByClassName("showData")[0].addEventListener("click", function () {
var insert = document.getElementsByClassName("insert")[0];
var result = apples.includes(insert.value);
if (result) {
console.log("有喔有喔");
} else {
console.log("等你發明");
}
insert.value="";
});

day26置換class網頁效能的方法一

提高網頁效能的方法一用JS置換class。
我們做一個checkbox,可以使用加上class的方式,來呈現checked效果。

stackblitz

說明:
我們用sprite的方式,來切換”部分”背景圖片,製造圖片切換的效果。而不需要重新載入一個圖片進行切換。

1
2
<h1>戳我戳我</h1>
<div class="sampleClass" ></div>
1
2
3
4
5
6
7
8
9
10
.sampleClass {
width:300px;
height:330px;
background:url('https://image.freepik.com/free-vector/check-and-cross-signs-paint-design_1102-228.jpg') no-repeat;
background-position: 100% 50%;
background-size:cover;
}
.active{
background-position: 0% 50%;
}

說明:
使用className這個屬性,抓出元素的class名稱字串,再去針對字串處理加減class來達到效果。而下面的程式碼用includes去判斷是否是checked的狀態,也就是有加上active的class,如果有就切換回原本的名稱,如果沒有就加上active。

1
2
3
4
5
6
7
8
9
var checkBar = document.getElementsByTagName("div")[0];
checkBar.addEventListener("click", () => {
//console.log("checkBar.className="+checkBar.className);
if (checkBar.className.includes("active")) {
checkBar.className = "sampleClass";
} else {
checkBar.className += " active";
}
});

day27置換css屬性網頁效能的方法二

用cssText來置換屬性。每當點擊橘色盒子的時候,盒子就會向左偏移。並做一個按鈕可以讓盒子回到原來位置。

stackblitz

說明:
我們把原本的rectangle.style.left = left + 'px';
置換成rectangle.style.sytle.cssText+=left: ${left}px;來避免reflow

小提醒:
因為cssText是直接複寫css內容,所以我們用+=累加的方式,將要修改的樣式字串加到後面。

HTML code:

1
2
<div class="move where">點我向右走</dvi>
<button class="reset">reset</button>

CSS code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.move {
width:100px;
line-height:100px;
text-align: center;
background:orange;
position:absolute;
top:50%;
transform: translateY(-50%);
cursor:pointer;
}
.where{
left:10px;
}
.reset{
border-radius:50px;
font-weight:bold;
background:black;
color:white;
}

JS code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Import stylesheets
import './style.css';

var left = 10;
var top = 10;

var rectangle = document.getElementsByClassName("where")[0];
var reset = document.getElementsByClassName("reset")[0];

rectangle.addEventListener("click", function () {
left += 10;
rectangle.style.cssText = `left:${left}px`;
//rectangle.style.left= left+'px';
});

reset.addEventListener("click", () => {
rectangle.style.cssText = "left:10px";
left = 10;
});

day28處理時間

javascript如何處理時間

stackblitz

html

1
2
<h1 class="time"></h1>
<button class="transTime">現在是幾年</button>

javascript

1
2
3
4
5
6
7
8
9
var ele = document.getelementsByClassName("time")[0];
var trans = document.getElementsByClassName("transTime")[0];
var now = new Date().getFullYear()
ele.innerHTML= now;
trans.addEventListener("click",function(){
now = now - 1911;
ele.innerHTML = '民 '+now;
trans.disabled = 'disabled';
});

day29 一些小技巧

總要進步,總能更好,保持學習。

以下一個清除陣列的小技巧

1
2
3
4
var list = [1, 2, 3, 4];
list.length = 0;
console.log(list);
//[]

下一步,JS框架。
思考:為什麼要用框架?
如果只是寫JS的一些小東西,可能不需要用到框架。但當專案的規模越來越大,更需要有效的管理程式碼,考慮擴展與降低重複問題。很多東西是一體的兩面,框架用得好可以幫助我們產生更好的成果與增加可維謢性,用得不好可能綁手綁腳,本末導致。除了規模上的總則,選擇框架也會考慮技術的成熟度,社群的支援度,甚至是學習曲線。而下面是始一個Vue.js的例子。

說明:

兩個大括號中間是渲染的文字,然後把資料分離到JS裡面。data的部分就是儲存文字資料的地方。所以當我們之後要改變資料內容。就不需要直接改html畫面的檔案,而是去調整JS裡的資料內容,

1
2
3
<div id="app">
{{ text }} Nice to meet Vue.
</div>
1
2
3
4
5
6
new Vue({
el: '#app',
data: {
text: 'Hello World!'
}
});
參考資料

一個 JS 學習者的日常 共 30 篇

https://rxjs-dev.firebaseapp.com/api

前言

之前在nodejs下有成功的建立動態的namespace,因為後端是使用python所以來測試一下是否可以動態的來產生namespace

服務端

一樣是建立Flask-SocketIO的服務器,可以參考之前的建立的專案在app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from flask import Flask, request
from flask_socketio import SocketIO, send, emit


app = Flask(__name__)

app.config["SECRET_KEY"] = "secret1"
socketio = SocketIO(app)
@app.route('/')
def index():
return "hello Flask-SocketIO"


@socketio.on("connect")
def onconnect():
# socket id
currentSocketId = request.sid
# socketio namespace
print(request.namespace)
print("[server]<connect> socket.id=%s" % (currentSocketId))


@socketio.on("connected")
def onConnected(data):
currentSocketId = request.sid
print("[server]<connected> socket.id=%s msg=%s" %
(currentSocketId, data))


@socketio.on("disconnect")
def ondisconnect():
print("[server]<disconnect>")


@socketio.on("chatmessage")
def onchatmessage(data):
msg = "[server]<chatmessage>:%s" % (data)
print("[server]<chatmessage>:%s", msg)
## emit("chatmessage", data)
## emit("chatmessage", data, broadcast=True)
emit("chatmessage", data, broadcast=True, include_self=False)


if __name__ == "__main__":
app.debug = False # vscode 才可以偵錯
socketio.run(host='localhost', port=5000)

server起動時,如果要修改port可以修改.vscode\launch.json中的args

1
2
3
4
5
6
"args": [
"run",
"--port=6000",
"--no-debugger",
"--no-reload"
],

在送json給客戶端有個坑,只要在送的時候設定一下json=True如下的部份程式碼

1
2
3
4
senddata = {'result': self.namespace_queue}
print("[server]<updateNamespaceList> socket.id=%s result=%s" %
(currentSocketId, senddata))
emit('updateNamespaceList', senddata, broadcast=True, json=True)

客戶端

可以參考之前建立的專案學習Flask-SocketIO的聊天訊息

因為要有多台的電腦可以連線來測試,所以要修改angular.json的內容,要加入"host": "0.0.0.0"如下

1
2
3
4
5
6
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "chatclient:build",
"host": "0.0.0.0"
},

主要的程式碼

我是以手冊上建議的Class-Based Namespaces的方式來處理動態的namespace一開始先建立一個預設的namespace先就是空字串給它

1
2
3
4
5
6
def init_app(self, app):
self.app = app
socketio.init_app(app)
self.createClassNamepace("", True)
# self.createClassNamepace("/")
# self.createClassNamepace("/bb")

createClassNamepace的程式中是在server中有一個list來保留有多少被建立的namespace和建立真正有網路事件中處理的物件MyCustomNamespace之後要掛起來socketio.on_namespace(myclsns)

1
2
3
4
5
def createClassNamepace(self, nsname, startup=False):
self.createNamespace(nsname, startup)
myclsns = MyCustomNamespace("/"+nsname)
self.CustomNamespace.append(myclsns)
socketio.on_namespace(myclsns)

在物件MyCustomNamespace要注意的是物件功能的名稱例如你的事件是connect那你的物件的方法名稱是on_connect此外在加入namespace只要通知自已

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class MyCustomNamespace(Namespace):
ChatServer = None
# 客戶connect的事件

def on_connect(self):
sckns = request.namespace
fmt = "[myns ns=%s]<connect>" % (sckns)
print(fmt)
self.sendUpdateNamespace()
# 客戶disconnect的事件

def on_disconnect(self):
sckns = request.namespace
fmt = "[myns ns=%s]<disconnect>" % (sckns)
print(fmt)
# 客戶已連線connected的事件

def on_connected(self, data):
# socket id
currentSocketId = request.sid
sckns = request.namespace
fmt = "[myns ns=%s]<connected> socket.id=%s msg=%s" % (
sckns, currentSocketId, data)
print(fmt)
# 聊天chatmessage的事件

def on_chatmessage(self, data):
currentSocketId = request.sid
sckns = request.namespace
fmt = "[myns ns=%s]<chatmessage>:%s" % (sckns, data)
print(fmt)
# emit("chatmessage", data)
emit("chatmessage", data, broadcast=True)
# emit("chatmessage", data, broadcast=True, include_self=False)
# 建立createNamespace的事件

def on_createNamespace(self, data):
currentSocketId = request.sid
sckns = request.namespace
print("[myns ns=%s]<createNamespace> socket.id=%s nsname=%s" %
(sckns, currentSocketId, data["name"]))
self.ChatServer.createClassNamepace(data["name"])

# 傳送namespace列表

def sendUpdateNamespace(self):
currentSocketId = request.sid
sckns = request.namespace
senddata = {'result': self.ChatServer.namespace_queue}
print("[myns ns=%s]<updateNamespaceList> socket.id=%s result=%s" %
(sckns, currentSocketId, senddata))
emit('updateNamespaceList', senddata, broadcast=True, json=True)
# 加入JoinToApp事件加入某個namespace

def on_JoinToApp(self, data):
namespaceToConnect = self.ChatServer.searchObjectOnArray(
data["namespace"])
if namespaceToConnect != None:
sendmsg = {"namespace": namespaceToConnect}
emit('JoinToApp', sendmsg, json=True)

可以參考githubTestFlaskSocketIONs專案

因為要處理影像相關的程式所以要來建立新的service的處理

1
ng g service services/imagefile 

客戶端一次開8個

將傳送端分開先產生頁面

1
ng g c \chat\sendchannel

將之的room1, 掛到這個元件的下面在src\app\chat\sendchannel\sendchannel.component.html

1
2
3
4
<p>
不顯示只傳送: <input type="checkbox" id="myCheck" [(ngModel)]="myCheck" [value]="myCheck" (click)="CheckClick()" />
</p>
<app-room1 [isVisiableImg]="myCheck"></app-room1>

先產生頁面chat\room,

1
ng g c \chat\room

在加入路由表在src\app\app-routing.module.ts的內容

1
2
3
4
5
const routes: Routes = [
{ path: '', redirectTo: 'room', pathMatch: 'full' },
{ path: 'sendchannel', component: SendchannelComponent },
{ path: 'room', component: RoomComponent },
];

每一個是獨立的charRoom,所以來產生

1
ng g c \chat\charRoom

因為要有4個頻道所以來產生char\channel

1
ng g c \chat\channel

問題目前同時連線到服務器,上限只有5個,超個5個不能動作,原因還不知道是因為沒有使用winsocket的協議,在python中要安裝gevent-websocket這個套件

參考資料

結合manage.py,在flask項目中使用 flask-socketio
ular 4 上传多个文件到Spring boot
Angular2裡獲取(input file)上傳檔案的內容的方法
How to allow access outside localhost

前言

這是為了了解Flask-SocketIO的特性和是否可以使用動態的namespace來建立的測試程式

設定環境

先建立來使用虛擬環境來建立Flask的環境

可以參考之前學習Flask起手式因為我之前有建立好的虛擬環境,所以只要起動它就可以,在下面建立app.py的內容如下

1
2
3
4
5
6
7
8
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return "hello flask"
if __name__ == "__main__":
app.debug = False # vscode 才可以偵錯
app.run(host='localhost', port=5000)

在設定vscode的工作區設定裏將虛擬環境的python執行路徑加入到python:Venv Path如下的圖示

目前的內容如下,這是虛擬環境的python的執行檔

1
D:\Project\github\StudyPython\pyvirenv\pyenv37\Scripts

要設定launch.json的內容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
// 使用 IntelliSense 以得知可用的屬性。
// 暫留以檢視現有屬性的描述。
// 如需詳細資訊,請瀏覽: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python:Flask",
"type": "python",
"request": "launch",
"module": "flask",
// 選擇虛擬環境中的python版本
"pythonPath": "D:/Project/github/StudyPython/pyvirenv/pyenv37/Scripts/python.exe",
"env": {
"FLASK_APP": "app.py",
"FLASK_ENV": "development",
"FLASK_DEBUG": "0"
},
"args": [
"run",
"--no-debugger",
"--no-reload"
],
"jinja": true
}
]
}

安裝Flask-SocketIO

啟動虛擬環境來安裝

1
2
pip install flask-socketio
pip install gevent

修改app.py如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from flask import Flask, request
from flask_socketio import SocketIO, send, emit


app = Flask(__name__)

app.config["SECRET_KEY"] = "secret1"
socketio = SocketIO(app)
@app.route('/')
def index():
return "hello Flask-SocketIO"


@socketio.on("connect")
def onconnect():
# socket id
currentSocketId = request.sid
# socketio namespace
print(request.namespace)
print("[server]<connect> socket.id=%s" % (currentSocketId))


@socketio.on("connected")
def onConnected(data):
currentSocketId = request.sid
print("[server]<connected> socket.id=%s msg=%s" %
(currentSocketId, data))


@socketio.on("disconnect")
def ondisconnect():
print("[server]<disconnect>")


@socketio.on("chatmessage")
def onchatmessage(data):
msg = "[server]<chatmessage>:%s" % (data)
print("[server]<chatmessage>:%s", msg)
##emit("chatmessage", {'data': data})
emit("chatmessage", data)


if __name__ == "__main__":
app.debug = False # vscode 才可以偵錯
socketio.run(host='localhost', port=5000)

客戶端

設定client端是用Angular可以參考之前的學習typescript和socket-io的使用有關客戶端的設定

建立新的angular的客戶端

1
ng new chatclient

建立chat下的room1的頁面

1
ng g c chat/room1

安裝用到的模組

1
npm install socket.io --save

加入連線用到的服務

1
ng g service chat/services/socket

src\app\chat\services\socket.service.ts如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { Injectable } from '@angular/core';
import * as socketIo from "socket.io-client";

import { Event } from "../model/event";
import { Observable } from 'rxjs';


@Injectable({
providedIn: 'root'
})
export class SocketService {
private socket: socketIo;
constructor() { }
public initSocket(ns_url: string) {
this.socket = socketIo(ns_url);
}
public onEvent(event: Event): Observable<any> {
return new Observable<Event>(observer => {
this.socket.on(event, () => observer.next());
});
}
public SendConnect() {
this.socket.emit("connected", "我已連線了");
}
public Sendchatmessage(msg: string): void {
this.socket.emit("chatmessage", msg);
}
public Onchatmessage(): Observable<string> {
return new Observable<string>(observer => {
this.socket.on("chatmessage", (data: string) => {
observer.next(data)
});
});
}
// public disconnect(){
// this.socket.disconnect();
// }

}

剩下的可以參考一下github中的TestFlaskSocketIOchat

注意app.py中的emit使用上要注意

1
2
3
emit("chatmessage", data) ##只傳送給自已
emit("chatmessage", data, broadcast=True) ## 會傳送給所有在這namespace和room包括自已
emit("chatmessage", data, broadcast=True, include_self=False) ## 會傳送給所有在這namespace和room不包括自已

前言

先來測試一個在Socket.IO裏的namespaceroom的使用,先有一個namespce下面有一個聊天的頻道,在有一個namespace加上有4個room來傳送和接收圖片

先建立專案和之前的方式一樣先建立一個資料夾namespace-example,在下其它的指令

1
2
3
npm init
npm install --save express
npm install --save socket.io

單一namespace和單一room頻道的測試

服務端的設定

先設定有幾個namespace,可以處理多個namespaceapp.js下設定

1
2
3
4
5
6
//建立socket.io的namespace
var namespaces = [
io.of('/nsp_chat'),
io.of('/ns2'),
io.of('/ns3')
];

在設定有幾個room房間,目前先設定四個

1
2
//建立room
let registeredRooms = ["channel1", "channel2", "channel3","channel4"];

在加入可以回傳處理的函式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (i in namespaces) {
namespaces[i].on('connection',handleConnection(namespaces[i]));
}
function handleConnection(ns) {
return function (socket){ //connection
console.log("connected ");
socket.emit("connected",registeredRooms);
//socket.on('setUsername',setUsernameCallback(socket,ns));
socket.on('disconnect', disconnectCallback(socket,ns));
socket.on('chatmessage',chatMessageCallback(socket,ns));
//socket.on('messageChat',messageChatCallback(socket,ns));
socket.on('createJoinRoom',createJoinRoomCallback(socket,ns));
socket.on('bytemessage',byteMessaecCallback(socket,ns));
};
}

每個namespace有自已處理的函式

在流程是

  • client先設定好連線到server
  • server收到連線後會回傳房間列表
  • client端收到房間列表,送createJoinRoom和那一個房間到server
  • server收到createJoinRoom, 將client加入它送上來的房間和回傳success
  • client收到有效房間的訊息
    如下圖所示

app.js的程式碼如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
var app = require('express')();
var http = require('http').createServer(app);
var io = require('socket.io')(http);


app.get('/',function(req,res){
res.sendFile(__dirname+'/index.html');
});

//有關Socket.IO的設定
//建立socket.io的namespace
var namespaces = [
io.of('/nsp_chat'),
io.of('/ns2'),
io.of('/ns3')
];
//建立room
let registeredRooms = ["channel1", "channel2", "channel3","channel4"];
for (i in namespaces) {
namespaces[i].on('connection',handleConnection(namespaces[i]));
}

function handleConnection(ns) {

return function (socket){ //connection
console.log("connection ");
socket.emit("connection",registeredRooms);
//socket.on('setUsername',setUsernameCallback(socket,ns));
socket.on('disconnect', disconnectCallback(socket,ns));
socket.on('chatmessage',chatMessageCallback(socket,ns));
//socket.on('messageChat',messageChatCallback(socket,ns));
socket.on('createJoinRoom',createJoinRoomCallback(socket,ns));
socket.on('bytemessage',byteMessaecCallback(socket,ns));

};
}
//
function byteMessaecCallback(socket,ns){
return function(bufdata){
console.log("bytemessage ");
socket.emit("bytemessage",bufdata);
};
}
//
function connectedCallback(socket, ns){
return function(socket){
console.log("connected ");
//socket.broadcast.send("It works!");
};
}
// 加入房間
function createJoinRoomCallback(socket, ns) {
return function(room){
console.log("Joining Room...: " + room);
if(registeredRooms.includes(room)){
//socket已加入房間
socket.emit("success","有效房間名:"+room);
}else{
// 沒有此房間
socket.emit("err","無效房間名:"+room);
}
}
}
//斷線處理
function disconnectCallback(socket,ns) {
return function(msg){
console.log("Disconnected ");
socket.broadcast.send("It works!");
};
}
// 聊天訊息
function chatMessageCallback(socket,ns){
return function(msg){
console.log('chatmessage: '+ msg);
socket.emit('chatmessage',msg);
}
}




//有關Socket.IO的處理
//有關chat
// 有client連線
// nsp_chat.on('connection',function(socket){
// socket.broadcast.emit('chat message','hi');
// console.log('an user connected');
// //client斷線
// nsp_chat.on('disconnect',function(){
// console.log('user disconnected');
// });
// nsp_chat.on('chat message',function(msg){
// console.log('message: '+ msg);
// //收到訊息後,回傳給自已
// socket.emit('chat message',msg);
// });
// });


http.listen(3000,function(){
console.log('listen on *:3000');
});

客戶端的設定

index.html的程式碼如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
<!DOCTYPE html>
<html>

<head>
<title>Socket.IO chat</title>
</head>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font: 13px Helvetica, Arail;
}
.imgshow {
width:20%;
height:200px;
float: left;
}
.inputdiv {
background: rgb(224, 188, 188);
margin-right: .5%;
float: left;
padding: 3px;
bottom: 0;
width: 20%;
}

.clsmsginput {
border: 0;
width: 50%;
margin-right: .5%;
}

#msgbtn {
background: rgb(130, 224, 225);
border: none;
padding: 1px;
}

#messages {
list-style-type: none;
margin: 0;
padding: 0;
}

#messages li {
padding: 5px 10px;
}

#messages li:nth-child(odd) {
background: #eee;
}

#messages {
margin-bottom: 40px
}
</style>

<body>
不顯示只傳送: <input type="checkbox" id="myCheck" />
<div>
<div class="imgshow" >
<img id="photo1" style="width:100%; height:100%" />
</div>
<div class="imgshow" >
<img id="photo2" style="width:100%; height:100%" />
</div>
<div class="imgshow" >
<img id="photo3" style="width:100%; height:100%" />
</div>
<div class="imgshow" >
<img id="photo4" style="width:100%; height:100%" />
</div>
</div>


<h1 style="clear: left;">群聊</h1>
<ul id="messages"></ul>
<div >
<div class="inputdiv" >
輸入:<input type="text" class="clsmsginput" id="msginput1">
<button id="msgbtn1">發送</button>
<hr>
<input type="file" id="imgfile1" multiple="multiple">
<button id="imgbtn1">發送圖片</button>
</div>
<div class="inputdiv">
輸入:<input type="text" class="clsmsginput">
<button id="msgbtn2">發送</button>
<hr>
<input type="file" id="imgfile2" multiple="multiple">
<button id="imgbtn2">發送圖片</button>
</div>
<div class="inputdiv">
輸入:<input type="text" class="clsmsginput" id="msginput3">
<button id="msgbtn3">發送</button>
<hr>
<input type="file" id="imgfile3" multiple="multiple">
<button id="imgbtn3">發送圖片</button>
</div>
<div class="inputdiv">
輸入:<input type="text" class="clsmsginput">
<button id="msgbtn4">發送</button>
<hr>
<input type="file" id="imgfile4" multiple="multiple">
<button id="imgbtn4">發送圖片</button>
</div>
</div>

<script src="/socket.io/socket.io.js"></script>
<script src="https://code.jquery.com/jquery-1.11.1.js"></script>
<script>
$(function () {
var channellist=[];
//網路相關
//建立連接
var socket_chat = io('http://localhost:3000/nsp_chat');
socket_chat.on('connection',function(chlist){
console.log('connection:'+chlist);
channellist=chlist;
socket_chat.emit('createJoinRoom',channellist[0]);
});
socket_chat.on('chatmessage', function (msg) {
//console.log('收到:'+msg);
$('#messages').append($('<li>').text(msg));
window.scrollTo(0, document.body.scrollHeight);
});
socket_chat.on('success',function(room){
var limsg='成功加入此房間:'+room;
//console.log('成功加入:'+room);
$('#messages').append($('<li>').text(limsg));
});
socket_chat.on('err',function(room){
var limsg='無效房間名:'+room;
//console.log('無效房間名:'+room);
$('#messages').append($('<li>').text(limsg));
});
socket_chat.on('bytemessage', function (data) {
console.log('byte message');
//var bufView = new Uint8Array(data);
//console.log(data.buffer);
// Get the checkbox
var checkBox = document.getElementById("myCheck");
if (checkBox.checked != true) {
var blob = new Blob([data], { type: "image/jpeg" });
var urlCreator = window.URL || window.webkitURL;
var imageUrl = urlCreator.createObjectURL(blob);
var img = document.querySelector("#photo1");
img.src = imageUrl;
}
data.length=0;
});
//------------------------------------------
$('#msgbtn1').click((e) => {
let msg = $('#msginput1').val();
console.log('發射:[' + msg + ']');
socket_chat.emit('chatmessage', msg);
$('#msginput1').val('');
return false;
});
//發送圖片
$('#imgbtn1').click((e) => {
console.log('imgbtn1:click');
//讀所有的圖檔
const uploadPromises = [];
var files=$('#imgfile1').get(0).files;
var Bufferary= new ArrayBuffer(files.length);
for(i=0 ;i<files.length;i++){
var uploadPromise=getBufferFromFile(files[i]);
uploadPromises.push(uploadPromise);
}
Promise.all(uploadPromises).then(result => {
for (i = 0; i < files.length; i++) {
Bufferary[i] = result[i];
}
//測試
socket_chat.emit('bytemessage', Bufferary[0]);
//socket_chat.emit('byte message', Bufferary[0]);
//開始發射
// setInterval(() => {
// for (i = 0; i < files.length; i++) {
// var file = files[i]
// //console.log('發射:'+file.name )
// //socket.emit('byte message', { image: true, buffer: Bufferary[i] });
// socket.emit('byte message', Bufferary[i]);
// }
// }, 500);
});
});
});
function getBufferFromFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();

reader.readAsArrayBuffer(file);
reader.onload = () => {

var buf = reader.result;
resolve(buf);
}
reader.onerror = () => {
reject('讀取檔案失敗');
}
});
}
</script>
</body>

</html>

單一namespace和4個room頻道的測試

服務端的設定

要在預設的namespace不然之後會操作上會有問題app.js的程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
var app = require('express')();
var http = require('http').createServer(app);
var io = require('socket.io')(http);
// 服務那一個Port
http.listen(3000, function () {
console.log('listen on *:3000');

});
//首頁
app.get('/', function (req, res) {
res.sendFile(__dirname+'/index.html');
});
//建立room
let registeredRooms = ["room1", "room2", "room3","room4"];

//default namespace
io.on('connection', function (socket) {
var ip = socket.conn.remoteAddress;
console.log('default_ns:a user connected IP:'+ip);
socket.on('disconnect', function () {
console.log('default_ns:user disconnected');
});


});

var namespaces = [
io.of('/aaa-123'),
io.of('/bbb-123'),
io.of('/ccc-123'),
];
var ns_name = namespaces[0];
ns_name.on('connection',function(socket){

// 取得房間
socket.on('getRooms',function(msg){
console.log("<getRooms>: ",ns_name.name);
socket.emit('rooomsData',registeredRooms);
});
// 加入房間
socket.on('createJoinRoom', function (room) {
console.log('<createJoinRoom> :' + room);
if (registeredRooms.includes(room)) {
//socket已加入房間
socket.emit('success', room);
console.log('有效房間名:' + room);
socket.join(room);
} else {
// 沒有此房間
socket.emit('err', room);
console.log('無效房間名:' + room);
}
});
//收到聊天訊息
socket.on('chatmessage',function(data){
console.log('<chatmessage> :' + 'msg:'+data.msg + ' room:'+data.room);
ns_name.to(data.room).emit('chatmessage',data);
});
//收到二進位資料
socket.on('bytemessage',function(data){

//console.log('<bytemessage> :' + ' room:'+data.room);
ns_name.to(data.room).emit('bytemessage',data);
});
});

客戶端的設定

在客戶端的index.html的程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
<!DOCTYPE html>
<html>

<head>
<title>Socket.IO 多頻道</title>
</head>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font: 13px Helvetica, Arail;
}

.imgshow {
width: 100%;
height: 200px;
}
.clsimage {
width:100%;
height:100%
}
.clschannel {
width: 20%;
float: left;
background: #eee;
}

.clsmessages {
list-style-type: none;
margin: 0;
;
padding: 0;
margin-bottom: 40px;
}

.inputdiv {
background: rgb(224, 188, 188);
margin-right: .5%;

padding: 3px;
bottom: 0;

}

.clsmsginput {
border: 0;
width: 50%;
margin-right: .5%;
}

#msgbtn {
background: rgb(130, 224, 225);
border: none;
padding: 1px;
}


#messages1 li {
padding: 5px 10px;
}

#messages1 li:nth-child(odd) {
background: #eee;
}
</style>

<body>


不顯示只傳送: <input type="checkbox" id="myCheck"/>
<h1 style="clear: left;">頻道</h1>
<div>
<div class="clschannel">
<div class="imgshow" >
<img id="photo1" class="clsimage" />
</div>
<ul id="messages1" class="clsmessages"></ul>
<div class="inputdiv">
輸入:<input type="text" class="clsmsginput" id="msginput1">
<button id="msgbtn1">發送</button>
<hr>
<input type="file" id="imgfile1" multiple="multiple">
<button id="imgbtn1">發送圖片</button>
</div>
</div>
<div class="clschannel">
<div class="imgshow">
<img id="photo2" class="clsimage" />
</div>
<ul class="clsmessages" id="messages2" ></ul>
<div class="inputdiv">
輸入:<input type="text" class="clsmsginput" id="msginput2">
<button id="msgbtn2">發送</button>
<hr>
<input type="file" id="imgfile2" multiple="multiple">
<button id="imgbtn2">發送圖片</button>
</div>
</div>
<div class="clschannel">
<div class="imgshow">
<img id="photo3" class="clsimage" />
</div>
<ul class="clsmessages" id="messages3"></ul>
<div class="inputdiv">
輸入:<input type="text" class="clsmsginput" id="msginput3">
<button id="msgbtn3">發送</button>
<hr>
<input type="file" id="imgfile3" multiple="multiple">
<button id="imgbtn3">發送圖片</button>
</div>
</div>
<div class="clschannel">
<div class="imgshow">
<img id="photo4" class="clsimage" />
</div>
<ul class="clsmessages" id="messages4"></ul>
<div class="inputdiv">
輸入:<input type="text" class="clsmsginput" id="msginput4">
<button id="msgbtn4">發送</button>
<hr>
<input type="file" id="imgfile4" multiple="multiple">
<button id="imgbtn4">發送圖片</button>
</div>
</div>



</div>

</body>
<script src="socket.io/socket.io.js"></script>
<script src="https://code.jquery.com/jquery-1.11.1.js"></script>
<script>
$(function () {
console.log("doucument ready!");
var ns_sockets = [
io.connect('http://localhost:3000/aaa-123'),
io.connect('http://localhost:3000/aaa-123'),
io.connect('http://localhost:3000/aaa-123'),
io.connect('http://localhost:3000/aaa-123')
];
var roomlst=[];
//建立回調函數
for (i in ns_sockets) {
console.log('處理ns_sockets->'+i);
// 連線處理
ns_sockets[i].on('connect', handleConnect(ns_sockets[i], i));
// 房間列表
ns_sockets[i].on('rooomsData', handlerooomsData(ns_sockets[i], i));
// success 處理
ns_sockets[i].on('success', handleSuccess(ns_sockets[i]),i);
// err 處理
ns_sockets[i].on('err', handleErr(ns_sockets[i]),i);
// 斷線處理
ns_sockets[i].on('disconnect', handleDisconnect(ns_sockets[i]),i);
// 聊天處理
ns_sockets[i].on('chatmessage', handleChatmessage(ns_sockets[i]),i);
// 二進位處理
ns_sockets[i].on('bytemessage', handleBytemessage(ns_sockets[i]),i);
}
//UI
$('#msgbtn1').click(function () {
console.log('msgbtn1 click');
let msg = $('#msginput1').val();
ns_sockets[0].emit('chatmessage', { "room": 'room1', "msg": msg });
$('#msginput1').val('');
});
$('#msgbtn2').click(function () {
console.log('msgbtn2 click');
let msg = $('#msginput2').val();
ns_sockets[1].emit('chatmessage', { "room": 'room2', "msg": msg });
$('#msginput2').val('');
});
$('#msgbtn3').click(function () {
console.log('msgbtn3 click');
let msg = $('#msginput3').val();
ns_sockets[2].emit('chatmessage', { "room": 'room3', "msg": msg });
$('#msginput3').val('');
});
$('#msgbtn4').click(function () {
console.log('msgbtn4 click');
let msg = $('#msginput4').val();
ns_sockets[3].emit('chatmessage', { "room": 'room4', "msg": msg });
$('#msginput4').val('');
});
//發送圖片
$('#imgbtn1').click(()=>{
console.log('imgbtn1:click');
//讀所有的圖檔
const uploadPromises = [];
var files = $('#imgfile1').get(0).files;
var Bufferary = new ArrayBuffer(files.length);
for (i = 0; i < files.length; i++) {
var uploadPromise = getBufferFromFile(files[i]);
uploadPromises.push(uploadPromise);
}
Promise.all(uploadPromises).then(result => {
for (i = 0; i < files.length; i++) {
Bufferary[i] = result[i];
}
//開始發射
//ns_sockets[0].emit('bytemessage',{ "room": 'room1', "bufdata": Bufferary[0] } );
setInterval(()=>{
for (i = 0; i < files.length; i++) {
var file = files[i]
//console.log('發射:'+file.name )
//socket.emit('byte message', { image: true, buffer: Bufferary[i] });
ns_sockets[0].emit('bytemessage', {"room": 'room1', "bufdata": Bufferary[i]});
}
},600);
});
});
$('#imgbtn2').click(()=>{
console.log('imgbtn2:click');
//讀所有的圖檔
const uploadPromises = [];
var files = $('#imgfile2').get(0).files;
var Bufferary = new ArrayBuffer(files.length);
for (i = 0; i < files.length; i++) {
var uploadPromise = getBufferFromFile(files[i]);
uploadPromises.push(uploadPromise);
}
Promise.all(uploadPromises).then(result => {
for (i = 0; i < files.length; i++) {
Bufferary[i] = result[i];
}
//開始發射
//ns_sockets[0].emit('bytemessage',{ "room": 'room1', "bufdata": Bufferary[0] } );
setInterval(()=>{
for (i = 0; i < files.length; i++) {
var file = files[i]
//console.log('發射:'+file.name )
//socket.emit('byte message', { image: true, buffer: Bufferary[i] });
ns_sockets[1].emit('bytemessage', {"room": 'room2', "bufdata": Bufferary[i]});
}
},600);
});
});
$('#imgbtn3').click(()=>{
console.log('imgbtn3:click');
//讀所有的圖檔
const uploadPromises = [];
var files = $('#imgfile3').get(0).files;
var Bufferary = new ArrayBuffer(files.length);
for (i = 0; i < files.length; i++) {
var uploadPromise = getBufferFromFile(files[i]);
uploadPromises.push(uploadPromise);
}
Promise.all(uploadPromises).then(result => {
for (i = 0; i < files.length; i++) {
Bufferary[i] = result[i];
}
//開始發射
//ns_sockets[0].emit('bytemessage',{ "room": 'room1', "bufdata": Bufferary[0] } );
setInterval(()=>{
for (i = 0; i < files.length; i++) {
var file = files[i]
//console.log('發射:'+file.name )
//socket.emit('byte message', { image: true, buffer: Bufferary[i] });
ns_sockets[1].emit('bytemessage', {"room": 'room3', "bufdata": Bufferary[i]});
}
},600);
});
});
$('#imgbtn4').click(()=>{
console.log('imgbtn4:click');
//讀所有的圖檔
const uploadPromises = [];
var files = $('#imgfile4').get(0).files;
var Bufferary = new ArrayBuffer(files.length);
for (i = 0; i < files.length; i++) {
var uploadPromise = getBufferFromFile(files[i]);
uploadPromises.push(uploadPromise);
}
Promise.all(uploadPromises).then(result => {
for (i = 0; i < files.length; i++) {
Bufferary[i] = result[i];
}
//開始發射
//ns_sockets[0].emit('bytemessage',{ "room": 'room1', "bufdata": Bufferary[0] } );
setInterval(()=>{
for (i = 0; i < files.length; i++) {
var file = files[i]
//console.log('發射:'+file.name )
//socket.emit('byte message', { image: true, buffer: Bufferary[i] });
ns_sockets[1].emit('bytemessage', {"room": 'room4', "bufdata": Bufferary[i]});
}
},600);
});
});

// 二進位處理
function handleBytemessage(ns, idx){
return function (data) {
//console.log('收到:<bytemessage>' + data.room + ' buffdata :' + data.bufdata);
var checkBox = document.getElementById("myCheck");
if (checkBox.checked != true) {
idx = getidxfromroom(data.room) + 1;
var blob = new Blob([data.bufdata], { type: "image/jpeg" });
var urlCreator = window.URL || window.webkitURL;
var imageUrl = urlCreator.createObjectURL(blob);
var img = document.querySelector("#photo" + idx);
img.src = imageUrl;
}
data.bufdata.length=0;
}
}
// 聊天處理
function handleChatmessage(ns, idx) {
return function (data) {
console.log('收到:<chatmessage>' + data.room + ' msg:' + data.msg);
idx = getidxfromroom(data.room)+1;
//console.log('idx' + idx);
var msglst = '#messages' + idx;
console.log(msglst);
$(msglst).append($('<li>').text(data.msg));
}
}
// err 處理
function handleErr(ns, idx){
return function(room){
console.log(ns.id+' 收到:<err>加入' + room);
}
}
// success 處理
function handleSuccess(ns, idx){
return function(room){
console.log(ns.id+' 收到:<success>加入' + room);
}
}

// 房間列表
function handlerooomsData(ns, idx) {
return function (roomlist) {
console.log('收到:<rooomsData>' + roomlist);
roomlst = roomlist;
//加入房間
var myroom = roomlist[idx];
console.log('client: <createJoinRoom>-room=' + myroom);
ns.emit('createJoinRoom', myroom);
}
}
// 斷線處理
function handleDisconnect(ns, idx){
return function () {
console.log('收到:<disconnect> ns-id:'+ns.id);

}
}
// 連線處理
function handleConnect(ns, idx) {
return function () {
console.log('收到:<connect> ns-id:'+ns.id);
// 要求房間列表
ns.emit('getRooms', '');
}
}

function getidxfromroom(room){
for(i=0;i<roomlst.length;i++){
if(roomlst[i]== room) return i;
}
return -1;
}

function getBufferFromFile(file){
return new Promise( (resolve,reject)=>{
const reader = new FileReader();

reader.readAsArrayBuffer(file);
reader.onload=()=>{

var buf = reader.result;
resolve(buf);
}
reader.onerror=()=>{
reject('讀取檔案失敗');
}
});
}
});
</script>

</html>
參考資訊

https://gist.github.com/companje/0a42eb25dd3caff92ab7

Socket.io 的說話島
Node.js Socket.io Namespaces, Rooms and Connections 02
socket.io+express多房间聊天应用
Dynamic Namespaces Socket.IO
Socket.io: Namespaces, channels & co
且戰且走HTML5(5) 更深入Socket.IO
node.js and socket.io multiroom chat tutorial
Connect to Socket.IO server with specific path and namespace

前言

因為之前有測試可以使用陣列來相互傳送資料,這此想以之前的聊天的基礎上加入可以傳送圖片,只不過圖片是以byte陣列的方式來傳送,在收到之後在顯示,目前只能傳送jpg類型

建立專案

和聊天的專案是一樣的建立方式,先建立資料夾byte-img-example 剩下的參考基本聊天的設定在服務端的程式index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var app = require('express')();
var http = require('http').createServer(app);
var io = require('socket.io')(http);

app.get('/',function(req,res){
res.sendFile(__dirname+'/index.html');
});
// 有client連線
io.on('connection',function(socket){
socket.broadcast.emit('chat message','hi');
console.log('an user connected');
//client斷線
socket.on('disconnect',function(){
console.log('user disconnected');
});
socket.on('byte message',function(data){
//console.log('byte message: '+ data.buffer);
//收到訊息後,回傳給自已
io.emit('byte message',data);
});
socket.on('chat message',function(msg){
console.log('message: '+ msg);
//收到訊息後,回傳給自已
io.emit('chat message',msg);
});
})
http.listen(3000,function(){
console.log('listening on *:3000');
});

在客戶端的index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<!DOCTYPE html>
<html>

<head>
<title>Socket.IO chat</title>
</head>
<style>
* { margin: 0; padding: 0;box-sizing: border-box; }

body { font: 13px Helvetica, Arail; }

#inputdiv { background: rgb(224, 188, 188);padding: 3px;position: fixed;bottom: 0;width: 50%; }

#msginput {border: 0; width: 90%; margin-right: .5% }

#msgbtn {background: rgb(130, 224, 225);border: none;padding: 1px;}

#messages { list-style-type: none; margin: 0; padding: 0; }
#messages li { padding: 5px 10px; }
#messages li:nth-child(odd) { background: #eee; }
#messages { margin-bottom: 40px }
</style>
<body>
<img id="photo"/>
<h1>群聊</h1>
<ul id="messages"></ul>
<div id="inputdiv">
輸入:<input type="text" id="msginput">
<button id="msgbtn">發送</button>
<hr>
<input type="file" id="imgfile">
<button id="imgbtn" >發送圖片</button>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="https://code.jquery.com/jquery-1.11.1.js"></script>
<script>
$(function () {
//網路相關
var socket = io();
socket.on('chat message',function(msg){
//console.log('收到:'+msg);
$('#messages').append($('<li>').text(msg));
window.scrollTo(0, document.body.scrollHeight);
});
socket.on('byte message', function (data) {
console.log('byte message');
var bufView = new Uint8Array(data.buffer);
console.log(data.buffer);
var blob = new Blob([data.buffer], { type: "image/jpeg" });
var urlCreator = window.URL || window.webkitURL;
var imageUrl = urlCreator.createObjectURL(blob);
//var img = document.querySelector("#photo");
//img.src = imageUrl;
$('#messages').append($('<li>').append(
$('<img>').width(100).height(100).attr('src',imageUrl)
));
window.scrollTo(0, document.body.scrollHeight);
});
//
$('#msgbtn').click((e) => {
let msg = $('#msginput').val();
//console.log('發射:[' + msg + ']');
socket.emit('chat message', msg);
$('#msginput').val('');
return false;
});
//發送圖片
$('#imgbtn').click((e)=>{
//console.log('imgbtn:click');
var file = $('#imgfile').get(0).files[0];
let reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = function (e) {
var arrayBuffer = e.target.result;
//var bytes = new Uint8Array(arrayBuffer);
console.log(arrayBuffer);

socket.emit('byte message', { image: true, buffer: arrayBuffer });
//
// var blob = new Blob([arrayBuffer], { type: "image/jpeg" });
// var urlCreator = window.URL || window.webkitURL;
// var imageUrl = urlCreator.createObjectURL(blob);
// var img = document.querySelector("#photo");
// img.src = imageUrl;
}

/* var selectedFile = $('#imgfile').get(0).files[0];
let reader = new FileReader();
reader.readAsArrayBuffer (selectedFile);
reader.onload = function () {
var arrayBuffer = this.result;
var bufView = new Uint8Array(arrayBuffer);
socket.emit('byte message', bufView);
} */
});
});
</script>
</body>

</html>

比較要注意的是在Socket.IO在陣列上只能傳送

以下是讀取圖片資料和傳送到Socket.IO的程式片段

1
2
3
4
5
6
7
let reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = function (e) {
var arrayBuffer = e.target.result;
console.log(arrayBuffer);
socket.emit('byte message', { image: true, buffer: arrayBuffer });
}

以下是收到圖片資料和轉成html的圖片

1
2
3
4
5
6
7
8
9
10
11
12
13
socket.on('byte message', function (data) {
console.log('byte message');
var bufView = new Uint8Array(data.buffer);
console.log(data.buffer);
var blob = new Blob([data.buffer], { type: "image/jpeg" });
var urlCreator = window.URL || window.webkitURL;
var imageUrl = urlCreator.createObjectURL(blob);

$('#messages').append($('<li>').append(
$('<img>').width(100).height(100).attr('src',imageUrl)
));
window.scrollTo(0, document.body.scrollHeight);
});

參考資料

Socket.IO

Socket.io 的說話島

How to send binary data with socket.io?

Return the Array of Bytes from FileReader()

Getting byte array through input type = file

DAY 30. JavaScript Blob, Buffer

socket.io简易教程

建立的步驟和聊天的一樣

  • 設定資料夾byte-example

  • 設定package.json npm init

  • 安裝express npm install -- save express

  • 安裝 Sokcet.IO npm install --save socket.io

  • 將使用原來的index.htm

修改index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var app= require('express')();
var http = require('http').createServer(app);
var io = require('socket.io')(http);

app.get('/',function(req,res){
res.sendFile(__dirname+'/index.html');
});
// 有client連線
io.on('connection',function(socket){
socket.broadcast.emit('chat message','hi');
console.log('an user connected');
//client斷線
socket.on('disconnect',function(){
console.log('user disconnected');
});
socket.on('byte message',function(byteary){
var bystring = byteary.toString();
console.log('byte message: '+ bystring);
//收到訊息後,回傳給自已
io.emit('byte message',byteary);
});
})
http.listen(3000,function(){
console.log('listening on *:3000');
});

修改index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!DOCTYPE html>
<html>

<head>
<title>Socket.IO chat</title>
</head>
<style>
* { margin: 0; padding: 0;box-sizing: border-box; }

body { font: 13px Helvetica, Arail; }

form { background: #000;padding: 3px;position: fixed;bottom: 0;width: 100%; }

form input {border: 0;padding: 10px; width: 90%; margin-right: .5% }

form button {width: 9%;background: rgb(130, 224, 225);border: none;padding: 10px;}

#messages { list-style-type: none; margin: 0; padding: 0; }
#messages li { padding: 5px 10px; }
#messages li:nth-child(odd) { background: #eee; }
#messages { margin-bottom: 40px }
</style>
<body>
<ul id="messages"></ul>
<form action="">
<input id="m" autocomplete="off" /> <button>送出</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script src="https://code.jquery.com/jquery-1.11.1.js"></script>
<script>
$(function () {
var socket = io();
$('form').submit(function (e) {
e.preventDefault();//prevent page reloading
var bufArr = new ArrayBuffer(4);
var bufView = new Uint8Array(bufArr);
bufView[0]=6;
bufView[1]=7;
bufView[2]=8;
bufView[3]=9;
var bystring = bufView.toString();
var msg=$('#m').val=bystring;
console.log('發射:['+msg+']');
socket.emit('byte message', msg);

//$('#m').val('');
return false;
});
socket.on('byte message',function(msg){
console.log('收到:'+msg);
$('#messages').append($('<li>').text(msg));
window.scrollTo(0, document.body.scrollHeight);
});
socket.on('chat message',function(msg){
console.log('收到:'+msg);
$('#messages').append($('<li>').text(msg));
window.scrollTo(0, document.body.scrollHeight);
});
});
</script>
</body>
</html>

建立基本卿天架構

先在資料夾建立chat-example的資料夾

設定package.json

先建立一個資料夾叫helloexpress, 在到它的目錄下使用命令列,下下面的指令npm init先建立package.json,它會問些問題,會將資料寫入package.json按照需要輸入或是直接按下不輸入

安裝express

在命令列下npm install --save express,它會建立node_modules的資料夾和將相關的程式碼放到此目錄

建立index.js

在和package.json的相同目錄下,建立index.js,這是程式執行的地方輸入如下

1
2
3
4
5
6
7
8
9
10
var app = require('express')();
var http = require('http').createServer(app);

app.get('/',function(req,res){
res.send('<h1>Hello express</h1>')
})

app.listen(3000,function(){
console.log('listening on *:3000');
})

在命令列下輸入node index.js接下來在瀏覽器中輸入http://127.0.0.1:3000在瀏覽器的畫面會顯示<h1>Hello express</h1>

建立網頁

我們要向client送出設定好的網頁,可以修改index.js的碼如下

1
2
3
4
5
6
7
8
9
var app= require('express')();
var http = require('http').createServer(app);

app.get('/',function(req,res){
res.sendFile(__dirname+'/index.html');
});
http.listen(3000,function(){
console.log('listening on *:3000');
});

向client端送的index.html的內容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html>

<head>
<title>Socket.IO chat</title>
</head>
<style>
* { margin: 0; padding: 0;box-sizing: border-box; }

body { font: 13px Helvetica, Arail; }

form { background: #000;padding: 3px;position: fixed;bottom: 0;width: 100%; }

form input {border: 0;padding: 10px; width: 90%; margin-right: .5% }

form button {width: 9%;background: rgb(130, 224, 225);border: none;padding: 10px;}

#message {list-style-type: none; margin: 0;padding: 0;}

#message li:nth-child(odd) { background: #eee }
</style>
<body>
<ul id="message"></ul>
<form action="">
<input id="m" autocomplete="off" /> <button>送出</button>
</form>
</body>
</html>

加入Socket.IO

以下的指令來安裝

1
npm install --save socket.io

來修改index.js如下加入var io = require('socket.io')(http);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var app= require('express')();
var http = require('http').createServer(app);
var io = require('socket.io')(http);

app.get('/',function(req,res){
res.sendFile(__dirname+'/index.html');
});

io.on('connection',function(socket){
console.log('an user connected');
})
http.listen(3000,function(){
console.log('listening on *:3000');
});

在修改給client端的index.html的端如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!DOCTYPE html>
<html>

<head>
<title>Socket.IO chat</title>
</head>
<style>
* { margin: 0; padding: 0;box-sizing: border-box; }

body { font: 13px Helvetica, Arail; }

form { background: #000;padding: 3px;position: fixed;bottom: 0;width: 100%; }

form input {border: 0;padding: 10px; width: 90%; margin-right: .5% }

form button {width: 9%;background: rgb(130, 224, 225);border: none;padding: 10px;}

#message {list-style-type: none; margin: 0;padding: 0;}

#message li:nth-child(odd) { background: #eee }
</style>
<body>
<ul id="message"></ul>
<form action="">
<input id="m" autocomplete="off" /> <button>送出</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script>
var socket = io();
</script>
</body>
</html>

在console的模式下顯示

1
2
3
4
5
6
7
listening on *:3000
index.js:13
an user connected
index.js:10
an user connected
index.js:10
an user connected

加入disconnect的事件

1
2
3
4
5
6
io.on('connection',function(socket){
console.log('an user connected');
socket.on('disconnect',function(){
console.log('user disconnected');
});
})

在console的模式下顯示

1
2
3
4
5
6
7
8
9
an user connected
index.js:10
user disconnected
index.js:12
an user connected
index.js:10
user disconnected
index.js:12
an user connected

發射事件

使用JQuery來取得html上的元件和資料來發送聊天訊息在index.html中修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!DOCTYPE html>
<html>

<head>
<title>Socket.IO chat</title>
</head>
<style>
* { margin: 0; padding: 0;box-sizing: border-box; }

body { font: 13px Helvetica, Arail; }

form { background: #000;padding: 3px;position: fixed;bottom: 0;width: 100%; }

form input {border: 0;padding: 10px; width: 90%; margin-right: .5% }

form button {width: 9%;background: rgb(130, 224, 225);border: none;padding: 10px;}

#messages { list-style-type: none; margin: 0; padding: 0; }
#messages li { padding: 5px 10px; }
#messages li:nth-child(odd) { background: #eee; }
#messages { margin-bottom: 40px }
</style>
<body>
<ul id="messages"></ul>
<form action="">
<input id="m" autocomplete="off" /> <button>送出</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script src="https://code.jquery.com/jquery-1.11.1.js"></script>
<script>
$(function () {
var socket = io();
$('form').submit(function (e) {
e.preventDefault();//prevent page reloading
var msg=$('#m').val()
console.log(msg);
socket.emit('chat message', msg);

$('#m').val('');
return false;
});
});
</script>
</body>
</html>

index.js中修改

1
2
3
4
5
6
7
8
9
10
11
12
// 有client連線
io.on('connection',function(socket){
console.log('an user connected');
//client斷線
socket.on('disconnect',function(){
console.log('user disconnected');
});
//接收到事件
socket.on('chat message',function(msg){
console.log('message: '+ msg);
});
})

廣播訊息

要將訊息傳給client端可以使用emit這個功能,使用如下

1
2
socket.broadcast.emit('chat message',msg);//除了自已不會收到,其餘的會收到
io.emit('chat message',msg);//全部都會收到,包括自已

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 有client連線
io.on('connection',function(socket){
socket.broadcast.emit('chat message','hi');
console.log('an user connected');
//client斷線
socket.on('disconnect',function(){
console.log('user disconnected');
});
socket.on('chat message',function(msg){
//console.log('message: '+ msg);
//收到訊息後,回傳給自已
io.emit('chat message',msg);
});
})

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<body>
<ul id="messages"></ul>
<form action="">
<input id="m" autocomplete="off" /> <button>送出</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script src="https://code.jquery.com/jquery-1.11.1.js"></script>
<script>
$(function () {
var socket = io();
$('form').submit(function (e) {
e.preventDefault();//prevent page reloading
var msg=$('#m').val()
console.log('發射:'+msg);
socket.emit('chat message', msg);

$('#m').val('');
return false;
});
socket.on('chat message',function(msg){
console.log('收到:'+msg);
$('#messages').append($('<li>').text(msg));
window.scrollTo(0, document.body.scrollHeight);
});
});
</script>
</body>
0%