又是this指向!在vue中实现节流防抖的踩坑实践

3,636 阅读5分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

前言

今天在手写节流防抖操作时,想放到vue的一个项目中测试一下,这不测不知道,一测就耗费了一个下午来归纳总结😭,具体代码如下

<template>
  <div id="app">
    <!-- <router-view /> -->
    <input type="text" v-model="test" style="border: red solid 1px;" />
  </div>
</template><script>
import debounce from "@/utils/lib/debounce.js"
export default {
  components: {},
  data() {
    return {
      test: "",
    }
  },
  watch: {
    test() {
      debounce(() => {
        console.log(this)
      }, 500)
    },
  },
}
</script>
// 函数防抖(debounce)
// 在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。
function debounce(fn, delay) {
  let timer = null
  return function (...args) {
    // 若定时器已存在则清除定时器
    if (timer) {
      clearTimeout(timer)
    }
    // 新建一个定时器,在delay后触发方法
    timer = setTimeout(()=> {
      fn.apply(this, args)
    }, delay)
  }
}
​

注意点

方法的绑定方式

按照以下方式执行防抖方法是无效的,原本我们将执行逻辑写在闭包中就是为了 避免在声明方法 的的时候就直接调用了我们需要执行防抖的方法。

watch: {
    test() {
      debounce(() => {
        console.log(this)
      }, 500)
    },
  },

在test改变的时候,我们在方法体内直接调用debounce方法,那我们只能执行到方法的外层 let timer = null 这个方法,而事实上我们需要的是执行闭包内的函数

因此我们需要将写法改为如下代码

<template>
  <div id="app">
    <!-- <router-view /> -->
    <input type="text" v-model="test" style="border: red solid 1px;" />
  </div>
</template><script>
import debounce from "@/utils/lib/debounce.js"
export default {
  components: {},
  data() {
    return {
      test: "",
    }
  },
  watch: {
    test: {
      handler: debounce(()=> {
        console.log(this)
      }, 500),
    },
  },
}
</script>

我们直接声明test变量改变时的 handler 为我们的 debounce 方法,如果我们在 let timer = null 下方加入一行输入例如 console.log("声明成功") ,那么我们在组件创建时就会发现打印了 声明成功 这四个字,此时再去输入框中输入我们就可以发现我们的防抖方法可以正常执行。

this指向问题

但是!还有一个坑是作用域的问题,在上面的 debounce 方法中,我为了精简代码使用了 ()=> {console.log(this) } 这样的箭头函数来当做回调函数。在函数中我们打印了 this 关键字。正常情况下,我们的期望当然是打印出我们当前组件的 实例 对象。

但是实际打印出来的是undefined ,这就涉及到箭头函数的 this指向问题,浏览器输出结果如下图

image.png

我们来分析一下为什么会打印出undefined呢,首先我们打印的是 this ,this在一般情况下打印出来的是定义时所在的对象,这个对象即使在作用域最外层也会打印出 window 对象。

严格模式的最外层this

但是在严格模式下,我们作用域的最外层的 this打印出来的是undefined,而我们的防抖函数顶部并没有声明是严格模式。事实上我们在vue中编写代码时,最终使用babel进行 es6 转 es5 时会自动为我们的代码添加 use strict即声明为严格模式。因此我们可以判断我们打印出来的this应该是处在最外层的作用域

箭头函数的call和apply

我们在防抖的函数中调用回调时我们是 fn.apply(this, args) 这样调用的,这是为了将我们执行回调方法时的上下文绑定在回调函数上,避免回调函数内的作用域错误,按理来说即使是箭头函数与普通函数指向不同也可以将正确的 this 传入进去,但是由于 箭头函数没有自己的this指针,通过 call() apply() 方法调用一个函数时,只能传递参数他们的第一个参数会被忽略。

箭头函数的this

箭头函数没有自己的this,箭头函数中的this继承于外层代码库中的this。

为了看的更加清晰,我们改造一下以上代码,将回调函数直接内置在方法内,既然apply方法对于箭头函数来说没有用,那我们也将apply给去掉。

function debounce() {
  let timer = null
  return function (...args) {
    // 若定时器已存在则清除定时器
    if (timer) {
      clearTimeout(timer)
    }
    // 新建一个定时器,在delay后触发方法
    timer = setTimeout(()=> {
      ((...args)=> {
        console.log(this)
      })()
    },100)
  }
}
​

这一下可以看的很直观了,真正在调用时就是这么个结构,但是这时候我们执行一下,发现控制台打印的结果又变成我们想要的结果了!如下图

image.png

我们成功的打印出了vue的组件实例,那么我们来分析一下现在this指向哪个作用域。

  1. 我们的console在一个箭头函数中,因此它指向外层的作用域也就是 setTimeout 的回调函数中
  1. setTimeout 的回调函数也是箭头函数,也指向外层作用域,最终指向的是我们的 return function(){},而这个函数在debounce方法声明在test的handler中时,方法体被返回给了handler。而我们在平时使用vue时也知道handler中打印this肯定是指向vue组件的实例对象。
  1. 因此我们在handler中输出的this就是我们console方法的this,也就是我们的vue实例

那为什么我将箭头函数作为回调函数参数传入方法时指向的却是undefined呢,因为箭头函数实际上可以让this指向固定化,绑定this使得它不再可变。

我们在执行下面这步操作时,回调函数是全局创建的,不是闭包中的那个函数创建的,this指向的已经是最外层的作用域了。即使在调用时我们的作用域是正确的,但是this已经不会再发生变化了

handler: debounce(()=> {
        console.log(this)
      }, 500),

根据以上所解析,我们可以将箭头函数修改为普通函数,debounce方法无需改变,这样输出的结果就是正确的啦

import debounce from "@/utils/lib/debounce.js"
export default {
  components: {},
  data() {
    return {
      test: "",
    }
  },
  watch: {
    test: {
      handler: debounce(function(){
        console.log(this)
      }, 500),
    },
  },
}

最后我将上面的问题抽象简化出来,大家猜猜下面的代码输出结果是什么呢?

function a(callback) {
  var oil = '大欧呦'
  return function () {
    var oil = '中欧呦'
    callback.apply(this)
  }
}
​
const b = {
  oil: "小欧呦",
  handler: a(() => {
    var oil = "迷你欧呦"
    console.log(this.oil) //???
  })
}
​
var oil = "超大欧呦"
b.handler()