搜搜网站提交,音乐网站的音乐怎么做音乐试听,蜜雪冰城网络营销论文,网站不更新摘要#xff1a; 新技术层出不穷#xff0c;长江后浪推前浪。在浪潮褪去后#xff0c;能留下来的#xff0c;是一些经典的设计思想。 在前端界#xff0c;以前有远近闻名的 jQuery#xff0c;近来有声名鹊起的 Vue.js。这两者叫好又叫座的原因固然有很多#xff0c;但是…摘要 新技术层出不穷长江后浪推前浪。在浪潮褪去后能留下来的是一些经典的设计思想。 在前端界以前有远近闻名的 jQuery近来有声名鹊起的 Vue.js。这两者叫好又叫座的原因固然有很多但是其中有一个共同特质不可忽视那便是它们的 API 设计 非常优雅。 因此这次我想来谈个大课题 —— API 设计之道。 ******新技术层出不穷长江后浪推前浪。在浪潮褪去后能留下来的是一些经典的设计思想。
在前端界以前有远近闻名的 jQuery近来有声名鹊起的 Vue.js。这两者叫好又叫座的原因固然有很多但是其中有一个共同特质不可忽视那便是它们的 API 设计 非常优雅。
因此这次我想来谈个大课题 —— API 设计之道。
讨论内容的定义域 本文并不是《jQuery API 赏析》当我们谈论 API 的设计时不只局限于讨论「某个框架应该如何设计暴露出来的方法」。作为程序世界分治复杂逻辑的基本协作手段广义的 API 设计涉及到我们日常开发中的方方面面。
最常见的 API 暴露途径是函数声明Function Signature以及属性字段Attributes而当我们涉及到前后端 IO 时则需要关注通信接口的数据结构JSON Schema如果还有异步的通信那么事件Events或消息Message如何设计也是个问题甚至依赖一个包Package的时候包名本身就是接口你是否也曾碰到过一个奇葩的包名而吐槽半天
总之「API 设计」不只关乎到框架或库的设计者它和每个开发者息息相关。
提纲挈领 有一个核心问题是我们如何评判一个 API 的设计算「好」在我看来一言以蔽之易用。
那「易用」又是什么呢我的理解是只要能够足够接近人类的日常语言和思维并且不需要引发额外的大脑思考那就是易用。
Don’t make me think. 具体地我根据这些年来碰到的大量反面和正面案例归纳出以下这些要点。按照要求从低到高的顺序如下
达标词法和语法 正确拼写 准确用词 注意单复数 不要搞错词性 处理缩写 用对时态和语态 进阶语义和可用性 单一职责 避免副作用 合理设计函数参数 合理运用函数重载 使返回值可预期 固化术语表 遵循一致的 API 风格 卓越系统性和大局观 版本控制 确保向下兼容 设计扩展机制 控制 API 的抽象级别 收敛 API 集 发散 API 集 制定 API 的支持策略 本文主要以 JavaScript 作为语言示例。
达标词法和语法 高级语言和自然语言英语其实相差无几因此正确地使用英语的词法和语法是程序员最基本的素养。而涉及到 API 这种供用户调用的代码时则尤其重要。
但事实上由于亚洲地区对英语的掌握能力普遍一般……所以现实状况并不乐观 —— 如果以正确使用词法和语法作为达标的门槛很多 API 都没能达标。
正确拼写 正确地拼写一个单词是底线这一点无需赘述。然而 API 中的各种错别字现象仍屡见不鲜即使是在我们阿里这样的大公司内。
曾经有某个 JSON 接口mtop返回这样一组店铺数据以在前端模板中渲染
// json [ { “shopBottom”: { “isTmall”: “false”, “shopLevel”: “916”, “shopLeveImg”: “//xxx.jpg” } } ] 乍一看平淡无奇结果我调试了小半天都没能渲染出店铺的「店铺等级标识图片」即 shopLevelImg 字段。问题到底出在了哪里
眼细的朋友可能已经发现接口给的字段名是 shopLeveImg少了一个 l而在其后字母 I 的光辉照耀下肉眼很难分辨出这个细节问题。
拼错单词的问题真的是太普遍了再比如
某个叫做 toast 的库package.json 中的 name 写成了 taost。导致在 npm 中没能找到这个包。 某个跑马灯组件工厂方法中的一个属性错将 panel 写成了 pannel。导致以正确的属性名初始化时代码跑不起来。 某个 urlwww.ruanyifeng.com/blog/2017/01/entainment.html中错将 entertainment 写成了 entainment……这倒没什么大影响只是 url 发布后就改不了了留下了错别字不好看。 …… 注意到这些拼写错误经常出现在 字符串 的场景中。不同于变量名IDE 无法检查字符串中的单词是否科学、是否和一些变量名一致因此我们在对待一些需要公开出去的 API 时需要尤其注意这方面的问题另一方面更认真地注意 IDE 的 typo 提示单词拼写错误提示也会对我们产生很大帮助。
准确用词 我们知道中英文单词的含义并非一一对应有时一个中文意思可以用不同的英文单词来解释这时我们需要选择使用恰当的准确的词来描述。
比如中文的「消息」可以翻译为 message、notification、news 等。虽然这几个不同的单词都可以有「消息」的意思但它们在用法和语境场景上存在着细微差异
message一般指双方通信的消息是内容载体。而且经常有来有往、成对出现。比如 postMessage() 和 receiveMessage()。 notification经常用于那种比较短小的通知现在甚至专指 iOS / Android 那样的通知消息。比如 new NotificationManager()。 news内容较长的新闻消息比 notification 更重量级。比如 getTopNews()。 feed自从 RSS 订阅时代出现的一个单词现在 RSS 已经日薄西山但是 feed 这个词被用在了更多的地方。其含义只可意会不可言传。比如 fetchWeitaoFeeds()。 所以即使中文意思大体相近也要准确地用词从而让读者更易理解 API 的作用和 上下文场景。
有一个正面案例是关于 React 的。在未使用 ES2015 的React 中有两个方法叫做
React.createClass({ getDefaultProps: function() { // return a dictionary }, getInitialState: function() { // return a dictionary either } }); 它们的作用都是用来定义初始化的组件信息返回值的类型也都一样但是在方法名上却分别用了 default 和 initial 来修饰为什么不统一为一个呢
原因和 React 的机制有关
props 是指 Element 的属性要么是不存在某个属性值后来为它赋值要么是存在属性的默认值后来将其覆盖。所以这种行为default 是合理的修饰词。 state 是整个 Component 状态机中的某一个特定状态既然描述为了状态机那么状态和状态之间是互相切换的关系。所以对于初始状态用 initial 来修饰。 就这么个小小的细节就可一瞥 React 本身的机制足以体现 API 设计者的智慧。
另外最近我还碰到了这样一组事件 API
// event name 1 page.emit(‘pageShowModal’);
// event name 2 page.emit(‘pageCloseModal’); 这两个事件显然是一对正反义的动作在上述案例中表示「显示窗口」时使用了 show表示「关闭窗口」时使用了 close这都是非常直觉化的直译。而事实上成对出现的词应该是show hide、open close。
因此这里必须强调成对出现的正反义词不可混用。在程序世界经常成对出现的词还有
in out on off previous next forward backward success failure … 总之我们可以试着扩充英语的词汇量使用合适的词这对我们准确描述 API 有很大的帮助。
注意单复数 所有涉及到诸如数组Array、集合Collection、列表List这样的数据结构在命名时都要使用复数形式
var shopItems [ // … ];
export function getShopItems() { // return an array }
// fail export function getShopItem() { // unless you really return a non-array } 现实往往出人意表地糟糕前不久刚改一个项目我就碰到了这样的写法
class MarketFloor extends Component { state { item: [ {} ] }; } 这里的 item 实为一个数组即使它内部只有一个成员。因此应该命名为 items 或 itemList无论如何不应该是表示单数的 item。
同时要注意在复数的风格上保持一致要么所有都是 -s要么所有都是 -list。
反过来我们在涉及到诸如字典Dictionary、表Map的时候不要使用复数
// fail var EVENT_MAPS { MODAL_WILL_SHOW: ‘modalWillShow’, MODAL_WILL_HIDE: ‘modalWillHide’, // … }; 虽然这个数据结构看上去由很多 key-value 对组成是个类似于集合的存在但是「map」本身已经包含了这层意思不需要再用复数去修饰它。
不要搞错词性 另外一个容易犯的低级错误是搞错词性即命名时拎不清名词、动词、形容词……
asyncFunc({ success: function() {}, fail: function() {} }); success 算是一个在程序界出镜率很高的词了但是有些同学会搞混把它当做动词来用。在上述案例中成对出现的单词其词性应该保持一致这里应该写作 succeed 和 fail当然在这个语境中最好遵从惯例使用名词组合 success 和 failure。
这一对词全部的词性如下
n. 名词success, failure v. 动词succeed, fail adj. 形容词successful, failed无形容词以过去分词充当 adv. 副词successfully, fail to do sth.无副词以不定式充当 注意到如果有些词没有对应的词性则考虑变通地采用其他形式来达到同样的意思。
所以即使我们大部分人都知道方法命名用动词、属性命名用名词、布尔值类型用形容词或等价的表语但由于对某些单词的词性不熟悉也会导致最终的 API 命名有问题这样的话就很尴尬了。
处理缩写 关于词法最后一个要注意的点是缩写。有时我们经常会纠结首字母缩写词acronym如 DOM、SQL 是用大写还是小写还是仅首字母大写在驼峰格式中又该怎么办……
对于这个问题简单不易混淆的做法是首字母缩写词的所有字母均大写。如果某个语言环境有明确的业界惯例则遵循惯例。
// before export function getDomNode() {}
// after export function getDOMNode() {} 在经典前端库 KISSY 的早期版本中DOM 在 API 中都命名为 dom驼峰下变为 Dom而在后面的版本内统一写定为全大写的 DOM。
另外一种缩写的情况是对长单词简写shortened word如 btn (button)、chk (checkbox)、tpl (template)。这要视具体的语言规范 / 开发框架规范而定。如果什么都没定也没业界惯例那么把单词写全了总是不会错的。
用对时态和语态 由于我们在调用 API 时一般类似于「调用一条指令」所以在语法上一个函数命名是祈使句式时态使用一般现在时。
但在某些情况下我们需要使用其他时态进行时、过去时、将来时。比如当我们涉及到 生命周期、事件节点。
在一些组件系统中必然涉及到生命周期我们来看一下 React 的 API 是怎么设计的
export function componentWillMount() {} export function componentDidMount() {} export function componentWillUpdate() {} export function componentDidUpdate() {} export function componentWillUnmount() {} React 划分了几个关键的生命周期节点mount, update, unmount, …以将来时和过去时描述这些节点片段暴露 API。注意到一个小细节React 采用了 componentDidMount 这种过去时风格而没有使用 componentMounted从而跟 componentWillMount 形成对照组方便记忆。
同样地当我们设计事件 API 时也要考虑使用合适的时态特别是希望提供精细的事件切面时。或者引入 before、after 这样的介词来简化
// will render Component.on(‘beforeRender’, function() {});
// now rendering Component.on(‘rendering’, function() {});
// has rendered Component.on(‘afterRender’, function() {}); 另一方面是关于语态即选用主动语态和被动语态的问题。其实最好的原则就是 尽量避免使用被动语态。因为被动语态看起来会比较绕不够直观因此我们要将被动语态的 API 转换为主动语态。
写成代码即形如
// passive voice, make me confused object.beDoneSomethingBy(subject);
// active voice, much more clear now subject.doSomething(object); 进阶语义和可用性 说了那么多词法和语法的注意点不过才是达标级别而已。确保 API 的可用性和语义才使 API 真正「可用」。
无论是友好的参数设置还是让人甜蜜蜜的语法糖都体现了程序员的人文关怀。
单一职责 单一职责是软件工程中一条著名的原则然而知易行难一是我们对于具体业务逻辑中「职责」的划分可能存在难度二是部分同学仍没有养成贯彻此原则的习惯。
小到函数级别的 API大到整个包保持单一核心的职责都是很重要的一件事。
// fail component.fetchDataAndRender(url, template);
// good var data component.fetchData(url); component.render(data, template); 如上将混杂在一个大坨函数中的两件独立事情拆分出去保证函数function级别的职责单一。
更进一步地假设fetchData 本身更适合用另一个类class来封装则对原来的组件类 Component 再进行拆分将不属于它的取数据职责也分离出去
class DataManager { fetchData(url) {} }
class Component { constructor() { this.dataManager new DataManager(); } render(data, template) {} }
// more code, less responsibility var data component.dataManager.fetchData(url); component.render(data, template); 在文件file层面同样如此一个文件只编写一个类保证文件的职责单一当然这对很多语言来说是天然的规则。
最后视具体的业务关联度而决定是否将一簇文件做成一个包package或是拆成多个。
避免副作用 严格「无 副作用 的编程」几乎只出现在纯函数式程序中现实中的 OOP 编程场景难免触及副作用。因此在这里所说的「避免副作用」主要指的是
函数本身的运行稳定可预期。 函数的运行不对外部环境造成意料外的污染。 对于无副作用的纯函数而言输入同样的参数执行后总能得到同样的结果这种幂等性使得一个函数无论在什么上下文中运行、运行多少次最后的结果总是可预期的 —— 这让用户非常放心不用关心函数逻辑的细节、考虑是否应该在某个特定的时机调用、记录调用的次数等等。希望我们以后设计的 API 不会出现这个案例中的情况
// return x.x.x.1 while call it once this.context.getSPM();
// return x.x.x.2 while call it twice this.context.getSPM(); 在这里getSPM() 用来获取每个链接唯一的 SPM 码SPM 是阿里通用的埋点统计方案。但是用法却显得诡异每调用一次就会返回一个不同的 SPM 串于是当我们需要获得几个 SPM 时就会这样写
var spm1 this.context.getSPM(); var spm2 this.context.getSPM(); var spm3 this.context.getSPM(); 虽然在实现上可以理解 —— 此函数内部维护了一个计数器每次返回一个自增的 SPM D 位但是 这样的实现方式与这个命名看似是幂等的 getter 型函数完全不匹配换句话说这使得这个 API 不可预期。
如何修改之一种做法是不改变此函数内部的实现而是将 API 改为 Generator 式的风格通过形如 SPMGenerator.next() 接口来获取自增的 SPM 码。
另一种做法是如果要保留原名称可以将函数签名改为 getSPM(spmD)接受一个自定义的 SPM D 位然后返回整个 SPM 码。这样在调用时也会更明确。
除了函数内部的运行需可预期外它对外部一旦造成不可预期的污染那么影响将更大而且更隐蔽。
对外部造成污染一般是两种途径一是在函数体内部直接修改外部作用域的变量甚至全局变量二是通过修改实参间接影响到外部环境如果实参是引用类型的数据结构。
曾经也有发生因为对全局变量操作而导致整个容器垮掉的情况这里就不再展开。
如何防止此类副作用发生本质上说需要控制读写权限。比如
模块沙箱机制严格限定模块对外部作用域的修改 对关键成员作访问控制access control冻结写权限等等。 合理设计函数参数 对一个函数来说「函数签名」Function Signature比函数体本身更重要。函数名、参数设置、返回值类型这三要素构成了完整的函数签名。而其中参数设置对用户来说是接触最频繁也最为关心的部分。
那如何优雅地设计函数的入口参数呢我的理解是这样几个要点
优化参数顺序。相关性越高的参数越要前置。
这很好理解相关性越高的参数越重要越要在前面出现。其实这还有两个隐含的意思即 可省略的参数后置以及 为可省略的参数设定缺省值。对某些语言来说如 C调用的时候如果想省略实参那么一定要为它定义缺省值而带缺省值的参数必须后置这是在编译层面就规定死的。而对另一部分灵活的语言来说如 JS将可省参数后置同样是最佳实践。
// bad function renderPage(pageIndex, pageData) {}
renderPage(0, {}); renderPage(1, {});
// good function renderPage(pageData, pageIndex 0) {}
renderPage({}); renderPage({}, 1); 第二个要点是控制参数个数。用户记不住过多的入口参数因此参数能省略则省略或更进一步合并同类型的参数。
由于可以方便地创建 Object 这种复合数据结构合并参数的这种做法在 JS 中尤为普遍。常见的情况是将很多配置项都包成一个配置对象
// traditional $.ajax(url, params, success);
// or $.ajax({ url, params, success, failure }); 这样做的好处是
用户虽然仍需记住参数名但不用再关心参数顺序。 不必担心参数列表过长。将参数合并为字典这种结构后想增加多少参数都可以也不用关心需要将哪些可省略的参数后置的问题。 当然凡事有利有弊由于缺乏顺序就无法突出哪些是最核心的参数信息另外在设定参数的默认值上会比参数列表的形式更繁琐。因此需要兼顾地使用最优的办法来设计函数参数为了同一个目的易用。
合理运用函数重载 谈到 API 的设计尤其是函数的设计总离不开一个机制重载overload。
对于强类型语言来说重载是个很 cool 的功能能够大幅减少函数名的数量避免命名空间的污染。然而对于弱类型语言而言由于不需要在编译时做 type-binding函数在调用阶段想怎么传实参都行……所以重载在这里变得非常微妙。以下着重谈一下什么时候该选择重载什么时候又不该。
Element getElementById(String: id)
HTMLCollection getElementsByClassName(String: names)
HTMLCollection getElementsByTagName(String: name) 以上三个函数是再经典不过的 DOM API而在当初学习它们的时候从 Java 思维转到 JS 思维我就在想这两个问题
为什么要设计成 getSomethingBySomething 这么复杂结构的名字而不是使用 getSomething 做重载 这三个函数只有 getElementById 是单数形式为何不设计为返回 HTMLCollection即使只返回一个成员也可以包一个 Collection 嘛以做成复数形式的函数名从而保持一致性 两个问题中如果第二个问题能解决那么这三个函数的结构将完全一致从而可以考虑解决第一个问题。
先来看问题二。稍微深入下 DOM 知识后就知道id 对于整个 DOM 来说必须是唯一的因此在理论上 getElementsById注意有复数将永远返回仅有 0 或 1 个成员的 Collection这样一来用户的调用方式将始终是 var element getElementsById(id)[0]而这是非常荒谬的。所以 DOM API 设计得没问题。
既然问题二无解那么自然这三个函数没法做成一个重载。退一步说即使问题二能解决还存在另外一个麻烦它们的入口参数都是一样的都是 String对于强类型语言来说参数类型和顺序、返回值统统一样的情况下压根无法重载。因为编译器无法通过任何一个有效的特征来执行不同的逻辑
所以如果入口参数无法进行有效区分不要选择重载。
当然有一种奇怪的做法可以绕过去
// fail function getElementsBy(byWhat, name) { switch(byWhat) { case ‘className’: // … case ‘tagName’: // … } }
getElementsBy(‘tagName’, name); getElementsBy(‘className’, name); 一种在风格上类似重载的但实际是在运行时走分支逻辑的做法……可以看到API 的信息总量并没降低。不过话不能说死这种风格在某些特定场景也有用武之地只是多数情况下并不推荐。
与上述风格类似的是这样一种做法
// get elements by tag-name by default HTMLCollection getElements(String: name)
// if you add a flag, it goes by class-name HTMLCollection getElements(String: name, Boolean: byClassName) 「将 flag 标记位作为了重载手段」—— 在早期微软的一些 API 中经常能见到这样的写法可以说一旦离开了文档就无法编码根本不明白某个 Boolean 标记位是用来干嘛的这大大降低了用户的开发体验以及代码可读性。
这样看起来可重载的场景真是太少了也不尽然在我看来有一种场景很适合用重载批量处理。
Module handleModules(Module: module)
Collection handleModules(Collection: modules) 当用户经常面临处理一个或多个不确定数量的对象时他可能需要思考和判断什么时候用单数 handleModule、什么时候用复数 handleModules。将这种类型的操作重载为一个大抵见于 setter 型操作同时支持单个和批量的处理可以降低用户的认知负担。
所以在合适的时机重载否则宁愿选择「函数名结构相同的多个函数」。原则是一样的保证逻辑正确的前提下尽可能降低用户负担。
对了关于 getElements 那三个 API它们最终的进化版本回到了同一个函数querySelector(selectors)。
使返回值可预期 函数的易用性体现在两方面入口和出口。上面已经讲述了足够多关于入口的设计事项这一节讲出口函数返回值。
对于 getter 型的函数来说调用的直接目的就是为了获得返回值。因此我们要让返回值的类型和函数名的期望保持一致。
// expect ‘a.b.c.d’ function getSPMInString() {
// fail return { a, b, c, d }; } 从这一点上来讲要慎用 ES2015 中的新特性「解构赋值」。
而对于 setter 型的函数调用的期望是它能执行一系列的指令然后去达到一些副作用比如存文件、改写变量值等等。因此绝大多数情况我们都选择了返回 undefined / void —— 这并不总是最好的选择。
回想一下我们在调用操作系统的命令时系统总会返回「exit code」这让我们能够获知系统命令的执行结果如何而不必通过其他手段去验证「这个操作到底生效了没」。因此创建这样一种返回值风格或可一定程度增加健壮性。
另外一个选项是让 setter 型 API 始终返回 this。这是 jQuery 为我们带来的经典启示 —— 通过返回 this来产生一种「链式调用chaining」的风格简化代码并且增加可读性
$(‘div’) .attr(‘foo’, ‘bar’) .data(‘hello’, ‘world’) .on(‘click’, function() {}); 最后还有一个异类就是异步执行的函数。由于异步的特性对于这种需要一定延时才能得到的返回值只能使用 callback 来继续操作。使用 Promise 来包装它们尤为必要。对异步操作都返回一个 Promise使整体的 API 风格更可预期。
固化术语表 在前面的词法部分中曾经提到「准确用词」但即使我们已经尽量去用恰当的词在有些情况下仍然不免碰到一些难以抉择的尴尬场景。
比如我们经常会看到 pic 和 image、path 和 url 混用的情况这两组词的意思非常接近当然严格来说 path 和 url 的意义是明确不同的在此暂且忽略稍不留神就会产生 4 种组合……
picUrl picPath imageUrl imagePath 更糟糕的情况是 imgUrl、picUri、picURL…… 所以在一开始就要 产出术语表包括对缩写词的大小写如何处理、是否有自定义的缩写词等等。一个术语表可以形如
标准术语 含义 禁用的非标准词 pic 图片 image, picture path 路径 URL, url, uri on 绑定事件 bind, addEventListener off 解绑事件 unbind, removeEventListener emit 触发事件 fire, trigger module 模块 mod 不仅在公开的 API 中要遵守术语表规范在局部变量甚至字符串中都最好按照术语表来。
page.emit(‘pageRenderRow’, { index: this.props.index, modList: moduleList }); 比如这个我最近碰到的案例同时写作了 modList 和 moduleList这就有点怪怪的。
另外对于一些创造出来的、业务特色的词汇如果不能用英语简明地翻译就直接用拼音
淘宝 Taobao 微淘 Weitao 极有家 Jiyoujia …… 在这里千万不要把「微淘」翻译为 MicroTaobao……当然专有词已经有英文名的除外如 Tmall。
遵循一致的 API 风格 这一节算得上是一个复习章节。词法、语法、语义中的很多节都指向同一个要点一致性。
一致性可以最大程度降低信息熵。 好吧这句话不是什么名人名言就是我现编的。总而言之一致性能大大降低用户的学习成本并对 API 产生准确的预期。
在词法上提炼术语表全局保持一致的用词避免出现不同的但是含义相近的词。 在语法上遵循统一的语法结构主谓宾顺序、主被动语态避免天马行空的造句。 在语义上合理运用函数的重载提供可预期的甚至一致类型的函数入口和出口。 甚至还可以一致得更细节些只是举些例子
打 log 要么都用中文要么都用英文。 异步接口要么都用回调要么都改成 Promise。 事件机制只能选择其一object.onDoSomething func 或 object.on(‘doSomething’, func)。 所有的 setter 操作必须返回 this。 …… 一份代码写得再怎么烂把某个单词都拼成一样的错误也好过这个单词只出现一次错误。 是的一致性再怎么强调都不为过。
卓越系统性和大局观 不管是大到发布至业界或小到在公司内跨部门使用一组 API 一旦公开整体上就是一个产品而调用方就是用户。所谓牵一发而动全身一个小细节可能影响整个产品的面貌一个小改动也可能引发整个产品崩坏。因此我们一定要站在全局的层面甚至考虑整个技术环境系统性地把握整个体系内 API 的设计体现大局观。
版本控制 80% 的项目开发在版本控制方面做得都很糟糕随心所欲的版本命名、空洞诡异的提交信息、毫无规划的功能更新……人们显然需要一段时间来培养规范化开发的风度但是至少得先保证一件事情
在大版本号不变的情况下API 保证向前兼容。 这里说的「大版本号」即「语义化版本命名」.. 中的第一位 位。
这一位的改动表明 API 整体有大的改动很可能不兼容因此用户对大版本的依赖改动会慎之又慎反之如果 API 有不兼容的改动意味着必须修改大版本号否则用户很容易出现在例行更新依赖后整个系统跑不起来的情况更糟糕的情况则是引发线上故障。
如果这种情况得不到改善用户们就会选择 永远不升级依赖导致更多的潜在问题。久而久之最终他们便会弃用这些产品库、中间件、whatever。
所以希望 API 的提供者们以后不会再将大版本锁定为 0。更多关于「语义化版本」的内容请参考我的另一篇文章《论版本号的正确打开方式》。
确保向下兼容 如果不希望对客户造成更新升级方面的困扰我们首先要做好的就是确保 API 向下兼容。
API 发生改动要么是需要提供新的功能要么是为之前的糟糕设计买单……具体来说改动无外乎增加、删除、修改 三方面。
首先是删除。不要轻易删除公开发布的 API无论之前写得多么糟糕。如果一定要删除那么确保正确使用了「Deprecated」
对于某个不想保留的可怜 API先不要直接删除将其标记为 deprecated 后置入下一个小版本升级比如从 1.0.2 到 1.1.0。
/** * deprecated */ export function youWantToRemove(foo, bar) {}
/** * This is the replacement. */ export function youWantToKeep(foo) {} 并且在 changelog 中明确指出这些 API 即将移除不推荐使用但是目前仍然能用。关于 changelog 的写法建议可参考 更新日志的写法规范。
之后在下一个 大版本 中比如 1.1.0 到 2.0.0删除标记为 deprecated 的部分同时在 changelog 中指明它们已删除。
其次是 API 的修改。如果我们仅仅是修复 bug、重构实现、或者添加一些小特性那自然没什么可说的但是如果想彻底修改一个 API……比如重做入口参数、改写业务逻辑等等建议的做法是
确保原来的 API 符合「单一职责」原则如果不是则修改之。 增加一个全新的 API 去实现新的需求由于我们的 API 都遵循「单一职责」因此一旦需要彻底修改 API意味着新需求和原来的职责已经完全无法匹配不如干脆新增一个 API。 视具体情况选择保留或移除旧 API进入前面所述「删除 API」的流程。 最后是新增 API。事实上即使是只加代码不删代码整体也不一定是向下兼容的。有一个经典的正面案例是
// modern browsers document.hidden false;
// out-of-date browsers document.hidden undefined; 浏览器新增的一个 API用以标记「当前文档是否可见」。直观的设计应该是新增 document.visible 这样的属性名……问题是在逻辑上文档默认是可见的即 document.visible 默认为 true而不支持此新属性的旧浏览器返回 document.visible undefined是个 falsy 值。因此如果用户在代码中简单地以
if (document.visible) { // do some stuff } 做特征检测的话在旧浏览器中就会进入错误的条件分支……而反之以 document.hidden API 来判断则是向下兼容的。
设计扩展机制 毫无疑问在保证向下兼容的同时API 需要有一个对应的扩展机制以可持续发展 —— 一方面便于开发者自身增加功能另一方面用户也能参与进来共建生态。
技术上来说接口的扩展方式有很多比如继承extend、组合mixin、装饰decorate……选择没有对错因为不同的扩展方式适用于不同的场景在逻辑上确实存在派生关系并且需要沿用基类行为同时自定义行为的采用重量级的继承仅仅是扩充一些行为功能但是逻辑上压根不存在父子关系的使用组合而装饰手法更多应用于给定一个接口将其包装成多种适用于不同场景新接口的情况……
另一方面对于不同的编程语言来说由于不同的语言特性……静态、动态等各自更适合用某几种扩展方式。所以到底采用什么扩展办法还是得视情况而定。
在 JS 界有一些经典的技术产品它们的扩展甚至已经形成生态如
jQuery。耳熟能详的 $.fn.customMethod function() {};。这种简单的 mixin 做法已经为 jQuery 提供了成千上万的插件而 jQuery 自己的大部分 API 本身也是基于这个写法构建起来的。 React。React 自身已经处理了所有有关组件实例化、生命周期、渲染和更新等繁琐的事项只要开发者基于 React.Component 来继承出一个组件类。对于一个 component system 来说这是一个经典的做法。 Gulp。相比于近两年的大热 Webpack个人认为 Gulp 更能体现一个 building system 的逻辑 —— 定义各种各样的「任务」然后用「管道」将它们串起来。一个 Gulp 插件也是那么的纯粹接受文件流返回文件流如是而已。 Koa。对于主流的 HTTP Server 来说中间件的设计大同小异接受上一个 request返回一个新的 response。而对天生 Promise 化的 Koa 来说它的中间件风格更接近于 Gulp 了区别仅在于一个是 file stream一个是 HTTP stream。 不只是庞大的框架需要考虑扩展性设计可扩展的 API 应该变成一种基本的思维方式。比如这个活生生的业务例子
// json [ { “type”: “item”, “otherAttrs”: “foo” }, { “type”: “shop”, “otherAttrs”: “bar” } ]
// render logic switch(feed.type) { case ‘item’: console.log(‘render in item-style.’); break; case ‘shop’: console.log(‘render in shop-style.’); break; case ‘other’: default: console.log(‘render in other styles, maybe banner or sth.’); break; } 根据不同的类型渲染一组 feeds 信息商品模块、店铺模块或是其他。某天新增了需求说要支持渲染天猫的店铺模块多显示个天猫标等等于是 JSON 接口直接新增一个 type ‘tmallShop’ —— 这种接口改法很简单直观但是并不好。在不改前端代码的情况下tmallShop 类型默认进入 default 分支导致奇奇怪怪的渲染结果。
考虑到 tmallShop 和 shop 之间是一个继承的关系tmallShop 完全可以当一个普通的 shop 来用执行后者的所有逻辑。用 Java 的表达方式来说就是
// a tmallShop is a shop Shop tmallShop new TmallShop(); tmallShop.doSomeShopStuff(); 将这个逻辑关系反映到 JSON 接口中合理的做法是新增一个 subType 字段用来标记 tmallShop而它的 type 仍然保持为 shop。这样一来即使原来的前端代码完全不修改仍然可以正常运行除了无法渲染出一些天猫店铺的特征。
这里还有一个非常类似的正面案例是 ABS 搭建系统淘宝 FED 出品的站点搭建系统设计的模块 JSON Schema
// json [ { “type”: “string”, “format”: “enum” }, { “type”: “string”, “format”: “URL” } ] 同样采用了 type 为主类型而扩展字段在这里变成了 format用来容纳一些扩展特性。在实际开发中的确也很方便新增各种新的数据结构逻辑。
控制 API 的抽象级别 API 能扩展的前提是什么是接口足够抽象。这样才能够加上各种具体的定语、装饰更多功能。用日常语言举个例子
// abstract I want to go to a place. // when {Today, Tomorrow, Jan. 1st} I want to go to a place. // where I want to go to {mall, cafe, bed}.
// concrete, no extends any more Today I want to go to a cafe for my business. 所以在设计 API 时要高抽象不要陷入具体的实现不要陷入具体的需求要高屋建瓴。
看个实际的案例一个类 React Native 的页面框架想暴露出一个事件「滚动到第二屏」以便页面开发者能监听这个事件从而更好地控制页面资源的加载策略比如首屏默认加载渲染、到第二屏之后再去加载剩下的资源。
但是因为一些实现上的原因页面框架还不能通过页面位移offset来精确地通知「滚动到了第二屏」而只能判断「第二屏的第一个模块出现了」。于是这个事件没有被设计为 secondScreenReached而变成了 secondScreenFirstModuleAppear……虽然 secondScreenFirstModuleAppear 不能精确定义 secondScreenReached但是直接暴露这个具体的 API 实在太糟糕了问题在于
用户在依赖一个非常非常具体的 API给用户造成了额外的信息负担。「第二屏的第一个模块出现了」这很怪异用户根本不关心模块的事情用户关心的只是他是否到达了第二屏。 一旦页面框架能够真正通过页面位移来实现「滚动到第二屏」如果我们暴露的是高抽象的 secondScreenReached那么只需要更改一下这个接口的具体实现即可反之我们暴露的是很具体的 secondScreenFirstModuleAppear就只能挨个通知用户「你现在可以不用依赖这个事件了改成我们新出的 secondScreenReached 吧」 是的抽象级别一般来说越高越好将 API 设计成业务无关的更通用而且方便扩展。但是物极必反对于像我这样的抽象控来说最好能学会控制接口的抽象级别将其保持在一个恰到好处的层次上不要做无休止的抽象。
还是刚才的例子 secondScreenReached我们还可以将其抽象成 targetScreenReached可以支持到达首屏、到达第二屏、第三屏……的事件这样是不是更灵活、更优雅呢并没有 ——
抽象时一定要考虑到具体的业务需求场景有些实现路径如果永远不可能走到就没必要抽出来。比如这个例子中没有人会去关心第三屏、第四屏的事件。 太高的抽象容易造成太多的层次带来额外的耦合、通信等不同层次之间的沟通成本这将会成为新的麻烦。对用户而言也是额外的信息负担。 对于特定的业务来说接口越抽象越通用而越具体则越能解决特定问题。所以思考清楚API 面向的场景范围避免懒惰设计避免过度设计。
收敛 API 集 对于一整个体系的 API 来说用户面对的是这个整体集合而不是其中某几个单一的 API。我们要保证集合内的 API 都在一致的抽象维度上并且适当地合并 API减小整个集合的信息量酌情做减法。
产品开始做减法便是对用户的温柔。 收敛近似意义的参数和局部变量。下面这样的一组 API 好像没什么不对但是对强迫症来说一定产生了不祥的直觉
export function selectTab(index) {}
export function highlightTab(tabIndex) {}
export function gotoPage(index) {} 又是 index 又是 tabIndex 的或许还会有 pageIndex诚然函数形参和局部变量的命名对最终用户来说没有直接影响但是这些不一致的写法仍然能反映到 API 文档中并且对开发者自身也会产生混淆。所以选一个固定的命名风格然后从一而终如果忘了的话回头看一下前文「固化术语表」这一节吧
收敛近似职责的函数。对用户暴露出太多的接口不是好事但是一旦要合并不同的函数是否就会破坏「单一职责」原则呢
不因为「单一职责」本身也要看具体的抽象层次。以下这个例子和前文「合理运用函数重载」中的例子有相似之处但具体又有所不同。
// a complex rendering process function renderPage() {
// too many APIs here renderHeader(); renderBody(); renderSidebar(); renderFooter(); }
// now merged function renderPage() { renderSections([ ‘header’, ‘body’, ‘sidebar’, ‘footer’ ]); }
// call renderSection function renderSections(sections) {}
// and the real labor function renderSection(section) {} 类似于这样避免暴露过多近似的 API合理利用抽象将其合并减小对用户的压力。
对于一个有清晰继承树的场景来说收敛 API 显得更加自然且意义重大 —— 利用多态性Polymorphism构建 Consistent APIs。以下例子来源于 Clean Code JS。
// bad: type-checking here function travelToTexas(vehicle) { if (vehicle instanceof Bicycle) { vehicle.pedal(this.currentLocation, new Location(‘texas’)); } else if (vehicle instanceof Car) { vehicle.drive(this.currentLocation, new Location(‘texas’)); } }
// cool function travelToTexas(vehicle) { vehicle.move(this.currentLocation, new Location(‘texas’)); } 有一个将 API 收敛到极致的家伙恐怕大家都不会陌生jQuery 的 $()。这个风格不正是 jQuery 当年的杀手级特性之一吗
如果 $() 能让我搞定这件事就不要再给我 foo() 和 bar()。 收敛近似功能的包。再往上一级我们甚至可以合并相近的 package。
淘宝 FED 的 Rax 体系类 RN 框架中有基础的组件标签如 (in ali/rax-components)、 (in ali/rax-components)也有一些增强功能的 package如 (in ali/rax-picture)、 (in ali/rax-spmlink)。
在这里后者包之于前者相当于装饰了更多功能是前者的增强版。而在实际应用中也是推荐使用诸如 而禁止使用 。那么在这种大环境下 等基础 API 的暴露就反而变得很扰民。可以考虑将增强包的功能完全合并入基础组件即将 并入 用户只需面对单一的、标准的组件 API。
发散 API 集 这听上去很荒谬为什么一个 API 集合又要收敛又要发散仅仅是为了大纲上的对称性吗
当然不是。存在这个小节是因为我有一个不得不提的案例不适合放在其他段落只能放在这里……不言归正传我们有时的确需要发散 API 集提供几个看似接近的 API以引导用户。因为 —— 虽然这听起来很荒谬 —— 某些情况下API 其实不够用但是用户 没有意识到 API 不够用而是选择了混用、滥用。看下面这个例子
// the func is used here requestAnimationFrame(() {
// what? trigger an event? emitter.emit(‘moduleDidRenderRow’); });
// …and there requestAnimationFrame(() {
// another one here, I guess rendering? this.setState({ // … }); }); 在重构一组代码时我看到代码里充斥着 requestAnimationFrame()这是一个比较新的全局 API它会以接近 60 FPS 的速率延时执行一个传入的函数类似于一个针对特定场景优化过的 setTimeout()但它的初衷是用来绘制动画帧的而不应该用在奇奇怪怪的场景中。
在深入地了解了代码逻辑之后我认识到这里如此调用是为了「延时一丢丢执行一些操作」避免阻塞主渲染线程。然而这种情况下还不如直接调用 setTimeout() 来做延时操作。虽然没有太明确的语义但是至少好过把自己伪装成一次动画的绘制。更可怕的是据我所知 requestAnimationFrame() 的滥用不仅出现在这次重构的代码中我至少在三个不同的库见过它的身影 —— 无一例外地这些库和动画并没有什么关系。
一个可能的推断是调用 requestAnimationFrame(callback) 时不用指定 timeout 毫秒数而 setTimeout(callback, timeout) 是需要的。似乎对很多用户来说前者的调用方式更 cool
所以在市面上有一些 API 好像是「偏方」一般的存在虽然不知道为什么要这么用但是……用它就对了
事实上对于上面这个场景最恰当的解法是使用一个更加新的 API叫做 requestIdleCallback(callback)。这个 API 从名字上看起来就很有语义在线程空闲的时候再执行操作。这完全契合上述场景的需求而且还自带底层的优化。
当然由于 API 比较新还不是所有的平台都能支持。即便如此我们也可以先面向接口编程自己做一个 polyfill
// simple polyfill export function requestIdleCallback(callback) { callback setTimeout(callback, 1e3 / 60); }; 另一个经典的滥用例子是 ES2015 中的「Generator / yield」。
原本使用场景非常有限的生成器 Generator 机制被大神匠心独运地加以改造包装成用来异步代码同步化的解决方案。这种做法自然很有创意但是从语义用法上来说实在不足称道让代码变得非常难读并且带来维护隐患。与其如此还不如仅仅使用 Promise。
令人欣慰的是随后新版的 ES 即提出了新的异步代码关键字「async / await」真正在语法层面解决了异步代码同步化的问题并且新版的 Node.js 也已经支持这种语法。
因此我们作为 API 的开发者一定要提供足够场景适用的 API来引导我们的用户不要让他们做出一些出人意料的「妙用」之举。
制定 API 的支持策略 我们说一组公开的 API 是产品。而产品一定有特定的用户群或是全球的开发者或仅仅是跨部门的同事产品同时有保质期或者说生命周期。
面向目标用户群体我们要制定 API 的支持策略
每一个大版本的支持周期是多久。 是否有长期稳定的 API 支持版本。Long-term Support 如何从旧版本升级。 老旧版本很可能还在运行但维护者已经没时间精力再去管这些历史遗物这时明确地指出某些版本不再维护对开发者和用户都好。当然同时别忘了给出升级文档指导老用户如何迁移到新版本。还有一个更好的做法是在我们开启一个新版本之际就确定好上一个版本的寿命终点提前知会到用户。
还有一个技术上的注意事项那就是大版本间最好有明确的隔离。对于一个复杂的技术产品来说API 只是最终直接面向用户的接口背后还有特定的环境、工具组、依赖包等各种支撑互相之间并不能混用。
比如曾经的经典前端库 KISSY。在业界技术方案日新月异的大潮下KISSY 6 版本已经强依赖了 TNPM阿里内网的 NPM、DEF 套件组淘宝 FED 的前端工具套件虽然和之前的 1.4 版本相比 API 的变化并不大但是仍然不能在老环境下直接使用 6 版本的代码库……这一定程度上降低了自由组合的灵活度但事实上随着业务问题场景的复杂度提升解决方案本身会需要更定制化因此将环境、工具等上下游关联物随代码一起打包做成一整个技术方案这正是业界的现状。
所以隔离大版本制定好 API 支持策略让我们的产品更专业让用户免去后顾之忧。
总结 以上便是我从业以来感悟到的一些「道」三个进阶层次、几十个细分要点不知有没有给读者您带来一丁点启发。
但实际上大道至简。我一直认为程序开发和平时的说话写字其实没有太大区别无非三者 ——
逻辑和抽象。 领域知识。 语感。 写代码就像写作而设计 API 好比列提纲。勤写、勤思了解前人的模式、套路学习一些流行库的设计方法掌握英语、提高语感……相信大家都能设计出卓越的 API。
最后附上 API 设计的经典原则
Think about future, design with flexibility, but only implement for production. 引用 Framework Design Guidelines Page Visibility 的 API 设计 我心目中的优秀 API Clean Code JavaScript 题图只是一张符合上下文的图片并没有更深的含义。
花絮由于文章很长在编写过程中我也不由得发生了「同一个意思却使用多种表达方式」的情况。某些时候这是必要的 —— 可以丰富文字的多样性而有些时候则显得全文缺乏一致性。在发表本文之前我搜索了这些词语「调用者」、「调用方」、「引用者」、「使用者」然后将它们统一修改为我们熟悉的名字「用户」。