前端常见手写题、面试题(干货拉满)

1,083 阅读5分钟

我想变强

想起,刚工作那会,还没体会过社会的残酷。边玩边滑,就这样混混沌沌一年多。身边的同学都跻身进入大厂。自己却还是默默的小程序仔。
贫穷迫使我改变,也在不断学习,不断吸收。尽管目前还是个小程序仔,但确实,自己已经明显感受到了变化。

在这过程中,也参与了很多面试,包含了很多互联网大厂。很多环节就是手写代码手写算法等等。这种对js的理解和运用能力是必不可少的,所以这里就是收集整理遇到或者没遇到的前端代码手写题,能给其他同学和自己再加深印象,多点经验都是不错的。

👌开始

形式会是题目+代码+解析,尽可能通俗易懂的描述问题和理解过程,代码思路。当然有些问题会涉及很多js原理,需要配合阅读MDN Web文档或者一些大牛的博客,更能理解透彻。

冴羽的博客

阮一峰 ECMAScript 6 入门

代码有不合理、有补充的,希望能指出,也是自己学习吸收的过程。

防抖&节流

防抖节流,绝对是出镜率非常高的问题,原理都是利用闭包函数的特性,保存变量。然后我们要区分开防抖和节流概念就好。

  • 防抖:任务频繁触发的情况下,只有触发任务的间隔超过指定时间,任务才会执行,否则重新初始化任务。常用场景是,搜索框,搜索内容频繁改变,只有内容稳定超过间隔后才触发搜索功能。(白话文就是,疯狂触发任务,基本就是最后一次任务能正常执行,可以理解为只执行一次

  • 节流:任务在指定时间间隔内只执行一次。常用场景是,滚动监听。(白话文就是,**疯狂滚动,设置200ms间隔的话,滚动事件每200ms执行一次,会执行很多次,**频率被控制了)

👇

// 防抖
function debounce (fn, time) {
  let timer = null
  return function (...rest) {
    // 时间间隔内,清除之前的任务,重新计算时间
    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(() => {
      fn.call(this, ...rest)
    }, time)
  }
}

// 节流
function throttle (fn, time) {
  let isRun = false
  return function (...rest) {
    // 任务正在执行,则直接返回
    if (isRun) {
      return
    }
    // 标记执行
    isRun = true
    setTimeout(() => {
      fn.call(this, ...rest)
      // 执行结束
      isRun = false
    }, time)
  }
}

深拷贝

又一经典题出现。

JS基本数据类型和引用数据类型的区别及深浅拷贝

JS分两种数据类型:

  • 基本数据类型:**Number、String、Boolean、Null、 Undefined、Symbol(ES6),**这些类型可以直接操作保存在变量中的实际值。
  • 引用数据类型Object(在JS中除了基本数据类型以外的都是对象,数组是对象,函数是对象,正则表达式是对象)

白话文理解就是,我们需要复制一个变量,无论它内部是什么样的,我们在改变它的复制品时,不希望对原变量产生影响。我们要切掉复制品和原变量的联系。

// 这里我们实现了防止嵌套引用引起的死循环,只复制非继承类的属性
// WeakMap结构用于保存复制过的变量,以判断是否循环引用
function deepClone (obj, cache = new WeakMap()) {
  // 如果是基本数据类型或者函数或null等直接返回
  if (typeof obj !== 'object' || obj === null) return obj
  // 是否复制过
  if (cache.has(obj)) return cache.get(obj)
  let result = Array.isArray(obj) ? [] : {}
  // 保存当前复制的变量
  cache.set(obj, result)
  for (let key in obj) {
    // 判断是不是自身属性,忽略原型链上继承的属性
    if (obj.hasOwnProperty(key)) {
      result[key] = deepClone(obj[key], cache)
    }
  }
  return result
}

数组乱序

数组题目在面试中出现的概率也是挺高的。

42.洗牌算法(shuffle)的js实现

// 在排序函数中随机返回大于0或小于0的数
function sort (arr) {
  return arr.sort(() => Math.random() - 0.5)
}

// 著名的Fisher–Yates shuffle 洗牌算法
function shuffle(arr){
    let m = arr.length
    while(m > 0){
        let index = parseInt(Math.random() * m--)
        [arr[index],arr[m]] = [arr[m],arr[index]]
    }
    return arr
}

数组去重

最简单的数组题,不说了,上代码。

// 简单版
function setArr (arr) {
  return Array.from(new Set(arr))
  // 或者 [...new Set(arr)]
}

// 利用map存储已经出现过的
// 如果不让用filter,可以用普通循环实现,道理一样
function removeDup (arr) {
  const hashMap = new Map()
  return arr.filter((item) => {
    if (hashMap.has(item)) {
      return false
    }
    hashMap.set(item)
    return true
  })
}

数组flat

数组flat方法是ES6新增的一个特性,可以将多维数组展平为低维数组。如果不传参默认展平一层,传参可以规定展平的层级。

function myFlat (arr, deep = 2) {
  let result = []
  for (let i = 0; i < arr.length; i++) {
    // 如果目标还是数组且展开数大于0,则继续展开,否则直接push
    if (Array.isArray(arr[i]) && deep > 0) {
      result = [...result, ...myFlat(arr[i], deep - 1)]
      // result = result.concat(myFlat(array[i],deep -1))
    } else {
      result.push(arr[i])
    }
  }
  return result
}

数组filter

filter方法经常用,实现起来也简单。需要注意的就是filter接收的参数依次为数组当前元素、数组index、整个数组,并返回结果为ture的元素。

Array.prototype.filter = function (fn, context) {
  if (typeof fn != 'function') {
    throw new TypeError(`${fn} is not a function`)
  }
  const arr = this
  const reuslt = []
  for (let i = 0; i < arr.length; i++) {
    const temp = fn.call(context, arr[i], i, arr)
    if (temp) result.push(arr[i])
  }
  return result
}

call&apply

来了来了,call和apply老生常谈了。必考,划重点。

Function.prototype.call() MDN

call() 方法使用一个指定的this值和单独给出的一个或多个参数来调用一个函数。
注意:该方法的语法和作用与apply()方法类似,只有一个区别,就是call()方法接受的是一个参数列表,而 apply()方法接受的是一个包含多个参数的数组

Function.prototype.myCall = function (ctx, ...rest) {
  // 获取执行上下文
  const ctx = ctx || window
  // 用symbol定义变量,防止命名冲突
  const fn = Symbol('fn')
  // 用上下文执行当前函数
  ctx[fn] = this
  const result = rest && rest.length > 0 ? ctx[fn](...rest) : ctx[fn]()
  // 执行完后,删除挂载的函数
  delete ctx[fn]
  return result
}

Function.prototype.myApply = function (ctx, rest = null) {
  const ctx = ctx || window
  const fn = Symbol('fn')
  ctx[fn] = this
  const result = rest ? ctx[fn](...rest) : ctx[fn]()
  delete ctx[fn]
  return result
}

bind

重点重点重点。必考,内容涉及原型、原型链。

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

绑定函数也可以使用 new 运算符构造,它会表现为目标函数已经被构建完毕了似的。提供的 this 值会被忽略,但前置参数仍会提供给模拟函数。

白话文理解

  • bind就是接收一系列参数,返回一个新函数。
  • 当新函数被 new Fn()当成构造函数执行时,传入的第一个参数this无效

涉及2个知识点

  • 新函数原型需要在原型链上连接原函数原型。这样我们就能判断,新函数是否被当成构造函数创建实例
  • **instanceof,**用于检测某个构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

Function.prototype.bind() MDN

JavaScript深入之bind的模拟实现

Function.prototype.myBind = function (ctx, ...arr) {  
  // 要绑定的this  
  const ctx = ctx || window  
  // 原函数本身  
  const _self = this  
  // 中间函数,为了建立新函数的原型对象与原函数原型对象的联系  
  const middle = function () { }  
  // 新函数  
  const newF = function (...rest) {
    // 判断新函数的this在原型链式能否找到_self.prototype,新函数是否被当成构造函数执行    
    const _this = this instanceof _self ? _self : ctx
    const arg = [...arr, ...rest]
    arg.length > 0 ? _self.call(_this, ...arg) : _self.call(_this)
  }
  // 维护原型链,这样我们才能用instanceof判断  
  middle.prototype = _self.prototype
  newF.prototype = new middle()
  // 思考为什么不直接  
  // newF.prototype = new _self(),或 newF.prototype = _self.prototype  
  return newF
}

不懂没关系,我们结合👇👇图和后续的 instanceofnew 运算符代码,反复理解就好。

  • 思考:继承时为什么 **newF.prototype = new _self()**或 newF.prototype = _self.prototype

通过后面的继承学习,就能解决你的疑惑。

instanceof运算符

instanceof运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

白话文

运算符左边是实例对象右边是构造函数。就是沿着实例对象的原型链找,能不能找到构造函数的原型对象

// 左对象,右构造函数
function myInstanceof(left,right){
  // 构造函数原型对象
  const protoT = right.prototype
  let proto = left.__proto__
  while(proto!==null){
    if(proto === protoT){
      return true
    }else{
      // 原型链寻找
      proto = proto.__proto__
    }
  }
  return false
}

new运算符

new运算符、instanceof运算符、bind一定要弄明白。很关键!!!

new 运算符 MDN

JavaScript深入之new的模拟实现

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

new constructor[([arguments])]new 关键字会进行如下的操作:

  • 创建一个空的简单JavaScript对象(即**{}**);
  • 链接该对象(设置该对象的constructor)到另一个对象 ;
  • 将步骤1新创建的对象作为**this**的上下文 ;
  • 如果该函数没有返回对象,则返回**this**。
function myNew (fn, ...rest) {
  const obj = new Object()
  // 维护原型链,这样obj.constructor其实就是通过原型链查找,在fn.prototype上找到  
  obj.__proto__ = fn.prototype  // 将obj作为fn的this上下文  
  let result = fn.call(obj, ...rest)
  // 函数是否返回对象  
  return typeof result === 'object' ? result : obj
}

Obejct.create

**Object.create()**方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。

首先在create的内部创建的一个Fun的构造函数,然后将传入的对象赋值给Fun的原型,然后再返回Fun的实例,此时该实例的__proto__指向了传入对象,则实现了创建对象。

Obejct.prototype.create = function(obj){
  const func = function(){}
  func.prototype = obj
  return new func()
}

实现继承

继承可以说是面试必备了。es5实现继承有很多种方式,各有好坏。

  • 原型链继承
  • 构造函数继承(经典)
  • 组合继承
  • 原型式继承(同原型链类似)
  • 寄生式继承(同构造函数类似)
  • 寄生组合式继承(类似组合继承,将其优化成相对最优方案)

JavaScript深入之继承的多种方式和优缺点看看看🔪)

反复多看,并尝试执行多几遍就会理解了。之前在bind方法中的思考,这里继承篇便能解答。

// 寄生组合继承
function Parent (name){
  this.name = name
}
Parent.prototype.getName = function (){
  return this. name
}

function Child (name,age){
  Parent.call(this,name)
  this.age = age
}
const Func = function(){}
Func.prototype = Parent.prototype
Child.prototype = new Func()
Child.prototype.constructor = Child

// 如果 Child.prototype = new Parent() 
// 这样会多执行一遍Parent,在新实例中创建Parent的属性和方法
// 如果直接 Child.prototype = Parent.prototype
// 如果修改child原型上的方法或属性,会影响破坏父级的方法属性


// ES6
class Parent{
  constructor(name){
    this.name = name
  getName(){
    console.log(this.name)
  }
}
class Child extends Parent{
  constructor(name,age){
    super(name)
    this.age = age
  }
}

发布订阅模式

发布订阅模式也是经常出现,从我们的js事件监听到vue的双向数据绑定。都离不开这个设计模式。

为了解决主体对象与观察者之间的功能的耦合。比方js事件中,我们对某种操作定义的回调函数就属于订阅事件,click操作后,就会通知订阅者,执行相应函数。

class EventEmitter{
  constructor(){
    this.events = {}
  }
  on(name,fn){
    // 注册订阅
    if(this.events[name]){
      this.events[name].push(fn)
    }else{
      this.events[name] = [fn]
    }
  }
  off(name,fn){
    // 取消订阅
    if(this.events[name]){
      this.events[name] = this.events[name].filter((item)=>fn!==item)
    }else{
      new Error('')
    }
  }
  emit(name,...rest){
    // 发布
    if(this.events[name]){
      this.events[name].forEach((item)=>{
        item.call(this,...rest)
      })
    }
  }
  once(name,fn){
    // 注册一次性订阅
    const onceF = (...rest)=>{
      fn.call(this,...rest)
      this.off(name,onceF)
    }
    this.on(name,onceF)
  }
}

lazyMan任务队列

**此题也是在别的文章中发现,搬过来分享的。**考察了具体场景下,设计代码的思路。

涉及知识点:观察者模式、简单的事件队列机制。

题目:

// 实现一个LazyMan,可以按照以下方式调用:
LazyMan("Hank")输出:
// Hi! This is Hank!
 
LazyMan("Hank").sleep(10).eat("dinner")输出
Hi! This is Hank!
// 等待10秒..
// Wake up after 10
// Eat dinner~
 
LazyMan("Hank").eat("dinner").eat("supper")输出
// Hi This is Hank!
// Eat dinner~
// Eat supper~
 
LazyMan("Hank").sleepFirst(5).eat("supper")输出
// 等待5秒
// Wake up after 5
// Hi This is Hank!
// Eat supper
 
// 以此类推。
  • 首先定义一个Lazy类,它可以接收一个名称参数,可以执行一系列动作
  • 我们可以通过观察者模式,来向这个类订阅需要执行的事件。这个类需要统一收集起来。
  • 有sleepFirst功能,所以我们需要用到setTimeout的在事件队列中属于宏任务的机制。

👌,需求清晰,代码如下,边看边分析

class Lazy {
  constructor(name) {
    this.name = name
    // 任务列表
    this.taskList = []
    // 将输出名称推入任务列表
    this.taskList.push(this.sayName)
    this.do()
  }
  do () {
    // 每一个任务通过setTimeout执行,这样就让sleepFirst在执行前做操作
    setTimeout(() => {
      let fn = this.taskList.shift()
      fn && fn.call(this)
    }, 0)
  }
  sayName () {
    console.log(this)
    console.log(`Hi! This is ${this.name}!`)
    this.do()
  }
  sleep (time) {
    // 插入一个睡眠任务
    this.taskList.push(() => {
      setTimeout(() => {
        console.log(`Wake up after ${time}`)
        this.do()
      }, time)
    })
    return this
  }
  sleepFirst (time) {
    // 将提前睡眠任务插入任务列表首位
    this.taskList.unshift(() => {
      setTimeout(() => {
        console.log(`Wake up after ${time}`)
        this.do()
      }, time)
    })
    return this
  }
  eat (thing) {
    this.taskList.push(() => {
      console.log(`Eat ${thing}`)
      this.do()
    })
    return this
  }
}
function lazyMan (name) {
  return new Lazy(name)
}

函数柯里化

函数柯里化是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回结果是接受余下的参数的新函数的技术,是高阶函数的一种用法。比如求和函数add(1,2,3), 经过柯里化后变成add(1)(2)(3)

JavaScript专题之函数柯里化
详解JS函数柯里化

主要的用途,我这边大致理解是,可以将重复参数服用,将复杂的逻辑提前处理,精简代码。(我们👆写的bind函数也是运用了这个逻辑处理,可以接收多个参数或者先接收this,再传递参数)

// 简单实现
function curry (fn, ...arg) {
  const len = fn.length // fn参数个数
  arg = arg || []
  return function (...rest) {
    // 参数收集
    const _arg = [...arg, ...rest]
    if (_arg.length < len) {
      return curry.call(this, fn, ..._arg)
    } else {
      return fn.call(this, ..._arg)
    }
  }
}

compose

compose就是执行一系列的任务(函数)。webpack的loader执行流就是采用compose,一层一层处理文件代码。还有nodejs的koa2框架,大名鼎鼎的中间件原理也是compose在发挥主要作用。

可以去看看这篇文章Koa2中间件实现的原理剖析,很好的讲解了compose的应用。

接下来我们通过简单的案例,动手写一遍👇

const tasks = [step1, step2, step3, step4]

每一个step都是一个步骤,按照步骤一步一步的执行到结尾,这就是一个compose

compose在函数式编程中是一个很重要的工具函数,在这里实现的compose有三点说明

  • 第一个函数是多元的(接受多个参数),后面的函数都是单元的(接受一个参数)
  • 执行顺序的自右向左的
  • 所有函数的执行都是同步的(异步的后面文章会讲到)

还是用一个例子来说,比如有以下几个函数

const init = (...args) => args.reduce((total, val) => total + val, 0) 
const step2 = (val) => val + 2 
const step3 = (val) => val + 3 
const step4 = (val) => val + 4

这几个函数组成一个任务队列

const steps = [step4, step3, step2, init]

使用compose组合这个队列并执行

let composeFunc = compose(...steps)      
console.log(composeFunc(1, 2, 3))

同步:

function compose (...steps) {
  steps = [...steps]
  // 获取最初始函数
  const init = steps.pop()
  return function (...args) {
    // 通过reduce逐步执行函数
    return steps.reverse().reduce((total, step) => step(total), init(...args))
  }
}

异步:

function compose (...steps) {
  const init = steps.pop()
  return function myf (...args) {
    return steps.reverse().reduce((res, step) => {
      // 我们使用promise将每一个步骤包裹起来,使其链式执行
      return res.then((...result) => Promise.resolve(step(...result)))
    }, Promise.resolve(init(...args)))
  }
}
// 最后返回的是一个promise对象,我们在then方法中将最终值输出
let composeFunc = compose(...steps)
composeFunc(1, 2, 3).then((val) => {
  console.log(val)
})

JSONP

jsonp也是前端中时常出现的东西。它主要是为了解决获取数据跨域的问题。

原理是通过动态插入script标签来实现跨域,因为script脚本不受同源策略的限制。它由两部分组成:回调函数和数据

我们要编写一段代码,逻辑如下:

  • 我们的jsonp需要返回的是一个promise对象,这样更接近平常的ajax请求。
  • 处理请求地址和参数,拼接到同一字符串上,接着再定义一个方法作为回调,将方法名继续拼接上,最终形成请求地址。
  • 新建一个script标签,src属性即为需要请求数据的地址。
  • script标签加载完成时,返回的js内容包含一个执行函数,执行的就是我们传递的回调方法名,参数就是我们需要的数据
  • 最终在回调中执行promise.resolve,再销毁我们定义的回调函数即可。

直接上代码👇

function jsonp (url, data) {
  if (!url || !data) {
    return Promise.reject('参数缺失')
  }
  // 处理请求地址和参数
  const query = dataToUrl(data)
  const url = `${url}?${query}`
  return new Promise((resolve, reject) => {
    // 新建script标签
    const scriptE = document.createElement('script')
    // 回调函数方法名
    const cbFn = `jsonp${new Date()}`
    scriptE.src = `${url}callback=${cbFn}`
    const head = document.getElementsByTagName('head')[0]
    head.appendChild(scriptE)
    // 定义全局回调函数
    window[cbFn] = function (res) {
      res ? resolve(res) : reject('')
      head.removeChild(scriptE)
      window[cbFn] = null
    }
  })
}
function dataToUrl (data) {
  let result = ''
  for (let key in data) {
    if (data.hasOwnProperty(key)) {
      result += `${key}=${data[key]}&`
    }
  }
  return result
}

promise篇

promise也是面试过程中,很喜欢考察的知识点,我在另外的文章中写了更详细的手写过程。

会将一些列的promise知识点总结,并解析原理。

👇👇

白话文,轻松理解Promise原理,并实现(一)

白话文,轻松理解Promise原理,并实现(二)

结尾

这些便是我曾经在面试中遇到的,或在其他文章也高频讨论的问题,这里我进行了简单的汇总。也是想巩固自己的记忆,也希望分享出来,让大家对这些问题有更多的了解。共同进步,希望我们的努力都有收获吧。

后续如有看到更有意思的手写代码题,也会补充。其他同学有补充的可以提出,也希望能收获点意见,有个共同讨论的地方。