前言:你看到的不一定是真相
嘿,各位前端工友们!👋
每天写着 arr.map()、parseInt()、str.length,你真的以为你了解它们吗?
很多时候,我们就像只会按按钮的操作工,知道按一下会出结果,但机器内部是怎么哐当哐当运作的,那就是一片迷雾了。今天,我就来当一回你们的 "金牌导游",带你深入 JS 引擎的锅炉房,扒一扒这些常用方法背后那些不为人知的 "黑魔法"。
坐稳了,我们要发车了!
一、 map() 的底层:不仅仅是遍历
javascript
运行
ini
const arr = [1, 2, 3, 4, 5, 6];
console.log(arr.map(item => item * item));
// 输出: [1, 4, 9, 16, 25, 36]
map 是个好东西,ES6 一出来就成了香饽饽。我们都知道它能遍历数组,返回一个新数组。但它的底层是怎样的呢?
底层揭秘:
- 创建新数组 :
map方法一调用,首先会在内存里开辟一块新空间,创建一个空数组,用来存放后续的结果。这也是为什么map会返回一个新数组,而不会修改原数组的原因。 - 遍历老数组:接着,它会像一个勤劳的小蜜蜂,挨个儿访问原数组中的每一个元素。
- 执行回调函数 :对于每一个元素,它都会把这个元素(
item)、它的索引(index)以及整个原数组(array)作为参数,传给你写的那个回调函数。 - 收集返回值 :你的回调函数执行完后,会返回一个值。
map会把这个返回值,像捡到宝贝一样,小心翼翼地放进最开始创建的那个新数组里。 - 返回新数组 :等所有元素都遍历完,回调函数也都执行完毕,新数组也收集满了宝贝,
map就会把这个新数组作为最终结果返回给你。
一句话概括 :map 的核心是 "映射",它负责把老数组里的每一个元素,经过你给的 "加工机器"(回调函数),变成一个新元素,然后组装成一个全新的数组。
二、 parseInt() 的血泪史:你以为你懂了,其实你错了
javascript
运行
arduino
console.log([1, 2, 3].map(parseInt)); // 输出: [1, NaN, NaN]
这道题堪称面试界的 "送命题"。为什么不是 [1, 2, 3]?parseInt 你到底在搞什么鬼?
要搞懂这个,我们必须先看 parseInt 的完整签名:parseInt(string, radix)。
string:要被解析的值。radix:基数 ,一个 2 到 36 之间的整数。表示string参数的基数(进制)。
问题就出在 map 的回调函数会接收三个参数:(item, index, array)。当你把 parseInt 直接作为回调函数传给 map 时,map 会非常 "热心" 地把这三个参数都传给 parseInt。
所以,上面那段代码的实际执行过程是这样的:
-
parseInt('1', 0, [1,2,3])radix为0时,parseInt会根据字符串的开头来判断基数。以 '1' 开头,默认为十进制。所以结果是1。
-
parseInt('2', 1, [1,2,3])radix为1。但是,parseInt的radix范围是 2-36。1是一个无效的基数。所以结果是NaN。
-
parseInt('3', 2, [1,2,3])radix为2(二进制)。但二进制里只有0和1。字符串 '3' 在二进制里是无效的。所以结果也是NaN。
底层揭秘 & 血泪教训:
parseInt 的底层会根据你提供的 radix 去尝试将字符串解析为对应进制的整数。如果 radix 无效,或者字符串内容超出了 radix 进制的表示范围,它就会返回 NaN(Not a Number)。
所以,正确的用法是,给 map 传一个匿名函数,明确地只把 item 传给 parseInt。
javascript
运行
javascript
// 正确用法
console.log([1, 2, 3].map(item => parseInt(item))); // 输出: [1, 2, 3]
记住,不要轻易把一个需要特定参数的函数直接作为回调函数传递,除非你非常清楚调用方会传递什么参数。
三、 JS 的 "包装类" 黑魔法:str.length 是怎么来的?
javascript
运行
ini
let str = "hello"; // typeof str 是 "string",一个原始值
console.log(str.length); // 输出: 5
这看起来天经地义,但如果你细想一下,就会发现其中的奥秘。str 是一个字符串原始值 ,不是一个对象。那它为什么能像对象一样,拥有 .length 属性并调用方法呢?
这就是 JS 引擎的 "包装类"(Wrapper Classes)黑魔法。
底层揭秘:
当你试图访问一个原始值(如 string, number, boolean)的属性或方法时,JS 引擎会偷偷地、瞬间地做以下几件事:
- 创建包装对象 :JS 引擎会根据原始值的类型,创建一个对应的临时对象。比如
'hello'会创建一个new String('hello')对象。 - 访问属性 / 方法 :然后,在这个临时对象上访问你想要的属性(
length)或方法(如toUpperCase())。 - 销毁包装对象:访问完成后,这个临时的包装对象就会被立即销毁,释放内存。
整个过程快如闪电,你完全感知不到。JS 引擎这么做,是为了让代码写起来更简洁、更直观,让你可以像操作对象一样操作简单的原始值。
你可以把它想象成:你(开发者)想跟一个明星(原始值 'hello')说话。你不能直接上去说,于是经纪人(JS 引擎)临时给明星套上一个 "人形外壳"(包装对象 String {'hello'}),你跟这个外壳交流(访问 .length),交流完,外壳就被收走了。
javascript
运行
ini
let str = "hello";
str.length; // 这里发生了包装类的魔法
// 等价于:
let tempObj = new String(str); // 创建临时对象
let len = tempObj.length; // 访问属性
tempObj = null; // 销毁临时对象(示意)
console.log(len);
四、 NaN 的迷之特性:连自己都不认识的 "数字"
javascript
运行
javascript
console.log(NaN, typeof NaN); // 输出: NaN 'number'
console.log(0/0); // 输出: NaN
console.log(parseInt("hello"));// 输出: NaN
// 最诡异的特性
console.log(NaN === NaN); // 输出: false
NaN(Not a Number)是一个非常特殊的值。它表示一个 "不是数字" 的数字。
底层揭秘:
NaN 是 Number 类型,但它代表一个无效的或未定义的数学运算结果。比如 0 除以 0,或者试图把一个非数字字符串 'hello' 转换成数字。
它最让人头疼的特性是:它不等于任何值,包括它自己。
所以,你永远不能用 === 来判断一个值是不是 NaN。
javascript
运行
scss
// 错误的判断方式
if (someValue === NaN) {
// 这里的代码永远不会执行
}
那该怎么判断呢?正确的姿势是使用全局函数 isNaN() 或者 ES6 新增的 Number.isNaN()。
javascript
运行
javascript
const b = parseInt("hello"); // b 的值是 NaN
// 正确的判断方式
if (Number.isNaN(b)) {
console.log("哎呀,出错了,这不是一个有效的数字!");
}
Number.isNaN() 比全局的 isNaN() 更严谨,因为 isNaN() 会先尝试将参数转换为数字,导致一些误判。
五、字符串的索引与 length 的小秘密
javascript
运行
ini
const str = " Hello, 世界! 👋 ";
console.log(str.length); // 输出: 15
console.log(str[1]); // 输出: 'H'
str.length 返回字符串的长度,str[index] 可以通过索引访问字符。这很基础,但底层也有讲究。
底层揭秘:
-
length的计算 :JS 字符串在底层是基于 UTF-16 编码存储的。length属性返回的是字符串中 UTF-16 编码单元(code unit)的数量,而不是字符(code point)的数量。- 对于大多数常见字符(如英文字母、数字、常用中文),一个字符对应一个 UTF-16 编码单元,所以
length看起来是正确的。 - 但对于一些扩展字符集的字符,比如某些 emoji 😊、👋 或者一些生僻字,它们可能需要两个或更多的 UTF-16 编码单元来表示。
javascript
运行
arduinoconsole.log('𝄞'.length); // 输出: 2 (这是一个音乐符号,需要两个UTF-16编码单元) console.log('😊'.length); // 输出: 2 (这个emoji也是)这是一个常见的 "陷阱",在处理包含 emoji 或特殊字符的字符串时需要特别注意。
- 对于大多数常见字符(如英文字母、数字、常用中文),一个字符对应一个 UTF-16 编码单元,所以
-
str[index]的访问 :这种方式访问的是第index个 UTF-16 编码单元,而不是第index个视觉上的字符。对于上面的例子,'𝄞'[0]会得到一个无效的代理对(surrogate pair)字符。如果你需要正确地遍历每一个视觉上的字符(code point),应该使用
for...of循环或者Array.from()。javascript
运行
arduinoconst emoji = '😊'; console.log(emoji.length); // 2 for (const char of emoji) { console.log(char); // 正确输出: 😊 } console.log(Array.from(emoji)); // 正确输出: ['😊']
总结:知其然,更要知其所以然
今天我们深入探讨了 map, parseInt, length, 包装类 和 NaN 这些 JS 中看似简单的特性背后的底层逻辑。
了解这些底层原理,不仅仅是为了在面试中炫技,更重要的是:
- 避免踩坑 :比如
[1,2,3].map(parseInt)的陷阱。 - 写出更健壮的代码 :比如知道了
NaN的特性,就会用Number.isNaN()来判断。 - 理解代码行为:当代码出现意外结果时,能够从底层逻辑出发去分析和调试问题。
JS 是一门充满 "惊喜" 的语言,表面简单,实则水深。希望这篇文章能帮助你拨开迷雾,看到 JS 更真实、更有趣的一面。
你还想知道哪些 JS 方法的底层实现?评论区告诉我!