随着公司内部对各类报表需求的不断攀升,原有的极光系统已经无法满足移动端、实时大屏等各类场景;另外由于页面逻辑中涵盖了大量计算,导致页面性能偏差且无法支持灵活的外部拓展,导致数据源单一;最后由于项目比较久远,整体UI风格相对老旧、交互方式单一。为了满足公司内部各业务灵活的数据报表产出需求,以及保证数据团队报表产出效率,就有了我们的北极星报表搭建平台。

伽利略数据平台

首先我们来介绍一下伽利略数据平台,伽利略数据平台主要由三个部分组成,一个是报表展示层,就是目前的伽利略报表系统,同时支持运行于钉钉微应用和PC端。一个是伽利略管理后台,主要用于报表各数据源管理配置和报表目录与发布管理。最后一个部分就是我们的报表搭建平台–北极星。

由上图我们也可以看出三个系统之间相互关联,又完全解耦,可完全独立运行。北极星搭建系统,作为数据报表产出工具,支持高度复用,可随意对接外部展示系统和数据来源系统。

北极星报表搭建平台

首先我们从一张流程示意图来看看,北极星主要实现原理。我们通过管理后台的编辑器以及组件管理模块,抽象维护数据、组件、树结构、样式、组件属性等模块信息,再通过解析器解析渲染,控制页面上下线、版本以及页面中包含的组件,接着通过接口调用获取数据源信息,生成适配各个场景的报表能力。

北极星报表搭建系统主要分成四个层面,有展示层,包括渲染器,编辑管理后台,以及多端的适配能力。有基础层,通过抽象页面、组件、数据、协议、权限等模块,来支持展示层的复用。有框架层,主要依赖业界成熟的antd (antd-mobile) + react、图表使用F2和G2。还有服务层,通过自建node服务,提供页面、组件、报表和权限等管理能力。

组件设计

北极星搭建系统支持antd 现成组件、自定义业务组件的灵活接入,只需要组件配合引入属性配置表和默认数据信息,通过发布平台发布npm包,最后在北极星系统上线后,即可快速接入到各报表页面。在一些特殊的属性场景上,我们还支持对组件属性的配置能力进行自定义开发。

组件版本控制

在搭建系统中,其实我们用的最多的就是组件,而且组件随着项目的不断迭代,可能会出现很多版本,那么在多个版本的情况下,我们如何来保障页面可以快速读取对应版本组件的信息呢? 如果使用npm形式引入,我们需要将所有组件拆分成独立的npm包,进行管理。而且这种情况下,我们也不能做到在一个页面需要多个版本组件的情况下共存,只能同时更新所有组件版本,这样很可能存在潜在的问题,导致页面出现版本不兼容的bug。

组件按需加载

在通常的前端项目中,我们都是通过引用npm组件,然后加载并渲染在页面上。这种形式有个问题,在我们不知道用户引用哪个组件时,那我们就需要将所有组件都引用,打到最终的渲染包里,然后根据用户需求,去读取对应的组件。这个过程就浪费了很多资源,也会造成页面性能问题。

动态组件方案

为了解决上面的问题,保障页面性能和准确的版本控制。我们使用的cdn方式,我们将每次发布的后的组件都上传至cdn,再通过组件类型、组件版本等字段拼接cdn链接,最后通过ajax请求动态获取组件文件,渲染成相应的组件,提供给页面使用。这也是为什么,我们需要对现成可用的antd组件,进行简单封装上架到北极星。另外我们后续还将提供更加灵活的升级配置,支持快速、批量修复线上bug。

动态组件在实现上我们提供了两个方案,一个是通过ajax获取文件,通过eval进行代码执行,导出对应的组件实例。另一个方案是,通过动态加载script标签的方式加载指定组件。两种方案有各自的优缺点,通过ajax方式获取,可以通过indexedDB进行缓存,减少了后续请求,性能提升明显,但这种方案组件报错后,无法获取具体报错信息。动态加载script标签的方式容易出现缓存泄露问题,且无法支持缓存。所以我们目前的方案是,在测试开发环境,使用动态script标签方式,在线上使用ajax获取文件方式。具体代码如下:

// 使用eval解析, 无法catch组件内部的错误
export const loadScriptModule = async (
  url: string, packageName: string,
) => new Promise(async (reslove, reject) => {
  const id = getIdformUrl(url);
  const Reg = /[http://|https://]/;
  if (url && Reg.test(url) && id) {
    try {
      // 这里可添加缓存获取逻辑

      
      const { data } = await axios.get(url, { timeout: 30 * 1000 });
      
      if (!data) {
        const err = new Error('文件加载失败,请检查链接');
        reject(err);
        return;
      }

      // 这里可添加缓存存储逻辑

      
      
      window.eval(`${data}`);
      const res = (window as any)[packageName] || {};
      reslove(res);
      (window as any)[packageName] = null;
    } catch (error) {
      console.error(error);
      reject(error);
    }
  } else {
    const error = new Error('url 格式错误,请检查链接');
    reject(error);
  }
});
// 动态加载js文件, 可能存在内存泄露的问题
export const loadScriptModule = async (
  url: string, packageName: string,
) => new Promise(async (resolve, reject) => {
  const id = getIdformUrl(url);
  const Reg = /[http://|https://]/;
  if (url && Reg.test(url)) {
    let script: any = document.createElement('script');
    script.setAttribute('src', url);
    document.head.appendChild(script);

    // 监听加载完成事件
    script.addEventListener('load', async () => {
      const res = (window as any)[packageName] || {};
      resolve(res);
      (window as any)[packageName] = null;
      script = null;
    });

    // 监听加载失败情况
    script.addEventListener('error', (error: any) => {
      message.error('组件加载失败,请检查是否已同步CDN');
      reject(error);
    });
    
  } else {
    reject(error);
  }
});