使用electron + React开发MD编辑器

开发之前——React 哲学

  • 将设计好的UI划分为组件层级
  • 创建应用的静态版本

搭建开发环境

create-react-app脚手架创建react项目

npx create-react-app llr-note 

在项目中安装electron,添加electron的主进程文件main.js

yarn add electron

npm insatall electron --save-dev

配置npm run dev的启动方式,同时启动react项目与electron,缺点:log混在一起,react进程与electron进程无法同时关闭

{
  "name": "llr-note",
  "version": "0.1.0",
  "private": true,
  "main": "main.js",
  "dependencies": {
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.3.2",
    "@testing-library/user-event": "^7.1.2",
    "electron": "^8.2.1",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react-scripts": "3.4.1"
  },
  "devDependencies": {
    "devtron": "^1.4.0",
    "electron-is-dev": "^1.2.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "ele": "electron .",
    "dev": "npm start && npm run ele"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

使用concurrently优化启动方式,修改启动方式之后可以保证react与electron单独启动,但是react启动比较慢,electron会白屏很久:

yarn add -D concurrently

npm insatall concurrently --save-dev
{
  //...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "dev": "concurrently \"npm start\" \"electron .\""
  }
}

可以使用工具wait-on来先等待react在3000端口启动,再自动启动electron:

yarn add -D wait-on

npm insatall wait-on --save-dev
{
  //修改启动配置,⚠️:引号在代码中需要添加转义符
  "scripts": {
    "dev": "concurrently "npm start" "wait-on http://localhost:3000 && electron .""
  }
}

同时启动react && electron的时候,每次浏览器都会弹出一个新的tab,这在单独开发web网站的时候很好用,但是在我们的场景下就显得很讨厌,我们可以通过cross-env配置环境变量

yarn add -D cross-env

npm insatall cross-env --save-dev
{
  //再次修改启动配置
  "scripts": {
    "dev": "concurrently "cross-env BROWSER=none npm start" "wait-on http://localhost:3000 && electron .""
  }
}

关于文件结构标准
React官方文档——项目文件结构中指出,编写React应用并没有官方推荐的文件组织结构,一般有两种:一种是按照功能,一种是按照文件的类型

值得注意的是:

  • 避免很深的文件嵌套层级
  • 不要过度思考
    • 一开始不要太多规矩,花5min搭建一个代码结构
    • 根据经验,真正开发之后,很可能会重新考虑它,不管最开始理想多么丰满

考虑State的数据类型

调整数组的数据结构——flatten化:

// [{"id": 1, title: "a"}] ====> {"1":{"id": 1, title: "a"}}
export const flattenArr = (arr) => {
  return arr.reduce((map, item) => {
    map[item.id] = item
    return map
  }, {})
}

export const objToArr = (obj) => {
  return Object.keys(obj).map(key => obj[key])
}

参考:Redux团队的建议

fs模块——操作系统文件的API

应用将md文件保存在用户的计算机中。开发中涉及到了读取文件、保存文件到本地、重命名文件、删除文件等操作,electron开发支持使用nodeJs的API,所以实现本地文件读写操作相关的需求可以通过NodeJs内置的fs - 文件系统来完成,开发时可基于fs-文件系统提供的promise API简单封装出一个工具库,详细代码如下:

const fs = window.require('fs').promises

const fileHelper = {
  readFile: (path) => {
    return fs.readFile(path, {encoding: 'utf8'})
  },
  writeFile: (path, content) => {
    return fs.writeFile(path, content,{encoding: 'utf8'})
  },
  renameFile: (path, newPath) => {
    return fs.rename(path, newPath)
  },
  deleteFile: (path) => {
    return fs.unlink(path)
  },
}

export default fileHelper

electron-store: 基于文件的存储方式

除了markdown文件的存储,我们的应用本身也会产生一些用户数据:

  • 导入到应用中的文件列表数据
  • 文件是否跟云文件同步
  • 应用的设置数据
  • 文件的创建时间、更新时间等

在考虑此类信息存储的时候可以考虑:

  • 轻量级数据库,比如SQLite、mongodb等等
  • 文件存储

因为是桌面应用,以文件的方式存储用户数据其实是比较合适的解决方案,electron生态圈为我们提供了基于文件存储数据的库electron-store,其使用方式与redis类似。

应用数据将会以文件的方式存储在用户本地目录:/Users/UserName/Library/Application Support

// 引入工具库
const Store = window.require('electron-store')

// 构造实例
const fileStore = new Store({name: 'Files Data'})

// 通过instance.set(key, value)方法更新数据
const saveFilesToStore = (files) => {
  const filesStoreObj = objToArr(files).reduce((result, file) => {
    const {id, path, title, createdAt, isSynced, updatedAt} = file
    result[id] = {id, path, title, createdAt, isSynced, updatedAt}
    return result
  }, {})
  fileStore.set('files', filesStoreObj)
}

保存应用中的的文件列表基本信息

采用electron-store,我们仅需要将文件的基本信息保存在应用数据中,markdown文档的文本内容只需要以文件的形式存在与PC系统。

// 基本信息数据结构
{
  "id": "1fc5a6bf-e6fc-44ac-863c-1124c20f6b98",
  "path": "/Users/lrliang/Documents/哈哈.md",
  "title": "哈哈",
  "isSynced": true,
  "updatedAt": 1590719500019
}

更便捷的数据读取:Flatten Array

开发过程中,我们会经常性地在数组中找查找文件,比如打开文件内容、删除文件、更新文件名,每一次查找都需要对文件数组进行一次遍历。因此推荐将数组Flatten化为一个类似Map<string, obj>的对象,极大程度地方便我们查询数据。当然有些场景下我们也需要增加、减少数据,这时候得益于JavaScript动态语言的灵活性,我们可以很方便地把obj重新转换为数组。为此,我在应用的utils文件夹中写了如下工具包:

// helper.js
export const flattenArr = (arr) => {
  return arr.reduce((map, item) => {
    map[item.id] = item
    return map
  }, {})
}

export const objToArr = (obj) => {
  return Object.keys(obj).map(key => obj[key])
}

最终,使用electron-store存储在Files Data.json中的数据长这样:

{
  "files": {
    "74702cc2-45d5-46dc-9814-4b085fbc4684": {
      "id": "74702cc2-45d5-46dc-9814-4b085fbc4684",
      "path": "/Users/lrliang/Documents/Zoom/xixi.md",
      "title": "xixi",
      "isSynced": true,
      "updatedAt": 1590157323659
    },
    "b2219ec3-af81-4595-9d65-ea4f7c307581": {
      "id": "b2219ec3-af81-4595-9d65-ea4f7c307581",
      "path": "/Users/lrliang/Documents/aha.md",
      "title": "aha",
      "isSynced": true,
      "updatedAt": 1590157398101
    },
    "1fc5a6bf-e6fc-44ac-863c-1124c20f6b98": {
      "id": "1fc5a6bf-e6fc-44ac-863c-1124c20f6b98",
      "path": "/Users/lrliang/Documents/哈哈嘿呀.md",
      "title": "哈哈嘿呀",
      "isSynced": true,
      "updatedAt": 1590719500019
    }
  }
}

封装七牛云的对象存储API

除了能够操作系统文件,我们期望可以将文件同步到云平台。这里选择了七牛云平台的对象存储来保存静态文件。七牛云为开发者提供了SDK。开发过程中可以选择我们需要的方法进行封装,这里统一暴露出promise包装的API:

// 
const qiniu = require('qiniu')
const axios = require('axios')
const fs = require('fs')

class QiniuManager {

    constructor (accessKey, secretKey, bucket) {
      // generate mac
      this.mac = new qiniu.auth.digest.Mac(accessKey, secretKey)
      this.bucket = bucket
    
      // init config
      this.config = new qiniu.conf.Config()
      this.config.zone = qiniu.zone.Zone_z0
    
      // init bucket
      this.bucketManager = new qiniu.rs.BucketManager(this.mac, this.config)
    }
  
    deleteFile (key) {
       return new Promise(((resolve, reject) => {
         this.bucketManager.delete(this.bucket, key,
           this._handleCallback(resolve, reject))
       }))
     }

    _handleCallback (resolve, reject) {
       return (respErr, respBody, respInfo) => {
         if (respErr) {
           throw respErr
         }
         if (respInfo.statusCode === 200) {
           resolve(respBody)
         } else {
           reject({
             statusCode: respInfo.statusCode,
             body: respBody,
           })
         }
       }
     }
}

将与云服务集成的逻辑统一放在electron主线程

虽然在renderer线程中我们同样可以通过remote来调用electron跟node的API,但是根据分层架构的思想,我们将与七牛云交互的模块统一写在electron主进程中(模拟web应用后端),并通过事件的方式来跟renderer线程进行交互。因此在renderer的逻辑代码中只需要处理与系统文件、应用信息的交互逻辑。
这里仅列举上传文件的代码片段:

// main.js
const { ipcMain } = require('electron')

const createCloudManager = () => {
  const accessKey = settingsStore.get('accessKey')
  const secretKey = settingsStore.get('secretKey')
  const bucketName = settingsStore.get('bucketName')
  return new QiniuManager(accessKey, secretKey, bucketName)
}

ipcMain.on('upload-file', (event, args) => {
   const manager = createCloudManager()
   manager.uploadFile(args.key, args.path).then(res => {
     console.log('上传成功', res)
     mainWidow.webContents.send('active-file-uploaded')
   }).catch(() => {
     dialog.showErrorBox('同步失败', '请检查云同步设置')
   })
 })

使用react hook在渲染进程统一监听主线程发过来的事件

因为将与云集成的代码逻辑统一放在了主进程,在主进程处理完成后对renderer进程的通知是通过IPC进程通信完成的,electron的IPC对象实际上是nodeJs中EventEmitter的一个实例。
所以renderer的react代码中需要写多个监听事件的代码逻辑。我们都知道React中编写监听事件的代码很麻烦,需要在React生命周期mount的阶段注册监听,unmount的时候移除监听。这里可以使用React Hook将事件监听的逻辑抽离出来:

import { useEffect } from 'react'

const {ipcRenderer} = window.require('electron')

const useIpcRenderer = (keyCallbackMap) => {
  useEffect(() => {
    Object.keys(keyCallbackMap).forEach(key => {
      ipcRenderer.on(key, keyCallbackMap[key])
    })

    return () => {
      Object.keys(keyCallbackMap).forEach(key => {
        ipcRenderer.removeListener(key, keyCallbackMap[key])
      })
    }
  })
}

这样,我们在renderer中就只需要:

useIpcRenderer({
    'create-new-file': () => createNewFile(MDContentTemp.default),
    'import-file': importFiles,
    'save-edit-file': saveCurrentFile,
    'active-file-uploaded': activeFileUploaded,
    'file-downloaded': activeFileDownloaded,
    'files-uploaded': filesUploaded,
    'files-downloaded': filesDownloaded,
    'loading-status': (message, status) => {setIsLoading(status)},
  })

electron应用中的原生菜单

原生应用或者说桌面应用跟web应用使用起来很大的区别是可以调用操作系统API并且有系统原生的应用菜单。electron同样为我们封装了API便于我们创建原生菜单。
我们只需要按照应用的需求编写menuTemplate,举个例子

// main.js
// import dependency
const { Menu } = require('electron')

// set up menu
const menu = Menu.buildFromTemplate(menuTemplate)
Menu.setApplicationMenu(menu)

electron应用中的上下文菜单

除了应用的原生菜单,原生应用常见的右键点击出现下拉选择框的上下文菜单毫无疑问也是支持的。
Menu的选项模版跟原生菜单类似,只不过上下文菜单需要手动在调用的地方调用menu.popup()来触发。这里我们也通过React Hook装了触发上下文菜单的逻辑:

import { useEffect, useRef } from 'react'

const {remote} = window.require('electron')
const {Menu, MenuItem} = remote

const useContextMenu = (itemArr, targetSelector, deps) => {
  const clickedElement = useRef(null)

  useEffect(() => {
    const menu = new Menu()
    itemArr.forEach(item => {
      menu.append(new MenuItem(item))
    })

    const handleContextMenu = (e) => {
      if(document.querySelector(targetSelector).contains(e.target)) {
        menu.popup({window: remote.getCurrentWindow()})
        clickedElement.current = e.target
      }
    }
    window.addEventListener('contextmenu', handleContextMenu)
    return () => {
      window.removeEventListener('contextmenu', handleContextMenu)
    }
  }, deps)

  return clickedElement
}

export default useContextMenu

这样调用就可以实现右键添加文件,支持选择文件模版:

useContextMenu([
    {
      label: '默认',
      click: () => {
        onAddFileButtonClick(MDContentTemp.default)
      },
    },
    {
      label: 'Story Card',
      click: () => {
        onAddFileButtonClick(MDContentTemp.story)
      },
    },
    {
      label: 'TB Plan',
      click: () => {
        onAddFileButtonClick(MDContentTemp.tbPlan)
      },
    }], '.add-file', [])

使用electron-builder打包应用安装包

应用的发布也是跟web应用完全不同的地方,桌面应用需要打包成安装包。electron builder帮助我们实现electron应用的打包,可以支持打包不同平台(windows、macOs、linux、docker...)的应用安装包。

我们需要做的只是安装electron-builder,并在package.json中配置它(当然也可以选择通过yml文件的方式来配置),具体打包配置可查看文档

最终源代码:https://github.com/Easy-Dojo/llr-note

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343