框架源碼中用來提升擴展性的設計模式

爲何要提升代碼擴展性

咱們寫的代碼都是爲了必定的需求服務的,可是這些需求並非一成不變的,當需求變動了,若是咱們代碼的擴展性很好,咱們可能只須要簡單的添加或者刪除模塊就好了,若是擴展性很差,可能全部代碼都須要重寫,那就是一場災難了,因此提升代碼的擴展性是勢在必行的。怎樣纔算有好的擴展性呢?好的擴展性應該具有如下特徵:javascript

  1. 需求變動時,代碼不須要重寫。
  2. 局部代碼的修改不會引發大規模的改動。有時候咱們去重構一小塊代碼,可是發現他跟其餘代碼都是雜糅在一塊兒的,裏面各類耦合,一件事情拆在幾個地方作,要想改這一小塊必需要改不少其餘代碼。那說明這些代碼的耦合過高,擴展性不強。
  3. 能夠很方便的引入新功能和新模塊。

怎麼提升代碼擴展性?

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

職責鏈模式

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

實例:Axios攔截器

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

// 先從用法入手,通常咱們添加攔截器是這樣寫的 
// 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:java

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源碼中精簡出來的,能夠看出他巧妙的運用了職責鏈模式,將須要作的任務組織成一個鏈條,這個鏈條上的任務相互不影響,攔截器無關緊要,並且能夠有多個,兼容性很是強。webpack

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

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

// 前端驗證先寫個方法
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,徹底還能夠用到其餘模塊去,其餘模塊根據須要組織本身的職責鏈就好了。git

觀察者模式

觀察者模式還有個名字叫發佈訂閱模式,這在JS的世界裏但是大名鼎鼎,你們或多或少都用到過,最多見的就是事件綁定了,有些面試還會要求面試者手寫一個事件中心,其實就是一個觀察者模式。觀察者模式的優勢是可讓事件的產生者和消費者相互不知道,只須要產生和消費相應的事件就行,特別適合事件的生產者和消費者不方便直接調用的狀況,好比異步中。咱們來手寫一個觀察者模式看看:github

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源碼,我這裏就不重複書寫了,上面的手寫代碼也是來自這篇文章。web

實例:轉圈抽獎

同樣的,看了優秀框架的源碼,咱們本身也要試着來用一下,這裏的例子是轉圈抽獎。想必不少朋友都在網上抽過獎,一個轉盤,裏面各類獎品,點一下抽獎,而後指針開始旋轉,最後會停留到一個獎品那裏。咱們這個例子就是要實現這樣一個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. 仍是那句話,設計模式更注重的是思想,不用生搬硬套代碼模板。也不要在全部地方硬套設計模式,而是在真正須要的時候才使用他來增長咱們代碼的可擴展性。

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

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

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

我也搞了個公衆號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~

相關文章
相關標籤/搜索