20 前端面试javascript/es6重难点:常见排序、数组/字符串方法、es6语法、DOM操作、异步函数、高阶函数、this绑定、原型链函数、继承原理、手写深拷贝、防抖节流、等知识点汇总

最近的几次面试、笔试都被考到了相关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:语法糖,内部实现更优雅。

解析:

  1. Child.prototype = new Parent();:这是最容易踩坑的地方!当你执行这行代码时,Parent 构造函数会立即执行一次。它会像普通函数一样被调用,只不过此时的 this 指向一个新的空对象,并最终成为 Child.prototype 的值。这意味着 Parent 构造函数里的 console.log(name) 会打印 undefined(因为此时 name 未传入),并且 Child.prototype 上会有一个 name 属性(值为 undefined)。

  2. Parent.call(this, name):这句代码在 Child 构造函数内部执行,目的是借用 Parent 的构造函数,把 Parent 构造函数里的属性(如 this.name = name)复制到 Child 的实例上。这里的 this 绑定到了 child1 实例。

  3. 最终结果:

    • 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 元素时,事件的传播顺序是:

  1. 捕获阶段 (Capturing Phase):事件从 window 根节点开始,向下“捕获”到目标元素。所有在捕获阶段注册的事件监听器会按从外到内的顺序触发。

    • 1. 捕获阶段: body click
    • 2. 捕获阶段: parent click
    • 3. 捕获阶段: child click
  2. 目标阶段 (Target Phase):事件到达实际被点击的元素(#child)。此时,在目标元素上注册的所有事件监听器(无论注册时设置的是捕获还是冒泡)都会被触发。

  3. 冒泡阶段 (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() 是什么?什么时候用?
  • 事件委托/代理是什么?有什么好处?如何实现?
  • mouseovermouseenter 的区别?(下文有详细解释)

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 等。

面试官可能会追问:

  • spliceslice 的区别?
  • 如何实现数组的深拷贝?(下文有详细解释)
  • mapforEach 的区别?(下文有详细解释)
  • 请用 reduce 实现 mapfilter

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 vs map
    • forEach无返回值,用于遍历数组执行副作用(如打印、修改外部变量)。它不创建新数组。
    • map返回一个新数组,新数组的元素是原数组元素经过回调函数处理后的结果。它不改变原数组。
  • filter 用于筛选符合条件的元素,返回一个包含这些元素的新数组。
  • reduce 数组“变形金刚”,能实现很多复杂操作,比如求和、求最大值、数组去重、将数组转成对象等。

面试官可能会追问:

  • for...offorEach 的区别?
  • 如何判断一个数组是否为空?(Array.isArray(arr) && arr.length === 0
  • 手写一个 mapfilter 方法。

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 带来了 Promiseasync/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 宏任务执行

解析:

  1. 同步代码优先:所有 console.log 和 Promise 的初始化(但不触发 .then 回调)会立即执行。p4new Promise 内部 resolve(p1) 时,由于 p1 也是一个 Promise,p4 会等待 p1 的状态确定,所以初始状态是 pending
  2. 微任务队列Promise.prototype.then()catch()finally()process.nextTick (Node.js) 等回调会进入微任务队列。微任务在当前宏任务执行完毕后,下一个宏任务开始之前,全部清空
  3. 宏任务队列setTimeoutsetIntervalrequestAnimationFrame、I/O 操作、UI 渲染等会进入宏任务队列。
  4. 事件循环流程
    • 执行一个宏任务(通常是脚本本身)。
    • 执行所有微任务。
    • 渲染(浏览器环境)。
    • 取下一个宏任务。
    • 循环往复。

面试官可能会追问:

  • Promise.allPromise.racePromise.anyPromise.allSettled 的区别和使用场景?
  • async/await 是什么?如何实现异步编程的同步化?
  • 宏任务和微任务的具体种类有哪些?它们之间的优先级是怎样的?
  • Node.js 环境下的事件循环和浏览器环境有什么不同?

7. 异步加载与浏览器渲染:前端性能的“隐形战线”

除了事件循环,面试官还会从宏观角度考察你对前端性能优化的理解,尤其是异步加载资源对浏览器渲染的影响。

面试常问: 为什么把 <script> 放在 <body> 底部?deferasync 有什么区别?

7.1 <script> 标签位置的影响

  • <script> 放在 <head> 中:

    • 浏览器在解析 HTML 时,遇到 <script> 标签会暂停 HTML 解析,立即下载并执行脚本。
    • 如果脚本很大或需要进行大量 DOM 操作,会导致页面加载变慢,出现白屏现象。
    • 此时脚本可能无法访问到尚未构建的 DOM 元素。
  • <script> 放在 <body> 底部 (推荐):

    • 浏览器会先解析完整个 HTML 结构,构建好 DOM 树。
    • 脚本在 DOM 树构建完成后再下载并执行,不会阻塞页面内容的呈现。
    • 脚本可以立即访问到完整的 DOM 元素,减少兼容性问题。

7.2 asyncdefer 属性 (仅适用于外部脚本)

这俩兄弟是解决脚本加载阻塞问题的利器。

  • <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

匯出到試算表

面试官可能会追问:

  • 除了 asyncdefer,还有哪些优化前端加载性能的方法?
  • 浏览器渲染过程是怎样的?(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 (或 lastExecutionTimetimer) 变量

  • 时间戳版本: 每次触发事件时,都会检查当前时间距离上次执行时间是否超过了设定的 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)

  1. 选择基准 (Pivot):从数组中选择一个元素作为基准。
  2. 划分 (Partition):将数组中小于基准的所有元素移到基准的左边,将所有大于基准的元素移到基准的右边。
  3. 递归 (Recursion):对基准左右两边的子数组重复上述过程,直到子数组只包含一个元素或为空。
  4. 合并 (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 的工作流程可以概括为以下三个核心步骤:

  1. 生成 Virtual DOM 树 (Create Virtual DOM Tree)

    • 当状态数据发生变化时,框架会根据新的数据生成一棵新的 Virtual DOM 树
    • 这个过程只是 JavaScript 对象的创建和赋值,非常高效。
  2. 比较两棵树的差异 (Diffing / Reconciliation)

    • 框架会将新生成的 Virtual DOM 树与旧的 Virtual DOM 树进行逐层、逐节点地比较,找出它们之间的最小差异
    • 这个比较算法(Diff 算法)是 Virtual DOM 的核心,它能够高效地找出需要更新的部分,而不是简单地重新渲染整个 DOM。
  3. 更新视图 (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 交互时,它们的细微差别至关重要。

面试官常问: mouseovermouseenter 有什么区别?

  • 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>

预期行为:

  1. 鼠标从外部移入 #container
    • Container: mouseover (冒泡)
    • Container: mouseenter (不冒泡)
  2. 鼠标从 #container 内部移入 #box
    • Box: mouseover (冒泡)
    • Box: mouseenter (不冒泡)
    • 接着,由于 Boxmouseover 冒泡到 Container,所以会再次触发:
      • Container: mouseover (冒泡) (注意:mouseenter 不会再次触发)
  3. 鼠标从 #box 移回 #container 内部 (不离开 #container):
    • Container: mouseover (冒泡) (因为鼠标再次“经过”了 Container 的区域,即使它已经离开了 Box)
    • Box: mouseout (未监听,但会触发)
    • Box: mouseleave (未监听,但会触发)
  4. 鼠标从 #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

解析:

手写深拷贝的难点在于以下几个方面:

  1. 区分基本类型与引用类型: 基本类型直接返回,引用类型需要递归复制。
  2. 处理数组与对象: 需要根据源对象的类型创建新的数组或对象。
  3. 避免原型链上的属性被拷贝: 使用 hasOwnProperty 确保只拷贝对象自身的属性。
  4. 处理循环引用 (Circular Reference): 这是最关键也是最容易被忽略的点。当对象中存在相互引用时,如果不加处理,递归会陷入无限循环,导致栈溢出。

循环引用解决方案:WeakMap

  • 我们使用一个 WeakMap (或者 Map) 来存储已经访问过的对象和它们对应的副本。
  • 在每次递归开始时,先检查当前对象是否已经在 WeakMap 中。如果存在,直接返回其对应的副本,从而切断循环。
  • 在创建新对象/数组后,立即将原始对象和新创建的副本存入 WeakMap,以备后续引用。

WeakMap 的优势:

  • 弱引用: WeakMap 的键是弱引用,当键所引用的对象被垃圾回收时,WeakMap 中的对应条目也会被自动移除,有助于防止内存泄漏。这对于深拷贝这种临时映射非常合适。

面试官可能会追问:

  • 深拷贝和浅拷贝的区别?
  • JSON.parse(JSON.stringify(obj)) 可以实现深拷贝吗?它的优缺点是什么?(无法拷贝函数、Symbol、undefined,无法处理循环引用,无法拷贝特殊对象如 Date, RegExp)
  • 如果对象属性是 Symbol 或不可枚举的,如何处理?(使用 Reflect.ownKeysObject.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!祝君好运,我们顶峰相见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值