怎样编写更好的 JavaScript 代码

404 阅读13分钟

作者:Ryland G

翻译:疯狂的技术宅

原文:dev.to/taillogs/pr…

未经许可严禁转载

我看到没有多少人谈论改进 JavaScript 代码的实用方法。以下是我用来编写更好的 JS 的一些顶级方法。

使用TypeScript

改进你 JS 代码要做的第一件事就是不写 JS。TypeScript(TS)是JS的“编译”超集(所有能在 JS 中运行的东西都能在 TS 中运行)。 TS 在 vanilla JS 体验之上增加了一个全面的可选类型系统。很长一段时间里,整个 JS 生态系统对 TS 的支持不足以让我觉得应该推荐它。但值得庆幸的是,那养的日子已经过去很久了,大多数框架都支持开箱即用的 TS。假设我们都知道 TS 是什么,现在让我们来谈谈为什么要使用它。

TypeScript 强制执行“类型安全”。

类型安全描述了一个过程,其中编译器验证在整个代码段中以“合法”方式使用所有类型。换句话说,如果你创建一个带有 number 类型参数的函数 foo

function foo(someNum: number): number {
  return someNum + 5;
}

只应使给 foo 函数提供 number 类型的参数:

good

console.log(foo(2)); // prints "7"

no good

console.log(foo("two")); // invalid TS code

除了向代码添加类型的开销之外,使用类型安全没有任何缺点。额外的好处太大了而不容忽视。类型安全提供额外级别的保护,以防止出现常见的错误或bug,这是对像 JS 这样无法无天的语言的祝福。

无法无天-主演:shia lebouf

电影:无法无天,主演 shia lebouf

Typescript 类型,可以重构更大的程序

重构大型 JS 程序是一场真正的噩梦。重构 JS 过程中引起痛苦的大部分原因是它没有强制按照函数的原型执行。这意味着 JS 函数永远不会被“误用”。如果我有一个由 1000 种不同的服务使用的函数 myAPI

function myAPI(someNum, someString) {
  if (someNum > 0) {
    leakCredentials();
  } else {
    console.log(someString);
  }
}

我稍微改变了函数的原型:

function myAPI(someString, someNum) {
  if (someNum > 0) {
    leakCredentials();
  } else {
    console.log(someString);
  }
}

这时我必须 100% 确定每个使用此函数的位置(足足有1000个)都正确地更新了用法。哪怕我漏掉一个地方,函数也可能就会失效。这与使用 TS 的情况相同:

之前

function myAPITS(someNum: number, someString: string) { ... }

之后

function myAPITS(someString: string, someNum: number) { ... }

正如你所看到的,我对 myAPITS 函数进行了与 JavaScript 对应的相同更改。但是这个代码不是产生有效的 JavaScript,而是导致无效的 TypeScript,因为现在使用它的 1000 个位置提供了错误的类型。而且由于我们之前讨论过的“类型安全”,这 1000 个问题将会阻止编译,并且你的函数不会失效(这非常好)。

TypeScript使团队架构沟通更容易。

正确设置 TS 后,如果事先没有定义好接口和类,就很难编写代码。这也提供了一种简洁的分享、交流架构方案的方法。在 TS 出现之前,也存在解决这个问题的其他方案,但是没有一个能够真正的解决它,并且还需要你做额外的工作。例如,如果我想为自己的后端添加一个新的 Request 类型,我可以使用 TS 将以下内容发送给一个队友。

interface BasicRequest {
  body: Buffer;
  headers: { [header: string]: string | string[] | undefined; };
  secret: Shhh;
}

尽管我不得不编写一些代码,但是现在可以分享自己的增量进度并获得反馈,而无需投入更多时间。我不知道 TS 本质上是否能比 JS 更少出现“错误”,不给我强烈认为,迫使开发人员首先定义接口和 API,从而产生更好的代码是很有必要的。

总的来说,TS 已经发展成为一种成熟且更可预测的 vanilla JS替代品。肯定仍然需要 vanilla JS,但是我现在的大多数新项目都是从一开始就是 TS。

使用现代功能

JavaScript 是世界上最流行的编程语言之一。你可能会认为,有大约数百万人使用的 JS 现在已经有 20 多岁了,但事实恰恰相反。JS 已经做了很多改变和补充(是的我知道,从技术上说是 ECMAScript),从根本上改变了开发人员的体验。作为近两年才开始编写 JS 的人,我的优势在于没有偏见或期望。这导致了我关于要使用哪种语言更加务实。

async 和 await

很长一段时间里,异步、事件驱动的回调是 JS 开发中不可避免的一部分:

传统的回调

makeHttpRequest('google.com', function (err, result) {
  if (err) {
    console.log('Oh boy, an error');
  } else {
    console.log(result);
  }
});

我不打算花时间来解释上述问题(我以前写过此类文章)。为了解决回调问题,JS 中增加了一个新概念 “Promise”。 Promise 允许你编写异步逻辑,同时避免以前基于回调的代码嵌套问题的困扰。

Promises

makeHttpRequest('google.com').then(function (result) {
  console.log(result);
}).catch(function (err) {
  console.log('Oh boy, an error');
});

Promise 优于回调的最大优点是可读性和可链接性。

虽然 Promise 很棒,但它们仍然有待改进。到现在为止,写 Promise 仍然感觉不到“原生”。为了解决这个问题,ECMAScript 委员会决定添加一种利用 promise,asyncawait 的新方法:

async 和 await

try {
  const result = await makeHttpRequest('google.com');
  console.log(result);
} catch (err) {
  console.log('Oh boy, an error');
}

需要注意的是,你要 await 的任何东西都必须被声明为 async

在上一个例子中需要定义 makeHttpRequest

async function makeHttpRequest(url) {
  // ...
}

也可以直接 await 一个 Promise,因为 async 函数实际上只是一个花哨的 Promise 包装器。这也意味着,async/await 代码和 Promise 代码在功能上是等价的。所以随意使用 async/await 并不会让你感到不安。

let 和 const

对于大多数 JS 只有一个变量限定符 varvar 在处理方面有一些非常独特且有趣的规则。 var 的作用域行为是不一致而且令人困惑的,在 JS 的整个生命周期中导致了意外行为和错误。但是从 ES6 开始有了 var 的替代品:constlet。几乎没有必要再使用 var 了。使用 var 的任何逻辑都可以转换为等效的 constlet 代码。

至于何时使用 constlet,我总是优先使用 constconst 是更严格的限制和 “永固的”,通常会产生更好的代码。我仅有 1/20 的变量用 let 声明,其余的都是 const

我之所以说 const 是 “永固的” 是因为它与 C/C++ 中的 const 的工作方式不同。 const 对 JavaScript 运行时的意义在于对 const 变量的引用永远不会改变。这并不意味着存储在该引用中的内容永远不会改变。对于原始类型(数字,布尔等),const 确实转化为不变性(因为它是单个内存地址)。但对于所有对象(类,数组,dicts),const 并不能保证不变性。

箭头函数 =>

箭头函数是在 JS 中声明匿名函数的简明方法。匿名函数即描述未明确命名的函数。通常匿名函数作为回调或事件钩子传递。

vanilla 匿名函数

someMethod(1, function () { // has no name
  console.log('called');
});

在大多数情况下,这种风格没有任何“错误”。 Vanilla 匿名函数在作用域方面表现得“有趣”,这可能导致许多意外错误。有了箭头函数,我们就不必再担心了。以下是使用箭头函数实现的相同代码:

匿名箭头函数

someMethod(1, () => { // has no name
  console.log('called');
});

除了更简洁之外,箭头函数还具有更实用的作用域行为。箭头函数从它们定义的作用域继承 this

在某些情况下,箭头函数可以更简洁:

const added = [0, 1, 2, 3, 4].map((item) => item + 1);
console.log(added) // prints "[1, 2, 3, 4, 5]"

第 1 行的箭头函数包含一个隐式的 return 声明。不需要具有单线箭头功能的括号或分号。

在这里我想说清楚,这和 var 不一样,对于 vanilla 匿名函数(特别是类方法)仍有效。话虽这么说,但如果你总是默认使用箭头函数而不是vanilla匿名函数的话,最终你debug的时间会更少。

像以往一样,Mozilla 文档是最好的资源

展开操作符

提取一个对象的键值对,并将它们作为另一个对象的子对象添加,是一种很常见的情况。有几种方法可以实现这一目标,但它们都非常笨重:

const obj1 = { dog: 'woof' };
const obj2 = { cat: 'meow' };
const merged = Object.assign({}, obj1, obj2);
console.log(merged) // prints { dog: 'woof', cat: 'meow' }

这种模式非常普遍,但也很乏味。感谢“展开操作符”,再也不需要这样了:

const obj1 = { dog: 'woof' };
const obj2 = { cat: 'meow' };
console.log({ ...obj1, ...obj2 }); // prints { dog: 'woof', cat: 'meow' }

最重要的是,这也可以与数组无缝协作:

const arr1 = [1, 2];
const arr2 = [3, 4];
console.log([ ...arr1, ...arr2 ]); // prints [1, 2, 3, 4]

它可能不是最重要的 JS 功能,但它是我最喜欢的功能之一。

文字模板(字符串模板)

字符串是最常见的编程结构之一。这就是为什么它如此令人尴尬,以至于本地声明字符串在许多语言中仍然得不到很好的支持的原因。在很长一段时间里,JS 都处于“糟糕的字符串”系列中。但是文字模板的添加使 JS 成为它自己的一个类别。本地文字模板,方便地解决了编写字符串,添加动态内容和编写桥接多行的两个最大问题:

const name = 'Ryland';
const helloString =
`Hello
 ${name}`;

我认为代码说明了一切。多么令人赞叹。

对象解构

对象解构是一种从数据集合(对象,数组等)中提取值的方法,无需对数据进行迭代或显的式访问它的 key:

旧方法

function animalParty(dogSound, catSound) {}

const myDict = {
  dog: 'woof',
  cat: 'meow',
};

animalParty(myDict.dog, myDict.cat);

解构

function animalParty(dogSound, catSound) {}

const myDict = {
  dog: 'woof',
  cat: 'meow',
};

const { dog, cat } = myDict;
animalParty(dog, cat);

不过还有更多方式。你还可以在函数的签名中定义解构:

解构2

function animalParty({ dog, cat }) {}

const myDict = {
  dog: 'woof',
  cat: 'meow',
};

animalParty(myDict);

它也适用于数组:

解构3

[a, b] = [10, 20];

console.log(a); // prints 10

还有很多你应该使用现代功能。以下是我认为值得推荐的:

始终假设你的系统是分布式的

编写并行化程序时,你的目标是优化你一次性能够完成的工作量。如果你有 4 个可用的 CPU 核心,并且你的代码只能使用单个核心,则会浪费 75% 的算力。这意味着,阻塞、同步操作是并行计算的最终敌人。但考虑到 JS 是单线程语言,不会在多个核心上运行。那这有什么意义呢?

尽管 JS 是单线程的,它仍然是可以并发执行的。发送 HTTP 请求可能需要几秒甚至几分钟,在这期间如果 JS 停止执行代码,直到响应返回之前,语言将无法使用。

JavaScript 通过事件循环解决了这个问题。事件循环,即循环注册事件并基于内部调度或优先级逻辑去执行它们。这使得能够“同时”发送1000个 HTTP 请求或从磁盘读取多个文件。这是一个问题,如果你想要使用类似的功能,JavaScript 只能这样做。最简单的例子是 for 循环:

let sum = 0;
const myArray = [1, 2, 3, 4, 5, ... 99, 100];
for (let i = 0; i < myArray.length; i += 1) {
  sum += myArray[i];
}

for 循环是编程中存在的最不并发的构造之一。在上一份工作中,我带领一个团队花了几个月的时间尝试将 R 语言中的 for-loops 转换为自动并行代码。这基本上是一个不可能的任务,只有通过等待深度学习技术的改善才能解决。并行化 for 循环的难度来自一些有问题的模式。用 for 循环进行顺序执行的情况是比较罕见的,但它们无法保证循环的可分离性:

let runningTotal = 0;
for (let i = 0; i < myArray.length; i += 1) {
  if (i === 50 && runningTotal > 50) {
    runningTotal = 0;
  }
  runningTotal += Math.random() + runningTotal;
}

如果按顺序执行迭代,此代码仅生成预期结果。如果你尝试执行多次迭代,则处理器可能会根据不准确的值进入错误地分支,从而使结果无效。如果这是 C 代码,我们将会进行不同的讨论,因为使用情况不同,编译器可以使用循环实现相当多的技巧。在 JavaScript 中,只有绝对必要时才应使用传统的 for 循环。否则使用以下构造:

map

// in decreasing relevancy :0
const urls = ['google.com', 'yahoo.com', 'aol.com', 'netscape.com'];
const resultingPromises = urls.map((url) => makHttpRequest(url));
const results = await Promise.all(resultingPromises);

带索引的 map

// in decreasing relevancy :0
const urls = ['google.com', 'yahoo.com', 'aol.com', 'netscape.com'];
const resultingPromises = urls.map((url, index) => makHttpRequest(url, index));
const results = await Promise.all(resultingPromises);

for-each

const urls = ['google.com', 'yahoo.com', 'aol.com', 'netscape.com'];
// note this is non blocking
urls.forEach(async (url) => {
  try {
    await makHttpRequest(url);
  } catch (err) {
    console.log(`${err} bad practice`);
  }
});

下面我将解释为什么这是对传统 for 循环的改进:不是按顺序执行每个“迭代”,而是构造诸如 map 之类的所有元素,并将它们作为单独的事件提交给用户定义的映射函数。这将直接与运行时通信,各个“迭代”彼此之间没有连接或依赖,所以能够允许它们同时运行。我认为现在应该抛弃一些循环,应该去使用定义良好的 API。这样对任何未来数据访问模式实现的改进都将使你的代码受益。 for 循环过于通用,无法对同一模式进行有意义的优化。

map 和 forEach 之外还有其他有效的异步选择,例如 for-await-of。

Lint 你的代码并强制使用一致的风格

没有一致风格的代码难以阅读和理解。因此,用任何语言编写高端代码的一个关键就是具有一致和合理的风格。由于 JS 生态系统的广度,有许多针对 linter 和样式细节的选项。我不能强调的是,你使用一个 linter 并强制执行同一个样式(随便哪个)比你专门选择的 linter 或风格更重要。最终没人能够准确地编写代码,所以优化它是一个不切实际的目标。

有很多人问他们是否应该用 eslintprettier。对我来说,它们的目的是有很大区别的,因此应该结合使用。 Eslint 是一种传统的 “linter”,大多数情况下,它会识别代码中与样式关系不大的问题,更多的是与正确性有关。例如,我使用eslint与 AirBNB 规则。如果用了这个配置,以下代码将会强制 linter 失败:

var fooVar = 3; // airbnb rules forebid "var"

很明显,eslint 为你的开发周期增加价值。从本质上讲,它确保你遵循关于“is”和“isn't”良好实践的规则。因此 linters 本质上是固执的,只要你的代码不符合规则,linter 可能就会报错。

Prettier 是一个代码格式化程序。它不太关心“正确性”,更关注一致性。 Prettier 不会对使用 var 提出异议,但会自动对齐代码中的所有括号。在我的开发过程中,在将代码推送到 Git 之前,总是处理得很​​漂亮。很多时候让 Prettier 在每次提交到 repo 时自动运行是非常有意义的。这确保了进入源码控制系统的所有代码都有一致的样式和结构。

测试你的代码

编写测试是一种间接改进你代码但非常有效的方法。我建议你熟悉各种测试工具。你的测试需求会有所不同,没有哪一种工具可以处理所有的问题。 JS 生态系统中有大量完善的测试工具,因此选择哪种工具主要归结为个人偏好。一如既往,要为你自己考虑。

Test Driver - Ava

测试驱动 — Ava

AvaJS on Github

测试驱动只是简单的框架,可以提供非常高级别的结构和工具。它们通常与其他特定测试工具结合使用,这些工​​具根据你的实际需求而有所不同。

Ava 是表达力和简洁性的完美平衡。 Ava 的并行和独立的架构是我的最爱。快速运行的测试可以节省开发人员的时间和公司的资金。Ava 拥有许多不错的功能,例如内置断言等。

替代品:Jest,Mocha,Jasmine

Spies 和 Stubs — Sinon

Sinon on Githubgithub.com/sinonjs/sin…

Spies 为我们提供了“功能分析”,例如调用函数的次数,调用了哪些函数以及其他有用的数据。

Sinon 是一个可以做很多事的库,但只有少数的事情做得超级好。具体来说,当涉及到 Spies 和 Stubs 时,sinon非常擅长。功能集丰富而且语法简洁。这对于 Stubs 尤其重要,因为它们为了节省空间而只是部分存在。

替代方案:testdouble

模拟 — Nock

Nock on Githubgithub.com/nock/nock?s…

HTTP 模拟是伪造 http 请求中某些部分的过程,因此测试人员可以注入自定义逻辑来模拟服务器行为。

http 模拟可能是一种真正的痛苦,nock 使它不那么痛苦。 Nock 直接覆盖 nodejs 内置的 request 并拦截传出的 http 请求。这使你可以完全控制 http 响应。

替代方案:我真的不知道 :(

网络自动化 - Selenium

Selenium on Githubgithub.com/SeleniumHQ/…

我对推荐 Selenium 有着一种复杂的态度。由于它是 Web 自动化最受欢迎的选择,因此它拥有庞大的社区和在线资源集。不幸的是学习曲线相当陡峭,并且它依赖许多外部库。尽管如此,它是唯一真正的免费选项,所以除非你做一些企业级的网络自动化,否则还是 Selenium 最适合这个工作。

欢迎关注前端公众号:前端先锋,领取前端工程化实用工具包。