主题
浏览器
1. 事件机制
- 事件触发三阶段
document往事件触发处传播, 遇到注册的捕获事件会触发- 传播到事件触发处时触发注册的事件
- 从事件触发处往
document传播, 遇到注册的冒泡事件会触发
事件触发⼀般来说会按照上面的顺序进行,但是也有特例, 如果给⼀个目标节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行
js
// 以下会先打印冒泡然后是捕获
node.addEventListener(
"click",
(event) => {
console.log("冒泡 ");
},
false
);
node.addEventListener(
"click",
(event) => {
console.log("捕获 ");
},
true
);注册事件
- 通常我们使用
addEventListener注册事件,该函数的第三个参数可以是布尔值,也可以是对象 。对于布尔值useCapture参数来说,该参数默认值为false。useCapture决定了注册的事件是捕获事件还是冒泡事件 - ⼀般来说, 我们只希望事件只触发在目标上, 这时候可以使用
stopPropagation来阻止事件的进⼀步传播 。通常我们认为stopPropagation是用来阻止事件冒泡的, 其实该函数也可以阻止捕获事件 。stopImmediatePropagation同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件
jsnode.addEventListener( " click", (event) => { event.stopImmediatePropagation(); console.log("冒泡 "); }, false ); // 点击 node 只会执行上面的函数,该函数不会执行 node.addEventListener( "click", (event) => { console.log("捕获 "); }, true );- 通常我们使用
事件代理
如果⼀个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上
html
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
<script>
let ul = document.querySelector("##ul");
ul.addEventListener("click", (event) => {
console.log(event.target);
});
</script>事件代理的方式相对于直接给目标注册事件来说,有以下优点:
- 节省内存
- 不需要给子节点注销事件
2. 跨域
因为浏览器出于安全考虑,有同源策略 。也就是说, 如果协议 、域名或者端⼝有⼀个不同就是跨域, Ajax 请求会失败。
我们可以通过以下⼏种常用方法解决跨域的问题
JSONPJSONP的原理很简单,就是利用<script>标签没有跨域限制的漏洞 。通过<script>标签指向⼀个需要访问的地址并提供⼀个回调函数来接收数据当需要通讯时
html
<script src=" http: / / domain/ api? param1 = a& param2 = b& callback= jsonp"></script>
<script>
function jsonp(data) {
console.log(data);
}
</script>JSONP 使用简单且兼容性不错,但是只限于 get 请求
在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的, 这时候就需要自己封装⼀个 JSONP , 以下是简单实现:
js
function jsonp(url, jsonpCallback, success) {
let script = document.createElement("script");
script.src = url;
script.async = true;
script.type = "text/javascript";
window[jsonpCallback] = function (data) {
success && success(data);
};
document.body.appendChild(script);
}
jsonp("http://xxx", "callback", function (value) {
console.log(value);
});CORSCORS需要浏览器和后端同时支持 。IE 8和9需要通过XDomainRequest来实现。- 浏览器会自动进⾏
CORS通信, 实现CORS通信的关键是后端 。只要后端实现了CORS,就实现了跨域。 - 服务端设置
Access-Control-Allow-Origin就可以开启CORS。 该属性表示哪些域名可以访问资源, 如果设置通配符则表示所有网站都可以访问资源。
document.domain- 该方式只能用于二级域名相同的情况下, 比如
a.test.com和b.test.com适用于该方式。 - 只需要给页面添加
document.domain = 'test.com'表示二级域名都相同就可以实现跨域
- 该方式只能用于二级域名相同的情况下, 比如
postMessage
这种方式通常用于获取嵌⼊页面中的第三方页面数据 。⼀个页面发送消息, 另⼀个页面判断来源并接收消息
js
// 发送消息端
window.parent.postMessage("message", "http://test.com");
// 接收消息端
var mc = new MessageChannel();
mc.addEventListener("message", (event) => {
var origin = event.origin || event.originalEvent.origin;
if (origin === "http://test.com") {
console.log("验证通过");
}
});3. Event loop
- JS 中的
event loop
众所周知
JS是⻔⾮阻塞单线程语⾔, 因为在最初JS就是为了和浏览器交互而诞生的 。如果JS是⻔多线程的语⾔话, 我们在多个线程中处理DOM就可能会发生问题 (⼀个线程中新加节点, 另⼀个线程中删除节点)
JS 在执⾏的过程中会产生执⾏环境, 这些执⾏环境会被顺序的加入到执⾏栈中 。如果遇到异步的代码,会被挂起并加入到 Task ( 有多种 task ) 队列中 。⼀旦执⾏栈为空, Event Loop 就会从 Task 队列中拿出需要执⾏的代码并放入执⾏栈中执⾏ ,所以本质上来说 JS 中的异步还是同步⾏为
js
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
console.log("script end");不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务( microtask ) 和 宏任务 ( macrotask ) 。在 ES6 规范中,microtask 称为 jobs, macrotask 称为 task
js
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
new Promise((resolve) => {
console.log("Promise");
resolve();
})
.then(function () {
console.log("promise1");
})
.then(function () {
console.log("promise2");
});
console.log("script end");
// script start => Promise => script end => promise1 => promise2 => setTime以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务
微任务
process.nextTickpromiseObject.observeMutationObserver
宏任务
scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering
宏任务中包括了 script , 浏览器会先执行⼀个宏任务,接下来有异步代码的话就先执行微任务
所以正确的⼀次 Event loop 顺序是这样的:
- 执行同步代码, 这属于宏任务
- 执行栈为空,查询是否有微任务需要执行
- 执行所有微任务
- 必要的话渲染
UI - 然后开始下⼀轮
Event loop,执行宏任务中的异步代码
通过上述的 Event loop 顺序可知, 如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话, 为了更快的响应界面响应, 我们可以把操作 DOM 放入微任务中
Node 中的 Event loop
Node中的Event loop和浏览器中的不相同。Node的Event loop分为6个阶段, 它们会按照顺序反复运行

timer
timers阶段会执行setTimeout和setInterval- ⼀个
timer指定的时间并不是准确时间,而是在达到这个时间后尽快执行回调, 可能会因为系统正在执行别的事务而延迟
I/O
I/O阶段会执行除了close事件,定时器和setImmediate的回调
poll
poll阶段很重要, 这⼀阶段中, 系统会做两件事情- 执行到点的定时器
- 执行
poll队列中的事件
并且当
poll中没有定时器的情况下, 会发现以下两件事情- 如果
poll队列不为空,会遍历回调队列并同步执行, 直到队列为空或者系统限制 - 如果
poll队列为空,会有两件事发生 - 如果有
setImmediate需要执行,poll阶段会停止并且进⼊到check阶段执行setImmediate - 如果没有
setImmediate需要执行,会等待回调被加⼊到队列中并立即执行回调 - 如果有别的定时器需要被执行,会回到
timer阶段执行回调。
- 如果
check
check 阶段执行 setImmediate
close callbacks
close callbacks阶段执行close事件- 并且在
Node中,有些情况下的定时器执行顺序是随机的
js
setTimeout(() => {
console.log(" setTimeout");
}, 0);
setImmediate(() => {
console.log(" setImmediate");
});
// 这里可能会输出 setTimeout, setImmediate
// 可能也会相反的输出, 这取决于性能
// 因为可能进入 event loop 用了不到 1 毫秒, 这时候会执行 setImmediate
// 否则会执行 setTimeout上面介绍的都是 macrotask 的执行情况, microtask 会在以上每个阶段完成后立即执行
js
setTimeout(() => {
console.log("timer1");
Promise.resolve().then(function () {
console.log("promise1");
});
}, 0);
setTimeout(() => {
console.log("timer2");
Promise.resolve().then(function () {
console.log("promise2");
});
}, 0);
// 以上代码在浏览器和 node 中打印情况是不同的
// 浏览器中⼀定打印 timer1, promise1, timer2, promise2
// node 中可能打印 timer1, timer2, promise1, promise2
// 也可能打印 timer1, promise1, timer2, promise2Node 中的 process.nextTick 会先于其他 microtask 执行
js
setTimeout(() => {
console.log("timer1");
Promise.resolve().then(function () {
console.log("promise1");
});
}, 0);
process.nextTick(() => {
console.log("nextTick");
});
// nextTick, timer1, promise14. Service Worker
Service workers 本质上充当 Web 应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理 。它们旨在 ( 除其他之外) 使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作 。他们还允许访问推送通知和后台同步 API
目前该技术通常用来做缓存文件,提高首屏速度
js
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register("sw.js")
.then(function (registration) {
console.log("service worker 注册成功");
})
.catch(function (err) {
console.log("servcie worker 注册失败");
});
}js
// sw.js
// 监听 `install` 事件, 回调中缓存所需文件
self.addEventListener("install", (e) => {
e.waitUntil(
caches.open("my-cache").then(function (cache) {
return cache.addAll(["./index.html", "./index.js"]);
})
);
});
// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener("fetch", (e) => {
e.respondWith(
caches.match(e.request).then(function (response) {
if (response) {
return response;
}
console.log("fetch source");
})
);
});打开页面, 可以在开发者⼯具中的 Application 看到 Service Worker 已经启动了
在 Cache 中也可以发现我们所需的文件已被缓存
当我们重新刷新页面可以发现我们缓存的数据是从 Service Worker 中读取的
5. 渲染机制
浏览器的渲染机制⼀般分为以下几个步骤
- 处理
HTML并构建DOM树。 - 处理
CSS构建CSSOM树。 - 将
DOM与CSSOM合并成⼀个渲染树。 - 根据渲染树来布局,计算每个节点的位置。
- 调用 GPU 绘制,合成图层, 显示在屏幕上
在构建 CSSOM 树时,会阻塞渲染, 直至 CSSOM 树构建完成 。并且构建 CSSOM 树是⼀个⼗分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的 CSS 选择器,执⾏速度越慢
当 HTML 解析到 script 标签时,会暂停构建 DOM , 完成后才会从暂停的地方重新开始。也就是说, 如果你想首屏渲染的越快,就越不应该在首屏就加载 1S 文件 。并且 CSS 也会影响 1S 的执⾏, 只有当解析完样式表才会执⾏ 1S ,所以也可以认为这种情况下, CSS 也会暂停构建 DOM 图层
⼀般来说, 可以把普通文档流看成⼀个图层 。特定的属性可以生成⼀个新的图层 。不同的图层渲染互不影响,所以对于某些频繁需要渲染的建议单独生成⼀个新图层,提高性能 。但也不能生成过多的图层,会引起反作用
通过以下⼏个常用属性可以生成新图层:
3D变换:translate3d、translateZwill-changevideo、iframe标签- 通过动画实现的
opacity动画转换 position: fixed
重绘 ( Repaint) 和回流 ( Reflow)
重绘是当节点需要更改外观而不会影响布局的, 比如改变 color 就叫称为重绘
回流是布局或者⼏何属性需要改变就称为回流
回流必定会发生重绘, 重绘不⼀定会引发回流 。回流所需的成本比重绘高的多, 改变深层次的节点很可能导致父节点的⼀系列回流
所以以下几个动作可能会导致性能问题:
- 改变
window大⼩ - 改变字体
- 添加或删除样式
- 文字改变
- 定位或者浮动
- 盒模型
很多人不知道的是,重绘和回流其实和 Event loop 有关:
- 当
Event loop执⾏完Microtasks后,会判断document是否需要更新 。因为浏览器是60Hz的刷新率,每16ms才会更新⼀次。 - 然后判断是否有
resize或者scroll,有的话会去触发事件,所以resize和scroll事件也是至少 16ms 才会触发⼀次, 并且自带节流功能。 - 判断是否触发了
media query - 更新动画并且发送事件
- 判断是否有全屏操作事件
- 执⾏
requestAnimationFrame回调 - 执⾏
IntersectionObserver回调,该方法用于判断元素是否可⻅, 可以用于懒加载上, 但是兼容性不好 - 更新界面
- 以上就是⼀帧中可能会做的事情 。如果在⼀帧中有空闲时间,就会去执行
requestIdleCallback回调
减少重绘和回流
- 使用
translate替代top - 使用
visibility替换display: none, 因为前者只会引起重绘,后者会引发回流(改变了布局) - 不要使用
table布局, 可能很⼩的⼀个⼩改动会造成整个table的重新布局 - 动画实现的速度的选择, 动画速度越快, 回流次数越多,也可以选择使用
requestAnimationFrame CSS选择符从右往左匹配查找, 避免DOM深度过深- 将频繁运行的动画变为图层, 图层能够阻止该节点回流影响别的元素 。比如对于
video标签, 浏览器会自动将该节点变为图层
