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 状态
- 如果是宏任务:浏览器在每次修改后都尝试渲染一次。
- 如果是微任务:所有的修改都会在当前宏任务 + 微任务阶段内完成,浏览器只需要在所有微任务跑完之后,确认最终结果并渲染一次即可(自动批量处理)。
- 减少重复渲染:如果通过 promise 连续修改 10 次 dom 状态
1.4 你怎么理解浏览器的一个事件循环机制?为什么浏览器需要这个机制?它是解决什么问题吗?
为了避免因 JS 是单线程,导致在处理耗时任务(network、定时器)时不至于卡死的一套工作调度机制。
运行流程:
- 执行同步代码:将脚本中的所有同步代码全部塞进调用栈中执行,直到栈被清空。
- 清空微任务:执行所有微任务直到微任务队列清空。
- 尝试更新 UI:如果浏览器认为此时需要更新界面(通常每 16.6ms 刷新一次),它会在微任务清空后进行页面渲染。
- 执行一个宏任务:去宏任务队列里取「排在最前面」的一个任务,把它丢进调用栈去执行。执行完这一个后,再次回到第 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 函数的作用
- 强制返回 Promise。
- 允许 await。
- 非阻塞暂停:当函数执行到 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 关键字时发生了什么?
-
开辟内存空间:创建一个全新对象
{}。 -
建立原型链:将这个新对象的
__proto__指向构造函数的prototype属性。 -
绑定并执行:将构造函数内部的 this 指向这个新对象,并执行构造函数内部的代码(为新对象添加属性)。
-
决定返回值:
- 如果构造函数返回了一个对象,则返回该对象;
- 否则,默认返回第一步创建的新对象。
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 指向应该怎么判断?
-
new 绑定:如果函数是 new 调用的,this 指向新创建的那个实例对象。
-
显式绑定:如果通过 call、apply 或 bind 调用,this 指向指定的那个对象。
-
隐式绑定:如果函数作为对象的方法被调用(例如
obj.foo()),this 指向该对象 (obj)。 -
默认绑定:如果直接调用(例如
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.prototype → Object.prototype → null
总结:原型链在其中的角色
原型链是 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 引擎会:
- 第一站:看看 arr 实例自己有没有 toString?(没有)
- 第二站:顺着
__proto__找到Array.prototype,发现这里有一个 toString! - 终点:引擎直接执行这个方法,停止向下寻找。
虽然 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):
- 临时包装:发现你在对一个基础类型调方法,它会瞬间调用
new String('123')创建一个临时对象(这个对象是有方法的)。 - 调用方法:在这个临时对象上找到
.split方法并执行。 - 过河拆桥:方法执行完,拿到结果,立刻销毁这个临时对象。
5 闭包与作用域
5.1 讲一下你对闭包的理解
在大多数编程语言中,函数内部的变量像「昙花一现」:函数执行开始,变量出生;函数执行结束,变量销毁。但在 JavaScript 里,闭包赋予了变量一种**「长生不老」**的能力。
什么是闭包?(背包理论)
用最通俗的话说:闭包 = 函数 + 它的「背包」(定义时所在的环境)。
想象一个探险家(内部函数)从大本营(外部函数)出发去探险。虽然大本营任务结束「撤走」了,但探险家背后的背包里依然装满了大本营提供的物资(变量)。只要探险家还活着,这个背包就一直跟着他,里面的东西也一直有效。
function createCounter() { let count = 0; // 这里的 count 就是「大本营物资」 return function() { count++; console.log(count); };}
const counter = createCounter(); // createCounter 执行完了,按理说 count 应该没了counter(); // 输出 1counter(); // 输出 2闭包的底层逻辑:作用域链(Scope Chain)
为什么 count 没有被销毁?这涉及到 JS 的垃圾回收机制(GC)和作用域链。
-
正常情况:函数执行完毕后,如果没有人再引用它的内部变量,GC 就会把这块内存回收。
-
闭包情况:
- 外部函数返回了内部函数。
- 内部函数的作用域链中保存着外部函数变量对象的引用。
- 因为内部函数 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(同一个背包)。
解决方法:
- 用
let:let 在每次循环都会创建一个独立的块级作用域。 - 立即执行函数 (IIFE):手动制造一个闭包来「锁住」当时的 i。
总结
闭包是 JavaScript 灵活性的源泉。它让我们可以:锁住状态(计数器、计时器);隐藏秘密(私有变量);延续逻辑(柯里化、高阶函数)。
5.2 垃圾回收机制(GC)
了解 JavaScript 的垃圾回收(GC)机制,本质上是学习 V8 引擎如何管理「生存空间」。虽然我们写代码时不需要手动释放内存,但了解这套算法能帮你写出真正高性能的代码。
一、核心准则:可达性(Reachability)
垃圾回收的核心前提是判断:这个变量还有用吗?现代引擎采用可达性分析算法(不再使用简单的「引用计数」,因为解决不了循环引用问题)。
- 根(Roots):垃圾回收器维护一组根列表,包括全局对象(window/global)、当前执行栈中的局部变量、参数等。
- 标记过程:从根出发,遍历所有引用的对象,打上「存活」标记。
- 回收过程:没有被标记到的对象,就是不可达的「孤岛」,会被物理清除。
二、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 引入了:
- 增量标记(Incremental Marking):将标记工作拆成许多小块,穿插在 JS 任务之间运行。
- 并发回收(Concurrent Marking):利用辅助线程进行标记,不影响主线程执行。
- 延迟清理(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,专门解决「模板引用」的类型和初始化问题。
优点:
- 更清晰的语义:明确表示「这就是拿模板里的东西」。
- 更简单的写法:不需要在 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" 对应的字符串即可。
总结:
- 想拿函数参数:用
Parameters<typeof 变量>。 - 想拿组件提示:用
ref<InstanceType<typeof 组件>>。 - 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 会变慢,可考虑换数据结构(如链表)。