编写可维护的JS

0. 写在前面

当你开始工做时,你不是在给你本身写代码,而是为后来人写代码。 —— Nichloas C. Zakasjavascript

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

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

关于代码的维护,你须要明白如下四点:java

  • 软件生命周期中80%的成本消耗在了维护上。
  • 几乎全部的软件维护者都不是它的最初做者。
  • 编码规范提升了软件的可读性,它让工程师可以快速且充分地理解新的代码。
  • 若是你将源码做为产品来发布,你须要确保它是可完整打包的,且像你建立的其余产品同样整洁。

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

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

1. 编程风格

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

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

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

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

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 国际许可协议进行许可。

相关文章
相关标签/搜索