一文掌握前端面試瀏覽器相關知識點
事件機制
事件觸發三階段
事件觸發有三個階段
window window
事件觸發一般來說會按照上面的順序進行,但是也有特例,如果給一個目標節點同時註冊冒泡和捕獲事件,事件觸發會按照註冊的順序執行。
// 以下會先列印冒泡然後是捕獲 node.addEventListener('click',(event) =>{ console.log('冒泡') },false); node.addEventListener('click',(event) =>{ console.log('捕獲 ') },true)
註冊事件
通常我們使用 addEventListener
註冊事件,該函式的第三個引數可以是布林值,也可以是物件。對於布林值 useCapture
引數來說,該引數預設值為 false
。 useCapture
決定了註冊的事件是捕獲事件還是冒泡事件。對於物件引數來說,可以使用以下幾個屬性
-
capture
,布林值,和useCapture
作用一樣 -
once
,布林值,值為true
表示該回調只會呼叫一次,呼叫後會移除監聽 -
passive
,布林值,表示永遠不會呼叫preventDefault
一般來說,我們只希望事件只觸發在目標上,這時候可以使用 stopPropagation
來阻止事件的進一步傳播。通常我們認為 stopPropagation
是用來阻止事件冒泡的,其實該函式也可以阻止捕獲事件。 stopImmediatePropagation
同樣也能實現阻止事件,但是還能阻止該事件目標執行別的註冊事件。
node.addEventListener('click',(event) =>{ event.stopImmediatePropagation() console.log('冒泡') },false); // 點選 node 只會執行上面的函式,該函式不會執行 node.addEventListener('click',(event) => { console.log('捕獲 ') },true)
事件代理
如果一個節點中的子節點是動態生成的,那麼子節點需要註冊事件的話應該註冊在父節點上
<ul id="ul"> <li>1</li> <li>2</li> <li>3</li> <li>4</li> <li>5</li> </ul> <script> let ul = document.querySelector('#ul') ul.addEventListener('click', (event) => { console.log(event.target); }) </script>
事件代理的方式相對於直接給目標註冊事件來說,有以下優點
- 節省記憶體
- 不需要給子節點登出事件
跨域
因為瀏覽器出於安全考慮,有同源策略。也就是說,如果協議、域名或者埠有一個不同就是跨域,Ajax 請求會失敗。
我們可以通過以下幾種常用方法解決跨域的問題
JSONP
JSONP 的原理很簡單,就是利用 <script>
標籤沒有跨域限制的漏洞。通過 <script>
標籤指向一個需要訪問的地址並提供一個回撥函式來接收資料當需要通訊時。
<script src="http://domain/api?param1=a¶m2=b&callback=jsonp"></script> <script> function jsonp(data) { console.log(data) } </script>
JSONP 使用簡單且相容性不錯,但是隻限於 get
請求。
在開發中可能會遇到多個 JSONP 請求的回撥函式名是相同的,這時候就需要自己封裝一個 JSONP,以下是簡單實現
function jsonp(url, jsonpCallback, success) { let script = document.createElement("script"); script.src = url; script.async = true; script.type = "text/javascript"; window[jsonpCallback] = function(data) { success && success(data); }; document.body.appendChild(script); } jsonp( "http://xxx", "callback", function(value) { console.log(value); } );
CORS
CORS需要瀏覽器和後端同時支援。IE 8 和 9 需要通過 XDomainRequest
來實現。
瀏覽器會自動進行 CORS 通訊,實現CORS通訊的關鍵是後端。只要後端實現了 CORS,就實現了跨域。
服務端設定 Access-Control-Allow-Origin
就可以開啟 CORS。 該屬性表示哪些域名可以訪問資源,如果設定萬用字元則表示所有網站都可以訪問資源。
document.domain
該方式只能用於二級域名相同的情況下,比如 a.test.com
和 b.test.com
適用於該方式。
只需要給頁面新增 document.domain = 'test.com'
表示二級域名都相同就可以實現跨域
postMessage
這種方式通常用於獲取嵌入頁面中的第三方頁面資料。一個頁面傳送訊息,另一個頁面判斷來源並接收訊息
// 傳送訊息端 window.parent.postMessage('message', 'http://test.com'); // 接收訊息端 var mc = new MessageChannel(); mc.addEventListener('message', (event) => { var origin = event.origin || event.originalEvent.origin; if (origin === 'http://test.com') { console.log('驗證通過') } });
Event loop
眾所周知 JS 是門非阻塞單執行緒語言,因為在最初 JS 就是為了和瀏覽器互動而誕生的。如果 JS 是門多執行緒的語言話,我們在多個執行緒中處理 DOM 就可能會發生問題(一個執行緒中新加節點,另一個執行緒中刪除節點),當然可以引入讀寫鎖解決這個問題。
JS 在執行的過程中會產生執行環境,這些執行環境會被順序的加入到執行棧中。如果遇到非同步的程式碼,會被掛起並加入到 Task(有多種 task) 佇列中。一旦執行棧為空,Event Loop 就會從 Task 佇列中拿出需要執行的程式碼並放入執行棧中執行,所以本質上來說 JS 中的非同步還是同步行為。
console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); console.log('script end');
以上程式碼雖然 setTimeout
延時為 0,其實還是非同步。這是因為 HTML5 標準規定這個函式第二個引數不得小於 4 毫秒,不足會自動增加。所以 setTimeout
還是會在 script end
之後列印。
不同的任務源會被分配到不同的 Task 佇列中,任務源可以分為 微任務(microtask) 和 巨集任務(macrotask)。在 ES6 規範中,microtask 稱為 jobs
,macrotask 稱為 task
。
console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); new Promise((resolve) => { console.log('Promise') resolve() }).then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('script end'); // script start => Promise => script end => promise1 => promise2 => setTimeout
以上程式碼雖然 setTimeout
寫在 Promise
之前,但是因為 Promise
屬於微任務而 setTimeout
屬於巨集任務,所以會有以上的列印。
微任務包括 process.nextTick
, promise
, Object.observe
, MutationObserver
巨集任務包括 script
, setTimeout
, setInterval
, setImmediate
, I/O
, UI rendering
很多人有個誤區,認為微任務快於巨集任務,其實是錯誤的。因為巨集任務中包括了 script
,瀏覽器會先執行一個巨集任務,接下來有非同步程式碼的話就先執行微任務。
所以正確的一次 Event loop 順序是這樣的
- 執行同步程式碼,這屬於巨集任務
- 執行棧為空,查詢是否有微任務需要執行
- 執行所有微任務
- 必要的話渲染 UI
- 然後開始下一輪 Event loop,執行巨集任務中的非同步程式碼
通過上述的 Event loop 順序可知,如果巨集任務中的非同步程式碼有大量的計算並且需要操作 DOM 的話,為了更快的 介面響應,我們可以把操作 DOM 放入微任務中。
Node 中的 Event loop
Node 中的 Event loop 和瀏覽器中的不相同。
Node 的 Event loop 分為6個階段,它們會按照順序反覆執行
┌───────────────────────┐ ┌─>│timers│ │└──────────┬────────────┘ │┌──────────┴────────────┐ ││I/O callbacks│ │└──────────┬────────────┘ │┌──────────┴────────────┐ ││idle, prepare│ │└──────────┬────────────┘┌───────────────┐ │┌──────────┴────────────┐│incoming:│ ││poll│<──connections───│ │└──────────┬────────────┘│data, etc.│ │┌──────────┴────────────┐└───────────────┘ ││check│ │└──────────┬────────────┘ │┌──────────┴────────────┐ └──┤close callbacks│ └───────────────────────┘
timer
timers 階段會執行 setTimeout
和 setInterval
一個 timer
指定的時間並不是準確時間,而是在達到這個時間後儘快執行回撥,可能會因為系統正在執行別的事務而延遲。
下限的時間有一個範圍: [1, 2147483647]
,如果設定的時間不在這個範圍,將被設定為1。
I/O
I/O 階段會執行除了 close 事件,定時器和 setImmediate
的回撥
idle, prepare
idle, prepare 階段內部實現
poll
poll 階段很重要,這一階段中,系統會做兩件事情
- 執行到點的定時器
- 執行 poll 佇列中的事件
並且當 poll 中沒有定時器的情況下,會發現以下兩件事情
- 如果 poll 佇列不為空,會遍歷回撥佇列並同步執行,直到佇列為空或者系統限制
-
如果 poll 佇列為空,會有兩件事發生
- 如果有
setImmediate
需要執行,poll 階段會停止並且進入到 check 階段執行setImmediate
- 如果沒有
setImmediate
需要執行,會等待回撥被加入到佇列中並立即執行回撥
- 如果有
如果有別的定時器需要被執行,會回到 timer 階段執行回撥。
check
check 階段執行 setImmediate
close callbacks
close callbacks 階段執行 close 事件
並且在 Node 中,有些情況下的定時器執行順序是隨機的
setTimeout(() => { console.log('setTimeout'); }, 0); setImmediate(() => { console.log('setImmediate'); }) // 這裡可能會輸出 setTimeout,setImmediate // 可能也會相反的輸出,這取決於效能 // 因為可能進入 event loop 用了不到 1 毫秒,這時候會執行 setImmediate // 否則會執行 setTimeout
當然在這種情況下,執行順序是相同的
var fs = require('fs') fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); }); // 因為 readFile 的回撥在 poll 中執行 // 發現有 setImmediate ,所以會立即跳到 check 階段執行回撥 // 再去 timer 階段執行 setTimeout // 所以以上輸出一定是 setImmediate,setTimeout
上面介紹的都是 macrotask 的執行情況,microtask 會在以上每個階段完成後立即執行。
setTimeout(()=>{ console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) setTimeout(()=>{ console.log('timer2') Promise.resolve().then(function() { console.log('promise2') }) }, 0) // 以上程式碼在瀏覽器和 node 中列印情況是不同的 // 瀏覽器中一定列印 timer1, promise1, timer2, promise2 // node 中可能列印 timer1, timer2, promise1, promise2 // 也可能列印 timer1, promise1, timer2, promise2
Node 中的 process.nextTick
會先於其他 microtask 執行。
setTimeout(() => { console.log("timer1"); Promise.resolve().then(function() { console.log("promise1"); }); }, 0); process.nextTick(() => { console.log("nextTick"); }); // nextTick, timer1, promise1
儲存
cookie,localStorage,sessionStorage,indexDB
特性 | cookie | localStorage | sessionStorage | indexDB |
---|---|---|---|---|
資料生命週期 | 一般由伺服器生成,可以設定過期時間 | 除非被清理,否則一直存在 | 頁面關閉就清理 | 除非被清理,否則一直存在 |
資料儲存大小 | 4K | 5M | 5M | 無限 |
與服務端通訊 | 每次都會攜帶在 header 中,對於請求效能影響 | 不參與 | 不參與 | 不參與 |
從上表可以看到, cookie
已經不建議用於儲存。如果沒有大量資料儲存需求的話,可以使用 localStorage
和 sessionStorage
。對於不怎麼改變的資料儘量使用 localStorage
儲存,否則可以用 sessionStorage
儲存。
對於 cookie
,我們還需要注意安全性。
屬性 | 作用 |
---|---|
value | 如果用於儲存使用者登入態,應該將該值加密,不能使用明文的使用者標識 |
http-only | 不能通過 JS 訪問 Cookie,減少 XSS 攻擊 |
secure | 只能在協議為 HTTPS 的請求中攜帶 |
same-site | 規定瀏覽器不能在跨域請求中攜帶 Cookie,減少 CSRF 攻擊 |
Service Worker
Service workers 本質上充當Web應用程式與瀏覽器之間的代理伺服器,也可以在網路可用時作為瀏覽器和網路間的代理。它們旨在(除其他之外)使得能夠建立有效的離線體驗,攔截網路請求並基於網路是否可用以及更新的資源是否駐留在伺服器上來採取適當的動作。他們還允許訪問推送通知和後臺同步API。
目前該技術通常用來做快取檔案,提高首屏速度,可以試著來實現這個功能。
// index.js if (navigator.serviceWorker) { navigator.serviceWorker .register("sw.js") .then(function(registration) { console.log("service worker 註冊成功"); }) .catch(function(err) { console.log("servcie worker 註冊失敗"); }); } // sw.js // 監聽 `install` 事件,回撥中快取所需檔案 self.addEventListener("install", e => { e.waitUntil( caches.open("my-cache").then(function(cache) { return cache.addAll(["./index.html", "./index.js"]); }) ); }); // 攔截所有請求事件 // 如果快取中已經有請求的資料就直接用快取,否則去請求資料 self.addEventListener("fetch", e => { e.respondWith( caches.match(e.request).then(function(response) { if (response) { return response; } console.log("fetch source"); }) ); });
開啟頁面,可以在開發者工具中的 Application
看到 Service Worker 已經啟動了
在 Cache 中也可以發現我們所需的檔案已被快取
當我們重新重新整理頁面可以發現我們快取的資料是從 Service Worker 中讀取的
渲染機制
瀏覽器的渲染機制一般分為以下幾個步驟
- 處理 HTML 並構建 DOM 樹。
- 處理 CSS 構建 CSSOM 樹。
- 將 DOM 與 CSSOM 合併成一個渲染樹。
- 根據渲染樹來佈局,計算每個節點的位置。
- 呼叫 GPU 繪製,合成圖層,顯示在螢幕上。
在構建 CSSOM 樹時,會阻塞渲染,直至 CSSOM 樹構建完成。並且構建 CSSOM 樹是一個十分消耗效能的過程,所以應該儘量保證層級扁平,減少過度層疊,越是具體的 CSS 選擇器,執行速度越慢。
當 HTML 解析到 script 標籤時,會暫停構建 DOM,完成後才會從暫停的地方重新開始。也就是說,如果你想首屏渲染的越快,就越不應該在首屏就載入 JS 檔案。並且 CSS 也會影響 JS 的執行,只有當解析完樣式表才會執行 JS,所以也可以認為這種情況下,CSS 也會暫停構建 DOM。
Load 和 DOMContentLoaded 區別
Load 事件觸發代表頁面中的 DOM,CSS,JS,圖片已經全部載入完畢。
DOMContentLoaded 事件觸發代表初始的 HTML 被完全載入和解析,不需要等待 CSS,JS,圖片載入。
圖層
一般來說,可以把普通文件流看成一個圖層。特定的屬性可以生成一個新的圖層。 不同的圖層渲染互不影響 ,所以對於某些頻繁需要渲染的建議單獨生成一個新圖層,提高效能。 但也不能生成過多的圖層,會引起反作用。
通過以下幾個常用屬性可以生成新圖層
- 3D 變換:
translate3d
、translateZ
-
will-change
-
video
、iframe
標籤 - 通過動畫實現的
opacity
動畫轉換 -
position: fixed
重繪(Repaint)和迴流(Reflow)
重繪和迴流是渲染步驟中的一小節,但是這兩個步驟對於效能影響很大。
color
迴流必定會發生重繪,重繪不一定會引發迴流。迴流所需的成本比重繪高的多,改變深層次的節點很可能導致父節點的一系列迴流。
所以以下幾個動作可能會導致效能問題:
- 改變 window 大小
- 改變字型
- 新增或刪除樣式
- 文字改變
- 定位或者浮動
- 盒模型
很多人不知道的是,重繪和迴流其實和 Event loop 有關。
- 當 Event loop 執行完 Microtasks 後,會判斷 document 是否需要更新。因為瀏覽器是 60Hz 的重新整理率,每 16ms 才會更新一次。
- 然後判斷是否有
resize
或者scroll
,有的話會去觸發事件,所以resize
和scroll
事件也是至少 16ms 才會觸發一次,並且自帶節流功能。 - 判斷是否觸發了 media query
- 更新動畫並且傳送事件
- 判斷是否有全屏操作事件
- 執行
requestAnimationFrame
回撥 - 執行
IntersectionObserver
回撥,該方法用於判斷元素是否可見,可以用於懶載入上,但是相容性不好 - 更新介面
- 以上就是一幀中可能會做的事情。如果在一幀中有空閒時間,就會去執行
requestIdleCallback
回撥。
以上內容來自於 HTML 文件
減少重繪和迴流
-
使用
translate
替代top
<div class="test"></div> <style> .test { position: absolute; top: 10px; width: 100px; height: 100px; background: red; } </style> <script> setTimeout(() => { // 引起迴流 document.querySelector('.test').style.top = '100px' }, 1000) </script>
- 使用
visibility
替換display: none
,因為前者只會引起重繪,後者會引發迴流(改變了佈局) - 把 DOM 離線後修改,比如:先把 DOM 給
display:none
(有一次 Reflow),然後你修改100次,然後再把它顯示出來 -
不要把 DOM 結點的屬性值放在一個迴圈裡當成迴圈裡的變數
for(let i = 0; i < 1000; i++) { // 獲取 offsetTop 會導致迴流,因為需要去獲取正確的值 console.log(document.querySelector('.test').style.offsetTop) }
- 不要使用 table 佈局,可能很小的一個小改動會造成整個 table 的重新佈局
- 動畫實現的速度的選擇,動畫速度越快,迴流次數越多,也可以選擇使用
requestAnimationFrame
- CSS 選擇符從右往左匹配查詢,避免 DOM 深度過深
- 將頻繁執行的動畫變為圖層,圖層能夠阻止該節點回流影響別的元素。比如對於
video
標籤,瀏覽器會自動將該節點變為圖層。