20210103 更新 之前写的比较草率,只是为了实现而实现,没有进行更深层次的思考。所以打算对文章进行一个整体的重写,代码完善一下。
背景 最近因为临近年关,公司内好多活动页、落地页这种简单的页面要写。每次写都是在做重复的工作。
作为一个一直以优化掉自己为目标的前端切图仔,所以打算搞个可以让产品、UI 都能用明白的工具,来实现一个可视化拖拽平台,来优化掉自己。
最终虽然在公司落地失败了,但是自己也积累了一些小经验,所以打算记录一下自己实现的过程。
什么是低代码平台? 本来我理解的低代码平台就是编辑器和渲染引擎的一个组合,后来随着我在查看其他平台的时候,发现我自己想的太简单了,编辑器和渲染引擎只能说是低代码平台的一部分内容,那低代码平台应该具备哪些能力呢?
核心能力 开发模式的转变
低代码的「家人们」 low code
预置组件实现全集功能
可视化配置降低开发门槛
支持定制&扩展
no code
pro code
原理
页面管理 配置页面 物料堆(组件库) 由元组件和布局组件组成,元组件具有以下特点:
布局组件特点:虚拟概念,不渲染自己,只渲染传进来的元组件。
因为元组件具有功能结构单一的特点,所以在处理复合布局/逻辑时需要在外加一层容器(布局组件)形成复合组件,然后进行统一处理(遇事不决加一层)。
多个复合组件构成就可以组一个页面。
画布 目前主流画布实现方案有两种:画布渲染一体化 和 多态画布(配置画布+渲染引擎)
多态画布特点:状态分离,效率高,可以两个组来维护
一体化特点:复杂度高(先转中间语言,再转页面),逻辑集成度高
配置面板(配置项) 配置面板和组件类型关系是:1 对多的(比如 按钮 和 图标的配置是不同的),组件类型对组件配置的关系也是:1 对多的(可以看一下宜搭)。组件类型是 class,组件配置就是实例。
顶栏(全局、页面配置)
输出页面 根据配置页面中输出的页面原数据,进行渲染还原页面。
页面元数据(JSON)->渲染引擎->组装协议。
渲染引擎工作内容
开发目标 物料堆抽象 - 需要后续组件完成时,直接丢入物料堆可以被加载,无需后续调整物料堆代码
解析 JSON - 响应加载物料堆中的组件进行渲染
点击渲染好的页面某一块,点击组件树可以拿到对应的组件,并且能拿到挂载在上面的 config
物料堆 为了实现方便,所以我们引入个组件库,我这里用的arco-design
。
因为很多时候,我们在搞组件库的时候,并不都是自己开发的,大部分都是有基础建设组来搞的组件,所以我们要尽可能不去修改最内层的东西,那我们就需要在组件外面加一层
按照这个再搞个Container和Input,Container的话就直接自己写一个吧,因为需要在这层处理好多东西,比如width
,height
等等等,选中外边框之类的。
// parser-button.tsx import React from 'react'; import CButton from './cButton'; // 假装CButton是自己写的, // 这里进行一些自己的逻辑封装 const ParserButton: React.FC = (props) => { return <CButton>{props.children}</CButton>; }; export default ParserButton;
需要处理的逻辑就都放在 parserButton 里,这里当然只是最简单的一种。
物料堆其实就是一堆组件的数组,然后遍历渲染,所以我们把有的组件都放到一个数组里去
目前每次写个组件都得在这个文件里引入,比较蛋疼,也不知道有什么好的方法自动生成这种东西。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // components/index.ts import CButton from './cButton'; import Container from './Container'; import CInput from './cInput'; import ParserButton from './parser-button'; import ParserInput from './parser-input'; import ParserContainer from './parser-container'; // 物料堆里用到的基础组件 export const componentList = [ { name: 'CButton', Component: CButton }, { name: 'CInput', Component: CInput }, { name: 'Container', Component: Container }, ]; // 渲染引擎里用到的解析器 export const parserList = { CButton: ParserButton, CInput: ParserInput, Container: ParserContainer, };
然后对componentList
进行遍历渲染就可以了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // fragments/componentStack.tsx import React, { useContext } from 'react'; import { componentList } from '../components'; const ComponentStack: React.FC = () => { return ( <> <div className="component-stack">物料堆</div> <ul> {componentList.map((info) => ( <li className="border-1 border-gray-900 my-[2px] mx-[5px] px-0 py-[10px]" key={info.name} > <info.Component /> </li> ))} </ul> </> ); }; export default ComponentStack;
我们在使用的时候应该是能拖拽的,所以再给每个li
加上draggable
和onDrag
事件,并用 context 记录一下自己 drag 的东西是什么。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // fragments/componentStack.tsx import React, { useContext } from 'react'; import { componentList } from '../components'; import { EditorContext } from '../context/editorContext'; const { updateSelectType } = useContext(EditorContext); const onHandleDrag = (item: Record<string, any>) => { updateSelectType(item.name); }; <li draggable onDrag={() => { onHandleDrag(info); }} className="border-1 border-gray-900 my-[2px] mx-[5px] px-0 py-[10px]" key={info.name} > <info.Component /> </li>;
要美化的话,可以给 componentList 加个 type 区分一些组件类别等等等操作。
代码可见componentStack
画布区 因为是自己的练手项目,就直接将画布和渲染引擎放在一起了,拖动东西到画布,解析成 json,传送到渲染引擎里进行渲染。
首先我们要明确画布区要实现什么内容
接收拖拽来的组件
组装 json
渲染页面
接收拖拽来的组件 这个就直接在渲染引擎套一个drop
事件就可以。然后读取一下context
保存的组件类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // page/mainPage.tsx const onHandleDropContainer = (e: React.DragEvent<HTMLDivElement>) => { // 将拖拽的组件加到树中 dispatch(addNodeIntoRoot(selectType)); }; // 这个一定要有,dragOver的时候阻止事件传播不然的话不会触发onDrop事件。 const onHandleDragOver = (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault(); }; <div className="stage h-[90vh] border-1 border-gray-500 overflow-scroll" onDrop={onHandleDropContainer} onDragOver={onHandleDragOver} > <RenderEngine /> </div>
渲染页面 渲染之前,我们得先知道我们要组装成什么样。
为什么先写渲染json呢,只是为了方便调试,先界定好输入和输出,然后组装的json按照输入来组装就可以了。
想一下 react 的 fiber 节点。搞成类型 fiber 节点那种形式
{ "fiberRoot" : { "children" : [{ "type" : "div" , "children" : ["你好" ], "props" : {} }] } }
或者直接借鉴一下 宜搭
的 json 格式,只要能描述出页面就可以,以下是我的结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { "page" : { "type" : "Container" , "children" : [ { "type" : "Container" , "children" : [ { "type" : "CButton" }, { "type" : "CInput" } ] } ] } }
然后只要把这个json树能在画布区域渲染成实际的组件就可以了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 // fragments/renderEngine.tsx // 从顶向下渲染,先渲染根节点,并把根节点下的内容传给组件渲染器 const renderRoot = (scheme: Record<string, any>) => { // 全局配置可以在这操作 const page = scheme.page; return <div className="root">{renderComponents(page)}</div>; }; // 渲染组件 const renderComponents = (section: Record<string, any>) => { // 取出children let children = null; // 判断当前组件是否有子节点,如果有子节点,先渲染子节点。 if (section.children) { // 将子节点渲染出来 children = renderChildren(section.children); } // 渲染当前层级。 return startRender(section, children); }; const renderChildren = (section: Record<string, any>) => { // 将子节点们做成数组 let nodeArray = section.children || ([] as any).concat(section); // 遍历再渲染,相当于递归遍历了,最终做出来子节点及其子节点。 return nodeArray.map((node: any, idx: number) => { return renderComponents(node); }); }; // 解析当前组件的json const startRender = ( section: Record<string, any>, children?: ReactNode | null, ) => { // TODOS: 类型需要完善一下暂时先都用any 这么处理一下吧。 // 取出要用的解析器类型 const type = section.type as 'CButton' | 'CInput' | 'Container'; const { componentId } = section; const RenderMod = parserList[type]; // 直接渲染 if (RenderMod) { return ( // 这里面就是上面咱们写的parserButton 这种东西,传jsonScheme下去主要是因为里面可以放一些props。 <RenderMod jsonScheme={section} key={componentId}> {children} </RenderMod> ); } return null; };
将我搞的这个树的结构传到renderRoot
里,就可以在页面上显示内容了。 代码可见renderEngine
组装json 组装json要分情况讨论,一种是直接拖拽到画布上,一种是拖拽到container上去
直接拖拽到画布上的话,其实就是给page.children不断的push东西。
为了偷懒不配置immer,直接安装了个redux/toolkit
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // redux/reducers/editSlice.ts 的addNodeIntoRoot方法。 addNodeIntoRoot: (state, action) => { const { JSONScheme } = state; const _page = JSONScheme.page; // 如果page上没有type,初始化page if (!_page.type) { // 生成个唯一id const rootContainerId = genCompId(); _page.type = 'Container'; _page.componentId = rootContainerId; _page.children = []; } // 如果page的type不是container,给page下加个container,并把之前的放到children里。 if (_page.type !== 'Container') { const oldPage = JSON.parse(JSON.stringify(_page)); const rootContainerId = genCompId(); _page.type = 'Container'; _page.componentId = rootContainerId; _page.children = [oldPage]; } // 获取要添加的type const addType = action.payload; // 生成唯一ID const componentId = genCompId(); // 放到page的children下 _page.children.push({ type: addType, componentId }); state.JSONScheme.page = _page; },
如果拖拽到container上的话。咱们需要先对container进行处理一下。
让container能接收到drop的东西,把这两个放到container上去
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { // 不能让他冒泡。 e.stopPropagation(); // 当前的componentId const { componentId } = jsonScheme; dispatch( addNodeIntoContainer({ addType: addNode, targetComponentId: componentId, }), ); }; const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault(); };
然后再来写addNodeIntoContainer
:主要思路就是判断targetComponentId
是不是container
,只有是container
才可以加。
addNodeIntoContainer: (state, action ) => { const { targetComponentId, addType } = action.payload; const { JSONScheme } = state; const _page = JSONScheme.page; const targetNode = findCompById(_page, targetComponentId); if (targetNode && targetNode.type === 'Container' ) { if (!targetNode.children) { targetNode.children = []; } targetNode.children.push({ type : addType }); } },
完整代码可见reducers/editSlice
配置面板区 要实现的内容:
选中不同类型的组件,显示不同的面板
配置面板更改组件属性,画布区属性跟着变。
配置面板可以按照在做组件parser层那样搞出来一个,然后多个面板凑在一起组成一个对象数组,根据选中的组件来渲染不同的面板就可以了。
那如何取到选中的组件呢?
我们可以在parser加一个onClick事件,选中的时候就在context里更新selectedType就可以了。
那如何更新属性呢?
在jsonScheme里加入props字段,往里透传就可以了,最后结构一下。在选中组件的时候,根据选中的组件id来更新type即可
changeNodeProps:(state,action )=> { const { targetComponentId, propKey,propValue } = action.payload; const { JSONScheme } = state; const _page = JSONScheme.page; const targetNode = findCompById(_page, targetComponentId); if (targetNode){ if (!targetNode.props){ targetNode.props= {}; } targetNode.props[propKey] = propValue; } }
总结 目前来说,一个简易的可视化拖动页面其实是已经实现了。目前可以做的优化的点:
渲染面板的再抽象,抽象成类似于渲染引擎那种,只需要根据不同的组件传进去不同的json,然后生成不同的面板就可以了。
拖出来的组件如何实现通信?(还没想出来要怎么解决,感觉上是可以通过eventBus来实现)。
老内容 editor 一般分为四块内容
操作栏:放一些 撤销,保存等操作的地方 组件栏:放一些自己封装好的组件,渲染出这个列表,需要编辑页面时从这往外拖 画布:存放拖拽的东西,要等于最终渲染出来的结果 属性栏:用来设置选中组件的属性,如文字颜色,大小,背景,宽高等。 render 渲染画布这部分的内容
怎么搞? DSL,可以简单粗暴的理解为一个大 json。
我们可以用一个 json 来描述一个组件,然后多个 json 拼到一起就是整个页面。
比如一个 button
{ comp : Button, key : 'my-btn' , label : '按钮' , icon : '' , desc : '这是一个按钮' , category : 'form' , attrs : { size : 'normal' , value : '确定' }, style : { width : 80 , height : 36 } }
用 key 来作为这个组件的唯一标识,然后其他的作为props
传到comp
内。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import React from 'react' ; interface IProps { style : React.CSSProperties; label: string; }const Button: React.FC<IProps> = (props ) => { const { style, label } = props; return ( <button className ="" style ={{ ...style }}> {label} </button > ); }; Button.defaultProps = { style : {}, label : '按钮' , };export default Button;import Button from './index' ;export const option = { c : Button, key : 'my-btn' , label : '按钮' , icon : '' , desc : '这是一个按钮' , category : 'form' , attrs : { size : 'normal' , value : '确定' , }, style : { width : 80 , height : 36 , }, };
然后画布和 render 就按照 key 找到组件,然后按传参渲染就可以了。
怎么拖动? 因为是内部使用,也不用考虑什么兼容性,直接 dragEvent 一把梭。
难道要每个组件都写上吗?
作为我这种偷懒前端工程师怎么可能会这么搞呢。有两种方案可以实现
hook,将拖动这类都封装成 hook,然后引入进去就完事了。但是这样还是要重复写好多次。所以看第二种 利用事件的冒泡机制,在这个列表外面加一层,遇事不决加一层,接收同理,在画布组件外面加一层接收装置 const handleDragStart = (e: React.DragEvent<HTMLDivElement> ) => { if (!e) return ; const id = (e.target as HTMLDivElement).dataset.id as string; console .log('drag start ... ' , e); e.dataTransfer.setData('id' , id); }; <div onDragStart ={handleDragStart} > {componentList.map((comp) => ( <section draggable ={true} className ="item" data-id ={comp.key} key ={comp.key} > <span > {comp.label}</span > </section > ))} </div >
怎么画? 全局搞个 store,每次拖动进去时就给这个描述这个组件的 json 扔进去,然后画布遍历这个 store 进行渲染就可以了。
const MainCanvas: React.FC = () => { const { comps } = useContext(CanvasContext); return ( <> {comps.map((Comp: any) => { return <Comp.c /> ; })} </> ); };
大致逻辑就这样。目前我也刚写到这里哈哈哈哈。到这里的代码简易可视化生成页面
另外在拖动的时候看其他的应该会有个瞄准线,接近的其他元素的时候会出现,还有自动吸附等。
瞄准线的话有个思路:
给每个元素拖拽到画布上时都标记为同一种 class,然后拖拽时就知道了当前在被拖动的,还有画布中的其他元素。 遍历其他元素 计算与当前拖动元素的距离,当达到一定阈值时就显示标准线。 标准线的话就用 div,在画布上预先占位,等到需要显示的时,根据当前拖拽的元素的位置设置这个标准线的位置,并设置显示。 思考 我司主要是虽然是 TODO 类型的软件,但是在输入文本时也是支持 markdown 以及一些特别操作的。比如插入附件,插入标签等等等。因为项目是由 jq 时代直接迁移到 React 项目的,所以编辑栏还是之前的 jq 直接操作 DOM 节点这么搞的,耦合严重且 markdown 部分处理的东西已经非常难以维护了。现在对于内容的保存方式如下
{ content :"## 111111\n::123456::\n*1122213123123*\n\n[111](www.baidu.com)\n " , tag :["tag1" ,"tag2" ], attachment :["1" ,"2" ] }
那是不是可以转换成 DSL 这种结构,如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 { content : [ { type : 'h1' , content : '11111' , }, { type : 'hightLight' , content : '123456' }, { type : 'url' , data : { name : '111' , target : 'www.baidu.com' , }, }, { type : 'tag' , tagId : ['tag1' , 'tag2' ], }, { type : 'attachment' , data : [ { name : '1' , url : '11111' , }, { name : '1' , url : '11111' , }, ], }, ]; }
然后搞一种 dsl2Markdown 的中间插件。按需往里面渲染组件这种思路,扩展的话每次只需要扩展 type 和对应的组件即可。这样后续扩展新的语法也很方便。
以上 dsl 好像不太对。「可视化搭建系统」——从设计到架构,探索前端的领域和意义 今天上班摸鱼看到了这篇文章,大概意思就是本地输入 markdown 语法,解析成 AST,然后根据 ast 渲染成 HTML(富文本节点或卡片类型),然后服务器存 HTML 就好了。因为我司的是多端的,所以存入 HTML 会导致其他端有问题,所以存入最原始的 markdown 语法,然后每个端自己去解析 AST->渲染对应组件应该是我司优化的最优解。我司主要是用codemirrior
这个东西来做的 markdown 和自定义组件,原理都差不多,也是先解析 ast,然后根据对应类型使用createElement
创建 html 标签,然后 append 插进去。部分复杂的就是用react.createElement
来创建。看起来优化空间还是有的。