一、浏览器事件机制
1、事件是什么?事件模型?
在浏览器中,用户进行页面的交互行为,这个行为称为事件。一般有点击事件,除了用户的触发行为,还可以是文档加载,页面滚动,大小调整等。当触发事件时,会触发js引擎线程,将事件存放到js引擎中,等js引擎空闲时进行调用。
1. 事件流
事件流有三个阶段,捕获阶段(从window到目标节点);目标阶段/事件处理阶段(目标节点);冒泡阶段(从目标节点到window)
-
事件冒泡:描述了浏览器如何处理针对嵌套元素的事件。从目标元素向父元素冒泡,父元素中有事件,子元素中也有事件,触发子元素事件,父元素事件会自动触发,向上冒泡。例如div中有p元素,div与p元素都有点击事件,这个时候点击p元素时,就会发生向上冒泡行为,同时触发两个事件,这个时候需要使用
event.stopPropagation();阻止冒泡行为 -
事件处理:对于事件的处理阶段,执行目标元素的监听事件。
-
事件捕获:这就像事件冒泡,但顺序是相反的:事件先在最外层元素进行捕获,再一步一步到嵌套的元素上,直到达到目标。事件捕获默认是禁用的,你需要在
addEventListener()的capture: true选项中启用它。
2. 事件模型
事件模型分为:
-
DOM0 级事件模型:这种模型不会传播,所以没有事件流的概念,但是现在有的浏览器支持以冒泡的方式实现,它可以在网页中直接定义监听函数,也可以通过 js 属性来指定监听函数。所有浏览器都兼容这种方式。直接在dom对象上注册事件名称,就是DOM0写法。
-
IE 事件模型:在该事件模型中,一次事件共有两个过程,事件处理阶段和事件冒泡阶段。事件处理阶段会首先执行目标元素绑定的监听事件。然后是事件冒泡阶段。
-
DOM2 级事件模型:在该事件模型中,一次事件共有三个过程,事件捕获阶段,事件处理阶段,事件冒泡阶段。这种事件模型,事件绑定的函数是
addEventListener,其中第三个参数可以指定事件是否在捕获阶段执行。
2、对事件委托(事件代理)的理解?
1. 事件委托是什么?
事件委托(事件代理)利用了事件冒泡阶段,事件向上传播到父节点,通过父节点来控制多个子节点的事件。例如当我们想在用户与大量的子元素中的任何一个互动时运行一些代码时,我们在它们的父元素上设置事件监听器,让发生在它们身上的事件冒泡到它们的父元素上,而不必在每个子元素上单独设置事件监听器。
事件委托的使用场景?
比如当用户点击一个按钮时,我们设置整个页面的背景颜色。假设取而代之的是,页面被分为 16 个区域,我们想在用户点击每个区域时将其设置为随机颜色。
<div id="container">
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
...
<div class="tile"></div>
</div>
.tile {
height: 100px;
width: 25%;
float: left;
}
function random(number) {
return Math.floor(Math.random() * number);
}
function bgChange() {
const rndCol = `rgb(${random(255)}, ${random(255)}, ${random(255)})`;
return rndCol;
}
const container = document.querySelector("#container");
container.addEventListener("click", (event) => {
event.target.style.backgroundColor = bgChange();
});
2. 事件委托优点缺点?
优点:
- 减少内存消耗:不用在每个列表项一一都绑定一个函数,减少内存消耗,节约效率
- 动态绑定属性:
缺点:
事件委托也是有局限的。比如 focus、blur 之类的事件没有事件冒泡机制,所以无法实现事件委托;mousemove、mouseout 这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的。
事件委托会影响页面性能,主要影响因素有:
- 元素中,绑定事件委托的次数;
- 点击的最底层元素,到绑定事件元素之间的
DOM层数;
在必须使用事件委托的地方,可以进行如下的处理:
- 只在必须的地方,使用事件委托,比如:
ajax的局部刷新区域 - 尽量的减少绑定的层级,不在
body元素上,进行绑定 - 减少绑定的次数,如果可以,那么把多个事件的绑定,合并到一次事件委托中去,由这个事件委托的回调,来进行分发。
3、同步和异步的区别?
-
同步指的是当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,那么这个进程会一直等待下去,直到消息返回为止再继续向下执行。
-
异步指的是当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,这个时候进程会继续往下执行,不会阻塞等待消息的返回,当消息返回时系统再通知进程进行处理。
4、对事件循环的理解?
js是单线程,执行完一段代码执行另一段,执行顺序是:同步 -> 微任务 -> DOM操作 -> 事件循环 -> 宏任务。宏任务是由浏览器发起的,微任务是js自身发起的。
-
执行代码,将代码放入到调用栈
-
先执行同步代码,在browser中执行
-
遇到微任务放入到microTask queue中排队等待执行,等同步执行完执行微任务
-
如果有DOM事件,执行完微任务后执行DOM事件
-
遇到宏任务放入到浏览器的webAPIs中,例如setTimeout在5秒后执行,执行完后会放入到callback queue中,等待eventloop调用
-
在DOM事件执行完后,现在调用栈为空,循环查看执行callback queue中的回调函数
5、宏任务和微任务分别有哪些?
-
微任务包括: promise 的回调、node 中的 process.nextTick 、对 Dom 变化监听的 MutationObserver。
-
宏任务包括: script 脚本的执行、setTimeout ,setInterval ,setImmediate 一类的定时事件,还有如 I/O 操作、UI 渲染等。
6、什么是执行栈?
可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则,后执行的函数会先弹出栈,当执行完毕后就从栈中弹出了。当使用递归时,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题。
function bar() { bar() }
bar()
7、Node 中的 Event Loop 和浏览器中的有什么区别?process.nextTick 执行顺序?
Node 中的 Event Loop 和浏览器中的是完全不相同的东西。
Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
(1)Timers(计时器阶段) :初次进入事件循环,会从计时器阶段开始。此阶段会判断是否存在过期的计时器回调(包含 setTimeout 和 setInterval),如果存在则会执行所有过期的计时器回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Pending callbacks 阶段。
(2)Pending callbacks:执行推迟到下一个循环迭代的I / O回调(系统调用相关的回调)。
(3)Idle/Prepare:仅供内部使用。
(4)Poll(轮询阶段) :
-
当回调队列不为空时:会执行回调,若回调中触发了相应的微任务,这里的微任务执行时机和其他地方有所不同,不会等到所有回调执行完毕后才执行,而是针对每一个回调执行完毕后,就执行相应微任务。执行完所有的回调后,变为下面的情况。
-
当回调队列为空时(没有回调或所有回调执行完毕):但如果存在有计时器(setTimeout、setInterval和setImmediate)没有执行,会结束轮询阶段,进入 Check 阶段。否则会阻塞并等待任何正在执行的I/O操作完成,并马上执行相应的回调,直到所有回调执行完毕。
(5)Check(查询阶段) :会检查是否存在 setImmediate 相关的回调,如果存在则执行所有回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Close callbacks 阶段。
(6)Close callbacks:执行一些关闭回调,比如socket.on('close', ...)等。
下面来看一个例子,首先在有些情况下,定时器的执行顺序其实是随机的
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')}
)
对于以上代码来说,setTimeout 可能执行在前,也可能执行在后
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的 - 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行
setTimeout回调 - 那么如果准备时间花费小于 1ms,那么就是
setImmediate回调先执行了
当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。
上面都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask
对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面。
最后来看 Node 中的 process.nextTick,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
对于以上代码,永远都是先把 nextTick 全部打印出来。
二、浏览器垃圾回收机制
1、浏览器垃圾回收机制(GC)?
JavaScript 是在创建变量(对象,字符串等)时自动进行了内存分配,并且在不使用它们时“自动”释放。释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。
1. 内存的生命周期?
- 分配你所需要的内存(定义变量分配内存,函数调用分配内存)
- 使用分配到的内存(读、写操作)
- 不需要时将其释放\归还
所有语言第二部分都是明确的。第一和第三部分在底层语言(比如C语言)中是明确的,但在像 JavaScript 这些高级语言中,大部分都是隐含的。
我们知道JS基础数据类型和引用数据类型的分配机制,即:
- 简单数据类型内存保存在固定的栈空间中,可直接通过值进行访问
- 引用数据类型的值大小不固定,其引用地址保存在栈空间、引用所指向的值保存在堆空间中,需要通过引用进行访问
栈内存中的基本数据类型,可以直接通过操作系统进行处理,而堆内存中的引用数据类型的值大小不确定,因此需要JS的引擎通过垃圾回收机制进行处理。
2、JS的V8的垃圾回收机制是什么样的?
自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。
在2012年前,垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。但是这个对于循环引用无法进行垃圾回收。后来使用标记——清除算法
1. 标记——清除算法
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。这个算法假定设置一个叫做根(root)的对象(在 Javascript 里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。循环引用不再是问题。
整个标记清除算法大致过程就像下面这样
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记
- 然后从各个根对象开始遍历,把还在被上下文变量引用的变量标记去掉标记
- 清理所有带有标牌机的变量,销毁并回收它们所占用的内存空间
- 最后垃圾回收程序做一次内存清理
使用标记清除策略的最重要的优点在于简单,无非是标记和不标记的差异。通过标记清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,这就造成出现内存碎片的问题。内存碎片多了后,如果要存储一个新的需要占据较大内存空间的对象,就会造成影响。对于通过标记清除产生的内存碎片,还是需要通过标记整理策略进行解决。
标记清除算法:
- 优点:简单,标记与不标记
- 缺点:内存碎片化、分配速度慢
2. 标记——整理(压缩)
经过标记清除策略整理后,老生代内存中因此产生了许多内存碎片,如果不进行清理内存碎片,就会对存储造成影响。
标记整理(Mark-Compact)算法 就可以有效地解决标记清除的两个缺点。它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存。
3. V8回收机制
大多数浏览器都是基于标记清除算法,不同的只是在运行垃圾回收的频率具有差异。V8 对其进行了一些优化加工处理,V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)分为新生代和老生代两部分。
(1)新生代算法
在64操作系统下分配为32MB,因为新生代中的变量存活时间短,不太容易产生太大的内存压力,因此不够大也是能够理解,使用 Scavenge GC 算法。
在新生代空间中,内存空间分为两部分,正在被使用的内存空间称为使用区,而限制状态的内存空间称为空闲区。
回收原理:
- 新加入的对象都会存放在使用区,当使用区快写满时就进行一次垃圾清理操作。
- 在开始进行垃圾回收时,新生代回收器会对使用区内的对象进行标记
- 标记完成后,需要对使用区内的活动对象拷贝到空闲区进行排序
- 而后进入垃圾清理阶段,将非活动对象占用的内存空间进行清理
- 最后对使用区和空闲区进行交换,使用区->空闲区,空闲区->使用区
新生代中的变量如果经过回收之后依然一直存在,那么会放入到老生代内存中,只要是已经经历过一次Scavenge算法回收的,就可以晋升为老生代内存的对象。
(2)老生代算法
老生代中的对象一般存活时间较长且数量也多使用了两个算法,分别是标记清除算法和标记压缩算法。
什么情况下对象会出现在老生代空间中?
- 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
- 新生代空闲区的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。
在老生代中,以下情况会先启动标记清除算法:
- 某一个空间没有分块的时候
- 空间中被对象超过一定限制
- 空间不能保证新生代中的对象移动到老生代中
在这个阶段中:
- 会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。
- 在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。在 2018 年,GC 技术又有了一个重大突破,并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行。
清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象向一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
3、哪些操作会造成内存泄漏?
内存泄漏,指在JS中已经分配内存地址的对象由于长时间未进行内存释放或无法清除,造成了长期占用内存,使得内存资源浪费,最终导致运行的应用响应速度变慢以及最终崩溃的情况。
在代码中创建对象和变量时会占据内存,但是JS基于自己的内存回收机制是可以确定哪些变量不再需要,并将其进行清除。但是,当代码中存在逻辑缺陷时,你以为你已经不需要,但是程序中还存在引用,这就导致程序运行完后并没有进行合适的回收所占有的内存空间。运行时间越长占用内存越多,随之出现的问题就是:性能不佳、高延迟、频繁崩溃。
造成内存泄漏的常见原因有:
- 过多的缓存:及时清理过多的缓存。
- 滥用闭包:尽量避免使用大量的闭包。
- 定时器或回调太多:当设置了setTimeout或setInterval时,定时器忘记被清除。解决方法:在定时器完成工作时,需要手动清除定时器。
- 太多无效的DOM引用:DOM删除了,但是节点的引用还在,导致GC无法实现对其所占内存的回收。解决方法:给删除的DOM节点引用设置为null。
- 滥用全局变量:全局变量是根据定义无法被垃圾回收机制进行收集的,因此需要特别注意临时存储和处理大量信息的全局变量。如果必须使用全局变量来存储数据,请确保将其指定为null或在完成后重新分配它。解决方法:使用严格模式。
- 从外到内执行appendChild:此时即使调用removeChild也无法进行释放内存。解决方法:从内到外appendChild。
- 反复重写同一个数据会造成内存大量占用:但是IE浏览器关闭后会被释放。
- 注意程序逻辑:避免编写『死循环』之类的代码。
- DOM对象和JS对象相互引用
关于内存泄漏,如果你想要更好地排查以及提前避免问题的发生,最好的解决方法是通过熟练使用Chrome的内存剖析工具,多分析多定位Chrome帮你分析保留的内存快照,来查看持续占用大量内存的对象。