javascript基础修炼(3)—What's this(下)
开发者的javascript造诣取决于对【动态】和【异步】这两个词的理解水平。
这一期主要分析各种实际开发中各种复杂的this
指向问题。
一. 严格模式
严格模式是ES5中添加的javascript
的另一种运行模式,它可以禁止使用一些语法上不合理的部分,提高编译和运行速度,但语法要求也更为严格,使用use strict
标记开启。
严格模式中this
的默认指向不再为全局对象,而是默认指向undefined
。这样限制的好处是在使用构造函数而忘记写new
操作符时会报错,而不会把本来需要绑定在实例上的一堆属性全绑在window
对象上,在许多没有正确地绑定this
的场景中也会报错。
二. 函数和方法的嵌套与混用
词法定义并不影响this
的指向 , 因为this
是运行时确定指向的。
2.1 函数定义的嵌套
function outerFun(){ function innerFun(){ console.log('innerFun内部的this指向了:',this); } innerFun();}outerFun();
控制台输出的this
指向全局对象。
2.2 对象属性的嵌套
当调用的函数在对象结构上的定义具有一定深度时,this
指向这个方法所在的对象,而不是最外层的对象。
var IronMan = { realname:'Tony Stark', rank:'1', ability:{ total_types:100, fly:function(){ console.log('IronMan.ability.fly ,作为方法调用时this指向:',this); }, }}IronMan.ability.fly();
控制台输出的this
指向IronMan的ability属性所指向的对象,调用fly( )
这个方法的对象是IronMan.ability
所指向的对象,而不是IronMan
所指向的对象。
this
作为对象方法调用时,标识着这个方法是如何被找到的。IronMan
这个标识符指向的对象信息并不能在运行时找到fly( )
这个方法的位置,因为ability属性中只存了另一个对象的引用地址,而IronMan.ability
对象的fly属性所记录的指向,才能让引擎在运行时找到这个匿名方法。
三. 引用转换
引用转换实际上并不会影响this
的指向,因为它是词法性质的,发生在定义时,而this
的指向是运行时确定的。只要遵循this指向的基本原则就不难理解。
3.1 标识符引用转换为对象方法引用
var originFun = function (){ console.log('originFun内部的this为:',this);}var ironMan = { attack:originFun};ironMan.attack();
这里的this
指向其调用者,也就是ironMan
引用的对象。
3.2 对象方法转换为标识符引用
var ironMan = { attack:function(){ console.log('对象方法中this指向了:',this); }}var originFun = ironMan.attack;originFun();
这里的this
指向全局对象,浏览器中也就是window
对象。3.2中的示例被认为是javascript语言的bug,即this指向丢失的问题。同样的问题也可能在回调函数传参时发生,本文【第5章】将对这种情况进行详细说明。
四. 回调函数
javascript中的函数是可以被当做参数传递进另一个函数中的,也就有了回调函数这样一个概念。
4.1 this在回调函数中的表现
var IronMan = { attack:function(findEnemy){ findEnemy(); } } function findEnemy(){ console.log('已声明的函数被当做回调函数调用,this指向:',this); } var attackAction = { findEnemy:function(){ console.log('attackAction.findEnemy本当做回调函数调用时,this指向',this); }, isArmed:function(){ console.log('check whether the actor is Armed'); } } //1.直接传入匿名函数 IronMan.attack(function(){ console.log(this); }); //2.传入外部定义函数 IronMan.attack(findEnemy); //3.传入外部定义的对象方法 IronMan.attack(attackAction.findEnemy);
从控制台打印的结果来看,无论以哪种方式来传递回调函数,回调函数执行时的this
都指向了全局变量。
4.2 原理
javascript中函数传参全部都是值传递,也就是说如果调用函数时传入一个原始类型,则会把这个值赋值给对应的形参;如果传入一个引用类型,则会把其中保存的内存指向的地址赋值给对应的形参。所以在函数内部操作一个值为引用类型的形参时,会影响到函数外部作用域,因为它们均指向内存中的同一个函数。详细可参考[深入理解javascript函数系列第二篇——函数参数]这篇博文。
理解了函数传参,就很容易理解回调函数中this
为何指向全局了,回调函数对应的形参是一个引用类型的标识符,其中保存的地址直接指向这个函数在内存中的真实位置,那么通过执行这个标识符来调用函数就等同于this基本指向规则中的作为函数来调用的情况,其this
指向全局对象也就不难理解了。
五. this指针丢失
在第三节和第四节中,通过原理分析就能够明白为何在一些特定的场合下this
会指向全局对象,但是从语言的角度来看,却很难理解this
为什么指向了全局对象,因为这个规则和语法的字面意思是有冲突的。
5.1 回调函数的字面语境
var name = 'HanMeiMei';var liLei = { name:'liLei', introduce:function () { console.log('My name is ', this.name); } };var liLeiSay = liLei.introduce;liLeiSay();//同第三节中的引用转换示例setTimeout(liLei.introduce,2000);//同第四节中的回调函数示例
上面的代码从字面上看意义是很明确的,就是希望liLei立刻介绍一下自己,在2秒后再介绍一下他自己。但控制台输出的结果中,他却两次都说自己的名字是HanMeiMei。
5.2 this指针丢失
5.1中的示例,也称为this指针丢失问题,被认为是Javascript语言的设计失误,因为这种设计在字面语义上造成了混乱。
5.3 this指针修复
方式1-使用bind
为了使代码的字面语境和实际执行保持一致,需要通过显示指定this的方式对this
的指向进行修复。常用的方法是使用bind( )
生成一个确定了this
指向的新函数,将上述示例改为如下方式即可修复this
的指向:
var liLeiSay = liLei.introduce.bind(liLei);setTimeout(liLei.introduce.bind(liLei),2000);
bind( )
的实现其实并不复杂,是闭包实现高阶函数的一个简单的实例,感兴趣的读者可以自行了解。
方式2-使用Proxy
Proxy是ES6
中才支持的方法。
//绑定This的函数function fixThis (target) { const cache = new WeakMap(); //返回一个新的代理对象 return new Proxy(target, { get (target, key) { const value = Reflect.get(target, key); //如果要取的属性不是函数,则直接返回属性值 if (typeof value !== 'function') { return value; } if (!cache.has(value)) { cache.set(value, value.bind(target)); } return cache.get(value); } });}const toggleButtonInstance = fitThis(new ToggleButton());
两种修复
this
指向的思路其实很类似,第一种方式相当于为调用的方法创建了一个代理方法
,第二种方式是为被访问的对象创建了一个代理对象
。
六. this的透传
实际开发过程中,往往需要在更深层次的函数中获取外层this
的指向。
常规的解决方案是:将外层函数的this
赋值给一个局部变量,通会使用_this
,that
,self
,_self
等来作为变量名保存当前作用域中的this
。由于在javascript
中作用域链的存在,嵌套的内部函数可以调用外部函数的局部变量,标识符会去寻找距离作用域链末端最近的一个指向作为其值,示例如下:
document.querySelector('#btn').onclick = function(){ //保存外部函数中的this var _this = this; _.each(dataSet, function(item, index){ //回调函数的this指向了全局,调用外部函数的this来操作DOM元素 _this.innerHTML += item; }); }
七. 事件监听
事件监听中this
的指向情况其实是几种情况的集合,与代码如何编写有很大关系。
7.1 表现
1. 在html文件中使用事件监听相关的属性来触发方法
<button onclick="someFun()">点击按钮</button><button onclick="someObj.someFun()">点击按钮</button>
如果以第一种方式触发,则函数中的this
指向全局;
如果以第二种方式触发,则函数中的this
指向someObj这个对象。
2. 在js文件中直接为属性赋值
//声明一个函数 function callFromHTML() { console.log('callFromHTML,this指向:',this);}//定义一个对象方法var obj = { callFromObj:function () { console.log('callFromObj',this); } }//注册事件监听-方式1 document.querySelector('#btn').onclick = function (event) { console.log(this);} //注册事件监听-方式2document.querySelector('#btn').onclick = callFromHTML;//注册事件监听-方式3document.querySelector('#btn').onclick = obj.callFromObj;
以上三种注册的事件监听响应函数,其this
均指向id="btn"
的DOM元素。
3. 使用addEventListener
方法注册响应函数
//低版本IE浏览器中需要使用另外的方法document.querySelector('#btn').addEventListener('click',function(event){ console.log(this);});//也可以将函数名或对象方法作为回调函数传入document.querySelector('#btn').addEventListener('click',callFromHTML);document.querySelector('#btn').addEventListener('click',obj.callFromObj);
这种方式注册的响应函数,其this
与场景2相同,均指向id="btn"
的DOM元素。区别在于使用addEventListener
方法添加的响应函数会依次执行,而采用场景2的方式时,只有最后一次赋值的函数会被调用。
7.2 基本原理
1. 通过标签属性注册
<button id="btn" onclick="callFromHTML()">点我</button><script> function callFromHTML() { console.log(document.querySelector('#btn').onclick); }</script>
在html中绑定事件处理程序,然后当按钮点击时,在控制台打印出DOM对象的onclick
属性,可以看到:
这种绑定方式其实是将监听方法包裹在另一个函数中去执行,相当于:
document.querySelector('#btn').onclick = function(event){ callFromHTML();}
这样上述的表现就不难理解了。
2. 通过元素对象属性注册
document
在javascript中是一个对象,通过其暴露的查找方法返回的节点也是一个对象,那么方式二绑定的监听函数在运行时,实际上就是在执行指定节点的onclick
方法,根据this指向的基本规则可知其函数体中的this
应该指向调用对象,也就是onclick
这个方法所在的节点对象。
3. 通过addEventListener
方法注册
这种方式是在DOM2事件模型中扩展的,用于支持多个监听器绑定的场景。DOM2事件模型的描述中规定了通过这种方式添加的监听函数执行时的this
指向所在的节点对象,不同内核的浏览器实现方式有区别。
7.3 使用建议
不同的使用方式实质上是伴随着DOM事件模型升级而发生改变的,现代浏览器对于以上几种模式都是支持的,只有需要兼容老版本浏览器时需要考虑对DOM事件模型的支持程度。开发中DOM2级事件模型中addEventListener()
和removeEventListener()
来管理事件监听函数是最为推荐的方法。
八. 异步函数
1. setTimeout( )和setInterval( )
这里的情况相当于上文中的回调函数的情况。
2. 事件监听
详见第7章。
3. ajax请求
几乎没有遇到过。
4. Promise
这里的情况相当于上文中的回调函数的情况。
九. 箭头函数和this
箭头函数是ES6
标准中支持的语法,它的诞生不仅仅是因为表达方式简洁,也是为了更好地支持函数式编程。箭头函数内部不绑定this
,arguments
,super
,new.target
,所以由于作用域链的机制,箭头函数的函数体中如果使用到this
,则执行引擎会沿着作用域链去获取外层的this
。
十. Nodejs中的this
Nodejs
是一种脱离浏览器环境的javascript
运行环境,this
的指向规则上与浏览器环境在全局对象的指向上存在一定差异。
1. 全局对象global
Nodejs
的运行环境并不是浏览器,所以程序里没有DOM
和BOM
对象,Nodejs
中也存在全局作用域,用来定义一些不需要通过任何模块的加载即可使用的变量、函数或类,全局对象中多为一些系统级的信息或方法,例如获取当前模块的路径,操作进程,定时任务等等。
2. 文件级this指向
Nodejs
是支持模块作用域的,每一个文件都是一个模块,可通过require( )
的方式同步引入,通过module.exports
来暴露接口供其他模块调用。在一个文件中最顶级的this
指向当前这个文件模块对外暴露的接口对象,也就是module.exports
指向的对象。示例:
var IronMan = { name:'Tony Stark', attack: function(){ }}exports.IronMan = IronMan;console.log(this);
在控制台即可看到,this
指向一个对象,对象中只有一个属性IronMan
,属性值为文件中定义的IronMan
这个对象。
3. 函数级this指向
this的基本规则中有一条—当作为函数调用时,函数中的this
指向全局对象,这一条在nodejs
中也是成立的,这里的this
指向了全局对象(此处的全局对象Global对象是有别于模块级全局对象的)。
思考题— React组件中为什么要bind(this)
如果你尝试使用过React
进行前端开发,一定见过下面这样的代码:
//假想定义一个ToggleButton开关组件class ToggleButton extends React.Component{ constructor(props){ super(props); this.state = {isToggleOn: true}; this.handleClick = this.handleClick.bind(this); this.handleChange = this.handleChange.bind(this); } handleClick(){ this.setState(prevState => ({ isToggleOn: !preveState.isToggleOn })); } handleChange(){ console.log(this.state.isToggleOn); } render(){ return( <button onClick={this.handleClick} onChange={this.handleChange}> {this.state.isToggleOn ? 'ON':'OFF'} </button> ) }}
思考题:构造方法中为什么要给所有的实例方法绑定this呢?(强烈建议读者先自己思考再看笔者分析)
1. 代码执行的细节
上例仅仅是一个组件类的定义,当在其他组件中调用或是使用ReactDOM.render( )
方法将其渲染到界面上时会生成一个组件的实例,因为组件是可以复用的,面向对象的编程方式非常适合它的定位。根据this指向的基本规则就可以知道,这里的this
最终会指向组件的实例。
组件实例生成的时候,构造器constructor
会被执行,此处着重分析一下下面这行代码:
this.handleClick = this.handleClick.bind(this);
此时的this
指向新生成的实例,那么赋值语句右侧的表达式先查找this.handleClick( )
这个方法,由对象的属性查找机制(沿原型链由近及远查找)可知此处会查找到原型方法this.handleClick( )
,接着执行bind(this)
,此处的this
指向新生成的实例,所以赋值语句右侧的表达式计算完成后,会生成一个指定了this
的新方法,接着执行赋值操作,将新生成的函数赋值给实例的handleClick
属性,由对象的赋值机制可知,此处的handleClick
会直接作为实例属性生成。总结一下,上面的语句做了一件这样的事情:
把原型方法handleClick( )
改变为实例方法handleClick( )
,并且强制指定这个方法中的this
指向当前的实例。
2. 绑定this的必要性
在组件上绑定事件监听器,是为了响应用户的交互动作,特定的交互动作触发事件时,监听函数中往往都需要操作组件某个状态的值,进而对用户的点击行为提供响应反馈,对开发者来说,这个函数触发的时候,就需要能够拿到这个组件专属的状态合集(例如在上面的开关组件ToggleButton
例子中,它的内部状态属性state.isToggleOn
的值就标记了这个按钮应该显示ON或者OFF),所以此处强制绑定监听器函数的this
指向当前实例的也很容易理解。
React构造方法中的bind会将响应函数与这个组件Component进行绑定以确保在这个处理函数中使用this时可以时刻指向这一组件的实例。
3. 如果不绑定this
如果类定义中没有绑定this
的指向,当用户的点击动作触发this.handleClick( )
这个方法时,实际上执行的是原型方法,可这样看起来并没有什么影响,如果当前组件的构造器中初始化了state
这个属性,那么原型方法执行时,this.state
会直接获取实例的state
属性,如果构造其中没有初始化state
这个属性(比如React中的UI组件),说明组件没有自身状态,此时即使调用原型方法似乎也没什么影响。
事实上的确是这样,这里的bind(this)
所希望提前规避的,就是第五章中的this指针丢失的问题。
例如使用解构赋值的方式获取某个属性方法时,就会造成引用转换丢失this的问题:
const toggleButton = new ToggleButton();import {handleClick} = toggleButton;
上例中解构赋值获取到的handleClick
这个方法在执行时就会报错,Class的内部是强制运行在严格模式下的,此处的this
在赋值中丢失了原有的指向,在运行时指向了undefined
,而undefined
是没有属性的。
另一个存在的限制,是没有绑定this
的响应函数在异步运行时可能会出问题,当它作为回调函数被传入一个异步执行的方法时,同样会因为丢失了this
的指向而引发错误。
如果没有强制指定组件实例方法的
this
,在将来的使用中就无法安心使用引用转换或作为回调函数传递这样的方式,对于后续使用和协作开发而言都是不方便的。