前面幾天改畫面文字我一直用 textContent。今天要學它的兄弟 innerHTML,而這兩個的差別,對我這個 QA 來說比想像中重要。
const str = "<b>粗體測試</b> 和 <span style='color:red'>紅色測試</span>";
test1.textContent = str;
test2.innerHTML = str;
同一段字串,兩個結果:
textContent → <b>粗體測試</b> 和 <span style='color:red'>紅色測試</span>
innerHTML → 粗體測試 和 紅色測試 (真的變粗體、真的變紅色)

| 屬性 | 把內容當成 |
|---|---|
textContent |
純文字,原封不動顯示 |
innerHTML |
HTML 原始碼,會被解析並執行 |
有了 innerHTML,戰鬥訊息就不只是一行乾巴巴的字:
logText.innerHTML =
`<b>${hero.name}</b> 造成 <span style="color:red">${damage}</span> 點傷害!`;
名字變粗體、傷害數字變紅色。同樣的資料,看起來完全不同——這是遊戲第一次有了「視覺效果」而不只是文字。

假設之後要做「玩家自訂勇者名字」的功能(這本來就在我的計畫裡),玩家輸入什麼就顯示什麼。如果我用 innerHTML,而玩家把名字輸入成一段 HTML 呢?
我決定自己攻擊自己一次:
const evilName = `<img src=x onerror="alert('我控制了你的網頁')">`;
test1.textContent = evilName; // 安全
test2.innerHTML = evilName; // 危險
innerHTML 那行執行之後,畫面真的跳出了對話框:「我控制了你的網頁」。

原理是:給圖片一個不存在的路徑(x),圖片載入必定失敗,失敗就觸發 onerror,而 onerror 裡寫的程式碼就被執行了。不需要 <script> 標籤也能執行程式,這是我最意外的地方。
這種攻擊叫 XSS(Cross-Site Scripting,跨站腳本攻擊)。今天跳出來的只是 alert,實際上可以做的事嚴重得多:偷走其他玩家的登入憑證、竄改畫面騙人輸入密碼、把惡意程式碼存進資料庫讓每個訪客都中招。
對照那兩行的結果:textContent 版原樣顯示了整串程式碼,看得見;而 innerHTML 版呢?
畫面上什麼都沒有。
那個 <img> 確實被插進去了,但它是一張破圖,沒有寬高、沒有替代文字,在畫面上完全隱形。也就是說:程式碼執行了,但頁面看起來一切正常。
今天是 alert 所以我看到了。如果攻擊者換成「偷走 cookie 傳到自己的伺服器」,畫面會完全正常,玩家永遠不會知道自己被攻擊過。
這又是「安靜失敗」家族的一員,只是這次安靜的那一方是攻擊者。從測試的角度看,這種問題比報錯的 bug 難抓太多了——因為沒有任何現象可以觀察。
看到這個結果,我馬上想起工作上聽過的另一種攻擊:有些密碼輸入框可以透過特定的輸入,繞過判斷直接登入。
追下去發現,這兩件事是同一個病根。
那種登入繞過叫 SQL injection。後端如果把使用者輸入直接拼進資料庫的查詢語句裡:
SELECT * FROM users WHERE name = '輸入的帳號' AND password = '輸入的密碼'
攻擊者只要在欄位裡輸入 ' OR '1'='1,拼起來就變成「密碼正確或者 1=1」——1=1 永遠成立,判斷就直接通過了。攻擊者沒有破解密碼,是讓系統忘了要檢查密碼。
對照一下:
| XSS(今天遇到的) | SQL injection | |
|---|---|---|
| 輸入被送進 | HTML | 資料庫查詢語句 |
| 系統誤把輸入當成 | 標籤和程式碼 | 查詢指令 |
| 結果 | 執行攻擊者的 JavaScript | 繞過密碼檢查 |
共同的病根是同一句話:把使用者輸入的東西,當成程式碼執行了。
而正確解法的思路也一樣——明確區分「這是資料」和「這是指令」:
textContent(它永遠只當資料,從不執行任何東西)寫了十三天 JavaScript,今天是第一次覺得「我的本業知識直接接上了」。以前測登入框我知道要試奇怪的輸入,但不太清楚背後為什麼會出事;今天親手做了一次,原理突然變得很具體。
// 內容完全由我自己寫死 → innerHTML 可以用
logText.innerHTML = `<b>${hero.name}</b> 造成 <span style="color:red">${damage}</span> 點傷害!`;
// 內容有任何一部分來自使用者 → 一律 textContent
playerNameText.textContent = playerInput;
這也解釋了為什麼前面十二天我一直只用 textContent——它天生安全,因為它從不執行任何東西。innerHTML 是有用的工具,但用它之前必須先確認一件事:這段字串裡,有沒有任何一個字是別人給的?
順帶一提,那段 XSS 測試碼我在推上 GitHub 之前已經註解掉了。它可以留著當紀錄,但不該留在會執行的狀態。
學會 innerHTML 之後我馬上想到一個應用:讓擊敗小怪之後的「鞭屍!!」按鈕變成紅底白字,大一點,比較有警告感。
問題是,我當時手上只有 innerHTML 這一把工具。所以我這樣寫:
attackBtn.innerHTML =
`<button style="background-color:#ff0000; color:#ffffff; padding:10px 20px; font-size:16px;">鞭屍!!</button>`;
視覺效果達成了,畫面上出現一顆紅色的按鈕。

但仔細看那張截圖,紅按鈕外面還有一圈細細的框。因為 attackBtn 本身就是一個 <button>,我剛剛做的事是把一個按鈕塞進另一個按鈕裡面:
<button id="attackBtn">
<button style="...">鞭屍!!</button>
</button>
HTML 規格不允許按鈕嵌套按鈕。 而我實際測試按下去,訊息確實變成了「勇者請不要對小怪屍體動手!」——功能正常。
但它能動的原因是:點擊事件會往外層冒泡,所以外層那個真正掛了監聽器的按鈕還是被觸發了。也就是說,它能動是運氣,不是設計。瀏覽器對非法的 HTML 結構怎麼處理沒有保證,換一個瀏覽器、或改一點結構,行為就可能不一樣。
這種「測起來會過,但結構是錯的」情況,是我工作上最不喜歡的一類問題:因為測試通過了,所以沒有人會回頭看它,直到某天換了環境才爆掉。
而我後來查到,想改按鈕的顏色和大小根本不需要生一個新按鈕,直接改現有元素的樣式就好——那是明天要學的東西。所以這段程式碼我先留著,明天第一件事就是回來修掉它。
第一次執行的時候,實驗 3 其實完全沒跑——alert 沒有跳出來,而且 test1 顯示的還是實驗 1 的字串。
原因是我在實驗 2 那行用了 logText,但那個檔案裡我還沒宣告它,於是噴了 ReferenceError: logText is not defined。Day 9 學過:執行期錯誤會從出錯那行開始,把後面的程式全部砍掉——所以實驗 3 連被讀到的機會都沒有。
我修好之後才看到那個 alert。也就是說,一個變數沒宣告的低級錯誤,意外地暫時擋住了一次 XSS。這當然不是什麼防禦機制,但它讓我更確定一件事:程式的執行順序真的很重要,而且錯誤訊息永遠值得認真讀完。
今天搞懂的事:
textContent 把內容當純文字,innerHTML 會解析並執行 HTMLinnerHTML 能讓畫面變好看(粗體、顏色),但它會執行你給它的東西<img src=x onerror="..."> 不需要 <script> 也能執行程式innerHTML 可用;有任何使用者輸入 → 一律 textContent
innerHTML 生一顆按鈕塞進按鈕裡:測起來會過(事件冒泡救了它),但結構違法——能動不代表對明天要讓畫面更有回饋:用 JS 改樣式,血量低的時候讓畫面變紅——順便回來修掉那顆嵌套的按鈕。