文章目录
引言
防抖、节流,作为前端优化中常用的手段,经常会出现在各种地方,比如按钮的点击、输入框的输入、浏览器视窗的变化、滚动条的滑动等等。一般我们实现也是写一个函数,在需要用到的地方引入并使用。
在Vue2中给methods中的某个函数添加防抖:
methods: {
testFunc(){ //需要防抖的函数
console.log(this)
}
},
mounted() {
this.testFunc = Debounce(this.testFunc, this, 1000) //在挂载后给特定函数防抖
}
Q:为什么不在methods中直接添加呢?
methods: {
testFunc:Debounce(function (){
console.log(this)
}, this, 1000) //此时传入的this为undefined
},
A:vue2中只保证了在methods中的函数可以访问到当前vm实例
methods: {
print(){
console.log(this) //输出当前vm实例
setTimeout(() => {
console.log(this) //输出当前vm实例
})
},
},
有点麻烦有没有?此时我们希望代码写成这样也能实现防抖功能:
<sss-limiter debounce timeout="1000" :events="['click', 'input']" deep>
<sss-nexter>
<el-input @input.native="func(1)" v-model="str1" placeholder="组件输入框"></el-input>
<input @input="func(2)" v-model="str2" placeholder="普通输入框">
<sss-button type="main" round @click.native="func(3)">自定义组件按钮</sss-button>
</sss-nexter>
<button @click="testFunc()">普通按钮</button>
</sss-limiter>
效果展示:插入视频在此处
limiter演示
sss-nexter具体作用是为输入框和按钮添加功能:按下enter按钮聚焦到下一个输入框或按钮。详见:vue2-抽象组件-nexter
在此处如果忽略它的功能,可以直接把它等价为一个div
limiter
为什么我要自己写一个?
在这之前,我看过好几篇关于防抖抽象组件的文章了,但是那些代码或多或少都有点问题:
- 只能运用于button按钮
- 只能为单个元素添加,比如元素非常多时,需要逐个添加
- 只能为单层元素添加,比如要添加的元素被
div
包裹了一层,则会失效。 - 不兼容组件形式的元素,比如el-button, el-input
- 若元素时input框,且input框运用了v-model,防抖失效
- …
这次我们设计一个组件,解决上诉所有问题!!!
limiter应该具有的作用
前置说明,这次防抖组件设计是为了为按钮,输入框添加防抖,对于其他类型暂时不考虑
limiter应该可以:适用于button,input、适用于自定义buton、input组件(比如elmentUI)、能够为多个元素添加limiter逻辑,并且无论元素的嵌套关系…
参数 | 描述 | 类型 | 可选值 | 默认值 |
---|---|---|---|---|
debounce | 是否添加防抖逻辑 | Boolean | — | false |
throttle | 是否添加节流逻辑 | Boolean | — | false |
timeout | 对应时间间隔 | String | 写成数字就行 | — |
events | 监听的事件 | Array | item:‘click’ / ‘input’ | — |
deep | 是否深度监听 | Boolean | — | false |
总体实现说明
<sss-limiter debounce timeout="1000" :events="['click', 'input']" deep>
<sss-nexter>
<el-input @input.native="func(1)" v-model="str1" placeholder="组件输入框"></el-input>
<input @input="func(2)" v-model="str2" placeholder="普通输入框">
<sss-button type="main" round @click.native="func(3)">自定义组件按钮</sss-button>
</sss-nexter>
<button @click="testFunc()">普通按钮</button>
</sss-limiter>
下面都以这段代码为例子
-
对于limiter我们自然是写成一个抽象组件,
-
对于多个根元素的情况, 上述代码则有两个根元素:
sss-limiter
button
在render函数中判断根元素数量,多余1则添加div作为根元素
-
对于给自定义组件(
elementUI
)添加逻辑vue中对于元素,都会编译为虚拟节点vnode, 通过vnode的
tag
属性判断是组件节点还是普通节点,执行不同逻辑 -
对于给内部所有元素添加逻辑
获取到根元素vnode,递归遍历所有子节点,符合条件则添加
-
对于input框且使用了v-model,逻辑失效
判断是否是初次渲染
render
该抽象组件limiter
不是初次渲染则执行其他逻辑,使得防抖逻辑不会失效
使用到的自定义函数说明
函数名 | 参数列表 | 返回值 | 功能 |
---|---|---|---|
__debounce | cb (回调函数)、 ctx (执行上下文) 、 timeout (时间间隔) | none | 为cb 添加防抖逻辑 |
__throttle | cb (回调函数) 、 ctx (执行上下文) 、 timeout (间隔) | none | 为cb 添加节流逻辑 |
__isComNode | vnode (虚拟节点) | Boolean | 判断vnode 是否是组件节点 |
__add | vnode (虚拟节点) | none | 为vnode 添加limiter (防抖 / 节流) |
dataExtend | oldVnode (旧虚拟节点) 、 newVnode (新虚拟节点) | none | 继承oldVnode 的事件 |
whatIs | variable (任意变量) | String | 判断一个变量的具体类型 |
这些函数目前只需要知道他的作用就行,具体实现将会在下文有需要的地方给出
code开始
_ _debounce & _ _throttle
function __debounce(cb, ctx, timeout) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
cb.apply(ctx, ...args);
}, timeout)
}
}
function __throttle(cb, ctx, timeout) {
let staTime = 0;
return function (...args) {
const now = new Date();
if (now - staTime > timeout) {
staTime = now;
cb.apply(ctx, ...args)
}
}
}
相信这两个函数大家不会陌生,这里不做说明了就👻
render函数
相信看过其他防抖抽象组件的各位看到的大概代码是这样的:
render() {
//获取默认插槽内部元素
//为元素添加对应防抖/节流逻辑
//返回元素
},
Q:有什么问题么?
A:也许没深度监听所有元素(这是小事), 最重要的是!!,对于使用了v-model的input框,逻辑会失效
为什么?
想想vue的响应式,当组件内的某个data发生改变时,不管组件内的元素是否都会更新,但是render函数一定会执行。
最终render函数会调用patch函数对比新旧dom树来决定vnode的改变和真实dom的刷新。
当组件具有子组件时,会递归执行子组件的render函数。
当我们使用limiter这个抽象组件时,肯定是在某个组件内部使用的:
//app.vue 这里在app内部使用, 用来说明的代码, 不能运行
<template>
<sss-limiter >
<input @input="func(2)" v-model="str" placeholder="普通输入框">
</sss-limiter>
</template>
<script>
export default {
name: 'App',
data() {
return {
str: "",
}
},
methods: {
func(a) {
console.log("func, index = ", a)
},
},
}
</script>
上述代码中input框v-model了app这个组件data中的str, 当我们通过输入框输入数据时,app这个组件的data会发生变化, 之后app组件的render函数执行, 发现有sss-limiter这个子组件,则执行该组件的render函数…
当limiter组件的render函数执行后,想当给input框的input事件重新添加对应逻辑。
需要注意的是:我们一般理解是给一个函数func
当给该函数添加防抖逻辑之后,这个函数会指向一个新的函数,只有不断执行这个函数,防抖逻辑才会失效。 但问题是,假设我每次都给你一个独立的func
函数,每次都为他添加防抖逻辑变成新的独立的函数, 此时我不断输入执行这个func
函数, 实际上执行的都是分别独立的函数,防抖逻辑自然不起作用
Q:我给input框绑定了input事件的回调函数,我只写了一个,为什么说每次给一个新的独立的函数呢?
A:这涉及到vue如何将你写的.VUE文件编译并最终变成为一个vm组件和虚拟节点。
当初次渲染时,.VUE文件变为vm实例,同时vm有一个 v n o d e 属性,代表的该组件对应的虚拟节点,当重渲染时, v u e 根据原本 . V U E 文件编译出另一个虚拟节点,此时旧的 v n o d e 会变 为 n o d e ,新的 v n o d e 变成 vnode属性,代表的该组件对应的虚拟节点, 当重渲染时,vue根据原本.VUE文件编译出另一个虚拟节点,此时旧的vnode会变为_node,新的vnode变成 vnode属性,代表的该组件对应的虚拟节点,当重渲染时,vue根据原本.VUE文件编译出另一个虚拟节点,此时旧的vnode会变为node,新的vnode变成vnode, 此时的$vnode和_vnode都是通过你写的.VUE代码编译出来的, 虽然完全相同,但是两者并没有任何交集,相当于孪生兄弟?
正确的render函数姿势
render() {
// console.log("--------------------render----------")
const slots = this.$slots.default //获取默认插槽内部元素
//锁
if (this.isInitialRender) {
slots.forEach(item => {
__add.call(this, item)
})
}
this.isInitialRender = false
return slots.length > 1 ? h('div', slots) : slots
},
其中slots获取内部所有根节点,然后分别为节点添加防抖/限流逻辑
this.isInitialRender
为该抽象组件data的一个属性,标志是否初次render,默认为true
通过上面的代码完成了:监听所有根节点、 深度监听(__add内部递归)
对于v-model产生的问题,我们现在只是防止了重新添加防抖逻辑,但是render函数依旧会产生新的vnode节点,之后我们要做的只有一件事:将新的vnode的事件属性全部改为旧的vnode的事件属性。
_add
在继续下去之前,我们先了解下_add函数如何实现的
- add函数实现为当前(符合条件的)节点添加对应逻辑
- 通过判断
events
属性分别为不同事件添加逻辑 - add函数应该递归当前节点的子节点(
deep
为true时)
function __add(vnode) {
//exit
if (!vnode || !this.events || !vnode.tag) return
// console.log("__add", vnode)
//click事件
if (this.events.includes('click')) {
const cb = this._.get(vnode, 'data.on.click')
if (typeof cb === 'function') {
if (this.debounce) {
vnode.data.on.click = __debounce(cb, null, Number.parseInt(this.timeout))
} else if (this.throttle) {
vnode.data.on.click = __throttle(cb, null, Number.parseInt(this.timeout))
}
}
}
//input事件
if (this.events.includes('input')) {
const cb = this._.get(vnode, 'data.on.input')
if (this.whatIs(cb) === 'Function') {
if (this.debounce) {
vnode.data.on.input = __debounce(cb, null, Number.parseInt(this.timeout))
} else if (this.throttle) {
vnode.data.on.input = __throttle(cb, null, Number.parseInt(this.timeout))
}
}
else if(this.whatIs(cb) === 'Array'){
vnode.data.on.input[1] = __debounce(cb[1], null, Number.parseInt(this.timeout))
}
}
/*递归调用*/
if (__isComNode(vnode)) { //节点为vue组件
const childrens = this._.get(vnode, "componentOptions.children");
if (this.deep && childrens) {
childrens.forEach(item => __add.call(this, item))
}
} else { //普通节点
if (this.deep && vnode.children) {
vnode.children.forEach(item => __add.call(this, item))
}
}
}
只对input事件进行说明(因为他比较特殊)
vnode部分属性介绍
首先我们要知道vnode具有那些属性:
对于组件节点:
对于普通节点:
节点的类型可以通过tag属性来判断,这也是函数__isComNode
的判断逻辑
function __isComNode(node) {
return node.tag.startsWith('vue-component')
}
-
tag:节点描述符
-
data:保存了当前vnode的所有data,这里的data可不仅仅是我们在.vue文件中写的data配置项,此data包含了该节点的各种数据信息,例如节点属性、指令、事件等,比如一个input框,那么他的input事件则会保存在data.on.input当中
-
elm:保存了当前vnode对应的真实dom
-
componentOptions:组件节点特有属性,包含有关组件选项的信息
propsData
:传递给组件的属性数据。slots
:子组件插槽的 VNode 对象列表。scopedSlots
:子组件具名插槽的 VNode 对象列表。children
:子组件实例对象列表。tag
:组件的标签名称或组件对象。attrs
:组件的非 prop 特性和特性绑定对象。props
:组件的 prop 配置对象。computed
:组件的计算属性配置对象。methods
:组件的方法配置对象。watch
:组件的侦听器配置对象。model
:组件的 v-model 配置对象。- 好有好多,更多不介绍了,目前只用到childen属性就行
-
目前只到需要用到的属性,还有好多
现在回过头来分析_add函数的input事件部分
//input事件
if (this.events.includes('input')) {
const cb = this._.get(vnode, 'data.on.input')
if (this.whatIs(cb) === 'Function') {
if (this.debounce) {
vnode.data.on.input = __debounce(cb, null, Number.parseInt(this.timeout))
} else if (this.throttle) {
vnode.data.on.input = __throttle(cb, null, Number.parseInt(this.timeout))
}
}
else if(this.whatIs(cb) === 'Array'){
vnode.data.on.input[1] = __debounce(cb[1], null, Number.parseInt(this.timeout))
}
}
首先是获取vnode的 data.in.input属性, 关于这个_是个啥,详见: Lodash 简介 | Lodash 中文文档 | Lodash 中文网 (lodashjs.com)
Q:这个cb不应该是获取到的回调函数么,为什么还要判断它是否是数组类型?
A:回调函数可能具有多个。当特定事件回调函数具有多个时,会形成一个回调函数列表。
Q:为什么只为cb[1]添加对应逻辑?
A:要知道我们写事件回调一般只为他添加一个就行,在我们的limiter当中,内部元素也只添加了应该input回调函数。
那为什么cb函数还会形成一个列表呢?
其实是v-model作用(因为这玩意我最终改了两次代码…)
v-model如何实现的?
v-model其实是一个语法糖
<!-- 在大部分情况下,以下两种写法是等价的 -->
<el-input v-model="foo" />
<el-input :value="foo" @input="foo = $event" />
详见:面试官:你真的了解v-model是什么吗?(vue2) - 知乎 (zhihu.com)
我们现在只需要知道一件事:使用了v-model之后会给当前元素添加一个input事件。
<input @input="func(2)" v-model="str2" placeholder="普通输入框">
对于上面这个元素,实际上会有两个input回调函数,而cb[1]才是我们需要的
dataExtend
在正确的render函数姿势 我们还遗留了一个问题,那就是如何将新节点的事件继承自旧节点的事件
毫无疑问,这个函数是用来实现这个功能的,那么在什么时候使用呢应该?
要实现这个功能,则需要能够同时访问到新旧vnode,很容易会想应该是在那个生命周期中。
beforeUpdate
, 因为是重渲染想到是update相关的生命周期,再者需要访问新旧dom,会想到是before。
事实也是如此:在beforeUpdate中通过this._vnode访问旧节点, 通过this.%vnode访问新节点
beforeUpdate() {
// console.log("--------------------re render----------")
let oldVnodeList = null;
const newVnodeList = this.$vnode.componentOptions.children
if (newVnodeList.length === 1) { //slots只有唯一元素
oldVnodeList = [this._vnode]
} else {
if (__isComNode(this._vnode)) {
oldVnodeList = this._vnode.componentOptions.children
} else {
oldVnodeList = this._vnode.children
}
}
for (let i = 0; i < newVnodeList.length; i++) {
this.dataExtend(oldVnodeList[i], newVnodeList[i])
}
},
Q:为什么newVnodeList
oldVnodeList
是这样赋值?
A:$vnode
是当前组件的vnode,在本文中值sss-limiter
而_vnode
是当前组件的内部根元素,在render函数中, 当根元素大于1时我们会嵌套一层div
作为根元素
这么写目的是为了让oldVnodeList和newVnodeList都代表我们真正传入limiter内部的元素,不需要外层div
之后每一个元素都执行dataExtend即可
dataExtend函数实现:
dataExtend(oldVnode, newVnode) { //新vnode->data.on继承自旧vnode->data.on
//exit
if (!newVnode || !newVnode.tag) return
// console.log("extend", newVnode)
//继承
if (this._.get(newVnode, "data.on")) newVnode.data.on = oldVnode.data.on
/*递归调用*/
if (__isComNode(newVnode)) { //节点为vue组件
const oldChildrens = this._.get(oldVnode, "componentOptions.children");
const newChildrens = this._.get(newVnode, "componentOptions.children");
if (this.deep && newChildrens) {
for (let i = 0; i < newChildrens.length; i++) {
this.dataExtend(oldChildrens[i], newChildrens[i])
}
}
} else { //普通节点
if (this.deep && newVnode.children) {
const oldChildrens = oldVnode.children
const newChildrens = newVnode.children
for (let i = 0; i < newChildrens.length; i++) {
this.dataExtend(oldChildrens[i], newChildrens[i])
}
}
}
}
因为只有data.on才是所有的事件,所有只需要继承这个才行。
为了防止意料之外的影响,也可以只继承里面的click事件和input事件。
完整code
完整code:vue-sss/sss-limiter.js at master · lastertd/vue-sss (github.com)
希望点个star⭐🧡🧡🧡
end
对于实现防抖/节流功能而言,抽象组件也许不是最佳选择,但是通过此组件让我了解了vue的许多。
感谢看到最后😉