开发难点分析
1 )怎样实现组件
- 核心问题:编辑器 和 页面其实整个就是一系列元素构成的
- 这些元素的自然应该抽象成组件,这些组件的属性应该怎样设计
- 在不同的项目中怎样做到统一的使用
2 )跨项目使用
- 在不同的项目中怎样做到统一的使用
3 )组件的可扩展性
- 组件的可扩展性,虽然在需求中我们只要求了三种组件
- 但是最初的设计是否能够具有良好的可扩展性
4 )编辑器的整体状态
- 说完了组件作为个体的问题,现在把它融合到编辑器页面来讨论
- 编辑器做的功能其实就是对一系列组件增删改的操作
- 所以怎样设计编辑器的整体状态
5 )增加和删除
- 说完了组件作为个体的问题,现在把它融合到编辑器页面来讨论
- 编辑器做的功能其实就是对一系列组件增删改的操作
- 所以怎样设计编辑器的整体状态
6 )属性渲染成表单
- 组件有多种,它的属性也有多种
- 1 怎样将这些属性渲染成不同的表单组件(也有可能不仅仅是表单组件)
- 2 在表单组件中,属性作出修改以后,怎样实时的将值反射到组件中去
7 )实时的反馈
- 在编辑器中通过属性修改,如何同步到页面
8 )插件化
- 编辑器有很多的交互:拖动移动位置,拖动改变大小,快捷键,右键菜单,缩放,重做/回滚等等功能
- 它们都是在核心问题之外的交互,那么很自然,我们是否能将这些功能进行解耦?
核心技术架构
1 )项目拆分
基于上述分析,我们可以把项目拆分成不同的项目
1.1 前端-B端系统仓库
- 可以采用简单的SPA模型,基于 Vue 或 React
- 这里可以集成下面的编辑器项目,但是会成为一个巨石仓库
- 建议采用微前端的模式,集成下面的编辑器项目
1.2 前端-编辑器
- 用于实现复杂的编辑器项目
- 可以再上述B端中耦合,但推荐使用微前端拆分出来单独成一个项目
1.3 前端-组件库项目
- 复用于 B端,C端(包含移动端)
1.4 后端项目
- 基于 Restful API 提供接口服务
- 基于SSR 提供H5页面
2 )项目间的关系
3 ) 技术方案设计注意事项
- 技术方案设计,为的就是寻找一个方向,论证:可行性、扩展性、复杂度高低。
- 不要一直沉浸在细节里,要站在上帝的视角来看问题
4 ) 核心问题分析与设计
4.1 业务组件库实现方案
设计原则
- 业务组件库大多数都是展示型组件
- 其实就是把对应的 template 加上属性(大部分是 css 属性)展示出来
- 会有少量行为,比如点击跳转等
- 而且这些组件会在多个不同的端进行展示,所以业务组件库就是从简的原则
- 必须避免和编辑器编辑流程的耦合
组件命名
- 使用一个字母(X)加组件的名称:比如 XText 或 x-text
- 这个 X 可以是你的域名,公司名,部门名等有意义的名称
属性设计
- 属性其实可以分为两大类
- 伪代码
// 方案一,将 css 作为一个统一的对象传入 <XText css={{color: '#fff' ...}} text="nihao" /> // 内部实现比较简单 <p style={props.css}></p> // 方案二,将 所有属性全部平铺传入 <XText :text="nihao" :color="#fff" ... /> // 内部实现会复杂一点 const styles = stylePick(props) <p style={styles}></p>
- 方案一内部实现简单,但是保存的时候要多一层结构
- 并且更新数据的时候要知道是样式还是其他属性
- 方案二 内部实现稍微复杂一点,但是保存简单,更新数据不需要在做辨别
- 这些组件目前有一些共有的属性,称之为公共属性
- 提到公共属性我们就要注意代码重用的问题
- 点击跳转伪代码
// 比如 在 Xtext 和 XImage 中都点击跳转的功能,属于公共属性的行为 // 抽象出一些通用的函数,在组件中完成通用的功能 import useClick from 'useClick' useClick(props)
4.2 编辑器细节问题
4.2.1页面的组成
- 可能存在背景的设定:由图片或者纯色组成
- 关于页面的元素
- 由各种不同的元素(组件)组成。在这里面有属性的配置
- 一部分属性界定它的位置(position)
- 一部分属性界定它的展示(looks),比如宽高,内容等
- 由各种不同的元素(组件)组成。在这里面有属性的配置
4.2.2 由此,我们可以设计相关JSON数据模型
{
"page": {
"title": "作品1",
"props":{
"backgroundImage":"",
"backgroundPostion": "",
"backgroundMusic":"2.mp3", // 可能有的网页背景音乐
"backgroundMusicAutoStart": "true" // 背景音乐是否可以自动播放
//...
},
}
// 该采用对象还是数组?
"components":[
{
//指代对应的组件类型-可以和公共组件库的组件命名对应
"name": "text",
//要有特殊的ID,因为每个组件都是独特的,需要使用ID筛选组件
"id":"1",
"props": {
//位置属性
"left": "10px", "top": "10px",
//展示属性
"text": "hello world", "fontSize": "16px"
},
},
{
"name": "image",
"id": "2",
"props": {
"left": "5px","top": "20px",
"src": "cdn.url", "width": "100px", "height": "100px"
},
},
{
"name":"date", // 日期组件
"props": {
"left": "5px", "top": "20px",
"date": "now", "width": "100px", "height": "100px", "style":1
}
}
]
}
4.2.3 数据的流转
- 向画布添加组件或者删除组件(向 components 数组添加或者删除特定的组件)
- 更新组件的某个属性 (找到对应的 component,然后更新它的 props)
- 渲染画布或者作品(循环保存的作品信息,使用每个组件特定的属性进行渲染)
4.2.4 编辑器的设计
-
在实现工程中,我们完成了表单和数据的对应其实完成了一个高层次的抽象,用数据描述界面
-
对比阿里的项目 form-render 现在更名为 x-render, 或访问 xrender.fun 这个开源工具其实和我们的思路是一样的
-
编辑器页面主要有三个部分,为左中右结构,左侧为组件模版库,中间为画布,右侧是设置面板
- 左侧是预设各种组件模版并进行添加
- 中间是使用交互的手段更新元素的值
- 右侧是使用表单的手段更新元素的值
-
注意:和后端相关的暂不讨论 - 预览 发布 等
整体状态设计
- 不难看出我们的编辑器其实就是围绕着中间画布的元素来进行一系列操作
- 那么自然而然着是一系列的元素组成的
- 我们应把它抽象成一系列拥有特定数据结构的数组
- 伪代码
export interface GlobalDataProps { // 供中间编辑器渲染的数组 components: ComponentData[]; // 当前编辑的是哪个元素,uuid currentElement: string; // 当然最后保存的时候还有有一些项目信息,这里并没有写出,等做到的时候再补充 } interface ComponentData { // 这个元素的 属性,属性请详见下面 props: { [key: string]: any }; // id,uuid v4 生成 id: string; // 业务组件库名称 x-text,x-image 等等 name: string; }
场景设计
- 根据上面的数据结构我们可以针对不同的需求进行技术方案设计
- 将元素渲染到画布
- 使用 store 中 compoents 当中的数据,循环渲染输出组件即可
compoents.map(component => <component.name {...props} />)
- 使用 store 中 compoents 当中的数据,循环渲染输出组件即可
- 渲染左侧预设组件模版
- 原理和上面一样的,只不过数据是预设好的,这个可以写死在本地,也可以从服务器端取得。
- 他们和中间元素不一样的是,这些组件都有一个点击事件,我们可以添加一层 wrapper 来解决这个问题。
- 这样也可以和内部的 components 做到隔离,互不影响
compoents.map(component => <Wrapper><component.name {...props} /></Wrapper>)
- 添加和删除组件
- 非常简单的逻辑,向 store 中添加和删除组件即可。
// 添加 components.push({type: '', props: {} }) // 删除 components = components.filter((component) => component.id !== id)
- 非常简单的逻辑,向 store 中添加和删除组件即可。
- 将元素渲染到画布
编辑组件设计
-
将属性映射到表单
- 点击画布中的某个组件需要将该元素的属性以不同表单的形式展示到右侧
- 几个典型场景的实现,大家应该发现,其实没那么复杂
- 就是对全局状态中的 components 字段进行修改而已
- 现在我用一张更大的图,来描述应用的整个流程
- 通常方案, 将这些表单组件写死到页面中去 (不推荐)
{ text: '123' color: '#fff' } <input value={text}/> <color-picker value={color}/> ...
- 这样写非常简单,但是后期遇到的问题也会非常多,比如代码会非常冗余
- 对不同类型的业务组件都要做一大堆判断,可扩展性很差
- 对新的业务组件都要加一堆代码等等
- 抽象遍历方案
- 看到界面展示,应该想到另外一个纬度,界面UI 其实就是数据的抽象
- 所以我们自然想到的就是使用特定的数据结构将它渲染成界面
const textComponentProps = { text: 'hello', color: '#fff' } interface PropsToForm { component: String; } const propsMap = { text: { component: 'input' }, color: { component: 'color-picker' } } // 这里我们还是循环所有属性,在每个属性中渲染对应处理这个属性的组件 map(textComponentProps, (key, value) => { <propsMap[key].component value={value}/> })
- 当遇到没有类似的 Form 组件的时候,我们可以进行二次开发
- 只要这个组件有 value 的对应属性
- 这在一定程度上还满足了 可扩展性这个命题,组件的属性可以扩展
- 对于 color 这个属性,我们自己开发一个取色器或者二次封装一个取色器组件
- 只要传入 value 属性即可
-
更新表单将数据更新到属性
- 我们的数据流始终保持自上而下的顺序
- 也就是说表单更新最终要反射回到总体的 store 当中去
- 这个时候我们在对应的组件当中发射出一个事件,change,当 change 发生的时候
- 我们能够知道是哪个元素的哪个属性,以及新的值是什么,我们就用这些信息更新这个值
- 这样 store 完成更新,元素的 props 发生更新,那么整个数据流动就完成了
map(textComponentProps, (key, value) => { const handleChange = (propKey, newValue, id) => { const updatedComponent = store.components.find(component.id === id) updatedComponent.props[propKey] = newValue } <propsMap[key] value={value} @change={handleChange}> }
-
更新表单将数据更新到属性
-
我们的数据流始终保持自上而下的顺序
-
也就是说表单更新最终要反射回到总体的 store 当中去
-
这个时候我们在对应的组件当中发射出一个事件,change
-
当 change 发生的时候,我们能够知道是哪个元素的哪个属性
-
以及新的值是什么,我们就用这些信息更新这个值
-
这样 store 完成更新,元素的 props 发生更新,那么整个数据流动就完成了
map(textComponentProps, (key, value) => { const handleChange = (propKey, newValue, id) => { const updatedComponent = store.components.find(component.id === id) updatedComponent.props[propKey] = newValue } <propsMap[key] value={value} @change={handleChange}> }
-
除了表单的更新,还要说一下画布中的交互更新
-
其实画布中的更新也是采用发射事件的方式对store 的某些值进行更新
-
比如说拖动改变位置,最终拖动的过程中也是触发对应的change 事件去用相同的逻辑对值进行更新
-
这里也要注意,我们需要在业务组件外层,添加一个Wrapper
-
各种事件都是放在这个 Wrapper 上面的,比如支持拖动,改变位置后发送 change 事件
-
对于复杂组件也是如此,不管你内部的逻辑有多复杂,添加上传图片,删除,编辑
-
最终发送出来的事件里面的值,就是对这个 pictures 的值的变换
-
比如多加了一张照片,那就是数组中的值变成了三项
-
-
业务组件可扩展性
- 属性映射到表单:更复杂的业务组件也只不过是对应各种新的属性而已
- 可以用现有的 form 对应组件或者是自研或者二次包装的组件对其进行处理
// 假如有复杂组件 - 比如说是幻灯片 { pictures: ['1.jpg', '2.jpg'] } { pictures: { component: 'pics-processer' } } // 我们自己开发一个 图片处理的 组件即可,传入 value ,这个数组,他应该会渲染出一系列的图片显示
- 表单修改后更新
- 不管你内部有的逻辑有多复杂,添加上传图片,删除,编辑,最终发送出来的事件里面的值
- 就是对这个 pictures 的值的变换,比如多加了一张照片,那就是数组中的值变成了三项
-
画布插件化
- 比如快捷健,他只写成普通的可重用的函数即可,提供回调即可
- 在回调中,我们可以对全局 store 进行一系列的改写
- 而快捷键这个功能和编辑器是没有任何关系的
// 伪代码 useHotKey() useContextMenu() ...