寫 JavaScript,最常卡在兩件事:資料怎麼被「傳來傳去」,還有型別到底在玩什麼把戲。你以為只是把值丟進函式,結果原陣列被改爛;你以為用 == 省事,卻莫名其妙通過判斷。
其實關鍵就三塊:原始型別是傳值、物件是參考語意(改內容會外溢)、以及顯式/隱式的型別轉換規則。再把「強型別 vs 弱型別、動態 vs 靜態」放進座標系,你就知道 JS 為何容易靈活,也為何容易翻車。
這篇用白話和可複製範例,帶你拆解常見情境(物件/陣列、解構/展開、API I/O),給出清楚的 SOP(該拷貝就拷貝、該轉型就轉型、比較用 ===),最後附上避雷清單,讓你少踩坑、多專注在功能本身。希望本篇文章可以幫助到需要的您。
目錄
{tocify} $title={目錄}
為什麼你要在意「傳值/傳參考」
你以為改的是副本,結果把共用資料改壞。
你以為函式會回傳新陣列,實際卻把原陣列動到變形。
你以為 == 很方便,結果比較結果全歪。
這些「以為」幾乎都來自值到底怎麼被傳遞、型別怎麼被轉,以及語言的型別系統。把這三塊搞懂,bug 會少一半。
變數、值與記憶體模型
1. 值的兩大族群
原始型別(Primitives):number, string, boolean, null, undefined, bigint, symbol
特色:不可變(immutable)、按值複製。
物件型別(Objects):Object, Array, Function, Date, Map, Set…
特色:可變(mutable)、變數中存的是參考(reference)。
2. 變數到底存了什麼?
對 原始值:變數直接存「值本身」。
對 物件:變數存的是「指向物件的參考」。多個變數可以指向同一個物件實體。
小提醒:有時你會看到「傳址」這個詞。嚴謹來說,JS 呼叫參數是傳值,只不過對物件來說,那個值是個參考,所以看起來有傳參考的效果。
傳值(Primitives)與「參考語意」(Objects)
1. 原始型別:傳值
let a = 10;
let b = a; // 複製值
b = 20;
console.log(a); // 10(不受影響)
結論:改 b 不會影響 a。
2. 物件型別:參考語意
const user1 = { name: "Lin" };
const user2 = user1; // 複製的是參考
user2.name = "Chen";
console.log(user1.name); // "Chen"(被影響)
結論:兩個變數指向同一個物件,改其中之一,另一個會一起變。
補充說明 :
「參考語意(reference semantics)」是教科書用語,意思是:變數裡放的是「參考(reference)」這種值。在 JavaScript 裡——
JS 一律是傳值(pass-by-value)。
但:當值是物件時,這個「值」本身是一個參考。所以你把它傳進函式,裡面如果「改到那個物件的內容」,外面就會看到變化。
這常被稱作 call-by-sharing(傳共享):傳進去的是「對同一份物件的參考副本」,不是把「參數變數本身」交出去讓你改指向。
為什麼不直接說「傳參考/傳址」?因為那兩個詞在 C/C++ 等語言有更精確的語意(指標、位址、引用參數)。JS 沒有指標,也不支援像 C++ void foo(int& x) 那種把變數本身交進去改的做法。JS 傳的是值;只是在物件情境下,那個值剛好是參考,效果看起來像「傳參考」,但本質不同。
看兩個 10 秒就懂的範例:
A. 改「物件內容」→ 外面會變(因為兩邊指向同一物件)
function touch(o) {
o.name = "changed"; // 改的是同一個物件
}
const u = { name: "orig" };
touch(u);
console.log(u.name); // "changed"
B. 重新指定「參數變數」→ 外面不會變(因為參數也是傳值)
function rebind(o) {
o = { name: "new object" }; // 只改到參數 o 這個局部變數
}
const u = { name: "orig" };
rebind(u);
console.log(u.name); // "orig"
如果 JS 真的是「傳參考/傳址」那種語言,第二段把參數指到新物件時,外面的 u 也會一起被改掉;但事實不是。這就是為什麼用「參考語意」來區分的原因:傳的是值;物件的那個值是參考;改內容會外溢,改參數本身不會。
口訣:
原始型別:
值本身 → 改副本不影響原件
物件型別:
值是參考 → 改內容會影響原件;把參數重新指向別的物件不會影響外面
想避免外溢:
在函式邊界拷貝({...obj} 淺拷貝、structuredClone(obj) 深拷貝),或回傳新值而不就地改。
3. 函式參數:一律傳值,但物件有副作用
function rename(u) {
u.name = "NewName"; // 改到同一個物件
}
const u1 = { name: "Old" };
rename(u1);
console.log(u1.name); // "NewName"
如果你要避免副作用,在函式內建立副本:
function renameImmutably(u) {
const copy = { ...u, name: "NewName" };
return copy;
}
const u2 = { name: "Old" };
const u3 = renameImmutably(u2);
console.log(u2.name); // "Old"
console.log(u3.name); // "NewName"
4. 淺拷貝 vs 深拷貝
淺拷貝:只複製第一層({...obj}, Array.prototype.slice, Array.from, Object.assign)。
深拷貝:所有巢狀層級都複製(可用 structuredClone(obj),或 JSON.parse(JSON.stringify(obj)) 有限制,或使用專門函式庫)。
const a = { p: { q: 1 } };
const shallow = { ...a };
shallow.p.q = 2;
console.log(a.p.q); // 2(被影響)
const deep = structuredClone(a);
deep.p.q = 999;
console.log(a.p.q); // 2(不受影響)
常見案例
1. 陣列傳遞與變更
const arr = [1, 2, 3];
function pushX(a) {
a.push("x"); // 直接改原陣列
}
pushX(arr);
console.log(arr); // [1,2,3,"x"]
function addImmutably(a, v) {
return [...a, v]; // 回傳新陣列
}
const arr2 = addImmutably(arr, "y");
console.log(arr, arr2); // 原:含 x,新:含 x 與 y
2. 物件合併
const base = { x: 1, y: { z: 2 } };
const merged = { ...base, x: 99 }; // 淺拷貝
merged.y.z = 1000;
console.log(base.y.z); // 1000(被影響)
3. 解構/展開的陷阱(仍是淺拷貝)
const o = { a: { b: 1 } };
const { a } = o; // a 指向同一個巢狀物件
a.b = 2;
console.log(o.a.b); // 2
4. 函式回傳物件:別把內部可變狀態外洩
function makeCounter() {
let n = 0;
return {
inc() { n += 1; return n; },
value() { return n; }
};
}
const c = makeCounter();
c.inc(); c.inc();
console.log(c.value()); // 2(封裝成功)
型別轉換全圖
1. 兩條主線
顯式轉換(Explicit):你主動轉:String(v), Number(v), Boolean(v), BigInt(v)。
隱式轉換(Implicit / Coercion):運算符幫你轉:+, ==, 字串拼接、if (v) 等。
2. 字串化(ToString)
常見情境:
String(123) // "123"
String(true) // "true"
String(null) // "null"
String(undefined) // "undefined"
[1,2] + "" // "1,2"(陣列會先 toString 再拼接)
({}) + "" // "[object Object]"
想得到可讀性高的字串:JSON.stringify(obj),可加第二、三參數做縮排。
3. 數值化(ToNumber)
Number("42") // 42
Number(" 42 ") // 42(會修剪空白)
Number("") // 0(容易踩雷)
Number("0xFF") // 255(十六進位)
Number(true) // 1
Number(false) // 0
Number(null) // 0
Number(undefined) // NaN
+"123" // 123(單目 + 會數值化)
字串轉數字建議:Number(s) 或 parseInt(s, 10) / parseFloat(s) 視需求。
4. 布林化(ToBoolean)
只有七種 falsy:false, 0, -0, 0n, "", null, undefined, NaN。
其餘皆 truthy(包括 "0", "false", [], {})。
Boolean("") // false
Boolean("0") // true
Boolean([]) // true
Boolean({}) // true
5. 寬鬆等號 == 的轉換規則(易雷)
0 == "" // true("" 先轉成 0)
0 == "0" // true
false == 0 // true
null == undefined // true(特例)
" \n" == 0 // true(字串修剪後為空 -> 0)
建議:一律用嚴格等號 ===,除非你非常熟悉規則而且有明確理由。
6. 物件到原始值(ToPrimitive)
物件被要求轉成原始值時,會走 @@toPrimitive → valueOf → toString 順序(視 hint 而定)。
const obj = {
valueOf() { return 7; }
};
console.log(obj + 1); // 8(先 valueOf -> 7,再 +1)
強型別 vs 弱型別、靜態 vs 動態
1. 定義快覽
強型別(Strongly Typed):
不同型別不會被默默互轉(或轉換受限),誤用型別很快報錯。
弱型別(Weakly Typed):
語言允許較多隱式轉換,型別邊界較鬆。
靜態型別(Static):
變數型別在編譯期就確定。
動態型別(Dynamic):
變數在執行期可持有任何型別。
2. JavaScript 在哪一格?
動態、弱型別:變數可裝任何型別,而且允許大量隱式轉換。
想要「強一點」?→ 使用 TypeScript 在編譯期加型別檢查;或堅持嚴格的程式風格(===、明確轉型、lint 規則)。
實務操作步驟:怎麼寫、怎麼測、怎麼避坑
1. 對「傳值/參考語意」的標準作業
預設 immutability:能不改參考,就回傳新值或新結構。
在函式邊界複製:輸入是物件/陣列,先做淺拷貝或深拷貝(視需求)。
深拷貝採 structuredClone:支援大多數可序列化結構,避免 JSON 技巧的限制。
以不變資料(immutable data)思維設計 API:明確宣告「會不會改參數」。
範例:更新使用者清單但不動原始陣列
function upsertUser(list, user) {
const idx = list.findIndex(u => u.id === user.id);
if (idx === -1) return [...list, user];
return [
...list.slice(0, idx),
{ ...list[idx], ...user },
...list.slice(idx + 1)
];
}
2. 型別轉換的標準作業
輸入就轉型:
API 一進來就把字串→數字、字串→日期,寫在最前面。
比較用 ===:除非你刻意運用 == 規則。
邏輯判斷避免「真值陷阱」:
判斷字串是否有內容用 s.length > 0,不要只用 if (s)。
字串拼接顯式轉:
用模板字串或 String(v),避免不小心把物件拼成 [object Object]。
3. 防呆工具與規格
ESLint 規則:eqeqeq: "error"、no-param-reassign、prefer-const、no-implicit-coercion。
單元測試:針對轉型邊界(空字串、null、undefined、極端值)設測例。
常見錯誤與雷點清單(含修正)
雷點 1. 以為展開運算子是深拷貝
const a = { p: { q: 1 } };
const b = { ...a };
b.p.q = 2;
console.log(a.p.q); // 2(中槍)
修正:需要深拷貝時用 structuredClone(a) 或專門函式。
const b2 = structuredClone(a);
b2.p.q = 999;
雷點 2. 在函式裡直接改入參(物件/陣列)
function addTag(u, t) {
u.tags.push(t); // 破壞呼叫者資料
}
修正:回傳新值。
function addTag(u, t) {
return { ...u, tags: [...u.tags, t] };
}
雷點 3. 用 == 造成幽靈相等
if (input == 0) { /* ... */ } // "", " ", false 也可能成立
修正:改用 ===,或先顯式轉型。
if (Number(input) === 0) { /* ... */ }
雷點 4. 用空字串判斷當布林
if (s) { /* 有內容? */ } // "0" 會被當作 true
修正:
if (typeof s === "string" && s.length > 0) { /* ... */ }
雷點 5. Number("") === 0 的陷阱
Number("") // 0(容易誤會)
修正:明確處理「空字串代表沒有值」。
const n = s === "" ? null : Number(s);
雷點 6. 以為 Array.prototype.sort() 不會改原陣列
const xs = [3,1,2];
const ys = xs.sort(); // 直接改 xs
修正:
const ys = [...xs].sort((a,b) => a - b);
雷點 7. 把可變物件當 Map 的 key
const m = new Map();
const k = { id: 1 };
m.set(k, "a");
k.id = 999; // key 物件內容改了不影響相等性,但語意混亂
修正:key 用不可變的原始值或凍結物件:
const k2 = Object.freeze({ id: 1 });
雷點 8. JSON 深拷貝的限制
const copy = JSON.parse(JSON.stringify(obj));
// 會丟掉:函式、Symbol、undefined,Date 變字串,Map/Set 敗陣
修正:structuredClone 或專用工具。
雷點 9. 忘了 Date、正規表示式等也是物件
const d1 = new Date();
const d2 = d1; // 指到同一個實例
d2.setFullYear(2099);
console.log(d1.getFullYear()); // 2099
修正:建立新實例:
const d2b = new Date(d1.getTime());
問題集
Q1:JS 到底是傳值還是傳參考?
A:**傳值。**但對物件來說,傳入的是「參考這個物件的值」,所以在函式裡改到物件屬性,外面會看到變化。
Q2:我要怎麼確認某值的型別?
A:
原始型別用 typeof:typeof 123 === "number"。
陣列用 Array.isArray(v)。
null 用 v === null。
物件細項可用 Object.prototype.toString.call(v):[object Date]、[object RegExp]。
NaN 用 Number.isNaN(v)(不是 isNaN)。
Q3:何時需要深拷貝?
A:當你要保證巢狀物件不被外界改壞,或在狀態管理(例如 React)中維持不可變資料。
Q4:為什麼大家叫我用 ===?
A:因為 == 會做隱式轉型,規則很多、容易誤判;=== 更直覺。
總結與延伸練習
核心要點回顧
原始值:傳值、不可變。
物件:參考語意,修改會外溢;需要時請複製(淺/深)。
型別轉換:盡量顯式轉,比較用 ===。
JS:動態、弱型別。想要更穩:TS 或嚴格風格。
延伸練習(含參考解)
1. 寫一個不變版本的 removeAt(list, idx)
要求:不改原陣列,回傳新陣列。
function removeAt(list, idx) {
return [...list.slice(0, idx), ...list.slice(idx + 1)];
}
2. 實作 setIn(obj, path, value)(淺版)
要求:setIn({a:{b:1}}, ["a","b"], 2) → 回傳新物件,不改原始。
function setIn(obj, path, value) {
if (path.length === 0) return value;
const [head, ...tail] = path;
return {
...obj,
[head]: setIn(obj?.[head] ?? {}, tail, value)
};
}
3. 寫一個安全的 toNumber(s):空字串回 null,其他才嘗試轉數字。
function toNumber(s) {
if (s === "") return null;
const n = Number(s);
return Number.isNaN(n) ? null : n;
}
附錄 A:快速對照表
型別判斷:
陣列:Array.isArray(v)
函式:typeof v === "function"
null:v === null
日期:Object.prototype.toString.call(v) === "[object Date]"
NaN:Number.isNaN(v)
常用拷貝:
陣列(淺):[...arr], arr.slice()
物件(淺):{ ...obj }, Object.assign({}, obj)
深拷貝:structuredClone(obj)(優先)
轉型:
字串化:String(v), JSON.stringify(v, null, 2)
數值化:Number(s), parseInt(s, 10), parseFloat(s)
布林化:Boolean(v)(注意 falsy 清單)
比較:
一律 === / !==
只在你確定規則時用 ==(幾乎不建議)
附錄 B:常見錯誤訊號與排查腳本
症狀:UI 上的清單無故多了元素
可能原因:函式內直接 push 呼叫者陣列
排查:搜尋 push, splice, sort 是否直接用在入參
修正:改成回傳新陣列(展開運算子/切片重組)
症狀:比較條件怪怪的、偶爾才錯
可能原因:用了 == 或依賴 falsy 規則
排查:grep ==[^=]、檢查 if (x) 是否能被空字串等擊倒
修正:改 ===、顯式轉型或做長度檢查
症狀:序列化資料丟欄位
可能原因:用 JSON 技巧做深拷貝
修正:structuredClone
