老铁,听说你的setTimeout不会触发,咋整

3,962 阅读4分钟

最近在一台安卓手机的webview里面遇到一个神奇的问题,setTimeout不会触发了。起因是笔者用了一个动画库,这个动画库调了它的初始化方法后没有生成DOM元素,经过一番排查,最后发现是有一个地方的setTimeout回调没有执行,如下图所示:

setTimeout有被执行到,但是它的回调始终没有执行,我直接在控制台上执行setTimeout也没有效果,如下图所示:

这是为啥呢?经检验setTimeout没有被覆盖,还是原生的那个。这个的UA为安卓8,如下图所示:

在网上搜罗一番,只在Stackoverflow找到一个相关的Q&A,但是没有办法解决,唯一能解决的方法是重启APP或者重启机器有时候就可以了。那怎么办呢,难道只能坐以待毙,跟他们说这个问题是手机的bug,无法解决?有没有办法hack一下

试了下setInterval也是同样现象,无法触发,推测可能事件循环有点混乱了。又试了下requestAnimationFrame可以用,似乎看到了曙光,这个也是另外一种异步的机制,可以在requestAnimationFrame里面判断时间是否接近设定的时间,如果是的话,那就执行回调,也就是说用requestAnimationFrame来polyfill setTimeout.

第一步,需要判断一下setTimeout是否能运行,如果不能的话才进行覆盖。怎么判断呢?自然是setTimeout里面设置一个变量,如果设置生效说明能运行,如下代码所示:

let setTimeoutWork = false;
setTimeout(() => {
  setTimeoutWork = true;
}, 0);

接着第二步,polyfill需要在什么时机判断这个变量有没有被设置成功?可以在requestAnimationFrame里面,如下代码所示:

function hackSetTimeout() {
  if (setTimeoutWork) {
    return;
  }
  console.warn('setTimeout not work!');
}
window.requestAnimationFrame(hackSetTimeout);

按理说requestAnimationFrame应该会更慢于setTimeout 0,然鹅,我们发现,这个requestAnimationFrame居然比setTimeout 0更快执行,如下图所示:

requestAnimationFrame是在0.3ms之后执行,而setTimeout是在1.1ms后执行的。而在火狐上结果是相反的:

这个可能是因为Chrome认为requestAnimationFrame比setTimeout 0拥有更高的优先级。不管怎么样,需要变一下,我们可以在第二次requestAnimationFrame的时候才去判断,如下代码所示:

let time = 0;
function hackSetTimeout() {
  // 等到第二次,setTimeout 0才会执行
  if (++time <= 1) {
    window.requestAnimationFrame(hackSetTimeout);
    return;
  }
  if (setTimeoutWork) {
    return;
  }
  console.warn('setTimeout not work!');
}
window.requestAnimationFrame(hackSetTimeout);

这个时候顺序就对了,如下图所示:

这个判断需要非常谨慎,因为我们不能够影响绝大多数正常的设备。

第三步对setTimeout进行覆盖,如下代码所示:

window.setTimeout = function(caller, time) { 
  let begin = Date.now();
  window.requestAnimationFrame(function call() { 
    if (Date.now() - begin > time) { 
      caller();
    } else { 
      window.requestAnimationFrame(call);
    } 
  });
  return 0;
};

逻辑很简单,就是利用闭包,设置一个beginTime,然后不断地requestAnimationFrame,当时间到的时候便执行传给setTimeout的回调,函数还要返回一个tId。

第四步,考虑clearTimeout如何实现,如下代码所示:

let tId = 0;
let tIdCancelMap = {};
let tIdCallers = [];

window.clearTimeout = function(tId) {
 tIdCancelMap[tId] = true;
};

window.setTimeout = function(caller, time) {
  tIdCallers[++tId] = caller;
  let begin = Date.now();
  window.requestAnimationFrame(function call() { 
    let _tId = tIdCallers.indexOf(caller);
    if (tIdCancelMap[_tId]) { 
      return;
    } 
    if (Date.now() - begin > time) { 
      caller();
    } else { 
      window.requestAnimationFrame(call);
    }
  });
  return tId;
};

如上代码所示,用一个tIdCallers数组保存所有的setTimeout回调,数组的索引便是tId,然后再用一个Map记录对应的tId有没有被cancel,当requestAnimationFrame回调触发执行的时候,先找一下caller所对应的tId,这个就是tIdCallers数组的作用,因为我们要想办法得到对应的tId(注意这里不能利用闭包里的tId,因为它永远是多次调用后最后的那个值),然后在canleMap里面看一下这个tId有没有被canel了,如果是的话到此结束,否则才比较时间。

setInterval也是用同样的方式,只是它在执行完回调后还要继续注册requestAnimationFrame,如下代码所示:

window.clearInterval = function(tId) { 
  tIdCancelMap[tId] = true;
};

window.setInterval = function(caller, time) { 
  tIdCallers[++tId] = caller;
  let begin = Date.now();
  window.requestAnimationFrame(function call() { 
    let _tId = tIdCallers.indexOf(caller);
    if (tIdCancelMap[_tId]) { 
      return;
    } 
    if (Date.now() - begin > time) { 
      caller();
      begin = Date.now();
      window.requestAnimationFrame(call);
    } else { 
      window.requestAnimationFrame(call);
    } 
  });
  return 0;
};

这样便解决了setTimeout回调不触发的问题,可能时间没有setTimeout的准,会稍微延后一点,可以进一步优化,例如当时间差绝对值小于某个数如10ms的时候便认为到时间了。在性能上也不会有太大的消耗,虽然requestAnimationFrame的触发比较快,但是我们里面的操作非常少,经观察,如果页面是纯静态的,注册了requestAnimationFrame会导致CPU上升到3% ~ 4%,而如果页面本身已经注册了requestAnimationFrame那么上升几乎就看不出来了。