Node.js躬行記(24)——低程式碼
- 2022 年 10 月 17 日
- 筆記
- Node.js躬行記
低程式碼開發平台(LCDP)是無需編碼(0程式碼)或通過少量程式碼就可以快速生成應用程式的開發平台。讓具有不同經驗水平的開發人員可以通過圖形化的用戶介面,通過拖拽組件和模型驅動的邏輯來創建網頁和移動應用程式。
低程式碼的核心是呈現、交互和擴展,其中呈現和交互需要藉助自行研發的渲染引擎實現。而此處的擴展特指物料庫,也就是各類自定義的業務組件,有了物料庫後才能滿足更多的場景。
在 4 個月前研發過一套可視化搭建系統,當時採用的是生成程式碼的方式渲染頁面。而本次研發採用的則是運行時渲染,功能比較基礎,基於React開發,程式碼量在 3000 多行左右,用戶群是本組團隊成員,目標是:
- 滿足 80% 的後台需求,高效賦能解放生產力。
- 抽象共性,標準化流程,提升程式碼維護性。
- 減少項目程式碼量,加快構建速度。
平台的操作介面如下,由於管理後台頁面的元素比較單一,所以暫不支援拖拽和縮放等功能,也就是沒有通用的布局器。
組件區域可以選擇內置的通用模板組件,點擊添加可在預覽區域顯示對應的組件,位置可上下調整,並且可以像真實的頁面那樣進行動態交互。配置區域可填寫菜單名稱、許可權、路由等資訊,點擊更新文件後,會將數據存儲到 MongoDB 中。
一、渲染引擎
在資料庫中保存的組件是一套 JSON 格式的 Schema(頁面的描述性數據),將 Schema 讀取出來後,經過渲染引擎解析後,得到對應的組件,最後在頁面中顯示。
1)Schema
下面的 Schema 描述的是一個提示組件,參數的值是字元串和布爾值。為了能讓組件滿足更多的場景,有時候,組件的參數值可以是字元串類型的 JSX 程式碼或回調函數,例如下面的 description 屬性,那這些就需要做特殊處理了。
{ props: { message: "123", description: "<p>456</p>", showIcon: true }, name: "Prompt" }
點擊 Schema 按鈕,可實時查看當前的 Schema 結構,這些 Schema 最終也會存儲到 MongoDB 中。
2)參數解析
從組件區域得到的參數都是字元串類型,此時需要做一次適當的類型轉換,變成數組、函數等。eval() 比較適合做這個活,它會將字元串當做 JavaScript 程式碼進行執行,執行後就能得到各種類型的值。
在下面的遍歷中,先對數組做特殊處理,然後再判斷字元串是否是對象或數組,最後在運行 eval()函數時,要加 try-catch,捕獲異常,因為字元串中有可能包含各種語法錯誤。
for (const key in values) { // 未定義的值不做處理 if (values[key] === undefined) continue; // 對數組做特殊處理 if (Array.isArray(values[key])) { // 將數組的空元素過濾掉 values[key] = removeEmptyInArray(values[key]); newValues[key] = values[key]; continue; } const originValue = values[key]; let value = originValue; // 判斷是對象或數組 const len = originValue.length; if ( (originValue[0] === "{" && originValue[len - 1] === "}") || (originValue[0] === "[" && originValue[len - 1] === "]") ) { try { /** * 字元串轉換成對象 * 若 values[key] 是數組,會有BUG * eval(`(${[1,2]})`)的值為 2,因為數組會先調用toString(),得到 eval("(1,2)") */ value = eval(`(${originValue})`); } catch (e) { // eval(`test`)字元串也會報test未定義的錯誤 value = originValue; } } newValues[key] = value; }
在將參數轉換類型後,接下來渲染引擎就會根據不同的組件對這些參數進行訂製處理,例如將提示組件的 description 屬性轉換成 JSX 語法的程式碼。parse()是一個解析函數,來自於 html-react-parser 庫,可將組件轉換成 React.createElement() 的形式。回調函數的處理會在後面做詳細的講解。
{ handleProps: (values: ObjectType) => { // 將字元串轉換成JSX if (values.description) { values.description = parse(values.description.toString()); } return values; }; }
3)回調函數
除了 JSX 之外,為了能適應更多的業務場景,提供了自定義的回調函數。
{ props: { btns: `onClick: function(dispatch) { dispatch({ type: "template/showCreate", payload: { modalName: 'add' } });` }, name: "Btns" }
編輯器組件使用的是 react-monaco-editor,即 React 版本的 Monaco Editor。
編輯器默認是不支援放大的,這是自己加的一個功能。點擊放大按鈕後,修改編輯器父級的樣式,如下所示,全螢幕狀態能更直觀的修改程式碼。
.fullscreen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 10000; }
函數默認是字元串,需要進行一次轉換,採用的是 new Function(),這種方式可以將參數傳遞進來。eval() 雖然也能執行字元串程式碼,但是它不能傳遞上下文或參數。
const stringToFunction = (func:string) => { const editorWarpper = new Function(`return ${func}`); return editorWarpper(); };
本來是想在編輯器中沿用 TypeScript 語法,但是在程式碼中沒有編譯成功,會報錯。
4)組件映射
一開始是想在編輯器中直接輸入 JSX 程式碼,然後通過 Babel 轉譯,但在程式碼中引入 Babel 後也是出現了一系列的錯誤,只得作罷。
之前的 parse() 函數可將字元串轉換成組件,但是在實際開發,需要添加各種類型的屬性,還有各類事件,全部揉成字元串並不直觀,並且 antd 組件不能直接通過 parse() 解析得到。所以仍然是書寫一定規則的 Schema(如下所示),再轉換成對應的組件。
{ name: "antd.TextArea", props: { width: 200 }, events: { onChange: function (dispatch, e) { const str = e.target.value; const keys = str.match(/\{(\w+)\}/g); const params = {}; keys && keys.forEach((item) => (params[item] = {})); dispatch({ type: "groupTemplate/setSqlParams", payload: params }); } } };
name 中會包含組件類別和名稱,類別包括 4 種:antd、模板、HTML標準元素和自定義組件。
export const componentHash:ObjectType = { admin: { Prompt, SelectTabs, CreateModal, }, antd: { Affix, Anchor, AutoComplete, }, html: { a: (node:JSX.Element|string, props = {}) => <a {...props}>{parse(node.toString())}</a>, p: (node:JSX.Element|string, props = {}) => <p {...props}>{parse(node.toString())}</p>, }, custom: { ...Custom }, };
jsonToComponent() 是將JSON轉換成組件的函數,就是從上面的對象中得到組件,帶上屬性、子組件後,再將其返回。
const jsonToComponent = (item:JsonComponentItemType) => { const { name, props = {}, node, } = item; const names = name.split('.'); const types = componentHash[names[0]]; // 異常情況 if (!types || names.length === 1) { return null; } const Component = types[names[1]]; // HTML元素處理 if (names[0] === 'html') { return Component(node, props); } // 組件處理 if (node) { return <Component {...props}>{parse(node)}</Component>; } return <Component {...props} />; };
5)關聯組件
關聯組件特指一個模板組件內包含另一個模板組件,例如標籤欄組件,它會包含其他模板組件。
如果要做到關聯,最簡單的方法是將組件的配置一起寫到標籤欄的參數中,但這麼做會非常繁瑣,並且內容太多,不夠直觀。還不如跳過低程式碼平台,直接在編輯器中編寫,來的省事。
後面就想到關聯組件索引,關聯的組件也可以在平台中編輯自己的參數。只是當組件刪除後,關聯的組件也要一併刪除,程式碼的複雜度會變高。
6)交互預覽
在預覽時,為了能實現交互,就需要修改狀態驅動視圖的更新。
對於一些方法,在執行過後,就能實現狀態或視圖的更新。
但對於一些屬性,例如 values.allState,若要讓其能動態讀取內容,就需要藉助 getter。
const values:ObjectType = { get allState() { return wrapperState; }, };
二、配套設施
要將該平台推廣到內部使用,除了渲染引擎外,還需要些配套設施,包括自定義業務組件、頁面呈現、持久化存儲等。
1)業務組件
內置的組件肯定是無法滿足實際的業務,所以需要可以擴展業務組件,由此制訂了一套簡單的數據源規範。所有的業務組件我都放到了custom文件中,可自行創建新文件,例如 demo。
custom
├──── demo
├──── index.tsx
├──── test.tsx
在 index.tsx 文件中,會引入自定義的組件,後面就能在平台中使用了。
import Demo from './demo'; const Components:ObjectType = { Demo, }; export default Components;
為了便於調試,預留了測試組件的頁面,在下拉框中選擇相應的組件,並填寫完屬性後,就會在組件內容區域呈現效果。
2)生成文件
在配置區域點擊生成/更新文件後,就會將菜單、路由、許可權等資訊保存到 MongoDB 中。其中最重要的就是組件的原始資訊,如下所示。
{ "components": [{ "props": { "message": "44", "description": "555", "showIcon": true }, "name": "Prompt" }], "auto_url": ['api', 'article/list'], "authority": "backend.sql.ccc", "parent": "backend.sql", "path": "lowcode/test", "name": "測試", }
為了與之前的路由和許可權機制保持一致,在保存成功後,需要自動更新本地的路由文件(router.js)和許可權文件(authority.ts)。
// 路由 { path: "/view/lowcode/test", exact: true, component: "lowcode/editor/run" } // 許可權 { id: "backend.sql.test", pid: "backend.sql", status: 1, type: 1, name: "測試", desc: "", routers: "/view/lowcode/test" }
3)頁面呈現
由於是運行時渲染,因此頁面的呈現都使用了一套程式碼,只是路由會不同。所有的路由都是以 view/ 為前綴,在首次進入頁面時,會根據路徑讀取頁面資訊,路徑會去除前綴。
const { pathname } = location; // 查詢參數 if (pathname.indexOf("/view/") >= 0) { dispatch({ type: "getOnePage", payload: { path: pathname.replace("/view/", "") } }); }
在頁面呈現的內部,程式碼很少,在調用 initialPage() 函數後,得到組件列表,直接在頁面中渲染即可。initialPage() 其實就是渲染引擎,內部程式碼比較多,在此不展開。
function Run({ dispatch, state, allState }:EditorProps) { const { pageInfo } = state; let components; if (pageInfo.components) { components = initialPage(pageInfo, dispatch, allState, false); } return ( <> {components && components.map((item:ComponentType2) => (item.visible !== false && item.component))} </> ); }
4)體驗優化
體驗優化很值得推敲,目前還有很多地方有待優化,自己只完成了一小部分。
例如在創建頁面時,第一次點擊後,第二次點擊是做更新,而不是再次創建。因為在創建後會更新路由和許可權文件,那麼就會重新構建,完成熱更新,頁面再刷新一次。為了下次點擊按鈕是更新,可以更改地址,帶上id。
history.push(`/lowcode/editor2?id=${data._id}`)
在組件區域提供一個按鈕,還原最近一次的組件狀態,這樣即使頁面報錯,刷新後,還能繼續上一步未完成的操作。