宏观视角下的浏览器
浏览器最开始是美国网景公司开发的,自诞生之日起,地位一直只增不减。
C/S client、server,即客户端、服务端
B/S browser、server,即浏览器、服务端
浏览器工作原理重要性
- 了解浏览器如何工作,能够让我们更准确地决策是否可以采用Web来开发项目
- 站在更高的角度审视前端页面
- 在技术快速迭代的时代把握本质
进程与线程
进程:就是内存中正在运行的应用程序,包括如下特点:
- 进程在内存中独占一个内存空间
- 进程与进程之间是隔离的,比如手机玩王者荣耀(进程),如果王者荣耀崩溃了,这个进程就终止了,但是手机中的其他应用(比如微信)不会受到影响(进程之间隔离)
线程:进程的最小执行单位。它也有一定的特点:
- 一个进程是由多个线程组成的
- 每一个线程之间也是相互隔离的(比如微信可以和多个人聊天,也互不干扰)
一个页面启动的时候,至少启动了4个进程:
- 浏览器主进程
- 渲染进程
- 网络进程
- GPU进程
如果安装了插件,还会运行插件进程。
下面逐个简介这些进程:
- 浏览器主进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能;
- 渲染进程:核心任务是将HTML、CSS和JavaScript转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都运行在该进程中;
- 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,现在已经独立成为单独一个进程;
- GPU进程:GPU,即图像处理单元,其使用初衷是为了实现3D、CSS的效果,后来网页等界面都采用了GPU来绘制。
- 插件进程:主要负责插件运行,由于插件容易崩溃,于是该进程与其他进程隔离开来,防止插件的崩溃影响到浏览器和页面。
计算机网络的七层模型
从下往上的顺序为:
- 物理层:使用一定的物理介质来进行计算机之间的连接,以电信号0、1进行传输;
- 数据链路层:MAC地址,对物理层的0、1信号进行封装,封装成比特;
- 网络层:IP地址
- 传输层:涉及UDP(用户数据包协议)/TCP(传输控制协议)
- 会话层:断点续传
- 表示层:解决不同系统之间数据传输的问题
- 应用层:HTTP
UDP:只管发,不管收
TCP:具有重传机制、排序机制
也可以表示为四层:
- 物理层:物理层、数据链路层
- 网络层
- 传输层
- 应用层:会话层、表示层、应用层
HTTP请求流程
浏览器发送HTTP请求的大致请求流程如下:
- 构造请求行
- 查找缓存
- 准备IP地址和端口号
- 等待TCP队列,一个域名最多只能和建立6个TCP连接
- 建立TCP连接
- 发送HTTP请求
服务器处理HTTP请求的流程:
- 返回请求内容
- 断开连接
从进程角度看浏览器
从用户在url地址栏到浏览器显示页面,这个过程中到底发生了什么。
主要涉及了浏览器主进程、网络进程、渲染进程。
- 用户在浏览器主进程中输入url地址;
- 浏览器主进程将url请求派发给网络进程;
- 在网络进程中,发送url请求,获取响应头数据,解析响应头数据;
- 网络进程将解析出来的数据转发给浏览器主进程;
- 浏览器主进程接收到网络进程的响应数据,发送“提交文档”(HTML数据)消息给渲染进程;
- 在渲染进程中,接收到消息之后,准备接收HTML数据,接收数据的方式是直接和网络进程之间建立数据管道。
- 文档数据传输完毕,渲染进程将会返回“确认提交”消息给浏览器主进程。
- 在浏览器主进程中,收到渲染进程后“确认提交”消息后,便开始移除旧文档,更新浏览器进程状态。
浏览器渲染流程
以一个很简单的.html文件为例,进行说明:
- 构建DOM树——使用html解析器(ParseHTML)将html页面转化为浏览器能够理解的DOM树;
因为浏览器无法直接理解和使用html,因此需要将html转化为浏览器能够理解的结构——DOM树。 - 将css解析成浏览器能够识别的CSS树
- 样式计算
- 由DOM树、CSS树得到布局树
- 根据布局树生成图层树(只有某些具有特殊属性的节点才会单独占据一个图层)
- 绘制
- 组合图层,生成最终的页面
图层:在html页面中,虽然网页是二维的,但是实际上相当于一个“俯视图”,也就是说,实际上是存在“图层”的,以下html元素可以作为一个单独的图层:
- 具有3d效果的元素
- fixed固定定位的元素
- 视频播放元素video
- 绘图画板canvas
- css动画节点
浏览器每次最多可以接收64Kb资源。
图层
图层是在布局的基础上,为了进一步显示一些复杂的3D变换、页面滚动等效果,也产生的一种渲染结果。
哪些节点会被渲染为新的层
当然了,并不是布局树的每一个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。以下两种情况的节点会被单独提升为一个图层:
- 拥有层叠上下文属性的元素
比如,fixed固定定位属性的元素、定义透明属性的元素、使用CSS滤镜的元素等。 - 需要剪裁(clip)的地方也会被创建为图层。
图层绘制
得到绘制列表
图层树构建完成后,渲染引擎会对图层树中的每一个图层进行绘制,大体步骤是把每一个图层的绘制拆分成很多个小的绘制指令,然后把这些指令按照顺序组成一个待绘制列表,称为绘制列表。
在浏览器开发者工具的Layers(图层)选项中,可以清楚地看到,当点击一个图层进行分析,会出现诸如drawRect、drawPaint等小绘制指令。
当图层的绘制列表准备好之后,主线程会把该绘制列表提交给合成线程,也就是说,接下来的工作将由合成线程完成。
栅格化操作
合成线程并不会一下子绘制出所有图层内容,因为这样花销太大。它转而会将图层划分为图块(tile,是栅格化执行的最小单位),并且按照视口附近的图块来优先生成位图。
所谓栅格化,就是将图块转换为位图,会在线程池内执行,该线程池称为栅格化线程池。
合成、显示
上述提到的栅格化如果对所有图块都进行完毕,合成线程就会生成一个绘制图块的命令——DrawQuad,然后将该命令提交给浏览器进程,由其中的viz组件接收命令,将页面内容绘制到内存中,最后将内存显示在屏幕上。
重排、重绘、合成
在厘清这三者之前,我们首先强调浏览器渲染流水线:
- DOM(生成浏览器可以识别的DOM树)
- Style(生成CSS树、样式计算)
- Layout(得到布局树)
- Layer(得到图层树)
- Paint(绘制)
- 合成操作
上述1~5都是需要主线程参与的,而只有最后一步合成不需要主线程参与,需要非主线程(合成线程)参与。
重排
如果,我们通过JavaScript或者CSS修改元素几何位置属性,比如改变元素宽度、高度等,会引发浏览器重新布局——此之谓“重排”。
重排需要更新完整的渲染流水线,所以开销很大。
重绘
现在情况变了,我们通过JavaScript更改某些元素的背景颜色,很显然,由于几何位置不变,布局不会受到影响,因此渲染流水线中的Layout(布局)阶段不会重新进行,而是直接进入绘制(Paint)阶段,然后再执行其子阶段——此之谓“重绘”。
相较于重排,重绘省去了布局和分层阶段,所以执行效率会比重排操作更高一些。
合成(直接合成)
也即跳过了渲染流水线的布局、绘制,只执行后续合成操作——此之谓“合成”。
相比于重排、重绘,合成可以大大提高绘制效率,因为它根本没有占用主线程的资源。
浏览器中JavaScript的执行
执行上下文
变量提升
首先我们需要明白的是,在JavaScript中何为声明,何为赋值。
var myName = '奇异博士'
// 上面的语句等价于两句,声明、赋值分离开来写为
var myName
myName = '奇异博士'
// 以下是一个完整的函数声明
function foo() {
console.log('foo')
}
// 以下是一个变量声明、赋值,只不过赋值了一个函数
var hello = function () {
console.log('hello!')
}
那么,我们可以得到变量提升的定义:
所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。
何为执行上下文
我们从如下一段JavaScript代码分析起:
showName()
console.log(myName)
var myName = '朱由榔'
function showName() {
console.log('showName被调用!')
}
在经过浏览器的JavaScript引擎的编译后,会生成两部分内容:
- 执行上下文(Execution context)
- 可执行代码
所谓执行上下文,是JavaScript执行一段代码时的运行环境,在执行上下文终存在一个变量环境的对象,它保存了变量提升的内容。
以上面的代码为例,逐句分析:
- 第1、2句并不涉及变量声明,JavaScript引擎不作处理;
- 第3句,JavaScript引擎会在环境对象中创建一个名为myName的属性,并使用undefined对其初始化;
- 第4句,JavaScript将函数showName定义存储到堆(HEAP)中,并在环境对象中创建了一个showName属性,并用该属性值指向堆中函数的位置。
// 执行上下文
var myName = undefined
function showName() {
console.log('showName被调用!')
}
// 可执行代码
showName()
console.log(myName)
myName = '朱由榔'
另外,如果变量和函数名相同,那么在编译阶段,变量的声明会被忽略。换言之,函数提升优于变量提升,导致变量声明被忽略。
调用栈
调用栈,就是用来管理函数调用关系的一种数据结构。
通俗地说,每一个函数都对应一个函数上下文,在整个代码片段中,调用栈中保存的都是执行上下文,故又称执行上下文栈。
当然,调用栈也是有大小的,当入栈的执行上下文超过一定数目时,JavaScript引擎就会报错,这种错误就叫栈溢出。
块级作用域
作用域
在JavaScript中,有两种作用域是ES6之前支持的:
- 全局作用域
- 函数作用域
此外,ES6之前的JavaScript不支持块级作用域(即使用大括号包裹的区域单独形成一个作用域)。
变量提升带来的副作用
变量提升会带来一些让常人觉得匪夷所思的问题。我们从一个栗子看起。
var myname = " 极客时间 "
function showName(){
console.log(myname);
if(0){
var myname = " 极客邦 "
}
console.log(myname);
}
showName()
上面代码的运行结果都是undefined。和其他语言(如C)不同,它们都有块级作用域,在此处的打印结果为“ 极客时间 ”。
原因就是变量提升以及调用栈。showName函数提升后,会创建执行上下文,即showName的函数执行上下文,它入调用栈,压在全局执行上下文上面。当执行showName函数时,就会在调用栈顶的函数执行上下文中寻找myname变量,它的初始值为undefined,于是打印undefined。
下面的一个栗子也可以说明问题:
function foo(){
for (var i = 0; i < 7; i++) {
}
console.log(i);
}
foo()
上述代码的运行结果为7,原因很简单,是因为在创建执行上下文时,变量i会提升,而for循环虽然结束,但是函数并未结束,变量i仍然存在(或者更准确地说,for循环本应该形成一个单独的块级作用域,但是在此处JavaScript并不支持块级作用域)。
ES6的对块级作用域的优化
JavaScript通过执行上下文来创建作用域(函数执行上下文对应函数作用域、全局执行上下文对应全局作用域),为了进一步说明ES6对作用域的优化,我们进一步分析执行上下文的组成:
- 变量环境
- 词法环境
然后,我们给出如下结论:
- 函数内部,通过var声明的变量,在编译阶段全都存放到变量环境中去了
- 通过let声明的变量,在编译阶段会被存放到词法环境中
- 在函数的作用域内部,通过let声明的变量并没有被存放到词法环境中
其实,在词法环境内部,也维护了一个小型栈结构,栈底是函数最外层的变量,每进入一个作用域块,就会将当前作用域块内部的变量压入栈顶;当作用域执行完毕,该作用域的信息就会从栈顶弹出。这就是词法环境。
最后总结一句话:JavaScript中的块级作用域就是通过词法环境的栈结构来实现的。
下面有一个思考题:
let myname= '极客时间'
{
console.log(myname)
let myname= '极客邦'
}
运行结果为:报错。
let声明的变量提升,并不是创建、初始化(声明)全部被提升,而是仅仅创建被提升,在初始化之前,会形成一个暂时性死区。
与通过 var 声明的有初始化值 undefined 的变量不同,通过 let 声明的变量直到它们的定义被执行时才初始化。在变量初始化前访问该变量会导致ReferenceError。该变量处在一个自块顶部到初始化处理的“暂存死区”中。
- var的创建和初始化被提升,赋值不会被提升。
- let的创建被提升,初始化和赋值不会被提升。
- function的创建、初始化和赋值均会被提升。
浏览器中的页面循环系统
消息队列与页面循环
消息队列是一种数据结构,可以存放要执行的任务,符合队列“先进先出”的特点:在队列尾部添加新任务,在队列头部取出任务。
其实,页面线程所有执行的任务都来自于消息队列,鉴于其“先进先出”的属性,有如下两个问题需要解决:
- 如何处理高优先级的任务——宏任务与微任务
- 如何解决单个任务执行时间过长的问题——JavaScript的回调功能
探究setTimeout
上面提到过,事件循环系统中存在一个消息队列,并按顺序执行消息队列中的任务,而setTimeout是一个定时器,需要延迟一定时间再执行任务,因此不能直接将要延迟执行的任务放入消息队列中。
事实上,在Chrome中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟的任务列表,包括定时器。当定时器被创建时,渲染进程将定时器的回调任务添加到延迟队列中。
定时器的注意事项
- 如果当前任务执行时间过久,会影响迟到期定时器任务的执行
- 如果setTimeout存在嵌套调用,那么系统会设置最短时间间隔为4毫秒
- 未激活的页面,setTimeout执行最小间隔是1000毫秒
- 延时执行时间有最大值
如果setTimeout设置的延迟值大于2147483647毫秒(Chrome以32位来存储延迟值,最大为2^32-1 - setTimeout设置的回调函数中的this指向问题
如果setTimeout推迟执行的回调函数是某一个对象的方法,那么该方法中的this指向全局环境window,而不是定义时方法所在的那个对象。
var name = 1
var MyObj = {
name: 2,
showName: function () {
console.log(this.name);
}
}
setTimeout(MyObj.showName, 1000); // 输出undefined
在上面的栗子中,由于showName函数内部的this指向window,而window上并没有name属性,因此呢,控制台输出undefined。
对此,一般的解决方法有两种:
- 将showName放到另一个匿名函数中执行(箭头或者ES5写法都行)
var name = 1
var MyObj = {
name: 2,
showName: function () {
console.log(this.name);
}
}
setTimeout(() => {
MyObj.showName()
}, 1000);
setTimeout( function() {
MyObj.showName()
}, 1000);
- 使用bind方法,将showName绑定到MyObj对象上
bind()方法会创建一个新的函数,在bind()被调用时,这个新函数的this会被指定为bind()的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
var name = 1
var MyObj = {
name: 2,
showName: function () {
console.log(this.name);
}
}
// 调用MyObj对象的showName方法的bind(),将该函数中的this设置为MyObj
setTimeout(MyObj.showName.bind(MyObj), 1000);
宏任务与微任务
- 宏任务:页面中大部分任务(渲染事件、用户交互、网络请求完成等)都是在主线程上执行的,这些消息队列中的任务被称为宏任务。
- 微任务:
其实JavaScript在执行一段脚本的时候,V8引擎在创建全局执行上下文的同时创建一个微任务队列,用于存放微任务,这样每一个宏任务都会关联一个微任务队列。
当宏任务中的JavaScript快执行完成时,也就在JavaScript引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
Promise
Promise是用来处理异步任务的,以XML网络请求为例,我们先来看看一般的异步操作写法:
//makeRequest 用来构造 request 对象
function makeRequest(request_url) {
let request = {
method: 'Get',
url: request_url,
headers: '',
body: '',
credentials: false,
sync: true,
responseType: 'text',
referrer: ''
}
return request
}
XFetch(makeRequest('https://time.geekbang.org/?category'),
function resolve(response) {
console.log(response)
XFetch(makeRequest('https://time.geekbang.org/column'),
function resolve(response) {
console.log(response)
XFetch(makeRequest('https://time.geekbang.org')
function resolve(response) {
console.log(response)
}, function reject(e) {
console.log(e)
})
}, function reject(e) {
console.log(e)
})
}, function reject(e) {
console.log(e)
})
上面的代码完成的大概是三次嵌套请求,混乱不堪,难以入目,总结起来问题有两个:
- 嵌套调用,回调函数内部再次执行回调函数
- 任务具有不确定性,每一个请求任务都有两种可能的结果
而Promise就解决了这两个问题。
Promise构造函数语法如下:
Promise(function(resolve, reject) {})
其中,resolve是成功的回调函数,reject是失败的回调函数。使用Promise来对上述代码进行优化:
function XFetch(request) {
function executor(resolve, reject) {
let xhr = new XMLHttpRequest()
xhr.open('GET', request.url, true)
xhr.ontimeout = function (e) {
reject(e)
}
xhr.onerror = function (e) {
reject(e)
}
xhr.onreadystatechange = function () {
if (this.readyState === 4) {
if (this.status === 200) {
resolve(this.responseText, this)
} else {
let error = {
code: this.status,
response: this.response
}
reject(error, this)
}
}
}
xhr.send()
}
return new Promise(executor)
}
也就是说,调用XFetch()函数会返回一个Promise对象,然后就可以使用Promise.then()方法继续发起请求了:
var x1 = XFetch(makeRequest('https://time.geekbang.org/?category'))
var x2 = x1.then(value => {
console.log(value)
return XFetch(makeRequest('https://www.geekbang.org/column'))
})
var x3 = x2.then(value => {
console.log(value)
return XFetch(makeRequest('https://time.geekbang.org'))
})
x3.catch(error => {
console.log(error)
})
深入Promise
Promise用法大致如下:
let p = new Promise((resolve, reject) => {
...
resolve()
...
reject()
})
p.then((value) => {
...
})
按照Promise的语法,resolve()函数的调用会触发p.then()方法,因此,可以推测resolve函数内部调用了p.then()设置的回调函数,但是,如果真的是我们所猜测的这样,resolve()执行的时候,p.then()还没有绑定回调函数,这又应当作何解释呢?
答案就是Promise采用了回调函数延迟绑定技术,即在执行resolve函数时,由于回调函数还没有绑定到then()上,因此只能推迟回调函数的执行。
下面我们简单自己实现一个支持延时回调的类Promise对象(Bromise):
function Bromise(executor) {
var onResolve_ = null
var onReject_ = null
// 给Bromise对象绑定一个then方法,类似于Promise对象的then方法
this.then = function(onResolve, onReject) {
onResolve_ = onResolve
}
// 在resolve内部必定要执行demo.then()绑定的onResolve()函数,因此让resolve中的onResolve_()函数延后执行
function resolve(value) {
setTimeout(() => {
onResolve_(value)
}, 0)
// onResolve_(value)
}
executor(resolve, null)
}
function executor(resolve, reject) {
resolve(100)
}
let demo = new Bromise(executor)
function onResolve(value) {
console.log(value);
}
demo.then(onResolve)
实际上,由于定时器的效率不是很高,因此Promise将这个定时器改造成了微任务,进一步提升了代码执行效率。
async与await——同步代码书写异步操作
先来看一段Promise异步操作代码:
fetch('https://www.geekbang.org')
.then((response) => {
console.log(response)
return fetch('https://www.geekbang.org/test')
}).then((response) => {
console.log(response)
}).catch((error) => {
console.log(error)
})
虽然和回调地狱相比,Promise已然改进了不少了,但是promise.then()方法的链式调用也显得代码有一点凌乱。因此,我们引入ES7的新语法特性——async/await——它支持我们在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力:
async function foo(){
try{
let response1 = await fetch('https://www.geekbang.org')
console.log('response1')
console.log(response1)
let response2 = await fetch('https://www.geekbang.org/test')
console.log('response2')
console.log(response2)
}catch(err) {
console.error(err)
}
}
foo()
生成器
在进一步学习async/await之前,我们需要先了解生成器函数。
生成器函数:
- 在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
- 外部函数可以通过 next 方法恢复函数的执行。
为了理解生成器函数的实现原理,这里简单介绍一个概念——协程。简单而言,协程是跑在线程上的任务,是一种比线程更加轻量级的存在。虽然一个线程上可以存在多个协程,但是同时只能执行一个协程。
通过生成器配合Promise改造代码:
function* foo() {
let response1 = yield fetch('https://www.geekbang.org')
console.log('response1')
console.log(response1);
let response2 = yield fetch('https://www.geekbang.org/test')
console.log('response2');
console.log(response2);
}
// 执行foo函数的代码,创建gen协程
let gen = foo()
// 继续执行foo函数,将主线程控制权交给gen协程
function getGenPromise(gen) {
return gen.next().value
}
getGenPromise(gen).then((response) => {
console.log('response1')
console.log(response);
return getGenPromise(gen)
}).then((response) => {
console.log('response2')
console.log(response)
})
其实,async、await的底层原理就是生成器与Promise。
神奇的语法糖
async、await两个关键字其实是语法糖,虽然看起来很简洁,实际上它们完成的工作量并不小。
- async:修饰函数,使之成为async函数,该函数通过异步执行并隐式返回Promise作为结果
- await:顾名思义,它将“等待”一个Promise对象(或者其他值),如果是Promise对象,就返回其处理结果,如果是其他值,就返回其本身。await将会暂停当前async函数的执行,等待Promise的处理完成,此时主线程控制权转交给父协程,同时Promise也会进行处理。
请看下面一个简单的示例。
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)
/*运行结果为:
0
1
3
100
2
*/
主线程中,0首先被打印;程序进入foo(),打印1,let a = await 100,则当前async函数被停止,程序继续往下执行,即打印3,同时由于100并不是Promise对象,故a就是100本身,只不过会经过Promise处理,故在3打印后,100被打印,接着再继续执行foo,打印2。
浏览器中的页面
开发者工具——网络面板
我们重点来了解一下网络面板的时间线面板(Timing)。
打开网络面板(Network),在详细列表中点击某一个任务,可以发现该项的详细信息(Headers、Preview、Response、Timing),打开Timing,我们会发现如下选项:
- Queueing:浏览器发起一个请求时,有多种原因导致其不能被立即执行而进入排队等待状态,比如
- 页面中资源具有优先级,图片、视频等资源优先级就比较低;
- 浏览器为每一个域名最多维护6个TCP连接;
- 网络进程为数据分配磁盘空间时,新的HTTP请求需要短暂地等待磁盘分配结果
- Stalled:等待排队完成后、发起连接之前,可能有一些原因导致连接过程被推迟,这就是Stalled,停滞
- Initial connection/SSL:和服务器建立连接
- Request sent:网络进程发送请求,这个阶段很快
- Waiting(TTFB):也称第一字节时间,是反映服务端响应速度的重要指标,指等待接收服务器第一个字节数据
- Content Download:陆续接收完整数据
DOM树与JavaScript
DOM树的生成简介
我们知道,在使用浏览器访问网页时,是由浏览器先向服务器发起网络请求,拿到网页源代码,然后生成各式各样的网页,供给客户端查看。从进程角度看,这涉及了两个重要的进程——网络进程、渲染进程。
网络进程和渲染进程之间会建立一个共享数据的管道,网络进程将拿到的数据放进该管道,而另一边的渲染进程则从管道的另外一端不断地读取数据,将其丢给HTML解析器,由其生成DOM。
其实,网络进程拿过来的数据是字节流形式的,可以理解为页面源代码,也就是说,HTML解析器拿到的第一手数据也是字节流,需要先将字节流转化为Token,再将Token转化为DOM节点,并添加到DOM树上。
第一部分,HTML解析器会维护一个Token栈,用以保存当前解析到的标签。
- 如果解析到的是StartToken,就将其入栈,同时为该Token创建一个DOM节点;
- 如果解析到的是一个TextToken,不用入栈,直接创建文本节点,加入到DOM树中;
- 如果解析到的是EndToken,此时与栈顶元素匹配,将栈顶元素(StartToken)弹出,表示该元素标签封闭,解析完成。
JavaScript会阻塞DOM的生成
网络进程传输给渲染进程的字节流中不一定全部都是html代码,也可能包括JavaScript脚本代码:
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
</script>
<div>test</div>
</body>
</html>
当HTML解析器解析到script标签时,会发现这是JavaScript代码,由于它有可能操作DOM,因此HTML解析器停止解析,转而由JavaScript引擎介入,在执行JavaScript代码(修改了文本节点的内容)后,HTML解析器恢复解析,继续工作。
那如果拿到的JavaScript代码以文件形式被引入呢?
<html>
<body>
<div>1</div>
<script type="text/javascript" src='foo.js'></script>
<div>test</div>
</body>
</html>
这里,JavaScript文件需要被下载,该下载过程会阻塞HTML的解析。Chrome对此做出的优化是,在渲染引擎收到字节流后,就会开启一个预解析线程,用于下载HTML中包含的JavaScript、CSS等相关文件。
CSS样式表文件会阻塞JavaScript的执行
那接下来我们再来分析另外一种情况。
<html>
<head>
<style src='theme.css'></style>
</head>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang' // 需要 DOM
div1.style.color = 'red' // 需要 CSSOM
</script>
<div>test</div>
</body>
</html>
在script标签中,出现了操作css样式的JavaScript代码,也就是说在执行JavaScript之前,还需要解析所有的CSS样式。
但是实际上,JavaScript引擎在解析JavaScript之前,并不知道JavaScript是否操作了CSSOM,因此渲染引擎在遇到JavaScript脚本时,不论其是否操作CSSOM,都会执行CSS文件下载、解析CSS,然后再执行JavaScript脚本。
思考题
如下代码,分析浏览器中的运行情况。
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
let div2 = document.getElementsByTagName('div')[1]
div2.innerText = 'time.geekbang.com'
</script>
<div>test</div>
</body>
</html>
之前提到过,JavaScript会阻塞DOM树的生成,因此HTML解析器解析到script标签的时候,会停止解析,交给JavaScript引擎。而此时DOM树中仅有一个div节点,因此div1有值,div2并不会拿到值,进而拿不到它的innerText属性,会报错。
JavaScript引擎将JavaScript代码执行完毕后,HTML解析器继续执行,将剩余的div解析出来,但是文本内容就是test,不会被改变。
CSS动画
显示器显示图像
显示器每秒固定读取一定次数前缓冲区的图像到显示器上,而显卡的职责是合成新的图像,并将图像保存到后缓冲区中,之后系统会让后缓冲区和前缓冲区互换,保证显示器能够读取最新显卡合成的图像。这样一来,通常情况下,显卡的更新频率和显示器的刷新频率一致。
帧与帧率
我们把渲染流水线生成的每一幅图片称为一帧,把渲染流水线每秒更新了多少帧称为帧率。
虚拟DOM
当DOM结构较为复杂时,不断地修改DOM树会导致重排、重绘等操作,给渲染器增加负担。因此,虚拟DOM的出现,就是想尽可能地减少对真实DOM的操作次数。
虚拟DOM用来干什么
虚拟DOM其实可以理解为真实DOM之前的一个存在,在页面数据发生变化时,先重新创建出新的虚拟DOM树,然后与旧的DOM树进行比较,将变化的节点一次性全部应用到真实DOM树上,完成修改。
从双缓存视角看虚拟DOM
缓冲区其实是一个方便计算机快速处理的机制。但有时候,如果将数据一部分一部分地写入缓冲区,就会导致页面上的信息一块一块地显示出来,带给用户不良的体验。因此,双缓存发挥了优势:
先将计算的中间结果存放在另一个缓冲区中,待全部计算结束,缓冲区已经存储了完整的数据之后(比如完整的图像数据信息),一次性复制到显示缓冲区,这样就会使得整个数据输出十分稳定。
这里的虚拟DOM就是一种类似双缓存的机制。
MVC模式
- M:model,模型,可以理解为数据
- V:view,视图,可以理解为人眼看到的画面
- C:controller,控制器,是连接model与view的枢纽
通过MVC模式来看,
- 虚拟DOM实际上是MVC的视图部分,因为它将涉及后续视图的更新
- 控制器用于监视DOM的变化,一旦DOM变化了,控制器就会通知模型,让其更新(update)数据
- 模型数据更新好后,控制器会通知视图,告知(notify)其数据模型已经变化
- 视图收到消息,会根据新的数据来生成新的虚拟DOM
- 新的虚拟DOM一旦生成,会与旧的虚拟DOM进行比较,将变化的节点应用到真实DOM上
WebComponent——组件化
组件可以理解为具有某一个特定功能的“工具”,它高内聚、低耦合,在多人协作开发中可以被方便地复用,极大地提高了开发效率。
对于大多数编程语言,都可以实现组件化——凭借其类、作用域等特性,实现模块的封装等等,JavaScript也不例外。但是,在前端开发中,仍然存在着阻碍组件化开发的因素。
- CSS对标签属性的控制:css代码的标签选择器对全局标签进行选择,并设置属性,这样一来,不同开发人员编写的某一元素的样式可能会被全部修改。
- DOM的阻碍:全局DOM只有一个,但是任意一个地方都可以修改DOM。
对此,WebComponent提出了解决思路,提供了对局部视图封装能力——Web Components技术,它由三项主要技术组成:
- 自定义元素(Custom elements):一组JavaScript API
- 影子DOM(Shadow DOM):一组JavaScript API,将封装的影子DOM树附加到元素(与主文档DOM分开呈现),这样可以保持元素功能私有
- HTML模板(HTML templates)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<template id="geekbang-t">
<style>
p {
background-color: brown;
color: coral;
}
div {
width: 200px;
background-color: bisque;
border: 3px solid chocolate;
border-radius: 10px;
}
</style>
<div>
<p>time.geekbang.org</p>
<p>time1.geekbang.org</p>
</div>
<script>
function foo() {
console.log('inner log');
}
</script>
</template>
<script>
class GeekBang extends HTMLElement {
constructor() {
super()
// 这里的content就是template下所有的html内容
const content = document.querySelector('#geekbang-t').content
const shadowDOM = this.attachShadow({
mode: 'open'
})
console.log(shadowDOM);
shadowDOM.appendChild(content.cloneNode(true))
}
}
customElements.define('geek-bang', GeekBang)
</script>
<geek-bang></geek-bang>
<div>
<p>time.geekbang.org</p>
<p>time1.geekbang.org</p>
</div>
<geek-bang></geek-bang>
</body>
</html>
要使用Web Component,通常要实现下面三个步骤:
使用template属性来创建模板。实际上,模板元素内容并不会被渲染到页面上,也就是说template节点不会出现在布局树中。
创建一个GeekBang类。这个类的构造函数中完成三件事:
- 查找模板内容
- 创建影子DOM
- 再将模板添加到影子DOM上
影子DOM,将模板中的内容与全局DOM和CSS进行隔离,实现元素和样式的私有化。
上面两步实现后,就可以像正常使用HTML元素一样使用该元素,如\
\</geek-bang>
浏览器中的网络
HTTP性能优化
HTTP是浏览器最重要且使用最多的协议,是浏览器和服务器之间的通信语言。随着互联网的发展,HTTP也在持续进化。
HTTP/0.9
用途较为简单,主要用于学术交流,需求也很简单,只需要在网络之间传递HTML超文本内容,故称“超文本传输协议”。
- 客户端根据IP地址、端口、服务器建立TCP连接,涉及TCP协议三次握手过程;
- TCP连接建立完成后,发送应该GET请求行信息,如
GET /index.html
用来获取index.html; - 服务器接收请求信息之后,读取对应的HTML文件,并将数据以ASCII字符流返回给客户端;
- 传输完成,连接断开。
HTTP/1.0
万维网的高速发展带来了很多新需求,HTTP/0.9已经不再适用于新的网络发展了。新的需求包括:
- 浏览器中展示的不单单是HTML文件了,还有JavaScript、CSS、图片、音视频等不同类型的文件。
- 文件格式不仅仅局限于ASCII编码,还有很多其他类型编码的文件。
为了支持对多种类型文件的下载,HTTP/1.0引入了请求头和响应头,也就是说,在HTTP发送请求时,会带上请求头信息,服务器返回数据时,会先返回响应头信息。
HTTP/1.0通过请求头、响应头支持多种不同类型的数据的原理:
HTTP在发起请求时候会通过HTTP请求头告诉服务器它期待服务器
- 返回什么类型的文件
- 采取什么形式的压缩
- 提供什么语言的文件以及编码
accept: text/html // 期望浏览器返回html类型文件 accept-encoding: gzip, deflate, br // 期望浏览器采用gzip、deflate或者br压缩方式 accept-Charset: ISO-8859-1,utf-8 // 期望返回的文件编码是UTF-8或者ISO-8859-1 accept-language: zh-CN,zh // 期望页面的优先语言是中文
服务器接收到浏览器发送过来的请求头信息之后,就会根据请求头的信息准备响应数据,并将相关信息以响应头形式反馈给客户端浏览器(按要求做事情,并给出反馈)。
content-encoding: br // 服务器采用了br压缩方法 content-type: text/html; charset=UTF-8 // 服务器返回html文件,编码类型为UTF-8
拿到如上的服务器响应头信息后,最终浏览器需要根据响应头的信息来处理数据。
此外,HTTP/1.0还引入了很多其他的特性,比如
- 状态码——通过响应行的方式通知浏览器,服务器最终处理情况
- Cache机制——缓存已经下载过的数据,减轻服务器压力
- 用户代理字段——服务器需要统计客户端的基础信息
HTTP/1.1
相比于HTTP/1.0,1.1有如下方面的改进:
- 改进持久连接
在HTTP/1.0中,一次HTTP通信,需要经历建立TCP连接、传输HTTP数据和断开TCP连接三个阶段。但是随着单个页面中文件数量增多,每一次下载文件都需要重复这三个步骤,无疑会增加大量开销。
对此,HTTP/1.1中增加了持久连接,在应该TCP连接上可以传输多个HTTP请求,只要浏览器或者服务器没有断开连接,该TCP连接会一直保持。
另外,浏览器为每个域名最多同时维护6个TCP持久连接。
- 不成熟的HTTP管线化
TCP通道中一旦某个请求因为某些原因没有及时返回,就会阻塞后面的所有请求——队头阻塞问题。对此,HTTP/1.1中试图引入管线化技术来解决队头阻塞问题。
- 引入客户端Cookie与安全机制
但是,HTTP/1.1仍然有很大的不足,核心问题在于对带宽的利用率并不理想。
带宽,是指每秒最大能够发送或者接收的字节数。每秒能够发送的最大字节数称为上行带宽,每秒能够接收的最大字节数称为下行带宽。
原因主要有三点:
- TCP慢启动。TCP连接建立之后,会进入发送数据状态,刚开始TCP协议会采用应该非常慢的速度去发送数据。
- 同时开启多条TCP连接会竞争固定的带宽。
- HTTP/1.1的队头阻塞问题。
HTTP/2
那么如何解决HTTP/1.1的问题呢?
我们先分析上面的三点原因,会发现,第一条、第二条是TCP本身的机制引起的,第三条是HTTP/1.1的机制导致的。
因此,HTTP/2的思路就是一个域名只使用一个TCP长连接来传输数据,这样整个页面资源的下载过程只需要一次慢启动,也不会有多个TCP连接竞争问题。
此外,HTTP/2最核心的功能是多路复用机制——引入二进制分帧层,实现了资源的并行传输。
当然,HTTP/2还有一些其他特性,比如
- 可以设置请求的优先级:在发送请求时,可以标上该请求的优先级
- 服务器推送:直接将数据提前推送到浏览器
- 头部压缩:对请求头和响应头都进行了压缩
浏览器安全
Web页面安全
Web页面安全的意义在于可以保障我们的隐私和数据的安全。
Web页面安全中最基础、最核心的安全策略——同源策略。
同源是针对URL而言的,如果两个 URL 的协议、域名和端口都相同,我们就称这两个 URL 同源。比如:
https://time.geekbang.org/?category=1
https://time.geekbang.org/?category=0
而同源策略就是,两个不同的源之间如果想要互相访问资源或者操作DOM,就会有一套基础的安全策略的制约。
同源策略会隔离不同源的DOM、页面数据和网络通信,进而实现Web页面的安全性。
但是,安全性和便利性是相互对立的。对此浏览器出让一些安全性来满足灵活性,以方便Web开发。浏览器出让了同源策略的哪些安全性呢?
- 页面中可以嵌入第三方资源
最初的浏览器都是支持外部引用资源文件的,不过这也带来了很多问题。最多的问题是,浏览器的首页内容会被一些恶意程序劫持,其中,最常见的是恶意程序通过各种途径往HTML文件中插入恶意脚本。
对此,我们引入了CSP策略来加以限制。
- 跨域资源共享和跨文档消息机制
跨域资源共享,CORS,允许进行跨域访问控制;
跨文档消息机制,允许两个不同源的DOM之间进行通信。
跨站脚本攻击——XSS攻击
XSS攻击以及危害
首先我们需要知道什么是XSS。XSS,Cross Site Scripting,为了区分于CSS,故称XSS,意为“跨站脚本”。所谓XSS攻击,指黑客往HTML文件中或者DOM中注入恶意脚本,从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段。
HTML文件中注入恶意代码会给用户带来很多危害。比如
窃取Cookie信息。
恶意的JavaScript通过document.cookie来获取Cookie信息,然后通过XMLHttpRequest或者Fetch加上CORS功能将数据发送给恶意服务器。恶意服务器一旦拿到用户的Cookie信息,也即掌握了用户的账户等隐私信息,几乎可以为所欲为。
监听用户行为。
恶意JavaScript通过addEventListener接口来监听键盘事件,比如可以获取用户输入的信用卡等信息。
伪造假的登录窗口。
恶意代码修改DOM,属于欺骗用户行为。
生成浮窗广告。
恶意脚本的注入
要想避免站点被注入恶意脚本,就要知道有哪些常见的注入方式:
- 存储型XSS攻击
- 反射型XSS攻击
- 基于DOM的XSS攻击
存储型XSS攻击
- 黑客首先利用站点漏洞,将一段恶意JavaScript代码提交到网站的数据库中
- 用户向网站请求包含了恶意JavaScript脚本的页面
- 用户浏览该页面,恶意脚本上传用户的Cookie等信息
反射型xss攻击
恶意JavaScript脚本属于用户发送给网站请求中的一部分,随后网站又把JavaScript脚本返回给用户了。
Web 服务器不会存储反射型 XSS 攻击的恶意脚本,这是和存储型 XSS 攻击不同的地方。
基于DOM的XSS攻击
不牵涉页面Web服务器,具体来讲,黑客通过各种手段将恶意脚本注入用户的页面中,比如网络劫持等。
阻止XSS攻击
无论是何种类型的 XSS 攻击,它们都有一个共同点,那就是首先往浏览器中注入恶意脚本,然后再通过恶意脚本将用户信息发送至黑客部署的恶意服务器上。所以要阻止 XSS 攻击,我们可以通过阻止恶意 JavaScript 脚本的注入和恶意消息的发送来实现。
服务器对输入脚本进行过滤或者转码
充分利用CSP
CSP,Content Security Policy,即内容安全策略,其主要目标是减少和报告XSS攻击。其实质就是白名单制度,开发者明确告诉客户端,哪些外部资源可以被加载和执行(提供白名单)。
使用HttpOnly属性
使用HttpOnly标记的Cookie只能使用在HTTP请求过程中,无法通过JavaScript来读取(document.cookie)。
CSRF攻击
CSRF,Cross-site request forgery,又称“跨站请求伪造”,指黑客引诱用户打开黑客的网站,在黑客的网站中,利用用户的登录状态发起的跨站请求。
页面安全和操作系统安全
初代浏览器的架构
最开始的阶段浏览器是单进程的,JavaScript执行、网络加载、UI绘制等过程都是在同一个进程中执行的,如此简单的结构却也带来了很多问题。首先,就是单进程架构的浏览器并不稳定。
浏览器进程中任意一个功能出现异常,都有可能影响到整个浏览器。而如果浏览器存在漏洞,黑客有可能通过恶意的页面向浏览器中注入恶意程序,不仅如此,还可以穿透浏览器,在用户的操作系统上悄悄地安装恶意软件、监听用户键盘输入以及读取硬盘上的文件内容。
现代浏览器的两个核心模块
现代浏览器采用了多进程架构,将渲染进程和浏览器主进程分离开来。浏览器被划分为
- 浏览器内核
- 网络进程
- 浏览器主进程
- GPU进程
- 渲染内核
- 渲染进程
当我们打开一个页面时,这两个模块就会互相配合。
- 首先,浏览器内核会下载所有的网络资源,下载后的资源会通过IPC将其提交给渲染进程(IPC是浏览器内核和渲染进程的通信通道);
- 然后渲染进程会对这些资源进行解析、绘制等操作,最终生成一幅图片;
- 这张生成的图片会被提交给浏览器内核模块,由浏览器内核模块负责显示这张图片。
安全沙箱
渲染进程执行下载的各种网络资源时,需要十分小心,否则容易执行恶意程序,进而被黑客攻击。对此,我们需要在渲染进程和操作系统之间建立一道墙,即使渲染进程由于存在漏洞被黑客攻击,但由于这道墙的存在,黑客获取不到渲染进程之外的任何操作权限——这道墙就是安全沙箱。