去除 JavaScript 代碼的怪味

jopen 9年前發布 | 35K 次閱讀 JavaScript開發 JavaScript

原文出處: 張金龍的博客(@涂鴉碼農)

難聞的代碼

/ const / var CONSONANTS = 'bcdfghjklmnpqrstvwxyz';
/ const / var VOWELS = 'aeiou';

function englishToPigLatin(english) { / const / var SYLLABLE = 'ay'; var pigLatin = '';

if (english !== null && english.length > 0 && (VOWELS.indexOf(english[0]) > -1 || CONSONANTS.indexOf(english[0]) > -1 )) { if (VOWELS.indexOf(english[0]) > -1) { pigLatin = english + SYLLABLE; } else { var preConsonants = ''; for (var i = 0; i < english.length; ++i) { if (CONSONANTS.indexOf(english[i]) > -1) { preConsonants += english[i]; if (preConsonants == 'q' && i+1 < english.length && english[i+1] == 'u') { preConsonants += 'u'; i += 2; break; } } else { break; } } pigLatin = english.substring(i) + preConsonants + SYLLABLE; } }

return pigLatin; }</pre>

為毛是這個味?

很多原因:

  • 聲明過多
  • 嵌套太深
  • 復雜度太高
  • </ul>

    檢查工具

    Lint 規則

    /*jshint maxstatements:15, maxdepth:2, maxcomplexity:5 */
    /*jshint 最多聲明:15, 最大深度:2, 最高復雜度:5*/
    
    /*eslint max-statements:[2, 15], max-depth:[1, 2], complexity:[2, 5] */

    結果

    7:0 - Function 'englishToPigLatin' has a complexity of 7.
    //englishToPigLatin 方法復雜度為 7
    7:0 - This function has too many statements (16). Maximum allowed is 15.
    // 次方法有太多聲明(16)。最大允許值為 15。
    22:10 - Blocks are nested too deeply (5).
    // 嵌套太深(5)

    重構

    const CONSONANTS = ['th', 'qu', 'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k',
    'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'y', 'z'];
    const VOWELS = ['a', 'e', 'i', 'o', 'u'];
    const ENDING = 'ay';
    
    let isValid = word => startsWithVowel(word) || startsWithConsonant(word);
    let startsWithVowel = word => !!~VOWELS.indexOf(word[0]);
    let startsWithConsonant = word => !!~CONSONANTS.indexOf(word[0]);
    let getConsonants = word => CONSONANTS.reduce((memo, char) => {
      if (word.startsWith(char)) {
        memo += char;
        word = word.substr(char.length);
      }
      return memo;
    }, '');
    
    function englishToPigLatin(english='') {
       if (isValid(english)) {
          if (startsWithVowel(english)) {
            english += ENDING;
          } else {
            let letters = getConsonants(english);
            english = `${english.substr(letters.length)}${letters}${ENDING}`;
          }
       }
       return english;
    }

    重構后統計

    • max-statements(最多聲明): 16 → 6
    • max-depth(最大嵌套): 5 → 2
    • complexity(復雜度): 7 → 3
    • max-len(最多行數): 65 → 73
    • max-params(最多參數): 1 → 2
    • max-nested-callbacks(最多嵌套回調): 0 → 1

    資源

    jshint - http://jshint.com/
    eslint - http://eslint.org/
    jscomplexity - http://jscomplexity.org/
    escomplex - https://github.com/philbooth/escomplex
    jasmine - http://jasmine.github.io/

    復制粘貼代碼的味道

    已有功能…

     去除 JavaScript 代碼的怪味

    已有代碼,BOX.js

    // ... more code ...
    
    var boxes = document.querySelectorAll('.Box');
    
    [].forEach.call(boxes, function(element, index) {
      element.innerText = "Box: " + index;
      element.style.backgroundColor =
        '#' + (Math.random() * 0xFFFFFF << 0).toString(16);
    });
    
    // ... more code ...

    那么,現在想要這個功能

     去除 JavaScript 代碼的怪味

    于是,Duang! CIRCLE.JS 就出現了…

    // ... more code ...
    
    var circles = document.querySelectorAll(".Circle");
    
    [].forEach.call(circles, function(element, index) {
      element.innerText = "Circle: " + index;
      element.style.color =
        '#' + (Math.random() * 0xFFFFFF << 0).toString(16);
    });
    
    // ... more code ...

    為毛是這個味?因為我們復制粘貼了!

    工具

    JSINSPECT

    檢查復制粘貼和結構相似的代碼

    一行命令:

    jsinspect

     去除 JavaScript 代碼的怪味

    JSCPD

    程序源碼的復制 / 粘貼檢查器

    (JavaScript, TypeScript, C#, Ruby, CSS, SCSS, HTML, 等等都適用…)

    jscpd -f **/*.js -l 1 -t 30 --languages javascript

     去除 JavaScript 代碼的怪味

    怎么能不被發現?重構

    把隨機顏色部分丟出去…

    let randomColor = () => `#${(Math.random() * 0xFFFFFF << 0).toString(16)};
    
    let boxes = document.querySelectorAll(".Box");
    [].forEach.call(boxes, (element, index) => {
      element.innerText = "Box: " + index;
      element.style.backgroundColor = randomColor();
    });
    
    let circles = document.querySelectorAll(".Circle");
    [].forEach.call(circles, (element, index) => {
      element.innerText = "Circle: " + index;
      element.style.color = randomColor();
    });

    再重構

    再把怪異的 [].forEach.call 部分丟出去…

    let randomColor = () => `#${(Math.random() * 0xFFFFFF << 0).toString(16)};
    
    let $$ = selector => [].slice.call(document.querySelectorAll(selector || '*'));
    
    $$('.Box').forEach((element, index) => {
      element.innerText = "Box: " + index;
      element.style.backgroundColor = randomColor();
    });
    
    $$(".Circle").forEach((element, index) => {
      element.innerText = "Circle: " + index;
      element.style.color = randomColor();
    });

    再再重構

    let randomColor = () => `#${(Math.random() * 0xFFFFFF << 0).toString(16)};
    
    let $$ = selector => [].slice.call(document.querySelectorAll(selector || '*'));
    
    let updateElement = (selector, textPrefix, styleProperty) => {
      $$(selector).forEach((element, index) => {
        element.innerText = textPrefix + ': ' + index;
        element.style[styleProperty] = randomColor();
      });
    }
    
    updateElement('.Box', 'Box', 'backgroundColor');
    
    updateElement('.Circle', 'Circle', 'color');

    資源

    switch 味道

    難聞的代碼

    function getArea(shape, options) {
      var area = 0;
    
      switch (shape) {
        case 'Triangle':
          area = .5 * options.width * options.height;
          break;
    
        case 'Square':
          area = Math.pow(options.width, 2);
          break;
    
        case 'Rectangle':
          area = options.width * options.height;
          break;
    
        default:
          throw new Error('Invalid shape: ' + shape);
      }
    
      return area;
    }
    
    getArea('Triangle',  { width: 100, height: 100 });
    getArea('Square',    { width: 100 });
    getArea('Rectangle', { width: 100, height: 100 });
    getArea('Bogus');

    為毛是這個味?違背“打開 / 關閉原則

    增加個新形狀

    function getArea(shape, options) {
      var area = 0;
    
      switch (shape) {
        case 'Triangle':
          area = .5 * options.width * options.height;
          break;
    
        case 'Square':
          area = Math.pow(options.width, 2);
          break;
    
        case 'Rectangle':
          area = options.width * options.height;
          break;
    
        case 'Circle':
          area = Math.PI * Math.pow(options.radius, 2);
          break;
    
        default:
          throw new Error('Invalid shape: ' + shape);
      }
    
      return area;
    }

    加點設計模式

    (function(shapes) { // triangle.js
      var Triangle = shapes.Triangle = function(options) {
        this.width = options.width;
        this.height = options.height;
      };
      Triangle.prototype.getArea = function() {
        return 0.5 * this.width * this.height;
      };  
    }(window.shapes = window.shapes || {}));
    
    function getArea(shape, options) {
      var Shape = window.shapes[shape], area = 0;
    
      if (Shape && typeof Shape === 'function') {
        area = new Shape(options).getArea();
      } else {
        throw new Error('Invalid shape: ' + shape);
      }
    
      return area;
    }
    
    getArea('Triangle',  { width: 100, height: 100 });
    getArea('Square',    { width: 100 });
    getArea('Rectangle', { width: 100, height: 100 });
    getArea('Bogus');

    再增加新形狀時

    // circle.js
    (function(shapes) {
      var Circle = shapes.Circle = function(options) {
        this.radius = options.radius;
      };
    
      Circle.prototype.getArea = function() {
        return Math.PI * Math.pow(this.radius, 2);
      };
    
      Circle.prototype.getCircumference = function() {
        return 2 * Math.PI * this.radius;
      };
    }(window.shapes = window.shapes || {}));

    還有其它的味道嗎?神奇的字符串

    function getArea(shape, options) {
      var area = 0;
    
      switch (shape) {
        case 'Triangle':
          area = .5 * options.width * options.height;
          break;
        /* ... more code ... */
      }
    
      return area;
    }
    
    getArea('Triangle', { width: 100, height: 100 });

    神奇的字符串重構為對象類型

    var shapeType = {
      triangle: 'Triangle'
    };
    
    function getArea(shape, options) {
      var area = 0;
      switch (shape) {
        case shapeType.triangle:
          area = .5 * options.width * options.height;
          break;
      }
      return area;
    }
    
    getArea(shapeType.triangle, { width: 100, height: 100 });

    神奇字符重構為 CONST & SYMBOLS

    const shapeType = {
      triangle: new Symbol()
    };
    
    function getArea(shape, options) {
      var area = 0;
      switch (shape) {
        case shapeType.triangle:
          area = .5 * options.width * options.height;
          break;
      }
      return area;
    }
    
    getArea(shapeType.triangle, { width: 100, height: 100 });

    工具!?!

    木有 :(

    ESLINT-PLUGIN-SMELLS
    用于 JavaScript Smells(味道) 的 ESLint 規則

    規則

    • no-switch – 不允許使用 switch 聲明
    • no-complex-switch-case – 不允許使用復雜的 switch 聲明

    資源

    this 深淵的味道

    難聞的代碼

    function Person() {
      this.teeth = [{ clean: false }, { clean: false }, { clean: false }];
    };
    
    Person.prototype.brush = function() {
      var that = this;
    
      this.teeth.forEach(function(tooth) {
        that.clean(tooth);
      });
    
      console.log('brushed');
    };
    
    Person.prototype.clean = function(tooth) {
      tooth.clean = true;
    }
    
    var person = new Person();
    person.brush();
    console.log(person.teeth);

    為什么是這個味?that 還是 self 還是 selfie

    替換方案
    1) bind

    Person.prototype.brush = function() {
      this.teeth.forEach(function(tooth) {
        this.clean(tooth);
      }.bind(this));
    
      console.log('brushed');
    };

    替換方案
    2) forEach 的第二個參數

    Person.prototype.brush = function() {
      this.teeth.forEach(function(tooth) {
        this.clean(tooth);
      }, this);
    
      console.log('brushed');
    };

    替換方案
    3) ECMAScript 2015 (ES6)

    Person.prototype.brush = function() {
      this.teeth.forEach(tooth => {
        this.clean(tooth);
      });
    
      console.log('brushed');
    };

    4a) 函數式編程

    Person.prototype.brush = function() {
      this.teeth.forEach(this.clean);
    
      console.log('brushed');
    };

    4b) 函數式編程

    Person.prototype.brush = function() {
      this.teeth.forEach(this.clean.bind(this));
    
      console.log('brushed');
    };

    工具

    ESLint

    字符串連接的味道

    難聞的代碼

    var build = function(id, href) {
      return $( "<div id='tab'><a href='" + href + "' id='"+ id + "'></div>" );
    }

    為毛是這個味?因為字符串連接

    替換方案
    @thomasfuchs 推文上的 JavaScript 模板引擎

    function t(s, d) {
      for (var p in d)
        s = s.replace(new RegExp('{' + p + '}', 'g'), d[p]);
      return s;
    }
    
    var build = function(id, href) {
      var options = {
        id: id
        href: href
      };
    
      return t('<div id="tab"><a href="{href}" id="{id}"></div>', options);
    }

    替換方案
    2) ECMAScript 2015 (ES6) 模板字符串

    var build = (id, href) =>
      `<div id="tab"><a href="${href}" id="${id}"></div>`;

    替換方案
    3) ECMAScript 2015 (ES6) 模板字符串 (多行)

    替換方案
    4) 其它小型庫或大型庫 / 框架

    • Lowdash 或 Underscore
    • Angular
    • React
    • Ember
    • 等等…

    工具

    ESLINT-PLUGIN-SMELLS
    no-complex-string-concat

    資源

    Tweet Sized JavaScript Templating Engine by @thomasfuchs
    Learn ECMAScript 2015 (ES6) - http://babeljs.io/docs/learn-es6/

    jQuery 調查

    難聞的代碼

    $(document).ready(function() {
      $('.Component')
        .find('button')
          .addClass('Component-button--action')
          .click(function() { alert('HEY!'); })
        .end()
        .mouseenter(function() { $(this).addClass('Component--over'); })
        .mouseleave(function() { $(this).removeClass('Component--over'); })
        .addClass('initialized');
    });

    為毛是這個味?喪心病狂的鏈式調用

    愉快地重構吧

    // Event Delegation before DOM Ready
    $(document).on('mouseenter mouseleave', '.Component', function(e) {
      $(this).toggleClass('Component--over', e.type === 'mouseenter');  
    });
    
    $(document).on('click', '.Component', function(e) {
      alert('HEY!');
    });
    
    $(document).ready(function() {
      $('.Component button').addClass('Component-button--action');
    });

    最終 Demo

    工具

    ESLINT-PLUGIN-SMELLS

    難以琢磨的計時器

    難聞的代碼

    setInterval(function() {
      console.log('start setInterval');
      someLongProcess(getRandomInt(2000, 4000));
    }, 3000);
    
    function someLongProcess(duration) {
      setTimeout(
        function() { console.log('long process: ' + duration); },
        duration
      );  
    }
    
    function getRandomInt(min, max) {
      return Math.floor(Math.random() * (max - min + 1)) + min;
    }

    為毛這個味?無法同步的計時器

    Demo: setInterval

    用 setTimeout 保證順序

    setTimeout(function timer() {
      console.log('start setTimeout')
      someLongProcess(getRandomInt(2000, 4000), function() {
        setTimeout(timer, 3000);
      });
    }, 3000);
    
    function someLongProcess(duration, callback) {
      setTimeout(function() {
        console.log('long process: ' + duration);
        callback();
      }, duration);  
    }
    
    /* getRandomInt(min, max) {} */

    Demo: setTimeout

    重復定義

    難聞的代碼

    data = this.appendAnalyticsData(data);
    data = this.appendSubmissionData(data);
    data = this.appendAdditionalInputs(data);
    data = this.pruneObject(data);

    替換方案
    1) 嵌套調用函數

    data = this.pruneObject(
      this.appendAdditionalInputs(
        this.appendSubmissionData(
          this.appendAnalyticsData(data)
        )
      )
    );

    2) forEach

    var funcs = [
      this.appendAnalyticsData,
      this.appendSubmissionData,
      this.appendAdditionalInputs,
      this.pruneObject
    ];
    
    funcs.forEach(function(func) {
      data = func(data);
    });

    3) reduce

    var funcs = [
      this.appendAnalyticsData,
      this.appendSubmissionData,
      this.appendAdditionalInputs,
      this.pruneObject
    ];
    
    data = funcs.reduce(function(memo, func) {
      return func(memo);
    }, data);

    4) flow

    data = _.flow(
      this.appendAnalyticsData,
      this.appendSubmissionData,
      this.appendAdditionalInputs,
      this.pruneObject
    )(data);

    工具

    ESLINT-PLUGIN-SMELLS

    資源

    過度耦合

    難聞的代碼

    function ShoppingCart() { this.items = []; }
    ShoppingCart.prototype.addItem = function(item) {
      this.items.push(item);
    };
    
    function Product(name) { this.name = name; }
    Product.prototype.addToCart = function() {
      shoppingCart.addItem(this);
    };
    
    var shoppingCart = new ShoppingCart();
    var product = new Product('Socks');
    product.addToCart();
    console.log(shoppingCart.items);

    為毛是這個味?緊密耦合的依賴關系

    如何改善!?!

    1. 依賴注入
    2. 消息訂閱
    3. 依賴注入
    function ShoppingCart() { this.items = []; }
    ShoppingCart.prototype.addItem = function(item) {
      this.items.push(item);
    };
    
    function Product(name, shoppingCart) {
      this.name = name;
      this.shoppingCart = shoppingCart;
    }
    Product.prototype.addToCart = function() {
      this.shoppingCart.addItem(this);
    };
    
    var shoppingCart = new ShoppingCart();
    var product = new Product('Socks', shoppingCart);
    product.addToCart();
    console.log(shoppingCart.items);
    1. 消息訂閱
    var channel = postal.channel();
    
    function ShoppingCart() {
      this.items = [];
      channel.subscribe('shoppingcart.add', this.addItem);
    }
    ShoppingCart.prototype.addItem = function(item) {
      this.items.push(item);
    };
    
    function Product(name) { this.name = name; }
    Product.prototype.addToCart = function() {
      channel.publish('shoppingcart.add', this);
    };
    
    var shoppingCart = new ShoppingCart();
    var product = new Product('Socks');
    product.addToCart();
    console.log(shoppingCart.items);

    資源

    連續不斷的交互

    難聞的代碼

    var search = document.querySelector('.Autocomplete');
    
    search.addEventListener('input', function(e) {
      // Make Ajax call for autocomplete
    
      console.log(e.target.value);
    });

    Demo: 根本停不下來

    解決方案:節流閥

    var search = document.querySelector('.Autocomplete');
    
    search.addEventListener('input', _.throttle(function(e) {
      // Make Ajax call for autocomplete
    
      console.log(e.target.value);
    }, 500));

    Demo

    解決方案:DEBOUNCE

    var search = document.querySelector('.Autocomplete');
    
    search.addEventListener('input', _.debounce(function(e) {
      // Make Ajax call for autocomplete
    
      console.log(e.target.value);
    }, 500));

    Demo

    資源

    匿名函數

    難聞的代碼

    var search = document.querySelector('.Autocomplete');
    
    search.addEventListener('input', function(e) {
      console.log(e.target.value);
    });

    給函數命名的原因:

    1. 堆棧追蹤
    2. 去關聯
    3. 代碼復用
    4. 堆棧追蹤
    var search = document.querySelector('.Autocomplete');
    
    search.addEventListener('input', function(e) {
      console.log(e.target.value);
    });

     去除 JavaScript 代碼的怪味

    修改后

    var search = document.querySelector('.Autocomplete');
    
    search.addEventListener('input', function matches(e) {
      console.log(e.target.value);
    });

     去除 JavaScript 代碼的怪味

    1. 去關聯

    單次事件綁定

    document.querySelector('button')
      .addEventListener('click', function handler() {
          alert('Ka-boom!');
        this.removeEventListener('click', handler);
      });

    Demo

    1. 代碼復用
    var kaboom = function() { alert('Ka-boom'); };
    
    document.querySelector('button').addEventListener('click', kaboom);
    
    document.querySelector('#egg').addEventListener('mouseenter', kaboom);

    資源

    結尾

    更多的 ESLint 規則

    資源

    NPM 搜索 eslint-plugin

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!