很全面的前端面试题——手写题(上)

引言

在前端面试中,手写代码题是考察候选人基础能力和编程思维的重要环节。这类题目通常要求面试者在不依赖外部库的情况下,手动实现一些常见的功能或算法。对于前端开发者来说,掌握这些手写题不仅能帮助你在面试中脱颖而出,更能加深你对JavaScript核心概念的理解。

什么是手写题?

手写题是指面试官给出一个功能描述,要求面试者不使用现成的API或库,而是通过自己编写代码来实现这个功能。这类题目主要考察候选人的:

  1. JavaScript基础知识掌握程度
  2. 代码实现能力和逻辑思维
  3. 对底层原理的理解
  4. 编码规范和最佳实践

常见手写题及实现

1. 数组相关手写题

1.1 数组去重
// 方法一:使用Set
function uniqueArray1(arr) {
  return [...new Set(arr)];
}

// 方法二:使用对象/Map记录
function uniqueArray2(arr) {
  const result = [];
  const obj = {};
  
  for (let i = 0; i < arr.length; i++) {
    if (!obj[arr[i]]) {
      result.push(arr[i]);
      obj[arr[i]] = true;
    }
  }
  
  return result;
}

// 方法三:使用filter和indexOf
function uniqueArray3(arr) {
  return arr.filter((item, index) => {
    return arr.indexOf(item) === index;
  });
}

解析

  • 方法一利用了Set数据结构自动去重的特性
  • 方法二通过对象记录已出现过的元素
  • 方法三利用indexOf查找元素第一次出现的位置
1.2 数组扁平化
// 方法一:使用递归
function flatten1(arr) {
  let result = [];
  
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
      result = result.concat(flatten1(arr[i]));
    } else {
      result.push(arr[i]);
    }
  }
  
  return result;
}

// 方法二:使用reduce
function flatten2(arr) {
  return arr.reduce((prev, curr) => {
    return prev.concat(Array.isArray(curr) ? flatten2(curr) : curr);
  }, []);
}

// 方法三:使用ES6的flat方法(虽然不是手写,但作为了解)
function flatten3(arr, depth = 1) {
  return arr.flat(depth);
}

解析

  • 方法一使用递归遍历数组,遇到子数组继续递归
  • 方法二使用reduce函数简化递归逻辑
  • 方法三是ES6内置方法,了解即可
1.3 实现数组的reduce方法
Array.prototype.myReduce = function(callback, initialValue) {
  if (typeof callback !== 'function') {
    throw new TypeError(callback + ' is not a function');
  }
  
  const arr = this;
  let accumulator = initialValue;
  let startIndex = 0;
  
  if (initialValue === undefined) {
    accumulator = arr[0];
    startIndex = 1;
  }
  
  for (let i = startIndex; i < arr.length; i++) {
    accumulator = callback(accumulator, arr[i], i, arr);
  }
  
  return accumulator;
};

// 使用示例
const sum = [1, 2, 3, 4].myReduce((acc, curr) => acc + curr, 0);
console.log(sum); // 10

解析

  • 实现了reduce的核心功能:遍历数组,将上一次回调函数的返回值作为下一次的初始值
  • 处理了initialValue可选参数的情况
  • 保持了与原生reduce相同的参数顺序和功能

2. 函数相关手写题

2.1 实现call方法
Function.prototype.myCall = function(context, ...args) {
  // 如果context为null或undefined,则指向全局对象
  context = context || window;
  
  // 将当前函数作为context的一个属性
  const fn = Symbol('fn'); // 使用Symbol避免属性名冲突
  context[fn] = this;
  
  // 调用函数并获取结果
  const result = context[fn](...args);
  
  // 删除添加的属性
  delete context[fn];
  
  return result;
};

// 使用示例
const obj = { value: 1 };
function fn(name, age) {
  console.log(this.value);
  console.log(name, age);
}

fn.myCall(obj, '张三', 18); // 输出: 1, 张三, 18

解析

  • 将当前函数作为context的一个属性调用
  • 使用Symbol确保属性名不会与已有属性冲突
  • 处理了context为null或undefined的情况
  • 调用完成后删除添加的属性
2.2 实现apply方法
Function.prototype.myApply = function(context, args) {
  context = context || window;
  
  const fn = Symbol('fn');
  context[fn] = this;
  
  // 处理args为null或undefined的情况
  let result;
  if (args) {
    result = context[fn](...args);
  } else {
    result = context[fn]();
  }
  
  delete context[fn];
  return result;
};

// 使用示例
const obj = { value: 1 };
function fn(name, age) {
  console.log(this.value);
  console.log(name, age);
}

fn.myApply(obj, ['李四', 20]); // 输出: 1, 李四, 20

解析

  • 与call类似,但参数以数组形式传入
  • 处理了args为null或undefined的情况
  • 其他逻辑与call基本相同
2.3 实现bind方法
Function.prototype.myBind = function(context, ...args1) {
  const fn = this;
  
  // 返回一个新函数
  return function(...args2) {
    // 判断是否作为构造函数使用
    if (this instanceof fn) {
      return new fn(...args1, ...args2);
    } else {
      return fn.apply(context, [...args1, ...args2]);
    }
  };
};

// 使用示例
const obj = { value: 1 };
function fn(name, age) {
  console.log(this.value);
  console.log(name, age);
}

const boundFn = fn.myBind(obj, '王五');
boundFn(25); // 输出: 1, 王五, 25

// 作为构造函数使用
const newObj = new boundFn(30);
console.log(newObj.value); // 输出: undefined

解析

  • 返回一个新函数,该函数可以传入参数
  • 处理了作为构造函数使用的情况
  • 合并了bind时传入的参数和调用时传入的参数

3. 异步编程相关手写题

3.1 实现Promise
class MyPromise {
  constructor(executor) {
    this.state = 'pending'; // pending, fulfilled, rejected
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(callback => callback(value));
      }
    };
    
    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(callback => callback(reason));
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
  
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason; };
    
    const promise2 = new MyPromise((resolve, reject) => {
      if (this.state === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        }, 0);
      }
      
      if (this.state === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        }, 0);
      }
      
      if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value);
              resolvePromise(promise2, x, resolve, reject);
            } catch (error) {
              reject(error);
            }
          }, 0);
        });
        
        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason);
              resolvePromise(promise2, x, resolve, reject);
            } catch (error) {
              reject(error);
            }
          }, 0);
        });
      }
    });
    
    return promise2;
  }
  
  catch(onRejected) {
    return this.then(null, onRejected);
  }
}

// 处理Promise解析
function resolvePromise(promise2, x, resolve, reject) {
  if (promise2 === x) {
    return reject(new TypeError('Chaining cycle detected for promise'));
  }
  
  let called = false;
  
  if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
    try {
      const then = x.then;
      if (typeof then === 'function') {
        then.call(
          x,
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(promise2, y, resolve, reject);
          },
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        resolve(x);
      }
    } catch (error) {
      if (called) return;
      called = true;
      reject(error);
    }
  } else {
    resolve(x);
  }
}

// 使用示例
const promise = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve('成功');
  }, 1000);
});

promise
  .then(value => {
    console.log(value);
    return '第二次成功';
  })
  .then(value => {
    console.log(value);
  })
  .catch(error => {
    console.error(error);
  });

解析

  • 实现了Promise的基本状态管理
  • 实现了then方法,支持链式调用
  • 处理了Promise解析过程(resolvePromise函数)
  • 实现了catch方法
  • 处理了循环引用的情况
3.2 实现防抖函数
function debounce(func, wait, immediate) {
  let timeout;
  
  return function(...args) {
    const context = this;
    
    // 清除之前的定时器
    clearTimeout(timeout);
    
    // 如果需要立即执行
    if (immediate) {
      // 如果没有定时器,则立即执行
      const callNow = !timeout;
      timeout = setTimeout(() => {
        timeout = null;
      }, wait);
      
      if (callNow) {
        func.apply(context, args);
      }
    } else {
      // 延迟执行
      timeout = setTimeout(() => {
        func.apply(context, args);
      }, wait);
    }
  };
}

// 使用示例
const handleScroll = debounce(() => {
  console.log('滚动事件触发');
}, 300);

window.addEventListener('scroll', handleScroll);

解析

  • 使用setTimeout实现延迟执行
  • 每次触发事件都会清除之前的定时器,重新计时
  • 支持immediate参数,决定是否立即执行
  • 保留了this和参数
3.3 实现节流函数
function throttle(func, limit) {
  let inThrottle;
  let lastFunc;
  let lastRan;
  
  return function(...args) {
    const context = this;
    
    if (!inThrottle) {
      // 第一次立即执行
      func.apply(context, args);
      lastRan = Date.now();
      inThrottle = true;
    } else {
      // 清除之前的定时器
      clearTimeout(lastFunc);
      
      // 设置新的定时器
      lastFunc = setTimeout(() => {
        if ((Date.now() - lastRan) >= limit) {
          func.apply(context, args);
          lastRan = Date.now();
        }
      }, limit - (Date.now() - lastRan));
    }
  };
}

// 使用示例
const handleResize = throttle(() => {
  console.log('窗口大小改变');
}, 300);

window.addEventListener('resize', handleResize);

解析

  • 使用setTimeout和Date.now()控制执行频率
  • 每次触发事件都会重置定时器
  • 确保函数在指定的时间间隔内至少执行一次
  • 保留了this和参数

4. DOM操作相关手写题

4.1 实现深拷贝
// 方法一:使用JSON.parse和JSON.stringify(有局限性)
function deepClone1(obj) {
  return JSON.parse(JSON.stringify(obj));
}

// 方法二:递归实现
function deepClone2(obj, hash = new WeakMap()) {
  // 处理null或非对象
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  // 处理日期对象
  if (obj instanceof Date) {
    return new Date(obj);
  }
  
  // 处理正则表达式
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
  
  // 处理循环引用
  if (hash.has(obj)) {
    return hash.get(obj);
  }
  
  // 创建新对象或数组
  const result = Array.isArray(obj) ? [] : {};
  hash.set(obj, result);
  
  // 递归拷贝所有属性
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      result[key] = deepClone2(obj[key], hash);
    }
  }
  
  return result;
}

// 使用示例
const obj = {
  a: 1,
  b: {
    c: 2,
    d: [3, 4, { e: 5 }]
  },
  f: new Date(),
  g: /abc/g
};

const clonedObj = deepClone2(obj);
console.log(clonedObj);

解析

  • 方法一简单但有局限性(无法处理函数、循环引用等)
  • 方法二使用递归和WeakMap处理各种数据类型和循环引用
  • 处理了特殊对象类型(Date、RegExp)
  • 使用WeakMap避免内存泄漏
4.2 实现一个简单的DOM选择器
function $(selector, context = document) {
  // 去除前后空格
  selector = selector.trim();
  
  // 处理ID选择器
  if (selector.startsWith('#')) {
    return context.getElementById(selector.substring(1));
  }
  
  // 处理类选择器
  if (selector.startsWith('.')) {
    return context.getElementsByClassName(selector.substring(1));
  }
  
  // 处理标签选择器
  return context.getElementsByTagName(selector);
}

// 使用示例
const element = $('#myElement');
console.log(element);

解析

  • 简单实现了ID、类和标签选择器
  • 支持指定上下文元素
  • 返回的是原生DOM元素或集合

手写题的解题技巧和注意事项

  1. 理解题目要求:在开始编码前,确保完全理解题目要求的功能和边界条件。

  2. 考虑各种情况:思考正常情况、边界情况和异常情况,确保代码的健壮性。

  3. 保持代码简洁:尽量使用简洁明了的代码实现功能,避免过度复杂化。

  4. 添加注释:在关键步骤添加注释,解释代码逻辑,展示你的思考过程。

  5. 注意性能:考虑算法的时间复杂度和空间复杂度,选择最优解。

  6. 遵循最佳实践:注意变量命名、代码格式、错误处理等。

  7. 测试代码:编写测试用例验证代码的正确性。

总结

手写题是前端面试中非常重要的一部分,它直接反映了候选人的基础能力和编程思维。通过本文介绍的手写题,包括数组操作、函数实现、异步编程和DOM操作等方面,希望能够帮助大家更好地准备前端面试。

在下一篇文章中,我们将继续探讨更多类型的手写题,包括数据结构、算法设计等方面的内容。敬请期待!


*注:本文中的代码示例仅供参考,实际面试中可能需要根据具体要求进行调整和优化。*喜欢的点个关注,每天为大家分享前端全栈、AIGC副业信息差等,更多知识分享尽在【程序员七小AIGC网站

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐