如何封装一个无限加载组件

1,235 阅读4分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

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参数,用于设置组件的状态,例如:获取到新数据后需要执行state 参数,用于设置组件的状态,例如:获取到新数据后需要执行state.loaded() 将状态设置为已加载,否则加载器动画会不停旋转
  • 参数:$state 组件状态对象
    • $state.loaded:此次数据加载完成后需要执行的方法,会关闭加载动画并继续监听滚动事件。
    • **state.complete:所有数据都加载完成时需要执行的方法。如果调用此方法前没有调用过state.complete**:所有数据都加载完成时需要执行的方法。如果调用此方法前没有调用过 state.loaded,那么 no-results 的内容将会被展示;如果调用此方法前调用过 $state.loaded,那么 no-more 的内容将会被展示。
    • $state.error:当此次数据加载失败时需要执行的方法,会显示 error 的内容并显示按钮,点击按钮后会再次请求加载数据。

插槽

1. no-results

在没有加载到任何数据时展示的内容(即没有调用过 state.loaded方法就调用了state.loaded 方法就调用了 state.complete 方法时展示)

2. no-more

所有数据都已经加载完时展示的内容(即调用过 state.loaded方法之后调用了state.loaded 方法之后调用了 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,
};

「欢迎在评论区讨论」

希望看完的朋友可以给个赞,鼓励一下