2025-03-09
63 min read

簡約的軟體開發思維

筆記約的軟體開發思維書內重要的部分以及心得。

第一章

要設法將程式碼區分為 Actions(動作、操作或行為)Calculations(計算、運算)Data(資料、數據)

  1. Actions: [最複雜]但又通常為商業邏輯的主要功能,任何受時間(執行時間:即先執行或等等執行,結果就不同)或次數(執行一次或兩次,結果就不同)影響的都是Action,
    例如: db存取(因第一次取出來的資料與第二次可能不同)、取得當前時間(因時間會不斷推進)
  2. Calculations: [中間]利用輸入(input) 推導 輸出(output),無論何時何地(不論何時呼叫或呼叫幾次)進行呼叫,如果輸入相同,輸出也必相同才是Calculation。
  3. Data: [最簡單]是各種事件有關的事實記錄,已經是結果故無法執行,但因解讀方式不同而有不同意義。

第二章

將經常改變的到不常改變的分層級由上而下排列,當處理分散式系統時,需要釐清所有流程之間的執行順序,若不進行協調,時間線的順序可能會錯亂。

例如: 本周菜單(營業規則) -> 比薩製作(領域規則) -> JavaScript Object、Array(技術棧)

利用時間線圖來幫助整理各種流程(Action)的時間順序。 (時間線圖指的是所有流程依據先後順序排列,幫助了解哪些流程是可同時進行的)

例如: 製作一個比薩的流程如下: 客人點餐 -> 揉麵團 -> 擀麵糰 -> 調配醬汁 -> 塗抹醬汁 -> 磨起司 -> 撒起司 -> 放入烤箱烘烤 -> 等10分鐘 -> 上桌 ↓ 若同時間有3個人可做,分配3種工作,揉麵團[需最久時間]、磨起司[也需要不少時間但與前者不相干]、調配醬汁[也需要不少時間但與前者不相干],然後某個人會繼續完成上面流程剩餘的工作。

第三章

如何分辨ACD(Action、Calculation、Data)?

通常一個很大的Action都可以拆分出Data或Calculation,

例如: 買菜流程

  1. 目前庫存[Data]
  2. 需要食材[Data]
  3. 需要量扣除庫存量[Calculation]
  4. 購物清單[Data]
  5. 依"購物清單"採買[Action]

Data的例子:

  • 食材採購清單
  • 你的姓名
  • 某人的電話
  • 一道菜的食譜

Calculaton的例子:

  • 加法與乘法
  • 字串串接
  • 規劃購物行程

Action的例子:

  • 寄送電子郵件(跟時間有關)
  • 從銀行帳戶中取款(跟次數有關:第一次取款與第二次取款,肯定存款不同)
  • 修改全域變數的值(跟次數有關)
  • 傳送一個ajax請求(跟時間有關)

第四章

從Action中擷取Calculation的三個步驟:

  1. 選取並分離與計算有關的程式碼
  2. 找出函式內的隱性輸入隱性輸出
  3. 隱性輸入轉換為引數(Argument)、將隱性輸出轉換為回傳值

何謂隱性輸入隱性輸出

輸入是一切可影響函式運算結果的資料,輸出是一切被函式運算結果影響的東西,只有傳入函式的引數(Argument)是顯性輸入,讀取函式以外的變數或從資料庫查詢返回的值均是隱性輸入,只有函式返回的值是顯性輸出,修改全域變數、修改共享物件、發送ajax請求均是隱性輸出

範例1(隱性輸入與隱性輸出):

var total = 0;

// 傳入參數amount是"顯性輸入"
function add_to_total(amount) {
    // console.log是"隱性輸出",total變數是讀取外部輸入所以也是"隱性輸入"
    console.log('Old total:', total);
    // 修改全域變數的值為隱性輸出
    total += amount;
    // 返回total值為"顯性輸出"
    return total;
}

範例2(FP的不可變):

// 修改前
function add_item_to_car(name, price) {
    add_item(shopping_cart, name, price);
    cal_cart_total();
}
function add_item(cart, name, price) {
    cart.push({ name, price })
}

// 修改後
function add_item_to_car(name, price) {
    add_item(shopping_cart, name, price);
    cal_cart_total();
}
function add_item(cart, name, price) {
    // 建立購物車副本以避免新增時變更到原本的引數
    var new_cart = cart.slice();
    // 副本新增內容,此處並沒有違反FP的不可變(immutable),此為初始化該變數的必要流程
    new_cart.push({ name, price });
    // 返回處理過的新副本
    return new_cart;
}

第五章

設計原則:

  1. 最小化隱性輸入與輸出
  2. "拆解"(將原本的大函式拆成更多的小涵式)是設計本質,使得程式可重複使用性提升、維護更方便、測試更簡單

程式的總行數增加不代表不好,因拆分的每個函式很短,比較容易理解與修正。

程式中的隱性輸入與輸出不可能完全消除,但能讓隱性輸入與輸出的影響範圍越小,就能讓部分函式像模組一樣可以被輕易的組合與連接,但要注意只要函式內有Action,該函式就是Action。

替Calculation函式分類,最終會擴展為程式的意義分層(layers of meaning)

當Calculation函式越來越多時,需要將函式再依據類型分層級,例如:組合各類函式的商業層、單一邏輯的商業層、基底元素操作層...等等

程式碼重複不見得是壞事,但卻是"程式碼異味"。

程式碼異味(code smell)即暗示程式可能有潛在問題的程式碼特徵。

第六章

寫入時複製(copy-on-write) 是一種為了實現FP的資料不變性的技巧,目的是防止其他使用相同資料的函式受到數值變化而被影響。

讀取不可變資料結構屬於Calculations,將寫入轉換為讀取,可讓更多函式變成Calculations

OS: 如果是並行(Concurrent)的情況下確實也不會受到影響,因為修改的是複本

實作寫入時複製的步驟

  1. 產生複本
  2. 修改複本(在函式裡改幾次都沒問題)
  3. 回傳複本

範例:

// 修改前
function remove_item_by_name(cart, name) {
    var idx = null;
    for(var i = 0; i < cart.length; i++) {
        if(cart[i].name === name)
            idx = i;
    }
    if(idx !== null) {
        cart.splice(idx, 1); // 若找到該項目就移除(寫入操作)
    }
}

// 修改後
function remove_item_by_name(cart, name) {
    var idx = null;
    for(var i = 0; i < cart.length; i++) {
        if(cart[i].name === name)
            idx = i;
    }
    if(idx !== null) {
        return removeItems(cart, idx, 1);
    }

    // 若沒有修改,則返回原陣列,
    return cart
}

// [修改後] 新增一個函式作普適化
function removeItems(arr, idx, count) {
    // 1. 產生複本
    var copy = arr.slice();
    // 2. 修改複本
    copy.splice(idx, count);
    // 3. 回傳複本
    return copy;
}

操作同時要"讀取"與"寫入"時,如何完成"寫入時複製"?

寫入時複製的重點是將"寫入"轉換成"讀取",所以有兩種做法:

  1. 將函式內的"讀取"與"寫入"部分拆開
  2. 讓函式回傳兩個值。

範例:

// 修改前
var a = [1, 2, 3, 4];
var b = a.shift();
console.log(a);// [2, 3, 4]
console.log(b);// 1

// 修改後[方法1-將函式內的"讀取"與"寫入"部分拆開]
function first_element(arr) {
    return arr[0];
}

function drop_element(arr) {
    var new_arr = arr.slice();
    new_arr.shift();
    return new_arr;
}

var b1 = first_element(a);
a1 = drop_element(a);

// 修改後[方法2-讓函式回傳兩個值]
function shift(arr) {
    var new_arr = arr.slice();
    var val = new_arr.shift();
    return {
      first: val,
      array: new_arr
    };
}

var { first: b2, array: a2 } = shift(a);

物件上的"寫入時複製"方法

同上3個步驟,作物件的淺拷貝(shallow copy),"淺拷貝"是為了"共享"更內層的資料結構因為要"替換"的資料只有最上層

範例:

// 修改前
function setPriceByName(cart, name, price;) {
    for (var i = 0; i < cart.length; i++) {
        if (cart[i].name === name) {
            cart[i].price = price;
        }
    }
        
}

shopping_cart = setPriceByName(shopping_cart, "T-shirt", 12);

// 修改後 - 僅"陣列"與"T-shirt項目"為複本,其他項仍保持原本的參考
function setPrice(item, new_price) {
    // 1. 產生複本
    var copy_item = Object.assign({}, item);
    // 2. 修改複本
    copy_item.price = new_price;
    // 3. 回傳複本
    return copy_item;
}

function setPriceByName(cart, name, price) {
    var copy_cart = cart.slice();
    for (var i = 0; i < copy_cart.length; i++) {
        // 僅匹配的項目做"寫入時複製",其他項目仍保持原本的項目,為了"結構共享"
        if (copy_cart[i].name === name) {
            copy_cart[i] = setPrice(copy_cart[i], price);
        }
    }
        
}

shopping_cart = setPriceByName(shopping_cart, "T-shirt", 12);

第七章

當既有的函式(legacy code)會破壞資料不變性但又不能修改既有程式時,可用防禦型複製的方式來與未實作不變性的函式作交換資料,一樣可達到寫入時複製並保證不變性。

作法是傳入參數進入不受信任的函式前,作深拷貝(deep copy),執行後,再將不受信任的函式回傳的資料再作一次深拷貝(deep copy),才接續後續流程。

原則:

  1. 資料離開安全區時複製[深拷貝(deep copy)]
  2. 資料進入安全區前複製[深拷貝(deep copy)]

範例:

// 修改前
function add_item_to_cart(name, price) {
    var item = fn1(name, price);
    shopping_cart = fn2(shopping_cart, item);
    var total = fn3(shopping_cart);
    fn4(total);
    
    legacyCode(shopping_cart); // 會變更不變性的函式
}

// 修改後
function add_item_to_cart(name, price) {
    var item = fn1(name, price);
    shopping_cart = fn2(shopping_cart, item);
    var total = fn3(shopping_cart);
    fn4(total);
    
    // 1. 進入前先作深拷貝
    var copy_cart = deepCopy(shopping_cart);
    legacyCode(copy_cart); // 會變更不變性的函式
    // 2. 執行後再作一次深拷貝
    shopping_cart = deepCopy(copy_cart);
}

注意: 寫入時複製防禦型複製各有不同的適用場景,但防禦型複製消耗的資源比較高,這也是需要考量的。

第八章

程式的 分層設計(stratified design) 就是將軟體區分成數個層,每一層函式都其目的,主要目的就是先將要實作的功能由高至低的拆分函式,越高階表示比較抽象的函式也就是比較接近業務邏輯,越低階越接近程式底層運作也越難讀懂,利用高階來封裝實作細節,維護上比較好理解。

分層設計的原則:

  1. 讓實作更直觀: 直觀的函式實作中,大部分元素的細節應該都類似,才不會造成程式碼難以理解。
  2. 抽象屏障輔助實作: 將某些層當作介面(interface),用來隱藏關鍵細節,就能用更高階的觀點思考。
  3. 讓下層函式保持簡約與不變: 越基底的函式越簡單越好。
  4. 分層只要舒適即可

範例:

// 修改前
function freeTieClip(cart) {
    var hasA = false;
    var hasB = false;
    for (var i = 0; i < cart.length; i++) {
        if (cart[i].name === 'A') {
            hasA = true;
        }
        if (cart[i].name === 'B') {
            hasB = true;
        }
    }
    // 若符合A且不符合B就送出一條領帶到購物車中
    if (hasA && !hasB) {
        var tieClip = make_item('tie clip', 0);
        return add_item(cart, tieClip);
    }
    
    return cart
}

// 修改後 - 將部分邏輯抽取出來就更清楚也簡單
function freeTieClip(cart) {
    var hasA = isMatch(cart, 'A');
    var hasB = isMatch(cart, 'B');

    // 若符合A且不符合B就送出一條領帶到購物車中
    if (hasA && !hasB) {
        var tieClip = make_item('tie clip', 0);
        return add_item(cart, tieClip);
    }
    
    return cart
}

function isMatch(cart, name) {
    for (var i = 0; i < cart.length; i++) {
        if (cart[i].name === name) {
            return true;
        }
    }
    
    return false
}

當函式業務邏輯很複雜時,可透過呼叫圖(call graph)來將函式內的邏輯依據是否為程式語言元素其他函式來作分層,越接近底層運作則表示越難理解,適合將它用函式包裝起來以便日後維護,呼叫圖協助我們直觀的了解當前的分層。

同一層的函式應服務相同目的

call_graph

注意: 函式不能被同一層的函式呼叫,上層函式也不能被下層函式呼叫

三個不同的檢視等級

  1. 全域檢視(Global zoom level): 觀察整個呼叫圖
  2. 層檢視(Layer zoom level): 觀察某個目標層以及它下方與他有關的層級,用來檢視是否缺漏或多餘。
  3. 函式檢視(Function zoom level): 觀察某個函式和被該函式呼叫的下層函式,用來檢查目標函式的實作是否有問題。

第九章

抽象屏障就是完美的隱藏實作細節的函式層,當使用該層函式時,就不需要考慮底層如何運作。

例如有3層,第一層的函式均為業務層,第二層的函式均為基底函式(操作語言元素用),第三層為語言的元素(例如:array、object 變數物件),第一層就是抽象屏障,用來操作或執行第二層的基底函式,若基底的資料結構改變(例如原本操作陣列,因Hash map效能較佳,故改操作物件),不會影響原本業務層所要的功能,這就是抽象屏障的功能。

抽象屏障的原則:

  1. 應該讓實作修改更容易
  2. 應該讓程式更易讀,易寫(即使剛進入的人也能知道該層的功能)
  3. 應該讓不同部門協調頻率降低(雙方只需要專注各自的責任)
  4. 應該讓程式設計師更專注特定問題

新加的功能應盡可能的放在上層,才可讓下層保持簡約與不變

注意: 是否需要抽象屏障,須審慎評估,否則就變多餘的雜訊。

函式擺放位置的影響

假設行銷部門希望保留一份購物車的商品紀錄用來分析為何消費者不結帳,開發就建立了資料庫表格並新增一個函式 logAddToCart(user_id, item),並放在某處就完成了,一開始打算放在add_item(cart, item)內,但這會造成原本的Calculation函式變成Action函式,而且會影響到 update_shipping_icons(cart) 這個函式,此函式原本只是用來標記商品如果被加入購物車,是否就顯示免運費的圖示(預估總金額是否達標),並不是消費者真正的點擊加入購物車,故並不適合放在此處。

function add_item(cart, item) {
    // 添加log函式在此處
    logAddToCart(global_user_id, item);
    return objectSet(cart, item.name, item);
}

function update_shipping_icons(cart) {
    var buttons = get_buy_buttons_dom();
    for (var i = 0; i < buttons.length; i++) {
        var button = buttons[i];
        var new_cart = add_item(cart, item); // 並不希望這裡會被記錄到資料庫
        if (gets_free_shipping(new_cart)) {
            button.show_free_shipping_icon();
        } else {
            button.hide_free_shipping_icon();
        }
    }
}

比較適合放的函式為 add_item_to_cart(name, price),因為這是消費者點擊將商品加入購物車的按鈕所執行的功能,並且該函式本來就是Action,

function add_item_to_cart(name, price) {
    var item = make_cart_item(name, price);
    shopping_cart = add_item(shopping_cart, item);
    var total = calc_total(shopping_cart);
    set_cart_total_dom(total);
    update_shipping_icons(shopping_cart);
    update_tax_dom(total);
    // 添加log函式在此處 - 比較洽當
    logAddToCart(global_user_id, item);
}

呼叫圖可呈現三項非功能性需求(nonfunctional requirements, NFRs):

  1. 可維護性: 當需求改變,哪些函式修改起來比較安全
  2. 可測試性: 可看出哪些函式是需要重點測試的
  3. 可重複使用性: 可看出哪些函是重複性最高

注意: 修改來說,越上層的修改風險越小,測試來說,越底層的測試,越能持續很長的時間,因為上層一變化,測試就必需修改了。

第十章

當函式符合 頭等物件(first-class-objects) 的四種操作就簡稱"頭等函式"。 高階函式 是以其他函式為引數(或回傳其他函式)的函式,這樣就可以將程式行為抽象化後傳給高階函式執行。

頭等物件(first-class-objects)的四種操作:

  • 可賦值給變數
  • 可作為參數傳入函式(高階抽象化必備)
  • 可做為函式的回傳值(也就是函式返回值也是函式)
  • 可存入資料結構中

程式碼異味(code smell)

當函式中有隱性引數(implicit argument),要設法將其轉換為顯性引數,隱性引數有兩個特徵,函式之間的內容非常類似且實作的不同顯示於函式的名稱上。

// 修改前 - 下面4個函式幾乎類似,差異只有傳入的引數變數不同,故可再將其抽象一層
function setPriceByName(cart, name, price) {
    var item = cart[name];
    var newItem = objectSet(item, 'price', price); // 'price' 即為隱性引數
    var newCart = objectSet(cart, name, newItem);
    return newCart;
}

function setQuantityByName(cart, name, quant) {
    var item = cart[name];
    var newItem = objectSet(item, 'quantity', quant); // 'quantity' 即為隱性引數
    var newCart = objectSet(cart, name, newItem);
    return newCart;
}

function setShippingByName(cart, name, ship) {
    var item = cart[name];
    var newItem = objectSet(item, 'shipping', ship); // 'shipping' 即為隱性引數
    var newCart = objectSet(cart, name, newItem);
    return newCart;
}

function setTaxByName(cart, name, tax) {
    var item = cart[name];
    var newItem = objectSet(item, 'tax', tax); // 'tax' 即為隱性引數
    var newCart = objectSet(cart, name, newItem);
    return newCart;
}

function objectSet(object, key, value) {
    var copy = Object.assign({}, object);
    copy[key] = value;
    return copy;
}

cart = setPriceByName(cart, "t-shirt", 12);
cart = setQuantityByName(cart, "t-shirt", 3);
cart = setShippingByName(cart, "t-shirt", 0);
cart = setTaxByName(cart, "t-shirt", 1.23);

// 修改後 - 其實也就是把隱性引數往上拉一層,若需要針對該引數作保護或檢查就可以在此層統一實作
validFieldList = ['price', 'quantity', 'shipping', 'tax']
function setFieldByName(cart, name, field, value) {
    if (!validFieldList.includes(field)) {
      throw new Error('Not a valid item field:' + field)
    }
    var item = cart[name];
    var newItem = objectSet(item, field, value); // field 是顯性引數
    var newCart = objectSet(cart, name, newItem);
    return newCart;
}

cart = setFieldByName(cart, "t-shirt", 'price',12);
cart = setFieldByName(cart, "t-shirt", 'quantity', 3);
cart = setFieldByName(cart, "t-shirt", 'shipping', 0);
cart = setFieldByName(cart, "t-shirt", 'tax', 1.23);

for迴圈的重構

// 修改前 - 要轉換隱性引數,然後再抽象一層
// 功能一: 煮飯與吃飯
function cookieAndEatFoods() {
    for(let i = 0; i < foods.length; i++) {
        var food = foods[i];
        cook(food);
        eat(food);
    }
}
// 功能二: 洗碗
function cleanDishes() {
    for(let i = 0; i < dishes.length; i++) {
        var dish = dishes[i];
        wash(dish);
        dry(dish);
        putAway(dish);
    }
}

// 修改後 - 將行為抽象出來當傳遞的引數,提供高階函式執行(高階函式不用管裡面怎麼寫)
// 抽象化for loop
function forEach(arr, fn) {
    for(let i = 0; i < arr.length; i++) {
        var item = arr[i];
        fn(item);
    }
}

// 將行為包覆成函式,用來提供高階函式執行
function cookAndEat(food) {
    cook(food);
    eat(food);
}

function clean(dish) {
    wash(dish);
    dry(dish);
    putAway(dish);
}

// 呼叫變得簡潔清楚
forEach(foods, cookAndEat);
forEach(dishes, clean);

第十一章

重構陣列的寫入時複製

步驟:

  1. 辨識前段、主體與後段區塊,要設法將某段程式行為拉出來當引數。 以下範例剛好對應 "寫入時複製" 的3個步驟: 1.產生複本 2.修改複本 3.傳回複本
  2. 將所有區塊包裝函式
  3. 將主體區塊擷取成回呼函式

範例1:

// 修改前
function arraySet(arr, idx, value) {
    var copy = arr.slice(); // 此為前段
    copy[idx] = value;      // 此為主體(因為修改是主要行為)
    return copy;            // 此為後段
}

// 修改後 - 透過傳入回呼就可很方便的調整
function arraySet(arr, idx, value) {
    return withArrayCopy(
        arr, 
        function(copy) {
            // 此函式就可以很彈型的調整其內容
            copy[idx] = value;
        }
    );
}

function withArrayCopy(arr, modify) {
    var copy = arr.slice();
    modify(copy);
    return copy;
}

範例2:

// 修改前 - 以下範例使用寫入時複製,但中繼過程可能產生很多複本
var a1 = drop_first(array);
var a2 = push(a1, 10);
var a3 = push(a2, 18);
var a4 = arraySet(a3, 0, 123); // 此時產生了 a1, a2, a3, a4共四個陣列的複本,有點浪費資源

// 修改後 - 透過傳入回呼就可一次完成
var a4 = withArrayCopy(array, function(copy) {
    // 此處對唯一的複本做4次修改
    copy.shift();   // 同drop_first(array)所做的事
    copy.push(10);  // 同push(a1, 10)所做的事
    copy.push(18);  // 同push(a2, 18)所做的事
    copy[0] = 123;  // 同arraySet(a3, 0, 123)所做的事
})

範例3:

// 修改前 - 假設很多地方需要包覆try catch並記錄錯誤,範例如下
try {
    saveXXX(data);
} catch (err) {
    logError(err)
}

// 修改後 - 透過返回函式給予其超能力
function withLog(fn) {
    return function (...args) {
        try {
            return fn(...args);   
        } catch (err) {
            logError(err)
        }
    }
}

var saveXXXwithLog = withLog(saveXXX); // 此時返回的函式就有log功能
var createXXXwithLog = withLog(createXXX); // 此時返回的函式就有log功能

注意: 理想的FP是用來降低程式碼重複、易讀,但需思考簡單的實作是否需要? 高階函式還是有些代價,抽象化越多越造成可讀性降低。

第十二章

隱性引數 轉化為 顯性引數 至關重要,不但有助於表達程式碼意圖,更有機會消除重複。

回呼 取代主體實作(也就是比較容易變更的程式碼盡量透過回呼來處理,以便日後維護或替換)。

OS: 只要不是從函式傳入的引數(arguments)就是隱性引數,例如:外部變數、全域變數、寫死的值

三大函數map()、filter()、reduce()

map()範例:

// 修改前
function customerFullNames(customers) {
    var fullNames = [];
    for (var i = 0; i < customers.length; i++) {
        var customer = customers[i];
        var name = customer.firstName + ' ' + customer.lastName;
        fullNames.push(name);
    }
    return fullNames;
}

// 修改後
function customerFullNames(customers) {
    return map(customers, function(customer) {
        return customer.firstName + ' ' + customer.lastName;
    });
}

function forEach(arr, fn) {
    for (var i = 0; i < arr.length; i++) {
        var item = arr[i];
        fn(item);
    }
}

function map(arr, fn) {
    var newArr = [];
    forEach(arr, function(item) {
        newArr.push(fn(item));
    })
    return newArr;
}

filter()範例:

// 修改前
function customerPurchaseOver3Times(customers) {
    var purchaseOver3times = [];
    for (var i = 0; i < customers.length; i++) {
        var customer = customers[i];
        // 找出消費次數超過3次的客戶
        if(customer.purchases.length > 3) {
          purchaseOver3times.push(name);   
        }
    }
    return purchaseOver3times;
}

// 修改後
function customerPurchaseOver3Times(customers) {
    return filter(customers, function(customer) {
        // 找出消費次數超過3次的客戶
        return customer.purchases.length > 3;
    });
}

function forEach(arr, fn) {
    for (var i = 0; i < arr.length; i++) {
        var item = arr[i];
        fn(item);
    }
}

function filter(arr, fn) {
    var newArr = [];
    forEach(arr, function(item) {
        if(fn(item)) {
            newArr.push(item)
        }
    })
    return newArr;
}

reduce()範例:

// 修改前
function countAllPurchases(customers) {
    var total = 0;
    for (var i = 0; i < customers.length; i++) {
        var customer = customers[i];
        // 總計蕭消費次數
        total = total + customer.purchases.length;
    }
    return total;
}

// 修改後
function countAllPurchases(customers) {
    return reduce(customers, 0, function(total, customer.) {
        return total + customer.purchases.length;
    });
}

function forEach(arr, fn) {
    for (var i = 0; i < arr.length; i++) {
        var item = arr[i];
        fn(item);
    }
}

function reduce(arr, init, fn) {
    var accum = init;
    forEach(arr, function(item) {
        accum = fn(accum, item);
    })
    return accum;
}

規則只是"指導方針",沒事不會特別違背,當違背時要仔細想想,因為結果未必不好

使用reduce()實作filer()、map():

function reduce(arr, init, fn) {
    var accum = init;
    forEach(arr, function(item) {
        accum = fn(accum, item);
    })
    return accum;
}

function map(arr, fn) {
    return reduce(arr, [], function(result, item) {
        // 只使用不可變操作(效率較低,因為浪費了一堆新陣列)
        return result.concat(fn(item));
    })
}

function map(arr, fn) {
    return reduce(arr, [], function(result, item) {
        // 使用可變操作(效率較高,變動都在同一個陣列內)
        result.push(fn(item))
        return result;
    })
}

function filter(arr, fn) {
    return reduce(arr, [], function(result, item) {
        // 只使用不可變操作(效率較低,因為浪費了一堆新陣列)
        return fn(item) ? result.concat(item) : result;
    })
}

function filter(arr, fn) {
    return reduce(arr, [], function(result, item) {
        // 使用可變操作(效率較高,變動都在同一個陣列內)
        if (fn(item)) {
            result.push(item)
        }
        return result;
    })
}

第十三章

透過鏈式操作(chain)可以讓程式簡單易讀易寫,又能確保與分離每個步驟流程。 鏈式操作(chain)就是上一步操作的"輸出"就是下一步操作的"輸入"。

函式鏈的整理方法:

  1. 擷取函式內的步驟並命名成另一個函式
  2. 為傳入函式的回呼函式命名(也就是要將其移出去並為此函數命名,然後傳入函式的回呼函式就以函式名稱代替)

範例:

// 原始 - 計算高消費力顧客的最高消費金額
function biggestPurchasesBestCustomers(customers) {
    // 1.過濾消費超過3次的顧客
    var bestCustomers = filter(customers, function(customer) {
        return customer.purchases.length >= 3;
    });
    
    // 2. 找出顧客們的各自的最高消費金額(巢狀回呼很難懂)
    var biggestPurchases = map(bestCustomers, function (customer) {
        return reduce(customer.purchases, { total: 0 }, function(biggestSoFar, purchase) {
            if (biggestSoFar.total > purchase.total) {
                return biggestSoFar;
            } else {
                return purchase;
            }
        })
    })
        
    return biggestPurchases;
}


// 方法1: 擷取函式內的步驟並命名成另一個函式
function  biggestPurchasesBestCustomers(customers) {
    // 將上面的filer包成函式
    var bestCustomers = selectBestCustomers(customers);
    // 將上面map包成函式並拆解巢狀函式
    var biggestPurchases = getBiggestPurchases(bestCustomers);

    return biggestPurchases;
}
    
function selectBestCustomers(customers) {
    return filter(customers, function(customer) {
        return customer.purchases.length >= 3;
    });
}

function getBiggestPurchases(customers) {
    return map(customers, getBiggestPurchase)
}

function getBiggestPurchase(customer) {
    return maxKey(customer.purchases, { total: 0 }, function(purchase) {
        return purchase.total;
    })
}

function maxKey(arr, init, fn) {
    return reduce(arr, init, function(a, b) {
        if (fn(a) > fn(b)) {
            return a;
        } else {
            return b;
        }
    })
}

// 方法2: 為傳入函式的回呼函式命名(以目前來看,方法2是比較清楚好懂的,但不同情況會有不同的適合方式)
function  biggestPurchasesBestCustomers(customers) {
    // 將回呼命名
    var bestCustomers = filter(customers, isGoodCustomer);
    // 將回呼命名並拆解巢狀函式
    var biggestPurchases = map(bestCustomers, getBiggestPurchase);

    return biggestPurchases;
}

function isGoodCustomer(customer) {
    return customer.purchases.length >= 3;
}

function getBiggestPurchase(customer) {
    return maxKey(customer.purchases, { total: 0 }, getPurchaseTotal)
}

function getPurchaseTotal(purchase) {
    return purchase.total;
}

流融合(Stream fusion)

當 filter() 或 map() 被呼叫就會產生新陣列,這作法雖沒效率,通常也不會造成問題,如果要改善此問題可用 流融合(stream fusion) 來做優化,這樣就不會產生多個新陣列,但只有當程式效率不夠高才適合使用,維護上還是單一步驟比較易讀。

map()範例:

// 流融合前
var names = map(customers, getFullName);
var nameLengths = filter(names, stringLength);
    
// 流融合後
var nameLengths = filter(customers, function(customer) {
    // 合併成一個
    return stringLength(getFullName(customer));
})

filter()範例:

// 流融合前
var goodCustomers = filter(customers, isGoodCustomer);
var withAddress = filter(goodCustomers, hasAddress);
    
// 流融合後
var withAddress = filter(customers, function(customer) {
    // 合併成一個
    return isGoodCustomer(customer) && hasAddress(customer);
})

reduce()範例:

// 流融合前
var purchaseTotals = map(purchases, getPurchaseTotal);
var purchaseSum = reduce(purchaseTotals, 0, plus);
    
// 流融合後
var purchaseSum = reduce(purchases, 0, function(total, purchase) {
    // 合併成一個
    return total + getPurchaseTotal(purchase);
})

重構既有的for迴圈

重構原則:

  1. 將資料儲存至獨立陣列 (需思考map(),filter,reduce()內的回呼流程)
  2. 細化步驟 (若流程很難在一步內完成,再將流程拆解成更多小函式)

範例:

// 修改前
/**
 * 流程:
 * 1. 外層迴圈走訪陣列內元素
 * 2. 內層迴圈走訪0、1、2、3、4
 * 3. 計算新索引
 * 4. 累計count變數
 * 5. 將結果加入answer陣列
 * 
 */
var answer = [];

var window = 5;

for (var i = 0; i < array.length; i++) {
    var sum = 0;
    var count = 0;
    for (var w = 0; w < window; w++) {
        var idx = i + w;
        if (idx < array.length) {
            sum += array[idx];
            count += 1;
        }
    }
    
    answer.push(sum/count);
}

// 修改後
/**
 * 1. 先將子陣列拆分出來(內層迴圈只走訪原陣列內的部分元素)
 * 2. 需要將第一層for迴圈改用map,但此迴圈走訪的其實是索引,所以需要有個產生索引陣列的函式range
 * 3. 需要有個可以算平均值的函式
 */

function range(start, end) {
    var ret = [];
    for(var i = start; i < end; i++) {
        ret.push(i)
    }
    return ret;
}

function average(numbers) {
    return reduce(numbers, 0, function (a, b) { return a + b; }) / numbers.length;
}

// 1. 產生陣列索引
var indices = range(0, array.length);
// 2. 取得所有的子陣列
var subArrays = map(indices, function(i) {
    return array.slice(i, i + window); // 這樣連if判斷式都可省略(因為slice不會超過原陣列大小)
})
// 3. 計算所有子陣列的平均值
var answer = map(subArrays, average);

第十四章

若修改物件內的值,也可拆分前段、主體、後段,然後把主體抽出去當函式傳入,就可以依據不同情境傳入不同函式。

若要修改的物件是很深的巢狀結構,則適合使用遞迴來處理。

範例:

// 修改前 - 4個函式分別對物件做加減乘除運算
function incrementField(item, field) {
    var value = item[field];  // 前段
    var newValue = value + 1; // 要修改的主體
    var newItem = objectSet(item, field, newValue); // 後段
    return newItem;
}

function decrementField(item, field) {
    var value = item[field];  // 前段
    var newValue = value - 1; // 要修改的主體
    var newItem = objectSet(item, field, newValue); // 後段
    return newItem;
}

function doubleField(item, field) {
    var value = item[field];  // 前段
    var newValue = value * 2; // 要修改的主體
    var newItem = objectSet(item, field, newValue);  // 後段
    return newItem;
}

function havleField(item, field) {
    var value = item[field];  // 前段
    var newValue = value / 2; // 要修改的主體
    var newItem = objectSet(item, field, newValue);  // 後段
    return newItem;
}

// 修改後 - 將共用部分抽成函式並將要修改主體抽離成函式各別傳入
function update(obj, key, modify) {
    var value = obj[key];// 取得
    var newValue = modifty(value); // 修改
    var newItem = objectSet(obj, key, newValue); // 設定
    return newItem; // 返回修改後的物件複本
}

function incrementField(item, field) {
    return update(item, field, function (value) {
        return value + 1;
    });
}

function decrementField(item, field) {
    return update(item, field, function (value) {
        return value - 1;
    });
}

function doubleField(item, field) {
    return update(item, field, function (value) {
        return value * 2;
    });
}

function havleField(item, field) {
    return update(item, field, function (value) {
        return value / 2;
    });
}

修改的值為多層巢狀結構(遞迴)

遞迴很適合使用在巢狀結構上,主要是由外而內的進入到內層來"取得"某些東西,然後"設定",然後再由內而外的返回"設定"。

維護成本很高,可以建立一層"抽象屏障"函式來幫助理解該功能用途。

範例:

var shirt = {
    name: 'shirt',
    price: 13,
    options: {
        color: 'blue',
        size: {
            sm: 3,
            m: 5,
            xl: 8, // 要修改這裡
            xxl: 12
        }
    }
}
// 修改前
function incrementSize(item) {
    var options = item.options;
    var size = options.size;
    var newSize = size + 1;
    var newOptions = objectSet(options, 'size', newSize);
    var newItem = objectSet(item, 'options', newOptions);
    return newItem;
}

// 修改後
// 第一版 - 寫三層update(若越來越多層,將會很恐怖)
function incrementSize(item, key1, key2, key3, modify) {
    return update(item, key1, function (options) {
        return update(options, key2, function (size) {
            return update(size, key3, function(val) {
                return val + 1
            })
        })
    })
}

// 第二版 - 使用遞迴(可處理任意長度的巢狀結構)
function incrementSize(item, keys, modify) {
    // 達到終止條件才執行修改函式,否則就繼續進入內層物件
    if (keys.length === 0) {
        return modify(item);
    }

    var key1 = keys[0];
    var restOfKeys = drop_first(keys); // 移除第一個key,並返回剩餘key的複本
    return update(item, key1, function (value) {
        return incrementSize(value, restOfKeys, modify); // 不斷的深入進去物件的key
    })
}

// [自己實作forloop版本] 需要先順向取得最內層物件值,然後再從內層實作"寫入時複製",慢慢一直"寫入時複製"到最上層(真的麻煩)
function incrementSize(item, keys, modify) {
    let curr = item;
    const newObj = []
    // 開始由外而內的推進取得內層屬性的值
    for(let i = 0; i < keys.length; i++) {
        var key = keys[i];
        newObj.push([key, curr]); // 記錄每一層的pointer與key
        curr = curr[key];
    }
    
    const newValue = modify(curr);// 呼叫回呼函式執行修改
    const lastValue = newValue;
    
    // 開始由內而外的執行"寫入時複製"
    while (newObj.length !== 0) {
        const [key, obj] = newObj.pop()
        lastValue = objectSet(obj, key, lastValue)
    }
    
    return lastValue;
}

第十五章

當可非同步執行的序列越多,排列組合出來的時間線圖就越複雜,就會產生許多非預期的"潛在執行順序"。

利用時間線圖能反映平行執行的順序不確定性(即列出同時執行、先執行、後執行),再評估後續引發的問題,然後改善問題。

Action 依據 時間線圖 分為兩種:

  • 序列執行(有固定的先後順序)
  • 平行執行(沒有先後順序,可同時執行、先執行、後執行)

序列執行:

// 必定先執行 action 1 再執行 action 2
-> action 1 -> action 2 ->

平行執行:

同時執行 action1 & action2:
-> action 1 ->
-> action 2 ->

先執行 action1 再執行 action2:
-> action 1 ->
            -> action 2 ->

先執行 action2 後執行 action1:
            -> action 1 ->
-> action 2 ->

前置知識

+++= 其實包含了3個步驟,若涉及多執行緒時,第1、3步驟都是action,因每個執行緒都會有自己的時間線,為了避免 race condition (讀取非預期值或寫入非預期值),通常會借助lock之類的工具,以便控制程式順序。

total++;

vat temp = total; // 1.讀取(action)
temp = temp + 1;  // 2.加法運算(calculation)
total = temp;     // 3.寫入(action)

改善時間線

多時間線系統(eg: 分散式系統)很難設計的原因之一就是須考量"潛在順序"過多。

改善時間線的原則:

  • 時間線越少越好
  • 時間線上的步驟越少越好
  • 共享資源越少越好
  • 協調友共享資源的時間線
  • 更改程式的時間模型

範例:

// 發起user請求
saveUserAjax(user, function() {
    // user請求加載完畢
    setUserLoadingDOM(false);
});
// user請求加載中
setUserLoadingDOM(true);
// 發起document請求
saveDocumentAjax(document, function() {
    // document請求加載完畢
    setDocumentLoadingDOM(false);
});
// document請求加載中
setDocumentLoadingDOM(true);

執行的時間線圖如下:

| 時間線1 | 時間線2 | 時間線3 | | :---: | :---: | :---: | | ↓ | | | | saveUserAjax() | | | | ↓ | ↓ | | | setUserLoadingDOM(true) | setUserLoadingDOM(false) | | | ↓ | | | | saveDocumentAjax() | | | | ↓ | | ↓ | | setDocumentLoadingDOM(true) | | setDocumentLoadingDOM(false) | | ↓ | | |

簡化上面的時間線(合併同一個時間線上的Actions)

| 時間線1 | 時間線2 | 時間線3 | | :---: | :---: | :---: | | ↓ | | | | saveUserAjax() setUserLoadingDOM(true) saveDocumentAjax() setDocumentLoadingDOM(true) | | | | ↓ | | | | | setUserLoadingDOM(false) | setDocumentLoadingDOM(false) |

簡化時間線兩種方式: 1.合併同一個時間線上的Actions 2.若某時間線在末端產生單一條新時間線,將兩者合併

序列執行範例

商城列表出商品,每個商品右側有顆按鈕,左上顯示購物車總金額,用戶點擊商品會執行 add_item_to_cart 方法,計算購物金額+運費, 當點擊一次的流程是正確的,但快速點擊兩次時,計算的金額是錯誤的。

正確Case: 點擊商品一次 -> 取得商品價格(6元)並累加總金額(0 + 6) -> 取得運費(2元)並累加總金額(6 + 2) -> 購物車顯示8元 正確Case: 點擊商品二次 -> 取得商品價格(6元)並累加總金額(0 + 6) -> 取得運費(2元)並累加總金額(6 + 2) -> 取得商品價格(6元)並累加總金額(8 + 6) -> 取得運費(0元)並累加總金額(14 + 0)[前次金額已有運費,故不會累加運費] -> 購物車顯示14元 錯誤Case: 快速點擊商品二次 -> 取得商品價格(6元)並累加總金額(0 + 6) -> 取得運費(2元)並累加總金額(6 + 2) -> 取得商品價格(6元)並累加總金額(8 + 6) -> 取得運費(2元)並累加總金額(14 + 2) -> 購物車顯示16元???

// 給頁面按鈕執行的將商品加入購物車
function add_item_to_cart(name, price, quantity) {
    // 將商品加入購物車
    cart = add_item(cart, name, price, quantity);
    // 計算總金額
    calc_cart_total();
}

function calc_cart_total() {
    // 重置總金額
    total = 0;
    // 計算商品價格
    cost_ajax(cart, function(cost) {
        // 累加商品價格
        total += cost;
        // 計算運費
        shipping_ajax(cart, function(shipping) {
            // 累加運費
            total += shipping;
            // 更新總金額到畫面上
            update_total_dom(total);
        })
    })
}

將以上按鈕行為分析其Actions:

  1. 讀取cart (add_item流程 - 第1步)
  2. 寫入cart (add_item流程 - 第2步)
  3. 寫入total = 0 (calc_cart_total流程 - 第1步)
  4. 讀取cart (calc_cart_total流程 - 第2步)
  5. 呼叫cost_ajax() (calc_cart_total流程 - 第3步)
  6. 讀取total (calc_cart_total流程 - 第4步)
  7. 寫入total (calc_cart_total流程 - 第5步)
  8. 讀取cart (calc_cart_total流程 - 第6步)
  9. 呼叫shipping_ajax() (calc_cart_total流程 - 第7步)
  10. 讀取total (calc_cart_total流程 - 第8步)
  11. 寫入total (calc_cart_total流程 - 第9步)
  12. 讀取total (calc_cart_total流程 - 第10步)
  13. 呼叫update_total_dom(total) (calc_cart_total流程 - 第11步)

共3條時間線,13個步驟。

| 時間線1 | 時間線2 | 時間線3 | | :---: |:---: | :---: | | ↓ | | | | 讀取cart | | | | ↓ | | | | 寫入cart | | | | ↓ | | | | 寫入total=0 | | | | ↓ | | | | 讀取cart | | | | ↓ | | | | 呼叫cost_ajax | | | | | ↓ | | | | 讀取total | | | | ↓ | | | | 寫入total | | | | ↓ | | | | 讀取cart | | | | ↓ | | | | 呼叫shipping_ajax | | | | | ↓ | | | | 讀取total | | | | ↓ | | | | 寫入total | | | | ↓ | | | | 讀取total | | | | ↓ | | | | 呼叫update_total_dom更新DOM |

合併同一時間線的步驟: | 時間線1 | 時間線2 | 時間線3 | | :---: |:---: | :---: | | ↓ | | | | 讀取cart寫入cart寫入total=0讀取cart呼叫cost_ajax | | | | | ↓ | | | | 讀取total寫入total讀取cart呼叫shipping_ajax | | | | | ↓ | | | | 讀取total寫入total讀取total呼叫update_total_dom更新DOM |

慢慢點擊兩次產生正確結果

| 時間線1 | 時間線2 | | :---: |:---: | | ↓ | | | 讀取cart寫入cart寫入total=0讀取cart呼叫cost_ajax | | | ↓ | | | | 讀取total寫入total讀取cart呼叫shipping_ajax | | | ↓ | | | | 讀取total寫入total讀取total呼叫update_total_dom更新DOM | | | ↓ | | | | ↓ | | | 讀取cart寫入cart寫入total=0讀取cart呼叫cost_ajax | | | ↓ | | | | | 讀取total寫入total讀取cart呼叫shipping_ajax | | | ↓ | | | 讀取total寫入total讀取total呼叫update_total_dom更新DOM | | | ↓ |

快速點擊兩次產生錯誤結果

| 時間線1 | 時間線2 | | :---: |:---: | | ↓ | | | 讀取cart寫入cart寫入total=0讀取cart呼叫cost_ajax | | | ↓ | | | | 讀取total寫入total讀取cart呼叫shipping_ajax | | | ↓ | | | | | ↓ | | | 讀取cart寫入cart寫入total=0讀取cart呼叫cost_ajax | | | ↓ | | | | | 讀取total寫入total讀取cart呼叫shipping_ajax | | | ↓ | | 讀取total寫入total讀取total呼叫update_total_dom更新DOM | | | ↓ | | | | 讀取total寫入total讀取total呼叫update_total_dom更新DOM | | | ↓ |

因第一次點擊的呼叫shipping_ajax較晚回應,所以時間線1計算的total=14,而第二次呼叫shipping_ajax後讀取的total=14,然後14+2=16,就將錯誤的結果顯示到dom上,所以問題在於共享資源total

以下為改寫後的內容:

// 改寫共享資源: 
// 1.將cart改成傳入引數
// 2.將total改為區域變數
function add_item_to_cart(name, price, quantity) {
    cart = add_item(cart, name, price, quantity);
    calc_cart_total(cart);
}

function calc_cart_total(cart) {
    var total = 0;
    cost_ajax(cart, function(cost) {
        total += cost;
        shipping_ajax(cart, function(shipping) {
            // 上面剛剛的錯誤就是在這裡發生的
            // 因第一次的shipping_ajax延遲,total = 12 + 2
            // 造成第二次的shipping_ajax讀取total時的值為14, total = 14 + 2
            total += shipping;
            update_total_dom(total);
        })
    })
}

第十六章

上章還有一個DOM問題未處理,假設點擊兩次商品,若第1次的更新DOM動作比第2次慢,會造成第2次的更新被第1次蓋掉,顯示錯誤的結果。

更新DOM會有以下3種組合:

同時執行 (因遊覽器為single thread,故不可能發生)
-> 更新 DOM[第1次點擊] ->
-> 更新 DOM[第2次點擊] ->

先執行 **第1次點擊的** 再執行 **第2次點擊的** (正確順序):
-> 更新 DOM[第1次點擊] ->
                     -> 更新 DOM[第2次點擊]->

先執行 **第2次點擊的** 後執行 **第1次點擊的** (錯誤順序):
                     -> 更新 DOM[第1次點擊] ->
-> 更新 DOM[第2次點擊] ->

利用佇列(queue)

利用佇列(queue)的 先進先出[FIFO] 特性來確保順序是正確的。

例如:點擊3次滑鼠產生3個任務依序推入佇列,再利用先進先出機制來一個一個完成任務。

// 改寫佇列方式更新DOM: 
// 1.新增佇列陣列與佇列陣列的操作函式
// 2.將 calc_cart_total 這個非同步操作改成丟入佇列任務函式
//   並透過timer來讓event loop取出任務來執行(OS:類似throttle)
// 3.新增一個變數用來記錄當前佇列是否仍工作中
// 4.調整 calc_cart_total 函式,使得執行完DOM更新的回呼後變更"工作"狀態並且"執行下次"任務
var queue_items = [];
var working = false;

function update_total_queue(cart) {
    queue_items.push(cart);
    // 透過timer來交給event loop執行
    setTimeout(runNext, 0);
}

function runNext() {
    // 若"工作中"就不執行,讓回呼去執行下次的任務
    if (working) return;
    // 若佇列沒有任務也不執行(終止條件)
    if (queue_items.length === 0) return;
    working = true;
    var cart = queue_items.shift();
    calc_cart_total(cart, update_total_dom);
}

function add_item_to_cart(name, price, quantity) {
    cart = add_item(cart, name, price, quantity);
    update_total_queue(cart);
}

function calc_cart_total(cart, callback) {
    var total = 0;
    cost_ajax(cart, function(cost) {
        total += cost;
        shipping_ajax(cart, function(shipping) {
            total += shipping;
            callback(total);
            // 當執行完總金額回呼後表示dom已更新完畢,就工作狀態改為"無工作",並執行佇列的下個任務
            working = false;
            runNext();
        })
    })
}

接下來把上方程式內的全域變數改放進函式,才不會被任意修改。

// 1.新增一個函式來將全域變數放在其中

function queue() {
    var queue_items = [];
    var working = false;
    
    function runNext() {
        if (working) return;
        if (queue_items.length === 0) return;

        working = true;
        var cart = queue_items.shift();
        calc_cart_total(cart, update_total_dom);
    }
    
    function calc_cart_total(cart, callback) {
        var total = 0;
        cost_ajax(cart, function(cost) {
            total += cost;
            shipping_ajax(cart, function(shipping) {
                total += shipping;
                callback(total);

                working = false;
                runNext();
            })
        })
    }
    
    return function (cart) {
        queue_items.push(cart);
        setTimeout(runNext, 0);
    }
}

var update_total_queue = queue();

function add_item_to_cart(name, price, quantity) {
    cart = add_item(cart, name, price, quantity);
    update_total_queue(cart);
}

再來把上方程式內的的"商業邏輯"抽出來,讓佇列功能可以被其他函式共用。

// 1.將商業邏輯用函式包裝起來,並新增一個完成後的回呼給該函式,將該函式當引數傳入"佇列功能"(告訴佇列這是等等要執行的功能)
// 佇列功能就只負責:
//   1.將任務推入佇列並讓event loop執行該任務
//   2.執行任務時,將佇列內的項目取出並執行佇列設定的回呼(需檢查是否"工作中"或"沒有任務"可執行)
//   3.當外部函式呼叫回呼表示任務執行完畢,變更"工作狀態"並且繼續檢查與執行下個任務直到沒有任務

function queue(worker) {
    var queue_items = [];
    var working = false;
    
    function runNext() {
        if (working) return;
        if (queue_items.length === 0) return;

        working = true;
        var item = queue_items.shift();

        // 執行此外部函式,並傳入回呼給外部函式
        worker(item, function () {
            working = false;
            runNext();
        })
    }
    
    
    return function (item) {
        queue_items.push(item);
        setTimeout(runNext, 0);
    }
}

// 將商業邏輯用函式包裝起來,並新增一個完成後的回呼給該函式(這是用來告訴queue外部功能已執行完畢)
function worker(cart, done) {
    calc_cart_total(cart, function(total) {
        update_total_dom(total);
        done(true);// 此回呼表示該任務執行完畢了
    });
}

var update_total_queue = queue(worker);

function add_item_to_cart(name, price, quantity) {
    cart = add_item(cart, name, price, quantity);
    update_total_queue(cart);
}

function calc_cart_total(cart, callback) {
    var total = 0;
    cost_ajax(cart, function(cost) {
        total += cost;
        shipping_ajax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}

佇列新增一個功能,每次完成任務後執行外部所提供的回呼。

// queue原本紀錄的內容再多一個回呼,並且於任務完成後執行該回呼並將外部傳入的引數也傳給回呼

function queue(worker) {
    var queue_items = [];
    var working = false;
    
    function runNext() {
        if (working) return;
        if (queue_items.length === 0) return;

        working = true;
        var item = queue_items.shift();

        worker(item.data, function (val) {
            working = false;
            // 使用非同步呼叫該回呼並傳入外部傳入的引數
            setTimeout(item.callback, 0, val);
            runNext();
        })
    }
    
    
    return function (item, callback) {
        // 擴充佇列內紀錄的內容,新增一個回呼提供任務完成後執行用
        queue_items.push({
            data: item,
            callback: callback || function(){}
        });
        setTimeout(runNext, 0);
    }
}

function worker(cart, done) {
    calc_cart_total(cart, function(total) {
        update_total_dom(total);
        done(total);
    });
}

var update_total_queue = queue(worker);

function add_item_to_cart(name, price, quantity) {
    cart = add_item(cart, name, price, quantity);
    update_total_queue(cart);
}

function calc_cart_total(cart, callback) {
    var total = 0;
    cost_ajax(cart, function(cost) {
        total += cost;
        shipping_ajax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}

佇列再新增一個功能,可以設定佇列容量上限,避免當佇列任務過多時,要等很久。

// queue新增一個容量上限值,每次推入佇列時就檢查是否超過容量上限

function queue(max, worker) {
    var queue_items = [];
    var working = false;
    
    function runNext() {
        if (working) return;
        if (queue_items.length === 0) return;

        working = true;
        var item = queue_items.shift();

        worker(item.data, function (val) {
            working = false;
            setTimeout(item.callback, 0, val);
            runNext();
        })
    }
    
    
    return function (item, callback) {
        // 擴充佇列內紀錄的內容,新增一個回呼提供任務完成後執行用
        queue_items.push({
            data: item,
            callback: callback || function(){}
        });
        // 檢查佇列任務是否超過容量上限,超過的任務就丟棄
        while(queue_items.length > max) {
            queue_items.shift()
        }
        setTimeout(runNext, 0);
    }
}

function worker(cart, done) {
    calc_cart_total(cart, function(total) {
        update_total_dom(total);
        done(total);
    });
}

var update_total_queue = queue(3, worker); // 超過3個就丟棄

function add_item_to_cart(name, price, quantity) {
    cart = add_item(cart, name, price, quantity);
    update_total_queue(cart);
}

function calc_cart_total(cart, callback) {
    var total = 0;
    cost_ajax(cart, function(cost) {
        total += cost;
        shipping_ajax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}

以上就完成了Queue的高階功能,使得其他地方均可使用。

當同時間點擊了各種商品加入購物車,此Queue仍依照一開始點擊的順序執行每一個任務。

第十七章

透過時間線可以幫助辨識哪些Action的先後順序會造成問題。

範例(購物車新增商品後計算總金額到DOM上,已使用Queue來避免同時點擊多次的錯誤):

function worker(cart, done) {
    calc_cart_total(cart, function(total) {
        update_total_dom(total);
        done(total);
    });
}

var update_total_queue = queue(1, worker);

function add_item_to_cart(item) {
    cart = add_item(cart, item);
    update_total_queue(cart);
}

// 修改前(程序正常) 購物車總金額計算如下:
// 1.先執行cost_ajax取得商品價格然後total累加
// 2.然後再執行shipping_ajax取得運費然後total累加
// 3.將總金額更新到畫面
// 4.執行完成
function calc_cart_total(cart, callback) {
    var total = 0;
    
    cost_ajax(cart, function(cost) {
        total += cost;
        
        shipping_ajax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}

// 修改後(程序可能發生錯誤) - 將上面的第1步與第2步同時執行(因為兩個互不相干,為了加速,故同時執行)
function calc_cart_total(cart, callback) {
    var total = 0;
    
    cost_ajax(cart, function(cost) {
        total += cost;
    })
    
    shipping_ajax(cart, function(shipping) {
        total += shipping;
        callback(total);
    })
}

上面修改後的時間線如下:

| 主時間線 | 時間線分支1 | 時間線分支2 | | :---: | :---: |:---: | | ↓ | | | | 讀取cart寫入cart讀取cart呼叫update_total_queue | | | | ↓ | | | | | 初始化total呼叫cost_ajax呼叫shipping_ajax | | | | ↓ | | | | | | ↓ | ↓ | | | 讀取total寫入total | 讀取total寫入total讀取total呼叫update_total_dom | | | ↓ | ↓ |

再來看時間線分支1時間線分支2的潛在順序

同時執行 (因遊覽器為single thread,故不可能發生)
-> 讀取total & 寫入total ->
-> 讀取total & 寫入total & 讀取total & update_total_dom() ->



先執行 **時間線分支1** 再執行 **時間線分支2** (正確順序):
-> 讀取total & 寫入total ->
                        -> 讀取total & 寫入total & 讀取total & update_total_dom() ->



先執行 **時間線分支2** 再執行 **時間線分支1** (錯誤順序):
                                                        -> 讀取total & 寫入total ->
-> 讀取total & 寫入total & 讀取total & update_total_dom() ->

以上這兩步驟確實可以同時執行,但問題是total應該是要等這兩個步驟都執行完畢才需要讀取total並顯示到DOM上。

所以實作一個 Cut 函式用來等待指定數量的時間線後才執行回呼

function Cut(num, callback) {
    var num_finished = 0;
    return function () {
        num_finished += 1;
        if (num_finished === num) {
            callback();
        }
    }
}

// 範例
var done = Cut(3, function() {
    console.log('完成了3個任務');
});

done();
done();
done(); // 完成了3個任務

然後將此函式加入到上面修改而產生錯誤的代碼中

// 修改前(程序可能發生錯誤) - 因兩個(cost_ajax, shipping_ajax)潛在順序不同可能造成顯示total錯誤
function calc_cart_total(cart, callback) {
    var total = 0;
    
    cost_ajax(cart, function(cost) {
        total += cost;
    })
    
    shipping_ajax(cart, function(shipping) {
        total += shipping;
        callback(total);
    })
}

// 修改後(程序正確) - 不管哪一個後執行都不會影響total的計算,因只有當兩個任務都完成後才會計算總額
function calc_cart_total(cart, callback) {
    var total = 0;
    // cut就是個計數器而已,用來記錄執行了幾次,當完成了指定次數就呼叫回呼
    var done = Cut(2, function() {
        callback(total)
    })
    cost_ajax(cart, function(cost) {
        total += cost;
        done();
    })
    
    shipping_ajax(cart, function(shipping) {
        total += shipping;
        done();
    })
}

僅執行一次的函式

情境: 想要顧客首次將商品加入購物車時顯示一行訊息,之後就不再顯示了。

function JustOnce(action) {
    var alreadyCalled = false;
    
    return function (...args) {
        if (alreadyCalled) return;
        
        alreadyCalled = true;
        action(...args);
    }
}

隱性與顯性時間模型

所有程式語言均有一個隱性的時間模型,該模型描述了兩件與程式執行有關的事。

  1. 順序(Ordering)
  2. 重複(Repetition)

JavaScript的模型因Single Thread與event loop的關係,所以相對簡單,如下:

  1. 序列式的陳述式就按照序列順序執行

    a();
    b();
    // 先執行a()後再執行b()
    
  2. 不同時間線上的兩個步驟,可能出現"左側先執行"或"右側先執行"

    ajaxA(function() {
      a();
    });
    ajaxB(function() {
      b();
    });
    // 可能先執行a()後再執行b()也可能先執行b()後再執行a()
    
  3. 非同步事件需再新的時間線中呼叫(與上面類似)

  4. 呼叫幾次Action函式,該函式就執行幾次

    a();
    a();
    a();
    a();
    // 由上而下依序執行每個a()
    

第十八章

1.反應式架構(reactive architecture): 由Actions組成的序列 2.洋蔥式架構(onion architecture): 對於任何必須與外界互動的服務透過此設計均能賦予清楚的結構, 有點像剝洋蔥一樣一層一層被包覆的結構,例如: 第一層:互動層,第二層:領域層,第三層:語言層