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 生成字符串。

示例:

// good
function foo() {
    let html = `
        <div>
            <p></p>
            <p></p>
        </div>
    `;
}

// Good
function greeting(name) {
    return 'Hello, \n'
        + `${name.firstName} ${name.lastName}`;
}

// Bad
function greeting(name) {
    return `Hello,
${name.firstName} ${name.lastName}`;
}

2.2.2 空格

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

示例:

// good
function* caller() {
    yield 'a';
    yield* callee();
    yield 'd';
}

// bad
function * caller() {
    yield 'a';
    yield *callee();
    yield 'd';
}

2.2.3 语句

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

解释:

与函数声明保持一致。

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

解释:

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

示例:

// good
class Foo {
    foo = 3;

    bar() {

    }
}

// bad
class Foo {
    foo = 3

    bar() {

    }
}
[强制] export 语句后,不允许出现表示空语句的分号。

解释:

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

示例:

// good
export function foo() {
}

export default function bar() {
}


// bad
export function foo() {
};

export default function bar() {
};
[强制] 属性装饰器后,可以不加分号的场景,不允许加分号。

解释:

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

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

示例:

// good
class Foo {
    @log('INFO')
    bar() {

    }

    @log('INFO');
    ['bar' + 2]() {

    }
}

// bad
class Foo {
    @log('INFO');
    bar() {

    }

    @log('INFO')
    ['bar' + 2]() {

    }
}
[强制] 箭头函数的参数只有一个,并且不包含解构时,参数部分的括号必须省略。

示例:

// good
list.map(item => item * 2);

// good
let fetchName = async id => {
    let user = await request(`users/${id}`);
    return user.fullName;
};

// bad
list.map((item) => item * 2);

// bad
let fetchName = async (id) => {
    let user = await request(`users/${id}`);
    return user.fullName;
};
[建议] 箭头函数的函数体只有一个单行表达式语句,且作为返回值时,省略 {}return

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

示例:

// good
list.map(item => item * 2);

let foo = () => (
    condition
        ? returnValueA()
        : returnValueB()
);

// bad
list.map(item => {
    return item * 2;
});
[建议] 箭头函数的函数体只有一个 Object Literal,且作为返回值时,使用 () 包裹。

示例:

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

解释:

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

示例:

// good
let {
    name: personName,
    email: personEmail,
    sex: personSex,
    age: personAge
} = person;

// bad
let {name: personName, email: personEmail,
    sex: personSex, age: personAge
} = person;

3 语言特性

3.1 变量

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

解释:

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

示例:

// good
for (let i = 0; i < 10; i++) {

}

// bad
for (var i = 0; i < 10; i++) {

}

3.2 解构

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

解释:

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

示例:

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

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

解释:

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

示例:

// good
[x, y] = [y, x];

// bad
let temp = x;
x = y;
y = temp;

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

解释:

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

示例:

// good
let len = myString.length;

// bad
let {length: len} = myString;

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

解释:

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

示例:

// good
let {first: firstName, last: lastName} = person;
let one = 1;
let two = 2;

// bad
let [one, two] = [1, 2];

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

解释:

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

示例:

// good
let [one, two, ...anyOther] = myArray;
let other = myArray.slice(3);

// bad
let [,,, ...other] = myArray;

3.3 模板字符串

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

解释:

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

示例:

// good
let fullName = getFullName(getFirstName(), getLastName());
let s = `Hello ${fullName}`;

// bad
let s = `Hello ${getFullName(getFirstName(), getLastName())}`;

3.4 函数

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

解释:

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

示例:

// good
function foo(text = 'hello') {
}

// bad
function foo(text) {
    text = text || 'hello';
}

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

解释:

在未来 strong modearguments 将被禁用。

示例:

// good
function foo(...args) {
    console.log(args.join(''));
}

// bad
function foo() {
    console.log([].join.call(arguments));
}

3.5 箭头函数

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

解释:

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

3.6 对象

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

解释:

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

// good
let foo = {x, y, z};

let foo2 = {
    x: 1,
    y: 2,
    z: z
};


// bad
let foo = {
    x: x,
    y: y,
    z: z
};

let foo2 = {
    x: 1,
    y: 2,
    z
};

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

解释:

MethodDefinition 语法更清晰简洁。

示例:

// good
let foo = {
    bar(x, y) {
        return x + y;
    }
};

// bad
let foo = {
    bar: function (x, y) {
        return x + y;
    }
};

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

解释:

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

示例:

// good
for (let key of Object.keys(foo)) {
    let value = foo[key];
}

// good
for (let [key, value] of Object.entries(foo)) {
    // ...
}

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

解释:

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

示例:

// good
let foo = {
    bar(x, y) {
        return x + y;
    }
};

// bad
let foo = {
    bar: (x, y) => x + y
};

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

解释:

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

示例:

// good
const MY_KEY = 'bar';
let foo = {
    [MY_KEY + 'Hash']: 123
};

// bad
const MY_KEY = 'bar';
let foo = {};
foo[MY_KEY + 'Hash'] = 123;

3.7 类

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

解释:

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

// good
class TextNode {
    constructor(value, engine) {
        this.value = value;
        this.engine = engine;
    }

    clone() {
        return this;
    }
}

// bad
function TextNode(value, engine) {
    this.value = value;
    this.engine = engine;
}

TextNode.prototype.clone = function () {
    return this;
};

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

解释:

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

// good
class TextNode extends Node {
    constructor(value, engine) {
        super(value);
        this.engine = engine;
    }

    setNodeValue(value) {
        super.setNodeValue(value);
        this.textContent = value;
    }
}

// bad
class TextNode extends Node {
    constructor(value, engine) {
        Node.apply(this, arguments);
        this.engine = engine;
    }

    setNodeValue(value) {
        Node.prototype.setNodeValue.call(this, value);
        this.textContent = value;
    }
}

3.8 模块

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

解释:

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

示例:

// good
export function foo() {
}

export const bar = 3;


// bad
function foo() {
}

const bar = 3;

export {foo};
export {bar};

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

解释:

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

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

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

示例:

// good
import {bar} from './bar';

function foo() {
    bar.work();
}

// bad
function foo() {
    import {bar} from './bar';
    bar.work();
}

3.9 集合

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

解释:

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

示例:

// good
let foo = [...foo, newValue];
let bar = [...bar, ...newValues];

// bad
let foo = foo.concat(newValue);
let bar = bar.concat(newValues);

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

解释:

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

示例:

// good
let otherArr = Array.from(arr);

// bad
let otherArr = [...arr];

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

解释:

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

以下情况除外:

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

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

解释:

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

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

解释:

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

示例:

// good
let members = new Set(['one', 'two', 'three']);

// bad
let members = {
    one: true,
    two: true,
    three: true
};

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

解释:

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

示例:

// good
let membersAge = new Map([
    ['one', 10],
    ['two', 20],
    ['three', 30]
]);
for (let [key, value] of map) {
}

// bad
let membersAge = {
    one: 10,
    two: 20,
    three: 30
};
for (let key in membersAge) {
    if (membersAge.hasOwnProperty(key)) {
        let value = membersAge[key];
    }
}

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

解释:

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

示例:

// good
let membersAge = new Map();
membersAge.set('one', 10);
membersAge.set('two', 20);
membersAge.set('three', 30);
membersAge.delete('one');

// bad
let membersAge = {};
membersAge.one = 10;
membersAge.two = 20;
membersAge.three = 30;
delete membersAge['one'];

3.10 异步

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

解释:

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

示例:

// bad
getUser(userId, function (user) {
    validateUser(user, function (isValid) {
        if (isValid) {
            saveReport(report, user, function () {
                notice('Saved!');
            });
        }
    });
});

[建议] 使用 Promise 代替 callback

解释:

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

示例:

// good
let user;
getUser(userId)
    .then(function (userObj) {
        user = userObj;
        return validateUser(user);
    })
    .then(function (isValid) {
        if (isValid) {
            return saveReport(report, user);
        }

        return Promise.reject('Invalid!');
    })
    .then(
        function () {
            notice('Saved!');
        },
        function (message) {
            notice(message);
        }
    );

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

解释:

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

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

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

解释:

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

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

解释:

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

示例:

requestData().then(function (data) {
    renderTags(data.tags);
    renderArticles(data.articles);
});

// good
async function requestData() {
    const [tags, articles] = await Promise.all([
        requestTags(),
        requestArticles()
    ]);
    return {tags, articles};
}

// bad
async function requestData() {
    let tags = await requestTags();
    let articles = await requestArticles();

    return Promise.resolve({tags, articles});
}

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

解释:

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

示例:

addReport(report, userId).then(
    function () {
        notice('Saved!');
    },
    function (message) {
        notice(message);
    }
);

// good
async function addReport(report, userId) {
    let user = await getUser(userId);
    let isValid = await validateUser(user);

    if (isValid) {
        let savePromise = saveReport(report, user);
        return savePromise();
    }

    return Promise.reject('Invalid');
}

// bad
function addReport(report, userId) {
    return co(function* () {
        let user = yield getUser(userId);
        let isValid = yield validateUser(user);

        if (isValid) {
            let savePromise = saveReport(report, user);
            return savePromise();
        }

        return Promise.reject('Invalid');
    });
}

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 的时候,需要设置的参数也有一些区别。下面在示例中给出一些建议的参数。

示例:

# 建议的参数
--loose all --modules amd --blacklist strict

# 如果需要使用 es7.classProperties、es7.decorators 等一些特性,需要额外的 --stage 0 参数
--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

示例:

# 默认方式
--loose all --modules amd --external-helpers
# `babelHelpers` 的代码可以通过执行 `babel-external-helpers -t var` 得到所有相关API的实现

# 定制方式
--loose all --modules amd --optional runtime

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

解释:

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

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

示例:

--module amd --target ES3
--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 方法,所以不会产生这个问题。