Skip to content
On this page

你的v-debounce封装真的没问题么?自定义防抖指令踩坑记录

起因

那本是一个闲适的清晨,突然测试的一条信息引起了我的注意。
“你的上线按钮怎么执行的下线操作?”, 我:“???”,
赶紧打开代码一看,没毛病啊,事件绑定的方法没错,提示语也没有打错,发生了啥?
项目跑起来一试,也没问题啊,测试你坑我吧。
跑到测试座位开始对线,一看,还真有问题,咋我自己那就没问题。
赶紧对了下线上版本,也没毛病啊,这还能是偶现bug?
直到我点进了另一个任务,这两任务按钮正好一个是上线一个是下线,竟然真有问题(→_→)
测试大哥,我错了,先别提我研究下。

问题复现

内部代码不方便直接放出来,写了个简单的demo,更好看懂。

首先是一个简单的页面结构

js
<template>
  <div class="container">
    <button v-debounce="handlerClick1" v-if="showbutton">1</button>
    <button v-debounce="handlerClick2" v-else>2</button>
    <button @click="toggle">toggle</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      showbutton: true,
    }
  },
  methods: {
    toggle() {
      this.showbutton = !this.showbutton
    },
    handlerClick1() {
      console.log(1, this)
    },
    handlerClick2() {
      console.log(2, this)
    },
  },
}
</script>

这里我就不赘述了,就三个按钮,toggle按钮用来切换其他两个按钮的显示,一个打印1,一个打印2

然后是个简化版的v-debounce

js
const debounce = (fn) => {
  let timer
  return () => {
    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(() => {
      fn()
    }, 300)
  }
}

const vDebounce = {
  install(Vue) {
    Vue.directive('debounce', {
      bind(el, binding) {
        el.debounceFn = debounce(binding.value)
        el.addEventListener('click', el.debounceFn)
      },
      unbind(el) {
        el.removeEventListener('click', el.debounceFn)
      },
    })
  },
}

Vue.use(vDebounce)

应该也没有更简化的了吧,搞定,启动项目!

button1.png

此时,点击按钮1,控制台输出为

log1.png

没毛病,然后再点击toggle按钮

button2.png

点击按钮2,控制台输出为

log2.png

我的2呢!!!看过vue源码的同学肯定很快就能反应过来,这肯定是patchVnode方法在搞事
最容易想到的方法就是给每个按钮加上key,你再复用个我看看?
是的,加上key值确实可以解决这个问题,但是你不能要求每个用你这个指令的人,给每个用了这个指令的元素都加上key吧,忘记一次就是一个bug单,害人不浅。所以,来探究下有什么好点的解决方法吧!

vue更新vnode时,怎么处理的自定义指令

vue的diff算法和patch流程,掘金上已经有很多大佬详细讲过了,这里我就不赘述了,我们单独看看updateDirectives做了什么。

js
// 不管新旧节点,只要一边存在自定义指令,就执行_update
if (oldVnode.data.directives || vnode.data.directives) {
    _update(oldVnode, vnode);
  }

_update包含了各种可能的情况,我们单独看看本次我们问题的情况,也就是新旧节点都存在自定义指令

js
const dirsWithPostpatch = [];

for (key in newDirs) {
    oldDir = oldDirs[key];
    dir = newDirs[key];
    dir.oldValue = oldDir.value;
    dir.oldArg = oldDir.arg;
    callHook(dir, "update", vnode, oldVnode);
    if (dir.def && dir.def.componentUpdated) {
      dirsWithPostpatch.push(dir);
    }
  }

为方便阅读,此处我删了旧节点不存在指令的情况。
简单说,就是vue会把旧的指令参数存在对象里面,然后触发update钩子方法,然后用一个数组存起来

js
if (dirsWithPostpatch.length) {
    mergeVNodeHook(vnode, "postpatch", () => {
      for (let i = 0; i < dirsWithPostpatch.length; i++) {
        callHook(dirsWithPostpatch[i], "componentUpdated", vnode, oldVnode);
      }
    });
  }

然后遍历这个数组,分别触发componentUpdated钩子函数
再然后,再然后就没了(¬‿¬),感情vue就光触发了两钩子。

此时,再回头看一下我们的v-debounce,我们在bind钩子中,给元素绑定了click事件,并且已经生成了防抖的事件执行方法,vue在更新vnode时,并不会重置它,导致了我们遇到的问题。

解决方案

明白问题的起因,解决起来就很简单了,我们在componentUpdated钩子中,重新绑定事件即可。

js
componentUpdated(el, binding) {
        el.removeEventListener('click', el.debounceFn)
        el.debounceFn = debounce(binding.value)
        el.addEventListener('click', el.debounceFn)
      },

控制台执行结果

log3.png

完全没问题,防抖也正常,下面放下改动后的v-debounce

js
const debounce = (fn) => {
  let timer
  return () => {
    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(() => {
      fn()
    }, 300)
  }
}

// 自定义防抖指令
const vDebounce = {
  install(Vue) {
    Vue.directive('debounce', {
      bind(el, binding) {
        el.debounceFn = debounce(binding.value)
        el.addEventListener('click', el.debounceFn)
      },
      /* 解决代码 start */
      componentUpdated(el, binding) {
        el.removeEventListener('click', el.debounceFn)
        el.debounceFn = debounce(binding.value)
        el.addEventListener('click', el.debounceFn)
      },
      /* 解决代码 end */
      unbind(el) {
        el.removeEventListener('click', el.debounceFn)
      },
    })
  },
}

Vue.use(vDebounce)

上次更新于: