6151 字
31 分钟
Js
2026-02-19
2026-02-19
统计加载中...

Js#

1 异步与事件循环#

1.1 JS/TS 的异步机制是怎么实现的#

经过事件循环将同步任务和异步任务区分开,并将宏任务与微任务分别放入任务队列,通过调用栈对同步代码和任务队列中异步代码的执行控制。

1.2 JS 的宏任务和微任务#

宏任务:

  • setTimeout / setInterval
  • setImmediate (Node.js)
  • I/O 操作
  • UI 渲染(浏览器)
  • script 标签整体代码

微任务:

  • Promise.then / Promise.catch / Promise.finally
  • queueMicrotask()
  • MutationObserver (浏览器)
  • process.nextTick (Node.js,优先级最高)

1.3 为什么设计上要先清空微任务,再做宏任务?这种设计逻辑是什么?#

本质是优先级分层的调度模式,为了让程序状态的更新在视觉呈现和新任务执行之前保持一致,保证异步代码能以最快的速度得到处理,同时避免了不必要的渲染开销。

  • 保证逻辑的连续和实时 微任务一般是通过正在执行的代码产生的,是当前任务的后续延伸。

    • 逻辑闭环:如果将微任务放到下一个宏任务之后执行,那么程序的状态可能在空档期被下一个宏任务(点击、定时器等)修改,导致逻辑断层。
    • 尽快反馈:微任务的目的就是为了让异步回调尽可能快的执行。
  • 优化渲染性能 浏览器的渲染一般发生在微任务队列清空之后,下一个宏任务开始之前。

    • 减少重复渲染:如果通过 promise 连续修改 10 次 dom 状态
      • 如果是宏任务:浏览器在每次修改后都尝试渲染一次。
      • 如果是微任务:所有的修改都会在当前宏任务 + 微任务阶段内完成,浏览器只需要在所有微任务跑完之后,确认最终结果并渲染一次即可(自动批量处理)。

1.4 你怎么理解浏览器的一个事件循环机制?为什么浏览器需要这个机制?它是解决什么问题吗?#

为了避免因 JS 是单线程,导致在处理耗时任务(network、定时器)时不至于卡死的一套工作调度机制。

运行流程:

  1. 执行同步代码:将脚本中的所有同步代码全部塞进调用栈中执行,直到栈被清空。
  2. 清空微任务:执行所有微任务直到微任务队列清空。
  3. 尝试更新 UI:如果浏览器认为此时需要更新界面(通常每 16.6ms 刷新一次),它会在微任务清空后进行页面渲染。
  4. 执行一个宏任务:去宏任务队列里取「排在最前面」的一个任务,把它丢进调用栈去执行。执行完这一个后,再次回到第 2 步(清空微任务)。

1.4.1 UI 渲染是宏任务还是微任务?#

既不是微任务也不是宏任务,是事件循环中一个独立的阶段。浏览器并不保证每次事件循环都立即触发渲染,会根据屏幕刷新率来决定,如果一个循环时间过短,会好几个循环之后才进行一次真正的像素绘制。

1.4.2 事件循环在浏览器跟 Node 上有区别吗?#

  • Node 相比浏览器的宏任务/微任务阶段,有复杂的 Timers、I/O、Check、Close 阶段。
  • 有特有的 process.nextTick、setImmediate。
  • process.nextTick 拥有最高优先级。

1.5 async 函数加了这个标识有什么区别?如果 await 一个普通方法,会怎样?#

async 函数的作用#

  1. 强制返回 Promise。
  2. 允许 await。
  3. 非阻塞暂停:当函数执行到 await,会立即暂停执行该函数后续代码,将后续代码塞进微任务队列,然后把主线程交还出去。
async function showHandover() {
console.log("2. 进入 async 函数,开始执行同步部分");
await console.log("3. 执行 await 后面的表达式(这也是同步的)");
// --- 重点:下面这一行及之后的代码,被塞进微任务队列,主线程此时交还 ---
console.log("5. 异步部分执行:主线程忙完了别的,又回到这里了");
}
console.log("1. 主线程开始执行同步代码");
showHandover();
console.log("4. 证明:主线程被交还了!async 函数还没跑完,我就执行了");

await 普通值#

如果 await 的不是 Promise,而是一个普通的同步函数或者一个基本类型值(如 await syncTask()),JavaScript 会自动将其隐式包装成一个立即成功的 Promise。

注意:

  • 同步执行:await 之后的那行代码,会被放到当前微任务队列的末尾。
  • 现象:意味着 await 之后的代码永远不会在当前同步轮次里立即执行。
async function test() {
console.log('开始');
const result = await 123; // 等同于 await Promise.resolve(123)
console.log(result);
console.log('结束');
}

2 Promise 相关#

2.1 实现一个 Promise.all#

Promise.myAll = function (promises) {
return new Promise((res, rej) => {
if (!Array.isArray(promises)) {
return rej(new TypeError('promise must be an array'));
}
let resCount = 0;
let results = [];
let len = promises.length;
if (!len) {
return res([]);
}
promises.forEach((promise, index) => {
Promise.resolve(promise).then(
val => {
resCount++;
results[index] = val;
if (resCount === len) {
return res(results);
}
},
e => rej(e)
);
});
});
};

2.2 实现一个 Promise.allSettled#

Promise.myAllSettled = function (promises) {
return new Promise(res => {
let completeCount = 0;
let results = [];
let len = promises.length;
if (!len) return res([]);
promises.forEach((promise, index) => {
Promise.resolve(promise)
.then(
val => {
results[index] = { status: 'fulfilled', val };
},
reason => {
results[index] = { status: 'rejected', reason };
}
)
.finally(() => {
completeCount++;
if (completeCount === len) {
return res(results);
}
});
});
});
};

2.3 Promise.all 里有三个 Promise:P1(100ms, success), P2(200ms, fail), P3(300ms, success)。请问整个 Promise.all 会执行多久?#

短路机制:发现失败,立刻宣布整个任务失败。

2.4 Promise 的错误捕获#

  • 链式捕获.then(success, fail).catch(fail)。建议用 .catch(),因为它可以捕获前面所有 .then 里的报错。
  • 全局捕获(浏览器)unhandledrejection 事件。
window.addEventListener('unhandledrejection', (event) => {
console.warn('捕获到未处理的 Promise 异常:', event.reason);
});
  • Async/Await 下的捕获:必须配合 try...catch,否则报错会直接抛向外部。

2.5 什么是 Promise?它解决了什么?又创造了什么新问题?#

什么是 Promise?#

Promise 本质上是一个值的容器,这个值在现在可能还不可用,但在未来某个时刻会交付。它是一个状态机。

解决了什么?#

  • 回调地狱(Callback Hell):把嵌套的异步逻辑拉平为链式调用。
  • 控制权反转:以前你传回调给第三方库,不知道它会调几次;现在它返还给你一个 Promise,状态改变由 Promise 规范严格保证。

它创造了什么新问题?(重点)#

  • Promise 地狱:虽然解决了回调嵌套,但如果不善用链式调用,代码会变成一堆 .then() 的层层嵌套,可读性依然很差。
  • 错误「静默」:如果你忘记写 .catch(),Promise 内部的报错可能被「吞掉」,导致程序在静默中崩溃。
  • 调试困难:传统的断点调试在异步链条中非常痛苦,堆栈信息有时无法准确定位到最初报错的业务代码。
  • 内存开销:每一个 .then() 都会创建一个新的 Promise 实例,在极端高性能要求的场景下,这比纯回调略重。

2.6 Promise 里面的静态方法#


3 函数与 this#

3.1 JS 的箭头函数和 function 函数,这两种函数有什么不同?#

3.2 执行 new 关键字时发生了什么?#

  1. 开辟内存空间:创建一个全新对象 {}

  2. 建立原型链:将这个新对象的 __proto__ 指向构造函数的 prototype 属性。

  3. 绑定并执行:将构造函数内部的 this 指向这个新对象,并执行构造函数内部的代码(为新对象添加属性)。

  4. 决定返回值

    • 如果构造函数返回了一个对象,则返回该对象;
    • 否则,默认返回第一步创建的新对象。
function myNew(constructor, ...args) {
const obj = Object.create(constructor.prototype);
const result = constructor.apply(obj, args);
const isObject = typeof result === 'object' && result !== null;
const isFunction = typeof result === 'function';
return (isObject || isFunction) ? result : obj;
}

3.3 普通函数的 this 指向应该怎么判断?#

  1. new 绑定:如果函数是 new 调用的,this 指向新创建的那个实例对象。

  2. 显式绑定:如果通过 call、apply 或 bind 调用,this 指向指定的那个对象。

  3. 隐式绑定:如果函数作为对象的方法被调用(例如 obj.foo()),this 指向该对象 (obj)。

  4. 默认绑定:如果直接调用(例如 foo()):

    • 非严格模式:指向全局对象 (window 或 global)。
    • 严格模式 ('use strict'):指向 undefined。

3.4 如果把一个普通函数作为 DOM 点击事件的回调,执行时内部的 this 指向是什么?#

  • 普通函数:当你把普通函数作为 DOM 事件的回调,执行时 this 会指向触发事件的那个 DOM 元素(等同于 event.currentTarget)。
btn.addEventListener('click', function() {
console.log(this); // 输出: <button> 元素本身
});
  • 箭头函数:由于箭头函数没有自己的 this,它会去抓取定义它时的外层环境。通常在全局定义时,它会指向 window。

4 原型链与对象#

4.1 基于你的理解,谈一下你对于 JavaScript 里面的原型链的理解#

在 JavaScript 里,几乎每个对象都有一个隐藏的「靠山」,叫 [[Prototype]](在代码里通常体现为 __proto__)。

  • 逻辑:当你找一个对象的属性(比如 split)时,如果对象自己没有,它就会顺着 __proto__ 去它的「爸爸」那里找;如果「爸爸」也没有,就去「爷爷」那里找。
  • 终点:这条链条的尽头是 Object.prototype,而 Object.prototype.__proto__ 是 null。

原型链的等式关系:

instance.__proto__ === Constructor.prototype

例如:字符串包装对象的原型链: '123' 的临时对象 → String.prototypeObject.prototypenull

总结:原型链在其中的角色

原型链是 JS 实现共享的一种方式。如果没有原型链,我们每创建一个字符串,都要给它分配一套 split、slice、indexOf 等方法,内存会瞬间爆炸。有了原型链,成千上万个字符串只需要通过「装箱」机制,统一去 String.prototype 这个「公共仓库」里取用方法即可。

属性遮蔽#

在 JavaScript 的原型链世界里,有一个非常简单的生存法则:「近水楼台先得月」。

现象:明明名字一样,长得却不同

分别对一个普通对象和一个数组调用 .toString()

const obj = { name: 'Gemini' };
const arr = [1, 2, 3];
console.log(obj.toString()); // "[object Object]"
console.log(arr.toString()); // "1,2,3"

按照「万物皆对象」,它们最终都会指向 Object.prototype。既然 Object.prototype 上已经有一个 toString 了,为什么数组表现得这么「叛逆」?这就是属性遮蔽

真相:什么是属性遮蔽?

当你调用 arr.toString() 时,JS 引擎会:

  1. 第一站:看看 arr 实例自己有没有 toString?(没有)
  2. 第二站:顺着 __proto__ 找到 Array.prototype,发现这里有一个 toString!
  3. 终点:引擎直接执行这个方法,停止向下寻找。

虽然 Object.prototype 确实也有一个 toString,但因为它在原型链的更深层(爷爷辈),被 Array.prototype(爸爸辈)给「遮住」了。

为什么要「遮蔽」?(定制化需求)

这是面向对象设计中的多态(Polymorphism):

  • Object.prototype.toString:最原始的,负责告诉你「这玩意儿是个什么类型的对象」。
  • Array.prototype.toString:数组重写了这个方法,用来展现数组里的内容(把元素用逗号连起来)。

同理,Function.prototype.toString 也会遮蔽掉 Object 的方法,用来打印出函数的源代码。

降维打击:如何找回「失踪」的原始方法?

Object.prototype.toString.call() 绕过数组的遮蔽:

const arr = [1, 2, 3];
console.log(Object.prototype.toString.call(arr)); // "[object Array]"

这也是前端开发中判断数据类型最精准的方案。

总结

  • 原型链是一条向下的搜索线。
  • 属性遮蔽是在搜索线上提前截获了请求。
  • 这套机制让 JS 既能保持结构的统一(都有 toString),又能实现功能的灵活(数组和对象表现不同)。

4.2 「JavaScript 万物皆对象」与基础类型:字符串 ‘123’ 为什么能调方法?#

字符串 '123' 是基础类型(Primitive),它本身确实只是内存里的一串纯粹的数据,没有属性,也没有方法。但当写下 '123'.split('') 时,JS 引擎在后台会做「装箱」(Autoboxing):

  1. 临时包装:发现你在对一个基础类型调方法,它会瞬间调用 new String('123') 创建一个临时对象(这个对象是有方法的)。
  2. 调用方法:在这个临时对象上找到 .split 方法并执行。
  3. 过河拆桥:方法执行完,拿到结果,立刻销毁这个临时对象。

5 闭包与作用域#

5.1 讲一下你对闭包的理解#

在大多数编程语言中,函数内部的变量像「昙花一现」:函数执行开始,变量出生;函数执行结束,变量销毁。但在 JavaScript 里,闭包赋予了变量一种**「长生不老」**的能力。

什么是闭包?(背包理论)#

用最通俗的话说:闭包 = 函数 + 它的「背包」(定义时所在的环境)

想象一个探险家(内部函数)从大本营(外部函数)出发去探险。虽然大本营任务结束「撤走」了,但探险家背后的背包里依然装满了大本营提供的物资(变量)。只要探险家还活着,这个背包就一直跟着他,里面的东西也一直有效。

function createCounter() {
let count = 0; // 这里的 count 就是「大本营物资」
return function() {
count++;
console.log(count);
};
}
const counter = createCounter(); // createCounter 执行完了,按理说 count 应该没了
counter(); // 输出 1
counter(); // 输出 2

闭包的底层逻辑:作用域链(Scope Chain)#

为什么 count 没有被销毁?这涉及到 JS 的垃圾回收机制(GC)和作用域链。

  • 正常情况:函数执行完毕后,如果没有人再引用它的内部变量,GC 就会把这块内存回收。

  • 闭包情况

    1. 外部函数返回了内部函数。
    2. 内部函数的作用域链中保存着外部函数变量对象的引用。
    3. 因为内部函数 counter 还在被全局变量引用,所以它引用的那个「背包」(外部作用域)就不能被回收。

闭包能解决什么问题?#

  • A. 封装私有变量(模拟私有属性) 在 JS 还没有原生 # 私有字段之前,闭包是实现「别人改不了我的数据」的常用方法。
function User(name) {
let _password = '123'; // 私有变量
return {
getName: () => name,
checkPassword: (pw) => pw === _password
};
}
const me = User('Gemini');
console.log(me._password); // undefined (拿不到!)
  • B. 延续局部变量的寿命 最经典的场景就是给一排按钮绑定点击事件,或者是防抖(Debounce)/ 节流(Throttle)函数的实现。

闭包带来的「新麻烦」#

内存消耗(Memory Overhead) 因为闭包会让本该销毁的变量常驻内存,如果大量、滥用闭包,或者闭包里的东西很大,会导致内存占用过高。 注意:闭包本身不是内存泄漏。只有当你无意识地留下了闭包引用,导致内存无法释放时,才叫泄漏。

性能开销 比起普通函数,闭包在处理速度和内存消耗上稍微重一点,因为多了一层作用域链的查找。在现代 V8 引擎面前,这点开销在绝大多数场景下可以忽略不计。

经典面试:循环中的闭包#

在 for 循环里用 var 定义变量并设置异步回调,会遇到闭包的「背叛」:

for (var i = 1; i <= 3; i++) {
setTimeout(() => console.log(i), 100); // 全部输出 4
}

原因:这 3 个回调函数共享了同一个 i(同一个背包)。

解决方法:

  1. let:let 在每次循环都会创建一个独立的块级作用域。
  2. 立即执行函数 (IIFE):手动制造一个闭包来「锁住」当时的 i。

总结

闭包是 JavaScript 灵活性的源泉。它让我们可以:锁住状态(计数器、计时器);隐藏秘密(私有变量);延续逻辑(柯里化、高阶函数)。

5.2 垃圾回收机制(GC)#

了解 JavaScript 的垃圾回收(GC)机制,本质上是学习 V8 引擎如何管理「生存空间」。虽然我们写代码时不需要手动释放内存,但了解这套算法能帮你写出真正高性能的代码。

一、核心准则:可达性(Reachability)#

垃圾回收的核心前提是判断:这个变量还有用吗?现代引擎采用可达性分析算法(不再使用简单的「引用计数」,因为解决不了循环引用问题)。

  1. 根(Roots):垃圾回收器维护一组根列表,包括全局对象(window/global)、当前执行栈中的局部变量、参数等。
  2. 标记过程:从根出发,遍历所有引用的对象,打上「存活」标记。
  3. 回收过程:没有被标记到的对象,就是不可达的「孤岛」,会被物理清除。

二、V8 的王牌:分代回收(Generational Collection)#

V8 引擎将堆内存划分为新生代老生代。这是基于「绝大多数对象在分配后不久就会变得不可达」的弱代假说。

新生代(The Nursery)

  • 特点:容量小(1MB - 8MB),存放生命周期短的对象。
  • 算法:Scavenge (Semi-space)——内存平分为使用区 (From-space) 和空闲区 (To-space);新对象入使用区,快满时将存活对象拷贝到空闲区,然后角色互换。
  • 优势:只处理活对象,速度极快。

老生代(The Attic)

  • 特点:容量大,存放从新生代「晋升」过来的对象(经历过两次 GC 依然存活)。
  • 算法:Mark-Sweep(标记清除)& Mark-Compact(标记整理)——后者将活对象向一端移动,减少内存碎片。

三、性能优化:如何避免「全线停顿」?#

早期 GC 是 Stop-the-world 的。V8 引入了:

  1. 增量标记(Incremental Marking):将标记工作拆成许多小块,穿插在 JS 任务之间运行。
  2. 并发回收(Concurrent Marking):利用辅助线程进行标记,不影响主线程执行。
  3. 延迟清理(Lazy Sweeping):标记完成后,根据需要逐个清理,不急着立即删除所有垃圾。

四、为什么闭包和 GC 会产生冲突?#

闭包本质上是人为地制造「可达性」。

  • 正常情况:函数执行完,局部变量不可达 → GC 回收。
  • 闭包情况:返回的内部函数依然持有对局部变量的引用;只要这个内部函数还活着(被全局变量或 DOM 引用),那个局部变量就永远是「可达」的,会从新生代挺进老生代,一直占用内存。

五、如何写出 GC 友好的代码?#

  • 及时解除引用:如果一个大数据结构用完了,手动设置为 null。
  • 避免在循环中创建对象:这会频繁触发新生代的 Scavenge,造成 CPU 抖动。
  • 慎用全局变量:全局变量是 GC 树的根,它们引用的任何东西都不会被回收。
  • 弱引用 WeakMap / WeakSet:如果只想在对象存在时关联一些数据,而不影响它被回收,请使用这两个 API。

6 模块规范#

6.1 CommonJS 和 ES Module 的区别#

可以从运行机制、语法特性、加载方式三个维度来拆解。

动态 vs 静态(最本质的区别)#

  • CommonJS 是动态的:你可以在 if 语句或者函数里写 require()。因为它是在代码执行到那一行时,才去同步读取并执行模块。
  • ESM 是静态的:import 必须写在顶层(除了动态 import())。JS 引擎在代码执行前就会先扫描所有的 import,构建模块依赖图。这也是为什么 ESM 能做 Tree Shaking(剔除无用代码),而 CJS 很难做到的原因。

拷贝 vs 引用(最容易掉坑的区别)#

  • CJS 导出的是值的拷贝:一旦你导出了一个数字或字符串,即便原模块内部改了这个值,外部引用的值也不会变(除非导出的对象)。
  • ESM 导出的是值的引用:它像是一个只读的「窗口」。如果原模块内部修改了变量,外部通过 import 拿到的值会实时同步更新。

6.2 TypeScript 是基于哪个模块规范实现的?#

TypeScript 在语法层面是基于 ES Module 实现的,但在底层实现和输出上是「全能选手」。

语法:向 ESM 看齐#

TypeScript 官方推荐并默认使用 ESM 语法(即使用 import 和 export),因为 ESM 是 ECMAScript 的正式标准。

输出:取决于 tsconfig.json#

虽然你写的是 import/export,但 tsc 会根据配置将代码「翻译」成不同的规范。

{
"compilerOptions": {
"module": "CommonJS"
}
}
  • 开发 Node.js 后端项目,通常配置为 CommonJS。
  • 开发现代前端应用,通常配置为 ESNext 或 ES2020。

TS 的特有处理(import = require)#

为了兼容 CJS,TypeScript 有语法:import fs = require('fs');。在现代 TS 开发中已不推荐,但在一些老旧的 Node.js 库中依然能见到。


7 TypeScript#

7.1 TS 相关的内置方法#

  • Partial:把对象所有属性变成可选。
  • Required:把所有属性变成必选(和 Partial 相反)。
  • Readonly:所有属性变为只读。
  • Pick<T, Keys>:从一个大类型里,挑选出几个属性。例如 Pick<User, 'name' | 'age'>
  • Omit<T, Keys>:从一个大类型里,排除掉几个属性。
  • Record<Keys, Type>:定义一个对象的键值对类型。Record<string, number> 相当于 { [key: string]: number }

7.2 如何提取函数入参/返回值的类型?(同事没导出类型时)#

提取入参类型:使用 Parameters<T>

// 同事的文件里只导出了函数,没导出类型
const addCustomer = (name: string, age: number, info: { address: string }) => { /* ... */ };
// 在你的代码里:
type AddCustomerArgs = Parameters<typeof addCustomer>;
// 结果:[string, number, { address: string }]
type FirstArg = AddCustomerArgs[0]; // string

提取返回值类型:使用 ReturnType<T>

type ApiResult = ReturnType<typeof addCustomer>;

7.3 ref 获取组件时,点 value 没有类型提示怎么解决?#

在 Vue 中使用 ref 绑定组件时,默认是 ref(null),导致点 value 时没有类型提示。

解决方案:InstanceType

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import MyChildComponent from './MyChildComponent.vue';
const childRef = ref<InstanceType<typeof MyChildComponent> | null>(null);
onMounted(() => {
childRef.value?.someMethod(); // 此时有类型提示
});
</script>
<template>
<MyChildComponent ref="childRef" />
</template>

原理解析:

  • typeof MyChildComponent 拿到的是组件的定义(构造函数)。
  • InstanceType<...> 拿到的是该构造函数构造出来的实例。

useTemplateRef(Vue 3.5+ 新特性)#

Vue 3.5 引入的 API,专门解决「模板引用」的类型和初始化问题。

优点:

  1. 更清晰的语义:明确表示「这就是拿模板里的东西」。
  2. 更简单的写法:不需要在 ref() 里写 null,也不需要复杂的泛型组合。

对比:

旧写法:

const inputRef = ref<HTMLInputElement | null>(null);

Vue 3.5 新写法:

import { useTemplateRef } from 'vue';
const inputElement = useTemplateRef<HTMLInputElement>('my-input'); // 'my-input' 对应模板里的 ref="my-input"
onMounted(() => {
inputElement.value?.focus();
});

使用 useTemplateRef 可以自定义变量名,只需传入与模板中 ref="xxx" 对应的字符串即可。

总结:

  1. 想拿函数参数:用 Parameters<typeof 变量>
  2. 想拿组件提示:用 ref<InstanceType<typeof 组件>>
  3. Vue 3.5+:可直接用 useTemplateRef,更简洁。

8 其他基础#

8.1 为什么 JavaScript 是单线程?为什么不能多线程?#

因为 JavaScript 诞生的初衷是**「浏览器脚本语言」**,主要任务是处理用户交互和操作 DOM。

核心矛盾:DOM 的一致性#

如果 JS 是多线程的:线程 A 正在把按钮背景色改成红色,线程 B 同时正在把这个按钮从页面上删掉——浏览器该听谁的?多线程需要引入复杂的锁和信号量机制。

避免死锁与复杂性#

多线程编程有死锁、竞态条件、上下文切换开销等问题,对早期网页开发者不友好。 JavaScript 选择了「单线程 + 事件循环」:保持逻辑简单;把耗时操作(网络、定时器)交给宿主环境,自己只负责排队处理结果(非阻塞)。

现在的进化#

虽然核心是单线程,但现在有 Web Workers。子线程严禁操作 DOM,只能通过消息和主线程通信,维持「主线程控制 UI」的底线。

8.2 给数组前面插入一个元素该怎么实现?#

unshift() —— 最直接(修改原数组)#

const arr = [2, 3, 4];
arr.unshift(1);
console.log(arr); // [1, 2, 3, 4]

扩展运算符 … —— 最优雅(不修改原数组)#

const arr = [2, 3, 4];
const newArr = [1, ...arr];
console.log(newArr); // [1, 2, 3, 4]

splice() —— 最全能#

在开头插入:从索引 0 开始,删除 0 个,插入新元素。

const arr = [2, 3, 4];
arr.splice(0, 0, 1); // 在索引0的位置插入1

性能注意#

  • push(末尾加):复杂度 O(1)。
  • unshift(开头加):复杂度 O(n)。数组很大时频繁 unshift 会变慢,可考虑换数据结构(如链表)。