普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月22日掘金 前端

动画开发的奥秘:时间管理与动画技巧

2025年5月22日 18:59

动画开发的奥秘:时间管理与动画技巧

在网页上创建流畅动画的关键在于精确的时间管理。我们希望动画看起来平滑,无论用户的电脑性能如何,或者浏览器在后台执行多少任务。本文将深入探讨两种主要的时间处理方法:基于帧间时间差 (getDelta()) 的动画和基于周期性进度 ((performance.now() % period) / period) 的动画。

一、核心概念与 getDelta()

这些是构建大多数平滑动画的基础:

  1. performance.now() :

    • 这是一个浏览器提供的函数,它返回一个高精度的时间戳(以毫秒为单位),表示从页面加载开始(具体来说是 navigationStart 事件之后)到当前时刻所经过的时间。
    • Date.now() 不同,performance.now() 提供了亚毫秒级的精度,并且不受系统时间更改的影响,这对于动画和性能测量至关重要。
  2. lastTime (用于 getDelta()) :

    • 这是一个变量,用于存储上一帧动画被渲染时的时间戳。
  3. getDelta() 函数 (计算帧间时间差) :

    // 通常在动画循环外部初始化
    let lastTime = performance.now();
    
    function getDelta() {
      const now = performance.now();
      // 计算自上一帧以来经过的时间,并转换为秒
      const delta = (now - lastTime) / 1000;
      lastTime = now; // 更新上一帧的时间戳为当前时间,供下一次调用
      return delta;
    }
    
    • now: 获取当前帧的时间戳。
    • delta (或 deltaTime) : 计算当前帧与上一帧之间的时间差 (now - lastTime)。我们通常将其除以 1000,将单位从毫秒转换为。这个 delta 值告诉我们上一帧持续了多长时间。
    • lastTime = now: 在计算完 delta 后,我们将 lastTime 更新为当前的 now,这样下一次调用 getDelta() 时,它就能正确计算出新一帧的持续时间。
    • 返回值: 函数返回计算出的 delta 时间(以秒为单位)。
  4. 为什么 delta 如此重要? (帧率独立性)

    • 不同的设备和浏览器以不同的帧率(FPS - Frames Per Second)渲染动画。快的电脑可能是 60FPS、120FPS甚至更高,慢的电脑或繁忙的浏览器可能只有 30FPS 或更低。

    • 如果我们仅仅在每一帧将物体移动固定的像素(例如,每帧移动 1 像素),那么在 60FPS 的设备上,物体每秒移动 60 像素;而在 30FPS 的设备上,物体每秒只移动 30 像素。动画速度会不一致!

    • 通过使用 delta,我们可以根据实际经过的时间来更新动画。例如,如果我们想让一个物体每秒移动 100 像素,那么在每一帧,我们就让它移动 100 * delta 像素。

      • 如果一帧耗时 1/60 秒 (delta ≈ 0.0167),物体移动 100 * 0.0167 ≈ 1.67 像素。
      • 如果一帧耗时 1/30 秒 (delta ≈ 0.0333),物体移动 100 * 0.0333 ≈ 3.33 像素。
    • 最终效果是,无论帧率如何,物体在相同的时间内移动相同的总距离,从而实现帧率独立的平滑动画。这对于物理模拟、连续运动等至关重要。

  5. requestAnimationFrame() :

    • 这是浏览器提供的用于执行动画的推荐方式。你传递给它一个回调函数,浏览器会在下一次重绘之前(通常是显示器刷新周期)调用这个函数。
    • 它会自动尝试匹配显示器的刷新率,从而产生更平滑、更高效的动画,并且当页面不可见时会自动降低频率或暂停,节省资源。

二、基于 getDelta() 的动画循环基本结构

// 在脚本的开头初始化 lastTime
let lastTimeForDeltaLoop = performance.now();

function getDeltaForLoop() {
  const now = performance.now();
  const delta = (now - lastTimeForDeltaLoop) / 1000;
  lastTimeForDeltaLoop = now;
  return delta;
}

// 动画相关的变量
let xPosition = 0;
const speed = 100; // 单位:像素/秒

function updateAnimationWithDelta() {
  const deltaTime = getDeltaForLoop(); // 获取自上一帧以来的时间(秒)

  // 1. 更新状态 (基于 deltaTime 和你的逻辑)
  //    例如,位置 = 当前位置 + 速度 * 时间差
  xPosition += speed * deltaTime;

  // 2. 渲染 (将更新后的状态应用到屏幕上)
  const animatedElement = document.getElementById('myDeltaElement');
  if (animatedElement) {
    animatedElement.style.transform = `translateX(${xPosition}px)`;
  }

  // 检查动画是否需要继续
  if (xPosition < 500) { // 示例停止条件
    requestAnimationFrame(updateAnimationWithDelta); // 请求下一帧
  } else {
    console.log("基于Delta的动画完成!");
  }
}

// 启动动画 (确保DOM加载完毕)
// window.addEventListener('load', () => {
//   lastTimeForDeltaLoop = performance.now(); // 重置时间戳
//   requestAnimationFrame(updateAnimationWithDelta);
// });

重要提示: lastTime 应该在动画循环开始之前,或者在 getDelta 第一次被有意义地调用之前,进行初始化。

三、周期性动画与 (performance.now() % period) / period

另一种常见的动画需求是创建精确重复的循环动画,例如一个物体在2秒内完成一次完整的脉冲,或者颜色在5秒内循环一遍。对于这类动画,我们通常希望动画的当前状态直接由它在当前周期内的进度决定,而不是依赖于上一帧的状态。

这时,t = (performance.now() % period) / period 这个公式就非常有用。

  • performance.now() (设为 now) : 获取自页面加载以来的总毫秒数。

  • period: 你定义的动画循环一次所需的总毫秒数 (例如, 2000 毫秒代表2秒的周期)。

  • now % period: 取模运算。这个表达式的结果是从 0period - 1 之间不断循环的毫秒数。例如,如果 period2000

    • now500 时, 500 % 2000 = 500
    • now1999 时, 1999 % 2000 = 1999
    • now2000 时, 2000 % 2000 = 0
    • now2500 时, 2500 % 2000 = 500
  • (now % period) / period (设为 t) : 将上述循环的毫秒数归一化。结果 t 是一个从 0 (包含) 到 1 (不包含) 之间平滑过渡并不断循环的值。这个 t 代表了当前动画在其周期内的完成进度 (0% 到 100%)。

getDelta() 的核心区别:

  • getDelta() 计算的是两帧之间的时间差。它关注的是“刚刚过去了多少时间”,用于逐步累积变化,如 位置 += 速度 * 时间差。动画的当前状态依赖于前一帧的状态和这个时间差。
  • (now % period) / period 计算的是动画在当前固定周期内的进度百分比。它关注的是“在当前这个循环中,我们进行到哪里了”,用于那些状态可以直接由周期进度决定的动画。动画的当前状态不直接依赖于前一帧的状态,而是由总时间和周期长度决定。

四、案例实战 🚀

(A) 使用 getDelta() 的案例

这些案例展示了如何使用帧间时间差来更新动画。

案例 1: 方块水平移动 (基于 getDelta()) 🟥➡️
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>动画案例1: 方块移动 (Delta)</title>
<style>
  body { margin: 20px; font-family: sans-serif; background-color: #f0f0f0; }
  .game-area {
    width: 90%; max-width: 600px; height: 150px; border: 2px solid #333;
    background-color: #fff; position: relative; overflow: hidden;
    margin: 20px auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);
  }
  .box {
    width: 50px; height: 50px; background-color: #e74c3c; position: absolute;
    top: 50px; border-radius: 5px; display: flex; justify-content: center;
    align-items: center; color: white; font-weight: bold;
  }
</style>
</head>
<body>
<h1>案例1: 方块水平移动 (基于 <code>getDelta()</code>)</h1>
<div id="gameArea1" class="game-area">
  <div id="box1" class="box">BOX</div>
</div>
<script>
  const box1 = document.getElementById('box1');
  const gameArea1 = document.getElementById('gameArea1');
  let lastTimeBox1 = performance.now();
  function getDeltaBox1() {
    const now = performance.now();
    const delta = (now - lastTimeBox1) / 1000;
    lastTimeBox1 = now;
    return delta;
  }
  let box1PositionX = 0;
  const speedBox1 = 150; // 像素/秒
  let directionBox1 = 1;

  function animateBox1() {
    const deltaTime = getDeltaBox1();
    box1PositionX += speedBox1 * deltaTime * directionBox1;
    const gameAreaWidth = gameArea1.clientWidth;
    const boxWidth = box1.offsetWidth;
    if (box1PositionX + boxWidth > gameAreaWidth) {
      box1PositionX = gameAreaWidth - boxWidth;
      directionBox1 = -1;
    } else if (box1PositionX < 0) {
      box1PositionX = 0;
      directionBox1 = 1;
    }
    box1.style.transform = `translateX(${box1PositionX}px)`;
    requestAnimationFrame(animateBox1);
  }
  window.addEventListener('load', () => {
    lastTimeBox1 = performance.now();
    requestAnimationFrame(animateBox1);
  });
</script>
</body>
</html>

讲解: 方块的位置每一帧都基于 deltaTime 和速度进行累加更新。

案例 2: 元素淡入淡出效果 (基于 getDelta()) ✨🌫️
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>动画案例2: 淡入淡出 (Delta)</title>
<style>
  body { margin: 20px; font-family: sans-serif; display: flex; flex-direction: column; align-items: center; background-color: #f0f0f0; }
  .fade-element {
    width: 200px; height: 100px; background-color: #3498db; color: white;
    display: flex; justify-content: center; align-items: center; font-size: 20px;
    opacity: 0; border-radius: 10px; margin-top: 20px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);
  }
  button {
    padding: 10px 20px; font-size: 16px; cursor: pointer; background-color: #2ecc71;
    color: white; border: none; border-radius: 5px; margin-top: 20px; transition: background-color 0.3s ease;
  }
  button:hover { background-color: #27ae60; }
</style>
</head>
<body>
<h1>案例2: 元素淡入淡出 (基于 <code>getDelta()</code>)</h1>
<div id="fadeElement2" class="fade-element">Hello!</div>
<button id="toggleFadeButton2">开始淡入淡出</button>
<script>
  const fadeElement2 = document.getElementById('fadeElement2');
  const toggleFadeButton2 = document.getElementById('toggleFadeButton2');
  let lastTimeFade2 = performance.now();
  function getDeltaFade2() {
    const now = performance.now();
    const delta = (now - lastTimeFade2) / 1000;
    lastTimeFade2 = now;
    return delta;
  }
  let currentOpacity2 = 0;
  const fadeDurationSeconds2 = 2;
  let fadeState2 = 'idle'; // 'in', 'out', 'idle', 'paused'
  let animationFrameId2 = null;

  function animateFade2() {
    if (fadeState2 === 'idle' || fadeState2 === 'paused') { animationFrameId2 = null; return; }
    const deltaTime = getDeltaFade2();
    if (fadeState2 === 'in') {
      currentOpacity2 += (1 / fadeDurationSeconds2) * deltaTime;
      if (currentOpacity2 >= 1) { currentOpacity2 = 1; fadeState2 = 'out'; }
    } else if (fadeState2 === 'out') {
      currentOpacity2 -= (1 / fadeDurationSeconds2) * deltaTime;
      if (currentOpacity2 <= 0) { currentOpacity2 = 0; fadeState2 = 'in';} // Loop back to 'in'
    }
    fadeElement2.style.opacity = currentOpacity2;
    animationFrameId2 = requestAnimationFrame(animateFade2);
  }
  toggleFadeButton2.addEventListener('click', () => {
    if (animationFrameId2 !== null) { // Animating or paused (was animating)
      if(fadeState2 !== 'paused') { // Is animating, so pause it
        cancelAnimationFrame(animationFrameId2);
        animationFrameId2 = null;
        fadeState2 = 'paused';
        toggleFadeButton2.textContent = '继续淡入淡出';
      } else { // Is paused, so resume
        fadeState2 = (currentOpacity2 < 0.01) ? 'in' : (currentOpacity2 > 0.99 ? 'out' : fadeState2); // Re-determine state if at extremes
         if(fadeState2 === 'paused') fadeState2 = 'in'; // Default to 'in' if still paused
        lastTimeFade2 = performance.now();
        animationFrameId2 = requestAnimationFrame(animateFade2);
        toggleFadeButton2.textContent = '停止淡入淡出';
      }
    } else { // Animation is idle, start it
      fadeState2 = 'in';
      currentOpacity2 = 0; // Reset opacity
      lastTimeFade2 = performance.now();
      animationFrameId2 = requestAnimationFrame(animateFade2);
      toggleFadeButton2.textContent = '停止淡入淡出';
    }
  });
</script>
</body>
</html>

讲解: 透明度的变化率是固定的(每秒 1/fadeDurationSeconds),通过 deltaTime 来保证不同帧率下总的淡入/淡出时间一致。

(B) 使用 (performance.now() % period) / period 的案例

这些案例展示了如何使用周期进度来实现精确的循环动画。

案例 3: 循环颜色渐变 (基于周期进度) 🌈

这个例子将使一个元素的背景色在 HSL 色彩空间的色相 (Hue) 上平滑地循环过渡。

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>动画案例3: 循环颜色渐变 (周期进度)</title>
<style>
  body { margin: 20px; font-family: sans-serif; display: flex; flex-direction: column; align-items: center; background-color: #f0f0f0; }
  .color-cycler {
    width: 200px; height: 200px;
    border: 2px solid #555;
    border-radius: 15px;
    margin-top: 20px;
    display: flex; justify-content: center; align-items: center;
    font-size: 24px; color: white; text-shadow: 1px 1px 2px black;
    box-shadow: 0 4px 8px rgba(0,0,0,0.2);
  }
</style>
</head>
<body>
<h1>案例3: 循环颜色渐变 (基于 <code>(now % period) / period</code>)</h1>
<div id="colorCyclerElement" class="color-cycler">Color Cycle!</div>
<script>
  const colorCyclerElement = document.getElementById('colorCyclerElement');
  const colorCyclePeriodMs = 5000; // 颜色循环一周的时间:5000毫秒 (5秒)
  const startTimeColors = performance.now(); // 记录一个起始时间参考点,可选,但有助于理解

  function animateColorCycle() {
    const now = performance.now();
    // 计算当前在周期内的进度 (0 到 1)
    const t = ((now - startTimeColors) % colorCyclePeriodMs) / colorCyclePeriodMs;
    // 如果不减去startTimeColors,则基于页面加载时间,效果一致,只是t的起始点不同
    // const t = (now % colorCyclePeriodMs) / colorCyclePeriodMs;


    const hue = t * 360; // 将进度映射到HSL色相的0-360度
    // 饱和度 (S) 和亮度 (L) 固定,可以按需调整
    colorCyclerElement.style.backgroundColor = `hsl(${hue}, 100%, 60%)`;

    requestAnimationFrame(animateColorCycle); // 持续动画循环
  }

  // 启动动画
  requestAnimationFrame(animateColorCycle);
</script>
</body>
</html>

讲解:

  1. colorCyclePeriodMs 定义了颜色完整循环一次所需的时间(例如5秒)。

  2. animateColorCycle 函数中:

    • now = performance.now() 获取当前总时间。
    • t = ((now - startTimeColors) % colorCyclePeriodMs) / colorCyclePeriodMs; 计算出当前在 colorCyclePeriodMs 这个周期内的归一化进度 t (0到1)。减去 startTimeColors 是为了让 t 从0开始对应动画的逻辑起点,但对于 % period 来说,只要 period 不变,循环模式是一致的。
    • hue = t * 360; 将这个0-1的进度值映射到HSL颜色模型的色相值 (0-360度)。
    • colorCyclerElement.style.backgroundColor = hsl(${hue}, 100%, 60%); 应用计算出的颜色。饱和度设为100%,亮度设为60%,可以得到鲜艳的颜色。
    • 动画会平滑地在所有颜色间过渡,并在5秒后精确地回到起点颜色,无限循环。
案例 4: 脉冲缩放效果 (基于周期进度) 💓

这个例子使用周期进度 tMath.sin() 来创建一个平滑的、有节奏的放大和缩小效果。

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>动画案例4: 脉冲缩放 (周期进度)</title>
<style>
  body { margin: 0; font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; background-color: #2c3e50; }
  h1 { color: #ecf0f1; margin-bottom: 30px; }
  .pulse-element {
    width: 100px; height: 100px; background-color: #9b59b6; border-radius: 50%;
    display: flex; justify-content: center; align-items: center; color: white;
    font-size: 18px; font-weight: bold; text-align: center;
    box-shadow: 0 0 20px rgba(155, 89, 182, 0.7);
  }
</style>
</head>
<body>
<h1>案例4: 脉冲缩放效果 (基于 <code>(now % period) / period</code>)</h1>
<div id="pulseElement4" class="pulse-element">Pulse!</div>
<script>
  const pulseElement4 = document.getElementById('pulseElement4');
  const pulsePeriodMs = 2000; // 一个完整的脉冲周期(放大再缩小)为2秒
  const minScalePulse = 0.8;
  const maxScalePulse = 1.2;
  const startTimePulse = performance.now();

  function animatePulseModular() {
    const now = performance.now();
    const t = ((now - startTimePulse) % pulsePeriodMs) / pulsePeriodMs; // 周期进度 (0 到 1)

    // 使用 Math.sin() 来创建一个平滑的振荡效果
    // t * Math.PI * 2 会使sin函数在t从0到1变化时完成一个完整周期 (0 到 2π)
    const scalePhase = (Math.sin(t * Math.PI * 2) + 1) / 2; // 结果在 0 和 1 之间波动

    // 将 0-1 的波动映射到 minScale-maxScale 之间
    const currentScale = minScalePulse + (maxScalePulse - minScalePulse) * scalePhase;

    pulseElement4.style.transform = `scale(${currentScale})`;
    requestAnimationFrame(animatePulseModular);
  }
  window.addEventListener('load', () => {
    requestAnimationFrame(animatePulseModular);
  });
</script>
</body>
</html>

讲解:

  1. pulsePeriodMs 定义了一个完整脉冲(例如,放大再缩小回原状)的周期。

  2. t = ((now - startTimePulse) % pulsePeriodMs) / pulsePeriodMs; 计算当前周期进度。

  3. t * Math.PI * 2 将0-1的进度映射到 Math.sin 函数的一个完整周期 (0 to 2π)。

  4. (Math.sin(...) + 1) / 2sin 函数的输出 (-1 到 1) 归一化到 0 到 1。

  5. 然后将这个0-1的值线性插值到 minScalePulse 和 maxScalePulse 之间,得到当前的缩放比例。

    这种方法确保了脉冲动画严格按照 pulsePeriodMs 定义的周期重复,并且动画状态(缩放大小)完全由当前在周期内的位置决定。

五、getDelta() vs. (now % period) / period:何时使用?

选择哪种时间处理方法取决于你的动画需求:

  1. 使用 getDelta() (帧间时间差) 的场景:

    • 物理模拟: 物体的运动(如速度、加速度、碰撞反应)通常基于上一帧的状态和经过的微小时间 delta 来计算。
    • 用户输入驱动的连续变化: 例如,按住一个键使物体持续加速,其加速度效果在每一帧基于 delta 累加。
    • 非固定周期的动画或一次性动画: 如一个物体从A点移动到B点然后停止,或者一个元素的淡入效果。
    • 动画状态需要逐步累积: 当动画的当前状态是前一状态加上某个变化量时(这个变化量与 delta 成正比)。
    • 目标: 实现帧率无关的、平滑的连续变化。
  2. 使用 (now % period) / period (周期进度) 的场景:

    • 精确的循环动画: 当动画需要严格按照一个固定周期重复时,如背景颜色循环、周期性的大小脉冲、匀速旋转等。
    • 动画状态可以直接由周期进度决定: 例如,在颜色循环中,周期的25%位置对应一个特定的颜色,50%对应另一个。
    • 需要动画在长时间运行后仍保持同步: 由于它基于总时间 performance.now(),不容易因浮点数累积误差导致周期性动画的“漂移”。
    • 缓动函数 (Easing) 的直接应用: 周期进度 t (0到1) 可以直接作为输入传递给缓动函数,以改变动画在周期内的变化速率。
    • 目标: 实现可预测的、精确重复的循环效果。

简单来说:

  • 如果你需要“每帧移动一点点,点点基于刚过去的时间”,用 getDelta()
  • 如果你需要“动画在这个2秒的循环里,现在应该是个什么样子”,用 (now % period) / period

有时,两者也可以结合。例如,一个复杂场景中,某个物体的整体循环行为可能由周期进度控制,但它对用户输入的即时反应可能需要 getDelta() 来处理。

六、进阶提示 🌟

  • 缓动函数 (Easing Functions) : 为了让动画看起来更自然(而不是匀速的机械感),可以使用缓动函数。它们改变动画属性随时间(通常是0-1的进度 t)变化的速率。例如 "ease-in" (慢启动), "ease-out" (慢结束), "ease-in-out" (两头慢中间快)。Math.sin 在脉冲案例中就提供了一种自然的缓动。

  • 状态管理: 对于复杂的动画或游戏,简单的全局变量可能不够。考虑使用对象、类或状态机来更好地组织和管理动画状态。

  • Canvas API: 对于需要绘制大量动态图形或进行像素级控制的场景(如游戏、粒子系统),直接使用 HTML5 <canvas> API 通常比操作DOM元素更高效。本文讨论的时间管理原则同样适用于Canvas动画。

  • 性能考量:

    • 尽量减少在动画循环中直接、频繁地读写DOM布局属性(如 width, height, left, top),因为这可能导致浏览器进行昂贵的重排(reflow)和重绘(repaint)。
    • 优先使用 CSS transform (如 translateX, scale, rotate) 和 opacity 进行动画,因为浏览器通常能对这些属性进行硬件加速优化,性能更好。
    • 避免在动画循环中执行非常耗时的计算或创建大量新对象,这可能导致卡顿。
    • 当动画元素不在视口内时,考虑使用 Intersection Observer API 来暂停动画,以节省资源。requestAnimationFrame 本身在页面非激活时也会降低频率。

通过理解并灵活运用这两种时间处理方法,你就能更有信心地创建出各种平滑、精确且富有表现力的网页动画了!

【大厂最爱考题】JavaScript进化论:从ES5到ES6的跃迁与实践

作者 遂心_
2025年5月22日 18:18

前言

ES6的发布是JavaScript发展史上的重要里程碑。它不仅填补了ES5的设计缺陷,更为现代前端开发奠定了坚实基础。本文将通过具体案例,剖析let/const、箭头函数等新特性如何解决传统问题,并展示它们在实际项目中的高效应用。

一、ES5的局限性:var的困境(深入解析)

ES5中唯一的变量声明方式var存在三个核心问题,这些问题在复杂代码中极易引发难以排查的Bug。下面通过多个角度和代码示例详细说明。


1. 函数作用域:变量泄露的陷阱

var声明的变量仅在函数作用域内有效,而无法约束在代码块(如iffor)中。这会导致变量意外泄露到外层作用域。

示例1:if块中的变量泄露

function checkUser() {
  if (true) {
    var isAdmin = true; // 使用var声明
  }
  console.log(isAdmin); // 输出true!变量泄露到函数作用域
}
checkUser();
  • 问题isAdmin本应仅在if块内有效,但var使其提升到函数作用域顶部,导致外部代码可以访问。

示例2:for循环的闭包问题

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i)); // 输出3,3,3
}
  • 问题分析

    1. var i被提升到全局作用域(或函数作用域)。
    2. 所有setTimeout回调共享同一个i,循环结束后i的值变为3。
    3. 最终所有回调打印的都是最终的i值。

对比ES6的let解决方案

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j)); // 输出0,1,2
}
  • 原理let为每次循环创建独立的块级作用域,每个回调捕获独立的j副本。

2. 变量提升(Hoisting):代码的“反直觉”行为

var声明的变量会提升到作用域顶部,但赋值操作留在原地。这会导致代码执行顺序与书写顺序不一致。

示例3:变量提升的诡异现象

console.log(name); // 输出undefined,而非报错!
var name = "Alice";
  • 实际执行逻辑

    var name;          // 声明提升到顶部
    console.log(name); // 此时name未赋值,输出undefined
    name = "Alice";    // 赋值留在原地
    

示例4:函数内的提升陷阱

function init() {
  console.log(version); // 输出undefined
  var version = "1.0";
}
init();
  • 问题:开发者可能误以为此处会报错,但实际上由于变量提升,代码可以运行但结果不符合预期。

对比ES6的let行为

console.log(version); // 报错:Cannot access 'version' before initialization
let version = "2.0";
  • 原理let存在暂时性死区(TDZ),声明前访问变量会直接报错,避免了隐式错误。

3. 全局污染:window对象的副作用

在浏览器环境中,var声明的全局变量会自动成为window对象的属性,容易引发命名冲突。

示例5:全局变量污染window

var globalVar = "I am global";
console.log(window.globalVar); // 输出"I am global"
  • 问题:如果多个脚本定义了同名全局变量,后者会覆盖前者:

    // 脚本A
    var utils = { /* 功能A */ };
    
    // 脚本B
    var utils = { /* 功能B */ }; // 覆盖脚本A的utils!
    

示例6:第三方库的冲突风险

<script src="jquery.js"></script> <!-- 内部使用var $ = ... -->
<script>
  var $ = "自定义变量"; // 覆盖jQuery的$对象!
  $.ajax(); // 报错:$.ajax is not a function
</script>
  • ES6的改进:使用letconst声明变量不会挂载到window

    let safeVar = "I am safe";
    console.log(window.safeVar); // 输出undefined
    

总结:为什么ES6要抛弃var

通过上述示例可以看出,var的设计在作用域控制、变量声明逻辑和全局管理上存在严重缺陷。ES6引入的letconst通过以下机制彻底解决了这些问题:

  1. 块级作用域:变量仅在{}内有效,避免泄露。
  2. 暂时性死区(TDZ) :禁止在声明前访问变量。
  3. 隔离全局污染:不绑定到window对象。

二、JavaScript底层原理:作用域与内存管理深度解析

1. 作用域链的运作机制

作用域链是JavaScript查找变量的核心规则,其工作原理可以通过以下示例说明:

示例1:多层嵌套作用域

let globalVar = '全局';

function outer() {
    let outerVar = '外层';
    
    function inner() {
        let innerVar = '内层';
        console.log(innerVar);    // 查找当前作用域
        console.log(outerVar);    // 向上查找outer作用域
        console.log(globalVar);   // 继续向上查找全局作用域
    }
    
    inner();
}

outer();
  • 执行过程

    1. inner()执行时,首先在当前作用域查找innerVar
    2. 查找outerVar时,引擎会沿着作用域链向上查找
    3. 最后在全局作用域找到globalVar

内存表现

作用域链:innerouterglobal
每个函数都保存着对其父级作用域的引用

2. LHS与RHS查询的底层差异

这两种查询方式在内存操作上有本质区别:

示例2:变量赋值与取值

function calculate(a) {  // LHS查询:为形参a分配内存
    let b = a * 2;      // RHS查询:读取a的值
    return b;           // RHS查询:读取b的值
}

let result = calculate(5); // LHS查询:为result分配内存

内存操作对比

查询类型 操作目标 失败行为
LHS 找到变量容器(内存地址) 非严格模式创建全局变量
RHS 获取变量的值 抛出ReferenceError

3. 内存分配:栈与堆的协同工作

JavaScript对不同数据类型采用不同的存储策略:

示例3:基本类型与引用类型的存储

let age = 25;                  // 栈内存存储
let user = { name: '张三' };   // 堆内存存储

let newAge = age;              // 值拷贝(栈内存复制)
let newUser = user;            // 引用拷贝(指针复制)

newAge = 30;                   // 不影响原age
newUser.name = '李四';          // 修改原user

内存结构图示

栈内存:
age: 25
newAge: 30
user:  堆内存地址0x001
newUser:  堆内存地址0x001

堆内存:
0x001: { name: "李四" }

4. 闭包的内存管理

闭包是理解JavaScript内存管理的关键案例:

示例4:闭包的内存保持

function createCounter() {
    let count = 0;  // 本应在函数执行后释放
    
    return function() {
        count++;
        return count;
    }
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2

内存机制

  1. 正常情况下,createCounter()执行后,其作用域应该被销毁
  2. 但由于返回的函数引用了count变量,形成闭包
  3. 引擎会保持这个作用域在内存中
  4. 直到所有引用都消失才会被垃圾回收

5. 垃圾回收的实践观察

通过内存快照可以验证垃圾回收行为:

示例5:手动触发垃圾回收

let largeData = new Array(1000000).fill('data');

function process() {
    let tempData = largeData;
    // 处理数据...
}

process();
largeData = null;  // 解除引用

// 此时可以观察到内存释放

优化建议

  • 及时解除不再需要的大对象引用
  • 避免意外的全局变量
  • 谨慎使用闭包

结尾

S6的革新让JavaScript从“脚本语言”蜕变为“工程语言”。掌握这些特性,不仅能写出更健壮的代码,还能显著提升开发效率。未来,随着ECMAScript标准持续演进,JavaScript的潜力将更加不可限量。

JavaScript闭包

2025年5月22日 18:17

在JavaScript开发中,闭包是一个非常重要且经常被提及的概念。它不仅可以帮助我们实现一些高级功能,还能解决一些常见的问题。本文将详细介绍闭包的概念、原理、应用场景以及如何正确使用和优化闭包。

一、什么是闭包?

闭包(Closure)是JavaScript中一个非常重要的知识点,也是前端面试中较高几率被问到的知识点之一。闭包并不是一个具体的技术,而是一种现象,是指在定义函数时,周围环境中的信息可以在函数中使用。简单来说,执行函数时,只要在函数中使用了外部的数据,就创建了闭包。

(一)闭包的定义

闭包是一个封闭的空间,里面存储了在其他地方会引用到的该作用域的值。在JavaScript中,闭包是通过作用域链来实现的。

(二)闭包的形成条件

只要在函数中使用了外部的数据,就创建了闭包。例如:

function a() {
    var i = 10;
    console.log(i);
}

在上面的代码中,a函数中使用了外部的数据i,因此创建了闭包。

(三)闭包的作用

闭包的主要作用是让外部环境访问到函数内部的局部变量,让局部变量持续保存下来,不随着它的上下文环境一起销毁。

二、闭包的原理

(一)作用域链

作用域链是实现闭包的手段。当访问一个变量时,JavaScript引擎会从当前作用域开始,逐层向上查找,直到找到该变量或到达全局作用域。例如:

var a = 10;
function f1() {
    var b = 20;
    function f2() {
        var c = 30;
        console.log(a); // 10
        console.log(b); // 20
        console.log(c); // 30
    }
    f2();
}
f1();

在上面的代码中,f2函数中访问了外部的变量ab,因此f2函数的闭包中包含了ab

(二)垃圾回收

JavaScript的垃圾回收机制会定期清理不再使用的变量。但是,如果一个变量被闭包引用,那么它不会被垃圾回收器回收。例如:

function a() {
    var i = 10;
    return function() {
        console.log(i);
    }
}
var b = a();
b(); // 10

在上面的代码中,i变量被闭包引用,因此它不会被垃圾回收器回收。

三、闭包的应用场景

(一)访问函数内部的局部变量

闭包可以让外部环境访问到函数内部的局部变量。例如:

function createCounter() {
    var count = 0;
    return function() {
        count++;
        console.log(count);
    }
}
var counter = createCounter();
counter(); // 1
counter(); // 2

在上面的代码中,createCounter函数返回了一个闭包,通过这个闭包可以访问函数内部的局部变量count

(二)避免全局变量污染

闭包可以用来避免全局变量污染。例如:

var init = (function() {
    var name = "initName";
    function callName() {
        console.log(name);
    }
    return function() {
        callName();
    }
})();
init(); // initName

在上面的代码中,name变量被定义在一个闭包中,避免了全局变量污染。

(三)实现模块化

闭包可以用来实现模块化。例如:

var myModule = (function() {
    var privateVar = "private";
    function privateFunction() {
        console.log(privateVar);
    }
    return {
        publicFunction: function() {
            privateFunction();
        }
    }
})();
myModule.publicFunction(); // private

在上面的代码中,privateVarprivateFunction被定义在一个闭包中,通过publicFunction可以访问它们。

四、闭包的经典问题

闭包可能会导致循环中的变量引用问题。例如:

for (var i = 1; i <= 3; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}

在上面的代码中,预期的结果是过1秒后分别输出i变量的值为1, 2, 3,但实际输出的是4, 4, 4。这是因为闭包引用了循环中的变量i,而i变量在循环结束后变成了4

要解决这个问题,可以使用立即执行函数来创建一个新的作用域。例如:

for (var i = 1; i <= 3; i++) {
    (function(index) {
        setTimeout(function() {
            console.log(index);
        }, 1000);
    })(i);
}

在上面的代码中,index变量被定义在一个新的作用域中,避免了闭包引用循环中的变量i

五、总结

闭包是JavaScript中一个非常重要的概念,它可以让外部环境访问到函数内部的局部变量,让局部变量持续保存下来,不随着它的上下文环境一起销毁。然而,闭包可能会导致内存泄漏,因此需要正确使用和优化闭包。

AutoCAD SHX字体查看器

作者 CAD老兵
2025年5月22日 18:01

在没有安装AutoCAD的情况下,如何查看SHX字体呢?答案是可以使用 @mlightcad/shx-parser

SHX 解析器

@mlightcad/shx-parser是一个用于解析 AutoCAD SHX 字体文件的 TypeScript 库,具备如下的一些功能。

功能特性

  • 解析 SHX 字体文件并提取字体数据
  • 支持多种 SHX 字体类型:
    • Shapes(图形字体)
    • Bigfont(大字体,包括扩展大字体)
    • Unifont(统一字体)
  • 图形解析进行了性能优化:
    • 按需解析
    • 按字符编码和字号缓存图形
  • 现代 TypeScript 实现
  • 面向对象设计
  • 完整的测试覆盖

安装方式

npm install @mlightcad/shx-parser

SHX字体查看器

@mlightcad/shx-parser 提供了一个示例应用 ,无需任何安装,打开网页即可使用。它可以用于查看和探索 SHX 字体文件,具有以下功能:

  • 双加载模式

    • 上传本地 SHX 文件
    • 从远程字体库中选择
  • 主要功能

    • 响应式网格布局查看所有字符
    • 按字符编码(十进制/十六进制)搜索字符
    • 点击字符以弹出放大视图
    • 可切换十进制和十六进制编码显示
  • 显示信息

    • 显示字体类型、版本、字符数量
    • 将字符渲染为 SVG 图形
    • 适配不同屏幕大小的响应式网格布局

可恶!小小scope害我好惨—Vue 项目踩坑实录

作者 bnnnnnnnn
2025年5月22日 17:59

小小scope害我好惨—Vue项目踩坑实录

前言

又是一个平平无奇的开发日,一个看似简单的需求:后台管理系统上传富文本内容,前台页面展示。简单吧?做过前端的同学可能已经开始摇头了——"我猜到了结局,但没猜到过程有多曲折"。

是的,就是这样一个需求,让我在 Vue 的 scoped 样式海洋里挣扎了半天。谁能想到,小小的 scoped 属性,竟然成了我的拦路虎?

哈哈哈哈,我是标题党!scoped 还是很必须的,它能有效隔离组件样式,防止样式污染,是大型项目的救星。只是在特定场景(比如富文本渲染)下需要特别注意它的工作原理。

测试1.jpg

为什么要把样式写在前端项目中?

在深入探讨问题前,先说说为什么我们需要把富文本的样式写在前端项目中,而不是直接在富文本编辑器里设置好:

  1. 富文本编辑器的局限性:大多数富文本编辑器虽然提供基础样式设置,但对于复杂的样式需求支持有限
  2. 一致性问题:让内容编辑者负责样式,可能导致网站风格不一致
  3. 响应式设计需求:富文本编辑器中无法编写媒体查询,无法实现根据设备自适应的内容展示
  4. 后期维护考虑:将样式集中在前端代码中管理,便于后期统一调整和维护
  5. 性能优化:可以与项目其他CSS一起打包压缩,减少重复代码

尤其是第三点,富文本编辑器无法处理媒体查询,这意味着我们必须在前端项目中通过类名来控制富文本内容在PC端和移动端的不同显示效果。这是一个不得不在前端项目中编写富文本样式的关键原因。

灾难现场

需求很简单:后台编辑的富文本内容(HTML),在前台页面展示,并应用我们定义好的样式。

我信心满满地写下了代码:

<template>
  <div class="article-wrapper">
    <div class="rich-content" v-html="htmlFromBackend"></div>
  </div>
</template>

<style scoped>
.rich-content h2 {
  color: #333;
  font-size: 22px;
  margin: 15px 0;
}

.rich-content p {
  font-size: 16px;
  line-height: 1.8;
  margin-bottom: 15px;
}

.rich-content .highlight {
  color: #ff6b00;
  font-weight: bold;
}
/* ...更多样式... */
</style>

看起来天衣无缝对吧?后台传来的 HTML 内容有 h2、p 标签和 highlight 类名,我也写好了对应的样式。

结果一运行...

样式全无! 明明写了样式,为什么不生效?控制台一看,HTML 结构没问题,样式也确实存在,但就是不生效!

真相大白

冷静分析,打开浏览器开发者工具,我惊讶地发现 CSS 选择器变成了这样:

.rich-content h2[data-v-7b7e7f9a] { color: #333; ... }
.rich-content p[data-v-7b7e7f9a] { font-size: 16px; ... }
.rich-content .highlight[data-v-7b7e7f9a] { color: #ff6b00; ... }

而后台传来的 HTML 内容却是这样的:

<div class="rich-content">
  <h2>文章标题</h2>
  <p>普通文本</p>
  <p class="highlight">高亮文本</p>
</div>

排查了好久 甚至想用js来控制样式了,

最后!问题找到了!这些动态插入的 HTML 标签上没有 data-v-7b7e7f9a 这个属性,所以选择器无法匹配它们!

原来如此!

原来,Vue 中的 scoped 样式实现机制是这样的:

  1. 为组件中的每个DOM元素添加一个独特的属性(如 data-v-7b7e7f9a)虽然一直知道 但是一开始完全想不到是这个问题导致的
  2. 将组件内的所有 CSS 选择器都添加这个属性选择器,确保样式只作用于当前组件

但是!通过 v-html 动态插入的内容不受 Vue 编译过程控制,所以不会自动添加这个属性,导致 scoped 样式无法应用到这些内容上。

这简直是一场完美的误会!

解决方案

方案一:放弃 scoped,拥抱全局样式

最直接的解决方法是删除 scoped 属性:

<style>
.rich-content h2 { /* 样式 */ }
</style>

但这样做可能导致样式污染,尤其是在大型项目中。

方案二:深度选择器 :deep()

Vue 提供了一个特殊的选择器 :deep(),可以"穿透" scoped 限制:

<style scoped>
.rich-content :deep(h2) { color: #333; }
.rich-content :deep(p) { /* 样式 */ }
.rich-content :deep(.highlight) { /* 样式 */ }
</style>

这样,编译后的 CSS 会变成:

.rich-content[data-v-7b7e7f9a] h2 { color: #333; }

属性选择器只应用在父元素上,而不是子元素上,这样就能正确匹配到动态内容了!

方案三:混合使用 scoped 和非 scoped 样式

在同一个组件中,可以同时使用两种样式块:

<!-- 组件特有样式,使用 scoped -->
<style scoped>
.article-wrapper { /* 样式 */ }
</style>

<!-- 富文本内容样式,不使用 scoped -->
<style>
.rich-content h2 { /* 样式 */ }
.rich-content p { /* 样式 */ }
</style>

方案四:使用命名空间和特定的类名

给富文本内容一个特定的命名空间,减少全局样式污染风险:

<style>
/* 使用特定前缀,降低样式冲突风险 */
.my-app-rich-content h2 { /* 样式 */ }
.my-app-rich-content p { /* 样式 */ }
</style>

富文本中的响应式设计问题

另一个常见的痛点:富文本编辑器中无法编写媒体查询,这导致在处理响应式设计时遇到了困难。

因此,我们必须在前端项目中通过类名来控制富文本内容在 PC 端和移动端的不同显示效果。这意味着需要在外部 CSS 中为富文本内容定义针对不同设备的样式规则。

例如,你可以这样做:

<style>
/* PC端样式 */
.rich-content img {
  max-width: 800px;
  margin: 20px auto;
}

/* 移动端样式 */
@media screen and (max-width: 768px) {
  .rich-content img {
    max-width: 100%;
  }

  .rich-content h2 {
    font-size: 18px; /* 在移动端缩小标题 */
  }
}
</style>

这种方式允许你为富文本内容创建响应式布局,即使富文本编辑器本身不支持媒体查询。

教训与反思

  1. 理解框架原理:了解 Vue 的 scoped 样式实现机制,才能避免相关陷阱
  2. 重视开发调试:使用浏览器开发者工具查看实际编译后的代码,是解决问题的关键
  3. 权衡利弊:scoped 样式有其优势(防止样式污染),但也有局限性
  4. 技术选型:针对不同场景选择合适的技术方案(scoped、CSS Modules 或全局样式)
  5. 响应式设计:处理富文本内容时,需要在外部 CSS 中处理响应式样式

结语

一个小小的 scoped 属性,竟能引发如此多的思考。前端开发就是这样,表面上看起来简单的需求,背后往往隐藏着复杂的技术细节。

下次当你要处理富文本内容时,记得想一想这个故事——小小 scope,害人不浅!不过好在我们已经掌握了对付它的武器,希望这篇文章能帮助你避开这个"坑"。

最后,分享一个调试技巧:当你怀疑是 scoped 样式导致的问题时,先尝试去掉 scoped 属性,看看样式是否生效,这往往是验证问题最快的方式。


编码快乐,调试愉快!别让小小的 scoped 成为你的噩梦~

JS实现图片转为扇形

作者 傲风残影
2025年5月22日 17:46

将一张普通的图片变形为扇形图片?

核心原理

  1. UV坐标系转换:将矩形图片的笛卡尔坐标(x, y)转换为极坐标(半径r,角度θ)。
  2. 扇形区域筛选:根据设定的起始角度、结束角度、内外半径筛选有效像素。
  3. 逆向采样:通过目标像素的极坐标,反向计算其在原图中的对应位置,避免图像断裂。

HTML部分

<input type="file" id="uploader" accept="image/jpeg" />
<canvas id="outputCanvas"></canvas>

Javascript部分

// 抗锯齿处理,在边缘添加插值计算,避免锯齿
// 使用双线性插值替代直接取整
const getPixel = (data, x, y, width) => {
  const x1 = Math.floor(x),
    y1 = Math.floor(y)
  const x2 = x1 + 1,
    y2 = y1 + 1
  const dx = x - x1,
    dy = y - y1

  // 边界检查
  if (x1 < 0 || x2 >= width || y1 < 0 || y2 >= width) return [0, 0, 0, 0]

  const idx = (y1 * width + x1) * 4
  const a = data.slice(idx, idx + 4)
  const b = data.slice(idx + 4, idx + 8)
  const c = data.slice((y2 * width + x1) * 4, (y2 * width + x1) * 4 + 4)
  const d = data.slice((y2 * width + x2) * 4, (y2 * width + x2) * 4 + 4)

  // 插值计算
  return [
    (a[0] * (1 - dx) + b[0] * dx) * (1 - dy) +
      (c[0] * (1 - dx) + d[0] * dx) * dy,
    (a[1] * (1 - dx) + b[1] * dx) * (1 - dy) +
      (c[1] * (1 - dx) + d[1] * dx) * dy,
    (a[2] * (1 - dx) + b[2] * dx) * (1 - dy) + c[2] * (1 - dx) * dy,
    (a[3] * (1 - dx) + b[3] * dx) * (1 - dy) +
      (c[3] * (1 - dx) + d[3] * dx) * dy
  ]
}
const canvas = document.getElementById('outputCanvas')
const ctx = canvas.getContext('2d')
const uploader = document.getElementById('uploader')

uploader.addEventListener('change', e => {
const file = e.target.files[0]
const reader = new FileReader()

reader.onload = event => {
  const img = new Image()
  img.onload = () => {
    // 初始化画布
    canvas.width = img.width
    canvas.height = img.height
    ctx.drawImage(img, 0, 0)

    // 获取像素数据
    const imageData = ctx.getImageData(
      0,
      0,
      canvas.width,
      canvas.height
    )
    const data = imageData.data

    // 创建目标像素数组
    const newData = new Uint8ClampedArray(data.length)

    // 2 * Math.PI
    // 扇形参数
    const startAngle = 0 // 起始角度(弧度)
    const endAngle = Math.PI / 5 // 结束角度(90度)
    const minRadius = 0.6 // 内半径比例(0~1)
    const maxRadius = 0.8 // 外半径比例(0~1)

    // 遍历每个像素
    for (let y = 0; y < canvas.height; y++) {
      for (let x = 0; x < canvas.width; x++) {
        // 转换为以中心为原点的坐标系
        const cx = x - canvas.width / 2
        const cy = y - canvas.height / 2

        // 计算极坐标
        const r = Math.sqrt(cx * cx + cy * cy) / (canvas.width / 2) // 归一化半径
        let theta = Math.atan2(cy, cx) // [-π, π]
        if (theta < 0) theta += 2 * Math.PI // 转换为[0, 2π]

        // 判断是否在扇形区域内
        if (
          theta >= startAngle &&
          theta <= endAngle &&
          r >= minRadius &&
          r <= maxRadius
        ) {
          // 计算原图对应位置(逆向采样)
          const u = (theta - startAngle) / (endAngle - startAngle) // 角度映射到[0,1]
          const v = (r - minRadius) / (maxRadius - minRadius) // 半径映射到[0,1]

          // 原图坐标(反向计算)
          const srcX = Math.round(u * canvas.width)
          const srcY = Math.round((1 - v) * canvas.height) // Y轴翻转

          // 边界检查
          if (
            srcX >= 0 &&
            srcX < canvas.width &&
            srcY >= 0 &&
            srcY < canvas.height
          ) {
            const dstIdx = (y * canvas.width + x) * 4
            const [r, g, b, a] = getPixel(
              data,
              srcX,
              srcY,
              canvas.width
            )
            newData[dstIdx] = r // R
            newData[dstIdx + 1] = g // G
            newData[dstIdx + 2] = b // B
            newData[dstIdx + 3] = a // A
          }
        }
      }
    }

// 应用新像素数据
ctx.putImageData(
  new ImageData(newData, canvas.width, canvas.height),
  0,
  0
)
}
img.src = event.target.result

reader.readAsDataURL(file)

Three.js 完全学习指南(九)性能优化技巧

作者 鲫小鱼
2025年5月22日 17:26

性能优化技巧

在 Three.js 开发中,性能优化是一个重要的课题。本章将介绍各种性能优化技巧,帮助你创建流畅的 3D 应用。

渲染优化

1. 渲染器设置

渲染性能对比

图 9.1: 渲染性能对比

// 优化渲染器设置
const renderer = new THREE.WebGLRenderer({
    antialias: false,  // 关闭抗锯齿
    powerPreference: 'high-performance',  // 优先使用高性能模式
    precision: 'mediump'  // 使用中等精度
});

// 设置像素比
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// 启用阴影
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;  // 使用 PCF 软阴影

2. 相机优化

// 优化相机设置
const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);

// 设置相机视锥体
camera.far = 1000;  // 减小远平面距离
camera.near = 0.1;  // 增大近平面距离

// 使用正交相机(适用于 2D 场景)
const orthoCamera = new THREE.OrthographicCamera(
    -width / 2,
    width / 2,
    height / 2,
    -height / 2,
    0.1,
    1000
);

几何体优化

1. 几何体合并

// 合并多个几何体
function mergeGeometries(geometries) {
    const mergedGeometry = new THREE.BufferGeometry();
    const positions = [];
    const normals = [];
    const uvs = [];

    // 合并顶点数据
    geometries.forEach(geometry => {
        const positionAttribute = geometry.getAttribute('position');
        const normalAttribute = geometry.getAttribute('normal');
        const uvAttribute = geometry.getAttribute('uv');

        for (let i = 0; i < positionAttribute.count; i++) {
            positions.push(
                positionAttribute.getX(i),
                positionAttribute.getY(i),
                positionAttribute.getZ(i)
            );
            normals.push(
                normalAttribute.getX(i),
                normalAttribute.getY(i),
                normalAttribute.getZ(i)
            );
            uvs.push(
                uvAttribute.getX(i),
                uvAttribute.getY(i)
            );
        }
    });

    // 设置合并后的属性
    mergedGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
    mergedGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
    mergedGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));

    return mergedGeometry;
}

2. 实例化渲染

// 使用实例化渲染
class InstancedMesh {
    constructor(geometry, material, count) {
        this.geometry = geometry;
        this.material = material;
        this.count = count;
        this.mesh = null;
        this.init();
    }

    init() {
        // 创建实例化网格
        this.mesh = new THREE.InstancedMesh(
            this.geometry,
            this.material,
            this.count
        );

        // 设置实例化矩阵
        const matrix = new THREE.Matrix4();
        for (let i = 0; i < this.count; i++) {
            matrix.setPosition(
                Math.random() * 10 - 5,
                Math.random() * 10 - 5,
                Math.random() * 10 - 5
            );
            this.mesh.setMatrixAt(i, matrix);
        }
    }

    update() {
        // 更新实例化矩阵
        const matrix = new THREE.Matrix4();
        for (let i = 0; i < this.count; i++) {
            this.mesh.getMatrixAt(i, matrix);
            matrix.multiply(
                new THREE.Matrix4().makeRotationY(0.01)
            );
            this.mesh.setMatrixAt(i, matrix);
        }
        this.mesh.instanceMatrix.needsUpdate = true;
    }
}

材质优化

1. 材质共享

// 共享材质
class MaterialManager {
    constructor() {
        this.materials = new Map();
    }

    getMaterial(type, params) {
        const key = this.getMaterialKey(type, params);
        if (!this.materials.has(key)) {
            this.materials.set(key, this.createMaterial(type, params));
        }
        return this.materials.get(key);
    }

    getMaterialKey(type, params) {
        return `${type}-${JSON.stringify(params)}`;
    }

    createMaterial(type, params) {
        switch (type) {
            case 'standard':
                return new THREE.MeshStandardMaterial(params);
            case 'basic':
                return new THREE.MeshBasicMaterial(params);
            default:
                return new THREE.MeshStandardMaterial(params);
        }
    }
}

2. 纹理优化

// 纹理优化
class TextureManager {
    constructor() {
        this.textures = new Map();
        this.loader = new THREE.TextureLoader();
    }

    loadTexture(url, options = {}) {
        if (this.textures.has(url)) {
            return this.textures.get(url);
        }

        const texture = this.loader.load(url, undefined, undefined, undefined);

        // 设置纹理参数
        texture.minFilter = THREE.LinearFilter;
        texture.magFilter = THREE.LinearFilter;
        texture.generateMipmaps = false;

        // 应用自定义选项
        Object.assign(texture, options);

        this.textures.set(url, texture);
        return texture;
    }

    dispose() {
        this.textures.forEach(texture => texture.dispose());
        this.textures.clear();
    }
}

内存管理

1. 资源释放

// 资源管理器
class ResourceManager {
    constructor() {
        this.geometries = new Set();
        this.materials = new Set();
        this.textures = new Set();
    }

    addGeometry(geometry) {
        this.geometries.add(geometry);
    }

    addMaterial(material) {
        this.materials.add(material);
        if (material.map) this.addTexture(material.map);
        if (material.normalMap) this.addTexture(material.normalMap);
        if (material.roughnessMap) this.addTexture(material.roughnessMap);
        if (material.metalnessMap) this.addTexture(material.metalnessMap);
    }

    addTexture(texture) {
        this.textures.add(texture);
    }

    dispose() {
        // 释放几何体
        this.geometries.forEach(geometry => {
            geometry.dispose();
        });

        // 释放材质
        this.materials.forEach(material => {
            material.dispose();
        });

        // 释放纹理
        this.textures.forEach(texture => {
            texture.dispose();
        });

        // 清空集合
        this.geometries.clear();
        this.materials.clear();
        this.textures.clear();
    }
}

2. 对象池

// 对象池
class ObjectPool {
    constructor(createFn, resetFn, initialSize = 10) {
        this.createFn = createFn;
        this.resetFn = resetFn;
        this.pool = [];
        this.active = new Set();

        // 初始化对象池
        for (let i = 0; i < initialSize; i++) {
            this.pool.push(this.createFn());
        }
    }

    get() {
        let obj = this.pool.pop();
        if (!obj) {
            obj = this.createFn();
        }
        this.active.add(obj);
        return obj;
    }

    release(obj) {
        if (this.active.has(obj)) {
            this.resetFn(obj);
            this.pool.push(obj);
            this.active.delete(obj);
        }
    }

    releaseAll() {
        this.active.forEach(obj => this.release(obj));
    }
}

性能监控

1. 性能统计

// 性能监控
class PerformanceMonitor {
    constructor() {
        this.stats = new Stats();
        this.stats.showPanel(0);
        document.body.appendChild(this.stats.dom);

        this.fps = 0;
        this.frameCount = 0;
        this.lastTime = performance.now();
    }

    update() {
        this.stats.begin();

        // 计算 FPS
        const now = performance.now();
        this.frameCount++;

        if (now - this.lastTime >= 1000) {
            this.fps = this.frameCount;
            this.frameCount = 0;
            this.lastTime = now;
        }

        this.stats.end();
    }

    getFPS() {
        return this.fps;
    }
}

2. 性能分析

// 性能分析器
class PerformanceAnalyzer {
    constructor() {
        this.metrics = {
            fps: [],
            drawCalls: [],
            triangles: [],
            geometries: [],
            textures: []
        };
    }

    collectMetrics(renderer) {
        const info = renderer.info;

        this.metrics.fps.push(performance.now());
        this.metrics.drawCalls.push(info.render.calls);
        this.metrics.triangles.push(info.render.triangles);
        this.metrics.geometries.push(info.memory.geometries);
        this.metrics.textures.push(info.memory.textures);
    }

    analyze() {
        return {
            averageFPS: this.calculateAverageFPS(),
            averageDrawCalls: this.calculateAverage(this.metrics.drawCalls),
            averageTriangles: this.calculateAverage(this.metrics.triangles),
            peakMemory: this.calculatePeakMemory()
        };
    }

    calculateAverageFPS() {
        const times = this.metrics.fps;
        if (times.length < 2) return 0;

        const intervals = [];
        for (let i = 1; i < times.length; i++) {
            intervals.push(times[i] - times[i - 1]);
        }

        return 1000 / (intervals.reduce((a, b) => a + b) / intervals.length);
    }

    calculateAverage(array) {
        return array.reduce((a, b) => a + b) / array.length;
    }

    calculatePeakMemory() {
        return {
            geometries: Math.max(...this.metrics.geometries),
            textures: Math.max(...this.metrics.textures)
        };
    }
}

实战:性能优化示例

让我们创建一个展示各种性能优化技巧的场景:

// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);

// 创建相机
const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.set(0, 0, 10);

// 创建渲染器
const renderer = new THREE.WebGLRenderer({
    antialias: false,
    powerPreference: 'high-performance',
    precision: 'mediump'
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

// 创建资源管理器
const resourceManager = new ResourceManager();
const materialManager = new MaterialManager();
const textureManager = new TextureManager();

// 创建实例化网格
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = materialManager.getMaterial('standard', {
    color: 0x00ff00,
    metalness: 0.5,
    roughness: 0.5
});
const instancedMesh = new InstancedMesh(geometry, material, 1000);
scene.add(instancedMesh.mesh);

// 创建性能监控
const performanceMonitor = new PerformanceMonitor();
const performanceAnalyzer = new PerformanceAnalyzer();

// 动画循环
function animate() {
    requestAnimationFrame(animate);

    // 更新实例化网格
    instancedMesh.update();

    // 更新性能监控
    performanceMonitor.update();
    performanceAnalyzer.collectMetrics(renderer);

    // 渲染场景
    renderer.render(scene, camera);
}

// 窗口大小改变时更新
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
});

animate();

练习

  1. 实现几何体合并
  2. 使用实例化渲染
  3. 优化材质和纹理
  4. 实现性能监控

下一步学习

在下一章中,我们将学习:

  • 模型加载与动画
  • 骨骼动画
  • 动画混合
  • 动画控制

Three.js 完全学习指南(八)后期处理与滤镜

作者 鲫小鱼
2025年5月22日 17:25

后期处理与滤镜

在 Three.js 中,后期处理(Post-processing)是一个强大的功能,它允许我们在场景渲染完成后对画面进行额外的处理,从而创建各种视觉效果。

基础后处理

1. EffectComposer 基础

后处理效果示例

图 8.1: 后处理效果示例

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';

// 创建后处理器
const composer = new EffectComposer(renderer);

// 添加渲染通道
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

// 在动画循环中使用后处理器
function animate() {
    requestAnimationFrame(animate);
    composer.render();
}

2. 基础滤镜

import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';

// 添加抗锯齿
const fxaaPass = new ShaderPass(FXAAShader);
fxaaPass.uniforms['resolution'].value.set(
    1 / (window.innerWidth * renderer.getPixelRatio()),
    1 / (window.innerHeight * renderer.getPixelRatio())
);
composer.addPass(fxaaPass);

高级后处理效果

1. 泛光效果(Bloom)

import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';

// 创建泛光效果
const bloomPass = new UnrealBloomPass(
    new THREE.Vector2(window.innerWidth, window.innerHeight),
    1.5,  // 强度
    0.4,  // 半径
    0.85  // 阈值
);

// 添加到后处理器
composer.addPass(bloomPass);

2. 景深效果(Depth of Field)

import { BokehPass } from 'three/examples/jsm/postprocessing/BokehPass.js';

// 创建景深效果
const bokehPass = new BokehPass(
    scene,
    camera,
    {
        focus: 10.0,      // 焦距
        aperture: 0.00002, // 光圈
        maxblur: 1.0      // 最大模糊
    }
);

// 添加到后处理器
composer.addPass(bokehPass);

3. 色彩调整

import { ColorCorrectionShader } from 'three/examples/jsm/shaders/ColorCorrectionShader.js';

// 创建色彩调整通道
const colorPass = new ShaderPass(ColorCorrectionShader);
colorPass.uniforms.powRGB.value = new THREE.Vector3(1.2, 1.2, 1.2);
colorPass.uniforms.mulRGB.value = new THREE.Vector3(1.1, 1.1, 1.1);

// 添加到后处理器
composer.addPass(colorPass);

自定义后处理

1. 自定义着色器通道

// 创建自定义着色器
const customShader = {
    uniforms: {
        tDiffuse: { value: null },
        time: { value: 0 }
    },
    vertexShader: `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        uniform sampler2D tDiffuse;
        uniform float time;
        varying vec2 vUv;

        void main() {
            vec2 uv = vUv;
            uv.x += sin(uv.y * 10.0 + time) * 0.01;
            gl_FragColor = texture2D(tDiffuse, uv);
        }
    `
};

// 创建自定义通道
const customPass = new ShaderPass(customShader);

// 在动画循环中更新
function animate() {
    requestAnimationFrame(animate);
    customPass.uniforms.time.value += 0.01;
    composer.render();
}

2. 多通道组合

// 创建多个后处理通道
const passes = [
    new RenderPass(scene, camera),
    new UnrealBloomPass(
        new THREE.Vector2(window.innerWidth, window.innerHeight),
        1.5, 0.4, 0.85
    ),
    new BokehPass(scene, camera, {
        focus: 10.0,
        aperture: 0.00002,
        maxblur: 1.0
    }),
    new ShaderPass(ColorCorrectionShader)
];

// 添加到后处理器
passes.forEach(pass => composer.addPass(pass));

性能优化

1. 通道管理

class PostProcessingManager {
    constructor(renderer, scene, camera) {
        this.composer = new EffectComposer(renderer);
        this.passes = new Map();
        this.init(scene, camera);
    }

    init(scene, camera) {
        // 添加基础渲染通道
        this.addPass('render', new RenderPass(scene, camera));
    }

    addPass(name, pass) {
        this.passes.set(name, pass);
        this.composer.addPass(pass);
    }

    removePass(name) {
        const pass = this.passes.get(name);
        if (pass) {
            this.composer.removePass(pass);
            this.passes.delete(name);
        }
    }

    update() {
        this.composer.render();
    }
}

2. 动态质量调整

class AdaptivePostProcessing {
    constructor(composer) {
        this.composer = composer;
        this.quality = 'high';
        this.fps = 60;
        this.lastTime = performance.now();
        this.frameCount = 0;
    }

    update() {
        // 计算 FPS
        const now = performance.now();
        this.frameCount++;

        if (now - this.lastTime >= 1000) {
            this.fps = this.frameCount;
            this.frameCount = 0;
            this.lastTime = now;

            // 根据 FPS 调整质量
            this.adjustQuality();
        }
    }

    adjustQuality() {
        if (this.fps < 30 && this.quality === 'high') {
            this.setQuality('medium');
        } else if (this.fps < 20 && this.quality === 'medium') {
            this.setQuality('low');
        } else if (this.fps > 50 && this.quality === 'low') {
            this.setQuality('medium');
        } else if (this.fps > 55 && this.quality === 'medium') {
            this.setQuality('high');
        }
    }

    setQuality(quality) {
        this.quality = quality;
        // 根据质量设置调整后处理参数
        switch (quality) {
            case 'high':
                this.setHighQuality();
                break;
            case 'medium':
                this.setMediumQuality();
                break;
            case 'low':
                this.setLowQuality();
                break;
        }
    }
}

实战:创建一个后处理场景

让我们创建一个展示各种后处理效果的场景:

// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);

// 创建相机
const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.set(0, 0, 5);

// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 创建后处理器
const composer = new EffectComposer(renderer);

// 添加基础渲染通道
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

// 添加泛光效果
const bloomPass = new UnrealBloomPass(
    new THREE.Vector2(window.innerWidth, window.innerHeight),
    1.5,
    0.4,
    0.85
);
composer.addPass(bloomPass);

// 添加景深效果
const bokehPass = new BokehPass(
    scene,
    camera,
    {
        focus: 5.0,
        aperture: 0.00002,
        maxblur: 1.0
    }
);
composer.addPass(bokehPass);

// 创建物体
const geometry = new THREE.TorusKnotGeometry(1, 0.3, 100, 16);
const material = new THREE.MeshStandardMaterial({
    color: 0x00ff00,
    metalness: 0.5,
    roughness: 0.5,
    emissive: 0x00ff00,
    emissiveIntensity: 0.5
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(5, 5, 5);
scene.add(directionalLight);

// 创建后处理管理器
const postProcessingManager = new PostProcessingManager(renderer, scene, camera);
const adaptivePostProcessing = new AdaptivePostProcessing(composer);

// 动画循环
function animate() {
    requestAnimationFrame(animate);

    // 更新物体
    mesh.rotation.x += 0.01;
    mesh.rotation.y += 0.01;

    // 更新后处理
    adaptivePostProcessing.update();
    postProcessingManager.update();
}

animate();

练习

  1. 创建一个基础的后处理场景
  2. 实现多种后处理效果的组合
  3. 添加自定义后处理效果
  4. 实现后处理性能优化

下一步学习

在下一章中,我们将学习:

  • 性能优化技巧
  • 渲染优化
  • 内存管理
  • 调试工具

vue2老项目如何使用pdfjs-dist解析pdf

2025年5月22日 17:20

先说结论

使用pdfjs-dist的2.7.570版本

# 使用pdfjs-dist的2.7.570版本的es5产物,该版本的es5的build产物没有特殊写法,对vue.config.js、babel.config.js的兼容性最好,不需要额外再下载其他插件,比如可选链插件等
# 引用方式
import * as pdfjsLib from 'pdfjs-dist/es5/build/pdf'
import pdfWorker from 'pdfjs-dist/es5/build/pdf.worker.entry'

pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
pdfjsLib.GlobalWorkerOptions.isEvalSupported = false // 关闭 eval 支持(防止漏洞)// 漏洞链接: https://www.venustech.com.cn/new_type/aqtg/20240514/27492.html

背景

某天,产品同事发现C端的资质展示页面(H5)的图片渲染有问题,经排查发现有些链接是.pdf结尾的pdf文件,最后导致某些机型无法正常渲染。如下图所示

<img src="https://xxx.xx.com/xxx.pdf" />

这里有个小点: ios机型会把pdf渲染出来1页,安卓机型无法渲染pdf

遇到问题时的思考

  • 这种情况在线上是否多,如果量级不多,个人感觉可以尝试后端进行解析PDF转成图片,在更新数据库。但这需要结合业务系统来看,因为这是“资质文件”,所以得保证业务系统在这方面的功能是怎么样的,是只能上传图片还是pdf,还是都能,不过这是后面分析才得到的结果
  • 后端是否好解决,这需要和后端沟通

需求

  • 能够根据pdf链接展示出他的所有页数内容,不失真,能在各个机型的web-view中运行,如安卓app、ios的app、支付小程序、微信小程序。
  • 只做pdf解析预览功能,不需要额外功能,把内容转成图片即可。
  • 尽量做到不修改vue.config.js、babel.config.js来解决这个问题,理想情况就是新增一个组件,然后对应页面判断是链接,最后使用下就好了。毕竟这是老项目,这些可不能乱动。

前期开发工作

  • 与后端沟通,结果是后端不好解决,只能交给前端来解决
  • 先问gpt,具体方案和关键依赖,可能的坑点
  • 搜索gpt,根据得到的关键依赖(pdfjs-dist),了解他的issue、坑点、demo等
  • 顺便搜下相关文章,但发现质量都挺一般的,数量有点少,于是就参考gpt的,其次就是pdfjs-dist的文档有点难看懂

具体实施

  • 下载 pdfjs-dist,下载时下的是最新版本,5.x.x
  • 新建pdf-viewr.vue组件
  • 复制gpt给我的代码
  • 运行项目
# gpt给的案例
import * as pdfjsLib from 'pdfjs-dist/build/pdf'
import pdfWorker from 'pdfjs-dist/build/pdf.worker.entry'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker

// 解析过程省略,主要核心是引用方式的问题

踩坑之旅

依照上述实施行为,结果就是直接报错,运行不了,于是我就开始了我的漫长踩坑之旅

我的依赖&版本

  • node: 12.22.22
  • @vue/cli: ^3.12.0
  • babel-core: 7.0.0-bridge.0
  • vue: 2.6.x

版本问题

使用pdfjs-dist遇到了非常多的版本兼容性问题,由于用的是最新版本,有很多写法,和我的对不上,于是我的想法是降版本,但一开始不知道降到多少,毕竟有1549个版本,想法是问ai,但ai回答出来的版本还是不太行,于是我就打算换个方式,找找有没有现成的封装好的组件

这里找到的是vue-pdf,这个组件是用pdfjs-dist+vue2的,我大喜过望,马上下载使用,写起来很快呀,但新的问题已然埋下

vue-pdf问题

  • issue数量特别多,只能说慎用,一共235个
  • 这个组件在打包上线后,会无法正常加载出来,导致对应页面白屏。根据gpt的说法是可能丢失了pdfjs-dist,然后我就去找原因,这种情况先找issue,我在vue-pdfissue中看到了有人遇到了同样的问题,然后我在某个issue中,看到了这么一句话:使用2.7.570版本

于是我顺藤摸瓜,找到pdfjs-dist的版本记录,找到他的内容是什么,然后我就发现了一个让我很兴奋的点:

这个依赖的有build和es5的build产物!!!,这是最关键的,他的其他版本我看了几个,我发现没有es5的打包产物

image.png

用build的产物仍然存在特殊写法,比如可选链,因为我的项目没有可选链的babel插件,所以仍然不能用

但是es5的产物没有特殊写法,所以理论上用这个就能解决了,因为引用报错的问题是语法相关的兼容性问题,我又不想新增babel插件、loader依赖等

最后在我使用了这个版本后,修改了原先AI提供的案例中的引用方式,于是项目正常跑了起来,这里就不使用vue-pdf了,使用pdfjs-dist,用它来解析,然后转图片。

对比如下:

# 前
import * as pdfjsLib from 'pdfjs-dist/build/pdf'
import pdfWorker from 'pdfjs-dist/build/pdf.worker.entry'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker

# 后
import * as pdfjsLib from 'pdfjs-dist/es5/build/pdf'
import pdfWorker from 'pdfjs-dist/es5/build/pdf.worker.entry'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker

病毒问题

病毒问题也是看vue-pdf的issue才知道的

根据报告链接所提供的方案就是

pdfjsLib.GlobalWorkerOptions.isEvalSupported = false // 关闭 eval 支持(防止漏洞)

链接: 【漏洞通告】Mozilla PDF.js代码执行漏洞(CVE-2024-4367)

小结

为了解决pdfjs-dist在vue2的老项目的兼容性问题,最终方案就是使用pdfjs-dist@2.7.570,这样就不用影响到之前的vue.config.js、babel.config.js相关配置了。

这个过程解决兼容问题,费时费力,还以为无法解决了,不过皇天不负苦心人,这个问题还是被解决了,所以记录下。

其他

暂时还没尝试过vue3的,看后期了

代码

pdf解析预览组件

<template>
  <div class="pdf-viewer">
    <van-loading v-if="loading" type="spinner" size="32px" vertical>加载中</van-loading>
    <div v-else>
      <div v-if="error" class="fallback">
        <p>当前信息无法加载,您可以<a :href="pdfUrl" target="_blank">点击查看</a></p>
      </div>
      <div v-else>
        <div v-for="(page, index) in pages" :key="index" class="pdf-page">
          <img :src="page" :alt="'pdf ' + (index + 1)" class="pdf-image">
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { Loading } from 'vant'

// ? 使用pdfjs-dist的2.7.570版本的es5产物,该版本的es5的build产物没有特殊写法,对本项目vue.config.js、babel.config.js的兼容性最好
import * as pdfjsLib from 'pdfjs-dist/es5/build/pdf'
import pdfWorker from 'pdfjs-dist/es5/build/pdf.worker.entry'

pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
pdfjsLib.GlobalWorkerOptions.isEvalSupported = false // 关闭 eval 支持(防止漏洞)// 漏洞链接: https://www.venustech.com.cn/new_type/aqtg/20240514/27492.html

export default {
  name: 'pdf-viewer',
  props: {
    pdfUrl: {
      type: String,
      required: true,
    },
  },
  components: {
    [Loading.name]: Loading,
  },
  data() {
    return {
      pages: [],
      loading: true,
      error: false,
    }
  },
  mounted() {
    this.loadPdf()
  },
  methods: {
    async loadPdf() {
      try {
        const loadingTask = pdfjsLib.getDocument(this.pdfUrl)
        const pdf = await loadingTask.promise
        const pageImages = []

        for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
          const page = await pdf.getPage(pageNum)
          const viewport = page.getViewport({ scale: 2 }) // scale 调大可提高清晰度

          const canvas = document.createElement('canvas')
          const context = canvas.getContext('2d')
          canvas.width = viewport.width
          canvas.height = viewport.height

          await page.render({ canvasContext: context, viewport }).promise
          pageImages.push(canvas.toDataURL())
        }

        this.pages = pageImages
        this.loading = false
      } catch (e) {
        console.error('PDF 加载失败:', e)
        this.error = true
        this.loading = false
      }
    },
  },
}
</script>

<style lang="stylus" scoped>
.pdf-viewer {
  width: 100%;
  overflow-x: hidden;
}
.pdf-image {
  width: 100%;
  display: block;
  object-fit: contain;
}
.fallback {
  text-align: center;
  color: #999;
  font-size: 14px;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;

  a {
    color: #007aff;
    text-decoration: underline;
  }
}
</style>

链接类型判断

    // 判断文件类型(扩展名优先,fallback 为 HEAD)
    async detectFileType(url) {
      // 1. 特殊 scheme 优先判断
      if (url.startsWith('data:image/')) return 'image'
      if (url.startsWith('data:application/pdf')) return 'pdf'
      if (url.startsWith('blob:')) return 'unknown'

      // 2. 扩展名判断(宽松匹配)
      if (/\.(jpe?g|png|gif|bmp|webp|svg)([\?#].*)?$/i.test(url)) return 'image'
      if (/\.pdf([\?#].*)?$/i.test(url)) return 'pdf'

      // 3. 尝试发 HEAD 请求获取 content-type
      try {
        const res = await axios.head(url) // ? 支付宝小程序如果遇到404链接会导致页面白屏,微信、安卓不会
        const contentType = res.headers['content-type'] || ''
        if (contentType.includes('image/')) return 'image'
        if (contentType.includes('application/pdf')) return 'pdf'
      } catch (e) {
        console.warn('链接类型获取失败:', url, e.message)
      }

      return 'unknown'
    }

vue移动应用拍照和扫码解决方案

作者 大巨头
2025年5月22日 17:01

Vue 移动应用条形码扫描与拍照集成方案

下面是一个完整的 Vue 移动应用实现方案,包含条形码扫描和拍照功能,并正确处理手机相机资源。

方案一:使用 Cordova/Capacitor 插件(适合混合应用)

1. 安装必要插件

# 对于 Capacitor 项目
npm install @capacitor/camera @capacitor/barcode-scanner
npx cap sync

# 对于 Cordova 项目
cordova plugin add phonegap-plugin-barcodescanner
cordova plugin add cordova-plugin-camera

2. 实现代码

<template>
  <div class="scanner-container">
    <!-- 扫描界面 -->
    <div v-if="mode === 'scan'" class="mode-container">
      <button @click="startScan">开始扫描条形码</button>
      <p v-if="scanResult">扫描结果: {{ scanResult }}</p>
    </div>
    
    <!-- 拍照界面 -->
    <div v-if="mode === 'camera'" class="mode-container">
      <div class="camera-preview">
        <img v-if="capturedImage" :src="capturedImage" class="preview-image">
        <video v-else ref="videoElement" autoplay playsinline class="camera-view"></video>
      </div>
      
      <div class="controls">
        <button @click="takePicture" v-if="!capturedImage">拍照</button>
        <button @click="retakePicture" v-if="capturedImage">重拍</button>
        <button @click="confirmPicture" v-if="capturedImage">确认照片</button>
        <button @click="closeCamera">返回</button>
      </div>
    </div>
  </div>
</template>

<script>
import { BarcodeScanner } from '@capacitor-community/barcode-scanner';
import { Camera } from '@capacitor/camera';

export default {
  data() {
    return {
      mode: 'scan', // 'scan' 或 'camera'
      scanResult: '',
      capturedImage: '',
      mediaStream: null,
      currentBarcode: null
    };
  },
  methods: {
    // 条形码扫描方法
    async startScan() {
      try {
        // 请求相机权限
        await BarcodeScanner.checkPermission({ force: true });
        
        // 隐藏页面内容(全屏扫描)
        document.querySelector('body').classList.add('scanner-active');
        
        const result = await BarcodeScanner.startScan();
        
        if (result.hasContent) {
          this.scanResult = result.content;
          this.currentBarcode = result.content;
          this.mode = 'camera'; // 扫描成功后切换到拍照模式
        }
      } catch (error) {
        console.error('扫描错误:', error);
        alert('扫描失败: ' + error.message);
      } finally {
        // 恢复页面显示
        BarcodeScanner.showBackground();
        document.querySelector('body').classList.remove('scanner-active');
      }
    },
    
    // 初始化相机
    async initCamera() {
      try {
        this.mediaStream = await navigator.mediaDevices.getUserMedia({
          video: {
            facingMode: 'environment', // 使用后置摄像头
            width: { ideal: 1280 },
            height: { ideal: 720 }
          }
        });
        
        this.$refs.videoElement.srcObject = this.mediaStream;
      } catch (error) {
        console.error('相机初始化失败:', error);
        alert('无法访问相机: ' + error.message);
      }
    },
    
    // 拍照
    async takePicture() {
      try {
        // 使用 Capacitor Camera 插件
        const image = await Camera.getPhoto({
          quality: 90,
          allowEditing: false,
          resultType: 'dataUrl',
          direction: 'rear'
        });
        
        this.capturedImage = image.dataUrl;
        
        // 关闭视频流
        this.closeVideoStream();
      } catch (error) {
        console.error('拍照失败:', error);
      }
    },
    
    // 重拍
    retakePicture() {
      this.capturedImage = '';
      this.initCamera();
    },
    
    // 确认照片
    confirmPicture() {
      // 这里可以处理上传逻辑
      alert(`条形码: ${this.currentBarcode}\n照片已保存`);
      this.resetProcess();
    },
    
    // 关闭相机
    closeCamera() {
      this.closeVideoStream();
      this.resetProcess();
    },
    
    // 关闭视频流
    closeVideoStream() {
      if (this.mediaStream) {
        this.mediaStream.getTracks().forEach(track => track.stop());
        if (this.$refs.videoElement) {
          this.$refs.videoElement.srcObject = null;
        }
        this.mediaStream = null;
      }
    },
    
    // 重置流程
    resetProcess() {
      this.capturedImage = '';
      this.currentBarcode = '';
      this.scanResult = '';
      this.mode = 'scan';
    }
  },
  beforeDestroy() {
    this.closeVideoStream();
  }
};
</script>

<style>
.scanner-container {
  padding: 20px;
}

.mode-container {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.camera-preview {
  width: 100%;
  max-width: 500px;
  margin: 20px 0;
  position: relative;
  aspect-ratio: 4/3;
  background-color: #000;
}

.camera-view, .preview-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.controls {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  justify-content: center;
}

button {
  padding: 10px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 5px;
  font-size: 16px;
  cursor: pointer;
}

/* 扫描模式下的全屏样式 */
.scanner-active {
  --background: transparent;
  --ion-background-color: transparent;
}
</style>

方案二:纯 Web 实现(使用开源库)

如果不使用 Cordova/Capacitor,可以使用以下纯 Web 方案:

1. 安装依赖

npm install vue-qrcode-reader quagga

2. 实现代码

<template>
  <div>
    <!-- 条形码扫描 -->
    <div v-if="!showCamera">
      <button @click="startBarcodeScan">扫描条形码</button>
      <div ref="barcodeScanner" style="width: 100%; height: 300px;"></div>
      <p v-if="barcodeResult">扫描结果: {{ barcodeResult }}</p>
    </div>
    
    <!-- 拍照界面 -->
    <div v-if="showCamera">
      <video ref="videoElement" autoplay playsinline style="width: 100%;"></video>
      <button @click="captureImage">拍照</button>
      <button @click="stopCamera">取消</button>
      <canvas ref="canvasElement" style="display: none;"></canvas>
      
      <div v-if="capturedImage">
        <img :src="capturedImage" style="max-width: 100%;">
        <button @click="savePhoto">保存照片</button>
        <button @click="retakePhoto">重拍</button>
      </div>
    </div>
  </div>
</template>

<script>
import Quagga from 'quagga';

export default {
  data() {
    return {
      showCamera: false,
      barcodeResult: '',
      capturedImage: '',
      mediaStream: null
    };
  },
  methods: {
    // 条形码扫描
    startBarcodeScan() {
      Quagga.init({
        inputStream: {
          name: "Live",
          type: "LiveStream",
          target: this.$refs.barcodeScanner,
          constraints: {
            width: 480,
            height: 320,
            facingMode: "environment"
          },
        },
        decoder: {
          readers: ["ean_reader", "ean_8_reader", "code_128_reader"]
        },
      }, err => {
        if (err) {
          console.error(err);
          return;
        }
        Quagga.start();
      });

      Quagga.onDetected(result => {
        this.barcodeResult = result.codeResult.code;
        Quagga.stop();
        this.showCamera = true;
        this.initCamera();
      });
    },
    
    // 初始化相机
    async initCamera() {
      try {
        this.mediaStream = await navigator.mediaDevices.getUserMedia({
          video: {
            facingMode: 'environment',
            width: { ideal: 1280 },
            height: { ideal: 720 }
          }
        });
        this.$refs.videoElement.srcObject = this.mediaStream;
      } catch (error) {
        console.error('相机错误:', error);
      }
    },
    
    // 拍照
    captureImage() {
      const video = this.$refs.videoElement;
      const canvas = this.$refs.canvasElement;
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;
      
      const ctx = canvas.getContext('2d');
      ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
      
      this.capturedImage = canvas.toDataURL('image/jpeg', 0.9);
      this.closeVideoStream();
    },
    
    // 保存照片
    savePhoto() {
      // 这里可以处理上传逻辑
      alert(`条形码: ${this.barcodeResult}\n照片已保存`);
      this.resetProcess();
    },
    
    // 重拍
    retakePhoto() {
      this.capturedImage = '';
      this.initCamera();
    },
    
    // 停止相机
    stopCamera() {
      this.closeVideoStream();
      this.resetProcess();
    },
    
    // 关闭视频流
    closeVideoStream() {
      if (this.mediaStream) {
        this.mediaStream.getTracks().forEach(track => track.stop());
        this.$refs.videoElement.srcObject = null;
        this.mediaStream = null;
      }
    },
    
    // 重置流程
    resetProcess() {
      this.capturedImage = '';
      this.barcodeResult = '';
      this.showCamera = false;
    }
  },
  beforeDestroy() {
    Quagga.stop();
    this.closeVideoStream();
  }
};
</script>

关键注意事项

  1. 相机权限管理

    • 确保应用有相机权限
    • 处理用户拒绝权限的情况
  2. 资源释放

    • 每次切换模式时释放之前的相机资源
    • 在组件销毁时确保释放所有资源
  3. 移动设备优化

    • 使用 facingMode: 'environment' 强制使用后置摄像头
    • 添加 playsinline 属性确保在 iOS 上正常工作
  4. 性能考虑

    • 限制相机分辨率以避免性能问题
    • 及时停止扫描器和相机以节省电量
  5. 用户体验

    • 提供清晰的界面状态指示
    • 添加加载状态和错误反馈

以上两种方案都可以在 Vue 移动应用中实现先扫描条形码再拍照的功能,第一种方案更适合打包成原生应用,第二种方案则适合纯 Web 应用。

Vue - GoF 设计模式

2025年5月22日 16:47

GoF 设计模式总览

GoF 提出的 23 种设计模式遵循两大核心面向对象设计原则

  1. 针对接口编程,而非针对实现编程
  2. 优先使用对象组合,而不是类继承

设计模式分为三大类:创建型、结构型、行为型


1. 创建型模式(Creational Patterns)

目的:关注“对象的创建方式”,将对象的创建与使用解耦,降低耦合度。

就像我们买商品不关心其生产过程,创建型模式将“怎么创建对象”的细节交由特定类处理。

模式 简述 Vue 使用场景
单例(Singleton) 一个类只有一个实例,并提供全局访问点。 Vuex store只有一个,通过this.$store访问
原型(Prototype) 通过克隆原型对象来创建新对象,适用于对象创建成本高的场景。
工厂方法(Factory Method) 定义创建对象的接口,由子类决定实例化哪一个类。
抽象工厂(Abstract Factory) 提供创建一系列相关对象的接口(产品族)。
建造者(Builder) 将复杂对象构建过程拆分成多个步骤,逐步构建对象。 Vue Router 链式路由配置(路由守卫)

分类

  • 类创建型:工厂方法
  • 对象创建型:单例、原型、抽象工厂、建造者

2. 结构型模式(Structural Patterns)

目的:描述如何组合类和对象以构建更大的结构,侧重类之间或对象之间的结构关系。

对象组合优于继承,体现了合成复用原则

模式 简述 Vue 使用场景
代理(Proxy) 为对象提供代理控制访问,可添加权限校验、延迟加载等功能。
适配器(Adapter) 将一个接口转换为客户端期望的另一个接口,实现接口兼容。
桥接(Bridge) 将抽象部分与实现部分分离,使二者可以独立变化。
外观(Facade) 为复杂系统提供统一的对外接口,简化使用。 Vue 选项式API
组合(Composite) 以树形结构组织对象,统一单个对象和组合对象的操作接口。 Vue 组件化系统
装饰(Decorator) 动态添加对象职责,比继承更灵活。 Vue 指令系统(如 v-model)和混入(Mixins)
享元(Flyweight) 通过共享技术减少内存消耗,适合大量重复对象。

分类

  • 类结构型:适配器(类适配器)
  • 对象结构型:其他全部(包括对象适配器)

3. 行为型模式(Behavioral Patterns)

目的:关注类或对象间的职责划分和协作方式,用于封装复杂的流程控制和行为变化。

模式 简述 Vue 使用场景
模板方法(Template Method) 定义算法框架,将部分步骤延迟到子类中实现。
策略(Strategy) 封装一系列算法,使它们可以互换并独立于使用者变化。 Vue nextTick 异步更新策略是优雅降级
命令(Command) 封装请求为对象,实现请求发送者与执行者的解耦。
职责链(Chain of Responsibility) 请求沿链传递,直到某个对象处理为止,降低请求发送者与处理者之间耦合。
状态(State) 对象行为随内部状态改变而改变。
观察者(Observer) 一对多依赖关系,主题状态变化通知所有观察者。 Vue 响应式系统
中介者(Mediator) 用中介对象封装对象间交互,减少耦合。
迭代器(Iterator) 提供顺序访问聚合对象元素的方法,隐藏内部结构。
访问者(Visitor) 在不修改对象结构的前提下定义新操作。
备忘录(Memento) 捕获并恢复对象内部状态,避免暴露内部实现。
解释器(Interpreter) 定义语言的文法和解释器,常用于规则表达式解析。

分类

  • 类行为型:模板方法、解释器
  • 对象行为型:其他全部

总结图示

创建型(5)
├─ 单例 / 原型 / 抽象工厂 / 建造者(对象创建型)
└─ 工厂方法(类创建型)

结构型(7)
├─ 适配器(类结构型 + 对象结构型)
└─ 代理 / 桥接 / 外观 / 组合 / 装饰 / 享元 (对象结构型)

行为型(11)
├─ 模板方法 / 解释器(类行为型)
└─ 策略 / 命令 / 职责链 / 状态 / 观察者 / 中介者 / 迭代器 / 访问者 / 备忘录(对象行为型)

Vue 设计模式

Vue.js 并非单一对应某一种 GoF 模式,而是通过组合多种设计模式实现高效开发体验:

  • 外观模式 简化复杂系统的使用。
  • 组合模式 支持组件化架构。
  • 装饰器模式策略模式 提供灵活的功能扩展。
  • 观察者模式 解决数据与视图同步问题。

这些模式的综合运用,使得 Vue 在代码复用、可维护性和扩展性上表现出色,符合设计模式“高内聚、低耦合”的核心原则。

Git 入门指南:从“不会吧”到“不就这?”

2025年5月22日 16:37

引入

嘿,各位程序员朋友们!今天咱们要聊的是一个你迟早会遇到的“老朋友”——Git。它可能让你在深夜崩溃得想扔键盘,也可能让你在代码出错时像救世主一样轻松回滚。别担心,这篇文章会用最通俗易懂、甚至有点幽默的方式带你走进 Git 的世界。

Git 是啥?我为啥要学它?

Git 是一个开源的分布式版本控制系统。听起来很高大上?其实你可以把它想象成一个“时间机器”,只不过这个时间机器不是穿越过去,而是用来管理你的代码文件的!

简单来说,Git 能帮你:

  • 记录每一次修改(就像记账软件帮你记录每一笔花销)
  • 随时回到过去的版本(比如你昨天写的代码还能跑,今天写了个 bug 炸了)
  • 和团队协作开发(不再是“你改一下发我,我改一下再发你”的原始社会模式)
  • 如果你是个开发者,那 Git 就是你吃饭的家伙之一,就像炒菜的锅之于厨师一样重要。

Git 的基本操作:五步走天下

第一步:配置身份信息(你是谁?)

每次提交代码,Git 都要知道你是谁。这就像是你在学校交作业前要写上自己的名字一样。 我们打开命令提示符,配置全局,以后就不需要配置了 image.png

git config --global user.name "你的git用户名" 
git config --global user.email "你绑定的邮箱"

第二步:初始化仓库(建个家)

在项目根目录下执行:

git init

这一步就像是在说:“从今天开始,这里就是我的 Git 仓库啦!”不过此时它还空空如也,像个刚装修好的房子。

第三步:添加文件到暂存区(将买的东西放入购物车,可以反悔)

你写了 new.txt 文件,想把它加入 Git 的管理中,可以用:

git add new.txt

如果你想一次性把所有改动都加进去,也可以偷个懒:

git add .

这一步相当于你把所有挑选好的商品放入购物车

第四步:提交更改(正式搬家)

确认无误后,就可以提交了:

git commit -m "注释(比如修改了哪些内容)"

这是 Git 中最重要的一步之一,它相当于你把打包好的东西搬进了 Git 这个“时间胶囊”。-m 后面的内容是你的提交说明,一定要写清楚,不然以后你翻历史记录的时候会一脸懵逼。一定要考虑清楚在提交

第五步:推送到远程仓库(晒朋友圈)

现在你想让全世界都知道你这个项目已经上线了,那就需要把它推送到 GitHub、Gitee 这样的平台上去:

git push origin main

之后你就可以在你的git仓库看到你提交或修改的内容

常用命令一览表(建议收藏)

命令 功能
git status 查看当前状态,知道哪些文件被修改了
git diff 文件名 查看某个文件的具体改动
git log --oneline 查看提交历史,方便找版本
git clone 地址 克隆别人的项目到本地
git pull 拉取最新代码,保持同步

Git 的三大区域:工作区、暂存区、仓库区

我们可以把 Git 的结构想象成一个公司内部流程:

  • 工作区:你现在正在干活的地方,比如你电脑上的代码文件夹。
  • 暂存区(Staging Area) :你把修改过的文件放在这里,准备提交。
  • 仓库区(Repository) :你最终提交的内容就保存在这里,Git 开始给你打上时间戳,随时可以回溯。

所以整个流程大概是这样的:

工作区 → git add → 暂存区 → git commit → 仓库区

分支管理:多线程开发神器

Git 最厉害的地方之一就是支持分支管理。你可以理解为:

  • 主线剧情是 main 分支
  • 如果你想尝试新功能,可以开一个新的分支,比如 feature/login
  • 等你开发完了,没问题再合并回主线 这样做的好处是:就算你在新分支上搞崩了,主线还是稳如老狗。

常见分支操作:

git branch feature/login   # 创建新分支
git checkout feature/login # 切换到该分支
git merge feature/login    # 合并分支

远程仓库连接:GitHub/Gitee 快乐老家

你可以在 GitHub 或 Gitee 上创建一个远程仓库(Repo),然后把你本地的代码上传过去:

git remote add origin https://github.com/你的用户名/你的项目.git
git push -u origin main

之后每次更新只需要:

git push

💡 提示:第一次推送时记得加上 -u 参数,这样以后就不用每次都指定远程分支了。

常见的 Git 错误与解决方案

❌ 报错:Please tell me who you are.

你没设置用户名或邮箱,赶紧设置一下:

git config --global user.name "你的名字"
git config --global user.email "你的邮箱"

❌ 报错:nothing to commit, working tree clean

你没有做任何修改,或者你忘了 git add。检查一下状态:

git status

❌ 报错:failed to push some refs to 'xxx'

可能是远程仓库有冲突,先拉取一下:

git pull

解决完冲突后再提交并推送。

Git 的哲学:拥抱错误,不怕重来

Git 最大的魅力在于:你永远有机会重新来过。哪怕你手滑删了整个文件夹,只要曾经提交过,Git 就能帮你恢复。

举个例子:

git reset --hard HEAD~1

这一行命令就能让你回到上一次提交的状态,仿佛刚才的操作从未发生过。

结语:Git 并不可怕,可怕的是你不敢用它

Git 刚开始看起来像是一堆乱码指令,但只要你愿意花点时间去了解它,它就会成为你最忠实的编程伙伴。

记住一句话:“Git 不是为了让你变聪明,而是为了让你即使犯傻也能活下来。”

所以,勇敢地打开终端,敲下 git init,开启你的版本控制之旅吧

每天都要有小绿标哟

React18代码的探索(五)

作者 灵梦乡
2025年5月22日 16:33

React18代码的探索(四)

diff算法核心函数reconcileChildren

reconcileChildren函数是实现Virtual DOM diff算法的核心载体,主要负责在组件更新时比较新旧子元素的差异,并生成新的fiber

参数current代表当前组件的Fiber节点,workInProgress是正在构建的新Fiber树,nextChildren是新的子元素,renderLanes是优先级相关的车道

首次渲染时,没有current,因此直接挂载子节点,而更新时则需要协调新旧子节点

更新前:<div><span key="a"/></div>
更新后:<div><p key="b"/><span key="a"/></div>

协调过程:
1. 检测key="b"新节点 → 创建新Fiber
2. 发现key="a"节点位置变化 → 标记Placement
3. 旧span节点 → 复用并移动位置

该函数在React渲染流程中的定位

beginWork阶段 → reconcileChildren → 生成子Fiber树
                        ↓
    实现Virtual DOM diff算法的核心载体
graph TD
    A[开始] --> B{current存在?}
    B -->|是| C[协调更新模式]
    B -->|否| D[初始挂载模式]
    C --> E[调用reconcileChildFibers]
    D --> F[调用mountChildFibers]
    E & F --> G[返回子Fiber节点]
export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    // 挂载阶段处理
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    // 更新阶段处理
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}

ChildReconciler工厂函数

这两个不同阶段的fiber处理都是调用的ChildReconciler函数,区别是shouldTrackSideEffects入参的区别

  • mountChildFibers -> false

  • reconcileChildFibers -> true

ChildReconciler是个工厂函数,函数内部定义了deleteChild、deleteRemainingChildren、mapRemainingChildren等方法,还有处理文本节点、元素、片段的update函数。这些方法共同负责处理子节点的增删改,以及Fiber节点的复用和标记副作用,对外暴露了reconcileChildFibers函数,也是个处理函数

函数reconcileChildFibers的主要职责是协调子Fiber节点,处理不同类型的子元素,如React元素、Portal、懒加载组件、数组、迭代器等。函数接收四个参数:returnFiber(父Fiber)、currentFirstChild(当前第一个子Fiber)、newChild(新的子元素)、lanes(优先级车道)

graph TD
    A[开始] --> B{是否无key Fragment?}
    B -->|是| C[展开子节点]
    B -->|否| D{类型判断}
    D -->|React元素| E[单元素协调]
    D -->|Portal| F[Portal协调]
    D -->|懒加载| G[递归解析]
    D -->|数组| H[数组协调]
    D -->|迭代器| I[迭代器协调]
    D -->|文本| J[文本节点处理]
    E & F & G & H & I & J --> K[标记位置]
    K --> L{是否有效节点?}
    L -->|否| M[删除旧节点]
    L -->|是| N[返回新Fiber树]
function ChildReconciler(shouldTrackSideEffects) {
  // 核心协调方法集合:
  // 1. 节点删除逻辑
  function deleteChild(returnFiber, childToDelete) {
    if (shouldTrackSideEffects) {
      // 维护父Fiber的deletions数组
      returnFiber.deletions = returnFiber.deletions || []
      returnFiber.deletions.push(childToDelete)
      returnFiber.flags |= ChildDeletion // 标记删除操作
    }
  }

  // 2. 节点复用机制
  function useFiber(fiber, pendingProps) {
    const clone = createWorkInProgress(fiber, pendingProps)
    clone.index = 0  // 重置索引
    clone.sibling = null // 断开兄弟节点
    return clone
  }

  // 3. 位置标记算法
  function placeChild(newFiber, lastPlacedIndex, newIndex) {
    newFiber.index = newIndex
    const current = newFiber.alternate
    if (current) {
      // 移动判断逻辑
      if (current.index < lastPlacedIndex) {
        newFiber.flags |= Placement // 标记移动
        return lastPlacedIndex
      }
      return current.index // 保持原位
    } else {
      newFiber.flags |= Placement // 标记插入
      return lastPlacedIndex
    }
  }

  // 4. 多种节点类型处理
  const updateFunctions = {
    Text: updateTextNode,
    Element: updateElement,
    Portal: updatePortal,
    Fragment: updateFragment
  }

  function reconcileChildFibers(
      returnFiber: Fiber,
      currentFirstChild: Fiber | null,
      newChild: any,
      lanes: Lanes,
    ): Fiber | null {
      // 处理无key的顶层Fragment
      const isUnkeyedTopLevelFragment = ...;
      if (isUnkeyedTopLevelFragment) {
        newChild = newChild.props.children; // 展开Fragment子节点
      }

      // 核心协调逻辑分支
      if (typeof newChild === 'object' && newChild !== null) {
        switch (newChild.$$typeof) {
          case REACT_ELEMENT_TYPE: // React元素处理
            return reconcileSingleElement(...); 
          case REACT_PORTAL_TYPE:  // Portal处理
            return reconcileSinglePortal(...);
          case REACT_LAZY_TYPE:    // 懒加载组件处理
            return reconcileChildFibers(...); // 递归解析
        }

        // 数组/迭代器处理
        if (isArray(newChild)) return reconcileChildrenArray(...);
        if (getIteratorFn(newChild)) return reconcileChildrenIterator(...);

        throwOnInvalidObjectType(...); // 非法对象类型报错
      }

      // 文本节点处理
      if (isStringOrNumber(newChild)) {
        return reconcileSingleTextNode(...);
      }

      // 清空剩余子节点
      return deleteRemainingChildren(...);
    }
  return reconcileChildFibers
}

下面我们就详细的看看不同种类的协调逻辑

reconcileSingleElement 协调React元素

该函数负责处理单个React元素,函数接收returnFiber、currentFirstChild、element和lanes作为参数。它的主要任务是在现有的子Fiber节点中查找是否有可以复用的节点,如果没有则创建新的Fiber节点

函数开始通过element.key进行循环查找currentFirstChild的子节点。如果找到key相同的子节点,就会检查其类型是否匹配。这里有两种情况:处理Fragment类型和其他元素类型。对于Fragment,需要验证子节点的标签是否为Fragment,然后复用现有的Fiber节点。对于其他元素类型,会检查elementType是否一致,或者是否属于热更新的兼容情况,或者是懒加载组件。如果匹配,则删除剩余的兄弟节点,并复用现有的Fiber。

如果没有找到匹配的key,则删除当前子节点,继续查找下一个兄弟节点。如果循环结束后仍未找到,就会创建新的Fiber节点。这里分Fragment和其他元素两种情况处理,分别调用不同的创建函数

最后,函数的返回值是新的或复用的Fiber节点,并正确设置其return指针,确保Fiber树的连接正确

单元素协调入口 → reconcileSingleElement → 复用或创建Fiber
                        ↓
        实现O(n)复杂度的key匹配优化算法
graph TD
    A[开始] --> B{遍历子节点}
    B --> C{key匹配?}
    C -->|是| D{类型匹配?}
    D -->|是| E[复用Fiber并返回]
    D -->|否| F[删除所有子节点]
    C -->|否| G[删除当前节点]
    B --> H[创建新Fiber]
    E & F & G & H --> I[返回新Fiber]
function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  // 核心协调流程:
  const key = element.key;
  let child = currentFirstChild;
  
  // 阶段1:遍历现有子节点寻找可复用节点
  while (child !== null) {
    if (child.key === key) { // Key匹配
      const elementType = element.type;
      
      // 处理Fragment类型
      if (elementType === REACT_FRAGMENT_TYPE) {
        if (child.tag === Fragment) { // Fragment类型匹配
          // 删除剩余兄弟节点
          deleteRemainingChildren(returnFiber, child.sibling);
          // 复用现有Fiber节点
          const existing = useFiber(child, element.props.children);
          existing.return = returnFiber;
          // 开发模式调试信息
          if (__DEV__) {
            existing._debugSource = element._source;
            existing._debugOwner = element._owner;
          }
          return existing;
        }
      } else {
        // 类型匹配检查(含热更新兼容)
        if (child.elementType === elementType || 
            (__DEV__ && isCompatibleFamilyForHotReloading(child, element)) ||
            (isLazyComponent(elementType) && resolveLazy(elementType) === child.type)) {
          
          // 清理后续兄弟节点
          deleteRemainingChildren(returnFiber, child.sibling);
          // 复用现有Fiber
          const existing = useFiber(child, element.props);
          existing.ref = coerceRef(returnFiber, child, element);
          existing.return = returnFiber;
          // 开发模式调试信息
          if (__DEV__) {
            existing._debugSource = element._source;
            existing._debugOwner = element._owner;
          }
          return existing;
        }
      }
      // Key匹配但类型不匹配 → 删除所有子节点
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // Key不匹配 → 删除当前子节点
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 阶段2:创建新Fiber节点
  if (element.type === REACT_FRAGMENT_TYPE) {
    // 创建Fragment类型的Fiber
    const created = createFiberFromFragment(
      element.props.children,
      returnFiber.mode,
      lanes,
      element.key,
    );
    created.return = returnFiber;
    return created;
  } else {
    // 创建普通元素类型的Fiber
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }
}

reconcileChildrenArray 子元素数组的更新

该函数无法进行两端优化,因为没有反向指针,所以主要采用前向遍历和映射查找的方式

函数分为几个阶段:初始遍历新旧子节点,处理剩余的新节点,以及使用映射处理移动和删除

graph TD
    A[开始] --> B{阶段1顺序遍历}
    B -->|匹配| C[复用节点]
    B -->|不匹配| D[阶段2快速插入]
    C --> E[标记位置]
    E --> B
    D --> F{旧节点耗尽?}
    F -->|是| G[创建新节点]
    F -->|否| H[阶段3映射匹配]
    G --> I[标记插入]
    H --> J[建立旧节点映射]
    J --> K[遍历新节点]
    K --> L{存在复用?}
    L -->|是| M[移动节点]
    L -->|否| N[创建新节点]
    M --> O[更新映射表]
    O --> K
function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes,
): Fiber | null {
  // 核心流程分解为三个阶段:
  
  // 阶段1:顺序匹配(新旧节点同步遍历)
  for (; oldFiber && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      // 索引跳跃处理
      nextOldFiber = oldFiber;
      oldFiber = null;
    }
    const newFiber = updateSlot(...); // 尝试复用节点
    if (!newFiber) break; // 不匹配时进入下一阶段
    deleteChild(...);     // 清理无法复用的旧节点
    placeChild(...);      // 标记位置变化
  }

  // 阶段2:批量插入新节点(当旧节点已耗尽)
  if (!oldFiber) {
    for (; newIdx < newChildren.length; newIdx++) {
      createChild(...);   // 创建新Fiber节点
      placeChild(...);    // 标记插入位置
    }
  }

  // 阶段3:映射匹配剩余节点(处理移动/删除操作)
  const existingChildren = mapRemainingChildren(...);
  for (; newIdx < newChildren.length; newIdx++) {
    updateFromMap(...);   // 从映射表查找可复用节点
    existingChildren.delete(...); // 移除已匹配节点
    placeChild(...);      // 更新位置标记
  }
  existingChildren.forEach(...); // 删除未匹配的旧节点
}

completeUnitOfWork

completeUnitOfWork函数属于工作循环的一部分,负责完成当前工作单元并处理可能的错误和兄弟节点

函数completeUnitOfWork接收一个unitOfWork参数,进入一个do-while循环,处理当前的工作单元。代码分为两部分:正常完成的情况和出现错误的情况

在正常流程中,函数调用completeWork来处理当前Fiber节点,如果返回了新的工作(next不为null),则立即处理。在错误情况下,调用unwindWork进行回滚,并处理可能的错误边界

graph TD
 A[开始] --> B{当前节点是否正常完成?}
 B -->|是| C[执行completeWork收集副作用]
 B -->|否| D[执行unwindWork错误处理]
 C --> E{生成新工作节点?}
 E -->|是| F[立即处理新节点]
 D --> G{生成新工作节点?}
 G -->|是| H[处理新节点]
 F & H --> I[处理兄弟节点]
 I --> J{有兄弟节点?}
 J -->|是| K[处理兄弟节点]
 J -->|否| L[回溯父节点]
 L --> M{是否到达根节点?}
 M -->|是| N[标记根完成]
function completeUnitOfWork(unitOfWork: Fiber): void {
 let completedWork = unitOfWork;  // 初始化当前工作单元
 do {
   const current = completedWork.alternate;  // 获取已提交的对应Fiber节点
   const returnFiber = completedWork.return; // 获取父Fiber节点

   // 检查节点是否正常完成(无错误标志)
   if ((completedWork.flags & Incomplete) === NoFlags) {
     setCurrentDebugFiberInDEV(completedWork);  // 开发环境设置调试标记

     let next;
     if (!enableProfilerTimer || (completedWork.mode & ProfileMode) === NoMode) {
       next = completeWork(current, completedWork, subtreeRenderLanes);  // 非性能分析模式
     } else {
       startProfilerTimer(completedWork);  // 启动性能分析计时器
       next = completeWork(current, completedWork, subtreeRenderLanes);
       stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);  // 停止并记录耗时
     }

     resetCurrentDebugFiberInDEV();  // 重置调试标记

     if (next !== null) {  // 如果生成新工作单元
       workInProgress = next;  // 更新全局工作指针
       return;  // 优先处理新任务
     }
   } else {
     // 错误处理流程
     const next = unwindWork(current, completedWork, subtreeRenderLanes);  // 执行错误边界处理

     if (next !== null) {  
       next.flags &= HostEffectMask;  // 保留宿主环境相关副作用
       workInProgress = next;  // 更新工作指针继续处理
       return;
     }

     // 性能分析相关错误处理
     if (enableProfilerTimer && (completedWork.mode & ProfileMode) !== NoMode) {
       let actualDuration = completedWork.actualDuration;
       let child = completedWork.child;  // 累加所有子节点耗时
       while (child !== null) {
         actualDuration += child.actualDuration;
         child = child.sibling;
       }
       completedWork.actualDuration = actualDuration;  // 更新总耗时
     }

     if (returnFiber !== null) {
       returnFiber.flags |= Incomplete;  // 标记父节点未完成
       returnFiber.subtreeFlags = NoFlags;  // 清空子树标志
       returnFiber.deletions = null;  // 重置删除队列
     } else {  // 已回溯到根节点
       workInProgressRootExitStatus = RootDidNotComplete;  // 设置根状态
       workInProgress = null;
       return;
     }
   }

   const siblingFiber = completedWork.sibling;  // 获取兄弟节点
   if (siblingFiber !== null) {
     workInProgress = siblingFiber;  // 优先处理兄弟节点
     return;
   }

   // 回溯至父节点
   completedWork = returnFiber;  
   workInProgress = completedWork;  // 更新工作指针
 } while (completedWork !== null);  // 循环直到根节点

 // 完成整个根节点的处理
 if (workInProgressRootExitStatus === RootInProgress) {
   workInProgressRootExitStatus = RootCompleted;  // 更新根状态为完成
 }
}

completeWork

该函数在React渲染流程中的作用定位

协调阶段 → beginWork → completeWork
              ↓
  完成DOM准备/属性收集/副作用标记

函数completeWork根据不同的Fiber类型处理各节点的完成工作,比如HostRoot、HostComponent、HostText等。每个case处理不同类型的组件,如类组件、函数组件、Host组件等

graph TD
   A[开始] --> B{当前Fiber类型}
   B -->|HostRoot| C[处理根节点状态]
   B -->|HostComponent| D[创建/更新DOM节点]
   B -->|HostText| E[处理文本更新]
   B -->|Suspense| F[处理挂起状态]
   C & D & E & F --> G[属性冒泡]
   G --> H[返回null继续遍历]
function completeWork(
 current: Fiber | null,
 workInProgress: Fiber,
 renderLanes: Lanes,
): Fiber | null {
 // 核心处理逻辑分三个阶段:
 // 1. 上下文管理 - 弹出当前Fiber的树上下文
 popTreeContext(workInProgress); 

 // 2. 分类型处理(核心switch结构)
 switch (workInProgress.tag) {
   // 函数组件/类组件等通用处理
   case FunctionComponent:
   case ClassComponent: {
     // 处理旧版上下文
     if (isLegacyContextProvider(Component)) {
       popLegacyContext(workInProgress);
     }
     // 属性冒泡机制
     bubbleProperties(workInProgress);
     return null;
   }

   // 根节点处理
   case HostRoot: {
     // 处理过渡追踪(Transition Tracing)
     if (enableTransitionTracing) {
       const transitions = getWorkInProgressTransitions();
       if (transitions !== null) {
         workInProgress.flags |= Passive; // 标记被动效果
       }
     }

     // 缓存处理逻辑
     if (enableCache) {
       // 比较新旧缓存差异
       if (cache !== previousCache) {
         workInProgress.flags |= Passive;
       }
       popCacheProvider(workInProgress, cache);
     }

     // 处理hydration状态
     const wasHydrated = popHydrationState(workInProgress);
     if (wasHydrated) {
       markUpdate(workInProgress); // 标记需要更新
     }
   }

   // DOM元素处理
   case HostComponent: {
     // 创建/更新DOM实例
     const instance = createInstance(
       type,
       newProps,
       rootContainerInstance,
       currentHostContext,
       workInProgress,
     );

     // 子节点追加策略
     appendAllChildren(instance, workInProgress, false, false);

     // 初始化DOM属性
     if (finalizeInitialChildren(...)) {
       markUpdate(workInProgress);
     }

     // Ref处理
     if (workInProgress.ref !== null) {
       markRef(workInProgress);
     }
   }

   // 文本节点处理
   case HostText: {
     // 文本内容对比更新
     if (oldText !== newText) {
       workInProgress.stateNode = createTextInstance(...);
       markUpdate(workInProgress);
     }
   }

   // Suspense组件处理
   case SuspenseComponent: {
     // 脱水边界处理
     if (current?.memoizedState?.dehydrated) {
       const fallthrough = completeDehydratedSuspenseBoundary(...);
       if (!fallthrough) return workInProgress;
     }

     // 挂起状态切换处理
     if (nextDidTimeout !== prevDidTimeout) {
       // 可见性状态更新
       offscreenFiber.flags |= Visibility;
       // 并发模式下的渲染控制
       renderDidSuspendDelayIfPossible();
     }
   }
 }

 // 3. 通用属性冒泡(所有类型最终调用)
 bubbleProperties(workInProgress);
}

renderRootSync 同步渲染

  • 无任务分片(与并发模式 workLoopConcurrent 对比)
  • 无时间切片( shouldYield 始终返回false)
  • 无优先级中断机制
更新调度 → renderRootSync → workLoopSync → commitRoot
                     ↓
        完成同步模式下的Fiber树协调
graph TD
   A[开始] --> B[保存上下文]
   B --> C{是否需新工作栈?}
   C -->|是| D[初始化工作栈]
   C -->|否| E[进入渲染循环]
   D --> E
   E --> F[执行workLoopSync]
   F --> G{是否报错?}
   G -->|是| H[处理错误]
   G -->|否| I[清理资源]
   H --> E
   I --> J[完整性检查]
   J --> K[返回结果状态]
function renderRootSync(root: FiberRoot, lanes: Lanes) {
 // 保存当前执行上下文和Dispatcher
 const prevExecutionContext = executionContext;
 executionContext |= RenderContext;  // 标记进入渲染阶段
 const prevDispatcher = pushDispatcher();  // 切换当前Dispatcher

 // 初始化工作堆栈
 if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
   if (enableUpdaterTracking) {  // 开发工具跟踪逻辑
     if (isDevToolsPresent) {
       // 处理更新队列缓存
       const memoizedUpdaters = root.memoizedUpdaters;
       if (memoizedUpdaters.size > 0) {
         restorePendingUpdaters(root, workInProgressRootRenderLanes);
         memoizedUpdaters.clear();
       }
       // 移动待处理Fiber到缓存
       movePendingFibersToMemoized(root, lanes);
     }
   }

   workInProgressTransitions = getTransitionsForLanes(root, lanes);  // 获取过渡追踪
   prepareFreshStack(root, lanes);  // 准备新工作栈
 }

 // 开发模式调试标记
 if (__DEV__) {
   if (enableDebugTracing) logRenderStarted(lanes);
 }

 // 性能分析标记
 if (enableSchedulingProfiler) markRenderStarted(lanes);

 // 核心渲染循环
 do {
   try {
     workLoopSync();  // 同步工作循环
     break;
   } catch (thrownValue) {
     handleError(root, thrownValue);  // 错误处理
   }
 } while (true);

 // 清理阶段
 resetContextDependencies();  // 重置上下文依赖
 executionContext = prevExecutionContext;  // 恢复执行上下文
 popDispatcher(prevDispatcher);  // 恢复原始Dispatcher

 // 完整性检查
 if (workInProgress !== null) {
   throw new Error('Cannot commit an incomplete root');
 }

 // 开发模式结束标记
 if (__DEV__ && enableDebugTracing) logRenderStopped();
 if (enableSchedulingProfiler) markRenderStopped();

 // 重置全局状态
 workInProgressRoot = null;
 workInProgressRootRenderLanes = NoLanes;

 return workInProgressRootExitStatus;  // 返回渲染结果状态
}

workLoopSync函数实现,与异步相比,少了shouldYield的判断

function workLoopSync() {
 // Already timed out, so perform work without checking if we need to yield.
 while (workInProgress !== null) {
   performUnitOfWork(workInProgress);
 }
}

深入理解 JSX:本质、原理与实践

2025年5月22日 16:19

1. 什么是 JSX?

JSX(JavaScript XML)是一种 JavaScript 的语法扩展,允许我们在 JavaScript 代码中编写类似 HTML 的结构。它最初由 React 团队提出,用于更直观地描述 UI 组件。

1.1 JSX 示例

const element = <h1 className="title">Hello, JSX!</h1>;

这看起来像 HTML,但实际上它是 JavaScript!

1.2 JSX 不是 HTML

虽然 JSX 看起来像 HTML,但它有几点不同:

  • 属性使用驼峰命名(如 className 而不是 class)。

  • 可以嵌入 JavaScript 表达式(用 { } 包裹)。

  • 最终会被编译成 JavaScript 代码,而不是直接渲染为 HTML。

2. JSX 的本质:编译后的 JavaScript

JSX 不能直接在浏览器中运行,它需要被编译成标准的 JavaScript。最常见的编译工具是 Babel

2.1 JSX 编译过程

假设我们有一个简单的 JSX:

const element = <div id="app">Hello, {name}!</div>;

经过 Babel 编译后,它会变成:

const element = React.createElement(
  "div",
  { id: "app" },
  "Hello, ",
  name,
  "!"
);

2.2 React.createElement() 的作用

React.createElement() 会返回一个 React Element(一个普通的 JavaScript 对象),描述 UI 的结构:

{
  type: "div",
  props: {
    id: "app",
    children: ["Hello, ", name, "!"]
  },
  // ...其他内部属性(如 key、ref)
}

这个对象就是 虚拟 DOM(Virtual DOM) 的一部分,React 用它来高效地更新真实 DOM。

3. 为什么需要 JSX?

3.1 更直观的 UI 描述

对比纯 JavaScript 写法:

// 纯 JavaScript(React 不使用 JSX)
const element = React.createElement(
  "div",
  { className: "container" },
  React.createElement("h1", null, "Hello"),
  React.createElement("p", null, "Welcome to JSX!")
);

使用 JSX 后:

// 使用 JSX
const element = (
  <div className="container">
    <h1>Hello</h1>
    <p>Welcome to JSX!</p>
  </div>
);

显然,JSX 更接近 HTML,可读性更强,尤其适合复杂 UI 结构。

3.2 支持 JavaScript 表达式

JSX 可以嵌入 JavaScript 表达式:

const name = "Alice";
const element = <p>Hello, {name.toUpperCase()}!</p>;

编译后:

const element = React.createElement(
  "p",
  null,
  "Hello, ",
  name.toUpperCase(),
  "!"
);

3.3 静态类型检查 & 优化

  • TypeScriptESLint 可以对 JSX 进行静态分析,提前发现错误。

  • BabelWebpack 可以优化 JSX 编译后的代码,提升性能。

4. JSX 底层原理

4.1 JSX 的运行时依赖

默认情况下,JSX 会被编译成 React.createElement(),所以 React 必须处于作用域内

import React from "react"; // 必须引入,即使没有直接使用 React
const element = <div>Hello</div>; // 编译后:React.createElement("div", null, "Hello")

4.2 自定义 JSX 编译函数(非 React 环境)

JSX 并不绑定 React,你可以配置 Babel 使用其他函数(如 Vue 的 h()):

// 在 Vue 3 中,JSX 会被编译成 `h()` 函数
const element = <div>Hello</div>;
// 编译后:h("div", null, "Hello")

Babel 配置(@babel/preset-react):

{
  "presets": [
    ["@babel/preset-react", {
      "pragma": "h" // 使用 h() 代替 React.createElement()
    }]
  ]
}

5. 常见问题 & 进阶用法

5.1 JSX 必须返回单个根元素

由于 JSX 最终会转换成 React.createElement(),而该函数只能返回一个元素,因此:
❌ 错误:

return (
  <h1>Title</h1>
  <p>Content</p>
);

✅ 正确:

return (
  <div>
    <h1>Title</h1>
    <p>Content</p>
  </div>
);

或者使用 Fragment(避免额外 DOM 节点):

return (
  <>
    <h1>Title</h1>
    <p>Content</p>
  </>
);

5.2 JSX 防止注入攻击(XSS)

React 会自动转义 JSX 中的变量,防止 XSS:

const userInput = "<script>alert('XSS')</script>";
const element = <div>{userInput}</div>; // 安全,会被转义成文本

5.3 JSX 与 key 属性

在循环中渲染列表时,必须提供 key 以优化 React 的 diff 算法:

const items = ["Apple", "Banana", "Orange"];
return (
  <ul>
    {items.map((item, index) => (
      <li key={index}>{item}</li>
    ))}
  </ul>
);

6. 总结

关键点

说明

JSX 是什么

JavaScript 的语法扩展,类似 HTML 但本质是 JavaScript

JSX 编译后

变成 React.createElement() 调用,返回 React Element(虚拟 DOM)

为什么用 JSX

更直观、支持 JavaScript 表达式、优化开发体验

JSX 不限于 React

可配置 Babel 用于 Vue、Preact 等其他库

最佳实践

单根元素、使用 key、防止 XSS

JSX 让前端开发更声明式,是 React 生态的核心特性之一。理解它的编译原理,有助于更好地掌握 React 和现代前端框架的工作机制。

多维度子树、树节点key不唯一的Tree组件封装

作者 Jolyne_
2025年5月22日 15:57

前言

业务需要有个弹窗

  • 左侧 tree 展示。
  • 左侧 tree 模糊搜索,并且高亮匹配的节点。
  • 左侧 tree 节点选择时,一并勾上相同key的节点。并且,父子节点不联动选择。
  • 右侧回显选择结果时,去重显示。

并且,需要兼容所有后续的接口

企业微信截图_17478996082782.png

然后这个业务的数据源有个问题:

  • 子树多维度。比如下图子树其实有两个,一个 userDTOList,一个childList
  • 企业微信截图_17478972838201.png
  • 节点key不唯一。比如下图,节点A的子树和节点B的子树,存在相同的key节点
  • 企业微信截图_17478973809793.png

本文就是记录一下处理的过程

实现思路

分为下面几个方面

  • 配置项

    • 不同的接口可能使用不同的字段。比如A接口转树节点时,key对应id,title对应userName。B接口转树节点,key对应key,title对应DeptName。
    • 有的接口可能有多维度子树(比如childList 和 userlist),有的接口可能只有单维度子树(比如chiList)
  • 转换树

    • 根据配置项,构造树。
    • 子树部分需要兼容多维度子树、单维度子树的情况。
    • 树节点可能根据不同的情况,需要额外的字段。
  • Map映射key和节点关系

    • 性能优化。减少树的遍历次数

实现代码

配置项

export type TTXTreeCascaderType = "deptUser";

export interface IConfig {
  /**@param 弹窗标题 */
  title: ReactNode;
  /**@param tree接口api */
  fetchApi: string;
  /**@param tree接口初始参数 */
  request: TRecord;
  /**@param 左侧输入框placeholder */
  placeholder: string;
  /**@param 右侧选择title */
  selectedTitle: string;
  /**@param 树节点配置信息*/
  nodeConfig: INodeConfig[];
}

export interface INodeConfig {
  /**@param 树节点的children字段名 */
  childrenKey: string;
  /**@param 树结点的title字段名 */
  searchFilterKey: string;
  /**@param 树节点key字段名*/
  key: string;
  /**@param 树节点paretnKey字段名 */
  parentKey: string;
}


export const deptUserConfig: IConfig = {
  title: "选择人员",
  fetchApi: "/api/base/v1/sys-user/dept-tree-and-user",
  request: {},
  placeholder: "请输入人员/部门名称",
  selectedTitle: "已选人员",
  nodeConfig: [
    {
      childrenKey: "childList",
      searchFilterKey: "deptName",
      key: "id",
      parentKey: "parentId",
    },
    {
      childrenKey: "userDTOList",
      searchFilterKey: "userName",
      key: "id",
      parentKey: "parentId",
    },
  ],
};

export const configMap = new Map<TTXTreeCascaderType, IConfig>([
  ["deptUser", deptUserConfig],
]);

/** @function 获取配置信息 */
export const getConfigMap = (type: TTXTreeCascaderType) => {
  return configMap.get(type);
};

说明:

  • nodeConfig 是个数组,有多少维度子树,数组长度就为多少。需要指定每个维度树节点的 idparentIdchildrentitle 分别对应数据源的哪个字段
  • 通过 getConfigMap 向外暴露配置项

转换树结构

首先是入口函数

/**@function 转换源树为指定树结构 */
export const transformOriginTree = (
  type: TTXTreeCascaderType,
  tree: TRecord[],
  onCustomExtraNodeKey?: (node: TRecord) => object
): TTXTreeCascaderNode[] => {
  const nodeConfig = getConfigMap(type)?.nodeConfig;
  const dealTree = mergeTreeMultipleChidlrens(
    tree,
    nodeConfig?.map((config) => config.childrenKey) ?? []
  );
  return transformMergeTreeMultipleTree(
    dealTree,
    nodeConfig,
    null,
    0,
    type,
    onCustomExtraNodeKey
  );
};

说明:

  • 先获取配置项
  • 调用 mergeTreeMultipleChidlrens 合并子树
  • 调用 transformMergeTreeMultipleTree 转换源树为指定结构树

然后是合并子树的操作

/**@function 初始节点合并多维度子树 */
export const mergeTreeMultipleChidlrens = (
  tree: TRecord[],
  fieldsToMerge: string[]
): TRecord[] => {
  return tree.map((node) => {
    const mergedNode = { ...node };
    const children: TRecord[] = [];

    fieldsToMerge.forEach((field) => {
      if (node[field] && Array.isArray(node[field])) {
        children.push(...node[field]);
        delete mergedNode[field];
      }
    });

    if (children.length > 0) {
      mergedNode.children = [...(mergedNode.children || []), ...children];
    }

    // 递归处理子节点
    if (mergedNode.children) {
      mergedNode.children = mergeTreeMultipleChidlrens(
        mergedNode.children,
        fieldsToMerge
      );
    }

    return mergedNode;
  });
};

说明:

  • fieldsToMerge 其实就是配置项 nodeConfig 中每一项的 childrenKey
  • 根据 childrenKey,把多维度子树都拼接到 children 下。由于业务中,基本返回的都是 childList,所以暂时没有覆盖源树chilren的情况
  • 返回合并后的结果

最后一步,处理多维度数为指定结构树

export interface ITXTreeDefaultCascaderNode extends TreeDataNode {
  /**@param 节点的源数据 */
  data?: TRecord;
  /**@param 是否高亮 */
  highLight?: boolean;
  /**@param 父节点id */
  parentId?: string;
  /**@param 层级 */
  level?: number;
  /**@param 其余自定义字段*/
  [key: string]: any;
}

// 转换后的树节点类型
export type TTXTreeCascaderNode = ITXTreeDefaultCascaderNode;

/**@function 处理多维度数为指定结构树 */
export const transformMergeTreeMultipleTree = (
  nodes: TRecord[],
  configs: INodeConfig[] | undefined = [],
  parentId: string | null = null,
  level: number,
  type: TTXTreeCascaderType,
  onCustomExtraNodeKey?: (node: TRecord) => object
): TTXTreeCascaderNode[] => {
  const txTreeMapHelper = TXTreeMapHelper.getInstance();
  return nodes.map((node) => {
    // 当前node是匹配哪一项配置
    const matchedConfigIndex = configs.findIndex((config) =>
      node.hasOwnProperty(config.searchFilterKey)
    );
    const matchedConfig = configs[matchedConfigIndex];
    const currentKey = level
      ? `${parentId}_${node[matchedConfig.key]}`
      : node[matchedConfig.key];
    const extraKeyValue = onCustomExtraNodeKey?.(node);
    const newNode: TTXTreeCascaderNode = {
      title: node[matchedConfig.searchFilterKey],
      key: currentKey,
      data: {
        ...node,
      },
      highLight: false,
      parentId: node[matchedConfig.parentKey] || parentId,
      level,
      ...(extraKeyValue ?? {}),
    };
    txTreeMapHelper.setTXTreeMapHelperMap(
      type,
      node[matchedConfig.key],
      newNode
    );
    // 递归处理子节点
    if (node.children) {
      newNode.children = transformMergeTreeMultipleTree(
        node.children,
        configs,
        currentKey,
        level + 1,
        type,
        onCustomExtraNodeKey
      );
    }

    return newNode;
  });
};

说明:

  • 根据之前 nodeConfig 配置项,构造指定树节点
  • 树节点的key是拼接了父节点key的。比如:1234_5678,1234是父节点的key,5678是节点本身的key
  • onCustomExtraNodeKey是由外部调用组件时,传入 props。此时回传 node 是接口返回的 node。方便自定义一些额外字段。
  • TXTreeMapHelper.getInstance() 获取单例Map对象,并构建映射关系

Map构建映射关系

由于树节点key不唯一,所以映射关系是 key一对多映射node

{
  key1: [
     node1,
     node2,
  ],
  key2: [
     node3
  ]
}

然后为了方便获取这个映射关系,TXTreeMapHelper是个单例对象,是个二维Map,结构如下

{
   'user': {
     key1: [node1, node2],
     key2: [node3],
     //...
   },
   
   'department': {
     key1: [node1, node2],
     key2: [node3, node4]
   }
}

这样不同的地方,如果调了相同的接口,都可以通过 user 或者 department 这个一级key,拿到对应的树节点映射关系。而且由因为是单例对象,所以只会构建一次。

然后在实际使用中,我们可以得到 map 如下:

企业微信截图_17478998482188.png

可以很清楚的看见相同的key对应多个子节点。并且转换后的节点key是拼接了父节点的key

回显选中态

回显时需要注意一个问题:

  • 如果是先打开弹窗选择后,关闭弹窗,然后再打开弹窗,此时 key 是拼接了父节点的 key。Tree组件能自动回显

  • 如果我们调一个详情接口,然后这个接口返回我们之前选择过的节点 key,此时 key 是只没有拼接父节点key的。

这种情况下回显的逻辑就依靠 map

/**@function 弹窗打开时,初始化选中态/
initTreeCheckedStatus() {
    const { propsStore } = this.rootStore;
    let result: TTXTreeCascaderNode[] = [];
    const initCheckedKeys: string[] =
      this.initData?.checkedNodes.map((node) => {
        let key = node.key as string;
        let newKey = key.includes("_") ? key.split("_")[node.level ?? 1] : key;
        return newKey;
      }) ?? [];
    const txTreeMapHelperMap = TXTreeMapHelper.getInstance().getTXTreeHelperMap(
      propsStore.props.type
    );
    initCheckedKeys.forEach((key) => {
      result = [...result, ...(txTreeMapHelperMap?.get(key) ?? [])];
    });

    this.checkedKeys = result.map((node) => node.key as string);
    this.checkedNodes = result;
}

说明:

  • 每一个节点的 key 通过 key.includes("_") ? key.split("_")[node.level ?? 1] : key 拿到节点本身的key。

    • 比如 1_2_33,我们得到的结果都是 3,即节点本身的 key 就是 3
  • 然后通过 map 就可以拿到 key 映射的节点列表。直接把结果回填到 checkedNodes 即可

企业微信截图_17479001746708.png

比如假如我们之前选择了 key 是 348913242698940451 的节点,通过 map 发现他对应两个节点。那这两个节点就作为 checkedNodes 即可。

然后 checkedkeys 就可以通过 checkedNodes.map((item) => item.key) 赋值

回显展开态

这个就简单些,直接帖代码:

initExpandStatus() {
    const keys = this.generateExpandKeysWithCheckedNodes();
    this.expandKeys = Array.from(keys);
    this.initExpandKeys = Array.from(keys);
}

generateExpandKeysWithCheckedNodes() {
    const keyMap = generateNodePath(this.originTransformTreeData);
    const keys = new Set<string>();

    const findParents = (key: string) => {
      const node = keyMap.get(key);
      if (node?.parentId) {
        keys.add(node.parentId);
        findParents(node.parentId);
      }
    };

    this.checkedNodes.forEach((node) => findParents(node.key as string));

    return Array.from(keys);
}

模糊查询

由于树节点以及转换了,所以直接匹配 title 即可

/**@function 模糊查询/
onSearch(value: string) {
    if (value.trim()) {
      const { filteredData, expandKeys } = filterTreeWithExpand(
        this.originTransformTreeData,
        value
      );
      this.treeData = filteredData;
      this.expandKeys = expandKeys;
    } else {
      this.treeData = [...this.originTransformTreeData];
      this.expandKeys = this.generateExpandKeysWithCheckedNodes();
    }
}

/**@function 过滤转换后的树,返回新的树及指定树节点的路径 */
export const filterTreeWithExpand = (
  tree: TTXTreeCascaderNode[],
  searchText: string
): { filteredData: TTXTreeCascaderNode[]; expandKeys: string[] } => {
  const expandedKeys: Set<string> = new Set();

  const filterFn = (nodes: TTXTreeCascaderNode[]): TTXTreeCascaderNode[] => {
    return nodes
      .map((node) => {
        const isMatch = (node.title as string)
          .toLowerCase()
          .includes(searchText.toLowerCase());
        const children = node.children
          ? filterFn(node.children as TTXTreeCascaderNode[])
          : undefined;
        const hasMatchedChild = children && children.length > 0;

        // 标记需展开的节点key(当前匹配或包含匹配子节点)
        if (isMatch || hasMatchedChild) {
          expandedKeys.add(node.key as string);
        }

        return {
          ...node,
          highLight: isMatch,
          children: hasMatchedChild || isMatch ? children : undefined,
        };
      })
      .filter(
        (node) =>
          node.children?.length ||
          (node.title as string)
            .toLowerCase()
            .includes(searchText.toLowerCase())
      );
  };

  return {
    filteredData: filterFn(tree),
    expandKeys: Array.from(expandedKeys),
  };
};

结尾

实现思路和过程大致这样

内存泄漏排查真经

作者 xiaoliang
2025年5月22日 15:50

内存泄漏排查真经

文/ 玄冥派内存监察使

(虚空之中,黑袍长老手持罗盘法器,罗盘上指针疯狂旋转)

"今日传授尔等内存监察大法。内存泄漏如同修士心魔,初时不显,日久必成大道之阻。且看这五方镇魔大阵——"


第一章:泄漏五方境界

1. 常规泄漏(土象)

// 未清理的全局变量
function leakEarth() {
  leakedData = new Array(1000000).fill('*'); // 百万级数据泄漏
}

2. 闭包泄漏(火象)

// 闭包持有大对象
function leakFire() {
  const hugeData = getHugeData();
  return function() {
    console.log(hugeData.length); // 闭包持续引用
  };
}

3. DOM泄漏(木象)

// 未解绑的DOM引用
function leakWood() {
  const button = document.getElementById('myButton');
  button.addEventListener('click', () => {
    console.log('Button clicked');
  });
  // 移除元素但未移除事件监听
  button.remove(); 
}

4. 定时器泄漏(金象)

// 未清除的定时器
function leakMetal() {
  setInterval(() => {
    const data = new Array(10000);
    // 持续产生内存占用
  }, 1000);
}

5. 缓存泄漏(水象)

// 无限增长的缓存
const cache = new Map();
function leakWater(key, value) {
  if (cache.size > 1000) return; // 缺少清理逻辑
  cache.set(key, value);
}

第二章:排查七式

第一式:开天眼(控制台监控)

// 内存快照对比
console.profile('Memory Snapshot 1');
takeSnapshot();
console.profileEnd();

// 执行可疑操作后
console.profile('Memory Snapshot 2');
takeSnapshot();
console.profileEnd();

第二式:祭法器(DevTools)

# Chrome内存记录
chrome://memory-redirect/

第三式:观星象(性能监控)

// 实时内存监控
setInterval(() => {
  const memory = performance.memory;
  console.log(`Used: ${memory.usedJSHeapSize}KB`);
}, 1000);

第四式:画符咒(内存快照)

// 生成堆快照
function takeHeapSnapshot() {
  if (window.chrome && window.chrome.devtools) {
    window.chrome.devtools.inspectedWindow.takeHeapSnapshot();
  }
}

第五式:测灵脉(压力测试)

// 自动触发GC观察内存
function triggerGC() {
  if (window.gc) {
    window.gc();
  } else {
    console.warn('请使用--expose-gc参数启动Chrome');
  }
}

第六式:追魂术(引用追踪)

// 跟踪特定对象
function trackObject(obj) {
  const ref = new WeakRef(obj);
  setInterval(() => {
    if (!ref.deref()) {
      console.log('对象已被回收');
      clearInterval(this);
    }
  }, 1000);
}

第七式:断因果(隔离测试)

// 创建隔离环境
function testInSandbox(code) {
  const iframe = document.createElement('iframe');
  document.body.appendChild(iframe);
  iframe.contentWindow.eval(code);
  setTimeout(() => {
    iframe.remove();
  }, 1000);
}

第三章:防治心法

"内存监察五要:
1️⃣ 定期自查(开发阶段监控)
2️⃣ 边界测试(大数据量压测)
3️⃣ 及时清理(释放无用引用)
4️⃣ 工具辅助(善用分析工具)
5️⃣ 代码规范(避免已知陷阱)"

第四章:实战演练

闭包泄漏解决方案

// 修复闭包泄漏
function fixClosureLeak() {
  const hugeData = getHugeData();
  
  // 使用弱引用
  const weakRef = new WeakRef(hugeData);
  
  return function() {
    const data = weakRef.deref();
    if (data) {
      console.log(data.length);
    }
  };
}

DOM泄漏完整解决方案

class SafeDOM {
  constructor(element) {
    this.element = element;
    this.handlers = new Map();
  }

  addEventListener(type, handler) {
    const wrappedHandler = (...args) => handler(...args);
    this.handlers.set(handler, wrappedHandler);
    this.element.addEventListener(type, wrappedHandler);
  }

  remove() {
    for (const [handler, wrapped] of this.handlers) {
      this.element.removeEventListener(type, wrapped);
    }
    this.element.remove();
    this.element = null;
  }
}

第五章:禁忌案例

魔道写法

// 错误1:无限增长的数组
const logs = [];
function logMessage(message) {
  logs.push(`${Date.now()}: ${message}`);
}

// 错误2:未清理的观察者
class Subject {
  constructor() {
    this.observers = [];
  }
  addObserver(obs) {
    this.observers.push(obs);
  }
  // 缺少removeObserver方法
}

正道解法

// 正确1:限制缓存大小
class LRUCache {
  constructor(maxSize = 100) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }

  set(key, value) {
    if (this.cache.size >= this.maxSize) {
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }
    this.cache.set(key, value);
  }
}

// 正确2:自动清理的观察者
class SafeSubject {
  constructor() {
    this.observers = new Set();
  }
  addObserver(obs) {
    this.observers.add(obs);
    return () => this.observers.delete(obs); // 返回清理函数
  }
}

(突然,罗盘指针剧烈抖动,出现内存溢出警告)

弟子:"师尊!Node进程内存突破2GB了!"

长老:"莫慌!此乃未释放的Stream导致,看老夫手段——"

长老掐诀念咒,虚空中浮现修复代码:

function fixStreamLeak() {
  const stream = createReadStream();
  
  // 自动销毁处理
  stream
    .on('data', processData)
    .on('end', () => stream.destroy())
    .on('error', () => stream.destroy());

  // 或者使用pipeline自动清理
  pipeline(
    stream,
    transformStream,
    processStream,
    (err) => {
      if (err) console.error(err);
    }
  );
}

飞升天象:

当内存监察修炼至大乘期,可:

  • 预判潜在泄漏点
  • 设计自清理架构
  • 实现零泄漏系统
  • 处理TB级内存管理

(长老化作五色流光,融入罗盘之中,浮现最后箴言)

"记住,内存之道在于'有借有还'。如同阴阳循环,分配必要及时释放......"

(罗盘展开,化作《内存监察真经》)

<真经展开,显现完整内存知识图谱> <第二元神显化:关注玄冥派,解锁更多内存秘法>


核心难点解析

1. 泄漏类型识别

graph TD
    A[内存持续增长] --> B[GC后不下降]
    B --> C{分析堆快照}
    C -->|全局变量| D[土象解法]
    C -->|闭包引用| E[火象解法]
    C -->|DOM游离| F[木象解法]

2. Node.js特殊泄漏

// 常见Node泄漏场景
const nodeLeaks = {
  '缓存未清理': {
    example: 'global.cache = {}',
    fix: '使用WeakMap或LRU缓存'
  },
  'Promise未处理': {
    example: 'new Promise(() => {...})',
    fix: '总是添加catch处理'
  },
  '事件监听未移除': {
    example: 'emitter.on(event, cb)',
    fix: '使用once或显式移除'
  }
};

3. 高级分析技巧

// 内存增长对比分析
function analyzeGrowth() {
  const snapshot1 = takeHeapSnapshot();
  // 执行操作...
  const snapshot2 = takeHeapSnapshot();
  
  return {
    sizeDiff: snapshot2.totalSize - snapshot1.totalSize,
    leakedTypes: compareSnapshots(snapshot1, snapshot2)
  };
}

4. 自动化检测方案

// 内存监控中间件
function memoryMonitor(req, res, next) {
  const startMem = process.memoryUsage();
  
  res.on('finish', () => {
    const endMem = process.memoryUsage();
    if (endMem.heapUsed - startMem.heapUsed > 1000000) {
      alertPotentialLeak(req.path);
    }
  });
  
  next();
}

5. 防御式编程规范

// 安全资源管理类
class SafeResource {
  constructor(resource) {
    this.resource = resource;
    this.closed = false;
  }

  use(callback) {
    if (this.closed) throw new Error('Resource closed');
    return callback(this.resource);
  }

  close() {
    cleanup(this.resource);
    this.resource = null;
    this.closed = true;
  }
}

// 使用示例
const resource = new SafeResource(createResource());
try {
  resource.use(res => {
    // 安全使用资源
  });
} finally {
  resource.close();
}

使用 Promise.all 与 Promise.race 实现并发请求限制(3个并发)

2025年5月22日 15:41

在现代前端开发中,我们经常需要处理大量异步请求,但浏览器对同一域名的并发请求数有限制(通常6-8个)。为了避免性能问题,我们需要手动控制并发请求数。本文将介绍如何使用 Promise.allPromise.race 来实现精确的并发控制,确保同时只发送3个请求。

一、为什么需要限制并发请求?

  1. 避免浏览器请求阻塞:浏览器对同一域名有并发请求限制
  2. 减轻服务器压力:防止瞬时大量请求压垮服务器
  3. 优化用户体验:有序的请求队列比混乱的大量请求更可控
  4. 资源合理利用:平衡网络带宽和CPU使用率

二、核心实现原理

我们将使用以下两个Promise API组合实现并发控制:

  • Promise.all:等待所有Promise完成
  • Promise.race:等待任意一个Promise完成

基本思路是:

  1. 初始化一个执行中的Promise集合
  2. 每当有新的请求时,如果已达到并发上限,就等待Promise.race
  3. 当一个请求完成时,从集合中移除,腾出空间给新请求
  4. 最终使用Promise.all等待所有请求完成

三、完整实现代码

/**
 * 限制并发数量的异步任务执行器
 * @param {Array<Function>} tasks 返回Promise的任务数组
 * @param {number} limit 并发限制数
 * @returns {Promise<Array>} 所有任务结果的数组
 */
async function runWithConcurrency(tasks, limit = 3) {
  // 存储所有任务的Promise
  const results = [];
  // 使用Set来追踪正在执行的任务
  const executing = new Set();
  
  for (const task of tasks) {
    // 如果达到并发限制,等待任意一个任务完成
    if (executing.size >= limit) {
      await Promise.race(executing);
    }
    
    // 创建并执行新任务
    const p = task()
      .then((res) => {
        executing.delete(p); // 任务完成,从执行集合中移除
        return res;
      })
      .catch((err) => {
        executing.delete(p); // 即使失败也要移除
        throw err;
      });
    
    executing.add(p); // 添加到执行集合
    results.push(p);  // 存储到结果数组
  }
  
  // 等待所有任务完成
  return Promise.all(results);
}

四、使用示例

// 模拟异步请求函数
function mockRequest(id, delay) {
  return new Promise((resolve) => {
    console.log(`请求 ${id} 开始`);
    setTimeout(() => {
      console.log(`请求 ${id} 完成`);
      resolve(`结果 ${id}`);
    }, delay);
  });
}

// 创建10个请求任务
const tasks = [];
for (let i = 1; i <= 10; i++) {
  tasks.push(() => mockRequest(i, Math.random() * 2000));
}

// 执行并发控制
runWithConcurrency(tasks, 3)
  .then((results) => {
    console.log('所有请求完成:', results);
  })
  .catch((err) => {
    console.error('请求出错:', err);
  });

五、代码解析

  1. 任务队列管理

    • executing Set集合用于跟踪正在执行的Promise
    • 通过executing.size实时获取当前并发数
  2. 并发控制逻辑

    • executing.size >= limit时,使用Promise.race(executing)等待任意一个任务完成
    • 任务完成后会自动从executing中移除,腾出并发空间
  3. 结果收集

    • 所有Promise都被推入results数组
    • 最终使用Promise.all(results)等待所有任务完成
  4. 错误处理

    • 每个Promise都添加了catch处理,确保出错时也能从executing中移除
    • 错误会通过Promise.all的catch传递出来

六、高级应用场景

  1. 文件分片上传

    async function uploadFiles(files, limit = 3) {
      const tasks = files.map(file => () => uploadChunk(file));
      return runWithConcurrency(tasks, limit);
    }
    
  2. 批量API请求

    async function fetchMultipleApis(apiUrls, limit = 3) {
      const tasks = apiUrls.map(url => () => fetch(url));
      return runWithConcurrency(tasks, limit);
    }
    
  3. 数据库操作

    async function batchInsert(records, limit = 3) {
      const tasks = records.map(record => () => db.insert(record));
      return runWithConcurrency(tasks, limit);
    }
    

七、性能优化建议

  1. 动态调整并发数

    // 根据网络状况动态调整
    const limit = navigator.connection?.effectiveType === '4g' ? 5 : 3;
    
  2. 优先级队列

    // 实现优先级任务调度
    tasks.sort((a, b) => b.priority - a.priority);
    
  3. 断点续传

    // 记录已完成任务,异常恢复后跳过
    const completed = new Set();
    const tasks = remainingTasks.filter(task => !completed.has(task.id));
    

八、总结

通过结合Promise.allPromise.race,我们可以实现优雅的并发请求控制。这种方法具有以下优点:

  1. 代码简洁:不到20行核心逻辑
  2. 功能强大:支持错误处理、结果收集
  3. 通用性好:适用于各种异步场景
  4. 性能优异:精确控制资源使用

这种模式已经成为现代前端开发中处理并发请求的标准实践,掌握它能够显著提升应用的稳定性和用户体验。

从零开始一个完整的全栈项目(9) - 前端登入登出页面

2025年5月22日 15:31

今天要完成前端的“登录页面”,包含登录之后的“登出”功能。


修改了如下文件:

image.png


要创建一个登录页面,我们需要:

  1. 创建登录页面组件
  2. 添加登录表单
  3. 处理登录逻辑
  4. 添加路由保护

1. 创建一个登录页面(Login.tsx)

import React, { useState } from 'react';
import { 
  Box, 
  Paper, 
  TextField, 
  Button, 
  Typography, 
  Container,
  Alert
} from '@mui/material';
import { useNavigate } from 'react-router-dom';

const Login: React.FC = () => {
  const navigate = useNavigate();
  const [formData, setFormData] = useState({
    username: '',
    password: ''
  });
  const [error, setError] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      const response = await fetch('http://localhost:8080/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(formData),
      });

      const data = await response.json();

      if (response.ok) {
        // 保存token和用户信息到localStorage
        localStorage.setItem('token', data.token);
        localStorage.setItem('user', JSON.stringify(data.user));
        // 跳转到仪表盘
        navigate('/');
      } else {
        setError(data.message || '登录失败');
      }
    } catch (err) {
      setError('网络错误,请稍后重试');
    }
  };

  return (
    <Container component="main" maxWidth="xs">
      <Box
        sx={{
          marginTop: 8,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
        }}
      >
        <Paper
          elevation={3}
          sx={{
            padding: 4,
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            width: '100%',
          }}
        >
          <Typography component="h1" variant="h5">
            仓库管理系统
          </Typography>
          <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1, width: '100%' }}>
            {error && (
              <Alert severity="error" sx={{ mb: 2 }}>
                {error}
              </Alert>
            )}
            <TextField
              margin="normal"
              required
              fullWidth
              id="username"
              label="用户名"
              name="username"
              autoComplete="username"
              autoFocus
              value={formData.username}
              onChange={handleChange}
            />
            <TextField
              margin="normal"
              required
              fullWidth
              name="password"
              label="密码"
              type="password"
              id="password"
              autoComplete="current-password"
              value={formData.password}
              onChange={handleChange}
            />
            <Button
              type="submit"
              fullWidth
              variant="contained"
              sx={{ mt: 3, mb: 2 }}
            >
              登录
            </Button>
          </Box>
        </Paper>
      </Box>
    </Container>
  );
};

export default Login; 

2. 创建一个路由保护(ProtectedRoute.tsx),用于保护需要登录才能访问的页面

import React from 'react';
import { Navigate } from 'react-router-dom';

interface ProtectedRouteProps {
  children: React.ReactNode;
}

const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
  const token = localStorage.getItem('token');

  if (!token) {
    // 如果没有token,重定向到登录页
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
};

export default ProtectedRoute; 

3. 更新APP.tsx,添加登录路由和保护其他路由

import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider, createTheme } from '@mui/material';
import MainLayout from './layouts/MainLayout';
import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import ProtectedRoute from './components/ProtectedRoute';

// 临时占位组件,后续会替换为实际页面组件
const PlaceholderPage = () => <div>Page under construction</div>;

const theme = createTheme({
  palette: {
    primary: {
      main: '#1976d2',
    },
    secondary: {
      main: '#dc004e',
    },
  },
});

function App() {
  return (
    <ThemeProvider theme={theme}>
      <Router>
        <Routes>
          <Route path="/login" element={<Login />} />
          <Route
            path="/"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <Dashboard />
                </MainLayout>
              </ProtectedRoute>
            }
          />
          <Route
            path="/products"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <PlaceholderPage />
                </MainLayout>
              </ProtectedRoute>
            }
          />
          <Route
            path="/orders"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <PlaceholderPage />
                </MainLayout>
              </ProtectedRoute>
            }
          />
          <Route
            path="/inbound"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <PlaceholderPage />
                </MainLayout>
              </ProtectedRoute>
            }
          />
          <Route
            path="/customers"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <PlaceholderPage />
                </MainLayout>
              </ProtectedRoute>
            }
          />
          <Route
            path="/delivery"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <PlaceholderPage />
                </MainLayout>
              </ProtectedRoute>
            }
          />
          <Route
            path="/users"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <PlaceholderPage />
                </MainLayout>
              </ProtectedRoute>
            }
          />
          <Route
            path="/reports"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <PlaceholderPage />
                </MainLayout>
              </ProtectedRoute>
            }
          />
          <Route
            path="/settings"
            element={
              <ProtectedRoute>
                <MainLayout>
                  <PlaceholderPage />
                </MainLayout>
              </ProtectedRoute>
            }
          />
        </Routes>
      </Router>
    </ThemeProvider>
  );
}

export default App;

4. 修改MainLayout.tsx,添加“登出”按钮及功能

import React from 'react';
import { Box, Drawer, AppBar, Toolbar, Typography, List, ListItem, ListItemIcon, ListItemText, IconButton, Button } from '@mui/material';
import { 
  Menu as MenuIcon,
  Dashboard, 
  Inventory, 
  People, 
  LocalShipping, 
  Settings,
  ShoppingCart,
  AddBox,
  Person,
  Assignment,
  Logout
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';

const drawerWidth = 240;

interface MainLayoutProps {
  children: React.ReactNode;
}

const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
  const navigate = useNavigate();
  const [mobileOpen, setMobileOpen] = React.useState(false);

  const menuItems = [
    { text: 'Dashboard', icon: <Dashboard />, path: '/' },
    { text: 'Products', icon: <Inventory />, path: '/products' },
    { text: 'Orders', icon: <ShoppingCart />, path: '/orders' },
    { text: 'Inbound', icon: <AddBox />, path: '/inbound' },
    { text: 'Customers', icon: <Person />, path: '/customers' },
    { text: 'Delivery', icon: <LocalShipping />, path: '/delivery' },
    { text: 'Users', icon: <People />, path: '/users' },
    { text: 'Reports', icon: <Assignment />, path: '/reports' },
    { text: 'Settings', icon: <Settings />, path: '/settings' },
  ];

  const handleLogout = () => {
    // 清除本地存储
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    // 跳转到登录页
    navigate('/login');
  };

  const drawer = (
    <div>
      <Toolbar>
        <Typography variant="h6" noWrap component="div">
          QuickStore
        </Typography>
      </Toolbar>
      <List>
        {menuItems.map((item) => (
          <ListItem 
            key={item.text} 
            onClick={() => navigate(item.path)}
            sx={{ cursor: 'pointer' }}
          >
            <ListItemIcon>{item.icon}</ListItemIcon>
            <ListItemText primary={item.text} />
          </ListItem>
        ))}
      </List>
    </div>
  );

  return (
    <Box sx={{ display: 'flex' }}>
      <AppBar
        position="fixed"
        sx={{
          width: { sm: `calc(100% - ${drawerWidth}px)` },
          ml: { sm: `${drawerWidth}px` },
        }}
      >
        <Toolbar>
          <IconButton
            color="inherit"
            edge="start"
            onClick={() => setMobileOpen(!mobileOpen)}
            sx={{ mr: 2, display: { sm: 'none' } }}
          >
            <MenuIcon />
          </IconButton>
          <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
            Aluminum Warehouse Management
          </Typography>
          <Button 
            color="inherit" 
            onClick={handleLogout}
            startIcon={<Logout />}
          >
            登出
          </Button>
        </Toolbar>
      </AppBar>
      <Box
        component="nav"
        sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
      >
        <Drawer
          variant="temporary"
          open={mobileOpen}
          onClose={() => setMobileOpen(false)}
          ModalProps={{
            keepMounted: true,
          }}
          sx={{
            display: { xs: 'block', sm: 'none' },
            '& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
          }}
        >
          {drawer}
        </Drawer>
        <Drawer
          variant="permanent"
          sx={{
            display: { xs: 'none', sm: 'block' },
            '& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
          }}
          open
        >
          {drawer}
        </Drawer>
      </Box>
      <Box
        component="main"
        sx={{
          flexGrow: 1,
          p: 3,
          width: { sm: `calc(100% - ${drawerWidth}px)` },
          mt: '64px',
        }}
      >
        {children}
      </Box>
    </Box>
  );
};

export default MainLayout; 

5. 需要修改一段后端代码

原因:由于前端和后端运行在不同的端口(前端在 3000,后端在 8080),我们需要处理跨域问题。

需要确保后端已经配置了 CORS (Cross-Origin Resource Sharing,跨源资源共享。是一个浏览器的安全机制,它决定了是否允许一个网页从不同的源(域名、端口或协议)请求资源),来允许来自 http://localhost:3000 的请求。

具体修改如下:

1. 创建CORS配置类

package com.quickstore.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        
        // 允许来自前端开发服务器的请求
        config.addAllowedOrigin("http://localhost:3000");
        
        // 允许的HTTP方法
        config.addAllowedMethod("*");
        
        // 允许的请求头
        config.addAllowedHeader("*");
        
        // 允许发送认证信息(cookies等)
        config.setAllowCredentials(true);
        
        // 对所有路径应用CORS配置
        source.registerCorsConfiguration("/**", config);
        
        return new CorsFilter(source);
    }
} 

2. 修改SecurityConfig文件,以启动CORS

package com.quickstore.config;

import com.quickstore.security.JwtAuthenticationFilter;
import com.quickstore.security.JwtTokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) throws Exception {
        http
            .cors().configurationSource(corsConfigurationSource())
            .and()
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(new JwtAuthenticationFilter(tokenProvider, userDetailsService), 
                            UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

修改完成之后,同样可以使用Postman发送请求,来进行测试。

image.png


完整的登录页面如下:

image.png

登录之后的“登出”按钮如下(右上角):

image.png 点击“登出”之后,回到登录页面。

完成!


说点题外话:
写完前端页面,最大的感觉是:AI让人感到有点害怕。
我以为对于我这种不太懂React的人来说,从零开始搭建框架、完成页面,应该要花费很多时间。但是实际上让AI来做,不用一会就搞定了。那在这个过程当中,我学到了些什么呢?细细回想一下,好像其实什么都没学到。
我能写出一个产品,但我还是不会这门技术。
那面试怎么办呢?
或者说,如果面试成功之后,我在工作当中,又要做些什么呢?老板给我一个需求,我再转达给“Cursor”大人?
我们还有“技术”么,我们还需要学习“技术”么?
未来好像是一个“产品”的时代,而不是一个“技术”的时代了?
更宝贵的是各种创意,不停用AI做出各种各样的“产品”?
有点迷茫。。。
不过不管怎样,还是先把前端最后几个页面做完,然后改简历,然后找到一份合适的工作。然后再想这些。


下一篇:完成前端用户注册页面

使用vue2做一个生成二维码的案例【可当组件使用】

作者 Json_
2025年5月22日 15:03

最近有个需求需要用前端来生成一个二维码,就封装了一个简单的组件,这篇文章来分享给大家。
使用的技术:

Vue2

Ant Design Vue

QRCodeJS2

node版本:16.20

组件样式:

image.png

大家可以根据自己的需求来调整代码。

image.png

依赖安装:

npm i

项目启动:

npm run serve

调用页面代码:

<template>
  <div id="app">
    <div class="container">
      <a-card title="二维码生成示例" style="width: 500px">
        <a-input
          v-model="qrContent"
          placeholder="请输入二维码内容"
          style="margin-bottom: 10px"
        />
        <a-input
          v-model="qrText"
          placeholder="请输入二维码说明文字"
          style="margin-bottom: 10px"
        />
        <a-input
          v-model="backgroundColor"
          placeholder="请输入二维码背景色(如:#ffffff)"
          style="margin-bottom: 10px"
        />
        <a-button type="primary" @click="generateQR">生成二维码</a-button>
        <a-divider />
        <QRCode
          v-if="showQR"
          ref="qrCode"
          :value="qrContent"
          :text="qrText"
          :qrSize="300"
          :padding="5"
          :fontSize="18"
          :backgroundColor="backgroundColor"
          textAlign="left"
        ></QRCode>
        <a-button v-if="showQR" type="primary" @click="downloadQR" style="margin-top: 10px">下载二维码</a-button>
      </a-card>
    </div>
  </div>
</template>

<script>
import QRCode from './components/QRCode.vue'

export default {
  name: 'App',
  components: {
    QRCode
  },
  data() {
    return {
      qrContent: '',
      qrText: '',
      backgroundColor: '#ffffff',
      showQR: false
    }
  },
  methods: {
    generateQR() {
      if (!this.qrContent) {
        this.$message.error('请输入二维码内容')
        return
      }
      this.showQR = true
    },
    downloadQR() {
      const qrData = this.$refs.qrCode.getQRCodeData()
      const link = document.createElement('a')
      link.download = 'qrcode.png'
      link.href = qrData
      link.click()
    }
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
}
.container {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  background-color: #f0f2f5;
}
</style>

组件核心代码:

image.png

这个使用vue2生成二维码的案例 代码 已经整理好了,有需要的小伙伴,可以自行获取使用: wwwoop.com/home/Index/…

❌
❌