用 JSX 实现 Carousel 轮播组件

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

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

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

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

由于是轮播图,那咱们固然须要用到图片,因此这里我准备了 4 张来源于 Unsplash 的开源图片,固然你们也能够换成本身的图片。首先咱们把这 4 张图片都放入一个 gallery 的变量当中:react

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 张图能够轮播起来。webpack


组件底层封装

首先咱们须要给咱们以前写的代码作一下封装,便于咱们开始编写这个组件。程序员

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

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

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 父类,这样咱们就能够省略重复实现一些方法。shell

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

这里咱们就能够正式开始开发组件了,可是若是每次都须要手动 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 。因此 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页数 。这样咱们就完成了图片屡次移动,一张一张图片展现了。

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 的余数就是 0,因此每次把 current 设置成 current 除以 children 长度的余数就能够达到无限循环了。

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

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

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

因此咱们须要找到当前看到的图片,还有下一张图片,而后每次移动到下一张图片就找到再下一张图片,把下一张图片挪动到正确的位置。

讲到这里可能仍是有点懵,可是没关系,咱们来整理一下逻辑。

  • 获取当前图片 index 和 下一张图的 index
    • 首先轮播确定是从第一张图开始,而这张图在咱们的节点中确定是第 0 个
    • 由于咱们须要在看到一张图的时候就准备第二张图,因此咱们就须要找到下一张图的位置
    • 根据咱们上面说的,下一张图的位置,咱们可使用数学里的技巧来得到: 下一张图的位置 = (当前位置 + 1 ÷ 图片数量 下一张图的位置 = (当前位置 + 1)\div 图片数量 余数,根据这个公式,当咱们达到图片最后一张的时候,就会返回 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

  • 首先当咱们 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 ,这里 3 除以 4 的余数就是 3,而 3 恰好就是这个数组的最后一个图片。

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

  • 经过这个公式咱们就能够取得上一张和下一张图片在数组里面的指针位置,这个时候咱们就能够用这个指针获取到他们在节点中的对象,使用 CSSDOM 来改变他们的属性
  • 这里咱们须要先把全部元素移动到当前图片的位置,而后根据 -一、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 函数以后,咱们就真正完成了这个手动轮播的组件了。


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

相关文章
相关标签/搜索