基于Vue3的UI组件库

244 阅读9分钟

(1)前言

本文是本人用vue3造轮子的学习记录博客,当然也可以欢迎大家批评指正,主要是针对我在项目中遇到的一些问题做出一些总结和当时思考过程的记录;预览链接

(2)项目配置问题

缺少文件报错

问题描述:在TS文件中引入vue文件,找不到模块“xxx.vue”或其相应的类型声明
解决方案:在src文件夹中创建了后缀名为 .d.ts 文件,写入如下代码

declare module "*.vue" {
  import { ComponentOptions } from "vue"
  const componentOptions: ComponentOptions
  export default componentOptions
}

即使这样做了之后,发现在引入App.vue文件的时候仍然出现了找不到模块的问题,发现当打开.d.ts文件时就不会发生报错,但是不想一直打开该文件怎么办?可以在项目的根目录下创建tsconfig.json文件

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "strict": false,
    "jsx": "preserve",
    "moduleResolution": "node"
  }
}

(3)路由配置问题

导入模块错误或者没有导入模块

问题描述:按照vueRouter官网配置路由时

const router = VueRouter.createRouter({
  // 4. 内部提供了 history 模式的实现。为了简单起见,我们在这里使用 hash 模式。
  history: VueRouter.createWebHashHistory(),
  routes, // `routes: routes` 的缩写
})

这样的引用该方式会在控制台报错,

The requested module '/@modules/vue-router/dist/vue-router.esm-bundler.js' does not provide an export named 'default

实际上就是说 VueRouter没有导出
解决方案:导入createRouter模块和createWebHashHistory模块

// don't
import VueRouter from "vue-router"

// do
import { createRouter, createWebHashHistory } from "vue-router"

(4)样式问题

使用了vue3不太支持的

问题描述:使用::v-deep语法尝试在父组件修改子组件样式失败,编译报错如下

[@vue/compiler-sfc] ::v-deep usage as a combinator has been deprecated. Use :deep(<inner-selector>) instead.

解决方案:按照编译报错的方式修改,将::v-deep的方式修改为:deep(选择器)可以实现深入修改样式的效果
补充说明:(vue3选择器的支持)

  • 完全弃用了>>>/deep/方法。
  • ::v-deep .xxx还可以用,但是不建议用,用了会发出警告。
  • 建议的写法是::v-deep(.xxx)或者简写:deep(.xxx)

(5)组件问题

1、编写Dialog组件时,vue3的render函数问题

问题描述:编写Dialog组件时,再render函数里写入titlecontent将内容传入slot插槽中,浏览器控制台警告如下: image.png 解决方案:按照警告建议的方式修改

{titel,content} //改为如下方式:
{
    title: () => { return title },
    content: () => { return content },
}

下图为github上关于类似问题的解决方案

image.png

2、编写Switch组件时,实现方式的讨论

问题描述:编写Switch组件时,想要达成如下效果,用什么样得方式比较好? image.png ======>点击按钮后切换 image.png
解决方案:
方案一:在setup中写入如下代码,再将其绑定在style属性上,原理为通过计算属性对每个组件进行条件判断,满足组件传入了props.color并且props.valuetrue,开关处于打开状态,则style样式生效。

const calColor = computed(() => {
      return props.color && props.value ? { backgroundColor: props.color } : {};
});
//以下是组件
<button
    class="Jv-ui"
    @click="toggle()"
    :class="{ 'Jv-ui-checked': value }"
    v-bind="$attrs"
    :style="[`transform: scale(${size});`, calColor]"
  >
    <span></span>
  </button>

方案二:在组件的style属性写入如下样式,然后在css样式中,原理为当valuetrueJv-ui-checked类型生效然后通过将propscolor传入的css中以此改变组件颜色。

<button
    class="Jv-ui"
    @click="toggle()"
    :class="{ 'Jv-ui-checked': value }"
    v-bind="$attrs"
    :style="`transform:scale(${size});--color:${color ? color : '#2d8cf0'}`"
  >
    <span></span>
</button>
  
  // 以下为css样式
.Jv-ui-checked {
  background-color: var(--color);
}
3、编写Tabs组件的相关问题
①、Tabs组件的实现思路

问题描述:点击对应导航栏实现下方内容给的切换,如何实现如下效果? GIF 2023-1-28 15-38-47.gif
解决方案:
定义一个Tabs组件和TabHTML结构包含了指示条(即下方蓝色长条)、导航栏、内容展示区域,最终TabsTabDOM结果如下:

//这是Tabs组件
<template>
  <div class="Jv-ui-Tabs" ref="container">
    <div class="Jv-ui-activeBar" ref="indicator"></div> //指示器
    <div
      class="Jv-ui-Nav"
      v-for="(item, index) in TabArray"
      :key="item.props.name"
      :class="{
        selected: item.props.name === selected,
        disabled: item.props.disabled,
      }"
      @click="handleClick(item)"
      :ref="
        (el) => {
          if (el) NavItems[index] = el;
        }
      "
    >
      {{ item.props.title }}
    </div>
    // 导航栏部分
  </div>
  // 内容部分
  <div class="Jv-ui-TabContent">
    <component v-for="c in TabFilter" :key="c.props.name" :is="c" />
  </div>
</template>
//这是Tab组件
<template>
  <div :title="title" class="Jv-ui-Tab">
    <slot></slot>
  </div>
</template>

以下代码是组合使用时

<template>
  <Tabs v-model:selected="TabFlag1">
    <Tab title="导航1" name="first">内容1</Tab>
    <Tab title="导航2" name="second">内容2</Tab>
    <Tab title="导航3" name="third">内容3</Tab>
  </Tabs>
</template>
②、Tabs组件的内容未更新问题

问题描述:编写Tabs组件时,通过 <component v-for="(c, index) in TabFilter" :key="index" :is="c">TabFilter是计算属性,通过这样的方式去渲染导航下方的内容区域,通过查看元素可以发现都已经更新了,但是内容没有更新
image.png
解决方案:
方案一:通过console.log()的方式输出过滤了插槽的内容,来了解虚拟DOM的内容以此来强制渲染

//selected 是props的属性
 let TabArray = context.slots.default();
 let TabFilter = computed(() => {
      return TabArray.filter((item) => {
        return item.props.name == selected.value;
      });
    });
    
  console.log(TabArray);
  console.log(TabArray[0].children);
  console.log(TabArray[0].children.default());
  console.log(TabArray[0].children.default()[0].children);

image.png
可以发现数组的第一个对象的children属性上的default函数是一个数组,这个数组包含一个对象,对象的内容就有我们想要的内容字符串,因此可以采用如下方式对内容进行渲染

<component v-for="(c, index) in TabFilter" :key="index" :is="c">
  {{ c.children.default()[0].children }}
</component>

上述方案虽然可以解决问题,但是可能不太优美,而且在开发工具里还会出现报错

image.png 方案二:采用动态设置css的方式来显示隐藏我们需要的和不需要的内容

<component class="Jv-ui-tabsContent-item" :class="{selected: c.props.name === selected }" v-for="c in TabFilter" :is="c" />
在css按照如下方式书写
&-item {
    display: none;
    &.selected {
        display: block;
    }
}

方案三:该方案也是比较符合vue思想的方案,更是vue作者本人的解决方案,通过给出一个唯一的key值,来使它进行更新 image.png

<component v-for="c in TabFilter" :key="c.props.name" :is="c" />

对于当前自己造轮子的Tabs组件来说,我采用如上方式,因为c.props.name是唯一值

image.png

③、Tabs组件点击导航栏,指示条变化

问题描述:编写Tabs组件时,点击导航栏时,指示条改变widthleft值,因此需要知道被点击的模块相关内容
方案:采用ref的方式来获取实例的内容,记住必须要在setupreturn出去否则无法建立连接,在v-for中可以采用函数的方式去处理。

<div class="Jv-ui-Tabs" ref="container">
    <div class="Jv-ui-activeBar" ref="indicator"></div>
    <div
      class="Jv-ui-Nav"
      v-for="(item, index) in TabArray"
      :key="item.props.name"
      :class="{ selected: item.props.name === selected }"
      @click="handleClick"
      :ref="
        (el) => {
          if (el) NavItems[index] = el;
        }
      "
    >
      {{ item.props.title }}
    </div>
  </div>

image.png
移动的本质是具体的NavItems相对于外层containerleft变化,可以通过getBoundingClientRect函数获得来获取相对边界的值变化和元素大小,将变化的值赋值给具体的NavItems,最后可以在css中添加transition来改善移动效果。

④、Tabs组件设置禁止点击

问题描述:给其中一个Tab组件添加disabled使其禁止被点击

image.png

image.png
由于Tabs组件用的插槽的方式接受的Tab里面的内容,所以这使得disabled不能通过v-bind="$attrs"来传入到Tab组件的内部
解决方案:

image.png
通过添加类的方式来改变样式,此时点击导航栏会发生如下效果

image.png

image.png 通知台报错表示下面的导航条指示器样式的修改逻辑有问题,其根本原因在于元素的结构是有问题的

image.png
按理来说,点击导航3在class属性上只有基础类和disabled,不应该出现selected属性,因此为了达到效果应该对handleClick点击事件进行修改

const handleClick = (e) => {
  if (e.target.className == "Jv-ui-Nav disabled") {
    return;
  } else {
    context.emit("update:selected", e.target.__vnode.key);
  }
};

通过对class属性进行检测如果有disabled就不执行emit,最后可以达到想要的效果

4、编写Select组件的相关问题

问题描述:自己造Select组件所遇到的问题及其对应的解决方案

①、Select组件的实现思路

初次造轮子简单的参考了一下elementUISelect组件,采用如下图的方式: image.png
图中展示的是SelectDemo组件的代码,因此爷组件是SelectDemo,子组件是Select,孙组件是Options Select组件的布局最初采用如下图的布局方式 image.png
input是选择框,span表示反转箭头,Options则是下拉列表,一开始没有思考好整体的逻辑布局可能会对后续的编写造成困难,由于OptionsSelect组件的内部,可以采用slot来接收,但是这样的方式不太好,也不太方便进行数据的双向传递

②、Select组件的数据传递问题

在问题(1)中的插图可以看到OptionsSelect组件中,可是我如何获取到其中的爷组件中写到Options上的数据呢?由于OptionsSelect中,因此在Select组件中console.log(context.slots)可以看到如下图的内容,
image.png
看到default是一个函数(旁边有一个f),因此可以再次console.log(context.slots.default())

image.png
可以看到context.slots.default()是一个数组且children属性中有我们想要的数据,因此我们通过循环将想要的数据去除放到声明的FilterData数组中,再通过Options声明的propsOptionsData将数据传递到Options组件中

③、Select组件反转箭头问题

箭头采用绝对定位,有一个正方形然后保留相邻两边,之后采用旋转的方式来实现向下与向上,爷组件通过v-model:value="bool"的方式实现与Select组件的数据传递和显隐,因此我们在反转箭头上采用添加类的方式来实现反转,当valuetrue时,添加isActive

 <span class="Jv-ui-arrow-reverse" :class="{ isActive: value }"></span>
 //css采用如下写法
 .Jv-ui-arrow-reverse {
    transform: rotate(-135deg);
    transition: transform 200ms;
 }
 //当value为true时触发以下样式,实现反转
 .isActive.Jv-ui-arrow-reverse {
  transform: rotate(45deg);
  transition: transform 200ms;
}
④、Select组件中input输入框blur事件比click先执行的问题

Options组件中定义一个propsvisible用于接收由爷组件传递到父组件selectvalue值一次来实现,Options的显示和隐藏,通过绑定clickshowOptionsblurhideOptions来实现点击input的显隐和input为聚焦的显隐代码如下

let showOptions = () => {
  context.emit("update:value", !props.value);
};
let hideOptions = () => {
  context.emit("update:value", false);
};

当显示出来下拉框后我们需要实现选择下拉框中的元素,放到input框中这时通过采用log打印输出的方式可以发现我们点击下拉框中的元素采用click事件,比blur后执行,也就是说,下拉框已经隐藏了我们根本没有点击到,由此可以发现input框的事件执行顺序mousedown > click > blur,因此将Options组件中的点击选中内容的事件改为mousedown,就可以确定所点击的内容,之后再触发blur隐藏下拉框

⑤、Select组件中点击下拉框内容将其更新到input框中

我们点击了具体的下拉框的内容时,我们需要将点击的内容emit到父组件Select中,点击事件中的e.target.innerText可以记录我们所点击的内容,因此可以在Options组件中采取如下方式:

const emit = defineEmits(["emitData"]);
let emitItem ;
let choose = (e) => {
    emitItem.value = e.target.innerText;
    context.emit("emitData", emitItem.value);
};

自定义一个emit事件名称,声明一个响应式变量来记录变化的值,最后将变化的值再emit给父组件,父组件Select,父组件内容如下:

<options
  :optionData="FilterData"
  :visible="value"
  @emitData="changePlaceholder"
  :left="resultX"
  :top="resultY"
></options>

//js逻辑
let selected = ref(null);
//用于接受子组件传递过来的值
let changePlaceholder = (data) => {
  selected.value = data;
  return selected.value;
};
//计算属性来确定用户选的内容,没有就采用数组默认第一个
let selectItem = computed(() => {
  return selected.value ? selected.value : FilterData[0].label;
});
//最后将selectItem绑定到input的placeholder属性上,以下是最终版本代码
 <div class="whole">
    <input
      type="text"
      readonly="true"
      autocomplete="off"
      class="Jv-ui-form-control"
      v-bind="$attrs"
      @click="showOptions"
      @blur="hideOptions"
      :placeholder="selectItem"
    />
    <span class="Jv-ui-arrow-reverse" :class="{ isActive: value }"></span>
    <options
      :optionData="FilterData"
      :visible="value"
      @emitData="changePlaceholder"
    ></options>
  </div>
⑥、Select组件中有多个select组件时,下拉框位置没有动态变化

以下是options组件的内容

<template>
  <template v-if="visible">
    <teleport to="body">
      <div
        class="Jv-ui-container"
        :style="{ left: left + 11 + 'px', top: top + 'px' }"
      >
        <div class="Jv-ui-arrow"></div>
        <div class="Jv-ui-select-dropdown">
          <ul class="Jv-ui-options">
            <li
              class="Jv-ui-select-item"
              v-for="item in optionData"
              :key="item.key"
              @mousedown="choose"
              :class="{
                select: emitItem == item.label ? true : false,
                disabled: item.disabled,
              }"
            >
              {{ item.label }}
            </li>
          </ul>
        </div>
      </div>
    </teleport>
  </template>
</template>

我们可以看到Jv-ui-container的类会被传送到body下面,因此对于下拉框得采用绝对定位,并且其用于定位的lefttop都得是动态获取,所以声明两个props分别是lefttopimage.png 通过给showOptions传入一个e可以获得点击事件的相关内容,其中就包含了clientXoffsetXscreenX等内容参考下图,假设矩形框就是input框,下拉框要在其下方显示,因此left = clientX-offsetX ,top = input的高 - offsetY + clientY

image.png

⑦、Select组件点击下拉框中的元素,被选中的元素需要改变字体的颜色和粗细,且二次打开保存其原有状态

效果图如下:

image.png
目前已选择了一个内容,打开之后内容如下

image.png
一开始我通过e.target的方式来确定了自己点击的对象内容,之后对其进行字体颜色改变和加粗,再采用排他思想,判断其他未点击的元素是否有行内样式,且样式未改变字体颜色跟粗细,可以达到每次点击只有一个元素表示选中但是再二次打开时无选中状态,没有记录上选中状态,一度陷入困局。
调整思路后采取给点击的元素添加类的方案,

//将记录选中元素的变量修改为响应式
let emitItem = ref(props.optionData[0].label);
//以下是html部分
<ul class="Jv-ui-options">
    <li
      class="Jv-ui-select-item"
      v-for="item in optionData"
      :key="item.key"
      @mousedown="choose"
      :class="{
        select: emitItem == item.label ? true : false,
        disabled: item.disabled,
      }"
    >
      {{ item.label }}
    </li>
</ul>

初次渲染,由于emitItem默认是第一个元素的内容,在v-for渲染时回去做三元判断,最后成功将第一个元素标记为选中状态,之后由于触发点击choose,改变了emitItem的内容再次触发select类的判断

⑧、Select组件点击input弹出下拉框,滑动滚轮选择后边的元素,且二次打开保存其原有状态,和滚动位置
<ul class="Jv-ui-options" @scroll="ListScroll" id="ulDom">
    <li
      class="Jv-ui-select-item"
      v-for="item in optionData"
      :key="item.key"
      @mousedown="choose($event)"
      :class="{
        select: emitItem == item.label ? true : false,
        disabled: item.disabled,
      }"
    >
      {{ item.label }}
    </li>
</ul>

由于下拉框弹出后,滚动的内容是ul和里面的li元素,所以在ul上监听滚动事件:scroll,同时给ul声明id,方便做dom操作,这里采用直接操作dom来对scrollTop进行修改,申明一个响应式变量,来记录scrollTop的变化,最后在更新的生命周期里对ulscrollTop进行赋值

let ListScroll = (e) => {
  //记录最后滚动高
  moveTop.value = e.target.scrollTop;
};
 onUpdated(() => {
  if (props.visible) {
    //显示下拉框时设置scrollTop的数值
    let dom = document.querySelector("#ulDom");
    dom.scrollTop = moveTop.value;
  } else {
    return;
  }
});
5、编写Notification组件的相关问题
①、Notification组件的实现思路

初次造轮子简单的参考了一下elementUINotification组件,由于本人比较懒,有些功能就没有做,或者做的比较简略。点击按钮,调用方法弹出弹窗,因此我需要创建一个ts文件用于书写方法,首先分析一下我们要做的功能

  1. 可以传递titlemessage
  2. 可以设置自动关闭时间,duration为 0 则不关闭
  3. 可以设置position位置,左上角:top-left,左下角:bottom-left,右上角:top-right,右下角:bottom-right
<template>
  <template v-if="visible">
    <teleport to="body">
      <div
        role="alert"
        class="Jv-ui-Notification"
        style="top:16px"
        v-bind="$attrs"
      >
        <div class="Jv-ui-Notification-group">
          <slot name="title">
            <h2 class="Jv-ui-Notification-title">提示</h2>
          </slot>
          <slot name="message">
            <div class="Jv-ui-Notification-content">
              <p>Notification</p>
            </div>
          </slot>
          <div class="Jv-ui-Notification-close" @click="Close"></div>
        </div>
      </div>
    </teleport>
  </template>
</template>

以上代码是Notification组件的完整代码,由于我们是弹窗为了避免因为z-index出现上下的重叠,所以采用teleport将其传送到body
以下代码是openNotification.ts文件的代码

import { createApp, h } from "vue";
import Notification from "../lib/Notification.vue"

export const openMessage = (options) => {
    const { title, message, duration, position } = options;
    const div = document.createElement('body');
    let Left; 
    let Right;
    let Top;
    let Bottom;
    const close = () => {
        app.unmount();
    }

    function calClass(position) {
        if (!!position) {//传了值
            if (position == 'top-left' ||position == 'bottom-left') {
                return true
            } else if (position == 'top-right' || position == 'bottom-right') {
                return false;
            }
        } else {
            return false
        } 
    }
    function calStyle(position) {
        if (!!position) {//传了值
            if (position == 'bottom-left' ||position ==  'bottom-right') {
                return true;
            } else if (position == 'top-left' || position == 'top-right') {
                return false;
            }
        } else {
            return false;
        }
    }
    calClass(position) ? Left = true : Right = true;
    calStyle(position) ? Bottom = true : Top = true;
    duration == 0 ? {}:setTimeout(close, duration?duration:3000);  
    
    const app = createApp({
        render() {
            return h(
                Notification,
                {
                    visible: true,
                    "onUpdate:value"(newValue) {
                        if (newValue === false) {
                            close();
                        }
                    },
                    class: { left: Left, right: Right },
                    style: {
                        top: Top? 16+'px':{},
                        bottom: Bottom?16+'px':{},
                    }
                },
                {
                    title: () => { return title },
                    message: () => { return message }
                }
            )
        }
    })
    app.mount(div)
}

由于我们会传入position来表示位置,因此会改变类和样式,因此声明两个函数分别为calClass、calStyle根据传入的positiontop-left、top-right、bottom-left、bottom-right,来确定我们需要渲染什么样的类和样式,在这个过程中出现了,我传入position的数值但是,在控制台却无法对类和样式进行设置 image.png

image.png 本应该出现在左下方的弹窗结果出现在了左上方,后来排查才得知,因为我们调用函数设置的classstyle都属于是非props所以需要在Notification组件中在需要添加类和样式的地方加上v-bind="$attrs"
在以上代码中可能会对感到奇怪

document.creatElement('body')

以前的写法是

const div1 = document.createElement("div");
document.body.appendChild(div1);

然后修改了teleport传送的地点为div1,但是会发现,每次点击调用函数都会创建div1,但是传送地点只出现在第一个div,后面的内容都是空,当然我们可以采用teleport to=div${num++}发现好像行不通,因此没有办法根据次数生成一个唯一的div并且让teleport也是动态变化传送地点,并且发现点击弹窗的右上方关闭icon,在控制台中查看元素,发现生成了很多没用的dom,因此该方法不妥,而且还发现一个隐藏bug,先点击一个设置了自动关闭的时间的按钮(比如两秒),在点击一个不会自动关闭的按钮,时间到了以后,不会自动关闭的弹窗居然被关闭了,其实就是因为openMessage,第一次被调用开启了setTimeout,然后时间一到就清除,两次点击都是调用的同样的openMessage不同之处在于是否设置了duration,最后发现采用创建body标签的方式即保证了没有多生成dom又保证了每个弹窗的唯一性。

(6)项目总体设置

①、编写插件支持import markdown文件

由于本人不想在官网的介绍、安装、快速上手的几个模块用html的方式编写,觉得太麻烦,也不太符合我们日常编辑描述性文字的习惯,因而想找个替代方案,发现已经有不少的解决方案了选择了一种我有点儿兴趣的。 在项目中新建一个plugin 文件夹再创建一个md.ts的文件,内容如下: 请先安装marked模块

// @ts-nocheck
import path from 'path'
import fs from 'fs'
import { marked } from 'marked'

const mdToJs = str => {
  const content = JSON.stringify(marked(str))
  return `export default ${content}`
}

export function md() {
  return {
    configureServer: [ // 用于开发
      async ({ app }) => {
        app.use(async (ctx, next) => { 
          if (ctx.path.endsWith('.md')) {
            ctx.type = 'js'
            const filePath = path.join(process.cwd(), ctx.path)
            ctx.body = mdToJs(fs.readFileSync(filePath).toString())
          } else {
            await next()
          }
        })
      },
    ],
    transforms: [{  // 用于 rollup // 插件
      test: context => context.path.endsWith('.md'),
      transform: ({ code }) => mdToJs(code) 
    }]
  }
} 

这个ts文件大致意思就是说,如果文件以.md结尾将其改为js文件,因为浏览器只认识js,由于我们是使用的vite,因此也需要配置vite.config.ts


// @ts-nocheck

import { md } from "./plugins/md";
import fs from 'fs'
import {baseParse} from '@vue/compiler-core'

export default {
  plugins: [md()],
};

然后自己创建以.md结尾的markdown文件,编写自己想要的内容

②、部署出现404

yarn build 之后生成dist文件,将其部署到GitHub Pages,出现404
最后发现在于vite生成的文件有下划线,而github发现文件有下划线的时候把路径进行了修改,所以在vite.config.ts中添加如下代码

base:'./',
assetsDir: 'assets',

确保打包的时候路径以及文件名已经修改成了合格的。

(7)结语

以上就是本人初次尝试使用vue3造轮子的时候遇到的一些比较棘手的问题总结,记录自己的心得以便于自己成长,后续有空或许会继续造新的组件,并且对原来的组件进行重构,以此进一步提升自己的技术和代码抽象能力。