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站,後續會上傳更多的原創視頻