know

浏览器考点

# 从输入 URL 到返回请求的过程

  1. 输入 URL 用户在浏览器地址栏输入网址(如 https://www.baidu.com/)。

  2. 查找强缓存 浏览器首先检查本地是否有可用的强缓存(如 Cache-Control、Expires),命中则直接使用缓存资源,否则进入下一步。

  3. DNS 解析 将域名解析为对应的 IP 地址。浏览器会优先查找本地 DNS 缓存,未命中则向操作系统或远程 DNS 服务器请求解析。

  4. 建立 TCP 连接 浏览器与目标服务器通过三次握手建立 TCP 连接,确保双方可以可靠通信。

  5. 发送 HTTP 请求 浏览器构建请求行、请求头(如 User-Agent、Cookie、Cache-Control 等),并发送到服务器。若为 POST 方法,还会携带请求体。

  6. 服务器处理请求 服务器接收请求,进行相应处理(如路由分发、数据查询等),并准备响应内容。

  7. 服务器返回 HTTP 响应 包含响应行(如 HTTP/1.1 200 OK)、响应头(如 Content-Type、Set-Cookie 等)、响应体(如 HTML、JSON、图片等)。

  8. 浏览器接收响应 浏览器收到响应后,根据响应头判断是否需要缓存、是否重定向等,并将内容交给后续的解析和渲染流程。

  9. TCP 连接关闭或复用 若响应头中包含 Connection: keep-alive,则 TCP 连接保持,后续请求可复用;否则连接关闭。

流程图示意: 输入 URL → 查缓存 → DNS 解析 → TCP 连接 → 发送请求 → 服务器处理 → 返回响应 → 浏览器接收 → 连接关闭/复用

# 为什么 HTTP(底层 TCP)要三次握手和四次挥手?

# 三次握手(建立连接)

三次握手用于建立可靠的 TCP 连接,确保客户端和服务器双方都具备发送和接收数据的能力。

  1. 第一次握手:客户端发送 SYN 包(请求建立连接)到服务器,进入 SYN_SEND 状态。
  2. 第二次握手:服务器收到 SYN 包,回复 SYN+ACK 包(同意连接并确认收到),进入 SYN_RECV 状态。
  3. 第三次握手:客户端收到 SYN+ACK 包,回复 ACK 包(确认连接建立),进入 ESTABLISHED 状态,服务器也进入 ESTABLISHED 状态。

目的:防止已失效的连接请求突然重新传到服务器,造成错误。三次握手能确保双方都能正常收发数据。

# 四次挥手(断开连接)

四次挥手用于可靠地断开 TCP 连接,确保双方数据都已传输完毕。

  1. 第一次挥手:客户端发送 FIN 包(请求断开连接),进入 FIN_WAIT_1 状态。
  2. 第二次挥手:服务器收到 FIN 包,回复 ACK 包(确认断开),进入 CLOSE_WAIT 状态,客户端进入 FIN_WAIT_2 状态。
  3. 第三次挥手:服务器准备好后,发送 FIN 包(请求断开),进入 LAST_ACK 状态。
  4. 第四次挥手:客户端收到 FIN 包,回复 ACK 包,进入 TIME_WAIT 状态,服务器关闭连接。

目的:确保双方都能完全关闭连接,防止数据丢失。四次挥手是因为 TCP 是全双工通信,双方都要单独关闭自己的发送通道。


总结: 三次握手保证连接可靠建立,四次挥手保证连接可靠关闭,防止数据丢失或异常。

1:11 道浏览器原理面试题

2:浏览器灵魂之问(源码已经下载)

3:18 道浏览器知识

4: 浏览器工作原理(了解就好)

5:面试分类集

6:设计模式与 http(高级需要掌握)

7: 5 种设计模式

8:强缓存和协商缓存 网址 (opens new window)

我来帮您获取这篇文章的内容并转换为 Markdown 格式。

# 以管理员权限打开 PowerShell
Invoke-WebRequest -Uri "https://juejin.cn/post/6844904021308735502?utm_source=gold_browser_extension" -UseBasicParsing | Select-Object -ExpandProperty Content | Select-String -Pattern '<title>.*</title>' | Select-Object -First 1

#https://juejin.cn/post/6844904021308735502?utm_source=gold_browser_extension   这个是要抓取的网页的地址

基于获取到的文章内容,我来为您整理成 Markdown 格式:

# 浏览器灵魂之问,请问你能接得住几个?

作为一个合格的前端工程师,浏览器相关的工作原理是我们进行性能优化的基石,我之前也强调过知识体系的重要性,这部分原理性的内容就是知识体系中的重要部分,必须牢牢掌握才能面对瞬息万变的实际场景,针对性地给出实际方案,而不是背诵各种开发军规和性能优化的条例,这样很难发现真正的问题所在, 更无法真正地解决问题。

内容会涵盖浏览器工作原理浏览器安全性能监控和分析。文章会分上下两次来发,今天这一篇是整个系列的上篇。

# 第 1 篇: 能不能说一说浏览器缓存?

缓存是性能优化中非常重要的一环,浏览器的缓存机制对开发也是非常重要的知识点。接下来以三个部分来把浏览器的缓存机制说清楚:

  • 强缓存
  • 协商缓存
  • 缓存位置

# 强缓存

浏览器中的缓存作用分为两种情况,一种是需要发送HTTP请求,一种是不需要发送。

首先是检查强缓存,这个阶段不需要发送 HTTP 请求。

如何来检查呢?通过相应的字段来进行,但是说起这个字段就有点门道了。

HTTP/1.0HTTP/1.1当中,这个字段是不一样的。在早期,也就是HTTP/1.0时期,使用的是Expires,而HTTP/1.1使用的是Cache-Control。让我们首先来看看 Expires。

# Expires

Expires即过期时间,存在于服务端返回的响应头中,告诉浏览器在这个过期时间之前可以直接从缓存里面获取数据,无需再次请求。比如下面这样:

Expires: Wed, 22 Nov 2019 08:41:00 GMT

表示资源在2019年11月22号8点41分过期,过期了就得向服务端发请求。

这个方式看上去没什么问题,合情合理,但其实潜藏了一个坑,那就是服务器的时间和浏览器的时间可能并不一致,那服务器返回的这个过期时间可能就是不准确的。因此这种方式很快在后来的 HTTP1.1 版本中被抛弃了。

# Cache-Control

在 HTTP1.1 中,采用了一个非常关键的字段:Cache-Control。这个字段也是存在于

它和Expires本质的不同在于它并没有采用具体的过期时间点这个方式,而是采用过期时长来控制缓存,对应的字段是max-age。比如这个例子:

Cache-Control:max-age=3600

代表这个响应返回后在 3600 秒,也就是一个小时之内可以直接使用缓存。

如果你觉得它只有max-age一个属性的话,那就大错特错了。

它其实可以组合非常多的指令,完成更多场景的缓存判断, 将一些关键的属性列举如下: public: 客户端和代理服务器都可以缓存。因为一个请求可能要经过不同的代理服务器最后才到达目标服务器,那么结果就是不仅仅浏览器可以缓存数据,中间的任何代理节点都可以进行缓存。

private: 这种情况就是只有浏览器能缓存了,中间的代理服务器不能缓存。

no-cache: 跳过当前的强缓存,发送 HTTP 请求,即直接进入协商缓存阶段

no-store:非常粗暴,不进行任何形式的缓存。

s-maxage:这和max-age长得比较像,但是区别在于 s-maxage 是针对代理服务器的缓存时间。

值得注意的是,当ExpiresCache-Control同时存在的时候,Cache-Control会优先考虑。

当然,还存在一种情况,当资源缓存时间超时了,也就是强缓存失效了,接下来怎么办?没错,这样就进入到第二级屏障——协商缓存了。

# 协商缓存

强缓存失效之后,浏览器在请求头中携带相应的缓存tag来向服务器发请求,由服务器根据这个 tag,来决定是否使用缓存,这就是协商缓存

具体来说,这样的缓存 tag 分为两种: Last-ModifiedETag。这两者各有优劣,并不存在谁对谁有绝对的优势,跟上面强缓存的两个 tag 不一样。

# Last-Modified

即最后修改时间。在浏览器第一次给服务器发送请求后,服务器会在响应头中加上这个字段。

浏览器接收到后,如果再次请求,会在请求头中携带If-Modified-Since字段,这个字段的值也就是服务器传来的最后修改时间。

服务器拿到请求头中的If-Modified-Since的字段后,其实会和这个服务器中该资源的最后修改时间对比:

  • 如果请求头中的这个值小于最后修改时间,说明是时候更新了。返回新的资源,跟常规的 HTTP 请求响应的流程一样。
  • 否则返回 304,告诉浏览器直接用缓存。

# ETag

ETag 是服务器根据当前文件的内容,给文件生成的唯一标识,只要里面的内容有改动,这个值就会变。服务器通过响应头把这个值给浏览器。

浏览器接收到ETag的值,会在下次请求时,将这个值作为If-None-Match这个字段的内容,并放到请求头中,然后发给服务器。

服务器接收到If-None-Match后,会跟服务器上该资源的ETag进行比对:

  • 如果两者不一样,说明要更新了。返回新的资源,跟常规的 HTTP 请求响应的流程一样。
  • 否则返回 304,告诉浏览器直接用缓存。

# 两者对比

  1. 精准度上,ETag优于Last-Modified。优于 ETag 是按照内容给资源上标识,因此能准确感知资源的变化。而 Last-Modified 就不一样了,它在一些特殊的情况并不能准确感知资源变化,主要有两种情况:

    • 编辑了资源文件,但是文件内容并没有更改,这样也会造成缓存失效。
    • Last-Modified 能够感知的单位时间是秒,如果文件在 1 秒内改变了多次,那么这时候的 Last-Modified 并没有体现出修改了。
  2. 在性能上,Last-Modified优于ETag,也很简单理解,Last-Modified仅仅只是记录一个时间点,而 Etag需要根据文件的具体内容生成哈希值。

另外,如果两种方式都支持的话,服务器会优先考虑ETag

# 缓存位置

前面我们已经提到,当强缓存命中或者协商缓存中服务器返回 304 的时候,我们直接从缓存中获取资源。那这些资源究竟缓存在什么位置呢?

浏览器中的缓存位置一共有四种,按优先级从高到低排列分别是:

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

# Service Worker

Service Worker 借鉴了 Web Worker 的 思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问DOM。虽然如此,但它仍然能帮助我们完成很多有用的功能,比如离线缓存消息推送网络代理等功能。其中的离线缓存就是 Service Worker Cache

Service Worker 同时也是 PWA 的重要实现机制,关于它的细节和特性,我们将会在后面的 PWA 的分享中详细介绍。

# Memory Cache 和 Disk Cache

Memory Cache指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

Disk Cache就是存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长。稍微有些计算机基础的应该很好理解,就不展开了。

好,现在问题来了,既然两者各有优劣,那浏览器如何决定将资源放进内存还是硬盘呢?主要策略如下:

  • 比较大的 JS、CSS 文件会直接被丢进磁盘,反之丢进内存
  • 内存使用率比较高的时候,文件优先进入磁盘

# Push Cache

即推送缓存,这是浏览器缓存的最后一道防线。它是 HTTP/2 中的内容,虽然现在应用的并不广泛,但随着 HTTP/2 的推广,它的应用越来越广泛。关于 Push Cache,有非常多的内容可以挖掘,不过这已经不是本文的重点,大家可以参考这篇扩展文章 (opens new window)

# 总结

对浏览器的缓存机制来做个简要的总结:

首先通过 Cache-Control 验证强缓存是否可用

  • 如果强缓存可用,直接使用
  • 否则进入协商缓存,即发送 HTTP 请求,服务器通过请求头中的If-Modified-Since或者If-None-Match字段检查资源是否更新
    • 若资源更新,返回资源和 200 状态码
    • 否则,返回 304,告诉浏览器直接从缓存获取资源

# 第 2 篇: 能不能说一说浏览器的本地存储?各自优劣如何?

浏览器的本地存储主要分为CookieWebStorageIndexedDB, 其中WebStorage又可以分为localStoragesessionStorage。接下来我们就来一一分析这些本地存储方案。

Cookie 最开始被设计出来其实并不是来做本地存储的,而是为了弥补HTTP状态管理上的不足

HTTP 协议是一个无状态协议,客户端向服务器发请求,服务器返回响应,故事就这样结束了,但是下次发请求如何让服务端知道客户端是谁呢?

这种背景下,就产生了 Cookie.

Cookie 本质上就是浏览器里面存储的一个很小的文本文件,内部以键值对的方式来存储(在 chrome 开发者面板的Application这一栏可以看到)。向同一个域名下发送请求,都会携带相同的 Cookie,服务器拿到 Cookie 进行解析,便能拿到客户端的状态。

Cookie 的作用很好理解,就是用来做状态存储的,但它也是有诸多致命的缺陷的:

  1. 容量缺陷。Cookie 的体积上限只有4KB,只能用来存储少量的信息。

  2. 性能缺陷。Cookie 紧跟域名,不管域名下面的某一个地址需不需要这个 Cookie ,请求都会携带上完整的 Cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容。

  3. 安全缺陷。由于 Cookie 以纯文本的形式在浏览器和服务器中传递,很容易被非法用户截获,然后进行一系列的篡改,在 Cookie 的有效期内重新发送给服务器,这是相当危险的。另外,在HttpOnly为 false 的情况下,Cookie 信息能直接通过 JS 脚本来读取。

# localStorage

localStorage有一点跟Cookie一样,就是针对一个域名,即在同一个域名下,会存储相同的一段localStorage

不过它相对Cookie还是有相当多的区别的:

  1. 容量。localStorage 的容量上限为5M,相比于Cookie的 4K 大大增加。当然这个 5M 是针对一个域名的,因此对于一个域名是持久存储的。

  2. 只存在客户端,默认不参与与服务端的通信。这样就很好地避免了 Cookie 带来的性能问题安全问题

  3. 接口封装。通过localStorage暴露在全局,并通过它的 setItemgetItem等方法进行操作,非常方便。

# 操作方式

接下来我们来具体看看如何来操作localStorage

let obj = { name: 'sanyuan', age: 18 }
localStorage.setItem('name', 'sanyuan')
localStorage.setItem('info', JSON.stringify(obj))

接着进入相同的域名时就能拿到相应的值:

let name = localStorage.getItem('name')
let info = JSON.parse(localStorage.getItem('info'))

从这里可以看出,localStorage其实存储的都是字符串,如果是存储对象需要调用JSONstringify方法,并且用JSON.parse来解析成对象。

# 应用场景

利用localStorage的较大容量和持久特性,可以利用localStorage存储一些内容稳定的资源,比如官网的logo,存储Base64格式的图片资源,因此利用localStorage

# sessionStorage

# 特点

sessionStorage以下方面和localStorage一致:

  • 容量。容量上限也为 5M。
  • 只存在客户端,默认不参与与服务端的通信。
  • 接口封装。除了sessionStorage名字有所变化,存储方式、操作方式均和localStorage一样。

sessionStoragelocalStorage有一个本质的区别,那就是前者只是会话级别的存储,并不是持久化存储。会话结束,也就是页面关闭,这部分sessionStorage就不复存在了。

# 应用场景

  1. 可以用它对表单信息进行维护,将表单信息存储在里面,可以保证页面即使刷新也不会让之前的表单信息丢失。
  2. 可以用它存储本次浏览记录。如果关闭页面后不需要这些记录,用sessionStorage就再合适不过了。事实上微博就采取了这样的存储方式。

# IndexedDB

IndexedDB是运行在浏览器中的非关系型数据库, 本质上是数据库,绝不是和刚才 WebStorage 的 5M 一个量级,理论上这个容量是没有上限的。

关于它的使用,本文侧重原理,而且 MDN 上的教程文档已经非常详尽,这里就不做赘述了,感兴趣可以看一下使用文档 (opens new window)

接着我们来分析一下IndexedDB的一些重要特性,除了拥有数据库本身的特性,比如支持事务存储二进制数据,还有这样一些特性需要格外注意:

  1. 键值对存储。内部采用对象仓库存放数据,在这个对象仓库中数据采用键值对的方式来存储。
  2. 异步操作。数据库的读写属于 I/O 操作, 浏览器中对异步 I/O 提供了支持。
  3. 受同源策略限制,即无法访问跨域的数据库。

# 总结

浏览器中各种本地存储和缓存技术的发展,给前端应用带来了大量的机会,PWA 也正是依托了这些优秀的存储方案才得以发展起来。重新梳理一下这些本地存储方案:

  1. cookie并不适合存储,而且存在非常多的缺陷。
  2. Web Storage包括localStoragesessionStorage, 默认不会参与和服务器的通信。
  3. IndexedDB为运行在浏览器上的非关系型数据库,为大型数据的存储提供了接口。

# 第 3 篇: 说一说从输入 URL 到页面呈现发生了什么?——网络篇

这是一个可以无限难的问题。出这个题目的目的就是为了考察你的 web 基础深入到什么程度。由于水平和篇幅有限,在这里我将把其中一些重要的过程给大家梳理一遍,相信能在绝大部分的情况下给出一个比较惊艳的答案。

这里我提前声明,由于是一个综合性非常强的问题,可能会在某一个点上深挖出非常多的细节,我个人觉得学习是一个循序渐进的过程,在明白了整体过程后再去自己研究这些细节,会对整个知识体系有更深的理解。同时,关于延伸出来的细节点我都有参考资料,看完这篇之后不妨再去深入学习一下,扩展知识面。

好,正题开始。

此时此刻,你在浏览器地址栏输入了百度的网址:

https://www.baidu.com/

# 网络请求

# 1. 构建请求

浏览器会构建请求行:

// 请求方法是GET,路径为根路径,HTTP协议版本为1.1
GET / HTTP/1.1

# 2. 查找强缓存

先检查强缓存,如果命中直接使用,否则进入下一步。关于强缓存,如果不清楚可以参考上一篇文章。

# 3. DNS 解析

由于我们输入的是域名,而数据包是通过IP地址传给对方的。因此我们需要得到域名对应的IP地址。这个过程需要依赖一个服务系统,这个系统将域名和 IP 一一映射,我们将这个系统就叫做DNS(域名系统)。得到具体 IP 的过程就是DNS解析。

当然,值得注意的是,浏览器提供了DNS 数据缓存功能。即如果一个域名已经解析过,那会把解析的结果缓存下来,下次处理直接走缓存,不需要经过 DNS解析

另外,如果不指定端口的话,默认采用对应的 IP 的 80 端口。

# 4. 建立 TCP 连接

这里要提醒一点,Chrome 在同一个域名下要求同时最多只能有 6 个 TCP 连接,超过 6 个的话剩下的请求就得等待。

假设现在不需要等待,我们进入了 TCP 连接的建立阶段。首先解释一下什么是 TCP:

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。

建立 TCP连接经历了下面三个阶段:

  1. 通过三次握手(即总共发送 3 个数据包确认已经建立连接)建立客户端和服务器之间的连接。
  2. 进行数据传输。这里有一个重要的机制,就是接收方接收到数据包后必须要向发送方确认, 如果发送方没有接到这个确认的消息,就判定为数据包丢失,并重新发送该数据包。当然,发送的过程中还有一个优化策略,就是把大的数据包拆成一个个小包,依次传输到接收方,接收方按照这个小包的顺序把它们组装成完整数据包。
  3. 断开连接的阶段。数据传输完成,现在要断开连接了,通过四次挥手来断开连接。

读到这里,你应该明白 TCP 连接通过什么手段来保证数据传输的可靠性,一是三次握手确认连接,二是数据包校验保证数据到达接收方,三是通过四次挥手断开连接。

当然,如果再深入地问,比如为什么要三次握手,两次不行吗?第三次握手失败了怎么办?为什么要四次挥手等等这一系列的问题,涉及计算机网络的基础知识,比较底层,但是也是非常重要的细节,希望你能好好研究一下,另外这里有一篇不错的文章,点击进入相应的推荐文章 (opens new window),相信这篇文章能给你启发。

# 5.发送 HTTP 请求

现在TCP连接建立完毕,浏览器可以和服务器开始通信,即开始发送 HTTP 请求。浏览器发 HTTP 请求要携带三样东西:请求行请求头请求体

首先,浏览器会向服务器发送请求行,关于请求行, 我们在这一部分的第一步就构建完了,贴一下内容:

// 请求方法是GET,路径为根路径,HTTP协议版本为1.1
GET / HTTP/1.1

结构很简单,由请求方法请求 URIHTTP 版本协议组成。

同时也要带上请求头,比如我们之前说的Cache-ControlIf-Modified-SinceIf-None-Match都由可能被放入请求头中作为缓存的标识信息。当然了还有一些其他的属性,列举如下:

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: no-cache
Connection: keep-alive
Cookie: /* 省略cookie信息 */
Host: www.baidu.com
Pragma: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1

最后是请求体,请求体只有在POST方法下存在,常见的场景是表单提交

# 网络响应

HTTP 请求到达服务器,服务器进行对应的处理。最后要把数据传给浏览器,也就是返回网络响应。

跟请求部分类似,网络响应具有三个部分:响应行响应头响应体

响应行类似下面这样:

HTTP/1.1 200 OK

HTTP协议版本状态码状态描述组成。

响应头包含了服务器及其返回数据的一些信息, 服务器生成数据的时间、返回的数据类型以及对即将写入的 Cookie 信息。

举例如下:

Cache-Control: no-cache
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html;charset=utf-8
Date: Wed, 04 Dec 2019 12:29:13 GMT
Server: apache
Set-Cookie: rsv_i=f9a0SIItKqzv7kqgAAgphbGyRts3RwTg%2FLyU3Y5Eh5LwyfOOrAsvdezbay0QqkDqFZ0DfQXby4wXKT8Au8O7ZT9UuMsBq2k; path=/; domain=.baidu.com

响应完成之后怎么办?TCP 连接就断开了吗?

不一定。这时候要判断Connection字段, 如果请求头或响应头中包含Connection: Keep-Alive,表示建立了持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。

否则断开TCP连接, 请求-响应流程结束。

# 总结

到此,我们来总结一下主要内容,也就是浏览器端的网络请求过程:

网络请求流程图

# 第 4 篇: 说一说从输入 URL 到页面呈现发生了什么?——解析算法篇

完成了网络请求和响应,如果响应头中Content-Type的值是text/html,那么接下来就是浏览器的解析渲染工作了。

首先来介绍解析部分,主要分为以下几个步骤:

  • 构建 DOM
  • 样式计算
  • 生成布局树(Layout Tree)

# 构建 DOM 树

由于浏览器无法直接理解HTML字符串,因此将这一系列的字节流转换为一种有意义并且方便操作的数据结构,这种数据结构就是DOM树DOM树本质上是一个以document为根节点的多叉树。

那通过什么样的方式来进行解析呢?

# HTML 文法的本质

首先,我们应该清楚把握一点: HTML 的文法并不是上下文无关文法

这里,有必要讨论一下什么是上下文无关文法

在计算机科学的编译原理学科中,有非常明确的定义:

若一个形式文法 G = (N, Σ, P, S) 的产生式规则都取如下的形式:V->w,则叫上下文无关语法。其中 V∈N ,w∈(N∪Σ)* 。

其中把 G = (N, Σ, P, S) 中各个参量的意义解释一下:

  1. N 是非终结符(顾名思义,就是说最后一个符号不是它, 下面同理)集合。
  2. Σ 是终结符集合。
  3. P 是开始符,它必须属于 N ,也就是非终结符。
  4. S 就是不同的产生式的集合。如 S -> aSb 等等。

通俗一点讲,上下文无关的文法就是说这个文法中所有产生式的左边都是一个非终结符。

看到这里,如果还有一点懵圈,我举个例子你就明白了。

比如:

A -> B

这个文法中,每个产生式左边都会有一个非终结符,这就是上下文无关的文法。在这种情况下,xBy一定是可以规约出xAy的。

我们下面看看看一个反例:

aA -> B
Aa -> B

这种情况就是不是上下文无关的文法,当遇到B的时候,我们不知道到底能不能规约出A,取决于左边或者右边是否有a存在,也就是说和上下文有关。

关于它为什么是非上下文无关文法,首先需要让大家注意的是,规范的 HTML 语法,是符合上下文无关文法的,能够体现它非上下文无关的是不标准的语法。在此我仅举一个反例即可证明。

比如解析器扫描到form标签的时候,上下文无关文法的处理方式是直接创建对应 form 的 DOM 对象,而真实的 HTML5 场景中却不是这样,解析器会查看 form 的上下文,如果这个 form 标签的父标签也是 form, 那么直接跳过当前的 form 标签,否则才创建 DOM 对象。

常规的编程语言都是上下文无关的,而 HTML 却相反,也正是它非上下文无关的特性,决定了HTML Parser并不能使用常规编程语言的解析器来完成,需要另辟蹊径。

# 解析算法

HTML5 规范 (opens new window)详细地介绍了解析算法。这个算法分为两个阶段:

  1. 标记化。
  2. 建树。

对应的两个过程就是词法分析语法分析

# 标记化算法

这个算法输入为HTML文本,输出为HTML标记,也成为标记生成器。其中运用有限自动状态机来完成。即在当当前状态下,接收一个或多个字符,就会更新到下一个状态。

<html>
  <body>
    Hello sanyuan
  </body>
</html>

通过一个简单的例子来演示一下标记化的过程。

遇到<, 状态为标记打开

接收[a-z]的字符,会进入标记名称状态

这个状态一直保持,直到遇到>,表示标记名称记录完成,这时候变为数据状态

接下来遇到body标签做同样的处理。

这个时候htmlbody的标记都记录好了。

现在来到

中的>,进入数据状态,之后保持这样状态接收后面的字符hello sanyuan

接着接收

中的<,回到标记打开, 接收下一个/后,这时候会创建一个end tag的 token。

随后进入标记名称状态, 遇到>回到数据状态

接着以同样的样式处理 。

# 建树算法

之前提到过,DOM 树是一个以document为根节点的多叉树。因此解析器首先会创建一个document对象。标记生成器会把每个标记的信息发送给建树器建树器接收到相应的标记时,会创建对应的 DOM 对象。创建这个DOM对象后会做两件事情:

  1. DOM对象加入 DOM 树中。
  2. 将对应标记压入存放开放(与闭合标签意思对应)元素的栈中。

还是拿下面这个例子说:

<html>
  <body>
    Hello sanyuan
  </body>
</html>

首先,状态为初始化状态

接收到标记生成器传来的html标签,这时候状态变为before html 状态。同时创建一个HTMLHtmlElement的 DOM 元素, 将其加到document根对象上,并进行压栈操作。

接着状态自动变为before head, 此时从标记生成器那边传来body,表示并没有head, 这时候建树器会自动创建一个HTMLHeadElement并将其加入到DOM树中。

现在进入到in head状态, 然后直接跳到after head

现在标记生成器传来了body标记,创建HTMLBodyElement, 插入到DOM树中,同时压入开放标记栈。

接着状态变为in body,然后来接收后面一系列的字符: Hello sanyuan。接收到第一个字符的时候,会创建一个Text节点并把字符插入其中,然后把Text节点插入到 DOM 树中body元素的下面。随着不断接收后面的字符,这些字符会附在Text节点上。

现在,标记生成器传过来一个body的结束标记,进入到after body状态。

标记生成器最后传过来一个html的结束标记, 进入到after after body的状态,表示解析过程到此结束。

# 容错机制

讲到HTML5规范,就不得不说它强大的宽容策略, 容错能力非常强,虽然大家褒贬不一,不过我想作为一名资深的前端工程师,有必要知道HTML Parser在容错方面做了哪些事情。

接下来是 WebKit 中一些经典的容错示例,发现有其他的也欢迎来补充。

  1. 使用
    而不是
if (t->isCloseTag(brTag) && m_document->inCompatMode()) {
  reportError(MalformedBRError);
  t->beginTag = true;
}

全部换为
的形式。

  1. 表格离散
<table>
  <table>
    <tr>
      <td>inner table</td>
    </tr>
  </table>
  <tr>
    <td>outer table</td>
  </tr>
</table>

WebKit会自动转换为:

<table>
  <tr>
    <td>outer table</td>
  </tr>
</table>
<table>
  <tr>
    <td>inner table</td>
  </tr>
</table>
  1. 表单元素嵌套

这时候直接忽略里面的form

# 样式计算

关于 CSS 样式,它的来源一般是三种:

  1. link 标签引用
  2. style 标签中的样式
  3. 元素的内嵌 style 属性

# 格式化样式表

首先,浏览器是无法直接识别 CSS 样式文本的,因此渲染引擎接收到 CSS 文本之后第一件事情就是将其转化为一个结构化的对象,即 styleSheets。

这个格式化的过程过于复杂,而且对于不同的浏览器会有不同的优化策略,这里就不展开了。

在浏览器控制台能够通过document.styleSheets来查看这个最终的结构。当然,这个结构包含了以上三种 CSS 来源,为后面的样式操作提供了基础。

# 标准化样式属性

有一些 CSS 样式的数值并不容易被渲染引擎所理解,因此需要在计算样式之前将它们标准化,如em->px,red->#ff0000,bold->700等等。

# 计算每个节点的具体样式

样式已经被格式化标准化,接下来就可以计算每个节点的具体样式信息了。

其实计算的方式也并不复杂,主要就是两个规则: 继承层叠

每个子节点都会默认继承父节点的样式属性,如果父节点中没有找到,就会采用浏览器默认样式,也叫UserAgent样式。这就是继承规则,非常容易理解。

然后是层叠规则,CSS 最大的特点在于它的层叠性,也就是最终的样式取决于各个属性共同作用的效果,甚至有很多诡异的层叠现象,看过《CSS 世界》的同学应该对此深有体会,具体的层叠规则属于深入 CSS 语言的范畴,这里就不过多介绍了。

不过值得注意的是,在计算完样式之后,所有的样式值会被挂在到window.getComputedStyle当中,也就是可以通过 JS 来获取计算后的样式,非常方便。

# 生成布局树

现在已经生成了DOM树DOM样式,接下来要做的就是通过浏览器的布局系统确定元素的位置,也就是要生成一棵布局树(Layout Tree)。

布局树生成的大致工作如下:

  1. 遍历生成的 DOM 树节点,并把他们添加到布局树中
  2. 计算布局树节点的坐标位置。

值得注意的是,这棵布局树值包含可见元素,对于 head标签和设置了display: none的元素,将不会被放入其中。

有人说首先会生成Render Tree,也就是渲染树,其实这还是 16 年之前的事情,现在 Chrome 团队已经做了大量的重构,已经没有生成Render Tree的过程了。而布局树的信息已经非常完善,完全拥有Render Tree的功能。

之所以不讲布局的细节,是因为它过于复杂,一一介绍会显得文章过于臃肿,不过大部分情况下我们只需要知道它所做的工作是什么即可,如果想深入其中的原理,知道它是如何来做的,我强烈推荐你去读一读人人 FED 团队的文章从 Chrome 源码看浏览器如何 layout 布局 (opens new window)

# 总结

梳理一下这一节的主要脉络:

解析算法流程图

# 第 5 篇: 说一说从输入 URL 到页面呈现发生了什么?——渲染过程篇

上一节介绍了浏览器解析的过程,其中包含构建DOM样式计算构建布局树

接下来就来拆解下一个过程——渲染。分为以下几个步骤:

  • 建立图层树(Layer Tree)
  • 生成绘制列表
  • 生成图块栅格化
  • 显示器显示内容

# 一、建图层树

如果你觉得现在DOM节点也有了,样式和位置信息也都有了,可以开始绘制页面了,那你就错了。

因为你考虑掉了另外一些复杂的场景,比如 3D 动画如何呈现出变换效果,当元素含有层叠上下文时如何控制显示和隐藏等等。

为了解决如上所述的问题,浏览器在构建完布局树之后,还会对特定的节点进行分层,构建一棵图层树(Layer Tree)。

那这棵图层树是根据什么来构建的呢?

一般情况下,节点的图层会默认属于父亲节点的图层(这些图层也称为合成层)。那什么时候会提升为一个单独的合成层呢?

有两种情况需要分别讨论,一种是显式合成,一种是隐式合成

# 显式合成

下面是显式合成的情况:

一、 拥有层叠上下文的节点。

层叠上下文也基本上是有一些特定的 CSS 属性创建的,一般有以下情况:

  • HTML 根元素本身就具有层叠上下文。
  • 普通元素设置position 不为 static并且设置了 z-index 属性,会产生层叠上下文。
  • 元素的 opacity 值不是 1
  • 元素的 transform 值不是 none
  • 元素的 filter 值不是 none
  • 元素的 isolation 值是 isolate
  • will-change指定的属性值为上面任意一个。(will-change 的作用后面会详细介绍)

二、需要剪裁的地方。

比如一个 div,你只给他设置 100 * 100 像素的大小,而你在里面放了非常多的文字,那么超出的文字部分就需要被剪裁。当然如果出现了滚动条,那么滚动条会被单独提升为一个图层。

# 隐式合成

接下来是隐式合成,简单来说就是层叠等级低的节点被提升为单独的图层之后,那么所有层叠等级比它高的节点都会成为一个单独的图层。

这个隐式合成其实隐藏着巨大的风险,如果在一个大型应用中,当一个z-index比较低的元素被提升为单独图层之后,层叠在它上面的的元素统统都会被提升为单独的图层,可能会增加上千个图层,大大增加内存的压力,甚至直接让页面崩溃。这就是层爆炸的原理。这里有一个具体的例子,点击打开 (opens new window)

值得注意的是,当需要repaint时,只需要repaint本身,而不会影响到其他的层。

# 二、生成绘制列表

接下来渲染引擎会将图层的绘制拆分成一个个绘制指令,比如先画背景、再描绘边框......然后将这些指令按顺序组合成一个待绘制列表,相当于给后面的绘制操作做了一波计划。

这里我以百度首页为例,大家可以在 Chrome 开发者工具中在设置栏中展开 more tools, 然后选择Layers面板,就能看到下面的绘制列表:

绘制列表示例

# 三、生成图块和生成位图

现在开始绘制操作,实际上在渲染进程中绘制操作是由专门的线程来完成的,这个线程叫合成线程

绘制列表准备好了之后,渲染进程的主线程会给合成线程发送commit消息,把绘制列表提交给合成线程。接下来就是合成线程一展宏图的时候啦。

首先,考虑到视口就这么大,当页面非常大的时候,要滑很长时间才能滑到底,如果要一口气全部绘制出来是相当浪费性能的。因此,合成线程要做的第一件事情就是将图层分块。这些块的大小一般不会特别大,通常是 256 _ 256 或者 512 _ 512 这个规格。这样可以大大加速页面的首屏展示。

因为后面图块数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。

顺便提醒一点,渲染进程中专门维护了一个栅格化线程池,专门负责把图块转换为位图数据

然后合成线程会选择视口附近的图块,把它交给栅格化线程池生成位图。

生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给合成线程

# 四、显示器显示内容

栅格化操作完成后,合成线程会生成一个绘制命令,即"DrawQuad",并发送给浏览器进程。

浏览器进程中的viz组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡。为什么发给显卡呢?我想有必要先聊一聊显示器显示图像的原理。

无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区后缓冲区对换位置,如此循环更新。

看到这里你也就是明白,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。

# 总结

到这里,我们算是把整个过程给走通了,现在重新来梳理一下页面渲染的流程。

页面渲染流程图

# 第 6 篇: 谈谈你对重绘和回流的理解。

我们首先来回顾一下渲染流水线的流程:

渲染流水线图

接下来,我们将来以此为依据来介绍重绘和回流,以及让更新视图的另外一种方式——合成。

# 回流

首先介绍回流回流也叫重排

# 触发条件

简单来说,就是当我们对 DOM 结构的修改引发 DOM 几何尺寸变化的时候,会发生回流的过程。

具体一点,有以下的操作会触发回流:

  1. 一个 DOM 元素的几何属性变化,常见的几何属性有widthheightpaddingmarginlefttopborder 等等, 这个很好理解。

  2. 使 DOM 节点发生增减或者移动

  3. 读写 offset族、Scroll族和client族属性的时候,浏览器为了获取这些值,需要进行回流操作。

  4. 调用 window.getComputedStyle 方法。

# 回流过程

依照上面的渲染流水线,触发回流的时候,如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。

回流过程图

相当于将解析和合成的过程重新又走了一篇,开销是非常大的。

# 重绘

# 触发条件

当 DOM 的修改导致了样式的变化,并且没有影响几何属性的时候,会导致重绘(repaint)。

# 重绘过程

由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程。流程如下:

重绘过程图

跳过了生成布局树建图层树的阶段,直接生成绘制列表,然后继续进行分块、生成位图等后面一系列操作。

可以看到,重绘不一定导致回流,但回流一定发生了重绘。

# 合成

还有一种情况,是直接合成。比如利用 CSS3 的transformopacityfilter这些属性就可以实现合成的效果,也就是大家常说的GPU 加速

# GPU 加速的原因

在合成的情况下,会直接跳过布局和绘制流程,直接进入非主线程处理的部分,即直接交给合成线程处理。交给它处理有两大好处:

  1. 能够充分发挥GPU的优势。合成线程生成位图的过程中会调用线程池,并在其中使用GPU进行加速生成,而 GPU 是擅长处理位图数据的。

  2. 没有占用主线程的资源,即使主线程卡住了,效果依然能够流畅地展示。

# 实践意义

知道上面的原理之后,对于开发过程有什么指导意义呢?

  1. 避免频繁使用 style,而是采用修改class的方式。
  2. 使用createDocumentFragment进行批量的 DOM 操作。
  3. 对于 resize、scroll 等进行防抖/节流处理。
  4. 添加 will-change: tranform ,让渲染引擎为其单独实现一个图层,当这些变换发生时,仅仅只是利用合成线程去处理这些变换,而不牵扯到主线程,大大提高渲染效率。当然这个变化不限于tranform, 任何可以实现合成效果的 CSS 属性都能用will-change来声明。这里有一个实际的例子,一行will-change: tranform拯救一个项目,点击直达 (opens new window)

# 第 7 篇: 能不能说一说 XSS 攻击?

# 什么是 XSS 攻击?

XSS 全称是 Cross Site Scripting(即跨站脚本),为了和 CSS 区分,故叫它XSS。XSS 攻击是指浏览器中执行恶意脚本(无论是跨域还是同域),从而拿到用户的信息并进行操作。

这些操作一般可以完成下面这些事情:

  1. 窃取Cookie
  2. 监听用户行为,比如输入账号密码后直接发送到黑客服务器。
  3. 修改 DOM 伪造登录表单。
  4. 在页面中生成浮窗广告。

通常情况,XSS 攻击的实现有三种方式——存储型反射型文档型。原理都比较简单,先来一一介绍一下。

# 存储型

存储型,顾名思义就是将恶意脚本存储了起来,确实,存储型的 XSS 将脚本存储到了服务端的数据库,然后在客户端执行这些脚本,从而达到攻击的效果。

常见的场景是留言评论区提交一段脚本代码,如果前后端没有做好转义的工作,那评论内容存到了数据库,在页面渲染过程中直接执行, 相当于执行一段未知逻辑的 JS 代码,是非常恐怖的。这就是存储型的 XSS 攻击。

# 反射型

反射型XSS指的是恶意脚本作为网络请求的一部分

比如我输入:

http://sanyuan.com?q=<script>alert("你完蛋了")</script>

这样,在服务器端会拿到q参数,然后将内容返回给浏览器端,浏览器将这些内容作为 HTML 的一部分解析,发现是一个脚本,直接执行,这样就被攻击了。

之所以叫它反射型, 是因为恶意脚本是通过作为网络请求的参数,经过服务器,然后再反射到 HTML 文档中,执行解析。和存储型不一样的是,服务器并不会存储这些恶意脚本。

# 文档型

文档型的 XSS 攻击并不会经过服务端,而是作为中间人的角色,在数据传输过程劫持到网络数据包,然后修改里面的 html 文档

这样的劫持方式包括WIFI路由器劫持或者本地恶意软件等。

# 防范措施

一个信念,两个利用

  1. 千万不要相信任何用户的输入!要对用户的输入进行转码或者过滤。
  2. CSP,即浏览器中的内容安全策略
  3. 利用 HttpOnly

分割线

上次更新: