十個你可能從未見過的 JavaScript 怪現象:第4條把我整破防(程序員別裝了,都中招)
上周我踩了一個特別隱蔽的坑,再次被提醒:不管你寫 JavaScript 多久,這門語言的“性格缺陷”,總有新的姿勢讓你開眼。
下面這些不是“Bug”,而是規范里白紙黑字的行為。理解它們,不是炫技,而是更穩地寫出可預期的代碼。本文挑了10個最值得知道的點,有例子、有緣由、有對策。
1. NaN 悖論:唯一一個“永遠不等于自己”的值
NaN(Not a Number)很諷刺:typeof NaN === 'number'。它也是 JS 里唯一一個不等于自身的值。
console.log(NaN === NaN); // false
console.log(NaN == NaN); // false為什么?NaN 表示“無法表示/未定義”的數值結果(比如 0/0、Math.sqrt(-1))。既然兩個“未定義的結果”無法保證一致,那么它們就不相等。
因此,用相等比較來檢測 NaN 完全靠不住。正確姿勢是 Number.isNaN(),避免了全局 isNaN() 的類型強轉陷阱。
console.log(Number.isNaN(NaN)); // true(推薦)
// 全局 isNaN 會先做 Number() 強轉
console.log(isNaN("hello")); // true,因為 Number("hello") 是 NaN
console.log(Number.isNaN("hello")); // false,因為它不是“值 NaN”要點串聯:因此→不要迷信比較運算;然而→全局 isNaN 會誤傷;盡管如此→Number.isNaN 足夠可靠。
2. Array.length:可寫的“幻覺”
JS 里數組的 length 屬性不只是“描述”,還是“指令”。你可以手動改它,后果要么制造空洞,要么直接裁掉元素。
let arr = ['a', 'b', 'c'];
// 增大長度:變稀疏數組
arr.length = 5;
console.log(arr); // ['a', 'b', 'c', empty × 2]
console.log(arr[3]); // undefined
// 縮短長度:破壞性截斷
arr.length = 1;
console.log(arr); // ['a'] —— 'b' 和 'c' 永久丟失“稀疏數組”的“空槽”和顯式的 undefined 不同;大多數迭代方法(map/filter/forEach)會跳過空槽:
const sparse = new Array(3); // [empty × 3]
const explicit = [undefined, undefined, undefined];
console.log(sparse.map(() => 1)); // [empty × 3]
console.log(explicit.map(() => 1)); // [1, 1, 1]實踐建議:因此→避免隨意改 length;然而→slice/splice 更可控;同時→遍歷前先判斷是否真的需要保留“空槽”語義。
3. “像數組但不是數組”的家族
很多對象長得像數組卻沒有數組方法,比如函數的 arguments、DOM 的 NodeList。
function example() {
console.log(Array.isArray(arguments)); // false
// arguments.map(...) // TypeError: arguments.map is not a function
}現代做法:用擴展語法或 Array.from() 把“類數組/可迭代”轉真數組。
function betterExample() {
const args = [...arguments]; // 簡潔首選
// const args = Array.from(arguments);
return args.map(x => x * 2);
}連接詞到位:因此→遇到類數組先轉型;不過→可迭代對象直接展開更優雅;與此同時→確保運行環境支持擴展語法。
4. 浮點數現實:IEEE 754 的代價
JS 使用 IEEE 754 雙精度,不是 JS 的錯,但你會見到熟悉的“驚嚇”:
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(
Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2
); // true(已超出“安全整數”范圍)現代解法
- 整數超范圍:用
BigInt。
const huge = BigInt(Number.MAX_SAFE_INTEGER);
console.log(huge + 2n === huge + 1n); // false- 小數運算:用“分(整數化)”方案,或
decimal.js等庫;展示用toFixed()/Intl.NumberFormat。
const price = 19.99;
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
console.log(fmt.format(price)); // "$19.99"因果鏈:因為→表示法有限;所以→算術有誤差;因此→要么整數化,要么引入十進制庫。
5. 對象屬性與“淺凍”真相
屬性背后有“描述符”(writable、enumerable、configurable)。Object.defineProperty 可控更細。Object.freeze() 常用但只有淺層:頂層屬性被凍結,嵌套對象仍可改。
const frozen = Object.freeze({
metadata: { version: 1 }
});
// 無法改引用
// frozen.metadata = {} // 嚴格模式下 TypeError
// 但能改內部屬性
frozen.metadata.version = 2;
console.log(frozen.metadata.version); // 2想要“真不可變”:寫個深凍結(遞歸 Object.freeze),或使用不可變數據工具(例如 Immer/Immutable.js)。
6. 原型污染:看不見的安全洞
當攻擊者改寫了 Object.prototype,問題會像傳染一樣擴散到全局對象,后果難以預料。 常見入口是不安全的遞歸合并(merge/clone)把惡意 __proto__ 丟進來了。
const maliciousPayload = JSON.parse('{"__proto__": {"isPolluted": true}}');
let target = {};
// 模擬某些老舊庫的合并副作用
if (maliciousPayload.__proto__) {
Object.assign(Object.prototype, maliciousPayload.__proto__);
}
const innocentObject = {};
console.log(innocentObject.isPolluted); // true!所有對象“中招”防御要點:因此→用 Object.create(null) 構造“無原型對象”;同時→對外部 JSON 做白名單校驗;并且→升級依賴(如 Lodash)到修復版本。
7. 類型強制:ToPrimitive 的“幕后操作”
自動類型轉換很能打,但容易反直覺,尤其 == 與 +。
當 + 遇到對象,會走 ToPrimitive 抽象操作:
[ ].toString()→""{ }.toString()→"[object Object]"
于是:
console.log([] + {}); // "[object Object]" == "" + "[object Object]"再看一個“坑樣例”:
console.log({} + []); // 瀏覽器里可能是 0 或 "[object Object]"原因:行首的 {} 有時會被當成空代碼塊,接著算 +[] → 0。對策:因此→堅持用 ===/!==;另外→顯式做 String()/Number() 轉換;盡管如此→模板字符串也能提升可讀性。
8. 聲明提升 vs. 暫時性死區(TDZ)
“提升”分三種:
- 函數聲明:名字與函數體都提升,可先用后聲明。
sayHello(); // "Hello!"
function sayHello(){ console.log("Hello!"); }- var:只提升“聲明”,并以
undefined初始化;賦值不提升。
console.log(myVar); // undefined
var myVar = "Hello";- let / const:也會被“登記”,但不初始化。從作用域開始到聲明處的這段是 TDZ,訪問會拋
ReferenceError。
// 作用域開始 & myLet 的 TDZ
console.log(myLet); // ReferenceError
let myLet = "Hello";
// TDZ 結束寫法建議:因此→統一把聲明放到使用之前;同時→塊作用域內盡量“聲明即賦值”;并且→避免在 TDZ 中做任何讀取。
9. this 的流沙:調用方式決定歸屬
this 的值取決于函數如何被調用。回調里尤其愛“跑丟”。
const myObj = {
name: "My Object",
regularMethod: function () {
setTimeout(function () {
// 這里的 this 指向 window/global,不是 myObj
console.log(this.name); // undefined
}, 100);
},
arrowMethod: function () {
setTimeout(() => {
// 箭頭函數從外層“捕獲”this
console.log(this.name); // "My Object"
}, 100);
},
};在 class 里,傳方法當回調時更明顯。類字段箭頭函數能自動綁定 this。
class Logger {
constructor() {
this.prefix = "LOG:";
}
// 自動綁定 this
logMessage = (message) => {
console.log(this.prefix, message);
};
}
const logger = new Logger();
const button = document.getElementById('my-button');
button.addEventListener('click', logger.logMessage); // this 正確指向 logger思路閉環:因此→箭頭函數適合回調;不過→對象方法若需復用,可 bind;與此同時→TS 中可用類字段語法統一風格。
10. Symbol:被忽略的“超能力”
Symbol 是唯一且不可變的原始值,常用作對象屬性鍵,既能防沖突,也能做“半隱藏”。
1) 隱式私有屬性:Symbol 作為鍵不會被 for...in/JSON.stringify 枚舉到。
const _privateMethod = Symbol('privateMethod');
class MyClass {
[_privateMethod]() {
return 'secret';
}
}2) 定制語言行為:一批“知名 Symbol”讓你接入 JS 內部協議。
const customIterable = {
*[Symbol.iterator]() { yield 1; yield 2; }
};
console.log([...customIterable]); // [1, 2]
const objWithCustomConversion = {
[Symbol.toPrimitive](hint) {
return hint === 'number' ? 42 : 'hello';
}
};
console.log(objWithCustomConversion + 10); // 52
console.log(`${objWithCustomConversion}`); // "hello"邏輯銜接:因此→Symbol能避免命名沖突;然而→調試要注意可見性;同時→知名 Symbol 讓對象“像內建類型一樣可用”。
速記卡
- 用
===/!==為默認:避開寬松相等的“黑魔法”。 - 擁抱不可變思維:默認
const;用Object.freeze()+ 展開語法(const clone = { ...orig })減少副作用。 - 理解原理,而不是死記:抽空翻翻規范里的
ToPrimitive、TDZ 等概念,理解一遍,受用更久。 - 上現代工具:ESLint 把大坑自動攔住;大項目考慮 TypeScript,類型安全從入口把關。
- 測邊界:對
null/undefined/NaN/稀疏數組等寫單測,把“怪現象”關進籠子。
現在,去把你代碼里那段“可疑正則/類型強轉/遍歷邏輯”修一修吧。你的未來維護者,會沖你微笑的。




























