不知道怎麼提高程式碼可擴展性?來看看優秀框架源碼中的這幾種設計模式吧!

為什麼要提高程式碼擴展性

我們寫的程式碼都是為了一定的需求服務的,但是這些需求並不是一成不變的,當需求變更了,如果我們程式碼的擴展性很好,我們可能只需要簡單的添加或者刪除模組就行了,如果擴展性不好,可能所有程式碼都需要重寫,那就是一場災難了,所以提高程式碼的擴展性是勢在必行的。怎樣才算有好的擴展性呢?好的擴展性應該具備以下特徵:

  1. 需求變更時,程式碼不需要重寫。
  2. 局部程式碼的修改不會引起大規模的改動。有時候我們去重構一小塊程式碼,但是發現他跟其他程式碼都是雜糅在一起的,裡面各種耦合,一件事情拆在幾個地方做,要想改這一小塊必須要改很多其他程式碼。那說明這些程式碼的耦合太高,擴展性不強。
  3. 可以很方便的引入新功能和新模組。

怎麼提高程式碼擴展性?

當然是從優秀的程式碼身上學習了,本文會深入AxiosNode.jsVue等優秀框架,從他們源碼總結幾種設計模式出來,然後再用這些設計模式嘗試解決下工作中遇到的問題。本文主要會講職責鏈模式觀察者模式適配器模式裝飾器模式。下面一起來看下吧:

職責鏈模式

職責鏈模式顧名思義就是一個鏈條,這個鏈條上串聯了很多的職責,一個事件過來,可以被鏈條上的職責依次處理。他的好處是鏈條上的各個職責,只需要關心自己的事情就行了,不需要知道自己的上一步是什麼,下一步是什麼,跟上下的職責都不耦合,這樣當上下職責變化了,自己也不受影響,往鏈條上添加或者減少職責也非常方便。

實例:Axios攔截器

用過Axios的朋友應該知道,Axios的攔截器有請求攔截器響應攔截器,執行的順序是請求攔截器 -> 發起請求 -> 響應攔截器,這其實就是一個鏈條上串起了三個職責。下面我們來看看這個鏈條怎麼實現:

// 先從用法入手,一般我們添加攔截器是這樣寫的 
// instance.interceptors.request.use(fulfilled, rejected)
// 根據這個用法我們先寫一個Axios類。
function Axios() {
  // 實例上有個interceptors對象,裡面有request和response兩個屬性
  // 這兩個屬性都是InterceptorManager的實例
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

// 然後是實現InterceptorManager類
function InterceptorManager() {
  // 實例上有一個數組,存儲攔截器方法
  this.handlers = [];
}

// InterceptorManager有一個實例方法use
InterceptorManager.prototype.use = function(fulfilled, rejected) {
  // 這個方法很簡單,把傳入的回調放到handlers裡面就行
  this.handlers.push({
    fulfilled,
    rejected
  })
}

上面的程式碼其實就完成了攔截器創建和use的邏輯,並不複雜,那這些攔截器方法都是什麼時候執行呢?當然是我們調用instance.request的時候,調用instance.request的時候真正執行的就是請求攔截器 -> 發起請求 -> 響應攔截器鏈條,所以我們還需要來實現下Axios.prototype.request:

Axios.prototype.request = function(config) {
  // chain裡面存的就是我們要執行的方法鏈條
  // dispatchRequest是發起網路請求的方法,本文主要講設計模式,這個方法就不實現了
  // chain裡面先把發起網路請求的方法放進去,他的位置應該在chain的中間
  const chain = [dispatchRequest, undefined];
  
  // chain前面是請求攔截器的方法,從request.handlers裡面取出來放進去
  this.interceptors.request.handlers.forEach(function unshiftRequestInterceptors(interceptor) {
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });
  
  // chain後面是響應攔截器的方法,從response.handlers裡面取出來放進去
  this.interceptors.response.handlers.forEach(function pushResponseInterceptors(interceptor) {
    chain.push(interceptor.fulfilled, interceptor.rejected);
  });
  
  // 經過上述程式碼的組織,chain這時候是這樣的:
  // [request.fulfilled, request.rejected, dispatchRequest, undefined, response.fulfilled,  
  // response.rejected]
  // 這其實已經按照請求攔截器 -> 發起請求 -> 響應攔截器的順序排好了,拿來執行就行
  
  let promise = Promise.resolve(config);   // 先來個空的promise,好開啟then
  while (chain.length) {
    // 用promise.then進行鏈式調用
    promise = promise.then(chain.shift(), chain.shift());
  }

  return promise;
}

上述程式碼是從Axios源碼中精簡出來的,可以看出他巧妙的運用了職責鏈模式,將需要做的任務組織成一個鏈條,這個鏈條上的任務相互不影響,攔截器可有可無,而且可以有多個,兼容性非常強。

實例:職責鏈組織表單驗證

看了優秀框架對職責鏈模式的運用,我們再看看在我們平時工作中這個模式怎麼運用起來。現在假設有這樣一個需求是做一個表單驗證,這個驗證需要前端先對格式等內容進行校驗,然後API發給後端進行合法性校驗。我們先分析下這個需求,前端校驗是同步的,後端驗證是非同步的,整個流程是同步非同步交織的,為了能兼容這種情況,我們的每個驗證方法的返回值都需要包裝成promise才行

// 前端驗證先寫個方法
function frontEndValidator(inputValue) {
  return Promise.resolve(inputValue);      // 注意返回值是個promise
}

// 後端驗證也寫個方法
function backEndValidator(inputValue) {
  return Promise.resolve(inputValue);      
}

// 寫一個驗證器
function validator(inputValue) {
  // 仿照Axios,將各個步驟放入一個數組
  const validators = [frontEndValidator, backEndValidator];
  
  // 前面Axios是循環調用promise.then來執行的職責鏈,我們這裡換個方式,用async來執行下
  async function runValidate() {
    let result = inputValue;
    while(validators.length) {
      result = await validators.shift()(result);
    }
    
    return result;
  }
  
  // 執行runValidate,注意返回值也是一個promise
  runValidate().then((res) => {console.log(res)});
}

// 上述程式碼已經可以執行了,只是我們沒有具體的校驗邏輯,輸入值會原封不動的返回
validator(123);     // 輸出: 123

上述程式碼我們用職責鏈模式組織了多個校驗邏輯,這幾個校驗之間相互之間沒有依賴,如果以後需要減少某個校驗,只需要將它從validators數組中刪除即可,如果要添加就往這個數組添加就行了。這幾個校驗器之間的耦合度就大大降低了,而且他們封裝的是promise,完全還可以用到其他模組去,其他模組根據需要組織自己的職責鏈就行了。

觀察者模式

觀察者模式還有個名字叫發布訂閱模式,這在JS的世界裡可是大名鼎鼎,大家或多或少都用到過,最常見的就是事件綁定了,有些面試還會要求面試者手寫一個事件中心,其實就是一個觀察者模式。觀察者模式的優點是可以讓事件的產生者和消費者相互不知道,只需要產生和消費相應的事件就行,特別適合事件的生產者和消費者不方便直接調用的情況,比如非同步中。我們來手寫一個觀察者模式看看:

class PubSub {
  constructor() {
    // 一個對象存放所有的消息訂閱
    // 每個消息對應一個數組,數組結構如下
    // {
    //   "event1": [cb1, cb2]
    // }
    this.events = {}
  }

  subscribe(event, callback) {
    if(this.events[event]) {
      // 如果有人訂閱過了,這個鍵已經存在,就往裡面加就好了
      this.events[event].push(callback);
    } else {
      // 沒人訂閱過,就建一個數組,回調放進去
      this.events[event] = [callback]
    }
  }

  publish(event, ...args) {
    // 取出所有訂閱者的回調執行
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      subscribedEvents.forEach(callback => {
        callback.call(this, ...args);
      });
    }
  }

  unsubscribe(event, callback) {
    // 刪除某個訂閱,保留其他訂閱
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      this.events[event] = this.events[event].filter(cb => cb !== callback)
    }
  }
}

// 使用的時候
const pubSub = new PubSub();
pubSub.subscribe('event1', () => {});    // 註冊事件
pubSub.publish('event1');                // 發布事件

實例:Node.js的EventEmitter

觀察者模式的一個典型應用就是Node.js的EventEmitter,我有另一篇文章從發布訂閱模式入手讀懂Node.js的EventEmitter源碼從非同步應用的角度詳細講解了觀察者模式的原理和Node.js的EventEmitter源碼,我這裡就不重複書寫了,上面的手寫程式碼也是來自這篇文章。

實例:轉圈抽獎

一樣的,看了優秀框架的源碼,我們自己也要試著來用一下,這裡的例子是轉圈抽獎。想必很多朋友都在網上抽過獎,一個轉盤,裡面各種獎品,點一下抽獎,然後指針開始旋轉,最後會停留到一個獎品那裡。我們這個例子就是要實現這樣一個Demo,但是還有一個要求是每轉一圈速度就加快一點。我們來分析下這個需求:

  1. 要轉盤抽獎,我們肯定先要把轉盤畫出來。
  2. 抽獎肯定會有個結果,有獎還是沒獎,具體是什麼獎品,一般這個結果都是API返回的,很多實現方案是點擊抽獎就發起API請求拿到結果了,轉圈動畫只是個效果而已。
  3. 我們寫一點程式碼讓轉盤動起來,需要一個運動效果
  4. 每轉一圈我們需要加快速度,所以還需要控制運動的速度

通過上面的分析我們發現一個問題,轉盤運動是需要一些時間的,當他運動完了需要告訴控制轉盤的模組加快速度進行下一圈的運動,所以運動模組和控制模組需要一個非同步通訊,這種非同步通訊就需要我們的觀察者模式來解決了。最終效果如下,由於只是個DEMO,我就用幾個DIV塊來代替轉盤了:

pubsub

下面是程式碼:

// 先把之前的發布訂閱模式拿過來
class PubSub {
  constructor() {
    this.events = {}
  }

  subscribe(event, callback) {
    if(this.events[event]) {
      this.events[event].push(callback);
    } else {
      this.events[event] = [callback]
    }
  }

  publish(event, ...args) {
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      subscribedEvents.forEach(callback => {
        callback.call(this, ...args);
      });
    }
  }

  unsubscribe(event, callback) {
    const subscribedEvents = this.events[event];

    if(subscribedEvents && subscribedEvents.length) {
      this.events[event] = this.events[event].filter(cb => cb !== callback)
    }
  }
}

// 實例化一個事件中心
const pubSub = new PubSub();

// 總共有 初始化頁面 -> 獲取最終結果 -> 運動效果 -> 運動控制 四個模組
// 初始化頁面
const domArr = [];
function initHTML(target) {
  // 總共10個可選獎品,也就是10個DIV
  for(let i = 0; i < 10; i++) {
    let div = document.createElement('div');
    div.innerHTML = i;
    div.setAttribute('class', 'item');
    target.appendChild(div);
    domArr.push(div);
  }
}

// 獲取最終結果,也就是總共需要轉幾次,我們採用一個隨機數加40(4圈)
function getFinal() {
  let _num = Math.random() * 10 + 40;

  return Math.floor(_num, 0);
}

// 運動模組,具體運動方法
function move(moveConfig) {
  // moveConfig = {
  //   times: 10,     // 本圈移動次數
  //   speed: 50      // 本圈速度
  // }
  let current = 0; // 當前位置
  let lastIndex = 9;   // 上個位置

  const timer = setInterval(() => {
    // 每次移動給當前元素加上邊框,移除上一個的邊框
    if(current !== 0) {
      lastIndex = current - 1;
    }

    domArr[lastIndex].setAttribute('class', 'item');
    domArr[current].setAttribute('class', 'item item-on');

    current++;

    if(current === moveConfig.times) {
      clearInterval(timer);

      // 轉完了一圈廣播事件
      if(moveConfig.times === 10) {
        pubSub.publish('finish');
      }
    }
  }, moveConfig.speed);
}

// 運動控制模組,控制每圈的參數
function moveController() {
  let allTimes = getFinal();
  let circles = Math.floor(allTimes / 10, 0);
  let stopNum = allTimes % circles;
  let speed = 250;  
  let ranCircle = 0;

  move({
    times: 10,
    speed
  });    // 手動開啟第一次旋轉

  // 監聽事件,每次旋轉完成自動開啟下一次旋轉
  pubSub.subscribe('finish', () => {
    let time = 0;
    speed -= 50;
    ranCircle++;

    if(ranCircle <= circles) {
      time = 10;
    } else {
      time = stopNum;
    }

    move({
      times: time,
      speed,
    })
  });
}

// 繪製頁面,開始轉動
initHTML(document.getElementById('root'));
moveController();

上述程式碼的難點就在於運動模組的運動是非同步的,需要在每圈運動完了之後通知運動控制模組進行下一次轉動,觀察者模式很好的解決了這個問題。本例完整程式碼我已經上傳到我的GitHub了,可以去拿下來運行下玩玩。

裝飾器模式

裝飾器模式針對的情況是我有一些老程式碼,但是這些老程式碼功能不夠,需要添加功能,但是我又不能去改老程式碼,比如Vue 2.x需要監聽數組的改變,給他添加響應式,但是他又不能直接修改Array.prototype。這種情況下,就特別適合使用裝飾者模式,給老方法重新裝飾下,變成一個新方法來使用。

基本結構

裝飾器模式的結構也很簡單,就是先調用一下原來的方法,然後加上更多的操作,就是裝飾一下。

var a = {
  b: function() {}
}

function myB() {
  // 先調用以前的方法
  a.b();
  
  // 再加上自己的新操作
  console.log('新操作');
}

實例:Vue數組的監聽

熟悉Vue響應式原理的朋友都知道(不熟悉的朋友可以看這裡),Vue 2.x對象的響應式是通過Object.defineProperty實現的,但是這個方法不能監聽數組的改變,那數組怎麼監聽的呢?數組操作一般就是pushshift這些方法,這些方法是數組原生的方法,我們當然不能去改他,那會了裝飾器模式,我們完全可以在保持他之前功能的基礎上給他擴展功能:

var arrayProto = Array.prototype;    // 先拿到原生數組的原型
var arrObj = Object.create(arrayProto);     // 用原生數組的原型創建一個新對象,免得污染原生數組
var methods = ['push', 'shift'];    // 需要擴展的方法,這裡只寫了兩個,但是不止這兩個

// 循環methods數組,擴展他們
methods.forEach(function(method) {
  // 用擴展的方法替換arrObj上的方法
  arrObj[method] = function() {
    var result = arrayProto[method].apply(this, arguments);    // 先執行老方法
    dep.notify();     // 這個是Vue的方法,用來做響應式
    return result;
  }
});

// 對於用戶定義的數組,手動將它的原型指向擴展了的arrObj
var a = [1, 2, 3];
a.__proto__ = arrObj;

上述程式碼是從Vue源碼精簡過來的,其實就是一個典型的使用裝飾器擴展原有方法的功能的例子,因為Vue只擴展了數組方法,如果你不通過這些方法,而是直接通過下標來操作數組,響應式就不起作用了。

實例:擴展已有的事件綁定

老規矩,學習了人家的程式碼,我們自己也來試試。這個例子面臨的需求是我們需要對已有的DOM點擊事件上增加一些操作。

// 我們以前的點擊事件只需要列印1
dom.onclick = function() {
  console.log(1);
}

但是我們現在的需求要求還要輸出一個2,我們當然可以返回原來的程式碼將他改掉,但是我們也可以用裝飾者模式給他添加功能:

var oldFunc = dom.onclick;  // 先將老方法拿出來
dom.onclick = function() {   // 重新綁定事件
  oldFunc.apply(this, arguments);  // 先執行老的方法
  
  // 然後添加新的方法
  console.log(2);
}

上述程式碼就擴展了dom的點擊事件,但是如果需要修改的DOM元素很多,我們要一個一個的去重新綁定事件,又會有大量相似程式碼,我們學設計模式的目的之一就是要避免重複程式碼,於是我們可以將公用的綁定操作提取出來,作為一個裝飾器:

var decorator = function(dom, fn) {
  var oldFunc = dom.onclick;
  
  if(typeof oldFunc === 'function'){
    dom.onclick = function() {
      oldFunc.apply(this, arguments);
      fn();
    }
  }
}

// 調用裝飾器,傳入參數就可以擴展了
decorator(document.getElementById('test'), function() {
  console.log(2);
})

這種方式特別適合我們引入的第三方UI組件,有些UI組件自己封裝了很多功能,但是並沒有暴露出介面,如果我們要添加功能,又不能直接修改他的源碼,最好的方法就是這樣使用裝飾器模式來擴展,而且有了裝飾工廠之後,我們還可以快速批量修改。

適配器模式

適配器想必大家都用過,我家裡的老顯示卡只有HDMI介面,但是顯示器是DP介面,這兩個插不上,怎麼辦呢?答案就是買個適配器,將DP介面轉換為HDMI的就行了。這裡的適配器模式原理類似,當我們面臨介面不通用,介面參數不匹配等情況,我們可以在他外面再包一個方法,這個方法接收我們現在的名字和參數,裡面調用老方法傳入以前的參數形式。

基本結構

適配器模式的基本結構就是下面這樣,假設我們要用的打log的函數叫mylog,但是具體方法我們又想調用現成的window.console.log實現,那我們就可以給他包一層。

var mylog = (function(){
  return window.console.log;
})()

如果覺得上面的結構太簡單了,仍然不知道怎麼運用,我們下面再通過一個例子來看下。

實例:框架變更了

假如我們現在面臨的一個問題是公司以前一直使用的A框架,但是現在決定換成jQuery了,這兩個框架大部分介面是兼容的,但是部分介面不適配,我們需要解決這個問題。

// 一個修改css的介面
$.css();      // jQuery叫css
A.style();    // A框架叫style

// 一個綁定事件的介面
$.on();       // jQuery叫on
A.bind();     // A框架叫bind

當然我們全局搜索把使用的地方改掉也行,但是如果使用適配器修改可能更優雅:

// 直接把以前用的A替換成$
window.A = $;

// 適配A.style
A.style = function() {
  return $.css.apply(this, arguments);    // 保持this不變
}

// 適配A.bind
A.bind = function() {
  return $.on.apply(this, arguments);
}

適配器就是這麼簡單,介面不一樣,包一層改成一樣就行了。

實例:參數適配

適配器模式不僅僅可以像上面那樣來適配介面不一致的情況,還可以用來適配參數的多樣性。假如我們的一個方法需要接收一個很複雜的對象參數,比如webpack的配置,可能有很多選項,但是用戶可能只用到部分,或者用戶可能傳入不支援的配置,那我們需要一個將用戶傳入的配置適配到標準配置的過程,這個做起來其實也很簡單:

// func方法接收一個很複雜的config
function func(config) {
  var defaultConfig = {
    name: 'hong',
    color: 'red',
    // ......
  };
  
  // 為了將用戶的配置適配到標準配置,我們直接循環defaultConfig
  // 如果用戶傳入了配置,就用用戶的,如果沒傳就用默認的
  for(var item in defaultConfig) {
    defaultConfig[item] = config[item] || defaultConfig[item];
  }
}

總結

  1. 高擴展性的核心其實就是高內聚,低耦合,各個模組都專註在自己的功能,盡量減少對外部的直接依賴。
  2. 職責鏈模式和觀察者模式主要是用來降低模組間耦合的,耦合低了就可以很方便的對他們進行組織,給他們擴展功能,適配器模式和裝飾器模式主要是用來在不影響原有程式碼的基礎上進行擴展的。
  3. 如果我們需要對某個對象進行一系列的操作,這些操作可以組織成一個鏈條,那我們可以考慮使用職責鏈模式。鏈條上的具體任務不需要知道其他任務的存在,只專註自己的工作,消息的傳遞由鏈條負責。使用了職責鏈模式,鏈條上的任務可以很方便的增加,刪除或者重新組織成新的鏈條,就像一個流水線一樣。
  4. 如果我們有兩個對象在不確定的時間點需要非同步通訊,我們可以考慮使用觀察者模式,使用者不需要一直關注其他特定的對象,他只要在消息中心註冊一個消息,當這個消息出現時,消息中心會負責來通知他。
  5. 如果我們已經拿到了一些舊程式碼,但是這些舊程式碼不能滿足我們的需求,我們又不能隨意更改他,我們可以考慮使用裝飾器模式來增強他的功能。
  6. 對於舊程式碼改造或者新模組引入,我們可能面臨介面不通用的情況,這時候我們可以考慮寫一個適配器來適配他們。適配器模式同樣適用於參數適配的情況。
  7. 還是那句話,設計模式更注重的是思想,不用生搬硬套程式碼模板。也不要在所有地方硬套設計模式,而是在真正需要的時候才使用他來增加我們程式碼的可擴展性。

本文是設計模式的第三篇文章,主要講提高擴展性的設計模式,前兩篇是:

(480贊!)不知道怎麼封裝程式碼?看看這幾種設計模式吧!

不知道怎麼提高程式碼復用性?看看這幾種設計模式吧

後面還有一篇提高程式碼品質的設計模式。

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

本文素材來自於網易高級前端開發工程師微專業唐磊老師的設計模式課程。

作者博文GitHub項目地址: //github.com/dennis-jiang/Front-End-Knowledges

作者掘金文章匯總://juejin.im/post/5e3ffc85518825494e2772fd