最近的几次面试、笔试都被考到了相关js语法,算法也不能少....
自建一个文件夹,保存js常考笔试面试题:
包含es6、this绑定、异步执行顺序分析
面试官“虐我千百遍”?JS/ES6/Vue 高频考点,我帮你掰碎了!从原型链到深拷贝,再战大厂!
以下是为你量身打造的 优快云 技术贴!!!!
引言:最近被面试官“教育”了,但这份总结能让你反杀!
最近几场面试和笔试下来,我真是感触颇深。原以为自己 JS 基础还行,结果在原型链、this 绑定、事件循环、乃至一些手写算法和 Vue 细节上,被问得体无完肤。尤其是那些看起来“傻瓜式”的问题,背后往往藏着对你底层理解的深度考察。
痛定思痛,我决定把这些高频、易混淆、甚至容易掉坑的知识点彻底掰碎了,整理成一份“反杀”面试官的笔记。这份笔记不仅有基础语法的深入剖析,还有前端框架中常见的概念(比如 Virtual DOM、Vue 滚动判断),甚至连手写代码环节的节流、快排、深拷贝都给你安排得明明白白。
如果你也正被这些问题困扰,或者想在面试中脱颖而出,那么这份笔记,就是你的“武功秘籍”!
话不多说,我们直接开干!
1. 抽丝剥茧原型链:new Parent()
到底做了什么?
原型链是 JS 的基石,但往往也是最容易绕晕的地方。面试官总喜欢通过继承的方式来考察你对原型链的理解。我们来看一个经典例子:
JavaScript
function Parent(name){
console.log(`执行 Parent 构造函数:${name}`); // 新增:更清晰的日志
this.name = name
}
function Child(name){
console.log(`执行 Child 构造函数:${name}`); // 新增:更清晰的日志
// 关键点:借用 Parent 构造函数,实现属性继承
Parent.call(this, name)
}
// 核心:让 Child 的原型指向 Parent 的实例
// 这句代码的副作用,往往是面试的切入点!
Child.prototype = new Parent(); // 注意:这里会执行 Parent 构造函数!
// 修正 Child.prototype.constructor 指向
// 否则 Child.prototype.constructor 会指向 Parent
Child.prototype.constructor = Child; // 推荐:保持 constructor 正确指向
var child1 = new Child("child1"); // 再次执行 Child 构造函数,内部调用 Parent.call()
console.log("----------------------- 打印结果分析 -----------------------");
console.log("child1:", child1);
console.log("Child.prototype:", Child.prototype);
console.log("child1.__proto__ === Child.prototype ? ", child1.__proto__ === Child.prototype);
console.log("Child.prototype.__proto__ === Parent.prototype ? ", Child.prototype.__proto__ === Parent.prototype);
console.log("child1 instanceof Child ? ", child1 instanceof Child); // true
console.log("child1 instanceof Parent ? ", child1 instanceof Parent); // true
console.log("Child.prototype instanceof Parent ? ", Child.prototype instanceof Parent); // true
// 考点延伸:
// 1. Child.prototype = new Parent() 的问题:
// - 调用了一次 Parent 构造函数,`console.log('执行 Parent 构造函数:undefined')` 会输出。
// - Parent 实例上的属性(如 name)会被 Child.prototype 共享,可能导致不必要的内存开销或数据污染。
// - 如果 Parent 构造函数有副作用(比如操作 DOM),这会是个问题。
// 2. 更好的继承方式:
// - Object.create(Parent.prototype):避免调用 Parent 构造函数,只继承原型。
// - ES6 Class extends:语法糖,内部实现更优雅。
解析:
-
Child.prototype = new Parent();
:这是最容易踩坑的地方!当你执行这行代码时,Parent
构造函数会立即执行一次。它会像普通函数一样被调用,只不过此时的this
指向一个新的空对象,并最终成为Child.prototype
的值。这意味着Parent
构造函数里的console.log(name)
会打印undefined
(因为此时name
未传入),并且Child.prototype
上会有一个name
属性(值为undefined
)。 -
Parent.call(this, name)
:这句代码在Child
构造函数内部执行,目的是借用Parent
的构造函数,把Parent
构造函数里的属性(如this.name = name
)复制到Child
的实例上。这里的this
绑定到了child1
实例。 -
最终结果:
console.log("执行 Parent 构造函数:undefined")
:由Child.prototype = new Parent()
触发。console.log("执行 Child 构造函数:child1")
:由new Child("child1")
触发。console.log("执行 Parent 构造函数:child1")
:由Parent.call(this, name)
触发。child1
实例上会有一个name: "child1"
属性。Child.prototype
上会有一个name: undefined
属性,这是new Parent()
的副作用。
面试官可能会追问:
Child.prototype = new Parent()
有什么问题?如何改进?(答案见代码注释)Parent.call(this, name)
是什么?它解决了什么问题?__proto__
和prototype
的关系是什么?instanceof
运算符的原理是什么?
2. 事件流的“三体”:冒泡、捕获与第三个参数的奥秘
前端事件机制是 DOM 编程的基础,面试官经常会通过一个简单的 HTML 结构,让你分析事件的触发顺序。这里最核心的就是 addEventListener
的第三个参数。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>事件机制深度解析</title>
<style>
body { padding: 50px; background-color: #f0f2f5; }
#parent {
height: 200px; /* 调整高度以更好地观察 */
width: 400px;
background-color: cadetblue;
padding: 20px; /* 增加内边距方便点击子元素时不直接点击父元素 */
box-sizing: border-box;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
#child {
height: 100px; /* 调整高度 */
width: 200px;
background-color: bisque;
cursor: pointer;
border-radius: 6px;
}
</style>
</head>
<body>
<div id="parent">
<div id="child" onclick="console.log('--- HTML 内联 click (冒泡阶段) ---')">
</div>
</div>
<script>
// 捕获阶段事件监听 (第三个参数为 true)
document.body.addEventListener("click", function () {
console.log('1. 捕获阶段: body click');
}, true);
document.getElementById('parent').addEventListener("click", function () {
console.log('2. 捕获阶段: parent click');
}, true);
document.getElementById('child').addEventListener("click", function () {
console.log('3. 捕获阶段: child click');
}, true);
// DOM Level 0 事件 (onclick) - 只能注册一个,且只在冒泡阶段执行
document.getElementById('parent').onclick = function () {
console.log('--- 4. DOM Level 0: parent self click (冒泡阶段) ---');
};
// 冒泡阶段事件监听 (第三个参数为 false 或省略)
document.getElementById('child').addEventListener("click", function () {
console.log('5. 冒泡阶段: child click');
}, false);
document.getElementById('parent').addEventListener("click", function () {
console.log('6. 冒泡阶段: parent click');
}, false);
document.body.addEventListener("click", function () {
console.log('7. 冒泡阶段: body click');
}, false);
// DOM Level 0 事件 (onclick) - 只能注册一个,且只在冒泡阶段执行
document.body.onclick = function () {
console.log('--- 8. DOM Level 0: body self click (冒泡阶段) ---');
};
// 思考:如果我在捕获阶段的某个监听器中调用 event.stopPropagation() 会发生什么?
// document.getElementById('parent').addEventListener("click", function (event) {
// console.log('2. 捕获阶段: parent click (阻止冒泡)');
// event.stopPropagation(); // 阻止事件传播,后续的捕获、目标、冒泡阶段都不会再触发
// }, true);
// 思考:如果我在冒泡阶段的某个监听器中调用 event.stopPropagation() 会发生什么?
// document.getElementById('parent').addEventListener("click", function (event) {
// console.log('6. 冒泡阶段: parent click (阻止冒泡)');
// event.stopPropagation(); // 阻止事件向上冒泡,后续的 body 冒泡不会触发
// }, false);
</script>
</body>
</html>
解析:
点击 #child
元素时,事件的传播顺序是:
-
捕获阶段 (Capturing Phase):事件从
window
根节点开始,向下“捕获”到目标元素。所有在捕获阶段注册的事件监听器会按从外到内的顺序触发。1. 捕获阶段: body click
2. 捕获阶段: parent click
3. 捕获阶段: child click
-
目标阶段 (Target Phase):事件到达实际被点击的元素(
#child
)。此时,在目标元素上注册的所有事件监听器(无论注册时设置的是捕获还是冒泡)都会被触发。 -
冒泡阶段 (Bubbling Phase):事件从目标元素开始,向上“冒泡”到根节点。所有在冒泡阶段注册的事件监听器会按从内到外的顺序触发。
--- HTML 内联 click (冒泡阶段) ---
(内联事件默认在冒泡阶段执行)5. 冒泡阶段: child click
--- 4. DOM Level 0: parent self click (冒泡阶段) ---
(DOM Level 0 事件也在冒泡阶段执行)6. 冒泡阶段: parent click
7. 冒泡阶段: body click
--- 8. DOM Level 0: body self click (冒泡阶段) ---
关键点:addEventListener
的第三个参数
true
:表示在 捕获阶段 触发事件。false
(或省略):表示在 冒泡阶段 触发事件。
面试官可能会追问:
event.stopPropagation()
和event.stopImmediatePropagation()
的区别?event.preventDefault()
是什么?什么时候用?- 事件委托/代理是什么?有什么好处?如何实现?
mouseover
和mouseenter
的区别?(下文有详细解释)
3. 数组操作的“左右互搏”:push
, splice
等非变异方法
数组是 JS 中最常用的数据结构之一,其方法多如牛毛,但面试中常考的无非就是那些会改变原数组和不会改变原数组的“变异”方法。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数组操作</title>
</head>
<body>
<script>
console.log("---------------------------- 数组基础操作 ----------------------------");
let arr = [];
console.log("初始数组:", arr); // []
// 1. push(): 向数组末尾添加一个或多个元素,改变原数组,返回新长度
arr.push(1, 2, 3, 4, 5, "12345");
console.log("push(1, 2, 3, 4, 5, '12345') 后:", arr); // [1, 2, 3, 4, 5, "12345"]
console.log("使用展开运算符打印:", ...arr); // 1 2 3 4 5 12345
console.log("---------------------------- splice() 的魔力 ----------------------------");
// 2. splice(start, deleteCount, item1, item2, ...):
// 从指定位置删除元素,并/或插入新元素,改变原数组,返回被删除的元素组成的数组
// 从索引 1 开始,删除 3 个元素 (2, 3, 4),然后插入 "abc"
let deletedItems = arr.splice(1, 3, "abc");
console.log("splice(1, 3, 'abc') 后:", arr); // [1, "abc", 5, "12345"]
console.log("被删除的元素:", deletedItems); // [2, 3, 4]
console.log("---------------------------- 数组删除操作 ----------------------------");
let arr2 = [1, 2, 3, 54, 5, 6];
console.log("arr2 初始:", arr2); // [1, 2, 3, 54, 5, 6]
// 3. pop(): 删除数组最后一个元素,改变原数组,返回被删除的元素
let poppedItem = arr2.pop();
console.log("pop() 后:", arr2); // [1, 2, 3, 54, 5]
console.log("被删除的元素 (pop):", poppedItem); // 6
// 4. shift(): 删除数组第一个元素,改变原数组,返回被删除的元素
let shiftedItem = arr2.shift();
console.log("shift() 后:", arr2); // [2, 3, 54, 5]
console.log("被删除的元素 (shift):", shiftedItem); // 1
console.log("---------------------------- 数组查询操作 ----------------------------");
let arr4 = [1, 34, 4, 5, 5, 6, 6, 6, 67, 7];
console.log("arr4 初始:", arr4); // [1, 34, 4, 5, 5, 6, 6, 6, 67, 7]
// 5. indexOf(searchElement, fromIndex): 返回元素在数组中第一次出现的索引,如果不存在则返回 -1
console.log("indexOf(5):", arr4.indexOf(5)); // 3
console.log("indexOf(99):", arr4.indexOf(99)); // -1
// 6. includes(searchElement, fromIndex): 判断数组是否包含某个元素,返回布尔值 (ES6)
console.log("includes(5):", arr4.includes(5)); // true
console.log("includes(99):", arr4.includes(99)); // false
// 7. find() (ES6): 查找第一个符合条件的元素,返回该元素
let found = arr4.find(item => item > 60);
console.log("find(item => item > 60):", found); // 67
// 8. findIndex() (ES6): 查找第一个符合条件的元素的索引,返回该索引
let foundIndex = arr4.findIndex(item => item > 60);
console.log("findIndex(item => item > 60):", foundIndex); // 8
</script>
</body>
</html>
解析:
- 变异方法 (会改变原数组):
push
,pop
,shift
,unshift
,splice
,sort
,reverse
。 - 非变异方法 (不会改变原数组,返回新数组或新值):
slice
,concat
,indexOf
,includes
,join
,map
,filter
,reduce
等。
面试官可能会追问:
splice
和slice
的区别?- 如何实现数组的深拷贝?(下文有详细解释)
map
和forEach
的区别?(下文有详细解释)- 请用
reduce
实现map
或filter
。
4. 数组方法:迭代、转换与排序的“十八般武艺”
数组方法在实际开发中应用极广,掌握其用法和特性,能让你写出更简洁高效的代码。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数组方法</title>
</head>
<body>
<script>
console.log("---------------------------- 数组排序 ----------------------------");
let paixu = [1, 32, 4, 5, 6, 6, 6, 67, 7, 7, 7, 7, 7, 9834634764587326, 37, 346, 4, 634, 673, 7, 37, 72, 62, 6, 6, 4, 472, 1, 74775873, 8, 348, 1];
console.log("原始数组 (paixu):", paixu);
// 1. reverse(): 反转数组元素的顺序,改变原数组,返回原数组引用
let reversedArr = paixu.reverse();
console.log("reverse() 后:", ...reversedArr); // 注意:paixu 已经被改变了
console.log("原数组 paixu (已改变):", paixu); // 与 reversedArr 相同
// 2. sort(): 对数组元素进行排序,改变原数组,返回原数组引用
// 默认按字典序排序,对于数字需要提供比较函数
let sortedArr = paixu.sort((a, b) => a - b); // 升序排列
console.log("sort((a, b) => a - b) 升序后:", ...sortedArr);
console.log("原数组 paixu (已改变):", paixu); // 与 sortedArr 相同
// 如果想保留原数组,可以先复制一份:
let originalArr = [1, 32, 4, 5, 6];
let sortedCopy = [...originalArr].sort((a, b) => a - b);
console.log("原始数组副本排序 (不改变原数组):", sortedCopy);
console.log("原始数组 (originalArr):", originalArr); // [1, 32, 4, 5, 6]
console.log("---------------------------- 数组转换 ----------------------------");
let zh = ["jiba", '123', 'erg>gt', '14,', ' 32572 385 93 7892357ew ryg89 9^&*()(*&UIHG*&', ' 31 4 4a n', 'as ,', '234 234 , rwf f, , , , ', '13 43 ', 'asf . . .fa.s , '];
console.log("原始数组 (zh):", zh);
// 3. join(separator): 将数组所有元素连接成一个字符串,不改变原数组,返回字符串
let zh1 = zh.join("<--->");
console.log("join('<--->') 后:", zh1); // jiba<--->123<--->...
// 4. split(separator, limit) (字符串方法): 将字符串分割成数组
let strToSplit = "apple,banana,orange";
let splitArr = strToSplit.split(',');
console.log("字符串 split(',') 后:", splitArr); // ["apple", "banana", "orange"]
console.log("---------------------------- 数组迭代操作:forEach, filter, map, reduce ----------------------------");
// 5. forEach(callback(currentValue, index, array)): 遍历数组,无返回值,不改变原数组
let feArr = [1, 2, 3, 4, 5, 4, 3, 2, 1];
console.log("forEach 原始数组:", feArr);
feArr.forEach((item, index, array) => {
console.log(`item是:${item}, index是:${index}, array自己是:`, array === feArr);
// item = item + 1; // 这并不会改变原数组的元素,只是改变了局部变量 item
});
console.log("forEach 执行后,原数组:", feArr); // [1, 2, 3, 4, 5, 4, 3, 2, 1]
console.log("-------------------- filter --------------------");
// 6. filter(callback(currentValue, index, array)): 过滤数组,返回新数组,不改变原数组
let filterArr = [1, 2, 3, 4, 5, 4, 3, 2, 1];
let newFtArr = filterArr.filter((item) => {
return item % 3 === 0; // 返回所有能被 3 整除的元素
});
console.log("filter 原始数组:", filterArr); // [1, 2, 3, 4, 5, 4, 3, 2, 1]
console.log("filter(item % 3 === 0) 后:", newFtArr); // [3, 3]
console.log("-------------------- map --------------------");
// 7. map(callback(currentValue, index, array)): 映射数组,返回新数组,不改变原数组
let mapArr = [1, 2, 3, 4, 5, 4, 3, 2, 1];
let newMpArr = mapArr.map((item) => {
return item * 10; // 将每个元素乘以 10
});
console.log("map 原始数组:", mapArr); // [1, 2, 3, 4, 5, 4, 3, 2, 1]
console.log("map(item * 10) 后:", newMpArr); // [10, 20, 30, 40, 50, 40, 30, 20, 10]
console.log("-------------------- reduce --------------------");
// 8. reduce(callback(accumulator, currentValue, index, array), initialValue): 归并数组
// 用于对数组中的所有元素进行累积计算,返回单个值
let reduceArr = [1, 2, 3, 4, 5];
let sum = reduceArr.reduce((acc, item) => acc + item, 0); // 计算总和
console.log("reduce(sum) 后:", sum); // 15
let max = reduceArr.reduce((acc, item) => Math.max(acc, item), -Infinity); // 查找最大值
console.log("reduce(max) 后:", max); // 5
</script>
</body>
</html>
解析:
forEach
vsmap
:forEach
:无返回值,用于遍历数组执行副作用(如打印、修改外部变量)。它不创建新数组。map
:返回一个新数组,新数组的元素是原数组元素经过回调函数处理后的结果。它不改变原数组。
filter
: 用于筛选符合条件的元素,返回一个包含这些元素的新数组。reduce
: 数组“变形金刚”,能实现很多复杂操作,比如求和、求最大值、数组去重、将数组转成对象等。
面试官可能会追问:
for...of
和forEach
的区别?- 如何判断一个数组是否为空?(
Array.isArray(arr) && arr.length === 0
) - 手写一个
map
或filter
方法。
5. 字符串方法的“神龙摆尾”:常用操作与实际应用
字符串在前端交互中无处不在,掌握其核心方法对于数据处理和格式化至关重要。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>字符串方法</title>
</head>
<body>
<script>
console.log("-------------------------- 字符串方法深度解析 --------------------------");
console.log("------------------- 1. 字符串拼接/增加 -------------------");
let stringValue = "hello string is here !!!";
console.log("原始字符串:", stringValue);
// 1. concat(str1, str2, ...): 连接字符串,返回新字符串,不改变原字符串
let newString = stringValue.concat(" end of the newString", " and more.");
console.log("concat() 后:", newString); // hello string is here !!! end of the newString and more.
// 常用且推荐的拼接方式:模板字符串或 + 运算符
let templateString = `${stringValue} with template literal.`;
console.log("模板字符串拼接:", templateString); // hello string is here !!! with template literal.
console.log("------------------- 2. 字符串截取/删除 (不改变原字符串) -------------------");
let shanString = "hello string is here !!!";
// 2. slice(startIndex, endIndex): 截取字符串,endIndex 不包含。支持负数索引。
console.log("slice(6) (从索引6到末尾):", shanString.slice(6)); // string is here !!!
console.log("slice(0, 5) (从索引0到4):", shanString.slice(0, 5)); // hello
console.log("slice(-5) (从倒数第五个字符到末尾):", shanString.slice(-5)); // !!!
// 3. substring(startIndex, endIndex): 截取字符串,endIndex 不包含。不支持负数索引,会自动调整参数。
console.log("substring(0, 3):", shanString.substring(0, 3)); // hel
console.log("substring(3, 0):", shanString.substring(3, 0)); // hel (自动将小值作为起始索引)
// 4. substr(startIndex, length): 截取指定长度的字符串 (已废弃,不推荐使用)
// console.log("substr(6, 6):", shanString.substr(6, 6)); // string
console.log("------------------- 3. 字符串修改/格式化 (不改变原字符串) -------------------");
let gaiString = " hello string is here !!! ";
console.log("原始字符串 (gaiString):", `"${gaiString}" (长度: ${gaiString.length})`); // " hello string is here !!! " (长度: 29)
// 5. trim(): 移除字符串两端的空白字符,返回新字符串
let gaiedString = gaiString.trim();
console.log("trim() 后:", `"${gaiedString}" (长度: ${gaiedString.length})`); // "hello string is here !!!" (长度: 24)
console.log("原始字符串 gaiString (未改变):", `"${gaiString}"`);
// trimStart() 和 trimEnd() (ES2019): 分别移除开头或结尾的空白字符
console.log("trimStart() 后:", `"${gaiString.trimStart()}"`);
console.log("trimEnd() 后:", `"${gaiString.trimEnd()}"`);
// 6. replace(searchValue, replaceValue): 替换字符串中的内容,返回新字符串
let replacedString = "hello world".replace("world", "JS");
console.log("replace('world', 'JS') 后:", replacedString); // hello JS
// 使用正则表达式进行全局替换
let globalReplace = "hello world world".replace(/world/g, "JS");
console.log("replace(/world/g, 'JS') 后:", globalReplace); // hello JS JS
// 7. toUpperCase(), toLowerCase(): 大小写转换
console.log("toUpperCase() 后:", "hello".toUpperCase()); // HELLO
console.log("toLowerCase() 后:", "HELLO".toLowerCase()); // hello
console.log("------------------- 4. 字符串查找 -------------------");
let chaString = " hello string is here !!! ";
// 8. charAt(index): 返回指定索引位置的字符
console.log("charAt(3):", chaString.charAt(3)); // 'h' (注意索引从0开始)
// 9. includes(searchString, position): 判断字符串是否包含某个子串,返回布尔值 (ES6)
console.log("includes('hello'):", chaString.includes("hello")); // true
console.log("includes('Vue'):", chaString.includes("Vue")); // false
// 10. indexOf(searchString, fromIndex): 返回子串第一次出现的索引,未找到返回 -1
console.log("indexOf('string'):", chaString.indexOf("string")); // 7
console.log("indexOf('string', 10):", chaString.indexOf("string", 10)); // -1 (从索引10开始找)
// 11. startsWith(), endsWith() (ES6): 判断字符串是否以某个子串开头或结尾
console.log("startsWith(' hello'):", chaString.startsWith(" hello")); // true
console.log("endsWith('!!! '):", chaString.endsWith("!!! ")); // true
console.log("------------------- 5. 字符串转换为数组 -------------------");
let changeString = " hello . asfd. ey45ygf. .at45 .fs.dfas. s.f sd. .fs.f astr.a s.f.asf.s.3.4.5.eg.f45..t.ing is here !.!! ";
// 12. split(separator, limit): 将字符串分割成数组,根据分隔符
let aftArr = changeString.split(".");
console.log("split('.') 后:", aftArr);
console.log("使用展开运算符打印:", ...aftArr);
// 过滤空字符串和去除空白
let cleanedArr = changeString.split('.').map(s => s.trim()).filter(s => s !== '');
console.log("split().trim().filter() 后:", cleanedArr);
console.log("------------------- 6. 经典面试题:100以内的质数 -------------------");
// 质数(素数)是指在大于1的自然数中,除了1和它本身以外不再有其他因数的自然数。
function findPrimesUpTo(num) {
const primes = [];
for (let i = 2; i <= num; i++) {
let isPrime = true;
// 从 2 到 sqrt(i) 检查,提高效率
for (let j = 2; j * j <= i; j++) {
if (i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
primes.push(i);
}
}
return primes;
}
console.log("100以内的质数:", findPrimesUpTo(100)); // [2, 3, 5, ..., 97]
</script>
</body>
</html>
解析:
concat
vs+
或模板字符串:+
和模板字符串通常更简洁灵活,是推荐的拼接方式。slice
,substring
,substr
:slice
最常用,功能最强大(支持负数索引)。substr
已被废弃。trim
系列:trim()
移除两端空白,ES6 提供了trimStart()
和trimEnd()
更精细控制。replace
: 注意默认只替换第一个匹配项,全局替换需要使用正则表达式 (/g
)。- 质数判断: 经典的算法题,考察循环、条件判断和基本的数学优化(
j * j <= i
)。
面试官可能会追问:
- 请手写一个
trim()
函数。 - 如何判断一个字符串是否是回文?
- 正则表达式在字符串方法中的应用场景。
6. ES6 进阶:Promise 链式与事件循环的“捉迷藏”
ES6 带来了 Promise
、async/await
等异步编程的利器,也让事件循环(Event Loop)成为了必考题。理解它们的执行顺序是难点也是重点。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ES6 Promise 与事件循环</title>
</head>
<body>
<script>
console.log("-------------------------- Promise 与事件循环 --------------------------");
// 1. Promise.resolve(value): 返回一个以给定值解析的 Promise 对象。
// 如果 value 是 Promise,则返回该 Promise;如果 value 是 Thenable,则将其展平。
var p1 = Promise.resolve(1); // 直接解析为 1 的 Promise
var p2 = Promise.resolve(p1); // p1 是 Promise,p2 会直接等于 p1 (引用相同)
// 2. new Promise(executor): 创建一个新的 Promise 对象。
var p3 = new Promise(function (resolve, reject) {
resolve(1); // 内部直接 resolve 为 1
});
var p4 = new Promise(function (resolve, reject) {
resolve(p1); // 内部 resolve 一个 Promise p1,会等待 p1 状态确定
});
console.log("主线程同步代码执行:");
console.log("p1:", p1); // Promise { <state>: "fulfilled", <value>: 1 }
console.log("p2:", p2); // Promise { <state>: "fulfilled", <value>: 1 }
console.log("p3:", p3); // Promise { <state>: "fulfilled", <value>: 1 }
console.log("p4:", p4); // Promise { <state>: "pending" } -> 此时 p4 还没有处理 p1,所以是 pending
console.log("------------------- 引用比较 -------------------");
// p1 和 p2 是同一个 Promise 对象的引用
console.log("p1 === p2:", p1 === p2); // true
// p1 和 p3 是两个不同的 Promise 实例,尽管它们都解析为相同的值
console.log("p1 === p3:", p1 === p3); // false
// p1 和 p4 也是两个不同的 Promise 实例
console.log("p1 === p4:", p1 === p4); // false
// p3 和 p4 是两个不同的 Promise 实例
console.log("p3 === p4:", p3 === p4); // false
console.log("------------------- 微任务队列执行顺序 -------------------");
// Promise 的 then/catch/finally 回调会被放入微任务队列
// 微任务的执行优先级高于宏任务 (setTimeout, setInterval, requestAnimationFrame)
// p4 内部 resolve 了 p1,所以 p4 的 then 会等待 p1 状态确定后才执行
p4.then(function (value) {
console.log('Promise p4 resolved with value:', value); // 1
});
// p2 已经被 resolve(p1) 解析,其 then 回调会立即放入微任务队列
p2.then(function (value) {
console.log('Promise p2 resolved with value:', value); // 1
});
// p1 已经被 resolve(1) 解析,其 then 回调会立即放入微任务队列
p1.then(function (value) {
console.log('Promise p1 resolved with value:', value); // 1
});
// 宏任务示例
setTimeout(() => {
console.log('setTimeout 宏任务执行');
}, 0);
console.log("主线程同步代码执行完毕");
// 完整的输出顺序分析:
// 1. 所有 console.log('主线程同步代码...') 立即执行
// 2. Promise.resolve(p1) 会导致 p2 引用 p1,但 p4 内部 resolve(p1) 仍需等待微任务队列
// 3. 所有 then 回调被注册,并被放入微任务队列,顺序为 p4.then, p2.then, p1.then (因为注册顺序)
// 4. setTimeout 被注册,放入宏任务队列
// 5. console.log('主线程同步代码执行完毕') 立即执行
// 6. 微任务队列开始执行:
// - 'Promise p4 resolved with value: 1'
// - 'Promise p2 resolved with value: 1'
// - 'Promise p1 resolved with value: 1'
// 7. 微任务队列清空,进入下一轮事件循环,执行宏任务:
// - 'setTimeout 宏任务执行'
</script>
</body>
</html>
输出顺序预测(请在浏览器或 Node.js 环境中验证):
主线程同步代码执行:
p1: Promise {<fulfilled>: 1}
p2: Promise {<fulfilled>: 1}
p3: Promise {<fulfilled>: 1}
p4: Promise {<pending>}
------------------- 引用比较 -------------------
p1 === p2: true
p1 === p3: false
p1 === p4: false
p3 === p4: false
------------------- 微任务队列执行顺序 -------------------
主线程同步代码执行完毕
Promise p4 resolved with value: 1
Promise p2 resolved with value: 1
Promise p1 resolved with value: 1
setTimeout 宏任务执行
解析:
- 同步代码优先:所有
console.log
和 Promise 的初始化(但不触发.then
回调)会立即执行。p4
在new Promise
内部resolve(p1)
时,由于p1
也是一个 Promise,p4
会等待p1
的状态确定,所以初始状态是pending
。 - 微任务队列:
Promise.prototype.then()
、catch()
、finally()
、process.nextTick
(Node.js) 等回调会进入微任务队列。微任务在当前宏任务执行完毕后,下一个宏任务开始之前,全部清空。 - 宏任务队列:
setTimeout
、setInterval
、requestAnimationFrame
、I/O 操作、UI 渲染等会进入宏任务队列。 - 事件循环流程:
- 执行一个宏任务(通常是脚本本身)。
- 执行所有微任务。
- 渲染(浏览器环境)。
- 取下一个宏任务。
- 循环往复。
面试官可能会追问:
Promise.all
、Promise.race
、Promise.any
、Promise.allSettled
的区别和使用场景?async/await
是什么?如何实现异步编程的同步化?- 宏任务和微任务的具体种类有哪些?它们之间的优先级是怎样的?
- Node.js 环境下的事件循环和浏览器环境有什么不同?
7. 异步加载与浏览器渲染:前端性能的“隐形战线”
除了事件循环,面试官还会从宏观角度考察你对前端性能优化的理解,尤其是异步加载资源对浏览器渲染的影响。
面试常问: 为什么把 <script>
放在 <body>
底部?defer
和 async
有什么区别?
7.1 <script>
标签位置的影响
-
<script>
放在<head>
中:- 浏览器在解析 HTML 时,遇到
<script>
标签会暂停 HTML 解析,立即下载并执行脚本。 - 如果脚本很大或需要进行大量 DOM 操作,会导致页面加载变慢,出现白屏现象。
- 此时脚本可能无法访问到尚未构建的 DOM 元素。
- 浏览器在解析 HTML 时,遇到
-
<script>
放在<body>
底部 (推荐):- 浏览器会先解析完整个 HTML 结构,构建好 DOM 树。
- 脚本在 DOM 树构建完成后再下载并执行,不会阻塞页面内容的呈现。
- 脚本可以立即访问到完整的 DOM 元素,减少兼容性问题。
7.2 async
与 defer
属性 (仅适用于外部脚本)
这俩兄弟是解决脚本加载阻塞问题的利器。
-
<script async src="script.js"></script>
- 并行下载: 脚本下载与 HTML 解析并行进行。
- 乱序执行: 下载完成后立即执行,不保证脚本的执行顺序,也可能会阻塞 HTML 解析。
- 适用场景: 独立的、不依赖于其他脚本或 DOMContentLoaded 事件的脚本,如统计代码、广告脚本。
-
<script defer src="script.js"></script>
- 并行下载: 脚本下载与 HTML 解析并行进行。
- 顺序执行: 下载完成后,但会等待 HTML 解析完毕(DOMContentLoaded 事件触发前),并按脚本在 HTML 中出现的顺序执行。
- 不阻塞: 脚本执行不会阻塞 HTML 解析和 DOM 构建。
- 适用场景: 大部分常规的、依赖 DOM 或其他脚本的业务逻辑脚本。
总结表格:
特性 | 无属性 <script> | async <script> | defer <script> |
---|---|---|---|
下载 | 阻塞 HTML 解析 | 与 HTML 解析并行 | 与 HTML 解析并行 |
执行 | 立即执行,阻塞 HTML 解析 | 下载完立即执行,可能阻塞渲染 | HTML 解析完后,DOMContentLoaded 前,按顺序执行 |
顺序 | 严格按照 HTML 顺序 | 不保证顺序 | 保证按 HTML 顺序 |
DOM 依赖 | 不推荐依赖 | 不推荐依赖 | 可以依赖完整的 DOM |
匯出到試算表
面试官可能会追问:
- 除了
async
和defer
,还有哪些优化前端加载性能的方法? - 浏览器渲染过程是怎样的?(DOM 树、CSSOM 树、渲染树、回流、重绘)
- 什么是 FMP (首次有意义绘制)?如何优化?
- HTTP/2 的多路复用如何影响脚本加载?
8. 性能优化“双煞”:防抖 (Debounce) 与节流 (Throttle)
在处理高频事件(如 resize
, scroll
, mousemove
, input
)时,如果不加限制,可能会导致大量的事件处理函数执行,造成性能问题甚至页面卡顿。防抖和节流就是解决这类问题的核心手段。
8.1 节流 (Throttle)
目标: 在规定时间内,函数只执行一次。 场景: 滚动加载、拖拽、射击游戏。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS 节流函数实现与应用</title>
<style>
body { font-family: sans-serif; padding: 20px; background-color: #f5f5f5; }
h2 { color: #333; }
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
input {
padding: 10px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px;
width: 300px;
}
#output {
margin-top: 20px;
padding: 15px;
background-color: #e9e9e9;
border: 1px solid #ddd;
border-radius: 5px;
min-height: 50px;
}
</style>
</head>
<body>
<h2>节流 (Throttle) 示例</h2>
<button id="throttleBtn">高频点击我 (查看控制台)</button>
<input type="text" placeholder="输入内容 (查看控制台)">
<div id="output"></div>
<script>
// 节流函数实现 (时间戳版本)
function throttleTimestamp(func, interval) {
let lastExecutionTime = 0; // 上次执行时间
const _throttle = function(...args) {
const now = Date.now(); // 当前时间
// 距离上次执行时间差,如果小于间隔时间,则不执行
if (now - lastExecutionTime >= interval) {
func.apply(this, args); // 执行函数
lastExecutionTime = now; // 更新上次执行时间
}
};
return _throttle;
}
// 节流函数实现 (定时器版本)
function throttleTimer(func, interval) {
let timer = null; // 定时器ID
const _throttle = function(...args) {
// 如果定时器已存在,说明还在等待时间,直接返回
if (timer) {
return;
}
// 设置定时器,interval 毫秒后执行函数
timer = setTimeout(() => {
func.apply(this, args);
timer = null; // 执行后清空定时器,允许下次触发
}, interval);
};
return _throttle;
}
// 节流函数实现 (混合版本 - 同时具备时间戳和定时器优点)
function throttle(func, interval) {
let lastExecutionTime = 0; // 上次执行时间戳
let timer = null; // 定时器ID
const _throttle = function(...args) {
const context = this;
const now = Date.now();
const remaining = interval - (now - lastExecutionTime); // 距离下次可执行时间
// 如果在规定间隔内再次触发,清除之前的定时器
clearTimeout(timer);
if (remaining <= 0) { // 如果时间到了,立即执行
func.apply(context, args);
lastExecutionTime = now;
} else { // 如果时间没到,设置定时器等待剩余时间后执行
timer = setTimeout(() => {
func.apply(context, args);
lastExecutionTime = Date.now(); // 定时器执行时更新时间戳
timer = null; // 定时器执行后清空
}, remaining);
}
};
// 提供一个取消节流的方法
_throttle.cancel = function() {
clearTimeout(timer);
lastExecutionTime = 0;
timer = null;
};
return _throttle;
}
</script>
<script>
const throttleBtn = document.getElementById('throttleBtn');
const inputEl = document.querySelector("input");
const outputDiv = document.getElementById('output');
let clickCounter = 1;
const throttledClickHandler = throttle(function() {
const message = `按钮点击事件触发 ${clickCounter++} 次 at ${new Date().toLocaleTimeString()}`;
console.log(message);
outputDiv.innerHTML += `<p>${message}</p>`;
}, 1000); // 1 秒内只执行一次
throttleBtn.addEventListener('click', throttledClickHandler);
let inputCounter = 1;
const throttledInputHandler = throttle(function() {
const message = `输入框事件触发 ${inputCounter++} 次,值: "${this.value}" at ${new Date().toLocaleTimeString()}`;
console.log(message);
outputDiv.innerHTML += `<p>${message}</p>`;
}, 500); // 0.5 秒内只执行一次
inputEl.addEventListener('input', throttledInputHandler);
// 演示取消节流
// setTimeout(() => {
// console.log('5秒后取消节流...');
// throttledClickHandler.cancel();
// }, 5000);
</script>
</body>
</html>
节流为什么能限制时间间隔?
核心在于闭包 (Closure) 和时间戳/定时器的巧妙配合。hythrottle
函数返回的 _throttle
是一个闭包,它能够记住并访问外部函数 hythrottle
中声明的 startTime
(或 lastExecutionTime
和 timer
) 变量。
- 时间戳版本: 每次触发事件时,都会检查当前时间距离上次执行时间是否超过了设定的
interval
。如果没有,就直接跳过执行。这样就保证了在interval
周期内,函数最多只执行一次。 - 定时器版本: 每次触发事件时,如果当前没有正在运行的定时器,就设置一个定时器在
interval
后执行函数。如果在interval
期间再次触发,由于定时器已存在,会直接跳过。这样也保证了interval
内只执行一次。 - 混合版本 (更完善): 结合了时间戳和定时器的优点,确保函数在第一次触发时立即执行,并且在停止触发后能再执行一次(满足 "首次执行" 和 "最后执行" 的需求)。
8.2 防抖 (Debounce)
目标: 在事件触发后,等待一定时间再执行。如果在等待时间内再次触发,则重新计时。 场景: 搜索框输入(用户停止输入后才发送请求)、窗口调整大小(用户停止调整后再重新计算布局)。
JavaScript
// 防抖函数实现
function debounce(func, delay) {
let timer = null; // 定时器ID
const _debounce = function(...args) {
const context = this;
// 每次触发时,都清除上次的定时器
if (timer) {
clearTimeout(timer);
}
// 重新设置定时器
timer = setTimeout(() => {
func.apply(context, args); // 延迟执行函数
timer = null; // 执行后清空定时器
}, delay);
};
// 提供一个取消防抖的方法
_debounce.cancel = function() {
clearTimeout(timer);
timer = null;
};
return _debounce;
}
// 使用示例 (可以添加到上述 HTML 的 <script> 标签中)
// const searchInput = document.getElementById('searchInput'); // 假定有一个 input
// const debouncedSearch = debounce(function() {
// console.log('发送搜索请求:', this.value);
// }, 500); // 用户停止输入 500ms 后才触发搜索
// searchInput.addEventListener('input', debouncedSearch);
面试官可能会追问:
- 节流和防抖的根本区别是什么?适用场景有哪些?
- 请手写一个防抖函数(并说明其闭包原理)。
- 如何实现防抖和节流的“立即执行”和“取消”功能?
- 在 React/Vue 中,你如何使用防抖和节流?(例如,使用
useRef
保持 timer 引用,onUnmounted
/useEffect
清除定时器)
9. 排序算法之魂:快速排序 (QuickSort)
排序算法是算法面试的常客,其中快速排序以其平均 O(n log n) 的时间复杂度而备受青睐。你需要理解其基本思想和实现方式。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>快速排序</title>
</head>
<body>
<script>
console.log("-------------------------- 快速排序 --------------------------");
var arr = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.log("原始数组:", arr);
function quickSort(arr) {
// 1. 递归终止条件:如果数组长度小于等于 1,则认为它已经有序,直接返回
if (arr.length <= 1) {
return arr;
}
// 2. 选择基准 (pivot) 元素:通常选择第一个元素,也可以选择中间元素、随机元素等
var pivot = arr[0];
// 3. 划分 (partition):
// 创建两个空数组,用于存放比基准元素小和大的元素
var leftArr = [];
var rightArr = [];
// 遍历除基准元素外的所有元素
for (var i = 1; i < arr.length; i++) {
if (arr[i] < pivot) {
leftArr.push(arr[i]); // 比基准小的放入左子数组
} else {
rightArr.push(arr[i]); // 比基准大的放入右子数组 (相等也放右边,保持稳定性)
}
}
// 4. 递归排序 (recursion):
// 递归地对左子数组和右子数组进行快速排序
// 5. 合并 (merge):
// 将排序后的左子数组、基准元素、排序后的右子数组合并,返回最终结果
return [...quickSort(leftArr), pivot, ...quickSort(rightArr)];
// 等价于 return [].concat(quickSort(leftArr), [pivot], quickSort(rightArr));
}
console.log("快速排序结果:", quickSort(arr)); // [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
console.log("排序完成!");
</script>
</body>
</html>
解析:
快速排序的基本思想是分而治之 (Divide and Conquer):
- 选择基准 (Pivot):从数组中选择一个元素作为基准。
- 划分 (Partition):将数组中小于基准的所有元素移到基准的左边,将所有大于基准的元素移到基准的右边。
- 递归 (Recursion):对基准左右两边的子数组重复上述过程,直到子数组只包含一个元素或为空。
- 合并 (Combine):因为排序是在原地进行的(或通过返回新数组的方式),所以无需额外的合并步骤,当所有子数组排序完成后,整个数组也就有序了。
时间复杂度:
- 平均情况: O(n log n)
- 最坏情况: O(n^2) (当数组已经有序或逆序时,每次都选择第一个元素作为基准,导致一边子数组为空)
空间复杂度:
- O(log n) (递归栈深度) 或 O(n) (如果像示例中一样创建新数组)
面试官可能会追问:
- 除了快速排序,还知道哪些排序算法?它们的优缺点?
- 快速排序最坏情况是什么?如何避免?
- 你能手写一个“原地排序”的快速排序吗(不创建新数组)?(这通常需要双指针法)
10. Virtual DOM:前端框架的“降龙十八掌”
Vue、React 等现代前端框架都引入了 Virtual DOM 的概念,它极大地提升了前端开发的效率和性能。理解 Virtual DOM 的原理是理解这些框架的关键。
面试官常问: 什么是 Virtual DOM?为什么需要它?它的工作流程是怎样的?
10.1 Virtual DOM 是什么?
Virtual DOM (虚拟 DOM) 本质上是一个用 JavaScript 对象表示的轻量级的真实 DOM 树的抽象。它不是真正的 DOM 节点,而是一个包含了标签名、属性、子节点等信息的纯粹的 JavaScript 对象。
JavaScript
// 真实的 DOM 结构
/*
<div id="app">
<p class="text">Hello</p>
</div>
*/
// 对应的 Virtual DOM 结构 (简化版)
const vnode = {
tagName: 'div',
props: { id: 'app' },
children: [
{
tagName: 'p',
props: { class: 'text' },
children: ['Hello']
}
]
};
10.2 为什么需要 Virtual DOM?
你说的很对:JavaScript 操作很快,直接操作真实 DOM 很慢。 这句话就是 Virtual DOM 产生的根本原因。
- 浏览器操作 DOM 的开销大: 每次对真实 DOM 的操作(增、删、改、查)都会引起浏览器重新计算布局 (reflow) 和绘制 (repaint),这些操作是非常耗费性能的。即使是很小的改动,也可能触发一系列复杂的渲染流程。
- JavaScript 运行速度快: 得益于 V8 引擎等高性能 JavaScript 引擎的出现,JavaScript 本身的计算和对象操作效率非常高。
Virtual DOM 作为中间层,将 DOM 操作的复杂性和性能开销“抽象”出来,利用 JavaScript 的高速计算能力来优化这个过程。
10.3 Virtual DOM 的核心工作流程 (三个步骤)
Virtual DOM 的工作流程可以概括为以下三个核心步骤:
-
生成 Virtual DOM 树 (Create Virtual DOM Tree):
- 当状态数据发生变化时,框架会根据新的数据生成一棵新的 Virtual DOM 树。
- 这个过程只是 JavaScript 对象的创建和赋值,非常高效。
-
比较两棵树的差异 (Diffing / Reconciliation):
- 框架会将新生成的 Virtual DOM 树与旧的 Virtual DOM 树进行逐层、逐节点地比较,找出它们之间的最小差异。
- 这个比较算法(Diff 算法)是 Virtual DOM 的核心,它能够高效地找出需要更新的部分,而不是简单地重新渲染整个 DOM。
-
更新视图 (Patching / Update Real DOM):
- 将上一步计算出的差异补丁 (patch) 应用到真实的 DOM 树上。
- 这样,浏览器只需要对发生变化的部分进行最小化的 DOM 操作,避免了不必要的重排和重绘,从而大大提升了渲染性能。
流程图 (简化):
状态变化 -> 生成新 Virtual DOM -> Diff 算法比较 (新旧 VDOM) -> 计算差异 (Patch) -> 应用到真实 DOM -> 浏览器渲染
面试官可能会追问:
- Diff 算法的原理是什么?(同层比较、key 的作用、组件复用策略)
key
属性在 Vue/React 中有什么作用?为什么列表渲染时需要key
?- Virtual DOM 一定比真实 DOM 快吗?什么情况下会慢?
- Virtual DOM 的优缺点?
- SSR (服务器端渲染) 和 Virtual DOM 的关系?
11. 滚动判断与 DOM 操作实用技巧
前端开发中,经常需要判断元素是否滚动到底部,或者进行一些特殊的 DOM 插入和节点操作。
11.1 判断是否在页面可视区域/滚动到底部
这是一个非常常见的需求,比如无限滚动加载。
JavaScript
// 假设你有一个滚动容器,可能是 window 或一个可滚动的 div
// el 可以是 window 或一个 DOM 元素 (如 document.getElementById('myScrollContainer'))
function checkScrollToBottom(el) {
let clientHeight, scrollTop, scrollHeight;
if (el === window) {
clientHeight = document.documentElement.clientHeight; // 视口高度
scrollTop = document.documentElement.scrollTop; // 滚动条距离顶部的距离
scrollHeight = document.documentElement.scrollHeight; // 整个可滚动区域的总高度
} else {
clientHeight = el.clientHeight; // 元素自身可视高度
scrollTop = el.scrollTop; // 元素内容滚动距离
scrollHeight = el.scrollHeight; // 元素内容总高度
}
// 判断条件:可视区域高度 + 滚动距离 >= 整个可滚动内容的总高度 - 某个阈值(可选,避免误差)
// 留个小余量,防止浮点数误差或刚好没到底部
const threshold = 1;
if (clientHeight + scrollTop >= scrollHeight - threshold) {
console.log("滚动到底部了!");
return true;
}
return false;
}
// 结合节流进行监听
// const scrollListenerHandler = throttle(() => {
// if (checkScrollToBottom(window)) {
// // 执行加载更多数据的逻辑
// console.log("加载更多数据...");
// }
// }, 100); // 节流 100ms
// window.addEventListener('scroll', scrollListenerHandler);
// 示例在 Vue 组件中的应用 (setup 语法糖)
/*
<template>
<div class="scroll-container" ref="scrollContainer">
<div v-for="item in items" :key="item">{{ item }}</div>
<div v-if="loading" class="loading-more">加载中...</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { throttle } from './utils/throttle.js'; // 导入你的节流函数
const scrollContainer = ref(null);
const items = ref(Array.from({ length: 20 }, (_, i) => `初始数据 ${i + 1}`));
const loading = ref(false);
const loadMore = () => {
if (loading.value) return;
loading.value = true;
console.log('加载更多数据...');
setTimeout(() => { // 模拟异步加载
const newItems = Array.from({ length: 10 }, (_, i) => `新增数据 ${items.value.length + i + 1}`);
items.value.push(...newItems);
loading.value = false;
console.log('数据加载完成');
}, 1000);
};
const handleScroll = throttle(() => {
const el = scrollContainer.value;
if (!el) return;
const clientHeight = el.clientHeight;
const scrollTop = el.scrollTop;
const scrollHeight = el.scrollHeight;
const threshold = 1; // 1px 误差容忍
if (clientHeight + scrollTop >= scrollHeight - threshold) {
console.log("滚动到底部了,触发加载更多!");
loadMore();
}
}, 200); // 200ms 节流
onMounted(() => {
// 注意:如果是监听 window 滚动,则是 window.addEventListener('scroll', handleScroll);
if (scrollContainer.value) {
scrollContainer.value.addEventListener('scroll', handleScroll);
}
});
onUnmounted(() => {
// 清除事件监听器,避免内存泄露
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll);
}
});
</script>
<style scoped>
.scroll-container {
height: 300px;
overflow-y: scroll;
border: 1px solid #ccc;
padding: 10px;
margin-top: 20px;
}
.loading-more {
text-align: center;
padding: 10px;
color: #888;
}
</style>
*/
11.2 insertAfter
:在指定元素后插入新元素
原生 DOM API 提供了 insertBefore
,但没有 insertAfter
。面试官会让你手写一个。
JavaScript
/**
* 在指定节点之后插入新节点
* @param {HTMLElement} target 要插入的新节点
* @param {HTMLElement} afterNode 作为参考的节点,新节点将插入到它之后
* @returns {HTMLElement} 插入的新节点
*/
HTMLElement.prototype.insertAfter = function(target, afterNode) {
// 获取 afterNode 的下一个兄弟元素
var nextElem = afterNode.nextElementSibling;
// 如果 nextElem 存在,说明 afterNode 后面还有兄弟节点
// 那么就将 target 插入到 nextElem 的前面
if (nextElem) {
this.insertBefore(target, nextElem);
} else {
// 如果 nextElem 不存在,说明 afterNode 是最后一个子节点
// 那么直接将 target 作为父元素的最后一个子节点追加
this.appendChild(target);
}
return target; // 返回被插入的节点
};
// 使用示例:
/*
<body>
<div id="container">
<p id="first">这是第一个段落</p>
<p id="second">这是第二个段落</p>
</div>
<script>
const container = document.getElementById('container');
const firstP = document.getElementById('first');
const secondP = document.getElementById('second');
// 创建一个新的段落
const newP = document.createElement('p');
newP.textContent = '这是在第二个段落之后插入的新段落!';
newP.style.color = 'blue';
// 在 secondP 之后插入 newP
container.insertAfter(newP, secondP);
const anotherP = document.createElement('p');
anotherP.textContent = '这是在第一个段落之后插入的另一个段落!';
anotherP.style.color = 'green';
// 在 firstP 之后插入 anotherP
container.insertAfter(anotherP, firstP);
const lastP = document.createElement('p');
lastP.textContent = '这是最后一个段落,在其后插入新的!';
lastP.style.color = 'red';
container.appendChild(lastP); // 先让它成为最后一个
const appendedNewP = document.createElement('p');
appendedNewP.textContent = '这是追加到最后一个元素后的元素。';
appendedNewP.style.fontWeight = 'bold';
// 在 lastP 之后插入 appendedNewP (此时 lastP 是最后一个)
container.insertAfter(appendedNewP, lastP);
</script>
</body>
*/
11.3 逆序排列元素的子节点
这个操作相对少见,但考察你对 DOM 节点遍历和 DocumentFragment
的理解。
JavaScript
/**
* 逆序排列给定父元素的子节点
* @returns {void}
*/
HTMLElement.prototype.reverseChildren = function() {
var childElems = this.children; // 获取实时更新的 HTMLCollection
var childrenLen = childElems.length;
// 使用 DocumentFragment 减少 DOM 操作,提高性能
// DocumentFragment 就像一个轻量级的 Document 对象,用于存储临时的 DOM 结构
var fragment = document.createDocumentFragment();
// 从最后一个子节点开始向前遍历,并将其添加到 DocumentFragment 中
// 当一个节点被 appendChild 到另一个地方时,它会自动从原位置移除
for (var i = childrenLen - 1; i >= 0; i--) {
fragment.appendChild(childElems[i]);
}
// 最后,将 DocumentFragment 一次性添加到父元素中
// 这会触发一次 DOM 重排,而不是每次添加一个子节点都触发
this.appendChild(fragment);
};
// 使用示例:
/*
<body>
<ul id="myList">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
<li>Item 4</li>
</ul>
<button onclick="document.getElementById('myList').reverseChildren()">逆序列表</button>
<script>
// 将上面 reverseChildren 函数代码复制到这里或单独的 JS 文件
HTMLElement.prototype.reverseChildren = function() {
var childElems = this.children;
var childrenLen = childElems.length;
var fragment = document.createDocumentFragment();
for (var i = childrenLen - 1; i >= 0; i--) {
fragment.appendChild(childElems[i]);
}
this.appendChild(fragment);
};
</script>
</body>
*/
面试官可能会追问:
Node.contains()
方法是做什么的?firstElementChild
,lastElementChild
,nextElementSibling
,previousElementSibling
和它们对应的firstChild
等属性的区别?DocumentFragment
是什么?为什么使用它能提高性能?
12. 事件触发:mouseover
vs mouseenter
,细节决定成败!
这两个事件看起来很像,但在处理复杂 UI 交互时,它们的细微差别至关重要。
面试官常问: mouseover
和 mouseenter
有什么区别?
-
mouseover
(鼠标移入):- 当鼠标指针进入元素或其任何后代元素时触发。
- 它会冒泡。这意味着如果子元素触发了
mouseover
,事件会向上冒泡到父元素,即使鼠标并没有直接进入父元素。 - 如果你在父元素上监听
mouseover
,当你从父元素移动到子元素时,也会触发父元素的mouseover
事件,因为子元素的mouseover
冒泡上来了。
-
mouseenter
(鼠标进入):- 当鼠标指针仅进入元素本身时触发。
- 它不冒泡。这意味着当你从父元素移动到其子元素时,不会触发父元素的
mouseenter
事件。只有当鼠标第一次从元素外部进入该元素时才会触发。 - 更适合用于实现“悬停”效果,因为它不会在子元素上重复触发。
简单记忆:
mouseover
:“过”,就像鼠标在元素及其子元素上“经过”,会触发。mouseenter
:“进”,只有鼠标从外部“进入”元素本体才会触发。
示例:
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>mouseover vs mouseenter</title>
<style>
#container {
width: 300px;
height: 200px;
background-color: lightblue;
padding: 20px;
margin: 50px;
border: 2px solid blue;
display: flex;
justify-content: center;
align-items: center;
}
#box {
width: 100px;
height: 100px;
background-color: lightcoral;
border: 2px solid red;
}
</style>
</head>
<body>
<div id="container">
<div id="box"></div>
</div>
<script>
const container = document.getElementById('container');
const box = document.getElementById('box');
container.addEventListener('mouseover', () => {
console.log('Container: mouseover (冒泡)');
});
container.addEventListener('mouseenter', () => {
console.log('Container: mouseenter (不冒泡)');
});
box.addEventListener('mouseover', () => {
console.log('Box: mouseover (冒泡)');
});
box.addEventListener('mouseenter', () => {
console.log('Box: mouseenter (不冒泡)');
});
// 测试步骤:
// 1. 鼠标从外部移入 #container -> 观察输出
// 2. 鼠标从 #container 内部移入 #box -> 观察输出
// 3. 鼠标从 #box 移回 #container 内部 (不离开 #container) -> 观察输出
// 4. 鼠标从 #container 外部移出
</script>
</body>
</html>
预期行为:
- 鼠标从外部移入
#container
:Container: mouseover (冒泡)
Container: mouseenter (不冒泡)
- 鼠标从
#container
内部移入#box
:Box: mouseover (冒泡)
Box: mouseenter (不冒泡)
- 接着,由于
Box
的mouseover
冒泡到Container
,所以会再次触发:Container: mouseover (冒泡)
(注意:mouseenter
不会再次触发)
- 鼠标从
#box
移回#container
内部 (不离开#container
):Container: mouseover (冒泡)
(因为鼠标再次“经过”了 Container 的区域,即使它已经离开了 Box)Box: mouseout
(未监听,但会触发)Box: mouseleave
(未监听,但会触发)
- 鼠标从
#container
外部移出:Container: mouseout
(未监听)Container: mouseleave
(未监听)
结论: 当你需要精确地知道鼠标是否真正进入了某个元素本身时(例如,只在鼠标进入一个按钮时显示提示),请使用 mouseenter
。如果需要处理鼠标在元素及其子元素之间移动的情况,或者需要事件委托,则使用 mouseover
。
13. 最近公共祖先:DOM 遍历与 contains
的巧妙结合
在处理 DOM 树结构时,查找两个元素的最近公共祖先是一个经典问题。
JavaScript
/**
* 查找两个 DOM 元素的最近公共祖先
* @param {HTMLElement} nodeA 第一个 DOM 元素
* @param {HTMLElement} nodeB 第二个 DOM 元素
* @returns {HTMLElement|null} 最近的公共祖先节点,如果不存在则返回 null
*/
function findClosestCommonAncestor(nodeA, nodeB) {
// 基础检查:如果任何一个节点为空,或者它们是同一个节点
if (!nodeA || !nodeB) {
return null;
}
if (nodeA === nodeB) {
return nodeA;
}
// 遍历 nodeA 的祖先链
// 从 nodeA 开始向上查找,直到 document 根节点
for (let currentA = nodeA; currentA; currentA = currentA.parentNode) {
// 判断当前的 currentA (nodeA 的祖先) 是否包含 nodeB
// Node.prototype.contains() 方法检查一个节点是否是另一个节点的后代(或自身)
if (currentA.contains(nodeB)) {
return currentA; // 如果包含,那么 currentA 就是最近的公共祖先
}
}
// 如果遍历完 nodeA 的祖先链都没有找到,通常在同一文档下会找到,除非不在同一个根元素下
return null;
}
// 使用示例 (假设有以下 HTML 结构)
/*
<div id="grandparent">
<div id="parent1">
<span id="child1">Child 1</span>
</div>
<div id="parent2">
<p id="child2">Child 2</p>
</div>
</div>
<div id="anotherRoot">
<span id="isolatedChild">Isolated</span>
</div>
<script>
const grandparent = document.getElementById('grandparent');
const parent1 = document.getElementById('parent1');
const child1 = document.getElementById('child1');
const parent2 = document.getElementById('parent2');
const child2 = document.getElementById('child2');
const isolatedChild = document.getElementById('isolatedChild');
console.log("findClosestCommonAncestor(child1, child2):", findClosestCommonAncestor(child1, child2)); // #grandparent
console.log("findClosestCommonAncestor(child1, parent1):", findClosestCommonAncestor(child1, parent1)); // #parent1
console.log("findClosestCommonAncestor(parent1, child2):", findClosestCommonAncestor(parent1, child2)); // #grandparent
console.log("findClosestCommonAncestor(child1, grandparent):", findClosestCommonAncestor(child1, grandparent)); // #grandparent
console.log("findClosestCommonAncestor(child1, isolatedChild):", findClosestCommonAncestor(child1, isolatedChild)); // null (在不同根节点下)
</script>
*/
解析:
该算法的核心思想是**“向上遍历”**。我们从第一个节点 nodeA
开始,不断向上访问其父节点(parentNode
),在每次访问时,都用 contains()
方法检查这个当前父节点是否包含 nodeB
。一旦找到,就说明找到了最近的公共祖先。
Node.prototype.contains(otherNode)
:这个方法非常有用,它返回一个布尔值,表示 otherNode
是否是当前节点的后代节点(包括它自身)。
面试官可能会追问:
- 除了
contains()
,还有其他方法判断节点关系吗?(compareDocumentPosition
) - 如果两个节点不在同一个文档中,会返回什么?
- 如何处理文本节点或注释节点?(
parentNode
对于这些节点也有效)
14. 手写深拷贝:告别循环引用与数据丢失!
面试中手写深拷贝是家常便饭,这不仅考察你对对象遍历的理解,更考察你对循环引用问题的处理能力。
JavaScript
/**
* 深度复制一个对象,可以处理循环引用
* @param {any} obj 要复制的源对象
* @param {WeakMap} [hash = new WeakMap()] 用于存储已访问对象的 WeakMap,处理循环引用
* @returns {any} 深拷贝后的新对象
*/
function deepCopyMyself(obj, hash = new WeakMap()) {
// 1. 基本类型和 null/undefined 直接返回
// 2. 函数、Symbol、RegExp、Date 等特殊对象,直接返回引用(或根据需求进行特殊处理)
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 处理特殊对象类型:Date, RegExp, Map, Set 等
// 默认的 JSON.parse(JSON.stringify()) 无法处理这些
if (obj instanceof Date) {
return new Date(obj);
}
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// TODO: 可以根据需求增加对 Map, Set, Function 等的支持
// if (obj instanceof Map) { ... }
// if (obj instanceof Set) { ... }
// if (obj instanceof Function) { return obj; } // 函数通常直接引用
// 3. 处理循环引用:
// 如果 WeakMap 中已存在该对象,说明之前已经复制过,直接返回之前复制的引用
if (hash.has(obj)) {
return hash.get(obj);
}
// 4. 创建新对象或新数组
// 根据源对象的类型来创建目标对象
const newObj = Array.isArray(obj) ? [] : {};
// 5. 将源对象和新创建的对象存入 WeakMap,建立映射关系
// 这一步必须在递归之前完成,否则当出现循环引用时,
// 递归进去的对象在 hash 中找不到,会再次尝试创建,导致栈溢出。
hash.set(obj, newObj);
// 6. 遍历源对象的所有属性,递归进行深拷贝
// 使用 for...in 遍历可枚举属性(包括原型链上的,但我们通常只拷贝自身属性)
// Object.keys(obj) 只遍历自身可枚举属性
// Reflect.ownKeys(obj) 可以遍历所有自身属性(包括 Symbol 属性)
for (let key in obj) {
// 确保只复制对象自身的属性,而不是原型链上的属性
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = deepCopyMyself(obj[key], hash);
}
}
// 7. 返回深拷贝后的新对象
return newObj;
}
// 示例用法:
const obj1 = {
a: 1,
b: { c: 2, d: { e: 3 } },
f: [1, 2, { g: 4 }],
h: null,
i: undefined,
j: Symbol('sym'),
k: new Date(),
l: /abc/g,
m: function() { console.log('func'); }
};
// 添加循环引用
obj1.b.self = obj1;
obj1.f.push(obj1.b);
const copiedObj = deepCopyMyself(obj1);
console.log("原始对象:", obj1);
console.log("深拷贝对象:", copiedObj);
console.log("------------------- 深拷贝验证 -------------------");
console.log("原始对象 === 拷贝对象?", obj1 === copiedObj); // false
console.log("原始对象.b === 拷贝对象.b?", obj1.b === copiedObj.b); // false
console.log("原始对象.b.d === 拷贝对象.b.d?", obj1.b.d === copiedObj.b.d); // false
console.log("原始对象.f[2] === 拷贝对象.f[2]?", obj1.f[2] === copiedObj.f[2]); // false
console.log("处理循环引用:", copiedObj.b.self === copiedObj); // true (重要:验证循环引用是否正确处理)
console.log("函数是否被拷贝 (引用相同):", obj1.m === copiedObj.m); // true
console.log("日期对象是否被拷贝 (新的实例):", obj1.k !== copiedObj.k && copiedObj.k instanceof Date); // true
console.log("正则对象是否被拷贝 (新的实例):", obj1.l !== copiedObj.l && copiedObj.l instanceof RegExp); // true
解析:
手写深拷贝的难点在于以下几个方面:
- 区分基本类型与引用类型: 基本类型直接返回,引用类型需要递归复制。
- 处理数组与对象: 需要根据源对象的类型创建新的数组或对象。
- 避免原型链上的属性被拷贝: 使用
hasOwnProperty
确保只拷贝对象自身的属性。 - 处理循环引用 (Circular Reference): 这是最关键也是最容易被忽略的点。当对象中存在相互引用时,如果不加处理,递归会陷入无限循环,导致栈溢出。
循环引用解决方案:WeakMap
- 我们使用一个
WeakMap
(或者Map
) 来存储已经访问过的对象和它们对应的副本。 - 在每次递归开始时,先检查当前对象是否已经在
WeakMap
中。如果存在,直接返回其对应的副本,从而切断循环。 - 在创建新对象/数组后,立即将原始对象和新创建的副本存入
WeakMap
,以备后续引用。
WeakMap
的优势:
- 弱引用:
WeakMap
的键是弱引用,当键所引用的对象被垃圾回收时,WeakMap
中的对应条目也会被自动移除,有助于防止内存泄漏。这对于深拷贝这种临时映射非常合适。
面试官可能会追问:
- 深拷贝和浅拷贝的区别?
JSON.parse(JSON.stringify(obj))
可以实现深拷贝吗?它的优缺点是什么?(无法拷贝函数、Symbol、undefined
,无法处理循环引用,无法拷贝特殊对象如 Date, RegExp)- 如果对象属性是
Symbol
或不可枚举的,如何处理?(使用Reflect.ownKeys
或Object.getOwnPropertySymbols
) - 如何处理函数?(通常直接引用,或根据需求创建新的函数实例)
15. Vue 滚动到底部:一个实用的场景结合
回到 Vue,结合之前的节流和滚动判断,我们可以实现一个非常实用的无限滚动加载功能。
程式碼片段
<template>
<div class="main-container">
<h2>Vue 滚动到底部加载示例</h2>
<div class="scroll-wrapper" ref="scrollContainer">
<div v-for="item in items" :key="item" class="data-item">
{{ item }}
</div>
<div v-if="loading" class="loading-status">
<p>加载中...</p>
<div class="spinner"></div>
</div>
<div v-if="!hasMore && !loading" class="no-more-data">
<p>没有更多数据了</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
// 导入你的节流函数,这里假设你已在某个文件 (如 utils/throttle.js) 中定义
// import { throttle } from '@/utils/throttle.js';
// 这里为了方便演示,直接在 script 中定义一个简化版节流
const throttle = (func, delay) => {
let timer = null;
return function(...args) {
const context = this;
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(context, args);
}, delay);
};
};
const scrollContainer = ref(null);
const items = ref(Array.from({ length: 20 }, (_, i) => `初始数据条目 ${i + 1}`));
const loading = ref(false);
const currentPage = ref(1);
const totalPages = 3; // 假设总共只有 3 页数据
const hasMore = computed(() => currentPage.value < totalPages); // 计算属性判断是否还有更多数据
// 模拟异步加载数据
const fetchData = async () => {
if (loading.value || !hasMore.value) return; // 正在加载或没有更多数据时不再加载
loading.value = true;
console.log(`正在加载第 ${currentPage.value + 1} 页数据...`);
try {
await new Promise(resolve => setTimeout(resolve, 800)); // 模拟网络请求延迟
currentPage.value++; // 页码递增
if (currentPage.value <= totalPages) {
const newItems = Array.from({ length: 10 }, (_, i) => `第${currentPage.value}页数据 ${items.value.length + i + 1}`);
items.value.push(...newItems);
} else {
console.log("所有数据已加载完毕。");
}
} catch (error) {
console.error("数据加载失败:", error);
} finally {
loading.value = false;
}
};
// 滚动事件处理函数 (已节流)
const handleScroll = throttle(() => {
const el = scrollContainer.value;
if (!el) return;
// clientHeight: 元素内容及其 padding 的高度 (可视区域高度)
// scrollTop: 元素内容已滚动的距离 (隐藏在顶部的高度)
// scrollHeight: 元素内容的实际总高度 (包括溢出部分)
// 判断是否滚动到底部:
// 元素的可视高度 + 滚动距离 >= 元素内容的总高度 - 一个小误差值
const isAtBottom = el.clientHeight + el.scrollTop >= el.scrollHeight - 5; // 留 5px 误差
if (isAtBottom) {
console.log("滚动到底部了,尝试加载更多!");
fetchData(); // 触发数据加载
}
}, 150); // 节流 150ms
onMounted(() => {
// 确保 DOM 元素存在再添加监听器
if (scrollContainer.value) {
scrollContainer.value.addEventListener('scroll', handleScroll);
}
});
onUnmounted(() => {
// 组件卸载时移除事件监听器,避免内存泄漏
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll);
}
});
</script>
<style scoped>
.main-container {
max-width: 600px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
background-color: #fff;
}
h2 {
text-align: center;
color: #333;
margin-bottom: 25px;
}
.scroll-wrapper {
height: 400px; /* 固定高度,使其可滚动 */
overflow-y: auto; /* 允许垂直滚动 */
border: 1px solid #e0e0e0;
border-radius: 6px;
background-color: #f9f9f9;
padding: 10px;
box-sizing: border-box;
}
.data-item {
padding: 12px;
border-bottom: 1px dashed #eee;
font-size: 0.95em;
color: #555;
text-align: left;
}
.data-item:last-child {
border-bottom: none;
}
.loading-status, .no-more-data {
text-align: center;
padding: 20px;
color: #888;
font-style: italic;
font-size: 0.9em;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #007bff;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
margin: 10px auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
解析:
判断滚动到底部的核心公式是:clientHeight + scrollTop >= scrollHeight
。
clientHeight
:元素内容可视区域的高度(包含 padding,不包含 border 和 margin)。scrollTop
:元素内容顶部被滚动条隐藏的距离。scrollHeight
:元素内容的实际总高度(包括溢出部分,不包含 margin)。
当 clientHeight + scrollTop
等于或大于 scrollHeight
时,说明滚动条已经到达底部。我们通常会减去一个小的阈值(例如 1px 或 5px),以应对浮点数计算误差或提前一点点触发加载。
面试官可能会追问:
- 在 Vue/React 中,如何正确地添加和移除事件监听器以防止内存泄漏?
- 为什么在
onUnmounted
(Vue) 或useEffect
(React) 中清除定时器和事件监听器很重要? - 如何实现一个带有加载状态和“没有更多数据”提示的无限滚动组件?
- 如果滚动容器是
window
,公式有什么不同?(document.documentElement.clientHeight
,document.documentElement.scrollTop
,document.documentElement.scrollHeight
)
16. 结语:你的 JS/Vue 进阶之路,从这里开始!
好了,兄弟们,这份面试突击秘籍就到这里了!从最基础的原型链和事件流,到实用的数组/字符串操作,再到 ES6 异步、性能优化、Virtual DOM 和手写算法,我把我最近踩的坑和理解的重点都掰碎了喂给你。
记住,面试不仅仅是考知识点,更是考你对这些知识点的深度理解、实际应用能力,以及解决问题的思路。当你能把这些点融会贯通,并结合 Vue 框架的实践来阐述时,相信你已经不是一个简单的“八股文背诵机”,而是一个真正有思考、有经验的“老兵”了!
希望这份笔记能帮助你少走弯路,在接下来的面试中乘风破浪,拿到心仪的 Offer!祝君好运,我们顶峰相见!