⚡ JavaScript 核心概念
📌 什麼是 JavaScript?
比喻:JavaScript 是房子的水電和自動化系統 💡
HTML 是骨架,CSS 是裝潢,而 JavaScript 就是讓房子「活起來」的系統。 它控制電燈開關(事件處理)、自動門(互動效果)、中央空調(狀態管理)。 沒有 JavaScript,網頁就像一棟沒有水電的樣品屋——好看但不能住。
📌 變數宣告:var / let / const
// var:函式作用域,會被提升(hoisting),現代開發中盡量避免使用
var oldWay = "我是 var"; // 用 var 宣告的變數,作用範圍是整個函式
// let:區塊作用域,可重新賦值,適合會變動的值
let counter = 0; // 用 let 宣告計數器,之後可以改變它的值
counter = 1; // 合法:let 允許重新賦值
// const:區塊作用域,不可重新賦值,適合常數和物件參考
const PI = 3.14159; // 用 const 宣告常數,之後不能改變
// PI = 3; // 錯誤!const 不允許重新賦值
// 但注意!const 的物件內容可以修改
const user = { name: "小明" }; // const 鎖住的是「參考」,不是「內容」
user.name = "小華"; // 合法:修改物件的屬性,不是修改參考
// user = {}; // 錯誤!不能把 user 指向新的物件
// var 的提升陷阱
console.log(hoisted); // 不會報錯,但值是 undefined(被提升了)
var hoisted = "hello"; // var 宣告會被移到作用域頂端,但賦值不會
// let 不會被提升(嚴格來說有,但在暫時性死區 TDZ)
// console.log(notHoisted); // 報錯!ReferenceError
let notHoisted = "world"; // let 在宣告前不能使用
📌 資料型別與型別轉換陷阱
// JavaScript 有 7 種原始型別
let str = "文字"; // string:字串
let num = 42; // number:數字(整數和浮點數都是 number)
let bool = true; // boolean:布林值(true 或 false)
let nothing = null; // null:刻意設定的「空值」
let notDefined = undefined; // undefined:尚未賦值的變數
let bigNum = 9007199254740991n; // bigint:超大整數
let sym = Symbol("id"); // symbol:唯一識別符
// 型別轉換陷阱(JavaScript 最讓人崩潰的部分)
console.log("5" + 3); // "53":字串 + 數字 → 字串串接(不是加法!)
console.log("5" - 3); // 2:字串 - 數字 → 自動轉成數字做減法
console.log("5" * "3"); // 15:兩個字串相乘 → 自動轉成數字
console.log(true + 1); // 2:true 被轉成 1,加上 1 等於 2
console.log(false + "hello"); // "falsehello":false 轉成字串再串接
console.log(null + 1); // 1:null 被轉成 0,加上 1 等於 1
console.log(undefined + 1); // NaN:undefined 無法轉成有效數字
// == vs ===(建議永遠用 ===)
console.log(0 == false); // true:== 會做型別轉換後比較
console.log(0 === false); // false:=== 不做型別轉換,型別不同直接 false
console.log("" == false); // true:空字串和 false 在 == 下相等
console.log("" === false); // false:嚴格比較,型別不同
console.log(null == undefined); // true:這是 == 的特例
console.log(null === undefined); // false:嚴格比較,型別不同
📌 函式(Functions)
// 函式宣告(Function Declaration):會被提升,可以在宣告前呼叫
function greet(name) { // 宣告一個名為 greet 的函式,接受 name 參數
return `你好,${name}!`; // 用模板字串回傳問候語
}
// 函式表達式(Function Expression):不會被提升
const add = function(a, b) { // 將匿名函式賦值給 add 變數
return a + b; // 回傳兩數相加的結果
};
// 箭頭函式(Arrow Function):更簡潔的語法
const multiply = (a, b) => { // 箭頭函式用 => 取代 function 關鍵字
return a * b; // 回傳兩數相乘的結果
};
// 箭頭函式的簡寫:只有一行時可以省略大括號和 return
const double = x => x * 2; // 單一參數可省略括號,單一表達式可省略 return
// 預設參數
const createUser = (name, role = "member") => { // role 預設為 "member"
return { name, role }; // 簡寫語法:屬性名稱和變數名稱相同時可省略
};
console.log(createUser("小明")); // { name: "小明", role: "member" }(使用預設值)
console.log(createUser("小華", "admin")); // { name: "小華", role: "admin" }(覆蓋預設值)
// 解構參數(在處理 API 回應時很常用)
const displayUser = ({ name, age, email = "未提供" }) => { // 直接解構物件參數
console.log(`姓名:${name},年齡:${age},信箱:${email}`); // 使用解構出的變數
};
displayUser({ name: "小明", age: 25 }); // 信箱會用預設值「未提供」
📌 閉包(Closure)與作用域
// 閉包:內層函式可以存取外層函式的變數,即使外層函式已經執行完畢
function createCounter() { // 外層函式:建立計數器
let count = 0; // 這個變數被「封閉」在閉包裡
return {
increment: () => ++count, // 內層函式:可以存取外層的 count
decrement: () => --count, // 內層函式:也可以存取同一個 count
getCount: () => count // 內層函式:讀取 count 的值
};
}
const counter = createCounter(); // 建立一個計數器實例
console.log(counter.increment()); // 1:count 從 0 變成 1
console.log(counter.increment()); // 2:count 從 1 變成 2
console.log(counter.getCount()); // 2:讀取目前的 count 值
// console.log(count); // 錯誤!外部無法直接存取 count,實現了封裝
// 閉包的經典陷阱:迴圈中的 var
for (var i = 0; i < 3; i++) { // var 是函式作用域,迴圈結束後 i = 3
setTimeout(() => console.log(i), 100); // 全部印出 3,因為共用同一個 i
}
// 解法:用 let 取代 var
for (let j = 0; j < 3; j++) { // let 是區塊作用域,每次迴圈有自己的 j
setTimeout(() => console.log(j), 100); // 正確印出 0, 1, 2
}
📌 Promise 與 async/await
// Promise:代表一個非同步操作的最終結果
const fetchData = (url) => { // 建立一個回傳 Promise 的函式
return new Promise((resolve, reject) => { // Promise 接受 resolve 和 reject
setTimeout(() => { // 模擬網路請求的延遲
if (url) {
resolve({ data: "取得成功" }); // 成功時呼叫 resolve
} else {
reject(new Error("URL 不能為空")); // 失敗時呼叫 reject
}
}, 1000); // 延遲 1 秒
});
};
// 用 .then() / .catch() 處理 Promise
fetchData("/api/users") // 呼叫函式,取得 Promise
.then(result => console.log(result)) // 成功時執行:印出結果
.catch(error => console.error(error)) // 失敗時執行:印出錯誤
.finally(() => console.log("請求結束")); // 不論成敗都會執行
// async/await:更直覺的非同步寫法(語法糖)
async function loadUserData() { // async 標記這是一個非同步函式
try { // 用 try/catch 取代 .then()/.catch()
const response = await fetch("/api/users"); // await 等待 Promise 完成
if (!response.ok) { // 檢查 HTTP 狀態碼
throw new Error(`HTTP 錯誤:${response.status}`); // 手動拋出錯誤
}
const data = await response.json(); // 等待 JSON 解析完成
console.log(data); // 印出解析後的資料
return data; // 回傳資料(自動包裝成 Promise)
} catch (error) { // 捕捉任何錯誤(網路錯誤或程式錯誤)
console.error("載入失敗:", error.message); // 印出錯誤訊息
}
}
// 平行執行多個 Promise
async function loadDashboard() { // 同時載入多個 API 資料
try {
const [users, products, orders] = await Promise.all([ // 平行送出三個請求
fetch("/api/users").then(r => r.json()), // 第一個請求:取得使用者
fetch("/api/products").then(r => r.json()), // 第二個請求:取得產品
fetch("/api/orders").then(r => r.json()) // 第三個請求:取得訂單
]); // Promise.all 等待全部完成
console.log("全部載入完成", { users, products, orders }); // 印出所有結果
} catch (error) {
console.error("其中一個請求失敗:", error); // 任一失敗就會進入 catch
}
}
📌 事件處理
// 取得 DOM 元素
const button = document.querySelector("#myButton"); // 用 CSS 選擇器取得按鈕
// 基本事件監聽
button.addEventListener("click", function(event) { // 監聽點擊事件
console.log("按鈕被點擊了!"); // 印出訊息
console.log("事件目標:", event.target); // event.target 是觸發事件的元素
});
// 事件冒泡(Event Bubbling):事件從子元素向上傳遞到父元素
document.querySelector(".parent").addEventListener("click", () => { // 父元素監聽
console.log("父元素被點擊"); // 點擊子元素時,這裡也會觸發
});
document.querySelector(".child").addEventListener("click", (e) => { // 子元素監聽
console.log("子元素被點擊"); // 先觸發子元素的事件
e.stopPropagation(); // 阻止事件繼續向上冒泡到父元素
});
// 事件委託(Event Delegation):在父元素上統一處理子元素的事件
document.querySelector("#todoList").addEventListener("click", (e) => { // 在列表上監聽
if (e.target.classList.contains("delete-btn")) { // 判斷點擊的是否為刪除按鈕
const item = e.target.closest("li"); // 找到最近的 li 祖先元素
item.remove(); // 移除該列表項目
}
});
// 常用事件類型
const input = document.querySelector("#searchInput"); // 取得搜尋輸入框
input.addEventListener("input", (e) => { // input 事件:每次輸入都會觸發
console.log("目前輸入:", e.target.value); // 取得輸入框的值
});
input.addEventListener("keydown", (e) => { // keydown 事件:按下鍵盤時觸發
if (e.key === "Enter") { // 判斷是否按下 Enter 鍵
console.log("送出搜尋:", e.target.value); // 執行搜尋
}
});
📌 DOM 操作
// 選取元素
const title = document.querySelector("h1"); // 選取第一個 h1 元素
const items = document.querySelectorAll(".item"); // 選取所有 class="item" 的元素
const main = document.getElementById("main"); // 用 ID 選取元素
// 修改內容
title.textContent = "新標題"; // 修改純文字內容(安全,不解析 HTML)
title.innerHTML = "<em>斜體標題</em>"; // 修改 HTML 內容(注意 XSS 風險)
// 修改樣式
title.style.color = "blue"; // 直接修改行內樣式
title.style.fontSize = "2rem"; // CSS 屬性名用駝峰命名
// 操作 CSS class
title.classList.add("active"); // 新增 class
title.classList.remove("hidden"); // 移除 class
title.classList.toggle("dark-mode"); // 切換 class(有就移除,沒有就新增)
title.classList.contains("active"); // 檢查是否有某個 class,回傳 boolean
// 建立和插入元素
const newCard = document.createElement("div"); // 建立一個新的 div 元素
newCard.className = "card"; // 設定 class 名稱
newCard.textContent = "新卡片"; // 設定文字內容
const container = document.querySelector(".container"); // 取得容器元素
container.appendChild(newCard); // 將新元素加到容器的最後面
container.prepend(newCard); // 將新元素加到容器的最前面
container.insertBefore(newCard, container.firstChild); // 插入到第一個子元素前面
// 移除元素
const oldItem = document.querySelector(".old-item"); // 選取要刪除的元素
oldItem.remove(); // 直接移除元素
// 操作屬性
const link = document.querySelector("a"); // 取得超連結元素
link.setAttribute("href", "https://example.com"); // 設定 href 屬性
link.getAttribute("href"); // 取得 href 屬性的值
link.removeAttribute("target"); // 移除 target 屬性
// data 屬性(自訂資料)
const card = document.querySelector(".card"); // 取得卡片元素
card.dataset.id = "123"; // 設定 data-id="123"
console.log(card.dataset.id); // 讀取 data-id 的值:"123"
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:用 == 比較導致意外結果
// 錯誤:== 會做型別轉換,結果不如預期
if (userInput == 0) { // 如果 userInput 是空字串 "",這也會是 true!
console.log("輸入為零"); // 空字串被轉成 0,意外觸發這段
}
// 正確:用 === 嚴格比較
if (userInput === 0) { // === 不做型別轉換,只有真正的數字 0 才會是 true
console.log("輸入為零"); // 現在只有數字 0 會觸發
}
❌ 錯誤 2:async/await 忘記用 try/catch
// 錯誤:沒有錯誤處理,網路失敗時整個程式會崩潰
async function loadData() { // 非同步函式
const response = await fetch("/api/data"); // 如果網路失敗,會拋出未處理的錯誤
const data = await response.json(); // 如果回應不是 JSON,也會拋出錯誤
return data; // 這行可能永遠不會執行到
}
// 正確:用 try/catch 包裹非同步操作
async function loadData() { // 非同步函式
try { // 用 try 包裹可能出錯的程式碼
const response = await fetch("/api/data"); // 等待回應
if (!response.ok) throw new Error("HTTP 錯誤"); // 檢查 HTTP 狀態
const data = await response.json(); // 解析 JSON
return data; // 回傳資料
} catch (error) { // 捕捉所有錯誤
console.error("載入失敗:", error); // 印出錯誤訊息
return null; // 回傳預設值,避免後續程式出錯
}
}
❌ 錯誤 3:在迴圈中用 var 導致閉包問題
// 錯誤:var 沒有區塊作用域,所有計時器共用同一個 i
for (var i = 0; i < 5; i++) { // var 宣告的 i 在迴圈外也存在
setTimeout(() => { // 箭頭函式捕捉的是「同一個」i
console.log(i); // 全部印出 5,因為迴圈結束後 i 是 5
}, i * 100); // 即使延遲不同,印出的都是 5
}
// 正確:用 let 取代 var
for (let i = 0; i < 5; i++) { // let 在每次迴圈建立新的區塊作用域
setTimeout(() => { // 每個箭頭函式捕捉到自己的 i
console.log(i); // 正確印出 0, 1, 2, 3, 4
}, i * 100); // 每隔 100ms 依序印出
}