『译』JavaScript 错误处理权威指南

12/16/2018
0
JavaScript、译文

原文链接:Handling Errors in JavaScript: The Definitive Guide

这篇文章将分为三个部分。首先,我们会看一下常规错误;之后,我们会集中梳理一下后端(Node.js 和 Express.js)中的错误处理;最后,我们会看看在 React.js 中如何处理错误。我选择这些框架是因为它们都是当下最受欢迎的,但是,你应该能够很容易地将这些新知识应用到其它框架里面去。

完整的项目示例参见 GitHub

JavaScript 常规错误处理

throw new Error('something went wrong!')——在 JavaScript 中创建一个 Error 实例,并且中断执行脚本,除非你事先处理了这个错误。如果你的职业生涯是以 JavaScript 开发者作为开端,你自己很可能不会去这样做,而你会在其他库(或者运行时)看到,例如:ReferenceError: fs is not defined 或者类似的错误。

Error 对象

Error 对象为我们提供了两个内置属性。第一个是 message,就是你传递给 Error 构造函数的参数。例如:new Error('This is message'),这样的话就可以通过 message 属性来访问:

const myError = new Error('please improve your code');
console.log(myError.message); // please improve your code

第二个是非常重要的属性是错误堆栈。你可以通过 stack 属性来访问它。错误堆栈会给你一些哪些文件导致发生错误的历史记录(调用堆栈)。堆栈还包括顶部的信息,然后紧接着是实际堆栈,以最近的错误或错误隔离点开始,然后转到最外层的“负责”文件。

Error: please improve your code
  at Object.<anonymous> (/Users/gisderdube/Documents/_projects/hacking.nosync/error-handling/src/general.js:1:79)
  at Module._compile (internal/modules/cjs/loader.js:689:30)
  at Object.Module._extensions..js (internal/modules/cjs/loader.js:700:10)
  at Module.load (internal/modules/cjs/loader.js:599:32)
  at tryModuleLoad (internal/modules/cjs/loader.js:538:12)
  at Function.Module._load (internal/modules/cjs/loader.js:530:3)
  at Function.Module.runMain (internal/modules/cjs/loader.js:742:12)
  at startup (internal/bootstrap/node.js:266:19)
  at bootstrapNodeJSCore (internal/bootstrap/node.js:596:3)

抛出并处理错误

目前,单独的 Error 实例不会出现任何问题。比如: new Error('...') 不会执行任何操作。当 throw Error 时会变得有趣。接着,就像之前说的,你的脚本会中断执行,除非你在代码中已经进行了处理。记住,即使你手动抛出错误,也无关紧要,它会被第三方库、运行时(Node 或浏览器)抛出。下面让我们来看看在不同场景中如何处理错误。

try…catch

这是最简单,也是经常最容易被忘记的处理错误的一种方式——由于 async/await 的出现,它的用途也更加多样。看看下面的例子,它可以用来捕获各种同步代码错误:

const a = 5;

try {
  console.log(b); // b 未定义,抛出错误
} catch (err) {
  console.error(err); // 打印错误堆栈
}

console.log(a); // 5

如果不把 console.log(b) 放在 try...catch 语句块中的话,脚本就会中断执行。

…finally()

有时必须要执行一些代码,无论有没有错误。这时你可以选择第三种方式,即 try...catch 的可选语句块 finally。它和在 try...catch 语句块后写一行代码的作用相同,但有些情况下非常有用。

const a = 5;

try {
  console.log(b); // b 未定义,抛出错误
} catch (err) {
  console.error(err); // 打印错误堆栈
} finally {
  console.log(a); // 5
}

异步——回调

异步是你在 JavaScript 中必须要去考虑的一件事情。假设你拥有一个异步函数,并且函数内部出现了一个错误,此时脚本会继续执行,但错误并不会立即呈现出来。使用回调处理异步函数时(不推荐使用),回调函数通常可以接收两个参数,看起来是下面这样:

myAsyncFunc(someInput, (err, result) => {
  if (err) return console.log(err);
  console.log(result);
});

如果出现错误,参数 err 将会等于 Error。如果没有 err 就是 undefined 或者 null。所以在 if (err) 中返回或者将其它代码写在 else 块中非常重要。除此之外,你可能还会遇到其它错误,例如,resultundefined,而你想访问其 data 属性等等其它类似情况。

异步——Promise

一种更好地处理异步代码的方式是使用 promises。除了使代码更具可读性之外,我们还可以改进错误处理。只要有一个 catch 语句块,我们就不需要再关心那么多的错误捕获了。在把 promises 链接起来时,一个 catch 语句块可以捕获来自 promises 执行过程中所有的或者最后一个 catch 块中的错误。注意:没有 catch 语句块不会中断脚本的执行,但是会给出你一些可读性比较差的信息:

(node:7741) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: something went wrong
(node:7741) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code. */

因此,请始终在 promises 后面添加 catch 块,看看下面的代码:

Promise.resolve(1)
  .then((res) => {
    console.log(res); // 1
    throw new Error('something went wrong');
    return Promise.resolve(2);
  })
  .then((res) => {
    console.log(res); // 2
  })
  .catch((err) => {
    console.log(err);
    return Promise.resolve(3);
  })
  .then((res) => {
    console.log(res); // 3
  })
  .catch((err) => {
    console.log(err);
  });

再谈 try…catch

随着 JavaScript 中引入 async/await,我们又回到了最初处理错误的方式,使用 try...catch,这使得处理错误变得比较容易:

(async function () {
  try {
    await someFuncThatThrowsAnError();
  } catch (err) {
    console.error(err);
  }
})();

因为这和我们用于处理同步代码错误的方式相同,因此如果有需要,使用范围更广的 catch 语句更加容易。

服务端错误处理

现在我们有了处理错误的工具,然我们来看看可以将其运用在哪些实际的场景中呢?在服务端处理错误是应用程序的很关键的一部分。其实关于错误处理,有很多种不同的方法。我将为你演示一种具有自定义 Error 构造函数和错误代码的处理方式,这使得我们可以轻松地将其传递给前端或者其他 API 调用者。这样一来,如何组织后端代码并不重要,因为这种思想是保持不变的。

我们将使用 Express.js 作为后端框架。思考一下最有效的处理错误的结构,我们可能需要:

  1. 一般性的错误处理,某种回退,也就是说:“发生了一些错误,请重试或者联系我们”。这种方式不是特别明智,但至少我们可以告知用户发生了错误——而不是无限加载等类似情况。

  2. 特殊的错误处理,告知用户错误的详细信息,并且告知用户该如何修复,例如,缺失某些信息,某些条目已存在于数据库中等等。

构建自定义 Error 构造函数

我们可以使用现有的 Error 构造函数并将其扩展。虽然在 JavaScript 中使用继承的风险比较高,但在这种情况下,我觉得继承非常有用。为什么需要继承呢?因为我们仍然希望堆栈跟踪能为我们带来良好的调试体验。扩展原生的 JavaScript Error 构造函数能让我们自由地使用堆栈跟踪。我们要添加的唯一一个属性就是 code,以便稍后可以使用 err.code,其实相当于传递给前台的状态码(http 状态码)。

class CustemError extends Error {
  constructor(code = 'GENERIC', status = 500, ...params) {
    super(...params);

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, CustemError);
    }

    this.code = code;
    this.status = status;
  }
}

module.exports = CustemError;

如何处理路由

我们自定义的 Error 已经可以使用了,接下来就要需要设置路由结构。正如我指出的,我们需要提供一个统一的错误处理逻辑,因为我们想要让每个路由都拥有同样的错误处理的行为。默认情况下,Express 是不支持的,因为路由都是封装过的。

为此,我们可以实现一个路由处理器,并且将真正的路由逻辑定义为一个常规函数。通过这种方式,当路由函数(内部函数)抛出错误时,就能将错误返回至路由处理器中,最终传递到前端。当后端出现错误时,我们希望向前端传输一条响应——假设是 JSON API——类似下面的格式:

{
    error: 'SOME_ERROR_CODE',
    description: 'Something bad happened. Please try again or contact support.'
}

准备好面对不知所措。每当我对学生说起下面这句话时,他们总是会很生气。

第一次看到某些东西的时候无法理解其实无所谓。你只管先用起来,过段时间之后你自然会明白它的意义。

注:这也称之为自上而下的学习,我非常喜爱。

下面是路由处理器的代码:

const express = require('express');
const router = express.Router();
const CustomError = require('../CustomError');

router.use(async (req, res) => {
  try {
    const route = require(`.${req.path}`)[req.method];

    try {
      const result = route(req); // 把 request 传递个路由函数
      res.send(result); // 把从路由函数返回的数据发送给客户端
    } catch (err) {
      // 如果路由函数中发生错误,将会执行以下代码
      if (err instanceof CustomError) {
        // 错误已经被处理的情况下,我们需要将其转化为我们想要的对象
        return res.status(err.status).send({
          error: err.code,
          description: err.messsage,
        });
      } else {
        console.error(err);

        // 未处理错误,我们只要返回通用的错误对象
        return res.status(500).send({
          error: 'GENERIC',
          description: 'Something went wrong. Please try again or contact support.',
        });
      }
    }
  } catch {
    // 请求失败
    res.status(404).send({
      error: 'NOT_FOUND',
      description: 'The resource you tried to access does not exist.',
    });
  }
});

我希望你可以阅读代码中的注释,因为我觉得这比我在这里解释更有意义。接下来让我们看一下真实的路由文件:

const CustomError = require('../CustomError');

const GET = (req) => {
  return { name: 'Rio de Janeiro' };
};

const POST = (req) => {
  throw new Error('Some unexpected error, may also be thrown by a library or the runtime.');
};

const DELETE = (req) => {
  throw new CustomError('CITY_NOT_FOUND', 404, 'The city you are trying to delete could not be found.');
};

const PATCH = (req) => {
  try {
    throw new Error('Some internal error');
  } catch (err) {
    throw new CustomError('CITY_NOT_EDITABLE', 400, 'The city you are trying to edit is not editable.');
  }
};

module.exports = {
  GET,
  POST,
  DELETE,
  PATCH,
};

在这些例子中,我没有对实际请求做任何事情,我只是模拟了各种不同的错误场景。例如,GET /city 在第 3 行,POST /city 在第 8 行。这些接口也可以和查询参数一起使用,如:GET /city?startWith=R。对于未处理错误,前端将会接收如下错误信息:

{
  error: 'GENERIC',
  description: 'Something went wrong. Please try again or contact support.',
}

或者你可以手动抛出 CustomError 错误:

throw new CustomError('MY_CODE', 400, 'Error description');

它将会被转化为:

{
  error: 'MY_CODE',
  description: 'Error description'
}

现在后端有了完美的错误处理方式,不必再将错误日志发送给前端,而且每次发生错误时,我们总是可以返回有用的信息。

确保你在 GitHub 上看过了完整的代码。你可以在项目中自由地使用,或者按照你的需求修改这些代码。

向用户显示错误

下一步也是最后一步就是在处理前端错误,你需要使用第一部分中描述的工具来处理前端逻辑本身产生的错误。然而来自后端的错误信息,同样需要显示出来。让我们来看看如何显示错误。正如之前提到的,我们将在演示中继续使用 React。

在 React 状态中保存错误

和其它数据一样,错误和错误信息也可以是变化的。因此你希望将其放在组件的状态中。默认情况和挂载时,你希望重置错误,所以当用户第一次看见页面时,就不会显示错误。

接下来的我们要弄清楚的是不同的错误类型所匹配的视觉 UI。和后端一样,有 3 种类型:

  1. 全局错误。如:来自后端的一般性错误,或者用户没有登录等等。
  2. 来自后端的特定错误。如:用户将他的登录凭证发送到后端,后端的响应是密码不匹配。前端无法验证这些东西,因此它们都是从后端发送过来。
  3. 前端逻辑产生的特定错误。如:邮件地址校验失败。

第 2 和 第 3 种非常相似,而且可以使用同样的状态保存,只是它们的来源不一样而已。我们会在代码中如何实现。

我会使用 React 原生的状态实现,但是你也可以使用 MobX 或者 Redux 等状态管理系统。

全局错误

我通常会把这些错误保存在最外层有状态组件,并且将其渲染为一个静态 UI 元素,它可以是屏幕顶部的一个红色条幅,一个模态框或者其他任何东西,具体的设计实现取决于你自己。

UI for global errors

让我们看看代码实现:

import React, { Component } from 'react';
import GlobalError from './GlobalError';

class Application extends Component {
  constructor(props) {
    super(props);

    this.state = {
      error: '',
    };

    this._resetError = this._resetError.bind(this);
    this._setError = this._setError.bind(this);
  }

  render() {
    return (
      <div className="container">
        <GlobalError error={this.state.error} resetError={this._resetError} />
        <h1>Handling Errors</h1>
      </div>
    );
  }

  _resetError() {
    this.setState({ error: '' });
  }

  _setError(newError) {
    this.setState({ error: newError });
  }
}

export default Application;

正如我们看到的,我们把错误保存在 Application.js 的状态中,我们也有重置和改变错误值的方法。我们把错误值和重置方法传递给 GlobalError 组件,它负责显示错误,以及在点击 “X” 时重置错误。让我们看看 GlobalError 组件的代码:

import React, { Component } from 'react';

class GlobalError extends Component {
  render() {
    if (!this.props.error) return null;

    return (
      <div
        style={{
          position: 'fixed',
          top: 0,
          left: '50%',
          transform: 'translateX(-50%)',
          padding: '10px',
          backgroundColor: '#ffcccc',
          boxShadow: '0 3px 25px -10px rgba(0, 0, 0, 0.5)',
          display: 'flex',
          alignItems: 'center',
        }}
      >
        {this.props.error}
        &nbsp;
        <i className="material-icons" style={{ cursor: 'pointer' }} onClick={this.props.resetError}>
          close
        </i>
      </div>
    );
  }
}

export default GlobalError;

你可以看到第 5 行,如果没有错误,我们不会渲染任何 UI。它可以防止页面顶部一直有一个空的红色方框。当然,你也可以修改组件的样式或者行为。例如,你可以把 “X” 替换为超时,它会在几秒钟的超时后重置错误。

现在你可以在任何地方使用这个全局错误组件,只需从 Application.js 传递 _setError,而且你也可以设置全局错误,例如,一个请求从后台返回的字段是 error: 'GENERIC'

import React, { Component } from 'react';
import axios from 'axios';

class GenericErrorReq extends Component {
  constructor(props) {
    super(props);

    this._callBackend = this._callBackend.bind(this);
  }

  render() {
    return (
      <div>
        <button onClick={this._callBackend}>Click me to call backend</button>
      </div>
    );
  }

  _callBackend() {
    axios
      .post('/city')
      .then((result) => {
        // 请求成功
      })
      .catch((err) => {
        if (err.response.data.error === 'GENERIC') {
          this.props._setError({ error: err.response.data.error });
        }
      });
  }
}

如果你比较懒,你可以停在这里了。至于特定的错误,你可以通过更改全局错误状态把错误方框显示在页面的顶部。但是,我将继续为你展示如何处理和显示特定的错误。为什么呢?首先,这篇文章是处理错误权威指南,所以我们不能停在这里。第二,如果你把所有错误显示为全局错误,恐怕 UX 人员会吓坏吧。

处理特定请求错误

Handling specific request errors

和全局错误类似,我们可以在其它组件中包含局部错误状态。程序是类似的:

import React, { Component } from 'react';
import axios from 'axios';

import InlineError from './InlineError';

class SpecificErrorRequest extends Component {
  constructor(props) {
    super(props);

    this.state = {
      error: '',
    };

    this._callBackend = this._callBackend.bind(this);
  }

  render() {
    return (
      <div>
        <button onClick={this._callBackend}>Delete you city</button>
        <InlineError error={this.state.error} />
      </div>
    );
  }

  _callBackend() {
    this.setState({ error: '' });

    axios
      .delete('/api/city')
      .then((result) => {
        // 请求成功
      })
      .catch((err) => {
        if (err.response.data.error === 'GENERIC') {
          this.props.setError(err.response.data.description);
        } else {
          this.setState({
            error: err.response.data.description,
          });
        }
      });
  }
}

export default SpecificErrorRequest;

需要记住的一点是,清除错误的方式通常有所不同。在这里使用 “X” 清除错误没有意义,更好的方式是在发起一次新请求的时候清除错误,例如,当输入框的值发生了变化。

前端自身错误

Frontend origin errors

正如之前提到过,这些错误可以使用与处理从后端返回特定错误的相同方式来处理。让我们用一个输入字段的例子来说明,这次我们允许用户在输入之后删除一个城市:

import React, { Component } from 'react';
import axios from 'axios';

import InlineError from './InlineError';

class SpecificErrorRequest extends Component {
  constructor(props) {
    super(props);

    this.state = {
      error: '',
      city: '',
    };

    this._callBackend = this._callBackend.bind(this);
    this._changeCity = this._changeCity.bind(this);
  }

  render() {
    return (
      <div>
        <input type="text" value={this.state.city} style={{ marginRight: 15 }} onChange={this._changeCity} />
        <button onClick={this._callBackend}>Delete you city</button>
        <InlineError error={this.props._callBackend} />
      </div>
    );
  }

  _changeCity(e) {
    this.setState({
      error: '',
      city: e.target.value,
    });
  }

  _validate() {
    if (!this.state.city.length) throw new Error('Please provide a city name.');
  }

  _callBackend() {
    this.setState({ error: '' });

    try {
      this._validate();
    } catch (err) {
      return this.setState({ error: err.message });
    }

    axios
      .delete('/api/city')
      .then((result) => {
        // 请求成功
      })
      .catch((err) => {
        if (err.response.data.error === 'GENERIC') {
          this.props.setError(err.response.data.description);
        } else {
          this.setState({
            error: err.response.data.description,
          });
        }
      });
  }
}

export default SpecificErrorRequest;

使用错误代码进行错误国际化

也许你会想为什么我们需要错误代码,如 GENERIC,我们只显示了从后端传回的错误描述。现在随程序的增长,你希望抢占新的市场,这个时候就必须要面对支持多语言的问题了。如果在这种情况下,你就可以使用错误代码来显示正确的用户当地语言。

我希望你对错误处理能够有所收获。console.log(err) 应该已经成为过去了。它对于调试必不可少,但是它不应该在你的生产构建中结束。为了防止出现这种情况,我建议你使用日志库,我过去一直使用 loglevel,我非常喜欢它。


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用 CC BY-NC-SA 4.0 - 非商业性使用 - 相同方式共享 4.0 国际进行许可。