# 浏览器核心技术

本章关于浏览器原理部分的内容主要来源于浏览器工作原理 (opens new window),这是一篇很长的文章,可以算上一本小书了,有精力的非常建议阅读。

# 常见的浏览器内核有哪些?

浏览器/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

# 浏览器的主要组成部分是什么?

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

图:浏览器的主要组件。

值得注意的是,和大多数浏览器不同,Chrome 浏览器的每个标签页都分别对应一个呈现引擎实例。每个标签页都是一个独立的进程。

# Chrome 多进程架构

# 浏览器进程

  • UI 线程 用于绘制浏览器的按钮和输入字段

  • 网络线程 用于处理网络请求,以及从服务器接收数据

  • 存储线程 用于控制对文件的访问

# 渲染进程

  • GUI 渲染线程 负责对浏览器界面进行渲染
  • JavaScript 引擎线程 负责解析和执行 javaScript 脚本
  • 浏览器定时器触发线程 setTimeout setInterval
  • 浏览器事件触发线程 负责处理浏览器事件,并将事件触发后需要执行对代码放置到 javaScript 引擎中执行

# GPU 进程

# 插件进程

# 浏览器是如何渲染 UI 的?

  1. 浏览器获取 HTML 文件,然后对文件进行解析,形成 DOM Tree
  2. 与此同时,进行 CSS 解析,生成 Style Rules
  3. 接着将 DOM Tree 与 Style Rules 合成为 Render Tree
  4. 接着进入布局(Layout)阶段,也就是为每个节点分配一个应出现在屏幕上的确切坐标
  5. 随后调用 GPU 进行绘制(Paint),遍历 Render Tree 的节点,并将元素呈现出来

2019-06-22-06-48-02

# 浏览器如何解析 css 选择器?

浏览器会『从右往左』解析 CSS 选择器。

我们知道 DOM Tree 与 Style Rules 合成为 Render Tree,实际上是需要将Style Rules附着到 DOM Tree 上,因此需要根据选择器提供的信息对 DOM Tree 进行遍历,才能将样式附着到对应的 DOM 元素上。

以下这段 css 为例

.mod-nav h3 span {
  font-size: 16px;
}
1
2
3

我们对应的 DOM Tree 如下

2019-06-22-06-58-56

若从左向右的匹配,过程是:

  1. 从 .mod-nav 开始,遍历子节点 header 和子节点 div
  2. 然后各自向子节点遍历。在右侧 div 的分支中
  3. 最后遍历到叶子节点 a ,发现不符合规则,需要回溯到 ul 节点,再遍历下一个 li-a,一颗 DOM 树的节点动不动上千,这种效率很低。

如果从右至左的匹配:

  1. 先找到所有的最右节点 span,对于每一个 span,向上寻找节点 h3
  2. 由 h3 再向上寻找 class=mod-nav 的节点
  3. 最后找到根元素 html 则结束这个分支的遍历。

后者匹配性能更好,是因为从右向左的匹配在第一步就筛选掉了大量的不符合条件的最右节点(叶子节点);而从左向右的匹配规则的性能都浪费在了失败的查找上面。

# DOM Tree 是如何构建的?

  1. 转码: 浏览器将接收到的二进制数据按照指定编码格式转化为 HTML 字符串
  2. 生成 Tokens: 之后开始 parser,浏览器会将 HTML 字符串解析成 Tokens
  3. 构建 Nodes: 对 Node 添加特定的属性,通过指针确定 Node 的父、子、兄弟关系和所属 treeScope
  4. 生成 DOM Tree: 通过 node 包含的指针确定的关系构建出 DOM Tree

2019-06-22-11-48-00

# 浏览器重绘与重排的区别?

  • 重排: 部分渲染树(或者整个渲染树)需要重新分析并且节点尺寸需要重新计算,表现为重新生成布局,重新排列元素
  • 重绘: 由于节点的几何属性发生改变或者由于样式发生改变,例如改变元素背景色时,屏幕上的部分内容需要更新,表现为某些元素的外观被改变

单单改变元素的外观,肯定不会引起网页重新生成布局,但当浏览器完成重排之后,将会重新绘制受到此次重排影响的部分

重排和重绘代价是高昂的,它们会破坏用户体验,并且让 UI 展示非常迟缓,而相比之下重排的性能影响更大,在两者无法避免的情况下,一般我们宁可选择代价更小的重绘。

『重绘』不一定会出现『重排』,『重排』必然会出现『重绘』。

# 如何触发重排和重绘?

任何改变用来构建渲染树的信息都会导致一次重排或重绘:

  • 添加、删除、更新 DOM 节点
  • 通过 display: none 隐藏一个 DOM 节点-触发重排和重绘
  • 通过 visibility: hidden 隐藏一个 DOM 节点-只触发重绘,因为没有几何变化
  • 移动或者给页面中的 DOM 节点添加动画
  • 添加一个样式表,调整样式属性
  • 用户行为,例如调整窗口大小,改变字号,或者滚动。

# 如何避免重绘或者重排?

# 集中改变样式

我们往往通过改变 class 的方式来集中改变样式

// 判断是否是黑色系样式
const theme = isDark ? "dark" : "light";

// 根据判断来设置不同的class
ele.setAttribute("className", theme);
1
2
3
4
5

# 使用 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);
1
2
3
4
5
6
7
8
9

# 提升为合成层

将元素提升为合成层有以下优点:

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
  • 对于 transform 和 opacity 效果,不会触发 layout 和 paint

提升合成层的最好方式是使用 CSS 的 will-change 属性:

#target {
  will-change: transform;
}
1
2
3

关于合成层的详解请移步无线性能优化:Composite (opens new window)

# 前端如何实现即时通讯?

# 短轮询

短轮询的原理很简单,每隔一段时间客户端就发出一个请求,去获取服务器最新的数据,一定程度上模拟实现了即时通讯。

  • 优点:兼容性强,实现非常简单
  • 缺点:延迟性高,非常消耗请求资源,影响性能

# comet

comet 有两种主要实现手段,一种是基于 AJAX 的长轮询(long-polling)方式,另一种是基于 Iframe 及 htmlfile 的流(streaming)方式,通常被叫做长连接。

具体两种手段的操作方法请移步Comet 技术详解:基于 HTTP 长连接的 Web 端实时通信技术 (opens new window)

长轮询优缺点:

  • 优点:兼容性好,资源浪费较小
  • 缺点:服务器 hold 连接会消耗资源,返回数据顺序无保证,难于管理维护

长连接优缺点:

  • 优点:兼容性好,消息即时到达,不发无用请求
  • 缺点:服务器维护长连接消耗资源

# SSE

使用指南请看Server-Sent Events 教程 (opens new window)

SSE(Server-Sent Event,服务端推送事件)是一种允许服务端向客户端推送新数据的 HTML5 技术。

  • 优点:基于 HTTP 而生,因此不需要太多改造就能使用,使用方便,而 websocket 非常复杂,必须借助成熟的库或框架
  • 缺点:基于文本传输效率没有 websocket 高,不是严格的双向通信,客户端向服务端发送请求无法复用之前的连接,需要重新发出独立的请求

2019-06-22-12-33-19

# Websocket

使用指南请看WebSocket 教程 (opens new window)

Websocket 是一个全新的、独立的协议,基于 TCP 协议,与 http 协议兼容、却不会融入 http 协议,仅仅作为 html5 的一部分,其作用就是在服务器和客户端之间建立实时的双向通信。

  • 优点:真正意义上的实时双向通信,性能好,低延迟
  • 缺点:独立与 http 的协议,因此需要额外的项目改造,使用复杂度高,必须引入成熟的库,无法兼容低版本浏览器

2019-06-22-12-33-43

# Web Worker

后面性能优化部分会用到,先做了解

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行

Web Worker 教程 (opens new window)

# Service workers

后面性能优化部分会用到,先做了解

Service workers 本质上充当 Web 应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理,创建有效的离线体验。

Service workers 教程 (opens new window)

# 什么是浏览器同源策略?

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。

同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个 ip 地址,也非同源。

下表给出了相对http://store.company.com/dir/page.html同源检测的示例:

2019-06-23-10-25-28

浏览器中的大部分内容都是受同源策略限制的,但是以下三个标签可以不受限制:

  • <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);
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 最流行的跨域方案 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"));
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在生产环境中建议用成熟的开源中间件解决问题。

# 最方便的跨域方案 Nginx

nginx 是一款极其强大的 web 服务器,其优点就是轻量级、启动快、高并发。

现在的新项目中 nginx 几乎是首选,我们用 node 或者 java 开发的服务通常都需要经过 nginx 的反向代理。

2019-06-24-10-19-34

反向代理的原理很简单,即所有客户端的请求都必须先经过 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;
        }

    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

# 其它跨域方案

  1. HTML5 XMLHttpRequest 有一个 API,postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递。
  2. WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了,因此可以跨域。
  3. window.name + iframe:window.name 属性值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值,我们可以利用这个特点进行跨域。
  4. location.hash + iframe:a.html 欲与 c.html 跨域相互通信,通过中间页 b.html 来实现。 三个页面,不同域之间利用 iframe 的 location.hash 传值,相同域之间直接 js 访问来通信。
  5. document.domain + iframe: 该方式只能用于二级域名相同的情况下,比如 a.test.com 和 b.test.com 适用于该方式,我们只需要给页面添加 document.domain ='test.com' 表示二级域名都相同就可以实现跨域,两个页面都通过 js 强制设置 document.domain 为基础主域,就实现了同域。

其余方案来源于九种跨域方式 (opens new window)


参考文章: