一、一個頁面爲什麼4個進程?
(1)主要原因
- 進程中的任何一個線程崩潰都會導致整個進程崩潰。
- 線程之間的數據時共享的,多頁面使用多線程有安全性問題。
- 當一個進程關閉後資源的回收時候操作系統控制的,不易出現內存泄漏。
- 插件的崩潰會導致Chrome的不穩定。
- 所有模塊都在一個進程導致Chrome不流暢。
(2)目前Chrome的進程架構
- 瀏覽器進程:主要負責用戶界面顯示、交互、子進程管理、存儲。
- 渲染進程:使用Blink排版引擎和V8引擎渲染出頁面,Chrome會爲每一個Tab創建一個渲染進程,每個進程運行在沙箱中。
- GPU進程:初衷是實現CSS 3D、網頁繪製和Chrome的UI部分。
- 網絡進程:加載網絡資源。
- 插件進程:負責插件的運行。
(3)當前的Chrome架構帶來的問題
- 消耗資源
- 體系複雜
(4)未來面向服務的架構(SOA)
構建一個更加內聚、鬆耦合、易於維護和擴展的系統
Chrome正在構建操作系統化的Chrome基礎服務,在性能強大的設備上使用多進程的方式運行基礎服務,當在硬件資源受限的設備上使用Chrome的時候,就使用單進程的方式。
二、TCP協議如何保證頁面傳送到瀏覽器?
從TCP和UDP協議的傳輸過程思考,引出QUIC和HTTP3。
三、爲什麼第二次打開站點會很快?
瀏覽器發起HTTP請求的流程:
- 構建請求,首先是構建請求行。
- 查找緩存,查找瀏覽器緩存失敗纔會進行網絡請求。
- 通過DNS準備IP地址和端口。
- 等待TCP隊列,Chrome機制是同一個域名下最多隻能建立6個連接,否則就會進入等待TCP隊列。
- 建立TCP連接。
- 發送HTTP請求。
- 首先發送請求行,分別是請求方法、請求URI、HTTP協議版本。
- 如果是POST請求,那麼還要發送請求體。
- 然後服務器返回數據,包括響應頭和響應體。
- 通常情況下會斷開TCP連接,如果在HTTP頭部加入
Connection:Keep-Alive
,這樣TCP就不會斷開,連接可以被複用。
四、從輸入URL到頁面展示,中間發生了什麼?
- 用戶向瀏覽器輸入URL,然後瀏覽器處理用戶輸入,判斷是合法的URL地址還是搜索關鍵字,如果是關鍵字那麼使用默認的搜索引擎構建搜索URL。
- 檢查是否有緩存內容,否則瀏覽器通過進程間的IPC通信向網絡進程發送請求信息。
- 網絡進程接收服務器返回的數據,根據狀態碼判斷是否進行重定向等操作。如果是301或302,那麼表示需要重定向,此時網絡進程會在請求頭的Location字段中讀取重定向的地址,再次發送網絡請求。
- 通過響應頭中的Content-Type判斷進行何種操作。如果是下載類型,那麼網絡進程會把任務交給下載任務管理器。
- 由於服務器響應數據的時候就已經準備好了渲染進程,那麼會將數據提交給渲染進程。Chrome會爲每個站點打開一個渲染進程。
五、JavaScript、HTML和CSS如何變成頁面?
由於渲染機制的複雜性,所以劃分爲許多子階段,每個子階段都有輸入、輸出和處理過程,這許多子階段構成了渲染流水線。
(1)構建DOM
將HTML標籤轉化爲DOM樹,每個節點對應一個HTML標籤。可以通過如下代碼從Chrome開發者工具中獲取當前頁面的DOM樹:
document
HTML解析器會隨着文檔的加載,邊加載邊解析。HTML解析器維護了一個Token棧結構用於計算父子節點之間的關係。
當解析到JavaScript的時候,DOM解析將會停止,執行代碼,因爲JavaScript有可能修改DOM結構。
Chrome在解析之前會有預解析線程先下載文檔內嵌入的JavaScript下載鏈接。
在解析JavaScript之前首先要解析CSS文件,CSS文件加載會阻塞JavaScript腳本執行。
(2)樣式計算
- 解析CSS文件。當渲染引擎接收到一個CSS文件的時候,會將CSS文本轉換爲樣式表結構。可以通過如下代碼在Chrome開發者工具中獲取當前頁面的樣式表:
document.styleSheets
- 轉換CSS屬性值,使其標準化。比如2em會被轉換爲35px,HTML顏色會被轉化RGB顏色。
- 計算出DOM樹每個節點的樣式。CSS具有繼承和層疊規則。這些會在Chrome的Computed標籤中顯示。
(3)佈局節點
- 創建佈局樹。由於HTML中還包含了許多不可見的元素,因此還需要創建一棵只包含可見元素的佈局樹。
- 然後將可見佈局樹和Computed CSS合成帶有CSS的DOM樹。
(4)圖層樹
渲染引擎還需要爲特定的節點生成專門的圖層,並生成一棵圖層樹。可以在Chrome的Layers標籤中查看。
並不是每一個節點都會對應一個圖層,如果一個節點沒有圖層,那麼這個節點就屬於父節點的圖層。
- 擁有層疊上下文屬性的HTML元素會提升爲一個圖層。
- 需要進行裁剪的HTML元素,比如overflow屬性。
(5)圖層繪製
渲染引擎會將圖層樹中的每個圖層進行繪製,首先會將每一層的繪製拆分成許多繪製指令,然後繪製指令按照順序組成待繪製列表。
(6)柵格化操作
-
主線程將待繪製列表準備好後提交給合成線程。通常情況下,一個頁面可能很大,但是視口ViewPort是有限大的。因此合成線程會將圖層劃分爲圖塊,通常是
256*256
或者512*512
。 - 合成線程會將視口附近的圖塊來優先生成位圖,實際生成位圖的操作由柵格化線程來執行。
- 柵格化線程通常情況下會使用GPU來完成,也叫快速柵格化。
- GPU生成的位圖保存在GPU的顯存中。瀏覽器中有個viz組件用來接收合成線程的DrawQuad命令,然後瀏覽器根據該命令將頁面內容顯示在屏幕上。
(7)3個重要概念
- 重排:更新元素的幾何屬性。通過CSS或者JS修改了元素的位置屬性,那麼就會觸發瀏覽器重新佈局,導致需要完整的渲染流水線。
- 重繪:更新元素的繪製屬性。如果更改了頁面的顏色屬性,那麼就會省去佈局和分層階段。
- 合成:比如使用了CSS的transform屬性,那麼就會避開重繪和重排。
六、JavaScript是按照順序執行的嗎?
變量提升:JavaScript解析引擎執行代碼過程中,將變量的聲明部分和函數的聲明部分提升到代碼開頭的行爲,變量提升以後會給變量設置默認值,這個默認是就是undefined
。
變量提升發生在編譯階段,在這個階段會生成執行上下文和可執行代碼。在執行上下文中保存了變量環境對象,該對象保存了變量提升的內容。
如果函數或者變量出現了重名,那麼變量環境對象將會發生覆蓋。
console.log(x);
var x = 10;
var x = 20;
f1();
function f1() {console.log('method: f1');}
function f1() {console.log('method: f1 override');}
f2();
var f2 = function() {console.log('method: f2');}
var f2 = function() {console.log('method: f2 override');}
undefined
method: f1 override
/Users/koils/test.js:9
f2();
^
TypeError: f2 is not a functio
七、爲什麼JavaScript會出現棧溢出?
在JavaScript中每個函數都有自己的執行上下文,JavaScript使用調用棧來管理這些執行上下文環境。全局執行上下文位於棧底。
調用棧是JavaScript引擎追蹤函數執行的一個機制。
棧溢出:棧是有大小的,當入棧數目超過這個大小就會造成棧溢出現象。
八、作用域、作用域鏈和閉包
ES6中通過引入塊級作用域配合let和const來避免變量提升這個設計缺陷。
作用域:是指在程序中定義變量的區域,這個位置決定了變量的生命週期。作用域是變量和函數的可訪問範圍。在ES6之前只有全局作用域和函數作用域,之後支持塊級作用域。
變量提升帶來的問題:
- 變量容易在不被察覺的情況下被覆蓋掉。
- 本來應該銷燬的變量沒有被銷燬。
JavaScript如何支持塊級作用域?通過let聲明的變量在編譯階段會被存放到詞法環境,因此是使用詞法環境和棧來支持的。
在JavaScript的每個執行上下文中都包含一個叫做outer的外部引用,用來指向外部的執行上下文。當進行變量查找的時候在當前作用域找不到就回去outer中查找,直到找到。這個查找鏈條叫做作用域鏈。
詞法作用域:作用域由代碼中的函數聲明的位置來決定,是靜態的作用域,通過這個作用域可以預測代碼的執行過程。詞法作用域在代碼階段就決定好了,與函數如何調用無關。
閉包:在JavaScript中,根據詞法作用域的規則,內部函數總是可以訪問其外部函數聲明的變量,當通過調用一個外部函數返回一個內部函數的時候,即使該外部函數已經執行結束,但是內部函數引用外部函數變量依然保存在內存中,就把這些變量的集合稱爲閉包。
閉包的回收:如果閉包會一直被使用,那麼可以當做全局變量存在。但是如果使用頻率不高,而且佔用內存較大,儘量讓該閉包作爲局部變量。
九、this
(1)全局執行上下文的this
全局執行上下文的this指向window對象。
(2)函數執行上下文的this
默認情況下也是指向window對象。設置this指向的方法有三種:
-
call方法,bind方法和apply方法。
-
通過對象調用位置:使用對象調用其內部的一個方法,該方法的this是指向對象本身的。
var object = { fn: function () {console.log(this);} }; object.fn();
-
通過構造函數:函數中的this屬於新對象
function fn () {this.x = 'HelloWorld';} var object = new fn();
(3)this的設計缺陷
-
嵌套函數中this不會從外層函數中繼承,解決方法有
- 將this體系轉化爲作用域體系
function fn () { this.x = 1; var that = this; function fx () { that.x = 10; } }
- 使用ES6中的箭頭函數
function fn () { this.x = 1; var fx = () => {this.x = 10;}; }
- 普通函數的this指向window對象,這個問題可以通過使用嚴格模式解決。
十、JavaScript的內存機制
(1)數據存儲
- 原始類型
類型 | 描述 |
---|---|
Boolean | 只有true和false兩個值 |
Null | 只有一個值null,使用typeof檢測時會返回object類型,這是JavaScript的Bug |
undefined | 一個沒有被賦值的默認值,變量提升時也會使用該值 |
Number | 數字類型,64位二進制格式 |
BigInt | 可以用於表示任何精度 |
String | 表示文本數據,不可變 |
Symbol | 唯一且不可修改,通常用於作爲Object和Key |
Object | 一組屬性的集合 |
- 引用類型
JavaScript的內存空間分爲棧空間、堆空間和代碼空間。棧空間用於存儲執行上下文。在JavaScript的賦值過程中,引用類型只會複製內存地址。
(2)垃圾回收
-
調用棧中的垃圾回收
JavaScript引擎通過向下移動ESP來銷燬該函數保存在棧中執行的上下文。
-
堆中的垃圾回收
-
JavaScript使用垃圾回收器收集垃圾。待際假說:大部分對象在內存中存活時間會很短,不死的對象會活的更久。在V8引擎中分爲新生代和老年代,新生代通常是1-8MB的內存空間,並且兩個區域使用不同的GC機制。
- 新生代使用Scavenge算法,它將新生代劃分爲兩個區域,一半是對象區域,另一半是空閒區域。當對象區域寫滿以後就進行GC,首先對對象區域的對象進行標記,然後再清理垃圾,副垃圾收集器將這些沒有變成垃圾的對象複製到空閒區域,然後有序的排列,最後將對象區域和空閒區域進行角色翻轉。
- JavaScript的主垃圾回收器主要進行老年代的垃圾回收工作,使用標記-清除算法。
- 當JavaScript的進行GC的時候,會產生StopTheWorld(全停頓)現象。由於老年代受到GC全停頓的影響較大,因此老年代的垃圾回收使用增量-標記算法,使得JavaScript腳本的執行和GC兩個線程交替執行。
-
(3)解釋編譯
在JavaScript的執行引擎V8中,既有解釋器(Ignition)也存在編譯器(TurboFan)。
- 首先會從JavaScript代碼翻譯爲AST並生成執行上下文。AST是⾮常重要的⼀種數據結構,在很多項⽬中有着⼴泛的應⽤。其中最著名的⼀個項⽬是Babel。的⼯作原理就是先將ES6源碼轉換爲AST,然後再將ES6語法的AST 轉換爲ES5語法的AST,最後利⽤ES5的AST⽣成JavaScript源代碼。
- 詞法分析,生成Token。
- 語法分析,解析Token生成AST。
- 生成字節碼。解釋器根據AST解釋並執行字節碼。字節碼是介於AST和機器碼之間的一種代碼,與特定類型的機器無關。
- 執行代碼。多次重複執行的代碼會選定爲熱點代碼,由編譯器編譯爲機器代碼並保存。解釋器Ignition在解釋執⾏字節碼 的同時,收集代碼信息,當它發現某⼀部分代碼變熱了之後,TurboFan編譯器把熱點的字節 碼轉換爲機器碼,並把轉換後的機器碼保存起來,以備下次使⽤,這叫做JIT即時編譯。
十一、消息隊列和事件循環
- Chrome將事件存放到隊列,然後使用循環機制將消息取出,然後執行。比如渲染進程專門有一個IO線程用於通過隊列接受其他線程傳來的任務。
- 當線程需要安全的退出的時候,由於在進程中設置了退出標誌,每次在隊列中取出任務執行之前都需要檢查標誌。
- 對於高優先級任務的處理,比如監聽DOM節點的變化情況,會作爲微任務添加到隊列中宏任務的微任務隊列中,當任務執行完成後檢查當前任務的微任務隊列是否存在微任務,有就取出來執行。
- 通過Promise和MutationObserver監控某個DOM節點都會產生微任務。
十二、JavaScript面向對象
(1)封裝
由於JavaScript沒有提供權限訪問修飾符,因此可以通過閉包的方式實現私有變量的保護:
function Book(name) {
this.getName = () => {return name;}
this.setName = (x) => {name = x;}
}
let book = new Book("HelloWorld");
book.setName("JavaScript");
book.getName();
(2)繼承
在ES6之前,沒有extends關鍵字,最常見的叫做原型鏈繼承。原型prototype是JavaScript函數中的一個內置屬性,指向另外一個對象,被指向的對象的所有的屬性和方法都會被當前的實例所繼承。
- 設置prototype的代碼需要放到構造器之外。
- 設置prototype的代碼需要放到任何實例化之前。
原型鏈繼承無法解決父類構造方法存在參數的問題,因此可以通過構造繼承解決:
function Base1(name) {this.name = name;}
function Base2(age) {this.age = age;}
function Child(name, age) {
Base1.call(this, name);
Base2.call(this, age);
}
(3)多態
- 當創建類的實例的時候,沒有使用new關鍵字,this指的是window對象,否則指向的是當前實例對象。
- 當類存在return語句的時候,如果返回的是基本數據類型,那麼this就會強制指定爲當前類對象;如果返回的是引用數據類型,那麼會遵循return語句。
十三、setTimeout實現原理
Chrome中使用延遲隊列保存Chrome內部的延時任務和setTimeout提交的延時任務。當執行完消息隊列中的任務之後就會開始執行延時隊列的處理函數,然後延時隊列處理函數會根據發起時間和延遲時間計算出到期任務。
使用setTimeout的注意事項:
-
如果當前任務執行時間過久,會影響到定時器的執行。
-
如果setTimout存在嵌套,那麼系統會設置4ms的間隔時間。
-
當前頁面標籤如果沒有被激活,那麼setTimeout的執行最小時間間隔是1s。目的是優化加載消耗和耗電量。
-
延遲執行時間有最大值,當延時24.8天setTimeout就會溢出,因爲setTimeout使用的是32bit來存儲。
-
setTimeout執行的函數this對象指向window,可以通過匿名函數或者bind方法解決:
setTimeout(function() {}, 1); setTimeout(() => {}, 1); setTimeout(object.func.bind(object), 1);
十四、瀏覽器緩存
(1)強緩存與協商緩存
在瀏覽器中分爲強緩存和協商緩存。強緩存不需要發送HTTP請求,當檢查是否是強緩存的時候在HTTP1.0和HTTP1.1中是不一樣的:
- 早期的HTTP1.0使用的是Expires字段,它指明瞭過期時間。
- HTTP1.1使用的是Cache-Control字段,有以下參數:
- 通過max-age指明緩存存活時間。
- private表示只有瀏覽器才能緩存,中間代理服務器無法緩存。
- no-cache表示跳過強緩存,直接進入協商緩存階段
- no-store表示直接不進行緩存
- s-maxage表示針對代理服務器的緩存時長
- Expires和Cache-Control同時存在的時候,優先考慮Cache-Control
當強緩存失效之後,瀏覽器在請求頭中攜帶相應的緩存tag
來向服務器發請求,由服務器根據這個tag,來決定是否使用緩存,這就是協商緩存。
- Last-Modified:即最後修改時間。在瀏覽器第一次給服務器發送請求後,服務器會在響應頭中加上這個字段。瀏覽器接收到後,如果再次請求,會在請求頭中攜帶
If-Modified-Since
字段,這個字段的值也就是服務器傳來的最後修改時間。服務器拿到請求頭中的If-Modified-Since
的字段後,其實會和這個服務器中Last-Modified
對比:- 如果請求頭中的這個值小於最後修改時間,說明是時候更新了。返回新的資源,跟常規的HTTP請求響應的流程一樣。
- 否則返回304,告訴瀏覽器直接用緩存。
- ETag:
ETag
是服務器根據當前文件的內容,給文件生成的唯一標識,只要裏面的內容有改動,這個值就會變。服務器通過響應頭
把這個值給瀏覽器。瀏覽器接收到ETag
的值,會在下次請求時,將這個值作爲If-None-Match這個字段的內容,並放到請求頭中,然後發給服務器。服務器接收到If-None-Match後,會跟服務器上該資源的ETag進行比對:- 如果兩者不一樣,說明要更新了。返回新的資源,跟常規的HTTP請求響應的流程一樣。
- 否則返回304,告訴瀏覽器直接用緩存。
在精準度上,ETag優於Last-Modified。優於 ETag 是按照內容給資源上標識,因此能準確感知資源的變化。Last-Modified 能夠感知的單位時間是秒,如果文件在 1 秒內改變了多次,那麼這時候的 Last-Modified 並沒有體現出修改了。
在性能上,Last-Modified優於ETag,也很簡單理解,Last-Modified僅僅只是記錄一個時間點,而 Etag需要根據文件的具體內容生成哈希值。
(2)Service Worker Cache
Service Worker 借鑑了 Web Worker的 思路,即讓 JS 運行在主線程之外,由於它脫離了瀏覽器的窗體,因此無法直接訪問DOM。雖然如此,但它仍然能幫助我們完成很多有用的功能,比如離線緩存、消息推送和網絡代理等功能。其中的離線緩存就是 Service Worker Cache。Service Worker 同時也是 PWA 的重要實現機制。
(3)Memory Cache
內存緩存,從效率上講它是最快的。但是從存活時間來講又是最短的,當渲染進程結束後,內存緩存也就不存在了。
(4)Disk Cache
存儲在磁盤中的緩存,從存取效率上講是比內存緩存慢的,但是他的優勢在於存儲容量和存儲時長。
(5)Push Cache
即推送緩存,這是瀏覽器緩存的最後一道防線。它是 HTTP/2中的內容,雖然現在應用的並不廣泛,但隨着 HTTP/2 的推廣,它的應用越來越廣泛。
十五、瀏覽器存儲
(1)Cookie
Cookie 本質上就是瀏覽器裏面存儲的一個很小的文本文件,內部以鍵值對的方式來存儲。向同一個域名下發送請求,都會攜帶相同的 Cookie,服務器拿到 Cookie 進行解析,便能拿到客戶端的狀態。
Cookie就是用來做狀態存儲的。缺陷如下:
- 容量缺陷。Cookie 的體積上限只有4KB。
- 性能缺陷。Cookie 緊跟域名,不管域名下面的某一個地址需不需要這個 Cookie ,請求都會攜帶上完整的 Cookie,這樣隨着請求數的增多,其實會造成巨大的性能浪費的,因爲請求攜帶了很多不必要的內容。
- 安全缺陷。由於 Cookie 以純文本的形式在瀏覽器和服務器中傳遞。在HttpOnly爲 false 的情況下,Cookie 信息能直接通過 JS 腳本來讀取。
(2)localStorage
也是針對一個域名,即在同一個域名下,會存儲相同的一段localStorage。與Cookie的區別如下:
- 容量。localStorage 的容量上限爲5M。對於一個域名是持久存儲的。
- 只存在客戶端,默認不參與與服務端的通信。這樣就很好地避免了 Cookie 帶來的性能問題和安全問題。
- 接口封裝。通過localStorage暴露在全局,並通過它的 setItem 和 getItem等方法進行操作。
(3)sessionStorage
- 容量。容量上限也爲 5M。
- 只存在客戶端,默認不參與與服務端的通信。
- 接口封裝。
但sessionStorage
和localStorage
有一個本質的區別,那就是前者只是會話級別的存儲,並不是持久化存儲。會話結束,也就是頁面關閉,這部分sessionStorage
就不復存在了。
(4)IndexedDB
IndexedDB是運行在瀏覽器中的非關係型數據庫, 本質上是數據庫,理論上這個容量是沒有上限的。支持事務和二進制存儲。
- 鍵值對存儲,內部採用對象倉庫存儲方式。
- 異步操作,數據庫的讀寫屬於IO操作,瀏覽器提供了異步IO支持。
- 受到同源策略限制,無法跨域訪問數據庫。