0
点赞
收藏
分享

微信扫一扫

前端实战:electron+vue3+ts开发桌面端便签应用


前端时间我的一个朋友为了快速熟悉 Vue3 开发, 特意使用 electron+vue3+ts 开发了一个桌面端应用, 并在 ​​github​​​ 上开源了, 接下来我就带大家一起了解一下这个项目, 在文章末尾我会放 ​​github​​​的地址, 大家如果想学习vue3 + ts + electron 开发, 可以本地 ​​clone​​ 学习参考一下.

 

 


前端实战:electron+vue3+ts开发桌面端便签应用_typescript

image.png

技术栈

以上是我们看到的便签软件使用界面, 整体技术选型如下:

脚手架 vue-cli

前端框架和语言规范 vue + typescript

桌面端开发框架 electron

electron支持插件 vue-cli-plugin-electron-builder

数据库 NeDB | 一款NoSQL嵌入式数据库

代码格式规范 eslint

接下来我们来看看具体的演示效果:

前端实战:electron+vue3+ts开发桌面端便签应用_Vue_02


具体实现过程, 内容很长, 建议先点赞收藏, 再一步步学习, 接下来会就该项目的每一个重点细节做详细的分析.

开发思路

页面:

列表页​​index.vue​​ 头部、搜索、内容部分,只能有一个列表页存在

设置页​​setting.vue​​ 设置内容和软件信息,和列表页一样只能有一个存在

编辑页 ​​editor.vue​​ icons功能和背景颜色功能,可以多个编辑页同时存在

动效:

打开动效,有一个放大、透明度的过渡,放不了动图这里暂时不演示了。

标题过渡效果

切换​​index​​​和​​setting​​时头部不变,内容过渡

数据储存:数据的创建和更新都在编辑页​​editor.vue​​​进行,这个过程中在储存进​​nedb​​​之后才通信列表页​​index.vue​​​更新内容,考虑到性能问题,这里使用了​​防抖​​防止连续性的更新而导致卡顿(不过貌似没有这个必要。。也算是一个小功能吧,然后可以设置这个更新速度)

错误采集:采集在使用中的错误并弹窗提示

编辑显示:​​document​​​暴露 ​​execCommand​​ 方法,该方法允许运行命令来操纵可编辑内容区域的元素。

在开发的时候还遇到过好多坑,这些都是在​​electron​​环境中才有,比如

​@input​​​触发2次,加上​​v-model​​​触发3次。包括创建一个新的electron框架也是这样,别人电脑上不会出现这个问题,猜测是​​electron缓存​​问题

vue3碰到​​空属性​​报错时无限报错,在普通浏览器(edge和chrome)是正常一次

组件无法正常渲染不报错,只在控制台报异常

打包后由于​​electron​​的缓存导致打开需要10秒左右,清除c盘软件缓存后正常

其他的不记得了。。

这里暂时不提供vue3和electron介绍,有需要的可以先看看社区其他的有关文章或者后期再详细专门提供。软件命名为​​i-notes​​。

vue3中文教程 vue3js.cn/docs/zh/gui…[1] electron教程 www.electronjs.org/[2]
typescript教程 www.typescriptlang.org/[3]

​electron-vue​​里面的包环境太低了,所以是手动配置electron+vue3(虽然说是手动。。其实就两个步骤)

目录结构

 
electron-vue-notes

 
├── public

 
│   ├── css

 
│   ├── font

 
│   └── index.html

 
├── src

 
│   ├── assets

 
│   │   └── empty-content.svg

 
│   ├── components

 
│   │   ├── message

 
│   │   ├── rightClick

 
│   │   ├── editor.vue

 
│   │   ├── header.vue

 
│   │   ├── input.vue

 
│   │   ├── messageBox.vue

 
│   │   ├── switch.vue

 
│   │   └── tick.vue

 
│   ├── config

 
│   │   ├── browser.options.ts

 
│   │   ├── classNames.options.ts

 
│   │   ├── editorIcons.options.ts

 
│   │   ├── index.ts

 
│   │   └── shortcuts.keys.ts

 
│   ├── inotedb

 
│   │   └── index.ts

 
│   ├── less

 
│   │   └── index.less

 
│   ├── router

 
│   │   └── index.ts

 
│   ├── script

 
│   │   └── deleteBuild.js

 
│   ├── store

 
│   │   ├── exeConfig.state.ts

 
│   │   └── index.ts

 
│   ├── utils

 
│   │   ├── errorLog.ts

 
│   │   └── index.ts

 
│   ├── views

 
│   │   ├── editor.vue

 
│   │   ├── index.vue

 
│   │   ├── main.vue

 
│   │   └── setting.vue

 
│   ├── App.vue

 
│   ├── background.ts

 
│   ├── main.ts

 
│   └── shims-vue.d.ts

 
├── .browserslistrc

 
├── .eslintrc.js

 
├── .prettierrc.js

 
├── babel.config.js

 
├── inoteError.log

 
├── LICENSE

 
├── package-lock.json

 
├── package.json

 
├── README.md

 
├── tsconfig.json

 
├── vue.config.js

 
└── yarn.lock 

使用脚手架搭建vue3环境

没有脚手架的可以先安装脚手架

npm install -g @vue/cli

创建vue3项目

 
vue create electron-vue-notes

  

 
# 后续

 

? Please pick a preset: (Use arrow keys)

2] babel, eslint)

3 Preview) ([Vue 3] babel, eslint)

> Manually select features

 
# 手动选择配置

  

 
# 后续所有配置

 
? Please pick a preset: Manually select features

 
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, CSS Pre-processors, Linter

 
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)

 
? Use class-style component syntax? Yes

 
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes

 
? Use history mode for router? (Requires proper server setup for index fallback in production) No

 
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Less

 
? Pick a linter / formatter config: Prettier

 
? Pick additional lint features: Lint on save, Lint and fix on commit

 
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files

 
? Save this as a preset for future projects? (y/N) n 

创建完之后的目录是这样的

 
electron-vue-notes

 
├── public

 
│   ├── favicon.ico

 
│   └── index.html

 
├── src

 
│   ├── assets

 
│   │   └── logo.png

 
│   ├── components

 
│   │   └── HelloWorld.vue

 
│   ├── router

 
│   │   └── index.ts

 
│   ├── views

 
│   │   ├── About.vue

 
│   │   └── Home.vue

 
│   ├── App.vue

 
│   ├── main.ts

 
│   └── shims-vue.d.ts

 
├── .browserslistrc

 
├── .eslintrc.js

 
├── babel.config.js

 
├── package.json

 
├── README.md

 
├── tsconfig.json

 
└── yarn.lock 

安装electron的依赖

 
# yarn

 
yarn add vue-cli-plugin-electron-builder electron

  

 
# npm 或 cnpm

 
npm i vue-cli-plugin-electron-builder electron 

安装完之后完善一些配置,比如​​别名​​​、​​eslint​​​、​​prettier​​​等等基础配置,还有一些​​颜色​​​、​​icons​​等等具体可以看下面

项目的一些基础配置

eslint

使用eslint主要是规范代码风格,不推荐tslint是因为tslint已经不更新了,tslint也推荐使用eslint 安装eslint

npm i eslint -g

进入项目之后初始化eslint

 
eslint --init

  

 
# 后续配置

 
? How would you like to use ESLint? To check syntax and find problems

 
? What type of modules does your project use? JavaScript modules (import/export)

 
? Which framework does your project use? Vue.js

 
? Does your project use TypeScript? Yes

 
? Where does your code run? Browser, Node

 
? What format do you want your config file to be in? JavaScript

 
The config that you've selected requires the following dependencies:

  

 
eslint-plugin-vue@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest

 
? Would you like to install them now with npm? (Y/n) y 

  

修改eslint配置,·​​.eslintrc.js​​​,规则​​rules​​可以根据自己的喜欢配置 eslint.org/docs/user-g…[4]

 

module.exports = {

true,

env: {

true

},

extends: [

'plugin:vue/vue3-essential',

'eslint:recommended',

'plugin:prettier/recommended',

'plugin:@typescript-eslint/eslint-recommended',

'@vue/typescript/recommended',

'@vue/prettier',

'@vue/prettier/@typescript-eslint'

],

parserOptions: {

2020

},

rules: {

1, 'single'],

1,

'@typescript-eslint/camelcase': 0,

'@typescript-eslint/no-explicit-any': 0,

'no-irregular-whitespace': 2,

'no-case-declarations': 0,

'no-undef': 0,

'eol-last': 1,

'block-scoped-var': 2,

'comma-dangle': [2, 'never'],

'no-dupe-keys': 2,

'no-empty': 1,

'no-extra-semi': 2,

'no-multiple-empty-lines': [1, { max: 1, maxEOF: 1 }],

'no-trailing-spaces': 1,

'semi-spacing': [2, { before: false, after: true }],

'no-unreachable': 1,

'space-infix-ops': 1,

'spaced-comment': 1,

'no-var': 2,

'no-multi-spaces': 2,

'comma-spacing': 1

}

};
prettier

在根目录增加​​.prettierrc.js​​配置,根据自己的喜好进行配置,单行多少个字符、单引号、分号、逗号结尾等等

 

module.exports = {

120,

true,

true,

'none'

};
tsconfig.json

如果这里没有配置识别​​@/​​路径的话,在项目中使用会报错

 

"paths": {

"@/*": [


]

}
package.json

"author": "heiyehk",

"description": "I便笺个人开发者heiyehk独立开发,在Windows中更方便的记录文字。",

"main": "background.js",

"scripts": {

"lint": "vue-cli-service lint",

"electron:build": "vue-cli-service electron:build",

"electron:serve": "vue-cli-service electron:serve"

}

配置入口文件​​background.ts​

因为需要做一些打开和关闭的动效,因此我们需要配置​​electron​​​为​​frame无边框​​​和​​透明transparent​​的属性

 

/* eslint-disable @typescript-eslint/no-empty-function */

'use strict';


import { app, protocol, BrowserWindow, globalShortcut } from 'electron';

import {

createProtocol

// installVueDevtools

} from 'vue-cli-plugin-electron-builder/lib';


const isDevelopment = process.env.NODE_ENV !== 'production';


let win: BrowserWindow | null;

protocol.registerSchemesAsPrivileged([

{

'app',

privileges: {

true,

true

}

}

]);


function createWindow() {

new BrowserWindow({

false, // 无边框

false,

true, // 透明

950,

600,

webPreferences: {

true,

true

}

});


if (process.env.WEBPACK_DEV_SERVER_URL) {

win.loadURL(process.env.WEBPACK_DEV_SERVER_URL);

if (!process.env.IS_TEST) win.webContents.openDevTools();

else {

'app');

'http://localhost:8080');

}


'closed', () => {

win = null;

});

}


app.on('window-all-closed', () => {

if (process.platform !== 'darwin') {

app.quit();

}

});


app.on('activate', () => {

if (win === null) {

createWindow();

}

});


app.on('ready', async () => {

// 这里注释掉是因为会安装tools插件,需要屏蔽掉,有能力的话可以打开注释

// if (isDevelopment && !process.env.IS_TEST) {

// try {

// await installVueDevtools();

// } catch (e) {

// console.error('Vue Devtools failed to install:', e.toString());

// }

// }

createWindow();

});


if (isDevelopment) {

if (process.platform === 'win32') {

'message', data => {

if (data === 'graceful-exit') {

app.quit();

}

});

else {

'SIGTERM', () => {

app.quit();

});

}

}

启动

yarn electron:serve

到这里配置就算是成功搭建好这个窗口了,但是还有一些其他细节需要进行配置,比如​​electron打包​​​配置,​​模块化​​的配置等等

常规配置

这里配置一些常用的开发内容和一些轮子代码, 大家可以参考 ​​reset.csss​​​ 和 ​​common.css​​ 这两个文件.

config

这个对应项目中的config文件夹

 
config

 
├── browser.options.ts # 窗口的配置

 
├── classNames.options.ts # 样式名的配置,背景样式都通过这个文件渲染

 
├── editorIcons.options.ts # 编辑页面的一些editor图标

 
├── index.ts # 导出

 
└── shortcuts.keys.ts # 禁用的一些快捷键,electron是基于chromium浏览器,所以也存在一些浏览器快捷键比如F5 

browser.options

这个文件的主要作用就是配置主窗口和编辑窗口区分开发正式的配置,宽高等等,以及要显示的主页面

 

/**

* 软件数据和配置

* C:\Users\{用户名}\AppData\Roaming

* 共享

* C:\ProgramData\Intel\ShaderCache\i-notes{xx}

* 快捷方式

* C:\Users\{用户名}\AppData\Roaming\Microsoft\Windows\Recent

* 电脑自动创建缓存

* C:\Windows\Prefetch\I-NOTES.EXE{xx}
*/


/** */

const globalEnv = process.env.NODE_ENV;


const devWid = globalEnv === 'development' ? 950 : 0;

const devHei = globalEnv === 'development' ? 600 : 0;


// 底部icon: 40*40
const editorWindowOptions = {

290,

350,

250

};


/**

* BrowserWindow的配置项

* @param type 单独给编辑窗口的配置

*/

const browserWindowOption = (type?: 'editor'): Electron.BrowserWindowConstructorOptions => {

const commonOptions = {

48,

false,

true,

true,

webPreferences: {

true,

true

}

};

if (!type) {

return {

350,

600,

320,

...commonOptions

};

}

return {

...editorWindowOptions,

...commonOptions

};

};


/**

* 开发环境: http://localhost:8080

* 正式环境: file://${__dirname}/index.html

*/

const winURL = globalEnv === 'development' ? 'http://localhost:8080' : `file://${__dirname}/index.html`;


export { browserWindowOption, winURL };
router
增加​​meta​​​中的​​title​​属性,显示在软件上方头部

import { createRouter, createWebHashHistory } from 'vue-router';

import { RouteRecordRaw } from 'vue-router';

import main from '../views/main.vue';


const routes: Array<RouteRecordRaw> = [

{

'/',

'main',

component: main,

children: [

{

'/',

'index',

import('../views/index.vue'),

meta: {

'I便笺'

}

},

{

'/editor',

'editor',

import('../views/editor.vue'),

meta: {

''

}

},

{

'/setting',

'setting',

import('../views/setting.vue'),

meta: {

'设置'

}

}

]

}

];


const router = createRouter({

history: createWebHashHistory(process.env.BASE_URL),

routes

});


export default router;

main.vue

​main.vue​​​文件主要是作为一个整体框架,考虑到页面切换时候的动效,分为头部和主体部分,头部作为一个单独的组件处理,内容区域使用​​router-view​​渲染。html部分,这里和vue2.x有点区别的是,在vue2.x中可以直接

 

// bad

<transition name="fade">

<keep-alive>

<router-view />

</keep-alive>

</transition>

上面的这种写法在vue3中会在控制台报异常,记不住写法的可以看看控制台????????

 

<router-view v-slot="{ Component }">

"main-fade">

"transition" :key="routeName">

<keep-alive>

"Component" />

</keep-alive>

</div>

</transition>

</router-view>

然后就是ts部分了,使用vue3的写法去写,​​script​​​标签注意需要写上​​lang="ts"​​​代表是ts语法。​​router​​​的写法也不一样,虽然在vue3中还能写vue2的格式,但是不推荐使用。这里是获取​​route​​​的​​name​​属性,来进行一个页面过渡的效果。

 

<script lang="ts">

import { defineComponent, ref, onBeforeUpdate } from 'vue';

import { useRoute } from 'vue-router';

import Header from '@/components/header.vue';


export default defineComponent({

components: {

Header

},

setup() {

const routeName = ref(useRoute().name);


onBeforeUpdate(() => {

routeName.value = useRoute().name;

});


return {

routeName

};

}

});

</script>
less部分

<style lang="less" scoped>

.main-fade-enter,

.main-fade-leave-to {

display: none;

opacity: 0;

animation: main-fade 0.4s reverse;

}

.main-fade-enter-active,

.main-fade-leave-active {

opacity: 0;

animation: main-fade 0.4s;

}

@keyframes main-fade {

from {

opacity: 0;

transform: scale(0.96);

}

to {

opacity: 1;

transform: scale(1);

}

}

</style>

以上就是​​main.vue​​​的内容,在页面刷新或者进入的时候根据​​useRouter().name​​​的切换进行​​放大的过渡效果​​,后面的内容会更简洁一点。

header.vue

onBeforeRouteUpdate

头部组件还有一个标题过渡的效果,根据路由导航获取当前路由的​​mate.title​​​变化进行过渡效果。vue3中路由守卫需要从​​vue-route​​导入使用。

 

import { onBeforeRouteUpdate, useRoute } from 'vue-router';

...

onBeforeRouteUpdate((to, from, next) => {

title.value = to.meta.title;

currentRouteName.value = to.name;

next();

});
computed

这里是计算不同的路由下标题内边距的不同,首页是有个设置入口的按钮,而设置页面是只有两个按钮,​​computed​​​会返回一个你需要的​​新的值​

// 获取首页的内边距 
const computedPaddingLeft = computed(() => {

return currentRouteName.value === 'index' ? 'padding-left: 40px;' : '';

});

emit子传父和props父传子

vue3没有了​​this​​​,那么要使用​​emit​​​怎么办呢?在入口​​setup​​​中有​​2个参数​

setup(props, content) {}

​props​​​是父组件传给子组件的内容,​​props​​​常用的​​emit​​​和​​props​​​都在​​content​​中。

????这里需要注意的是,使用​​props​​​和​​emit​​​需要先定义,才能去使用,并且会在​​vscode​​中直接调用时辅助弹窗显示

props示例

emit示例

 

export default defineComponent({

props: {

test: String

},

'option-click', 'on-close'],

// 如果只用emit的话可以使用es6解构

// 如:setup(props, { emit })

setup(props, content) {

'option-click'));

}

})
electron打开窗口

import { browserWindowOption } from '@/config';

import { createBrowserWindow, transitCloseWindow } from '@/utils';

...

const editorWinOptions = browserWindowOption('editor');

// 打开新窗口

const openNewWindow = () => {

'/editor');

};

electron图钉固定屏幕前面

先获取当前屏幕实例

????这里需要注意的是,需要从​​remote​​​获取当前​​窗口信息​

判断当前窗口是否在最前面​​isAlwaysOnTop()​​​,然后通过​​setAlwaysOnTop()​​属性设置当前窗口最前面。

 

import { remote } from 'electron';

...

// 获取窗口固定状态

let isAlwaysOnTop = ref(false);

const currentWindow = remote.getCurrentWindow();

isAlwaysOnTop.value = currentWindow.isAlwaysOnTop();


// 固定前面

const drawingPin = () => {

if (isAlwaysOnTop.value) {

false);

false;

else {

true);

true;

}

};
electron关闭窗口
这里是在​​utils​​​封装了通过对​​dom​​的样式名操作,达到一个退出的过渡效果,然后再关闭。

// 过渡关闭窗口

export const transitCloseWindow = (): void => {

'#app')?.classList.remove('app-show');

'#app')?.classList.add('app-hide');

close();

};

noteDb数据库

安装nedb数据库,文档: www.w3cschool.cn/nedbintro/n…[5]

yarn add nedb @types/nedb

数据储存在​​nedb​​​中,定义字段,并在根目录的​​shims-vue.d.ts​​加入类型

/** 
* 储存数据库的

*/

interface DBNotes {

string; // 样式名

string; // 内容

// 创建时间,这个时间是nedb自动生成的

string; // uid,utils中的方法生成

// update,自动创建的

string; // 自动创建的

}

对nedb的封装

自我感觉这里写的有点烂。。。勿喷,持续学习中

这里的​​QueryDB​​​是​​shims-vue.d.ts​​定义好的类型

这里的意思是​​QueryDB<T>​​​是一个对象,然后这个对象传入一个​​泛型T​​​,这里​​keyof T​​​获取这个对象的​​key​​​(属性)值,​​?:​​​代表这个​​key​​​可以是​​undefined​​​,表示可以不存在。​​T[K]​​​表示从这个对象中获取这个​​K​​的值。

 

type QueryDB<T> = {

[K in keyof T]?: T[K];

};

import Datastore from 'nedb';

import path from 'path';

import { remote } from 'electron';


/**

* @see https://www.npmjs.com/package/nedb

*/

class INoteDB<G = any> {

/**

* 默认储存位置

* C:\Users\{Windows User Name}\AppData\Roaming\i-notes

*/

// dbPath = path.join(remote.app.getPath('userData'), 'db/inote.db');

// dbPath = './db/inote.db';

dbPath = this.path;


_db: Datastore<Datastore.DataStoreOptions> = this.backDatastore;


get path() {

if (process.env.NODE_ENV === 'development') {

return path.join(__dirname, 'db/inote.db');

}

return path.join(remote.app.getPath('userData'), 'db/inote.db');

}


get backDatastore() {

return new Datastore({

/**

* autoload

* default: false

* 当数据存储被创建时,数据将自动从文件中加载到内存,不必去调用loadDatabase

* 注意所有命令操作只有在数据加载完成后才会被执行

*/

true,

filename: this.dbPath,

true

});

}


refreshDB() {

this._db = this.backDatastore;

}


insert<T extends G>(doc: T) {

return new Promise((resolve: (value: T) => void) => {

this._db.insert(doc, (error: Error | null, document: T) => {

if (!error) resolve(document);

});

});

}


/**

* db.find(query)

* @param {Query<T>} query:object类型,查询条件,可以使用空对象{}。

* 支持使用比较运算符($lt, $lte, $gt, $gte, $in, $nin, $ne)

* 逻辑运算符($or, $and, $not, $where)

* 正则表达式进行查询。

*/

find(query: QueryDB<DBNotes>) {

return new Promise((resolve: (value: DBNotes[]) => void) => {

this._db.find(query, (error: Error | null, document: DBNotes[]) => {

if (!error) resolve(document as DBNotes[]);

});

});

}


/**

* db.findOne(query)

* @param query

*/

findOne(query: QueryDB<DBNotes>) {

return new Promise((resolve: (value: DBNotes) => void) => {

this._db.findOne(query, (error: Error | null, document) => {

if (!error) resolve(document as DBNotes);

});

});

}


/**

* db.remove(query, options)

* @param {Record<keyof DBNotes, any>} query

* @param {Nedb.RemoveOptions} options

* @return {BackPromise<number>}

*/

remove(query: QueryDB<DBNotes>, options?: Nedb.RemoveOptions) {

return new Promise((resolve: (value: number) => void) => {

if (options) {

this._db.remove(query, options, (error: Error | null, n: number) => {

if (!error) resolve(n);

});

else {

this._db.remove(query, (error: Error | null, n: number) => {

if (!error) resolve(n);

});

}

});

}


update<T extends G>(query: T, updateQuery: T, options: Nedb.UpdateOptions = {}) {

return new Promise((resolve: (value: T) => void) => {

this._db.update(

query,

updateQuery,

options,

(error: Error | null, numberOfUpdated: number, affectedDocuments: T) => {

if (!error) resolve(affectedDocuments);

}

);

});

}

}

  

 
export default new INoteDB(); 

使用​​ref​​​和​​reactive​​​代替vuex,并用​​watch​​监听

创建​​exeConfig.state.ts​

用​​ref​​​和​​reactive​​​引入的方式就可以达到​​vuex​​​的​​state​​​效果,这样就可以完全舍弃掉​​vuex​​​。比如软件配置,创建​​exeConfig.state.ts​​​在​​store​​​中,这样在外部​​.vue​​文件中进行更改也能去更新视图。

import { reactive, watch } from 'vue';  

const exeConfigLocal = localStorage.getItem('exeConfig');


export let exeConfig = reactive({

1000,

...

switchStatus: {

/**

* 开启提示

*/

true

}

});


if (exeConfigLocal) {

exeConfig = reactive(JSON.parse(exeConfigLocal));

} else {

'exeConfig', JSON.stringify(exeConfig));

}


watch(exeConfig, e => {

'exeConfig', JSON.stringify(e));

});

vuex番外

vuex的使用是直接在项目中引入​​useStore​​​,但是是没有​​state​​​类型提示的,所以需要手动去推导​​state​​​的内容。这里的​​S​​​代表​​state​​​的类型,然后传入​​vuex​​​中​​export declare class Store<S> { readonly state: S; }​

想要查看某个值的类型的时候在vscode中​​ctrl+鼠标左键​​点进去就能看到,或者鼠标悬浮该值

 

declare module 'vuex' {

type StoreStateType = typeof store.state;

export function useStore<S = StoreStateType>(): Store<S>;

}

index.vue

这里在防止没有数据的时候页面空白闪烁,使用一个图片和列表区域去控制显示,拿到数据之后就显示列表,否则就只显示图片。

在这个页面对​​editor.vue​​​进行了​​createNewNote​​​创建便笺笔记、​​updateNoteItem_className​​​更新类型更改颜色、​​updateNoteItem_content​​​更新内容、​​removeEmptyNoteItem​​​删除、​​whetherToOpen​​​是否打开(在editor中需要打开列表的操作)​​通信操作​

以及对软件失去焦点进行监听​​getCurrentWindow().on('blur')​​,如果失去焦点,那么在右键弹窗打开的情况下进行去除。

​deleteActiveItem_{uid}​​​删除便笺笔记内容,这里在​​component​​​封装了一个弹窗组件​​messageBox​​​,然后在弹窗的时候提示​​是否删除​​​和​​不在询问​​的功能操作。

????如果​​勾选不在询问​​​,那么在​​store=>exeConfig.state​​中做相应的更改

这里在设置中会进行详细的介绍

开发一个vue3右键弹窗插件

vue3也发布了有段时间了,虽然还没有完全稳定,但后面的时间出现的插件开发方式说不定也会多起来。插件开发思路

定义好插件类型,比如需要哪些属性​​MenuOptions​

判断是否需要在触发之后立即关闭还是继续显示

在插入​​body​​时判断是否存在,否则就删除重新显示

 

import { createApp, h, App, VNode, RendererElement, RendererNode } from 'vue';

import './index.css';

type ClassName = string | string[];


interface MenuOptions {

/**

* 文本

*/

string;


/**

* 是否在使用后就关闭

*/

once?: boolean;


/**

* 单独的样式名

*/

className?: ClassName;


/**

* 图标样式名

*/

iconName?: ClassName;


/**

* 函数

*/

handler(): void;

}


type RenderVNode = VNode<

RendererNode,

RendererElement,

{

string]: any;

}

>;


class CreateRightClick {

rightClickEl?: App<Element>;

rightClickElBox?: HTMLDivElement | null;


constructor() {

this.removeRightClickHandler();

}


/**

* 渲染dom

* @param menu

*/

render(menu: MenuOptions[]): RenderVNode {

return h(

'ul',

{

'right-click-menu-list']

},

[

map(item => {

return h(

'li',

{

class: item.className,

// vue3.x中简化了render,直接onclick即可,onClick也可以

onclick: () => {

// 如果只是一次,那么点击之后直接关闭

if (item.once) this.remove();

return item.handler();

}

},

[

// icon

'i', {

class: item.iconName

}),

// text

h(

'span',

{

'right-click-menu-text'

},

item.text

)

]

);

})

]

);

}


/**

* 给右键的样式

* @param event 鼠标事件

*/

len: number): void {

if (!this.rightClickElBox) return;

`${len * 36}px`;

const { clientX, clientY } = event;

const { innerWidth, innerHeight } = window;

const { clientWidth, clientHeight } = this.rightClickElBox;

`height: ${len * 36}px;opacity: 1;transition: all 0.2s;`;

if (clientX + clientWidth < innerWidth) {

`left: ${clientX + 2}px;`;

else {

`left: ${clientX - clientWidth}px;`;

}

if (clientY + clientHeight < innerHeight) {

`top: ${clientY + 2}px;`;

else {

`top: ${clientY - clientHeight}px;`;

}

`height: ${len * 36}px`;

this.rightClickElBox.style.cssText = cssText;

}


remove(): void {

if (this.rightClickElBox) {

this.rightClickElBox.remove();

this.rightClickElBox = null;

}

}


removeRightClickHandler(): void {

'click', e => {

if (this.rightClickElBox) {

const currentEl = e.target as Node;

if (!currentEl || !this.rightClickElBox.contains(currentEl)) {

this.remove();

}

}

});

}


/**

* 鼠标右键悬浮

* @param event

* @param menu

*/

useRightClick = (event: MouseEvent, menu: MenuOptions[] = []): void => {
this.remove();

if (!this.rightClickElBox || !this.rightClickEl) {

const createRender = this.render(menu);

this.rightClickEl = createApp({

setup() {

return () => createRender;

}

});

}

if (!this.rightClickElBox) {

'div');

'rightClick';

document.body.appendChild(this.rightClickElBox);

'#rightClick');

}

this.setRightClickElStyle(event, menu.length);

};

}


export default CreateRightClick;

右键弹窗插件配合electron打开、删除便笺笔记

在使用的时候直接引入即可,如在​​index.vue​​​中使用创建右键的方式,这里需要额外的说明一下,打开窗口需要进行一个窗口通信判断,​​ipcMain​​​需要从​​remote​​中获取

每个便笺笔记都有一个​​uid​​​,也就是​​utils​​中生成的

每个在打开笔记的时候也就是编辑页,需要判断​​该uid的窗口​​是否已经打开

窗口之间用​​ipcRenderer​​​和​​ipcMain​​去通信

判断通信失败的方法,用一个定时器来延时判断是否​​通信成功​​,因为没有判断通信失败的方法

​countFlag = true​​​就说明打开窗口,​​countFlag = false​​说明没有打开窗口

​ipcRenderer​​​和​​ipcMain​​通信

????​​on​​​是一直处于通信状态,​​once​​是通信一次之后就关闭了

 

// countFlag是一个状态来标记收到东西没

// index问editor打开了没有

ipcRenderer.send('你好')


// 这时候editor收到消息了

remote.ipcMain.on('你好', e => {

// 收到消息后显示

remote.getCurrentWindow().show();

// 然后回index消息

'你好我在的');

});


// index在等editor消息

ipcRenderer.on('你好我在的', () => {

// 好的我收到了

true;

});


// 如果没收到消息,那标记一直是false,根据定时器来做相应操作
右键弹窗的使用
????这里的打开笔记功能会把选中的笔记​​uid​​​当作一个​​query​​​参数跳转到​​编辑页​​

import CreateRightClick from '@/components/rightClick';

...

const rightClick = new CreateRightClick();

...

const contextMenu = (event: MouseEvent, uid: string) => {

rightClick.useRightClick(event, [

{

'打开笔记',

true,

'iconfont', 'icon-newopen'],

handler: () => {

false;

`${uid}_toOpen`);

`get_${uid}_toOpen`, () => {

true;

});

setTimeout(() => {

if (!countFlag) openEditorWindow(uid);

100);

}

},

{

'删除笔记',

true,

'iconfont', 'icon-delete'],

handler: () => {

deleteCurrentUid.value = uid;

if (exeConfig.switchStatus.deleteTip) {

true;

else {

// 根据弹窗组件进行判断

onConfirm();

}

}

}

]);

};

...

editor.vue重点

这个​​editor.vue是view/文件夹下​​​的,以下对本页面统称编辑页,更好区分​​editor组件​​和页面 开发思路

打开​​新增​​​编辑页窗口时就​​生成uid​​​并向数据库​​nedb​​​添加数据,并向列表页通信​​ipcRenderer.send('createNewNote', res)​

需要使用富文本,能实时处理格式​​document.execCommand​

页面加载完时进行聚焦​​createRange​​​和​​getSelection​

对列表页实时更新,编辑的时候防抖函数​​debounce​​​可以控制输入更新,这个时间在设置是​​可控​​的

​图钉固定​​​在​​header.vue​​已经说明

​选项功能​​能选择颜色,打开列表之后需要判断是否已经打开列表窗口

在​​点击关闭​​​的时候需要​​删除​​​数据库本条数据,如果没有输入内容就删除数据库​​uid​​​内容并向列表页通信​​removeEmptyNoteItem​

在列表页时关闭本窗口的一个通信​​deleteActiveItem_{uid}​

列表页​​打开笔记​​​时,携带​​uid​​​,在编辑页根据是否携带​​uid​​查询该条数据库内容

富文本编辑做成了一个单独的组件,使​​编辑页​​的代码不会太臃肿

document.execCommand文档 developer.mozilla.org/zh-CN/docs/…[6]

首先在编辑页对路由进行判断是否存在,如果不存在就创建,否则就查询并把查询到的笔记传给​​editor组件​

<Editor :content="editContent" :className="currentBgClassName" @on-input="changeEditContent" />

 

const routeUid = useRoute().query.uid as string;

if (routeUid) {

// 查询

uid.value = routeUid;

getCurUidItem(routeUid);

} else {

// 生成uid并把uid放到地址栏

const uuidString = uuid();

uid.value = uuidString;

useRouter().push({

query: {

uid: uuidString

}

});

// 插入数据库并向列表页通信

...

}

富文本聚焦和ref获取dom节点

原理是通过​​getSelection​​​选择光标和​​createRange​​​文本范围两个方法,选中​​富文本节点​​。获取

 

import { defineComponent, onMounted, ref, Ref, watch } from 'vue';

...

// setup中创建一个和<div ref="editor">同名的变量,就可以直接拿到dom节点,一定要return!!!

let editor: Ref<HTMLDivElement | null> = ref(null);


onMounted(() => {

focus();

});


const focus = () => {

const range = document.createRange();

range.selectNodeContents(editor.value as HTMLDivElement);

range.collapse(false);

const selecton = window.getSelection() as Selection;

selecton.removeAllRanges();

range);

};


...

return {

editor,

...

}

editor组件的父传子以及watch监听

????这里需要注意的是因为在父组件传给子组件,然后子组件进行更新一次会导致富文本无法撤回,相当于重新给富文本组件赋值渲染了一次,因此这里就只用一次​​props.content​

 

export default defineComponent({

props: {

content: String,

className: String

},

'on-input'],

setup(props, { emit }) {

let editor: Ref<HTMLDivElement | null> = ref(null);

const bottomIcons = editorIcons;

const editorContent: Ref<string | undefined> = ref('');


// 监听从父组件传来的内容,因为是从数据库查询所以会有一定的延迟

watch(props, nv => {

if (!editorContent.value) {

// 只赋值一次

editorContent.value = nv.content;

}

});

}

});

editor组件的防抖子传父

​exeConfig.syncDelay​​​是设置里面的一个时间,可以动态根据这个时间来调节储存进数据库和列表的更新,获取富文本组件的​​html​​然后储存到数据库并传到列表页更新

 

const changeEditorContent = debounce((e: InputEvent) => {

const editorHtml = (e.target as Element).innerHTML;

'on-input', editorHtml);

}, exeConfig.syncDelay);

富文本组件的粘贴纯文本

vue自带的粘贴事件,​​@paste​​​获取到剪切板的内容,然后获取文本格式的内容​​e.clipboardData?.getData('text/plain')​​并插入富文本

 

const paste = (e: ClipboardEvent) => {

const pasteText = e.clipboardData?.getData('text/plain');

console.log(pasteText);

'insertText', false, pasteText);

};

(????????????额外的)​​getCurrentInstance​​选择dom方式

官方和网上的例子是这样:

<div ref="editor"></div>

 

setup(props, { emit }) {

let editor = ref(null);

return { editor }

})

直接获取​​dom节点​​​,但其实不管这个​​editor​​​是什么,只要从​​setup​​​中​​return​​​,就会直接标记​​instance​​​变量名,强行把内容替换成​​dom节点​​,甚至不用定义可以看看下面例子

<div ref="test"></div>

import { defineComponent, getCurrentInstance, onMounted } from 'vue'; 
...

setup(props, { emit }) {

onMounted(() => {

console.log(getCurrentInstance().refs);

// 得到的是test dom以及其他定义的节点

});

return {

''

}

})

但是为了规范还是使用下面这样

<div ref="dom"></div>

 

const dom = ref(null);

return {

dom

};
setting.vue

这里的话需要用到​​exeConfig.state.ts​​​的配置信息,包括封装的​​input​​​、​​switch​​​、​​tick​​组件

前端实战:electron+vue3+ts开发桌面端便签应用_Vue_03

在这里说明一下,​​自动缩小​​​、​​靠边隐藏​​​和​​同步设置​​暂时还没有开发的

​自动缩小​​: 编辑页失去焦点时自动最小化,获得焦点重新打开

​靠边隐藏​​: 把软件拖动到屏幕边缘时,自动隐藏到边上,类似QQ那样的功能

​同步设置​​​: 打算使用​​nestjs​​​做同步服务,后面​​可能​​会出一篇有关的文章,但是功能一定会做的

directives自定义指令

根据是否开启提示的设置写的一个方便控制的功能,这个功能是首先获取初始化的节点高度,放置在​​dom​​​的自定义数据上面​​data-xx​​,然后下次显示的时候再重新获取赋值css显示,当然这里也是用了一个过渡效果

使用方法

<div v-tip="switch"></div>

 

export default defineComponent({

components: {

Tick,

Input,

Switch

},

directives: {

tip(el, { value }) {

const { height } = el.dataset;

// 储存最初的高度

if (!height && height !== '0') {

el.dataset.height = el.clientHeight;

}

const clientHeight = height || el.clientHeight;

'transition: all 0.4s;';

if (value) {

`height: ${clientHeight}px;opacity: 1;`;

else {

'height: 0;opacity: 0;overflow: hidden;';

}

el.style.cssText = cssText;

}

}

})

原生点击复制

原理是先隐藏一个​​input​​​标签,然后点击的之后选择它的内容,在使用​​document.execCommand('copy')​​复制就可以

 

<a @click="copyEmail">复制</a>

<input class="hide-input" ref="mailInput" type="text" value="heiyehk@foxmail.com" />

const mailInput: Ref<HTMLInputElement | null> = ref(null);

const copyEmail = () => {

if (copyStatus.value) return;

true;

select();

'copy');

};


return {

copyEmail

...

}

electron打开文件夹和打开默认浏览器链接

打开文件夹使用​​shell​​这个方法

 
import { remote } from 'electron';

  

 
remote.shell.showItemInFolder('D:'); 

打开默认浏览器链接

 
import { remote } from 'electron';

  

 
remote.shell.openExternal('www.github.com'); 

错误收集

收集一些使用中的错误,并使用​​message​​​插件进行弹窗提示,软件宽高和屏幕宽高只是辅助信息。碰到这些错误之后,在软件安装位置输出一个​​inoteError.log​​的错误日志文件,然后在设置中判断文件是否存在,存在就打开目录选中。

版本号

时间

错误

electron版本

Windows信息

软件宽高信息

屏幕宽高

比如这个框中的才是主要的信息

vue3 errorHandler

​main.ts​​​我们需要进行一下改造,并使用​​errorHandler​​进行全局的错误监控

 
import { createApp } from 'vue';

 
import App from './App.vue';

 
import router from './router';

 
import outputErrorLog from '@/utils/errorLog';

  

 
const app = createApp(App);

  

 
// 错误收集方法

 
app.config.errorHandler = outputErrorLog;

  

 
app.use(router).mount('#app'); 

errorLog.ts封装对Error类型输出为日志文件

获取软件安装位置

​remote.app.getPath('exe')​​​获取软件安装路径,包含​​软件名.exe​

export const errorLogPath = path.join(remote.app.getPath('exe'), '../inoteError.log');

输出日志文件

​flag: a​​​代表末尾追加,确保每一行一个错误加上换行符​​'\n'​

fs.writeFileSync(errorLogPath, JSON.stringify(errorLog) + '\n', { flag: 'a' });

​errorLog.ts​​​的封装,对​​Error​​类型的封装

 

import { ComponentPublicInstance } from 'vue';

import dayjs from 'dayjs';

import fs from 'fs-extra';

import os from 'os';

import path from 'path';

import useMessage from '@/components/message';


function getShortStack(stack?: string): string {

const splitStack = stack?.split('\n ');

if (!splitStack) return '';

const newStack: string[] = [];

for (const line of splitStack) {

// 其他信息

if (line.includes('bundler')) continue;


// 只保留错误文件信息

if (line.includes('?!.')) {

''));

else {

newStack.push(line);

}

}

// 转换string

return newStack.join('\n ');

}


export const errorLogPath = path.join(remote.app.getPath('exe'), '../inoteError.log');


export default function(error: unknown, vm: ComponentPublicInstance | null, info: string): void {

const { message, stack } = error as Error;

const { electron, chrome, node, v8 } = process.versions;

const { outerWidth, outerHeight, innerWidth, innerHeight } = window;

const { width, height } = window.screen;


// 报错信息

const errorInfo = {

errorInfo: info,

errorMessage: message,

errorStack: getShortStack(stack)

};


// electron

const electronInfo = { electron, chrome, node, v8 };


// 浏览器窗口信息

const browserInfo = { outerWidth, outerHeight, innerWidth, innerHeight };


const errorLog = {

versions: remote.app.getVersion(),

'YYYY-MM-DD HH:mm'),

error: errorInfo,

electron: electronInfo,

window: {

type: os.type(),

platform: os.platform()

},

browser: browserInfo,

screen: { width, height }

};


'程序出现异常', 'error');


if (process.env.NODE_ENV === 'production') {

'\n', { flag: 'a' });

else {

console.log(error);

console.log(errorInfo.errorStack);

}

}

使用此方法后封装的结果是这样的,​​message​​​插件具体看​​component​

这个是之前的错误日志文件

前端实战:electron+vue3+ts开发桌面端便签应用_typescript_04

获取electron版本等信息

const appInfo = process.versions;

打包

这个倒是没什么好讲的了,主要还是在​​vue.config.js​​​文件中进行配置一下,然后使用命令​​yarn electron:build​​即可,当然了,还有一个打包前清空的旧的打包文件夹的脚本

deleteBuild.js

打包清空​​dist_electron​​​旧的打包内容,因为​​eslint​​​的原因,这里就用​​eslint-disable​​关掉了几个

原理就是先获取​​vue.config.js​​​中的打包配置,如果重新配置了路径​​directories.output​​就动态去清空

const path = require('path');

const pluginOptions = require('../../vue.config').pluginOptions;


let directories = pluginOptions.electronBuilder.builderOptions.directories;

let buildPath = '';


if (directories && directories.output) {

buildPath = directories.output;

}


// 删除作用只用于删除打包前的buildPath || dist_electron

// dist_electron是默认打包文件夹

rm(path.join(__dirname, `../../${buildPath || 'dist_electron'}`), () => {});

以上就是本篇主要开发内容了,欢迎支持我的开源项目electron-vue3-inote。

相关资料

github地址: https://github.com/heiyehk/electron-vue3-inote


❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

或者分享转发,让更多的人也能看到这篇内容

关注公众号【趣谈前端】,定期分享 工程化 / 可视化 / 低代码 / 优秀开源。




举报

相关推荐

0 条评论