Stories

Detail Return Return

【面試系列】萬字長文,總結瀏覽器十大問題 - Stories Detail

一、瀏覽器對象模型(BOM)有哪些屬性

這裏不會詳細介紹每個BOM屬性(確實沒必要哈)。主要是圍繞BOM,發散一些常見的面試題,看看是如何回答的。

BOM的屬性:

  • window
  • location
  • navigator
  • history
  • screen

location - hash路由

http://foouser:barpassword@www.wrox.com:80/WileyCDA/?q=javascript#contents
屬性名 例子 説明
hash "#contents" utl中#後面的字符,沒有則返回空串
host www.wrox.com:80 服務器名稱和端口號
hostname www.wrox.com 域名,不帶端口號
href http://www.wrox.com:80/WileyCDA/?q=javascript#contents 完整url
pathname "/WileyCDA/" 服務器下面的文件路徑
port 80 url的端口號,沒有則為空
protocol http: 使用的協議
search ?q=javascript url的查詢字符串,通常為?後面的內容

1.除了 hash之外,只要修改location的一個屬性,就會導致頁面重新加載新URL現代前端框架react/vue的路由的hash模式就是基於這個屬性實現的(下面會較詳細介紹)
2.location.reload(),此方法可以重新刷新當前頁面。就像刷新按鈕一樣,如果本地有強緩存會走強緩存。
3.當傳入參數true,location.reload(true)會強制重載,忽略緩存(包括強緩存和協商緩存)。默認為 false。僅在 Firefox 中支持。

navigator - 判斷瀏覽器設備

關於navigator,我們重點回答這個2問題:

  • 如何判斷是Mobile還是PC?
  • 如何判斷是瀏覽器類型(chrome/edge/safari/微信/QQ/支付寶webview)?

navigator.userAgent 通常包含:

  • 瀏覽器內核和版本
  • 系統信息
  • 特定 App 或 WebView 的標識

例如一個典型的UA:

Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)
AppleWebKit/605.1.15 (KHTML, like Gecko)
Mobile/15E148 MicroMessenger/8.0.39(0x1800273f) NetType/WIFI Language/zh_CN

包含了操作系統信息iPhone,渲染引擎信息AppleWebKit,瀏覽器信息MicroMessenger/8.0.39。

如何判斷是Mobile還是PC?

const ua = navigator.userAgent.toLowerCase();
const isMobile = /iphone|ipad|ipod|android|mobile|phone|tablet/i.test();

如何判斷瀏覽器/webview的類型?

function getBrowserType() {
  const ua = navigator.userAgent.toLowerCase();

  if (ua.includes('micromessenger')) {
    return 'WeChat';
  } else if (ua.includes('qq') || ua.includes('mqqbrowser')) {
    return 'QQ';
  } else if (ua.includes('alipayclient')) {
    return 'Alipay';
  } else if (ua.includes('ucbrowser')) {
    return 'UC';
  } else if (ua.includes('baiduboxapp')) {
    return 'Baidu';
  } else if (ua.includes('aweme')) {
    return 'Douyin';
  } else if (ua.includes('safari') && !ua.includes('chrome')) {
    return 'Safari';
  } else if (ua.includes('chrome')) {
    return 'Chrome';
  } else {
    return 'Other';
  }
}

history - 框架路由實現

history這個API也需要重點關注下,因為現代前端框架的路由react-router, vue-router都是基於這個實現的。

下面以react-router為例(vue-router也是類似的原理),介紹它是如何基於history和location API實現的。
要實現路由導航需要解決核心兩個問題:

  1. 如何導航時不刷新頁面
  2. 如何監聽路由的變化

BrowserRouter(history模式的路由)
首先介紹一下history API:

  • history.pushState(stateObj, title , url)
  • history.replaceState(stateObj, title , url)
  • history.go(delta)
  • history.forward()
  • history.back()

pushState和replaceState可以修改歷史記錄(往歷史記錄棧中加一個歷史記錄條目)。stateObj表示傳遞給下一個歷史記錄條目的狀態,可以通過history.state獲取;title通常被現代瀏覽器忽略,並不改變標籤頁的標題;url是字符串,表示新的 URL。

然後介紹下如何利用history API解決兩個核心問題:

  1. pushState / replaceState ,這兩個方法改變 URL 的 path 部分不會引起頁面刷新。通過go/back方法在兩個路由間導航也不會刷新頁面。
  2. 通過監聽popstate事件來監聽路由的變化,但是這個事件僅針對瀏覽器本身(通過瀏覽器按鈕操作)的前進後退和history.forward()/history.go(delta)/history.back()有效,對於<a>pushState / replaceState 可以通過攔截方法的調用來實現監聽。

HashRouter(hash模式的路由)

介紹如何用location.hash解決兩個核心問題:

  1. URL的#後面的hash字符串作為路由, 通過location.hash="#/next"可以改變hash,並且不會刷新頁面。
  2. hash字符串變化時會觸發hashchange事件,從而可以在hashchange事件監聽hash路由變化。

實現原理
react-router(vue-router也是如此)實際的實現上會使用history npm包,它基於瀏覽器的history API做了更統一的封裝實現,提供了三種類型的history: browserHistoryhashHistorymemoryHistory.

1.當輸入地址時:

  • 從地址欄的URL解析出來的路徑和配置好的routes做匹配,根據匹配到的route,更新內部維護的路由狀態(location state,這個狀態是放在RouterContext中)。
  • 路由狀態變化後,組件樹重新渲染,此時在渲染<Outlet/>(這種佔位組件)時就會根據location state來決定具體渲染那個element組件。

2.當用API主動導航時:

  • 使用react-router提供的navigate API進行導航,本質是調用pushState/replaceState這些API 來改變URL,從而匹配路由並改變路由狀態,引起視圖變化。

二、script和link標籤屬性

script標籤的defer和async

async:立即下載,下載結束後立即執行,不等dom加載完成。(async腳本先下載的先執行,不按原本的在html中的位置順序)
defer:立即下載,有序執行(按在html的位置順序),等到dom加載完後執行。

(圖來自網絡)
image.png

link標籤的preload和prefetch

preload
作用:告知瀏覽器頁面即將用到某些資源,並以較高優先級即時下載它們。
用途:適用於當前頁面的資源:首屏css、字體等。
注意: 瀏覽器會立即加載,但不會執行,等用到該資源才會下載。
prefetch
作用:提示瀏覽器在空閒時,在後台加載可能需要的資源。
用途:提升用户訪問下一頁面時的加載速度,比如電商網站對商品列表頁/產品頁等重要且高頻訪問頁面提前預下載。

preload舉例:css資源,避免css加載不及時導致閃爍。

<link rel="preload" href="critical-styles.css" as="style" onload="this.rel='stylesheet'">

prefetch舉例:產品詳情頁,提前下載好產品頁,加速進入產品頁顯示。

<link rel="prefetch" href="/js/product-detail.js" as="javascript" >

三、瀏覽器渲染流程

進程和線程

在瀏覽器篇我這裏插入了「進程和線程」一小節,一方面是為了幫大家回顧一個進程和線程,另一方面這塊也是很有可能被面試官提問的(作為操作系統的知識)。
進程和線程的區別

進程:

  • 最小資源分配單元;
  • 進程間相互隔離(1個進程崩潰不影響另外一個);
  • 擁有獨立的內存空間,包括虛擬地址空間、頁表和TLB(快表)。

線程:

  • 最小調度執行單元;
  • 線程間不完全隔離,同一個進程內線程會互相影響(1個線程崩潰會影響另外一個);
  • 有獨立的執行棧和PC計數器,共享進程的代碼和堆空間;

進程上下文切換比線程切換開銷大的原因:

  • 需要切換進程的頁表和TLB(快表),而線程切換隻需要切換執行棧和PC計數器。
  • 進程切換需要保存和恢復更多的寄存器和上下文信息。
進程的通信方式

1、 管道:是操作系統在內核中開闢一段緩衝區,進程A可以將需要交互的數據拷貝到這個緩衝區裏,進程B就可以讀取了。特點如下:

  • 內核的一塊內存空間.
  • 字節流,沒有邊界,半雙工(數據流只能向一個方向)
  • 多對多通信:需要多個管道。
  • 大小: 4-6kb (較小)

2、消息隊列:是操作系統在內核中開闢的一段內存空間,以鏈表結構形式存在,進程A和進程B都可以在這個列表中讀寫從而實現通信。特點如下:

  • 內核中的一塊內存空間,以鏈表數據結構形式存在。
  • 有邊界,消息是帶有類型標識的,全雙工通信(可以雙方同時收發)。
  • 多對多通信:1 個消息隊列支持。
  • 大小:比管道大很多(大概4M這種級別),但也有容量限制。

3、共享內存: 它通過將物理內存映射到不同進程的虛擬地址空間來實現,一個進程對共享內存的寫入會立即被其他進程看到。多個進程同時進行讀寫,必須使用信號量、互斥鎖等同步機制來避免數據混亂。它避免了數據在進程間的複製,是所有IPC機制中最快的一種。
4、信號量:信號量就是一個變量,用來表示系統中某個資源的數量。它是用來解決進程的同步和互斥問題,是進程通信的包含機制,本質上不用來傳輸真正的通信內容。
5、 socket:socket套接字通信是網絡通信,是基於TCP/IP協議的網絡通信基本單元。上述的通信方式都是同一台主機之間的進程通信,而在不同主機的進程通信就要用到socket的通信方式。

P.S. 管道和消息隊列兩者使用場景比較:

維度 管道 消息隊列
速度 更高(內核緩衝區直接拷貝) 略低(需處理消息結構)
開發複雜度 簡單(僅文件描述符操作) 較高(需管理鍵值、類型等)
擴展性 差(難以支持複雜通信模式) 強(支持多對多、優先級等)
持久化 否(進程退出後數據丟失) 是(消息隊列可以持久化)

總結
選擇管道:當需要簡單、高效的字節流通信,且進程關係緊密(如父子進程)。
選擇消息隊列:當需要結構化消息、無關進程通信、或持久化支持。

瀏覽器的進程
  • 瀏覽器進程
    作為總控進程,負責瀏覽器界面的顯示、用户交互和子進程的創建與管理。
  • 渲染進程(內核進程)
    通常情況下,(不嚴謹的説)每個標籤頁(tab)會開啓一個渲染進程。 它負責將HTML、CSS和JavaScript等轉換為用户可視化的網頁,V8引擎和Blink等核心組件都在此進程內運行,並且出於安全考慮,它運行在沙箱環境中。
  • GPU進程
    專門用於處理圖形渲染,尤其是對3D圖形和GPU硬件加速的支持。 隨着網頁和瀏覽器UI界面的複雜化,GPU進程也變得越來越重要。
  • 插件進程
    負責運行瀏覽器中安裝的第三方插件。 為了防止插件崩潰影響整個瀏覽器,每個插件會擁有一個獨立的進程。
  • 網絡進程
    負責處理頁面的網絡請求,如下載HTML、CSS、圖片等資源。 該進程從瀏覽器進程中獨立出來,以提高效率。

瀏覽器渲染

瀏覽器的線程

一般來説,現代瀏覽中同源的頁面(是通過window.open, a標籤跳轉打開的,不是手動點擊「加號」打開一個新tab輸入地址打開的)會共享一個渲染進程。(實際要看瀏覽器是什麼進程模型實現的,可自行了解)

渲染進程包括:主線程、光柵化線程、合成線程和Worker線程(Web worker/Service worker)

P.S.在前端的很多文章裏提到:

“JavaScript 是單線程的,它和 GUI 渲染線程互斥。”

這裏的 「GUI 線程」指的是:

  • 負責 構建 DOM、樣式計算、佈局(layout)、繪製(paint)的那一套 UI 更新機制。
  • 它與 JS 引擎線程(主線程)共存於同一個渲染進程中,但不能並行執行。
    嚴格來説,GUI 渲染並不是一個獨立的真實線程,而是主線程在某個階段執行“渲染任務”的統稱。
瀏覽渲染html頁面的流程(5步)

1.解析html(執行JS)生成DOM;
2.計算樣式,將樣式應用到DOM生成Render Tree;
3.計算佈局,生成Layout Tree;
4.繪製,描述低層GUI庫如何繪製,產出一個繪製指令列表;
5.柵格化&合成(前面4步發生在渲染進程的主線程,這一步發生在合成線程)。

更具體的細節可以讀一下這篇文章渲染流程:HTML、CSS和JavaScript是如何變成頁面的?

關於上述渲染流程可以提幾個問題:

a.display:none的元素會出現在Layout Tree上嗎?
答:不會。構建佈局樹是遍歷render tree上可見的元素生成的。

b.字體大小改變會觸發迴流嗎?
答:會,font-size屬於幾何屬性,會觸發迴流。

c.html解析和計算樣式可以並行嗎?
答:是存在局部並行的。最終的Render Tree構建是要依賴DOM的,並且要考慮樣式繼承,故無法直接並行。但這個過程中的一些步驟可以並行:將DOM構建好的子樹劃分成獨立的chunk,可以使用worker並行計算chunk的樣式,最後合併樣式結果。

d.圖片的下載會影響html的解析嗎?
答:不會,圖片的加載是異步的。

e.你知道什麼是柵格化嗎?
答:知道,柵格化是將頁面的​​圖層(Layer)​​或​​圖塊(Tile)​​轉換為位圖Bitmap)的過程。(合成就是GPU將位圖合成到屏幕上顯示圖像)

迴流和重繪

當我們修改DOM、樣式就會重新渲染,就是如下圖(圖來自網絡)的的流程:image.png

當修改/訪問了元素的幾何屬性(比如樣式的寬高和位置,增刪元素)和訪問offset/scroll/client家族屬性和getComputedStyle屬性,就會發生迴流——即重新計算佈局。然後會觸發重繪。

當修改背景圖、顏色color、不透明度opactiy、可見性visibility等不影響佈局的屬性,就會跳過「佈局」,僅僅引起重繪。

對比
迴流的代價比較大,重繪的開銷相對較小(重繪不一定導致迴流,但迴流一定會導致重繪)。

避免迴流的優化

  • 移動元素:儘量使用transform屬性的translate移動,不使用absolute定位移動元素。
  • 避免分多次修改幾何屬性:集中在一起修改幾何屬性,不要分散到不同的任務(宏任務)。
  • 先修改再掛載DOM:創建好的DOM元素,完成全部修改後再掛載到DOM樹上(這塊可以引申:用文檔片段Fragment來做這種優化);
  • 先修改再用display屬性展示出來:先將元素脱離文檔流(display:none;),等元素的修改全部完成再顯示(display:block;)。
  • 動畫:將動畫效果應用到 position 屬性為 absolute 或 fixed 的元素上,給 z-index 層級變高一點。
  • 寬高計算:避免使用CSS表達式 calc

四、存儲

localStorage和sessionStorage

兩者的存儲大小5-10M,每個域名下的localStorage和sessionStorage的存儲大小是獨立的。兩者區別如下:

  • sessionStorage: 當tab頁關閉時會被清除;
  • localStorage:會長期存儲,直到手動刪除。

友情提示:localStorage 的API記一下哦:

  • setItem(key: string, value: string): 存儲數據
  • getItem(key: string): 獲取數據
  • removeItem(key: string): 刪除指定數據
  • clear(): 清空所有數據

localStorage滿了會怎麼樣?
當localStorage存儲達到上限時,瀏覽器會拋出 QuotaExceededError異常。

localStorage如何擴容?
原則是是無法對直接擴大localStorage容量。只能通過使用其他替代的存儲方式(比如indexDB);另外可以對存儲內容進行壓縮和定期清理過期localStorage來高效利用localStorage。

Cookie

存儲大小在4KB。可以由服務端設置Cookie(這個Cookie會被瀏覽器保存,這是HTTP協議的一部分)。因為HTTP是無狀態的,所以需要Cookie來保存Client/瀏覽器的會話狀態(比如:Cookie結合Session一起使用可以維護登錄狀態, Cookie還可以保存用户偏好、購物車數據等)。

瀏覽器端通過document.cookie來訪問Cookie。比如加一段新Cookie:

document.cookie += newCookie;

下面是 Set-Cookie 字段的一些常見屬性:

  1. Name: Cookie 的名稱和值。
  2. Expires: 指定 Cookie 何時過期。如果沒有指定,Cookie 將在會話結束(關閉Tab頁)時過期。
  3. Max-Age: 指定 Cookie 的有效期(以秒為單位)。這個屬性優先於 Expires
  4. Domain: 指定 Cookie 所屬的域名。
  5. Path: 指定 Cookie 的有效路徑。
  6. Secure: 指定 Cookie 只能通過 HTTPS 傳輸。
  7. HttpOnly: 指定 Cookie 不能通過 JavaScript 訪問,減少 XSS 攻擊風險。
  8. SameSite: 控制 Cookie 是否隨跨域(準確説是跨Domain)請求發送。可能的值有 StrictLaxNoneStrict表示不攜帶cookie,Lax 表示僅在a標籤跳轉請求攜帶,None表示可以攜帶。

例如,一個 Set-Cookie 頭長這樣:

Set-Cookie: sessionId=abc123; Expires=Wed, 09 Jun 2025 10:18:14 GMT; Path=/; Secure; HttpOnly

關於Cookie的其他注意事項:

  1. DomainPath兩個屬性決定cookie是否『同源』,也就是説cookie的「同源」限制和瀏覽器的同源策略是不一樣的(即cookie的Domain與是否為子域名、協議、端口無關)
  2. 默認情況下出現跨「域」(Domain),是不會攜帶cookie的(因為默認SameSite=Lax;)。另外要區分:請求API中的withCredentials僅僅是控制請求時API帶不帶cookie,對於那些被設置了SameSite=Lax|Strict的cookie是不論怎樣都不會被帶到請求上的。因此,跨「域」想要攜帶cookie,只能設置Set-Cookie字段的屬性:SameSite=None
  3. 説起跨域,大家會聯想起CORS,但是這個和Cookie的跨域攜帶沒有聯繫。CORS解決的是跨域下請求響應被攔截的解決方案,而Cookie的“跨域”是不同“域”不能被訪問、不能被攜帶的範疇。

IndexDB

IndexedDB 是一個事務型數據庫系統, 是一個基於 JavaScript 的面向對象數據庫。它有3個顯著的特點:

  • 支持事務、索引。
  • 容量大,通常是幾百M~GB級別。
  • 存儲是異步的。

使用示例:

/*1.連接*/
const request = window.indexedDB.open('myDatabase', 1);
request.onerror = (event) => {
  // 使用 request.errorCode 來做點什麼!
};
request.onsuccess = (event) => {
  // 使用 request.result 來做點什麼!
};


/*2.定義存儲結構*/
// onupgradeneeded 是我們唯一可以修改數據庫結構的地方(相當於初始化表結構)
request.onupgradeneeded = (event) => {
  const db = event.target.result;

  
  // 創建一個對象存儲來存儲我們客户的相關信息,keyPath類似主鍵,標識對象,具有唯一性。createObjectStore類似創建表,但是IndexDB是面向對象數據庫(不是關係型數據庫),所以是創建一個對象存儲(也可以理解成集合,對象存儲的結構並不固定,只需要有屬性ssn)
  const objectStore = db.createObjectStore("customers", { keyPath: "ssn" });
  
  /*
  客户數據看起來像這樣的
  const customerData = [
  { ssn: "444-44-4444", name: "Bill", age: 35, email: "bill@company.com" },
  { ssn: "555-55-5555", name: "Donna", age: 32, email: "donna@home.org" },
];  */

};



/*3.讀寫數據*/
//啓動事務
const transaction = db.transaction(["customers"], "readwrite");
//添加數據
const objectStore = transaction.objectStore("customers");
customerData.forEach((customer) => {
  const request = objectStore.add(customer);
  request.onsuccess = (event) => {
    // event.target.result === customer.ssn;
  };
});

//使用遊標,遍歷所有數據
const objectStore = db.transaction("customers").objectStore("customers");

objectStore.openCursor().onsuccess = (event) => {
  const cursor = event.target.result;
  if (cursor) {
    console.log(`SSN ${cursor.key} 對應的名字是 ${cursor.value.name}`);
    cursor.continue();
  } else {
    console.log("沒有更多記錄了!");
  }
};

IndexDB的使用場景:

  • 前端日誌上報和監控場景,需要本地存儲日誌(考慮離線、避免頻繁發請求),例如美團的logan-web
  • 即時通訊 (離線訪問信息,包括文本、圖片等等,數據量大)

Cookie和LocalStorage誰更適合存token?

首先比較下Cookie和LocalStorage兩種存儲方案

特性 Cookie localStorage
存儲大小 4KB 5-10MB左右
請求攜帶 自動攜帶 不會自動攜帶
有效期 可設置有效期,過期自動刪除 需要手動刪除,或者通過存儲額外的時間信息判斷是否過期
安全性 可通過httpOnly不允許js讀寫來避免XSS攻擊;可以通SameSite=Lax來避免CSRF攻擊 數據可被JavaScript訪問,容易受到XSS攻擊;和CSRF攻擊無關;

答案:Cookie。首先兩種方案都可以存token,下面將從安全性和使用場景分析為什麼選Cookie方案。

  1. Cookie便捷:Cookie自動攜帶token,使用比localstorage方便(localStorage需要在請求頭上手動設置);
  2. 安全性:Cookie可以通過httpOnly不允許js讀寫、secure保證僅在https下傳輸(避免了XSS和中間人攻擊);Cookie容易受到CSRF攻擊但可以通過SameSite和CSRF token等手段避免。 localStorage中的數據可以被JavaScript訪問,因此容易受到XSS攻擊。CSRF攻擊是比較容易預防的,而XSS形式太多,相對不好預防,localStorage無法避免在XSS中不被訪問,所以Cookie相對安全點。
  3. SSO場景使用Cookie實現更簡單: localStorage(受同源策略限制),不同域的應用無法讀取,實現SSO就比較複雜。

綜上,Cookie比localstorage更安全,更便捷,更適合存token.

五、跨域問題

同源策略

解釋:同源策略是==一種瀏覽器安全機制,用於限制一個源(協議、主機和端口相同)的文檔或腳本如何與來自另一個源的資源進行交互==。
這裏的同源指下面三個條件一樣:

協議(protocol) + 域名(host) + 端口(port)

為什麼要同源策略:限制js,保障網站的用户信息

  • 不同源的網站的js不能互相訪問對方的存儲,包括cookie/localstorage/indexDB。
  • 不同源的網站的js不能訪問對方的DOM
  • ajax請求不同源的話,會被瀏覽器攔截響應。

跨域解決方案:

瀏覽器的跨域問題:通常是指ajax請求了不同源的服務,導致響應被瀏覽器攔截了。解決方式有以下幾種:

1. CORS(跨域資源共享)
CORS是現代瀏覽器支持的標準跨域解決方案,通過在服務器端設置響應頭來實現:

  • 設置響應頭Access-Control-Allow-Origin,值可以是*(允許任何域訪問)或特定域名;
  • 域名可以動態設置,根據請求頭的Origin判斷是否在域名白名單,然後動態設置響應頭的Access-Control-Allow-Origin為該域名;
  • 對於非簡單請求,瀏覽器會先發送預檢請求(OPTIONS),服務器需設置Access-Control-Allow-MethodsAccess-Control-Allow-Headers等響應頭信息;
  • 如需攜帶cookie,前端需設置withCredentials: true,後端需設置Access-Control-Allow-Credentials: true

滿足下面兩個條件即簡單請求

  • head/get/post方法
  • 請求頭不超出以下字段:(其中Content-Type有限制)
    Accept
    Accept-Language
    Content-Language
    Last-Event-ID
    Content-Type:只限於三個值application/x-www-form-urlencodedmultipart/form-datatext/plain

滿足下面任意條件即非簡單請求

  • put或者delete請求
  • 任意請求方法,但是,超出了簡單請求的規定的方法,或者Content-Type超出了規定的值。

非簡單請求的跨域請求,會在正式發請求前,增加一次HTTP查詢請求,稱為options請求("預檢"請求)(P.S. 如果不是跨域請求,則不存在預檢請求)。
就是説,瀏覽器會先發一個options請求。因為瀏覽器認為即將要執行的請求可能會對服務器造成不可預知的影響時,所以瀏覽器會先發這麼個請求來驗證,然後根據響應的Control-Allow-MethodsAccess-Control-Allow-Headers決定是否要發正式請求。

2. Nginx 反向代理
利用服務器不受瀏覽器同源策略限制的特點,在服務器端做轉發:

  • Nginx將前端頁面和API接口配置在同一域名和端口下,通過不同的URL路徑區分
  • 對於瀏覽器來説,請求是發送到同源服務器,而Nginx則負責將請求轉發到真正的API服務器
  • location塊通過指定模式來匹配客户端請求的URI,進行服務轉發或靜態資源匹配
    例如Nginx配置:
server {
    server_name website.com;
    
    # 靜態資源直接由nginx提供服務
    location ^~ /static/ { 
        root /webroot/static/; 
    }
    
    # 前端頁面請求
    location = / {
        proxy_pass http://frontend-server:3000;
    }
    
    # API請求轉發到後端服務
    location /api/ {
        proxy_pass http://backend-server:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

3.Nodejs做中間層
當node提供網頁服務時,ajax請求發給node,node代理請求,把數據返回給瀏覽器。原理本質是和Nginx代理一樣。

4.JSONP
默認情況下瀏覽器沒有對script標籤進行同源限制(CSP可限制script的源),從而可以通過script標籤發起一個get請求,把回調函數放到這個請求的query參數中,服務端把數據作為回調函數的參數寫入js腳本交給瀏覽器。
script標籤類似下面這樣:

script.src = "http://jsonp.js?callback=cb";

服務端響應的內容類似下面這樣:

res.send(`${callback}(${JSON.stringify(data)})`)

下面3種方式是開發時處理跨域可採用的:

  1. webpack/vite devServer
  2. 本地修改host文件——設置 域名映射環回地址
  3. whistle 服務 + proxy switchyOmega瀏覽器插件進行代理

六、瀏覽器安全

CSRF

解釋

什麼是 CSRF(Cross-Site Request Forgery):

  • CSRF(跨網站請求偽造)是利用用户在已登錄的網站上的身份,通過偽造的請求,在用户不知情的情況下,向目標網站發送請求,執行目標網站的操作。
  • 舉例:你在 A 網站已登錄認證過,有人誤導你去點他們的 B 網站的鏈接或頁面,B 悄悄讓你的瀏覽器去請求 A,此時瀏覽器會“自動帶上 A 的 Cookie”,結果就用你的身份在 A 上做了你沒授權的事。

為什麼會發生:

  • 瀏覽器只要發起到 A 的請求,就會自動附帶 A 的 Cookie(這是“幫你保持登錄”的機制)。
  • 攻擊者是不知道你的Cookie內容的(同源策略),但CSRF攻擊並不需要知道Cookie內容,只要能“讓你的瀏覽器把請求發出去並帶上 Cookie”,就可能成功。

預防

1.Cookie 設置:

  • 默認用 SameSite=Lax|Strict:跨站場景不帶 Cookie。

2.校驗來源:

  • 優先看 Origin(非冪等方法通常會帶),再回退 Referer(可能被隱私或代理去掉)。
  • 只允許同源或受信子域來源,其他直接拒絕。

3.令牌校驗(CSRF Token):

  • 做法一:服務端生成表單頁面時,生成一個CSRF token作為一個隱藏字段注入到表單,同時這個CSRF token在服務端保存(session/redis)。表單提交時會攜帶CSRF token,然後服務端核對。這種在傳統網站/SSR網站較為適用。
  • 做法二(雙cookie法):web頁面託管服務在瀏覽器請求頁面時,生成一個CSRF token並將其設置到Cookie(設置httpOnly=false),然後當瀏覽器獲取到這個CSRF token,在後續的業務請求的請求頭帶上CSRF token。

4.接口設計:

  • 嚴禁用 GET 改數據(這點容易在開發時不經意間被忽略);

5.驗證碼:

  • 通過強制用户與應用程序進行交互來預防CSRF 攻擊;

補充:CSRF Token 雙Cookie方法細節,驚豔面試官

將CSRF token存放到Cookie這種方案,像Spring Security和Axios請求庫都是踐行了這套方案的。初次接觸你可能會疑惑既然是利用Cookie攻擊,為啥還要將CSRF token存cookie裏?不存localStorage裏呢?

首先我們瞭解下雙Cookie法的流程細節:
1.服務端響應頁面請求時,設置Cookie:Name=XSRF-TOKEN

// 中間件:檢查或設置 CSRF Token
app.use((req, res, next) => {
  if (!req.cookies['XSRF-TOKEN']) {
    const token = crypto.randomBytes(20).toString('hex');
    // 注意:CSRF token 必須能被前端讀取,所以 HttpOnly 設為 false
    res.cookie('XSRF-TOKEN', token, {
      httpOnly: false,
      sameSite: 'Strict', // 防止跨站攜帶
      secure: false       // 本地測試先關閉;線上建議啓用 HTTPS 後開啓
    });
  }
  next();
});

2.js請求時,從cookie中讀取XSRF-TOKEN屬性值,並將之設置到請求頭字段X-XSRF-TOKEN中。(如果你是使用axios請求庫,這個讀XSRF-TOKEN並設置X-XSRF-TOKEN是默認幫你做了的)

http.interceptors.request.use((req) => {
  //jwt等操作
  req.headers.set("client", "web");
  req.headers.set("X-Requested-With", "XMLHttpRequest");
  let csrf = utils.getCookieValue("XSRF-TOKEN");
  if (csrf) {
    req.headers.set("X-XSRF-TOKEN", csrf);
  }
  return req;
});

到這裏你應該理解為啥是能存Cookie了吧,本質上還是需要讀取Cookie拿到token,然後把token放到請求頭上給後端校驗。
為什麼不用localStorage呢?我想是方便的緣故,存Cookie可以在請求頁面的時候下發(通過攔截器/中間件把這層邏輯和業務隔開),如果是存localStorage那麼就需要考慮CSRF token的下發時機,比如在登錄時,有一定的業務侵入性。

【參考】
如何防止CSRF攻擊?- 美團技術
CSRF 詳解:攻擊,防禦,Spring Security應用等
asp.net core在vue等spa程序防止csrf攻擊

XSS

解釋

什麼是XSS(Cross Site Scripting):

  • XSS(跨站腳本):是一種常見的安全漏洞,攻擊者利用它將惡意JS腳本注入到合法的網站中,然後執行這些惡意腳本來竊取用户敏感信息(如登錄的token)、冒充身份和篡改網頁等等。

根據攻擊手段分為三種類型:
1.反射型
構造惡意的URL,參數包含JS腳本。服務端未對參數進行轉義,直接渲染到頁面。
2.存儲型
用户輸入的數據被存儲在服務端的數據庫中,當其他用户訪問該數據時,會被直接渲染到頁面(盜取cookie/localstorage的敏感信息)。常發生在註冊、評論和發帖等輸入交互場景。
3.DOM型
構造惡意的URL (目標網站的URL帶一些惡意參數), 誘導用户點擊後前端讀取參數渲染頁面時注入了JS腳本。

辨析:

  • 反射型和存儲型的區別:反射型的惡意代碼在URL中,存儲型代碼在數據庫中。
  • 反射型和DOM型很類似,都是URL參數帶有JS鏈接/JS協議代碼導致的,區別在於一個是服務端渲染頁面注入腳本的,一個是前端渲染時注入的。
  • 反射型、存儲型是後端的責任(需要後端過濾、轉義),而DOM型是前端的責任。用大白話講就是如果是服務度負責拼接HTML那麼就是服務端責任,如果是前端拼接就是前端責任。

預防

總體上來説,有以下5種預防手段:

  1. 輸入過濾
  2. 輸出編碼
  3. CSP(Content-Security-Policy 內容安全策略)
  4. Cookie: 設置httpOnly ,禁止 JavaScript 讀取某些敏感 Cookie,攻擊者完成 XSS 注入後也無法竊取此 Cookie。
  5. 輸入字符串的長度限制

輸入過濾&輸出編碼

輸入過濾和輸出轉義,兩者本質上處理方式有很大相同的——主要是對字符串轉義(因為這些字符串最終是要拼接到HTML上渲染的)。
區別在於:

  • 字符串作為數據要存入數據庫時處理,這個時候轉義行為屬於"輸入過濾"。
  • 字符串在要拼接到HTML上渲染時處理,這個時候轉義行為屬於"輸出編碼"。

那麼轉義,需要轉義哪些字符呢?
轉義< , > , ' , " , & 這5個字符,通常能避免大多數JS注入了。代碼如下:

function htmlEscape(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}
// 示例
let myString = "This is a string with < and > symbols and \"quotes\".";
let escapedString = htmlEscape(myString);
// 用於動態生成的文本
document.getElementById('output').innerHTML = htmlEscape(escapedString);

但實際上需要編碼情況更為複雜,比如你還需要考慮javascript協議的腳本: javascript:惡意代碼.
onerror/onload/onclick等內聯事件能執行js協議字符串。

// src 是用用户數據拼接的
<img src="<%= imgSrc %>" />

// 如果用户數據如下
$imgSrc = './not-exist-img.png" onerror="javascript:惡意代碼'

// 那麼拼接後的 img 標籤就是
<img src="./not-exist-img.png" onerror="javascript:惡意代碼" />

location<a>標籤的href屬性,也會執行 。

location.href = 'javascript:惡意代碼'
<a href="javascript:惡意代碼">1</a>
//可以通過限制只允許http/https協議,來避免。

eval/setTimeout/setInterval這些函數也能執行。

setTimeout("UNTRUSTED") 
setInterval("UNTRUSTED")
eval("UNTRUSTED")
//應該避免這種調用執行

總之在轉義的時候,需要採用成熟的白名單消毒庫(比如DOMPurify)來處理。

P.S.

  • 輸入過濾其實並不是很推薦使用,因為字符串作為數據保存時,如果轉義了,其實就不知道原本的字符串了,在要操作數據時就比較麻煩。當然也看場景,我覺得富文本編輯這種場景就比較適合輸入過濾。
  • vue的插值 {{}} 語法,react的jsx動態插入內容,都是框架層面進行過非法字符轉義的。應當避免使用v-html指令, react的dangerouslySetInnerHTML屬性,因為這些不會進行轉義,從而可能有XSS攻擊的風險。

CSP

通過 HTTP 響應的頭 Content-Security-Policy 字段來定義的。在響應頁面的響應頭加上這個字段,這個頭部字段包含一系列指令,每個指令指定允許從哪些來加載特定類型的資源。例如,script-src 指令可以用來限制從哪些域加載 JavaScript 腳本。

常見指令:

  • default-src: 為其他未顯式指定的資源類型定義默認策略。
  • script-src: 限制 JavaScript 資源的加載來源。
  • style-src: 限制 CSS 樣式表的加載來源。
  • img-src: 限制圖像的加載來源。
  • connect-src: 限制 XHR、fetch、WebSocket 等請求的目的地。
  • font-src: 限制字體的加載來源。
  • child-src:限制 Web Worker 腳本文件或者其它 iframe 等內嵌到文檔中的資源來源。
  • report-to: 違規上報地址。
    例如:
Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.example.com; script-src 'self';

這段策略的意思是:

  • default-src 'self':默認資源只允許從本站加載。
  • img-src 'self' https://cdn.example.com:圖片可從本站和指定 CDN 加載。
  • script-src 'self':腳本只能從本站加載,禁止內聯腳本和外部第三方腳本。

此外,處理在響應頭上設置的形式外,還可以採用HTML的meta標籤形式,比如,上面例子對應到meta標籤就是:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' https://cdn.example.com; script-src 'self';">

Cookie設置httpOnly
Cookie的httpOnly這個並不能直接預防XSS,而是當收到XSS攻擊後能一定程度上減少攻擊範圍,能包含cookie信息不被竊取。

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict;

長度限制
限制用户的輸入內容長度可提高XSS攻擊的難度。

【參考】:
如何防止XSS攻擊?- 美團技術

七、垃圾回收機制

標記清除法

策略: 一定的頻率執行一次GC(garbage collection)的流程。

  • 遍歷內存,給所有對象加標記0(表示無用)。
  • 遍歷對象window,訪問到的對象加標記1(表示在用)
  • 再次遍歷內存中所有對象,所有標記為0的清除,標記為1的改為0(等待下一次回收)。

優點:
實現簡單,只需用一個二進制位表示用/無用兩個狀態,然後做遍歷清除無用的。

缺點:
產生內存碎片。這會導致下一次分配內存慢(需要找到size大小的連續內存空間)。針對這個缺點,標記整理(Mark-Compact)算法可以有效解決(本質上就是標記結束後移動內存,讓剩餘空間連續)。

引用計數法

策略: 跟蹤記錄每個變量值被引用的次數

  • 當對象賦值給變量,這個變量值引用次數+1
  • 如果變量原本值,被其他值覆蓋(比如null),則原本值的引用次數-1
  • 當這個值引用次數變為0,則會被垃圾回收器回收。
let a = new Object()     // 此對象的引用計數為 1(a引用)
let b = a         // 此對象的引用計數是 2(a,b引用)
a = null          // 此對象的引用計數為 1(b引用)
b = null          // 此對象的引用計數為 0(無引用)
...            // GC 回收此對象

優點: 實時性高,當一個對象的引用計數降為零時,該對象可以立即被回收,不會像其他算法那樣需要等待特定時機。

缺點:

  1. 性能開銷。引用計數法需要在​​每次引用關係發生變化時​​(如變量賦值、引用覆蓋、對象屬性修改、變量離開作用域)都立刻更新計數器。這帶來了額外的性能負擔,尤其是在引用關係變化頻繁的場景下。
  2. 存儲開銷。每個對象都需要分配一個額外空間,存儲計數值。
  3. 無法解決循環引用無法回收的問題,這是最嚴重的。

循環引用問題:

function problem() {
    let objA = {};
    let objB = {};
    objA.someProp = objB; // objB 被 objA 引用,計數+1 -> 計數=2
    objB.anotherProp = objA; // objA 被 objB 引用,計數+1 -> 計數=2
}
problem(); // 函數調用結束,棧上的 objA 和 objB 被清除,它們的引用計數各減1 -> 都變為1
// 此時 objA 和 objB 仍相互引用,計數永不為0,無法被引用計數算法回收
V8的分代式垃圾回收

策略: 將內存空間分為新生代和老生代。

  1. 新生代分為使用區和空閒區。使用「複製」算法,新對象被放入使用區,一段時間後進行GC時,對使用區的對象進行標記然後排序(清理沒有引用的),將存活的對象放入空閒區(此時空閒區和使用區互換)。
  2. 對於複製(到空閒區)頻繁的對象和比較大的對象,將被移入老生代。老生代使用 「標記清除法」

為什麼要分代式?
基於“分代假説”(多數對象短命),將堆分為新生代與老生代。新生代空間較小、由兩個半空間組成,採用複製回收(Minor GC)且觸發頻繁;對象若在多次回收後仍存活,會晉升到老生代。老生代空間更大,使用標記-清除/標記-整理(常見是增量、併發)進行較低頻率的 Major GC。體積特別大的對象通常直接進入老生代的大對象空間,避免複製成本。分代機制把主要回收壓力集中在短命對象上,提升吞吐並降低停頓,從而顯著提高垃圾回收效率。

【參考】
「硬核JS」你真的瞭解垃圾回收機制嗎

八、跨tab通信的方式

回答下面三種即可:

  • Broadcast Channel
  • Shared Worker
  • localStorage/sessionStorage

【參考】
瀏覽器跨 Tab 窗口通信原理及應用實踐

九、對JS引擎和異步實現的理解

JS引擎是隻負責解釋和執行JS代碼,並不提供setTimeout/fetch這類API。JS引擎是單線程的,只管同步執行代碼,異步代碼的執行和管理是交給運行時來處理的。

JS引擎的功能:

  • 解釋(Interpreter)和及時編譯(JIT, Just-In-Time Compilation)。

    • 解釋:引擎將對代碼做詞法分析,轉AST,然後轉為字節碼(二進制);
    • 編譯:將字節碼編譯為機器碼。
  • 垃圾回收。

JS中的異步
“異步”由引擎 + 運行時(瀏覽器、Nodejs)配合實現 ; 其中運行時提供了異步API和任務隊列(Macro Task和Micro Task等等),通過將異步任務的回調放入隊列,等異步任務完成後把隊列任務載入JS引擎的執行棧中執行。
JS中處理異步的三種方式:

  • 回調函數
  • Promise。解決地獄回調問題,需要JS引擎支持Promise語法。
  • Async/Await​。語法糖簡化異步代碼,類似生成器的原理處理了Promise。讓異步代碼從語法上變"同步代碼"

十、瀏覽器的API

介紹下Worker

web worker可以細分為Worker、SharedWorker 和 ServiceWorker 等,接下來我們一一介紹其使用方法和適合的場景。

普通Worker

使用場景:處理一些長任務計算,避免主線程的阻塞。

代碼示例:
在主線程:

const worker = new Worker('./worker.js'); // 參數是url,這個url必須與創建者同源 
// 拿到句柄 worker後可以發送消息和接受消息
worker.postMessage('Hello World');

worker.onmessage = function (event) {
  console.log('Received message ' + event.data);
  doSomething();
}

worker.js 中接受和發送消息:

//通過self這個句柄/關鍵字,監聽message事件接受消息,postMessage發消息
self.addEventListener('message', function (e) {
  self.postMessage('Hello, 我是worker');
}, false);
SharedWorker

和普通Worker的區別: SharedWorker 的方法都在 port 上,這是它與普通 Worker 不同的地方。
使用場景: 可以跨window/tab/worker 共享數據(只要在同源範圍內)。

代碼示例:
每個頁面的連接,產生的port句柄會被放入connections數組中。

// shared-worker.js
const connections = []; // 存儲所有頁面的端口

self.onconnect = function (e) {
  // 獲取新連接的端口
  const port = e.ports[0];
  // 將新端口存入連接列表
  connections.push(port);

  // 監聽來自這個端口的消息
  port.onmessage = function (event) {
    // 接收到消息後,廣播給所有其他連接的頁面
    connections.forEach(conn => {
      if (conn !== port) { // 可選:不發送回消息來源頁
        conn.postMessage(event.data);
      }
    });
  };

  // 啓動端口通信 (當使用 onmessage 隱式調用了 start, 但顯式調用更安全)
  port.start();
};

Tab 1/ Tab 2/ Tab ...都使用下面類似的結構(每個頁面都需要new SharedWorker建立連接)

// 檢查瀏覽器支持
if (window.SharedWorker) {
  // 創建 SharedWorker 實例,指向同一個腳本文件
  const worker = new SharedWorker('shared-worker.js');
  
  // 啓動端口
  worker.port.start();
  
  // 監聽來自 SharedWorker 的消息
  worker.port.onmessage = function (e) {
    console.log('Received message:', e.data);
    // 在這裏處理接收到的消息,更新頁面DOM等
  };
  
  // 發送消息
  function sendMessage(message) {
    worker.port.postMessage(message);
  }
  
  // 例如,點擊按鈕發送消息
  document.getElementById('sendBtn').addEventListener('click', () => {
    sendMessage('Hello from Tab!');
  });
} else {
  console.error('Your browser does not support SharedWorker.');
}
ServiceWoker
ServiceWorker 一般作為 Web 應用程序、瀏覽器和網絡之間的代理服務。他們旨在創建有效的離線體驗,攔截網絡請求,以及根據網絡是否可用採取合適的行動,更新駐留在服務器上的資源。他們還將允許訪問推送通知和後台同步 API。

【參考】
WebWorker、SharedWorker 和 ServiceWorker 有哪些區別?

為什麼setTimeout不準確?

遇到這麼個問題:"請實現一個倒計時的組件,要求倒計時要精準"。
這個時候相信聰明的你肯定想到用setTimeout/setInterval,但setTimeout/setInterval計時準確嗎?

JavaScript 是單線程的,setTimeout() 的回調函數並不是在時間到了就立刻執行,而是:

“時間到了後,回調會被放入任務隊列(task queue),等待主線程空閒時再執行。”

因此:
如果主線程正在執行其他任務(例如計算、渲染),回調就會被延遲執行。並且嵌套調用setTimeout會讓這種誤差慢慢累積,導致後面越來越不準確。
嵌套調用setTimeout:

let count = 60;
function tick() {
  console.log(count);
  count--;
  setTimeout(tick, 1000); // 每次調用都會累積一點延遲,count並不會準確每秒減少1
}
tick();

解決方法: 使用 requestAnimationFrame()
你可能會疑問:requestAnimationFrame的回調是在瀏覽器下一次重繪(repaint)前執行。這也收到主線程阻塞的影響啊。
確實,requestAnimationFrame (rAF)不能保證絕對精確,它同樣會被主線程阻塞延遲。 並且當頁面切到後台或瀏覽器標籤頁不可見時, rAF 會暫停或降低幀率(通常暫停執行)。
但是, rAF保證了一件事,就是頁面出現變化前肯定會執行 rAF的回調(與瀏覽器繪製同步),這就意味着每次頁面變化我都看到一定是準確的結果(這點不同setTimeout的倒計時可能頁面看到不準確的結果)。然後,只需要麼次 rAF調用時做時間校準就行,看下面的例子。

let count = 60;                         // 初始倒計時秒數
let startTime = performance.now();      // 開始的高精度時間
let lastSecond = 0;                     // 上一次減少 count 的時間

function tick(now) {
  //now是一個當前時間的時間戳
  const elapsed = Math.floor((now - startTime) / 1000); // 已經過的秒數

  if (elapsed > lastSecond) {
    const delta = elapsed - lastSecond; // 理論上應減少的秒數
    count -= delta;
    lastSecond = elapsed;
    console.log(count);
  }

  if (count > 0) {
    requestAnimationFrame(tick);
  } else {
    console.log('⏰ 倒計時結束');
  }
}

requestAnimationFrame(tick);

就是説主線程阻塞了渲染導致頁面沒變化,這個無論都是沒辦法的,但是當頁面重新渲染(發生變化)rAF能保證一定顯示正確的count(倒計時)。

XHR, axios和fetch的區別?

xhr是最早瀏覽器的請求API, fetch是現代瀏覽器的請求API. axios則是基於xhr封裝的支持promise語法形式的庫。
一些具體的區別:

  • json: xhr/fetch需要手動 解析json數據。axios直接自動將數據轉json。
  • 取消:axios/fetch支持AbortController 取消請求,xhr通過 xhr.abort()
  • 處理錯誤:axios自動將 HTTP 錯誤狀態碼(如 404、500)轉為 Promise 的 reject; xhr和fetch不會。
  • 攔截:axios提供請求攔截和響應攔截。
user avatar zaotalk Avatar jingdongkeji Avatar chongdianqishi Avatar leexiaohui1997 Avatar huichangkudelingdai Avatar solvep Avatar imba97 Avatar wmbuke Avatar bugDiDiDi Avatar zhuifengdekaomianbao Avatar kitty-38 Avatar romanticcrystal Avatar
Favorites 73 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.