前端基础回顾之手写题

1,230 阅读9分钟

前言

本文还是依然针对前端重点基础知识点进行整体回顾系列的一篇,目标是帮助自己理解避免死记硬背。
下面针对new、Object.create、call、apply、new、bind 等基础API,从用法到原理实现过一遍,期望看完之后大家实现时不是死记硬背而是根据理解记忆推导。

基础准备

在探究上述内容原理之前,可以将上述API分为两类。
一类是new、Object.create这两者,涉及实例化对象的。

其对应的基础内容部分和上篇前端面试基础回顾之深入JS继承的基础部分相同。就是原型链和构造函数,这里不再赘述。

剩下的就是关于this指向的修改。
这里我们可以看下MDN中对this的描述
this由调用时环境确定,简单总结如下:

  1. 显式指定:
  • new 实例化

    this指向新构建的对象(new 显式返回一个对象,则this指向该返回对象,否则指向该对象实例)

    // 例如
    var bar = new foo()
    
  • bind、call、apply ,指向绑定对象

    var bar = foo.call( obj2 )
    
  1. 隐式指定:
  • 函数作为对象属性调用,即如object.func()形式,指向该对象。

    //指向obj1
     obj1.foo()
    
  1. 无指定 即不属于以上情况,为默认绑定。在strict mode下,就是undefined,否则是global对象。

    var fun1 = obj1.foo
    // this指向全局对象
    fun1()
    

这里顺便把this指向也给过了一遍,以后遇到this指向,再复杂的都可以按照这个规律进行判断。

既然call、apply、new、bind具备修改this指向的功能,那么具体如何实现,就是下面要讨论的内容。

手写实现

new

用法

new 用法比较常见,举个MDN例子:

  function Car(make, model, year) {
    this.make = make;
    this.model = model;
    this.year = year;
}

const car1 = new Car('Eagle', 'Talon TSi', 1993);

console.log(car1.make);
// expected output: "Eagle"

这里实例化了一个Car的实例对象car1,就不多说了。

分析

我们关注该方法功能是什么,然后由此推如何手写实现。
根据MDN的说法:

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

我们要实现的点主要也有两个:

  1. 实现一个新对象
  2. 继承F的属性
  3. 绑定this

如何实现上述两点,就用到我们的基础知识了。

  1. 新建对象,这个显然都会。
  2. 继承F的属性 这里说继承可能不如说赋值更好理解一些。
    对于一个构造函数来说,属性包括两部分实例属性和原型属性。
    新对象要继承其原型属性,修改原型链指向即可。
    继承实例属性,将构造函数F的this指向新对象,并执行一次就实现了对新对象的赋值。该过程顺便还实现了this的绑定。

结合该思路一起来看看实现思路

实现

初版实现:

// 1.首先声明函数my_new
function my_new(func){
    // 2. 新建对象
    var o = {}
    // 3. 修改原型链
    o._proto_ = func.prototype
    // 示例属性获取,并修改this
    func.call(o)
    // 返回对象
    return o
}

根据分析自然就实现了上面的代码。

不过new 还有个点分析时上面没有提到,由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。

假如构造函数返回了对象,那么需要进行判断func执行的结果是不是对象,不能直接返回执行结果。

// 1.首先声明函数my_new
function my_new(func){
    // 2. 新建对象
    var o = {}
    // 3. 修改原型链
    o._proto_ = func.prototype
    // 示例属性获取,并修改this
    // 获取构造函数执行结果,判断是否有显式返回。
    var res = func.call(o)
    // 视res类型决定返回对象
    return  typeof res === "object" ?res : o
}

到这里new 的实现就完成了。

Object.create

用法

该方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。即基于现有对象创建一个新的对象,直接看代码比较直接:

const person = {
  isHuman: false,
  printIntroduction: function () {
    console.log(`My name is ${this.name}. Am I human? ${this.isHuman}`);
  }
};

const me = Object.create(person);

me.name = "Matthew"; // "name" is a property set on "me", but not on "person"
me.isHuman = true; // inherited properties can be overwritten

me.printIntroduction();
// expected output: "My name is Matthew. Am I human? true"

分析

该方法的功能在于两点:

  1. 一个新对象
  2. 带有指定的原型对象和属性

结合上述,倒序来分析:

  1. 带有指定的原型对象和属性 这里比较特殊,因为原始对象不是构造函数,要继承其所有属性的话,还是要借助构造函数来实现,即原始对象person,作为新构造函数F的原型对象,新对象me是F的实例。
  2. 一个新对象 新的对象可以是字面量声明,也可以通过使用new来实例化。这里就是后者了。这也是倒序分析的原因。

实现

// 1. 声明函数
function create(Obj){
    // 2. 新建构造函数
    function F() {}
    // 3. 原型链修改
    F.prototype = Obj
    // 4.新建对象
    return new F()
}

至于ES6正式规范中还是可以第二个参数的情况暂时不补充,我也没有见到比较好的实现,大家可以补充。

call和apply

这两者用法和实现差别不大,就放一起分析了。

用法

采用W3C的例子

//call 用法
var person = {
    firstName:"Steve",
    lastName: "Jobs",
    fullName: function() {
        return this.firstName + " " + this.lastName;
    }
}
var person1 = {
    firstName:"Bill",
    lastName: "Gates",
}
person.fullName.call(person1);  //  "Bill Gates"
// apply 用法
person.fullName.apply(person1);  // 将返回 "Bill Gates"

这里没有体现出两者差别,差别在于传参的不同。

  • call() 方法分别接受参数。

  • apply() 方法接受数组形式的参数。

分析

call函数的功能有如下几点:

  • 改变函数中this指向
  • 获取后续参数则并执行

针对以上两点,主要在于如何改变this指向。

  • 回顾准备里面的内容,改变this指向的方法,除去显式的,我们也只剩下作为对象属性调用了。
    即将函数赋值给被调用对象,作为其属性方法执行,至于参数执行时调用就好。

不过这里有些点要注意

  • 我们给被调用对象增加属性,执行完毕之后还是要删除的,避免与其他操作。
  • 同样增加属性时,属性名也要注意避免冲突,最好直接使用Symbol

实现

call的实现:

// 函数
Function.prototype._call = function (ctx) {
    // 1. 构造被调用对象,兼容默认值
    var obj = ctx || window
    // 2. 获取后续参数
    var args = Array.from(arguments).slice(1)
    // 3. 获取唯一属性名
    var fun = Symbol()
    // 4. 增加属性方法,指向待调用函数
    obj[fun] = this
    var result = obj[fun](...args)
    // 5. 执行完毕后,删除该属性
    delete obj[fun]
    return result
}

apply实现与call很类似只是参数处理有些差别。

Function.prototype._apply = function (ctx) {
    var obj = ctx || window
    var args = Array.from(arguments).slice(1)
    var fun = Symbol()
    // 参数处理
    var result = obj[fun](args.join(','))
    delete obj[fun]
    return result
}

bind

用法

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

const module = {
  x: 42,
  getX: function() {
    return this.x;
  }
}

const unboundGetX = module.getX;
console.log(unboundGetX()); // The function gets invoked at the global scope
// expected output: undefined

const boundGetX = unboundGetX.bind(module);
console.log(boundGetX());// expected output: 42

分析

其功能分为如下几点:

  • 修改this指向到指定对象
  • 返回函数,可被后续执行
  • 参数处理,较简单

解决思路:

  • this指向 因为call等的实现,这里就可以偷懒了,使用apply来实现
  • 返回一个函数 常见的闭包形式
  • 参数处理 可能稍微复杂点的在于,执行时要考虑后续的参数拼接。

实现

初版实现:

Function.prototype._bind = function(ctx){
    // 1. 兼容判断
    var ctx = ctx || window
    // 2. 保留当前获取参数
    var args = Array.from(arguments).slice(1)
    var _this = this
    // 3. 返回函数
    return function F (arguments){
        //   4. 绑定this指向,拼接新增参数
        return _this.apply(ctx,args.concat(arguments))
    }
}

上述完成了初版的功能要求,但是bind还有一种情况,返回的毕竟是个函数,就可以当做构造函数与new 结合使用。

function Point(x, y) {
    this.x = x;
    this.y = y;
}

Point.prototype.toString = function () {
    return this.x + ',' + this.y;
};
var emptyObj = {};
var YAxisPoint = Point.bind(emptyObj, 0/*x*/);

var axisPoint = new YAxisPoint(5);
axisPoint.toString(); // '0,5' 此时this指向当前示例对象,而非emptyObj

这种场景下,为什么this指向了实例对象,主要是new 本身的功能体现。
而我们的api要支持new 的情况还是要结合new 的功能来看。

new 通过调用构造函数,产生了一个示例对象。主要是下面这段代码。

var res = func.call(o)

结合到我们的call中,此时func即为我们return 的F函数。 即此时函数中的this 为F的示例,由此可以区分两种场景。

Function.prototype._bind = function (ctx) {
    // 1. 兼容判断
    var ctx = ctx || window
    // 2. 保留当前获取参数
    var args = Array.from(arguments).slice(1)
    var _this = this
    // 3. 返回函数
    return function F(arguments) {
        // 4.判断是否new 场景
        if(this instanceof F){
            // 5. 此时直接执行构造函数
            return new _this(...args, ...arguments)
        }else{
            //   5. 常规场景,依然绑定this指向,拼接新增参数
            return _this.apply(ctx, args.concat(arguments))
        }
        
    }
}

结束语

到这里,几个简单的手写题就总结完毕了,上面的例子多出自MDN。当然上面的代码都存在一个问题就是对于异常的处理。这里就不列出了,大家可以自行补充。对于前面提到的js继承的基础,可以看我前面的文章。还是同样一句话共勉你我,你若盛开蝴蝶自来。