一个小组件之List组件

874 阅读6分钟

前言

两周一个组件系列文章将会介绍一个个 mini 版的react组件的建造过程及其中的一些心得感悟, mini 版的react组件会参考社区中优秀的开源组件库,理解其组件的实现原理,然后吸收其中的精华应用于我的 mini 版react组件中,并将其实践出来(俗称造轮子)。主要想通过这种记录和分享的方式督促自己去了解学习那些优秀的组件库的实现,并通过造轮子的方式提高自己(个人认为造轮子是个很累但是收获颇多的过程)。

list组件

思考与准备

接下来让我们进入今天的主题:List组件的实现过程,实现这个组件的初衷是在项目开发的过程中有这样一个需求,有一堆按一定方向排列的元素,但容器空间不足时,能够支持滚动显示超出的内容或是左右箭头切换显示超出的内容。然后一番了解下来现有的组件库似乎没有特别合适的组件实现这个需求,而且在我看来实现这个功能好像也不是很复杂,那么既然可以自己写一个,为何不自己实现呢? 那就。。自己撸一个吧!

首先,上图片!先看看我们最终的实现效果:

Jul-18-2021 15-24-34.gif

Jul-18-2021 15-25-38.gif

Jul-18-2021 16-09-43.gif 而达到这样的效果就只需几行代码:

<NodeList direction="vertical">{renderButton()}
</NodeList>
<NodeList direction="horizonal">
{renderCard()}
</NodeList>
<NodeList direction="horizonal">
{renderCardWithResize()}
</NodeList>
  const renderButton = ()=>{
        const buttonTextArr = Array.from({length: 30},(v,k)=>`button${k+1}`)
        return buttonTextArr.map(item=><button className='demo-node-list-section-btn' key={item}>{item}</button>)
    }
    const renderCard = ()=>{
        const CardTextArr = Array.from({length: 30},(v,k)=>`Card${k+1}`)
        return CardTextArr.map(item=><div className='demo-node-list-section-card' key={item}>{item}</div>)

    }
    const renderCardWithResize =()=>{
        const CardTextArr = Array.from({length: 7},(v,k)=>`Card${k+1}`)
        return CardTextArr.map(item=><div className='demo-node-list-section-card' key={item}>{item}</div>)
    }

效果还不错!

接下来我们就看看如何实现这样的一个 mini 组件: 首先要清楚组件最终实现的功能,也就是明确目的,在这里我希望这个 mini 的 list 组件,能够作为一个容器,将其包裹的任意长内容变成可支持滚动和左右箭头切换,总结来说就是三个功能点:

  1. 支持滚动;
  2. 元素整屏切换:点击切换按钮时,实现整屏切换的效果
  3. 监听容器尺寸变化,空间足够完全展示时屏蔽滚动和切换;

而且作为一个容器,其内容的方向应该是可控的(即支持水平、垂直方向)。 明确了目标,接下来就是找出技术难点,思考可行的技术方案。在这个list组件中,如何实现内容切换和滚动效果是其中的难点,而实现这样的效果有三种可行的技术方案:

  1. 通过控制css样式的left(top)属性,实现切换和模拟滚动;
  2. 通过css3transform属性,实现切换和模拟滚动;
  3. 利用原生的scroll事件和scrollto方法实现; 最终在这三种方案中我比较倾向于方案2,相比于方案1,方案2采用的transform相比与left是具有更好的性能优势的,而方案3会更多的依赖于原生的方法,可定制化差些。

确定了方案,如何实现它呢?

实现过程

定义一个容器元素,存储其ref并取消其滚动:

.node-list {
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
}
 <div ref={nodesWrapperRef}></div>

定义一个子元素的容器(实际内容的容器,控制transform的目标元素)并存储其ref

     <div
          ref={nodeListRef}
          className={`${prefixCs}-content ${horizonal?``:`${prefixCs}-content-vertical`}`}
          style={{
            transform: `translate(${transformLeft}px, ${transformTop}px)`,
          }}
        >
      //children node
    </div>

处理容器组件的children属性(子元素,真正需要渲染的内容),这里我们稍微把这个处理子元素的方法包装下:

function parseTabList(children) {
  return children
    .map((node,index) => {
      if (React.isValidElement(node)) {
        const key = node.key !== undefined ? String(node.key) : index;
        return {
          key,
          ...node.props,
          node,
        };
      }
      return null;
    })
    .filter((node) => node);
}

通过这个方法,我们将子元素转成一个个包含子元素信息的对象,并将其存储在数组元素nodes中(出于健壮性的考虑,我们还在这里用 isValidElement 进行react element的校验)

接着将子元素渲染出来,用上刚刚构造的子元素对象:

    const nodeRender = nodes.map((node) => (
      <div
        key={`_${node.key}`}
        ref={refs(node.key)}
      >
        {node.node}
      </div>
    ));

这样我们就可以通过ref获取到真正意义上的子元素( dom ),即通过useRef创建对应的ref容器,不过在子元素数量不确定的情况下,我们可能需要采取一些策略生成这样的ref容器并保存:

 function useRefs() {
  const cacheRefs = useRef(new Map());

  function getRef(key) {
    if (!cacheRefs.current.has(key)) {
      cacheRefs.current.set(key, React.createRef());
    }
    return cacheRefs.current.get(key);
  }

  function removeRef(key) {
    cacheRefs.current.delete(key);
  }

  return [getRef, removeRef];
}

const [getNodeRef,removeNodeRef] = useRefs();

这里采用自定义的hook的方式生成一段独立可复用的代码是个不错的选择,而且还能一定程度上更贴近原来 useRef ,而多个ref的存储则采用Map类型,key为子元素点 key props

最后我们还应当渲染两个切换按钮的元素:

   <div ref={ref} className={optionLeftClass} onClick={onLeftClick} disabled >
        <span className={`${prefixCs}-operation-arrow`}></span>
      </div>
      <div ref={ref} className={optionRightClass} onClick={onRightClick} disabled >
        <span className={`${prefixCs}-operation-arrow`}></span>
      </div>

WechatIMG15.jpeg

基于我们的方案,我们需要获取许多元素的位置和大小的信息。

获取子元素的位置大小信息:

const [nodesSizes, setNodesSizes] = useState(new Map());
  setNodesSizes(() => {
      const newSizes = new Map();

      nodes.forEach(({ key }) => {
        const Node = getRefBykey(key).current;
        if (Node) {
          newSizes.set(key, {
            width: Node.offsetWidth,
            height: Node.offsetHeight,
            left: Node.offsetLeft,
            top: Node.offsetTop,
          });
        }
      });
      return newSizes;
    });

nodesSizes这个state里,包含了每个子元素的实际宽高(包括margin在内)和位置(在容器内lefttop的偏移量)

这里需要说明下,offsetWidth属性( The HTMLElement.offsetWidth read-only property returns the layout width of an element as an integer. )和 offsetHeight属性( The HTMLElement.offsetHeight read-only property returns the height of an element, including vertical padding and borders, as an integer. )

从介绍来看,这两个属性表示的范围只到border-box,这里因为我们对实际的子元素包裹了一层div元素,我们实际上获取的是div元素的offsetWidthoffsetHeight,因此能够获取到实际子元素的全部宽高(包括margin在内)。

获取可视区域的宽高:

    const offsetWidth = nodesWrapperRef.current?.offsetWidth || 0;
    const offsetHeight = nodesWrapperRef.current?.offsetHeight || 0;

但是可视区域除了子元素外还可能存在切换操作按钮,因此实际的可视区域宽高应当减去切换按钮的宽高(如果存在的话):

    setWrapperWidth(offsetWidth - (isOperationHidden ? 0 : newOperationWidth * 2));
    setWrapperHeight(offsetHeight - (isOperationHidden ? 0 : newOperationHeight * 2));

获取全部内容的宽高(即滚动区域):

const newWrapperScrollWidth = nodeListRef.current?.scrollWidth || 0;
const newWrapperScrollHeight = nodeListRef.current?.scrollHeight || 0;

我们需要获取的信息全部具备后,实现滚动的逻辑就不再困难了。

而在我们的transform方案中,滚动效果实际上是在控制transform属性的变化(根据排列方向的不同,控制transformXtransformY

监听元素的滚轮事件,并执行改变transform的逻辑:

 useTouchMove(nodesWrapperRef, (offsetX, offsetY) => {
    function doMove(setState, offset) {
      setState((value) => {
        const newValue = alignInRange(value + offset, transformMin, transformMax);

        return newValue;
      });
    }

    if (horizonal) {
      // Skip scroll if place is enough
      if (wrapperWidth >= wrapperScrollWidth) {
        return false;
      }

      doMove(setTransformLeft, offsetX);
    } else {
      if (wrapperHeight >= wrapperScrollHeight) {
        return false;
      }

      doMove(setTransformTop, offsetY);
    }

    // clearTouchMoving();

    return true;
  });

这里我们采用自定义hook方式将滚轮事件监听的逻辑包装在自定义hook useTouchMove里,内部代码不在这里贴出,其主要的功能就是监听外层容器的滚轮事件,并将滚动的距离和方向计算成对应的偏移量,作为doMove 方法的参数。

function doMove代码不难看出doMove方法就是将滚轮滚动的距离转换成相对应的transform属性值,不过这里我们也需要考虑一些边界情况,即:

  • 可视区域的宽高大于滚动区域的宽高时,即容器内容能够完全展示的情况下,不应当响应滚轮事件;
  • transform属性值的设置应当是有边界的,即存在最大值和最小值(对应元素滚动到达最顶部和最底部)所以这里我们在设置transform属性值是会先通过alignInRange函数的处理,其中主要是对最大值最小值的判断;
  if (!horizonal) {
    transformMin = Math.min(0, wrapperHeight - wrapperScrollHeight);
    transformMax = 0;
  } else {
    transformMin = Math.min(0, wrapperWidth - wrapperScrollWidth);
    transformMax = 0;
  }

transform属性最大值为0,最小值为可视区域宽(高)减去滚动区域宽(高),这里的最小值为负值,因为元素向左或向上的滚动,对应于transformXtransformY都为负值。

这样便实现了第一个目标支持滚动。

而实现操作按钮切换展示内容的功能,需要稍微复杂一些。

首先我们需要获取当前出现在可视区域内的元素(完全出现的元素,不包括只出现一部分),在我们已知当前的 transform 属性值和每个子元素的位置宽高信息以及可视区域宽高的前提下,实现起来是不困难的:

    const len = nodes.length;
    let endIndex = len - 1;
    for (let i = 0; i < len; i += 1) {
      const offset = tabOffsets.get(nodes[i].key) || DEFAULT_SIZE;
      const deltaOffset = offset[unit]
      if (offset[position] + deltaOffset > transformSize + basicSize) {
        endIndex = i - 1;
        break;
      }
    }

    let startIndex = 0;
    for (let i = len - 1; i >= 0; i -= 1) {
      const offset = tabOffsets.get(nodes[i].key) || DEFAULT_SIZE;
      if (offset[position] < transformSize) {
        startIndex = i + 1;
        break;
      }
    }

    return [startIndex, endIndex];

这里我们只分析水平排列(垂直排列同理),结合先前我们得到每个子元素的位置宽高信息,可以通过子元素 offsetleft + offsetwidth > transformSize + basicSize 判定子元素不在可视区域中,其中 transformSize 表示当前的transform值的绝对值,basicSize表示可视区域宽(高)。 并且通过子元素offsetLeft < transformSize,判定该子元素不在可视区域。

由此可以计算出可视区域内子元素在nodes的起始下标和结束下标,每次点击切换按钮时根据可视区域宽高和当前可视元素的下标范围,可以计算出每次切换应展示的新子元素范围,这里我们结合onNodeScroll方法实现切换:

  function onNodeScroll(key = activeKey, toTopOrLeft) {
    const nodeOffset = nodesOffset.get(key) || {
      width: 0,
      height: 0,
      left: 0,
      right: 0,
      top: 0,
    };

    if (horizonal) {
      // ============ Align with top & bottom ============
      let newTransform = transformLeft;
      //目标子元素 隐藏在左边
      // 计算新的transform后出现在最左边
      if (nodeOffset.left < -transformLeft) {
        newTransform = -nodeOffset.left;
      } 
      //目标子元素隐藏在右边,
      //计算出新的transform后出现在最右边
      else if (nodeOffset.left + nodeOffset.width > -transformLeft + wrapperWidth) {
        newTransform = -(nodeOffset.left + nodeOffset.width - wrapperWidth);
      }
      setTransformTop(0);
      setTransformLeft(alignInRange(newTransform, transformMin, transformMax));
    } 

以上便完成了我们的第二个功能点。

至于第三个功能点其实是最简单的,只需要监听resize事件,在事件回调中重新获取可视区域大小,滚动区域大小,实现起来相对简单,就不在这里赘述了。

以上便是本次分享的全部内容,在后续的文章中将会分享更多的内容。