工程篇:借助 Parcel 搭建 Electron 应用开发环境

1,627 阅读6分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

先对这个系列的专栏做一个简单的介绍。

本专栏主要是通过 ElectronProseMirror 两个框架来开发一个本地的 Markdown 编辑器,UI 框架我会选择 React,然后使用 Typescript 来开发。专栏的内容会涉及到更新升级、自定义窗口、多 tab 栏的实现、跨进程通信、Sqlite 数据库实践、应用上架等。

虽然这个项目是从零开发,但是不会讲解太多的关于如何配置 Webpack 等方面的知识,相关内容你们可以搜索其他博文进行学习。当然,有任何问题欢迎直接在评论区提问,我会进行解答。

本文所示代码均在 Github 仓库中

main 和 renderer

使用过 Electron 的同学都知道 main主进程renderer渲染进程

main 负责使用 Nodejs 处理和系统交互的部分,renderer 负责渲染呈现在用户面前的 UI 界面。他俩通过 IPC 进行通信。

当我们要开始开发一个 electron 项目时,这里以主流的构建工具 Webpack 为例。当编译 main 代码时,编译目标要选择 electron-main,这样生产依赖的库的代码就不会打包到最终的 main.js 中,依旧保持类似 const fse = require("fs-extra") 的形式,在 runtime 时动态从 app.asar 里的 node_modules 里引入。而编译 renderer 时,以往我们是选择 electron-renderer,现在 electron 提供了一种 preload 的方式后,我们可以直接选择 web 为编译目标,这样 renderer 的代码里也不用混杂一些 node 代码了。

新特性 preload,类似于混合开发中的 JSBridge, 借助 contextBridge.exposeInMainWorld('JSBridge', {}) 可以往页面中注入一个对象用来和 native 部分进行交互,在 electron 中就是和 main 主进程进行交互。

这个新设计带来的好处是显而易见的 。当你想要将一个 web 应用改造成拥有原生能力的桌面应用时,你只要在 preload 中实现一个 sdk,就能很简单的将 web 应用接入进来。另一方面,禁止在renderer 中执行 node 代码,大大提高了安全性(防止有的库会用 nodejs 违规操作用户系统)。

这里简单看一下,我们要如何使用 preload

const win = new BrowserWindow({
    width: 800,
    height: 600,
    show: false,
    webPreferences: {
      nodeIntegration: true,
      nodeIntegrationInSubFrames: true,
      devTools: app.isPackaged ? false : true,
      contextIsolation: true,
      preload: path.join(app.getAppPath(), './static/preload.js'),
    },
  })

上面的 preload 我用的是编译后的文件,preload 的编译也是采用 electron-main 的标准。

renderer 渲染进程

我们现在开始创建项目,我给它取名为 ENotes

mkdir enotes
cd enotes
yarn init -y

添加 React、ReactDom 等依赖库

yarn add react react-dom
yarn add parcel typescript @types/react @types/react-dom -D
yarn tsc --init --locale zh-cn

经常有同学会抱怨 tsconfig.json 不会配置,注意上面我加了 --locale zh-cn 的参数,这样生成出来的 tsconfig.json 里注释就是中文的,如下图所示。

image.png

现在的目录结构是这样的

├── package.json
├── public
│   └── template.html
├── src
│   └── renderer
│       └── index.tsx
├── tsconfig.json
└── yarn.lock

template.html 模版中,我简单写了如下代码。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div id="root"></div>
    <script src="../src/renderer/index.tsx" type="module"></script>
</body>

</html>

我自己项目中是用 webpack 来编译代码的,这里为了方便,我选择使用 parcel 来编译 renderer 代码。接下来,命令行运行 yarn parcel public/template.html, 访问 http://localhost:1234(默认端口 1234) 就可以看到 web 界面了。

image.png

main 主进程

光有 web 页面还不行,我们用 electron 给它加件衣服。我们先安装 electron

yarn add electron

接下来我们要实现

graph LR
启动应用 --> 显示窗口 --> 加载网页
// src/main/index.ts
import { app, BrowserWindow } from "electron";
import path from "path";

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    show: false,
    webPreferences: {
      devTools: app.isPackaged ? false : true,
    },
  });
  return win;
}

app.whenReady().then(() => {
  const win = createWindow();
  app.isPackaged
    ? win.loadFile(path.join(app.getAppPath(), "dist", "template.html"))
    : win.loadURL("http://localhost:1234");

  win.webContents.on("dom-ready", () => {
    win.show();
  });
});

main 主进程代码,依旧使用 parcel 来编译。在项目根目录下创建 config/main.mjs 文件,如下。

import { Parcel } from "@parcel/core";

let bundler = new Parcel({
  entries: "./src/main/index.ts",
  defaultConfig: "@parcel/config-default",
  targets: {
    main: {
      distDir: "dist",
      context: "electron-main"
    },
  },
});

await bundler.run();

我们在 package.json 的 scripts 中添加一些启动脚本。

concurrently 可以同时运行多个 npm 脚本

yarn add concurrently -D
// package.json
{
    "scripts": {
        "start": "concurrently \"npm:start:renderer\" \"npm:start:main\"",
        "start:renderer": "parcel public/template.html",
        "start:main": "node config/main.mjs && electron dist/index.js"
      }
}

运行一下 yarn start,就可以看到应用启动了。

image.png

preload 预加载脚本

接下来我们要使用 preload 为网页增加点原生功能,看看一个简单的 preload 该如何写。

// src/main/preload.ts
import { contextBridge } from "electron";

contextBridge.exposeInMainWorld("Bridge", {
  test: () => {
    console.log("bridge is working");
  },
});

同样需要配置 config/preload.mjs, 可以参考 main.mjs。

我们把之前代码里的 createWindow 调整下,让页面预加载 preload。

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    show: false,
    webPreferences: {
      nodeIntegration: true,
      nodeIntegrationInSubFrames: true,
      devTools: app.isPackaged ? false : true,
      contextIsolation: true,
      preload: path.join(app.getAppPath(), "dist", "preload.js"),
    },
  });
  return win;
}

在 package.json 中的 scripts 加上 preload 的编译。

{
  "scripts": {
    "start": "concurrently \"npm:start:renderer\" \"npm:start:preload\" \"npm:start:main\"",
    "start:renderer": "parcel public/template.html",
    "start:preload": "node config/preload.mjs",
    "start:main": "node config/main.mjs && electron dist/index.js"
  }
}

使用 win.webContents.openDevTools({ mode: "detach" }); ,或者窗口显示以后,用快捷键 CmdOrCtrl+Shift+I, 打开 devtools。我们测试一下 bridge 是否成功注入到页面里了。

image.png

看起来已经成功注入到页面中。

这个例子不是很能体现 preload 的作用,我们换一个,实现一个 showMessage 方法,调用 electron 的提示弹窗。

// src/main/preload.ts
import { contextBridge, ipcRenderer, MessageBoxOptions } from "electron";

contextBridge.exposeInMainWorld("Bridge", {
  showMessage: (options: MessageBoxOptions) => {
    ipcRenderer.invoke("showMessage", options);
  },
});

// src/main/index.ts
import { dialog, MessageBoxOptions } from 'electron'

ipcMain.handle("showMessage", (_e, options: MessageBoxOptions) => {
    // win 是之前创建的窗口
   dialog.showMessageBox(win, options);
});

让我们再测试一下。

image.png

生产环境

之前的 npm 脚本并没有区分环境,我们需要通过设置环境变量来实现这个。

cross-env 可以很方便地设置环境变量

yarn add cross-env -D
"start:main": "cross-env NODE_ENV=development node config/main.mjs && electron dist/index.js"

单元测试

为了保证代码的可靠性,我们还需要为项目添加单元测试、e2e测试等。由于 Electron 不适合做 e2e 测试,所以这里我就添加单元测试,用的是最近很火的 Vitest

yarn add vitest -D
// vitest.config.js
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[tj]s?(x)"],
  },
});

{
  "scripts": {
    "test": "vitest"
  }
}

这样就配置好了,是不是很简单。我们简单写一个示例测试一下。

// src/utils/node/index.ts
import { basename, extname } from "path";

export function getFileNameWithoutExt(filePath: string) {
  const name = basename(filePath);
  const ext = extname(filePath);
  return name.substring(0, name.lastIndexOf(ext));
}
// src/utils/node/__tests__/index.test.ts
import { describe, test, expect } from 'vitest'
import { getFileNameWithoutExt } from '..'

describe('utils', () => {
  test('getFileName', () => {
    const filePath = '/xx/test.md'
    const fileName = getFileNameWithoutExt(filePath)
    expect(fileName).toEqual('test')
  })
})

image.png

到这里,基本的开发环境已经配置好了,接下来就可以愉快的开发了。

总结

用 Parcel 来编译代码,明显比 Webpack 方便了很多。

该项目的代码我放在这里了 ENotes