开发者的 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);
- }
- // 注册事件监听 - 方式 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 的运行环境并不是浏览器, 所以程序里没有 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, 在将来的使用中就无法安心使用引用转换或作为回调函数传递这样的方式, 对于后续使用和协作开发而言都是不方便的.
来源: https://www.cnblogs.com/dashnowords/p/9410498.html