普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月14日技术

JavaScript设计模式(十二)——代理模式 (Proxy)

作者 Asort
2025年10月14日 10:26

引言 - 代理模式的核心概念

代理模式是一种结构型设计模式,它为其他对象提供代理以控制访问。代理模式遵循控制访问、分离关注点及增强功能的核心原则。在现代JavaScript开发中,代理模式日益重要,它能解决对象访问控制、数据验证、懒加载、性能优化等问题,同时保持代码灵活性和可维护性。

JavaScript Proxy API详解

JavaScript Proxy API是ES6引入的强大功能,用于创建对象的代理,控制对对象的访问。基本语法为const proxy = new Proxy(target, handler),其中target是被代理对象,handler定义拦截行为。

Handler对象包含trap方法,用于拦截对象操作。例如:

const handler = {
  get(target, prop) { // 拦截属性读取
    return prop in target ? target[prop] : 'default';
  },
  set(target, prop, value) { // 拦截属性设置
    target[prop] = value;
    return true;
  }
};

Proxy支持13种拦截操作,包括get、set、has、deleteProperty、ownKeys、apply、construct等,覆盖了大部分JavaScript操作。

Proxy与Reflect紧密相关,Reflect提供的方法与Proxy的trap一一对应,使代理实现更简洁:

const handler = {
  get: Reflect.get, // 使用Reflect的get方法
  set: Reflect.set
};

Reflect API确保了默认行为的一致性,同时提供了更优雅的函数式操作方式。

代理模式的几种实现方式

代理模式通过中间层控制对象访问,提供多种实现方式满足不同需求。

虚拟代理与保护代理:虚拟代理延迟创建开销大的对象,保护代理控制访问权限。

// 保护代理示例
const user = { role: 'admin' };
const protectedObj = new Proxy(target, {
  get(target, prop) {
    if (user.role !== 'admin' && prop === 'sensitiveData') {
      throw new Error('无访问权限');
    }
    return target[prop];
  }
});

缓存代理与日志代理:缓存代理存储计算结果,日志代理记录操作,便于调试和优化。

// 缓存代理示例
const cachedFn = new Proxy(originalFn, {
  cache: {},
  apply(target, thisArg, args) {
    const key = JSON.stringify(args);
    return this.cache[key] || (this.cache[key] = target.apply(thisArg, args));
  }
});

防火墙代理:过滤危险请求,增强系统安全性。

// 防火墙代理示例
const firewall = new Proxy(target, {
  has(target, prop) {
    return ['exec', 'eval'].includes(prop) ? false : prop in target;
  }
});

这些代理模式为JavaScript开发提供了灵活的对象访问控制机制。

实际应用场景和案例

JavaScript代理模式在实际开发中有多种应用场景。在Vue3中,响应式系统通过Proxy实现数据追踪:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key); // 依赖收集
      return target[key];
    },
    set(target, key, value) {
      target[key] = value;
      trigger(target, key); // 触发更新
      return true;
    }
  });
}

API请求拦截与封装方面,Proxy可以统一处理请求:

const api = new Proxy({}, {
  get(target, prop) {
    return async (...args) => {
      const res = await fetch(prop, args);
      return handleResponse(res); // 统一错误处理
    };
  }
});

对象访问控制与验证示例:

const validator = new Proxy({}, {
  get(target, prop) {
    return (value) => {
      if (prop === 'email' && !/^\S+@\S+\.\S+$/.test(value)) {
        throw new Error('Invalid email format');
      }
      return value;
    };
  }
});

在模块系统中,Proxy可实现懒加载:

const moduleLoader = new Proxy({}, {
  get(target, prop) {
    if (!target[prop]) {
      target[prop] = import(`./modules/${prop}.js`); // 按需加载
    }
    return target[prop];
  }
});

最佳实践、性能与展望

代理模式适用于数据访问控制、对象功能扩展和操作拦截场景。实践中应避免过度使用代理,优先考虑直接操作性能。对于性能敏感场景,可采用缓存策略、减少代理层级及仅在必要时使用代理。现代框架如Vue 3、React广泛利用代理实现响应式系统,未来随着JavaScript引擎优化,代理模式将在状态管理、数据绑定和API封装领域有更广泛应用。掌握代理模式将帮助开发者构建更灵活、高效的应用架构。

第 8 篇:更广阔的世界 - 加载 3D 模型

作者 Zuckjet_
2025年10月14日 10:18

在前面的教程中,我们学会了如何手动定义顶点数据来创建简单的几何体,比如三角形和立方体。但是,如果我们想要渲染更复杂的模型——比如一个人物角色、一辆汽车或者一个精细的建筑——手动编写成千上万个顶点数据显然是不现实的。

这就是为什么我们需要学习如何加载外部 3D 模型文件。在这篇教程中,我们将探索如何在 WebGL 中加载和渲染 .OBJ 格式的 3D 模型,让我们的应用能够展示专业 3D 建模软件创建的复杂模型。

为什么选择 OBJ 格式?

在众多 3D 模型格式中(如 FBX、GLTF、Collada 等),我们选择 OBJ 格式作为入门的原因有:

  1. 简单易懂: OBJ 是纯文本格式,可以用任何文本编辑器打开查看
  2. 广泛支持: 几乎所有 3D 建模软件都支持导出 OBJ 格式
  3. 无需额外库: 解析逻辑相对简单,适合学习底层原理
  4. 社区资源丰富: 网上有大量免费的 OBJ 模型可供下载

注意: 虽然 OBJ 格式适合学习,但在生产环境中,GLTF 格式因其对动画、材质等特性的更好支持而更受欢迎。

OBJ 文件格式解析

让我们先了解 OBJ 文件的基本结构。下面是一个简单的 OBJ 文件示例:

# 这是注释
# 顶点坐标 (x, y, z)
v 0.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 0.0 0.0
v 1.0 1.0 0.0

# 纹理坐标 (u, v)
vt 0.0 0.0
vt 0.0 1.0
vt 1.0 0.0
vt 1.0 1.0

# 顶点法向量 (nx, ny, nz)
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0
vn 0.0 0.0 1.0

# 面定义 (顶点索引/纹理索引/法向量索引)
f 1/1/1 2/2/2 3/3/3
f 2/2/2 4/4/4 3/3/3

OBJ 格式的主要元素:

  • v (vertex): 定义 3D 空间中的顶点坐标
  • vt (texture coordinate): 定义纹理坐标(UV 映射)
  • vn (vertex normal): 定义顶点的法向量(用于光照计算)
  • f (face): 定义面(通常是三角形),使用索引引用前面定义的数据

重要: OBJ 文件中的索引是从 1 开始的,而 JavaScript 数组索引是从 0 开始的,解析时需要注意转换。

实现 OBJ 解析器

现在让我们编写一个简单的 OBJ 文件解析器:

class OBJParser {
  /**
   * 解析 OBJ 文件内容
   * @param {string} objText - OBJ 文件的文本内容
   * @returns {Object} 包含顶点、纹理坐标、法向量和索引的对象
   */
  static parse(objText) {
    // 临时数组,存储解析出的原始数据
    const tempPositions = [];
    const tempTexCoords = [];
    const tempNormals = [];

    // 最终的顶点数据(展开后)
    const positions = [];
    const texCoords = [];
    const normals = [];
    const indices = [];

    // 用于去重的映射表
    const vertexMap = new Map();
    let currentIndex = 0;

    // 按行分割文本
    const lines = objText.split('\n');

    for (let line of lines) {
      line = line.trim();

      // 跳过空行和注释
      if (!line || line.startsWith('#')) continue;

      const parts = line.split(/\s+/);
      const type = parts[0];

      switch (type) {
        case 'v':
          // 解析顶点坐标
          tempPositions.push([
            parseFloat(parts[1]),
            parseFloat(parts[2]),
            parseFloat(parts[3])
          ]);
          break;

        case 'vt':
          // 解析纹理坐标
          tempTexCoords.push([
            parseFloat(parts[1]),
            parseFloat(parts[2])
          ]);
          break;

        case 'vn':
          // 解析法向量
          tempNormals.push([
            parseFloat(parts[1]),
            parseFloat(parts[2]),
            parseFloat(parts[3])
          ]);
          break;

        case 'f':
          // 解析面(三角形)
          // OBJ 可能有四边形,我们需要三角化
          const faceVertices = parts.slice(1);

          // 将多边形分解为三角形扇形
          for (let i = 1; i < faceVertices.length - 1; i++) {
            const triangleVertices = [
              faceVertices[0],
              faceVertices[i],
              faceVertices[i + 1]
            ];

            // 处理三角形的每个顶点
            for (const vertex of triangleVertices) {
              const index = this.processVertex(
                vertex,
                tempPositions,
                tempTexCoords,
                tempNormals,
                vertexMap,
                positions,
                texCoords,
                normals
              );

              indices.push(index);
            }
          }
          break;
      }
    }

    return {
      positions: new Float32Array(positions),
      texCoords: new Float32Array(texCoords),
      normals: new Float32Array(normals),
      indices: new Uint16Array(indices)
    };
  }

  /**
   * 处理单个顶点
   * @returns {number} 顶点在最终数组中的索引
   */
  static processVertex(
    vertexString,
    tempPositions,
    tempTexCoords,
    tempNormals,
    vertexMap,
    positions,
    texCoords,
    normals
  ) {
    // 检查是否已经处理过这个顶点组合
    if (vertexMap.has(vertexString)) {
      return vertexMap.get(vertexString);
    }

    // 解析顶点字符串: "position/texcoord/normal"
    const [posIdx, texIdx, normIdx] = vertexString
      .split('/')
      .map(s => s ? parseInt(s) - 1 : -1); // OBJ 索引从 1 开始

    // 添加位置数据
    if (posIdx >= 0 && posIdx < tempPositions.length) {
      positions.push(...tempPositions[posIdx]);
    } else {
      positions.push(0, 0, 0); // 默认值
    }

    // 添加纹理坐标
    if (texIdx >= 0 && texIdx < tempTexCoords.length) {
      texCoords.push(...tempTexCoords[texIdx]);
    } else {
      texCoords.push(0, 0); // 默认值
    }

    // 添加法向量
    if (normIdx >= 0 && normIdx < tempNormals.length) {
      normals.push(...tempNormals[normIdx]);
    } else {
      normals.push(0, 0, 1); // 默认法向量
    }

    // 记录这个顶点的索引
    const index = vertexMap.size;
    vertexMap.set(vertexString, index);

    return index;
  }
}

解析器的关键点:

  1. 顶点去重: 使用 Map 来追踪已处理的顶点组合,避免重复数据
  2. 索引转换: 将 OBJ 的 1-based 索引转换为 JavaScript 的 0-based 索引
  3. 三角化: 将可能的四边形或多边形面分解为三角形
  4. 数据展开: 根据面的引用,将顶点属性组合成 WebGL 可用的格式

加载和解析 OBJ 文件

现在让我们编写一个函数来加载 OBJ 文件:

/**
 * 从 URL 加载 OBJ 模型
 * @param {string} url - OBJ 文件的 URL
 * @returns {Promise<Object>} 解析后的模型数据
 */
async function loadOBJ(url) {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const objText = await response.text();
    const modelData = OBJParser.parse(objText);

    console.log(`模型加载成功: ${modelData.positions.length / 3} 个顶点`);

    return modelData;
  } catch (error) {
    console.error('加载 OBJ 文件失败:', error);
    throw error;
  }
}

将模型数据传递给 WebGL

加载了模型数据后,我们需要将其传递给 WebGL。这个过程与我们之前手动创建几何体的过程相同:

/**
 * 创建模型的 WebGL 缓冲区
 */
function createModelBuffers(gl, modelData) {
  // 创建位置缓冲区
  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, modelData.positions, gl.STATIC_DRAW);

  // 创建纹理坐标缓冲区
  const texCoordBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, modelData.texCoords, gl.STATIC_DRAW);

  // 创建法向量缓冲区
  const normalBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, modelData.normals, gl.STATIC_DRAW);

  // 创建索引缓冲区
  const indexBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, modelData.indices, gl.STATIC_DRAW);

  return {
    position: positionBuffer,
    texCoord: texCoordBuffer,
    normal: normalBuffer,
    index: indexBuffer,
    vertexCount: modelData.indices.length
  };
}

渲染加载的模型

现在让我们把所有部分整合起来,创建一个完整的渲染循环:

/**
 * 设置顶点属性
 */
function setupVertexAttributes(gl, programInfo, buffers) {
  // 位置属性
  gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
  gl.vertexAttribPointer(
    programInfo.attribLocations.position,
    3,  // 每个顶点 3 个分量 (x, y, z)
    gl.FLOAT,
    false,
    0,
    0
  );
  gl.enableVertexAttribArray(programInfo.attribLocations.position);

  // 纹理坐标属性
  gl.bindBuffer(gl.ARRAY_BUFFER, buffers.texCoord);
  gl.vertexAttribPointer(
    programInfo.attribLocations.texCoord,
    2,  // 每个顶点 2 个分量 (u, v)
    gl.FLOAT,
    false,
    0,
    0
  );
  gl.enableVertexAttribArray(programInfo.attribLocations.texCoord);

  // 法向量属性
  gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
  gl.vertexAttribPointer(
    programInfo.attribLocations.normal,
    3,  // 每个顶点 3 个分量 (nx, ny, nz)
    gl.FLOAT,
    false,
    0,
    0
  );
  gl.enableVertexAttribArray(programInfo.attribLocations.normal);

  // 绑定索引缓冲区
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.index);
}

/**
 * 渲染场景
 */
function render(gl, programInfo, buffers, uniforms) {
  // 清除画布
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

  // 使用着色器程序
  gl.useProgram(programInfo.program);

  // 设置顶点属性
  setupVertexAttributes(gl, programInfo, buffers);

  // 设置 uniform 变量
  gl.uniformMatrix4fv(
    programInfo.uniformLocations.modelMatrix,
    false,
    uniforms.modelMatrix
  );
  gl.uniformMatrix4fv(
    programInfo.uniformLocations.viewMatrix,
    false,
    uniforms.viewMatrix
  );
  gl.uniformMatrix4fv(
    programInfo.uniformLocations.projectionMatrix,
    false,
    uniforms.projectionMatrix
  );

  // 绘制模型
  gl.drawElements(
    gl.TRIANGLES,
    buffers.vertexCount,
    gl.UNSIGNED_SHORT,
    0
  );
}

完整示例

让我们把所有内容整合到一个完整的 HTML 示例中:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>WebGL - 加载 OBJ 模型</title>
  <style>
    body {
      margin: 0;
      padding: 0;
      background: #1a1a1a;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      font-family: Arial, sans-serif;
    }

    canvas {
      border: 2px solid #333;
      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
    }

    #info {
      position: absolute;
      top: 20px;
      left: 20px;
      color: white;
      background: rgba(0, 0, 0, 0.7);
      padding: 15px;
      border-radius: 5px;
      font-size: 14px;
    }

    #loading {
      position: absolute;
      color: white;
      font-size: 20px;
    }
  </style>
</head>
<body>
  <canvas id="glCanvas" width="800" height="600"></canvas>
  <div id="info">
    <div>拖拽鼠标旋转模型</div>
    <div id="stats"></div>
  </div>
  <div id="loading">加载中...</div>

  <script>
    // ==================== 着色器代码 ====================

    const vertexShaderSource = `
      attribute vec3 aPosition;
      attribute vec2 aTexCoord;
      attribute vec3 aNormal;

      uniform mat4 uModelMatrix;
      uniform mat4 uViewMatrix;
      uniform mat4 uProjectionMatrix;
      uniform mat4 uNormalMatrix;

      varying vec2 vTexCoord;
      varying vec3 vNormal;
      varying vec3 vFragPos;

      void main() {
        vec4 worldPos = uModelMatrix * vec4(aPosition, 1.0);
        vFragPos = worldPos.xyz;

        gl_Position = uProjectionMatrix * uViewMatrix * worldPos;

        vTexCoord = aTexCoord;
        vNormal = mat3(uNormalMatrix) * aNormal;
      }
    `;

    const fragmentShaderSource = `
      precision mediump float;

      varying vec2 vTexCoord;
      varying vec3 vNormal;
      varying vec3 vFragPos;

      uniform vec3 uLightPos;
      uniform vec3 uViewPos;
      uniform vec3 uLightColor;
      uniform vec3 uObjectColor;

      void main() {
        // 环境光
        float ambientStrength = 0.3;
        vec3 ambient = ambientStrength * uLightColor;

        // 漫反射
        vec3 norm = normalize(vNormal);
        vec3 lightDir = normalize(uLightPos - vFragPos);
        float diff = max(dot(norm, lightDir), 0.0);
        vec3 diffuse = diff * uLightColor;

        // 镜面光
        float specularStrength = 0.5;
        vec3 viewDir = normalize(uViewPos - vFragPos);
        vec3 reflectDir = reflect(-lightDir, norm);
        float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
        vec3 specular = specularStrength * spec * uLightColor;

        // 最终颜色
        vec3 result = (ambient + diffuse + specular) * uObjectColor;
        gl_FragColor = vec4(result, 1.0);
      }
    `;

    // ==================== OBJ 解析器 ====================

    class OBJParser {
      static parse(objText) {
        const tempPositions = [];
        const tempTexCoords = [];
        const tempNormals = [];

        const positions = [];
        const texCoords = [];
        const normals = [];
        const indices = [];

        const vertexMap = new Map();

        const lines = objText.split('\n');

        for (let line of lines) {
          line = line.trim();
          if (!line || line.startsWith('#')) continue;

          const parts = line.split(/\s+/);
          const type = parts[0];

          switch (type) {
            case 'v':
              tempPositions.push([
                parseFloat(parts[1]),
                parseFloat(parts[2]),
                parseFloat(parts[3])
              ]);
              break;

            case 'vt':
              tempTexCoords.push([
                parseFloat(parts[1]),
                parseFloat(parts[2])
              ]);
              break;

            case 'vn':
              tempNormals.push([
                parseFloat(parts[1]),
                parseFloat(parts[2]),
                parseFloat(parts[3])
              ]);
              break;

            case 'f':
              const faceVertices = parts.slice(1);

              for (let i = 1; i < faceVertices.length - 1; i++) {
                const triangleVertices = [
                  faceVertices[0],
                  faceVertices[i],
                  faceVertices[i + 1]
                ];

                for (const vertex of triangleVertices) {
                  const index = this.processVertex(
                    vertex,
                    tempPositions,
                    tempTexCoords,
                    tempNormals,
                    vertexMap,
                    positions,
                    texCoords,
                    normals
                  );

                  indices.push(index);
                }
              }
              break;
          }
        }

        return {
          positions: new Float32Array(positions),
          texCoords: new Float32Array(texCoords),
          normals: new Float32Array(normals),
          indices: new Uint16Array(indices)
        };
      }

      static processVertex(
        vertexString,
        tempPositions,
        tempTexCoords,
        tempNormals,
        vertexMap,
        positions,
        texCoords,
        normals
      ) {
        if (vertexMap.has(vertexString)) {
          return vertexMap.get(vertexString);
        }

        const [posIdx, texIdx, normIdx] = vertexString
          .split('/')
          .map(s => s ? parseInt(s) - 1 : -1);

        if (posIdx >= 0 && posIdx < tempPositions.length) {
          positions.push(...tempPositions[posIdx]);
        } else {
          positions.push(0, 0, 0);
        }

        if (texIdx >= 0 && texIdx < tempTexCoords.length) {
          texCoords.push(...tempTexCoords[texIdx]);
        } else {
          texCoords.push(0, 0);
        }

        if (normIdx >= 0 && normIdx < tempNormals.length) {
          normals.push(...tempNormals[normIdx]);
        } else {
          normals.push(0, 0, 1);
        }

        const index = vertexMap.size;
        vertexMap.set(vertexString, index);

        return index;
      }
    }

    // ==================== 矩阵工具函数 ====================

    const mat4 = {
      create() {
        return new Float32Array([
          1, 0, 0, 0,
          0, 1, 0, 0,
          0, 0, 1, 0,
          0, 0, 0, 1
        ]);
      },

      perspective(fov, aspect, near, far) {
        const f = 1.0 / Math.tan(fov / 2);
        const nf = 1 / (near - far);

        return new Float32Array([
          f / aspect, 0, 0, 0,
          0, f, 0, 0,
          0, 0, (far + near) * nf, -1,
          0, 0, 2 * far * near * nf, 0
        ]);
      },

      lookAt(eye, center, up) {
        const z = [
          eye[0] - center[0],
          eye[1] - center[1],
          eye[2] - center[2]
        ];
        const len = Math.sqrt(z[0] * z[0] + z[1] * z[1] + z[2] * z[2]);
        z[0] /= len; z[1] /= len; z[2] /= len;

        const x = [
          up[1] * z[2] - up[2] * z[1],
          up[2] * z[0] - up[0] * z[2],
          up[0] * z[1] - up[1] * z[0]
        ];
        const lenX = Math.sqrt(x[0] * x[0] + x[1] * x[1] + x[2] * x[2]);
        x[0] /= lenX; x[1] /= lenX; x[2] /= lenX;

        const y = [
          z[1] * x[2] - z[2] * x[1],
          z[2] * x[0] - z[0] * x[2],
          z[0] * x[1] - z[1] * x[0]
        ];

        return new Float32Array([
          x[0], y[0], z[0], 0,
          x[1], y[1], z[1], 0,
          x[2], y[2], z[2], 0,
          -(x[0] * eye[0] + x[1] * eye[1] + x[2] * eye[2]),
          -(y[0] * eye[0] + y[1] * eye[1] + y[2] * eye[2]),
          -(z[0] * eye[0] + z[1] * eye[1] + z[2] * eye[2]),
          1
        ]);
      },

      rotateY(angle) {
        const c = Math.cos(angle);
        const s = Math.sin(angle);

        return new Float32Array([
          c, 0, s, 0,
          0, 1, 0, 0,
          -s, 0, c, 0,
          0, 0, 0, 1
        ]);
      },

      rotateX(angle) {
        const c = Math.cos(angle);
        const s = Math.sin(angle);

        return new Float32Array([
          1, 0, 0, 0,
          0, c, -s, 0,
          0, s, c, 0,
          0, 0, 0, 1
        ]);
      },

      multiply(a, b) {
        const result = new Float32Array(16);

        for (let i = 0; i < 4; i++) {
          for (let j = 0; j < 4; j++) {
            result[i * 4 + j] =
              a[i * 4 + 0] * b[0 * 4 + j] +
              a[i * 4 + 1] * b[1 * 4 + j] +
              a[i * 4 + 2] * b[2 * 4 + j] +
              a[i * 4 + 3] * b[3 * 4 + j];
          }
        }

        return result;
      },

      invert(m) {
        const inv = new Float32Array(16);

        inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] -
                 m[9] * m[6] * m[15] + m[9] * m[7] * m[14] +
                 m[13] * m[6] * m[11] - m[13] * m[7] * m[10];

        inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] +
                  m[8] * m[6] * m[15] - m[8] * m[7] * m[14] -
                  m[12] * m[6] * m[11] + m[12] * m[7] * m[10];

        inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] -
                 m[8] * m[5] * m[15] + m[8] * m[7] * m[13] +
                 m[12] * m[5] * m[11] - m[12] * m[7] * m[9];

        inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] +
                   m[8] * m[5] * m[14] - m[8] * m[6] * m[13] -
                   m[12] * m[5] * m[10] + m[12] * m[6] * m[9];

        inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] +
                  m[9] * m[2] * m[15] - m[9] * m[3] * m[14] -
                  m[13] * m[2] * m[11] + m[13] * m[3] * m[10];

        inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] -
                 m[8] * m[2] * m[15] + m[8] * m[3] * m[14] +
                 m[12] * m[2] * m[11] - m[12] * m[3] * m[10];

        inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] +
                  m[8] * m[1] * m[15] - m[8] * m[3] * m[13] -
                  m[12] * m[1] * m[11] + m[12] * m[3] * m[9];

        inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] -
                  m[8] * m[1] * m[14] + m[8] * m[2] * m[13] +
                  m[12] * m[1] * m[10] - m[12] * m[2] * m[9];

        inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] -
                 m[5] * m[2] * m[15] + m[5] * m[3] * m[14] +
                 m[13] * m[2] * m[7] - m[13] * m[3] * m[6];

        inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] +
                  m[4] * m[2] * m[15] - m[4] * m[3] * m[14] -
                  m[12] * m[2] * m[7] + m[12] * m[3] * m[6];

        inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] -
                  m[4] * m[1] * m[15] + m[4] * m[3] * m[13] +
                  m[12] * m[1] * m[7] - m[12] * m[3] * m[5];

        inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] +
                   m[4] * m[1] * m[14] - m[4] * m[2] * m[13] -
                   m[12] * m[1] * m[6] + m[12] * m[2] * m[5];

        inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] +
                  m[5] * m[2] * m[11] - m[5] * m[3] * m[10] -
                  m[9] * m[2] * m[7] + m[9] * m[3] * m[6];

        inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] -
                 m[4] * m[2] * m[11] + m[4] * m[3] * m[10] +
                 m[8] * m[2] * m[7] - m[8] * m[3] * m[6];

        inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] +
                   m[4] * m[1] * m[11] - m[4] * m[3] * m[9] -
                   m[8] * m[1] * m[7] + m[8] * m[3] * m[5];

        inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] -
                  m[4] * m[1] * m[10] + m[4] * m[2] * m[9] +
                  m[8] * m[1] * m[6] - m[8] * m[2] * m[5];

        let det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12];

        if (det === 0) return null;

        det = 1.0 / det;

        for (let i = 0; i < 16; i++) {
          inv[i] = inv[i] * det;
        }

        return inv;
      },

      transpose(m) {
        return new Float32Array([
          m[0], m[4], m[8], m[12],
          m[1], m[5], m[9], m[13],
          m[2], m[6], m[10], m[14],
          m[3], m[7], m[11], m[15]
        ]);
      }
    };

    // ==================== WebGL 初始化 ====================

    function initShaders(gl) {
      const vertexShader = gl.createShader(gl.VERTEX_SHADER);
      gl.shaderSource(vertexShader, vertexShaderSource);
      gl.compileShader(vertexShader);

      if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
        console.error('顶点着色器编译失败:', gl.getShaderInfoLog(vertexShader));
        return null;
      }

      const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
      gl.shaderSource(fragmentShader, fragmentShaderSource);
      gl.compileShader(fragmentShader);

      if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
        console.error('片段着色器编译失败:', gl.getShaderInfoLog(fragmentShader));
        return null;
      }

      const program = gl.createProgram();
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      gl.linkProgram(program);

      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.error('着色器程序链接失败:', gl.getProgramInfoLog(program));
        return null;
      }

      return {
        program: program,
        attribLocations: {
          position: gl.getAttribLocation(program, 'aPosition'),
          texCoord: gl.getAttribLocation(program, 'aTexCoord'),
          normal: gl.getAttribLocation(program, 'aNormal')
        },
        uniformLocations: {
          modelMatrix: gl.getUniformLocation(program, 'uModelMatrix'),
          viewMatrix: gl.getUniformLocation(program, 'uViewMatrix'),
          projectionMatrix: gl.getUniformLocation(program, 'uProjectionMatrix'),
          normalMatrix: gl.getUniformLocation(program, 'uNormalMatrix'),
          lightPos: gl.getUniformLocation(program, 'uLightPos'),
          viewPos: gl.getUniformLocation(program, 'uViewPos'),
          lightColor: gl.getUniformLocation(program, 'uLightColor'),
          objectColor: gl.getUniformLocation(program, 'uObjectColor')
        }
      };
    }

    function createModelBuffers(gl, modelData) {
      const positionBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, modelData.positions, gl.STATIC_DRAW);

      const texCoordBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, modelData.texCoords, gl.STATIC_DRAW);

      const normalBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, modelData.normals, gl.STATIC_DRAW);

      const indexBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, modelData.indices, gl.STATIC_DRAW);

      return {
        position: positionBuffer,
        texCoord: texCoordBuffer,
        normal: normalBuffer,
        index: indexBuffer,
        vertexCount: modelData.indices.length
      };
    }

    // ==================== 主程序 ====================

    async function main() {
      const canvas = document.getElementById('glCanvas');
      const gl = canvas.getContext('webgl');

      if (!gl) {
        alert('无法初始化 WebGL,您的浏览器可能不支持。');
        return;
      }

      // 启用深度测试
      gl.enable(gl.DEPTH_TEST);
      gl.depthFunc(gl.LEQUAL);

      // 设置清除颜色
      gl.clearColor(0.1, 0.1, 0.1, 1.0);

      // 初始化着色器
      const programInfo = initShaders(gl);
      if (!programInfo) return;

      // 创建一个简单的立方体 OBJ 数据(用于演示)
      const cubeOBJ = `
# 立方体
v -1.0 -1.0  1.0
v  1.0 -1.0  1.0
v  1.0  1.0  1.0
v -1.0  1.0  1.0
v -1.0 -1.0 -1.0
v  1.0 -1.0 -1.0
v  1.0  1.0 -1.0
v -1.0  1.0 -1.0

vt 0.0 0.0
vt 1.0 0.0
vt 1.0 1.0
vt 0.0 1.0

vn  0.0  0.0  1.0
vn  0.0  0.0 -1.0
vn  0.0  1.0  0.0
vn  0.0 -1.0  0.0
vn  1.0  0.0  0.0
vn -1.0  0.0  0.0

# 前面
f 1/1/1 2/2/1 3/3/1
f 1/1/1 3/3/1 4/4/1

# 后面
f 5/1/2 6/2/2 7/3/2
f 5/1/2 7/3/2 8/4/2

# 顶面
f 4/1/3 3/2/3 7/3/3
f 4/1/3 7/3/3 8/4/3

# 底面
f 1/1/4 2/2/4 6/3/4
f 1/1/4 6/3/4 5/4/4

# 右面
f 2/1/5 6/2/5 7/3/5
f 2/1/5 7/3/5 3/4/5

# 左面
f 1/1/6 5/2/6 8/3/6
f 1/1/6 8/3/6 4/4/6
      `;

      // 解析模型
      const modelData = OBJParser.parse(cubeOBJ);
      const buffers = createModelBuffers(gl, modelData);

      // 隐藏加载提示
      document.getElementById('loading').style.display = 'none';

      // 显示统计信息
      document.getElementById('stats').innerHTML =
        `顶点数: ${modelData.positions.length / 3}<br>三角形数: ${modelData.indices.length / 3}`;

      // 设置投影矩阵
      const projectionMatrix = mat4.perspective(
        Math.PI / 4,  // 45度视场角
        canvas.width / canvas.height,
        0.1,
        100.0
      );

      // 相机位置
      let cameraDistance = 5.0;
      const cameraPos = [0, 0, cameraDistance];

      // 旋转角度
      let rotationX = 0.2;
      let rotationY = 0;

      // 鼠标交互
      let isDragging = false;
      let lastX = 0;
      let lastY = 0;

      canvas.addEventListener('mousedown', (e) => {
        isDragging = true;
        lastX = e.clientX;
        lastY = e.clientY;
      });

      canvas.addEventListener('mousemove', (e) => {
        if (!isDragging) return;

        const deltaX = e.clientX - lastX;
        const deltaY = e.clientY - lastY;

        rotationY += deltaX * 0.01;
        rotationX += deltaY * 0.01;

        // 限制 X 轴旋转角度
        rotationX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotationX));

        lastX = e.clientX;
        lastY = e.clientY;
      });

      canvas.addEventListener('mouseup', () => {
        isDragging = false;
      });

      canvas.addEventListener('mouseleave', () => {
        isDragging = false;
      });

      // 滚轮缩放
      canvas.addEventListener('wheel', (e) => {
        e.preventDefault();
        cameraDistance += e.deltaY * 0.01;
        cameraDistance = Math.max(2, Math.min(20, cameraDistance));
        cameraPos[2] = cameraDistance;
      });

      // 渲染循环
      function render() {
        // 清除画布
        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

        // 使用着色器程序
        gl.useProgram(programInfo.program);

        // 设置顶点属性
        gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
        gl.vertexAttribPointer(programInfo.attribLocations.position, 3, gl.FLOAT, false, 0, 0);
        gl.enableVertexAttribArray(programInfo.attribLocations.position);

        gl.bindBuffer(gl.ARRAY_BUFFER, buffers.texCoord);
        gl.vertexAttribPointer(programInfo.attribLocations.texCoord, 2, gl.FLOAT, false, 0, 0);
        gl.enableVertexAttribArray(programInfo.attribLocations.texCoord);

        gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
        gl.vertexAttribPointer(programInfo.attribLocations.normal, 3, gl.FLOAT, false, 0, 0);
        gl.enableVertexAttribArray(programInfo.attribLocations.normal);

        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.index);

        // 计算模型矩阵
        const rotY = mat4.rotateY(rotationY);
        const rotX = mat4.rotateX(rotationX);
        const modelMatrix = mat4.multiply(rotY, rotX);

        // 计算视图矩阵
        const viewMatrix = mat4.lookAt(
          [0, 0, cameraDistance],
          [0, 0, 0],
          [0, 1, 0]
        );

        // 计算法向量矩阵(模型矩阵的逆转置)
        const normalMatrix = mat4.transpose(mat4.invert(modelMatrix));

        // 设置 uniform 变量
        gl.uniformMatrix4fv(programInfo.uniformLocations.modelMatrix, false, modelMatrix);
        gl.uniformMatrix4fv(programInfo.uniformLocations.viewMatrix, false, viewMatrix);
        gl.uniformMatrix4fv(programInfo.uniformLocations.projectionMatrix, false, projectionMatrix);
        gl.uniformMatrix4fv(programInfo.uniformLocations.normalMatrix, false, normalMatrix);

        gl.uniform3f(programInfo.uniformLocations.lightPos, 5.0, 5.0, 5.0);
        gl.uniform3f(programInfo.uniformLocations.viewPos, cameraPos[0], cameraPos[1], cameraPos[2]);
        gl.uniform3f(programInfo.uniformLocations.lightColor, 1.0, 1.0, 1.0);
        gl.uniform3f(programInfo.uniformLocations.objectColor, 0.3, 0.6, 0.9);

        // 绘制模型
        gl.drawElements(gl.TRIANGLES, buffers.vertexCount, gl.UNSIGNED_SHORT, 0);

        requestAnimationFrame(render);
      }

      render();
    }

    main();
  </script>
</body>
</html>

优化建议

1. 法向量计算

如果 OBJ 文件没有提供法向量数据,我们需要自己计算:

/**
 * 计算平面法向量
 */
function calculateFaceNormal(v0, v1, v2) {
  // 计算两条边向量
  const edge1 = [
    v1[0] - v0[0],
    v1[1] - v0[1],
    v1[2] - v0[2]
  ];

  const edge2 = [
    v2[0] - v0[0],
    v2[1] - v0[1],
    v2[2] - v0[2]
  ];

  // 计算叉积(法向量)
  const normal = [
    edge1[1] * edge2[2] - edge1[2] * edge2[1],
    edge1[2] * edge2[0] - edge1[0] * edge2[2],
    edge1[0] * edge2[1] - edge1[1] * edge2[0]
  ];

  // 归一化
  const length = Math.sqrt(
    normal[0] * normal[0] +
    normal[1] * normal[1] +
    normal[2] * normal[2]
  );

  return [
    normal[0] / length,
    normal[1] / length,
    normal[2] / length
  ];
}

2. 性能优化

对于大型模型,可以考虑以下优化:

  • Web Workers: 在后台线程中解析 OBJ 文件
  • 增量加载: 对于超大模型,分批加载和渲染
  • LOD (Level of Detail): 根据距离使用不同精度的模型
  • 剔除 (Culling): 不渲染视野外的对象
// 使用 Web Worker 解析 OBJ
const worker = new Worker('obj-parser-worker.js');

worker.postMessage({ objText: objFileContent });

worker.onmessage = (e) => {
  const modelData = e.data;
  // 创建缓冲区并渲染...
};

3. 材质支持

完整的 OBJ 加载器还应该支持 MTL (Material Library) 文件:

// 解析 MTL 文件
class MTLParser {
  static parse(mtlText) {
    const materials = {};
    let currentMaterial = null;

    const lines = mtlText.split('\n');

    for (let line of lines) {
      line = line.trim();
      if (!line || line.startsWith('#')) continue;

      const parts = line.split(/\s+/);

      switch (parts[0]) {
        case 'newmtl':
          currentMaterial = parts[1];
          materials[currentMaterial] = {};
          break;

        case 'Ka': // 环境光颜色
          materials[currentMaterial].ambient = [
            parseFloat(parts[1]),
            parseFloat(parts[2]),
            parseFloat(parts[3])
          ];
          break;

        case 'Kd': // 漫反射颜色
          materials[currentMaterial].diffuse = [
            parseFloat(parts[1]),
            parseFloat(parts[2]),
            parseFloat(parts[3])
          ];
          break;

        case 'Ks': // 镜面光颜色
          materials[currentMaterial].specular = [
            parseFloat(parts[1]),
            parseFloat(parts[2]),
            parseFloat(parts[3])
          ];
          break;

        case 'map_Kd': // 漫反射纹理贴图
          materials[currentMaterial].diffuseMap = parts[1];
          break;
      }
    }

    return materials;
  }
}

获取免费 3D 模型资源

学习过程中,你可以从以下网站获取免费的 OBJ 模型:

  1. Sketchfab (sketchfab.com) - 可以下载很多免费模型
  2. Free3D (free3d.com) - 大量免费 3D 模型
  3. TurboSquid (www.turbosquid.com/Search/3D-M…) - 有免费模型区域
  4. CGTrader (www.cgtrader.com/free-3d-mod…) - 免费 3D 模型市场

常见问题

1. 模型显示不完整或倒置?

这可能是坐标系的问题。不同的 3D 软件使用不同的坐标系统(Y-up vs Z-up)。你可能需要在加载后对模型进行变换:

// 如果模型是 Z-up,而 WebGL 使用 Y-up
const rotationMatrix = mat4.rotateX(-Math.PI / 2);

2. 模型太大或太小?

在加载模型后,计算其包围盒并进行缩放:

function calculateBoundingBox(positions) {
  let min = [Infinity, Infinity, Infinity];
  let max = [-Infinity, -Infinity, -Infinity];

  for (let i = 0; i < positions.length; i += 3) {
    min[0] = Math.min(min[0], positions[i]);
    min[1] = Math.min(min[1], positions[i + 1]);
    min[2] = Math.min(min[2], positions[i + 2]);

    max[0] = Math.max(max[0], positions[i]);
    max[1] = Math.max(max[1], positions[i + 1]);
    max[2] = Math.max(max[2], positions[i + 2]);
  }

  return { min, max };
}

3. 性能问题?

  • 使用 gl.STATIC_DRAW 而不是 gl.DYNAMIC_DRAW(如果数据不会改变)
  • 考虑使用索引缓冲区来减少重复顶点
  • 对于动画模型,考虑使用顶点着色器进行变换

总结

在本教程中,我们学习了:

  • OBJ 文件格式的基本结构和语法
  • 如何编写一个简单的 OBJ 解析器
  • 如何将解析后的模型数据传递给 WebGL
  • 如何渲染加载的 3D 模型
  • 性能优化和材质支持的进阶技巧

现在,你已经掌握了加载和渲染外部 3D 模型的能力,可以在你的 WebGL 应用中展示更复杂、更精美的 3D 内容了!

在下一篇教程中,我们将探索 WebGL 框架(如 Three.js),看看它们如何简化我们的开发流程,以及何时应该选择使用框架而不是原生 WebGL。

练习

  1. 尝试从免费模型网站下载一个 OBJ 模型,并在你的应用中加载它
  2. 为解析器添加错误处理,当 OBJ 文件格式不正确时给出友好的提示
  3. 实现一个简单的 MTL 解析器,让模型能够显示正确的材质颜色
  4. 添加一个文件上传功能,让用户可以加载本地的 OBJ 文件

智能聊天机器人实践应用版(适合企业 / 项目落地者)

作者 星链引擎
2025年10月14日 10:14

为什么现在要做智能聊天机器人?

对企业来说,智能聊天机器人不是 “锦上添花”,而是 “降本增效” 的刚需:

  • 客服场景:人工客服人均接待量约 5-8 人 / 小时,机器人可同时接待上千人,还能 24 小时在线,能帮企业节省 60% 以上的客服成本;
  • 营销场景:传统营销靠 “广撒网”,机器人能通过对话精准判断用户需求(如 “想买保湿护肤品”),推荐转化率比传统广告高 30%+;
  • 内部协作:企业内部可做 “知识库机器人”,员工查规章制度、系统操作指南时,不用翻文档,直接问机器人就行。

而现在落地门槛极低:OpenAI 提供 “聪明的大脑”(模型),New API 提供 “稳定的通道”(接口),不用组建大技术团队,1-2 个开发者就能搞定。

核心逻辑:怎么让机器人 “能用、好用”?

1. 基础逻辑:模型 + API 的协同

  • 模型负责 “懂” 和 “说”:比如用户问 “怎么退货”,GPT-3 能理解这是 “售后需求”,还能生成 “先申请退货,再寄回商品” 的步骤;
  • API 负责 “稳定跑”:New API 平台解决了 “调用卡壳”“高峰期用不了” 的问题,哪怕双 11、618 这样的大促,也能稳定响应。

2. 关键:贴合业务需求

光有基础逻辑不够,要结合自己的业务改:比如电商客服机器人,要把 “退货流程”“物流查询” 这些自家的规则,通过 “提示词” 告诉模型,让机器人说的话符合企业要求。

实践代码:能直接落地的电商客服机器人(示例)

下面代码已经适配 New API 的稳定服务,加了电商客服的核心功能(回复退货、查物流),改改 “业务规则” 就能用:

python

运行

import openai
import os  # 用环境变量存密钥,更安全

# 1. 初始化:从环境变量读密钥,避免硬编码
client = openai.OpenAI(
    base_url='https://yunwu.ai/v1',  # 国内稳定节点,大促也不卡
    api_key=os.getenv("OPENAI_API_KEY")  # 本地设环境变量:export OPENAI_API_KEY=你的密钥
)

# 2. 电商客服专属:拼接业务规则(告诉机器人怎么回复)
def get_ecommerce_prompt(user_input: str) -> str:
    """
    拼接电商客服的业务规则,让机器人按规则回复
    """
    business_rules = """
    你是某电商平台的售后客服机器人,回复要满足:
    1. 先回应用户需求(比如“您好,退货流程是这样的:”);
    2. 退货问题:要提“需在收货后7天内申请,寄回地址是XX省XX市XX路”;
    3. 物流问题:要提“可在APP‘我的订单’里查,或告诉我订单号帮你查”;
    4. 不知道的问题:说“我帮你转人工客服,稍等~”,别乱答。
    """
    # 把“业务规则+用户问题”一起传给模型
    return f"{business_rules}\n用户问:{user_input}"

# 3. 核心函数:生成客服回复
def ecommerce_chat(user_input: str) -> str:
    prompt = get_ecommerce_prompt(user_input)
    response = client.Completion.create(
        engine="davinci",  # 选稳定的模型,客服场景要准确
        prompt=prompt,
        max_tokens=200,  # 客服回复要详细,比普通对话长一点
        temperature=0.3  # 降低随机性,确保回复符合业务规则
    )
    return response.choices[0].text.strip()

# 4. 测试:模拟用户问退货和物流
test_cases = [
    "我想退货,怎么操作?",
    "我的快递到哪了?"
]
for case in test_cases:
    print(f"用户:{case}")
    print(f"客服机器人:{ecommerce_chat(case)}\n")

落地时要注意的 3 个关键问题

1. 怎么让机器人 “说对业务”?

  • 把业务规则写细:比如退货的 “7 天期限”“寄回地址”,一定要在business_rules里写清楚,模型会照着规则回复;
  • 定期更新规则:比如活动期间退货期限延长到 15 天,要及时改business_rules,避免机器人说旧规则。

2. 怎么保证用户数据安全?

  • 密钥别泄露:用环境变量(如代码里的os.getenv)或配置文件存密钥,别把密钥写在代码里,更别传到网上;
  • 敏感信息过滤:用户说 “我的手机号是 138XXXX1234”,要在传给模型前删掉手机号,避免模型记住或泄露。

3. 怎么知道机器人好不好用?

  • 看用户反馈:在机器人回复后加 “是否解决你的问题?”,用户说 “没解决” 就转人工,同时记录问题,优化business_rules
  • 看数据:统计 “机器人解决率”(比如 100 个问题里,80 个能解决),低于 70% 就要优化。

后续可以加什么功能?

  1. 订单绑定:让用户输入订单号,机器人能调用企业的订单系统,查具体的订单状态(比如 “你的订单已发货,快递号是 XXX”);
  2. 语音回复:对接语音合成 API,让机器人能 “说话”,适合老年人用户;
  3. 多语言支持:加一句business_rules(“如果用户说英文,就用英文回复”),就能做跨境电商的多语言客服。

如果落地时遇到具体问题(比如怎么对接订单系统),欢迎在评论区交流。

⚡当 Next.js 遇上实时通信:Socket.io 与 Pusher 双雄传

作者 LeonGao
2025年10月14日 10:11

🌍 开场白:当页面开始“呼吸”

在 Web2 的世界里,页面是“死”的。
用户刷新一次,服务器哐当地扔一坨数据回来,然后各自回家睡觉。

后来我们嫌这种“单向关系”太冷漠,于是出现了 WebSocket,让客户端和服务器能像恋人那样彼此守候、实时回应

Next.js,是现代前端框架的黄金代表。
Socket.io / Pusher,则是实时通信界的两位剑客。
今天我们就让他们在同一个擂台上比武,看谁能让你的 Web 应用活得更生动。


🔧 一、实时通信的底层原理(轻松一点讲)

当你在聊天应用中看到“对方正在输入...”,其实幕后是这样的:

🔹 浏览器(客户端)
      ↕️   WebSocket(一个常开的小隧道)
🔹 Node.js 服务器(Socket.io / Pusher)

不像传统 HTTP 每次都要三拜九叩(请求-响应),
WebSocket 一旦连接,就像打电话一样直接通话,实时、双向。

🧠 底层机制核心要点:

特点 意义
双向通信 客户端和服务端都能主动“开口”
长连接 无需频繁建立TCP连接
帧传输(frame) 每次通信是一帧,比 HTTP 报文更轻量
握手阶段 第一次是 HTTP 升级请求,之后是 WebSocket

想象一下,两人打电话:HTTP 是“每说一句就挂电话”,WebSocket 则是“24小时免提开着”。


💭 二、Next.js 与实时通信的“身份焦虑”

Next.js 同时身兼:

  • SSR 🧱 (服务器端渲染)
  • API Routes 🧩 (Serverless 接口)
  • Client-side React 🎨 (前端视图)

这就带来一个问题:
Socket.io 是一个需要常驻进程的家伙,而 Next.js 的 Serverless 环境可是打工性质的(执行完后自动销毁)。

所以,Socket.io 在 Next.js 中的宿主问题是核心。
我们要么:

  1. 把 Socket.io 独立部署(传统 Node 服务器)
  2. 或者,用 Pusher 这样的第三方实时服务(不依赖自身常驻)

⚙️ 三、初级实验:用 Socket.io 让页面会说话

📁 项目结构

my-realtime-app/
├─ pages/
│  ├─ index.js
│  └─ api/
│     └─ socket.js
├─ package.json

🧩 安装依赖

npm install socket.io socket.io-client

🧠 核心服务端逻辑(pages/api/socket.js

import { Server } from "socket.io";

let io;

export default function handler(req, res) {
  if (!io) {
    const httpServer = res.socket.server;
    io = new Server(httpServer, {
      path: "/api/socket",
    });

    io.on("connection", (socket) => {
      console.log("⚡ 一个新客户端连接了:", socket.id);

      socket.on("send-message", (msg) => {
        console.log("💬 收到消息:", msg);
        io.emit("new-message", msg);
      });
    });
  }
  res.end();
}

这相当于让 Next.js 的 API 路由成为 WebSocket 服务器 的门户。


💬 客户端逻辑(pages/index.js

import { useEffect, useState } from "react";
import { io } from "socket.io-client";

let socket;

export default function Home() {
  const [message, setMessage] = useState("");
  const [chat, setChat] = useState([]);

  useEffect(() => {
    socketInitializer();
  }, []);

  const socketInitializer = async () => {
    await fetch("/api/socket");
    socket = io({ path: "/api/socket" });

    socket.on("new-message", (msg) => {
      setChat((prev) => [...prev, msg]);
    });
  };

  const sendMessage = () => {
    socket.emit("send-message", message);
    setMessage("");
  };

  return (
    <div style={{ padding: 20 }}>
      <h1>⚡ Next.js 实时聊天室</h1>
      <div>
        {chat.map((m, i) => (<p key={i}>💬 {m}</p>))}
      </div>
      <input
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        placeholder="输入消息..."
      />
      <button onClick={sendMessage}>发送</button>
    </div>
  );
}

打开两个浏览器窗口输入消息,看着它们实时同步,
你会突然觉得自己的人生“充满了连接”。


🪄 四、云端方案:Pusher——云时代的广播大师

为什么选择 Pusher?

因为你不需要自己维护 WebSocket 服务器。
它相当于“Socket.io 的云托管版”,具备稳定的连接管理、数据分发、Auth 控制。

🍀 使用步骤:

  1. 注册 Pusher
  2. 创建一个 "Channels" 应用
  3. .env.local 里配置密钥:
NEXT_PUBLIC_PUSHER_KEY=xxxxxx
PUSHER_SECRET=yyyyyy
PUSHER_APP_ID=zzzzz

💻 代码结构:

npm install pusher pusher-js

pages/api/pusher.js

import Pusher from "pusher";

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY,
  secret: process.env.PUSHER_SECRET,
  cluster: "ap3",
  useTLS: true,
});

export default async function handler(req, res) {
  const { message } = req.body;
  await pusher.trigger("chat", "new-message", { message });
  res.json({ status: "ok" });
}

pages/index.js

import { useEffect, useState } from "react";
import Pusher from "pusher-js";

export default function Home() {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");

  useEffect(() => {
    const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY, {
      cluster: "ap3",
    });
    const channel = pusher.subscribe("chat");
    channel.bind("new-message", (data) => {
      setMessages((prev) => [...prev, data.message]);
    });
    return () => pusher.disconnect();
  }, []);

  const send = async () => {
    await fetch("/api/pusher", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: input }),
    });
    setInput("");
  };

  return (
    <div style={{ margin: 20 }}>
      <h2>📡 使用 Pusher 的实时聊天室</h2>
      {messages.map((msg, i) => (
        <p key={i}>💬 {msg}</p>
      ))}
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="输入消息..."
      />
      <button onClick={send}>发送</button>
    </div>
  );
}

🚀 五、性能与架构思考

特性 Socket.io Pusher
控制权 完全自建 托管服务
成本 免费(自行维护) 付费(按消息量)
延迟 更低(全球节点)
部署复杂度
可扩展性 依赖负载均衡 内置弹性扩展

想象一下:

  • Socket.io 是你亲手养的猫,灵活、听话,但要自己铲屎。
  • Pusher 是别人家的猫,不用喂,只能远观。

🎨 六、结语:当代码懂得倾听

实时通信不是技术炫技,而是一种更温柔的产品体验:

让界面能感知变化,
让用户感受到回应,
让数据在时间中流动。

Next.js 给我们结构,
Socket.io / Pusher 给我们脉动。
希望这篇文章,让你不仅懂了代码,也感受到了连接的浪漫。


🧩 延伸阅读:


💡如果你看到别人网页动态地闪烁着“有人正在输入...”,
那不一定是魔法,也可能只是一个 /api/socket 在悄悄呼吸。

如何用 Vue3 打造高级音乐播放器?进度条+可视化效果,代码简洁可复用!

作者 刘大华
2025年10月14日 10:01

大家好,我是大华! 这篇文章将分享一下如何使用Vue3打造一个优雅的音乐播放器。这个播放器拥有精美的视觉效果,包括旋转专辑封面、动态进度条和音频可视化效果。

效果预览

我们最终实现的音乐播放器将包含以下特性:

  • 专辑封面旋转动画
  • 歌曲信息展示
  • 可交互的进度条
  • 播放控制按钮
  • 音频可视化效果

效果图:

640.gif

实现步骤

1. 播放器布局设计

播放器主要分为以下几个区域:

<div class="music-player">
  <!-- 专辑区域 -->
  <div class="album-container">
    <!-- 专辑封面和底座 -->
  </div>
  
  <!-- 歌曲信息 -->
  <div class="song-info">
    <!-- 歌曲标题和艺术家 -->
  </div>
  
  <!-- 进度条 -->
  <div class="progress-container">
    <!-- 可交互进度条和时间显示 -->
  </div>
  
  <!-- 控制按钮 -->
  <div class="controls">
    <!-- 播放/暂停、上一曲、下一曲按钮 -->
  </div>
  
  <!-- 音频可视化 -->
  <div class="visualizer">
    <!-- 动态音频条 -->
  </div>
</div>

2. 响应式数据与计算属性

使用Vue 3的ref和computed来管理播放器状态:

import { ref, computed, onMounted } from 'vue';

// 响应式数据
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(240); // 4分钟
const currentSongIndex = ref(0);

// 歌曲列表
const songs = ref([
  {
    title: '夜空中最亮的星',
    artist: '逃跑计划',
    cover: 'https://picsum.photos/400/400?random=1',
  },
  // 更多歌曲...
]);

// 计算当前歌曲
const currentSong = computed(() => songs.value[currentSongIndex.value]);

// 计算进度百分比
const progressPercent = computed(() => {
  return (currentTime.value / duration.value) * 100 + '%';
});

3. 核心功能实现

播放控制

// 播放/暂停切换
const togglePlay = () => {
  isPlaying.value = !isPlaying.value;
};

// 上一曲
const prevSong = () => {
  currentSongIndex.value = 
    currentSongIndex.value > 0 ? currentSongIndex.value - 1 : songs.value.length - 1;
  resetPlayback();
};

// 下一曲
const nextSong = () => {
  currentSongIndex.value = 
    currentSongIndex.value < songs.value.length - 1 ? currentSongIndex.value + 1 : 0;
  resetPlayback();
};

// 重置播放状态
const resetPlayback = () => {
  currentTime.value = 0;
  if (!isPlaying.value) isPlaying.value = true;
};

进度条交互

// 设置播放进度
const setProgress = (e) => {
  const progressBar = e.currentTarget;
  const clickX = e.offsetX;
  const width = progressBar.offsetWidth;
  const percent = clickX / width;
  currentTime.value = percent * duration.value;
};

// 时间格式化
const formatTime = (seconds) => {
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
};

4. 音频可视化效果

虽然我们无法直接获取音频数据,但可以模拟可视化效果:

// 可视化数据
const visualizerData = ref([]);

// 初始化可视化
const initVisualizer = () => {
  visualizerData.value = Array.from({ length: 30 }, () => Math.random() * 80 + 10);
};

// 更新可视化效果
const updateVisualizer = () => {
  if (!isPlaying.value) return;

  visualizerData.value = visualizerData.value.map((value, index) => {
    const intensity = Math.sin(Date.now() * 0.01 + index) * 30 + 30;
    const change = (Math.random() - 0.5) * 40;
    let newValue = intensity + change;
    return Math.max(5, Math.min(95, newValue));
  });
};

6. 播放模拟与可视化更新

在组件挂载后,设置定时器模拟播放进度和更新可视化:

onMounted(() => {
  initVisualizer();
  setInterval(() => {
    if (isPlaying.value) {
      if (currentTime.value < duration.value) {
        currentTime.value += 0.1;
      } else {
        nextSong();
      }
      updateVisualizer();
    }
  }, 100);
});

7. 样式设计要点

专辑封面旋转动画

.album-cover.playing {
  animation: rotate 20s linear infinite;
}

@keyframes rotate {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

进度条样式

.progress {
  height: 100%;
  background: linear-gradient(90deg, #e94560, #ff7aa8);
  border-radius: 3px;
  position: relative;
  transition: width 0.1s linear;
}

控制按钮交互效果

.control-btn {
  transition: all 0.3s ease;
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}

.control-btn:active {
  transform: scale(0.95);
}

完整代码

<template>
  <div class="music-player">
    <!-- 专辑区域 -->
    <div class="album-container">
      <div class="album-cover" :class="{ playing: isPlaying }">
        <img :src="currentSong.cover" alt="专辑封面" />
        <div class="album-center"></div>
      </div>
      <div class="album-base"></div>
    </div>

    <!-- 歌曲信息 -->
    <div class="song-info">
      <h2 class="song-title">{{ currentSong.title }}</h2>
      <p class="song-artist">{{ currentSong.artist }}</p>
    </div>

    <!-- 进度条 -->
    <div class="progress-container">
      <div class="progress-bar" @click="setProgress">
        <div class="progress" :style="{ width: progressPercent }">
          <div class="progress-handle"></div>
        </div>
      </div>
      <div class="time-display">
        <span>{{ formatTime(currentTime) }}</span>
        <span>{{ formatTime(duration) }}</span>
      </div>
    </div>

    <!-- 控制按钮 -->
    <div class="controls">
      <button class="control-btn" @click="prevSong">
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M6 6H8V18H6V6ZM9.5 12L18 18V6L9.5 12Z" fill="currentColor" />
        </svg>
      </button>
      <button class="control-btn play-btn" @click="togglePlay">
        <svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M8 5V19L19 12L8 5Z" fill="currentColor" />
        </svg>
        <svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M6 19H10V5H6V19ZM14 5V19H18V5H14Z" fill="currentColor" />
        </svg>
      </button>
      <button class="control-btn" @click="nextSong">
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M6 18L14.5 12L6 6V18ZM16 6V18H18V6H16Z" fill="currentColor" />
        </svg>
      </button>
    </div>

    <!-- 音频可视化 -->
    <div class="visualizer">
      <div
        v-for="(height, index) in visualizerData"
        :key="index"
        class="bar"
        :style="{ height: height + '%' }"
      ></div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

// 响应式数据
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(240); // 4分钟(秒)
const currentSongIndex = ref(0);

// 歌曲列表
const songs = ref([
  {
    title: '夜空中最亮的星',
    artist: '逃跑计划',
    cover: 'https://picsum.photos/400/400?random=1',
  },
  {
    title: '平凡之路',
    artist: '朴树',
    cover: 'https://picsum.photos/400/400?random=2',
  },
  {
    title: '光年之外',
    artist: 'G.E.M.邓紫棋',
    cover: 'https://picsum.photos/400/400?random=3',
  },
]);

// 当前歌曲
const currentSong = computed(() => songs.value[currentSongIndex.value]);

// 进度条百分比
const progressPercent = computed(() => {
  return (currentTime.value / duration.value) * 100 + '%';
});

// 时间格式化
const formatTime = (seconds) => {
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
};

// 播放/暂停
const togglePlay = () => {
  isPlaying.value = !isPlaying.value;
};

// 上一曲
const prevSong = () => {
  currentSongIndex.value =
    currentSongIndex.value > 0 ? currentSongIndex.value - 1 : songs.value.length - 1;
  currentTime.value = 0;
  if (!isPlaying.value) isPlaying.value = true;
};

// 下一曲
const nextSong = () => {
  currentSongIndex.value =
    currentSongIndex.value < songs.value.length - 1 ? currentSongIndex.value + 1 : 0;
  currentTime.value = 0;
  if (!isPlaying.value) isPlaying.value = true;
};

// 设置进度
const setProgress = (e) => {
  const progressBar = e.currentTarget;
  const clickX = e.offsetX;
  const width = progressBar.offsetWidth;
  const percent = clickX / width;
  currentTime.value = percent * duration.value;
};

// 可视化数据
const visualizerData = ref([]);

// 初始化可视化
const initVisualizer = () => {
  visualizerData.value = Array.from({ length: 30 }, () => Math.random() * 80 + 10);
};

// 更新可视化
const updateVisualizer = () => {
  if (!isPlaying.value) return;

  visualizerData.value = visualizerData.value.map((value, index) => {
    const intensity = Math.sin(Date.now() * 0.01 + index) * 30 + 30;
    const change = (Math.random() - 0.5) * 40;
    let newValue = intensity + change;
    return Math.max(5, Math.min(95, newValue));
  });
};

// 模拟播放进度和可视化更新
onMounted(() => {
  initVisualizer();
  setInterval(() => {
    if (isPlaying.value) {
      if (currentTime.value < duration.value) {
        currentTime.value += 0.1;
      } else {
        nextSong();
      }
      updateVisualizer();
    }
  }, 100);
});
</script>

<style scoped>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  -webkit-tap-highlight-color: transparent;
}

.music-player {
  width: 100%;
  max-width: 350px;
  background: rgba(30, 30, 46, 0.8);
  border-radius: 24px;
  padding: 30px 20px;
  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
  backdrop-filter: blur(10px);
  position: relative;
  overflow: hidden;
  font-family: 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
  color: #fff;
  margin: 0 auto;
}

.music-player::before {
  content: '';
  position: absolute;
  top: -50%;
  left: -50%;
  width: 200%;
  height: 200%;
  background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
  z-index: -1;
}

.album-container {
  position: relative;
  width: 280px;
  height: 280px;
  margin: 0 auto 30px;
  perspective: 1000px;
}

.album-base {
  position: absolute;
  bottom: -15px;
  left: 50%;
  transform: translateX(-50%);
  width: 200px;
  height: 30px;
  background: rgba(0, 0, 0, 0.5);
  border-radius: 50%;
  filter: blur(10px);
  z-index: -1;
}

.album-cover {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  overflow: hidden;
  box-shadow: 0 15px 35px rgba(0, 0, 0, 0.5);
  position: relative;
  transition: transform 0.3s ease;
}

.album-cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.album-center {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 50px;
  height: 50px;
  background: #1a1a2e;
  border-radius: 50%;
  border: 5px solid rgba(255, 255, 255, 0.1);
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}

.album-cover.playing {
  animation: rotate 20s linear infinite;
}

@keyframes rotate {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.song-info {
  text-align: center;
  margin-bottom: 30px;
}

.song-title {
  font-size: 24px;
  font-weight: 600;
  margin-bottom: 8px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.song-artist {
  font-size: 16px;
  color: rgba(255, 255, 255, 0.7);
}

.progress-container {
  margin-bottom: 30px;
}

.progress-bar {
  width: 100%;
  height: 6px;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 3px;
  position: relative;
  margin-bottom: 10px;
  cursor: pointer;
}

.progress {
  height: 100%;
  background: linear-gradient(90deg, #e94560, #ff7aa8);
  border-radius: 3px;
  width: 30%;
  position: relative;
  transition: width 0.1s linear;
}

.progress-handle {
  position: absolute;
  right: -8px;
  top: 50%;
  transform: translateY(-50%);
  width: 16px;
  height: 16px;
  background: #fff;
  border-radius: 50%;
  box-shadow: 0 0 10px rgba(233, 69, 96, 0.8);
}

.time-display {
  display: flex;
  justify-content: space-between;
  font-size: 14px;
  color: rgba(255, 255, 255, 0.7);
}

.controls {
  display: flex;
  justify-content: center;
  align-items: center;
  margin-bottom: 30px;
}

.control-btn {
  width: 50px;
  height: 50px;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgba(255, 255, 255, 0.1);
  border: none;
  color: white;
  font-size: 20px;
  margin: 0 15px;
  cursor: pointer;
  transition: all 0.3s ease;
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}

.control-btn:active {
  transform: scale(0.95);
}

.play-btn {
  width: 60px;
  height: 60px;
  background: linear-gradient(135deg, #e94560, #ff7aa8);
  box-shadow: 0 10px 20px rgba(233, 69, 96, 0.4);
}

.visualizer {
  height: 100px;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 4px;
  margin-top: 20px;
}

.bar {
  width: 6px;
  background: linear-gradient(to top, #e94560, #ff7aa8);
  border-radius: 3px 3px 0 0;
  transition: height 0.2s ease;
}
</style>

扩展思路

1.真实音频集成:使用Web Audio API替换模拟音频,实现真实的可视化效果 2.播放列表:添加播放列表功能,支持歌曲选择 3.主题切换:实现明暗主题切换功能 4.响应式优化:针对不同屏幕尺寸进行优化 5.离线功能:添加PWA支持,实现离线播放

你可以在此基础上继续扩展功能,打造属于自己的独特音乐播放体验。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot 中的 7 种耗时统计方式,你用过几种?》

《Spring事件的3种高级玩法,90%的人根本不会用》

《Vue3 如何优雅地实现一个全局的 loading 组件》

《我用 Vue3 + Canvas 做了个超实用的水印工具,同事都在抢着用》

Godot 城市模拟 - 002 根据地面四个点的坐标和高度,动态创建立方体节点

2025年10月14日 09:52

在Godot中根据地面点和高度创建立方体

在3D游戏开发中,经常需要根据已知的地面点位置和高度创建三维物体。本文将详细介绍如何在Godot引擎中,根据四个地面点和一个高度值创建立方体。

效果预览

file

实现原理

1. 立方体顶点计算

立方体有8个顶点,分为两组:

  • 底部4个顶点:直接使用输入的四个地面点
  • 顶部4个顶点:地面点加上高度向量(Y轴方向)

2. 立方体面定义

立方体由6个四边形面组成,每个面可以分解为2个三角形。我们需要定义每个面由哪些顶点组成,并确保顶点顺序正确(逆时针方向以保证法线朝外)。

3. 网格生成

使用Godot的SurfaceTool类逐步构建网格:

  1. 开始网格创建
  2. 添加顶点数据
  3. 生成网格数据
  4. 创建MeshInstance3D节点

完整代码实现

extends Node3D

func _ready() -> void:
# Called when the node enters the scene tree for the first time.
create_cube_from_base_and_height()


# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
pass

# 根据地面四个点和高度创建立方体
func create_cube_from_base_and_height() -> void:
# 示例:地面四个点的坐标(可以替换为实际数据)
var ground_points = [
Vector3(-1, 0, -1),  # 左下角
Vector3(1, 0, -1),   # 右下角
Vector3(1, 0, 1),    # 右上角
Vector3(-1, 0, 1)    # 左上角
]

# 立方体高度
var height = 1.5

# 计算立方体的8个顶点
var cube_points = []

# 底部四个点(地面点)
for i in range(4):
cube_points.append(ground_points[i])

# 顶部四个点(地面点加上高度)
for i in range(4):
var top_point = ground_points[i] + Vector3(0, height, 0)
cube_points.append(top_point)

# 定义6个面的顶点索引(每个面4个顶点)
var faces = [
[4, 5, 6, 7],  # 前(顶部前表面)
[0, 3, 2, 1],  # 后(底部后表面)
[7, 6, 2, 3],  # 上(顶部表面)
[4, 0, 1, 5],  # 下(底部表面)
[4, 7, 3, 0],  # 左(左侧表面)
[5, 1, 2, 6]   # 右(右侧表面)
]

# 使用SurfaceTool创建立方体
var surface_tool = SurfaceTool.new()
surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES)

# 为每个面添加顶点
for face in faces:
# 第一个三角形
surface_tool.add_vertex(cube_points[face[0]])
surface_tool.add_vertex(cube_points[face[1]])
surface_tool.add_vertex(cube_points[face[2]])

# 第二个三角形
surface_tool.add_vertex(cube_points[face[0]])
surface_tool.add_vertex(cube_points[face[2]])
surface_tool.add_vertex(cube_points[face[3]])

# 生成网格
var array_mesh = surface_tool.commit()

# 创建节点和材质
var mesh_instance = MeshInstance3D.new()
mesh_instance.mesh = array_mesh

var material = StandardMaterial3D.new()
material.albedo_color = Color(0.8, 0.2, 0.2)
mesh_instance.material_override = material

add_child(mesh_instance)
mesh_instance.position = Vector3(2,0,1)
print("基于地面点和高度创建立方体完成!")

关键点解析

1. 顶点计算

# 顶部四个点(地面点加上高度)
for i in range(4):
var top_point = ground_points[i] + Vector3(0, height, 0)
cube_points.append(top_point)

2. 面定义与三角形划分

每个四边形面分解为两个三角形:

面顶点索引: [A, B, C, D]

三角形1: ABC
三角形2: ACD

3. SurfaceTool工作流程

  1. begin():开始网格创建,指定图元类型
  2. add_vertex():添加顶点数据
  3. commit():生成最终网格

4. 材质应用

var material = StandardMaterial3D.new()
material.albedo_color = Color(0.8, 0.2, 0.2)
mesh_instance.material_override = material

实际应用场景

这种方法特别适用于:

  1. 根据地形生成建筑物
  2. 创建自定义形状的3D物体
  3. 程序化生成游戏内容
  4. 根据测量数据可视化3D结构

[Js]使用highlight.js高亮vue代码

作者 七月十二
2025年10月14日 09:47

[Js]使用highlight.js高亮vue代码

Vue SFC (.vue) 文件本身是 复合语言,包含 <template><script><style>

所以只使用单语言进行高亮效果很差。

思路是通过工具进行拆分,单部分进行高亮,再通过拆分参数进行整合

  • 高亮使用highlight.js
  • 拆分使用@vue/compiler-sfc

目前粗略的试了下,是可以实现的

示例代码

其中vue3_ts_prefix等几个变量是我提前格式化一遍的,后续等做工具的时候,可以放到逻辑里实现

import hljs from 'highlight.js/lib/core'
import plaintext from 'highlight.js/lib/languages/plaintext'
import html from 'highlight.js/lib/languages/xml'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import css from 'highlight.js/lib/languages/css'
import { parse } from '@vue/compiler-sfc'

// 注册语言
if (!hljs.getLanguage('plaintext')) hljs.registerLanguage('plaintext', plaintext)
if (!hljs.getLanguage('html')) hljs.registerLanguage('html', html)
if (!hljs.getLanguage('js')) hljs.registerLanguage('js', js)
if (!hljs.getLanguage('ts')) hljs.registerLanguage('ts', ts)
if (!hljs.getLanguage('css')) hljs.registerLanguage('css', css)

const TypeMap: Record<string, string> = {
  html: 'html',
  js: 'js',
  css: 'css'
}

const vue3_ts_prefix =
  '<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">setup</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">&quot;ts&quot;</span>&gt;</span>'
const vue3_ts_suffix = '<span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>'

const vue3_html_prefix = '<span class="hljs-tag">&lt;<span class="hljs-name">template</span>&gt;</span>'
const vue3_html_suffix = '<span class="hljs-tag">&lt;/<span class="hljs-name">template</span>&gt;</span>'

export const renderCode = (code: string, type: string) => {
  if (type === 'vue3') {
    const { descriptor } = parse(code)
    let text = ''
    if (descriptor.scriptSetup) {
      const t1 = hljs.highlight(descriptor.scriptSetup.content, { language: 'ts' }).value
      text += vue3_ts_prefix + t1 + vue3_ts_suffix
    }
    if (descriptor.template) {
      const t2 = hljs.highlight(descriptor.template.content, { language: 'html' }).value
      if (text) text += '\n\n'
      text += vue3_html_prefix + t2 + vue3_html_suffix
    }
    return text
  }
  const language = TypeMap[type] || 'plaintext'
  return hljs.highlight(code, { language }).value
}
使用效果

在这里插入图片描述

鸿蒙应用开发从入门到实战(二十一):ArkUI自定义弹窗组件

2025年10月14日 09:39

大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容、欢迎关注!

上一篇文章讲述了ArkUI提供的各种内置弹窗组件,当项目中遇到这些组件仍然不满足需求时,可以使用自定义弹窗组件。本文研究自定义弹窗组件的使用。

一、概述

当现有组件不满足要求时,可考虑自定义弹窗,自定义弹窗允许开发者自定义弹窗内容和样式。例如

1自定义弹窗.gif

示例代码

pages/component/dialog/新建CustomDialogPage.ets文件

@Entry
@Component
struct CustomDialogPage {
  @State answer: string = '?'
  controller: CustomDialogController = new CustomDialogController({
    builder: TextInputDialog({
      confirm: (value) => {
        this.answer = value;
      }
    }),
    alignment: DialogAlignment.Bottom,
    offset: { dx: 0, dy: -30 }
  })

  build() {
    Column({ space: 50 }) {
      Row() {
        Text('1+1=')
          .fontWeight(FontWeight.Bold)
          .fontSize(30)
        Text(this.answer)
          .fontWeight(FontWeight.Bold)
          .fontSize(30)
      }

      Button('作答')
        .onClick(() => {
          this.controller.open();
        })
    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}


@CustomDialog
struct TextInputDialog {
  controller: CustomDialogController = new CustomDialogController({ builder: TextInputDialog() })
  confirm: (value: string) => void;
  value: string = '';

  build() {
    Column({ space: 20 }) {
      Text('请输入你的答案')
      TextInput({ placeholder: '请输入数字' })
        .type(InputType.Number)
        .onChange((value) => {
          this.value = value;
        })
      Row({ space: 50 }) {
        Button('取消')
          .onClick(() => {
            this.controller.close();
          })
        Button('确认').onClick(() => {
          this.confirm(this.value);
          this.controller.close();
        })
      }
    }.padding(20)
  }
}

二、使用说明

显示自定义弹窗需要使用CustomDialogController

developer.huawei.com/consumer/cn…

《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!

Flutter 系列教程:列表与网格 - `ListView` 和 `GridView`

作者 Zuckjet_
2025年10月14日 09:25

当你的内容超出一个屏幕的高度时,你就需要一个可滚动的容器。ListViewGridView 是 Flutter 中最重要、最常用的可滚动组件,它们专门用于高效地展示大量数据。理解它们的不同构造方式和适用场景对于构建高性能的应用至关-重要。

学习目标

  • 理解 ListView 最常用的三种构造函数:默认构造函数、.builder().separated(),并了解它们各自的性能特点和适用场景。
  • 掌握 GridView 的核心概念 SliverGridDelegate,并学会使用 GridView.count()GridView.builder() 来创建网格布局。
  • 学会根据具体需求选择最合适的列表或网格实现方式。

1. ListView:线性可滚动列表

ListView 是一个将子组件沿垂直(默认)或水平方向线性排列的可滚动组件。

方式一:默认构造函数 ListView()

这是最简单的方式,它接收一个 List<Widget> 作为 children

  • 特点:一次性创建并渲染列表中的所有子组件。
  • 适用场景:当列表项数量很少且固定时。例如,一个设置页面,里面只有十几个固定的选项。
  • 性能陷阱绝对不要用这种方式来展示一个很长或无限的列表!因为它会一次性把所有 Widget 都加载到内存中,即使它们在屏幕外,这会导致严重的性能问题和内存占用。
ListView(
  padding: const EdgeInsets.all(8),
  children: <Widget>[
    Container(height: 50, color: Colors.amber[600], child: const Center(child: Text('Entry A'))),
    Container(height: 50, color: Colors.amber[500], child: const Center(child: Text('Entry B'))),
    Container(height: 50, color: Colors.amber[100], child: const Center(child: Text('Entry C'))),
  ],
)

方式二:ListView.builder()【最常用、性能最好】

这是构建长列表的标准和推荐方式。它采用“懒加载”机制,只创建和渲染那些当前在屏幕上可见的列表项。

  • 特点:按需构建,性能极高。
  • 核心属性
    • itemCount: 列表项的总数。
    • itemBuilder: 一个函数,用于构建每个列表项的 Widget。它接收 contextindex (当前项的索引)作为参数。
  • 适用场景:几乎所有长列表或数据量不确定的列表,如新闻 Feed、聊天记录、商品列表等。

代码示例:构建一个动态新闻列表

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // 假设这是从 API 获取的数据
    final List<String> entries = List<String>.generate(50, (i) => 'News Article ${i + 1}');

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('ListView.builder Demo')),
        body: ListView.builder(
          itemCount: entries.length, // 1. 告诉 ListView 总共有多少项
          itemBuilder: (BuildContext context, int index) { // 2. 按需构建每一项
            return ListTile(
              leading: CircleAvatar(child: Text('${index + 1}')),
              title: Text(entries[index]),
              subtitle: Text('This is the subtitle for item $index'),
              onTap: () {
                print('Tapped on ${entries[index]}');
              },
            );
          },
        ),
      ),
    );
  }
}

方式三:ListView.separated()

这个构造函数与 .builder() 非常相似,但增加了一个额外的 separatorBuilder 函数,用于在每个列表项之间构建一个分隔符 Widget。

  • 特点:在 .builder() 的基础上,可以方便地添加自定义的分隔线。
  • 适用场景:需要明确分隔符的列表,如联系人列表、设置菜单等。
ListView.separated(
  itemCount: 25,
  separatorBuilder: (BuildContext context, int index) => const Divider(color: Colors.grey), // 分隔符构建器
  itemBuilder: (BuildContext context, int index) {
    return ListTile(
      title: Text('Item $index'),
    );
  },
)

2. GridView:二维可滚动网格

GridView 用于在二维空间中排列子组件,常用于相册、商品分类等场景。

核心概念:gridDelegate

GridView 的布局方式由 gridDelegate 属性控制,它接收一个 SliverGridDelegate 对象。这个 "delegate" (委托) 告诉 GridView 如何排列其子项。最常用的有两个:

  1. SliverGridDelegateWithFixedCrossAxisCount:

    • 创建一个在交叉轴上具有固定数量的网格。
    • 例如,在垂直滚动的 GridView 中,交叉轴是水平的,crossAxisCount: 3 就意味着每行固定有 3 个子项。
  2. SliverGridDelegateWithMaxCrossAxisExtent:

    • 创建一个子项在交叉轴上具有最大宽度/高度的网格。
    • 例如,maxCrossAxisExtent: 150 意味着每个子项的宽度最多为 150 像素。GridView 会根据屏幕总宽度自动计算一行能放几个子项。这对于构建响应式布局非常有用。

方式一:GridView.count()

这是 SliverGridDelegateWithFixedCrossAxisCount 的一个便捷构造函数。

  • 特点:简单直观,快速创建一个固定列数的网格。
  • 适用场景:当你明确知道每行需要显示几个项目时。
GridView.count(
  crossAxisCount: 3, // 每行3个
  mainAxisSpacing: 10,  // 主轴间距
  crossAxisSpacing: 10, // 交叉轴间距
  children: List.generate(20, (index) {
    return Container(
      color: Colors.teal[100 * (index % 9)],
      child: Center(child: Text('Item $index')),
    );
  }),
)

方式二:GridView.builder()【性能最好】

ListView.builder() 类似,GridView.builder() 也采用懒加载机制,是构建大型网格的首选。

  • 特点:按需构建,高性能,与 gridDelegate 结合使用,布局灵活。
  • 适用场景:需要展示大量数据的相册、商品目录等。

代码示例:构建一个商品展示网格

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('GridView.builder Demo')),
        body: GridView.builder(
          padding: const EdgeInsets.all(10.0),
          // 1. 提供 Grid Delegate
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2,    // 每行2个
            crossAxisSpacing: 10, // 水平间距
            mainAxisSpacing: 10,  // 垂直间距
            childAspectRatio: 3 / 2, // 宽高比
          ),
          itemCount: 30, // 2. 网格项总数
          itemBuilder: (BuildContext context, int index) { // 3. 按需构建
            return Container(
              padding: const EdgeInsets.all(8),
              color: Colors.blueGrey,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.shopping_bag, size: 40, color: Colors.white),
                  Text(
                    'Product $index',
                    style: const TextStyle(color: Colors.white, fontSize: 18),
                  ),
                ],
              ),
            );
          },
        ),
      ),
    );
  }
}

总结:如何选择?

  1. 确定数据量

    • 数据量小且固定? -> 可以使用 ListView()GridView.count() 的默认构造函数。
    • 数据量大或不确定? -> 必须使用 ListView.builderGridView.builder 以保证性能。
  2. 确定布局样式

    • 单列垂直/水平滚动列表? -> ListView
    • 需要分隔符? -> ListView.separated
    • 多行多列的网格布局? -> GridView
  3. 确定网格列数

    • 固定列数? -> GridView.countGridView.builder + SliverGridDelegateWithFixedCrossAxisCount
    • 希望根据屏幕宽度自适应列数? -> GridView.builder + SliverGridDelegateWithMaxCrossAxisExtent

掌握了 ListViewGridView 的高效用法,你就具备了构建流畅、可响应的数据密集型应用的核心能力。接下来,我们将进入 Flutter 开发中另一个至关重要的主题:页面导航与路由管理。我们下篇见!

VSCode源码解密:Event<T> - 类型安全的事件系统

作者 简瑞_Jerry
2025年10月14日 09:07

VSCode源码解密:Event<T> - 类型安全的事件系统

VSCode通过Event<T>函数式设计,解决了传统EventEmitter的类型安全、内存泄漏和组合能力问题,为大型TypeScript项目提供优雅的事件系统解决方案。


一、引言

在大型TypeScript项目中,你可能经常遇到这样的场景:

// 传统EventEmitter:运行时才发现错误
const watcher = new FileWatcher();
watcher.on('fileChanged', (data: any) => {
    console.log(data.filename); // 运行正常
    console.log(data.typo);     // 拼写错误,运行时才崩溃!
});

setupWatching(); // 创建了监听器
// 忘记调用 watcher.off() 内存泄漏!

这些问题在小项目中也许不明显,但在VSCode这样拥有数百万行代码的大型项目中,类型不安全和内存泄漏会导致严重的质量问题。

VSCode团队重新设计了一套事件系统:Event<T>。它不仅解决了传统EventEmitter的痛点,还带来了函数式编程的优雅组合能力。更重要的是,这套系统的核心实现只有几百行代码,却在整个VSCode项目中被广泛使用。

本文将深入解析Event<T>的设计原理,看看它是如何做到类型安全、零内存泄漏和强大组合能力的。

二、传统EventEmitter的三大痛点

在理解VSCode的解决方案之前,让我们先明确传统EventEmitter存在哪些问题。

痛点1:类型安全缺失

// Node.js EventEmitter的类型问题
import { EventEmitter } from 'events';

class FileWatcher extends EventEmitter {
    watchFile(path: string) {
        fs.watch(path, (event, filename) => {
            // 触发事件
            this.emit('fileChanged', { event, filename, path });
        });
    }
}

const watcher = new FileWatcher();

// 问题:无法在编译时检查事件数据结构
watcher.on('fileChanged', (data: any) => {
    console.log(data.filename);  // any类型,没有智能提示
    console.log(data.typo);      // 拼写错误,编译器无法发现
});

// 问题:事件名拼写错误
watcher.on('fileChangd', handler); // 事件名拼错,监听器永远不会触发

痛点2:内存泄漏风险

// 需要手动管理监听器引用
class DocumentEditor {
    private listeners: Array<() => void> = [];
    
    constructor(fileWatcher: FileWatcher) {
        const handler1 = (e) => this.onFileChange(e);
        const handler2 = (e) => this.onFileSave(e);
        
        fileWatcher.on('change', handler1);
        fileWatcher.on('save', handler2);
        
        // 需要手动保存引用,容易遗漏
        this.listeners.push(
            () => fileWatcher.off('change', handler1),
            () => fileWatcher.off('save', handler2)
        );
    }
    
    dispose() {
        // 如果忘记调用dispose,或者清理不完整
        // 就会导致内存泄漏
        this.listeners.forEach(cleanup => cleanup());
    }
}

痛点3:缺乏组合能力

// 想要防抖?手写定时器逻辑
let debounceTimer: NodeJS.Timeout;
fileWatcher.on('change', (data) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
        // 想要过滤?手写if判断
        if (data.path.endsWith('.ts')) {
            // 想要转换?手写映射逻辑
            const fileName = data.path.split('/').pop();
            handleTsFileChange(fileName);
        }
    }, 300);
});

// 代码冗长、容易出错、难以复用

这些问题在VSCode这样的大型项目中会被放大,因此需要一套更好的解决方案。

三、VSCode的解决方案:Event系统

VSCode通过三个核心组件构建了一套全新的事件系统:

01_Event架构图.png

3.1 核心设计:Event是一个函数

/**
 * 源码位置: src/vs/base/common/event.ts
 * 核心设计: Event本身是一个函数接口
 */
export interface Event<T> {
    (listener: (e: T) => unknown, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;
}

理解这个设计的关键:

  1. Event是函数类型:调用它就是订阅事件
  2. 泛型T保证类型安全:事件数据类型在编译时检查
  3. 返回IDisposable:用于取消订阅,防止内存泄漏
  4. 支持DisposableStore:自动管理多个订阅

关于Disposable模式的详细实现原理,可以参考我的另一篇文章:VSCode源码解密:一行代码解决内存泄漏难题

3.2 完整示例:类型安全且优雅

/**
 * 定义事件数据结构
 */
interface IFileChangeEvent {
    readonly path: string;
    readonly type: 'created' | 'modified' | 'deleted';
    readonly timestamp: number;
}

/**
 * 文件监听器实现
 */
class FileWatcher extends Disposable {
    // 私有Emitter,内部实现
    private readonly _onDidChange = new Emitter<IFileChangeEvent>();
    
    // 公开Event,外部只能订阅
    public readonly onDidChange: Event<IFileChangeEvent> = this._onDidChange.event;
    
    watchFile(path: string): void {
        const watcher = fs.watch(path, (eventType, filename) => {
        // 触发事件
            this._onDidChange.fire({
                path,
                type: eventType as 'modified',
                timestamp: Date.now()
            });
        });
        
        // 自动管理资源
        this._register(toDisposable(() => watcher.close()));
    }
}

/**
 * 使用方式:完整的类型安全
 */
const fileWatcher = new FileWatcher();

// 方式1:手动管理
const disposable = fileWatcher.onDidChange(event => {
    // event有完整的类型提示
    console.log(event.path);      // 正确
    console.log(event.type);      // 正确
    console.log(event.timestamp); // 正确
    // console.log(event.typo);   // 编译错误!
});

// 取消订阅
disposable.dispose();

// 方式2:自动管理
class MyComponent extends Disposable {
    constructor(watcher: FileWatcher) {
        super();
        
        // 使用_register自动管理
        this._register(watcher.onDidChange(event => {
            console.log('File changed:', event.path);
        }));
    }
    
    // 当组件销毁时,自动取消所有订阅
}

设计亮点

  • 读写分离:外部拿到的是Event,只能监听,无法触发fire()
  • 类型安全:事件数据结构在编译时检查
  • 自动清理:结合Disposable模式,零内存泄漏

设计思考

VSCode选择将Event设计为函数而不是类,体现了深刻的架构思考:

  1. 函数式设计:Event是函数类型,支持函数式组合,可以轻松进行mapfilterdebounce等转换
  2. 无状态安全:Event函数本身不持有状态,避免了状态管理的复杂性
  3. 类型推导:泛型T在编译时就能确定事件数据结构,提供完整的类型提示
  4. 权限控制:通过读写分离,确保只有类内部可以触发事件,外部只能订阅

四、核心实现:Emitter<T>类

让我们看看VSCode是如何实现Emitter的。

4.1 最小化实现

/**
 * 源码位置: src/vs/base/common/event.ts
 * 简化版实现,仅展示核心逻辑
 */
class Emitter<T> {
    private _listeners?: Array<(e: T) => void>;
    private _event?: Event<T>;
    
    /**
     * 暴露给外部的订阅接口
     */
    get event(): Event<T> {
        if (!this._event) {
            this._event = (callback: (e: T) => void, thisArgs?: any) => {
                // 绑定this上下文
                if (thisArgs) {
                    callback = callback.bind(thisArgs);
                }
                
                // 添加监听器
                if (!this._listeners) {
                    this._listeners = [];
                }
                this._listeners.push(callback);
                
                // 返回清理函数
                return {
                    dispose: () => {
                        if (!this._listeners) return;
                        
                        const index = this._listeners.indexOf(callback);
                        if (index >= 0) {
                            this._listeners.splice(index, 1);
                        }
                    }
                };
            };
        }
        return this._event;
    }
    
    /**
     * 触发事件
     */
    fire(event: T): void {
        if (!this._listeners) {
            return;
        }
        
        // 复制数组,防止在迭代中修改
        const listeners = this._listeners.slice();
        
        // 依次调用所有监听器
        for (const listener of listeners) {
            try {
                listener(event);
            } catch (error) {
                // 捕获错误,防止影响其他监听器
                console.error('Event listener error:', error);
            }
        }
    }
    
    dispose(): void {
        this._listeners = undefined;
        this._event = undefined;
    }
}

仅50行代码,就实现了类型安全的事件系统!

4.2 生产级优化

VSCode的实际实现包含了许多优化:

4.2.1 单监听器优化

/**
 * 源码位置: src/vs/base/common/event.ts
 * 大多数Event只有一个监听器,避免创建数组
 */
type ListenerContainer<T> = UniqueContainer<(data: T) => void>;
type ListenerOrListeners<T> = (ListenerContainer<T> | undefined)[] | ListenerContainer<T>;

private _listeners?: ListenerOrListeners<T>;

// 添加第一个监听器时,直接存储
if (!this._listeners) {
    this._listeners = contained; // 直接存UniqueContainer
} 
// 添加第二个监听器时,转换为数组
else if (this._listeners instanceof UniqueContainer) {
    this._listeners = [this._listeners, contained];
} 
// 后续直接push
else {
    this._listeners.push(contained);
}

为什么这样优化?

  • 减少内存分配:单监听器场景不创建数组
  • 提升性能:直接调用函数,无需遍历数组

4.2.2 内存泄漏检测

/**
 * 源码位置: src/vs/base/common/event.ts
 */
export interface EmitterOptions {
    /** 第一个监听器添加之前调用 */
    onWillAddFirstListener?: Function;
    /** 第一个监听器添加之后调用 */
    onDidAddFirstListener?: Function;
    /** 最后一个监听器移除之后调用 */
    onDidRemoveLastListener?: Function;
    /** 监听器抛出错误时调用 */
    onListenerError?: (e: any) => void;
    /** 内存泄漏警告阈值 */
    leakWarningThreshold?: number;
}

实际使用

// 源码位置: src/vs/base/common/event.ts
get event(): Event<T> {
    return (callback: (e: T) => void) => {
        // 检查监听器数量是否超过阈值
        if (this._leakageMon && this._size > this._leakageMon.threshold ** 2) {
            const message = `[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;
            console.warn(message);
            
            // 打印最频繁的监听器堆栈
            const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1];
            const error = new ListenerRefusalError(`${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]);
            const errorHandler = this._options?.onListenerError || onUnexpectedError;
            errorHandler(error);
            
            return Disposable.None;
        }
        
        // ... 正常添加监听器
    };
}

设计思考:这种主动防御的设计体现了VSCode团队对内存泄漏的重视。不是等到问题发生后再去修复,而是在问题发生前就主动拒绝,并提供详细的调试信息。这种"预防胜于治疗"的思路在大型项目中非常重要。

4.2.3 生命周期钩子

/**
 * 使用生命周期钩子实现延迟订阅
 */
function createLazyEvent<T>(sourceEvent: Event<T>): Event<T> {
    let subscription: IDisposable | undefined;
    
    const emitter = new Emitter<T>({
        // 第一个监听器添加时,订阅源事件
        onDidAddFirstListener() {
            subscription = sourceEvent(e => emitter.fire(e));
        },
        // 最后一个监听器移除时,取消订阅
        onDidRemoveLastListener() {
            subscription?.dispose();
            subscription = undefined;
        }
    });
    
    return emitter.event;
}

// 使用示例
const lazyEvent = createLazyEvent(expensiveEvent);
// 此时还没有订阅expensiveEvent

const listener = lazyEvent(e => console.log(e));
// 现在才订阅expensiveEvent

listener.dispose();
// expensiveEvent的订阅被自动清理

五、函数式编程的威力:Event命名空间

VSCode提供了丰富的Event工具函数,让事件处理变得优雅而强大。

5.1 常用工具一览

02_Event工具链.png

5.2 工具函数实战

场景:监听TypeScript文件变化,防抖处理,提取文件名

传统方式:

// 传统方式:大量手写代码
let debounceTimer: NodeJS.Timeout;
fileWatcher.on('change', (event) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
        if (event.path.endsWith('.ts')) {
            const fileName = event.path.split('/').pop();
            handleTsFileChange(fileName);
        }
    }, 300);
});

VSCode方式:

// VSCode方式:链式调用,优雅简洁
const onTsFileChange = Event.map(
    Event.debounce(
        Event.filter(
            fileWatcher.onDidChange,
            e => e.path.endsWith('.ts')  // 过滤.ts文件
        ),
        (last, current) => current,      // 防抖合并逻辑
        300                              // 300ms防抖
    ),
    e => e.path.split('/').pop()         // 提取文件名
);

// 使用时类型完全正确
onTsFileChange(fileName => {
    // fileName的类型是 string | undefined
    console.log('TS file changed:', fileName);
});

5.3 核心工具函数详解

5.3.1 Event.filter - 条件过滤

/**
 * 源码位置: src/vs/base/common/event.ts
 */
export function filter<T>(
    event: Event<T>, 
    filter: (e: T) => boolean
): Event<T> {
    return (listener, thisArgs?, disposables?) => {
        return event(e => {
            if (filter(e)) {
                listener.call(thisArgs, e);
            }
        }, undefined, disposables);
    };
}

// 使用示例
const onTsFileChange = Event.filter(
    fileWatcher.onDidChange,
    e => e.path.endsWith('.ts')
);

5.3.2 Event.map - 数据转换

/**
 * 源码位置: src/vs/base/common/event.ts
 */
export function map<I, O>(
    event: Event<I>, 
    map: (i: I) => O
): Event<O> {
    return (listener, thisArgs?, disposables?) => {
        return event(i => {
            listener.call(thisArgs, map(i));
        }, undefined, disposables);
    };
}

// 使用示例
const onFileName = Event.map(
    fileWatcher.onDidChange,
    e => e.path.split('/').pop()
);

5.3.3 Event.debounce - 防抖处理

/**
 * 源码位置: src/vs/base/common/event.ts
 */
export function debounce<I, O>(
    event: Event<I>,
    merge: (last: O | undefined, event: I) => O,
    delay: number = 100
): Event<O> {
    let subscription: IDisposable;
    let output: O | undefined;
    let handle: NodeJS.Timeout | undefined;
    
    const emitter = new Emitter<O>({
        onWillAddFirstListener() {
            subscription = event(cur => {
                output = merge(output, cur);
                
                if (handle) {
                    clearTimeout(handle);
                }
                
                handle = setTimeout(() => {
                    emitter.fire(output!);
                    output = undefined;
                }, delay);
            });
        },
        onDidRemoveLastListener() {
            subscription.dispose();
        }
    });
    
    return emitter.event;
}

// 使用示例
const debouncedChange = Event.debounce(
    fileWatcher.onDidChange,
    (last, current) => current,
    300
);

5.3.4 Event.once - 只触发一次

/**
 * 源码位置: src/vs/base/common/event.ts
 */
export function once<T>(event: Event<T>): Event<T> {
    return (listener, thisArgs?, disposables?) => {
        const result = event(e => {
            result.dispose();
            return listener.call(thisArgs, e);
        }, undefined, disposables);
        
        return result;
    };
}

// 使用示例
Event.once(fileWatcher.onDidChange)(event => {
    console.log('First change:', event);
});

5.3.5 Event.any - 合并多个事件

/**
 * 源码位置: src/vs/base/common/event.ts
 */
export function any<T>(...events: Event<T>[]): Event<T> {
    return (listener, thisArgs?, disposables?) => {
        const disposable = combinedDisposable(
            ...events.map(event => event(e => listener.call(thisArgs, e)))
        );
        
        return addDisposableListener(disposables, disposable);
    };
}

// 使用示例
const onAnyFileChange = Event.any(
    watcher1.onDidChange,
    watcher2.onDidChange,
    watcher3.onDidChange
);

5.4 工具函数速查

工具 功能 使用场景
Event.once 只触发一次 初始化事件、一次性操作
Event.filter 条件过滤 只关心特定类型的事件
Event.map 数据转换 提取需要的字段
Event.debounce 防抖处理 用户输入、文件变化
Event.any 合并事件 监听多个相同类型的事件源
Event.fromPromise Promise转Event 异步操作转事件流
Event.buffer 缓冲事件 批量处理事件

六、VSCode中的实际应用

让我们看看Event系统在VSCode源码中的实际使用。

6.1 示例1:文本模型变化通知

/**
 * 源码位置: src/vs/editor/common/model/textModelEvents.ts
 */
interface IModelContentChangedEvent {
    readonly changes: IModelContentChange[];
    readonly eol: string;
    readonly versionId: number;
    readonly isUndoing: boolean;
    readonly isRedoing: boolean;
}

/**
 * 源码位置: src/vs/editor/common/model/textModel.ts
 */
class TextModel extends Disposable {
    private readonly _onDidChangeContent = new Emitter<IModelContentChangedEvent>();
    public readonly onDidChangeContent = this._onDidChangeContent.event;
    
    private _emitContentChangedEvent(
        rawContentChangedEvent: InternalModelContentChangeEvent
    ): void {
        this._onDidChangeContent.fire({
            changes: rawContentChangedEvent.changes,
            eol: this._buffer.getEOL(),
            versionId: this.getVersionId(),
            isUndoing: this._isUndoing,
            isRedoing: this._isRedoing
        });
    }
}

// 使用示例
const model = new TextModel();
model.onDidChangeContent(e => {
    console.log(`Version ${e.versionId}: ${e.changes.length} changes`);
});

6.2 示例2:生命周期钩子的延迟订阅

/**
 * 源码位置: src/vs/workbench/services/search/node/rawSearchService.ts
 * 真实场景:只在有监听器时才启动搜索
 */
class RawSearchService {
    fileSearch(config: IRawFileQuery): Event<ISerializedSearchProgressItem | ISerializedSearchComplete> {
        let promise: CancelablePromise<ISerializedSearchSuccess>;

        const query = reviveQuery(config);
        const emitter = new Emitter<ISerializedSearchProgressItem | ISerializedSearchComplete>({
            // 第一个监听器添加时,启动搜索
            onDidAddFirstListener: () => {
                promise = createCancelablePromise(async token => {
                    const numThreads = await this.getNumThreads?.();
                    return this.doFileSearchWithEngine(FileSearchEngine, query, p => emitter.fire(p), token, SearchService.BATCH_SIZE, numThreads);
                });

                promise.then(
                    c => emitter.fire(c),
                    err => emitter.fire({ type: 'error', error: { message: err.message, stack: err.stack } }));
            },
            // 最后一个监听器移除时,取消搜索
            onDidRemoveLastListener: () => {
                promise.cancel();
            }
        });

        return emitter.event;
    }
}

设计亮点

  • 延迟启动:只有在有人监听时才启动昂贵的搜索操作
  • 自动清理:当没有监听器时自动取消搜索,释放系统资源
  • 资源优化:避免无意义的搜索操作,提升性能

七、总结

VSCode的Event<T>系统通过函数式设计实现了类型安全、零内存泄漏和优雅组合:

  • 类型安全:泛型T实现编译时类型检查
  • 读写分离:外部只能订阅,内部才能触发
  • 自动清理:结合Disposable模式,零内存泄漏
  • 函数式组合:map/filter/debounce等工具让事件处理更优雅

Event<T>系统证明了:好的设计不是添加更多特性,而是用最简单的方式解决最复杂的问题。

八、参考资源

8.1 源码

8.2 官方文档

九、写在最后

Event<T>系统是VSCode架构中的重要组成部分,为整个应用提供了类型安全的事件通信机制。

读完这篇文章:

  • 你在项目中遇到过EventEmitter的类型安全问题吗? 是如何解决的?
  • 你觉得Event<T>的设计有哪些可以改进的地方? 或者有更好的替代方案?
  • 你会在自己的项目中应用这个模式吗? 可能会遇到什么挑战?

如果你对VSCode源码或架构设计感兴趣,欢迎关注「VSCode 源码寻宝」专栏的文章。接下来,我会继续探索 VSCode 中的其他精巧设计。如果你对某个话题特别感兴趣(如事件系统、命令模式、虚拟滚动等),欢迎在评论区告诉我。


💡 如果这篇文章对你有帮助

  • 👍 点赞:让更多人看到这篇优质内容
  • ⭐ 收藏:方便随时查阅和复习
  • 👀 关注我的掘金主页,第一时间获取最新文章
  • 📝 评论:分享你的想法和疑问,我们一起讨论

期待与你交流!

React hooks (useRef)

2025年10月14日 09:07

useRef

当你在React中需要处理DOM元素或需要在组件渲染之间保持持久性数据时,便可以使用useRef。

import { useRef } from 'react';
const refValue = useRef(initialValue)
refValue.current // 访问ref的值 类似于vue的ref,Vue的ref是.value,其次就是vue的ref是响应式的,而react的ref不是响应式的

通过Ref操作DOM元素

参数
  • initialValue:ref 对象的 current 属性的初始值。可以是任意类型的值。这个参数在首次渲染后被忽略。
返回值
  • useRef返回一个对象,对象的current属性指向传入的初始值。 {current:xxxx}
注意
  • 改变 ref.current 属性时,React 不会重新渲染组件。React 不知道它何时会发生改变,因为 ref 是一个普通的 JavaScript 对象。
  • 除了 初始化 外不要在渲染期间写入或者读取 ref.current,否则会使组件行为变得不可预测。
import { useRef } from "react"
function App() {
  //首先,声明一个 初始值 为 null 的 ref 对象
  let div = useRef(null)
  const heandleClick = () => {
    //当 React 创建 DOM 节点并将其渲染到屏幕时,React 将会把 DOM 节点设置为 ref 对象的 current 属性
    console.log(div.current)
  }
  return (
    <>
      {/*然后将 ref 对象作为 ref 属性传递给想要操作的 DOM 节点的 JSX*/}
      <div ref={div}>dom元素</div>
      <button onClick={heandleClick}>获取dom元素</button>
    </>
  )
}
export default App

数据存储

我们实现一个保存count的新值和旧值的例子,但是在过程中我们发现一个问题,就是num的值一直为0,这是为什么呢?

因为等useStateSetCount执行之后,组件会重新rerender,num的值又被初始化为了0,所以num的值一直为0。

import React, { useLayoutEffect, useRef, useState } from 'react';
// let num = 0 // 可以放到这里,这是第一种解决方式
function App() {
   let num = 0
   let [count, setCount] = useState(0)
   const handleClick = () => {
      setCount(count + 1)
      num = count;
   };
   return (
      <div>
         <button onClick={handleClick}>增加</button>
         <div>{count}:{num}</div>
      </div>
   );
}

export default App;

image.png

如何修改?

我们可以使用useRef来解决这个问题,因为useRef只会在初始化的时候执行一次,当组件reRender的时候,useRef的值不会被重新初始化。

import React, { useLayoutEffect, useRef, useState } from 'react';

function App() {
   let num = useRef(0) // 这是第二种解决方式
   let [count, setCount] = useState(0)
   const handleClick = () => {
      setCount(count + 1)
      num.current = count;
   };
   return (
      <div>
         <button onClick={handleClick}>增加</button>
         <div>{count}:{num.current}</div>
      </div>
   );
}

export default App;

image.png

拯救还在使用vue2+element-ui的小伙伴——tooltip内存泄露的问题

作者 wzyoung
2025年10月14日 08:59

懒的写文案,我就直接上代码了, 解决了内存泄露的问题,文档在最下方!还在使用vue2+element-ui的直接贴过去用哈,记得点个赞赞~~~

MyTooltip 组件为二次封装的组件

  • 使用示例
// 1. 超出一行自动省略,且提示
<my-tooltip :rows="1"> 这是一段文本内容,这是一段文本内容 </my-tooltip>

// 2. 超出两行自动省略,且提示
<my-tooltip :rows="2"> 这是一段文本内容,这是一段文本内容 </my-tooltip>

// 3. 这里是自定义内容
<my-tooltip content="这里是自定义内容"> 这是一段文本内容,这是一段文本内容 </my-tooltip>

// 4. 这里是插槽自定义内容
<my-tooltip content="这里是自定义内容"> 
    <template #content>
        <div>
          #content插槽
          <br>
          换行
        </div>
    </template>
    这是一段文本内容,这是一段文本内容
</my-tooltip>

// 5. 点击按钮,控制tootlip显隐(需要定义`tooltipVisible` 变量
<el-button @click="tooltipVisible = !tooltipVisible">点击这里</jl-button>
<my-tooltip v-model="tooltipVisible">这是一段文本内容,这是一段文本内容</my-tooltip>
  • 组件代码
<template>
  <span
    class="my-tooltip"
    ref="textRef"
    :style="{
      '-webkit-line-clamp': rows,
      display: block ? '-webkit-box' : '-webkit-inline-box',
    }"
    :class="{
      'is-visible': !rows,
    }"
    @click="handleClick"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave">
    <slot></slot>
    <el-tooltip
      v-if="showPopper"
      ref="tooltipRef"
      effect="dark"
      :class="{ 'is-one-row': rows === 1 }"
      :popper-class="internalPopperClass"
      :content="hasContentSlot ? undefined : content || internalContent"
      :disabled="isDisabled"
      :enterable="enterable"
      :placement="placement"
      :visible-arrow="false">
      <template v-if="hasContentSlot" #content>
        <slot name="content"></slot>
      </template>
    </el-tooltip>
  </span>
</template>

<script>
export default {
  name: 'MyTooltip',
  inheritAttrs: false,
  props: {
    value: { type: Boolean, default: false },
    rows: { type: Number, default: 0 },
    block: { type: Boolean, default: false },
    disabled: { type: Boolean, default: false },
    enterable: { type: Boolean, default: true },
    content: { type: String, default: '' },
    placement: { type: String, default: 'top' },
    popperClass: { type: String, default: '' },
    hideOnClick: { type: Boolean, default: false },
  },
  watch: {
    async isHovering(newVal) {
      if (newVal) {
        this.showPopper = true;
      }
      await this.$nextTick();
      const popper = this.$refs.tooltipRef;
      const reference = this.$refs.textRef;
      if (!popper) return;
      if (newVal) {
        if (reference) {
          popper.referenceElm = reference;
          popper.popperJS && (popper.popperJS._reference = reference);
        }
        // 监听为了销毁
        this.showPopperUnwatcher = this.$watch(
          () => popper.showPopper,
          (isShowNewVal) => {
            this.showPopper = this.internalValue = isShowNewVal;
            if (isShowNewVal) {
              //
            } else {
              this.unwatchShowPopper();
            }
          }
        );
        popper.updatePopper && popper.updatePopper();
        popper.show && popper.show();
      } else {
        popper.hide && popper.hide();
        // this.unwatchShowPopper();
      }
    },
    async internalValue(newVal) {
      if (newVal) {
        this.handleMouseEnter();
      } else {
        this.handleMouseLeave();
      }
    },
  },
  computed: {
    internalValue: {
      get() {
        return this.value;
      },
      set(value) {
        this.$emit('input', value);
      },
    },
    internalPopperClass() {
      return ['custom-tooltip', this.popperClass].filter(Boolean).join(' ');
    },
    isDisabled() {
      return this.disabled || (!this.isOverflow && !!this.rows);
    },
    hasContentSlot() {
      return !!this.$slots.content;
    },
  },
  data() {
    return {
      isOverflow: false,
      internalContent: '',
      textEl: null,

      isHovering: false,
      showPopper: false,
      showPopperUnwatcher: null,
    };
  },
  methods: {
    getContent() {
      this.textEl = this.$refs.textRef;
      if (!this.textEl) return;
      this.internalContent = this.textEl.innerText;
    },
    handleMouseEnter() {
      this.getContent();
      this.checkOverflow();
      if (this.isDisabled) return;
      this.isHovering = this.internalValue = true;
    },
    async handleMouseLeave() {
      this.isHovering = false;
      if (!this.enterable) {
        this.showPopper = false;
      }
    },
    checkOverflow() {
      if (!this.textEl) return;
      this.isOverflow = this.textEl.scrollWidth - 1 > this.textEl.clientWidth || this.textEl.scrollHeight - 1 > this.textEl.clientHeight;
    },
    unwatchShowPopper() {
      if (this.showPopperUnwatcher) {
        this.showPopperUnwatcher();
        this.showPopperUnwatcher = null;
      }
    },
    handleClick() {
      if (this.hideOnClick) {
        this.showPopper = false;
      }
      this.$emit('click');
    },
  },
  beforeDestroy() {
    // 组件销毁时清理 watcher,防止内存泄漏
    this.unwatchShowPopper();
  },
};
</script>

<style lang="scss" scoped>
.my-tooltip {
  max-width: 100%;
  word-break: break-all;
  vertical-align: bottom;
  -webkit-box-orient: vertical;
  text-overflow: ellipsis;
  overflow: hidden;

  &.is-visible {
    overflow: visible;
  }
  &.is-one-row {
    white-space: nowrap;
  }
}
</style>
<style lang="scss">
.el-tooltip__popper {
  &.custom-tooltip {
    z-index: 99999 !important;
    max-width: 400px;
    max-height: 300px;
    overflow-y: auto;
    margin: 4px 0;
    .popper__arrow {
      display: none;
    }
  }
}
</style>

Attributes

参数 说明 类型 可选值 默认值
value / v-model 控制 tooltip 显示与隐藏(受控) boolean true / false false
rows 控制省略行数 number 0
content 显示的内容 string 默认插槽中的内容
block 是否块级元素(占一整行) boolean true / false false
enterable 鼠标是否可进入到 tooltip 中 boolean true / false false
disabled 禁用 boolean true / false false
placement 位置 string top/top-start/top-end/bottom/bottom-start/bottom-end/left/left-start/left-end/right/right-start/right-end top
hide-on-click 点击时是否隐藏 tooltip boolean true / false false

Slot

name 说明
content 自定义tooltip提示内容

解决 Vite 代理中的 E RR_CONTENT_DECODING_FAILED 错误:禁用自动压缩的实践

作者 Neil鹏
2025年10月14日 08:18

最近在使用 Vite 开发一个 Vue3 项目时,遇到了一个颇为棘手的网络错误。项目配置了代理 ( serv er.proxy ) 将特定前缀(比如 /api )的请求转发到后端服务。大部分接口工作正常,但部分接口在浏览器控制台会抛出 ERR_ CONTENT_DECODING_FAILED 错误。这个错误通常意味着浏览器接收到了经过压缩(如 gzip, br)的响应内容,但无法正确解码。

排查过程

  1. 检查后端服务: 首先确认后端服务本身是正常的,直接访问后端接口 URL(不通过 Vite 代理)可以成功返回预期的 JSON 数据或其它内容,且响应头 Content-Encoding 显示后端确实返回了压缩内容(如 gzip )。

  2. 检查 Vite 代理配置: 基础的代理配置看起来没有问题:

    // vite.config.jsexport default defineConfig({ server: { proxy: { '/api': { target: ' your-backend-server.com', // 后端地址 changeOrigin: true, // 通常建议开启 rewrite: (path) => path.replace(/^/api/, ''), // 可选,重写路径 // ... 其他配置 ... } } }, // ... 其他配置 ...});

JavaScript

配置了 changeOrigin: true 确保请求头中的 Host 和 Origin 被正确修改以应对跨域问题。

  1. 对比请求差异: 使用浏览器开发者工具对比了通过 Vite 代理的请求和直接请求后端的请求/响应头信息。发现关键差异在于 Accept-Encoding 请求头:
  • 直接请求后端: 浏览器发送的 Accept-Encoding 通常包含 gzip, deflate, br 等,表明浏览器可以接受这些压缩格式。后端据此返回压缩内容并设置 Content-Encoding: gzip 。

  • 通过 Vite 代理请求: Vite 开发服务器在转发请求给后端时,默认也会带上 Accept-Encoding: gzip, deflate, br (或类似)的请求头。后端同样识别到这个头,并返回了压缩内容 ( Content-Encoding: gzip )。

  1. 问题定位: 问题出在 Vite 开发服务器对代理响应的处理上。当后端返回压缩内容时:
  • Vite 开发服务器(基于 http-proxy-middleware )接收到了这个压缩的响应体。

  • 试图将这个压缩的响应体原样转发给浏览器

  • 然而,浏览器在接收到这个响应时,发现响应头 Content-Encoding: gzip 存在,表明内容需要解压。

  • 浏览器尝试解压这个响应体,但失败了,导致 ERR_CONTENT_DECODING_FAILED 错误。

核心原因

Vite 代理默认行为是“透明”转发请求和响应。它不会主动解压后端返回的压缩内容,而是直接将其传递给前端浏览器。浏览器看到 Content-Encoding 头,就会尝试解压,但如果这个压缩流在传输或处理过程中出现任何不兼容或损坏(即使后端压缩本身是正确的,代理的传递过程也可能引入微妙的不兼容),或者浏览器对特定压缩算法的实现有细微差异,解压就可能失败。

解决方案:强制后端返回未压缩内容

既然问题源于浏览器无法正确处理代理转发的压缩响应,最直接的思路就是阻止后端返回压缩内容。我们可以在代理请求中明确告诉后端:“我不接受任何压缩格式,请给我原始(identity)内容”。

这就是通过设置 headers 选项中的 Accept-Encoding 来实现的:

// vite.config.jsexport default defineConfig({  server: {    proxy: {      '/api': {        target: ' https://your-backend-server.com',        changeOrigin: true,        rewrite: (path) => path.replace(/^\/api/, ''), // 可选        // 关键解决方案:添加 headers 配置        headers: {          'Accept-Encoding': 'identity', // 明确要求后端不要压缩响应体        },      }    }  },  // ... 其他配置 ...});

TypeScript

解释

  • headers 选项允许我们在 Vite 代理将请求转发给目标服务器(后端)之前,修改或添加请求头。

  • 设置 'Accept-Encoding': 'identity' :

  • Accept-Encoding 是 HTTP 请求头,用于告知服务器客户端能够理解的内容编码(压缩)方式。

  • identity 是一个特殊值,表示“不压缩”、“无编码”、“原样”。它明确告诉服务器:“请直接返回原始数据,不要进行任何压缩”。

  • 效果: 后端服务器收到这个请求头后,知道客户端(此时是 Vite 代理服务器,它代表浏览器)不接受压缩,因此会返回未经压缩的原始响应体,并且响应头中通常不会包含 Content-Encoding ,或者其值为 identity 。

  • 结果: Vite 代理将这个未压缩的响应体转发给浏览器。浏览器没有看到 Content-Encoding 头,或者看到 identity ,就知道内容不需要解压,直接使用即可。 ERR_CONTENT_DECODING_FAILED 错误消失。

总结与启示

  1. 问题本质: ERR_CONTENT_DECODING_FAILED 在 Vite 代理场景下,通常是由于代理直接转发了后端的压缩响应,而浏览器解压该响应时失败。

  2. 解决方案: 在 Vite 的代理配置 ( server.proxy[xxx].headers ) 中设置 'Accept-Encoding': 'identity' ,强制要求后端返回未压缩的原始内容。这消除了浏览器解压环节,从而避免了解压失败的错误。

  3. 权衡: 此方案的代价是牺牲了网络传输的压缩效率。未压缩的内容体积更大,可能会略微增加加载时间。但在开发环境或部分特定接口遇到此问题时,稳定性优先于那一点传输效率通常是更合理的选择。对于生产环境,静态资源应使用构建时预压缩(如 vite-plugin-compression ),并由服务器(如 Nginx)根据请求头 Accept-Encoding 动态提供正确的压缩版本或原始版本给浏览器。

  4. 排查技巧: 遇到代理相关问题时,仔细对比代理前后请求/响应头的差异是至关重要的第一步。开发者工具的网络面板是解决此类问题的利器。

微信小程序同声传译插件深度应用:语音合成与长文本播放优化

作者 _AaronWong
2025年10月14日 07:35

之前的文章 微信小程序同声传译插件接入实战:语音识别功能完整实现指南介绍如何使用同声传译插件进行语音识别,这篇将会讲述同声传译的另一个功能语音合成。

功能概述

微信小程序同声传译插件的语音合成(TTS)功能能将文字内容转换为语音播放,适用于内容朗读、语音提醒、无障碍阅读等场景。

核心实现架构

状态管理

const textToSpeechContent = ref("")
const textToSpeechStatus = ref(0)  // 0 未播放 1 合成中 2 正在播放

核心功能实现

语音合成主函数

function onTextToSpeech(text = "") {
  // 如果正在播放,先停止
  if(textToSpeechStatus.value > 0) {
    uni.$emit("STOP_INNER_AUDIO_CONTEXT")
  }
  
  textToSpeechStatus.value = 1
  uni.showLoading({
    title: "语音合成中...",
    mask: true,
  })
  
  // 处理文本内容
  if(text.length) {
    textToSpeechContent.value = text
  }
  
  // 分段处理长文本(微信限制每次最多200字)
  let content = textToSpeechContent.value.slice(0, 200)
  textToSpeechContent.value = textToSpeechContent.value.slice(200)
  
  if(!content) {
    uni.hideLoading()
    return
  }
  
  // 调用合成接口
  plugin.textToSpeech({
    lang: "zh_CN",
    tts: true,
    content: content,
    success: (res) => {
      handleSpeechSuccess(res)
    },
    fail: (res) => {
      handleSpeechFail(res)
    }
  })
}

合成成功处理

function handleSpeechSuccess(res) {
  uni.hideLoading()
  
  // 创建音频上下文
  innerAudioContext = uni.createInnerAudioContext()
  innerAudioContext.src = res.filename
  innerAudioContext.play()
  textToSpeechStatus.value = 2
  
  // 播放结束自动播下一段
  innerAudioContext.onEnded(() => {
    innerAudioContext = null
    textToSpeechStatus.value = 0
    onTextToSpeech() // 递归播放剩余内容
  })
  
  setupAudioControl()
}

音频控制管理

function setupAudioControl() {
  uni.$off("STOP_INNER_AUDIO_CONTEXT")
  uni.$on("STOP_INNER_AUDIO_CONTEXT", (pause) => {
    textToSpeechStatus.value = 0
    if(pause) {
      innerAudioContext?.pause()
    } else {
      innerAudioContext?.stop()
      innerAudioContext = null
      textToSpeechContent.value = ""
    }
  })
}

错误处理

function handleSpeechFail(res) {
  textToSpeechStatus.value = 0
  uni.hideLoading()
  toast("不支持合成的文字")
  console.log("fail tts", res)
}

关键技术点

1. 长文本分段处理

由于微信接口限制,单次合成最多200字,需要实现自动分段:

let content = textToSpeechContent.value.slice(0, 200)
textToSpeechContent.value = textToSpeechContent.value.slice(200)

2. 播放状态管理

通过状态值精确控制播放流程:

  • 0:未播放,可以开始新的合成
  • 1:合成中,显示loading状态
  • 2:播放中,可以暂停或停止

3. 自动连续播放

利用递归实现长文本的自动连续播放:

innerAudioContext.onEnded(() => {
  onTextToSpeech() // 播放结束继续合成下一段
})

完整代码

export function useTextToSpeech() {
  const plugin = requirePlugin('WechatSI')
  let innerAudioContext = null
  const textToSpeechContent = ref("")
  const textToSpeechStatus = ref(0)
  
  function onTextToSpeech(text = "") {
    if(textToSpeechStatus.value > 0) {
      uni.$emit("STOP_INNER_AUDIO_CONTEXT")
    }
    textToSpeechStatus.value = 1
    uni.showLoading({
      title: "语音合成中...",
      mask: true,
    })
    
    if(text.length) {
      textToSpeechContent.value = text
    }
    
    let content = textToSpeechContent.value.slice(0, 200)
    textToSpeechContent.value = textToSpeechContent.value.slice(200)
    
    if(!content) {
      uni.hideLoading()
      return
    }
    
    plugin.textToSpeech({
      lang: "zh_CN",
      tts: true,
      content: content,
      success: (res) => {
        uni.hideLoading()
        innerAudioContext = uni.createInnerAudioContext()
        innerAudioContext.src = res.filename
        innerAudioContext.play()
        textToSpeechStatus.value = 2
        
        innerAudioContext.onEnded(() => {
          innerAudioContext = null
          textToSpeechStatus.value = 0
          onTextToSpeech()
        })
        
        uni.$off("STOP_INNER_AUDIO_CONTEXT")
        uni.$on("STOP_INNER_AUDIO_CONTEXT", (pause) => {
          textToSpeechStatus.value = 0
          if(pause) {
            innerAudioContext?.pause()
          } else {
            innerAudioContext?.stop()
            innerAudioContext = null
            textToSpeechContent.value = ""
          }
        })
      },
      fail: (res) => {
        textToSpeechStatus.value = 0
        uni.hideLoading()
        toast("不支持合成的文字")
        console.log("fail tts", res)
      }
    })
  }
  
  return {
    onTextToSpeech,
    textToSpeechContent,
    textToSpeechStatus
  }
}

Vue 与 React 应用初始化机制对比 - 前端框架思考笔记

作者 Takklin
2025年10月14日 02:10

Vue 与 React 应用初始化机制对比 - 前端框架思考笔记

引子:从挂载点开始的思考

最近在准备前端面试时,我一直在思考一个问题:为什么 Vue 和 React 都需要一个挂载点?这个看似简单的 <div id="app"></div> 到底在框架中扮演什么角色?

我当时想:这不就是一个普通的 div 吗?为什么非要指定它?直接往 body 里塞内容不行吗?

通过深入理解,我发现这背后涉及到现代前端框架的核心设计理念。

什么是挂载点?为什么需要它?

挂载点就是一个特定的 DOM 元素,作为我们应用的渲染容器。在 Vue 或 React 中,我们通过指定挂载点来告诉框架:"请把整个应用的内容都渲染到这个元素内部"。

<body>
  <!-- 这就是挂载点 -->
  <div id="app"></div>
  
  <script src="main.js"></script>
</body>

我当时疑惑:如果不指定挂载点会怎样?框架会把内容直接插入到 body 中吗?

确实如此!如果没有明确的挂载点,Vue 或 React 可能会直接把内容插入到 body 或其他 DOM 元素中,造成页面结构混乱。想象一下,你的应用内容散落在 body 的各个角落,没有统一的容器,管理和定位 DOM 元素会变得极其困难。

Vue 的应用初始化过程

createApp 和 mount 的分离

在 Vue 3 中,应用初始化分为两个清晰的步骤:

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

我当时不理解:为什么要分 createApp 和 mount 两步?直接像 React 那样渲染不行吗?

深入思考后我明白了:

  • createApp(App):创建 Vue 应用实例
  • .mount('#app'):将实例挂载到 DOM

这种分离设计让 Vue 在挂载前可以进行各种配置,比如注册全局组件、插件等。

Vue 的组件解析过程

我当时问:Vue 是怎么把模板变成实际页面的?

Vue 的模板编译过程是这样的:

  1. 模板解析:Vue 将 .vue 文件中的模板代码转换成 JavaScript 对象
  2. 生成虚拟 DOM:这些对象构成了虚拟 DOM(VNode),描述页面结构
  3. 渲染到实际 DOM:虚拟 DOM 通过比对算法更新实际页面
// 模板
<template>
  <div>{{ message }}</div>
</template>

// 被编译成渲染函数
render() {
  return createVNode('div', null, this.message)
}

我当时想:为什么要经过虚拟 DOM 这个中间步骤?

虚拟 DOM 的优势在于性能优化。Vue 通过比较新旧虚拟 DOM 的差异,只更新发生变化的部分,而不是重新渲染整个页面。

React 的应用初始化

直接的渲染方式

React 的初始化相对直接:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.getElementById('app'))

我当时对比:React 为什么不需要像 Vue 那样先创建应用实例?

这与两个框架的设计哲学有关。React 更专注于组件本身的渲染,而 Vue 强调应用级别的管理和配置。

JSX 与 Vue 模板的差异

我当时注意到:React 的组件导出看起来比 Vue 简单很多:

// React 组件
function App() {
  return (
    <div>
      <h1>Welcome to My React App</h1>
    </div>
  )
}

export default App
<!-- Vue 组件 -->
<template>
  <div>
    <h1>Welcome to My Vue App</h1>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: 'Hello, world!'
    }
  }
}
</script>

这种差异源于 Vue 的响应式系统需要更明确的数据声明。

设计哲学的深层差异

React:专注于组件渲染的简洁性

我当时困惑:React 是从根组件开始构建虚拟 DOM 树,而 Vue 是组件级框架自底向上构建,这和我前文的这两个框架的设计哲学:React 更专注于组件本身的渲染,而 Vue 强调应用级别的管理和配置。总觉得有哪里矛盾?

通过深入研究,我发现 React 的设计哲学是:

组件树作为应用核心

  • React 将整个应用视为组件树,根组件是起点
  • 通过 ReactDOM.render() 从根组件开始渲染整个树结构
  • 每个组件在渲染和状态管理上保持独立性
  • 不需要显式的应用实例,简化了配置

我当时理解:React 的简洁性体现在它把应用管理隐藏在组件树中,让开发者更专注于组件本身的实现。

Vue:应用实例与组件化的平衡

Vue 采取了不同的路径:

明确的应用实例概念

  • 通过 createApp() 创建明确的应用实例
  • 应用实例负责全局配置、插件、状态管理
  • 在保持组件化的同时,提供应用级别的管理能力

我当时对比:Vue 的设计既照顾了大型应用的需求(通过应用实例),又保持了组件级别的灵活性。

引用GPT的精彩理解image.png

单页应用(SPA)与多页应用(MPA)

我当时困惑:什么叫做"页面本身只有一个 HTML 文件"?我们不是有 index.html 还有各种 .vue 文件吗?

这里的关键区别在于:

单页应用(SPA)

  • 只有一个 HTML 文件(通常是 index.html
  • 页面切换通过 JavaScript 动态渲染内容
  • 不会重新加载整个页面
  • 用户体验更流畅

多页应用(MPA)

  • 每个页面都有独立的 HTML 文件
  • 页面切换需要重新加载
  • 传统的网页开发方式

我当时恍然大悟:原来 .vue 文件在构建时会被打包工具处理,最终都合并到同一个 HTML 中!

构建工具的作用

我当时问:Vue 的模板编译是通过什么工具完成的?

现代前端开发离不开构建工具:

  • Webpack/Vite:模块打包和构建
  • Babel:JavaScript 代码转换
  • Vue Loader:处理 .vue 文件

Vue 的模板编译器会将模板转换成抽象语法树(AST),然后生成渲染函数。这个过程在构建阶段完成,而不是在浏览器中运行时。

多个 Vue 实例的情况

我当时好奇:什么情况下需要多个 Vue 实例?

虽然在单页应用中通常只有一个 Vue 实例,但在某些场景下可能需要多个:

// 不同功能模块使用不同实例
createApp(App1).mount('#app1')
createApp(App2).mount('#app2')

这种情况常见于:

  • 老项目渐进式迁移
  • 页面中有多个独立的功能模块
  • 微前端架构

虚拟 DOM 的重要性

我当时不理解:为什么要用虚拟 DOM?直接操作真实 DOM 不行吗?

虚拟 DOM(VNode)的本质是 JavaScript 对象,它描述了页面的结构。优势在于:

  1. 性能优化:通过 Diff 算法最小化 DOM 操作
  2. 跨平台能力:同一套虚拟 DOM 可以渲染到不同平台
  3. 开发体验:让开发者更关注业务逻辑而不是 DOM 操作

总结与面试要点

通过这番探索,我对 Vue 和 React 的初始化机制有了更深入的理解:

Vue 的特点

  • 明确的应用实例概念
  • 模板编译在构建时完成
  • 响应式数据系统
  • 配置灵活,适合大型应用

React 的特点

  • 专注于组件渲染
  • JSX 语法更接近 JavaScript
  • 函数式编程思想
  • 生态丰富,社区活跃

面试中如何描述

当被问到 Vue 和 React 的区别时,我可以这样回答:

"两者都是优秀的现代前端框架,但在设计理念上有所不同。Vue 通过 createApp 创建明确的应用实例,提供了更多的配置和管理能力;而 React 更专注于组件本身的渲染,通过 ReactDOM.render 直接渲染组件。这种差异体现在开发体验、性能优化和适用场景上。"

我的最终感悟:前端框架的每一个设计选择都有其深层考量。从简单的挂载点开始,深入理解框架的设计哲学,才能真正掌握前端开发的精髓。

可怕!我的Nodejs系统因为日志打印了Error 对象就崩溃了😱 Node.js System Crashed Because of Logging

2025年10月14日 01:35

本文为中英文双语,需要英文博客可以滑动到下面查看哦 | This is a bilingual article. Scroll down for the English version.

小伙伴们!今天我在本地调试项目的过程中,想记录一下错误信息,结果程序就"啪"地一下报出 "Maximum call stack size exceeded" 错误,然后项目直接就crash了。但是我看我用的这个开源项目,官方的代码里好多地方就是这么用的呀?我很纳闷,这是为什么呢?

Snipaste_2025-10-10_00-28-45.png

报错信息


[LOGGER PARSING ERROR] Maximum call stack size exceeded
2025-10-13T17:06:59.643Z debug: Error code: 400 - {'error': {'message': 'Budget has been exceeded! Current cost: 28.097367900000002, Max budget: 0.0', 'type': 'budget_exceeded', 'par... [truncated]
{
  unknown: [object Object],
}
2025-10-13T17:06:59.643Z debug: [api/server/middleware/abortMiddleware.js] respondWithError called
2025-10-13T17:06:59.644Z error: There was an uncaught error: Cannot read properties of undefined (reading 'emit')
2025-10-13T17:06:59.645Z debug: [indexSync] Clearing sync timeouts before exiting...
[nodemon] app crashed - waiting for file changes before starting...

报错截图

image

错误分析

晚上下班以后,晚上躺在床上,我翻来覆去睡不着,干脆打开电脑一番探究,想要知道 ,这个错误到底为何触发,实质原因是什么,以及如何解决它。让我们一起把这个小调皮鬼揪出来看看它到底在搞什么鬼吧!👻

场景复现

想象一下这个场景,你正在开心地写着代码:

app.get('/api/data', async (req, res) => {
  try {
    // 一些可能会出小差错的业务逻辑
    const data = await fetchDataFromAPI();
    res.json(data);
  } catch (error) {
    // 记录错误信息
    logger.debug('获取数据时出错啦~', error); // 哎呀!这一行可能会让我们的程序崩溃哦!
    res.status(500).json({ error: '内部服务器出错啦~' });
  }
});

看起来是不是很正常呢?但是当你运行这段代码的时候,突然就出现了这样的错误:

[LOGGER PARSING ERROR] Maximum call stack size exceeded

更神奇的是,如果你把代码改成这样:

console.log(error); // 这一行却不会让程序崩溃哦,但是上prod的系统,不要这么用哦

它就能正常工作啦!这是为什么呢?🤔

小秘密大揭秘!🔍

console.log虽好,但请勿用它来记录PROD错误!

console.log 是 Node.js 原生提供的函数,它就像一个经验超级丰富的大叔,知道怎么处理各种"调皮"的对象。当 console.log 遇到包含循环引用的对象时,它会聪明地检测这些循环引用,并用 [Circular] 标记来代替实际的循环部分,这样就不会无限递归啦!

简单来说,Node.js 的 console.log 就像一个超级厉害的武林高手,知道如何闪转腾挪,避开各种陷阱!🥋

日志库的"小烦恼"

但是我们自己封装的日志系统(比如项目中使用的 Winston)就不一样啦!为了实现各种炫酷的功能(比如格式化、过滤敏感信息等),日志库通常会使用一些第三方库来处理传入的对象。

在我们的案例中,日志系统使用了 [traverse] 库来遍历对象。这个库在大多数情况下工作得都很好,但当它遇到某些复杂的 Error 对象时,就可能会迷路啦!

Error 对象可不是普通对象那么简单哦!它们可能包含各种隐藏的属性、getter 方法,甚至在某些情况下会动态生成属性。当 [traverse] 库尝试遍历这些复杂结构时,就可能陷入无限递归的迷宫,最终导致调用栈溢出。

什么是循环引用?🌀

在深入了解这个问题之前,我们先来了解一下什么是循环引用。循环引用指的是对象之间相互引用,形成一个闭环。比如说:

const objA = { name: '小A' };
const objB = { name: '小B' };

objA.ref = objB;
objB.ref = objA; // 哎呀!形成循环引用啦!

当尝试序列化这样的对象时(比如用 JSON.stringify),就会出现问题,因为序列化过程会无限递归下去,就像两只小仓鼠在滚轮里永远跑不完一样!🐹

Error 对象虽然看起来简单,但内部结构可能非常复杂,特别是在一些框架或库中创建的 Error 对象,它们可能包含对 request、response 等对象的引用,而这些对象又可能包含对 Error 对象的引用,从而形成复杂的循环引用网络,就像一张大蜘蛛网一样!🕷️

怎样才能让我们的日志系统乖乖听话呢?✨

1. 只记录我们需要的信息

最简单直接的方法就是不要把整个 Error 对象传递给日志函数,而是只传递我们需要的具体属性:

// ❌ 不推荐的做法 - 会让日志系统"生气"
logger.debug('获取数据时出错啦~', error);

// ✅ 推荐的做法 - 让日志系统开心地工作
logger.debug('获取数据时出错啦~', {
  message: error.message,
  stack: error.stack,
  code: error.code
});

2. 使用专门的错误序列化函数

你可以创建一个专门用于序列化 Error 对象的函数,就像给 Error 对象穿上一件"安全外套":

function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    // 添加其他你需要的属性
  };
}

// 使用方式
logger.debug('获取数据时出错啦~', serializeError(error));

3. 使用成熟的错误处理库

有些库专门为处理这类问题而设计,比如 serialize-error,它们就像专业的保姆一样,会把 Error 对象照顾得好好的:

const { serializeError } = require('serialize-error');

logger.debug('获取数据时出错啦~', serializeError(error));

4. 配置日志库的防护机制

如果你使用的是 Winston,可以配置一些防护机制,给它穿上"防弹衣":

const winston = require('winston');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  // ... 其他配置
});

最佳实践小贴士 🌟

  1. 永远不要直接记录原始的 Error 对象:它们可能包含复杂的循环引用结构,就像一个调皮的小恶魔。

  2. 提取关键信息:只记录我们需要的错误信息,比如 message、stack 等,就像挑选糖果一样只拿最喜欢的。

  3. 使用安全的序列化方法:确保我们的日志系统能够处理各种边界情况,做一个贴心的小棉袄。

  4. 添加防护措施:在日志处理逻辑中添加 try-catch 块,防止日志系统本身成为故障点,就像给程序戴上安全帽。

  5. 测试边界情况:在测试中模拟各种错误场景,确保日志系统在极端情况下也能正常工作,做一个负责任的好孩子。

image

Terrifying! My Node.js System Crashed Because of Logging an Error Object 😱

Fellow developers! Today, while debugging a project locally, I wanted to log some error information, but suddenly the program threw a "Maximum call stack size exceeded" error and crashed the entire project. But when I look at the open-source project I'm using, I see that the official code does this in many places. I was puzzled, why is this happening?

Error Message


[LOGGER PARSING ERROR] Maximum call stack size exceeded
2025-10-13T17:06:59.643Z debug: Error code: 400 - {'error': {'message': 'Budget has been exceeded! Current cost: 28.097367900000002, Max budget: 0.0', 'type': 'budget_exceeded', 'par... [truncated]
{
  unknown: [object Object],
}
2025-10-13T17:06:59.643Z debug: [api/server/middleware/abortMiddleware.js] respondWithError called
2025-10-13T17:06:59.644Z error: There was an uncaught error: Cannot read properties of undefined (reading 'emit')
2025-10-13T17:06:59.645Z debug: [indexSync] Clearing sync timeouts before exiting...
[nodemon] app crashed - waiting for file changes before starting...

Error Screenshot

image

Error Analysis

After work, I couldn't resist investigating why this error was triggered, what the root cause was, and how to solve it. Let's together catch this little troublemaker and see what it's up to! 👻

Reproducing the Scenario

Imagine this scenario, you're happily coding:

app.get('/api/data', async (req, res) => {
  try {
    // Some business logic that might go wrong
    const data = await fetchDataFromAPI();
    res.json(data);
  } catch (error) {
    // Log the error
    logger.debug('Error fetching data~', error); // Oops! This line might crash our program!
    res.status(500).json({ error: 'Internal server error~' });
  }
});

Doesn't this look normal? But when you run this code, suddenly this error appears:

[LOGGER PARSING ERROR] Maximum call stack size exceeded

What's even more神奇 is, if you change the code to this:

console.log(error); // This line won't crash the program, but don't use this in production systems

It works fine! Why is that? 🤔

The Big Reveal of Little Secrets! 🔍

console.log is Good, But Don't Use It to Log PROD Errors!

console.log is a native Node.js function. It's like an extremely experienced uncle who knows how to handle all kinds of "naughty" objects. When console.log encounters objects with circular references, it cleverly detects these circular references and replaces the actual circular parts with [Circular] markers, so it won't recurse infinitely!

Simply put, Node.js's console.log is like a super skilled martial arts master who knows how to dodge and avoid all kinds of traps! 🥋

The "Little Troubles" of Logging Libraries

But our custom logging systems (like Winston used in the project) are different! To implement various cool features (like formatting, filtering sensitive information, etc.), logging libraries often use third-party libraries to process incoming objects.

In our case, the logging system uses the [traverse] library to traverse objects. This library works well in most cases, but when it encounters certain complex Error objects, it might get lost!

Error objects are not as simple as ordinary objects! They may contain various hidden properties, getter methods, and in some cases, dynamically generated properties. When the [traverse] library tries to traverse these complex structures, it may fall into an infinite recursion maze, ultimately causing a stack overflow.

What Are Circular References? 🌀

Before diving deeper into this issue, let's first understand what circular references are. Circular references refer to objects that reference each other, forming a closed loop. For example:

const objA = { name: 'A' };
const objB = { name: 'B' };

objA.ref = objB;
objB.ref = objA; // Oops! Circular reference formed!

When trying to serialize such objects (like with JSON.stringify), problems arise because the serialization process will recurse infinitely, like two hamsters running forever in a wheel! 🐹

Although Error objects look simple, their internal structure can be very complex, especially Error objects created in some frameworks or libraries. They may contain references to request, response, and other objects, and these objects may in turn contain references to the Error object, forming a complex circular reference network, like a giant spider web! 🕷️

How to Make Our Logging System Behave? ✨

1. Only Log the Information We Need

The simplest and most direct method is not to pass the entire Error object to the logging function, but to pass only the specific properties we need:

// ❌ Not recommended - will make the logging system "angry"
logger.debug('Error fetching data~', error);

// ✅ Recommended - makes the logging system work happily
logger.debug('Error fetching data~', {
  message: error.message,
  stack: error.stack,
  code: error.code
});

2. Use a Dedicated Error Serialization Function

You can create a dedicated function for serializing Error objects, like putting a "safety coat" on the Error object:

function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    // Add other properties you need
  };
}

// Usage
logger.debug('Error fetching data~', serializeError(error));

3. Use Mature Error Handling Libraries

Some libraries are specifically designed to handle these kinds of issues, such as serialize-error. They're like professional nannies who will take good care of Error objects:

const { serializeError } = require('serialize-error');

logger.debug('Error fetching data~', serializeError(error));

4. Configure Protective Mechanisms for Logging Libraries

If you're using Winston, you can configure some protective mechanisms to give it "bulletproof armor":

const winston = require('winston');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  // ... other configurations
});

Best Practice Tips 🌟

  1. Never log raw Error objects directly: They may contain complex circular reference structures, like a mischievous little devil.

  2. Extract key information: Only log the error information we need, such as message, stack, etc., like picking candy - only take your favorites.

  3. Use safe serialization methods: Ensure our logging system can handle various edge cases, be a thoughtful companion.

  4. Add protective measures: Add try-catch blocks in the logging logic to prevent the logging system itself from becoming a failure point, like giving the program a safety helmet.

  5. Test edge cases: Simulate various error scenarios in testing to ensure the logging system works properly under extreme conditions, be a responsible good child.

Conclusion | 结语

  • That's all for today~ - | 今天就写到这里啦~

  • Guys, ( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ See you tomorrow~ | 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • Everyone, be happy every day! 大家要天天开心哦

  • Welcome everyone to point out any mistakes in the article~ | 欢迎大家指出文章需要改正之处~

  • Learning has no end; win-win cooperation | 学无止境,合作共赢

  • Welcome all the passers-by, boys and girls, to offer better suggestions! ~ | 欢迎路过的小哥哥小姐姐们提出更好的意见哇~~

理解 JavaScript 中的 this 上下文保存

作者 呼叫6945
2025年10月13日 23:49

保存 this 上下文是 JavaScript 中一个非常重要的概念,尤其是在处理闭包、定时器等场景时。让我们深入理解这个概念。

this 是什么?

this 是 JavaScript 中的一个特殊关键字,它指向的是当前代码执行的上下文对象。简单来说,this 的值取决于函数被调用的方式,而不是函数被定义的位置。

为什么需要保存 this 上下文?

在防抖函数中,我们遇到了一个典型问题:在 setTimeout 回调函数中,this 的指向会发生变化

让我们看一个例子来说明这个问题:

function debounce(func, wait) {
    let timeout;
    
    return function executedFunction(...args) {
        // 这里的 this 指向的是调用 debounced 函数的对象
        console.log('外层 this:', this); // 假设是按钮元素
        
        timeout = setTimeout(function() {
            // 这里的 this 默认指向 window 或 undefined(严格模式)
            console.log('setTimeout 中的 this:', this);
            func.apply(this, args); // 这会导致错误,因为 this 已经变了
        }, wait);
    };
}

问题所在:当我们在 setTimeout 的回调函数中使用 this 时,它不再指向原始调用上下文(比如按钮元素),而是指向全局对象 window(非严格模式)或 undefined(严格模式)。

如何正确保存 this 上下文

为了解决这个问题,我们需要在进入 setTimeout 之前保存原始的 this 引用:

function debounce(func, wait) {
    let timeout;
    
    return function executedFunction(...args) {
        // 保存原始的 this 上下文
        const context = this; // 关键步骤!
        
        timeout = setTimeout(function() {
            // 现在我们使用保存的 context 而不是这里的 this
            func.apply(context, args);
        }, wait);
    };
}

通过 const context = this; 这行代码,我们将原始的 this 引用保存到了 context 变量中,这样即使在 setTimeout 回调函数中 this 发生了变化,我们仍然可以通过 context 访问到原始的上下文。

实际应用场景示例

让我们看一个更贴近实际开发的例子:

// 假设我们有一个计数器对象
const counter = {
    count: 0,
    increment: function() {
        this.count++;
        console.log(`当前计数: ${this.count}`);
    }
};

// 创建防抖版本的 increment 方法
const debouncedIncrement = debounce(counter.increment, 1000);

// 添加事件监听
button.addEventListener('click', debouncedIncrement);

如果防抖函数中没有正确保存 this 上下文,点击按钮时会出现错误,因为 this.count 会变成 undefined.count

但如果我们使用正确实现的防抖函数(保存了 this 上下文),就不会有问题:

button.addEventListener('click', function() {
    // 手动绑定 this 到 counter
    debouncedIncrement.call(counter);
});

总结

保存 this 上下文是 JavaScript 中处理函数调用的重要技巧,特别是在使用闭包和定时器时:

  1. this 的值取决于函数被调用的方式
  2. setTimeout 等异步回调中,this 的指向会改变
  3. 通过在异步操作前保存 this 引用,我们可以确保函数在正确的上下文中执行
  4. applycall 方法允许我们显式地设置函数执行的上下文

理解并掌握 this 的工作原理,对于前端开发者至关重要,前端学习ing,欢迎各位佬指正

代码质量工程完全指南 🚀

作者 Holin_浩霖
2025年10月13日 23:43

代码质量工程完全指南 🚀

构建可维护、高质量代码库的完整实践方案

TypeScript 高级用法 ⚙️

1. 泛型约束(Generics & Constraints)🎯

为什么需要泛型约束?

在开发可复用组件时,我们经常需要处理多种数据类型,但又不想失去 TypeScript 的类型安全优势。泛型约束允许我们在保持灵活性的同时,对类型参数施加限制。

解决的问题:

  • 避免使用 any 类型导致类型信息丢失
  • 在通用函数中保持输入输出类型关系
  • 提供更好的 IDE 智能提示和自文档化

缺点与限制:

  • 过度复杂的约束会让错误信息难以理解
  • 嵌套泛型可能导致编译性能下降
  • 初学者可能需要时间适应这种抽象思维
详细代码示例
// 🎯 基础泛型函数
function identity<T>(value: T): T {
  return value;
}

// 使用显式类型参数
const result1 = identity<string>("Hello"); // 类型: string
// 使用类型推断
const result2 = identity(42); // 类型: number

// 🎯 泛型约束 - 确保类型具有特定属性
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): void {
  console.log(`Length: ${item.length}`);
}

// 这些调用都是合法的
logLength("hello");     // 字符串有 length 属性
logLength([1, 2, 3]);   // 数组有 length 属性
logLength({ length: 5, name: "test" }); // 对象有 length 属性

// 🎯 泛型约束与 keyof 结合
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { 
  id: 1, 
  name: "Alice", 
  email: "alice@example.com" 
};

const userName = getProperty(user, "name");    // 类型: string
const userId = getProperty(user, "id");        // 类型: number

// 🎯 多重约束
interface Serializable {
  serialize(): string;
}

interface Identifiable {
  id: number;
}

function processEntity<T extends Serializable & Identifiable>(entity: T): void {
  console.log(`ID: ${entity.id}`);
  console.log(`Serialized: ${entity.serialize()}`);
}

// 🎯 泛型类示例
class Repository<T extends { id: number }> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  findById(id: number): T | undefined {
    return this.items.find(item => item.id === id);
  }

  getAll(): T[] {
    return [...this.items];
  }
}

// 使用泛型类
interface Product {
  id: number;
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.add({ id: 1, name: "Laptop", price: 999 });
const laptop = productRepo.findById(1); // 类型: Product | undefined

2. 条件类型与推断(Conditional Types & infer)🧠

为什么需要条件类型?

条件类型允许我们在类型级别进行条件判断,实现基于输入类型的动态类型转换。这在创建灵活的类型工具和库时特别有用。

解决的问题:

  • 根据条件动态推导类型
  • 从复杂类型中提取子类型
  • 减少重复的类型定义

缺点与限制:

  • 可读性较差,特别是嵌套条件类型
  • 错误信息可能非常复杂难懂
  • 需要深入理解 TypeScript 的类型系统
详细代码示例
// 🧠 基础条件类型
type IsString<T> = T extends string ? true : false;

type Test1 = IsString<"hello">;    // true
type Test2 = IsString<number>;     // false
type Test3 = IsString<string | number>; // boolean

// 🧠 使用 infer 进行类型提取
type ExtractPromiseType<T> = T extends Promise<infer U> ? U : T;

type AsyncString = ExtractPromiseType<Promise<string>>; // string
type JustNumber = ExtractPromiseType<number>;           // number

// 🧠 从函数类型中提取参数和返回类型
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type GetParameters<T> = T extends (...args: infer P) => any ? P : never;

type Func = (a: number, b: string) => boolean;
type Return = GetReturnType<Func>;    // boolean
type Params = GetParameters<Func>;    // [number, string]

// 🧠 分发条件类型(分布式条件类型)
type ToArray<T> = T extends any ? T[] : never;

type StringOrNumberArray = ToArray<string | number>; 
// 等价于: string[] | number[]

type NeverArray = ToArray<never>; // never

// 🧠 排除 null 和 undefined
type NonNullable<T> = T extends null | undefined ? never : T;

type ValidString = NonNullable<string | null>;        // string
type ValidNumber = NonNullable<number | undefined>;   // number

// 🧠 递归条件类型 - DeepPartial
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface User {
  id: number;
  profile: {
    name: string;
    settings: {
      theme: string;
      notifications: boolean;
    };
  };
}

type PartialUser = DeepPartial<User>;
// 等价于:
// {
//   id?: number;
//   profile?: {
//     name?: string;
//     settings?: {
//       theme?: string;
//       notifications?: boolean;
//     };
//   };
// }

// 🧠 条件类型与模板字面量结合
type GetterName<T extends string> = T extends `_${infer Rest}` 
  ? `get${Capitalize<Rest>}` 
  : `get${Capitalize<T>}`;

type NameGetter = GetterName<"name">;     // "getName"
type PrivateGetter = GetterName<"_email">; // "getEmail"

3. 映射类型(Mapped Types)🔁

为什么需要映射类型?

映射类型允许我们基于现有类型创建新类型,通过转换每个属性来实现类型的批量操作。

解决的问题:

  • 批量修改类型属性(只读、可选等)
  • 基于现有类型创建变体
  • 减少重复的类型定义代码

缺点与限制:

  • 映射类型不会自动递归处理嵌套对象
  • 复杂的映射类型可能难以理解和调试
  • 某些高级用法需要深入的类型系统知识
详细代码示例
// 🔁 基础映射类型
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

type Partial<T> = {
  [P in keyof T]?: T[P];
};

type Required<T> = {
  [P in keyof T]-?: T[P];
};

// 🔁 键重映射
type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};

interface User {
  name: string;
  age: number;
}

type UserGetters = Getters<User>;
// 等价于:
// {
//   getName: () => string;
//   getAge: () => number;
// }

// 🔁 过滤属性
type OnlyFunctions<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K];
};

interface MixedInterface {
  name: string;
  age: number;
  getName(): string;
  setAge(age: number): void;
}

type FunctionsOnly = OnlyFunctions<MixedInterface>;
// 等价于:
// {
//   getName: () => string;
//   setAge: (age: number) => void;
// }

// 🔁 基于值的类型映射
type EventConfig<T extends { kind: string }> = {
  [E in T as E["kind"]]: (event: E) => void;
};

type Event = 
  | { kind: "click"; x: number; y: number }
  | { kind: "keypress"; key: string }
  | { kind: "focus"; element: HTMLElement };

type Config = EventConfig<Event>;
// 等价于:
// {
//   click: (event: { kind: "click"; x: number; y: number }) => void;
//   keypress: (event: { kind: "keypress"; key: string }) => void;
//   focus: (event: { kind: "focus"; element: HTMLElement }) => void;
// }

// 🔁 实用映射类型示例
// 1. 将所有属性变为可空
type Nullable<T> = {
  [P in keyof T]: T[P] | null;
};

// 2. 将函数返回值包装为 Promise
type Promisify<T> = {
  [P in keyof T]: T[P] extends (...args: infer A) => infer R 
    ? (...args: A) => Promise<R> 
    : T[P];
};

// 3. 创建严格的不可变类型
type Immutable<T> = {
  readonly [P in keyof T]: T[P] extends object ? Immutable<T[P]> : T[P];
};

4. 实用工具类型(Utility Types)🧰

为什么需要工具类型?

工具类型提供了常见的类型转换操作,让类型定义更加简洁和可维护。

解决的问题:

  • 减少重复的类型定义
  • 提供标准的类型转换模式
  • 提高代码的可读性和一致性

缺点与限制:

  • 初学者可能需要时间学习各种工具类型
  • 过度使用可能让代码看起来更复杂
  • 某些工具类型的行为可能不符合直觉
详细代码示例
interface User {
  id: number;
  name: string;
  email: string;
  age?: number;
  createdAt: Date;
  updatedAt?: Date;
}

// 🧰 Partial - 所有属性变为可选
type UserUpdate = Partial<User>;
// 等价于:
// {
//   id?: number;
//   name?: string;
//   email?: string;
//   age?: number;
//   createdAt?: Date;
//   updatedAt?: Date;
// }

// 🧰 Required - 所有属性变为必需
type CompleteUser = Required<User>;
// 等价于:
// {
//   id: number;
//   name: string;
//   email: string;
//   age: number;
//   createdAt: Date;
//   updatedAt: Date;
// }

// 🧰 Pick - 选择特定属性
type UserBasicInfo = Pick<User, 'id' | 'name'>;
// 等价于:
// {
//   id: number;
//   name: string;
// }

// 🧰 Omit - 排除特定属性
type UserWithoutDates = Omit<User, 'createdAt' | 'updatedAt'>;
// 等价于:
// {
//   id: number;
//   name: string;
//   email: string;
//   age?: number;
// }

// 🧰 Record - 创建键值映射
type UserMap = Record<number, User>;
// 等价于:
// {
//   [key: number]: User;
// }

type StatusMap = Record<'success' | 'error' | 'loading', boolean>;
// 等价于:
// {
//   success: boolean;
//   error: boolean;
//   loading: boolean;
// }

// 🧰 Extract - 提取匹配的类型
type StringKeys = Extract<keyof User, string>;
// 从 'id' | 'name' | 'email' | 'age' | 'createdAt' | 'updatedAt'
// 提取出所有字符串键(这里全部都是)

// 🧰 Exclude - 排除匹配的类型
type NonFunctionKeys = Exclude<keyof User, Function>;
// 排除函数类型的键(这里没有函数,所以返回所有键)

// 🧰 工具类型组合使用
// 创建用户表单数据类型
type UserFormData = Partial<Pick<User, 'name' | 'email' | 'age'>>;
// 等价于:
// {
//   name?: string;
//   email?: string;
//   age?: number;
// }

// 创建 API 响应类型
type ApiResponse<T> = {
  data: T;
  success: boolean;
  message?: string;
};

type UserResponse = ApiResponse<Omit<User, 'password'>>;

// 🧰 自定义工具类型
// 1. 值类型为特定类型的属性键
type KeysOfType<T, U> = {
  [K in keyof T]: T[K] extends U ? K : never;
}[keyof T];

type StringKeysOfUser = KeysOfType<User, string>; 
// "name" | "email"

// 2. 深度只读
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object 
    ? DeepReadonly<T[P]> 
    : T[P];
};

// 3. 异步函数包装
type AsyncFunction<T extends (...args: any[]) => any> = 
  (...args: Parameters<T>) => Promise<ReturnType<T>>;

5. 模板字面量类型 ✂️

为什么需要模板字面量类型?

模板字面量类型允许在类型级别进行字符串操作,创建精确的字符串字面量类型。

解决的问题:

  • 创建精确的字符串联合类型
  • 基于模式生成类型安全的字符串
  • 减少运行时字符串验证的需要

缺点与限制:

  • 复杂的模板类型可能影响编译性能
  • 错误信息可能难以理解
  • 某些字符串操作在类型级别有限制
详细代码示例
// ✂️ 基础模板字面量类型
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiVersion = 'v1' | 'v2' | 'v3';

type ApiEndpoint = `/${ApiVersion}/${string}`;
type FullEndpoint = `${HttpMethod} ${ApiEndpoint}`;

// 使用示例
type UserEndpoint = `GET /v1/users` | `POST /v1/users` | `GET /v1/users/${string}`;

// ✂️ 字符串操作类型
// Uppercase, Lowercase, Capitalize, Uncapitalize
type UpperCaseMethod = Uppercase<HttpMethod>; 
// "GET" | "POST" | "PUT" | "DELETE" | "PATCH"

type EventName = 'click' | 'change' | 'submit';
type EventHandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onChange" | "onSubmit"

// ✂️ 路径参数提取
type ExtractPathParams<T extends string> = 
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractPathParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
    ? Param
    : never;

type RouteParams = ExtractPathParams<'/users/:userId/posts/:postId'>;
// "userId" | "postId"

// ✂️ 配置键生成
type FeatureFlags = 'darkMode' | 'notifications' | 'analytics';
type ConfigKeys = `feature_${Uppercase<FeatureFlags>}`;
// "feature_DARKMODE" | "feature_NOTIFICATIONS" | "feature_ANALYTICS"

// ✂️ CSS 类名生成
type Color = 'primary' | 'secondary' | 'success' | 'danger';
type Size = 'sm' | 'md' | 'lg';

type ButtonClass = `btn-${Color}-${Size}`;
// "btn-primary-sm" | "btn-primary-md" | "btn-primary-lg" | ...

// ✂️ 高级模式匹配
type ParseQueryString<T extends string> = 
  T extends `${infer Key}=${infer Value}&${infer Rest}`
    ? { [K in Key]: Value } & ParseQueryString<Rest>
    : T extends `${infer Key}=${infer Value}`
    ? { [K in Key]: Value }
    : {};

type QueryParams = ParseQueryString<'name=John&age=30&city=NY'>;
// 等价于:
// {
//   name: "John";
//   age: "30";
//   city: "NY";
// }

// ✂️ 自动生成 API 客户端类型
type Resource = 'users' | 'posts' | 'comments';
type Action = 'create' | 'read' | 'update' | 'delete';

type ApiAction<T extends Resource> = {
  [K in Action as `${K}${Capitalize<T>}`]: () => Promise<void>;
};

type UserApi = ApiAction<'users'>;
// 等价于:
// {
//   createUsers: () => Promise<void>;
//   readUsers: () => Promise<void>;
//   updateUsers: () => Promise<void>;
//   deleteUsers: () => Promise<void>;
// }

6. 类型推断与保护 🔎

为什么需要类型保护?

类型保护允许我们在运行时检查值的类型,并让 TypeScript 编译器理解这些检查,从而在特定代码块中缩小类型范围。

解决的问题:

  • 安全地处理联合类型
  • 减少类型断言的使用
  • 提供更好的开发体验和代码安全性

缺点与限制:

  • 需要编写额外的运行时检查代码
  • 复杂的类型保护可能难以维护
  • 某些模式可能无法被 TypeScript 正确推断
详细代码示例
// 🔎 基础类型保护
const isString = (value: unknown): value is string => {
  return typeof value === 'string';
};

const isNumber = (value: unknown): value is number => {
  return typeof value === 'number' && !isNaN(value);
};

const isArray = <T>(value: unknown): value is T[] => {
  return Array.isArray(value);
};

// 🔎 自定义类型保护
interface Cat {
  type: 'cat';
  meow(): void;
  climbTrees(): void;
}

interface Dog {
  type: 'dog';
  bark(): void;
  fetch(): void;
}

type Animal = Cat | Dog;

const isCat = (animal: Animal): animal is Cat => {
  return animal.type === 'cat';
};

const isDog = (animal: Animal): animal is Dog => {
  return animal.type === 'dog';
};

function handleAnimal(animal: Animal) {
  if (isCat(animal)) {
    animal.meow();        // TypeScript 知道这是 Cat
    animal.climbTrees();  // 可以安全调用
  } else {
    animal.bark();        // TypeScript 知道这是 Dog
    animal.fetch();       // 可以安全调用
  }
}

// 🔎  discriminated unions(可区分联合)
type NetworkState = 
  | { state: 'loading' }
  | { state: 'success'; data: string }
  | { state: 'error'; error: Error };

function handleNetworkState(state: NetworkState) {
  switch (state.state) {
    case 'loading':
      console.log('Loading...');
      break;
    case 'success':
      console.log('Data:', state.data);  // TypeScript 知道有 data 属性
      break;
    case 'error':
      console.log('Error:', state.error.message);  // TypeScript 知道有 error 属性
      break;
  }
}

// 🔎 使用 in 操作符进行类型保护
interface AdminUser {
  role: 'admin';
  permissions: string[];
  manageUsers(): void;
}

interface RegularUser {
  role: 'user';
  preferences: object;
}

type User = AdminUser | RegularUser;

function handleUser(user: User) {
  if ('permissions' in user) {
    user.manageUsers();  // TypeScript 知道这是 AdminUser
  } else {
    console.log(user.preferences);  // TypeScript 知道这是 RegularUser
  }
}

// 🔎 类型断言的最佳实践
// 方式1: as 语法
const element1 = document.getElementById('my-input') as HTMLInputElement;

// 方式2: 尖括号语法(不推荐在 JSX 中使用)
const element2 = <HTMLInputElement>document.getElementById('my-input');

// 方式3: 非空断言(谨慎使用)
const element3 = document.getElementById('my-input')!;

// 方式4: 安全的类型断言函数
function assertIsHTMLElement(element: unknown): asserts element is HTMLElement {
  if (!(element instanceof HTMLElement)) {
    throw new Error('Not an HTMLElement');
  }
}

const element4 = document.getElementById('my-input');
assertIsHTMLElement(element4);
element4.style.color = 'red';  // 现在可以安全访问

// 🔎 复杂的类型保护示例
interface ApiSuccess<T> {
  status: 'success';
  data: T;
  timestamp: Date;
}

interface ApiError {
  status: 'error';
  error: string;
  code: number;
}

type ApiResponse<T> = ApiSuccess<T> | ApiError;

function isApiSuccess<T>(
  response: ApiResponse<T>
): response is ApiSuccess<T> {
  return response.status === 'success';
}

async function fetchData<T>(url: string): Promise<T> {
  const response: ApiResponse<T> = await fetch(url).then(res => res.json());
  
  if (isApiSuccess(response)) {
    return response.data;  // TypeScript 知道这是 ApiSuccess<T>
  } else {
    throw new Error(`API Error ${response.code}: ${response.error}`);
  }
}

// 🔎 类型保护与错误处理
class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

class NetworkError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NetworkError';
  }
}

type AppError = ValidationError | NetworkError;

const isValidationError = (error: Error): error is ValidationError => {
  return error.name === 'ValidationError';
};

function handleError(error: AppError) {
  if (isValidationError(error)) {
    console.log('Validation error:', error.message);
    // 可以访问 ValidationError 特有的属性或方法
  } else {
    console.log('Network error:', error.message);
    // 可以访问 NetworkError 特有的属性或方法
  }
}

测试策略 🧪

测试金字塔架构

graph TB
    A[单元测试 70%] --> B[集成测试 20%]
    B --> C[E2E 测试 10%]
    
    subgraph A [单元测试 - 快速反馈]
        A1[工具函数]
        A2[React 组件]
        A3[自定义 Hooks]
        A4[工具类]
    end
    
    subgraph B [集成测试 - 模块协作]
        B1[组件集成]
        B2[API 集成]
        B3[状态管理]
        B4[路由测试]
    end
    
    subgraph C [E2E 测试 - 用户流程]
        C1[关键业务流程]
        C2[跨页面交互]
        C3[性能测试]
        C4[兼容性测试]
    end
    
    style A fill:#e3f2fd
    style B fill:#f3e5f5
    style C fill:#e8f5e8

1. 单元测试:Jest + React Testing Library

为什么需要单元测试?

单元测试确保代码的最小单元(函数、组件)按预期工作,提供快速反馈和代码质量保障。

解决的问题:

  • 快速发现回归问题
  • 提供代码文档和示例
  • 支持重构和代码演进

缺点与限制:

  • 不能完全模拟真实用户行为
  • 过度 mock 可能导致测试与实现耦合
  • 维护测试需要额外工作量
详细配置与示例
// 🧪 Jest 配置文件示例
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.tsx',
    '!src/reportWebVitals.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  transform: {
    '^.+\.(ts|tsx)$': 'ts-jest',
  },
};

// 🧪 测试工具函数示例
// src/utils/format.test.ts
import { formatDate, capitalize, debounce } from './format';

describe('format utilities', () => {
  describe('formatDate', () => {
    it('格式化日期为 YYYY-MM-DD', () => {
      const date = new Date('2023-12-25');
      expect(formatDate(date)).toBe('2023-12-25');
    });

    it('处理无效日期', () => {
      expect(formatDate(new Date('invalid'))).toBe('Invalid Date');
    });
  });

  describe('capitalize', () => {
    it('将字符串首字母大写', () => {
      expect(capitalize('hello world')).toBe('Hello world');
    });

    it('处理空字符串', () => {
      expect(capitalize('')).toBe('');
    });
  });

  describe('debounce', () => {
    jest.useFakeTimers();

    it('防抖函数延迟执行', () => {
      const mockFn = jest.fn();
      const debouncedFn = debounce(mockFn, 100);

      debouncedFn();
      debouncedFn();
      debouncedFn();

      expect(mockFn).not.toHaveBeenCalled();

      jest.advanceTimersByTime(100);
      expect(mockFn).toHaveBeenCalledTimes(1);
    });
  });
});

// 🧪 React 组件测试示例
// src/components/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button Component', () => {
  const defaultProps = {
    onClick: jest.fn(),
    children: 'Click me',
  };

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('渲染按钮文本', () => {
    render(<Button {...defaultProps} />);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('点击时触发回调', async () => {
    const user = userEvent.setup();
    render(<Button {...defaultProps} />);

    await user.click(screen.getByRole('button'));
    expect(defaultProps.onClick).toHaveBeenCalledTimes(1);
  });

  it('禁用状态下不触发点击', async () => {
    const user = userEvent.setup();
    render(<Button {...defaultProps} disabled />);

    await user.click(screen.getByRole('button'));
    expect(defaultProps.onClick).not.toHaveBeenCalled();
  });

  it('显示加载状态', () => {
    render(<Button {...defaultProps} loading />);
    
    const button = screen.getByRole('button');
    expect(button).toBeDisabled();
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
  });

  it('应用正确的 CSS 类', () => {
    render(<Button {...defaultProps} variant="primary" size="large" />);
    
    const button = screen.getByRole('button');
    expect(button).toHaveClass('btn-primary', 'btn-large');
  });
});

// 🧪 自定义 Hook 测试
// src/hooks/useCounter/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('使用初始值初始化', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });

  it('默认初始值为 0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('递增计数器', () => {
    const { result } = renderHook(() => useCounter(0));
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });

  it('递减计数器', () => {
    const { result } = renderHook(() => useCounter(2));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(1);
  });

  it('重置计数器', () => {
    const { result } = renderHook(() => useCounter(10));
    
    act(() => {
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });

  it('设置特定值', () => {
    const { result } = renderHook(() => useCounter(0));
    
    act(() => {
      result.current.setCount(42);
    });
    
    expect(result.current.count).toBe(42);
  });
});

2. 集成测试 🔗

为什么需要集成测试?

集成测试验证多个模块如何协同工作,确保系统各部分正确集成。

解决的问题:

  • 发现模块间的集成问题
  • 验证数据流和状态管理
  • 确保 API 集成正常工作

缺点与限制:

  • 执行速度比单元测试慢
  • 设置和维护更复杂
  • 可能需要真实的外部依赖
详细代码示例
// 🔗 组件集成测试示例
// src/components/UserProfile/UserProfile.integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import { UserProvider } from '@/contexts/UserContext';
import { NotificationProvider } from '@/contexts/NotificationContext';
import { server } from '@/mocks/server';

// 设置 API Mock
beforeAll(() => server.listen());
afterEach(() => {
  server.resetHandlers();
  jest.clearAllMocks();
});
afterAll(() => server.close());

describe('UserProfile Integration', () => {
  const renderWithProviders = (component: React.ReactElement) => {
    return render(
      <UserProvider>
        <NotificationProvider>
          {component}
        </NotificationProvider>
      </UserProvider>
    );
  };

  it('加载并显示用户信息', async () => {
    renderWithProviders(<UserProfile userId="123" />);

    // 验证加载状态
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();

    // 等待数据加载完成
    await waitFor(() => {
      expect(screen.getByText('张三')).toBeInTheDocument();
    });

    // 验证用户信息显示
    expect(screen.getByText('zhangsan@example.com')).toBeInTheDocument();
    expect(screen.getByText('高级用户')).toBeInTheDocument();
  });

  it('编辑用户信息', async () => {
    const user = userEvent.setup();
    renderWithProviders(<UserProfile userId="123" />);

    // 等待数据加载
    await screen.findByText('张三');

    // 点击编辑按钮
    await user.click(screen.getByRole('button', { name: /编辑/i }));

    // 验证表单显示
    expect(screen.getByLabelText(/姓名/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/邮箱/i)).toBeInTheDocument();

    // 修改信息
    await user.clear(screen.getByLabelText(/姓名/i));
    await user.type(screen.getByLabelText(/姓名/i), '李四');

    // 提交表单
    await user.click(screen.getByRole('button', { name: /保存/i }));

    // 验证成功消息
    await waitFor(() => {
      expect(screen.getByText('用户信息更新成功')).toBeInTheDocument();
    });

    // 验证数据更新
    expect(screen.getByText('李四')).toBeInTheDocument();
  });

  it('处理网络错误', async () => {
    // 模拟 API 错误
    server.use(
      rest.get('/api/users/123', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ error: '服务器错误' }));
      })
    );

    renderWithProviders(<UserProfile userId="123" />);

    // 验证错误处理
    await waitFor(() => {
      expect(screen.getByText('加载失败,请重试')).toBeInTheDocument();
    });

    // 验证重试功能
    const retryButton = screen.getByRole('button', { name: /重试/i });
    await userEvent.click(retryButton);

    // 注意:这里需要重新 mock 成功的响应
  });
});

// 🔗 API 集成测试
// src/services/api.integration.test.ts
import { fetchUser, updateUser, deleteUser } from './userApi';
import { server } from '@/mocks/server';

describe('User API Integration', () => {
  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());

  it('成功获取用户信息', async () => {
    const user = await fetchUser('123');
    
    expect(user).toEqual({
      id: '123',
      name: '测试用户',
      email: 'test@example.com',
      role: 'user',
    });
  });

  it('处理 404 错误', async () => {
    server.use(
      rest.get('/api/users/999', (req, res, ctx) => {
        return res(ctx.status(404));
      })
    );

    await expect(fetchUser('999')).rejects.toThrow('用户不存在');
  });

  it('更新用户信息', async () => {
    const updates = { name: '新名字', email: 'new@example.com' };
    const updatedUser = await updateUser('123', updates);
    
    expect(updatedUser.name).toBe('新名字');
    expect(updatedUser.email).toBe('new@example.com');
  });
});

// 🔗 状态管理集成测试
// src/store/userStore.integration.test.ts
import { renderHook, act } from '@testing-library/react';
import { useUserStore } from './userStore';
import { server } from '@/mocks/server';

describe('User Store Integration', () => {
  beforeAll(() => server.listen());
  afterEach(() => {
    server.resetHandlers();
    // 重置 store 状态
    const { result } = renderHook(() => useUserStore());
    act(() => result.current.reset());
  });
  afterAll(() => server.close());

  it('登录流程', async () => {
    const { result } = renderHook(() => useUserStore());

    expect(result.current.user).toBeNull();
    expect(result.current.isLoading).toBe(false);

    // 执行登录
    await act(async () => {
      await result.current.login('test@example.com', 'password');
    });

    // 验证登录结果
    expect(result.current.user).toEqual({
      id: '123',
      name: '测试用户',
      email: 'test@example.com',
    });
    expect(result.current.isLoading).toBe(false);
  });

  it('登录失败处理', async () => {
    // 模拟登录失败
    server.use(
      rest.post('/api/login', (req, res, ctx) => {
        return res(ctx.status(401), ctx.json({ error: '认证失败' }));
      })
    );

    const { result } = renderHook(() => useUserStore());

    await act(async () => {
      await expect(
        result.current.login('wrong@example.com', 'wrong')
      ).rejects.toThrow('认证失败');
    });

    expect(result.current.user).toBeNull();
    expect(result.current.error).toBe('认证失败');
  });
});

3. E2E 测试:Playwright 🌍

为什么需要 E2E 测试?

E2E 测试模拟真实用户行为,验证整个应用程序从开始到结束的工作流程。

解决的问题:

  • 验证完整的用户流程
  • 发现集成和环境相关问题
  • 确保关键业务功能正常工作

缺点与限制:

  • 执行速度最慢
  • 测试脆弱,容易受 UI 变化影响
  • 调试和维护成本较高
详细配置与示例
// 🌍 Playwright 配置文件
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results.json' }],
  ],
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

// 🌍 关键用户流程测试
// e2e/critical-flows.spec.ts
import { test, expect } from '@playwright/test';

test.describe('关键用户流程', () => {
  test('用户完整注册流程', async ({ page }) => {
    // 1. 访问首页
    await page.goto('/');
    await expect(page).toHaveTitle('我的应用');
    
    // 2. 导航到注册页
    await page.click('text=注册');
    await expect(page).toHaveURL(/.*\/register/);
    
    // 3. 填写注册表单
    await page.fill('[data-testid="email"]', 'test@example.com');
    await page.fill('[data-testid="password"]', 'Password123!');
    await page.fill('[data-testid="confirmPassword"]', 'Password123!');
    await page.fill('[data-testid="fullName"]', '测试用户');
    
    // 4. 提交表单
    await page.click('button[type="submit"]');
    
    // 5. 验证重定向和成功消息
    await expect(page).toHaveURL(/.*\/dashboard/);
    await expect(page.locator('[data-testid="welcome-message"]'))
      .toContainText('欢迎,测试用户');
  });

  test('购物车完整流程', async ({ page }) => {
    await page.goto('/products');
    
    // 1. 浏览商品
    await expect(page.locator('[data-testid="product-list"]')).toBeVisible();
    
    // 2. 搜索商品
    await page.fill('[data-testid="search-input"]', '笔记本电脑');
    await page.click('[data-testid="search-button"]');
    
    // 3. 添加商品到购物车
    const firstProduct = page.locator('[data-testid="product-item"]').first();
    await firstProduct.locator('[data-testid="add-to-cart"]').click();
    
    // 验证购物车数量更新
    await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');
    
    // 4. 前往购物车
    await page.click('[data-testid="cart-icon"]');
    await expect(page).toHaveURL(/.*\/cart/);
    
    // 5. 验证购物车内容
    await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1);
    await expect(page.locator('[data-testid="total-price"]')).toBeVisible();
    
    // 6. 结账流程
    await page.click('text=去结账');
    await expect(page).toHaveURL(/.*\/checkout/);
    
    // 7. 填写配送信息
    await page.fill('[data-testid="shipping-name"]', '收货人');
    await page.fill('[data-testid="shipping-address"]', '收货地址');
    await page.fill('[data-testid="shipping-phone"]', '13800138000');
    
    // 8. 选择支付方式并提交订单
    await page.click('[data-testid="payment-method-alipay"]');
    await page.click('[data-testid="place-order"]');
    
    // 9. 验证订单完成
    await expect(page).toHaveURL(/.*\/order-success/);
    await expect(page.locator('[data-testid="success-message"]'))
      .toContainText('订单提交成功');
  });

  test('用户登录和权限控制', async ({ page }) => {
    // 1. 访问受保护页面
    await page.goto('/dashboard');
    
    // 2. 验证重定向到登录页
    await expect(page).toHaveURL(/.*\/login/);
    
    // 3. 登录
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password');
    await page.click('button[type="submit"]');
    
    // 4. 验证成功登录并重定向
    await expect(page).toHaveURL(/.*\/dashboard/);
    
    // 5. 验证用户菜单显示
    await page.click('[data-testid="user-menu"]');
    await expect(page.locator('[data-testid="user-name"]'))
      .toContainText('当前用户');
  });
});

// 🌍 页面对象模型 (Page Object Model)
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[data-testid="email"]');
    this.passwordInput = page.locator('[data-testid="password"]');
    this.submitButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorMessage() {
    return this.errorMessage.textContent();
  }
}

// 🌍 使用页面对象的测试
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('登录功能', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('成功登录', async ({ page }) => {
    await loginPage.login('user@example.com', 'password');
    
    await expect(page).toHaveURL(/.*\/dashboard/);
    await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
  });

  test('登录失败显示错误信息', async () => {
    await loginPage.login('wrong@example.com', 'wrong');
    
    const errorMessage = await loginPage.getErrorMessage();
    expect(errorMessage).toContain('邮箱或密码错误');
  });

  test('表单验证', async () => {
    await loginPage.login('', '');
    
    await expect(loginPage.emailInput).toHaveAttribute('aria-invalid', 'true');
    await expect(loginPage.passwordInput).toHaveAttribute('aria-invalid', 'true');
  });
});

// 🌍 CI 集成配置
// .github/workflows/e2e.yml
name: E2E Tests
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Install Playwright
        run: npx playwright install --with-deps
        
      - name: Build application
        run: npm run build
        
      - name: Run E2E tests
        run: npx playwright test
        
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

4. 视觉回归测试 🖼️

为什么需要视觉测试?

视觉测试确保 UI 组件在不同版本间保持一致的视觉外观,捕捉意外的样式变化。

解决的问题:

  • 检测意外的视觉回归
  • 确保跨浏览器一致性
  • 验证响应式设计

缺点与限制:

  • 对微小变化敏感,可能产生误报
  • 需要维护基线图片
  • 执行速度较慢
详细配置与示例
// 🖼️ Storybook 配置
// .storybook/main.js
module.exports = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-a11y',
    '@storybook/addon-interactions',
    '@storybook/addon-viewport',
  ],
  framework: '@storybook/react-vite',
  typescript: {
    check: false,
    reactDocgen: 'react-docgen-typescript',
  },
  staticDirs: ['../public'],
};

// 🖼️ 组件 Stories
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'UI/Button',
  component: Button,
  parameters: {
    layout: 'centered',
    chromatic: { 
      disable: false,
      viewports: [375, 768, 1200],
    },
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: { type: 'select' },
      options: ['small', 'medium', 'large'],
    },
    disabled: {
      control: { type: 'boolean' },
    },
    loading: {
      control: { type: 'boolean' },
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: '主要按钮',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: '次要按钮',
  },
};

export const Danger: Story = {
  args: {
    variant: 'danger',
    children: '危险操作',
  },
};

export const Small: Story = {
  args: {
    size: 'small',
    children: '小按钮',
  },
};

export const Large: Story = {
  args: {
    size: 'large',
    children: '大按钮',
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
    children: '禁用按钮',
  },
};

export const Loading: Story = {
  args: {
    loading: true,
    children: '加载中',
  },
};

// 🖼️ 交互测试 Stories
// src/components/Modal/Modal.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within, expect } from '@storybook/test';
import { Modal } from './Modal';

const meta: Meta<typeof Modal> = {
  title: 'UI/Modal',
  component: Modal,
  parameters: {
    layout: 'centered',
  },
};

export default meta;
type Story = StoryObj<typeof Modal>;

export const Default: Story = {
  args: {
    title: '示例弹窗',
    children: '这是弹窗的内容',
    isOpen: true,
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // 验证弹窗标题
    await expect(canvas.getByText('示例弹窗')).toBeInTheDocument();
    
    // 验证弹窗内容
    await expect(canvas.getByText('这是弹窗的内容')).toBeInTheDocument();
  },
};

export const WithInteractions: Story = {
  args: {
    title: '交互测试',
    children: '点击关闭按钮应该关闭弹窗',
    isOpen: true,
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    
    // 点击关闭按钮
    const closeButton = canvas.getByLabelText('关闭');
    await userEvent.click(closeButton);
    
    // 验证 onClose 被调用
    await expect(args.onClose).toHaveBeenCalled();
  },
};

// 🖼️ Chromatic 配置
// .storybook/chromatic.config.js
import { defineConfig } from 'chromatic';

export default defineConfig({
  projectId: 'your-project-id',
  storybook: {
    build: {
      outputDir: 'storybook-static',
    },
  },
  // 只在 main 分支上自动接受更改
  autoAcceptChanges: process.env.BRANCH === 'main',
  // 设置视觉测试的阈值
  diffThreshold: 0.2,
  // 需要手动审核的 stories
  storiesToReview: [
    'UI/Button--Primary',
    'UI/Modal--Default',
  ],
});

// 🖼️ package.json 脚本
{
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "chromatic": "chromatic --exit-zero-on-changes",
    "test:visual": "npm run build-storybook && chromatic"
  }
}

// 🖼️ CI 集成配置
// .github/workflows/visual.yml
name: Visual Tests
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  visual:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          
      - uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Build Storybook
        run: npm run build-storybook
        
      - name: Publish to Chromatic
        uses: chromaui/action@v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          exitOnceUploaded: true
          autoAcceptChanges: ${{ github.ref == 'refs/heads/main' }}

工程规范 📋

1. ESLint 配置 🛡️

为什么需要 ESLint?

ESLint 通过静态分析识别代码中的问题和模式违规,确保代码质量和一致性。

解决的问题:

  • 强制执行编码标准
  • 提前发现潜在错误
  • 保持代码风格一致性

缺点与限制:

  • 配置复杂,学习曲线较陡
  • 可能产生误报或漏报
  • 严格的规则可能影响开发速度
详细配置示例
// .eslintrc.js
module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: './tsconfig.json',
    tsconfigRootDir: __dirname,
    ecmaVersion: 2022,
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true,
    },
  },
  env: {
    browser: true,
    es2022: true,
    node: true,
    jest: true,
  },
  extends: [
    // ESLint 推荐规则
    'eslint:recommended',
    
    // TypeScript 规则
    '@typescript-eslint/recommended',
    '@typescript-eslint/recommended-requiring-type-checking',
    
    // React 规则
    'plugin:react/recommended',
    'plugin:react-hooks/recommended',
    
    // 可访问性规则
    'plugin:jsx-a11y/recommended',
    
    // 导入排序规则
    'plugin:import/errors',
    'plugin:import/warnings',
    'plugin:import/typescript',
    
    // Prettier 兼容(必须放在最后)
    'prettier',
  ],
  plugins: [
    '@typescript-eslint',
    'react',
    'react-hooks',
    'jsx-a11y',
    'import',
    'prettier',
  ],
  rules: {
    // TypeScript 规则
    '@typescript-eslint/no-unused-vars': 'error',
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/prefer-const': 'error',
    '@typescript-eslint/no-floating-promises': 'error',
    '@typescript-eslint/await-thenable': 'error',
    
    // React 规则
    'react/react-in-jsx-scope': 'off',
    'react/prop-types': 'off',
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    
    // 导入规则
    'import/order': [
      'error',
      {
        groups: [
          'builtin',
          'external',
          'internal',
          'parent',
          'sibling',
          'index',
        ],
        'newlines-between': 'always',
        alphabetize: { 
          order: 'asc',
          caseInsensitive: true,
        },
      },
    ],
    'import/no-unresolved': 'error',
    'import/no-cycle': 'error',
    
    // 代码质量规则
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
    'prefer-const': 'error',
    'no-var': 'error',
    'object-shorthand': 'error',
    'prefer-template': 'error',
    
    // Prettier 集成
    'prettier/prettier': 'error',
  },
  settings: {
    react: {
      version: 'detect',
    },
    'import/resolver': {
      typescript: {
        alwaysTryTypes: true,
      },
      node: {
        paths: ['src'],
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
      },
    },
  },
  overrides: [
    {
      files: ['**/*.test.{js,jsx,ts,tsx}'],
      env: {
        jest: true,
      },
      rules: {
        '@typescript-eslint/no-explicit-any': 'off',
      },
    },
    {
      files: ['**/*.stories.{js,jsx,ts,tsx}'],
      rules: {
        'import/no-anonymous-default-export': 'off',
      },
    },
  ],
};

// 自定义 ESLint 规则示例
// eslint-plugin-custom-rules/index.js
module.exports = {
  rules: {
    'no-relative-imports': {
      meta: {
        type: 'problem',
        docs: {
          description: '禁止使用相对路径导入',
          category: 'Best Practices',
          recommended: true,
        },
        messages: {
          noRelativeImports: '请使用绝对路径导入,避免使用相对路径',
        },
      },
      create(context) {
        return {
          ImportDeclaration(node) {
            const importPath = node.source.value;
            
            // 检查是否是相对路径
            if (importPath.startsWith('.')) {
              context.report({
                node,
                messageId: 'noRelativeImports',
              });
            }
          },
        };
      },
    },
    
    'no-hardcoded-colors': {
      meta: {
        type: 'problem',
        docs: {
          description: '禁止硬编码颜色值',
          category: 'Best Practices',
          recommended: true,
        },
        messages: {
          noHardcodedColors: '请使用设计系统中的颜色变量,避免硬编码颜色值',
        },
      },
      create(context) {
        return {
          Literal(node) {
            const value = node.value;
            const colorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|rgb|hsl|rgba|hsla/i;
            
            if (typeof value === 'string' && colorRegex.test(value)) {
              context.report({
                node,
                messageId: 'noHardcodedColors',
              });
            }
          },
        };
      },
    },
  },
};

2. Prettier 配置 🧹

为什么需要 Prettier?

Prettier 自动格式化代码,确保团队代码风格一致,减少格式争议。

解决的问题:

  • 自动统一代码风格
  • 减少代码审查中的格式讨论
  • 提高代码可读性

缺点与限制:

  • 某些自定义格式可能无法配置
  • 可能与现有代码风格冲突
  • 需要团队适应自动化格式
详细配置示例
// .prettierrc.js
module.exports = {
  // 每行最大字符数
  printWidth: 100,
  
  // 缩进使用空格数
  tabWidth: 2,
  
  // 使用空格而不是制表符
  useTabs: false,
  
  // 语句末尾添加分号
  semi: true,
  
  // 使用单引号
  singleQuote: true,
  
  // 对象属性引号使用方式
  quoteProps: 'as-needed',
  
  // JSX 中使用单引号
  jsxSingleQuote: true,
  
  // 尾随逗号(ES5 标准)
  trailingComma: 'es5',
  
  // 对象花括号内的空格
  bracketSpacing: true,
  
  // JSX 标签的闭合括号位置
  bracketSameLine: false,
  
  // 箭头函数参数括号
  arrowParens: 'avoid',
  
  // 格式化范围
  rangeStart: 0,
  rangeEnd: Infinity,
  
  // 不需要在文件顶部添加 @format 标记
  requirePragma: false,
  
  // 不插入 @format 标记
  insertPragma: false,
  
  // 折行标准
  proseWrap: 'preserve',
  
  // HTML 空白敏感性
  htmlWhitespaceSensitivity: 'css',
  
  // Vue 文件脚本和样式标签缩进
  vueIndentScriptAndStyle: false,
  
  // 换行符
  endOfLine: 'lf',
  
  // 嵌入式语言格式化
  embeddedLanguageFormatting: 'auto',
  
  // 单个属性时的括号
  singleAttributePerLine: false,
};

// Prettier 忽略文件
// .prettierignore
# 依赖目录
node_modules/
dist/
build/

# 生成的文件
coverage/
*.log

# 配置文件
*.config.js

# 锁文件
package-lock.json
yarn.lock

# 文档
*.md
*.mdx

# 图片和字体
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.woff
*.woff2

// package.json 中的格式化脚本
{
  "scripts": {
    "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
    "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
    "format:staged": "lint-staged"
  }
}

3. Git Hooks 配置 🪝

为什么需要 Git Hooks?

Git Hooks 在代码提交和推送前自动运行检查,防止低质量代码进入仓库。

解决的问题:

  • 自动化代码质量检查
  • 强制执行代码标准
  • 减少 CI 失败次数

缺点与限制:

  • 可能减慢开发流程
  • 需要团队统一配置
  • 复杂的钩子可能难以调试
详细配置示例
// package.json 中的 Husky 配置
{
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint src --ext .ts,.tsx,.js,.jsx --max-warnings 0",
    "lint:fix": "npm run lint -- --fix",
    "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:ci": "vitest run --coverage",
    "validate": "npm run lint && npm run typecheck && npm run test:ci"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,mdx,css,scss,yml,yaml}": [
      "prettier --write"
    ],
    "*.{ts,tsx}": [
      "bash -c 'npm run typecheck'"
    ]
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "pre-push": "npm run test:ci",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  }
}

// commitlint 配置
// .commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',     // 新功能
        'fix',      // 修复 bug
        'docs',     // 文档更新
        'style',    // 代码格式调整
        'refactor', // 代码重构
        'test',     // 测试相关
        'chore',    // 构建过程或辅助工具变动
        'perf',     // 性能优化
        'ci',       // CI 配置变更
        'revert',   // 回滚提交
      ],
    ],
    'type-case': [2, 'always', 'lower-case'],
    'type-empty': [2, 'never'],
    'scope-case': [2, 'always', 'lower-case'],
    'subject-empty': [2, 'never'],
    'subject-full-stop': [2, 'never', '.'],
    'header-max-length': [2, 'always', 100],
  },
};

// 手动设置 Git Hooks(Husky v8+)
// .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run lint-staged

// .husky/pre-push
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run test:ci

// .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no -- commitlint --edit "$1"

// 自定义 Git Hook 脚本示例
// scripts/pre-commit-check.sh
#!/bin/bash

# 检查是否有未解决的合并冲突
if git grep -l '<<<<<<<' -- ':(exclude)package-lock.json' | grep -q .; then
  echo "错误: 发现未解决的合并冲突"
  git grep -l '<<<<<<<' -- ':(exclude)package-lock.json'
  exit 1
fi

# 检查调试语句
if git diff --cached --name-only | xargs grep -l 'console.log\|debugger' | grep -q .; then
  echo "警告: 发现调试语句"
  git diff --cached --name-only | xargs grep -l 'console.log\|debugger'
  read -p "是否继续提交? (y/n) " -n 1 -r
  echo
  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    exit 1
  fi
fi

# 检查文件大小
MAX_FILE_SIZE=5242880 # 5MB
for file in $(git diff --cached --name-only); do
  if [ -f "$file" ]; then
    size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null)
    if [ "$size" -gt "$MAX_FILE_SIZE" ]; then
      echo "错误: 文件 $file 过大 ($size 字节),最大允许 $MAX_FILE_SIZE 字节"
      exit 1
    fi
  fi
done

echo "预提交检查通过"
exit 0

4. Commitizen 标准化提交 ✍️

为什么需要标准化提交?

标准化提交信息便于自动化生成变更日志、版本管理和代码审查。

解决的问题:

  • 统一的提交信息格式
  • 自动化版本管理
  • 清晰的变更历史

缺点与限制:

  • 需要团队成员适应新流程
  • 可能增加提交的复杂性
  • 某些简单修改可能显得过度正式
详细配置示例
// .cz-config.js
module.exports = {
  types: [
    { value: 'feat', name: 'feat:     新功能' },
    { value: 'fix', name: 'fix:      修复 bug' },
    { value: 'docs', name: 'docs:     文档更新' },
    { value: 'style', name: 'style:    代码格式调整(不影响功能)' },
    { value: 'refactor', name: 'refactor: 代码重构(既不是新功能也不是修复 bug)' },
    { value: 'perf', name: 'perf:     性能优化' },
    { value: 'test', name: 'test:     测试相关' },
    { value: 'chore', name: 'chore:    构建过程或辅助工具变动' },
    { value: 'ci', name: 'ci:        CI 配置变更' },
    { value: 'revert', name: 'revert:   回滚提交' },
  ],
  
  scopes: [
    { name: 'ui', description: '用户界面相关' },
    { name: 'api', description: 'API 相关' },
    { name: 'auth', description: '认证授权相关' },
    { name: 'database', description: '数据库相关' },
    { name: 'config', description: '配置相关' },
    { name: 'deps', description: '依赖更新' },
    { name: 'other', description: '其他' },
  ],
  
  messages: {
    type: '选择提交类型:',
    scope: '选择影响范围 (可选):',
    customScope: '输入自定义范围:',
    subject: '简短描述(必填):\n',
    body: '详细描述(可选). 使用 "|" 换行:\n',
    breaking: '破坏性变化说明(可选):\n',
    footer: '关联关闭的 issue(可选). 例如: #31, #34:\n',
    confirmCommit: '确认提交?',
  },
  
  allowCustomScopes: true,
  allowBreakingChanges: ['feat', 'fix'],
  skipQuestions: ['body', 'footer'],
  subjectLimit: 100,
  
  // 范围验证
  scopeOverrides: {
    fix: [
      { name: 'merge' },
      { name: 'style' },
      { name: 'e2eTest' },
      { name: 'unitTest' },
    ],
  },
};

// 提交信息验证脚本
// scripts/verify-commit-msg.js
const fs = require('fs');
const path = require('path');

// 获取提交信息
const commitMsgFile = process.argv[2];
const commitMsg = fs.readFileSync(commitMsgFile, 'utf8').trim();

// 提交信息格式正则
const commitRegex = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|revert)(\([^)]+\))?: .{1,100}/;

if (!commitRegex.test(commitMsg)) {
  console.error(`
    提交信息格式错误!
    
    正确格式: <type>(<scope>): <subject>
    
    示例:
    - feat(auth): 添加用户登录功能
    - fix(ui): 修复按钮点击无效的问题
    - docs: 更新 README 文档
    
    允许的类型:
    - feat:     新功能
    - fix:      修复 bug
    - docs:     文档更新
    - style:    代码格式调整
    - refactor: 代码重构
    - perf:     性能优化
    - test:     测试相关
    - chore:    构建过程或辅助工具变动
    - ci:       CI 配置变更
    - revert:   回滚提交
  `);
  process.exit(1);
}

console.log('✅ 提交信息格式正确');
process.exit(0);

// 自动化版本管理和变更日志生成
// .versionrc.js
module.exports = {
  types: [
    { type: 'feat', section: '新功能' },
    { type: 'fix', section: 'Bug 修复' },
    { type: 'docs', section: '文档' },
    { type: 'style', section: '代码风格' },
    { type: 'refactor', section: '代码重构' },
    { type: 'perf', section: '性能优化' },
    { type: 'test', section: '测试' },
    { type: 'chore', section: '构建工具' },
    { type: 'ci', section: 'CI 配置' },
    { type: 'revert', section: '回滚' },
  ],
  commitUrlFormat: '{{host}}/{{owner}}/{{repository}}/commit/{{hash}}',
  compareUrlFormat: '{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}',
  issueUrlFormat: '{{host}}/{{owner}}/{{repository}}/issues/{{id}}',
  userUrlFormat: '{{host}}/{{user}}',
};

// package.json 中的相关脚本
{
  "scripts": {
    "commit": "cz",
    "commit:retry": "git add . && cz --retry",
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
    "release": "standard-version",
    "release:minor": "standard-version --release-as minor",
    "release:major": "standard-version --release-as major"
  },
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-conventional-changelog"
    }
  }
}

总结 🎯

通过实施完整的代码质量工程体系,团队可以获得以下收益:

核心优势

  1. 类型安全 - 减少运行时错误,提高代码可靠性
  2. 测试覆盖 - 确保功能正确性,支持持续重构
  3. 规范统一 - 提高代码可读性和可维护性
  4. 自动化流程 - 减少人为错误,提高开发效率

实施建议

  1. 渐进式采用 - 从最急需的环节开始,逐步推广
  2. 团队培训 - 确保所有成员理解并认可质量工程的价值
  3. 持续优化 - 定期回顾和改进质量工程实践
  4. 工具整合 - 将质量检查集成到开发工作流中

成功指标

  • 类型检查通过率 100%
  • 测试覆盖率 > 80%
  • CI/CD 流水线通过率 > 95%
  • 代码审查反馈周期缩短
  • 生产环境 bug 数量显著减少

通过系统化地实施这些代码质量工程实践,团队可以构建出更加健壮、可维护且高质量的软件产品。

JavaScript字符串填充:padStart()方法

作者 CodingGoat
2025年10月13日 22:52

原文:xuanhu.info/projects/it…

JavaScript字符串填充:padStart()方法

在编程实践中,字符串填充是高频操作需求。无论是格式化输出、数据对齐还是生成固定格式标识符,都需要高效可靠的填充方案。本文将深入探讨JavaScript中最优雅的字符串填充方案——padStart()方法,通过理论解析+实战案例带你掌握这一核心技能。

🧩 字符串填充的本质需求

字符串填充指在原始字符串的指定侧添加特定字符直至达到目标长度。常见应用场景包括:

  • 数字补零(如日期格式化 "2023-1-1" → "2023-01-01")
  • 表格数据对齐
  • 生成固定长度交易号
  • 控制台输出美化

🚫 传统填充方案的痛点

在ES2017规范前,开发者通常采用以下方式实现填充:

// 手动实现左填充函数
function leftPad(str, length, padChar = ' ') {
  const padCount = length - str.length;
  return padCount > 0 
    ? padChar.repeat(padCount) + str 
    : str;
}

console.log(leftPad('42', 5, '0')); // "00042"

这种方案存在三大缺陷:

  1. 代码冗余:每个项目需重复实现工具函数
  2. 边界处理复杂:需手动处理超长字符串、空字符等边界情况
  3. 性能瓶颈:大数量级操作时循环效率低下

✨ padStart()方法

ES2017引入的padStart()是String原型链上的原生方法,完美解决上述痛点。

📚 方法参数

/**
 * 字符串起始位置填充
 * @param {number} targetLength - 填充后目标长度
 * @param {string} [padString=' '] - 填充字符(默认空格)
 * @returns {string} 填充后的新字符串
 */
String.prototype.padStart(targetLength, padString);

🔬 核心特性详解

  1. 智能截断:当填充字符串超出需要长度时自动截断

    '7'.padStart(3, 'abcdef'); // "ab7" 
    
  2. 类型安全:自动转换非字符串参数

    const price = 9.9;
    price.toString().padStart(5, '0'); // "09.9"
    
  3. 空值处理:对null/undefined返回原始值

    String(null).padStart(2, '0'); // "null"
    

🚀 应用场景

场景1:数据格式化

// 金额分转元并补零
function formatCurrency(cents) {
  const yuan = (cents / 100).toFixed(2);
  return yuan.padStart(8, ' '); // 对齐到8位
}

console.log(formatCurrency(12345)); // "  123.45"

场景2:二进制数据转换

// 10进制转8位二进制
function toBinary(num) {
  return num.toString(2).padStart(8, '0');
}

console.log(toBinary(42)); // "00101010"

场景3:日志系统对齐

const logLevels = ['DEBUG', 'INFO', 'WARN'];
const messages = ['Starting app', 'User logged in', 'Memory low'];

// 生成对齐的日志输出
logLevels.forEach((level, i) => {
  console.log(
    `[${level.padStart(5)}] ${messages[i].padEnd(20)}`
  );
});
/*
[DEBUG] Starting app        
[ INFO] User logged in      
[ WARN] Memory low          
*/

⚖️ 性能对比测试

通过Benchmark.js对10万次操作进行性能测试:

方法 操作耗时(ms) 内存占用(MB)
手动循环填充 142.5 82.3
Array.join填充 98.7 76.1
padStart 32.8 54.2
pie
    title 各方法CPU耗时占比
    "手动循环填充" : 42
    "Array.join填充" : 29
    "padStart" : 29

🛠️ 进阶技巧与陷阱规避

技巧1:链式填充组合

// 生成银行账号格式:****-****-1234
const lastFour = '1234';
const masked = lastFour
  .padStart(12, '*')      // "********1234"
  .replace(/(.{4})/g, '$1-') // 每4位加分隔符
  .slice(0, -1);          // 移除末尾多余分隔符

console.log(masked); // "****-****-1234"

技巧2:多字符模式填充

// 创建文本装饰线
const title = " CHAPTER 1 ";
console.log(
  title.padStart(30, '═').padEnd(40, '═')
);
// "══════════ CHAPTER 1 ══════════"

⚠️ 常见陷阱及解决方案

  1. 负数长度处理:目标长度小于原字符串时返回原字符串

    'overflow'.padStart(3); // "overflow" 
    
  2. 非字符串填充符:自动调用toString()转换

    '1'.padStart(3, true); // "tr1" 
    
  3. 多字符截断规则:从左向右截取填充字符

    'A'.padStart(5, 'XYZ'); // "XYXYA" 
    

🌐 浏览器兼容性与Polyfill

虽然现代浏览器普遍支持padStart(),但需考虑兼容旧版环境:

// 安全垫片实现
if (!String.prototype.padStart) {
  String.prototype.padStart = function(targetLen, padStr) {
    targetLen = Math.floor(targetLen) || 0;
    if (targetLen <= this.length) return String(this);
    
    padStr = padStr ? String(padStr) : ' ';
    let repeatCnt = Math.ceil((targetLen - this.length) / padStr.length);
    
    return padStr.repeat(repeatCnt).slice(0, targetLen - this.length) 
           + String(this);
  };
}

💡 总结

  1. 优先选择padStart:性能优于手动实现方案
  2. 明确长度预期:提前计算目标长度避免意外截断
  3. 处理特殊字符:对换行符等特殊字符需额外处理
  4. 组合使用padEnd:实现双向填充需求

原文:xuanhu.info/projects/it…

❌
❌