前面幾篇文章,我跟大家分享了JavaScript的一些基礎知識,這篇文章,將會進入第一個實戰環節:利用前面幾章的所涉及到的知識,封裝一個拖拽物件。為了能夠幫助大家瞭解更多的方式與進行對比,我會使用三種不同的方式來實現拖拽。
- 不封裝物件直接實現;
- 利用原生JavaScript封裝拖拽物件;
- 通過擴充套件jQuery來實現拖拽物件。
本文的例子會放置於codepen.io中,供大家在閱讀時直接檢視。如果對於codepen不瞭解的同學,可以花點時間稍微瞭解一下。
拖拽的實現過程會涉及到非常多的實用小知識,因此為了鞏固我自己的知識積累,也為了大家能夠學到更多的知識,我會盡量詳細的將一些細節分享出來,相信大家認真閱讀之後,一定能學到一些東西。
1、如何讓一個DOM元素動起來
我們常常會通過修改元素的top,left,translate
來其的位置發生改變。在下面的例子中,每點選一次按鈕,對應的元素就會移動5px。大家可點選檢視。
由於修改一個元素top/left值會引起頁面重繪,而translate不會,因此從效能優化上來判斷,我們會優先使用translate屬性。
2、如何獲取當前瀏覽器支援的transform相容寫法
transform是css3的屬性,當我們使用它時就不得不面對相容性的問題。不同版本瀏覽器的相容寫法大致有如下幾種:
['transform', 'webkitTransform', 'MozTransform', 'msTransform', 'OTransform']
因此我們需要判斷當前瀏覽器環境支援的transform屬性是哪一種,方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 獲取當前瀏覽器支援的transform相容寫法 function getTransform() { var transform = '', divStyle = document.createElement('div').style, // 可能涉及到的幾種相容性寫法,通過迴圈找出瀏覽器識別的那一個 transformArr = ['transform', 'webkitTransform', 'MozTransform', 'msTransform', 'OTransform'], i = 0, len = transformArr.length; for(; i < len; i++) { if(transformArr[i] in divStyle) { // 找到之後立即返回,結束函式 return transform = transformArr[i]; } } // 如果沒有找到,就直接返回空字串 return transform; } |
該方法用於獲取瀏覽器支援的transform屬性。如果返回的為空字串,則表示當前瀏覽器並不支援transform,這個時候我們就需要使用left,top值來改變元素的位置。如果支援,就改變transform的值。
3、 如何獲取元素的初始位置
我們首先需要獲取到目標元素的初始位置,因此這裡我們需要一個專門用來獲取元素樣式的功能函式。
但是獲取元素樣式在IE瀏覽器與其他瀏覽器有一些不同,因此我們需要一個相容性的寫法。
1 2 3 4 |
function getStyle(elem, property) { // ie通過currentStyle來獲取元素的樣式,其他瀏覽器通過getComputedStyle來獲取 return document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(elem, false)[property] : elem.currentStyle[property]; } |
有了這個方法之後,就可以開始動手寫獲取目標元素初始位置的方法了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
function getTargetPos(elem) { var pos = {x: 0, y: 0}; var transform = getTransform(); if(transform) { var transformValue = getStyle(elem, transform); if(transformValue == 'none') { elem.style[transform] = 'translate(0, 0)'; return pos; } else { var temp = transformValue.match(/-?\d+/g); return pos = { x: parseInt(temp[4].trim()), y: parseInt(temp[5].trim()) } } } else { if(getStyle(elem, 'position') == 'static') { elem.style.position = 'relative'; return pos; } else { var x = parseInt(getStyle(elem, 'left') ? getStyle(elem, 'left') : 0); var y = parseInt(getStyle(elem, 'top') ? getStyle(elem, 'top') : 0); return pos = { x: x, y: y } } } } |
在拖拽過程中,我們需要不停的設定目標元素的新位置,這樣它才會移動起來,因此我們需要一個設定目標元素位置的方法。
1 2 3 4 5 6 7 8 9 10 11 |
// pos = { x: 200, y: 100 } function setTargetPos(elem, pos) { var transform = getTransform(); if(transform) { elem.style[transform] = 'translate('+ pos.x +'px, '+ pos.y +'px)'; } else { elem.style.left = pos.x + 'px'; elem.style.top = pos.y + 'px'; } return elem; } |
5、我們需要用到哪些事件?
在pc上的瀏覽器中,結合mousedown、mousemove、mouseup
這三個事件可以幫助我們實現拖拽。
mousedown
滑鼠按下時觸發mousemove
滑鼠按下後拖動時觸發mouseup
滑鼠鬆開時觸發
而在移動端,分別與之對應的則是
touchstart、touchmove、touchend
。
當我們將元素繫結這些事件時,有一個事件物件將會作為引數傳遞給回撥函式,通過事件物件,我們可以獲取到當前滑鼠的精確位置,滑鼠位置資訊是實現拖拽的關鍵。
事件物件十分重要,其中包含了非常多的有用的資訊,這裡我就不擴充套件了,大家可以在函式中將事件物件列印出來檢視其中的具體屬性,這個方法對於記不清事件物件重要屬性的童鞋非常有用。
6、拖拽的原理
當事件觸發時,我們可以通過事件物件獲取到滑鼠的精切位置。這是實現拖拽的關鍵。當滑鼠按下(mousedown觸發)時,我們需要記住滑鼠的初始位置與目標元素的初始位置,我們的目標就是實現當滑鼠移動時,目標元素也跟著移動,根據常理我們可以得出如下關係:
1 |
移動後的滑鼠位置 - 滑鼠初始位置 = 移動後的目標元素位置 - 目標元素的初始位置 |
如果滑鼠位置的差值我們用dis來表示,那麼目標元素的位置就等於:
1 |
移動後目標元素的位置 = dis + 目標元素的初始位置 |
通過事件物件,我們可以精確的知道滑鼠的當前位置,因此當滑鼠拖動(mousemove)時,我們可以不停的計算出滑鼠移動的差值,以此來求出目標元素的當前位置。這個過程,就實現了拖拽。
而在滑鼠鬆開(mouseup)結束拖拽時,我們需要處理一些收尾工作。詳情見程式碼。
7、 我又來推薦思維導圖輔助寫程式碼了
常常有新人朋友跑來問我,如果邏輯思維能力不強,能不能寫程式碼做前端。我的答案是:能。因為藉助思維導圖,可以很輕鬆的彌補邏輯的短板。而且比在自己頭腦中腦補邏輯更加清晰明瞭,不易出錯。
上面第六點我介紹了原理,因此如何做就顯得不是那麼難了,而具體的步驟,則在下面的思維導圖中明確給出,我們只需要按照這個步驟來寫程式碼即可,試試看,一定很輕鬆。
8、程式碼實現
part1、準備工作
1 2 3 4 5 6 7 8 9 10 |
// 獲取目標元素物件 var oElem = document.getElementById('target'); // 宣告2個變數用來儲存滑鼠初始位置的x,y座標 var startX = 0; var startY = 0; // 宣告2個變數用來儲存目標元素初始位置的x,y座標 var sourceX = 0; var sourceY = 0; |
part2、功能函式
因為之前已經貼過程式碼,就不再重複
1 2 3 4 5 6 7 8 9 10 11 |
// 獲取當前瀏覽器支援的transform相容寫法 function getTransform() {} // 獲取元素屬性 function getStyle(elem, property) {} // 獲取元素的初始位置 function getTargetPos(elem) {} // 設定元素的初始位置 function setTargetPos(elem, potions) {} |
part3、宣告三個事件的回撥函式
這三個方法就是實現拖拽的核心所在,我將嚴格按照上面思維導圖中的步驟來完成我們的程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// 繫結在mousedown上的回撥,event為傳入的事件物件 function start(event) { // 獲取滑鼠初始位置 startX = event.pageX; startY = event.pageY; // 獲取元素初始位置 var pos = getTargetPos(oElem); sourceX = pos.x; sourceY = pos.y; // 繫結 document.addEventListener('mousemove', move, false); document.addEventListener('mouseup', end, false); } function move(event) { // 獲取滑鼠當前位置 var currentX = event.pageX; var currentY = event.pageY; // 計算差值 var distanceX = currentX - startX; var distanceY = currentY - startY; // 計算並設定元素當前位置 setTargetPos(oElem, { x: (sourceX + distanceX).toFixed(), y: (sourceY + distanceY).toFixed() }) } function end(event) { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', end); // do other things } |
OK,一個簡單的拖拽,就這樣愉快的實現了。點選下面的連結,可以線上檢視該例子的demo。
9、封裝拖拽物件
在前面一章我給大家分享了物件導向如何實現,基於那些基礎知識,我們來將上面實現的拖拽封裝為一個拖拽物件。我們的目標是,只要我們宣告一個拖拽例項,那麼傳入的目標元素將自動具備可以被拖拽的功能。
在實際開發中,一個物件我們常常會單獨放在一個js檔案中,這個js檔案將單獨作為一個模組,利用各種模組的方式組織起來使用。當然這裡沒有複雜的模組互動,因為這個例子,我們只需要一個模組即可。
為了避免變數汙染,我們需要將模組放置於一個函式自執行方式模擬的塊級作用域中。
1 2 3 4 |
; (function() { ... })(); |
在普通的模組組織中,我們只是單純的將許多js檔案壓縮成為一個js檔案,因此此處的第一個分號則是為了防止上一個模組的結尾不用分號導致報錯。必不可少。當然在通過require或者ES6模組等方式就不會出現這樣的情況。
我們知道,在封裝一個物件的時候,我們可以將屬性與方法放置於建構函式或者原型中,而在增加了自執行函式之後,我們又可以將屬性和方法防止與模組的內部作用域。這是閉包的知識。
那麼我們面臨的挑戰就在於,如何合理的處理屬性與方法的位置。
當然,每一個物件的情況都不一樣,不能一概而論,我們需要清晰的知道這三種位置的特性才能做出最適合的決定。
- 建構函式中: 屬性與方法為當前例項單獨擁有,只能被當前例項訪問,並且每宣告一個例項,其中的方法都會被重新建立一次。
- 原型中: 屬性與方法為所有例項共同擁有,可以被所有例項訪問,新宣告例項不會重複建立方法。
- 模組作用域中:屬性和方法不能被任何例項訪問,但是能被內部方法訪問,新宣告的例項,不會重複建立相同的方法。
對於方法的判斷比較簡單。
因為在建構函式中的方法總會在宣告一個新的例項時被重複建立,因此我們宣告的方法都儘量避免出現在建構函式中。
而如果你的方法中需要用到建構函式中的變數,或者想要公開,那就需要放在原型中。
如果方法需要私有不被外界訪問,那麼就放置在模組作用域中。
對於屬性放置於什麼位置有的時候很難做出正確的判斷,因此我很難給出一個準確的定義告訴你什麼屬性一定要放在什麼位置,這需要在實際開發中不斷的總結經驗。但是總的來說,仍然要結合這三個位置的特性來做出最合適的判斷。
如果屬性值只能被例項單獨擁有,比如person物件的name,只能屬於某一個person例項,又比如這裡拖拽物件中,某一個元素的初始位置,也僅僅只是這個元素的當前位置,這個屬性,則適合放在建構函式中。
而如果一個屬性僅僅供內部方法訪問,這個屬性就適合放在模組作用域中。
關於物件導向,上面的幾點思考我認為是這篇文章最值得認真思考的精華。如果在封裝時沒有思考清楚,很可能會遇到很多你意想不到的bug,所以建議大家結合自己的開發經驗,多多思考,總結出自己的觀點。
根據這些思考,大家可以自己嘗試封裝一下。然後與我的做一些對比,看看我們的想法有什麼不同,在下面例子的註釋中,我將自己的想法表達出來。
js 原始碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
; (function() { // 這是一個私有屬性,不需要被例項訪問 var transform = getTransform(); function Drag(selector) { // 放在建構函式中的屬性,都是屬於每一個例項單獨擁有 this.elem = typeof selector == 'Object' ? selector : document.getElementById(selector); this.startX = 0; this.startY = 0; this.sourceX = 0; this.sourceY = 0; this.init(); } // 原型 Drag.prototype = { constructor: Drag, init: function() { // 初始時需要做些什麼事情 this.setDrag(); }, // 稍作改造,僅用於獲取當前元素的屬性,類似於getName getStyle: function(property) { return document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(this.elem, false)[property] : this.elem.currentStyle[property]; }, // 用來獲取當前元素的位置資訊,注意與之前的不同之處 getPosition: function() { var pos = {x: 0, y: 0}; if(transform) { var transformValue = this.getStyle(transform); if(transformValue == 'none') { this.elem.style[transform] = 'translate(0, 0)'; } else { var temp = transformValue.match(/-?\d+/g); pos = { x: parseInt(temp[4].trim()), y: parseInt(temp[5].trim()) } } } else { if(this.getStyle('position') == 'static') { this.elem.style.position = 'relative'; } else { pos = { x: parseInt(this.getStyle('left') ? this.getStyle('left') : 0), y: parseInt(this.getStyle('top') ? this.getStyle('top') : 0) } } } return pos; }, // 用來設定當前元素的位置 setPostion: function(pos) { if(transform) { this.elem.style[transform] = 'translate('+ pos.x +'px, '+ pos.y +'px)'; } else { this.elem.style.left = pos.x + 'px'; this.elem.style.top = pos.y + 'px'; } }, // 該方法用來繫結事件 setDrag: function() { var self = this; this.elem.addEventListener('mousedown', start, false); function start(event) { self.startX = event.pageX; self.startY = event.pageY; var pos = self.getPosition(); self.sourceX = pos.x; self.sourceY = pos.y; document.addEventListener('mousemove', move, false); document.addEventListener('mouseup', end, false); } function move(event) { var currentX = event.pageX; var currentY = event.pageY; var distanceX = currentX - self.startX; var distanceY = currentY - self.startY; self.setPostion({ x: (self.sourceX + distanceX).toFixed(), y: (self.sourceY + distanceY).toFixed() }) } function end(event) { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', end); // do other things } } } // 私有方法,僅僅用來獲取transform的相容寫法 function getTransform() { var transform = '', divStyle = document.createElement('div').style, transformArr = ['transform', 'webkitTransform', 'MozTransform', 'msTransform', 'OTransform'], i = 0, len = transformArr.length; for(; i < len; i++) { if(transformArr[i] in divStyle) { return transform = transformArr[i]; } } return transform; } // 一種對外暴露的方式 window.Drag = Drag; })(); // 使用:宣告2個拖拽例項 new Drag('target'); new Drag('target2'); |
這樣一個拖拽物件就封裝完畢了。
建議大家根據我提供的思維方式,多多嘗試封裝一些元件。比如封裝一個彈窗,封裝一個迴圈輪播等。練得多了,物件導向就不再是問題了。這種思維方式,在未來任何時候都是能夠用到的。
下一章分析jQuery物件的實現,與如何將我們這裡封裝的拖拽物件擴充套件為jQuery外掛。