各位大佬早上好、中午好、晚上好!
志哥有多年的低代码经验,了解了国内的低代码平台好像在线编写组件的功能都没有实现,是这个功能比较鸡肋还是比较难实现??
在我们公司自研的低代码平台中,有个需求是在内置组件无法满足业务需求的情况下,需要快速进行组件的设计开发,目前有三种方案:
- 插件组件:使用自定义脚手架搭建一个模板项目用于开发自定义组件,然后打包为umd.js格式,上传到代码库,基座项目通过远程组件的方式进行组件的加载渲染。
- 微前端组件:可以使用qiankun、micro app或者module federation等微前端解决方案加载渲染微前端页面或者组件。
- 在线设计组件:在线编写组件的html、css、js,保存到后端,然后请求接口拿到数据动态生成组件,动态进行组件渲染。
本文将从项目的创建到设计器的开发,组件的加载,渲染等步骤向你一步步揭开在线设计组件的面纱。
此demo所使用的技术栈为:
- Vue3.4.31:开发框架。
- Vite5.3.1:打包框架。
- ace-builds:一个在线代码编写的库。
志哥最近两年都在用Angular,如有Angular技术栈的同学,志哥用Angular也实现了一遍,移步:juejin.cn/post/737872…
先体验再看实现:zzhimin.github.io/online-comp…
项目初始化
1. 使用脚手架创建项目,并安装ace-builds库
pnpm create vue@latest
项目创建好了安装ace-builds库
yarn add ace-builds
初始化ace-builds库,在文件@/utils/aceConfig里写入:
// ace配置,使用动态加载来避免第一次加载开销
import ace from "ace-builds";
// 导入不同的主题模块,并设置对应 URL
import themeGithubUrl from "ace-builds/src-noconflict/theme-github?url";
ace.config.setModuleUrl("ace/theme/github", themeGithubUrl);
import themeChromeUrl from "ace-builds/src-noconflict/theme-chrome?url";
ace.config.setModuleUrl("ace/theme/chrome", themeChromeUrl);
import themeMonokaiUrl from "ace-builds/src-noconflict/theme-monokai?url";
ace.config.setModuleUrl("ace/theme/monokai", themeMonokaiUrl);
// 导入不同语言的语法模式模块,并设置对应 URL (所有支持的主题和模式:node_modules/ace-builds/src-noconflict)
import modeJsonUrl from "ace-builds/src-noconflict/mode-json?url";
ace.config.setModuleUrl("ace/mode/json", modeJsonUrl);
import modeJavascriptUrl from "ace-builds/src-noconflict/mode-javascript?url";
ace.config.setModuleUrl("ace/mode/javascript", modeJavascriptUrl);
import modeHtmlUrl from "ace-builds/src-noconflict/mode-html?url";
ace.config.setModuleUrl("ace/mode/html", modeHtmlUrl);
import modeCssUrl from "ace-builds/src-noconflict/mode-css?url";
ace.config.setModuleUrl("ace/mode/css", modeCssUrl);
import modePythonUrl from "ace-builds/src-noconflict/mode-python?url";
ace.config.setModuleUrl("ace/mode/yaml", modePythonUrl);
// 用于完成语法检查、代码提示、自动补全等代码编辑功能,必须注册模块 ace/mode/lang _ worker,并设置选项 useWorker: true
import workerBaseUrl from "ace-builds/src-noconflict/worker-base?url";
ace.config.setModuleUrl("ace/mode/base", workerBaseUrl);
import workerJsonUrl from "ace-builds/src-noconflict/worker-json?url"; // for vite
ace.config.setModuleUrl("ace/mode/json_worker", workerJsonUrl);
import workerJavascriptUrl from "ace-builds/src-noconflict/worker-javascript?url";
ace.config.setModuleUrl("ace/mode/javascript_worker", workerJavascriptUrl);
import workerHtmlUrl from "ace-builds/src-noconflict/worker-html?url";
ace.config.setModuleUrl("ace/mode/html_worker", workerHtmlUrl);
import workerCssUrl from "ace-builds/src-noconflict/worker-css?url";
ace.config.setModuleUrl("ace/mode/css_worker", workerCssUrl);
// 导入不同语言的代码片段,提供代码自动补全和代码块功能
import snippetsJsonUrl from "ace-builds/src-noconflict/snippets/json?url";
ace.config.setModuleUrl("ace/snippets/json", snippetsJsonUrl);
import snippetsJsUrl from "ace-builds/src-noconflict/snippets/javascript?url";
ace.config.setModuleUrl("ace/snippets/javascript", snippetsJsUrl);
import snippetsHtmlUrl from "ace-builds/src-noconflict/snippets/html?url";
ace.config.setModuleUrl("ace/snippets/html", snippetsHtmlUrl);
import snippetsCssUrl from "ace-builds/src-noconflict/snippets/css?url";
ace.config.setModuleUrl("ace/snippets/css", snippetsCssUrl);
import snippetsPyhontUrl from "ace-builds/src-noconflict/snippets/python?url";
ace.config.setModuleUrl("ace/snippets/javascript", snippetsPyhontUrl);
// 启用自动补全等高级编辑支持,
import extSearchboxUrl from "ace-builds/src-noconflict/ext-searchbox?url";
ace.config.setModuleUrl("ace/ext/searchbox", extSearchboxUrl);
// 启用自动补全等高级编辑支持
import "ace-builds/src-noconflict/ext-language_tools";
ace.require("ace/ext/language_tools");
以上ace-builds的配置文件在组件CodeEditor.vue里引入:
<script setup>
import { onMounted, ref, watch, onUnmounted } from 'vue';
import * as ace from 'ace-builds';
import '@/utils/aceConfig';
...
组件CodeEditor.vue有两个参数
- value:初始化的值,
- mode:编辑器的类型,如css、js、html或者json编辑器
const props = defineProps({
value: {
type: String,
required: false,
default: ''
},
mode: {
type: String,
required: true,
default: 'javascript'
}
})
编辑器初始化
function createEditor() {
const editorElement = codeEditRef.value;
let editorOptions = {
mode: `ace/mode/${props.mode}`,
// theme: 'ace/theme/github',
fontSize: 16, // 编辑器内字体大小
showGutter: true,
showPrintMargin: false,
};
const advancedOptions = {
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true
};
editorOptions = { ...editorOptions, ...advancedOptions };
editor = ace.edit(editorElement, editorOptions);
editor.session.setUseWrapMode(true);
if (props.value) editor.setValue(props.value, -1);
editor.on("change", () => {
if (emit) {
emit("update:value", editor.getValue());
}
});
}
以上是便完成组件CodeEditor.vue的开发。
2. App.vue布局
我设计了6大部分用于设计组件:
- 资源:引用一些第三方库
- html:书写template的地方
- css:书写css的地方
- 组件设置:使用JSON Schema配置组件,在组件实例化时,通过此配置出不同的表现行为
- JavaScript:书写逻辑的地方
- 组件实时预览区
页面布局 采用split.js将页面分为四大部分,在代码编辑的时候可以随时调整大小甚至全屏编辑
这是useSplit.js
import { ref, onMounted } from 'vue';
import Split from 'split.js'
export default function useSplit() {
onMounted(() => {
initSplitLayout()
})
const topPanelRef = ref(null)
const bottomPanelRef = ref(null)
const topLeftPanelRef = ref(null)
const topRightPanelRef = ref(null)
const bottomLeftPanelRef = ref(null)
const bottomRightPanelRef = ref(null)
function initSplitLayout() {
Split([topPanelRef.value, bottomPanelRef.value], { // 元素实例
sizes: [35, 65], // 初始化两边元素的大小
gutterSize: 8, // 两个元素的间隔
cursor: 'row-resize', // 鼠标样式
direction: 'vertical' // 方向
});
Split([topLeftPanelRef.value, topRightPanelRef.value], {
sizes: [50, 50],
gutterSize: 8,
cursor: 'col-resize'
});
Split([bottomLeftPanelRef.value, bottomRightPanelRef.value], {
sizes: [50, 50],
gutterSize: 8,
cursor: 'col-resize'
});
}
return {
topPanelRef,
bottomPanelRef,
topLeftPanelRef,
topRightPanelRef,
bottomLeftPanelRef,
bottomRightPanelRef,
}
}
在App.vue导进来
const {
topPanelRef,
bottomPanelRef,
topLeftPanelRef,
topRightPanelRef,
bottomLeftPanelRef,
bottomRightPanelRef,
} = useSplit();
3. 渲染方案
完成以上工作,基本的工作已经完成,下一步就要思考如何通过用户的源代码通过某种方式渲染到界面上。
我们可以回忆下vue是怎么编译一个.vue文件的:
- Webpack或Vue CLI等构建工具会使用vue-loader加载.vue文件。
- vue-loader会提取文件中的不同部分:
<template>、<script>和<style> template部分包含组件的HTML结构,这部分通过Vue的模板编译器进行编译,并转换成一个渲染函数render<style>部分包含CSS样式,可以通过css-loader和style-loader进行处理。javascript通过Babel或其他JavaScript转换器进行处理,以便在旧版浏览器中运行新版本的JavaScript代码。
以上流程都用了一个vue核心库@vue/compiler-sfc,@vue/compiler-sfc的作用就是把单文件组件(sfc)编译成为js代码。
它有四个核心方法分别是:
- parse:这个方法用于解析单文件组件的内容。它读取
.vue文件的内容,并将其拆分为不同的部分:模板(<template>)、脚本(<script>)、样式(<style>)和自定义块。解析的结果是一个包含这些部分的AST(抽象语法树),这个AST可以被后续的编译步骤进一步处理。 - compileTemplate:这个方法用于编译模板部分。它将模板字符串转换为一个渲染函数,这个渲染函数在运行时可以生成虚拟DOM。编译过程包括解析模板中的指令、表达式等,并生成对应的JavaScript代码。
- compileStyle:这个方法用于处理样式部分。它负责处理
<style>标签中的CSS,并可以应用如 scoped CSS(作用域内的CSS)等特定于Vue的转换。这个方法也会处理CSS预处理器,如Sass或Less,将其转换为浏览器可以解析的标准CSS。 - compileScript:这个方法用于处理脚本部分。它通常涉及对
<script>标签内的JavaScript代码进行处理,例如进行语法转换、添加必要的Vue运行时帮助代码等。这个方法确保脚本在Vue环境中正确执行。
所以我们的方案就可以确定了:
- 将用户编写的字符串数据组装为一个类似于.vue的文件。然后交给
@vue/compiler-sfc的parse处理 - compileScript处理脚本数据
- compileTemplate得到render函数给到compileScript的setup函数的返回值,这一步其实可以省略,因为compileScript第二个参数有个配置项
inlineTemplate,这样得到的脚步文件直接就有render函数了。然后动态创建script标签,指定src为编译后的脚步文件,浏览器就会执行他,我们就得到了setup对象(组件)了。想办法得到这个setup对象,我们就可以动态渲染组件。 - compileStyle得到样式字符串,通过动态创建
style然后push到html上。 - 完成以上步骤就可以动态的将字符串的vue组件渲染到页面上了。
在这个示例中,为了组件设计的时候不和主应用相互污染,我采用了iframe的方式渲染,目的是隔离作用域。
下面我们一起来实现它~
3.1 组装字符串 拿到用户输入的html、JavaScript、css组装为一个vue文件字符串:
const sfcContent = `
<template>
${htmlValue}
</template>
<script setup>
${javaScriptValue}
<\/script>
<style>
${cssValue}
<\/style>
`
3.2 编译sfcContent
使用@vue/compiler-sfc的parse处理sfcContent:
const filename = `widget-design-${id}`;
const { descriptor } = parse(sfcContent, {
filename
});
console.log("🚀 ~ descriptor:", descriptor)
打印parse的结果descriptor:
使用compileScript编译descriptor:
const script = compileScript(descriptor, {
id,
inlineTemplate: true,
...templateOptions,
sourceMap: true
});
注意:配置项inlineTemplate一定要为true,上面说明了。
得到的结果script:
其中content即为我们需要的结果。
3.3 编译styles
// 编译 css 样式。
if (descriptor.styles?.length) {
styles = descriptor.styles.map((style) => {
return compileStyle({
source: style.content,
scoped: style.scoped,
id: id,
}).code;
});
}
这里为什么styles是数组呢?
因为一个vue文件里可以有多个style标签。
3.4 widget.html
为了使动态生成的组件不污染主应用,我使用一个html文件渲染我们得到的script.content和styles。然后通过一个vue组件动态创建一个iframe,将此html文件地址赋值给iframe,这样就能渲染了。
因为iframe能完美的隔绝环境。
我们可以通过postMessage来和iframe通信,在每次ctrl + s保存的时候通过postMessage将script.content和styles传递给iframe。
发送(widget.vue):
// hooks监听ctrl + s
useKeyDown('ctrl+s', async () => {
// 这个方法保存组件数据到localStorage。实际项目请通过接口保存到数据库
saveWidgetDescriptor()
const { scriptContent, styles } = await compilerWidgetDescriptor(widgetDescriptor, id);
// console.log("🚀 ~ styles:", styles)
// console.log("🚀 ~ scriptContent:", scriptContent)
_iframe.contentWindow.postMessage(
{ scriptContent, styles, id },
"*"
);
})
接收(widget.html):
window.addEventListener("message", ({ data }) => {
const { scriptContent, styles, id } = data;
handleEval(scriptContent, styles, id);
});
handleEval的实现:
window.inlineImport = async (moduleID) => {
let blobURL = null;
const module = document.querySelector(`script[type="inline-module"]${moduleID}`);
if (module) {
blobURL = getBlobURL(module);
}
if (blobURL) {
const result = await import(blobURL);
return result;
}
return null;
};
async function handleEval(code, styles, id) {
// 移除历史脚本
if (oldElements.length) {
oldElements.forEach(el => el.remove());
}
setStyles(styles, id) //动态创建styles标签
// 创建新的脚本元素
const script = document.createElement("script");
script.setAttribute("type", "inline-module");
script.id = `script-content-${id}`;
script.innerHTML = code;
oldElements.push(script);
// 获取另外一个标签的默认导出的内容 https://github.com/xitu/inline-module
const inlineModule = document.getElementById(`inline-module-${id}`);
if (!inlineModule) {
const script2 = document.createElement("script");
script2.id = `inline-module-${id}`
script2.src = '/inline-module.js';
document.body.appendChild(script2);
document.body.insertBefore(script, script2);
} else {
document.body.insertBefore(script, inlineModule);
}
window.inlineImport(`#script-content-${id}`).then(m => {
const app = createApp(m.default)
if (instance) {
instance.unmount()
instance = null;
}
instance = app;
app.mount('#app');
});
}
这里有两个技巧:
- script里怎么获取另外一个script里默认导出的内容,我这里使用一个库:github.com/xitu/inline… 有兴趣的同学可以查看下怎么使用,通过这种方式就可以拿到组件对象了。因源码会对加载过的内容缓存。我这里将这个库本地化并修改源码将缓存去除掉了,才能实现边写边渲染。
- 可以用BlobURL来import模块:使用示例点击这里
3.5 widget.vue 这个组件是渲染widget.html的。 通过这种方式将刚刚编写的widget.html文件导入:
import widgetHtml from "./widget.html?raw";
在onMounted的时候执行:
function createIframe() {
const iframe = document.createElement('iframe');
iframe.setAttribute('frameborder', '0');
iframe.style = 'width: 100%; height:100%';
iframe.srcdoc = widgetHtml;
widgetContainerRef.value.appendChild(iframe);
iframe.onload = () => {
};
return iframe;
}
onMounted(() => {
_iframe = createIframe();
})
完成以上核心代码的编写,我们就得到了一个简单的vue3组件在线设计器:
志哥我想说
完成以上JavaScript、html、css的解析,我们的组件在线编写和渲染也差不多完成了,这个只是示例,实际生产环境需要考虑更多情况和边界条件如:
- 样式污染问题:本示例中的styles未设置
scoped,我找到了一篇文章,有时间可以参考此文章实现一下 - 依赖注入问题:可以将一些常用函数或者服务注入甚至是第三方库都可以放到在线设计组件的ctx中,通过
inject('ctx')拿到注入的数据:- 组件的上级容器
- 日期操作
- 网络请求服务axios
- 消息服务message
- 路由router
- utils
- ...
- 组件资源:如我可以引入一些第三方库的cdn地址,通过动态创建script将第三方库暴露在window下,组件就可以使用这些库了。
- 组件settings:实际项目中可以用JSON Schema编辑和渲染,配置demo点击这个链接。编辑settings,让组件表现不同的行为。这一步其实就是组件实例化的过程。
- 组件实时预览采用了iframe进行隔离,在实际生产环境可以需要在同一个环境进行加载和渲染,这部分功能待实现。
- 组件通过
new Function动态执行js安全问题。 - 组件间通信未设计。
- 内置组件的使用问题。
- ...
各位大佬可以参考此实现方式,给做低代码的同学提供一个设计思路,如有更多实现方式,欢迎讨论。
本文所有的代码均已开源放到gitHub上了,欢迎各位大佬食用:
在线体验:zzhimin.github.io/online-comp…
如果觉得有帮助可以加个关注。