自己设计的Vue3的实用项目(内含对项目亮点的实现思路与介绍)

在11月初的时候,我给自己定的目标:了解完 Vue3,然后做一个小项目

其中,Vue3 是早就学完了的,然后也写了两篇总结或是心得吧,其中有很多都是在做项目中踩出来的坑,所以大家可以看一下,避免之后开发中遇到:

然后做的 Vue3 项目也是我自己构思出来,因为当时网上的项目也不多或是大部分都是商城项目,之前也写过很多类似的了,所以就还是打算自己写一个,我给它取名叫做 nav-url,顾名思义就是一个网址导航栏,在我写这篇文章时,项目是已经上线并被我自己以及身边的小伙伴使用了的,下面放上预览链接 👇👇

点击即可预览 👉 项目预览链接

再放上项目源码地址 👉:项目源码链接(欢迎各位 star

接下来就详细地介绍一下我的项目

设计初衷

我现在也是个非计算机专业的大四在校生,平时前端都是自学的,所以从初学到现在基本上都是通过白嫖网上的视频、买书或从图书馆借书看、逛技术博客长长见识等等。这期间我会看到很多实用的工具网站或一些有趣的网站,我都会把他们收藏下来,生怕之后找不到了,但是随着时间的推移,收藏的网站越来越多,我的浏览器收藏夹可能变成了这样

这些都是我很久之前收藏夹收藏的,要是按照这个势头,我的收藏夹不出半年就爆满了,到时候找网站都不方便,所以我就想做一个我自己的网站导航栏,要求不高 : 简单大方方便快捷

于是就有了现在这个项目,如下图所示:

项目功能 && 特色

毕竟是个网址导航栏,所以功能非常的简单,但之后我会尽可能地去完善该项目的一些额外的功能

项目的功能:

✅ 标签的添加、修改、删除

✅ 网址的添加、修改、删除

✅ 搜索功能

✅ 配置的导入、导出

项目的特色:

⭐ 基于 Vue3 开发

⭐ 页面简单大方

⭐ 提供网站图标名称的获取接口

⭐ 标签栏支持多种 icon 选择

⭐ 通过 localStorage 存储,无需配置数据库

⭐ 用 Vue3 封装了 Element UImessagedialogbuttoninputpopover 组件

⭐ 通过 Vuex 4 进行状态管理

⭐ 页面的滚动动画

⭐ 支持一键保存导出数据一键导入数据

项目文件结构

整个项目主要的文件都在 src 文件夹下,结构目录如下:

├── src 
     ├── assets      // 存放静态资源
     ├── components  // 各种组件
     │   ├── main    // 页面主要内容相关组件
     │   ├── tabs    // 标签栏相关组件
     │   └── public  // 全局公共组件
     ├── network     // 网络请求
     ├── store       // Vuex
     ├── utils       // 存放自己封装的工具
     ├── APP.vue
     └── main.jsss

重点介绍

对于项目的逻辑代码,你们可以直接查看我的源码,全部都是用的 Vue3 语法写的

在最初做这个项目时,还没找到合适的 Vue3 组件库,所以我就根据自己的需求,封装了 messagedialoginputbuttonpopover 这样五个组件,其中重点讲一下 messagedialog 吧,另外还有这个项目的亮点:配置导入与导出

Dilog组件

首先是组件内容:

// 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">&#10006;</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,用于确认用户的点击情况

再来看看组件的处理代码:

// 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 文件中,通过 Vue3provide 方法暴露在全局

<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组件

首先是组件内容:

// 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>

然后是组件的处理代码:

// 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 文件中,通过 Vue3provide 方法暴露在全局

<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 来调用

接下来看下我的设计过程哈

首先是组件的内容:

// 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

然后再来看一下自定义指令是如何写的

// 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')

再来看一下使用方式

<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()

我大致是这样实现的:

// 封装的下载数据函数
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

我大致是这样实现的:

// 导入配置
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

我大致是这样实现的:

// 跳转到指定标签
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-agentreferer等请求头都做了处理了,但还是无济于事,大家如果有好的办法也可以提供给我尝试

然后给大家简单演示一下如何使用的吧~

这个动图上好像有些模糊或者是样式的变动,都是因为 gif录制器的原因哈

其它

对于这个项目,因为刚出来半个月不到嘛,肯定还有需要改进的地方,我也已经列出了之后需要继续跟进的新功能:

  1. URL 的拖拽、排列
  2. 页面账号信息存储功能
  3. 提供更多的网址 icon 的选择
  4. more ……

第一个功能什么意思呢,就是我现在的项目中是不支持添加好后的 URL 重新排序的,但我觉得这个功能是一定要有的,之后会加上,打算想办法做一个在编辑状态下拖拽即可完成排列的功能

第二个功能的目的是因为对于很多个网站,你也许会有不同的账号和密码,但现在最令人头疼的就是,总是记不住这个网站我的账号或密码是啥,导致每次都要多次尝试或找回密码,特别的麻烦;所以我想做一个鼠标移到对应网址上,有一个查看此网址对应我的账号密码的功能

第三个功能就是为了针对那些无法获取 icon 的网站导致我们导航栏中显示的图标为默认图标,比较丑,所以到时候可以支持大家自行选择喜欢的图标

更多的功能还请大家多提建议啦~

最后

有些小伙伴问,为啥不做一个账号登录的网址导航栏,这样到哪都不用带着配置文件了,只需要记住账号密码就可以了。我又要强调本项目的选择了,能不用服务器就不用服务器,能不用数据库就不用数据库,用你自己的本地的 localStorage 作为数据库存储,你不是更放心嘛,比如你收藏了一些奇奇怪怪的网站,反正就只有你知道,我反正肯定是不知道的 😏 而且细心的小伙伴有没有发现,我连静态页面都不是用的自己的服务器,直接部署在码云上的

自学前端这么久了,之前一直做着别人的项目或是模仿一些网站做一个项目,细数一下有这么几个:淘宝首页静态页面、蘑菇街移动端APP、node社区、elementUi组件以及组件文档展示等等,这次这个项目也算属于我自己的了,而且对于我来说是非常实用的一个小工具了,希望大家多多支持~ 给我提提意见,可以的话点个 star 🤞

再放一次项目源码地址:项目源码

对于这个项目有什么疑问或是项目出现问题的小伙伴可以告知我,vx:Lpyexplore333

关注公众号:前端印象,领取更多前端资料,我们还能一起学习交流前端技术,分享开发经验

看到这里了,你们不点个 👍 再走忍心嘛,最后谢谢各位的耐心观看

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 博客之星2020 设计师:CY__ 返回首页