万事开头难,在文章创作前往往需要先有一个想法,当你有了模糊的想法,便可以开始研究。比方说,你想要写关于”微前端”的文章,那么,可以去调研实践微前端框架。做这些事情的同时,你可能会得到灵感,然后知道接下来怎么去写完它。(例如我的一篇文章《微前端很好,为什么我却不使用? 》就是这么写的,虽然写的一般)
仅有想法肯定是不够的,一旦你知道了文章的走向以及讲述方式,应当记录下来,这将会是你的脉络图,同时也是流畅写下整个文章的关键。例如记录好每个节点提纲、讲述目的、技术要点、个人想法和感受等等这些基本点。这样做能防止创作时出现文思枯竭,即使你可能不觉得这样写是完美的,但你至少仍然能知道全篇基本脉络。
不管是不是计划好的,你的文章一定会有一个主题,而根据这个主题,最终你会作一番关于你对这个题目的想法和声明。工欲善其事,必先利其器。这就是为什么我要做这个项目的原因——总有一些想法需要记录,围绕着某个主题,还需要把文章脉络理清。”草稿箱”或许可以做到记录功能,但是作为一个优秀的码农,有什么理由拒绝自己开发一套笔记程序呢?
本文将带大家鼓捣一个跨端笔记本程序,让你能够时刻记录碎片的文字或灵感,并且同时拥有在线网站,无需服务器维护费用,无需域名(有的话当然更好),重点是开发非常简单 ,文末也会附上项目完整开源地址。
通过本项目你将会学习到:
开发一个完整的 Electron 小项目
不依赖框架的原生 Node 如何开发 http 服务,接收处理参数、接收表单图片、创建图片静态服务等
Electron 中如何顺利进行进程通信、资源目录注意事项等
先来看看项目初步效果:
创建主项目 本来想使用 Vite + Vue3 + Electron 的方案,但是找了一圈似乎没有 Electron 官方的案例,大都为社区实现,于是改用 Vue2 (后面还有一个原因是md编辑器也暂时没有官方适配Vue3),本着简单快速研发本项目的宗旨,使用 Vue-cli 创建工程是目前最快速稳定的方法:
本项目创建环境:
首先你需要安装好 vue-cli 工具,在任意终端执行 vue ui
命令即可进入UI界面
记得选择Vue2的版本,随意配置过后,点击“插件”,添加插件:
搜索并安装 Electron builder 插件
该插件会自动将刚才创建的项目改造为 Electron 环境的项目,目前仅支持至13.0这个版本,此时运行 electron:serve
,可以看到桌面app就可以跑起来了:
也可以试试运行 electron:build
看看打包后的文件,这里就不演示了。
接下来引入 element ui ,同样也是用“插件”,选择按需引入,很快就自动配置好了~
如果你需要代码美化校验等规范化功能,也可以试试我写的这个插件~
初始化仓库 搭建了项目的基础,现在我们需要初始化一个文章的舞台了。网页项目使用 Docsify 驱动,开启 GitHubPages 之后便能搭起一个在线站点,而文章也是保存在 Github 仓库中。我们使用子进程操作 Git 提交即是“发布到线上”,也是“储存到云端”,而每次打开程序都 Git 拉取一遍代码,简单粗暴实现了“云端”同步。
首先到 Github 创建一个空仓库,可以勾选个 README.md
之类的,反正就是创建一个文件,这样可以默认建立一个main分支。
初始化的项目原型是使用我的一个仓库实例 FEbook 来修改的,接下来要做的事情就是将这个项目“克隆”到上面的空仓库中,然后修改其中一些配置即可,这里先简单写一个表单界面,确定需要修改的值:
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
文件复制多一份出来,使用简单的字符串替换即可完成一个”从配置文件生成页面”的功能啦。
这样在点击按钮的时候我们还需要一个数据库进行保存,既然是在渲染层,那么直接使用 localStorage 就是最简单的持久化储存方案了。
在主进程中编写Node服务 配置页面有了,现在我们需要根据配置对项目进行初始化等操作了,可能需要如下一些接口:
/pull
拉取线上项目,一些初始化操作,根据配置生成文件等
/push
提交发布以及保存项目
/list
文章列表
/detail
获取文章详情
/save
保存文章内容
接下来开始实现以上接口,创建一个 server.js
作为http服务,这里我们就不使用任何框架,仅用node原生模块来开发:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const http = require ('http' )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' ) { } else if (request.url === '/push' ) { } console .log(request.method + ': ' + request.url) }) server.listen(3000 )
获取资源路径 在 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' : 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 的一些初始化文件,后面会用到。
项目初始化
以项目中是否包含dosc
这个文件夹为依据,判断此时的项目状态是否完整。因为这个目录将会作为Pages 的根目录,文章等资源当然也是存放在这下面,而如果是空项目,则将事先准备好的一众初始文件复制到项目中。
判断目录是否存在 1 2 3 4 fs.access(path, (err ) => { if (err) { } })
实现文件复制 1 2 3 4 exec(`cp -r ${getTemplatePath()} /complete/* ${getResourcesPath()} ` , () => { })
实现拉取接口
默认你本地已有Git环境,本程序不会做判断
创建一个 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.execconst pullRepository = function (name ) { const gitAddress = `git@github.com:${name} .git` const shell = `git clone -b ${branch} ${gitAddress} --depth 1 ${fullPath} ` 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,按下图示保存:
稍等一会刷新就可以看到你的站点已经生成了,如果你有自己的域名也可以配置下面的 custom domain
,或手写CNAME文件,这里就不演示了
Pages效果展示:
Electron中的进程通信 本来我用 Node 创建服务,只需要使用 TCP 即可在主渲染层之间通信,但是Node服务在本地启动就需要监听一个端口,本地往往不能确定端口是否被占用,也就意味着服务启动完成时端口号才可以被确定,这时就需要从主进程往渲染层通知到如何访问服务了。
由于渲染进程相当于开启一个浏览器,是不带 Node 执行环境的(好像也可以强行开启,默认是关闭),所以主渲染进程间一般是采用 IPC 进行通信,通过 preload
预加载的方式向渲染进程注入一个 electron 的 ipcRenderer
方法。
由于项目依赖了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 = ipcRendererwindow .ipcRenderer.on('setConfig' , (e, data ) => { console .log(data) })
然后在主进程中就可以这么发送数据:
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 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' )function startServer (port = 3000 ) { checkPort(port) .then(() => { server.listen(port) window ._apiUrl = `http://127.0.0.1:${port} ` }) .catch(() => { startServer(port + 1 ) }) } startServer()
渲染进程中获得请求地址:
接入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(), ] export default { .... 以下省略
图片上传 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` ) 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 被提交到远程仓库中,实现了在线图床。
图片静态服务 一开始其实想把图片放进 public
目录来读取就行,本地测试是没问题,但打包的程序是会把网页项目整个打包成 asar
模式,无法做可写文件操作,所以这里我选择用一个静态资源服务来为保存的图片提供读取功能,保存路径也在上面说明了。而在渲染进程提交保存时则把路径地址替换掉,读取时再逆向拼接回本地服务地址以保证显示。
编辑/查看状态
保存后
就在前面 server.js
上定义一个url判断,将 images
目录作为静态目录,访问这个链接即为读取该目录下的图片,创建一个可读流 (写入内存中)然后直接响应给客户端即可实现资源读取服务。
1 2 3 4 5 6 7 8 ....http.createServer..... if (request.url.indexOf('/images/' ) !== -1 ) { const fileName = request.url.split('/images/' )[1 ] const stream = fs.createReadStream(path.join(basePath, 'static/' + fileName)) response.setHeader('content-type' , 'images/jpg' ) stream.pipe(response) }
之所以不保存为网址的绝对路径,是考虑到域名可能发生变化的原因,相对路径更灵活些。还有 Pages
网页部署需要时间,不是即时生效的,显示网络路径有波动问题。
结尾 基于本仓库的几个在线案例展示:
https://m.palxp.cn/#/
https://book.yzmblog.top/#/
https://myhzxn.github.io/Taro/#/
项目还有不少可以完善的地方,有需要再看看继续更新点啥功能🤗开源地址:any-note-book