《Effective JS》的 68 条准则「五十三至六十条」- 库和 API 设计

179 阅读17分钟

起因

开始阅读学习《Effective JavaScript》,以自身阅读和理解,着重记录内容精华部分以及对内容进行排版,便于日后自身回顾学习以及大家交流学习。

因内容居多,分为每个章节来进行编写文章,每章节的准条多少不一,故每篇学习笔记的文章以章节为准。

适合碎片化阅读,精简阅读的小友们。争取让小友们看完系列 === 看整本书的 85+%。

前言

内容总览

  • 第一章让初学者快速熟悉 JavaScript,了解 JavaScript 中的原始类型、隐式强制转换、编码类型等几本概念;
  • 第二章着重讲解了有关 JavaScript 的变量作用域的建议,不仅介绍了怎么做,还介绍了操作背后的原因,帮助读者加深理解;
  • 第三章和第四章的主题涵盖函数、对象及原型三大方面,这可是 JavaScript 区别于其他语言的核心;
  • 第五章阐述了数组和字典这两种容易混淆的常用类型及具体使用时的建议,避免陷入一些陷阱;
  • 第六章讲述了库和 API 设计;
  • 第七章讲述了并行编程,这是晋升为 JavaScript 专家的必经之路

第 6 章「库和 API 设计」

当你的程序已在一个平台上运行足够长的时间,将对常见的问题建立了完善的解决方案,便会开始开发可重用的程序和组建,提升自己的技能成为一个库作者能让你编写更好的组件。

设计程序库是一个棘手的业务,更多地表现为科学艺术,API 是程序员的基本词汇。设计良好的 API 能让你的用户清楚、简洁和明确地表达自己的程序。

第 53 条:保持一致的约定

对于 API 使用者来说,你所使用的命名和函数签名是最能产生普遍影响的决策。它们建立了基本的词汇和使用它们的应用程序的惯用法,当我们设计库应当使得学习曲线尽可能简单。

其中一个关键约定是参数的顺序。保持多个 API 通用参数总是以相同的顺序出现,例如简单的(宽度、高度)参数函数,保持一致通用型。

如果你的 API 使用选项对象(第 55 条),你可以避免参数顺序的依赖。对于标准选项,你应该挑选一个命名约定并坚定地坚持它。如宽度或高度测量值,width 和 height 选项而其他的查找 w 和 h 选项,这将困扰 API 使用者。

每一个优秀的库都需要详尽的文档,而一个极优秀的库会将其文档作为辅助。一旦你的用户习惯了库中的约定,便可以做一些常见的任务而无需每次都查看文档。甚至帮助用户推测属性或方法并且推测其行为。

总结

  • 在变量命名和函数签名中使用一致的约定。
  • 不要偏离用户在他们的开发平台中很可能遇到的约定。

第 54 条:将 undefinend 看作“没有值”

每当 JavaScript 无法提供具体的值时,即未赋值的变量的初始值即为 undefined。

const x;	// undefined

const obj = {};
obj.x;	// undefined

function f() {
  return;
}
function g() {}
f();	// undefined
g();	// undefined

function f(x) {
  return x;
}
f();	// undefined

JavaScript 每个操作都要产出点什么,故可以使用 undefined 来填补这个空白。

将 undefined 看做缺少某个特定的值是 JavaScript 语言建立的一种公约,我们可以使用逻辑运算符来避免缺失值的问题:

function Server(hostname) {
  hostname = String(hostname || 'localhost');
}

但真值测试不总是安全,如接收数字为参数的函数允许 0 或接收NaN,则不应该使用真值测试。

function Element(w, h) {
  this.w = w || 320;
  this.h = h || 240;
}

// 未符合预期
const c1 = new Element(0, 0);
c1.w;	// 320;
c1.h;	// 240;

因此需使用更详细的测试

function Element(w, h) {
  this.w = w === undefined ? 320 : width;
  this.h = h === undefined ? 240 : height;
}

或者使用 ES11 的新特性 ?? 避免空值(undefined、Null)

function Element(w, h) {
  this.w = w ?? 320
  this.h = h ?? 240
}
  
const c1 = new Element(0, 0);
c1.w;	// 0;
c1.h;	// 0;

总结

  • 避免使用 undefined 表示任何非特定值。
  • 使用描述性的字符串或命名布尔属性的对象,而不要使用 undefined 或 null 来代表特定的应用标志。
  • 在允许任意假值为有效参数的地方,绝不要通过真值测试来实现参数默认值。

第 55 条:接收关键字参数的选项对象

保持参数顺序的一致约定对于帮助程序员记住每个参数在函数调用中的意义是很重要的,但参数过多后,它将难以维护。而我们可以尝试采用选项对象:

const alert = new Alert({
  x: 100,
  y: 75,
  ...
})

这样一来,每个参数都是自我描述,并且所有的参数都是可选,调用者可以提供任一可选参数的子集。从而避免可选参数的不可描述。如 Alert 中 width、height 均为可选参数:

const alert = new Alert({
  app,
  150,
  "Error",
  ...
})

该 150 是赋予 width 还是 height 将会出现歧义。选项对象则不会:

const alert = new Alert({
  parent: app,
  height: 150,
  ...
})

如果有一个或者两个必选参数,最好是它们独立于选项对象,以下我们实现一个接收选项对象函数的详细实现:

function Alert(parent, message, opts) {
  opts = opts || {};
  this.width = opts.width === undetined ? 320 : opts.width;
  this.height = opts.height === undetined ? 240 : opts.height;
  this.x = opts.x === undefined ? (parent.width / 2) - (this.width / 2) : opts.x;
  this.y = opts.y === undefined ? (parent.height / 2) - (this.height / 2) : opts.y;
  this.title = opts.title || 'Alert';
  this.modal = !!opts.modal;
  this.message = message;
}

接收选项对象函数实现从使用或(||)操作符提供一个默认空选项对象开始。由于 0 是一个有效值但不是默认值,所以需要测试数值参数是否为 undefined。modal 参数使用双重否定模式将其参数强制转换为一个布尔值。

比起使用顺序参数的方式,选项参数有些烦琐,因此我们可以使用有用的抽象(对象扩展或合并函数)来简化我们的工作。如许多 JavaScript 库和框架都提供一个 extend 函数,该函数接收一个 target 对象和一个 source 对象,并将后者的属性复制到前者中。其最有用的应用之一是抽象出了合并默认值和用户提供的选项对象值的逻辑。

function Alert(parent, message, opts) {
  opts = extend({width: 320, height});
  opts = extend({
    x: (parent.width / 2) - (this.width / 2),
    y: (parent.height / 2) - (this.height / 2),
    title: 'Alert',
    modal: false
  }, opts)
  
  this.width = opts.width;
  this.height = opts.height;
  this.x = opts.x;
  this.y = opts.y;
  this.title = opts.title;
  this.modal = opts.modal
}

这避免了不断地重新实现检查每个参数是否存在的逻辑。请注意调用了两次 extend 函数,因为 x 和 y 的默认值依赖于早前计算出的 width 和 height 值。

如果我们不额外对选项对象值坐特殊处理,只将整个 options 复制到 this 对象,那么我们可以进一步简化它。

function Alert(parent, message, opts) {
  opts = extend({width: 320, height});
  opts = extend({
    x: (parent.width / 2) - (this.width / 2),
    y: (parent.height / 2) - (this.height / 2),
    title: 'Alert',
    modal: false
  }, opts)
  extend(this, opts);
}

extend 函数在不同框架中提供了不同的函数变种,典型的实现是枚举源对象的属性,并当这些属性不是 undefined 或 null 时将其复制到目标对象中。

function extend(target, source) {
  if (source) {
    for (let key in source) {
      const val = source[key];
      if (typeof val !== 'undefined') {
        target[key] = val;
      }
    }
  }
  return target;
}

在 Alert 早些版本中对于字符串参数,我们将空字符串视为与 undefined 等价。但将 undefined 视为缺省的参数更为恰当。

总结

  • 使用选项对象使得 API 更具可读性和记忆性。
  • 所有通过选项对象提供的参数应当被视为可选的。
  • 使用 extend 函数抽象出从选项对象中提取的逻辑。

第 56 条:避免不惜要的状态

API 有时被归为两类:有状态的和无状态的。无状态的 API 提供的函数或方法的行为只取决于输入,而与程序的状态改变无关。如字符串的方法是无状态的,其方法只取决于字符串的内容及传递给方法的参数而不管程序其他部分,"foo".toUpperCase() 总是产生 FOO。Date 对象方法则是有状态的。

无状态的 API 往往更容易学习和使用,更自我描述,且不易不稳定。如一个著名的有状态 API 是 Web 的 Canvas 库。它提供了绘制形状和图片到其平面的用户界面元素方法。一个程序可以使用 fillText 方法绘制文本到一个画布。

该方法未制定被绘制文本的其他属性,例如颜色、文本属性等。所有的这些属性通过改变画布的内部状态来单独指定。

c.fillStyle = "blue";
c.font = "24pt serif";
c.textAlign = "center";
c.fillText('hello', 75, 75);

该 API 的无状态版本可能如下:

c.fillText('hello', 75, 75, {
  fillStyle: "blue",
  font: "24pt serif",
  textAlign: "center"
});

**而针对单独某个 API 来说,后者无状态版本更加可取。**首先它实现定制化,有状态的 API 需要修改画布的内部状态,这将导致绘制操作之间互相影响。例如默认的填充样式是黑色,那你需要显式指定默认值。

c.fillText('text 1', 0, 0);	// 默认颜色黑色
c.fillStyle = 'blue';
c.fillText('text 2', 0, 30);	// 蓝色
c.fillStyle = 'black';
c.fillText('text 3', 0, 60);	// 黑色

相比无状态的 API 会自动重用默认值:

c.fillText('text 1', 0, 0);	// 默认颜色黑色
c.fillText('text 2', 0, 30, { fillStyle: 'blue'});	// 蓝色
c.fillText('text 3', 0, 60);	// 默认黑色

并且有状态 API 有可能影响其他 API 的操作:

c.fillStyle = 'blue';
drawMyImage(c);	// 自定义方法操作 c 实例
c.fillText('hello', 75, 25);

为了理解最后 fillText 的动作,你不得不理解 drawMyImage 方法可能对画布做了什么修改。因此无状态的 API 会使代码更加模块化,从而避免代码不同部分相互影响且更易阅读。

无状态 API 的另一个好处是简洁。有状态的 API 往往需要额外的声明,在调用 API 前去设置该对象的内部状态如何。但对于无状态 API 没必要创建额外的变量,在更新区域前保存提取数据即可。

总结

  • 尽可能地使用无状态的 API。
  • 无状态 API 更易理解、阅读、使用,且使代码模块化,无需额外的声明。
  • 如果 API 是有状态的,标示出每个操作与哪些状态有关联。

第 57 条:使用结构类型设计灵活的接口

结构接口

想象一个 创建 wiki 的库,该库必须能提取元数据,如页面标题和作者信息等,并能将页面内容格式化为 HTML 呈现给 wiki 读者。我们可以将 wiki 中每一个页面表示为提供了通过 getTitle、getAuthor 和 toHTML 页面方法获取这些数据的一个对象。

Wiki.prototype.displayPage = function(source) {
  const page = this.format(source);
  const title = page.getTitle();
  const author = page.getAuthor();
  const output = page.toHTML();
};

接下来 wiki 库提供创建一个自定义 wiki 格式化器的应用程序的方法,以及一些针对流行标记格式的内置格式化器。如编写者可能希望使用 MediaWiki 格式化(这事维基百科所使用的格式)。

const app = new Wiki(Wiki.formats.MEDIAWIKI);

该库将该格式化函数存储在 wiki 实例对象的内部。

function Wiki(format) {
  this.format = format;
}

该 Wiki.formats.MEDIAWIKI 的格式化器是如何实现的呢?熟悉基于类编程的程序员可能倾向于创建一个 Page 的基类,该 Page 类表示用户创建的内容,每个 Page 的子类实现不同的格式。MediaWiki 格式化可能实现为一个继承 Page 的 MWPage 类,而 MEDIAWIKI 则是一个返回 MWPage 实例的工厂函数。

function MWPage(source) {
  Page.call(this, source);	// 继承 Page 父类构造函数
  // ... 不同格式构造函数
}
// MWPage 原型继承 Page
MWpage.prototype = Object.create(Page.prototype);
// MWPage 格式化
MWPage.prototype.getTitle = ...;
MWPage.prototype.getAuthor = ...;
MWPage.prototype.toHTML = ...;

Wiki.formats.MEDIAWIKI = function(source) {
  return new MWPage(source);
};

由于 MWPage 类需要自己实现所有 wiki 应用程序需要的 getTitle、getAuthor 和 toHtml 方法,因此未必可继承任何有用的实现代码。而且 Wiki.prototype.displayPage 方法并不关心页面对象的继承体系,只需要实现如何显示页面的相关方法。

尽管大多数面向对象语言都鼓励使用类和继承方法,但 JavaScript 只需提供一个使用简单对象字面量构建 MediaWiki 页面格式的接口实现就足够了。

Wiki.formats.MEDIAWIKI = function(source) {
  // extract contents from source
  // ...
  return {
    getTitle: function() {...},
		getAuthor: function() {...},
 	  toHTML: function() {...},
  }
}

但这样的继承会出现很多问题,当有几个不同的 wiki 格式共享不相重叠的功能集时,也许继承便会导致混乱。如我们想实现各个部分的功能来识别每种不同类型的输入,然而功能的混合和匹配并没有映射到任何清晰的 A、B 和 C 之间的继承层次关系。正确的做法是分别实现每种输入匹配的函数(单星号、双星号、斜杠等等),再对其每种格式的需要混合和匹配功能。但继承往往容易引起这样的事故。

结构类型接口

任何人希望实现一个新的自定义格式都可以这样做,而不需要再某处再“注册”它。只要 displayPage 方法结构正确,具有预期行为的 getTitle、getAuthor 和 getHTML 方法,那么它就适用于任何 JavaScript 对象。正如该方法与 Page 基类相互解耦。

这种接口有时称为结构类型鸭子类型。任何对象只要具有预期的结构就属于该类型(如果它看起来像只鸭子、游泳像只鸭子或叫声像只鸭子,那么它就是鸭子)。在类型 JavaScript 这样的动态语言中,不需要编写任何显式的声明。一个调用某个对象方法的函数就能够与任何实现了相同接口的对象一起工作。

当然我们应当在 API 的文档中列出对象接口的预期结构。这样,接口实现者便会知道哪些属性和方法是必须的以及你的库或应用程序期望的行为是什么。

结构接口利于单元测试

灵活的结构类型的另一个好处就是有利于单元测试。我们的 wiki 库可能期望嵌入一个 HTTP 服务器对象来实现 wiki 网站的网络功能,如果我们想在没有连接网络的情况下测试 wiki 网站的交互时序,那么我们可以实现一个 mock 对象来模拟 HTTP 服务器的行为。这些行为是遵照预定的脚本而不是真实的接触网络,这使得测试组件与服务器的交互行为成为可能。

总结

  • 结构接口更灵活、更轻量,所以应当避免使用继承。
  • 使用结构类型(也称为鸭子类型)来设计灵活的对象接口。
  • 针对单元测试,使用 mock 对象即接口的替代实现来提供可复验的行为。

第 59 条:避免过度的强制转换

JavaScript 语言是弱类型,许多标准的操作符和代码库会自动地将非预期的输入参数强制转换为期望的类型而不是抛出异常。如果未提供额外的逻辑,使用这些内置操作符构建的程序会继承其强制转换的行为。

例如我们希望有一个重载数值或数组的方法 enable,但一般方法的使用是含糊不清的。调用者若是没有理解 API 的意思,便会传入错误类型的参数。

bits.enable('100');

事实上我们可以强制只接收数字和对象。

BitVector.prototype.enable = function(x) {
  if (typeof x === 'number') {
    this.enableBit(x);
  } else if (typeof x === 'object' && x) {
    for (let i = 0, n = x.length; i < n; i++) {
      this.enableBit(x[i]);
    }
  } else {
    throw new TypeError('expected number or array-like');
  }
}

这种更为谨慎的风格被称为防御性编程,试图以额外的检查来抵御潜在的错误。一般情况下,抵御所有潜在的错误是不可能的。例如,我们可能使用检查来确保如果 x 具有 length 属性,那么它应该是一个对象。

JavaScript 除了提供实现这些检查的基本工具(如 typeof 操作符)外,还可以编写更为简洁的工具函数来监视函数签名。例如,我们可以使用一个预先检查来监视 bitVector 的构造函数。每个监视对象实现自己的 test 方法和错误信息的字符串描述。

function BitVector(x) {
  uint32.or(arrayLike).guard(x);
}

const guard = {
  guard: function(x) {
    if (!this.test(x)) {
      throw new TypeError('expected' + this);
    }
  }
};

const uint32 = Object.create(guard);
uint32.test = function(x) {
  return typeof x === 'number' && x === (x >>> 0);
}
uint32.toString = function() {
  return 'unit32';
}

uint32 的监视对象使用 JavaScript 位操作符的一个诀窍来实现到 32 位无符号整数的转换。无符号右移位运算符在执行移位运算前会将其第一个参数转换为一个 32 位的无符号整数。移入零位对整数值没有影响。因此,uint32.test 实际上是把一个数字与该数字转换为 32 位无符号整数的结果做比较。

接下来,实现 arrayLike 的监视对象。采取防御性编程来确保一个类数组对象应该具有一个无符号整数的 length 属性。

const arrayLike = Object.create(guard);

arrayLike.test = function(x) {
  return typeof x === 'object' && x && uint32.test(x.length);
};

arrayLike.toString = function() {
  return 'array-like object';
};

最后,我们实现一些作为原型方法的 “链” 方法,比如 or 方法。

guard.or = function(other) {
  const result = Object.create(guard);
  
  const self = this;
  result.test = function(x) {
    return self.test(x) || other.test(x);
  };
  
  result.toString = function() {
    return self + " or " + other;
  }
  
  return result;
}

该方法合并了接收者监视对象(绑定到 this 的对象)和另一个监视对象(other 参数),产生一个新的监视对象。新监视对象的 test 和 toString 方法合并了这两个输入对象的方法。请注意我们使用了一个局部的 self 变量来保存 this 的引用,以确保能在合成的监视对象的 test 方法中引用。

综上所述,当遇到错误时,这些测试能帮助我们更早地捕获错误,这使得它们更容易诊断。然而,它们可能扰乱代码库并潜在地影响应用程序的性能。因此,是否使用防御性编程是一个成本(不得不编写和执行额外测试的数量)和收益(你更早捕获的错误数,节省的开发和调试时间)的问题。

总结

  • 避免强制转换和重载的混用。
  • 考虑防御性地监视非预期的输入。

第 60 条:支持方法链

无状态 API 的方法链

无状态的 API 的部分能力是将复杂操作分解为更小的操作的灵活性。如字符串的 replace 方法,由于结果本身是字符串,我们可以对前一个 replace 操作重复执行替换,这种模式常用于 HTML 的转义字符。

function escapeBasicHTML(str) {
  return str.replace(/&/g, "&amp;")
  					.replace(/</g, "&lt;")
    				.replace(/>/g, "&gt;")
    				.replace(/"/g, "&quot;")
    				.replace(/'/g, "&apos;")
}

这种重复的方法调用风格就叫做方法链,相比于将每个中间结果保存为变量,它更为简洁。

function escapeBasicHTML(str) {
  const str1 = str.replace(/&/g, "&amp;")
	const str2 = str1.replace(/</g, "&lt;")
	const str3 = str2.replace(/>/g, "&gt;")
	const str4 = str3.replace(/"/g, "&quot;")
	const str5 = str4.replace(/'/g, "&apos;")
  return str5;
}

因此,形成方法链的主要条件为如果一个 API 产生了一个接口对象,调用这个接口对象的方法产生的对象如果具有相同的接口,那么就可以使用方法链。

故在通常情况下,无状态的 API 中,如果你的 API 不修改对象,而是返回一个新对象,则链式得到了自然的结果。

有状态 API 的方法链

但在有状态的设置中方法链也是值得支持的。这里的技巧是方法在更新对象时返回 this,通过一个链式方法调用的序列来对同一个对象执行多次更新,这种有状态的 API 的方法链有时被称为流畅式。

element.setBackgroundColo('yellow')
			 .setColor('red')
			 .setFontWeight('bold');

因此综上,通过支持方法链,API 允许程序员按自己的喜好选择风格。

总结

  • 使用方法链来连接方法的操作。
  • 通过在无状态的方法中返回新对象来支持方法链。
  • 通过在有状态的方法中返回 this 来支持方法链。

链接