用 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 函數以後,咱們就真正完成了這個手動輪播的組件了。


我是來自公衆號《技術銀河》的三鑽,一位正在重塑知識的技術人。下期再見。

相關文章
相關標籤/搜索