基于electron搭建vue组件组合工具

2,458 阅读8分钟

前言

雪球系统,通过拖拽基础组件,组合成复合组件的一个桌面应用,最终以.vue文件的形式保存到指定目录,旨在加快工作效率。

操作系统为 Mac OS。

效果展示

从gif中可以看出应用的主要功能,界面左侧为组件列表,中间部分是效果展示区和代码展示区,界面右侧为操作按钮。从操作中可以看出,随着基础组件被拖拽到效果展示区,不仅效果变了,代码展示区的代码也跟着变了,最终以vue文件的形式产出。

d1.gif

项目地址

snowBall

第一次在掘金发文,写的不好请多担待,项目仅供学习,请勿商用,如转载请标明出处。最后,觉得不错的小伙伴给一下star哦~

技术选型

展示中有明显的IO操作,我们来做个对比, 传统的web项目:如果要实现IO操作,要基于node来做,部署到服务器上,涉及到修改源文件到话,安全性欠佳。 electron:Electron通过将Chromium和Node.js合并到同一个运行时环境中,并将web项目打包为Mac,Windows和Linux系统下的应用。 实际项目中会修改到工程中到源文件,所以最终选择electron作为最终方案。

electron简介

主进程和渲染器进程

Electron 运行 package.json 的 main 脚本的进程被称为主进程。 在主进程中运行的脚本通过创建web页面来展示用户界面。 一个 Electron 应用总是有且只有一个主进程。

由于 Electron 使用了 Chromium 来展示 web 页面,所以 Chromium 的多进程架构也被使用到。 每个 Electron 中的 web 页面运行在它自己的渲染进程中。

在普通的浏览器中,web页面通常在沙盒环境中运行,并且无法访问操作系统的原生资源。 然而 Electron 的用户在 Node.js 的 API 支持下可以在页面中和操作系统进行一些底层交互。

主进程和渲染进程之间的区别

主进程使用 BrowserWindow 实例创建页面。 每个 BrowserWindow 实例都在自己的渲染进程里运行页面。 当一个 BrowserWindow 实例被销毁后,相应的渲染进程也会被终止。

主进程管理所有的web页面和它们对应的渲染进程。 每个渲染进程都是独立的,它只关心它所运行的 web 页面。

在页面中调用与 GUI 相关的原生 API 是不被允许的,因为在 web 页面里操作原生的 GUI 资源是非常危险的,而且容易造成资源泄露。 如果你想在 web 页面里使用 GUI 操作,其对应的渲染进程必须与主进程进行通讯,请求主进程进行相关的 GUI 操作。

进程间通讯

Electron为主进程( main process)和渲染器进程(renderer processes)通信提供了多种实现方式,如可以使用ipcRenderer 和 ipcMain模块发送消息,使用 remote模块进行RPC方式通信。

electron-vue脚手架

项目基于 electron-vue

# 安装 vue-cli 和 脚手架样板代码
npm install -g vue-cli
vue init simulatedgreg/electron-vue my-project

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

运行起来之后,会自动起一个客户端。

项目实现过程

拖拽与多物体碰撞检测

d3.gif
如图所示,如果总容器中添加容器,拖拽组件的时候就需要检测碰撞到的到底是哪个容器。

先考虑单个物体碰撞,如下图所示,拖拽物体D如果一直在容器Container的左侧、右侧、上侧或者下侧的话,那就代表一直没有碰撞。同理,当检测是否与多个物体碰撞时,只需要将多个物体的参数与拖拽物体D的参数做对比就可以了。

d4.png
代码如下:

mousedown(e, tag) {
      // oDiv为拖拽物体
      let oDiv = this.$refs.move
      this.nowTag = tag
      let left = getElementPageLeft(e.target)
      let top = getElementPageTop(e.target)
      oDiv.style.left = left + 'px'
      oDiv.style.top = top + 'px'
      this.showMove = true
      this.disX = e.clientX - oDiv.offsetLeft - left
      this.disY = e.clientY - oDiv.offsetTop - top
      // oDiv2为总容器
      let oDiv2 = this.$refs.hideBox
      let children = Array.from(oDiv2.childNodes)
      this.mousemove(oDiv, children.length > 0 ? children : [oDiv2], this.disX, this.disY, children.length > 0)
    },
    mousemove(oDiv, arr, disX, disY, needTest = false) {
      // needTest表示总容器中有子容器,若检测完子容器没有碰撞,则需要再检测与总容器是否碰撞
      // 将需要使用到到目标容器属性存在数组中,减少dom操作
      arr = arr.map((item, index) => {
        const obj = {
          className: item.className,
          t2: getElementPageTop(item),
          l2: getElementPageLeft(item),
          r2: getElementPageLeft(item) + item.offsetWidth,
          b2: getElementPageTop(item) + item.offsetHeight
        }
        return obj
      })
      // 移动物体跟着鼠标移动
      document.onmousemove = ev => {
        oDiv.style.left = ev.clientX - disX + 'px'
        oDiv.style.top = ev.clientY - disY + 'px'
      }
      // 鼠标抬起检测碰撞
      document.onmouseup = (ev) => {
        ev = ev || window.event
        let t1 = getElementPageTop(oDiv)
        let l1 = getElementPageLeft(oDiv)
        let r1 = getElementPageLeft(oDiv) + oDiv.offsetWidth
        let b1 = getElementPageTop(oDiv) + oDiv.offsetHeight
        for (let index = 0; index < arr.length; index++) {
          let item = arr[index]
          // isKnocked为true表示没有碰撞
          const isKnocked = b1 < item.t2 || l1 > item.r2 || t1 > item.b2 || r1 < item.l2
          arr[index].isKnocked = !isKnocked
        }
        // 状态重置
        document.onmousemove = null
        document.onmouseup = null
        oDiv.style.left = '0px'
        oDiv.style.top = '0px'
        this.showMove = false
        // 筛选结果 当检测完没有碰到到子盒子时,继续检测是否碰撞到父盒子
        let result = arr.filter((item, index) => {
          return item.isKnocked
        })
        if (result.length > 0) {
          this.knockSuccess(result[0].className)
        } else {
          if (needTest) {
            let oDiv2 = this.$refs.hideBox
            const object = {
              className: oDiv2.className,
              t2: getElementPageTop(oDiv2),
              l2: getElementPageLeft(oDiv2),
              r2: getElementPageLeft(oDiv2) + oDiv2.offsetWidth,
              b2: getElementPageTop(oDiv2) + oDiv2.offsetHeight
            }
            // isKnocked为true表示没有碰撞
            const isKnocked = b1 < object.t2 || l1 > object.r2 || t1 > object.b2 || r1 < object.l2
            if (!isKnocked) {
              this.knockSuccess(oDiv2.className)
            }
            console.log('object~~~~', object, isKnocked)
          }
        }
        console.log('~~~~~碰撞结束', result[0])
      }
    }

其中getElementPageLeft和getElementPageTop为物体离可视区左侧和上侧的真实距离

const getElementPageLeft = (element) => {
  var actualLeft = element.offsetLeft
  var parent = element.offsetParent
  while (parent != null) {
    actualLeft += parent.offsetLeft + (parent.offsetWidth - parent.clientWidth) / 2
    parent = parent.offsetParent
  }
  return actualLeft
}

const getElementPageTop = (element) => {
  var actualTop = element.offsetTop
  var parent = element.offsetParent
  while (parent != null) {
    actualTop += parent.offsetTop + (parent.offsetHeight - parent.clientHeight) / 2
    parent = parent.offsetParent
  }
  return actualTop
}

SFC(Single File Components)的拆分与组合

碰撞检测之后要做的事情就是SFC的拆分与组合。 vue的SFC文件都有一定的格式

d5.png
如上图所示,一个SFC文件由三个部分组成,template、script和style,其中style最好处理,将2个组件的style部分拼接起来就行,其次template部分,总容器中的innerHTML就是最新的html结构。 难点是script部分,首先data是一个函数返回一个对象,对象有可能是多层嵌套结构,props部分也有点特殊,type的值在对象和字符串的转换中会出问题,watch和methods可以归为对象,生命周期都可以看作是函数,函数中的函数体可以通过拼接的方式组合成新的函数。

SFC拆分成3部分

import fs from 'fs'
import path from 'path'

// 获取vue文件并拆分
const getVueContent = (src) => {
  let fileContents = fs.readFileSync(path.join(__static, src), 'utf8')
  return splitTmp(fileContents)
}

// vue字符串拆分成template、js、css
const splitTmp = (fileContents) => {
  const scriptReg = /<script.*?>([\s\S]+?)<\/script>/img
  const temReg = /<template>([\s\S]+?)<\/template>/img
  const styleReg = /<style.*?>([\s\S]+?)<\/style>/img

  let scriptResult = scriptReg.exec(fileContents)[1]
  let temResult = temReg.exec(fileContents)[1]
  let styleResult = styleReg.exec(fileContents)[1]
  scriptResult = scriptResult.split('export default')[1].replace(/(^\s*)|(\s*$)/g, '')
  return {
    temResult,
    scriptResult,
    styleResult
  }
}

函数不能通过JSON.stringify()的方式直接转成字符串,所以通过fnToString方法处理,并将props的特殊情况一同处理

// 转js模块成带有###Fn### 的字符串
const JsonToSpecialStr = (data) => {
  data = fnToString(data)
  data = JSON.stringify(data)
  if (data.indexOf('\\n')) {
    data = data.replace(/\\n/g, ' \n').replace(/["]/g, '')
  }
  return data
}

// 将json对象中的方法转换成字符串,并在方法前加上###Fn###  方便后面去除
const fnToString = (data) => {
  for (let [key, value] of Object.entries(data)) {
    if (typeof value === 'string') {
      data[key] = value
    } else if (typeof value === 'function') {
      data[key] = `###Fn###${value.toString()}`
    } else if (key === 'props') {
      let props = ''
      for (let [key1, value1] of Object.entries(value)) {
        let name = value1.type.name
        if (name === 'String') {
          props += `${key1}:{default: '${value1.default}', type: ${name}},`
        } else if (name === 'Number' || name === 'Boolean') {
          props += `${key1}:{default: ${value1.default}, type: ${name}},`
        } else if (name === 'Object' || name === 'Array') {
          props += `${key1}:{default: () => {}, type: ${name}},`
        }
        data[key] = `{${props.substr(0, props.length - 1)}}`
      }
    } else {
      fnToString(data[key])
    }
  }
  return data
}

后续在合并的时候去除###Fn###

// 去除###Fn### 
const FnMove = (string) => {
  const FnMoveReg = /###Fn###([\s\S]+?)\(/img
  let  moveStr = FnMoveReg.exec(string)
  if (moveStr === null) {
    return string
  }
  string = string.replace(`${moveStr[1]}:###Fn###`, '')
  return FnMove(string)
}

代码的美化

代码组合好之后,格式不是很工整,所以需要美化一下。 安装依赖vue-beautify

npm i vue-beautify -S

使用如下,其中options为格式配置项

var vueBeautify = require('vue-beautify')

// 美化模版
const beautifyTmp = (res) => {
  const options = {
    intent_scripts: 'keep',
    indent_size: 2,
    space_in_empty_paren: true
  }
  let output = `
    <template>${res.temResult}</template>
    <script>
      export default ${res.scriptResult}
    </script>
    <style scoped lang="less">
      ${res.styleResult}
    </style>
  `
  output = FnMove(output)
  output = vueBeautify(output, options)
  return output
}

效果展示区实现热更新

d6.png
先介绍一下项目中static文件夹目录结构,components文件夹存放待拖拽的基础组件,vue-tmp文件夹为令一个vue项目。

效果展示区实现热更新过程:eletron项目启动的同时会启动vue-tmp,并开启热更新,主项目中用iframe来展示vue-tmp页面。当基础组件被拖拽碰撞到容器时,将基础组件写入vue-tmp项目中的App.vue中,webpack-dev-server检测到变动,刷新视图。

这里要实现eletron启动之后自执行命令行语句,所以要用到node的子进程child_process。

检查端口是否占用

启动vue-tmp前要检查一下端口是否被占用,如果占用要先释放端口(linux和windows方式不一样,这里只实现了linux)

const exec = require('child_process').exec

// 任何你期望执行的cmd命令,ls都可以
let findStr = 'lsof -i:1112'
// 执行cmd命令的目录,如果使用cd xx && 上面的命令,这种将会无法正常退出子进程
let cmdPath = './'
// 子进程名称
let findProcess, killProcess

function runFindExec(Fn) {
  // 执行命令行,如果命令不需要路径,或就是项目根目录,则不需要cwd参数:
  findProcess = exec(findStr, {cwd: cmdPath})
  // 不受child_process默认的缓冲区大小的使用方法,没参数也要写上{}:workerProcess = exec(cmdStr, {})

  // 打印正常的后台可执行程序输出
  findProcess.stdout.on('data', Fn)

  // 打印错误的后台可执行程序输出
  findProcess.stderr.on('data', function (data) {
    console.log('stderr: find:' + data)
  })

  // 退出之后的输出
  findProcess.on('close', function (code) {
    console.log('out code find:' + code)
  })
}

function runKillExec(pid) {
  // 执行命令行,如果命令不需要路径,或就是项目根目录,则不需要cwd参数:
  killProcess = exec(`kill ${pid}`, {cwd: cmdPath})
  // 不受child_process默认的缓冲区大小的使用方法,没参数也要写上{}:workerProcess = exec(cmdStr, {})

  // 打印正常的后台可执行程序输出
  killProcess.stdout.on('data', data => {
    console.log('stderr222: ', data)
  })

  // 打印错误的后台可执行程序输出
  killProcess.stderr.on('data', function (data) {
    console.log('stderr: kill:' + data)
  })

  // 退出之后的输出
  killProcess.on('close', function (code) {
    console.log('out code kill:' + code)
  })
}

runFindExec(data => {
  let reg = /node([\s\S]+?)IPv4/mg
  let res = reg.exec(data)
  if (res) {
    res = parseFloat(res[1])
    console.log('stderr111: ', res)
    if (res) {
      runKillExec(res)
    }
  }
})

eletron启动vue-tmp项目

import outFileBase from './setOutFileBase'
// const exec = require('child_process').exec
const spawn = require('child_process').spawn

let cmdPath = outFileBase.split('/src/App.vue')[0]
console.log('cmdPath', cmdPath)
// 子进程名称
let workerProcess
function runExec(Fn) {
  // 执行命令行,如果命令不需要路径,或就是项目根目录,则不需要cwd参数:
  workerProcess = spawn('/usr/local/bin/node', [`${cmdPath}/node_modules/.bin/webpack-dev-server`, '--hot', '--port', '1112'], {
    cwd: cmdPath
  })

  // 打印正常的后台可执行程序输出
  workerProcess.stdout.on('data', Fn)

  // 打印错误的后台可执行程序输出
  workerProcess.stderr.on('data', function (data) {
    console.log('stderr: npm run dev' + data)
  })

  // 退出之后的输出
  workerProcess.on('close', function (code) {
    console.log('out code:npm run dev' + code)
  })
}
export {
  runExec
}

简易ide实现

d7.gif
利用codemirror实现简易ide,包括代码高亮、修改、tab等功能。

npm i codemirror -S
<template>
  <div class="in-coder-panel">
    <textarea ref="textarea"></textarea>
  </div>
</template>

<script>
  // 引入全局实例
  import _CodeMirror from 'codemirror'

  // 核心样式
  import 'codemirror/lib/codemirror.css'
  // 引入主题后还需要在 options 中指定主题才会生效
  import 'codemirror/theme/monokai.css'

  // 需要引入具体的语法高亮库才会有对应的语法高亮效果
  // codemirror 官方其实支持通过 /addon/mode/loadmode.js 和 /mode/meta.js 来实现动态加载对应语法高亮库
  // 但 vue 貌似没有无法在实例初始化后再动态加载对应 JS ,所以此处才把对应的 JS 提前引入
  import 'codemirror/mode/javascript/javascript.js'
  import 'codemirror/mode/vue/vue.js'

  // 尝试获取全局实例
  const CodeMirror = window.CodeMirror || _CodeMirror

  export default {
    name: 'in-coder',
    props: {
      // 外部传入的内容,用于实现双向绑定
      value: String
    },
    data () {
      return {
        // 内部真实的内容
        code: '',
        // 默认的语法类型
        mode: 'x-vue',
        // 编辑器实例
        coder: null,
        // 默认配置
        options: {
          // 缩进格式
          tabSize: 2,
          // 主题,对应主题库 JS 需要提前引入
          theme: 'monokai',
          // 显示行号
          lineNumbers: true,
          line: true,
          extraKeys: { 'Ctrl': 'autocomplete' },
          hintOptions: {
            // 当匹配只有一项的时候是否自动补全
            completeSingle: false
          }
        }
      }
    },
    mounted () {
      // 初始化
      this._initialize()
    },
    watch: {
      value(data) {
        this.coder.setValue(this.value)
      }
    },
    methods: {
      // 初始化
      _initialize () {
        // 初始化编辑器实例,传入需要被实例化的文本域对象和默认配置
        this.coder = CodeMirror.fromTextArea(this.$refs.textarea, this.options)
        // 编辑器赋值
        this.coder.setValue(this.value || this.code)
        // 修改编辑器的语法配置
        this.coder.setOption('mode', `text/${this.mode}`)
        // 支持双向绑定
        this.coder.on('change', (coder) => {
          this.code = coder.getValue()

          if (this.$emit) {
            this.$emit('input', this.code)
          }
        })
      }
    }
  }
</script>

<style lang="scss" scoped="" type="text/css">
  .in-coder-panel {
    width: 375px;
    height: 667px;
  }
</style>

画布、容器样式

可以调整画布和容器的布局方式(flex或者block),可以设置背景色方便观察。

d8.gif

文件重置

d9.gif

清除背景

清除掉之前设置的背景色。

d10.gif

导出文件

将文件导出到用户选择的文件夹。

gif太大,上传不了了,效果可到snowBall或者blog查看。