iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 12
12
Modern Web

重新認識 JavaScript系列 第 12

重新認識 JavaScript: Day 12 透過 DOM API 查找節點

本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。

購書連結 https://www.tenlong.com.tw/products/9789864344130

讓我們再次重新認識 JavaScript!


在上一篇的分享當中,我們簡單介紹了 BOM 與 DOM,也了解到 JavaScript 是怎麼透過它們提供的方法來與瀏覽器溝通。

當一個網頁被載入到瀏覽器時,瀏覽器會先分析這個 HTML 檔案,然後會依照這份 HTML 的內容解析成「DOM」 (Document Object Model,文件物件模型)。

DOM 是 W3C 制定的一個規範,它是獨立於平台與語言的標準。 換言之,只要遵守這樣的規範實作,不管是什麼平台或者是什麼語言開發,都可以透過 DOM 提供的 API 來操作 DOM 的內容、結構與樣式。

所以說,DOM 是網頁的根本,懂得 控制籃板球的人就能控制整場比賽 控制 DOM 就可以控制整個網頁,做出良好的互動體驗

那麼在今天的分享中,我們就繼續來介紹 DOM API 查找節點的方法吧。


前言:<script> 標籤放哪裡有差別?

這個題目其實沒有標準答案,認真要講的話之後或許可以用一整篇來說明這個。

常常聽到初學者朋友問說,為什麼放在 <head> ... </head> 裡面的 JavaScript 沒有作用? 這裡我們簡單來講一下問題所在。

針對 <script> 標籤放哪裡,一般你會聽到有兩種版本:

  • 放在 <head> ... </head> 之間
  • 放在 </body> 之前

確實,這兩個地方都可以放置 <script> 標籤。

那麼我們來試試昨天介紹過的,先以 document.querySelector 取得 id="hello" 的節點,然後透過 textContent 來修改內容。

先來試試把 <script> 標籤放在 </body> 之前。
馬上執行看看,看起來似乎很 ok 呢,太好了!
https://ithelp.ithome.com.tw/upload/images/20171215/20065504b1wm0khepl.png

接著,我們試著把 <script> 標籤移到 <head> ... </head> 之間:
https://ithelp.ithome.com.tw/upload/images/20171215/200655049T005bwDr4.png

咦咦咦? 怎麼什麼都沒有?
也沒有錯誤訊息,可惡 JavaScript 你果然跟大家說的一樣,真是太雷了! (淚奔)

https://ithelp.ithome.com.tw/upload/images/20171215/20065504kbhLNNmtdH.jpg

翻桌之前,先聽我解釋。

前面說過,當一個網頁被載入到瀏覽器時,瀏覽器會先分析這個 HTML 檔案,「由上而下」依序來讀取解析:

http://taligarsiel.com/Projects/webkitflow.png
圖片來源: How browsers work

此時,當瀏覽器在 <head> ... </head> 之間遇到 <script> 標籤時,就會暫停解析網頁,並且「立即」執行 <script> 裡的內容,直到 script 執行完畢後再繼續解析網頁。

發現問題所在了嗎?

<head> ... </head> 裡的 <script> 想要嘗試去尋找 <div id="hello"> 這個標籤,但因為還沒解析到網頁本體,所以也無從取得。

不是瀏覽器壞掉,也不是 JavaScript 太雷,而是因為我們不理解瀏覽器執行的原理所造成的誤會

那麼,當我們把 <script> 標籤放在 </body> 結束之前,由於 DOM 已經解析完成,所以 document.querySelector 就可以順利取得 id="hello" 的節點,並且把 'HELLO' 的字串放在網頁裡囉!

這樣說起來, <script> 標籤是不是就不適合放在 <head> ... </head> 之間呢?

也不能這麼說,這點就留待後續介紹過「事件」以及網頁效能優化的時候,再回頭來詳細解說吧!


DOM 節點的選取

https://d1dwq032kyr03c.cloudfront.net/upload/images/20171214/20065504rULoAa69HV.png

上一篇文章說過,document 物件是 「DOM tree」 的根節點,所以當我們要存取 HTML 時,都從 document 物件開始。 而 DOM 的節點類型除了 「HTML 元素節點」 (element nodes) 外,還有「文字節點」 (text nodes)、「註解節點」 (comment nodes) 等。

而常見的 DOM 選取方法有下列這些:

// 根據傳入的值,找到 DOM 中 id 為 'xxx' 的元素。
document.getElementById('xxx');

// 針對給定的 tag 名稱,回傳所有符合條件的集合
document.getElementsByTagName('xxx');

// 針對給定的 class 名稱,回傳所有符合條件的集合
// IE9 後開始支援
document.getElementsByClassName('xxx');

// 針對給定的 Selector 條件,回傳第一個 或 所有符合條件的 NodeList。
// IE8 後開始支援
document.querySelector('xxx');
document.querySelectorAll('xxx');

document.querySelectordocument.querySelectorAll 可以用 「CSS 選擇器」 (CSS Selectors) 來取得「第一個」或「所有」符合條件的元素集合 (NodeList)。


DOM 節點的類型

DOM 節點的類型常見的有下面幾種:

節點類型常數 對應數值 說明
Node.ELEMENT_NODE 1 HTML 元素的 Element 節點
Node.TEXT_NODE 3 實際文字節點,包括了換行與空格
Node.COMMENT_NODE 8 註解節點
Node.DOCUMENT_NODE 9 根節點 (Document)
Node.DOCUMENT_TYPE_NODE 10 文件類型的 DocumentType 節點,例如 HTML5 的 <!DOCTYPE html>
Node.DOCUMENT_FRAGMENT_NODE 11 DocumentFragment 節點

可以透過節點類型「常數」或是「對應數值」來判斷:

document.nodeType === Node.DOCUMENT_NODE;   // true
document.nodeType === 9;   // true

其他不常使用或是已經廢棄的部分可以參考:MDN Node.nodeType 一節。


DOM 節點間的查找遍歷 (Traversing)

由於 DOM 節點有分層的概念,於是節點與節點之間的關係,我們大致上可以分成兩種:

  • 父子關係
    除了 document 之外,每一個節點都會有個上層的節點,我們通常稱之為「父節點」 (Parent node),而相對地,從屬於自己下層的節點,就會稱為「子節點」(Child node)。

  • 兄弟關係:有同一個「父節點」的節點,那麼他們彼此之間就是「兄弟節點」(Siblings node)。

當然沒有爺孫節點、也沒有表兄弟或是堂兄弟節點這種東西,隔層的節點基本上沒有直接關係。

https://ithelp.ithome.com.tw/upload/images/20171215/20065504XmP9IxYFCP.png

Node.childNodes

所有的 DOM 節點物件都有 childNodes 屬性,且此種屬性無法修改。
我們可以透過 Node.hasChildNodes() 來檢查某個 DOM 節點是否有子節點。

var node = document.querySelector('#hello');

// 如果 node 內有子元素
if( node.hasChildNodes() ) {

    // 可以透過 node.childNodes[n] (n 為數字索引) 取得對應的節點
    // 注意,NodeList 物件內容為即時更新的集合
    for (var i = 0; i < node.childNodes[i].length; i++) {
      // ...
    };
}

Node.childNodes 回傳的可能會有這幾種:

  • HTML 元素節點 (element nodes)
  • 文字節點 (text nodes),包含空白
  • 註解節點 (comment nodes)

Node.firstChild

Node.firstChild 可以取得 Node 節點的第一個子節點,如果沒有子節點則回傳 null

要注意的是,子節點包括「空白」節點,所以像下面範例:

<p>
  <span>span 1</span>
  <span>span 2</span>
  <span>span 3</span>
</p>

<script>
  var p = document.querySelector('p');

  // tagName 屬性可以取得 node 的標籤名稱
  console.log(p.firstChild.tagName);      // undefined
</script>

因為拿到的是 <p> 與第一個 <span> 中間的「換行字元」,所以 p.firstChild.tagName 會得到 undefined

改成這樣:

<p><span>span 1</span><span>span 2</span><span>span 3</span></p>

<script>
  var p = document.querySelector('p');

  // tagName 屬性可以取得 node 的標籤名稱
  console.log(p.firstChild.tagName);      // "SPAN"
</script>

把中間的換行與空白移除,就會得到預期中的 "SPAN" 了。

Node.lastChild

Node.lastChild 可以取得 Node 節點的最後一個子節點,如果沒有子節點則回傳 null

Node.firstChild 一樣的是,子節點包括「空白」節點,所以像這樣:

<p>
  <span>span 1</span>
  <span>span 2</span>
  <span>span 3</span>
</p>

<script>
  var p = document.querySelector('p');

  // textContent 屬性可以取得節點內的文字內容
  console.log(p.lastChild.textContent);      // "" (換行字元)
</script>

得到的會是一個換行字元的空字串。

移除節點之間多餘的空白後:

<p><span>span 1</span><span>span 2</span><span>span 3</span></p>

<script>
  var p = document.querySelector('p');

  // textContent 屬性可以取得節點內的文字內容
  console.log(p.lastChild.textContent);      // "span 3"
</script>

就會是正確的 "span 3" 了。

Node.parentNode

那麼相較於「Child 系列」,parentNode 就單純一些。
透過 Node.parentNode 可以用來取得父元素,回傳值可能會是一個元素節點 (Element node)、根節點 (Document node) 或 DocumentFragment 節點。

<p><span>span 1</span><span>span 2</span><span>span 3</span></p>

<script>
  var el = document.querySelector('span');
  console.log( el.parentNode.nodeName );    // "P"
</script>

Node.previousSibling

看完了 DOM「父與子」之後,接著來看看兄弟節點。
透過 Node.previousSibling 可以取得同層之間的「前一個」節點,如果 node 已經是第一個節點,則回傳 null

<p><span>span 1</span><span>span 2</span><span>span 3</span></p>

<script>
  var el = document.querySelector('span');
  console.log( el.previousSibling );    // null

  // document.querySelectorAll 會取得所有符合條件的集合,
  // 而 document.querySelectorAll('span')[2] 指的是「第三個」符合條件的元素。
  var el2 = document.querySelectorAll('span')[2];
  console.log( el2.previousSibling.textContent );    // "span 2"
</script>

Node.nextSibling

Node.previousSibling 類似,透過 Node.previousSibling 可以取得同層之間的「下一個」節點,如果 node 已經是最後一個節點,則回傳 null

// document.querySelector 會取得第一個符合條件的元素
var el = document.querySelector('span');

console.log( el.nextSibling.textContent );    // "span 2"

document.getElementsBy**document.querySelector / document.querySelectorAll 的差異

今天分享了很多關於 DOM 的選取以及查找遍歷的方式,其中,像是 document.getElementById 以及 document.querySelector 因爲取得的一定只會有一個元素/節點,所以不會有 index 與 length 屬性。

document.getElementsBy** (注意,有個 s) 以及 document.querySelectorAll 則分別回傳 「HTMLCollection」 與 「NodeList」。

這兩者其實是類似的規格實作,「HTMLCollection」只收集 HTML element 節點,而「NodeList」除了 HTML element 節點,也包含文字節點、屬性節點等。 雖然不能使用陣列型別的 method,但這兩種都可以用「陣列索引」的方式來存取內容。

另一個需要注意的地方是,HTMLCollection / NodeList 在大部分情況下是即時更新的,但透過 document.querySelector / document.querySelectorAll 取得的 NodeList 是靜態的。

什麼意思呢? 舉個例子:

<div id="outer">
  <div id="inner">inner</div>
</div>

<script>

  // <div id="outer">
  var outerDiv = document.getElementById('outer');

  // 所有的 <div> 標籤
  var allDivs = document.getElementsByTagName('div');

  console.log(allDivs.length);    // 2

  // 清空 <div id="outer"> 下的節點
  outerDiv.innerHTML = '';

  // 因為清空了<div id="outer"> 下的節點,所以只剩下 outer
  console.log(allDivs.length);    // 1
</script>

如果改成 document.querySelector 的寫法:

<div id="outer">
  <div id="inner">inner</div>
</div>

<script>

  // <div id="outer">
  var outerDiv = document.getElementById('outer');

  // 所有的 <div> 標籤
  var allDivs = document.querySelectorAll('div');

  console.log(allDivs.length);    // 2

  // 清空 <div id="outer"> 下的節點
  outerDiv.innerHTML = '';

  // document.querySelector 回傳的是靜態的 NodeList,不受 outerDiv 更新影響
  console.log(allDivs.length);    // 2
</script>


那麼以上就是今天介紹的內容。
在後續的文章會再繼續說明 DOM API 新增/刪除/修改 節點的部分,歡迎持續關注。


上一篇
重新認識 JavaScript: Day 11 前端工程師的主戰場:瀏覽器裡的 JavaScript
下一篇
重新認識 JavaScript: Day 13 DOM Node 的建立、刪除與修改
系列文
重新認識 JavaScript37
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
0
o2398941649
iT邦新手 5 級 ‧ 2017-12-18 20:45:07

最後兩個比較是不是一樣,都getElementById

Kuro Hsu iT邦新手 1 級 ‧ 2017-12-18 21:44:17 檢舉

啊,其實要比較的是這段,但兩次貼到重複的了 (糗

  // 所有的 <div> 標籤
  var allDivs = document.getElementsByTagName('div');
  // 所有的 <div> 標籤
  var allDivs = document.querySelectorAll('div');

謝謝提醒! :)

1
Chelsea
iT邦新手 5 級 ‧ 2017-12-19 14:08:36
// 所有的 <div> 標籤
  var allDivs = document.querySelector('div');

Kuro 大大, allDivs 應該是 querySelectorAll 才對?
寫得超仔細的!覺得受益良多 :D

Kuro Hsu iT邦新手 1 級 ‧ 2017-12-19 14:11:21 檢舉

對,大家眼睛真好 (超糗

/images/emoticon/emoticon41.gif

0
Rplus
iT邦新手 5 級 ‧ 2017-12-21 15:10:23

帳號等級不夠,回文直接清空是怎麼樣… QQ
What the hell…

Kuro Hsu iT邦新手 1 級 ‧ 2017-12-21 15:11:52 檢舉

那你只好再回一次 orz

0
gkfat
iT邦新手 5 級 ‧ 2019-03-12 16:56:51

真的是很實用的JS基礎系列文,感謝大大造福新手啊QQ
原本用得一頭霧水的JS語法,看過這系列有如撥雲見日(?)

0
noway
iT邦研究生 4 級 ‧ 2019-08-27 21:42:28

您好:
Node.firstChild 的範例

我要留言

立即登入留言