内容字号:默认大号超大号

段落设置:段首缩进取消段首缩进

字体设置:切换到微软雅黑切换到宋体

javascript基础修炼(3):What's this(下)

2018-08-04 18:26 出处:清屏网 人气: 评论(0

开发者的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 指向 IronManability 属性所指向的对象,调用 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);
} 
//注册事件监听-方式2
document.querySelector('#btn').onclick = callFromHTML;

//注册事件监听-方式3
document.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 的运行环境并不是浏览器,所以程序里没有 DOMBOM 对象, 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 ,在将来的使用中就无法安心使用 引用转换作为回调函数传递 这样的方式,对于后续使用和协作开发而言都是不方便的。

参考

[1]《javascript高级程序设计(第三版)》

[2]《深入理解javascript函数系列第二篇》 https://www.cnblogs.com/xiaohuochai/p/5706289.html

[3]《ES6-Class基本语法》 https://www.cnblogs.com/ChenChunChang/p/8296350.html

分享给小伙伴们:
本文标签: javascript

相关文章

发表评论愿您的每句评论,都能给大家的生活添色彩,带来共鸣,带来思索,带来快乐。

CopyRight © 2015-2016 QingPingShan.com , All Rights Reserved.

清屏网 版权所有 豫ICP备15026204号