前端面试常考手写代码

944 阅读11分钟

前言

在面试的时候,经常有面试官问候选人:“你可以实现一个XXX嘛?”。如果候选人没有写过的话,可能会多多少少有些发懵。随着前端的发展,前端的门槛变得越来越高,很多面试官开始注重候选人的coding能力。如果没有写好的话,可能会给面试官留下不太好的印象。下面是一些常考的基础手写代码总结。

new

new用构造函数创建实例对象,为实例对象添加this属性和方法。

new在调用过程中实现了以下几个步骤:

  1. 创建一个新的对象
  2. 链接到原型,将该对象 obj 的原型链指向构造函数的原型 prototype
  3. 绑定this,让this变量指向这个新创建的对象
  4. 返回新对象
function createNew() {
    // 创建一个空的对象
    var obj = new Object();
    // 获得构造函数,arguments中去除第一个参数
    var Con = [].shift.call(arguments);
    // 链接到原型,obj 可以访问到构造函数原型中的属性
    obj.__proto__ = Con.prototype;
    // 绑定 this 实现继承,obj 可以访问到构造函数中的属性
    var res = Con.apply(obj, arguments);
    // 优先返回构造函数返回的对象,判断下返回的值是不是一个对象,如果是对象则返回这个对象,不然返回新创建的 obj对象。
    return res instanceof Object ? res : obj;
};

instanceof

instanceof用于判断对象的具体类型。它依靠原型链向上查找,遍历左侧变量的原型链,查看右侧变量的 prototype是否在左边的原型链上。如果查找失败,返回false。

function creatInstanceof(left, right) {
    //如果不是object或者为null,直接返回false
    if (typeof(left)!== 'object' || left === null) return false;
    // 取左表达式的__proto__值
    let left = left.__proto__; 
    while (true) {
        if (left === null || left === undefined)
            return false;
        //判断右表达式的 prototype 值是否和左表达式的__proto__值相等
        if (right.prototype === left)
            return true;
        //往下走
        left = left.__proto__;
    }
}

节流

节流主要用来稀释函数的执行次数,当持续触发事件时,让函数在特定的时间内只执行一次。例如:假设我们设定延时时间为1000ms,有一个函数A一直在被调用,我们使用节流,就可以让它1000ms执行一次,而不是一直在执行。

function throttle(fn, delay = 1000) {
    // 定义开始时间
    var startTime = 0;
    return function () {
      //获取当前时间
      var nowTime = Date.now();
      //如果当前时间减去开始时间大于约定的延时时间,则执行
      if (nowTime - startTime > delay) {
        //执行函数,同时改变this当前指向
        fn.call(this);
        //更新开始时间的值
        startTime = nowTime;
      }
    }
}

//应用:滑动鼠标输出12345(如果一直滑动的话,会1000ms输出一次)
document.onmousemove = throttle(() =>{
    console.log('12345');
},1000)

防抖

防抖是函数在特定的时间内不被调用后再执行。例如:假设我们设定延时时间为1000ms,有一个按钮,点击这个按钮生成随机数。我们使用防抖,点击按钮之后的1000ms内,按钮没有被再次点击,才会生成随机数,如果在1000ms内按钮被再次点击,那么它会重新计时,不会生成随机数。(搜索框的联想功能也会应用到防抖思想)

const debounce = function(fn,delay){
    //定义一个timer
    var timer = null;
    return function(){
        //如果函数执行了,那么清除定时器
        clearTimeout(timer)
        //应用定时器计时,超过延时时间,执行函数
        timer = setTimeout(() =>{
            //执行函数,同时改变this指向
            fn.call(this);
        },delay)
    }
}

//举例中的应用:点击按钮输出12345。
obtn.onclick = debounce(function(){
    console.log('12345');
},1000)

深拷贝

  • 浅拷贝:复制对象的第一层,指向被复制的内存地址,如果原地址发生改变,那么浅拷贝出来的对象也会相应的改变。
  • 深拷贝:在计算机中开辟一块新的内存地址用于存放复制的对象。复制后的两个对象对应两个不同的地址,修改一个对象的属性,不会改变另一个对象的属性。

针对像Object, Array 这样的复杂对象的。简单来说,浅复制只复制一层对象的属性,而深复制则递归复制了所有层级。

简单写法:

let newObj1 = JSON.parse(JSON.stringfy(obj));

该方法有一些局限性:

  • 会忽略 undefined
  • 会忽略 symbol
  • 不能序列化函数
  • 不能解决循环引用的对象

递归写法(简版):

const deepClone = function (obj) {
    // 判断是否为对象,不是的话返回
    if (typeof obj !== 'object') return;
    // 判断是数组还是对象
    let newObj = obj instanceof Array ? [] : {};
    //循环这个对象
    for (var key in obj) {
        //判断对象自身属性是否有这个key
        if (obj.hasOwnProperty(key)) {
            //如果obj[key]为对象的话,递归。否则直接赋值即可。
            newObj[key] = typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key];
        }
    }
    return newObj;
}

call

call用来改变this的指向。使用:fn.call(obj, arg1, arg2, ...)

Function.prototype.myCall = function (context) {
    // 判断如果不是函数的话,报出错误
    if (typeof this !== 'function') {
      throw new TypeError('Error');
    }
    // context为null或undefined的话会被全局对象代替
    context = context || window;
    // 获取调用call的函数(就是this)把它作为context的一个属性
    context.fn = this;
    // 获取剩余参数
    const args = [...arguments].slice(1);
    // 执行
    const result = context.fn(...args);
    // 执行后删除即可
    delete context.fn;
    // 返回结果
    return result;
}

apply

apply用来改变this的指向,和call的区别是传递的参数格式不同,apply接受数组作为参数。

使用:fn.apply(obj, [arg1, arg2, ...])

Function.prototype.myApply = function (context) {
    if (typeof this !== 'function') {
      throw new TypeError('Error');
    }
    context = context || window;
    context.fn = this;
    let result;
    // 参数处理和 call 有区别,其余基本一致
    if (arguments[1]) {
      result = context.fn(...arguments[1]);
    } else {
      result = context.fn();
    }
    delete context.fn;
    return result;
}

注:上面实现的call和apply,当我们被问到的时候,通常可以直接这么写。假如写完之后,面试官问你,如果context里面原来就有fn属性,你该怎么办? 由于我们最后一步delete context.fn;将我们建的fn删除了,如果它原来存在的话,那么也就被我们删除啦。我们可以考虑复制一个新的context2,而不是直接改context,或者先保留旧的context.fn的值,执行完逻辑之后再将它还原,这样的话就可以解决这个问题了。(有把握的话也可以直接说哦~)

bind

bind用来改变this指向,和call、apply的区别是bind是绑定,执行需要再次调用。

使用:fn.bind(obj, arg1, arg2, ...)()

bind() 方法会创建一个新函数。当这个新函数被调用时,bind() 的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数。(来自于 MDN )

Function.prototype.myBind = function (context) {
    if (typeof this !== 'function') {
      throw new TypeError('Error');
    }
    const _this = this;
    // 获取当前的参数
    const args = [...arguments].slice(1);
    // 返回一个函数
    return function F() {
      // 因为返回了一个函数,我们可以 new F(),所以需要判断
      if (this instanceof F) {
        return new _this(...args, ...arguments);
      }
      // 利用apply改变this指向,同时拼接返回的这个函数中的参数
      return _this.apply(context, args.concat(...arguments));
    }
}

Promise.all

Promise.all方法接受一个数组,当数组中所有的promise请求成功之后,会走到.then的方法里面。如果中间哪一个promise失败了,那么promise.all就会直接走到.catch的方法里面。

使用:Promise.all([p1,p2,p3,···]).then(function(){}).catch(function(){})

Promise.prototype.all = function (promiseAry = []) {
    // 定义一个index,用于计数。
    let index = 0;
    // 定义空数组,用于保存结果
    let result = [];
    // 返回新的Promise
    return new Promise((resolve, reject) => {
      // 循环传入的promise数组
      for (let i = 0; i < promiseAry.length; i++) {
        // 执行成功走到then
        promiseAry[i].then(val => {
          // 成功,index+1
          index++;
          // 存入对应结果
          result[i] = val;
          // 当所有函数都正确执行了,resolve输出所有返回结果
          if (index === promiseAry.length) {
            resolve(result)
          }
        }, reject)
      }
    })
}

Promise.race

Promise.race方法接受一个数组,只要请求最快的promise请求成功,那么就会直接走到then的方法里面,即使后面有请求失败的promise不用管。同理,只要请求最快的promise请求失败。那么promise.race就直接走到catch啦。

使用:Promise.all([p1,p2,p3,···]).then(function(){}).catch(function(){})

Promise.prototype.race = function (promiseAry) {
  // 返回一个promise
  return new Promise((resolve, reject) => {
    // 如果数组长度为0,直接返回
    if (promiseAry.length === 0) {
      return;
    } else {
      // 循环数组
      for (let i = 0; i < promiseAry.length; i++) {
        // 最快的那个成功的话走到then,失败走到后面
        promiseAry[i].then(val => {
          // resolve输出对应结果
          resolve(result);
          return;
        }, reject)
      }
    }
  })
}

Promise.finally

Promise.finally不管 Promise 对象最后状态如何,都会执行finally方法指定的回调函数。

Promise.prototype.finally = function (callback) {
  let P = this.constructor;
  return this.then(
    // onFulfilled
    value => P.resolve(callback()).then(() => value),
    // onRejected
    reason => P.resolve(callback()).then(() => {
      throw reason
    })
  );
};

函数柯里化

柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。(来自于百度百科)

简单点来说就是把一个多参数的函数(fn)作为参数,传递到柯里化的这个函数中(curry),运行后能够返回一个新的函数。这个新的函数能够继续处理这个函数(fn)的剩余参数。

function curry(fn, args) {
  // 获取函数需要的参数长度
  let length = fn.length;
  args = args || [];
  return function () {
    let subArgs = args.slice(0);
    // 拼接得到现有的所有参数
    subArgs = subArgs.concat(Array.prototype.slice.call(arguments))
    // 判断参数的长度是否已经满足函数所需参数的长度
    if (subArgs.length >= length) {
      // 如果满足,执行函数
      return fn.apply(this, subArgs);
    } else {
      // 如果不满足,递归返回科里化的函数,等待参数的传入
      return curry.call(this, fn, subArgs);
    }
  };
}

//应用:
function add(a, b, c) {
  return a + b + c;
}
var _add = curry(add)
console.log(_add(1, 2, 3)) //6
console.log(_add(1)(2)(3)) //6
console.log(_add(1, 2)(3)) //6

深度优先遍历

深度优先遍历算法(DFS),是从根节点开始,沿着树的深度遍历树的节点。简单来说,首先以一个未被访问过的顶点作为起始顶点,沿当前顶点的边开始走未访问过的顶点。当没有未访问过的顶点时,则回到上一个顶点,继续重复以上过程,直至所有的顶点都被访问,算法中止。

递归写法

let deepTraversal = (node) => {
  // 定义空数组,用于存储节点
  let nodes = [];
  // 当节点不为空时
  if (node !== null) {
    // 将当前节点push进数组中
    nodes.push(node);
    // 取出当前节点的孩子节点
    let children = node.children;
    // 循环所有的孩子节点
    if (children) {
      for (let i = 0; i < children.length; i++) {
        // 递归调用并将结果进行拼接
        nodes = nodes.concat(deepTraversal(children[i]));
      }
    }
  }
  // 返回结果
  return nodes
}

非递归写法

let deepTraversal = function (node) {
  // 定义保存结果数组nodes,以及辅助数组stack(栈)
  let stack = [];
  let nodes = [];
  if (node) {
    // 推入当前处理的node
    stack.push(node);
    while (stack.length) {
      // 将最后一个弹出
      let item = stack.pop();
      // 取出他的孩子节点
      let children = item.children;
      // 将这个节点push进结果数组
      nodes.push(item);
      // 将孩子节点倒过来push进辅助栈中。例如当前节点有两个孩子,children1和children2
      // 那么stack里面为[children2,children1],这样pop()的时候children1会先弹出,
      // 进而children1会先被push进nodes,先遍历children1的孩子节点(以此类推)
      if (children) {
        for (let i = children.length - 1; i >= 0; i--) {
          stack.push(children[i]);
        }
      }
    }
  }
  // 返回结果数组
  return nodes;
}

广度优先遍历

广度优先遍历算法(BFS),是从根节点开始,沿着树的宽度遍历树的节点(一层一层的遍历)。如果所有节点均被访问,则算法中止。

let widthTraversal = (node) => {
  // 定义保存结果数组nodes,以及辅助数组queue(队列)
  let nodes = [];
  let queue = [];
  if (node) {
    // 将节点push进队列中
    queue.push(node);
    // 当队列长度不为0时循环
    while (queue.length) {
      // 将值从头部弹出
      let item = queue.shift();
      // 取出当前节点的孩子节点
      let children = item.children;
      // 将当前节点push进结果数组
      nodes.push(item);
      // 将孩子节点顺次push进辅助队列中。例如当前节点有两个孩子,children1和children2
      // 那么queue里面为[children1,children2],这样shift()的时候children1会先弹出,
      // 进而children1会先被push进nodes,children1的孩子节点会顺次push进queue中 [child2,child1-1](以此类推)
      if (children) {
        for (let i = 0; i < children.length; i++) {
          queue.push(children[i]);
        }
      }
    }
  }
  return nodes;
}

发布订阅模式

发布订阅模式是比较常问的设计模式之一,实现一般分为两步,一是注册也就是添加订阅(添加监听事件及对应的方法),二是进行激活也就是发布消息(根据传入的事件类型触发相应的方法)。

let event = {
  // 存放订阅事件
  childrenList: {},
  //订阅函数
  listen(key, fn) {
    //如果chilidrenlist里这个缓存不存在,就先将它创建为空,为后续做准备
    if (!this.childrenList[key]) {
      this.childrenList[key] = [];
    }
    // 判断传进来的是否是一个函数,若是就加到childrenList[key]下的数组中等待执行
    if (typeof fn == 'function') {
      this.childrenList[key].push(fn);
    }
  },
  // 发布函数
  touch(key) {
    // 取出对应key中的函数
    let fns = this.childrenList[key];
    // 判断如果不存在直接返回
    if (!fns && fns.length === 0) {
      return false;
    }
    // 循环出每一个函数,进行执行
    fns.forEach(fn => {
      fn.apply(this, [arguments]);
    });
  },
  // 删除订阅函数
  remove(key, fn) {
    // 取出该类型对应的消息集合
    var fns = this.childrenList[key];
    if (!fns) {
      return false;
    }
    // 如果没有传入具体的fn,就表示需要取消所有订阅
    if (!fn) {
      fns && (fns.length = 0);
    } else {
      // 将函数循环取出
      for (var i = 0; i < fns.length; i++) {
        if (fn === fns[i]) {
          // 删除订阅者的这个回掉
          fns.splice(i, 1);
        }
      }
    }
  }
}

// 应用:
event.listen('zs', arguments => {
  console.log(`${arguments[0]}${arguments[1]}`)
})
event.listen('lisi', arguments => {
  console.log(`${arguments[0]}${arguments[1]}`)
})
event.touch('zs', '收到啦面试邀请');
event.touch('lisi', '接到啦offer');

实现数组的扁平化

数组的扁平化主要是将多维数组转化为一维数组。平常的写法可以直接调用数组的Api:var newArr = arr.flat(Infinity);。下面用原生简单实现:

const getFlat = function (arr) {
    // 循环数组,当发现里面元素包含数组时
    while (arr.some(item => Array.isArray(item))) {
        // 用扩展运算符取出元素,concat进行拼接
        arr = [].concat(...arr);
    }
    return arr;
}

//应用
let arr = [1, 2, [3, 4, 5, [6, 7], 8], [9, [10]]];
console.log(getFlat(arr)) //[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

二分查找

二分法查找,也称折半查找,是一种在有序数组中查找特定元素的搜索算法。

实现思路:先取出中间值,如果和目标值相等则直接返回索引。否则比较目标值和中间值的大小,进而在数组大于或小于中间值的那一半区域查找,重复上述操作。

非递归写法

function search(arr, key) {
    // 初始化low和height
    var low = 0;
    var height = arr.length - 1;
    var mid;
    while (low <= height) {
        // (小索引+大索引)除以2,向下取整找到中间值
        mid = Math.floor((low + height) / 2);
        // 如果当前索引为中间值的数值刚好和这个目标值想等
        if (arr[mid] == key) {
            // 返回目标元素的索引值
            return mid;
        } else if (arr[mid] < key) {//如果当前索引为中间值的数值小于这个目标值
            // 将中间值+1赋值给low
            low = mid + 1;
        } else {// 如果当前索引为中间值的数值大于这个目标值
            // 将中间值-1赋值给height
            height = mid - 1;
        }
    }
    // 没有查到,返回-1
    return -1;
}

递归写法

function search(arr, low, height, key) {
    // 递归出口(没找到返回-1)
    if (low > height) {
        return -1;
    }
    // (小索引+大索引)除以2,向下取整找到中间值
    var mid = Math.floor((low + height) / 2);
    // 如果当前索引为中间值的数值刚好和这个目标值想等
    if (arr[mid] == key) {
        // 返回目标元素的索引值
        return mid;
    } else if (arr[mid] < key) { // 如果当前索引为中间值的数值小于这个目标值
        // 将中间值+1赋值给low
        low = mid + 1;
        // 递归调用
        return search(arr, low, height, key);
    } else { // 如果当前索引为中间值的数值大于这个目标值
        // 将中间值-1赋值给height
        height = mid - 1;
        // 递归调用
        return search(arr, low, height, key);
    }
}

总结

上边写啦一些常见的基础代码,写给即将站在面试场上的你,希望能够对同在前端路上不断前行的你有所帮助!

如有问题,还望辛苦指正,谢谢~