原生DOM基本功 -- 成语匹配实现拖拽、回弹和吸附效果

1,772 阅读11分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

参考B站UP主“前端小野森森-1”的视频制作的,原视频链接:www.bilibili.com/video/BV1D3… why-mobile.gif

1. 视图布局

1.1 顶部输入区域的四个盒子

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>拖拽、回弹和吸附的成语匹配</title>
    <script>
      document.documentElement.style.fontSize = '10px'
    </script>
  </head>
  <body>
    <div id="app">
      <div class="container">
        <!-- 顶部空白盒子 -->
        <div class="blank-cell-group">
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
        </div>
      </div>
    </div>
  </body>
</html>

这里在.cell-item内部再放.wrapper的目的是给.cell-item加上内边距后,能让.wrapper之间出现间隙

<style>
  body {
    margin: 0;
    background-color: #114b5f;
  }

  div {
    display: flex;
    flex-direction: column;
  }

  /* 盒子组容器 flex 布局 */
  .blank-cell-group,
  .char-cell-group {
    width: 100%;
    flex-direction: row;
  }

  /* 让每个盒子是一个正方形,并用 padding 添加间隙 */
  .blank-cell-group .cell-item,
  .char-cell-group .cell-item {
    width: 25%;
    height: 25vw;
    padding: 0.5rem;
    box-sizing: border-box;
  }

  /* 给盒子添加边框 */
  .blank-cell-group .cell-item .wrapper,
  .char-cell-group .cell-item .wrapper {
    width: 100%;
    height: 100%;
    border: 0.5rem solid #456990;
    box-sizing: border-box;
    border-radius: 1rem;
  }

  /* 字符盒子 flex wrap */
  .char-cell-group {
    flex-wrap: wrap;
    margin-top: 5rem;
  }

  /* 字符盒子单独的样式 */
  .char-cell-group .cell-item .wrapper {
    border: none;
  }
</style>

border-box是因为如果用content-box的话宽度会溢出屏幕,导致出现滚动条,改成border-box,宽度的计算会包括borderpadding,从而达到我们想要的效果 效果图如下 image.png

1.2 底下的字符盒子

<!-- 字符盒子 -->
<div class="char-cell-group">
  <div class="cell-item">
    <div class="wrapper"></div>
  </div>
  <div class="cell-item">
    <div class="wrapper"></div>
  </div>
  <div class="cell-item">
    <div class="wrapper"></div>
  </div>
  <div class="cell-item">
    <div class="wrapper"></div>
  </div>
  <div class="cell-item">
    <div class="wrapper"></div>
  </div>
  <div class="cell-item">
    <div class="wrapper"></div>
  </div>
  <div class="cell-item">
    <div class="wrapper"></div>
  </div>
  <div class="cell-item">
    <div class="wrapper"></div>
  </div>
</div>
/* 盒子组容器 flex 布局 */
.blank-cell-group,
.char-cell-group {
  width: 100%;
  flex-direction: row;
}

/* 让每个盒子是一个正方形,并用 padding 添加间隙 */
.blank-cell-group .cell-item,
.char-cell-group .cell-item {
  width: 25%;
  height: 25vw;
  padding: 0.5rem;
  box-sizing: border-box;
}

/* 给盒子添加边框 */
.blank-cell-group .cell-item .wrapper,
.char-cell-group .cell-item .wrapper {
  width: 100%;
  height: 100%;
  border: 0.5rem solid #456990;
  box-sizing: border-box;
  border-radius: 1rem;
}

/* 字符盒子 flex wrap */
.char-cell-group {
  flex-wrap: wrap;
  margin-top: 5rem;
}

/* 字符盒子单独的样式 */
.char-cell-group .cell-item .wrapper {
  justify-content: center;
  align-items: center;
  font-size: 3rem;
  color: #e4fde1;
  border: none;
  background-color: #456990;
}

字符盒子有部分样式和顶部盒子是一样的,直接将类名选择器加上去复用即可 效果图 image.png

2. 数据结构化与视图动态渲染

2.1 将成语数组打平成单个字符数组

<script>
  // <div class="cell-item">
  //   <div class="wrapper"></div>
  // </div>

  // ['诗情画意', '南来北往', '一团和气', '落花流水']

  // 立即执行函数 IIFE 用于形成一个单独的作用域
  ;(() => {
    const idioms = ['诗情画意', '南来北往', '一团和气', '落花流水']
    let charCollection = []

    const init = () => {
      charCollection = formatCharsArr()
    }
    init()

    // 将成语数组拆解成单个字符的数组,并且是乱序的
    function formatCharsArr() {
      // 私有变量
      let _arr = []
      idioms.forEach((item) => {
        _arr = _arr.concat(item.split(''))
      })

      return _arr.sort(randomSort)
    }

    // 将成语字符数组随机排序
    function randomSort(a, b) {
      return Math.random() > 0.5 ? -1 : 1
    }
  })()
</script>

2.2 用ES6模板字符串以及编写render函数渲染模板

目前我们需要做的就是将成语字符盒子动态地渲染到页面上,由前面的html框架可以知道,其实就是需要动态地渲染出.cell-item即可,传统的方式是手动创建结点并将数据添加到结点中,但是这种方式效率太低了,编写出来的代码也不优雅。 现在考虑使用ES6的新特性模板字符串以及手动编写一个渲染函数**render()**去实现只用提供数据,然后调用render函数即可实现将结点渲染到页面上。

function charCellTpl(char, index) {
    return `
        <div class="cell-item">
          <div class="wrapper" data-index="${index}">${char}</div>
        </div>
        `
}

**data-index**用于之后处理盒子回弹的时候,将盒子放回原来的位置,需要**data-index**记录每个盒子的初始坐标。 有了模板,现在就需要将它添加到存放字符盒子的容器盒子中,因此要先拿到 .char-cell-groupDOM元素

const oCharCellGroup = document.querySelector('.char-cell-group')
现在需要有一个渲染函数,能够从**charCollection**中获取数据然后调用模板函数生成html模板字符串,再放到`oCharCellGroup`中即可。
// 渲染函数
function render() {
  let list = ''

  charCollection.forEach((char, index) => {
    list += charCellTpl(char, index)
  })

  oCharCellGroup.innerHTML = list
}
渲染函数何时调用呢?应当在它依赖的数据**charCollection**一生成就开始渲染。
const init = () => {
  charCollection = formatCharsArr()
  render()
}

why-mobile.gif


3. 移动端事件与拖动盒子的位置移动

3.1 绑定事件

首先什么都别想,先将可能会用到的事件都给绑定上再说,思考一下移动拖拽会触发哪些事件呢? 从我们点击屏幕,到拖拽,到松开屏幕这一个过程,或者说好听点,有逼格一点,叫生命周期,很明显会涉及到三个事件,touchstarttouchmovetouchend。 这三个事件应该给谁绑定呢?很明显,是文字。 而文字放在哪里?放在.cell-item .wrapper里面。 那么我们现在就先把事件绑定给处理好,别想太多别的事情,先把基本的事件绑定处理好才是最关键的。 首先既然要绑定事件,就得先获取到对应的DOM元素,而对应的DOM元素是在render渲染函数执行完后才会有的,因此我们需要在render渲染函数执行完后获取所有的.cell-item .wrapper元素

let oChars = null

const init = () => {
  charCollection = formatCharsArr()
  render()

  oChars = oCharCellGroup.querySelectorAll('.cell-item .wrapper')
  bindEvents() // 肯定是要等所有的字符都渲染出来了才能够获取到它们并绑定事件
}
然后就是给它们绑定事件了
function bindEvents() {
  let oChar = null

  for (let i = 0; i < oChars.length; i++) {
    oChar = oChars[i]
    oChar.addEventListener('touchstart', handleTouchStart, false)
    oChar.addEventListener('touchmove', handleTouchMove, false)
    oChar.addEventListener('touchend', handleTouchEnd, false)
  }
}

function handleTouchStart() {}
function handleTouchMove() {}
function handleTouchEnd() {}

3.2 确定拖拽盒子会用到的坐标变量

思考一下,从触摸到盒子那一瞬间,到拖动,到放下,会涉及到哪些需要的信息? 首先鼠标的坐标肯定要有,将它们命名为startXstartY。 其次是盒子本身的坐标也是需要的,将它们命名为cellXcellY。 最后是需要用到鼠标离盒子左边框和上边框的距离,并且这个距离是一旦触摸到盒子之后就固定不变的,将它们命名为mouseXmouseY注意:它们不是坐标,而是距离mouseX表示鼠标触摸点到字符盒子左边框的距离,mouseY是鼠标触摸点到字符盒子上边框的距离。 补充一下:还需要获取到字符盒子的**宽和高**,因为在还没拖拽的时候它的宽高都是**100%**,是相对于父容器**.cell-item**的,拖拽之后就变成了**fixed**定位了,因此要设置固定的宽高,而宽高肯定要和拖拽之前一致,因此还需要两个变量**cellW****cellH**


3.3 touchstart要做的事情

  1. 最基本的肯定是要获取到上面定义的所有的坐标和距离变量
  2. 其次就是点击了之后就要将被点击的元素的display设置成fixed,这样它才能够在整个屏幕上滑动
  3. 最后就是要给被点击元素设置坐标和设置绝对的宽高,因为display改变了,因此要第一时间设置上它的topleft属性和宽高
function handleTouchStart(e) {
  // 1. 确定所有需要用到的变量
  cellW = this.offsetWidth // offsetXXX 等于 border-box 盒模型的宽高
  cellH = this.offsetHeight
  cellX = this.offsetLeft
  cellY = this.offsetTop
  startX = e.touches[0].clientX // 第一个触摸点距离 viewport 的 X 坐标
  startY = e.touches[0].clientY
  mouseX = startX - cellX
  mouseY = startY - cellY

  // 2. display 设置成 fixed
  this.style.position = 'fixed'

  // 3. 设置坐标和宽高

  // cellW 是 px 单位,而根的 font-size 是 10px,这样写就能保证和触摸前是一样的宽高
  this.style.width = cellW / 10 + 'rem'
  this.style.height = cellH / 10 + 'rem'
  this.style.left = cellX / 10 + 'rem'
  this.style.top = cellY / 10 + 'rem'
}

3.4 touchmove要做的事情

  1. 鼠标移动的时候,我们需要获取到移动过程中鼠标的坐标,将它们命名为moveXmoveY
  2. 计算移动时,字符盒子的坐标,这个很简单,前面我们已经获得了mouseXmouseY了,也就是鼠标相对于盒子的距离,那么我们用鼠标的当前坐标减去对应坐标轴的距离不就是盒子的坐标了吗?

image.png

function handleTouchMove(e) {
  const moveX = e.touches[0].clientX
  const moveY = e.touches[0].clientY

  cellX = moveX - mouseX
  cellY = moveY - mouseY

  this.style.left = cellX / 10 + 'rem'
  this.style.top = cellY / 10 + 'rem'
}

why-mobile.gif


4. 拖动回弹原位的逻辑实现

4.1 获取所有盒子初始坐标

首先需要获取到每个字符盒子的起始坐标,这样才能在鼠标松开的时候触发touchend然后将它们恢复到起始坐标处。 定义一个函数getAreas(domCollection, arrWrapper),该函数用于获取所有字符盒子的起始坐标,并将结果放入arrWrapper数组中,domCollection传入oCharCellGroup即可。 由于前面模板字符串中有设置每个字符盒子的下标,放在.cell-itemdata-index属性中,刚好能够和arrWrapper对应上。 这个函数之所以接收这两个参数,是因为后面还需要获取顶部四个空白盒子的坐标,逻辑和字符盒子坐标的获取是一样的,所以出于可复用的角度考虑,将该函数这样定义最合适,这样一来想要获取空白盒子的坐标的时候,只需要调用getAreas(空白盒子DOM元素数组,存放坐标的数组)即可。

function getAreas(domCollection, arrWrapper) {
  let startX = 0,
    startY = 0,
    oItem = null

  for (let i = 0; i < domCollection.length; i++) {
    oItem = domCollection[i]
    startX = oItem.offsetLeft
    startY = oItem.offsetTop

    arrWrapper.push({
      startX,
      startY,
    })
  }
}

何时调用该函数去获取呢?肯定是要在所有字符盒子渲染完成后就去获取。

let charAreas = []

const init = () => {
  charCollection = formatCharsArr()
  render()

  oChars = oCharCellGroup.querySelectorAll('.cell-item .wrapper')
  getAreas(oChars, charAreas)
  bindEvents() // 肯定是要等所有的字符都渲染出来了才能够获取到它们并绑定事件
}

image.png 现在有了charAreas,可以根据每个字符盒子的data-index属性拿到它们对应的初始下标了。


4.2 处理回弹逻辑

一旦松开鼠标,就将被拖拽的字符盒子的topleft属性设置为charAreas中对应下标的初始坐标即可。

function handleTouchEnd(e) {
  // this 在注册事件的时候隐式绑定到 oChar ,也就是被拖拽的字符盒子上
  const _index = parseInt(this.dataset.index),
    charArea = charAreas[_index]

  // 将字符盒子的 top 和 left 设置为 charAreas[_index] 对应值
  this.style.left = charArea.startX / 10 + 'rem'
  this.style.top = charArea.startY / 10 + 'rem'
}

why-mobile.gif


4.3 添加过渡动画

目前的回弹效果是直接就回去了,效果太生硬,可以考虑添加一下过渡动画。 那么就得思考了,该怎么添加?在哪里添加合适?直接在css中给字符盒子添加类似

transition: all 0.5s ease

这样子的样式吗?这样子显然不行,因为一个字符盒子被拖拽的整个过程中,它的高度宽度以及位置都被修改过,如果直接简单粗暴用all的话,那么它的宽高改变的时候也会受过渡动画的影响,导致最终的效果就是一点击字符盒子,首先大小会改变,然后变回来,并且拖拽的过程中会有明显的延迟感(500ms)的延迟,这是因为在拖拽过程中的位置改变其实我们不应该给它施加动画的,最合适的添加动画的时机应当是touchend事件中给它动态添加动画,因为我们要的只是最终的回弹动画,不需要触摸和移动时的动画。

function handleTouchEnd(e) {
  ...

  // 加上 transition 动画,让盒子回弹效果更加顺滑
  const transitionDuration = '.5s',
    transitionTimeFunction = 'ease'
  this.style.transition = `top ${transitionDuration} ${transitionTimeFunction}, left ${transitionDuration} ${transitionTimeFunction}`

  ...
}

注意:还应当在**touchstart**中删除过渡动画,否则这个动画会一直在字符盒子身上,导致移动的过程中也有,这就意味着会有拖拽延迟感了。

function handleTouchStart(e) {
  // 1. 确定所有需要用到的变量
  ...

  // 2. display 设置成 fixed
  ...

  // 3. 清除设置过的动画
  this.style.transition = ''

  // 4. 设置坐标和宽高
  ...
}

why-mobile.gif


5. 吸附功能的区域范围判断逻辑

5.1 获取顶部四个空白盒子的坐标

只需要调用前面的getAreas函数即可,先获取到四个空白盒子DOM元素数组,再定义一个blankAreas数组用来存放每个盒子的坐标结果,然后传给getAreas即可。

const oBlanks = document.querySelectorAll('.blank-cell-group .wrapper')
let blankAreas = []

const init = () => {
  // 空白盒子的坐标在一开始就可以获取,因为是静态的
  getAreas(oBlanks, blankAreas)

  ...
}
init()

5.2 吸附逻辑判断

首先要明确的是,吸附逻辑的判断代码应该在哪里添加?很明显,是在touchend的时候,因为松开屏幕,要么就是让字符盒子吸附到空白盒子内,要么就是回到自己原来的位置里,因此在touchend的处理中,先判断是否能够吸附,能吸附就不执行后面的回弹逻辑,直接return退出事件回调函数即可。 其次,需要遍历四个空白盒子,看看当前盒子是否有字符盒子放入,有的话就跳过当前这个空白盒子的吸附逻辑判断,进入下一个盒子的吸附逻辑判断,因此需要有一个数组来存放每个空白盒子的状态信息,需要保存的状态有两个:

  1. 当前空白盒子中存放的字符
  2. 当前空白盒子存放的字符盒子的DOM元素,用于之后全部盒子放完后判断完成语是否合法后将盒子们移回原处

这个数组我们就叫它blankStates吧。

let blankStates = [], // {char: '水', el: 字符盒子DOM元素}

接下来是吸附逻辑的处理了,吸附逻辑主要处理以下几件事情:

  1. 获取空白盒子的面积
  2. 遍历每一个空白盒子来判断当前被拖拽的元素应该放入哪个空白盒子中
  3. 计算被拖拽盒子占据当前遍历的空白盒子的面积百分比
  4. 吸附:将被拖拽盒子的坐标设置为当前遍历的空白盒子的坐标
  5. 更新当前空白盒子的状态信息
function handleTouchEnd(e) {
  // 加上 transition 动画,让盒子回弹效果更加顺滑
  const transitionDuration = '.5s',
    transitionTimeFunction = 'ease'
  this.style.transition = `top ${transitionDuration} ${transitionTimeFunction}, left ${transitionDuration} ${transitionTimeFunction}`

  // ============= 吸附逻辑 =============
  // 吸附的逻辑 -- 被拖拽元素占空白盒子的面积大于 50% 时就吸附
  // 1. 获取空白盒子的面积
  const blankArea = oBlanks[0].offsetWidth * oBlanks[0].offsetHeight

  // 2. 遍历每一个空白盒子来判断当前被拖拽的元素应该放入哪个空白盒子中
  for (let i = 0; i < oBlanks.length; i++) {
    // 判断当前盒子是否已有元素存放,有的话直接跳过这轮遍历
    if (blankStates[i] !== undefined) {
      continue
    }

    // 能来到这说明当前空白盒子中没有字符盒子,可以判断吸附逻辑了

    // 3. 计算被拖拽盒子占据空白盒子的面积百分比
    const oBlank = oBlanks[i]
    let occupiedArea = 0

    // 垂直长度 = 空白盒子高度 - (this 纵坐标 - 空白盒子上边框纵坐标)
    const verticalLength =
      oBlank.offsetHeight - (this.offsetTop - oBlank.offsetTop)
    let horizontalLength = 0

    // this 左边框在空白盒子左边框的 左边 和 右边 时,水平长度的计算方式不同,需要分开处理
    if (this.offsetLeft >= oBlank.offsetLeft) {
      // this 左边框在盒子左边框右边时的占用面积
      // 水平长度 = 空白盒子右边框横坐标 - this 横坐标
      horizontalLength =
        oBlank.offsetLeft + oBlank.offsetWidth - this.offsetLeft
    } else {
      // this 左边框在盒子左边框左边时的占用面积
      // 水平长度 = this 右边框横坐标 - 空白盒子左边框横坐标
      horizontalLength =
        this.offsetLeft + this.offsetWidth - oBlank.offsetLeft
    }

    // 占用面积 = 水平长度 * 垂直长度
    occupiedArea =
      horizontalLength > 0 && verticalLength > 0
        ? horizontalLength * verticalLength
        : 0

    if (occupiedArea / blankArea >= 0.5) {
      // 4. 吸附 --> 将 this 的坐标设置为 当前空白盒子的坐标
      this.style.top = oBlank.offsetTop / 10 + 'rem'
      this.style.left = oBlank.offsetLeft / 10 + 'rem'

      // 5. 更新当前空白盒子的状态信息 --> {char: this.innerText, el: this}
      blankStates[i] = {
        char: this.innerText,
        el: this,
      }
      console.log(blankStates)
      return // 吸附后不执行回弹逻辑
    }
  }

  // ============= 回弹逻辑 =============
  // this 在注册事件的时候隐式绑定到 oChar ,也就是被拖拽的字符盒子上
  const _index = parseInt(this.dataset.index),
    charArea = charAreas[_index]

  // 将字符盒子的 top 和 left 设置为 charAreas[_index] 对应值
  this.style.left = charArea.startX / 10 + 'rem'
  this.style.top = charArea.startY / 10 + 'rem'
}

注意:占用面积的计算一定要先判断水平长度和垂直长度是否都是正数,因为如果两个都是负数,那么相乘还是会为正数,并且很有可能大于空白盒子面积的一半,因此会出现单击一下字符盒子就直接吸附的bug出现。

occupiedArea =
      horizontalLength > 0 && verticalLength > 0
        ? horizontalLength * verticalLength
        : 0

主要的代码量是在计算被拖拽元素占用空白盒子的面积这一块,这里还修改了一下添加动画的位置,因为无论是回弹和吸附都是需要动画的,因此将添加动画的代码从回弹逻辑中抽离,添加到touchend的最开始。


6. 答案验证和重置

6.1 答案验证

答案的验证应当放在吸附完成之后执行,如果直接把答案验证的逻辑写在吸附的逻辑里面的话会让代码结构看起来很混乱,这里我们将答案的验证封装成一个函数checkAnwser来处理。 首先要明确什么时候调用checkAnwser,肯定是在全部空白盒子被填完的时候调用,那么如果直接用oBlanks.length === 4来判断是不行的,因为如果用户不是从第一个空白盒子开始放,而是先放后面的空白盒子,比如说先放在第3个空白盒子里,那么就会导致oBlanks的长度为3,因为吸附后会更新blankStates[2]的值,而blankStates本身是一个空数组,如果直接给下标2赋值的话,会强行填充两个empty在前面,因此考虑将blankStates赋值为包含四个undefined的空数组,然后判断的时候就用!blankStates.includes(undefined)来表示四个盒子都被填充完毕。

let blankStates = [undefined, undefined, undefined, undefined]

checkAnwser中要做那么几件事情:

  1. 从空白盒子中获取到输入的成语
  2. 判断成语是否在 idioms 中
  3. 无论结果是否正确,都要将字符盒子恢复到原来的位置
  4. 清空空白盒子状态 blankStates
function checkAnwser() {
  // 1. 从空白盒子中获取到输入的成语
  let idiom = ''
  blankStates.forEach((item) => {
    idiom += item.char
  })

  // 2. 判断成语是否在 idioms 中 -- 由于 alert 会阻塞浏览器,因此要放入计时器中延迟运行
  setTimeout(() => {
    if (idioms.includes(idiom)) {
      alert('正确')
    } else {
      alert('错误')
    }

    // 3. 将字符盒子恢复到原来的位置
    blankStates.forEach((item) => {
      const charEl = item.el
      const index = charEl.dataset.index

      charEl.style.left = charAreas[index].startX / 10 + 'rem'
      charEl.style.top = charAreas[index].startY / 10 + 'rem'
    })

    // 4. 清空空白盒子状态 blankStates
    for (let i = 0; i < blankStates.length; i++) {
      blankStates[i] = undefined
    }
  }, 500)
}

7. 重构优化

7.1 坐标修改的优化

当前代码中,太多的类似这种代码了

charEl.style.top = charAreas[index].startY / 10 + 'rem'

这种代码阅读性不好,应当封装一个函数pxToRem,用来统一转换pxrem单位。

function pxToRem(item) {
  return item / 10 + 'rem'
}

小技巧:可以利用正则表达式去修改这样的代码 image.png

7.2 handleTouchEnd中抽离逻辑

handleTouchEnd中的代码有点多了,包括了添加动画处理附加逻辑答案验证处理回弹逻辑,因此可以考虑将它们分别拆分成函数,让代码简洁一些

// 处理吸附逻辑 -- 返回 true 表示 charEl 当前吸附成功
function handleAdsorbed(charEl) {
  // 吸附的逻辑 -- 被拖拽元素占空白盒子的面积大于 50% 时就吸附
  // 1. 获取空白盒子的面积
  const blankArea = oBlanks[0].offsetWidth * oBlanks[0].offsetHeight

  // 2. 遍历每一个空白盒子来判断当前被拖拽的元素应该放入哪个空白盒子中
  for (let i = 0; i < oBlanks.length; i++) {
    // 判断当前盒子是否已有元素存放,有的话直接跳过这轮遍历
    if (blankStates[i] !== undefined) {
      continue
    }

    // 能来到这说明当前空白盒子中没有字符盒子,可以判断吸附逻辑了

    // 3. 计算被拖拽盒子占据空白盒子的面积百分比
    const oBlank = oBlanks[i]
    let occupiedArea = 0

    // 垂直长度 = 空白盒子高度 - (this 纵坐标 - 空白盒子上边框纵坐标)
    const verticalLength =
      oBlank.offsetHeight - (charEl.offsetTop - oBlank.offsetTop)
    let horizontalLength = 0

    // charEl 左边框在空白盒子左边框的 左边 和 右边 时,水平长度的计算方式不同,需要分开处理
    if (charEl.offsetLeft >= oBlank.offsetLeft) {
      // charEl 左边框在盒子左边框右边时的占用面积
      // 水平长度 = 空白盒子右边框横坐标 - charEl 横坐标
      horizontalLength =
        oBlank.offsetLeft + oBlank.offsetWidth - charEl.offsetLeft
    } else {
      // charEl 左边框在盒子左边框左边时的占用面积
      // 水平长度 = charEl 右边框横坐标 - 空白盒子左边框横坐标
      horizontalLength =
        charEl.offsetLeft + charEl.offsetWidth - oBlank.offsetLeft
    }

    // 占用面积 = 水平长度 * 垂直长度
    occupiedArea =
      horizontalLength > 0 && verticalLength > 0
        ? horizontalLength * verticalLength
        : 0

    if (occupiedArea / blankArea >= 0.5) {
      // 4. 吸附 --> 将 charEl 的坐标设置为 当前空白盒子的坐标
      charEl.style.top = pxToRem(oBlank.offsetTop)
      charEl.style.left = pxToRem(oBlank.offsetLeft)

      // 5. 更新当前空白盒子的状态信息 --> {char: charEl.innerText, el: charEl}
      blankStates[i] = {
        char: charEl.innerText,
        el: charEl,
      }

      return true // 吸附后不执行回弹逻辑
    }
  }
}
function handleReBound(charEl) {
  const _index = parseInt(charEl.dataset.index),
    charArea = charAreas[_index]

  // 将字符盒子的 top 和 left 设置为 charAreas[_index] 对应值
  charEl.style.left = pxToRem(charArea.startX)
  charEl.style.top = pxToRem(charArea.startY)
}

7.3 坐标修改封装

有大量的坐标修改的代码

charEl.style.top = pxToRem(oBlank.offsetTop)
charEl.style.left = pxToRem(oBlank.offsetLeft)

可以封装一个setPosition函数,统一修改DOM元素的坐标

function setPosition(dom, position) {
  dom.style.left = pxToRem(position[0])
  dom.style.top = pxToRem(position[1])
}

8. 完整代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>拖拽、回弹和吸附的成语匹配</title>
    <script>
      document.documentElement.style.fontSize = '10px'
    </script>
    <style>
      body {
        margin: 0;
        background-color: #114b5f;
      }

      div {
        display: flex;
        flex-direction: column;
      }

      /* 盒子组容器 flex 布局 */
      .blank-cell-group,
      .char-cell-group {
        width: 100%;
        flex-direction: row;
      }

      /* 让每个盒子是一个正方形,并用 padding 添加间隙 */
      .blank-cell-group .cell-item,
      .char-cell-group .cell-item {
        width: 25%;
        height: 25vw;
        padding: 0.5rem;
        box-sizing: border-box;
      }

      /* 给盒子添加边框 */
      .blank-cell-group .cell-item .wrapper,
      .char-cell-group .cell-item .wrapper {
        width: 100%;
        height: 100%;
        border: 0.5rem solid #456990;
        box-sizing: border-box;
        border-radius: 1rem;
      }

      /* 字符盒子 flex wrap */
      .char-cell-group {
        flex-wrap: wrap;
        margin-top: 5rem;
      }

      /* 字符盒子单独的样式 */
      .char-cell-group .cell-item .wrapper {
        justify-content: center;
        align-items: center;
        font-size: 3rem;
        color: #e4fde1;
        border: none;
        background-color: #456990;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="container">
        <!-- 顶部空白盒子 -->
        <div class="blank-cell-group">
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
          <div class="cell-item">
            <div class="wrapper"></div>
          </div>
        </div>
        <!-- 字符盒子 -->
        <div class="char-cell-group"></div>
      </div>
    </div>

    <script>
      // 立即执行函数 IIFE 用于形成一个单独的作用域
      ;(() => {
        const idioms = ['诗情画意', '南来北往', '一团和气', '落花流水'],
          oCharCellGroup = document.querySelector('.char-cell-group'),
          oBlanks = document.querySelectorAll('.blank-cell-group .wrapper')
        let charCollection = [],
          charAreas = [],
          blankAreas = [],
          blankStates = [undefined, undefined, undefined, undefined], // {char: '水', el: 字符盒子DOM元素}
          oChars = null,
          startX = 0,
          startY = 0,
          cellX = 0,
          cellY = 0,
          cellH = 0,
          cellW = 0,
          mouseX = 0,
          mouseY = 0

        // ============================== 初始化函数 ==============================
        const init = () => {
          // 空白盒子的坐标在一开始就可以获取,因为是静态的
          getAreas(oBlanks, blankAreas)

          charCollection = formatCharsArr() // 将成语数组打乱成随机单个字符组成的数组
          render()

          oChars = oCharCellGroup.querySelectorAll('.cell-item .wrapper')
          getAreas(oChars, charAreas)
          bindEvents() // 肯定是要等所有的字符都渲染出来了才能够获取到它们并绑定事件
        }
        init()

        // ============================== 普通函数 ==============================
        // 将成语数组拆解成单个字符的数组,并且是乱序的
        function formatCharsArr() {
          // 私有变量
          let _arr = []
          idioms.forEach((item) => {
            _arr = _arr.concat(item.split(''))
          })

          return _arr.sort(randomSort)
        }

        // 将成语字符数组随机排序
        function randomSort(a, b) {
          return Math.random() > 0.5 ? -1 : 1
        }

        // 获取所有字符盒子的初始坐标并存入数组中
        function getAreas(domCollection, arrWrapper) {
          let startX = 0,
            startY = 0,
            oItem = null

          for (let i = 0; i < domCollection.length; i++) {
            oItem = domCollection[i]
            startX = oItem.offsetLeft
            startY = oItem.offsetTop

            arrWrapper.push({
              startX,
              startY,
            })
          }
        }

        // 答案验证
        function checkAnwser() {
          // 1. 从空白盒子中获取到输入的成语
          let idiom = ''
          blankStates.forEach((item) => {
            idiom += item.char
          })

          // 2. 判断成语是否在 idioms 中 -- 由于 alert 会阻塞浏览器,因此要放入计时器中延迟运行
          setTimeout(() => {
            if (idioms.includes(idiom)) {
              alert('正确')
            } else {
              alert('错误')
            }

            // 3. 将字符盒子恢复到原来的位置
            blankStates.forEach((item) => {
              const charEl = item.el
              const index = charEl.dataset.index

              setPosition(charEl, [
                charAreas[index].startX,
                charAreas[index].startY,
              ])
            })

            // 4. 清空空白盒子状态 blankStates
            for (let i = 0; i < blankStates.length; i++) {
              blankStates[i] = undefined
            }
          }, 500)
        }

        // 将 px 转为 rem
        function pxToRem(item) {
          return item / 10 + 'rem'
        }

        // 处理吸附逻辑 -- 返回 true 表示 charEl 当前吸附成功
        function handleAdsorbed(charEl) {
          // 吸附的逻辑 -- 被拖拽元素占空白盒子的面积大于 50% 时就吸附
          // 1. 获取空白盒子的面积
          const blankArea = oBlanks[0].offsetWidth * oBlanks[0].offsetHeight

          // 2. 遍历每一个空白盒子来判断当前被拖拽的元素应该放入哪个空白盒子中
          for (let i = 0; i < oBlanks.length; i++) {
            // 判断当前盒子是否已有元素存放,有的话直接跳过这轮遍历
            if (blankStates[i] !== undefined) {
              continue
            }

            // 能来到这说明当前空白盒子中没有字符盒子,可以判断吸附逻辑了

            // 3. 计算被拖拽盒子占据空白盒子的面积百分比
            const oBlank = oBlanks[i]
            let occupiedArea = 0

            // 垂直长度 = 空白盒子高度 - (this 纵坐标 - 空白盒子上边框纵坐标)
            const verticalLength =
              oBlank.offsetHeight - (charEl.offsetTop - oBlank.offsetTop)
            let horizontalLength = 0

            // charEl 左边框在空白盒子左边框的 左边 和 右边 时,水平长度的计算方式不同,需要分开处理
            if (charEl.offsetLeft >= oBlank.offsetLeft) {
              // charEl 左边框在盒子左边框右边时的占用面积
              // 水平长度 = 空白盒子右边框横坐标 - charEl 横坐标
              horizontalLength =
                oBlank.offsetLeft + oBlank.offsetWidth - charEl.offsetLeft
            } else {
              // charEl 左边框在盒子左边框左边时的占用面积
              // 水平长度 = charEl 右边框横坐标 - 空白盒子左边框横坐标
              horizontalLength =
                charEl.offsetLeft + charEl.offsetWidth - oBlank.offsetLeft
            }

            // 占用面积 = 水平长度 * 垂直长度
            occupiedArea =
              horizontalLength > 0 && verticalLength > 0
                ? horizontalLength * verticalLength
                : 0

            if (occupiedArea / blankArea >= 0.5) {
              // 4. 吸附 --> 将 charEl 的坐标设置为 当前空白盒子的坐标
              setPosition(charEl, [oBlank.offsetLeft, oBlank.offsetTop])

              // 5. 更新当前空白盒子的状态信息 --> {char: charEl.innerText, el: charEl}
              blankStates[i] = {
                char: charEl.innerText,
                el: charEl,
              }

              return true // 吸附后不执行回弹逻辑
            }
          }
        }

        // 处理回弹逻辑
        function handleReBound(charEl) {
          const _index = parseInt(charEl.dataset.index),
            charArea = charAreas[_index]

          // 将字符盒子的 top 和 left 设置为 charAreas[_index] 对应值
          setPosition(charEl, [charArea.startX, charArea.startY])
        }

        // 修改 DOM 元素坐标 -- position 是一个数组 [x, y]
        function setPosition(dom, position) {
          dom.style.left = pxToRem(position[0])
          dom.style.top = pxToRem(position[1])
        }

        // ============================== 渲染相关的函数 ==============================
        // 字符盒子 DOM 元素模板
        function charCellTpl(char, index) {
          return `
              <div class="cell-item">
                <div class="wrapper" data-index="${index}">${char}</div>
              </div>
              `
        }

        // 渲染函数
        function render() {
          let list = ''

          charCollection.forEach((char, index) => {
            list += charCellTpl(char, index)
          })

          oCharCellGroup.innerHTML = list
        }

        // ============================== 事件绑定函数 ==============================
        function bindEvents() {
          let oChar = null

          for (let i = 0; i < oChars.length; i++) {
            oChar = oChars[i]
            oChar.addEventListener('touchstart', handleTouchStart, false)
            oChar.addEventListener('touchmove', handleTouchMove, false)
            oChar.addEventListener('touchend', handleTouchEnd, false)
          }
        }

        function handleTouchStart(e) {
          // 1. 确定所有需要用到的变量
          cellW = this.offsetWidth // offsetXXX 等于 border-box 盒模型的宽高
          cellH = this.offsetHeight
          cellX = this.offsetLeft
          cellY = this.offsetTop
          startX = e.touches[0].clientX // 第一个触摸点距离 viewport 的 X 坐标
          startY = e.touches[0].clientY
          mouseX = startX - cellX
          mouseY = startY - cellY

          // 2. display 设置成 fixed
          this.style.position = 'fixed'

          // 3. 清除设置过的动画
          this.style.transition = ''

          // 4. 设置坐标和宽高

          // cellW 是 px 单位,而根的 font-size 是 10px,这样写就能保证和触摸前是一样的宽高
          this.style.width = pxToRem(cellW)
          this.style.height = pxToRem(cellH)
          setPosition(this, [cellX, cellY])
        }

        function handleTouchMove(e) {
          const moveX = e.touches[0].clientX
          const moveY = e.touches[0].clientY

          cellX = moveX - mouseX
          cellY = moveY - mouseY

          setPosition(this, [cellX, cellY])
        }

        function handleTouchEnd(e) {
          // 加上 transition 动画,让盒子回弹效果更加顺滑
          const transitionDuration = '.5s',
            transitionTimeFunction = 'ease'
          this.style.transition = `top ${transitionDuration} ${transitionTimeFunction}, left ${transitionDuration} ${transitionTimeFunction}`

          // ============= 吸附逻辑 =============
          let isAdsorbed = handleAdsorbed(this) // 标志当前被拖拽盒子是否已经吸附 吸附了就不执行回弹逻辑

          // 答案验证 -- 当且仅当所有空白盒子填写完毕的时候才验证答案
          if (!blankStates.includes(undefined)) checkAnwser()

          // ============= 回弹逻辑 =============
          if (!isAdsorbed) handleReBound(this)
        }
      })()
    </script>
  </body>
</html>