使用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的对象,极大程度地方便我们查询数据。当然有些场景下我们也需要增加、减少数据,这时候得益于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

你可能感兴趣的:(使用electron + React开发MD编辑器)