2019年了,高手们现在怎么写JavaScript代码

2019年了,高手们现在都是怎么写JavaScript代码的,有没有一些看了就能用上的技巧,近些年有没有什么新的最佳实践?本文就是应StackOverFlow邀请所做的分享,作者慷慨分享了自己使用的方法和经验。读者可以根据实际情况自己选择,就算不接受也算开阔一下思路。原文评论争论比较激烈,也可以进去看看其他人的观点。

作者:Ryland Goldstein
原文链接: Practical Ways to Write Better JavaScript

有什么切实有效的方法写出更好的 JavaScript ,我看谈论这个话题的人不多,本文就是我最常用的一些实践。

1. 使用 TypeScript

改善JS的第一件事就是不要写JS。对不了解的人说明一下,TypeScript(TS) 是JS的严格超集(现有的JS程序都是合法的TS程序)。TypeScript 为原生 JavaScript 加上了(可选的)全面的类型批注。很长一段时间里,TS的支持不是很好,我也不推荐使用。但是现在大多数框架都支持了。知道了TS是什么,我们来看看为什么要用它。

1.1 TypeScript 强制类型安全

类型安全是这样实现的,编译时检查代码中所有的类型都以合法的方式使用。比如你创建了一个函数 foo 接受一个数字类型参数:

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

调用这个 foo 函数时,只能传入数字:

// 正确
console.log(foo(2)); // 7
// 错误
console .log(foo("two")); // 不合法的 TS 代码

除了为代码添加类型的工作量,强制类型安全没有任何副作用。但是这样做的收益不容忽视。类型安全能防止一些常见的错误或者bug,这对弱语法的JS来说是一件幸事。

1.2 TypeScript 让大型应用得以重构

重构大型JS应用真的是一场噩梦,过程中最大的痛苦来自于没有强制参数类型检查,也就是说JS函数一点容错性都没有,假设我有一个函数 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);
  }
}

我必须保证这一千个函数调用,都要正确地修改参数,漏掉一个,程序就会出错。而在TS中是这样的:

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

如你所见,函数 myAPITS 做了与JS一样的修改,但在JS中不会报错的代码,在TS中会通不过编译,所有调用的地方都会报出类型错误。及早发现错误,而不会有bug隐藏在代码中。

1.3 TypeScript 让团队架构沟通更容易

正确设置 TS 后,不先定义接口和类型就很难开始写代码。这就有了一种简洁、易沟通的架构方案。在TS之前,不是没有类似方案,但是都需要做额外的事来达到同样的效果。用TS后,我可以把下面的提案发给同事:

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

我要先写一点代码,但是我可以不用花太多时间就能让同事明白我的进度,并获得反馈。我不确定TS本质上是不是比JS更不容易出错,但是我坚信强制开发者先定义接口能写出更好的代码。开发者肯定更熟悉原生JS,但是我现在做的大多数新项目,从一开始就是用TS。

2. 使用新特性

JavaScript 是世界上最流行的编程语言(写不写之一都行),这是一个数以亿计的人使用过的,超过20年历史的语言。最近很多修改和新特性加到了JS中(严格来说叫ECMAScript),已经从根本上改变了开发者的体验。作为一个在两年前才开始用JS的人,我的优势是没有偏见和历史包袱。能让我选用或者放弃某些功能,做出更加务实的选择。

2.1 async 和 await

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

// 传统的回调
makeHttpRequest('google.com', function (err, result) {
  if (err) {
    console.log('Oh boy, an error');
  } else {
    console.log(result);
  }
});

我不在这里花时间解释上述代码的问题(但是我以前解释过)。为了解决回调问题,JS中新增了 Promise 的概念,使用 Promise 可以不用嵌套就能写出异步逻辑。

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

Promise 最大的优点在于可读性和链式写法,虽然很棒,但Promise写法还是太像以前的回调。那么有没有一种更好的替代方法呢,ECMAScript委员会决定添加一种新的方法来使用promises、async、wait:

// 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 是等价的。

2.2 let 和 const

一直以来,JS中声明变量只有一个范围会变化的限定符: var。它的作用范围规则很独特很有趣,var 的作用域不一致,不明确,可能导致意外发生,也是很多bug的元凶。但是在 ES6 中有了替代方法:let 和 const。这就没必要再用var了。所以以后不要使用 var ,任何用 var 的地方都可以转换为等效的 constlet 代码。

至于何时使用 const 何时使用 let,我都是先用 const , const 强制性更强,不可变,写出的代码更好。并没有太多“真正的场景”需要用到 let,可以说我代码里 let 用的不到 const 的 1/20 。

这里说的“不可变”(immutablish)不是C/C++中的 const 。JS中用 const 声明的变量不是说永远不会改变,而是指向变量的地址不变。简单类型(number、booolean等)内容永远不会变,但是所有对象(class、array、dict),const不保证不改变。

2.3 箭头 => 函数

剪头函数是JS中一种更简洁的声明匿名函数方法。匿名函数就是未明确命名的函数,一般用在回调或者事件处理函数。

// 匿名函数的普通写法
someMethod(1, function () { // has no name
  console.log('called');
});

一般情况下,这种写法不算错,但是匿名函数的作用域很“有趣”,可能会导致意料之外的错误。有了箭头函数,再也不用担心作用域的问题了,下面是用箭头函数实现同样的功能:

// 用箭头定义的匿名函数
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]"

单行的箭头函数有个隐式 return 语句,也不用写前面的括号和函数体的分号。

再多说一点,用 var 声明变量的时候可不能这么用(译注:此处是说单行箭头函数返回对象字面量行不通)。普通的匿名函数还是有他的应用场景,默认用箭头函数,比默认用普通匿名函数能节省不少调试时间。

再次声明 MDN文档真好用。

2.4 扩展运算符 …

提取一个对象的键值对放到另外一个对象是常见的场景。以前有好几种方法实现,但是看起来都有些笨拙:

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]

2.5 模板字符串

在很多语言中,字符串都是最常见的结构之一,所以内置的字符串都做不好也太尴尬了,JS就属于这种语言。有了模板字符串以后,JS变得别具一格,解决了字符串处理中两个大问题,加入变量和换行:

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

代码可以说明一切,是不是很棒。

2.6 对象解构

对象解构可以不遍历数据或者访问对象的 key 就能获取值的方法,对象、数组等(译注:有 Iterator 接口的)都可以使用。

// 以前的写法
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);

还可以在函数参数解构:

function animalParty({ dog, cat }) {}

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

animalParty(myDict);

数组的例子:

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

console.log(a); // prints 10

还有很多新增的现代特性,下面几个我也特别推荐:

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

写并行程序时,你从一开始就会优化,如果一核工作三核围观,就浪费了75%的算力。阻塞式的同步操作,是并行计算的最大敌人。但我们说的是JS,这是一个单线程语言,讨论这个有意义吗?

JS是单线程但不是单文件,没有并行计算,但是有并发。一个HTTP请求可能需要几秒钟甚至几分钟,这时候如果停下来等待请求返回,页面就没法用了。

JavaScript用事件循环解决了这个问题。事件循环遍历已注册的事件,根据JS内部调度和优先级逻辑执行这些事件。这就可以同时发送数千个(译注:浏览器限制一般几个或十几个)HTTP请求,也可以同时加载多个文件。要注意的是:只有正确的使用特性才能利用这一功能,举一个简单的例子, 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 循环是最难并行的构造之一。让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;
}

上面代码只有按顺序迭代执行,才能产生预期结果,如果试图让多个迭代并行,可能就会进入不正确的分支,导致计算结果无效。建议仅在绝对有必要时才使用传统的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 with index
// 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 并不是以顺序迭代,而是将每个元素分别提交给用户定义的事件处理函数。大多数情况下,各个迭代之间没有依赖关系,因此可以并行。也不是for循环不能做同样的事,可以这样写:

const items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

async function testCall() {
  // do async stuff here
}

for (let i = 0; i < 10; i += 1) {
  testCall();
}

是的for循环也可以,但是没有map简单,来看 map 版本:

const items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
items.map(async (item) => {
 // do async stuff here
});

map这样写就可以了,用map还有一个好处就是如果你想等待所有异步操作完成后做处理,也可以很简单的实现,如果是用for循环的话,就需要自己管理一个数组才行,来看map怎么做:

const items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const allResults = await Promise.all(items.map(async (item) => {
  // do async stuff here
}));

简单吧。

许多情况下,for循环与map和forEach的性能相当,可能更好一些,我仍然认为牺牲几个循环的时间,换来更清晰的API完全值得。未来也有机会改进请求数据的部分,而不用操心循环部分。for循环没有区分各种情况,要优化就麻烦的多了。

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

Lint和格式化

代码没有统一的风格,就很难阅读理解。因此高端的代码不管用任何语言写,都需要有统一并且合理的风格。JS语言生态系统广泛,代码格式和指南有很多种,一定要选择一种来格式化你的代码。选不选比选择哪个重要的多。

我看到很多人问到底是该用 eslint 还是 prettier。在我看来他们的用途不同,需要结合使用。Eslint 是一个老牌的lint工具,比起关注样式,更多的注重正确性。我在用AirBNB的Lint规则的配置,使用这个配置的时候以下的语句将会Lint失败:

var fooVar = 3; // airbnb 规则禁止使用 "var"

eslint 对开发的好处显而易见。从本质上说,它确保你的代码遵循最佳实践,有些规则不一定全部适合,可根据实际情况定制。

Prettier 是代码格式化工具,不太关心正确性,更注格式统一。 Prettier 不会抱怨你有没有使用 var,但是会自动对齐代码中的所有括号。我自己的开发过程中,推送代码到 git 之前总会跑一遍 Prettier 把代码格式化。还可以在每次提交git时自动格式化代码,保证入库的代码都有一致的风格。

4. 测试代码

写测试改善JS代码,是一种间接但是非常有效的方法。JS生态系统中有很多成熟的测试工具,没有一个工具可以处理所有的情况,我建议你熟悉各种测试工具,根据需求选择使用。

4.1 测试驱动程序 – Ava

Github上的Avajs

测试驱动程序,简单来说就是一个框架,在高层次提供结构和实用工具,根据测试需求不同,常常与其他特定的测试工具一起使用。

Ava 在功能和简洁之间取得很好的平衡。它的异步特性和并发运行架构是我最喜欢的。更快的运行节约了开发者的时间也为公司省钱。Ava保持简洁的同时,还内置了一些很不错的特性,比如断言。

替代选择:Jest,Mocha,Jasmin。

4.2 Spies 和 Stubs – Sinon

Github 上的 Sinon

spies 是一个函数分析工具,比如一个函数被调用了多少次,每次调用的详情等数据。

Sinon 可以做很多事,有几个工具超级好用, spiesstub 是最出色的两个,功能丰富而且语法简洁,

替代选择: testdouble

4.3 模拟请求 – Nock

github上的nock

HTTP模拟就是拼装http请求,模拟服务器端行为,结合自己的代码方便测试过程。

HTTP模拟写起来可能很麻烦,但是 nock 让这个过程方便不少,它直接重写 nodejs 的内置请求,并拦截传出的请求,让你可以完全控制响应。

替代选择:我没听说有。

4.4 Web 自动化测试 – Selenium

Github上的 Selenium

推荐 Selenium 让我心情复杂,由于它是Web自动化最流行的选择,拥有庞大的社区和在线资源。不幸的是,学习曲线相当陡峭,而且也依赖了太多的外部代码库。话虽如此,它也是唯一真正免费的选择,因此除非你是在进行企业级的Web自动化,否则 Selenium 都可以胜任。

替代选择:Cypress,,PhantomJS

永无止境的旅程

跟世间大多数事情一样,写出更好的JavaScript是一个持续的过程,代码总是可以更干净,总会有新特性被添加,测试怎么也不嫌多,让人应接不暇疲于应付,但是有这么多可以改进的地方,如果按照自己的步伐,一步一个脚印的做,不知不觉中就会成为JavaScript高手。