创建一个
Vue指令,用于自定义格式列表的渲染中快捷键的使用
背景
业务场景中,列表渲染很常见,在PC或者外接键盘的情况下,对于快捷键的支持,能够给用户带来良好的体验效果。
在每个业务中去做键盘事件监听可以实现这样的效果,但是,如果这样的业务场景较多的时候,不可避免的会造成代码的重复使用。所以,我们采用引入指令的方式,简化业务代码中的书写。避免大量的重复操作。
想要的效果
一般针对列表的处理,我们一边是选择的时候回去使用快捷键,查看的时候也会用到,所以我们做如下的要求:
- 可以默认选中一行,也可以不选中
- 支持上下键切换行
- 支持回车键选中当前行
所以,我们可以从自定义一些参数提供开发人员自行选择:
defaultSelect:默认选中项(可以不传,默认不选中)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树绘制前调用,inserted在dom树绘制后调用
由于我们这里需要在上下切换时,需要同时控制滚动条的滚动,所以,我们在这里使用 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、初始化参数
这里主要是针对一些自定义的场景
- 把接收的数据存放到我们的scope属性上。
- 设置默认选中,并且把样式处理一下,给选中的行添加样式。
- 增加一个参数
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、控制快捷键的操作
接下来就是针对用户的操作处理了。
- 按下向下键
- 按下向上键
- 按下回车键:回调函数执行并传递数据
- 其他:不作处理
// 键盘监听事件触发
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、页面的渲染
这里主要是切换行后:
- 行选中的样式添加以及取消行选中的样式移除。
- 保证当前行始终在用户可视范围
- 向下按键,直到最可视区域看不见当前行,才向下滚动;
- 向上按键,直到可视区域可视区域看不见,就向上滚动一个高度。
// 处理视图渲染
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);
}
});