Skip to main content

[JS] 最佳实践之可维护性

摘记<Javascript高级程序设计> -- 马特 2020版 第28章 28-1

可维护性

没什么可解释的 , 懂得都懂

什么是可维护的代码

  • 容易理解:无须求助原始开发者,任何人一看代码就知道它是干什么的,以及它是怎么实现的。
  • 符合常识:代码中的一切都显得顺理成章,无论操作有多么复杂。
  • 容易适配:即使数据发生变化也不用完全重写。
  • 容易扩展:代码架构经过认真设计,支持未来扩展核心功能。
  • 容易调试:出问题时,代码可以给出明确的信息,通过它能直接定位问题。

能够写出可维护的 JavaScript 代码是一项重要的专业技能。这就是业余爱好者和专业开发人员之间的区别,前者用一个周末就拼凑出一个网站,而后者真正了解自己的技术

编码规范

1. 可读性

  • 代码缩进
  • 注释

2. 变量和函数命名

  • 变量名应该是名词,例如 car 或 person
  • 函数名应该以动词开始,例如 getName()
  • 返回布尔值的函数通常以 is 开头,比如 isEnabled()
  • 对变量和函数都使用符合逻辑的名称,不用担心长度。长名字的问题可以通过后处理和压缩解决
  • 变量、函数和方法应该以小写字母开头,使用驼峰大小写(camelCase)形式
  • 通过适当命名,代码读起来就会像故事,因此更容易理解。

3. 变量类型透明化

因为 JavaScript 是松散类型的语言,所以很容易忘记变量包含的数据类型。适当命名可以在某种程度上解决这个问题,但还不够。有三种方式可以标明变量的数据类型。

  • 第一种标明变量类型的方式是通过初始化。
// 通过初始化标明变量类型
let found = false; // 布尔值
let count = -1; // 数值
let name = ""; // 字符串
let person = null; // 对象
  • 第二种标明变量类型的方式是使用匈牙利表示法。

匈牙利表示法指的是在变量名前面前缀一个或多个字符表示数据类型。这种表示法曾在脚本语言中非常流行,很长时间以来也是 JavaScript 首选的格式。对于基本数据类型,JavaScript 传统的匈牙利表示法用 o 表示对象,s 表示字符串,i 表示整数,f 表示浮点数,b 表示布尔值。示例如下:

// 使用匈牙利表示法标明数据类型
let bFound; // 布尔值
let iCount; // 整数
let sName; // 字符串
let oPerson; // 对象
  • 第三种标明变量类型的方式是使用类型注释。

类型注释放在变量名后面、初始化表达式的前面。基本思路是在变量旁边使用注释说明类型,比如:

// 使用类型注释表明数据类型
let found /*:Boolean*/ = false;
let count /*:int*/ = 10;
let name /*:String*/ = "Nicholas";
let person /*:Object*/ = null;

松散耦合

只要应用程序的某个部分对另一个部分依赖得过于紧密,代码就会变成紧密耦合,因而难以维护。典型的问题是在一个对象中直接引用另一个对象,这样,修改其中一个,可能必须还得修改另一个。紧密耦合的软件难于维护,肯定需要频繁地重写。

考虑到相关的技术,Web 应用程序在某些情况下可能变得过于紧密耦合。关键在于有这个意识,随时注意不要让代码产生紧密耦合。

1. 解耦 HTML/JavaScript

Web 开发中最常见的耦合是 HTML/JavaScript 耦合。在网页中,HTML 和 JavaScript 分别代表不同层面的解决方案。HTML 是数据,JavaScript 是行为。这是因为它们之间要交互操作,需要通过不同的方式将这两种技术联系起来。可惜的是,其中一些方式会导致 HTML 与 JavaScript 紧密耦合。

JavaScript 直接嵌入在 HTML 中,要么使用包含嵌入代码的<script>元素,要么使用 HTML 属性添加事件处理程序,这些都会造成紧密耦合。比如下面的例子:

<!-- 使用<script>造成 HTML/JavaScript 紧密耦合 -->
<script>
document.write("Hello world!");
</script>
<!-- 使用事件处理程序属性造成 HTML/JavaScript 紧密耦合 -->
<input type="button" value="Click Me" onclick="doSomething()"/>

虽然技术上这样做没有问题,但实践中,这样会将表示数据的 HTML 与定义行为的 JavaScript 紧密耦合在一起。理想情况下,HTML 和 JavaScript 应该完全分开,通过外部文件引入 JavaScript,然后使用DOM 添加行为。

HTML 与 JavaScript 紧密耦合的情况下,每次分析 JavaScript 的报错都要先确定错误来自 HTML 还是 JavaScript。这样也会引入代码可用性的新错误。在这个例子中,用户可能会在 doSomething()函数可用之前点击按钮,从而导致 JavaScript 报错。因为每次修改按钮的行为都需要既改 HTML 又改JavaScript,而实际上只有后者才是有必要修改的,所以就会降低代码的可维护性。

在相反的情况下,HTML 和 JavaScript 也会变得紧密耦合:把 HTML 包含在 JavaScript 中。这种情况通常发生在把一段 HTML 通过 innerHTML 插入到页面中时,示例如下:

// HTML 紧密耦合到了 JavaScript
function insertMessage(msg) {
let container = document.getElementById("container");
container.innerHTML = `<div class="msg">
<p> class="post">${msg}</p>
<p><em>Latest message above.</em></p>
</div>`;
}

一般来说,应该避免在 JavaScript 中创建大量 HTML。同样,这主要是为了做到数据层和行为层各司其职,在出错时更容易定位问题所在。使用上面的示例代码时,如果动态插入的 HTML 格式不对,就会造成页面布局出错。不过在这种情况下定位错误就更困难了,因为这时候通常首先会去找页面中出错的 HTML 源代码,但又找不到,因为它是动态生成的。修改数据或页面的同时还需要修改 JavaScript,这说明两层是紧密耦合的。

HTML 渲染应该尽可能与 JavaScript 分开。在使用 JavaScript 插入数据时,应该尽可能不要插入标记。相应的标记可以包含并隐藏在页面中,在需要的时候 JavaScript 可以直接用它来显示,而不需要动态生成。另一个办法是通过 Ajax 请求获取要显示的 HTML,这样也可以保证同一个渲染层(PHP、JSP、Ruby 等)负责输出标记,而不是把标记嵌在 JavaScript 中。

解耦 HTML 和 JavaScript 可以节省排错时间,因为更容易定位错误来源。同样解耦也有助于保证可维护性。修改行为只涉及 JavaScript,修改标记只涉及要渲染的文件

2. 解耦 CSS/JavaScript

Web应用程序的另一层是 CSS,主要负责页面显示。JavaScript和CSS紧密相关,它们都建构在HTML之上,因此也经常一起使用。与 HTML 和 JavaScript 的情况类似,CSS 也可能与 JavaScript 产生紧密耦合。最常见的例子就是使用 JavaScript 修改个别样式,比如:

// CSS 紧耦合到了 JavaScript
element.style.color = "red";
element.style.backgroundColor = "blue";

因为 CSS 负责页面显示,所以任何样式的问题都应该通过 CSS 文件解决。可是,如果 JavaScript直接修改个别样式(比如颜色),就会增加一个排错时要考虑甚至要修改的因素。结果是 JavaScript 某种程度上承担了页面显示的任务,与 CSS 成了紧密耦合。如果将来有一天要修改样式,那么 CSS 和JavaScript 可能都需要修改。这对负责维护的开发者来说是一个噩梦。层与层的清晰解耦是必需的。

现代 Web 应用程序经常使用 JavaScript 改变样式,因此虽然不太可能完全解耦 CSS 和 JavaScript,但可以让这种耦合变成更松散。这主要可以通过动态修改类名而不是样式来实现,比如:

// CSS 与 JavaScript 松散耦合
element.className = "edit";

通过修改元素的 CSS 类名,可以把大部分样式限制在 CSS 文件里。JavaScript 只负责修改应用样式的类名,而不直接影响元素的样式。只要应用的类名没错,那么显示的问题就只跟 CSS 有关,而跟JavaScript 无关。

同样,保证层与层之间的适当分离至关重要。显示出问题就应该只到 CSS 中解决,行为出问题就应该只找 JavaScript 的问题。这些层之间的松散耦合可以提升整个应用程序的可维护性

3. 解耦应用程序逻辑/事件处理程序

每个 Web 应用程序中都会有大量事件处理程序在监听各种事件。可是,其中很少能真正做到应用程序逻辑与事件处理程序分离。来看下面的例子:

function handleKeyPress(event) {
if (event.keyCode == 13) {
let target = event.target;
let value = 5 * parseInt(target.value);
if (value > 10) {
document.getElementById("error-msg").style.display = "block";
}
}
}

这个事件处理程序除了处理事件,还包含了应用程序逻辑。这样做的问题是双重的。首先,除了事件没有办法触发应用程序逻辑,结果造成调试困难。如果没有产生预期的结果怎么办?是因为没有调用事件处理程序,还是因为应用程序逻辑有错误?其次,如果后续事件也会对应相同的应用程序逻辑,则会导致代码重复,或者把它提取到单独的函数中。无论情况如何,都会导致原本不必要的多余工作。

更好的做法是将应用程序逻辑与事件处理程序分开,各自负责处理各自的事情。事件处理程序应该专注于 event 对象的相关信息,然后把这些信息传给处理应用程序逻辑的某些方法。例如,前面的例子可以重写为如下代码:

/*
事件处理程序

检测用户是否按下回车, 将目标值传给另一个函数
*/
function handleKeyPress(event) {
if (event.keyCode == 13) {
let target = event.target;
validateValue(target.value);
}
}

/*
该函数只包含应用程序逻辑
负责接收一个值并根据该值执行其他所有操作
*/
function validateValue(value) {
value = 5 * parseInt(value);
if (value > 10) {
document.getElementById("error-msg").style.display = "block";
}
}

这样修改之后,应用程序逻辑跟事件处理程序就分开了。handleKeyPress()函数只负责检查用户是不是按下了回车键(event.keyCode 等于 13),如果是则取得事件目标,并把目标值传给validateValue()函数,该函数包含应用程序逻辑。注意,validateValue()函数中不包含任何依赖事件处理程序的代码。这个函数只负责接收一个值,并根据该值执行其他所有操作。

把应用程序逻辑从事件处理程序中分离出来有很多好处。首先,这可以让我们以最少的工作量轻松地修改触发某些流程的事件。如果原来是通过鼠标单击触发流程,而现在又想增加键盘操作来触发,那么修改起来也很简单。其次,可以在不用添加事件的情况下测试代码,这样创建单元测试或自动化应用程序流都会更简单。

以下是在解耦应用程序逻辑和业务逻辑时应该注意的几点。

  • 不要把 event 对象传给其他方法,而是只传递 event 对象中必要的数据。
  • 应用程序中每个可能的操作都应该无须事件处理程序就可以执行。
  • 事件处理程序应该处理事件,而把后续处理交给应用程序逻辑。

做到上述几点能够给任何代码的可维护性带来巨大的提升,同时也能为将来的测试和开发提供很多可能性。

编码惯例

编写可维护的 JavaScript 不仅仅涉及代码格式和规范,也涉及代码做什么。企业开发 Web 应用程序通常需要很多人协同工作。这时候就需要保证每个人的浏览器环境都有恒定不变的规则。为此,开发者应该遵守某些编码惯例。

1. 尊重对象所有权

JavaScript 的动态特性意味着几乎可以在任何时候修改任何东西。过去有人说,JavaScript 中没有什么是神圣不可侵犯的,因为不能把任何东西标记为最终结果或者恒定不变。但 ECMAScript 5 引入防篡改对象之后,情况不同了。当然,对象默认还是可以修改的。在其他语言中,在没有源代码的情况下,对象和类不可修改。JavaScript 则允许在任何时候修改任何对象,因此就可能导致意外地覆盖默认行为。因为这门语言没有什么限制,所以就需要开发者自己限制自己。

  • 不要给实例或原型添加属性。
  • 不要给实例或原型添加方法。
  • 不要重定义已有的方法。

以上规则不仅适用于自定义类型和对象,而且适用于原生类型和对象,比如 Object、Stringdocument、window,等等。考虑到浏览器厂商也有可能会在不公开的情况下以非预期方式修改这些对象,潜在的风险就更大了。

有个流行的 Prototype 库就发生过类似的事件。该库在 document 对象上实现了 getElementsByClassName()方法,返回一个 Array 的实例,而这个实例上还增加了 each()方法。jQuery 的作者 JohnResig 后来在自己的博客上分析了这个问题造成的影响。他在博客中指出这个问题是由于浏览器也原生实现了相同的 getElementsByClassName()方法造成的,但 Prototype 的同名方法返回的是 Array 而非 NodeList,NodeList 没有 each()方法。使用这个库的开发者之前会写这样的代码:

document.getElementsByClassName("selected").each(Element.hide); 

虽然这样写在没有原生实现 getElementsByClassName()方法的浏览器里没有问题,但在实现它的浏览器里就会出问题。这是因为两个同名方法返回的结果不一样。我们不能预见浏览器厂商将来会怎么修改原生对象,因此不管怎么修改它们都可能在将来某个时刻出现冲突时导致问题

为此,最好的方法是永远不要修改不属于你的对象。只有你自己创建的才是你的对象,包括自定义类型和对象字面量。Array、document 等对象都不是你的,因为在你的代码执行之前它们已经存在了。

2. 不声明全局变量

与尊重对象所有权密切相关的是尽可能不声明全局变量和函数。同样,这也关系到创建一致和可维护的脚本运行环境。最多可以创建一个全局变量,作为其他对象和函数的命名空间。来看下面的例子:

// 两个全局变量:不要!
var name = "Nicholas";
function sayName() {
console.log(name);
}

以上代码声明了两个全局变量:name 和 sayName()。可以像下面这样把它们包含在一个对象中:

// 一个全局变量:推荐
var MyApplication = {
name: "Nicholas",
sayName: function() {
console.log(this.name);
}
};

这个重写后的版本只声明了一个全局对象 MyApplication。该对象包含了 name 和 sayName()。这样可以避免之前版本的几个问题。首先,变量 name 会覆盖 window.name 属性,而这可能会影响其他功能。其次,有助于分清功能都集中在哪里。调用 MyApplication.sayName()从逻辑上会暗示,出现任何问题都可以在 MyApplication 的代码中找原因。

这样一个全局对象可以扩展为命名空间的概念。命名空间涉及创建一个对象,然后通过这个对象来暴露能力。比如,Google Closure 库就利用了这样的命名空间来组织其代码。下面是几个例子。

  • goog.string:用于操作字符串的方法。
  • goog.html.utils:与 HTML 相关的方法。
  • goog.i18n:与国际化(i18n)相关的方法。

对象 goog 就相当于一个容器,其他对象包含在这里面。只要使用对象以这种方式来组织功能,就可以称该对象为命名空间。整个 Google Closure 库都构建在这个概念之上,能够在同一个页面上与其他JavaScript 库共存。

关于命名空间,最重要的确定一个所有人都同意的全局对象名称。这个名称要足够独特,不可能与其他人的冲突。大多数情况下,可以使用开发者所在的公司名,例如 goog 或 Wrox。下面的例子演示了使用 Wrox 作为命名空间来组织功能:

// 创建全局对象
var Wrox = {};
// 为本书(Professional JavaScript)创建命名空间
Wrox.ProJS = {};
// 添加本书用到的其他对象
Wrox.ProJS.EventUtil = { ... };
Wrox.ProJS.CookieUtil = { ... };

3. 不要比较 null

JavaScript 不会自动做任何类型检查,因此就需要开发者担起这个责任。结果,很多 JavaScript 代码不会做类型检查。最常见的类型检查是看值是不是 null。然而,与 null 进行比较的代码太多了,其中很多因为类型检查不够而频繁引发错误。比如下面的例子:

function sortArray(values) {
if (values != null) { // 不要这样比较!
values.sort(comparator);
}
}

这个函数的目的是使用给定的比较函数对数组进行排序。为保证函数正常执行,values 参数必须是数组。但是,if 语句在这里只简单地检查了这个值不是 null。实际上,字符串、数值还有其他很多值可以通过这里的检查,结果就会导致错误。

现实当中,单纯比较 null 通常是不够的。检查值的类型就要真的检查类型,而不是检查它不能是什么。例如,在前面的代码中,values 参数应该是数组。为此,应该检查它到底是不是数组,而不是检查它不是 null。可以像下面这样重写那个函数:

function sortArray(values) {
if (values instanceof Array) { // 推荐
values.sort(comparator);
}
}

此函数的这个版本可以过滤所有无效的值,根本不需要使用 null。如果看到比较 null 的代码,可以使用下列某种技术替换它。

  • 如果值应该是引用类型,则使用 instanceof 操作符检查其构造函数。
  • 如果值应该是原始类型,则使用 typeof 检查其类型。
  • 如果希望值是有特定方法名的对象,则使用 typeof 操作符确保对象上存在给定名字的方法。

代码中比较 null 的地方越少,就越容易明确类型检查的目的,从而消除不必要的错误。

4. 使用常量

依赖常量的目标是从应用程序逻辑中分离数据,以便修改数据时不会引发错误。显示在用户界面上的字符串就应该以这种方式提取出来,可以方便实现国际化。URL 也应该这样提取出来,因为随着应用程序越来越复杂,URL 极有可能变化。基本上,像这种地方将来因为某种原因而需要修改时,可能就要找到某个函数并修改其中的代码。每次像这样修改应用程序逻辑,都可能引入新错误。为此,可以把这些可能会修改的数据提取出来,放在单独定义的常量中,以实现数据与逻辑分离。

关键在于把数据从使用它们的逻辑中分离出来。可以使用以下标准检查哪些数据需要提取。

  • 重复出现的值:任何使用超过一次的值都应该提取到常量中,这样可以消除一个值改了而另一 个值没改造成的错误。这里也包括 CSS 的类名。

  • 用户界面字符串:任何会显示给用户的字符串都应该提取出来,以方便实现国际化。

  • URL:Web 应用程序中资源的地址经常会发生变化,因此建议把所有 URL 集中放在一个地方 管理。

  • 任何可能变化的值:任何时候,只要在代码中使用字面值,就问问自己这个值将来是否可能会 变。如果答案是“是”,那么就应该把它提取到常量中。