JS核心理论之《浏览器基础知识、缓存与渲染原理》

269 阅读11分钟

事件机制

现代浏览器(指 IE6-IE8 除外的浏览器,包括 IE9+、FireFox、Safari、Chrome 和 Opera 等)事件流包含三个过程,分别是捕获阶段、目标阶段和冒泡阶段。

v2-eae0aead63e1ad8b3ed2f06579c594dc_hd

事件代理:如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上。相较于直接给目标注册事件来说,有两个优点:节省内存、不需要给子节点注销事件。

DOM节点

DOM(Document Object Model):文档对象模型。整个文档是由一系列节点对象组成的一棵树。

节点类型包括:元素节点(1)、属性节点(2)、文本节点(3)、 ... (其中,1..2..3..代表节点类型)

var th1= document.getElementById("th1");
alert(th1.nodeType);
alert(th1.nodeName);
alert(th1.nodeValue);
//th1代表一个元素节点(nodeType=1),nodeName就是标签名(th),元素节点的nodeValue=null。
  • 获取元素:getElementById getElementsByTagName getElementsByClassName getElementsByName
  • 修改元素:innerText innerHTML
  • 添加删除元素:
    1. createElement建一个元素节点
    2. createTextNode创建一个文本节点
    3. appendChild 添加子节点
    4. insertBefore 在某个元素之前插入元素
    5. removeChild 删除子节点

跨域

因为浏览器出于安全考虑,有同源策略。也就是说,如果协议、域名或者端口有一个不同就是跨域,Ajax 请求会失败。 那么是出于什么安全考虑才会引入这种机制呢? 其实主要是用来防止 CSRF 攻击的。简单点说,CSRF 攻击是利用用户的登录态发起恶意请求。

也就是说,没有同源策略的情况下,A 网站可以被任意其他来源的 Ajax 访问到内容。如果你当前 A 网站还存在登录态, 那么对方就可以通过 Ajax 获得你的任何信息。当然跨域并不能完全阻止 CSRF

然后我们来考虑一个问题,请求跨域了,那么请求到底发出去没有? 请求必然是发出去了,但是浏览器拦截了响应。 你可能会疑问明明通过表单的方式可以发起跨域请求,为什么 Ajax 就不会。因为归根结底,跨域是为了阻止用户读取到另一个域名下的内容, Ajax 可以获取响应,浏览器认为这不安全,所以拦截了响应。但是表单并不会获取新的内容,所以可以发起跨域请求。 同时也说明了跨域并不能完全阻止 CSRF,因为请求毕竟是发出去了。

解决跨域的方式

  1. JSNOP : 原理很简单,就是利用 script标签没有跨域限制的漏洞。通过script标签指向一个需要访问的地址并提供一个回调函数来接收数据当需要通讯时。
  2. CORS : CORS 需要浏览器和后端同时支持。服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。此时复杂请求会首先发起option预检请求,检查服务端是否允许跨域。
  3. document.domain :只能用于二级域名相同的情况下,比如 a.test.com 和 b.test.com ,只需要给页面添加 document.domain = 'test.com' 表示二级域名都相同就可以实现跨域。
  4. 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('验证通过')
    }
})

存储机制

WechatIMG254

从上表可以看到,cookie 已经不建议用于存储。对于 cookie 来说,我们还需要注意安全性。 如果没有大量数据存储需求的话,可以使用 localStorage 和 sessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储。

缓存机制

缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络

  1. Service Worker : 运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS.
  2. 内存Cache :读取内存中的数据肯定比磁盘快。可是缓存持续性很短,会随着进程的释放而释放。
  3. 磁盘Cache : 读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。
  4. Push Cache : HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在。

Service Worker 实现缓存功能一般分为三个步骤:

  1. 首先需要先注册 Service Worker,
  2. 然后监听到 install 事件以后就可以缓存需要的文件,
  3. 那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。 示例:
// 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')
    })
  )
})

缓存策略 强缓存和协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的。

  1. 强缓存 强缓存表示在缓存期间不需要请求,state code 为 200。可以通过设置两种 HTTP Header 实现:ExpiresCache-Control且Cache-Control优先级高于 Expires
  2. 协商缓存 如果缓存过期了,就需要发起请求验证资源是否有更新。协商缓存可以通过设置两种 HTTP Header 实现:Last-ModifiedETag且 ETag 优先级比 Last-Modified 高

对于频繁变动的资源,首先需要使用 Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。 这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

代码文件设置缓存有效期一年 Cache-Control: max-age=31536000,这样只有当 HTML 文件中引入的文件名发生了改变才会去下载最新的代码文件,否则就一直使用缓存

缓存判断流程

  1. 存储策略发生在收到请求响应后,用于决定是否缓存相应资源;

  2. 过期策略发生在请求前,用于判断缓存是否过期,即是通过判断强缓存中的Cache-control与Expires;

  3. 协商策略发生在请求中,用于判断缓存资源是否更新,即是通过协商缓存中的ETag与Last-Modified判断。

浏览器下访问资源的方式主要有以下 7 种:

  • (新标签)地址栏回车
  • 链接跳转
  • 前进、后退
  • 从收藏栏打开链接
  • (window.open)新开窗口
  • 刷新(Command + R / F5)
  • 强制刷新(Command + Shift + R / Ctrl + F5)
  • 使用这 7 种方式访问资源时,应用缓存的策略会有一些不同。

cache

渲染原理

JS 有一个 JS 引擎,那么执行渲染也有一个渲染引擎。同样,渲染引擎在不同的浏览器中也不是都相同的。比如在 Firefox 中叫做 Gecko,在 Chrome 和 Safari 中都是基于 WebKit 开发的。

浏览器接收到 HTML 文件并转换为 DOM 树的过程:

字节数据 => 字符串 => Token => Node => DOM

网络中传输的内容其实都是 0 和 1 这些字节数据。当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串, 也就是我们写的代码 当数据转换为字符串以后,浏览器会先将这些字符串通过词法分析转换为标记(token),这一过程在词法分析中叫做标记化(tokenization) 当结束标记化后,这些标记会紧接着转换为 Node,最后这些 Node 会根据不同 Node 之前的联系构建为一颗 DOM 树。 以上就是浏览器从网络中接收到 HTML 文件然后一系列的转换过程。

将CSS文件转换为CSSDOM树:

字节数据 => 字符串 => Token => Node => CSSDOM

浏览器需要递归CSSDOM树,然后确定具体元素到底 是什么样式,这个过程很消耗资源。比如 div > a > span这样的选择器就比 直接span这样的选择器 更耗资源,因为需要递归,所以要尽可能保证层级扁平,且尽量少的添加无意义标签

生成渲染树 当我们生成 DOM 树和 CSSOM 树以后,就需要将这两棵树组合为渲染树。 在这一过程中,不是简单的将两者合并就行了。渲染树只会包括需要显示的节点和这些节点的样式信息,如果某个节点是 display: none 的,那么就不会在渲染树中显示。 当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流),然后调用 GPU 绘制,合成图层,显示在屏幕上。 WechatIMG271

重绘(Repaint)和回流(Reflow

重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。 重绘是当节点需要更改外观而不会影响布局的,比如改变 color 就叫称为重绘 回流是布局或者几何属性需要改变就称为回流。 回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流。

以下几个动作可能会导致性能问题:

  • 改变 window 大小
  • 改变字体
  • 添加或删除样式
  • 文字改变
  • 定位或者浮动
  • 盒模型

减少重绘和回流的建议:

  1. 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)
  2. 不要把节点的属性值放在一个循环里当成循环里的变量
    for(let i = 0; i < 1000; i++) {
        // 获取 offsetTop 会导致回流,因为需要去获取正确的值
        console.log(document.querySelector('.test').style.offsetTop)
    }
  1. 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局。
  2. 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  3. CSS 选择符从右往左匹配查找,避免节点层级过多。

为什么操作 DOM 慢

因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。 操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。

插入几万个 DOM,如何实现页面不卡顿

肯定不能一次性把几万个 DOM 全部插入,这样肯定会造成卡顿,所以解决问题的重点应该是如何分批次部分渲染 DOM。 大部分人应该可以想到通过 requestAnimationFrame 的方式去循环的插入 DOM,其实还有种方式去解决这个问题:虚拟滚动(virtualized scroller)。 这种技术的原理就是只渲染可视区域内的内容,非可见区域的那就完全不渲染了,当用户在滚动的时候就实时去替换渲染的内容。

什么情况阻塞渲染?

首先渲染的前提是生成渲染树,所以 HTML 和 CSS 肯定会阻塞渲染。如果你想渲染的越快,你越应该降低一开始需要渲染的文件大小,并且扁平层级,优化选择器。 然后当浏览器在解析到 script 标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件, 这也是都建议将 script 标签放在 body 标签底部的原因。

当然在当下,并不是说 script 标签必须放在底部,因为你可以给 script 标签添加 defer 或者 async 属性。 当 script 标签加上 defer 属性以后,表示该 JS 文件会并行下载,但是会放到 HTML 解析完成后顺序执行,所以对于这种情况你可以把 script 标签放在任意位置。 对于没有任何依赖的 JS 文件可以加上 async 属性,表示 JS 文件下载和解析不会阻塞渲染。