手写自定义指令实现全键盘操作,参考vue-direction-key源码

1,327 阅读5分钟

一、前言

Vue项目中有许多表格需要使用快捷键操作,以提高操作速度,比如财务报表,销售开单等的快速输入和保存上。如果直接使用DOM方法进行处理的话,性能较差,且Vue并不推荐直接操作DOM对象。了解到vue-direction-key解决了这样一个问题,因此想手写一个类似的指令。

那我们先了解一下vue-direction-key的基本使用

二、vue-direction-key的使用

1. 下载

npm i vue-direction-key -S

2.在入口文件main.js中引入插件

import Direction from 'vue-direction-key'
Vue.use(Direction)

基本用法

1. 在需要的input框或者el-input框上绑定指定

  • v-direction的值是当前表格所在的坐标位置
  • 不绑定指令的input框,键盘事件会跳过当前单元格
<el-table :data="tableData" border style="width: 100%">
  <el-table-column prop="code" label="商品编号" width="180">
    <template slot-scope="{ row, $index }">
      <el-input v-model="row.code" v-direction="{ x: 0, y: $index }"></el-input>
    </template>
  </el-table-column>
  <el-table-column prop="name" label="商品" width="180">
    <template slot-scope="{ row, $index }">
      <el-input v-model="row.name" v-direction="{ x: 1, y: $index }"></el-input>
    </template>
  </el-table-column>
  <el-table-column prop="barcodes" label="商品条码">
    <template slot-scope="{ row }">
      <!-- 可以不绑定指令,不绑定指令的时候就会跳过该单元格 -->
      <el-input v-model="row.barcodes"></el-input>
    </template>
  </el-table-column>
  <el-table-column prop="specification" label="规格">
    <template slot-scope="{ row, $index }">
      <el-input v-model="row.specification" v-direction="{ x: 3, y: $index }"></el-input>
    </template>
  </el-table-column>
</el-table>
...
<script>
export default {
  data() {
    return {
      tableData: [
        {
          code: '20220513001',
          name: '连衣裙',
          barcodes: '52132124511',
          specification: '红色'
        },
        {
          code: '20220513001',
          name: '连衣裙',
          barcodes: '52132124511',
          specification: '红色'
        },
        {
          code: '20220513001',
          name: '连衣裙',
          barcodes: '52132124511',
          specification: '红色'
        },
      ]
    }
  }
}
</script>

2. 在生命周期钩子中监听键盘事件,并做对应操作

  • 可以在ceatedmounted中监听事件并处理,官网推荐在created
  • direction的移动方法中可以接收两个参数,表示对应点的坐标,默认为当前聚焦元素的坐标
    • 比如:direction.next(1,2),移动到x=1,y=2 坐标的右边,也就是:x=2,y=2
created() {
  // 在源码中,这行代码相当于是在获取操作键盘的实例,它具有上移,下移等方法
  let direction = this.$getDirection()
  // 监听键盘操作事件,内部处理就是document.addEventListener
  direction.on('keyup', function (e, val) {
    // e是event对象,val是当前操作的元素的坐标,如:{x: 0, y: 0}
    if (e.key === 'ArrowUp') { // 这里的键盘事件可以自己指定
      console.log('按下 ↑ 键')
      direction.previousLine() // 上移
    } else if (e.key === 'ArrowRight' || e.key === 'Enter') {
      console.log('按下 → 键,或 Enter 键')
      direction.next() // 右移
    } else if (e.key === 'ArrowDown') {
      console.log('按下 ↓ 键')
      direction.nextLine() // 下移
    } else if (e.key === 'ArrowLeft') {
      console.log('按下 ← 键')
      direction.previous() // 左移
    }
  })
 },

效果:

多组件共存情况的使用

  • 比如有多个表格,每个表格对应一个坐标体系
  • 这种情况,在指令中增加参数来区别,如:v-direction:a="{x: 0, y: 0}"
  • 然后在this.$getDirection('a')中传入这个参数

以下案例是官网的案例

<input type="text" v-direction:a="{x: 0, y: 0}">
<input type="text" v-direction:a="{x: 1, y: 0}">
<input type="text" v-direction:b="{x: 0, y: 0}">
<input type="text" v-direction:b="{x: 1, y: 0}">
created: function () {
  let a = this.$getDirection('a')
  a.on('keyup', function (e, val) {
    if (e.keyCode === 39) {
      a.next()
    }
    if (e.keyCode === 37) {
      a.previous()
    }
    if (e.keyCode === 38) {
      a.previousLine()
    }
    if (e.keyCode === 40) {
      a.nextLine()
    }
  })


  let b = this.$getDirection('b')
  b.on('keyup', function (e, val) {
    if (e.keyCode === 39) {
      b.next()
    }
    if (e.keyCode === 37) {
      b.previous()
    }
    if (e.keyCode === 38) {
      b.previousLine()
    }
    if (e.keyCode === 40) {
      b.nextLine()
    }
  })
}

三、手写实现

本次实现不考虑以下问题:

  1. 多组件共存的情况
  2. 未使用插件的形式,主要是实现指令和操作类
  3. 不对方法监听进行封装,直接在created中使用document.addEventListener

思路分析

  1. 通过指令收集表格的坐标体系,采用二维数组的方式收集,比如以下二维数组:
let arr = [
  [A, B, C], // 第一行,如:A的坐标就是{x:0,y:0}
  [D, E, F]  // 第二行
]

在表格坐标体系中的位置如下:

  1. 定义一个操作类,并将其实例挂载到Vue原型上
    • 这个操作类中具有nextprevious等方法,让对应坐标的元素获取焦点

实现代码:

direction-key.js

  • 自定义指令,定义操作类
import Vue from 'vue'
// 声明一个数组,用于存储需要键盘操作的input元素,这是一个二维数组
const nodeArr = []
// 自定义指令
Vue.directive('direction', {
  inserted: function (el, binding) {
    // 获取绑定自定义指令的元素的坐标
    if (!nodeArr[binding.value.y]) {
      nodeArr[binding.value.y] = []
    }
    if (el.tagName !== 'input') {
      // 如果元素不是input,则找其子元素中的input
      el = el.querySelector('input')
    }
    // 将元素,和其坐标,保存在二维数组的对应位置
    nodeArr[binding.value.y][binding.value.x] = {
      el,
      value: binding.value // 指令的值{x:??,y:??}
    }
  }
})

// 定义一个操作类,该类接收需要操作的二维数组
class Direction {
  constructor(nodeArr) {
    this.nodeArr = nodeArr
    this.x = 0
    this.y = 0
  }
  // x轴上前进,右移,接收当前元素作为参数
  next(target) {
    // 获取当前元素的X和Y,以这个目标元素作为参照点
    this.getXandY(target)
    // 处理坐标
    // X轴上加1,如果超长换行,最后一个不做处理
    if (this.x < this.nodeArr[this.y].length - 1) {
      this.x += 1
    } else {
      this.y += 1
      if (this.y < this.nodeArr.length) {
        this.x = 0
      } else {
        console.log('到底了')
        return
      }
    }
    // 获取下一个node
    const node = this.nodeArr[this.y][this.x]
    if (node && node.el) {
      node.el.focus() // 下一个node获取焦点
    } else {
      this.next(null) // 下一个元素为空,直接跳过
    }
  }
  // x轴上左移
  previous(target) {
    this.getXandY(target)
    // X轴上减1,到第一个时回退到上一行,到头时不做操作
    if (this.x > 0) {
      this.x -= 1
    } else {
      this.y -= 1
      if (this.y >= 0) {
        this.x = this.nodeArr[this.y].length - 1
      } else {
        console.log('到头了')
        return
      }
    }
    const node = this.nodeArr[this.y][this.x]
    if (node && node.el) {
      node.el.focus() // 下一个node获取焦点
    } else {
      this.previous(null) // 下一个元素为空,直接跳过
    }
  }
  // 下移
  nextLine(target) {
    this.getXandY(target)
    // y轴上加1,到底后不做操作
    if (this.y < this.nodeArr.length - 1) {
      this.y += 1
    } else {
      console.log('Y轴到底了')
      return
    }
    const node = this.nodeArr[this.y][this.x]
    if (node && node.el) {
      node.el.focus() // 下一个node获取焦点
    } else {
      this.nextLine(null) // 下一个元素为空,直接跳过
    }
  }
  // 上移
  previousLine(target) {
    this.getXandY(target)
    // y轴上减1,到头后不做操作
    if (this.y > 0) {
      this.y -= 1
    } else {
      console.log('Y轴到头了')
      return
    }
    const node = this.nodeArr[this.y][this.x]
    if (node && node.el) {
      node.el.focus() // 下一个node获取焦点
    } else {
      this.previousLine(null) // 下一个元素为空,直接跳过
    }
  }
  // 获取目标元素的X和Y
  getXandY(target) {
    if (target) {
      this.nodeArr.forEach(list => {
        list.forEach(node => {
          if (node.el === target) {
            this.x = node.value.x
            this.y = node.value.y
          }
        })
      })
    }
  }
}
// 向外暴露操作类的实例
export default new Direction(nodeArr)

main.js中引入,并挂载到VUE原型上

import direction from './directives/direction-key'
Vue.prototype.$direction = direction

使用:

<template>
  <div id="app">
    <el-table :data="tableData" border style="width: 100%">
      <el-table-column prop="code" label="商品编号" width="180">
        <template slot-scope="{ row, $index }">
          <!-- 类readonlyClass,用于处理input框失去焦点时,不显示外框 -->
          <!-- @focus="row.active = 0" @blur="row.active = 11",都是用于处理样式的,与主代码无关 -->
          <el-input v-model="row.code" :class="{ readonlyClass: row.active !== 0 }" @focus="row.active = 0" @blur="row.active = 11" v-direction="{ x: 0, y: $index }"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="name" label="商品" width="180">
        <template slot-scope="{ row, $index }">
          <el-input v-model="row.name" :class="{ readonlyClass: row.active !== 1 }" @focus="row.active = 1" @blur="row.active = 11" v-direction="{ x: 1, y: $index }"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="barcodes" label="商品条码">
        <template slot-scope="{ row }">
          <!-- 这里没有绑定v-direction ,键盘操作事件时会跳过该单元格-->
          <el-input v-model="row.barcodes" :class="{ readonlyClass: row.active !== 2 }" @focus="row.active = 2" @blur="row.active = 11"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="specification" label="规格">
        <template slot-scope="{ row, $index }">
          <el-input v-model="row.specification" :class="{ readonlyClass: row.active !== 3 }" @focus="row.active = 3" @blur="row.active = 11" v-direction="{ x: 3, y: $index }"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="warehouse" label="仓库">
        <template slot-scope="{ row, $index }">
          <el-input v-model="row.warehouse" :class="{ readonlyClass: row.active !== 4 }" @focus="row.active = 4" @blur="row.active = 11" v-direction="{ x: 4, y: $index }"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="count" label="数量">
        <template slot-scope="{ row, $index }">
          <el-input v-model="row.count" :class="{ readonlyClass: row.active !== 5 }" @focus="row.active = 5" @blur="row.active = 11" v-direction="{ x: 5, y: $index }"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="stock" label="库存">
        <template slot-scope="{ row, $index }">
          <el-input v-model="row.stock" :class="{ readonlyClass: row.active !== 6 }" @focus="row.active = 6" @blur="row.active = 11" v-direction="{ x: 6, y: $index }"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="unit" label="单位">
        <template slot-scope="{ row, $index }">
          <el-input v-model="row.unit" :class="{ readonlyClass: row.active !== 7 }" @focus="row.active = 7" @blur="row.active = 11" v-direction="{ x: 7, y: $index }"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="weight" label="重量">
        <template slot-scope="{ row, $index }">
          <el-input v-model="row.weight" :class="{ readonlyClass: row.active !== 8 }" @focus="row.active = 8" @blur="row.active = 11" v-direction="{ x: 8, y: $index }"></el-input>
        </template>
      </el-table-column>
      <el-table-column prop="price" label="单价">
        <template slot-scope="{ row, $index }">
          <el-input v-model="row.price" :class="{ readonlyClass: row.active !== 9 }" @focus="row.active = 9" @blur="row.active = 11" v-direction="{ x: 9, y: $index }"></el-input>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
  export default {
    // 在created中监听键盘事件
    created() {
      document.addEventListener('keyup', e => {
        if (e.key === 'ArrowUp') {
          console.log('按下 ↑ 键')
          // 传入当前元素,作为参照点
          this.$direction.previousLine(e.target)
        } else if (e.key === 'ArrowRight' || e.key === 'Enter') {
          console.log('按下 → 键,或 Enter 键')
          this.$direction.next(e.target)
        } else if (e.key === 'ArrowDown') {
          console.log('按下 ↓ 键')
          this.$direction.nextLine(e.target)
        } else if (e.key === 'ArrowLeft') {
          console.log('按下 ← 键')
          this.$direction.previous(e.target)
        }
      })
    },
    data() {
      return {
        tableData: [
          {
            code: '20220513001',
            name: '连衣裙',
            barcodes: '52132124511',
            specification: '红色',
            warehouse: '主仓库',
            count: 5,
            stock: '53件',
            unit: '件',
            weight: '0.3kg',
            price: 120,
            active: 1 // 1商品编号获取焦点,11是没有单元格被激活。用于处理获取焦点或失去焦点时候的样式。
          },
          {
            code: '20220513001',
            name: '连衣裙',
            barcodes: '52132124518',
            specification: '红色',
            warehouse: '主仓库',
            count: 5,
            stock: '53件',
            unit: '件',
            weight: '0.3kg',
            price: 120,
            active: 11
          },
          {
            code: '20220513001',
            name: '连衣裙',
            barcodes: '52132124511',
            specification: '红色',
            warehouse: '主仓库',
            count: 5,
            stock: '53件',
            unit: '件',
            weight: '0.3kg',
            price: 120,
            active: 11
          },
          {
            code: '20220513001',
            name: '连衣裙',
            barcodes: '52132124511',
            specification: '红色',
            warehouse: '主仓库',
            count: 5,
            stock: '53件',
            unit: '件',
            weight: '0.3kg',
            price: 120,
            active: 11
          },
          {
            code: '20220513001',
            name: '连衣裙',
            barcodes: '52132124511',
            specification: '红色',
            warehouse: '主仓库',
            count: 5,
            stock: '53件',
            unit: '件',
            weight: '0.3kg',
            price: 120,
            active: 11
          },
          {
            code: '20220513001',
            name: '连衣裙',
            barcodes: '52132124511',
            specification: '红色',
            warehouse: '主仓库',
            count: 5,
            stock: '53件',
            unit: '件',
            weight: '0.3kg',
            price: 120,
            active: 11
          }
        ]
      }
    }
  }
</script>

<style>
  .el-table__body-wrapper .cell {
    padding: 0 0 !important;
    line-height: normal !important;
  }
  .el-table__body-wrapper .el-table__cell {
    padding: 0 0 !important;
  }
  .el-table__body-wrapper .el-input input {
    border-radius: 0;
  }
  .el-table__body-wrapper .el-input.readonlyClass input {
    background: transparent;
    border-color: transparent;
  }
</style>

效果: