一. 前言
在上一篇文章中我们简单的构思了一下vue3.0的代码生成器的工作流程,以及设计流程。(有兴趣的朋友们可以去看看上一篇文章, 一款Vue3.0的代码生成器的设计与实现。那么从这篇文章开始,我们会一步一步实现这个代码生成器。为了让项目更通透一点,我取名为 vue-generator
。
二. 项目搭建
因为没有自己搭项目,用的脚手架,所以这里简单过一下就好。
技术上采用vue3.0 + vite + ts + pinia
。
新建一个vue项目
pnpm i vite@latest
cd vue-generator
pnpm i
pnpm run dev
执行完之后,就会在看到一个运行起来的vue项目。下一步我们需要对主页面进行拆分设计一下,如下图:
主页主要分为以下三个部分:
- 面板。主要是UI框架的选择、以及可以提供渲染的基本组件。
- 配置组件。这块区域的渲染组件决定了渲染区展示哪些组件,同时在这块暴露出配置组件的配置项。
- 渲染区,就是页面最后渲染的结果,可以根据配置组件渲染出页面,以及一些动态效果。
那现在基于上面三点,我们把页面的Layout
布局搭建一下。新建四个组件
/src/views/dashborad/index.vue (主页)
/src/views/aside/index.vue (面板)
/src/views/options/index.vue (配置组件)
/src/views/face/index.vue (渲染区)
之后,我们按照位置把对应的组件放入进去
页面的基本布局大概就是这样,然后我们在对应的组件里面简单写一点东西,最终页面的效果如下。
三. 面板
按照顺序来,我们先实现一下面板。根据之前的设计,面板里面应该包含两大块内容,UI框架
, 基本组件
。
3.1 UI框架
可以提供两种UI框架选择,elementUI
和antd
。但是这块牵涉到整体的代码改动,先不考虑实现这个。先简单的把页面上显示出来即可。
在src下,新建一个constants
文件夹,作为常量保存文件:
export const farmWorkList = [
{
id: 1,
label: 'element-plus',
value: 'element'
},
{
id: 2,
label: 'antd for Vue',
value: 'antd'
}
]
在aside/index.vue
中引入farmWorkList,然后遍历展示即可。具体结果如下:
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
。页面结果如下:
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
方法更新对象。
最终效果如下:
四. 配置组件
上面都算是铺垫,配置组件的实现才是重点。按照之前的设计思想,每个配置组件,都要有单独的配置项
、样式
、事件
,都可以单独定制
的。
我们现在用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,我们看一下页面上的效果
4.1.3 样式编辑器实现
每个配置组件的属性都会根据**不同的类型有不同的改变
,但是样式编辑器是统一的
。所以只需要完成一个样式编辑器
的开发就行。
考虑到样式是一种语言,这里统一用css。这里希望的是。
-
用户像写css语言一样,
写入css
。 -
系统读取当前的css内容,在配置组件的配置项中
保存当前的css
,然后在渲染组件中通过style的方式渲染当前的css里面的样式
。
所以第一步就是如何在系统里面嵌入一个编辑器
可以帮助用户书写css。
研究了许多市面上的编辑器,发现monaco-editor
比较好用,但是吧,市面上很多的文档都很奇怪,要么就是编辑器不显示
,要么就是输入报错
。经过一番痛苦的排查,我这里放一下我的使用流程。
4.1.3.1 monaco-editor安装
pnpm install monaco-editor
这里我用的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";
在使用的时候,会出现一个报错:
这里需要在初始化一下基本的配置:
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
方法。
可以看到,拿到的就是一个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
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
。我们需要把每种事件类型
罗列出来,然后分别对每种事件类型设置对应的执行函数
。就是一对一的关系。
所以第一步要罗列出,当前类型组件的所有事件类型。
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>
页面上的效果如下:
之后,我们通过点击每个事件对应的事件设置
,打开设置弹窗
,编写对应的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事件
4.1.4.3 实现通用hooks
OK,我们接下来实现一下事件弹窗里面的功能。弹窗里面的功能主要是一下几点:
- 实现js的写入(这里考虑的是写函数)。
- 获取写入的代码放到对应的组件事件中。
- 可以把已有的事件代码复现。
第一条跟上面的样式组件实现是一样的,只需要初始化一下monaco-editor
就好。关键是如何实现第二条?第二条要考虑这么几个问题:
- 每个组件的事件类型会有
多个
,所以这里保存的时候肯定要是一个对象。key就是事件类型,value是事件代码
。 - 不仅要获取写入的代码,还需要提取每个函数的名称。(后面有用)
流程如下:
所以这里需要先获取编辑器里面的代码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代码
。
数据回显就很简单了。在初始化的时候,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
这个对象,在一个容器中显示出来。要考虑几个问题:
- 组件按照什么顺序显示
- 组件怎么排列?如何布局?
组件顺序简单,我们可以按照配置组件列表顺序
,在渲染区显示出来。后期还可以通过拖动配置组件列表的顺序
,完成渲染区组件位置的改变。
问题是容器如何排列?如何布局?
我们分析一下日常的布局写法,如下一个页面:
如果没有特殊的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>
那这样的话,问题就变成了,如何计算一共有多少行?
,每行有多少个行元素?
,每个行元素之间有多少间距?
这时候,之前配置项的时候,给每个组件加了一个层级
的配置项就起到了大作用。这里的层级我们可以简单理解,每个层级就是一行元素。
比如,现在有层级为一的元素两个,层级为二的元素三个。那么放到页面上就应该如下:
所以,我们需要拿到所有的层级,判断当前有多少层级,然后每个层级对应有多少的行元素,然后在计算每个行元素的间距。
新建一个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。最终我们看一下页面结果:
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…]