前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >从一个超时程序的设计聊聊定时器的方方面面

从一个超时程序的设计聊聊定时器的方方面面

作者头像
LIYI
发布2022-03-08 12:07:00
1.4K0
发布2022-03-08 12:07:00
举报
文章被收录于专栏:艺述论专栏

目录

  1. 如何设计一个靠谱的超时程序
  2. JS引擎的运行机制是怎样的?
  3. 如何避免程序卡顿?
  4. 如何判断H5程序是从后台台恢复过来的?
  5. 如何理解定时器的丢弃行为?
  6. 在开发中如何选择使用合适的定时器?
  7. 有没有一键回收所有定时器的方法?
  8. 如何理解定时器中的this对象?
  9. 零超时定时器在冒泡链中的活用
  10. 能否写一个通用的、立马执行的、有总数限制的、时间间隔均等的定时器?
  11. 习题与答案

如何设计一个靠谱的超时程序?

企业项目开发中经常有这样一个逻辑场景:在界面上显示倒计时,时间到了给出提示,禁止用户操作。

这个逻辑,简单一点可以使用JS的定时器实现,每隔1秒钟检查一次剩余时间,时间到了终止计时给出提示,时间不到就更新计时界面。代码如下所示:

代码语言:javascript
复制
let totalSeconds = 10
let timerId = setInterval(_=>{
 // 业务逻辑代码
 totalSeconds--
 console.log(`时间剩余: ${totalSeconds}`)
 if (totalSeconds <= 0) {
   console.log(`时间到`)
   clearInterval(timerId)
 }
}, 1000)

代码1

输出是这样的:

从输出看没有问题,但这个实现有很大问题。没有错误并不代表写对了。

在上面的代码,函数setInterval将产生一个间隔定时器。JS定时器共有三种:间隔定时器、超时定时器、立即定时器。后两者分别由setTimeout、setImmediate产生,这两个函数稍后再讲。

看一下setInterval的使用语法:

代码语言:javascript
复制
var timerId = setTimeout(func, miliseconds, args...);

第一个参数func是一个函数,可以是匿名函数,也可以是命名函数,上面作者所用乃是es6的匿名箭头函数。

下面的代码为什么在小游戏中不能运行?

代码语言:javascript
复制
setTimeout("alert('5 秒!')",1000)

在小游戏中,setTimeout第一个参数必须为function类型,第二点alert属于BOM方法,是window对象的方法,在小游戏中没有window对象。

第二个参数miliseconds,顾名思义是毫秒,意即间隔多少毫秒执行一次参数1。因为是每间隔一段时间执行一次,所以起名为间隔计时器。

第三个及以后的参数args是不定参数,是在定时器触发时向参数1传递的实参。

setInterval返回的是定时器ID,这个ID在单程度内是唯一的且是递增的。向函数clearInterval传入定时器ID,便是清除了定时器,定时器便不再触发。超时之后如果忘记了清理,也有办法统一打扫,这个问题稍后再讲。

以上面的代码1为例,如果想向参数1传递两个参数,一个任务名称和一个人员数量,应该如何改写?

作者试写一个,代码如下所示:

代码语言:javascript
复制
let totalSeconds = 10
let timerId = setInterval((taskName, numPersons)=>{
 // 业务逻辑代码
 totalSeconds--
 console.log(`任务名称:${taskName},人员数量:${numPersons},时间剩余: ${totalSeconds}`)
 if (totalSeconds <= 0) {
   console.log(`时间到`)
   clearInterval(timerId)
 }
}, 1000, '丽人搬砖', 3)

代码2

在上面的代码中,'丽人搬砖', 3是函数setInterval的不定参数args。taskName, numPersons是匿名函数参数1的形参。

输出是这样的:

还是那句话,没有错误不代表写对了。在代码1中,我们设定定时器每隔1秒触发一次,但在实际的运行过程中,无法保证每隔1秒执行一次。如果间隔时间无法保证,例如延后了,那么总执行时间就要长于允许的总时间。

在代码1中,我们看到有一行这样的注释:

代码语言:javascript
复制
业务逻辑代码

如果于此处加入一段非常耗时的逻辑代码,如下所示,势必将大大增加定时器的执时时间。

代码语言:javascript
复制
for(let i = 0; i < 100000000; i++) {}

上面的代码仅是模拟,但在实际的项目中,确实可能存在这样耗费资源的操作,这便会导致超时计算出现误差。但是作者说的问题,原因还不在这里。如下所示:

图1

设间隔定时器每隔10秒触发一次,但青色逻辑代码仅耗时6秒,在这种情况下逻辑代码并不会对定时器造成影响。间隔定时器的触发,是由主线程之外的线程管理高度的,时间到了,就塞到主线程里执行,并不管上一次的代码有没有执行完。

在图1中,如果青色逻辑的执行时间是10s,而定时器的预设间隔是6秒呢,逻辑代码是多少秒执行一次?

不少于10s。在具体的实例中,可能还存在其它耗时操作,合理的答案是不少于10s。

如果在逻辑代码中访问了临界资源,会不会因为多次定时器重叠触发造成程序死锁?

不会,JS程序是单线程的。后面应当触发执行的代码,会被前面的延后。

对于函数setInterval第二个参数的描述,确准一点应该这样讲:

代码语言:javascript
复制
用户期望的,不小于此的定时器间隔时间,单位毫秒。

setInterval并不能保证定时器代码每隔一定时间如期执行。在实际的项目开发中,经常会有接口轮询操作,即每隔一定时间向服务器发起一次查询操作。在这种场景中,setInterval就不合适了,取而代之的当是setTimeout。伪代码如下:

代码语言:javascript
复制
let timerId = 0
function polling(count){
 // ajax请求代码集于在此处
 console.log(`轮询 ${count++}`)
 timerId = setTimeout(polling, 1000, count)
}
polling(1)

setTimeout函数的语法与setInterval是类似的:

代码语言:javascript
复制
var timerId = setTimeout(func, miliseconds, args...);

两者的参数与返回类型都是一样的,详见间隔定时器的语法说明。不同之处在于,setTimeout生成的是超时定时器,在指定时间触发,且仅执行一次。

这个轮询不能停止,可以这样改进一下:

代码语言:javascript
复制
let timerId = 0
function polling(count){
 // ajax请求代码集于在此处
 console.log(`轮询 ${count++}`)
 timerId = setTimeout(polling, 1000, count)
}
polling(1)function stopPolling() {
 clearTimeout(timerId)
}

在需要停止轮询的时候,调用stopPolling函数即可。clearTimeout函数用于清除超时定时器。在JS引擎内部,都维护了一个定时ID集合,每个ID对应什么类型的定时器都是一清二楚的,实在没有必要存在两个清除定时器的方法,clearTimeoutclearInterval是完全可以合二为一的。

在上面的代码中,无论注释处的ajax请求代码执行多久,相隔时间都是3秒。貌似这样就解决了问题,实现了时间上的等距轮询。

但是,超时定时器的执行同样受到JS是单线程的限制,即使轮询代码是一样的,但不能保证其它地方在本次循环中没有新增的代码,所以使用setTimeout模拟的间隔定时器,仍然不能保证相待的间隔时间。

回到本文开始的问题上,应该如何设计超时逻辑?

定时器的时间不可信任,就不能拿定时器来衡量时间。可以将当前时间来计算剩余时间,代码如下所示:

代码语言:javascript
复制
var totalTime = 10 //seconds
var endTimeMiliseconds = new Date().getTime() + totalTime * 1000
var timer = setInterval(_=>{
// 客户代码
totalTime--
console.log(totalTime)
if (endTimeMiliseconds <= new Date().getTime()) {
 console.log('时间到')
 clearInterval(timer)
}
},1000)

定时器启动之前,先计算好超时时间。在定时器代码中,每次都检查一下当前时间与超时时间。这样无论定时器如何偏差,时间总不会错。

使用时间计算超时,这种方案适用于对时间要求不是特别精准的场景。说其不精准,不但是由于时钟的校准受限于PC或手机设备本身,还有定时器的时间粒度,最后应该超时时如果定时器代码一直被延后一直不被执行,同样会有很大误差。所以,还有一种做法是(定时器启动前)从服务器拉取时间,取服务器的时间作为时间参考值;在改变用户数据的时候,每次都要做检验,不能相信前端传递过来的数据。

JS引擎的运行机制是怎样的?

JS这门语言最大的特征就是单线程与异步操作。一个JS程序,无论是H5页面,还是小游戏/小程序,主线程是一个单线程。主线程从起始处依次解析、执行代码,然后会不断的添加新的代码,循环执行,形成一代一代的代码代。

  1. 当遇到定时器代码时,记当下定时器的注册时间,并将定时器交给另一个异步线程管理。定时器管理线程,会在设定的时间将定时器代码推入主线程。推入并不意味着一定执行,这要看主线程是否空闲。
  2. 遇到交互操作时,例如通过键盘敲入了字符,或单击了鼠标,此时协线程会将按键回调函数、鼠标单击回调函数添加到主线程作业栈尾部。会推迟到下一代执行,也可能是下下代。交互操作的优先级比定时器要高,这与乔布斯设计iOS系统的思想如出一辙,先保证操作的流畅性。
  3. 定时器代码、交互操作代码都是异步代码。如果在定时器代码,或在交互回调函数中又添加了新的代码,相当于在主线程尾部又续接了新的代码码,整个主线程像一个雷达波不断扩大,又像一根节节草一节一节循环执行。可以把这一节,称为桢。
  4. 对于不同的引擎,可能有一些常规代码基本是定时、自动插入主线程的,例如浏览器引擎,过一段都要渲染页面,渲染代码是每桢必有的;又如一些游戏引擎,本身有loop机制,在每个循环中都要重绘屏幕。

如何避免程序卡顿?

由于JS是单线程的,没有专门负责渲染UI的线程,如果引擎长时间耗于某段执行超过200ms的代码,就会呈现卡顿现象。解决方法,就是要善用JS的异步机制。

在JS中,有一些方法可以实现“下一代执行这些代码”,按照被处理的优先级,从上向下依次是:

1,process.nextTick

process.nextTick是Nodejs的API。process.nextTick不会进入异步队列,它是Nodejs特有的接口,会被强制加入主线程的尾部。也因为没有进入异步线程,所以比接下来的其它方法,其执行时间都要靠前。附其使用方法,参数是一个函数:

代码语言:javascript
复制
process.nextTick(function() {
 // somecode
});

但这个方法是一个双刃剑,如果在process.nextTick中嵌套了process.nextTick,那么异步代码就永久没有办法执行了,程序可能进入了永久的卡顿或死锁当中。

2,Promise

process.nextTick是Nodejs特有的,使用不当容易自伤,忘记它吧。Promise的优先级虽比process.nextTick低,便比其它异步方法都高。附Promise的基本用法:

代码语言:javascript
复制
new Promise((resolve, reject) => {
 var value = new Date()
 if (true){
   resolve(value);
 } else {
   reject(error);
 }
}).then((data) => {
 console.log(`从resolve处传递过来的value:${data}`);
});

在上面的代码,resolve与reject都是function类型,是Promise内部负责实现调用链传递的。使用Promise的方便之处在于,不必关心调用链如果流动,只须把每一步的代码处理好。

Promise在小游戏中是可以使用的。

3,setImmediate

语法是这样的:

代码语言:javascript
复制
setImmediate(function, args...)

setImmediate立即定时器,相比setIntervalsetTimeout,它没有设置时间的参数。顾名思义,立即定时器即是立即执行。但在JS引擎中,没有立即执行,所以这里的立即执行,即是在下一代中执行。

在下面会提到setTimeout(function,0)这种用法,setImmediate是这种用法的代替品,但是执行优先级比后者要高。

立即定时器是一个比较新的定时器,目前IE11/Edge支持、Nodejs支持,Chrome不支持。将用到setImmediate的代码,在Chrome浏览器中测试,是无法执行的。在小游戏中也未实现这个接口。

4,requestAnimationFrame

这是一个特殊的setTimeout类型的定时器,它的语法是这样的:

代码语言:javascript
复制
requestAnimationFrame(fn)

它的调用时机与屏幕刷新是一致的。所有涉及到与界面UI更新的代码,放在requestAnimationFrame里会被优雅,既浪费CPU资源,又可以避免卡顿现象。卡顿对象是一种视觉上的错觉,是屏幕刷新迟钝了,不一定是屏幕未刷新,也可能是要刷新的数据没有被及时更新。

5,setTimeout(fn, 0)

因为setImmediate未被广泛实现,所以这种方法在单次延时执行的场景中便成了最佳选择。通过将超时时间设置为0,fn将在下一代循环中被执行。

综上所述,刷新屏幕UI,使用requestAnimationFrame是先选。在不涉及界面的情况下,处理相互依赖的并发操作,使用Promise是首选,其它情况下使用setTimeout(fn, 0)最简单。

如何判断H5程序是从后台台恢复过来的?

定时器的时间是一成不变的吗?

不是的。

HTML5规范规定最小延迟时间不能小于4ms,即x如果小于4,会被当做4来处理。不同浏览器的实现也不一样,比如,Chrome可以设置1ms,IE11/Edge是4ms。

PC上的Firefox、Chrome和Safari等浏览器,都会自动把未激活页面中的定时器间隔最小值改为1秒以上;而移动设备上的浏览器往往会直接冻结未激活页面上的所有定时器,以此节省CPU开销。

基于此,上文中使用定时器累积计算时间也是有问题的。

在移动设备上利用定时器会冻结这个特征,可以判断程序是不是进入后台了。如下所示:

代码语言:javascript
复制
var lastTime = (new Date()).getTime();
setInterval(() => {
 console.log(lastTime)
 if (Math.abs((new Date()).getTime() - lastTime) > 2000) {
   console.log('从后台切回!')
 }
 lastTime = (new Date()).getTime()
}, 1000);

上面这段代码,如果在微信开发者工具中测试,是没有效果的。在微信中经作者测试也没有预期效果。

定时器的丢弃行为

代码语言:javascript
复制
看一个setInterval的例子。
setInterval(function () {
 console.log(2);
}, 1000);
sleep(3000);
console.log('exit')

在上面的代码中,sleep相当于是一段相当耗费资源的操作,虽然在它之前一个间隔为1秒的定时器已经被注册了,但在它的执行过程中,定时器不会触发(JS是单线程);并且在它之后,也不会输出3个2。因为线程被阻塞,定时器没有在应该触发的时间被触发,看起来像是被丢弃了,这便是定时器的丢弃行为。从本质上看,丢弃行为和延时行为是一致的。

从这点来看,也说明定时器的时间是靠不住的。

在开发中如何选择使用合适的定时器?

代码语言:javascript
复制
// 方法1
function showTime() {
 var today = new Date();
 console.log( " The time is: " + today.toString());
 setTimeout(showTime, 5000 );
}
showTime()// 方法2
setInterval(_=>{
 var today = new Date();
 console.log( " The time is: " + today.toString());
} , 5000 );

在上面的代码中,方法1不会每隔5秒钟就执行一次showTime函数,它是在每次调用setTimeout后过5秒钟再去执行showTime函数。假设showTime函数的主体部分需要2秒钟执行完,那么整个函数则要每7秒钟才执行一次。而setInterval却没有被自己所调用的函数所束缚,它只是简单地每隔一定时间就重复执行一次那个函数。

如果要求在每隔一个固定的时间间隔后就精确地执行某动作,那么最好使用setInterval,而如果不想由于连续调用产生互相干扰的问题,尤其是每次函数的调用需要繁重的计算以及很长的处理时间,那么最好使用setTimeout。

换言之,如果间隔时间较长,使用setInterval基本没有问题;如果间隔时间较短,且上下可能存在数据依赖或资源竞争,当使用setTimeout。

下面的代码为什么在小游戏中不能运行?

代码语言:javascript
复制
setTimeout("alert('5 秒!')",1000)

在小游戏中,setTimeout第一个参数必须为function类型,第二点alert属于BOM方法,是window对象的方法,在小游戏中没有window对象。

有没有一键回收所有定时器的方法?

如果对定时器函数不加以处理,那么setInterval将会持续执行相同的代码,一直到程序窗口关闭,或者用户转到了另外一个页面为止。这可能会造成内存泄漏,严重影响用户体验。

setTimeout和setInterval返回的整数值是连续的,也就是说,第二个setTimeout方法返回的整数值,将比第一个的整数值大1。利用这个特性,可以设计出一个定时器批量回收函数。如下所示:

代码语言:javascript
复制
function clearTimers() {
 var id = setTimeout(function() {}, 0);
 while (id > 0) {
   if (id !== gid) {
     clearTimeout(id);
   }
   id--;
 }
}

清理间隔定时器与此同法。

关于定时器中的this对象

由于JS的作用域机制,this对象总是飘忽不定。

代码语言:javascript
复制
var x = 1;
var obj = {
 x: 2,
 y: function () {
   console.log(this.x);
 }
};
setTimeout(obj.y, 1000) // 输出1

在上面的代码中,this并非指定义所在的obj对象,this.x奇迹般的指向了对象外的x。当obj.y在1000毫秒后运行时,this所指向的已经不是obj了,而是全局环境。

解决这个问题,有三种方法。

方法一:

代码语言:javascript
复制
var x = 1
var obj = {
 x: 2,
 y: function () {
   console.log(this.x);
 }
};
setTimeout(function () {
 obj.y() // 输出2
}, 1000);

上面代码中,obj.y放在一个匿名函数之中,这使得obj.y在obj的作用域执行,而不是在全局作用域内执行,所以能够显示正确的值。

方法二:

代码语言:javascript
复制
var x = 1
var obj = {
 x: 2,
 y: function () {
   console.log(this.x);
 }
};
setTimeout(obj.y.bind(obj), 1000)//输出 2

上面的代码,是使用bind方法,将obj.y这个方法绑定在obj上面。

方法三:

代码语言:javascript
复制
var x = 1
var obj = {
 x: 2,
 y: function () {
   console.log(this.x);
 }
};
setTimeout(_=> obj.y(), 1000)//

这种方法更简洁,它利用了箭头函数可以保持当前作用域不变的特性,作用域不变,this对象当然也不会变化。

零超时定时器在冒泡链中的活用

将setTimeout第二个函数设置为0,便是零超时定时器。上文中曾提到过,使用它避免程序卡顿现象的发生。现在谈一谈它在BOM冒泡链中的活用方法。

例如,在H5开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果,想让父元素的事件回调函数先发生,就可以用setTimeout(fn, 0)。

代码语言:javascript
复制
<button id="btn1" type="button">Click Me!</button>
<script>
//设页面有一个id为btn1的Button
var btn = document.getElementById('btn1');
btn.onclick = _=>{
 setTimeout(_=>{
   //这行代码将在下一代循环中执行
   console.log('单击了子元素按钮')
 }, 0)
}
document.body.onclick = function C() {
 console.log('单击了父元素页文档')
};
</script>

输出:

代码语言:javascript
复制
单击了父元素页文档
v.asp:16 单击了子元素按钮

能否写一个通用的、立马执行的、有总数限制的、时间间隔均等的定时器?

代码语言:javascript
复制
function setImitateInterval(fn, max, delay, ...args) {
   // fn.apply(null, args) // apply也可以
   fn(...args) // 使用数组展开运算符,es6语法
   if (--max > 0) {
   setTimeout(function () {
       setImitateInterval(fn, max, delay, ...args);
   }, delay);
   }
}
// 调用示例
setImitateInterval(function(start, name){
   console.log(name + ' start', Date.now()-start);
   for (var i = 0; i < 100000; i++) {}
   console.log(name + ' end', Date.now()-start);
}, 3, 1000, Date.now(), "test")

上面是一个模拟的间隔定时器。调用代码示例仅调用3次。在实际的企业项目开发中,对于一些网络请求,可能需要尝试3次甚至多次。

习题

1,下面代码的输出是什么?

代码语言:javascript
复制
console.log(1);
setTimeout(()=> console.log(2),1000);
console.log(3);

2,执行在面的代码块会输出什么?

代码语言:javascript
复制
setTimeout(function () {
   console.log(0);
}, 1000);
setInterval(function () {
   console.log(1)
}, 1000);

3,下面代码执行之后会输出什么?

代码语言:javascript
复制
var intervalId, timeoutId;timeoutId = setTimeout(function () {
 console.log(1);
}, 300);setTimeout(function () {
 clearTimeout(timeoutId);
 console.log(2);
}, 100);setTimeout(console.log, 400, 5);intervalId = setInterval(function () {
 console.log(4);
 clearInterval(intervalId);
}, 200);

4,模拟打印一个数字时钟?

答案见原文链接

原文链接:http://www.yishulun.com/微信小游戏入门/1523962119.html

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2018-04-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 艺述论 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 如何设计一个靠谱的超时程序?
  • JS引擎的运行机制是怎样的?
  • 如何避免程序卡顿?
  • 如何判断H5程序是从后台台恢复过来的?
  • 定时器的丢弃行为
  • 在开发中如何选择使用合适的定时器?
  • 下面的代码为什么在小游戏中不能运行?
  • 有没有一键回收所有定时器的方法?
  • 关于定时器中的this对象
  • 零超时定时器在冒泡链中的活用
  • 能否写一个通用的、立马执行的、有总数限制的、时间间隔均等的定时器?
  • 习题
相关产品与服务
云开发 CLI 工具
云开发 CLI 工具(Cloudbase CLI Devtools,CCLID)是云开发官方指定的 CLI 工具,可以帮助开发者快速构建 Serverless 应用。CLI 工具提供能力包括文件储存的管理、云函数的部署、模板项目的创建、HTTP Service、静态网站托管等,您可以专注于编码,无需在平台中切换各类配置。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档