Electron实践之仿spotlight的网址检索工具

Electron实践之仿spotlight的网址检索工具

技术杂谈小彩虹2021-07-15 23:27:12100A+A-

写在前面

Electron是一个用前端语言来构建跨平台桌面应用程序的一个开源库,由Github开发,在Electron中,我们可以很方便的使用Node API & Chromium API & Native API 构建多端的桌面应用程序,关于Electron的基础概念,前些天写过一篇文章,简单对Electron做了一个大致的科普,对Electron不太了解的同学可以去看一下。

传送门:Electron—Electron原理与简单实践

起因&价值

在日常生活以及工作中,我们常用来手机常用网址的手段主要是笔记&浏览器书签,我也一直沿用这样的方式,也曾找过许多解决方案,包括但不限于,浏览器插件,桌面便签等等。但是在考拉还在网易的时候,就觉得内部平台太多,不同平台还有不同环境,并不像日常生活需要的网址那么固定好记,搬到阿里,新换环境又是一堆新平台,同事之间日常问答:

xx平台的网址你知道吗?.

不知道,你去xx文档/聊天记录里找找,可能会有。

而书签或者笔记可以用作记录却存在一些问题,让我感觉不够便捷

  • 链路长——需要先打开笔记/浏览器 查找书签栏地址
  • 中断当前屏幕的思考
  • 书签库共享麻烦,没有基础的书签库,同样的网址每个人都需要收藏

作为一个程序员,理所当然的想搞个工具优化一下这个操作。那这个工具要有哪些功能呢?由于日常开发需求较多,能抽出来的空闲时间较少。


Why Electron?

为什么选择Electron来做,开始有想法的时候基本上搜寻了一下市面上其他的一些相关技术:

PyQt

PyQt主要是基于Python代码构建,简单、易学、免费、开源,社区成熟,虽然Python上手较为容易,但是现阶段并不是我熟悉的领域,推荐Python的同学使用,所以PyQt目前被pass了,不排除后续做一定尝试

Nw.js

与Electron的作者是同一个人,但是Electron由Github专门的团队维护官方有一个技术对比:Electron 和 NW.js (原名 node-webkit) 在技术上的差异,简单来说Nw.js是桌面版+网络版二合一,中文文档比较匮乏,社区活跃度低,更加底层不熟悉底层的人,会遇到很多坑且难以解决,所以本位没有选择Nw.js

ps:可以关注知乎问题:www.zhihu.com/question/38…

Flutter

fultter是由Google开发和维护的跨端工具,但是Dart有一定的学习成本,虽然我很愿意学习一下,但是Flutter的桌面端并不成熟,Google公司并不承诺对它进行支持,只是“探索性质的尝试”,遂放弃

Electron

electron的介绍不在赘述,选择Electron主要看中社区活跃,文档齐全,成功的案例较多,对于了解VScode插件开发也有一定帮助,下面就开始构思这个小工具吧~

要实现什么

有了初步的思路和工具选型,梳理一下大致的思路,我将要实现的功能拆分为两类:

  • 基础功能(短期内自己和小范围可以用的
  • 进阶功能(长期规划,能推广使用的)

基础功能:

  1. 基础操作界面,简洁方便
  2. 快捷唤起,可以在不同的场景通过快捷键迅速的唤起搜索bar
  3. 支持本地录入条目,构建持久化本地数据库,能快速检索条目并给出期望的匹配结果
  4. 读取chrome书签,将chrome书签导入本地数据库
  5. 提供一个导航界面,方便直接进入常用网址

进阶功能:

  1. chrome插件,配套工具,快速同步chrome中的页面和书签到程序
  2. 更新服务器,支持自动更新甚至是热更新
  3. 部署服务器可以选择性与其他人共享数据


基本思路

将功能做了一些梳理,捋了一捋实现的基本思路,可以点开看一下大图,


image.png

边踩坑边实现

思路捋清了,开始撸代码~,鉴于日常开发的技术栈主要是Vue,所以选择的Electron-vue进这个脚手架减少前期的配置工作,附:Electron--vue官方文档

跪在第一步:Install失败

根据官方步骤,初始化好electron-vue的工程,

vue init simulatedgreg/electron-vue my-project
# 安装依赖并运行你的程序
cd my-project
yarn # 或者 npm install
yarn run dev # 或者 npm run dev

然而在之心install的时候发现,electron的下载速度奇慢,往往下载进度50%的时候挂掉因为是私人电脑不能上公司的网络加速,第一时间更了淘宝源镜像,本以为速度问题会得到解决,然而。。

image.png

排查发现,electron下载的时候会从github下载release的压缩包,国内下载速度波动很大,经常会出现下载失败的问题,为此你需要一个好的梯子和好的代理。

温馨提示:ss的全局代理并不会对命令行生效,在mac上你需要privoxy,可自行百度/Google:"MAC命令行privoxy的使用"


准备好这些,终于可以用愉快的实现我要的功能啦~~

可以随时唤起的无框界面

剔除掉示例代码中无关的部分,跑起代码,有这么一个界面:

image.png

对比spotlight,美观度差了不是一点半点

image.png

那么如何实现一个无框的界面呢,这时候就需要一个属性

new BrowserWindow({
        height: 400,
        width: windowWidth,
        frame: false,
    })

这样我们就可以得到一个无框界面

image.png

那么如何实现随时随地的快捷唤起呢?这时候自然想到全局快捷键

 ...
        new BrowserWindow({
        height: 400,
        width: windowWidth,
        frame: false,
          show: false, // 初始将窗口隐藏
    })
...
// 注册全局快捷键,并显示窗口
    globalShortcut.register('Alt+A', () => {
        mainWindow.show();
    })

普通情况下,单纯的快捷减满足了我们的需求,然而在全屏时效果如下



全屏情况下单纯注册快捷键并不能达到spotlight全屏情况下与背景结合一体的效果,翻了很多Issue和文章,最终找到两种解决方案:

  1. 使用MACOS的PanelWindow,Electron原生并不支持,需要手动实现,我并不是OS的开发者,github倒是有大佬实现了:electron-panel-window,不过一年多没有动静,遂放弃
  2. 使用app.dock.hide(),这个函数的本意是隐藏mac底部的dock,却对于实现全屏下的唤起融合有以外之喜
    // 改进后的快捷键注册回调函数
    globalShortcut.register('Alt+A', () => {
        if (!mainWindow.isVisible()) {
            app.dock.hide();
            mainWindow.show();
        } else {
            mainWindow.hide();
            app.dock.show();
        }
    })

欢欢喜喜实现一番,发现虽然全屏下能唤起,但是切到其他屏幕的时候搜索框并没有跟过来:



查阅官方文档,发现BrowserWindow下有一个属性:

alwaysOnTop:Boolean (可选) -窗口是否永远在别的窗口的上面. 默认值为false.

我们添加上这个属性,并加上APP的失焦事件,终于获得了我们所期望的表现


...
mainWindow = new BrowserWindow({
  height: 50,
  width: windowWidth,
  alwaysOnTop: true,
  show: false,
  frame: false,
})
...
// 监听应用失焦事件
app.on('browser-window-blur', () => {
  mainWindow.hide();
  app.dock.show();
})

效果:



搜索功能的实现

基本的窗口显示解决了,现在要实现基本的搜索功能,搜索的基础是数据~

本地数据库与数据结构

要存储数据,需要一个持久化的本地数据库,官方推荐的数据库为Nedb,关于Nedb的基本描述就一句

The JavaScript Database, for Node.js, nw.js, electron and the browser

这已经足以让我使用它了,相对于Mysql、MongoDB、redis等,Nedb更小,更轻量,使用JavaScript编写对Electron有天然的支持,可以将它看成一个MongoDB的精简版。

选好数据库接下来便设计一个基础的数据结构用于基础的信息存储与展示,Nedb的文档结构可以很方便的后续对数据进行扩充。

{
  url: 'http://www.baidu.com', // 网址的URL
  name: '百度', // 搜索是条目展示的标题
  keyWords: '搜索', // 关键词
  type: 'insert', // 是在程序中内嵌还是在浏览器打开
  desc: '百度旗下的搜索产品', // 描述
  icon: 'http://www.google.com/s2/favicons?domain=www.baidu.com' // 用展示的图标
}


分词&相似度比较

数据已经有了,接下来便是如何搜索到我们期望的条目,想更大范围的检索到我们期望的条目,设计了简单的搜索算法:

image.png

1、分词

结巴分词是中文分词中用的较为广泛的一个分词库,本次我选用的分词库就是“结巴分词”的node版本: nodejieba:

需要注意的是,实操过程中发现,默认的分词算法:nodejieba.cut()会将英文单词切割成字母,建议使用HMM算法:

nodejieba.cutHMM(searchKey);

2、检索

分词过后我们的搜索词会被切割成一串数组,例如:

searchKey = "百度1baidu"
console.log(nodejieba.cutHMM(searchKey));
//[ '百度', '1', 'baidu' ]

我们需要根据这一串数组,将包含所有数组项的数据筛选出来,基础的查询要实现匹配无序包含所有数组项作为子串的字符串较为复杂,索性Nedb支持正则表达式进行查询,我们利用正向先行断言正则表达式来实现匹配包含所有子串的字符串的功能。示例代码如下

let reg = /(?=.*?aa)(?=.*?bb)(?=.*?cc)/
reg.test('aabbcc')// true
reg.test('ccbbaa')//true
reg.test('bbvvaaffcc')//true
reg.test('bbvvaaff')//false


完整的搜索函数

let search = function ({ searchKey }) {
    // 分词
    let searchTokens = nodejieba.cutHMM(searchKey);
    // 所搜索项必须全包括分词的结果才会被搜索出来,可以无序
    let regString = searchTokens.map(ele => `(?=.*?${ele})`).join('')
    let reg = new RegExp(regString);
    return new Promise((reslove, reject) => {
        db.find({
            $or: [{
                url: { $regex: reg }
            }, {
                name: { $regex: reg }
            }, {
                keyWords: { $regex: reg }
            }, {
                desc: { $regex: reg }
            }]
        }, function (err, docs) {
            if (err) {
                reject(err)
            } else {
                reslove(docs)
            }
        });
    })
}


3、排序

数据匹配到了,但是数据却可能有很多条,那么哪一条才最符合我们的期望呢,这时候我们需要对检索结果进行一个排序:

计算两个字符串的相似度:

本文选择的第三方的包string-similarity,来计算两个字符串的相似度,string-similarity计算相似度的算法基于 Dice's Coefficient,有兴趣的可以参考Wiki百科中对于Dice系数的介绍。整体代码分为

  • 分别获取搜索词与检索结果中的条目的标题、描述、关键字、URL的相似度
  • 对相似度进行=> url:名称:关键字:描述 10:10:5:3 的加权
  • 依据加权值对搜索结果进行排序

具体代码逻辑如下:

let compareTwoStrings = function (first, second) {
    if (first == second) {
        return 1;
    }
    if (first.length === 1 || second.length === 1) {
        let max = Math.max(first.length, second.length);
        let min = Math.min(first.length, second.length);
        return min/max;
    } else {
        return stringSimilarity.compareTwoStrings(first, second);
    }
}
let sort = function (searchResult, searchKey) {
    // console.log('sort', searchResult, searchKey)
    searchResult = searchResult.map((ele) => {
        let { url, name, keyWords, desc } = ele;
        let urlRate = compareTwoStrings(url, searchKey);
        let nameRate = compareTwoStrings(name, searchKey);
        // console.log(name, searchKey, nameRate)
        let keyWordsRate = compareTwoStrings(keyWords, searchKey);
        let descRate = compareTwoStrings(desc, searchKey);
        // console.log(ele, urlRate, nameRate, keyWordsRate, descRate)
        // url:名称:关键字:描述对的权重比 为 10:10:5:3
        ele.rate = 10 * urlRate + 10 * nameRate + 5 * keyWordsRate + 3 * descRate;
        return ele;
    })

    return searchResult.sort((a, b) => b.rate - a.rate);

}


PS:踩坑

使用nodejieba遇到NODE_MODULE_VERSION的错误,如下:

error:: Error: The module '<project>/node_modules/electron/node_modules/ref/build/Release/binding.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 57. This version of Node.js requires
NODE_MODULE_VERSION 54. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or`npm install`).

解决方法

yarn add electron-rebuild --dev
./node_modules/.bin/electron-rebuild

有个细节前提需要注意,electron-rebuild重新build的模块必须在dependencies中,不能在devDependencies中。因为electron-rebuild只会rebuild dependencies中依赖。

渲染进程的展示

基本的数据与搜索已经准备好,接下来就是展示了,本例将展示模式区分为四种

  • 单bar: 只有一个搜索框
  • bar+list: 搜索框+搜索结果列表
  • bar+导航: 搜索框+ 导航列表
  • bar+iframe:搜索框+内嵌页面

单搜索bar

搜索bar的功能主要是:

  • 输入搜索词
  • Enter&Input事件触发搜索
  • 改变窗口大小

搜索事件触发主要依赖于进程通信与事件监听,事件监听与普通浏览器中监听input事件与键盘事件一致。

ipcRenderer.on('search-result', (event, arg) => {
  this.list = arg;
  this.$forceUpdate();
  // this.handleReset(['iframeUrl'])
  // console.log(arg) // prints "pong"
})
...
 ipcRenderer.send('search', {
        searchKey: $event
 });

改变窗口大小的功能基于

BrowserWindow中的setSize函数,核心代码

    ipcMain.on('change-window', (event, arg) => {
        if (arg === 'small') {
            mainWindow.setSize(windowWidth, minWindowHeight, true)
        } else {
            mainWindow.setSize(windowWidth, maxWindowHeight, true)
        }
    })

效果:



bar+ 导航

导航主要用来展示用户固定的或者使用次数较多的页面,相关的表结构和频次算法尚未完成,以下为导航的展示效果,导航可以删除、编辑、内外打开。



bar+list

list的功能主要为:

  • 展示搜索结果
  • 点击或者enter跳转内嵌或者浏览器页面,up & down控制列表激活上下条目。

核心点1: 获取网站的icon:

获取Icon的思路主要是

  1. 通过request获取网页html文件
  2. 通过cheerio加载html数据,并读取link[rel~="icon"]或者link[rel~="ICON"]的数据
  3. 根据不同情况修正URL
  4. 读取失败:使用默认的`${parseUrl.origin}/favicon.ico`

核心代码:

const $ = cheerio.load(body);
let icon = ($('link[rel~="icon"]')[0] && $('link[rel~="icon"]')[0].attribs.href) ||
    ($('link[rel~="ICON"]')[0] && $('link[rel~="ICON"]')[0].attribs.href);
const parseUrl = new URL(url);
if (icon) {
  if (!/^http/.test(icon)) {
    // 不是http开头
    if (icon[0] === '/') {
      icon = `${parseUrl.origin}${icon}`
    } else {
      icon = `${parseUrl.origin}/${icon}`
    }
  }
} else {
  icon = `${parseUrl.origin}/favicon.ico`
}
if (url[url.length - 1] === '/') {
  url = url.slice(0, url.length - 1);
}


核心点2:默认浏览器打开链接,主要依赖于shell模块的openExternal函数

ipcMain.on('goto', (event, arg) => {
  shell.openExternal(arg.url)
})



bar+iframe

单纯的Iframe内嵌比较简单,在Vue组件中内嵌一个Iframe元素就好了,但是使用过程中却发现了一些坑点:

坑点1:展示窗口较小,许多页面需要滑动,如果内嵌一个语雀编辑页,操作就比较难受

解决:将iframe的宽高进行一定倍数加宽,在通过transform成比例缩小,再重新依据scale计算定位

<iframe
        :src="iframeUrl"
        :width="iframe.width * scale"
        :style="{transform: `scale(${1/scale})`, position: 'absolute', top: `${-top}px`,left: `${-left}px`}"
        :height="iframe.height * scale"
        frameborder="0"
        name="test"
        ref="iframe"
        @load="handleLoad"
        />
...
  computed: {
    top() {
      return (
        (this.iframe.height * (this.scale - 1)) / 2 - this.iframe.minHeight
      );
    },
    left() {
      return (this.iframe.width * (this.scale - 1)) / 2;
    }
  },

坑点2:Iframe中内嵌的页面二次跳转

ifram内嵌页面,再次跳转,例如百度跳转具体页面、语雀跳转编辑页,带来了一些其他坑点,

  1. 实现页面收藏功能时,由于跨域问题,无法获取iframe中实际页面的动态的地址
  2. 页面跳转第三方_blank的形式会打开新窗口

由于跳到二次或多次页面时,比如利用百度搜索结果,语雀工作台点击具体的编辑,往往回想将当前页记录下来,存储到本地,但是由于iframe的跨域安全限制,并不能读取到动态iframe的实际内容。嵌入的第三方页面不受该程序的控制,无法通过postmessage的方式拉解决,查阅文档后发现Electron的webview标签可以解决这个问题。

替换iframe为webview出现新的问题:

  • 语雀编辑页光标经常找不到
  • 展示与iframe表现不一致
  • Electron官方警告

Electron的 webview 标签基于 Chromium webview </0> ,后者正在经历巨大的架构变化。 这将影响 webview 的稳定性,包括呈现、导航和事件路由。 我们目前建议不使用 webview 标签,并考虑其他替代方案,如 iframe 、Electron的 BrowserView 或完全避免嵌入内容的体系结构。

所以不得不另求他法。翻阅Electron的文档发现:Bro'w'er'ViewwebContents

It can happen when the window.location object is changed or a user clicks a link in the page.

实践发现它并不能检测到Iframe中的二次跳转,继续翻阅文档,发现了BrowerView.webContents上的几个其他的事件,经过试验以下几个事件可以检测到Iframe的跳转动作:

这些事件虽然能监听到iframe的改变,但是当我们iframe嵌入的第三方页面中嵌入了子级的iframe也会被他们检测到,我们并不能确认哪个事件的触发对应了当前展示额页面,思考了实验了一下did-frame-navigate虽然可以检测事件,但是内嵌iframe加载时间不确定,加载顺序不确定,但是did-start-navigation却可以保证我们的的目标URl是第一个要跳转的URL,所以有了下面的解决办法:

  1. 监听did-start-navigation事件,维护一个全局数组,在did-start-navigation事件中,想数组当中Push目标URL
  2. 监听iframe的onload事件,onload事件不会受到子级iframe加载发的影响,在onload中发送消息,通主进程
  3. 主进程监听iframe的onload消息,取得数组中的第一个即为目标URL
  4. 根据URL使用前文获取Icon的类似方式获取Title,desc,keywords、URl等参数
  5. 清空全局数组

PS: 为了提高准确率,减少异步iframe加载对读取当前iframe信息的影响,还维护了一个域名黑名单对常见的内嵌iframe进行过滤,例如:

  'pagead2.googlesyndication.com',
  'googleads.g.doubleclick.net'

后续会形成上报机制进行维护,具体代码较长,可以查看GitHub链接

另一个问题是页面跳转第三方_blank的形式会打开新窗口,解决办法是监听new-window事件,阻止默认并在浏览器打开改URL。

webContents.on('new-window', async (event, navigationUrl) => {
  // In this example, we'll ask the operating system // to open this event's url in the default browser.
  const parsedUrl = new URL(navigationUrl);
  // console.log(parsedUrl)
  event.preventDefault();
  await shell.openExternal(navigationUrl)
})


PS:踩坑,icon加载403。

解决:在html中加入:

   <meta name="referrer" content="no-referrer">



调试electron-vue

前文已经实现了搜索工具的基本展示、搜索、存储等功能,基本功能已经可以用起来了,但是开发的途中和后续桂发仍然有一些复杂逻辑,编写复杂逻辑不可缺少的就是调试了,在前一篇的文章Electron—Electron原理与简单实践中已经简单介绍过Electron的调试,这里我们主要说一下Electron-vue的调试。

渲染进程

Electorn-vue中已经集成了electron-debug,在窗口中也集成了VueTools,可以很方便的对渲染进程进行调试,

require('electron-debug')({ isEnabled: true })
// Install `vue-devtools`
require('electron').app.on('ready', () => {
  let installExtension = require('electron-devtools-installer')
  installExtension.default(installExtension.VUEJS_DEVTOOLS)
    .then(() => { })
    .catch(err => {
      console.log('Unable to install `vue-devtools`: \n', err)
    })
})
// Require `main` process to boot app
require('./index')

electron-debug中集成了devtron,我们在窗口的控制台中输入require('devtron').install()即可启用,在devtron中我们可以观察到依赖拓扑、事件监听、IPC监控等等,具体可以查看:Devtron

主进程

渲染进程的调试较为简单,但是主进程却较为复杂,electron-debug会打印出一个Ws的地址,我们可以用inspect的方式使用chrome对程序进行调试,但是却有聊个弊端

  • 操作较为麻烦
  • 代码有一定的编译

理想的模式还是使用Vscode进行调试,参照官方VScode调试的代码,发现主进程可以启动,但是没有渲染进程的内容,而且主进程不能打断点,经过观察发现,Electron-vue的几个特点:

  1. 使用了babel和webpack
  2. 开发模式入口为:index.dev.j。

为此,我们在launch.json中加入了DEBUG_ENV,NODE_END,BABEL_ENV添加上babel-register,得到下面的配置文件,然后我们就可以愉快的给主进程打断点了~

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch",
            "type": "node",
            "request": "launch",
            "program": "${workspaceRoot}/src/main/index.dev.js",
            "stopOnEntry": false,
            "args": [],
            "cwd": "${workspaceRoot}",
            "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
            "runtimeArgs": [
                "--nolazy",
                "-r",
                "babel-register"
            ],
            "env": {
                "DEBUG_ENV": "debug",
                "BABEL_ENV": "main",
                "NODE_ENV":"development"
            },
            "sourceMaps": false,
        },
    ]
}

主进程起来了,但是界面一片空白,原因是,我们直接使用调试只启动了主进程,并没有启动渲染进程,我们在package.json中加上下面的脚本:

"render": "webpack-dev-server --hot --colors --config .electron-vue/webpack.renderer.config.js --port 9080 --content-base ./"

启动脚本,然后再启动调试,界面就出来啦,

打包之路

Electron-vue集成了electron-builder和electron-packager,初始化工程是可以选择其中任意一种,本工程选择的是electron-builder,electron-builder与electron-packager已经将复杂的打包过程做了极大的简化,这里说几个注意点

asar

默认electron-builder是开启asar的,asar可以避免源码被暴露,但是一些情况下回增大打包的体积,本工程开启asar之前打包有140M+,关闭后只有60M+的体积,鉴于项目源码没有啥机密,以及nodejieba会因为asar无法正确读取文件,所以本工程里关闭了asar

静态资源打包

打包之前我按照日常习惯的方式,在renderer进程下面添加了assets文件夹,开发是表现正常,打包后发现静态资源找不到,发现Electron-vue中静态资源需要放在根目录下的static文件夹下并相对路径引用才能得到正确的表现。

iconfont打包

iconfont需要下载下来并在本地引入,在ejs文件头部引入在线链接并不会在打包的时候生效


未来规划

目前的功能还较为基础,后续规划主要在服务器一级chrome书签上,改善体验与效率,敬请期待~

  • 服务器: 软件更新&数据共享
  • 客户端: chrome对应插件完善,错误上报与性能优化,数据导出导出(以设备为唯一ID,但是数据可导出导入),chrome书签导入,导航功能完善

结尾

本项目Github链接(只有客户端部分):github.com/ZhenyuCheng…

PS:功能更新中,前期代码比较粗糙,勉强可以看,后面准备加上状态管理,重新拆分一下模块


点击这里复制本文地址 以上内容由权冠洲的博客整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!

支持Ctrl+Enter提交

联系我们| 本站介绍| 留言建议 | 交换友链 | 域名展示
本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除

权冠洲的博客 © All Rights Reserved.  Copyright quanguanzhou.top All Rights Reserved
苏公网安备 32030302000848号   苏ICP备20033101号-1

联系我们