普通视图

发现新文章,点击刷新页面。
昨天 — 2025年7月23日首页

JavaScript垃圾回收:你不知道的内存管理秘密

作者 前端大鱼
2025年7月22日 20:57

大家好,我是前端大鱼。作为前端开发者,我们每天都在与JavaScript打交道,但很少有人真正了解JavaScript是如何管理内存的。今天,我们就来揭开JavaScript垃圾回收机制的神秘面纱,让你对内存管理有更深入的理解。

为什么需要垃圾回收?

在编程中,内存管理一直是个重要话题。C/C++等语言需要手动管理内存,而JavaScript则采用了自动内存管理机制。这是因为:

  1. 防止内存泄漏(应用程序不再需要的内存没有被释放)
  2. 避免野指针(访问已释放的内存)
  3. 减轻开发者负担,让开发者更专注于业务逻辑
// 伪代码示例:手动内存管理 vs 自动内存管理
// C语言风格(手动)
let ptr = malloc(1024); // 分配内存
// 使用内存...
free(ptr); // 必须手动释放

// JavaScript风格(自动)
let obj = { data: "value" }; // 自动分配
obj = null; // 不再需要时,垃圾回收器会自动回收

JavaScript的内存生命周期

JavaScript中的内存生命周期可以分为三个阶段:

  1. 分配阶段:当声明变量、函数或创建对象时,JavaScript会自动分配内存
  2. 使用阶段:读写分配的内存
  3. 释放阶段:当内存不再需要时自动释放

垃圾回收的基本策略

现代JavaScript引擎主要采用两种垃圾回收策略:

1. 标记-清除算法(Mark-and-Sweep)

这是目前主流JavaScript引擎(V8、SpiderMonkey等)采用的算法。其工作原理如下:

// 标记-清除算法伪代码
function garbageCollect() {
    // 标记阶段:从根对象出发,标记所有可达对象
    markFromRoots();
    
    // 清除阶段:遍历堆内存,回收未被标记的对象
    sweep();
}

function markFromRoots() {
    let worklist = [...roots]; // roots包括全局对象、当前调用栈等
    
    while (worklist.length > 0) {
        let obj = worklist.pop();
        if (!obj.marked) {
            obj.marked = true;
            worklist.push(...obj.references); // 递归标记引用对象
        }
    }
}

function sweep() {
    for (let obj in heap) {
        if (obj.marked) {
            obj.marked = false; // 为下次GC准备
        } else {
            free(obj); // 释放内存
        }
    }
}

2. 引用计数(Reference Counting)

这是一种较简单的策略,但现在已很少单独使用:

// 引用计数伪代码
let obj = { count: 0 }; // 新对象引用计数为0

// 当有引用指向该对象时
function addReference(obj) {
    obj.count++;
}

// 当引用移除时
function removeReference(obj) {
    obj.count--;
    if (obj.count === 0) {
        free(obj); // 释放内存
    }
}

引用计数的主要问题是无法处理循环引用:

// 循环引用示例
function createCycle() {
    let a = {};
    let b = {};
    a.ref = b; // a引用b
    b.ref = a; // b引用a
    // 即使函数执行完毕,a和b的引用计数仍为1,无法回收
}

V8引擎的垃圾回收优化

现代JavaScript引擎如V8对基本标记-清除算法做了许多优化:

1. 分代收集(Generational Collection)

V8将堆内存分为新生代(Young Generation)和老生代(Old Generation):

  • 新生代:存放生命周期短的对象,使用Scavenge算法(一种复制算法)频繁回收
  • 老生代:存放存活时间长的对象,使用标记-清除或标记-整理算法较少回收
// 分代收集伪代码
function generationalGC() {
    if (youngGenerationIsFull()) {
        scavengeYoungGeneration();
        if (promotionConditionMet()) {
            promoteToOldGeneration();
        }
    }
    
    if (oldGenerationIsFull()) {
        markSweepOrCompactOldGeneration();
    }
}

2. 增量标记(Incremental Marking)

为了避免长时间停顿,V8将标记过程分成多个小步骤,与JavaScript执行交替进行。

3. 空闲时间收集(Idle-time Collection)

利用浏览器空闲时段进行垃圾回收,减少对主线程的影响。

内存泄漏的常见模式

即使有垃圾回收机制,不当的代码仍可能导致内存泄漏:

  1. 意外的全局变量
function leak() {
    leakedVar = '这是一个全局变量'; // 意外创建全局变量
}
  1. 遗忘的定时器或回调
let data = getHugeData();
setInterval(() => {
    // 即使data不再需要,定时器仍保持引用
    process(data);
}, 1000);
  1. DOM引用
let elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};

// 即使从DOM移除,JavaScript引用仍然存在
document.body.removeChild(document.getElementById('image'));
  1. 闭包
function outer() {
    let largeData = new Array(1000000).fill('*');
    
    return function inner() {
        // inner函数保持对largeData的引用
        return 'Hello';
    };
}

最佳实践

  1. 使用弱引用:对于不需要强引用的数据,可以使用WeakMap或WeakSet
let weakMap = new WeakMap();
let key = { id: 1 };
weakMap.set(key, 'some data');
// 当key不再被引用时,条目会自动从WeakMap中移除
  1. 及时清理:不再需要的引用显式设为null
let data = getLargeData();
process(data);
data = null; // 不再需要时清除引用
  1. 避免内存密集操作:特别是在循环或频繁调用的函数中

  2. 使用开发者工具监控内存:Chrome DevTools的Memory面板是强大的内存分析工具

🌟总结

JavaScript的垃圾回收机制是语言设计的一大优势,它让开发者从繁琐的内存管理中解放出来。理解其工作原理不仅能帮助我们编写更高效的代码,还能有效避免内存泄漏问题。

希望这篇文章能帮助你更深入地理解JavaScript的内存管理机制,写出更健壮、高效的代码!

昨天以前首页

Nuxt 4 正式发布!

2025年7月21日 09:09

作者:前端界

原文:mp.weixin.qq.com/s/HAN31v9vl…

经过一年多的实战测试,Nuxt 4.0 正式发布了! 这是一个专注于稳定性和开发者体验的重大版本更新,在提升性能、代码结构、类型系统等方面做出了诸多优化。如果你正在使用 Nuxt 3,升级将会非常平滑!

核心亮点

1. 更清晰的项目结构:新增 app/ 目录

Nuxt 4 推出了一种新的默认项目结构,把你的应用代码集中到 app/ 目录中,使得客户端代码、服务器端代码和配置之间更加清晰分离:

my-nuxt-app/
├─ app/             # 应用核心代码
│  ├─ components/
│  ├─ pages/
│  ├─ layouts/
│  └─ app.vue
├─ public/
├─ shared/         # 通用工具库
├─ server/
└─ nuxt.config.ts

✅ 如果你不想迁移,Nuxt 也会自动识别旧结构,保持兼容!

2. useAsyncData 和 useFetch 升级

Nuxt 4 对数据获取进行了大幅优化,可以更智能获取数据:

  • 相同 key 的组件共享数据,避免重复请求
  • 组件卸载时自动清理缓存
  • 支持响应式 key 重新触发请求
  • 更可控的缓存策略

示例代码:

const { data, pending, refresh } = await useFetch('/api/posts', {
  key'posts',
  servertrue,
  lazytrue
})

你可以通过 refresh() 手动触发数据更新,或用 watch() 响应变化。

3. 更优秀的 TypeScript 体验

Nuxt 4 使用 多项目结构(project references) ,将 app、server、shared、builder 等上下文分离,提高类型推断准确性。

只需一个 tsconfig.json 即可:

{
  "extends""./.nuxt/tsconfig.json",
  "compilerOptions": {
    "types": ["@nuxt/types"]
  }
}

你将获得更少的类型错误和更强的自动补全支持!

4. 更快的 CLI 与开发体验

Nuxt CLI 在性能上也做了不少优化:

  • 冷启动更快
  • 使用 V8 编译缓存
  • 原生文件监听(更省资源)
  • CLI 与 Vite 通过 socket 通信(减少网络开销)

尤其是在 Windows 和大型项目中,这些改进会非常明显!

如何升级到 Nuxt 4?

Nuxt 4 的升级非常平滑,推荐按以下步骤进行:

第一步:一键升级

使用官方命令自动升级并去重依赖:

npx nuxt upgrade --dedupe

第二步:使用 Codemod 自动迁移(可选)

npx codemod@latest nuxt/4/migration-recipe

该工具可以自动处理部分兼容性变更,特别适合项目较大的同学使用。

第三步:测试与调整

运行你的测试、查看构建结果,根据 升级指南(nuxt.com/docs/4.x/ge… 检查是否有需要手动修复的地方。比如:

  • 清理弃用的 API
  • 检查 ts 类型是否有报错
  • 更新不兼容的模块(极少数)

未来路线图:Nuxt 5 与更多功能

虽然 Nuxt 4 才刚刚发布,但 Nuxt 团队已经计划 Nuxt 5 将引入:

  • Nitro v3 + h3 v2 性能再还能更秀
  • Vite 环境 API 支持
  • 更强的 SSR 流式渲染
  • 内置缓存策略、动态路由发现
  • 多应用支持(multi-app)

最后

Nuxt 4 是一次以稳定和开发体验为核心的进化,它没有一味追求“炫酷”的新特性,而是逐步打磨每一处细节,让开发者写得更安心、改得更省心、跑得更顺心。

如果你已经在使用 Nuxt 3,升级会非常顺滑。如果你还在 Nuxt 2,也许这正是切换的好时机。

📘 官方文档:nuxt.com

🎉 快试试 Nuxt 4 吧!

🚀JavaScript 闭包应用大全:面试高频 + 实战技巧全掌握

作者 TimelessHaze
2025年7月21日 14:53

🚀 JavaScript 闭包应用大全:面试高频 + 实战技巧全掌握

💡 前情回顾:我们在上一篇文章中深入剖析了 JavaScript 的作用域链与闭包机制,理解了闭包的本质是函数 + 其词法环境。本篇将继续进阶,带你串联作用域、闭包与工程实践技巧,帮助你从底层原理顺利过渡到**高阶应用场景[🚀 深入理解 JavaScript 作用域链与闭包机制:从原理到实践的完全指南。](这是闭包的原理)


一、温故而知新:闭包的本质回顾

闭包(Closure)是 JavaScript 的核心概念之一。其本质是:

闭包 = 函数 + 定义时的词法作用域环境

即使外层函数已经执行完毕,内层函数依旧可以访问其定义时的变量。

function outer() {
  let count = 0;
  return function inner() {
    count++;
    console.log(count);
  };
}

const counter = outer();
counter(); // 1
counter(); // 2

🧠 关键词回顾:作用域链、变量查找、引用保留、闭包的“背包模型”


二、闭包的运行环境:事件循环机制深入剖析

闭包与异步任务密不可分,而 JavaScript 的异步调度机制依赖于 事件循环(Event Loop) 。我们用两张图帮助你理清闭包在异步环境中的作用:

📌 图 1:主线程、异步线程与事件队列的协作关系 884a7c0a52236d2a789896a40664c10.png

🧩 当执行栈为空时,事件队列中的异步任务(如定时器、IO 回调)才会被调度到主线程执行。此时这些回调依赖闭包,来“记住”原本定义时的上下文变量。

📌 图 2:setTimeout / AJAX 的调度流程

e16683a61175d04eb9f496690dc31bd.png

👀 你可以看到 setTimeoutajax 分别进入各自的线程处理,最终都依靠事件循环将回调推回主线程队列,这一过程中闭包始终起到“变量保持器”的作用。


三、闭包的典型应用场景

闭包无处不在,特别在以下常见开发场景中尤为重要:

应用场景 技术组合 实用目的
防抖(debounce) 闭包 + 定时器 限制短时间内频繁调用,性能优化
节流(throttle) 闭包 + 时间戳 限制一定时间间隔内只触发一次
数据封装 / 私有变量 闭包 + 模块模式 模拟类中的私有成员
柯里化(Currying) 闭包 + 高阶函数 多参数函数转为链式调用,提高函数复用
记忆函数(Memoization) 闭包 + 缓存对象 缓存计算结果,避免重复执行
异步上下文保持 闭包 + 事件循环 在异步执行中保留创建时的变量状态

四、实战演练:闭包在项目中的四大经典用法

✅ Debounce 防抖函数

function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

🧠 通过闭包缓存 timer,每次触发前清除前一个定时器,实现最后一次触发。


✅ Throttle 节流函数

function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      fn.apply(this, args);
      last = now;
    }
  };
}

🧠 使用闭包保存上次调用时间,避免函数被频繁触发。


✅ 数据封装与私有变量模拟

const Counter = (function () {
  let count = 0;
  return {
    increment: () => ++count,
    getCount: () => count
  };
})();

🧠 闭包中的 count 变量对外部不可见,仅能通过暴露的方法访问。


✅ 记忆函数 Memoization

function memoize(fn) {
  const cache = {};
  return function (n) {
    if (cache[n]) return cache[n];
    return (cache[n] = fn(n));
  };
}

🧠 闭包内部维护 cache,实现函数结果的缓存复用。


五、手写题与面试高频考点

闭包相关面试题典型例子:

js
复制编辑
function test() {
  let arr = [];
  for (var i = 0; i < 3; i++) {
    arr[i] = function () {
      return i;
    };
  }
  return arr;
}

const res = test();
console.log(res[0]()); // ?

正确答案是 3,因为闭包捕获的是变量的引用。

✅ 修正方式:

js
复制编辑
for (var i = 0; i < 3; i++) {
  (function (j) {
    arr[j] = function () {
      return j;
    };
  })(i);
}

六、现代 JavaScript 与闭包的最佳实践

闭包在现代开发中也需注意配合其他语法特性:

  • 使用 let/const 避免变量提升问题
  • 使用箭头函数绑定上下文,避免 this 迷失
  • 合理命名与文档注释,避免闭包过度嵌套造成混乱

示例:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // 输出:0 1 2
}

📌 若使用 var,则闭包中所有回调都访问同一个 i,导致输出 3 个 3。


七、性能优化与潜在陷阱

📌 图示:浏览器任务管理器分析闭包带来的异步任务

(此处建议插入图像)

Chrome 浏览器任务管理器(或 Performance 面板)可以查看由于闭包引入的额外内存和 CPU 占用。

问题类型 建议优化措施
内存泄漏 避免不必要的闭包引用,及时清理长生命周期对象
性能负担 减少作用域层级、避免深层嵌套、精简闭包捕获变量
上下文混乱 明确 this 指向(使用箭头函数或 .bind())确保行为一致性

八、总结与展望

闭包不仅是语言特性,更是连接作用域、异步逻辑、模块封装的桥梁

  • ✅ 保留变量状态,解决异步陷阱
  • ✅ 封装私有变量,提升代码安全性
  • ✅ 支持高阶函数与函数式编程风格
  • ✅ 与事件循环机制深度融合

📚 推荐阅读

Vue 3 中 async setup () 的「坑」与避坑指南1

2025年7月20日 14:45

在 Vue 3 中,setup()函数是组件的核心入口,负责组合组件的响应式数据和方法。当setup()返回一个渲染函数时,这个渲染函数必须是同步的。如果使用async setup(),会导致以下问题:

1. 渲染函数必须是同步的

Vue 3 的渲染流程要求渲染函数(h 函数或 JSX)必须立即返回 VNode 结构,而不是 Promise。如果setup()是异步的,会出现以下问题:

// ❌ 错误示例:async setup() 返回渲染函数
export default {
  async setup() {
    // 模拟异步操作(如API请求)
    const data = await fetchData();
    
    // 返回渲染函数(此时组件已经开始渲染,但数据还未返回)
    return () => h('div', data.value); // 此时data可能为undefined
  }
};
  • 问题:Vue 在调用setup()时不会等待 Promise resolve,而是直接执行后续渲染逻辑。此时渲染函数可能在数据加载完成前就被调用,导致显示undefined或空值。

2. 异步 setup () 的正确用法

如果确实需要在setup()中使用异步操作,不要返回渲染函数,而是通过refreactive定义响应式数据,让 Vue 自动跟踪数据变化并触发更新:

javascript

// ✅ 正确示例:使用ref/reactive + 模板(或setup返回对象)
export default {
  async setup() {
    const data = ref(null);
    const loading = ref(true);
    
    try {
      data.value = await fetchData();
    } finally {
      loading.value = false;
    }
    
    // 返回数据(不返回渲染函数,由模板自动响应数据变化)
    return {
      data,
      loading
    };
  }
};
  • 模板

    预览

    <template>
      <div v-if="loading">加载中...</div>
      <div v-else>{{ data }}</div>
    </template>
    

3. 为什么渲染函数不能是异步的?

Vue 的渲染流程是同步执行的:

  1. 调用setup()获取渲染上下文(数据、方法)。

  2. 执行渲染函数生成 VNode 树。

  3. 根据 VNode 树创建 DOM 节点并挂载。

如果渲染函数是异步的,Vue 无法确定何时才能获取完整的 VNode 结构,会导致:

  • 初始渲染时数据缺失。
  • DOM 频繁更新(数据返回后需要重新渲染)。
  • 性能问题(多次不必要的渲染)。

4. 对比 Vue 2 的异步 mounted ()

Vue 2 的mounted()是生命周期钩子,组件已经渲染完成后才执行,异步操作只会影响后续更新,不会阻塞初始渲染:

javascript

// Vue 2 异步mounted()
export default {
  data() {
    return {
      data: null
    };
  },
  async mounted() {
    this.data = await fetchData(); // 数据返回后触发更新
  }
};

5. 总结

在 Vue 3 中:

  • 不要使用async setup()返回渲染函数,因为渲染函数必须同步返回 VNode。
  • 如果需要异步操作,使用ref/reactive定义响应式数据,并在模板中使用条件渲染(如v-if)处理加载状态。
  • 若使用组合式 API 的defineComponent,Vue 会自动警告async setup()返回渲染函数的错误。

针对初学者的JS八种类型实用小技巧总结

2025年7月20日 15:29

一、!! 和 !!! 的深入理解

1. !!(双重非)操作符
将任意值强制转换为布尔类型,等效于 Boolean() 函数。
转换规则

  • 假值nullundefined0''NaNfalse)→ false

  • 其他值 → true(包括空数组[]、空对象{}、函数等)

典型应用场景

javascript

// 判断对象是否存在
const user = null;
console.log(!!user); // false

// 简化条件判断
if (!!items.length) { /* 处理非空数组 */ }

// 在Vue项目中判断数据状态
const isLoggedIn = !!user.token;

2. !!!(三重非)操作符
先通过!!转换为布尔值,再取反一次,等效于 !Boolean(值)
典型应用场景

javascript

// 简化反向逻辑判断
const isEmpty = !!!value; // 等效于 value === null || value === undefined || value === ''

// 在Vue中处理加载状态
loading.value = !!!data; // 数据存在时隐藏加载状态

二、JavaScript 基础实用技巧

1. 空值合并与默认值处理

javascript

// 传统写法(缺陷:0、''、false 也会被替换)
const name = user.name || '默认名称';

// 推荐写法(仅替换 null/undefined)
const name = user.name ?? '默认名称';

// 对象解构默认值
const { age = 18, address = {} } = user;
2. 可选链操作符(Optional Chaining)

javascript

// 传统写法
const city = user && user.address && user.address.city;

// 简洁写法
const city = user?.address?.city;

// 结合空值合并
const city = user?.address?.city ?? '未知城市';
3. 快速数值转换

javascript

const strNum = '123';
const num = +strNum; // 等效于 Number(strNum)

// 取整技巧
const floatNum = 3.14;
const intNum = ~~floatNum; // 双波浪号取整,等效于 Math.floor(3.14)
4. 数组去重

javascript

const arr = [1, 2, 2, 3, 3, 3];
const uniqueArr = [...new Set(arr)]; // [1, 2, 3]
5. 交换变量值

javascript

let a = 1, b = 2;

// 传统写法
const temp = a;
a = b;
b = temp;

// 简洁写法
[a, b] = [b, a];

三、函数与作用域技巧

1. 函数参数默认值

javascript

// 传统写法
function greet(name) {
  name = name || 'Guest';
  console.log(`Hello, ${name}`);
}

// 推荐写法
function greet(name = 'Guest') {
  console.log(`Hello, ${name}`);
}
2. 箭头函数简化

javascript

// 传统函数
const sum = function(a, b) {
  return a + b;
};

// 箭头函数
const sum = (a, b) => a + b;
3. 立即执行函数(IIFE)

javascript

// ES5常用
(function() {
  const privateVar = '私有变量';
  // 私有作用域
})();

// ES6模块替代方案
{
  const privateVar = '私有变量';
  // 块级作用域
}

四、对象与数组操作技巧

1. 对象浅拷贝

javascript

const obj = { a: 1, b: 2 };
const clone = { ...obj }; // 展开语法
// 等效于 Object.assign({}, obj)
2. 数组合并

javascript

const arr1 = [1, 2];
const arr2 = [3, 4];

const merged = [...arr1, ...arr2]; // [1, 2, 3, 4]
3. 数组过滤与映射

javascript

const numbers = [1, 2, 3, 4, 5];

// 过滤偶数并翻倍
const result = numbers
  .filter(n => n % 2 === 0) // [2, 4]
  .map(n => n * 2); // [4, 8]
4. 解构赋值高级用法

javascript

// 对象解构重命名
const { name: userName, age: userAge } = user;

// 数组解构
const [first, second, ...rest] = [1, 2, 3, 4, 5];

五、异步编程技巧

1. 异步函数简化

javascript

// 传统Promise
fetchData()
  .then(data => processData(data))
  .catch(error => console.error(error));

// 推荐:async/await
async function fetchAndProcess() {
  try {
    const data = await fetchData();
    const result = processData(data);
  } catch (error) {
    console.error(error);
  }
}
2. 并行请求处理

javascript

// 多个API并行请求
async function fetchAll() {
  const [user, posts] = await Promise.all([
    fetchUser(),
    fetchPosts()
  ]);
  return { user, posts };
}
3. 防抖与节流

javascript

// 防抖函数(避免频繁触发)
const debounce = (fn, delay) => {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
};

// 节流函数(限制执行频率)
const throttle = (fn, limit) => {
  let inThrottle;
  return (...args) => {
    if (!inThrottle) {
      fn(...args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
};

六、性能优化技巧

1. 延迟加载(懒加载)

javascript

// 按需加载模块
const loadHeavyModule = async () => {
  const heavyModule = await import('./heavy-module.js');
  heavyModule.init();
};

// 点击按钮时加载
button.addEventListener('click', loadHeavyModule);
2. 循环优化

javascript

// 传统for循环(性能最优)
for (let i = 0, len = arr.length; i < len; i++) {
  // ...
}

// 避免使用for...in遍历数组(性能较差)
3. 事件委托

javascript

// 父元素监听,子元素触发
parentElement.addEventListener('click', (e) => {
  if (e.target.matches('.child-element')) {
    // 处理子元素点击事件
  }
});

七、调试与错误处理

1. 控制台美化输出

javascript

// 带颜色的日志
console.log('%c重要信息', 'color: blue; font-weight: bold');

// 表格形式输出
console.table([{ name: '张三', age: 20 }, { name: '李四', age: 25 }]);
2. 错误边界(Error Boundary)

javascript

// 自定义错误捕获函数
window.onerror = function(message, source, lineno, colno, error) {
  // 记录错误信息
  logError({ message, source, lineno, colno, error });
  return true; // 阻止错误冒泡
};
3. 断言(Assertion)

javascript

function assert(condition, message) {
  if (!condition) {
    throw new Error(message || 'Assertion failed');
  }
}

// 使用示例
assert(typeof value === 'number', 'value必须是数字');

八、类型检查与转换

1. 类型安全检查

javascript

// 检查数组
Array.isArray([]); // true

// 检查空对象
const isEmptyObject = obj => 
  obj && typeof obj === 'object' && !Object.keys(obj).length;

// 检查null/undefined
const isNullOrUndefined = val => val == null; // 注意:使用==而非===

2. 安全的类型转换

javascript

// 字符串转数字
const num = parseInt('123', 10); // 第二个参数必须为10

// 安全的JSON解析
const parseJSON = (str) => {
  try {
    return JSON.parse(str);
  } catch (e) {
    return null;
  }
};

useContext 与 useReducer 的组合使用

作者 红衣信
2025年7月20日 15:24

前言

在讲useReducer之前需要介绍一下useState这个hooks函数。useState用于数据管理,响应式数据管理,允许我们在函数组件中存储状态。

随着应用逐渐复杂,我们经常发现useState在管理复杂的状态逻辑时显得有些力不从心。这时,React为我们提供的另一个更为强大的hook——useReducer——可以帮助我们优雅地处理复杂状态。

useReducer允许我们使用 action 和 reducer 的方式来组织复杂的状态逻辑,使其变得更加清晰和模块化,弥补了useState的局限性。

今天让我们用useReducer+useContext来实现一个todosList功能。

了解一下什么是useReducer?

useReducer

useState相似,useReducer也是 React 的 Hook,而且也只能放在组件最顶层使用。与前者不同的地方在于,它是通过 action 来更新状态的,使状态更新逻辑更具可读性。

useReducer接受三个参数:

  • reducer:用于更新 state 的纯函数。参数为 state 和 action,返回值是更新后的 state。state 与 action 可以是任意合法值。
  • initialArg:用于初始化 state 的任意值。初始值的计算逻辑取决于接下来的 init 参数。
  • 可选参数 init:用于计算初始值的函数。如果存在,使用 init(initialArg) 的执行结果作为初始值,否则使用 initialArg
什么是纯函数?

纯函数(Pure Function)是函数式编程中的一个核心概念,它指的是那些在同样的输入下总是产生相同输出的函数,并且不会产生任何副作用。具体来说,纯函数具有以下两个主要特性:

  1. 确定性:给定相同的输入参数,纯函数总是返回相同的结果。这意味着纯函数的输出仅取决于它的输入参数,不受外部状态(比如全局变量、数据库查询或网络请求等)的影响。
  2. 无副作用:纯函数不会修改任何外部状态或数据,也不会进行任何形式的I/O操作,例如文件系统操作、网络请求或打印到控制台等。它们仅仅根据输入参数计算并返回结果。

useReducer返回两个参数:

useReducer 返回一个由两个值组成的数组:

  1. 当前的 state。初次渲染时,它是 init(initialArg) 或 initialArg (如果没有 init 函数)。
  2. dispatch 函数。用于更新 state 并触发组件的重新渲染。
const [state, dispatch] = useReducer(reducer, initialArg, init?)

当你使用 useReducer 时,useReducer 返回一个数组,第一个元素是当前的状态 (state),第二个元素是一个可以用来触发状态更新的 dispatch 函数。通过解构赋值,我们可以方便地将这两个值分别赋给 statedispatch 变量。

实现todoList

1.创建全局上下文

export const TodoContext = createContext(null);

2.创建自定义hooks函数useTodos ,其主要作用是封装待办事项(todos)的状态管理和操作逻辑,方便在不同组件中复用。

const initialTodos =[
    {
        id:1,
        text:'学习React',
        done:false
    }
]
export function useTodos(initial=initialTodos) {
    const [todos,dispatch] = useReducer(todoReducer,initial)

    const addTodo = text => dispatch({type: 'ADD_TODO',text})
    const toggleTodo =(id)=> dispatch({type: 'TOGGLE_TODO',id})
    const removeTodo =(id)=> dispatch({type: 'REMOVE_TODO',id})

    return {
        todos,
        addTodo,
        toggleTodo,
        removeTodo
    }
}

3.在App.jsx中,用创建好的上下文包裹子组件,这样就不需要传递数据了,value 属性指定了要共享的数据,这里共享的是 todosHook 对象。todosHook 包含当前的待办事项列表 todos 和三个操作函数 addTodo 、 toggleTodo 、 removeTodo 。

function App() {
  const todosHook=useTodos();
  return (
      <TodoContext.Provider value={todosHook}>
         <h1>Todo App</h1>
         <AddTodo />
         <TodoList />
      </TodoContext.Provider>
  )
}

4.创建一个纯函数,用来出来todoReducer用于根据不同的 action 来更新待办事项的状态。reducer 接收两个参数:

  • state :当前的待办事项状态,通常是一个数组。
  • action :一个对象,包含 type 属性(表示操作类型)和其他必要的数据。
function todoReducer(state,action){
    switch(action.type){
        case 'ADD_TODO':
            return [...state,{
                    id:Date.now(),
                    text:action.text,
                    done:false
                }]
        case 'TOGGLE_TODO':
            return state.map(todo =>
                todo.id === action.id ?{...todo,done:!todo.done}:todo
            );
        case 'REMOVE_TODO':
            return state.filter(todo => todo.id !== action.id);
        default:
            return state;
    }
}

5.创建一个自定义hooksuseTodoContext,使用全局上下文.创建这么多自定义hooks函数,就是为了让组件内部更加干净,更好管理。

export function useTodoContext(){
    return useContext(TodoContext);
}

6.完成添加todo的功能

const AddTodo =()=>{
    const [text,setText]=useState('');
    const { addTodo }=useTodoContext();// 跨层级
    const handleSubmit=(e)=>{
        e.preventDefault(); // 阻止表单默认行为
        if(text.trim()){ // 去空格
            addTodo(text.trim()); // 调用addTodo方法添加任务
            setText(''); // 清空输入框
        }
    }
    return (
        <form onSubmit={handleSubmit}>
            <input 
            type="text" 
            value={text}
            onChange={(e)=>{
                setText(e.target.value)
            }}
            />
            <button type="submit">Add</button>
        </form>
    )
}

7.完成展示todoslist的功能

const TodoList = () => {
    const {
        todos,
        toggleTodo,
        removeTodo,
    } = useTodoContext();
    
    return (
        <ul>
            {
                todos.map((todo)=>(
                    <li key={todo.id}>
                        <span
                          onClick={()=>toggleTodo(todo.id)}
                          style={{textDecoration:todo.done ? 'line-through':'none'}}
                        >
                        {todo.text}
                        {/* {localStorage.getItem('text')} */}
                        </span>
                        <button onClick={()=>removeTodo(todo.id)}>remove</button>
                    </li>
                ))
            }
        </ul>
    )
}

总结

  • useReducer 核心 :包含响应式状态管理、使用纯函数 reducer 规定状态改变规则、初始值 initValue 以及通过 dispatch 派发 action 对象。
  • useContext 作用 :用于跨层次共享状态,借助 createContext 创建上下文, Context.Provider 提供状态, useContext 消费状态。
  • 组合使用 :将 useContext 和 useReducer 结合,可实现跨层级的全局状态管理,应用场景包括主题、登录状态、待办事项等。
  • 自定义 Hook :介绍了组件渲染与 Hook 状态管理的关系,以及 hook 分别与 useContext 、 useReducer 组合使用在全局应用级别状态管理中的作用。

key、JSX、Babel编译、受控组件与非受控组件、虚拟DOM考点解析

作者 归于尽
2025年7月20日 15:04

一、在react中,使用map遍历的时候为什么一定要带上key?

在 React 中使用 map 遍历生成元素时,key 是一个非常关键但容易被忽视的属性。很多开发者会疑惑:"明明不带 key 也能正常显示,为什么非要加呢?"。

 const [todos,setTodos] = useState([
    {
        id:1,
        title:'吃饭',
    },
    {
        id:2,
        title:'睡觉',
    },
    {
        id:3,
        title:'打豆豆',
    }
  ])
  return (
    <>
      {
        todos.map((todo)=>{
            return <li>{todo.title}</li>
        })
      }
    </>
  )

结果是正常的,不带上key也能正常遍历出来,与带上key似乎没有什么差别,但当缺少 key 时,React 会默认使用数组索引作为隐式 key。这种方式在列表静态不变时看似正常,但在列表发生增删改查或排序时会引发严重问题:

  useEffect(()=>{
        setTimeout(()=>{
            setTodos([     
                {
                    id:4,
                    title:'吃饭1',
                },
                ...todos,
            ])
        },1000)
    },[])
  return (
    <>
      {
        todos.map((todo)=>{
            return <li>{todo.title}</li>
        })
      }
    </>
  )

image.png 此时浏览器控制台会显示 所有 DOM 节点都被重新渲染(图中高亮的更新提示)。这是因为:

  • 无 key 时,React 用索引匹配新旧元素
  • 新增元素插入头部后,原有元素的索引全部错位
  • React 误判为 "所有元素都被修改",导致全量更新
旧DOM顺序        新DOM顺序
---------       ---------
<li>吃饭</li>    <li>吃饭1</li>  // 内容被错误更新
<li>睡觉</li>    <li>吃饭</li>   // 应该保留原内容
<li>打豆豆</li>  <li>睡觉</li>   // 内容被污染
                <li>打豆豆</li> // 新增节点

这种 "错误复用" 不仅浪费性能(全量 DOM 重排重绘),还可能导致状态混乱(如输入框内容错位)。

如果加上了key会怎么样

{
    todos.map((todo)=>{
        return <li key={todo.id}>{todo.title}</li>
    })
  }

image.png reat建立虚拟DOM映射表

Key映射关系:
1 → <li>吃饭</li>
2 → <li>睡觉</li> 
3 → <li>打豆豆</li>

当新增元素时:

新增Key映射:
4 → <li>吃饭1</li>  // 仅插入此新节点
其他节点保持原位

从浏览器控制台可见,此时只有新增的节点被创建,原有节点无更新 —— 这就是 key 带来的性能优化。

key 的本质作用

key 是 React 用于识别列表中元素唯一性的标识,它的核心作用是:

  • 帮助 React 区分不同元素,精准识别哪些元素被新增、删除或重新排序
  • 减少不必要的 DOM 操作,提升渲染性能
  • 避免因元素复用错误导致的状态混乱

下面是key的工作原理

graph TD
  A[新虚拟DOM] --> B{Key匹配?}
  B -->|是| C[复用现有DOM]
  B -->|否| D[创建新DOM]
  C --> E[属性更新]
  D --> F[插入DOM树]

总结:当使用 map 遍历生成元素却未指定 key 时,React 会默认将数组索引作为元素的标识来对比新旧虚拟 DOM。这种方式在列表发生新增、删除或重新排序时,会导致元素与索引的对应关系错位,进而引发 DOM 节点的错误复用—— 例如将原本属于 A 元素的 DOM 节点错误分配给 B 元素,导致节点内容被意外更新。这不仅会造成不必要的 DOM 重排与重绘,产生额外的性能开销,还可能引发表单输入值错位等状态混乱问题。

而当为元素指定唯一 key 后,React 能够通过 key 精准识别每个元素的身份,直接定位到需要新增、删除或更新的元素,从而只对变化的部分进行 DOM 操作,避免无意义的整体更新,既保证了渲染准确性,又提升了性能。

二、什么是JSX?

JSX(JavaScript XML)是 JavaScript 的语法扩展,允许在 JS 代码中嵌入 XML 风格的标签:

// JSX 语法
const element = (
  <div className="container">
    <h1>Hello, React!</h1>
    <p>当前时间:{new Date().toLocaleTimeString()}</p>
  </div>
);

它既不是 HTML 也不是字符串,最终会被编译为普通的 JavaScript 函数调用。

JSX 的设计哲学

  • 声明式编程:描述 "UI 应该是什么样子",而非 "如何构建 UI"
  • 关注点分离:将 UI 结构与逻辑放在一起(组件),而非分离到 HTML 和 JS 文件
  • 直观性:相比纯 JS 创建元素,JSX 更接近视觉呈现的结构

JSX 与 HTML 的关键区别

虽然 JSX 看起来像 HTML,但存在多处语法差异:

特性 JSX 语法 HTML 语法 原因
类名 className class class 是 JavaScript 保留字
事件处理 onClick(驼峰式) onclick(全小写) 遵循 JS 变量命名规范
内联样式 style={{ color: 'red' }} style="color: red" JSX 中样式是对象
自闭合标签 必须闭合(<img /> 可省略(<img> 符合 XML 规范,避免歧义
注释 {/* 注释内容 */} <!-- 注释内容 --> 嵌入在 JS 环境中的注释

JSX 中的表达式

使用 {} 可以在 JSX 中嵌入任意 JavaScript 表达式:

// 变量
const name = "React";
const user = { name: "Alice", age: 25 };

// 表达式嵌入
const profile = (
  <div>
    <h1>姓名:{name}</h1>
    <p>年龄:{user.age > 18 ? "成年" : "未成年"}</p>
    <p>爱好:{["阅读", "编程"].join("、")}</p>
  </div>
);

注意:{} 中只能放表达式(有返回值的代码),不能放语句(如 iffor)。

JSX能被直接运行吗? 并不能,JSX 是开发时 方便书写 的语法糖,但浏览器 只能运行标准的 JavaScript,所以必须经过编译转换才能执行。

三、JSX 的编译过程与 Babel 配置

JSX 不能被浏览器直接执行,必须经过编译转换。这个过程主要由 Babel 完成。

graph LR
    A[JSX代码] --> B[Babel编译] --> C[React.createElement调用] --> D[虚拟DOM对象] --> E[真实DOM]

JSX 的编译目标是将标签转换为 React.createElement 调用(或新转换中的 jsx 函数)。以这段代码为例:

// 原始 JSX
const element = <h1 className="title">Hello, {name}!</h1>;

编译后会变成:

// 传统编译结果
const element = React.createElement(
  'h1',          // 标签类型
  { className: 'title' },  // 属性对象
  'Hello, ',     // 子节点1
  name,          // 子节点2(表达式)
  '!'            // 子节点3
);

从 React 17 开始,引入了新的 JSX 转换,无需显式导入 React:

// 新编译结果(自动导入运行时)
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx(
  'h1',
  { className: 'title', children: `Hello, ${name}!` }
);

Babel:JSX 编译的核心工具

Babel 是一个 JavaScript 编译器,负责将 JSX 转换为浏览器可执行的代码。以下是完整的配置与使用流程:

1.安装依赖

# 核心依赖
pnpm install react react-dom

# Babel 相关开发依赖
pnpm install --save-dev @babel/core @babel/cli @babel/preset-react

2.配置 Babel

创建 .babelrc 配置文件:

{
  "presets": [
    [
      "@babel/preset-react",
      {
        "runtime": "automatic"  // 使用新的 JSX 运行时(推荐)
      }
    ]
  ]
}

3.编译命令

在 package.json 中添加脚本:

{
  "scripts": {
    "build:jsx": "babel src --out-dir dist"  // 编译 src 目录到 dist
  }
}

4.执行编译

pnpm run build:jsx

新旧 JSX 转换的对比

React 17 引入的新 JSX 转换(runtime: "automatic")带来了显著改进:

特性 旧转换(runtime: "classic" 新转换(runtime: "automatic"
React 导入 必须手动导入 import React from 'react' 自动导入必要的运行时函数
打包体积 更大(包含冗余的 React.createElement 更小(仅导入所需函数)
兼容性 支持所有 React 版本 需 React 17+
自定义工厂 不支持 支持自定义 JSX 工厂函数

四、受控组件与非受控组件

在 React 中处理表单元素时,有两种核心模式:受控组件和非受控组件。它们的区别在于谁来管理表单数据

受控组件

表单数据由 React 组件的状态(useState)管理,表单元素的值通过 value 属性控制。

function ControlledInput() {
  const [value, setValue] = useState('') // 响应式状态
  const [error, setError] = useState('')
  const handleSubmit = (e) => {
    e.preventDefault()
    console.log(value, '//////');
  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="controlled-input">受控组件</label>
      <input 
        type="text" 
        value={value}
        onChange={(e) => setValue(e.target.value)}
        required
      />
      {error && <p>{error}</p>}
      <input type="submit" value="提交" />
    </form>
  )
}

非受控组件

表单数据由 DOM 自身管理,通过 ref 访问表单值。

function UncontrolledRef() {
  const inputRef = useRef(null) // 非响应式状态
  const handleSubmit = (e) => {
    e.preventDefault()
    console.log(inputRef.current.value);
  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="uncontrolled-input">非受控组件</label>
      <input
        type="text"
        id='uncontrolled-input'
        ref={inputRef}
      />
      <input type="submit" value="提交" />
    </form>
  )
}

两种模式的对比

特性 受控组件 非受控组件
数据存储 React 状态(useState DOM 元素自身
取值方式 直接从状态读取 通过 ref.current.value 读取
实时验证 容易实现(状态变化时验证) 较难(需监听 DOM 事件)
初始值 value(受控) defaultValue(仅初始化)
重渲染 输入时会触发 输入时不会触发
代码量 较多(需编写 onChange 较少(无需状态管理)
应用场景 需要实时验证、复杂表单交互、表单数据需要实时处理等交互性强的场景 简单表单、表单数据不需要实时处理、表单数据不需要实时验证等交互性不强的场景

五、什么是虚拟DOM ?

虚拟 DOM 是 JavaScript 对象,用于描述真实 DOM 的结构和属性。它是真实 DOM 的 "轻量副本",不依赖浏览器环境,仅存在于内存中。

例如,一段 JSX 对应的虚拟 DOM 如下:

// JSX 结构
const element = <li className="active">吃饭</li>;

// 对应的虚拟 DOM 对象
const vdom = {
  type: 'li',          // 标签类型
  props: {             // 属性集合
    className: 'active',
    children: '吃饭'   // 子节点
  },
  key: null            // 若指定了 key 会包含在这里
};

可以看出,虚拟 DOM 用简单的键值对描述了真实 DOM 的所有信息,但比真实 DOM 更 "轻量"(没有浏览器相关的复杂属性和方法)。

为什么需要虚拟 DOM?

真实 DOM 操作是前端性能瓶颈之一 —— 每次 DOM 更新都可能触发重排(重新计算布局)和重绘(重新绘制像素),代价高昂。

虚拟 DOM 的核心价值在于 减少真实 DOM 操作

  • 批量更新:先在内存中计算所有变化,再一次性同步到真实 DOM
  • 最小操作:通过对比新旧虚拟 DOM,只更新变化的部分(而非全量替换)
  • 跨平台兼容:虚拟 DOM 与平台无关,让 React 能同时支持浏览器、移动端(React Native)等环境

虚拟 DOM 的工作流程

React 利用虚拟 DOM 实现更新的过程可分为三步:

graph LR
  A[组件状态变化] --> B[生成新虚拟DOM]
  B --> C(Diff算法对比新旧虚拟DOM)
  C --> D[计算出最小更新范围]
  D --> E[同步变化到真实DOM]

具体拆解:

  1. 状态变化触发重新渲染:当组件的 state 或 props 变化时,React 会重新调用组件函数,生成新的虚拟 DOM。

  2. Diff 算法对比差异:React 会对比新旧两个虚拟 DOM 树,找出需要更新的部分(这个过程称为 "协调")。

  • 对比规则:先按 type(标签类型)和 key 匹配节点,再对比 props 和子节点
  • 优化策略:只做同级对比(不跨层级比较),大幅减少计算量
  1. 更新真实 DOM:React 只将差异部分同步到真实 DOM,避免全量替换。

虚拟 DOM 一定更快吗?

虚拟 DOM 的优势在于 减少不必要的真实 DOM 操作,但并非在所有场景下都比直接操作 DOM 快:

  • 对于简单的单次更新(如修改一个文本),直接操作 DOM 可能更快(省去虚拟 DOM 的计算开销)
  • 对于复杂组件或频繁更新,虚拟 DOM 的批量处理和最小更新策略能显著提升性能

React 的目标不是 "比原生 DOM 快",而是通过虚拟 DOM 提供 更一致的开发体验和可预测的更新机制,同时在大多数场景下保证良好性能。

手写 `new`、`call`、`apply`、`bind` + V8 函数调用机制解密

作者 DoraBigHead
2025年7月19日 22:30

🌀 小Dora 的 JS 修炼日记 · Day 7

“写 polyfill,不只是为了面试,而是走进 JS 引擎脑子里的最近通道。”
——dora · 准高级前端工程师


🌟 开篇:四大函数机制是怎么被 JS 引擎执行的?

  • new:你以为只是创建对象?其实背后隐藏了 V8 的 Hidden Class 分配 + 构造绑定策略
  • call / apply:你以为只是换个 this?其实是 JS 上下文切换 + Inline Cache 的血泪史
  • bind:你以为是懒执行?其实 V8 想优化都优化不了,还会禁用内联!

我们要掌握的,不只是 API 行为,而是 👇

函数机制 执行过程 V8 处理重点 性能影响
new 创建对象 + 构造函数调用 HiddenClass 状态迁移 构造对象不一致会触发 deopt
call/apply 上下文切换,立即调用 Inline Cache 路径匹配 多变 this 会失去优化
bind 延迟绑定 this 返回闭包 函数不可预测性高 V8 无法内联,优化死角

🔧 一、手写 new 操作符 + 底层流程

function myNew(Ctor, ...args) {
  const obj = Object.create(Ctor.prototype); // 模拟原型链挂载
  const result = Ctor.apply(obj, args);      // 执行构造函数
  return result instanceof Object ? result : obj;
}

🧠 底层发生了什么?

  1. 申请内存空间(堆)
  2. 创建隐藏类(Hidden Class)
  3. this 绑定到新对象
  4. 构造函数执行
  5. 返回对象

🐛 示例陷阱题:

function A() {
  this.name = '小吴';
  return { age: 26 };
}
const res = myNew(A);
console.log(res.name); // ❓ undefined or 小吴?

✅ 解析:构造函数返回对象,会覆盖 new 创建的 this,所以 name 是 undefined。


🔧 二、手写 call / apply

Function.prototype.myCall = function(ctx, ...args) {
  ctx = ctx || globalThis;
  const fn = Symbol();
  ctx[fn] = this;
  const result = ctx[fn](...args);
  delete ctx[fn];
  return result;
};

👇 题目验证理解:

function say(a, b) {
  console.log(this.name, a, b);
}
const obj = { name: '小吴' };

say.call(obj, 'Hello', 'World'); // 小吴 Hello World
say.myCall(obj, 'Hi', 'V8');     // 小吴 Hi V8

✅ call/apply 的实质:临时把函数挂在 obj 上执行,然后删除


🔧 三、手写 bind

Function.prototype.myBind = function(ctx, ...args) {
  const originFn = this;
  function bound(...restArgs) {
    const finalCtx = this instanceof bound ? this : ctx;
    return originFn.apply(finalCtx, [...args, ...restArgs]);
  }
  bound.prototype = Object.create(originFn.prototype);
  return bound;
};

🧪 测试继承 + 构造:

function Person(name) {
  this.name = name;
}
const BoundPerson = Person.myBind({});
const p = new BoundPerson('小吴');
console.log(p.name); // ✅ 小吴

📛 误区题目:

const obj = { name: 'V8' };
function foo() {
  console.log(this.name);
}
const bound = foo.bind(obj);
const newFoo = new bound();

🔍 注意:当用 new 调用 bind 结果时,this 会忽略绑定的 obj,绑定到新创建对象。


🔬 四、V8 背后的执行模型(执行栈 + Hidden Class)

  • call/apply 会触发函数上下文切换:push stack → bind this → run → pop
  • bind 返回闭包,闭包结构复杂,V8 无法内联展开,性能差
  • new 会判断构造函数是否符合 inline 构造路径(不能随意返回对象!)

Hidden Class 的影响:

function A() {
  this.x = 1;
}
const a1 = new A();
const a2 = new A();
a1.y = 2; // ⚠️ 改变 Hidden Class,性能损

🔍 五、典型面试题 + 实战题自测

题 1:手写一个组合继承函数

function Parent(name) {
  this.name = name;
}
Parent.prototype.say = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

👉 这段代码能否被 V8 优化?为何?

题 2:输出结果分析

function Foo() {}
Foo.prototype.test = function() {
  console.log(this);
};
const f = new Foo();
const test = f.test;
test();       // ❓
f.test();     // ❓
test.call(f); // ❓

题 3:bind 后还能再 call 吗?

function test() {
  console.log(this.name);
}
const obj = { name: '小吴' };
const bindFn = test.bind(obj);
bindFn.call({ name: 'V8' }); // 输出?

✅ 输出是 "小吴",因为 bind 优先级更高。


📋 📌 函数调用机制专项自查 Checklist

自查点 是否掌握
能否手写 new 实现并解释 proto 绑定
知道 call/apply 原理及内存释放机制
bind 的延迟执行及构造 this 替换规则
V8 中 call 的 Inline Cache 原理
bind 无法内联优化的底层限制
函数上下文与 this 的绑定顺序
构造函数返回对象 VS 返回原始值
new + bind 的优先级理解

💡 总结一句话

call 是函数扮演别人,apply 是换衣服一起上,
bind 是懒汉型“认主”,而 new 是新生儿找爹。

而 V8 背后,会对每个调用路径打上优化或惩罚标签,你写的每一行代码,都在引擎眼皮底下“评优评级”

数组的创建与遍历:从入门到精通,这些坑你踩过吗? 🧐

作者 然我
2025年7月18日 19:03

数组是 JavaScript 中最核心的数据结构之一,几乎所有业务场景都会用到。但你真的掌握了数组的 “创建” 和 “遍历” 吗?

一、创建数组的 5 种方法:选对起点很重要

创建数组的方式看似简单,但不同方法的行为差异可能让你踩坑。

[]数组字面量

这是最常用和直接的方式,使用方括号 [] 来创建一个数组。

```javascript
let arr = [1, 2, 3];
```

特点:

  • 简洁直观:是创建数组最常用的方式,几乎所有场景都能覆盖;
  • 支持稀疏数组:允许[1, , 3]这样的写法,中间的空槽会被视为empty(但遍历可能出问题,不推荐);
  • 直接赋值:创建时可以直接指定元素,无需额外处理。

适用场景:

  • 已知初始元素的数组(如[1, 2, 3]);
  • 临时创建空数组(如const arr = [];后续动态添加元素)。

new Array()—— 参数不同,行为大不同

new Array()的行为很 “特殊”,它的效果取决于传入的参数数量:

(1)传入 1 个数字参数:创建指定长度的空数组

const arr=new Array(5);//指定了大小
console.log(arr);
image.png

这种数组的 “空槽”(empty)和undefined不同:

  • 空槽不会被forEachmap等方法遍历(跳过);
  • for...in遍历会忽略空槽(但for...of会遍历为undefined)。
const arr2= new Array(5).fill(undefined)
arr2[8]=undefined;
console.log(arr2);
image.png
for(let key in arr2){
    console.log(key,arr2[key]);
}
image.png

(2)传入多个参数:创建包含元素的数组

const arr = new Array(1, 2, 3);
console.log(arr); // [1, 2, 3](和字面量[1,2,3]效果一致)

(3)踩坑示例:想创建长度为 5、元素为undefined的数组?

// 错误写法:new Array(5)创建的是empty,不是undefined
const arr = new Array(5);
console.log(arr[0] === undefined); // true(访问时返回undefined)
console.log(arr.includes(undefined)); // false(因为实际是empty)

// 正确写法:用fill()填充undefined
const arr2 = new Array(5).fill(undefined);
console.log(arr2.includes(undefined)); // true(真正的undefined)

适用场景:

  • 明确需要 “指定长度的空数组”(如后续用fill填充固定值);
  • 不推荐用于创建已知元素的数组(不如字面量直观)。

Array.of()—— 参数统一视为元素

Array.of()是 ES6 新增的静态方法,专门解决new Array()的参数歧义问题:无论传入多少个参数,都被视为数组元素

// 1. 传入1个数字:视为元素,不是长度
const arr1 = Array.of(5);
console.log(arr1); // [5](长度为1,元素是5)

// 2. 传入多个参数:和字面量效果一致
const arr2 = Array.of(1, 2, 3);
console.log(arr2); // [1, 2, 3]

// 3. 传入非数字参数:正常处理
const arr3 = Array.of('a', true, null);
console.log(arr3); // ['a', true, null]

new Array()的对比:

方法 Array.of(5) new Array(5)
结果 [5](元素为 5) [empty × 5](长度 5)
参数处理 所有参数都视为元素 单个数字参数视为长度

适用场景:

  • 动态创建数组(如参数数量不确定时,确保参数被当作元素);
  • 替代new Array(),避免因参数数量导致的歧义。

Array.from()—— 灵活转换的利器

Array.from()是 ES6 新增的静态方法,用于将类数组对象(如arguments、DOM 集合)或可迭代对象(如SetMap)转换为真正的数组。它还支持第二个参数(类似map的回调),可以在转换时处理元素。

(1)基本用法:转换类数组

// 类数组对象(有length和索引,但不是数组)
const likeArray = { 0: 'a', 1: 'b', length: 2 };
const arr = Array.from(likeArray);
console.log(arr); // ['a', 'b'](转为真正的数组)

(2)进阶用法:配合回调处理元素

// 创建26个大写字母数组(A-Z)
const letters = Array.from(
  new Array(26), // 长度为26的空数组
  (_, index) => String.fromCharCode(65 + index) // 65是'A'的ASCII码
);
console.log(letters); // ['A', 'B', ..., 'Z']

(3)转换可迭代对象(如 Set)

const set = new Set([1, 2, 3, 3]); // Set自动去重
const arr = Array.from(set);
console.log(arr); // [1, 2, 3]

特点:

  • 处理空槽Array.from(new Array(5))会将空槽转为undefined
  • 支持映射:第二个参数可以对每个元素做处理,相当于map的简化版;
  • 真正的数组:返回的是标准数组,支持所有数组方法。

适用场景:

  • 转换类数组对象(如 DOM 集合document.querySelectorAll('div'));
  • 生成有规律的数组(如字母表、数字序列);
  • 转换可迭代对象(如SetMap)为数组。

扩展运算符...:复制或合并数组

严格来说,扩展运算符是 “数组复制 / 合并” 的工具,但也可用于创建新数组:

// ① 复制数组
const arr = [1, 2, 3];
const copy = [...arr];

// ② 合并数组
const arr1 = [1, 2];
const arr2 = [3, 4];
const merged = [...arr1, ...arr2]; // [1, 2, 3, 4]

// ③ 转换可迭代对象(同Array.from())
const set = new Set([1, 2, 3]);
const arrFromSet = [...set]; // [1, 2, 3]

扩展:稀疏数组 vs 密集数组

创建数组时,一定要注意 “稀疏数组”(含空槽)和 “密集数组”(含实际元素)的区别:

类型 示例 forEach遍历 includes(undefined) 推荐度
稀疏数组 [1, , 3]new Array(3) 跳过空槽 false ❌ 不推荐
密集数组 [1, undefined, 3]new Array(3).fill(undefined) 遍历所有元素 true ✅ 推荐

记住一个原则:优先用数组字面量[],复杂场景用Array.from(),避免手动创建稀疏数组

二、遍历数组的 6 种方法:避开这些坑!

创建数组后,遍历是最常用的操作。不同遍历方式的特性差异很大,选错了可能导致逻辑错误。

1. 基础 for 循环(计数循环):性能王者

const arr = [1, 2, 3, 4];
for (let i = 0; i < arr.length; i++) {
  console.log(`索引${i}:`, arr[i]);
  if (arr[i] === 2) break; // 可中断循环
}

特点

  • 性能最优(直接操作索引,无额外开销);
  • 可通过break/continue控制循环;
  • 需手动维护索引(稍显繁琐)。

适用场景:对性能要求高、需要中断循环的场景。

2. forEach:简洁但无法中断

const arr = [1, 2, 3, 4];
arr.forEach((item, index) => {
  console.log(`索引${index}:`, item);
  if (item === 2) return; // 仅跳过当前次,无法中断整个循环
});

坑点

  • 不能中断returnbreak均无效(break会报错);
  • 跳过空槽:对稀疏数组[1, , 3],会跳过empty元素;
  • 不改变原数组:但可通过索引修改(arr[index] = ...)。

适用场景:简单遍历,且不需要中断循环的场景。

image.png

3. for...of:迭代器遍历,兼顾优雅与灵活

const arr = [1, 2, 3, 4];

// ① 遍历元素
for (const item of arr) {
  console.log(item);
  if (item === 2) break; // 可中断
}

// ② 同时获取索引和元素(用entries())
for (const [index, item] of arr.entries()) {
  console.log(`索引${index}:`, item);
}

entries()是数组的内置方法,返回一个迭代器对象,迭代结果为[索引, 元素]数组。

特点

  • 支持中断:可使用breakcontinue
  • 遍历所有元素:包括undefined,但对空槽(empty)视为undefined
  • 适用于所有可迭代对象:数组、SetMap、字符串等。

适用场景:大多数需要灵活控制(中断、获取索引)的遍历场景。

4. 数组方法:map/filter/find等(遍历 + 处理)

这些方法本质上是 “遍历 + 处理” 的组合,返回新数组或值:

  • map:遍历并返回新数组(一一映射);
  • filter:遍历并返回符合条件的元素组成的新数组;
  • find:遍历并返回第一个符合条件的元素;
  • some:判断是否有至少一个元素符合条件(返回布尔值);
  • every:判断是否所有元素都符合条件(返回布尔值)。
const arr = [1, 2, 3, 4];

// map:返回新数组
const doubled = arr.map(item => item * 2); // [2, 4, 6, 8]

// filter:筛选元素
const evens = arr.filter(item => item % 2 === 0); // [2, 4]

// find:查找第一个偶数
const firstEven = arr.find(item => item % 2 === 0); // 2

注意

  • 这些方法均不改变原数组
  • some/every外,均遍历所有元素(无法中途中断)。

5. reduce:累加器,处理复杂逻辑

reduce是最强大的数组方法之一,可将数组 “缩减” 为一个值(或对象、数组等):

const arr = [1, 2, 3, 4, 5];

// ① 求和
const sum = arr.reduce((prev, curr) => prev + curr, 0); // 15

// ② 求最大值
const max = arr.reduce((prev, curr) => Math.max(prev, curr), -Infinity); // 5

// ③ 分组(复杂处理)
const people = [
  { name: 'Alice', age: 20 },
  { name: 'Bob', age: 20 },
  { name: 'Charlie', age: 30 }
];
const groupedByAge = people.reduce((groups, person) => {
  const key = person.age;
  if (!groups[key]) groups[key] = [];
  groups[key].push(person);
  return groups;
}, {});
// { 20: [{name: 'Alice', ...}, {name: 'Bob', ...}], 30: [...] }

特点

  • 灵活性极高,可实现求和、分组、转换等复杂逻辑;
  • 遍历所有元素,无法中断。

适用场景:需要对数组元素进行累加、聚合、转换等复杂处理时。

6. for...in:遍历对象的 “坑王”,慎用!

const arr = [1, 2, 3];
// 给数组添加自定义属性
arr.customProp = '我是自定义属性';

// for...in会遍历所有可枚举属性(包括自定义属性和原型链属性)
for (const key in arr) {
  console.log(key); // 0, 1, 2, customProp(意外遍历到自定义属性)
}

坑点

  • 遍历所有可枚举属性:包括数组的自定义属性、原型链上的属性;
  • 索引是字符串类型key"0""1"而非数字;
  • 跳过空槽但遍历自定义属性:行为混乱。

结论永远不要用for...in遍历数组!它是为对象设计的。

❌
❌