基於中間件思想設計可擴展的UI組件庫

在UI組件庫的過程當中,有一個常常會考慮到的問題是,使用者在使用這個UI庫的過程當中,可能不滿意庫默認提供的表現和行爲,須要對默認的表現和行爲進行本身的定製。javascript

評價一個庫是否好用的一條標準是可擴展性。css

評價一個庫可擴展性是否優秀的一個很重要的原則是:經過增長代碼來實現新功能或者改變已有的功能,而不是修改原有的代碼。前端

這個原則是設計模式中的一基本原則:開放封閉原則(Open-Closed Principle OCP)java

Software entities(classes,modules,functions etc) should open for extension ,but close for modification.
開放封閉原則主要體如今兩個方面:
對擴展開放,意味着有新的需求或變化時,能夠對現有代碼進行擴展,以適應新的狀況。
對修改封閉,意味着類一旦設計完成,就能夠獨立其工做,而不要對類盡任何修改。node

本文分享我在基於react框架構建開源思惟導圖庫blink-mind的過程當中的一些實踐和經驗。但願可以對你們的工做有所幫助。react

中間件思想在不少的開源項目中都有使用,其中很著名的一個例子是開源的node.js下一代 web服務端框架koagit

關於koa的中間件怎麼使用這裏不作過多的展開了。下面部分將具體分享在前端組件庫中應用中間件思想來提升庫的擴展性。github

對於一些簡的組件庫,使用者的需求可能只是改變一下樣式,經過覆蓋css就能夠解決。web

可是對於一複雜的組件庫,比方說富文本編輯器,表格編輯器,流程圖等數據和交互都比較複雜的組件庫,使用者可能不單單侷限於改變樣式。他們的需求多是對某個地方的popup menu 或者context menu進行定製,自定義大的組件中的某個子組件,自定義快捷鍵,對數據定義(model)進行擴展並對擴展的數據定義進行自定義的展現。設計模式

以我正在開發的思惟導圖庫爲例,思惟導圖中的一個節點稱爲一個topic。使用者可能想要:

  1. 對默認的topic項的右鍵彈出菜單不滿意,想本身定製
  2. 對默認的topic項的文本編輯器不滿意,想要用某個具有特定的功能的文本編輯器替換掉默認的文本編輯器
  3. 對默認的topic之間的連線的樣式不滿意
  4. 想在原有的幾種佈局方式上增長新的佈局方式

爲了可以支持使用者的這些需求,以及應用上文提到了開放封閉原則。我想到了能夠將中間件思想應用到UI組件庫的設計中來。

具體的實踐是:

中間件基礎設施

整個工程採用monorepo的組織方式,其中@blink-mind/core這個package 包含數據的scehema 定義以及 一個最關鍵的類Controller, Controller的主要職責是管理中間件以及經過名稱調用某個中間件函數。

Controller中用一個map 來管理中間件

middleware: Map<string, Function[]>;
複製代碼

註冊中間件

function registerPlugin(controller: Controller, plugin: any) {
  if (Array.isArray(plugin)) {
    plugin.forEach(p => registerPlugin(controller, p));
    return;
  }

  if (plugin == null) {
    return;
  }

  for (const key in plugin) {
    const fn = plugin[key];
    controller.middleware[key] = controller.middleware[key] || [];
    controller.middleware[key].push(fn);
  }
}
複製代碼

將多個同名的中間件函數組合(相似koa-compose)

function compose(middleware) {
  if (!Array.isArray(middleware))
    throw new TypeError('Middleware stack must be an array!');
  for (const fn of middleware) {
    if (typeof fn !== 'function')
      throw new TypeError('Middleware must be composed of functions!');
  }

  return function(context, next) {
    // last called middleware #
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      if (i <= index) throw new Error('next() called multiple times');
      index = i;
      let fn = middleware[i];
      if (i === middleware.length) fn = next;
      if (!fn) return null;
      try {
        return fn(context, dispatch.bind(null, i + 1));
      } catch (err) {
        return err;
      }
    }
  };
}
複製代碼

調用某個中間件函數

run(key: string, ...args: any[]) {
    const { middleware } = this;
    const fns = middleware[key] || [];
    warning(fns.length !== 0, `the middleware function ${key} is not found!`);
    const composedFn = memoizeOne(compose)(fns);
    return composedFn(...args);
}
複製代碼

中間件的使用

默認的渲染方式由@blik-mind/renderer-react這個package 來實現,

其中渲染部分的中間件代碼見rendering.tsx

舉個例子系統默認的文本編輯器是個簡單的文本編輯器,在渲染一個內容區域的過程當中會調用到renderTopicContentEditor中間件方法

renderBlock(props) {
  const { controller, block } = props;
  switch (block.type) {
    case BlockType.CONTENT:
    // 若是此內容區域block的類型是CONTENT
      return controller.run('renderTopicContentEditor', props);
    case BlockType.DESC:
      return <TopicDescIcon {...props} />;
    default:
      break;
  }
  return null;
},
複製代碼
renderTopicContentEditor(props) {
  return <SimpleTopicContentEditor {...props} />;
},
複製代碼

若是想要複雜的富文本編輯器,可使用@blik-mind/plugin-rich-text-editor這個package 中提供的plugin

plugin-rich-text-editor的實現代碼以下

import * as React from 'react';
import { TopicContentEditor } from '../components/topic-content-editor';
import { TopicDescEditor } from '../components/topic-desc-editor';
export default function RichTextEditorPlugin() {
  return {
    renderTopicContentEditor(props) {
      return <TopicContentEditor {...props} />;
    },

    renderTopicDescEditor(props) {
      return <TopicDescEditor {...props} />;
    }
  };
}
複製代碼

具體的TopicContentEditor 實現代碼見rich-text-editor.tsx

中間件函數中的next參數

和koa同樣,經過compose方法將多個同名的中間件函數組合成一個函數,這些同名的中間件函數的調用次序由函數註冊的次序以及函數中對next參數的使用有關。 即: 同名中間件函數中,最後註冊的中間件函數先調用,在中間件函數中經過next參數調用下一個中間件函數 舉個例子:

經過編寫插件改變系統默認的context menu:刪除edit menu 項,在第二個位置增長自定義項

function ChangeDefaultTopicContextMenuPlugin() {
  return {
    customizeTopicContextMenu(props, next) {
    // 首先調用 next() 方法,獲取系統默認的菜單項
      let defaultMenus = next();
    // 刪除第一項,也就是 edit 
      defaultMenus.splice(0, 1);
    // 在第二個位置增長自定義項
      defaultMenus.splice(
        1,
        0,
        <MenuItem
          icon="group-objects"
          label="Shift + A"
          text="my customize menu"
          onClick={onClickMyMenu(props)}
        />
      );
      console.log(defaultMenus);
      return <>{defaultMenus}</>; } }; } 複製代碼

這個例子的運行效果見 awehook.github.io/blink-mind/…

再舉一個上文渲染內容區域的例子:

renderBlock(props) {
  const { controller, block } = props;
  switch (block.type) {
    case BlockType.CONTENT:
    // 若是此內容區域block的類型是CONTENT
      return controller.run('renderTopicContentEditor', props);
    case BlockType.DESC:
      return <TopicDescIcon {...props} />;
    default:
      break;
  }
  return null;
},
複製代碼

框架默認支持兩種類的BlockType, 比方說使用者想要增長一種block的類型,好比說是個emoj圖標,那麼他能夠編寫一個插件,重寫renderBlock函數,

renderBlock(props,next) {
  const { controller, block } = props;
  // 判斷若是block 類型是emoj, 則用Emoj組件來進行渲染
  if (block.type==='EMOJ') {
    return <Emoj {...props}/>
  }
  // 其餘狀況下使用系統默認的渲染方式
  return next();
},
複製代碼

總結

在UI組件庫中使用中間件思想能夠很方便的實現可擴展性,而且遵循了開放封閉原則。

最後很是歡迎各位小夥伴們給這個項目 blink-mind 點個star~~~~~~~~~~~~~~~~~~~~

相關文章
相關標籤/搜索