小知识,大挑战!本文正在参与“程序员必备小知识”创作活动
本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。
TIP 👉 冤家宜解不宜结,各自回头看后头。明·冯梦龙《古今小说》
前言
在我们日常项目开发中,我们经常会做一些无限加载功能,所以封装了这款无限加载组件。无限加载组件
属性
1. distance
- 触发加载的临界距离
- 值为数字类型(单位:px),默认值为100
2. spinner
- 加载器动画类型
- 值为字符串类型,必须是以下值之一
- default: 默认类型,圆环上有一个旋转球
- circles: 多个圆点旋转
- bubbles: 由小到大的气泡旋转
- spiral: 圆弧旋转
- wavedots: 圆点波浪
3. direction
- 无限加载的方向
- 值为字符串类型,必须是"bottom" 或者 "top"
- bottom:到底自动加载(默认)
- top:到顶自动加载
4. forceUseInfiniteWrapper
- 强制指定滚动目标元素
- 值为布尔类型或者字符串类型,默认为false
- 值为false(默认):会寻找最近的具备 overflow-y: auto | scroll CSS 样式的父元素作为滚动容器
- 值为true:会向上查找最近的具备 infinite-wrapper 属性的父元素作为滚动容器
- 值为字符串,则会将该值当作 CSS 选择器并使用 querySelector 查找该元素,将其作为滚动容器,例如:'.block-body'
5. identifier
- 无限加载的标识,当值改变时组件重新初始化,可用于列表刷新、切换条件查询等情况
- 类型无限制,一般使用时间戳作为标识,例:
- +new Date()、Date.now() 或者 new Date().getTime()
事件
1. infinite
- 滚动距离小于 distance 属性时触发的数据加载事件。此事件的监听函数需要请求新的数据,并添加到列表;事件会传递state.loaded() 将状态设置为已加载,否则加载器动画会不停旋转
- 参数:$state 组件状态对象
- $state.loaded:此次数据加载完成后需要执行的方法,会关闭加载动画并继续监听滚动事件。
- **state.loaded,那么 no-results 的内容将会被展示;如果调用此方法前调用过 $state.loaded,那么 no-more 的内容将会被展示。
- $state.error:当此次数据加载失败时需要执行的方法,会显示 error 的内容并显示按钮,点击按钮后会再次请求加载数据。
插槽
1. no-results
在没有加载到任何数据时展示的内容(即没有调用过 state.complete 方法时展示)
2. no-more
所有数据都已经加载完时展示的内容(即调用过 state.complete 方法时展示)
3. error
加载出现错误时展示的内容(即调用 $state.error 方法时展示)
4. spinner
加载数据时展示的加载动画内容
插槽示例:
<InfiniteLoading
@infinite="infiniteHandler"
:identifier="infiniteId">
<span class="no-results" slot="no-results">没有查到任何数据~</span>
<span class="no-more" slot="no-more">没有更多了~</span>
<span class="error" slot="error">加载失败了~</span>
</InfiniteLoading>
示例
<template>
<div class="base-list">
<div class="block">
<div class="block-head">
<h3>
<div class="header-right" @click="refresh">
<Icon name="refresh" class="refresh-icon"></Icon>
</div>
<span class="header-text">无限加载</span>
</h3>
</div>
<div class="block-body clearfix">
<table class="table-list">
<thead class="table-thead">
<tr>
<th class="table-align-left">公司名称</th>
<th class="table-align-left">联络人</th>
<th class="table-align-left">产品</th>
<th class="table-align-left">处理状态</th>
<th class="table-align-left">分配状态</th>
<th class="table-align-left">更新时间</th>
</tr>
</thead>
<tbody class="table-tbody">
<tr v-for="item in dataList" class="table-align-center" :key="item.id">
<td class="table-align-left">{{item.company}}</td>
<td class="table-align-left">{{item.name}}</td>
<td class="table-align-left">{{item.product}}</td>
<td class="table-align-left">{{item.dealStatus}}</td>
<td class="table-align-left">{{item.assignStatus}}</td>
<td class="table-align-left">{{item.updateTime}}</td>
</tr>
</tbody>
</table>
<InfiniteLoading
@infinite="infiniteHandler"
:identifier="infiniteId">
</InfiniteLoading>
</div>
</div>
</div>
</template>
<script>
import InfiniteLoading from '@/components/base/InfiniteLoading'
export default {
name: 'InfiniteLoadingDemo',
components: {
InfiniteLoading
},
data () {
return {
// 是否加载中
isLoading: false,
// 无限加载标识,如果发生改变则组件重置
infiniteId: +new Date(),
// 列表数据
dataList: [],
// 分页页码
pageNo: 0,
// 每页条数(即每次加载数据量)
pageSize: 50
}
},
methods: {
// 加载时触发的方法
infiniteHandler ($state) {
if (!this.isLoading) {
this.doQuery(this.pageNo + 1).then((result) => {
if (result && result.length > 0) {
$state && $state.loaded()
} else {
$state && $state.complete()
}
}, (err) => {
console.error('数据加载失败!', err)
$state && $state.error()
})
}
},
// 按分页查询方法
doQuery (pageNo) {
this.isLoading = true
return new Promise((resolve, reject) => {
this.$api.post({
url: '/api/basicList/query',
params: { pageSize: this.pageSize, pageNo }
}, this).then(data => {
this.pageNo = data.page.pageNo
this.count = data.page.count
this.dataList.push(...data.page.list)
// this.dataList.unshift(...data.page.list.reverse()) // 加载方向为top时的数据添加方式
this.pageNo = data.page.pageNo
this.pageSize = data.page.pageSize
this.isLoading = false
resolve(data.page.list)
}, e => {
if (e.name === 'BusinessError' && !e.ignore) {
this.$toast(e.message)
}
this.isLoading = false
reject(e)
})
})
},
// 刷新
refresh () {
this.dataList = []
this.pageNo = 0
this.infiniteId = +new Date()
}
}
}
</script>
<style lang="scss" scoped>
.base-list {
margin: 20px;
padding: 20px;
.block {
margin-bottom: 30px;
border-radius: 5px;
background-color: #fff;
.block-head {
height: 55px;
padding: 0 25px;
vertical-align: middle;
border-bottom: 1px solid $border-color-light;
h3 {
font-size: 17px;
line-height: 55px;
.header-text {
vertical-align: middle;
}
.header-right {
float: right;
height: 55px;
padding-top: 7px;
cursor: pointer;
.refresh-icon {
@include primary-font-color();
}
}
}
}
.block-body {
height: 420px;
overflow-y: auto;
padding: 25px;
table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
tr {
height: 48px;
background-color: #FFF;
th, td {
padding: 0 .5em;
border-left: 0;
border-bottom: 1px solid #e8e8e8;
}
}
thead tr {
background-color: #fafafa;
th {
font-weight: bold;
white-space: nowrap;
}
}
tbody tr {
&:hover {
@include primary-background-color(.1);
}
td {
a {
@include primary-font-color();
}
}
}
.table-align-left{
text-align: left;
}
.table-align-center{
text-align: center;
}
.table-align-right{
text-align: right;
}
}
}
}
}
</style>
实现InfiniteLoading.vue
<template>
<div class="infinite-loading-container">
<div
class="infinite-status-prompt"
v-show="isShowSpinner"
:style="slotStyles.spinner">
<slot name="spinner" v-bind="{ isFirstLoad }">
<spinner :spinner="spinner" />
</slot>
</div>
<div
class="infinite-status-prompt"
:style="slotStyles.noResults"
v-show="isShowNoResults">
<slot name="no-results">
<component v-if="slots.noResults.render" :is="slots.noResults"></component>
<template v-else>{{ slots.noResults }}</template>
</slot>
</div>
<div
class="infinite-status-prompt"
:style="slotStyles.noMore"
v-show="isShowNoMore">
<slot name="no-more">
<component v-if="slots.noMore.render" :is="slots.noMore"></component>
<template v-else>{{ slots.noMore }}</template>
</slot>
</div>
<div
class="infinite-status-prompt"
:style="slotStyles.error"
v-show="isShowError">
<slot name="error" :trigger="attemptLoad">
<component
v-if="slots.error.render"
:is="slots.error"
:trigger="attemptLoad">
</component>
<template v-else>
{{ slots.error }}
<br>
<button
class="btn-try-infinite"
@click="attemptLoad"
v-text="slots.errorBtnText">
</button>
</template>
</slot>
</div>
</div>
</template>
<script>
import Spinner from '../spinner'
import config, {
evt3rdArg, WARNINGS, STATUS, SLOT_STYLES
} from './config'
import {
warn, throttleer, loopTracker, scrollBarStorage, kebabCase, isVisible
} from './utils'
export default {
name: 'InfiniteLoading',
components: {
Spinner
},
props: {
// 触发加载的临界距离
distance: {
type: Number,
default: config.props.distance
},
/* 加载器动画类型
* default: 默认类型,圆环上有一个旋转球
* circles: 多个圆点旋转
* bubbles: 由小到大的气泡旋转
* spiral: 圆弧旋转
* wavedots: 圆点波浪
*/
spinner: String,
// 无限加载方向(bottom:到底自动加载(默认);top:到顶自动加载)
direction: {
type: String,
default: 'bottom'
},
/* 用于强制指定滚动目标元素
* 1. 如果该值为 true,则会向上查找最近的具备 infinite-wrapper 属性的父元素作为滚动容器
* 2. 如果该值为一个字符串,则会将该值当作 CSS 选择器并使用 querySelector 查找该元素,将其作为滚动容器,例如:'.block-body'
* (默认情况下,会寻找最近的具备 overflow-y: auto | scroll CSS 样式的父元素,作为监听滚动事件的目标元素)
*/
forceUseInfiniteWrapper: {
type: [Boolean, String],
default: config.props.forceUseInfiniteWrapper
},
// 无限加载的标识,当值改变时组件重新初始化
identifier: {
default: +new Date()
}
},
data () {
return {
scrollParent: null,
scrollHandler: null,
isFirstLoad: true, // save the current loading whether it is the first loading
status: STATUS.READY,
slots: config.slots
}
},
computed: {
isShowSpinner () {
return this.status === STATUS.LOADING
},
isShowError () {
return this.status === STATUS.ERROR
},
isShowNoResults () {
return (
this.status === STATUS.COMPLETE && this.isFirstLoad
)
},
isShowNoMore () {
return (
this.status === STATUS.COMPLETE && !this.isFirstLoad
)
},
slotStyles () {
const styles = {}
Object.keys(config.slots).forEach((key) => {
const name = kebabCase(key)
if (
// no slot and the configured default slot is not a Vue component
(
!this.$slots[name] && !config.slots[key].render
) ||
// has slot and slot is pure text node
(
this.$slots[name] && !this.$slots[name][0].tag
)
) {
// only apply default styles for pure text slot
styles[key] = SLOT_STYLES
}
})
return styles
}
},
watch: {
// 无限加载的标识改变时组件重新初始化
identifier () {
this.stateChanger.reset()
}
},
mounted () {
this.$watch('forceUseInfiniteWrapper', () => {
this.scrollParent = this.getScrollParent()
}, { immediate: true })
this.scrollHandler = (ev) => {
if (this.status === STATUS.READY) {
if (ev && ev.constructor === Event && isVisible(this.$el)) {
throttleer.throttle(this.attemptLoad)
} else {
this.attemptLoad()
}
}
}
setTimeout(() => {
this.scrollHandler()
this.scrollParent.addEventListener('scroll', this.scrollHandler, evt3rdArg);
}, 1)
this.$on('$InfiniteLoading:loaded', (ev) => {
this.isFirstLoad = false
if (this.direction === 'top') {
// wait for DOM updated
this.$nextTick(() => {
scrollBarStorage.restore(this.scrollParent)
})
}
if (this.status === STATUS.LOADING) {
this.$nextTick(this.attemptLoad.bind(null, true))
}
if (!ev || ev.target !== this) {
warn(WARNINGS.STATE_CHANGER)
}
})
this.$on('$InfiniteLoading:complete', (ev) => {
this.status = STATUS.COMPLETE
// force re-complation computed properties to fix the problem of get slot text delay
this.$nextTick(() => {
this.$forceUpdate()
})
this.scrollParent.removeEventListener('scroll', this.scrollHandler, evt3rdArg)
if (!ev || ev.target !== this) {
warn(WARNINGS.STATE_CHANGER)
}
})
this.$on('$InfiniteLoading:reset', (ev) => {
this.status = STATUS.READY
this.isFirstLoad = true
scrollBarStorage.remove(this.scrollParent)
this.scrollParent.addEventListener('scroll', this.scrollHandler, evt3rdArg);
// wait for list to be empty and the empty action may trigger a scroll event
setTimeout(() => {
throttleer.reset()
this.scrollHandler()
}, 1)
if (!ev || ev.target !== this) {
warn(WARNINGS.IDENTIFIER)
}
})
/**
* change state for this component, pass to the callback
*/
this.stateChanger = {
loaded: () => {
this.$emit('$InfiniteLoading:loaded', { target: this })
},
complete: () => {
this.$emit('$InfiniteLoading:complete', { target: this })
},
reset: () => {
this.$emit('$InfiniteLoading:reset', { target: this })
},
error: () => {
this.status = STATUS.ERROR
throttleer.reset()
}
}
},
/**
* To adapt to keep-alive feature, but only work on Vue 2.2.0 and above, see: https://vuejs.org/v2/api/#keep-alive
*/
deactivated () {
/* istanbul ignore else */
if (this.status === STATUS.LOADING) {
this.status = STATUS.READY
}
this.scrollParent.removeEventListener('scroll', this.scrollHandler, evt3rdArg)
},
activated () {
this.scrollParent.addEventListener('scroll', this.scrollHandler, evt3rdArg)
},
methods: {
/**
* attempt trigger load
* @param {Boolean} isContinuousCall the flag of continuous call, it will be true
* if this method be called in the `loaded`
* event handler
*/
attemptLoad (isContinuousCall) {
if (
this.status !== STATUS.COMPLETE &&
isVisible(this.$el) &&
this.getCurrentDistance() <= this.distance
) {
this.status = STATUS.LOADING
if (this.direction === 'top') {
// wait for spinner display
this.$nextTick(() => {
scrollBarStorage.save(this.scrollParent)
})
}
this.$emit('infinite', this.stateChanger)
if (isContinuousCall && !this.forceUseInfiniteWrapper && !loopTracker.isChecked) {
// check this component whether be in an infinite loop if it is not checked
// more details: https://github.com/PeachScript/vue-infinite-loading/issues/55#issuecomment-316934169
loopTracker.track()
}
} else if (this.status === STATUS.LOADING) {
this.status = STATUS.READY
}
},
/**
* get current distance from the specified direction
* @return {Number} distance
*/
getCurrentDistance () {
let distance
if (this.direction === 'top') {
distance = typeof this.scrollParent.scrollTop === 'number'
? this.scrollParent.scrollTop
: this.scrollParent.pageYOffset
} else {
const infiniteElmOffsetTopFromBottom = this.$el.getBoundingClientRect().top
const scrollElmOffsetTopFromBottom = this.scrollParent === window
? window.innerHeight
: this.scrollParent.getBoundingClientRect().bottom
distance = infiniteElmOffsetTopFromBottom - scrollElmOffsetTopFromBottom
}
return distance
},
/**
* get the first scroll parent of an element
* @param {DOM} elm cache element for recursive search
* @return {DOM} the first scroll parent
*/
getScrollParent (elm = this.$el) {
let result
if (typeof this.forceUseInfiniteWrapper === 'string') {
result = document.querySelector(this.forceUseInfiniteWrapper)
}
if (!result) {
if (elm.tagName === 'BODY') {
result = window
} else if (!this.forceUseInfiniteWrapper && ['scroll', 'auto'].indexOf(getComputedStyle(elm).overflowY) > -1) {
result = elm
} else if (elm.hasAttribute('infinite-wrapper') || elm.hasAttribute('data-infinite-wrapper')) {
result = elm
}
}
return result || this.getScrollParent(elm.parentNode)
}
},
destroyed () {
/* istanbul ignore else */
if (!this.status !== STATUS.COMPLETE) {
throttleer.reset()
scrollBarStorage.remove(this.scrollParent)
this.scrollParent.removeEventListener('scroll', this.scrollHandler, evt3rdArg)
}
}
}
</script>
<style lang="scss" scoped px2rem="false">
$size: 28px;
.infinite-loading-container {
clear: both;
text-align: center;
::v-deep *[class^=loading-] {
display: inline-block;
margin: 5px 0;
width: $size;
height: $size;
font-size: $size;
line-height: $size;
border-radius: 50%;
}
}
.btn-try-infinite {
margin-top: 5px;
padding: 5px 10px;
color: #999;
font-size: 14px;
line-height: 1;
background: transparent;
border: 1px solid #ccc;
border-radius: 3px;
outline: none;
cursor: pointer;
&:not(:active):hover {
opacity: 0.8;
}
}
</style>
config.js
/*
* 默认配置
*/
// 默认参数配置
const props = {
// 触发加载的临界距离
distance: 100,
// 强制使用无限滚动包裹器
forceUseInfiniteWrapper: false
}
/**
* 默认系统配置
*/
const system = {
// scroll 事件节流的间隔时间(单位:毫秒)
throttleLimit: 50,
// the timeout for check infinite loop, unit: ms
loopCheckTimeout: 1000,
// the max allowed number of continuous calls, unit: ms
loopCheckMaxCalls: 10
}
/**
* default slot messages
*/
const slots = {
noResults: '没有查到任何数据 o(╥﹏╥)o',
noMore: '没有更多了',
error: '加载异常 o(╥﹏╥)o',
errorBtnText: '再试'
}
/**
* 是否支持 事件第3个参数 passive 的安全检测
* (如果支持 passive 并且值为true 则表示 listener 永远不会调用 preventDefault(),从而改善滚屏新能)
* @see https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
*/
export const evt3rdArg = (() => {
let result = false
try {
const arg = Object.defineProperty({}, 'passive', {
get () {
result = { passive: true }
return true
}
})
window.addEventListener('testpassive', arg, arg)
window.remove('testpassive', arg, arg)
} catch (e) { /* */ }
return result
})()
/**
* 警告信息
*/
export const WARNINGS = {
STATE_CHANGER: [
'emit `loaded` and `complete` event through component instance of `$refs` may cause error, so it will be deprecated soon, please use the `$state` argument instead (`$state` just the special `$event` variable):',
'\ntemplate:',
'<infinite-loading @infinite="infiniteHandler"></infinite-loading>',
`
script:
...
infiniteHandler($state) {
ajax('https://www.example.com/api/news')
.then((res) => {
if (res.data.length) {
$state.loaded();
} else {
$state.complete();
}
});
}
...`,
'',
'more details: https://github.com/PeachScript/vue-infinite-loading/issues/57#issuecomment-324370549',
].join('\n')
}
/**
* error messages
*/
export const ERRORS = {
INFINITE_LOOP: [
`executed the callback function more than ${system.loopCheckMaxCalls} times for a short time, it looks like searched a wrong scroll wrapper that doest not has fixed height or maximum height, please check it. If you want to force to set a element as scroll wrapper ranther than automatic searching, you can do this:`,
`
<!-- add a special attribute for the real scroll wrapper -->
<div infinite-wrapper>
...
<!-- set force-use-infinite-wrapper -->
<infinite-loading force-use-infinite-wrapper></infinite-loading>
</div>
or
<div class="infinite-wrapper">
...
<!-- set force-use-infinite-wrapper as css selector of the real scroll wrapper -->
<infinite-loading force-use-infinite-wrapper=".infinite-wrapper"></infinite-loading>
</div>
`,
'more details: https://github.com/PeachScript/vue-infinite-loading/issues/55#issuecomment-316934169',
].join('\n')
}
/**
* plugin status constants
*/
export const STATUS = {
READY: 0,
LOADING: 1,
COMPLETE: 2,
ERROR: 3
}
/**
* default slot styles
*/
export const SLOT_STYLES = {
color: '#666',
fontSize: '14px',
padding: '10px 0'
}
export default {
mode: 'development',
props,
system,
slots,
WARNINGS,
ERRORS,
STATUS
}
index.js
/**
* 无限加载组件
* @see https://github.com/PeachScript/vue-infinite-loading
* @see https://peachscript.github.io/vue-infinite-loading/zh/guide/
*/
import InfiniteLoading from './InfiniteLoading.vue'
export default InfiniteLoading
utils.js
/* eslint-disable no-console */
import config, { ERRORS } from './config';
/**
* console warning in production
* @param {String} msg console content
*/
export function warn(msg) {
/* istanbul ignore else */
if (config.mode !== 'production') {
console.warn(`[Vue-infinite-loading warn]: ${msg}`);
}
}
/**
* console error
* @param {String} msg console content
*/
export function error(msg) {
console.error(`[Vue-infinite-loading error]: ${msg}`);
}
export const throttleer = {
timers: [],
caches: [],
throttle(fn) {
if (this.caches.indexOf(fn) === -1) {
// cache current handler
this.caches.push(fn);
// save timer for current handler
this.timers.push(setTimeout(() => {
fn();
// empty cache and timer
this.caches.splice(this.caches.indexOf(fn), 1);
this.timers.shift();
}, config.system.throttleLimit));
}
},
reset() {
// reset all timers
this.timers.forEach((timer) => {
clearTimeout(timer);
});
this.timers.length = 0;
// empty caches
this.caches = [];
},
};
export const loopTracker = {
isChecked: false,
timer: null,
times: 0,
track() {
// record track times
this.times += 1;
// try to mark check status
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.isChecked = true;
}, config.system.loopCheckTimeout);
// throw warning if the times of continuous calls large than the maximum times
if (this.times > config.system.loopCheckMaxCalls) {
error(ERRORS.INFINITE_LOOP);
this.isChecked = true;
}
},
};
export const scrollBarStorage = {
key: '_infiniteScrollHeight',
getScrollElm(elm) {
return elm === window ? document.documentElement : elm;
},
save(elm) {
const target = this.getScrollElm(elm);
// save scroll height on the scroll parent
target[this.key] = target.scrollHeight;
},
restore(elm) {
const target = this.getScrollElm(elm);
/* istanbul ignore else */
if (typeof target[this.key] === 'number') {
target.scrollTop = target.scrollHeight - target[this.key] + target.scrollTop;
}
this.remove(target);
},
remove(elm) {
if (elm[this.key] !== undefined) {
// remove scroll height
delete elm[this.key]; // eslint-disable-line no-param-reassign
}
},
};
/**
* kebab-case a camel-case string
* @param {String} str source string
* @return {String}
*/
export function kebabCase(str) {
return str.replace(/[A-Z]/g, s => `-${s.toLowerCase()}`);
}
/**
* get visibility for element
* @param {DOM} elm
* @return {Boolean}
*/
export function isVisible(elm) {
return (elm.offsetWidth + elm.offsetHeight) > 0;
}
export default {
warn,
error,
throttleer,
loopTracker,
kebabCase,
scrollBarStorage,
isVisible,
};
「欢迎在评论区讨论」