[譯] 理解JS的函式呼叫和‘this’的指向

tomoya06發表於2019-02-07

原標題:Understanding JavaScript Function Invocation and "this",出自Yehuda的這篇部落格,是在Typescript的中文教程裡看到的。

JS的函式呼叫一直以來給不少人帶來疑惑,其中this的語義是人們抱怨的最多的。

在我看來,首先理解了函式呼叫的原始核心語法,然後弄清楚其他呼叫函式的語法糖,這些疑惑就能解決了。實際上這正式ECMA規範所設計的思路。在某種程度上,這篇文章是ECMA規範的簡化版,不過基本理念都是一樣的。

核心原始碼

首先來看JS函式呼叫的核心,Function類的call方法【1】。call方法的邏輯很直白:

  1. 把從第二個起的所有引數放進一個引數列表,如argList
  2. 把第一個引數定為thisValue
  3. 執行function,把this指向thisValueargList作為引數列表

例如:

function hello(thing) {
    console.log(this + " says hello " + thing);
}

hello.call("Yehuda", "world") //=> Yehuda says hello world
複製程式碼

可以看到,在執行hello函式時我們把this指向"Yehuda",傳入單個引數"world"。這就是JS函式呼叫的核心原始碼。你可以把其他的函式呼叫的語法都看成是這個原始碼的語法糖。

【1】在ES5規範中,call還是另一個更低層次的原始碼的語法糖,但包裝得並不複雜,所以在這裡直接簡化了。文末有更多資料。

簡單的函式呼叫

顯然每次都用call來呼叫函式太累贅了。JS允許我們直接使用括號來呼叫函式,如hello("world"),這個就是一個語法糖了:

function hello(thing) {
  console.log("Hello " + thing);
}

// this:
hello("world")

// desugars to:
hello.call(window, "world");
複製程式碼

在ES5的嚴格模式(strict mode)下,有一點小小的改動:【2】

// this:
hello("world")

// desugars to:
hello.call(undefined, "world");
複製程式碼

所以簡單來講,通過括號的函式呼叫fn(...args)等價於fn.call(window [ES5-strict: undefined], ...args)

要注意這對匿名函式來講也是成立的:(function() {})()等價於(function() {}).call(window [ES5-strict: undefined)

【2】實際上原作者說他撒了個小謊。ES5規範說的是給thisValue所繫結的幾乎都是undefined(The ECMAScript 5 spec says that undefined is (almost) always passed),但他認為不在嚴格模式時thisValue應該繫結到global物件。This allows strict mode callers to avoid breaking existing non-strict-mode libraries.

成員函式

另一個常見的場景是呼叫一個物件的成員函式,如person.hello()。這時候函式呼叫的語法糖分析如下:

var person = {
  name: "Brendan Eich",
  hello: function(thing) {
    console.log(this + " says hello " + thing);
  }
}

// this:
person.hello("world")

// desugars to this:
person.hello.call(person, "world");
複製程式碼

要注意,無論hello函式是如何新增到這個物件的,效果都是一樣的,記得事先宣告一個獨立的hello函式即可。現在來看下把hello函式動態新增到某個物件,呼叫起來是什麼效果:

function hello(thing) {
  console.log(this + " says hello " + thing);
}

person = { name: "Brendan Eich" }
person.hello = hello;

person.hello("world") // still desugars to person.hello.call(person, "world")

hello("world") // "[object DOMWindow]world"
複製程式碼

注意到函式對this的指向不是恆定不變的,每次都是根據呼叫函式方法的不同來執行不同的繫結。

使用Function.prototype.bind

有時候會想讓一個函式始終保持相同的this指向,開發者會使用閉包來實現這個目的:

var person = {
  name: "Brendan Eich",
  hello: function(thing) {
    console.log(this.name + " says hello " + thing);
  }
}

var boundHello = function(thing) { 
    return person.hello.call(person, thing); 
}

boundHello("world");
複製程式碼

儘管boundHello("world")最終會解析成boundHello.call(window, "world"),但之前的操作已經把this繫結回我們想要的物件了。

我們還可以把這樣的轉換封裝成通用模組:

var bind = function(func, thisValue) {
  return function() {
    return func.apply(thisValue, arguments);
  }
}

var boundHello = bind(person.hello, person);
boundHello("world") // "Brendan Eich says hello world"
複製程式碼

要理解這段程式碼,你只需直到另外兩個資訊:首先,arguments是一個類陣列物件,表示所有傳給這個函式的物件;其次,apply的作用和call類似,但前者一次接收一個類陣列物件作為傳參,後者接收多個引數。

這裡的bind函式簡單返回一個新的函式。在呼叫bind()時,它又會呼叫之前傳參進去的函式,並且把後者的this繫結到第二個引數。

因為這種用法也很常見,所以ES5引入了一個新的方法bind,適用於所有Function類物件,效果如下:

var boundHello = person.hello.bind(person);
boundHello("world") // "Brendan Eich says hello world"
複製程式碼

如果你需要寫一個(帶有this,但其指向有特定需要的)回撥函式,這種寫法就很有用:

var person = {
  name: "Alex Russell",
  hello: function() { console.log(this.name + " says hello world"); }
}

$("#some-div").click(person.hello.bind(person));

// when the div is clicked, "Alex Russell says hello world" is printed
複製程式碼

當然這種寫法還是有點拗手。TC39(ECMAScript標準制定委員會)還在努力尋找一種更優雅的解決方案。

jQuery

本段不做翻譯。

後記(編者作)

有好幾處的描述我對原本的規範描述做了簡化,其中最關鍵的一點是我把func.call稱為原始碼(primitive)。實際上在規範裡,function.call[obj.]func()還有更深一層的原原始碼。

但來看一下ES5標準中func.call的宣告(譯者:即ES5標準裡Function.prototype.call (thisArg [ , arg1 [ , arg2, … ] ] )的描述):

  1. 如果IsCallable(func)false,丟擲TypeError錯誤
  2. argList初始化為空陣列
  3. 如果這個函式傳入了多個引數,將這些引數從左往右加入到argList,從arg1開始標記
  4. 返回撥用func內建方法[[call]]的結果,呼叫時把thisArg賦給this,傳入argList作為引數佇列

可以看到,這只是一段很簡單的繫結到[[call]]操作的JS程式碼。

如果你去看呼叫函式的定義,頭7步都是在初始化thisValueargList,最後一步是“返回撥用func內建方法[[call]]的結果,呼叫時把thisArg賦給this,傳入argList作為引數佇列(Return the result of calling the [[Call]] internal method on func, providing thisValue as the this value and providing the list argList as the argument values.)”

一旦argListthisValue準備好之後,之後的工作原理都是一樣的了。

所以我偷了個小懶,但我把ES5規範裡的描述都拎出來了,他們的意義都是一樣的。

還有一些其他用法,比如跟with相關的用法,在這裡我沒有涉及。

相關文章