【JS 口袋書】第 8 章:以更細的角度來看 JS 中的 this

前端小智發表於2019-10-21

作者:valentinogagliardi

譯者:前端小智

來源:github


為了保證的可讀性,本文采用意譯而非直譯。

揭祕 "this"

JS 中的this關鍵字對於初學者來說是一個謎,對於經驗豐富的開發人員來說則是一個永恆的難題。this 實際上是一個移動的目標,在程式碼執行過程中可能會發生變化,而沒有任何明顯的原因。首先,看一下this關鍵字在其他程式語言中是什麼樣子的。 以下是 JS 中的一個 Person 類:

class Person {
  constructor(name) {
    this.name = name;
  }

  greet() {
    console.log("Hello " + this.name);
  }
}
複製程式碼

Python 類也有一個跟 this 差不多的東西,叫做self

class Person:
    def __init__(self, name):
        self.name = name
    
    def greet(self):
        return 'Hello' + self.name
複製程式碼

Python類中,self表示類的例項:即從類開始建立的新物件

me = Person('Valentino')
複製程式碼

PHP中也有類似的東西:

class Person {
    public $name; 

    public function __construct($name){
        $this->name = $name;
    }

    public function greet(){
        echo 'Hello ' . $this->name;
    }
 }
複製程式碼

這裡$this是類例項。再次使用JS類來建立兩個新物件,可以看到每當我們們呼叫object.name時,都會返回正確的名字:

class Person {
  constructor(name) {
    this.name = name;
  }

  greet() {
    console.log("Hello " + this.name);
  }
}

const me = new Person("前端小智");
console.log(me.name); // '前端小智'

const you = new Person("小智");
console.log(you.name); // '小智'
複製程式碼

JS 中類似乎類似於PythonJavaPHP,因為 this 看起來似乎指向實際的類例項?

這是不對的。我們們不要忘記JS不是一種物件導向的語言,而且它是寬鬆的、動態的,並且沒有真正的類。this與類無關,我們們可以先用一個簡單的JS函式(試試瀏覽器)來證明這一點:

function whoIsThis() {
  console.log(this);
}

whoIsThis();
複製程式碼

規則1:回到全域性“this”(即預設繫結)

如果在瀏覽器中執行以下程式碼

function whoIsThis() {
  console.log(this);
}

whoIsThis();
複製程式碼

輸出如下:

Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}
複製程式碼

如上所示,我們們當 this 沒有在任何類中的時候,this 仍然有值。當一個函式在全域性環境中被呼叫時,該函式會將它的this指向全域性物件,在我們們的例子中是window

這是JS的第一條規則,叫作預設繫結。預設繫結就像一個回退,大多數情況下它是不受歡迎的。在全域性環境中執行的任何函式都可能“汙染”全域性變數並破壞程式碼。考慮下面的程式碼:

function firstDev() {
  window.globalSum = function(a, b) {
    return a + b;
  };
}

function nastyDev() {
  window.globalSum = null;
}

firstDev();
nastyDev();
var result = firstDev();
console.log(result);

// Output: undefined
複製程式碼

第一個開發人員建立一個名為globalSum的全域性變數,併為其分配一個函式。接著,另一個開發人員將null分配給相同的變數,從而導致程式碼出現故障。

處理全域性變數總是有風險的,因此JS引入了**“安全模式”:嚴格模式。嚴格模式是通過使用“use Strict”啟用。嚴格模式中的一個好處就是消除了預設繫結**。在嚴格模式下,當試圖從全域性上下文中訪問this時,會得到 undefined

"use strict";

function whoIsThis() {
  console.log(this);
}

whoIsThis();

// Output: undefined
複製程式碼

嚴格的模式使JS程式碼更安全。

小結一下,預設繫結是JS中的第一條規則:當引擎無法找出this是什麼時,它會返回到全域性物件。接下看看另外三條規則。

規則2: 當“this”是宿主物件時(即隱式繫結)

“隱式繫結”是一個令人生畏的術語,但它背後的理論並不那麼複雜。它把範圍縮小到物件。

var widget = {
  items: ["a", "b", "c"],
  printItems: function() {
    console.log(this.items);
  }
};
複製程式碼

當一個函式被賦值為一個物件的屬性時,該物件就成為函式執行的宿主。換句話說,函式中的this將自動指向該物件。這是JS中的第二條規則,名為隱式繫結。即使在全域性上下文中呼叫函式,隱式繫結也在起作用

function whoIsThis() {
  console.log(this);
}

whoIsThis();
複製程式碼

我們們無法從程式碼中看出,但是JS引擎將該函式分配給全域性物件 window 上的一個新屬性,如下所示:

window.whoIsThis = function() {
  console.log(this);
};
複製程式碼

我們們可以很容易地證實這個假設。在瀏覽器中執行以下程式碼:

function whoIsThis() {
  console.log(this);
}

console.log(typeof window.whoIsThis)
複製程式碼

列印"function"。對於這一點你可能會問:在全域性函式中this 的真正規則是什麼?

像是預設繫結,但實際上更像是隱式繫結。有點令人困惑,但只要記住,JS引擎在在無法確定上下文(預設繫結)時總是返回全域性this。另一方面,當函式作為物件的一部分呼叫時,this 指向該呼叫的物件(隱式繫結)。

規則 3: 顯示指定 “this”(即顯式繫結)

如果不是 JS 使用者,很難看到這樣的程式碼:

someObject.call(anotherObject);
Someobject.prototype.someMethod.apply(someOtherObject);
複製程式碼

這就是顯式繫結,在 React 會經常看到這中繫結方式:

class Button extends React.Component {
  constructor(props) {
    super(props);
    this.state = { text: "" };
    // bounded method
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState(() => {
      return { text: "PROCEED TO CHECKOUT" };
    });
  }

  render() {
    return (
      <button onClick={this.handleClick}>
        {this.state.text || this.props.text}
      </button>
    );
  }
}
複製程式碼

現在React Hooks 使得類幾乎沒有必要了,但是仍然有很多使用ES6類的“遺留”React元件。大多數初學者會問的一個問題是,為什麼我們們要在 React 中通過bind` 方法重新繫結事件處理程式方法?

callapplybind 這三個方法都屬於Function.prototype。用於的顯式繫結(規則3):顯式繫結指顯示地將this繫結到一個上下文。但為什麼要顯式繫結或重新繫結函式呢?考慮一些遺留的JS程式碼:

var legacyWidget = {
  html: "",
  init: function() {
    this.html = document.createElement("div");
  },
  showModal: function(htmlElement) {
    var newElement = document.createElement(htmlElement);
    this.html.appendChild(newElement);
    window.document.body.appendChild(this.html);
  }
};
複製程式碼

showModal是繫結到物件legacyWidget的“方法”。this.html 屬於硬編碼,把建立的元素寫死了(div)。這樣我們們沒有辦法把內容附加到我們們想附加的標籤上。

解決方法就是可以使用顯式繫結this來更改showModal的物件。。現在,我們們可以建立一個小部件,並提供一個不同的HTML元素作附加的物件:

var legacyWidget = {
  html: "",
  init: function() {
    this.html = document.createElement("div");
  },
  showModal: function(htmlElement) {
    var newElement = document.createElement(htmlElement);
    this.html.appendChild(newElement);
    window.document.body.appendChild(this.html);
  }
};

var shinyNewWidget = {
  html: "",
  init: function() {
    // A different HTML element
    this.html = document.createElement("section");
  }
};
複製程式碼

接著,使用 call 呼叫原始的方法:

var legacyWidget = {
  html: "",
  init: function() {
    this.html = document.createElement("div");
  },
  showModal: function(htmlElement) {
    var newElement = document.createElement(htmlElement);
    this.html.appendChild(newElement);
    window.document.body.appendChild(this.html);
  }
};

var shinyNewWidget = {
  html: "",
  init: function() {
    this.html = document.createElement("section");
  }
};

// 使用不同的HTML元素初始化
shinyNewWidget.init();

// 使用新的上下文物件執行原始方法
legacyWidget.showModal.call(shinyNewWidget, "p");
複製程式碼

如果你仍然對顯式繫結感到困惑,請將其視為重用程式碼的基本模板。這種看起來有點繁瑣冗長,但如果有遺留的JS程式碼需要重構,這種方式是非常合適的。

此外,你可能想知道什麼是applybindapply具有與call相同的效果,只是前者接受一個引數陣列,而後者是引數列表。

var obj = {
  version: "0.0.1",
  printParams: function(param1, param2, param3) {
    console.log(this.version, param1, param2, param3);
  }
};

var newObj = {
  version: "0.0.2"
};

obj.printParams.call(newObj, "aa", "bb", "cc");
複製程式碼

apply需要一個引數陣列

var obj = {
  version: "0.0.1",
  printParams: function(param1, param2, param3) {
    console.log(this.version, param1, param2, param3);
  }
};

var newObj = {
  version: "0.0.2"
};

obj.printParams.apply(newObj, ["aa", "bb", "cc"]);
複製程式碼

那麼bind呢? bind 是繫結函式最強大的方法。bind仍然為給定的函式接受一個新的上下文物件,但它不只是用新的上下文物件呼叫函式,而是返回一個永久繫結到該物件的新函式。

var obj = {
  version: "0.0.1",
  printParams: function(param1, param2, param3) {
    console.log(this.version, param1, param2, param3);
  }
};

var newObj = {
  version: "0.0.2"
};

var newFunc = obj.printParams.bind(newObj);

newFunc("aa", "bb", "cc");
複製程式碼

bind的一個常見用例是對原始函式的 this 永久重新繫結:

var obj = {
  version: "0.0.1",
  printParams: function(param1, param2, param3) {
    console.log(this.version, param1, param2, param3);
  }
};

var newObj = {
  version: "0.0.2"
};

obj.printParams = obj.printParams.bind(newObj);

obj.printParams("aa", "bb", "cc");
複製程式碼

從現在起obj.printParams 裡面的 this 總是指向newObj。現在應該清楚為什麼要在 React 使用 bind來重新繫結類方法了吧。

class Button extends React.Component {
  constructor(props) {
    super(props);
    this.state = { text: "" };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState(() => {
      return { text: "PROCEED TO CHECKOUT" };
    });
  }

  render() {
    return (
      <button onClick={this.handleClick}>
        {this.state.text || this.props.text}
      </button>
    );
  }
}
複製程式碼

但現實更為微妙,與“丟失繫結”有關。當我們們將事件處理程式作為一個prop分配給React元素時,該方法將作為引用而不是函式傳遞,這就像在另一個回撥中傳遞事件處理程式引用:

// 丟失繫結
const handleClick = this.handleClick;

element.addEventListener("click", function() {
  handleClick();
});
複製程式碼

賦值操作會破壞了繫結。在上面的示例元件中,handleClick方法(分配給button元素)試圖通過呼叫this.setState()更新元件的狀態。當呼叫該方法時,它已經失去了繫結,不再是類本身:現在它的上下文物件是window全域性物件。此時,會得到"TypeError: Cannot read property 'setState' of undefined"的錯誤。

React元件大多數時候匯出為ES2015模組:this未定義的,因為ES模組預設使用嚴格模式,因此禁用預設繫結,ES6 的類也啟用嚴格模式。我們們可以使用一個模擬React元件的簡單類進行測試。handleClick呼叫setState方法來響應單擊事件

class ExampleComponent {
  constructor() {
    this.state = { text: "" };
  }

  handleClick() {
    this.setState({ text: "New text" });
    alert(`New state is ${this.state.text}`);
  }

  setState(newState) {
    this.state = newState;
  }

  render() {
    const element = document.createElement("button");
    document.body.appendChild(element);
    const text = document.createTextNode("Click me");
    element.appendChild(text);

    const handleClick = this.handleClick;

    element.addEventListener("click", function() {
      handleClick();
    });
  }
}

const component = new ExampleComponent();
component.render();
複製程式碼

錯誤的程式碼行是

const handleClick = this.handleClick;
複製程式碼

然後點選按鈕,檢視控制檯,會看到 ·"TypeError: Cannot read property 'setState' of undefined"·.。要解決這個問題,可以使用bind使方法繫結到正確的上下文,即類本身

  constructor() {
    this.state = { text: "" };
    this.handleClick = this.handleClick.bind(this);
  }
複製程式碼

再次單擊該按鈕,執行正確。顯式繫結比隱式繫結和預設繫結都更強。使用applycallbind,我們們可以通過為函式提供一個動態上下文物件來隨意修改它。

規則 4:"new" 繫結

建構函式模式,有助於用JS封裝建立新物件的行為:

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.greet = function() {
  console.log("Hello " + this.name);
};

var me = new Person("Valentino");
me.greet();

// Output: "Hello Valentino"
複製程式碼

這裡,我們們為一個名為“Person”的實體建立一個藍圖。根據這個藍圖,就可以通過“new”呼叫“構造”Person型別的新物件:

var me = new Person("Valentino");
複製程式碼

在JS中有很多方法可以改變 this 指向,但是當在建構函式上使用new時,this 指向就確定了,它總是指向新建立的物件。在建構函式原型上定義的任何函式,如下所示

Person.prototype.greet = function() {
  console.log("Hello " + this.name);
};
複製程式碼

這樣始終知道“this”指向是啥,因為大多數時候this指向操作的宿主物件。在下面的例子中,greet是由me的呼叫

var me = new Person("Valentino");
me.greet();

// Output: "Hello Valentino"
複製程式碼

由於me是通過建構函式呼叫構造的,所以它的含義並不含糊。當然,仍然可以從Person借用greet並用另一個物件執行它:

Person.prototype.greet.apply({ name: "Tom" });

// Output: "Hello Tom"
複製程式碼

正如我們們所看到的,this非常靈活,但是如果不知道this所依據的規則,我們們就不能做出有根據的猜測,也不能利用它的真正威力。長話短說,this是基於四個“簡單”的規則。

箭頭函式和 "this"

箭頭函式的語法方便簡潔,但是建議不要濫用它們。當然,箭頭函式有很多有趣的特性。首先考慮一個名為Post的建構函式。只要我們們從建構函式中建立一個新物件,就會有一個針對REST API的Fetch請求:

"use strict";

function Post(id) {
  this.data = [];

  fetch("https://jsonplaceholder.typicode.com/posts/" + id)
    .then(function(response) {
      return response.json();
    })
    .then(function(json) {
      this.data = json;
    });
}

var post1 = new Post(3);
複製程式碼

上面的程式碼處於嚴格模式,因此禁止預設繫結(回到全域性this)。嘗試在瀏覽器中執行該程式碼,會報錯:"TypeError: Cannot set property 'data' of undefined at :11:17"

這報錯做是對的。全域性變數 this 在嚴格模式下是undefined為什麼我們們的函式試圖更新 window.data而不是post.data?

原因很簡單:由Fetch觸發的回撥在瀏覽器中執行,因此它指向 window。為了解決這個問題,早期有個老做法,就是使用臨時亦是:“that”。換句話說,就是將this引用儲存在一個名為that的變數中:

"use strict";

function Post(id) {
  var that = this;
  this.data = [];

  fetch("https://jsonplaceholder.typicode.com/posts/" + id)
    .then(function(response) {
      return response.json();
    })
    .then(function(json) {
      that.data = json;
    });
}

var post1 = new Post(3);
複製程式碼

如果不用這樣,最簡單的做法就是使用箭頭函式:

"use strict";

function Post(id) {
  this.data = [];

  fetch("https://jsonplaceholder.typicode.com/posts/" + id)
    .then(response => {
      return response.json();
    })
    .then(json => {
      this.data = json;
    });
}

var post1 = new Post(3);
複製程式碼

問題解決。現在 this.data 總是指向post1。為什麼? 箭頭函式將this指向其封閉的環境(也稱“詞法作用域”)。換句話說,箭頭函式並不關心它是否在window物件中執行。它的封閉環境是物件post1,以post1為宿主。當然,這也是箭頭函式最有趣的用例之一。

總結

JS 中 this 是什麼? 這得視情況而定。this 建立在四個規則上:預設繫結、隱式繫結、顯式繫結和 “new”繫結。

隱式繫結表示當一個函式引用 this 並作為 JS 物件的一部分執行時,this 將指向這個“宿主”物件。但 JS 函式總是在一個物件中執行,這是任何全域性函式在所謂的全域性作用域中定義的情況。

在瀏覽器中工作時,全域性作用域是 window。在這種情況下,在全域性中執行的任何函式都將看到this 就是 window:它是 this 的預設繫結。

大多數情況下,不希望與全域性作用域互動,JS 為此就提供了一種用嚴格模式來中和預設繫結的方法。在嚴格模式下,對全域性物件的任何引用都是 undefined,這有效地保護了我們避免愚蠢的錯誤。

除了隱式繫結和預設繫結之外,還有“顯式繫結”,我們可以使用三種方法來實現這一點:applycallbind。 這些方法對於傳遞給定函式應在其上執行的顯式宿主物件很有用。

最後同樣重要的是“new”繫結,它在通過呼叫“建構函式”時在底層做了五處理。對於大多數開發人員來說,this 是一件可怕的事情,必須不惜一切代價避免。但是對於那些想深入研究的人來說,this 是一個強大而靈活的系統,可以重用 JS 程式碼。

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

原文:github.com/valentinoga…

交流

乾貨系列文章彙總如下,覺得不錯點個Star,歡迎 加群 互相學習。

github.com/qq449245884…

因為篇幅的限制,今天的分享只到這裡。如果大家想了解更多的內容的話,可以去掃一掃每篇文章最下面的二維碼,然後關注我們們的微信公眾號,瞭解更多的資訊和有價值的內容。

clipboard.png

每次整理文章,一般都到2點才睡覺,一週4次左右,挺苦的,還望支援,給點鼓勵

【JS 口袋書】第 8 章:以更細的角度來看 JS 中的 this

相關文章