Vue3应用简单实践

Vue3应用实践

技术栈:Vue3+Vite+Electron+Vuetify+JS

Vue3用于编写页面,Vite+Electron用于打包,Vuetify则可以帮助开发者快速构建起一个漂亮的页面。

这个应用笔者用来学习技术栈里各个框架的功能,在接下来会详细介绍,笔者也是首次接触这些,难免有误,敬请见谅,笔者会及时勘误。

我希望实现的功能是一个类似备忘录的功能,我可以加入事件和截止日期,会显示倒计时在组件里,在侧边导航栏里,我可以增删事件。

准备工作

项目初始化

项目使用electron-vite进行构建,然后我们手动安装Vuetify,安装过程请参见文档。

为了保证代码的规范性,笔者启用了Eslint,如果使用Webstorm,则可以在设置里开启Eslint的选项:

image1

我们初始化完项目后,进入我们的项目根目录,执行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年一次更新里更新了手动选择配置文件的功能,但是我尝试后并没有生效:

imageEL.png

代码编写

框架准备

为了和脚手架的默认代码分隔开来,我们进入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的帮助,我们可以快速创建一个好看的页面出来。首先,我们看看文档里为我们提供了哪些默认布局:

image3.png

我选择第一个Baseline,点入后在右下角可以找到github页面的链接,我们可以在里面获得其原始代码,把它们复制入我们的文件的模版部分后运行,即可获得一样的布局。注意源代码里有两个<script>​,分别是Vue3的组合式API和选项式api,本文将使用组合式API。

我们注意到该布局里,当窗口分辨率较小时,抽屉导航栏打开时,并没有推开主要内容,而我想实现无论分辨率如何,都推开主要内容,所以我们给导航栏的标签里加入permanent​,关于此API的信息,可以在Vuetify的文档的API速查​部分查询。

<v-navigation-drawer v-model="drawer" permanent>
</v-navigation-drawer>

然后我们去Vuetify的抽屉导航栏部分,找一个喜欢的样式复制过来并覆盖:

​![image](image-4.png)​

背景图是通过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> 

此时我们的应用就长这样了:

​![image](image-5.png)​

然后我们需要创建对事件的输入和输出。输入部分我们使用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>

这样我们的导航栏就长这样了:

image-6.png

主要内容

然后,主要内容部分我们需要显示每一个事件,使用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>

这样,我们就可以看见一个个卡片显示内容:

image7.png

然后我们需要实现倒计时功能,添加计算时间用的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()​。

写:

这里较为复杂,我们的流程简单来说就是:

  1. 用户试图关闭窗口时,阻止窗口的关闭,向渲染进程发送事件'save-before-close'​。

      mainWindow.on('close', (e) => {
        // 阻止窗口直接关闭
        e.preventDefault()
        // 请求渲染进程进行保存操作
        mainWindow.webContents.send('save-before-close')
      })
  2. 渲染进程接收到事件'save-before-close'​,执行保存函数,执行完毕后发送'save-complete'​事件到主进程

    onMounted(() => {
      // 当窗口准备关闭时,监听save-before-close事件
      window.myAPI.on('save-before-close', async () => {
        await save()
        window.myAPI.send('save-complete', 'done')
      })
    })
  3. 主进程接收到'save-complete'​事件,得知保存完成,关闭窗口。

      ipcMain.on('save-complete', () => {
        // 当从渲染进程接收到数据后,进行保存
        // 保存完毕后,可以安全关闭窗口
          mainWindow.destroy() // 或者使用 mainWindow.close(); 依情况而定
      })

最后修改:2025 年 09 月 18 日
如果觉得我的文章对你有用,请随意赞赏