浏览器与新技术
常见的浏览器内核有哪些?
浏览器/RunTime | 内核(渲染引擎) | JavaScript 引擎 |
---|---|---|
Chrome | Blink(28~) Webkit(Chrome 27) | V8 |
FireFox | Gecko | SpiderMonkey |
Safari | Webkit | JavaScriptCore |
Edge | EdgeHTML | Chakra(for JavaScript) |
IE | Trident | Chakra(for JScript) |
PhantomJS | Webkit | JavaScriptCore |
Node.js | - | V8 |
浏览器的主要组成部分是什么?
- 用户界面 - 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。
- 浏览器引擎 - 在用户界面和呈现引擎之间传送指令。
- 呈现引擎 - 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
- 网络 - 用于网络调用,比如 HTTP 请求。其接口与平台无关,并为所有平台提供底层实现。
- 用户界面后端 - 用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。
- JavaScript 解释器。用于解析和执行 JavaScript 代码。
- 数据存储。这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“网络数据库”,这是一个完整(但是轻便)的浏览器内数据库。
值得注意的是,和大多数浏览器不同,Chrome 浏览器的每个标签页都分别对应一个呈现引擎实例。每个标签页都是一个独立的进程。
浏览器是如何渲染 UI 的?
- 浏览器获取 HTML 文件,然后对文件进行解析,形成 DOM Tree
- 与此同时,进行 CSS 解析,生成 Style Rules
- 接着将 DOM Tree 与 Style Rules 合成为 Render Tree
- 接着进入布局(Layout)阶段,也就是为每个节点分配一个应出现在屏幕上的确切坐标
- 随后调用 GPU 进行绘制(Paint),遍历 Render Tree 的节点,并将元素呈现出来
浏览器如何解析 css 选择器?
浏览器会『从右往左』解析 CSS 选择器。
我们知道 DOM Tree 与 Style Rules 合成为 Render Tree,实际上是需要将Style Rules附着到 DOM Tree 上,因此需要根据选择器提供的信息对 DOM Tree 进行遍历,才能将样式附着到对应的 DOM 元素上。
以下这段 css 为例
.mod-nav h3 span {
font-size: 16px;
}
我们对应的 DOM Tree 如下
若从左向右的匹配,过程是:
- 从 .mod-nav 开始,遍历子节点 header 和子节点 div
- 然后各自向子节点遍历。在右侧 div 的分支中
- 最后遍历到叶子节点 a ,发现不符合规则,需要回溯到 ul 节点,再遍历下一个 li-a,一颗 DOM 树的节点动不动上千,这种效率很低。
如果从右至左的匹配:
- 先找到所有的最右节点 span,对于每一个 span,向上寻找节点 h3
- 由 h3 再向上寻找 class=mod-nav 的节点
- 最后找到根元素 html 则结束这个分支的遍历。
后者匹配性能更好,是因为从右向左的匹配在第一步就筛选掉了大量的不符合条件的最右节点(叶子节点);而从左向右的匹配规则的性能都浪费在了失败的查找上面。
DOM Tree 是如何构建的?
- 转码: 浏览器将接收到的二进制数据按照指定编码格式转化为 HTML 字符串
- 生成 Tokens: 之后开始 parser,浏览器会将 HTML 字符串解析成 Tokens
- 构建 Nodes: 对 Node 添加特定的属性,通过指针确定 Node 的父、子、兄弟关系和所属 treeScope
- 生成 DOM Tree: 通过 node 包含的指针确定的关系构建出 DOM Tree
浏览器重绘与重排的区别?
- 重排: 部分渲染树(或者整个渲染树)需要重新分析并且节点尺寸需要重新计算,表现为重新生成布局,重新排列元素
- 重绘: 由于节点的几何属性发生改变或者由于样式发生改变,例如改变元素背景色时,屏幕上的部分内容需要更新,表现为某些元素的外观被改变
单单改变元素的外观,肯定不会引起网页重新生成布局,但当浏览器完成重排之后,将会重新绘制受到此次重排影响的部分
重排和重绘代价是高昂的,它们会破坏用户体验,并且让 UI 展示非常迟缓,而相比之下重排的性能影响更大,在两者无法避免的情况下,一般我们宁可选择代价更小的重绘。
『重绘』不一定会出现『重排』,『重排』必然会出现『重绘』。
如何触发重排和重绘?
任何改变用来构建渲染树的信息都会导致一次重排或重绘:
- 添加、删除、更新 DOM 节点
- 通过 display: none 隐藏一个 DOM 节点-触发重排和重绘
- 通过 visibility: hidden 隐藏一个 DOM 节点-只触发重绘,因为没有几何变化
- 移动或者给页面中的 DOM 节点添加动画
- 添加一个样式表,调整样式属性
- 用户行为,例如调整窗口大小,改变字号,或者滚动。
如何避免重绘或者重排?
集中改变样式
我们往往通过改变 class 的方式来集中改变样式
// 判断是否是黑色系样式
const theme = isDark ? "dark" : "light";
// 根据判断来设置不同的class
ele.setAttribute("className", theme);
使用 DocumentFragment
我们可以通过 createDocumentFragment 创建一个游离于 DOM 树之外的节点,然后在此节点上批量操作,最后插入 DOM 树中,因此只触发一次重排
var fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
let node = document.createElement("p");
node.innerHTML = i;
fragment.appendChild(node);
}
document.body.appendChild(fragment);
提升为合成层
将元素提升为合成层有以下优点:
- 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
- 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
- 对于 transform 和 opacity 效果,不会触发 layout 和 paint
提升合成层的最好方式是使用 CSS 的 will-change 属性:
#target {
will-change: transform;
}
关于合成层的详解请移步无线性能优化:Composite
前端如何实现即时通讯?
短轮询
短轮询的原理很简单,每隔一段时间客户端就发出一个请求,去获取服务器最新的数据,一定程度上模拟实现了即时通讯。
- 优点:兼容性强,实现非常简单
- 缺点:延迟性高,非常消耗请求资源,影响性能
comet
comet 有两种主要实现手段,一种是基于 AJAX 的长轮询(long-polling)方式,另一种是基于 Iframe 及 htmlfile 的流(streaming)方式,通常被叫做长连接。
具体两种手段的操作方法请移步Comet 技术详解:基于 HTTP 长连接的 Web 端实时通信技术
长轮询优缺点:
- 优点:兼容性好,资源浪费较小
- 缺点:服务器 hold 连接会消耗资源,返回数据顺序无保证,难于管理维护
长连接优缺点:
- 优点:兼容性好,消息即时到达,不发无用请求
- 缺点:服务器维护长连接消耗资源
SSE
使用指南请看Server-Sent Events 教程
SSE(Server-Sent Event,服务端推送事件)是一种允许服务端向客户端推送新数据的 HTML5 技术。
- 优点:基于 HTTP 而生,因此不需要太多改造就能使用,使用方便,而 websocket 非常复杂,必须借助成熟的库或框架
- 缺点:基于文本传输效率没有 websocket 高,不是严格的双向通信,客户端向服务端发送请求无法复用之前的连接,需要重新发出独立的请求
Websocket
使用指南请看WebSocket 教程
Websocket 是一个全新的、独立的协议,基于 TCP 协议,与 http 协议兼容、却不会融入 http 协议,仅仅作为 html5 的一部分,其作用就是在服务器和客户端之间建立实时的双向通信。
- 优点:真正意义上的实时双向通信,性能好,低延迟
- 缺点:独立与 http 的协议,因此需要额外的项目改造,使用复杂度高,必须引入成熟的库,无法兼容低版本浏览器
Web Worker
后面性能优化部分会用到,先做了解
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行
Service workers
后面性能优化部分会用到,先做了解
Service workers 本质上充当 Web 应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理,创建有效的离线体验。
什么是浏览器同源策略?
同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。
同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个 ip 地址,也非同源。
下表给出了相对http://store.company.com/dir/page.html
同源检测的示例:
浏览器中的大部分内容都是受同源策略限制的,但是以下三个标签可以不受限制:
<img src=XXX>
<link href=XXX>
<script src=XXX>
如何实现跨域?
跨域是个比较古老的命题了,历史上跨域的实现手段有很多,我们现在主要介绍三种比较主流的跨域方案,其余的方案我们就不深入讨论了,因为使用场景很少,也没必要记这么多奇技淫巧。
最经典的跨域方案 jsonp
jsonp 本质上是一个 Hack,它利用<script>
标签不受同源策略限制的特性进行跨域操作。
jsonp 优点:
- 实现简单
- 兼容性非常好
jsonp 的缺点:
- 只支持 get 请求(因为
<script>
标签只能 get) - 有安全性问题,容易遭受 xss 攻击
- 需要服务端配合 jsonp 进行一定程度的改造
jsonp 的实现:
function JSONP({ url, params, callbackKey, callback }) {
// 在参数里制定 callback 的名字
params = params || {};
params[callbackKey] = "jsonpCallback";
// 预留 callback
window.jsonpCallback = callback;
// 拼接参数字符串
const paramKeys = Object.keys(params);
const paramString = paramKeys.map((key) => `${key}=${params[key]}`).join("&");
// 插入 DOM 元素
const script = document.createElement("script");
script.setAttribute("src", `${url}?${paramString}`);
document.body.appendChild(script);
}
JSONP({
url: "http://s.weibo.com/ajax/jsonp/suggestion",
params: {
key: "test",
},
callbackKey: "_cb",
callback(result) {
console.log(result.data);
},
});
最流行的跨域方案 cors
cors 是目前主流的跨域解决方案,跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器 让运行在一个 origin (domain) 上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。
如果你用 express,可以这样在后端设置
//CORS middleware
var allowCrossDomain = function (req, res, next) {
res.header("Access-Control-Allow-Origin", "http://example.com");
res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE");
res.header("Access-Control-Allow-Headers", "Content-Type");
next();
};
//...
app.configure(function () {
app.use(express.bodyParser());
app.use(express.cookieParser());
app.use(express.session({ secret: "cool beans" }));
app.use(express.methodOverride());
app.use(allowCrossDomain);
app.use(app.router);
app.use(express.static(__dirname + "/public"));
});
在生产环境中建议用成熟的开源中间件解决问题。
最方便的跨域方案 Nginx
nginx 是一款极其强大的 web 服务器,其优点就是轻量级、启动快、高并发。
现在的新项目中 nginx 几乎是首选,我们用 node 或者 java 开发的服务通常都需要经过 nginx 的反向代理。
反向代理的原理很简单,即所有客户端的请求都必须先经过 nginx 的处理,nginx 作为代理服务器再讲请求转发给 node 或者 java 服务,这样就规避了同源策略。
#进程, 可更具cpu数量调整
worker_processes 1;
events {
#连接数
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
#连接超时时间,服务器会在这个时间过后关闭连接。
keepalive_timeout 10;
# gizp压缩
gzip on;
# 直接请求nginx也是会报跨域错误的这里设置允许跨域
# 如果代理地址已经允许跨域则不需要这些, 否则报错(虽然这样nginx跨域就没意义了)
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Headers X-Requested-With;
add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
# srever模块配置是http模块中的一个子模块,用来定义一个虚拟访问主机
server {
listen 80;
server_name localhost;
# 根路径指到index.html
location / {
root html;
index index.html index.htm;
}
# localhost/api 的请求会被转发到192.168.0.103:8080
location /api {
rewrite ^/b/(.*)$ /$1 break; # 去除本地接口/api前缀, 否则会出现404
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.0.103:8080; # 转发地址
}
# 重定向错误页面到/50x.html
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
其它跨域方案
- HTML5 XMLHttpRequest 有一个 API,postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递。
- WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了,因此可以跨域。
- window.name + iframe:window.name 属性值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值,我们可以利用这个特点进行跨域。
- location.hash + iframe:a.html 欲与 c.html 跨域相互通信,通过中间页 b.html 来实现。 三个页面,不同域之间利用 iframe 的 location.hash 传值,相同域之间直接 js 访问来通信。
- document.domain + iframe: 该方式只能用于二级域名相同的情况下,比如 a.test.com 和 b.test.com 适用于该方式,我们只需要给页面添加 document.domain ='test.com' 表示二级域名都相同就可以实现跨域,两个页面都通过 js 强制设置 document.domain 为基础主域,就实现了同域。
其余方案来源于九种跨域方式
事件机制
事件触发三阶段
事件触发有三个阶段
window
往事件触发处传播,遇到注册的捕获事件会触发- 传播到事件触发处时触发注册的事件
- 从事件触发处往
window
传播,遇到注册的冒泡事件会触发
事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个目标节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。
// 以下会先打印冒泡然后是捕获
node.addEventListener(
'click',
event => {
console.log('冒泡')
},
false
)
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
注册事件
通常我们使用addEventListener
注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值useCapture
参数来说,该参数默认值为false
。useCapture
决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性
capture
,布尔值,和useCapture
作用一样once
,布尔值,值为true
表示该回调只会调用一次,调用后会移除监听passive
,布尔值,表示永远不会调用preventDefault
一般来说,我们只希望事件只触发在目标上,这时候可以使用stopPropagation
来阻止事件的进一步传播。通常我们认为stopPropagation
是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagation
同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。
node.addEventListener(
'click',
event => {
event.stopImmediatePropagation()
console.log('冒泡')
},
false
)
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
事件代理
如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上
<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>
事件代理的方式相对于直接给目标注册事件来说,有以下优点
- 节省内存
- 不需要给子节点注销事件
跨域
因为浏览器出于安全考虑,有同源策略。也就是说,如果协议、域名或者端口有一个不同就是跨域,Ajax 请求会失败。
我们可以通过以下几种常用方法解决跨域的问题
JSONP
JSONP 的原理很简单,就是利用<script>
标签没有跨域限制的漏洞。通过<script>
标签指向一个需要访问的地址并提供一个回调函数来接收数据当需要通讯时。
<script src="http://domain/api?param1=a¶m2=b&callback=jsonp"></script>
<script>
function jsonp(data) {
console.log(data)
}
</script>
JSONP 使用简单且兼容性不错,但是只限于get
请求。
在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,这时候就需要自己封装一个 JSONP,以下是简单实现
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)
})
CORS
CORS 需要浏览器和后端同时支持。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
这种方式通常用于获取嵌入页面中的第三方页面数据。一个页面发送消息,另一个页面判断来源并接收消息
// 发送消息端
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('验证通过')
}
})
Event loop
众所周知 JS 是门非阻塞单线程语言,因为在最初 JS 就是为了和浏览器交互而诞生的。如果 JS 是门多线程的语言话,我们在多个线程中处理 DOM 就可能会发生问题(一个线程中新加节点,另一个线程中删除节点),当然可以引入读写锁解决这个问题。
JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。
console.log('script start')
setTimeout(function() {
console.log('setTimeout')
}, 0)
console.log('script end')
以上代码虽然setTimeout
延时为 0,其实还是异步。这是因为 HTML5 标准规定这个函数第二个参数不得小于 4 毫秒,不足会自动增加。所以setTimeout
还是会在script end
之后打印。
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为jobs
,macrotask 称为task
。
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 => setTimeout
以上代码虽然setTimeout
写在Promise
之前,但是因为Promise
属于微任务而setTimeout
属于宏任务,所以会有以上的打印。
微任务包括process.nextTick
,promise
,Object.observe
,MutationObserver
宏任务包括script
,setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
很多人有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了script
,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务。
所以正确的一次 Event loop 顺序是这样的
- 执行同步代码,这属于宏任务
- 执行栈为空,查询是否有微任务需要执行
- 执行所有微任务
- 必要的话渲染 UI
- 然后开始下一轮 Event loop,执行宏任务中的异步代码
通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的 界面响应,我们可以把操作 DOM 放入微任务中。
Node 中的 Event loop
Node 中的 Event loop 和浏览器中的不相同。
Node 的 Event loop 分为 6 个阶段,它们会按照顺序反复运行
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
timer
timers 阶段会执行setTimeout
和setInterval
一个timer
指定的时间并不是准确时间,而是在达到这个时间后尽快执行回调,可能会因为系统正在执行别的事务而延迟。
下限的时间有一个范围:[1, 2147483647]
,如果设定的时间不在这个范围,将被设置为 1。
I/O
I/O 阶段会执行除了 close 事件,定时器和setImmediate
的回调
idle, prepare
idle, prepare 阶段内部实现
poll
poll 阶段很重要,这一阶段中,系统会做两件事情
- 执行到点的定时器
- 执行 poll 队列中的事件
并且当 poll 中没有定时器的情况下,会发现以下两件事情
- 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者系统限制
- 如果 poll 队列为空,会有两件事发生
- 如果有
setImmediate
需要执行,poll 阶段会停止并且进入到 check 阶段执行setImmediate
- 如果没有
setImmediate
需要执行,会等待回调被加入到队列中并立即执行回调
- 如果有
如果有别的定时器需要被执行,会回到 timer 阶段执行回调。
check
check 阶段执行setImmediate
close callbacks
close callbacks 阶段执行 close 事件
并且在 Node 中,有些情况下的定时器执行顺序是随机的
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
// 这里可能会输出 setTimeout,setImmediate
// 可能也会相反的输出,这取决于性能
// 因为可能进入 event loop 用了不到 1 毫秒,这时候会执行 setImmediate
// 否则会执行 setTimeout
当然在这种情况下,执行顺序是相同的
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
// 因为 readFile 的回调在 poll 中执行
// 发现有 setImmediate ,所以会立即跳到 check 阶段执行回调
// 再去 timer 阶段执行 setTimeout
// 所以以上输出一定是 setImmediate,setTimeout
上面介绍的都是 macrotask 的执行情况,microtask 会在以上每个阶段完成后立即执行。
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, promise2
Node 中的process.nextTick
会先于其他 microtask 执行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
})
// nextTick, timer1, promise1
存储
cookie,localStorage,sessionStorage,indexDB
特性 | cookie | localStorage | sessionStorage | indexDB |
---|---|---|---|---|
数据生命周期 | 一般由服务器生成,可以设置过期时间 | 除非被清理,否则一直存在 | 页面关闭就清理 | 除非被清理,否则一直存在 |
数据存储大小 | 4K | 5M | 5M | 无限 |
与服务端通信 | 每次都会携带在 header 中,对于请求性能影响 | 不参与 | 不参与 | 不参与 |
从上表可以看到,cookie
已经不建议用于存储。如果没有大量数据存储需求的话,可以使用localStorage
和sessionStorage
。对于不怎么改变的数据尽量使用localStorage
存储,否则可以用sessionStorage
存储。
对于cookie
,我们还需要注意安全性。
属性 | 作用 |
---|---|
value | 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识 |
http-only | 不能通过 JS 访问 Cookie,减少 XSS 攻击 |
secure | 只能在协议为 HTTPS 的请求中携带 |
same-site | 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击 |
Service Worker
Service workers 本质上充当 Web 应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步 API。
目前该技术通常用来做缓存文件,提高首屏速度,可以试着来实现这个功能。
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register('sw.js')
.then(function(registration) {
console.log('service worker 注册成功')
})
.catch(function(err) {
console.log('servcie worker 注册失败')
})
}
// 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 中读取的
渲染机制
浏览器的渲染机制一般分为以下几个步骤
- 处理 HTML 并构建 DOM 树。
- 处理 CSS 构建 CSSOM 树。
- 将 DOM 与 CSSOM 合并成一个渲染树。
- 根据渲染树来布局,计算每个节点的位置。
- 调用 GPU 绘制,合成图层,显示在屏幕上。
在构建 CSSOM 树时,会阻塞渲染,直至 CSSOM 树构建完成。并且构建 CSSOM 树是一个十分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的 CSS 选择器,执行速度越慢。
当 HTML 解析到 script 标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件。并且 CSS 也会影响 JS 的执行,只有当解析完样式表才会执行 JS,所以也可以认为这种情况下,CSS 也会暂停构建 DOM。
Load 和 DOMContentLoaded 区别
Load 事件触发代表页面中的 DOM,CSS,JS,图片已经全部加载完毕。
DOMContentLoaded 事件触发代表初始的 HTML 被完全加载和解析,不需要等待 CSS,JS,图片加载。
图层
一般来说,可以把普通文档流看成一个图层。特定的属性可以生成一个新的图层。不同的图层渲染互不影响,所以对于某些频繁需要渲染的建议单独生成一个新图层,提高性能。但也不能生成过多的图层,会引起反作用。
通过以下几个常用属性可以生成新图层
- 3D 变换:
translate3d
、translateZ
will-change
video
、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
回调。
以上内容来自于HTML 文档
减少重绘和回流
使用
translate
替代top
<div class="test"></div> <style> .test { position: absolute; top: 10px; width: 100px; height: 100px; background: red; } </style> <script> setTimeout(() => { // 引起回流 document.querySelector('.test').style.top = '100px' }, 1000) </script>
使用
visibility
替换display: none
,因为前者只会引起重绘,后者会引发回流(改变了布局)把 DOM 离线后修改,比如:先把 DOM 给
display:none
(有一次 Reflow),然后你修改 100 次,然后再把它显示出来不要把 DOM 结点的属性值放在一个循环里当成循环里的变量
for (let i = 0; i < 1000; i++) { // 获取 offsetTop 会导致回流,因为需要去获取正确的值 console.log(document.querySelector('.test').style.offsetTop) }
不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用
requestAnimationFrame
CSS 选择符从右往左匹配查找,避免 DOM 深度过深
将频繁运行的动画变为图层,图层能够阻止该节点回流影响别的元素。比如对于
video
标签,浏览器会自动将该节点变为图层。