实现目标功能

Q1:二分查找

难度:⭐⭐

解析

在 JavaScript 中,可以使用几种不同的方法来实现二分查找算法,具体取决于个人的编程偏好和问题的特性。以下是几种常见的实现方式:

  1. 迭代法

    使用 while 循环来不断缩小搜索范围,直到找到目标值或确定目标值不存在于数组中。

  2. 递归法

    通过递归调用来实现二分查找算法,将数组分割成更小的部分

无论使用哪种方法,二分查找的核心思想都是将搜索范围一分为二,通过比较中间元素和目标值的大小关系来决定继续搜索的方向,从而达到快速查找的目的

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function binarySearch(arr, target) {
let left = 0;
let right = arr.length - 1;

// 处理边界情况
if (target < arr[left] || target > arr[right]) {
return -1;
}

while (left <= right) {
// 避免数值溢出
let mid = left + Math.floor((right - left) / 2);
let guess = arr[mid];

if (guess === target) {
// 继续向左扫描,找到第一个等于目标值的索引
while (arr[mid - 1] === target) {
mid--;
}
return mid; // 返回第一个等于目标值的索引
} else if (guess < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}

return -1; // 目标值不存在于数组中
}

// 示例用法:
const arr = [1, 3, 5, 7, 9, 9, 9, 11, 13, 15];
const target = 9;
const index = binarySearch(arr, target);
if (index !== -1) {
console.log(`目标值 ${target} 的第一个索引为:${index}`);
} else {
console.log(`目标值 ${target} 不存在于数组中。`);
}
引申

递归法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function binarySearchRecursive(arr, target, left = 0, right = arr.length - 1) {
// 处理边界情况
if (left > right || left < 0 || right >= arr.length) {
return -1; // 目标值不存在于数组中
}

// 计算中间索引
let mid = left + Math.floor((right - left) / 2);
let guess = arr[mid];

if (guess === target) {
// 继续向左扫描,找到第一个等于目标值的索引
while (arr[mid - 1] === target) {
mid--;
}
return mid; // 返回第一个等于目标值的索引
} else if (guess < target) {
return binarySearchRecursive(arr, target, mid + 1, right); // 在右半部分继续查找
} else {
return binarySearchRecursive(arr, target, left, mid - 1); // 在左半部分继续查找
}
}

// 示例用法:
const arr = [1, 3, 5, 7, 9, 11, 13, 15];
const target = 9;
const index = binarySearchRecursive(arr, target);
if (index !== -1) {
console.log(`目标值 ${target} 的第一个索引为:${index}`);
} else {
console.log(`目标值 ${target} 不存在于数组中。`);
}


Q2:怎么预防用户连续快速点击,造成数据多次提交?

难度:⭐⭐

答案

为了防止用户连续快速点击造成数据多次提交,你可以考虑以下几种方法:

  1. 防抖(Debouncing)

    • 防抖是一种控制函数调用频率的技术,通过延迟执行函数来确保在一定时间内只触发一次。当用户连续快速点击时,只有最后一次点击会触发实际的操作。

    • 在前端中,你可以使用 JavaScript 中的 setTimeout和 clearTimeout

      来实现防抖。例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      let timer;

      function debounce(fn, delay) {
      clearTimeout(timer);
      timer = setTimeout(() => {
      fn();
      }, delay);
      }

      // 在点击事件中使用防抖
      button.addEventListener('click', () => {
      debounce(submitData, 1000); // 1000 毫秒内只会触发一次 submitData 函数
      });
  2. 禁用按钮

    • 在用户点击后,禁用提交按钮,防止连续点击。可以在提交后添加一个 loading 状态,完成后再启用按钮。

    • 例如:

      1
      2
      3
      4
      button.addEventListener('click', () => {
      button.disabled = true;
      submitData();
      });
  3. 点击间隔判断

    • 记录上一次点击的时间戳,在点击时判断与上一次点击的时间间隔,只有在时间间隔足够长时才执行实际的提交操作。

    • 例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      let lastClickTime = 0;

      button.addEventListener('click', () => {
      const currentTime = new Date().getTime();
      if (currentTime - lastClickTime > 1000) { // 限定间隔为 1000 毫秒
      submitData();
      lastClickTime = currentTime;
      }
      });


Q3:实现温度转换函数,让华氏度跟摄氏度可以互相转换,结果保留两位小数

难度:⭐

解析

公式:(摄氏度 * 9/5) + 32

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 将摄氏度转换为华氏度
function celsiusToFahrenheit(celsius) {
// 公式:(摄氏度 * 9/5) + 32
const fahrenheit = (celsius * 9/5) + 32;
return parseFloat(fahrenheit.toFixed(2)); // 保留两位小数
}

// 将华氏度转换为摄氏度
function fahrenheitToCelsius(fahrenheit) {
// 公式:(华氏度 - 32) * 5/9
const celsius = (fahrenheit - 32) * 5/9;
return parseFloat(celsius.toFixed(2)); // 保留两位小数
}

// 示例用法
const celsiusValue = 25; // 25摄氏度
const fahrenheitValue = 77; // 77华氏度

const convertedToFahrenheit = celsiusToFahrenheit(celsiusValue);
const convertedToCelsius = fahrenheitToCelsius(fahrenheitValue);

console.log(`${celsiusValue}摄氏度 = ${convertedToFahrenheit}华氏度`);
console.log(`${fahrenheitValue}华氏度 = ${convertedToCelsius}摄氏度`);


Q4:使用原生js给一个按钮绑定两个onclick事件

难度:⭐

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multiple Click Events</title>
</head>
<body>

<button id="myButton">Click me</button>

<script>
// 获取按钮元素
const myButton = document.getElementById('myButton');

// 第一个click事件处理函数
function handleClick1() {
alert('First click event!');
}

// 第二个click事件处理函数
function handleClick2() {
alert('Second click event!');
}

// 给按钮添加第一个click事件
myButton.addEventListener('click', handleClick1);

// 给按钮添加第二个click事件
myButton.addEventListener('click', handleClick2);
</script>

</body>
</html>


Q5:给某个资源的链接,如 https://www.baidu.com/index.html请实现一个方法,获取该资源的后缀,如 html

难度:⭐

答案
  1. 切割

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function getFileExtension(url) {
    // 获取最后一个斜杠后面的内容
    const filename = url.split('/').pop();
    // 获取最后一个点后面的内容作为后缀
    const extension = filename.split('.').pop();
    return extension;
    }

    // 示例用法
    const url = "https://www.baidu.com/index.html";
    const fileExtension = getFileExtension(url);
    console.log(fileExtension); // 输出 "html"

    这个方法首先通过拆分URL来获取文件名,然后再从文件名中获取后缀。需要注意的是,这个方法假设文件名中只有一个点作为后缀名的分隔符,并且后缀名在点后面。如果URL中包含查询参数或片段标识符,可能需要进行额外的处理以避免错误。

  2. 正则

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    function getFileExtension(url) {
    // 使用正则表达式匹配最后一个点后面的内容作为后缀
    const extension = url.match(/\.([^.]+)$/);
    if (extension) {
    return extension[1];
    } else {
    return ""; // 如果没有后缀,则返回空字符串
    }
    }

    // 示例用法
    const url = "https://www.baidu.com/index.html";
    const fileExtension = getFileExtension(url);
    console.log(fileExtension); // 输出 "html"

    这个方法使用正则表达式 \.[^.]+$ 来匹配最后一个点后面的内容作为后缀,然后返回匹配到的结果。如果没有找到后缀,则返回空字符串。与之前的方法相比,这种方法更加灵活,可以处理更多可能的情况。

  3. 获取索引

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function getFileExtension(url) {
    // 找到最后一个点的索引
    const lastDotIndex = url.lastIndexOf('.');
    if (lastDotIndex !== -1) {
    // 切片并获取后缀
    const extension = url.slice(lastDotIndex + 1);
    return extension;
    } else {
    return ""; // 如果没有后缀,则返回空字符串
    }
    }

    // 示例用法
    const url = "https://www.baidu.com/index.html";
    const fileExtension = getFileExtension(url);
    console.log(fileExtension); // 输出 "html"

    这个方法首先使用 lastIndexOf() 方法找到最后一个点的索引,然后通过切片获取后缀。如果没有找到后缀,则返回空字符串。这种方法比较简洁,并且与正则表达式相比,可能具有更好的性能。


Q6:一个滚动公告组件,如何在鼠标滑入时停止播放,在鼠标离开时继续等待滑入时的剩余等待时间后播放?

难度:⭐

答案

要实现这样的滚动公告组件,你可以使用 JavaScript 来控制滑入和滑出事件,并结合定时器来暂停和恢复播放。下面是一个基本的实现示例:

HTML:

1
<div id="announcement">这是滚动公告内容</div>

JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 获取公告元素
const announcementElement = document.getElementById("announcement");
// 设置滚动公告的内容和滚动速度
const announcementContent = "这是滚动公告内容,这是滚动公告内容,这是滚动公告内容";
const scrollSpeed = 50; // 滚动速度(单位:毫秒)

// 定义滚动公告函数
function scrollAnnouncement() {
announcementElement.innerText += " " + announcementContent; // 添加滚动内容
const intervalId = setInterval(() => {
// 检查是否需要停止滚动
if (!announcementElement.getAttribute("data-paused")) {
// 滚动内容
announcementElement.scrollLeft += 1;
// 如果内容滚动到末尾,则重置滚动位置
if (announcementElement.scrollLeft >= announcementElement.scrollWidth - announcementElement.clientWidth) {
announcementElement.scrollLeft = 0;
}
}
}, scrollSpeed);

// 将定时器ID存储在元素的自定义属性中
announcementElement.setAttribute("data-interval-id", intervalId);
}

// 初始滚动
scrollAnnouncement();

// 鼠标移入时暂停滚动
announcementElement.addEventListener("mouseenter", function() {
// 获取剩余等待时间
const remainingTime = scrollSpeed - (announcementElement.scrollWidth - announcementElement.scrollLeft) % scrollSpeed;
// 停止滚动
clearInterval(announcementElement.getAttribute("data-interval-id"));
announcementElement.setAttribute("data-paused", true);
// 等待剩余时间后继续滚动
setTimeout(() => {
announcementElement.removeAttribute("data-paused");
scrollAnnouncement();
}, remainingTime);
});

// 鼠标移出时继续滚动
announcementElement.addEventListener("mouseleave", function() {
// 继续滚动
announcementElement.removeAttribute("data-paused");
scrollAnnouncement();
});

这个代码片段首先定义了一个滚动公告的容器,然后设置了滚动的速度和内容。接着定义了一个函数 scrollAnnouncement() 来执行滚动公告的动作,并设置了一个定时器来控制滚动的频率。在鼠标移入和移出事件中,暂停和恢复滚动的操作通过设置一个自定义属性 data-paused 来实现。当鼠标移入时,先暂停滚动,然后等待剩余的滚动时间后再继续滚动。


Q7:怎么把十进制的 0.2 转换成二进制?

难度:⭐

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function decimalToBinary(decimal, precision) {
let binary = "0."; // 二进制结果的初始部分
let remainder = decimal; // 初始余数为十进制数

// 循环指定的精度,进行乘以 2 取整操作
for (let i = 0; i < precision; i++) {
// 将余数乘以 2
remainder *= 2;
// 如果结果大于等于 1,则整数部分为 1,否则为 0
if (remainder >= 1) {
binary += "1";
remainder -= 1; // 更新余数
} else {
binary += "0";
}
}

return binary;
}

// 调用函数进行转换,精度为 16 位
const binaryRepresentation = decimalToBinary(0.2, 16);
console.log(binaryRepresentation); // 输出 "0.0011001100110011"

这个函数 decimalToBinary() 接受两个参数:要转换的十进制数和转换后二进制的精度(即位数)。然后通过循环将小数不断乘以 2,并根据结果的整数部分确定二进制的相应位数。最终返回的二进制字符串即为所需的结果。

引申

乘以 2 取整法和除以 2 取余法

  1. 乘以 2 取整法
    • 优点:
      • 不涉及除法运算,乘法运算通常比除法运算更高效,因此在某些情况下可能更快。
      • 适用于不需要精确表示小数的情况,例如计算机中的浮点数表示。
    • 计算方式:
      • 首先将十进制数乘以 2,然后取整部分作为二进制的下一位数。
      • 如果乘以 2 的结果大于等于 1,则记录为 1,并将结果减去 1;否则记录为 0。
      • 然后将结果乘以 2,继续重复以上步骤,直到得到所需的位数或者小数部分为 0。
    • 适用场景:
      • 在计算机程序中,特别是涉及到浮点数转换为二进制表示时,可能更常用乘以 2 取整法。
  2. 除以 2 取余法
    • 优点:
      • 更直观易懂,与人们对十进制到二进制的转换过程更为熟悉。
      • 对于需要精确表示小数的情况,除以 2 取余法可以提供更准确的结果。
    • 计算方式:
      • 首先将十进制数除以 2,然后取余数作为二进制的下一位数。
      • 将商作为新的要除以 2 的十进制数。
      • 继续重复以上步骤,直到得到的商为 0。
    • 适用场景:
      • 在需要精确表示小数的情况下,例如数学计算、金融领域等,除以 2 取余法可能更为适用。


Q8:请对以下数组,根据born”的值降序排列

难度:⭐

1
2
3
4
5
6
const singers = [  
{ name: 'Steven Tyler', band: 'Aerosmith', born: 1948 },
{ name: 'Karen Carpenter', band: 'The Carpenters', born: 1950 },
{ name: 'Kurt Cobain', band: 'Nirvana', born: 1967 },
{ name: 'Stevie Nicks', band: 'Fleetwood Mac', born: 1948 },
];
答案

使用 sort() 方法和箭头函数:

1
singers.sort((a, b) => b.born - a.born);
引申
  1. 使用 reduce() 方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const sortedSingers = singers.reduce((acc, cur) => {
    const insertIndex = acc.findIndex(item => item.born < cur.born);
    if (insertIndex === -1) {
    acc.push(cur);
    } else {
    acc.splice(insertIndex, 0, cur);
    }
    return acc;
    }, []);

    这种方法使用了 reduce() 方法遍历原数组,然后根据 born 属性的值将元素插入到新数组的适当位置,最终得到的新数组就是按 born 属性的值降序排列的数组。

  2. 使用 Array.prototype.concat() 和 for…of 循环

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const sortedSingers = [];
    for (const singer of singers) {
    let inserted = false;
    for (let i = 0; i < sortedSingers.length; i++) {
    if (singer.born > sortedSingers[i].born) {
    sortedSingers.splice(i, 0, singer);
    inserted = true;
    break;
    }
    }
    if (!inserted) {
    sortedSingers.push(singer);
    }
    }

    这种方法使用了 for…of 循环遍历原数组,然后根据 born 属性的值将元素插入到新数组的适当位置,最终得到的新数组就是按 born 属性的值降序排列的数组。

  3. 使用 Array.prototype.unshift() 方法

    1
    2
    3
    4
    5
    6
    const sortedSingers = [];
    singers.forEach(singer => {
    let i = 0;
    while (sortedSingers[i] && singer.born < sortedSingers[i].born) i++;
    sortedSingers.splice(i, 0, singer);
    });

    这种方法使用了 forEach() 方法遍历原数组,然后根据 born 属性的值将元素插入到新数组的适当位置,最终得到的新数组就是按 born 属性的值降序排列的数组。

  4. 使用 Array.prototype.push() 和 Array.prototype.pop() 方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const sortedSingers = [];
    singers.forEach(singer => {
    const index = sortedSingers.findIndex(item => item.born < singer.born);
    if (index === -1) {
    sortedSingers.push(singer);
    } else {
    sortedSingers.splice(index, 0, singer);
    }
    });

    这种方法使用了 forEach() 方法遍历原数组,然后根据 born 属性的值将元素插入到新数组的适当位置,最终得到的新数组就是按 born 属性的值降序排列的数组。

  5. 使用 Array.prototype.push() 和 Math.max() 方法

    1
    2
    3
    4
    5
    const sortedSingers = [];
    singers.forEach(singer => {
    const index = Math.max(...sortedSingers.map(item => item.born < singer.born ? sortedSingers.indexOf(item) : -1));
    sortedSingers.splice(index + 1, 0, singer);
    });

    这种方法使用了 forEach() 方法遍历原数组,然后根据 born 属性的值将元素插入到新数组的适当位置,最终得到的新数组就是按 born 属性的值降序排列的数组。

  6. 使用 Array.prototype.reduceRight() 方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const sortedSingers = singers.reduceRight((acc, cur) => {
    const insertIndex = acc.findIndex(item => item.born < cur.born);
    if (insertIndex === -1) {
    acc.push(cur);
    } else {
    acc.splice(insertIndex, 0, cur);
    }
    return acc;
    }, []);

    这种方法使用了 reduceRight() 方法遍历原数组,然后根据 born 属性的值将元素插入到新数组的适当位置,最终得到的新数组就是按 born 属性的值降序排列的数组。


Q9 :遍历一个任意长度的list中的元素并依次创建异步任务如何获取所有任务的执行结果?

难度:⭐

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function asyncTask(item) {
return new Promise((resolve, reject) => {
// 异步操作,例如请求数据或其他耗时任务
// 假设这里是一个异步任务,返回处理结果
setTimeout(() => {
resolve(`Processed item: ${item}`);
}, Math.random() * 1000);
});
}

const myList = [1, 2, 3, 4, 5];
const promises = myList.map(item => asyncTask(item));


// 使用 Promise.all():
Promise.all(promises)
.then(results => {
console.log("All tasks completed successfully:", results);
})
.catch(error => {
console.error("Error processing tasks:", error);
});


// 使用 Promise.allSettled():
Promise.allSettled(promises)
.then(results => {
const successfulResults = results.filter(result => result.status === 'fulfilled').map(result => result.value);
const failedResults = results.filter(result => result.status === 'rejected').map(result => result.reason);

console.log("Successful tasks:", successfulResults);
console.log("Failed tasks:", failedResults);
});

引申

Promise.all()Promise.allSettled() 是两种不同的 Promise 组合方法,它们之间有一些关键的区别:

  1. Promise.all()
    • 当所有 Promise 都成功解决时,返回一个包含所有 Promise 结果的数组。
    • 如果其中任何一个 Promise 被拒绝(rejected),则立即将返回的 Promise 对象标记为拒绝,并且会传递第一个被拒绝的 Promise 的拒因(rejection reason)。
    • 如果有一个 Promise 被拒绝,那么其他 Promise 的执行依然会继续,但 Promise.all() 返回的 Promise 对象将被标记为拒绝,无法获取其他 Promise 的结果。
  2. Promise.allSettled()
    • 等待所有 Promise 执行完成,不管是成功还是失败,都会返回一个包含所有 Promise 执行结果的数组。
    • 返回的 Promise 对象状态总是成功的,不会因为其中的某个 Promise 被拒绝而导致整个组合 Promise 被拒绝。
    • 返回的数组中的每个元素都是一个对象,代表对应的 Promise 执行结果,包括状态(fulfilled 或 rejected)和对应的值或拒因。

简而言之,Promise.all() 等待所有 Promise 解决,如果有一个被拒绝则整个组合 Promise 被拒绝;而 Promise.allSettled() 等待所有 Promise 执行完成,无论成功或失败,返回的 Promise 总是成功的,并提供每个 Promise 的执行结果。


Q10:怎么把函数中的 arguments 转成数组?

难度:⭐

答案
  1. Array.from() 方法

    • 使用 Array.from() 方法可以将类数组对象转换为数组。
    • 实现原理:Array.from() 方法从一个类数组或可迭代对象中创建一个新的数组实例。它会遍历类数组对象的每个元素,并将其添加到新数组中。
    1
    2
    3
    4
    5
    6
    7
    function myFunction() {
    const argsArray = Array.from(arguments);
    return argsArray;
    }

    const result = myFunction(1, 2, 3);
    console.log(result); // 输出: [1, 2, 3]
  2. Array.prototype.slice.call() 方法

    • 使用 Array.prototype.slice.call() 方法通过调用数组的 slice() 方法,将一个类数组对象转换为数组。
    • 实现原理:slice() 方法可以接受两个参数,第一个参数是开始截取的索引,第二个参数是结束截取的索引。如果省略第二个参数,则从开始索引截取到数组末尾。因为我们没有传入任何参数,所以它会将整个类数组对象转换为数组。
    1
    2
    3
    4
    5
    6
    7
    function myFunction() {
    const argsArray = Array.prototype.slice.call(arguments);
    return argsArray;
    }

    const result = myFunction(1, 2, 3);
    console.log(result); // 输出: [1, 2, 3]
  3. 扩展运算符 ...

    • 使用扩展运算符 ... 可以将一个可迭代对象展开为多个元素,并创建一个包含所有参数的数组。
    • 实现原理:扩展运算符 ... 可以将一个可迭代对象展开为多个元素。在这种情况下,arguments 对象是一个可迭代对象,因此使用 ...arguments 可以将其展开为多个参数。
    1
    2
    3
    4
    5
    6
    7
    function myFunction() {
    const argsArray = [...arguments];
    return argsArray;
    }

    const result = myFunction(1, 2, 3);
    console.log(result); // 输出: [1, 2, 3]
引申

在 JavaScript 中,arguments 是一个特殊的对象,用于在函数内部访问传递给函数的参数列表。它类似于数组,但并不是一个真正的数组,而是一个类数组对象(Array-like Object)。arguments 对象包含了所有传递给函数的参数,可以通过索引来访问每个参数。

arguments 对象有以下特点:

  1. 类数组对象arguments 对象类似于数组,但不是一个真正的数组,因为它没有数组的方法和属性。
  2. 自动创建arguments 对象在函数内部自动创建,无需显式声明或定义。
  3. 包含所有参数arguments 对象包含了所有传递给函数的参数,包括函数定义时声明的参数以及调用函数时传递的参数。
  4. 索引访问:可以通过索引来访问 arguments 对象中的每个参数,索引从 0 开始。
  5. length 属性arguments 对象具有 length 属性,表示传递给函数的参数数量。

使用 arguments 对象可以使函数更加灵活,因为它允许函数接受任意数量的参数,而不需要提前定义函数参数的个数。然而,由于 arguments 对象不是一个真正的数组,因此不能直接使用数组的方法,例如 push()pop()。 若要在 arguments 对象上使用数组方法,需要先将其转换为真正的数组。

数组和类数组之间的主要区别:

特点数组(Array)类数组对象(Array-like Object)
数据类型存储任意类型的数据,可以是基本类型和对象等只能存储元素,通常是单一类型的
长度(Length)属性有长度属性 length通常没有 length 属性,除非明确添加
可迭代性(Iterable)可以使用迭代器方法(如 forEach, map通常不能直接使用数组方法,但可以通过其他方式进行遍历,如 for...ofArray.from()
方法和属性拥有数组方法和属性,如 push, pop, slice通常没有数组方法和属性

总的来说,数组是 JavaScript 中的一种特殊对象,拥有一系列方法和属性,可以轻松地操作其元素。而类数组对象通常只能存储元素,缺乏数组的方法和属性,但有时可以通过一些技巧或转换方法来模拟数组的行为。


Q11:写一个repeat 方法,实现字符串的复制拼接

难度:⭐

答案
  1. String.prototype.repeat() 方法

    1
    2
    3
    function repeatString(str, n) {
    return str.repeat(n);
    }
  2. Array.prototype.fill() 方法

    1
    2
    3
    function repeatString(str, n) {
    return new Array(n).fill(str).join('');
    }
  3. Array.prototype.join() 方法

    1
    2
    3
    function repeatString(str, n) {
    return new Array(n + 1).join(str);
    }
  4. Array.prototype.map() 方法

    1
    2
    3
    function repeatString(str, n) {
    return Array.from({ length: n }, () => str).join('');
    }
  5. 循环

    1
    2
    3
    4
    5
    6
    7
    function repeatString(str, n) {
    let result = '';
    for (let i = 0; i < n; i++) {
    result += str;
    }
    return result;
    }
  6. String.prototype.padStart() 方法

    1
    2
    3
    function repeatString(str, n) {
    return str.padStart(str.length * n, str);
    }
  7. while 循环

    1
    2
    3
    4
    5
    6
    7
    8
    function repeatString(str, n) {
    let result = '';
    while (n > 0) {
    result += str;
    n--;
    }
    return result;
    }
  8. 递归

    1
    2
    3
    4
    function repeatString(str, n) {
    if (n === 1) return str;
    return str + repeatString(str, n - 1);
    }
  9. reduce 方法

    1
    2
    3
    function repeatString(str, n) {
    return Array.from({ length: n }).reduce((acc) => acc + str, '');
    }
  10. Polyfill 方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    if (!String.prototype.repeat) {
    String.prototype.repeat = function(count) {
    'use strict';
    if (this == null)
    throw new TypeError('can\'t convert ' + this + ' to object');
    var str = '' + this;
    // To convert string to integer.
    count = +count;
    // Check NaN
    if (count != count)
    count = 0;
    if (count < 0)
    throw new RangeError('repeat count must be non-negative');
    if (count == Infinity)
    throw new RangeError('repeat count must be less than infinity');
    count = Math.floor(count);
    if (str.length == 0 || count == 0)
    return '';
    // Ensuring count is a 31-bit integer allows us to heavily optimize the
    // main part. But anyway, most current (August 2014) browsers can't handle
    // strings 1 << 28 chars or longer, so:
    if (str.length * count >= 1 << 28)
    throw new RangeError('repeat count must not overflow maximum string size');
    var maxCount = str.length * count;
    count = Math.floor(Math.log(count) / Math.log(2));
    while (count) {
    str += str;
    count--;
    }
    str += str.substring(0, maxCount - str.length);
    return str;
    }
    }
引申
  1. String.prototype.repeat() 方法
    • API:String.prototype.repeat(count)
    • 参数:count,一个非负整数,表示要重复字符串的次数。
    • 返回值:返回一个新的字符串,该字符串包含指定次数的原始字符串拼接在一起的结果。
  2. Array.prototype.fill() 方法
    • API:Array.prototype.fill(value, start, end)
    • 参数:value,要填充到数组中的值;start,填充的起始索引(可选,默认为0);end,填充的结束索引(可选,默认为数组末尾)。
    • 返回值:修改后的数组。
  3. Array.prototype.join() 方法
    • API:Array.prototype.join(separator)
    • 参数:separator,一个可选的字符串,用作分隔符,将数组中的每个元素连接在一起。如果省略,则默认使用逗号作为分隔符。
    • 返回值:返回一个由数组元素组成的字符串,每个元素之间由分隔符分隔。
  4. Array.prototype.map() 方法
    • API:Array.prototype.map(callback, thisArg)
    • 参数:callback,一个用于处理数组元素的函数,该函数接收三个参数:当前值(currentValue)、当前索引(index)、原数组(array);thisArg,可选参数,用作执行回调时的 this 值。
    • 返回值:返回一个新数组,数组中的每个元素都是回调函数的结果。
  5. String.prototype.padStart() 方法
    • API:String.prototype.padStart(targetLength, padString)
    • 参数:targetLength,目标字符串的长度;padString,一个可选的填充字符串,默认为空格。
    • 返回值:返回一个新的字符串,该字符串由原始字符串重复填充至目标长度后返回。
  6. Polyfill 方法
    • 这是一个用于解决在不支持 String.prototype.repeat() 方法的环境中提供兼容性的方法。
    • 它检查是否存在 String.prototype.repeat() 方法,如果不存在则定义一个 Polyfill 方法,否则不做任何操作。
    • 该 Polyfill 方法实现了重复字符串的功能,与原生的 String.prototype.repeat() 方法功能相同。


Q12:使用is生成1-10000的数组

难度:⭐

答案
1
const arr = Array.from({ length: 10000 }, (_, index) => index + 1);

这段代码将创建一个长度为 10000 的数组,并使用箭头函数来填充数组的每个元素,使其从 1 到 10000。


Q13:移动端的点击事件的有延迟,时间是多久,为什么会有?怎么解决这个延时?

难度:⭐⭐

答案

移动端点击事件的延迟通常称为”点击延迟”(Click Delay),在某些移动设备和浏览器中会存在这个问题。这种延迟的主要原因是浏览器为了等待可能的双击事件(double tap),以便在用户进行双击时执行相应的操作。

这个点击延迟的时间通常在 300 毫秒左右,具体时间可能因浏览器和设备而异。

解决这个延时的常见方法包括:

  1. 使用 touchstart 事件

    touchstart 事件触发的速度更快,可以替代 click 事件

    但需要注意的是,touchstart 事件与 click 事件有一些区别,如在移动设备上长按屏幕时不会触发 click 事件,但会触发 touchstart 事件

  2. 使用 CSS 属性 touch-action: none

    通过在元素上设置 touch-action: none CSS 属性来禁用浏览器的默认行为,从而避免点击延迟

    这个方法可能会影响到页面的默认滚动行为,需要根据具体情况来决定是否使用

  3. 使用第三方库或框架

    一些 JavaScript 库或框架(如 FastClick、TapJS 等)专门用于解决移动端点击延迟的问题

    这些库通常会在内部优化事件处理逻辑,以提供更快的响应速度

  4. meta 标签设置

    可以使用 <meta> 标签中的 viewport 属性来控制页面的缩放行为,通过设置 user-scalable=no 来禁止用户缩放页面,可以减少点击延迟

    例如:

    1
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">

    需要注意的是,禁止用户缩放页面可能会影响用户体验,因此需要谨慎使用

  5. 优化页面性能

    优化页面的性能和加载速度,减少资源的请求和加载时间,可以间接地减少点击延迟

    特别是减少 JavaScript 和 CSS 文件的大小和数量,以及优化图片资源的加载方式


Q14:如何判断当前脚本运行在浏览器还是 node 环境中?

难度:⭐⭐

答案
1
2
3
4
5
6
7
8
9
10
if (typeof window !== 'undefined') {
// 在浏览器环境中
console.log('当前环境是浏览器');
} else if (typeof global !== 'undefined') {
// 在 Node.js 环境中
console.log('当前环境是 Node.js');
} else {
// 未知环境
console.log('未知环境');
}
引申

在 JavaScript 中,可以通过检查全局对象来判断当前脚本是在浏览器环境还是 Node.js 环境中运行。在浏览器环境中,全局对象是 window,而在 Node.js 环境中,全局对象是 global


Q15:前端路由’a ->b->c`这样前进,也可以返回c->b->a,用什么数据结构来存比较高效

难度:⭐⭐

解析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Router {
constructor() {
this.backStack = [];
this.forwardStack = [];
this.current = null;
}

navigate(page) {
if (this.current) {
this.backStack.push(this.current);
}
this.current = page;
this.forwardStack.length = 0; // 清空前进栈,因为导航到新页面后不能再前进
}

back() {
if (this.backStack.length > 0) {
this.forwardStack.push(this.current);
this.current = this.backStack.pop();
}
}

forward() {
if (this.forwardStack.length > 0) {
this.backStack.push(this.current);
this.current = this.forwardStack.pop();
}
}
}

// 使用
const router = new Router();
router.navigate('a'); // 用户导航到a
router.navigate('b'); // 用户从a导航到b
router.navigate('c'); // 用户从b导航到c

router.back(); // 用户点击后退,从c回到b
router.back(); // 用户再次点击后退,从b回到a
router.forward(); // 用户点击前进,从a前进到b

在这个例子中,navigate方法用于模拟用户导航到新页面的行为,back方法和forward方法分别用于后退和前进。使用栈结构允许我们保持一个清晰的历史记录,并且能够以逆序访问这些记录,这正是我们在浏览器历史中所期望的行为。

答案
  1. 前进栈(Forward Stack): 当用户从一个页面导航到另一个页面时,将当前页面推入前进栈。
  2. 后退栈(Back Stack): 当用户点击后退时,将当前页面推入后退栈,并从前进栈中弹出顶部页面来显示。
  3. 前进: 当用户点击前进按钮时,将当前页面推入后退栈,并从前进栈中弹出顶部页面来显示。
引申

在计算机科学中,管理这种前进和后退(前进和后退导航)的一种有效方法是使用两个栈(堆栈)——一个用于前进历史,另一个用于后退历史。这种方法在许多浏览器的历史功能的实现中被采用


Q16:怎么预防用户快速连续点击,造成数据多次提交

难度:⭐⭐

答案

为了防止重复提交,前端一般会在第一次提交的结果返回前,将提交按钮禁用

  1. 禁用按钮

    • 在提交操作后立即禁用按钮,防止用户再次点击,直到操作完成或过一段时间后重新启用按钮。

    • 示例代码:

      1
      2
      3
      4
      5
      6
      7
      const button = document.querySelector('#submitButton');
      button.addEventListener('click', () => {
      button.disabled = true;
      submitData()
      .then(() => button.disabled = false)
      .catch((error) => button.disabled = false);
      });
  2. 节流(Throttle)或防抖(Debounce)

    • 使用节流或防抖函数控制点击事件的频率,限制在特定时间内只允许触发一次。

    • 示例代码(防抖):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      const debounce = (fn, delay) => {
      let timeoutId;
      return function (...args) {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => fn.apply(this, args), delay);
      };
      };
      const submitDataDebounced = debounce(submitData, 3000);
      button.addEventListener('click', submitDataDebounced);
  3. 状态管理

    • 使用状态管理追踪提交的状态,如果某次提交尚未完成,则拒绝新的提交。

    • 示例代码:

      1
      2
      3
      4
      5
      6
      7
      8
      let isSubmitting = false;
      button.addEventListener('click', () => {
      if (isSubmitting) return;
      isSubmitting = true;
      submitData()
      .then(() => isSubmitting = false)
      .catch(() => isSubmitting = false);
      });
  4. 全屏遮罩

    • 显示一个全屏遮罩元素阻止用户在关键操作期间与页面交互。

    • 示例代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      const mask = document.createElement('div');
      mask.style = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1000; display: none;';
      document.body.appendChild(mask);
      const showMask = () => mask.style.display = 'block';
      const hideMask = () => mask.style.display = 'none';
      button.addEventListener('click', () => {
      showMask();
      submitData()
      .then(() => hideMask())
      .catch(() => hideMask());
      });


Q17:实现以下转换,合并连续的数字

难度:⭐⭐

1
[1,2,3,4,6,7,9,13,15]=>['1->4','6->7','9','13','15']
解析

mergeConsecutiveNumbers 函数的思路是将输入数组中的连续数字范围合并成字符串形式,然后返回这些合并后的字符串列表。具体的思路如下:

  1. 参数验证
    • 首先,函数会验证输入是否为非空数组,以避免空数组或非数组的输入导致的潜在错误
    • 如果输入不是数组或者数组为空,则函数直接返回空数组
  2. 初始化
    • 初始化一个结果数组 result,用于存储最终的合并结果
    • 定义变量 startend,表示当前连续数字范围的开始和结束。初始值均为输入数组的第一个元素
  3. 帮助函数
    • 定义一个名为 addRangeToResult 的帮助函数,用于将连续数字范围添加到结果数组 result
    • 如果开始和结束是相同的,则表示这是一个单独的数字;否则表示这是一个连续的数字范围
  4. 遍历输入数组
    • 遍历输入数组,从索引 1 开始(因为初始值已经处理了第一个元素)
    • 对于每一个元素 current,检查其是否与 end + 1 相等。如果是,说明 current 是连续的数字,更新 end 变量
    • 如果 current 不等于 end + 1,则表示当前元素不是连续数字的一部分。在这种情况下,调用 addRangeToResult 函数将当前范围添加到结果数组中,然后重置 startend 为当前元素
  5. 处理最后一个范围
    • 在循环结束后,函数调用 addRangeToResult 函数来处理最后一个范围(因为在遍历结束后,可能还有未处理的范围)
  6. 返回结果
    • 最后,函数返回结果数组 result,其中包含了输入数组中的合并后的连续数字范围
答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function mergeConsecutiveNumbers(input) {
// 参数验证
if (!Array.isArray(input) || input.length === 0) {
return [];
}

// 结果数组
const result = [];

// 初始化范围的开始和结束
let start = input[0];
let end = start;

// 帮助函数来添加范围到结果数组
const addRangeToResult = (start, end) => {
if (start === end) {
result.push(start.toString());
} else {
result.push(`${start}->${end}`);
}
};

// 遍历输入数组
for (let i = 1; i < input.length; i++) {
const current = input[i];

// 如果当前数字与前一个数字连续
if (current === end + 1) {
end = current;
} else {
// 否则,将当前范围添加到结果数组
addRangeToResult(start, end);
// 开始一个新的范围
start = current;
end = current;
}
}

// 处理最后一个范围
addRangeToResult(start, end);

return result;
}

// 示例输入
const input = [1, 2, 3, 4, 6, 7, 9, 13, 15];

// 调用函数并输出结果
console.log(mergeConsecutiveNumbers(input)); // 输出 ['1->4', '6->7', '9', '13', '15']


Q18:非递归遍历二叉树

难度:⭐⭐

答案

在数据结构和算法中,非递归遍历二叉树是指通过迭代的方式来遍历二叉树,而不是使用递归的方法。二叉树的遍历方式主要有三种:前序遍历、中序遍历和后序遍历。接下来,我将分别介绍这些遍历方式的非递归实现。

前序遍历(Pre-order Traversal):

在前序遍历中,遍历顺序是根节点 -> 左子树 -> 右子树。非递归实现通常使用栈来模拟递归过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function preOrderTraversal(root) {
if (!root) {
return;
}

const stack = [];
stack.push(root);

while (stack.length > 0) {
const currentNode = stack.pop();
console.log(currentNode.value); // 处理当前节点

// 先将右子节点入栈,因为栈是后进先出
if (currentNode.right) {
stack.push(currentNode.right);
}
// 再将左子节点入栈
if (currentNode.left) {
stack.push(currentNode.left);
}
}
}

中序遍历(In-order Traversal):

在中序遍历中,遍历顺序是左子树 -> 根节点 -> 右子树。非递归实现通常使用栈来辅助遍历。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function inOrderTraversal(root) {
const stack = [];
let currentNode = root;

while (currentNode || stack.length > 0) {
// 向左走到底,将所有左子节点入栈
while (currentNode) {
stack.push(currentNode);
currentNode = currentNode.left;
}

// 弹出一个节点并处理
currentNode = stack.pop();
console.log(currentNode.value); // 处理当前节点

// 继续遍历右子树
currentNode = currentNode.right;
}
}

后序遍历(Post-order Traversal):

在后序遍历中,遍历顺序是左子树 -> 右子树 -> 根节点。非递归实现有几种方法,通常使用栈进行辅助。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function postOrderTraversal(root) {
if (!root) {
return;
}

const stack1 = [];
const stack2 = [];
stack1.push(root);

// 使用两个栈来处理
while (stack1.length > 0) {
const currentNode = stack1.pop();
stack2.push(currentNode);

// 将左子树和右子树依次入栈
if (currentNode.left) {
stack1.push(currentNode.left);
}
if (currentNode.right) {
stack1.push(currentNode.right);
}
}

// 从 stack2 中弹出所有节点即为后序遍历
while (stack2.length > 0) {
const node = stack2.pop();
console.log(node.value); // 处理节点
}
}

通过以上的非递归遍历实现,你可以在不使用递归的情况下对二叉树进行前序遍历、中序遍历和后序遍历。这些实现都使用了栈来模拟递归过程,以达到非递归遍历的目的


Q19 :写一个返回数据类型的函数,要求自定义的类实例化的对象返回定义的类名

难度:⭐⭐

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 返回数据类型的函数
function getTypeOf(obj) {
// 检查参数是否为 null 或 undefined
if (obj === null || obj === undefined) {
return 'null or undefined';
}

// 使用 Object.prototype.toString.call(obj) 获取对象的类型信息
const type = Object.prototype.toString.call(obj);

// 处理自定义类的类名
if (type === '[object Object]') {
if (obj.constructor && obj.constructor.name) {
return obj.constructor.name;
} else {
// 如果无法获取类名,返回 'Object' 作为兜底类型
return 'Object';
}
}

// 其他类型直接返回类型信息
return type;
}

// 示例用法
const myObject = new MyClass('Example');
const myArray = [1, 2, 3];
const myString = 'Hello';
const myNumber = 42;
const myFunction = function() {};

console.log(getTypeOf(myObject)); // 输出: MyClass
console.log(getTypeOf(myArray)); // 输出: [object Array]
console.log(getTypeOf(myString)); // 输出: [object String]
console.log(getTypeOf(myNumber)); // 输出: [object Number]
console.log(getTypeOf(myFunction)); // 输出: [object Function]
console.log(getTypeOf(null)); // 输出: null or undefined
console.log(getTypeOf(undefined)); // 输出: null or undefined
引申

谢谢你提醒我可以详细一些。下面是对比 JavaScript 中几种判断数据类型的方法的表格形式,包括它们的优点和缺点:

判断类型方法优点缺点示例代码
typeof- 简单易用,代码简洁。
- 对于基本数据类型(如字符串、数字、布尔值、函数、未定义)判断准确。
- 对于对象、数组和 null,返回的结果不准确。
- null 的判断结果是 "object",这可能导致混淆。
- 无法区分复杂数据类型(如数组和对象)。
image-20240418185016079
instanceof- 判断对象是否属于某个类(包括内置的类,如 ArrayDate)。
- 适用于对象的类继承关系判断。
- 对于原始数据类型(如字符串、数字等)无效。
- 无法区分原始数据类型和其包装对象(例如 String 对象和字符串)。
image-20240418185032946
Object.prototype.toString.call()- 对各种类型(包括 nullundefined)返回明确的类型信息。
- 可以准确判断复杂数据类型(如数组、日期、正则表达式等)。
- 代码较长,不如 typeof 简洁。image-20240418185041665
Array.isArray()- 专门用于判断数组,判断准确。- 只能判断数组类型,无法判断其他类型。image-20240418185052865


Q20:JQuery的链式调用怎么实现的

难度:⭐⭐

解析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 假设有一个 jQuery 类
class jQuery {
constructor(selector) {
// 模拟选择器操作
this.elements = document.querySelectorAll(selector);
}

// 模拟设置 CSS 样式的方法
css(property, value) {
for (let element of this.elements) {
element.style[property] = value;
}
// 返回 this 对象,实现链式调用
return this;
}

// 模拟添加 CSS 类的方法
addClass(className) {
for (let element of this.elements) {
element.classList.add(className);
}
// 返回 this 对象,实现链式调用
return this;
}

// 模拟设置元素内容的方法
html(content) {
for (let element of this.elements) {
element.innerHTML = content;
}
// 返回 this 对象,实现链式调用
return this;
}
}

// 创建一个 jQuery 对象并进行链式调用
const $div = new jQuery('div')
.css('color', 'red') // 设置字体颜色为红色
.addClass('highlight') // 添加 CSS 类名
.html('Hello, World!'); // 设置元素内容为 "Hello, World!"

console.log($div); // 输出 jQuery 对象,包含了设置后的元素
答案

jQuery 的链式调用是通过在每个 jQuery 方法中返回 jQuery 对象的引用来实现的

当你调用一个 jQuery 方法时,该方法会修改 jQuery 对象的状态,并返回修改后的 jQuery 对象,从而使得可以连续调用其他 jQuery 方法

这种链式调用的实现方式可以使得 jQuery 代码更加清晰和易于编写,同时也能够提高代码的可读性和可维护性


Q21:怎么检测浏览器的版本

难度:⭐⭐

解析

监测浏览器版本是通过获取用户浏览器的 User Agent 字符串,并解析该字符串来获取浏览器的名称和版本信息

通常情况下,User Agent 字符串会包含浏览器的相关信息,包括名称、版本号和操作系统等

以下是一种常见的检测浏览器版本的方法:

  1. 使用 navigator.userAgent 获取用户浏览器的 User Agent 字符串
  2. 根据 User Agent 字符串中是否包含特定浏览器的标识来确定浏览器的类型。常见的浏览器标识包括 "Chrome""Firefox""Safari""Edge""MSIE"
  3. 通过正则表达式匹配来提取出浏览器的版本信息。通常情况下,浏览器的版本号会跟在浏览器名称后面,并且以斜杠 / 分隔,例如 "Chrome/92.0.4515.159"
  4. 根据不同的浏览器类型和版本信息,进行相应的处理或输出

这种方法是一种基于 User Agent 字符串的检测浏览器版本的常用方法。然而,需要注意的是,User Agent 字符串可以被篡改,因此在实际应用中可能会存在一定的不准确性

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 获取浏览器的 user agent 字符串
var userAgent = navigator.userAgent;

// 定义浏览器名称与对应版本匹配的正则表达式
var browserRegex = {
"Chrome": /Chrome\/(\d+)/,
"Firefox": /Firefox\/(\d+)/,
"Edge": /Edge\/(\d+)/,
"Safari": /Version\/(\d+)/,
"IE": /(?:MSIE |rv:)(\d+)/
};

// 检测浏览器的版本
for (var browser in browserRegex) {
if (userAgent.indexOf(browser) != -1) {
var version = userAgent.match(browserRegex[browser])[1];
console.log(browser + " 版本:" + version);
break; // 找到匹配的浏览器后立即跳出循环
}
}

// 如果没有找到匹配的浏览器,输出提示信息
if (!version) {
console.log("无法检测浏览器版本。");
}
引申
  1. Feature Detection(特性检测)

    通过检测浏览器是否支持某些特定的 JavaScript 特性或 API 来判断浏览器版本

    不同版本的浏览器可能会实现不同的特性或 API,因此可以根据特性的支持情况来推断浏览器版本

    这种方法不依赖于 User Agent 字符串,更加可靠,但需要了解不同浏览器版本对特性的支持情况

  2. 嗅探技术(Sniffing)

    通过检测浏览器的行为、属性或功能来确定其类型和版本

    例如,可以检测浏览器的渲染引擎、JavaScript 引擎、CSS 属性支持情况等

    嗅探技术相对复杂,需要针对不同浏览器进行详细的测试和分析,但可以提供更准确的浏览器识别结果

  3. 服务端检测

    在服务器端进行浏览器检测

    当浏览器向服务器发送请求时,服务器可以通过解析请求的 User Agent 字符串来确定浏览器类型和版本,并返回相应的内容或页面

    这种方法相对可靠,但需要在服务器端进行处理,可能会增加服务器的负载和复杂度


Q22:什么是单点登录,以及如何进行实现

难度:⭐⭐

解析
答案

一、单点登录是什么

单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一

SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统

SSO 一般都需要一个独立的认证中心(passport),子系统的登录均得通过passport,子系统本身将不参与登录操作

当一个系统成功登录以后,passport将会颁发一个令牌给各个子系统,子系统可以拿着令牌会获取各自的受保护资源,为了减少频繁认证,各个子系统在被passport授权以后,会建立一个局部会话,在一定时间内可以无需再次向passport发起认证

image-20240510155436715

上图有四个系统,分别是Application1Application2Application3、和SSO,当Application1Application2Application3需要登录时,将跳到SSO系统,SSO系统完成登录,其他的应用系统也就随之登录了

举个例子

淘宝、天猫都属于阿里旗下,当用户登录淘宝后,再打开天猫,系统便自动帮用户登录了天猫,这种现象就属于单点登录

二、如何实现

同域名下的单点登录

cookiedomin属性设置为当前域的父域,并且父域的cookie会被子域所共享。path属性默认为web应用的上下文路径

利用 Cookie 的这个特点,没错,我们只需要将Cookiedomain属性设置为父域的域名(主域名),同时将 Cookiepath属性设置为根路径,将 Session ID(或 Token)保存到父域中。这样所有的子域应用就都可以访问到这个Cookie

不过这要求应用系统的域名需建立在一个共同的主域名之下,如 tieba.baidu.commap.baidu.com,它们都建立在 baidu.com这个主域名之下,那么它们就可以通过这种方式来实现单点登录

不同域名下的单点登录(一)

如果是不同域的情况下,Cookie是不共享的,这里我们可以部署一个认证中心,用于专门处理登录请求的独立的 Web服务

用户统一在认证中心进行登录,登录成功后,认证中心记录用户的登录状态,并将 token 写入 Cookie(注意这个 Cookie是认证中心的,应用系统是访问不到的)

应用系统检查当前请求有没有 Token,如果没有,说明用户在当前系统中尚未登录,那么就将页面跳转至认证中心

由于这个操作会将认证中心的 Cookie 自动带过去,因此,认证中心能够根据 Cookie 知道用户是否已经登录过了

如果认证中心发现用户尚未登录,则返回登录页面,等待用户登录

如果发现用户已经登录过了,就不会让用户再次登录了,而是会跳转回目标 URL,并在跳转前生成一个 Token,拼接在目标URL 的后面,回传给目标应用系统

应用系统拿到 Token之后,还需要向认证中心确认下 Token 的合法性,防止用户伪造。确认无误后,应用系统记录用户的登录状态,并将 Token写入Cookie,然后给本次访问放行。(注意这个 Cookie 是当前应用系统的)当用户再次访问当前应用系统时,就会自动带上这个 Token,应用系统验证 Token 发现用户已登录,于是就不会有认证中心什么事了

此种实现方式相对复杂,支持跨域,扩展性好,是单点登录的标准做法

不同域名下的单点登录(二)

可以选择将 Session ID (或 Token )保存到浏览器的 LocalStorage 中,让前端在每次向后端发送请求时,主动将LocalStorage的数据传递给服务端

这些都是由前端来控制的,后端需要做的仅仅是在用户登录成功后,将 Session ID(或 Token)放在响应体中传递给前端

单点登录完全可以在前端实现。前端拿到 Session ID(或 Token )后,除了将它写入自己的 LocalStorage 中之外,还可以通过特殊手段将它写入多个其他域下的 LocalStorage

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 获取 token 
var hl-= result.data.token;
// 动态创建一个不可见的iframe,在iframe中加载一个跨域HTML
var iframe = document.createElement("iframe")
iframe.src = "http://app1.com/localstorage.html"
document.body.append(iframe);
// 使用postMessage()方法将token传递给iframe
setTimeout(function () { iframe.contentWindow.postMessage(token, "http://app1.com")
}, 4000)
setTimeout(function () {
iframe.remove()
}, 6000);
// 在这个iframe所加载的HTML中绑定一个事件监听器,当事件被触发时,把接收到的token数据写入localStorage
window.addEventListener('message', function (event) { localStorage.setItem('token', event.data)
}, false)

前端通过 iframe+postMessage() 方式,将同一份 Token 写入到了多个域下的 LocalStorage 中,前端每次在向后端发送请求之前,都会主动从 LocalStorage 中读取Token并在请求中携带,这样就实现了同一份Token 被多个域所共享

此种实现方式完全由前端控制,几乎不需要后端参与,同样支持跨域

三、流程

单点登录的流程图如下所示:

image-20240510155953411

  • 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
  • sso认证中心发现用户未登录,将用户引导至登录页面
  • 用户输入用户名密码提交登录申请
  • sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
  • sso认证中心带着令牌跳转会最初的请求地址(系统1)
  • 系统1拿到令牌,去sso认证中心校验令牌是否有效
  • sso认证中心校验令牌,返回有效,注册系统1
  • 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
  • 用户访问系统2的受保护资源
  • 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
  • sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
  • 系统2拿到令牌,去sso认证中心校验令牌是否有效
  • sso认证中心校验令牌,返回有效,注册系统2
  • 系统2使用该令牌创建与用户的局部会话,返回受保护资源

用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话

用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心

全局会话与局部会话有如下约束关系:

  • 局部会话存在,全局会话一定存在
  • 全局会话存在,局部会话不一定存在
  • 全局会话销毁,局部会话必须销毁

单点登录参考文章

引申


Q23:如何判断一个元素是否在可视区域中

难度:⭐⭐

答案
  1. element.getBoundingClientRect()

    使用element.getBoundingClientRect()方法,它会返回元素的大小及其相对于视口的位置

    这个方法返回一个DOMRect对象,包含了top, right, bottom, left, width, height这些值,其中topleft表示元素左上角相对于视口的位置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function isInViewport(element) {
    const rect = element.getBoundingClientRect();
    return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
    );
    }

    这个函数会返回一个布尔值,如果元素完全在视口内部则为true,否则为false

  2. Intersection Observer API

    Intersection Observer API提供了一种异步检测目标元素与其祖先元素或顶级文档视窗(viewport)交叉状态变化的方式

    这种方法非常适合用来实现懒加载、无限滚动等功能

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 通过IntersectionObserver构造函数创建一个观察者对象
    let observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
    // 如果entry.isIntersecting为true,表示元素进入可视区
    if (entry.isIntersecting) {
    console.log('元素已进入可视区域');
    // 做一些操作,比如加载图片等
    // 完成操作后,可以取消观察
    observer.unobserve(entry.target);
    }
    });
    });

    // 调用observe方法来观察一个元素
    observer.observe(document.querySelector('#yourElementId'));


Q24:什么是防抖和节流,以及如何编码实现

难度:⭐⭐

解析
答案

是什么

本质上是优化高频率执行代码的一种手段

如:浏览器的 resizescrollkeypressmousemove 等事件在触发时,会不断地调用绑定在事件上的回调函数,极大地浪费资源,降低前端性能

为了优化体验,需要对这类事件进行调用次数的限制,对此我们就可以采用throttle(节流)和debounce(防抖)的方式来减少调用频率

属性/技术防抖 (Debouncing)节流 (Throttling)
定义在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。在固定时间间隔内只执行一次事件回调。
相同点两者都是性能优化的技术,用于减少事件处理器执行的频率。同左。
不同点防抖关注于延迟执行。一段时间内的多次操作只会执行最后一次。节流关注于定时执行。一段时间内的操作,无论触发多少次,只会定期执行一次。
应用场景- 搜索框搜索输入。(减少请求次数)
- 窗口大小调整(resize)。(避免重绘和重流)
- 表单验证。(停止输入一段时间后进行验证)
- 滚动加载,滚动事件的无限加载。(比如滚动到页面底部自动加载内容)
- 轮播图用户快速多次点击箭头按钮。(限制用户点击频率)
- 测量滚动位置。(例如固定时间内只计算一次滚动距离)

image-20240510161540382

举例:

每天上班大厦底下的电梯。把电梯完成一次运送,类比为一次函数的执行和响应

假设电梯有两种运行策略 debouncethrottle,超时设定为15秒,不考虑容量限制

电梯第一个人进来后,15秒后准时运送一次,这是节流

电梯第一个人进来后,等待15秒。如果过程中又有人进来,15秒等待重新计时,直到15秒后开始运送,这是防抖

代码实现

  1. 防抖

    • 立即执行

      事件触发后立即执行,然后n秒内不再执行

      这种实现方式的效果是,事件触发后立即执行,然后在等待的n秒内如果事件又被触发则重新等待n秒

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      function debounce(func, wait) {
      let timeout;
      return function() {
      const context = this;
      const args = arguments;
      if (timeout) clearTimeout(timeout);
      let callNow = !timeout;
      timeout = setTimeout(() => {
      timeout = null;
      }, wait);
      if (callNow) func.apply(context, args);
      }
      }
    • 非立即执行

      事件触发后不立即执行,等待n秒后执行,如果在这n秒内又触发了事件,则重新开始等待

      这种实现方式的效果是,当事件在n秒内没有再次被触发,才会执行回调函数。即当你滚动滚动条时,滚动结束后n秒再执行,如果滚动过程中又滚动了,那么重新等待n秒

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      function debounce(func, wait) {
      let timeout;
      return function() {
      const context = this;
      const args = arguments;
      if (timeout) clearTimeout(timeout);
      timeout = setTimeout(() => {
      func.apply(context, args);
      }, wait);
      }
      }
  2. 节流

    • 时间戳方式

      时间戳方式的节流函数会立即执行第一次触发的事件,然后如果在设定的时间间隔内再次触发事件,这次触发的事件不会被执行

      只有当过了设定的时间间隔后,才会再次执行事件处理函数

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      function throttle(func, wait) {
      let previous = 0;
      return function() {
      const now = Date.now();
      const context = this;
      const args = arguments;
      if (now - previous > wait) {
      func.apply(context, args);
      previous = now;
      }
      }
      }
    • 定时器方式

      定时器方式的节流函数会在第一次触发事件时不立即执行事件处理函数,而是设置一个延时,等待指定的时间间隔后执行。如果在这个延时期间内再次触发了事件,这次触发的事件不会被执行

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      function throttle(func, wait) {
      let timeout;
      return function() {
      const context = this;
      const args = arguments;
      if (!timeout) {
      timeout = setTimeout(() => {
      timeout = null;
      func.apply(context, args);
      }, wait);
      }
      }
      }
    • 两者结合方式

      有时候,为了同时满足立即执行第一次触发的事件和结束触发时还能执行一次事件处理函数的需求,可以将时间戳方式和定时器方式结合起来使用

      这种方式结合了两者的优点,第一次触发事件会立即执行,之后如果在设定的时间间隔内再次触发事件,则会延迟执行,确保在结束触发后还能再执行一次

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      function throttle(func, wait) {
      let timeout, context, args, previous = 0;

      const later = function() {
      previous = Date.now();
      timeout = null;
      func.apply(context, args);
      };

      return function() {
      const now = Date.now();
      const remaining = wait - (now - previous);
      context = this;
      args = arguments;
      if (remaining <= 0 || remaining > wait) {
      if (timeout) {
      clearTimeout(timeout);
      timeout = null;
      }
      previous = now;
      func.apply(context, args);
      } else if (!timeout) {
      timeout = setTimeout(later, remaining);
      }
      };
      }


Q25:Javascript中如何实现函数缓存?函数缓存有哪些应用场景

难度:⭐⭐

解析
答案

函数缓存,也被称为记忆化(Memoization),是一种优化技术,主要用于存储昂贵函数调用的结果,然后在再次调用同样的输入时直接返回缓存的结果

  1. 闭包(Closures)

    使用闭包可以缓存函数的计算结果

    闭包可以访问函数外部的变量,因此可以将函数结果存储在一个变量中,并在下次执行时首先检查该变量

  2. 映射对象(Map Object)

    Map对象可以存储键值对,并且能记住原始插入顺序

    你可以用传递给函数的参数作为键,把函数返回的结果作为值来进行存储

  3. 高阶函数(Higher Order Functions)

    可以创建一个高阶函数,它接受一个函数并返回一个新的函数

    这个新函数会检查是否已经缓存了传递给原函数的特定参数的结果

实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function memoize(fn) {
const cache = new Map();
return function(...args) {
// 使用args作为键创建一个唯一的表示,这里简单地将参数连接为字符串
const key = args.toString();
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}

// 一个简单的加法函数,后面将使用memoize进行缓存
function add(a, b) {
return a + b;
}

// 使用memoize创建一个缓存版本的add函数
const memoizedAdd = memoize(add);

// 第一次调用会执行计算
console.log(memoizedAdd(1, 2)); // 结果为3,进行计算,并存入缓存
// 第二次调用时,同样的参数会直接从缓存中获取结果
console.log(memoizedAdd(1, 2)); // 结果为3,直接从缓存中获取,不再执行计算

使用场景

  1. 重复计算

    当函数需要多次重复计算相同参数的结果时,函数缓存可以避免重复工作,只需计算一次然后存储结果即可。

  2. 昂贵的函数调用

    对于计算成本很高的函数,如涉及复杂算法的函数,使用缓存可以减少执行时间。

  3. 递归函数

    特别是在处理递归函数时,如计算斐波纳契数列等,缓存结果可以大幅度减少计算量。

  4. 数据库查询

    在需要频繁进行相同查询的数据库操作中,函数缓存可以存储之前的查询结果,减少数据库的压力。

  5. API调用

    如果一个API的数据不会频繁更新,通过缓存API调用结果可以节省网络带宽并提高响应速度。

  6. DOM操作的优化

    如果有一个函数根据相同参数总是生成相同的DOM结构,可以通过缓存来优化,避免重复的DOM操作。

  7. 数据转换

    对于数据格式化或转换操作,如日期格式化,如果转换规则复杂且转换对象重复性高,可以用缓存来提高效率。

引申
特性闭包高阶函数函数柯里化
定义函数嵌套函数,内部函数访问外部变量一个至少满足以下一个条件的函数:接受一个或多个函数作为参数,或者返回一个函数一个将多参数函数转换为一系列使用一个参数的函数的技术
作用使函数可以访问其定义时的作用域中的变量,即使它在其定义环境外执行抽象或封装行为,可以用函数作为参数或返回新的函数来构建更加模块化的代码分解复杂的函数参数,使得函数变得易于处理,易于复用,并且能够逐步应用函数
状态保持可以记住和访问创建它的函数的局部变量,参数和其他闭包不一定保持任何状态,可以是无状态的,仅用于抽象计算步骤或创建新的函数不直接关注状态,而是关注参数的重组和分配
返回值会返回一个函数,这个函数可以访问创建它时的环境可以返回函数,也可以返回其他任何类型的值返回接收下一个参数的新函数,直到所有参数被”消耗”
参数处理参数在闭包创建时确定,并在闭包的上下文中保持不变参数的处理取决于具体的高阶函数实现,并非固定模式每次调用仅接受一个参数,并返回接受下一个参数的新函数
使用场景用于创建私有变量和方法,保存状态,实现模块化用于函数抽象和复用,函数组合,以及处理不确定数量的参数用于固定某些参数,简化多参数函数的逆复,以及函数的偏应用


Q26:如何有顺序的执行10个异步任务

难度:⭐⭐⭐

答案
  1. Async/Await

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    async function asyncTask(i) {
    return new Promise((resolve) => setTimeout(() => {
    console.log('Task ' + i);
    resolve(i);
    }, 1000));
    }

    async function runTasks() {
    for (let i = 1; i <= 10; i++) {
    await asyncTask(i); // 等待当前任务完成
    }
    }

    runTasks(); // 启动任务

    在这个例子中,await 关键字使得 JavaScript 引擎等待每个 asyncTask 完成之后再继续循环

  2. Promise 链

    通过在每个异步操作上调用 .then() 方法,可以确保它们按顺序执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function asyncTask(i) {
    return new Promise((resolve) => setTimeout(() => {
    console.log('Task ' + i);
    resolve();
    }, 1000));
    }

    let promise = Promise.resolve();

    for (let i = 1; i <= 10; i++) {
    promise = promise.then(() => asyncTask(i));
    }

    在这个例子中,每个任务都将在前一个任务解决后启动

  3. 使用递归

    通过递归函数顺序执行异步任务

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function asyncTask(i) {
    return new Promise((resolve) => setTimeout(() => {
    console.log('Task ' + i);
    resolve(i);
    }, 1000));
    }

    function runTasks(i) {
    if (i > 10) {
    return;
    }

    asyncTask(i).then(() => runTasks(i + 1));
    }

    runTasks(1);

    当一个任务完成,它会调用runTasks来启动下一个任务


Q27:javascript里面怎么取消请求

难度:⭐⭐⭐

答案
  1. 取消 XMLHttpRequest 请求

    对于 XMLHttpRequest 对象,我们可以使用 abort 方法来取消请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    var xhr = new XMLHttpRequest();
    xhr.open("GET", "https://www.example.com/api", true);

    xhr.onload = function(e) {
    // 请求成功的处理
    };

    xhr.onerror = function(e) {
    // 请求失败的处理
    };

    xhr.send(null);

    // 在需要取消请求的时候调用
    xhr.abort();

    在这个例子中,调用 xhr.abort() 会立即终止请求,然后触发 onabort 事件

    加上对 onerror 的监听可以确保所有的异常情况都有对应的处理

  2. 取消 Fetch 请求

    通过使用 AbortController 和它的 signal 属性,可以取消一个 fetch 请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const controller = new AbortController();
    const signal = controller.signal;

    fetch('https://www.example.com/api', { signal }).then(response => {
    // 请求成功的处理
    }).catch((e) => {
    if (e.name === 'AbortError') {
    console.log('Fetch operation was aborted');
    } else {
    // 其它错误的处理
    }
    });

    // 在需要取消请求的地方调用
    controller.abort();

    在此示例中,创建了一个 AbortController ,并将其 signal 属性传递给 fetch

    要取消请求,在合适的时机调用 controller.abort()

  3. 取消 Axios 请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    const controller = new AbortController();

    axios.get('https://www.example.com/api', {
    signal: controller.signal
    }).then(response => {
    // 请求成功的处理
    }).catch(error => {
    if (axios.isCancel(error)) {
    console.log('Request canceled', error.message);
    } else {
    // 其他错误的处理
    }
    });

    // 当需要取消请求时执行
    controller.abort();

    我们创建了一个 AbortController 的实例,并将它的 signal 属性作为配置选项传递给 Axios 的请求方法

    当调用 controller.abort() 方法时,如果请求仍在进行中,它将被取消,并且 catch 块将捕获到一个异常,其中 axios.isCancel(error) 可以检测这个异常是否是因为请求被取消造成的

    您应该确保使用的 Axios 版本支持 AbortController

    这个特性自 Axios v0.19.0 起被引入


Q28:如何让 var [a, b]={a: 1,b: 2}解构赋值成功

难度:⭐⭐⭐

解析
答案

在浏览器运行的时候,报错如下:

1
2
3
4
5
6
const obj = {
a:'1',
b:'2',
}

const [a,b] = obj

image-20240511174905915

它告诉我们obj这个对象是不可迭代的,只要改成可迭代的,就可以解决这个问题

要想满足迭代协议需要对象身上有一个名为[Symbol.iterator]的方法

再使用for..of或者解构赋值的时候会隐式的调用这个方法,得到一个迭代对象,通过迭代对象的next方法判断当前是否完成迭代和具体迭代的值

也就是说我们要在obj上添加[Symbol.iterator]方法并且完成next方法的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const obj = {
a: '1',
b: '2',
[Symbol.iterator]() {
let index = 0
const keys = Object.keys(this)
return {
next() {
if (index < keys.length) {
return {
done: false,
value: obj[keys[index++]]
}
}
return {done:true,value:undefined}
}
}
}
}
引申

可迭代协议的概念( MDN

可迭代协议是JavaScript中规定的一套标准,它允许对象通过特定的方法定义它们的迭代行为,即规定了对象如何被for...of这样的迭代语句遍历。这个协议主要涉及到两个部分:

  1. 迭代器方法 (@@iterator):

    对象或其原型链中的某个对象必须有一个名为Symbol.iterator的属性,该属性是一个无参数函数,它返回一个迭代器对象。当对象需要被迭代时,例如在for...of循环中,这个方法会被调用

  2. 迭代器对象:

    该对象必须遵守迭代器协议,具体来说,需要有一个next()方法,该方法在每次迭代时被调用。每次调用next()方法时,迭代器对象返回另一个称为迭代结果的对象。迭代结果对象必须具有两个属性:

    • value:迭代的当前值
    • done:布尔值,如果迭代器已经遍历了迭代对象所有值,则为true;如果迭代还未结束,则为false

通过实现这个协议,任何对象都可定制它在迭代时产生值的序列,成为可迭代的

例如,数组和字符串是可迭代的,因为它们的原型链上都有Symbol.iterator属性

1
2
3
4
5
6
7
let arr = [1, 2, 3];
let it = arr[Symbol.iterator]();

console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: undefined, done: true }

在此示例中,数组arr是内建可迭代对象,它的Symbol.iterator方法返回一个迭代器对象it,我们可以手动调用next()方法来迭代数组

如果需要让一个自定义对象满足可迭代协议,可以定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const obj = {
a: 1,
b: 2,
[Symbol.iterator]: function() {
const keys = Object.keys(this);
let index = 0;
return {
next: () => {
if (index < keys.length) {
return { value: this[keys[index++]], done: false };
} else {
return { done: true };
}
}
};
}
};

// 使用 for...of 循环
for (let value of obj) {
console.log(value); // 输出:1, 然后 2
}

在这个例子中,我们创建了一个普通对象,并给它添加了一个[Symbol.iterator]属性,从而使对象变成可迭代的

通过这种方式,对象就可以用在任何期待可迭代值的JavaScript语言结构中


Q29:前端的页面截图怎么实现

难度:⭐⭐⭐

答案
  1. Canvas

    最常见的方式是将需要截图的内容绘制到一个<canvas>元素上,然后使用canvas.toDataURL()方法获取图像的DataURL,这是一个Base64编码的字符串表示的图片

    基于此字符串,可以显示图片或者转换成Blob进行下载

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    function screenshotDOMElement(element, callback) {
    // 将DOM元素渲染到canvas上的函数
    html2canvas(element).then(canvas => {
    // 转换canvas为DataURL
    var dataURL = canvas.toDataURL('image/png');
    callback(dataURL);
    });
    }

    // 使用示例
    screenshotDOMElement(document.body, function(dataURL){
    // 创建一个Image元素
    var img = new Image();
    img.src = dataURL;

    // 将Image元素添加到页面中以显示截图
    document.body.appendChild(img);

    // 或者创建下载链接以下载截图
    var a = document.createElement('a');
    a.href = dataURL;
    a.download = 'screenshot.png';
    a.click();
    });

    需要提醒的是,上面的代码使用了html2canvas库,它是一个非常流行的库,可以将HTML转换成Canvas图片

    你需要在你的项目中包含这个库才能使用上述功能

  2. 浏览器拓展API

    如果你正在开发一个浏览器扩展,你可以利用浏览器提供的API来捕捉当前页面或者页面的部分区域

    例如,在Chrome扩展程序中,可以使用chrome.tabs.captureVisibleTab方法实现截屏

    这通常用于浏览器插件开发,对于普通的前端网页开发并不适用

    1
    2
    3
    4
    5
    6
    chrome.tabs.captureVisibleTab(null, {}, function (image) {
    // image是一个base64编码的DataURL
    var screenshotUrl = image;

    // 下面你可以将截图DataURL用于不同的目的,例如显示或下载
    });

    使用这个API,用户的浏览器必须安装相应的扩展,并且在扩展的权限设置中允许这种截图行为

注意

  • 如果页面中有跨域内容,使用Canvas进行截图可能会遇到安全限制
  • 上述方法仅适合静态内容的截图。如果页面有复杂的动态效果或者视频等流媒体内容,可能无法准确捕获
  • 对于特权环境(如浏览器扩展),可能有额外的API用于截图
  • 用户隐私应予以考虑,不应在用户不知情或未经允许的情况下进行截图


Q30:怎么实现虚拟列表

难度:⭐⭐⭐

解析

虚拟列表(Virtual List)或虚拟滚动(Virtual Scrolling)是一种极其有效的前端性能优化技术,专门用于优化长列表数据的渲染性能

当面对需要渲染成千上万条数据项的列表时,虚拟列表技术通过只渲染当前视口(viewport)内以及少量缓冲区内的列表项,而不是整个列表的数据项,来显著提高页面的渲染效率及性能

  1. 核心概念

    • 渲染优化

      虚拟列表通过减少实际渲染的DOM节点数量来降低浏览器的负担,使得即使是数据量极大的列表页也能实现流畅的滚动和交互体验

    • 动态渲染

      随着用户滚动,应用程序会根据视口的变化动态地添加或移除列表项,确保在任何时候,DOM中的节点数量保持恒定

  2. 优点与缺点

    • 优点

      • 性能提升

        显著减少了待渲染的DOM元素数量,提高页面渲染性能

      • 用户体验增强

        页面滚动流畅,减少了内存的占用,提高了页面响应速度

    • 缺点

      • 实现复杂度

        动态计算可见项、处理滚动事件等,实现相对较为复杂

      • 滚动位置误差

        尤其在列表项高度不一致时,可能会导致滚动位置的计算误差

  3. 应用场景

    • 电商网站的商品列表

      展示成千上万的商品信息

    • 后台系统的数据表格

      处理大量的数据展示

    • 社交媒体信息流

      如新闻、文章列表或社交动态的无限滚动加载

虽然可以手动实现虚拟列表,但大多数开发者倾向于使用现成的库来简化开发流程,例如react-windowreact-virtualized等库,它们提供了开箱即用的虚拟列表实现方案,大大降低了复杂度

答案

实现虚拟列表涉及以下关键步骤与技术:

  1. 计算可见区域

    根据视口大小和滚动位置来计算当前应该渲染哪些列表项

  2. 渲染策略

    仅渲染视口内的数据项,对于非可见区域的数据,通过占位符或者估算高度来确保滚动条正常

  3. 动态调整

    根据滚动位置动态加载新的列表项并回收离开视口的旧数据项

  4. 优化技术

    • 虚拟DOM

      降低重绘与回流,提升性能

    • 懒加载

      延迟渲染非可见区域的数据,减少网络和资源消耗

    • 缓存机制

      缓存已渲染的列表项,提高滚动性能

    • 预测加载

      根据用户的滚动行为预测接下来的数据需求,提前加载数据

具体实现


Q31:如何判断某个字符串长度(要求支持表情)

难度:⭐⭐⭐

答案

JavaScript 的 length 属性在处理包含四字节 Unicode 字符(例如表情符号)的字符串时可能不会返回预期的结果,原因是 length 属性会将四字节的 Unicode 字符计算为 2 个字符

所以如果需要准确获得包含表情符号的字符串长度,我们要用到“展开”操作符 (...) 和 Array 对象的 length 属性

1
2
3
4
5
6
7
8
9
10
11
12
13
let str = "Hello, World! 😊";

function getStringLength(str) {
// 检查输入是否为字符串
if (typeof str !== 'string') {
console.error('getStringLength error: The input must be a string.');
return 0; // 或者根据你的需要,返回其他方案
}

return [...str].length;
}

getStringLength(str); // 返回正确的字符数量,包括表情符号

这段代码会将字符串里的每个字符(包括四字节 Unicode 字符)都单独展开成独立的数组元素,然后使用 length 属性统计元素的数量,避免了四字节字符被错误地计为2个字符的问题


Q32:如何判断页面是通过PC端还是移动端访问

难度:⭐⭐⭐

答案
  1. navigator.userAgent

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function detectDevice() {
    let userAgent = navigator.userAgent;
    if (userAgent.match(/Android/i)
    || userAgent.match(/webOS/i)
    || userAgent.match(/iPhone/i)
    || userAgent.match(/iPad/i)
    || userAgent.match(/iPod/i)
    || userAgent.match(/BlackBerry/i)
    || userAgent.match(/Windows Phone/i)) {
    return 'Mobile Device';
    }
    else {
    return 'Desktop Device';
    }
    }

    console.log(detectDevice());

    这个 detectDevice() 函数在执行时会检查navigator.userAgent是否包含特定的移动设备标识符

    如果匹配到这些设备标识符中的任何一个,该函数将返回 'Mobile Device'

    否则,该函数将返回 'Desktop Device'

    这个方法并不是100%准确的,因为用户代理字符串可以被浏览器设置项或恶意用户修改

    然而,这仍然是一个大多数情况下有效的方法,用于判断访问者使用的是桌面还是移动设备

  2. 使用CSS媒体查询

    通过CSS媒体查询判断窗口的大小,从而对移动设备和桌面设备采取不同的样式

    1
    2
    3
    @media only screen and (max-width: 768px) {
    /* 在这里放置移动端的CSS */
    }
  3. 检查屏幕宽度

    移动设备通常支持触摸事件,而桌面则依赖于鼠标事件,所以你可以侦测页面是否可以进行触摸事件来判定

    1
    2
    3
    4
    5
    if (window.innerWidth <= 768) {
    // 移动端执行的代码
    } else {
    // 桌面端执行的代码
    }
  4. 触摸事件检测

    移动设备通常支持触摸事件,而桌面则依赖于鼠标事件,所以可以侦测页面是否可以进行触摸事件来判定

    1
    2
    3
    4
    5
    6
    7
    function isTouchDevice() {
    return (
    ('ontouchstart' in window)
    || (navigator.MaxTouchPoints > 0)
    || (navigator.msMaxTouchPoints > 0)
    )
    }
  5. 使用库或框架

    现成的库,如Modernizr、WURFL.js等,可以帮助你检测设备的类型


Q33:实现一个数字转中文的方法

难度:⭐⭐⭐

答案

函数目标与输入检验

这个函数的主要目标是将数字转换为汉字表示的形式

它不仅包括正负整数的转换,也支持处理小数部分以及检查输入数字是否在特定的区间内

最开始的几行代码用于验证输入是否为合法的数字,并确定输入数字是否在函数处理的范围内(本例中为-1亿到1亿)

javascript

1
2
3
4
5
6
7
if (isNaN(num)) return '输入不是一个有效的数字';

const MIN_NUM = -100000000;
const MAX_NUM = 100000000;
if (num < MIN_NUM || num > MAX_NUM) {
return `数字必须在${MIN_NUM}${MAX_NUM}之间`;
}

负数处理

接下来,函数检查数字是否为负

如果是负数,它会标记一个负数标志为true并将数字转换为正数以便后续处理

1
2
3
4
5
let negative = false;
if (num < 0) {
negative = true;
num = -num;
}

整数部分转换

函数中的getIntegerChn辅助函数负责将数字的整数部分转换成汉字

这一过程通过递归地处理数字的每四位(即在中文中的“万”与“亿”单位间的分割点)来完成

它首先将数字分解成小于10000的块,并为每个块生成对应的中文表示,然后根据其在原数中的位置添加适当的单位(如“万”、“亿”等)

1
const getIntegerChn = (intNum) => { ... };

小数部分转换

如果数字有小数点,getDecimalChn函数负责将小数点后的每位数字转换为相应的汉字,并在前面加上“点”字以区分整数和小数部分

1
const getDecimalChn = (str) => { ... };

负数的最终表示

在整数和小数部分都转换完成后,如果最初标记的负数标志为true,则在最终的中文字符串前加上“负”字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
function convertToChinese(num) {
if (isNaN(num)) return '输入不是一个有效的数字';

// 定义转换的区间
const MIN_NUM = -100000000;
const MAX_NUM = 100000000;

// 检查数字是否在允许的区间内
if (num < MIN_NUM || num > MAX_NUM) {
return `数字必须在${MIN_NUM}${MAX_NUM}之间`;
}

const numMap = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
const unitMap = ['', '十', '百', '千', '万', '十', '百', '千', '亿', '十', '百', '千', '万亿'];

let negative = false; // 标记是否为负数
if (num < 0) {
negative = true;
num = -num; // 将负数转为正数
}

// 转换整数部分
const getIntegerChn = (intNum) => {
if(intNum === 0) return numMap[0];
let chnStr = '';
let zero = false;
let count = 0;
while (intNum > 0) {
let section = intNum % 10000;
if (zero) chnStr = numMap[0] + chnStr;
let strIns = sectionToChinese(section);
strIns += (section !== 0) ? unitMap[count] : unitMap[0];
chnStr = strIns + chnStr;
zero = section < 1000 && section > 0;
intNum = Math.floor(intNum / 10000);
count++;
}
return chnStr;
};

// 转换小数部分
const getDecimalChn = (str) => {
let decimalStr = '点';
for (let char of str.split("")) {
decimalStr += numMap[+char];
}
return decimalStr;
};

let [integerPart, decimalPart] = num.toString().split(".");
let chineseStr = getIntegerChn(parseInt(integerPart));

if (decimalPart) {
chineseStr += getDecimalChn(decimalPart);
}

if (negative) {
chineseStr = '负' + chineseStr;
}

return chineseStr;
}

// 使用示例:
console.log(convertToChinese(-12345.678)); // 返回 "负一万二千三百四十五点六七八"
console.log(convertToChinese(1000000000)); // 返回 "数字必须在-100000000到100000000之间"


Q34:用js实现二叉树的定义和基本操作(待完善)

难度:⭐⭐⭐

解析
答案
引申

二叉树是一种特殊的树形数据结构,其中每个节点最多有两个子节点,通常被称为左子节点和右子节点

按定义,二叉树是空的(即,没有节点)或者由一个根节点和两棵互不相交的、被分别称为左子树和右子树的二叉树组成

详细地说,二叉树的每个节点包含以下部分:

  • 数据元素:包含节点值或者其他相关信息的部分
  • 左指针:指向左子节点的指针。如果没有左子节点,该指针指向空或者NULL
  • 右指针:指向右子节点的指针。如果没有右子节点,该指针指向空或者NULL

二叉树有许多变体,如二叉搜索树、AVL树(自平衡二叉搜索树)、堆(一种特殊的完全二叉树)和红黑树等

其中,二叉搜索树是最常用的一种,它的特点是任意节点的值都大于或等于其左子树中存储的值,并且小于或等于其右子树中存储的值

二叉树的优势在于其查找、插入、删除的平均和最佳时间复杂度可以达到O(log n)(其中n代表树中节点的数量),这归功于二叉树数据结构的特性使查找路径大大缩短

但是,为了保持树的平衡(即保持左右两边节点数量均衡,避免退化为链),可能需要进行一些额外操作

二叉树可被用来实现二叉搜索树、表达式解析树、堆和B+树(一种用于数据库和文件系统的数据结构)等等


Q35:查找岛屿数量

难度:⭐⭐⭐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 1、给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。
// 2、岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
// 3、此外,你可以假设该网格的四条边均被水包围。

// 示例1
// 输入
const grid1 = [
["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]
]
// 返回结果 1

// 示例2
// 输入
const grid2 = [
["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]
]
// 返回结果 3

// 提示
m == grid.length
n == grid[i].length
1 <= m, n <= 300
grid[i][j] 的值为 '0''1'
解析

在计算二维网格中岛屿的数量问题中,主要有两种搜索方法来遍历岛屿:

  1. 深度优先搜索(DFS, Depth-First Search)
    深度优先搜索是以垂直的方式进行探索。它从一个节点开始,尽可能深地搜索每一个分支,直到到达底部,然后回溯并探索下一条分支

    在岛屿数量的问题中,DFS可以从任一未访问过的陆地出发,尽可能探索与之相连的所有陆地,然后将其标记为已访问

    DFS通过递归或栈实现

  2. 广度优先搜索(BFS, Breadth-First Search)
    广度优先搜索是以水平的方式进行探索

    它从一个节点开始,首先访问所有邻近的节点,即先宽后深地探索每一层

    在岛屿数量的问题中,BFS可以从任一未访问过的陆地出发,一层一层地探索所有相邻的陆地,并将其标记为已访问

    BFS通常用队列来实现

答案
  1. 使用递归的方式进行深度优先搜索

    每当我们遇到”1”(即陆地)时,我们将尝试访问它的所有相邻的”1”(即未访问过的陆地),并将它们标记为已访问,以此来识别一个完整的岛屿

    完成对一个岛屿的探索后,我们继续搜索下一个未被访问的”1”

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    function numIslands(grid) {
    // 辅助函数用于执行深度优先搜索
    function dfs(i, j) {
    // 检查越界情况以及是否是水域或已访问过的陆地
    if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] === '0') {
    return;
    }
    // 将当前位置标记为已访问
    grid[i][j] = '0'; // 通过将1修改为0来标记已访问
    // 访问四个方向的相邻陆地
    dfs(i+1, j); // 下
    dfs(i-1, j); // 上
    dfs(i, j+1); // 右
    dfs(i, j-1); // 左
    }

    if (!grid || !grid.length) return 0;

    let count = 0; // 计数器,用于统计岛屿数量
    for (let i = 0; i < grid.length; i++) {
    for (let j = 0; j < grid[i].length; j++) {
    if (grid[i][j] === '1') { // 发现未访问的陆地,启动深度优先搜索
    dfs(i, j);
    count++; // 完成一个岛屿的搜索,岛屿数量加1
    }
    }
    }
    return count;
    }

    // 示例1
    const grid1 = [
    ["1", "1", "1", "1", "0"],
    ["1", "1", "0", "1", "0"],
    ["1", "1", "0", "0", "0"],
    ["0", "0", "0", "0", "0"]
    ];
    console.log(numIslands(grid1)); // 返回结果 1

    // 示例2
    const grid2 = [
    ["1", "1", "0", "0", "0"],
    ["1", "1", "0", "0", "0"],
    ["0", "0", "1", "0", "0"],
    ["0", "0", "0", "1", "1"]
    ];
    console.log(numIslands(grid2)); // 返回结果 3

    这段代码首先定义了一个dfs函数,用于实现深度优先搜索并标记已访问的陆地

    然后,我们遍历整个网格,每次遇到”1”(未访问过的陆地)时,使用dfs函数搜索整个岛屿,并通过将”1”标记为”0”来标记已访问的陆地

    遍历过程中每完成对一个岛屿的探索,就将岛屿数量加一

  2. 使用广度优先搜索(BFS)

    我们通常会用队列来追踪需要探索的陆地

    从遇到初始的一个“1”开始,我们将其添加到队列中,然后按层序遍历相邻的格子,将遇到的所有“1”都置为“0”以防重复计数,直到队列为空

    这样,每完成一轮循环,就能识别并计数一个岛屿

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    function numIslands(grid) {
    if (!grid || !grid.length) return 0;

    const rows = grid.length;
    const cols = grid[0].length;
    const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]]; // 上下左右移动的方向
    let count = 0;

    for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
    if (grid[i][j] === '1') {
    count++; // 找到一个岛屿,岛屿数量加1
    grid[i][j] = '0'; // 标记为已访问
    const queue = [[i, j]]; // 使用数组模拟队列,存储岛屿中的陆地坐标

    while (queue.length > 0) {
    const [x, y] = queue.shift(); // 从队列中取出一个陆地坐标

    // 检查此陆地的所有相邻陆地
    for (let [dx, dy] of dirs) {
    const nx = x + dx, ny = y + dy;
    if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && grid[nx][ny] === '1') {
    grid[nx][ny] = '0'; // 标记为已访问
    queue.push([nx, ny]); // 相邻的陆地入队
    }
    }
    }
    }
    }
    }

    return count;
    }

    // 示例1
    const grid1 = [
    ["1", "1", "1", "1", "0"],
    ["1", "1", "0", "1", "0"],
    ["1", "1", "0", "0", "0"],
    ["0", "0", "0", "0", "0"]
    ];
    console.log(numIslands(grid1)); // 返回结果 1

    // 示例2
    const grid2 = [
    ["1", "1", "0", "0", "0"],
    ["1", "1", "0", "0", "0"],
    ["0", "0", "1", "0", "0"],
    ["0", "0", "0", "1", "1"]
    ];
    console.log(numIslands(grid2)); // 返回结果 3

    在这段代码中,dirs数组定义了可以移动的方向,即上下左右

    每次发现一个“1”后,都开始一个新的BFS过程:首先将其标记为“0”,然后将它的坐标加入队列中

    在队列不为空的情况下循环,每次从队列中取出一个坐标,再将其相邻的未标记的陆地坐标入队,同时将这些陆地标记为“0”以表示已访问

引申

时间复杂度和空间复杂度是用来评估算法效率的两个标准。想象一下,你在厨房做饭:

  1. 时间复杂度

    就像是完成菜肴所需要的总时间

    简单的菜,比如煮鸡蛋,可能只需要几分钟

    但如果是做一个复杂的烤鸡大餐,可能需要几个小时

    在计算机的世界里,时间复杂度帮助我们了解一个程序运行完成需要多长时间

    评估标准

    • 确定算法的基本操作

      识别算法中执行最频繁的操作

      比如在排序算法中,基本操作可能是比较或交换元素

    • 分析操作的数量

      计算输入数据大小为n时,基本操作执行了多少次

      假设操作数量为f(n)

    • 考虑最坏情况

      通常我们评估算法的时间复杂度时,考虑的是最坏的情况

      即对于给定大小的输入,算法所需时间的最大值

    • 简化表达式

      f(n)中去掉常数项和低阶项,保留最高阶项,并注意它前面的系数也不重要

      因为时间复杂度关注的是随着输入规模n的增长,算法所需时间的变化趋势

    • 使用大O符号

      得到简化后的表达式并采用大O符号来描述算法的时间复杂度

      例如,如果操作数量f(n)可被简化为2n^2 + 3n + 5,则算法的时间复杂度是O(n^2)

  2. 空间复杂度

    则像是你在做饭时占用的厨房空间大小

    如果你只是煮个鸡蛋,可能只需要一个小锅

    但如果是准备一个大餐,你可能需要整个厨房的空间

    在计算机中,空间复杂度告诉我们运行一个程序需要多少内存空间

    评估标准

    • 考虑所有内存使用

      算法可能使用内存来存储各种变量、数组、来自外部输入的数据和递归栈

    • 区分临时和持久空间使用

      有些内存使用在算法执行完后就不再需要了,这是临时内存使用

      有些可能是算法的输出,或者是算法运行过程中必须长期保留的,这是持久内存使用

    • 计算所有部件的总空间

      评估由输入大小n导致的总内存使用情况

      这通常包括输入数据结构的大小,加上算法内部额外创建的数据结构大小

    • 简化表达式

      和时间复杂度一样,去掉表达式中的常量项和低阶项,只保留最高阶项

    • 使用大O符号

      最后,使用大O符号来描述空间复杂度

      例如,如果一个算法只需要一个大小与输入大小n成比例的数组,那么空间复杂度就是O(n)


Q36:计算背包

难度:⭐⭐⭐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 有 N 件物品和一个容量是 V 的背包。每件物品有且只有一件。
// 第 i 件物品的体积是 v[i] ,价值是 w[i] 。
// 求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。

// 示例 1:
// 输入:
let N = 3, V = 4, v = [4,2,3], w = [4,2,3]
// 输出: 4
// 解释: 只选第一件物品,可使价值最大。

// 示例 2:
// 输入:
let N = 3, V = 5, v = [4,2,3], w = [4,2,3]
// 输出: 5
// 解释: 不选第一件物品,选择第二件和第三件物品,可使价值最大
解析

解决这类背包问题,有几种不同的方法,通常包括动态规划、回溯、贪心算法等。下面我将概述几种常用的解决方法:

  1. 动态规划(DP)

    动态规划是解决背包问题最经典也是最有效的方法之一

    它主要有两种方式:0-1背包完全背包

    对于你的问题,我们使用0-1背包的思路,因为每件物品只能选择一次

    • 0-1背包问题

      可以用二维数组dp表示,其中dp[i][j]表示从前i个物品中挑选,总体积不超过j的条件下的最大价值

      状态转移方程为:dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]] + w[i])

    这种方法的时间复杂度和空间复杂度均为O(NV),N是物品数量,V是背包容量

  2. 动态规划(空间优化)

    基于上面的动态规划方法,可以对其进行空间优化,将二维数组压缩为一维数组。优化后,使用单个数组dp[j]表示总体积不超过j的条件下的最大价值,状态转移方程变为:dp[j] = max(dp[j], dp[j-v[i]] + w[i])

    空间复杂度降低为O(V)。

  3. 贪心算法

    贪心算法是另一种思路,它不总能得到最优解,但在某些情况下计算效率较高

    对于物品选择问题,一个简单的贪心策略可能是按价值密度(价值除以体积)降序排列物品,然后尽量选择价值密度高的物品装入背包,直到装不下为止

  4. 分支限界法

    分支限界法是基于队列的宽度优先搜索,它使用“分支”来探索可行的解空间,并使用“限界”来剪枝,不继续探索那些无法得到最优解的分支

    这种方法适用于更复杂的背包问题或需要精确解的场景

答案
  1. 0-1背包写法

    实现0-1背包问题的动态规划算法,你需要构建一个二维数组用来保存每个状态下的最大价值

    在实现过程中,我们创建了一个二维数组dp,其中dp[i][j]代表考虑前i个物品,当背包容量限制为j时我们能够装进背包的最大价值

    通过填充这个二维数组,我们可以找到最后的解

    在结束时,dp[N][V]就代表了所有物品在不超过背包容量V的条件下能达到的最大总价值

    注意在真实应用中,为了节省空间,你也可以将这个二维数组压缩到一维数组

    这样的优化需要你在更新dp[j]的时候从后往前更新,这样可以保证状态转移使用的是上个物品的数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    function knapsack(N, V, v, w) {
    // dp是一个二维数组,dp[i][j]表示前i个物品,在不超过j的体积下的最大价值
    let dp = Array.from({ length: N + 1 }, () => Array(V + 1).fill(0));

    // 遍历所有物品
    for (let i = 1; i <= N; i++) {
    // 遍历所有容量
    for (let j = 1; j <= V; j++) {
    // 如果当前物品体积大于当前背包容量,不能加入当前物品
    if (v[i - 1] > j) {
    dp[i][j] = dp[i - 1][j];
    } else {
    // 状态转移方程
    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - v[i - 1]] + w[i - 1]);
    }
    }
    }

    // 最大价值在dp[N][V]中
    return dp[N][V];
    }

    // 示例 1:
    let N = 3, V = 4, v = [4, 2, 3], w = [4, 2, 3];
    console.log(knapsack(N, V, v, w)); // 输出: 4

    // 示例 2:
    N = 3, V = 5, v = [4, 2, 3], w = [4, 2, 3];
    console.log(knapsack(N, V, v, w)); // 输出: 5
  2. 完全背包

    完全背包问题的关键区别在于每种物品可以选取无限次

    在JavaScript中,这种问题的动态规划解法与0-1背包问题类似,但是在更新dp数组的时候,内部循环会有所不同,因为你需要考虑重复选择某一物品的情况

    这段代码的处理方式与0-1背包问题的主要差异在于内层循环j的方向

    在完全背包问题中,当你在计算dp[j]时,因为每种类型的物品可以使用多次,所以可以从左到右遍历,并且用自身的先前计算的值来更新后面的值

    这种处理方式意味着在计算dp[j]时,可以重复考虑物品i,而这在0-1背包问题中不被允许

    注意,这里的代码为了简洁明了,省略了物品数量的限制,也就是说,默认情况下每种物品可以无限使用。如果物品有数量限制,解法将不同

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    function completeKnapsack(N, V, v, w) {
    // 初始化dp数组,dp[j]代表容量为j的背包能达到的最大值
    let dp = new Array(V + 1).fill(0);

    // 遍历物品
    for (let i = 0; i < N; i++) {
    // 遍历背包容量
    // 注意:此循环与0-1背包问题相反,我们从小到大遍历
    for (let j = v[i]; j <= V; j++) {
    // 更新dp[j],选择当前物品i或者不选择,取二者的最大值
    dp[j] = Math.max(dp[j], dp[j - v[i]] + w[i]);
    }
    }

    // 返回容量为V的背包的最大价值
    return dp[V];
    }

    // 示例 1:
    let N = 3, V = 4, v = [4, 2, 3], w = [4, 2, 3];
    console.log(completeKnapsack(N, V, v, w)); // 输出: 4

    // 示例 2:
    N = 3, V = 5, v = [4, 2, 3], w = [4, 2, 3];
    console.log(completeKnapsack(N, V, v, w)); // 输出: 5
    // 解释: 选择第二件物品两次
  3. 空间优化写法

    空间优化版本的完全背包问题可以将二维的dp数组压缩为一维数组

    空间复杂度从O(NV)降为O(V)

    空间优化的关键在于对dp数组定义的更改

    在原始方法中,dp是一个二维数组,表示考虑前i个物品在不超过j的体积上的最大价值

    优化后,我们在更新dp[j]时,不再需要考虑“前i个物品”这个维度,只需要考虑“不超过j的体积上的最大价值”,因为我们已经在内层循环中涵盖了所有的物品

    这种优化极大地节省了空间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    function completeKnapsack(N, V, v, w) {
    // 初始化状态数组,dp[v]代表容量为v的背包能装载物品的最大总价值
    let dp = new Array(V + 1).fill(0);

    // 遍历物品
    for (let i = 0; i < N; i++) {
    // 从小到大遍历背包容量
    for (let j = v[i]; j <= V; j++) {
    // 状态转移方程,取最大值
    dp[j] = Math.max(dp[j], dp[j - v[i]] + w[i]);
    }
    }

    // 最大背包价值存储在dp[V]
    return dp[V];
    }

    // 示例 1:
    let N = 3, V = 4, v = [4, 2, 3], w = [4, 2, 3];
    console.log(completeKnapsack(N, V, v, w)); // 输出: 4

    // 示例 2:
    N = 3, V = 5, v = [4, 2, 3], w = [4, 2, 3];
    console.log(completeKnapsack(N, V, v, w)); // 输出: 6,解释:选择第二件物品两次


Q37:全排列

难度:⭐⭐⭐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

// 示例 1:
// 输入:nums = [1,2,3]
// 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

// 示例 2:
// 输入:nums = [0,1]
// 输出:[[0,1],[1,0]]

// 示例 3:
// 输入:nums = [1]
// 输出:[[1]]

// 提示:
// 1 <= nums.length <= 6
// -10 <= nums[i] <= 10
// nums 中的所有整数 互不相同
解析

解决这个问题的关键是采用回溯法

回溯法是一个通过探索所有可能的候选解来找出所有解的策略

如果候选解被确认不是一个解(或者至少不是最后一个解),回溯算法会丢弃它,并且递归地尝试另一个候选解

对于全排列问题,我们可以将问题视为一个树形结构,每一层选择一个不在当前排列中的数字

当我们达到树的底部时,就找到了一个排列,并且回退到上一步,尝试下一个数字

答案

这段代码通过递归函数backtrack遍历所有可能的排列情况

对于每个位置,它都会尝试数组nums中的每一个数字,如果这个数字还没有被使用过(也就是说,它还没有在path中),就将它添加到当前排列(path)中,并继续递归处理下一个位置

当达到一个完整排列时(也就是path的长度与nums的长度相等),就将其加入到结果result

在处理完一个位置所有可能的数字之后,需要进行回溯(通过path.pop()),也就是撤销上一步的选择,尝试其他选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function permute(nums) {
// 结果列表
let result = [];
// 路径列表,用来保存当前的排列组合
let path = [];

function backtrack(nums, path) {
// 当前排列长度等于原始数组长度时,记录当前排列
if (path.length === nums.length) {
result.push(Array.from(path));
return;
}

for (let i = 0; i < nums.length; i++) {
// 跳过已经选择的数字
if (path.includes(nums[i])) continue;
// 选择当前数字
path.push(nums[i]);
// 进入下一层决策树
backtrack(nums, path);
// 取消选择当前数字,回退到之前的状态
path.pop();
}
}

// 从空路径开始进行回溯
backtrack(nums, path);

return result;
}

// 示例
console.log(permute([1, 2, 3])); // 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
console.log(permute([0, 1])); // 输出:[[0,1],[1,0]]
console.log(permute([1])); // 输出:[[1]]


Q38:写一个LRU缓存函数

难度:⭐⭐⭐

解析

LRU缓存,即最近最少使用(Least Recently Used)缓存算法,是一种常用的页面置换算法,选择最近最久未使用的数据进行淘汰

这个算法的主要目的是为了保持最常用的数据在缓存中,而不经常使用的数据会被移出缓存

实现LRU缓存机制的分析过程通常包括以下步骤:

  1. 确定数据结构

    一个高效的LRU缓存通常需要使用两种数据结构,一种是用于保持插入顺序的链表(比如双向链表),另一种是用于O(1)时间复杂度查找的哈希表

  2. 实现插入操作

    插入新元素时,我们需要将元素插入到链表的头部

    如果缓存已经达到最大容量,我们则移除链表尾部的元素,因为尾部元素是最近最少使用的

    同时,需要在哈希表中添加该元素的键和指向链表中元素的指针

  3. 实现访问操作

    访问元素时,如果元素在缓存中,则需要将其移动到链表的头部,表示它最近被使用过

    这也意味着链表的尾部始终表示最久未使用的元素

  4. 同步操作

    插入和访问操作都需要及时在哈希表和链表之间进行同步,确保两者的数据是一致的

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class LRUCache {
#map;
#capacity;

constructor(capacity) {
this.#capacity = capacity;
this.#map = new Map();
}

get(key) {
if (!this.#map.has(key)) {
return -1;
}
const value = this.#map.get(key);
// 如果key存在,首先删除旧的位置,然后将其重新设置到Map的末尾,代表最近使用过
this.#map.delete(key);
this.#map.set(key, value);
return value;
}

put(key, value) {
if (this.#map.has(key)) {
this.#map.delete(key);
} else if (this.#map.size === this.#capacity) {
// 当缓存达到上限时,删除最近最少使用的元素
const oldestKey = this.#map.keys().next().value;
this.#map.delete(oldestKey);
}
this.#map.set(key, value);
}

// 提供一个方法来修改缓存的容量
setCapacity(newCapacity) {
this.#capacity = newCapacity;
// 如果新容量小于当前map的大小,需要删除最久未使用的项直到大小符合新容量
while (this.#map.size > this.#capacity) {
const oldestKey = this.#map.keys().next().value;
this.#map.delete(oldestKey);
}
}

// 可以提供一些其他的方法来满足不同的需求,例如获取当前缓存容量
getCapacity() {
return this.#capacity;
}

// 获取当前缓存中元素的数量
getSize() {
return this.#map.size;
}
}

// 使用示例:
const lruCache = new LRUCache(2);
lruCache.put(1, 1);
lruCache.put(2, 2);
console.log(lruCache.get(1)); // 返回 1
lruCache.put(3, 3); // 移除 key 2
console.log(lruCache.get(2)); // 返回 -1 (未找到)
lruCache.put(4, 4); // 移除 key 1
console.log(lruCache.get(1)); // 返回 -1 (未找到)
console.log(lruCache.get(3)); // 返回 3
console.log(lruCache.get(4)); // 返回 4


Q39:大文件怎么实现断点续传

难度:⭐⭐⭐

答案
  1. Step 1: 切割文件

    利用JavaScript中的Blob对象的slice方法来切割大文件成为多个小块(即文件片段或file chunks)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function sliceFile(file, chunkSize) {
    let chunks = [];
    const size = file.size;
    for (let start = 0; start < size; start += chunkSize) {
    const chunk = file.slice(start, Math.min(start + chunkSize, size));
    chunks.push(chunk);
    }
    return chunks;
    }
  2. Step 2: 上传文件切片

    上传每个文件切片之前,需要检查服务器上已经上传了哪些切片

    通过fetchaxios等HTTP客户端进行异步上传,并在请求中发送额外信息,如切片序号、文件唯一标识符等

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    async function uploadChunks(chunks, fileId) {
    for (let i = 0; i < chunks.length; i++) {
    const formData = new FormData();
    formData.append('chunk', chunks[i]);
    formData.append('fileId', fileId);
    formData.append('index', i);
    // 发送请求到后端
    await fetch('/upload', { method: 'POST', body: formData });
    // 保存上传进度,用于续传
    saveProgress(fileId, i + 1);
    }
    }
  3. Step 3: 保存上传进度

    利用localStorage或其他持久化方法来记录上传的进度,这样在网络断开后可以从中断点继续上传

    1
    2
    3
    function saveProgress(fileId, chunkIndex) {
    localStorage.setItem(fileId, chunkIndex);
    }
  4. Step 4: 断点续传

    在上传之前,先向服务器查询文件上传的进度或已上传的切片,从而决定从哪个切片开始上传

    1
    2
    3
    4
    5
    6
    7
    8
    async function continueUploading(file, fileId) {
    const chunkSize = /* 您设置的切片大小 */;
    const chunks = sliceFile(file, chunkSize);
    const uploadedChunks = await fetchUploadedChunks(fileId);
    const remainingChunks = chunks.slice(uploadedChunks);

    await uploadChunks(remainingChunks, fileId);
    }
  5. Step 5: 后端合并切片

    在服务器端,当所有切片上传完成后,需要将这些切片合并成原始的文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // Node.js 示例
    const fs = require('fs');
    const path = require('path');

    function mergeChunks(chunks, dest) {
    const wStream = fs.createWriteStream(dest);

    chunks.forEach(chunkPath => {
    const data = fs.readFileSync(chunkPath);
    wStream.write(data);
    fs.unlinkSync(chunkPath); // 删除已合并的切片
    });

    wStream.end();
    }

这些是客户端实现大文件断点续传的基本步骤

实际实现时,你还需要处理异常情况,比如网络错误重试、权限校验、加密等安全措施、以及后端接收切片的API设计等

引申

大文件断点续传是网络传输中的一个术语,指的是在上传或下载大文件时,如果传输中断,则下次可以从中断的地方继续传输,而不是重新开始

底层实现通常依靠的是HTTP协议的范围请求(Range Requests)

工作原理简述:

  1. 切片

    将整个文件切分成多个小块,每一小块可以单独传输

  2. 传输记录

    记录每个小块的传输进度,成功或失败

  3. 中断和恢复

    如果传输中断(比如网络问题、程序关闭等),下次传输可以根据已有的记录,直接从上次中断的小块开始传输

  4. 文件重组

    当所有的小块都传输完成后,将这些小块重组成原始的大文件

为什么需要断点续传?

  • 节省带宽

    如果网络连接不稳定,避免多次上传下载同一个文件的相同部分

  • 提高效率

    对于特别大的文件,重新传输全部内容代价很高

  • 用户体验

    提供更为流畅和友好的用户体验,特别是移动设备用户在不稳定网络下

断点续传对于提高大文件传输的可靠性和效率至关重要,特别是在容错、网络条件差、数据传输成本高的情况下


Q40:版本号排序

难度:⭐⭐⭐

1
2
3
4
// 有一组版本号如下
['0.1.1', '2.3.3', '0.302.1', '4.2', '4.3.5', '4.3.4.5']。

// 现在需要对其进行排序,排序的结果为 ['4.3.5','4.3.4.5','2.3.3','0.302.1','0.1.1']
答案

这是一个关于版本号排序问题的javascript解决办法

把每一个版本号分解,并使用’.’做成一个数组,这样你具有一个一维数组,其中每个元素是一个数组

然后根据这个数组,你可以使用数组的sort()方法进行排序

在排序函数中,你需要进行一些定制,以确保排序行为符合你的期望

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function versionSort(arr) {
return arr.sort((a, b) => {
let aArr = a.split('.').map(Number);
let bArr = b.split('.').map(Number);

for (let i = 0; i < Math.max(aArr.length, bArr.length); i++) {
if ((aArr[i] || 0) < (bArr[i] || 0)) {
return 1;
}
if ((aArr[i] || 0) > (bArr[i] || 0)) {
return -1;
}
}

return 0;
});
}

let versions = ['0.1.1', '2.3.3', '0.302.1', '4.2', '4.3.5', '4.3.4.5'];
console.log(versionSort(versions));

// 输出: [ '4.3.5', '4.3.4.5', '4.2', '2.3.3', '0.302.1', '0.1.1' ]

在上述代码中,我们首先定义了一个函数versionSort(),它接收一个版本号数组

在这个函数中我们使用了数组的sort()方法,定制排序函数来对数组排序

在排序函数中,我们首先把版本号分解成多个部分,然后我们对版本号的每一部分进行比较,如果发现某一部分的版本号更大,我们就认定此版本号整体更大,并结束循环和比较

如果所有部分的版本号都相等,我们认定此版本号等于比较的版本号


排序算法拓展Q41-Q42

引申
  1. 冒泡排序(Bubble Sort)

    通过重复遍历要排序的数列,比较每对相邻元素,如果他们的顺序错误就把他们交换过来

    过程类似在水中的气泡逐渐上升

  2. 选择排序(Selection Sort)

    首先在未排序的数列中找到最小(或最大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(或最大)元素,然后放到已排序序列的末尾

  3. 希尔排序(Shell Sort)

    是插入排序的一种更高效的改进版本

    希尔排序会先将数列进行分组,然后对每组数据进行插入排序,逐渐缩小分组的间隔进行排序,最终当整个数列基本有序时,再对全体元素进行一次直接插入排序

  4. 插入排序(Insertion Sort)

    通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入

  5. 归并排序(Merge Sort)

    归并排序是建立在归并操作上的一种有效的排序算法

    该算法是采用分治法(Divide and Conquer)的一个非常典型的应用

  6. 快速排序(Quick Sort)

    通过选取一个元素作为基准,重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面,此时基准元素在其排好序后的正确位置

  7. 堆排序(Heap Sort)

    利用堆这种数据结构所设计的,视为一种树形选择排序

  8. 计数排序(Counting Sort)

    是一个非比较排序,利用数组下标来确定元素的正确位置

  9. 桶排序(Bucket Sort)

    数组分到有限数量的桶里,每个桶再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)

  10. 基数排序(Radix Sort)

    按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位

    有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序

Q41:实现冒泡排序

难度:⭐⭐⭐⭐⭐

解析

冒泡排序(Bubble Sort)是一种简单的排序算法,它重复地遍历待排序的数组,比较两个相邻的项目,并且在必要时交换它们

每遍历一次都会确保至少有一个元素被移动到其最终位置

实现冒泡排序的步骤解析:

  1. 检查输入是否为数组 (Array.isArray(arr))

    这个条件判定确保了输入的数据类型是数组

    冒泡排序是针对数组设计的,所以这个检查是必要的

  2. 空数组或单个元素数组的检查 (arr.length <= 1)

    如果数组为空或者仅包含一个元素,则不需要排序,因为它已经是有序的

    直接返回原数组

  3. 设置n为数组的长度 (n = arr.length)

    这个变量后面会被用来确定当前未排序部分的范围

  4. 定义newN变量

    这个变量用来记录在某一趟遍历中最后一次发生元素交换的位置,表示数组从newN之后的位置已经是排序好的,下一轮排序时不需要再考虑

  5. 执行排序的外部循环 (do...while)

    外部循环确保排序会持续进行直到没有再需要交换的项,这表示数组已经完全排序

  6. 内部循环 (for循环)

    在数组的未排序部分进行遍历

    • 类型检查 (typeof arr[i - 1] !== typeof arr[i])

      在比较之前确认元素类型相同,以确保排序的有效性和防止JavaScript的强制类型转换可能导致的问题

    • 相邻元素比较 (arr[i - 1] > arr[i])

      对当前元素和它之前的元素进行比较。如果前面的元素比当前元素大,它们的位置需要交换

    • 交换元素

      使用临时变量temp来交换两个元素的位置

    • 更新newN

      设置newN为这次内部循环中最后一次发生交换的索引

  7. 更新n

    n设置为最后一次发生交换的位置newN,这样下一次外部循环时,只需要检查到newN为止,因为后面的部分已经排序好了

  8. 循环结束条件 (newN != 0)

    如果在一轮完整的遍历中没有发生任何交换,newN会保持为0,这表明数组已经完全排序,循环可以结束

通过以上步骤,冒泡排序利用交换元素的方式实现了数组的排序

冒泡的“名字”来源于较大元素的“冒泡”效应,因为在每轮遍历中,它们会如气泡一般逐渐“浮”到数组的一端

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function bubbleSort(arr) {
// 检查输入是否为数组
if (!Array.isArray(arr)) {
throw new TypeError('The provided input is not an array.');
}

// 检查数组长度,对于空数组或只有一个元素的数组直接返回
if(arr.length <= 1) {
return arr;
}

let n = arr.length;
let newN; // 用于记录一轮遍历中最后一次交换的位置

do {
newN = 0; // 每轮开始前将newN重置为0
for (let i = 1; i < n; i++) {
// 添加了一个检查用以确保元素能够比较
if (typeof arr[i - 1] !== typeof arr[i]) {
throw new Error('Elements are not of the same type for comparison.');
}
if (arr[i - 1] > arr[i]) {
let temp = arr[i - 1];
arr[i - 1] = arr[i];
arr[i] = temp;
newN = i; // 更新为最新的交换位置
}
}
n = newN; // 将n更新为最后一次交换的位置,下一轮遍历只到这里为止
} while (newN != 0); // 如果一轮中没有任何交换发生,那么数组已经是排序好的,循环结束

return arr;
}

// 测试数组
let testArray = [64, 34, 25, 12, 22, 11, 90];

// 执行冒泡排序
bubbleSort(testArray);

// 输出排序后的数组
console.log(testArray);


Q42:实现选择排序

难度:⭐⭐⭐⭐⭐

解析

这段代码是选择排序算法的一个 JavaScript 实现

以下是每个主要部分的详细解析:

  1. if (!Array.isArray(arr)) 这个部分检查输入的参数是否为数组

    如果不是,就抛出一个 TypeError

  2. if(arr.length <= 1) return arr; 对于空数组或只有一个元素的数组,它们已经被认为是排序过的,所以直接返回就可以了

  3. arr.forEach((ele, index) => {...} 这个循环检查数组的每个元素是否为数字,并且不是 NaN

    如果元素不是数字或者是 NaN,那么会抛出一个 TypeError

  4. for (let i = 0; i < n - 1; i++) {...} 这个外层循环遍历数组的每个元素,将当前元素设为最小元素

  5. for (let j = i + 1; j < n; j++) {...} 内层循环遍历当前元素之后的所有元素,如果找到比当前最小元素还小的元素,就更新最小元素的索引

  6. if (i !== minIndex) {...} 当一次内层循环结束后,如果最小元素不是当前元素(that is, 索引 iminIndex 不等),就交换这两个元素的位置

    这样,第 i 个位置上的元素就会是前 i+1 个元素中的最小元素

  7. 最后,返回排序后的数组

这个算法的时间复杂度是 O(n^2),其中 n 是数组的长度

尽管选择排序在大多数情况下没有快速排序或归并排序高效,但是它的代码简洁,而且不需要使用额外的内存空间,这使得它在某些情况下仍然是一个很好的选择

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function selectionSort(arr) {
// 检查输入是否为数组
if (!Array.isArray(arr)) {
throw new TypeError('The provided input is not an array.');
}

// 对于非常小的数组,排序是不必要的,可以早点返回
if(arr.length <= 1) {
return arr;
}

// 在开始排序之前,检查数组元素是否可比较
arr.forEach((ele, index) => {
if (typeof ele !== 'number' || isNaN(ele)) {
throw new TypeError(`The provided input array contains non-comparable elements at index ${index}: ${ele}`);
}
});

let n = arr.length;

for (let i = 0; i < n - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}

// 如果找到了一个较小的元素,与当前位置的元素交换
if (i !== minIndex) {
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
}
}

return arr;
}


Q43:实现希尔排序

难度:⭐⭐⭐⭐⭐

解析

这段代码是希尔排序算法的实现,我会详细地逐步解析这个过程。

  1. 输入验证

    首先, 它验证传入的参数是不是一个数组,如果不是数组则抛出错误“Input must be an array

    然后, 它通过一个for循环遍历数组中的每一个元素来验证每个元素是否为数字或字符串

    如果数组中存在既不是数字也不是字符串的元素,则抛出错误“Array elements must be numbers or strings

  2. 初始化变量

    接下来, 定义了数组的长度len和初始间隔gap

    间隔的初始值设为1,这个值将用于确定排序时比较元素之间的间隔

  3. 动态定义间隔序列

    使用一个while循环计算初始的gap

    这个循环基于希尔排序的增量序列计算方法(gap = gap * 3 + 1),直到gap值不小于数组长度的三分之一

    这样做是为了从较宽的间隔开始排序,并逐步缩短间隔,最终达到1

  4. 进行希尔排序

    在外层for循环中, gap值从初始值开始不断地通过Math.floor(gap / 3)缩小,直到gap值为1

    这是希尔排序中减小间隔的过程

    内层for循环i = gap开始迭代数组,这确保了每次比较都是在间隔gap之间进行的

    最内层for循环将当前元素arr[j]与距离gap位置之前的对应元素arr[j - gap]进行比较

    如果arr[j] < arr[j - gap],则将它们位置互换

    这个比较和交换过程一直进行,直到当前元素正确地“插入”(实际上是交换到正确位置)或是已经比较到数组的开始位置

  5. 结束和返回

    当所有的间隔遍历完成,数组将完成排序,并通过return arr;返回排序后的数组

这就是希尔排序的整个过程,从较大的间隔开始,逐渐缩小间隔进行比较和交换,直至间隔为1,相当于进行了一次插入排序,这时数组已经是排序好的了

这种方法相比于直接插入排序,在同样是基于插入的排序算法中,大大提升了效率

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function shellSort(arr) {
// 输入验证
if (!Array.isArray(arr)) {
throw new Error('Input must be an array');
}
for (let element of arr) {
if (typeof element !== 'number' && typeof element !== 'string') {
throw new Error('Array elements must be numbers or strings');
}
}

let len = arr.length,
gap = 1;

while (gap < len / 3) {
gap = gap * 3 + 1;
}

for (gap; gap > 0; gap = Math.floor(gap / 3)) {
for (let i = gap; i < len; i++) {
for (let j = i; j >= gap && arr[j] < arr[j - gap]; j -= gap) {
// 交换元素位置
[arr[j], arr[j - gap]] = [arr[j - gap], arr[j]];
}
}
}
return arr;
}

console.log(shellSort([8, 9, 1, 7, 2, 3, 5, 4, 6, 0]));


Q44:实现插入排序

难度:⭐⭐⭐⭐⭐

解析

这段代码是插入排序算法的一个实现,适用于JavaScript数组

下面我会逐步解析代码的各个部分:

  1. 数组输入验证

    • 函数首先检查传入的参数arr是否为数组类型。如果不是,抛出一个错误
  2. 元素类型验证与NaN排除

    • 检查数组中的每个元素是否都是数字类型,并且不是NaN(Not a Number)

      如果某个元素不是数字或者是NaN,抛出一个错误

  3. 排序逻辑

    • 函数通过两层循环来实现插入排序

      • 外层循环从arr的第二个元素开始,即索引1,遍历到数组的末尾

        这个外层循环表示我们从未排序部分取出的待插入元素

      • 内层循环用于找到这个待插入元素在已排序部分的正确位置,并将其插入

        在这个过程中,我们把当前元素current保存在一个变量中,并逐一将已排序的元素向后移动,直到找到一个比current小的元素

    • 一旦找到合适的位置,我们退出内层循环,并将current放到正确的位置上

  4. 插入动作

    • 当内层循环完成后,已找到current元素的插入位置,此时j变量代表current元素应该插入的位置索引
    • 最后一步是将current变量赋值给arr[j+1],这样我们就将元素插入到了数组的正确位置

代码执行后会返回已经排序的数组

插入排序算法利用了事实,即数组的一个子序列(在当前索引之前的元素)已经是排序过的

它在每次迭代中都会将一个新元素插入到已排序的子序列中,直到整个数组排序完成

插入排序的时间复杂度通常是O(n^2),对于较小的或部分排序的数组来说效率是可接受的

对于较大的数组,可能会需要更高效的算法,如快速排序、堆排序或归并排序

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function insertionSort(arr) {
// 验证输入是否为数组
if (!Array.isArray(arr)) {
throw new Error('Input must be an array');
}

// 验证数组元素类型并排除 NaN
arr.forEach(item => {
if (typeof item !== 'number' || isNaN(item)) {
throw new Error('All array elements must be valid numbers');
}
});

for (let i = 1; i < arr.length; i++) {
let current = arr[i];
let j = i - 1;

// 寻找插入位置的同时进行元素移动
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current; // 完成插入动作
}
return arr;
}


Q45:实现归并排序

难度:⭐⭐⭐⭐⭐

解析

这段代码是一个实现归并排序的JavaScript函数,它包括两个部分:mergeSort主函数和merge辅助函数

让我们逐步来解析一下这段代码

  1. mergeSort函数

    这是一个调用归并排序算法的函数,输入参数为一个待排序的数组arr

    • 首先,函数通过Array.isArray(arr)来检查输入是否为数组,然后使用every方法检查数组内的每个元素是否都是有限的数字,对于非数组或者数组中含有无效数字的情况,会抛出一个错误信息
    • 如果数组长度小于等于1(表示数组为空或只有一个元素),那就没有必要排序,直接返回原数组
    • 然后,函数将待排序的数组从中间拆分为两个子数组,一个包含前半部分的元素,另一个包含后半部分的元素
    • 最后,函数递归地对这两个子数组进行排序,然后通过merge函数将排序后的结果合并在一起
  2. merge函数

    这是一个辅助函数,使用两个已经排序的数组作为输入参数,在这个例子中就是leftright

    • 函数首先初始化一个空的结果数组resultArray和两个从0开始的索引leftIndexrightIndex
    • 在接下来的循环中,函数将left数组中索引为leftIndex的元素和right数组中索引为rightIndex的元素进行比较,较小的那一个将被推入resultArray,并将相应的索引加1,用于追踪两个数组中的比较进度
    • 最后,当left数组和right数组中的元素都被比较过后,剩下的未被比较的元素(其一定会全部在其中一个数组的末尾并且比已经比较过的元素要大)会被加入到结果数组的尾部
答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function mergeSort(arr) {
// 验证输入是否为数组, 并确保每个元素都是有效的数字
if (!Array.isArray(arr) || !arr.every(num => typeof num === 'number' && isFinite(num))) {
throw new Error('Input must be an array of numbers.');
}

if (arr.length <= 1) {
return arr;
}

const middle = Math.floor(arr.length / 2);
const left = arr.slice(0, middle);
const right = arr.slice(middle);

return merge(mergeSort(left), mergeSort(right));
}

function merge(left, right) {
let resultArray = [], leftIndex = 0, rightIndex = 0;

while (leftIndex < left.length && rightIndex < right.length) {
if (left[leftIndex] < right[rightIndex]) {
resultArray.push(left[leftIndex]);
leftIndex++;
} else {
resultArray.push(right[rightIndex]);
rightIndex++;
}
}

return resultArray
.concat(left.slice(leftIndex))
.concat(right.slice(rightIndex));
}


Q46:实现快速排序

难度:⭐⭐⭐⭐⭐

解析
  1. isNumeric(value)

    这是一个辅助函数,接收一个参数并返回一个布尔值表示该参数是否是finite number(即不是NaN, Infinity或-Infinity)

  2. swap(array, i, j)

    它是用于交换数组两个索引下的元素的位置

    这个函数在排序算法中非常关键

  3. partition(array, left, right) 在快速排序中也是一个核心的函数

    所谓”partition operation”,就是将数组分成两半,使得左边子数组的所有元素都小于右边子数组的所有元素

    同时,该函数还会返回一个“pivot index”

    这个索引是计算后得到,使得所有比它小的元素都在它的左边,所有比它大的元素都在它的右边

    这个pivot就是我们之后进行递归操作的基础

  4. quickSort(array, left, right) 是主要的排序函数

    它首先检查传入的参数是否满足所需的要求,例如数组中的元素是否全为数字等

    然后,它使用了划分(partition)操作并得到了一个“pivot index”

    在得到这个pivot index后,quickSort函数进行自我调用,对pivot index的左边和右边的子数组分别进行排序

    这个过程中的自我调用,就是quickSort中的“递归”部分

快速排序算法是一种非常高效的排序算法

它的基本思想是:通过一次排序将待排序数据分割成独立的两部分,其中一部分数据都比另外一部分的数据小,然后再按此方法对这两部分数据分别进行快速排序,整个过程可以递归进行,以此达到整个数据变成有序序列

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function isNumeric(value) {
return typeof value === 'number' && !isNaN(value) && isFinite(value);
}

function quickSort(array, left = 0, right = array.length - 1) {
// 参数验证
if (!Array.isArray(array)) throw new TypeError('The first argument must be an array');
if (typeof left !== 'number' || typeof right !== 'number') throw new TypeError('Left and right must be numbers');

// 返回已经排序好的小数组
if(left >= right) return;

// 检查元素是否全部为数字
if (!array.slice(left, right + 1).every(isNumeric)) throw new TypeError('Array should contain only numbers');

// 开始排序
let pivotIndex = partition(array, left, right);

if (left < pivotIndex - 1) {
quickSort(array, left, pivotIndex - 1);
}
if (pivotIndex < right) {
quickSort(array, pivotIndex, right);
}

return array;
}

function swap(array, i, j) {
[array[i], array[j]] = [array[j], array[i]];
}

function partition(array, left, right) {
let pivot = array[Math.floor((right + left) / 2)];
let i = left;
let j = right;

while (i <= j) {
while (array[i] < pivot) i++;
while (array[j] > pivot) j--;
if (i <= j) {
swap(array, i, j);
i++;
j--;
}
}

return i;
}


Q47:实现堆排序

难度:⭐⭐⭐⭐⭐

解析
  1. heapSort 函数

    这是排序的主函数,接收一个数组 arr 作为参数

    1. 输入验证: 首先,进行检查以确保 arr 参数是一个数组
    2. 长度验证: 如果数组长度 <= 1,那就不需要排序,直接返回原数组
    3. 类型验证: 接下来检查数组中的每个元素都是有限数值
    4. 构建最大堆: 使用 buildMaxHeap 函数把输入数组 arr 转换为最大堆
    5. 排序逻辑
      • 循环从数组的末尾开始到索引1处结束
      • 使用 swap 函数将堆顶的最大元素与当前考察位置的元素交换
      • 调用 maxHeapify 函数将堆顶至考察位置上的部分调整为最大堆

    这个函数将按升序排列数组 arr

  2. buildMaxHeap 函数

    这个辅助函数将数组转换成一个最大堆结构

    • 从最后一个非叶子节点开始向前(这是由 start = Math.floor(length / 2) - 1 决定的),每个节点都进行下沉操作,以保证子节点的值小于这个节点的值
  3. maxHeapify 函数

    maxHeapify 是堆排序核心步骤,它确保堆的属性被维护

    • 该函数接收一个索引 index 和堆的大小 heapSize,使用左儿子和右儿子的索引与当前节点的值进行比较
    • 如果子节点的值大于父节点,largest 将记录最大值的索引
    • 如果发现最大值的索引不是当前考察的父节点,则通过 swap 交换当前节点和最大值的节点
    • 由于交换后影响了子堆的结构,所以递归调用 maxHeapify 来调整变动后的子堆,确保它仍然是最大堆
  4. swap 函数

    这个简单的函数通过解构赋值交换数组中两个元素的位置

    • 实现数组 arr 中索引 ij 上的两个元素的交换。这种写法是ES6语法糖,是交换元素的快捷方式

这整个堆排序的过程:

首先,通过 buildMaxHeap 将数组重新排序成最大堆

然后,堆顶(也是整个数组中的最大值)和数组的最后一个元素交换位置,然后缩小考察的数组范围(即 减少 heapSize),由于交换破坏了最大堆的属性,需要重新调用 maxHeapify 来恢复最大堆

重复这个过程,直到整个数组完全排序

最终,原数组 arr 变成已排序的数组,并返回

由于堆排序的方法,我们每次都能确定最大的元素被放置在最终的位置,因此不需要额外的存储空间之外的数组来进行排序

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function heapSort(arr) {
if (!Array.isArray(arr)) {
throw new TypeError('Expected an array');
}

if (arr.length <= 1) {
return arr;
}

if (!arr.every(Number.isFinite)) {
throw new TypeError('Array must contain only numbers');
}

// 构建最大堆
buildMaxHeap(arr);

// 交换首尾元素并重新调整最大堆
for (let i = arr.length - 1; i > 0; i--) {
swap(arr, 0, i);
maxHeapify(arr, 0, i);
}
return arr;
}

function buildMaxHeap(arr) {
// 从最后一个非叶子节点开始向前遍历,进行下沉操作
const length = arr.length;
const start = Math.floor(length / 2) - 1;
for (let i = start; i >= 0; i--) {
maxHeapify(arr, i, length);
}
}

function maxHeapify(arr, index, heapSize) {
let left = 2 * index + 1, right = 2 * index + 2, largest = index;

if (left < heapSize && arr[left] > arr[largest]) {
largest = left;
}
if (right < heapSize && arr[right] > arr[largest]) {
largest = right;
}
if (largest !== index) {
swap(arr, index, largest);
// 继续调整下面的非叶子节点
maxHeapify(arr, largest, heapSize);
}
}

function swap(arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}


Q48:怎么实现一个二维码登录pc网站

难度:⭐⭐⭐⭐

答案

二维码登录是现代Web开发中常见的一种身份验证方式,尤其在移动设备广泛使用的背景下

它提供了一个便捷的方法,允许用户通过扫描二维码来登录PC网站,避免了手动输入账号密码的麻烦

接下来,我将详细解释二维码的本质及如何实现二维码登录PC网站

二维码的本质

二维码(Quick Response Code, QR Code)是一种矩阵二维码或二维条码,它由黑白色块组成

与传统的一维条码相比,二维码可以存储更多信息,包括网址、文本或其他数据

二维码可以通过相机扫描进行读取,这一点在二维码登录中尤为重要

实现二维码登录的流程

实现二维码登录通常涉及后端服务器、PC网站(通常是网站的登录页面)和用户的移动设备

基本流程大致如下:

  1. 生成登录二维码

    在PC网站登录页面生成二维码

    当用户打开PC网站的登录页面时,网站后台会生成一个唯一标识(通常是一个随机生成的字符串或UUID),并将这个唯一标识与一个登录请求的URL合并

    然后,使用这个URL生成二维码显示在登录页面上

  2. 移动设备扫描二维码

    用户使用移动设备扫描二维码

    这一步通常需要用户已经在移动设备上登录了相应的移动应用(比如微信、支付宝等),应用可以识别并处理扫描到的URL

  3. 移动设备请求登录授权

    移动应用处理二维码中的URL,向服务器发起登录授权请求

    在这一步,移动应用通常会向服务器发送包含用户凭据(如令牌)和二维码中的唯一标识的请求

  4. 服务器验证并授权登录

    服务器验证请求

    服务器接收到登录授权请求后,验证用户凭据和唯一标识

    如果验证成功,服务器则认为PC端的登录请求是合法的

  5. 通知PC端登录成功

    服务器通过WebSocket或轮询机制通知PC端

    一旦服务器验证用户成功,并且授权了PC端的登录请求,它需要一个方式来通知PC端

    这通常通过WebSocket连接实现,如果PC端在生成二维码时就建立了WebSocket连接

    或者通过PC端定期轮询服务器来检查登录状态

  6. PC端完成登录

    PC端接收到登录成功的通知后,完成登录过程

    这可能涉及到设置用户的登录状态、储存令牌和跳转到登录后的页面等

关键技术实现

  1. 二维码生成

    可以使用各种前端库来在网页上生成二维码,例如qrcode.js

  2. WebSocket或轮询

    WebSocket提供了一个实时双向通信方案,使得服务器可以实时通知PC网站

    如果不使用WebSocket,可以采用定时轮询的方式,让PC端定时向服务器发起请求查询登录状态

  3. 唯一标识处理

    在服务器端生成并处理唯一标识(比如UUID),将它与用户的登录状态关联起来

  4. 安全性

    确保所有通信都通过HTTPS进行,保护用户数据安全

    另外,二维码的有效期应该设定得短些,避免被滥用

通过上述流程和关键技术实现点,你可以在自己的PC网站上实现二维码登录功能

这种登录方式为用户提供了便利的同时,也要确保整个过程的安全性


Q49:Javascript里面的倒计时怎么纠正偏差

难度:⭐⭐⭐⭐

答案
  1. 使用服务器时间作为基准

    因为用户的设备时间可能不准确,所以最好在倒计时开始时同步一次服务器时间,并据此进行后续的倒计时计算

    • AJAX请求

      通过一个异步请求获取服务器时间,并存储这个时间用于后续计算

      1
      2
      3
      4
      5
      6
      7
      function getServerTime(callback) {
      fetch('/api/server-time')
      .then(response => response.json())
      .then(data => {
      callback(data.serverTime); // 服务器的UNIX时间戳
      });
      }
    • WebSocket连接

      维持一个WebSocket连接,动态同步服务器时间用于精确计时

      1
      2
      3
      4
      5
      // 假设已经打开了一个WebSocket连接ws
      ws.onmessage = function(event) {
      const serverTime = JSON.parse(event.data).serverTime;
      startCountdown(serverTime);
      };
  2. 校正时间间隔

    使用setTimeoutsetInterval的周期性调用可以不断校正时间,避免累计误差

    每次回调时应该重新计算剩余时间,而不是简单依赖固定的间隔

    • 递归setTimeout调用:使用setTimeout代替setInterval,并在每次回调执行时递归地调用它,实现自调正的特性

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      function countdown(remaining) {
      if (remaining <= 0) {
      console.log('Countdown finished');
      return;
      }
      console.log(remaining + ' ms remaining');

      setTimeout(() => countdown(remaining - 1000), 1000);
      }
      countdown(10000);
    • 动态延迟:在setTimeout中动态计算下一次回调的延迟时间

      1
      2
      3
      4
      5
      6
      7
      8
      9
      function adaptiveCountdown(endTime) {
      function scheduleNextTick() {
      var now = Date.now();
      var remaining = endTime - now;
      var nextTick = remaining % 1000 || 1000;
      setTimeout(scheduleNextTick, nextTick);
      }
      scheduleNextTick();
      }
  3. 避免长时间间隔

    尽量不要设置很长的单次倒计时(如几分钟以上),因为更长的时间间隔意味着更大的潜在偏差

    如果需要长时间倒计时,应该分成多个较短的间隔

    • 分段计时

      将长时间间隔分成多个短时间间隔,逐段计时

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      function segmentCountdown(duration, segment) {
      var segmentsLeft = Math.ceil(duration / segment);

      function tick() {
      segmentsLeft--;
      if (segmentsLeft > 0) {
      setTimeout(tick, segment);
      } else {
      console.log('Countdown finished');
      }
      }

      setTimeout(tick, segment);
      }
  4. 使用性能优化

    确保倒计时函数的执行尽可能快和轻量,减少其他JavaScript任务对倒计时精度的影响

    • 降低页面复杂度

      确保倒计时期间页面尽可能简洁,减少DOM操作和CPU密集型任务

    • 使用requestAnimationFrame

      这通常用于动画,但可以确保计时器的代码在浏览器绘图前执行,从而保持同步

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      function frameCountdown(targetTime) {
      function frame() {
      var now = Date.now();
      if (targetTime > now) {
      requestAnimationFrame(frame);
      } else {
      console.log('Countdown finished');
      }
      }
      requestAnimationFrame(frame);
      }
  5. 使用Web Workers

    如果需要高精度计时器,可以使用Web Workers,因为它们运行在后台线程,不受主线程任务的干扰

    • Dedicated Worker

      创建一个独立的脚本文件,该文件作为Web Worker运行,用于倒计时

      这个Dedicated Worker脚本接收来自主线程的倒计时开始指令,然后每秒通过postMessage方法更新剩余时间,直至倒计时结束

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      // 接收主线程消息,开始倒计时
      onmessage = function(event) {
      var seconds = event.data;

      var countdown = function() {
      postMessage(seconds);
      if(seconds-- > 0) {
      setTimeout(countdown, 1000);
      }
      };

      countdown();
      };

      在主线程处理倒计时逻辑

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      // 创建Dedicated Worker
      var countdownWorker = new Worker('countdownWorker.js');

      // 接收Worker发来的倒计时数据
      countdownWorker.onmessage = function(event) {
      console.log("剩余时间: " + event.data + "秒");
      if(event.data === 0) {
      console.log("倒计时结束!");
      countdownWorker.terminate(); // 停止Worker
      }
      };

      // 启动倒计时,比如60秒
      countdownWorker.postMessage(60);
    • Shared Worker

      如果需要在多个标签页或者iframe中共享同一个倒计时,可以使用Shared Worker来实现

      主线程代码

      只需要一处(例如,用户打开的第一个标签页)通过postMessage启动倒计时,其他所有连接了这个Shared Worker的标签页都会收到倒计时更新的消息

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      var mySharedWorker = new SharedWorker('sharedCountdownWorker.js');

      mySharedWorker.port.start();

      mySharedWorker.port.onmessage = function(e) {
      console.log("剩余时间: " + e.data + "秒");
      if(e.data === 0) {
      console.log("倒计时结束!");
      }
      };

      // 只在一个标签页中启动倒计时即可
      // mySharedWorker.port.postMessage(60);

      这个Shared Worker脚本接收来自任一连接它的页面的倒计时开始指令,然后每秒通过postMessage给所有连接的端口更新剩余时间,实现多标签页或iframe间的倒计时共享

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      var ports = [];
      onconnect = function(e) {
      var port = e.ports[0];
      ports.push(port);

      port.onmessage = function(event) {
      var seconds = event.data;
      countdown(seconds);
      };
      };

      function countdown(seconds) {
      ports.forEach(port => {
      port.postMessage(seconds);
      });

      if(seconds-- > 0) {
      setTimeout(() => countdown(seconds), 1000);
      }
      }
引申

前端JavaScript获取的时间实际上是来自用户设备的系统时间,这意味着如果用户修改了设备的时间,那么JavaScript获取的时间也会受到影响,倒计时和其他依赖时间的功能会因此而不准确


Q50:实现合并Promise函数

实现mergePromise函数,把传进去的数组按顺序先后执行,并且把返回的数据先后放到数组data中

难度:⭐⭐⭐⭐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const time = (timer) => {
return new Promise(resolve => {
setTimeout(() => {
resolve()
}, timer)
})
}
const ajax1 = () => time(2000).then(() => {
console.log(1);
return 1
})
const ajax2 = () => time(1000).then(() => {
console.log(2);
return 2
})
const ajax3 = () => time(1000).then(() => {
console.log(3);
return 3
})

function mergePromise () {
// 在这里写代码
}

mergePromise([ajax1, ajax2, ajax3]).then(data => {
console.log("done");
console.log(data); // data 为 [1, 2, 3]
});

// 要求分别输出
// 1
// 2
// 3
// done
// [1, 2, 3]
解析
  1. promise方式

    为了实现mergePromise函数,我们需要按照传入数组的顺序依次执行这些异步操作,并收集每个异步操作的结果

    这可以通过链式调用Promise的.then()方法来完成

    在这个实现中,mergePromise函数接受一个包含ajax函数的数组ajaxArray

    我们定义了data数组来保存每个ajax调用后resolve的结果

    我们开始时有一个“空”的已经resolved的Promise对象,我们把这个Promise对象赋值给sequence变量

    然后我们利用Array.prototype.forEach遍历ajaxArray数组,将数组中的每个函数加到sequence的调用链中,并通过连续调用.then()保证顺序执行

    在每一次迭代中,我们都将上一个Promise通过.then()链接到当前的ajax调用,然后在另一个.then()中将结果res推入data数组,并返回data数组,这样下一个Promise的.then()可以接收到上一个.then()返回的数据数组

    这样遍历之后,data数组就按顺序积累了所有ajax调用的结果

    最后返回的sequence Promise在所有的ajax函数都顺序执行并且它们的结果都被推入data数组之后,就会被resolve

    这个最后的Promise resolve了data数组作为结果,然后我们在.then()回调函数中打印出”data”数组和”done”

    利用这种方式,我们实现了函数依次执行,并按序收集它们resolve的值

  2. await方式

    也可以使用async/await来实现

    使用async/await可以让我们的代码看起来更像同步代码,降低了复杂性

    在此实现中,我们使用async关键字声明了一个异步函数mergePromise,此函数接受一个包含ajax函数的数组ajaxArray

    在异步函数内部,我们可以使用await关键字来等待每个ajax函数的完成,并将结果push进data数组

    这种方式的优点是,代码更简洁,更易于阅读

    我们没有必要手动创建一个Promise链,可以直接在for循环中循环等待每个Promise的resolve,一旦全部完成,我们便得到了所有值放在data数组中

    然后返回这个数组,当所有ajax调用都结束后,将会resolve整个mergePromise函数的Promise

答案

promise方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function mergePromise(ajaxArray) {
// 存储每个ajax的结果
const data = [];

// 初始值是一个已解决的Promise
let sequence = Promise.resolve();

ajaxArray.forEach(item => {
// 上一个Promise的.then()的结果是新的Promise
sequence = sequence.then(item).then(res => {
// 把每次得到的结果放入data
data.push(res);
return data; // 传递data到下一个.then
});
});

// sequence的最终值是最后一个被处理的Promise
return sequence;
}

mergePromise([ajax1, ajax2, ajax3]).then(data => {
console.log("done");
console.log(data); // data 为 [1, 2, 3]
});

await方式

1
2
3
4
5
6
7
8
9
10
11
12
async function mergePromise(ajaxArray) {
let data = [];
for(let value of ajaxArray) {
data.push(await value());
}
return data;
}

mergePromise([ajax1, ajax2, ajax3]).then(data => {
console.log("done");
console.log(data); // data 为 [1, 2, 3]
});


Q51:使用Promise实现,限制异步并发个数,并尽快完成

有8个图片资源的url,已经存储在数组urls中

urls类似于['https://image1.png', 'https://image2.png', ....] 而且已经有一个函数function loadImg,输入一个url链接,返回一个Promise,该Promise在图片下载完成的时候resolve,下载失败则reject。

但有一个要求,任何时刻同时下载的链接数量不可以超过3个

请写一段代码实现这个需求,要求尽可能快速地将所有图片下载完成

难度:⭐⭐⭐⭐⭐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var urls = [
"https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/AboutMe-painting1.png",
"https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/AboutMe-painting2.png",
"https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/AboutMe-painting3.png",
"https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/AboutMe-painting4.png",
"https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/AboutMe-painting5.png",
"https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/bpmn6.png",
"https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/bpmn7.png",
"https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/bpmn8.png",
];
function loadImg(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
console.log("一张图片加载完成");
resolve(img);
};
img.onerror = function() {
reject(new Error('Could not load image at' + url));
};
img.src = url;
});
解析

可以考虑使用”并发控制”的模式,即每次并发请求的数量为3,当有请求完成后再发起新的请求,直到全部图片都加载完成

这样可以确保任何时刻最多只有3个请求在同时进行

这段代码实现了一个名为 limitLoad 的函数,其目的是管理对图片资源的异步加载,限制在任何时刻同时进行的加载数量不超过给定的限制值(limit

下面是对这个函数如何工作的逐步解析:

  1. 输入参数

    • urls

      一个包含图片资源URLs的数组

    • loadImg

      一个接受单个URL为参数并返回一个Promise的函数,该Promise会在相应的图片加载完成时解决,如果加载失败,则拒绝

    • limit

      同时进行图片加载的最大数量

  2. 函数执行流程

    1. 初始化变量
      • sequence: 通过复制urls数组创建,以避免修改原始数组
      • promises: 存储正在进行的、数量不超过limit的加载操作的Promise对象数组
    2. 启动初始的并发请求
      • 使用map函数从sequence中提取前limit个URL,并对每个URL调用loadImg函数,开始加载对应的图片
      • loadImg返回的每个Promise,通过thencatch确保无论成功还是失败都将返回resolving的Promise,其中包含了该次加载操作在promises数组中的索引
    3. 利用reduce顺序处理剩余的urls
      • reduce通过一个初始的Promise开始执行,并且为序列中的每个URL顺序安排加载操作
      • 在每次迭代中,使用Promise.race等待promises数组中任意一个Promise完成(无论是解决还是拒绝)
      • 一旦有Promise完成,就用新的加载操作的Promise替换promises数组中对应的位置,这样可以确保promises数组始终在维护最多limit个正在进行的加载操作
    4. 处理所有加载操作完成后的情况
      • 使用promiseChain.then()等待所有通过reduce安排的加载操作完成
      • 再通过Promise.all(promises)等待当前正在进行的所有加载操作的完成
      • 之后,通过then()可以执行一些所有图片加载尝试完成(无论成功或失败)后的最终处理
    5. 错误处理
      • 在最后,通过catch捕获整个加载过程中遇到的任何错误,并打印错误信息
答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function limitLoad(urls, loadImg, limit) {
// 拷贝urls
let sequence = [].concat(urls);
// 存储正在加载的图片Promise数组
let promises = [];

// 启动初始的并发请求
promises = sequence.splice(0, limit).map((url, index) => {
// 这里加载图片,然后返回图片索引
return loadImg(url).then(() => index).catch(() => index);
});

// 使用reduce依次加载剩余的图片
let promiseChain = sequence.reduce((lastPromiseChain, url, currentIndex) => {
return lastPromiseChain.then(() => {
// 等待到promises数组中任何一个Promise被解决(或被拒绝)
return Promise.race(promises);
}).then((fastestIndex) => {
// 一旦图片加载完成,用新的Promise替换掉promises数组中已完成的Promise
promises[fastestIndex] = loadImg(url).then(() => fastestIndex).catch(() => fastestIndex);
});
}, Promise.resolve(null));

// 当所有图片加载完成(或失败)后,返回一个新的Promise
return promiseChain.then(() => {
// 最后等待剩余的图片加载完成(或失败)
return Promise.all(promises);
}).then(() => {
// 在所有图片加载(或失败)后,可能需要做一些最终的处理
console.log('所有图片都已尝试加载');
}).catch(err => {
// 处理整个图片加载过程中遇到的任何错误
console.error("在加载所有图片的过程中发生了一个错误: ", err);
});
}

limitLoad(urls, loadImg, 3);


实现API

Q1:使用 setTimeout 实现 setlnterval

难度:⭐⭐⭐

解析

在使用setTimeout实现setInterval(即模拟周期性执行某个函数的行为)的时候,核心思想是通过递归调用setTimeout来达到setInterval的效果

这么做有几个关键的注意点和技巧:

  1. 精确的时间控制

    使用setTimeout递归调用的方式可以比setInterval提供更精确的时间控制

    因为setInterval在某些情况下可能会受到多个待执行的回调函数堆积的影响,导致实际执行间隔不准确

    在递归调用setTimeout时,每次都是在上一个调用完成后才设置下一个调用,从一定程度上避免了这个问题

  2. 避免堆积

    在高负载或者执行时间较长的情况下,避免回调函数的堆积。如果回调函数执行时间过长,超过了设置的间隔时间,setTimeout会等待回调函数执行完毕后立即执行,不会像setInterval那样导致回调函数堆积。

  3. 错误处理

    递归调用时,可以在每一次的回调函数中添加错误处理逻辑

    这是利用setTimeout模拟setInterval可以做到而直接使用setInterval较难实现的

  4. 提供停止机制

    在直接使用setInterval时,可以通过clearInterval停止执行

    在用setTimeout模拟时,需要手动提供一个机制来停止循环(比如,通过一个标志变量控制)

  5. 性能考虑

    虽然使用setTimeout递归可以提供更灵活的控制,但在某些极端情况下(比如,非常短的时间间隔和复杂的业务逻辑),它可能会导致性能问题

    因此,设计时应考虑到具体的业务需求和环境

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function mySetInterval(fn, timeout) {
let timerId;

// 用于停止定时器的函数
function stop() {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
}

// 递归函数用来模拟 setInterval 的行为
function intervalFunction() {
if (timerId !== null) { // 检查定时器是否已经被停止
try {
fn(); // 尝试执行用户提供的回调函数
} catch (error) {
console.error("Error during interval execution:", error);
stop(); // 出错时停止定时器
throw error; // 可选,根据需要决定是否需要重新抛出错误
}

// 安排下一次执行
timerId = setTimeout(intervalFunction, timeout);
}
}

// 启动定时器
timerId = setTimeout(intervalFunction, timeout);

// 返回一个对象,包含 stop 方法,以便外部可以停止定时器
return { stop };
}


// 使用方式:
let interval = mySetInterval(() => console.log("Hello World"), 1000);

// 在需要停止定时器时,可以调用 stop 方法
// 例如,5秒后停止
setTimeout(() => {
interval.stop();
console.log("Interval stopped");
}, 5000);


Q5:实现js里面的sort排序

难度:⭐⭐⭐⭐⭐

答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function quickSort(arr, compareFn = defaultCompare) {
if (arr.length < 2) return arr;

const pivot = arr[0];
const lesser = [];
const greater = [];

for (let i = 1; i < arr.length; i++) {
if (compareFn(arr[i], pivot) < 0) {
lesser.push(arr[i]);
} else {
greater.push(arr[i]);
}
}

return [...quickSort(lesser, compareFn), pivot, ...quickSort(greater, compareFn)];
}

function defaultCompare(a, b) {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}

quickSort([3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]);