用 Range 实现 CSSOM 精准操作

1,788 阅读12分钟

同学们好,我是三钻

这一部分的编程练习,我们来使用 RangeCSSOM 做一个综合练习。

这里我们一起来尝试实现一个简单的拖拽功能。我们一般的拖拽就是把一个在浏览器上的盒子捡起来,然后用鼠标可以拖动这个盒子到任意的位置。

那么我们今天要做的拖拽跟这个稍微有一点不一样。我们要允许这个盒子参与到我们的排版当中。意识就是说在我们的拖拽的过程中,实际是在改变他在页面上的排版的位置。

首先我们还是需要实现一个正常的拖拽。

基础拖拽

这里我们来开始实现一个基础的拖拽功能。

拖拽监听

  • 创建一个 draggable 的 div 元素
  • 给这个 div 赋予 属性 id="draggable",一个宽高和背景颜色
  • 使用 getElementById 获得这个 div 的 DOM 对象
  • 给这个 dragable 的 div 元素加上 addEventListener,拖拽这个动作我们是无法使用 drag 事件来实现,因为我们需要的是这个盒子跟随我们的鼠标移动,所以我们要用到的是以下几个监听事件:
    • mousedown —— 用于我们点击时触发
    • mousemove —— 用于我们点击后拖动时候触发
    • mouseup —— 用于我们松开鼠标点击时时触发

我们为了我们的拖拽能够响应 mousemovemouseup 的事件,我们需要在 mousedown 的事件里面去监听这两个事件。

为什么我们需要在 mousedown 的时候去监听呢?因为只有我们的鼠标在按下去之后我们去监听这个事件才能在性能上和逻辑上都正确

  1. 如果我们的 mousemove 写在 mousedown 之外,我们会发现当我们鼠标一移动,mousemove 就触发了。
  2. 就算我们使用了一个 flag 来标记当前状态,让我们在 mousedown没有发生的情况下不去触发,但是它在性能上总是要多执行一遍这个函数。
  3. 还有我们实际上 mousemovemouseup 都是要在 document 上去进行监听的,如果我们在 dragable 这个 div 元素上去监听,就容易出现当我们鼠标一下拖得快,移开了 dragable 盒子的区域,那么它就会发生一个拖断的现象。

在现代新的浏览器上面,我们用 document 来监听,就会产生一个捕捉鼠标的效果。即使我们移出浏览器的范围外,这个事件仍然是能够接收到我们鼠标的移动信息的。

这里还有一个点需要我们注意的,就是当我们在 draggable 上的 mousedown 中监听了 mousemove 和 mouseup 之后,我们会发现我们在 mouseup 的时候,我们是要把 mousemove 和 mouseup 的监听给移出掉的。

为了可以让我们在 mouseup 的时候可以移出这两个监听,我们是需要用两个变量把 mousemove 和 mouseup 的事件监听给保存起来的。

<div id="draggable" style="width: 100px; height: 100px; background-color: aqua"></div>

<script>
  let draggable = document.getElementById('draggable');

draggable.addEventListener('mousedown', function (event) {
  let up = () => {
    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', up);
  };

  let move = event => {
    console.log(event);
  };

  document.addEventListener('mousemove', move);
  document.addEventListener('mouseup', up);
});
</script>

移动计算

上面的我们实现的只是鼠标动作的监听,这个时候我们点击盒子然后拖动,只会得到这些鼠标动作的事件,但是盒子并不会与我们鼠标一起移动的。

这里我们就需要用到 CSS 中的 transform 属性。

  • 在 move 的事件中我们会触发这个 draggable 元素的 transform 属性的改变
  • 我们会用到 transform 中的 translate 函数来改变元素在页面上的所在位置
  • 然后 translate 里面有两个位置,一个是 x 轴,一个是 y 轴
let move = event => {
  draggable.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
};

这里我们发现有一个问题,即使我们从盒子的中间开始拖动,我们只要拖一下我们的鼠标就会固定在盒子右上角。并不是我们从哪里开始拖动,鼠标就固定在哪里。

这个是因为我们的 transform 中用到的 x 和 y 是按照鼠标的 clientXclientY 的,所以它 并没有识别我们的鼠标的起始点。我们要修正这个现象,我们只需要用鼠标的起始点与 clientX 和 clientY 做一个差值即可。

那么我们就把这个逻辑加上就可以修复这个问题了。

  • 在 mousedown 的时候记录鼠标 x 和 y 的起始位置
  • 在计算我们 transform 的 x 和 y 的时候,用 clientX - 鼠标 X 起始点,clientY - 鼠标 Y 起始点
let draggable = document.getElementById('draggable');

draggable.addEventListener('mousedown', function (event) {
  let startX = event.clientX,
      startY = event.clientY;

  let up = () => {
    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', up);
  };

  let move = event => {
    draggable.style.transform = `translate(${event.clientX - startX}px, ${event.clientY - startY }px)`;
  };

  document.addEventListener('mousemove', move);
  document.addEventListener('mouseup', up);
});

这样我们就可以正常的拖动这个盒子了。但是我们又会发现另外一个问题,如果我们再次点击这个盒子我们会发现盒子会飞出我们鼠标的位置了。

问题在哪里呢?就是我们没有考虑到在我们第二次点击的时候,我们的 draggable 元素重置到了它起始的位置开始计算了,并没有考虑到我们 draggable 元素一定被偏移到的位置。

所以我们每一次点击的时候,应该在原有的 translate 属性偏移的位置上去再次移动。也就是在原有的 X,Y 位置之上叠加新的 X,Y 偏移位置。那么这里我们要再加两个变量 baseXbaseY 来储存我们的上一次移动到的位置。

注意着两个变量需要在我们全局的作用域里面,如果我们这个功能做成了一个模块的,那就要在这个模块的作用域下。只要在这个 mousedown 监听之外就可以了。

  • 在 mousedown 之外加入两个变量 baseXbaseY
  • 它们的默认值都是 0
  • 在 mouseup 的时候我们去更新一下这个 baseX 和 baseY
  • 在我们 translate 的地方也要加上 baseX 和 baseY 的叠加值
let draggable = document.getElementById('draggable');

let baseX = 0, baseY = 0;

draggable.addEventListener('mousedown', function (event) {
  let startX = event.clientX,
      startY = event.clientY;

  let up = event => {
    baseX = baseX + event.clientX - startX;
    baseY = baseY + event.clientY - startY;
    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', up);
  };

  let move = event => {
    draggable.style.transform = `translate(${baseX + event.clientX - startX}px, ${baseY + event.clientY - startY}px)`;
  };

  document.addEventListener('mousemove', move);
  document.addEventListener('mouseup', up);
});

这样我们就完成了我们的基本拖拽的盒子啦!

正常流中拖拽

上面我们完成了一个普通的拖拽,接下来我们就来看看怎么把这个普通的拖拽变成一个在正常流中的拖拽。

首先我们放入一些文字:

<div id="container">
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
  三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
</div>
<div id="draggable" style="width: 100px; height: 100px; background-color: aqua"></div>

然后我们尝试把下边的 draggable 的元素拖进这个 container 的元素之中。那么我们能怎么做呢?这里就需要用到 range 和 CSSOM 去处理了。我们一起来看看我们可以怎么使用它们。

首先如果我们想把这个 draggable 元素拖到文字之中,我们是有一个前提条件的。我们要看到这些文字它们是没有分节点的,那就意味着我们必须要用 Range 去找到 draggable 能拖拽到的空位。

所以我们首先就需要先建一张 Range 表,然后把所有能插入的空隙都列出来。

获取文字位置

我们来看看代码的实现思路:

  • 使用 document.getElementById把 container 元素取出来
  • 我们需要找到每一个文字的所在位置,并且给每一个文字创建一个 range
  • 所以这里我们使用 container.childNodes[0].textContent.length 获得我们 container 里面文字的总长度
  • 通过循环这个长度,我们在每一个字的位置创建一个 Range,这样我们就可以利用这些 Range 来拿到每一个文字所在的 X 和 Y 的位置。
  • 要拿每一个文字的所在位置,我们就需要使用 CSSOM 中的 range.getBoundingClientRect()
  • 最后我们需要把所有这些 range 保存起来,在我们后面拖拽的监听事件里面来找到盒子可以插入的位置
let ranges = [];

let container = document.getElementById('container');

for (let i = 0; i < container.childNodes[0].textContent.length; i++) {
  let range = document.createRange();
  range.setStart(container.childNodes[0], i);
  range.setEnd(container.childNodes[0], i);
  
  ranges.push(range);
  
  console.log(range.getBoundingClientRect());
}

寻找最近的文字

我们在上面的代码中我们给 container 里面每一个文字都创建了一个 range,并且都可以找到每一个文字的具体位置。

现在我们需要知道,当我们拖动我们的盒子到文字之中的时候,我们盒子最接近那个字的位置,并且放入那个文字的位置。

所以我们需要编写一个函数,从这些 range 里面找到某一个点最近的 range。

  • 这个方法,我们需要接收一个点的 X 和 Y 值
  • 然后我们 for 循环 ranges,找一个离我们黑子位置最近的一个 range
  • 这个其实也不难找,这个跟我们在一堆数字里面找最大的值是一模一样的找法
  • 我们就放两个变量,一个 min=infinity —— 因为我们要找最小的值,所以我们给的初始值是 Infinity,这样任何值都会比它小并且踢掉掉它
  • 第二个 nearest=null ,最近的 range 一开始是没有的
  • 接着我们就需要挨个 range 来计算它与我们的 X,Y 的距离,这里我们直接可以使用数学中的 x2+y2=z2x^2 + y^2 = z^2 的公式
  • 这里面的 x = range 所在的 x 位置 - x 位置,y = range 所在的 y 位置 - y 位置
  • 而 range 的 X 和 Y 的位置,我们可以通过 range.getBoundingClientRect() 来获取
  • 算出来的 z2z^2 我们都可以与我们的 min 比较,如果小于我们的 min 值,就直接替换,并且记录当前的 range 到我们的 nearest 变量之中
  • 循环结束我们就能拿到最近的 range 了

这里要注意一个点,我们需要在循环的时候再去调用 range.getBoundingClientRect(),而不再创建 range 之后直接获得它的所在位置。这个是因为 range 是不会变的,但是界面发生变化的时候,ranges 的所在位置是会变的。所以我们要在计算距离的时候再去获取 range 此时此刻的位置。

还有一个地方,我们在计算当前点到 range 的距离的时候,我们是没有给结果开根号。这个是因为我们不需要获取到 z 的值,只需要获取到 z2z^2 即可,因为开不开根号不会影响它们直接的大小关系。我们只需要找到最小值即可。

function getNearest(x, y) {
  let min = Infinity;
  let nearest = null;

  for (let range of ranges) {
    let rect = range.getBoundingClientRect();
    let distance = (rect.x - x) ** 2 + (rect.y - y) ** 2;
    if (distance < min) {
      nearest = range;
      min = distance;
    }
  }
  return nearest;
}

说了一大堆,其实代码没有多少,这里我们就完成了一个找到一个离我们一个点最近的一个 range 的函数。在我们调用它之前,我们先对它进行一个简单的测试。

其实这个操作就是一种单元测试,只不过我们没有用像 Mocha、Jest 这种工具把单元测试的 case 给管理起来。

那么我们在浏览器运行,然后再 console 里面调用一下这个函数看一下:

这里我们用 x,y 都为 0 的位置来寻找一个 range,我们的函数确实返回了一个最近的 range。然后我们使用 .getBoundingClientRect() 来取得这个 range 的具体位置,它的位置在 x=8,y=10。这个看起来是没有问题的。所以我们的这个获取最近 range 的方法就可以投入使用了。

实现正常流拖拽

这里我们终于拿到最近的文字的 Range,这样我们就可以继续完成我们的功能了。

  • 首先我们需要改造我们的 move 监听回调方法
  • 这里我们就不需要再改变 draggable 的 transform 属性了
  • 先通过使用我们的 getNearest 函数,获得盒子当前位置最近的 range
  • 然后我们使用 range 的 insertNode 方法,把我们的 draggable 盒子插入到这个文字 range 里面
  • 这样我们就已经完成了这个功能了

我们来看看代码是如何实现的:

let move = event => {
  let range = getNearest(event.clientX, event.clientY);
  range.insertNode(draggable);
};

看到我们上面的效果图,我们发现我们的盒子有被插入到文字的空隙中,但是有两个问题。第一就是盒子并不是加入到行内,第二就是在拖动的时候会出现那种选中的状态。

接下来我们来处理掉这两个问题就完美了。

首先盒子在拖动后,没有加入好文字的行内,那是因为我们的盒子没有加上 display: inline-block 的属性。我们的盒子默认就是块级盒。加上这个属性我们就解决了第一个问题了。

而第二个问题,我们可以加上一个监听事件来禁止这个选中的动作。也就是 document.addEventListener('selectstart', event => event.preventDefault) 加上这个即可。

所以最后我们完成的代码如下:

<div id="container" style="color: #ededed">
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻 三钻
      三钻 三钻
    </div>
    <div
      id="draggable"
      style="display: inline-block; width: 100px; height: 100px; background-color: aqua"
    ></div>

<script>
  let draggable = document.getElementById('draggable');

  let baseX = 0,
      baseY = 0;

  draggable.addEventListener('mousedown', function (event) {
    let startX = event.clientX,
        startY = event.clientY;

    let up = event => {
      baseX = baseX + event.clientX - startX;
      baseY = baseY + event.clientY - startY;
      document.removeEventListener('mousemove', move);
      document.removeEventListener('mouseup', up);
    };

    let move = event => {
      let range = getNearest(event.clientX, event.clientY);
      range.insertNode(draggable);
    };

    document.addEventListener('mousemove', move);
    document.addEventListener('mouseup', up);
  });

  let ranges = [];

  let container = document.getElementById('container');

  for (let i = 0; i < container.childNodes[0].textContent.length; i++) {
    let range = document.createRange();
    range.setStart(container.childNodes[0], i);
    range.setEnd(container.childNodes[0], i);

    ranges.push(range);

    console.log(range.getBoundingClientRect());
  }

  function getNearest(x, y) {
    let min = Infinity;
    let nearest = null;

    for (let range of ranges) {
      let rect = range.getBoundingClientRect();
      let distance = (rect.x - x) ** 2 + (rect.y - y) ** 2;
      if (distance < min) {
        nearest = range;
        min = distance;
      }
    }
    return nearest;
  }

  document.addEventListener('selectstart', event => event.preventDefault());
</script>

这样我们就完成了在正常流中的拖拽,我们想往哪里拖都可以,这个盒子都会被保留在这个正常流之中。通过实现这个功能,让我们更进一步的认识到 range 和 CSSOM 在 DOM API 里面的一些应用场景。

基本上我们要实现一些视觉效果都是离不开我们的 CSSOM。


博主开始在B站直播学习,欢迎过来《直播间》一起学习。

我们在这里互相监督,互相鼓励,互相努力走上人生学习之路,让学习改变我们生活!

学习的路上,很枯燥,很寂寞,但是希望这样可以给我们彼此带来多一点陪伴,多一点鼓励。我们一起加油吧! (๑ •̀ㅂ•́)و


我是来自微信公众号《技术银河》的三钻,一位正在重塑知识的技术人。下期再见。