在11月初的时候,我给自己定的目标:了解完 Vue3
,然后做一个小项目
其中,Vue3
是早就学完了的,然后也写了两篇总结或是心得吧,其中有很多都是在做项目中踩出来的坑,所以大家可以看一下,避免之后开发中遇到:
- 快速使用Vue3最新的15个常用API(400 个????)
- 关于Vue3获取当前组件实例的 getCurrentInstance 方法的补充(30 个????)
然后做的 Vue3
项目也是我自己构思出来,因为当时网上的项目也不多或是大部分都是商城项目,之前也写过很多类似的了,所以就还是打算自己写一个,我给它取名叫做 nav-url
,顾名思义就是一个网址导航栏,在我写这篇文章时,项目是已经上线并被我自己以及身边的小伙伴使用了的,下面放上预览链接 ????????
点击即可预览 ???? 项目预览链接
再放上项目源码地址 ????:项目源码链接(欢迎各位 star)
接下来就详细地介绍一下我的项目
设计初衷
我现在也是个非计算机专业的大四在校生,平时前端都是自学的,所以从初学到现在基本上都是通过白嫖网上的视频、买书或从图书馆借书看、逛技术博客长长见识等等。这期间我会看到很多实用的工具网站或一些有趣的网站,我都会把他们收藏下来,生怕之后找不到了,但是随着时间的推移,收藏的网站越来越多,我的浏览器收藏夹可能变成了这样
这些都是我很久之前收藏夹收藏的,要是按照这个势头,我的收藏夹不出半年就爆满了,到时候找网站都不方便,所以我就想做一个我自己的网站导航栏,要求不高 : 简单大方、方便快捷
于是就有了现在这个项目,如下图所示:
项目功能 && 特色
毕竟是个网址导航栏,所以功能非常的简单,但之后我会尽可能地去完善该项目的一些额外的功能
项目的功能:
✅ 标签的添加、修改、删除
✅ 网址的添加、修改、删除
✅ 搜索功能
✅ 配置的导入、导出
项目的特色:
⭐ 基于 Vue3
开发
⭐ 页面简单大方
⭐ 提供网站图标、名称的获取接口
⭐ 标签栏支持多种 icon
选择
⭐ 通过 localStorage
存储,无需配置数据库
⭐ 用 Vue3
封装了 Element UI
的 message
、dialog
、button
、input
、popover
组件
⭐ 通过 Vuex 4
进行状态管理
⭐ 页面的滚动动画
⭐ 支持一键保存导出数据、一键导入数据
项目文件结构
整个项目主要的文件都在 src
文件夹下,结构目录如下:
├── src
├── assets // 存放静态资源
├── components // 各种组件
│ ├── main // 页面主要内容相关组件
│ ├── tabs // 标签栏相关组件
│ └── public // 全局公共组件
├── network // 网络请求
├── store // Vuex
├── utils // 存放自己封装的工具
├── APP.vue
└── main.jsss
重点介绍
对于项目的逻辑代码,你们可以直接查看我的源码,全部都是用的 Vue3
语法写的
在最初做这个项目时,还没找到合适的 Vue3
组件库,所以我就根据自己的需求,封装了 message
、dialog
、input
、button
、popover
这样五个组件,其中重点讲一下 message
和 dialog
吧,另外还有这个项目的亮点:配置导入与导出
Dilog组件
首先是组件内容:
代码语言:javascript复制// lp-dialog.vue
<template>
<div class="lp-confirm-container" ref="lpConfirmAlert">
<div class="lp-confirm-box">
<div class="lp-confirm-title">
<span class="lp-confirm-title-txt">{{ title }}span>
<span class="lp-confirm-title-close" @click="closeConfirm">✖span>
div>
<div class="lp-confirm-content">
<span class="lp-confirm-content-txt">{{ content }}span>
div>
<div class="lp-confirm-btn-groups">
<lp-button type="primary" class="lp-confirm-btn" @_click="sureConfirm">确定lp-button>
<lp-button type="default" class="lp-confirm-btn lp-confirm-btn-cancel" @_click="closeConfirm">取消lp-button>
div>
div>
div>
template>
<script>
import lpButton from '../lp-button/lp-button'
import {ref} from 'vue'
export default {
components: {
lpButton
},
props: {
title: {
type: String,
default: '提示'
},
content: {
type: String,
default: '确定关闭吗?'
}
},
setup() {
const status = ref(-1) // 存储用户点的状态,-1:未点击;0:取消;1:确定
const lpConfirmAlert = ref(null)
function removeElement() {
lpConfirmAlert.value.parentNode.removeChild(lpConfirmAlert.value)
}
function closeConfirm() {
status.value = 0
removeElement()
}
function sureConfirm() {
status.value = 1
removeElement()
}
return {removeElement, closeConfirm, sureConfirm, status, lpConfirmAlert}
}
}
script>
<style scoped>
/* 样式见源码,此处省略 */
style>
这里我在 dialog
组件内设定了一个组件的状态变量 status
,用于确认用户的点击情况
再来看看组件的处理代码:
代码语言:javascript复制// lp-dialog.js
import lp_dialog from './lp-dialog.vue'
import {defineComponent, createVNode, render, toRef, watch} from 'vue'
const confirmConstructor = defineComponent(lp_dialog)
export const createDialog = (options) => {
if(!Object.prototype.toString.call(options) === '[Object Object]') {
console.error('Please enter an object as a parameter');
}
options = options ? options : {}
// 生成组件实例
const instance = createVNode(
confirmConstructor,
options
)
// 渲染挂载组件
const container = document.createElement('div')
render(instance, container)
document.querySelector('#app').appendChild(instance.el)
// 初始化组件参数
const props = instance.component.props
Object.keys(options).forEach(key => {
props[key] = options[key]
})
// 获取组件的 status 状态变量
const status = toRef(instance.component.setupState, 'status')
// 返回 promise,方便外部调用
return new Promise((resolve, reject) => {
// 监听组件的按钮点击情况
watch(status, (now) => {
if(now == 0) reject();
else if(now == 1) resolve()
})
})
}
接下来把 dialog
作为一个方法注册到全局中,这个我就把它放在了 App.vue
文件中,通过 Vue3
的 provide
方法暴露在全局
<template>
<div id="app">div>
template>
<script>
import { provide } from 'vue'
import createDialog from './components/public/lp-dialog/lp-dialog.js'
export default {
setup() {
// 全局暴露创建 dialog 组件的方法
provide('confirm', createDialog)
}
}
script>
然后在别的组件中使用 dialog
组件
<template>
<div class="tabs" @click="btnConfirm">div>
template>
<script>
import { inject } from 'vue'
export default {
setup() {
// 接收创建 dialog 组件的方法
let $confirm = inject('confirm')
btnConfirm() {
// 调用方法
$confirm({
title: '提示', // 确认框的标题
content: '确认关闭吗?', // 消息内容
})
.then(() => {
console.log('确认')
})
.catch(() => {
console.log('取消')
})
}
return { btnConfirm }
}
}
script>
这样就实现了一个基于 promise
的链式调用,可以设定用户点击了 确认 或 取消 之后的处理代码
Message组件
首先是组件内容:
代码语言:javascript复制// lp-message.vue
<template>
<div class="message_container"
:class="[
{'show': isShow},
{'hide': !isShow},
{'enter': isEnter},
{'leave': isLeave},
type
]"
:style="{
'top': `${seed * 70}px`
}">
<div class="content">
<i :class="[
`lp-message-${type}`,
'icon',
'fa',
{'fa-info-circle': type == 'info'},
{'fa-check-circle': type == 'success'},
{'fa-times-circle': type == 'err'},
{'fa-exclamation-triangle': type == 'warning'},
]"/>
<div class="txt"
:class="[`txt_${type}`]">
{{content}}
div>
div>
div>
template>
<script>
export default {
name: "lp-message",
props: {
type: {
type: String,
default: 'info'
},
lastTime: {
type: Number,
default: 2500
},
content: {
type: String,
default: '这是一条提示信息'
},
isShow: {
type: Boolean,
default: false
},
isLeave: {
type: Boolean,
default: false
},
isEnter: {
type: Boolean,
default: false
},
seed: {
type: Number,
default: 0
}
}
}
script>
<style scoped>
/* 样式见源码,此处省略 */
style>
然后是组件的处理代码:
代码语言:javascript复制// lp-message.js
import lp_message from "./lp-message.vue"
import { defineComponent, createVNode, render } from 'vue'
let MessageConstructor = defineComponent(lp_message)
let instance;
const instances = []
export const createMessage = (options) => {
if(!Object.prototype.toString.call(options) === '[object Object]') {
console.error('Please enter an object as a parameter')
}
options = options ? options : {}
instance = createVNode(
MessageConstructor,
options
)
//挂载
const container = document.createElement('div')
render(instance, container)
document.querySelector('#app').appendChild(instance.el)
const cpn = instance.component
const el = instance.el
const props = cpn.props
props.seed = instances.length
// 初始化参数
Object.keys(options).forEach(key => {
props[key] = options[key]
})
// 加入到instances中管理
instances.push(instance)
// 消息框出现
setTimeout(() => {
props.isShow = true
props.isEnter = true
}, 200)
// 消息框离开
setTimeout(() => {
props.isEnter = false
props.isShow = false
props.isLeave = true
}, props.lastTime)
// 移除消息框
setTimeout(() => {
close(el)
}, props.lastTime 200)
}
// 关闭某个弹框
const close = (el) => {
instances.shift()
instances.forEach((v) => {
v.component.props.seed -= 1
})
document.querySelector('#app').removeChild(el)
}
这里模仿了 element-ui
的思想,把所有的 message
实力管理在一个数组中
然后我们要把其作为一个方法注册到全局中,这个我就把它放在了 App.vue
文件中,通过 Vue3
的 provide
方法暴露在全局
<template>
<div id="app">div>
template>
<script>
import { provide } from 'vue'
import createMessage from './components/public/lp-message/lp-message.js'
export default {
setup() {
// 全局暴露创建 message 组件的方法
provide('message', createMessage)
}
}
script>
使用 message
组件,通过 inject
方法获取即可
<template>
<div class="main">div>
template>
<script>
import { inject } from 'vue'
export default {
setup() {
// 接收创建 message 组件的方法
let $message = inject('message')
// 调用方法
$message({
type: 'success', // 消息框的类型,可选:info | success | err | warning
content: '这是一条成功的消息', // 消息内容
lastTime: 5000 // 消息框持续的时间
})
}
}
script>
Popover组件
这个组件我没有模仿 element-ui
,因为我不太喜欢它的那种调用方式,所以我就根据自己的奇思妙想设计了一下这个组件:既然这个组件是一个气泡框,那么必然需要一个元素来确定这个气泡框的出现位置,因此我想把这个组件做成通过自定义指令 v-popover
来调用
接下来看下我的设计过程哈
首先是组件的内容:
代码语言:javascript复制// lp-popover.vue
<template>
<div ref="popover"
:class="['lp-popover-container', position]"
:style="{
'top': `${top}px`,
'left': `${left}px`,
}">
<div class="container-proxy">
<div class="lp-popover-title" v-html="title">div>
<div class="lp-popover-content" v-html="content">div>
div>
div>
template>
<script>
import {ref, onMounted, reactive, toRefs} from 'vue'
export default {
props: {
title: {
type: String,
default: '我是标题'
},
content: {
type: String,
default: '我是一段内容'
},
position: { // 出现的位置, top | bottom | left | right
type: String,
default: 'bottom'
},
type: { // 触发方式, hover | click
type: String,
default: 'hover'
}
},
setup({ position, type }) {
const popover = ref(null)
const site = reactive({
top: 0,
left: 0,
})
onMounted(() => {
const el = popover.value
let { top, left, height: pHeight, widht: pWidth } = el.parentNode.getBoundingClientRect() // 获取目标元素的页面位置信息与尺寸大小
let { height: cHeight, width: cWidth } = el.getBoundingClientRect() // 获取气泡框的宽高
// 设置气泡框的位置
switch(position) {
case 'top':
site['left'] = left
site['top'] = top - cHeight - 25
break;
case 'bottom':
site['left'] = left
site['top'] = top pHeight 25
break;
case 'left':
site['top'] = top
site['left'] = left - cWidth - 25
break;
case 'right':
site['top'] = top
site['left'] = left pWidth 25
break;
}
// 为气泡框设置触发方式
switch(type) {
case 'hover':
el.parentNode.addEventListener('mouseover', function() {
el.style.visibility = 'visible'
el.style.opacity = '1'
})
el.parentNode.addEventListener('mouseout', function() {
el.style.visibility = 'hidden'
el.style.opacity = '0'
})
break;
case 'click':
el.parentNode.addEventListener('click', function() {
if(el.style.visibility == 'hidden' || el.style.visibility == '') {
el.style.visibility = 'visible'
el.style.opacity = '1'
} else {
el.style.visibility = 'hidden'
el.style.opacity = '2'
}
})
break;
}
})
return {
...toRefs(site),
popover
}
}
}
script>
<style scoped>
/* 组件样式省略,详情见源码 */
style>
主要思路就是根据 position
定位好气泡框相对于其父元素的位置,支持的位置一共有四种,即 top | bottom | left | right
,同时根据 type
处理触发展示气泡框的方法,一共有两种触发方式,即 hover | click
然后再来看一下自定义指令是如何写的
代码语言:javascript复制// lp-popover.js
import lpPopover from './lp-popover.vue'
import {defineComponent, createVNode, render, toRaw} from 'vue'
// 定义组件
const popoverConstructor = defineComponent(lpPopover)
export default function createPopover(app) {
// 在全局上注册自定义指令v-popover
app.directive('popover', {
// 在元素挂载后调用
mounted (el, binding) {
// 获取外界传入的指令的值,例如v-popover="data",value获取的就是data对应的值
let { value } = binding
let options = toRaw(value)
// 判断传入的参数是否为对象
if(!Object.prototype.toString.call(options) === '[Object Object]') {
console.error('Please enter an object as a parameter');
}
options = options ? options : {}
const popoverInstance = createVNode(
popoverConstructor,
options
)
const container = document.createElement('div')
render(popoverInstance, container)
el.appendChild(popoverInstance.el)
const props = popoverInstance.component.props
// 通过我们传入的参数对组件进行数据的初始化
Object.keys(options).forEach(v => {
props[v] = options[v]
})
}
})
}
然后我们再在 main.js
文件中注册一下自定义指令
import { createApp } from 'vue';
import App from './App.vue'
import vuex from './store/index'
import vPopover from './components/public/lp-popover/lp-popover'
const app = createApp(App)
// 注册自定义指令 v-popver
vPopover(app)
app.use(vuex).mount('#app')
再来看一下使用方式
代码语言:javascript复制<template>
<div id="app" v-popover="infos">
div>
template>
<script>
import { reactive } from 'vue'
export default {
setup() {
const infos = reactive({
title: '提醒',
content: '这是一条提醒内容',
position: 'left',
type: 'click'
})
return { infos }
}
}
script>
<style scoped>
style>
这样就简单地实现了气泡框组件的调用,当然其中 content
也是支持 html
的
但总的来说,这个组件的性能可能没 element-ui
好,因为我是直接对DOM进行了操作,也许后期还需要进行改善
SaveConfig
在介绍配置的导出与导入之前, 我先来介绍一下这个项目的数据存储
我秉承着一种能不用到服务器就不用服务器,能不用数据库就不用数据库的原则,想到了 localStorage
可以作为一个本地的数据库使用,每次换浏览器或设备时,只需要将 localStorage
里的数据再导入一次就好啦,因此我把这个数据称为配置(Config)
首先我们得拥有配置,所以需要有一个把 localStorage
里数据一键导出保存为一个文件的功能
该功能我是参考的 MDN 文档,你们有兴趣可以了解一下:Web API——URL.createObjectURL()
我大致是这样实现的:
代码语言:javascript复制// 封装的下载数据函数
function downLoadFile(fileName, content) {
var aTag = document.createElement('a'); // 获取 a 元素
var blob = new Blob([content]); // 将数据保存在 blob 对象中
aTag.download = fileName; // 设置保存的文件名称
aTag.href = URL.createObjectURL(blob); // 将数据保存在 href 属性中
aTag.click(); // 模拟点击 a 元素,进行下载
URL.revokeObjectURL(blob); // 删除内存中的 blob 对象的数据
}
// 调用下载接口
function saveConfig() {
downLoadFile('nav.config.json', window.localStorage.navInfos)
}
试着点击一下看看效果 ????:
ImportConfig
既然已经手握配置文件,那么走到哪里都不怕了~ 接下来要做的就是将配置文件导入 localStorage
中
该方法是参考了 MDN 文档了的,大家可以前去了解一下: Web API——FilerReader
我大致是这样实现的:
代码语言:javascript复制// 导入配置
function importConfig() {
let reader = new FileReader() // 创建 FileReader 对象
let files = document.querySelector('.file').value.files // 获取已上传的文件信息
reader.readAsText(files[0]) // 读取文件内容
reader.onload = function() { // 读取操作完成的处理函数
let data = this.result // 获取文件读取结果
window.localStorage.navInfos = data // 将文件数据存入 localStorage
location.reload() // 刷新页面
}
}
然后我们把刚才导出保存的 json
配置文件重新导入看看效果:
哈哈哈,这样就成功导入文件啦~ ✔
Scroll Animation
因为我们所有的 URL
都是在一个页面内的,并且搭配着侧边栏中的按钮进行标签的跳转,即在左侧点哪个标签,右侧的内容就跳到哪个标签。刚开始我是用锚点实现的,但后来发现这样的跳转太生硬了,所以就自己简单地实现了一下跳转动画
实现原理大概是这样:右侧内容中每个标签都有带有一个 id
,并且左侧的每个按钮也是对应着各自的 id
的,所以当点击了按钮时,先获取到对应 id
的元素 el
,并获取 el
离滚动页面顶部的距离,即 el.scrollTop
,然后同时获取一下当前位置离滚动页面离顶部的距离,如下图所示:
那么我们的跳转距离就是图中的 Location - Current
我大致是这样实现的:
代码语言:javascript复制// 跳转到指定标签
function toID(id) {
const content = document.getElementById('content') // 获取滚动页面元素
const el = document.getElementById(`${id}`) // 获取对应id的标签元素
let start = content.scrollTop // 获取当前页面离顶部的距离
let end = el.offsetTop - 80 // 获取目标元素离顶部的距离(这里的80是减去了我顶部消息栏的高度,大家可以不用管)
let each = start > end ? -1 * Math.abs(start - end) / 20 : Math.abs(start - end) / 20 // 考虑滚动方向并计算总共需要滚动的距离,同时将距离平分成20份
let count = 0 // 记录滚动次数
let timer = setInterval(() => { // 设置定时器,滚动20次
if(count < 20) {
content.scrollTop = each
count
} else {
clearInterval(timer)
}
}, 10)
}
我们来看看滚动的效果如何吧~
我感觉滚动还是挺丝滑的 ???? 如果大家有更简单方便、性能更好的方法可以推荐给我
Get Icons Interface
我前面一直说,本着能不用服务器就不用服务器,能不用数据库就不用数据库的原则,但是自动获取页面图标这个功能真的没有办法了,要在浏览器端访问别人的网页还要得到 icon URL
,几乎是不可能的,因为存在跨域问题,所以我就拿自己的服务器暴露了个接口出来用于获取目标网页的 icon
地址
代码这里我就不放上了,因为也比较简单,就是访问目标网页,得到 html
文档内容,从中筛选出 icon
的地址再返回就好了,要看代码的可以在 项目源码 中的 app.js
中去查看
这里还要强调的是,虽然我提供了一个接口用于自动获取对方网页的图标,但是有些网页对外部来路不明的请求都做了处理,例如返回一个 403 Forbiden
把我的请求给拒绝了,因此一些无法获得的图标或者无法加载的图标,我都是用一个默认图标统一替代,虽然之前我做过挺久的爬虫,想办法对 user-agent
、referer
等请求头都做了处理了,但还是无济于事,大家如果有好的办法也可以提供给我尝试
然后给大家简单演示一下如何使用的吧~
这个动图上好像有些模糊或者是样式的变动,都是因为 gif录制器的原因哈
其它
对于这个项目,因为刚出来半个月不到嘛,肯定还有需要改进的地方,我也已经列出了之后需要继续跟进的新功能:
URL
的拖拽、排列- 页面账号信息存储功能
- 提供更多的网址
icon
的选择 - more ……
第一个功能什么意思呢,就是我现在的项目中是不支持添加好后的 URL
重新排序的,但我觉得这个功能是一定要有的,之后会加上,打算想办法做一个在编辑状态下拖拽即可完成排列的功能
第二个功能的目的是因为对于很多个网站,你也许会有不同的账号和密码,但现在最令人头疼的就是,总是记不住这个网站我的账号或密码是啥,导致每次都要多次尝试或找回密码,特别的麻烦;所以我想做一个鼠标移到对应网址上,有一个查看此网址对应我的账号密码的功能
第三个功能就是为了针对那些无法获取 icon
的网站导致我们导航栏中显示的图标为默认图标,比较丑,所以到时候可以支持大家自行选择喜欢的图标
更多的功能还请大家多提建议啦~
最后
有些小伙伴问,为啥不做一个账号登录的网址导航栏,这样到哪都不用带着配置文件了,只需要记住账号密码就可以了。我又要强调本项目的选择了,能不用服务器就不用服务器,能不用数据库就不用数据库,用你自己的本地的 localStorage
作为数据库存储,你不是更放心嘛,比如你收藏了一些奇奇怪怪的网站,反正就只有你知道,我反正肯定是不知道的 ???? 而且细心的小伙伴有没有发现,我连静态页面都不是用的自己的服务器,直接部署在码云上的
自学前端这么久了,之前一直做着别人的项目或是模仿一些网站做一个项目,细数一下有这么几个:淘宝首页静态页面、蘑菇街移动端APP、node社区、elementUi组件以及组件文档展示等等,这次这个项目也算属于我自己的了,而且对于我来说是非常实用的一个小工具了,希望大家多多支持~ 给我提提意见,可以的话点个 star ????
再放一次项目源码地址:项目源码
对于这个项目有什么疑问或是项目出现问题的小伙伴可以告知我,vx:Lpyexplore333