现代 JavaScript 技术
追求纯粹的简洁且可扩展的语法
JavaScript是一种优美而复杂的语言,它拥有诸多优势,并且这些优势还在逐年增长。鉴于它在将用户界面交互性和响应式网页设计引入互联网方面发挥了重要作用,该语言及其社区如此受欢迎也就不足为奇了。虽然有时略显复杂,但 JavaScript 易于上手,并且由于它是在客户端执行,因此能够带来更快的用户体验。
很长一段时间以来,JavaScript 都被视为问题重重、缺陷百出。这并非语言本身的问题,而是它所运行的平台——浏览器——的问题。当时的生态系统存在诸多缺陷,因为其中充斥着太多各自为政的派系——尤其是微软的介入更是雪上加霜。在此期间, Mozilla一直保持着理性,但直到 Chrome 获得了足够的市场份额,才促使各方重新围绕引擎的架构和外观标准进行合作。V8 引擎正是在此基础上构建了Node.js。如今,JavaScript 作为一种支持服务器端执行的完整编程语言,为现代 Web 应用提供强大动力,并能轻松扩展到各种技术栈中。
方法
我既是Accord项目(一个用于智能法律合约的开源项目)的维护者,也是全栈工程师,这些经历让我深刻体会到JavaScript强大的应用潜力。此外,我也非常渴望学习并采用更高效、更实用的JavaScript实践。我将分享这篇文章,既作为对他人有用的参考,也作为我日后学习的历史记录。我希望能够以此为基础,在后续更深入的文章中拓展本文涵盖的主题。
Accord 项目的大部分工作都使用 JavaScript,并辅以一些领域特定语言。为了构建一个可靠的技术栈,确保智能合约的稳定性和高效性,Accord 项目主要依赖 JavaScript,同时也使用OCaml和Ergo。JavaScript提供了一套最佳的工具,能够应对各种用例和环境。我们选择 JavaScript 的原因在于它的适用性强、库丰富且易于使用。该语言的语法既简洁又富有表现力。
Accord 项目的核心代码库包含超过 25 万行代码。加上我们的模板库和 UI 组件,代码量接近一百万行。
大纲:
→方法
→基本原理
→工作流程
→操作
→函数
→异步
→函数式编程
→结论
→资源
基本面
可理解的
编写代码文档。代码的可读性对于编程至关重要,因为最终需要人来解读代码才能进行协作。与其为了节省几个字符而用单个字母命名变量,不如编写足够详细的代码,以便日后或其他人能够轻松阅读。此外,注释和文档(例如JSDocs格式)对于构建易于访问且可与团队或其他人员共享的代码极其有用。
乍一看似乎有些多余,但尽可能地对代码进行注释,可以让你在几个月后重新审视某个项目或与同事合作时,通过内置文档轻松查阅。
全球
避免在全局作用域中使用变量。避免在全局作用域中使用变量的原因有很多。首先,由于函数执行时 JavaScript 需要遍历作用域,从 `in` 到 `out` 才能找到全局对象,因此会降低性能。其次,这样做也存在安全漏洞,因为定义在全局作用域中的函数可以通过浏览器调用。这一点在函数式编程部分会再次提及。
变量
停止使用 `const` var。其作用域行为不一致且令人困惑,可能导致错误。ES6 引入了 `const`const和 `const` let。尽量严格使用`const` const,只有let在无法避免的情况下才使用 `const`。`const` 的限制更多,并且不可重新赋值,但并非完全不可变。变量将始终指向同一个对象或原始值,但变量所持有的值并非不可变。尽管如此,这仍然是未来最佳实践。
命名
稍微跑题一下,程序员们可能会在命名规范上花费十倍的精力,但却很难让他们的语言具有包容性。
花时间编写描述性强、措辞恰当、易于阅读和理解的代码,对代码的未来发展大有裨益。
这一点对于那些想要教育他人的人来说尤为重要;变量名应该有助于解释代码的运行机制并提供上下文。即使是初次接触这段代码的人,也应该能够大致了解其含义。使用动词!例如,布尔变量可以以“是”开头is...,函数也可以使用动作动词。
这里可以找到一些不错的参考资料:基于语法的命名约定
工作流程
可维护性的关键在于将逻辑放在正确的位置,避免杂乱无章。项目或代码库的结构方式会极大地影响其可理解性和可追随性。
进口订单
从细粒度层面来看,不同模块的导入顺序可以通过可预测的模式来减少混乱。你使用的具体结构并不重要,重要的是要有某种结构:
/* Packages */
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import * as R from 'ramda';
/* Styled Components */
import * as SC from './styles';
/* Components */
import Navigation from './Navigation';
/* Actions */
import * as ACT from './actions';
/* Utilities */
import { navigateToClause } from '../utilities';
模块化
需要牢记的目标是保持包、模块、函数和作用域的精简。这样做能显著提高代码的重用性和链式调用能力。可以将相似的函数或步骤繁多的函数合并到一个模块或类中。尽量保持函数的简洁性,并将复杂的流程分步骤执行。
当一个文件超过 300-400 行代码时,它很可能已经过于臃肿且难以维护。此时,创建新的模块和文件夹来拆分流程会带来诸多好处。不妨将项目想象成一棵枝繁叶茂的大树,而不是一座堆积如山的代码。
ESLint是个很棒的工具,能帮上大忙。尽量保持文件缩进深度不超过四到五层。这样可以保持代码的专用性,并有助于清理无用代码。多个只执行一个简单流程的函数比一个执行多个功能的函数更有用。大型函数只能用于一种用途,而小型函数则可以在项目的多个流程中使用。公开这些小型辅助函数可以为项目创建一个强大的 API 基础。
优秀的代码无需全部重写也能得到改进。
隔离代码
一个函数应该只有一个目的,而不是执行多个操作。这个目的不应该是副作用,但我们会在函数式编程部分详细讨论这一点。
一个人为设计的例子就是封装条件语句:
// NO:
if (props.contract.errors === [] && isEmpty(parseErrors)) {
// ... code
}
// YES:
const errorsExist = (props, parseErrors) => props.contract.errors === [] && isEmpty(parseErrors);
if (errorsExist(contractProps, parseErrors)) {
// ... code
}
保障条款
构建具有会导致错误或空结果的边界情况的函数的一个好方法是尽早检查这些无效结果。如果此条件不满足或存在无效用例,则大部分计算将被阻止,因为我们已经知道结果。这被称为“跳板模式”或“守卫子句”。
const parseContract = (contract) => {
// Does a contract exist
if (!contract) return "Error, no contract!";
// Are there already parsed errors
if (contract.currentErrors.length > 0) return contract.currentErrors;
// Parse the contract
return contract.clauses.map((clause) => doSomething(clause));
}
这不仅可以优化代码,还可以鼓励人们以一种考虑到处理极端情况的方式来思考函数和流程。
更漂亮 + 林廷
本文的主题是代码应该易于阅读和理解。这需要一致的样式和结构。任何代码检查工具(linter)都会非常有用。ESLint 就是一款代码检查工具,它可以识别代码正确性问题,例如使用 `<br>` 标签的警告var。Prettier是一款代码格式化工具,它可以识别统一性和一致性问题,并自动对齐括号等。建议将两者结合使用。
如果您需要一个好的起点, StandardJS和 ESLint 的预定义配置都是很好的代码检查规则来源。
运营
解构
解构赋值可以通过保持变量简短并尽早从对象中提取出来,从而节省大量输入和代码行数。ECMAScript 6引入了这项功能,允许访问任何对象或模块中的特定字段,并立即将其赋值给一个变量。
对象:
// NO
const generateText = contract => {
const clauses = contract.body.clauses;
const text = contract.body.text;
const errors = contract.errors;
Cicero.parseContract( clauses, text )
};
// YES
const generateText = contract => {
const { body: { clauses, text }, errors }, = contract;
Cicero.parseContract( clauses, text )
};
数组(跳过元素, ,):
// NO
const lettersArray = [ "A", "B", "C", "D", "E", "F" ];
const firstLetter = lettersArray[0]; // "A"
const thirdLetter = lettersArray[2]; // "C"
// YES
const [ firstLetter, , thirdLetter, ...remaining ] = lettersArray; // remaining = [ "D", "E", "F" ]
函数(类似于对象):
// NO
const generateText = (contract) => {
if(contract.errors) return "Errors exist!"
if(!contract.clauses) return "No clauses exist!"
}
// YES
const generateText = ({ errors = null, clauses = null }) => {
if(errors) return "Errors exist!"
if(!clauses) return "No clauses exist!"
}
默认值
解构赋值时,可以为参数设置默认值。这还可以向用户表明哪些值可以传入,哪些值是必需的。
const generateText = ({
name = "Stock Contract",
language = "English",
text = "No text exists yet!",
errors = [],
clauses = [],
}) => { Cicero.parseContract( clauses, text ) }
如果未传递值时不应抛出错误,则默认值可能很有用。
三元
该运算符的工作方式类似于逻辑运算符和if...else语句,它由三个部分组成:
- 布尔条件
- 真值时的返回值
- 如果返回值为假值,则返回值为
// condition ? truthyResult : falsyResult
const errorArrayLength = errors => ( errorsExist(errors) ? errors.length : 'No' );
尽量避免使用否定条件句——检查某事物是否存在,而不是检查它是否存在。
传播
另一种对象解构形式是展开运算符,它允许从数据中提取值,而无需显式地遍历数据。这在Redux和函数式编程中很常见,因为它是一种在不改变对象本身的情况下添加值的简便方法——通过展开运算符复制一个旧对象,然后向其中添加新值。
const firstHalf = [ "A", "B", "C" ];
const secondHalf = [ "D", "E", "F" ];
const lettersArray = [ ...firstHalf, ...secondHalf ];
// lettersArray = [ "A", "B", "C", "D", "E", "F" ];
const contract = {
text = "No text exists yet!",
errors = []
};
const contractWithClauses = {
...contract,
clauses = []
};
模板字面量
此功能允许将动态内容嵌入字符串中,并编写跨越多行的字符串。这些内容用反引号和模板字面量片段()表示${}。
// NO
var contractTitle = ("Contract Name: " + contract.name + ", Errors: " + contract.errors.length + ".")
// YES
const contractTitle = `Contract Name: ${contract.name}, Errors: ${contract.errors.length}.`
// OTHER USES
const conditionalTitle = `${contractExist() ? ('Contract Name: ' + contract.name) : 'No contract exists.'}`
const multipleLines = `Hello,
Good to meet you`
函数
限制范围
函数应该只做一件事。一旦开始执行多个操作,测试和推理就会变得困难。尽量保持函数不超过一层抽象——必要时拆分函数。
// NO
const parseContract = contract => {
contract.forEach(contract => {
const contractText = generateText(contract);
if (contractText.noErrors()) {
execute(contract);
};
});
};
// YES
const isContractValid = contract => {
const contractText = generateText(contract);
return contractText.noErrors();
};
const parseContract = contracts => contracts.filter(isContractValid).forEach(execute);
箭
这种新的函数语法使代码表达更加简洁清晰。此外,它还具有更实用的作用域行为,能够继承this函数定义时的作用域。
以前,函数会这样写:
function someFunction(input) {
// ... code
}
现在我们对同一事物进行如下定义:
const someFunction = input => {
// ... code
}
如果函数只返回简单的值,我们可以使用隐式return语句将其写在一行中:
const add = (a, b) => a + b;
const createObject = (a, b) => ({ a, b });
参数
为了提高可测试性,应尽量限制传递给函数的参数数量。理想情况下,参数数量应少于三个。通常情况下,如果参数数量达到三个或更多,则说明该函数可能试图执行多项任务,应该将其拆分并合并。
连锁
目前令人沮丧的一个原因是无法轻松访问对象中的嵌套值。目前可能使用类似这样的方法:
if(contract && contract.firstProp && contract.firstProp.secondProp && contract.firstProp.secondProp.thirdProp && contract.firstProp.secondProp.thirdProp.fourthProp.data) execute(contract.firstProp.secondProp.thirdProp.fourthProp.data)
可怕。
这样做的原因是,如果您直接执行最后一行,可能会遇到这种错误:
TypeError: Cannot read property ‘fourthProp’ of undefined
TC39(决定哪些功能成为 JavaScript 标准一部分的技术委员会)已将可选链提案推进到接受的后期阶段。
我非常期待这一点,因为这样上面的代码就会显示成这样:
const data = contract?.firstProp?.secondProp?.thirdProp?.fourthProp?.data
if(data) execute(data)
如果任何属性不存在,则挖掘结束并返回undefined。
目前还有一种解决方案是Ramda,它使用一个名为 `get_code()` 的函数path在运行时安全地执行代码,而不会undefined在控制台中出现错误。
异步
我之前写过关于Redux Saga 的异步操作,但这次我会更侧重于async/和 promise。await
异步是指某些操作独立于主程序流程之外进行;计算机的设计初衷就是如此。处理器不会暂停运行,等待副作用发生后再恢复操作。JavaScript 默认是同步的单线程代码,无法并行执行。然而,JavaScript 的设计初衷是为了响应用户操作,而用户操作本质上是异步的。浏览器(JavaScript 运行的平台)提供了一系列 API 来处理这些功能。此外,Node.js引入了非阻塞 I/O 环境,将这一概念扩展到文件、网络调用等领域。
当这个外部函数被传递给单独的线程(例如 API 调用)时,它会返回一个回调函数,该函数会作为参数传递给另一个函数。然后,外部函数内部会调用这个回调函数来完成操作。
异步 + 等待
以前,JavaScript 依赖 Promise 和回调函数来处理异步代码,这很容易导致回调地狱。这种基于 Promise 的语法糖提供了一种更流畅的异步代码处理方式,但不能与普通回调函数或 Node 回调函数一起使用。现在,异步代码可以像同步代码一样编写。与 Promise 类似,这些异步函数也是非阻塞的。
使用此async关键字的函数需要在其前面加上 `this` 关键字,并且await只能在包含此关键字的函数中使用。此async函数隐式返回一个 Promise,该 Promise 最终会解析为函数内部返回的值。
// Promises
const outsideRequest = () =>
retrieveData()
.then(data => {
execute(data)
return “Executed”
})
// Async/Await
const outsideRequest = async () => {
execute(await retrieveData())
return “Executed”
}
优势:+清晰性——代码更少,更易读。+错误处理——try/catch可处理同步和异步代码。+条件语句——更直接地处理动态结果。+调试——错误堆栈跟踪更容易追踪。+等待任何条件语句。
函数式编程
编程领域主要有两种范式:命令式和声明式。命令式编程要求对函数的每个细节步骤都进行详细说明,而声明式编程则倾向于直接表达计算逻辑,无需描述具体的流程。
祈使句:如何做某事
例如:一步一步地指导某人烤蛋糕
陈述句:做什么
例如:通过描述蛋糕来告诉某人烤蛋糕
函数式编程是一种声明式编程范式。它既强大又令人望而生畏,将计算视为数学函数的求值,避免了状态和可变数据的变更。在 JavaScript 中,函数是一等公民,这意味着它们被视为值,可以像数据一样使用。函数可以被常量和变量引用,可以作为参数传递给其他函数,也可以作为函数的返回值。
在函数式编程中,输出值仅取决于传入的参数,并且对于相同的输入,输出值始终相同。相比之下,面向对象程序通常依赖于状态,并且对于相同的参数,在不同的时间点可能会产生不同的结果。
纯函数
纯函数是指遵循函数式编程某些准则的函数,即对于相同的参数,它返回相同的结果(幂等性),并且不会产生可观察到的副作用。这使得纯函数具有引用透明性,其优点之一是代码更容易测试。利用这一概念,我们可以对这些函数进行记忆化。
副作用
函数式编程避免了可变性,例如修改全局对象或全局作用域中的值。函数式编程的目标是创建数据的新副本,通过添加或删除操作来改变原始数据,而不是直接修改原始数据。
关键在于避免诸如对象间共享状态或使用任何对象都可以写入的可变数据之类的陷阱。非纯操作(例如写入文件)应仅限于单个服务执行——尽量减少不纯的功能。
在 JavaScript 中,基本数据类型按值传递,而对象按引用传递。因此,如果一个函数修改了数组,任何引用该数组的其他函数都会受到影响。这是函数式编程力求避免的巨大风险;如果两个独立且不相关的函数接收相同的输入,但其中一个函数修改了该输入,那么另一个函数就会出错。频繁地克隆大型对象会严重影响性能,但有一些性能优异的库可以解决这个问题,例如Ramda。
拉姆达

这是一个优秀的库,为 JavaScript 中的函数式编程提供了额外的实用功能,使创建代码管道变得更加容易。所有函数都会自动柯里化,这使得该库非常实用。他们的 wiki 中有一个非常有用的章节,可以帮助您找到“我应该使用哪个函数”。
柯里化使我们能够高效地使用高阶函数(以函数为输入并返回函数的函数)和闭包。柯里化后的函数不再是带有多个参数的函数,而是接受一个参数并返回一个同样接受一个参数的函数。这些函数可以串联起来,形成一个管道。
管道
Ramda 非常适合在管道中组合函数,但 JavaScript 是一种不断发展的语言,很快就会原生支持此功能。TC39 目前有一个关于管道操作 符 (Pipeline Operator)的提案。在此期间,不妨了解一下 Ramda,它提供了一些非常强大的工具!
结论
这种老生常谈的说法早已过时,许多人对 JavaScript 的批评也失去了道理。我猜想,有些人需要十倍的时间才能消除他们的疑虑。这门语言高效实用,适用于多种环境和应用。它在整个技术领域都有许多令人兴奋的用例,能够触及整个技术栈。
抛开这个领域的准入门槛和不良风气不谈,能够接触到如此多的不同领域,确实能为社区带来更协作、更丰富的经验。这门语言拥有巨大的力量。我们可以用 Electron 中的 JavaScript 构建跨平台桌面应用,用 React Native 构建移动应用,用 Node.js 构建服务器端解决方案。
虽然这门语言一直在不断发展,但并非每周都会有新的框架出现。这种进步是好事,而且这门语言背后的社区也相当积极进取、勇于创新。
如有任何疑问或反馈,请随时与我联系。
资源
社区
教育
图书
博客
播客
杂项
- JavaScript:了解其奇特之处
- Wes Bos 的 30 天 JavaScript 挑战及相应视频:JS 30
- 趣味功能
- Switch Case 与 Object Litial:
- 静态类型
- 功能前端

