万事开头难,在文章创作前往往需要先有一个想法,当你有了模糊的想法,便可以开始研究。比方说,你想要写关于”微前端”的文章,那么,可以去调研实践微前端框架。做这些事情的同时,你可能会得到灵感,然后知道接下来怎么去写完它。(例如我的一篇文章《微前端很好,为什么我却不使用?》就是这么写的,虽然写的一般)

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

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

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

1
2
3
4
5
6
7
form: {
name: 'Any-Note-Book',
repo: 'palxiao/FEbook',
plugin: ['文字计数', '图片缩放查看器', '代码复制到剪贴板'],
blog: 'https://www.palxp.cn',
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原生模块来开发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 导入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资源目录下,但由于开发时资源目录与生产时有所区别,所以需要封装一个方法来获取相应路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 的配置形式有所差别:

1
2
3
4
5
6
7
8
9
10
pluginOptions: {
electronBuilder: {
builderOptions: {
extraResources: {
from: './template/',
to: 'template',
},
},
},
},

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

项目初始化

2022-08-03 17.26.35.gif

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

判断目录是否存在

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

实现文件复制

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

实现拉取接口

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

image.png

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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中添加:

1
2
3
4
5
6
7
8
module.exports = defineConfig({
.....
pluginOptions: {
electronBuilder: {
preload: 'src/preload.js',
},
},
})

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

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

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

1
2
3
4
5
6
import { ipcRenderer } from 'electron'
window.ipcRenderer = ipcRenderer

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

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

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

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

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

Nodejs判断端口是否占用

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,咱们直接全套梭哈:

1
2
3
4
5
6
7
8
9
10
11
12
13
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"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<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 类型,直接发送即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 封装保存图片方法:
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)
})
}
1
2
3
4
5
// 编辑器的图片上传回调中调用方法
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 也很类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 目录作为静态目录,访问这个链接即为读取该目录下的图片,创建一个可读流(写入内存中)然后直接响应给客户端即可实现资源读取服务。

1
2
3
4
5
6
7
8
// 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://m.palxp.cn/#/

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

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

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