返回 登录
0

远离面条代码:编写可维护的 JS 代码

英文:Moritz Kröger 译文:伯乐在线专栏作者 - 小谢

几乎每个开发者都接手或维护过遗留项目,或者说是重启一个旧的项目。通常第一反应是抛弃原有的代码,从头开始写。这些代码会混乱不堪,没有文档,并且别人可能要花费好几天去读懂代码。但是,如果结合正确的规划、分析、和一个好的工作流程,那就有可能把一个意大利面式的代码仓库整理成一个整洁、有组织并易扩展的一份项目代码。

我曾经不得不接手并整理了很多的项目。从一开始就混乱不堪的也不是特别多。但实际上,最近就遇到了一个这样的情况。我已经学会了关于JavaScript代码组织的很多知识,最重要的是,不要被之前的程序员逼疯。在这篇文章中我想分享下一些我的步骤和我的经验。

分析项目

开始的第一步是简要看一下要做什么。如果是个网站,点击网站所有的功能:打开对话框、提交表单等等。做这些的时候,打开你的开发者工具,看下是否有报错或输出日志。如果是个node.js项目,打开命令行接口检查一下api。最好的情况是项目有一个入口(例如main.js,index.js,app.js),通过入口能将所有的模块初始化;如果是最坏的情况,也要找到每个业务逻辑的位置。

找出使用的工具。jquery? React? Express? 列出需要了解的一切重要的东西。如果所在项目使用 angular2 写的,而你还没有使用过,直接去看文档,最起码有个基本的了解。总之寻找最佳实践。

深入的了解项目

了解技术是一个好的开始,但是要得到真实的感觉和理解,需要研究一下 单元测试 。单元测试是用来测试代码的功能和方法是否按预期调用的一种方式。相比阅读和运行代码,单元测试能更深入的帮你了解代码。如果在你的项目中还没有单元测试,别急,我们接着往下看。

创建一个规范

这些都是关于代码一致性的内容。现在你已经了解了项目中使用的所有工具集,你知道了代码的结构和逻辑功能的位置,是时候建立一个规范。我建议添加一个 .editorconfig 文件来保证代码在不同的编辑器、IDE 或不同的开发者之间的编写风格一致。

正确的缩进

这是一个 饱受争议 (跟战争一样),代码中使用空格还是tab,其实这不重要。如果之前代码用的空格,那么使用空格,如果使用tab,继续用tab。只有当代码中都用到的时候才有必要决定使用其中的哪一个。讨论的观点是好的,但是一个好的项目必须保证所有的开发者能在一起和谐的工作。

为什么这很重要。因为每个人都有使用自己编辑器或使用 IDE 的方式。举例来说,我是 code folding 的追捧者。没有这些特性,我几乎会在文件中迷失。如果缩进不一样,那么代码看起来会很乱。所以,每次打开一个文件,在我开始工作之前必须修复缩进的问题。这很浪费时间。

// While this is valid JavaScript, the block can’t
// be properly folded due to its mixed indentation.
function foo (data) {
let property = String(data);

if (property === ‘bar’) {
property = doSomething(property);
}
//… more logic.
}

// Correct indentation makes the code block foldable,
// enabling a better experience and clean codebase.
function foo (data) {
let property = String(data);

if (property === ‘bar’) {
property = doSomething(property);
}
//… more logic.
}

命名

保证项目里面使用到的命名规则是合理的。通常 JavaScript 使用驼峰式命名方式,但是我看到了很多混合式的命名方式。举例来说,jQuery 项目常常含有 jQuery 变量和其他变量的混合命名。

// Inconsistent naming makes it harder
// to scan and understand the code. It can also
// lead to false expectations.
const element=(‘.element’);

function _privateMethod () {
const self = (this);constinternalElement=(‘.internal-element’);
let $data = element.data(‘foo’);
//… more logic.
}

// This is much easier and faster to understand.
const element=(‘.element’);

function _privateMethod () {
const this=(this);
const internalElement=(‘.internal-element’);
let elementData = $element.data(‘foo’);
//… more logic.
}

尽可能使用 lint

前面的几步会使我们的代码变得好看些,能够帮助我们快速地浏览代码,在此我还要推荐保证代码整洁性的最佳实践方案。 ESlint,JSlint , JSHint 是现在最流行的 JavaScript 格式工具。个人来说,之前使用 JSLint 比较多,现在开始又开始喜欢 ESlint 了,主要是它的一些自定义规则和最早支持 ES2015 语法很好用。

当你使用 lint 时,如果编辑器报了一堆错误,那么修复它们。在此之前什么也不要做。

更新依赖

更新依赖需要非常谨慎,如果你更换或更新了依赖很容易引发更多的错误,所以一些项目可能在某个版本(例如 v1.12.5)下面正常工作,然而通配符匹配到另个版本(例如 v1.22.x)就出问题了。这种情况下,你需要快速升级,版本号一般是这样的: MAJOR.MINOR.PATCH 。如果你还对语义化版本不熟悉,建议先读下 Tim Oxley 的这篇文章– Semver: A Primer 。

升级依赖没有通用的处理规则。每个项目不同,必须区分对待。项目中升级补丁版本号一般都不是问题,也可以建立副本使用。只有当依赖中主版本号内容发生冲突错误时,那就应该看下具体是什么发生了变化。可能 API 改变了,那样你就要大面积重写你项目中的代码。如果那觉得这样代价太高,那么我建议不要升级这个主版本号。

如果你使用 npm 来管理依赖(而且基本没什么其他好的方案了),你可以使用 npm outdated 命令在你的CLI里来检查哪些依赖版本是比较旧的。我举一个我项目里面叫 FrontBook 的例子,在这个项目中我经常更新依赖:
图片描述

如你所见,我这里有很多更新。但我一般不会马上更新,而是一次更新一个。可以说,这样花费了很多时间,然而这是保证不出问题的唯一方法(如果项目没有任何测试用例的话)。

让我们动起手来

这里我主要要表达的是整理项目并不一定意味着移除或重写大部分的代码。当然,有时候这是唯一的解决方案,但是这不是你一开始就应该考虑的问题。JavaScript 代码很可能成为一个奇怪的代码,后面去做一些调整通常是不可能的。我们通常需要根据特定的场景来给出一个改造方案。

建立单元测试

使用测试用例可以保证你的代码能进行正确的运行而不会出现意外的错误。JavaScript 单元测试直接可以写出很多文章,所有我这里没办法介绍太多。广泛使用的框架有 karma、jasmine、macha 和 ava 等。如果你也想测试你的用户界面,推荐使用 Nightwatch.js 和 Dalekjs 这类浏览器自动测试工具。

单元测试和浏览器自动化测试的区别是,前者测试 JavaScript 本身代码。它保证了所有的模块和通用逻辑能预期运行。浏览器自动化,另一方面来说是测试界面,也就是项目的用户界面,保证页面上的元素在预期正确的位置。

在重构任何事情之前先建立好单元测试。这样项目的稳定性会提升,或许你没有考虑过项目稳定性的东西。这样做的好处是,不必担心犯一些自己没有意识到的错误。

Rebecca Murphey 写过一篇非常不错的文章 writing unit tests for existing JavaScript 。

架构

JavaScript 架构是另一个大的主题。重构和整理框架决定于你在这方面有多少经验。我们在软件开发中有很多的设计模式。但是并不是所有的都能适应稳定性的需求。很不幸,这篇文章中我不能给出所有的场景,但至少还是可以给一些通用性的建议。

首先,你要知道你的项目中使用到了那种设计模式。了解下这种模式,并保证它在整个项目是一致的。可扩展性的一个关键的地方是和设计模式相结合的,而不是混合的方法。当然,在你的项目里可以使用不同的设计模式来达到不同的目的(例如使用 单例模式 来建立数据结构或者短命名的工具函数,或者在模块中使用 观察者模式 ),但是绝对不要在一个模块中使用了一种设计模式,而在另外的模块中使用不同的模式。

如果在你的项目中个确实没有使用到什么架构(可能什么代码都在一个巨大的 app.js 中 ),那么是时候改变它了。但不要马上做所有的改变,而是一点一点的来。同样,这里没有万能的方法,每个项目的设置也是不一样的。项目目录结构根据项目的规模和复杂度不同也不一样。通常,对于最基本的层级,结构一般分为分为第三方内容、模块内容、数据和一个初始化所有模块和逻辑的入口(例如 index.js、main.js)。

这样我们就需要模块化了。

所有的东西都模块化?

模块化至今也不是大规模可扩展 JavaScript 项目的解决方案。它需要开发者必须去熟悉另一层 API 。尽管这样可能会带来很多的困难,但它的原则是把你的功能划分成小的模块。这样,在团队协作过程中解决问题就变的更简单了。每个模块应该有个一个明确的目标功能点。一个模块应该是不知道你外面代码逻辑是什么样的,并且能在不同的地方和场景下复用。

那么怎样将大量关联逻辑的代码拆分成模块呢?一起看下。

// This example uses the Fetch API to request an API. Let’s assume
// that it returns a JSON file with some basic content. We then create a
// new element, count all characters from some fictional content
// and insert it somewhere in your UI.
fetch(‘https://api.somewebsite.io/post/61454e0126ebb8a2e85d‘, { method: ‘GET’ })
.then(response => {
if (response.status === 200) {
return response.json();
}
})
.then(json => {
if (json) {
Object.keys(json).forEach(key => {
const item = json[key];
const count = item.content.trim().replace(/s+/gi, ”).length;
const el =
<div class="foo-${item.className}">
<p>Total characters: ${count}</p>
</div>
;
const wrapper = document.querySelector(‘.info-element’);

    wrapper.innerHTML = el;
  });
}

})
.catch(error => console.error(error));

这里基本没有模块化。所有的东西都是紧密结合的,并且相互依赖。想象一下在更大、更复杂的函数里,如果出了问题你需要调试 bug。可能API 不响应、JSON 里面字段改变了或者其他的问题。这简直是噩梦。

让我们把它按照不同职责分离开来:

// In the previous example we had a function that counted
// the characters of a string. Let’s turn that into a module.
function countCharacters (text) {
const removeWhitespace = /s+/gi;
return text.trim().replace(removeWhitespace, ”).length;
}

// The part where we had a string with some markup in it,
// is also a proper module now. We use the DOM API to create
// the HTML, instead of inserting it with a string.
function createWrapperElement (cssClass, content) {
const className = cssClass || ‘default’;
const wrapperElement = document.createElement(‘div’);
const textElement = document.createElement(‘p’);
const textNode = document.createTextNode(Total characters: ${content});

wrapperElement.classList.add(className);
textElement.appendChild(textNode);
wrapperElement.appendChild(textElement);

return wrapperElement;
}

// The anonymous function from the .forEach() method,
// should also be its own module.
function appendCharacterCount (config) {
const wordCount = countCharacters(config.content);
const wrapperElement = createWrapperElement(config.className, wordCount);
const infoElement = document.querySelector(‘.info-element’);

infoElement.appendChild(wrapperElement);
}

很好,我们现在有三个模块了,我们来看下调用的情况:

fetch(‘https://api.somewebsite.io/post/61454e0126ebb8a2e85d‘, { method: ‘GET’ })
.then(response => {
if (response.status === 200) {
return response.json();
}
})
.then(json => {
if (json) {
Object.keys(json).forEach(key => appendCharacterCount(json[key]))
}
})
.catch(error => console.error(error));

我们将 .then() 里面的方法提取了出来,这里我想我已经向大家演示了模块化的意思了。

如果不用模块化会怎么样

正如我所说的,将你的项目代码分成增加了 API 的小模块 。如果你不想这样,又想保证代码在团队协作中的整洁性,那绝对就是使用更大的函数。但是你仍然可以将你的代码拆分成多个简单的部分并专注于可测试代码。

写注释

注释是一个很沉重的讨论话题。编程社区中一部分人主张为任何东西书写注释,而另一部分人认为 自带必要注释 的代码就够了。就像生活中很多事情一样,我想在两者之间做一个平衡是最好的选择。这里推荐使用 JSDoc 来管理你的文档。

JSDoc 是一个 JavaScript 的 API 文档生成器。通常可以在 IDE 插件里面使用。例如

function properties (name, obj = {}) {
if (!name) return;
const arr = [];

Object.keys(obj).forEach(key => {
if (arr.indexOf(obj[key][name]) <= -1) {
arr.push(obj[key][name]);
}
});

return arr;
}

这个函数接受两个参数后遍历一个对象,然后返回一个数组。这段代码不是很复杂,但是对于没有接触过这段代码的人还是需要一点时间来弄明白发生了什么事情。此外,函数的功能不是很明确。所以注释可以这样写:

/**
* Iterates over an object, pushes all properties matching ‘name’ into
* a new array, but only once per occurance.
* @param {String} propertyName - Name of the property you want
* @param {Object} obj - The object you want to iterate over
* @return {Array}
*/
function getArrayOfProperties (propertyName, obj = {}) {
if (!propertyName) return;
const properties = [];
Object.keys(obj).forEach(child => {
if (properties.indexOf(obj[child][propertyName]) <= -1) {
properties.push(obj[child][propertyName]);
}
});
return properties;
}

我没有接触太多代码本身。只是通过重命名了函数并且添加了一个简短的描述性注释块,这样就提升了代码的可读性。

拥有一个有组织的提交工作流程

重构本身是件巨大的工作。为了可以回滚更改(实际上你尝尝写错了但是后来才知道),我建议提交你的每一次修改。重写了一个方法? git commit (或者 svn commit ,如果你用的是 SVN)。重命名一个名字,文件名或者一些图片? git commit 。你可能已经懂了,我强烈建议一些人使用,它确实能帮你做整理和组织化代码。

在一个新分支上重构,不要在主干上直接修改。你可能需要快速修改主干并提交 bug fixes 到正式环境,但是你又不想你的重构代码没有测试或完成就上线。所以建议在另一个分支上开始。

假如你需要快速了解 git 的工作原理。这里有一个 GitHub 上的版本控制的 介绍 。

怎样不会疯掉

除了保持代码整洁的技术步骤之外,还有重要的一步——我很少看到别的地方有提到(就是负面情绪):不要被之前的开发者逼疯。当然,这并不是说每个人,但是我知道有些人经历过。我花了数年时间才真正明白并克服它。我曾经几乎被我前面的开发者逼疯了,完全不明白他的代码、解决方案以及为什么一切都是这么乱套。

最后,这些负面情绪没有改变什么。没有帮我重构代码,反而浪费了我的时间,让我的代码出错。这会让你越来越郁闷。因为你可能花掉几个小时去重构一个功能,并且没有人会感谢你重写了一个已经存在的模块。这不值得。做需要的事情,然后分析处境。你可能经常需要重构一些模块里面很小的部分。

代码为什么是这样写总是有原因的。可能之前的程序员并没有时间去思考正确的方法,或者因为其他原因。我们自己也是

评论