(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
函数里写入title
和content
将内容传入slot
插槽中,浏览器控制台警告如下:
解决方案:按照警告建议的方式修改
{titel,content} //改为如下方式:
{
title: () => { return title },
content: () => { return content },
}
下图为github上关于类似问题的解决方案
2、编写Switch
组件时,实现方式的讨论
问题描述:编写Switch
组件时,想要达成如下效果,用什么样得方式比较好?
======>点击按钮后切换
解决方案:
方案一:在setup
中写入如下代码,再将其绑定在style
属性上,原理为通过计算属性对每个组件进行条件判断,满足组件传入了props.color
并且props.value
为true
,开关处于打开状态,则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
样式中,原理为当value
为true
则Jv-ui-checked
类型生效然后通过将props
的color
传入的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
组件的实现思路
问题描述:点击对应导航栏实现下方内容给的切换,如何实现如下效果?
解决方案:
定义一个Tabs
组件和Tab
,HTML
结构包含了指示条(即下方蓝色长条)、导航栏、内容展示区域,最终Tabs
和Tab
的DOM
结果如下:
//这是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是计算属性,通过这样的方式去渲染导航下方的内容区域,通过查看元素可以发现都已经更新了,但是内容没有更新
解决方案:
方案一:通过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);
可以发现数组的第一个对象的children
属性上的default
函数是一个数组,这个数组包含一个对象,对象的内容就有我们想要的内容字符串,因此可以采用如下方式对内容进行渲染
<component v-for="(c, index) in TabFilter" :key="index" :is="c">
{{ c.children.default()[0].children }}
</component>
上述方案虽然可以解决问题,但是可能不太优美,而且在开发工具里还会出现报错
方案二:采用动态设置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值,来使它进行更新
<component v-for="c in TabFilter" :key="c.props.name" :is="c" />
对于当前自己造轮子的Tabs
组件来说,我采用如上方式,因为c.props.name
是唯一值
③、Tabs
组件点击导航栏,指示条变化
问题描述:编写Tabs
组件时,点击导航栏时,指示条改变width
和left
值,因此需要知道被点击的模块相关内容
方案:采用ref
的方式来获取实例的内容,记住必须要在setup
中return
出去否则无法建立连接,在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>
移动的本质是具体的NavItems
相对于外层container
的left
变化,可以通过getBoundingClientRect
函数获得来获取相对边界的值变化和元素大小,将变化的值赋值给具体的NavItems
,最后可以在css
中添加transition
来改善移动效果。
④、Tabs
组件设置禁止点击
问题描述:给其中一个Tab
组件添加disabled
使其禁止被点击
由于Tabs
组件用的插槽的方式接受的Tab
里面的内容,所以这使得disabled
不能通过v-bind="$attrs"
来传入到Tab
组件的内部
解决方案:
通过添加类的方式来改变样式,此时点击导航栏会发生如下效果
通知台报错表示下面的导航条指示器样式的修改逻辑有问题,其根本原因在于元素的结构是有问题的
按理来说,点击导航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
组件的实现思路
初次造轮子简单的参考了一下elementUI
的Select
组件,采用如下图的方式:
图中展示的是SelectDemo
组件的代码,因此爷组件是SelectDemo
,子组件是Select
,孙组件是Options
Select
组件的布局最初采用如下图的布局方式
input
是选择框,span
表示反转箭头,Options
则是下拉列表,一开始没有思考好整体的逻辑布局可能会对后续的编写造成困难,由于Options
在Select
组件的内部,可以采用slot
来接收,但是这样的方式不太好,也不太方便进行数据的双向传递
②、Select
组件的数据传递问题
在问题(1)中的插图可以看到Options
在Select
组件中,可是我如何获取到其中的爷组件中写到Options
上的数据呢?由于Options
在Select
中,因此在Select
组件中console.log(context.slots)
可以看到如下图的内容,
看到default是一个函数(旁边有一个f),因此可以再次console.log(context.slots.default())
可以看到context.slots.default()
是一个数组且children
属性中有我们想要的数据,因此我们通过循环将想要的数据去除放到声明的FilterData
数组中,再通过Options
声明的props
的OptionsData
将数据传递到Options
组件中
③、Select
组件反转箭头问题
箭头采用绝对定位,有一个正方形然后保留相邻两边,之后采用旋转的方式来实现向下与向上,爷组件通过v-model:value="bool"
的方式实现与Select
组件的数据传递和显隐,因此我们在反转箭头上采用添加类的方式来实现反转,当value
为true
时,添加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
组件中定义一个props
为visible
用于接收由爷组件传递到父组件select
的value
值一次来实现,Options
的显示和隐藏,通过绑定click
为showOptions
,blur
为hideOptions
来实现点击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
下面,因此对于下拉框得采用绝对定位,并且其用于定位的left
和top
都得是动态获取,所以声明两个props
分别是left
和top
。
通过给showOptions
传入一个e
可以获得点击事件的相关内容,其中就包含了clientX
、offsetX
、screenX
等内容参考下图,假设矩形框就是input
框,下拉框要在其下方显示,因此left = clientX-offsetX
,top = input的高 - offsetY + clientY
⑦、Select
组件点击下拉框中的元素,被选中的元素需要改变字体的颜色和粗细,且二次打开保存其原有状态
效果图如下:
目前已选择了一个内容,打开之后内容如下
一开始我通过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
的变化,最后在更新的生命周期里对ul
的scrollTop
进行赋值
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
组件的实现思路
初次造轮子简单的参考了一下elementUI
的Notification
组件,由于本人比较懒,有些功能就没有做,或者做的比较简略。点击按钮,调用方法弹出弹窗,因此我需要创建一个ts
文件用于书写方法,首先分析一下我们要做的功能
- 可以传递
title
和message
- 可以设置自动关闭时间,
duration
为 0 则不关闭 - 可以设置
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
根据传入的position
是top-left、top-right、bottom-left、bottom-right
,来确定我们需要渲染什么样的类和样式,在这个过程中出现了,我传入position
的数值但是,在控制台却无法对类和样式进行设置
本应该出现在左下方的弹窗结果出现在了左上方,后来排查才得知,因为我们调用函数设置的class
和style
都属于是非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造轮子的时候遇到的一些比较棘手的问题总结,记录自己的心得以便于自己成长,后续有空或许会继续造新的组件,并且对原来的组件进行重构,以此进一步提升自己的技术和代码抽象能力。