視覺化搭建 - 定義聯動協議

黃子毅發表於2023-03-06

雖然底層框架提供了通用的元件值與聯動配置,可以建立對元件任意 props 的對映,但這只是一個能力,還不是協議。

業務層是可以確定一個協議的,還要讓這個協議具有擴充性。

我們先從使用者角度設計 API,再看看如何根據已有的元件值與聯動能力去實現。

設計聯動協議

首先,不同的業務方會定義不同的聯動協議,因此該聯動協議需要透過擴充的方式注入:

import { createDesigner } from 'designer'
import { onReadComponentMeta } from 'linkage-protocol'

return <Designer onReadComponentMeta={onReadComponentMeta} />

首先視覺化搭建框架支援 onReadComponentMeta 屬性,用於擴充所有已註冊的元件元資訊,而聯動協議的擴充就是基於元件值與元件聯動能力的,因此這種是最合理的擴充方式。

之後我們就註冊了一個固定的聯動協議,它形如下:

{
  "componentName": "input",
  "linkage": [{
    "target": "input1",
    "do": {
      "value": "{{ $self.value + 'hello' }}"
    }
  }]
}

只要在元件例項上定義 linkage 屬性,就可以生效聯動。比如上面的例子:

  • target: 聯動目標。
  • do: 聯動效果,比如該例子為,元件 ID 為 input1 的元件,元件值同步為當前元件例項的元件值 + 'hello'
  • $self: 描述自己例項,比如可以從 $self.value 拿到自己的元件值,從 $self.props 拿到自己的 props。

更近一步,target 還可以支援陣列,就表示同時對多個元件生效相同規則。

我們還可以支援更復雜的語法,比如讓該元件可以同步其他元件值:

{
  "componentName": "input",
  "linkage": [{
    "deps": ["input1", "input2"]
    "props": {
      "text": "{{ $deps[0].value + deps[1].value }}"
    }
  }]
}

上面的例子表示,該元件例項的 props.text 同步為 input1 + input2 的元件值:

  • deps: 描述依賴列表,每個依賴例項都可以在表示式裡用 $deps[] 訪問到,比如 $deps[0].props 可以訪問元件 ID 為 input1 元件的 props。
  • props: 同步元件的 props。

如果定義了 target 則作用於目標元件,未定義 target 則作用於自身。但無論如何,表示式的 $self 都指向自己例項。

總結一下,該聯動協議允許元件例項實現以下效果:

  1. 設定元件值、元件 props 的聯動效果。
  2. 可以將自己的元件值同步給元件例項,也可以將其他元件值同步給自己。

基本上,可以滿足任意元件聯動到任意元件的訴求。而且甚至支援元件間傳遞,比如 A 元件的元件值同步元件 B, B 元件的元件值同步元件 C,那麼 A 元件 setValue() 後,元件 B 和 元件 C 的元件值會同時更新。

實現聯動協議

以上聯動協議只是一種實現,我們可以基於元件值與元件聯動設定任意協議,因此實現聯動協議的思維具備通用性,但為了方便,我們以上面說的這個協議為例子,說明如何用視覺化搭建框架的基礎功能實現協議。

首先解讀元件例項的 linkage 屬性,將聯動定義轉化為元件聯動關係,因為聯動協議本質上就是產生了元件聯動。接下來程式碼片段比較長,因此會盡量使用程式碼註釋來解釋:

const extendMeta = {
  // 定義 valueRelates 關係,就是我們上一節提到的定義元件聯動關係的 key
  valueRelates: ({ componentId, selector }) => {
    // 利用 selector 讀取元件例項 linkage 屬性
    // 由於 selector 的特性,會實時更新,因此聯動協議變化後,聯動狀態也會實時更新
    const linkage = selector(({ componentInstance }) => componentInstance.linkage)

    // 返回聯動陣列,結構: [{ sourceComponentId, targetComponentId, payload }]
    return linkage.map(relation => {
        const result = [];

        // 定義此類聯動型別,就叫做 simpleRelation
        const payload = {
          type: 'simpleRelation',
          do: JSON.parse(
            JSON.stringify(relation.do)
              // 將 $deps[index] 替換為 $deps[componentId]
              .replace(
                /\$deps\[([0-9]+)\]/g,
                (match: string, index: string) =>
                  `$deps['${relation.deps[Number(index)]}']`,
              )
              // 將 $self 替換為 $deps[componentId]
              .replace(/\$self/g, () => `$deps['${componentId}']`),
          ),
        };
        // 經過上面的程式碼,表示式裡無論是 $self. 還是 $deps[0]. 都轉化為了
        // $deps[componentId] 這個具體元件 ID,這樣後面處理流程會簡單而統一

        // 讀取 deps,並定義 dep 元件作為 source,target 作為目標元件
        // 這是最關鍵的一步,將 dep -> target 關係繫結上
        relation.target.forEach((targetComponentId) => {
          if (relation.deps) {
            relation.deps.forEach((depIdPath: string) => {
              result.push({
                sourceComponentId: depIdPath,
                targetComponentId,
              });
            });
          }

          // 定義自己到 target 目標元件的聯動關係
          result.push({
            sourceComponentId: componentId,
            targetComponentId,
            payload,
          });
        });

        return result;
      }).flat()
  }
}

上述程式碼利用 valueRelates,將聯動協議的關聯關係提取出來,轉化為值聯動關係。

接著,我們要實現 props 同步功能,實現這個功能自然是利用 runtimeProps 以及 selector.relates,將關聯到當前元件的元件值,按照聯動協議的表示式執行,並更新到對應 key 上,下面是大致實現思路:

const extendMeta = {
  runtimeProps: ({ componentId, selector, getProps, getMergedProps }) => {
    // 拿到作用於自己的值關聯資訊: relates
    const relates = selector(({ relates }) => relates);

    // 記錄最終因為值聯動而影響的 props
    let relationProps: any = {};

    // 記錄關聯到自己的元件此時元件值
    const $deps = relates?.reduce(
      (result, next) => ({
        ...result,
        [next.componentId]: {
          value: next.value,
        },
      }),
      {},
    );

    // 為了讓每個依賴變化都能生效,多對一每一項 do 都帶過來了,需要按照 relationIndex 先去重
    relates
      .filter((relate) => relate.payload?.type === 'simpleRelation')
      .forEach((relate) => {
        const expressionArgs = {
          // $deps[].value 指向依賴的 value
          $deps,
          get,
          getProps: relate.componentId === componentId ? getProps : getMergedProps,
        };

        // 處理 props 聯動
        if (isObject(relate.payload?.do?.props)) {
          Object.keys(relate.payload?.do?.props).forEach((propsKey) => {
            relationProps = set(
              propsKey,
              selector(
                () =>
                  // 這個函式是關鍵,傳入元件 props 與表示式,返回新的 props 值
                  getExpressionResult(
                    get(propsKey, relate.payload?.do?.props),
                    expressionArgs,
                  ),
                {
                  compare: equals,
                  // 根據表示式數量可能不同,所以不啟用快取
                  cache: false,
                },
              ),
              relationProps,
            );
          });
        }
      });

    return relationProps
  }
}

其中比較複雜函式就是 getExpressionResult,它要解析表示式並執行,原理就是利用程式碼沙盒執行字串函式,並利用正則替換變數名以匹配上下文中的變數,大致程式碼如下:

// 程式碼執行沙盒,傳入字串 js 函式,利用 new Function 執行
function sandBox(code: string) {
  // with 是關鍵,利用 with 定製程式碼執行的上下文
  const withStr = `with(obj) { 
    ${code}
  }`;
  const fun = new Function('obj', withStr);

  return function (obj: any) {
    return fun(obj);
  };
}

// 獲取沙盒程式碼執行結果,可以傳入引數覆蓋沙盒內上下文
function getSandBoxReturnValue(code: string, args = {}) {
  try {
    return sandBox(code)(args);
  } catch (error) {
    // eslint-disable-next-line no-console
    console.warn(error);
  }
}

// 如果物件是字串則直接返回,是 {{}} 表示式則執行後返回
function getExpressionResult(code: string, args = {}) {
  if (code.startsWith('{{') && code.endsWith('}}')) {
    // {{}} 內的表示式
    let codeContent = code.slice(2, code.length - 2);

    // 將形如 $deps['id'].props.a.b.c
    // 轉換為 get('a.b.c', getProps('id'))
    codeContent = codeContent.replace(
      /\$deps\[['"]([a-zA-Z0-9]*)['"]\]\.props\.([a-zA-Z0-9.]*)/g,
      (str: string, componentId: string, propsKeyPath: string) => {
        return `get('${propsKeyPath}', getProps('${componentId}'))`;
      },
    );

    return getSandBoxReturnValue(`return ${codeContent}`, args);
  }

  return code;
}

其中 with 是沙盒執行時替換程式碼上下文的關鍵。

總結

componentMeta.valueRelatescomponentMeta.runtimeProps 可以靈活的定義元件聯動關係,與更新元件 props,利用這兩個宣告式 API,甚至可以實現元件聯動協議。總結一下,包含以下幾個關鍵點:

  1. depstarget 利用 valueRelates 轉化為元件值關聯關係。
  2. 將聯動協議定義的相對關係(比較容易寫於容易記)轉化為絕對關係(利用 componentId 定位),方便框架處理。
  3. 利用 with 執行表示式上下文。
  4. 利用 runtimeProps + selector 實現注入元件 props 與響應聯動值 relates 變化,從而實現按需聯動。
討論地址是:精讀《定義聯動協議》· Issue #471 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章