2022-05-05
tmagic-editor
github tmagic-editor tmagic-editor可视化开源项目是从魔方平台演化而来的开源项目, 意在提供一个供开发者快速搭建可视化搭建平台的解决方案;
npm run playground
编辑器-实例
for vite
初始化 一个标准的 vite vue 项目
# 初始化 一个 vite vue 项目
npm init vite@latest tmagic-project --template vue
cd tmagic-project安装相关依赖
- 编辑器 @tmagic/editor 实现一个可视化编辑器。 @tmagic/form 实现组件在编辑器中自定义表单配置。
# 安装 @tmagic/editor 和 @tmagic/form
npm install @tmagic/editor @tmagic/form -S- UI 适配 由于在实际应用中项目常常会用到例如element-plus、tdesign-vue-next等UI组件库。 为了能让使用者能够选择不同UI库,@tmagic/editor将其中使用到的UI组件封装到@tmagic/design中,然后通过不同的adapter 来指定使用具体的对应的UI库,我们提供了@tmagic/element-plus-adapter来支持element-plus,所以还需要安装相关的依赖。
# 安装 UI 实现 适配
npm install @tmagic/element-plus-adapter @tmagic/design element-plus -S- 其他 editor 中还包含了monaco-editor,所以还需安装 monaco-editor,可以参考 monaco-editor 的配置指引。 Momonaco-editor是微软提供的代码编辑器, 可以嵌入到浏览器中, 方便开发?
# 代码编辑器
npm install monaco-editor -S
# 样式
npm install sass -D用来AMD引入 propsConfigs, propsValues
asyncLoadJs.js
function asyncLoadJs (url) {
return new Promise((resolve, reject) => {
let script = document.createElement('script')//没有校验 重复加载
script.type = 'text/javascript'
script.src = url
document.body.appendChild(script)
script.onload = () => {
resolve()
}
script.onerror = () => {
reject()
}
})
}引入 @tmagic/editor
main.js
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import TMagicDesign from '@tmagic/design';
import MagicEditor from '@tmagic/editor';
import MagicElementPlusAdapter from '@tmagic/element-plus-adapter';
import MagicForm from '@tmagic/form';
import App from './App.vue';
import 'element-plus/dist/index.css';
import '@tmagic/editor/dist/style.css';
import '@tmagic/form/dist/style.css';
const app = createApp(App);
app.use(ElementPlus, {
locale: zhCn,
});
app.use(TMagicDesign, MagicElementPlusAdapter);
app.use(MagicEditor);
app.use(MagicForm);
app.mount("#app");以上代码便完成了 @tmagic/editor 的引入。需要注意的是,样式文件需要单独引入。
使用 m-editor 组件
App.vue
<template>
<m-editor
v-model="dsl"
:menu="menu"
:runtime-url="runtimeUrl"
:props-configs="propsConfigs"
:props-values="propsValues"
:component-group-list="componentGroupList"
>
</m-editor>
</template>
<script>
import { defineComponent, ref } from "vue";
import { markRaw } from 'vue';
import { FolderOpened, SwitchButton, Tickets } from '@element-plus/icons-vue';
export default defineComponent({
name: "App",
setup() {
// 组件列表
const componentGroupList = ref([
{
title: '容器',
items: [
{
icon: markRaw(FolderOpened),
text: '组',
type: 'container',
},
],
},
{
title: '基础组件',
items: [
{
icon: markRaw(Tickets),
text: '文本',
type: 'text',
},
{
icon: markRaw(SwitchButton),
text: '按钮',
type: 'button',
},
],
},
]);
// 组件属性列表
const propsConfigs = ref([])
// 组件默认值
const propsValues = ref([])
asyncLoadJs(`entry/config/index.umd.cjs`).then(() => {
propsConfigs.value = window.magicPresetConfigs;
});
asyncLoadJs(`entry/value/index.umd.cjs`).then(() => {
propsValues.value = window.magicPresetValues;
});
return {
menu: ref({
left: [
// 顶部左侧菜单按钮
],
center: [
// 顶部中间菜单按钮
],
right: [
// 顶部右侧菜单按钮
],
}),
dsl: ref({
// 初始化页面数据
}),
runtimeUrl: "/runtime/playground/index.html",
// 组件属性列表
propsConfigs,
// 组件默认值
propsValues,
// 组件列表
componentGroupList,
};
},
});
</script>
<style lang="scss">
... 略
</style>vite 走你~
m-editor 配置项
其中,有四个需要注意的属性配置项:runtimeUrl values configs componentGroupList。这是能让我们的编辑器正常运行的关键。
runtimeUrl
该配置涉及到 runtime 概念, tmagic-editor编辑器中心的模拟器画布, 是一个 iframe(这里的 runtimeUrl 配置的, 就是你提供的 iframe 的 url), 其中渲染了一个 runtime, 用来响应编辑器中的组件增删改等操作;
如何快速得到一个 runtime?
可以使用tmagic-editor项目源码中的 runtime,在提供的三个框架 vue2/vue3/react runtime 目录中选择一个,执行 npm run build:admin 得到产物,并将 ./dist/*产物放到你的项目中 ./public/,此处的 runtimeUrl 指向你放置 runtime/playground/index.html 的路径。(注意index.html引入外部js文件路径有错误的)
runtimeUrl: "/runtime/playground/index.html"
componentGroupList
componentGroupList 是指定左侧组件库内容的配置; 此处定义了在编辑器组件库中有什么组件; 在添加的时候通过组件 type 来确定 runtime 中要渲染什么组件; componentgrouplist
// 组件列表
const componentGroupList = ref([
{
title: '容器',
items: [
{
icon: markRaw(FolderOpened),
text: '组',
type: 'container',
},
],
},
{
title: '基础组件',
items: [
{
icon: markRaw(Tickets),
text: '文本',
type: 'text',
},
{
icon: markRaw(SwitchButton),
text: '按钮',
type: 'button',
},
],
},
]);propsConfigs/propsValues
propsConfigs propsValues 和 componentGroupList 中声明的组件是一一对应的,通过 type 来识别属于哪个组件,该配置涉及的内容,就是组件的表单配置描述,在组件开发中会通过 formConfig 配置来声明这份内容。
上述的 runtime 产物中,dist 目录中即包含一个 entry 文件夹,在你的项目组件初始化之后,分别异步加载里面的config/index.umd.js、value/index.umd.js。并如上面代码中,赋值给 configs/values 即可。
// 组件属性列表
const propsConfigs = ref([])
// 组件默认值
const propsValues = ref([])
asyncLoadJs(`/entry/config/assets/index.umd.cjs`).then(() => {
propsConfigs.value = window.magicPresetConfigs;
});
asyncLoadJs(`/entry/value/assets/index.umd.cjs`).then(() => {
propsValues.value = window.magicPresetValues;
});其实官方[tmagic-editor]的 playground, 就是一个编辑器实例的 demo 项目
dsl
点击中间的新增页面也会发现没有反应,这是因为没有编辑器初始值,只需要给value赋上初始值就可以了离谱! 加上初始值后,点击新增页面就可以渲染出一个画布了,但是点击添加HelloWorld组件依然没有反应
const dsl = ref({
type: 'app',
// 必须加上ID,这个id可能是数据库生成的key,也可以是生成的uuid
id: 1,
items: [],
});踩坑
internal server error preprocessor dependency sass not found. did you install it vite [plugin:vite:css] Preprocessor dependency “sass” not found. Did you install it?
npm install sass --save-dev
Uncaught ReferenceError: global is not defined at node_modules/randombytes/browser.js (browser.js:16:14) 总有那么低级的错误!
修改其源码 ./node_modules/serialize-javascript/index.js增加:
window.global = window
如果是使用vite构建工具,如果出现 Uncaught ReferenceError: global is not defined,那么需要再vite.config.js中添加如下配置:
{
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis',
},
},
},
}import { performance } from ‘node:perf_hooks’
SyntaxError: Cannot use import statement outside a module
npm i --save-dev @types/node
基础概念
组件
组件是tmagic-editor可配置页面元素的最小单位;
组件样式、逻辑代码(即开发者写的 vue, react 等代码)。
组件 type, 是组件的类型,这是用来告诉编辑器,我们要渲染的是什么组件。每个组件在开发时就应该确定这样一个唯一、不和其他组件冲突的组件 type。
插件
插件和组件类似,但是插件的功能是作为页面逻辑行为的一种补充方式。一般不显式的在模拟器中被渲染出具体内容(除非插件中会生成组件并插入页面),通常我们会用插件实现类似登录,页面环境判断,请求拦截器等等功能。
插件一般包含如下内容:
插件逻辑代码。 插件 type,是插件的类型,和组件 type 作用相同。在开发时就应该确定这样一个唯一、不和其他组件冲突的组件 type
容器
容器是tmagic-editor编辑器中的一个基础单位, 页面本身就是一个容器, 在基础组件中称为组, tmagic-editor通过容器概念, 实现了丰富的布局方式, 因为我们的布局行为是设置在容器上的, 容器内的组件是绝对定位, 或是顺序排布, 是根据容器的配置行为改变的; tmagic-editor的容器理论上可以无限嵌套;
表单配置
配置项目都是由组件里的表单描述来决定的, 用户可以在表单配置区域里通过配置项来改变组件的行为和样式;
DSL(描述文件)
DSL 是编辑器搭建页面的最终产物(描述文件), 其中包含了所有组件信息(组件布局, 组件配置等)和插件内容, 以及其他可拓展的信息都存放在 DSL 中; 根据 DSL 的描述进行渲染 https://tencent.github.io/tmagic-editor/docs/guide/advanced/js-schema.html
页面
我们把页面统一称为 runtime,更具体的 runtime 概念可以查看页面发布。runtime 是承载tmagic-editor项目页面的运行环境。编辑器的工作区是 runtime 的一个具体实例,另一个就是我们发布上线后,用户访问的真实项目页面。
核心概念
tmagic-editor
tmagic-editor可视化开源项目是从魔方平台演化而来的开源项目,意在提供一个供开发者快速搭建可视化搭建平台的解决方案。
tmagic-editor的编辑器我们已经封装成一个 npm 包,可以直接安装使用。编辑器是使用 vue3 开发的,但使用编辑器的业务可以不限框架,可以用 vue2、react 等开发业务组件。
@tmagic/editor 实现一个可视化编辑器;
in short 可以理解为一个编辑器, 可视化界面设计器, 它的产物是DSL
runtime
runtime 的概念, 是理解tmagic-editor项目页运行的重要概念, runtime 是承载tmagic-editor项目页面的运行环境;
可视化页面需要在tmagic-editor编辑器中搭建、渲染,通过模拟器所见即所得。搭建完成后,保存配置并发布,然后渲染到真实页面。其中涉及到两个不同的 runtime:
-
终端打开真实页面 (runtime page) 对应的是 runtime 中的 page。即把tmagic-editor 保存后的配置进行加载、解析、渲染,然后呈现页面的过程。
-
编辑器中的模拟器 (runtime playground) 对应的是 runtime 中的 playground。其实仔细查看源码,playground 和 page runtime 的差异,在于 playground 中需要响应编辑器中用户的操作
runtime 的 playground 部分,和 page 做的事情几乎一致,业务方可以包含上述 page 所拥有的全部能力。 但是,因为 playground 需要被编辑器加载,作为编辑器中页面模拟器的渲染容器,和编辑器通信,接受编辑器中组件的增删改查。所以,除了保持和 page 一样的渲染逻辑之外,playground 还要额外实现一套既定通信内容和 api,才能实现和编辑器的通信功能。
各个 runtime 的作用除了作为不同场景je下的渲染环境,同时也是不同环境的打包构建载体。
in short tmagic-editor负责编辑设计出DSL, 最终是给 runtime 渲染, 所以runtime是有不同实现的
官方示例的 runtime
tmagic-editor提供了三个版本的 runtime 示例,可以参考:
....
window.magic?.onRuntimeReady({
getApp() {
return app;
},
updateRootConfig(config: MApp) {
console.log('update config', config);
root.value = config;
app?.setConfig(config, curPageId.value);
},
updatePageId(id: Id) {
console.log('update page id', id);
curPageId.value = id;
app?.setPage(id);
},
select(id: Id) {
console.log('select config', id);
selectedId.value = id;
const el = document.getElementById(`${id}`);
// 未在当前文档下找到目标元素,可能是还未渲染,等待渲染完成后再尝试获取
return nextTick().then(() => document.getElementById(`${id}`) as HTMLElement);
...
实现一个runtime 需要提供的API
| API | 说明 | 参数 |
|---|---|---|
| updateRootConfig | 根节点更新 | root: MApp |
| updatePageId | 更新当前页面 id | id: string |
| select | 选中组件 | id: string |
| add | 增加组件 | { config , root }: UpdateData |
| update | 更新组件 | { config , root }: UpdateData |
| remove | 删除组件 | { config , root }: UpdateData |
| sortNode | 组件在容器间排序 | { src , dist, root }: SortEventData |
业务逻辑写在哪? 由于 runtime 是页面渲染的承载环境,其中会加载 @tmagic/ui 以及各个业务组件,业务发布项目页也是基于 runtime,所以在 runtime 中实现业务方的自定义逻辑是最合适的。
tmagic/ui
tmagic-editor的页面渲染,是通过在载入编辑器中保存的 DSL 配置,通过 ui 渲染器渲染页面。在容器布局原理里我们提到过,容器和组件在配置中呈树状结构,所以渲染页面的时候,渲染器会递归配置内容,从而渲染出页面所有组件。
tmagic-editor的设计中,针对每个前端框架,都需要有一个对应的 @tmagic/ui 来承担渲染器职责。
in short 相当于一个UI实现层, @tmagic/ui 和 runtime 是配套出现的, runtime 必须基于 @tmagic/ui 才可以实现渲染; 因为 @tmagic/ui 需要提供 runtime 所需要的渲染器;
@tmagic/ui vue3
在 @tmagic/ui vue3 中,我们提供了几个基础组件,可以在项目源码中找到对应内容。
page tmagic-editor的页面基础 container tmagic-editor 的容器渲染器 Component.vue tmagic-editor的组件渲染器
button/text 是一个组件开发的示例,具体组件开发相关规范可以参考组件开发。 其中 page/container/Component 是 UI 的基础,是每个框架的 UI 都应该实现的。
@tmagic/form
tmagic-editor的表单配置,核心就是使用了 @tmagic/form 来作为渲染器。@tmagic/form 是一个 npm 包,可以安装它,在你想使用的地方单独使用。
相关核心库
@tmagic/editor 实现一个可视化编辑器;
@tmagic/form 实现组件在编辑器中自定义表单配置;
@tmagic/core 实现对组件进行跨框架管理与一些通用复杂逻辑的实现;
我们在 @tmagic/core 这个包中, 实现了tmagic-editor组件节点的 Node 类, 每个组件在tmagic-editor的运行环境被渲染前, 都会对应初始化一个 Node 类实例; 而这些 Node 实例上包含了一些基础功能, 包括触发指定钩子函数; 这是一个框架无关的核心库, 所以支持在各个语言框架中使用; 但是具体触发时机需要由各个框架的渲染器实现;
@tmagic/stage 实现在编辑器中对组件的位置拖动与大小拖拉;
@tmagic/ui 提供一些vue3基础组件;
@tmagic/ui-vue2 提供一些vue2基础组件;
@tmagic/ui-react 提供一些react基础组件;
runtime 实现在编辑器中对使用不同框架的组件的渲染;
page 项目提供最终页面发布的执行环境与组件构建;
实例教程
以 vue3 的组件开发为例。运行项目中的 playground 示例,会自动加载 vue3 的 runtime。runtime会加载@tmagic/ui
简单示例 Hello World
初始化 项目
# vite 项目
npm init vite@latest tmagic-components --template vue
cd tmagic-components
# tmagic/editor 依赖
npm install --save @tmagic/editor @tmagic/form @tmagic/stage @tmagic/design @tmagic/element-plus-adapter element-plus
初始化 编辑器组件
main.js
import 'element-plus/dist/index.css';
import '@tmagic/editor/dist/style.css';
import '@tmagic/form/dist/style.css';
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import TMagicDesign from '@tmagic/design';
import TMagicEditor from '@tmagic/editor';
import TMagicElementPlusAdapter from '@tmagic/element-plus-adapter';
import TMagicForm from '@tmagic/form';
import App from './App.vue';
createApp(App)
.use(ElementPlus)
.use(TMagicDesign, TMagicElementPlusAdapter)
.use(TMagicEditor)
.use(TMagicForm)
.mount('#app');App.vue
<template>
<m-editor
v-model="value"
:render="render"
:component-group-list="componentGroupList"
></m-editor>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
const value = ref();
const componentGroupList = ref([]);
const render = () => window.document.createElement('div');
</script>
<style>
....
</style>添加组件列表配置
const componentGroupList = ref([
{
title: '组件列表',
items: [
{
icon: 'https://vfiles.gtimg.cn/vupload/20220614/9cc3091655207317835.png',
text: 'HelloWorld',
type: 'hello-world',
},
],
},
]);初始化DSL
const value = ref({
type: 'app',
// 必须加上ID,这个id可能是数据库生成的key,也可以是生成的uuid
id: 1,
items: [],
});加上初始值后,点击新增页面就可以渲染出一个画布了,但是点击添加 HelloWorld 组件没有反应
这是因为这时的编辑器并能理解HelloWorld是什么,需要在render函数中处理
编辑器的渲染
中间工作区域中画布渲染的内容,通常是通过解析modelValue来渲染出DOM,return的DOM结构需要有一个根节点。 https://tencent.github.io/tmagic-editor/docs/api/editor/props.html#render
in short 这里其实就是 runtime 的实现
const render = () => {
const root = window.document.createElement('div');//创建一个根dom
const page = value.value.items[0];//DSL 这里第一个必定是Page配置?
if (!page) return root;
root.id = `${page.value.id}`;
createApp(//动态编译Vue模板
{
template: '<p v-for="node in config.items" :key="node.id" :id="node.id">hello world</p>',
props: ['config'],
},
{
config: page,
},
).mount(root);
return root;
};
render函数中获取 page 是通过 value.value.items[0],这样只是表示第一个页面, 如果页面有多个页面就会有问题 可以通过editorService.get('page')获取到当前选中的页面
import { editorService } from '@tmagic/editor';
import type { MPage } from '@tmagic/schema';
const page = computed(() => editorService.get<MPage>('page'))这里用到了动态编译Vue模板,所以需要在vue.config.js中添加vue alias
configureWebpack: {
resolve: {
alias: {
vue$: 'vue/dist/vue.esm-bundler.js',
},
},
}总而言之, render 可以接受 DSL 和 与之交互;
参考: https://tencent.github.io/tmagic-editor/docs/guide/tutorial/hello-world.html
实现 runtime for vue3
参考: https://tencent.github.io/tmagic-editor/docs/guide/tutorial/runtime.html
runtime 渲染项目
初始化
# vite 项目
npm init vite@latest editor-runtime --template vue
cd editor-runtime
# 依赖
npm install --save @tmagic/schema @tmagic/stage
新建 ui-page.vue文件
ui-page.vue
<template>
<div v-if="config" :id="config.id" :style="style">
<div v-for="node in config.items" :key="node.id" :id="node.id">hello world</div>
</div>
</template>
<script lang="ts" setup>
import { computed, defineProps } from 'vue';
const props = defineProps(["config"]);//接受一个 config prop
const style = computed(() => {
const { width = 375, height = 1700 } = props.config.style || {};
return {
width: `${width}px`,
height: `${height}px`,
};
});
</script>runtime-url 入口, 使用 http url ;
App.vue
<template>
<uiPage :config="page"></uiPage>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import uiPage from './ui-page.vue';
const page = ref();
</script>
<style scoped>
</style>启动: vite —port 5100
editor 编辑器项目
初始化
# vite 项目
npm init vite@latest hello-editor --template vue
cd hello-editor
# tmagic/editor 依赖
npm install --save @tmagic/editor @tmagic/form @tmagic/stage @tmagic/design @tmagic/element-plus-adapter element-plus
App.vue
<template>
<m-editor style="width: 100%; height: 100%"
v-model="value"
:runtime-url="runtimeUrl"
:component-group-list="componentGroupList"
></m-editor>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
const value = ref({
type: "app",
// 必须加上ID,这个id可能是数据库生成的key,也可以是生成的uuid
id: 1,
items: [],
});
const componentGroupList = ref([
{
title: "组件列表",
items: [
{
icon: "https://vfiles.gtimg.cn/vupload/20220614/9cc3091655207317835.png",
text: "HelloWorld",
type: "hello-world",
},
],
},
]);
const runtimeUrl = "http://127.0.0.1:5100/";//runtime 的url;
</script>
<style>
#app {
overflow: auto;
}
html,body,#app {
height: 100%; margin: 0;padding: 0;
}
#app::-webkit-scrollbar {
width: 0 !important;
display: none;
}
</style>
在editor-runtime 项目下的 vue.config.js 下配置
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis',
},
},
},main.js
import 'element-plus/dist/index.css';
import '@tmagic/editor/dist/style.css';
import '@tmagic/form/dist/style.css';
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import TMagicDesign from '@tmagic/design';
import TMagicEditor from '@tmagic/editor';
import TMagicElementPlusAdapter from '@tmagic/element-plus-adapter';
import TMagicForm from '@tmagic/form';
import App from './App.vue';
createApp(App)
.use(ElementPlus)
.use(TMagicDesign, TMagicElementPlusAdapter)
.use(TMagicEditor)
.use(TMagicForm)
.mount('#app');启动: vite
runtime 与 editor 通信
到这里项目就可以正常访问了,但是会发现添加组件没有反应。 这是因为在 runtime 中无法直接获取到editor中的dsl,所以需要通过editor注入到window的magic api来交互
如出现在runtime中出现magic为undefined, 可以尝试在App.vue中通过监听message,来准备获取magic注入时机,然后调用magic.onRuntimeReady,示例代码如下
实测 editor-runtime 接受不到, 因为是 <iframe srcdoc= 的属性问题??
import { onMounted } from 'vue'
import type { RemoveData, UpdateData } from '@tmagic/stage';
import type { Id, MApp, MNode } from '@tmagic/schema';
const root = ref<MApp>();
// onMounted(()=>{
console.log("editor-runtime onMounted")
window.addEventListener('message', ({ data }) => {
console.log("editor-runtime event message",data)
if (!data.tmagicRuntimeReady) {
return;
}
console.log("editor-runtime onRuntimeReady")
window.magic?.onRuntimeReady({
/** 当编辑器的dsl对象变化时会调用 */
updateRootConfig(config: MApp) {
console.log("当编辑器的dsl对象变化时会调用");
root.value = config;
},
/** 当编辑器的切换页面时会调用 */
updatePageId(id: Id) {
console.log("当编辑器的切换页面时会调用");
page.value = root.value?.items?.find((item) => item.id === id);
},
/** 新增组件时调用 */
add({ config }: UpdateData) {
const parent = config.type === 'page' ? root.value : page.value;
parent.items?.push(config);
console.log("新增组件时调用");
},
/** 更新组件时调用 */
update({ config }: UpdateData) {
const index = page.value.items?.findIndex((child: MNode) => child.id === config.id);
page.value.items.splice(index, 1, reactive(config));
console.log("更新组件时调用");
},
/** 删除组件时调用 */
remove({ id }: RemoveData) {
const index = page.value.items?.findIndex((child: MNode) => child.id === id);
page.value.items.splice(index, 1);
console.log("删除组件时调用");
},
});
})同步页面dom给编辑器
由于组件渲染在runtime中,对于编辑器来说是个黑盒,并不知道哪个dom节点才是页面(对于dsl的解析渲染可能是千奇百怪的),所以需要将页面的dom节点同步给编辑器
watch(page, async () => {
// page配置变化后,需要等dom更新
await nextTick();
window?.magic.onPageElUpdate(pageComp.value?.$el);//将对应组件的 dom 告知 editor
});以上就是一个简单runtime实现,以及与编辑的交互,这是一个不完善的实现(会发现组件再画布中无法自由拖动,是因为没有完整的解析style)
项目归档
实现 @tmagic/ui
新建一个 hello-ui 项目文件夹, 用作ui渲染实现
.
└─editor-runtime
└─hello-editor
└─hello-uiDSL 描述数据
节点(Node)
每一个组件最终都是由一个节点来描述,每个节点至少拥有id,type两个属性: id: 节点的唯一标识,不可重复 type: 节点的类型,有业务自行定义
容器(Container)
容器也是节点的一种,容器可以包含多个节点并且是保存在items属性下 items: 容器下包含的节点组成的数组,items中不能有page,app
页面(Page)
页面是容器的一种,type固定为page,items中不能有page
根(Root)
根节点也是一个容器,type固定为 app,items只能是page
DSL 渲染实现
节点渲染
在 hello-ui 下创建 Component.vue 文件
由于节点的type是由业务自行定义的,可以使用component 组件通过is参数来决定哪个组件被渲染,所以将type与组件做绑定
<template>
<component v-if="config" :is="type" :id="`${id}`" :style="style" :config="config"></component>
</template>
<script lang=ts setup>
import { computed } from 'vue';
import type { MNode } from '@tmagic/schema';
// 将节点作品参数传入组件中 <!> 外部传入的节点 DSL
const props = defineProps<{
config: MNode;
}>();
const type = computed(() => {
if (!props.config.type || ['page', 'container'].includes(props.config.type)) return 'div';
return props.config.type;
});
const id = computed(() => props.config.id);
const fillBackgroundImage = (value: string) => {
if (value && !/^url/.test(value) && !/^linear-gradient/.test(value)) {
return `url(${value})`;
}
return value;
};style 的解析, 需要注意几个地方
-
数字: css中的数值有些是需要单位的,例如px,有些是不需要的,例如opacity, 在tmagic/editor中,默认都是不带单位的,所以需要将需要单位的地方补齐单位 这里做补齐px处理,如果需要做屏幕大小适应, 可以使用rem或者vw,这个可以根据自身需求处理。
-
url: css中的url需要是用url(),所以当值为url时,需要转为url(xxx)
-
transform : transform属性可以指定为关键字值none 或一个或多个transform-function值。
const style = computed(() => {
if (!props.config.style) {
return {};
}
const results: Record<string, any> = {};
const whiteList = ['zIndex', 'opacity', 'fontWeight'];
Object.entries(props.config.style).forEach(([key, value]) => {
if (key === 'backgroundImage') {
value && (results[key] = fillBackgroundImage(value));
} else if (key === 'transform' && typeof value !== 'string') {
results[key] = Object.entries(value as Record<string, string>)
.map(([transformKey, transformValue]) => {
let defaultValue = 0;
if (transformKey === 'scale') {
defaultValue = 1;
}
return `${transformKey}(${transformValue || defaultValue})`;
})
.join(' ');
} else if (!whiteList.includes(key) && value && /^[-]?[0-9]*[.]?[0-9]*$/.test(value)) {
results[key] = `${value}px`;
} else {
results[key] = value;
}
});
return results;
});
</script>
容器渲染
容器与普通节点的区别,就是需要多一个items的解析
在 hello-ui 下新增Container.vue文件
<template>
<Component :config="config">
<Component v-for="item in config.items" :key="item.id" :config="item"></Component>
</Component>
</template>
<script lang="ts" setup>
import type { MContainer } from '@tmagic/schema';
import Component from './Component.vue';
defineProps<{
config: MContainer;
}>();
</script>页面渲染
新增Page.vue文件
<template>
<Container :config="config"></Container>
</template>
<script lang="ts" setup>
import type { MPage } from '@tmagic/schema';
import Container from './Container.vue';
defineProps<{
config: MPage;
}>();
defineExpose({
reload() {
window.location.reload();
}
});
</script>
添加HelloWorld组件
在hello-ui下新增 HelloWorld.vue
<template>
<div>hollo-world</div>
</template>
<script lang="ts" setup>
import type { MNode } from '@tmagic/schema';
defineProps<{
config: MNode;
}>();
</script>在 editor-runtime 中
- 将App.vue中的 ui-page 改成 hello-ui中的Page
<template>
<Page v-if="page" :config="page" ref="pageComp"></Page>
</template>
<script lang="ts" setup>
// eslint-disable-next-line
import { Page } from 'hello-ui';
<script>- 注册 HelloWorld 组件
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { HelloWorld } from 'hello-ui';
let app = createApp(App)
app.mount('#app');
app.component('hello-world', HelloWorld);- 配置解析路径
editor-runtime/vue.config.js
configureWebpack: {
resolve: {
alias: {
'hello-ui': path.resolve(__dirname, '../hello-ui'),
vue$: path.resolve(__dirname, './node_modules/vue'),
},
},
},自此三个项目(参考上 [核心概念]): 一个编辑器项目(hello-editor): 编辑, 页面设计器 (设计生成DSL) 一个runtime项目(editor-runtime): 一切和业务相关的逻辑,都应该在 runtime 中实现 (还负责响应编辑器, 引用ui渲染) 一个ui渲染项目(hello-ui) : 承担的是业务逻辑无关的,基础组件渲染的功能 (负责渲染 节点组件, 容器, 页面)
项目归档
二次开发
官方 tmagic-editor (https://tencent.github.io/tmagic-editor/)项目中 ./playground 目录源码对应 页面设计器; ./runtime/vue3 目录源码对应 runtime-vue3 ./packages/ui 目录源码对应 @tmagic/ui
node.js >= 18 pnpm >= 9
- 先安装 pnpm
npm install -g pnpm- 然后安装依赖
pnpm bootstrap- 运行项目, 执行命令
pnpm playground编辑器分离
按照架构设计 编辑器与UI渲染无关, 不加功能, 就用官方实现即可, 不做扩展, 把它打包处理变成一个纯的编辑器
- 关键三个地方需要为修改外部注入
1.playground
runtimeUrl路径
// runtimeUrl 和 预览 的地址
const { VITE_RUNTIME_PATH, VITE_ENTRY_PATH } = import.meta.env;
const runtimeUrl = `${VITE_RUNTIME_PATH}/playground/index.html`;
预览的DSL 参数传递
playground\src\pages\Editor.vue
const previewUrl = computed(
() => `${VITE_RUNTIME_PATH}/page/index.html?localPreview=1&page=${editor.value?.editorService.get('page')?.id}`,
);在 runtime 预览获取 DSL 的地方
runtime\vue3\page\main.ts
const app = new Core({
ua: window.navigator.userAgent,
config: ((getUrlParam('localPreview') ? getLocalConfig() : window.magicDSL) || [])[0] || {},
curPage: getUrlParam('page'),
});左边的组件列表配置
// 组件列表
import componentGroupList from '../configs/componentGroupList';
// componentGroupList.ts
import { FolderOpened, Grid, PictureFilled, SwitchButton, Tickets } from '@element-plus/icons-vue';
export default [
{
title: '示例容器',
items: [
{
icon: FolderOpened,//直接引用组件 要改下, 才能外部注入
text: '组',
type: 'container',
},
{
icon: FolderOpened,
text: '蒙层',
type: 'overlay',
},
],
},
.....
playground的index.html 引用这个js
EDITOR_CONFIG.js 直接定义为全局变量, 好调试 也好修改
const EDITOR_CONFIG = {
componentGroupList: [
{
title: '示例容器',
items: [
{
icon: "FolderOpened",
text: '组',
type: 'container',
},
{
icon: "FolderOpened",
text: '蒙层',
type: 'overlay',
},
],
},
//右边列表
,
// runtime 地址, 原总的项目开发环境(得看 runtime 项目 dev.vite.config 打包后的目录; 执行的是runtime打包不是rund ev); npm run build:playground
// runtimeUrl: "http://localhost:8888/playground/runtime/vue3/",
// previewUrl: "http://localhost:8888/playground/runtime/vue3/",
// runtime 地址, 执行本地 public 资源; npm run build:playground
runtimePath: "runtime/vue3",
entryPath: "./entry/vue3",
}
window.__G_EDITOR_CONFIG = EDITOR_CONFIG;修改为读取全局变量
./playground/src/pages/Editor.vue
//预览DSL数据, 改为外部js注入
// import componentGroupList from '../configs/componentGroupList';
import {getList} from '../configs/componentGroupList';
let componentGroupList = getList(__G_EDITOR_CONFIG.componentGroupList)//处理 ico组件
// const { VITE_RUNTIME_PATH, VITE_ENTRY_PATH } = import.meta.env;
// console.log(" run %s, %s", VITE_RUNTIME_PATH, VITE_ENTRY_PATH) // ///tmagic-editor/playground/runtime/vue3, ./entry/vue3
// 指向的 runtime 的地址
let VITE_RUNTIME_PATH = __G_EDITOR_CONFIG.runtimePath;
let VITE_ENTRY_PATH = __G_EDITOR_CONFIG.entryPath;
npm run build:playground
2.编辑器的 props
分离编辑器需要
runtimeUrl属性: (runtime 编译出来的 page)entryPath属性: (runtime 编译出来的 entry)
编辑器默认内部dom只要有id都可被选中,可通过 canSelect 控制!!! https://tencent.github.io/tmagic-editor/docs/api/editor/props.html#canselect
playground\src\pages\Editor.vue
const canSelect = (el :any) => {
const inn = ["page", "container", "button", "text", "img", "qrcode", "overlay"]
for (let index = 0; index < inn.length; index++) {
const element = inn[index];
if(el?.id.startsWith(element)) return true;
}
return (el?.id.startsWith("sny"))
};document.getElementsByTagName(“iframe”)[0].contentWindow.appInstance
3.为组件添加统一配置项
编辑器的 ‘属性’, ‘样式’ 等 来源于: import propsService from './services/props';
内部 fillConfig 实际来源于./tmagic-editor\packages\editor\src\utils\props.ts 文件定义的;
官方还预留了扩展可以使用 usePlugin, 在 fillConfig 之后调用 afterFillConfig, 填充自己定义的属性
propsService.usePlugin({
/**
oldConfig: 系统生成的配置
componentConfig: 组件内定义的formConfig
**/
afterFillConfig(oldConfig, componentConfig) {
return yourConfig
}
})
runtime & ui 项目定制
runtime 修改
删除没用的, 只保留 runtime (runtime\vue3) , packages\ui(ui) 及其依赖
├─packages
│ ├─cli
│ ├─core
│ ├─data-source
│ ├─schema
│ ├─stage
│ └─ui //ui 项目
├─runtime
│ └─vue3
│ ├─page
│ │ └─assets
│ └─playground
│ └─assets
└─runtime: 项目 可增加业务代码, 供ui组件使用, 使用DSL调用ui, 作为中介者; packages ui: 项目纯作为符合 tmagic-editor 组件规范的, 可作为组件资产项目;
main.ts修改加载 DSL 和 page id的方式, 注意 localPreview 是作为设计器预览的参数, 方便发布嵌入 main.ts
const app = new Core({
ua: window.navigator.userAgent,
// config: ((getUrlParam('localPreview') ? getLocalConfig() : window.magicDSL) || [])[0] || {},
config: (getUrlParam('localPreview') ? getLocalConfig()[0] : getConfig()),//返回DSL
curPage: getUrlParam('page')//返回 page id
});runtime\vue3\build.vite.config.ts
修改 base, 不然发布后的js引用路径有问题
if (['page', 'playground', 'page:admin', 'playground:admin'].includes(mode)) {
const [type, isAdmin] = mode.split(':');
// const base = isAdmin ? `/static/vue3/runtime/${type}/` : `/tmagic-editor/playground/runtime/vue3/${type}`;
const base = `tmagic-test/${type}`; //ctx路径!
const outDir = isAdmin
? path.resolve(process.cwd(), `./dist/runtime/${type}`)
: path.resolve(process.cwd(), `../../playground/public/runtime/vue3/${type}`);//这里可配置 runtime 编译输出的 entry 和 runtime
return {
.....dev.vite.config.ts
export default defineConfig({
plugins: [vue(), vueJsx()],
resolve: {
alias: [
{ find: /^@\/assets\//, replacement: path.join(__dirname, '../../packages/ui/src') },//<!> 增加导入别名, 方便组件引用资源; ui 项目新增一个 assets文件夹
{ find: /^vue$/, replacement: path.join(__dirname, 'node_modules/vue/dist/vue.esm-bundler.js') },
{
find: /^@tmagic\/utils\/resetcss.css/,
replacement: path.join(__dirname, '../../packages/utils/src/resetcss.css'),
},
....
root: './',
// base: '/tmagic-editor/playground/runtime/vue3/',
base: '/runtime/',// <!> 改下路径 太长了
publicDir: 'te',//<!>修改开放资源目录, 将打包后的playground放到这个目录, 方便开发cd runtime\vue3
pnpm install
runtime 编译新增的组件
runtime 项目的入口
runtime\vue3\page\main.ts
import { createApp, defineAsyncComponent } from 'vue';
import Core from '@tmagic/core';
import { getUrlParam } from '@tmagic/utils';
import components from '../.tmagic/async-comp-entry';//关键这个文件是 tmagic entry && npm run build:config && npm run build:value && npm run build:event 编译出来的
....
Object.entries(components).forEach(([type, component]: [string, any]) => {//这里导入注册的组件
magicApp.component(`magic-ui-${type}`, defineAsyncComponent(component));
});
tmagic entry && npm run build:config && npm run build:value && npm run build:event 看下编译配置
runtime\vue3\build.vite.config.ts
if (['value', 'config', 'event', 'value:admin', 'config:admin', 'event:admin'].includes(mode)) {
const [type, isAdmin] = mode.split(':');
const capitalToken = type.charAt(0).toUpperCase() + type.slice(1);
return {
publicDir: './.tmagic/public',
build: {
cssCodeSplit: false,
sourcemap: true,//是否生成源码映射 调试用的
minify: false,
target: 'esnext',
outDir: isAdmin ? `./dist/entry/${type}` : `../../playground/public/entry/vue3/${type}`,//这里可配置 runtime 编译输出的 entry 和 runtime
lib: {
entry: `.tmagic/${type}-entry.ts`,//应该就是这个输出的文件
name: `magicPreset${capitalToken}s`,
fileName: 'index',
formats: ['umd'],
},
},
};
}还一个编译配置: runtime\vue3\tmagic.config.ts 指定了路径
import path from 'path';
import { defineConfig } from '@tmagic/cli';
export default defineConfig({
packages: [path.join(__dirname, '../../packages/ui')],//指定了ui项目路径
componentFileAffix: '.vue',
dynamicImport: true,
});packages 导出组件
最终在 packages\ui\src\index.ts 导出即可
import Button from './button';
import Container from './container';
import Img from './img';
import Overlay from './overlay';
import Page from './page';
import Qrcode from './qrcode';
import Text from './text';
import testComp from './testComp';
const ui: Record<string, any> = {
page: Page,
container: Container,
button: Button,
text: Text,
img: Img,
qrcode: Qrcode,
overlay: Overlay,
testComp:testComp, //导出新增的组件
};
export default ui;其次还需要注意 runtime 编译出来的 npm run build:config && npm run build:value && npm run build:event 替换 playground 的 entryPath 指向的文件
// npm run build:config && npm run build:value && npm run build:event //命令, 可只更新配置文件, 不用每次都重新打包
npm run build //完整打包 ui安装第三方库
> cd packages\ui
pnpm install element-plus --save
pnpm install --save lodash
pnpm install --save pinia错误?
This error shows up when you run npm install in a directory where you previously ran pnpm install. The solution is to remove your node_modules directory and run npm install again.
使用 pnpm pnpm install element-plus --save
ui 添不添加 element-plus 都无所谓, 只是为了ide引用不报错; 因为最终打包发布是 runtime 编译的 page 和 playground
cd runtime\vue3
pnpm install element-plus --save
pnpm install --save lodash
pnpm install --save pinia
踩坑指南
playground 几个关键点方便调试
在画布 选择/移除 组件时事件处理
packages\editor\src\utils\stage.ts
packages\editor\src\layouts\workspace\Stage.vue
//在组件拖动到画布时, 由该函数处理
const dropHandler = async (e: DragEvent) => {
e.preventDefault();
const doc = stage?.renderer.contentWindow?.document;
const parentEl: HTMLElement | null | undefined = doc?.querySelector(`.${stageOptions?.containerHighlightClassName}`);
let parent: MContainer | undefined | null = page.value;
if (parentEl) {
parent = services?.editorService.getNodeById(parentEl.id, false) as MContainer;
}
....
// config 即是 componentGroupList 的组件配置
services?.editorService.add(config, parent);编辑服务
packages\editor\src\services\editor.ts
playground 新增组件没有配置属性值?
获取组件默认值在
packages\editor\src\services\props.ts
public async getPropsValue(type: string, { inputEvent, ...defaultValue }: Record<string, any> = {}) {
....
return {
id,
...defaultPropsValue,
...mergeWith({}, cloneDeep(this.state.propsValueMap[type] || {}), data),// this.state.propsValueMap 配置属性值 全部缓存在这里, 最组件名称 test-comp ; 将驼峰命名 testComp 使用 - 我靠!!
};
}
所以注册时全部统一用 ’-’ 命名!!
ui/index.ts 导出的组件名是 undefined?
ui 项目 index.ts 导出的组件名是 undefined? 编译出来的 entry 配置名称是 undefined ?
runtime 编译的配置 runtime\vue3\tmagic.config.ts cli 源码
packages\cli\src\utils\resolveAppPackages.ts
const getComponentPackageImports = function ({
............
if (propertyMatch) {
result.imports.push({
// type: property.key.name,// ui/index.ts 导出的ui key真按字符串定义, name 没值, 用 value 离谱!!
type: property.key.name? property.key.name: property.key.value,
name: propertyMatch.specifiers[0].local.name,
indexPath: getIndexPath(path.resolve(path.dirname(indexPath), propertyMatch.source.value)),
});
}
});
return result;
};改完, 需要重新编译一下 cli
runtime 编译错误 terser not found.?
[vite:terser] terser not found. Since Vite v3, terser has become an optional dependency. You need to install it.
npm install terser -S
pinia 开发环境正常, 打包后响应式失效?
Cannot read properties of undefined (reading ‘_s’) 明显打包编译 pinia 代码有问题 TODO
The inferred type of ‘useClick’ cannot be named without a reference to
error TS2742: The inferred type of ‘useClick’ cannot be named without a reference to ‘.pnpm/@types+lodash@4.17.4/node_modules/@types/lodash’. This is likely not portable. A type annotation is necessary.
项目归档
playground (编辑器)项目
Transclude of playground_20230630.zip
runtime + ui 项目
Transclude of sny-tmagic-components_20230630.zip
tmagic-editor + ui + runtime 更新20240521 改善数据源
Transclude of tmagic-editor_20240521.zip
组件开发
组件是tmagic-editor可配置页面元素的最小单位。我们都会从左面板的组件区中选中组件,加入到工作区的模拟器中,然后在右面板中对组件进行配置。一个组件的基本内容,会包含如下:
-
组件样式、逻辑代码(即开发者写的 vue, react 等代码)。
-
表单配置描述,tmagic-editor的定义是导出一个表单对象,这份配置仅在编辑器中使用。 表单配置是编辑器右面板展示的内容,配置项目都是由组件里的表单描述来决定的,用户可以在表单配置区域里通过配置项来改变组件的行为和样式。 注意,由于每个组件都需要有一些共同的表单配置项目,所以tmagic-editor通过在表单渲染器,统一为所有组件加上了通用的表单配置项目。包括基础组件样式配置、钩子事件配置等。
-
拓展描述,这部分内容目前还未有严格定义,但是我们保留这个扩展能力。
-
组件 type, 是组件的类型,这是用来告诉编辑器,我们要渲染的是什么组件。每个组件在开发时就应该确定这样一个唯一、不和其他组件冲突的组件 type。
https://tencent.github.io/tmagic-editor/docs/guide/component.html
组件规范
组件的基础形式,需要有四个文件 index 入口文件,引入下面几个文件
- formConfig 表单配置描述;
-
initValue 表单初始值
-
event 定义联动事件 具体可以参考(组件联动)[https://tencent.github.io/tmagic-editor/docs/guide/advanced/coupling.html#%E7%BB%84%E4%BB%B6%E8%81%94%E5%8A%A8]
-
component.{vue,jsx} 组件样式、逻辑代码
@tmagic/ui 中的 button/text 就是基础的组件示例。我们要求声明 index 入口,因为我们希望在后续的配套打包工具实现上,可以有一个统一规范入口。
为容器增加 flex 支持
复制自带的 container 主要就是修改
formConfig.ts
const onStyleChange = (formState: any, v: string, ctx: any) => {
let { model , prop } = ctx;
if (!model.style) return v;
model.style[prop] = v;
}
export default [
{
name: 'layout',
text: '容器布局',
type: 'select',
defaultValue: 'absolute',
options: [
{ value: 'absolute', text: '绝对定位' },
{ value: 'relative', text: '流式布局' },
],
onChange: (formState: any, v: string, { model }: any) => {
if (!model.style) return v;
if (v === 'relative') {
model.style.display = 'block';
model.style.height = 'auto';
} else {
const el = formState.stage?.renderer?.contentWindow.document.getElementById(model.id);
if (el) {
model.style.display = 'block';
model.style.height = el.getBoundingClientRect().height;
}
}
},
},
{
name: 'display',
text: 'display',
type: 'select',
defaultValue: 'block',
options: [
{ value: 'block', text: 'block' },
{ value: 'flex', text: 'flex' },
],
onChange: onStyleChange,
},
{
name: 'flex-direction',
text: 'direction',
type: 'select',
defaultValue: 'row',
options: [
{ value: 'row', text: 'row' },
{ value: 'row-reverse', text: 'row-reverse' },
{ value: 'column', text: 'column' },
{ value: 'column-reverse', text: 'column-reverse' },
],
onChange: onStyleChange,
},
{
name: 'flex-wrap',
text: 'wrap',
type: 'select',
defaultValue: 'nowrap',
options: [
{ value: 'nowrap', text: 'nowrap' },
{ value: 'wrap', text: 'wrap' },
],
onChange: onStyleChange,
},
{
name: 'align-content',
text: 'align-c',
type: 'select',
defaultValue: 'stretch',
options: [
{ value: 'stretch', text: 'stretch' },
{ value: 'flex-start', text: 'flex-start' },
{ value: 'flex-end', text: 'flex-end' },
{ value: 'center', text: 'center' },
{ value: 'space-between', text: 'space-between' },
{ value: 'space-around', text: 'space-around' },
],
onChange: onStyleChange,
},
{
name: 'align-items',
text: 'align-i',
type: 'select',
defaultValue: 'stretch',
options: [
{ value: 'stretch', text: 'stretch' },
{ value: 'flex-start', text: 'flex-start' },
{ value: 'flex-end', text: 'flex-end' },
{ value: 'center', text: 'center' },
{ value: 'baseline', text: 'baseline' },
],
onChange: onStyleChange,
},
{
name: 'justify-content',
text: 'justify-c',
type: 'select',
defaultValue: 'flex-start',
options: [
{ value: 'flex-start', text: 'flex-start' },
{ value: 'flex-end', text: 'flex-end' },
{ value: 'center', text: 'center' },
{ value: 'space-between', text: 'space-between' },
{ value: 'space-around', text: 'space-around' },
],
onChange: onStyleChange,
},
];npm run build:config && npm run build:value && npm run build:event 更新配置
组件自定义事件
可以通过声明组件中的 event 文件,在文件中描述当前组件可以配置的事件名,和可以被触发的动作。
event.js
export default {
events: [
{
label: '完成某事件',
value: 'yourComponent:finishSomething',
},
],
methods: [
{
label: '弹出 Toast',// 编辑器可以触发的动作 行为
value: 'toast',
},
],
};in short events组件主动触发的, methods组件被动接受的
触发事件和提供方法 index.vue
const node = app?.page?.getNode(props.config.id);//触发事件, 第二个参数必须是 node !!
const onClick = () => {
// app.emit 第一个参数为事件名,其余参数为你要传给接受事件组件的参数
app?.emit("yourComponent:finishSomething", node, /*可以传参给接收方*/);
};
defineExpose({
// 此处实现事件动作
// 实际触发时是调用vue实例上的方法,所以需要将改方法暴露到实例上
toast: (/*触发组件node*/, /*接收触发事件组件传进来的参数*/) => {
toast('测试 vue3')
}
});插件开发
插件开发和组件开发形式类似,但是插件开发不需要有组件的规范。在以 vue 为基础的 ui 和 runtime 中,插件其实就是一个 vue 插件。
我们只需要在插件中提供一个入口文件,其中包含 vue 的 install 方法即可。
javascript export default { install() {} } 在插件中开发者可以自由实现需要的业务逻辑。插件和组件一样,只需要在 units.js 中,加入导出的 units 对象里即可。
数据源
const app = new Core({
ua: window.navigator.userAgent,
});
const dataSourceManager = app?.dataSourceManager
ds = dataSourceManager.get("ds_b64c92b5");// 拿到对应的 DataSource
//通过 DataSource 的 setData 修改的数据, 会触发 change 事件
// ds.setData(data: Record<string, any>)
ds.setData({text:'测试修改'})