手撕JS原生代碼

960 阅读9分钟

最近看到有些朋友去大厂面试时,面试官几乎都要求手撕原生代码 (真的手撕哦,不是仅仅懂原理)。这年头不会手撕原生代码,算法,都不好意思叫自己程序员 (=_=),揾食艰难。我自己有些慌了,所以把我所知的记录下来。

call

call是Function的一个原型方法,可以改变传入函数的this的指向。具体实现方法如下:

Function.prototype.mycall = function (context, ...args) {
    context.fn = this
    context.fn(...args)
    context.fn = null
}

要点是为传入的上下文添加fn属性,以让它调用。在JS里,谁调用,this就指向谁。context.fn() 里的this是指向context,如果是 context.subcontext.fn(),this是指向subcontext。

apply

apply也是类似的方法,只是apply传入的参数要求是数组。如果用...args可以把数组解构,这样的话实现方法与call是基本一样。

Function.prototype.mycall = function (context, args) {
    context.fn = this
    context.fn(...args)
    context.fn = null
}

new

在实现bind之前,先研究new的实现,因为接下来的bind需要了解它的原理。当new 一个函数 (类)时,它做了什么?根据MDN,new的时候会发生以下事情:

当代码 new Foo(...) 执行时,会发生以下事情:

  1. 一个继承自 Foo.prototype 的新对象被创建。
  2. 使用指定的参数调用构造函数 Foo,并将 this 绑定到新创建的对象。new Foo 等同于 new Foo(),也就是没有指定参数列表,Foo 不带任何参数调用的情况。
  3. 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是用户可以选择主动返回对象,来覆盖正常的对象创建步骤)

通过上述内容,大概可以知道实现方法。

function mynew (fn, ...args) {
    obj = {}
    const result = fn.call(obj, ...args)
    obj.__proto__ = fn.prototype
    return result !== undefined ? result : obj
}

bind

bind的方法就有点不同。它是返回一个函数,这个函数的this指向传入的上下文,还有保存之前的参数。

先把上述要求实现吧。既然要保存传入的参数,自然要利用闭包

Function.prototype.mybind = function (context, ...firstargs) {
    const self = this
    return function (...args) {
        firstargs.concat(args)
        self.call(context, ...firstargs.concat(args))
    }
}

不过还要考虑bind的一个特性,就是

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

上述是MDN的官方描述。举个例:

const obj = {a: "12",b: "54", z: "hello world"}

function bind_demo (s) {
    console.log (this.z)
    this.s = s
}

const bind_fn = bind_demo.bind(obj, "added")
bind_fn()  // hello world
console.log(obj) // { a: '12', b: '54', z: 'hello world', s: 'added' }
const new_obj = new bind_fn() // undefined
console.log(new_obj)  // bind_demo { s: 'added' }
console.log(new_obj instanceof bind_fn) // true

如果直接调用bind后的函数,结果如我们所想的一样。然而当new了bind_fn后,情况发生变化,console.log (this.z)的结果成了undefined,因为这时的this不是指向obj了,而是new所创建的对象,它是bind_demo的实例。所以应当添加一个判断,判断this是指向new创建的类,还是传入的上下文context。

Function.prototype.mybind = function (context, ...firstargs) {
    const self = this
    const fn_bind =  function (...args) {
       firstargs.concat(args)
       self.call(this instanceof fn_bind ? this : context, ...firstargs.concat(args))
    }
    fn_bind.prototype = self.prototype
    return fn_bind
}

别忘了,最后还要考虑继承函数的原型。

数组拍平

关于数组拍平,我第一时间想到的是用递归来做:

Array.prototype.myflatten = function () {
    return recursiveflat (this, [])
}

function recursiveflat (arr, result) {
    if (!Array.isArray(arr)) {
        throw new Error ("The first parameter is not array.")
    }

    arr.forEach (item => {
        if (Array.isArray(item)) {
            recursiveflat (item, result)
        } else {
            result.push (item)
        }
    })
    return result
}

不过能否不用递归呢? 这样效率会更高。可以利用some验测数组有没有数组元素,如果有,利用apply把传入的数组拍平一层,然后把数组与空数组合并。

function flat(arr){
     while(arr.some(item => Array.isArray(item))){
        arr = [].concat.apply([],arr); // apply会把传入的数组转换为参数
     }
     return arr;
}
var arr = [1,2,[3,4,5,[6,7,8],9],10,[11,12]];
flat(arr);

深拷贝

这个真的要用递归才能解决。深拷贝的代码实现不难,难是难在把所有情况考虑。一步一步实现吧。

先从浅拷贝着手:

function shallowClone (obj) {
    const cloneObj = {}
    const objKey = Object.keys(obj)

    objKey.forEach (item => {
        if (Object.prototype.hasOwnProperty.call(obj, item)) {
            cloneObj[item] = obj[item]
        }
    })
    return cloneObj
}

深拷贝只是在浅拷贝的基础上加上递归:

function deepClone (obj) {
    const cloneObj = {}
    const objKey = Object.keys(obj)

    objKey.forEach (item => {
        if (Object.prototype.hasOwnProperty.call(obj, item)) {
            if (typeof obj[item] === 'object') {
                cloneObj[item] = deepClone(obj[item])
            } else {
                cloneObj[item] = obj[item]
            }
        }
    })
    return cloneObj
}

不过还是有些问题:

  1. 由于 typeof null是 object,所以还要考虑null的问题
  2. 没有考虑数组

我这里参考 木易杨 大神的写法,详细可以阅读他的实现:

【进阶4-3期】面试题之如何实现一个深拷贝

首先先解决null的问题,先封装一个真正的判断object的方法。

function isObject(obj) {
	return typeof obj === 'object' && obj != null;
}

然后开始真正实现深拷贝:

function isObject(obj) {
	return typeof obj === 'object' && obj != null;
}

function deepClone (obj) {
    if(!isObject (obj)) {
        return obj 
    }

    const cloneObj = Array.isArray(obj) ? [] : {}
    
    for (let item in obj) {
        if (Object.prototype.hasOwnProperty.call(obj, item)) {
            if (isObject (obj[item])) { // 数组也包含在内
                cloneObj[item] = deepClone(obj[item])
            } else {
                cloneObj[item] = obj[item]
            }
        }
    }
    return cloneObj
}

柯里化

了解柯里化前,得要知道函数式编程。

所谓函数式编程是一种编程思想,简单来说,就是把程序里的函数封装成 纯函数,纯函数就是应用数学的函数概念:函数即映射。

function

因此往纯函数传入的参数,返回结果都是相同,好像平时做数学题,往函数代入某个数,出来的结果也是相同的。这样做有什麽好处?

最直观的结果就是可预测,不会如非函数般,每次调用的结果可能不同,例如栈stack的pop方法,从而使得程序更加健壮,更容易测试。还有就是写出来的程序更接近自然语言,例如:

operate (substract (3), add (4), multiply (10), 7)

即使不懂程序,很容易推断出这是做一系列运算的程序。

函数式编程的介绍就到这里,终于可以正式谈柯里化。 柯里化

引用维基百科的定义:

在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

可以看一下lodash里柯里化函数的用法:

const _ = require('lodash')

// 要柯里化的函数
function getSum (a, b, c) { return a + b + c }
// 柯里化后的函数
let curried = _.curry(getSum)
// 测试
curried(1, 2, 3)
curried(1)(2)(3)
curried(1, 2)(3)

三者结局是一样的,第一个curried直接执行,其馀两个则是缓存参数后再执行。

我们可以利用闭包实现一个柯里化函数:

function currying (fn) {
    return function curryfn (...args) {
        if (fn.length > args.length) {
            return function () {
                return curryfn (...args.concat (Array.from (arguments)))
            }
        }
        return fn (...args)
    }
}

memorize

既然纯函数的返回结果是可预测,可以利用缓存,把之前传入参数的返回结果存下来,如果之后传入相同参数,则直接返回结果,不用调用函数。 Vue的computed就是运用这原理的。

function memorize (fn) {
    const cache = {}

    return function (arg) {
        if (cache[arg]) {
            return cache[arg]
        }

        cache[arg] = fn (arg)
        return cache[arg]
    }
}

函数组合

可以利用纯函数和柯里化的特点,把函数组合。通常组合的函数是从右至左执行。组合的实现如下:

function flowRight (...args) {
    return function (initValue) {
        return args.reverse().reduce ((acc, fn) => fn (acc), initValue)
    }
}

const toUpper = s => s.toUpperCase()
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const f = flowRight(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))  // THREE

防抖与节流

这两个概念很容易混淆,因为它们要实现的功能都很相似,都是为了防止用户在一段时间内频繁调用函数,不同之处是防抖是指定某个时间点调用函数,如果在该时间点前再次调用,则取消之前的调用函数。

节流则是调用函数后,在一定时间内不许再次调用。

这里参考朱德龙老师在前端高手进阶的写法,他的写法是我见过最全面的,详情可阅读 3 个使用场景助你用好 DOM 事件

先把防抖实现吧。通常网上的写法是:

function deBounce (fn, wait = 0) {
  let timeout = null

  return function deBounced (...args) {
      if (timeout) {
          clearTimeout (timeout)
          timeout = null
      }
      setTimeout (fn(...args), wait)
  }
}

这样写也没错,不过没有考虑

  1. 函数之后是否需要回调其他函数
  2. 是否需要手动直接调用
  3. 取消调用函数

朱德龙老师的代现实现是:

const debounce = (func, wait = 0) => {
  let timeout = null
  let args

  function debounced(...arg) {
    args = arg
    if(timeout) {
      clearTimeout(timeout)
      timeout = null 
    }

    // 以Promise的形式返回函数执行结果
    return new Promise((res, rej) => {
      timeout = setTimeout(async () => {
        try {
          const result = await func.apply(this, args)
          res(result)
        } catch(e) {
          rej(e)
        }
      }, wait)
    })
  }

  // 允许取消
  function cancel() {
    clearTimeout(timeout)
    timeout = null
  }

  // 允许立即执行
  function flush() {
    cancel()
    return func.apply(this, args)
  }

  debounced.cancel = cancel
  debounced.flush = flush

  return debounced 
}

面试应该不会考得这么细,不过值得学习一下。

函数节流主要有两种实现方法:时间戳和定时器。时间戳就是规定时间内只能调用一次,在该时间内再次调用无效。

时间戳:

function throttle1 (fn, wait = 0) {
   let lastTime = new Date().getTime()
   return function (...args) {
       if (lastTIme - new Date().getTime() >= wait) {
           lastTime = new Date().getTime()
           fn.apply (fn, args)
       }
   }
} 

定时器指的是规定时间只能调用一次函数,如果规定时间内再次调用,则把它放在下一个规定时间调用。代码实现如下:

const throttle = (func, wait = 0) => {
 let timeout = null
 let args
 let firstCallTimestamp

 function throttled(...arg) {
   if (!firstCallTimestamp) firstCallTimestamp = new Date().getTime()
   if (!args) {
     console.log('set args:', arg)
     args = arg
   }

   if (timeout) {
     clearTimeout(timeout)
     timeout = null
   }

   // 以Promise的形式返回函数执行结果
   return new Promise(async(res, rej) => {
     if (new Date().getTime() - firstCallTimestamp >= wait) {
       try {
         const result = await func.apply(this, args)
         res(result)
       } catch (e) {
         rej(e)
       } finally {
         cancel()
       }
     } else {
       timeout = setTimeout(async () => {
         try {
           const result = await func.apply(this, args)
           res(result)
         } catch (e) {
           rej(e)
         } finally {
           cancel()
         }
       }, firstCallTimestamp + wait - new Date().getTime())  // 计算下一个指定时间
     }
   })
 }

 // 允许取消
 function cancel() {
   clearTimeout(timeout)
   args = null
   timeout = null
   firstCallTimestamp = null
 }

 // 允许立即执行
 function flush() {
   cancel()
   return func.apply(this, args)
 }

 throttled.cancel = cancel
 throttled.flush = flush

 return throttled
}

Promise

这个我之前写过,放过链接:

Promise的实现原理