嗨!我是日渐圆润的团子,大家好久不见~
在前端的技术面试中,手撕题是一个非常重要的环节。手撕题不仅能考察面试者的编程水平,还能考察面试者的思考方式和解决问题的能力。
因此,今天就来分享一下借助ChatGPT为大家整理的十道前端高频手撕题!
1. 深拷贝
所谓深拷贝,指的是创建一个新的对象,将原始对象中的所有属性和嵌套的对象完全复制到新对象中。
新对象与原对象在内存中完全独立。在新对象中修改属性不会影响原始对象。
与之相对的,浅拷贝只复制对象的引用,而不是对象本身,因此修改新对象中的属性可能会影响原始对象。
下列展示了使用ts实现深拷贝的代码示例:
function deepCopy<T>(obj: T): T {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
let result: any;
if (Array.isArray(obj)) {
result = [];
for (let i = 0; i < obj.length; i++) {
result[i] = deepCopy(obj[i]);
}
} else {
result = {};
for (let key in obj) {
result[key] = deepCopy(obj[key]);
}
}
return result;
}
在上述代码中,deepCopy函数接受一个泛型参数 T,用于指定待拷贝对象的类型。
在函数中,首先判断待拷贝对象是否为基本类型或 null,如果是,则直接返回原对象。否则,根据对象类型创建一个新对象,并递归拷贝原对象的每个属性值到新对象中。
该函数使用了递归算法来实现深拷贝,对于复杂对象或嵌套对象,也能正确地进行拷贝。
需要注意的是,对于循环引用的对象,上述写法可能会陷入死循环,因此需要特别处理循环引用的情况。
下列代码可以正确处理循环引用问题:
function deepCopy<T>(obj: T, hash = new WeakMap()): T {
if (Object(obj) !== obj) return obj; // 原始类型直接返回
if (obj instanceof RegExp) return new RegExp(obj); // 正则类型特殊处理
if (obj instanceof Date) return new Date(obj); // 日期类型特殊处理
if (hash.has(obj)) return hash.get(obj); // 处理循环引用
const cloneObj = Array.isArray(obj) ? [] : {}; // 创建一个新的对象或数组
hash.set(obj, cloneObj); // 存储原对象和新对象的对应关系
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloneObj[key] = deepCopy(obj[key], hash); // 递归拷贝对象的属性
}
}
return cloneObj as T;
}
在上述代码中,使用了 WeakMap 来解决循环引用的问题。
WeakMap 是一种弱引用的数据结构,它可以将对象作为键存储在其中,并且不会影响对象的垃圾回收。
在这个实现中,我们使用 WeakMap 来记录原始对象和拷贝对象的对应关系,以避免循环引用导致的死循环。
2. 函数柯里化
柯里化是一种函数式编程技术,它将一个多参数函数转换为一系列单参数函数的调用链。
使用柯里化的优点是可以将函数变得更加灵活和可复用,支持预先传递一些参数,留下另一些参数等待后续调用。
另外,柯里化也可以用于创建更加通用和抽象的函数,因此可以将柯里化函数看作一种构建其他函数的基础组件。
下面是使用 ts 实现函数柯里化的代码:
function curry(fn: Function) {
return function curried(...args: any[]) {
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function(...moreArgs: any[]) {
return curried.apply(this, args.concat(moreArgs));
}
}
}
}
该函数接受一个多参数函数 fn 作为参数,并返回一个新的函数 curried。
curried 函数会检查当前传递的参数数量是否足够调用 fn 函数,如果是,则调用 fn 函数并返回结果,否则返回一个新的函数,等待更多参数的传递。
这个新的函数也是一个柯里化函数,它会将之前传递的参数和当前传递的参数合并,并递归调用 curried 函数,直到所有参数都被传递完毕。
使用 ts实现柯里化后,我们可以将一个多参数函数转换为一系列单参数函数的调用链,例如:
function add(x: number, y: number, z: number) {
return x + y + z;
}
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 输出 6
console.log(curriedAdd(1, 2)(3)); // 输出 6
console.log(curriedAdd(1)(2, 3)); // 输出 6
console.log(curriedAdd(1, 2, 3)); // 输出 6
3. 数组扁平化
数组扁平化是指将一个嵌套的数组展开成一个一维的数组。
例如,将 [1, [2, [3, 4], 5], 6] 扁平化成 [1, 2, 3, 4, 5, 6]。
使用ts实现数组扁平化,可以使用递归算法或者使用 ES6 中的 flat 方法。下面分别介绍这两种方法。
1. 递归算法
递归算法的思路是遍历数组中的每个元素,如果当前元素是数组,则继续递归处理,直到最终得到一个一维数组为止。
具体实现代码如下:
function flatten(arr: any[]): any[] {
let result: any[] = [];
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
result = result.concat(flatten(arr[i]));
} else {
result.push(arr[i]);
}
}
return result;
}
2. 使用 flat 方法
ES6 中的 flat 方法可以将多维数组扁平化为一维数组。
flat 方法接受一个可选参数,用于指定扁平化的深度,默认为 1。如果指定深度为 Infinity,则可以扁平化任意维度的数组。
具体实现代码如下:
function flatten(arr: any[]): any[] {
return arr.flat(Infinity);
}
3. 洋葱模型
洋葱模型是指 Koa 的中间件机制,中间件可以看做是对请求和响应的预处理。
通过洋葱模型,每一个中间件都会在请求到来时先被执行,然后再执行下一个中间件,最后执行响应操作,整个过程就像是一个洋葱,由内向外一层层地执行,最后再由外向内一层层地返回。
下面是使用 ts 实现 Koa 洋葱模型的代码示例:
function f(next: () => void): void {
console.log(1);
next(); // next的作用就是执行下一个函数
console.log(2);
}
function g(next: () => void): void {
console.log(3);
next();
console.log(4);
}
function h(next: () => void): void {
console.log(5);
next();
console.log(6);
}
function compose(...funcs: ((next: () => void) => void)[]): () => void {
return function (): void {
function execute(index: number): void {
// 依次处理函数
const fn = funcs[index];
if (!fn) return null; // 代表遍历结束
fn(function next(): void {
execute(index + 1); // 进入下一个函数
});
}
execute(0);
};
}
在上述代码中,compose函数用来将一组函数按照洋葱模型的方式组合起来,返回一个函数。这个函数在被调用时,会按照从外到内的顺序执行一组函数。
在compose函数中,我们定义了一个execute函数,它会按照顺序依次执行每个函数,每个函数中都调用next函数进入下一个函数,直到函数数组遍历结束。
最后,我们返回一个新的函数,它会按照洋葱模型的顺序执行这一组函数。
4. 睡眠函数
睡眠函数的作用是在程序执行时延迟一段时间。
在某些情况下,我们可能需要程序在一定时间后才能执行下一步操作,例如:
- 需要等待某个事件的发生,比如等待用户输入、等待服务器返回数据等;
- 需要等待一段时间后再执行下一步操作,比如在轮询操作中等待一段时间后再向服务器发起请求;
- 需要模拟程序的等待行为,比如在测试代码中等待一段时间后再检查某个条件是否满足等。
在这些情况下,使用睡眠函数可以让程序在指定时间内等待,避免了程序不必要的忙等待,同时也方便了代码的编写和调试。
下面是使用 ts 实现睡眠函数的代码示例:
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
5. 防抖函数
防抖函数的作用是在某个事件被触发后,如果在指定的时间内再次触发该事件,就会取消之前的事件处理函数,并重新开始计时。
这样可以避免事件处理函数被频繁触发,从而减少一些不必要的操作。
常见的应用场景包括:
- 搜索框自动补全:当用户输入关键字时,防抖函数可以避免在用户连续输入时频繁触发搜索请求;
- 滚动事件处理:当页面滚动时,防抖函数可以避免过多的计算和DOM操作,从而提高页面的性能。
下面是使用ts实现一个防抖函数的代码:
function debounce(func: Function, delay: number): Function {
let timer: ReturnType<typeof setTimeout>;
return function (...args: any[]) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
这个函数接受两个参数:第一个参数是需要执行的函数,第二个参数是需要防抖的延迟时间。
它返回一个新的函数,在调用该函数时,如果在延迟时间内再次调用该函数,就会取消之前的定时器,重新开始计时,直到延迟时间过去后再执行该函数。
6. 节流函数
节流函数的作用是在某个事件被触发时,按照一定的时间间隔执行事件处理函数。
这样可以避免事件处理函数被频繁触发,从而减少一些不必要的操作。
常见的应用场景包括:
- 滚动事件处理:当页面滚动时,节流函数可以避免过多的计算和DOM操作,从而提高页面的性能;
- 鼠标移动事件处理:当鼠标移动时,节流函数可以避免频繁地更新DOM元素,从而提高页面的性能。
下面是使用ts实现一个节流函数的代码示例:
function throttle(func: Function, delay: number): Function {
let timer: ReturnType<typeof setTimeout>;
return function (...args: any[]) {
if (!timer) {
timer = setTimeout(() => {
func.apply(this, args);
timer = undefined;
}, delay);
}
};
}
这个函数接受两个参数:第一个参数是需要执行的函数,第二个参数是需要节流的时间间隔。它返回一个新的函数,在调用该函数时,如果在时间间隔内再次调用该函数,就会忽略该次调用,直到时间间隔过去后再次执行该函数。
需要注意的是,节流函数和防抖函数的作用有所不同。
防抖函数会在延迟时间内只执行一次事件处理函数,而节流函数会按照一定的时间间隔执行事件处理函数。
7. PromiseAll
Promise.all()方法通常用于在一个异步操作中执行多个并发的异步请求,然后在所有请求都完成后,将它们的结果合并成一个结果。
下面是使用ts实现一个PromiseAll函数的代码示例:
function promiseAll<T>(promises: Promise<T>[]): Promise<T[]> {
return new Promise((resolve, reject) => {
let count = 0;
const result: T[] = [];
promises.forEach((promise, index) => {
promise
.then((res) => {
count++;
result[index] = res;
if (count === promises.length) {
resolve(result);
}
})
.catch((err) => {
reject(err);
});
});
});
}
promiseAll函数接受一个Promise对象数组作为参数,返回一个新的Promise对象。
promiseAll函数会同时执行数组中的所有Promise对象,并在所有Promise对象都成功时返回一个数组,包含了所有Promise对象的结果值。
如果有任何一个Promise对象被拒绝(rejected),则返回的Promise对象会被拒绝,并带有该Promise对象的拒绝原因。
8. 数组去重
所谓数组去重,指的是删除数组中重复的元素,使得最终数组中的元素各不相同。
下面是使用 ts来对包含各种类型数据的数组去重的代码示例:
function removeDuplicates(arr: (number | object | boolean | string | null | undefined)[]): (number | object | boolean | string | null | undefined)[] {
const uniqueArr: (number | object | boolean | string | null | undefined)[] = [];
const seen: Set<string> = new Set();
for (const item of arr) {
const itemType: string = typeof item;
if (itemType === 'number' && isNaN(item as number)) {
if (!seen.has('NaN')) {
seen.add('NaN');
uniqueArr.push(item);
}
} else if (!seen.has(itemType + JSON.stringify(item))) {
seen.add(itemType + JSON.stringify(item));
uniqueArr.push(item);
}
}
return uniqueArr;
}
这个函数接受一个数组作为输入,然后对其进行去重,并返回一个新的数组。
它会遍历输入数组中的每一个元素,根据元素的类型以及其值来判断是否需要将它加入到输出数组中。
9. 树形结构转列表
树形结构和列表互相转换是前端开发中常见的一个操作。
将树形结构转换为列表后,可以方便地对数据进行处理,比如排序、过滤、搜索等操作。
下面是使用 ts来实现树形结构转列表的代码示例:
interface TreeNode {
id: number;
name: string;
children?: TreeNode[];
}
interface ListItem {
id: number;
name: string;
depth: number;
}
function flattenTreeToList(tree: TreeNode[], depth = 0): ListItem[] {
let result: ListItem[] = [];
for (const node of tree) {
result.push({ id: node.id, name: node.name, depth });
if (node.children) {
result = result.concat(flattenTreeToList(node.children, depth + 1));
}
}
return result;
}
这个代码实现了一个 flattenTreeToList 函数,它接受一个树形结构的数组和一个可选的深度参数。如果不提供深度参数,则默认为 0。该函数将树形结构转换为一个扁平的列表,其中每个节点都是一个 ListItem 对象,包含节点的 ID、名称和所在的深度。
函数通过递归遍历树形结构来生成列表。对于每个节点,它将一个新的 ListItem 对象推入结果数组中,并检查该节点是否有子节点。如果有,它将递归调用自身来处理子节点,并将结果合并到结果数组中。
例如,假设有以下树形结构:
const tree: TreeNode[] = [
{
id: 1,
name: "Node 1",
children: [
{ id: 2, name: "Node 1.1" },
{ id: 3, name: "Node 1.2" },
],
},
{
id: 4,
name: "Node 2",
children: [
{ id: 5, name: "Node 2.1" },
{ id: 6, name: "Node 2.2" },
{
id: 7,
name: "Node 2.3",
children: [{ id: 8, name: "Node 2.3.1" }],
},
],
},
];
可以使用以下代码将其转换为列表:
const list = flattenTreeToList(tree);
console.log(list);
输出应该类似于以下内容:
[
{ id: 1, name: 'Node 1', depth: 0 },
{ id: 2, name: 'Node 1.1', depth: 1 },
{ id: 3, name: 'Node 1.2', depth: 1 },
{ id: 4, name: 'Node 2', depth: 0 },
{ id: 5, name: 'Node 2.1', depth: 1 },
{ id: 6, name: 'Node 2.2', depth: 1 },
{ id: 7, name: 'Node 2.3', depth: 1 },
{ id: 8, name: 'Node 2.3.1', depth: 2 }
]
10. 列表转树形结构
在前端页面中,对于需要展示树形结构的组件,例如树形菜单、树形表格、树形选择器等组件,就需要将列表数据转换为树形结构以便于展示。
下面是使用 ts来实现列表转树形结构的代码示例:
interface TreeNode {
id: number;
name: string;
parentId?: number;
children?: TreeNode[];
}
function listToTree(list: TreeNode[]): TreeNode[] {
const map: Record<number, TreeNode> = {};
const roots: TreeNode[] = [];
// 将节点映射到 ID
for (const node of list) {
node.children = [];
map[node.id] = node;
// 如果该节点没有父节点,则将其添加到根节点列表中
if (!node.parentId) {
roots.push(node);
}
}
// 遍历列表中的每个节点,并将其添加到其父节点的 children 数组中
for (const node of list) {
if (node.parentId) {
const parent = map[node.parentId];
if (parent) {
parent.children.push(node);
}
}
}
return roots;
}
这个代码实现了一个 listToTree 函数,它接受一个节点数组,并返回一个包含树形结构的节点数组。每个节点都是一个 TreeNode 对象,包含节点的 ID、名称、父节点 ID 和子节点数组。
函数通过两个循环来构建树形结构。
首先,它遍历节点列表,并将每个节点映射到其 ID。如果该节点没有父节点,则将其添加到根节点列表中。
接下来,它再次遍历节点列表,并将每个节点添加到其父节点的 children 数组中。
例如,假设有以下节点列表:
const list: TreeNode[] = [
{ id: 1, name: "Node 1" },
{ id: 2, name: "Node 1.1", parentId: 1 },
{ id: 3, name: "Node 1.2", parentId: 1 },
{ id: 4, name: "Node 2" },
{ id: 5, name: "Node 2.1", parentId: 4 },
{ id: 6, name: "Node 2.2", parentId: 4 },
{ id: 7, name: "Node 2.3", parentId: 4 },
{ id: 8, name: "Node 2.3.1", parentId: 7 },
];
可以使用以下代码将其转换为树形结构:
const tree = listToTree(list);
console.log(tree);
输出应该类似于以下内容:
[
{
id: 1,
name: 'Node 1',
children: [
{ id: 2, name: 'Node 1.1', parentId: 1, children: [] },
{ id: 3, name: 'Node 1.2', parentId: 1, children: [] }
]
},
{
id: 4,
name: 'Node 2',
children: [
{ id: 5, name: 'Node 2.1', parentId: 4, children: [] },
{ id: 6, name: 'Node 2.2', parentId: 4, children: [] },
{
id: 7,
name: 'Node 2.3',
parentId: 4,
children: [{ id: 8, name: 'Node 2.3.1', parentId: 7, children: [] }]
}
]
}
]