"Script error." 排查和治理

avatar
阿里巴巴 前端委员会智能化小组 @阿里巴巴

文/ 阿里淘系 F(x) Team - 牟牟

引言

前端错误治理是保障用户交互体验的基础。在实际前端开发过程中,我们往往需要对各种异常情况进行处理,但由于编码疏忽或者依赖资源异常导致的一些错误没有被 catch 到,也是很难避免的。本文记录了一次实际项目中的错误治理过程,梳理了一些常见的错误追踪方法和工具。

问题发现

最近接手了一个 Rax 项目,通过监控工具看到项目整体错误率偏高,其中 99% 的错误信息都是 “Script error.”,于是尝试解决。

错误栈获取

首先,监控工具通过监听全局 errorunhandledrejection 事件来获取未处理的页面错误,并统计汇总到报表上。报表内容如下:

image-20201026162357018.png

所有错误都没有错误信息,更不要说错误栈了,检查了下源码,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

错误栈分析

发布之后继续跟踪: image-20201026163543344.png 可以看到有一个 TypeError:Cannot set property 'tn' of null 的错误占了全部错误的 57.4%,另外还是有 36% 的 Script error., 这应该是某些动态引入的 script 标签没有加上 crossOrigin 导致的,先解决主要问题。

打开错误栈: image.png 然后,我们需要用到一个 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) {
                                              ^

打开源码文件:

  1. vdom/reactive.js
componentWillMount() { // 报错位置
  this.__shouldUpdate = true;
}
  1. ../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()} />
})

F(X)Team 开通 微博 啦!
除文章外还有更多的团队内容等你解锁🔓

image.png