react组件库源码+ 单测解析(Affix 固钉组件)

1,430 阅读9分钟

前言

正式开始react组件库开发,目的是为了自己后续的低代码平台拥有对组件的绝对掌控力,所以前提必须拥有一套组件库。

之前写过一个form的实现,有兴趣的同学请看:# 实现一个比ant-design更好form组件,可用于生产环境!

还有多行文本省略号ant deisign处理原理

为了在短时间内开发出来,主要是借(chao)鉴(xi)参考了很多组件库,给大家简单普及一下,如果你也想开发react组件库,如何在短时间内找到自己满意的借(chao)鉴(xi)对象。

国内react组件库推荐(针对PC端):Tdesign(腾讯),arco design(字节)

这时候肯定有人问了,为啥不是老牌的ant deisgn,而且字节还有一套组件库叫semi design(我也贡献过arco和semi的源码,不多,主要是参考源码的时候发现了bug。。。哈哈)

首先, ant design是在本身内部的rc-xx组件库的基础上,二次封装的,所以学习的时候相当于多了一倍的工作量,这是不考虑的原因,semi design主要是采用的calss语法,个人感觉hooks是react官方未来的发力点,所以也不考虑。

首选建议是Tdesign,因为他们的组件库了解的成本比较低(有些功能不全,相比ant design),但恰恰是我们可以入手的原因,你在看懂Tdesign的基础上,看别的组件库,就会轻松非常多,组件很多原理是一样的。

对了,并列首选的是国外的Chakra UI和Material UI,原因是有很多b端经验不多的同学,总以为组件库就是ant design那样,国外组件库的构建思路和不同的组件非常值得学习,而且难度不高。(比如table,他们的功能还不够覆盖ant deisgn table的百分之40呢)

其次推荐arco design可以简单理解为ant deisgn的翻版,它省去了不少低版本浏览器的兼容性(毕竟现在开发b端的新项目谁还兼容ie呢),而且api基本跟ant是一致的,但是代码质量我不是很觉得那个啥。

组件展示的基本原理

如下图,这是一个按钮(内容为"固钉"),我希望在离屏幕150px的时候固定住它。

image.png

这里我们不考虑按钮在另一个容器的情况,简单说下原理。

首先,元素本身是在文档流的,然后固定住,就是position变为fixed了,所以我们只要在浏览器滚动的时候,监听onScroll事件,并且在事件里判断,是否按钮的getBoundingClientRect()中(getBoundingClientRect()用于获得页面中某个元素的左,上,右和下分别相对浏览器视窗的位置),是否top小于等于150px。

如果是的话,就把按钮的style属性变为position:fixed,然后top: 150px即可

然后,如果你采用了fixed定位,那么元素就脱离文档流了,所以我们需要加一个元素,宽高等于按钮的宽高,插入到按钮原来的位置,撑开文档流。等发现元素top大于150px的时候,再把这个元素删除(dom api 删除和添加元素)。

我们先用这个思路实现一版,最后考虑按钮如果在另一个能滚动的容器里该怎么办。

代码实现

首先我们看下dom结构

const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
  const {
    children,
    className,
  } = props;

  const affixRef = useRef<HTMLDivElement>(null);
  const affixWrapRef = useRef<HTMLDivElement>(null);

  return (
    <div ref={affixWrapRef} className={className} style={style}>
      <div ref={affixRef}>{children}</div>
    </div>
  );
});

其中affixWrapRef是用来使用getBoundingClientRect()来获取到浏览器窗口顶部的top值,也用来添加占位元素,直接使用

affixWrapRef.current.appendChild(占位元素)

affixRef是用来改变定位的,类似

// 定位
affixRef.current.className = 固定的class,比如position:fixed;
affixRef.current.style.top = 固定的top;

好了,接着我们加入监听的代码,首次监听一定是在useEffect里

const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
  const {
    children,
    zIndex, // 固钉定位层级,样式默认为 500
    container, // 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body
    offsetBottom, // 距离容器顶部达到指定距离后触发固定
    offsetTop, // 距离容器底部达到指定距离后触发固定
    className,
    style,
    onFixedChange, // (affixed: boolean, context: { top: number }) => void 固定状态发生变化时触发
  } = props;

  
  const affixRef = useRef<HTMLDivElement>(null);
  const affixWrapRef = useRef<HTMLDivElement>(null);
  // 占位符的ref,用来创建占位的dom元素
  const placeholderEL = useRef<HTMLElement>(null);
  // 滚动容器的ref,默认是window
  const scrollContainer = useRef<ScrollContainerElement>(Window);

  // 它是用来处理滚动时,判断是否需要固定组件的函数
  const handleScroll = useCallback(() => {
       // xxx 后面会讲这里的逻辑
  }, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]);

  useImperativeHandle(ref, () => ({
    handleScroll,
  }));

  useEffect(() => {
    // 创建占位节点
    placeholderEL.current = document.createElement('div');
  }, []);

  useEffect(() => {
    // 这里可以看到首次加载Affix组件,会执行一下handleScroll,它是用来处理滚动时,判断是否需要固定组件的函数
    if (scrollContainer.current) {
      handleScroll();
      scrollContainer.current.addEventListener('scroll', handleScroll);
      window.addEventListener('resize', handleScroll);

      return () => {
        scrollContainer.current.removeEventListener('scroll', handleScroll);
        window.removeEventListener('resize', handleScroll);
      };
    }
  }, [container, handleScroll]);

  return (
    <div ref={affixWrapRef} className={className} style={style}>
      <div ref={affixRef}>{children}</div>
    </div>
  );
});

这里就有一个问题了,为啥在绑定scroll事件的时候,要提前调用一下handleScroll方法呢,因为可能首次加载就满足元素被固定的条件了,比如距离浏览器顶部150px的时候固定,首次加载完Affix组建后就正好是150px。

组件最核心的handleScroll逻辑

const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
  const {
    children,
    zIndex, // 固钉定位层级,样式默认为 500
    container, // 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body
    offsetBottom, // 距离容器底部达到指定距离后触发固定
    offsetTop, // 距离容器顶部达到指定距离后触发固定
    className,
    style,
    onFixedChange, // (affixed: boolean, context: { top: number }) => void 固定状态发生变化时触发
  } = props;

  const { classPrefix } = useConfig();

  const affixRef = useRef<HTMLDivElement>(null);
  const affixWrapRef = useRef<HTMLDivElement>(null);
  const placeholderEL = useRef<HTMLElement>(null);
  const scrollContainer = useRef<ScrollContainerElement>(null);

  const ticking = useRef(false);

  const handleScroll = useCallback(() => {
    if (!ticking.current) {
      window.requestAnimationFrame(() => {
        // top 是固定包裹元素affixWrapRef到浏览器视口顶部的距离,不包括margin
        // width是元素的宽,不包含margin
        // height是元素的搞,不包含margin
        const {
          top: wrapToTop = 0,
          width: wrapWidth = 0,
          height: wrapHeight = 0,
        } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 };


        const calcTop = wrapToTop  节点顶部到 container 顶部的距离
        // 整个视口的高减去元素的高
        const containerHeight =
          scrollContainer.current['innerHeight'] -
          wrapHeight; 

        const calcBottom = containerHeight  - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值 

        // 这里是固定的关键代码,fixedTop表示是否此时处于固定状态
        let fixedTop: number | false;
        // offsetTop,也就是外部传入的,我想元素距离浏览器顶部多远的时候固定
        // 当offsetTop存在,并且calcTop(节点顶部到 container 顶部的距离)小于我们设置的offsetTop
        // 这时候就需要固定
        if (offsetTop !== undefined && calcTop <= offsetTop) {
          // top 的触发
          fixedTop = containerToTop + offsetTop;
          
          // 下面这一行if判断的意思是,我们外部传入offsetBottom = 20的话
          // 就是希望在元素距离浏览器视口顶部20px的时候固定住
          // 所以wrapToTop,也就是元素距离浏览器视口顶部的距离,大于calcBottom的时候,
          // calcBottom是指浏览器视口的高度减去元素本身的高度,再减去offsetBottom,此时,就是元素到浏览器视口顶部剩余的高度了
          // 剩余的高度如果wrapToTop比它还大,那肯定就要固定住了呗
        } else if (offsetBottom !== undefined && wrapToTop >= calcBottom) {
          // bottom 的触发
          fixedTop = calcBottom;
        } else {
          fixedTop = false;
        }

        // 这里是处理固定时加入position: fixed的代码
        // 以及在fixed时候插入占位元素的
        if (affixRef.current) {
          // 判断当前是否需要固定状态
          const affixed = fixedTop !== false;
          // 判断此时是否已经把占位元素插入进去了
          const placeholderStatus = affixWrapRef.current.contains(placeholderEL.current);
          
          // 如果当前需要处于固定状态
          if (affixed) {
            // 定位,这里的className主要就是position:fixed
            affixRef.current.className = `${classPrefix}-affix`;
            affixRef.current.style.top = `${fixedTop}px`;
            affixRef.current.style.width = `${wrapWidth}px`;
            affixRef.current.style.height = `${wrapHeight}px`;

            // 设置z-Index
            if (zIndex) {
              affixRef.current.style.zIndex = `${zIndex}`;
            }

            // 插入占位节点
            if (!placeholderStatus) {
              placeholderEL.current.style.width = `${wrapWidth}px`;
              placeholderEL.current.style.height = `${wrapHeight}px`;
              affixWrapRef.current.appendChild(placeholderEL.current);
            }
          } else {
            affixRef.current.removeAttribute('class');
            affixRef.current.removeAttribute('style');

            // 删除占位节点
            placeholderStatus && placeholderEL.current.remove();
          }
          
          // 触发onFixedChange,这里其实腾讯的T-deisgn实现的有问题,可以去提pr了,因为它应该判断当前fiexd的值是否跟上一次的不一样,那么说明fixed的情况发生变化了
          if (isFunction(onFixedChange)) {
            onFixedChange(affixed, { top: +fixedTop });
          }
        }

        ticking.current = false;
      });
    }
    ticking.current = true;
  }, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]);

  useImperativeHandle(ref, () => ({
    handleScroll,
  }));

  useEffect(() => {
    // 创建占位节点
    placeholderEL.current = document.createElement('div');
  }, []);

  useEffect(() => {
    scrollContainer.current = getScrollContainer(container);
    if (scrollContainer.current) {
      handleScroll();
      scrollContainer.current.addEventListener('scroll', handleScroll);
      window.addEventListener('resize', handleScroll);

      return () => {
        scrollContainer.current.removeEventListener('scroll', handleScroll);
        window.removeEventListener('resize', handleScroll);
      };
    }
  }, [container, handleScroll]);

  return (
    <div ref={affixWrapRef} className={className} style={style}>
      <div ref={affixRef}>{children}</div>
    </div>
  );
});

Affix.displayName = 'Affix';
Affix.defaultProps = affixDefaultProps;

export default Affix;

最后,我们把容器如果不是Window的情况处理一下,在handleScroll函数中

const {
          top: wrapToTop = 0,
          width: wrapWidth = 0,
          height: wrapHeight = 0,
        } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 };

        // 这里加入containerToTop,表示固定元素外部容器,距离浏览器顶部的高度
        // 因为固定元素是在这个容器里被固定,所以只能获取到
        let containerToTop = 0;
        if (scrollContainer.current instanceof HTMLElement) {
          containerToTop = scrollContainer.current.getBoundingClientRect().top;
        }

        // 这里需要你思考一下为啥有了容器,距离顶部的距离就是 wrapToTop - containerToTop
        const calcTop = wrapToTop - containerToTop; // 节点顶部到 container 顶部的距离
        // 如果是有容器的情况,就不能用innerHeight API了,只有window才有,所以可以用clientHeight来得到容器的高度
        const containerHeight =
          scrollContainer.current[scrollContainer.current instanceof Window ? 'innerHeight' : 'clientHeight'] -
          wrapHeight;
        
        // 这里其实很简单,原来我们用  containerHeight - (offsetBottom ?? 0)获取到容器是Window的情况
        // 现在改为其他容器,是不是只要加上containerToTop,也就是容器到浏览器视口顶部的高度就行了,哈哈
  
        const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值

上面讲完了代码,中场休息一下,我们接着讲一下单测怎么写。

image.png

首先,我们测试,

import React from 'react';
// 这里的render理解为test/library的render即可,就是渲染组件的函数
// describe是编写单测的函数,意思是我要把单测内容分组,比如这里这个组就是 Affix 组件测试
// vi可以理解为jest函数,拥有比如vi.fn -> jest.fn , vi.spyOn -> jest.spyOn等函数,功能也是一致的
import { render, describe, vi } from '@test/utils';
// 这里获取到我们之前写的Affix组件
import Affix from '../index';

describe('Affix 组件测试', () => {
  // 这里就是把Html的getBoundingClientRect函数模拟了一下
  const mockFn = vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect');
  const mockScrollTo = async (top: number) => {
    // mockImplementation函数就是具体模拟为什么,参数就是具体模拟的函数
    // 我们可以看到,我们模拟为 一个返回值为对象的函数,对象有 top: 传入值,bottom:0的key和value
    // 为什么需要这个模拟函数呢,是因为我们判断是否固定组件的一个很重要的依据就是getBoundingClientRect的top值
    mockFn.mockImplementation(
      () =>
        ({
          top,
          bottom: 0,
        } as DOMRect),
    );
  };
  // 我们在test之前,先把getBoundingClientRect的值。模拟为0,也就是top: 0
  beforeEach(async () => {
    await mockScrollTo(0);
  });
  test('render perfectly', async () => {
    const { queryByText } = render(
      <Affix>
        <div>固钉</div>
      </Affix>,
    );

    // 意思是获取到固定元素,然后存在于document中
    expect(queryByText('固钉')).toBeInTheDocument();
  })
});

接着我们假设offsetTop刚开始等于-1,没有固定,然后改为-10就固定住了

  test('offsetTop and onFixedChange', async () => {
    // 这里我们mock了一个函数,用来模拟在Affix触发onScroll事件的时候触发的函数
    const onFixedChangeMock = vi.fn();

    const { getByText } = render(
      <Affix offsetTop={-1} onFixedChange={onFixedChangeMock} zIndex={2}>
        <div>固钉</div>
      </Affix>,
    );
    
    // 此时因为offsetTop没有到-1,所以expect(getByText('固钉').parentNode).not.toHaveClass('t-affix') 是对的
    // 这个class类名出现是fixed的标志
    expect(onFixedChangeMock).toBeCalledTimes(0);
    expect(getByText('固钉').parentNode).not.toHaveClass('t-affix');

    此时掉一下mockScrollTo,把top变为-10,所以fixed的class就应该出现了
    await mockScrollTo(-10);

    setTimeout(() => {
      expect(onFixedChangeMock).toHaveBeenCalledTimes(1);
      expect(getByText('固钉').parentNode).toHaveClass('t-affix');
   
    }, 20);
  });

这里其实测试代码写的有点问题,就是没办法模拟scroll事件,这个谁有思路欢迎指点一下,谢谢了。