引言
前端错误治理是保障用户交互体验的基础。在实际前端开发过程中,我们往往需要对各种异常情况进行处理,但由于编码疏忽或者依赖资源异常导致的一些错误没有被 catch 到,也是很难避免的。本文记录了一次实际项目中的错误治理过程,梳理了一些常见的错误追踪方法和工具。
问题发现
最近接手了一个 Rax 项目,通过监控工具看到项目整体错误率偏高,其中 99% 的错误信息都是 “Script error.”,于是尝试解决。
错误栈获取
首先,监控工具通过监听全局 error 和 unhandledrejection 事件来获取未处理的页面错误,并统计汇总到报表上。报表内容如下:
所有错误都没有错误信息,更不要说错误栈了,检查了下源码,script 标签内的脚本跨域了,为了获取这些 js 脚本的错误,需要为 script 标签增加 crossOrigin 属性,CDN 也需要添加跨域头,核心代码如下:
import { createElement } from 'rax';
import { Root, Style, Script} from 'rax-document';
import css from './css';
function Document() {
return (
<html>
<head>
<script crossorigin="anonymous" src="//cnd.com/corss-origin.js"></script>
</head>
<body id="body">
{/* root container */}
<Root />
<Script crossorigin="anonymous" />
</body>
</html>
);
}
export default Document;
Rax 的 Script 组件实现了一个 html script 标签,和 html script 标签的修改方式是一样的,这样这两个 script 标签内的 js 脚本出错的话,错误栈都可以被获取到。
另外,需要在 build 的时候开启 source-map(需要 Rax 版本 1.0.0 以上),修改 build.json,加入 sourceMap
配置:
{
"sourceMap": "source-map"
}
这样就可以把错误栈和源码对应起来了,否则看到的错误栈都是 pack 之后的源码位置,就无法分析了。 然后重新编译并上线:
$ npm run build
$ ls ./build/web/index.js.map
./build/web/index.js.map
错误栈分析
发布之后继续跟踪:
可以看到有一个 TypeError:Cannot set property 'tn' of null
的错误占了全部错误的 57.4%,另外还是有 36% 的 Script error.
, 这应该是某些动态引入的 script 标签没有加上 crossOrigin
导致的,先解决主要问题。
打开错误栈:
然后,我们需要用到一个 source map 工具 source-map-cli
,这个工具可以帮我们把 pack 之后的错误栈的信息映射到源码,使用也很简单:
$ npm install source-map-cli -g
$ source-map resolve /path/to/source-map-file.map 1 127855 # 行号 列号
结合错误栈,我们可以拿到如下信息:
$ source-map resolve ./index.js.map 1 127855
Maps to webpack:///node_modules/_rax@1.1.4@rax/dist/rax.min.js:1:10078 (this)
!function(){var P={n:1,t:!1,driver:null,rootComponents:{},rootInstances:{}... # 省略了其他源码
$ source-map resolve ./index.js.map 1 119884
Maps to webpack:///node_modules/_rax@1.1.4@rax/dist/rax.min.js:1:2092 (t)
!function(){var P={n:1,t:!1,driver:null,rootComponents:{},rootInstances:{}... # 省略了其他源码
$ source-map resolve ./index.js.map 1 428416
Maps to webpack:///src/component/A/index.jsx:100:8 (animation)
animation({
^
$ source-map resolve ./index.js.map 1 428729
Maps to webpack:///src/components/A/index.jsx:137:2 (useEffect)
useEffect(() => {
^
$ source-map resolve ./index.js.map 1 135896
Maps to webpack:///node_modules/_appear-polyfill@0.1.1@appear-polyfill/lib/intersectionObserverManager.js:73:6 (target)
target.setAttribute('data-has-appeared', 'true');
^
$ source-map resolve ./index.js.map 1 135396
Maps to webpack:///node_modules/_appear-polyfill@0.1.1@appear-polyfill/lib/intersectionObserverManager.js:16:106 (window)
if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
^
栈顶两个调用栈都在 rax 内,而且因为打包时 rax 是通过 pack 之后的 rax.min.js 引入到当前项目中的,所以我们只能定位到 rax.min.js 内错误位置信息,但是第 3 个栈比较明确,看了代码是组件 A 中的动画组件的回调,核心代码如下:
animateFun.current = animation(
{} // 动画配置,
() => {
// 返回原位置
animation({ // 报错位置
props: [
{
element: refDoms[currentIndex],
property: 'transform.translateX',
easing: 'linear',
duration: 10,
start: -4,
end: 5,
delay: 0,
},
]
})
//设置下一页
let index = currentIndex + 1;
if (index >= data.brandItems.length) {
index = 0;
}
setCurrentIndex(index);
animateFun.current = null;
}
)
这里结合报错信息 Cannot set property 'tn' of null
可以初步判断出是动画回调函数中操作了某个不存在的 Rax VDOM 导致的问题,通过源码目录下的 rax.min.js.map 进一步跟踪 rax.min.js 的错误栈:
$ source-map resolve ./node_modules/_rax@1.1.4@rax/dist/rax.min.js.map 1 10078
Maps to ../src/vdom/reactive.js:92:2
componentWillMount() {
^
$ source-map resolve ./node_modules/_rax@1.1.4@rax/dist/rax.min.js.map 1 2092
Maps to ../src/hooks.js:26:46 (prevInputs)
if (isNull(prevInputs) || inputs.length !== prevInputs.length) {
^
打开源码文件:
- vdom/reactive.js
componentWillMount() { // 报错位置
this.__shouldUpdate = true;
}
- ../src/hooks.js
function areInputsEqual(inputs, prevInputs) {
if (isNull(prevInputs) || inputs.length !== prevInputs.length) { // 报错位置
return false;
}
for (let i = 0; i < inputs.length; i++) {
if (is(inputs[i], prevInputs[i])) {
continue;
}
return false;
}
return true;
}
这个报错栈信息看着不太对劲,不像是正确的位置,估计 rax 的构建环境和当前项目有什么差异。既然这条路不通,那根据报错信息 Cannot set property 'tn' of null
就查一下 rax.min.js 的原始位置:
在 rax.min.js 中搜索 .tn=
总共有 4 个结果,摘选如下:
t.componentWillUnmount=function(){k(this.willUnmount)},t.u=function(){this[S].tn=!0,this.setState(s)}
!s.un&&e&&yn(n,!0)):(s.tn=!0,e&&yn(n)))}var jn={setState:function(n,t,i){P.t||w(),bn(n,t,i)},forceUpdate:function(n,t)
unmountComponent(n),this[N]=null),this.in=null,this.tn=!1,this.v
v.shouldComponentUpdate(i,f,t):v.R&&(u=!R(e,i)||!R(o,f))),u?(l.tn=!1,r=v.context,v.componentWillUpdate&&v.componentWillUpdate(i,f,t)
可以发现基本上都是和组件生命周期或 state 管理相关的代码,所以判断问题应该是出现在组件生命周期结束后又调用了 VDOM 相关的代码导致的。
错误验证
先验证下错误栈,在动画组件回调函数中设置 timeout 5000 ms 后执行,然后页面添加手动销毁组件的测试代码,操作使 A 组件被销毁,复现当前问题:
reactive.js:143 Uncaught TypeError: Cannot set property '__isPendingForceUpdate' of null
at ReactiveComponent._proto.__update (reactive.js:143)
at setState (hooks.js:96)
at index.jsx:118
_proto.__update = function __update() {
this[_constant.INTERNAL].__isPendingForceUpdate = true; // 报错代码
this.setState(_types.EMPTY_OBJECT);
};
也印证了 rax.min.js
的第一个搜索结果:
t.componentWillUnmount=function(){k(this.willUnmount)},t.u=function(){this[S].tn=!0,this.setState(s)}
然后结合项目实际情况,确实在动画组件相关的组件中复现了这个错误。
修复
找到问题之后修复就比较简单了,引入 rax-use-mounted-state
组件,这个组件会将当前 VDOM 挂在一个闭包内,并提供一个 isMounted
函数判断 VDOM 是否已挂载。在所有外部回调函数执行前都加上 VDOM 是否挂载的判断:
animateFun.current = animation(
{}, () => {
// 异步回调需要判断当前 DOM 是否被挂载
if (isMounted()) {
// 返回原位置
animation({
props: [
{
element: refDoms[currentIndex],
property: 'transform.translateX',
easing: 'linear',
duration: 10,
start: -4,
end: 5,
delay: 0,
},
]
})
//设置下一页
let index = currentIndex + 1;
if (index >= data.brandItems.length) {
index = 0;
}
setCurrentIndex(index);
animateFun.current = null;
}
});
发布验证
发布后错误率降低了 68%,符合预期。
总结
本次 debug 过程有点复杂,涉及到 source map 配置,source-map-cli 使用,甚至需要阅读一部分 pack 后的代码,总体来说这个过程还是有很大的优化空间的,比如错误栈分析完全通过工具自动完成,就不用排查得这么累了。 问题本身的原因在排查过程中已经明确了,由于动画组件的 callback 是在生命周期之外的,因此会出现 callback 回调时 DOM 已经被 unmounted 的可能性。但实际上,不仅动画组件的 callback,其他 callback 的场景都会有类似的情况,比如 setTimeout,建议所有涉及 DOM 修改的 setTimeout 全部替换成 useTimeout, 但是 useTimeout 不能在 hooks 内使用,在 hooks 内的定时器可以在 return 函数内 clearTimeout:
useEffect(() => {
const id = setTimeout(() => {}, 100);
return () => clearTimeout(id);
});
此外,在验证过程中,还发现一个情况是在 onDisappear 事件回调中,也会出现 DOM 不存在的情况,可以在 onDisappear 事件回调中用使用 useMountedState 判断当前 DOM 存在后再进行 state 操作,否则也会出现相同的问题。
import useMountedState from 'rax-use-mounted-state';
export default memo((props={}) => {
const isMounted = useMountedState();
const [value, setValue] = useState(true);
const disappearHandler = useCallback(() => {
if (isMounted()) {
setValue(false);
}
}, [setValue]);
return <View onDisappear={() => disappearHandler()} />
})