1. 程式人生 > >前端基礎回顧之手寫題

前端基礎回顧之手寫題

前言

本文還是依然針對前端重點基礎知識點進行整體回顧系列的一篇,目標是幫助自己理解避免死記硬背。
下面針對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繼承的基礎,可以看我前面的文章。還是同樣一句話共勉你我,你若盛開蝴蝶自來