b站视频演示~前端
github代码git
年前年后比较闲,因而用React作了一个简单的lowcode平台,功能如上面动图所示。接下来按照完成功能点介绍下,主要包括:程序员
lowcode平台挺常见的,目前网上作的比较成熟且通用的有兔展、易企秀、码卡、图司机等,可是为了个性化的设置,好比要访问本公司的数据库,不少公司也都有本身的lowcode平台,好比阿里、百度、腾讯等等。lowcode平台其实就是经过拖拽或者点击预约义好的组件来生成页面,比较多的应用场景就是产品经理或者运维或者销售来自定义生成活动页,这样的操做简直不要太好,由于不再须要一堆产品经理、交互、设计以及程序员开会才能完成一个活动页了,这期间包括设置下架、过时时间这些也不须要程序员插手。github
若是你尚未作过这样的项目,那接下来,咱们就来捋一下应该怎么样才能作一个这样的项目。数据库
这个项目能够分红两部分,一部分是编辑器,一部分是生成器。编辑器用于生成页面,其实就是生成一个包含页面全部组件信息的对象值,以下:redux
而后能够把这个对象转成字符串存入数据库,对应一个id。canvas
而生成器要作的事情就是根据id解析对应的字符串,而后把解析出来的对象渲染成组件呈现出来,就是咱们以前在编辑器上建立的页面了。数组
从上一个图看出来,canvas数据里有一个cmps数组,这个数组是全部的组件。markdown
每一个组件都有一个随机生成的onlyKey做为惟一标识,能够用于删除、查找、更新等。数据结构
desc与type则标识了组件类型,前者是汉字描述,能够用于页面展现,后者主要判断组件类型。
value在不一样组件里定义不一样,如文本组件或者按钮里表示显示的文本,图片组件里则用于记录图片地址。
style记录了组件的样式style
首先,咱们先来看下编辑器的布局,这里能够分红四个大模块:
这个时候代码以下:
export default function App() {
return (
<div id="app" className={styles.main}> {/* 模块1:组件选择区 */} <Cmps /> {/* 模块2和模块4:画布模块和画布操做模块 */} <Content /> {/* 模块3:画布属性操做模块 */} <Edit /> </div>
);
}
复制代码
(关于状态管理,如下前部分是个人选择过程,不想看的能够直接跳到最后一段看我最终选择的方案。)
知道了咱们要搭建一个怎么样的编辑器以后,接下来咱们须要考虑一件重要的事情,就是画布数据放哪儿?首先要知道画布数据变动的时候,相关的组件也要更新,也就是模块234都要接到变动通知,这就是所谓的状态管理了。
关于这个状态管理我考虑过了如下几种方案:
同时,因为模块1234以及他们的子组件都要使用画布数据,这个时候就要考虑跨层级数据的传递了,固然这个可使用Context,方案12均可以使用Context。
当我使用方案1的时候,由于画布数据太大了,再加上不少操做画布数据的增删改查函数,最后App组件就很臃肿,感受View和Data层都黏在一块儿了,添加增删改查函数的时候很是费劲,很差很差。放弃方案1。
既然方案1的View和Data太黏,那我换用redux做为第三方来管理画布数据,起初小操做很好,可是由于涉及到组件数据里style、value等的修改,嵌套层级有点深,不少增删改查函数须要复用,可是使用reducer的话,不少修改逻辑要写在组件里,可是我不想组件过于臃肿,拆成工具函数又和View、Data分离了,很差维护。
因此,最终我想要的实际上是一个数据仓库来存储个人画布数据,而且这个仓库里还要提供不少增删改查的功能函数。
最后, 我选择了第三种方案,本身定义一个Canvas类来管理个人画布数据。
在这个类里我定义了如下几个数据:
存储全部的画布数据,即这些:
而后再提供一些功能函数,如getCanvas能够获取this.canvas数据,主要用于最后提交发布更新到数据库中,还有updateCanvas用于更新画布数据,还有emptyCanvas用于清空画布数据,还有updateCanvasStyle用于更新画布的style样式。
监听函数。即若是this.canvas发生了改变,该作什么,其实这里就是this.canvas发生了改变,就更新整个App就好了。那这个时候只须要在App组件中加个订阅就好了:
export default function App() {
const forceUpdate = useForceUpdate();
// 全部组件
const globalCanvas = useCanvas();
useLayoutEffect(() => {
const unsubscribe = globalCanvas.subscribe(() => {
forceUpdate();
});
return () => {
unsubscribe();
};
}, [globalCanvas, forceUpdate]);
return (
<div id="app" className={styles.main}> <CanvasContext.Provider value={globalCanvas}> <Cmps /> <Content /> <Edit /> </CanvasContext.Provider> </div>
);
}
复制代码
Canvas类中的订阅函数以下:
subscribe = (listener) => {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((lis) => lis !== listener);
};
};
复制代码
啰嗦一句,尽管我之前也老强调,订阅与取消订阅必定要成对出现。
固然,若是你了解redux和Antd4 Form原理,会发现这里逻辑和他们很是类似。
记录当前选中的组件。全部组件数据都存在this.canvas中,可是因为层级比较深,取值查找的话比较麻烦,因此每次更新选中组件的时候,更新thia.canvas的时候,也同步更新这个值就好了。
前者记录画布修改历史,用于顶部模块2里的撤销与重作用的。后者则是记录当前处于哪一个修改历史中。每次画布更新都要记录当前的画布数据,如更新组件、清空画布等,记录画布数据修改历史的函数以下:
recordCanvasChangeHistory = () => {
this.canvasChangeHistory.push(this.canvas);
this.canvasIndex = this.canvasChangeHistory.length - 1; //2;
};
复制代码
// 返回画布数据的增删改查函数
getCanvas = () => {
const returnFuncs = [
"getCanvasData",
"recordCanvasChangeHistory",
"goPrevCanvasHistory",
"goNextCanvasHistory",
"updateCanvas",
"emptyCanvas",
"getCanvasStyle",
"updateCanvasStyle",
"registerStoreChangeCmps",
"registerCmpsEntity",
"getCmp",
"getCmps",
"setCmps",
"addCmp",
"getSelectedCmp",
"setSelectedCmp",
"updateSelectedCmpStyle",
"updateSelectedCmpValue",
"deleteSelectedCmp",
"changeCmpIndex",
"subscribe",
];
const obj = {};
returnFuncs.forEach((func) => {
obj[func] = this[func];
});
return obj;
};
复制代码
Canvas类已经建立完成,接下来是须要实例化这个类,而后经过Context传递下去。
export default function App() {
const forceUpdate = useForceUpdate();
// 全部组件
const globalCanvas = useCanvas();
useLayoutEffect(() => {
const unsubscribe = globalCanvas.subscribe(() => {
forceUpdate();
});
return () => {
unsubscribe();
};
}, [globalCanvas, forceUpdate]);
return (
<div id="app" className={styles.main}> <CanvasContext.Provider value={globalCanvas}> <Cmps /> <Content /> <Edit /> </CanvasContext.Provider> </div>
);
}
复制代码
useCanvas里实例化自定义Canvas类,返回getCanvas方法。
export function useCanvas(canvas) {
const canvasRef = useRef();
if (!canvasRef.current) {
if (canvas) {
canvasRef.current = canvas;
} else {
const globalCanvas = new Canvas();
canvasRef.current = globalCanvas.getCanvas();
}
}
return canvasRef.current;
}
复制代码
模块1就是自定义生成组件的部分,这类须要考虑两件事情:
模块1代码以下:
export default function Cmps(props) {
const globalCanvas = useContext(CanvasContext);
const [list, setList] = useState(null);
const handleDragStart = (e, cmp) => {
if (cmp.data.type === isImgComponent) {
return;
}
e.dataTransfer.setData("add-component", JSON.stringify(cmp));
};
const handleClick = (e, cmp) => {
e.preventDefault();
e.stopPropagation();
if (
cmp.data.type === isTextComponent ||
cmp.data.type === isButtonComponent
) {
globalCanvas.addCmp(cmp);
return;
}
// 图片组件
if (list) {
setList(null);
} else {
let l = null;
switch (cmp.data.type) {
case isImgComponent:
l = <Img baseCmp={cmp} />;
break;
default:
l = null;
}
setList(l);
}
};
return (
<div id="cmps" className={styles.main}> <div className={styles.cmpList}> {menus.map((item) => ( <div key={item.desc} className={styles.cmp} draggable={item.data.type !== isImgComponent} onDragStart={(e) => handleDragStart(e, item)} onClick={(e) => handleClick(e, item)}> {item.desc} </div> ))} </div> {list && ( <button className={classnames("iconfont icon-close", styles.close)} onClick={() => setList(null)}></button> )} {list && <ul className={styles.detailList}> {list}</ul>} </div>
);
}
复制代码
新增组件到画布以后,组件默认是选中状态,这个时候右边编辑模块须要显示组件的属性,而且是可编辑状态。
画布上的组件须要是可拖拽的,经过拖拽控制位置,这个时候其实就是获取x与y轴上的移动距离,那么只须要用此次位置减去初始值位置就能够了。另外须要注意的是,因为拖拽会频繁修改画布数据,因为以前设置的监听的函数,那也就须要频繁更新组件,可是这个其实不必每次移动都要更新组件,能够经过节流的方式提升性能,好比每500ms更新一次,事件代码以下:
记录初始位置:
handleDragStart = (e) => {
this.setActive(e);
let pageX = e.pageX;
let pageY = e.pageY;
e.dataTransfer.setData("startPos", JSON.stringify({pageX, pageY}));
};
复制代码
画布上的drop事件,这个时候须要判断是新增仍是已有组件拖拽变化为止,
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
// 新增的组件
let addingCmp = e.dataTransfer.getData("add-component");
if (addingCmp) {
// 拖拽进来新增的组件
addingCmp = JSON.parse(addingCmp);
const top = e.pageY - canvasPos.top - 15;
const left = e.pageX - canvasPos.left - 40;
let resData = {
...addingCmp,
data: {
...addingCmp.data,
style: {
...addingCmp.data.style,
top,
left,
},
},
};
globalCanvas.addCmp(resData);
} else {
// 拖拽画布内的组件
let startPos = e.dataTransfer.getData("startPos");
startPos = JSON.parse(startPos);
let disX = e.pageX - startPos.pageX;
let disY = e.pageY - startPos.pageY;
// 获取当前选中的组件的最新信息
const selectedCmp = globalCanvas.getSelectedCmp();
const top = selectedCmp.data.style.top + disY;
const left = selectedCmp.data.style.left + disX;
globalCanvas.updateSelectedCmpStyle({top, left});
}
};
复制代码
画布上的组件还应该是能够往八个方向放大缩小的,和拖拽类似,只须要记录鼠标的移动距离就好了,而后修改width、height、top、left就好了,须要注意的是组件的位置是根据top和left定位的,那么往下、右、右下的时候不须要修改top和left,由于这个时候左上角坐标没有改变,事件代码以下:
handleMouseDown = (e, direction) => {
e.stopPropagation();
e.preventDefault();
const cmp = this.context.getCmp(this.props.index);
let startX = e.pageX;
let startY = e.pageY;
const move = (e) => {
let x = e.pageX;
let y = e.pageY;
let disX = x - startX;
let disY = y - startY;
let newStyle = {};
if (direction) {
if (direction.indexOf("top") >= 0) {
disY = 0 - disY;
newStyle.top = cmp.data.style.top - disY;
}
if (direction.indexOf("left") >= 0) {
disX = 0 - disX;
newStyle.left = cmp.data.style.left - disX;
}
}
// 特别频繁改变,加上一个标记,
debounce(
this.context.updateSelectedCmpStyle(
{
...newStyle,
width: cmp.data.style.width + disX,
height: cmp.data.style.height + disY,
},
"frequently"
)
);
};
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
this.context.recordCanvasChangeHistory();
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
};
复制代码
和拖拽类似,旋转组件其实就是记录鼠标移动的x与y轴距离,而后计算出鼠标的移动角度,更新组件的transform的rotate值就能够了。代码以下:
handleMouseDownofRotate = (e) => {
e.stopPropagation();
e.preventDefault();
const {getCmp, updateSelectedCmpStyle} = this.context;
const cmp = getCmp(this.props.index);
let startX = e.pageX;
let startY = e.pageY;
const move = (e) => {
let x = e.pageX;
let y = e.pageY;
let disX = x - startX;
let disY = y - startY;
const deg = (360 * Math.atan2(disY, disX)) / (2 * Math.PI);
// 特别频繁改变,加上一个标记,
debounce(
updateSelectedCmpStyle(
{
transform: `rotate(${deg}deg)`,
},
"frequently"
)
);
};
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
this.context.recordCanvasChangeHistory();
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
};
复制代码
选中组件,单击右键,须要呈现一个菜单,显示组件的复制、删除、置顶与展现全部组件的功能。这个组件的展现与否经过一个状态值判断,选中组件,单击右键则显示,点击别的区域则隐藏这个菜单:
{showContextMenu && (
<ContextMenu index={index} pos={{top: style.top - 80, left: style.left + 60}} cmp={cmp} />
)}
复制代码
复制其实就是复制一份选中组件的数据,而后新增就能够了。
const copy = () => {
globalCanvas.addCmp(cmp);
};
复制代码
而在Canvas中有一个新增组件的函数,须要注意的是,onlyKey须要从新生成,同时更新选中的组件为新复制的组件:
addCmp = (_cmp) => {
this.selectedCmp = {
..._cmp,
onlyKey: getOnlyKey(),
};
const cmps = this.getCmps();
this.updateCmps([...cmps, this.selectedCmp]);
};
复制代码
删除最简单,根据当前组件的onlyKey去this.canvas的cmps中找到这个组件数据删除就好了,不要忘记把this.seletedCmp置null,由于编辑组件的区域是根据this.seletedCmp显示的,而后更新画布与编辑区域组件就能够了。
// 点击组件,右键删除组件
deleteSelectedCmp = (_cmp) => {
this.setSelectedCmp(null);
const cmps = this.getCmps();
this.updateCmps(cmps.filter((cmp) => cmp.onlyKey !== _cmp.onlyKey));
};
复制代码
这里全部组件的层级关系经过z-index控制,而z-index的取值则是组件在cmps数组中的下标,因此调整层级关系则经过更新组件在数组中的顺序就好了。那么置顶则是交换cmps中最后一个组件和选中组件的位置就好了,置底则是交换cmps中第0个组件和选中组件的位置。
const beTop = (e) => {
globalCanvas.changeCmpIndex(index);
};
const beBottom = (e) => {
globalCanvas.changeCmpIndex(index, 0);
};
复制代码
右键菜单还有一个功能就是展现全部组件,由于组件太多的时候,有些组件会被覆盖掉,那么但从画布上就无法选中被覆盖掉的组件,这个时候能够经过右键出现的菜单查看全部组件,鼠标停留,则会显示对应的组件,点击的话则有选中的功能。事件代码以下:
const cmps = globalCanvas.getCmps();
const mouseOver = (e, _cmp) => {
let cmpTarget = document.getElementById("cmp" + _cmp.onlyKey);
let prevClassName = cmpTarget.className;
if (prevClassName.indexOf("hover") === -1) {
cmpTarget.setAttribute("class", prevClassName + " hover");
}
};
const mouseLeave = (e, _cmp) => {
let cmpTarget = document.getElementById("cmp" + _cmp.onlyKey);
let prevClassName = cmpTarget.className;
if (prevClassName.indexOf("hover") > -1) {
cmpTarget.setAttribute("class", prevClassName.slice(0, -6));
}
};
const selectCmp = (e, cmp) => {
globalCanvas.setSelectedCmp(cmp);
};
复制代码
其实就是个时光机,想回到某一时刻,那么你须要记录下本身的修改历史。这个时候须要两个值this.canvasChangeHistory与this.canvasIndex。在修改画布数据以及组件数据的时候执行this.recordCanvasChangeHistory()函数记录下历史便可。点击到上一步,则获取this.canvasChangeHistory中this.canvasIndex的上一个值,下一步则获取下一个。注意下第0个和最后一个检验边界值就好了。
组件能够添加一些动画属性,这里从兔展拷贝了三个动画,能够修改一动画的单次持续时长、重复次数以及延迟时间。
每次都从0建立太慢,能够作一些预设模板,而后用数据填充画布就能够了。
export default function Tpl({openOrCloseTpl, globalCanvas}) {
const updateCmps = (cmps) => {
globalCanvas.updateCanvas(JSON.parse(cmps));
openOrCloseTpl(false);
};
return (
<ul className={styles.main}> <li className={styles.close} onClick={openOrCloseTpl}> <i className="iconfont icon-close"></i> </li> {tplData.map((item) => ( <li key={item.id} className={styles.item} onClick={() => updateCmps(item.cmps)}> <div className={styles.name}>{item.name}</div> <div className={styles.thumbnail}> <img src={item.img} /> </div> </li> ))} </ul>
);
}
复制代码
编辑器作完以后,能够把画布那部分拿出来,再作一个生成器项目,
function App() {
const [canvas, setCanvas] = useState(null);
const {cmps, style} = canvas || {};
useEffect(() => {
let cc = JSON.parse(
// ! 元宵节
'{"style":{"width":320,"height":568,"backgroundColor":"#fc0000ff","backgroundImage":"https://img.tusij.com/ips_asset/16/11/10/44/54/a5/a57d2950001941a5e65fc3ac73fe8cb8.png!l800_i_w?auth_key=1639324800-0-0-d94f8946bfa0f7eca8fc8094a1516003","backgroundPosition":"center","backgroundSize":"cover","backgroundRepeat":"no-repeat","boxSizing":"content-box"},"cmps":[{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/a5/a57d2950001941a5e65fc3ac73fe8cb8.png!l800_i_w?auth_key=1639324800-0-0-d94f8946bfa0f7eca8fc8094a1516003","style":{"top":-1,"left":-1,"width":321,"height":153,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.27364639468523455},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/a5/a57d2950001941a5e65fc3ac73fe8cb8.png!l800_i_w?auth_key=1639324800-0-0-d94f8946bfa0f7eca8fc8094a1516003","style":{"top":155,"left":-3,"width":321,"height":153,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.7545885469950053},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/a5/a57d2950001941a5e65fc3ac73fe8cb8.png!l800_i_w?auth_key=1639324800-0-0-d94f8946bfa0f7eca8fc8094a1516003","style":{"top":420,"left":-3,"width":321,"height":153,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.7590306166672274},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/53/ca/ca7ebd1a9683109e61f374e75e87fc85.png!l800_i_w?auth_key=1639324800-0-0-04d5239353f80379a2430dc74d1ac11a","style":{"top":18,"left":211,"width":89,"height":81,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.14191580299167428},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/70/70913bd41742596a4a0dd68b088e6551.png!l800_i_w?auth_key=1639324800-0-0-2a8cd9567a9d2a9aa2ddd8acc4a24450","style":{"top":460,"left":0,"width":320,"height":110,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.5399342806341869},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/53/9a/9a353760e02b49cbdd2706f5c452291b.png!l800_i_w?auth_key=1639324800-0-0-8825104eb9f4bd5ca42b9ff8c3690c9c","style":{"top":403,"left":10,"width":121,"height":50,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.27065004352847866},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/53/69/6917ec339fa98e4cb97cf596cc9179df.png!l800_i_w?auth_key=1639324800-0-0-31958bfca526c4f4f87f4363b8b16b61","style":{"top":461,"left":28,"width":97,"height":49,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.3396974553981347},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/53/e7/e722646ec5596c852c8b193b2ef09db9.png!l800_i_w?auth_key=1639324800-0-0-0e5dcd8e08ad1e7f0de72c2dad23419c","style":{"top":439,"left":158,"width":100,"height":47,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.02766075271613433},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/09/09917bf7e35711c91d353fd7aebf2a38.png!l800_i_w?auth_key=1639324800-0-0-bd838424e74c24b3f0787ae4c4fb11d6","style":{"top":215,"left":116,"width":114,"height":154,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.6929555070607207},{"desc":"图片","data":{"type":2,"value":"https://tva1.sinaimg.cn/large/008eGmZEly1gnqdhx1eprj303m03mjrm.jpg","style":{"top":388,"left":245,"width":41,"height":58,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff","animationName":"wobble","animationDelay":0,"animationDuration":"8","animationIterationCount":"infinite"}},"onlyKey":0.7708575276016363},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/15/48/39/56/74/56/564896077cb72510ff3b920732d8c53c.png!l800_i_w?auth_key=1639152000-0-0-456d31b72cda757ae3945425296bd646","style":{"top":173,"left":248,"width":51,"height":58,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.540328523257599}]}'
);
setCanvas(cc);
}, []);
return canvas ? (
<div className={styles.main} style={{ ...formatStyle(style), backgroundImage: `url(${style.backgroundImage})`, }}> {cmps.map((cmp, index) => ( <Draggable key={cmp.onlyKey} cmp={cmp} index={index} canvasWidth={style.width} canvasHeight={style.height} /> ))} </div>
) : (
<div> <i className="iconfont icon-loading"></i> </div>
);
}
复制代码
完结~
别忘了给文章点赞 也欢迎关注公众号【花果山前端】和个人B站,后续会上传更多的原创视频