基於electron搭建vue組件組合工具

前言

雪球系統,經過拖拽基礎組件,組合成複合組件的一個桌面應用,最終以.vue文件的形式保存到指定目錄,旨在加快工做效率。javascript

操做系統爲 Mac OS。css

效果展現

從gif中能夠看出應用的主要功能,界面左側爲組件列表,中間部分是效果展現區和代碼展現區,界面右側爲操做按鈕。從操做中能夠看出,隨着基礎組件被拖拽到效果展現區,不只效果變了,代碼展現區的代碼也跟着變了,最終以vue文件的形式產出。 html

d1.gif

項目地址

snowBallvue

第一次在掘金髮文,寫的很差請多擔待,項目僅供學習,請勿商用,如轉載請標明出處。最後,以爲不錯的小夥伴給一下star哦~java

技術選型

展現中有明顯的IO操做,咱們來作個對比, 傳統的web項目:若是要實現IO操做,要基於node來作,部署到服務器上,涉及到修改源文件到話,安全性欠佳。 electron:Electron經過將Chromium和Node.js合併到同一個運行時環境中,並將web項目打包爲Mac,Windows和Linux系統下的應用。 實際項目中會修改到工程中到源文件,因此最終選擇electron做爲最終方案。node

electron簡介

主進程和渲染器進程

Electron 運行 package.json 的 main 腳本的進程被稱爲主進程。 在主進程中運行的腳本經過建立web頁面來展現用戶界面。 一個 Electron 應用老是有且只有一個主進程。linux

因爲 Electron 使用了 Chromium 來展現 web 頁面,因此 Chromium 的多進程架構也被使用到。 每一個 Electron 中的 web 頁面運行在它本身的渲染進程中。webpack

在普通的瀏覽器中,web頁面一般在沙盒環境中運行,而且沒法訪問操做系統的原生資源。 然而 Electron 的用戶在 Node.js 的 API 支持下能夠在頁面中和操做系統進行一些底層交互。git

主進程和渲染進程之間的區別

主進程使用 BrowserWindow 實例建立頁面。 每一個 BrowserWindow 實例都在本身的渲染進程裏運行頁面。 當一個 BrowserWindow 實例被銷燬後,相應的渲染進程也會被終止。github

主進程管理全部的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查看。

相關文章
相關標籤/搜索