瀏覽器工作原理 學習筆記

學習地址:

瀏覽器工作原理與實踐

瀏覽器架構演化

進程:一個程序的運行實例。詳細解釋就是,啓動一個程序的時候,操作系統會爲該程序創建一塊內存,用來存放代碼、運行中的數據和一個執行任務的主線程,我們把這樣的一個運行環境叫進程
線程:依附於進程的,在進程中使用多線程並行處理能提升運算效率
進程與線程關係:
1.進程中的任意一線程執行出錯,都會導致整個進程的崩潰。
2.線程之間共享進程中的數據。
3.當一個進程關閉之後,操作系統會回收進程所佔用的內存
4.進程之間的內容相互隔離。
**單進程瀏覽器:**不穩定(插件和渲染引擎),不流暢(同一時刻只有一個模塊運行),不安全(插件和腳本)

早期多進程架構

image.png

目前多進程架構image.png

缺點:佔用資源多(喫內存),架構複雜(各模塊耦合性高、擴展性差)

面向服務的架構(Service Oriented Architecture)

各種模塊會被重構成獨立的服務(Service),每個服務(Service)都可以在獨立的進程中運行,訪問服務(Service)必須使用定義好的接口,通過 IPC 來通信,從而構建一個更內聚、松耦合、易於維護和擴展的系統,更好實現 Chrome 簡單、穩定、高速、安全的目標。

image.png

TCP/IP

IP

(Internet Protocol 網際協議)**😗*把數據包送達目的主機(靠IP辨認)
image.png

UDP

(User Datagram Protocol 用戶數據包協議): 把數據包送達應用程序(靠端口號辨認目的地)
特點:不能保證數據可靠性,速度快
image.png

TCP

(Transmission Control Protocol):把數據完整地送達應用程序(一種面向連接的、可靠的、基於字節流的傳輸層通信協議)
特點:提供重傳機制防止數據包丟失;引入數據包排序機制,確保數據恢復
image.png

TCP連接過程

image.png
建立連接:三次握手
數據傳輸:接收端需要對每個數據包進行確認操作
斷開連接:四次揮手

HTTP

建立在TCP連接基礎之上,一種允許瀏覽器向服務器獲取資源的協議。

瀏覽器發起HTTP請求

1.構建請求:構建請求行信息

GET /index.html HTTP1.1

2.查找緩存:查看瀏覽器緩存是否有資源副本
3.準備IP和端口:請求DNS返回域名指向的IP(DNS也有緩存)
image.png
4.等待TCP隊列:同一域名同時最多隻能建立6個TCP連接,超過的連接會處於等待狀態,直到前面的請求完成
5.建立TCP連接:
6.發送HTTP請求:
image.png

服務器處理HTTP請求

1.返回請求:
image.png
2.斷開連接:一般服務器返回數據就會關閉TCP連接,除非

Connection:Keep-Alive 

保持 TCP 連接可以省去下次請求時需要建立連接的時間,提升資源加載速度。
3.重定向:

image.png

瀏覽器緩存

image.png

登錄狀態保持

image.png

輸入URL到頁面展示

image.png
1.用戶輸入:生成請求的URL(搜索內容拼接或直接就是URL)
2.URL請求過程:瀏覽器進程通過進程通信(IPC)把URL給網絡進程,網絡進程先找緩存,有緩存返回無緩存請求
2.1重定向
2.2響應數據類型處理:Content-Type
image.png
image.png
3.準備渲染進程:默認每一個頁面一個渲染進程,但如果從一個頁面打開另一個新頁面且二者屬於同一站點(根域名和協議都相同)那麼新頁面複用父頁面的渲染進程(即process-per-site-instance)
4.提交文檔:此處文檔指URL請求的響應體數據

  • 提交文檔的消息是瀏覽器進程發出,渲染進程接收到消息後會和網絡進程建立傳輸數據的管道
  • 文檔傳輸完成後渲染進程會返回“確認提交”的消息給瀏覽器進程
  • 瀏覽器進程在收到“確認提交”的消息後,會更新瀏覽器界面狀態(安全狀態、地址欄URL、歷史信息等)並更新web頁面

image.png
5.渲染階段:渲染進程開始頁面解析和子資源的加載

HTML、CSS、JS如何變成頁面

image.png

渲染流水線

image.png

構建DOM樹:

image.png

樣式計算:

1.把CSS轉換爲瀏覽器能夠理解的結構(styleSheets)
image.png
2.轉換樣式表中的屬性值,使其標準化
image.png
3.計算出DOM樹中每個節點的具體樣式(根據繼承規則和層疊規則)
image.png
image.png

佈局階段:

計算DOM樹中可見元素的幾何位置
1.創建佈局樹
image.png
2.佈局計算

分層

:爲特定的節點生成專用的圖層,並生成一棵對應的圖層樹(LayerTree),圖層疊加合成最終頁面(不是每個節點都包含一個圖層,如果一個節點沒有對應的層,那該節點姐從屬於父節點所在圖層)
創建型層的要求:
1)擁有層疊上下文屬性的元素會被提升爲單獨的一層(關於層疊上下文
image.png
2)需要裁剪(clip)的地方也會被創建爲圖層

<style>
      div {
            width: 200;
            height: 200;
            overflow:auto;
            background: gray;
        } 
</style>
<body>
    <div >
        <p> 所以元素有了層疊上下文的屬性或者需要被剪裁,那麼就會被提升成爲單獨一層,你可以參看下圖:</p>
        <p> 從上圖我們可以看到,document 層上有 A 和 B 層,而 B 層之上又有兩個圖層。這些圖層組織在一起也是一顆樹狀結構。</p>
        <p> 圖層樹是基於佈局樹來創建的,爲了找出哪些元素需要在哪些層中,渲染引擎會遍歷佈局樹來創建層樹(Update LayerTree)。</p> 
    </div>
</body>

上面文字顯示區域會超出div,產生剪裁,渲染引擎會把剪裁文字內容的一部分用於顯示在div區域
渲染引擎會爲文字部分單獨創建一個層,如果出現滾動條,滾動條也會被提升爲單獨的層
image.png
image.png

圖層繪製

:把一個圖層繪製拆分Wie很多小的繪製指令,按順序組成待繪製列表
image.png

柵格化

繪製操作實際由渲染引擎中的合成線程來完成
image.png
視口:屏幕上頁面的可見區域(ViewPort)
image.png
合成線程會將圖層分爲圖塊(tile),通常爲256X256或512X512。按照視口附近的圖塊來優先生成位圖,實際生成位圖的操作由柵格化(將圖塊轉化爲位圖)來執行。圖塊是柵格化執行的最小單位。渲染進程維護了一個柵格化的線程池,所有的圖塊柵格化都是在線程池內執行的
image.png
柵格化都會使用GPU來加速(快速柵格化),生成的位圖保存在GPU內存中
image.png

合成和顯示:

圖塊光柵化結束,合成線程生成繪製圖塊的命令——“DrawQuad”,將該命令交給瀏覽器進程。瀏覽器進程中viz組件接收命令根據命令將頁面內容繪製到內存中,再顯示到屏幕上。
image.png

相關概念

重排

:更新了元素的幾何屬性(重排需要更新完整的流水線,開銷最大)
image.png

重繪

:更新元素的繪製屬性(不進行佈局和分層,效率比重排高)
image.png

直接合成

**:**修改既不佈局也不繪製的屬性,渲染引擎會跳過佈局和繪製只執行後續的合成操作。
image.png

JS執行機制

變量提升

JS代碼執行中,JS引擎把變量的聲明部分和函數的聲明部分提升到代碼開頭的行爲。變量提升後,會給變量設置默認值爲undefined
image.png

JS代碼執行流程

實際上變量和函數聲明在代碼裏的位置是不會改變的,而是在編譯階段被JavaScript引擎放入內存中。
image.png

1.編譯階段

編譯結果:執行上下文(Execution context)和可執行代碼
執行上下文是JS執行一段代碼時的運行環境。執行上下文中存在一個變量環境的對象(Viriable Environment),該對象中保存了變量提升的內容。
image.png

2.執行階段

  • 當執行到 showName 函數時,JavaScript 引擎便開始在變量環境對象中查找該函數,由於變量環境對象中存在該函數的引用,所以 JavaScript 引擎便開始執行該函數,並輸出“函數 showName 被執行”結果。
  • 接下來打印“myname”信息,JavaScript 引擎繼續在變量環境對象中查找該對象,由於變量環境存在 myname 變量,並且其值爲 undefined,所以這時候就輸出 undefined。
  • 接下來執行第 3 行,把“極客時間”賦給 myname 變量,賦值後變量環境中的 myname 屬性值改變爲“極客時間”

調用棧

用來管理函數調用關係的一種數據結構

函數調用

即運行一個函數——即,函數名()

var a = 2
function add(){
  var b = 10
  return  a+b
}
add()

在執行到add()之前,JS引擎會創建全局執行上下文,包含聲明的函數和變量
image.png
首先,從全局執行上下文中,取出add函數代碼。其次,對add函數進行編譯,並創建該函數的執行上下文和可執行代碼。最後,執行代碼,輸出結果。
image.png

image.png

JS調用棧

在執行上下文創建好後,JS引擎會將執行上下文壓入棧中(執行上下文棧或調用棧)

var a = 2
function add(b,c){
  return b+c
}
function addAll(b,c){
  var d = 10
  result = add(b,c)
  return  a+result+d
}
addAll(3,6)

1.創建全局上下文,並將其壓入棧底
image.png
執行全局代碼
image.png
2.調用addAll函數
image.png
執行d=10
3.執行到add函數
image.png
add函數返回,並出棧
image.png
addAll出棧
image.png

調用棧的使用

1.利用瀏覽器查看調用棧的信息:
加斷點,call stack
image.png
console.trace()
image.png
2.棧溢出(Stack Overflow)
當入棧的執行上下文超過一定的數目,JS引擎就會報錯,即棧溢出
image.png

塊級作用域

作用域

程序中定義變量的區域,該位置決定了變量的生命週期。作用域就是變量與函數的可訪問範圍,即作用域控制着變量和函數的可見性和生命週期。

變量提升帶來的問題

1.變量容易在不被察覺的情況下被覆蓋掉
2.本應該銷燬的變量沒有被銷燬

ES6的解決方案

引入let和const關鍵字

function letTest() {
  let x = 1;
  if (true) {
    let x = 2;  // 不同的變量
    console.log(x);  // 2
  }
  console.log(x);  // 1
}

JS如何支持塊級作用域

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()

1.編譯並創建執行上下文
image.png

  • 通過var聲明的變量,在編譯階段會被存放到變量環境裏面
  • 通過let聲明的變量,在編譯階段會被存放到詞法環境(Lexical Environment)中
  • 函數作用域內部,通過let聲明的變量並沒有被存放到詞法環境中

2.繼續執行代碼
image.png
在詞法環境內維護一個小型棧結構,棧底是函數最外層的變量。當執行到console.log(a)時,需要在詞法環境和變量環境中查找變量a的值,沿詞法環境棧頂向下查詢,如果在詞法環境中的某個塊中查找到了就返回,如果沒有就在變量環境中查找。
image.png
當作用域塊執行結束之後,其內部定義的變量就會從詞法環境的棧頂彈出

image.png

擴展

報錯,因爲let在作用域內的聲明被提升但是初始化沒有被提上去,會形成暫時性死區

let myname= '極客時間'
{
  console.log(myname) 
  let myname= '極客邦'
}

作用域鏈和閉包

function bar() {
    console.log(myName)
}
function foo() {
    var myName = " 極客邦 "
    bar()
}
var myName = " 極客時間 "
foo()

image.png

作用域鏈

每個執行上下文的變量環境中,都包含了一個外部引用(outer),用來指向外部的執行上下文。當一段代碼使用了一個變量時,JS引擎先在當前的執行上下文中查找該變量,如果沒有找到,JS引擎會繼續在outer所指的執行上下文中查找。
image.png
從圖中可以看出,bar 函數和 foo 函數的 outer 都是指向全局上下文的,這也就意味着如果在 bar 函數或者 foo 函數中使用了外部變量,那麼 JavaScript 引擎會去全局執行上下文中查找。我們把這個查找的鏈條就稱爲作用域鏈

詞法作用域

JS執行過程中,其作用域鏈是由詞法作用域決定的。詞法作用域就是指作用域是由代碼中函數聲明的位置來決定的,所以詞法作用域是靜態的作用域,通過它就能夠預測代碼在執行過程中如何查找標識符。
image.png
詞法作用域是在代碼階段就決定好的,和函數是怎麼調用的沒有關係

塊級作用域中的變量查找

function bar() {
    var myName = " 極客世界 "
    let test1 = 100
    if (1) {
        let myName = "Chrome 瀏覽器 "
        console.log(test)
    }
}
function foo() {
    var myName = " 極客邦 "
    let test = 2
    {
        let test = 3
        bar()
    }
}
var myName = " 極客時間 "
let myAge = 10
let test = 1
foo()

image.png

閉包

function foo() {
    var myName = " 極客時間 "
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(" 極客邦 ")
bar.getName()
console.log(bar.getName())

image.png
根據詞法作用域鏈的規則,內部函數getName和setName總是可以訪問他們的外部函數foo中的變量。所以當innerBar對象返回giee全局變量bar時,雖然foo函數已經執行結束,但是getName和setName函數依然可以使用foo函數中的變量myName和test1.
image.png
foo 函數執行完成之後,其執行上下文從棧頂彈出了,但是由於返回的 setName 和 getName 方法中使用了 foo 函數內部的變量 myName 和 test1,所以這兩個變量依然保存在內存中。這像極了 setName 和 getName 方法背的一個專屬揹包,無論在哪裏調用了 setName 和 getName 方法,它們都會揹着這個 foo 函數的專屬揹包。
之所以是
專屬
揹包,是因爲除了 setName 和 getName 函數之外,其他任何地方都是無法訪問該揹包的,我們就可以把這個揹包稱爲 foo 函數的閉包
在 JavaScript 中,根據詞法作用域的規則,內部函數總是可以訪問其外部函數中聲明的變量,當通過調用一個外部函數返回一個內部函數後,即使該外部函數已經執行結束了,但是內部函數引用外部函數的變量依然保存在內存中,我們就把這些變量的集合稱爲閉包。比如外部函數是 foo,那麼這些變量的集合就稱爲 foo 函數的閉包
image.png

閉包怎麼回收

通常,如果引用閉包的函數是一個全局變量,那麼閉包會一直存在直到頁面關閉;但如果這個閉包以後不再使用的話,就會造成內存泄漏。
如果引用閉包的函數是個局部變量,等函數銷燬後,在下次 JavaScript 引擎執行垃圾回收時,判斷閉包這塊內容如果已經不再被使用了,那麼 JavaScript 引擎的垃圾回收器就會回收這塊內存。
所以在使用閉包的時候,你要儘量注意一個原則:如果該閉包會一直使用,那麼它可以作爲全局變量而存在;但如果使用頻率不高,而且佔用內存又比較大的話,那就儘量讓它成爲一個局部變量

this

var bar = {
    myName:"time.geekbang.com",
    printName: function () {
        console.log(myName)
    }    
}
function foo() {
    let myName = " 極客時間 "
    return bar.printName
}
let myName = " 極客邦 "
let _printName = foo()
_printName()
bar.printName()

此處輸出“極客邦”,爲了輸出bar內部的myName,引入了this機制
爲了在對象內部的方法中使用對象內部的屬性是一個非常普遍的需求

this是什麼

image.png
this是和執行上下文綁定的,即每一個執行上下文都有一個this

#### 全局上下文中的this
全局執行上下文中的 this 是指向 window 對象的。

函數執行上下文中的this

默認情況下調用一個函數,其執行上下文中的 this 也是指向 window 對象的可以進行設置
1.通過函數的 call 方法設置(還有bind和apply)

let bar = {
  myName : " 極客邦 ",
  test1 : 1
}
function foo(){
  this.myName = " 極客時間 "
}
foo.call(bar)
console.log(bar)
console.log(myName)

image.png
2. 通過對象調用方法設置
使用對象來調用其內部的一個方法,該方法的 this 是指向對象本身的

var myObj = {
  name : " 極客時間 ", 
  showThis: function(){
    console.log(this)
  }
}
myObj.showThis()

在全局環境中調用一個函數,函數內部的 this 指向的是全局變量 window
image.png
3. 通過構造函數中設置

function CreateObj(){
  this.name = " 極客時間 "
}
var myObj = new CreateObj()

其實,當執行 new CreateObj() 的時候,JavaScript 引擎做了如下四件事:

  • 首先創建了一個空對象 tempObj;
  • 接着調用 CreateObj.call 方法,並將 tempObj 作爲 call 方法的參數,這樣當 CreateObj 的執行上下文創建時,它的 this 就指向了 tempObj 對象;
  • 然後執行 CreateObj 函數,此時的 CreateObj 函數執行上下文中的 this 指向了 tempObj 對象;
  • 最後返回 tempObj 對象。

this的缺點及解決方案

嵌套函數中的this不會繼承外層this

var myObj = {
  name : " 極客時間 ", 
  showThis: function(){
    console.log(this)
    function bar(){console.log(this)}
    bar()
  }
}
myObj.showThis()

函數 bar 中的 this 指向的是全局 window 對象,而函數 showThis 中的 this 指向的是 myObj 對象

var myObj = {
  name : " 極客時間 ", 
  showThis: function(){
    console.log(this)
    var self = this
    function bar(){
      self.name = " 極客邦 "
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

上面方法的本質是把this體系轉換爲了作用域體系
也可以使用 ES6 中的箭頭函數來解決這個問題

var myObj = {
  name : " 極客時間 ", 
  showThis: function(){
    console.log(this)
    var bar = ()=>{
      this.name = " 極客邦 "
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

ES6 中的箭頭函數並不會創建其自身的執行上下文,所以箭頭函數中的 this 取決於它的外部函數。

普通函數中的 this 默認指向全局對象 window

在默認情況下調用一個函數,其執行上下文中的 this 是默認指向全局對象 window 的。如果要讓函數執行上下文中的 this 指向某個對象,最好的方式是通過 call 方法來顯示調用。
可以通過設置 JavaScript 的“嚴格模式”來解決。在嚴格模式下,默認執行一個函數,其函數的執行上下文中的 this 值是 undefined,這就解決上面的問題了。

總結

  1. 當函數作爲對象的方法調用時,函數中的 this 就是該對象;
  2. 當函數被正常調用時,在嚴格模式下,this 值是 undefined,非嚴格模式下 this 指向的是全局對象 window;
  3. 嵌套函數中的 this 不會繼承外層函數的 this 值。

V8工作原理

JS中數據如何存儲在內存中

JS是什麼類型的語言

在使用之前就需要確認其變量數據類型的稱爲靜態語言。相反地,我們把在運行過程中需要檢查數據類型的語言稱爲動態語言。JS爲動態語言。
支持隱式類型轉換的語言稱爲弱類型語言,不支持隱式類型轉換的語言稱爲強類型語言。在這點上,C 和 JavaScript 都是弱類型語言。

image.png
弱類型,意味着你不需要告訴 JavaScript 引擎這個或那個變量是什麼數據類型,JavaScript 引擎在運行代碼的時候自己會計算出來。
動態,意味着你可以使用同一個變量保存不同類型的數據。

JS數據類型

var bar
console.log(typeof bar)  //undefined
bar = 12 
console.log(typeof bar) //number
bar = " 極客時間 "
console.log(typeof bar)//string
bar = true
console.log(typeof bar) //boolean
bar = null
console.log(typeof bar) //object
bar = {name:" 極客時間 "}
console.log(typeof bar) //object

image.png
注意:
1.使用 typeof 檢測 Null 類型時,返回的是 Object。遺留bug()
2.Object 類型比較特殊,它是由上述 7 種類型組成的一個包含了 key-value 對的數據類型。
3.把前面的 7 種數據類型稱爲原始類型,把最後一個對象類型稱爲引用類型

內存空間

image.png
JS內存模型
在 JavaScript 的執行過程中, 主要有三種類型內存空間,分別是代碼空間、棧空間堆空間

棧空間和堆空間

棧空間即調用棧,是來存儲執行上下文的。

function foo(){
    var a = " 極客時間 "
    var b = a
    var c = {name:" 極客時間 "}
    var d = c
}
foo()

執行到第三行
image.png
第4行,對象類型存放在堆空間,在棧空間只保留了對象的引用地址。
image.png
原始類型的數據值都是直接保存在“棧”中的,引用類型的值是存放在“堆”中的

爲什麼要區分堆棧

因爲 JavaScript 引擎需要用棧來維護程序執行期間上下文的狀態,如果棧空間大了話,所有的數據都存放在棧空間裏面,那麼會影響到上下文切換的效率,進而又影響到整個程序的執行效率
。所以通常情況下,棧空間都不會設置太大,主要用來存放一些原始類型的小數據。而引用類型的數據佔用的空間都比較大,所以這一類數據會被存放到堆中,堆空間很大,能存放很多大的數據,不過缺點是分配內存和回收內存都會佔用一定的時間。
image.png
在 JavaScript 中,賦值操作和其他語言有很大的不同,原始類型的賦值會完整複製變量值,而引用類型的賦值是複製引用地址
d=c

image.png

閉包的內存模型

function foo() {
    var myName = " 極客時間 "
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(" 極客邦 ")
bar.getName()
console.log(bar.getName())
  1. 當 JavaScript 引擎執行到 foo 函數時,首先會編譯,並創建一個空執行上下文。
  2. 在編譯過程中,遇到內部函數 setName,JavaScript 引擎還要對內部函數做一次快速的詞法掃描,發現該內部函數引用了 foo 函數中的 myName 變量,由於是內部函數引用了外部函數的變量,所以 JavaScript 引擎判斷這是一個閉包,於是在堆空間創建換一個“closure(foo)”的對象(這是一個內部對象,JavaScript 是無法訪問的),用來保存 myName 變量。
  3. 接着繼續掃描到 getName 方法時,發現該函數內部還引用變量 test1,於是 JavaScript 引擎又將 test1 添加到“closure(foo)”對象中。這時候堆中的“closure(foo)”對象中就包含了 myName 和 test1 兩個變量了。
  4. 由於 test2 並沒有被內部函數引用,所以 test2 依然保存在調用棧中。

執行到return innerBar時
image.png
當 foo 函數執行結束之後,返回的 getName 和 setName 方法都引用“clourse(foo)”對象,所以即使 foo 函數退出了,“clourse(foo)”依然被其內部的 getName 和 setName 方法引用。所以在下次調用bar.setName或者bar.getName時,創建的執行上下文中就包含了“clourse(foo)”。
產生閉包的核心有兩步:第一步是需要預掃描內部函數;第二步是把內部函數引用的外部變量保存到堆中。

垃圾回收

不同語言的垃圾回收策略

手動回收自動回收
C/C++ 就是使用手動回收策略,何時分配內存、何時銷燬內存都是由代碼控制的

// 在堆中分配內存
char* p =  (char*)malloc(2048);  // 在堆空間中分配 2048 字節的空間,並將分配後的引用地址保存到 p 中
 
 // 使用 p 指向的內存
 {
   //....
 }
 
// 使用結束後,銷燬這段內存
free(p);
p = NULL

如果這段數據已經不再需要了,但是又沒有主動調用 free 函數來銷燬,那麼這種情況就被稱爲內存泄漏
JavaScript、Java、Python 等語言,產生的垃圾數據是由垃圾回收器來釋放的,並不需要手動通過代碼來釋放。

調用棧中的數據是如何回收的

function foo(){
    var a = 1
    var b = {name:" 極客邦 "}
    function showName(){
      var c = 1
      var d = {name:" 極客時間 "}
    }
    showName()
}
foo()

當執行到第6行時,
image.png有一個記錄當前執行狀態的指針(稱爲 ESP),指向調用棧中 showName 函數的執行上下文,表示當前正在執行 showName 函數。接着,當 showName 函數執行完成之後,函數執行流程就進入了 foo 函數,那這時就需要銷燬 showName 函數的執行上下文了。ESP 這時候就幫上忙了,JavaScript 會將 ESP 下移到 foo 函數的執行上下文,這個下移操作就是銷燬 showName 函數執行上下文的過程
image.png

堆中的數據是如何回收的

當函數執行完ESP指向全局執行上下文
image.png

代際假說和分代收集

代際假說(The Generational Hypothesis)

  • 第一個是大部分對象在內存中存在的時間很短,簡單來說,就是很多對象一經分配內存,很快就變得不可訪問;
  • 第二個是不死的對象,會活得更久。

在 V8 中會把堆分爲新生代老生代兩個區域,新生代中存放的是生存時間短的對象,老生代中存放的生存時間久的對象
新生區通常只支持 1~8M 的容量,而老生區支持的容量就大很多了。對於這兩塊區域,V8 分別使用兩個不同的垃圾回收器,以便更高效地實施垃圾回收。

  • 副垃圾回收器,主要負責新生代的垃圾回收。
  • 主垃圾回收器,主要負責老生代的垃圾回收。

垃圾回收器工作流程

1.標記空間中活動對象和非活動對象
2.回收非活動對象所佔據的內存
3.內存整理(處理內存碎片)

副垃圾回收器

新生代中用Scavenge 算法來處理。即把新生代空間對半劃分爲兩個區域,一半是對象區域,一半是空閒區域,

image.png
新加入的對象都會存放到對象區域,當對象區域快被寫滿時,就需要執行一次垃圾清理操作。
在垃圾回收過程中,首先要對對象區域中的垃圾做標記;標記完成之後,就進入垃圾清理階段,副垃圾回收器會把這些存活的對象複製到空閒區域中,同時它還會把這些對象有序地排列起來,所以這個複製過程,也就相當於完成了內存整理操作,複製後空閒區域就沒有內存碎片了。
完成複製後,對象區域與空閒區域進行角色翻轉,也就是原來的對象區域變成空閒區域,原來的空閒區域變成了對象區域。這樣就完成了垃圾對象的回收操作,同時這種角色翻轉的操作還能讓新生代中的這兩塊區域無限重複使用下去
由於新生代中採用的 Scavenge 算法,所以每次執行清理操作時,都需要將存活的對象從對象區域複製到空閒區域。但複製操作需要時間成本,如果新生區空間設置得太大了,那麼每次清理的時間就會過久,所以爲了執行效率,一般新生區的空間會被設置得比較小
也正是因爲新生區的空間不大,所以很容易被存活的對象裝滿整個區域。爲了解決這個問題,JavaScript 引擎採用了對象晉升策略,也就是經過兩次垃圾回收依然還存活的對象,會被移動到老生區中。

主垃圾回收器

老生區中的對象有兩個特點,一個是對象佔用空間大,另一個是對象存活時間長。主垃圾回收器是採用**標記 - 清除(Mark-Sweep)**的算法進行垃圾回收的。
比如最開始的那段代碼,當 showName 函數執行退出之後,這段代碼的調用棧和堆空間如下圖所示
image.png
如果遍歷調用棧,是不會找到引用 1003 地址的變量,也就意味着 1003 這塊數據爲垃圾數據,被標記爲紅色。由於 1050 這塊數據被變量 b 引用了,所以這塊數據會被標記爲活動對象。這就是大致的標記過程。
接下來就是垃圾的清除過程。它和副垃圾回收器的垃圾清除過程完全不同,你可以理解這個過程是清除掉紅色標記數據的過程,可參考下圖大致理解下其清除過程:
image.png
對一塊內存多次執行標記 - 清除算法後,會產生大量不連續的內存碎片。而碎片過多會導致大對象無法分配到足夠的連續內存,於是又產生了另外一種算法——標記 - 整理(Mark-Compact),這個標記過程仍然與標記 - 清除算法裏的是一樣的,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。

image.png

全停頓

由於 JavaScript 是運行在主線程之上的,一旦執行垃圾回收算法,都需要將正在執行的 JavaScript 腳本暫停下來,待垃圾回收完畢後再恢復腳本執行,稱全停頓(Stop-The-World)

image.png
在 V8 新生代的垃圾回收中,因其空間較小,且存活對象較少,所以全停頓的影響不大,但老生代就不一樣了。
爲了降低老生代的垃圾回收而造成的卡頓,V8 將標記過程分爲一個個的子標記過程,同時讓垃圾回收標記和 JavaScript 應用邏輯交替進行,直到標記階段完成,我們把這個算法稱爲增量標記(Incremental Marking)算法

image.png
使用增量標記算法,可以把一個完整的垃圾回收任務拆分爲很多小的任務,這些小的任務執行時間比較短,可以穿插在其他的 JavaScript 任務中間執行,這樣當執行上述動畫效果時,就不會讓用戶因爲垃圾回收任務而感受到頁面的卡頓了。

V8是如何執行一段JS的

編譯器(Compiler)和解釋器(Interpreter)

編譯型語言在程序執行之前,需要經過編譯器的編譯過程,並且編譯之後會直接保留機器能讀懂的二進制文件,這樣每次運行程序時,都可以直接運行該二進制文件,而不需要再次重新編譯了。比如 C/C++、GO 等都是編譯型語言。
而由解釋型語言編寫的程序,在每次運行時都需要通過解釋器對程序進行動態解釋和執行。比如 Python、JavaScript 等都屬於解釋型語言。
image.png

  1. 在編譯型語言的編譯過程中,編譯器首先會依次對源代碼進行詞法分析、語法分析,生成抽象語法樹(AST),然後是優化代碼,最後再生成處理器能夠理解的機器碼。如果編譯成功,將會生成一個可執行的文件。但如果編譯過程發生了語法或者其他的錯誤,那麼編譯器就會拋出異常,最後的二進制文件也不會生成成功。
  2. 在解釋型語言的解釋過程中,同樣解釋器也會對源代碼進行詞法分析、語法分析,並生成抽象語法樹(AST),不過它會再基於抽象語法樹生成字節碼,最後再根據字節碼來執行程序、輸出結果。

V8如何執行一段JS

image.png
V8 在執行過程中既有解釋器 Ignition,又有編譯器 TurboFan

1.生成抽象語法樹(AST)和執行上下文

高級語言是開發者可以理解的語言,但是讓編譯器或者解釋器來理解就非常困難了。對於編譯器或者解釋器來說,它們可以理解的就是 AST 了。所以無論使用的是解釋型語言還是編譯型語言,在編譯過程中,它們都會生成一個 AST。這和渲染引擎將 HTML 格式文件轉換爲計算機可以理解的 DOM 樹的情況類似。

var myName = " 極客時間 "
function foo(){
  return 23;
}
myName = "geektime"
foo()

image.png
AST 是非常重要的一種數據結構,在很多項目中有着廣泛的應用。其中最著名的一個項目是 Babel。Babel 是一個被廣泛使用的代碼轉碼器,可以將 ES6 代碼轉爲 ES5 代碼,這意味着你可以現在就用 ES6 編寫程序,而不用擔心現有環境是否支持 ES6。Babel 的工作原理就是先將 ES6 源碼轉換爲 AST,然後再將 ES6 語法的 AST 轉換爲 ES5 語法的 AST,最後利用 ES5 的 AST 生成 JavaScript 源代碼。
除了 Babel 外,還有 ESLint 也使用 AST。ESLint 是一個用來檢查 JavaScript 編寫規範的插件,其檢測流程也是需要將源碼轉換爲 AST,然後再利用 AST 來檢查代碼規範化的問題。
生成AST:
1)分詞(tokenize),又稱爲詞法分析:將一行行的源碼拆解成一個個 token。所謂token,指的是語法上不可能再分的、最小的單個字符或字符串。

image.png
2)**解析(parse),又稱爲語法分析:**將上一步生成的 token 數據,根據語法規則轉爲 AST。如果源碼符合語法規則,這一步就會順利完成。但如果源碼存在語法錯誤,這一步就會終止,並拋出一個“語法錯誤”。

2.生成字節碼

其實一開始 V8 並沒有字節碼,而是直接將 AST 轉換爲機器碼,由於執行機器碼的效率是非常高效的,所以這種方式在發佈後的一段時間內運行效果是非常好的。但是隨着 Chrome 在手機上的廣泛普及,特別是運行在 512M 內存的手機上,內存佔用問題也暴露出來了,因爲 V8 需要消耗大量的內存來存放轉換後的機器碼。爲了解決內存佔用問題,V8 團隊大幅重構了引擎架構,引入字節碼,並且拋棄了之前的編譯器,最終花了將進四年的時間,實現了現在的這套架構。

字節碼就是介於 AST 和機器碼之間的一種代碼。但是與特定類型的機器碼無關,字節碼需要通過解釋器將其轉換爲機器碼後才能執行。
**image.png

3.執行代碼

通常,如果有一段第一次執行的字節碼,解釋器 Ignition 會逐條解釋執行。在執行字節碼的過程中,如果發現有熱點代碼(HotSpot),比如一段代碼被重複執行多次,這種就稱爲熱點代碼,那麼後臺的編譯器 TurboFan 就會把該段熱點的字節碼編譯爲高效的機器碼,然後當再次執行這段被優化的代碼時,只需要執行編譯後的機器碼就可以了,這樣就大大提升了代碼的執行效率。
V8 的解釋器和編譯器的取名也很有意思。解釋器 Ignition 是點火器的意思,編譯器 TurboFan 是渦輪增壓的意思,寓意着代碼啓動時通過點火器慢慢發動,一旦啓動,渦輪增壓介入,其執行效率隨着執行時間越來越高效率,因爲熱點代碼都被編譯器 TurboFan 轉換了機器碼,直接執行機器碼就省去了字節碼“翻譯”爲機器碼的過程。
其實字節碼配合解釋器和編譯器是最近一段時間很火的技術,比如 Java 和 Python 的虛擬機也都是基於這種技術實現的,我們把這種技術稱爲即時編譯(JIT)。具體到 V8,就是指解釋器 Ignition 在解釋執行字節碼的同時,收集代碼信息,當它發現某一部分代碼變熱了之後,TurboFan 編譯器便閃亮登場,把熱點的字節碼轉換爲機器碼,並把轉換後的機器碼保存起來,以備下次使用。

對於 JavaScript 工作引擎,除了 V8 使用了“字節碼 +JIT”技術之外,蘋果的 SquirrelFish Extreme 和 Mozilla 的 SpiderMonkey 也都使用了該技術。

image.png

JS性能優化

  1. 提升單次腳本的執行速度,避免 JavaScript 的長任務霸佔主線程,這樣可以使得頁面快速響應交互;
  2. 避免大的內聯腳本,因爲在解析 HTML 的過程中,解析和編譯也會佔用主線程;
  3. 減少 JavaScript 文件的容量,因爲更小的文件會提升下載速度,並且佔用更低的內存。

頁面循環系統

消息隊列和事件循環:頁面是怎麼活起來的

使用單線程處理安排好的任務

void MainThread(){
  int num1 = 1+2; // 任務 1
  int num2 = 20/5; // 任務 2
  int num3 = 7*8; // 任務 3
  print(" 最終計算的值爲:%d,%d,%d",num,num2,num3)// 任務 4
}

image.png

在線程運行過程中處理新任務

要想在線程運行過程中,能接收並執行新的任務,就需要採用事件循環機制

//GetInput
// 等待用戶從鍵盤輸入一個數字,並返回該輸入的數字
int GetInput(){
    int input_number = 0;
    cout<<" 請輸入一個數:";
    cin>>input_number;
    return input_number;
}
 
// 主線程 (Main Thread)
void MainThread(){
     for(;;){
          int first_num = GetInput()int second_num = GetInput();
          result_num = first_num + second_num;
          print(" 最終計算的值爲:%d",result_num)}
}

相較於第一版的線程,這一版的線程做了兩點改進。

  • 第一點引入了循環機制,具體實現方式是在線程語句最後添加了一個for 循環語句,線程會一直循環執行。
  • 第二點是引入了事件,可以在線程運行過程中,等待用戶輸入的數字,等待過程中線程處於暫停狀態,一旦接收到用戶輸入的信息,那麼線程會被激活,然後執行相加運算,最後輸出結果。

image.png

處理其他線程發送過來的任務

image.png
那麼如何設計好一個線程模型,能讓其能夠接收其他線程發送的消息呢?一個通用模式是使用消息隊列
image.png
有了隊列之後,就可以繼續改造線程模型了,改造方案如下

image.png

  1. 添加一個消息隊列;
  2. IO 線程中產生的新任務添加進消息隊列尾部;
  3. 渲染主線程會循環地從消息隊列頭部中讀取任務,執行任務。

處理其他進程發送過來的任務

image.png
渲染進程專門有一個 IO 線程用來接收其他進程傳進來的消息,接收到消息之後,會將這些消息組裝成任務發送給渲染主線程

消息隊列中的任務類型

內部消息類型,如輸入事件(鼠標滾動、點擊、移動)、微任務、文件讀寫、WebSocket、JavaScript 定時器等等。消息隊列中還包含了很多與頁面相關的事件,如 JavaScript 執行、解析 DOM、樣式計算、佈局計算、CSS 動畫等。

如何安全退出

Chrome在確定要退出當前頁面時,頁面主線程會設置一個退出標誌的變量,在每次執行完一個任務時,判斷是否有設置退出標誌。如果設置了,那麼就直接中斷當前的所有任務,退出線程。

TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainThread(){
  for(;;){
    Task task = task_queue.takeTask();
    ProcessTask(task);
    if(!keep_running) // 如果設置了退出標誌,那麼直接退出線程循環
        break; 
  }
}

頁面使用單線程的缺點

如何處理高優先級的任務

爲了權衡效率和實時性,使用微任務。
通常把消息隊列中的任務稱爲宏任務,每個宏任務中都包含了一個微任務隊列,在執行宏任務的過程中,如果 DOM 有變化,那麼就會將該變化添加到微任務列表中,這樣就不會影響到宏任務的繼續執行,因此也就解決了執行效率的問題。
等宏任務中的主要功能都直接完成之後,這時候,渲染引擎並不着急去執行下一個宏任務,而是執行當前宏任務中的微任務,因爲 DOM 變化的事件都保存在這些微任務隊列中,這樣也就解決了實時性問題。

如何解決單個任務執行時長過久的問題

因爲所有的任務都是在單線程中執行的,所以每次只能執行一個任務,而其他任務就都處於等待狀態。如果其中一個任務執行時間過久,那麼下一個任務就要等待很長時間。

image.png
如果在執行動畫過程中,其中有個 JavaScript 任務因執行時間過久,佔用了動畫單幀的時間,這樣會給用戶製造了卡頓的感覺,這當然是極不好的用戶體驗。針對這種情況,JavaScript 可以通過回調功能來規避這種問題,也就是讓要執行的 JavaScript 任務滯後執行。

實踐:瀏覽器頁面是如何運行的

打開開發者工具,點擊“Performance”標籤,選擇左上角的“start porfiling and load page”來記錄整個頁面加載過程中的事件執行情況
image.png

WebAPI:setTimeout是如何實現的?

要執行一段異步任務,需要先將任務添加到消息隊列中。不過通過定時器設置回調函數有點特別,它們需要在指定的時間間隔內被調用,但消息隊列中的任務是按照順序執行的,所以爲了保證回調函數能在指定時間內執行,你不能將定時器的回調函數直接添加到消息隊列中。
在 Chrome 中除了正常使用的消息隊列之外,還有另外一個消息隊列,這個隊列中維護了需要延遲執行的任務列表,包括了定時器和 Chromium 內部一些需要延遲執行的任務。所以當通過 JavaScript 創建一個定時器時,渲染進程會將該定時器的回調任務添加到延遲隊列中。
延遲隊列

 DelayedIncomingQueue delayed_incoming_queue;

當通過 JavaScript 調用 setTimeout 設置回調函數的時候,渲染進程將會創建一個回調任務,包含了回調函數 showName、當前發起時間、延遲執行時間,其模擬代碼如下

struct DelayTask{
  int64 id;
  CallBackFunction cbf;
  int start_time;
  int delay_time;
};
DelayTask timerTask;
timerTask.cbf = showName;
timerTask.start_time = getCurrentTime(); // 獲取當前時間
timerTask.delay_time = 200;// 設置延遲執行時間

創建好回調任務之後,再將該任務添加到延遲執行隊列中

delayed_incoming_queue.push(timerTask)

消息循環系統是怎麼觸發延遲隊列的呢?

void ProcessTimerTask(){
  // 從 delayed_incoming_queue 中取出已經到期的定時器任務
  // 依次執行這些任務
}
 
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
  for(;;){
    // 執行消息隊列中的任務
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    // 執行延遲隊列中的任務
    ProcessDelayTask()
 
    if(!keep_running) // 如果設置了退出標誌,那麼直接退出線程循環
        break; 
  }
}

設置一個定時器,JavaScript 引擎會返回一個定時器的 ID。那通常情況下,當一個定時器的任務還沒有被執行的時候,也是可以取消的,具體方法是調用clearTimeout 函數,並傳入需要取消的定時器的 ID。其實瀏覽器內部實現取消定時器的操作也是非常簡單的,就是直接從 delayed_incoming_queue 延遲隊列中,通過 ID 查找到對應的任務,然後再將其從隊列中刪除掉就可以了。

使用 setTimeout 的一些注意事項

1. 如果當前任務執行時間過久,會影延遲到期定時器任務的執行

2. 如果 setTimeout 存在嵌套調用,那麼系統會設置最短時間間隔爲 4 毫秒

也就是說在定時器函數裏面嵌套調用定時器,也會延長定時器的執行時間
在 Chrome 中,定時器被嵌套調用 5 次以上,系統會判斷該函數方法被阻塞了,如果定時器的調用時間間隔小於 4 毫秒,那麼瀏覽器會將每次調用的時間間隔設置爲 4 毫秒。

3. 未激活的頁面,setTimeout 執行最小間隔是 1000 毫秒

如果標籤不是當前的激活標籤,那麼定時器最小的時間間隔是 1000 毫秒,目的是爲了優化後臺頁面的加載損耗以及降低耗電量。

4. 延時執行時間有最大值

Chrome、Safari、Firefox 都是以 32 個 bit 來存儲延時值的,32bit 最大隻能存放的數字是 2147483647 毫秒,這就意味着,如果 setTimeout 設置的延遲值大於 2147483647 毫秒(大約 24.8 天)時就會溢出,這導致定時器會被立即執行。

5. 使用 setTimeout 設置的回調函數中的 this 不符合直覺

如果被 setTimeout 推遲執行的回調函數是某個對象的方法,那麼該方法中的 this 關鍵字將指向全局環境,而不是定義時所在的那個對象。

var name= 1;
var MyObj = {
  name: 2,
  showName: function(){
    console.log(this.name);
  }
}
setTimeout(MyObj.showName,1000)
// 輸出是1

解決方案
1)將MyObj.showName放在匿名函數中執行

// 箭頭函數
setTimeout(() => {
    MyObj.showName()
}, 1000);
// 或者 function 函數
setTimeout(function() {
  MyObj.showName();
}, 1000)

2)使用 bind 方法,將 showName 綁定在 MyObj 上面

setTimeout(MyObj.showName.bind(MyObj), 1000)

擴展

setTimeout 設置的回調任務實時性並不是太好,所以很多場景並不適合使用 setTimeout。比如要使用 JavaScript 來實現動畫效果,函數 requestAnimationFrame 就是個很好的選擇。

WebAPI:XMLHttpRequest是怎麼實現的

在 XMLHttpRequest 出現之前,如果服務器數據有更新,依然需要重新刷新整個頁面。

回調函數VS系統調用棧

let callback = function(){
    console.log('i am do homework')
}
function doWork(cb) {
    console.log('start do work')
    cb()
    console.log('end do work')
}
doWork(callback)

回調函數 callback 是在主函數 doWork 返回之前執行的,把這個回調過程稱爲同步回調

let callback = function(){
    console.log('i am do homework')
}
function doWork(cb) {
    console.log('start do work')
    setTimeout(cb,1000)   
    console.log('end do work')
}
doWork(callback)

使用了 setTimeout 函數讓 callback 在 doWork 函數執行結束後,又延時了 1 秒再執行,這次 callback 並沒有在主函數 doWork 內部被調用,把這種回調函數在主函數外部執行的過程稱爲異步回調
消息隊列和主線程循環機制保證了頁面有條不紊地運行
循環系統在執行一個任務的時候,都要爲這個任務維護一個系統調用棧。這個系統調用棧類似於 JavaScript 的調用棧,只不過系統調用棧是 Chromium 的開發語言 C++ 來維護的,其完整的調用棧信息你可以通過 chrome://tracing/ 來抓取。當然,你也可以通過 Performance 來抓取它核心的調用信息
image.png
異步回調是指回調函數在主函數之外執行,一般有兩種方式:

  • 第一種是把異步函數做成一個任務,添加到信息隊列尾部;
  • 第二種是把異步函數添加到微任務隊列中,這樣就可以在當前任務的末尾處執行微任務了。

XMLHttpRequest 運作機制

image.png

 function GetWebData(URL){
    /**
     * 1: 新建 XMLHttpRequest 請求對象
     */
    let xhr = new XMLHttpRequest()
 
    /**
     * 2: 註冊相關事件回調處理函數 
     */
    xhr.onreadystatechange = function () {
        switch(xhr.readyState){
          case 0: // 請求未初始化
            console.log(" 請求未初始化 ")
            break;
          case 1://OPENED
            console.log("OPENED")
            break;
          case 2://HEADERS_RECEIVED
            console.log("HEADERS_RECEIVED")
            break;
          case 3://LOADING  
            console.log("LOADING")
            break;
          case 4://DONE
            if(this.status == 200||this.status == 304){
                console.log(this.responseText);
                }
            console.log("DONE")
            break;
        }
    }
 
    xhr.ontimeout = function(e) { console.log('ontimeout') }
    xhr.onerror = function(e) { console.log('onerror') }
 
    /**
     * 3: 打開請求
     */
    xhr.open('Get', URL, true);// 創建一個 Get 請求, 採用異步
 
 
    /**
     * 4: 配置參數
     */
    xhr.timeout = 3000 // 設置 xhr 請求的超時時間
    xhr.responseType = "text" // 設置響應返回的數據格式
    xhr.setRequestHeader("X_TEST","time.geekbang")
 
    /**
     * 5: 發送請求
     */
    xhr.send();
}

執行流程
1.創建 XMLHttpRequest 對象
2.爲 xhr 對象註冊回調函數
3.配置基礎的請求信息
4.發起請求

XMLHttpRequest 使用過程中的“坑”

1.跨域問題

默認情況下,跨域請求是不被允許的

2.HTTPS 混合內容的問題

HTTPS 混合內容是 HTTPS 頁面中包含了不符合 HTTPS 安全要求的內容,比如包含了 HTTP 資源,通過 HTTP 加載的圖像、視頻、樣式表、腳本等,都屬於混合內容。
通過 HTML 文件加載的混合資源,雖然給出警告,但大部分類型還是能加載的。而使用 XMLHttpRequest 請求時,瀏覽器認爲這種請求可能是攻擊者發起的,會阻止此類危險的請求。

宏任務和微任務

不過隨着瀏覽器的應用領域越來越廣泛,消息隊列中這種粗時間顆粒度的任務已經不能勝任部分領域的需求,所以又出現了一種新的技術——微任務微任務可以在實時性和效率之間做一個有效的權衡

宏任務

頁面中的大部分任務都是在主線程上執行的,這些任務包括了:

  • 渲染事件(如解析 DOM、計算佈局、繪製);
  • 用戶交互事件(如鼠標點擊、滾動頁面、放大縮小等);
  • JavaScript 腳本執行事件;
  • 網絡請求完成、文件讀寫完成事件。

爲了協調這些任務有條不紊地在主線程上執行,頁面進程引入了消息隊列和事件循環機制,渲染進程內部會維護多個消息隊列,比如延遲執行隊列和普通的消息隊列。然後主線程採用一個 for 循環,不斷地從這些任務隊列中取出任務並執行任務。把這些消息隊列中的任務稱爲宏任務
WHATWG 規範定義的大致流程:

  • 先從多個消息隊列中選出一個最老的任務,這個任務稱爲 oldestTask;
  • 然後循環系統記錄任務開始執行的時間,並把這個 oldestTask 設置爲當前正在執行的任務;
  • 當任務執行完成之後,刪除當前正在執行的任務,並從對應的消息隊列中刪除掉這個 oldestTask;
  • 最後統計執行完成的時長等信息。

宏任務可以滿足我們大部分的日常需求,不過如果有對時間精度要求較高的需求,宏任務就難以勝任了。宏任務的時間粒度比較大,執行的時間間隔是不能精確控制的,對一些高實時性的需求就不太符合了,比如監聽 DOM 變化的需求。

微任務

微任務就是一個需要異步執行的函數,執行時機是在主函數執行結束之後、當前宏任務結束之前。
當 JavaScript 執行一段腳本的時候,V8 會爲其創建一個全局執行上下文,在創建全局執行上下文的同時,V8 引擎也會在內部創建一個微任務隊列。顧名思義,這個微任務隊列就是用來存放微任務的,因爲在當前宏任務執行的過程中,有時候會產生多個微任務,這時候就需要使用這個微任務隊列來保存這些微任務了。不過這個微任務隊列是給 V8 引擎內部使用的,所以無法通過 JavaScript 直接訪問的。

微任務產生方式

第一種方式是使用 MutationObserver 監控某個 DOM 節點,然後再通過 JavaScript 來修改這個節點,或者爲這個節點添加、刪除部分子節點,當 DOM 節點發生變化時,就會產生 DOM 變化記錄的微任務。
第二種方式是使用 Promise,當調用 Promise.resolve() 或者 Promise.reject() 的時候,也會產生微任務

微任務隊列是何時被執行

通常情況下,在當前宏任務中的 JavaScript 快執行完成時,也就在 JavaScript 引擎準備退出全局執行上下文並清空調用棧的時候,JavaScript 引擎會檢查全局執行上下文中的微任務隊列,然後按照順序執行隊列中的微任務。WHATWG 把執行微任務的時間點稱爲檢查點
如果在執行微任務的過程中,產生了新的微任務,同樣會將該微任務添加到微任務隊列中,V8 引擎一直循環執行微任務隊列中的任務,直到隊列爲空纔算執行結束。也就是說在執行微任務過程中產生的新的微任務並不會推遲到下個宏任務中執行,而是在當前的宏任務中繼續執行。

image.png
image.png
特點:

  • 微任務和宏任務是綁定的,每個宏任務在執行時,會創建自己的微任務隊列。
  • 微任務的執行時長會影響到當前宏任務的時長。比如一個宏任務在執行過程中,產生了 100 個微任務,執行每個微任務的時間是 10 毫秒,那麼執行這 100 個微任務的時間就是 1000 毫秒,也可以說這 100 個微任務讓宏任務的執行時間延長了 1000 毫秒。所以你在寫代碼的時候一定要注意控制微任務的執行時長。
  • 在一個宏任務中,分別創建一個用於回調的宏任務和微任務,無論什麼情況下,微任務都早於宏任務執行。

監聽 DOM 變化方法演變

早期頁面並沒有提供對監聽的支持,所以那時要觀察 DOM 是否變化,唯一能做的就是輪詢檢測,比如使用 setTimeout 或者 setInterval 來定時檢測 DOM 是否有改變。這種方式簡單粗暴,但是會遇到兩個問題:如果時間間隔設置過長,DOM 變化響應不夠及時;反過來如果時間間隔設置過短,又會浪費很多無用的工作量去檢查 DOM,會讓頁面變得低效。
直到 2000 年的時候引入了 Mutation Event,Mutation Event 採用了觀察者的設計模式,當 DOM 有變動時就會立刻觸發相應的事件,這種方式屬於同步回調。採用 Mutation Event 解決了實時性的問題,因爲 DOM 一旦發生變化,就會立即調用 JavaScript 接口。但也正是這種實時性造成了嚴重的性能問題,因爲每次 DOM 變動,渲染引擎都會去調用 JavaScript,這樣會產生較大的性能開銷。
爲了解決了 Mutation Event 由於同步調用 JavaScript 而造成的性能問題,從 DOM4 開始,推薦使用 MutationObserver 來代替 Mutation Event。MutationObserver API 可以用來監視 DOM 的變化,包括屬性的變化、節點的增減、內容的變化等。
首先,MutationObserver 將響應函數改成異步調用,可以不用在每次 DOM 變化都觸發異步調用,而是等多次 DOM 變化後,一次觸發異步調用,並且還會使用一個數據結構來記錄這期間所有的 DOM 變化。這樣即使頻繁地操縱 DOM,也不會對性能造成太大的影響。在每次 DOM 節點發生變化的時候,渲染引擎將變化記錄封裝成微任務,並將微任務添加進當前的微任務隊列中。這樣當執行到檢查點的時候,V8 引擎就會按照順序執行微任務了。
MutationObserver 採用了“異步 + 微任務”的策略。

  • 通過異步操作解決了同步操作的性能問題
  • 通過微任務解決了實時性的問題

Promise

異步編程的問題:代碼邏輯不連續

image.png
Web 頁面的單線程架構決定了異步回調,而異步回調影響到了編碼方式,回調會導致代碼的邏輯不連貫、不線性,非常不符合人的直覺。

封裝異步代碼,讓處理流程變得線性

image.png

//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 函數

//[in] request,請求信息,請求頭,延時值,返回類型等
//[out] resolve, 執行成功,回調該函數
//[out] reject  執行失敗,回調該函數
function XFetch(request, resolve, reject) {
    let xhr = new XMLHttpRequest()
    xhr.ontimeout = function (e) { reject(e) }
    xhr.onerror = function (e) { reject(e) }
    xhr.onreadystatechange = function () {
        if (xhr.status = 200)
            resolve(xhr.response)
    }
    xhr.open(request.method, URL, request.sync);
    xhr.timeout = request.timeout;
    xhr.responseType = request.responseType;
    // 補充其他請求信息
    //...
    xhr.send();
}

XFetch 函數需要一個 request 作爲輸入,然後還需要兩個回調函數 resolve 和 reject,當請求成功時回調 resolve 函數,當請求出現問題時回調 reject 函數

XFetch(makeRequest('https://time.geekbang.org'),
    function resolve(data) {
        console.log(data)
    }, function reject(e) {
        console.log(e)
    })

新的問題:回調地獄

原因

  • 第一是嵌套調用,下面的任務依賴上個任務的請求結果,並在上個任務的回調函數內部執行新的業務邏輯,這樣當嵌套層次多了之後,代碼的可讀性就變得非常差了。
  • 第二是任務的不確定性,執行每個任務都有兩種可能的結果(成功或者失敗),所以體現在代碼中就需要對每個任務的執行結果做兩次判斷,這種對每個任務都要進行一次額外的錯誤處理的方式,明顯增加了代碼的混亂程度。

解決方案

  • 第一是消滅嵌套調用
  • 第二是合併多個任務的錯誤處理

Promise:消滅嵌套調用和多次錯誤處理

用 Promise 來重構 XFetch

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 來構造請求流程

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 實現了回調函數的延時綁定,**回調函數的延時綁定在代碼上體現就是先創建 Promise 對象 x1,通過 Promise 的構造函數 executor 來執行業務邏輯;創建好 Promise 對象 x1 之後,再使用 x1.then 來設置回調函數。

// 創建 Promise 對象 x1,並在 executor 函數中執行業務邏輯
function executor(resolve, reject){
    resolve(100)
}
let x1 = new Promise(executor)
 
 
//x1 延遲綁定回調函數 onResolve
function onResolve(value){
    console.log(value)
}
x1.then(onResolve)

其次,需要將回調函數 onResolve 的返回值穿透到最外層。根據 onResolve 函數的傳入值來決定創建什麼類型的 Promise 任務,創建好的 Promise 對象需要返回到最外層,這樣就可以擺脫嵌套循環了。

image.png
Promise 通過回調函數延遲綁定回調函數返回值穿透的技術,解決了循環嵌套。

Promise異常處理

function executor(resolve, reject) {
    let rand = Math.random();
    console.log(1)
    console.log(rand)
    if (rand > 0.5)
        resolve()
    else
        reject()
}
var p0 = new Promise(executor);
 
var p1 = p0.then((value) => {
    console.log("succeed-1")
    return new Promise(executor)
})
 
var p3 = p1.then((value) => {
    console.log("succeed-2")
    return new Promise(executor)
})
 
var p4 = p3.then((value) => {
    console.log("succeed-3")
    return new Promise(executor)
})
 
p4.catch((error) => {
    console.log("error")
})
console.log(2)

Promise 對象的錯誤具有“冒泡”性質,會一直向後傳遞,直到被 onReject 函數處理或 catch 語句捕獲爲止。具備了這樣“冒泡”的特性後,就不需要在每個 Promise 對象中單獨捕獲異常了。

Promise 與微任務

由於 Promise 採用了回調函數延遲綁定技術,所以在執行 resolve 函數的時候,回調函數還沒有綁定,那麼只能推遲迴調函數的執行

function Bromise(executor) {
    var onResolve_ = null
    var onReject_ = null
     // 模擬實現 resolve 和 then,暫不支持 rejcet
    this.then = function (onResolve, onReject) {
        onResolve_ = onResolve
    };
    function resolve(value) {
          setTimeout(()=>{
            onResolve_(value)
          },0)
    }
    executor(resolve, null);
}
function executor(resolve, reject) {
    resolve(100)
}
// 將 Promise 改成我們自己的 Bromsie
let demo = new Bromise(executor)
 
function onResolve(value){
    console.log(value)
}
demo.then(onResolve)

使用定時器的效率並不是太高,所以 Promise 又把這個定時器改造成了微任務了,這樣既可以讓 onResolve_ 延時被調用,又提升了代碼的執行效率。

async/await

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 也是相當複雜,雖然整個請求流程已經線性化了,但是代碼裏面包含了大量的 then 函數,使得代碼依然不是太容易閱讀。基於這個原因,ES7 引入了 async/await,這是 JavaScript 異步編程的一個重大改進,提供了在不阻塞主線程的情況下使用同步代碼實現異步訪問資源的能力,並且使得代碼邏輯更加清晰

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()

生成器 (Generator)VS 協程(Coroutine)

生成器函數是一個帶星號函數,而且是可以暫停執行和恢復執行的

function* genDemo() {
    console.log(" 開始執行第一段 ")
    yield 'generator 2'
 
    console.log(" 開始執行第二段 ")
    yield 'generator 2'
 
    console.log(" 開始執行第三段 ")
    yield 'generator 2'
 
    console.log(" 執行結束 ")
    return 'generator 2'
}
 
console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')

生成器函數的具體使用方式:

  1. 在生成器函數內部執行一段代碼,如果遇到 yield 關鍵字,那麼 JavaScript 引擎將返回關鍵字後面的內容給外部,並暫停該函數的執行。
  2. 外部函數可以通過 next 方法恢復函數的執行。

協程是一種比線程更加輕量級的存在。可以把協程看成是跑在線程上的任務,一個線程上可以存在多個協程,但是在線程上同時只能執行一個協程,比如當前執行的是 A 協程,要啓動 B 協程,那麼 A 協程就需要將主線程的控制權交給 B 協程,這就體現在 A 協程暫停執行,B 協程恢復執行;同樣,也可以從 B 協程中啓動 A 協程。通常,如果從 A 協程啓動 B 協程,把 A 協程稱爲 B 協程的父協程
正如一個進程可以擁有多個線程一樣,一個線程也可以擁有多個協程。最重要的是,協程不是被操作系統內核所管理,而完全是由程序所控制(也就是在用戶態執行)。這樣帶來的好處就是性能得到了很大的提升,不會像線程切換那樣消耗資源。

image.png

協程的四點規則:

  1. 通過調用生成器函數 genDemo 來創建一個協程 gen,創建之後,gen 協程並沒有立即執行。
  2. 要讓 gen 協程執行,需要通過調用 gen.next。
  3. 當協程正在執行的時候,可以通過 yield 關鍵字來暫停 gen 協程的執行,並返回主要信息給父協程。
  4. 如果協程在執行期間,遇到了 return 關鍵字,那麼 JavaScript 引擎會結束當前協程,並將 return 後面的內容返回給父協程。

父協程和 gen 協程是如何切換調用棧

image.png
在 JavaScript 中,生成器就是協程的一種實現方式

//foo 函數
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 函數的代碼
let gen = foo()
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)
})

foo 函數是一個生成器函數,在 foo 函數裏面實現了用同步代碼形式來實現異步操作;

  • 首先執行的是let gen = foo(),創建了 gen 協程。
  • 然後在父協程中通過執行 gen.next 把主線程的控制權交給 gen 協程。
  • gen 協程獲取到主線程的控制權後,就調用 fetch 函數創建了一個 Promise 對象 response1,然後通過 yield 暫停 gen 協程的執行,並將 response1 返回給父協程。
  • 父協程恢復執行後,調用 response1.then 方法等待請求結果。
  • 等通過 fetch 發起的請求完成之後,會調用 then 中的回調函數,then 中的回調函數拿到結果之後,通過調用 gen.next 放棄主線程的控制權,將控制權交 gen 協程繼續執行下個請求。

以上就是協程和 Promise 相互配合執行的一個大致流程。不過通常,我們把執行生成器的代碼封裝成一個函數,並把這個執行生成器代碼的函數稱爲執行器(可參考著名的 co 框架)

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)
}
co(foo());

通過使用生成器配合執行器,就能實現使用同步的方式寫出異步代碼了,這樣也大大加強了代碼的可讀性。

async/await

async

async 是一個通過異步執行隱式返回 Promise 作爲結果的函數。

async function foo() {
    return 2
}
console.log(foo())  // Promise {<resolved>: 2}

調用 async 聲明的 foo 函數返回了一個 Promise 對象,狀態是 resolved

await

async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)

image.png

瀏覽器中的頁面

Chrome開發者工具:利用網絡面板做性能分析

Chrome 開發者工具

image.png

網絡面板

image.png

控制器

image.png

過濾器

起過濾功能,有時候一個頁面有太多內容在詳細列表區域中展示了,而你可能只想查看 JavaScript 文件或者 CSS 文件,這時候就可以通過過濾器模塊來篩選你想要的文件類型。.

抓圖信息

抓圖信息區域,可以用來分析用戶等待頁面加載時間內所看到的內容,分析用戶實際的體驗情況。比如,如果頁面加載 1 秒多之後屏幕截圖還是白屏狀態,這時候就需要分析是網絡還是代碼的問題了。(勾選面板上的“Capture screenshots”即可啓用屏幕截圖。)

時間線

時間線,主要用來展示 HTTP、HTTPS、WebSocket 加載的狀態和時間的一個關係,用於直觀感受頁面的加載過程。如果是多條豎線堆疊在一起,那說明這些資源被同時被加載。

詳細列表

這個區域是最重要的,它詳細記錄了每個資源從發起請求到完成請求這中間所有過程的狀態,以及最終請求完成的數據信息。通過該列表,你就能很容易地去診斷一些網絡問題。

下載信息概要

下載信息概要中,要重點關注下 DOMContentLoaded 和 Load 兩個事件,以及這兩個事件的完成時間。

  • DOMContentLoaded,這個事件發生後,說明頁面已經構建好 DOM 了,這意味着構建 DOM 所需要的 HTML 文件、JavaScript 文件、CSS 文件都已經下載完成了。
  • Load,說明瀏覽器已經加載了所有的資源(圖像、樣式表等)

網絡面板中的詳細列表

列表的屬性

列表的屬性比較多,比如 Name、Status、Type、Initiator 等等。可以按照列表的屬性來給列表排序,默認情況下,列表是按請求發起的時間來排序的,最早發起請求的資源在頂部。當然也可以按照返回狀態碼、請求類型、請求時長、內容大小等基礎屬性排序,只需點擊相應屬性即可。

image.png

詳細信息

如果選中詳細列表中的一項,右邊就會出現該項的詳細信息

image.png

單個資源的時間線

image.png
時間線面板
image.png
**Queuing,**當瀏覽器發起一個請求的時候,會有很多原因導致該請求不能被立即執行,而是需要排隊等待。

  • 首先,頁面中的資源是有優先級的,比如 CSS、HTML、JavaScript 等都是頁面中的核心文件,所以優先級最高;而圖片、視頻、音頻這類資源就不是核心資源,優先級就比較低。通常當後者遇到前者時,就需要“讓路”,進入待排隊狀態。
  • 其次,瀏覽器會爲每個域名最多維護 6 個 TCP 連接,如果發起一個 HTTP 請求時,這 6 個 TCP 連接都處於忙碌狀態,那麼這個請求就會處於排隊狀態。
  • 最後,網絡進程在爲數據分配磁盤空間時,新的 HTTP 請求也需要短暫地等待磁盤分配結束。

等待排隊完成之後,就要進入發起連接的狀態了。不過在發起連接之前,還有一些原因可能導致連接過程被推遲,這個推遲就表現在面板中的Stalled上,它表示停滯的意思。如果使用了代理服務器,還會增加一個Proxy Negotiation階段,也就是代理協商階段,它表示代理服務器連接協商所用的時間。接下來,就到了Initial connection/SSL 階段了,也就是和服務器建立連接的階段,這包括了建立 TCP 連接所花費的時間;不過如果你使用了 HTTPS 協議,那麼還需要一個額外的 SSL 握手時間,這個過程主要是用來協商一些加密信息的。和服務器建立好連接之後,網絡進程會準備請求數據,並將其發送給網絡,這就是Request sent 階段。通常這個階段非常快,因爲只需要把瀏覽器緩衝區的數據發送出去就結束了,並不需要判斷服務器是否接收到了,所以這個時間通常不到 1 毫秒。數據發送出去了,接下來就是等待接收服務器第一個字節的數據,這個階段稱爲 Waiting (TTFB),通常也稱爲“第一字節時間”。 TTFB 是反映服務端響應速度的重要指標,對服務器來說,TTFB 時間越短,就說明服務器響應越快。接收到第一個字節之後,進入陸續接收完整數據的階段,也就是Content Download 階段,這意味着從第一字節時間到接收到全部響應數據所用的時間。

優化時間線上耗時項

1. 排隊(Queuing)時間過久

排隊時間過久,大概率是由瀏覽器爲每個域名最多維護 6 個連接導致的。那麼基於這個原因,你就可以讓 1 個站點下面的資源放在多個域名下面,比如放到 3 個域名下面,這樣就可以同時支持 18 個連接了,這種方案稱爲域名分片技術。除了域名分片技術外,還建議把站點升級到 HTTP2,因爲 HTTP2 已經沒有每個域名最多維護 6 個 TCP 連接的限制了。

2. 第一字節時間(TTFB)時間過久

  • 服務器生成頁面數據的時間過久。對於動態網頁來說,服務器收到用戶打開一個頁面的請求時,首先要從數據庫中讀取該頁面需要的數據,然後把這些數據傳入到模板中,模板渲染後,再返回給用戶。服務器在處理這個數據的過程中,可能某個環節會出問題。(去提高服務器的處理速度,比如通過增加各種緩存的技術)
  • 網絡的原因。比如使用了低帶寬的服務器,或者本來用的是電信的服務器,可聯通的網絡用戶要來訪問你的服務器,這樣也會拖慢網速。(使用 CDN 來緩存一些靜態文件)
  • 發送請求頭時帶上了多餘的用戶信息。比如一些不必要的 Cookie 信息,服務器接收到這些 Cookie 信息之後可能需要對每一項都做處理,這樣就加大了服務器的處理時長。(發送請求時就去儘可能地減少一些不必要的 Cookie 數據信息)

3. Content Download 時間過久

如果單個請求的 Content Download 花費了大量時間,有可能是字節數太多的原因導致的。這時候就需要減少文件大小,比如壓縮、去掉源碼中不必要的註釋等方法。

DOM樹:JavaScript是如何影響DOM樹構建的?

什麼是 DOM

DOM 提供了對 HTML 文檔結構化的表述。在渲染引擎中,DOM 有三個層面的作用

  • 從頁面的視角來看,DOM 是生成頁面的基礎數據結構。
  • 從 JavaScript 腳本視角來看,DOM 提供給 JavaScript 腳本操作的接口,通過這套接口,JavaScript 可以對 DOM 結構進行訪問,從而改變文檔的結構、樣式和內容。
  • 從安全視角來看,DOM 是一道安全防護線,一些不安全的內容在 DOM 解析階段就被拒之門外了。

DOM 樹如何生成

在渲染引擎內部,有一個叫HTML 解析器(HTMLParser)的模塊,它的職責就是負責將 HTML 字節流轉換爲 DOM 結構。HTML 解析器並不是等整個文檔加載完成之後再解析的,而是網絡進程加載了多少數據,HTML 解析器便解析多少數據
網絡進程接收到響應頭之後,會根據響應頭中的 content-type 字段來判斷文件的類型,比如 content-type 的值是“text/html”,那麼瀏覽器就會判斷這是一個 HTML 類型的文件,然後爲該請求選擇或者創建一個渲染進程。渲染進程準備好之後,網絡進程和渲染進程之間會建立一個共享數據的管道,網絡進程接收到數據後就往這個管道里面放,而渲染進程則從管道的另外一端不斷地讀取數據,並同時將讀取的數據“喂”給 HTML 解析器。

image.png
第一個階段,通過分詞器將字節流轉換爲 Token
**image.png

至於後續的第二個和第三個階段是同步進行的,需要將 Token 解析爲 DOM 節點,並將 DOM 節點添加到 DOM 樹中。
HTML 解析器維護了一個Token 棧結構,該 Token 棧主要用來計算節點之間的父子關係,在第一個階段中生成的 Token 會被按照順序壓到這個棧中。具體的處理規則如下所示:

  • 如果壓入到棧中的是StartTag Token,HTML 解析器會爲該 Token 創建一個 DOM 節點,然後將該節點加入到 DOM 樹中,它的父節點就是棧中相鄰的那個元素生成的節點。
  • 如果分詞器解析出來是文本 Token,那麼會生成一個文本節點,然後將該節點加入到 DOM 樹中,文本 Token 是不需要壓入到棧中,它的父節點就是當前棧頂 Token 所對應的 DOM 節點。
  • 如果分詞器解析出來的是EndTag 標籤,比如是 EndTag div,HTML 解析器會查看 Token 棧頂的元素是否是 StarTag div,如果是,就將 StartTag div 從棧中彈出,表示該 div 元素解析完成。

HTML 解析器開始工作時,會默認創建了一個根爲 document 的空 DOM 結構,同時會將一個 StartTag document 的 Token 壓入棧底。然後經過分詞器解析出來的第一個 StartTag html Token 會被壓入到棧中,並創建一個 html 的 DOM 節點,添加到 document 上

image.png
然後按照同樣的流程解析出來 StartTag body 和 StartTag div,其 Token 棧和 DOM 的狀態如下圖所示:

image.png
接下來解析出來的是第一個 div 的文本 Token,渲染引擎會爲該 Token 創建一個文本節點,並將該 Token 添加到 DOM 中,它的父節點就是當前 Token 棧頂元素對應的節點
image.png
再接下來,分詞器解析出來第一個 EndTag div,這時候 HTML 解析器會去判斷當前棧頂的元素是否是 StartTag div,如果是則從棧頂彈出 StartTag div
image.png
按照同樣的規則,一路解析,最終結果如下圖所示

image.png

JavaScript 是如何影響 DOM 生成的

<html>
<body>
    <div>1</div>
    <script>
    let div1 = document.getElementsByTagName('div')[0]
    div1.innerText = 'time.geekbang'
    </script>
    <div>test</div>
</body>
</html>

解析到<script>標籤時,渲染引擎判斷這是一段腳本,此時 HTML 解析器就會暫停 DOM 的解析,因爲接下來的 JavaScript 可能要修改當前已經生成的 DOM 結構。

image.png
HTML 解析器暫停工作,JavaScript 引擎介入,並執行 script 標籤中的這段腳本,因爲這段 JavaScript 腳本修改了 DOM 中第一個 div 中的內容,所以執行這段腳本之後,div 節點內容已經修改爲 time.geekbang 了。腳本執行完成之後,HTML 解析器恢復解析過程,繼續解析後續的內容,直至生成最終的 DOM。

引入類型JS的解析過程

執行到 JavaScript 標籤時,暫停整個 DOM 的解析,執行 JavaScript 代碼,不過這裏執行 JavaScript 時,需要先下載這段 JavaScript 代碼。這裏需要重點關注下載環境,因爲JavaScript 文件的下載過程會阻塞 DOM 解析,而通常下載又是非常耗時的,會受到網絡環境、JavaScript 文件大小等因素的影響。
不過 Chrome 瀏覽器做了很多優化,其中一個主要的優化是預解析操作。當渲染引擎收到字節流之後,會開啓一個預解析線程,用來分析 HTML 文件中包含的 JavaScript、CSS 等相關文件,解析到相關文件之後,預解析線程會提前下載這些文件。
引入 JavaScript 線程會阻塞 DOM,不過也有一些相關的策略來規避,比如使用 CDN 來加速 JavaScript 文件的加載,壓縮 JavaScript 文件的體積。另外,如果 JavaScript 文件中沒有操作 DOM 相關代碼,就可以將該 JavaScript 腳本設置爲異步加載,通過 async 或 defer 來標記代碼

<script async type="text/javascript" src='foo.js'></script>
<script defer type="text/javascript" src='foo.js'></script>

async 和 defer 雖然都是異步的,不過還有一些差異,使用 async 標誌的腳本文件一旦加載完成,會立即執行;而使用了 defer 標記的腳本文件,需要在 DOMContentLoaded 事件之前執行。

渲染流水線:CSS如何影響首次加載時的白屏時間?

渲染流水線視角下的 CSS

//theme.css
div{ 
    color : coral;
    background-color:black
}
<html>
<head>
    <link href="theme.css" rel="stylesheet">
</head>
<body>
    <div>geekbang com</div>
</body>
</html>

image.png

那渲染流水線爲什麼需要 CSSOM 呢?

和 HTML 一樣,渲染引擎也是無法直接理解 CSS 文件內容的,所以需要將其解析成渲染引擎能夠理解的結構,這個結構就是 CSSOM。和 DOM 一樣,CSSOM 也具有兩個作用,第一個是提供給 JavaScript 操作樣式表的能力,第二個是爲佈局樹的合成提供基礎的樣式信息。這個 CSSOM 體現在 DOM 中就是document.styleSheets
等 DOM 和 CSSOM 都構建好之後,渲染引擎就會構造佈局樹。佈局樹的結構基本上就是複製 DOM 樹的結構,不同之處在於 DOM 樹中那些不需要顯示的元素會被過濾掉,如 display:none 屬性的元素、head 標籤、script 標籤等。複製好基本的佈局樹結構之後,渲染引擎會爲對應的 DOM 元素選擇對應的樣式信息,這個過程就是樣式計算。樣式計算完成之後,渲染引擎還需要計算佈局樹中每個元素對應的幾何位置,這個過程就是計算佈局。通過樣式計算和計算佈局就完成了最終佈局樹的構建。再之後,就該進行後續的繪製操作了。

影響頁面展示的因素以及優化策略

渲染流水線影響到了首次頁面展示的速度,而首次頁面展示的速度又直接影響到了用戶體驗。
從發起 URL 請求開始,到首次顯示頁面的內容,在視覺上經歷的三個階段

  • 第一個階段,等請求發出去之後,到提交數據階段,這時頁面展示出來的還是之前頁面的內容;
  • 第二個階段,提交數據之後渲染進程會創建一個空白頁面,通常把這段時間稱爲解析白屏,並等待 CSS 文件和 JavaScript 文件的加載完成,生成 CSSOM 和 DOM,然後合成佈局樹,最後還要經過一系列的步驟準備首次渲染。
  • 第三個階段,等首次渲染完成之後,就開始進入完整頁面的生成階段了,然後頁面會一點點被繪製出來。

對於第二階段,通常情況下的瓶頸主要體現在**下載 CSS 文件、下載 JavaScript 文件和執行 JavaScript,**應對策略有:

  • 通過內聯 JavaScript、內聯 CSS 來移除這兩種類型的文件下載,這樣獲取到 HTML 文件之後就可以直接開始渲染流程了。
  • 但並不是所有的場合都適合內聯,那麼還可以儘量減少文件大小,比如通過 webpack 等工具移除一些不必要的註釋,並壓縮 JavaScript 文件。
  • 還可以將一些不需要在解析 HTML 階段使用的 JavaScript 標記上 sync 或者 defer。
  • 對於大的 CSS 文件,可以通過媒體查詢屬性,將其拆分爲多個不同用途的 CSS 文件,這樣只有在特定的場景下才會加載特定的 CSS 文件。

分層和合成機制:爲什麼CSS動畫比JavaScript高效?

顯示器是怎麼顯示圖像的

每個顯示器都有固定的刷新頻率,通常是 60HZ,也就是每秒更新 60 張圖片,更新的圖片都來自於顯卡中一個叫前緩衝區的地方,顯示器所做的任務很簡單,就是每秒固定讀取 60 次前緩衝區中的圖像,並將讀取的圖像顯示到顯示器上。

顯卡做什麼呢?

顯卡的職責就是合成新的圖像,並將圖像保存到後緩衝區中,一旦顯卡把合成的圖像寫到後緩衝區,系統就會讓後緩衝區和前緩衝區互換,這樣就能保證顯示器能讀取到最新顯卡合成的圖像。通常情況下,顯卡的更新頻率和顯示器的刷新頻率是一致的。但有時候,在一些複雜的場景中,顯卡處理一張圖片的速度會變慢,這樣就會造成視覺上的卡頓。

幀 VS 幀率

大多數設備屏幕的更新頻率是 60 次 / 秒,這也就意味着正常情況下要實現流暢的動畫效果,渲染引擎需要每秒更新 60 張圖片到顯卡的後緩衝區。
把渲染流水線生成的每一副圖片稱爲一幀,把渲染流水線每秒更新了多少幀稱爲幀率,比如滾動過程中 1 秒更新了 60 幀,那麼幀率就是 60Hz(或者 60FPS)。
由於用戶很容易觀察到那些丟失的幀,如果在一次動畫過程中,渲染引擎生成某些幀的時間過久,那麼用戶就會感受到卡頓,這會給用戶造成非常不好的印象。要解決卡頓問題,就要解決每幀生成時間過久的問題,爲此 Chrome 對瀏覽器渲染方式做了大量的工作,其中最卓有成效的策略就是引入了分層和合成機制。

如何生成一幀圖像

任意一幀的生成方式,有重排、重繪合成三種方式
這三種方式的渲染路徑是不同的,通常渲染路徑越長,生成圖像花費的時間就越多。比如重排,它需要重新根據 CSSOM 和 DOM 來計算佈局樹,這樣生成一幅圖片時,會讓整個渲染流水線的每個階段都執行一遍,如果佈局複雜的話,就很難保證渲染的效率了。而重繪因爲沒有了重新佈局的階段,操作效率稍微高點,但是依然需要重新計算繪製信息,並觸發繪製操作之後的一系列操作。
相較於重排和重繪,合成操作的路徑就顯得非常短了,並不需要觸發佈局和繪製兩個階段,如果採用了 GPU,那麼合成的效率會非常高。
所以,關於渲染引擎生成一幀圖像的幾種方式,按照效率我們推薦合成方式優先,若實在不能滿足需求,那麼就再退後一步使用重繪或者重排的方式。

分層和合成

爲了提升每幀的渲染效率,Chrome 引入了分層和合成的機制。將素材分解爲多個圖層的操作就稱爲分層,最後將這些圖層合併到一起的操作就稱爲合成
考慮到一個頁面被劃分爲兩個層,當進行到下一幀的渲染時,上面的一幀可能需要實現某些變換,如平移、旋轉、縮放、陰影或者 Alpha 漸變,這時候合成器只需要將兩個層進行相應的變化操作就可以了,顯卡處理這些操作駕輕就熟,所以這個合成過程時間非常短。

Chrome 是怎麼實現分層和合成機制的

在 Chrome 的渲染流水線中,分層體現在生成佈局樹之後,渲染引擎會根據佈局樹的特點將其轉換爲層樹(Layer Tree),層樹是渲染流水線後續流程的基礎結構。
層樹中的每個節點都對應着一個圖層,下一步的繪製階段就依賴於層樹中的節點。繪製階段其實並不是真正地繪出圖片,而是將繪製指令組合成一個列表,比如一個圖層要設置的背景爲黑色,並且還要在中間畫一個圓形,那麼繪製過程會生成|Paint BackGroundColor:Black | Paint Circle|這樣的繪製指令列表,繪製過程就完成了。
有了繪製列表之後,就需要進入光柵化階段了,光柵化就是按照繪製列表中的指令生成圖片。每一個圖層都對應一張圖片,合成線程有了這些圖片之後,會將這些圖片合成爲“一張”圖片,並最終將生成的圖片發送到後緩衝區。這就是一個大致的分層、合成流程。
需要重點關注的是,合成操作是在合成線程上完成的,這也就意味着在執行合成操作時,是不會影響到主線程執行的。這就是爲什麼經常主線程卡住了,但是 CSS 動畫依然能執行的原因。

分塊

如果說分層是從宏觀上提升了渲染效率,那麼分塊則是從微觀層面提升了渲染效率。
通常情況下,頁面的內容都要比屏幕大得多,顯示一個頁面時,如果等待所有的圖層都生成完畢,再進行合成的話,會產生一些不必要的開銷,也會讓合成圖片的時間變得更久。
因此,合成線程會將每個圖層分割爲大小固定的圖塊,然後優先繪製靠近視口的圖塊,這樣就可以大大加速頁面的顯示速度。不過有時候, 即使只繪製那些優先級最高的圖塊,也要耗費不少的時間,因爲涉及到一個很關鍵的因素——紋理上傳,這是因爲從計算機內存上傳到 GPU 內存的操作會比較慢。
爲了解決這個問題,Chrome 又採取了一個策略:在首次合成圖塊的時候使用一個低分辨率的圖片。比如可以是正常分辨率的一半,分辨率減少一半,紋理就減少了四分之三。在首次顯示頁面內容的時候,將這個低分辨率的圖片顯示出來,然後合成器繼續繪製正常比例的網頁內容,當正常比例的網頁內容繪製完成後,再替換掉當前顯示的低分辨率內容。這種方式儘管會讓用戶在開始時看到的是低分辨率的內容,但是也比用戶在開始時什麼都看不到要好。

如何利用分層技術優化代碼

在寫 Web 應用的時候,可能經常需要對某個元素做幾何形狀變換、透明度變換或者一些縮放操作,如果使用 JavaScript 來寫這些效果,會牽涉到整個渲染流水線,所以 JavaScript 的繪製效率會非常低下。這時可以使用 will-change 來告訴渲染引擎你會對該元素做一些特效變換

.box {
will-change: transform, opacity;
}

這段代碼就是提前告訴渲染引擎 box 元素將要做幾何變換和透明度變換操作,這時候渲染引擎會將該元素單獨實現一幀,等這些變換髮生時,渲染引擎會通過合成線程直接去處理變換,這些變換並沒有涉及到主線程,這樣就大大提升了渲染的效率。這也是 CSS 動畫比 JavaScript 動畫高效的原因
所以,如果涉及到一些可以使用合成線程來處理 CSS 特效或者動畫的情況,就儘量使用 will-change 來提前告訴渲染引擎,讓它爲該元素準備獨立的層。但是凡事都有兩面性,每當渲染引擎爲一個元素準備一個獨立層的時候,它佔用的內存也會大大增加,因爲從層樹開始,後續每個階段都會多一個層結構,這些都需要外的內存,所以你需要恰當地使用 will-change。

頁面性能:如何系統地優化頁面?

讓頁面更快地顯示和響應

  • 加載階段,是指從發出請求到渲染出完整頁面的過程,影響到這個階段的主要因素有網絡和 JavaScript 腳本。
  • 交互階段,主要是從頁面加載完成到用戶交互的整合過程,影響到這個階段的主要因素是 JavaScript 腳本。
  • 關閉階段,主要是用戶發出關閉指令後頁面所做的一些清理操作。

加載階段

image.png
能阻塞網頁首次渲染的資源稱爲關鍵資源。基於關鍵資源,可以繼續細化出來三個影響頁面首次渲染的核心因素。
第一個是關鍵資源個數。關鍵資源個數越多,首次頁面的加載時間就會越長。
第二個是關鍵資源大小。通常情況下,所有關鍵資源的內容越小,其整個資源的下載時間也就越短,那麼阻塞渲染的時間也就越短。
第三個是請求關鍵資源需要多少個 RTT(Round Trip Time)RTT 就是往返時延。它是網絡中一個重要的性能指標,表示從發送端發送數據開始,到發送端收到來自接收端的確認,總共經歷的時延。通常 1 個 HTTP 的數據包在 14KB 左右,所以 1 個 0.1M 的頁面就需要拆分成 8 個包來傳輸了,也就是說需要 8 個 RTT。
總的優化原則就是減少關鍵資源個數,降低關鍵資源大小,降低關鍵資源的 RTT 次數

  • 如何減少關鍵資源的個數?一種方式是可以將 JavaScript 和 CSS 改成內聯的形式。另一種方式,如果 JavaScript 代碼沒有 DOM 或者 CSSOM 的操作,則可以改成 sync 或者 defer 屬性;同樣對於 CSS,如果不是在構建頁面之前加載的,則可以添加媒體取消阻止顯現的標誌。當 JavaScript 標籤加上了 sync 或者 defer、CSSlink 屬性之前加上了取消阻止顯現的標誌後,它們就變成了非關鍵資源了。
  • 如何減少關鍵資源的大小?可以壓縮 CSS 和 JavaScript 資源,移除 HTML、CSS、JavaScript 文件中一些註釋內容,也可以通過前面講的取消 CSS 或者 JavaScript 中關鍵資源的方式。
  • 如何減少關鍵資源 RTT 的次數?可以通過減少關鍵資源的個數和減少關鍵資源的大小搭配來實現。除此之外,還可以使用 CDN 來減少每次 RTT 時長。

交互階段

在交互階段沒有了加載關鍵資源和構建 DOM、CSSOM 流程,通常是由 JavaScript 觸發交互動畫的。
image.png
交互階段,大部分情況下,生成一個新的幀都是由 JavaScript 通過修改 DOM 或者 CSSOM 來觸發的。還有另外一部分幀是由 CSS 來觸發的。如果在計算樣式階段發現有佈局信息的修改,那麼就會觸發重排操作,然後觸發後續渲染流水線的一系列操作,這個代價是非常大的。同樣如果在計算樣式階段沒有發現有佈局信息的修改,只是修改了顏色一類的信息,那麼就不會涉及到佈局相關的調整,所以可以跳過佈局階段,直接進入繪製階段,這個過程叫重繪。還有另外一種情況,通過 CSS 實現一些變形、漸變、動畫等特效,這是由 CSS 觸發的,並且是在合成線程上執行的,這個過程稱爲合成。因爲它不會觸發重排或者重繪,而且合成操作本身的速度就非常快,所以執行合成是效率最高的方式。
優化原則就是讓單個幀的生成速度變快

  1. 減少 JavaScript 腳本執行時間
  • 一種是將一次執行的函數分解爲多個任務,使得每次的執行時間不要過久。
  • 另一種是採用 Web Workers。不過 Web Workers 中沒有 DOM、CSSOM 環境,這意味着在 Web Workers 中是無法通過 JavaScript 來訪問 DOM 的,所以我們可以把一些和 DOM 操作無關且耗時的任務放到 Web Workers 中去執行。

2.避免強制同步佈局
所謂強制同步佈局,是指 JavaScript 強制將計算樣式和佈局操作提前到當前的任務中

function foo() {
    let main_div = document.getElementById("mian_div")
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);
    // 由於要獲取到 offsetHeight,
    // 但是此時的 offsetHeight 還是老的數據,
    // 所以需要立即執行佈局操作
    console.log(main_div.offsetHeight)
}

優化

function foo() {
    let main_div = document.getElementById("mian_div")
    // 爲了避免強制同步佈局,在修改 DOM 之前查詢相關值
    console.log(main_div.offsetHeight)
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);
    
}

3.避免佈局抖動
所謂佈局抖動,是指在一次 JavaScript 執行過程中,多次執行強制佈局和抖動操作。

function foo() {
    let time_li = document.getElementById("time_li")
    for (let i = 0; i < 100; i++) {
        let main_div = document.getElementById("mian_div")
        let new_node = document.createElement("li")
        let textnode = document.createTextNode("time.geekbang")
        new_node.appendChild(textnode);
        new_node.offsetHeight = time_li.offsetHeight;
        document.getElementById("mian_div").appendChild(new_node);
    }
}

一個 for 循環語句裏面不斷讀取屬性值,每次讀取屬性值之前都要進行計算樣式和佈局。
4.合理利用CSS動畫
合成動畫是直接在合成線程上執行的,這和在主線程上執行的佈局、繪製等操作不同,如果主線程被 JavaScript 或者一些佈局任務佔用,CSS 動畫依然能繼續執行。所以要儘量利用好 CSS 合成動畫,如果能讓 CSS 處理動畫,就儘量交給 CSS 來操作。
另外,如果能提前知道對某個元素執行動畫操作,那就最好將其標記爲 will-change,這是告訴渲染引擎需要將該元素單獨生成一個圖層。
5.避免頻繁的垃圾回收
JavaScript 使用了自動垃圾回收機制,如果在一些函數中頻繁創建臨時對象,那麼垃圾回收器也會頻繁地去執行垃圾回收策略。這樣當垃圾回收操作發生時,就會佔用主線程,從而影響到其他任務的執行,嚴重的話還會讓用戶產生掉幀、不流暢的感覺。
所以要儘量避免產生那些臨時垃圾數據。那該怎麼做呢?可以儘可能優化儲存結構,儘可能避免小顆粒對象的產生。

虛擬DOM:虛擬DOM和實際的DOM有何不同?

DOM 的缺陷

牽一髮而動全身

什麼是虛擬DOM

作用:

  • 將頁面改變的內容應用到虛擬 DOM 上,而不是直接應用到 DOM 上。
  • 變化被應用到虛擬 DOM 上時,虛擬 DOM 並不急着去渲染頁面,而僅僅是調整虛擬 DOM 的內部狀態,這樣操作虛擬 DOM 的代價就變得非常輕了。
  • 在虛擬 DOM 收集到足夠的改變時,再把這些變化一次性應用到真實的 DOM 上。

image.png

運行過程

  • 創建階段。首先依據 JSX 和基礎數據創建出來虛擬 DOM,它反映了真實的 DOM 樹的結構。然後由虛擬 DOM 樹創建出真實 DOM 樹,真實的 DOM 樹生成完後,再觸發渲染流水線往屏幕輸出頁面。
  • 更新階段。如果數據發生了改變,那麼就需要根據新的數據創建一個新的虛擬 DOM 樹;然後 React 比較兩個樹,找出變化的地方,並把變化的地方一次性更新到真實的 DOM 樹上;最後渲染引擎更新渲染流水線,並生成新的頁面。

雙緩存

在開發遊戲或者處理其他圖像的過程中,屏幕從前緩衝區讀取數據然後顯示。但是很多圖形操作都很複雜且需要大量的運算,比如一幅完整的畫面,可能需要計算多次才能完成,如果每次計算完一部分圖像,就將其寫入緩衝區,那麼就會造成一個後果,那就是在顯示一個稍微複雜點的圖像的過程中,你看到的頁面效果可能是一部分一部分地顯示出來,因此在刷新頁面的過程中,會讓用戶感受到界面的閃爍。
而使用雙緩存,可以讓你先將計算的中間結果存放在另一個緩衝區中,等全部的計算結束,該緩衝區已經存儲了完整的圖形之後,再將該緩衝區的圖形數據一次性複製到顯示緩衝區,這樣就使得整個圖像的輸出非常穩定。
在這裏,你可以把虛擬 DOM 看成是 DOM 的一個 buffer,和圖形顯示一樣,它會在完成一次完整的操作之後,再把結果應用到 DOM 上,這樣就能減少一些不必要的更新,同時還能保證 DOM 的穩定輸出。

MVC模式

image.png
MVC 的整體結構比較簡單,由模型、視圖和控制器組成,其核心思想就是將數據和視圖分離,也就是說視圖和模型之間是不允許直接通信的,它們之間的通信都是通過控制器來完成的。通常情況下的通信路徑是視圖發生了改變,然後通知控制器,控制器再根據情況判斷是否需要更新模型數據。當然還可以根據不同的通信路徑和控制器不同的實現方式,基於 MVC 又能衍生出很多其他的模式,如 MVP、MVVM 等,不過萬變不離其宗,它們的基礎骨架都是基於 MVC 而來。
比如在分析 React 項目時,我們可以把 React 的部分看成是一個 MVC 中的視圖,在項目中結合 Redux 就可以構建一個 MVC 的模型結構,

image.png
在該圖中,可以把虛擬 DOM 看成是 MVC 的視圖部分,其控制器和模型都是由 Redux 提供的。其具體實現過程如下:

  • 圖中的控制器是用來監控 DOM 的變化,一旦 DOM 發生變化,控制器便會通知模型,讓其更新數據;
  • 模型數據更新好之後,控制器會通知視圖,告訴它模型的數據發生了變化;
  • 視圖接收到更新消息之後,會根據模型所提供的數據來生成新的虛擬 DOM;
  • 新的虛擬 DOM 生成好之後,就需要與之前的虛擬 DOM 進行比較,找出變化的節點;
  • 比較出變化的節點之後,React 將變化的虛擬節點應用到 DOM 上,這樣就會觸發 DOM 節點的更新;
  • DOM 節點的變化又會觸發後續一系列渲染流水線的變化,從而實現頁面的更新。

漸進式網頁應用(PWA):它究竟解決了Web應用的哪些問題?

PWA,全稱是 Progressive Web App,翻譯過來就是漸進式網頁應用,需要從下面兩個方面來理解。

  • 站在 Web 應用開發者來說,PWA 提供了一個漸進式的過渡方案,讓普通站點逐步過渡到 Web 應用。採取漸進式可以降低站點改造的代價,使得站點逐步支持各項新技術,而不是一步到位。
  • 站在技術角度來說,PWA 技術也是一個漸進式的演化過程,在技術層面會一點點演進,比如逐漸提供更好的設備特性支持,不斷優化更加流暢的動畫效果,不斷讓頁面的加載速度變得更快,不斷實現本地應用的特性。

PWA 採取的是非常一個緩和的漸進式策略,不再像以前那樣激進,動不動就是取代本地 App、取代小程序。與之相反,而是要充分發揮 Web 的優勢,漸進式地縮短和本地應用或者小程序的距離。
它是一套理念,漸進式增強 Web 的優勢,並通過技術手段漸進式縮短和本地應用或者小程序的距離。基於這套理念之下的技術都可以歸類到 PWA。

Web 應用 VS 本地應用

web應用缺什麼?

  • 首先,Web 應用缺少離線使用能力,在離線或者在弱網環境下基本上是無法使用的。而用戶需要的是沉浸式的體驗,在離線或者弱網環境下能夠流暢地使用是用戶對一個應用的基本要求。
  • 其次,Web 應用還缺少了消息推送的能力,因爲作爲一個 App 廠商,需要有將消息送達到應用的能力。
  • 最後,Web 應用缺少一級入口,也就是將 Web 應用安裝到桌面,在需要的時候直接從桌面打開 Web 應用,而不是每次都需要通過瀏覽器來打開。

PWA的方案:通過引入 Service Worker 來試着解決離線存儲和消息推送的問題,通過引入 manifest.json 來解決一級入口的問題

Service Worker

主要思想是在頁面和網絡之間增加一個攔截器,用來緩存和攔截請求

image.png

Service Worker 的設計思路

1.架構
讓其運行在主線程之外”就是 Service Worker 來自 Web Worker 的一個核心思想。不過 Web Worker 是臨時的,每次 JavaScript 腳本執行完成之後都會退出,執行結果也不能保存下來,如果下次還有同樣的操作,就還得重新來一遍。所以 Service Worker 需要在 Web Worker 的基礎之上加上儲存功能。
另外,由於 Service Worker 還需要會爲多個頁面提供服務,所以還不能把 Service Worker 和單個頁面綁定起來。在目前的 Chrome 架構中,Service Worker 是運行在瀏覽器進程中的,因爲瀏覽器進程生命週期是最長的,所以在瀏覽器的生命週期內,能夠爲所有的頁面提供服務。
2.消息推送
消息推送也是基於 Service Worker 來實現的。因爲消息推送時,瀏覽器頁面也許並沒有啓動,這時就需要 Service Worker 來接收服務器推送的消息,並將消息通過一定方式展示給用戶。
3.安全
在設計之初,就考慮對 Service Worker 採用 HTTPS 協議,因爲採用 HTTPS 的通信數據都是經過加密的,即便攔截了數據,也無法破解數據內容,而且 HTTPS 還有校驗機制,通信雙方很容易知道數據是否被篡改。
除了必須要使用 HTTPS,Service Worker 還需要同時支持 Web 頁面默認的安全策略、儲入同源策略、內容安全策略(CSP)等

WebComponent:像搭積木一樣構建Web應用

對內高內聚,對外低耦合。對內各個元素彼此緊密結合、相互依賴,對外和其他組件的聯繫最少且接口簡單。

阻礙前端組件化的因素

除了 CSS 的全局屬性會阻礙組件化,DOM 也是阻礙組件化的一個因素,因爲在頁面中只有一個 DOM,任何地方都可以直接讀取和修改 DOM。所以使用 JavaScript 來實現組件化是沒有問題的,但是 JavaScript 一旦遇上 CSS 和 DOM,那麼就相當難辦了。

WebComponent 組件化開發

WebComponent 給出瞭解決思路,它提供了對局部視圖封裝能力,可以讓 DOM、CSSOM 和 JavaScript 運行在局部環境中,這樣就使得局部的 CSS 和 DOM 不會影響到全局。
WebComponent 是一套技術的組合,具體涉及到了Custom elements(自定義元素)、Shadow DOM(影子 DOM)HTML templates(HTML 模板)

<!DOCTYPE html>
<html>
 
 
<body>
    <!--
            一:定義模板
            二:定義內部 CSS 樣式
            三:定義 JavaScript 行爲
    -->
    <template id="geekbang-t">
        <style>
            p {
                background-color: brown;
                color: cornsilk
            }
 
 
            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()
                // 獲取組件模板
                const content = document.querySelector('#geekbang-t').content
                // 創建影子 DOM 節點
                const shadowDOM = this.attachShadow({ mode: 'open' })
                // 將模板添加到影子 DOM 上
                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>

要使用 WebComponent,通常要實現下面三個步驟。
首先,使用 template 屬性來創建模板。利用 DOM 可以查找到模板的內容,但是模板元素是不會被渲染到頁面上的,也就是說 DOM 樹中的 template 節點不會出現在佈局樹中,所以我們可以使用 template 來自定義一些基礎的元素結構,這些基礎的元素結構是可以被重複使用的。一般模板定義好之後,我們還需要在模板的內部定義樣式信息。
其次,我們需要創建一個 GeekBang 的類。在該類的構造函數中要完成三件事:

  1. 查找模板內容;
  2. 創建影子 DOM;
  3. 再將模板添加到影子 DOM 上。

上面最難理解的是影子 DOM,其實影子 DOM 的作用是將模板中的內容與全局 DOM 和 CSS 進行隔離,這樣我們就可以實現元素和樣式的私有化了。你可以把影子 DOM 看成是一個作用域,其內部的樣式和元素是不會影響到全局的樣式和元素的,而在全局環境下,要訪問影子 DOM 內部的樣式或者元素也是需要通過約定好的接口的。
總之,通過影子 DOM,我們就實現了 CSS 和元素的封裝,在創建好封裝影子 DOM 的類之後,我們就可以使用 customElements.define 來自定義元素了(可參考上述代碼定義元素的方式)。
最後,就很簡單了,可以像正常使用 HTML 元素一樣使用該元素,如上述代碼中的<geek-bang></geek-bang>
image.png

瀏覽器如何實現影子 DOM

影子 DOM 的作用:

  1. 影子 DOM 中的元素對於整個網頁是不可見的;
  2. 影子 DOM 的 CSS 不會影響到整個網頁的 CSSOM,影子 DOM 內部的 CSS 只對內部的元素起作用。

image.png

瀏覽器中的網絡

HTTP性能優化

超文本傳輸協議 HTTP/0.9

HTTP/0.9 是於 1991 年提出的,主要用於學術交流,需求很簡單——用來在網絡之間傳遞 HTML 超文本的內容,所以被稱爲超文本傳輸協議。整體來看,它的實現也很簡單,採用了基於請求響應的模式,從客戶端發出請求,服務器返回數據。

請求流程:

  • 因爲 HTTP 都是基於 TCP 協議的,所以客戶端先要根據 IP 地址、端口和服務器建立 TCP 連接,而建立連接的過程就是 TCP 協議三次握手的過程。
  • 建立好連接之後,會發送一個 GET 請求行的信息,如GET /index.html用來獲取 index.html。
  • 服務器接收請求信息之後,讀取對應的 HTML 文件,並將數據以 ASCII 字符流返回給客戶端。
  • HTML 文檔傳輸完成後,斷開連接。

image.png

特點:

  • 第一個是隻有一個請求行,並沒有HTTP 請求頭和請求體,因爲只需要一個請求行就可以完整表達客戶端的需求了。
  • 第二個是服務器也沒有返回頭信息,這是因爲服務器端並不需要告訴客戶端太多信息,只需要返回數據就可以了。
  • 第三個是返回的文件內容是以 ASCII 字符流來傳輸的,因爲都是 HTML 格式的文件,所以使用 ASCII 字節碼來傳輸是最合適的。

被瀏覽器推動的 HTTP/1.0

首先在瀏覽器中展示的不單是 HTML 文件了,還包括了 JavaScript、CSS、圖片、音頻、視頻等不同類型的文件。因此支持多種類型的文件下載是 HTTP/1.0 的一個核心訴求,而且文件格式不僅僅侷限於 ASCII 編碼,還有很多其他類型編碼的文件。

如何實現多種類型文件的下載

HTTP/1.0 引入了請求頭和響應頭,它們都是以爲 Key-Value 形式保存的,在 HTTP 發送請求時,會帶上請求頭信息,服務器返回數據時,會先返回響應頭信息。

image.png

HTTP/1.0 是怎麼通過請求頭和響應頭來支持多種不同類型的數據呢?

  • 首先,瀏覽器需要知道服務器返回的數據是什麼類型的,然後瀏覽器才能根據不同的數據類型做針對性的處理。
  • 其次,由於萬維網所支持的應用變得越來越廣,所以單個文件的數據量也變得越來越大。爲了減輕傳輸性能,服務器會對數據進行壓縮後再傳輸,所以瀏覽器需要知道服務器壓縮的方法。
  • 再次,由於萬維網是支持全球範圍的,所以需要提供國際化的支持,服務器需要對不同的地區提供不同的語言版本,這就需要瀏覽器告訴服務器它想要什麼語言版本的頁面。
  • 最後,由於增加了各種不同類型的文件,而每種文件的編碼形式又可能不一樣,爲了能夠準確地讀取文件,瀏覽器需要知道文件的編碼類型。

請求頭:

accept: text/html
accept-encoding: gzip, deflate, br
accept-Charset: ISO-8859-1,utf-8
accept-language: zh-CN,zh

響應頭:

content-encoding: br
content-type: text/html; charset=UTF-8

其他特性:

  • 有的請求服務器可能無法處理,或者處理出錯,這時候就需要告訴瀏覽器服務器最終處理該請求的情況,這就引入了狀態碼。狀態碼是通過響應行的方式來通知瀏覽器的。
  • 爲了減輕服務器的壓力,在 HTTP/1.0 中提供了Cache 機制,用來緩存已經下載過的數據。
  • 服務器需要統計客戶端的基礎信息,比如 Windows 和 macOS 的用戶數量分別是多少,所以 HTTP/1.0 的請求頭中還加入了用戶代理的字段。

縫縫補補的 HTTP/1.1

1.改進持久連接

image.png
HTTP/1.1 中增加了持久連接的方法,它的特點是在一個 TCP 連接上可以傳輸多個 HTTP 請求,只要瀏覽器或者服務器沒有明確斷開連接,那麼該 TCP 連接會一直保持

image.png
持久連接在 HTTP/1.1 中是默認開啓的,所以不需要專門爲了持久連接去 HTTP 請求頭設置信息,如果你不想要採用持久連接,可以在 HTTP 請求頭中加上Connection: close。目前瀏覽器中對於同一個域名,默認允許同時建立 6 個 TCP 持久連接。

2. 不成熟的 HTTP 管線化

持久連接雖然能減少 TCP 的建立和斷開次數,但是它需要等待前面的請求返回之後,才能進行下一次請求。如果 TCP 通道中的某個請求因爲某些原因沒有及時返回,那麼就會阻塞後面的所有請求,這就是著名的隊頭阻塞的問題。
HTTP/1.1 中試圖通過管線化的技術來解決隊頭阻塞的問題。HTTP/1.1 中的管線化是指將多個 HTTP 請求整批提交給服務器的技術,雖然可以整批發送請求,不過服務器依然需要根據請求順序來回復瀏覽器的請求。
FireFox、Chrome 都做過管線化的試驗,但是由於各種原因,它們最終都放棄了管線化技術。

3. 提供虛擬主機的支持

在 HTTP/1.0 中,每個域名綁定了一個唯一的 IP 地址,因此一個服務器只能支持一個域名。但是隨着虛擬主機技術的發展,需要實現在一臺物理主機上綁定多個虛擬主機,每個虛擬主機都有自己的單獨的域名,這些單獨的域名都公用同一個 IP 地址。
因此,HTTP/1.1 的請求頭中增加了Host 字段,用來表示當前的域名地址,這樣服務器就可以根據不同的 Host 值做不同的處理。

4. 對動態生成的內容提供了完美支持

在設計 HTTP/1.0 時,需要在響應頭中設置完整的數據大小,如Content-Length: 901,這樣瀏覽器就可以根據設置的數據大小來接收數據。不過隨着服務器端的技術發展,很多頁面的內容都是動態生成的,因此在傳輸數據之前並不知道最終的數據大小,這就導致了瀏覽器不知道何時會接收完所有的文件數據。
HTTP/1.1 通過引入Chunk transfer 機制來解決這個問題,服務器會將數據分割成若干個任意大小的數據塊,每個數據塊發送時會附上上個數據塊的長度,最後使用一個零長度的塊作爲發送數據完成的標誌。這樣就提供了對動態內容的支持。

5. 客戶端 Cookie、安全機制

HTTP/1.1 還引入了客戶端 Cookie 機制和安全機制

HTTP/2:如何提升網絡速度?

HTTP/1.1 爲網絡效率做了大量的優化,最核心的有如下三種方式:

  1. 增加了持久連接;
  2. 瀏覽器爲每個域名最多同時維護 6 個 TCP 持久連接;
  3. 使用 CDN 的實現域名分片機制。

image.png

HTTP/1.1 的主要問題

HTTP/1.1對帶寬的利用率卻並不理想,帶寬是指每秒最大能發送或者接收的字節數。我們把每秒能發送的最大字節數稱爲上行帶寬,每秒能夠接收的最大字節數稱爲下行帶寬
原因:
**第一個原因,TCP 的慢啓動。**一旦一個 TCP 連接建立之後,就進入了發送數據狀態,剛開始 TCP 協議會採用一個非常慢的速度去發送數據,然後慢慢加快發送數據的速度,直到發送數據的速度達到一個理想狀態,我們把這個過程稱爲慢啓動。慢啓動是 TCP 爲了減少網絡擁塞的一種策略。而之所以說慢啓動會帶來性能問題,是因爲頁面中常用的一些關鍵資源文件本來就不大,如 HTML 文件、CSS 文件和 JavaScript 文件,通常這些文件在 TCP 連接建立好之後就要發起請求的,但這個過程是慢啓動,所以耗費的時間比正常的時間要多很多,這樣就推遲了寶貴的首次渲染頁面的時長了。
第二個原因,同時開啓了多條 TCP 連接,那麼這些連接會競爭固定的帶寬。
**第三個原因,HTTP/1.1 隊頭阻塞的問題。**在 HTTP/1.1 中使用持久連接時,雖然能公用一個 TCP 管道,但是在一個管道中同一時刻只能處理一個請求,在當前的請求沒有結束之前,其他的請求只能處於阻塞狀態。這意味着我們不能隨意在一個管道中發送請求和接收內容。

HTTP/2 的多路複用

HTTP/2 的思路就是一個域名只使用一個 TCP 長連接來傳輸數據,這樣整個頁面資源的下載過程只需要一次慢啓動,同時也避免了多個 TCP 連接競爭帶寬所帶來的問題。
另外,就是隊頭阻塞的問題,等待請求完成後才能去請求下一個資源,這種方式無疑是最慢的,所以 HTTP/2 需要實現資源的並行請求,也就是任何時候都可以將請求發送給服務器,而並不需要等待其他請求的完成,然後服務器也可以隨時返回處理好的請求資源給瀏覽器。
總結爲:一個域名只使用一個 TCP 長連接和消除隊頭阻塞問題
image.png
該圖就是 HTTP/2 最核心、最重要且最具顛覆性的多路複用機制。從圖中你會發現每個請求都有一個對應的 ID,如 stream1 表示 index.html 的請求,stream2 表示 foo.css 的請求。這樣在瀏覽器端,就可以隨時將請求發送給服務器了。
服務器端接收到這些請求後,會根據自己的喜好來決定優先返回哪些內容。之所以可以隨意發送,是因爲每份數據都有對應的 ID,瀏覽器接收到之後,會篩選出相同 ID 的內容,將其拼接爲完整的 HTTP 響應數據。

多路複用的實現

image.png
HTTP/2 協議棧
HTTP/2 添加了一個二進制分幀層,通過引入二進制分幀層,就實現了 HTTP 的多路複用技術

  • 首先,瀏覽器準備好請求數據,包括了請求行、請求頭等信息,如果是 POST 方法,那麼還要有請求體。
  • 這些數據經過二進制分幀層處理之後,會被轉換爲一個個帶有請求 ID 編號的幀,通過協議棧將這些幀發送給服務器。
  • 服務器接收到所有幀之後,會將所有相同 ID 的幀合併爲一條完整的請求信息。
  • 然後服務器處理該條請求,並將處理的響應行、響應頭和響應體分別發送至二進制分幀層。
  • 同樣,二進制分幀層會將這些響應數據轉換爲一個個帶有請求 ID 編號的幀,經過協議棧發送給瀏覽器。
  • 瀏覽器接收到響應幀之後,會根據 ID 編號將幀的數據提交給對應的請求。

HTTP/2 其他特性

1. 可以設置請求的優先級

HTTP/2 提供了請求優先級,可以在發送請求時,標上該請求的優先級,這樣服務器接收到請求之後,會優先處理優先級高的請求。

2. 服務器推送

除了設置請求的優先級外,HTTP/2 還可以直接將數據提前推送到瀏覽器。

3. 頭部壓縮

無論是 HTTP/1.1 還是 HTTP/2,它們都有請求頭和響應頭,這是瀏覽器和服務器的通信語言。HTTP/2 對請求頭和響應頭進行了壓縮。

HTTP/3:甩掉TCP、TLS 的包袱,構建高效網絡

TCP 的隊頭阻塞

HTTP/1.1 協議棧中 TCP 是如何傳輸數據的

image.png
從一端發送給另外一端的數據會被拆分爲一個個按照順序排列的數據包,這些數據包通過網絡傳輸到了接收端,接收端再按照順序將這些數據包組合成原始數據,這樣就完成了數據傳輸。
不過,如果在數據傳輸的過程中,有一個數據因爲網絡故障或者其他原因而丟包了,那麼整個 TCP 的連接就會處於暫停狀態,需要等待丟失的數據包被重新傳輸過來。
image.png
在 TCP 傳輸過程中,由於單個數據包的丟失而造成的阻塞稱爲 TCP 上的隊頭阻塞
那隊頭阻塞是怎麼影響 HTTP/2 傳輸的呢?

image.png
在 HTTP/2 中,多個請求是跑在一個 TCP 管道中的,如果其中任意一路數據流中出現了丟包的情況,那麼就會阻塞該 TCP 連接中的所有請求。這不同於 HTTP/1.1,使用 HTTP/1.1 時,瀏覽器爲每個域名開啓了 6 個 TCP 連接,如果其中的 1 個 TCP 連接發生了隊頭阻塞,那麼其他的 5 個連接依然可以繼續傳輸數據。
所以隨着丟包率的增加,HTTP/2 的傳輸效率也會越來越差。有測試數據表明,當系統達到了 2% 的丟包率時,HTTP/1.1 的傳輸效率反而比 HTTP/2 表現得更好。

TCP 建立連接的延時

網絡延遲又稱爲 RTT(Round Trip Time)。把從瀏覽器發送一個數據包到服務器,再從服務器返回數據包到瀏覽器的整個往返時間稱爲 RTT(如下圖)。RTT 是反映網絡性能的一個重要指標。

image.png
HTTP/1 和 HTTP/2 都是使用 TCP 協議來傳輸的,而如果使用 HTTPS 的話,還需要使用 TLS 協議進行安全傳輸,而使用 TLS 也需要一個握手過程,這樣就需要有兩個握手延遲過程。

  1. 在建立 TCP 連接的時候,需要和服務器進行三次握手來確認連接成功,也就是說需要在消耗完 1.5 個 RTT 之後才能進行數據傳輸。
  2. 進行 TLS 連接,TLS 有兩個版本——TLS1.2 和 TLS1.3,每個版本建立連接所花的時間不同,大致是需要 1~2 個 RTT

總之,在傳輸數據之前,需要花掉 3~4 個 RTT。如果瀏覽器和服務器的物理距離較近,那麼 1 個 RTT 的時間可能在 10 毫秒以內,也就是說總共要消耗掉 30~40 毫秒。這個時間也許用戶還可以接受,但如果服務器相隔較遠,那麼 1 個 RTT 就可能需要 100 毫秒以上了,這種情況下整個握手過程需要 300~400 毫秒,這時用戶就能明顯地感受到“慢”了。

TCP 協議僵化

1.中間設備的僵化
互聯網是由多個網絡互聯的網狀結構,爲了能夠保障互聯網的正常工作,需要在互聯網的各處搭建各種設備,這些設備就被稱爲中間設備。
這些中間設備有很多種類型,並且每種設備都有自己的目的,這些設備包括了路由器、防火牆、NAT、交換機等。它們通常依賴一些很少升級的軟件,這些軟件使用了大量的 TCP 特性,這些功能被設置之後就很少更新了。
所以,如果我們在客戶端升級了 TCP 協議,但是當新協議的數據包經過這些中間設備時,它們可能不理解包的內容,於是這些數據就會被丟棄掉。這就是中間設備僵化,它是阻礙 TCP 更新的一大障礙。
除了中間設備僵化外,操作系統也是導致 TCP 協議僵化的另外一個原因。因爲 TCP 協議都是通過操作系統內核來實現的,應用程序只能使用不能修改。通常操作系統的更新都滯後於軟件的更新,因此要想自由地更新內核中的 TCP 協議也是非常困難的。

QUIC 協議

HTTP/2 存在一些比較嚴重的與 TCP 協議相關的缺陷,但由於 TCP 協議僵化,幾乎不可能通過修改 TCP 協議自身來解決這些問題,那麼解決問題的思路是繞過 TCP 協議,發明一個 TCP 和 UDP 之外的新的傳輸協議。但是這也面臨着和修改 TCP 一樣的挑戰,因爲中間設備的僵化,這些設備只認 TCP 和 UDP,如果採用了新的協議,新協議在這些設備同樣不被很好地支持。
因此,HTTP/3 選擇了一個折衷的方法——UDP 協議,基於 UDP 實現了類似於 TCP 的多路數據流、傳輸可靠性等功能,稱爲QUIC 協議image.png
HTTP/3 中的 QUIC 協議集合了以下幾點功能

  • 實現了類似 TCP 的流量控制、傳輸可靠性的功能。雖然 UDP 不提供可靠性的傳輸,但 QUIC 在 UDP 的基礎之上增加了一層來保證數據可靠性傳輸。它提供了數據包重傳、擁塞控制以及其他一些 TCP 中存在的特性。
  • 集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相較於早期版本 TLS1.3 有更多的優點,其中最重要的一點是減少了握手所花費的 RTT 個數。
  • 實現了 HTTP/2 中的多路複用功能。和 TCP 不同,QUIC 實現了在同一物理連接上可以有多個獨立的邏輯數據流(如下圖)。實現了數據流的單獨傳輸,就解決了 TCP 中隊頭阻塞的問題。
  • 實現了快速握手功能。由於 QUIC 是基於 UDP 的,所以 QUIC 可以實現使用 0-RTT 或者 1-RTT 來建立連接,這意味着 QUIC 可以用最快的速度來發送和接收數據,這樣可以大大提升首次打開頁面的速度。

image.png

HTTP/3 的挑戰

第一,從目前的情況來看,服務器和瀏覽器端都沒有對 HTTP/3 提供比較完整的支持。Chrome 雖然在數年前就開始支持 Google 版本的 QUIC,但是這個版本的 QUIC 和官方的 QUIC 存在着非常大的差異。
第二,部署 HTTP/3 也存在着非常大的問題。因爲系統內核對 UDP 的優化遠遠沒有達到 TCP 的優化程度,這也是阻礙 QUIC 的一個重要原因。
第三,中間設備僵化的問題。這些設備對 UDP 的優化程度遠遠低於 TCP,據統計使用 QUIC 協議時,大約有 3%~7% 的丟包率。

瀏覽器安全

同源策略:爲什麼XMLHttpRequest不能跨域請求資源?

同源策略(Same-origin policy):

如果兩個 URL 的協議、域名和端口都相同,我們就稱這兩個 URL 同源瀏覽器默認兩個相同的源之間是可以相互訪問資源和操作 DOM 的。兩個不同的源之間若想要相互訪問資源或者操作 DOM,那麼會有一套基礎的安全策略的制約。
同源策略主要表現在 DOM、Web 數據和網絡這三個層面
第一個,DOM 層面。同源策略限制了來自不同源的 JavaScript 腳本對當前 DOM 對象讀和寫的操作。
第二個,數據層面。同源策略限制了不同源的站點讀取當前站點的 Cookie、IndexDB、LocalStorage 等數據。
第三個,網絡層面。同源策略限制了通過 XMLHttpRequest 等方式將站點的數據發送給不同源的站點。

安全和便利性的權衡

不過安全性和便利性是相互對立的,讓不同的源之間絕對隔離,無疑是最安全的措施,但這也會使得 Web 項目難以開發和使用。因此我們就要在這之間做出權衡,出讓一些安全性來滿足靈活性;而出讓安全性又帶來了很多安全問題,最典型的是 XSS 攻擊和 CSRF 攻擊。
覽器出讓了同源策略的哪些安全性:

1. 頁面中可以嵌入第三方資源

將不同的資源部署到不同的 CDN 上時,CDN 上的資源就部署在另外一個域名上,因此我們就需要同源策略對頁面的引用資源開一個“口子”,讓其任意引用外部文件。
所以最初的瀏覽器都是支持外部引用資源文件的,不過這也帶來了很多問題。之前在開發瀏覽器的時候,遇到最多的一個問題是瀏覽器的首頁內容會被一些惡意程序劫持,劫持的途徑很多,其中最常見的是惡意程序通過各種途徑往 HTML 文件中插入惡意腳本。

image.png
這段 HTML 文件的數據被送達瀏覽器時,瀏覽器是無法區分被插入的文件是惡意的還是正常的,這樣惡意腳本就寄生在頁面之中,當頁面啓動時,它可以修改用戶的搜索結果、改變一些內容的連接指向,等等。
除此之外,它還能將頁面的的敏感數據,如 Cookie、IndexDB、LoacalStorage 等數據通過 XSS 的手段發送給服務器。
以上就是一個非常典型的 XSS 攻擊。爲了解決 XSS 攻擊,瀏覽器中引入了內容安全策略,稱爲 CSP。CSP 的核心思想是讓服務器決定瀏覽器能夠加載哪些資源,讓服務器決定瀏覽器是否能夠執行內聯 JavaScript 代碼。通過這些手段就可以大大減少 XSS 攻擊。

2. 跨域資源共享和跨文檔消息機制

跨域資源共享(CORS),使用該機制可以進行跨域訪問控制,從而使跨域數據傳輸得以安全進行。
在實際應用中,經常需要兩個不同源的 DOM 之間進行通信,於是瀏覽器中又引入了跨文檔消息機制,可以通過 window.postMessage 的 JavaScript 接口來和不同源的 DOM 進行通信。

跨站腳本攻擊(XSS):爲什麼Cookie中有HttpOnly屬性?

什麼是 XSS 攻擊

XSS 全稱是 Cross Site Scripting,爲了與“CSS”區分開來,故簡稱 XSS,翻譯過來就是“跨站腳本”。XSS 攻擊是指黑客往 HTML 文件中或者 DOM 中注入惡意腳本,從而在用戶瀏覽頁面時利用注入的惡意腳本對用戶實施攻擊的一種手段。
最開始的時候,這種攻擊是通過跨域來實現的,所以叫“跨域腳本”。但是發展到現在,往 HTML 文件中注入惡意代碼的方式越來越多了,所以是否跨域注入腳本已經不是唯一的注入手段了,但是 XSS 這個名字卻一直保留至今。
當頁面被注入了惡意 JavaScript 腳本時,瀏覽器無法區分這些腳本是被惡意注入的還是正常的頁面內容,所以惡意注入 JavaScript 腳本也擁有所有的腳本權限。
惡意腳本能作甚?

  • 可以竊取 Cookie 信息。惡意 JavaScript 可以通過“document.cookie”獲取 Cookie 信息,然後通過 XMLHttpRequest 或者 Fetch 加上 CORS 功能將數據發送給惡意服務器;惡意服務器拿到用戶的 Cookie 信息之後,就可以在其他電腦上模擬用戶的登錄,然後進行轉賬等操作。
  • 可以監聽用戶行爲。惡意 JavaScript 可以使用“addEventListener”接口來監聽鍵盤事件,比如可以獲取用戶輸入的信用卡等信息,將其發送到惡意服務器。黑客掌握了這些信息之後,又可以做很多違法的事情。
  • 可以通過修改 DOM僞造假的登錄窗口,用來欺騙用戶輸入用戶名和密碼等信息。
  • 還可以在頁面內生成浮窗廣告,這些廣告會嚴重地影響用戶體驗。

惡意腳本是怎麼注入的

1. 存儲型 XSS 攻擊

image.png

  • 首先黑客利用站點漏洞將一段惡意 JavaScript 代碼提交到網站的數據庫中;
  • 然後用戶向網站請求包含了惡意 JavaScript 腳本的頁面;
  • 當用戶瀏覽該頁面的時候,惡意腳本就會將用戶的 Cookie 信息等數據上傳到服務器

2. 反射型 XSS 攻擊

在一個反射型 XSS 攻擊過程中,惡意 JavaScript 腳本屬於用戶發送給網站請求中的一部分,隨後網站又把惡意 JavaScript 腳本返回給用戶。當惡意 JavaScript 腳本在用戶頁面中被執行時,黑客就可以利用該腳本做一些惡意操作。在現實生活中,黑客經常會通過 QQ 羣或者郵件等渠道誘導用戶去點擊這些惡意鏈接,所以對於一些鏈接我們一定要慎之又慎。
另外需要注意的是,Web 服務器不會存儲反射型 XSS 攻擊的惡意腳本,這是和存儲型 XSS 攻擊不同的地方

3. 基於 DOM 的 XSS 攻擊

基於 DOM 的 XSS 攻擊是不牽涉到頁面 Web 服務器的。具體來講,黑客通過各種手段將惡意腳本注入用戶的頁面中,比如通過網絡劫持在頁面傳輸過程中修改 HTML 頁面的內容,這種劫持類型很多,有通過 WiFi 路由器劫持的,有通過本地惡意軟件來劫持的,它們的共同點是在 Web 資源傳輸過程或者在用戶使用頁面的過程中修改 Web 頁面的數據

如何阻止 XSS 攻擊

通過阻止惡意 JavaScript 腳本的注入和惡意消息的發送來實現。

1. 服務器對輸入腳本進行過濾或轉碼

2. 充分利用 CSP

實施嚴格的 CSP 可以有效地防範 XSS 攻擊,具體來講 CSP 有如下幾個功能:

  • 限制加載其他域下的資源文件,這樣即使黑客插入了一個 JavaScript 文件,這個 JavaScript 文件也是無法被加載的;
  • 禁止向第三方域提交數據,這樣用戶數據也不會外泄;
  • 禁止執行內聯腳本和未授權的腳本;
  • 還提供了上報機制,這樣可以幫助我們儘快發現有哪些 XSS 攻擊,以便儘快修復問題。

因此,利用好 CSP 能夠有效降低 XSS 攻擊的概率。

3. 使用 HttpOnly 屬性

由於很多 XSS 攻擊都是來盜用 Cookie 的,因此還可以通過使用 HttpOnly 屬性來保護我們 Cookie 的安全。
通常服務器可以將某些 Cookie 設置爲 HttpOnly 標誌,HttpOnly 是服務器通過 HTTP 響應頭來設置的。。顧名思義,使用 HttpOnly 標記的 Cookie 只能使用在 HTTP 請求過程中,所以無法通過 JavaScript 來讀取這段 Cookie。
由於 JavaScript 無法讀取設置了 HttpOnly 的 Cookie 數據,所以即使頁面被注入了惡意 JavaScript 腳本,也是無法獲取到設置了 HttpOnly 的數據。因此一些比較重要的數據我們建議設置 HttpOnly 標誌。

CSRF攻擊:陌生鏈接不要隨便點

案例

image.png
首先 David 發起登錄 Gmail 郵箱請求,然後 Gmail 服務器返回一些登錄狀態給 David 的瀏覽器,這些信息包括了 Cookie、Session 等,這樣在 David 的瀏覽器中,Gmail 郵箱就處於登錄狀態了。
接着黑客通過各種手段引誘 David 去打開他的鏈接,比如 hacker.com,然後在 hacker.com 頁面中,黑客編寫好了一個郵件過濾器,並通過 Gmail 提供的 HTTP 設置接口設置好了新的郵件過濾功能,該過濾器會將 David 所有的郵件都轉發到黑客的郵箱中。
最後的事情就很簡單了,因爲有了 David 的郵件內容,所以黑客就可以去域名服務商那邊重置 David 域名賬戶的密碼,重置好密碼之後,就可以將其轉出到黑客的賬戶了。

什麼是 CSRF 攻擊

CSRF 英文全稱是 Cross-site request forgery,所以又稱爲“跨站請求僞造”,是指黑客引誘用戶打開黑客的網站,在黑客的網站中,利用用戶的登錄狀態發起的跨站請求。簡單來講,CSRF 攻擊就是黑客利用了用戶的登錄狀態,並通過第三方的站點來做一些壞事
黑客有三種方式去實施 CSRF 攻擊

1. 自動發起 Get 請求

<!DOCTYPE html>
<html>
  <body>
    <h1> 黑客的站點:CSRF 攻擊演示 </h1>
    <img src="https://time.geekbang.org/sendcoin?user=hacker&number=100">
  </body>
</html>

2. 自動發起 POST 請求

<!DOCTYPE html>
<html>
<body>
  <h1> 黑客的站點:CSRF 攻擊演示 </h1>
  <form id='hacker-form' action="https://time.geekbang.org/sendcoin" method=POST>
    <input type="hidden" name="user" value="hacker" />
    <input type="hidden" name="number" value="100" />
  </form>
  <script> document.getElementById('hacker-form').submit(); </script>
</body>
</html>

3. 引誘用戶點擊鏈接

<div>
  <img width=150 src=http://images.xuejuzi.cn/1612/1_161230185104_1.jpg> </img> </div> <div>
  <a href="https://time.geekbang.org/sendcoin?user=hacker&number=100" taget="_blank">
    點擊下載美女照片
  </a>
</div>

和 XSS 不同的是,CSRF 攻擊不需要將惡意代碼注入用戶的頁面,僅僅是利用服務器的漏洞和用戶的登錄狀態來實施攻擊

如何防止 CSRF 攻擊

發起 CSRF 攻擊的三個必要條件:
第一個,目標站點一定要有 CSRF 漏洞;
第二個,用戶要登錄過目標站點,並且在瀏覽器上保持有該站點的登錄狀態;
第三個,需要用戶打開一個第三方站點,可以是黑客的站點,也可以是一些論壇。
要讓服務器避免遭受到 CSRF 攻擊,通常有以下幾種途徑

1. 充分利用好 Cookie 的 SameSite 屬性

黑客會利用用戶的登錄狀態來發起 CSRF 攻擊,而Cookie 正是瀏覽器和服務器之間維護登錄狀態的一個關鍵數據,因此要阻止 CSRF 攻擊,首先就要考慮在 Cookie 上來做文章。
通常 CSRF 攻擊都是從第三方站點發起的,要防止 CSRF 攻擊,我們最好能實現從第三方站點發送請求時禁止 Cookie 的發送,因此在瀏覽器通過不同來源發送 HTTP 請求時,有如下區別:
如果是從第三方站點發起的請求,那麼需要瀏覽器禁止發送某些關鍵 Cookie 數據到服務器;
如果是同一個站點發起的請求,那麼就需要保證 Cookie 數據正常發送。
在 HTTP 響應頭中,通過 set-cookie 字段設置 Cookie 時,可以帶上 SameSite 選項

set-cookie: 1P_JAR=2019-10-20-06; expires=Tue, 19-Nov-2019 06:36:21 GMT; path=/; d

SameSite 選項通常有 Strict、Lax 和 None 三個值。

  • Strict 最爲嚴格。如果 SameSite 的值是 Strict,那麼瀏覽器會完全禁止第三方 Cookie。
  • Lax 相對寬鬆一點。在跨站點的情況下,從第三方站點的鏈接打開和從第三方站點提交 Get 方式的表單這兩種方式都會攜帶 Cookie。但如果在第三方站點中使用 Post 方法,或者通過 img、iframe 等標籤加載的 URL,這些場景都不會攜帶 Cookie。
  • 而如果使用 None 的話,在任何情況下都會發送 Cookie 數據。

2. 驗證請求的來源站點

Referer 是 HTTP 請求頭中的一個字段,記錄了該 HTTP 請求的來源地址。雖然可以通過 Referer 告訴服務器 HTTP 請求的來源,但是有一些場景是不適合將來源 URL 暴露給服務器的,因此瀏覽器提供給開發者一個選項,可以不用上傳 Referer 值,具體可參考Referrer Policy
但在服務器端驗證請求頭中的 Referer 並不是太可靠,因此標準委員會又制定了Origin 屬性,在一些重要的場合,比如通過 XMLHttpRequest、Fecth 發起跨站請求或者通過 Post 方法發送請求時,都會帶上 Origin 屬性

image.png

3. CSRF Token

採用 CSRF Token 來驗證,這個流程比較好理解,大致分爲兩步。
第一步,在瀏覽器向服務器發起請求時,服務器生成一個 CSRF Token。CSRF Token 其實就是服務器生成的字符串,然後將該字符串植入到返回的頁面中。
第二步,在瀏覽器端如果要發起轉賬的請求,那麼需要帶上頁面中的 CSRF Token,然後服務器會驗證該 Token 是否合法。如果是從第三方站點發出的請求,那麼將無法獲取到 CSRF Token 的值,所以即使發出了請求,服務器也會因爲 CSRF Token 不正確而拒絕請求。

安全沙箱:頁面和系統之間的隔離牆

安全視角下的多進程架構

image.png
所有的網絡資源都是通過瀏覽器內核來下載的,下載後的資源會通過 IPC 將其提交給渲染進程(瀏覽器內核和渲染進程之間都是通過 IPC 來通信的)。然後渲染進程會對這些資源進行解析、繪製等操作,最終生成一幅圖片。但是渲染進程並不負責將圖片顯示到界面上,而是將最終生成的圖片提交給瀏覽器內核模塊,由瀏覽器內核模塊負責顯示這張圖片。

安全沙箱

由於渲染進程需要執行 DOM 解析、CSS 解析、網絡圖片解碼等操作,如果渲染進程中存在系統級別的漏洞,那麼以上操作就有可能讓惡意的站點獲取到渲染進程的控制權限,進而又獲取操作系統的控制權限,這對於用戶來說是非常危險的。
因爲網絡資源的內容存在着各種可能性,所以瀏覽器會默認所有的網絡資源都是不可信的,都是不安全的。但誰也不能保證瀏覽器不存在漏洞,只要出現漏洞,黑客就可以通過網絡內容對用戶發起攻擊。
基於以上原因,需要在渲染進程和操作系統之間建一道牆,即便渲染進程由於存在漏洞被黑客攻擊,但由於這道牆,黑客就獲取不到渲染進程之外的任何操作權限。將渲染進程和操作系統隔離的這道牆就是安全沙箱
瀏覽器中的安全沙箱是利用操作系統提供的安全技術,讓渲染進程在執行過程中無法訪問或者修改操作系統中的數據,在渲染進程需要訪問系統資源的時候,需要通過瀏覽器內核來實現,然後將訪問的結果通過 IPC 轉發給渲染進程。
安全沙箱最小的保護單位是進程。因爲單進程瀏覽器需要頻繁訪問或者修改操作系統的數據,所以單進程瀏覽器是無法被安全沙箱保護的,而現代瀏覽器採用的多進程架構使得安全沙箱可以發揮作用。

安全沙箱如何影響各個模塊功能

安全沙箱最小的保護單位是進程,並且能限制進程對操作系統資源的訪問和修改,這就意味着如果要讓安全沙箱應用在某個進程上,那麼這個進程必須沒有讀寫操作系統的功能,比如讀寫本地文件、發起網絡請求、調用 GPU 接口等。

image.png
那安全沙箱是如何影響到各個模塊功能的呢?

1. 持久存儲

由於安全沙箱需要負責確保渲染進程無法直接訪問用戶的文件系統,但是在渲染進程內部有訪問 Cookie 的需求、有上傳文件的需求,爲了解決這些文件的訪問需求,所以現代瀏覽器將讀寫文件的操作全部放在了瀏覽器內核中實現,然後通過 IPC 將操作結果轉發給渲染進程。
具體地講,如下文件內容的讀寫都是在瀏覽器內核中完成的:

  • 存儲 Cookie 數據的讀寫。通常瀏覽器內核會維護一個存放所有 Cookie 的 Cookie 數據庫,然後當渲染進程通過 JavaScript 來讀取 Cookie 時,渲染進程會通過 IPC 將讀取 Cookie 的信息發送給瀏覽器內核,瀏覽器內核讀取 Cookie 之後再將內容返回給渲染進程。
  • 一些緩存文件的讀寫也是由瀏覽器內核實現的,比如網絡文件緩存的讀取。

2.網絡訪問

同樣有了安全沙箱的保護,在渲染進程內部也是不能直接訪問網絡的,如果要訪問網絡,則需要通過瀏覽器內核。不過瀏覽器內核在處理 URL 請求之前,會檢查渲染進程是否有權限請求該 URL,比如檢查 XMLHttpRequest 或者 Fetch 是否是跨站點請求,或者檢測 HTTPS 的站點中是否包含了 HTTP 的請求。

3.用戶交互

通常情況下,如果你要實現一個 UI 程序,操作系統會提供一個界面給你,該界面允許應用程序與用戶交互,允許應用程序在該界面上進行繪製,比如 Windows 提供的是 HWND,Linux 提供的 X Window,我們就把 HWND 和 X Window 統稱爲窗口句柄。應用程序可以在窗口句柄上進行繪製和接收鍵盤鼠標消息。
不過在現代瀏覽器中,由於每個渲染進程都有安全沙箱的保護,所以在渲染進程內部是無法直接操作窗口句柄的,這也是爲了限制渲染進程監控到用戶的輸入事件。
由於渲染進程不能直接訪問窗口句柄,所以渲染進程需要完成以下兩點大的改變。
第一點,渲染進程需要渲染出位圖。爲了向用戶顯示渲染進程渲染出來的位圖,渲染進程需要將生成好的位圖發送到瀏覽器內核,然後瀏覽器內核將位圖複製到屏幕上。
第二點,操作系統沒有將用戶輸入事件直接傳遞給渲染進程,而是將這些事件傳遞給瀏覽器內核。然後瀏覽器內核再根據當前瀏覽器界面的狀態來判斷如何調度這些事件,如果當前焦點位於瀏覽器地址欄中,則輸入事件會在瀏覽器內核內部處理;如果當前焦點在頁面的區域內,則瀏覽器內核會將輸入事件轉發給渲染進程。

站點隔離(Site Isolation)

所謂站點隔離是指 Chrome 將同一站點(包含了相同根域名和相同協議的地址)中相互關聯的頁面放到同一個渲染進程中執行。
最開始 Chrome 劃分渲染進程是以標籤頁爲單位,也就是說整個標籤頁會被劃分給某個渲染進程。但是,按照標籤頁劃分渲染進程存在一些問題,原因就是一個標籤頁中可能包含了多個 iframe,而這些 iframe 又有可能來自於不同的站點,這就導致了多個不同站點中的內容通過 iframe 同時運行在同一個渲染進程中。
目前所有操作系統都面臨着兩個 A 級漏洞——幽靈(Spectre)和熔燬(Meltdown),這兩個漏洞是由處理器架構導致的,很難修補,黑客通過這兩個漏洞可以直接入侵到進程的內部,如果入侵的進程沒有安全沙箱的保護,那麼黑客還可以發起對操作系統的攻擊。
將標籤級的渲染進程重構爲 iframe 級的渲染進程,然後嚴格按照同一站點的策略來分配渲染進程,這就是 Chrome 中的站點隔離。

HTTPS:讓數據傳輸更安全

image.png
中間人攻擊

在 HTTP 協議棧中引入安全層

image.png
對發起 HTTP 請求的數據進行加密操作對接收到 HTTP 的內容進行解密操作

第一版:使用對稱加密

對稱加密是指加密和解密都使用的是相同的密鑰

image.png
具體過程如下:

            - 瀏覽器發送它所支持的加密套件列表和一個隨機數 client-random,這裏的**加密套件是指加密的方法**,加密套件列表就是指瀏覽器能支持多少種加密方法列表。
            - 服務器會從加密套件列表中選取一個加密套件,然後還會生成一個隨機數 service-random,並將 service-random 和加密套件列表返回給瀏覽器。
            - 最後瀏覽器和服務器分別返回確認消息。

雖然這個版本能夠很好地工作,但是其中傳輸 client-random 和 service-random 的過程卻是明文的,這意味着黑客也可以拿到協商的加密套件和雙方的隨機數,由於利用隨機數合成密鑰的算法是公開的,所以黑客拿到隨機數之後,也可以合成密鑰,這樣數據依然可以被破解,那麼黑客也就可以使用密鑰來僞造或篡改數據了。

第二版:使用非對稱加密

非對稱加密算法有 A、B 兩把密鑰,如果你用 A 密鑰來加密,那麼只能使用 B 密鑰來解密;反過來,如果你要 B 密鑰來加密,那麼只能用 A 密鑰來解密
在 HTTPS 中,服務器會將其中的一個密鑰通過明文的形式發送給瀏覽器,我們把這個密鑰稱爲公鑰,服務器自己留下的那個密鑰稱爲私鑰。顧名思義,公鑰是每個人都能獲取到的,而私鑰只有服務器才能知道,不對任何人公開

image.png
非對稱加密的請求流程。

            - 首先瀏覽器還是發送加密套件列表給服務器。
            - 然後服務器會選擇一個加密套件,不過和對稱加密不同的是,使用非對稱加密時服務器上需要有用於瀏覽器加密的公鑰和服務器解密 HTTP 數據的私鑰,由於公鑰是給瀏覽器加密使用的,因此服務器會將加密套件和公鑰一道發送給瀏覽器。
            - 最後就是瀏覽器和服務器返回確認消息。

採用非對稱加密,就能保證瀏覽器發送給服務器的數據是安全的了,這看上去似乎很完美,不過這種方式依然存在兩個嚴重的問題。
第一個是非對稱加密的效率太低。這會嚴重影響到加解密數據的速度,進而影響到用戶打開頁面的速度。
第二個是無法保證服務器發送給瀏覽器的數據安全。雖然瀏覽器端可以使用公鑰來加密,但是服務器端只能採用私鑰來加密,私鑰加密只有公鑰能解密,但黑客也是可以獲取得到公鑰的,這樣就不能保證服務器端數據的安全了。

第三版:對稱加密和非對稱加密搭配使用

在傳輸數據階段依然使用對稱加密,但是對稱加密的密鑰採用非對稱加密來傳輸
image.png
改造後的流程是這樣的:

  • 首先瀏覽器向服務器發送對稱加密套件列表、非對稱加密套件列表和隨機數 client-random;
  • 服務器保存隨機數 client-random,選擇對稱加密和非對稱加密的套件,然後生成隨機數 service-random,向瀏覽器發送選擇的加密套件、service-random 和公鑰;
  • 瀏覽器保存公鑰,並利用 client-random 和 service-random 計算出來 pre-master,然後利用公鑰對 pre-master 加密,並向服務器發送加密後的數據;
  • 最後服務器拿出自己的私鑰,解密出 pre-master 數據,並返回確認消息。

第四版:添加數字證書

極客時間要證明這個服務器就是極客時間的,也需要使用權威機構頒發的證書,這個權威機構稱爲CA(Certificate Authority),頒發的證書就稱爲數字證書(Digital Certificate)
數字證書有兩個作用:一個是通過數字證書向瀏覽器證明服務器的身份,另一個是數字證書裏面包含了服務器公鑰。

image.png
這裏主要有兩點改變:

  • 服務器沒有直接返回公鑰給瀏覽器,而是返回了數字證書,而公鑰正是包含在數字證書中的;
  • 在瀏覽器端多了一個證書驗證的操作,驗證了證書之後,才繼續後續流程。

數字證書的申請和驗證

如何申請數字證書

  1. 首先極客時間需要準備一套私鑰和公鑰,私鑰留着自己使用;
  2. 然後極客時間向 CA 機構提交公鑰、公司、站點等信息並等待認證,這個認證過程可能是收費的;
  3. CA 通過線上、線下等多種渠道來驗證極客時間所提供信息的真實性,如公司是否存在、企業是否合法、域名是否歸屬該企業等;
  4. 如信息審覈通過,CA 會向極客時間簽發認證的數字證書,包含了極客時間的公鑰、組織信息、CA 的信息、有效時間、證書序列號等,這些信息都是明文的,同時包含一個 CA 生成的簽名

瀏覽器如何驗證數字證書

有了 CA 簽名過的數字證書,當瀏覽器向極客時間服務器發出請求時,服務器會返回數字證書給瀏覽器。
瀏覽器接收到數字證書之後,會對數字證書進行驗證。首先瀏覽器讀取證書中相關的明文信息,採用 CA 簽名時相同的 Hash 函數來計算並得到信息摘要 A;然後再利用對應 CA 的公鑰解密簽名數據,得到信息摘要 B;對比信息摘要 A 和信息摘要 B,如果一致,則可以確認證書是合法的,即證明了這個服務器是極客時間的;同時瀏覽器還會驗證證書相關的域名信息、有效時間等信息。
這時候相當於驗證了 CA 是誰,但是這個 CA 可能比較小衆,瀏覽器不知道該不該信任它,然後瀏覽器會繼續查找給這個 CA 頒發證書的 CA,再以同樣的方式驗證它上級 CA 的可靠性。通常情況下,操作系統中會內置信任的頂級 CA 的證書信息(包含公鑰),如果這個 CA 鏈中沒有找到瀏覽器內置的頂級的 CA,證書也會被判定非法。
在申請和使用證書的過程中,還需要注意以下三點:

  1. 申請數字證書是不需要提供私鑰的,要確保私鑰永遠只能由服務器掌握;
  2. 數字證書最核心的是 CA 使用它的私鑰生成的數字簽名;
  3. 內置 CA 對應的證書稱爲根證書,根證書是最權威的機構,它們自己爲自己簽名,我們把這稱爲自簽名證書。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章