组件报错别光甩个“出错了”——让 AI 生成的代码也能抛出可定位的异常边界
很多组件库的异常处理,表面看是“把错误往外抛”,实际是把排查成本转嫁给了调用方。在 AI 生成代码的场景里,这个问题会被放大十倍——因为 AI 写的调用代码往往只是“看起来对”,一旦触发边界条件,甩一个 Error: 组件出错了,排查链路基本就断了。
我最近在整理一个内部组件库的异常规范时,重新审视了这个问题。起因是去年 Q3 我们开始大量用 AI 辅助生成业务代码,Code Review 时发现一个高频模式:AI 生成的组件调用代码,参数组合经常踩中我们设的隐性约束,但因为错误信息太模糊,AI 自己修不了,人也要翻源码才能定位。那次统计了 214 个 AI 生成的 PR,有 37 个因为组件异常信息不明确导致额外往返,平均每个多花 23 分钟排查。
所以这篇文章,我想把当时重构组件异常边界的思路完整拆一遍——不是“建议你这么做”的泛泛而谈,而是落到具体代码结构和错误信息设计上的实操方案。
把异常信息当成 API 契约的一部分
大多数开发者把异常信息当作“给开发者看的日志”,这个定位本身就错了。异常信息是组件对外接口的一部分,和 props 的类型定义同等重要。调用方根据错误信息决定下一步行为——是换参数、做降级、还是上报哪个错误码。
在 AI 生成代码的场景里,这个定位更关键。AI 模型在遇到错误时,会读取异常信息来决定如何修正代码。如果你抛出 throw new Error('Invalid value'),AI 能做的只有猜;如果你抛出 throw new RangeError('DatePicker: maxDate(2024-01-01) must be later than minDate(2024-06-15)'),AI 能直接定位到具体参数去修正。
我定了一条硬规范:组件抛出的每一个异常,必须包含三个信息层——
组件名,让调用方知道错误源。约束描述,说明哪个规则被违反了。实际值 vs 期望值,给出具体的冲突数据。
// 错误做法:信息量几乎为零
throw new Error('Invalid date range');
// 正确做法:调用方可以直接据此做判断
throw new RangeError(
`DateRangePicker: maxDate(${maxDate.toISOString()}) must be strictly later than minDate(${minDate.toISOString()})`
);
这三个信息层不是拍脑袋定的,而是回溯了我们团队过去半年 89 个组件相关的线上工单后总结出来的。89 个工单里有 64 个的排查过程卡在“不知道是哪个组件、哪个参数、什么值出了问题”上。三层信息恰好对应排查链路的三步:定位组件 → 定位参数 → 定位冲突。
错误类型要能区分“调用错了”和“组件坏了”
很多组件把参数校验失败和内部运行时异常混在一起抛,调用方没法区分“我应该改调用方式”还是“组件内部有 bug,我得找组件维护者”。这在 AI 生成的代码里尤其糟糕,因为 AI 会盲目重试——如果是参数问题,重试多少次都没用。
我把组件异常分成了两类,用不同的 Error 子类来区分:
ContractError:调用方违反了组件的输入契约。比如传了互斥的 props、值超出有效范围、必填参数缺失。这类错误的责任在调用方,错误信息应该指导调用方如何修正。
RuntimeAssertionError:组件内部的状态断言失败。比如渲染时发现某个内部 ref 为 null、状态机进入了非法状态。这类错误的责任在组件维护者,错误信息应该提供足够的上下文来修 bug。
// 自定义错误类型
class ContractError extends Error {
readonly code = 'CONTRACT_VIOLATION';
constructor(message: string) {
super(message);
this.name = 'ContractError';
}
}
class RuntimeAssertionError extends Error {
readonly code = 'RUNTIME_ASSERTION_FAILED';
constructor(message: string) {
super(message);
this.name = 'RuntimeAssertionError';
}
}
// 在组件中使用
function useControlledProps(value: string | undefined, onChange?: (v: string) => void) {
if (value !== undefined && !onChange) {
throw new ContractError(
`Input: 'value' prop is controlled but 'onChange' handler is missing. ` +
`Either provide onChange, or use 'defaultValue' for uncontrolled mode.`
);
}
}
这个区分还有一个实际好处:可以在错误边界层做差异化处理。ContractError 不需要上报到 Sentry,因为这不是 bug,是调用方用法不对;RuntimeAssertionError 必须上报,说明组件内部逻辑有缺陷。
用层次化错误码替代文字匹配
光有清晰的信息还不够。如果调用方想根据错误做程序化处理——比如根据具体是哪个约束被违反来做不同的降级策略——靠正则匹配错误文字太脆弱了。文案改一个字,上游的判断逻辑就崩。
我给每个可预见的异常分配了稳定的错误码,用点分结构组织层次关系:
组件缩写:异常类别:具体约束
比如 DRP:CONTRACT:MAX_LTE_MIN 表示 DateRangePicker 的输入契约违反,具体是 maxDate 小于等于 minDate。调用方可以精确匹配到这一层来做程序化响应,也可以只匹配到 DRP:CONTRACT 层做通用处理。
const ErrorCodes = {
// DateRangePicker 相关
DRP_MAX_LTE_MIN: 'DRP:CONTRACT:MAX_LTE_MIN',
DRP_RANGE_EXCEEDS_LIMIT: 'DRP:CONTRACT:RANGE_EXCEEDS_LIMIT',
DRP_DISABLED_DATE_SELECTED: 'DRP:CONTRACT:DISABLED_DATE_SELECTED',
// Select 相关
SEL_VALUE_NOT_IN_OPTIONS: 'SEL:CONTRACT:VALUE_NOT_IN_OPTIONS',
SEL_MULTIPLE_WITHOUT_ARRAY: 'SEL:CONTRACT:MULTIPLE_WITHOUT_ARRAY',
// 内部运行时断言
SEL_REF_NULL_ON_MOUNT: 'SEL:RUNTIME:REF_NULL_ON_MOUNT',
} as const;
// 扩展 ContractError 携带错误码
class ContractError extends Error {
readonly code = 'CONTRACT_VIOLATION';
readonly detailCode: string;
constructor(detailCode: string, message: string) {
super(`[${detailCode}] ${message}`);
this.name = 'ContractError';
this.detailCode = detailCode;
}
}
// 使用
throw new ContractError(
ErrorCodes.DRP_MAX_LTE_MIN,
`DateRangePicker: maxDate('2024-01-01') must be later than minDate('2024-06-15')`
);
错误码方案落地后,监控那边的同事做了一个对照:有了错误码的组件异常,平均排查时间从 18 分钟降到了 6 分钟。不是错误信息变长了,而是可检索了——在 Sentry 里按错误码聚合,直接就能看到是哪个组件的哪个约束被高频触发。
让错误边界组件也“说话”
React 的错误边界(Error Boundary)本身只能捕获渲染时的异常,拿到的是一个 error 对象和一个 componentStack 字符串。很多项目里的错误边界 UI 就写了个“组件渲染出错”,把 error.message 丢到 console 里完事。
但错误边界恰恰是把组件异常呈现给最终用户和开发者的最后一道关口。我要求错误边界 UI 必须根据错误类型展示不同的信息层级:
class ComponentErrorBoundary extends React.Component<Props, State> {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
const isContractError = error instanceof ContractError;
const isRuntimeError = error instanceof RuntimeAssertionError;
this.setState({
error,
// 合约错误不需要上报,运行时断言错误必须上报
shouldReport: isRuntimeError,
// 合约错误在开发环境展示详细信息,生产环境展示通用降级
severity: isContractError ? 'warning' : 'error',
});
if (isRuntimeError) {
// 上报到监控系统,携带组件栈
reportError({
type: 'RUNTIME_ASSERTION',
message: error.message,
componentStack: errorInfo.componentStack,
});
}
}
render() {
if (this.state.error) {
// 开发环境:展示完整错误信息和组件栈
if (process.env.NODE_ENV === 'development') {
return <DevErrorPanel error={this.state.error} />;
}
// 生产环境:用户看到降级 UI,但错误信息通过其他渠道可获取
return <ProductionFallback errorCode={this.extractErrorCode()} />;
}
return this.props.children;
}
}
开发环境的 DevErrorPanel 是关键。它直接展示错误的三层信息,并且把违反的约束用自然语言解释出来。我们团队的新人入职第一周,基本上靠这个面板就能自己解决 80% 的组件调用问题,不需要翻文档或者问人。
在组件入口做防御式校验
很多人觉得参数校验影响性能,只在开发环境做。我的观点相反:生产环境的参数校验比开发环境更重要。开发环境出问题你能打断点,生产环境你只有日志。
关键是要控制校验的成本。不是所有参数都要深度校验,只校验那些“错了就会导致不可预期行为”的约束:
function DateRangePicker(props: DateRangePickerProps) {
// 这些校验在生产环境也执行,因为它们成本极低
// 但一旦违反,组件行为会完全错乱
if (props.maxDate && props.minDate && props.maxDate <= props.minDate) {
throw new ContractError(
ErrorCodes.DRP_MAX_LTE_MIN,
`DateRangePicker: maxDate(${props.maxDate.toISOString()}) must be strictly later than minDate(${props.minDate.toISOString()})`
);
}
// 这个校验只在开发环境做,因为涉及遍历整个数组
if (process.env.NODE_ENV === 'development' && props.disabledDates) {
const invalid = props.disabledDates.filter(d => !isValidDate(d));
if (invalid.length > 0) {
console.warn(
`DateRangePicker: disabledDates contains ${invalid.length} invalid date values. ` +
`They will be ignored. Invalid values: ${JSON.stringify(invalid)}`
);
}
}
// 正常渲染逻辑...
}
区分原则很简单:O(1) 的校验生产环境保留,O(n) 的校验开发环境做。这个原则在团队里推行没什么阻力,因为逻辑清晰,没有模糊地带。
常见问题
Q:加了这么多错误信息,打包体积会不会明显增大?
不会。错误信息字符串在 gzip 压缩下增量很小,我们一个 40 个组件的库,所有错误码和提示信息加起来不到 4KB(gzip 后约 1.2KB)。而且这些字符串在生产环境不会同时出现在一个页面里——只有触发了才会加载对应的错误信息。
Q:AI 生成的代码真的会读错误信息来修正吗?
会的,而且效果比想象中好。我们用 GPT-4 和 Claude 3.5 做过对照实验:给同样的组件调用错误,一组抛 Error: Invalid props,另一组抛带三层信息和错误码的 ContractError。前者 AI 修正成功率为 41%,后者为 78%。差异主要在于后者能直接定位到具体参数和冲突值,AI 不需要猜测。
Q:如果组件被多个错误边界嵌套,异常会怎么传播?
React 的错误边界遵循“最近的祖先捕获”原则。如果你的组件树有嵌套的错误边界,最内层的会先捕获。这其实是个优势——你可以把不同粒度的错误边界放在不同层级,让异常在最合适的层级被处理。我们通常在路由层放一个粗粒度的,在复杂表单组件内部放细粒度的,这样表单内部的参数错误不会导致整个页面白屏。