低代码实践:手把手带你封装一个数据轮播列表组件,Echarts 没有咱就手搓一个!🧐

1,876 阅读19分钟

扯皮

最近这两个月好像都没怎么更新文章,主要一直在忙三件事:考驾照、搞毕设、处理家务事,好在这三件事都比较顺利😄

驾照历经 40 多天顺利搞到手,每轮考试都一把过,也算是在毕业前完成最后一个主线任务了,虽然现在还没有车吧但想想以后一旦上班就很难抽出时间去考试了,所以自己也比较重视🧐

毕设这里真的要感谢我的导师在一开始定题时给了我很大的支持。虽然在答辩过程中还不小心怼了一下答辩老师,但好在没出大问题也是顺利通过了,也算是完成了自己大学四年的最后一个作品,后面准备写两篇非技术文章来总结一下大学四年的生活🤪

家务事不必多说,今年我已经 23 了,虽然还没有到下有小,但是上有老的年纪是已经到了,步入社会后男人的生活压力慢慢就上来了😶

不扯那么多了,这次主要介绍自己毕设中的一个组件,因为项目与大屏可视化有关,但是在 Echarts 中真的就只有统计图表,数据列表展示相关的组件是一个都没有🙃

后续自己就调研了相关大屏的项目,找到了这个👇:

DataV-Team/DataV: Vue数据可视化组件库(类似阿里DataV,大屏数据展示)

除图表外还提供了一些装饰组件,最重要的是它有我想要到的轮播组件:

轮播表.png

但是想了想没必要就为了这个组件又在项目中安装另一个图表库,所以就去扒了一下这个组件的源码,发现还有点费劲,尤其是这里的轮播效果方案以前还真没见过这样的思路,所以赶紧写一篇文章记录一下😁

既然是低代码实践就不能只封装一个组件就完事了,需要能够进行动态配置,具体实现的效果如下图 gif:

轮播组件展示.gif

该组件所用技术:React18 + TS + immer + antd + unocss

组件实现思路和相关配置参考的项目如下:

DataV-Team/DataV-React: React数据可视化组件库

GoView 说明文档 | 低代码数据可视化开发平台 (mtruning.club)

GoViewPro | 低代码数据可视化开发平台

正文

Schema 设计

首先我们的第一步并不是上来就模拟数据画列表样式而是确定该组件配置的 Schema 结构,根据该结构才能决定组件的代码如何组织🧐

根据该列表组件的外观可以把配置项分为三大部分,不需要做过多的解释直接来看代码和效果图就明白了:

第一部分:公共配置(滚动动画相关和行数据背景颜色)

type AnimationType = "none" | "row" | "page";

const publicConfig = {
   animationStyle: "row" as AnimationType,
   animationTime: 2,
   hoverStop: false,
   oddBg: "#2A2A2CFF",
   evenBg: "#232324FF",
}

公共配置.png

第二部分:行配置(文字、背景、高度相关)

其实关于行配置又分为两个部分:header、body,一般情况下 header 是可以让用户控制是否显示的,其次 body 中可以单独配置行高或者自适应行数,因此需要分开设计

const header = {
  headerBg: "#B885851A",
  headerHeight: 40,
  show: true,
  fontStyle: {
    size: 14,
    weight: "normal",
    color: "#fff",
    skew: "normal",
  },
}

header配置.png

const body = {
  isFixedHeight: false,
  rowHeight: 50,
  rowCount: 7,
  fontStyle: {
    size: 14,
    weight: "normal",
    color: "#fff",
    skew: "normal",
  }
}

body配置.png

第三部分:列配置(布局、宽度、内容)

列配置也分为两个部分:序号列、内容列,因为内容列可以随意增减,因此为了方便编写代码将序号列单独拿出来配置:

type AlignType = "left" | "center" | "right";

type ColumnType = {
  title: string;
  columnWidth: number;
  mapField: string;
  defaultValue: string;
}

const columnConfig = {
  fixedWidth: false,
  showIndex: false,
  align: "center" as AlignType,
  indexColumn: {
    title: "#",
    startIndex: 1,
    columnWidth: 50,
    fontStyle: {
      size: 14,
      weight: "normal",
      color: "",
      skew: "normal",
    },
  },
  columns: [] as ColumnType[],
}

col配置.png

到此我们就有了整个组件的配置,顺便再增加一下数据源,直接把 Element Plus 数据扒下来用:

const tableData = [
  {
    date: "1",
    name: "Tom",
    address: "No. 189, Grove St, Los Angeles",
  },
  {
    date: "2",
    name: "Tom",
    address: "No. 189, Grove St, Los Angeles",
  },
  {
    date: "3",
    name: "Tom",
    address: "No. 189, Grove St, Los Angeles",
  },
  {
    date: "4",
    name: "Tom",
    address: "No. 189, Grove St, Los Angeles",
  },
  {
    date: "5",
    name: "Tom",
    address: "No. 189, Grove St, Los Angeles",
  },
  {
    date: "6",
    name: "Tom",
    address: "No. 189, Grove St, Los Angeles",
  },
  {
    date: "7",
    name: "Tom",
    address: "No. 189, Grove St, Los Angeles",
  },
  {
    date: "8",
    name: "Tom",
    address: "No. 189, Grove St, Los Angeles",
  },
];

组件所有配置如下,一些不太清楚的字段含义等到实现该部分内容时再讲解:

export type AlignType = "left" | "center" | "right";

export type AnimationType = "none" | "row" | "page";

export type ColumnType = {
  title: string;
  columnWidth: number;
  mapField: string;
  defaultValue: string;
};

const tableConfig = {
  // 保存数据源
  dataset: tableData as Record<string, string>[],
  publicConfig: {
    animationStyle: "row" as AnimationType,
    animationTime: 2,
    hoverStop: false,
    oddBg: "#2A2A2CFF",
    evenBg: "#232324FF",
  },
  rowConfig: {
    header: {
      headerBg: "#B885851A",
      headerHeight: 40,
      show: true,
      fontStyle: {
        size: 14,
        weight: "normal",
        color: "#fff",
        skew: "normal",
      },
    },
    body: {
      isFixedHeight: false,
      rowHeight: 50,
      rowCount: 7,
      fontStyle: {
        size: 14,
        weight: "normal",
        color: "#fff",
        skew: "normal",
      },
    },
  },
  columnConfig: {
    fixedWidth: false,
    showIndex: false,
    align: "center" as AlignType,
    indexColumn: {
      title: "#",
      startIndex: 1,
      columnWidth: 50,
      fontStyle: {
        size: 14,
        weight: "normal",
        color: "",
        skew: "normal",
      },
    },
    columns: [] as ColumnType[],
  },
};

export type ComponentConfigType = typeof tableConfig;
export { tableConfig, tableData, initColumn };

搭建 row header

柿子还得挑软的捏,先把最简单的部分给实现了,再把整个组件结构搭建起来给后面铺路😏

整个数据轮播列表组件主要分为:TableScrollBoradComponent、TableScrollBoradConfigComponent,一个展示组件和一个配置组件

整体逻辑其实很简单,父组件创建配置 state 并以 props 的形式传入 Component、ConfigComponent,在 ConfigComponent 中绑定对应的表单配置项来动态更改 state,而 Component 拿到最新的 state 进行视图展示

App 父组件:

const initColumn = (title: string, mapField: string): ColumnType => ({
  title,
  mapField,
  defaultValue: "",
  columnWidth: 110,
});

function App() {
  // 根据 dataset 初始化 columns
  const [configState, setConfigState] = useState(() => {
    const keys = Object.keys(tableConfig.dataset[0]);
    keys.forEach((key) => {
      tableConfig.columnConfig.columns.push(initColumn(key, key));
    });
    return tableConfig;
  });

  return (
    <ConfigProvider
      theme={{
        algorithm: theme.darkAlgorithm,
      }}
    >
      <div className="w-screen h-screen overflow-hidden flex justify-center items-center gap-20 bg-dark">
        <div className="border-1 border-dark-100 border-solid" style={{ width: "600px", height: "400px" }}>
          <TableScrollBoradComponent data={tableData} config={configState} />
        </div>
        <div className="w-95 h-100 overflow-y-scroll overflow-x-hidden border-1 border-dark-100 border-solid p-2">
          <TableScrollBoradConfigComponent data={tableData} config={configState} updateConfig={setConfigState} />
        </div>
      </div>
    </ConfigProvider>
  );
}

export default App;

关于 table 组件给一个固定宽高,在低代码业务中可以配置尺寸,但这里不是我们的重点,因此先写死即可

需要补充的一点就是在初始化 state 时需要对配置列 columns 初始化,因为一开始是空数组,只有用户传入对应的数据源才能进行初始化,而这里我们使用的是写死的数据,所以直接导入进来即可

TableScrollBoradComponent 组件:

interface TableComponentProps {
  data: Record<string, string>[];
  config: ComponentConfigType;
}

const TableScrollBoradComponent = (props: TableComponentProps) => {
  const { config } = props;

  const headerTitles = useMemo(() => {
    const headers = config.columnConfig.showIndex ? [config.columnConfig.indexColumn.title] : [];
    config.columnConfig.columns.forEach((column) => {
      headers.push(column.title);
    });
    return headers;
  }, [config.columnConfig]);

  return (
    <div className="w-full h-full overflow-hidden">
      {config.rowConfig.header.show && <TableHeader headerTitles={headerTitles} config={config} />}
    </div>
  );
};

在 TableScrollBoradComponent 我们再进行抽离,分为 TableHeader 和 TableBody 两个小组件,这也是 JSX 的优点,如果像 Vue 的话就需要单独再创建两个 .vue 组件了,本小节我们主要实现的是 TableHeader,TableBody 留到下一小节

一开始就可以根据传入的 column 相关配置就可以计算出 row header 里每一列的 title,注意别忘了还需要对序号列进行处理,将计算出的 titles 传入至 TableHeader 中

TableHeader 组件:

interface HeaderComponentProps {
  headerTitles: string[];
  config: ComponentConfigType;
}

const TableHeader = memo((props: HeaderComponentProps) => {
  const { config, headerTitles } = props;

  // 头数据整体样式
  const headerStyle = useMemo<React.CSSProperties>(() => {
    const headerConfig = config.rowConfig.header;
    const { headerHeight, headerBg, fontStyle } = headerConfig;
    return {
      height: `${headerHeight}px`,
      lineHeight: `${headerHeight}px`,
      background: `${headerBg}`,
      fontSize: `${fontStyle.size}px`,
      fontWeight: `${fontStyle.weight}`,
      color: `${fontStyle.color}`,
      fontStyle: `${fontStyle.skew}`,
    };
  }, [config.rowConfig.header]);

  return (
    <div
      className="w-full flex items-center justify-between"
      style={{
        ...headerStyle,
      }}
    >
      {headerTitles.map((title, index) => (
        <div key={index}>{title}</div>
      ))}
    </div>
  );
});

在 TableHeader 中拿到对应的 titles 直接进行遍历即可,通过传入的 props config 来设置 header 样式,由于我们现在还没有到 column 配置宽度和对齐方式,因此先用 flex 布局将就一下,来看效果:

header效果.png

下面紧接着实现 TableScrollBoradConfigComponent,也就是该 table 组件的所有配置表单,它的逻辑无非就是通过 props 拿到对应的配置与表单项进行绑定,修改时同样取 props 中的 update 方法来更改 App 组件里的总配置即可,这样 TableScrollBoradComponent 组件对应的 props 也会跟着修改

TableScrollBoradConfigComponent 组件:


interface ConfigComponentProps {
  config: ComponentConfigType;
  updateConfig: React.Dispatch<React.SetStateAction<ComponentConfigType>>;
  data: Record<string, string>[];
}

const TableScrollBoradConfigComponent = (props: ConfigComponentProps) => {
  return <RowConifgComponent {...props} />;
};

依然选择对组件进行抽离,该小节的 header 配置属于 row 配置范畴,因此我们再单独抽离一个 RowConifgComponent 组件:

// 行设置
const RowConifgComponent = (props: ConfigComponentProps) => {
  const { config, updateConfig } = props;
  return (
    <>
      <JCollapseBox
        name="表头"
        unfold
        operator={
          <Switch
            checkedChildren="开启"
            unCheckedChildren="关闭"
            value={config.rowConfig.header.show}
            onChange={(val) => {
              updateConfig(
                produce((state) => {
                  state.rowConfig.header.show = val;
                })
              );
            }}
          />
        }
      >
        <JSettingBox name="样式">
          <div className="config-items-layout">
            <JSettingItem text="背景">
              <ColorPicker
                className="w-full"
                showText
                value={config.rowConfig.header.headerBg}
                onChange={(val) => {
                  const color = val.toHexString();
                  updateConfig(
                    produce((state) => {
                      state.rowConfig.header.headerBg = color;
                    })
                  );
                }}
              />
            </JSettingItem>
            <JSettingItem text="行高">
              <InputNumber
                className="w-full"
                placeholder="请输入"
                min={10}
                value={config.rowConfig.header.headerHeight}
                onChange={(val) => {
                  updateConfig(
                    produce((state) => {
                      state.rowConfig.header.headerHeight = val!;
                    })
                  );
                }}
              />
            </JSettingItem>
          </div>
        </JSettingBox>
        <JSettingBox name="文字">
          <div className="config-items-layout">
            <JSettingItem text="字体大小">
              <InputNumber
                className="w-full"
                placeholder="请输入"
                min={5}
                value={config.rowConfig.header.fontStyle.size}
                onChange={(val) => {
                  updateConfig(
                    produce((state) => {
                      state.rowConfig.header.fontStyle.size = val!;
                    })
                  );
                }}
              />
            </JSettingItem>
            <JSettingItem text="字体颜色">
              <ColorPicker
                className="w-full"
                showText
                value={config.rowConfig.header.fontStyle.color}
                onChange={(val) => {
                  const color = val.toHexString();
                  updateConfig(
                    produce((state) => {
                      state.rowConfig.header.fontStyle.color = color;
                    })
                  );
                }}
              />
            </JSettingItem>
            <JSettingItem text="字体粗细">
              <Select
                className="w-full"
                placeholder="请输入"
                options={WeightOptions}
                value={config.rowConfig.header.fontStyle.weight}
                onChange={(val) => {
                  updateConfig(
                    produce((state) => {
                      state.rowConfig.header.fontStyle.weight = val;
                    })
                  );
                }}
              />
            </JSettingItem>
            <JSettingItem text="字体倾斜">
              <Select
                className="w-full"
                placeholder="请输入"
                options={SkewOptions}
                value={config.rowConfig.header.fontStyle.skew}
                onChange={(val) => {
                  updateConfig(
                    produce((state) => {
                      state.rowConfig.header.fontStyle.skew = val;
                    })
                  );
                }}
              />
            </JSettingItem>
          </div>
        </JSettingBox>
      </JCollapseBox>
    </>
  );
};

这里的代码就比较长了,因为是刚开始讲配置组件就来讲细一些🧐,后面再都是重复内容就可以省略了

首先介绍一下样式结构,这里出现了 JCollapseBox、JSettingBox、JSettingItem 组件,这三个只是起到布局作用不再粘贴源码了,它们三个的效果如下,一看就懂:

布局组件展示.png

层层包裹,最终在 JSettingItem 里添加我们对应的配置表单项

关于配置表单动态绑定逻辑我们单独拿出来一项来看:

<JSettingItem text="行高">
  <InputNumber
    className="w-full"
    placeholder="请输入"
    min={10}
    value={config.rowConfig.header.headerHeight}
    onChange={(val) => {
      updateConfig(
        produce((state) => {
          state.rowConfig.header.headerHeight = val!;
        })
      );
    }}
  />
 </JSettingItem>

无非就是正常的受控组件,通过总 config 配置找到对应的配置项,在 change 事件中调用 updateConfig 来更新总 config 配置,这里考虑到配置层级过深的问题,引入了 immer 中的 produce 来方便对配置进行修改

再补充一点在 JSettingBox 下包裹的一个 div 的类名是 "config-items-layout",这里是使用了原子化 CSS 的shortcuts 功能,在目录下的 unocss.config.ts 有进行配置:

import { defineConfig, presetUno } from "unocss";
export default defineConfig({
  presets: [presetUno()],
  shortcuts: {
    "config-items-layout": "grid grid-cols-2 gap-2",
  },
});

配置组件的逻辑就是这么多,都是一些受控表单罢了,后续就不再详细讲配置组件相关代码了

现在已经可以动态更改配置了,而在 table 组件中 header 也已经根据配置给 header 绑定了样式,row header 配置就完成了🤪:

header-config-show.gif

搭建 row body

关于 body 的搭建就涉及到渲染 table 数据了,我们知道正常 table 组件是按单元格划分:

table数据结构.png

很明显我们需要的是一个二维数组进行遍历渲染,而一开始的数据源长这样:

const tableData = [
  {
    date: "1",
    name: "Tom",
    address: "xx",
  },
  {
    date: "2",
    name: "Tom",
    address: "xx",
  },
  {
    date: "3",
    name: "Tom",
    address: "xx",
  }
];

因此必然需要我们手动转化,当然在本小节我们只关心 row body 中的配置,实际上这里的转化如果考虑到后面列字段映射问题以及索引列时还是有点麻烦的,这点等后面讲到再说

我们先将源数据转化为二维数组,在 TableScrollBoradComponent 组件中增加这段逻辑,并将转化后的结果一并传入抽离的 TableBody 组件中:

数据结构转换.png

const TableScrollBoradComponent = (props: TableComponentProps) => {
  const { config } = props;

  const headerTitles = useMemo(() => {
    const headers = config.columnConfig.showIndex ? [config.columnConfig.indexColumn.title] : [];
    config.columnConfig.columns.forEach((column) => {
      headers.push(column.title);
    });
    return headers;
  }, [config.columnConfig]);

  // new: 转换数据 
  const rowsData = useMemo(
    () =>
      new Array(config.dataset.length).fill(0).map((_, index) => {
        const rowData = config.dataset[index];
        return Object.entries(rowData).map((i) => i[1]);
      }),
    [config.dataset]
  );

  return (
    <div className="w-full h-full overflow-hidden">
      {config.rowConfig.header.show && <TableHeader headerTitles={headerTitles} config={config} />}
      <TableBody data={rowsData} config={config}  />
    </div>
  );
};
interface BodyComponentProps {
  data: string[][];
  config: ComponentConfigType;
  calcColumnWidth: (index: number) => string;
}

const TableBody = (props: BodyComponentProps) => {
  const { data, config } = props;
  return (
    <div className="flex flex-col" style={{ gap: `${config.rowConfig.body.rowSpacing}px` }}>
      {data.map((row, rowIndex) => (
        <div key={rowIndex} className="flex items-center justify-between">
          {row.map((ceil, ceilIndex) => (
            <div className="text-light-50" key={ceilIndex}>{ceil}</div>
          ))}
        </div>
      ))}
    </div>
  );
};

依旧先不考虑 column 对齐配置统一先用 flex 一把梭😇,来看当前效果:

body效果.png

接下来就是针对于 row body 的配置部分,字体配置与之前的 Header 部分一样,直接套用绑定样式到对应元素上即可:

const TableBody = (props: BodyComponentProps) => {
  const { data, config } = props;
  
    // new: 行数据文字样式
  const rowFontStyle = useMemo<React.CSSProperties>(() => {
    const fontStyle = config.rowConfig.body.fontStyle;
    return {
      fontSize: `${fontStyle.size}px`,
      fontWeight: `${fontStyle.weight}`,
      color: `${fontStyle.color}`,
      fontStyle: `${fontStyle.skew}`,
    };
  }, [config.rowConfig.body.fontStyle]);
  
  return (
    <div className="flex flex-col">
      {data.map((row, rowIndex) => (
        <div key={rowIndex} className="flex items-center justify-between">
          {row.map((ceil, ceilIndex) => (
             // new: 动态绑定 fontStyle
            <div  key={ceilIndex} style={{ ...rowFontStyle }}>
              {ceil}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
};

至于右边的配置栏我就不再重复粘贴代码了,和 Header 部分一样就是一些表单的动态绑定,具体效果如下:

body字体动态效果.gif

当然 row body 配置的重点不在于文字,关键在于可以设置行高、行数等内容,这些牵扯到一些计算,我们一点一点来看🧐

首先针对于行配置我们提供了两种策略:

  • 固定高度 => 允许用户定制行高
  • 固定行数 => 允许用户定制行数

所以这里的行高计算需要分这两种情况,我们实现一个 calcRowHeight 方法来进行处理:

function calcRowHeight(rowCount: number) {
  const { header, body } = config.rowConfig;
  // h 是图表组件高度(headerHeight + bodyHeight),在本 demo 中直接写死了数值
  const totalHeight = h - header.headerHeight;
  // 固定高度:直接使用配置的 rowHeight  固定行数:根据 bodyHeight 和 配置的 rowCount 计算平均值
  return body.isFixedHeight ? body.rowHeight : totalHeight / rowCount;
}

之后需要在组件内部定义状态来保存计算出的 heights 并保存数据源为 rows,这里的数据源依然先定义为 string[][] 类型,后续到实现滚动动画部分需要进行调整

注意这里数据源需要进行判断转换,也是考虑到滚动动画问题,类似于我们实现无缝轮播图需要在末尾增加第一张图片一样,原理都是类似的

const [bodyState, setBodyState] = useState<{
  rows: string[][];
  heights: number[];
}>(calcBodyState);

// 动态修改时 rowConfig 重新计算
useEffect(() => {
  setBodyState(calcBodyState);
}, [config.rowConfig]);

function calcBodyState() {
  const rowLength = data.length;
  const rowCount = config.rowConfig.body.rowCount;
  let newData = data;
  if (rowLength > rowCount && rowLength < 2 * rowCount) {
    // 配合后续滚动动画效果,需要扩充为双屏数据
    newData = [...newData, ...newData];
  }
  // 使用上面实现的 calcRowHeight 方法来计算行高
  const heights = new Array(data.length).fill(calcRowHeight(rowCount));

  return {
    rows: newData,
    heights,
  };
}

最后我们在修改 JSX 视图部分,使用 bodyState 代替之前的 data 遍历,同时补充行高样式:

const TableBody = (props: BodyComponentProps) => {
  const { data, config } = props;

  const [bodyState, setBodyState] = useState<{
    rows: string[][];
    heights: number[];
  }>(calcBodyState);

  useEffect(() => {
    setBodyState(calcBodyState);
  }, [config.rowConfig]);

  function calcRowHeight(rowCount: number) {
    // ...
  }

  function calcBodyState() {
   // ...
  }

  const rowFontStyle = useMemo<React.CSSProperties>(() => {
    // ...
  }, [config.rowConfig.body.fontStyle]);
  
  return (
    <div className="flex flex-col">
      {/* 遍历 bodyState.rows */}
      {bodyState.rows.map((row, rowIndex) => (
        <div
          key={rowIndex}
          className="flex items-center justify-between"
          // 使用计算后的行高设置高度样式
          style={{ height: `${bodyState.heights[rowIndex]}px` }}
        >
          {row.map((ceil, ceilIndex) => (
            <div key={ceilIndex} style={{ ...rowFontStyle }}>
              {ceil}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
};

关于配置组件的代码依旧省略,现在我们就实现了行数、行高的配置效果:

动态行高配置.gif

实现内容列配置

到此我们的行配置就完成了,现在来看列配置,列配置主要分为列宽度、对齐、内容字段映射这三个方面

首先来看列宽度,我们在配置项中针对于每一列都有设置对应的列宽度字段:

export type ColumnType = {
  title: string;
  columnWidth: number; // 列宽
  mapField: string;
  defaultValue: string;
};

针对于列宽度我们提供了两种配置方案:

  • 自适应列宽:计算总宽度,根据每列的宽度计算所占份额,使用 calc 函数设置对应的百分比宽度样式
  • 固定列宽:直接使用每列的宽度字段设置对应样式

由于这里要考虑 header、body 两部分的列宽,因此我们在它们的父组件增加计算每列宽度的逻辑,之后再以 props 的形式传入即可:

interface HeaderComponentProps {
  headerTitles: string[];
  columnWidths: string[]; // new
  config: ComponentConfigType;
}

interface BodyComponentProps {
  data: string[][];
  columnWidths: string[]; // new
  config: ComponentConfigType;
}

const TableScrollBoradComponent = (props: TableComponentProps) => {
  const { config } = props;

  const headerTitles = useMemo(() => {
    //...
  }, [config.columnConfig]);

  const rowsData = useMemo(
    () =>//...,
    [config.dataset]
  );
  
  // new
  const columnWidths = useMemo(() => {
    const columns = config.columnConfig.columns;
    const contentTotalWidth = columns.map((col) => col.columnWidth).reduce((prev, current) => prev + current, 0);
    // 根据两种不同配置策略来计算每列的宽度样式
    if (config.columnConfig.fixedWidth) {
      return columns.map((col) => `${col.columnWidth}px`);
    } else {
      return columns.map((col) => `calc(${((col.columnWidth / contentTotalWidth) * 100).toFixed(2)}%)`);
    }
  }, [config.columnConfig]);
    
  // TableHeader、TableBody 都传入对应的 columnWidths
  return (
    <div className="w-full h-full overflow-hidden">
      {config.rowConfig.header.show && (
        <TableHeader headerTitles={headerTitles} config={config} columnWidths={columnWidths} />
      )}
      <TableBody data={rowsData} config={config} columnWidths={columnWidths} />
    </div>
  );
};

再在 TableHeader、TableBody 组件中设置每列的宽度样式,现在可以把一开始每行的 flex justify-between 去掉了,除此之外还可以再补充列对齐方式样式,只需要设置对应的 textAlign 属性即可:

const TableHeader = memo((props: HeaderComponentProps) => {
  const { config, headerTitles, columnWidths } = props;

  const headerStyle = useMemo<React.CSSProperties>(() => {
    // ...
  }, [config.rowConfig.header]);

  return (
    // 移除 justify-between
    <div
      className="w-full flex items-center"
      style={{
        ...headerStyle,
      }}
    >
      {headerTitles.map((title, index) => (
        // 每列根据 columnWidths 设置对应宽度和对齐方式
        <div key={index} style={{ width: columnWidths[index],  textAlign: config.columnConfig.align }}>
          {title}
        </div>
      ))}
    </div>
  );
});


const TableBody = (props: BodyComponentProps) => {
  const { data, config, columnWidths } = props;

  const [bodyState, setBodyState] = useState<{
    rows: string[][];
    heights: number[];
  }>(calcBodyState);

  useEffect(() => {
    setBodyState(calcBodyState);
  }, [config.rowConfig]);

  function calcRowHeight(rowCount: number) {
    // ...
  }

  function calcBodyState() {
    // ...
  }

  const rowFontStyle = useMemo<React.CSSProperties>(() => {
    //...
  }, [config.rowConfig.body.fontStyle]);
  
  return (
    <div className="flex flex-col">
      {bodyState.rows.map((row, rowIndex) => (
        // 移除 justify-between
        <div
          key={rowIndex}
          className="flex items-center"
          style={{ height: `${bodyState.heights[rowIndex]}px` }}
        >
          {row.map((ceil, ceilIndex) => (
            // 每列根据 columnWidths 设置对应宽度和对齐方式
            <div key={ceilIndex} style={{ ...rowFontStyle, width: columnWidths[ceilIndex],  textAlign: config.columnConfig.align }}>
              {ceil}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
};

现在来看列宽配置和对齐效果:

列宽对齐效果.gif

下面我们来实现列字段映射的功能,最初我们是根据 dataset 数据源对象来计算出列表数据 rowsData,数据源对象属性顺序就是列顺序

但为了让用户更方便定制我们希望能实现这样的功能:用户可以配置每列的 mapField 字段,该字段会对应数据源的对象属性,而其列内容会与对应的对象属性值匹配,也就是下面这样的效果:

字段映射效果.gif

在 TableScrollBoradComponent 中我们修改之前的 rowsData 计算过程,如下:


// TableScrollBoradComponent 内部

// before
// const rowsData = useMemo(
//   () =>
//     new Array(config.dataset.length).fill(0).map((_, index) => {
//       const rowData = config.dataset[index];
//       return Object.entries(rowData).map((i) => i[1]);
//     }),
//   [config.dataset]
// );

// new
const rowsData = useMemo(() => {
  const columns = config.columnConfig.columns;
  return new Array(config.dataset.length).fill(0).map((_, index) => {
    // 获取数据源的行数据对象
    const rowData = config.dataset[index];
    return columns.map((item) => {
      // 根据配置列判断每一项的 mapFiled 与行数据对象属性进行匹配,未匹配就使用默认值
      return rowData[item.mapField] || item.defaultValue;
    });
  });
}, [config.columnConfig]);

现在就能够实现根据配置列 columns 的 mapFiled 来重新计算数据,注意要增加 TableBody 中 useEffect 的依赖项,保证 columns 发生改变时也要重新计算 State

// TableBody 内部
useEffect(() => {
  setBodyState(calcBodyState);
  // new
}, [config.rowConfig, config.columnConfig]);

到此我们就实现了上面动图的字段映射效果,内容列的配置就到此为止了🤪

实现索引列配置

索引列本质上还是属于列的范畴,所以它只会在列配置的基础上增加额外的判断来特殊处理,从一开始计算 rowsData 开始,我们需要判断是否展示索引列来增加内容:

// TableScrollBoradComponent 组件
const rowsData = useMemo(() => {
  const columnConfig = config.columnConfig;
  const columns = config.columnConfig.columns;
  let startIndex = columnConfig.indexColumn.startIndex;
  return new Array(config.dataset.length).fill(0).map((_, index) => {
    const rowData = config.dataset[index];
    // 针对于每一行的列内容增加判断是否显示索引列,索引列内容按照配置的 startIndex 值依次递增
    const row = columnConfig.showIndex ? [`${startIndex++}`] : [];
    return [...row, ...columns.map((item) => rowData[item.mapField] || item.defaultValue)];
  });
}, [config.columnConfig]);

除此之外还要对列宽度计算增加额外的判断,因为索引列的宽度都是独立于内容列配置的,所以它需要单独计算处理:

// TableScrollBoradComponent 组件
const columnWidths = useMemo(() => {
  const columns = config.columnConfig.columns;
  // new
  const indexColumn = config.columnConfig.indexColumn;
  const showIndex = config.columnConfig.showIndex;
  const contentTotalWidth = columns.map((col) => col.columnWidth).reduce((prev, current) => prev + current, 0);
  // 总宽度需要考虑到索引列宽
  const totalWidth = showIndex ? contentTotalWidth + indexColumn.columnWidth : contentTotalWidth;
    
  if (config.columnConfig.fixedWidth) {
    const contentColumnWidths = columns.map((col) => `${col.columnWidth}px`);
    // 固定列宽,增加判断索引列宽度逻辑
    return showIndex ? [`${indexColumn.columnWidth}px`, ...contentColumnWidths] : contentColumnWidths;
  } else {
    const contentColumnWidths = columns.map((col) => `calc(${((col.columnWidth / totalWidth) * 100).toFixed(2)}%)`);
    // 自适应列宽,索引列宽单独计算
    return showIndex
      ? [`calc(${((indexColumn.columnWidth / totalWidth) * 100).toFixed(2)}%)`, ...contentColumnWidths]
      : contentColumnWidths;
  }
}, [config.columnConfig]);

至于 header 部分索引列计算实际上在一开始我们实现 header 时就已经考虑到了:

headertitle计算.png

现在我们的索引列配置就完成了,来看效果🧐:

索引列效果.gif

实现轮播动画效果

下面就是我们整个组件最核心的部分了,既然是轮播列表肯定得有轮播效果吧😋

一开始我的思路就跟实现轮播图一样,通过定时器不断设置列表容器的 transform 并添加 transition 过渡来实现轮播的动画效果,当然要考虑到无缝轮播的问题,所以在 TableBody 内计算 rows state 时有这样的操作:

function calcBodyState() {
  const rowLength = data.length;
  const rowCount = config.rowConfig.body.rowCount;
  let newData = data;
  // 配合实现无缝轮播,增加数据渲染视图,多出列表容器的数据进行隐藏
  if (rowLength > rowCount && rowLength < 2 * rowCount) {
    newData = [...newData, ...newData];
  }
  const heights = new Array(data.length).fill(calcRowHeight(rowCount));

  return {
    rows: newData,
    heights,
  };
}

正当我准备去敲代码时好奇去扒了一下 DataV 中实现轮播效果的源码,发现它的实现逻辑还真不太一样,这里我们具体来看它的实现方案🤔

整个动画过程主要在 TableBody 组件,首先需要定义一些变量来存储动画相关的状态,因为这里的状态并不随着视图更新进行变化,所以我们使用 useRef:

// 滚动动画状态
const animationRef = useRef({
  rowCount: 0, // 行数
  rowsData: [] as RowType[], // 行数据
  rowHeight: 0, // 行高
  updater: 0, // 更新 updater(后续讲解)
  animationIndex: 0, // 当前索引(类比轮播图的索引维护)
  animationTimer: 0, // 动画定时器
});

注意这里的 rowsData 数据结构就要发生更改了,之前没有动画时我们直接使用的是 string[][] 二维数组,而现在除了内容以外还需要设置对应的索引和 key 值,因此它的数据结构如下:

type RowType = {
  ceils: string[]; // 一行中每列的内容
  rowIndex: number; // 行索引(后续区分奇偶行使用)
  scrollKey: number; // 作为 jsx 循环遍历的 key,代替之前的 index
};

由于数据结构发生改变,我们就需要修改之前计算 rows 的流程,在计算过程中还需要初始化 ref 变量中的状态:

const [bodyState, setBodyState] = useState<{
  rows: RowType[];
  heights: number[];
}>(calcBodyState);

function calcBodyState() {
  const rowLength = data.length;
  const rowCount = config.rowConfig.body.rowCount;
  // new
  let newData = data.map((i, index) => ({ ceils: i, rowIndex: index }));
  if (rowLength > rowCount && rowLength < 2 * rowCount) {
    newData = [...newData, ...newData];
  }
  const scrollData = newData.map((i, index) => ({ ...i, scrollKey: index }));
  const heights = new Array(data.length).fill(calcRowHeight(config.rowConfig.body.rowCount));
  // new
  animationRef.current.rowsData = scrollData;
  animationRef.current.rowHeight = heights[0];
  animationRef.current.rowCount = rowCount;
  return {
    rows: scrollData,
    heights,
  };
}

有了这些就要考虑如何实现轮播动画了,我们封装一个 startAnimation 函数封装整个轮播动画逻辑,且内部也会创建定时器重新执行该函数实现无限轮播

之所以说 DataV 的轮播实现不太常规是因为它并没有像轮播图一样设置通过 transform 或者 position 的方式来实现 item 的滚动,而是设置 item 的高度为 0 和 overflow 为 hidden,最后添加对应的 transition,营造出 item 滚出列表视图的效果,比如下面的 demo:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      div {
        box-sizing: border-box;
      }
      .list {
        width: 700px;
        height: 600px;
        border: 1px solid red;
        margin: 100px auto;
      }
      .item {
        width: 100%;
        height: 100px;
        border: 1px solid #000;
        font-size: 30px;
        font-weight: 700;
        text-align: center;
        line-height: 100px;
        transition: all 0.3s ease-in-out;
        overflow: hidden;
      }
    </style>
  </head>
  <body>
    <div class="list">
      <div class="item">1</div>
      <div class="item">2</div>
      <div class="item">3</div>
      <div class="item">4</div>
      <div class="item">5</div>
      <div class="item">6</div>
    </div>
    <script>
      const items = document.querySelectorAll(".item");
      let index = 1;
      items.forEach((item) => {
        setTimeout(() => {
          item.style.height = "0px";
          item.style.border = "none";
        }, index++ * 1500);
      });
    </script>
  </body>
</html>

轮播效果.gif

对吧,看着真的就跟每一项滚动出列表一样,实际上只是把每一项的高度挤压为 0 罢了😆

当然如果应用到我们组件当中考虑的事情就比较多了,这里直接放整体的实现,详细看注释:

const { config } = props;
// 获取用户动画配置项
const { animationTime, animationStyle, hoverStop } = config.publicConfig;
  
useEffect(() => {
  startAnimation(true); // 初次开启动画
}, []);

async function startAnimation(start = false) {
  let { animationIndex } = animationRef.current;
  const { rowCount, rowHeight, rowsData } = animationRef.current;

  // 用户配置关闭动画则直接 return
  if (animationStyle === "none") return;

  // 判断是否为第一次启动动画根据配置的动画时间进行阻塞,否则第一项 item 会立即执行动画被跳过
  if (start) {
    await new Promise((resolve) => setTimeout(resolve, animationTime * 1000));
  }

  // 进行截取移动操作,将将要轮播的 item 数据移动至数组后面
  let rows = rowsData.slice(animationIndex);
  rows.push(...rowsData.slice(0, animationIndex));
  rows = rows.slice(0, animationStyle === "row" ? rowCount + 1 : rowCount * 2);

  const rowLength = rowsData.length;
  const heights = new Array(rowLength).fill(rowHeight);

  // 根据用户配置的 animationStyle 来区分是单行轮播还是整页轮播,获取轮播 item 数量
  const animationNum = animationStyle === "row" ? 1 : rowCount;
  // 将待轮播的 item 的高度全部设置为 0
  heights.splice(0, animationNum, ...new Array(animationNum).fill(0));
  // 更新状态,触发 item 的过渡动画
  setBodyState((pre) => ({ ...pre, rows, heights }));

  // 更新 ref index 和开启定时器
  animationIndex += animationNum;
  // 考虑无限轮播,如果索引递增超出源数据长度则返回至起点
  const back = animationIndex - rowLength;
  if (back >= 0) animationIndex = back;
  animationRef.current.animationIndex = animationIndex;
  animationRef.current.animationTimer = setTimeout(startAnimation, animationTime * 1000 + 300);
}

可以看到这时候轮播效果就出来了,且还是无限轮播😎:

轮播效果2.gif

当然这里肯定会有疑问,比如这里的置换操作是什么意思,我们直接根据上面展示的 demo 数据走一个动画函数执行流程来看看就清楚了🧐:

假定初次渲染时:dataSource: [1, 2, 3, 4, 5, 6, 7, 8]、rowCount: 3、animationIndex: 0

由于 rowLength > rowCount && rowLength < 2 * rowCountfalse,所以 rowsData 是与 dataSource一样:[1, 2, 3, 4, 5, 6, 7, 8]

首次执行 startAnimation 函数:

第一步:先阻塞 animationTime 时间再开始执行轮播动画流程(首次执行时保证第一项有延迟时间)

第二步:进行截取移动操作

let rows = rowsData.slice(animationIndex); 
// rows: [1, 2, 3, 4, 5, 6, 7, 8]
rows.push(...rowsData.slice(0, animationIndex)); 
// rows: [1, 2, 3, 4, 5, 6, 7, 8]
rows = rows.slice(0, animationStyle === "row" ? rowCount + 1 : rowCount * 2);
// rows: [1, 2, 3, 4]

看着好像没什么逻辑🤔,因为这里的 animationIndex 初始为 0,以单行轮播为例,最终在第三次 slice 只截取出要展示到视图上的 4 条数据

为什么这里是截取 rowCount + 1 而不是 rowCount 呢?因为第一项轮播出去之后要保证末尾的数据会跟上,起到一个衔接作用,否则滚出轮播后界面上显示就会少一条

第三步:完成 heights 计算,因为初始化时数据 item 都是固定的高度,现在由于添加过渡动画会根据要轮播的数据 item 设置其高度为 0,其他数据 item 的高度保持不变,所以就有了下面这段代码:

heights.splice(0, animationNum, ...new Array(animationNum).fill(0));

第四步:rows 和 heights 都计算完成,执行 setState 进行视图渲染,视图展示过渡动画

第五步:更新 animationIndex 和 animationTimer,详细见注释

之后定时器重新执行 startAnimation 函数,与初次渲染不同的是不再会先进行阻塞,且 animationIndex++ 变为 1,因此流程如下:

第一步:进行截取移动操作

let rows = rowsData.slice(animationIndex); 
// rows: [2, 3, 4, 5, 6, 7, 8] ❗
rows.push(...rowsData.slice(0, animationIndex)); 
// rows: [2, 3, 4, 5, 6, 7, 8, 1] ❗
rows = rows.slice(0, animationStyle === "row" ? rowCount + 1 : rowCount * 2);
// rows: [2, 3, 4, 5] ❗

可以看到这次的截取移动操作由于 animationIndex 为 1,所以前两步操作相当于把之前已经轮播出去的第一项移动到了末尾,第三步依旧根据 rowCount 进行截取

看到这样的结果后我们就能明白所谓的截取移动操作就是将数组中已轮播出去的 item 移动至末尾,紧接着从头开始截取新一轮要展示到视图上的数据,后续轮播时会再次利用放置末尾的数据,实现无限轮播

第二步:完成 heights 计算

第三步:rows 和 heights 都计算完成,执行 setState 进行视图渲染,视图展示过渡动画

第四步:更新 animationIndex 和 animationTimer

本轮 over,继续反复执行 startAnimation 函数

到此我们就实现了基本的轮播列表效果,但并没有结束,还记得我们一开始定义的 ref 中有这样一个变量吗?

// 滚动动画状态
const animationRef = useRef({
  //...
  updater: 0, // 更新 updater
});

这玩意儿有什么用我们还没有讲,实际上就是跟动态配置更新有关

因为我们要实现动态配置,所以每当我们修改一个配置项都要取消掉上次定时器重新执行动画,且修改之前的 useEffect,与配置进行关联

// before
// useEffect(() => {
//   startAnimation(true); // 初次开启动画
// }, []);

// new
useEffect(() => {
  setBodyState(calcBodyState);
  reAnimation();
}, [config.rowConfig, config.columnConfig]);

function stopAnimation() {
  animationRef.current.animationTimer && clearTimeout(animationRef.current.animationTimer);
}

function reAnimation() {
  stopAnimation();
  startAnimation(true);
}

但这样真的能实现动画的重新启动吗?🤔我们来看效果:

定时器取消问题.gif 我们不断修改字体大小会重复触发 reAnimation 函数,按照流程来说确实应该取消上次定时器再重新执行轮播动画,但是并没有

原因很简单,在 startAnimation 中我们有这样的操作导致其成为异步函数:

// 判断是否为第一次启动动画根据配置的动画时间进行阻塞,否则第一项 item 会立即执行动画被跳过
if (start) {
  await new Promise((resolve) => setTimeout(resolve, animationTime * 1000));
}

因此频繁执行 reAnimation 并不会立即清除上次轮播动画的定时器,因为可能上轮还在阻塞还没开启定时器呢,而阻塞之后又开启了新的定时器,多个定时器累积最终造成上图的效果🧐

所以我们才需要额外的 updater 变量来对其进行控制,我们修改 stopAnimation 的实现,引入 updater 变量:

function stopAnimation() {
  const { updater, animationTimer } = animationRef.current;
  animationRef.current.updater = (updater + 1) % 999999;
  animationTimer && clearTimeout(animationTimer);
}

每当我们修改配置即执行 stopAnimation 时修改 ref 中的 updater 值,之后在 startAnimation 中首次阻塞逻辑中增加额外判断:

const { updater } = animationRef.current;

if (start) {
  await new Promise((resolve) => setTimeout(resolve, animationTime * 1000));
  // new
  if (updater !== animationRef.current.updater) {
    return;
  }
}

上面的 updater 变量形成了闭包,我们比对闭包变量 updater 与 animationRef.current.updater,如果两者不相等,说明是在阻塞过程中修改了 updater,不应该在执行下面轮播动画逻辑,直至最后一次修改配置动作才会使得 updater 与 animationRef.current.updater 相等,大体逻辑类似于防抖操作,我们来看效果:

动画清除问题解决.gif

到此轮播列表的滚动效果就全部结束了,最后就剩下收尾工作,比如鼠标悬停暂停动画的配置,还有奇、偶行颜色配置等,这些都比较简单🤪

鼠标悬停无非是监听 mouseEnter 和 mouseLeave 来对动画启动和暂停进行控制,至于奇、偶行颜色直接使用我们 rowsData item 里的 rowIndex 字段区分即可,具体逻辑请看下面源代码,这里不再过多解释

End

源码链接:DrssXpro/table-scrollboard-demo: a table-scrollboard-component with low code (github.com)

以上仅仅是实现一个基本的数据轮播列表组件的 demo,具体使用还需要根据自己的业务场景需求进行扩展,当然也可能会有些错误望各位路过的佬可以指正😶

虽然总体来看并没有特别复杂的业务逻辑,但是要是没有参考样例自己从 0 到 1 实现出来还是需要费点劲的,比如这里的列配置、字段映射、动画效果等,总而言之前端的基本功还是要打扎实