乐趣区

关于javascript:两周一个小组件之List组件

前言

两周一个组件系列文章将会介绍一个个 mini 版的 react 组件的建造过程及其中的一些心得感悟,mini 版的 react 组件会参考社区中优良的开源组件库,了解其组件的实现原理,而后排汇其中的精髓利用于我的 mini 版 react 组件中,并将其实际进去(俗称造轮子)。次要想通过这种记录和分享的形式督促本人去理解学习那些优良的组件库的实现,并通过造轮子的形式进步本人(集体认为造轮子是个很累然而播种颇多的过程)。

list 组件

思考与筹备

接下来让咱们进入明天的主题:List 组件的实现过程,实现这个组件的初衷是在我的项目开发的过程中有这样一个需要,有一堆按肯定方向排列的元素,但容器空间有余时,可能反对滚动显示超出的内容或是左右箭头切换显示超出的内容。而后一番理解下来现有的组件库仿佛没有特地适合的组件实现这个需要,而且在我看来实现这个性能如同也不是很简单,那么既然能够本人写一个,为何不本人实现呢?
那就。。本人撸一个吧!

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


而达到这样的成果就只需几行代码:

<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. 通过 css3 transform 属性,实现切换和模仿滚动;
  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>

基于咱们的计划,咱们须要获取许多元素的地位和大小的信息。

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

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 在内)和地位(在容器内 left top 的偏移量)

这里须要阐明下, 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 元素的 offsetWidth offsetHeight ,因而可能获取到理论子元素的全副宽高(包含 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 属性的变动(依据排列方向的不同,管制 transformX transformY

监听元素的滚轮事件,并执行扭转 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,最小值为可视区域宽(高)减去滚动区域宽(高),这里的最小值为负值,因为元素向左或向上的滚动,对应于 transformX transformY 都为负值。

这样便实现了第一个指标反对滚动。

而实现操作按钮切换展现内容的性能,须要略微简单一些。

首先咱们须要获取以后呈现在可视区域内的元素(齐全呈现的元素,不包含只呈现一部分),在咱们已知以后的 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 事件,在事件回调中从新获取可视区域大小,滚动区域大小,实现起来绝对简略,就不在这里赘述了。

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

退出移动版