2016年ES6(ES2015)已经成为了前端开发的标准。

let/const、箭头函数、解构赋值、模板字符串、Promise、模块化、类、扩展运算符……这些新特性让JavaScript变得更强大、更优雅写代码的效率也提高了很多。

但是在使用ES6新特性的过程中我踩了很多坑。有些问题很隐蔽很难排查甚至让我熬夜到凌晨。

今天就来聊聊ES6新特性的踩坑记那些让我熬夜的问题以及从中得到的经验和教训。

一、let/const:暂时性死区,让我排查了很久

第一个坑就是let/const的暂时性死区(Temporal Dead Zone简称TDZ)。

以前用var的时候变量声明会有变量提升(hoisting),也就是说变量可以在声明之前使用值是undefined。

比如:

console.log(a); // undefined
var a = 1;

但是用let/const的时候就不一样了。let/const声明的变量虽然也有变量提升但是在声明之前是不能访问的否则会报错ReferenceError。

比如:

console.log(a); // ReferenceError: a is not defined
let a = 1;

这就是暂时性死区。在变量声明之前到代码块开始的这段区域就是暂时性死区在这个区域里访问变量会报错。

刚开始用let的时候我很不习惯经常在变量声明之前就使用了变量导致报错。而且这个错误有时候很隐蔽很难排查。

有一次我写了这样的代码:

function foo() {
    console.log(a);
    let a = 1;
    if (true) {
        let a = 2;
        console.log(a);
    }
}

foo();

我以为第一个console.log(a),会打印undefined但是实际上会报错ReferenceError。因为在函数作用域里let a有暂时性死区在声明之前访问a会报错。

这个问题让我排查了很久因为我一直以为是其他的问题没想到是暂时性死区的问题。

后来我养成了一个习惯就是把所有的变量声明都放在作用域的最前面这样就不会遇到暂时性死区的问题了。

还有const的问题。const声明的变量是常量不能重新赋值。但是注意const声明的对象对象的属性是可以修改的。

比如:

const obj = { a: 1 };
obj.a = 2; // 可以,没问题
console.log(obj.a); // 2

obj = { b: 2 }; // 报错,TypeError: Assignment to constant variable.

刚开始我以为const声明的对象也不能修改属性后来才知道const只是不能重新赋值对象的属性是可以修改的。如果想让对象的属性也不能修改需要用Object.freeze()。

二、箭头函数:this指向,让我踩了大坑

第二个坑就是箭头函数的this指向。

箭头函数是ES6里我最喜欢的特性之一语法简洁写回调函数的时候特别方便。

但是箭头函数的this指向和普通函数不一样。普通函数的this是在调用的时候确定的取决于调用的方式。而箭头函数没有自己的this它的this是继承自外层作用域的this。

刚开始用箭头函数的时候我没有注意到这个区别踩了很多坑。

有一次我写了这样的代码:

const obj = {
    name: 'test',
    sayHello: function() {
        setTimeout(() => {
            console.log('Hello, ' + this.name);
        }, 1000);
    }
};

obj.sayHello(); // 打印:Hello, test

这个是对的因为箭头函数的this继承自外层作用域的this也就是sayHello函数的this也就是obj。

但是我又写了这样的代码:

const obj = {
    name: 'test',
    sayHello: () => {
        console.log('Hello, ' + this.name);
    }
};

obj.sayHello(); // 打印:Hello, undefined

这个就错了。因为sayHello本身就是箭头函数它的this继承自外层作用域的this也就是全局作用域的this(在浏览器里是window在Node.js里是module.exports),不是obj。所以this.name是undefined。

这个问题让我排查了很久因为我一直以为this指向的是obj没想到是全局对象。

后来我总结了一个经验:对象的方法不要用箭头函数要用普通函数或者ES6的方法简写。因为对象的方法需要this指向对象本身而箭头函数的this继承自外层作用域不是对象本身。

还有构造函数也不能用箭头函数因为箭头函数没有自己的this不能用new调用。

还有事件监听器也要注意箭头函数的this指向。比如:

button.addEventListener('click', () => {
    console.log(this); // this是外层作用域的this,不是button
});

如果你需要this指向触发事件的元素那么就不要用箭头函数要用普通函数。

三、解构赋值:默认值,让我困惑了很久

第三个坑就是解构赋值的默认值。

解构赋值是ES6里很实用的特性可以快速地从对象或者数组里提取值赋值给变量。

比如:

const obj = { a: 1, b: 2 };
const { a, b } = obj;
console.log(a, b); // 1 2

const arr = [1, 2, 3];
const [x, y, z] = arr;
console.log(x, y, z); // 1 2 3

解构赋值还支持默认值。当被解构的对象或者数组里没有对应的属性或者元素的时候会使用默认值。

比如:

const obj = { a: 1 };
const { a, b = 2 } = obj;
console.log(a, b); // 1 2

但是这里有一个,坑:默认值只有当属性的值严格等于undefined的时候才会生效。如果属性的值是null或者其他的假值(比如0,'',false),默认值是不会生效的。

比如:

const obj = { a: null, b: 0, c: '', d: false };
const { a = 1, b = 2, c = 3, d = 4 } = obj;
console.log(a, b, c, d); // null 0 '' false

刚开始我以为只要属性的值是假值默认值就会生效后来才知道只有严格等于undefined的时候默认值才会生效。

这个问题让我困惑了很久。有一次我写了这样的代码:

function foo(options = {}) {
    const { timeout = 5000 } = options;
    console.log(timeout);
}

foo({ timeout: null }); // 打印:null

我以为timeout是null的时候会使用默认值5000但是实际上打印的是null。因为null不是严格等于undefined所以默认值不会生效。

后来我总结了一个经验:如果需要处理null的情况那么就不要用解构赋值的默认值要自己写判断逻辑。

还有数组解构的默认值也有类似的问题。

还有嵌套解构的问题。嵌套解构写起来很简洁但是如果嵌套的层级太深或者中间的属性不存在就会报错。

比如:

const obj = { a: { b: { c: 1 } } };
const { a: { b: { c } } } = obj;
console.log(c); // 1

const obj2 = {};
const { a: { b: { c } } } = obj2; // 报错,TypeError: Cannot destructure property 'b' of 'undefined'

因为obj2.a是undefined然后从undefined里解构b就会报错。

所以嵌套解构的时候一定要注意中间的属性是否存在否则很容易报错。如果不确定可以用可选链(?.),不过可选链是ES2020的特性ES6里没有。

四、Promise:错误处理,让我熬夜排查

第四个坑就是Promise的错误处理。

Promise是ES6里很重要的特性解决了回调地狱的问题让异步编程更优雅。

但是Promise的错误处理也有很多坑。

第一个坑就是忘记写catch。

如果Promise,reject了但是没有写catch那么就会变成未处理的Promise拒绝(Unhandled Promise Rejection),在浏览器里会报错在Node.js里可能会导致进程崩溃。

比如:

Promise.reject(new Error('error')); // 未处理的Promise拒绝

刚开始我经常忘记写catch导致很多未处理的Promise拒绝很难排查。

后来我养成了一个习惯就是每个Promise链都要写catch处理错误。

第二个坑就是then里的异步操作忘记return。

比如:

fetch('/api/user')
    .then(response => {
        response.json(); // 忘记return了
    })
    .then(data => {
        console.log(data); // data是undefined
    })
    .catch(err => {
        console.error(err);
    });

因为第一个then里忘记return了所以第二个then里的data是undefined。而且如果response.json(),抛出了错误也不会被后面的catch捕获因为没有return这个Promise没有加入到Promise链里。

这个问题很常见特别是新手很容易忘记return。

第三个坑就是catch里又抛出错误。

如果在catch里又抛出了错误那么这个错误需要再后面再跟一个catch才能捕获。否则也会变成未处理的Promise拒绝。

比如:

fetch('/api/user')
    .then(response => response.json())
    .catch(err => {
        console.error(err);
        throw new Error('处理错误失败');
    }); // 这里抛出的错误,没有被捕获

第四个坑就是Promise.all的错误处理。

Promise.all是当所有的Promise都resolve了才会resolve。如果有一个Promise,reject了那么整个Promise.all就会reject。

但是有时候我们希望即使有一些Promise,reject了也能拿到其他resolve的Promise的结果。这时候用Promise.all就不合适了因为只要有一个reject整个就reject了拿不到其他的结果。

这时候可以用Promise.allSettled(),不过这个是ES2020的特性ES6里没有。或者自己给每个Promise加catch让它们不会reject。

比如:

const promises = [
    fetch('/api/user/1').catch(err => null),
    fetch('/api/user/2').catch(err => null),
    fetch('/api/user/3').catch(err => null),
];

Promise.all(promises)
    .then(results => {
        console.log(results); // 即使有失败的,也能拿到其他的结果,失败的是null
    });

五、模块化:循环依赖,让我头疼了很久

第五个坑就是ES6模块化的循环依赖。

ES6的模块化import/export是静态的在编译的时候就能确定依赖关系。这比CommonJS的require更好因为可以做静态分析树摇(tree shaking)等优化。

但是ES6的模块化也有循环依赖的问题。

循环依赖就是A模块依赖B模块B模块又依赖A模块。

比如:

// a.js
import { b } from './b.js';
console.log('a.js', b);
export const a = 'a';

// b.js
import { a } from './a.js';
console.log('b.js', a);
export const b = 'b';

如果运行a.js那么执行顺序是这样的:

  1. 执行a.js首先import b.js所以先执行b.js
  2. 执行b.js首先import a.js但是a.js已经在执行中了所以不会重新执行而是拿到a.js的导出的引用(注意是引用不是值)
  3. 继续执行b.js,console.log('b.js', a),这时候a还没被赋值所以是undefined
  4. 继续执行b.js,export const b = 'b',b被赋值
  5. b.js执行完毕回到a.js
  6. 继续执行a.js,console.log('a.js', b),这时候b已经被赋值了所以是'b'
  7. 继续执行a.js,export const a = 'a',a被赋值

所以最终打印的是:

b.js undefined
a.js b

看到了吗?因为循环依赖b.js里的a是undefined因为a.js还没执行到赋值的地方。

这个问题让我头疼了很久。有一次我写的代码有循环依赖导致某个变量是undefined报错但是我排查了很久才发现是循环依赖的问题。

后来我总结了几个经验:

  1. 尽量避免循环依赖。如果有循环依赖说明代码的结构可能有问题需要重构。
  2. 如果确实需要循环依赖那么要注意变量的赋值时机。不要在模块的顶层使用还没被赋值的变量。
  3. 可以把变量的使用放在函数里延迟执行这样等函数被调用的时候变量已经被赋值了。

比如:

// b.js
import { a } from './a.js';
export function foo() {
    console.log(a); // 等foo被调用的时候,a已经被赋值了
}
export const b = 'b';

这样就不会有问题了因为a的使用被放在了函数里延迟执行了。

六、扩展运算符:性能问题,让我意外

第六个坑就是扩展运算符的性能问题。

扩展运算符(...),是ES6里很实用的特性可以用来展开数组或者对象。

比如:

const arr1 = [1, 2, 3];
const arr2 = [...arr1, 4, 5]; // [1, 2, 3, 4, 5]

const obj1 = { a: 1, b: 2 };
const obj2 = { ...obj1, c: 3 }; // { a: 1, b: 2, c: 3 }

扩展运算符用起来很方便但是它也有性能问题。

特别是在循环里用扩展运算符合并数组或者对象性能会很差。

比如:

let result = [];
for (let i = 0; i < 1000; i++) {
    result = [...result, i]; // 每次都创建一个新数组,性能很差
}

这样的代码每次循环都会创建一个新的数组把原来的数组展开再加上新的元素然后赋值给result。这样时间复杂度是O(n²),因为每次都要复制原来的数组。

如果用push就好很多:

const result = [];
for (let i = 0; i < 1000; i++) {
    result.push(i); // 直接在原数组上添加,性能好
}

这样时间复杂度是O(n),性能好很多。

对象的扩展运算符也是类似的问题。在循环里用扩展运算符合并对象每次都要创建一个新的对象复制原来的对象的属性性能很差。

有一次我写了一个函数用来合并多个对象用的是reduce加扩展运算符:

function mergeObjects(objects) {
    return objects.reduce((result, obj) => {
        return { ...result, ...obj };
    }, {});
}

当objects的数量很多的时候这个函数性能很差因为每次都要创建一个新的对象复制原来的对象的属性。

后来我改成了用Object.assign直接在原对象上修改性能就好很多了:

function mergeObjects(objects) {
    return objects.reduce((result, obj) => {
        return Object.assign(result, obj);
    }, {});
}

所以扩展运算符虽然好用但是也要注意性能问题特别是在循环里不要滥用扩展运算符。

七、其他的小坑

除了上面说的这些比较大的坑还有一些小坑也让我踩过。

1. 模板字符串的换行

模板字符串支持换行但是换行会被保留在字符串里。

比如:

const str = `hello
world`;
console.log(str); 
// 打印:
// hello
// world

如果你不想要换行那么就不要在模板字符串里换行或者用反斜杠转义。

2. 默认参数的求值时机

函数的默认参数是在调用的时候求值的不是在定义的时候。

比如:

let x = 1;
function foo(a = x) {
    console.log(a);
}
x = 2;
foo(); // 打印:2,不是1

因为默认参数x是在调用foo的时候求值的这时候x已经是2了。

3. 类的this指向

ES6的类方法里的this和普通函数一样是在调用的时候确定的。

比如:

class Foo {
    constructor() {
        this.name = 'test';
    }
    sayHello() {
        console.log('Hello, ' + this.name);
    }
}

const foo = new Foo();
const sayHello = foo.sayHello;
sayHello(); // 报错,this是undefined

因为把sayHello赋值给变量然后调用这时候this不是foo而是undefined(在严格模式下)。

所以如果需要把类的方法作为回调函数传递那么需要用bind绑定this或者用箭头函数定义方法(不过箭头函数定义方法是类的实例属性不是原型方法需要注意)。

4. for...of的遍历

for...of是ES6里用来遍历可迭代对象的比如数组、字符串、Set、Map等。

但是for...of不能直接遍历普通对象因为普通对象不是可迭代的。如果想遍历普通对象的属性需要用Object.keys(),Object.values(),Object.entries(),等。

比如:

const obj = { a: 1, b: 2, c: 3 };
for (const key of Object.keys(obj)) {
    console.log(key, obj[key]);
}

八、我的反思和总结

踩了这么多ES6的坑我有一些反思和总结。

1. 新特性虽然好用但是也要理解原理

ES6的新特性虽然好用能提高开发效率但是也要理解它们的原理和注意事项。不能只学会语法就随便用否则很容易踩坑。

比如let/const的暂时性死区箭头函数的this指向Promise的错误处理模块化的循环依赖这些都需要理解原理才能用好不踩坑。

2. 多实践多踩坑才能真正掌握

和Node.js异步编程一样ES6的新特性也是需要多实践多踩坑才能真正掌握。

刚开始学ES6的时候看了很多教程觉得自己懂了但是一写代码就各种出错各种踩坑。后来写的代码多了踩的坑多了才慢慢真正理解了这些新特性。

所以不要怕踩坑踩坑是学习的一部分。每踩一个坑你就对ES6多了一份理解。

3. 工具能帮我们避免很多坑

现在有很多工具能帮我们避免很多ES6的坑。

比如ESLint能检查代码的语法错误和常见的错误写法比如未使用的变量未处理的Promise错误,等。

比如Babel能把ES6的代码转换成ES5的代码兼容旧的浏览器。

比如TypeScript能在ES6的基础上加上类型系统在编译的时候就能发现很多类型错误避免很多运行时的坑。

善用这些工具能帮我们避免很多坑提高代码质量。

4. 不要为了用新特性而用新特性

最后想说的是不要为了用新特性而用新特性。

ES6的新特性虽然好用但是也要根据实际情况选择合适的写法。有些场景用ES5的写法可能更简单更清晰性能更好。

比如简单的异步操作用回调函数就够了不一定非要用Promise或者async/await。比如合并对象用Object.assign可能比扩展运算符性能更好。

不要为了炫技而用新特性要根据实际情况选择最合适的写法。

九、写在最后

ES6新特性踩坑记:那些让我熬夜的问题。

从let/const的暂时性死区到箭头函数的this指向到解构赋值的默认值到Promise的错误处理到模块化的循环依赖到扩展运算符的性能问题我踩了一个又一个的坑有些甚至让我熬夜排查。

但是正是这些坑让我对ES6的新特性有了更深的理解也让我写代码的时候更加谨慎更加注意细节。

ES6的新特性虽然有很多坑但是总体来说它们让JavaScript变得更强大更优雅写代码的效率也提高了很多。只要我们理解了它们的原理注意了常见的坑就能用好它们写出更优雅更高效的代码。

最后用一句话结尾:

"坑是成长的阶梯。每踩一个坑你就成长了一步。"

愿大家都能在踩坑中成长真正掌握ES6写出更优雅的代码。