一個進程就是一個程序的運行實例。javascript
詳細解釋就是,啓動一個程序的時候,操做系統會爲該程序建立一塊內存,用來存放代碼、運行中的數據和一個執行任務的主線程,咱們把這樣的一個運行環境叫進程。css
單線程與多線程的進程對比圖html
線程是依附於進程的,而進程中使用多線程並行處理能提高運算效率。java
進程和線程之間的關係有如下 4 個特色。node
進程中的任意一線程執行出錯,都會致使整個進程的崩潰。webpack
線程之間共享進程中的數據。web
線程之間共享進程中的數據示意圖算法
從上圖能夠看出,線程 一、線程 二、線程 3 分別把執行的結果寫入 A、B、C 中,而後線程 2 繼續從 A、B、C 中讀取數據,用來顯示執行結果。chrome
當一個進程關閉以後,操做系統會回收進程所佔用的內存。promise
進程之間的內容相互隔離。
單進程瀏覽器是指瀏覽器的全部功能模塊都是運行在同一個進程裏
如此多的功能模塊運行在一個進程裏,是致使單進程瀏覽器不穩定、不流暢和不安全的一個主要因素。
chrome 進程架構
Chrome 的頁面是運行在單獨的渲染進程中的,同時頁面裏的插件也是運行在單獨的插件進程之中,而進程之間是經過 IPC 機制進行通訊(如圖中虛線部分)。
經過單獨進程模式來解決單進程瀏覽器碰到 不穩定 不流暢的問題,經過sandbox(安全沙箱)來解決安全問題。
Chrome 把插件進程和渲染進程鎖在沙箱裏面,這樣即便在渲染進程或者插件進程裏面執行了惡意程序,惡意程序也沒法突破沙箱去獲取系統權限。
從圖中能夠看出,最新的 Chrome 瀏覽器包括:1 個瀏覽器(Browser)主進程、1 個 GPU 進程、1 個網絡(NetWork)進程、多個渲染進程和多個插件進程。
打開 1 個頁面至少須要 1 個網絡進程、1 個瀏覽器進程、1 個 GPU 進程以及 1 個渲染進程,共 4 個;若是打開的頁面有運行插件的話,還須要再加上 1 個插件進程。
負面問題:
爲了解決這些問題,在 2016 年,Chrome 官方團隊使用**"面向服務的架構"**(Services Oriented Architecture,簡稱 SOA)的思想設計了新的 Chrome 架構。
也就是說 Chrome 總體架構會朝向現代操做系統所採用的「面向服務的架構」 方向發展,原來的各類模塊會被重構成獨立的服務(Service),每一個服務(Service)均可以在獨立的進程中運行,訪問服務(Service)必須使用定義好的接口,經過 IPC 來通訊,從而構建一個更內聚、鬆耦合、易於維護和擴展的系統,更好實現 Chrome 簡單、穩定、高速、安全的目標。
Chrome「面向服務的架構」進程模型圖
同時 Chrome 還提供靈活的彈性架構,在強大性能設備上會以多進程的方式運行基礎服務,可是若是在資源受限的設備上(以下圖),Chrome 會將不少服務整合到一個進程中,從而節省內存佔用。
在資源不足的設備上,將服務合併到瀏覽器進程中
數據包要在互聯網上進行傳輸,就要符合網際協議(Internet Protocol,簡稱 IP)標準。
計算機的地址就稱爲 IP 地址,訪問任何網站實際上只是你的計算機向另一臺計算機請求信息。
無需創建鏈接就能夠發送封裝的 IP 數據報的方法
IP 是很是底層的協議,只負責把數據包傳送到對方電腦,可是對方電腦並不知道把數據包交給哪一個程序,是交給瀏覽器仍是交給xxx?所以,須要基於 IP 之上開發能和應用打交道的協議,最多見的是「用戶數據包協議(User Datagram Protocol)」,簡稱 UDP。
UDP 中一個最重要的信息是端口號,端口號其實就是一個數字,每一個想訪問網絡的程序都須要綁定一個端口號。經過端口號 UDP 就能把指定的數據包發送給指定的程序了,因此 IP 經過 IP 地址信息把數據包發送給指定的電腦,而 UDP 經過端口號把數據包分發給正確的程序。和 IP 頭同樣,端口號會被裝進 UDP 頭裏面,UDP 頭再和原始數據包合併組成新的 UDP 數據包。UDP 頭中除了目的端口,還有源端口號等信息。
簡化的 UDP 網絡四層傳輸模型
在使用 UDP 發送數據時,有各類因素會致使數據包出錯,雖然 UDP 能夠校驗數據是否正確,可是對於錯誤的數據包,UDP 並不提供重發機制,只是丟棄當前的包,並且 UDP 在發送以後也沒法知道是否能達到目的地。
雖然說 UDP 不能保證數據可靠性,可是傳輸速度卻很是快,因此 UDP 會應用在一些關注速度、但不那麼嚴格要求數據完整性的領域,如在線視頻、互動遊戲等。
對於瀏覽器請求,或者郵件這類要求數據傳輸可靠性(reliability)的應用,若是使用 UDP 來傳輸會存在兩個問題:
基於這兩個問題,咱們引入 TCP 了。TCP(Transmission Control Protocol,傳輸控制協議)是一種面向鏈接的、可靠的、基於字節流的傳輸層通訊協議。相對於 UDP,TCP 有下面兩個特色:
和 UDP 頭同樣,TCP 頭除了包含了目標端口和本機端口號外,還提供了用於排序的序列號,以便接收端經過序號來重排數據包
簡化的 TCP 網絡四層傳輸模型
TCP 單個數據包的傳輸流程和 UDP 流程差很少,不一樣的地方在於,經過 TCP 頭的信息保證了一塊大的數據傳輸的完整性。
一個完整的 TCP 鏈接的生命週期包括了「創建鏈接」「傳輸數據」和「斷開鏈接」三個階段。
一個 TCP 鏈接的生命週期
TCP 爲了保證數據傳輸的可靠性,犧牲了數據包的傳輸速度,由於「三次握手」和「數據包校驗機制」等把傳輸過程當中的數據包的數量提升了一倍。
HTTP協議和TCP協議都是TCP/IP協議簇的子集。
HTTP協議屬於應用層,TCP協議屬於傳輸層,HTTP協議位於TCP協議的上層。
請求方要發送的數據包,在應用層加上HTTP頭之後會交給傳輸層的TCP協議處理,應答方接收到的數據包,在傳輸層拆掉TCP頭之後交給應用層的HTTP協議處理。創建 TCP 鏈接後會順序收發數據,請求方和應答方都必須依據 HTTP 規範構建和解析HTTP報文。
tcp協議是傳輸協議,如何運輸,運輸內容就是http協議中的報文。
構建請求
查找緩存
準備 IP 地址和端口
瀏覽器使用 HTTP 協議做爲應用層協議,用來封裝請求的文本信息;並使用 TCP/IP 做傳輸層協議將它發到網絡上,因此在 HTTP 工做開始以前,瀏覽器須要經過 TCP 與服務器創建鏈接。也就是說 HTTP 的內容是經過 TCP 的傳輸數據階段來實現的
TCP 和 HTTP 的關係示意圖
把域名和 IP 地址作一一映射關係。這套域名映射爲 IP 的系統就叫作「域名系統」
第一步瀏覽器會請求 DNS 返回域名對應的 IP。 (包含DNS 數據緩存服務)
等待 TCP 隊列。(同一個域名同時最多隻能創建 6 個 TCP 鏈接,若是在同一個域名下同時有 10 個請求發生,那麼其中 4 個請求會進入排隊等待狀態,直至進行中的請求完成。)
創建 TCP 鏈接
發送 HTTP 請求
HTTP 請求數據格式
首先瀏覽器會向服務器發送請求行,它包括了請求方法、請求 URI(Uniform Resource Identifier)和 HTTP 版本協議。
服務器端處理 HTTP 請求流程
返回請求
服務器返回內容
斷開鏈接
一般狀況下,一旦服務器向客戶端返回了請求數據,它就要關閉 TCP 鏈接。不過若是瀏覽器或者服務器在其頭信息中加入了:Connection:Keep-Alive
那麼 TCP 鏈接在發送後將仍然保持打開狀態,這樣瀏覽器就能夠繼續經過同一個 TCP 鏈接發送請求。保持 TCP 鏈接能夠省去下次請求時須要創建鏈接的時間,提高資源加載速度。好比,一個 Web 頁面中內嵌的圖片就都來自同一個 Web 站點,若是初始化了一個持久鏈接,你就能夠複用該鏈接,以請求其餘資源,而不須要從新再創建新的 TCP 鏈接。
重定向
緩存
緩存查找流程示意圖
總結圖
HTTP 請求流程示意圖
從輸入 URL 到頁面展現完整流程示意圖
大體步驟
詳細步驟
用戶輸入
URL 請求過程
瀏覽器進程會經過進程間通訊(IPC)把 URL 請求發送至網絡進程,網絡進程接收到 URL 請求後,會在這裏發起真正的 URL 請求流程。
首先,網絡進程會查找本地緩存是否緩存了該資源。若是有緩存資源,那麼直接返回資源給瀏覽器進程;若是在緩存中沒有查找到資源,那麼直接進入網絡請求流程。這請求前的第一步是要進行 DNS 解析,以獲取請求域名的服務器 IP 地址。若是請求協議是 HTTPS,那麼還須要創建 TLS 鏈接
https和http區別
接下來就是利用 IP 地址和服務器創建 TCP 鏈接。鏈接創建以後,瀏覽器端會構建請求行、請求頭等信息,並把和該域名相關的 Cookie 等數據附加到請求頭中,而後向服務器發送構建的請求信息。
重定向
響應數據類型處理
準備渲染進程
提交文檔
渲染階段
一旦文檔被提交,渲染進程便開始頁面解析和子資源加載了
構建 DOM 樹
樣式計算(Recalculate Style)
樣式計算的目的是爲了計算出 DOM 節點中每一個元素的具體樣式,這個階段大致可分爲三步來完成。
把 CSS 轉換爲瀏覽器可以理解的結構
當渲染引擎接收到 CSS 文本時,會執行一個轉換操做,將 CSS 文本轉換爲瀏覽器能夠理解的結構——styleSheets。
styleSheets
轉換樣式表中的屬性值,使其標準化
CSS 文本中有不少屬性值,如 2em、blue、bold,這些類型數值不容易被渲染引擎理解,因此須要將全部值轉換爲渲染引擎容易理解的、標準化的計算值,這個過程就是屬性值標準化。
標準化屬性值
計算出 DOM 樹中每一個節點的具體樣式
CSS 的繼承規則
body { font-size: 20px }
p {color:blue;}
span {display: none}
div {font-weight: bold;color:red}
div p {color:green;}
複製代碼
計算後 DOM 的樣式
層疊規則
層疊是 CSS 的一個基本特徵,它是一個定義瞭如何合併來自多個源的屬性值的算法。
樣式計算階段的目的是爲了計算出 DOM 節點中每一個元素的具體樣式,在計算過程當中須要遵照 CSS 的繼承和層疊兩個規則。這個階段最終輸出的內容是每一個 DOM 節點的樣式,並被保存在 ComputedStyle 的結構內。
佈局
計算出 DOM 樹中可見元素的幾何位置,咱們把這個計算過程叫作佈局。
Chrome 在佈局階段須要完成兩個任務:建立佈局樹和佈局計算。
建立佈局樹
佈局樹構造過程示意圖
爲了構建佈局樹,瀏覽器大致上完成了下面這些工做:
佈局計算
在執行佈局操做的時候,會把佈局運算的結果從新寫回佈局樹中,因此佈局樹既是輸入內容也是輸出內容。
這是佈局階段一個不合理的地方,由於在佈局階段並無清晰地將輸入內容和輸出內容區分開來。針對這個問題,Chrome 團隊正在重構佈局代碼,下一代佈局系統叫 LayoutNG,試圖更清晰地分離輸入和輸出,從而讓新設計的佈局算法更加簡單。
若是下載 CSS 文件阻塞了,會阻塞 DOM 樹的合成嗎?會阻塞頁面的顯示嗎?
分層
頁面中有不少複雜的效果,如一些複雜的 3D 變換、頁面滾動,或者使用 z-indexing 作 z 軸排序等,爲了更加方便地實現這些效果,渲染引擎還須要爲特定的節點生成專用的圖層,並生成一棵對應的圖層樹(LayerTree)
佈局樹和圖層樹關係示意圖
一般狀況下,並非佈局樹的每一個節點都包含一個圖層,若是一個節點沒有對應的層,那麼這個節點就從屬於父節點的圖層。
那麼須要知足什麼條件,渲染引擎纔會爲特定的節點建立新的層呢?
一般知足下面兩點中任意一點的元素就能夠被提高爲單獨的一個圖層。
擁有層疊上下文屬性的元素會被提高爲單獨的一層。
根元素 (HTML)
z-index
值不爲auto
的絕對/相對定位元素
固定(fixed
) / 沾滯(sticky
)定位(沾滯定位適配全部移動設備上的瀏覽器,但老的桌面瀏覽器不支持)
z-index
值不爲auto
的flex(flexbox
)子項 (flex item),即:父元素display: flex|inline-flex
,
opacity
屬性值小於1
的元素
mix-blend-mode
屬性值不爲normal
的元素。
如下任意屬性值不爲none
的元素:
isolation
屬性被設置爲isolate
的元素。
-webkit-overflow-scrolling
屬性被設置爲touch
的元素
在will-change
中指定了任意CSS屬性(參考這篇文章)
contain
屬性值爲layout
、paint
,或者綜合值(好比contain: strict
、contain: content
)。
在層疊上下文中,其子元素一樣也按照上面解釋的規則進行層疊。 特別值得一提的是,其子元素的
z-index
值只在父級層疊上下文中有意義。子級層疊上下文被自動視爲父級層疊上下文的一個獨立單元。總結:
- 層疊上下文能夠包含在其餘層疊上下文中,而且一塊兒建立一個有層級的層疊上下文。
- 每一個層疊上下文徹底獨立於它的兄弟元素:當處理層疊時只考慮子元素。
- 每一個層疊上下文是自包含的:當元素的內容發生層疊後,整個該元素將會在父層疊上下文中按順序進行層疊。
須要剪裁(clip)的地方也會被建立爲圖層。
圖層繪製
在完成圖層樹的構建以後,渲染引擎會對圖層樹中的每一個圖層進行繪製。
渲染引擎實現圖層的繪製與之相似,會把一個圖層的繪製拆分紅不少小的繪製指令,而後再把這些指令按照順序組成一個待繪製列表,以下圖所示:
繪製列表
從圖中能夠看出,繪製列表中的指令其實很是簡單,就是讓其執行一個簡單的繪製操做,好比繪製粉色矩形或者黑色的線等。而繪製一個元素一般須要好幾條繪製指令,由於每一個元素的背景、前景、邊框都須要單獨的指令去繪製。因此在圖層繪製階段,輸出的內容就是這些待繪製列表。
柵格化操做(raster)
繪製列表只是用來記錄繪製順序和繪製指令的列表,而實際上繪製操做是由渲染引擎中的合成線程來完成的。你能夠結合下圖來看下渲染主線程和合成線程之間的關係:
渲染進程中的合成線程和主線程
如上圖所示,當圖層的繪製列表準備好以後,主線程會把該繪製列表提交(commit)給合成線程
合成線程
一般一個頁面可能很大,可是用戶只能看到其中的一部分,咱們把用戶能夠看到的這個部分叫作視口(viewport)。
在有些狀況下,有的圖層能夠很大,好比有的頁面你使用滾動條要滾動很久才能滾動到底部,可是經過視口,用戶只能看到頁面的很小一部分,因此在這種狀況下,要繪製出全部圖層內容的話,就會產生太大的開銷,並且也沒有必要。
基於這個緣由,合成線程會將圖層劃分爲圖塊(tile)
合成線程會將圖層劃分爲圖塊。這些圖塊的大小一般是 256x256 或者 512x512,以下圖所示:
圖層被劃分爲圖塊示意圖
而後合成線程會按照視口附近的圖塊來優先生成位圖,實際生成位圖的操做是由柵格化來執行的。**所謂柵格化,是指將圖塊轉換爲位圖。**而圖塊是柵格化執行的最小單位。渲染進程維護了一個柵格化的線程池,全部的圖塊柵格化都是在線程池內執行的,運行方式以下圖所示:
一般,柵格化過程都會使用 GPU 來加速生成,使用 GPU 生成位圖的過程叫快速柵格化,或者 GPU 柵格化,生成的位圖被保存在 GPU 內存中。
從圖中能夠看出,渲染進程把生成圖塊的指令發送給 GPU,而後在 GPU 中執行生成圖塊的位圖,並保存在 GPU 的內存中。
合成和顯示
總結
完整的渲染流水線示意圖
結合上圖,一個完整的渲染流程大體可總結爲以下:
相關概念
「重排」
更新了元素的幾何屬性(重排)
更新元素的幾何屬性
從上圖能夠看出,若是你經過 JavaScript 或者 CSS 修改元素的幾何位置屬性,例如改變元素的寬度、高度等,那麼瀏覽器會觸發從新佈局,解析以後的一系列子階段,這個過程就叫重排。無疑,重排鬚要更新完整的渲染流水線,因此開銷也是最大的。
」重繪「
更新元素背景
從圖中能夠看出,若是修改了元素的背景顏色,那麼佈局階段將不會被執行,由於並無引發幾何位置的變換,因此就直接進入了繪製階段,而後執行以後的一系列子階段,這個過程就叫重繪。相較於重排操做,重繪省去了佈局和分層階段,因此執行效率會比重排操做要高一些。
"合成"
若是你更改一個既不要佈局也不要繪製的屬性,會發生什麼變化呢?渲染引擎將跳過佈局和繪製,只執行後續的合成操做,咱們把這個過程叫作合成。具體流程參考下圖:
避開重排和重繪
在上圖中,咱們使用了 CSS 的 transform 來實現動畫效果,這能夠避開重排和重繪階段,直接在非主線程上執行合成動畫操做。這樣的效率是最高的,由於是在非主線程上合成,並無佔用主線程的資源,另外也避開了佈局和繪製兩個子階段,因此相對於重繪和重排,合成能大大提高繪製效率。
變量提高(Hoisting)
所謂的變量提高,是指在 JavaScript 代碼執行過程當中,JavaScript 引擎把變量的聲明部分和函數的聲明部分提高到代碼開頭的「行爲」。變量被提高後,會給變量設置默認值,這個默認值就是咱們熟悉的 undefined。
實際上變量和函數聲明在代碼裏的位置是不會改變的,並且是在編譯階段被 JavaScript 引擎放入內存中。
做用域(scope)
做用域是指在程序中定義變量的區域,該位置決定了變量的生命週期。通俗地理解,做用域就是變量與函數的可訪問範圍,即做用域控制着變量和函數的可見性和生命週期。
在 ES6 以前,ES 的做用域只有兩種:全局做用域和函數做用域。
變量提高所帶來的問題
ES6 中的let和const
做用域塊內聲明的變量不影響塊外面的變量。
JavaScript 是如何支持塊級做用域的?
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()
複製代碼
第一步是編譯並建立執行上下文:
第二步繼續執行代碼
當執行到代碼塊裏面時,變量環境中 a 的值已經被設置成了 1,詞法環境中 b 的值已經被設置成了 2,這時候函數的執行上下文就以下圖所示:
執行 foo 函數內部做用域塊時的執行上下文
當進入函數的做用域塊時,做用域塊中經過 let 聲明的變量,會被存放在詞法環境的一個單獨的區域中,這個區域中的變量並不影響做用域塊外面的變量,好比在做用域外面聲明瞭變量 b,在該做用域塊內部也聲明瞭變量 b,當執行到做用域內部時,它們都是獨立的存在。
其實,在詞法環境內部,維護了一個小型棧結構,棧底是函數最外層的變量,進入一個做用域塊後,就會把該做用域塊內部的變量壓到棧頂;看成用域執行完成以後,該做用域的信息就會從棧頂彈出,這就是詞法環境的結構。須要注意下,我這裏所講的變量是指經過 let 或者 const 聲明的變量。
當執行到做用域塊中的console.log(a)這行代碼時,就須要在詞法環境和變量環境中查找變量 a 的值了,具體查找方式是:沿着詞法環境的棧頂向下查詢,若是在詞法環境中的某個塊中查找到了,就直接返回給 JavaScript 引擎,若是沒有查找到,那麼繼續在變量環境中查找。
這樣一個變量查找過程就完成了:
看成用域塊執行結束以後,其內部定義的變量就會從詞法環境的棧頂彈出,最終執行上下文以下圖所示:
做用域執行完成示意圖
做用域鏈&閉包
function bar() {
console.log(myName)
}
function foo() {
var myName = "極客邦"
bar()
}
var myName = "極客時間"
foo()
複製代碼
在每一個執行上下文的變量環境中,都包含了一個外部引用,用來指向外部的執行上下文,咱們把這個外部引用稱爲 outer。
當一段代碼使用了一個變量時,JavaScript 引擎首先會在「當前的執行上下文」中查找該變量,
好比上面那段代碼在查找 myName 變量時,若是在當前的變量環境中沒有查找到,那麼 JavaScript 引擎會繼續在 outer 所指向的執行上下文中查找。爲了直觀理解,你能夠看下面這張圖:
從圖中能夠看出,bar 函數和 foo 函數的 outer 都是指向全局上下文的,這也就意味着若是在 bar 函數或者 foo 函數中使用了外部變量,那麼 JavaScript 引擎會去全局執行上下文中查找。咱們把這個查找的鏈條就稱爲做用域鏈。
詞法做用域
詞法做用域就是指做用域是由代碼中函數聲明的位置來決定的,因此詞法做用域是靜態的做用域,經過它就可以預測代碼在執行過程當中如何查找標識符。
從圖中能夠看出,詞法做用域就是根據代碼的位置來決定的,其中 main 函數包含了 bar 函數,bar 函數中包含了 foo 函數,由於 JavaScript 做用域鏈是由詞法做用域決定的,因此整個詞法做用域鏈的順序是:
foo 函數做用域—>bar 函數做用域—>main 函數做用域—> 全局做用域。
詞法做用域是代碼階段就決定好的,和函數是怎麼調用的沒有關係。
塊級做用域中的變量查找
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()
複製代碼
塊級做用域中是如何查找變量的
在 JavaScript 中,根據詞法做用域的規則,內部函數老是能夠訪問其外部函數中聲明的變量,當經過調用一個外部函數返回一個內部函數後,即便該外部函數已經執行結束了,可是內部函數引用外部函數的變量依然保存在內存中,咱們就把這些變量的集合稱爲閉包。
閉包是怎麼回收的
原則:若是該閉包會一直使用,那麼它能夠做爲全局變量而存在;但若是使用頻率不高,並且佔用內存又比較大的話,那就儘可能讓它成爲一個局部變量。
This
箭頭函數
ES6 中的箭頭函數並不會建立其自身的執行上下文,因此箭頭函數中的 this 取決於它的外部函數。
**New **
var tempObj = {}
CreateObj.call(tempObj)
return tempObj
複製代碼
JavaScript 的執行流程圖
編譯階段
JavaScript 執行流程細化圖
第 1 行和第 2 行,因爲這兩行代碼不是聲明操做,因此 JavaScript 引擎不會作任何處理;
第 3 行,因爲這行是通過 var 聲明的,所以 JavaScript 引擎將在環境對象中建立一個名爲 myname 的屬性,並使用 undefined 對其初始化;
第 4 行,JavaScript 引擎發現了一個經過 function 定義的函數,因此它將函數定義存儲到堆 (HEAP)中,並在環境對象中建立一個 showName 的屬性,而後將該屬性值指向堆中函數的位置
執行上下文是 JavaScript 執行一段代碼時的運行環境
執行上下文
當 JavaScript 執行全局代碼的時候,會編譯全局代碼並建立全局執行上下文,並且在整個頁面的生存週期內,全局執行上下文只有一份。
當調用一個函數的時候,函數體內的代碼會被編譯,並建立函數執行上下文,通常狀況下,函數執行結束以後,建立的函數執行上下文會被銷燬。
當使用 eval 函數的時候,eval 的代碼也會被編譯,並建立執行上下文。
調用棧
調用棧就是用來管理函數調用關係的一種數據結構
什麼是函數調用
var a = 2
function add(){
var b = 10
return a+b
}
add()
複製代碼
在執行到函數 add() 以前,JavaScript 引擎會爲上面這段代碼建立全局執行上下文,包含了聲明的函數和變量,你能夠參考下圖:
全局執行上下文
從圖中能夠看出,代碼中全局變量和函數都保存在全局上下文的變量環境中。
執行上下文準備好以後,便開始執行全局代碼,當執行到 add 這兒時,JavaScript 判斷這是一個函數調用,那麼將執行如下操做:
首先,從全局執行上下文中,取出 add 函數代碼。
其次,對 add 函數的這段代碼進行編譯,並建立該函數的執行上下文和可執行代碼。
最後,執行代碼,輸出結果。
完整流程你能夠參考下圖:
就這樣,當執行到 add 函數的時候,咱們就有了兩個執行上下文了——全局執行上下文和 add 函數的執行上下文。
也就是說在執行 JavaScript 時,可能會存在多個執行上下文,JavaScript 引擎是經過一種叫棧的數據結構來管理的上下文的。
什麼是棧
關於棧,你能夠結合這麼一個貼切的例子來理解,一條單車道的單行線,一端被堵住了,而另外一端入口處沒有任何提示信息,堵住以後就只能後進去的車子先出來,這時這個堵住的單行線就能夠被看做是一個棧容器,車子開進單行線的操做叫作入棧,車子倒出去的操做叫作出棧。
在車流量較大的場景中,就會發生反覆的入棧、棧滿、出棧、空棧和再次入棧,一直循環。
因此,棧就是相似於一端被堵住的單行線,車子相似於棧中的元素,棧中的元素知足後進先出的特色。你能夠參看下圖:
什麼是 JavaScript 的調用棧
JavaScript 引擎正是利用棧的這種結構來管理執行上下文的。在執行上下文建立好後,JavaScript 引擎會將執行上下文壓入棧中,一般把這種用來管理執行上下文的棧稱爲執行上下文棧,又稱調用棧。
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)
複製代碼
第一步,建立全局上下文,並將其壓入棧底。以下圖所示:
全局執行上下文壓入到調用棧後,JavaScript 引擎便開始執行全局代碼了。首先會執行 a=2 的賦值操做,執行該語句會將全局上下文變量環境中 a 的值設置爲 2。設置後的全局上下文的狀態以下圖所示:
第二步 調用 addAll 函數
當調用該函數時,JavaScript 引擎會編譯該函數,併爲其建立一個執行上下文,最後還將該函數的執行上下文壓入棧中,以下圖所示:
addAll 函數的執行上下文建立好以後,便進入了函數代碼的執行階段了,這裏先執行的是 d=10 的賦值操做,執行語句會將 addAll 函數執行上下文中的 d 由 undefined 變成了 10。
第三步,當執行到 add 函數調用語句時,一樣會爲其建立執行上下文,並將其壓入調用棧,以下圖所示:
當 add 函數返回時,該函數的執行上下文就會從棧頂彈出,並將 result 的值設置爲 add 函數的返回值,也就是 9。以下圖所示:
緊接着 addAll 執行最後一個相加操做後並返回,addAll 的執行上下文也會從棧頂部彈出,此時調用棧中就只剩下全局上下文了。最終以下圖所示:
至此,整個 JavaScript 流程執行結束了。
在開發中,如何利用好調用棧
棧溢出(Stack Overflow)
調用棧是有大小的,當入棧的執行上下文超過必定數目,JavaScript 引擎就會報錯,咱們把這種錯誤叫作棧溢出。
遞歸代碼例子:
function division(a,b){
return division(a,b)
}
console.log(division(1,2))
複製代碼
執行階段
當執行到 showName 函數時,JavaScript 引擎便開始在變量環境對象中查找該函數,因爲變量環境對象中存在該函數的引用,因此 JavaScript 引擎便開始執行該函數,並輸出「函數 showName 被執行」結果。
接下來打印「myname」信息,JavaScript 引擎繼續在變量環境對象中查找該對象,因爲變量環境存在 myname 變量,而且其值爲 undefined,因此這時候就輸出 undefined。
接下來執行第 3 行,把「極客時間」賦給 myname 變量,賦值後變量環境中的 myname 屬性值改變爲「極客時間」,變量環境以下所示:
VariableEnvironment:
myname -> "極客時間",
showName -> function : {console.log(myname)
複製代碼
內存空間
對象類型(引用類型)是「堆」來存儲
原始類型的數據值都是直接保存在「棧」中的,引用類型的值是存放在「堆」中的
調用棧中切換執行上下文狀態
一般狀況下,棧空間都不會設置太大,主要用來存放一些原始類型的小數據。
堆空間很大,能存放不少大的數據,不過缺點是分配內存和回收內存都會佔用必定的時間。
原始類型的賦值會完整複製變量值,而引用類型的賦值是複製引用地址。
function foo() {
var myName = "aaa"
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("bbbb")
bar.getName()
console.log(bar.getName())
複製代碼
當執行到 foo 函數時,閉包就產生了;當 foo 函數執行結束以後,返回的 getName 和 setName 方法都引用「clourse(foo)」對象,因此即便 foo 函數退出了,「clourse(foo)」依然被其內部的 getName 和 setName 方法引用。因此在下次調用bar.setName或者bar.getName時,建立的執行上下文中就包含了「clourse(foo)」
深拷貝
function deepCopy(o1, o2){
// 取出第一個對象的每個屬性
for(var key in o1){
// 取出第一個對象當前屬性對應的值
var item = o1[key]; // dog
// 判斷當前的值是不是引用類型
// 若是是引用類型, 咱們就從新開闢一塊存儲空間
if(item instanceof Object){
var temp = new Object();
/* {name: "wc",age: "3"} */
deepCopy(item, temp); //遞歸
o2[key] = temp;
}else{
// 基本數據類型
o2[key] = o1[key];
}
}
}
複製代碼
調用棧中的數據是如何回收的
function foo(){
var a = 1
var b = {name:"極客邦"}
function showName(){
var c = "極客時間"
var d = {name:"極客時間"}
}
showName()
}
foo()
複製代碼
當執行到第 6 行代碼時,其調用棧和堆空間狀態圖以下所示:
執行到 showName 函數時的內存模型
從圖中能夠看出,原始類型的數據被分配到棧中,引用類型的數據會被分配到堆中。當 foo 函數執行結束以後,foo 函數的執行上下文會從堆中被銷燬掉,那麼它是怎麼被銷燬的呢?下面咱們就來分析一下。
若是執行到 showName 函數時,那麼 JavaScript 引擎會建立 showName 函數的執行上下文,並將 showName 函數的執行上下文壓入到調用棧中,最終執行到 showName 函數時,其調用棧就如上圖所示。與此同時,還有一個記錄當前執行狀態的指針(稱爲 ESP),指向調用棧中 showName 函數的執行上下文,表示當前正在執行 showName 函數。
當 showName 函數執行完成以後,函數執行流程就進入了 foo 函數,那這時就須要銷燬 showName 函數的執行上下文了。ESP 這時候就幫上忙了,JavaScript 會將 ESP 下移到 foo 函數的執行上下文,這個下移操做就是銷燬 showName 函數執行上下文的過程。
當 showName 函數執行結束以後,ESP 向下移動到 foo 函數的執行上下文中,上面 showName 的執行上下文雖然保存在棧內存中,可是已是無效內存了。好比當 foo 函數再次調用另一個函數時,這塊內容會被直接覆蓋掉,用來存放另一個函數的執行上下文。
當一個函數執行結束以後,JavaScript 引擎會經過向下移動 ESP 來銷燬該函數保存在棧中的執行上下文。
堆中的數據是如何回收的
當上面那段代碼的 foo 函數執行結束以後,ESP 應該是指向全局執行上下文的,那這樣的話,showName 函數和 foo 函數的執行上下文就處於無效狀態了,不過保存在堆中的兩個對象依然佔用着空間,以下圖所示:
foo 函數執行結束後的內存狀態
1003 和 1050 這兩塊內存依然被佔用。要回收堆中的垃圾數據,就須要用到 JavaScript 中的垃圾回收器了。
代際假說(The Generational Hypothesis)和分代收集
代際假說有如下兩個特色:
在 V8 中會把堆分爲新生代和老生代兩個區域,新生代中存放的是生存時間短的對象,老生代中存放的生存時間久的對象。
新生區一般只支持 1~8M 的容量,而老生區支持的容量就大不少了。對於這兩塊區域,V8 分別使用兩個不一樣的垃圾回收器,以便更高效地實施垃圾回收。
垃圾回收器的工做流程
不論什麼類型的垃圾回收器,它們都有一套共同的執行流程。
副垃圾回收器
副垃圾回收器主要負責新生區的垃圾回收。而一般狀況下,大多數小的對象都會被分配到新生區,因此說這個區域雖然不大,可是垃圾回收仍是比較頻繁的。
新生代中用 Scavenge 算法來處理。所謂 Scavenge 算法,是把新生代空間對半劃分爲兩個區域,一半是對象區域,一半是空閒區域,以下圖所示:
新加入的對象都會存放到對象區域,當對象區域快被寫滿時,就須要執行一次垃圾清理操做。
在垃圾回收過程當中,首先要對對象區域中的垃圾作標記;標記完成以後,就進入垃圾清理階段,副垃圾回收器會把這些存活的對象複製到空閒區域中,同時它還會把這些對象有序地排列起來,因此這個複製過程,也就至關於完成了內存整理操做,複製後空閒區域就沒有內存碎片了。
完成複製後,對象區域與空閒區域進行角色翻轉,也就是原來的對象區域變成空閒區域,原來的空閒區域變成了對象區域。這樣就完成了垃圾對象的回收操做,同時這種角色翻轉的操做還能讓新生代中的這兩塊區域無限重複使用下去。
因爲新生代中採用的 Scavenge 算法,因此每次執行清理操做時,都須要將存活的對象從對象區域複製到空閒區域。但複製操做須要時間成本,若是新生區空間設置得太大了,那麼每次清理的時間就會太久,因此爲了執行效率,通常新生區的空間會被設置得比較小。
也正是由於新生區的空間不大,因此很容易被存活的對象裝滿整個區域。爲了解決這個問題,JavaScript 引擎採用了對象晉升策略,也就是通過兩次垃圾回收依然還存活的對象,會被移動到老生區中。
主垃圾回收器
主垃圾回收器主要負責老生區中的垃圾回收。除了新生區中晉升的對象,一些大的對象會直接被分配到老生區。所以老生區中的對象有兩個特色,一個是對象佔用空間大,另外一個是對象存活時間長。
因爲老生區的對象比較大,若要在老生區中使用 Scavenge 算法進行垃圾回收,複製這些大的對象將會花費比較多的時間,從而致使回收執行效率不高,同時還會浪費一半的空間。於是,主垃圾回收器是採用標記 - 清除(Mark-Sweep)的算法進行垃圾回收的。下面咱們來看看該算法是如何工做的。
首先是標記過程階段。標記階段就是從一組根元素開始,遞歸遍歷這組根元素,在這個遍歷過程當中,能到達的元素稱爲活動對象,沒有到達的元素就能夠判斷爲垃圾數據。
當 showName 函數執行結束以後,ESP 向下移動,指向了 foo 函數的執行上下文,這時候若是遍歷調用棧,是不會找到引用 1003 地址的變量,也就意味着 1003 這塊數據爲垃圾數據,被標記爲紅色。因爲 1050 這塊數據被變量 b 引用了,因此這塊數據會被標記爲活動對象。這就是大體的標記過程。
接下來就是垃圾的清除過程。它和副垃圾回收器的垃圾清除過程徹底不一樣,你能夠理解這個過程是清除掉紅色標記數據的過程,可參考下圖大體理解下其清除過程:
上面的標記過程和清除過程就是標記 - 清除算法,不過對一塊內存屢次執行標記 - 清除算法後,會產生大量不連續的內存碎片。而碎片過多會致使大對象沒法分配到足夠的連續內存,因而又產生了另一種算法——標記 - 整理(Mark-Compact)
這個標記過程仍然與標記 - 清除算法裏的是同樣的,但後續步驟不是直接對可回收對象進行清理,而是讓全部存活的對象都向一端移動,而後直接清理掉端邊界之外的內存。你能夠參考下圖:
標記整理過程
全停頓
因爲 JavaScript 是運行在主線程之上的,一旦執行垃圾回收算法,都須要將正在執行的 JavaScript 腳本暫停下來,待垃圾回收完畢後再恢復腳本執行。咱們把這種行爲叫作全停頓(Stop-The-World)。
好比堆中的數據有 1.5GB,V8 實現一次完整的垃圾回收須要 1 秒以上的時間,這也是因爲垃圾回收而引發 JavaScript 線程暫停執行的時間,如果這樣的時間花銷,那麼應用的性能和響應能力都會直線降低。主垃圾回收器執行一次完整的垃圾回收流程以下圖所示:
全停頓
在 V8 新生代的垃圾回收中,因其空間較小,且存活對象較少,因此全停頓的影響不大,但老生代就不同了。若是在執行垃圾回收的過程當中,佔用主線程時間太久,就像上面圖片展現的那樣,花費了 200 毫秒,在這 200 毫秒內,主線程是不能作其餘事情的。好比頁面正在執行一個 JavaScript 動畫,由於垃圾回收器在工做,就會致使這個動畫在這 200 毫秒內沒法執行的,這將會形成頁面的卡頓現象。
爲了下降老生代的垃圾回收而形成的卡頓,V8 將標記過程分爲一個個的子標記過程,同時讓垃圾回收標記和 JavaScript 應用邏輯交替進行,直到標記階段完成,咱們把這個算法稱爲增量標記(Incremental Marking)算法。以下圖所示:
使用增量標記算法,能夠把一個完整的垃圾回收任務拆分爲不少小的任務,這些小的任務執行時間比較短,能夠穿插在其餘的 JavaScript 任務中間執行,這樣當執行上述動畫效果時,就不會讓用戶由於垃圾回收任務而感覺到頁面的卡頓了。
編譯器和解釋器
編譯型語言在程序執行以前,須要通過編譯器的編譯過程,而且編譯以後會直接保留機器能讀懂的二進制文件,這樣每次運行程序時,均可以直接運行該二進制文件,而不須要再次從新編譯了。好比 C/C++、GO 等都是編譯型語言。而由解釋型語言編寫的程序,在每次運行時都須要經過解釋器對程序進行動態解釋和執行。好比 Python、JavaScript 等都屬於解釋型語言。
編譯器和解釋器「翻譯」代碼
從圖中你能夠看出這兩者的執行流程,大體可闡述爲以下:
V8 是如何執行一段 JavaScript 代碼的
V8 執行一段代碼流程圖
從圖中能夠清楚地看到,V8 在執行過程當中既有解釋器 Ignition,又有編譯器 TurboFan,那麼它們是如何配合去執行一段 JavaScript 代碼的呢? 下面咱們就按照上圖來一一分解其執行流程。
1. 生成抽象語法樹(AST)和執行上下文
將源代碼轉換爲抽象語法樹,並生成執行上下文
AST
var myName = "極客時間"
function foo(){ return 23;}
myName = "geektime"
foo()
複製代碼
抽象語法樹(AST)結構
Babel 的工做原理就是先將 ES6 源碼轉換爲 AST,而後再將 ES6 語法的 AST 轉換爲 ES5 語法的 AST,最後利用 ES5 的 AST 生成 JavaScript 源代碼。 ESLint 其檢測流程也是須要將源碼轉換爲 AST,而後再利用 AST 來檢查代碼規範化的問題。
生成 AST 須要通過兩個階段。
第一階段是分詞(tokenize),又稱爲詞法分析,其做用是將一行行的源碼拆解成一個個 token。所謂 token,指的是語法上不可能再分的、最小的單個字符或字符串。你能夠參考下圖來更好地理解什麼 token。
分解 token 示意圖
從圖中能夠看出,經過var myName = 「極客時間」簡單地定義了一個變量,其中關鍵字「var」、標識符「myName」 、賦值運算符「=」、字符串「極客時間」四個都是 token,並且它們表明的屬性還不同。
第二階段是解析(parse),又稱爲語法分析,其做用是將上一步生成的 token 數據,根據語法規則轉爲 AST。若是源碼符合語法規則,這一步就會順利完成。但若是源碼存在語法錯誤,這一步就會終止,並拋出一個「語法錯誤」。
有了AST後,v8就會生成該段代碼的執行上下文。
2. 生成字節碼
有了 AST 和執行上下文後,那接下來的第二步,解釋器 Ignition 就登場了,它會根據 AST 生成字節碼,並解釋執行字節碼。
其實一開始 V8 並無字節碼,而是直接將 AST 轉換爲機器碼,因爲執行機器碼的效率是很是高效的,因此這種方式在發佈後的一段時間內運行效果是很是好的。可是隨着 Chrome 在手機上的普遍普及,特別是運行在 512M 內存的手機上,內存佔用問題也暴露出來了,由於 V8 須要消耗大量的內存來存放轉換後的機器碼。爲了解決內存佔用問題,V8 團隊大幅重構了引擎架構,引入字節碼,而且拋棄了以前的編譯器,最終花了將進四年的時間,實現瞭如今的這套架構。
字節碼就是介於 AST 和機器碼之間的一種代碼。可是與特定類型的機器碼無關,字節碼須要經過解釋器將其轉換爲機器碼後才能執行。
字節碼和機器碼佔用空間對比
生成字節碼以後,接下來就要進入執行階段了。
一般,若是有一段第一次執行的字節碼,解釋器 Ignition 會逐條解釋執行。在執行字節碼的過程當中,若是發現有熱點代碼(HotSpot),好比一段代碼被重複執行屢次,這種就稱爲熱點代碼。
那麼後臺的編譯器 TurboFan 就會把該段熱點的字節碼編譯爲高效的機器碼,而後當再次執行這段被優化的代碼時,只須要執行編譯後的機器碼就能夠了,這樣就大大提高了代碼的執行效率。
其實字節碼配合解釋器和編譯器是最近一段時間很火的技術,好比 Java 和 Python 的虛擬機也都是基於這種技術實現的,咱們把這種技術稱爲即時編譯(JIT)
具體到 V8,就是指解釋器 Ignition 在解釋執行字節碼的同時,收集代碼信息,當它發現某一部分代碼變熱了以後,TurboFan 編譯器便閃亮登場,把熱點的字節碼轉換爲機器碼,並把轉換後的機器碼保存起來,以備下次使用。
每一個渲染進程都有一個主線程,而且主線程很是繁忙,既要處理 DOM,又要計算樣式,還要處理佈局,同時還須要處理 JavaScript 任務以及各類輸入事件。要讓這麼多不一樣類型的任務在主線程中有條不紊地執行,這就須要一個系統來統籌調度這些任務,這個統籌調度系統就是消息隊列和事件循環系統。
要想在線程運行過程當中,能接收並執行新的任務,就須要採用事件循環機制。
消息隊列是一種數據結構,能夠存放要執行的任務。它符合隊列「先進先出」的特色,也就是說要添加任務的話,添加到隊列的尾部;要取出任務的話,從隊列頭部去取。
隊列 + 循環
因爲是多個線程操做同一個消息隊列,因此在添加任務和取出任務時還會加上一個同步鎖。
跨進程發送消息
渲染進程專門有一個 IO 線程用來接收其餘進程傳進來的消息,接收到消息以後,會將這些消息組裝成任務發送給渲染主線程
一般咱們把消息隊列中的任務稱爲宏任務
每一個宏任務中都包含了一個微任務隊列
異步回調的概念,其主要有兩種方式。
微任務就是一個須要異步執行的函數,執行時機是在主函數執行結束以後、當前宏任務結束以前。
當 JavaScript 執行一段腳本的時候,V8 會爲其建立一個全局執行上下文,在建立全局執行上下文的同時,V8 引擎也會在內部建立一個微任務隊列。
每一個宏任務都關聯了一個微任務隊列。
在現代瀏覽器裏面,產生微任務有兩種方式。
微任務隊列是什麼時候被執行的
微任務添加和執行流程示意圖
結論
setTimeout
setTimeout在被使用時會被推入 延遲隊列, 延遲隊列是個小頂堆,會根據時間將要執行的回調推入堆頂,宏任務結束後會去執行堆頂的內容
若是 setTimeout 存在嵌套調用,那麼系統會設置最短期間隔爲 4 毫秒(系統會認爲阻塞)v8源碼定義4ms出
未激活的頁面,setTimeout 執行最小間隔是 1000 毫秒
延時執行時間有最大值 Chrome、Safari、Firefox 都是以 32 個 bit 來存儲延時值的,32bit 最大隻能存放的數字是 2147483647 毫秒
使用 setTimeout 設置的回調函數中的 this 不必定指向當前環境
requestAnimationFrame
使用 requestAnimationFrame 不須要設置具體的時間,由系統來決定回調函數的執行時間,requestAnimationFrame 裏面的回調函數是在頁面刷新以前執行,它跟着屏幕的刷新頻率走,保證每一個刷新間隔只執行一次,內若是頁面未激活的話,requestAnimationFrame 也會中止渲染,這樣既能夠保證頁面的流暢性,又能節省主線程執行函數的開銷
Promise 實現了回調函數的延時綁定。
須要將回調函數 onResolve 的返回值穿透到最外層。
模擬promise
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);
}
複製代碼
async/await. 提供了在不阻塞主線程的狀況下使用同步代碼實現異步訪問資源的能力。
生成器
單個文件請求的時間線
Queuing
當瀏覽器發起一個請求的時候,會有不少緣由致使該請求不能被當即執行,而是須要排隊等待。致使請求處於排隊狀態的緣由有不少。
優化:
1. 排隊(Queuing)時間太久
排隊時間太久,大機率是由瀏覽器爲每一個域名最多維護 6 個鏈接致使的。那麼基於這個緣由,你就可讓 1 個站點下面的資源放在多個域名下面,好比放到 3 個域名下面,這樣就能夠同時支持 18 個鏈接了,這種方案稱爲域名分片技術。除了域名分片技術外,把站點升級到 HTTP2,由於 HTTP2 已經沒有每一個域名最多維護 6 個 TCP 鏈接的限制了。
2.第一字節時間(TTFB)時間太久
服務器生成頁面數據的時間太久
網絡的緣由
發送請求頭時帶上了多餘的用戶信息
3.Content Download 時間太久
若是單個請求的 Content Download 花費了大量時間,有多是字節數太多的緣由致使的。這時候你就須要減小文件大小,好比壓縮、去掉源碼中沒必要要的註釋等方法。
Stalled
等待排隊完成以後,就要進入發起鏈接的狀態了。不過在發起鏈接以前,還有一些緣由可能致使鏈接過程被推遲,這個推遲就表如今面板中的 Stalled 上
Proxy Negotiation
若是你使用了代理服務器,還會增長一個 Proxy Negotiation 階段,也就是代理協商階段,它表示代理服務器鏈接協商所用的時間
**Initial connection/SSL **
服務器創建鏈接的階段,這包括了創建 TCP 鏈接所花費的時間,若是你使用了 HTTPS 協議,那麼還須要一個額外的 SSL 握手時間,這個過程主要是用來協商一些加密信息。
Request sent
和服務器創建好鏈接以後,網絡進程會準備請求數據,並將其發送給網絡
Waiting (TTFB)
等待接收服務器第一個字節的數據
一般也稱爲「第一字節時間」,TTFB 時間越短,就說明服務器響應越快。
Content Download
接收到第一個字節以後,進入陸續接收完整數據的階段
從第一字節時間到接收到所有響應數據所用的時間。
DOM 樹如何生成
在渲染引擎內部,有一個叫 HTML 解析器(HTMLParser)的模塊
HTML 解析器是等整個 HTML 文檔加載完成以後開始解析的,仍是隨着 HTML 文檔邊加載邊解析的?
網絡進程加載了多少數據,HTML 解析器便解析多少數據。
網絡進程接收到響應頭以後,會根據響應頭中的 content-type 字段來判斷文件的類型,好比 content-type 的值是「text/html」,那麼瀏覽器就會判斷這是一個 HTML 類型的文件,而後爲該請求選擇或者建立一個渲染進程。渲染進程準備好以後,網絡進程和渲染進程之間會創建一個共享數據的管道,網絡進程接收到數據後就往這個管道里面放,而渲染進程則從管道的另一端不斷地讀取數據,並同時將讀取的數據「喂」給 HTML 解析器。你能夠把這個管道想象成一個「水管」,網絡進程接收到的字節流像水同樣倒進這個「水管」,而「水管」的另一端是渲染進程的 HTML 解析器,它會動態接收字節流,並將其解析爲 DOM。
第一個階段,經過分詞器將字節流轉換爲 Token。
第二個和第三個階段是同步進行的,須要將 Token 解析爲 DOM 節點,並將 DOM 節點添加到 DOM 樹中。
HTML 解析器維護了一個 Token 棧結構
生成的 Token 示意圖
該 Token 棧主要用來計算節點之間的父子關係,在第一個階段中生成的 Token 會被按照順序壓到這個棧中。具體的處理規則以下所示:
經過分詞器產生的新 Token 就這樣不停地壓棧和出棧,整個解析過程就這樣一直持續下去,直到分詞器將全部字節流分詞完成。
元素彈出 Token 棧示意圖
最終解析結果
預解析操做
當渲染引擎收到字節流以後,會開啓一個預解析線程,用來分析 HTML 文件中包含的 JavaScript、CSS 等相關文件,解析到相關文件以後,預解析線程會提早下載這些文件。
XSSAuditor
渲染引擎還有一個安全檢查模塊叫 XSSAuditor,是用來檢測詞法安全的。在分詞器解析出來 Token 以後,它會檢測這些模塊是否安全,好比是否引用了外部腳本,是否符合 CSP 規範,是否存在跨站點請求等。若是出現不符合規範的內容,XSSAuditor 會對該腳本或者下載任務進行攔截。
含有 CSS 的頁面渲染流水線
首先是發起主頁面的請求,這個發起請求方多是渲染進程,也有多是瀏覽器進程,發起的請求被送到網絡進程中去執行。網絡進程接收到返回的 HTML 數據以後,將其發送給渲染進程,渲染進程會解析 HTML 數據並構建 DOM。這裏你須要特別注意下,請求 HTML 數據和構建 DOM 中間有一段空閒時間,這個空閒時間有可能成爲頁面渲染的瓶頸。
當渲染進程接收 HTML 文件字節流時,會先開啓一個預解析線程,若是遇到 JavaScript 文件或者 CSS 文件,那麼預解析線程會提早下載這些數據。對於上面的代碼,預解析線程會解析出來一個外部的 theme.css 文件,併發起 theme.css 的下載。這裏也有一個空閒時間須要你注意一下,就是在 DOM 構建結束以後、theme.css 文件還未下載完成的這段時間內,渲染流水線無事可作,由於下一步是合成佈局樹,而合成佈局樹須要 CSSOM 和 DOM,因此這裏須要等待 CSS 加載結束並解析成 CSSOM。
和 HTML 同樣,渲染引擎也是沒法直接理解 CSS 文件內容的,因此須要將其解析成渲染引擎可以理解的結構,這個結構就是** CSSOM**。和 DOM 同樣,CSSOM 也具備兩個做用,第一個是提供給 JavaScript 操做樣式表的能力,第二個是爲佈局樹的合成提供基礎的樣式信息。**
含有 JavaScript 和 CSS 的頁面渲染流水線
在執行 JavaScript 腳本以前,若是頁面中包含了外部 CSS 文件的引用,或者經過 style 標籤內置了 CSS 內容,那麼渲染引擎還須要將這些內容轉換爲 CSSOM,由於 JavaScript 有修改 CSSOM 的能力,因此在執行 JavaScript 以前,還須要依賴 CSSOM。也就是說 CSS 在部分狀況下也會阻塞 DOM 的生成。
含有 JavaScript 文件和 CSS 文件頁面的渲染流水線
從圖中能夠看出來,在接收到 HTML 數據以後的預解析過程當中,HTML 預解析器識別出來了有 CSS 文件和 JavaScript 文件須要下載,而後就同時發起這兩個文件的下載請求,須要注意的是,這兩個文件的下載過程是重疊的,因此下載時間按照最久的那個文件來算。
無論 CSS 文件和 JavaScript 文件誰先到達,都要先等到 CSS 文件下載完成並生成 CSSOM,而後再執行 JavaScript 腳本,最後再繼續構建 DOM,構建佈局樹,繪製頁面。
一般狀況下的瓶頸主要體如今下載 CSS 文件、下載 JavaScript 文件和執行 JavaScript。
顯示器是怎麼顯示圖像的
每一個顯示器都有固定的刷新頻率,一般是 60HZ,也就是每秒更新 60 張圖片,更新的圖片都來自於顯卡中一個叫前緩衝區的地方,顯示器所作的任務很簡單,就是每秒固定讀取 60 次前緩衝區中的圖像,並將讀取的圖像顯示到顯示器上。
顯卡
顯卡的職責就是合成新的圖像,並將圖像保存到後緩衝區中,一旦顯卡把合成的圖像寫到後緩衝區,系統就會讓後緩衝區和前緩衝區互換,這樣就能保證顯示器能讀取到最新顯卡合成的圖像。一般狀況下,顯卡的更新頻率和顯示器的刷新頻率是一致的。但有時候,在一些複雜的場景中,顯卡處理一張圖片的速度會變慢,這樣就會形成視覺上的卡頓。
幀 VS 幀率
咱們把渲染流水線生成的每一副圖片稱爲一幀,把渲染流水線每秒更新了多少幀稱爲幀率,好比滾動過程當中 1 秒更新了 60 幀,那麼幀率就是 60Hz(或者 60FPS)。
如何生成一幀圖像
看完前面的內容應該知道渲染的效率 重排 < 重繪 < 合成
這裏咱們詳解合成的方式生產一幀
分層和合成
在 Chrome 的渲染流水線中,分層體如今生成佈局樹以後,渲染引擎會根據佈局樹的特色將其轉換爲層樹(Layer Tree),層樹是渲染流水線後續流程的基礎結構。
合成操做是在合成線程上完成的,這也就意味着在執行合成操做時,是不會影響到主線程執行的。這就是爲何常常主線程卡住了,可是 CSS 動畫依然能執行的緣由。
分塊
若是說分層是從宏觀上提高了渲染效率,那麼分塊則是從微觀層面提高了渲染效率。
一般狀況下,頁面的內容都要比屏幕大得多,顯示一個頁面時,若是等待全部的圖層都生成完畢,再進行合成的話,會產生一些沒必要要的開銷,也會讓合成圖片的時間變得更久。
所以,合成線程會將每一個圖層分割爲大小固定的圖塊,而後優先繪製靠近視口的圖塊,這樣就能夠大大加速頁面的顯示速度。不過有時候, 即便只繪製那些優先級最高的圖塊,也要耗費很多的時間,由於涉及到一個很關鍵的因素——紋理上傳,這是由於從計算機內存上傳到 GPU 內存的操做會比較慢。
爲了解決這個問題,Chrome 又採起了一個策略:在首次合成圖塊的時候使用一個低分辨率的圖片。
好比能夠是正常分辨率的一半,分辨率減小一半,紋理就減小了四分之三。在首次顯示頁面內容的時候,將這個低分辨率的圖片顯示出來,而後合成器繼續繪製正常比例的網頁內容,當正常比例的網頁內容繪製完成後,再替換掉當前顯示的低分辨率內容。這種方式儘管會讓用戶在開始時看到的是低分辨率的內容。
will-change
.box {
will-change: transform, opacity;
}
複製代碼
這段代碼就是提早告訴渲染引擎 box 元素將要作幾何變換和透明度變換操做,這時候渲染引擎會將該元素單獨實現一幀,等這些變換髮生時,渲染引擎會經過合成線程直接去處理變換,這些變換並無涉及到主線程,這樣就大大提高了渲染的效率。這也是 CSS 動畫比 JavaScript 動畫高效的緣由。
dom的缺陷
好比,咱們能夠調用document.body.appendChild(node)往 body 節點上添加一個元素,調用該 API 以後會引起一系列的連鎖反應。首先渲染引擎會將 node 節點添加到 body 節點之上,而後觸發樣式計算、佈局、繪製、柵格化、合成等任務,咱們把這一過程稱爲重排。除了重排以外,還有可能引發重繪或者合成操做,形象地理解就是「牽一髮而動全身」。另外,對於 DOM 的不當操做還有可能引起強制同步佈局和佈局抖動的問題,這些操做都會大大下降渲染效率。所以,對於 DOM 的操做咱們時刻都須要很是當心謹慎。
虛擬 DOM 特色
虛擬 DOM 執行流程
虛擬 DOM 怎麼運行的
這裏咱們重點關注下比較過程,最開始的時候,比較兩個虛擬 DOM 的過程是在一個遞歸函數裏執行的,其核心算法是 reconciliation。一般狀況下,這個比較過程執行得很快,不過當虛擬 DOM 比較複雜的時候,執行比較函數就有可能佔據主線程比較久的時間,這樣就會致使其餘任務的等待,形成頁面卡頓。爲了解決這個問題,React 團隊重寫了 reconciliation 算法,新的算法稱爲 Fiber reconciler,以前老的算法稱爲 Stack reconciler。
雙緩存
在開發遊戲或者處理其餘圖像的過程當中,屏幕從前緩衝區讀取數據而後顯示。可是不少圖形操做都很複雜且須要大量的運算,好比一幅完整的畫面,可能須要計算屢次才能完成,若是每次計算完一部分圖像,就將其寫入緩衝區,那麼就會形成一個後果,那就是在顯示一個稍微複雜點的圖像的過程當中,你看到的頁面效果多是一部分一部分地顯示出來,所以在刷新頁面的過程當中,會讓用戶感覺到界面的閃爍。
而使用雙緩存,可讓你先將計算的中間結果存放在另外一個緩衝區中,等所有的計算結束,該緩衝區已經存儲了完整的圖形以後,再將該緩衝區的圖形數據一次性複製到顯示緩衝區,這樣就使得整個圖像的輸出很是穩定。
你能夠把虛擬 DOM 當作是 DOM 的一個 buffer,和圖形顯示同樣,它會在完成一次完整的操做以後,再把結果應用到 DOM 上,這樣就能減小一些沒必要要的更新,同時還能保證 DOM 的穩定輸出。
MVC 模式
MVC 基礎結構
其核心思想就是將數據和視圖分離
基於 MVC 又能衍生出不少其餘的模式,如 MVP、MVVM 等,不過萬變不離其宗,它們的基礎骨架都是基於 MVC 而來。
基於 React 和 Redux 構建 MVC 模型
在該圖中,咱們能夠把虛擬 DOM 當作是 MVC 的視圖部分,其控制器和模型都是由 Redux 提供的。其具體實現過程以下:
<!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>
複製代碼
影子 DOM 的做用是將模板中的內容與全局 DOM 和 CSS 進行隔離,這樣咱們就能夠實現元素和樣式的私有化了。你能夠把影子 DOM 當作是一個做用域,其內部的樣式和元素是不會影響到全局的樣式和元素的,而在全局環境下,要訪問影子 DOM 內部的樣式或者元素也是須要經過約定好的接口的。
Shadow dom 的javascript 腳本不會被隔離
HTTP/0.9
HTTP/1.0
HTTP/1.0 的請求流程
HTTP/1.1
缺點
同時開啓了多條 TCP 鏈接,那麼這些鏈接會競爭固定的帶寬。
tcp慢啓動
HTTP/1.1 隊頭阻塞的問題(阻塞的請求)
HTTP/2
HTTP/2 的多路複用
一個域名只使用一個 TCP 長鏈接和消除隊頭阻塞問題。
多路複用實現原理
HTTP/2 協議棧
HTTP/3
tcp的隊頭阻塞
從一端發送給另一端的數據會被拆分爲一個個按照順序排列的數據包,這些數據包經過網絡傳輸到了接收端,接收端再按照順序將這些數據包組合成原始數據,這樣就完成了數據傳輸。
不過,若是在數據傳輸的過程當中,有一個數據由於網絡故障或者其餘緣由而丟包了,那麼整個 TCP 的鏈接就會處於暫停狀態,須要等待丟失的數據包被從新傳輸過來
在 TCP 傳輸過程當中,因爲單個數據包的丟失而形成的阻塞稱爲 TCP 上的隊頭阻塞。
HTTP/2 多路複用
經過該圖,咱們知道在 HTTP/2 中,多個請求是跑在一個 TCP 管道中的,若是其中任意一路數據流中出現了丟包的狀況,那麼就會阻塞該 TCP 鏈接中的全部請求。這不一樣於 HTTP/1.1,使用 HTTP/1.1 時,瀏覽器爲每一個域名開啓了 6 個 TCP 鏈接,若是其中的 1 個 TCP 鏈接發生了隊頭阻塞,那麼其餘的 5 個鏈接依然能夠繼續傳輸數據。
因此隨着丟包率的增長,HTTP/2 的傳輸效率也會愈來愈差。有測試數據代表,當系統達到了 2% 的丟包率時,HTTP/1.1 的傳輸效率反而比 HTTP/2 表現得更好。
QUIC 協議
HTTP/3 選擇了一個折衷的方法——UDP 協議,基於 UDP 實現了相似於 TCP 的多路數據流、傳輸可靠性等功能,咱們把這套功能稱爲 QUIC 協議
HTTP/2 和 HTTP/3 協議棧
HTTP/3 中的 QUIC 協議集合瞭如下幾點功能。
QUIC 協議的多路複用
HTTP/3的困境
第一,從目前的狀況來看,服務器和瀏覽器端都沒有對 HTTP/3 提供比較完整的支持。Chrome 雖然在數年前就開始支持 Google 版本的 QUIC,可是這個版本的 QUIC 和官方的 QUIC 存在着很是大的差別。
第二,部署 HTTP/3 也存在着很是大的問題。由於系統內核對 UDP 的優化遠遠沒有達到 TCP 的優化程度,這也是阻礙 QUIC 的一個重要緣由。
第三,中間設備僵化的問題。這些設備對 UDP 的優化程度遠遠低於 TCP,據統計使用 QUIC 協議時,大約有 3%~7% 的丟包率。