返回

JavaScript动画不出界:Math.random边界控制技巧

javascript

让乱跑的 JavaScript 动画乖乖待在容器里:Math.random 边界控制技巧

写网页动画时,有时会希望元素在一个指定区域内随机移动,比如鼠标悬停时,一个小气泡在某个框框里跳来跳去。用 Math.random() 生成随机坐标挺方便,但一不小心,这些活泼的小家伙就可能飞出边界,跑到页面奇怪的地方去。

最近就遇到了这么个情况:几个用 Math.random() 驱动、mouseover 触发的 div 动画,总是控制不住地往右边跑,甚至消失在屏幕外。明明代码里看起来是想把它们限制在父容器 (#container) 里的。

这是怎么回事?又该怎么把它修好呢?

问题出在哪儿?

扒拉一下代码,问题主要出在 CSS 的 position 属性和 JavaScript 计算坐标的方式上。

原代码里,容器 #container 设置了 position: relative;,这本身没问题,是创建定位上下文的好方法。但问题在于,那些需要被限制移动的 div#description1#description4)也设置了 position: relative;,并且还带了个 display: inline;

关键点来了:

  1. position: relative 的误用: 当一个元素设为 position: relative 时,它的 lefttop 属性是相对于它原本应该在文档流中的位置进行偏移。它并不会把自己“锁死”在父容器的边界内。如果你想让一个元素根据父容器的左上角来定位,你应该给它设置 position: absolute
  2. display: inline 的干扰: display: inline 的元素,像文字一样,会一个接一个地排在行内。这种布局方式跟通过 left/top 进行像素级精确定位有点八字不合。而且,inline 元素的宽高计算也可能跟块级元素(display: blockinline-block)不太一样,可能会影响到 JavaScript 获取的尺寸。
  3. JavaScript 边界计算: 虽然计算 nextXnextY 的公式看起来想限制范围(parentWidth - elementWidth),但如果定位方式(position)不对,计算出的坐标应用在 relative 定位上,结果就不是我们想要的“容器内定位”了。此外,原始计算中 + 1- 1 的调整有些画蛇添足,容易在边界处理上引入微小的偏差。

综合起来看,主要是 position: relativedisplay: inline 的组合,让 lefttop 的动画效果偏离了预期,元素并没有按照父容器的边界来移动,而是基于它们在文档流中的原始位置进行偏移,并且由于 inline 的特性,它们会先在水平方向上依次排列,这导致初始位置就可能靠近右侧,再随机一动,自然容易出界。

解决方案来了!

知道了问题所在,解决起来就有方向了。主要是两步:修正 CSS 定位方式,优化 JavaScript 计算逻辑。顺便,还能让重复的 JavaScript 代码更简洁点。

方案一:釜底抽薪 —— 调整 CSS 定位

这是最根本的解决办法。我们需要告诉浏览器:“嘿,这些小气泡的位置,是相对于 #container 这个爹来定的,别管它们原来排在哪儿!”

原理:

  • 给父容器 #container 设置 position: relative; (原代码已做)。这会为所有后代元素创建一个定位上下文。
  • 给需要被限制的子元素(小气泡们)设置 position: absolute;。这样,它们的 left, top, right, bottom 属性就会相对于最近的已定位祖先元素(也就是 #container)的内边距边缘(padding edge)进行定位。
  • 同时,去掉子元素的 display: inline;position: absolute 的元素会自动变成类似块级(block-level)的行为,display: inline 会被忽略或产生非预期的效果,直接删掉就好。

操作步骤:

  1. 修改 CSS:

    body {
      margin: 0;
      padding: 0;
      /* align: center; /* 'align' is not a valid property here, maybe 'text-align: center;' if needed on body */
      background-color: teal;
      /* Adding a bit more structure might be useful */
      display: flex; /* Optional: Helps center the container */
      justify-content: center; /* Optional: Horizontally centers the container */
      align-items: center; /* Optional: Vertically centers the container */
      min-height: 100vh; /* Optional: Ensure body takes full viewport height */
    }
    
    #container {
      /* Let's give it a more specific size for reliable bounding */
      width: 600px; /* Example width, adjust as needed */
      height: 300px; /* Example height, adjust as needed */
      /* margin-left/right auto works well with a fixed width */
      margin-left: auto;
      margin-right: auto;
      padding: 20px; /* Add some padding so bubbles don't stick to edges */
      position: relative; /* Correct! Establishes positioning context */
      border: 2px solid #eee; /* Optional: Visualize the container bounds */
      background-color: #f0f0f0; /* Optional: Different background */
      /* align-content: center; /* Not applicable here, remove */
    }
    
    /* Use a class for common styles and positioning */
    .bubble {
      position: absolute; /* THE KEY CHANGE: Position relative to #container */
      /* display: inline; /* REMOVE THIS: 'absolute' handles layout */
      /* width: fit-content; /* 'absolute' needs explicit or calculated width/height sometimes */
      /* Instead of fit-content, let padding define size */
      height: auto; /* Let content + padding define height */
      background-color: rgba(255, 255, 255, 0.85); /* Slightly less transparent maybe */
      color: #000;
      border-radius: 999px; /* Keep the bubble shape */
      font-weight: bold;
      text-align: center;
      /* margin: 8px; /* Margin doesn't work well with absolute positioning for spacing, use padding */
      padding: 8px 16px; /* Keep padding */
      cursor: pointer; /* Indicate it's interactive */
      /* Start elements at a defined position, e.g., top-left */
      top: 10px; /* Example starting position */
      left: 10px; /* Example starting position */
    }
    
    /* If you need specific styles for each bubble, keep IDs, but positioning is handled by the class */
    #description1 { /* Specific styles if needed */ }
    #description2 { /* Specific styles if needed */ }
    #description3 { /* Specific styles if needed */ }
    #description4 { /* Specific styles if needed */ }
    
  2. 更新 HTML (添加 bubble 类):

    给每个需要移动的 div 添加 class="bubble"

    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    <div id="container">
      <div id="description1" class="bubble" style="background-color:rgba(255, 100, 100, 0.75);">Bubble 1</div>
      <div id="description2" class="bubble" style="background-color:rgba(100, 255, 100, 0.75);">Bubble 2</div>
      <div id="description3" class="bubble" style="background-color:rgba(100, 100, 255, 0.75);">Bubble 3</div>
      <div id="description4" class="bubble" style="background-color:rgba(255, 255, 100, 0.75);">Bubble 4</div>
      <!-- Removed inline styles for positioning/display, rely on CSS class -->
      <!-- Example with modified text and unique background colors for clarity -->
    </div>
    

效果:

现在,小气泡们的 lefttop 值将是相对于 #container 的左上角(更准确地说是内边距框的左上角)来计算的。这就把它们“关”进了容器里。

方案二:精益求精 —— 优化 JavaScript 计算

CSS 搞定了定位基础,接下来确保 JavaScript 生成的坐标值确实在容器的安全范围内。

原理:

一个绝对定位的元素,其左上角的可移动范围是:

  • 水平方向(left):从 0容器宽度 - 元素自身宽度
  • 垂直方向(top):从 0容器高度 - 元素自身高度

我们需要用 Math.random() 来生成这个范围内的整数。Math.random() 生成的是 [0, 1) 区间(包含 0,不包含 1)的浮点数。

生成 [min, max] 范围内随机整数的常用公式是 Math.floor(Math.random() * (max - min + 1)) + min
在我们这里,min 是 0。所以 maxLeft = containerWidth - elementWidth。我们需要生成 [0, maxLeft] 范围的整数。
代入公式:Math.floor(Math.random() * (maxLeft - 0 + 1)) + 0,简化为 Math.floor(Math.random() * (maxLeft + 1))

但是,还有一种更直观的理解:Math.random() * N 会生成 [0, N) 范围的数。如果我们希望得到最大值为 maxLeft 的整数,也就是范围 [0, maxLeft],我们可以乘以 (maxLeft + 1),然后向下取整。Math.random() * (containerWidth - elementWidth + 1) 就差不多是这个意思。

操作步骤:

修改 JavaScript 中的 nextXnextY 计算。

jQuery(function($) {
  // Select all bubbles by class
  $('.bubble').mouseover(function() {
    var $element = $(this); // The bubble being hovered over
    var $container = $element.parent(); // Assumes direct parent is the container

    // Get container dimensions (inner width/height, excluding padding)
    var containerWidth = $container.innerWidth();
    var containerHeight = $container.innerHeight();

    // Get element dimensions (outer width/height, including padding/border)
    var elementWidth = $element.outerWidth();
    var elementHeight = $element.outerHeight();

    // Calculate the maximum possible top and left positions
    // Ensure max is not negative if element is larger than container
    var maxLeft = Math.max(0, containerWidth - elementWidth);
    var maxTop = Math.max(0, containerHeight - elementHeight);

    // Calculate random coordinates within the bounds [0, max]
    // Math.random() * (max + 1) generates [0, max + 1)
    // Math.floor brings it down to integers [0, max]
    var nextX = Math.floor(Math.random() * (maxLeft + 1));
    var nextY = Math.floor(Math.random() * (maxTop + 1));

    // Clear any ongoing animation on this element before starting a new one
    $element.stop(true, false).animate({
      left: nextX + 'px',
      top: nextY + 'px'
    }, 300); // Added animation duration (e.g., 300ms)
  });
});

说明:

  • $container.innerWidth() / innerHeight():获取容器的内容区域尺寸,不包括边框,但包括内边距(padding)。这通常是我们希望元素活动的区域。
  • $element.outerWidth() / outerHeight():获取元素完整的外部尺寸,包括内边距和边框。这是计算边界时需要减去的元素自身占用的空间。如果你希望它包含外边距(margin),用 outerWidth(true) / outerHeight(true)。不过绝对定位下,外边距通常不参与这种边界计算。
  • Math.max(0, ...):防止当元素比容器还大时,计算出负的最大坐标值。
  • Math.floor(Math.random() * (max + 1)):生成 0max(包含 max)之间的随机整数。
  • .stop(true, false):这是一个好习惯。在用户快速重复触发 mouseover 时,先停止当前元素上正在进行的动画,避免动画队列堆积导致奇怪的行为。true 清除队列,false 不立即跳转到最终状态。
  • animate({...}, 300):给动画加上一个持续时间,让它看起来更平滑。

注意: 使用 innerWidth/Height 还是 width/height,以及 outerWidth/Height 还是 width/height 取决于你的 CSS 盒模型 (box-sizing) 设置以及你希望元素活动的确切区域(是贴着边框内侧,还是贴着内边距内侧)。innerWidthouterWidth 通常是更安全的选择,因为它们考虑了内边距或边框。

进阶技巧:代码复用,拒绝重复

你可能已经注意到,原始的 JavaScript 代码对 #description1#description4 做了几乎完全相同的事情。这违反了 DRY (Don't Repeat Yourself) 原则,不好维护。万一要改动画逻辑,得改四遍。

原理:

利用 CSS 类选择器和 jQuery 的 $(this) 上下文。

操作步骤:

  1. 确保 HTML 中已添加共同类名: 如方案一所示,给所有气泡 div 添加 class="bubble"

  2. 改写 JavaScript,使用类选择器:

    jQuery(function($) {
      // Target ALL elements with class 'bubble' using just ONE handler
      $('.bubble').mouseover(function() {
        var $element = $(this); // 'this' refers to the specific .bubble being hovered
        var $container = $element.parent();
    
        var containerWidth = $container.innerWidth();
        var containerHeight = $container.innerHeight();
        var elementWidth = $element.outerWidth();
        var elementHeight = $element.outerHeight();
    
        var maxLeft = Math.max(0, containerWidth - elementWidth);
        var maxTop = Math.max(0, containerHeight - elementHeight);
    
        var nextX = Math.floor(Math.random() * (maxLeft + 1));
        var nextY = Math.floor(Math.random() * (maxTop + 1));
    
        $element.stop(true, false).animate({
          left: nextX + 'px',
          top: nextY + 'px'
        }, 300);
      });
    });
    

    现在,只需要这一段 JavaScript 代码,就能处理所有 class="bubble" 的元素了。是不是清爽多了?

总结一下:

要让 Math.random() 驱动的动画元素老实待在父容器里:

  1. 父容器(Container): 必须有 position: relative; (或其他非 static 的定位,relative 最常用)。同时,最好有明确的尺寸(width, height)。
  2. 动画元素(Bubbles): 必须用 position: absolute;。去掉 display: inline;。最好用一个公共类来管理样式和行为。
  3. JavaScript 计算: 精确计算活动边界 (maxLeft = containerWidth - elementWidth, maxTop = containerHeight - elementHeight)。使用 Math.floor(Math.random() * (max + 1)) 来生成 [0, max] 区间的随机坐标。记得使用考虑了 padding/border 的尺寸函数(如 .innerWidth(), .outerWidth())。
  4. 代码简洁性: 用类选择器绑定事件,避免重复代码。

通过这些调整,那些到处乱跑的小气泡们就应该能被乖乖地“圈养”在指定的容器里了。