记一次矩阵列单元格合并和拆分组件的开发

1、思路来源

最近公司做商城的项目,商城首页有楼层设计,楼层需要自定义布局,于是在运营端配置的时候就需要预定一个矩阵列,通过鼠标滑动,确定最终的楼层布局。

image.png

拿到需求,第一个想到的是以前学过的一个开发数独游戏的课程,虽然需求不太一样,但都是基于宫格这样的结构开发的。

这个数独游戏是基于原生DOM,使用ES6的class和less动态样式表开发,最后使用gulp+webpack打包生成。借此基础,为了让我的组件能够兼容原生和框架,我选择了基于原生DOM,使用ES6的class和less动态样式表开发,最后使用webpack打包生成,发布至GitHub开源,发布到npm仓库。在使用的的时候只需要new一个实例对象,就生成了所需要的可以合并、拆分的矩阵列,如下:

aa.gif

项目地址:https://github.com/cumtchj/merge-split-box 欢迎star

2、项目搭建

项目使用webpack打包,涉及到ES6语法,就需要使用babel,样式使用scss(最终打包出了点小问题,把scss全部转为了使用js直接操作dom样式),开发过程中为了可以实时查看结果,使用了webpack-dev-server热启动。

项目结构如下:

image.png

打包配置如下:

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

image.png

遍历拍平的宫格二维数组,数组每一项都有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)

拆分事件做了几件事件:

  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)鼠标滑出界问题

鼠标有时候绘制速度过快,滑出容器,会导致绘制失败,具体原因应该是滑动速度过快导致,具体还要找原因优化

原文地址:https://www.cnblogs.com/cumtchj/p/13411277.html