前端大屏原理系列:多组件联动

793 阅读5分钟

本文是《前端大屏原理系列》第三篇:多组件联动。

本系列所有技术点,均经过本人开源项目 react-big-screen 实际应用,欢迎 star (*´▽`)ノノ.

一、效果演示

演示.gif

demo地址:点击访问

二、简介

多组件联动就是指多个组件相互触发,从而完成某些行为逻辑。需要注意的是两个随机组件之间互不相识,因此每个组件只关心自己能做什么(暴露事件)、以及广播出去自己做了什么(触发事件)。我使用事件发布 - 订阅模式来管理组件之间的触发逻辑,以此实现对多组件交互的解耦。

// 一个简单的事件发布-订阅模型
class Observer {
    private eventMap = {};
    
    // 监听事件
    public on(id, callback) {
        (this.eventMap[id] ||= []).push(callback);
        return () => {
            this.remove(id, callback);
        }
    }
    
    // 触发事件
    public notify(id, payload) {
       this.eventMap[id]?.forEach?.(callback => {
           callback?.(payload);
       })
    }
    
    // 卸载事件
    public remove(id, callback) {
       if (!this.eventMap[id]?.length) return;
       this.eventMap[id] = this.eventMap[id].filter(cb => {
          return cb !== callback;
       })
    }
}

三、暴露事件

暴露事件指的是组件告诉外界自己能做什么,主要用于修改组件内部逻辑。

由于组件是运行时动态创建的,所以在组件创建时才开始暴露事件。

3.1 简单实现

在组件渲染时注册内部事件,并在组件卸载时同时取消注册对应事件。

function renderComponentNode ({ componentNode }) {
  ...
  
  useEffect(() => {
    // 暴露事件(注册事件监听)
    engine.events.on('[组件id]-[callbackId]', callback)
    // 卸载事件
    return () => {
      engine.events.remove('[组件id]-[callbackId]', callback)
    }
  }, [])
    
  ...
}

3.2 封装运行时hook

前面的例子中,我们需要在每个自定义组件中都要去写一遍注册、卸载事件,并直接修改 engine.events上的事件绑定。这样在后续的维护中可能存在问题:修改事件id的格式后「发布-订阅模型」无法触发不再使用 engine.events 管理事件等等。所以,我们可以将其封装成一个hook,这样开发自定义组件时就只需要关心如何编写暴露事件本身了。

运行时创建 hook:

// ------------- react-big-screen 源码部分 -------------
// 获取约定格式的事件id
export function getEventId(componentNodeId: string, exposeId: string): string {
  return `${componentNodeId}-${exposeId}`;
}

// 创建暴露事件 hook
function createUseExposeHook(componentNodeId: string) {
 return function (exposes: Record<string, (payload: any) => void>) {
   useEffect(() => {
     for (const key in exposes) {
        engine.events.on(getEventId(componentNodeId, key), exposes[key]);
     }
     return () => {
        for (const key in exposes) {
           engine.events.remove(getEventId(componentNodeId, key), exposes[key]);
        }
     };
   }, [])
  };
}

// 运行时生成 hook
function useCreateUseExposeHook(componentNodeId: string) {
  return useMemo(() => createUseExposeHook(componentNodeId), []);
}

// ------------- 渲染通用组件模板 -------------
function renderComponentNode ({ componentNode }) {
  const Component = ... // 自定义组件模板

  ...
  // 创建运行时 hooks
  const useExpose = useCreateUseExposeHook(componentNode?.id);
  ...
  
  return (
    <Component 
      ...
      useExpose={useExpose}
      ...
    />
  )
}

使用方式(自定义组件内部暴露事件):

export default createComponent(({ useExpose }) => {
   ...
   
   // 暴露事件
   useExpose({
     setValue: (payload) => {
        // (内部操作)
        // ...
     },
     refresh: (payload) => {
        // (内部操作)
        // ...
     }
   })
   
   ...
})

四. 触发事件

触发事件就是组件向外界广播自己做了什么,一般用来主动发起一个行为逻辑。这个很简单,直接触发事件即可。

4.1 简单实现

engine.events.notify('[目标组件id]-[目标组件callbackId]', payload);

4.2 封装运行时函数

虽然只要触发事件就可以,但是我们怎么知道要触发哪个组件的哪个事件呢?每个组件可以同时触发多个组件,每个组件又有多个事件待触发。所以我们需要在当前组件保存 当前组件 ~ 多个目标组件 ~ 多个暴露事件的事件关联关系,在运行时找到并触发所有关联的组件,就可以实现多组件联动了。

创建触发函数:

// ------------- react-big-screen 源码部分 -------------
function createHandleTrigger(componentNodeId: string) {
  // triggerId:当前组件的触发事件id。
  // payload:携带参数。
  return function (triggerId: string, payload: any) {
    ...
    // 当前组件
    const origin = engine.componentNode.get(componentNodeId);

    // 找到所有的待触发目标(因为可能同时触发多个组件,一个target对应一个待触发组件)
    const targets = origin?.events?.find?.((event) => {
      return event?.triggerId === triggerId;
    })?.targets;

    // 处理所有关联的待触发目标组件。
    targets.forEach((optTarget: ComponentNodeEventTarget) => {
      // 待触发的目标组件
      const target: ComponentNodeType | undefined = engine.componentNode.get(
        optTarget.id
      );
      if (!target) {
        message.error("[bigScreen]: target componentNode not exist.");
        return;
      }
      // 触发目标组件所有待执行的暴露事件。
      optTarget.opts.forEach((opt: ComponentNodeEventTargetOpt) => {
        // 处理待触发目标组件的事件配置数据(opt)
        switch (opt.exposeId) {
          // 显隐类型
          case INIT_EXPOSES.visible:
            handleVisibleOption(opt, target);
            break;
          // 请求类型
          case INIT_EXPOSES.request:
            handleRequestOption(opt, target, origin, payload);
            break;
          // 自定义操作类型
          case INIT_EXPOSES.custom:
            handleCustomOption(opt, target, origin, payload);
            break;
          // 【通用处理】自定义组件类型
          default:
            handleCommonOption(opt, target, origin, payload);
            break;
        }
      });
    });
    ...
  };
}

// 创建触发函数
function useCreateHandleTrigger(componentNodeId: string) {
  return useMemo(() => createHandleTrigger(componentNodeId), []);
}

// ------------- 渲染自定义组件 -------------
function renderComponentNode ({ componentNode }) {
  const Component = ... // 自定义组件模板

  ...
  // 创建运行时 hooks
  const handleTrigger = useCreateHandleTrigger(componentNode?.id);
  ...
  
  return (
    <Component 
      ...
      handleTrigger={handleTrigger}
      ...
    />
  )
}

使用方式(自定义组件内部触发事件):

export default createComponent(props => {
   const { width, height, handleTrigger } = props;
   return (
     <div style={{ width, height }}>
       <a onClick={e => handleTrigger('click', e)}>
         点击我呀!
       </a>
     </div>
   )
})

五、总结

我们之所以在运行时封装hook、封装触发函数,都是为了屏蔽暴露事件、触发事件的具体实现逻辑。这样后续如果更新事件处理方式时,不会影响所有组件。并且可以提高自定义组件开发时的效率,而不用关心事件注册、卸载以及事件id的命名问题。

【前端大屏原理系列】

react-big-screen 是一个从0到1使用React开发的前端拖拽大屏开源项目。此系列将对大屏的关键技术点一一解析。包含了:拖拽系统实现、自定义组件、收藏夹、快捷键、可撤销历史记录、加载远程组件/本地组件、自适应预览页、布局容器组件、多组件联动(基于事件机制)、成组/取消成组、多子页面切换、i18n国际化语言、鼠标范围框选、... ... 等等。

演示地址:点击访问