JavaScript 是万众瞩目的力量。它是世界上最流行的编程语言。它容易理解,有丰富的学习资源,对初学者非常友好。JavaScript 有着庞大的资源库,对小公司和大企业都颇具吸引力。庞大的 JS 工具和库生态系统为开发者的生产力带来了福音。只用 JS 一种语言就能统一前端和后端,于是就可以在整个技术栈中使用同一套技能组合。
JavaScript 的力量就像核能
JavaScript 提供了许多工具和选项,但它对开发者几乎没有任何限制。让没有经验的人使用 JavaScript,就像是给一个两岁的孩子一盒火柴和一罐汽油一样…
JavaScript 的力量就像核能——既可以用来为城市供电,也可以用来摧毁一切。用 JavaScript 构建东西很容易。但构建既可靠又易维护的软件就不是什么轻松的事情了。
代码可靠性
在建造大坝时,工程师首先关注的是可靠性。在没有任何规划或安全措施的前提下修建大坝是很危险的。建造桥梁、生产飞机、汽车…都是一回事。如果汽车不安全不可靠,那么像马力、引擎响声,还是内饰中使用的皮革类型这些都无关紧要了。
同样,每位软件开发者的目标都是编写可靠的软件。如果代码有缺陷且不可靠,那么其他问题都是小巫见大巫了。编写可靠代码的最佳方法是什么?那就是写出简洁的代码。简单的反面是复杂。因此软件开发者首要的责任应该是降低代码的复杂度。
开发者经验丰富的标志就是能编写可靠的软件。可靠性还包括可维护性——只有可维护的代码库才是可靠的。
虽然我是函数式编程的坚定信徒,但我不会安利什么内容。我只是会从函数式编程中引用一些概念,并演示如何在 JavaScript 中应用它们。
我们真的需要软件可靠性吗?这取决于你自己。有些人认为客户能凑合用软件就行了,我不敢苟同。事实上,如果软件不可靠且难以维护,那么其他问题根本就不重要了。谁会购买一辆随机刹车和加速的汽车呢?谁会希望自己的手机每天断线几次,随机重启呢?
内存不足
我们怎样开发可靠的软件?
首先考虑可用内存的大小。我们的程序应该尽量节约内存,永远不会耗尽所有可用内存,以避免性能下降。
这和编写可靠的软件有什么关系?人类的大脑也有自己的内存,叫做工作记忆。我们的大脑是宇宙中已知最强大的机器,但它有自己的一套限制——我们只能在工作记忆中保存大约五条信息。
对于编程工作来说,这意味着简单的代码消耗的脑力资源更少,进而提升我们的效率,并产出更可靠的软件。本文和一些 JavaScript 工具将帮助你实现这一目标!
初学者的注意事项
本文中我将大量使用 ES6 函数。简单回顾一下:
工具
JavaScript 的最大优势之一是丰富的可用工具。没有其他哪种编程语言有如此庞大的工具和库生态系统。
我们应该充分利用这些工具,尤其是 ESLint(https://eslint.org/)。ESLint 是静态代码分析工具,可以找到代码库中潜在的问题,维持代码库的高质量。而且 linting 是一个完全自动化的过程,可以防止低质量代码进入代码库。
很多人没能充分利用 ESLint——他们只用了预建配置,如 eslint-config-airbnb 而已。很可惜这只是 ESlint 的皮毛。JavaScript 是一种没有限制的语言。而 linting 设置不当会带来深远的影响。
熟练的开发者不仅知道该用哪些函数,还会知道不应该使用哪些 JS 函数。JavaScript 是一种古老的语言,有很多包袱,所以区分好坏是很重要的。
ESLint 配置
你可以按如下方式设置 ESLint。我建议逐一熟悉这些建议,并将 ESLint 规则逐一纳入你的项目中。先将它们配置为 warn,习惯了可以将一些规则转为 error。
在项目的根目录中运行:
然后在项目的根目录中创建一个.eslintrc.yml 文件:
如果你使用的是像 VSCode 这样的 IDE,请安装ESLint插件。
你还可以从命令行手动运行 ESLint:
重构的重要性
重构是降低现有代码复杂度的过程。如果使用得当,它将成为我们对付可怕的技术债务怪物的最佳武器。如果没有持续的重构,技术债务将不断积累,反过来又会拖累开发者。
重构就是清理现有代码,同时确保代码仍能正常运行的过程。重构是软件开发中的良好实践,是健康组织中开发流程的一部分。
需要注意的是,在重构之前最好将代码纳入自动化测试。重构时很容易在无意中破坏现有功能,全面的测试套件是预防潜在风险的好办法。
复杂度的最大源头
这可能听起来很奇怪,但代码本身就是复杂度的最大源头。实际上无代码就是编写安全可靠软件的最佳途径。但很多时候我们做不到无代码,所以备选答案就是减少代码量。更少的代码意味着更少的复杂度,也意味着产生错误的潜在区域更少。有人说初级开发者编写代码,而高级开发者删除代码——不能同意更多。
长文件
人类是懒惰的。懒惰是一种短期生存策略,舍弃对生存不重要的事物来节省能量。
有些人很懒,不守规矩。人们将越来越多的代码放入同一个文件中…如果文件的长度没有限制,那么这些文件往往会无限增长下去。根据我的经验,超过 200 行代码的文件就太难理解、太难维护了。长文件还意味着程序可能处理的工作太多了,违反了单一责任原则。
怎么解决这个问题?只需将大文件分解为更细粒度的模块即可。
建议的 ESLint 配置:
长函数
复杂度的另一大来源是漫长而复杂的函数,很难推理;而且函数的职责太多,很难测试。
例如下面的 express.js 代码片段是用来更新博客条目的:
函数体长度为 38 行,执行以下操作:分析 post id、查找现有博客帖子、验证用户输入、在输入无效的情况下返回验证错误、更新帖子集合,并返回更新的博客帖子。
显然它可以重构为一些较小的函数。路由处理程序可能看起来像这样:
推荐的 ESLint 配置:
复杂函数
复杂函数往往就是长函数,反之亦然。函数之所以变复杂可能有很多因素,但其中嵌套回调和圈复杂度较高都是比较容易解决的。
嵌套回调往往导致回调地狱。可以用 promise 处理回调,然后使用 async-await 就能削弱其影响。
来看一个带有深度嵌套回调的函数:
圈复杂度
函数复杂度的另一大来源是圈复杂度。它指的是给定函数中的语句(逻辑)数,诸如 if 语句、循环和 switch 语句。这些函数很难推理,要尽量避免使用。这是一个例子:
推荐的 ESLint 配置:
另一个降低代码复杂度的方法是声明式代码,稍后会具体展开。
可变状态
状态是存储在内存中的临时数据,例如对象中的变量或字面量。状态本身是无害的,但可变状态是软件复杂度的最大源头之一,与面向对象结合时尤其如此(稍后将详细介绍)。
人脑的局限
如前所述,人类大脑是宇宙中已知最强大的机器。然而我们的大脑很难应付状态,因为我们在工作记忆中一次只能容纳五件事情。我们很容易推理一段代码本身的作用,但涉及到它对代码库中变量的影响时就会糊涂了。
使用可变状态编程容易让人精神错乱。只要放弃可变状态,我们的代码就能变得更加可靠。
可变状态的问题
举个例子:
错误很难看出来:我们改变函数参数时不小心修改了原始项目的价格。本来应该是 10,实际上改成了 13。
我们构造和返回一个不可变的新对象来解决这个问题:
请记住,使用 ES6 spread 等运算符复制时会生成浅拷贝,而不是深拷贝——它不会复制任何嵌套属性。如果上面的 item 具有 item.seller.id 这样的内容,则新 item 的 seller 仍将引用旧 item,这是不行的。在 JavaScript 中使用不可变状态时,一些较为稳健的方法包括 immutable.js 和 Ramda lense 等。我将在另一篇文章中介绍这些选项。
建议的 ESLint 配置:
不要 push 数组
在数组突变中使用像 push 这样的方法也存在同样的问题:
数组 b 本应保持不变的。我们创建一个新数组而不是调用 push 就可以了。
构造新数组来避免问题:
不确定性
不确定性是说程序在输入不变的情况下输出却无法确定。明明 2 + 2 == 4,但不确定性程序不一定得出这个结果。
虽然可变状态本身并不是不确定性的,但它会使代码更容易出现不确定性(如上所示)。讽刺的是最流行的编程范式(OOP 和命令式编程)特别容易产生不确定性。
不变性
想要避免可变性的缺陷,最好的方法就是改用不变性。不变性一个很大的话题,我可能会专门撰文讨论它。
建议的 ESLint 配置:
避免使用 Let 关键字
我们不应该用 var 在 JavaScript 中声明变量,同样我们也应该避免使用 let 关键字。用 let 声明的变量可以被重新分配,让代码更难推理。本质上这也是人脑工作记忆的一种限制。使用 let 关键字编程时,我们必须记住所有副作用和潜在的极端情况。我们可能不小心为变量分配一个不正确的值,结果就得浪费时间来调试了。
单元测试受其影响最严重。多数测试都是并行开展的,所以在多个测试之间共享可变状态是一种灾难。
let 关键字的替代方案当然是 const 关键字。虽然它不能保证不变性,但它会禁止重新分配,使代码更易推理。大多数情况下,重新给变量赋值的代码可以被提取到一个单独的函数中。来看一个例子:
将同一个示例提取到一个函数中:
一开始不用 let 可能会不习惯,但这样代码会更简洁易懂。我很久没用 let 了,一点都不想它。
养成不使用 let 关键字编程的习惯可以让你更有条理。你必须将代码分解为更小,更易于管理的函数组合,进而让函数的职责更清晰,更好地分离关注点,并使代码库更具可读性和可维护性。
建议的 ESLint 配置:
面向对象编程
Java 是自 MS-DOS 以来计算行业最令人痛苦的事情。”
{“class”:“right”}—— 名 Alan Kay,面向对象编程的发明者
面向对象编程是一种用来组织代码的流行编程范例。本节会讨论 Java、C#、JavaScript、TypeScript 等语言中使用的主流 OOP 的局限。我不会批判正确的 OOP(例如 SmallTalk)。
如果你认为在开发软件时必须使用 OOP,则可以跳过本节。
优秀程序员和普通程序员
优秀的程序员会编写好的代码,普通的程序员编写错误的代码,无论什么编程范式都是如此。编程范式要做的是防止普通的程序员搞出太多破坏。不管你愿不愿意,你都会和普通的程序员共事。可惜 OOP 没有足够的约束力来防止他们造成巨大的伤害。
OOP 的初衷是帮助程序员打理代码库。讽刺的是人们认为 OOP 可以降低复杂度,但它提供的工具似乎只是在增加复杂度而已。
OOP 不确定性
OOP 代码容易出现不确定性——它严重依赖可变状态,不像函数式编程那样可以保证输出不变,让代码更难推理。涉及并发时这种问题更为严重。
共享可变状态
“我觉得用可变对象构建大型对象图会让面向对象的大型程序越来越复杂。你得试着理解并记住你在调用一种方法时会发生什么,副作用会是什么。“
{“class”:“right”}——Rich Hickey,Clojure 的创造者
可变状态很棘手,而 OOP 共享可变状态的引用(而非值)的做法让这个问题更严重了。这意味着几乎任何东西都可以改变给定对象的状态。开发者必须牢记与当前对象交互的每个对象的状态,很快就会超过人脑工作记忆的上限。人脑要推理这种复杂的可变对象是极为困难的。它消耗了宝贵且有限的认知资源,并且不可避免地会导致大量缺陷。
共享可变对象的引用是为了提高效率而做出的权衡,过去这可能还很合理。但如今硬件性能飞速提升,我们应该更加关注开发者的效率而不是代码的执行效率。而且有了现代工具的支持,不变性几乎不会影响性能。
OOP 说全局状态是万恶之源。但讽刺的是 OOP 程序基本上就是一个大型全局状态(因为一切都是可变的并且通过引用共享)。
最小知识原则没什么用途,只是鸵鸟政策而已——不管你怎样访问或改变一个状态,共享的可变状态仍然是共享的可变状态。领域驱动设计是一种有用的设计方法,能解决一些复杂度问题。但它仍然没有解决不确定性这个根本问题。
信噪比
很多人都在关注 OOP 程序的不确定性引入的复杂度。他们提出了许多设计模式试图解决这些问题。但这只是自欺欺人,并引入了更加不必要的复杂度。
正如我之前所说,代码本身是复杂度的最大来源,代码总是越少越好。OOP 程序通常带有大量的样板代码,以及设计模式提供的“创可贴”,这些都会降低信噪比。这意味着代码变得更加冗长,人们更难看到程序的原始意图,使代码库变得非常复杂,不太可靠。
我坚信现代 OOP 是软件复杂度的最大来源之一。的确有使用 OOP 构建的成功项目,但这并不意味着此类项目不会受无谓的复杂度影响。
JavaScript 中的 OOP 尤其糟糕,因为这种语言缺少静态类型检查、泛型和接口等。JavaScript 中的 this 关键字相当不可靠。
如果我们的目标是编写可靠的软件,那么我们应该努力降低复杂度,理想情况下应该避免使用 OOP。如果你有兴趣了解更多信息,请务必阅读我的另一篇文“OOP,万亿美元的灾难”。
This 关键字
this 关键字的行为总是飘忽不定。它很挑剔,在不同的环境中可能搞出来完全不同的东西。它的行为甚至取决于谁调用了一个给定的函数。使用 this 关键字经常会导致细小而奇怪的错误,很难调试。
拿它做面试问题可能很有意思,但关于 this 关键字的知识其实也没什么意义,只能说明应聘者花了几个小时研究过最常见的 JavaScript 面试问题。
真实世界的代码不应该那么容易出错,应该是可读的,不让人感到莫名其妙。This 是一个明显的语言设计缺陷,别再用它了。
建议的 ESLint 配置:
声明式代码
声明式编程是一个流行术语,我们来看看它的实质和优点。
如果你是编程老手,可能你一直在用命令式的编程风格,这种风格描述了一系列实现结果所需的步骤。相比之下声明式风格是描述期望的结果,而不是具体的步骤。
典型的声明式语言有 SQL 和 HTML。甚至包括 React 中的 JSX!
我们不会指定具体的步骤来告诉数据库如何获取数据,而是使用 SQL 来描述要获取的内容:
在命令式 JavaScript 中这样表示:
在声明式 JavaScript 中使用实验性流水线运算符(https://github.com/tc39/proposal-pipeline-operator):
我觉得第二种方法看起来更简洁,更具可读性。
优先使用表达式而非语句
编写声明式代码时应优先使用表达式而非语句。表达式始终返回一个值,而语句是用来执行操作的,不返回任何结果。这在函数式编程中也称为“副作用”。顺便说一句,前面讨论的状态突变也是副作用。
常用的语句有 if、return、switch、for、while。
来看一个简单的例子:
这可以很容易地重写为三元表达式(这是声明式的):
如果 lambda 函数中只有 return 语句,那么 JavaScript 也允许我们不用 lambda 语句:
函数长度从六行减到了一行。声明式编程太有用了!
语句还会引起副作用和突变,进而产生不确定性,降低代码的可读性和可靠性。重新排序语句是不安全的,因为它们的执行依赖编写的顺序。语句(包括循环)难以并行化,因为它们在其作用域之外突变状态。使用语句会带来更多复杂度,进而产生额外的头脑负担。
相比之下,表达式可以安全地重新排序,不会产生副作用,易于并行化。
声明式编程需要努力才能熟练
学习声明式编程不是一蹴而就的,尤其是多数人学的都是命令式编程。声明式编程需要全新的思维模式。要熟悉声明式编程,学习使用没有可变状态的程序是一个好的开始——既不用 let 关键字,也不改变状态。我可以肯定,熟悉声明式编程后你的代码会变得美观优雅。
建议的 ESLint 配置:
避免将多个参数传递给函数
JavaScript 不是静态类型语言,无法保证函数使用正确和符合预期的参数来调用。ES6 引入了许多出色的功能,解构对象就是其中之一,它也可用于函数参数。
下面的代码很直观吗?你能立刻说出参数是什么吗?我反正不能。
下面的例子呢?
显然后者比前者更具可读性。从不同模块发起的函数调用尤其符合这种情况。使用参数对象还能让参数不受编写顺序的影响。
建议的 ESLint 配置:
优先从函数返回对象
下面这段代码的函数签名是什么?它返回了什么?是返回用户对象?用户 ID?操作状态?不看上下文很难回答。
从函数返回一个对象能明确开发者的意图,使代码更易读:
控制执行流程中的异常
我们经常会遇到莫名其妙的错误,错误信息什么细节都没有。虽说老师教我们在发生意外情况时抛出异常,但这并不是处理错误的最佳方法。
异常破坏了类型安全
即使在静态类型语言中,异常也会破坏类型安全性。根据其签名所示,函数 fetchUser(id: number): User 应该返回一个用户。函数签名没说如果找不到用户就抛出异常。如果需要异常,那么更合适的函数签名是:fetchUser(…): User|throws UserNotFoundError。当然这种语法在任何语言中都是无效的。
推理程序的异常是很难的——人们可能永远不会知道函数是否会抛出异常。我们是可以把函数调用都包装在 try/catch 块中,但这不怎么实用,并且会严重影响代码的可读性。
异常破坏了函数组合
异常使函数组合难以利用。下面的例子中如果某篇帖子无法获取,服务器将返回 500 内部服务器错误。
如果其中一个帖子被删除,但由于一些模糊的 bug,用户仍然试图访问它怎么办?这将显著降低用户体验。
用元组处理错误
一种简单的错误处理方法是返回包含结果和错误的元组,而不是抛出异常。JavaScript 的确不支持元组,但可以使用[error,result]形式的双值数组很容易地模拟它们。顺便说一下,这也是 Go 中错误处理的默认方法:
有时异常也有用途
异常仍然在开发中占有一席之地。一个简单的原则是你来问自己一个问题——我是否能接受程序崩溃?抛出的任何异常都可能摧毁整个流程。就算我们仔细考虑了所有极端情况,异常仍然是不安全的,迟早让程序崩溃。只有在你能接受程序崩溃时才抛出异常,比如说开发者错误或数据库连接失败等。
所谓异常,只应该用在出现例外情况,程序别无选择只能崩溃的时候。为了控制执行流程应该尽量避免抛出和捕获异常。
让它崩溃——避免捕获异常
于是就可以总结出处理错误的终极规则——避免捕获异常。如果我们打算让程序崩溃就可以抛出错误,但永远不应该捕获这些错误。这也是 Haskell 和 Elixir 等函数式语言推荐的方法。
唯一例外是使用第三方 API 的情况。即使在这种情况下也最好还是使用包装函数的辅助函数来返回[error,result]元组代替异常。你可以使用像saferr这样的工具。
问问自己谁应该对错误负责。如果答案是用户,则应该正常处理错误。我们应该向用户显示友好的消息,而不是什么 500 内部服务器错误。
可惜这里没有 no-try-catch ESLint 规则。最接近的是 no-throw 规则。出现特殊情况时,你抛出异常就应该预料到程序的崩溃。
建议的 ESLint 配置:
部分应用函数
部分应用函数(Partial function application)可能是史上最佳的代码共享机制之一。它摆脱了 OOP 依赖注入。你无需使用典型的 OOP 样板也能在代码中注入依赖项。
以下示例包装了因抛出异常(而不是返回失败的响应)而臭名昭著的Axios库)。这些库根本没必要,尤其是在使用 async/await 时。
下面的例子中我们使用 currying 和部分应用函数来保证一个不安全函数的安全性。
注意,safeApiCall 函数写为 func = (params) => (data) => {…}。这是函数式编程中的常用技术,称为 currying;它与部分应用函数关系密切。使用 params 调用时,func 函数返回另一个实际执行作业的函数。换句话说,该函数部分应用了 params。
它也可以写成:
请注意,依赖项(params)作为第一个参数传递,实际数据作为第二个参数传递。
为了简化操作你可以使用 saferr npm 包,它也适用于 promise 和 async/await:
几个小技巧
列举一些方便的小技巧。它们不一定让代码更可靠,但可以让我们的工作更轻松。有些技巧广为人知,有些不然。
来一点类型安全
JavaScript 不是静态类型语言。但我们可以按需标记函数参数来使代码更加健壮。下面的代码中,所需的值没能传入时将抛出错误。请注意它不适用于空值,但仍然可以很好地防范未定义的值。
短路条件和评估
大家都熟悉短路条件,它能用来访问嵌套对象中的值。
如果值为虚值(falsey),那么短路评估可以用来提供替代值:
赋值两次
给值赋值两次可以将任何值转换为布尔值。请注意,任何虚值都将转换为 false,这可能并不总是你想要的。数字绝不能这样做,因为 0 也将被转换为 false。
使用现场日志来调试
我们可以利用短路和 console.log 的虚值输出来调试函数代码,甚至 React 组件:
总结
你真的需要代码可靠性吗?答案取决于你自己的决定。你的组织是否认为开发者的效率取决于完成的 JIRA 故事?你们是不是所谓的函数工厂,工作只是生产更多的函数?如果是这样的话还是换个工作吧。
本文的内容在实践中非常有用,值得反复阅读。好好看看这些技巧,ESLint 规则也都试一试吧。
英文原文:https://medium.com/better-programming/js-reliable-fdea261012ee
评论