超简单的Vue+Electron快速开发多端在线笔记本

简介: 开发一个完整的 Electron 小项目,不依赖框架的原生 Node 如何开发 http 服务,接收处理参数、接收表单图片、创建图片静态服务,Electron 中如何顺利进行进程通信、资源目录注意事项等。

万事开头难,在文章创作前往往需要先有一个想法,当你有了模糊的想法,便可以开始研究。比方说,你想要写关于"微前端"的文章,那么,可以去调研实践微前端框架。做这些事情的同时,你可能会得到灵感,然后知道接下来怎么去写完它。

仅有想法肯定是不够的,一旦你知道了文章的走向以及讲述方式,应当记录下来,这将会是你的脉络图,同时也是流畅写下整个文章的关键。例如记录好每个节点提纲、讲述目的、技术要点、个人想法和感受等等这些基本点。这样做能防止创作时出现文思枯竭,即使你可能不觉得这样写是完美的,但你至少仍然能知道全篇基本脉络。

不管是不是计划好的,你的文章一定会有一个主题,而根据这个主题,最终你会作一番关于你对这个题目的想法和声明。工欲善其事,必先利其器。这就是为什么我要做这个项目的原因——总有一些想法需要记录,围绕着某个主题,还需要把文章脉络理清。"草稿箱"或许可以做到记录功能,但是作为一个优秀的码农,有什么理由拒绝自己开发一套笔记程序呢?

image.png

本文将带大家鼓捣一个跨端笔记本程序,让你能够时刻记录碎片的文字或灵感,并且同时拥有在线网站,无需服务器维护费用,无需域名(有的话当然更好),重点是开发非常简单,文末也会附上项目完整开源地址。

通过本项目你将会学习到:

  • 开发一个完整的 Electron 小项目
  • 不依赖框架的原生 Node 如何开发 http 服务,接收处理参数、接收表单图片、创建图片静态服务等
  • Electron 中如何顺利进行进程通信、资源目录注意事项等

先来看看项目初步效果:

2022-08-03 17.58.05.gif

创建主项目

本来想使用 Vite + Vue3 + Electron 的方案,但是找了一圈似乎没有 Electron 官方的案例,大都为社区实现,于是改用 Vue2 (后面还有一个原因是md编辑器也暂时没有官方适配Vue3),本着简单快速研发本项目的宗旨,使用 Vue-cli 创建工程是目前最快速稳定的方法:

本项目创建环境:

image.png

首先你需要安装好 vue-cli 工具,在任意终端执行 vue ui 命令即可进入UI界面

image.png

记得选择Vue2的版本,随意配置过后,点击“插件”,添加插件:

image.png

搜索并安装 Electron builder 插件

image.png

该插件会自动将刚才创建的项目改造为 Electron 环境的项目,目前仅支持至13.0这个版本,此时运行 electron:serve,可以看到桌面app就可以跑起来了:

image.png

也可以试试运行 electron:build 看看打包后的文件,这里就不演示了。

接下来引入 element ui,同样也是用“插件”,选择按需引入,很快就自动配置好了~

image.png

如果你需要代码美化校验等规范化功能,也可以试试我写的这个插件~

image.png

image.png

初始化仓库

搭建了项目的基础,现在我们需要初始化一个文章的舞台了。网页项目使用 Docsify 驱动,开启 GitHubPages 之后便能搭起一个在线站点,而文章也是保存在 Github 仓库中。我们使用子进程操作 Git 提交即是“发布到线上”,也是“储存到云端”,而每次打开程序都 Git 拉取一遍代码,简单粗暴实现了“云端”同步。

image.png

首先到 Github 创建一个空仓库,可以勾选个 README.md 之类的,反正就是创建一个文件,这样可以默认建立一个main分支。

image.png

初始化的项目原型是使用我的一个仓库实例 FEbook 来修改的,接下来要做的事情就是将这个项目“克隆”到上面的空仓库中,然后修改其中一些配置即可,这里先简单写一个表单界面,确定需要修改的值:

image.png

    form: {
        name: 'Any-Note-Book',
        repo: 'palxiao/FEbook',
        plugin: ['文字计数', '图片缩放查看器', '代码复制到剪贴板'],
        blog: 'https://blog.palxp.com',
        juejin: 'https://juejin.cn/user/2682464103060541/posts',
      }

这里的form表单对象就对应着要修改的值,到时候将项目中的 index.html 文件复制多一份出来,使用简单的字符串替换即可完成一个"从配置文件生成页面"的功能啦。

image.png

这样在点击按钮的时候我们还需要一个数据库进行保存,既然是在渲染层,那么直接使用 localStorage 就是最简单的持久化储存方案了。

在主进程中编写Node服务

配置页面有了,现在我们需要根据配置对项目进行初始化等操作了,可能需要如下一些接口:

  1. /pull 拉取线上项目,一些初始化操作,根据配置生成文件等
  2. /push 提交发布以及保存项目
  3. /list 文章列表
  4. /detail 获取文章详情
  5. /save 保存文章内容

接下来开始实现以上接口,创建一个 server.js 作为http服务,这里我们就不使用任何框架,仅用node原生模块来开发:

// 导入http模块:
const http = require('http')

// 创建http server,并传入回调函数:
const server = http.createServer(function (request, response) {
  // 设置跨域允许
  response.setHeader('Access-Control-Allow-Origin', '*')
  response.setHeader('Access-Control-Allow-Headers', '*')
  response.setHeader('Access-Control-Allow-Methods', '*')
  
  if (request.url === '/init') {
    // TODO 没有路由系统,简单写几个接口,直接if大法
  } else if (request.url === '/push') {
  }

  console.log(request.method + ': ' + request.url)
})

server.listen(3000) // http listen in 3000 port

获取资源路径

Electron 中,我们通常可以将文件保存在app资源目录下,但由于开发时资源目录与生产时有所区别,所以需要封装一个方法来获取相应路径:

const getResourcesPath = () => {
  let thePath = ''
  switch (process.env.NODE_ENV) {
    case 'development':
      // basePath = path.join(__static, '/') // eslint-disable-line no-undef
      thePath = 'static'
      break
    case 'production':
      thePath = path.join(process.resourcesPath, './static')
      break
  }
  return thePath
}
path.join(__static, '/') 该目录可以作为程序的资源引用目录,作为资源读写目录的话经测试会有问题,本地环境中对 public 目录频繁写入数据可能会造成前端代码重复编译,所以不能使用这个目录。如果生产环境下把资源全部写入 process.resourcesPath 中肯定会很乱,直接使用 Node 在其中创建目录,则会提示没有写入权限,此时可以配置 extraResources 在打包时引入额外目录来做归档管理。

如何配置额外资源路径

vue.config.js 中如下配置,注意其中嵌套的字段,由于项目是通过 cli 创建的,这和 package.json 的配置形式有所差别:

pluginOptions: {
    electronBuilder: {
      builderOptions: {
        extraResources: {
          from: './template/',
          to: 'template',
        },
      },
    },
  },

这里我是配置了将一个 template 文件夹打包进程序资源目录中,该目录放置的是Docsify的一些初始化文件,后面会用到。

项目初始化

2022-08-03 17.26.35.gif

以项目中是否包含dosc这个文件夹为依据,判断此时的项目状态是否完整。因为这个目录将会作为Pages的根目录,文章等资源当然也是存放在这下面,而如果是空项目,则将事先准备好的一众初始文件复制到项目中。

判断目录是否存在

// 判断目录是否存在,可以用 fs.access
fs.access(path, (err) => {
      if (err) { } // err失败表示目录不存在,否则目录存在
})

实现文件复制

// Node实现复制涉及到目录的递归相对复杂,我们可以用shell的cp命令来代替
exec(`cp -r ${getTemplatePath()}/complete/* ${getResourcesPath()}`, () => {
    // 将准备好的Template复制到资源路径当中
})

实现拉取接口

默认你本地已有Git环境,本程序不会做判断

image.png

创建一个 shell.js,开启子进程调用Git,根据传入的项目名拉取代码:

const branch = 'main' // 拉取的分支名
const fullPath = 'static/' // 项目拉取到本地的路径

const child_process = require('child_process')
const exec = child_process.exec

const pullRepository = function (name) {
  const gitAddress = `git@github.com:${name}.git`
  const shell = `git clone -b ${branch} ${gitAddress} --depth 1 ${fullPath}` // 克隆深度为1,节省空间
  exec(shell, function (error, stdout, stderr) {
      // 执行完即可拉取到代码仓库
    })
}
module.exports = { pullRepository }

执行完即可拉取到代码仓库,仓库的路径是我们接下来整个项目的核心目录,对文章及资源的处理都会在这个目录下进行。

实现提交接口

提交与上面的拉取类似,利用子进程对Git进行处理:

const pushRepository = function () {
  const message = 'feat: auto update'
  const fullPath = getResourcesPath()
  const sp = `cd ${fullPath} &&`

  return new Promise((resolve) => {
    exec(`${sp} git add . && git commit -m '${message}'`, (error, stdout, stderr) => {
      if (String(error) !== 'null') {
        resolve('没有可更新的提交')
        return
      }
      exec(`cd ${fullPath} && git push origin ${branch}`, (error, stdout, stderr) => {
        resolve(stdout)
      })
    })
  })
}

第一次进行提交后进入 Github 仓库,选择 Setting -> Pages -> Branch,按下图示保存:

image.png

稍等一会刷新就可以看到你的站点已经生成了,如果你有自己的域名也可以配置下面的 custom domain,或手写CNAME文件,这里就不演示了

image.png

Pages效果展示:

2022-08-03 17.37.09.gif

Electron中的进程通信

本来我用 Node 创建服务,只需要使用 TCP 即可在主渲染层之间通信,但是Node服务在本地启动就需要监听一个端口,本地往往不能确定端口是否被占用,也就意味着服务启动完成时端口号才可以被确定,这时就需要从主进程往渲染层通知到如何访问服务了。

由于渲染进程相当于开启一个浏览器,是不带 Node 执行环境的(好像也可以强行开启,默认是关闭),所以主渲染进程间一般是采用 IPC 进行通信,通过 preload 预加载的方式向渲染进程注入一个 electronipcRenderer 方法。

由于项目依赖了cli工具(官方文档完整说明)需要先在vue.config.js中添加:

module.exports = defineConfig({
  .....
  pluginOptions: {
    electronBuilder: {
      preload: 'src/preload.js',
    },
  },
})

接着在 src/background.js 中就加上预加载文件:

const { join } = require('path')
const win = new BrowserWindow({
    ....
    webPreferences: {
      ....
      preload: join(__dirname, 'preload.js'), // 直接这么写就行,对应路径在上面配置了
    },
  })

src/preload.js 文件可以这么写:

import { ipcRenderer } from 'electron'
window.ipcRenderer = ipcRenderer

window.ipcRenderer.on('setConfig', (e, data) => {
  console.log(data) // 打印出 xxxxx
})

然后在主进程中就可以这么发送数据:

win.webContents.send('setConfig', xxxxx)

就在我这么一顿行云流水的操作过后,实际运行却是没有效果的!因为 Electron 又默认把 contextIsolation 上下文隔离给默认开启了。这就和预加载功能冲突了,上下文环境都不是同一个,我预加载了个寂寞。。对于刚接触 Electron 的小白我真的表示不能理解,所以只好把这个变量设置为false先了。

走到这一步的时候,我也成功进行了 IPC 通信(其实就是个全局事件广播),但是我突然意识到不需要IPC通信了,把 preload.js 改成 server.js ,成功后返回端口注入到渲染进程中不就解决我的需求了?

Nodejs判断端口是否占用

我们可以使用 net 模块建立一个 TCP 连接,成功连接则立即关闭服务,返回端口可用。连接失败则返回端口已被占用。

// checkPortIsOccupied.js
const net = require('net')

// 检测端口是否被占用
function portIsOccupied(port, cb) {
  // 创建服务并监听该端口
  const server = net.createServer().listen(port)

  server.on('listening', function () {
    server.close() // 成功连接,则关闭服务
    cb(false)
  })

  server.on('error', function (err) {
    if (err.code === 'EADDRINUSE') {
      cb(true)
      console.log('The port【' + port + '】 is occupied, please change other port.')
    }
  })
}

function checkPort(port) {
  return new Promise((resolve, reject) => {
    portIsOccupied(port, (isOccupied) => {
      isOccupied ? reject() : resolve()
    })
  })
}

module.exports = checkPort

接着就可以在 预加载 server.js 中修改 server.listen

const checkPort = require('./utils/checkPortIsOccupied')

// listen
function startServer(port = 3000) {
  checkPort(port)
    .then(() => {
      server.listen(port)
      // 此时服务启动,端口号确定,通过注入到全局变量 window 中,告知渲染进程请求地址
      window._apiUrl = `http://127.0.0.1:${port}`
    })
    .catch(() => {
      startServer(port + 1)
    })
}

startServer()

渲染进程中获得请求地址:
image.png

接入md编辑器

md编辑器当然是使用字节开源的掘金同款编辑器 bytemd,咱们直接全套梭哈:

dependencies: {
    "@bytemd/plugin-breaks": "^1.17.2",
    "@bytemd/plugin-footnotes": "^1.12.4",
    "@bytemd/plugin-frontmatter": "^1.17.2",
    "@bytemd/plugin-gemoji": "^1.17.2",
    "@bytemd/plugin-gfm": "^1.17.2",
    "@bytemd/plugin-highlight": "^1.17.2",
    "@bytemd/plugin-medium-zoom": "^1.17.2",
    "@bytemd/plugin-mermaid": "^1.17.2",
    "@bytemd/vue": "^1.17.2",
    "bytemd": "^1.17.2",
    "juejin-markdown-themes": "^1.29.3"
}
<template>
  <Editor v-bind="options" :value="content" :plugins="plugins" @change="handleChange" />
</template>

<script>
import 'bytemd/dist/index.min.css'
import 'juejin-markdown-themes/dist/juejin.min.css'
import 'highlight.js/styles/github.css'

import { Editor } from '@bytemd/vue'
import zhHans from 'bytemd/locales/zh_Hans.json'
import breaks from '@bytemd/plugin-breaks'
import highlight from '@bytemd/plugin-highlight'
import footnotes from '@bytemd/plugin-footnotes'
import frontmatter from '@bytemd/plugin-frontmatter'
import gfm from '@bytemd/plugin-gfm'
import mediumZoom from '@bytemd/plugin-medium-zoom'
import gemoji from '@bytemd/plugin-gemoji'
import mermaid from '@bytemd/plugin-mermaid'

const uploadImages = async (files) => {
  // 执行图片上传操作
  return [{ url, alt }]
}

const plugins = [
  gfm(),
  breaks(),
  footnotes(),
  frontmatter(),
  gemoji(),
  highlight(),
  mediumZoom(),
  mermaid(),
  // Add more plugins here
]

export default { .... 以下省略

image.png

图片上传

bytemd 配置了 uploadImages 这个钩子即可开启图片上传,在回调中会返回一个files对象,我们先只取数组第0项实现单文件上传,那么如何将 file 类型对象传递给服务端呢?这时候就需要使用浏览器 FormData 函数来构建一个表单数据,这里注意不需要设置成 multipart/form-data 类型,直接发送即可。

// 封装保存图片方法:
const saveImg = async (files) => {
  return new Promise((resolve) => {
    const formData = new FormData()
    formData.append('file', files[0])
    const request = new XMLHttpRequest()
    request.open('POST', `${window._apiUrl}/upload`) // 请求upload接口
    // 监听request获取上传结果,load为成功回调,loadend则不管成功与否都会返回
    request.addEventListener('load', ({ currentTarget }) => {
      resolve(JSON.parse(currentTarget.response))
    })
    request.send(formData)
  })
}
// 编辑器的图片上传回调中调用方法
const uploadImages = async (files) => {
  const res = await api.saveImg(files)
  return [{ url: window._apiUrl + res.result.path, alt: 'img' }]
}

一般如果使用http框架开发的话 (如 express) 框架会帮我们处理好参数,这里我们原生开发,所以需要额外处理。一开始我选用比较常见的表单数据解析包 formidable 来处理文件,但是在 Electron 的打包环境中却出现了未知的报错,苦恼之余又找到了另一个解析包 multiparty 非常完美而且使用起来和 formidable 也很类似。

const multiparty = require('multiparty')

.... const server = http.createServer( ......

if (request.url === '/upload') {
    const form = new multiparty.Form()
    form.parse(request, async function (err, fields, files) {
      const file = files.file[0]
      const reader = fs.createReadStream(file.path)
      const name = Math.random().toString() + '.jpg'
      const stream = fs.createWriteStream(path.join(getResourcesPath(), './docs/images/' + name))
      reader.pipe(stream)
      setJson(response, { path: '/images/' + name })
    })
  } 

docs/images 这个目录是我定义的项目中图片资源目录,图片最终会随着 git push 被提交到远程仓库中,实现了在线图床。

image.png

图片静态服务

一开始其实想把图片放进 public 目录来读取就行,本地测试是没问题,但打包的程序是会把网页项目整个打包成 asar 模式,无法做可写文件操作,所以这里我选择用一个静态资源服务来为保存的图片提供读取功能,保存路径也在上面说明了。而在渲染进程提交保存时则把路径地址替换掉,读取时再逆向拼接回本地服务地址以保证显示。

编辑/查看状态 保存后
image.png image.png

就在前面 server.js 上定义一个url判断,将 images 目录作为静态目录,访问这个链接即为读取该目录下的图片,创建一个可读流(写入内存中)然后直接响应给客户端即可实现资源读取服务。

// server.js
....http.createServer.....
if (request.url.indexOf('/images/') !== -1) {
    const fileName = request.url.split('/images/')[1]
    const stream = fs.createReadStream(path.join(basePath, 'static/' + fileName)) // basePath后面说明
    response.setHeader('content-type', 'images/jpg')
    stream.pipe(response)
}

2022-08-01 11.09.03.gif

之所以不保存为网址的绝对路径,是考虑到域名可能发生变化的原因,相对路径更灵活些。还有 Pages 网页部署需要时间,不是即时生效的,显示网络路径有波动问题。

结尾

基于本仓库的几个在线案例展示:

https://book.palxp.com/#/

https://book.yzmblog.top/#/

https://myhzxn.github.io/Taro/#/

项目还有不少可以完善的地方,有需要再看看继续更新点啥功能🤗开源地址:any-note-book

image.png

相关文章
|
1天前
|
JavaScript 测试技术
vue不同环境打包环境变量处理
vue不同环境打包环境变量处理
13 0
|
1天前
|
JavaScript
vue中高精度小数问题(加减乘除方法封装)处理
vue中高精度小数问题(加减乘除方法封装)处理
10 0
|
1天前
|
JavaScript
vue项目使用可选链操作符编译报错问题
vue项目使用可选链操作符编译报错问题
6 0
|
1天前
|
JavaScript
Vue项目启动报错处理
Vue项目启动报错处理
6 1
|
1天前
|
JavaScript 定位技术
vue项目开发笔记记录(二)
vue项目开发笔记记录
28 0
|
1天前
|
JSON JavaScript API
vue项目开发笔记记录(一)
vue项目开发笔记记录
29 0
|
2天前
|
JavaScript
Vue-实现点击空白处隐藏某节点
Vue-实现点击空白处隐藏某节点
9 1
|
2天前
|
缓存 JavaScript 前端开发
vue项目实战:实战技巧总结(中)
vue项目实战:实战技巧总结
25 1
|
5天前
|
JavaScript 搜索推荐 测试技术
深入了解 Vue CLI:现代化 Vue.js 项目开发工具
深入了解 Vue CLI:现代化 Vue.js 项目开发工具
|
6天前
|
JavaScript
【vue】vue2 获取本地IP地址
【vue】vue2 获取本地IP地址
9 1