使用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);
});

好,到瞭這裡我們發現瞭兩個問題:

我們第一次點擊然後拖動的時候圖片的起始位置是對的,但是我們再點擊的時候圖片的位置就不對瞭。我們拖動瞭圖片之後,當我們松開鼠標按鈕,這個圖片就會停留在拖動結束的位置瞭,但是在正常的輪播圖組件中,我們如果拖動瞭圖片超過一定的位置,就會自動輪播到下一張圖的。

要解決這兩個問題,我們可以這麼計算,因為我們做的是一個輪播圖的組件,按照現在一般的輪播組件來說,當我們把圖片拖動在大於半個圖的位置時,就會輪播到下一張圖瞭,如果不到一半的位置的話就會回到當前拖動的圖的位置。

按照這樣的一個需求,我們就需要記錄一個 position,它記錄瞭當前是第幾個圖片(從 0 開始計算)。如果我們每張圖片都是 500px 寬,那麼第一張圖的 current 就是 0,偏移的距離就是 0 * 500 = 0, 而第二張圖就是 1 * 500 px,第三張圖就是 2 * 500px,以此類推。根據這樣的規律,第 N 張圖的偏移位置就是 n ∗ 500 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 (−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 函數之後,我們就真正完成瞭這個手動輪播的組件瞭。


到此這篇關於使用JSX實現Carousel輪播組件的方法(前端組件化)的文章就介紹到這瞭,更多相關JSX實現Carousel輪播組件內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!