不知道怎麼提高程式碼質量?來看看這幾種設計模式吧!

_蔣鵬飛發表於2020-06-05

提高程式碼質量的目的

程式猿的本職工作就是寫程式碼,寫出高質量的程式碼應該是我們的追求和對自己的要求,因為:

  1. 高質量的程式碼往往意味著更少的BUG,更好的模組化,是我們擴充套件性,複用性的基礎
  2. 高質量的程式碼也意味著更好的書寫,更好的命名,有利於我們的維護

什麼程式碼算好的質量

怎樣來定義程式碼質量的"好",業界有很多標準,本文認為好的程式碼應該有以下特點:

  1. 程式碼整潔,比如縮排之類的,現在有很多工具可以自動解決這個問題,比如eslint。
  2. 結構規整,沒有漫長的結構,函式拆分合理,不會來一個幾千行的函式,也不會有幾十個if...else。這要求寫程式碼的人有一些優化的經驗,本文會介紹幾種模式來優化這些情況。
  3. 閱讀起來好理解,不會出現一堆a,b,c這種命名,而是應該儘量語義化,變數名和函式名都儘量有意義,最好是程式碼即註釋,讓別人看你的程式碼就知道你在幹嘛。

本文介紹的設計模式主要有策略/狀態模式外觀模式迭代器模式備忘錄模式

策略/狀態模式

策略模式基本結構

假如我們需要做一個計算器,需要支援加減乘除,為了判斷使用者具體需要進行哪個操作,我們需要4個if...else來進行判斷,如果支援更多操作,那if...else會更長,不利於閱讀,看著也不優雅。所以我們可以用策略模式優化如下:

function calculator(type, a, b) {
  const strategy = {
    add: function(a, b) {
      return a + b;
    },
    minus: function(a, b) {
      return a - b;
    },
    division: function(a, b) {
      return a / b;
    },
    times: function(a, b) {
      return a * b;
    }
  }
  
  return strategy[type](a, b);
}

// 使用時
calculator('add', 1, 1);

上述程式碼我們用一個物件取代了多個if...else,我們需要的操作都對應這個物件裡面的一個屬性,這個屬性名字對應我們傳入的type,我們直接用這個屬性名字就可以獲取對應的操作。

狀態模式基本結構

狀態模式和策略模式很像,也是有一個物件儲存一些策略,但是還有一個變數來儲存當前的狀態,我們根據當前狀態來獲取具體的操作:

function stateFactor(state) {
  const stateObj = {
    status: '',
    state: {
      state1: function(){},
      state2: function(){},
    },
    run: function() {
      return this.state[this.status];
    }
  }
  
  stateObj.status = state;
  return stateObj;
}

// 使用時
stateFactor('state1').run();

if...else其實是根據不同的條件來改變程式碼的行為,而策略模式和狀態模式都可以根據傳入的策略或者狀態的不同來改變行為,所有我們可以用這兩種模式來替代if...else

例項:訪問許可權

這個例子的需求是我們的頁面需要根據不同的角色來渲染不同的內容,如果我們用if...else寫就是這樣:

// 有三個模組需要顯示,不同角色看到的模組應該不同
function showPart1() {}
function showPart2() {}
function showPart3() {}

// 獲取當前使用者的角色,然後決定顯示哪些部分
axios.get('xxx').then((role) => {
  if(role === 'boss'){
    showPart1();
    showPart2();
    showPart3();
  } else if(role === 'manager') {
    showPart1();
    showPart2();
  } else if(role === 'staff') {
    showPart3();
  }
});

上述程式碼中我們通過API請求獲得了當前使用者的角色,然後一堆if...else去判斷應該顯示哪些模組,如果角色很多,這裡的if...else就可能很長,我們可以嘗試用狀態模式優化下:

// 先把各種角色都包裝到一個ShowController類裡面
function ShowController() {
  this.role = '';
  this.roleMap = {
    boss: function() {
      showPart1();
      showPart2();
      showPart3();
    },
    manager: function() {
      showPart1();
    	showPart2();
    },
    staff: function() {
      showPart3();
    }
  }
}

// ShowController上新增一個例項方法show,用來根據角色展示不同的內容
ShowController.prototype.show = function() {
  axios.get('xxx').then((role) => {
    this.role = role;
    this.roleMap[this.role]();
  });
}

// 使用時
new ShowController().show();

上述程式碼我們通過一個狀態模式改寫了訪問許可權模組,去掉了if...else,而且不同角色的展示都封裝到了roleMap裡面,後面要增加或者減少都會方便很多。

例項:複合運動

這個例子的需求是我們現在有一個小球,我們需要控制他移動,他移動的方向可以是上下左右,還可以是左上,右下之類的複合運動。如果我們也用if...else來寫,這頭都會寫大:

// 先來四個方向的基本運動
function moveUp() {}
function moveDown() {}
function moveLeft() {}
function moveRight() {}

// 具體移動的方法,可以接收一個或兩個引數,一個就是基本操作,兩個引數就是左上,右下這類操作
function move(...args) {
  if(args.length === 1) {
    if(args[0] === 'up') {
      moveUp();
    } else if(args[0] === 'down') {
      moveDown();        
    } else if(args[0] === 'left') {
      moveLeft();        
    } else if(args[0] === 'right') {
      moveRight();        
    }
  } else {
    if(args[0] === 'left' && args[1] === 'up') {
      moveLeft();
      moveUp();
    } else if(args[0] === 'right' && args[1] === 'down') {
      moveRight();
      moveDown();
    }
    // 後面還有很多if...
  }
}

可以看到這裡if...else看得我們頭都大了,還是用策略模式來優化下吧:

// 建一個移動控制類
function MoveController() {
  this.status = [];
  this.moveHanders = {
    // 寫上每個指令對應的方法
    up: moveUp,
    dowm: moveDown,
    left: moveLeft,
    right: moveRight
  }
}

// MoveController新增一個例項方法來觸發運動
MoveController.prototype.run = function(...args) {
  this.status = args;
  this.status.forEach((move) => {
    this.moveHanders[move]();
  });
}

// 使用時
new MoveController().run('left', 'up')

上述程式碼我們也是將所有的策略都封裝到了moveHanders裡面,然後通過例項方法run傳入的方法來執行具體的策略。

外觀模式

基本結構

當我們設計一個模組時,裡面的方法可以會設計得比較細,但是暴露給外面使用的時候,不一定非得直接暴露這些細小的介面,外部使用者需要的可能是組合部分介面來實現某個功能,我們暴露的時候其實就可以將這個組織好。這就像餐廳裡面的選單,有很多菜,使用者可以一個一個菜去點,也可以直接點一個套餐,外觀模式提供的就類似於這樣一個組織好的套餐:

function model1() {}

function model2() {}

// 可以提供一個更高階的介面,組合好了model1和model2給外部使用
function use() {
  model2(model1());
}

例項:常見的介面封裝

外觀模式說起來其實非常常見,很多模組內部都很複雜,但是對外的介面可能都是一兩個,我們無需知道複雜的內部細節,只需要呼叫統一的高階介面就行,比如下面的選項卡模組:

// 一個選項卡類,他內部可能有多個子模組
function Tab() {}

Tab.prototype.renderHTML = function() {}    // 渲染頁面的子模組
Tab.prototype.bindEvent = function() {}    // 繫結事件的子模組
Tab.prototype.loadCss = function() {}    // 載入樣式的子模組

// 對外不需要暴露上面那些具體的子模組,只需要一個高階介面就行
Tab.prototype.init = function(config) {
  this.loadCss();
  this.renderHTML();
  this.bindEvent();
}

上述程式碼這種封裝模式非常常見,其實也是用到了外觀模式,他當然也可以暴露具體的renderHTMLbindEventloadCss這些子模組,但是外部使用者可能並不關心這些細節,只需要給一個統一的高階介面就行,就相當於改變了外觀暴露出來,所以叫外觀模式

例項:方法的封裝

這個例子也很常見,就是把一些類似的功能封裝成一個方法,而不是每個地方去寫一遍。在以前還是IE主導天下的時候,我們需要做很多相容的工作,僅僅是一個繫結事件就有addEventListenerattachEvent,onclick等,為了避免每次都進行這些檢測,我們可以將他們封裝成一個方法:

function addEvent(dom, type, fn) {
  if(dom.addEventListener) {
    return dom.addEventListener(type, fn, false);
  } else if(dom.attachEvent) {
    return dom.attachEvent("on" + type, fn);
  } else {
    dom["on" + type] = fn;
  }
}

然後將addEvent暴露出去給外面使用,其實我們在實際編碼時經常這樣封裝方法,只是我們自己可能沒意識到這個是外觀模式。

迭代器模式

基本結構

迭代器模式模式在JS裡面很常見了,陣列自帶的forEach就是迭代器模式的一個應用,我們也可以實現一個類似的功能:

function Iterator(items) {
  this.items = items;
}

Iterator.prototype.dealEach = function(fn) {
  for(let i = 0; i < this.items.length; i++) {
    fn(this.items[i], i);
  }
}

上述程式碼我們新建了一個迭代器類,建構函式接收一個陣列,例項方法dealEach可以接收一個回撥,對例項上的items每一項都執行這個回撥。

例項:資料迭代器

其實JS陣列很多原生方法都用了迭代器模式,比如findfind接收一個測試函式,返回符合這個測試函式的第一個資料。這個例子要做的是擴充套件這個功能,返回所有符合這個測試函式的資料項,而且也可以接收兩個引數,第一個引數是屬性名,第二個引數是值,同樣返回所有該屬性與值匹配的項:

// 外層用一個工廠模式封裝下,呼叫時不用寫new
function iteratorFactory(data) {
  function Iterator(data) {
    this.data = data;
  }
  
  Iterator.prototype.findAll = function(handler, value) {
    const result = [];
    let handlerFn;
    // 處理引數,如果第一個引數是函式,直接拿來用
    // 如果不是函式,就是屬性名,給一個對比的預設函式
    if(typeof handler === 'function') {
      handlerFn = handler;
    } else {
      handlerFn = function(item) {
        if(item[handler] === value) {
          return true;
        }
        
        return false;
      }
    }
    
    // 迴圈資料裡面的每一項,將符合結果的塞入結果陣列
    for(let i = 0; i < this.data.length; i++) {
      const item = this.data[i];
      const res = handlerFn(item);
      if(res) {
        result.push(item)
      }
    }
    
    return result;
  }
  
  return new Iterator(data);
}

// 寫個資料測試下
const data = [{num: 1}, {num: 2}, {num: 3}];
iteratorFactory(data).findAll('num', 2);    // [{num: 2}]
iteratorFactory(data).findAll(item => item.num >= 2); // [{num: 2}, {num: 3}]

上述程式碼封裝了一個類似陣列find的迭代器,擴充套件了他的功能,這種迭代器非常適合用來處理API返回的大量結構相似的資料。

備忘錄模式

基本結構

備忘錄模式類似於JS經常使用的快取函式,內部記錄一個狀態,也就是快取,當我們再次訪問的時候可以直接拿快取資料:

function memo() {
  const cache = {};
  
  return function(arg) {
    if(cache[arg]) {
      return cache[arg];
    } else {
      // 沒快取的時候先執行方法,得到結果res
      // 然後將res寫入快取
      cache[arg] = res;
      return res;
    }
}

例項:文章快取

這個例子在實際專案中也比較常見,使用者每次點進一個新文章都需要從API請求資料,如果他下次再點進同一篇文章,我們可能希望直接用上次請求的資料,而不再次請求,這時候就可以用到我們的備忘錄模式了,直接拿上面的結構來用就行了:

function pageCache(pageId) {
  const cache = {};
  
  return function(pageId) {
    // 為了保持返回型別一致,我們都返回一個Promise
    if(cache[pageId]) {
      return Promise.solve(cache[pageId]);
    } else {
      return axios.get(pageId).then((data) => {
        cache[pageId] = data;
        return data;
      })
    }
  }
}

上述程式碼用了備忘錄模式來解決這個問題,但是程式碼比較簡單,實際專案中可能需求會更加複雜一些,但是這個思路還是可以參考的。

例項:前進後退功能

這個例子的需求是,我們需要做一個可以移動的DIV,使用者把這個DIV隨意移動,但是他有時候可能誤操作或者反悔了,想把這個DIV移動回去,也就是將狀態回退到上一次,有了回退狀態的需求,當然還有配對的前進狀態的需求。這種類似的需求我們就可以用備忘錄模式實現:

function moveDiv() {
  this.states = [];       // 一個陣列記錄所有狀態
  this.currentState = 0;  // 一個變數記錄當前狀態位置
}

// 移動方法,每次移動記錄狀態
moveDiv.prototype.move = function(type, num) {
  changeDiv(type, num);       // 虛擬碼,移動DIV的具體操作,這裡並未實現
  
  // 記錄本次操作到states裡面去
  this.states.push({type,num});
  this.currentState = this.states.length - 1;   // 改變當前狀態指標
}

// 前進方法,取出狀態執行
moveDiv.prototype.forward = function() {
  // 如果當前不是最後一個狀態
  if(this.currentState < this.states.length - 1) {
    // 取出前進的狀態
    this.currentState++;
    const state = this.states[this.currentState];
    
    // 執行該狀態位置
    changeDiv(state.type, state.num);
  }
}

// 後退方法是類似的
moveDiv.prototype.back = function() {
  // 如果當前不是第一個狀態
  if(this.currentState > 0) {
    // 取出後退的狀態
    this.currentState--;
    const state = this.states[this.currentState];
    
    // 執行該狀態位置
    changeDiv(state.type, state.num);
  }
}

上述程式碼通過一個陣列將使用者所有操作過的狀態都記錄下來了,使用者可以隨時在狀態間進行前進和後退。

總結

本文講的這幾種設計模式策略/狀態模式外觀模式迭代器模式備忘錄模式都很好理解,而且在實際工作中也非常常見,熟練使用他們可以有效減少冗餘程式碼,提高我們的程式碼質量。

  1. 策略模式通過將我們的if條件改寫為一條條的策略減少了if...else的數量,看起來更清爽,擴充套件起來也更方便。狀態模式策略模式很像,只是還多了一個狀態,可以根據這個狀態來選取具體的策略。
  2. 外觀模式可能我們已經在無意間使用了,就是將模組一些內部邏輯封裝在一個更高階的介面內部,或者將一些類似操作封裝在一個方法內部,從而讓外部呼叫更加方便。
  3. 迭代器模式在JS陣列上有很多實現,我們也可以模仿他們做一下資料處理的工作,特別適合處理從API拿來的大量結構相似的資料。
  4. 備忘錄模式就是加一個快取物件,用來記錄之前獲取過的資料或者操作的狀態,後面可以用來加快訪問速度或者進行狀態回滾。
  5. 還是那句話,設計模式的重點在於理解思想,實現方式可以多種多樣。

本文是講設計模式的最後一篇文章,前面三篇是:

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

(100+贊!)框架原始碼中用來提高擴充套件性的設計模式

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

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

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

作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges

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

相關文章