基于Vue3+Vite+TS,二次封装element-plus业务组件 笔记第四章

124 min read

4-1 导航菜单-导航菜单的需求分析和数据设计

二级菜单源码

<template>
  <el-menu
    class="el-menu-vertical-demo"
    :default-active="defaultActive"
    :router="router"
    v-bind="$attrs"
  >
    <template v-for="(item, i) in data" :key="i">
      <el-menu-item v-if="!item[children] || !item[children].length" :index="item[index]">
        <component v-if="item[icon]" :is="`el-icon-${toLine(item[icon])}`"></component>
        <span>{{ item[name] }}</span>
      </el-menu-item>
      <el-sub-menu v-if="item[children] && item[children].length" :index="item[index]">
        <template #title>
          <component v-if="item[icon]" :is="`el-icon-${toLine(item[icon])}`"></component>
          <span>{{ item[name] }}</span>
        </template>
        <el-menu-item v-for="(item1, index1) in item[children]" :key="index1" :index="item1.index">
          <component v-if="item1[icon]" :is="`el-icon-${toLine(item1[icon])}`"></component>
          <span>{{ item1[name] }}</span>
        </el-menu-item>
      </el-sub-menu>
    </template>
  </el-menu>
</template>

<script lang='ts' setup>
import { PropType } from 'vue'
import { toLine } from '../../../utils'


let props = defineProps({
  // 导航菜单的数据
  data: {
    type: Array as PropType<any[]>,
    required: true
  },
  // 默认选中的菜单
  defaultActive: {
    type: String,
    default: ''
  },
  // 是否是路由模式
  router: {
    type: Boolean,
    default: false
  },
  // 键名
  // 菜单标题的键名
  name: {
    type: String,
    default: 'name'
  },
  // 菜单标识的键名
  index: {
    type: String,
    default: 'index'
  },
  // 菜单图标的键名
  icon: {
    type: String,
    default: 'icon'
  },
  // 菜单子菜单的键名
  children: {
    type: String,
    default: 'children'
  },
})
</script>

<style lang='scss' scoped>
svg {
  margin-right: 4px;
  width: 1em;
  height: 1em;
}
.el-menu-vertical-demo:not(.el-menu--collapse) {
  width: 200px;
}
</style>

4-2 导航菜单-巧用template实现两级结构的菜单

子组件使用 $attrs 来收集并注入未在子组件props里定义的props属性,在组件封装时免于定义一长串的props

使用方法

子组件使用v-bind来接收$attrs

 <el-menu
    class="el-menu-vertical-demo"
    :default-active="defaultActive"
    :router="router"
    v-bind="$attrs"
  >

那么父组件上传递非props属性

<m-menu text-color="red"/>

子组件会接受text-color属性到自己组件上, 此时的子组件相当于

 <el-menu
    class="el-menu-vertical-demo"
    :default-active="defaultActive"
    :router="router"
    text-color="red"
  >

4-3导航菜单-使用tsx实现无限层级菜单

无级限菜单

import { defineComponent, PropType, useAttrs } from 'vue'
import { MenuItem } from './types'
import * as Icons from '@element-plus/icons'
import './styles/index.scss'

export default defineComponent({
  props: {
    // 导航菜单的数据
    data: {
      type: Array as PropType<MenuItem[]>,
      required: true
    },
    // 默认选中的菜单
    defaultActive: {
      type: String,
      default: ''
    },
    // 是否是路由模式
    router: {
      type: Boolean,
      default: false
    },
    // 菜单标题的键名
    name: {
      type: String,
      default: 'name'
    },
    // 菜单标识的键名
    index: {
      type: String,
      default: 'index'
    },
    // 菜单图标的键名
    icon: {
      type: String,
      default: 'icon'
    },
    // 菜单子菜单的键名
    children: {
      type: String,
      default: 'children'
    },
  },
  setup(props, ctx) {
    // 封装一个渲染无限层级菜单的方法
    // 函数会返回一段jsx的代码
    let renderMenu = (data: any[]) => {
      return data.map((item: any) => {
        // 每个菜单的图标
        item.i = (Icons as any)[item[props.icon!]]
        // 处理sub-menu的插槽
        let slots = {
          title: () => {
            return <>
              <item.i />
              <span>{item[props.name]}</span>
            </>
          }
        }
        // 递归渲染children
        if (item[props.children!] && item[props.children!].length) {
          return (
            <el-sub-menu index={item[props.index]} v-slots={slots}>
              {renderMenu(item[props.children!])}
            </el-sub-menu>
          )
        }
        // 正常渲染普通的菜单
        return (
          <el-menu-item index={item[props.index]}>
            <item.i />
            <span>{item[props.name]}</span>
          </el-menu-item>
        )
      })
    }
    let attrs = useAttrs()
    return () => {
      return (
        <el-menu
          class="menu-icon-svg"
          default-active={props.defaultActive}
          router={props.router}
          {...attrs}
        >
          {renderMenu(props.data)}
        </el-menu>
      )
    }
  }
})
  • 在Vue3中使用jsx
npm i -D @vitejs/plugin-vue-jsx
  • 在vite.config.js配置jsx插件
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(), vueJsx()],
  server: {
    port: 8080
  }
})
  • 使用jsx 只能使用defineComponent语法,不能使用setup的语法糖

  • 在jsx中使用attrs

let attrs = useAttrs()
<el-menu
          class="menu-icon-svg"
          default-active={props.defaultActive}
          router={props.router}
          {...attrs} // 展开属性
        >
          {renderMenu(props.data)}
</el-menu>
  • 在jsx中使用slot

在jsx中无法直接使用template ,需要搭配v-slots和slots对象

先定义一个slots对象

// 处理el-sub-menu的插槽
let slots = {
    title: () => {
      return <>
        <item.i />
        <span>{item[props.name]}</span>
        </>
      }
}

使用v-slots的指令接受slot对象

if (item[props.children!] &&  item[props.children!].length) {
  return (
  <el-sub-menu index={item[props.index]} v-slots={slots}>
  	{renderMenu(item[props.children!])}
  </el-sub-menu>
 )
}
  • TS的类型断言使用
item.i = (Icons as any)[item[props.icon!]]

4-4导航菜单-利用封装好的导航菜单组件改造项目整体结构

4-5导航菜单-完善细节并增加自定义键名功能

有时间接口的键名和实际定义的组件变量名不一定,可以使用ES6的动态key,在子组件上接受props来接受传入的动态键名

封装使用props的键名,这样就灵活处理掉了写死的键名

item[props.name]

4-6进度条-完成进度条动态加载效果

<template>
  <el-progress :percentage="p" v-bind="$attrs"></el-progress>
</template>

<script lang='ts' setup>
import { onMounted, ref } from 'vue'

// 标识动画加载过程中改变的值
let num = ref<number>(0)

let props = defineProps({
  // 进度条进度
  percentage: {
    type: Number,
    required: true
  },
  // 是否有动画效果
  isAnimate: {
    type: Boolean,
    default: false
  },
  // 动画时长(毫秒)
  time: {
    type: Number,
    default: 3000
  },
})

let p = ref(0)

onMounted(() => {
  if (props.isAnimate) {
    // 规定时间内加载完成
    let t = Math.ceil(props.time / props.percentage)
    let timer = setInterval(() => {
      p.value += 1
      if (p.value >= props.percentage) {
        p.value = props.percentage
        clearInterval(timer)
      }
    }, t)
  }
})
</script>

<style lang='scss' scoped>
</style>

4-7时间选择组件-完成时间选择组件的全部功能

<template>
  <div style="display: flex;">
    <div style="margin-right: 20px;">
      <el-time-select
        v-model="startTime"
        :placeholder="startPlaceholder"
        :start="startTimeStart"
        :step="startStep"
        :end="startTimeEnd"
        v-bind="$attrs.startOptions"
      ></el-time-select>
    </div>
    <div>
      <el-time-select
        v-model="endTime"
        :min-time="startTime"
        :placeholder="endPlaceholder"
        :start="endTimeStart"
        :step="endStep"
        :end="endTimeEnd"
        :disabled="endTimeDisabled"
        v-bind="$attrs.endOptions"
      ></el-time-select>
    </div>
  </div>
</template>

<script lang='ts' setup>
import {ref, watch} from 'vue'

let props = defineProps({
  // 开始时间的占位符
  startPlaceholder: {
    type: String,
    default: '请选择开始时间'
  },
  // 结束时间的占位符
  endPlaceholder: {
    type: String,
    default: '请选择结束时间'
  },
  // 开始时间的开始选择
  startTimeStart: {
    type: String,
    default: "08:00"
  },
  // 开始时间的步进
  startStep: {
    type: String,
    default: "00:30"
  },
  // 开始时间的结束选择
  startTimeEnd: {
    type: String,
    default: "24:00"
  },
  // 结束时间的开始选择
  endTimeStart: {
    type: String,
    default: "08:00"
  },
  // 结束时间的步进
  endStep: {
    type: String,
    default: "00:30"
  },
  // 结束时间的结束选择
  endTimeEnd: {
    type: String,
    default: "24:00"
  },
})

let emits = defineEmits(['startChange', 'endChange'])

// 开始时间
let startTime = ref<string>('')
// 结束时间
let endTime = ref<string>('')
// 是否禁用结束时间
let endTimeDisabled = ref<boolean>(true)

// 监听开始时间的变化
watch(() => startTime.value, val => {
  if (val === '') {
    endTime.value = ''
    endTimeDisabled.value = true
  }
  else {
    endTimeDisabled.value = false
    // 给父组件分发事件
    emits('startChange', val)
  }
})

// 监听结束时间的变化
watch(() => endTime.value, val => {
  if (val !== '') {
    emits('endChange', {
      startTime: startTime.value,
      endTime: val
    })
  }
})
</script>

<style lang='scss' scoped>
</style>

4-8时间选择组件-完成日期选择组件所有功能

<template>
  <div style="display: flex;">
    <div style="margin-right: 20px;">
      <el-date-picker
        v-model="startDate"
        type="date"
        :placeholder="startPlaceholder"
        :disabledDate="startDisabledDate"
        v-bind="$attrs.startOptions"
      ></el-date-picker>
    </div>
    <div>
      <el-date-picker
        v-model="endDate"
        type="date"
        :placeholder="endPlaceholder"
        :disabled="endDateDisabled"
        :disabledDate="endDisabledDate"
        v-bind="$attrs.endOptions"
      ></el-date-picker>
    </div>
  </div>
</template>

<script lang='ts' setup>
import { ref, watch } from 'vue'

let props = defineProps({
  // 开始日期的占位符
  startPlaceholder: {
    type: String,
    default: '请选择开始日期'
  },
  // 结束日期的占位符
  endPlaceholder: {
    type: String,
    default: '请选择结束日期'
  },
  // 是否禁用选择今天之前的日期
  disableToday: {
    type: Boolean,
    default: true
  }
})

let emits = defineEmits(['startChange', 'endChange'])

// 开始日期
let startDate = ref<Date | null>(null)
// 结束日期
let endDate = ref<Date | null>(null)
// 控制结束日期的禁用状态
let endDateDisabled = ref<boolean>(true)

// 禁用开始日期的函数
let startDisabledDate = (time: Date) => {
  if (props.disableToday) return time.getTime() < Date.now() - 1000 * 60 * 60 * 24
}
// 禁用结束日期的函数
let endDisabledDate = (time: Date) => {
  if (startDate.value) {
    return time.getTime() < startDate.value.getTime() + 1000 * 60 * 60 * 24
  }
}

// 监听开始的日期
watch(() => startDate.value, val => {
  if (!val) {
    endDateDisabled.value = true
    endDate.value = null
  } else {
    emits('startChange', val)
    endDateDisabled.value = false
  }
})
// 监听结束的日期
watch(() => endDate.value, val => {
  if (val) {
    emits('endChange', {
      startDate: startDate.value,
      endDate: val
    })
  }
})
</script>

<style lang='scss' scoped>
</style>

Element-Plus是否禁用选择今天之前的日期

:disabledDate="startDisabledDate"

接受一个返回的时间对象,判断哪些时间是否禁用

let startDisabledDate = (time: Date) => {
  if (props.disableToday) return time.getTime() < Date.now() - 1000 * 60 * 60 * 24
}

4-9时间选择组件-修复日期选择组件结束日期未清空问题

联动watch时 需要联动清空相关的数据

4-10城市选择组件-组合式使用组件完成基本布局

<template>
  <el-popover v-model:visible="visible" placement="bottom-start" :width="430" trigger="click">
    <template #reference>
      <div class="result">
        <div>{{ result }}</div>
        <div>
          <el-icon-arrowdown :class="{ 'rotate': visible }"></el-icon-arrowdown>
        </div>
      </div>
    </template>
    <div class="container">
      <el-row>
        <el-col :span="8">
          <el-radio-group v-model="radioValue" size="small">
            <el-radio-button label="按城市"></el-radio-button>
            <el-radio-button label="按省份"></el-radio-button>
          </el-radio-group>
        </el-col>
        <el-col :offset="1" :span="15">
          <el-select
            @change="changeSelect"
            placeholder="请搜索城市"
            size="small"
            v-model="selectValue"
            filterable
            :filter-method="filterMethod"
          >
            <el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id"></el-option>
          </el-select>
        </el-col>
      </el-row>
      <template v-if="radioValue === '按城市'">
        <div class="city">
          <!--  <div v-for="(value, key) in cities">{{key}}</div> -->
          <!-- 字母区域 -->
          <div
            class="city-item"
            @click="clickChat(item)"
            v-for="(item, index) in Object.keys(cities)"
          >{{ item }}</div>
        </div>
        <el-scrollbar max-height="300px">
          <template v-for="(value, key) in cities" :key="key">
            <el-row style="margin-bottom: 10px;" :id="key">
              <el-col :span="2">{{ key }}:</el-col>
              <el-col :span="22" class="city-name">
                <div
                  @click="clickItem(item)"
                  class="city-name-item"
                  v-for="(item, index) in value"
                  :key="item.id"
                >
                  <div>{{ item.name }}</div>
                </div>
              </el-col>
            </el-row>
          </template>
        </el-scrollbar>
      </template>
      <template v-else>
        <div class="province">
          <div
            class="province-item"
            v-for="(item, index) in Object.keys(provinces)"
            :key="index"
            @click="clickChat(item)"
          >{{ item }}</div>
        </div>
        <el-scrollbar max-height="300px">
          <template v-for="(item, index) in Object.values(provinces)" :key="index">
            <template v-for="(item1, index1) in item" :key="index1">
              <el-row style="margin-bottom: 10px;" :id="item1.id">
                <el-col :span="3">{{ item1.name }}:</el-col>
                <el-col :span="21" class="province-name">
                  <div
                    class="province-name-item"
                    v-for="(item2, index2) in item1.data"
                    :key="index2"
                  >
                    <div @click="clickProvince(item2)">{{ item2 }}</div>
                  </div>
                </el-col>
              </el-row>
            </template>
          </template>
        </el-scrollbar>
      </template>
    </div>
  </el-popover>
</template>

<script lang='ts' setup>
import { ref, onMounted } from 'vue'
import city from '../lib/city'
import { City } from './types'
import province from '../lib/province.json'

// 分发事件
let emits = defineEmits(['changeCity', "changeProvince"])

// 最终选择的结果
let result = ref<string>('请选择')
// 控制弹出层的显示
let visible = ref<boolean>(false)
// 单选框的值 按城市还是按省份选择
let radioValue = ref<string>('按城市')
// 下拉框的值 搜索下拉框
let selectValue = ref<string>('')
// 下拉框显示城市的数据
let options = ref<City[]>([])
// 所有的城市数据
let cities = ref(city.cities)
// 所有省份的数据
let provinces = ref(province)
// 所有的城市数据
let allCity = ref<City[]>([])

// 点击每个城市
let clickItem = (item: City) => {
  // 给结果赋值
  result.value = item.name
  // 关闭弹出层
  visible.value = false
  emits('changeCity', item)
}
let clickProvince = (item: string) => {
  // 给结果赋值
  result.value = item
  // 关闭弹出层
  visible.value = false
  emits('changeProvince', item)
}

// 点击字母区域
let clickChat = (item: string) => {
  let el = document.getElementById(item)
  if (el) el.scrollIntoView()
}
// 自定义搜索过滤
let filterMethod = (val: string) => {
  let values = Object.values(cities.value).flat(2)
  if (val === '') {
    options.value = values
  } else {
    if (radioValue.value === '按城市') {
      // 中文和拼音一起过滤
      options.value = values.filter(item => {
        return item.name.includes(val) || item.spell.includes(val)
      })
    } else {
      // 中文过滤
      options.value = values.filter(item => {
        return item.name.includes(val)
      })
    }
  }
}
// 下拉框选择
let changeSelect = (val: number) => {
  let city = allCity.value.find(item => item.id === val)!
  result.value = city.name
  if (radioValue.value === '按城市') {
    emits('changeCity', city)
  } else {
    emits('changeProvince', city.name)
  }
}
onMounted(() => {
  // 获取下拉框的城市数据
  let values = Object.values(cities.value).flat(2)
  allCity.value = values
  options.value = values
})
</script>

<style lang='scss' scoped>
.result {
  display: flex;
  align-items: center;
  width: fit-content;
  cursor: pointer;
}
.rotate {
  transform: rotate(180deg);
}
svg {
  width: 1em;
  height: 1em;
  position: relative;
  top: 2px;
  margin-left: 4px;
  transition: all 0.25s linear;
}
.container {
  padding: 6px;
}
.city,
.province {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  margin-top: 10px;
  margin-bottom: 10px;
  &-item {
    padding: 3px 6px;
    margin-right: 8px;
    border: 1px solid #eee;
    margin-bottom: 8px;
    cursor: pointer;
  }
}
.city-name,
.province-name {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  .city-name-item,
  .province-name-item {
    margin-right: 6px;
    margin-bottom: 6px;
    cursor: pointer;
  }
}
</style>

el-radio-group 使用按钮组件的切换组件

 <el-radio-group v-model="radioValue" size="small">
            <el-radio-button label="按城市"></el-radio-button>
            <el-radio-button label="按省份"></el-radio-button>
</el-radio-group>

4-11城市选择组件-获取城市数据并显示所有城市

剪头动画的翻转的实现, 显示和隐藏时动态添加一个过渡类名rotate


.rotate {
  transform: rotate(180deg);
}

svg {
  width: 1em;
  height: 1em;
  position: relative;
  top: 2px;
  margin-left: 4px;
  transition: all 0.25s linear;
}

El-scroll 滚动内容区组件,定义滚动区域的最大显示高度

<el-scrollbar max-height="300px"></el-scroll>

4-12城市选择组件-绑定事件并实现点击字母跳转到对应区域

Element.scrollIntoView() 方法让当前的元素滚动到浏览器窗口的可视区域

语法

element.scrollIntoView(); // 等同于element.scrollIntoView(true) 
element.scrollIntoView(alignToTop); // Boolean型参数 
element.scrollIntoView(scrollIntoViewOptions); // Object型参数

参数有两种写法

alignToTop : 布尔值类型

true 元素的顶端将和其所在滚动区的可视区域的最顶端对齐, 等同于scrollIntoViewOptions: {block: "start", inline: "nearest"}默认值

false 元素的底部将和其所在的滚动区域的可视区域的底部的对齐,等同于scrollIntoViewOptions: {block: "end", inline: "nearest"}默认值

scrollIntoViewOptions :对象类型(可选参数)

一个包含下列属性的对象:
behavior 可选
定义动画过渡效果, "auto"或 "smooth" 之一。默认为 "auto"
block 可选
定义垂直方向的对齐, "start", "center", "end", 或 "nearest"之一。默认为 "start"
inline 可选
定义水平方向的对齐, "start", "center", "end", 或 "nearest"之一。默认为 "nearest"

示例

var element = document.getElementById("box");
element.scrollIntoView();
element.scrollIntoView(false);
element.scrollIntoView({block: "end"});
element.scrollIntoView({behavior: "instant", block: "end", inline: "nearest"});

4-13城市选择组件-完善按省份选择城市

ES6 数组方法flat()

作用:将数组扁平化,对每一项的值进行循环处理,如果该项的值也是数组,则取出来(相当于去掉了这项数组的[]括号)

flat(n)将每一项的数组偏平化,n默认是1,表示扁平化深度.

let a = ['a',["a","b"],{"a":"aaaa"},
            [
              {"bb":"bbbb"},
              {"c":[
                      {"c1":"ccc111111"}
                   ]
              },
              [12,22,32]
            ]
        ];
let b = a.flat();  // [ 'a', 'a', 'b', {'a': 'aaaa'}, {'bb':'bbbb'},{'c':[...]},12,22,32]
b[4]["bb"] = "b2b2b2b2b2b2"; //影响了原数组a,以及新数组bb
b[5].c[0].c1 = "ccc12121211111"; //影响了原数组a,以及新数组bb
let bb = a.flat(2);

console.log(a, b, bb);
复制代码

下面用自己的代码实现flat

let a = ['a',["a","b"],{"a":"aaaa"},
            [
              {"bb":"bbbb"},
              {"c":[
                      {"c1":"ccc111111"}
                   ]
              },
              [12,22,32]
            ]
        ];

function myFlat(dept) {
  let arr = this;
  dept = typeof dept === "undefined" ? 1 : dept;
  if (typeof dept !== "number") {
    return;
  }

  let res = JSON.parse(JSON.stringify(arr));

  let testNum = 0;

  let reduceArr = function (_arr) {
    console.log(++testNum);
    return _arr.reduce(function(prevItem, curItem){
      return prevItem.concat(curItem);
    },[])
  };

  for (let i = 0; i < dept; i++) {
    let hasArrayItem = res.some(function(item){
      return Array.isArray(item);
    });
    if (hasArrayItem) {
      res = reduceArr(res);
    }
  }
  return res;
}
Array.prototype.myFlat = myFlat;
let c = a.myFlat();
c[4]["bb"] = "b2b2b2b2b2b2"; //并没有影响原数组a,以及新数组bb
let cc = a.myFlat(2);
console.log(a, c, cc);

4-14城市选择组件-使用filter-method实现搜索过滤

El-select 自动过滤

 <el-select
   @change="changeSelect"
   placeholder="请搜索城市"
   size="small"
   v-model="selectValue"
   filterable
   :filter-method="filterMethod"
   >
	<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
	</el-option>
</el-select>