编写可维护的JS

3,632 阅读1小时+

0. 写在前面

当你开始工作时,你不是在给你自己写代码,而是为后来人写代码。 —— Nichloas C. Zakas

本文主要是《编写可维护的JS》的读书笔记,会结合我自己的工作经验,谈一谈如何写出易于维护的JS。作者写这本书的时候(大概2012-2013年)ES6还没出来,考虑到当前MV*时代下,大家几乎都在写ES6,所以本文会针对ES6作特别说明(原书内容针对ES5)。原书作者结合自己的工作经验(2006年开始在雅虎为期5年的工作)写出了这本书,作者在书中浓墨重彩强调的东西,我们现在看来都稀疏平常(如:为什么需要禁用witheval,为什么始终使用===!==进行比较),在这些内容上我会一笔带过,假定你已经熟知这些基本常识了。

我们知道JS语言有着先天的设计缺陷(ES6之后才好转了不少),如何不刻意学习如何编写优质易维护的代码,你很容易就写出糟糕的代码(虽然它可以运行)。

关于代码的维护,你需要明白以下四点:

  • 软件生命周期中80%的成本消耗在了维护上。
  • 几乎所有的软件维护者都不是它的最初作者。
  • 编码规范提高了软件的可读性,它让工程师能够快速且充分地理解新的代码。
  • 如果你将源码作为产品来发布,你需要确保它是可完整打包的,且像你创建的其他产品一样整洁。

对的,你写的代码很大概率上,并不是由你来维护的。因为你可能换公司了,可能去做新项目了,也可能你压根就不记得这段代码是你六个月前写的。所以,不要抱着“我就是来搬砖的,随便写写,改不动了就溜了”的态度来写代码,相信读者你也维护过别人写的代码,吐槽过那难以理解没有任何注释的代码,恨不得把写那代码的人拉过来打一顿。所以,请不要成为你所讨厌的人。编写出可维护的代码,既是职业素养的问题,也是你专业精神的体现。

关于如何编写可维护的JS,我将从 编程风格编程实践工程化 三个方面进行阐述。

1. 编程风格

程序是写给人读的,只是偶尔让计算机执行一下。 —— Donald Knuth

我们会经常碰到这两个术语:“编程风格”(style guideline)和“编码规范”(code convention)。编程风格是编码规范的一种,用来规约单文件中代码的规划。编码规范还包括编程最佳实践、文件和目录的规划以及注释等方面。本文集中讨论JS的编码规范。

为什么要讨论编程风格?每个人都有自己偏爱的编程风格,但更多的时候我们是作为团队一员进行协作开发的,统一风格十分重要,因为它会促成团队成员高水准的协作(所有的代码看起来极为类似)。毫无疑问,全球性的大公司都对外或者对内发布过编程风格文档,如:Airbnb JavaScript Style Guide, Google JavaScript Style Guide等,你若仔细阅读会发现它们很多规范都是相同的,只是部分细节略有差异。

在某些场景中,很难说哪种编程风格好,哪种编程风格不好,因为有些编程风格只是某些人的偏好。本文并不是向你灌输我个人的风格偏好,而是提炼出了编程风格应当遵循的重要的通用规则。

1.1 格式化

关于缩进层次: 我不想挑起“Tab or Space”和“2 or 4 or 6 or 8 Space”的辩论,对这个话题是可以争论上好几个小时的,缩进甚至关系到程序员的价值观。你只要记住以下三点:

  1. 代码一定要缩进,保持对其。
  2. 不要在同一个项目中混用Tab和Space。
  3. 保持与团队风格的统一。

关于结尾分号: 有赖于分析器的自动分号插入(Automatic Semicolon Insertion, ASI)机制,JS代码省略分号也是可以正常工作的。ASI会自动寻找代码中应当使用分号但实际没有分号的位置,并插入分号。大多数场景下ASI都会正确插入分号,不会产生错误,但ASI的分号插入规则非常复杂且很难记住,因此我推荐不要省略分号。大部分的风格指南(除了JavaScript Standard Style)都推荐不要省略分号。

关于行的长度: 大部分的语言以及JS编码风格指南都指定一行的长度为80个字符,这个数值来源于很久之前文本编辑器的单行最多字符限制,即编辑器中单行最多只能显示80个字符,超过80个字符的行要么折行,要么被隐藏起来,这些都是我们所不希望的。我也倾向于将行长度限定在80个字符。

关于换行:当一行长度达到了单行最大字符限制时,就需要手动将一行拆成两行。通常我们会在运算符后换行,下一行会增加两个层次的缩进(我个人认为一个缩进也可以,但绝对不能没有缩进)。例如:

callFunc(document, element, window, 'test', 100,
  true);

在这个例子中,逗号是一个运算符,应当作为前一行的行尾。这个换行位置非常重要,因为ASI机制会在某些场景下在行结束的位置插入分号。总是将一个运算符置于行尾,ASI就不会自作主张地插入分号,也就避免了错误的发生。这个规则有一个例外:当给变量赋值时,第二行的位置应当和赋值运算符的位置保持对齐。比如:

var result = something + anotherThing + yetAnotherThing + somethingElse +
             anotherSomethingElse;

这段代码里,变量 anotherSomethingElse 和行首的 something 保持左对齐,确保代码的可读性,并能一眼看清楚折行文本的上下文。

关于空行:在编程规范中,空行是常常被忽略的一个方面。通常来讲,代码看起来应当像一系列可读的段落,而不是一大段揉在一起的连续文本。有时一段代码的语义和另一段代码不相关,这时就应该使用空行将它们分隔,确保语义有关联的代码展现在一起。一般来讲,建议下面这些场景中添加空行:

  • 在方法之间。
  • 在方法中的局部变量和第一条语句之间。
  • 在多行或单行注释之前。
  • 在方法内的逻辑片段之间插入空行,提高可读性。

1.2 命名

命名分变量、常量、函数、构造函数四类:其中变量和函数使用小驼峰命名法(首字母小写),构造函数使用大驼峰命名法(首字母大写),常量使用全大写并用下划线分割单词。

let myAge; // 变量:小驼峰命名
const PAGE_SIZE; // 常量:全大写,用下划线分割单词

function getAge() {} // 普通函数:小驼峰命名
function Person() {} // 构造函数:大驼峰命名

为了区分变量和函数,变量命名应该以名字作为前缀,而函数名前缀应当是动词(构造函数的命名通常是名词)。看如下例子:

let count = 10; // Good
let getCount = 10; // Bad, look like function

function getName() {} // Good
function theName() {} // Bad, look like variable

命名不仅是一门科学,更是一门技术,但通常来讲,命名长度应该尽可能短,并抓住要点。尽量在变量名中体现出值的数据类型。比如,命名countlengthsize表明数据类型是数字,而命名nametitlemessage表明数据类型是字符串。但用单个字符命名的变量诸如ijk通常在循环中使用。使用这些能够体现出数据类型的命名,可以让你的代码容易被别人和自己读懂。

要避免使用没有意义的命名,如:foobartmp。对于函数和方法命名来说,第一个单词应该是动词,这里有一些使用动词常见的约定:

动词 含义
can 函数返回一个布尔值
has 函数返回一个布尔值
is 函数返回一个布尔值
get 函数返回一个非布尔值
set 函数用来保存一个值

1.3 直接量

JS中包含一些类型的原始值:字符串、数字、布尔值、nullundefined。同样也包含对象直接量和数组直接量。这其中,只有布尔值是自解释(self-explanatory)的,其他的类型或多或少都需要思考一下它们如何才能更精确地表示出来。

关于字符串:字符串可以用双引号也可以用单引号,不同的JS规范推荐都不同, 但切记不可在一个项目中混用单引号和双引号。

关于数字:记住两点建议:第一,为了避免歧义,请不要省略小数点之前或之后的数字;第二,大多数开发者对八进制格式并不熟悉,也很少用到,所以最好的做法是在代码中禁止八进制直接量。

// 不推荐的小数写法:没有小数部分
let price = 10.;

// 不推荐的小数写法:没有整数部分
let price = .1;

// 不推荐的写法:八进制写法已经被弃用了
let num = 010;

关于nullnull是一个特殊值,但我们常常误解它,将它和undefined搞混。在下列场景中应当使用null

  • 用来初始化一个变量,这个变量可能赋值为一个对象。
  • 用来和一个已经初始化的变量比较,这个变量可以是也可以不是一个对象。
  • 当函数的参数期望是对象时,用作参数传入。
  • 当函数的返回值期望是对象时,用作返回值传出。

还有下面一些场景不应当使用null

  • 不要使用null来检测是否传入了某个参数。
  • 不要用null来检测一个未初始化的变量。

理解null最好的方式是将它当做对象的占位符(placeholder)。这个规则在所有的主流编程规范中都没有提及,但对于全局可维护性来说至关重要。

关于undefinedundefined是一个特殊值,我们常常将它和null搞混。其中一个让人颇感困惑之处在于null == undefined结果是true。然而,这两个值的用途却各不相同。那些没有被初始化的变量都有一个初始值,即undefined,表示这个变量等待被赋值。比如:

let person; // 不好的写法
console.log(person === undefined); // true

尽管这段代码能正常工作,但我建议避免在代码中使用undefined。这个值常常和返回"undefined"的typeof运算符混淆。事实上,typeof的行为也很让人费解,因为不管是值是undefined的变量还是未声明的变量,typeof运算结果都是"undefined"。比如:

// foo未被声明
let person;
console.log(typeof person); // "undefined"
console.log(typeof foo); // "undefined"

这段代码中,person和foo都会导致typeof返回"undefined",哪怕person和foo在其他场景中的行为有天壤之别(在语句中使用foo会报错,而使用person则不会报错)。

通过禁止使用特殊值undefined,可以有效地确保只在一种情况下typeof才会返回"undefined":当变量为声明时。如果你使用了一个可能(或者可能不会)赋值为一个对象的变量时,则将其赋值为null

// 好的做法
let person = null;
console.log(person === null); // true

将变量初始值赋值为null表明了这个变量的意图,它最终很可能赋值为对象。typeof运算符运算null的类型时返回"object", 这样就可以和undefined区分开了。

关于对象直接量和数组直接量: 请直接使用直接量语法来创建对象和数组,避免使用ObjectArray构造函数来创建对象和数组。

1.4 注释

注释是代码中最常见的组成部分。它们是另一种形式的文档,也是程序员最后才舍得花时间去写的。但是,对于代码的总体可维护性而言,注释是非常重要的一环。JS支持两种注释:单行注释和多行注释。

很多人喜欢在双斜线后敲入一个空格,用来让注释文本有一定的偏移(我非常推荐你这么做)。单行注释有三种使用方法:

  • 独占一行的注释,用来解释下一行代码。这行注释之前总是有一个空行,且缩进层级和下一行代码保持一致。
  • 在代码行的尾部的注释。代码结束到注释之间至少有一个缩进。注释(包括之前的代码部分)不应当超过最大字符数限制,如果超过了,就将这条注释放置于当前代码行的上方。
  • 被注释的大段代码(很多编辑器都可以批量注释掉多行代码)。

单行注释不应当以连续多行注释的形式出现,除非你注释掉一大段代码。只有当需要注释一段很长的文本时才使用多行注释。

虽然多行注释也可以用于注释单行,但是我还是推荐仅在需要使用多行注释的时候,才使用多行注释。多行注释一般用于以下场景:

  • 模块、类、函数开头的注释
  • 需要使用多行注释

我十分推荐你使用Java风格的多行注释,看起来十分美观,而且很多编辑器支持自动生成,见如下示例:

/**
 * Java风格的注释,注意*和注释之间
 * 有一个空格,并且*左边也有一个空格。
 * 你甚至可以加上一些@参数来说明一些东西。
 * 例如:
 *
 * @author 作者
 * @param Object person
 */

何时添加注释是程序员经常争论的一个话题。一个通行的指导原则是, 当代码不够清晰时添加注释,而当代码很明了时不应当添加注释。 基于这个原则,我推荐你在下面几种情况下添加注释:

  • 难以理解的代码: 难以理解的代码通常都应当加注释。根据代码的用途,你可以用单行注释、多行注释,或者混用这两种注释。关键是让其他人更容易读懂这段代码。
  • 可能被误认为错误的代码: 例如这段代码while(el && (el = el.next)) {}。在团队开发中,总是会有一些好心的开发者在编辑代码时发现他人的代码错误,就立即将它修复。有时这段代码并不是错误的源头,所以“修复”这个错误往往会制造其他错误,因此本次修改应当是可追踪的。当你写的代码有可能会被别的开发者认为有错误时,则需要添加注释。
  • 浏览器特性hack: 这个写过前端的都知道,有时候你不得不写一些低效的、不雅的、彻头彻尾的肮脏代码,用来让低版本浏览器正常工作。

1.5 语句和表达式

关于 花括号的对齐方式 ,有两种主要的花括号对齐风格。第一种风格是,将左花括号放置在块语句中第一句代码的末尾,这种风格继承自Java;第二种风格是将左花括号放置于块语句首行的下一行,这种风格是随着C#流行起来的,因为Visual Studio强制使用这种对齐方式。当前并无主流的JS编程规范推荐这种风格,Google JS风格指南明确禁止这种用法,以免导致错误的分号自动插入。我个人也推荐使用第一种花括号对齐格式。

// 第一种花括号对齐风格
if (condition) {

}

// 第二种花括号对齐风格
if (condition)
{

}

关于块语句间隔: 有下面三种风格,大部分的代码规范都推荐使用第二种风格:

// 第一种风格
if(condition){
  doSomething();
}

// 第二种风格
if (condition) {
  doSomething();
}

// 第三种风格
if ( condition ) {
  doSomething();
}

关于switch语句,很多JS代码规范都没有对此做详细的规定,一个是而实际工作中你也会发现使用场景比较少。因为你只有在有很多条件判断的情况下才会用switch(短条件就直接用if语句了),但是熟练的程序员面对很多的判断条件一般都会用对象表查询来解决这个问题。看如下推荐的风格代码:

switch (condition) {
  case 'cond1':
  case 'cond2':
    doCond1();
    break;
  case 'cond3':
    doCond3();
    break;
  default:
    doDefault();
}

推荐你遵循如下的风格:

  1. switch后的条件括号需要前后各一个空格;
  2. case语句需要相对switch语句缩进一个层级;
  3. 允许多个case语句共用一个处理语句;
  4. 如果没有默认执行代码,可以不用加default

关于with:JS引擎和压缩工具无法对有with语句的代码进行优化,因为它们无法猜出代码的正确含义。在严格模式中,with语句是被明确禁止的,如果使用则报语法错误。这表明ECMAScript委员会确信with不应当继续使用。我也强烈推荐避免使用with语句。

关于for循环:for循环有两种,一种是传统的for循环,是JS从C和Java中继承而来,主要用于遍历数组成员;另外一种是for-in循环,用来遍历对象的属性。

针对for循环, 我推荐尽可能避免使用continue,但也没有理由完全禁止使用,它的使用应当根据代码可读性来决定。

for-in循环是用来遍历对象属性的。不用定义任何控制条件,循环将会有条不紊地遍历每个对象属性,并返回属性名而不是值。for-in循环有一个问题,就是它不仅遍历对象的实例属性(instance property),同样还遍历从原型继承来的属性。当遍历自定义对象的属性时,往往会因为意外的结果而终止。出于这个原因,最好使用hasOwnProperty()方法来为for-in循环过滤出实例属性。我也推荐你这么做,除非你确实想要去遍历对象的原型链,这个时候你应该加上注释说明一下。

// 包含对原型链的遍历
for (let prop in obj) {
  console.log(`key: ${prop}; value: ${obj[prop]}`);
}

for (let prop in obj) {
  if (obj.hasOwnProperty(prop)) {
    console.log(`key: ${prop}; value: ${obj[prop]}`);
  }
}

关于for-in循环,还有一点需要注意,即for-in循环是用来遍历对象的。一个常见的错误用法是使用for-in循环来遍历数组成员,它的结果可能不是你想要的(得到的是数组下标),你应该使用ES6的for-of循环来遍历数组。

let arr = ['a', 'b', 'c'];

for (let i in arr) {
  console.log(i); // 0, 1, 2
}

for (let v of arr) {
  console.log(v); // 'a', 'b', 'c'
}

1.6 变量声明

我们知道JS中var声明的变量存在变量提升,对变量提升不熟悉的同学写代码的时候就会产生不可意料的Bug。例如:

function func () {
  var result = 10 + result;
  var value = 10;
  return result; // return NaN
}

// 实际被解释成
function func () {
  var result;
  var value;

  result = 10 + result;
  value = 10;
  return result;
}

在某些场景中,开发者往往会漏掉变量提升,for语句就是其中一个常见的例子(因为ES5之前没有块级作用域):

function func (arr) {
  for (var i = 0, len = arr.length; i < len; i += 1) {}
}

// 实际被解释成
function func (arr) {
  var i, len;
  for (i = 0, len = arr.length; i < len; i += 1) {}
}

变量声明提前意味着:在函数内部任意地方定义变量和在函数顶部定义变量是完全一样的。 因此,一种流行的风格是将你所有变量声明放在函数顶部而不是散落在各个角落。简言之,依照这种风格写出的代码逻辑和JS引擎解析这段代码的习惯是非常相似的。我也建议你总是将局部变量的定义作为函数内第一条语句。

function func (arr) {
  var i, len;
  var value = 10;
  var result = value + 10;

  for (i = 0; len = arr.length; i < len; i += 1) {
    console.log(arr[i]);
  }
}

当然,如果你有机会使用ES6,那我强烈推荐你完全抛弃var,直接用let和const来定义变量。相信我,抛弃var绝对值得的,let和const提供了块级作用域,比var更安全可靠,行为更可预测。

1.7 函数声明与调用

和变量声明一样,函数声明也会被JS引擎提升。因此,在代码中函数的调用可以出现在函数声明之前。但是,我们推荐总是先声明JS函数然后使用函数。此外,函数声明不应当出现在语句块之内。例如,这段代码就不会按照我们的意图来执行:

// 不好的写法
if (condition) {
  function func () {
    alert("Hi!");
  }
} else {
  function func () {
    alert("Yo!");
  }
}

这段代码在不同浏览器中的运行结果也是不尽相同的。不管condition的计算结果如何,大多数浏览器都会自动使用第二个声明。而Firefox则根据condition的计算结果选用合适的函数声明。这种场景是ECMAScript的一个灰色地带,应当尽可能地避免。函数声明应当在条件语句的外部使用。这种模式也是Google的JS风格指南明确禁止的。

一般情况下,对于函数调用写法推荐的风格是,在函数名和左括号之间没有空格。这样做是为了将它和块语句区分开发。

// 好的写法
callFunc(params);

// 不好的写法,看起来像一个块语句
callFunc (params);

// 用来做对比的块语句
while (condition) {}

1.8 立即调用的函数

IIFE(Immediately Invoked Function Expression),意为立即调用的函数表达式,也就是说,声明函数的同时立即调用这个函数。ES6中很少使用了,因为有模块机制,而IIFE最主要的用途就是来模拟模块隔离作用域的。下面有一些推荐的IIFE写法:

// 不好的写法:会让人误以为将一个匿名函数赋值给了这个变量
var value = function () {
  return {
    msg: 'Hi'
  };
}();

// 为了让IIFE能够被一眼看出来,可以将函数用一对圆括号包裹起来
// 好的写法
var value = (function () {
  return {
    msg: 'Hi'
  };
}());

// 好的写法
var value = (function () {
  return {
    msg: 'Hi'
  };
})();

1.9 严格模式

如果你在写ES5代码,推荐总是使用严格模式。不推荐使用全局的严格模式,可能会导致老的代码报错。推荐使用函数级别的严格模式,或者在IIFE中使用严格模式。

1.10 相等

关于JS的强制类型转换机制,我们不得不承认它确实很复杂,很难全部记住(主要是懒)。所以我推荐你,任何情况下,做相等比较请用===!==

1.11 eval

动态执行JS字符串可不是一个好主意,在下面几种情况中,都可以动态执行JS,我建议你应该避免这么做,除非你精通JS,并且知道自己在做什么。

eval("alert('bad')");
const func = new Function("alert bad('bad')");
setTimeout("alert('bad')", 1000);
setInterval("alert('bad')", 1000);

1.12 原始包装类型

JS装箱和拆箱了解下,原始值是没有属性和方法的,当我们调用一个字符串的方法时,JS引擎会自动把原始值装箱成一个对象,然后调用这个对象的方法。但这并不意味着你应该使用原始包装类型来创建对应的原始值,因为开发者的思路常常会在对象和原始值之间跳来跳去,这样会增加出bug的概率,从而使开发者陷入困惑。你也没有理由自己手动创建这些对象。

// 自动装箱
const name = 'Nicholas';
console.log(name.toUpperCase());

// 好的写法
const name = 'Nicholas';
const author = true;
const count = 10;

// 不好的写法
const name = new String('Nicholas');
const author = new String(true);
const count = new Number(10);

1.13 工具

团队开发中,为了保持风格的统一,Lint工具必不可少。因为即使大家都明白要遵守统一的编程风格,但是写代码的时候总是不经意就违背风格指南的规定了(毕竟人是会犯错的)。这里我推荐你使用ESLint工具进行代码的风格检查,你没必要完全重新写配置规则,你可以继承已有的业内优秀的JS编码规范来针对你团队做微调。我这里推荐继承自Airbnb JavaScript Style Guide,当然,你也可以继承官方推荐的配置或者Google的JS编码风格,其实在编码风格上,三者在大部分的规则上是相同的,只是在一部分细节上不一致而已。

当然,如果你实在是太懒了,那了解一下JavaScript Standard Style,它是基于ESLint的一个JS风格检查工具,有自己的一套风格,强制你必须遵守。可配置性没有直接引入ESLint那么强,如果你很懒并且能够接受它推荐的风格,那使用StandardJS倒也无妨。

2. 编程实践

构建软件设计的方法有两种:一种是把软件做得很简单以至于明显找不到缺陷;另一种是把它做得很复杂以至于找不到明显的缺陷。——CAR Hoare,1980年图灵奖获得者

第一部分我们主要讨论的是JS的代码风格规范(style guideline),代码风格规范的目的是在多人协作的场景下使代码具有一致性。关于如何解决一般性的问题的讨论是不包含在风格规范中的,那是编程实践中的内容。

编程实践是另外一类编程规范。代码风格规范只关心代码的呈现,而编程实践则关心编码的结果。你可以将编程实践看作是“秘方”——它们指引开发者以某种方式编写代码,这样做的结果是已知的。如果你使用过一些设计模式比如MVC中的观察者模式,那么你已经对编程实践很熟悉了。设计模式是编程实践的组成部分,专用于解决和软件组织相关的特定问题。

这一部分的编程实践只会涵盖很小的问题。其中一些实践是和设计模式相关的,另外更多的内容只是增强你的代码总体质量的一些简单小技巧。ESLint除了对代码风格进行检查,也包含了一些关于编程实践方面的警告。非常推荐大家在JS开发工作中使用这个工具,来确保不会发生那些看上去不起眼但又难于发现的错误。

2.1 UI层的松耦合

在Web开发中,UI是由三个彼此隔离又相互作用的层定义的。

  • HTML用来定义页面的数据和语义
  • CSS用来给页面添加样式,创建视觉特征
  • JS用来给页面添加行为,使其更具交互性

关于松耦合,容我废话几句。当你能够做到修改一个组件而不需要更改其他的组件时,你就做到了松耦合。对于多人大型系统来说,有很多人参与维护代码,松耦合对于代码可维护性来说至关重要。你绝对希望开发人员在修改某部分代码时不会破坏其他人的代码。当一个大系统的每个组件的内容有了限制,就做到了松耦合。本质上讲,每个组件需要保持足够瘦身来确保松耦合。组件知道的越少,就越有利于形成整个系统。

有一点需要注意:在一起工作的组件无法达到“无耦合”(no coupling)。在所有系统中,组件之间总要共享一些信息来完成各自的工作。这很好理解,我们的目标是确保对一个组件的修改不会经常性地影响其他部分。

如果一个 Web UI是松耦合的,则很容易调试。和文本或结构相关的问题,通过查找HTML即可定位。当发生了样式相关的问题,你知道问题出现在CSS中。最后,对于那些行为相关的问题,你直接去JS中找到问题所在,这种能力是Web界面的可维护性的核心部分。

WebPage时代,我们推崇将HTML/CSS/JS三层分离,例如禁止使用DOM的内联属性来绑定监听器,<button onclick=handler>test</button>这么写会被喷的。但是,WebApp时代下,以React为代表性的MVVM和MVC框架(严格来说,React只是个专注于View层的一个框架),它们都推崇你把HTML、CSS和JS写一块,经常就可以看到内联绑定事件监听器的代码。

你不禁在想,难道我们在走倒退路?

历史有时候会打转,咋一看以为是回去了。实际上是螺旋转了一圈,站在了一个新的起点。——玉伯《Web 研发模式演变》

传统WebPage时代,组件化支持程度不高,语言层面和框架层面上都是如此,想想没有原生不支持模块的JS(ES6之前的时代)和jQuery,所以为了避免增加维护成本,推崇三层分离的最佳实践。随着ES6与前端MV*框架的崛起,整个的前端开发模式都发生了变化。你会发现前端不仅仅是写页面了,写的更多的是WebApp,应用的规模和复杂程度与WebPage时代不可同日而语。

React就是其中极为典型的代表,它提出用JSX来写HTML,直接是将页面结构和页面逻辑写在了一块。这若放在WebPage时代,相信直接被当做反模式的典型教材;但在WebApp时代却为大多数人接受并使用。包括React团队提出的CSS in JS,更是想通过把CSS写在JS中,使得前端开发完全由JS主导,组件化做的更加彻底(CSS in JS我没有做更深的调研和理解,没有实际大型项目的实践经验,所以现在我还是保持观望态度,继续沿用之前的SASS和LESS来做CSS开发)。

尽管两个Web时代的开发模式发生了巨大变化,关于三层的松耦合设计,还是有一些通用原则你需要遵守:

将JS从CSS中抽离。 早期的IE8和更早版本的浏览器中允许在CSS中写JS(不写例子,这是反模式,记不住更好),这会带来性能底下的问题,更可怕的是后期难以维护。不过我相信在座各位估计都接触不到这类代码了,也好。

将CSS从JS中抽离。 不是说不能再JS中修改CSS,是不允许你直接去改样式,而是通过修改类来间接的修改样式。见如下示例:

// 不好的写法
element.style.color = 'red';
element.style.left = '10px';
element.style.top = '100px';
element.style.visibility = 'visible';

// 好的写法
.reveal {
  color: red;
  left: 10px;
  top: 100px;
  visibility: visible;
}

element.classList.add('.reveal');

由于CSS的className可以成为CSS和JS之间通信的桥梁。在页面的生命周期中, JS可以随意添加和删除元素的className。而className所定义的样式则在CSS代码之中。任何时刻,CSS中的样式都是可以修改的而不必更新JS。JS不应当直接操作样式,以便保持和CSS的松耦合。

有一种使用style属性的情形是可以接受的:当你需要给页面中的元素会作定位,使其相对于另外一个元素或整个页面重新定位。这种计算是无法在CSS中完成的,因此这时是可以使用style.topstyle.leftstyle.bottomstyle.rght来对元素作正确定位的。在CSS中定义这个元素的默认属性,而在 Javascript中修改这些默认值。

鉴于现在前端已经将HTML和JS写在一块的现状,我就不谈原书中如何将两者分离的实践了。但是,我说了这么多废话,请记住一点:“可预见性”(Predictability)会带来更快的遇试和开发,并确信(而非猜测)从何入手调试bug,这会让问题解决得更快、代码总体质量更高。

2.2 避免使用全局变量

全局变量带来的问题主要是:随着代码量的增长,过多的全局变量会导致代码难以维护,并且容易出bug。一两个全局变量没什么大问题,你几乎不可能做到零全局变量(除非你的JS代码不与任何其他JS代码产生联系,仅仅做了些自己的事情,这种情况十分少见,不代表没有)。

如果是写ES6代码,你会发现你很难去创建一个全局变量,除非你显式的写window.globalVar = 'something',ES6的模块机制自动帮你做好了作用域分割,使得你写的代码维护性和安全性都变高了(老JSer不得不感慨现代的前端开发者真幸福)。

如果是ES6之前的代码,就得注意点了。比如你在函数中没有用var来声明的变量会直接挂载到全局变量中(这个应该是JS基本知识),所以一般都是通过IIFE来实现模块化,对外只暴露一个全局变量(当然,你也可以使用RequireJS或者YUI模块加载器等三方的模块管理工具来实现模块化)。

window.global = (function () {
  var exportVar = {}; // ES5没有let和const,故用var

  // add method and variable to exportVar

  return exportVar;
})();

2.3 事件处理

我们知道事件触发时,事件对象(event对象)会作为回调参数传入事件处理程序中,举个例子:

// 不好的写法
function handleClick(event) {
  var pop = document.getElementById('popup');
  popup.style.left = event.clientX + 'px';
  popup.style.top = event.clientY + 'px';
  popup.className = 'reveal';
}

// 你应该明白addListener函数的意思
addListener(element, 'click', handleClick);

这段代码只用到了event对象的两个属性:clientX和clientY。在将元素显示在页面里之前先用这两个属性个它作定位。尽管这段代码看起来非常简单且没有什么问题,但实际上是不好的写法,因为这种做法有其局限性。

规则1:隔离应用逻辑

上段实例代码的第一个问题是事件处理程序包含了应用用逻辑(application logic)。应用逻辑是和应用相关的功能性代码,而不是和用户行为相关的。上段实例代码中应用逻辑是在特定位置显示一个弹出框。尽管这个交互应当是在用户点击某个特定元素时发生,但情况并不总是如此。

将应用逻辑从所有事件处理程序中抽离出来的做法是一种最佳实践,因为说不定什么时候其他地方就会触发同一段逻辑。比如,有时你需要在用户将鼠标移到某个元素上时判断是否显示弹出框,或者当按下键盘上的某个键时也作同样的逻辑判断。这样多个事件的处理程序执行了同样的逻辑,而你的代码却被不小心复制了多份。

将应用逻辑放置于事件处理程序中的另一个缺点是和测试有关的。测试时需要直接触发功能代码,而不必通过模拟对元素的点击来触发。如果将应用逻辑放置于事件处理程序中,唯一的测试方法是制造事件的触发。尽管某些测试框架可以模拟触发事件,但实际上这不是测试的最佳方法。调用功能性代码最好的做法就是单个的函数调用。

你总是需要将应用逻辑和事件处理的代码拆分开来。如果要对上一段实例代码进行重构,第一步是将处理弹出框逻辑的代码放入一个单独的函数中,这个函数很可能挂载于为该应用定义的一个全局对象上。事件处理程序应当总是在一个相同的全局对象中,因此就有了以下两个方法。

// 好的写法 - 拆分应用逻辑
var MyApplication = {
  handleClick: function (event) {
    this.showPopup(event);
  },

  showPopup: function (event) {
    var pop = document.getElementById('popup');
    popup.style.left = event.clientX + 'px';
    popup.style.top = event.clientY + 'px';
    popup.className = 'reveal';
  }
};

addListener(element, 'click', function (event) {
  MyApplication.handleClick(event);
});

之前在事件处理程序中包含的所有应用逻辑现在转移到了MyApplication.showPopup()方法中。现在MyApplication.handleClick()方法只做一件事情,即调用MyApplication.showPopup()。若应用逻辑被剥离出去,对同一段功能代码的调用可以在多点发生,则不需要一定依赖于某个特定事件的触发,这显然更加方便。但这只是拆解事件处理程序代码的第一步。

规则2:不要分发事件对象

在剥离出应用逻辑之后,上段实例代码还存在一个问题,即event对象被无节制地分发。它从匿名的事件处理函数传入了MyApplication.handleClick(),然后又传入了MyApplication.showPopup()。正如上文提到的,event对象上包含很多和事件相关的额外信息,而这段代码只用到了其中的两个而已。应用逻辑不应当依赖于event对象来正确完成功能,原因如下:

  • 方法接口并没有表明哪些数据是必要的。好的API一定是对于期望和依赖都是透明的。将event对象作为为参数并不能告诉你event的哪些属性是有用的,用来干什么?
  • 因此,如果你想测试这个方法,你必须重新创建一个 event对象并将它作为参数传入。所以,你需要确切地知道这个方法使用了哪些信息,这样才能正确地写出测试代码。

这些问题(指接口格式不清晰和自行构造event对象来用于测试)在大型Web应用用中都是不可取的。代码不够明晰就会导致bug。

最佳的办法是让事件处理程序使用event对象来处理事件,然后拿到所有需要的数据传给应用逻辑。例如,MyApplication.showPopup()方法只需要两个数据,x坐标和y坐标。这样我们将方法重写一下,让它来接收这两个参数。

// 好的写法
var MyApplication = {
  handleClick: function (event) {
    this.showPopup(event.clientX, event.clientY);
  },

  showPopup: function (x, y) {
    var pop = document.getElementById('popup');
    popup.style.left = x + 'px';
    popup.style.top = y + 'px';
    popup.className = 'reveal';
  }
};

addListener(element, 'click', function (event) {
  MyApplication.handleClick(event);
});

在这段新重写的代码中,MyApplication.handleClick()x坐标和y坐标传入了MyApplication.showPopup(),代替了之前传入的事件对象。可以很清晰地看到MyApplication.showPopup()所期望传入的参数,并且在测试或代码的任意位置都可以很轻易地直接调用这段逻辑,比如:

// 这样调用非常棒
MyApplication.showPopup(10, 10);

当处理事件时,最好让事件处理程序成为接触到event对象的唯一的函数。事件处理程序应当在进入应用逻辑之前针对event对象执行任何必要的操作,包括阻止默认事件或阻止事件冒泡,都应当直接包含在事件处理程序中。比如:

// 好的写法
var MyApplication = {
  handleClick: function (event) {
    // 假设事件支持DOM Level2
    event.preventDefault();
    event.stopPropagation();

    // 传入应用逻辑
    this.showPopup(event.clientX, event.clientY);
  },

  showPopup: function (x, y) {
    var pop = document.getElementById('popup');
    popup.style.left = x + 'px';
    popup.style.top = y + 'px';
    popup.className = 'reveal';
  }
};

addListener(element, 'click', function (event) {
  MyApplication.handleClick(event);
});

在这段代码中,MyApplication.handleClick()是事件处理程序,因此它在将数据传入应用逻辑之前调用了event.preventDefault()event.stopPropagation(),这清除地展示了事件处理程序和应用逻辑之间的分工。因为应用逻辑不需要对event产生依赖,进而在很多地方都可以轻松地使用相同的业务逻辑,包括写测试代码。

2.4 避免“空比较”

在JS中,我们常常会看到这种代码:变量与null的比较(这种用法很有问题),用来判断变量是否被赋予了一个合理的值。比如:

var Controller = {
  process: function(items) {
    if (items !== null) {
      items.sort();
      items.forEach(function(item){});
    }
  }
};

在这段代码中,process()方法显然希望items是一个数组,因为我们看到items拥有sort()forEach()。这段代码的意图非常明显:如果参数items不是一个数组,则停止接下来的操作。这种写法的问题在于,和null的比较并不能真正避免错误的发生。items的值可以是1,也可以是字符串,甚至可以是任意对象。这些值都和null不相等,进而会导致process()方法一旦执行到sort()时就会出错。

仅仅和null比较并不能提供足够的信息来判断后续代码的执行是否真的安全。好在JS为我们提供了多种方法来检测变量的真实值。

2.4.1 检测原始值

在JS中有5种原始类型:字符串、数字、布尔值、nullundefined。如果你希望一个值是字符串、数字、布尔值或者undefined,最佳选择是使用typeof运算符。typeof运算符会返回一个表示值的类型的字符串。

  • 对于字符串,typeof返回"string"
  • 对于数字,typeof返回"number"
  • 对于布尔值,typeof返回"boolean"
  • 对于undefinedtypeof返回"undefined"

对于typeof的用法,如下:

// 推荐使用,这种用法让`typeof`看起来像运算符
typeof variable

// 不推荐使用,因为它让`typeof`看起来像函数调用
typeof(variable)

使用typeof来检测上面四种原始值类型是非常安全的做法。

typeof运算符的独特之处在于,将其用于一个未声明的变量也不会报错。未定义的变量和值为undefined的变量通过typeof都将返回"undefined"

最后一个原始值,null,一般不应用于检测语句。正如上文提到的,简单地和null比较通常不会包含足够的信息以判断值的类型是否合法。但有一个例外,如果所期望的值真的是null,则可以直接和null进行比较。这时应当使用===或者!==来和null进行比较,比如:

// 如果你需要检测null,则使用这种方法
var element = document.getElementById('my-div');
if (element !== null) {
  element.className = 'found';
}

如果DOM元素不存在,则通过document.getElementById()得到的值为null。这个方法要么返回一个节点,要么返回null。由于这时null是可预见的一种输出,则可以使用!==来检测返回结果。

运行typeof null则返回"object",这是一种低效的判断null的方法。如果你需要检测null,则直接使用恒等运算符(===)或非恒等运算符(!==)。

2.4.2 检测引用值

引用值也称作对象(object)。在JS中除了原始值之外的值都是引用。有这样几种内置的引用类型:ObjectArrayDateError,数量不多。typeof运算符在判断这些引用类型时显得力不从心,因为所有对象都会返回"object"

typeof另外一种不推荐的用法是当检测null的类型时,typeof运算符用于null时将全返回"object"。这看上去很怪异,被认为是标准规范的严重bug,因此在编程时要杜绝使用typeof来检测null的类型。

检测某个引用值的类型的最好方法是使用instanceof运算符。instanceof的基本语法是:value instanceof constructor

instanceof的一个有意思的特性是它不仅检测构造这个对象的构造器,还检测原型链。原型链包含了很多信息,包括定义对象所采用的继承模式。比如,默认情况下,每个对象都继承自Object,因此每个对象的value instanceof Object都会返回true。因为这个原因,使用value instanceof Object来判断对象是否属于某个特定类型的做法并非最佳。

instanceof运算符也可以检测自定义的类型,比如:

function Person (name) {
  this.name = name;
}

var me = new Person('Nicholas');
console.log(me instanceof Object); // true
console.log(me instanceof Person); // true

在JS中检测自定义类型时,最好的做法就是使用instanceof运算符,这也是唯一的方法。同样对于内置JS类型也是如此(使用instanceof运算符)。但是,有一个严重的限制。

假设一个浏览器帧(frameA)里的一个对象被传入到另一个帧(frameB)中。两个帧里都定义了构造函数Person。如果来自帧A的对象是帧A的Person的实例,则如下规则成立。

frameAPersonInstance instanceof frameAPerson; // true
frameAPersonInstance instanceof frameBPerson; // false

因为每个帧(frame)都拥有Person的一份拷贝,它被认为是该帧(frame)中的Person的拷贝实例,尽管两个定义可能完全一样的。这个问题不仅出现在自定义类型身上,其他两个非常重要的内置类型也有这个问题:函数和数组。对于这两个类型来说,一般用不着使用instanceof

2.4.3 检测函数

从技术上讲,JS中的函数是引用类型,同样存在Function构造函数,每个函数都是其实例,比如:

function myFunc () {}

// 不好的写法
console.log(myFunc instanceof Function); // true

// 好的写法
console.log(typeof myFunc === 'function'); // true

然而,这个方法亦不能跨帧(frame)使用,因为每个帧都有各自的Function构造函数。好在typeof运算符也是可以用于函数的,返回"function"检测函数最好的方法是使用typeof,因为它可以跨帧(frame)使用。

typeof来检测函数有一个限制。在IE8和更早版本的IE浏览器中,使用typeof来检测DOM节点(比如document.getElementById())中的函数都返回"object"而不是"function"。比如:

// IE 8及其更早版本的IE
console.log(typeof document.getElementById); // "object"
console.log(typeof document.createElement); // "object"
console.log(typeof document.getElementByTagName); // "object"

之所以出现这种怪异的现象是因为浏览器对DOM的实现由差异。简言之,这些早版本的IE并没有将DOM实现为内置的JS方法,导致内置typeof运算符将这些函数识别为对象。因为DOM是有明确定义的,了解到对象成员如果存在则意味着它是一个方法,开发者往往通过in运算符来检测DOM的方法,比如:

// 检测DOM方法
if ("querySelectorAll" in document) {
  images = document.querySelectorAll("img");
}

这段代码检查querySelectorAll是否定义在了document中,如果是,则使用这个方法。尽管不是最理想的方法,如果想在IE8及更早浏览器中检测DOM方法是否存在,这是最安全的做法。在其他所有的情形中,typeof运算符是检测JS函数的最佳选择。

2.4.4 检测数组

JS中最古老的跨域问题之一就是在帧(frame)之间来回传递数组。开发者很快发现instanceof Array在此场景中不总是返回正确的结果。正如上文提到的,每个帧(frame)都有各自的Array构造函数,因此一个帧(frame)中的实例在另外一个帧里不会被识别。Douglas Crockford首先推荐使用“鸭式辨型”接口(duck typing)(“鸭式辨型”是由作家James Whitcomb Riley首先提出的概念,即“像鸭子一样走路、游泳并且嘎嘎叫的鸟就是鸭子”,本质上是关注“对象能做什么”,而不要关注“对象是什么”,更多内容请参照《JS权威指南》(第六版)9.5,4小节)来检测其sort()方法是否存在。

// 采用鸭式辨型的方法检测数组
function isArray(value) {
  return typeof value.sort === "function";
}

这种检测方法依赖一个事实,即数组是唯一包含sort()方法的对象。当然,如果传入isArray()的参数是一个包含sort()方法的对象,它也会返回true

关于如何在JS中检测数组类型已经有很多研究了,最终,Juriy Zaytsev(也被称作Kangax)给出了一种优雅的解决方案。

function isArray(value) {
  return Object.prototype.toString.call(value) === "[object Array]";
}

Kangax发现调用某个值的内置toString()方法在所有浏览器中都会返回标准的字符串结果。对于数组来说,返回的字符串为"[object Array]",也不用考虑数组实例是在哪个帧(frame)中被构造出来的。Kangax给出的解决方案很快流行起来,并被大多数JS类库所采纳。

这种方法在识别内置对象时往往十分有用,但对于自定义对象请不要用这种方法。比如,内置JSON对象使用这种方法将返回"[object JSON]"

从那时起, ECMAScript5将Array.isArray()正式引入JS。唯一的目的就是准确地检测一个值是否为数组。同Kangax的函数一样, Array.isArray()也可以检测跨帧(frame)传递的值,因此很多JS类库目前都类似地实现了这个方法。

2.4.5 检测属性

另外一种用到null(以及undefined)的场景是当检测一个属性是否在对象中存在时,比如:

// 不好的写法:检测假值
if (object[propertyName]) {}

// 不好的写法:和null相比较
if (object[propertyName] != null) {}

// 不好的写法:和undefined比较
if (object[propertyName] != undefined) {}

上面这段代码里的每个判断,实际上是通过给定的名字来检査属性的值,而非判断给定的名字所指的属性是否存在,因为当属性值为假值(falsy value)时结果会出错,比如0、""(空字符串)、 false、null和undefined。毕竟,这些都是属性的合法值。比如,如果属性记录了一个数字,则这个值可以是零。这样的话,上段代码中的第一个判断就会导致错误。以此类推,如果属性值为null或者undefined时,三个判断都会导致错误。

判断属性是否存在的最好的方法是使用in运算符。in运算符仅仅会简单地判断属性是否存在,而不会去读属性的值,这样就可以避免出现本小节中前文提到的有歧义的语句。 如果实例对象的属性存在、或者继承自对象的原型,in运算符都会返回true。比如:

var object = {
  count: 0,
  related: null
};

// 好的写法
if ("count" in object) {
  // 这里的代码会执行
}

// 不好的写法:检测假值
if (object["count"]) {
  // 这里的代码不会执行
}

// 好的写法
if ("related" in object) {
  // 这里的代码会执行
}

// 好的写法
if (object["related"] != null) {
  // 这里的代码不会执行
}

如果你只想检查实例对象的某个属性是否存在,则使用hasOwnProperty()方法。所有继承自Object的JS对象都有这个方法,如果实例中存在这个属性则返回true(如果这个属属性只存在于原型里,则返回false)。需要注意的是,在IE8以及更早版本的IE中,DOM对象并非继承自Object,因此也不包含这个方法。也就是说,你在调用DOM对象的 hasOwnProperty()方法之前应当先检测其是否存在(假如你已经知道对象不是DOM,则可以省略这一步)。

// 对于所有非DOM对象来说,这是好的写法
if (object.hasOwnProperty("related")) {
  // 执行这里的代码
}

// 如果你不确定是否为DOM对象,则这样来写
if ("hasOwnProperty" in object && object.hasOwnProperty("related")) {
  // 执行这里的代码
}

因为存在IE8以及更早版本IE的情形,在判断实例对象的属性是否存在时,我更倾向于使用in运算符,只有在需要判断实例属性时才会用到hasOwnProperty()。不管你什么时候需要检测属性的存在性,请使用in运算符或者hasOwnProperty()。这样做可以避免很多bug。

2.5 将配置数据从代码中分离出来

代码无非是定义一些指令的集合让计算机来执行。我们]常常将数据传入计算机,由指令对数据进行操作,并最终产生一个结果。当不得不修改数据时问题就来了。任何时候你修改源代码都会有引入bug的风险,且只修改一些数据的值也会带来一些不必要的风险,因为数据是不应当影响指令的正常运行的。 精心设计的应用应当将关键数据从主要的源码中抽离出来,这样我们修改源码时才更加放心。

配置数据时在应用中写死(hardcoded)的值,比如:

  • 魔法数(magic number)
  • URL
  • 需要展现给用户的字符串(可能要做国际化)
  • 重复的值
  • 设置
  • 任何可能发生变更的值

我们时刻要记住,配置数据是可发生变更的,而且你不希望有人突然想修改页面中展示的信息,而导致你去修改JS源码。

对于这些配置数据,你可以把它们抽离成常量、或者挂载到某个对象中、或写成配置文件(JS中推荐JSON),通过程序读取配置文件中的数据,这样即使修改了数据,你的程序代码不会有任何的改动,减少了出错的可能性。

2.6 抛出自定义错误

在JS中抛出错误是一门艺术。摸清楚代码中哪里合适抛出错误是需要时间的。因此,一旦搞清楚了这一点,调试代码的事件将大大缩短,对代码的满意度将急剧提升。

2.6.1 错误的本质

当某些非期望的事情发生时程序就引发一个错误。也许是给一个函数传递了一个不正确的值,或者是数学运算碰到了一个无效的操作数。编程语言定义了一组基本的规则,当偏离了这些规则时将导致错误,然后开发者能修复代码。如果错误没有被抛出或者报告给你的话,调试是非常困难的。如果所有的失败都是悄无声息的,首要的问题是那必将消耗你大量的时间才能发现它,更不要说单独隔离并修复它了。所以,错误是开发者的朋友,而不是敌人

错误常常在非期望的地点、不恬当的时机跳出来,这很麻烦。更糟糕的是,默认的错误消息通常太简洁而无法解释到底什么东西出错了。JS错误消息以信息稀少、隐晦含糊而臭名昭著(特别是在老版本的IE中),这只会让问题更加复杂化。想象一下,如果跳出一个错误能这样描述:“由于发生这些情况,该函数调用失败”。那么,调试任务马上就会变得更加简单,这正是抛出自己的错误的好处。

像内置的失败案例一样来考虑错误是非常有帮助的。在代码某个特殊之处计划一个失败总比要在所有的地方都预期失败简单的多。在产品设计上,这是非常普遍的实践经验,而不仅仅是在代码编写方面。汽车尚有碰撞力吸收区域,这些区域框架的设计旨在撞击发生时以可预测的方式崩塌。知道一个碰撞到来时这些框架将如何反应——特别是,哪些部分将失败——制造商将能保证乘客的安全。你的代码也可以用这种方法来创建。

2.6.2 在JS中抛出错误

毫无疑问,在JS中抛出错误要比在任何其他语言中做同样的事情更加有价值,这归咎于Web端调试的复杂性。可以使用throw操作符,将提供的一个对象作为错误抛出。任何类型的对象都可以作为错误抛出,然而,Error对象是最常用的。

throw new Error('Something bad happened.');

内置的Error类型在所有的JS实现中都是有效的,它的构造器只接受一个参数,指代错误消息(message)。当以这种方式抛出错误时,如果没有通过try-catch语句来捕获的话,浏览器通常直接显示该消息(message字符串)。当今大多数浏览器都有一个控制台(console),一旦发生错误都会在这里输出错误信息。换言之,任何你抛出的和没抛出的错误都被以相同的方式来对待。

缺乏经验的开发者有时直接将一个字符串作为错误抛出,如:

// 不好的写法
throw 'message';

这样做确实能够抛出一个错误,但不是所有的浏览器做出的响应都会按照你的预期。Firefox、Opera和Chrome都将显示一条“uncaught exception”消息,同时它们包含上述消息字符串。Safari和IE只是简陋地抛出一个“uncaught exception”错误,完全不提供上述消息字符串,这种方式对调试无益。

显然,如果愿意,你可以抛出任何类型的数据。没有任何规则约束不能是特定的数据类型。

throw { name: 'Nicholas' };
throw true;
throw 12345;
throw new Date();

就一件事情需要牢记,如果没有通过try-catch语句捕获,抛出任何值都将引发一个错误。Firefox、Opera和Chrome都会在该抛出的值上调用String()函数,来完成错误消息的显示逻辑,但Safari和IE不是这样的。针对所有的浏览器,唯一不出差错的显示自定义的错误消息的方式就是用一个Error对象。

2.6.3 抛出错误的好处

抛出自己的错误可以使用确切的文本供浏览器显示。除了行和列的号码,还可以包含任何你需要的有助于调试问题的信息。我推荐总是在错误消息中包含函数名称,以及函数失败的原因。考察下面的函数:

function getDivs (element) {
  return element.getElementsByTagName('div');
}

这个函数旨在获取element元素下所有后代元素中的div元素。传递给函数要操作的DOM元素为null值可能是件很常见的事情,但实际需要的是DOM元素。如果给这个函数传递null会发生什么情况呢?你会看到一个类似“object expected”的含糊的错误消息。然后,你要去看执行栈,再实际定位到源文件中的问题。通过抛出一个错误,调试会更简单:

function getDivs (element) {
  if (element && element.getElementsByTagName) {
    return element.getElementsByTagName('div');
  } else {
    throw new Error('getDivs(): Argument must be a DOM element.');
  }
}

现在给getDivs()函数抛出一个错误,任何时候只要element不满足继续执行的条件,就会抛出一个错误明确地陈述发生的问题。如果在浏览器控制台中输出该错误,你马上能开始调试,并知道最有可能导致该错误的原因是调用函数试图用一个值为null的DOM元素去做进一步的事情。

我倾向于认为抛出错误就像给自己留下告诉自己为什么失败的标签

2.6.4 何时抛出错误

理解了如何抛出错误只是等式的一个部分,另外一部分就是要理解什么时候抛出错误。由于JS没有类型和参数检查,大量的开发者错误地假设他们自己应该实现每个函数的类型检查。这种做法并不实际,并且会对脚本的整体性能造成影响。考察下面的函数,它试图实现充分的类型检查。

// 不好的做法:检查了太多的错误
function addClass (element, className) {
  if (!element || typeof element.className !== 'string') {
    throw new Error('addClass(): First argument must be a DOM element.');
  }
  if (typeof className !== 'string') {
    throw new Error('addClass(): Second argument must be a string.');
  }
  element.className += '' + className;
}

这个函数本来只是简单地给一个给定的元素增加一个CSS类名(className),因此,函数的大部分工作变成了错误检查。纵然它能在每个函数中检查每个参数(模仿静态语言),在JS中这么做也会引起过度的杀伤。辨识代码中哪些部分在特定的情况下最有可能导致失败,并只在那些地方抛出错误才是关键所在。

在上例中,最有可能引发错误的是给函数传递一个null引用值。如果第二个参数是null或者一个数字或者一个布尔值是不会抛出错误的,因为JS会将其强制转换为字符串。那意味着导致DOM元素的显示不符合期望,但这并不至于提高到严重错误的程度。所以,我只会检查DOM元素。

// 好的写法
function addClass (element, className) {
  if (!element || typeof element.className !== 'string') {
    throw new Error('addClass(): First argument must be a DOM element.');
  }
  element.className += '' + className;
}

如果一个函数只被已知的实体调用,错误检查很可能没有必要(这个案例是私有函数);如果不能提前确定函数会被调用的所有地方,你很可能需要一些错误检查。这就更有可能从抛出自己的错误中获益。抛出错误最佳的地方是在工具函数中,如addClass()函数,它是通用脚本环境中的一部分,会在很多地方使用,更准确的案例是JS类库。

针对已知条件引发的错误,所有的JS类库都应该从它们的公共接口里抛出错误。如jQuery、YUI和Dojo等大型的库,不可能预料你在何时何地调用了它们的函数。当你做错事的时候通知你是它们的责任,因为你不可能进入库代码中去调试错误的原因。函数调用栈应该在进入库代码接口时就终止,不应该更深了。没有比看到由一打库代码中函数调用时发生一个错误更加糟糕的事情了吧,库的开发者应该承担起防止类似情况发生的责任。

私有JS库也类似。许多Web应用程序都有自己专用的内置的JS库或“拿来”一些有名的开源类库(类似jQuery)。类库提供了对脏的实现细节的抽象,目的是让开发者用得更爽。抛出错误有助于对开发者安全地隐藏这些脏的实现细节。

这里有一些关于抛出错误很好的经验法则:

  • 一旦修复了一个很难调试的错误,尝试增加一两个自定义错误。当再次发生错误时,这将有助于更容易地解决问题。
  • 如果正在编写代码,思考一下:“我希望[某些事情]不会发生,如果发生,我的代码会一团糟糕”。这时,如果“某些事情”发生,就抛出一个错误。
  • 如果正在编写的代码别人(不知道是谁)也会使用,思考一下他们使用的方式,在特定的情况下抛出错误。

请牢记,我们目的不是防止错误,而是在错误发生时能更加容易地调试。

2.6.5 try-catch语句

应用程序逻辑总是知道调用某个特定函数的原因,因此也是最合适处理错误的。千万不要将try-catch中的catch块留空,你应该总是写点什么来处理错误。例如,不要像下面这样做:

try {
  somethingThatMightCauseAnError();
} catch (ex) {
  // do nothing
}

如果知道可能要发生错误,那肯定知道如何从错误中恢复。确切地说,如何从错误中恢复在开发模式中与实际放到生产环境中是不一样的,这没关系。最重要的是,你实实在在地在处理错误,而不是忽略它。

2.6.6 错误类型

ECMA-262规范指出了7种错误类型。当不同错误条件发生时,这些类型在JS引擎中都有用到,当然我们也可以手动创建它们。

  1. Error: 所有错误的基本类型。实际上引擎从来不会抛出该类型的错误。
  2. EvalError: 通过eval()函数执行代码发生错误时抛出。
  3. RangeError: 一个数字超出它的边界时抛出——例如,试图创建一个长度为-20的数组(new Array(-20);)。该错误在正常的代码执行中非常罕见。
  4. ReferenceError: 期望的对象不存在时抛出——例如,试图在一个null对象引用上调用一个函数。
  5. SyntaxError: 代码有语法错误时抛出。
  6. TypeError: 变量不是期望的类型时抛出。例如,new 10'prop' in true
  7. URIError: 给encodeURI()encodeURIComponent()decodeURI()或者decodeURIComponent()等函数传递格式非法的URI字符串时抛出。

理解错误的不同类型可以帮助我们更容易地处理它。所有的错误类型都继承自Error,所以用instanceof Error检查其类型得不到任何有用的信息。通过检查特定的错误类型可以更可靠地处理错误。

try {
  // 有些代码引发了错误
} catch (ex) {
  if (ex instanceof TypeError) {
    // 处理TypeError错误
  } else if (ex instanceof ReferenceError) {
    // 处理ReferenceError错误
  } else {
    // 其他处理
  }
}

如果抛出自己的错误,并且是数据类型而不是一个错误,你可以非常轻松地区分自己的错误和浏览器的错误类型的不同。但是,抛出实际类型的错误与抛出其他类型的对象相比,有几大优点。

首先,如上讨论,在浏览器正常错误处理机制中会显示错误消息。其次,浏览器给抛出的Error对象附加了一些额外的信息。这些信息不同浏览器各不相同,但它们为错误提供了如行、列号等上下文信息,在有些浏览器中也提供了堆栈和源代码信息。当然,如果用了Error的构造器,你就丧失了区分自己抛出的错误和浏览器错误的能力。

解决方案就是创建自己的错误类型,让它继承自Error。这种做法允许你提供额外的信息,同时可区别于浏览器抛出的错误。可以用如下的模式来创建自定义的错误类型。

function MyError (message) {
  this.message = message;
}
MyError.prototype = new Error();

这段代码有两个重要的部分:message属性,浏览器必须要知道的错误消息字符串;设置prototype为Error的一个实例,这样对JS引擎而言就标识它是一个错误对象了。接下来就可以抛出一个MyError的实例对象,使得浏览器能像处理原生错误一样做出响应。

throw new MyError('Hello World!');

提醒一下,该方法在IE8和更早的浏览器中不显示错误消息。相反,会看见那个通用的“Exception thrown but not caught”消息。这个方法最大的好处是,自定义错误类型可以检测自己的错误。

try {
  // 有些代码引发了错误
} catch (ex) {
  if (ex instanceof MyError) {
    // 处理自己的错误
  } else {
    // 其他处理
  }
}

如果总是捕获你自己抛出的所有错误,那么IE的那点儿小愚蠢也不足为道了。在一个正确的错误处理系统中获得的好处是巨大的。该方法可以给出更多、更灵活的信息,告知开发者如何正确地处理错误。

2.7 不是你的对象不要动

JS独一无二之处在于任何东西都不是神圣不可侵犯的。默认情况下,你可以修改任何你可以触及的对象。它(解析器)根本就不在乎这些对象是开发者定义的还是默认执行环境的一部分——只要是能访问到的对象都可以修改。在一个开发者独自开发的项目中,这不是问题,开发者确切地知道正在修改什么,因为他对所有代码都了如指掌。然而,在一个多人开发的项目中,对象的随意修改就是个大问题了。

2.7.1 什么是你的对象

当你的代码创建了这些对象时,你拥有这些对象。创建了对象的代码也许没必要一定由你来编写,但只要维护代码是你的责任,那么就是你拥有这些对象。举例来说,YUI团队拥有该YUI对象,Dojo团队拥有该dojo对象。即使编写代码定义该对象的原始作者离开了,各自对应的团队仍然是这些对象的拥有者。

当在项目中使用一个JS类库,你个人不会自动变成这些对象的拥有者。在一个多人开发的项目中,每个人都假设库对象会按照它们的文档中描述的一样正常工作。如果你在使用YUI,修改了其中的对象,那么这就给你自己的团队设置了一个陷阱。这必将导致一些问题,有些人可能会掉进去。

请牢记,如果你的代码没有创建这些对象,不要修改它们, 包括:

  • 原生对象(Object、Array等等)
  • DOM对象(例如,document)
  • 浏览器对象模型(BOM)对象(例如,window)
  • 类库的对象

上面所有这些对象是你项目执行环境的一部分。由于它们已经存在了,你可以直接使用这些或者用其来构建某些新的功能,而不应该去修改它们。

2.7.2 原则

企业软件需要一致而可靠的执行环境使其方便维护。在其他语言中,考虑将已存在的对象作为库用来完成开发任务。在JS中,我们可以将已存在的对象视为一种背景,在这之上可以做任何事情。你应该把已存在的JS对象如一个使用工具函数库一样来对待。

  • 不覆盖方法
  • 不新增方法
  • 不删除方法

当项目中只有你一个开发者时,因为你了解它们,对它们有预期,这些种类的修改很容易处理。当与一个团队一起在做一个大型的项目时,像这些情况的修改会导致大量的混乱,也会浪费很多时间。

不覆盖方法

在JS中,有史以来最糟糕的实践是覆盖一个非自己拥有的对象的方法,JS中覆盖一个已存在的方法是难以置信的容易。即使那个神圣的document.getElementById()方法也不例外,可以被轻而易举地覆盖。也许你看过类似下面的模式(这种做法也叫“函数劫持”):

// 不好的写法
document._originalGetElementById = document.getElementById;
document.getElementById = function (id) {
  if (id === 'window') {
    return window;
  } else {
    return document._originalGetElementById(id);
  }
}

上例中,将一个原生方法document.getElementById()的“指针”保存在document._originalGetElementById中,以便后续使用。然后,document.getElementById()被一个新的方法覆盖了。新方法有时也会调用原始的方法,其中有一种情况不调用。这种“覆盖加可靠退化”的模式至少和覆盖原生方法一样不好,也许会更糟,因为document.getElementById()时而符合预期,时而不符合。 在一个大型的项目中,一个此类问题就会导致浪费大量时间和金钱。

不新增方法

在JS中为已存在的对象新增方法是很简单的。只需要创建一个函数赋值给一个已存在的对象的属性,使其成为方法即可。这种做法可以修改所有类型的对象。

// 不好的写法 - 在DOM对象上增加了方法
document.sayImAwesome = function () {
  alert("You're awesome.");
}
// 不好的写法 - 在原生对象上增加了方法
Array.prototype.reverseSort = function () {
  return this.sort().reverse();
}
// 不好的写法 - 在库对象上增加了方法
YUI.doSomething = function () {
  // 代码
}

几乎不可能阻止你为任何对象添加方法(ES5新增了三个方法可以做到,后面会介绍)。为非自己拥有的对象增加方法一个大问题,会导致命名冲突。因为一个对象此刻没有某个方法不代表它未来也没有。 更糟糕的是如果将来原生的方法和你的方法行为不一致,你将陷入一场代码维护的噩梦。

我们要从Prototype JS类库的发展历史中吸取教训。从修改各种各样的JS对象角度而言Prototype非常著名。它很随意地为DOM和原生的对象增加方法。实际上,库的大多数代码定义为扩展已存在的对象,而不是自己创建对象。Prototype的开发者将该库看作是对JS的补充。在小于1.6的版本中,Prototype实现了一个document.getElementsByClassName()方法。也许你认识该方法,因为在HTML5中是官方定义的,它标准化了Prototype的用法。

Prototype的document.getElementsByClassName()方法返回包含了指定CSS类名的元素的一个数组。Prototype在数组上也增加了一个方法,Array.prototype.each(),它在该数组上迭代并在每个元素上执行一个函数。这让开发者可以编写如下代码:

document.getElementsByClassName('selected').each(doSomething);

在HTML5标准化该方法和浏览器开始原生地实现之前,代码是没有问题的。当Prototype团队知道原生的document.getElementsByClassName()即将到来,所以他们增加了一些防守性的代码,如下:

if (!document.getElementsByClassName) {
  document.getElementsByClassName = function (classes) {
    // 非原生实现
  };
}

故Prototype只是在document.getElementsByClassName()不存在的时候定义它。这看上去好像问题就此解决了,但还有一个重要的事实是:HTML5的document.getElementsByClassName()不返回一个数组,所以each()方法根本不存在。原生的DOM方法使用了一个特殊化的集合类型称为NodeList。document.getElementsByClassName()返回一个NodeList来匹配其他的DOM方法的调用。

如果浏览器中原生实现了document.getElementsByClassName()方法,那么由于NodeList没有each()方法,无论是原生的或是Prototype增加的each()方法,在执行时都将引发一个JS错误。最后的结局是Prototype的用户不得不既要升级类库代码还要修改他们自己的代码,真是一场维护的噩梦。

从Prototype的错误中可以学到,你不可能精确预测JS将来会如何变化。标准已经进化了,它们经常会从诸如Prototype这样的库代码中获得一些线索来决定下一代标准的新功能。事实上,原生的Array.prototype.forEach()方法在ECMAScript5有定义,它与Prototype的each()方法行为非常类似。问题是你不知道官方的功能与原生会有什么样的不同,甚至是微小的区别也将导致很大的问题。

大多数JS库代码有一个插件机制,允许为代码库安全地新增一些功能。如果想修改,最佳最可维护的方式是创建一个插件

不删除方法

删除JS方法和新增方法一样简单。当然,覆盖一个方法也是删除已存在的方法的一种方式。最简单的删除一个方法的方式就是给对应的名字赋值为null。

// 不好的写法 - 删除了DOM方法
document.getElementById = null;

将一个方法设置为null,不管它以前是怎么定义的,现在它已经不能被调用到了。如果方法是在对象的实例上定义的(相对于对象的原型而言),也可以使用delete操作符来删除。

var person = {
  name: 'Nicholas'
};

delete person.name;
console.log(person.name); // undefined

上例中,从person对象中删除了name属性。delete操作符只能对实例的属性和方法起作用。如果在prototype的属性或方法上使用delete是不起作用的。例如:

// 不影响
delete document.getElementById;
console.log(document.getElementById('myelement')); // 仍然能工作

因为document.getElementById()是原型上的一个方法,使用delete是无法删除的。但是,仍然可以用对其赋值为null的方式来阻止被调用。

无需赘述,删除一个已存在对象的方法是糟糕的实践。不仅有依赖那个方法的开发者存在,而且使用该方法的代码有可能已经存在了。删除一个在用的方法会导致运行时错误。如果你的团队不应该使用某个方法,将其标识为“废弃”,可以用文档或者用静态代码分析器。删除一个方法绝对应该是最后的选择。

反之,不删除你拥有对象的方法实际上是比较好的实践。从库代码或原生对象上删除方法是非常难的事情,因为第三方代码正依赖于这些功能。在很多案例中,库代码和浏览器都会将有bug或不完整的方法保留很长一段时间,因为删除它们以后会在数不胜数的网站上导致错误。

2.7.3 更好的途径

修改非自己拥有的对象是解决某些问题很好的方案。在一种“无公害”的状态下,它通常不会发生;发生的原因可能是开发者遇到了一个问题,然而又通过修改对象解决了这个问题。尽管如此,解决一个已知问题的方案总是不止一种的。大多是计算机科学知识已经在静态类型语言环境中进化出了解决难题方案,如Java。可能有一些方法,所谓的设计模式,不直接修改这些对象而是扩展这些对象。

在JS之外,最受欢迎的对象扩充的形式是继承。如果一种类型的对象已经做到了你想要的大多数工作,那么继承自它,然后再新增一些功能即可。在JS中有两种基本的形式:基于对象的继承和基于类型的继承。

在JS中,继承仍然有一些很大的限制。首先,不能从DOM或BOM对象继承。其次,由于数组索引和length属性之间错综复杂的关系,继承自Array是不能正常工作的。

基于对象的继承

在基于对象的继承中,也经常叫做原型继承,一个对象继承另外一个对象是不需要调用构造函数的。ES5的Object.create()方法是实现这种继承的最简单的方式。例如:

var person = {
  name: 'Nicholas',
  sayName: function () {
    console.log(this.name);
  }
};

var myPerson = Object.create(person);
myPerson.sayName(); // "Nicholas"

这个例子创建了一个新对象myPerson,它继承自person。这种继承方式就如同myPerson的原型设置为person,从此myPerson可以访问person的属性和方法,而不需要同名变量在新的对象上再重新定义一遍。例如,重新定义myPerson.sayName()会自动切断对person.sayName()的访问:

myPerson.sayName = function () {
  console.log('Anonymous');
};

myPerson.sayName(); // "Anonymous"
person.sayName(); // "Nicholas"

Object.create()方法可以指定第二个参数,该参数对象中的属性和方法将添加到新的对象中。例如:

var myPerson = Object.create(person, {
  name: {
    value: 'Greg'
  }
});

myPerson.sayName(); // "Greg"
person.sayName(); // "Nicholas"

这个例子创建的myPerson对象拥有自己的name属性值,所以调用sayName()显示的是“Greg”而不是“Nicholas”。

一旦以这种方式创建了一个新对象,该新对象完全可以随意修改。毕竟,你是该对象的拥有者,在自己的项目中你可以任意新增方法,覆盖已存在方法,甚至是删除方法(或者阻止它们的访问)。

基于类型的继承

基于类型的继承和基于对象的继承工作方式是差不多的,它从一个已存在的对象继承,这里的继承是依赖于原型的。因此,基于类型的继承是通过构造函数实现的,而非对象。这意味着,需要访问被继承对象的构造函数。比起JS中原生的类型,在开发者定义了构造函数的情况下,基于类型的继承是最合适的。同时,基于类型的继承一般需要两步:首先,原型继承;然后,构造器继承。构造器继承是调用超类的构造函数时传入新建的对象作为其this的值。例如:

function Person (name) {
  this.name = name;
}

function Author (name) {
  Person.call(this, name); // 继承构造器
}

Author.prototype = new Person();

这段代码里,Author类型继承自Person。属性name实际上是由Person类管理的,所以Person.call(this, name)允许Person构造器继续定义该属性。Person构造器是在this上执行的,this指向一个Author对象,所以最终的name定义在这个Author对象上。

对比基于对象的继承,基于类型的继承在创建新对象时更加灵活。定义了一个类型可以让你创建多个实例对象,所有的对象都是继承自一个通用的超类。新的类型应该明确定义需要使用的属性和方法,它们与超类中的应该完全不同。

门面模式

门面模式是一种流行的设计模式,它为一个已存在的对象创建一个新的接口。门面是一个全新的对象,其背后有一个已存在的对象在工作。门面有时也叫包装器,它们用不同的接口来包装已存在的对象。你的用例中如果继承无法满足要求,那么下一步骤就应该创建一个门面,这比较合乎逻辑。

jQuery和YUI的DOM接口都使用了门面。如上所述,你无法从DOM对象上继承,所以唯一的能够安全地为其新增功能的选择就是创建一个门面。下面是一个DOM对象包装器代码示例:

function DOMWrapper (element) {
  this.element = element;
}

DOMWrapper.prototype.addClass = function (className) {
  this.element.className += ' ' + className;
}

DOMWrapper.prototype.remove = function () {
  this.element.parentNode.removeChild(this.element);
}

// 用法
var wrapper = new DOMWrapper(document.getElementById('my-div'));
wrapper.addClass('selected');
wrapper.remove();

DOMWrapper类型期望传递给其构造器的是一个DOM元素。该元素会保存起来以便以后引用,它还定义了一些操作该元素的方法。addClass()方法是为那些还未实现HTML5的classList属性的元素增加className的一个简单的方法。remove()方法封装了从DOM中删除一个元素的操作,屏蔽了开发者要访问该元素父节点的需求。

从JS的可维护性而言,门面是非常合适的方式,自己可以完全控制这些接口。你可以允许访问任何底层对象的属性或方法,反之亦然,也就是有效地过滤对该对象的访问。你也可以对已有的方法进行改造,使其更加简单易用(上段示例代码就是一个案例)。底层的对象无论如何改变,只要修改门面,应用程序就能继续正常工作。

门面实现一个特定接口,让一个对象看上去像另一个对象,就称作一个适配器。门面和适配器唯一的不同是前者创建新接口,后者实现已存在的接口

2.7.4 关于Polyfill的注解

随着ES5和和HTML5的特性逐渐被各种浏览器实现。JS polyfills(也称为shim)变得流行起来了。 polyfill是对某种功能的模拟,这些功能在新版本的浏览器中有完整的定义和原生实现。例如,ES5为数组增加了forEach()函数。该方法在 ES3中有模拟实现,这样就可以在老版本浏览器中用上这个方法了。 polyfills的关键在于它们的模拟实现要与浏览器原生实现保持完全兼容。正是由于少部分浏览器原生实现这些功能,才需要尽可能的检测不同情况下它们这些功能的处理是否符合标准。

为了达到目的,polyfills经常会给非自己拥有的对象新增一些方法。我不是polyfills的粉丝,不过对于别人使用它们,我表示理解。相相比其他的对象修改而言,polyfills是有界限的,是相对安全的。因为原生实现中是存在这些方法并能工作的,有且仅当原生方法不存在时,polyfills才新增这些方法,并且它们和原生版本方法的行为是完全一致的。

polyfills的优点是,如果浏览器提供原生实现,可以非常轻松地移除它们。如果你使用了polyfills,你需要搞清楚哪些浏览器提供了原生实现。并确保polyfills的实现和浏览器原生实现保持完全一致,并再三检查类库是否提供验证这些方法正确性的测试用例。polyfills的缺点是,和浏览器的原生实现相比,它们的实现可能不精确,这会给你带来很多麻烦,还不如不实现它。

从最佳的可维护性角度而言,避免使用polyfills,相反可以在已存在的功能之上创建门面来实现。这种方法给了你最大的灵活性,当原生实现中有bug时这种做法(避免使用polyfills)就显得特别重要。这种情况下,你根本不想直接使用原生的API,不然无法将原生实现带有的bug隔离开来。

2.7.5 阻止修改

ES5引入了几个方法来防止对对象的修改。理解这些能力很重要,因此现在可以做到这样的事情:锁定这些对象,保证任何人不能有意或无意地修改他们不想要的功能。当前(2018年)的浏览器都支持ES5的这些功能,有三种锁定修改的级别:

  • 防止扩展(Object.preventExtension()):禁止为对象“添加”属性和方法,但已存在的属性和方法是可以被修改或删除
  • 密封(Object.seal()):类似“防止扩展”,而且禁止为对象“删除”已存在的属性和方法
  • 冻结(Object.freeze()):类似“密封”,而且禁止为对象“修改”已存在的属性和方法(所有字段均只读)

每种锁定的类型都拥有两个方法:一个用来实施操作,另一个用来检测是否应用了相应的操作。如防止扩展,Object.preventExtension()Object.isExtensible()两个函数可以使用。你可以在MDN上查看相关方法的使用,这里就不赘述了。

使用ES5中的这些方法是保证你的项目不经过你同意锁定修改的极佳的做法。如果你是一个代码库的作者,很可能想锁定核心库某些部分来保证它们不被意外修改,或者想强迫允许扩展的地方继续存活着。如果你是一个应用程序的开发者,锁定应用程序的任何不想被修改的部分。这两种情况中,在全部定义好这些对象的功能之后,才能使用上述的锁定方法。一旦一个对象被锁定了,它将无法解锁。

2.8 浏览器嗅探

浏览器嗅探在Web开发领域始终是一个热点话题,不管你是写JS或CSS或HTML,总会遇到跨浏览器做兼容的情况(虽然目前情况已经比之前好太多,但面对新API接口的使用,依然存在浏览器嗅探的情况)。下面介绍下基于UA检测的历史,来说明为什么UA检测不合理。

2.8.1 UA检测

最早的浏览器嗅探即用户代理(user-agent)检测,服务端(以及后来的客户端)根据user-agent字符串来确定浏览器的类型。在此期间,服务器会完全根据user-agent字符串屏蔽某些特定的浏览器查看网站内容。其中获益最大的浏览器就是网景浏览器。不可否认,网景(在当时)是最强大的浏览器,以至于很多网站都认为只有网景浏览器才会正常展现他们的网页。网景浏览器的user-agent字符串是Mozilla/2.0 (Win95; I)。当IE首次发布,基本上就被迫沿用了网景浏览器user-agent字符串的很大一部分,以此确保服务器能够为这款新的浏览器提供服务。因为绝大多数的用户代理检测的过程都是查找“Mozilla”字符串和斜线之后的版本号,IE浏览器的user-agent字符串设置成Mozilla/2.0 (compatible; MSIE 3.0; Windows 95),是不是觉得很鸡贼。IE采用了这样的用户代理字符串,这意味着每个浏览器类型检测也会把这款新的浏览器识别为网景的Navigator浏览器。这也使得新生浏览器部分复制现有浏览器用户代理字符串成为了一种趋势。Chrome发行版的用户代理字符串包含了Safari的一部分,而Safari的用户代理字符串又相应包含了Firefox的一部分,Firefox又依次包含了Netscape(网景)用户代理字符串的一部分。

基于UA检测是极其不靠谱的,并且维护困难,基于如下原因:

  • UA可以伪造,一个声明为Chrome的浏览器它可能是其他浏览器
  • 每次有新的浏览器出现,或者已有的浏览器版本升级,原先基于UA检测的代码都要更新,维护成本和出错几率极大

所以我建议你尽可能避免检测UA,即使在不得不这样做的情况下。

2.8.2 特性检测

我们希望有一种更聪明的基于浏览器条件(进行检测)的方法,于是一种叫特性检测的技术变得流行起来。特性检测的原理是为特定浏览器的特性进行测试,并仅当特性存在时即可应用特性检测,例如:

// 不好的写法
if (navigator.userAgent.indexOf("MSIE 7") > -1) { }

// 好的写法
if (document.getElementById) {}

因为特性检测不依赖于所使用的浏览器,而仅仅依据特性是否存在,所以并不一定需要新浏览器的支持。例如,在DOM早期的时候,并非所有浏览器都支持document.getElementById(),所以根据ID获取元素的代码看起来就有些冗余。

// 好的写法
// 仅为举例说明特性检测,现代浏览器都支持getElementById
function getById (id) {
  var el = null;

  if (document.getElementById) { // DOM
    el = document.getElementById(id);
  } else if (document.all) { // IE
    el = document.all[id];
  } else if (document.layers) { // Netscape <= 4
    el = document.layers[id];
  }

  return el;
}

这种方法同样适用于当今最新的浏览器特性检测,浏览器已经实验性地实现了这些最新的特性,而规范还正在最后确定中。常见的Polyfill就是特性检测的应用,例如:

if (!Array.isArray) {
  Array.isArray = function (arr) {
    return Object.prototype.toString.call(arr) === '[object Array]'
  }
}

2.8.3 避免特性推断

一种不当的使用特性检测的情况是“特性推断”(Feature Inference)。特性推断尝试使用多个特性但仅验证了其中之一。根据一个特性的存在推断另一个特性是否存在。问题是,推断是假设并非事实,而且可能会导致维护性的问题。例如,如下是一些使用特性推断的旧代码:

// 不好的写法 - 使用特性推断
function getById (id) {
  var el = null;

  if (document.getElementsByTagName) { // DOM
    el = document.getElementById(id);
  } else if (window.ActiveXObject) { // IE
    el = document.all[id];
  } else { // Netscape <= 4
    el = document.layers[id];
  }

  return el;
}

该函数是最糟糕的特性推断,其中做出了如下几个推断:

  • 如果document.getElementsByTagName()存在,则document.getElementById也存在。实际上,这个假设是从一个DOM方法的存在推断出所有方法都存在。
  • 如果window.ActiveXObject存在,则document.all也存在。这个推断基本上断定window.ActiveXObject仅仅存在于IE,且document.all也仅存在于IE,所以如果你判断一个存在,其他的也必定存在。实际上,Opera的一些版本也支持document.all
  • 如果这些推断都不成立,则一定是Netscape Navigator 4或者更早的版本。这看似正确,但及其不严格。

你不能从一个特性的存在推断出另一个特性是否存在。最好的情况下两者有薄弱的联系,最坏的情况下两者根本没有直接关系。也就好比说是,“如果它看起来像一个鸭子,就必定像鸭子一样嘎嘎地叫。”

2.8.4 避免浏览器推断

在某些时候,用户代理检测和特性检测让许多Web开发人员很困惑。于是写出来的代码就变成了这样:

// 不好的写法
if (document.all) {
  id = document.uniqueID;
} else {
  id = Math.random();
}

这段代码的问题是,通过检测document.all,间接地判断浏览器是否为IE。一旦确定了浏览器是IE,便假设可以安全地使用IE所特有的document.uniqueID。然而,你所做的所有探测仅仅说明document.all是否存在,而并不能用于判断浏览器是否是IE。正因为document.all的存在并不意味着document.uniqueID也是可用的,因此这是一个错误的隐式推断,可能会导致代码不能正常运行。

为了更清楚地表述该问题,代码被修改成这样:

var isIE = navigator.userAgent.indexOf("MSIE") > -1;

修改为如下这样:

// 不好的写法
var isIE = !!document.all;

这种转变体现了一种对“不要使用用户代理检测”的误解。虽然不是直接检测特定的浏览器,但是通过特性检测从而推断出是某个浏览器同样是很糟糕的做法。这叫做浏览器推断,是一种错误的实践。

到了某个阶段,开发者意识到document.all实际上并不是判断浏览器是否为IE的最佳方法。之前的代码加上了更多的特性检测,如下所示:

var isIE = !!document.all && document.uniqueID;

这种方法属于“自作聪明”型的。尝试通过越来越多的已知特性推断某些事情太困难了。更糟糕的是,你没办法阻止其他浏览器实现相同的功能,最终导致这段代码返回不可靠的结果。

2.8.5 应当如何取舍

特性推断和浏览器推断都是糟糕的做法,应当不惜一切代价避免使用。纯粹的特性检测是一种很好的做法,而且几乎在任何情况下,都是你想要的结果。通常,你仅需要在使用前检测特性是否可用。不要试图推断特性间的关系,否则最终得到的结果也是不可靠的。

迄今为止我不会说从来不要使用用户代理检测,因为我的确相信有合理的使用场景,但同时我也不相信会有很多使用场景。如果你想使用用户代理嗅探,记住这点:这么做唯一安全的方式是针对旧的或者特定版本的浏览器。而绝不应当针对最新版本或者未来的测览器。

我个人的建议是尽可能地使用特性检测。如果不能这么做的时候,可以退而求其次,考虑使用用户代理检测。永远不要使用浏浏览器推断,因为你会被这样维护性很差的代码缠身,而且随着新的浏览器出现,你需要不断地更新代码

3. 工程化

我相当乐意花一整天的时间通过编程把一个任务实现自动化,除非这个任务手动只需要10秒钟就能完成。——Douglas Adams, Last Chance to See

前端工程化是随着Web前端项目规模的不断增大而逐渐受到前端工程师的重视,前端工程化主要应该从模块化、组件化、规范化、自动化四个方面来思考。我这里侧重讲解下自动化的工作,现代前端(以SPA为代表的WebApp时代,与传统的WebPage时代相区别)的项目一般都包括了很多需要自动化的工作,比如:

  • 转码:ES6代码通过Babel转换成ES5,TS转成ES5;LESS、SASS转成CSS
  • 压缩:主要是JS和CSS的压缩,也包括静态资源(主要是图片)的压缩
  • 文件合并:合并多个JS文件或者CSS文件,减少HTTP请求
  • 环境:开发环境、测试环境、生产环境的自动化流程都是不同的
  • 部署:静态资源自动上CDN、自动发布等

这里只是列出了一部分需要自动化的工作,实际情况不同项目会有不同的定制化需求。我也相信现在肯定每人会手动执行这些工作,一般都会用webpack这类构建工具做这些工作。要写出可维护的JS(这里应该是更宽泛意义上的前端项目,不仅仅是JS),像上面这些自动化的流程(思考下你现在项目中有没有每次都要你手动操作的工作,考虑如何将它自动化)都应该用代码完成自动化,避免人工干预(人是会犯错的,而且,偷懒不是程序员的美德吗)。

前端工程化是个十分宽泛的话题,足以写另外一篇博文来介绍了,感兴趣的同学,我推荐一本书《前端工程化:体系设计与实践》,这本书2018年1月出版的,内容也是与时俱进,值得细细品尝。知乎也有关于前端工程化的讨论,不妨看看大咖们的观点。

文章首发于我的博客,本文采用知识共享署名 4.0 国际许可协议进行许可。