Vue3应用简单实践
Vue3应用实践
技术栈:Vue3+Vite+Electron+Vuetify+JS
Vue3用于编写页面,Vite+Electron用于打包,Vuetify则可以帮助开发者快速构建起一个漂亮的页面。
这个应用笔者用来学习技术栈里各个框架的功能,在接下来会详细介绍,笔者也是首次接触这些,难免有误,敬请见谅,笔者会及时勘误。
我希望实现的功能是一个类似备忘录的功能,我可以加入事件和截止日期,会显示倒计时在组件里,在侧边导航栏里,我可以增删事件。
准备工作
项目初始化
项目使用electron-vite进行构建,然后我们手动安装Vuetify,安装过程请参见文档。
为了保证代码的规范性,笔者启用了Eslint,如果使用Webstorm,则可以在设置里开启Eslint的选项:
我们初始化完项目后,进入我们的项目根目录,执行npm run dev
,应该可以看见Electron-vite的初始页面。我们再具体看看package.json
,看看脚手架还为我们提供了哪些命令:
"dev": "electron-vite dev", // 运行
"build": "electron-vite build", // 打包
"postinstall": "electron-builder install-app-deps", // 安装或者重建项目中特定于 Electron 应用的本地依赖模块
"build:win": "npm run build && electron-builder --win --config", // 以下为各个平台的打包命令
"build:mac": "npm run build && electron-builder --mac --config",
"build:linux": "npm run build && electron-builder --linux --config"
我们可以手动增加一个:"watch": "electron-vite dev --watch"
,这样就能启动热重载,在主进程或预加载脚本模块发生变化时快速重新构建并重启 Electron 程序,给我们带来更好的开发体验。
路径别名
Vite为我们提供了路径别名,我们可以为常用路径路径起一个别名,electron-vite的vite的配置文件不是vite.config.js
,而是electron.vite.config.js
,我们将内容修改如下:
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@':resolve('src/renderer/src') // 为'src/renderer/src'起别名为'@'
}
},
plugins: [vue()]
}
})
这样的话,我们的代码里就能用更简洁的路径表达方法:
import ima from 'src/renderer/src/assets/images/bg.png'
// |
// V
import ima from '@/assets/images/bg.png'
但是因为Vite配置文件名不是默认文件名,导致Webstorm无法识别默认配置文件,从而无法为我们提供路径自动补全功能,虽然2022年一次更新里更新了手动选择配置文件的功能,但是我尝试后并没有生效:
代码编写
框架准备
为了和脚手架的默认代码分隔开来,我们进入Vue组件保存的路径:/src/renderer/src/components,并新建一个Vue文件:vtf.vue,作为实现我们功能的组件,然后写一个简单的HelloWorld:
<template>
<h1>Hello World</h1>
</template>
<script setup>
</script>
<style scoped lang="less">
</style>
然后进入上层目录的App.vue,在模版里(<template>
)注释掉默认的<Versions></Versions>
组件,加上我们的<vtf />
,并注释掉对<style>
里CSS文件的导入,此时,该文件应该差不多长这样:
<script setup>
import Vtf from "./components/vtf.vue";
</script>
<template>
<!-- <Versions></Versions>-->
<vtf />
</template>
<style lang="less">
//@import './assets/css/styles.less';
</style>
这样我们再运行一下程序npm run dev
,应该就可以看见自己的HelloWorld了,此后我们大部分代码的编写都会围绕这个vtf.vue。
布局选择
有了Vuetify的帮助,我们可以快速创建一个好看的页面出来。首先,我们看看文档里为我们提供了哪些默认布局:
我选择第一个Baseline,点入后在右下角可以找到github页面的链接,我们可以在里面获得其原始代码,把它们复制入我们的文件的模版部分后运行,即可获得一样的布局。注意源代码里有两个<script>
,分别是Vue3的组合式API和选项式api,本文将使用组合式API。
我们注意到该布局里,当窗口分辨率较小时,抽屉导航栏打开时,并没有推开主要内容,而我想实现无论分辨率如何,都推开主要内容,所以我们给导航栏的标签里加入permanent
,关于此API的信息,可以在Vuetify的文档的API速查
部分查询。
<v-navigation-drawer v-model="drawer" permanent>
</v-navigation-drawer>
然后我们去Vuetify的抽屉导航栏部分,找一个喜欢的样式复制过来并覆盖:
背景图是通过CDN读取的,我们可以使用本地的图片。在components同级建立assets/images路径,在里面放入我们希望使用的背景图片,然后在代码里将其作为背景图:
<template>
<v-navigation-drawer
:image="bg"
permanent
theme="dark"
>
</v-navigation-drawer>
</template>
<script setup>
import { ref } from 'vue'
import bg from '../assets/images/bg.png'
const drawer = ref(null)
</script>
侧边栏选项
此时,我们要实现在侧边栏显示数据的功能,这里我们可以使用Vue3的功能:列表渲染,我们先新建一个响应式变量存储数据,并使用列表渲染实现对所有数据的逐一渲染。
const cards = ref([{title:"Inbox",content:"inbox",Day:"",Month:"",Year:"",Counter:0},
{title:"Supervisors",content:"supervisors",Day:"",Month:"",Year:"",Counter:0},
{title:"Clock-in",content:"clockin",Day:"",Month:"",Year:"",Counter:0}
])
然后,为了实现对事件的增删,我们需要记录选中的项,新增变量记录谁被我们选中:
const selected = ref(null)
然后,为了让它更易读,我们为其背景增加高斯模糊效果,新增CSS样式:
.blur-list-item {
backdrop-filter: blur(10px);
border: 1px solid #ffffff;
}
但是一片纯白也不好看,所以我们设置一个背景图,在Vuetify里,有<v-parallax>
用于给我们设置背景图,然后我们给把导航栏设置为透明,这样的话,我们就能给整个窗口加上背景图了,修改响应样式:
<v-navigation-drawer
style="background: transparent" 背景色设置为透明
>
</v-navigation-drawer>
<v-parallax :src="ima">
<v-main>
<!-- -->
</v-main>
</v-parallax>
JS代码:
import ima from '../assets/images/bg.png'
然后我想使用一个单独的按钮来决定侧边栏展开与否,所以我们删除顶部导航栏,并增加Vuetify的按钮<v-btn>
来控制侧边栏的行为,并为其添加CSS代码来调整按钮图标以及位置:
Change:
<v-app-bar>
<v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
<v-app-bar-title>Application</v-app-bar-title>
</v-app-bar>
TO:
<v-btn
density="compact" 按钮大小
:icon="buttonIcon" 按钮图标
rounded="lg" 圆角
style="backdrop-filter: blur(10px); background-color: transparent" CSS样式
@click="drawer = !drawer" 点击后的响应,参考Vue3的v-on监听器
></v-btn>
全部修改完之后,我们的导航栏标签以及如下:
<template>
<v-navigation-drawer
v-model="drawer"
width="300"
style="background: transparent"
permanent
theme="dark"
class="drawer-width"
>
<v-list nav>
<v-list-item
v-for="card in cards"
:key="card.title"
class="blur-list-item mx-auto mb-4" mx和mb设置水平和margin和底部的margin
prepend-icon="mdi-email" 图标
elevation="2" 阴影深度
:value="card.content"
@click="selected = card.title"
>
{{ card.title }} 渲染的值为card.title
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-parallax :src="ima">
<div :style="buttonStyle">
<v-btn
density="compact"
:icon="buttonIcon"
rounded="lg"
style="backdrop-filter: blur(10px); background-color: transparent"
@click="drawer = !drawer"
></v-btn>
</div>
</v-parallax>
</template>
<script setup>
import {computed, ref} from 'vue'
import bg from '../assets/images/bg.png'
const selected = ref(null)
const drawer = ref(false)
const cards = ref([{title:"Inbox",content:"inbox",Day:"",Month:"",Year:"",Counter:0},
{title:"Supervisors",content:"supervisors",Day:"",Month:"",Year:"",Counter:0},
{title:"Clock-in",content:"clockin",Day:"",Month:"",Year:"",Counter:0}
])
const buttonStyle = computed(() => { // 根据是否展开决定按钮位置
return {
transform: drawer.value ? 'translateX(300px)' : 'translateX(0)',
transition: 'transform 0.2s ease'
}
})
const buttonIcon = computed(() => {。// 根据是否展开决定按钮图标
const tem = drawer.value ? 'mdi mdi-arrow-collapse-left' : 'mdi mdi-arrow-collapse-right'
console.log(tem)
return tem
})
<script>
此时我们的应用就长这样了:
然后我们需要创建对事件的输入和输出。输入部分我们使用Vuetify的V-text-field、v-row、v-col
等组件来调整布局,用Vue3的v-model
来绑定输入到变量。使用的API也都可以在文档里查到:
<template>
<v-navigation-drawer
v-model="drawer"
width="300"
style="background: transparent"
permanent
theme="dark"
class="drawer-width"
>
<v-list nav>
<v-list-item
v-for="card in cards"
:key="card.title"
class="blur-list-item mx-auto mb-4"
prepend-icon="mdi-email"
elevation="2"
:value="card.content"
@click="selected = card.title"
>
{{ card.title }}
</v-list-item>
</v-list>
<v-sheet class="mx-3" style="background-color: transparent">
<v-form @submit.prevent>
<v-text-field
v-model="Title"
density="compact"
hide-details
label="Event Title"
class="backBlur mb-2"
></v-text-field>
<v-text-field
v-model="Content"
density="compact"
hide-details
class="backBlur mb-3"
label="Event Content"
></v-text-field>
<v-row>
<v-col cols="4">
<v-text-field
v-model="dateYear"
min-length="1"
density="compact"
hide-details
class="backBlur mb-3"
label="Y"
></v-text-field>
</v-col>
<v-divider vertical :thickness="3" class="custom-divider-color"></v-divider>
<v-col cols="3" width="10">
<v-text-field
v-model="dateMonth"
density="compact"
hide-details
class="backBlur mb-3"
label="M"
></v-text-field>
</v-col>
<v-divider vertical :thickness="3" color="white"></v-divider>
<v-col cols="3">
<v-text-field
v-model="dateDay"
density="compact"
hide-details
class="backBlur mb-3"
label="D"
></v-text-field>
</v-col>
</v-row>
<v-btn type="submit" block class="mt-2 backBlur" @click="add">Submit</v-btn>
<v-btn block class="mt-2 backBlur" @click="del">Click To Del</v-btn>
<!-- <v-btn block class="mt-2 backBlur" @click="save">Click To Save</v-btn>-->
<!-- <v-btn block class="mt-2 backBlur" @click="load">Click To Load</v-btn>-->
<!-- <v-btn block class="mt-2 backBlur" @click="func">Click To Close</v-btn>-->
</v-form>
</v-sheet>
</v-navigation-drawer>
</template>
<script setup>
</script>
const add = () => { // 添加事件
cards.value.push({
title: Title.value,
content: Content.value.toLowerCase(),
Year: dateYear.value,
Month: dateMonth.value,
Day: dateDay.value,
Counter: '0'
})
updateTime()
}
const del = () => {
const rawCards = toRaw(cards.value)
cards.value = rawCards.filter((card) => card.title !== selected.value)
}
<style>
.backBlur {
background-color: transparent;
backdrop-filter: blur(10px);
border: white;
color: white;
}
</style>
这样我们的导航栏就长这样了:
主要内容
然后,主要内容部分我们需要显示每一个事件,使用Vuetify的cards
和列表渲染来实现我们需要的功能:
<!-- 修改v-main: -->
<template>
<v-main>
<v-card
v-for="card in cards"
:key="card.title"
prepend-icon="mdi-home"
class="mx-auto mb-4 backBlur"
elevation="10"
width="auto"
:title="card.title"
>
<v-card-text>距离{{ card.content }}还有{{ card.Counter }}天</v-card-text>
</v-card>
</v-main>
</template>
<style>
.backBlur {
background-color: transparent;
backdrop-filter: blur(10px);
border: white;
color: white;
}
</style>
这样,我们就可以看见一个个卡片显示内容:
然后我们需要实现倒计时功能,添加计算时间用的JS代码,并在合适的时间调用该函数:
const countdown = (Year, Month, Day) => {
const targetDate = new Date(Year, Month - 1, Day) // 月份需要减1,因为月份索引是从0开始的
const today = new Date()
today.setHours(0, 0, 0, 0) // 将今天的时间设置为午夜,忽略小时和分钟
const diff = targetDate - today
const daysRemaining = Math.ceil(diff / (1000 * 60 * 60 * 24))
return daysRemaining >= 0 ? daysRemaining : '0'
}
const updateTime = () => {. // 更新时间
cards.value.forEach((card) => {
console.log(card.Year)
card.Counter = countdown(card.Year, card.Month, card.Day)
})
}
const add = () => {
cards.value.push({
title: Title.value,
content: Content.value.toLowerCase(),
Year: dateYear.value,
Month: dateMonth.value,
Day: dateDay.value,
Counter: '0'
})
updateTime() // 添加新事件时计算剩余时间
}
此时,我们的组件应该已经可以正常工作了,剩余内容就是我们需要在关闭应用时保存所有事件到本地,并在应用启动时读取。
文件读写
这里我们需要让主进程(控制窗口)和渲染进程(通信),所以首先我们需要修改预渲染文件:
// src/preload/index.js
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
const combinedAPI = {
...electronAPI,
loadJson: () => ipcRenderer.invoke('load-json'), // 读取文件
saveJson: (data) => ipcRenderer.invoke('save-json', data), // 保存文件
on: (channel, func) => {
// 安全地从主进程监听事件
const validChannels = ['save-before-close']
if (validChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => func(...args))
}
},
send: (channel, data) => {
// 安全地发送事件到主进程
const validChannels = ['save-complete']
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data)
}
},
closeProcess: () => ipcRenderer.invoke('shutdown')
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', combinedAPI)
} catch (error) {
console.error(error)
}
} else {
window.electron = combinedAPI
}
在主线程添加相应处理逻辑,在createWindow
内添加以下内容:
// src/main/main.js
const jsonPath = './dataaaaa.json' // 保存路径
ipcMain.handle('load-json', async () => {
// 从文件读取JSON数据并返回
if (fs.existsSync(jsonPath)) {
return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
}
return {}
})
ipcMain.handle('save-json', async (event, data) => {
// 把数据保存到JSON文件
fs.writeFileSync(jsonPath, JSON.stringify(data))
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.on('close', (e) => {.
// 用户试图关闭时,阻止窗口直接关闭
e.preventDefault()
// 请求渲染进程进行保存操作,发送'save-before-close'事件到渲染进程
mainWindow.webContents.send('save-before-close')
})
ipcMain.on('save-complete', () => {
// 当从渲染进程接收到保存完成的事件后,关闭窗口
mainWindow.destroy() // 或者使用 mainWindow.close(); 依情况而定
})
此时,我们的Vue组件里,就可以通过上述方法和主进程通信了:
<script setup>
const save = async () => { //被调用时,把cards保存到本地
console.log(cards.value)
await window.electron.saveJson(toRaw(cards.value))
}
onMounted(async () => { //组件挂载后,读取本地的json文件到cards变量
cards.value = await window.electron.loadJson()
console.log(cards)
})
onMounted(() => {
// 监听'save-before-close'事件
window.electron.on('save-before-close', async () => {
await save(). //等待保存完成
window.electron.send('save-complete', 'done')//发送'save-complete'事件到主线程。
})
})
</script>
此时,我们的程序就可以实现对文件的自动读写了(除mac平台)。
遇到问题
进程通信
主线程和渲染进程的通信需要依靠预渲染脚本preload/index.js
,需要在里面定义好API并暴露给渲染进程。使用第一节里给出的命令创建脚手架后,默认没有开启Electron的上下文隔离,所以不会步入if
,我们需要在else
里也加上对API的暴露:
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
const combinedAPI = {
...electronAPI, // 假设 electronAPI 是一个对象,包含了一些方法或属性
}
const myAPI = {}
if (process.contextIsolated) {。// 是否开启contextIsolation
try {
contextBridge.exposeInMainWorld('electron', combinedAPI)
contextBridge.exposeInMainWorld('myAPI', myAPI)
} catch (error) {
console.error(error)
}
} else {
window.electron = combinedAPI
window.myAPI = myAPI // 在这里暴露自定义的API
}
应用启动与关闭
我们要在应用启动和关闭时进行读和写,所以需要在合适的时机让主线程和渲染进程通信。
读:
onMounted(async () => {
cards.value = await window.electron.loadJson()
console.log(cards)
})
组件挂载完成后,调用API的loadJson()
。
写:
这里较为复杂,我们的流程简单来说就是:
用户试图关闭窗口时,阻止窗口的关闭,向渲染进程发送事件
'save-before-close'
。mainWindow.on('close', (e) => { // 阻止窗口直接关闭 e.preventDefault() // 请求渲染进程进行保存操作 mainWindow.webContents.send('save-before-close') })
渲染进程接收到事件
'save-before-close'
,执行保存函数,执行完毕后发送'save-complete'
事件到主进程onMounted(() => { // 当窗口准备关闭时,监听save-before-close事件 window.myAPI.on('save-before-close', async () => { await save() window.myAPI.send('save-complete', 'done') }) })
主进程接收到
'save-complete'
事件,得知保存完成,关闭窗口。ipcMain.on('save-complete', () => { // 当从渲染进程接收到数据后,进行保存 // 保存完毕后,可以安全关闭窗口 mainWindow.destroy() // 或者使用 mainWindow.close(); 依情况而定 })