記一次矩陣列單元格合併和拆分元件的開發
1、思路來源
最近公司做商城的專案,商城首頁有樓層設計,樓層需要自定義佈局,於是在運營端配置的時候就需要預定一個矩陣列,通過滑鼠滑動,確定最終的樓層佈局。
拿到需求,第一個想到的是以前學過的一個開發數獨遊戲的課程,雖然需求不太一樣,但都是基於宮格這樣的結構開發的。
這個數獨遊戲是基於原生DOM,使用ES6的class和less動態樣式表開發,最後使用gulp+webpack打包生成。藉此基礎,為了讓我的元件能夠相容原生和框架,我選擇了基於原生DOM,使用ES6的class和less動態樣式表開發,最後使用webpack打包生成,釋出至GitHub開源,釋出到npm倉庫。在使用的的時候只需要new一個例項物件,就生成了所需要的可以合併、拆分的矩陣列,如下:
專案地址:https://github.com/cumtchj/merge-split-box 歡迎star
2、專案搭建
專案使用webpack打包,涉及到ES6語法,就需要使用babel,樣式使用scss(最終打包出了點小問題,把scss全部轉為了使用js直接操作dom樣式),開發過程中為了可以實時檢視結果,使用了webpack-dev-server熱啟動。
專案結構如下:
打包配置如下:
module.exports = function () { let config = { optimization: { minimizer: [] }, plugins: [new HtmlWebpackPlugin({ template: "./src/index.html", }), new CleanWebpackPlugin({cleanOnceBeforeBuildPatterns: ["dist"]}), ], } if (ENV === "development") { config.devServer = { contentBase: "./dist", open: true, port: 8080, hot: true, hotOnly:true }; config.plugins.push(new webpack.HotModuleReplacementPlugin()) } // 打包以後使用壓縮,出現不知名報錯,故棄用 // if(ENV==="production"){ // config.optimization.minimizer.push( new UglifyjsWebpackPlugin( // { // uglifyOptions: { // mangle: true, // output: { // comments: false, // beautify: false, // }, // } // } // )) // } return { mode: ENV, devtool: ENV === "production" ? false : "clean-module-eval-source-map", entry: { index: PACK_TYPE === 'example' ? './src/example' : './src/index' }, output: { path: path.resolve(__dirname, "dist"), filename: "[name].js", library: "MergeSplitBox", libraryTarget: "umd", libraryExport: "default" }, resolve: { extensions: [".js", '.scss'] }, module: { rules: [ { test: /\.js$/, loader: "babel-loader", exclude: "/node_modules/", }, { test: /\.scss$/, use: [ 'style-loader', { loader: "css-loader", options: { importLoaders: 2, modules: true } }, 'sass-loader', 'postcss-loader' ] } ] }, ...config } }
在package.json中配置瞭如下scripts:
{ "scripts": { "build": "cross-env NODE_ENV=production webpack --config ./webpack.config.js", "build:example": "cross-env NODE_ENV=production PACK_TYPE=example webpack --config ./webpack.config.js", "start": " cross-env NODE_ENV=development PACK_TYPE=example webpack-dev-server --config ./webpack.config.js" } }
"build" 命令用來打包生成最終的檔案,為了可以外部引入
"build:example" 命令用來打包生成一個example
"start" 命令熱啟動
3、專案開發
整個專案分為了4大塊:入口檔案、宮格主體、滑鼠滑過生成的蒙層、工具類。還有一個style類,這個在後續迭代可能會使用
1)入口檔案
入口類index主要負責接收引數,格式化引數,執行初始化操作,獲取結果值。暴露了一個獲取結果值的方法getRes()
class MergeSplitBox { constructor(container, row, col, fn, style) { // 格式化引數 this._container = typeof container === "string" ? container.startsWith("#") ? document.querySelector(container) : container.startsWith(".") ? document.querySelector(container)[0] : document.getElementById(container) : container // 初始化宮格 this.grid = new Grid(this._container, row, col, fn, style) this.grid.build() this.grid.merge() } // 暴露結果方法 getRes() { return this.grid.res; } }
2)宮格主體
宮格主體類最為複雜,其中包括渲染DOM、繪製合併事件、拆分事件、計算新的資料、重渲染
1.渲染DOM
因為單元格佈局會出現跨行跨列的情況,經過考慮,宮格使用grid佈局,grid佈局的相容性如下(來自MDN):
遍歷拍平的宮格二維陣列,陣列每一項都有disabled屬性,通過判斷disabled屬性判斷渲染DOM,
createGrid() { // 清空容器 this._container.innerHTML = ""; this._idList = [] // 遍歷生成items並追加至容器 tools.flatten(this._array).forEach(item => { if (!item.disabled) { let div = document.createElement("div"); // 設定item樣式 div.innerText = `${item.top}_${item.left}`; // 設定每個格子樣式 ... if (item.row !== 1 || item.col !== 1) { let button = document.createElement("input") // 設定button樣式 ... button.type = "button" button.value = "拆分" let id = `${item.left}_${item.top}_${item.row}_${item.col}` button.id = id button.onclick = this.split.bind(this) this._idList.push(id) button.setAttribute("data", id) div.append(button); // 追加多個格子的樣式 div.style.gridColumnStart = item.left; div.style.gridColumnEnd = item.left + item.col; div.style.gridRowStart = item.top; div.style.gridRowEnd = item.top + item.row; } this._container.append(div); } }) this.res = tools.flatten(JSON.parse(JSON.stringify(this._array))).filter(item => !item.disabled).map(item => ({ left: item.left, top: item.top, row: item.row, col: item.col, })) // console.log(this.res) this._onChange && this._onChange(this.res) }
2.繪製合併事件
繪製合併主要分為兩部分:1、蒙層;2、宮格主體。蒙層因為是獨立的類,所以只需要給到座標就可以了。主要是宮格主體。合併事件主要分為三部分:mousedown事件、mousemove事件、mouseup事件。
mousedown事件,主要是記錄滑鼠按下時候的座標,座標取的是:
e.clientX - this._container.getBoundingClientRect().left e.clientY - this._container.getBoundingClientRect().top // 滑鼠按下的點在螢幕上的座標 - 容器距離螢幕的距離 關於滑鼠點的event的座標: e.clientX、e.clientY 滑鼠相對於瀏覽器視窗可視區域的X,Y座標(視窗座標),可視區域不包括工具欄和滾動條。IE事件和標準事件都定義了這2個屬性 e.pageX、e.pageY 類似於e.clientX、e.clientY,但它們使用的是文件座標而非視窗座標。這2個屬性不是標準屬性,但得到了廣泛支援。IE事件中沒有這2個屬性。 e.offsetX、e.offsetY 滑鼠相對於事件源元素(srcElement)的X,Y座標,只有IE事件有這2個屬性,標準事件沒有對應的屬性。 e.screenX、e.screenY 滑鼠相對於使用者顯示器螢幕左上角的X,Y座標。標準事件和IE事件都定義了這2個屬性 關於元素的位置距離: let rectObject = object.getBoundingClientRect(); rectObject.top:元素上邊到視窗上邊的距離; rectObject.right:元素右邊到視窗左邊的距離; rectObject.bottom:元素下邊到視窗上邊的距離; rectObject.left:元素左邊到視窗左邊的距離;
mousemove事件,獲取座標,同時resize蒙層,其中需要考慮滑鼠移出宮格範圍的情況,處理方式是把結束的座標設為宮格容器邊緣的座標
this._container.addEventListener('mousemove', e => { if (this._isSelect) { e.stopPropagation() let pos = this.getPos(e) this._endX = pos[0]; this._endY = pos[1]; // 超出範圍 if (this._endX <= 1 || this._endX >= (this._unitWidthNum * this._col) || this._endY <= 1 || this._endY >= (this._unitWidthNum * this._row)) { if (this._endX <= 1) { this._endX = 1 } if (this._endX >= (this._unitWidthNum * this._col)) { this._endX = (this._unitWidthNum * this._col) } if (this._endY <= 1) { this._endY = 1 } if (this._endY >= (this._unitWidthNum * this._row)) { this._endY = (this._unitWidthNum * this._row) } this.destroyCover(); } if (this._cover && this._cover.cover) { this._cover.resize(this._endX, this._endY) } } })
mouseup事件,和mousemove類似,得到結束點座標,如果結束點和mousedown的起始點不是同一個點,那證明不是click事件,是一個範圍,就需要計算蒙層範圍,改變陣列資料,rebuild宮格
this._container.addEventListener('mouseup', e => { if (this._isSelect) { this._isSelect = false e.stopPropagation() let pos = this.getPos(e) this._endX = pos[0]; this._endY = pos[1]; if (this._endX <= 1 || this._endX >= (this._unitWidthNum * this._col) || this._endY <= 1 || this._endY >= (this._unitWidthNum * this._row)) { if (this._endX <= 1) { this._endX = 1 } if (this._endX >= (this._unitWidthNum * this._col)) { this._endX = (this._unitWidthNum * this._col) } if (this._endY <= 1) { this._endY = 1 } if (this._endY >= (this._unitWidthNum * this._row)) { this._endY = (this._unitWidthNum * this._row) } } // console.log('end==', this._endX, this._endY) this.destroyCover(); if (Math.abs(this._endX - this._startX) > 0 || Math.abs(this._endY - this._startY)) { this.rebuild(); } } })
3.拆分事件
拆分事件是點選合併後的宮格內的button觸發的,此處有一個this指向的問題,因此在給button繫結事件的時候,使用bind函式改變了事件函式的this指向。
button.onclick = this.split.bind(this)
拆分事件做了幾件事件:
- 拿到點選的按鈕所在單元格對應的資料在整個二維陣列的座標
- 根據按鈕所在單元格對應的資料計算出該合併範圍最後一個單元格在整個二維陣列的座標
- 遍歷修改要拆分範圍的二維陣列資料的disabled狀態
- 清除記錄的合併範圍陣列中對應的合併範圍的那條資料
- 重新渲染宮格
split(e) { e.stopPropagation(); // 拿到第一個單元格的資料 let [left, top, row, col] = e.target.getAttribute("data").split("_").map(item => +item) // 計算第一個單元格和最後一個單元格在二維陣列的座標 let [xMin, yMin] = [top - 1, left - 1] let [xMax, yMax] = [top + row - 2, left + col - 2] // 遍歷改變資料 for (let i = xMin; i <= xMax; i++) { for (let j = yMin; j <= yMax; j++) { this._array[i][j].col = 1 this._array[i][j].row = 1 this._array[i][j].disabled = false } } // 清除 _areaList 中對應的資料 this.clearAreaList({xMin, xMax, yMin, yMax}) this.createGrid(); }
4.計算新的資料
主要是在繪製一個範圍以後,計算範圍之內對應的二維陣列,其中難點在於新畫出的範圍和之前已經合併的範圍有重疊,處理方案是將重疊範圍合併以後取邊緣組成的矩形,這樣會出現合併以後的大矩形和其他的合併區域又有新的交叉的問題,這樣需要再次判斷交叉問題,就使用了遞迴
checkOverlap(area) { // 判斷是否存在交叉,查詢區域數組裡面是否存在交叉項 let index = this._areaArray.findIndex(item => !( (item.xMin > area.xMax) || (item.xMax < area.xMin) || (item.yMin > area.yMax) || (item.yMax < area.yMin)) ) if (index > -1) { // 找到,存在交叉,合併區域 let obj = { xMin: Math.min(this._areaArray[index].xMin, area.xMin), xMax: Math.max(this._areaArray[index].xMax, area.xMax), yMin: Math.min(this._areaArray[index].yMin, area.yMin), yMax: Math.max(this._areaArray[index].yMax, area.yMax) } // 陣列中刪掉找到的這一項 this._areaArray.splice(index, 1) // 遞迴判斷 this.checkOverlap(obj) } else { // 找不到,不存在交叉,建立一個新的區域 this._areaArray.push(area) } }
3)蒙層
考慮到蒙層除了指示使用者繪製合併的範圍的作用之外,和宮格主體沒有其他關係,所以單獨把蒙層抽離成一個Cover類,其中包括蒙層的初始化、滑鼠滑動過程中的尺寸變化resize、最後滑鼠鬆開的銷燬操作。其中主要是尺寸變化resize,涉及到尺寸的計算,具體如下:
resize(x, y) { this.endX = x; this.endY = y; if (this.endX < this.startX) { this.cover.style.left = this.endX + 'px' } if (this.endY < this.startY) { this.cover.style.top = this.endY + 'px' } let width = Math.abs(this.endX - this.startX) let height = Math.abs(this.endY - this.startY) this.cover.style.width = width + 'px'; this.cover.style.height = height + 'px'; }
根據初始座標和結束座標,實時計算蒙層矩形的尺寸和位置
4)工具類
工具類分為兩個部分:和業務相關utils,和業務無關的純工具tools
1、utils
主要是用來生成初始化的宮格資料,通過傳入的行和列,生成一個二維陣列
class Utils { // 生成陣列行 makeRow(row, col) { return Array.from({length: col}).map((item, index) => ({ left: index + 1, top: row + 1, row: 1, col: 1, disabled: false })) } // 生成陣列矩陣 makeArray(row, col) { return Array.from({length: row}).map((item, index) => { return this.makeRow(index, col) }) } }
2.tools
主要是用來拍平二維陣列,便於渲染DOM元素
4、開發遇到的問題
1)打包問題
打包過後的包,樣式沒有生效,嘗試多次無果,最後選擇通過在DOM物件上直接修改樣式的方式,後續重構修改
2)滑鼠滑出界問題
滑鼠有時候繪製速度過快,滑出容器,會導致繪製失敗,具體原因應該是滑動速度過快導致,具體還要找原因優化