前端面试中的编程题通常不是为了刁难你的数学天赋,而是为了考察你对底层机制的理解(如事件循环、内存引用、异步)以及对日常业务中复杂数据处理的抽象能力。
为了最大化覆盖面试场景,我们将高频题目分为三大类:JavaScript 核心机制伪造(手写轮子)、业务数据结构转换、经典算法(前端高频应用)。以下为你精选的10道核心题目,它们能折射出绝大多数面试的考察点。
第一部分:JavaScript 核心机制手写
这类题目重点考察你对作用域、闭包、原型链和异步编程的理解。
1. 防抖(Debounce)与节流(Throttle)
考察重点: 闭包、高阶函数、时间机制(宏任务)。
解析: * 防抖就像是“电梯门机制”:只要一直有人进出,电梯门就不会关。只有等最后一个人进入后,且一段时间内没人再来,门才会关上并运行。用于搜索框联想。
- 节流就像是“水龙头滴水”:无论你把水龙头拧多大,水滴总是每隔固定的时间掉下来一滴。用于滚动事件监听、按钮防连击。
JavaScript
// 防抖实现
function debounce(fn, delay) {
let timer = null; // 闭包保存的定时器引用
return function (...args) {
// 每次触发时,如果之前有定时器,就打断它(电梯重新计时)
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args); // 确保 this 指向和参数正确传递
}, delay);
};
}
// 节流实现(时间戳版本,立即执行一次,后续按规定频率执行)
function throttle(fn, interval) {
let lastTime = 0; // 记录上次执行的时间
return function (...args) {
const now = Date.now();
// 如果当前时间距离上次执行时间超过了设定的间隔,则放行
if (now - lastTime >= interval) {
fn.apply(this, args);
lastTime = now; // 更新最后一次执行的时间
}
};
}
2. 带有循环引用检测的深拷贝 (Deep Clone)
考察重点: 数据类型判断、递归、内存引用(使用 WeakMap 解决循环引用避免内存泄漏)。
解析: 浅拷贝只是复制了房间的钥匙,深拷贝是造一栋完全一样的房子。难点在于,如果房子 A 的卧室里挂着一幅画,画里画着房子 A 本身(循环引用),普通的递归会陷入死循环。我们需要一个“记事本”(WeakMap)来记录“哪些房子已经建过了”,遇到建过的直接拿现成的。
JavaScript
function deepClone(obj, hash = new WeakMap()) {
// 1. 处理基本类型和 null
if (obj === null || typeof obj !== 'object') return obj;
// 2. 处理特殊对象(日期、正则)
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
// 3. 查“记事本”,如果这个对象之前拷贝过,直接返回其克隆体,防止死循环
if (hash.has(obj)) return hash.get(obj);
// 4. 创建新对象/数组(保持原型链)
const cloneObj = new obj.constructor();
hash.set(obj, cloneObj); // 记下:这个旧对象对应的克隆体是谁
// 5. 递归拷贝属性
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
cloneObj[key] = deepClone(obj[key], hash);
}
}
return cloneObj;
}
3. 手写 Promise.all
考察重点: Promise 规范、异步并发控制、状态流转。
解析: 想象你在组装一台电脑,你需要等待 CPU、主板、显卡(各个 Promise)都到货才能开始组装(resolve),但只要有一件快递在中途丢失或损坏(reject),整个装机计划就宣告失败。注意,最终零件的摆放顺序必须和购买清单的顺序一致,而不能是谁先到货谁排前面。
JavaScript
function myPromiseAll(promises) {
return new Promise((resolve, reject) => {
// 处理非数组或空迭代器的情况
if (!Array.isArray(promises)) {
return reject(new TypeError('Argument must be an iterable'));
}
if (promises.length === 0) return resolve([]);
let resolvedCount = 0;
const result = new Array(promises.length); // 预留固定长度的数组保证顺序
promises.forEach((p, index) => {
// 使用 Promise.resolve 包装,防止传入的不是 Promise 对象
Promise.resolve(p).then(
val => {
result[index] = val; // 按原索引存入结果,而非按完成时间
resolvedCount++;
// 所有任务都成功完成时,整体 resolve
if (resolvedCount === promises.length) {
resolve(result);
}
},
err => {
// 只要有一个失败,整体立即失败
reject(err);
}
);
});
});
}
4. 发布订阅模式 (EventBus)
考察重点: 设计模式、组件间解耦通信机制。
解析: 就像是 B 站的 UP 主(发布者)和粉丝(订阅者)。粉丝在 B 站(EventBus)点了关注(on),UP 主发视频时告诉 B 站(emit),B 站负责把通知推送给所有粉丝。取消关注就是 off,只看一次就是 once。
代码段
JavaScript
class EventEmitter {
constructor() {
this.events = {}; // 存储事件及其对应的回调函数列表
}
// 订阅事件
on(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
}
// 发布事件
emit(eventName, ...args) {
if (this.events[eventName]) {
// 遍历执行所有订阅了该事件的回调
this.events[eventName].forEach(cb => cb(...args));
}
}
// 取消订阅
off(eventName, callback) {
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(cb => cb !== callback);
}
}
// 单次订阅
once(eventName, callback) {
const onceWrapper = (...args) => {
callback(...args); // 执行原本的回调
this.off(eventName, onceWrapper); // 执行完立刻注销
};
this.on(eventName, onceWrapper);
}
}
第二部分:业务数据结构转换
日常前端开发重度依赖数据处理,尤其是在渲染菜单、权限树、表格展开行时。
5. 扁平数组转树形结构 (Array to Tree)
考察重点: 引用类型特性、空间换时间(构建哈希表降低时间复杂度至 )。
解析: 给定一堆打乱的人事档案卡片,上面写着自己的 ID 和直属上司的 ID(parentId)。常规做法是拿一张卡片去扫遍所有人找上司(复杂度 )。高效做法是:先按 ID 给所有人建一个目录字典,然后遍历一次,每个人只需翻一次字典找到上司,把自己挂到上司的 children 列表里。因为 JavaScript 中对象是按引用传递的,挂在字典里的变动会自动反映到最终的树中。
JavaScript
// 输入示例:[{id: 1, pid: 0}, {id: 2, pid: 1}, {id: 3, pid: 1}]
function arrayToTree(items) {
const result = []; // 存放根节点
const itemMap = {}; // 字典
// 1. 先把所有项放入字典,并初始化 children 数组
for (const item of items) {
itemMap[item.id] = { ...item, children: [] };
}
// 2. 遍历字典构建树
for (const item of items) {
const id = item.id;
const pid = item.pid;
const treeItem = itemMap[id];
if (pid === 0) {
// pid 为 0 代表是根节点,直接放入结果集
result.push(treeItem);
} else {
// 否则,找到其父节点,将自己推入父节点的 children 中
if (!itemMap[pid]) {
itemMap[pid] = { children: [] };
}
itemMap[pid].children.push(treeItem);
}
}
return result;
}
6. 版本号排序
考察重点: 字符串处理、边界条件思考(长度不一的处理)。
解析: 前端经常需要判断 App 版本(如 1.10.2 > 1.2.3)。不能直接按字符串比,因为按字典序 '10' 会排在 '2' 前面。我们需要把它们根据 . 切割成数字数组,像比较年、月、日一样逐个身位进行较量。缺位的地方自动补零(比如 1.2 和 1.2.0 相等)。
JavaScript
// arr = ['0.1.1', '2.3.3', '0.302.1', '4.2', '4.3.5', '4.3.4.5']
function sortVersions(versions) {
return versions.sort((a, b) => {
const v1 = a.split('.');
const v2 = b.split('.');
const maxLen = Math.max(v1.length, v2.length);
for (let i = 0; i < maxLen; i++) {
// 如果某一位没有,默认为 0
const num1 = parseInt(v1[i] || 0);
const num2 = parseInt(v2[i] || 0);
if (num1 > num2) return 1; // a > b,a 排后面
if (num1 < num2) return -1; // a < b,a 排前面
// 如果相等,继续循环看下一位
}
return 0; // 完全相等
});
}
第三部分:经典算法(前端高频)
前端的算法题往往侧重于 DOM 解析(树、栈)、缓存(哈希表、链表)以及字符处理。
7. 有效的括号 (Valid Parentheses)
考察重点: 栈(Stack)的后进先出(LIFO)特性。
解析: 浏览器解析 HTML 标签时就是用的类似原理(遇到 <div> 压栈,遇到 </div> 出栈)。规则很简单:遇到左括号就去“等待室”(栈)排队;遇到右括号,看看等待室最靠门的那个人是不是跟自己配对的。如果配对,那人离开;如果不配对或者等待室没人了,说明格式报错。
JavaScript
function isValid(s) {
// 奇数长度必然不匹配
if (s.length % 2 !== 0) return false;
const stack = [];
// 用 Map 存储配对关系,右括号为键,左括号为值
const map = new Map([
[')', '('],
[']', '['],
['}', '{']
]);
for (let char of s) {
if (map.has(char)) {
// 如果是右括号
// 弹出栈顶元素,如果栈为空则弹出一个伪造值 '#'
const topElement = stack.length ? stack.pop() : '#';
if (topElement !== map.get(char)) {
return false; // 栈顶元素与当前右括号不匹配
}
} else {
// 如果是左括号,压入栈
stack.push(char);
}
}
// 最后检查栈是否被清空,空了说明全匹配
return stack.length === 0;
}
8. LRU 缓存策略 (Least Recently Used)
考察重点: 哈希表与双向链表的结合。
解析: 想象你的衣柜空间有限(capacity)。你每次买新衣服(put)都挂在最显眼的位置;你每次穿某件旧衣服(get),也会把它重新挂到最显眼的位置。当衣柜满了,再买新衣服时,只能把挂在最角落(最久没被碰过)的那件扔掉。在 JavaScript 中,原生的 Map 对象非常神奇,它维护了键值对的插入顺序,我们恰好利用这个特性,避开手动写繁琐的双向链表。
JavaScript
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map(); // Map 既能 O(1) 查找,又能记录插入顺序
}
get(key) {
if (!this.cache.has(key)) return -1;
// 核心:如果有访问,把它从原位置删掉,重新插入,使其变成“最新鲜”的
const val = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, val);
return val;
}
put(key, value) {
// 如果已存在,先删除,再重新 set,更新新鲜度
if (this.cache.has(key)) {
this.cache.delete(key);
}
this.cache.set(key, value);
// 如果超出容量限制,删除最老的元素
if (this.cache.size > this.capacity) {
// map.keys().next().value 能获取到 Map 中最先插入的键(最老的)
this.cache.delete(this.cache.keys().next().value);
}
}
}
9. 无重复字符的最长子串
考察重点: 滑动窗口(双指针)、哈希集合(Set)。
解析: 就像一条贪吃蛇在字符串上爬。蛇头(右指针)不断往前探,只要没遇到吃过的字母(不在 Set 里),就吞进去变长。如果碰到了吃过的字母,蛇尾(左指针)就开始往外拉屎排空,直到把那个重复的字母排掉为止。记录下这条蛇达到过的最大长度。
JavaScript
function lengthOfLongestSubstring(s) {
const set = new Set(); // 记录窗口内出现过的字符
let left = 0; // 窗口左边界
let maxLength = 0; // 历史最大窗口长度
for (let right = 0; right < s.length; right++) {
// 如果遇到重复字符,左边界开始向右收缩,直到移出重复字符
while (set.has(s[right])) {
set.delete(s[left]);
left++;
}
// 右边界对应的新字符加入窗口
set.add(s[right]);
// 更新最大长度记录
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
10. 二叉树的层序遍历 (Level Order Traversal)
考察重点: 队列(Queue)、广度优先搜索(BFS)。
解析: 前端在进行 DOM 树节点遍历查询时,这是一种极常用的思维。这就好比公司的汇报层级:先拉个群,把大老板拉进来(Level 1);然后大老板发话,把各个部门总监拉进来,大老板退群(Level 2);总监再把各自的经理拉进来,总监退群(Level 3)。每次我们在群里清点当前人数(当前层级的节点数),就是这一层的名单。
JavaScript
// 树节点结构: { val: 1, left: Node, right: Node }
function levelOrder(root) {
if (!root) return [];
const result = [];
const queue = [root]; // 队列初始化放入根节点
while (queue.length > 0) {
const levelSize = queue.length; // 记录当前层的节点数量(非常关键)
const currentLevel = []; // 存放当前层的节点值
// 一次性把当前层的所有节点全部处理完
for (let i = 0; i < levelSize; i++) {
const node = queue.shift(); // 队首出列
currentLevel.push(node.val);
// 如果有下一级,将其加入队伍后面等待下一轮处理
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
result.push(currentLevel);
}
return result;
}
这 10 道题目并非独立的死记硬背的考点。从事件机制的底层实现(防抖/发布订阅)到数据状态流转(Promise/深拷贝),再到数据结构的解析组合(树/链表/窗口),它们共同构建了一个前端工程师所需的核心逻辑基础。当你能将这些问题与实际工作中的场景建立映射关联时,应对面试往往水到渠成。