用Vite+Electron搞个码上掘金(前端代码编辑工具)

5,201 阅读7分钟

我正在参加跨端技术专题征文活动,详情查看:juejin.cn/post/710123…

介绍

掘金平台在4月份推出的新功能【码上掘金】备受瞩目,在里面你可以通过代码编制一些创意想法从而实时编译出来看到立即看到展示效果,可以说就是国内版的codepen。本期将用Vite和Electron带大家仿制【码上掘金】,来开发出一款跨平台的代码编辑工具软件,其中从创建,开发到打包一应俱全,喜欢编制创意代码的同学不容错过哟~

首先,我们先康康最终效果吧:

演示图.gif

Web版在线地址: jsmask.gitee.io/cc-code-edi…

正文

创建vite

pnpm create vite

我们这里选择使用vue-ts来开发这个项目。

示图1-1.png

cd code-edit-app
pnpm i
pnpm dev

我们创建完vite后,先来运行一下,看看是否能本地跑起来。

示图1-2.png

创建主窗体

搭建之前,我们先下载一下 electron。

pnpm i electron -D

我们先在 package.json 中写入 electron 的入口文件。

// package.json
{
    "main": "electron/main.js",
}

然后,我们就要创建这个应用的主窗体了。

// electron/main.js
const { app, BrowserWindow } = require('electron')
const path = require('path')

function createWindow() {
  // 创建浏览器窗口 
  const mainWindow = new BrowserWindow({
    width: 1280,
    height: 720,
    autoHideMenuBar: true, // 自动隐藏菜单栏
    frame: true,  // 边框窗口
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),  // 隔离vite和Electron之间的状态
      nodeIntegration: true, // 使用页面中可以引入node和electron相关的API
      contextIsolation: false, // 是否在独立 JavaScript 环境中运行 Electron API和指定的preload 脚本
    },
    icon: path.join(__dirname, '../public/favicon.ico'),
  })
  
 // 主窗体要加载的url
  mainWindow.loadURL('http://localhost:3000')
}

app.whenReady().then(() => {
  createWindow()
    
  app.on('activate', ()=>{
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', ()=>{
  if (process.platform !== 'darwin') app.quit()
})

这里注意两点:

  • 目前将会加载本地3000端口的URL地址,后面是想用vite开启3000端口服务后得到地址然后再唤起electron进程构建其应用。
  • 为了让页面内部也可以使用electron和nodejs,所以要把 nodeIntegration 属性开启。

另外,为了后面的资源都可以访问到不出问题,我们还要修改 vite.config.ts 文件的 base

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  base:"./",
  plugins: [vue()],
})

热更新服务

接下来,为了 vite 和 electron 运行正常,就要需要先让vite运行,其服务完成后,保证本地3000端口可以正常访问运行了,再开启electron去加载url,最后再开启这个应用服务。其目的,大家也能猜到,其实就是为了在以后要让写进去的业务代码实时更新到服务中,然后再显示到应用上,后面就开发就非常的方便。

但我们怎么让这两个服务按刚才的要求运行呢?当然可以用两个本办法,就是手动开两个终端,一个终端跑vite,一个终端跑electron,但这样开发起来十分的麻烦,所以下面要安装几个脚本方便我们完成这件事咯。

pnpm i concurrently wait-on cross-env -D
  • concurrently:同时运行多个命令,在node实现任务自动化的业务中经常使用
  • wait-on:来完成等待资源,监控正在构建的文件的作用,我们这里用来监听vite是否生成url
  • cross-env: 可以用它设置一些环境变量,比如这里用它设置变量NODE_ENV来区分生成环境还是开发环境

我们创建两个指令,来同时开启这两个服务。

// package.json
{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
    "electron": "wait-on tcp:3000 && cross-env NODE_ENV=development electron .",
    "electron:serve": "concurrently -k \"npm run dev\" \"npm run electron\""
  }
}

其中用 cross-env 在开发环境赋值 NODE_ENV=development,而 concurrently -k--kill-others 是为了用来清除其它已经存在或者挂掉的进程。

接下来,我们运行下 electron:serve ,你就会发现非常快应用就自动打开了。

示图2-1.png

应用打包

为了后面我们专心写业务,所以我们把打包这步要提前做好。

首先,先要安装主流的 electron打包库。

pnpm i electron-builder -D

然后要在 package.json 写好打包配置,这里注意一下,logo图标需要256x256的ico格式的图片,不然很可能不会显示出来。

// package.json
{
  "build": {
    "appId": "cc-code-editor",
    "productName": "CC CodeEditor",
    "copyright": "Copyright © 2022 jsmask",
    "mac": {
      "icon": "./public/logo.ico"
    },
    "win": {
      "icon": "./public/logo.ico"
    },
    "linux": {
      "icon": "./public/logo.ico"
    },
    "nsis": {
      "oneClick": false,
      "allowToChangeInstallationDirectory": true,
      "installerIcon": "./public/favicon.ico",
      "uninstallerIcon": "./public/favicon.ico",
      "installerHeaderIcon": "./public/favicon.ico"
    },
    "files": [
      "dist/**/*",
      "electron/**/*"
    ],
    "directories": {
      "buildResources": "assets",
      "output": "build"
    }
  }
}

当然,光加个这些配置项可不行,还没有完呢,还要修改 electron/main.js 那个主窗体的入口文件。

// `electron/main.js`
const NODE_ENV = process.env.NODE_ENV
function createWindow() {
  // ...

  // 加载 index.html
  mainWindow.loadURL(NODE_ENV === 'development'
    ? 'http://localhost:3000'
    : `file://${path.join(__dirname, '../dist/index.html')}`)

  // 打开开发工具
  if (NODE_ENV === "development") {
    mainWindow.webContents.openDevTools()
  }
}

还记得上一步用我们刚在开发环境赋值为 development ,利用它我们让在开发环境下,自动打开开发工具,并且开发模式才会走URL,否则(生产环境)我们直接走本地vite打包后生产的文件。

此时,我们再加一道打包指令吧:

// package.json
{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
    "electron": "wait-on tcp:3000 && cross-env NODE_ENV=development electron .",
    "electron:serve": "concurrently -k \"npm run dev\" \"npm run electron\"",
    "electron:build": "npm run build && electron-builder"
  }
}

然后我们运行下应用打包指令:

pnpm run electron:build

等待一段时间就可以看到打包好的目录,点击就可以运行这个应用了,非常的方便。

示图3-1.png

但构建时会在外网下载包这一步很可能会卡住,由于默认的镜像源在国外,包下载速度和稳定性都不太好,有条件的小伙伴可以科学上网来解决。或者换成国内的镜像源配置一下地址即可:

npm config set ELECTRON_MIRROR "https://npmmirror.com/mirrors/electron/"
npm config set ELECTRON_BUILDER_BINARIES_MIRROR "https://mirrors.huaweicloud.com/electron-builder-binaries/"

Monaco Editor

众所周知,monaco-editor 是微软开源的一款非常强大的代码编辑器插件,其丰富的智能感知、验证、基本语法着色都非常的完美。此次,我们会用它来完成封装一个组件(EditBox.vue),这个组件将接收语法类型和初始内容,输入的同时还会通知父组件把输入的内容再传递过去,这就是我们这个前端代码编辑应用的其中一个核心部分。

<!-- EditBox.vue -->
<script setup lang="ts">
import { ref, nextTick, onMounted, withDefaults } from "vue";
import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import * as monaco from "monaco-editor";


const emit = defineEmits(["input"]);
const props = withDefaults(
  defineProps<{
    language?: string;
    content?: string;
  }>(),
  {
    language: "javascript",
    content: "",
  }
);

const editBox = ref<HTMLElement | null>(null);

// @ts-ignore
self.MonacoEnvironment = {
  getWorker(_: string, label: string) {
    if (["typescript", "javascript"].includes(label)) {
      return new TsWorker();
    }
    if (["json"].includes(label)) {
      return new JsonWorker();
    }
    if (["css", "scss"].includes(label)) {
      return new CssWorker();
    }
    if (["html"].includes(label)) {
      return new HtmlWorker();
    }
    return new EditorWorker();
  },
};

let editor: monaco.editor.IStandaloneCodeEditor;

function createEditor(): monaco.editor.IStandaloneCodeEditor {
  let editor: monaco.editor.IStandaloneCodeEditor;
  editor = monaco.editor.create(editBox.value as HTMLElement, {
    value: props.content,
    language: props.language,
    tabSize: 2,
    automaticLayout: true,
    scrollBeyondLastLine: false,
    theme: "vs-dark",
  });
  return editor;
}

onMounted(() => {
  editor = createEditor();
  editor.onDidChangeModelContent(() => {
    emit("input", editor.getValue());
  });
})
</script>

<template>
  <div ref="editBox" class="edit-box"></div>
</template>

上面一些vue3的基础语法就不再过多赘述了,这里唯一要说的是里面会引用很多Worker他们通过写这些根据语言来在编辑器中创造语法环境。

然后我们稍微在父页面就可以使用这个组件,使用三次分别传入html,css,javascript三种语言:

<script setup lang="ts">
import { ref, onMounted } from "vue";
import EditBox from "./components/EditBox.vue";

const codeContent = ref("");
const htmlContent = ref("");
const cssContent = ref("");
const jsContent = ref("");
    
onMounted(() => {});

function inputCodeContent(type: string, e: string) {
  let target: any;
  switch (type) {
    case "html":
      target = htmlContent;
      break;
    case "css":
      target = cssContent;
      break;
    case "javascript":
      target = jsContent;
      break;
    default:
      break;
  }
  target.value = e;
}
</script>

<template>
  <div class="page">
    <div class="edit-container">
      <div class="edit-warp">
        <div class="edit-head">html</div>
        <div class="edit-content">
          <EditBox
            language="html"
            :content="htmlContent"
            @input="inputCodeContent('html', $event)"
          />
        </div>
      </div>
      <div class="edit-warp">
        <div class="edit-head">css</div>
        <div class="edit-content">
          <EditBox 
            language="css"
            :content="cssContent" 
            @input="inputCodeContent('css', $event)" />
        </div>
      </div>
      <div class="edit-warp">
        <div class="edit-head">javascript</div>
        <div class="edit-content">
          <EditBox
            language="javascript"
            :content="jsContent"
            @input="inputCodeContent('javascript', $event)"
          />
        </div>
      </div>
    </div>
    <div class="preview"></div>
  </div>
</template>

再次运行如下图所示,编辑器主界面就完成了,而且编辑器也可以正常输入和做一些语法提示。

示图4-1.png

编译效果预览

为了展示和隔离环境,我们引入了 iframe 标签,接下来会通过在 srcdoc 属性上追加内容,来完成效果的预览。

<div class="preview">
   <iframe :srcdoc="codeContent" frameborder="0"></iframe>
</div>

【码上掘金】在iframe的显示其实是生成了一个在线的html文件的地址,要写的html,css和js代码处理后都会写入了进去来完成预览,而我们因为本地的,所以没那么复杂直接拼字符串完成即可,而且优点也很明显,因为【码上掘金】是在线平台每次编译都会请求接口来更新页面内容,所以并没有那么快速而且会加一些防抖节流的手段,本地暂时可以不用考虑这些了,当然你要在上面写的项目非常之大那还是要去做限制的,当前我们不用考虑这些,每当编辑器内容改变直接赋值即可。

import resetCss from "reset.css";
function renderCode() {
  codeContent.value = `
 <!DOCTYPE html>
 <html lang="en">
 <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 </head>
 <style>
 ${resetCss}
 ${cssContent.value}
 </style>
 <body>
 ${htmlContent.value}
 </body>
 <script type="module" crossorigin>
 ${jsContent.value}
 <\/script>
 `;
}

onMounted(() => {
  renderCode();
});

function inputCodeContent(type: string, e: string) {
  let target: any;
  switch (type) {
    case "html":
      target = htmlContent;
      break;
    case "css":
      target = cssContent;
      break;
    case "javascript":
      target = jsContent;
      break;
    default:
      break;
  }
  target.value = e;  
  try {
    renderCode();
  } catch (error) {}
}

另外,你可以发现我们这里引入了 reset.css ,其目的是完成样式的一些初始化,抹除了很多属性,因为我们在写样式的时候每次都要去除那么浏览器自带的一些丑陋样式,我们提前引入进去就可以避免重复写他们。

如下图,生成后我们可以看到重置的样式都写入进去了。

示图4-2.png

此时此刻,一个实时编译预览的编辑软件基础功能已经全部完成了,完成后你可以打包这个发给你的小伙伴展示试用了。

示图5-1.png

道阻且长

虽然基础功能已经完成了,但是其实还是十分的简陋比如语言切换,语言编译,自由布局,全屏,格式化,复制代码,本地存储,本地图床,生成导入文件等等还有很多很多设想都没有完成,所以要变得功能强大起来还有很长的路要走,很多时间精力去打磨,而且在做桌面软件时还会出现很多意外的问题。

最后就给大家讲一个比较严重的问题吧,就是在Electron用了 alert 会阻断进程,恢复后输入框会失效,这是 Electron 一直存在的一个bug,解决方案有很多,这里我们用的方案是把window对象中的alert抹除替换成Electron的dialog方法。

const jsAlert = () => {
  if (window.process) {
    const ipcRenderer = require("electron").ipcRenderer;
    (window as any).ipcRenderer = ipcRenderer;
    return `
    window.alert = message =>{
      window.top.ipcRenderer.send("alert",message)
    };`;
  }
  return "";
};

通过渲染进程发送同步和一步的消息到主进程的方式,通知主进程,当接收到 alert 消息时,用dialog方法弹出就解决了。

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

ipcMain.on('alert', (e,message)=>{
  dialog.showMessageBox({ type: 'info', title:"alert",message})
})

演示图.gif

至此,这个前端代码编辑软件第一版的开发已经完成了,虽然还有很多设想的功能没有实现,但是我们后面只要不停的去研究创造琢磨它,早晚会打磨出一款非常棒的作品。同样,很多小伙伴非科班出身再或学历不高再或入行较晚,其实都没有问题,道阻且长,行则将至,只要坚持不懈打好基本功,多练习多思考,一路前行终将到达自己的期望目标。