?徹底弄清 this call apply bind 以及原生實現

JS菌發表於2019-03-07

有關 JS 中的 this、call、apply 和 bind 的概念網路上已經有很多文章講解了 這篇文章目的是梳理一下這幾個概念的知識點以及闡述如何用原生 JS 去實現這幾個功能

this 指向問題

this

this 的指向在嚴格模式和非嚴格模式下有所不同;this 究竟指向什麼是,在絕大多數情況下取決於函式如何被呼叫

全域性執行環境的情況:

非嚴格模式下,this 在全域性執行環境中指向全域性物件(window、global、self);嚴格模式下則為 undefined

20190306083121.png

作為物件方法的呼叫情況:

假設函式作為一個方法被定義在物件中,那麼 this 指向最後呼叫他的這個物件

比如:

a = 10
obj = {
    a: 1,
    f() {
        console.log(this.a) // this -> obj
    }
}

obj.f() // 1 最後由 obj 呼叫
複製程式碼

obj.f() 等同於 window.obj.f() 最後由 obj 物件呼叫,因此 this 指向這個 obj

即便是這個物件的方法被賦值給一個變數並執行也是如此:

const fn = obj.f
fn() // 相當於 window.fn() 因此 this 仍然指向最後呼叫他的物件 window
複製程式碼

20190306084716.png

call apply bind 的情況:

想要修改 this 指向的時候,我們通常使用上述方法改變 this 的指向

a = 10
obj = {
	a: 1
}
function fn(...args) {
	console.log(this.a, 'args length: ', args)
}

fn.call(obj, 1, 2)
fn.apply(obj, [1, 2])
fn.bind(obj, ...[1, 2])()
複製程式碼

20190306090239.png

可以看到 this 全部被繫結在了 obj 物件上,列印的 this.a 也都為 1

new 操作符的情況:

new 操作符原理實際上就是建立了一個新的例項,被 new 的函式被稱為建構函式,建構函式 new 出來的物件方法中的 this 永遠指向這個新的物件:

a = 10
function fn(a) { this.a = a }
b = new fn(1)
b.a // 1
複製程式碼

20190306090716.png

箭頭函式的情況:

  • 普通函式在執行時才會確定 this 的指向
  • 箭頭函式則是在函式定義的時候就確定了 this 的指向,此時的 this 指向外層的作用域
a = 10
fn = () => { console.log(this.a) }
obj = { a: 20 }
obj.fn = fn
obj.fn()
window.obj.fn()
f = obj.fn
f()
複製程式碼

20190306091151.png

無論如何呼叫 fn 函式內的 this 永遠被固定在了這個外層的作用域(上述例子中的 window 物件)

this 改變指向問題

如果需要改變 this 的指向,有以下幾種方法:

  • 箭頭函式
  • 內部快取 this
  • apply 方法
  • call 方法
  • bind 方法
  • new 操作符

箭頭函式

普通函式

a = 10
obj = {
    a: 1,
    f() { // this -> obj
		function g() { // this -> window
        	console.log(this.a)
    	}
		g()
	}
}

obj.f() // 10
複製程式碼

在 f 函式體內 g 函式所在的作用域中 this 的指向是 obj:

20190306094032.png

在 g 函式體內,this 則變成了 window:

20190306094118.png

改為箭頭函式

a = 10
obj = {
    a: 1,
    f() { // this -> obj
		const g = () => { // this -> obj
        	console.log(this.a)
    	}
		g()
	}
}

obj.f() // 1
複製程式碼

在 f 函式體內 this 指向的是 obj:

20190306094446.png

在 g 函式體內 this 指向仍然是 obj:

20190306094528.png

內部快取 this

這個方法曾經經常用,即手動快取 this 給一個名為 _thisthat 等其他變數,當需要使用時用後者代替

a = 10
obj = {
    a: 20,
    f() {
        const _this = this
        setTimeout(function() {
            console.log(_this.a, this.a)
        }, 0)
    }
}

obj.f() // _this.a 指向 20 this.a 則指向 10
複製程式碼

20190306095926.png

檢視一下 this 和 _this 的指向,前者指向 window 後者則指向 obj 物件:

20190307081510.png

call

call 方法第一個引數為指定需要繫結的 this 物件;其他引數則為傳遞的值:

20190306100658.png

需要注意的是,第一個引數如果是:

  • null、undefined、不傳,this 將會指向全域性物件(非嚴格模式下)
  • 原始值將被轉為對應的包裝物件,如 f.call(1) this 將指向 Number,並且這個 Number 的 [[PrimitiveValue]] 值為 1
obj = {
    name: 'obj name'
}

{(function() {
    console.log(this.name)
}).call(obj)}
複製程式碼

20190306103718.png

apply

與 call 類似但第二個引數必須為陣列:

obj = {
    name: 'obj name'
}

{(function (...args){
	console.log(this.name, [...args])
}).apply(obj, [1, 2, 3])}
複製程式碼

20190306104048.png

bind

比如常見的函式內包含一個非同步方法:

function foo() {
	let _this = this // _this -> obj
	setTimeout(function() {
		console.log(_this.a) // _this.a -> obj.a
	}, 0)
}
obj = {
	a: 1
}
foo.call(obj) // this -> obj
// 1
複製程式碼

我們上面提到了可以使用快取 this 的方法來固定 this 指向,那麼使用 bind 程式碼看起來更加優雅:

function foo() { // this -> obj
	setTimeout(function () { // 如果不使用箭頭函式,則需要用 bind 方法繫結 this
		console.log(this.a) // this.a -> obj.a
	}.bind(this), 100)
}
obj = {
	a: 1
}

foo.call(obj) // this -> obj
// 1
複製程式碼

或者直接用箭頭函式:

function foo() { // this -> obj
	setTimeout(() => { // 箭頭函式沒有 this 繼承外部作用域的 this
		console.log(this.a) // this.a -> obj.a
	}, 100)
}
obj = {
	a: 1
}

foo.call(obj) // this -> obj
// 1
複製程式碼

20190307082854.png

new 操作符

new 操作符實際上就是生成一個新的物件,這個物件就是原來物件的例項。因為箭頭函式沒有 this 所以函式不能作為建構函式,建構函式通過 new 操作符改變了 this 的指向。

function Person(name) {
	this.name = name // this -> new 生成的例項
}
p = new Person('oli')
console.table(p)
複製程式碼

this.name 表明了新建立的例項擁有一個 name 屬性;當呼叫 new 操作符的時候,建構函式中的 this 就繫結在了例項物件上

20190306230406.png

原生實現 call apply bind new

文章上半部分講解了 this 的指向以及如何使用 call bind apply 方法修改 this 指向;文章下半部分我們用 JS 去自己實現這三種方法

myCall

  • 首先 myCall 需要被定義在 Function.prototype 上這樣才能在函式上呼叫到自定義的 myCall 方法
  • 然後定義 myCall 方法,該方法內部 this 指向的就是 myCall 方法被呼叫的那個函式
  • 其次 myCall 第一個引數物件中新增 this 指向的這個方法,並呼叫這個方法
  • 最後刪除這個臨時的方法即可

程式碼實現:

Function.prototype.myCall = function(ctx) {
	ctx.fn = this
	ctx.fn()
	delete ctx.fn
}
複製程式碼

20190306233008.png

最基本的 myCall 就實現了,ctx 代表的是需要繫結的物件,但這裡有幾個問題,如果 ctx 物件本身就擁有一個 fn 屬性或方法就會導致衝突。為了解決這個問題,我們需要修改程式碼使用 Symbol 來避免屬性的衝突:

Function.prototype.myCall = function(ctx) {
	const fn = Symbol('fn') // 使用 Symbol 避免屬性名衝突
	ctx[fn] = this
	ctx[fn]()
	delete ctx[fn]
}
obj = { fn: 'functionName' }
function foo() { console.log(this.fn) }

foo.myCall(obj)
複製程式碼

20190306233305.png

同樣的,我們還要解決引數傳遞的問題,上述程式碼中沒有引入其他引數還要繼續修改:

Function.prototype.myCall = function(ctx, ...argv) {
	const fn = Symbol('fn')
	ctx[fn] = this
	ctx[fn](...argv) // 傳入引數
	delete ctx[fn]
}
obj = { fn: 'functionName', a: 10 }
function foo(name) { console.log(this[name]) }

foo.myCall(obj, 'fn')
複製程式碼

20190306233625.png

另外,我們還要檢測傳入的第一個值是否為物件:

Function.prototype.myCall = function(ctx, ...argv) {
	ctx = typeof ctx === 'object' ? ctx || window : {} // 當 ctx 是物件的時候預設設定為 ctx;如果為 null 則設定為 window 否則為空物件
	const fn = Symbol('fn')
	ctx[fn] = this
	ctx[fn](...argv)
	delete ctx[fn]
}
obj = { fn: 'functionName', a: 10 }
function foo(name) { console.log(this[name]) }

foo.myCall(null, 'a')
複製程式碼

如果 ctx 為物件,那麼檢查 ctx 是否為 null 是則返回預設的 window 否則返回這個 ctx 物件;如果 ctx 不為物件那麼將 ctx 設定為空物件(按照語法規則,需要將原始型別轉化,為了簡單說明原理這裡就不考慮了)

執行效果如下:

20190306235453.png

這麼一來自定義的 myCall 也就完成了

另外修改一下檢測 ctx 是否為物件可以直接使用 Object;delete 物件的屬性也可改為 ES6 的 Reflect:

Function.prototype.myCall = function(ctx, ...argv) {
	ctx = ctx ? Object(ctx) : window
	const fn = Symbol('fn')
	ctx[fn] = this
	ctx[fn](...argv)
	Reflect.deleteProperty(ctx, fn) // 等同於 delete 操作符
	return result
}
複製程式碼

myApply

apply 效果跟 call 類似,將傳入的陣列通過擴充套件操作符傳入函式即可

Function.prototype.myApply = function(ctx, argv) {
	ctx = ctx ? Object(ctx) : window
	// 或者可以鑑別一下 argv 是不是陣列
	const fn = Symbol('fn')
	ctx[fn] = this
	ctx[fn](...argv)
	Reflect.deleteProperty(ctx, fn) // 等同於 delete 操作符
	return result
}
複製程式碼

myBind

bind 與 call 和 apply 不同的是,他不會立即呼叫這個函式,而是返回一個新的 this 改變後的函式。根據這一特點我們寫一個自定義的 myBind:

Function.prototype.myBind = function(ctx) {
	return () => { // 要用箭頭函式,否則 this 指向錯誤
		return this.call(ctx)
	}
}
複製程式碼

20190307224718.png

這裡需要注意的是,this 的指向原因需要在返回一個箭頭函式,箭頭函式內部的 this 指向來自外部

然後考慮合併接收到的引數,因為 bind 可能有如下寫法:

f.bind(obj, 2)(2)
// or
f.bind(obj)(2, 2)
複製程式碼

修改程式碼:

Function.prototype.myBind = function(ctx, ...argv1) {
	return (...argv2) => {
		return this.call(ctx, ...argv1, ...argv2)
	}
}
複製程式碼

20190307225732.png

另外補充一點,bind 後的函式還有可能會被使用 new 操作符建立物件。因此 this 理應被忽略但傳入的引數卻正常傳入。

舉個例子:

obj = {
    name: 'inner' // 首先定義一個包含 name 屬性的物件
}
function foo(fname, lname) { // 然後定義一個函式
	this.fname = fname
	console.log(fname, this.name, lname) // 列印 name 屬性
}
foo.prototype.age = 12
複製程式碼

然後我們使用 bind 建立一個新的函式並用 new 呼叫返回新的物件:

boundf = foo.bind(obj, 'oli', 'young')
newObj = new boundf()
複製程式碼

20190311095410.png

看圖片得知,儘管我們定義了 obj.name 並且使用了 bind 方法繫結 this 但因使用了 new 操作符 this 被重新繫結在了 newObj 上。因此列印出來的 this.name 就是 undefined 了

因此我們還要繼續修改我們的 myBind 方法:

Function.prototype.myBind = function (ctx, ...argv1) {
	let _this = this
	let boundFunc = function (...argv2) { // 這裡不能寫成箭頭函式了,因為要使用 new 操作符會報錯
		return _this.call(this instanceof boundFunc ? this : ctx, ...argv1, ...argv2) // 檢查 this 是否為 boundFunc 的例項
	}
	return boundFunc
}
複製程式碼

然後我們使用看看效果如何:

20190311100213.png

this 指向問題解決了但 newObj 例項並未繼承到繫結函式原型中的值,因此還要解決這個問題,那麼我們直接修改程式碼增加一個 prototype 的連線:

Function.prototype.myBind = function (ctx, ...argv1) {
	let _this = this
	let boundFunc = function (...argv2) {
		return _this.call(this instanceof boundFunc ? this : ctx, ...argv1, ...argv2)
	}
	boundFunc.prototype = this.prototype // 連線 prototype 繼承原型中的值
	return boundFunc
}
複製程式碼

20190311100453.png

看起來不錯,但還是有一個問題,嘗試修改 boundf 的原型:

20190311103407.png

發現我們的 foo 中原型的值也被修改了,因為直接使用 = 操作符賦值,其實本質上還是原型的值,最後我們再修改一下,使用一個空的函式來重新 new 一個:

Function.prototype.myBind = function (ctx, ...argv1) {
	let _this = this
	let temp = function() {} // 定義一個空的函式
	let boundFunc = function (...argv2) {
		return _this.call(this instanceof temp ? this : ctx, ...argv1, ...argv2)
	}
	temp.prototype = this.prototype // 繼承繫結函式原型的值
	boundFunc.prototype = new temp() // 使用 new 操作符建立例項並賦值
	return boundFunc
}
複製程式碼

最後看下效果:

20190311103534.png

new 操作符

最後我們再來實現一個 new 操作符名為 myNew

new 操作符的原理是啥:

  • 生成新的物件
  • 繫結 prototype (既然是 new 一個例項,那麼例項的 __proto__ 必然要與建構函式的 prototype 相連線)
  • 繫結 this
  • 返回這個新物件

程式碼實現:

function myNew(Constructor) { // 接收一個 Constructor 建構函式
	let newObj = {} // 建立一個新的物件
	newObj.__proto__ = Constructor.prototype // 繫結物件的 __proto__ 到建構函式的 prototype
	Constructor.call(newObj) // 修改 this 指向
	return newObj // 返回這個物件
}
複製程式碼

20190307232044.png

然後考慮傳入引數問題,繼續修改程式碼:

function myNew(Constructor, ...argv) { // 接收引數
	let newObj = {}
	newObj.__proto__ = Constructor.prototype
	Constructor.call(newObj, ...argv) // 傳入引數
	return newObj
}
複製程式碼

20190307232419.png

小結

到此為止

  • this 指向問題
  • 如何修改 this
  • 如何使用原生 JS 實現 call apply bind 和 new 方法

再遇到類似問題,基本常見的情況都能應付得來了

(完)

參考:

  • https://juejin.im/post/59bfe84351882531b730bac2#heading-1
  • https://segmentfault.com/a/1190000015438195#articleHeader3
  • https://github.com/Abiel1024/blog/issues/16
  • 感謝 webgzh907247189 修改了一些程式碼實現

?徹底弄清 this call apply bind 以及原生實現

相關文章