定时器的详解应用到背后的原理解析
前言
之前在研究css加载对DOM和js的影响时使用了下面的代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script>
function h() {
console.log( 222 )
}
function k() {
console.log(1111)
}
setTimeout(function () {
h()
}, 0)
k()
</script>
</head>
<body>
</body>
</html>
上面的代码发生的结果如下:
明明是定时器先执行,延时0s,按照预期应该是立马执行;
为什么是k函数
先执行?这其中定时器的运作原理到底是什么?
定时器的执行时机
关于定时器最重要的是:设定的时间是指代码加入队列的时间,而不是何时执行代码。例如:
var btn = document.getElementById('my-btn');
btn.onclick = function () {
setTimeout(function () {
document.getElementById('message').style.visibility = 'visible'
}, 250)
}
onclick事件执行执行,会执行定时器。在onclick执行300ms时,会被添加到事件处理程序中,等待被执行,一旦时间线程空闲下来,便开始执行定时器中的内容,所以定时器中的代码执行一定是>=300ms的,用时间线图表示为:
setInterval重复定时器可能存在的问题
当使用setInterval时,仅当没有该定时器的任何其他代码实例时,才将定时器添加到队列中。这确保了定时器代码添加到队列中的最小时间间隔为指定时间间隔。
有时候会出现以下问题:
- 某些间隔会被跳过
- 多个定时的代码执行之间的间隔可能会比预期小
举例: 某个onclick事件使用serInterval设置了一个200ms间隔的重复定时器,如果时间处理程序花了300多ms时间完成,同时定时器也花了差不多的时间,就会同时出现跳过间隔且连续运行定时器代码的情况。如图:
解决方法: 可以尝试使用 链式setTimeout()
//链式使用
setTimeout(function () {
setTimeout(arguments.callee, interval)
}, interval)
setTimeout的应用:Yeilding Processes (进程暂停)
运行在浏览器中的JavaScript都被分配了一个确定数量的资源,不同于桌面应用往往能够随意控制他们要的内存大小和处理时间,Javascript被严格限制了,以防止恶意的Web程序员吧用的计算机搞挂了,其中一个限制是长时间运行脚本的制约,如果代码运行超过特定时长或者特定数量的语句就不让它继续执行,询问是允许其继续执行还是停止它。所有JavaScript开发人员的目标就是,确保用户永远不会再浏览器中看到这个令人费解的对话框。定时器是绕开此限制的方法之一
脚本运行时间长的2个原因:
- 过长的、过深嵌套的函数调用;
- 进行大量处理的循环;
这2种后者较为容易的被解决。
解决"进行大量处理的循环"的问题
可以使用数组分块
,这种方式必须满足的要求:
- 该处理不是必须同步完成
- 数据不是必须按顺序完成
实现代码:
function chunk(arr, process, context){
setTimeout(function () {
var item = arr.shift()
process.call(context, item)
if(arr.length > 0){
setTimeout(arguments.callee, 100)
}
}, 100)
}
使用场景
一旦某个函数的执行需要花50ms以上的时间完成,那么最好看看能否将任务分割为一系列可以使用定时器的小任务。
setTimeout的应用:函数节流
函数节流
方法的实现引用自:《javascript高级程序设计》第22章第3小节 函数节流
节流函数的起源
浏览器中的某些计算和处理要比其他的开销大很多。例如,DOM比起非DOM交互需要更多的内存和CUP时间。连续尝试进行过多的DOM相关操作可能会导致浏览器挂起,有时候甚至奔溃。尤其在IE中使用onresize时处理程序的时候很容易发生,当调整浏览器大小的时候,该事件会连续触发。在onresize事件处理程序内部如果尝试进行DOM操作,其高频率的更改可能会让浏览器奔溃。为了绕开这个问题,可以使用定时器对该函数进行节流。
节流函数的原理
某段代码不能在没有间隔的情况下连续重复执行。
第一次调用函数,创建一个定时器,在指定的时间间隔后执行代码,当第二次调用该函数时,他会清除前一次的定时器并设置另一个定时器。如果前一个定时器已经执行了,这个操作没有任何意义。然后,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的只有一个:只有在执行函数的请求停止了一段时间之后才执行。
适合节流的场景: 周期性执行的代码
节流函数的实现
var processor = {
timeoutId: null,
performProcessing: function () {
//实际执行的代码
},
process: function () {
clearTimeout(this.timeoutId)
var that = this
this.timeoutId = setTimeout(function () {
that.performProcessing()
}, 100)
}
}
简化版本:
function throttle (cb, context) {
clearTimeout(cb.ID)
cb.ID = setTimeout(function () {
cb.call(context)
}, 1000)
}
网上函数节流和防抖
网上版本
函数节流: 将多次执行变成每隔一段时间执行
实现:
/**
* underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait
*
* @param {function} func 回调函数
* @param {number} wait 表示时间窗口的间隔
* @param {object} options 如果想忽略开始函数的的调用,传入{leading: false}。
* 如果想忽略结尾函数的调用,传入{trailing: false}
* 两者不能共存,否则函数不能执行
* @return {function} 返回客户调用函数
*/
_.throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
// 之前的时间戳
var previous = 0;
// 如果 options 没传则设为空对象
if (!options) options = {};
// 定时器回调函数
var later = function() {
// 如果设置了 leading,就将 previous 设为 0
// 用于下面函数的第一个 if 判断
previous = options.leading === false ? 0 : _.now();
// 置空一是为了防止内存泄漏,二是为了下面的定时器判断
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function() {
// 获得当前时间戳
var now = _.now();
// 首次进入前者肯定为 true
// 如果需要第一次不执行函数
// 就将上次时间戳设为当前的
// 这样在接下来计算 remaining 的值时会大于0
if (!previous && options.leading === false) previous = now;
// 计算剩余时间
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 如果当前调用已经大于上次调用时间 + wait
// 或者用户手动调了时间
// 如果设置了 trailing,只会进入这个条件
// 如果没有设置 leading,那么第一次会进入这个条件
// 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了
// 其实还是会进入的,因为定时器的延时
// 并不是准确的时间,很可能你设置了2秒
// 但是他需要2.2秒才触发,这时候就会进入这个条件
if (remaining <= 0 || remaining > wait) {
// 如果存在定时器就清理掉否则会调用二次回调
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
// 判断是否设置了定时器和 trailing
// 没有的话就开启一个定时器
// 并且不能不能同时设置 leading 和 trailing
timeout = setTimeout(later, remaining);
}
return result;
};
};
函数防抖: 将多次执行变为最后一次执行
实现:
// 这个是用来获取当前时间戳的
function now() {
return +new Date()
}
/**
* 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
*
* @param {function} func 回调函数
* @param {number} wait 表示时间窗口的间隔
* @param {boolean} immediate 设置为ture时,是否立即调用函数
* @return {function} 返回客户调用函数
*/
function debounce (func, wait = 50, immediate = true) {
let timer, context, args
// 延迟执行函数
const later = () => setTimeout(() => {
// 延迟函数执行完毕,清空缓存的定时器序号
timer = null
// 延迟执行的情况下,函数会在延迟函数中执行
// 使用到之前缓存的参数和上下文
if (!immediate) {
func.apply(context, args)
context = args = null
}
}, wait)
// 这里返回的函数是每次实际调用的函数
return function(...params) {
// 如果没有创建延迟执行函数(later),就创建一个
if (!timer) {
timer = later()
// 如果是立即执行,调用函数
// 否则缓存参数和调用上下文
if (immediate) {
func.apply(this, params)
} else {
context = this
args = params
}
// 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个
// 这样做延迟函数会重新计时
} else {
clearTimeout(timer)
timer = later()
}
}
}
Event Loop
js引擎解析异步任务的流程:
任务队列
Js 中,有两类任务队列:宏任务队列(macro tasks)和微任务队列(micro tasks)。宏任务队列可以有多个,微任务队列只有一个。那么什么任务,会分到哪个队列呢?
- 宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering.
- 微任务:process.nextTick, Promise, Object.observer, MutationObserver.
console.log('script start');
// 微任务
Promise.resolve().then(() => {
console.log('p 1');
});
// 宏任务
setTimeout(() => {
console.log('setTimeout');
}, 0);
var s = new Date();
while(new Date() - s < 50); // 阻塞50ms
// 微任务
Promise.resolve().then(() => {
console.log('p 2');
});
console.log('script ent');
/*** output ***/
// one macro task
script start
script ent
// all micro tasks
p 1
p 2
// one macro task again
setTimeout
Event Loop
1.js引擎存在monitoring process进程会检查主线程是否空闲?是就下一步。
2.检查是否有微任务? 有就将所有微任务进栈执行;没有就就下一步。
3.检查有宏任务吗?有就进栈执行。
Event Loop讲解
JavaScript 异步、栈、事件循环、任务队列
Nodejs的Event Loop
浏览器的组成
浏览器组成结构:
浏览器结构分为:
- 用户界面: 除了网页文档之外的其他内容,包括前进后退、地址栏、书签等。
- 浏览器引擎(浏览器内核): 可以在用户界面和渲染引擎之间传送指令或在客户端本地缓存中读写数据等,是浏览器中各个部分之间相互通信的核心
- 渲染引擎: 解析DOM文档和CSS规则并将内容排版到浏览器中显示有样式的界面,也有人称之为排版引擎,我们常说的浏览器内核主要指的就是渲染引擎。
- 网络: 用来完成网络调用或资源下载的模块。
- JavaScript 解释器: 用来解释执行JS脚本的模块,如 V8 引擎、JavaScriptCore
- UI 后端 : 用来绘制基本的浏览器窗口内控件,如输入框、按钮、单选按钮等,根据浏览器不同绘制的视觉效果也不同,但功能都是一样的。
- 数据存储: 浏览器在硬盘中保存 cookie、localStorage等各种数据,可通过浏览器引擎提供的API进行调用。
渲染引擎
在维基百科上是这样介绍浏览器内核的,网页浏览器的排版引擎(Layout Engine或Rendering Engine)也被称为浏览器内核、页面渲染引擎、解释引擎或模板引擎,它负责取得网页的内容(HTML、XML、图像等等)、整理消息(例如加入CSS等),以及计算网页的显示方式,然后会输出至显示器或打印机。所有网页浏览器、电子邮件客户端以及其它需要根据表示性的标记语言(Presentational markup)来显示内容的应用程序都需要排版引擎。
javascript引擎
JS引擎负责解析Javascript语言,执行javascript语言来实现网页的动态效果。
主流浏览器内核
浏览器 | 渲染引擎 | - | js引擎 |
---|---|---|---|
IE | Trident | - | Chakra |
Firefox | Gecko | - | SpiderMonkey |
Chrome | Webkit | Webcore | V8 |
Safri | Webkit | Webcore | javascriptcore |
Opera | Presto | - | Carakan |
注:我们上面提到Chrome是基于WebKit的分支,而WebKit又由渲染引擎“WebCore”和JS解释引擎“JSCore”组成,可能会让你搞不清V8和JSCore的关系。你可以这样理解——WebKit是一块主板,JSCore是一块可拆卸的内存条,谷歌实际上认为Webkit中的JSCore不够好,才自己搞了一个V8 JS引擎,这就是Chrome比Safari在某些JS测试中效率更高的原因。
更详细的内容参考:浏览器组成
js内存管理(栈内存和堆内存)
堆内存: 定义对象或函数,首先都会开一个堆内存且有一个引用地址,如果有变量知道了这个引用地址,我们就说该堆内存被占用了,不能被销毁
堆内存释放或销毁: 把所有知道该引用地址的变量赋值null,即没人知道该引用地址,浏览器就会在空闲的时候销毁它,也叫垃圾回收
栈内存: 有两种类别,全局作用域和私有作用域
全局作用域的栈内存: 页面关闭的时候,才会销毁
私有作用域的栈内存(只有函数执行的时候才有私有作用域)
a.一般情况:函数执行会形成一个新的私有作用域,当私有作用域的代码执行完之后,栈内存会自动销毁和释放
b.特殊情况:私有作用域的部分内存被其他作用域知道了,那么该栈内存就属于被占用,不会被销毁,常见的两种情况:
5.1.函数执行返回一个引用类型的值,且在别的作用域被接收了,该栈内存不会被销毁
5.2.私有作用域中,给DOM元素的事件绑定方法,该栈内存不会被销毁
js引擎解析同步任务的流程:
来看一段代码的内存进栈出栈:
function a (aa) {
var b = 3;
return 1 + 2 + b + aa
}
function c() {
var cc = 4
return a(cc)
}
c()
进栈
执行c函数
进入c函数
执行a函数
:
渲染引擎解析html、css
渲染引擎工作流程
渲染引擎开始于从网络层获取请求内容,一般是不超过8K的数据块。接下来就是渲染引擎的基本工作流程:
解析DOM生成DOM树,解析css生成Rules Tree,结合DOM Tree和Rules Tree生成Render Tree,再对Render Tree进心布局(即生成每个元素的位置颜色信息等等…),最后浏览器根据 布局 Render Tree绘制显示到屏幕上。
一定要理解这是一个缓慢的过程,为了更好的用户体验,渲染引擎会尝试尽快的把内容显示出来。它不会等到所有HTML都被解析完才创建并布局渲染树。它会 在处理后续内容的同时把处理过的局部内容先展示出来。
因为Firefox和Chrome的渲染引擎工作方式的不同,因此还是分开讲解他们的工作流程。
webkit-WebCore的渲染流程:
Firefox-Gecko的渲染流程:
从上面2张图可以看出,尽管Webkit与Gecko使用略微不同的术语,这个过程还是基本相同的。
Gecko 里把格式化好的可视元素称做“帧树”(Frame tree)。每个元素就是一个帧(frame)。 Webkit 则使用”渲染树”这个术语,渲染树由”渲染对象”组成。
Webkit 里使用”layout”表示元素的布局,Gecko则称为”Reflow”。
Webkit使用”Attachment”来连接DOM节点与可视化信息以构建渲染树。
一个非语义上的小差别是Gecko在HTML与DOM树之间有一个附加的层 ,称作”content sink”,是创建DOM对象的工厂。
回流: 改变布局,需要重新计算块的位置。
重绘: 不需要重新布局的修改;例如:字体颜色,背景色。
以上是不带js解析版本,如果附加js如何解析?
想了解更详细的原理
浏览器的渲染原理:解析算法
浏览器的渲染原理:偏向于流程讲解