讓不懂代碼的產品人員也能自己開發頁面了。
背景
低代碼一直以來爭議不斷,很多人認為低代碼平臺不好用,坑很多,很多功能實現不了,還不如自己用代碼開發。關于這個我說一下我的個人觀點,我覺得低代碼平臺不應該給開發者使用,應該是產品或者業務人員去使用,開發人員去維護低代碼平臺就好了。
低代碼平臺還有一個比較難平衡的點,如果想追求簡單,難免拓展性和靈活性變差,如果追求靈活性,難免要寫一些代碼,這樣上手難度就變高了。
LowCodeEngine低代碼引擎為了追求靈活性,所以對非前端技術人員不太友好,因為很多時候一個很簡單的功能都還要去寫代碼才能實現。
不過LowCodeEngine插件系統很強大,能夠快速實現自己想要的功能。下面就和大家分享一下我自己寫的一個插件,可以讓非技術人員也能快速開發頁面。
不使用插件實現一個小功能
下面先給大家演示一下,不使用我寫的插件的情況下,實現點擊一個按鈕,彈出一個彈框整個配置流程。
我這里用的是基于antd物料的官方demo平臺,官方還有一些基于其他物料的demo平臺,可以到這里查看。
先從左側的組件庫中拖一個按鈕到畫布中
再拖一個彈框組件到畫布
再拖一個文本組件到彈框里
在js源碼中添加一個變量
給彈框是否可見屬性綁定變量
綁定我們剛才在state里添加的變量,這里不知道是bug,還是特性,這里選不到剛才定義的變量,需要自己手寫。
變量綁定完成后,可以點擊這個給彈框隱藏掉,因為它會干擾我們給按鈕綁定事件。
選中按鈕,然后給按鈕onClick事件綁定方法
這里選擇新建一個方法,把visible設置為true。
點擊上面的預覽按鈕,可以看到效果
彈出來之后,發現點擊取消和確認不會關閉彈框,需要我們給取消和確認事件綁定方法,這里可以給彈框重新顯示出來
onOk事件也綁定onCancel方法
重新預覽一下,看一下效果
小結
看了上面步驟,大家是不是覺得很麻煩,并且對非react前端人員一點都不友好,更別說沒有代碼經驗的產品人員了。
站在一個不懂代碼人的角度來看這個功能,無非是點一下按鈕,彈出一個彈框,如果能把這個步驟可視化出來就好了,給按鈕的點擊事件直接綁定彈框組件的顯示方法,而不是使用代碼中的變量去關聯。
我做的插件就是把組件之間的邏輯使用可視化的方法配置出來,而不是使用代碼去實現聯動。
使用插件實現功能
按鈕打開彈框
下面使用我寫的插件后,來實現一下上面例子。
先把一個按鈕和一個彈框到畫布中
點擊上方的事件管理按鈕,這個是自定義插件生成的按鈕
點擊事件管理按鈕后,會彈出一個側拉框,左側內容會當前畫布中的組件,可以在這里給組件的事件綁定動作。
在左側選擇按鈕組件
選擇onClick事件
點擊開始下面的加號,添加一個動作節點,點擊動作節點配置動作類型為組件方法,選擇彈框組件的打開彈框方法。
先保存一下,然后再給彈框的onOk事件添加關閉彈框動作,先選中彈框,然后選onOk事件,添加一個動作節點,給動作節點配置關閉彈框。
這樣就行了,不用寫一行代碼。
根據輸入網址打開網頁
下面再給大家演示一個稍微復雜一點的例子。使用按鈕打開用戶輸入的網址,需要先校驗一下用戶輸入的是否為網址格式,如果格式不對,就不打開,并提示格式錯誤。
鏈接:https://www.ixigua.com/7344764451138372131?utm_source=iframe_share
表單聯動
再來一個例子,實現簡單的表單元素聯動。
鏈接:https://www.ixigua.com/7344772943915778610?utm_source=iframe_share
小結
上面幾個例子沒有寫一行代碼實現了一些簡單的功能,實現的這些例子雖然簡單,但是插件其實已經有了實現復雜功能的基礎,并且不需要寫一行代碼,對不懂代碼的人比較友好。
插件內容講解
前言
根據上面例子可以看出,插件主要更改了LowCodeEngine的事件綁定功能的方式和屬性綁定變量的彈框內容。
事件管理
LowCodeEngine原生事件綁定動作的方式需要寫一定的代碼,對不懂代碼的人很不友好,所以我給改了一下,使用可視化的方式配置事件動作,比較符合普通人的思維,上手也很簡單。
事件管理頁面有四塊內容,如下圖
組件區:當前畫布中存在的組件,可以選擇給某個組件事件綁定動作
事件區:可以給當前組件支持的某個事件綁定動作
動作配置區:給前面選擇的組件事件綁定動作,因為組件的事件很多情況不只是執行一個動作,所以支持配置多個動作。有時候在執行動作的時候,需要根據條件去執行,所以加了一個條件節點。執行動作后,動作執行完成后可能會有事件,比如調用接口后,接口有成功事件、失敗事件,這時候我們可以在接口調用完成后,根據事件執行后續動作,所以還有一個事件節點。
所以目前事件流程配置有以下四種類型節點
- 開始節點:沒有意義,表示事件入口,不能配置。
- 動作節點:綁定動作,可以連接事件節點和條件節點
- 條件節點:可以配置多個條件分支,每個條件分支可以綁定一個動作節點,只能連接事件節點。
- 事件節點:每個動作節點都會有事件節點,只能連接動作節點。
節點配置:目前支持配置的節點只有動作節點和條件節點
- 動作節點:可以設置具體的執行動作,比如調用組件方法等。
- 條件節點:可以添加多個條件,每個條件都可以綁定動作。
屬性綁定變量
為了上手簡單,我把組件屬性綁定變量的彈框內容也改造了一下,LowCodeEngine原生綁定變量需要寫代碼,雖然擴展性很強,但是上手難度很高,所以我給改造成只需要選擇組件里暴露出來的變量就行了,還提供了一些常用函數可以用來判斷和處理數據,上面demo項目里提供的函數比較少,真實項目可以內置很多常用函數,也可以動態拓展函數。
插件核心技術分享
在實現上面功能的時候,雖然LowCodeEngine文檔很詳細,但是還是遇到了一些問題,看了源碼才解決的。下面和大家分享一下,幫助大家寫好自己的插件。
事件管理插件
初始化插件項目
使用下面命令,可以快速創建一個插件
npm init @alilc/element your-material-name
這里類型選擇插件
如何拓展一個面板,官方文檔很詳細,大家可以看一下。
插件中使用到api
左側的組件樹數據可以通過下面方法獲取,下面代碼中ctx是LowCodeEngine引擎注入進來的上下文參數
const schema = ctx.project.exportSchema(IPublicEnumTransformStage.Save);return schema.componentsTree as IPublicTypeRootSchema[];
根據組件名稱獲取組件中文描述
ctx.material.getComponentMetasMap().get(componentName)?.title['zh-CN']
獲取當前組件支持那些事件
// 通過當前選中的組件id,獲取到組件const node = ctx.project.currentDocument.getNodeById(selectComponentId);// 根據組件名稱,獲取組件的props配置const { props } = ctx.material.getComponentMeta(node.componentName).getMetadata();// 過濾出事件propsreturn props.filter(p => p.propType === 'func');
動作流程配置完成后,把數據保存到當前節點的當前事件屬性中。
// 獲取流程編排數據const data = flowRef.current.save();// 根據id獲取節點const node = ctx.project.currentDocument.getNodeById(selectComponentId);// 給節點某個屬性設置值node.props.setPropValue(selectEventName, {type: 'flow',value: data,});
下次編輯的時候,獲取當前節點事件配置的動作編排數據
const node = ctx.project.currentDocument.getNodeById(selectComponentId);return node.getPropValue(selectEvent);
流程編排
流程編排使用的組件是antv中的G6庫,具體實現參考了官方的這個案例。
組件方法
有人可能會有疑問,選中了彈框組件組件后,為什么會知道它有打開彈框和關閉彈框方法,這個是在物料組件里配置的,這個下面說到自定義物料的時候再詳細說,先和大家說一下,獲取物料暴露出來的方法。
// 獲取節點信息const node = ctx.project.currentDocument.getNodeById(componentId);// 根據組件名稱獲取物料配置信息const { configure } = ctx.material.getComponentMeta(node.componentName).getMetadata();// 獲取組件暴露出來的可調用方法return configure?.supports?.methods || [];
變量彈框插件
自定義變量綁定的彈框,這個我在官方文檔沒有找到,還是看了源碼才知道的。
// 注冊變量綁定面板,CustomVariableDialog是自定義組件ctx.skeleton.add({area: 'centerArea',type: 'Widget',content: CustomVariableDialog,name: 'variableBindDialog',props: {ctx,},});
可以在組件內部監聽打開變量圖標點擊事件,打開我們自己的彈框
ctx.event.on('common:variableBindDialog.openDialog', ({ field }) => {// 獲取當前屬性綁定的值setScriptValue(field.getValue()?.script || '')// 顯示彈框setVisible(true);// 保存field對象,因為后面給屬性設置值會用到它fieldRef.current = field;});
面板中內置了一些常用函數,如果想拓展也很簡單,符合下面數據格式就行。
{label: "isUrl",template: "func.isUrl(${v1})",detail: "判斷內容是url",type: "function",handle: (v1: any) => {if (typeof v1 !== "string") return false;return /^https?://(([a-zA-Z0-9_-]) (.)?)*(:d )?(/((.)?(?)?=?&?[a-zA-Z0-9_-](?)?)*)*$/i.test(v1);},},
組件值面板里存放的是組件運行時對外暴露的變量,有哪些變量可以使用也是物料中配置的,這個后面會細說。
// 獲取組件中暴露出來的值名稱和描述const { values } = ctx.material.getComponentMeta(node.componentName).getMetadata().configure.supports;
右側的編輯器是我以前使用codemirror6封裝的,倉庫地址為github.com/dbfu/bp-scr…,大家感興趣可以去看一下。最大的特點是把插入的變量當成標簽,不允許修改。
把編輯器中的腳本保存到屬性上,可以使用filed.setValue方法,為啥是這樣的數據格式,我后面再說,這里有個很大的坑。
fieldRef.current?.setValue({type: 'variable',value: '[[變量]]',script: scriptValue,});
自定義物料
如何自定義一個物料,官方文檔寫的很清楚了。我這里給它擴展了兩個屬性配置,一個對外暴露的方法描述,還有一個是對外暴露的變量值描述。
使用下面命令,可以快速創建一個插件
npm init @alilc/element your-material-name
這里選擇物料
然后自定義一個組件,我這里以Modal彈框組件為例。
import { ModalProps, Modal as OriginalModal } from 'antd';import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';const Modal: any = (props: ModalProps & { setCurPageValue: (fbn: Function) => void, __designMode: string }, ref) => { // setCurPageValue和__designMode是低代碼引擎注入進來屬性,// setCurPageValue可以把值設置到全局const { setCurPageValue, __designMode, ...rest } = props; const [open, setopen] = useState(false); useEffect(() => {// 把當前open值暴露出去setCurPageValue((prev: any) => ({ ...prev, open }));}, [open]); // 對外暴露open和close方法useImperativeHandle(ref, () => ({open: () => {setOpen(true);},close: () => {setOpen(false);},}), []); const cancelHandle = (e) => {if (props.onCancel) {props?.onCancel(e);} else {setOpen(false);}}; const innerProps: any = {};if (__designMode === 'design') {// 低代碼編輯態中強制顯示,將控制權交給引擎側innerProps.open = true;}return <OriginalModal {...rest} open={open} {...innerProps} onCancel={cancelHandle} />;};export default forwardRef(Modal);
setCurPageValue是插件里給組件注入的一個方法,可以給組件里的變量暴露到全局。暴露open和close方法,也是為了在組件方法中去調用。
寫完組件后,執行npm run lowcode:build命令,會在根目錄下生成一個lowcode文件夾,里面存放的是物料描述。找到modal的物料描述,把要對外暴露的方法和值手動配置進去,后面想辦法拓展一下官方的插件,自動掃描,不用自己手動配了。
這里配置變量name和方法name要和代碼里的一樣
自定義render
設計階段
這里我被卡了一段時間,原因是我自定義了變量綁定的腳本格式,如果按照官方把類型設置為JSExpression,設計階段會報錯,因為他會執行里面的腳本,但是我們的腳本沒有按照官方的格式來,所以會報錯。
然后我就去翻源碼,看看有沒有方法繞過,然后就找到了這個代碼,組件在渲染前會格式化props。
我開始的想法是重寫里面的方法,參考了官方的這篇文檔,但是設計階段不允許自定義PageRenderer,然后繼續往下看源碼,看到了這段代碼。
沒想到這段兼容代碼幫了我的忙,我只需要給type設置為variable就行了,value為腳本。這樣做后我發現了一個新問題,如果給按鈕文本綁定變量,設計階段會把腳本當成文本顯示。后來一想把value寫死成[[變量]],告訴這里是變量,新加一個script屬性存腳本。
預覽階段
前面都是關于配置的,具體怎么把配置轉換為可正常使用的功能,還是在這一步。
把配置渲染成功能,官方有一個封裝好的ReactRenderer組件,具體怎么使用可以看一下文檔。
雖然ReactRenderer組件拓展性很強,支持很多東西,比如createElement方法,但是不支持重寫__parseProps方法,不重寫__parseProps方法,我這里變量綁定的腳本運行就會有問題。
還好預覽階段可以自定義BaseRenderer,__parseProps就是BaseRenderer類里的一個方法,我們只需要寫一個類繼承BaseRenderer,然后只重寫__parseProps方法,但是需要自定義PageRenderer,所以官方的就不能用了,不過可以把官方代碼拿出來改一改就行了。
import ConfigProvider from '@alifd/next/lib/config-provider';import {adapter,addonRendererFactory,baseRendererFactory,blockRendererFactory,componentRendererFactory,pageRendererFactory,rendererFactory,tempRendererFactory,types,} from '@alilc/lowcode-renderer-core';import { isVariable } from '@alilc/lowcode-utils';import React, {Component,ContextType,PureComponent,ReactInstance,createContext,createElement,forwardRef,} from 'react';import ReactDOM from 'react-dom';window.React = React;(window as any).ReactDom = ReactDOM;adapter.setRuntime({Component,PureComponent,createContext,createElement,forwardRef,findDOMNode: ReactDOM.findDOMNode,});const BaseRenderer = baseRendererFactory();class CustomBaseRenderer extends BaseRenderer {constructor(props: any, context: any) {super(props, context); const parseProps = this.__parseProps;this.__parseProps = (props: any, self: any, path: string, info: any) => {// 這里判斷一下如果是變量類型,把type改成script,不然執行base的__parseProps方法還是會問題,這個腳本后面在另外一個地方處理if (isVariable(props) as any) {return {type: 'script',value: props.value,script: props.script,} as any;}return parseProps(props, self, path, info);};}}// 把自定義的CustomBaseRenderer,設置進去。adapter.setRenderers({BaseRenderer: CustomBaseRenderer,} as any);adapter.setConfigProvider(ConfigProvider);const PageRenderer = pageRendererFactory();class CustomPageRenderer extends PageRenderer {constructor(props: any, context: any) {super(props, context);}}function factory(): types.IRenderComponent {adapter.setRenderers({BaseRenderer: CustomBaseRenderer,PageRenderer: CustomPageRenderer,ComponentRenderer: componentRendererFactory(),BlockRenderer: blockRendererFactory(),AddonRenderer: addonRendererFactory(),TempRenderer: tempRendererFactory(),DivRenderer: blockRendererFactory(),}); const Renderer = rendererFactory(); return class ReactRenderer extends Renderer implements Component {readonly props!: types.IRendererProps; context: ContextType<any>;setState!: (state: types.IRendererState, callback?: () => void) => void;forceUpdate!: (callback?: () => void) => void;refs!: {[key: string]: ReactInstance;}; constructor(props: types.IRendererProps, context: ContextType<any>) {super(props, context);} isValidComponent(obj: any) {return obj?.prototype?.isReactComponent || obj?.prototype instanceof Component;}};}export default factory();
這里重寫了BaseRenderer的__parseProps方法,把變量類型改了一下,后面在重寫createElement時再處理。
PageRenderer寫完后,我們下面去使用它
<ReactRenderclassName="lowcode-plugin-sample-preview-content"schema={schema}components={components}customCreateElement={(Component: any, props: any, children: any) => {// 給每個組件注入的上下文const ctx = {pageValue,setPageValue,getComponentRefs,}; // 當組件配置了是否渲染為變量時,動態執行腳本,如果腳本返回 false,則不渲染if (props?.__inner__?.condition && props?.__inner__?.condition?.type === 'variable') {if (!execScript(props?.__inner__?.condition?.script, ctx)) return ;} // 解析 propsconst newProps = parseProps(props, ctx); // 渲染組件return React.createElement(Component, newProps, newProps.children || children);}}onCompGetRef={(schema: any, ref: any) => {// 存儲每個組件的 ref實例componentRefs.current = {...componentRefs.current,[schema.id]: ref,}}}appHelper={{requestHandlersMap: {fetch: createFetchHandler()}}}/>
這里主要自定義了createElement方法,這樣我們可以在組件渲染前,更改props。
把組件實例存放到了componentRefs中,我們只要知道了組件id和方法,就可以通過componentRefs調用它的方法了。
看一下前面打開彈框的配置,知道了組件id,也知道是哪個方法,所以我們就可以調用彈框的打開方法了。
parseProps方法實現,可以看一下代碼中的注釋
export const parseProps = (props: any, ctx: any) => { const { setPageValue } = ctx; const newProps: any = {// 給每個組件注入設置值的方法,讓它們把想要暴露出來的值設置到全局setCurPageValue: (fn: Function) => {setPageValue((prev: any) => ({...prev,[props.__id]: fn(prev[props.__id]),}))}}; Object.keys(props).forEach(key => {// 判斷是否是事件if (key.startsWith('on') && props[key]) {const eventConfig = props[key];newProps[key] = () => {const { type, value } = eventConfig || {};// 如果事件綁定的動作為流程,那么去執行流程if (type === 'flow') {value.children && execEventFlow(value.children, ctx);}};} else if (typeof props[key] === 'object') {// 判斷是否是腳本if (props[key].type === 'script') {// 執行腳本newProps[key] = execScript(props[key].script, ctx);} else {newProps[key] = props[key];}} else {newProps[key] = props[key];}}) return newProps;}
execScript方法,使用的是new Function方法動態執行腳本。這里把存放組件暴露出來的值注入到了腳本的上下文中,所以腳本中可以直接獲取到某個組件暴露出來的值。
export function execScript(script: string, ctx: any) {const { pageValue } = ctx; if (!script) return; const result = script.replace(/[[(. ?)]]/g, (_: string, $2: string) => {const [fieldType, ...rest] = $2.split('.'); if (fieldType === 'C') {const keys = rest.map((t) => t.split(':')[1]);return `ctx.lodash.get(ctx.pageValue, "${keys.join('.')}")`;} return '';}); const func = new Function('ctx', 'func', `return ${result}`); const funcs = functions.reduce<any>((prev, cur) => {if (cur.handle) {prev[cur.label] = cur.handle;}return prev;}, {});const funcResult = func({pageValue,lodash,},funcs,);return funcResult;}
執行事件綁定動作的方法,主要使用了遞歸。
import { message } from 'antd';import { execScript } from './exec-script';import { getPropValue } from './utils';const actions = [{name: 'openPage',label: '打開頁面',paramsSetter: [{name: 'url',label: 'url',type: 'input',required: true,}, {name: 'isNew',label: '新開窗口',type: 'switch',}],handler: (config: { url: string, isNew: boolean }) => {const { url, isNew = false } = config;window.open(url, isNew ? '_blank' : '_self');}},{name: 'showMessage',label: '顯示消息',paramsSetter: [{name: 'type',label: '消息類型',type: 'select',options: [{label: 'success',value: 'success',}, {label: 'error',value: 'error',}],defaultValue: 'success',required: true,}, {name: 'text',label: '消息內容',type: 'input',required: true,}],handler: (config: { type: any, text: any }) => {const { type, text } = config; if (type === 'success' || type === 'error') {message[type as 'success' | 'error'](text);}}},];const actionMap = actions.reduce<any>((prev, cur) => {prev[cur.name] = cur.handler;return prev;}, {})async function componentMethod(actionConfig: any, ctx: any) {const componentRefs = ctx.getComponentRefs();if (!componentRefs[actionConfig.componentId]) {return Promise.reject();} // 拿到組件實例,執行對應的方法await componentRefs[actionConfig.componentId][actionConfig.method]();}export function execEventFlow(nodes: Node[] = [],ctx: any,) {if (!nodes.length) return; nodes.forEach(async (item: any) => {// 判斷是否是動作節點,如果是動作節點并且條件結果不為false,則執行動作if (item.type === 'action' && item.conditionResult !== false) { const { config } = item?.config || {}; const newConfig: any = {}; Object.keys(config).forEach((key: any) => {newConfig[key] = getPropValue(config[key], ctx);}); try {if (item.config.type === 'ComponentMethod') {await componentMethod(config, ctx);} else {// 根據不同動作類型執行不同動作await actionMap[item.config.type](newConfig,ctx,item,);} // 如果上面沒有拋出異常,執行成功事件的后續腳本const children = item.children?.filter((o: any) => o.eventKey === 'success');execEventFlow(children, ctx);} catch {// 如果上面拋出異常,執行失敗事件的后續腳本const children = item.children?.filter((o: any) => o.eventKey === 'error');execEventFlow(children, ctx);} finally {// 如果上面沒有拋出異常,執行finally事件的后續腳本const children = item.children?.filter((o: any) => o.eventKey === 'finally');execEventFlow(children, ctx);}} else if (item.type === 'condition') {// 如果是條件節點,執行條件腳本,把結果注入到子節點conditionResult屬性中const conditionResult = (item.config || []).reduce((prev: any, cur: any) => {const result = execScript(cur.condition, ctx);prev[cur.id] = result;return prev;},{}); (item.children || []).forEach((c: any) => {c.conditionResult = !!conditionResult[c.conditionId];});// 遞歸執行子節點事件流execEventFlow(item.children, ctx);} else if (item.type === 'event') {// 如果是事件節點,執行事件子節點事件流execEventFlow(item.children, ctx);}});}
到此整個插件核心功能介紹的差不多了,插件還沒成熟,就先不放出來了,等稍微成熟一點了,會給開源出來的。
我還在陸陸續續加一些功能,比如優化接口調用方式,和后端表模型對接、通過AI快速開發界面等,如果有對這個插件開發感興趣的,可以在評論區留言討論。
總結
個人認為LowCodeEngine不一定是一個好的低代碼平臺,但是它絕對是一個非常強大的低代碼引擎,它定義了低代碼很多協議和規范,讓別人可以在它的基礎上快速孵化出一個符合自己產品的低代碼平臺。
作者:前端小付
鏈接:https://juejin.cn/post/7344941254236389403
版權聲明:本文內容由互聯網用戶自發貢獻,該文觀點僅代表作者本人。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。如發現本站有涉嫌抄襲侵權/違法違規的內容, 請發送郵件至 舉報,一經查實,本站將立刻刪除。