Node.js躬行記(24)——低程式碼

2022-10-17 09:01:12

  低程式碼開發平臺(LCDP)是無需編碼(0程式碼)或通過少量程式碼就可以快速生成應用程式的開發平臺。讓具有不同經驗水平的開發人員可以通過圖形化的使用者介面,通過拖拽元件和模型驅動的邏輯來建立網頁和移動應用程式。

  低程式碼的核心是呈現、互動和擴充套件,其中呈現和互動需要藉助自行研發的渲染引擎實現。而此處的擴充套件特指物料庫,也就是各類自定義的業務元件,有了物料庫後才能滿足更多的場景。

  在 4 個月前研發過一套視覺化搭建系統,當時採用的是生成程式碼的方式渲染頁面。而本次研發採用的則是執行時渲染,功能比較基礎,基於React開發,程式碼量在 3000 多行左右,使用者群是本組團隊成員,目標是:

  1. 滿足 80% 的後臺需求,高效賦能解放生產力。
  2. 抽象共性,標準化流程,提升程式碼維護性。
  3. 減少專案程式碼量,加快構建速度。

  平臺的操作介面如下,由於管理後臺頁面的元素比較單一,所以暫不支援拖拽和縮放等功能,也就是沒有通用的佈局器。

  

  元件區域可以選擇內建的通用模板元件,點選新增可在預覽區域顯示對應的元件,位置可上下調整,並且可以像真實的頁面那樣進行動態互動。設定區域可填寫選單名稱、許可權、路由等資訊,點選更新檔案後,會將資料儲存到 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}`)

  在元件區域提供一個按鈕,還原最近一次的元件狀態,這樣即使頁面報錯,重新整理後,還能繼續上一步未完成的操作。