vue3.0实现代码生成器(1)页面流程实现

587 阅读13分钟

一. 前言

在上一篇文章中我们简单的构思了一下vue3.0的代码生成器的工作流程,以及设计流程。(有兴趣的朋友们可以去看看上一篇文章, 一款Vue3.0的代码生成器的设计与实现。那么从这篇文章开始,我们会一步一步实现这个代码生成器。为了让项目更通透一点,我取名为 vue-generator

e9a7af06ec9baca4029bff92698524a6.jpeg

二. 项目搭建

因为没有自己搭项目,用的脚手架,所以这里简单过一下就好。

技术上采用vue3.0 + vite + ts + pinia

新建一个vue项目

    pnpm i vite@latest
    cd vue-generator
    pnpm i
    pnpm run dev

执行完之后,就会在看到一个运行起来的vue项目。下一步我们需要对主页面进行拆分设计一下,如下图:

image-20241030095035878.png 主页主要分为以下三个部分:

  1. 面板。主要是UI框架的选择、以及可以提供渲染的基本组件。
  2. 配置组件。这块区域的渲染组件决定了渲染区展示哪些组件,同时在这块暴露出配置组件的配置项。
  3. 渲染区,就是页面最后渲染的结果,可以根据配置组件渲染出页面,以及一些动态效果。

那现在基于上面三点,我们把页面的Layout布局搭建一下。新建四个组件

    /src/views/dashborad/index.vue (主页)
    /src/views/aside/index.vue (面板)
    /src/views/options/index.vue (配置组件)
    /src/views/face/index.vue (渲染区)

之后,我们按照位置把对应的组件放入进去

image-20241030100047950.png

页面的基本布局大概就是这样,然后我们在对应的组件里面简单写一点东西,最终页面的效果如下。

image-20241030100953630.png

三. 面板

按照顺序来,我们先实现一下面板。根据之前的设计,面板里面应该包含两大块内容,UI框架 , 基本组件

3.1 UI框架

可以提供两种UI框架选择,elementUIantd。但是这块牵涉到整体的代码改动,先不考虑实现这个。先简单的把页面上显示出来即可。

在src下,新建一个constants文件夹,作为常量保存文件:

export const farmWorkList = [
  {
    id: 1,
    label: 'element-plus',
    value: 'element'
  },
  {
    id: 2,
    label: 'antd for Vue',
    value: 'antd'
  }
]

aside/index.vue中引入farmWorkList,然后遍历展示即可。具体结果如下:

image-20241030102048705.png

3.2 基本组件

现在我们实现一下基本组件。首先要思考一下这个基本组件的功能是什么?

  • 这里的基本组件应该是要包括所有的可以渲染的组件。这些组件需要我们自己去定义。
  • 当前区域内基本组件是可选择的。选择之后可以在旁边的配置组件区显示这些组件,同样的在渲染区也要显示这些组件。
  • 类型相同的组件可以重复选择,但是相同类型的组件又具有唯一的uuid,保证组件的唯一性。

3.2.1 基本组件定义

跟上面的UI框架相同,定义一个常量。回到constants文件夹下:

export const componentList = [
  {
    id: 1,
    label: '输入框',
    value: 'input'
  },
  {
    id: 2,
    label: '下拉框',
    value: 'select'
  },
  {
    id: 3,
    label: '时间选择框',
    value: 'dateTime'
  },
  {
    id: 4,
    label: '表单',
    value: 'form'
  },
  {
    id: 5,
    label: '表格',
    value: 'table'
  },
  {
    id: 6,
    label: '按钮',
    value: 'button'
  }
]

这里定义了6个组件,(具体可以定义多少个,完全看自己喜好),里面有两个重要的属性。

  • label: 在页面上显示的组件名称。
  • value: 使用的组件类型。

我们回到aside/index.vue中,遍历一下componentList。页面结果如下:

image-20241030103306001.png

3.2.2 组件选择

当我们每次选择完组件之后,都要在配置组件的组件列表下面显示出当前选择的组件。所以在这里需要维护一个大对象,里面需要包含选择的组件,所以这里肯定要用到状态管理pinia

pnpm i pinia

然后在main.ts中引入一下。

在src下新建一个store/index.ts

import { defineStore } from 'pinia'

export const useOptionsStore = defineStore('options', {
  state: () => {
    return {
      renderOptionsComponentList: {

      } as any
    }
  },
   actions: {
    setRenderOptionsComponent(component: any) {
      this.renderOptionsComponentList[component.uuid] = component
    }
  }
})

这里面的renderOptionsComponentList就是整个大对象。

之后当我们每次选择组件的时候,都要在renderOptionsComponentList中加入当前组件。考虑到同种类型的组件可能会选择很多次,这里需要一个唯一标识来区分每个组件。 实现如下:

pnpm i uuid

aside/index.vue中:

 import { v4 as uuidv4 } from 'uuid';
 import { useOptionsStore } from '../../store'
 const optionsStore = useOptionsStore()
 
 // 点击每个组件的时候触发
 const setOptionsComponent = (item: IcomponentList) => {
  const randomId = uuidv4();   // 唯一的uuid标识
  optionsStore.setRenderOptionsComponent({
    uuid: randomId,
    ...item,    // 每个组件的基本信息
    options: {     // 组件的配置项
      originEvent: {}   // 组件的事件
    }
  })
}

把每个组件加一个单独的uuid,同时把每个组件的配置项options加入。最后调用pinia的setRenderOptionsComponent方法更新对象。

最终效果如下:

image-20241030112312567.png

四. 配置组件

上面都算是铺垫,配置组件的实现才是重点。按照之前的设计思想,每个配置组件,都要有单独的配置项样式事件,都可以单独定制的。

image-20241030150855580.png

我们现在用input框作为样例。

4.1 配置实现

当我点击组件列表中的组件的时候,要做一下操作。

  • 此时组件变为选中状态

  • 获取当前点击组件的全部配置项(options)和当前组件的uuid

  • 打开配置项页面(这里用的抽屉实现),展示出当前的配置项。

4.1.1 获取当前组件配置项

新建一个src/hooks/useRenderComponentList.ts的文件:

import { useOptionsStore } from '../store'
import { storeToRefs } from 'pinia';
import { watch, ref } from 'vue'

export const useRenderComponentList = () => {
  const optionsStore = useOptionsStore()
  const { renderOptionsComponentList } = storeToRefs(optionsStore)

  const rdcList = ref([]) as any  // 这个是组件列表渲染的数据
  const checkedListItemUUid = ref('')  // 当前的uuid

  //点击获取当前的uuid
  const setCheckedListTag = (item: any) => {
    checkedListItemUUid.value = item.uuid
  }
  // 移除当前的uuid
  const removeList = (uuid: string) => {
    optionsStore.removeRenderOptionsComponent(uuid)
  }

  watch(
    // 监听renderOptionsComponentList,转为array,在配置组件下面渲染
    () => renderOptionsComponentList.value,
    (val: any) => {
      rdcList.value = []
      for (let key in val) {
        rdcList.value.push(val[key])
      }
    },
    {
      deep: true
    }
  )


  return {
    rdcList,
    checkedListItemUUid,
    removeList,
    setCheckedListTag
  }
}

代码很简单,主要功能就是配置组件的选择以及组件的移除。

然后我们回到views/options/index.vue中,使用一下这个hooks:

<div
  v-for="item in rdcList"
  class="rdc-list"
>
  /**设置选择状态**/
  <el-check-tag
    :key="item.uuid"
    :checked="checkedListItemUUid === item.uuid"
    @change="onChangeList(item)"
    type="success"
  >
    {{ item.label }}
  </el-check-tag>
  <el-tag closable type="success" @close="onCloseChangeList(item.uuid)"></el-tag>
</div>
<configuration ref="drawRef" />
<script setup lang="ts">
// 配置组件的抽屉
import configuration from '../../components/ConfigurationArea/index.vue'
import { useRenderComponentList } from '../../hooks/useRenderComponentList'
const { rdcList, checkedListItemUUid, setCheckedListTag, removeList } = useRenderComponentList()

const drawRef = ref()
// 选中的时候,给checkedListItemUUid赋值,然后打开抽屉,把当前checkedListItemUUid传入
const onChangeList = (item: any) => {
  setCheckedListTag(item)
  drawRef.value.init(checkedListItemUUid.value)
}
// 点击移除标签,移除当前元素
const onCloseChangeList = (uuid: string) => {
  removeList(uuid)
}
</script>


ok。完成这步之后,就要考虑一下configuration这个抽屉组件怎么实现。

4.1.2 抽屉配置组件实现

这里容器用的是drawer,打开drawer之后,里面有三个可以切换的tab。分别对应着配置组件的属性样式事件

新建src/components/ConfigurationArea/index.vue

<template>
  <el-drawer
    height="30%"
    v-model="drawer"
    direction="rtl"
    title="组件配置项"
  >
    <template #default>
      <el-tabs
        v-model="actionName"
        type="card"
      >
        <el-tab-pane label="属性" name="property">
          <div class="content">
            <div class="content-val">
              <property-input
                v-if="currentType === 'input'"
                :currentUUid="_currentUUid"
                :drawerTag="drawer"
              />
            </div>
          </div>
        </el-tab-pane>

        <el-tab-pane label="样式" name="style">
          <div class="content">
            <div class="content-val flow-css">
              <!-- 样式组件 -->
            </div> 
          </div>
        </el-tab-pane>

        <el-tab-pane label="事件" name="func">
          <div class="content">
            <div class="content-val flow-css">
              <!-- 事件组件 -->
            </div> 
          </div>
        </el-tab-pane>
      </el-tabs>
    </template>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import PropertyInput from '../PropertyRender/propertyForInput/index.vue'
import { useOptionsStore } from '../../store'
import { storeToRefs } from 'pinia'
const optionsStore = useOptionsStore()
const { renderOptionsComponentList } = storeToRefs(optionsStore)
// 打开弹窗的属性
const drawer = ref(false)
// 当前组件的类型
const currentType = ref('')
// 当前组件的uuid
const _currentUUid = ref('')
const actionName = ref('property')
// 弹窗初始化
const init = (currentUUid: string) => {
  drawer.value = true
  actionName.value = 'property'
  _currentUUid.value = currentUUid
  if (currentUUid) {
    currentType.value = renderOptionsComponentList.value[currentUUid].value
  }
  else {
    currentType.value = ''
  }
}
const closeDraw = () => drawer.value = false
const confirm = () => {
  closeDraw()
}
const close = () => {
  closeDraw()
}
defineExpose({
  init
})
</script>

核心的地方就是这个弹窗初始化,要获取当前组件的uuid,当前组件的currentType类型。然后通过类型判断出当前应该显示的是哪个类型组件的配置项当前的组件是input类型,所以需要展示的是input的配置项

新建一个src/components/PropertyRender/propertyForInput/index.vue,这个组件就是input类型的配置组件。

我们扩展一下配置项:

  • value(值)
  • label(input的名称)
  • placeholder(input框的placeholder)
  • type (input框的类型)
  • atPosIndex (当前元素在组件中的层级)

前三个没什么好说的,type是选择当前input框的类型(文本,密码,数字,文本域),atPosIndex是决定当前组件在渲染页面中的层级(这个很重要,关乎组件的布局,放到后面讲)。

简单实现一下:

<template>
  <div>
    <el-form :inline="true" label-width="auto">
      
      <!-- value -->
      <el-form-item label="value">
        <el-input v-model="renderOptionsComponentList[currentUUid].options.atValue" />
      </el-form-item>
      
      <!-- label(input的名称) -->
      <el-form-item label="label">
        <el-input v-model="renderOptionsComponentList[currentUUid].options.atLabel" />
      </el-form-item>
      
      <!-- placeholder(input框的placeholder) -->
      <el-form-item label="placeholder">
        <el-input v-model="renderOptionsComponentList[currentUUid].options.atPlaceHolder" />
      </el-form-item>
      
      <!-- type (input框的类型) -->
      <el-form-item label="type">
        <el-select 
          placeholder="请选择输入框类型"
          v-model="renderOptionsComponentList[currentUUid].options.atType" 
          style="width: 170px;"
        >
          <el-option
            v-for="item in typeSelect"
            :key="item.type"
            :label="item.label"
            :value="item.type"
          />
        </el-select>
      </el-form-item>

  		<!-- atPosIndex (当前元素在组件中的层级) -->
      <el-form-item label="层级">
        <el-select 
          placeholder="请选择当前元素的层级"
          v-model="renderOptionsComponentList[currentUUid].options.atPosIndex" 
          style="width: 200px;"
        >
          <el-option
            v-for="item in positionZIndexList"
            :key="item.value"
            :label="item.label"
            :value="item.value"  
          />
        </el-select>
      </el-form-item>
    </el-form>
  </div>
</template>

通过直接绑定pinia中的renderOptionsComponentList,来实现数据的动态更新。

ok,我们看一下页面上的效果

image-20241030160627964.png

4.1.3 样式编辑器实现

每个配置组件的属性都会根据**不同的类型有不同的改变,但是样式编辑器是统一的。所以只需要完成一个样式编辑器的开发就行。

考虑到样式是一种语言,这里统一用css。这里希望的是。

  • 用户像写css语言一样,写入css

  • 系统读取当前的css内容,在配置组件的配置项中 保存当前的css,然后在渲染组件中通过style的方式渲染当前的css里面的样式

image-20241030221316518.png

所以第一步就是如何在系统里面嵌入一个编辑器可以帮助用户书写css。

研究了许多市面上的编辑器,发现monaco-editor比较好用,但是吧,市面上很多的文档都很奇怪,要么就是编辑器不显示,要么就是输入报错。经过一番痛苦的排查,我这里放一下我的使用流程。

4.1.3.1 monaco-editor安装
 pnpm install monaco-editor

image-20241030163825414.png

这里我用的vite版本和vue版本最好可以对上,不然会报很多奇怪的错。

Vite-plugin-monaco-editor这样的vite插件可以不安装。

4.1.3.2 monaco-editor使用

包的引入问题。市面上包括各种ai给出的都是这种引入方式:

import * as monaco from 'monaco-editor';  

但是不知道为啥,会报初始化失败。

尝试了一下使用这种引入方式:

import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";

你需要用哪个模块就引入哪个模块;比如我需要使用css,以及ts就可以这样

import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";

在使用的时候,会出现一个报错:

image-20241030165239245.png 这里需要在初始化一下基本的配置:

  self.MonacoEnvironment= {
    getWorker(_, label){
      if (label === "typescript" || label === "javascript") {
        return new tsWorker();
      }
      if (label === 'css' || label === 'scss' || label === 'less') {
        return new cssWorker()
      }
      else return new editorWorker();
    }
  }

基本上,我遇到的就是这些问题。

然后在页面上选择一个容器,作为加载对象,完成初始化

const editor = monaco.editor.create(document.getElementById('codeEditBox'), {
    value: this.modelValue, // 编辑器初始显示文字
    language: this.language,
    theme: 'vs', // 官方自带三种主题,vs,hc-black,vs-dark
    selectOnLineNumbers: true, // 显示行号
    roundedSelection: false,
    readOnly: false, // 只读
    cursorStyle: 'line', // 光标样式
    automaticLayout: true, //自动布局
    glyphMargin: true, //字形边缘
    useTabStops: false,
    fontSize: 15, //字体大小
    autoIndent: true, //自动布局
    quickSuggestionsDelay: 100, //代码提示延时
})
4.1.3.3 实现通用hooks

新建一个src/components/CssRender/index.vue

<template>
  <div class="css-render">
    <div ref="editorContainer" class="css-render__container"></div>
    <div class="css-btn">
      <el-button
        @click="createRenderStyle"
      >样式生成</el-button>
    </div>
  </div>
</template>

定义一个**editorContainer**容器作为渲染器的显示区域。

按照一开始说的,我们编辑器中要实现一下功能。

  • 可以输入css。
  • 读取css的内容,转为字符串。
  • 点击上面样式生成按钮,会在渲染组件中添加当前样式。
  • 可以实现样式的回显。(如果渲染组件有样式的话,在style设置的时候,可以回显当前样式)

OK,我们一步步实现一下。

新建一个src/hooks/useRenderComponentStyle.ts

初始化:


	let editorer = null;
  const createCodeEditor = async (language: Language) => {
    // 这里用import的方式引入monaco-editor
    const monaco = await import('monaco-editor');
    // 跟上面的monaco-editor初始化一样
    editorer = monaco.editor.create(domRef.value, {
      value: beginCssTemplate,
      language,
      theme: 'vs-dark'
    })
  }

editorer提供了一个方法可以获取当前编辑器的输入内容。

editorer.getValue()

写一个方法,看一下这个getValue()获取的是什么东西

	const createRenderStyle = () => {
    const _value = editorer.getValue()
    console.log(_value)
  }

我们在编辑器里面输入点东西,然后样式生成按钮,调用createRenderStyle方法。

image-20241030171501393.png

可以看到,拿到的就是一个css样式的字符串。

OK,我们只要对这个字符串进行处理就好,读取字符串里面的样式,用key:value的形式保存。这里为了简单一点,只要用正则匹配 {} 里面的内容就好。

优化一下createRenderStyle方法:

 const createRenderStyle = () => {
    const _value = editorer.getValue()
    // 用正则匹配一下{}里面的内容
    const matches = _value.match(/{([^]*?)}/)
    // 保存到当前的options.style下面
    renderOptionsComponentList.value[uuid.value].options.style = matches ? matches[1].trim() : ''
  }

最终我们打印一下renderOptionsComponentList

image-20241030172236319.png

ok,完成了在渲染组件中添加当前样式的功能。

最后实现一下样式回显功能。很简单,这个编辑器提供了一个setValue方法,支持传入字符串实现回显。只需要在初始化的时候,调用一下这个setValue就好。


  /**
   * 初始化编辑器样式代码
   */
  const initCodeStyle = () => {
    // 这里判断一下当前的options下面的style是否存在
    if (renderOptionsComponentList.value[uuid.value] && renderOptionsComponentList.value[uuid.value].options && renderOptionsComponentList.value[uuid.value].options.style) {
      const _style = renderCSSTemplate(renderOptionsComponentList.value[uuid.value].options.style)
      editorer.setValue(_style)
    }
  }

还有一点就是在切换tab的时候,需要移除一下当前的编辑器。


  const destroyEditor = () => {
    if (editorer) {
      // 编辑器自带的移除方法
      editorer.dispose()
      editorer = null
    }
  }

OK,最终整个hooks代码如下:

import cssWorker from "monaco-editor/esm/vs/language/css/css.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 { watch } from 'vue'
import type { Ref } from 'vue'
import { useOptionsStore } from '../store'
import { storeToRefs } from 'pinia';
import { Language } from '../typings'

const optionsStore = useOptionsStore()
const { renderOptionsComponentList } = storeToRefs(optionsStore)

export const beginCssTemplate = `
.main {
}
`

export const renderCSSTemplate = (code: string) => {
  return `
.main {
  ${code}
}
  `
}

export const useRenderComponentStyleHooks = (actionName: Ref, uuid: Ref, domRef: any) => {
  /**
   * 编辑器
   */
  let editorer: any = null

  self.MonacoEnvironment= {
    getWorker(_, label){
      if (label === "typescript" || label === "javascript") {
        return new tsWorker();
      }
      if (label === 'css' || label === 'scss' || label === 'less') {
        return new cssWorker()
      }
      else return new editorWorker();
    }
  }

  const createCodeEditor = async (language: Language) => {
    const monaco = await import('monaco-editor');
    editorer = monaco.editor.create(domRef.value, {
      value: beginCssTemplate,
      language,
      theme: 'vs-dark'
    })

    if (editorer) {
      initCodeStyle()
    }
  }

  const destroyEditor = () => {
    if (editorer) {
      editorer.dispose()
      editorer = null
    }
  }

  /**
   * 初始化编辑器样式代码
   */
  const initCodeStyle = () => {
    if (renderOptionsComponentList.value[uuid.value] && renderOptionsComponentList.value[uuid.value].options && renderOptionsComponentList.value[uuid.value].options.style) {
      const _style = renderCSSTemplate(renderOptionsComponentList.value[uuid.value].options.style)
      editorer.setValue(_style)
    }
  }


  const createRenderStyle = () => {
    const _value = editorer.getValue()
    const matches = _value.match(/{([^]*?)}/)
    renderOptionsComponentList.value[uuid.value].options.style = matches ? matches[1].trim() : ''
    console.log(renderOptionsComponentList.value)
  }
  
  watch(
    () => actionName.value,
    /*	
    	*是style的话,初始化,不是移除。
    */
    (val: string) => {
      if (val === 'style') {
        createCodeEditor(Language.CSS)
      }
      else {
        destroyEditor()
      }
    }
  )

  return {
    createCodeEditor,
    initCodeStyle,
    createRenderStyle
  }
}

4.1.4 事件编辑器实现

有了样式编辑器的开发经验,事件编辑器就简单多了,还是通过引入编辑器来实现。但是有不同的地方。

事件编辑器中的每个事件是需要具体类型的,比如说:当前事件是click、还是mousemove。我们需要把每种事件类型罗列出来,然后分别对每种事件类型设置对应的执行函数。就是一对一的关系。

image-20241030174137523.png

所以第一步要罗列出,当前类型组件的所有事件类型。

4.1.4.1 事件类型

src/constants/index.ts下面:



export const functionType: any = {
  input: ['blur', 'focus', 'change', 'input'],
  select: ['blur', 'focus', 'change', 'input'],
  dateTime: ['change', 'blur', 'focus', 'calendar-change', 'panel-change'],
  table: ['getDataSource'],
  button: ['click']
}

这里定义了所有的事件类型。然后根据当前组件类型获取到对应的事件类型。

比如:

input -> ['blur', 'focus', 'change', 'input'],

我们把获取到的事件类型放到一个table里面展示出来:

新建src/FunctionRender/index.vue

<template>
  <div class="css-render">
    <el-table
      :data="dataSource"
      height="250px"
      align="center"
    >
      <el-table-column label="事件类型" prop="eventType" />
      <el-table-column label="事件设置">
        <template #default="scope">
          <el-button @click="setEvents(scope.row.eventType)">{{ '设置' }}</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script lang="ts" setup>
import { ref, toRefs, computed } from 'vue'
import { functionType } from '../../constants'
import settingsDialog from './settingsDialog.vue';
const props = defineProps<{
  actionName: string,
  currentUUid: string,
  currentType: any
}>()
const { currentUUid, currentType } = toRefs(props)
// 通过当前的组件类型,获取对应的eventType
const dataSource = computed(() => functionType[currentType.value].map((item: string) => {
  return {eventType: item}
}))
</script>

页面上的效果如下:

image-20241030175703134.png

之后,我们通过点击每个事件对应的事件设置,打开设置弹窗,编写对应的js事件代码。所以我们需要新建一个弹窗

这个弹窗接收uuid和当前的事件类型eventType

4.1.4.2 事件弹窗实现

src/FunctionRender文件夹下,新建settingsDialog.vue文件:

只要定义一个初始化init函数,和定义一个编辑器的放置容器

<template>
  <el-dialog
    v-model="dialogVisible"
  >
    <div ref="editorContainer" class="container"></div>
    <template #footer>
      <div>
        <el-button @click="cancel">取消</el-button>
        <el-button @click="confirm">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

写一下init函数,主要功能是打开弹窗,然后接受上面说的uuid事件类型(eventType)


const init = (item: any) => {
  /**
  	item: {
  		currentUUid: uuid,
  		eventType: 事件类型
		}
  */
  if (item) {
    currentOptions.value = item
  }
  else {
    currentOptions.value = {
      currentUUid: '',
      eventType: ''
    }
  }
  dialogVisible.value = true
}

之后在src/FunctionRender/index.vue里面使用一下。

<template>
		...
    <settingsDialog ref="settingDialogRef" />
</template>
<script lang="ts" setup>
  
const settingDialogRef = ref()
const setEvents = (eventType: string) => {
  settingDialogRef.value.init({
    eventType,
    currentUUid: currentUUid.value
  })
}

</script>

看一下页面的效果:如下所示,我们点击了列表上的change事件

image-20241030212748316.png

4.1.4.3 实现通用hooks

OK,我们接下来实现一下事件弹窗里面的功能。弹窗里面的功能主要是一下几点:

  • 实现js的写入(这里考虑的是写函数)。
  • 获取写入的代码放到对应的组件事件中。
  • 可以把已有的事件代码复现。

第一条跟上面的样式组件实现是一样的,只需要初始化一下monaco-editor就好。关键是如何实现第二条?第二条要考虑这么几个问题:

  • 每个组件的事件类型会有多个,所以这里保存的时候肯定要是一个对象。key就是事件类型,value是事件代码
  • 不仅要获取写入的代码,还需要提取每个函数的名称。(后面有用)

流程如下:

image-20241030222451395.png

所以这里需要先获取编辑器里面的代码getValue(),然后把字符串中的函数名称提取出来。这里可以通过AST实现,也可以通过正则匹配。这里采用的是正则匹配。

代码实现如下:

const combinedRegex = /(?:function\s+(\w+)|(\w+)\s*=\s*function\s*\(|const\s+(\w+)\s*=\s*\([^)]*\)\s*=>)/g;  

  const createRenderFunction = (uuid: string, eventType: string) => {
    // 获取js字符串
    const codeString = editorer.getValue()
    let match
    const allFunctionNames = []
    while ((match = combinedRegex.exec(codeString)) !== null) { 
      // 匹配function name() {} 写份
      if (match[1]) {  
        // 匹配成功之后把对象放到数组里面。对象的值为functionName, functionExpress
          allFunctionNames.push({
            eventType, functionName: match[1], functionExpress: editorer.getValue()
          });  
        // 匹配funciton匿名函数写法
      } else if (match[2]) {  
        allFunctionNames.push({
          eventType, functionName: match[2], functionExpress: editorer.getValue()
        });  
        // 匹配const函数常量写法
      } else if (match[3]) {  
        allFunctionNames.push({
          eventType, functionName: match[3], functionExpress: editorer.getValue()
        });  
      }  
    }
  
    renderOptionsComponentList.value[uuid].options.originEvent[eventType] = allFunctionNames
  }

可以在页面上简单试一下这个执行结果。选择change事件,简单输入一个changeFunction代码

image-20241030223354121.png

数据回显就很简单了。在初始化的时候,init一下当前的js代码就好。

  /**
   * 初始化编辑器js代码
   */
  const initCodeFunction = (uuid: string, eventType: string) => {
    // 判断是否存在当前事件类型
    if (
      renderOptionsComponentList.value[uuid].options!.originEvent && 
      renderOptionsComponentList.value[uuid].options!.originEvent[eventType] &&
      renderOptionsComponentList.value[uuid].options!.originEvent[eventType].length > 0
    ) {
      editorer.setValue(renderOptionsComponentList.value[uuid].options!.originEvent[eventType][0].functionExpress)
    }
  }

OK,事件组件算结束了。放一下这个hooks的代码。

import cssWorker from "monaco-editor/esm/vs/language/css/css.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 { useOptionsStore } from '../store'
import { storeToRefs } from 'pinia';
import { Language } from '../typings'

const combinedRegex = /(?:function\s+(\w+)|(\w+)\s*=\s*function\s*\(|const\s+(\w+)\s*=\s*\([^)]*\)\s*=>)/g;  

const optionsStore = useOptionsStore()
const { renderOptionsComponentList } = storeToRefs(optionsStore)


export const useRenderComponentFunctionHooks = () => {
  /**
   * 编辑器
   */
  let editorer: any = null

  self.MonacoEnvironment= {
    getWorker(_, label){
      if (label === "typescript" || label === "javascript") {
        return new tsWorker();
      }
      if (label === 'css' || label === 'scss' || label === 'less') {
        return new cssWorker()
      }
      else return new editorWorker();
    }
  }

  const createCodeEditor = async (uuid: string, eventType: string, domRef: any) => {
    const monaco = await import('monaco-editor');
    editorer = monaco.editor.create(domRef.value, {
      value: '',
      // 这里语言要用javascript
      language: Language.JAVASCRIPT,
      theme: 'vs-dark'
    })

    if (editorer) {
      initCodeFunction(uuid, eventType)
    }
  }

  const destroyEditor = () => {
    if (editorer) {
      editorer.dispose()
      editorer = null
    }
  }

  /**
   * 初始化编辑器js代码
   */
  const initCodeFunction = (uuid: string, eventType: string) => {
    if (
      renderOptionsComponentList.value[uuid].options!.originEvent && 
      renderOptionsComponentList.value[uuid].options!.originEvent[eventType] &&
      renderOptionsComponentList.value[uuid].options!.originEvent[eventType].length > 0
    ) {
      editorer.setValue(renderOptionsComponentList.value[uuid].options!.originEvent[eventType][0].functionExpress)
    }
  }


  const createRenderFunction = (uuid: string, eventType: string) => {
    const codeString = editorer.getValue()
    let match
    const allFunctionNames = []
    while ((match = combinedRegex.exec(codeString)) !== null) {  
      if (match[1]) {  
          allFunctionNames.push({
            eventType, functionName: match[1], functionExpress: editorer.getValue()
          });  
      } else if (match[2]) {  
        allFunctionNames.push({
          eventType, functionName: match[2], functionExpress: editorer.getValue()
        });  
      } else if (match[3]) {  
        allFunctionNames.push({
          eventType, functionName: match[3], functionExpress: editorer.getValue()
        });  
      }  
    }
    renderOptionsComponentList.value[uuid].options.originEvent[eventType] = allFunctionNames
    console.log(renderOptionsComponentList.value)
  }

  return {
    createCodeEditor,
    createRenderFunction,
    destroyEditor
  }
}

五. 渲染区

5.1 渲染区的布局

渲染区考虑的问题就是如何把我最终生成的renderOptionsComponentList这个对象,在一个容器中显示出来。要考虑几个问题:

  • 组件按照什么顺序显示
  • 组件怎么排列?如何布局?

组件顺序简单,我们可以按照配置组件列表顺序,在渲染区显示出来。后期还可以通过拖动配置组件列表的顺序,完成渲染区组件位置的改变。

问题是容器如何排列?如何布局?

我们分析一下日常的布局写法,如下一个页面:

image-20241030224943104.png

如果没有特殊的UI样式要求,每行每列之间都可以用element-plus的布局实现。

<el-col>来表示每行里面的行元素,然后动态计算span的大小,来确定每行元素之间的排列间距

<el-col :span="24"><div class="grid-content ep-bg-purple-dark" /></el-col>

<el-row>来包裹每一行的行元素,然后通过gutter属性,设置行距

  <el-row :gutter="20">
    <el-col :span="6"><div class="grid-content ep-bg-purple" /></el-col>
    <el-col :span="6"><div class="grid-content ep-bg-purple" /></el-col>
  </el-row>

那这样的话,问题就变成了,如何计算一共有多少行?每行有多少个行元素?每个行元素之间有多少间距?

这时候,之前配置项的时候,给每个组件加了一个层级的配置项就起到了大作用。这里的层级我们可以简单理解,每个层级就是一行元素。

比如,现在有层级为一的元素两个,层级为二的元素三个。那么放到页面上就应该如下:

image-20241030230449005.png

所以,我们需要拿到所有的层级,判断当前有多少层级,然后每个层级对应有多少的行元素,然后在计算每个行元素的间距。

新建一个src/components/renderComponent/index.vue

watch一下renderOptionsComponentList

const standradSize = 24

watch(
  () => renderOptionsComponentList.value,
  (newVal: any) => {
		// 渲染组件
    renderComponentArray.value = []
    for (let key in newVal) {
      renderComponentArray.value.push(
        {
          ...newVal[key],
					// 这里做个判断,如果没有设置atPosIndex的话,默认为1
          atPosIndex: newVal[key].options.atPosIndex ? newVal[key].options.atPosIndex : 1,
					// 每个元素的行间距大小,默认为24,作为最大的标准
          size: standradSize
        }
      )
    }
		// 计算每个层级有多少个元素
    calcPosIndexMap(renderComponentArray.value)
		// 对每个元素的层级大小计算
    calcRenderElementSize()
  },
  {
    immediate: true,
    deep: true
  }
)

之后,我们对renderComponentArray进行遍历,拿到每个层级下面有多少个元素,然后对应的行间距多少。这里用一个map结构存储一下。

map({
  key: 1 | 2 | 3 | 4 层级数
  value: {
  	 key: 1 | 2 | 3 | 4 层级数
  	 index: 层级数量
  	 size: 当前层级下的size
	}
})
const posIndexMap = ref(new Map()) as any
const calcPosIndexMap = (array: any) => {
  // 每一次执行的时候都clear一下map
  posIndexMap.value.clear()
  array.forEach((item: any) => {
    /**
    	这里做一个判断,如果当前map中已经有这个层级,就更新index,重新计算一下size
    	如果没有,就新建一下,index为 1 , size为 24 / 1
    */
    if (posIndexMap.value.has(item.atPosIndex)) {
      posIndexMap.value.set(item.atPosIndex, {
        key: item.atPosIndex,
        index: posIndexMap.value.get(item.atPosIndex).index + 1,
        size: standradSize / (posIndexMap.value.get(item.atPosIndex).index + 1)
      })
    }
    else {
      posIndexMap.value.set(item.atPosIndex, {
        key: item.atPosIndex,
        index: 1,
        size: standradSize / 1
      })
    }
  })
} 

/**对每个元素的层级大小计算,得到层数(层数决定我们需要遍历多少次) */

const maxPosIndex = ref(1)
// 这个maxPosIndexArray就是我们外层需要遍历的元素
const maxPosIndexArray = ref<number[]>([])

const calcRenderElementSize = () => {
  maxPosIndex.value = 1
  renderComponentArray.value.forEach((item: any, index: number) => {
    if (item.atPosIndex === posIndexMap.value.get(item.atPosIndex).key) {
      renderComponentArray.value[index].size = posIndexMap.value.get(item.atPosIndex).size
    }

    maxPosIndex.value = Math.max(maxPosIndex.value, item.atPosIndex)
  })
  maxPosIndexArray.value = []
  for (let i = 1 ; i <=  maxPosIndex.value; i++) {
    maxPosIndexArray.value.push(i)
  }

}

拿到maxPosIndexArray之后,在template里面v-for一下,就可以。这里要注意一下,一定要先遍历maxPosIndexArray,因为这个是决定有多少行,然后通过内部的层级是否相等以及元素类型完成没行内部元素的渲染。最终代码如下:

<template>
  <div v-if="renderComponentArray.length > 0">
    <el-form
      label-position="right"
    >
      // 遍历外层
      <el-row
        v-for="posIndx in maxPosIndexArray"
        :key="posIndx"
        :gutter="20"
      >
        <template v-for="item in renderComponentArray" :key="item.uuid">
          // 遍历内层
          <el-col v-if="posIndx === item.atPosIndex" :span="item.size">
            <renderInput
              v-if="item.value === 'input'"
              :options="item.options"
            />
          </el-col>
        </template>
      </el-row>
    </el-form>
  </div>
</template>

5.2 渲染组件的属性、样式、事件加载

我们还是用input为例子。

属性和样式的加载很简单,就是把属性设置到对应的位置上就可以了。如下

新建src/components/renderInput.vue

 <el-input
    v-model="options.atValue"
    :placeholder="options.atPlaceHolder"
    :type="options.atType"
    :style="`${options.style}`"
  />

我们设置两个输入框,设置不同的属性和css。最终我们看一下页面结果:

image-20241030232824323.png

OK,没有什么问题。稍微难一点的是事件如何绑定。

事件绑定主要是如下几个问题

  • 要同时绑定多种事件类型
  • 执行字符串函数

实现第一条就不得不说,v-on指令是可以绑定事件对象的,这个对象的键是事件名称,值是对应的事件处理函数

<template>
  <div id="app">
    <button v-on="{ click: handleClick, mouseover: handleMouseOver }">Click Me</button>
  </div>
</template>
 
<script>
export default {
  methods: {
    handleClick(event) {
      console.log('Button clicked', event);
    },
    handleMouseOver(event) {
      console.log('Mouse is over the button', event);
    }
  }
}
</script>

ok,对象的事件名称和执行的函数我们之前都保存好了,问题也算解决啦。

如何执行字符串函数?

其实不是很支持,直接执行字符串函数,因为有风险,但是这里可以尝试一下使用new Function

const code = "console.log('Hello, World!')";  
const func = new Function(code);  
func();  // 输出: Hello, World!

所以我们需要定义一个事件对象,然后构造一下事件对象的执行函数。

// 返回一个new Function执行
const evalJS = (funcStr: string) => {
  return new Function(funcStr)
}
const funcEvalList = computed(() => {
  const _event = {} as any
  for (let key in props.options.originEvent) {
    const _newStr = `${props.options.originEvent[key][0].functionExpress} ${props.options.originEvent[key][0].functionName}()`
    _event[key] = evalJS(_newStr)
  }
  return _event
})

代码也简单就是遍历处理一下options.originEvent。这里要注意一下这个地方:

    const _newStr = `${props.options.originEvent[key][0].functionExpress} ${props.options.originEvent[key][0].functionName}()`

意思就是我除了需要拿到字符串执行的表达式,还需要在最后执行一下函数调用。就类似于下面:

`
	function name() { console.log(1111) }
	name()
`

OK,完成之后,返回这个funcEvalList即可。

最后我们用v-on绑定一下就好。

  <el-input
    v-model="options.atValue"
    :placeholder="options.atPlaceHolder"
    :type="options.atType"
    :style="`${options.style}`"
    v-on="funcEvalList"
  />

六. 总结

我们大致的通过一个input组件的案例,完成了从选择组件配置组件渲染组件的流程。下一期将更新一下下载组件。有兴趣的朋友可以点个赞收藏一下哈。

github地址。[github.com/sqmmaybe/vu…]