← Frontend / Interview

常见手写题

常见的前端手写面试题

1. 实现防抖函数(debounce)

作用:连续多次调用同一个函数,只执行最后一次

使用场景:搜索框,在用户输入搜索关键词时,如果每次按键都触发搜索请求,会导致大量不必要的请求。使用防抖函数可以在用户停止输入后的一段时间内才发送请求,

// 基础版本
function debounce(func, wait) {
    let timeoutId; // 存定时器
    return function(...args) {
        // 清除储存的定时器
        if (timeoutId) clearTimeout(timeoutId)
        timeoutId = setTimeout(() => {
            func.apply(this, args);
        }, wait);
    };
}

// 立即执行版本
function debounce(func, wait, immediate) {
  let timeoutId = null
  return function(...args) {
    if (immediate && !timeoutId) {
      // immediate = true 并且当前没有定时器,表示本次是第一次调用立即执行
      func.apply(this, args);
    }
    if (timeoutId) clearTimeout(timeoutId)
    timeoutId = setTimeout(() => {
      func.apply(this, args);
      timeoutId = null // 执行完最后一次之后清除id
    }, wait)
  }
}

2. 实现节流函数(throttle)

作用:减少函数的触发频率,连续触发某个函数每隔一段时间执行一次,

使用场景:长列表绑定滚动事件时可以使用节流减少触发的频率


  // 时间戳版,会立即执行,停止后不会执行最后一次
  function throttle(fn, wait) {
    // 上一次执行的时间
    let startTime = 0
    return function(...args) {
      // 执行函数的时候判断现在的时间距离上一次调用的时间是否大于等待时间
      const now = Date.now()
      if (now - startTime >= wait) {
        fn.apply(this, args)
        startTime = now
      }
    }
  }


// 定时器和时间戳结合版,可以定义是否需要立即执行
  function throttle(fn, wait, immediate) {
    let timer = null // 定时器
    let startTime = 0 // 上一次最后的时间
    return function(...rest) {
      // 执行函数的时候判断现在的时间距离上一次调用的时间是否大于等待时间
      if (!timer) {
        if (immediate) { // 立即执行
          let now = Date.now()
          if (now - startTime >= wait) {
            fn.apply(this, rest)
          }
        }
        timer = setTimeout(() => {
          fn.apply(this, rest)
          timer = null
          startTime = Date.now()
        }, wait)
      }
    }
  }

  // 节流:定时器版,不会立即执行
  function throttle(fn, wait) {
    let timer = null
    return function(...rest) {
      if (!timer) {
        timer = setTimeout(() => {
          fn.apply(this, rest)
          timer = null
        }, wait)
      }
    }
  }

3. 实现Promise.all

function promiseAll(promises) {
  return new Promise((resolve, reject) => {
    // 存成功状态的次数
    let resolvedCount = 0
    // 存结果
    const results = []
    // 遍历传入的 promise
    promises.forEach((promise, index) => {
      // 确保传入的参数是 promise 类型
      Promise.resolve(promise)
        .then(value => {
          // 根据下标存结果,确保结果的顺序和参数的顺序一致
          results[index] = value
          // 成功一次次数加 1
          resolvedCount++
          // 判断是否全部执行完成,返回成功状态
          if (resolvedCount === promises.length) {
            resolve(results)
          }
        })
        // 只要有一项失败就直接返回 reject
        .catch(reject)
    })
  })
}

// 使用示例
const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'foo');
});

promiseAll([promise1, promise2, promise3]).then(values => {
  console.log(values); // [3, 42, 'foo']
});

4. 深拷贝(deepclone)

// 基础版本
function deppClone(target) {
  // 如果不是对象直接返回
  if (typeof target !== 'object' || target === null) {
    return target
  }
  // 如果传入的是对象创建新对象,如果是数组创建新数组
  let res = Array.isArray(target) ? [] : {}
  for (let key in target) {
    // 遍历传入的对象,递归拷贝每一项
    res[key] = deppClone(target[key])
  }
  return res
}

// 处理循环引用和特殊对象版本,map: 存已经拷贝过的对象
function cloneDeep(target, map = new WeakMap()) {
  if (typeof target !== 'object' || target === null) {
    return target
  }
  // 判断此对象是否已经拷贝过,如果存在 map 中就直接返回
  if (map.has(target)) {
    return map.get(target)
  }
  // 判断对象是不是特殊类型
  switch(Object.prototype.toString.call(target)) {
    case '[object Date]':
      return new Date(target)
    case '[object RegExp]':
      return new RegExp(target)
    case '[object Set]':
      return new Set(target)
    case '[object Map]':
      return new Map(target)
  }
  const res = Array.isArray(target) ? [] : {}
  // 把当前要拷贝的对象和新对象存到 map 中
  map.set(target, res)
  for (let key in target) {
    // 把 map 递归传入每一项
    res[key] = cloneDeep(target[key], map)
  }
  return res
}

注意:new WeakMap() 只接受对象作为 key,并且这些 key 是弱引用,如果没有其他引用指向这个对象,作为 key 的对象可以被垃圾回收机制回收

5. 数组扁平化的实现(flat)

let arr = [1,2,[3,4,[5,[6]]]]
// 递归版
// 数组扁平化基础版
function flat(array) {
  return array.reduce((prev, val) => {
    return prev.concat(Array.isArray(val) ? flat(val) : [val])
  }, [])
}

// 数组扁平化,传入扁平层级
function flat(array, num) {
  if (num === 0) return array
  return array.reduce((prev, val) => {
    return prev.concat(Array.isArray(val) ? flat(val, num - 1) : [val])
  }, [])
}

// while循环版
function flat(array) {
  while(array.some(v => Array.isArray(v))) {
    array = [].concat(...array)
  }
  return array
}
// 传入扁平层级
function flat(array, deep) {
  while(deep > 0 && array.some(v => Array.isArray(v))) {
    array = [].concat(...array)
    deep--
  }
  return array
}

6. 数组转树形

const items = [
  { id: 1, title: 'home', parentId: null },
  { id: 2, title: 'about', parentId: null },
  { id: 3, title: 'team', parentId: 2 },
  { id: 4, title: 'contact', parentId: null },
  { id: 5, title: 'team1', parentId: 3 },
  { id: 6, title: 'team2', parentId: 3 }
];

function arrToTree(arr) {
  const tree = []
  arr.forEach(item => {
    // 遍历数组根据 parentId 查找这一项的父级节点
    const parent = arr.find(v => v.id === item.parentId)
    if (parent) {
      // 如果父级节点存在就判断是否有children,有的话直接push
      if (parent.children) {
        parent.children.push(item)
      } else {
        // 该父级节点没有 children 就创建 children 数组
        parent.children = [item]
      }      
    } else {
      // 如果找不到父节点就表示是tree的第一层节点
      tree.push(item)
    }
  })
  return tree
}

console.log(arrToTree(items))

输出结果:
const tree = [
  {
    id: 1,
    title: 'home',
    children: []
  },
  {
    id: 2,
    title: 'about',
    children: [
      {
        id: 3,
        title: 'team',
        children: [
          { id: 5, title: 'team1', children: [] },
          { id: 6, title: 'team2', children: [] }
        ]
      }
    ]
  },
  {
    id: 4,
    title: 'contact',
    children: []
  }
];

7. 合并两个有序数组

function merge(arr1, arr2) {
    let merged = []; // 用于存储合并后的有序数组
    let i = 0, j = 0; // i 和 j 分别是 arr1 和 arr2 的索引指针
    
    // 同时遍历两个数组,直到其中一个数组被遍历完
    while (i < arr1.length && j < arr2.length) {
        // 比较当前元素,将较小的元素添加到 merged 数组中
        if (arr1[i] < arr2[j]) {
            merged.push(arr1[i]); // 将 arr1 的当前元素添加到 merged
            i++; // 移动 arr1 的指针
        } else {
            merged.push(arr2[j]); // 将 arr2 的当前元素添加到 merged
            j++; // 移动 arr2 的指针
        }
    }
  
    // 如果 arr1 有剩余元素,全部添加到 merged 中
    if (i < arr1.length) {
        merged.push(...arr1.slice(i));
    }
  
    // 如果 arr2 有剩余元素,全部添加到 merged 中
    if (j < arr2.length) {
        merged.push(...arr2.slice(j));
    }
    return merged; // 返回合并后的有序数组
}

// 示例
const arr1 = [1, 3, 5];
const arr2 = [2, 4, 6];
console.log(merge(arr1, arr2)); // [1, 2, 3, 4, 5, 6]

8. 格式化 query 参数

function query(url) {
  const res = {}
  const search = url.split('?')[1].split('&')
  search.forEach(it => {
    const [key, val] = it.split('=')
    if (res[key]) {
      if (Array.isArray(res[key])) {
        res[key].push(val)
      } else {
        res[key] = [res[key], val]
      }
    } else {
      res[key] = val
    }
  })

  return res
}
console.log(query('https://baidu.com?a=1&a=2&b=3'))  // { a: [1, 2], b: 3 }

9. 版本号排序

function versionSort(arr) {
    // 使用数组的 sort 方法对版本号进行排序
    return arr.sort((a, b) => {
        // 将版本号分割成数组
        const aParts = a.split('.');
        const bParts = b.split('.');
        // 获取两个版本号的最大长度
        const maxLength = Math.max(aParts.length, bParts.length)
        // 遍历版本号的每一部分,比较大小
        for (let i = 0; i < maxLength; i++) {
            // 将版本号的每一部分转换为整数,如果不是数字则默认为 0
            const aPart = parseInt(aParts[i]) || 0;
            const bPart = parseInt(bParts[i]) || 0;
            // 如果 aPart 小于 bPart,则 a 应该排在 b 前面
            if (aPart < bPart) return -1;
            // 如果 aPart 大于 bPart,则 a 应该排在 b 后面
            if (aPart > bPart) return 1;
        }
        // 如果所有部分都相等,则返回 0
        return 0;
    });
}

// 测试用例
console.log(versionSort(["1.5", "1.45", "5.1", "1.2.3", "2.6"])); 
// ["1.2.3", "1.5", "1.45", "2.6", "5.1"]

10. 发布订阅模式

class Event {
  list = {}
  on(name, callback) {
    if (Array.isArray(this.list[name])) {
      this.list[name].push(callback)
    } else {
      this.list[name] = [callback]
    }
  }
  emit(name, ...args) {
    this.list[name]?.forEach(callback => {
      callback(...args)
    })
  }
  off(name, callback) {
    if (Array.isArray(this.list[name])) {
      const index = this.list[name].indexOf(callback)
      if (index > -1) {
        this.list[name].splice(index, 1)
      }
    }
  }
  once(name, callback) {
    const handle = (...args) => {
      callback(...args)
      this.off(name, handle)
    }
    this.on(name, handle)
  }
}

11. 查找字符串中出现最多的字符

let s = 'bcabcaaabcdaaesssssssssssg'
// 方式一:两次循环,先计算每个字符的次数,在排序找出次数最多的字符
function getMax (str) {
  let obj = {} // 计算每个字符出现的次数
  for (let i = 0; i < str.length; i ++) {
    let key = str[i]
    if (obj[key]) {
      obj[key] ++
    } else {
      obj[key] = 1
    }
  }
  // 根据出现的次数排序
  let maxKey = Object.keys(obj).sort((a, b) => obj[b] - obj[a])[0]
  
  return `出现次数最多的字符是${maxKey}, 出现了${obj[maxKey]}次`
}

// 方式二:一次循环,定义第三方变量存和每个字符出现的次数依次比较
function getMax (str) {
  let obj = {} // 计算每个字符出现的次数
  let max = { // 存当前出现次数最多的字符和次数
    chars: '',
    count: 0
  }
  for (let i = 0; i < str.length; i ++) {
    let key = str[i]
    if (obj[key]) {
      obj[key] ++
    } else {
      obj[key] = 1
    }
    // 当前字符出现的次数和max比较,次数比max多就替换
    if (obj[key] > max.count) {
      max = {
        chars: key,
        count: obj[key]
      }
    }
  }

  return max
}
console.log(getMax(s))

12. 函数柯里化

function curry(func) {
  return function curried(...args) {
    // 判断传入的参数个数是否超过原本函数的参数数量,超过的话立即执行函数
    if (args.length >= func.length) {
      return func.apply(this, args);
    } else {
      // 参数数量不够就返回新函数继续收集参数
      return function(...args2) {
        return curried.apply(this, args.concat(args2));
      };
    }
  };
}

// 使用例子
function add(a, b, c) {
  return a + b + c;
}

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

13. add 函数柯里化

function add(...rest) {
  function fn(...r) {
    // 把每次调用的参数都存到rest中
    rest.push(...r)
    return fn
  }
  // 重写toString方法,当转换为字符串的时候调用会调用此函数
  fn.toString = function () {
    return rest.reduce((prev, val) => prev + val)
  }
  // 返回fn收集参数
  return fn
}
// 执行时通过 + 运算符执行 toString 方法
console.log(+add(1)(2)(3)(4)(5)) // 15

14. 斐波那契数列

// 1 1 2 3 5 8 13 21 34
// 方式1
function fibonacciIterative(n) {
    if (n <= 1) {
        return n;
    }
    let a = 0;
    let b = 1;
    for (let i = 2; i <= n; i++) {
        const temp = a + b;
        a = b;
        b = temp;
    }
    return b;
}

console.log(fibonacciIterative(10)); // 55

// 方式2: 递归
function fibonacci(n) {
  if (n < 2) {
    return n;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}

console.log(fibonacci(5))

// 方式3:
function fibonacci(n) {
  let fib = [0, 1];
  for (let i = 2; i <= n; i++) {
    fib[i] = fib[i - 1] + fib[i - 2];
  }
  return fib[n];
}
console.log(fibonacci(10)); // 输出: 55

15. 转化为驼峰命名

var s1 = "get-element-by-id"
// 转化为 getElementById

var f = function(s) {
    return s.replace(/-\w/g, function(x) {
        return x.slice(1).toUpperCase();
    })
}

16. 数组去重的方法

// 方式1: Set对象
function uniqueArray(arr) {
    // 将数组转换为 Set,自动去除重复项
    // 再将 Set 转换回数组
    return Array.from(new Set(arr));
}
// filter 去重
function uniqueArray(arr) {
    // 使用 filter 方法过滤数组
    // 只保留第一次出现的元素
    return arr.filter((item, index) => arr.indexOf(item) === index);
}
// reduce 去重
function uniqueArray(arr) {
    return arr.reduce((acc, item) => {
        if (!acc.includes(item)) { // 如果累积数组中不包含当前元素
            acc.push(item); // 将当前元素添加到累积数组中
        }
        return acc; // 返回累积数组
    }, []);
}
// for循环
function uniqueArray(arr) {
    const result = []; // 创建一个空数组用于存储去重后的结果
    for (let i = 0; i < arr.length; i++) {
        if (result.indexOf(arr[i]) === -1) { // 如果结果数组中不包含当前元素
            result.push(arr[i]); // 将当前元素添加到结果数组中
        }
    }
    return result;
}

// 示例
const arr = [1, 2, 2, 3, 4, 4, 5];
console.log(uniqueArray(arr)); // 输出:[1, 2, 3, 4, 5]

17. 千分符

// 循环方式处理
function formatNumberWithCommas(number) {
    // 将数字转换为字符串,方便后续处理
    let numStr = number.toString();
    // 按小数点分割数字,得到整数部分和小数部分
    const parts = numStr.split('.');
    // 获取整数部分的字符串
    let integerDigits = parts[0];
    // 获取小数部分的字符串,如果不存在小数部分,则默认为空字符串
    const decimalDigits = parts.length > 1 ? '.' + parts[1] : '';
    // 用于存储最终格式化后的结果
    let result = '';
    // 用于计数,记录当前处理到的数字位数
    let count = 0;
    // 从右向左遍历整数部分的每一位数字
    for (let i = integerDigits.length - 1; i >= 0; i--) {
        // 将当前数字添加到结果字符串的前面
        result = integerDigits[i] + result;
        // 计数加1
        count++;
        // 每3位添加一个逗号,但不在最前面加
        // 通过 count % 3 === 0 判断是否达到3位,通过 i !== 0 确保不是第一位
        if (count % 3 === 0 && i !== 0) {
            result = ',' + result;
        }
    }
    // 将格式化后的整数部分和小数部分拼接起来,返回最终结果
    return result + decimalDigits;
}

// 正则方式处理
function formatNumberWithCommas(number) {
    // 将数字转为字符串
    let numStr = number.toString();
    
    // 分割整数部分和小数部分
    const parts = numStr.split('.');
    let integerPart = parts[0];
    const decimalPart = parts.length > 1 ? '.' + parts[1] : '';
    
    // 对整数部分添加千分位分隔符
    integerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    
    return integerPart + decimalPart;
}

// toLocaleString 方法处理
function formatNumberWithCommas(number) {
    // 将数字转换为字符串,并按小数点分割为整数部分和小数部分
    const parts = number.toString().split('.');
    
    // 使用 parseFloat 将整数部分转换为数字,然后调用 toLocaleString 方法
    // toLocaleString 方法会根据本地环境的数字格式规范,自动在整数部分添加千位分隔符(通常是逗号)
    // 例如:123456789 会变成 123,456,789
    const integerPart = parseFloat(parts[0]).toLocaleString();
    
    // 检查是否存在小数部分
    // 如果存在小数部分,则将其拼接回字符串,否则小数部分为空字符串
    const decimalPart = parts.length > 1 ? '.' + parts[1] : '';
    
    // 将格式化后的整数部分和小数部分拼接起来,返回最终结果
    return integerPart + decimalPart;
}

// 测试
console.log(formatNumberWithCommas(12345678.123)); // 输出: "12,345,678.123"
console.log(formatNumberWithCommas(123456789));    // 输出: "123,456,789"
console.log(formatNumberWithCommas(1234.56));      // 输出: "1,234.56"

18. 封装倒计时hook

import { useState, useRef, useEffect } from 'react';

/**
 * 倒计时自定义 Hook
 * @param {number} initialSeconds - 初始倒计时秒数(必须为非负整数)
 * @returns {Object} { count: 剩余秒数, start: 启动倒计时, stop: 停止倒计时, reset: 重置倒计时 }
 */
const useCountdown = (initialSeconds = 60) => {

  // 当前倒计时数值
  const [count, setCount] = useState(initialSeconds);
  // 保存定时器 ID,用于清除定时器
  const timerRef = useRef(null);

  // 启动倒计时
  const start = () => {
    // 若已在倒计时或当前数值为 0,直接返回
    if (timerRef.current || count === 0) return;
    
    // 清除可能存在的旧定时器
    stop();
    
    // 启动新定时器,每秒更新倒计时
    timerRef.current = setInterval(() => {
      setCount(prev => {
        const newCount = prev - 1;
        // 倒计时到 0 时自动停止
        if (newCount === 0) {
          stop();
        }
        return newCount;
      });
    }, 1000);
  };

  // 停止倒计时
  const stop = () => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
  };

  // 重置倒计时(恢复初始值并停止)
  const reset = () => {
    stop();
    setCount(initialSeconds);
  };

  // 组件卸载时清除定时器,防止内存泄漏
  useEffect(() => {
    return () => {
      stop();
    };
  }, []);

  return { count, start, stop, reset };
};

// 示例使用组件
const CountdownDemo = () => {
  const { count, start, stop, reset } = useCountdown(60);

  return (
    <div style={{ padding: '20px' }}>
      <h2>倒计时:{count} 秒</h2>
      <button onClick={start} style={{ marginRight: '10px' }}>启动</button>
      <button onClick={stop} style={{ marginRight: '10px' }}>停止</button>
      <button onClick={reset}>重置</button>
    </div>
  );
};

export default CountdownDemo;

19. react 倒计时组件

import React, { useState, useEffect } from 'react';

// Timer 组件
const Timer = ({ from }) => {
  // 状态变量,用于存储当前剩余时间
  const [timeLeft, setTimeLeft] = useState(from);

  // 使用 useEffect 来处理倒计时逻辑
  useEffect(() => {
    // 如果时间已经为 0,停止倒计时
    if (timeLeft === 0) return;

    // 设置一个定时器,每秒更新一次时间
    const timer = setTimeout(() => {
      setTimeLeft((prevTime) => prevTime - 1);
    }, 1000);

    // 清理定时器,防止内存泄漏
    return () => clearTimeout(timer);
  }, [timeLeft]);

  // 渲染倒计时时间
  return (
    <div>
      <h1>倒计时:{timeLeft} 秒</h1>
    </div>
  );
};

export default Timer;

// 调用方式
<Timer from={60} />

20. 封装请求hook

import { useState, useCallback } from 'react';
import axios from 'axios';

// 自定义 Hook:useRequest
const useRequest = (url, options = {}) => {
    // 状态变量
    const [loading, setLoading] = useState(false); // 加载状态
    const [data, setData] = useState(null);        // 请求返回的数据
    const [error, setError] = useState(null);      // 请求错误

    // 请求函数
    const request = useCallback(async () => {
        // 设置加载状态为 true
        setLoading(true);
        try {
            // 发送请求
            const response = await axios(url, options);
            // 设置数据
            setData(response.data);
        } catch (err) {
            // 设置错误
            setError(err || '请求失败');
        } finally {
            // 设置加载状态为 false
            setLoading(false);
        }
    }, [url, options]);

    return { loading, data, error, request };
};

export default useRequest;

21. 数字转中文

function transfer(num, extra = '') {
  const hznums = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
  const unit = ['千', '百', '十', '']

  const numArr = num.toString().split('')

  if (numArr.length === 0) return ''
  if (numArr.every(it => it == 0)) return ''

  const transfered = numArr.reduce((prev, next, index) => {
      let ret = prev
      if (Number(next) === 0) {
          if (prev.endsWith('零')) return ret
          ret += hznums[Number(next)]
      } else {
          ret += hznums[Number(next)] + unit.slice(-numArr.length)[index]
      }
      return ret
  }, '')

  return (transfered.endsWith('零') ? transfered.slice(0, -1) : transfered) + extra
}

function numToChzn(num) {
  return transfer(num.slice(-16, -12), '兆') +
  transfer(num.slice(-12, -8), '亿') +
  transfer(num.slice(-8, -4), '万') +
  transfer(num.slice(-4))
}

// 测试
// numToChzn(8912345) => '八百九十一万二千三百四十五'