vue2-抽象组件-limiter-防抖节流组件正确姿势

引言

​ 防抖、节流,作为前端优化中常用的手段,经常会出现在各种地方,比如按钮的点击、输入框的输入、浏览器视窗的变化、滚动条的滑动等等。一般我们实现也是写一个函数,在需要用到的地方引入并使用。

​ 在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是否添加防抖逻辑Booleanfalse
throttle是否添加节流逻辑Booleanfalse
timeout对应时间间隔String写成数字就行
events监听的事件Arrayitem:‘click’ / ‘input’
deep是否深度监听Booleanfalse

总体实现说明

<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不是初次渲染则执行其他逻辑,使得防抖逻辑不会失效

使用到的自定义函数说明

函数名参数列表返回值功能
__debouncecb(回调函数)、 ctx(执行上下文) 、 timeout(时间间隔)nonecb添加防抖逻辑
__throttlecb(回调函数) 、 ctx(执行上下文) 、 timeout(间隔)nonecb添加节流逻辑
__isComNodevnode(虚拟节点)Boolean判断vnode是否是组件节点
__addvnode(虚拟节点)nonevnode添加limiter(防抖 / 节流)
dataExtendoldVnode(旧虚拟节点) 、 newVnode(新虚拟节点)none继承oldVnode的事件
whatIsvariable(任意变量)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的许多。

感谢看到最后😉

  • 5
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值