前端《手写》合集总结

128 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第7天,点击查看活动详情

一、概述

年关将至,找不到工作准备回家过年了,在这整理前端常见的手写代码的面试题。希望能帮助到需要的人。

二、手写代码题

1. 防抖函数 和 节流函数

防抖函数 函数可以使事件被触发n秒之后再进行处理,n秒内事件再次被触发则重新计时。比如页面上的用户点击事件

节流函数 节流指的是规定的一个时间,触发一次之后,如果在规定的时间内重复被触发了,只有一次是生效的。比如常见的在scroll函数的事件监听,input框的输入事件等等

防抖函数

// 防抖函数
function debounce(fn, wait) {
  // 1.需要一个定时器
  let timer = null;

  return function () {
    const args = arguments;

    // 3.中途再次触发,则清空定时器
    timer && clearTimeout(timer);

    // 2.将定时器设定成指定时间触发
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, wait);
  };
}

节流函数

// 节流函数
function throttle(fn, delay) {
  // 获取初始的时间点
  let currentTime = Date.now();

  return function () {
    // 触发的时间点
    let nowTime = Date.now();
    const args = arguments;

    if (nowTime - currentTime >= delay) {
      // 重置初始时间点
      currentTime = Date.now();
      fn.apply(this, args);
    }
  };
}

2. 手写函数call、apply、bind 方法

  • call()apply()都是来改变this指向的,调用之后立即执行调用它们的函数。 call() 方法接受的是一个参数列表,而 apply() 方法接受的是一个包含多个参数的数组
  • bind() 返回的是一个函数,参数和 call() 一样

call方法

call()  方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。

Function.prototype.myCall = function (context) {
  // 1.判断执行的对象是否为函数
  if (typeof this !== "function") {
    console.error("this is not a function");
  }

  // 2.获取参数
  const args = [...arguments].slice(1);

  // 3.定义接收函数返回的结果
  let result = null;

  // 4.判断是否传入context, 没有传入则指向全局即window
  context = context || window;

  // 5.执行对象挂载在context上
  context.fn = this;

  // 6.执行函数并接收结果
  result = context.fn(...args);

  // 7.将context复原 删除临时属性
  delete context.fn;

  // 8.返回6的结果
  return result;
};

apply 方法

apply() 方法调用一个具有给定 this 值的函数,以及以一个数组(或一个类数组对象)的形式提供的参数。

Function.prototype.myApply = function (context) {
  // 1.判断执行的对象是否为函数
  if (typeof this !== "function") {
    console.error("this is not a function");
  }

  // 2.获取参数
  const args = arguments[1];

  // 3.定义接收函数返回的结果
  let result = null;

  // 4.判断是否传入context, 没有传入则指向全局即window
  context = context || window;

  // 5.执行对象挂载在context上
  context.fn = this;

  // 6.判断是否有参数,
  if (args) {
    result = context.fn(...args);
  } else {
    result = context.fn();
  }

  // 7. 将上下文复原,删除新增临时属性
  delete context.fn;

  // 8. 返回5的结果
  return result;
};

bind 方法

bind()  方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

Function.prototype.myBind = function (context) {
  // 1.判断执行的对象是否为函数
  if (typeof this !== "function") {
    console.error("this is not a function");
  }

  // 2.获取参数
  const args = [...arguments].slice(1);

  // 3.定义this指向
  let fn = this;

  // 4.返回函数
  return function Fn() {
    // 5.根据调用方,确定最终返回值
    return fn.apply(
      this instanceof Fn ? this : context,
      args.concat(...arguments)
    );
  };
};

使用 bind() 函数

const obj = {
  name: "winter",
};

// 定义函数
function test() {
  console.log(this.name);
  console.log(arguments);
  return arguments;
}

// 打印
console.log(test.bind(obj, 333)(1, 2));
console.log(test.myBind(obj, 333)(1, 2));

3. 手写浅拷贝

如果属性是基本类型,拷贝的就是基本类型的值。如果属性是引用类型,拷贝的就是内存地址,即浅拷贝是拷贝一层,深层次的引用类型则共享内存地址

快速方法

  • Object.assign(obj1, obj2)
  • arr.slice()
  • arr.concat()
  • 扩展运算符 [...arr]

手写浅拷贝

// 浅拷贝
function shallowClone(obj) {
  if (!obj || typeof obj !== "object") return;

  let result = Array.isArray(obj) ? [] : {};

  // 循环遍历
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      result[key] = obj[key];
    }
  }

  return result;
}

4. 手写深拷贝

  • _.cloneDeep()
  • jQuery.extend()
  • JSON.stringify()
  • 手写循环递归

JSON.stringify()

JSON.stringify() 会忽略undefinedsymbol函数

举个例子:

// 定义一个对象
const obj = {
 name: "winter",
 a: null,
 b: undefined,
 c: function () {},
 d: Symbol("aa"),
 e: [1, 2, 3],
 info: {
   age: 21,
 },
};

// 通过 JSON.stringify() 转换
console.log(JSON.parse(JSON.stringify(obj)));

// 打印的结果 只有 name a e info 这四个属性
// {
//   name: 22,
//   a: null,
//   e: [1, 2, 3],
//   info: {
//     age: 22,
//   },
// };

手写循环递归

// 深拷贝
function deepClone(obj) {
  // 如果为 undefined 或 null 或 不是 object 类型则直接返回
  if (obj == null || typeof obj !== "object") return obj;

  // 定义返回格式
  let result = Array.isArray(obj) ? [] : {};

  // 循环遍历
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      result[key] = deepClone(obj[key]);
    }
  }

  // 返回结果
  return result;
}

5. 函数柯里化

函数柯里化指的是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。

例如:可以将 f(a, b, c) 转换成 f(a)(b)(c), 也可以转换成 f(a, b)(c)

function curry(fn, ...args) {  
  return fn.length <= args.length ? fn(...args) : curry.bind(null, fn, ...args);  
}

6. Promise相关

手写Promise(简易版)

Promise 想必不用过多介绍了,Promise入参是一个函数,可以用thencatch等链式调用,首先明确几点:

  1. Promise 有三个状态:pending fulfilled rejected
  2. new promise时, 需要传递一个executor()执行器,执行器立即执行;
  3. executor接受两个参数,分别是resolvereject
  4. promise 的默认状态是 pending
  5. promise 只能从pendingrejected, 或者从pendingfulfilled,状态一旦确认,就不会再改变;

按照上面的特征,我们尝试照葫芦画瓢手写一下:

class MyPromise {
  constructor(executor) {
    // 默认状态是pending
    this.state = "pending";
    // 存放成功和失败的值
    this.value = undefined;
    // 调用执行器  将resolve reject 传给使用者
    executor(this.resolve.bind(this), this.reject.bind(this));
  }
  // 调用此方法就是成功
  resolve(data) {
    // 状态不可变
    if (this.state !== "pending") return;
    // 成功的状态
    this.state = "fulfilled";
    this.value = data;
  }
  // 调用此方法就是失败
  reject(reason) {
    // 状态不可变
    if (this.state !== "pending") return;
    // 失败的状态
    this.state = "rejected";
    this.value = reason;
  }

  // 包含一个 then 方法,并接收两个参数 onFulfilled、onRejected
  then(onFulfilled, onRejected) {
    if (this.state === "fulfilled") {
      onFulfilled(this.value);
    }

    if (this.state === "rejected") {
      onRejected(this.value);
    }
  }
}

写完代码我们可以测试下:

const pro = new MyPromise((resolve, reject) => {
  resolve("成功");
  reject("失败");
});
console.log(pro);
pro.then(
  (data) => {
    console.log("success", data);
  },
  (err) => {
    console.log("error", err);
  }
);

控制台输出:

MyPromise {state: 'fulfilled', value: '成功'}
success 成功

手写Promise.all

promise.all 是解决并发问题的,多个异步并发获取最终的结果(如果有一个失败则失败)。

MyPromise 里面添加一个静态方法,如下:

  static all(PromiseList) {
    // 判断是否是数组
    if (!Array.isArray(PromiseList)) {
      // PromiseList 的类型
      const type = typeof PromiseList;
      return reject(new TypeError(`${type} ${PromiseList} is not iterable`));
    }

    return new Promise((resolve, reject) => {
      let resultArr = []; // 保留结果
      let orderIndex = 0; // 索引

      // 对结果进行处理
      const ProcessResultByKey = function (value, index) {
        resultArr[index] = value;
        orderIndex += 1;
        if (orderIndex === PromiseList.length) {
          resolve(resultArr);
        }
      };

      // 循环
      for (let i = 0; i < PromiseList.length; i++) {
        let promise = PromiseList[i];
        // 判断是否传入的是 Promise
        if (promise && typeof promise.then === "function") {
          promise.then((res) => {
            ProcessResultByKey(res, i);
          }, reject);
        } else {
          // 非Promise直接调用
          ProcessResultByKey(promise, i);
        }
      }
    });
  }

手写Promise.race

传参和上面的 all 一模一样,传入一个 Promise 实例集合的数组,然后全部同时执行,谁先快先执行完就返回谁,只返回一个结果

同样的在 MyPromise 中添加静态方法:

  static race(promisesList) {
    return new Promise((resolve, reject) => {
      // 直接循环同时执行传进来的promise
      for (const promise of promisesList) {
        // 直接返回出去,所以只有一个,就看那个快
        // 使用 Promise.resolve 可以兼容非Promise的值
        Promise.resolve(promise).then(resolve, reject);
      }
    });
  }

7. ajax 相关

手写ajax请求

ajax 是使用 XMLHttpRequest 实现的,创建ajax请求的步骤:

  • 创建一个 XMLHttpRequest 对象
  • 在对象上使用 open 方法创建一个HTTP请求
  • 使用 send 方法发送数据
  • 使用 onreadystatechange 监听 readyState 状态的变化,当 readyState 变成 4 的时候代表服务器返回的数据接收完成,再根据 state 判断请求的状态

根据以上步骤,我们来简单封装一个ajax:

const url =
  "	https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/6c61ae65d1c41ae8221a670fa32d05aa.svg";
// 创建对象
let xhr = new XMLHttpRequest();

// 创建HTTP请求
xhr.open("GET", url, true);

// 发送数据
xhr.send(null);

// 监听状态
xhr.onreadystatechange = function () {
  if (this.readyState !== 4) return;

  if (this.status === 200) {
    // 打印返回的内容
    console.log(this.response);
  } else {
    console.log(this.statusText);
  }
};

// 监听错误
xhr.onerror = function () {
  console.log(this.statusText);
};

Promise 封装一个ajax

function fetchData(url) {
  return new Promise((resolve, reject) => {
    // 创建对象
    let xhr = new XMLHttpRequest();

    // 创建HTTP请求
    xhr.open("GET", url, true);

    // 发送数据
    xhr.send(null);

    // 监听状态
    xhr.onreadystatechange = function () {
      if (this.readyState !== 4) return;

      if (this.status === 200) {
        // 成功返回
        resolve(this.response);
      } else {
        reject(new Error(this.statusText));
      }
    };

    // 监听错误
    xhr.onerror = function () {
      reject(new Error(this.statusText));
    };
  });
}

8. 数组相关

数组扁平化

通过循环递归的方式,一项一项地去遍历,如果每一项还是一个数组,那么就继续往下遍历,利用递归程序的方法,来实现数组的每一项的连接:

function flatten(arr) {
  let result = [];

  // 循环遍历
  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;
}

乱序输出

实现的思路如下:

  • 取出数组的第一个元素,随机产生一个索引值,将第一个元素和这个索引对应的元素进行交换
  • 第二次取出数组第二个元素,随机产生除了自身索引之外的索引值,并将第二个元素和该索引值对应的元素进行交换
  • 按照上面的规律,直至遍历完成
const arr = [1, 2, 3, 4, 5, 6, 7, 8];

for (let i = 0; i < arr.length; i++) {
  // 获取随机索引
  const randomIndex = Math.round(Math.random() * (arr.length - 1 - i)) + i;

  // 交换
  let temp = arr[i];
  arr[i] = arr[randomIndex];
  arr[randomIndex] = temp;
}

数组和类数组的转换

// 类数组 => 数组的转换
Array.prototype.slice.call(a_array);
Array.prototype.splice.call(a_array, 0);
Array.prototype.concat.call([], a_array);
Array.from(a_array);

实现数组的flat方法

function flat(arr, depth = 1) {
  // depth 为0 则不需要往下遍历
  if (!Array.isArray(arr) || depth <= 0) return arr;

  let result = [];
  // 循环遍历
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
      result = result.concat(flat(arr[i], --depth));
    } else {
      result.push(arr[i]);
    }
  }

  return result;
}

实现数组的push方法

Array.prototype.myPush = function () {
  for (let i = 0; i < arguments.length; i++) {
    this[this.length] = arguments[i];
  }
  return this.length;
};

实现数组的filter方法

filter方法输入一个函数,返回新的数组, 不会对原来的数组产生影响

Array.prototype.myFilter = function (fn) {
  if (typeof fn !== "function") {
    throw Error("参数必须是一个函数");
  }

  // 定义返回的数组
  const res = [];

  // 循环执行fn 返回true的元素 则将该元素放到res中
  for (let i = 0; i < this.length; i++) {
    fn(this[i]) && res.push(this[i]);
  }

  return res;
};

实现数组的map方法

map()  方法创建一个新数组,这个新数组由原数组中的每个元素都调用一次提供的函数后的返回值组成。

Array.prototype.myMap = function (fn) {
  if (typeof fn !== "function") {
    throw Error("参数必须是一个函数");
  }

  // 定义返回的数组
  const res = [];

  // 循环执行fn 返回true的元素 则将该元素放到res中
  for (let i = 0; i < this.length; i++) {
    res.push(fn(this[i]));
  }

  return res;
};

请写出至少三种数组去重的方法

  • 第一种 利用数组的 filter 方法
  • 利用ES6 中的 Set 去重(ES6 中最常用)
  • 遍历循环,然后用splice去重

更多的去重方法可参考: 去重方法

首先定义一个包含重复元素的数组

const arr = [
  1,
  1,
  "2",
  "2",
  true,
  true,
  false,
  false,
  undefined,
  undefined,
  null,
  null,
  NaN,
  NaN,
  [],
  [],
  {},
  {},
  0,
  0,
];

filter 方法

数组的 indexOf 方法会忽略 NaN 类型,console.log(arr.indexOf(NaN)) 打印的是 -1

function unique1(arr) {
  return arr.filter((item, index) => {
    // 重复的元素,只返回第一个
    return arr.indexOf(item) === index;
  });
}

console.log(unique1(arr));
// [1, "2", true, false, undefined, null, [], [], {}, {}, 0];

Set 去重

function unique2(arr) {
  // Array.from() 方法对一个类似数组或可迭代对象创建一个新的,浅拷贝的数组实例
  return Array.from(new Set(arr));
}

console.log(unique2(arr));
// [1, "2", true, false, undefined, null, NaN, [], [], {}, {}, 0];

循环去重

function unique3(arr) {
  for (var i = 0; i < arr.length; i++) {
    for (var j = i + 1; j < arr.length; j++) {
      // 注意 NaN === NaN 的结果是false
      if (arr[i] === arr[j]) {
        // 去除重复元素
        arr.splice(j, 1);
      }
    }
  }
  return arr;
}

console.log(unique3(arr));
// [1, "2", true, false, undefined, null, NaN, NaN, [], [], {}, {}, 0];

9. URL的解析并转成对象

例如有一条url地址为:www.xxx.com?id=2&age=18&name=张三, 解析为对象{id:2,age:18,name:'张三'} 的形式

function parseParam(url) {
  // 先提取 ? 后面的参数
  const paramsStr = /.+\?(.+)$/.exec(url)[1]; // id=2&age=a18&name=%E5%BC%A0%E4%B8%89&ok
  // 对提取出来的字符根据 & 分割
  const paramsArr = paramsStr.split("&"); // ['id=2', 'age=a18', 'name=%E5%BC%A0%E4%B8%89', 'ok']
  // 定义返回结果
  const result = {};
  console.log(paramsStr);

  // 循环 paramsArr
  (paramsArr || []).forEach((param) => {
    if (/=/.test(param)) {
      let [key, value] = param.split("=");
      // 中文解码 将转换后的中文转码成正确的中文
      value = decodeURIComponent(value); // %E5%BC%A0%E4%B8%89 转为 张三
      // 如果是数字 则转换成数字
      value = /^\d+$/.test(value) ? parseFloat(value) : value;

      if (result.hasOwnProperty(key)) {
        // 有相同的属性 则转换成数组
        result[key] = [].concat(result[key], value);
      } else {
        result[key] = value;
      }
    } else {
      result[param] = true;
    }
  });

  return result;
}

const url = "ww.xxx.com?id=2&age=a18&name=%E5%BC%A0%E4%B8%89&ok";
console.log(parseParam(url));
// {id: 2, age: 'a18', name: '张三', ok: true}

10. 对象转换成树

比如:

const objArr = [
  {
    id: 1,
    parent: 0,
    name: "a",
  },
  {
    id: 2,
    parent: 1,
    name: "b",
  },
  {
    id: 3,
    parent: 1,
    name: "b",
  },
  {
    id: 4,
    parent: 2,
    name: "d",
  },
];

转换成:

const tree = [
  {
    id: 1,
    name: "a",
    parent: 0,
    children: [
      {
        id: 2,
        parent: 1,
        name: "b",
        children: [
          {
            id: 4,
            parent: 2,
            name: "d",
          },
        ],
      },
      {
        id: 3,
        parent: 1,
        name: "b",
      },
    ],
  },
];

下面代码实现一下:

function arrToTree(arr) {
  if (!Array.isArray(arr)) {
    return [];
  }

  let result = [];
  let map = {};
  arr.forEach((item) => {
    map[item.id] = item;
  });

  arr.forEach((item) => {
    let _parent = map[item.parent];

    // parent 不为0
    if (_parent) {
      (_parent.children || (_parent.children = [])).push(item);
    } else {
      result.push(item);
    }
  });

  return result;
}

11. 手写instanceof

instanceof 运算符用于判断构造函数的prototype属性是否出现在对象的原型链中的任何位置。实现的思路:

  1. 获取左边的原型, 通过 Object.getPrototypeOf()
  2. 获取右边的prototype原型
  3. 循环往上获取左边的原型,直至为 null
function myInstanceof(left, right) {
  // 获取左边的原型
  let proto = Object.getPrototypeOf(left);
  // 获取右边的原型
  const prototype = right.prototype;

  // 循环
  while (true) {
    // 为 null 则没找到
    if (!proto) return false;
    if (proto === prototype) return true;
    // 往上一级查找
    proto = Object.getPrototypeOf(proto);
  }
}

12. 手写new

实现 new 有以下几个步骤:

  1. 创建一个空对象
  2. 将上一步创建的空对象的原型设置为传进来的函数的 prototype 对象
  3. 让函数的this 指向这个对象,并执行这个函数
  4. 判断函数返回值的类型,如果是引用类型,则返回函数返回值,否则返回创建的对象
function myNew(fn, ...args) {
  if (typeof fn !== "function") {
    return console.error("type error");
  }

  // 创建空对象
  const obj = {};

  // 将新对象原型指向构造函数原型对象
  obj.__proto__ = fn.prototype;

  // 将构建函数的this指向新对象并执行
  let result = fn.apply(obj, args);

  // 根据返回值判断
  return result instanceof Object ? result : obj;
}

13. 手写Object.create

将传入的对象作为原型

function create(obj) {
  function F() {}
  F.prototype = obj;
  return new F();
}

14. 手写发布-订阅模式

发布-订阅模式其实是一种对象间一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到状态改变的通知。

订阅者(Subscriber)把自己想订阅的事件注册(Subscribe)调度中心(Event Channel),当发布者(Publisher)发布该事件(Publish Event)到调度中心,也就是该事件触发时,由调度中心统一调度(Fire Event)订阅者注册到调度中心的处理代码。

有几个核心方法如下:

  • addEventListener 把函数加到事件中心
  • dispatchEvent 发布者发布事件
  • removeEventListener 取消订阅

具体的实现思路参考:手写一个基于发布订阅模式的js事件处理中心(EventEmitter)

class EventCenter {
  // 1.定义事件容器
  events = {};

  // 2.添加事件方法 参数:事件名 事件方法
  addEventListener(type, handler) {
    if (!this.events[type]) {
      this.events[type] = [];
    }

    // 存入事件
    this.events[type].push(handler);
  }

  // 3.触发事件 参数:事件名 事件参数
  dispatchEvent(type, ...params) {
    if (!this.events[type]) {
      return new Error("不存在该事件");
    }

    // 循环执行事件
    this.events[type].forEach((handler) => {
      handler(...params);
    });
  }

  // 4.事件移除 参数:事件名 要移除的参数 如果第二个参数不存在则删除该事件的订阅和发布
  removeEventListener(type, handler) {
    if (!this.events[type]) {
      return new Error("事件无效");
    }

    if (!handler) {
      delete this.events[type];
    } else {
      // 获取索引
      const index = this.events[type].findIndex((el) => el === handler);
      if (index === -1) {
        return new Error("无该绑定事件");
      }

      // 移除事件
      this.events[type].splice(index, 1);
      if (this.events[type].length === 0) {
        delete this.events[type];
      }
    }
  }
}

// 测试
const dom = new EventCenter();
const eventClick = function (e) {
  console.log("点击了", e);
};
const eventClick2 = function (e) {
  console.log("点击了2", e);
};
dom.addEventListener("click", eventClick);
dom.addEventListener("click", eventClick2);

dom.dispatchEvent("click", "click1");

console.log(dom.events);
dom.removeEventListener("click", eventClick);

15. Object.defineProperty

Vue2的响应式原理,结合了Object.defineProperty的数据劫持,以及发布订阅者模式

const obj = {
  name: "winter",
};
const data = {};

for (let key in obj) {
  Object.defineProperty(data, key, {
    get() {
      console.log(`读取了data里面的${key}`);
      return obj[key];
    },
    set(value) {
      if (value === obj[key]) return;
      console.log(`设置了${key}的值为${value}`);
      obj[key] = value;
    },
  });
}

16. Proxy

vue3的数据劫持通过Proxy函数对代理对象的属性进行劫持,通过Reflect对象里的方法对代理对象的属性进行修改

const obj = {
  name: "winter",
  age: 18,
};

const p = new Proxy(obj, {
  set(target, key, value) {
    console.log(`设置了${key}的值${value}`);
    return Reflect.set(target, key, value);
  },

  get(target, key) {
    console.log(`获取了${key}的值`);
    return Reflect.get(target, key);
  },
  deleteProperty(target, key) {
    console.log(`删除了${key}`);
    return Reflect.deleteProperty(target, key);
  },
});

p.age = 20;
console.log(p.age);

17. 定时器相关

用setTimeout 实现 setInterval

实现思路是使用递归函数,不断地去执行 setTimeout 从而达到 setInterval 的效果

function myInterval(cb, timeout) {
  function fn() {
    cb();
    setTimeout(fn, timeout);
  }

  setTimeout(fn, timeout);
}

使用setInterval实现setTimeout

function mySetTimeout(cb, timeout) {
  const timer = setInterval(() => {
    clearInterval(timer);
    cb();
  }, timeout);
}

三、总结

后续有其他的手写题再陆续加进来...