記一次矩陣列單元格合併和拆分組件的開發

(數據科學學習手札91)在Python中妥善使用進度條

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)
  }

 

無廢話設計模式(7)結構型模式–裝飾模式

 

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)

拆分事件做了幾件事件:

  1. 拿到點擊的按鈕所在單元格對應的數據在整個二維數組的坐標
  2. 根據按鈕所在單元格對應的數據計算出該合併範圍最後一個單元格在整個二維數組的坐標
  3. 遍歷修改要拆分範圍的二維數組數據的disabled狀態
  4. 清除記錄的合併範圍數組中對應的合併範圍的那條數據
  5. 重新渲染宮格
 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)鼠標滑出界問題

鼠標有時候繪製速度過快,滑出容器,會導致繪製失敗,具體原因應該是滑動速度過快導致,具體還要找原因優化

記一次矩陣列單元格合併和拆分組件的開發
免責聲明:非本網註明原創的信息,皆為程序自動獲取互聯網,目的在於傳遞更多信息,並不代表本網贊同其觀點和對其真實性負責;如此頁面有侵犯到您的權益,請給站長發送郵件,並提供相關證明(版權證明、身份證正反面、侵權鏈接),站長將在收到郵件12小時內刪除。

這屆 Showgirl行不行?AI告訴你誰是ChinaJoy上最漂亮的小姐姐