组件库设计实战系列:复杂组件设计

一个成熟的组件库一般都由数十个经常使用的 UI 组件构成,这其中既有按钮(Button),输入框(Input)等基础组件,也有表格(Table),日期选择器(DatePicker),轮播(Carousel)等自成一体的复杂组件。前端

这里咱们提出一个组件复杂度的概念,一个组件复杂度的主要来源就是其自身的状态,即组件自身须要维护多少个不依赖于外部输入的状态。参考原先文章中提到过的木偶组件(dumb component)与智能组件(smart component),两者的区别就是是否须要在组件内部维护不依赖于外部输入的状态。node

实战案例 - 轮播组件

在本篇文章中,咱们将以轮播(Carousel)组件为例,一步一步还原如何实现一个交互流畅的轮播组件。react

最简单的轮播组件

抛去全部复杂的功能,轮播组件的实质,实际上就是在一个固定区域实现不一样元素之间的切换。在明确了这点后,咱们就能够设计轮播组件的基础 DOM 结构为:git

<Frame>
  <SlideList>
    <SlideItem />
    ...
    <SlideItem />
  </SlideList>
</Frame>复制代码

以下图所示:github

carousel

Frame 即轮播组件的真实显示区域,其宽高为内部由使用者输入的 SlideItem 决定。这里须要注意的一点是须要设置 Frameoverflow 属性为 hidden,即隐藏超出其自己宽高的部分,每次只显示一个 SlideItem后端

SlideList 为轮播组件的轨道容器,改变其 translateX 的值便可实如今轨道的滑动,以显示不一样的轮播元素。数组

SlideItem 是使用者输入的轮播元素的一层抽象,内部能够是 imgdiv 等 DOM 元素,并不影响轮播组件自己的逻辑。浏览器

实现轮播元素以前的切换

为了实如今不一样 SlideItem 之间的切换,咱们须要定义轮播组件的第一个内部状态,即 currentIndex,即当前显示轮播元素的 index 值。上文中咱们提到了改变 SlideListtranslateX 是实现轮播元素切换的关键,因此这里咱们须要将 currentIndexSlideListtranslateX 对应起来,即:安全

translateX = -(width) * currentIndex复制代码

width 即为单个轮播元素的宽度,与 Frame 的宽度相同,因此咱们能够在 componentDidMount 时拿到 Frame 的宽度并以此计算出轨道的总宽度。bash

componentDidMount() {
  const width = get(this.container.getBoundingClientRect(), 'width');
}

render() {
  const rest = omit(this.props, Object.keys(defaultProps));
  const classes = classnames('ui-carousel', this.props.className);
  return (
    <div
      {...rest}
      className={classes}
      ref={(node) => { this.container = node; }}
    >
      {this.renderSildeList()}
      {this.renderDots()}
    </div>
  );
}复制代码

至此,咱们只须要改变轮播组件中的 currentIndex,便可间接改变 SlideListtranslateX,以此实现轮播元素之间的切换。

响应用户操做

轮播做为一个常见的通用组件,在桌面和移动端都有着很是普遍的应用,这里咱们先以移动端为例,来阐述如何响应用户操做。

{map(children, (child, i) => (
  <div
    className="slideItem"
    role="presentation"
    key={i}
    style={{ width }}
    onTouchStart={this.handleTouchStart}
    onTouchMove={this.handleTouchMove}
    onTouchEnd={this.handleTouchEnd}
  >
    {child}
  </div>
))}复制代码

在移动端,咱们须要监听三个事件,分别响应滑动开始,滑动中与滑动结束。其中滑动开始与滑动结束都是一次性事件,而滑动中则是持续性事件,以此咱们能够肯定在三个事件中咱们分别须要肯定哪些值。

滑动开始

  • startPositionX:这次滑动的起始位置
handleTouchStart = (e) => {
  const { x } = getPosition(e);
  this.setState({
    startPositionX: x,
  });
}复制代码

滑动中

  • moveDeltaX:这次滑动的实时距离
  • direction:这次滑动的实时方向
  • translateX:这次滑动中轨道的实时位置,用于渲染
handleTouchMove = (e) => {
  const { width, currentIndex, startPositionX } = this.state;
  const { x } = getPosition(e);

  const deltaX = x - startPositionX;
  const direction = deltaX > 0 ? 'right' : 'left';
  this.setState({
    moveDeltaX: deltaX,
    direction,
    translateX: -(width * currentIndex) + deltaX,
  });
}复制代码

滑动结束

  • currentIndex:这次滑动结束后新的 currentIndex
  • endValue:这次滑动结束后轨道的 translateX
handleTouchEnd = () => {
  this.handleSwipe();
}

handleSwipe = () => {
  const { children, speed } = this.props;
  const { width, currentIndex, direction, translateX } = this.state;
  const count = size(children);

  let newIndex;
  let endValue;
  if (direction === 'left') {
    newIndex = currentIndex !== count ? currentIndex + 1 : START_INDEX;
    endValue = -(width) * (currentIndex + 1);
  } else {
    newIndex = currentIndex !== START_INDEX ? currentIndex - 1 : count;
    endValue = -(width) * (currentIndex - 1);
  }

  const tweenQueue = this.getTweenQueue(translateX, endValue, speed);
  this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex));
}复制代码

由于咱们在滑动中会实时更新轨道的 translateX,咱们的轮播组件即可以作到跟手的用户体验,即在单次滑动中,轮播元素会跟随用户的操做向左或向右滑动。

实现顺滑的切换动画

在实现了滑动中跟手的用户体验后,咱们还须要在滑动结束后将显示的轮播元素定位到新的 currentIndex。根据用户的滑动方向,咱们能够对当前的 currentIndex 进行 +1 或 -1 以获得新的 currentIndex。但在处理第一个元素向左滑动或最后一个元素向右滑动时,新的 currentIndex 须要更新为最后一个或第一个。

这里的逻辑并不复杂,但却带来了一个很是难以解决的用户体验问题,那就是假设咱们有 3 个轮播元素,每一个轮播元素的宽度都为 300px,即显示最后一个元素时,轨道的 translateX 为 -600px,在咱们将最后一个元素向左滑动后,轨道的 translateX 将被从新定义为 0px,此时若咱们使用原生的 CSS 动画:

transition: 1s ease-in-out;复制代码

轨道将会在一秒内从左向右滑动至第一个轮播元素,而这是反直觉的,由于用户一个向左滑动的操做致使了一个向右的动画,反之亦然。

这个问题从上古时期就困扰着许多前端开发者,笔者也见过如下几种解决问题的方法:

  • 将轨道宽度定义为无限长(几百万 px),无限次重复有限的轮播元素。这种解决方案显然是一种 hack,并无从实质上解决轮播组件的问题。
  • 只渲染三个轮播元素,即前一个,当前一个,下一个,每次滑动后同时更新三个元素。这种解决方案实现起来很是复杂,由于组件内部要维护的状态从一个 currentIndex 增长到了三个拥有各自状态的 DOM 元素,且由于要不停的删除和新增 DOm 节点致使性能不佳。

这里让咱们再来思考一下滑动操做的本质。除去第一和最后两个元素,全部中间元素滑动后新的 translateX 的值都是固定的,即 -(width * currentIndex),这种状况下的动画均可以轻松地完美实现。而在最后一个元素向左滑动时,由于轨道的 translateX 已经到达了极限,面对这种状况咱们如何才能实现顺滑的切换动画呢?

这里咱们选择将最后一个及第一个元素分别拼接至轨道的头尾,以保证在 DOM 结构不须要改变的前提下实现顺滑的切换动画:

carousel-long

这样咱们就统一了每次滑动结束后 endValue 的计算方式,即

// left
endValue = -(width) * (currentIndex + 1)

// right
endValue = -(width) * (currentIndex - 1)复制代码

使用 requestAnimationFrame 实现高性能动画

requestAnimationFrame 是浏览器提供的一个专一于实现动画的 API,感兴趣的朋友能够再重温一下《React Motion 缓动函数剖析》这篇专栏。

全部的动画本质上都是一连串的时间轴上的值,具体到轮播场景下即:以用户中止滑动时的值为起始值,以新 currentIndextranslateX 的值为结束值,在使用者设定的动画时间(如0.5秒)内,依据使用者设定的缓动函数,计算每一帧动画时的 translateX 值并最终获得一个数组,以每秒 60 帧的速度更新在轨道的 style 属性上。每更新一次,将消耗掉动画值数组中的一个中间值,直到数组中全部的中间值被消耗完毕,动画结束并触发回调。

具体代码以下:

const FPS = 60;
const UPDATE_INTERVAL = 1000 / FPS;

animation = (tweenQueue, newIndex) => {
  if (isEmpty(tweenQueue)) {
    this.handleOperationEnd(newIndex);
    return;
  }

  this.setState({
    translateX: head(tweenQueue),
  });
  tweenQueue.shift();
  this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex));
}

getTweenQueue = (beginValue, endValue, speed) => {
  const tweenQueue = [];
  const updateTimes = speed / UPDATE_INTERVAL;
  for (let i = 0; i < updateTimes; i += 1) {
    tweenQueue.push(
      tweenFunctions.easeInOutQuad(UPDATE_INTERVAL * i, beginValue, endValue, speed),
    );
  }
  return tweenQueue;
}复制代码

在回调函数中,根据变更逻辑统一肯定组件当前新的稳定态值:

handleOperationEnd = (newIndex) => {
  const { width } = this.state;

  this.setState({
    currentIndex: newIndex,
    translateX: -(width) * newIndex,
    startPositionX: 0,
    moveDeltaX: 0,
    dragging: false,
    direction: null,
  });
}复制代码

完成后的轮播组件效果以下图:

carousel

优雅地处理特殊状况

  • 处理用户误触:在移动端,用户常常会误触到轮播组件,即有时手不当心滑过或点击时也会触发 onTouch 类事件。对此咱们能够采起对滑动距离添加阈值的方式来避免用户误触,阈值能够是轮播元素宽度的 10% 或其余合理值,在每次滑动距离超过阈值时,才会触发轮播组件后续的滑动。
  • 桌面端适配:对于桌面端而言,轮播组件所须要响应的事件名称与移动端是彻底不一样的,但又能够相对应地匹配起来。这里还须要注意的是,咱们须要为轮播组件添加一个 dragging 的状态来区分移动端与桌面端,从而安全地复用 handler 部分的代码。
// mobile
onTouchStart={this.handleTouchStart}
onTouchMove={this.handleTouchMove}
onTouchEnd={this.handleTouchEnd}
// desktop
onMouseDown={this.handleMouseDown}
onMouseMove={this.handleMouseMove}
onMouseUp={this.handleMouseUp}
onMouseLeave={this.handleMouseLeave}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
onFocus={this.handleMouseOver}
onBlur={this.handleMouseOut}

handleMouseDown = (evt) => {
  evt.preventDefault();
  this.setState({
    dragging: true,
  });
  this.handleTouchStart(evt);
}

handleMouseMove = (evt) => {
  if (!this.state.dragging) {
    return;
  }
  this.handleTouchMove(evt);
}

handleMouseUp = () => {
  if (!this.state.dragging) {
    return;
  }
  this.handleTouchEnd();
}

handleMouseLeave = () => {
  if (!this.state.dragging) {
    return;
  }
  this.handleTouchEnd();
}

handleMouseOver = () => {
  if (this.props.autoPlay) {
    clearInterval(this.autoPlayTimer);
  }
}

handleMouseOut = () => {
  if (this.props.autoPlay) {
    this.autoPlay();
  }
}复制代码

小结

至此咱们就实现了一个只有 tween-functions 一个第三方依赖的轮播组件,打包后大小不过 2KB,完整的源码你们能够参考这里 carousel/index.js

除了节省的代码体积,更让咱们欣喜的仍是完全弄清楚了轮播组件的实现模式以及如何使用 requestAnimationFrame 配合 setState 来在 react 中完成一组动画。

感想

horse

你们应该都看过上面这幅漫画,有趣之余也蕴含着一个朴素却深入的道理,那就是在解决一个复杂问题时,最重要的是思路,但仅仅有思路也还是远远不够的,还须要具体的执行方案。这个具体的执行方案,必须是连续的,其中不能够欠缺任何一环,不能够有任何思路或执行上的跳跃。因此解决任何复杂问题都没有银弹也没有捷径,咱们必须把它弄清楚,搞明白,而后才能真正地解决它。

至此,组件库设计实战系列文章也将告一段落。在所有四篇文章中,咱们分别讨论了组件库架构,组件分类,文档组织,国际化以及复杂组件设计这几个核心的话题,因笔者能力所限,其中天然有许多不足之处,烦请各位谅解。

组件库做为提高前端团队工做效率的重中之重,花再多时间去研究它都不为过。再加上与设计团队对接,造成设计语言,与后端团队对接,统一数据结构,组件库也能够说是前端工程师在拓展自身工做领域上的必经之路。

不要惧怕重复造轮子,关键是每造一次轮子后,从中学到了什么。

与各位共勉。

相关文章
相关标签/搜索