前言
人无完人,所以代码总会出异常的,异常并不可怕,关键是怎么处理
什么是异常
程序发生了意想不到的情况,影响到了程序的正确运行
从根本上来说,异常就是一个普通的对象,其保存了异常发生的相关信息,比如错误码、错误信息等。以 JS 中的标准内置对象 Error 为例,其标准属性有 message。许多宿主环境额外增加了 filename 和 stack 等属性
错误只有被 throw,才会产生异常,不被抛出的错误不会产生异常。比如直接new Error()
甚至打印 Error 但是不 throw,也是不会产生异常
异常的分类
编译时异常
源代码在编译成可执行代码之前产生的异常,无需执行即有异常。编译、语法解析发生错误。编译型语言对于这种很常见的,但是解析型的 js 也是会有编译型异常。通常是非合法的 js 语句、ts 编译报错
console.log(1)
let 1 // Uncaught SyntaxError: Unexpected number
function test() {
console.log(1)
await 1
}
代码本身不会执行就抛异常,不会处理到打印 1 的阶段。这种情况通常不会有实际影响,因为 babel/ts 等工具处理时就会直接报错。除非不经编译直接写代码,例如有时候我们直接写在 html 中写的一些代码
运行时异常
代码被执行之后产生的异常。这些通常是很难提前发现的,因为代码实际运行中会遇到。比较常见的如TypeError: Cannot read properties of undefined
这样的读取了undefined
的属性。运行时异常对比编译时异常的特点是代码执行到异常代码前都是会正常执行的
执行到a.b.c
前的打印能成功,异常抛出后后面的语句就不能执行了。运行时异常即可是这种引擎层面抛出的也可以是代码手动抛出的
而上面说的编译时异常,即使异常语句前的正常语句也是不会执行
异常传播
异常抛出就像事件冒泡一样具有传递性。如果一个异常没有被 catch,它会沿着函数调用栈一层层传播直到栈空。
异常会不断传播直到遇到第一个 catch。 如果都没有捕获,会抛出类似 unCaughtError,表示发生了一个异常,未被捕获的异常通常会被打印在控制台上
error 对象
Error
本身作为函数直接调用和被 new 调用的效果是一样的
const a = Error('a')
const b = new Error('b')
javascript 规范中总共有 8 中错误类型构造函数
- Error – 错误对象
- SyntaxError --解析过程语法错误(上面提到的编译时异常)
- TypeError – 不属于有效类型(上面举例的运行时异常)
- ReferenceError – 无效引用(严格模式下直接访问一个未定义的变量)
- RangeError – 数值超出有效范围
- URIError – 解析 URI 编码出错
- EvalError – 调用 eval 函数错误
- InternalError – Javascript 引擎内部错误的异常抛出, “递归太多”
Error 是错误的基类,其他类型都继承 Error 这个类
console.log(Object.getPrototypeOf(SyntaxError) === Error); // true
console.log(Object.getPrototypeOf(TypeError) === Error); // true
console.log(Object.getPrototypeOf(ReferenceError) === Error); // true
console.log(Object.getPrototypeOf(RangeError) === Error); // true
console.log(Object.getPrototypeOf(URIError) === Error); // true
console.log(Object.getPrototypeOf(EvalError) === Error); // true
默认的 error 对象只有一个 message 信息,很多时候对于错误的细分是很不好使,一般可以通过扩展这个错误对象,抛异常时抛出自定义的错误对象,在异常处理或时实现更精细化的处理
class ApiError extends Error {
constructor(message, code) {
super(message);
this.code = code
}
}
const err = new ApiError('xxx', 404)
err instanceof ApiError
一种常见的应用就是在 axios 处理的异常中抛出一个扩展的 ApiError 对象,传递错误信息、错误等,在错误处理时对于这种错误进行特殊处理。如自定义上报、catch 住不作为 js 异常上报。不进行这种处理的话平时比较常见的情况就是会造成 slardar 的中 js 错误部分会有很多 axios 抛出的噪音
除了扩展错误对象,目前有一个处于 stage 4 的 Error Cause 提案 https://github.com/tc39/proposal-error-cause。这个提案也是由阿里推进的国内的首个es提案
Chrome 96 版本目前还不可用,firefox 可用
通过传递给 Error 构造函数的第二个参数一个 cause 属性为一个 Error 对象,即可看到是哪个错误具体产生当前的错误,对于一些调用链路比较深的可可能存在多个异常抛出情况这个特性还是相当好用的,可以准确追踪。Error Cause 当然用自定义扩展错误也能够实现这个功能
async function doJob() {
const rawResource = await fetch('//domain/resource-a')
.catch(err => {
throw new Error('Download raw resource failed', { cause: err });
});
const jobResult = doComputationalHeavyJob(rawResource);
await fetch('//domain/upload', { method: 'POST', body: jobResult })
.catch(err => {
throw new Error('Upload job result failed', { cause: err });
});
}
try {
await doJob();
} catch (e) {
console.log(e);
console.log('Caused by', e.cause);
}
// Error: Upload job result failed
// Caused by TypeError: Failed to fetch
Error 的相关 api
- 改变堆栈帧数
默认情况下,V8 引发的几乎所有错误都具有一个 stack 属性,该属性保存最顶层的 10 个堆栈帧,格式为字符串 at xxx
Error.stackTraceLimit
Error.stackTraceLimit
属性指定堆栈跟踪收集的堆栈帧数。默认值为 10
,可以设置为任何有效的 JavaScript 数值。 更改将影响值更改后捕获的任何堆栈跟踪。如果设置为非数字值,或设置为负数,则堆栈跟踪将不会捕获任何帧
- 收集自定义异常
Error.captureStackTrace(error, constructorOpt)
这个 API 可以给自定义对象追加 stack 属性,达到模拟 Error 的效果,追加的 stack 表示调用 Error.captureStackTrace()
的代码中的位置的字符串。
function CustomError(message) {
this.message = message;
this.name = CustomError.name;
Error.captureStackTrace(this); // 给对象追加stack属性
}
try {
throw new CustomError('msg');
} catch (e) {
console.log(e)
}
需要注意的是stack
属性对于不同浏览器的格式是不一致的,通常而言监控 sdk 会统一做处理
这个方法支持传递一个constructorOpt
参数,表示所有 constructorOpt
以上的帧,包括 constructorOpt
,都将从生成的堆栈跟踪中省略。具体的差异如下
使用这个参数可以用于调用栈过深时隐藏深层次的一些调用细节
- sourcemap 还原错误
还原错误也是利用了 error 对象的 stack 属性。可以使用stacktracey
和source-map
实现根据错误堆栈还原到实际发生错误的代码
线上代码经过压缩后一般只有 1 行,对于定位原始错误是很困难的。并且默认的e.stack
属性是个字符串,可以借助stacktracey
进行解析并结合source-map
进行反解
const sourceMap = require('source-map');
const SourceMapConsumer = sourceMap.SourceMapConsumer;
const Stacktracey = require('stacktracey');
const errorStack = '...'; // 错误信息
const sourceMapFileContent = '...'; // sourcemap文件内容
const tracey = new Stacktracey(errorStack); // 解析错误信息
const sourceMapContent = JSON.parse(sourceMapFileContent);
const consumer = await new SourceMapConsumer(sourceMapContent);
for (const frame of tracey) { // 这里的frame就是stack中的一行所解析出来的内容
// originalPosition不仅仅是行列信息,还有错误发生的文件 originalPosition.source
const originalPosition = consumer.originalPositionFor({
line: frame.line,
column: frame.column,
});
// 拿到错误所对应的源码以及上面的行列号
const sourceContent = consumer.sourceContentFor(originalPosition.source);
console.log(sourceContent);
}
自动、手动抛出
异常可手动抛出也可自动抛出
自动抛出:代码执行报错由引擎抛出。这种由于逻辑缺失容错造成的自动抛出错误应该是要尽最大程度杜绝并防范的
const a = {}
a.b.c = 1
手动抛出:直接调用throw
那什么时候应该手动抛出异常呢?一个指导原则就是已经预知到程序不能正确进行下去了。
switch (type) {
case a:
break;
case b:
break;
default:
throw new Error('xxxx')
}
抛出异常还是返回特定错误信息
对于上面提到可预知的异常需要终止流程,也可以使用抛出异常或者返回特定数据来让调用方感知。
- 抛出异常
好处,调用方无需判断返回值,抛出异常默认就不会走后面的逻辑代码了。常见于 axios 对于 code 非 0 的异常抛出处理并自定义上报。再结合上面提到的扩展 error 对象,可以在监控上报前判断属于特定错误不作为 js 上报,避免网络异常造成的 js 错误增加噪音
instance.interceptors.response.use(async (res: AxiosResponse<Result>) => {
if (res?.data?.statusCode !== 0) {
throw new ApiError('xx', res?.data?.statusCode)
}
return res
})
- 不抛异常而是返回特定信息
如果上述的代码不抛出异常而是直接返回 res 的话,每一处调用就都要手动判断 code。接口 http 返回 http code 200 而响应体 code 不等于 0 也属于不抛异常而是返回特定信息的方式
const res = await api()
if (res.code !==0) {
return
}
异常处理
同步、异步
try-catch 作为 JavaScript 中处理异常的一种标准方式,如果 try 块中的任何同步代码发生了错误,就会立即退出代码执行过程,然后执行 catch 块。此时 catch 块会接收到一个包含错误信息的对象。try-catch 使用时也可以搭配 finnally 使用。 finally 一经使用,其代码无论如何都会执行。对于异步调用可封装成 promise 的 catch 方法进行调用或借助 async/await 语法糖使用 try/catch
try {
fn()
} catch(e) {}
fn().then(() =>{}, () => {
// catch
})
fn().catch(() => {})
try { await fn() } catch(e) {}
可能到处 try catch 确实不是一种优雅的方式,可以进行适当的封装
- 对于异步 promise 调用可以直接使用await-to-js,利用 Promise 的特性,分别在 promise.then 和 promise.catch 中返回不同的数组,其中 fulfilled 的时候返回数组第一项为 null,第二个是结果。rejected 的时候,返回数组第一项为错误信息,第二项为 undefined。使用的时候,判断第一项是否为空,即可知道是否有错误
import to from 'await-to-js';
async function asyncTask() {
let err, user, savedTask;
[err, user] = await to(UserModel.findById(1));
if(!user) throw new CustomerError('No user found');
[err, savedTask] = await to(TaskModel({userId: user.id, name: 'Demo Task'}));
if(err) throw new CustomError('Error occurred while saving task');
if(user.notificationsEnabled) {
const [err] = await to(NotificationService.sendNotification(user.id, 'Task Created'));
if (err) console.error('Just log the error and continue flow');
}
}
- 对于 class 方法调用适当使用装饰器进行 catch
export function CatchAsync(errorLabel = '') {
return function (target: unknown, key: string, descriptor: PropertyDescriptor) {
errorLabel = errorLabel || key
const originFn = descriptor.value
descriptor.value = async function (...rest: unknown[]) {
try {
return await originFn.call(this, ...rest)
} catch (e) {
// do something
throw e
}
}
}
}
export function CatchSync(errorLabel = '') {
return function (target: unknown, key: string, descriptor: PropertyDescriptor) {
errorLabel = errorLabel || key
const originFn = descriptor.value
descriptor.value = function (...rest: unknown[]) {
try {
return originFn.call(this, ...rest)
} catch (e) {
// do something
throw e
}
}
}
}
class A {
@CatchAsync('1')
async request() {
}
}
Promise catch 小细节
以下两种写法的区别
then(f1,f2) vs then(f1).catch(f2)
绝大多数情况下是相同的。
区别在于第一种写法 f2 无法捕获 f1 中的异常。第二种写法 f2 能捕获 f1 中的异常
全局兜底
对于无需手动捕获或者没有捕获的异常最终会抛到全局。通过全局error
和unhandledrejection
进行监听并处理。监听全局异常和未捕获的 Promise 异常并进行相关处理
function onReject(e) {
// ...
report(e)
}
window.addEventListener('unhandledrejection', onReject, true)
window.addEventListener('error', this.onError, true)
window.onerror
和window.addEventListener error
的区别
window.onerror
函数返回 true 可以阻止执行默认事件处理函数(即控制台没有 error 打印出来)window.addEventListener error
若为捕获阶段,则可额外捕获静态资源的加载错误。window.onerror
则无法捕获静态资源的加载错误
React 中的异常
白屏异常
React 处理阶段的同步代码报错,整个组件树挂了导致卸载掉,页面展示白屏
- 生命周期函数报错
- render 方法报错
- 构造函数报错
上述提到的是同步代码报错,异步代码的报错是不会产生页面白屏,只是会产生一些 console 中的 error。同理,因为事件回调函数的处理不是在 React 处理阶段(初始化或者事件处理setState
驱动 react 进行下次渲染的),所以事件处理函数中的报错同样不会触发白屏
Error Boundary
既然白屏问题如此严重,必须要有一种方式帮助开发者来感知 React 中的白屏问题。 于是 React16 就有了Error Boundary
来用来捕获渲染时错误的概念,在 React 新增了两个生命周期componentDidCatch
和static getDerivedStateFromError
用于捕获渲染时的错误,也仅能捕获上面提到的白屏异常(如异步错误等也是没有办法被捕获到),也就是说如果我们在Error Boundary
中捕获到错误并上报,这个错误通常是非常严重的。Error Boundary
只可用于捕获子组件中发生的异常(自身出现渲染错误也是无法捕获的)
无法捕获的异常
-
事件处理
-
异步代码(例如
setTimeout
或requestAnimationFrame
回调函数) -
服务端渲染
-
它自身抛出来的错误(并非它的子组件)
- componentDidCatch
用于出错时去执行的副作用代码,比如错误上报、错误兜底等
- static getDerivedStateFromError
在出错后触发,改函数返回的值能进行 setState 更新,触发一次重新 render 来渲染错误时的 fallback 组件。如果这次渲染仍然出现渲染错误,页面仍然会白屏,而不是执行类似 render error -> getDerivedStateFromError -> render error 这样的死循环
class Demo extends Component {
state = {
error: false
}
static getDerivedStateFromError(){
return {
error: true
}
}
componentDidCatch(error, info){
this.setState({
error: true
})
}
render() {
if (this.state.error) {
return <div>error</div>
}
// Child中发生渲染错误
return <div>
<Child/>
</div>
}
}
`
static getDerivedStateFromError
渲染阶段调用的,所以不允许出现副作用componentDidCatch
【commit】阶段被调用,所以允许出现副作用
目前 React 的 Error Boundary 提供的两个生命周期只存在于 class 组件;并没有相应的 hooks 能实现类似的功能
There are no Hook equivalents to the uncommon
getSnapshotBeforeUpdate
,getDerivedStateFromError
andcomponentDidCatch
lifecycles yet, but we plan to add them soon.
但是有一个比较有趣的是,Preact 提供了相应的 hook useErrorBoundary去实现 Error Boundary。preact 中的useErrorBoundary
的功能和getDerivedStateFromError
、componentDidCatch
是一模一样的
// error = The error that was caught or `undefined` if nothing errored.
// resetError = Call this function to mark an error as resolved. It's
// up to your app to decide what that means and if it is possible
// to recover from errors.
const [error, resetError] = useErrorBoundary();
用法也是非常简单,子组件触发异常会触发函数组件的 render 并且 error 是对应的错误信息,并且还提供了对应的 resetError 去重置错误。至于为何 Preact 能先于 React 支持功能,原因在于对于 Preact 的实现来说,它的函数组件和 class 组件都是实例化成一样的实例,函数组件的 hook 中直接定义componentDidCatch
进行处理,componentDidCatch 捕获到错误后通过setState
设置错误对象驱动下一次的 render 来拯救白屏
export function useErrorBoundary(cb) {
const state = getHookState(currentIndex++, 10);
const errState = useState();
state._value = cb;
if (!currentComponent.componentDidCatch) {
currentComponent.componentDidCatch = err => {
if (state._value) state._value(err);
errState[1](err);
};
}
return [
errState[0],
() => {
errState[1](undefined);
}
];
}
虽然这是一个 react 的 Error Boundary 只存在于 class 组件,但是对于子组件是函数组件的情况下,相关 hooks 的异常(useEffect
、useLayoutEffect
)一样是能捕获到的
实践
这么基础常用的 error-boundary 通常来说不需要我们手动去搞。开源社区已经有了成熟的封装解决方案react-error-boundary。它基于 React 提供的 error boundary 能力提供了开箱即用的功能,使用的时候只需要将我们的组件作为ErrorBoundary
的子组件传入即可,并且 ErrorBoundary 还提供 FallbackComponent 属性供出错时渲染 fallback 内容、错误恢复等许多更进阶的功能。并且也提供了 HOC 的方式供使用
Error Boundary 包子组件
import {ErrorBoundary} from 'react-error-boundary'
function ErrorFallback({error, resetErrorBoundary}) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
const ui = (
<ErrorBoundary
FallbackComponent={ErrorFallback}
>
<ComponentThatMayError />
</ErrorBoundary>)
高阶组件
import {withErrorBoundary} from 'react-error-boundary'
const ComponentWithErrorBoundary = withErrorBoundary(ComponentThatMayError, {
FallbackComponent: ErrorBoundaryFallbackComponent,
onError(error, info) {
// Do something with the error
// E.g. log to an error logging client here
},
})
// or
@withErrorBoundary({
FallbackComponent: ErrorBoundaryFallbackComponent,
onError(error, info) {
// Do something with the error
// E.g. log to an error logging client here
},
})
class ComponentThatMayError extends Component {}
const ui = <ComponentWithErrorBoundary />
在需要使用的地方对我们的组件进行一层包装即可。这时候可能会一种需求,手动包一层太麻烦了,为啥 react 不提供一个配置字段每个组件自带 error boundary 呢?
万能的开源社区也有人通过 babel 插件实现了这个能力babel-plugin-transform-react-error-boundary
{
test: /.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: [
[ 'babel-plugin-transform-react-error-boundary', {
ErrorBoundary: 'common/components/ErrorBoundary/index.js'
} ]
]
}
}
}
通过配置一个自定义的 Error Boundary 路径,即可实现所有的组件包一个 ErrorBoundary 了,再结合react-error-boundary
一顿操作,页面再也不会白屏了。具体实现就是通过 babel 实现以下这样的转换
class TestComponent extends React.Component {
render() {
return <div />;
}
}
// 转换
const ErrorBoundary = require('./path/to/my/ErrorBoundary.js');
class TestComponent extends React.Component {
render() {
return <ErrorBoundary>{this.__r__()}</ErrorBoundary>;
}
__r__) {
return <div />;
}
}
上面提到 Error boundaries
是不支持 ssr 场景的,所以又有人做了一个针对 ssr 的 babel 插件
babel-plugin-transform-react-ssr-try-catch。 通过对 render 函数进行 trycatch 实现类似的功能
{
"plugins": [
["react-ssr-try-catch", {
// global errorHandler
"errorHandler": "./path/to/my/SSRErrorHandler.js",
// component error render method
"errorRenderMethod": "renderErrorState",
}]
]
}
class MyCompoenent extends React.PureComponent {
render() {
return <div/>;
}
}
// 转换
const ReactSSRErrorHandler = require('./path/to/my/SSRErrorHandler.js');
class MyCompoenent extends React.PureComponent {
render() {
try {
return this.__originalRenderMethod__();
} catch (e) {
return ReactSSRErrorHandler(e, this.constructor.name, this);
}
}
__originalRenderMethod__() {
return <div />;
}
}
实现更多的功能
Error Boundary 除了用于捕获错误,这个特性也可以用来实现 React Suspense 相关的功能
- Suspense + Lazy
// 用法
const ProfilePage = lazy(() => import('./ProfilePage'));
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>
// 最简化实现Lazy + Suspense
function lazy(loader) {
let prom;
let component;
let error;
function Lazy(props) {
if (!prom) {
prom = loader();
prom.then(
exports => {
component = exports.default || exports;
},
e => {
error = e;
}
);
}
if (error) {
throw error;
}
// Lazy组件的render中,若组件未加载完成,抛出一个promise异常供Suspense的componentDidCatch捕获
if (!component) {
throw prom;
}
return createElement(component, props);
}
return Lazy;
}
export class Suspense extends React.Component {
state = {
isLoading: false
};
componentDidCatch(error) {
if (this._mounted) {
if (typeof error.then === 'function') {
this.setState({ isLoading: true });
error.then(() => {
if (this._mounted) {
this.setState({ isLoading: false })
}
});
}
}
}
componentDidMount() {
this._mounted = true;
}
componentWillUnmount() {
this._mounted = false;
}
render() {
const { children, fallback } = this.props;
const { isLoading } = this.state;
return isLoading ? fallback : children;
}
}
- Suspense + render 中的【同步】数据获取
const fetchApi = () => {
// 异步api
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("数据");
}, 3000);
});
}
const useData = () => {
const [data,setData] = useState()
useEffect(() => {
fetchApi().then((x) => setData(x))
},[])
return data
}
const ProfilePage = () => {
const data = useData()
if (!data) {
return <Loading/>
}
return <div>{data}</div>
}
const fetchApi = () => {
// 异步api
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("数据");
}, 3000);
});
}
const getData = createFetcher(fetchApi);
const ProfilePage = () => {
const data = getData()
return <div>{data}</div>
}
const Demo = () => {
return <Suspense fallback={<Loading />}>
<ProfilePage />
</Suspense>
}
var cached = {};
export const createFetcher = (promiseTask) => {
let ref = cached;
return () => {
if (ref !== cached) {
return ref
}
const task = promiseTask();
task.then(res => {
ref = res
});
if (ref === cached) {
throw task
}
}
}
Vue 中的异常
vue 提供了 4 个异常处理的 API,分别是 errorHandler,errorCaptured,renderError,warnHandler
。
errorHandler
我们最常用的是全局配置中注册的 errorHandler
,例如异常上报场景,可用如下代码:
Vue.config.errorHandler = function (error, vm, info) {
window.Slardar && window.Slardar('Sentry', (Sentry) => {
Sentry.captureMessage(error);
});
}
errorHandler
可以捕获 render
(vue 模板)、生命周期钩子、watch 回调、methods 方法等函数内的同步代码异常,info 参数会接收到报错函数类型(render/mounted/…);如果这些函数返回 promise ,则 promise 异常也会被捕获;
errorCaptured
errorCaptured
入参和 errorHandler
一样,它是 vue 组件的钩子函数,作用是捕获来自后代组件(注意不包含本组件)的错误。 vue 中的错误传播规则可以总结为,从子到父传播,依次触发各组件的 errorCaptured
钩子,若某 errorCaptured
返回 false,则停止传播,否则最终会传播到全局的 errorHandler
;
使用场景:我们可以在组件库等场景使用 errorCaptured
,捕获内部异常并上报,从而避免和业务代码报错混淆;
renderError
renderError
只在开发者环境下工作,当 render
函数报错时,其错误将会作为第二个参数传递到 renderError
,renderError
返回的 vnode
将会被渲染。
new Vue({
render (h) {
throw new Error('oops')
},
renderError (h, err) {
return h('pre', { style: { color: 'red' }}, err.stack)
}
}).$mount('#app')
使用场景:renderError
可用于开发环节实时把组件错误渲染到页面;
warnHandler
warnHandler
和 errorHandler
一样是全局配置项,但 warnHandler
只在开发者环境下生效,用于捕获 vue 告警。
Vue.config.warnHandler = function (msg, vm, trace) {
// `trace` 是组件的继承关系追踪
}
使用场景:一般情况下开发者直接在控制台查看 warn,所以 warnHandler
使用场景非常有限。
参考
React,优雅的捕获异常 - 掘金
精读《React Error Boundaries》
React:Suspense 的实现与探讨