用 JSX 实现 Carousel 轮播组件 - 前端组件化

在我们用 JSX 建立组件系统之前,我们先来用一个例子学习一下组件的实现原理和逻辑。这里我们就用一个轮播图的组件作为例子进行学习。轮播图的英文叫做 Carousel,它有一个旋转木马的意思。

上一篇文章《使用 JSX 建立 Markup 组件风格》中我们实现的代码,其实还不能称为一个组件系统,顶多是可以充当 DOM 的一个简单封装,让我们有能力定制 DOM。

要做这个轮播图的组件,我们应该先从一个最简单的 DOM 操作入手。使用 DOM 操作把整个轮播图的功能先实现出来,然后在一步一步去考虑怎么把它设计成一个组件系统。

TIPS:在开发中我们往往一开始做一个组件的时候,都会过度思考一个功能应该怎么设计,然后就把它实现的非常复杂。其实更好的方式是反过来的,先把功能实现了,然后通过分析这个功能从而设计出一个组件架构体系。

因为是轮播图,那我们当然需要用到图片,所以这里我准备了 4 张来源于 Unsplash 的开源图片,当然大家也可以换成自己的图片。首先我们把这 4 张图片都放入一个 gallery 的变量当中:

let gallery = [
  'https://source.unsplash.com/Y8lCoTRgHPE/1142x640',
  'https://source.unsplash.com/v7daTKlZzaw/1142x640',
  'https://source.unsplash.com/DlkF4-dbCOU/1142x640',
  'https://source.unsplash.com/8SQ6xjkxkCo/1142x640',
];

而我们的目标就是让这 4 张图可以轮播起来。


组件底层封装

首先我们需要给我们之前写的代码做一下封装,便于我们开始编写这个组件。

  • 根目录建立 framework.js
    • createElementElementWrapperTextWrapper 这三个移到我们的 framework.js 文件中
    • 然后 createElement 方法是需要 export 出去让我们可以引入这个基础创建元素的方法。
    • ElementWrapperTextWrapper 是不需要 export 的,因为它们都属于内部给 createElement 使用的
  • 封装 Wrapper 类中公共部分
    • ElementWrapperTextWrapper之中都有一样的 setAttributeappendChildmountTo ,这些都是重复并且可公用的
    • 所以我们可以建立一个 Component 类,把这三个方法封装进入
    • 然后让 ElementWrapperTextWrapper 继承 Component
  • Component 加入 render() 方法
    • 在 Component 类中加入 构造函数

这样我们就封装好我们组件的底层框架的代码,代码示例如下:

function createElement(type, attributes, ...children) {
  // 创建元素
  let element;
  if (typeof type === 'string') {
    element = new ElementWrapper(type);
  } else {
    element = new type();
  }

  // 挂上属性
  for (let name in attributes) {
    element.setAttribute(name, attributes[name]);
  }
  // 挂上所有子元素
  for (let child of children) {
    if (typeof child === 'string') child = new TextWrapper(child);
    element.appendChild(child);
  }
  // 最后我们的 element 就是一个节点
  // 所以我们可以直接返回
  return element;
}

export class Component {
  constructor() {
  }
  // 挂載元素的属性
  setAttribute(name, attribute) {
    this.root.setAttribute(name, attribute);
  }
  // 挂載元素子元素
  appendChild(child) {
    child.mountTo(this.root);
  }
  // 挂載当前元素
  mountTo(parent) {
    parent.appendChild(this.root);
  }
}

class ElementWrapper extends Component {
  // 构造函数
  // 创建 DOM 节点
  constructor(type) {
    this.root = document.createElement(type);
  }
}

class TextWrapper extends Component {
  // 构造函数
  // 创建 DOM 节点
  constructor(content) {
    this.root = document.createTextNode(content);
  }
}


实现 Carousel

接下来我们就要继续改造我们的 main.js。首先我们需要把 Div 改为 Carousel 并且让它继承我们写好的 Component 父类,这样我们就可以省略重复实现一些方法。

继承了 Component后,我们就要从 framework.js 中 import 我们的 Component。

这里我们就可以正式开始开发组件了,但是如果每次都需要手动 webpack 打包一下,就特别的麻烦。所以为了让我们可以更方便的调试代码,这里我们就一起来安装一下 webpack dev server 来解决这个问题。

执行一下代码,安装 webpack-dev-server

npm install --save-dev webpack-dev-server webpack-cli

看到上面这个结果,就证明我们安装成功了。我们最好也配置一下我们 webpack 服务器的运行文件夹,这里我们就用我们打包出来的 dist 作为我们的运行目录。

设置这个我们需要打开我们的 webpack.config.js,然后加入 devServer 的参数, contentBase 给予 ./dist 这个路径。

module.exports = {
  entry: './main.js',
  mode: 'development',
  devServer: {
    contentBase: './dist',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            plugins: [['@babel/plugin-transform-react-jsx', { pragma: 'createElement' }]],
          },
        },
      },
    ],
  },
};

用过 Vue 或者 React 的同学都知道,启动一个本地调试环境服务器,只需要执行 npm 命令就可以了。这里我们也设置一个快捷启动命令。打开我们的 package.json,在 scripts 的配置中添加一行 "start": "webpack start" 即可。

{
  "name": "jsx-component",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "webpack serve"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.12.3",
    "@babel/plugin-transform-react-jsx": "^7.12.5",
    "@babel/preset-env": "^7.12.1",
    "babel-loader": "^8.1.0",
    "webpack": "^5.4.0",
    "webpack-cli": "^4.2.0",
    "webpack-dev-server": "^3.11.0"
  },
  "dependencies": {}
}

这样我们就可以直接执行下面这个命令启动我们的本地调试服务器啦!

npm start

开启了这个之后,当我们修改任何文件时都会被监听到,这样就会实时给我们打包文件,非常方便我们调试。看到上图里面表示,我们的实时本地服务器地址就是 http://localhost:8080。我们在浏览器直接打开这个地址就可以访问这个项目。

这里要注意的一个点,我们把运行的目录改为了 dist,因为我们之前的 main.html 是放在根目录的,这样我们就在 localhost:8080 上就找不到这个 HTML 文件了,所以我们需要把 main.html 移动到 dist 目录下,并且改一下 main.js 的引入路径。

<!-- main.html 代码 -->
<body></body>

<script src="./main.js"></script>

打开链接后我们发现 Carousel 组件已经被挂載成功了,这个证明我们的代码封装是没有问题的。

接下来我们继续来实现我们的轮播图功能,首先要把我们的图片数据传进去我们的 Carousel 组件里面。

let a = <Carousel src={gallery}/>;

这样我们的 gallery 数组就会被设置到我们的 src 属性上。但是我们的这个 src 属性不是给我们的 Carousel 自身的元素使用的。也就说我们不是像之前那样直接挂載到 this.root 上。

所以我们需要另外储存这个 src 上的数据,后面使用它来生成我们轮播图的图片展示元素。在 React 里面是用 props 来储存元素属性,但是这里我们就用一个更加接近属性意思的 attributes 来储存。

因为我们需要储存进来的属性到 this.attributes 这个变量中,所以我们需要在 Component 类的 constructor 中先初始化这个类属性。

然后这个 attributes 是需要我们另外存储到类属性中,而不是挂載到我们元素节点上。所以我们需要在组件类中重新定义我们的 setAttribute 方法。

我们需要在组件渲染之前能拿到 src 属性的值,所以我们需要把 render 的触发放在 mountTo 之内。

class Carousel extends Component {
  // 构造函数
  // 创建 DOM 节点
  constructor() {
    super();
    this.attributes = Object.create(null);
  }
  setAttribute(name, value) {
    this.attributes[name] = value;
  }
  render() {
	console.log(this.attributes);
    return document.createElement('div');
  }
  mountTo() {
    parent.appendChild(this.render());
  }
}

接下来我们看看实际运行的结果,看看是不是能够获得图片的数据。

接下来我们就去把这些图给显示出来。这里我们需要改造一下 render 方法,在这里加入渲染图片的逻辑:

  • 首先我们需要把创建的新元素储起来
  • 循环我们的图片数据,给每条数据创建一个 img 元素
  • 给每一个 img 元素附上 src = 图片 url
  • 把附上 src 属性的图片元素挂載到我们的组件元素 this.root
  • 最后让 render 方法返回 this.root
class Carousel extends Component {
  // 构造函数
  // 创建 DOM 节点
  constructor() {
    super();
    this.attributes = Object.create(null);
  }
  setAttribute(name, value) {
    this.attributes[name] = value;
  }
  render() {
    this.root = document.createElement('div');

    for (let picture of this.attributes.src) {
      let child = document.createElement('img');
      child.src = picture;
      this.root.appendChild(child);
    }

    return this.root;
  }
  mountTo(parent) {
    parent.appendChild(this.render());
  }
}

就这样我们就可以看到我们的图片被正确的显示在我们的页面上。


排版与动画

首先我们图片的元素都是 img 标签,但是使用这个标签的话,当我们点击并且拖动的时候它自带就是可以被拖拽的。当然这个也是可以解决的,但是为了更简单的解决这个问题,我们就把 img 换成 div,然后使用 background-image。

默认 div 是没有宽高的,所以我们需要在组件的 div 这一层加一个 class 叫 carousel,然后在 HTML 中加入 css 样式表,直接选择 carousel 下的每一个 div,然后给他们合适的样式。

// main.js
class Carousel extends Component {
  // 构造函数
  // 创建 DOM 节点
  constructor() {
    super();
    this.attributes = Object.create(null);
  }
  setAttribute(name, value) {
    this.attributes[name] = value;
  }
  render() {
    this.root = document.createElement('div');
	this.root.addClassList('carousel'); // 加入 carousel class

    for (let picture of this.attributes.src) {
      let child = document.createElement('div');
      child.backgroundImage = `url('${picture}')`;
      this.root.appendChild(child);
    }

    return this.root;
  }
  mountTo(parent) {
    parent.appendChild(this.render());
  }
}
<!-- main.html -->
<head>
  <style>
    .carousel > div {
      width: 500px;
      height: 281px;
      background-size: contain;
    }
  </style>
</head>

<body></body>

<script src="./main.js"></script>

这里我们的宽是 500px,但是如果我们设置一个高是 300px,我们会发现图片的底部出现了一个图片重复的现象。这是因为图片的比例是 1600 x 900,而 500 x 300 比例与图片原来的比例不一致。

所以通过比例计算,我们可以得出这样一个高度: 500 ÷ 1900 × 900 = 281. x x x 500\div1900\times900 = 281.xxx 500÷1900×900=281.xxx。所以 500px 宽对应比例的高大概就是 281px。这样我们的图片就可以正常的显示在一个 div 里面了。

一个轮播图显然不可能所有的图片都显示出来的,我们认知中的轮播图都是一张一张图片显示的。首先我们需要让图片外层的 carousel div 元素有一个和它们一样宽高的盒子,然后我们设置 overflow: hidden。这样其他图片就会超出盒子所以被隐藏了。

这里有些同学可能问:“为什么不把其他图片改为 display: hidden 或者 opacity:0 呢?” 因为我们的轮播图在轮播的时候,实际上是可以看到当前的图片和下一张图片的。所以如果我们用了 display: hidden 这种隐藏属性,我们后面的效果就不好做了。

然后我们又有一个问题,轮播图一般来说都是左右滑动的,很少见是上下滑动的,但是我们这里图片就是默认从上往下排布的。所以这里我们需要调整图片的布局,让它们拍成一行。

这里我们使用正常流就可以了,所以只需要给 div 加上一个 display: inline-block,就可以让它们排列成一行,但是只有这个属性的话,如果图片超出了窗口宽度就会自动换行,所以我们还需要在它们父级加入强制不换行的属性 white-space: nowrap。这样我们就大功告成了。

<head>
  <style>
    .carousel {
      width: 500px;
      height: 281px;
      white-space: nowrap;
      overflow: hidden;
    }

    .carousel > div {
      width: 500px;
      height: 281px;
      background-size: contain;
      display: inline-block;
    }
  </style>
</head>

<body></body>

<script src="./main.js"></script>

接下来我们来实现自动轮播效果,在做这个之前我们先给这些图片元素加上一些动画属性。这里我们用 transition 来控制元素动效的时间,一般来说我们播一帧会用 0.5 秒 的 ease

Transition 一般来说都只用 ease 这个属性,除非是一些非常特殊的情况,ease-in 会用在推出动画当中,而 ease-out 就会用在进入动画当中。在同一屏幕上的,我们一般默认都会使用 ease,但是 linear 在大部分情况下我们是永远不会去用的。因为 ease 是最符合人类的感觉的一种运动曲线。

<head>
  <style>
    .carousel {
      width: 500px;
      height: 281px;
      white-space: nowrap;
      overflow: hidden;
    }

    .carousel > div {
      width: 500px;
      height: 281px;
      background-size: contain;
      display: inline-block;
      transition: ease 0.5s;
    }
  </style>
</head>

<body></body>

<script src="./main.js"></script>

实现自动轮播

有了动画效果属性,我们就可以在 JavaScript 中加入我们的定时器,让我们的图片在每三秒钟切换一次图片。我们使用 setInerval() 这个函数就可以解决这个问题了。

但是我们怎么才能让图片轮播,或者移动呢?想到 HTML 中的移动,大家有没有想到 CSS 当中有什么属性可以让我们移动元素的呢?

对没错,就是使用 transform,它就是在 CSS 当中专门用于挪动元素的。所以这里我们的逻辑就是,每 3 秒往左边挪动一次元素自身的长度,这样我们就可以挪动到下一张图的开始。

但是这样只能挪动一张图,所以如果我们需要挪动第二次,到达第三张图,我们就要让每一张图偏移 200%,以此类推。所以我们需要一个当前页数的值,叫做 current,默认值为 0。每次挪动的时候时就加一,这样偏移的值就是 − 100 × 页 数 -100\times页数 100×。这样我们就完成了图片多次移动,一张一张图片展示了。

class Carousel extends Component {
  // 构造函数
  // 创建 DOM 节点
  constructor() {
    super();
    this.attributes = Object.create(null);
  }
  setAttribute(name, value) {
    this.attributes[name] = value;
  }
  render() {
    this.root = document.createElement('div');
    this.root.classList.add('carousel');

    for (let picture of this.attributes.src) {
      let child = document.createElement('div');
      child.style.backgroundImage = `url('${picture}')`;
      this.root.appendChild(child);
    }

    let current = 0;
    setInterval(() => {
      let children = this.root.children;
      ++current;
      for (let child of children) {
        child.style.transform = `translateX(-${100 * current}%)`;
      }
    }, 3000);

    return this.root;
  }
  mountTo(parent) {
    parent.appendChild(this.render());
  }
}

这里我们发现一个问题,这个轮播是不会停止的,一直往左偏移没有停止。而我们需要轮播到最后一张的时候是回到一张图的。

要解决这个问题,我们可以利用一个数学的技巧,如果我们想要一个数是在 1 到 N 之间不断循环,我们就让它对 n 取余就可以了。在我们元素中,children 的长度是 4,所以当我们 current 到达 4 的时候, 4 ÷ 4 4\div4 4÷4 的余数就是 0,所以每次把 current 设置成 current 除以 children 长度的余数就可以达到无限循环了。

这里 current 就不会超过 4, 到达 4 之后就会回到 0。

用这个逻辑来实现我们的轮播,确实能让我们的图片无限循环,但是如果我们运行一下看看的话,我们又会发现另外一个问题。当我们播放到最后一个图片之后,就会快速滑动到第一个张图片,我们会看到一个快速回退的效果。这个确实不是那么好,我们想要的效果是,到达最后一张图之后,第一张图就直接在后面接上。

那么我们就一起去尝试解决这个问题,经过观察其实在屏幕上一次最多就只能看到两张图片。那么其实我们就把这两张图片挪到正确的位置就可以了。

所以我们需要找到当前看到的图片,还有下一张图片,然后每次移动到下一张图片就找到再下一张图片,把下一张图片挪动到正确的位置。

讲到这里可能还是有点懵,但是不要紧,我们来整理一下逻辑。

  • 获取当前图片 index 和 下一张图的 index
    • 首先轮播肯定是从第一张图开始,而这张图在我们的节点中肯定是第 0 个
    • 因为我们需要在看到一张图的时候就准备第二张图,所以我们就需要找到下一张图的位置
    • 根据我们上面说的,下一张图的位置,我们可以使用数学里的技巧来获得: 下 一 张 图 的 位 置 = ( 当 前 位 置 + 1 ) ÷ 图 片 数 量 下一张图的位置 = (当前位置 + 1)\div 图片数量 =+1÷余数,根据这个公式,当我们达到图片最后一张的时候,就会返回 0,回到第一个图片的位置
  • 计算图片移动的距离,保持当前图片后面有一张图片等着被挪动过来
    • 当前显示的图片的位置肯定是对的,所以我们是不需要计算的
    • 但是下一张图片的位置就需要我们去挪动它的位置,所以这里我们需要计算这个图片需要偏移的距离
    • 每一个图片移动一格的距离就是等于它自身的长度,加上往左移动是负数,所以每往左边移动一个格就是 -100%
    • 图片的 index 是从 0 到 n 的,如果我们用它们所在的 index 作为它们距离当前图片相差的图片数,我们就可以用 index * -100%,这样就可以把每一张图片移动到当前图片的位置。
    • 但是我们需要的是先把图片移动到当前图片的下一位的位置,所以下一位的所在位置是 index - 1 的图片距离,也就是说我们要移动的距离是 (index - 1) * -100%
    • 让第二张图就位的这个动作,我们不需要它出现任何动画效果,所以在这个过程中我们需要禁止图片的动画效果,那就要清楚 transition
  • 第二张图就位,就可以开始执行轮播效果
    • 因为上面我们需要至少一帧的图片移动时间,所以执行轮播效果之前需要一个 16 毫秒的延迟 (因为 16 毫秒刚好是浏览器一帧的时间)
    • 首先把行内标签中的 transition 重新开启,这样我们 CSS 中的动效就会重新起效,因为接下来的轮播效果是需要有动画效果的
    • 第一步是先把当前图片往右边移动一步,之前我们说的 index * -100% 让任何一张在 index 位置的图片移动到当前位置的公式,那么要再往右边移动多一个位置,那就是 (index + 1) * -100% 即可
    • 第二步就是让下一张图移动到当前显示的位置,这个就是直接用 index * -100%
    • 最后我们还需要更新一次我们记录, currentIndex = nextIndex,这样就大功告成了!

接下来我们把上面的逻辑翻译成 JavaScript:

class Carousel extends Component {
  // 构造函数
  // 创建 DOM 节点
  constructor() {
    super();
    this.attributes = Object.create(null);
  }
  setAttribute(name, value) {
    this.attributes[name] = value;
  }
  render() {
    this.root = document.createElement('div');
    this.root.classList.add('carousel');

    for (let picture of this.attributes.src) {
      let child = document.createElement('div');
      child.style.backgroundImage = `url('${picture}')`;
      this.root.appendChild(child);
    }

    // 当前图片的 index
    let currentIndex = 0;
    setInterval(() => {
      let children = this.root.children;
      // 下一张图片的 index
      let nextIndex = (currentIndex + 1) % children.length;

      // 当前图片的节点
      let current = children[currentIndex];
      // 下一张图片的节点
      let next = children[nextIndex]; 
	
      // 禁用图片的动效
      next.style.transition = 'none'; 
      // 移动下一张图片到正确的位置
      next.style.transform = `translateX(${-100 * (nextIndex - 1)}%)`;
	
      // 执行轮播效果,延迟了一帧的时间 16 毫秒
      setTimeout(() => {
        // 启用 CSS 中的动效
        next.style.transition = ''; 
        // 先移动当前图片离开当前位置
        current.style.transform = `translateX(${-100 * (currentIndex + 1)}%)`;
        // 移动下一张图片到当前显示的位置
        next.style.transform = `translateX(${-100 * nextIndex}%)`;
		
        // 最后更新当前位置的 index
        currentIndex = nextIndex;
      }, 16);
    }, 3000);

    return this.root;
  }
  mountTo(parent) {
    parent.appendChild(this.render());
  }
}

如果我们先去掉 overflow: hidden 的话,我们就可以很清晰的看到所有图片移动的轨迹了:


实现拖拽轮播

一般来说我们的轮播组件除了这种自动轮播的功能之外,还有可以使用我们的鼠标进行拖动来轮播。所以接下来我们一起来实现这个手动轮播功能。

因为自动轮播和手动轮播是有一定的冲突的,所以我们需要把我们前面实现的自动轮播的代码给注释掉。然后我们就可以使用这个轮播组件下的 children (子元素),也就是所有图片的元素,来实现我们的手动拖拽轮播功能。

那么拖拽的功能主要就是涉及我们的图片被拖动,所以我们需要给图片加入鼠标的监听事件。如果我们根据操作步骤来想的话,就可以整理出这么一套逻辑:

  • 我们肯定是需要先把鼠标移动到图片之上,然后点击图片。所以我们第一个需要监听的事件必然就是 mousedown 鼠标按下事件。
  • 点击了鼠标之后,那么我们就会开始移动我们的鼠标,让我们的图片跟随我们鼠标移动的方向去走。这个时候我们就要监听 mousemove 鼠标移动事件。
  • 当我们把图片拖动到我们想要的位置之后,我们就会松开我们鼠标的按键,这个时候也是我们要计算这个图片是否可以轮播的时候,这个就需要我们监听 mouseup 鼠标松开事件。
this.root.addEventListener('mousedown', event => {
  console.log('mousedown');
});

this.root.addEventListener('mousemove', event => {
  console.log('mousemove');
});

this.root.addEventListener('mouseup', event => {
  console.log('mouseup');
});

执行一下以上代码后,我们就会在 console 中看到,当我们鼠标放到图片上并且移动时,我们会不断的触发 mousemove。但是我们想要的效果是,当我们鼠标按住时移动才会触发 mousemove,我们鼠标单纯在图片上移动是不应该触发事件的。

所以我们需要把 mousemove 和 mouseup 两个事件,放在 mousedown 事件的回调函数当中,这样才能正确的在鼠标按住的时候监听移动和松开两个动作。这里还需要考虑,当我们 mouseup 的时候,我们需要把 mousemove 和 mouseup 两个监听事件给停掉,所以我们需要用函数把它们单独的存起来。

this.root.addEventListener('mousedown', event => {
  console.log('mousedown');

  let move = event => {
    console.log('mousemove');
  };

  let up = event => {
    this.root.removeEventListener('mousemove', move);
    this.root.removeEventListener('mouseup', up);
  };

  this.root.addEventListener('mousemove', move);
  this.root.addEventListener('mouseup', up);
});

这里我们在 mouseup 的时候就把 mousemove 和 mouseup 的事件给移除了。这个就是一般我们在做拖拽的时候都会用到的基础代码。

但是我们又会发现另外一个问题,鼠标点击拖动然后松开后,我们鼠标再次在图片上移动,还是会出发到我们的mousemove 事件。

这个是因为我们的 mousemove 是在 root 上被监听的。其实我们的 mousedown 已经是在 root 上监听,我们 mousemove 和 mouseup 就没有必要在 root 上监听了。

所以我们可以在 document 上直接监听这两个事件,而在现代浏览器当中,使用 document 监听还有额外的好处,即使我们的鼠标移出浏览器窗口外我们一样可以监听到事件。

this.root.addEventListener('mousedown', event => {
  console.log('mousedown');

  let move = event => {
    console.log('mousemove');
  };

  let up = event => {
    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', up);
  };

  document.addEventListener('mousemove', move);
  document.addEventListener('mouseup', up);
});

有了这个完整的监听机制之后,我们就可以尝试在 mousemove 里面去实现轮播图的移动功能了。我们一起来整理一下这个功能的逻辑:

  • 要做这个功能,首先我们要知道鼠标的位置,这里可以使用 mousemove 中的 event 参数去捕获到鼠标的坐标。
  • event 上其实有很多个鼠标的坐标,比如 offsetXoffsetY 等等,这些都是根据不同的参考系所获得坐标的。在这里我们比较推荐使用的是 clientXclientY
  • 这个坐标是相对于整个浏览器中可渲染区域的坐标,它不受任何的因素影响。很多时候我们组件在浏览器这个容器里面,当我们滚动了页面之后,在一些坐标体系中就会发生变化。这样我们就很容易会出现一些不可调和的 bug,但是 clientX 和 clientY 就不会出现这种问题。
  • 如果要知道我们图片要往某一个方向移动多少,我们就要知道我们鼠标点击时的起始坐标,然后与我们获取到的 clientX 和 clientY 做对比。所以我们需要记录一个 startXstartY,它们的默认值就是对应的当前 clientX 和 clientY
  • 所以我们鼠标移动的距离就是 终 点 坐 标 − 起 点 坐 标 终点坐标 - 起点坐标 ,在我们的 move 回调函数里面就是 clientX - startXclientY - startY
  • 我们轮播图只支持左右滑动的,所以在我们这个场景中,就不需要 Y 轴的值。
  • 那么我们计算好移动距离,就可以给对应被拖动的元素加上 transform,这样图片就会被移动了
  • 我们之前做自动轮播的时候给图片元素加入了 transition 动画,我们在拖动的时候如果有这个动画,就会出现延迟一样的效果,所以在给图片加入 transform 的同时,我们还需要禁用它们的 transition 属性
this.root.addEventListener('mousedown', event => {
  let children = this.root.children;
  let startX = event.clientX;

  let move = event => {
    let x = event.clientX - startX;
    for (let child of children) {
      child.style.transition = 'none';
      child.style.transform = `translateX(${x}px)`;
    }
  };

  let up = event => {
    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', up);
  };

  document.addEventListener('mousemove', move);
  document.addEventListener('mouseup', up);
});

好,到了这里我们发现了两个问题:

  1. 我们第一次点击然后拖动的时候图片的起始位置是对的,但是我们再点击的时候图片的位置就不对了。
  2. 我们拖动了图片之后,当我们松开鼠标按钮,这个图片就会停留在拖动结束的位置了,但是在正常的轮播图组件中,我们如果拖动了图片超过一定的位置,就会自动轮播到下一张图的。

要解决这两个问题,我们可以这么计算,因为我们做的是一个轮播图的组件,按照现在一般的轮播组件来说,当我们把图片拖动在大于半个图的位置时,就会轮播到下一张图了,如果不到一半的位置的话就会回到当前拖动的图的位置。

按照这样的一个需求,我们就需要记录一个 position,它记录了当前是第几个图片(从 0 开始计算)。如果我们每张图片都是 500px 宽,那么第一张图的 current 就是 0,偏移的距离就是 0 * 500 = 0, 而第二张图就是 1 * 500 px,第三张图就是 2 * 500px,以此类推。根据这样的规律,第 N 张图的偏移位置就是 n ∗ 500 n * 500 n500

  • 首先当我们 mousemove 的时候,我们需要计算当前图片已经从起点移动了多远,这个就可以通过 N * 500 来计算,这里的 N 就是目前的图片的 position 值。
  • 然后我们还需要在 mouseup 的时候,计算一下当前图片移动的距离是否有超过半张图的长度,如果超过了,我们直接 transform 到下一张图的起点位置
  • 这里的超出判断可以使用我们当前鼠标移动的距离 x 除与我们每张图的 长度(我们这个组件控制了图片是 500px,所以我们就用 x 除与 500),这样我们就会得出一个 0 到 1 的数字。如果这个数字等于或超过 0.5 那么就是过了图一半的长度了,就可以直接轮播到下一张图,如果是小于 0.5 就可以移动回去当前图的起始位置。
  • 上面计算出来的值,还可以结合我们的 position,如果大于等于 0.5 就可以四舍五入变成 1, 否则就是 0。这里的 1 代表我们可以把 position + 1,如果是 0 那么 position 就不会变。这样直接改变 current 的值,在 transform 的时候就会自动按照新的 current 值做计算,轮播的效果就达成了。
  • 因为 x 是可以左右移动的距离值,也就是说如果我们鼠标是往左移动的话,x 就会是负数,而相反就是正数,我们的轮播组件鼠标往左拖动就是前进,而往右拖动就是回退。所以这里运算这个 超出值 的时候就是 position = position - Math.round(x/500) 。比如我们鼠标往左边挪动了 400px,当前 current 值是 0,那么position = 0 - Math.round(400/500) = 0 - -1 = 0 + 1 = 1 所以最后我们的 current 变成了 1
  • 根据上面的逻辑,我们在 mouseup 的事件中要循环所有轮播中的 child 图片,给它们都设置一个新的 tranform 值
this.root.addEventListener('mousedown', event => {
  let children = this.root.children;
  let startX = event.clientX;

  let move = event => {
    let x = event.clientX - startX;
    for (let child of children) {
      child.style.transition = 'none';
      child.style.transform = `translateX(${x - current * 500}px)`;
    }
  };

  let up = event => {
    let x = event.clientX - startX;
    current = current - Math.round(x / 500);
    for (let child of children) {
      child.style.transition = '';
      child.style.transform = `translateX(${-current * 500}px)`;
    }
    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', up);
  };

  document.addEventListener('mousemove', move);
  document.addEventListener('mouseup', up);
});

注意这里我们用的 500 作为图片的长度,那是因为我们自己写的图片组件,它的图片被我们固定为 500px 宽,而如果我们需要做一个通用的轮播组件的话,最好就是获取元素的实际宽度,Element.clientWith()。这样我们的组件是可以随着使用者去改变的。

做到这里,我们就可以用拖拽来轮播我们的图片了,但是当我们拖到最后一张图的时候,我们就会发现最后一张图之后就是空白了,第一张图没有接着最后一张。

那么接下来我们就去完善这个功能。这里其实和我们的自动轮播是非常相似的,在做自动轮播的时候我们就知道,每次轮播图片的时候,我们最多就只能看到两张图片,可以看到三张图片的机率是非常小的,因为我们的轮播的宽度相对我们的页面来说是非常小的,除非用户有足够的位置去拖到第二张图以外才会出现这个问题。但是这里我们就不考虑这种因素了。

我们确定每次拖拽的时候只会看到两张图片,所以我们也可以像自动轮播那样去处理拖拽的轮播。但是这里有一个点是不一样的,我们自动轮播的时候,图片只会走一个方向,要么左要么右边。但是我们手动就可以往左或者往右拖动,图片是可以走任意方向的。所以我们就无法直接用自动轮播的代码来实现这个功能了。我们就需要自己重新处理一下轮播头和尾无限循环的逻辑。

  • 我们可以从 mousemove 的回调函数开始改造
  • 需要找到当前元素在屏幕上的位置,我们给它 一个变量名叫 current,它的值与我们之前在 mouseup 计算的 position 是一样的 position + Math.round(x/500)
  • 但是当前这个元素是前后都有一张图,这里我们就不去计算现在拖动是需要拼接它前面还是后面的图,我们直接就把当前元素前后两个图都移动到对应的位置即可
  • 这里我们直接循环一个 [-1, 0, 1] 的数组,对应的是前一个元素当前元素下一个元素,这里我们需要使用这三个偏移值,获取到上一个图片,当前拖动的图片和下一个图片的移动位置,这三个位置是跟随着我们鼠标的拖动实时计算的
  • 接着我们在这个循环里面需要先计算出前后两张图的位置,图片位置 = 当前图片位置 + 偏移,这里可以这么理解如果当前图片是在 2 这个位置,上一张图就是在 1,下一张图就在 3
  • 但是这里有一个问题,如果我们当前图是在 0 的位置,我们上一张图获取到的位置就是 -1,按照我们图片的数据结构来说,数组里面是没有 -1 这个位置的。所以当我们遇到计算出来的位置是负数的时候我们就要把它转成这一列图片的最后一张图的位置。
  • 按照我们的例子里面的图片数据来说的话,当前的图是在 0 这个位置,那么上一张图就应该是我们在3 号位的图。那么我们怎么能把 -1 变成 3, 在结尾的时候 4 变成 0 呢?
  • 这里需要用到一个数学中的小技巧了,如果我们想让头尾的两个值超出的时候可以翻转,我们就需要用到一个公式, 求 (当前指针 + 数组总长度)/ 数组总长度余数,这个获得的余数就正好是翻转的。

我们来证明一下这个公式是正确的,首先如果我们遇到 current = 0, 那么 0 这个位置的图片的上一张就会获得 -1 这个指针,这个时候我们用 ( − 1 + 4 ) / 4 = 3 / 4 (-1 + 4) / 4 = 3 / 4 (1+4)/4=3/4,这里 3 除以 4 的余数就是 3,而 3 刚好就是这个数组的最后一个图片。

然后我们来试试,如果当前图片就是数组里面的最后一张图,在我们的例子里面就是 3,3 + 1 = 4, 这个时候通过转换 ( 4 + 4 ) / 4 (4 + 4) / 4 (4+4)/4 余数就是 0,显然我们获得的数字就是数组的第一个图片的位置。

  • 通过这个公式我们就可以取得上一张和下一张图片在数组里面的指针位置,这个时候我们就可以用这个指针获取到他们在节点中的对象,使用 CSSDOM 来改变他们的属性
  • 这里我们需要先把所有元素移动到当前图片的位置,然后根据 -1、0、1 这三个偏移的值对这个图片进行往左或者往右移动,最后我们要需要加上当前鼠标的拖动距离

我们已经把整个逻辑给整理了一遍,下来我们看看 mousemove 这个事件回调函数代码的应该怎么写:

let move = event => {
  let x = event.clientX - startX;

  let current = position - Math.round(x / 500);

  for (let offset of [-1, 0, 1]) {
    let pos = current + offset;
    // 计算图片所在 index
    pos = (pos + children.length) % children.length;
    console.log('pos', pos);

    children[pos].style.transition = 'none';
    children[pos].style.transform = `translateX(${-pos * 500 + offset * 500 + (x % 500)}px)`;
  }
};

讲了那么多东西,代码就那么几行,确实代码简单不等于它背后的逻辑就简单。所以写代码的程序员也可以是深不可测的。

最后还有一个小问题,在我们拖拽的时候,我们会发现上一张图和下一张有一个奇怪跳动的现象。

这个问题是我们的 Math.round(x / 500) 所导致的,因为我们在 transform 的时候,加入了 x % 500, 而在我们的 current 值的计算中没有包含这一部分的计算,所以在鼠标拖动的时候就会缺少这部分的偏移度。

我们只需要把这里的 Math.round(x / 500) 改为 (x - x % 500) / 500 即可达到同样的取整数的效果,同时还可以保留我们 x 原有的正负值。

这里其实还有比较多的问题的,我们还没有去改 mouseup 事件里面的逻辑。那么接下来我们就来看看 up 中的逻辑我们应该怎么去实现。

这里我们需要改的就是 children 中 for 循环的代码,我们要实现的是让我们拖动图片超过一定的位置就会自动轮播到对应方向的下一张图片。up 这里的逻辑其实是和 move 是基本一样的,不过这里有几个地方需要更改的:

  • 首先我们的 transition 禁止是可以去掉了,改为 ' '
  • 在 transform 中的 + x % 500 就不需要了,因为这里图片是我们鼠标松开的时候,不需要图片再跟随我们鼠标的位置了
  • 在计算 pos = current + offset的这里,我们在 up 的回调中是没有 current 的,所以我们需要把 current 改为 position
  • 因为有一个 z-index 的层次关系,我们会看到有图片在被挪动位置的时候,它在我们当前图片上飞过,但是飞过去的元素其实是我们不需要的元素,而这个飞过去的元素是来源于我们之前用的 [-1, 0, 1] 这里面的 -1 和 1 的两个元素,所以在 up 这个逻辑里面我们要把不需要的给去掉。意思就是说,如果我们鼠标是往左移动的,那么我们只需要 -1 的元素,相反就是只需要 1 的元素,另外的那边的元素就可以去掉了。
  • 首先 for of 循环是没有顺序要求的,所以我们可以把 -1 和 1 这两个数字用一个公式来代替,放在我们 0 的后面。但是怎么才能找到我们需要的是哪一边呢?
  • 其实我们需要计算的就是图片在移动的方向,所以我们要改动的就是 position = position - Math.round(x / 500) 这行代码,这个方向可以通过 Math.round(x / 500) - x 获得。而这个值就是相对当前元素的中间,他是更偏向左边(负数)还是右边(正数),其实这个数字是多少并不是最重要的,我们要的是它的符号也就是 -1 还是 1,所以这里我们就可以使用 - Math.sign(Math.round(x / 500) - x) 来取得结果中的符号,这个函数最终返回要不就是 -1, 要不就是 1 了, 正好是我们想要的。
  • 其实还有一个小 bug,当我们拖动当前图片过短的时候,图片位置的计算是不正确的。

  • 这个是因为我们的 Match.round() 的特性,在 250(500px 刚好一半的位置) 之间是有一定的误区,让我们无法判断图片需要往那个方向移动的,所以在计算往 Match.round 的值之后我们还需要加上 + 250 * Match.sign(x),这样我们的计算才会合算出是应该往那边移动。

最终我们的代码就是这样的:

let up = event => {
  let x = event.clientX - startX;
  position = position - Math.round(x / 500);

  for (let offset of [0, -Math.sign(Math.round(x / 500) - x + 250 * Math.sign(x))]) {
    let pos = position + offset;
    // 计算图片所在 index
    pos = (pos + children.length) % children.length;

    children[pos].style.transition = '';
    children[pos].style.transform = `translateX(${-pos * 500 + offset * 500}px)`;
  }

  document.removeEventListener('mousemove', move);
  document.removeEventListener('mouseup', up);
};

改好了 up 函数之后,我们就真正完成了这个手动轮播的组件了。



博主开始在B站直播学习,欢迎过来《直播间》一起学习。

我们在这里互相监督,互相鼓励,互相努力走上人生学习之路,让学习改变我们生活!

学习的路上,很枯燥,很寂寞,但是希望这样可以给我们彼此带来多一点陪伴,多一点鼓励。我们一起加油吧! (๑ •̀ㅂ•́)و


我是来自《技术银河》的三钻,一位正在重塑知识的技术人。下期再见。

推荐专栏

小伙伴们可以查看或者订阅相关的专栏,从而集中阅读相关知识的文章哦。

  • ⛳️ 《2021年总结》 — 一个一线战场中的开发者,回归到学习的学堂中。一开始这个过程确实遇到了挺多困扰的。一开始无法静心下来学习,因为学习底层的知识确实需要静下心来学。但是坚持了一段时间后,又会发现自己会爱上学习,爱上深挖这些知识。

  • 📖 《前端进阶》 — 这里包含的文章学习内容需要我们拥有 1-2 年前端开发经验后,选择让自己升级到高级前端工程师的学习内容(这里学习的内容是对应阿里 P6 级别的内容)。

  • 📖 《数据结构与算法》 — 到了如今,如果想成为一个高级开发工程师或者进入大厂,不论岗位是前端、后端还是AI,算法都是重中之重。也无论我们需要进入的公司的岗位是否最后是做算法工程师,前提面试就需要考算法。

  • 📖 《FCC前端集训营》 — 根据FreeCodeCamp的学习课程,一起深入浅出学习前端。稳固前端知识,一起在FreeCodeCamp获得证书

  • 📖 《前端星球》 — 以实战为线索,深入浅出前端多维度的知识点。内含有多方面的前端知识文章,带领不懂前端的童鞋一起学习前端,在前端开发路上童鞋一起燃起心中那团火🔥

三钻 CSDN认证博客专家 前端 Vue React
—— 起步于PHP,一入前端深似海,最后爱上了前端。Vue、React使用者。专于Web、移动端开发。特别关注产品和UI设计。专心、专注、专研,与同学们一起终身学习。关注我的微信公众号《技术银河》有更多最新知识文章与同学们分享。
已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页