创建一个Vue 指令,用于自定义格式列表的渲染中快捷键的使用

577 阅读7分钟

创建一个Vue 指令,用于自定义格式列表的渲染中快捷键的使用

背景

​ 业务场景中,列表渲染很常见,在PC或者外接键盘的情况下,对于快捷键的支持,能够给用户带来良好的体验效果。

​ 在每个业务中去做键盘事件监听可以实现这样的效果,但是,如果这样的业务场景较多的时候,不可避免的会造成代码的重复使用。所以,我们采用引入指令的方式,简化业务代码中的书写。避免大量的重复操作。

想要的效果

一般针对列表的处理,我们一边是选择的时候回去使用快捷键,查看的时候也会用到,所以我们做如下的要求:

  1. 可以默认选中一行,也可以不选中
  2. 支持上下键切换行
  3. 支持回车键选中当前行

所以,我们可以从自定义一些参数提供开发人员自行选择:

  1. defaultSelect:默认选中项(可以不传,默认不选中)
  2. callback:选中回调

​ 由于我们需要获取到用户想要选中的数据,所以我们需要把列表中的数据原封不动的返回到业务中处理,所以我们也需要将渲染列表的数据传递过来用于获取数据处理。

​ 所以一共有三个参数。具体定义请看下面如何使用中的注释......

分析及代码书写

业务中如何使用

* 用途:列表组件快捷键注册(上、下、回车键)(可自行扩展)
* 使用方法:(可参考会员选择弹框)
* @params { list          } Array     必  传  列表渲染数据
* @params { callback      } Function  非必传  回车选中回调函数
* @params { defaultSelect } Number    非必传  默认选中项
* 标签的使用可以自行定义,没有特殊要求
<ul v-if="list.length" v-listShortCutKey="{ list: listData, callback: select => selectData(select), defaultSelect: 0 }" >
    <li v-for="item in list">...</li>
</ul>
  • 注意点:
    • 使用此指令,务必保证 overflow: scroll 设置的准确性, 不要写无用或者多余的 scroll 属性(后面会解释原因)
    • ul 使用相对定位,保证使用快捷键操作时页面滚动的正常

指令注册

Vue.directive('listShortCutKey', {
  inserted (el, binding, vnode) {},
  unbind (el, binding, vnode) {}
});

bind 和 inserted选择使用哪一个

共同点

dom插入都会调用,bind在inserted之前

不同点

bind 时父节点为 null

inserted 时父节点存在

bind是在dom树绘制前调用,inserteddom树绘制后调用

​ 由于我们这里需要在上下切换时,需要同时控制滚动条的滚动,所以,我们在这里使用 inserted,我们要去获取到对应的滚动区域控制页面滚动显示。

1、注册键盘监听事件

​ 注册一个键盘监听事件,一般情况下,如果用户按下一个键不松手的话,我们应该是一直支持往下切换的,所以,我们为keydown这个事件版是哪个一个方法。

​ 为了存放我们自定义的数据,我们为节点增加一个 scope 属性,这个属性用来存放需要使用的数据。

​ 卸载时,一定要移除监听。一方面为了业务逻辑的准确,另一方面也避免内存的占用,避免内存泄漏。

​ 在指令注册这个阶段,我们需要处理几件事:

		1. 增加一个 scope 属性,这个属性用来存放需要使用的数据
		2. 处理用户(程序猿)自定义的参数
		3. 键盘事件监听
const scope = '@listShortCutKey';

Vue.directive('listShortCutKey', {
  inserted (el, binding, vnode) {
    // 在el上增加一个属性用于存放这个指令需要的相关数据等
    el[scope] = {};
    // 初始化数据
    init(el, binding.value);
    const fn = (e) => handleKeydown({ keyCode: e.keyCode, el });
    // 监听执行函数
    el[scope]['listener'] = fn;
    // 找到滚动视图节点
    el[scope]['scrollView'] = getScrollContainer(el);
    // 事件监听
    document.addEventListener('keydown', el[scope]['listener'], true);
  },
  update (el, binding, vnode) {
    // 列表更新时也需要同时更新
    init(el, binding.value);
  },
  unbind (el, binding, vnode) {
    // 移除事件监听
    document.removeEventListener('keydown', el[scope]['listener'], true);
  }
});

初始化的时候,还需要去找到具有超出高度出现滚动的元素,在后面行切换时,要保证当前操作行始终在用户可视范围。

所以,这里对于css样式的书写要求很高,不要写一些无用的overflow / overflow-y : scroll / auto 属性

// 找到滚动的视图,需要处理滚动
const getScrollContainer = (el) => {
  // 递归向上查找 查找带有overflow 字样的元素
  let parent = el;
  while (parent) {
    if (document.documentElement === parent) {
      // 表示没有找到
      return window;
    }
    // 获取当前元素上是否有overflow属性
    const overflow = getComputedStyle(parent)['overflow-y'];
    if (overflow.match(/scroll|auto/)) {
      return parent;
    }
    parent = parent.parentNode;
  }
  return parent;
}

2、初始化参数

这里主要是针对一些自定义的场景

  1. 把接收的数据存放到我们的scope属性上。
  2. 设置默认选中,并且把样式处理一下,给选中的行添加样式。
  3. 增加一个参数 lastIndex,记录上一个选中的行,在切换后,需要把选中样式移除。
// 初始化数据
const init = (el, opts) => {
  const { list, callback, defaultSelect } = opts;
  // 回调函数
  el[scope]['callback'] = callback;
  // 把数据绑定到el上
  el[scope]['data'] = list || [];
  // 当前选中项(默认不选中)
  if ((defaultSelect === 0 || defaultSelect) && defaultSelect < list.length) {
    el[scope]['curIndex'] = defaultSelect;
    el.children[defaultSelect] && el.children[defaultSelect].classList.add('select');
  } else {
    el[scope]['curIndex'] = -1;
  }
  // 记录上一条选中的,需要移除样式
  el[scope]['lastIndex'] = null;
}
/* 样式 */
.select {
    background-color: red;
}

3、控制快捷键的操作

接下来就是针对用户的操作处理了。

  1. 按下向下键
  2. 按下向上键
  3. 按下回车键:回调函数执行并传递数据
  4. 其他:不作处理
// 键盘监听事件触发
const handleKeydown = (opts) => {
  const { keyCode, el } = opts;
  const elData = el[scope];
  const count = elData.data.length; // 数据总条数
  // 如果没有数据,下面的不用执行
  if (count === 0) return;
  if (keyCode === 38) {
    // ArrowUp
    elData.lastIndex = elData.curIndex;
    elData.curIndex = elData.curIndex === 0 || elData.curIndex === -1 ? count - 1 : elData.curIndex - 1;
  } else if (keyCode === 40) {
    // ArrowDown
    elData.lastIndex = elData.curIndex;
    elData.curIndex = elData.curIndex === count - 1 ? 0 : elData.curIndex + 1;
  } else if (elData.curIndex !== -1 && (keyCode === 13 || keyCode === 108)) {
    // 没有选中项时,不能触发回车事件
    // Enter
    elData.callback && elData.callback(elData.data[elData.curIndex]);
    return;
  } else {
    return;
  }
  handleRender(el);
}

4、页面的渲染

这里主要是切换行后:

  1. 行选中的样式添加以及取消行选中的样式移除。
  2. 保证当前行始终在用户可视范围
    • 向下按键,直到最可视区域看不见当前行,才向下滚动;
    • 向上按键,直到可视区域可视区域看不见,就向上滚动一个高度。
// 处理视图渲染
const handleRender = (el) => {
  const elData = el[scope];
  const element = el.children; // 列表的渲染子节点
  // 给当前节点增加选中的样式
  element[elData.curIndex] && element[elData.curIndex].classList.add('select');
  // 把之前的节点样式移除
  element[elData.lastIndex] && element[elData.lastIndex].classList.remove('select');
  // 滚动处理
  const curItemEle = element[elData.curIndex];
  const scrollView = elData['scrollView'];
  const curItemTop = curItemEle.offsetTop;
  const curItemHeight = curItemEle.offsetHeight;
  const scrollViewHeight = scrollView.offsetHeight;
  const scrollViewTop = scrollView.scrollTop;
  if (curItemTop + curItemHeight > scrollViewTop + scrollViewHeight) {
    // 如果 当前行所在位置与当前行高度之和 大于 容器滚动条位置与容器高度之和,就需要向下滚动
    // 向下滚动幅度为 容器滚动条位置 与 当前行高度 之和
    let newScrollTop = scrollViewTop + curItemHeight;
    if (scrollViewHeight + newScrollTop < curItemTop + curItemHeight) {
      // 计算后的滚动条位置 若当前行依然不在视图范围内,那就是下面的场景
      scrollView.scrollTop = curItemTop;
    } else {
      // 否则就当前滚动条位置加上当前项高度即可
      scrollView.scrollTop = newScrollTop;
    }
  } else if (curItemTop <= scrollViewTop) {
    // 如果当前选中行位置小于等于容器滚动条位置,就需要向上滚动
    // 滚动条设置为当前选中行距离顶部的位置即可
    scrollView.scrollTop = curItemTop;
  }
}

完整代码

import Vue from 'vue'
const scope = '@listShortCutKey';

// 初始化数据
const init = (el, opts) => {
  const { list, callback, defaultSelect } = opts;
  // 回调函数
  el[scope]['callback'] = callback;
  // 把数据绑定到el上
  el[scope]['data'] = list || [];
  // 当前选中项(默认不选中)
  if ((defaultSelect === 0 || defaultSelect) && defaultSelect < list.length) {
    el[scope]['curIndex'] = defaultSelect;
    el.children[defaultSelect] && el.children[defaultSelect].classList.add('jdy-list-select');
  } else {
    el[scope]['curIndex'] = -1;
  }
  // 记录上一条选中的,需要移除样式
  el[scope]['lastIndex'] = null;
}

// 键盘监听事件触发
const handleKeydown = (opts) => {
  const { keyCode, el } = opts;
  const elData = el[scope];
  const count = elData.data.length; // 数据总条数
  // 如果没有数据,下面的不用执行
  if (count === 0) return;
  if (keyCode === 38) {
    // ArrowUp
    elData.lastIndex = elData.curIndex;
    elData.curIndex = elData.curIndex === 0 || elData.curIndex === -1 ? count - 1 : elData.curIndex - 1;
  } else if (keyCode === 40) {
    // ArrowDown
    elData.lastIndex = elData.curIndex;
    elData.curIndex = elData.curIndex === count - 1 ? 0 : elData.curIndex + 1;
  } else if (elData.curIndex !== -1 && (keyCode === 13 || keyCode === 108)) {
    // 没有选中项时,不能触发回车事件
    // Enter
    elData.callback && elData.callback(elData.data[elData.curIndex]);
    return;
  } else {
    return;
  }
  handleRender(el);
}

// 处理视图渲染
const handleRender = (el) => {
  const elData = el[scope];
  const element = el.children; // 列表的渲染子节点
  // 给当前节点增加选中的样式
  element[elData.curIndex] && element[elData.curIndex].classList.add('jdy-list-select');
  // 把之前的节点样式移除
  element[elData.lastIndex] && element[elData.lastIndex].classList.remove('jdy-list-select');
  // 滚动处理
  const curItemEle = element[elData.curIndex];
  const scrollView = elData['scrollView'];
  const curItemTop = curItemEle.offsetTop;
  const curItemHeight = curItemEle.offsetHeight;
  const scrollViewHeight = scrollView.offsetHeight;
  const scrollViewTop = scrollView.scrollTop;
  if (curItemTop + curItemHeight > scrollViewTop + scrollViewHeight) {
    // 如果 当前行所在位置与当前行高度之和 大于 容器滚动条位置与容器高度之和,就需要向下滚动
    // 向下滚动幅度为 容器滚动条位置 与 当前行高度 之和
    let newScrollTop = scrollViewTop + curItemHeight;
    if (scrollViewHeight + newScrollTop < curItemTop + curItemHeight) {
      // 计算后的滚动条位置 若当前行依然不在视图范围内,那就是下面的场景
      scrollView.scrollTop = curItemTop;
    } else {
      // 否则就当前滚动条位置加上当前项高度即可
      scrollView.scrollTop = newScrollTop;
    }
  } else if (curItemTop <= scrollViewTop) {
    // 如果当前选中行位置小于等于容器滚动条位置,就需要向上滚动
    // 滚动条设置为当前选中行距离顶部的位置即可
    scrollView.scrollTop = curItemTop;
  }
}

// 找到滚动的视图,需要处理滚动
const getScrollContainer = (el) => {
  // 递归向上查找 查找带有overflow 字样的元素
  let parent = el;
  while (parent) {
    if (document.documentElement === parent) {
      // 表示没有找到
      return window;
    }
    // 获取当前元素上是否有overflow属性
    const overflow = getComputedStyle(parent)['overflow-y'];
    if (overflow.match(/scroll|auto/)) {
      return parent;
    }
    parent = parent.parentNode;
  }
  return parent;
}

Vue.directive('listShortCutKey', {
  inserted (el, binding, vnode) {
    // 在el上增加一个属性用于存放这个指令需要的相关数据等
    el[scope] = {};
    // 初始化数据
    init(el, binding.value);
    const fn = (e) => handleKeydown({ keyCode: e.keyCode, el });
    // 监听执行函数
    el[scope]['listener'] = fn;
    // 找到滚动视图节点
    el[scope]['scrollView'] = getScrollContainer(el);
    // 事件监听
    document.addEventListener('keydown', el[scope]['listener'], true);
  },
  update (el, binding, vnode) {
    // 列表更新时也需要同时更新
    init(el, binding.value);
  },
  unbind (el, binding, vnode) {
    // 移除事件监听
    document.removeEventListener('keydown', el[scope]['listener'], true);
  }
});