最近遇到一个问题,在开发编辑页面时候,遇到select大数据量,一个下拉选择器有两万个备选,页面上会不定出现3-18个select,预计可能会渲染30万多个option备选,这么多dom加载,直接把浏览器干无响应了。
不想看实现过程的,可以直接
c,v代码,就能用。
第一个构想就是虚拟select
搜索一番没有合适的轮子,vxe-table 比较合适,但是为了一个问题单独引入,不合适,
只能先基于elementUI 的select选择器再度封装解决问题,节流版select
第一版,节流select
此版本的select规避页面一打开,就加载所有option导致页面卡死,无法使用为目标
<throttling-select v-model="value" :list="options" :placeholder="placeholder" selkey="key" sellabel="label"></throttling-select>
在vue组件挂载的时候,判断当前select的option数量是否超过1000个,小于则直接渲染,大于则不渲染只启用搜索渲染,借助于el-select的远程搜索功能实现
<template>
<el-select
@visible-change="popChange"
v-model="selectValue"
filterable
remote
:placeholder="placeholder"
:remote-method="remoteMethod"
:loading="loading"
style="width: calc(98% - 20px)"
>
<el-option
v-for="item in options"
:key="item[selkey]"
:label="item[sellabel]"
:value="item[selkey]"
>
</el-option>
</el-select>
</template>
<script>
export default {
mixins: [],
filters: {},
components: {},
model: {
prop: 'value', //绑定的值,通过父组件传递
event: 'update' //自定义名
},
props: {
value: {
type: [String, Number],
default: ''
},
list: {//选项值
type: Array,
default: () => []
},
placeholder: {
type: [String, Number],
default: '选项多,加载慢,建议搜索'
},
selkey: {
type: [String, Number],
default: 'key'
},
sellabel: {
type: [String, Number],
default: 'label'
}
},
data() {
return {
options: [],
selectValue: '',
loading: false
}
},
computed: {},
watch: {
selectValue(val) {
console.log(val)
this.$emit('update', val)
}
},
created() {
this.selectValue = this.value
},
mounted() {
},
destroyed() {
},
methods: {
popChange(e) {
console.log(e)
if (e) {
this.loading = true
this.options = this.list.length > 1000 ? [] : [...this.list]
this.loading = false
}
},
remoteMethod(query) {
console.log(query)
if (query !== '') {
this.loading = true
this.options = []
const reg = new RegExp(query.toLowerCase())
setTimeout(() => {
this.options = this.list.filter(item => {
return reg.test(item[this.selkey].toLowerCase())
})
this.loading = false
}, 50)
} else {
this.options = []
}
}
}
}
</script>
<style rel="stylesheet/scss" lang="scss" scoped></style>
第二版:虚拟select滚动
实现原理:在限定区域内只展示部分dome元素,随着滚动,动态计算截取数据,复用dom元素。
基于vue-virtual-scroller插件二次开发,好处是支持大数据,复用dom元素,拖动滚动条流畅丝滑。
虽然作者 已经封装过virtual-selector插件,但是根本还是在virtual-scroller上做的二次封装,只完成了最基本的select功能,定制化不高,对c,v又特殊需求,想定制化的工程师并不友好。
索性自己重新封装,满足自由度,写满了注释。方便样式嫌丑或者功能不满足的工程师,自己改virtualSelector.vue文件
安装
npm install -save vue-virtual-scroller
创建virtualSelector.vue文件
//virtualSelector.vue 核心文件,想自定义的,随意修改
//开发插件
<template>
<div
class="virtual-selector"
:id="vsId"
>
<span
class="virtual-selector__label"
:class="{none: !label}"
>{{ label }}</span>
<div class="virtual-selector__input-wrapper">
<input
class="virtual-selector__input"
:placeholder="placeholder"
v-model="selected[option.itemNameKey]"
@keyup="handleKeyup"
@input="handleInput"
@focus="handleFocus($event)"
@select="handleInputSelect($event)"
/>
<i class="virtual-selector__arrow">
<svg
viewBox="64 64 896 896"
data-icon="down"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
class=""
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"></path>
</svg>
</i>
<div
v-if="loading"
class="virtual-selector__loading"
>
<slot name="loading"></slot>
</div>
<div
v-if="flist.length===0"
class="virtual-selector__dropdown"
style="text-align: center;"
>
<slot name="nodata"></slot>
</div>
<recycle-scroller
v-if="flist.length > 0"
class="virtual-selector__scroller virtual-selector__dropdown"
:items="flist"
:item-size="itemSize"
:key-field="option.itemNameKey"
v-slot="{ item }"
>
<div
class="virtual-selector__dropdown-item"
:class="{
'virtual-selector__dropdown-item--selected':
item[option.itemValueKey] === selected[option.itemValueKey],
}"
@click="handleItemSelect($event, item)"
>
<slot
v-if="$scopedSlots.item"
name="item"
:item="item"
></slot>
<slot v-else>{{ item[option.itemNameKey] }}</slot>
</div>
</recycle-scroller>
</div>
</div>
</template>
<script>
function debounce(fn, delay) {
let timer
return function() {
const context = this
const args = arguments
clearTimeout(timer)
timer = setTimeout(function() {
fn.apply(context, args)
}, delay)
}
}
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const defaultItemPageSize = 8
const defaultItemGap = 0
const dropdownActiveClassName = 'virtual-selector__input-wrapper--active'
export default {
name: 'VirtualSelector',
components: { RecycleScroller },
props: {
loading: {
type: Boolean,
default: false
},
label: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
value: {
type: Object,
default: () => {
}
},
list: {
type: Array,
required: true,
default: () => []
},
/**
* option: {
* itemNameKey: string,
* itemValueKey: string,
* itemPageSize: number
* itemGap: number
* }
*/
option: {
type: Object,
required: true,
default: () => {
}
}
},
data() {
return {
id: new Date().getTime(),
flist: [],
selected: {},
itemSize: 32 + ((this.option && this.option.itemGap) || defaultItemGap)
}
},
computed: {
vsId() {
return `virtual-selector-${this.id}`
}
},
watch: {
list: {
immediate: true,
handler() {
this.init()
}
}
},
methods: {
init() {
if (!this.list || this.list.length == 0) return
if (
!this.option ||
!this.option.itemNameKey ||
!this.option.itemValueKey
) {
throw new Error(
'请指定列表选项“itemNameKey”或“itemValueKey”'
)
}
this.flist = [...this.list]
this.value instanceof Object &&
(this.selected = {
[this.option.itemNameKey]: this.value[this.option.itemNameKey],
[this.option.itemValueKey]: this.value[this.option.itemValueKey]
})
this.$nextTick(() => {
document.getElementById(this.vsId).querySelector('.virtual-selector__scroller').style.maxHeight =
(this.option.itemPageSize || defaultItemPageSize) * this.itemSize +
4 +
'px'
})
},
mount() {
document.addEventListener('click', this.handleGlobalClick, false)
},
unmount() {
document.removeEventListener('click', this.handleGlobalClick, false)
},
handleKeyup: debounce(function() {
//防抖监控ipnut输入,并组装需要显示的内容
const input = this.selected[this.option.itemNameKey]
this.option.itemNameKey !== this.option.itemValueKey && (this.selected[this.option.itemValueKey] = '')
if (!input) {
this.flist = [...this.list]
} else {
this.flist = this.list.filter((item) => {
if (item[this.option.itemNameKey].toLowerCase() === input.toLowerCase()) {
this.selected[this.option.itemValueKey] = item[this.option.itemValueKey]
this.$nextTick(() => {
this.$emit('select', {
id: this.vsId,
select: { ...this.selected }
})
})
}
return item[this.option.itemNameKey].toString().toLowerCase().includes(input.toLowerCase())
})
}
this.$emit('search', {
id: this.vsId,
search: { [this.option.itemNameKey]: input }
})
}, 300),
handleInput() {
//通知上层封装,input输入了什么
this.$emit('input', {
[this.option.itemNameKey]: this.selected[this.option.itemNameKey]
})
},
handleFocus(e) {
//通知上层封装,当前点击的组件ID,它是那个
e.target.offsetParent.classList.toggle(dropdownActiveClassName)
this.$emit('focus', {
id: this.vsId,
focus: { event: e }
})
},
handleInputSelect(e) {
//框选input中的内容
e.target.offsetParent.classList.add(dropdownActiveClassName)
},
handleItemSelect(e, item) {
//返回用户选的option
this.selected = {
...item,
[this.option.itemNameKey]: e.target.offsetParent.innerText
}
this.$emit('select', {
id: this.vsId,
select: { ...this.selected }
})
},
handleGlobalClick(e) {
//监听页面点击事件,如果点击的不是组件,则清空组件的所有input 样式、关闭虚拟滚动弹窗
if (e.target.className === 'virtual-selector__input') return
Array.from(document.querySelectorAll('.virtual-selector')).forEach((el) => {
el.querySelector('.virtual-selector__input-wrapper').classList.remove(
dropdownActiveClassName
)
}
)
}
},
mounted: function() {
//挂载页面click监听
this.mount()
},
beforeDestroy: function() {
//卸载页面ckick监听
this.unmount()
}
}
</script>
<style scoped>
.virtual-selector {
display: inline-flex;
align-items: center;
box-sizing: border-box;
}
.virtual-selector__label {
line-height: 1.5;
font-size: 14px;
white-space: nowrap;
color: #333;
}
.virtual-selector__label::after {
position: relative;
top: -0.5px;
content: ":";
margin: 0 8px 0 2px;
}
.virtual-selector__label.none::after {
display: none;
}
.virtual-selector__input-wrapper {
position: relative;
flex: 1;
}
.virtual-selector__input {
display: block;
width: 100%;
height: 32px;
padding: 0 34px 0 11px;
border: 1px solid #DCDFE6;
border-radius: 5px;
background-color: #fff;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
box-sizing: border-box;
outline: none;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
cursor: text;
}
.virtual-selector__input::placeholder,
.virtual-selector__input::-webkit-input-placeholder {
color: rgba(0, 0, 0, 0.65);
}
.virtual-selector__input:hover {
border-color: #346fdf;
}
.virtual-selector__arrow {
position: absolute;
top: 10px;
right: 11px;
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
transition: transform 0.3s, -webkit-transform 0.3s;
pointer-events: none;
}
.virtual-selector__arrow svg {
color: rgba(0, 0, 0, 0.25);
}
.virtual-selector__loading {
position: absolute;
top: 1px;
left: 1px;
width: calc(100% - 2px);
height: 30px;
line-height: 30px;
font-size: 12px;
text-align: center;
color: rgba(0, 0, 0, 0.65);
background-color: #fff;
}
.virtual-selector__input-wrapper--active input {
border-color: #409eff;
/*box-shadow: 0 0 0 2px rgba(15, 72, 179, 0.2);*/
}
.virtual-selector__input-wrapper--active .virtual-selector__arrow {
transform: rotate(180deg);
}
.virtual-selector__input-wrapper--active .virtual-selector__dropdown {
display: block;
}
.virtual-selector__dropdown {
display: none;
position: absolute;
min-width: 100%;
padding: 4px 0;
margin: 5px 0 0;
border-radius: 5px;
box-sizing: border-box;
line-height: 1.5;
list-style: none;
font-size: 14px;
background-color: #fff;
color: rgba(0, 0, 0, 0.65);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
outline: none;
z-index: 1050;
overflow-y: auto;
transform: translateZ(0px);
}
.virtual-selector__scroller {
max-height: 252px;
}
/**/
.virtual-selector__scroller::-webkit-scrollbar {
width: 12px;
height: 6px;
background: transparent;
}
.virtual-selector__scroller::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 5px;
}
.virtual-selector__scroller::-webkit-scrollbar-thumb {
background: hsla(0, 0%, 53%, 0.4);
}
.virtual-selector__scroller::-webkit-scrollbar-track {
background: hsla(0, 0%, 53%, 0.1);
}
/**/
.virtual-selector__dropdown-item {
display: block;
padding: 5px 12px;
line-height: 22px;
font-weight: 400;
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
transition: background 0.3s ease;
}
.virtual-selector__dropdown-item:hover {
background-color: #dae7f2;
}
.virtual-selector__dropdown-item--selected {
font-weight: 600;
color: rgba(64, 158, 255, 1);
background-color: #fafafa;
}
</style>
抛出插件VirtualSelector.vue插件
//创建index.js
//写入
import VirtualSelector from '@/components/virtualSelector/Selector/virtualSelector'
const VirSelector = {
install(Vue) {
Vue.component('virtual-selector', VirtualSelector)
Vue.component('VirtualSelector', VirtualSelector)
}
}
export default VirSelector
挂载全局插件VirSelector
//main.js
import VirSelector from '@/components/virtualSelector/index'
Vue.use(VirSelector)
封装使用vue-virtual-selector
第三层封装,只需要考虑数据与交互,拿来就能用。
//vue-virtual-selector.vue
//可以拿去用了
<template>
<virtual-selector
:loading="loading"
label=""
:placeholder="placeholder"
v-model="selectedvalue"
:list="list"
:option="listOption"
@input="handleInput"
@focus="handleFocus"
@search="handleSearch"
@select="handleSelect">
<div slot="loading">loading...</div>
<div slot="nodata">未找到匹配项</div>
<div slot="item" slot-scope="{ item }">
<span>{{ item[listOption.itemNameKey] }}</span>
</div>
</virtual-selector>
</template>
<script>
export default {
name: 'VirSelector',
mixins: [],
filters: {},
model: {
prop: 'value', //绑定的值,通过父组件传递
event: 'update' //自定义名
},
components: {},
props: {
value: {
required: true,
type: [String, Number],
default: ''
},
list: {
required: true,
type: Array,
default: () => []
},
listOption: {
required: false,
type: Object,
default: () => {
return {
itemNameKey: 'label',
itemValueKey: 'key',
itemPageSize: 8,
itemGap: 5
}
}
},
placeholder: {
required: false,
type: [String, Number],
default: ''
}
},
data() {
return {
loading: false,
selectedvalue: {}
}
},
computed: {},
watch: {},
created() {
//如果value没值,有带选项,提示不存在
if (!this.value && this.list.length && !this.placeholder) {
//给value一个默认值
this.selectedvalue = this.list[0]
this.$emit('update', this.selectedvalue[this.listOption.itemValueKey])
} else {
this.selectedvalue = this.list.find(item => {
return item[this.listOption.itemNameKey] === this.value || item[this.listOption.itemValueKey] === this.value
})
}
},
mounted() {
},
destroyed() {
},
methods: {
handleInput(input){
console.log('Input : ', input)
},
//点击
handleFocus({ id, focus }) {
console.log('focus : ', { id, focus })
},
//搜索
handleSearch({ id, search }) {
console.log('search : ', { id, search })
this.$emit('update', search[this.listOption.itemValueKey])
this.selectedvalue = search
},
//选择
handleSelect({ id, select }) {
console.log('select : ', { id, select })
this.$emit('update', select[this.listOption.itemValueKey])
this.selectedvalue = select
}
}
}
</script>
<style rel="stylesheet/scss" lang="scss" scoped></style>