从零开始开发基于electron的switchHost应用

999 阅读7分钟

前言

市面上应该有不少切换window下域名映射的应用了,个人感觉这个功能实现起来应该不是很复杂,正好是自己切入electron学习的好机会。electron作为js生态在桌面端的重磅应用,极大地拓展了js的边界(vsCode就是用electron开发的)。最为一个前端开发,补齐桌面端的开发短板也是很有意义的一件事。

开发目标

实现一个简单的桌面端switch host应用,windows中有一个文件hosts(路径通常是C:\Windows\System32\drivers\etc\hosts),该文件维护了一个域名和ip地址的映射。一般长这样:

向文件中的域名发出请求时,将会直接向对应的ip地址发送请求体,通常用来实现本地代理,搞前端开发的应该经常会这样操作。使用#作为注释标记。在开发过程中,需要经常切换本地和线上真实环境,频繁改动这个文件比较繁琐,这里开发桌面应用来简化这个过程,提高效率。最后的成品长这样:
操作过后,配置文件中的内容也会更新,项目地址放在文末

实现

什么是electron

electron是一套基于js的桌面应用开发套件,使用它可以帮助js开发者方便地开发桌面应用,使用js开发者熟悉的html,css,js文件来绘制页面,实现交互。在使用原生能力上,electron可以开放node的能力给开发者使用,从而获得系统级别的能力,electron也封装了一系列原生的api,方便开发者调用,从而跟操作系统和桌面UI等进行交互。简单来说,可以使用前端开发者熟悉的工具链,直接实现你想要的功能,electron帮你完成了后续的封装,生成可执行文件等工作。electron的原理并不复杂,chrome的v8引擎的强劲性能给了很多开发者无限的想象力,electron也受益于chrome,它相当于把你写的js应用放到一个浏览器里面执行,从而实现开发语言和UI实现上跟前端开发工具链的无缝对接。

electron光速入门

首先编写你自己的js应用,将其整合成一个html文件,类似这样(这里是index.html):

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Switch Host</title>
    <!-- https://electronjs.org/docs/tutorial/security#csp-meta-tag -->
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
  </head>
  <body>
    <div id='main'></div>
  </body>
  <link rel="stylesheet" href="common.css" >
  <link rel="stylesheet" href="app.css" >
  <script src="app.bundle.js" type="text/javascript"></script>
  <script src="common.bundle.js" type="text/javascript"></script>
</html>

这可以看到,这里我把打包完成之后的文件和样式都通过内联的方式写在html文件里,打包完成之后的css和js文件跟这个html文件放在同一个目录下。我们app开发完之后的成品就长这样,然后安装electron(这里可能需要T子,或者使用国内镜像源),接下来写一个electron启动文件index.js文件:

const { app, BrowserWindow } = require('electron')

function createWindow () {   
  // 创建浏览器窗口
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true
    }
  })

  // 并且为你的应用加载index.html
  win.loadFile('app/index.html')

  // 打开开发者工具
  win.webContents.openDevTools()
}

// Electron会在初始化完成并且准备好创建浏览器窗口时调用这个方法
// 部分 API 在 ready 事件触发后才能使用。
app.whenReady().then(createWindow)

//当所有窗口都被关闭后退出
app.on('window-all-closed', () => {
  // 在 macOS 上,除非用户用 Cmd + Q 确定地退出,
  // 否则绝大部分应用及其菜单栏会保持激活。
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  // 在macOS上,当单击dock图标并且没有其他窗口打开时,
  // 通常在应用程序中重新创建一个窗口。
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})

// 您可以把应用程序其他的流程写在在此文件中
// 代码 也可以拆分成几个文件,然后用 require 导入。

接下来在package.json中添加命令:

  "scripts": {
    "start": "electron index",
  },

之后就可以开启你自己的应用了npm run start,正常的话,应该会在桌面弹出一个浏览器窗口,里面绘制的就是你index.html中的内容,这个html里面要实现什么逻辑就是你说了算。

业务逻辑

electron壳子的相关逻辑到这里就结束了,接下来我们转入业务逻辑的部分。这里笔者的工具链是ts+react(函数式组件hooks)+antd,通过node中的fspath来读取文件,通过webpack将所有逻辑打包成js和css文件,将其内联到我们的index.html里面。

数据初始化

接下来是业务逻辑,这里首先要做的就是从系统中读取hosts文件的内容,然后将其中的域名-ip映射一一提取出来,并抽象成一定的数据结构,方便后续处理,代码如下:

import React, { useState, useEffect, useReducer } from 'react';
import * as s from './index.css';
//  注意,如果要引用node模块中的方法,需要这么写
const fs = window.require('fs');
const path = window.require('path');
import { Form, Input, Switch, Modal, message, Upload, Button } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import { EditOutlined, DeleteOutlined, FileAddOutlined } from '@ant-design/icons';

const SPLIT_CHAR = '\r\n';
const FormItem = Form.Item;
const EDIT_CONFIG = 'edit';
const ADD_CONFIG = 'add';

//  出于篇幅考虑,这里省略interface定义

//  全文匹配ip地址
const regx = /((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})(\.((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})){3}/g;
//  匹配输入框的ip地址
const singleReg = /^((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})(\.((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})){3}$/g;
function Panel() {
    //  读取配置文件路径
    const configPath = 'C:\\Windows\\System32\\drivers\\etc\\hosts';
    //  hooks,记录原始文件内容和抽象过后的数据对象
    function objReducer(state: T_infoObj, action: T_infoObj) {
        //  以前没有空对象,react还是以为是原始对象,会不触发更新
        return Object.assign({}, state, action);
    }
    const [infoObj, setInfo] = useReducer(objReducer, {
        //  记录每一行的文本
        textLines: [],
        //  由每一行内容抽象出来的对象组成数组
        detailObjArr: [] as Array<detailObj>,
    } as T_infoObj);

    useEffect(() => {
        const TEXT_PATH = configPath; // 大文件存储目录
        //  读取文件内容
        const oldContent = fs.readFileSync(TEXT_PATH, 'utf-8');
        //  提取所有行
        const xxx = oldContent.split(SPLIT_CHAR);
        //  结果的结构体数组
        const yyy = xxx.map((item, index) => {
            //  匹配所有ip,返回数组
            const targetIps = item.match(regx) || [];
            //  根据目标ip,通常是首个,分裂行内容
            const splitRes = item.split(targetIps[0]);
            const first = splitRes[0].trim();
            const isValid = splitRes.length >= 2 && first.length <= 1;
            if (isValid) {
                return {
                    //  是否是符合语法的映射
                    isValid,
                    //  对应原始文件的第几行
                    index,
                    //  是数组中的第几个,后面会修改
                    objIndex: 0,
                    //  映射是否激活
                    able: first.length === 0,
                    //  对应的ip
                    ip: targetIps[0],
                    //  对应的域名
                    domain: splitRes[1].split('#')[0].trim()
                } as detailObj
            } else {
                return {
                    index,
                    isValid
                } as detailObj;
            }
        })
        setInfo({
            textLines: xxx,
            //  打上标记,说明是数组里的第几个
            detailObjArr: yyy.filter(item => item.isValid).map((item, index) => Object.assign(item, { objIndex: index })) as Array<detailObj>
        })
    }, []);

    //  省略渲染的部分
    return ();
}

这里的处理思路是根据hotsts文件中的行尾换行符\r\n,将文件切为单行内容分别分析。通过正则表达式,提取出每行中可能存在的ip地址。通过匹配到的ip地址将行内容split,split的结果来判断该行是否是符合语法的域名-ip映射。这一系列操作完成后,获得两个变量供后续操作:textLines是一个数组,其内部保存hotst文件中每一行的内容,detailObjArr也是一个数组,内容是所有符合hosts文件配置语法的域名-ip映射抽象出的对象。具体定义详见源码。

UI绘制与交互逻辑

//  省略重复代码
function Panel() {
    function setMSReducer(state: modalStatus, action: modalStatus) {
        return Object.assign({}, state, action);
    }
    //  用来控制modal显示的reducer
    const [modStatus, setMS] = useReducer(setMSReducer, {
        //  是否展示对话框
        showModal: false,
        //  对话框的类型:编辑或者新增
        modalType: EDIT_CONFIG,
        //  当前修改的是第几个抽象obj,方便修改
        curIndex: 0,
    } as modalStatus);

    //  切换switch,打开或者关闭某个配置,具体就是对没有一行删除行首的#或者添加#
    //  同时同步info.detailObjArr的内容,更新UI
    function changeSwitch(item: detailObj, value) {
        const { index, objIndex } = item;
        let finalStr = '';
        //  获得原始内容的副本
        let cpyLines = infoObj.textLines.slice(0);
        const allLength = infoObj.textLines.length;
        //  更新原始string,使用reduce,拼接成新的文本内容
        finalStr = infoObj.textLines.reduce((old, newItem, sindex) => {
            let newLine = newItem;
            //  到了修改的那一行
            if (sindex === index) {
                //  确定是添加还是去除注释,修改代码行
                const originContent = value ? newItem.slice(1) : ('#' + newItem);
                newLine = originContent + SPLIT_CHAR;
                cpyLines[index] = originContent;
            } else if (sindex !== (allLength - 1)) {
                newLine = newLine + SPLIT_CHAR;
            }
            return old + newLine;
        }, '');
        //  更新objArr
        const cpObjArr = infoObj.detailObjArr.slice();
        cpObjArr[objIndex].able = value;
        //  更新UI
        setInfo({
            textLines: cpyLines,
            detailObjArr: cpObjArr,
        })
        //  更新文件内容
        writeFile(finalStr);
    };
    //  写文件操作,覆盖原始文件内容
    function writeFile(content) {
        fs.writeFileSync(configPath, content);
    }
    //  校验修改后的内容是否符合语法
    function validateNewObj(obj) {
        const { ip, domain } = obj;
        return domain && ip.match(singleReg);
    }
    //  一个modal,删除某一项配置
    function deleteConfig(index) {
        Modal.confirm({
            title: '删除配置',
            content: (
              <div>确认删除本条配置?</div>
            ),
            onCancel() {},
            onOk() {
                //  获得副本
                const copyLines = infoObj.textLines.slice();
                const copyObjArray = infoObj.detailObjArr.slice();
                //  删除对应的内容
                copyLines.splice(infoObj.detailObjArr[index].index, 1);
                copyObjArray.splice(index, 1);
                //  写原始文件
                writeFile(copyLines.join(SPLIT_CHAR));
                //  更新数据与UI
                setInfo({
                    textLines: copyLines,
                    detailObjArr: copyObjArray,
                })
            }
        })
    }
    let newObj = {
        ip: '',
        domain: ''
    };
    //  改变输入框中的ip或者domain
    function changInfo(key, e) {
        newObj[key] = e.target.value;
    }
    //  点击确认时的回调
    function handleOk() {
        if (!validateNewObj(newObj)) {
            message.error('输入有误,请检查');
            return;
        }
        //  获得新字符串,本质是拷贝一个副本
        const newLines = infoObj.textLines.slice();
        //  获得新对象,拷贝一个副本
        const newObjArray= infoObj.detailObjArr.slice();
        //  判断功能,添加配置和修改配置走不同的分支
        if (modStatus.modalType === ADD_CONFIG) {
            //  代码行增加新内容
            newLines.push(`${newObj.ip}    ${newObj.domain}`);
            //  抽象数据添加新对象
            newObjArray.push({
                isValid: true,
                index: newLines.length - 1,
                able: true,
                ip: newObj.ip,
                objIndex: newObjArray.length,
                domain: newObj.domain,
            })

        } else {
            //  修改配置
            const { index, domain, ip } = infoObj.detailObjArr[modStatus.curIndex];
            //  替换文本行中的domain和ip
            const newContent = newLines[index].replace(domain, newObj.domain).replace(ip, newObj.ip);
            /// 更新文件内容
            newLines[index] = newContent;
            const targetObj = newObjArray[index];
            Object.assign(targetObj, newObj);
        }
        writeFile(newLines.join(SPLIT_CHAR));
        setInfo({
            textLines: newLines,
            detailObjArr: newObjArray,
        })
        //  关闭modal
        setMS({
            showModal: false
        })
    }
    function handleCancel() {
        setMS({
            showModal: false
        })
    }
    function showModalFuc(type, index = -1) {
        setMS({
            showModal: true,
            modalType: type,
            curIndex: index,
        })
    }
    //  弹出的对话框,新增和修改用的是同一个组件
    function getModal() {
        const { modalType, curIndex } = modStatus;
        newObj = { domain: infoObj.detailObjArr[curIndex]?.domain || '', ip: infoObj.detailObjArr[curIndex]?.ip || ''};
        return <Modal
            title={ modalType === EDIT_CONFIG ? '修改配置' : '新增配置'}
            visible={modStatus.showModal}
            onOk={handleOk}
            onCancel={handleCancel}
        >
            <Form {...layout} initialValues={{ domain: newObj.domain, ip: newObj.ip }}>
                <FormItem label='域名' name='domain' rules={[{ required: true }]}>
                    <Input onChange={changInfo.bind(null, 'domain')}/>
                </FormItem>
                <FormItem label='ip' name='ip' rules={[{ pattern: singleReg, message: '请输入正确的ip'}]}>
                    <Input onChange={changInfo.bind(null, 'ip')}/>
                </FormItem>
            </Form>
        </Modal>;
    }
    //  渲染主面板
    return <div className={s.name}>
        <div className={s.addWrapper}>
            <div className={s.aWord}>新增配置</div><FileAddOutlined onClick={showModalFuc.bind(null, ADD_CONFIG)}/>
        </div>
        {infoObj.detailObjArr.map((item, index) => {
            const { ip, domain, index: lineNum, able } = item;
            return (
                <div key={index} className={s.formWrapper}>
                    <FormItem label={`${able ? '关闭' : '开启'}映射`} >
                            <Switch onChange={changeSwitch.bind(null, item)} defaultChecked={able}/>
                            <span className={s.domainAndIp}>{domain}: {ip}</span>
                    </FormItem>
                    <div className={s.iconGroup}>
                        <EditOutlined onClick={showModalFuc.bind(null, EDIT_CONFIG, index)}/>
                        <div className={s.iSpan}/>
                        <DeleteOutlined onClick={deleteConfig.bind(null, index)}/>
                    </div>
                </div>
            );
        })}
        {getModal()}
    </div>
}

这里核心逻辑是渲染配置项并且实现交互过程,所有的修改和新添加配置的modal(对话框)共用同一个,通过传参的不同来决定渲染的具体内容。修改之后,同步更新原始的文件内容和抽象过后的配置数组。这里借用antd的能力实现了domain和ip输入的校验。当打开或者关闭开关时,原始文件和抽象配置数组也会有对应的变化,这里不再赘述。

项目打包,生成exe文件

业务逻辑完成之后,这里我们进入打包的逻辑,这里笔者推荐使用electron-builder,这个是业界广泛使用的electron打包器,配置很多,功能丰富。这里只跑一下流程,详情请查看文档。首先安装打包器npm i electron-builder --save-dev,这个包安装过程中,会继续下载一系列配套工具,这里也需要T子或者用国内的镜像源。然后再package.json中添加如下配置:

{
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "electron index",
    "build": "webpack --color --config webpack.config.js",
    //  新增构建指令
    "pack": "electron-builder",
  },
  //    打包配置  
  "build": {
      // app_id,随意填写
		"appId": "xxx.wss.app",
        //  输出目录位置
		"directories": {
			"output": "build"
		},
        //  windows系统下的配置
		"win": {
            //  生成安装文件
			"target": [
				"nsis",
				"zip"
			],
            "icon": "icon.ico"
		},
        //  打包内容
		"files": [
            //  app内容
			"app/**/*",
            //  启动文件
			"index.js"
		]
  },
}

注意,为了方便理解才加了注释,json文件里不允许有注释的。这里我们新增打包指令和相应的配置。运行指令npm run pack,进入build文件夹,最后的成品大概这样:

顺利的话点击exe文件安装即可。

总结

electron极大地拓展了前端在桌面端开发的空间,现在大厂中的桌面应用需求,很多都是以它为平台进行开发的,传统的桌面端应用开发已经比较少见(指的是简单应用)。以上只是非常基础的一个示例,electron自身提供了非常丰富的api,以它为桥梁,我们可以桌面操作系统进行非常丰富的交互,很多的细节都在官方文档中。笔者很早之前就想学习electron,不过没有合适的机会,这里借这个机会也上手了桌面端的开发,技术侧的学习就是这样,实操胜过一切。

参考链接

electron官方文档
electron-builder官方文档
文中项目git地址