本节代码对应 GitHub 分支: chapter3

仓库传送门 (opens new window)

本项目最大的亮点之一就是采用 better-scroll 打造了移动端滑动基础组件,不仅仅可以用在本项目,也可以直接移植到其他所有的移动端 React 项目。现在,我们来一起封装这个实用且强大的组件。

# 分步拆解 scroll 组件

// 安装 better-scroll
npm install better-scroll@next --save

我们依然采用函数式组件的形式进行开发,不过作为一个通用组件,scroll 组件在业务中会被经常取到原生 DOM 对象,而函数式组件天生不具备被上层组件直接调用 ref 的条件,因此需要用 React 当中一些特殊的方式来处理,即使用 forwardRef 进行包裹。

const Scroll = forwardRef ((props, ref) => {
  // 编写组件内容
})

首先梳理一下这个组件需要接受哪些参数:

Scroll.propTypes = {
  direction: PropTypes.oneOf (['vertical', 'horizental']),// 滚动的方向
  click: true,// 是否支持点击
  refresh: PropTypes.bool,// 是否刷新
  onScroll: PropTypes.func,// 滑动触发的回调函数
  pullUp: PropTypes.func,// 上拉加载逻辑
  pullDown: PropTypes.func,// 下拉加载逻辑
  pullUpLoading: PropTypes.bool,// 是否显示上拉 loading 动画
  pullDownLoading: PropTypes.bool,// 是否显示下拉 loading 动画
  bounceTop: PropTypes.bool,// 是否支持向上吸顶
  bounceBottom: PropTypes.bool// 是否支持向下吸底
};

目前归纳出了这些可能的参数,也正是后面的开发中所需要的,给他们赋默认值:

Scroll.defaultProps = {
  direction: "vertical",
  click: true,
  refresh: true,
  onScroll:null,
  pullUpLoading: false,
  pullDownLoading: false,
  pullUp: null,
  pullDown: null,
  bounceTop: true,
  bounceBottom: true
};

现在来写 scroll 组件的核心逻辑代码,首先声明如下 hooks 变量:

//better-scroll 实例对象
const [bScroll, setBScroll] = useState ();
//current 指向初始化 bs 实例需要的 DOM 元素
const scrollContaninerRef = useRef ();

从外面接受 props,解构赋值拿到这些参数:

const { direction, click, refresh, pullUpLoading, pullDownLoading, bounceTop, bounceBottom } = props;
const { pullUp, pullDown, onScroll } = props;

接下来创建 better-scroll,

useEffect (() => {
  const scroll = new BScroll (scrollContaninerRef.current, {
    scrollX: direction === "horizental",
    scrollY: direction === "vertical",
    probeType: 3,
    click: click,
    bounce:{
      top: bounceTop,
      bottom: bounceBottom
    }
  });
  setBScroll (scroll);
  return () => {
    setBScroll (null);
  }
}, []);

每次重新渲染都要刷新实例,防止无法滑动:

useEffect (() => {
  if (refresh && bScroll){
    bScroll.refresh ();
  }
});

给实例绑定 scroll 事件,

useEffect (() => {
  if (!bScroll || !onScroll) return;
  bScroll.on ('scroll', (scroll) => {
    onScroll (scroll);
  })
  return () => {
    bScroll.off ('scroll');
  }
}, [onScroll, bScroll]);

进行上拉到底的判断,调用上拉刷新的函数

useEffect (() => {
  if (!bScroll || !pullUp) return;
  bScroll.on ('scrollEnd', () => {
    // 判断是否滑动到了底部
    if (bScroll.y <= bScroll.maxScrollY + 100){
      pullUp ();
    }
  });
  return () => {
    bScroll.off ('scrollEnd');
  }
}, [pullUp, bScroll]);

进行下拉的判断,调用下拉刷新的函数

useEffect (() => {
  if (!bScroll || !pullDown) return;
  bScroll.on ('touchEnd', (pos) => {
    // 判断用户的下拉动作
    if (pos.y > 50) {
      pullDown ();
    }
  });
  return () => {
    bScroll.off ('touchEnd');
  }
}, [pullDown, bScroll]);

完成了滑动事件、上拉下拉事件的判断,现在需要给外界暴露组件方法,如:

// 上层组件代码
const scrollRef = useRef ();
...
<Scroll ref={scrollRef}></Scroll>

想要通过这种调用方法的方式刷新 scroll 组件:

scrollRef.current.refresh ();

这应该怎么办呢? React Hooks 中的 useImperativeHandle 已经给了我们解决方案,我们这样做就好了:

// 一般和 forwardRef 一起使用,ref 已经在 forWardRef 中默认传入
useImperativeHandle (ref, () => ({
  // 给外界暴露 refresh 方法
  refresh () {
    if (bScroll) {
      bScroll.refresh ();
      bScroll.scrollTo (0, 0);
    }
  },
  // 给外界暴露 getBScroll 方法,提供 bs 实例
  getBScroll () {
    if (bScroll) {
      return bScroll;
    }
  }
}));

剩下的是 UI 的渲染工作:

return (
  <ScrollContainer ref={scrollContaninerRef}>
    {props.children}
  </ScrollContainer>
);

同时贴出样式部分的 js 代码:

const ScrollContainer = styled.div`
  width: 100%;
  height: 100%;
  overflow: hidden;
`

加载动画部分涉及到 loading 组件我们放到下一章拆解:)

# 综合代码

这里给出综合后的代码:

import React, { forwardRef, useState,useEffect, useRef, useImperativeHandle } from "react"
import PropTypes from "prop-types"
import BScroll from "better-scroll"
import styled from'styled-components';

const ScrollContainer = styled.div`
  width: 100%;
  height: 100%;
  overflow: hidden;
`
const Scroll = forwardRef ((props, ref) => {
  const [bScroll, setBScroll] = useState ();

  const scrollContaninerRef = useRef ();

  const { direction, click, refresh,  bounceTop, bounceBottom } = props;

  const { pullUp, pullDown, onScroll } = props;

  useEffect (() => {
    const scroll = new BScroll (scrollContaninerRef.current, {
      scrollX: direction === "horizental",
      scrollY: direction === "vertical",
      probeType: 3,
      click: click,
      bounce:{
        top: bounceTop,
        bottom: bounceBottom
      }
    });
    setBScroll (scroll);
    return () => {
      setBScroll (null);
    }
    //eslint-disable-next-line
  }, []);

  useEffect (() => {
    if (!bScroll || !onScroll) return;
    bScroll.on ('scroll', (scroll) => {
      onScroll (scroll);
    })
    return () => {
      bScroll.off ('scroll');
    }
  }, [onScroll, bScroll]);

  useEffect (() => {
    if (!bScroll || !pullUp) return;
    bScroll.on ('scrollEnd', () => {
      // 判断是否滑动到了底部
      if (bScroll.y <= bScroll.maxScrollY + 100){
        pullUp ();
      }
    });
    return () => {
      bScroll.off ('scrollEnd');
    }
  }, [pullUp, bScroll]);

  useEffect (() => {
    if (!bScroll || !pullDown) return;
    bScroll.on ('touchEnd', (pos) => {
      // 判断用户的下拉动作
      if (pos.y > 50) {
        pullDown ();
      }
    });
    return () => {
      bScroll.off ('touchEnd');
    }
  }, [pullDown, bScroll]);


  useEffect (() => {
    if (refresh && bScroll){
      bScroll.refresh ();
    }
  });

  useImperativeHandle (ref, () => ({
    refresh () {
      if (bScroll) {
        bScroll.refresh ();
        bScroll.scrollTo (0, 0);
      }
    },
    getBScroll () {
      if (bScroll) {
        return bScroll;
      }
    }
  }));


  return (
    <ScrollContainer ref={scrollContaninerRef}>
      {props.children}
    </ScrollContainer>
  );
})

Scroll.defaultProps = {
  direction: "vertical",
  click: true,
  refresh: true,
  onScroll:null,
  pullUpLoading: false,
  pullDownLoading: false,
  pullUp: null,
  pullDown: null,
  bounceTop: true,
  bounceBottom: true
};

Scroll.propTypes = {
  direction: PropTypes.oneOf (['vertical', 'horizental']),
  refresh: PropTypes.bool,
  onScroll: PropTypes.func,
  pullUp: PropTypes.func,
  pullDown: PropTypes.func,
  pullUpLoading: PropTypes.bool,
  pullDownLoading: PropTypes.bool,
  bounceTop: PropTypes.bool,// 是否支持向上吸顶
  bounceBottom: PropTypes.bool// 是否支持向上吸顶
};

export default Scroll;

# 在项目中应用

scroll 组件已经初步实现。但是,这还不够。还有一些细节,比如防抖,loading 控制等等,后期会一步一步完善。更重要的是,我们还需要将它运用到项目中,进入到 Recommend 目录下的 index.js,函数返回的 JSX 代码变化如下:

<Content>
  <Scroll className="list">
    <div>
      <Slider bannerList={bannerList}></Slider>
      <RecommendList recommendList={recommendList}></RecommendList>
    </div>
  </Scroll>
</Content>

可能你会不解,Content 样式组件是个什么鬼?在这里我要强调一下,better-scroll 的原理并不复杂,就是在容器元素高度固定,当子元素高度超过容器元素高度时,通过 transfrom 动画产生滑动效果,因此它的使用原则就是外部容器必须是固定高度,不然没法滚动。而 Content 就是这个外部容器。

我们在对应 style.js 中增加以下代码:

import styled from'styled-components';

export const Content = styled.div`
  position: fixed;
  top: 90px;
  bottom: 0;
  width: 100%;
`

现在打开页面,你就能体会到下拉吸顶、上拉吸底的感觉了。不过还是有一个问题,当你下拉的时候,中间会有一段空白,感觉比较突兀,没错,这就是默认的背景颜色。那么怎么来解决这个问题呢?

还是从遮罩入手吧,还记得那个.before 的 div 吗?

.before {
  position: absolute;
  top: -300px;
  height: 400px;
  width: 100%;
  background: ${style ["theme-color"]};
}

如此修改即可,这样下拉间隙就变成了主题色了。

阅读全文