去除 JavaScript 代码的怪味

jopen 9年前

原文出处: 张金龙的博客(@涂鸦码农)

难闻的代码

/* 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;  }

为毛是这个味?

很多原因:

  • 声明过多
  • 嵌套太深
  • 复杂度太高

检查工具

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