JavaScript 编码规范 - ESNext 补充篇(草案)

1 前言

随着 ECMAScript 的不断发展,越来越多更新的语言特性将被使用,给应用的开发带来方便。本文档的目标是使 ECMAScript 新特性的代码风格保持一致,并给予一些实践建议。

本文档仅包含新特性部分。基础部分请遵循 JavaScript Style Guide

由于 ECMAScript 依然在快速的不断发展,本文档也将可能随时保持更新。更新内容主要涉及对新增的语言特性的格式规范化、实践指导,引擎与编译器环境变化的使用指导。

虽然本文档是针对 ECMAScript 设计的,但是在使用各种基于 ECMAScript 扩展的语言时(如 JSX、TypeScript 等),适用的部分也应尽量遵循本文档的约定。

2 代码风格

2.1 文件

[建议] ESNext 语法的 JavaScript 文件使用 .js 扩展名。
[强制] 当文件无法使用 .js 扩展名时,使用 .es 扩展名。

解释:

某些应用开发时,可能同时包含 ES 5和 ESNext 文件,运行环境仅支持 ES5,ESNext 文件需要经过预编译。部分场景下,编译工具的选择可能需要通过扩展名区分,需要重新定义ESNext文件的扩展名。此时,ESNext 文件必须使用 .es 扩展名。

但是,更推荐使用其他条件作为是否需要编译的区分:

  1. 基于文件内容。
  2. 不同类型文件放在不同目录下。

2.2 结构

2.2.1 缩进

[建议] 使用多行模板字符串时遵循缩进原则。当空行与空白字符敏感时,不使用多行模板字符串。

解释:

4 空格为一个缩进,换行后添加一层缩进。将起始和结束的 ` 符号单独放一行,有助于生成 HTML 时的标签对齐。

为避免破坏缩进的统一,当空行与空白字符敏感时,建议使用 多个模板字符串普通字符串 进行连接运算,也可使用数组 join 生成字符串。

示例:

  1. // good
  2. function foo() {
  3. let html = `
  4. <div>
  5. <p></p>
  6. <p></p>
  7. </div>
  8. `;
  9. }
  10. // Good
  11. function greeting(name) {
  12. return 'Hello, \n'
  13. + `${name.firstName} ${name.lastName}`;
  14. }
  15. // Bad
  16. function greeting(name) {
  17. return `Hello,
  18. ${name.firstName} ${name.lastName}`;
  19. }

2.2.2 空格

[强制] 使用 generator 时,* 前面不允许有空格,* 后面必须有一个空格。

示例:

  1. // good
  2. function* caller() {
  3. yield 'a';
  4. yield* callee();
  5. yield 'd';
  6. }
  7. // bad
  8. function * caller() {
  9. yield 'a';
  10. yield *callee();
  11. yield 'd';
  12. }

2.2.3 语句

[强制] 类声明结束不允许添加分号。

解释:

与函数声明保持一致。

[强制] 类成员定义中,方法定义后不允许添加分号,成员属性定义后必须添加分号。

解释:

成员属性是当前 Stage 0 的标准,如果使用的话,则定义后加上分号。

示例:

  1. // good
  2. class Foo {
  3. foo = 3;
  4. bar() {
  5. }
  6. }
  7. // bad
  8. class Foo {
  9. foo = 3
  10. bar() {
  11. }
  12. }
[强制] export 语句后,不允许出现表示空语句的分号。

解释:

export 关键字不影响后续语句类型。

示例:

  1. // good
  2. export function foo() {
  3. }
  4. export default function bar() {
  5. }
  6. // bad
  7. export function foo() {
  8. };
  9. export default function bar() {
  10. };
[强制] 属性装饰器后,可以不加分号的场景,不允许加分号。

解释:

只有一种场景是必须加分号的:当属性 keycomputed property key 时,其装饰器必须加分号,否则修饰 key[] 会做为之前表达式的 property accessor

上面描述的场景,装饰器后需要加分号。其余场景下的属性装饰器后不允许加分号。

示例:

  1. // good
  2. class Foo {
  3. @log('INFO')
  4. bar() {
  5. }
  6. @log('INFO');
  7. ['bar' + 2]() {
  8. }
  9. }
  10. // bad
  11. class Foo {
  12. @log('INFO');
  13. bar() {
  14. }
  15. @log('INFO')
  16. ['bar' + 2]() {
  17. }
  18. }
[强制] 箭头函数的参数只有一个,并且不包含解构时,参数部分的括号必须省略。

示例:

  1. // good
  2. list.map(item => item * 2);
  3. // good
  4. let fetchName = async id => {
  5. let user = await request(`users/${id}`);
  6. return user.fullName;
  7. };
  8. // bad
  9. list.map((item) => item * 2);
  10. // bad
  11. let fetchName = async (id) => {
  12. let user = await request(`users/${id}`);
  13. return user.fullName;
  14. };
[建议] 箭头函数的函数体只有一个单行表达式语句,且作为返回值时,省略 {}return

如果单个表达式过长,可以使用 () 进行包裹。

示例:

  1. // good
  2. list.map(item => item * 2);
  3. let foo = () => (
  4. condition
  5. ? returnValueA()
  6. : returnValueB()
  7. );
  8. // bad
  9. list.map(item => {
  10. return item * 2;
  11. });
[建议] 箭头函数的函数体只有一个 Object Literal,且作为返回值时,使用 () 包裹。

示例:

  1. // good
  2. list.map(item => ({name: item[0], email: item[1]}));
[强制] 解构多个变量时,如果超过行长度限制,每个解构的变量必须单独一行。

解释:

太多的变量解构会让一行的代码非常长,极有可能超过单行长度控制,使代码可读性下降。

示例:

  1. // good
  2. let {
  3. name: personName,
  4. email: personEmail,
  5. sex: personSex,
  6. age: personAge
  7. } = person;
  8. // bad
  9. let {name: personName, email: personEmail,
  10. sex: personSex, age: personAge
  11. } = person;

3 语言特性

3.1 变量

[强制] 使用 letconst 定义变量,不使用 var

解释:

使用 letconst 定义时,变量作用域范围更明确。

示例:

  1. // good
  2. for (let i = 0; i < 10; i++) {
  3. }
  4. // bad
  5. for (var i = 0; i < 10; i++) {
  6. }

3.2 解构

[强制] 不要使用3层及以上的解构。

解释:

过多层次的解构会让代码变得难以阅读。

示例:

  1. // bad
  2. let {documentElement: {firstElementChild: {nextSibling}}} = window;

[建议] 使用解构减少中间变量。

解释:

常见场景如变量值交换,可能产生中间变量。这种场景推荐使用解构。

示例:

  1. // good
  2. [x, y] = [y, x];
  3. // bad
  4. let temp = x;
  5. x = y;
  6. y = temp;

[强制] 仅定义一个变量时不允许使用解构。

解释:

在这种场景下,使用解构将降低代码可读性。

示例:

  1. // good
  2. let len = myString.length;
  3. // bad
  4. let {length: len} = myString;

[强制] 如果不节省编写时产生的中间变量,解构表达式 = 号右边不允许是 ObjectLiteralArrayLiteral

解释:

在这种场景下,使用解构将降低代码可读性,通常也并无收益。

示例:

  1. // good
  2. let {first: firstName, last: lastName} = person;
  3. let one = 1;
  4. let two = 2;
  5. // bad
  6. let [one, two] = [1, 2];

[强制] 使用剩余运算符时,剩余运算符之前的所有元素必需具名。

解释:

剩余运算符之前的元素省略名称可能带来较大的程序阅读障碍。如果仅仅为了取数组后几项,请使用 slice 方法。

示例:

  1. // good
  2. let [one, two, ...anyOther] = myArray;
  3. let other = myArray.slice(3);
  4. // bad
  5. let [,,, ...other] = myArray;

3.3 模板字符串

[强制] 字符串内变量替换时,不要使用 2 次及以上的函数调用。

解释:

在变量替换符内有太多的函数调用等复杂语法会导致可读性下降。

示例:

  1. // good
  2. let fullName = getFullName(getFirstName(), getLastName());
  3. let s = `Hello ${fullName}`;
  4. // bad
  5. let s = `Hello ${getFullName(getFirstName(), getLastName())}`;

3.4 函数

[建议] 使用变量默认语法代替基于条件判断的默认值声明。

解释:

添加默认值有助于引擎的优化,在未来 strong mode 下也会有更好的效果。

示例:

  1. // good
  2. function foo(text = 'hello') {
  3. }
  4. // bad
  5. function foo(text) {
  6. text = text || 'hello';
  7. }

[强制] 不要使用 arguments 对象,应使用 ...args 代替。

解释:

在未来 strong modearguments 将被禁用。

示例:

  1. // good
  2. function foo(...args) {
  3. console.log(args.join(''));
  4. }
  5. // bad
  6. function foo() {
  7. console.log([].join.call(arguments));
  8. }

3.5 箭头函数

[强制] 一个函数被设计为需要 callapply 的时候,不能是箭头函数。

解释:

箭头函数会强制绑定当前环境下的 this

3.6 对象

[建议] 定义对象时,如果所有键均指向同名变量,则所有键都使用缩写;如果有一个键无法指向同名变量,则所有键都不使用缩写。

解释:

目的在于保持所有键值对声明的一致性。

  1. // good
  2. let foo = {x, y, z};
  3. let foo2 = {
  4. x: 1,
  5. y: 2,
  6. z: z
  7. };
  8. // bad
  9. let foo = {
  10. x: x,
  11. y: y,
  12. z: z
  13. };
  14. let foo2 = {
  15. x: 1,
  16. y: 2,
  17. z
  18. };

[强制] 定义方法时使用 MethodDefinition 语法,不使用 PropertyName: FunctionExpression 语法。

解释:

MethodDefinition 语法更清晰简洁。

示例:

  1. // good
  2. let foo = {
  3. bar(x, y) {
  4. return x + y;
  5. }
  6. };
  7. // bad
  8. let foo = {
  9. bar: function (x, y) {
  10. return x + y;
  11. }
  12. };

[建议] 使用 Object.keysObject.entries 进行对象遍历。

解释:

不建议使用 for .. in 进行对象的遍历,以避免遗漏 hasOwnProperty 产生的错误。

示例:

  1. // good
  2. for (let key of Object.keys(foo)) {
  3. let value = foo[key];
  4. }
  5. // good
  6. for (let [key, value] of Object.entries(foo)) {
  7. // ...
  8. }

[建议] 定义对象的方法不应使用箭头函数。

解释:

箭头函数将 this 绑定到当前环境,在 obj.method() 调用时容易导致不期待的 this。除非明确需要绑定 this,否则不应使用箭头函数。

示例:

  1. // good
  2. let foo = {
  3. bar(x, y) {
  4. return x + y;
  5. }
  6. };
  7. // bad
  8. let foo = {
  9. bar: (x, y) => x + y
  10. };

[建议] 尽量使用计算属性键在一个完整的字面量中完整地定义一个对象,避免对象定义后直接增加对象属性。

解释:

在一个完整的字面量中声明所有的键值,而不需要将代码分散开来,有助于提升代码可读性。

示例:

  1. // good
  2. const MY_KEY = 'bar';
  3. let foo = {
  4. [MY_KEY + 'Hash']: 123
  5. };
  6. // bad
  7. const MY_KEY = 'bar';
  8. let foo = {};
  9. foo[MY_KEY + 'Hash'] = 123;

3.7 类

[强制] 使用 class 关键字定义一个类。

解释:

直接使用 class 定义类更清晰。不要再使用 functionprototype 形式的定义。

  1. // good
  2. class TextNode {
  3. constructor(value, engine) {
  4. this.value = value;
  5. this.engine = engine;
  6. }
  7. clone() {
  8. return this;
  9. }
  10. }
  11. // bad
  12. function TextNode(value, engine) {
  13. this.value = value;
  14. this.engine = engine;
  15. }
  16. TextNode.prototype.clone = function () {
  17. return this;
  18. };

[强制] 使用 super 访问父类成员,而非父类的 prototype

解释:

使用 supersuper.foo 可以快速访问父类成员,而不必硬编码父类模块而导致修改和维护的不便,同时更节省代码。

  1. // good
  2. class TextNode extends Node {
  3. constructor(value, engine) {
  4. super(value);
  5. this.engine = engine;
  6. }
  7. setNodeValue(value) {
  8. super.setNodeValue(value);
  9. this.textContent = value;
  10. }
  11. }
  12. // bad
  13. class TextNode extends Node {
  14. constructor(value, engine) {
  15. Node.apply(this, arguments);
  16. this.engine = engine;
  17. }
  18. setNodeValue(value) {
  19. Node.prototype.setNodeValue.call(this, value);
  20. this.textContent = value;
  21. }
  22. }

3.8 模块

[强制] export 与内容定义放在一起。

解释:

何处声明要导出的东西,就在何处使用 export 关键字,不在声明后再统一导出。

示例:

  1. // good
  2. export function foo() {
  3. }
  4. export const bar = 3;
  5. // bad
  6. function foo() {
  7. }
  8. const bar = 3;
  9. export {foo};
  10. export {bar};

[建议] 相互之间无关联的内容使用命名导出。

解释:

举个例子,工具对象中的各个方法,相互之间并没有强关联,通常外部会选择几个使用,则应该使用命名导出。

简而言之,当一个模块只扮演命名空间的作用时,使用命名导出。

[强制] 所有 import 语句写在模块开始处。

示例:

  1. // good
  2. import {bar} from './bar';
  3. function foo() {
  4. bar.work();
  5. }
  6. // bad
  7. function foo() {
  8. import {bar} from './bar';
  9. bar.work();
  10. }

3.9 集合

[建议] 对数组进行连接操作时,使用数组展开语法。

解释:

用数组展开代替 concat 方法,数组展开对 Iterable 有更好的兼容性。

示例:

  1. // good
  2. let foo = [...foo, newValue];
  3. let bar = [...bar, ...newValues];
  4. // bad
  5. let foo = foo.concat(newValue);
  6. let bar = bar.concat(newValues);

[建议] 不要使用数组展开进行数组的复制操作。

解释:

使用数组展开语法进行复制,代码可读性较差。推荐使用 Array.from 方法进行复制操作。

示例:

  1. // good
  2. let otherArr = Array.from(arr);
  3. // bad
  4. let otherArr = [...arr];

[建议] 尽可能使用 for .. of 进行遍历。

解释:

使用 for .. of 可以更好地接受任何的 Iterable 对象,如 Map#values 生成的迭代器,使得方法的通用性更强。

以下情况除外:

  1. 遍历确实成为了性能瓶颈,需要使用原生 for 循环提升性能。
  2. 需要遍历过程中的索引。

[强制] 当键值有可能不是字符串时,必须使用 Map;当元素有可能不是字符串时,必须使用 Set

解释:

使用普通 Object,对非字符串类型的 key,需要自己实现序列化。并且运行过程中的对象变化难以通知 Object。

[建议] 需要一个不可重复的集合时,应使用 Set

解释:

不要使用 {foo: true} 这样的普通 Object

示例:

  1. // good
  2. let members = new Set(['one', 'two', 'three']);
  3. // bad
  4. let members = {
  5. one: true,
  6. two: true,
  7. three: true
  8. };

[建议] 当需要遍历功能时,使用 MapSet

解释:

MapSet 是可遍历对象,能够方便地使用 for...of 遍历。不要使用使用普通 Object。

示例:

  1. // good
  2. let membersAge = new Map([
  3. ['one', 10],
  4. ['two', 20],
  5. ['three', 30]
  6. ]);
  7. for (let [key, value] of map) {
  8. }
  9. // bad
  10. let membersAge = {
  11. one: 10,
  12. two: 20,
  13. three: 30
  14. };
  15. for (let key in membersAge) {
  16. if (membersAge.hasOwnProperty(key)) {
  17. let value = membersAge[key];
  18. }
  19. }

[建议] 程序运行过程中有添加或移除元素的操作时,使用 MapSet

解释:

使用 MapSet,程序的可理解性更好;普通 Object 的语义更倾向于表达固定的结构。

示例:

  1. // good
  2. let membersAge = new Map();
  3. membersAge.set('one', 10);
  4. membersAge.set('two', 20);
  5. membersAge.set('three', 30);
  6. membersAge.delete('one');
  7. // bad
  8. let membersAge = {};
  9. membersAge.one = 10;
  10. membersAge.two = 20;
  11. membersAge.three = 30;
  12. delete membersAge['one'];

3.10 异步

[强制] 回调函数的嵌套不得超过3层。

解释:

深层次的回调函数的嵌套会让代码变得难以阅读。

示例:

  1. // bad
  2. getUser(userId, function (user) {
  3. validateUser(user, function (isValid) {
  4. if (isValid) {
  5. saveReport(report, user, function () {
  6. notice('Saved!');
  7. });
  8. }
  9. });
  10. });

[建议] 使用 Promise 代替 callback

解释:

相比 callback,使用 Promise 能够使复杂异步过程的代码更清晰。

示例:

  1. // good
  2. let user;
  3. getUser(userId)
  4. .then(function (userObj) {
  5. user = userObj;
  6. return validateUser(user);
  7. })
  8. .then(function (isValid) {
  9. if (isValid) {
  10. return saveReport(report, user);
  11. }
  12. return Promise.reject('Invalid!');
  13. })
  14. .then(
  15. function () {
  16. notice('Saved!');
  17. },
  18. function (message) {
  19. notice(message);
  20. }
  21. );

[强制] 使用标准的 Promise API。

解释:

  1. 不允许使用非标准的 Promise API,如 jQueryDeferredQ.jsdefer 等。
  2. 不允许使用非标准的 Promise 扩展 API,如 bluebirdPromise.any 等。

使用标准的 Promise API,当运行环境都支持时,可以把 Promise Lib 直接去掉。

[强制] 不允许直接扩展 Promise 对象的 prototype

解释:

理由和 不允许修改和扩展任何原生对象和宿主对象的原型 是一样的。如果想要使用更方便,可以用 utility 函数的形式。

[强制] 不得为了编写的方便,将可以并行的IO过程串行化。

解释:

并行 IO 消耗时间约等于 IO 时间最大的那个过程,串行的话消耗时间将是所有过程的时间之和。

示例:

  1. requestData().then(function (data) {
  2. renderTags(data.tags);
  3. renderArticles(data.articles);
  4. });
  5. // good
  6. async function requestData() {
  7. const [tags, articles] = await Promise.all([
  8. requestTags(),
  9. requestArticles()
  10. ]);
  11. return {tags, articles};
  12. }
  13. // bad
  14. async function requestData() {
  15. let tags = await requestTags();
  16. let articles = await requestArticles();
  17. return Promise.resolve({tags, articles});
  18. }

[建议] 使用 async/await 代替 generator + co

解释:

使用语言自身的能力可以使代码更清晰,也无需引入 co 库。

示例:

  1. addReport(report, userId).then(
  2. function () {
  3. notice('Saved!');
  4. },
  5. function (message) {
  6. notice(message);
  7. }
  8. );
  9. // good
  10. async function addReport(report, userId) {
  11. let user = await getUser(userId);
  12. let isValid = await validateUser(user);
  13. if (isValid) {
  14. let savePromise = saveReport(report, user);
  15. return savePromise();
  16. }
  17. return Promise.reject('Invalid');
  18. }
  19. // bad
  20. function addReport(report, userId) {
  21. return co(function* () {
  22. let user = yield getUser(userId);
  23. let isValid = yield validateUser(user);
  24. if (isValid) {
  25. let savePromise = saveReport(report, user);
  26. return savePromise();
  27. }
  28. return Promise.reject('Invalid');
  29. });
  30. }

4 环境

4.1 运行环境

[建议] 持续跟进与关注运行环境对语言特性的支持程度。

解释:

查看环境对语言特性的支持程度

ES 标准的制定还在不断进行中,各种环境对语言特性的支持也日新月异。了解项目中用到了哪些 ESNext 的特性,了解项目的运行环境,并持续跟进这些特性在运行环境中的支持程度是很有必要的。这意味着:

  1. 如果有任何一个运行环境(比如 chrome)支持了项目里用到的所有特性,你可以在开发时抛弃预编译。
  2. 如果所有环境都支持了某一特性(比如 Promise),你可以抛弃相关的 shim,或无需在预编译时进行转换。
  3. 如果所有环境都支持了项目里用到的所有特性,你可以完全抛弃预编译。

无论如何,在选择预编译工具时,你都需要清晰的知道你现阶段将在项目里使用哪些语言特性,然后了解预编译工具对语言特性的支持程度,做出选择。

[强制] 在运行环境中没有 Promise 时,将 Promise 的实现 shimglobal 中。

解释:

当前运行环境下没有 Promise 时,可以引入 shim 的扩展。如果自己实现,需要实现在 global 下,并且与标准 API 保持一致。

这样,未来运行环境支持时,可以随时把 Promise 扩展直接扔掉,而应用代码无需任何修改。

4.2 预编译

[建议] 使用 babel 做为预编译工具时,建议使用 5.x 版本。

解释:

由于 babel 最新的 6 暂时还不稳定,建议暂时使用 5.x。不同的产品,对于浏览器支持的情况不同,使用 babel 的时候,需要设置的参数也有一些区别。下面在示例中给出一些建议的参数。

示例:

  1. 建议的参数
  2. --loose all --modules amd --blacklist strict
  3. 如果需要使用 es7.classPropertieses7.decorators 等一些特性,需要额外的 --stage 0 参数
  4. --loose all --modules amd --blacklist strict --stage 0

[建议] 使用 babel 做为预编译工具时,通过 external-helpers 减少生成文件的大小。

解释:

babel 在转换代码的过程中发现需要一些特性时,会在该文件头部生成对应的 helper 代码。默认情况下,对于每一个经由 babel 处理的文件,均会在文件头部生成对应需要的辅助函数,多份文件辅助函数存在重复,占用了不必要的代码体积。

因此推荐打开externalHelpers: true选项,使 babel 在转换后内容中不写入 helper 相关的代码,而是使用一个外部的 .js统一提供所有的 helper。对于external-helpers的使用,可以有两种方式:

  1. 默认方式:需要通过 <script> 自行引入babel-polyfill.jsbabel-external-helpers.js
  2. 定制方式:自己实现 babel-runtime

示例:

  1. # 默认方式
  2. --loose all --modules amd --external-helpers
  3. # `babelHelpers` 的代码可以通过执行 `babel-external-helpers -t var` 得到所有相关API的实现
  4. # 定制方式
  5. --loose all --modules amd --optional runtime

[建议] 使用 TypeScript 做为预编译工具时,建议使用 1.6+ 版本。

解释:

TypeScript 1.6 之后,基本摒弃了之前的与 ESNext 相冲突的地方。目前 TypeScript 的思路就是遵循标准,将 stage 已经足够成熟的功能纳入,并提供静态类型和类型检查,所以其在 stage 0/1 的支持上不如 babel。另外,TypeScript 不能指定关闭某个 transform,但其编译速度比 babel 更高。

TypeScript 的常用参数在下面给出了示例。

示例:

  1. --module amd --target ES3
  2. --module commonjs --target ES6

[建议] 使用 TypeScript 做为预编译工具时,不使用 tsc 命令。

解释:

TypeScript 提供的 tsc 命令只支持后缀名 .ts.tsx.d.ts 的文件编译,对于 JavaScript 来说,保持后缀名为 .js 是原则,本文档的 文件 章节也有所要求。

如果要使用 TypeScript 做为预编译工具,可基于其 Compiler API 开发自己的预编译工具。如果你是 FIS 用户,可以使用 FIS TypeScript 插件

[建议] 生成的代码在浏览器环境运行时,应生成 AMD 模块化代码。

解释:

AMD 在浏览器环境应用较为成熟。

[建议] 浏览器端项目中如果 ESNext 代码和 ES3/5 代码混合,不要使用 TypeScript 做为预编译工具。

解释:

TypeScript 产生的 module 代码使用 exports.default 导出默认的 export,但是没有直接为 module.exports 赋值,导致在另外一个普通文件中使用 require(‘moduleName’) 是拿不到东西的。

需要使用 TypeScript 的话,建议整个项目所有文件都是 ESNext module 的,采用混合的 module 容易出现不可预期的结果。

[建议] AMD/CommonJS 模块依赖 ESNext 模块时,AMD/CommonJS 模块对 default export 的 require 需要改造。

解释:

ESNext 模块经过编译后,named export 会挂载在 exports 对象上,default export 也会挂载在 exports 对象上名称为 default 的属性。同时 exports 对象会包含一个值为 true 的 __esModule 属性。那么问题来了,当 AMD/CommonJS 模块依赖了 ESNext 模块时,require 期望拿到的是 exports.default,但你实际上拿到的是 exports。

所以,老的 AMD/CommonJS 模块依赖了 default export 的 ESNext 模块时,对 default export 的 require 需要改造成 require('name').default

另外,如果是 ESNext 模块之间的互相依赖,transpiler 会通过加入中间对象和引入 interop 方法,所以不会产生这个问题。