数据流
前言
日常开发中会有一些紧急的活动页面的需求或者需求变更频繁,需要进行快速开发和响应。之前有了解一个开源项目 luban-h5,年前也给该开源项目提过多次 pr(主要技术栈是 Vue JSX)。
最近公司有这样一个可视化搭建平台的业务需求就准备用它了,落地过程中发现一些问题(功能点待开发阶段如: 接口配置、表单数据提交、拖拽时候体验不好等)。所以我就 fork 了一份代码在源码上直接改,实现一套适合自己需求的搭建平台。经过(68次 commit 共 5587 行代码) 目前完成了第一版并上线。可以一起来看下基本的功能。
功能演示 bilibili视频
下面演示的代码,大多可以在luban-h5 源码中找到。
页面数据结构
{
// work 级别数据
// new Work() 创建一个 h5 页面
id: 1,
page_mode:"h5_long_page",
title:"标题",
is_publish:true,
pages:[
// 页面级数据...
// 如果是长页面模式只有一项
{
uuid: 234123123,
//
elements:[
{
// 组件级数据...
commonStyle: {
top:100,
left:100,
// 组件的 css
},
name: "lbp-background", // 组件名(悬浮框展示的可使用的组件)
pluginProps: { // 组件接受的 props
// ...
},
methodList: [] // 组件的执行的事件,例如跳转可以添加到该数组中
// ...
}
]
}
]
}
查看了 JSON Scheme 的定义,可以看到一个页面组成的最小颗粒度是 element, 多个 element 组成一个 page 对象,如果是多页面模式由多个 page 组成。
如何渲染
假设现在已经有如上这样一个数据对象,如何将这个对象转化成页面。需要用到 Vue JSX 的能力。查看 core/editor/canvas/preview.js 文件
renderPreview() {
const elements = this.elements || []
const pageWrapperStyle = { height: this.height + 'px' || '100%', position: 'relative' }
// 添加 component 标记
return (
<div component={true} style={pageWrapperStyle}>
{
elements.map((element, index) => {
return <node-wrapper element={element} height={(this.height)}>
{
this.$createElement(element.uuid, {
...element.getPreviewData({ isNodeWrapper: false }),
nativeOn: this.genEventHandlers(element)
})
}
</node-wrapper>
})
}
</div>
)
}
我们暂时不需要追究这些方法和功能是如何实现的,通过这段代码来了解大致的工作流程。读懂这段代码需要知道一些渲染函数的语法。
在对渲染函数语法有一定了解后,这里的 this.$createElement 就是渲染函数,一般用 h 表示。
- 接收的第一个参数是组件名、标签名,说明 element.uuid 就是组件的名称。
- 接收第二个参数是对象包含了对组件或标签的细致描述,如:
class、style、props等。- 说明 element.getPreveiewData() 方法 返回的是一个对象,包含了对元素的描述。
- nativeOn 仅用于组件,我们这里使用
h渲染的都是组件,监听组件原生事件例如 click 事件。所以this.genEventHandlers(element)返回的是对原生事件的监听。
通过这个流程的梳理,知道了如何将一个 page 对象包含的 elements 渲染出来。 elements 就是我们添加的一个个组件,我们通过拖拉拽的方式改变每个组件的样式,然后把这些组件数据保存在 page 中,在渲染的时候根据这些数据还原出页面。
继续看下 node-wrapper 组件是啥样的
this.element.getStyle()返回一个style对象,决定了组件的 width、height、left、top 等等样式。this.$slots.default就是组件默认的 slot ,也就是我们上面的代码了。
render(h) {
// ...
return (
<div style={ this.element.getStyle({ position, height: this.height })}>
{this.$slots.default}
</div>
)
}
Element Model
export default class Element {
// 接收一个 Element 组件作为参数
constructor(ele) {
this.name = ele.name
this.pluginType = ele.name
this.uuid = this.getUUID(ele)
this.title = ele.title // 组件展示用的中文名称
// 初始化数据...
}
getPreviewData({ position = 'static', isRem = false, mode = 'preview', isNodeWrapper = true } = {}) {
const data = {
// 与 edit 组件保持样式一致
style: this.getStyle({ position, isNodeWrapper }),
props: this.getProps({ mode }),
attrs: this.getAttrs(),
nativeOn: this.getEventHandlers()
}
return data
}
getStyle() {
const style = {
left: parsePx(pluginProps.left || commonStyle.left, isRem),
width,
// 判断组件是否使用默认高度, 如果使用默认高度,是否已经获取到组件原本高度,如果没有则使用 auto
height: isAutoSettingHeightElement(this.name) && !isSettingHeight ? 'auto' : pluginProps.height || commonStyle.height + commonStyle.heightUnit,
fontSize: parsePx(pluginProps.fontSize || commonStyle.fontSize, isRem),
...boxModel,
}
return style
}
getProps({ mode = 'edit' } = {}) {
const pluginProps = mode === 'preview' ? bindData(this.pluginProps, window) : this.pluginProps
return {
...pluginProps,
disabled: disabledPluginsForEditMode.includes(this.name) && mode === 'edit'
}
}
通过一个 Element 数据结构来描述一个组件。并且提供一些方法,将 Element 的数据整合出来提供给其他地方使用。可以将 Element 看作数据存储地,每个组件就是通过这些存储的数据进行渲染。
页面创建流程:
点击新建页面 => new Work(work) 构建 work 层数据 => new Page(page) 构建 page 层数据 => new Element(element) 构建 element 数据。
编辑组件
右侧面板如何控制编辑区域的组件渲染?首先我们要选中一个组件才会可进行属性的修改,当我们选中组件就会将该组件设置为 editingElement
// propConfig 取自 const vm = getVM(editingElement.name) vm.$options.props
// propKey 就是 props 的字段名
renderPropFormItem(h, { propKey, propConfig }) {
const editingElement = this.editingElement
const item = propConfig.editor
const data = {
// style: { width: '100%' },
props: {
...item.props || {},
style: {
// ...
},
on: {
// https://vuejs.org/v2/guide/render-function.html#v-model
[item.type === 'tinymce-editor' ? 'input' : 'change']: function(e) {
if (isInputNumber) {
typeof e === 'number' && onElementPluginChange(e || 0, propKey)
} else {
onElementPluginChange(e, propKey)
}
}
}
}
}
return (
<div style={{ display: 'flex', justifyContent: 'center', width: '100%', ...item.style }}>
{/* extra: 操作补充说明 */}
{ item.extra && <div slot='extra'>{typeof item.extra === 'function' ? item.extra(h) : item.extra}</div>}
{/* 根据组件是否存在 label 来判断是否显示 */}
{
item.label ? (
<a-popover placement='topLeft'>
<template slot='content'>
{item.label}
</template>
{ h(item.type, data) }
</a-popover>
) : h(item.type, data)
}
</div>
)
}
代码在core/editor/right-panel/props.js 我们要渲染该组件的属性控制器。renderPropFormItem() 函数返回一个 h(item.type,data)来渲染出控制器组件。关注一下 propConfig 属性的值,它是组件中定义的 props,平时我们定义 props 一般定义 default 和 type 这两个属性。由于我们根据 props 来渲染一个控制器,所以我们需要定义更多的属性。
规范 props 定义
plugin-common-props.js 文件中规范不同数据类型 props 定义以及其返回的数据结构。增加 editor 字段用于描述控制器组件。
控制器如何做到修改属性值
h(item.type,data) 这行代码是渲染出控制器,当我们改变控制器的值时候会触发事件,也就是 data 对象中的 on 事件,执行 onElementPluginChange(e, propKey) 修改 editingElement.pluginsProps 对应 prop 的值。
const onElementPluginChange = (e, key) => {
const value = e.target ? e.target.value : e
// 设置元素的 pluginProps 值
this.setElementPluginProps({ [key]: value })
}
// core/store/modules/element.js
setElementPluginProps(state, payload) {
if (state.editingElement) {
state.editingElement.pluginProps = {
...state.editingElement.pluginProps,
...payload
}
}
},
继续来梳理下数据流程
- Element.pluginProps 默认包含组件的 props 的默认值的一个对象。例如 pluginProps 默认值为
{text:'按钮',color:'red'}。 - 当我们修改控制器的值时候触发事件
onElementPluginChange('text','修改后的值')当前组件的 pluginProps 的值被修改为{text:'修改后的值',color:'red'}。 - 回到之前组件的渲染,通过 element.getPreviewData() 获取组件渲染需要的数据,由于我们修改 text 的值,那么 props 值发生改变,所以会触发组件的重新渲染。达到控制器修改,组件样式跟着变化的效果。
this.$createElement(element.uuid, {
...element.getPreviewData({ isNodeWrapper: false }),
nativeOn: this.genEventHandlers(element)
})
结尾
梳理了一个可视化搭建的数据结构,有助于更好的理解和扩展页面功能。如果你对文章敢兴趣后续更新如何实现 form 表单的数据提交、拖拽过程辅助性的生成等。