【electron+vue3+ts實戰便箋exe】二、electron+vue3開發內容

heiyehk發表於2020-12-28

不要讓自己的上限成為你的底線

本來以為有萬字的。。沒想到才堪堪近6000字。為了水文的嫌疑,只挑了重點的地方講,比如component內的元件就挑了右鍵彈窗去說明,建議在看本文的時候邊檢視專案,有不懂的可以在下方評論,謝謝。

github
github: https://github.com/heiyehk/electron-vue3-inote
包下載
release: https://github.com/heiyehk/electron-vue3-inote/releases

接上篇配置篇 【electron+vue3+ts實戰便箋exe】一、搭建框架配置,這裡更新了一下vue3的版本3.0.4,本篇文章只講開發內容,主要還是vue3方面,長文警告。ps:smartblue這個主題好好看。。。

router

增加meta中的title屬性,顯示在軟體上方頭部

import { createRouter, createWebHashHistory } from 'vue-router';
import { RouteRecordRaw } from 'vue-router';
import main from '../views/main.vue';

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'main',
    component: main,
    children: [
      {
        path: '/',
        name: 'index',
        component: () => import('../views/index.vue'),
        meta: {
          title: 'I便箋'
        }
      },
      {
        path: '/editor',
        name: 'editor',
        component: () => import('../views/editor.vue'),
        meta: {
          title: ''
        }
      },
      {
        path: '/setting',
        name: 'setting',
        component: () => import('../views/setting.vue'),
        meta: {
          title: '設定'
        }
      }
    ]
  }
];

const router = createRouter({
  history: createWebHashHistory(process.env.BASE_URL),
  routes
});

export default router;

utils

/* eslint-disable @typescript-eslint/ban-types */

import { winURL } from '@/config';
import { BrowserWindow, remote } from 'electron';

type FunctionalControl = (this: any, fn: any, delay?: number) => (...args: any) => void;
type DebounceEvent = FunctionalControl;
type ThrottleEvent = FunctionalControl;

// 防抖函式
export const debounce: DebounceEvent = function(fn, delay = 1000) {
  let timer: NodeJS.Timeout | null = null;
  return (...args: any) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
};

// 節流函式
export const throttle: ThrottleEvent = function(fn, delay = 500) {
  let flag = true;
  return (...args: any) => {
    if (!flag) return;
    flag = false;
    setTimeout(() => {
      fn.apply(this, args);
      flag = true;
    }, delay);
  };
};

// 建立視窗
export const createBrowserWindow = (bwopt = {}, url = '/', devTools = true): BrowserWindow | null => {
  let childrenWindow: BrowserWindow | null;
  childrenWindow = new remote.BrowserWindow(bwopt);

  if (process.env.NODE_ENV === 'development' && devTools) {
    childrenWindow.webContents.openDevTools();
  }
  childrenWindow.loadURL(`${winURL}/#${url}`);
  childrenWindow.on('closed', () => {
    childrenWindow = null;
  });
  return childrenWindow;
};

// 過渡關閉視窗
export const transitCloseWindow = (): void => {
  document.querySelector('#app')?.classList.remove('app-show');
  document.querySelector('#app')?.classList.add('app-hide');
  remote.getCurrentWindow().close();
};

// uuid
export const uuid = (): string => {
  const S4 = () => {
    return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
  };
  return S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4();
};

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 }">
  <transition name="main-fade">
    <div class="transition" :key="routeName">
      <keep-alive>
        <component :is="Component" />
      </keep-alive>
    </div>
  </transition>
</router-view>

然後就是ts部分了,使用vue3的寫法去寫,script標籤注意需要寫上lang="ts"代表是ts語法。router的寫法也不一樣,雖然在vue3中還能寫vue2的格式,但是不推薦使用。這裡是獲取routename屬性,來進行一個頁面過渡的效果。

<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常用的emitprops都在content中。

?這裡需要注意的是,使用propsemit需要先定義,才能去使用,並且會在vscode中直接呼叫時輔助彈窗顯示

props示例

emit示例

export default defineComponent({
  props: {
    test: String
  },
  emits: ['option-click', 'on-close'],
  // 如果只用emit的話可以使用es6解構
  // 如:setup(props, { emit })
  setup(props, content) {
    console.log(props.test, content.emit('option-click'));
  }
})

electron開啟視窗

import { browserWindowOption } from '@/config';
import { createBrowserWindow, transitCloseWindow } from '@/utils';
...
const editorWinOptions = browserWindowOption('editor');
// 開啟新視窗
const openNewWindow = () => {
  createBrowserWindow(editorWinOptions, '/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) {
    currentWindow.setAlwaysOnTop(false);
    isAlwaysOnTop.value = false;
  } else {
    currentWindow.setAlwaysOnTop(true);
    isAlwaysOnTop.value = true;
  }
};

electron關閉視窗

這裡是在utils封裝了通過對dom的樣式名操作,達到一個退出的過渡效果,然後再關閉。

// 過渡關閉視窗
export const transitCloseWindow = (): void => {
  document.querySelector('#app')?.classList.remove('app-show');
  document.querySelector('#app')?.classList.add('app-hide');
  remote.getCurrentWindow().close();
};

noteDb資料庫

安裝nedb資料庫,文件: https://www.w3cschool.cn/nedbintro/nedbintro-t9z327mh.html

yarn add nedb @types/nedb

資料儲存在nedb中,定義欄位,並在根目錄的shims-vue.d.ts加入型別

/**
 * 儲存資料庫的
 */
interface DBNotes {
  className: string; // 樣式名
  content: string; // 內容
  readonly createdAt: Date; // 建立時間,這個時間是nedb自動生成的
  readonly uid: string; // uid,utils中的方法生成
  readonly updatedAt: Date; // update,自動建立的
  readonly _id: string; // 自動建立的
}

對nedb的封裝

自我感覺這裡寫的有點爛。。。勿噴,持續學習中

這裡的QueryDBshims-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
       * 注意所有命令操作只有在資料載入完成後才會被執行
       */
      autoload: true,
      filename: this.dbPath,
      timestampData: 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();

使用refreactive代替vuex,並用watch監聽

建立exeConfig.state.ts

refreactive引入的方式就可以達到vuexstate效果,這樣就可以完全捨棄掉vuex。比如軟體配置,建立exeConfig.state.tsstore中,這樣在外部.vue檔案中進行更改也能去更新檢視。

import { reactive, watch } from 'vue';

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

export let exeConfig = reactive({
  syncDelay: 1000,
  ...
  switchStatus: {
    /**
     * 開啟提示
     */
    textTip: true
  }
});

if (exeConfigLocal) {
  exeConfig = reactive(JSON.parse(exeConfigLocal));
} else {
  localStorage.setItem('exeConfig', JSON.stringify(exeConfig));
}

watch(exeConfig, e => {
  localStorage.setItem('exeConfig', JSON.stringify(e));
});

vuex番外

vuex的使用是直接在專案中引入useStore,但是是沒有state型別提示的,所以需要手動去推導state的內容。這裡的S代表state的型別,然後傳入vuexexport 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也釋出了有段時間了,雖然還沒有完全穩定,但後面的時間出現的外掛開發方式說不定也會多起來。
外掛開發思路

  1. 定義好外掛型別,比如需要哪些屬性MenuOptions
  2. 判斷是否需要在觸發之後立即關閉還是繼續顯示
  3. 在插入body時判斷是否存在,否則就刪除重新顯示
import { createApp, h, App, VNode, RendererElement, RendererNode } from 'vue';
import './index.css';

type ClassName = string | string[];

interface MenuOptions {
  /**
   * 文字
   */
  text: string;

  /**
   * 是否在使用後就關閉
   */
  once?: boolean;

  /**
   * 單獨的樣式名
   */
  className?: ClassName;

  /**
   * 圖示樣式名
   */
  iconName?: ClassName;

  /**
   * 函式
   */
  handler(): void;
}

type RenderVNode = VNode<
  RendererNode,
  RendererElement,
  {
    [key: string]: any;
  }
>;

class CreateRightClick {
  rightClickEl?: App<Element>;
  rightClickElBox?: HTMLDivElement | null;

  constructor() {
    this.removeRightClickHandler();
  }

  /**
   * 渲染dom
   * @param menu
   */
  render(menu: MenuOptions[]): RenderVNode {
    return h(
      'ul',
      {
        class: ['right-click-menu-list']
      },
      [
        ...menu.map(item => {
          return h(
            'li',
            {
              class: item.className,
              // vue3.x中簡化了render,直接onclick即可,onClick也可以
              onclick: () => {
                // 如果只是一次,那麼點選之後直接關閉
                if (item.once) this.remove();
                return item.handler();
              }
            },
            [
              // icon
              h('i', {
                class: item.iconName
              }),
              // text
              h(
                'span',
                {
                  class: 'right-click-menu-text'
                },
                item.text
              )
            ]
          );
        })
      ]
    );
  }

  /**
   * 給右鍵的樣式
   * @param event 滑鼠事件
   */
  setRightClickElStyle(event: MouseEvent, len: number): void {
    if (!this.rightClickElBox) return;
    this.rightClickElBox.style.height = `${len * 36}px`;
    const { clientX, clientY } = event;
    const { innerWidth, innerHeight } = window;
    const { clientWidth, clientHeight } = this.rightClickElBox;
    let cssText = `height: ${len * 36}px;opacity: 1;transition: all 0.2s;`;
    if (clientX + clientWidth < innerWidth) {
      cssText += `left: ${clientX + 2}px;`;
    } else {
      cssText += `left: ${clientX - clientWidth}px;`;
    }
    if (clientY + clientHeight < innerHeight) {
      cssText += `top: ${clientY + 2}px;`;
    } else {
      cssText += `top: ${clientY - clientHeight}px;`;
    }
    cssText += `height: ${len * 36}px`;
    this.rightClickElBox.style.cssText = cssText;
  }

  remove(): void {
    if (this.rightClickElBox) {
      this.rightClickElBox.remove();
      this.rightClickElBox = null;
    }
  }

  removeRightClickHandler(): void {
    document.addEventListener('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) {
      this.rightClickElBox = document.createElement('div');
      this.rightClickElBox.id = 'rightClick';
      document.body.appendChild(this.rightClickElBox);
      this.rightClickEl.mount('#rightClick');
    }
    this.setRightClickElStyle(event, menu.length);
  };
}

export default CreateRightClick;

右鍵彈窗外掛配合electron開啟、刪除便箋筆記

在使用的時候直接引入即可,如在index.vue中使用建立右鍵的方式,這裡需要額外的說明一下,開啟視窗需要進行一個視窗通訊判斷,ipcMain需要從remote中獲取

  • 每個便箋筆記都有一個uid,也就是utils中生成的
  • 每個在開啟筆記的時候也就是編輯頁,需要判斷該uid的視窗是否已經開啟
  • 視窗之間用ipcRendereripcMain去通訊
  • 判斷通訊失敗的方法,用一個定時器來延時判斷是否通訊成功,因為沒有判斷通訊失敗的方法
  • countFlag = true就說明開啟視窗,countFlag = false說明沒有開啟視窗

ipcRendereripcMain通訊

?on是一直處於通訊狀態,once是通訊一次之後就關閉了

// countFlag是一個狀態來標記收到東西沒
// index問editor開啟了沒有
ipcRenderer.send('你好')

// 這時候editor收到訊息了
remote.ipcMain.on('你好', e => {
  // 收到訊息後顯示
  remote.getCurrentWindow().show();
  // 然後回index訊息
  e.sender.send('你好我在的');
});

// index在等editor訊息
ipcRenderer.on('你好我在的', () => {
  // 好的我收到了
  countFlag = true;
});

// 如果沒收到訊息,那標記一直是false,根據定時器來做相應操作

右鍵彈窗的使用

?這裡的開啟筆記功能會把選中的筆記uid當作一個query引數跳轉到編輯頁

import CreateRightClick from '@/components/rightClick';
...
const rightClick = new CreateRightClick();
...
const contextMenu = (event: MouseEvent, uid: string) => {
  rightClick.useRightClick(event, [
    {
      text: '開啟筆記',
      once: true,
      iconName: ['iconfont', 'icon-newopen'],
      handler: () => {
        let countFlag = false;
        ipcRenderer.send(`${uid}_toOpen`);
        ipcRenderer.on(`get_${uid}_toOpen`, () => {
          countFlag = true;
        });
        setTimeout(() => {
          if (!countFlag) openEditorWindow(uid);
        }, 100);
      }
    },
    {
      text: '刪除筆記',
      once: true,
      iconName: ['iconfont', 'icon-delete'],
      handler: () => {
        deleteCurrentUid.value = uid;
        if (exeConfig.switchStatus.deleteTip) {
          deleteMessageShow.value = true;
        } else {
          // 根據彈窗元件進行判斷
          onConfirm();
        }
      }
    }
  ]);
};
...

editor.vue重點

這個editor.vue是view/資料夾下的,以下對本頁面統稱編輯頁,更好區分editor元件和頁面
開發思路

  • 開啟新增編輯頁視窗時就生成uid並向資料庫nedb新增資料,並向列表頁通訊ipcRenderer.send('createNewNote', res)
  • 需要使用富文字,能實時處理格式document.execCommand
  • 頁面載入完時進行聚焦createRangegetSelection
  • 對列表頁實時更新,編輯的時候防抖函式debounce可以控制輸入更新,這個時間在設定是可控
  • 圖釘固定header.vue已經說明
  • 選項功能能選擇顏色,開啟列表之後需要判斷是否已經開啟列表視窗
  • 點選關閉的時候需要刪除資料庫本條資料,如果沒有輸入內容就刪除資料庫uid內容並向列表頁通訊removeEmptyNoteItem
  • 在列表頁時關閉本視窗的一個通訊deleteActiveItem_{uid}
  • 列表頁開啟筆記時,攜帶uid,在編輯頁根據是否攜帶uid查詢該條資料庫內容

富文字編輯做成了一個單獨的元件,使編輯頁的程式碼不會太臃腫

document.execCommand文件
https://developer.mozilla.org/zh-CN/docs/Web/API/Document/execCommand

首先在編輯頁對路由進行判斷是否存在,如果不存在就建立,否則就查詢並把查詢到的筆記傳給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();
  selecton.addRange(range);
};

...
return {
  editor,
  ...
}

editor元件的父傳子以及watch監聽

?這裡需要注意的是因為在父元件傳給子元件,然後子元件進行更新一次會導致富文字無法撤回,相當於重新給富文字元件賦值渲染了一次,因此這裡就只用一次props.content

export default defineComponent({
  props: {
    content: String,
    className: String
  },
  emits: ['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;
  emit('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);
  document.execCommand('insertText', false, pasteText);
};

(???額外的)getCurrentInstance選擇dom方式

官方和網上的例子是這樣:

<div ref="editor"></div>
setup(props, { emit }) {
  let editor = ref(null);
  return { editor }
})

直接獲取dom節點,但其實不管這個editor是什麼,只要從setupreturn,就會直接標記instance變數名,強行把內容替換成dom節點,甚至不用定義可以看看下面例子

<div ref="test"></div>
import { defineComponent, getCurrentInstance, onMounted } from 'vue';
...
setup(props, { emit }) {
  onMounted(() => {
    console.log(getCurrentInstance().refs);
    // 得到的是test dom以及其他定義的節點
  });
  return {
    test: ''
  }
})

但是為了規範還是使用下面這樣

<div ref="dom"></div>
const dom = ref(null);

return {
  dom
};

此處推廣一下一個98年大佬的vue3原始碼解析github: https://github.com/Kingbultsea/vue3-analysis

setting.vue

這裡的話需要用到exeConfig.state.ts的配置資訊,包括封裝的inputswitchtick元件

在這裡說明一下,自動縮小靠邊隱藏同步設定暫時還沒有開發的

  • 自動縮小: 編輯頁失去焦點時自動最小化,獲得焦點重新開啟
  • 靠邊隱藏: 把軟體拖動到螢幕邊緣時,自動隱藏到邊上,類似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;
      let cssText = 'transition: all 0.4s;';
      if (value) {
        cssText += `height: ${clientHeight}px;opacity: 1;`;
      } else {
        cssText += '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;
  copyStatus.value = true;
  mailInput.value?.select();
  document.execCommand('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 { remote } from 'electron';
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('?!.')) {
      newStack.push(line.replace(/webpack-internal:\/\/\/\.\/node_modules\/.+\?!/, ''));
    } 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(),
    date: dayjs().format('YYYY-MM-DD HH:mm'),
    error: errorInfo,
    electron: electronInfo,
    window: {
      type: os.type(),
      platform: os.platform()
    },
    browser: browserInfo,
    screen: { width, height }
  };

  useMessage('程式出現異常', 'error');

  if (process.env.NODE_ENV === 'production') {
    fs.writeFileSync(errorLogPath, JSON.stringify(errorLog) + '\n', { flag: 'a' });
  } else {
    console.log(error);
    console.log(errorInfo.errorStack);
  }
}

使用此方法後封裝的結果是這樣的,message外掛具體看component

這個是之前的錯誤日誌檔案

獲取electron版本等資訊

const appInfo = process.versions;

打包

這個倒是沒什麼好講的了,主要還是在vue.config.js檔案中進行配置一下,然後使用命令yarn electron:build即可,當然了,還有一個打包前清空的舊的打包資料夾的指令碼

deleteBuild.js

打包清空dist_electron舊的打包內容,因為eslint的原因,這裡就用eslint-disable關掉了幾個

原理就是先獲取vue.config.js中的打包配置,如果重新配置了路徑directories.output就動態去清空

const rm = require('rimraf');
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'}`), () => {});

結尾

以上就是本篇主要開發內容了,有錯誤的地方可以在下方評論,我會及時改正。有不理解或者缺少的地方也可以在下方評論。順便,能讓我上個推薦嗎= =

人學始知道不學非自然

如果有不好的地方勿噴,及時評論及時改正,謝謝!內容有點多。

相關文章