[实战] Electron + React 打造一个高颜值 cli-to-gui 伪终端工具

5,631 阅读8分钟

现在写个前端谁还不用个构建工具,每天早晨回去,找对应的项目目录,打开命令行工具,敲个npm run xxx,重复得有点无聊。特别面对着日渐增长的项目数量,好希望有个工具可以帮我管理所有的项目,两手抓,一手起项目,一手抓我的叉烧包。emmmm...

需要项目源码点这里哈😊

界面功能介绍

项目添加:通过拖拽项目package.json文件到应用面板完成解析(不是.json文件?你试试)

项目管理:红色区域展示添加的项目,支持切换/删除/重命名;黄色区域展示package.json中的scripts脚本,点击即可执行;绿色区域管理该项目下的多个命令行窗口,支持增加/删除/切换;蓝色区域为命令行执行区域。

一次过满足您三个愿望

  1. 小型的Teminal客户端,多tab切换,按项目分组管理
  2. 支持持久化存储,重新打开应用,基本的项目信息可复原、不丢失
  3. 开发/打包不用敲命令行了,轻轻一点击即可完成
  4. 高颜值,颜值即正义 (不接受反驳 !!!)

技术栈

  • 运行环境: node v8 + electron v4.0 + macOSX v10.13(还没支持windows,因为node-pty在windows上一直运行出错,使用官网demo测试都不行,黔驴技穷呀。大佬们有兴趣可以试试,指点下我 链接
  • electron 负责将web打包成一个桌面应用
  • react + redux 网页端的开发框架
  • redux-persist 应用数据持久化方案
  • node-pty + xtem.js web端构造shell命令行容器的解决方案(vscode内置的shell终端也是基于他俩实现哦)

开发环境搭建

项目目录一览

electron —— electron-quick-start

package.json目录的main字段指定electron应用的主渲染进程文件,除了该js文件以外,其他的都属于在渲染进程运行。

"main": "electron/index.js"

electron/index.js中,配置electron加载的文件——开发环境下加载开发服务器文件,生成环境下加载本地文件。

isDev 
    ? mainWindow.loadURL('http://localhost:3000/index.html')
    : mainWindow.loadFile(path.join(__dirname, '../react/build/index.html'))

react —— create-react-app

  • 把react的index.html指向electron目录,在react/config/paths.js中修改

    appHtml: resolveApp('../electron/index.html')

  • react的脚手架没有默认支持stylus?心好痛啊。自己动手,丰衣足食。create-react-app脚手架默认把webpack配置藏到node_modules中,需要执行npm run eject后才能释放出来。找到/config/webpack.config.js文件,参照sass的配置,写一遍stylus的,这样之后xx.styl的文件会被stylus-loader处理,xx.module.styl的文件会被当成局部样式处理,类似于.vue文件的<style lang="stylus" scoped>

    const stylusRegex = /\.(styl)$/;
    const stylusModuleRegex = /\.module\.(styl)$/;
    
    // module里追加stylus的配置
    {
      test: stylusRegex,
      exclude: stylusModuleRegex,
      use: getStyleLoaders(
        {
          importLoaders: 2,
          sourceMap: isEnvProduction && shouldUseSourceMap,
        },
        'stylus-loader'
      ),
      sideEffects: true,
    },
    {
      test: stylusModuleRegex,
      use: getStyleLoaders(
        {
          importLoaders: 2,
          sourceMap: isEnvProduction && shouldUseSourceMap,
          modules: true,
          getLocalIdent: getCSSModuleLocalIdent,
        },
        'stylus-loader'
      ),
    }, 
    
  • 使用开发者工具。安装react-devtool(待续...)

Coding 编码开始

react

  1. componentDidUpdate

    应用窗口resize后,web终端模拟器都要重新适配父元素的大小。在react中涉及dom更新后需要处理的逻辑(放在callback函数里),一是使用this.setState({}, callback);二是在componentDidUpdate里处理。前者特别方便快捷,更新行为跟数据源绑定到一起,类似于vuevm.$nextTick。后者就要麻烦很多了(谁叫你把状态放在全局中处理呢),要特别设定一个isNew变量来决定dom的更新回调是否执行。

  2. ref属性引用的传递

    ref属性不属于props,因此不走寻常路,在高阶组件里需要“委曲求全”地转发(官宣)。

    // Layout.js
    import Main from 'Main.js'
    export default class Layout extends Component {
      constructor (props) {
        super(props)
        this.mainRef = React.createRef()
      }
    
      render () {
        return (
          <Main ref={this.mainRef} />
        )
      }
    }
    
    // Main.js
    class Main extends Component {
      render () {
        const {
          myRef
        } = this.props
    
        return (
          <div ref={myRef}></div>
        )
      }
    }
    
    export default React.forwardRef((props, ref) => {
      // 把ref引用赋给名为'myRef'的props,达到传递的目的
      return (
        <Main
          myRef={ref}
          {...props}
        />
      )
    })
    

redux

  1. 异步dispatch action

    可使用redux-thunk/redux-saga,由于nodejs环境原生支持文件同步读取fs.readFileSync,所以以下两种方法均可以。

  1. reselect

    类似vuex的computed属性

    
    // /store/selectors/project.js
    import { createSelector } from 'reselect'
    
    // 计算依赖值
    const projectsSelector = state => state.project.projects
    const activeIdSelector = state => state.project.activeId
    
    export const getXtermList = createSelector(
      projectsSelector,
      activeIdSelector,
      (projects, id) => {
        // 入参对应createSelector前两位参数的结果值
        const project = projects.find(p => p.id === id)
    
        // must return a new "xterms", otherwhiles, it cannot update. 这里使用[ ...project.xterms ]是返回新的对象引用,否则不被看做有更新
        const xterms = (project && project.xterms) ? [...project.xterms] : []
    
        return xterms
      }
    )
    
    
    // /src/Tab.js
    import { getXtermList } from '/store/selectors/project.js'
    @connect(
      state => ({
        xterms: getXtermList(state),
      })
    )
    class Tabs extends Component {
    
      render () {
    
        <div>
          {
            this.props.xterms.map(() => (
              <div>
                {/* ... */}
              </div>
            ))
          }
        </div>
      }
    }
    export default Tabs
    
  2. redux-persist

    • redux-persist也是使用的webStorage(localStorage/sessionStorage),只支持ES5的数据类型,因此需要对我们的store数据做过滤,只留下项目基本信息的字段。
    • 官方文档也是够坑了,没有讲需要自己动手改造我们的reducer。最后google个天荒地老才在issue里发现宝藏刷新后数据无法恢复

node-pty + xterm.js

  1. node-pty伪终端是node和系统shell之间的通讯中间库;xterm.js负责绘制浏览器端的终端模拟器。web终端使用表单模拟输入,基本具备所有表单的api能力,支持代码自动触发和手动输入触发。

    const os = window.require('os')
    const pty = window.require('node-pty')
    const Terminal = window.require('xterm').Terminal
    
    class Xterm {
    
      constructor () {
    
        this.xterm = null
        this.ptyProcess = null
    
        this.createTerminal()
      }
    
      createTerminal () {
    
        const shell = Xterm.shell
        // 创建伪终端进程
        this.ptyProcess = pty.spawn(shell, [], this.opts)
        // 创建web终端模拟器
        this.xterm = new Terminal()
    
        this.initEvent()
      }
    
      initEvent () {
    
        // web终端模拟器监听用户输入,写入系统shell
        this.xterm.on('data', data => {
          this.ptyProcess.write(data)
        })
        // node-pty监听系统shell输出,写入web终端模拟器
        this.ptyProcess.on('data', data => {
          this.xterm.write(data)
        })
      }
    
      /**
       * 获取系统信息,拿到对应的shell终端
       */
      static get shell () {
        return window.process.env[os.platform() === 'win32' ? 'COMSPEC' : 'bash']
      }
    }
    

electron

  1. nodejs和webpack的模块管理冲突 webpack继续使用import/require,node模块的引入使用window.require,就可以逃过webpack的编译

  1. main process 调试

    • 热重启:

      • electron加载 .html文件时可使用electron-reload插件工具
      • 因为我们的electron加载的是一个webpack-dev-server开发服务器,所以需要用nodemon(监听除了react源码——app文件夹以外的其他文件)来做应用重启,react代码的热重启基于自身脚手架。
      // package.json
      "scripts": {
       "start": "electron .",
      "watch": "nodemon --watch . --ignore 'app' --exec \"npm start\"",
      "rebuild": "electron-rebuild -f -w node-pty"
      }
      
    • 打印:使用electron-log,打印信息和node调试信息一样展示在控制台中

  2. 主进程和渲染进程的通信

    Electron为主进程( main process)和渲染器进程(renderer processes)通信提供了多种实现方式,如可以使用ipcRenderer 和 ipcMain模块发送消息。通过这种方式可以模拟右键菜单进行系统级的操作(如打开系统的某个文件目录)

    // react
    const { ipcRenderer } = window.require('electron')
    // renderer process 发送显示右键菜单的请求
    ipcRenderer.send('show-context-menu'})
    
    // electron
    const {
      app,
      BrowserWindow,
      ipcMain,
      Menu,
      MenuItem
    } = require('electron')
    
    const template = [
      {
        label: '重命名',
        click: this.rename.bind(this)
      },
      {
        label: '打开文件目录',
        click: this.openFileManager.bind(this)
      }
    ]
    // 创建右键菜单
    const menu = Menu.buildFromTemplate(template)
    
    // main process 监听renderer process请求
    ipcMain.on('show-context-menu', (e, data) => {
    
      const win = BrowserWindow.fromWebContents(e.sender)
      // 弹出右键菜单
      menu.popup(win)
    })
    
  3. 原生desktop app菜单

    让换肤/toggle控制台/刷新程序等app功能常驻于程序菜单项里

打包发布electron-react项目

  • 打包。工具使用electron-builder,确保系统环境 一定要使用nodev8版本 一定要使用nodev8版本 一定要使用nodev8版本,曾经使用了v10,把网上几乎所有的demo项目都运行过了一遍,发现都在打包过程中出错,绝望地死磕了3、4天。

    • react打包。文件引用使用相对路径——在package.json中加入 "homepage": "./"。因为electron应用加载资源是使用本地文件的方式,使用相对路径,而以前web服务后台习惯使用绝对路径加载。

    打包后的路径为相对路径

    • node原生模块的编译

      如果项目里使用了一些node原生模块(用 C++ 编写的 Node.js 扩展),在安装后需要经过编译才能被使用。例如该项目使用了node-pty,可以通过以下两种办法编译,不编译会报错!!第一种方式在npm install后将自动执行,第二种则需要手动执行。

      To ensure your native dependencies are always matched electron version 源自electron-builder的说明

      • "postinstall": "electron-builder install-app-deps"
      • "rebuild": "electron-rebuild -f -w node-pty"
    • electron依赖下载。windows和mac都有全局缓存路径的,如果使用npm下载卡住无法进行下去,可以尝试去淘宝镜像网站(electron下载链接)下载文件放到对应系统的缓存目录,然后使用npm install安装已经下载的版本号,缓存的electron文件即可被使用。(我?当然是搬个🍇(和谐了)直接下载)

    mac缓存目录
    windows缓存目录

  • 发布release版本

    使用Travis配合electron-builder --publish指令,git push后自动通过travis-ci打包,把app提交到github的release中。

    • 配置Travis CI,让代码仓库和CI发布流程关联起来。参考教程
    • 配置 .travis.yml,打包发布工作流的配置文件

    • 发布后效果

    GitHub上已提供打包后的程序,欢迎下载使用或者下载源码自行构建(目前仅支持macOS)下载体验地址

  • 安装程序

    安装时提示非信任应用程序??抱歉,来不及做macOS签名。所以需要自行允许运行程序。处理教程

后话

  • 体验度、集成度更高的的electron+react项目模板 electron-react-boilerplate
  • 各位看官如果喜欢的话,麻烦点个赞star,谢谢鼓励

参考鸣谢