最近在写【重拾前端】系列,下面有几个快速通道,大家自取
前言
其实说起this,这个几乎是前端面试必考题,也是前端最多“脑经急转弯”的地方,也是让无数前端人烦恼的地方。今天我们就彻底的深入this,全面的攻破它!
绑定规则
我们来看看在函数的执行过程中调用位置如何决定 this 的绑定对象。
你必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。我们首先会分别解释 这四条规则,然后解释多条规则都可用时它们的优先级如何排列。
this的绑定规则总共有以下5种:
- 默认绑定(最令人头疼的)
- 严格模式
- 非严格模式
- 隐式绑定
- 显式绑定
- bind
- call
- apply
- new绑定
默认绑定
默认绑定顾名思义,就是没人要的“孤儿”就会应用默认绑定。
思考🤔一下👇的代码会输出什么?
1 | var a = 'out' |
答案是:’out’
我们可以看到当调用 fnc()
时,this.a
被解析成了全局变量 a
。为什么?因为在本 例中,函数调用时应用了 this
的默认绑定,因此 this
指向全局对象。
那我们怎么知道他是应用了默认绑定呢?
我们发现 fnc
他只是孤零零的被调用,没有任何的修饰(调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。),所以它就相当于是没有人要的孩子
,只能去福利院
—默认绑定。
这样说可能还不太明白默认绑定究竟是怎么一回事。等到后面的规则介绍的多了,你就会发现没有应用其他规则的“孤儿”只能来福利院 —默认绑定的怀抱
严格模式
在严格下,默认绑定不是绑定到window
,而是undefined
。
1 | var a = 'out' |
答案是:报错
为什么?因为我们想在undefined
里面找a
,那肯定是报错的
隐式绑定
隐式绑定,顾名思义就是悄悄的绑定,或者说公认的,但是没有明说的绑定。就好比一个男人和一个女人手牵手走在一起,他们基本上可以认定就是在情侣,但是还是有可能会是“偷情”(映射到隐式绑定的隐式丢失,等会会说到。)
思考🤔一下👇的代码会输出什么?
1 | var a = 'out' |
答案是:’in’
我们可以注意到,fnc
是在什么情况下被调用的?是obj
调用的,说明这个fnc
已经有主了,护花使者是obj
,所以fnc
说的话肯定是向着obj
的,胳膊肘不会往外拐。
其实这里面有一个小的点需要注意,那就是如果是多个对象引用的调用,那这个时候的执行上下文又是谁的呢?
其实我们用常理就可以解释这个问题,A叫B去吃饭,但是B想叫C一起去,结果A和B起了冲突,你作为这个C你会向着谁?那肯定是B,因为有C叫你去,你们才会出现在这场聚会。所以这个问题不难回答。我们直接看一个例子🌰
1 | var name = 'aa' |
答案是:bb
为什么?因为能让fnc
被调用的是bb
,没有bb
,fnc
根本没有机会登场。
隐式丢失
前面说过,虽然很多东西表上面看起来都很正常,但是也有可能有一些其他状况的出现。比如隐式绑定里面的隐式丢失。
我们把之前的例子修改一下
1 | var name = 'aa' |
答案是:aa
为什么?这个其实也很好理解,这个fnc
并没有被bb
调用,真正调用的地方是在window
里面声明的一个变量aa
。所以bb
丢失了fnc
的信任,fnc
无处可去,只能去孤儿院。
还是刚刚吃饭的例子,我们修改一下场景就很好理解了。
A叫B去吃饭,B把C的联系方式不小心弄丢了,结果被一个陌生人D捡去了,刚刚好D也要参加聚会,就打电话叫了D一起参加聚会。但是C不认识D,叫他去的不是熟人B,所以谁也信不过。最终只能应用默认规则–默认绑定了。
还有一种情况是非常常见的,也是隐式丢失,那就是回调函数。
看下面的例子:
1 | var name = 'window' |
答案是:widnow
为什么?这和之前的那个例子一样,表面上看起来好像是obj
调用的,但是其实是obj
把调用fnc
的方法转交给了别人,由fnc
不认识的来调用了。
再来看一个面试题经常考的题目:
1 | var name = 'window' |
答案是:widnow
我相信不用解释你也找到为什么了吧?fnc
的联系方式被obj
转交给别人了!
哈哈哈哈,有没有感觉this其实也不过如此,so easy!
思考题:那么react中的函数调用为什么要用箭头函数或者为什么要在constructor里面bind一下。
显式绑定
我们刚刚看到的是隐式绑定,是偷偷摸摸的那种。接下来我们就介绍一下显式绑定,光明正大的那种。
那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么
做呢?
JavaScript 中的“所有”函数都有一些有用的特性(这和它们的 [[ 原型 ]] 有关——之后我 们会详细介绍原型),可以用来解决这个问题。具体点说,可以使用函数的 call(..) 和 apply(..) 方法。严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们 并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及你自 己创建的所有函数都可以使用 call(..) 和 apply(..) 方法。
看下面的例子:
1 | var a = 'window' |
答案的先后顺序是:obj,window
通过 foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。
可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题。
1 | function sayHi(){ |
答案是:Hello, Wiliam
其实我们不难发现,这个fn
不是直接被调用的,所以造成了隐式丢失。我们的call
绑定的person
其实绑定在了Hi
上面了,不信的话我们改一下你就会发现了。
1 | function sayHi(){ |
输出了什么?
YvetteLau
Hello, Wiliam
那么咋办呢?别怕显示绑定的变异版,硬绑定可以解决这个问题。
硬绑定
硬绑定其实就是在最后一层给他进行显示绑定。
请看代码:
1 | function sayHi(){ |
输出了什么?
YvetteLau
Hello, Wiliam
虽然我们调用Hi
的时候,在call
之前this
还是指向了window
,但是我们用call
给他绑定上了person
。无论之后如何调用函数 fn
,它 总会手动在 person
上调用 fn
。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。
1 | function foo() { |
典型应用场景是创建一个包裹函数,负责接收参数并返回值。
1 | function foo(something) { |
创建一个可以重复使用的辅助函数。
1 | function foo(something) { |
ES5内置了Function.prototype.bind
,bind会返回一个硬绑定的新函数,用法如下。
1 | function foo(something) { |
API调用的“上下文”
JS许多内置函数提供了一个可选参数,被称之为“上下文”(context),其作用和bind(..)
一样,确保回调函数使用指定的this。这些函数实际上通过call(..)
和apply(..)
实现了显式绑定。
1 | function foo(el) { |
new 绑定
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行[[原型]]连接。
- 这个新对象会绑定到函数调用的this。
- 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
思考下面的代码:
1 | var a = 4 |
使用new
来调用foo(..)
时,会构造一个新对象并把它(bar
)绑定到foo(..)
调用中的this。
绑定优先级
看完规则,我们肯定想知道如果这些规则撞在一起,我们又该听谁的呢?
毫无疑问,默认绑定的优先级是四条规则中最低的,因为是没人应用任何规则才会去应用默认绑定。所以我们可以先不考虑它。 隐式绑定和显式绑定哪个优先级更高?我们来测试一下:
1 | function foo() { |
可以看到,显式绑定优先级更高,也就是说在判断时应当先考虑是否可以应用显式绑定。 现在我们需要搞清楚 new 绑定和隐式绑定的优先级谁高谁低:
1 | function foo(something) { |
可以看到 new 绑定比隐式绑定优先级高。但是 new 绑定和显式绑定谁的优先级更高呢?
new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接 进行测试。但是我们可以使用硬绑定来测试它俩的优先级。
这是因为函数内部有两个不同的方法:
[[Call]]
和[[Constructor]]
。 当使用普通函数调用时,[[Call]]
会被执行。当使用构造函数调用时,[[Constructor]]
会被执行。call
、apply
、bind
和箭头函数内部没有[[Constructor]]
方法。
1 |
|
我们可以看到new把bind的硬绑定给顶掉了
所以new > 硬绑定
总结
new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定
绑定例外
被忽略的this
把null
或者undefined
作为this
的绑定对象传入call
、apply
或者bind
,这些值在调用时会被忽略,实际应用的是默认规则。
下面两种情况下会传入null
- 使用
apply(..)
来“展开”一个数组,并当作参数传入一个函数 bind(..)
可以对参数进行柯里化(预先设置一些参数)
1 | function foo(a, b) { |
总是传入null
来忽略this绑定可能产生一些副作用。如果某个函数确实使用了this,那默认绑定规则会把this绑定到全局对象中。
更安全的this
安全的做法就是传入一个特殊的对象(空对象),把this绑定到这个对象不会对你的程序产生任何副作用。
JS中创建一个空对象最简单的方法是Object.create(null)
,这个和{}
很像,但是并不会创建Object.prototype
这个委托,所以比{}
更空。
1 | function foo(a, b) { |
间接引用
间接引用下,调用这个函数会应用默认绑定规则。间接引用最容易在赋值时发生。
1 | // p.foo = o.foo的返回值是目标函数的引用,所以调用位置是foo()而不是p.foo()或者o.foo() |
软绑定
- 硬绑定可以把this强制绑定到指定的对象(
new
除外),防止函数调用应用默认绑定规则。但是会降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改this。 - 如果给默认绑定指定一个全局对象和undefined以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显示绑定修改this的能力。
1 | // 默认绑定规则,优先级排最后 |
使用:软绑定版本的foo()可以手动将this绑定到obj2或者obj3上,但如果应用默认绑定,则会将this绑定到obj。
1 | function foo() { |
箭头函数
ES6新增一种特殊函数类型:箭头函数,箭头函数无法使用上述四条规则,而是根据外层(函数或者全局)作用域(词法作用域)来决定this。
foo()
内部创建的箭头函数会捕获调用时foo()
的this。由于foo()
的this绑定到obj1
,bar
(引用箭头函数)的this也会绑定到obj1
,箭头函数的绑定无法被修改(new
也不行)。
1 | function foo() { |
箭头函数最常用于回调函数中,例如事件处理器或者定时器:
1 |
|
ES6之前和箭头函数类似的模式,采用的是词法作用域取代了传统的this机制。
1 | function foo() { |
实现call和apply
了解call和apply
为什么会有call和apply? call和apply两个方法的作用基本相同,它们都是为了改变某个函数执行时的上下文(context)而建立的, 他的真正强大之处就是能够扩充函数赖以运行的作用域。通俗一点讲,就是改变函数体内部this 的指向。
举个栗子:
1 | window.color = "red"; |
解释:上面的栗子,很明显函数sayColor是在全局作用域(环境/window)中调用的,而全局作用域中有一个color属性,值为”red”,sayColor.call(this)这一行代码就是表示把函数体sayColor内部的this,绑到当前环境(作用域),而sayColor.call(window)这一行代码就是表示把函数体sayColor内部的this,绑到window(全局作用域),之所以这两行的输出都是”red”就是因为他当前作用域的this就是window(this === window); 最后,sayColor.call(o)这一行代码就表示把函数体sayColor内部的this,绑到o这个对象的执行环境(上下文)中来,也就是说sayColor内部的this——>o
call和apply的区别
call()
和 apply()
的区别在于,call()
方法接受的是若干个参数的列表,而apply()
方法接受的是一个包含多个参数的数组
举个例子:
1 | var func = function(arg1, arg2) { |
应用场景
其实这些应用场景都有新的方法可以快速解决, 我只是想告诉大家一些小的知识点, 而且这些东西虽然有新的方法代替,但是面试的时候很可能会被问到。
合并两个数组
1 | var a = ['a', 'aa']; |
其实现在有concat
来代替了。或者其他奇淫技巧。但是,都不是我们的重点。
当第二个数组(如示例中的 b )太大时不要使用这个方法来合并数组,因为一个函数能够接受的参数个数是有限制的。不同的引擎有不同的限制,JS核心限制在 65535,有些引擎会抛出异常,有些不抛出异常但丢失多余参数。
那么要如何解决呢?
我们可以把数组进行切割。然后分批次的调用。
1 | function myConcat(arr1, arr2, max = 32768) { |
验证是否是数组
1 | var arr = []; |
同样是检测对象类型,arr.toString()的结果和Object.prototype.toString.call(arr)的结果不一样,这是为什么?
这是因为toString()为Object的原型方法,而Array ,function等引用类型作为Object的实例,都重写了toString方法。不同的对象类型调用toString方法时,根据原型链的知识,调用的是对应的重写之后的toString方法(function类型返回内容为函数体的字符串,Array类型返回元素组成的字符串…..),而不会去调用Object上原型toString方法,所以采用arr.toString()不能得到其对象类型,只能将arr转换为字符串类型;因此,在想要得到对象的具体类型时,应该调用Object上原型toString方法。
类数组对象(Array-like Object)使用数组方法
1 | var domNodes = document.getElementsByTagName("*"); |
类数组对象有下面两个特性
- 1、具有:指向对象元素的数字索引下标和
length
属性 - 2、不具有:比如
push
、shift
、forEach
以及indexOf
等数组对象具有的方法
要说明的是,类数组对象是一个对象。JS中存在一种名为类数组的对象结构,比如 arguments
对象,还有DOM API 返回的 NodeList
对象都属于类数组对象,类数组对象不能使用 push/pop/shift/unshift
等数组方法,通过 Array.prototype.slice.call
转换成真正的数组,就可以使用 Array
下所有方法。
类数组对象转数组的其他方法:
1 | // 上面代码等同于 |
Array.from()
可以将两类对象转为真正的数组:类数组对象和可遍历(iterable)对象(包括ES6新增的数据结构 Set 和 Map)。
实现call
这里会使用隐式绑定来实现。
先丢出我们要测试的例子:
1 | var a = 1; |
OK, 我们现在就来实现以下自定义的call。应用隐式绑定的话就可以直接绑定上了。
1 |
|
很潇洒的完成了。但是好像不能接受参数诶,接受参数又要考虑边界情况,比如undefined
,null
之类的。而且!!而且!!而且call
不传的话是默认应用window
的;
所以他有我们也要有!冲!
1 | Function.prototype.myCall = function(context) { |
实现apply
因为他们两兄弟就参数不一样所以就不解释了,直接看代码吧。
1 | Function.prototype.myApply = function(context) { |
实现bind
了解bind
bind() 方法创建一个新的函数,在
bind()被调用时,这个新函数的
this被指定为
bind()` 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。语法:
fun.bind(thisArg[, arg1[, arg2[, ...]]])
MDN
bind和apply,call两兄弟的区别
bind
方法与 call / apply
最大的不同就是前者返回一个绑定上下文的函数,而后两者是直接执行了函数。
上代码!
1 | var value = 2; |
通过上述代码可以看出bind
有如下特性:
- 1、可以指定
this
- 2、返回一个函数
- 3、可以传入参数
- 4、柯里化
实现bind
直接开干!
1 | Function.prototype.myBind = function (context) { |
Ok,接下来我们处理一下参数和柯里化吧。
1 | Function.prototype.myBind = function (context) { |
到现在已经完成大部分了,但是还有一个难点,bind
有以下一个特性
一个绑定函数也能使用new操作符创建对象:这种行为就像把原函数当成构造器,提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。
举例说明:正规的bind
1 | var value = 2; |
上面例子中,运行结果this.value
输出为 undefined
,这不是全局value
也不是foo
对象中的value
,这说明 bind
的 this
对象失效了,new
的实现中生成一个新的对象,这个时候的 this
指向的是 obj
。
所以我们返回的时候需要判断一下他是不是作为了构造函数返回,如果是就返回当前的this,如果不是就绑定当前输入的context
1 | Function.prototype.myBind = function (context) { |
但是其实这样会有一个问题,我如果实例修改了原型,那么接下来的继承就会出现问题。
这个时候我们需要拷贝一下我们原型上面的参数。会用到
道格拉斯·克罗克福德在 2006 年写了一篇文章,题为 Prototypal Inheritance in JavaScript (JavaScript 10 中的原型式继承)。在这篇文章中,他介绍了一种实现继承的方法,这种方法并没有使用严格意义上的 构造函数。他的想法是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。为 了达到这个目的,他给出了如下函数。
1
2
3
4
5 function object(o){
function F(){}
F.prototype = o
return new F
}看起来非常简单
先在
object
函数内部创建一个临时的构造函数F
, 然后将传入的这个对象o
作为这个临时构造函数的原型, 最后返回这个临时构造函数的实例.简单来说就是
object
对传入的对象进行了浅复制.
这句话在我的《原型原型链和继承》里面有具体的介绍。想看的话可以点击头像查找文章。
这边可以直接使用ES5的 Object.create()
方法生成一个新对象
1 | fBound.prototype = Object.create(this.prototype); |
不过 bind
和 Object.create()
都是ES5方法,部分IE浏览器(IE < 9)并不支持,Polyfill中不能用 Object.create()
实现 bind
,不过原理是一样的。
ok。那我们就修改一下
1 | Function.prototype.myBind = function (context) { |
到这里其实已经差不多了,突然想起来还有一个问题是调用 bind
的不是函数,这时候需要抛出异常。
1 | if (typeof this !== "function") { |
所以
顶配
1 | Function.prototype.myBind = function (context) { |
实现new
了解new
new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。 ——(来自于MDN)
我们先试试现在有的new,他有什么功能,然后我们总结之后,在开始实现。
看个🌰:
1 | function NEW (a) { |
我们可以看到A是NEW的一个实例。继承了构造函数(NEW)的属性和原型上的属性(sayA)。
new
关键字会进行如下的操作:
- 创建一个空的简单JavaScript对象(即{});
- 链接该对象(即设置该对象的构造函数)到另一个对象 ;
- 将步骤1新创建的对象作为
this
的上下文 ;- 如果该函数没有返回对象,则返回
this
。MDN
实现一个new
说干就干:
1 | // new 是关键词,不可以直接覆盖。这里使用 create 来模拟实现 new 的效果。 |
构造函数返回值有如下三种情况:
- 1、返回一个对象
- 2、没有
return
,即返回undefined
- 3、返回
undefined
以外的基本类型
1 | function create() { |