去除 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/
復制粘貼代碼的味道
已有功能…
已有代碼,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 ... 那么,現在想要這個功能
于是,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
程序源碼的復制 / 粘貼檢查器
(JavaScript, TypeScript, C#, Ruby, CSS, SCSS, HTML, 等等都適用…)
jscpd -f **/*.js -l 1 -t 30 --languages 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 聲明
資源
- CodePen
- Addy Osmani’s JavaScript Design Patterns eBook
- ESLint
- eslint-plugin-smells
- ES6 Scoping
- ES6 Symbols
- Learn ES6
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
- no-this-assign (eslint-plugin-smells)
- consistent-this
- no-extra-bind
字符串連接的味道
難聞的代碼
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;
} 為毛這個味?無法同步的計時器
用 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) {} */
重復定義
難聞的代碼
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); 為毛是這個味?緊密耦合的依賴關系
如何改善!?!
- 依賴注入
- 消息訂閱
- 依賴注入
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); - 消息訂閱
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); 資源
- postal by @ifandelse
連續不斷的交互
難聞的代碼
var search = document.querySelector('.Autocomplete');
search.addEventListener('input', function(e) {
// Make Ajax call for autocomplete
console.log(e.target.value);
});
解決方案:節流閥
var search = document.querySelector('.Autocomplete');
search.addEventListener('input', _.throttle(function(e) {
// Make Ajax call for autocomplete
console.log(e.target.value);
}, 500));
解決方案:DEBOUNCE
var search = document.querySelector('.Autocomplete');
search.addEventListener('input', _.debounce(function(e) {
// Make Ajax call for autocomplete
console.log(e.target.value);
}, 500));
資源
匿名函數
難聞的代碼
var search = document.querySelector('.Autocomplete');
search.addEventListener('input', function(e) {
console.log(e.target.value);
}); 給函數命名的原因:
- 堆棧追蹤
- 去關聯
- 代碼復用
- 堆棧追蹤
var search = document.querySelector('.Autocomplete');
search.addEventListener('input', function(e) {
console.log(e.target.value);
});
修改后
var search = document.querySelector('.Autocomplete');
search.addEventListener('input', function matches(e) {
console.log(e.target.value);
});
- 去關聯
單次事件綁定
document.querySelector('button')
.addEventListener('click', function handler() {
alert('Ka-boom!');
this.removeEventListener('click', handler);
});
- 代碼復用
var kaboom = function() { alert('Ka-boom'); };
document.querySelector('button').addEventListener('click', kaboom);
document.querySelector('#egg').addEventListener('mouseenter', kaboom); 資源
結尾
更多的 ESLint 規則
資源
NPM 搜索 eslint-plugin