千里之行
始于足下

绘枫和畅

千里之行,始于足下

Fri.

火曜日

关于Electron



Electron是由Github开发,用HTMLCSSJavaScript来构建跨平台桌面应用程序的一个开源库。 Electron通过将Chromium

 Node.js 合并到同一个运行时环境中,并将其打包为MacWindowsLinux系统下的应用来实现这一目的。


核心理念

  • 为了保持Electron的小 (文件体积) 和可持续性 (依赖和API的扩展) ,Electron限制了使用的核心项目的范围。

  • Electron只用了`Chromium`的 **渲染库** 而不是 ~~全部~~。

  • Electron所添加的的新特性应主要用于原生API(node.js)。


Chromium是什么?

Chromium是由Google主导开发的网页浏览器,说明白点:

Chromium是Chrome浏览器背后的引擎。


几个无聊的联想

Chromium + Node.js = ?

如果把 *浏览器* 和 *Node.js* 联系在一起,一般想到的一个web服务,TCP以及其衍生协议充当了`页面`与`服务器`通信的桥梁。


Web + Native = ?

浏览器可以调用原生功能,很容易让人联想到Hybrid,web通过自定义的`桥接协议`与Native通信,Native拦截请求从而得知web需要调用的功能。


Electron的原理是什么?

在一个Electron桌面应用分成两个进程:

  • 主进程 —— MainProcess

  • 渲染进程 —— RenderProcess

主进程负责管理原生操作,例如: 文件读写,图形处理,程序开关等,同时还管理渲染进程以及应用的生命周期。

渲染进程通过 Chromium 绘制前端界面,用户可以在页面上触发事件。

那么,Node.js 如何与Chromium通信?

答案是 **IPC**——进程间通信。

需要强调的是在Electron中 Chromium 是可以直接引用原生模块的,这意味着网页中的JavaScript可以直接访问文件系统,当加载外部网页时,这就有极大的安全隐患,这点放到安全中去说。Electron的官方文档中也强调了:

当使用 Electron 时,很重要的一点是要理解 Electron 不是一个 Web 浏览器。

Electron IPC通信模型

Electron IPC提供了基于事件的API,在渲染进程和后台进程中都可以向对方发送事件,也可以在事件处理函数中通过发送的新的事件回复对方:

  • 通过EventEmitter实例向对方发送事件

  • 发送事件需要制定对方句柄,不支持全局广播(个人理解,多个BrowerWindow不会产生影响)

  • 支持同步事件和异步事件



创建一个桌面应用

Step 1. 环境

  • Node.js = 8.x | 9.x


Step 2. 安装electron包

npm install electron --save-dev --save-exact

如果下的慢,可以尝试淘宝镜像

Step 3. 项目结构

.
└── app
  ├── main.js
  ├── index.js
  ├── index.html
  ├── package.json

main.js 是主进程执行文件, index.html 和 index.js 是渲染进程的视图,和平时写的网页一样。

需要在package.json写明App名称等信息,同时 需要指定入口文件(即`main.js`),举个例子:

{
  "name": "app",
  "productName": "app",
  "version": "0.0.1",
  "main": "./app/index.js"
}

更详细的范例可以参照官方示例:electron-quick-start,如果想看electronreact整合的配置,可以瞧瞧Yosoro

Step 4. 启动electron

主进程服务加上npm scripts:

"dev:main": "cross-env NODE_ENV='development' electron -r babel-register ./app/",

启动主进程:

npm run dev:main

Step 5. 开发

electron主进程文件修改之后需要重启应用才能生效,每次手动重启不方便,可以使用鄙人撸的一个渣包:electron-watch

食用方法,在主进程文件中插入下面代码片段:

if (process.env.NODE_ENV === 'development') {
  require('electron-watch')(
    __dirname,
    'dev:main',     // means: npm run dev:electron-main
    process.cwd(),  // cwd
    3000,           // 防抖函数延迟参数
  );
}

PS: 该库Windows上有bug



electron常用模块

electron提供了丰富的API让你调用原生的功能,参见electron文档

过一下下面这几个模块就可以快速撸出一个Electron App。

1. app

一个桌面应用对象,提供API控制应用,同时可以监控应用程序的事件生命周期。

例如在最后一个窗口被关闭时退出应用:

const {app} = require('electron');
app.on('window-all-closed', () => {
  app.quit();
});

可以监控的生命周期事件:

  •  will-finish-launching:当应用程序完成基础的启动的时候被触发

  • ready`: 当 Electron: 完成初始化时被触发

  • window-all-closed:当所有的窗口都被关闭时触发

  • before-quit:在应用程序开始关闭窗口之前触发

  • will-quit: 当所有窗口都已关闭并且应用程序将退出时发出

  • quit:在应用程序退出时发出

2. BrowserWindow

 一个浏览器窗口

创建一个浏览器窗口:

import { app, BrowserWindow } from 'electron';
app.on('ready', () => {
  const win = new BrowserWindow({width: 800, height: 600});
  // 加载远程URL
  win.loadURL('https://www.coolecho.net'); 
  // 或加载本地HTML文件
  
  win.loadURL(`file://${__dirname}/app/index.html`); 
});

父子窗口:

const {BrowserWindow} = require('electron');
  
const top = new BrowserWindow();
const child = new BrowserWindow({parent: top})
child.show()
top.show()

child 窗口将总是显示在 top窗口的顶部,长得像这个样子:

3. ipcMain & ipcRender

ipcMain 是EventEmitter类的一个实例。 当在主进程中使用时,ipcRender处理从  渲染器进程 发送出来的异步和同步信息。 从渲染器进程发送的消息将被发送到该模块。

ipcRenderer也是一个 EventEmitter 的实例。 你可以使用它提供的一些方法从渲染进程 发送同步或异步的消息到主进程。 也可以接收主进程回复的消息。

下面是在渲染和主进程之间发送和处理消息的一个例子:

// main
const {ipcMain} = require('electron');
// 异步
ipcMain.on('asynchronous-message', (event, arg) => {
    console.log(arg);  // prints "ping"
    event.sender.send('asynchronous-reply', 'pong');
});
// 同步
ipcMain.on('synchronous-message', (event, arg) => {
  console.log(arg)  // prints "ping"
  event.returnValue = 'pong'
});


//在渲染器进程 (网页) 中
const {ipcRenderer} = require('electron');
// 同步请求
console.log(ipcRenderer.sendSync('synchronous-message', 'ping'));
// 异步发送请求
ipcRenderer.on('asynchronous-reply', (event, arg) => {
    console.log(arg) // prints "pong"
});
// 异步接收返回
ipcRenderer.send('asynchronous-message', 'ping');

你可以通过ipcMain和ipcRenderer模块,在渲染页面中调用原生功能处理一些事务。当然`electron`做到的远不止这些,它还提供了remote模块。使用remote, 你可以调用 主进程对象的方法, 而不必显式发送进程间消息。

4. Menu

创建原生应用菜单和上下文菜单。

import { Menu } from 'electron';
const menuTemplete = [{
    label: 'File',
    submenu: [{
      label: 'New Note',
      accelerator: 'CmdOrCtrl+N',
      enabled: false,
      // role: 'new file',
      click: () => mainWindow.webContents.send('new-file', 1),
    }];
const menu = Menu.buildFromTemplate(menuTemplete);
Menu.setApplicationMenu(menu);

效果:


还可以创建上下文菜单:

const menu = new Menu();
menu.append(new MenuItem({
    label: 'Rename',
    click: () => mainWindow.webContents.send('rename-project'),
}));
// ...
// 项目右键菜单
ipcMain.on('show-context-menu-project-item', (event) => {
  const win = BrowserWindow.fromWebContents(event.sender);
  menu.popup(win);
});

效果:

5. 菜单项

Electron提供了很多属性来定制一个菜单项,例如上面例子中的`label`,`click`,`role`等等:

  • click: 点击菜单项的回调方法

  • role: Electron定制的内置事件,有click的时候,此项将被忽略

  • type: 可以是 normal、separator、submenu、checkbox 或 radio。

  • label: 菜单名称,当设置role时默认为role

  • accelerator: 定义快捷键

  • icon

  • sublabel

  • enabled:  如果为 false,该菜单项将会置灰且不可点击

  • visible: 控制菜单项是否可见

  • checked` 控制菜单项是否选中,type为chckbox或radio时有效

  • submenu: 定义子菜单项

  • id: 可以通过它来引用该菜单项

  • position: 允许对给定菜单中的特定位置进行细粒度定义(没试过)

6. 其他模块

基本上知道上诉几个模块,就可以开发一个Electron APP了,但Electron的模块远远不止这些,它还囊括网络、电源、通知、进程、菜单、本地化、协议、会话、Shell等等模块,详细的介绍参阅Electron文档



安全

前面已经说过了,运行在Chromium的脚本是可以调用原生模块,这意味着当加载来源于网络上的资源会有潜在的危险,如果是编辑器也会发生XSS,例如这种情况。针对这种情况,Electron给出了12条安全建议:

  1. 只加载安全的内容

  2. 加载远端内容是禁用Node.js integration

  3. 加载远端内容启用context isolation

  4. 在所有加载远程内容的会话中使用 ses.setPermissionRequestHandler()

  5. 不要禁用 webSecurity

  6. 定义 Content-Security-Policy和限制性规则

  7. 重写或者禁用eval

  8. 不要将 allowRunningInsecureContent设置为true

  9. 不要启用实验功能

  10. 不要使用enableBlinkFeatures

  11. WebView: 不要使用allowpopups

  12. WebViews: 验证所有

这份清单十分受用。之前处理XSS,最开始解决方案是去掉html的关键字段,这降低了编辑器的速度,之后看了一下VS Code的实现,发现是将markdown渲染在WebView中的,准确的是在webview中生成一个webFrame加载内容。

于是我也去掉了js-xss,将内容放进webview中:

 <webview
   id="webview"
   className="preview-webview"
   autoresize="on"
   webpreferences="contextIsolation=yes"
   disableblinkfeatures="Auxclick"
   src={webviewPath} // 加载资源
   preload={preJSPath}
   ref={node => (this.webview = node)}
/>

src是加载资源的地址,可以是远端地址,也可以是文件路径,为了定义Content-Security-Policy和限制性规则,我写了一个html模版,将css也放进去定义了:

<!DOCTYPE html>
<html style="width: 100%; height: 100%">
<head>
  <meta charset="UTF-8">
  <title>Virtual Document</title>
  <!-- 定义CSP和规则 -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src http: https: data: file:; media-src file: http: https: data:; script-src 'none';style-src 'unsafe-inline' https: data:; font-src https: data:;">
  <style>
    ...
  </style>
</head>
<body>
  <div id="root">
  </div>
  <div id="loading">
  </div>
</body>
</html>

CSP规则使用meta标签定义,详细的规则参见MDN

preload可以注入js脚本,脚本可以控制页面,也可以与主进程或者webview进行通信:

const ipcRenderer = require('electron').ipcRenderer;
document.addEventListener('DOMContentLoaded', () => {
  ipcRenderer.sendToHost('wv-first-loaded');
  ipcRenderer.on('wv-render-html', (event, args) => {
    //...
  });
  ipcRenderer.on('wv-scroll', (event, radio) => {
    //...
  });
});

ipcRenderer.sendToHost向WebView发送消息,ipcRenderer.on也会接受主进程或者WebView发送的消息。

WebView接收消息,需要监听ipc-message,针对不同的channel进行处理,有点像redux的写法。使用webview.send()发送消息:

this.webview.addEventListener('ipc-message', this.onWVMessage);
// ...
onWVMessage(event) {
  const channel = event.channel;
  switch (channel) {
    case 'wv-first-loaded': {
    // ...
      break;
    }
    case 'did-click-link': {
      // ...
    default:
      break;
  }
}

WebView也支持注入css:

webview.insertCSS(css); // css: string

为了方便调试,webview提供了openDevTools()打开调试器。



优化

最新的Electron整合的Chrominum版本是61,以为着我们只需要兼容Chrome 61,可以尝试以下的操作优化性能:

  1. 去掉babel-polyfill

  2. 将`babel-preset-env`的目标设置为chrome 61`

如何查看Electron支持的env target?

可以在package.json中加上一行:

"browserslist": "electron 2.0"

使用npx运行命令:

npx browserslist

是否有坑?

有的,这个问题不知道是babel还是react的问题,我还给`babel`提过一个issue

简而言之,react官方建议的绑定事件有使用箭头函数和.bind(),当使用箭头函数绑定事件:

import React, { Component } from 'react';
export default class Explorer extends Component {
  constructor() {
    super();
    this.state = {
      searchStatus: 0, 
    };
  }
  getNotes = () => {
    const { note } = this.props;
    //...
  }
  render() {
    this.getNotes();
    return (hello world!);
  }
}

编译完成的代码是这样的:

let Explorer = (_temp = _class = class Explorer extends _react.Component {
  constructor() {
    super();
    this.getNotes = () => {
      const { note } = this.props;
      // do something...
    };
    this.state = {
      searchStatus: 0
    };
  }
  render() {
    this.getNotes();
    return (hello world!);
  }
}

getNotes其实是一个变量,而不是方法,所以babel会将变量放在`constructor`中定义,这就有意思了,如果我的constructor()没有加上super(props),根本无法访问组件的props。

如果加上了super(props),当我尝试更新state就会又个warning:

Warning: Can't call setState on a component that is not yet mounted.This is a no-top, but it might indicate a bug in your application.Instead, assign to /`this.state/` directly or define a /`state = {}/;` class property with desired state in the Explorer component.

这个错误出现在我们在constructor中调用this.setState(),当点击按钮触发事件,react却认为当前组件是在构造中。

.bind()我也试过,也会有这个提示,出现这种情况就意味这需要将ES6的类编译城ES5的类。

最后解决方案是使用修饰器和autobind-decorator

@autobind
getNotes() {
  const { note } = this.props;
    // do something...
};


分发应用

开发完了,就得打包了。

有三种种打包方式:

  1. 手动打包

  2. 打包工具

  3. 通过重编译源代码来进行重新定制

1,3两种方式有点繁琐,这里介绍几个常用的打包工具:

如果想在APP STORE上架应用,还需要签名。

吐槽:Apple的开发者每年要交699大洋,对于一个我这样的码畜,不得不放弃证书和签名,没有证书和签名,也没法使用autoUpdater模块。

electron-packer和electron-builder我都使用过,个人感觉electron-builder的功能更加强大,它支持一次性打出三个平台的包,而electron-packer需要在三个平台下打包。

因为最开始的`electron-packer`,后面也懒得再改配置,用起来也很简单,只需要定义npm script:

"packager:mac": "electron-packager ./lib Yosoro --overwrite --platform=darwin --arch=x64 --out=out --icon=assets/icons/osx/app.icns",

--icon参数是用来指定应用的logo的,macOS下是.icns格式, Windows下是.ico格式。

Linux的Logo需要在初始化Electron应用的时候指定,格式为PNG和JPEG,建议使用PNG:

const options = {
    title: 'Yosoro',
};
if (process.platform === 'linux') { // 加上logo
  options.icon = path.join(__dirname, './resource/app.png');
}
mainWindow = new BrowserWindow(options);

icon这个属性在macOS和Windows下是无效的。

最后,生成应用:

如果想分别打三端的包,需要分别在macOS,Windows,Linux这三个环境下打包。

electron-packager打出来的包是可以直接运行的,如果还需要生成安装包,你可能还需要下面的工具:

Windows

OS X

Linux



总结

Electron功能十分强大,对我这样的前端码畜十分友好,虽然PWA火起来了,但我不认为PWA可以取代Electron。


Master之令咒:

带我飞

©2016-2018 Powerd by Alchemy's Spruche 鄂ICP备14020745号