vue

Vue组件封装

封装满足特定需求的功能组件、包括自定义滚动条、自定义富文本编辑

Posted by page on June 18, 2021

vue功能组件

滚动条(scroll)

使用

<mini-scroll class="mini-scroll">
  <div class="content">...滚动内容撑开</div>
</mini-scroll>

<style lang="less" scoped>
.mini-scroll {
  width: 400px;
  height: 440px;
  border: 2px solid red;
  background-color: pink;
  .content {
    font-size: 35px;
    padding: 20px 40px 20px 20px;
  }
}
</style>

组件

<template>
  <div class="mini-scroll scroll-container d-flex">
    <!-- 滚动视口容器 -->
    <div class="scroll-body flex-1" @scroll="scroll" ref="scrollBody">
      <!-- 滚动内容容器 -->
      <div class="scroll-content">
        <!-- 滚动内容插槽 -->
        <slot></slot>
      </div>
    </div>
    <!-- 滚动条容器 -->
    <div class="scroll-slide-container">
      <!-- 滚动条外层——包含了padding -->
      <div class="scroll-slide-bar w-100 h-100 absCenterY">
        <!-- 滚动条内层——滚动芯活动区域 -->
        <div class="scroll-inner h-100 bd-filt" ref="slideInner">
          <!-- 滚动芯 -->
          <div
            class="scroll-slider absCenterX w-100 bd-filt"
            :style="{
              height: `${sliderHeight}px`,
              transform: `translateY(${sliderY}px) translateX(-50%)`
            }"
            ref="slider"
            @touchstart="touchstart"
            @touchmove="touchmove"
          ></div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      ratio: 1, // 映射比例
      sliderHeight: 0, // 滚动芯高度
      startY: 0, // 滚动芯起始触摸Y值
      sliderY: 0 // 滚动芯滑动Y值
    };
  },
  mounted() {
    this.initScroll();
  },
  methods: {
    // 初始化 计算比例与初始化slider高度
    initScroll() {
      // 容器相关高度
      let clientHeight = this.$refs.scrollBody.clientHeight;
      let scrollHeight = this.$refs.scrollBody.scrollHeight;

      // 滚动条内部 滚动范围高度
      let slideInnerHeight = this.$refs.slideInner.clientHeight;

      // 计算比例与滚动芯高度
      this.ratio = slideInnerHeight / scrollHeight;
      this.sliderHeight = parseInt(this.ratio * clientHeight);
    },

    // 内容滚动 实时映射 slider位置
    scroll: animationThrottle(function(e){
      let $miniScroll = e.target;
      let scrollTop = $miniScroll.scrollTop;
      this.sliderY = scrollTop * this.ratio;
    }),

    // 触摸滚动条
    touchstart: animationThrottle(function(e) {
      let toucher = e.targetTouches[0];
      this.startY = toucher.clientY;
    }),

    // 滑动滚动条
    touchmove(e) {
      let toucher = e.targetTouches[0];
      let touchY = toucher.clientY;
      let touchDistance = touchY - this.startY;
      let scrollTop = this.$refs.scrollBody.scrollTop;
      let nextTop = touchDistance / this.ratio + scrollTop;
      this.$refs.scrollBody.scrollTop = nextTop;
      this.startY = touchY;
    }
  }
};
</script>

<style lang="less" scoped>
.mini-scroll {
  position: relative;
  .scroll-body {
    height: 100%;
    scrollbar-width: none; /* firefox */
    -ms-overflow-style: none; /* IE 10+ */
    overflow-x: hidden;
    overflow-y: auto;
    &::-webkit-scrollbar {
      display: none; /* Chrome Safari */
    }
  }

  .scroll-slide-container {
    position: absolute;
    height: 100%; // 滚动条高度
    width: 20px; // 滚动条宽度
    top: 50%;
    right: 0;
    transform: translateY(-50%);
    .scroll-slide-bar {
      box-sizing: border-box;
      padding: 39px 0; // 内层区域padding边距,控制可滑动区域和左右padding
      background-size: 100% 100%;
      .scroll-inner {
        position: relative;
        background-color: #eee;
        .scroll-slider {
          background-color: #fff;
        }
      }
    }
  }
}
</style>

数字滚动(scroll-num)

使用

<div class="amount-table d-flex jc-between">
  <ScrollNum
    v-for="(num, i) in amountArr[0]"
    :key="'Int' + i"
    ref="ScrollNum"
    :number="num"
    :delay="100 * (amountArr[0].length - 1 - i)"
    class="scroll-num"
  />
  <div class="scroll-num d-flex jc-center al-center">.</div>
  <ScrollNum
    v-for="(num, i) in amountArr[1]"
    :key="'Float' + i"
    ref="ScrollNum"
    :number="num"
    :delay="100 * (amountArr[1].length - 1 - i)"
    class="scroll-num"
  />
</div> 
amountArr() {
  if (!this.moneyAmount)
    return [
      [0, 0, 0, 0, 0, 0],
      [0, 0]
    ];
  const strArr = this.moneyAmount.toFixed(2).split('.');
  return strArr.map((numStr) => Array.from(numStr).map((str) => +str));
}

组件

<template>
  <div class="scroll-wrap">
    <ul
      ref="scroller"
      class="scroll-container"
      :class="{ animating }"
      :style="{ animationDuration: `${speed}ms`, animationDelay: `${delay}ms` }"
    >
      <li class="num">0</li>
      <li class="num">1</li>
      <li class="num">2</li>
      <li class="num">3</li>
      <li class="num">4</li>
      <li class="num">5</li>
      <li class="num">6</li>
      <li class="num">7</li>
      <li class="num">8</li>
      <li class="num">9</li>
      <li class="num">0</li>
    </ul>
  </div>
</template>

<script>
  export default {
    props: { 
      // 滚动至数字
      number: { type: Number, default: 0, validator: (num) => Number.isInteger(num) && num < 10 && num >= 0 },
      speed: { type: Number, default: 1000 }, // 速度
      delay: { type: Number, default: 2000 }, // 延迟
      duration: { type: Number, default: 1200 } // 持续时长
    },
    data() {
      return {
        animating: false
      };
    },
    watch: {
      animating(value) {
        if (!value) return;
        setTimeout(() => {
          this.animating = false;
          this.scrollToNumber();
        }, this.duration + this.delay);
      }
    },
    methods: {
      scrollToNumber() {
        this.$refs.scroller.style.transform = `translateY(-${this.number * 10}%)`;
      }
    }
  };
</script>

<style lang="less" scoped>
  .scroll-wrap {
    width: 100%;
    height: 100%;
    overflow: hidden;
  }
  .scroll-container {
    height: 1100%;
    transform: translateY(0);
    transition: filter 0.3s;
    &.animating {
      filter: blur(1px);
      animation: scroll-vertical linear infinite;
    }
  }
  .num {
    display: flex;
    height: 10%;
    justify-content: center;
    align-items: center;
  }
  @keyframes scroll-vertical {
    form {
      transform: translateY(0);
    }
    to {
      transform: translateY(-100%);
    }
  }
</style>

折叠组件(collapse)

使用

<div
  class="chat-detail"
  v-collapse="{ max: 2, num: chat.messages.length }"
>
    <div class="item"></div>
    <div class="item"></div>
    ...
    <div class="collapse-switch">展开/折叠</div>
</div>

命令参数

  • max:子项数超出max则开启折叠
  • num:子项数
  • initHeight:初始折叠状态高度(友好处理滚动条/内容闪动)

指令

// 注册指令
directives: {
  collapse: {
    bind: v_collapse,
    componentUpdated: v_collapse,
    unbind(el){
      // 解绑命令时解绑折叠事件
      el.removeEventListener("click", collapseEvent);
    }
  }
}
// 折叠指令
function v_collapse(el, binding){
  var options = binding.value || {};
  const max = options.max || 0;   // 显示条数
  const num = options.num || 0;   // 当前总条数
  const initHeight = options.initHeight || "auto";   // 初始折叠高度
  if(num > max){
    // 默认折叠状态
    el.style.overflow = "hidden";
    el.dataset.collapsed = "collapsed";
    el.style.height = initHeight;
    var loading;
    var imgList = [].slice.call(el.querySelectorAll("img"));
    // 子项有img时,全部加载后才计算折叠高度
    if(imgList.length > 0){
      loading = Promise.all(
        imgList.map($img => new Promise(resolve => {
          $img.addEventListener("load", () => resolve());
          $img.addEventListener("error", () => resolve());
        }))
      )
    }else{
      loading = Promise.resolve()
    }
    loading.then(() => {
      // 计算折叠高度
      var collapsedHeight = []
        .slice.call(el.children, 0, max)
        .reduce((total, $ele) => total + $ele.clientHeight, 0);
      el.style.height = collapsedHeight + "px";
      el.dataset.collapsedHeight = collapsedHeight;
      el.addEventListener("click", collapseEvent);
    });
  }else{
    el.style.height = "auto";
    el.style.overflow = "unset";
  }
}

// 折叠事件
function collapseEvent(e){
  var el = e.currentTarget;
  var target = e.target;
  var list = [].slice.call(el.querySelectorAll(".collapse-switch"));
  var delegated = list.some($switch => $switch.contains(target));
  if(!delegated) return;
  // 切换折叠状态
  var collapsed = el.dataset.collapsed;
  var collapsedHeight = el.dataset.collapsedHeight + 'px' || 'auto';
  el.style.height = collapsed ? el.scrollHeight + 'px' : collapsedHeight;
  el.style.overflow = collapsed ? "unset" : "hidden";
  el.dataset.collapsed = collapsed ? "" : "collapsed";
}

更好的实践: 对满足折叠的内容区分折叠状态,折叠状态下截取内容item长度,展开交互时切换内容折叠状态,内容item全部绑定;最后添加vue transition动画控制即可;

预加载(loading-bar)

页面预加载静态资源,并用loading加载条实时显示加载进度;也可以做上传下载loading交互;

// Node
var fs = require("fs");
var path = require("path"),
  filesList = {}, // 资源json
  imgPath = "../../assets/img", // 资源相对路径
  imgPathName = "ROOT"; // 当前读取层级,起始为ROOT

function readFileList(dir, dirName, filesList = {}) {
  const files = fs.readdirSync(dir);
  files.forEach(item => {
    const stat = fs.statSync(path.join(dir, item));
    if (stat.isDirectory()) {
      readFileList(`${dir}/${item}`, item, filesList);
    } else {
      if (!filesList[dirName]) {
        filesList[dirName] = [];
      }
      var fullPath = `${dir}/${item}`.replace(`${path.resolve(__dirname, imgPath)}/`, "");
      filesList[dirName].push(fullPath);
    }
  });
  return filesList;
}

readFileList(path.resolve(__dirname, imgPath), imgPathName, filesList);
let str = `
const imgList=${JSON.stringify(filesList)}
export default imgList
 `;
fs.writeFileSync(path.resolve(__dirname, "../../assets/js/imgList.js"), str);

季度选择器(el-quarter)

组件功能

支持季度选择的表单组件,季度选择本质就是日期选择(3/6/9/12月最后一天);

与element-ui时间/日期选择器都支持 format valueFormat,定义输入输出值格式;支持disabledDate,控制不可选择季度;

disabled属性控制组件是否被禁用;

数据流规则

数据字段:

value 外部传入值,组件功能通过修改value实现

date 初始值由value计算得到,组件内因交互而需维护的状态

value => parseValue(value) => 初始date => 组件状态

组件交互 => 操作date => 组件状态 => 修改value => 同步date => 组件状态

文件结构

QuarterPicker (参考element-ui)

  • src
    • QuarterPicker.vue
  • index.js

code

// element.js
import QuarterPicker from '@/components/QuarterPicker';
...
Vue.use(QuarterPicker);
// index.js
import QuarterPicker from './src/QuarterPicker.vue';
export default {
  install(Vue) {
    Vue.component('ElQuarterPicker', QuarterPicker);
  }
};
// QuarterPicker.vue
<template>
  <el-popover popper-class="quarter-picker-popover" :disabled="disabled">
    <el-input ref="reference" slot="reference" :value="InputText" :disabled="disabled" readonly>
      <i slot="prefix" class="el-icon-date"></i>
    </el-input>
    <div class="el-quarter-panel">
      <div class="el-quarter-header">
        <button
          type="button"
          aria-label="前一年"
          class="el-picker-panel__icon-btn el-icon-d-arrow-left"
          @click="handlePrevYear"
        ></button
        ><span role="button" class="el-date-picker__header-label"></span>
        <button
          type="button"
          aria-label="后一年"
          class="el-picker-panel__icon-btn el-icon-d-arrow-right"
          @click="handleNextYear"
        ></button>
      </div>
      <div class="el-quarter-body">
        <div v-for="i in 4" :key="i">
          <a class="cell" :class="getCellClass(i)" @click="onSelectQuarter(i)">Q</a>
        </div>
      </div>
    </div>
  </el-popover>
</template>

<script>
  import { formatDate, parseDate, prevYear, nextYear } from 'element-ui/src/utils/date-util';
  export default {
    props: {
      value: { type: [Date, Number, String], default: () => new Date() },
      format: { type: String, default: 'yyyy-MM-dd' }, // 回显日期格式
      valueFormat: { type: String, default: '' }, // 输入输出日期格式
      disabledDate: { type: Function, default: () => {} },
      disabled: { type: Boolean, default: false }
    },
    data() {
      return {
        date: parseValue(this.value, this.valueFormat)
      };
    },
    computed: {
      year() {
        return this.date?.getFullYear();
      },
      InputText() {
        const date = parseValue(this.value, this.valueFormat);
        return formatValue(date, this.format);
      }
    },
    watch: {
      value(date) {
        this.date = parseValue(date, this.valueFormat);
      }
    },
    methods: {
      prevYear,
      nextYear,
      formatValue,
      getCellClass(i) {
        const className = [];
        const quarterDate = new Date(this.year, i * 3, 0);
        const currentQuarterDate = parseValue(this.value, this.valueFormat);
        if (formatValue(currentQuarterDate, this.valueFormat) === formatValue(quarterDate, this.valueFormat))
          className.push('current');
        if (this.disabledDate(quarterDate)) className.push('disabled');
        return className.join(' ');
      },
      handlePrevYear() {
        this.date = prevYear(this.date);
      },
      handleNextYear() {
        this.date = nextYear(this.date);
      },
      onSelectQuarter(quarter) {
        const quarterDate = new Date(this.year, quarter * 3, 0);
        if (this.disabledDate(quarterDate)) return;
        this.$emit('input', formatValue(quarterDate, this.valueFormat));
      }
    }
  };

  // 解析传入的值为Date对象
  function parseValue(value, format) {
    if (value instanceof Date) return value;
    if (format === 'timestamp') return new Date(value);
    return parseDate(value, format);
  }

  // 格式化传入的Date对象
  function formatValue(date, format) {
    if (!date) return '';
    if (!format) return date;
    if (format === 'timestamp') return date.getTime();
    if (format === 'yyyy-QN') {
      const year = date.getFullYear();
      const month = date.getMonth() + 1;
      const quarter = Math.ceil(month / 3);
      return `${year}-Q${quarter}`;
    }
    return formatDate(date, format);
  }
</script>

<style>
  .quarter-picker-popover {
    padding: 12px 12px 0;
  }
</style>
<style lang="scss" scoped>
  .el-quarter-panel {
    width: 292px;

    .el-quarter-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      border-bottom: 1px solid #ebeef5;
      padding-bottom: 12px;
      line-height: 30px;

      .el-picker-panel__icon-btn {
        margin-top: 0;
        padding: 0 6px;
      }
    }

    .el-quarter-body {
      display: flex;
      font-size: 12px;

      div {
        flex: 1;
        padding: 8px 0;
        text-align: center;
        cursor: pointer;

        .cell {
          display: block;
          margin: 0 auto;
          width: 60px;
          height: 36px;
          line-height: 36px;
          color: #606266;

          &:hover,
          &.current {
            color: #409eff;
            transition: 0.3s all;
          }

          &.current {
            font-weight: bold;
          }

          &.disabled {
            color: #c0c4cc;
            background-color: #f5f7fa;
            cursor: not-allowed;
          }
        }
      }
    }
  }

  ::v-deep {
    .el-input__prefix {
      left: 8px;
    }

    .el-input__suffix {
      right: 8px;
    }

    .el-icon-circle-close {
      cursor: pointer;
    }
  }
</style>
// 使用
<el-quarter-picker
  v-model="config.quarterDate"
  format="yyyy-QN"
  value-format="yyyy-MM"
  :disabled-date="disabledDate"
  @input="updateFundInfo"
/>

相关概念

时间处理:parseValue转为便于计算的值,formatValue转为用于业务回显的值;

水平折叠面板动画

element-ui 的 Collapse折叠面板仅支持垂直折叠动画,添加水平折叠动画封装

// element.js
import DrawerTransition from '@/components/DrawerTransition';
......
Vue.use(DrawerTransition);
// index.js
import DrawerTransition from './src/DrawerTransition.vue';

export default {
  install(Vue) {
    Vue.component('ElDrawerTransition', DrawerTransition);
  }
};
// DrawerTransition.vue
<template>
  <el-drawer-transition>
    <slot />
  </el-drawer-transition>
</template>

<script>
  import { addClass, removeClass } from 'element-ui/lib/utils/dom';

  export default {
    name: 'EkDrawerTransition',
    components: {
      'el-drawer-transition': {
        functional: true,
        render(createElement, context) {
          const data = {
            on: {
              beforeEnter(el) {
                addClass(el, 'drawer-transition');
                if (!el.dataset) el.dataset = {};

                el.dataset.oldPaddingLeft = el.style.paddingLeft;
                el.dataset.oldPaddingRight = el.style.paddingRight;

                el.style.width = '0';
                el.style.paddingLeft = 0;
                el.style.paddingRight = 0;
              },
              enter(el) {
                el.dataset.oldOverflow = el.style.overflow;
                if (el.scrollWidth !== 0) {
                  el.style.width = el.scrollWidth + 'px';
                  el.style.paddingLeft = el.dataset.oldPaddingLeft;
                  el.style.paddingRight = el.dataset.oldPaddingRight;
                } else {
                  el.style.width = '';
                  el.style.paddingLeft = el.dataset.oldPaddingLeft;
                  el.style.paddingRight = el.dataset.oldPaddingRight;
                }
                el.style.overflow = 'hidden';
              },
              afterEnter(el) {
                removeClass(el, 'drawer-transition');
                el.style.width = '';
                el.style.overflow = el.dataset.oldOverflow;
              },
              beforeLeave(el) {
                if (!el.dataset) el.dataset = {};
                el.dataset.oldPaddingLeft = el.style.paddingLeft;
                el.dataset.oldPaddingRight = el.style.paddingRight;
                el.dataset.oldOverflow = el.style.overflow;

                el.style.width = el.scrollWidth + 'px';
                el.style.overflow = 'hidden';
              },
              leave(el) {
                if (el.scrollWidth !== 0) {
                  addClass(el, 'drawer-transition');
                  el.style.width = 0;
                  el.style.paddingLeft = 0;
                  el.style.paddingRight = 0;
                }
              },
              afterLeave(el) {
                removeClass(el, 'drawer-transition');
                el.style.width = '';
                el.style.overflow = el.dataset.oldOverflow;
                el.style.paddingLeft = el.dataset.oldPaddingLeft;
                el.style.paddingRight = el.dataset.oldPaddingRight;
              }
            }
          };
          return createElement('transition', data, context.children);
        }
      }
    }
  };
</script>

<style lang="scss" scoped>
  .drawer-transition {
    transition: 0.3s width ease-in-out, 0.3s padding-left ease-in-out, 0.3s padding-right ease-in-out;
  }
</style>
// 使用
<el-drawer-transition>
  <div v-show="collapse">...content</div>
</el-drawer-transition>

文本一键编辑

<div v-if="editNameId === item.id">
  <el-input
    v-model.trim="item.name"
    class="name-input"
    maxlength="10"
    @blur.stop="editedName(item.name)"
  >
    <template #suffix><i class="icon icon-editing"></i></template>
  </el-input>
</div>
<div v-else class="title d-flex align-center">
   <i class="icon icon-edit" @click.stop="editName(item.id)"></i>
</div>
// 编辑名称时
editName(id) {
  this.editNameId = id;
  this.$nextTick(() => {
    document.querySelector('.name-input input').focus();
  });
}

// 编辑名称完成时
editedPageName(newName) {
  if (!isvalid) {
    console.log(`${newName}校验未通过`);
    document.querySelector('.name-input input').focus();
  } else {
    this.editNameId = '';
  }
}

验证码输入

<el-form-item class="form-item" :inline="true" prop="code">
  <div class="input-code">
    <el-input v-model.trim="form.code" type="text" placeholder="请输入验证码" maxlength="6"></el-input>
    <el-button
      class="toggle"
      :class="{ btnActive: isValidTel }"
      :disabled="!form.mobile || !!waitingTime"
      @click="getVerifyCode"
      ></el-button>
  </div>
</el-form-item>

waitingTime:下次可获取等待时间

isValidTel:是否有效手机号

getVerifyCode:校验手机号并获取验证码

validBtnTextwaitingTime ? "重新获取" + waitingTime.toString().padStart(2, '0')} + "s" : '获取验证码'

自定义下拉菜单选择

<el-dropdown
  class="tagList-dropdown fulled-w"
  trigger="click"
  placement="bottom-start"
  @visible-change="onVisibleChange"
>
  <div class="dropdown-toggle d-flex jc-between al-center bgc-white">
    <div class="d-flex">
      <div v-for="(item, i) in tagList" :key="i" class="tag-item d-flex al-center px-8 t-theme fs-xs bd-filt">
        <div class="flex-1 t-ellipsis"></div>
        <i class="el-icon-error ml-8" @click.stop="onSelectTag(item)"></i>
      </div>
    </div>
    <i class="el-icon-arrow-down" :class="dropdownIn ? 'dropdown-in' : 't-light'"></i>
  </div>
  <el-dropdown-menu slot="dropdown" class="tagList-dropdown-menu">
    <div v-for="(item, index) in tagData" :key="index" :class="{ floor: index < tagData.length - 1 }">
      <div class="title py-16"></div>
      <div class="list d-flex flex-wrap t-light">
        <div
          v-for="(tag, i) in item.list"
          :key="i"
          class="item mr-16 mb-16 pointer"
          :class="{ 't-theme': isSelectedTag(tag) }"
          @click="onSelectTag(tag)"
        >
          
        </div>
      </div>
    </div>
  </el-dropdown-menu>
</el-dropdown>
dropdownIn: false 是否处于下拉状态
tagDate: [] 选项数据
tagList: [] 已选则数据

methods: {
  // 是否为已选择标签
  isSelectedTag(tag) {
    return this.tagList.includes(tag);
  },
  // 选择标签
  onSelectTag(tag) {
    const index = this.tagList.indexOf(tag);
    // 添加选中
    if (index === -1) {
      this.tagList.push(tag);
    }
    // 取消选中
    else {
      this.tagList.splice(index, 1);
    }
  }
  // 标签选择下拉菜单 显示/隐藏
  onVisibleChange(visible) {
    this.dropdownIn = visible;
  }
}
<style lang="scss" scoped>
  .tagList-dropdown {
    .dropdown-toggle {
      border: 1px solid #cecece;
      border-radius: 4px;
      padding: 16px 7px 16px 16px;
      min-height: 62px;
      cursor: pointer;

      .tag-item {
        border: 1px solid #ccc;
        max-width: 94px;
        height: 28px;
        line-height: 26px;
        background-color: #f4f9ff;

        &:not(:last-of-type) {
          margin-right: 12px;
        }
      }

      .el-icon-arrow-down {
        border-radius: 50%;
        padding: 2px;
        transition: all 0.3s;

        &.dropdown-in {
          transform: rotate(180deg);
        }
      }
    }
  }

  .tagList-dropdown-menu {
    margin: 5px 0;
    padding: 0 24px 8px;
    width: 360px;

    .floor:not(:last-of-type) {
      border-bottom: 1px solid #c0c0c0;
    }

    ::v-deep {
      .popper__arrow {
        display: none;
      }
    }
  }
</style>

瀑布流布局

安装

npm i vue-masonry -S

引入

import { VueMasonryPlugin } from 'vue-masonry';
app.use(VueMasonryPlugin);

app.mount('#app');

使用

// 瀑布流布局自适应
const masonryId = generateID();
const redrawVueMasonry = inject('redrawVueMasonry');
// 数据改变重新排版
watch(list, () => {
  nextTick(() => redrawVueMasonry(masonryId));
});
// 子项状态时重新排版redrawVueMasonry...
<div
  v-masonry="masonryId"
  item-selector=".card"
  :gutter="36"
  transition-duration="0"
>
  <ArticleCard
    v-for="item in list"
    class="card"
    :key="item.id"
  />
</div>

gutter: 子项间距值

transition-duration: 子项动画效果时长