善用vue指令

483 阅读2分钟

前言

directive是一个容易被人遗忘的vue属性,因为vue的开发思想不推崇直接对dom进行操作,但是适当的dom操作有利于提升工程的健壮性。

使用方式

关于指令的具体讲解请看官方文档

https://cn.vuejs.org/v2/guide/custom-directive.html

其中bind函数使用较为频繁,以下使用几个示例进行讲解

输入框聚焦后自动select文本

QQ20190311-103104

代码:

Vue.directive('selectTextOnFocus', {
    bind: function(el, binding) {
        function bindDom(el) {
            if (el.tagName !== 'INPUT') {
                [...el.children].forEach(dom => {
                    return bindDom(dom)
                })
            } else {
                el.onfocus = function() {
                    setTimeout(() => {
                        el.select()
                    }, 30)
                }
                return true
            }
        }

        bindDom(el)
    }
})

大致的思路就是从父元素递归的查找input子元素(对于组件也可以使用),如果找到input子元素,那么就绑定focus事件,并且在input focus时将元素select。

移除input[type="number"]的默认事件

对于数字输入框聚焦后按下键盘方向键或者滚动鼠标滚轮,数字会自动递增或者递减,这一功能可能会在用户不经意的情况下改变输入的值,导致提交错误的数据,可以使用如下代码解决这个问题。

Vue.directive('removeMouseWheelEvent', {
    bind: function(el, binding) {
        el.onmousewheel = function(e) {
            el.blur()
        }

        el.onkeydown = function(e) {
            if ([38, 40, 37, 39].includes(e.keyCode)) {
                e.preventDefault()
            }
        }
    }
})

对于以上两个指令,使用方式如下

<input type="number" v-selectTextOnFocus v-removeMouseWheelEvent>

很方便的就给输入框添加了这两个功能

table滚动加载

QQ20190311-105635

element ui的表格组件不提供一个滚动加载的功能,但是既想使用element ui的table组件又想获得滚动加载的功能,那么就需要指令来完成这一功能,先看看指令的写法。

Vue.directive('scrollLoad', {
    bind: function(el, binding) {
        let lastPotion = 0
        const scrollWrap = el.querySelector('.el-table__body-wrapper')

        scrollWrap.onscroll = function() {
            const distanceRelativeToBottom = this.scrollHeight - this.scrollTop - this.clientHeight
            const direction = getDirection(lastPotion, this.scrollTop)
            lastPotion = this.scrollTop
            binding.value({
                direction,
                scrollTop: this.scrollTop,
                distanceRelativeToBottom
            })
        }

        function getDirection(last, now) {
            return now - last > 0 ? 'down' : 'up'
        }
    }
})

首先找到 .el-table__body-wrapper 这一元素,这是element ui 表格的容器(除去表头),其次给它添加onscroll事件,在滚动时进行位置的计算,并且将计算得到的方向以及位置信息传递给传入的回调函数,由回调函数来判断是否应该进行数据请求。

<el-table v-scrollLoad="scrollLoad">
binding.value({
    direction,
    scrollTop: this.scrollTop,
    distanceRelativeToBottom
})

方向键切换input输入框

QQ20190311-113641

一个表单的优化体验功能,在一两个月前对于这个功能,大致是这样实现的

<tr v-for="(goods, index) in tableData">
    <td class="t3">
        <input :ref="getRef(index, 1)" :data-ref="getRef(index, 1)" @focus="inputFocus($event)">
    </td>
    <td class="t4">
        <input :ref="getRef(index, 2)" :data-ref="getRef(index, 2)" @focus="inputFocus($event)">
    </td>
    <td class="t5">
        <input :ref="getRef(index, 3)" :data-ref="getRef(index, 3)" @focus="inputFocus($event)">
    </td>
</tr>
inputFocus(e) {
    this.nowInputAt = e.target.getAttribute('data-ref')
},

getRef(row, column) {
    return row + ':' + column
},

keydown(event) {
    let [row, column] = this.nowInputAt.split(':').map(value => parseInt(value))
    let pos = {
        row,
        column
    }
    if ([38, 40, 37, 39, 13].includes(event.keyCode)) {
        event.preventDefault()
    }

    function up() {
        if (pos.row === 0) return
        pos.row -= 1
    }

    function down() {
        if (pos.row === this.tableData.length - 1) return
        let a = pos.row + 1
        pos.row = a
    }

    function left() {
        if (pos.row === 0 && pos.column === 1) return
        if (pos.column === 1) {
            pos.row -= 1
            pos.column = 3
        } else {
            pos.column -= 1
        }
    }

    function right() {
        if (pos.row === this.tableData.length - 1 && pos.column === 3) return
        if (pos.column === 3) {
            pos.row += 1
            pos.column = 1
        } else {
            pos.column += 1
        }
    }

    switch (event.keyCode) {
        case 38: up.call(this); break
        case 40: down.call(this); break
        case 37: left.call(this); break
        case 39: right.call(this); break
        case 13: right.call(this); break
    }
    this.$nextTick(() => {
        this.nowInputAt = pos.row + ':' + pos.column
        this.$refs[pos.row + ':' + pos.column][0].focus()
    })
},

大致的做法就是给每一个input设置一个坐标信息,当输入框聚焦时存储当前的坐标,当方向键按下时利用存储的坐标信息进行计算得到下一个输入框同时进行聚焦。计算坐标的算法有点类似于推箱子游戏。

如今同样的需求出现在了另一个表单,如果复制一份代码,就很不优雅,于是决定使用指令来完成这一需求,先看看实现,接下来拆分代码进行讲解。

import _ from 'lodash'

export default function() {
    let gridSquare = []
    let pos = {
        column: 0,
        row: 0
    }
    let parentEl = null
    let keyUpDebounceFn = null

    function findRow(element) {
        if (!element) return
        if (element.dataset && 'sokobanrow' in element.dataset) {
            const row = []
            const findCol = function(htmlEl) {
                if (!htmlEl) return
                if (htmlEl.dataset && 'sokobancol' in htmlEl.dataset) {
                    row.push(htmlEl)
                } else {
                    [...htmlEl.childNodes].forEach(dom => {
                        findCol(dom)
                    })
                }
            }
            findCol(element)
            gridSquare.push(row)
        } else {
            [...element.childNodes].forEach(dom => {
                findRow(dom)
            })
        }
    }

    function registerGrid() {
        findRow(parentEl)
    }

    function bindEvent() {
        bindFocusEvent()
        bindKeyDownEvent()
    }

    function bindFocusEvent() {
        gridSquare.forEach((row, rowIndex) => {
            row.forEach((cell, cellIndex) => {
                cell.addEventListener('focus', function() {
                    pos = {
                        column: cellIndex,
                        row: rowIndex
                    }
                })
            })
        })
    }

    function bindKeyDownEvent() {
        const keyEvent = function(event) {
            // 上 38
            // 下 40
            // 左 37
            // 右 39
            // 回车 13
            if ([38, 40, 37, 39, 13].includes(event.keyCode)) {
                event.preventDefault()
            }

            function up() {
                if (pos.row === 0) return
                pos.row -= 1
            }

            function down() {
                if (pos.row === gridSquare.length - 1) return
                pos.row += 1
            }

            function left() {
                if (pos.row === 0 && pos.column === 0) return
                if (pos.column === 0) {
                    pos.row -= 1
                    pos.column = gridSquare[pos.row].length - 1
                } else {
                    pos.column -= 1
                }
            }

            function right() {
                if (pos.row === gridSquare.length - 1 && pos.column === gridSquare[gridSquare.length - 1].length - 1) return
                if (pos.column === gridSquare[pos.row].length - 1) {
                    pos.row += 1
                    pos.column = 1
                } else {
                    pos.column += 1
                }
            }

            switch (event.keyCode) {
                case 38: up(); break
                case 40: down(); break
                case 37: left(); break
                case 39: right(); break
                case 13: right(); break
            }
            try {
                gridSquare[pos.row][pos.column].focus()
            } catch (e) {}
        }

        keyUpDebounceFn = _.debounce(keyEvent, 100)
        window.addEventListener('keyup', keyUpDebounceFn)
    }

    return {
        bind(el, binding) {
            parentEl = el
        },

        unbind() {
            gridSquare = null
            pos = null
            parentEl = null
            window.removeEventListener('keyup', keyUpDebounceFn)
            keyUpDebounceFn = null
        }

        init() {
            gridSquare = []
            pos = {
                column: 0,
                row: 0
            }
            registerGrid()
            bindEvent()
        }
    }
}

首先定义一个闭包函数用于缓存dom节点,以及当前聚焦的位置信息等相关信息。其次闭包函数返回vue指令需要的对象,同时在此对象中,包含了自定义的init函数。这个函数的作用在于,因为对于动态渲染的dom节点,bind函数是无法获取到最新的dom节点,那么就需要暴露出init函数,用于延时绑定。其实指令也提供了 update componentUpdated 函数用于检测dom的改变,但是如果dom节点有一些例如v-if v-show 或者style的改变,都会触发这两个事件,所以这里暂不使用这两个事件进行初始化,会降低性能,同时提供 unbind 钩子以供元素销毁时释放闭包内的变量,代码如下:

import _ from 'lodash'

export default function() {
    const gridSquare = []
    let pos = {
        column: 0,
        row: 0
    }
    let parentEl = null
    let keyUpDebounceFn = null

    function registerGrid() {}

    function bindEvent() {}

    return {
        bind(el, binding) {
            parentEl = el
        },

        unbind() {
            gridSquare = null
            pos = null
            parentEl = null
            window.removeEventListener('keyup', keyUpDebounceFn)
            keyUpDebounceFn = null
        }

        init() {
            registerGrid()
            bindEvent()
        }
    }
}

其次初始化时,进行input输入框的二维坐标模型的建立,具体做法是,首先给每一行定义一个自定义属性 data-sokobanrow 以及每一列定义自定义属性 data-sokobancol,其次深度优先递归查找相关dom,如果是行元素,那么就新建一个数组(X轴),如果是列元素(Y轴),那么就将此dom push到X轴数组中,最后将X轴数组push到网格数组中,最终得到一个内部存放input DOM的二维数组。

<table v-sokoban>
    <tr data-sokobanrow v-for="(goods, index) in tableData">
        <td>
            <input data-sokobancol>
        </td>
        <td>
            <input data-sokobancol>
        </td>
        <td>
            <input data-sokobancol>
        </td>
    </tr>
</table>
const gridSquare = []

function findRow(element) {
    if (!element) return
    if (element.dataset && 'sokobanrow' in element.dataset) {
        const row = []
        const findCol = function(htmlEl) {
            if (htmlEl.dataset && 'sokobancol' in htmlEl.dataset) {
                row.push(htmlEl)
            } else {
                [...htmlEl.childNodes].forEach(dom => {
                    findCol(dom)
                })
            }
        }
        findCol(element)
        gridSquare.push(row)
    } else {
        [...element.childNodes].forEach(dom => {
            findRow(dom)
        })
    }
}

function registerGrid() {
    findRow(parentEl)
}

然后再进行相关的事件绑定,在input focus时存储当前坐标信息,在keyup时计算相关坐标得到下一input坐标并且使其focus,代码如下:

function bindEvent() {
    bindFocusEvent()
    bindKeyDownEvent()
}

function bindFocusEvent() {
    gridSquare.forEach((row, rowIndex) => {
        row.forEach((cell, cellIndex) => {
            cell.addEventListener('focus', function() {
                pos = {
                    column: cellIndex,
                    row: rowIndex
                }
            })
        })
    })
}

function bindKeyDownEvent() {
    const keyEvent = function(event) {
        // 上 38
        // 下 40
        // 左 37
        // 右 39
        // 回车 13
        if ([38, 40, 37, 39, 13].includes(event.keyCode)) {
            event.preventDefault()
        }

        function up() {
            if (pos.row === 0) return
            pos.row -= 1
        }

        function down() {
            if (pos.row === gridSquare.length - 1) return
            pos.row += 1
        }

        function left() {
            if (pos.row === 0 && pos.column === 0) return
            if (pos.column === 0) {
                pos.row -= 1
                pos.column = gridSquare[pos.row].length - 1
            } else {
                pos.column -= 1
            }
        }

        function right() {
            if (pos.row === gridSquare.length - 1 && pos.column === gridSquare[gridSquare.length - 1].length - 1) return
            if (pos.column === gridSquare[pos.row].length - 1) {
                pos.row += 1
                pos.column = 1
            } else {
                pos.column += 1
            }
        }

        switch (event.keyCode) {
            case 38: up(); break
            case 40: down(); break
            case 37: left(); break
            case 39: right(); break
            case 13: right(); break
        }
        try {
            gridSquare[pos.row][pos.column].focus()
        } catch (e) {}
    }

    keyUpDebounceFn = _.debounce(keyEvent, 100)
    window.addEventListener('keyup', keyUpDebounceFn)
}

最终用法如下:

<table v-sokoban>
    <tr data-sokobanrow v-for="(goods, index) in tableData">
        <td>
            <input data-sokobancol>
        </td>
        <td>
            <input data-sokobancol>
        </td>
        <td>
            <input data-sokobancol>
        </td>
    </tr>
</table>
import sokobanDirectiveGenerator from '@/directives/sokoban'
const sokoban = sokobanDirectiveGenerator()

export default {
    directives: {
        sokoban
    },

    methods: {
        getServerData() {
            setTimeout(() => { // 一个异步请求
                this.$nextTick(() => {
                    sokoban.init() // 页面渲染后进行初始化
                })
            }, 1000)
        }
    }
}

尾声

其实可以发现,这几个指令基本上都是为了优化体验而编写,而这样的功能在一个系统中肯定是大量存在的,所以使用指令,可以极大的节省代码,从而提升工程的健壮性。