Skip to content
大纲

浏览器

一、浏览器安全

1. XSS 攻击

跨域脚本攻击(cross-site scripting)

XSS 攻击指的是跨站脚本攻击,是一种代码注入攻击。攻击者通过在网站注入恶意脚本,使之在用户的浏览器上运行,从而盗取用户的信息如 cookie 等。

XSS 的本质是因为网站没有对恶意代码进行过滤,与正常的代码混合在一起了,浏览器没有办法分辨哪些脚本是可信的,从而导致了恶意代码的执行。

攻击者可以通过这种攻击方式可以进行以下操作:

  • 获取页面的数据,如 DOM、cookie、localStorage;
  • DOS 攻击,发送合理请求,占用服务器资源,从而使用户无法访问服务器;
  • 破坏页面结构;
  • 流量劫持(将链接指向某网站);

攻击类型:

  • 存储型(持久):恶意脚本会存储在目标服务器上,当浏览器请求数据时,脚本从服务器传回并执行。这种攻击危害性很大,因为如果网站访问量很大的话,就会导致大量正常访问页面的用户都受到攻击。

    常⻅于带有⽤户保存数据的⽹站功能,如论坛发帖、商品评论、⽤户私信等。

  • 反射型(非持久):攻击者诱导用户访问一个带有恶意代码的 URL 后,服务器端接收数据后处理,然后把带有恶意代码的数据发送到浏览器端,浏览器端解析这段带有 XSS 代码的数据后当做脚本执行,最终完成 XSS 攻击。

    反射型 XSS 漏洞常⻅于通过 URL 传递参数的功能,如⽹站搜索、跳转等。 由于需要⽤户主动打开恶意的 URL 才能⽣效,攻击者往往会结合多种⼿段诱导⽤户点击。

  • DOM 型:指的通过修改页面的 DOM 节点形成的 XSS。

    DOM 型 XSS 跟前两种 XSS 的区别:DOM 型 XSS 攻击中,取出和执⾏恶意代码由浏览器端完成,属于前端 JavaScript ⾃身的安全漏洞,⽽其他两种 XSS 都属于服务端的安全漏洞。

防御措施:

  • 转义字符:
    • HTML:对以下这些字符进行转义:
      js
      &&
      <&alt;
      >&gt;
      ':&#x27;
      ":&quot;
      /&#x2F;
      
    • Javascript:把所有非字母、数字的字符都转义成小于 256 的 ASCII 字符;
    • URL:使用 Javascript 的 encodeURIComponent()方法对用户的输入进行编码,该方法会编码如下字符:,/?😡&=+$ #
  • CSP: 建立白名单,开发者明确告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截是由浏览器自己实现的。我们可以通过这种方式来尽量减少 XSS 攻击 通常可以通过两种方式来开启 CSP:
    1. 设置 HTTP Header 中的 Content-Security-Policy
    2. 设置 meta 标签的方式
  • 对一些敏感信息进行保护,比如 cookie 使用 http-only,使得脚本无法获取。也可以使用验证码,避免脚本伪装成用户执行一些操作。

2. CSRF 攻击

跨站请求伪造(Cross-site request forgery)

攻击者诱导用户进入一个第三方网站,然后该网站向被攻击网站发送跨站请求。如果用户在被攻击网站中保存了登录状态,那么攻击者就可以利用这个登录状态,绕过后台的用户验证,冒充用户向服务器执行一些操作。

CSRF 攻击的本质是利用 cookie 会在同源请求中携带发送给服务器的特点,以此来实现用户的冒充。

攻击类型:

  • GET 类型的 CSRF 攻击,比如在网站中的一个 img 标签里构建一个请求,当用户打开这个网站的时候就会自动发起提交。
  • POST 类型的 CSRF 攻击,比如构建一个表单,然后隐藏它,当用户进入页面时,自动提交这个表单。
  • 链接类型的 CSRF 攻击,比如在 a 标签的 href 属性里构建一个请求,然后诱导用户去点击。

防御措施:

  • 进行同源检测,服务器根据 http 请求头中 origin 或者 referer 信息来判断请求是否为允许访问的站点,从而对请求进行过滤。当 origin 或者 referer 信息都不存在的时候,直接阻止请求。这种方式的缺点是有些情况下 referer 可以被伪造,同时还会把搜索引擎的链接也给屏蔽了。所以一般网站会允许搜索引擎的页面请求,但是相应的页面请求这种请求方式也可能被攻击者给利用。(Referer 字段会告诉服务器该网页是从哪个页面链接过来的)
  • 使用 CSRF Token 进行验证,服务器向用户返回一个随机数 Token ,当网站再次发起请求时,在请求参数中加入服务器端返回的 token ,然后服务器对这个 token 进行验证。这种方法解决了使用 cookie 单一验证方式时,可能会被冒用的问题,但是这种方法存在一个缺点就是,我们需要给网站中的所有请求都添加上这个 token,操作比较繁琐。还有一个问题是一般不会只有一台网站服务器,如果请求经过负载平衡转移到了其他的服务器,但是这个服务器的 session 中没有保留这个 token 的话,就没有办法验证了。这种情况可以通过改变 token 的构建方式来解决。
  • 对 Cookie 进行双重验证,服务器在用户访问网站页面时,向请求域名注入一个 Cookie,内容为随机字符串,然后当用户再次向服务器发送请求的时候,从 cookie 中取出这个字符串,添加到 URL 参数中,然后服务器通过对 cookie 中的数据和参数中的数据进行比较,来进行验证。使用这种方式是利用了攻击者只能利用 cookie,但是不能访问获取 cookie 的特点。并且这种方法比 CSRF Token 的方法更加方便,并且不涉及到分布式访问的问题。这种方法的缺点是如果网站存在 XSS 漏洞的,那么这种方式会失效。同时这种方式不能做到子域名的隔离。
  • 在设置 cookie 属性的时候设置 Samesite ,限制 cookie 不能作为被第三方使用,从而可以避免被攻击者利用。Samesite 一共有两种模式,一种是严格模式,在严格模式下 cookie 在任何情况下都不可能作为第三方 Cookie 使用,在宽松模式下,cookie 可以被请求是 GET 请求,且会发生页面跳转的请求所使用

3. 中间人攻击

因为我们没有办法确定我们得到的公钥就一定是安全的公钥。可能存在一个中间人,截取 了对方发给我们的公钥,然后将他自己的公钥发送给我们,当我们使用他的公钥加密后发送的信息,就可以被他用自己的私钥 解密。

然后他伪装成我们以同样的方法向对方发送信息,这样我们的信息就被窃取了,然而我们自己还不知道。

中间⼈ (Man-in-the-middle attack, MITM) 是指攻击者与通讯的两端分别创建独⽴的联系, 并交换其所收到的数据, 使通讯的两端认为他们正在通过⼀个私密的连接与对⽅直接对话, 但事实上整个会话都被攻击者完全控制。在中间⼈攻击中,攻击者可以拦截通讯双⽅的通话并插⼊新的内容。

攻击过程如下:

  • 客户端发送请求到服务端,请求被中间⼈截获
  • 服务器向客户端发送公钥
  • 中间⼈截获公钥,保留在⾃⼰⼿上。然后⾃⼰⽣成⼀个伪造的公钥,发给客户端
  • 客户端收到伪造的公钥后,⽣成加密 hash 值发给服务器
  • 中间⼈获得加密 hash 值,⽤⾃⼰的私钥解密获得真秘钥,同时⽣成假的加密 hash 值,发给服务器
  • 服务器⽤私钥解密获得假密钥,然后加密数据传输给客户端

4. 劫持

1)点击劫持

  • 原理:是一种视觉欺骗的攻击手段。攻击者将需要攻击的网站通过 iframe 嵌套的方式嵌入自己的网页中,并将 iframe 设置为透明,在页面中透出一个按钮诱导用户点击。
  • 防御:
    • X-FRAME-OPTIONS 响应头:
      • DENY,表示页面不允许通过 iframe 的方式展示
      • SAMEORIGIN,表示页面可以在相同域名下通过 iframe 的方式展示
      • ALLOW-FROM,表示页面可以在指定来源的 iframe 中展示
    • JS 防御:
      • 通过 self == top 判断

2)网络劫持

  1. DNS 劫持: (输⼊京东被强制跳转到淘宝这就属于 dns 劫持)
    • DNS 强制解析: 通过修改运营商的本地 DNS 记录,来引导⽤户流量到缓存服务器
    • 302 跳转的⽅式: 通过监控⽹络出⼝的流量,分析判断哪些内容是可以进⾏劫持处理的,再对劫持的内存发起 302 跳转的回复,引导⽤户获取内容
  2. HTTP 劫持: (访问⾕歌但是⼀直有贪玩蓝⽉的⼴告),由于 http 明⽂传输,运营商会修改你的 http 响应内容(即加⼴告)

二、进程与线程

1. 进程与线程的概念

从本质上说,进程和线程都是 CPU 工作时间片的一个描述:

  • 进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。
  • 线程是进程中的更小单位,描述了执行一段指令所需的时间。

进程是资源分配的最小单位,线程是 CPU 调度的最小单位。

一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。进程是运行在虚拟内存上的,虚拟内存是用来解决用户对硬件资源的无限需求和有限的硬件资源之间的矛盾的。从操作系统角度来看,虚拟内存即交换文件;从处理器角度看,虚拟内存即虚拟地址空间。

如果程序很多时,内存可能会不够,操作系统为每个进程提供一套独立的虚拟地址空间,从而使得同一块物理内存在不同的进程中可以对应到不同或相同的虚拟地址,变相的增加了程序可以使用的内存。

进程和线程之间的关系有以下四个特点

  1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。
  2. 线程之间共享进程中的数据
  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存,当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。
  4. 进程之间的内容相互隔离。进程隔离就是为了使操作系统中的进程互不干扰,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信的机制了。

Chrome 浏览器的架构图:Chrome架构图

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU 进程:其实, GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
  • 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。(非必需)

然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:

  • 更高的资源占用:因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
  • 更复杂的体系架构:浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。

2. 进程和线程的区别

  • 进程可以看做独立应用,线程不能
  • 资源:进程是 cpu 资源分配的最小单位(是能拥有资源和独立运行的最小单位);线程是 cpu 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)。
  • 通信方面:线程间可以通过直接共享同一进程中的资源,而进程通信需要借助 进程间通信。
  • 调度:进程切换比线程切换的开销要大。线程是 CPU 调度的基本单位,线程的切换不会引起进程切换,但某个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
  • 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存、I/O 等,其开销远大于创建或撤销线程时的开销。同理,在进行进程切换时,涉及当前执行进程 CPU 环境还有各种各样状态的保存及新调度进程状态的设置,而线程切换时只需保存和设置少量寄存器内容,开销较小。

3. JS 单线程模型

单线程模型

是指 JS 脚本只在一个线程上运行。JS 同时只能执行一个任务,其他任务都必须等待。

但是 JS 脚本只在一个线程上执行,不代表 JS 引擎只有一个线程。事实上,JS 引擎有多个线程,是一种主线程运行脚本,其他线程后台配合的模式

原因

JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

4. 浏览器渲染进程的线程有哪些

渲染进程的线程

1)GUI 渲染线程

负责渲染浏览器页面,解析 HTML、CSS,构建 DOM 树、构建 CSSOM 树、构建渲染树和绘制页面;当界面需要重绘或由于某种操作引发回流时,该线程就会执行。

注意:GUI 渲染线程和 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。

2)JS 引擎线程

JS 引擎线程也称为 JS 内核,负责处理 Javascript 脚本程序,解析 Javascript 脚本,运行代码;JS 引擎线程一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页中无论什么时候都只有一个 JS 引擎线程在运行 JS 程序;

注意:GUI 渲染线程与 JS 引擎线程的互斥关系,所以如果 JS 执行的时间过长,会造成页面的渲染不连贯,导致页面渲染加载阻塞。

3)时间触发线程

时间触发线程属于浏览器而不是 JS 引擎,用来控制事件循环;当 JS 引擎执行代码块如 setTimeOut 时(也可是来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件触发线程中;当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理;

注意:由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行);

4)定时器触发进程

定时器触发进程即 setInterval 与 setTimeout 所在线程;浏览器定时计数器并不是由 JS 引擎计数的,因为 JS 引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确性;因此使用单独线程来计时并触发定时器,计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行,所以定时器中的任务在设定的时间点不一定能够准时执行,定时器只是在指定时间点将任务添加到事件队列中;

注意:W3C 在 HTML 标准中规定,定时器的定时时间不能小于 4ms,如果是小于 4ms,则默认为 4ms。

5)异步 http 请求线程

  • XMLHttpRequest 连接后通过浏览器新开一个线程请求;
  • 检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将回调函数放入事件队列中,等待 JS 引擎空闲后执行;

5. 进程通信方式

1)管道

管道是一种最基本的进程间通信机制。管道就是操作系统在内核中开辟的一段缓冲区,进程 1 可以将需要交互的数据拷贝到这段缓冲区,进程 2 就可以读取了。

管道的特点

  • 只能单向通信
  • 只能血缘关系的进程进行通信
  • 依赖于文件系统
  • 生命周期随进程
  • 面向字节流的服务
  • 管道内部提供了同步机制

2)消息队列

消息队列就是一个消息的列表。用户可以在消息队列中添加消息、读取消息等。消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。

使用消息队列进行进程间通信,可能会收到数据块最大长度的限制约束等,这也是这种通信方式的缺点。如果频繁的发生进程间的通信行为,那么进程需要频繁地读取队列中的数据到内存,相当于间接地从一个进程拷贝到另一个进程,这需要花费时间。

3)信号量

共享内存最大的问题就是多进程竞争内存的问题,就像类似于线程安全问题。我们可以使用信号量来解决这个问题。信号量的本质就是一个计数器,用来实现进程之间的互斥与同步。例如信号量的初始值是 1,然后 a 进程来访问内存 1 的时候,我们就把信号量的值设为 0,然后进程 b 也要来访问内存 1 的时候,看到信号量的值为 0 就知道已经有进程在访问内存 1 了,这个时候进程 b 就会访问不了内存 1。所以说,信号量也是进程之间的一种通信方式。

4)信号

信号(Signals )是 Unix 系统中使用的最古老的进程间通信的方法之一。操作系统通过信号来通知进程系统中发生了某种预先规定好的事件(一组事件中的一个),它也是用户进程之间通信和同步的一种原始机制。

5)共享内存

共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问(使多个进程可以访问同一块内存空间)。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

6)套接字

上面我们说的共享内存、管道、信号量、消息队列,他们都是多个进程在一台主机之间的通信,那两个相隔几千里的进程能够进行通信吗?答是必须的,这个时候 Socket 这家伙就派上用场了,例如我们平时通过浏览器发起一个 http 请求,然后服务器给你返回对应的数据,这种就是采用 Socket 的通信方式了。

6. 如何实现浏览器内多个标签页之间的通信

实现多个标签页之间的通信,本质上都是通过中介者模式来实现的。因为标签页之间没有办法直接通信,因此我们可以找一个中介者,让标签页和中介者进行通信,然后让这个中介者来进行消息的转发。通信方法如下:

  • 使用 websocket 协议,因为 websocket 协议可以实现服务器推送,所以服务器就可以用来当做这个中介者。标签页通过向服务器发送数据,然后由服务器向其他标签页推送转发。
  • 使用 ShareWorker 的方式,shareWorker 会在页面存在的生命周期内创建一个唯一的线程,并且开启多个页面也只会使用同一个线程。这个时候共享线程就可以充当中介者的角色。标签页间通过共享一个线程,然后通过这个共享的线程来实现数据的交换。
  • 使用 localStorage 的方式,我们可以在一个标签页对 localStorage 的变化事件进行监听,然后当另一个标签页修改数据的时候,我们就可以通过这个监听事件来获取到数据。这个时候 localStorage 对象就是充当的中介者的角色。
  • 使用 postMessage 方法,如果我们能够获得对应标签页的引用,就可以使用 postMessage 方法,进行通信。

7. 对 Service Worker 的理解

Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker 的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。

Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。以下是这个步骤的实现:

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 注册失败');
    });
}
// 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');
    })
  );
});

三、浏览器缓存

1. 缓存机制

  • 浏览器第一次加载资源,服务器返回 200,浏览器从服务器下载资源文件,并缓存资源文件与 response header,以供下次加载时对比使用;
  • 下一次加载资源时,由于强制缓存优先级较高,先比较当前时间与上一次返回 200 时的时间差,如果没有超过 cache-control 设置的 max-age,则没有过期,并命中强缓存,直接从本地读取资源。如果浏览器不支持 HTTP1.1,则使用 expires 头判断是否过期;
  • 如果资源已过期,则表明强制缓存没有被命中,则开始协商缓存,向服务器发送带有 If-None-Match 和 If-Modified-Since 的请求;
  • 服务器收到请求后,优先根据 Etag 的值判断被请求的文件有没有做修改,Etag 值一致则没有修改,命中协商缓存,返回 304;如果不一致则有改动,直接返回新的资源文件带上新的 Etag 值并返回 200;
  • 如果服务器收到的请求没有 Etag 值,则将 If-Modified-Since 和被请求文件的最后修改时间做比对,一致则命中协商缓存,返回 304;不一致则返回新的 last-modified 和文件并返回 200;

缓存机制

很多网站的资源后面都加了版本号,这样做的目的是:每次升级了 JS 或 CSS 文件后,为了防止浏览器进行缓存,强制改变版本号,客户端浏览器就会重新下载新的 JS 或 CSS 文件 ,以保证用户能够及时获得网站的最新更新。

2. 缓存位置

1)Service Worker(目前该技术通常用来做缓存文件,提高首屏速度):

可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。 本质上充当 Web 应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步 API。

2)Memory Cache(内存缓存):

读取内存中的数据肯定比磁盘快,虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放

3)Disk Cache(硬盘缓存):

读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上,并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据

4)Push Cache(推送缓存):

HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放

以上缓存都没命中就会进行网络请求

3. 缓存策略

缓存策略都是通过设置 HTTP Header (response header)来实现的。

强制缓存优先于协商缓存进行,若强制缓存(Expires 和 Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified / If-Modified-Since 和 ETag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,返回 200,重新返回资源和缓存标识,再存入浏览器缓存中;生效则返回 304,继续使用缓存。

1)强缓存

在缓存期间不需要向服务器询,state code 为 200,实现方式:

  • 设置 Expires,过期时间,是绝对日期,容易因为服务端和客户端时间不一致而出错(基本淘汰)
  • 设置 Cache-Control,HTTP/1.1 新增字段,可以组合使用多种指令,通过 max-age 字段来设置相对过期时间
    • private 仅浏览器可以缓存
    • public 浏览器和代理服务器都可以缓存(对于 private 和 public ,前端可以认为一样,不用深究)
    • max-age=xxx 过期时间(重要)
    • no-cache 不进行强缓存(重要)
    • no-store 不强缓存,也不协商缓存,基本不用,缓存越多才越好呢
    • 注意:规则可以同时多个

2)协商缓存

强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程 需要向服务器询问缓存是否已经过期,如果缓存有效会返回 304,实现方式:

  • Last-Modified,最后修改日期,客户端和服务端通过比较修改日期来决定是否使用缓存(-->If-Modified-Since)
  • ETag,HTTP/1.1 新增字段,表示文件唯一标识,只要文件内容改动,ETag 就会重新计算。客户端和服务端通过比较文件是否修改来决定是否使用缓存(-->If-None-Match)
  • 对比:
    • 精确度: ETag > Last-Modified:如果我们打开文件但并没有修改,Last-Modified 也会改变,并且 Last-Modified 的单位时间为一秒,如果一秒内修改完了文件,那么还是会命中缓存
    • 性能: ETag < Last-Modified
    • 优先级: ETag > Last-Modified 如果什么缓存策略都没有设置,那么浏览器会取响应头中的 Date 减去 Last-Modified 值的 10% 作为缓存时间

3)使用场景

对于大部分的场景都可以使用强缓存配合协商缓存解决,但是在一些特殊的地方可能需要选择特殊的缓存策略:

  1. 对于某些不需要缓存的资源,可以使用 Cache-control: no-store ,表示该资源不需要缓存
  2. 对于频繁变动的资源,可以使用 Cache-Control: no-cache 并配合 ETag 使用,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新。
  3. 对于代码文件来说,通常使用 Cache-Control: max-age=31536000 并配合策略缓存使用,然后对文件进行指纹处理,一旦文件名变动就会立刻下载新的文件。
  4. 用户行为对缓存的影响:F5 会 跳过强缓存规则,直接走协商缓存;Ctrl+F5 ,跳过所有缓存规则,和第一次请求一样,重新获取资源

4. 意义

对于浏览器的缓存,主要针对的是前端的静态资源,最好的效果就是,在发起请求之后,拉取相应的静态资源,并保存在本地。如果服务器的静态资源没有更新,那么在下次请求的时候,就直接从本地读取即可,如果服务器的静态资源已经更新,那么我们再次请求的时候,就到服务器拉取新的资源,并保存在本地。这样就大大的减少了请求的次数,提高了网站的性能。这就要用到浏览器的缓存策略了。

所谓的浏览器缓存指的是浏览器将用户请求过的静态资源,存储到电脑本地磁盘中,当浏览器再次访问时,就可以直接从本地加载,不需要再去服务端请求了。

使用浏览器缓存,有以下优点:

  • 减少了服务器的负担,提高了网站的性能
  • 加快了客户端网页的加载速度
  • 减少了多余网络数据传输

四、浏览器组成

1. 概念

浏览器的主要功能是将用户选择的 web 资源呈现出来,它需要从服务器请求资源,并将其显示在浏览器窗口中,资源的格式通常是 HTML,也包括 PDF、image 及其他格式。用户用 URI(Uniform Resource Identifier 统一资源标识符)来指定所请求资源的位置。

HTML 和 CSS 规范中规定了浏览器解释 html 文档的方式,由 W3C 组织对这些规范进行维护,W3C 是负责制定 web 标准的组织。但是浏览器厂商纷纷开发自己的扩展,对规范的遵循并不完善,这为 web 开发者带来了严重的兼容性问题。

浏览器可以分为两部分,shell 和 内核。其中 shell 的种类相对比较多,内核则比较少。也有一些浏览器并不区分外壳和内核。从 Mozilla 将 Gecko 独立出来后,才有了外壳和内核的明确划分。

  • shell 是指浏览器的外壳:例如菜单,工具栏等。主要是提供给用户界面操作,参数设置等等。它是调用内核来实现各种功能的。
  • 内核是浏览器的核心。内核是基于标记语言显示内容的程序或模块。

2. 浏览器内核

浏览器内核主要分成两部分:

  • 渲染引擎的职责就是渲染,即在浏览器窗口中显示所请求的内容。默认情况下,渲染引擎可以显示 html、xml 文档及图片,它也可以借助插件显示其他类型数据,例如使用 PDF 阅读器插件,可以显示 PDF 格式。
  • JS 引擎:解析和执行 javascript 来实现网页的动态效果。

最开始渲染引擎和 JS 引擎并没有区分的很明确,后来 JS 引擎越来越独立,内核就倾向于只指渲染引擎。

3. 常见内核

  • Trident:这种浏览器内核是 IE 浏览器用的内核,因为在早期 IE 占有大量的市场份额,所以这种内核比较流行,以前有很多网页也是根据这个内核的标准来编写的,但是实际上这个内核对真正的网页标准支持不是很好。但是由于 IE 的高市场占有率,微软也很长时间没有更新 Trident 内核,就导致了 Trident 内核和 W3C 标准脱节。还有就是 Trident 内核的大量 Bug 等安全问题没有得到解决,加上一些专家学者公开自己认为 IE 浏览器不安全的观点,使很多用户开始转向其他浏览器。
  • Gecko:这是 Firefox 和 Flock 所采用的内核,这个内核的优点就是功能强大、丰富,可以支持很多复杂网页效果和浏览器扩展接口,但是代价是也显而易见就是要消耗很多的资源,比如内存。
  • Presto:Opera 曾经采用的就是 Presto 内核,Presto 内核被称为公认的浏览网页速度最快的内核,这得益于它在开发时的天生优势,在处理 JS 脚本等脚本语言时,会比其他的内核快 3 倍左右,缺点就是为了达到很快的速度而丢掉了一部分网页兼容性。
  • Webkit:Webkit 是 Safari 采用的内核,它的优点就是网页浏览速度较快,虽然不及 Presto 但是也胜于 Gecko 和 Trident,缺点是对于网页代码的容错性不高,也就是说对网页代码的兼容性较低,会使一些编写不标准的网页无法正确显示。WebKit 前身是 KDE 小组的 KHTML 引擎,可以说 WebKit 是 KHTML 的一个开源的分支。
  • Blink:谷歌在 Chromium Blog 上发表博客,称将与苹果的开源浏览器核心 Webkit 分道扬镳,在 Chromium 项目中研发 Blink 渲染引擎(即浏览器核心),内置于 Chrome 浏览器之中。其实 Blink 引擎就是 Webkit 的一个分支,就像 webkit 是 KHTML 的分支一样。Blink 引擎现在是谷歌公司与 Opera Software 共同研发,上面提到过的,Opera 弃用了自己的 Presto 内核,加入 Google 阵营,跟随谷歌一起研发 Blink。

4. 常用浏览器内核

  • IE 浏览器内核:Trident 内核,也是俗称的 IE 内核;
  • Chrome 浏览器内核:统称为 Chromium 内核或 Chrome 内核,以前是 Webkit 内核,现在是 Blink 内核;
  • Firefox 浏览器内核:Gecko 内核,俗称 Firefox 内核;
  • Safari 浏览器内核:Webkit 内核;
  • Opera 浏览器内核:最初是自己的 Presto 内核,后来加入谷歌大军,从 Webkit 又到了 Blink 内核;
  • 360 浏览器、猎豹浏览器内核:IE + Chrome 双内核;
  • 搜狗、遨游、QQ 浏览器内核:Trident(兼容模式)+ Webkit(高速模式);
  • 百度浏览器、世界之窗内核:IE 内核;
  • 2345 浏览器内核:好像以前是 IE 内核,现在也是 IE + Chrome 双内核了;
  • UC 浏览器内核:这个众口不一,UC 说是他们自己研发的 U3 内核,但好像还是基于 Webkit 和 Trident ,还有说是基于火狐内核。

5. 浏览器组成

  • ⽤户界⾯ - 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗⼝显示的您请求的⻚⾯外,其他显示的各个部分都属于⽤户界⾯。
  • 浏览器引擎 - 在⽤户界⾯和呈现引擎之间传送指令。
  • 呈现引擎 - 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
  • ⽹络 - ⽤于⽹络调⽤,⽐如 HTTP 请求。其接⼝与平台⽆关,并为所有平台提供底层实现。
  • ⽤户界⾯后端 - ⽤于绘制基本的窗⼝⼩部件,⽐如组合框和窗⼝。其公开了与平台⽆关的通⽤接⼝,⽽在底层使⽤操作系统的⽤户界⾯⽅法。
  • JavaScript 解释器 - ⽤于解析和执⾏ JavaScript 代码。
  • 数据存储 - 这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“⽹络数据库”,这是⼀个完整(但是轻便)的浏览器内数据库。

五、浏览器渲染原理

1. 输入 URL 到页面展示

  • 解析 URL:首先会对 URL 进行解析,分析所需要使用的传输协议和请求的资源的路径。如果输入的 URL 中的协议或者主机名不合法,将会把地址栏中输入的内容传递给搜索引擎。如果没有问题,浏览器会检查 URL 中是否出现了非法字符,如果存在非法字符,则对非法字符进行转义后再进行下一过程。

  • 缓存判断:浏览器会判断所请求的资源是否在缓存里,如果请求的资源在缓存里并且没有失效,那么就直接使用,否则向服务器发起新的请求。

  • DNS 解析:首先需要获取的是输入的 URL 中的域名的 IP 地址,首先会判断本地是否有该域名的 IP 地址的缓存,如果有则使用,如果没有则向本地 DNS 服务器发起请求。本地 DNS 服务器也会先检查是否存在缓存,如果没有就会先向根域名服务器发起请求,获得负责的顶级域名服务器的地址后,再向顶级域名服务器请求,然后获得负责的权威域名服务器的地址后,再向权威域名服务器发起请求,最终获得域名的 IP 地址后,本地 DNS 服务器再将这个 IP 地址返回给请求的用户。用户向本地 DNS 服务器发起请求属于递归请求,本地 DNS 服务器向各级域名服务器发起请求属于迭代请求

  • 获取 MAC 地址:当浏览器得到 IP 地址后,数据传输还需要知道目的主机 MAC 地址,因为应用层下发数据给传输层,TCP 协议会指定源端口号和目的端口号,然后下发给网络层。网络层会将本机地址作为源地址,获取的 IP 地址作为目的地址。然后将下发给数据链路层,数据链路层的发送需要加入通信双方的 MAC 地址,本机的 MAC 地址作为源 MAC 地址,目的 MAC 地址需要分情况处理。通过将 IP 地址与本机的子网掩码相与,可以判断是否与请求主机在同一个子网里,如果在同一个子网里,可以使用 APR 协议获取到目的主机的 MAC 地址,如果不在一个子网里,那么请求应该转发给网关,由它代为转发,此时同样可以通过 ARP 协议来获取网关的 MAC 地址,此时目的主机的 MAC 地址应该为网关的地址。

  • TCP 三次握手:下面是 TCP 建立连接的三次握手的过程,首先客户端向服务器发送一个 SYN 连接请求报文段和一个随机序号,服务端接收到请求后向服务器端发送一个 SYN ACK 报文段,确认连接请求,并且也向客户端发送一个随机序号。客户端接收服务器的确认应答后,进入连接建立的状态,同时向服务器也发送一个 ACK 确认报文段,服务器端接收到确认后,也进入连接建立状态,此时双方的连接就建立起来了

  • HTTPS 握手:如果使用的是 HTTPS 协议,在通信前还存在 TLS 的一个四次握手的过程。首先由客户端向服务器端发送使用的协议的版本号、一个随机数和可以使用的加密方法。服务器端收到后,确认加密的方法,也向客户端发送一个随机数和自己的数字证书。客户端收到后,首先检查数字证书是否有效,如果有效,则再生成一个随机数,并使用证书中的公钥对随机数加密,然后发送给服务器端,并且还会提供一个前面所有内容的 hash 值供服务器端检验。服务器端接收后,使用自己的私钥对数据解密,同时向客户端发送一个前面所有内容的 hash 值供客户端检验。这个时候双方都有了三个随机数,按照之前所约定的加密方法,使用这三个随机数生成一把秘钥,以后双方通信前,就使用这个秘钥对数据进行加密后再传输。

  • 返回数据:当页面请求发送到服务器端后,可能还会先经过负责负载均衡的服务器,它的作用就是将请求合理的分发到多台服务器上,这时假设服务器端会返回一个 html 文件作为响应,浏览器接收到响应后,开始对 html 文件进行解析,开始页面的渲染过程。

  • 页面渲染

    • 首先浏览器会判断状态码是什么,如果是 200 那就继续解析,如果 400 或 500 的话就会报错,如果 300 的话会进行重定向,这里会有个重定向计数器,避免过多次的重定向,超过次数也会报错
    • 浏览器开始解析文件,如果是 gzip 格式的话会先解压一下,然后通过文件的编码格式知道该如何去解码文件
    • 浏览器渲染
  • TCP 四次挥手:最后一步是 TCP 断开连接的四次挥手过程。若客户端认为数据发送完成,则它需要向服务端发送连接释放请求。服务端收到连接释放请求后,会告诉应用层要释放 TCP 链接。然后会发送 ACK 包,并进入 CLOSE_WAIT 状态,此时表明客户端到服务端的连接已经释放,不再接收客户端发的数据了。但是因为 TCP 连接是双向的,所以服务端仍旧可以发送数据给客户端。服务端如果此时还有没发完的数据会继续发送,完毕后会向客户端发送连接释放请求,然后服务端便进入 LAST-ACK 状态。客户端收到释放请求后,向服务端发送确认应答,此时客户端进入 TIME-WAIT 状态。该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有服务端的重发请求的话,就进入 CLOSED 状态。当服务端收到确认应答后,也便进入 CLOSED 状态。

2. 渲染过程

  • 首先解析收到的文档,根据文档定义构建一棵 DOM 树,DOM 树是由 DOM 元素及属性节点组成的。
  • 然后对 CSS 进行解析,生成 CSSOM 规则树。
  • 根据 DOM 树和 CSSOM 规则树构建渲染树。渲染树的节点被称为渲染对象,渲染对象是一个包含有颜色和大小等属性的矩形,渲染对象和 DOM 元素相对应,但这种对应关系不是一对一的,不可见的 DOM 元素不会被插入渲染树。还有一些 DOM 元素对应几个可见对象,它们一般是一些具有复杂结构的元素,无法用一个矩形来描述。
  • 当渲染对象被创建并添加到树中,它们并没有位置和大小,所以当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流)。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。通常这一行为也被称为“自动重排”。
  • 布局阶段结束后是绘制阶段,遍历渲染树并调用渲染对象的 paint 方法,合成图层,将它们的内容显示在屏幕上,绘制使用 UI 基础组件。

渲染过程

2. 渲染优化

1)JS

JavaScript 既会阻塞 HTML 的解析,也会阻塞 CSS 的解析。因此我们可以对 JavaScript 的加载方式进行改变,来进行优化:

  • 尽量将 JavaScript 文件放在 body 的最后

  • body 中间尽量不要写<script>标签

  • <script>标签的引入资源方式有三种,有一种就是我们常用的直接引入,还有两种就是使用 async 属性和 defer 属性来异步引入,两者都是去异步加载外部的 JS 文件,不会阻塞 DOM 的解析(尽量使用异步加载)。

    三者的区别如下:

    • script 立即停止页面渲染去加载资源文件,当资源加载完毕后立即执行 js 代码,js 代码执行完毕后继续渲染页面;
    • async 是在下载完成之后,立即异步加载,加载好后立即执行,多个带 async 属性的标签,不能保证加载的顺序;
    • defer 是在下载完成之后,立即异步加载。加载好后,如果 DOM 树还没构建好,则先等 DOM 树解析好再执行;如果 DOM 树已经准备好,则立即执行。多个带 defer 属性的标签,按照顺序执行。

2)CSS

使用 CSS 有三种方式:使用 link、@import、内联样式,其中 link 和@import 都是导入外部样式。它们之间的区别:

  • link:浏览器会派发一个新的线程(HTTP 线程)去加载资源文件,与此同时 GUI 渲染线程会继续向下渲染代码
  • @import:GUI 渲染线程会暂时停止渲染,去服务器加载资源文件,资源文件没有返回之前不会继续渲染(阻碍浏览器渲染)
  • style:GUI 直接渲染

外部样式如果长时间没有加载完毕,浏览器为了用户体验,会使用浏览器会默认样式,确保首次渲染的速度。所以 CSS 一般写在 header 中,让浏览器尽快发送请求去获取 css 样式。

所以,在开发过程中,导入外部样式使用 link,而不用@import。如果 css 少,尽可能采用内嵌样式,直接写在 style 标签中。

3)DOM & CSSOM

可以通过以下几种方式来减少渲染的时间:

  • HTML 文件的代码层级尽量不要太深
  • 使用语义化的标签,来避免不标准语义化的特殊处理
  • 减少 CSS 代码的层级,因为选择器是从右到左进行解析的

4)减少重绘和回流

3. 回流 reflow

当元素的尺寸或者位置发生了变化,就需要重新计算渲染树

  • 触发:

    • 页面首次渲染
    • 浏览器窗口大小发生改变
    • DOM 元素的几何属性(width/height/padding/margin/border)发生变化
    • DOM 元素内容变化(文字数量或图片大小等等)
    • DOM 元素字体大小变化
    • DOM 元素移动或增加
    • 激活 CSS 伪类(例如::hover)
    • 读写 offset/scroll/client 等属性
    • 调用 getComputedStyle()、getBoundingClientRect()、scrollTo()
  • 减少:

    • 使用 transform 替代 top
    • 使用 class 替代 style,减少 style 的使用
    • 使用 resize、scroll 时进行防抖和节流处理,这两者会直接导致回流
    • 使用 visibility 替换 display: none,因为前者只会引起重绘,后者会引发回流
    • 不要把节点的属性值放在一个循环里当成循环里的变量
    • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
    • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
    • CSS 选择符从右往左匹配查找,避免节点层级过多
    • 批量修改元素时,可以先让元素脱离文档流,等修改完毕后,再放入文档流
    • 避免触发同步布局事件
    • 对于复杂动画效果,使用绝对定位让其脱离文档流

4. 重绘 repaint

DOM 样式发生了变化,但没有影响 DOM 的几何属性和它在文档流中的位置时,会触发重绘,而不会触发回流

重绘由于 DOM 位置信息不需要更新,省去了布局过程,因而性能上优于回流

5. 合成 composite

在 DOM 树中每个节点都会对应一个 LayoutObject,当他们的 LayoutObject 处于相同的坐标空间时,就会形成一个 RenderLayers ,也就是渲染层。RenderLayers 来保证页面元素以正确的顺序合成,这时候就会出现层合成(composite),从而正确处理透明元素和重叠元素的显示

提升合成层的最好方式是使用 CSS 的 will-change 属性。而 will-change 设置为 opacity、transform、top、left、bottom、right 可以将元素提升为合成层。 video、iframe 标签也可以可以生成新图层

6. GPU 加速

  • 数据处理过程:

    • 将每个复合层绘制成一个单独的图像;
    • 准备层数据(尺寸、偏移量、透明度等);
    • 准备动画着色器(如果适用);
    • 将数据发送到 GPU
  • 优点:使用 transform、opacity、filters 等属性时,会直接在 GPU 中完成处理,这些属性的变化不会引起回流重绘

  • 缺点:GPU 渲染字体会导致字体模糊,过多的 GPU 处理会导致内存问题

六、浏览器本地存储

cookie,localStorage,sessionStorage,indexDB

特性cookielocalStoragesessionStorageindexDB
数据生命周期一般由服务器生成,可以设置过期时间除非被清理,否则一直存在页面关闭就清理除非被清理,否则一直存在
数据存储大小4K5M5M无限
与服务端通信每次都会携带在 header 中,对于请求性能影响不参与不参与不参与
  • 从上表可以看到,cookie 已经不建议用于存储。
  • 如果没有大量数据存储需求的话,可以使用 localStorage 和 sessionStorage 。
  • 对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储

对于 cookie,我们还需要注意安全性:

属性作用
value如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识
http-only不能通过 JS 访问 Cookie,减少 XSS 攻击
secure只能在协议为 HTTPS 的请求中携带
same-site规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击

七、浏览器跨域

1. 同源策略

同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到 XSS、CSRF 等攻击。所谓同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个 ip 地址,也非同源。

  • 同源策略限制内容有:

    • Cookie、LocalStorage、IndexedDB 等存储性内容
    • DOM 节点
    • AJAX 无法发送跨域请求
  • 但是有三个标签是允许跨域加载资源:

    • <img src=XXX>
    • <link href=XXX>
    • <script src=XXX>

同源政策的目的主要是为了保证用户的信息安全,它只是对 js 脚本的一种限制,并不是对浏览器的限制,对于一般的 img、或者 script 脚本请求都不会有跨域的限制,这是因为这些操作都不会通过响应结果来进行可能出现安全问题的操作。

2. JSONP

利用 <script> 标签没有跨域限制的漏洞。通过 <script> 标签指向一个需要访问的地址并提供一个回调函数来接收数据

特点:使用简单且兼容性不错,但是只限于 get 请求,且容易遭受 XSS 攻击。

js
function jsonp(url, callback, success) {
  let script = document.createElement('script');
  script.src = url;
  script.async = true;
  script.type = 'text/javascript';
  window[callback] = function (data) {
    success(data);
  };
  document.body.appendChild(script);
}

jsonp('https://api.github.com/users/octocat', 'callback', function (data) {
  console.log(data);
});

3. postMessage + iframe

postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,且是为数不多可以跨域操作的 window 属性之一,它可用于解决以下方面的问题:

  • 页面和其打开的新窗口的数据传递
  • 多窗口之间消息传递
  • 页面与嵌套的 iframe 消息传递
  • 上面三个场景的跨域数据传递

用法:postMessage(data,origin)方法接受两个参数:

  • data: html5 规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用 JSON.stringify()序列化。
  • origin: 协议+主机+端口号,也可以设置为"*",表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为"/"。

这种方式通常用于获取嵌入页面中的第三方页面数据。一个页面发送消息,另一个页面判断来源并接收消息

js
// 主页面
<iframe id='iframe' src='http://www.child.com'></iframe>;

var iframe = document.getElementById('iframe');
iframe.onload = function () {
  var data = {
    name: '***',
  };
  // 向 child 传送跨域数据
  iframe.contentWindow.postMessage(
    JSON.stringify(data),
    'http://www.child.com'
  );
};

// 接受 child 返回数据
window.addEventListener(
  'message',
  function (e) {
    alert('data from child ---> ' + e.data);
  },
  false
);
js
// 子页面
// 接收 parent 的数据
window.addEventListener(
  'message',
  function (e) {
    alert('data from parent ---> ' + e.data);
    var data = JSON.parse(e.data);
    if (data) {
      data.number = 666;
      var newData = JSON.stringify(data);

      // 处理后再发回 parent
      window.parent.postMessage(newData, 'http://www.parent.com');
    }
  },
  false
);

4. document.domain + iframe

WARNING

document.domain 已经废弃,不再推荐使用该特性

该方式只能用于二级域名相同的情况下,比如 a.test.com 和 b.test.com 适用于该方式。

只需要给页面添加 document.domain = 'test.com' 表示二级域名都相同就可以实现跨域

js
// 主页面
<iframe id="iframe" src="http://child.domain.com"></iframe>
<script>
    document.domain = 'domain.com';
    var user = 'admin';
</script>
js
// 子页面
<script>
  document.domain = 'domain.com'; // 获取父窗口中变量 console.log('get js data
  from parent ---> ' + window.parent.user);
</script>

5. CORS

下面是 MDN 对于 CORS 的定义

跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器 让运行在一个 Origin (domain)上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。

CORS 需要浏览器和后端同时支持。IE 8 和 9 需要通过 XDomainRequest 来实现。

浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了 CORS,就实现了跨域。

服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。

虽然设置 CORS 和前端没什么关系,但是通过这种方式解决跨域问题的话,会在发送请求时出现两种情况,分别为简单请求和复杂请求。

1)简单请求

以 Ajax 为例,当满足以下条件时,会触发简单请求

  • 使用下列方法之一:
    • GET
    • HEAD
    • POST
  • HTTP 的头信息不超出以下几种字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type 的值仅限于下列三者之一:
      • text/plain
      • multipart/form-data
      • application/x-www-form-urlencoded
  • 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器; XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

简单请求过程: 对于简单请求,浏览器会直接发出 CORS 请求,它会在请求的头信息中增加一个 Origin 字段,该字段用来说明本次请求来自哪个源(协议+端口+域名),服务器会根据这个值来决定是否同意这次请求。如果 Origin 指定的域名在许可范围之内,服务器返回的响应就会多出以下信息头:

http
Access-Control-Allow-Origin: http://api.***.com;  // 和 Origin 一致
Access-Control-Allow-Credentials: true;           // 表示是否允许发送Cookie
Access-Control-Expose-Headers: ***;               // 指定返回其他字段的值
Content-Type: text/html; charset=utf-8;           // 表示文档类型

如果 Origin 指定的域名不在许可范围之内,服务器会返回一个正常的 HTTP 回应,浏览器发现没有上面的 Access-Control-Allow-Origin 头部信息,就知道出错了。这个错误无法通过状态码识别,因为返回的状态码可能是 200。

2)复杂请求

对于复杂请求来说,首先会在正式通信之前进行一次 HTTP 查询请求,称为预检请求。

浏览器会询问服务器,当前所在的网页是否在服务器允许访问的范围内,以及可以使用哪些 HTTP 请求方式和头信息字段,只有得到肯定的回复,才会进行正式的 HTTP 请求,否则就会报错。

预检请求使用的请求方法是 OPTIONS,表示这个请求是来询问的。他的头信息中的关键字段是 Origin,表示请求来自哪个源。除此之外,头信息中还包括两个字段:

  • Access-Control-Request-Method:该字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法。
  • Access-Control-Request-Headers: 该字段是一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段。

服务器在收到浏览器的预检请求之后,会根据头信息的三个字段来进行判断,如果返回的头信息在中有 Access-Control-Allow-Origin 这个字段就是允许跨域请求,如果没有,就是不同意这个预检请求,就会报错。

服务器回应的 CORS 的字段如下:

http
Access-Control-Allow-Origin: http://api.***.com   // 允许跨域的源地址
Access-Control-Allow-Methods: GET, POST, PUT      // 服务器支持的所有跨域请求的方法
Access-Control-Allow-Headers: X-Custom-Header     // 服务器支持的所有头信息字段
Access-Control-Allow-Credentials: true            // 表示是否允许发送Cookie
Access-Control-Max-Age: 1728000                   // 用来指定本次预检请求的有效期,单位为秒

只要服务器通过了预检请求,在以后每次的 CORS 请求都会自带一个 Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。

在非简单请求中,至少需要设置以下字段

js
'Access-Control-Allow-Origin';
'Access-Control-Allow-Methods';
'Access-Control-Allow-Headers';

减少 OPTIONS 请求次数

OPTIONS 请求次数过多就会损耗页面加载的性能,降低用户体验度。所以尽量要减少 OPTIONS 请求次数,可以后端在请求的返回头部添加:Access-Control-Max-Age:number。它表示预检请求的返回结果可以被缓存多久,单位是秒。该字段只对完全一样的 URL 的缓存设置生效,所以设置了缓存时间,在这个时间范围内,再次发送请求就不需要进行预检请求了。

CORS 中 Cookie 相关问题: 在 CORS 请求中,如果想要传递 Cookie,就要满足以下三个条件:

  • Access-Control-Allow-Credentials 设置为 true

  • Access-Control-Allow-Origin 设置为非 *

  • 在请求中设置 withCredentials

    默认情况下在跨域请求,浏览器是不带 cookie 的。但是我们可以通过设置 withCredentials 来进行传递 cookie.

    js
    // 原生 xml 的设置方式
    var xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    // axios 设置方式
    axios.defaults.withCredentials = true;
    

6. nginx 和 nodejs 中间件代理

实现原理:同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略。 代理服务器,需要做以下几个步骤:

  • 接受客户端请求。
  • 将请求转发给服务器。
  • 拿到服务器响应数据。
  • 将响应转发给客户端。

1)nginx 反向代理:

思路:通过 Nginx 配置一个代理服务器域名与 domain1 相同,端口不同做跳板机,反向代理访问 domain2 接口,并且可以顺便修改 cookie 中 domain 信息,方便当前域 cookie 写入,实现跨域访问。

nginx
#proxy服务器
server {
    listen  81;
    server_name  www.domain1.com;
    location / {
        # 反向代理
        proxy_pass  http://www.domain2.com:8080;
        # 修改cookie里域名
        proxy_cookie_domain  www.domain2.com www.domain1.com;
        index  index.html index.htm;
        # 当用webpack-dev-server等中间件代理接口访问 nginx 时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
        # 当前端只跨域不带cookie时,可为 *
        add_header  Access-Control-Allow-Origin http://www.domain1.com;
        add_header  Access-Control-Allow-Credentials true;
    }
}

2)nodejs 中间件代理

思路:通过启一个代理服务器,实现数据的转发,也可以通过设置 cookieDomainRewrite 参数修改响应头中 cookie 中域名,实现当前域的 cookie 写入,方便接口登录认证。

js
var express = require('express');
var proxy = require('http-proxy-middleware');
var app = express();
app.use(
  '/',
  proxy({
    // 代理跨域目标接口
    target: 'http://www.domain2.com:8080',
    changeOrigin: true,
    // 修改响应头信息,实现跨域并允许带cookie
    onProxyRes: function (proxyRes, req, res) {
      res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
      res.header('Access-Control-Allow-Credentials', 'true');
    },
    // 修改响应信息中的cookie域名
    cookieDomainRewrite: 'www.domain1.com', // 可以为false,表示不修改
  })
);
app.listen(3000);
console.log('Proxy server is listen at port 3000...');

7. WebSocket

WebSocket 是 HTML5 提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于 TCP 传输协议,并复用 HTTP 的握手通道。浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接, 并进行双向数据传输。

WebSocket 的出现就解决了半双工通信的弊端。它最大的特点是:服务器可以向客户端主动推动消息,客户端也可以主动向服务器推送消息

1)原理

客户端向 WebSocket 服务器通知(notify)一个带有所有接收者 ID(recipients IDs)的事件(event),服务器接收后立即通知所有活跃的(active)客户端,只有 ID 在接收者 ID 序列中的客户端才会处理这个事件。

2)特点

  • 支持双向通信,实时性更强
  • 可以发送文本,也可以发送二进制数据
  • 建立在 TCP 协议之上,服务端的实现比较容易
  • 数据格式比较轻量,性能开销小,通信高效
  • 没有同源限制,客户端可以与任意服务器通信
  • 协议标识符是 ws(如果加密,则为 wss),服务器网址就是 URL
  • 与 HTTP 协议有着良好的兼容性。默认端口也是 80 和 443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

8. 正向代理和反向代理

  • 正向代理

客户端想获得一个服务器的数据,但是因为种种原因无法直接获取。于是客户端设置了一个代理服务器,并且指定目标服务器,之后代理服务器向目标服务器转交请求并将获得的内容发送给客户端。这样本质上起到了对真实服务器隐藏真实客户端的目的。实现正向代理需要修改客户端,比如修改浏览器配置。

  • 反向代理

服务器为了能够将工作负载分不到多个服务器来提高网站性能 (负载均衡)等目的,当其受到请求后,会首先根据转发规则来确定请求应该被转发到哪个服务器上,然后将请求转发到对应的真实服务器上。这样本质上起到了对客户端隐藏真实服务器的作用。

一般使用反向代理后,需要通过修改 DNS 让域名解析到代理服务器 IP,这时浏览器无法察觉到真正服务器的存在,当然也就不需要修改配置了。

正向代理和反向代理的结构是一样的,都是 client-proxy-server 的结构,它们主要的区别就在于中间这个 proxy 是哪一方设置的。在正向代理中,proxy 是 client 设置的,用来隐藏 client;而在反向代理中,proxy 是 server 设置的,用来隐藏 server。

9. Nginx

Nginx 是一款轻量级的 Web 服务器,也可以用于反向代理、负载平衡和 HTTP 缓存等。Nginx 使用异步事件驱动的方法来处理请求,是一款面向性能设计的 HTTP 服务器。

传统的 Web 服务器如 Apache 是 process-based(基于过程) 模型的,而 Nginx 是基于 event-driven(事件驱动) 模型的。正是这个主要的区别带给了 Nginx 在性能上的优势。

Nginx 架构的最顶层是一个 master process(主进程),这个 master process 用于产生其他的 worker process(工作进程),这一点和 Apache 非常像,但是 Nginx 的 worker process 可以同时处理大量的 HTTP 请求,而每个 Apache process 只能处理一个。

八、浏览器事件

1. 事件 & 事件模型

事件是用户操作网页时发生的交互动作,比如 click/move, 事件除了用户触发的动作外,还可以是文档加载,窗口滚动和大小调整。事件被封装成一个 event 对象,包含了该事件发生时的所有相关信息( event 的属性)以及可以对事件进行的操作( event 的方法)。

事件是用户操作网页时发生的交互动作或者网页本身的一些操作,现代浏览器一共有三种事件模型:

  • 原始(DOM 0 级)事件模型,这种模型不会传播,所以没有事件流的概念,但是现在有的浏览器支持以冒泡的方式实现,它可以在网页中直接定义监听函数,也可以通过 js 属性来指定监听函数。所有浏览器都兼容这种方式。直接在 dom 对象上注册事件名称,就是 DOM0 写法。
  • 标准(DOM 2 级)事件模型,在该事件模型中,一次事件共有三个过程。这种事件模型,事件绑定的函数是 addEventListener,其中第三个参数可以指定事件是否在捕获阶段执行。
    • 事件捕获阶段:事件从 document 一直向下传播到目标元素,依次检查经过的节点是否绑定了事件监听函数,如果有则执行
    • 事件处理阶段:事件到达目标元素,触发目标元素的监听函数
    • 事件冒泡阶段:事件从目标元素冒泡到 document,依次检查经过的节点是否绑定了事件监听函数,如果有则执行
  • IE 事件模型,在该事件模型中,一次事件共有两个过程,事件处理阶段和事件冒泡阶段。这种模型通过 attachEvent 来添加监听函数,可以添加多个监听函数,会按顺序依次执行。(基本不用)

2. 事件触发

事件触发有三个阶段:

  1. window 往事件触发处传播,遇到注册的捕获事件会触发
  2. 传播到事件触发处时触发注册的事件
  3. 从事件触发处往 window 传播,遇到注册的冒泡事件会触发

捕获的流程为:window -> document -> html -> body -> ... -> 目标元素。

冒泡的流程为:目标元素 -> ... -> body -> html -> document -> window。

3. 事件委托

1)概念

本质上是利用了浏览器事件冒泡的机制。因为事件在冒泡过程中会上传到父节点,并且父节点可以通过事件对象获取到 目标节点,因此可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件,这种方式称为事件委托(代理)

2)特点

  • 减少内存消耗:不必要为每一个子元素都绑定一个监听事件,把这个事件绑定到他的父层,然后在执行事件时再去匹配判断目标元素
  • 动态绑定事件:新增一个子节点,我们并不需要单独地为它添加一个监听事件,它所发生的事件会交给父元素中的监听函数来处理

3)局限性

比如 focus、blur 之类的事件没有事件冒泡机制,所以无法实现事件委托;mousemove、mouseout 这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的。

事件委托会影响页面性能,主要影响因素有:

  • 元素中,绑定事件委托的次数;
  • 点击的最底层元素,到绑定事件元素之间的 DOM 层数;

4)注意事项

  • 只在必须的地方,使用事件委托,比如:ajax 的局部刷新区域
  • 尽量的减少绑定的层级,不在 body 元素上,进行绑定
  • 减少绑定的次数,如果可以,那么把多个事件的绑定,合并到一次事件委托中去,由这个事件委托的回调,来进行分发。

4. 事件循环

事件循环

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)

  1. 整体的 script(作为第一个宏任务)开始执行的时候,会把所有代码分为两部分:“同步任务”、“异步任务”;
  2. 同步任务会直接进入主线程依次执行;
  3. 异步任务会再分为宏任务和微任务;
  4. 宏任务进入到 Event Table 中,并在里面注册回调函数,每当指定的事件完成时,Event Table 会将这个函数移到 Event Queue 中;
  5. 微任务也会进入到另一个 Event Table 中,并在里面注册回调函数,每当指定的事件完成时,Event Table 会将这个函数移到 Event Queue 中;
  6. 当主线程内的任务执行完毕,主线程为空时,会检查微任务的 Event Queue,如果有任务,就全部执行,如果没有就执行下一个宏任务;
  7. 上述过程会不断重复,这就是 Event Loop 事件循环;

5. 宏任务、微任务

这里需要注意的是 new Promise 是会进入到主线程中立刻执行,而 promise.then 则属于微任务

  • 宏任务(macro-task),ES6 规范称为 task:
    • 整体代码 script
    • setTimeOut
    • setInterval
    • setImmediate 一类的定时事件
    • I/O 操作
    • UI 渲染
    • event listener
  • 微任务(micro-task),ES6 规范称为 jobs:
    • promise.then
    • process.nextTick(node)
    • Observer.observe
    • 对 Dom 变化监听的 MutationObserver

6. 执行栈

执行栈

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则

执行栈

当开始执行 JS 代码时,根据先进后出的原则,后执行的函数会先弹出栈,可以看到,foo函数后执行,当执行完毕后就从栈中弹出了。

平时在开发中,可以在报错中找到执行栈的痕迹:

js
function foo() {
  throw new Error('error')
}
function bar() {
  foo()
}
bar()

// 控制台报错
❌ Uncaught Error: error   VM60961:2  
    at foo (<anonymous>:2:9)          
    at bar (<anonymous>:5:3)          
    at <anonymous>:7:1                

可以看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。当使用递归时,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题

js
function bar() {
  bar()
}
bar()

// 控制台报错
❌ Uncaught RangeError: Maximum call stack size exceeded VM70054:2 
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       
    at bar (<anonymous>:2:3)                                       

7. Node 中的 Event Loop

1)浏览器

在浏览器里,每当一个被监听的事件发生时,事件监听器绑定的相关任务就会被添加进回调队列。通过事件产生的任务是异步任务,常见的事件任务包括:

  • 用户交互事件产生的事件任务,比如输入操作;
  • 计时器产生的事件任务,比如 setTimeout;
  • 异步请求产生的事件任务,比如 HTTP 请求。

主线程运行的时候,会产生堆(heap)和栈(stack),其中堆为内存、栈为函数调用栈。我们能看到,Event Loop 负责执行代码、收集和处理事件以及执行队列中的子任务,具体包括以下过程:

  • JavaScript 有一个主线程和调用栈,所有的任务最终都会被放到调用栈等待主线程执行。
  • 同步任务会被放在调用栈中,按照顺序等待主线程依次执行。
  • 主线程之外存在一个回调队列,回调队列中的异步任务最终会在主线程中以调用栈的方式运行。
  • 同步任务都在主线程上执行,栈中代码在执行的时候会调用浏览器的 API,此时会产生一些异步任务。
  • 异步任务会在有了结果(比如被监听的事件发生时)后,将异步任务以及关联的回调函数放入回调队列中。
  • 调用栈中任务执行完毕后,此时主线程处于空闲状态,会从回调队列中获取任务进行处理。
  • 上述过程会不断重复,这就是 JavaScript 的运行机制,称为事件循环机制(Event Loop)。

2)NodeJs

NodeJs中的事件循环

  • Timers(计时器阶段):初次进入事件循环,会从计时器阶段开始。此阶段会判断是否存在过期的计时器回调(包含 setTimeout 和 setInterval),如果存在则会执行所有过期的计时器回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Pending callbacks 阶段。
  • Pending callbacks:执行推迟到下一个循环迭代的 I/O 回调(系统调用相关的回调)。
  • Idle/Prepare:仅供内部使用。
  • Poll(轮询阶段)
    • 当回调队列不为空时:会执行回调,若回调中触发了相应的微任务,这里的微任务执行时机和其他地方有所不同,不会等到所有回调执行完毕后才执行,而是针对每一个回调执行完毕后,就执行相应微任务。执行完所有的回调后,变为下面的情况。
    • 当回调队列为空时(没有回调或所有回调执行完毕):但如果存在有计时器(setTimeout、setInterval 和 setImmediate)没有执行,会结束轮询阶段,进入 Check 阶段。否则会阻塞并等待任何正在执行的 I/O 操作完成,并马上执行相应的回调,直到所有回调执行完毕。
  • Check(查询阶段):会检查是否存在 setImmediate 相关的回调,如果存在则执行所有回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Close callbacks 阶段。
  • Close callbacks:执行一些关闭回调,比如 socket.on('close', ...)等。

区别

  • 浏览器环境下,microTask 的任务队列是每个 macroTask 执行完之后执行。
  • 而在 Node.js 中,microTask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microTask 队列的任务。 如果是 node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval 和 setImmediate)就立刻执行微任务队列,这就跟浏览器端运行一致。
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);

// 浏览器环境:
(timer1) => (promise1) => (timer2) => promise2;

// node V11之后
(timer1) => (promise1) => (timer2) => promise2;

// node 10及其之前
(timer1) => (promise1) => (timer2) =>
  promise2(如果是第二个定时器还未在完成队列中);
(timer1) => (timer2) => (promise1) =>
  promise2(如果是第二个定时器已经在完成队列中);

九、新 API

1. MutationObserver

MutationObserver 是一个可以监听 DOM 结构变化的接口。当 DOM 对象树发生任何变动时,MutationObserver 会得到通知。

MutationObserver 是一个构造器,接受一个 callback 参数,用来处理节点变化的回调函数,返回两个参数:

  • mutations:节点变化记录列表(sequence <MutationRecord>
  • observer:构造 MutationObserver 对象。

MutationObserver 对象有三个方法,分别如下:

  • observe:设置观察目标,接受两个参数,target:观察目标,options:通过对象成员来设置观察选项
  • disconnect:阻止观察者观察任何改变
  • takeRecords:清空记录队列并返回里面的内容
js
//选择一个需要观察的节点
var targetNode = document.getElementById('root');
// 设置observer的配置选项
var config = { attributes: true, childList: true, subtree: true };
// 当节点发生变化时的需要执行的函数
var callback = function (mutationsList, observer) {
  for (var mutation of mutationsList) {
    if (mutation.type == 'childList') {
      console.log('A child node has been added or removed.');
    } else if (mutation.type == 'attributes') {
      console.log('The ' + mutation.attributeName + ' attribute was modified.');
    }
  }
};
// 创建一个observer示例与回调函数相关联
var observer = new MutationObserver(callback);
//使用配置文件对目标节点进行观测
observer.observe(targetNode, config);
// 停止观测
observer.disconnect();

observe 方法中 options 参数有已下几个选项:

  • childList:设置 true,表示观察目标子节点的变化,比如添加或者删除目标子节点,不包括修改子节点以及子节点后代的变化
  • attributes:设置 true,表示观察目标属性的改变
  • characterData:设置 true,表示观察目标数据的改变
  • subtree:设置为 true,目标以及目标的后代改变都会观察
  • attributeOldValue:如果属性为 true 或者省略,则相当于设置为 true,表示需要记录改变前的目标属性值,设置了 attributeOldValue 可以省略 attributes 设置
  • characterDataOldValue:如果 characterData 为 true 或省略,则相当于设置为 true,表示需要记录改变之前的目标数据,设置了 characterDataOldValue 可以省略 characterData 设置
  • attributeFilter:如果不是所有的属性改变都需要被观察,并且 attributes 设置为 true 或者被忽略,那么设置一个需要观察的属性本地名称(不需要命名空间)的列表

MutationObserver 有以下特点:

  • 它等待所有脚本任务完成后才会运行,即采用异步方式
  • 它把 DOM 变动记录封装成一个数组进行处理,而不是一条条地个别处理 DOM 变动。
  • 它即可以观察发生在 DOM 节点的所有变动,也可以观察某一类变动

当 DOM 发生变动会触发 MutationObserver 事件。但是,它与事件有一个本质不同:事件是同步触发,也就是说 DOM 发生变动立刻会触发相应的事件;MutationObserver 则是异步触发,DOM 发生变动以后,并不会马上触发,而是要等到当前所有 DOM 操作都结束后才触发。

举例来说,如果在文档中连续插入 1000 个段落(p 元素),会连续触发 1000 个插入事件,执行每个事件的回调函数,这很可能造成浏览器的卡顿;而 MutationObserver 完全不同,只在 1000 个段落都插入结束后才会触发,而且只触发一次,这样较少了 DOM 的频繁变动,大大有利于性能。

2. IntersectionObserver

网页开发时,常常需要了解某个元素是否进入了"视口"(viewport),即用户能不能看到它。

传统的实现方法是,监听到 scroll 事件后,调用目标元素的 getBoundingClientRect()方法,得到它对应于视口左上角的坐标,再判断是否在视口之内。这种方法的缺点是,由于 scroll 事件密集发生,计算量很大,容易造成性能问题。

目前有一个新的 IntersectionObserver API,可以自动"观察"元素是否可见,Chrome 51+ 已经支持。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉观察器"。

IntersectionObserver 是浏览器原生提供的构造函数,接受两个参数:callback 是可见性变化时的回调函数,option 是配置对象(该参数可选)。

js
var io = new IntersectionObserver(callback, option);
// 开始观察
io.observe(document.getElementById('example'));
// 停止观察
io.unobserve(element);
// 关闭观察器
io.disconnect();

目标元素的可见性变化时,就会调用观察器的回调函数 callback。callback 一般会触发两次。一次是目标元素刚刚进入视口(开始可见),另一次是完全离开视口(开始不可见)。

js
var io = new IntersectionObserver((entries) => {
  console.log(entries);
});

callback 函数的参数(entries)是一个数组,每个成员都是一个 IntersectionObserverEntry 对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,entries 数组就会有两个成员。

  • time:可见性发生变化的时间,是一个高精度时间戳,单位为毫秒
  • target:被观察的目标元素,是一个 DOM 节点对象
  • isIntersecting: 目标是否可见
  • rootBounds:根元素的矩形区域的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回 null
  • boundingClientRect:目标元素的矩形区域的信息
  • intersectionRect:目标元素与视口(或根元素)的交叉区域的信息
  • intersectionRatio:目标元素的可见比例,即 intersectionRect 占 boundingClientRect 的比例,完全可见时为 1,完全不可见时小于等于 0

相比于 getBoundingClientRect,它的优点是不会引起重绘回流。

3. getComputedStyle

DOM2 Style 在 document.defaultView 上增加了 getComputedStyle()方法,该方法返回一个 CSSStyleDeclaration 对象(与 style 属性的类型一样),包含元素的计算样式。

js
document.defaultView.getComputedStyle(element[,pseudo-element])
// or
window.getComputedStyle(element[,pseudo-element])

这个方法接收两个参数:要取得计算样式的元素和伪元素字符串(如":after")。如果不需要查询伪元素,则第二个参数可以传 null。

  • Polyfill:
js
function getStyleByAttr(obj, name) {
  return window.getComputedStyle
    ? window.getComputedStyle(obj, null)[name]
    : obj.currentStyle[name];
}
  • 和 style 的异同
    • getComputedStyle 和 element.style 的相同点就是二者返回的都是 CSSStyleDeclaration 对象。
    • element.style 读取的只是元素的内联样式,即写在元素的 style 属性上的样式;而 getComputedStyle 读取的样式是最终样式,包括了内联样式、嵌入样式和外部样式。
    • element.style 既支持读也支持写,我们通过 element.style 即可改写元素的样式。而 getComputedStyle 仅支持读并不支持写入。我们可以通过使用 getComputedStyle 读取样式,通过 element.style 修改样式

4. getBoundingClientRect

getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。

js
let DOMRect = object.getBoundingClientRect();

它的返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合,就是该元素的 CSS 边框大小。返回的结果是包含完整元素的最小矩形,并且拥有 left, top, right, bottom, x, y, width, 和 height 这几个以像素为单位的只读属性用于描述整个边框。除了 width 和 height 以外的属性是相对于视图窗口的左上角来计算的。

💪