添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

如何用electron解决实际业务问题_html

1 需求背景

本次需求来源于转转的游戏业务,游戏业务并非是开发游戏,而是专注于账号交易、租号、皮肤、游戏币等等,而账号交易是一个非常重客服的业务。

我们举个例子:假如你想买一个王者荣耀的王者账号,你首先要在转转APP内搜索“游戏”,选好心仪的账号并下单后,需要客服帮你完成验号、账号转移等操作,因为目前业内交易的账号以QQ账号绑定的游戏账号为主,也就是说,需要卖家把QQ账号转给你,而账号转移的过程中,需要客服人员在QQ安全中心完成验证、解绑、换绑等操作,因此我们的客服系统是长这个样子:

如何用electron解决实际业务问题_json_02 客服同学电脑截图

由于解绑换绑操作需要买卖家双方不断提供资料,客服对一个订单的处理周期较长,因此会同步处理很多订单,每个订单都需要打开一个QQ安全中心,这就是本文需求的关键点了:

  1. 如何打开多个QQ安全中心页面,且互相独立不受影响

  • 我们不能像浏览网页一样打开多个tab,每个tab里面一个QQ安全中心,因为页面登录信息会共享,不管你打开了多少个tab,实际上登录的都是一个QQ号
  • 最容易想到的就是用Chrome的无痕模式嘛,但是实际上你会发现无痕模式并非真正无痕,当你打开第一个无痕模式时,它跟普通tab确实是隔离的,但你后续再打开多个无痕模式,他们实际上跟第一个无痕模式是共享cookie、storage等存储空间的,这条路也不通
  • 于是客服同学们想到了“最土”的办法:下载市面上所有浏览器,每个浏览器打开1个普通tab,1个无痕tab

如何用electron解决实际业务问题_html_03 客服同学电脑截图

这就有点太委屈客服同学了。

  1. 如何快速找到订单对应的QQ安全中心页面

衍生的问题又来了,当客服同学打开了N个浏览器和2N个页面以后,如何知道哪个订单对应哪个tab就成了个大问题。

有的同学可能说,可以按照订单顺序来找嘛。

很抱歉,第一张图里面可以看到,订单顺序是像我们的电脑版微信界面一样动态变化的,有新消息的订单会自动排到最顶上。

如何用electron解决实际业务问题_加载_04 客服同学真是太委屈了

2 解决方案

为了解决上述两个问题,我们出手了,我们要用前端技术来解决这个问题,我们想到的方案是:

用electron开发一个桌面版浏览器,要做到每次新开tab都是一个绝对无痕的tab,并且能在订单页面自动定位到该tab。

最终截图如下:

如何用electron解决实际业务问题_html_05

3 技术方案

electron简介

electron是一个用于开发桌面端跨平台应用的框架,它使用Chromium内核渲染界面,从而让使用 ​ ​Html​ ​​ ​ ​CSS​ ​​ ​ ​JS​ ​​ 开发的页面能以原生应用的方式,呈现在用户面前。相比于浏览器,electron 中运行的网页,具备了文件系统访问等一系列原生应用能力。并且由于渲染内核是平台无关的,同样一套代码,可以运行在​ ​MacOS​ ​​ ​ ​Windows​ ​​ ​ ​Linux​ ​系统中。有许多知名的软件,使用了electron进行开发,除了大名鼎鼎的 VS Code,还包括Figma、Slack、Twitch等。

整体方向分析

从客服同学的反馈中,我们得知他们平常工作使用两个屏幕,一个屏幕用于放置客服系统,另一个屏幕用于访问qq安全中心。如果我们将功能做在一个electron窗口中,会导致他们在客服系统和安全中心页面中频繁切换,而且另一个屏幕也派不上用场。

所以我们的需求是,开启两个窗口,分别管理客服后台系统和qq安全中心。另外我们需要给窗口加上前进、后退和刷新等导航功能,以及多个QQ安全中心页面,需要进行tab管理,这样才方便客服同学的操作。

写一个 Hello World 应用

首先我们通过写一个hello world,来熟悉一下electron的基础开发方式。开发的第一步是创建electron工程,相关的脚手架比较多,这里我们采用了​ ​electron-forge​ ​。

npx create-electron-app gamekf-browser

在脚手架初始化完成以后,我们得到了这样的目录结构。

.
├── package.json
├── src
│ ├── index.css
│ ├── index.html
│ └── index.js
└── yarn.loc

其中关键文件是​ ​src/index.js​ ​,是electron项目的入口文件,其逻辑简化后如下:

const {app} = require('electron');

app.on('ready', () => {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
});

// and load the index.html of the app.
mainWindow.loadFile(path.join(__dirname, 'index.html'));
});

这一段代码的作用是:我们通过 app 对象,监听了​ ​ready​ ​​事件。当用户点击我们应用的图标时,就启动了electron进程。当electron初始化完毕后,触发了​ ​ready​ ​​事件。我们在事件的callback中,构造了一个窗口对象,加载本地文件​ ​index.html​ ​。

然后我们运行​ ​npm start​ ​​,electron会首先寻找​ ​package.json​ ​​中的​ ​main​ ​​字段,根据该字段的路径找到入口文件(这里是src/index.js)并运行它。之后,我们就可以看到​ ​index.html​ ​文件中的内容,呈现在了窗口中。

这样一个electron Hello World就完成了。

在Hello World的演示中,一个窗口只加载了一个url。但这对于我们的需求来说是不够的。这是其中一个窗口的效果图:

如何用electron解决实际业务问题_加载_06 从图中可以看出,界面分为上下两个部分,下面是安全中心(https://aq.qq.com),上面是我们自己写的一个网页,来提供导航功能和tab切换的交互界面。所以我们需要将窗口进行分割,让它们分别加载两个不同的url。

首先我们解决一个窗口加载多个url的问题,这要求我们使用​ ​BrowserView​ ​​对象,将界面分割成上下两个部分。具体思路是创建一个​ ​BrowserWindow​ ​​作为父容器,调用其​ ​addBrowserView​ ​​方法添加上下两个​ ​BrowserView​ ​对象,从而实现分割界面的需求。

// 创建BrowserWindow对象
// 创建BrowserView对象。与BrowserWindow的选项相同。
const createView = () => {
return new BrowserView({
webPreferences: {
nodeIntegration: false,
webSecurity: false,
}
});
};

// 这一步是将qqSecureCenterWindow作为高度500的容器,
const qqSecureCenterWindow = new BrowserWindow({
width: 500,
height: 500,
webPreferences: {
nodeIntegration: false,
webSecurity: false,
}
});
// contentView 指这个窗口的内容视图,tabsView 指这个窗口的导航视图。
const contentView = createView();
contentView.setBounds({
x: 0,
y: 100,
height: 400,
width: 500
})
contentView.webContents.loadUrl(
'https://aq.qq.com'
);

const tabsView = createView();
// 两个BrowserView对象,高度100+400=500刚好填充满整个容器(qqSecureCenterWindow)。
tabsView.setBounds({
x: 0,
y: 0,
height: 100,
width: 500
});
// 加载标签页,以提供前进、后退、刷新以及标签展示功能。
// http://localhost:8080#/tabs 这个网址是我们稍后要开发的内容,用于提供导航功能。
tabsView.webContents.loadUrl(
'http://localhost:8080#/tabs'
);

// 在一系列设置后,最终将它们添加到父容器中。
qqSecureCenterWindow.addBrowserView(tabsView);
qqSecureCenterWindow.addBrowserView(contentView);

上面的示例代码展示了qq安全中心窗口的初始化过程,也就是开篇的效果图左侧的窗口。事实上右侧的窗口也是类似的初始化过程,唯一的不同就是将qq安全中心的链接,替换成客服后台系统的链接。这样我们就做到了打开软件时,同时打开两个窗口,一个是qq安全中心窗口,另一个是客服后台系统窗口,它们都分别有导航功能。

添加 Vue 视图层

由于tabs管理需要一个网页来展现视图,也就是上文提到的​ ​http://localhost:8080#/tabs​ ​这个链接。但是在Hello World工程中,加载的是一个简单的html文件,也就意味着我们无法像其他web项目一样,可以享受模块化开发的好处,会严重影响我们的开发效率。所以下一步目标就是引入一个@vue/cli创建的工程。第一步是调用@vue/cli的创建命令。

# 这里的项目名可以随便取,因为后续
# 我们会将其中的文件合并入electron工程。
vue create electron-renderer

第二步是将​ ​electron-renderer​ ​​和​ ​gamekf-browser​ ​​工程,合并为一个工程。我们将​ ​electron-renderer​ ​​下的目录全量复制进​ ​gamekf-browser​ ​​,如果遇到同名的文件,就手动合并,比如​ ​package.json​ ​​、​ ​.gitignore​ ​​文件等。而​ ​gamekf-browser​ ​​工程中原有的src目录,我们将其移入根目录下的​ ​program​ ​文件夹下,这样我们可以将Vue项目和electron主程序的代码分开。最终的效果是Vue和electron主程序共用了同一个node_module和package.json文件。

在一顿整合之后的目录结构长这样:

.
├── dist # Vue输出目录
├── out # electron输出目录
├── program # electron代码
├── public # 从public目录开始,下面都是vue开发者非常熟悉的@vue/cli结构了。
├── src
├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── prettier.config.js
├── tsconfig.json
└── vue.config.js

代码注入与通信方式

在electron中,我们可以对创建的窗口拥有强大的控制权,比如在网页加载前,预先注入代码。注入的动作发生在创建 view 对象的时候,我们只要为 preload 属性指定一个 js 文件的路径,就可以在加载网页之前注入一段代码。

// main.js
const view = new BrowserView({
webPreferences: {
nodeIntegration: false,
// 为每个BrowserView注入变量
preload: `${__dirname}/injection.js`,
}
});

// 同目录下的injection.js
const { ipcRenderer } = require('electron');
window.ipcRenderer = ipcRenderer;
window.isInElectron = true;

上面的代码做了两件事情,一个是将​ ​nodeIntegration​ ​​选项关闭。该选项的意思是,是否为加载的网页提供node集成。如果开启的话,就可以在网页中可以通过​ ​window.require('fs')​ ​的形式,访问Node Api。这里推荐将其关闭,因为在开启该选项后,部分远程script文件的加载会被阻断。这是因为网页可以访问node模块,在加载第三方网页时,存在调用fs模块来破坏文件的可能性。electron官方出于安全方面的考量,后续的更新会默认关闭这个选项。

这里注入的代码做了两件事情,一个是在window对象上放了一个变量,让加载的网页可以判断自己所处的环境是否为electron。另一个是我们将​ ​ipcRenderer​ ​对象挂载到了window对象上,给web端暴露通信对象,实现通信的目的(下文将介绍通信的具体方式)。因为node集成被关闭的原因,网页无法直接访问node模块。

下面看一下一个利用注入的​ ​ipcRenderer​ ​,实现通信的最简例子:

// node代码,注册一个名为“event-name-here”的事件,供web端调用。
const {ipcMain} = require('electron')
ipcMain.handle('event-name-here', (params) => {
console.log(params.paramKey) // paramValue
})

// web代码,调用事先注册的“event-name-here”事件。
if (window.isInElectron) {
// 事件名与传参
window.ipcRenderer.invoke('event-name-here', {
paramKey: 'paramValue'
})
}

打开qq安全中心

从这里开始,是这次需求的核心,就是客服同学在客服系统中点击按钮,打开一个无痕的安全中心。

如何用electron解决实际业务问题_json_02

我们需要在客服系统中,加入一个“打开qq安全中心”按钮(也就是上图右上角的蓝色按钮),点击后触发​ ​open-tab​ ​​事件,通知node进程进行新的​ ​BrowserView​ ​对象创建。

// electron代码
// 使用ipcMain.handle来注册一个事件,供web端调用。
const {ipcMain} = require('electron');
const views = {}
ipcMain.handle('open-tab', ({orderId, url}) => {
const view = createView();
// 将创建的对象存储起来,因为后续的其他操作还需要访问这个对象。
views[orderId] = view
// 设置相对于window的坐标
view.setBounds({x: 0, y: 100})
view.loadUrl(url);
qqSecureCenterWindow.addBrowserView(view);
})
<template>
<div class="orders">
<div class="order" v-for="order in orders">
<button
@click="onClickOpenSecureCenterButton(order.id)"
>
打开qq安全中心
</button>
<!-- 省略订单详细html代码 -->
</div>
</div>
</template>

<script>
export default {
methods: {
onClickOpenSecureCenterButton(orderId) {
window.ipcRenderer.invoke(
'open-tab',
{orderId, url: 'https://aq.qq.com'}
)
}
}
}
</script>

客服系统将自己希望打开的网页url传递给electron主进程,主进程收到消息后,创建一个加载该url的view放入窗口中。

接下来我们来实现无痕功能,即每次新打开安全中心时,cookie、localStorage等一系列存储功能都是空的。在​ ​BrowserView​ ​​对象中,有一个​ ​view.webContents.session​ ​​对象,它有着清除存储的方法。然而这里有一个坑点,在创建一个​ ​BrowserView​ ​​时,如果不指定​ ​session​ ​选项,所有的实例都会使用默认的session对象,即共享cookie和storage。在一些情景下,这是违反直觉的,比如:

const view1 = new BrowserView()
const view2 = new BrowserView()
const view3 = new BrowserView()
const view4 = new BrowserView()

view1.webContents.session.clearStorageData()

当我调用其中一个对象的清除数据方法时,调用者往往期望仅清除view1的数据。然而electron此时会将view1至view4的数据都清除。若要避免这种情况,就需要在构建对象的时候,传入一个自定义的​ ​session​ ​​对象。session对象承载着localStorage、cookie等众多数据,只要每个​ ​BrowserView​ ​的session对象不是同一个,那么就相当于隔离了页面之间的cookie。

const session = require('electron').session
const counter = (() => {
let count = 0
return () => ++count
})()
const view = new BrowserView({
// 这里我们通过一个递增的数字,来保证fromPartition方法的参数不同,
// 从而可以返回一个新的session对象。
session: session.fromPartition(`persist:${counter()}`)
})

Tab管理与导航功能

我们目的是管理多个安全中心网页,这涉及到tab的新增、切换与删除三个功能。tab的新增上文已经描述过了,这里补充切换与删除。

首先我们在Vue项目中,新增一个路由:​ ​http://localhost:8080#/tabs​ ​,画出tab的界面,就像下图。然后就可以开始动手实现具体功能了。

如何用electron解决实际业务问题_加载_08


  • 切换

对于tab操作,在代码层面等同于对各个BrowserView对象的操作。当用户点击其中一个tab时,我们通过​ ​ipcRenderer​ ​​将tab对应的订单id传递给主进程,主进程再对已创建的​ ​BrowserView​ ​对象进行操作。

<template>
<div class="tabs">
<div
class="tab"
v-for="tab in tabs"
@click="onClickTab(tab.id)"
>
{{tab.name}}
</div>
</div>
</template>

<script>
export default {
methods: {
onClickTab(tabId) {
// 这里的tabId实际上就是取的orderId
window.ipcRenderer.invoke('select-tab', tabId)
}
}
}
</script>
// 首先移除窗口中所有的view对象,然后将用户选中的tab
// 对应的view对象,加入到窗口中。
ipcMain.handle('select-tab', (tabId) => {
Object.entries(views).forEach(
([orderId, view]) => {
qqSecureCenterWindow.removeBrowserView(view))
if (orderId === tabId) {
window.addBrowserView(view);
}
}
)
})
  • 关闭

需要注意的是,上文中的​ ​window.removeBrowserView​ ​方法,仅仅是将view对象中窗口中移除,并不会释放内存。查看任务管理器可以发现,每个BrowserView对象都会开启一个Renderer进程,在调用remove方法以后,该对象仍存在于内存中,并没有被销毁。

如何用electron解决实际业务问题_html_09

想要真正杀死这个进程,需要调用​ ​BrowserView.webContents.destroy​ ​​方法,destroy完成后,renderer进程就会从任务管理器中消失了。而执行destroy方法的时机,就是当用户点击tab上的x号,renderer进程发出对应事件时,由主进程调用​ ​destory​ ​方法。

  • 导航功能

导航功能的实现与其他功能的基本步骤非常类似,通过​ ​@click​ ​​监听导航事件,在点击对应按钮后,通过​ ​ipcRenderer.invoke​ ​​ ​ ​ipcMain.handle​ ​通信,node收到消息后,通过tabId(也就是orderId)找到对应的view对象,调用下面的api即可。

// 前进
view.webContents.goForward()
// 后退
view.webContents.goBack()
// 刷新
view.webContents.reload()

对应的ui就是下图的左侧三个按钮。

如何用electron解决实际业务问题_加载_08

总结

这次需求中,我们利用了electron对webview的控制能力,灵活地解决现有浏览器无法打开多个互相隔离的无痕窗口这个缺陷,让客服同学告别了在市面多个浏览器之间频繁切换的“原始时代“。经过产品的统计,客服提效16%,可以节省2-3个客服人力。在需求的开发中,开发人员能有所学习,同时又能进行业务提效,是非常让人开心的一件事情。