普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月26日掘金 前端

CSS-彻底搞懂选择器优先级与CSS单位

2026年1月26日 15:10

前言

为什么我写的样式不生效?为什么那个单位在移动端会缩放?理解 CSS 的优先级(Specificity)和度量单位,是每一个前端开发者从“画页面”走向“精通布局”的必经之路。

一、 CSS 优先级(权重)全解析

CSS 样式的应用遵循“就近原则”和“权重计算原则”。

1. 权重等级排列

我们将不同选择器划分为不同的等级:

  1. !important:无视规则,权重最高。
  2. 行内样式 (Inline style) :写在标签 style 属性里的样式。
  3. ID 选择器:如 #header
  4. 类、伪类、属性选择器:如 .content:hover[type="text"]
  5. 元素(标签)、伪元素选择器:如 div::before
  6. 通用选择器 (*) 、子选择器 (> )、相邻选择器 (+):权重极低(通常计为 0)。
  7. 继承的样式:权重最低。

2. 权重计算法(三位计数法)

为了方便对比,我们可以给它们分配分值(非绝对数值,仅作逻辑参考):

  • ID 选择器:100
  • 类/属性/伪类:10
  • 标签/伪元素:1

规则:从左往右比较,数值大的获胜。如果数值相等,则后者覆盖前者

⚠️ 注意!important 是“核武器”,如果它用于简写属性(如 background: !important),则其包含的所有子属性(color, image 等)都会获得最高权重,应谨慎使用


二、 CSS 常用度量单位

根据参考参照物的不同,单位可分为以下几类:

1. 绝对单位

  • px (像素) :最常用的基本单位,物理像素点,不随环境改变。

2. 相对单位(基于字体)

  • em:相对于当前元素font-size

    • 注意:如果当前元素未设置,则参考父元素;如果整个页面都没设置,则参考浏览器默认值(16px)。
  • rem (Root em) :相对于根元素 (<html>)font-size。是移动端适配的首选。

3. 相对单位(基于视口)

  • vw / vh:相对于浏览器可视窗口的宽度/高度。1vw 等于视口宽度的 1%。
  • % (百分比) :通常相对于父元素的对应属性(宽度、高度等)。

三、 现代布局黑科技

1. calc() 计算属性

允许在声明 CSS 属性值时执行加减乘除运算。

  • 语法width: calc(100% - 20px);
  • 注意:运算符前后必须保留空格。

2. aspect-ratio 设置宽高比

过去我们需要用 padding-top 技巧来实现等比缩放,现在一行代码搞定:

.box {
  width: 500px;
  aspect-ratio: 16 / 9; /*这时的宽高比就为16比9*/
  background: lightblue;
}

四、 总结:优先级冲突时的排查思路

  1. !important:有没有被强行置顶。
  2. 算权重值:ID > 类 > 标签。
  3. 看顺序:权重相同时,写在后面的样式生效。
  4. 看距离:行内样式 > 内部/外部样式表。
  5. 看加载:内部样式和外联样式的优先级,取决于它们在 HTML 中出现的先后顺序。

学习Three.js--基于GeoJSON绘制2D矢量地图

2026年1月26日 14:59

学习Three.js--基于GeoJSON绘制2D矢量地图

前置核心说明

开发目标

基于Three.js实现纯矢量2D地图,核心能力包括:

  1. 将GeoJSON地理数据(经纬度)转换为Three.js可渲染的平面图形;
  2. 支持墨卡托投影(经纬度→平面坐标)、地图居中缩放适配视口;
  3. 实现鼠标点击省份高亮(填充+边框变色);
  4. 纯2D交互(仅平移/缩放,禁用旋转),模拟传统地图体验。
  5. 开发效果如下

dc087832-635b-4da4-85d7-1d09b70b1e73.png

核心技术栈

技术点 作用
OrthographicCamera(正交相机) 实现无透视的2D效果(无近大远小),是2D地图的核心相机类型
墨卡托投影函数 将地理经纬度(lon/lat)转换为平面笛卡尔坐标(x/y)
Shape/ShapeGeometry 将GeoJSON的多边形坐标转换为Three.js可渲染的几何形状
Raycaster(射线检测) 实现鼠标点击与省份图形的交互(命中检测)
OrbitControls(轨道控制器) 自定义交互规则(禁用旋转,仅保留平移/缩放)
GeoJSON 行业标准地理数据格式,存储省份的多边形坐标信息

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

1.1 核心代码
// 存储所有省份Mesh,用于射线检测
const provinceMeshes = []; 

// 1. 场景初始化(地图容器)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf8fafc); // 浅灰背景

// 2. 正交相机(核心!2D地图必须用正交相机)
const aspect = window.innerWidth / window.innerHeight; // 窗口宽高比
const frustumSize = 800; // 相机视口高度(控制地图初始显示范围)
const camera = new THREE.OrthographicCamera(
  -frustumSize * aspect / 2, // 左边界
  frustumSize * aspect / 2,  // 右边界
  frustumSize / 2,           // 上边界
  -frustumSize / 2,          // 下边界
  1,                         // 近裁切面(最近可见距离)
  1000                       // 远裁切面(最远可见距离)
);
camera.position.set(0, 0, 10); // 相机在Z轴,看向场景中心
camera.lookAt(0, 0, 0);

// 3. 渲染器(抗锯齿+高清适配)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

// 4. 轨道控制器(自定义交互规则)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableRotate = false; // 禁用旋转(纯2D地图)
controls.enablePan = true;     // 启用平移(拖拽地图)
controls.enableZoom = true;    // 启用缩放(滚轮)
controls.zoomSpeed = 1.5;      // 缩放速度(适配体验)
1.2 关键参数解析
  • 正交相机参数:区别于透视相机(PerspectiveCamera),正交相机的视口是矩形,所有物体无论距离远近大小一致,完美适配2D地图;
  • frustumSize:控制相机视口高度,值越大地图初始显示范围越大;
  • 控制器配置:禁用旋转是2D地图的核心要求,避免视角倾斜。

步骤2:墨卡托投影函数(经纬度→平面坐标)

2.1 核心代码
// 墨卡托投影:将经纬度(lon/lat)转换为平面坐标(x/y)
function mercator(lon, lat) {
  const R = 6378137; // WGS84坐标系地球半径(米)
  const x = (lon * Math.PI / 180) * R; // 经度转弧度 × 地球半径
  const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) * R; // 纬度投影公式
  return { x, y }; // y轴北为正,x轴东为正
}
2.2 原理说明
  • 墨卡托投影是地图领域的标准投影方式,将球形地球的经纬度转换为平面矩形坐标;
  • 经度(lon)范围:-180°180°,纬度(lat)范围:-90°90°;
  • 核心公式:
    • 经度转换:直接将角度转为弧度后乘以地球半径;
    • 纬度转换:通过正切+对数函数,解决纬度越靠近极点拉伸越大的问题。

步骤3:GeoJSON加载与全局边界计算

3.1 核心代码
let bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };

// 异步加载GeoJSON并绘制地图
async function loadAndDrawMap() {
  // 1. 加载GeoJSON数据(本地文件)
  const response = await fetch('./china.json');
  const geojson = await response.json();

  // 2. 手动校正港澳位置(墨卡托投影后的偏移,单位:米)
  const SPECIAL_REGION_OFFSETS = {
    '香港特别行政区': { dx: 80000, dy: -60000 },
    '澳门特别行政区': { dx: 100000, dy: -80000 }
  };

  // 3. 遍历所有坐标,计算全局边界(用于地图居中缩放)
  geojson.features.forEach(feature => {
    traverseCoordinates(feature.geometry.coordinates, (lon, lat) => {
      const { x, y } = mercator(lon, lat);
      // 更新边界值(最小/最大x/y)
      bounds.minX = Math.min(bounds.minX, x);
      bounds.maxX = Math.max(bounds.maxX, x);
      bounds.minY = Math.min(bounds.minY, y);
      bounds.maxY = Math.max(bounds.maxY, y);
    });
  });

  // 4. 计算地图中心和缩放比例(适配视口)
  const centerX = (bounds.minX + bounds.maxX) / 2; // 地图中心X
  const centerY = (bounds.minY + bounds.maxY) / 2; // 地图中心Y
  const width = bounds.maxX - bounds.minX;         // 地图宽度
  const height = bounds.maxY - bounds.minY;        // 地图高度
  const scale = 700 / Math.max(width, height);     // 缩放比例(使地图适配视口)

  // 后续创建省份Shape...
}

// 递归遍历GeoJSON坐标(处理Polygon/MultiPolygon嵌套结构)
function traverseCoordinates(coords, callback) {
  if (typeof coords[0] === 'number') {
    // 基础情况:[lon, lat] 数组,执行回调
    callback(coords[0], coords[1]);
  } else {
    // 递归情况:嵌套数组,继续遍历
    coords.forEach(c => traverseCoordinates(c, callback));
  }
}
3.2 关键逻辑解析
  • 边界计算:遍历所有省份的所有坐标,得到地图的最小/最大x/y,用于后续居中;
  • 港澳偏移:解决GeoJSON中港澳坐标投影后位置偏差的问题;
  • 缩放比例700 / Math.max(width, height) 保证地图的最大维度适配视口(700为经验值,可调整);
  • 递归遍历坐标:GeoJSON的Polygon是单层数组,MultiPolygon是双层数组,需递归处理所有嵌套坐标。

步骤4:创建Shape与省份Mesh

4.1 核心代码
// 在loadAndDrawMap函数内,边界计算后执行:
geojson.features.forEach(feature => {
  const shapes = [];
  const provinceName = feature.properties.name;
  const offset = SPECIAL_REGION_OFFSETS[provinceName] || { dx: 0, dy: 0 };

  // 处理单个Polygon(如大多数省份)
  if (feature.geometry.type === 'Polygon') {
    const shape = createShape(feature.geometry.coordinates[0], centerX, centerY, scale, offset);
    if (shape) shapes.push(shape);
  } 
  // 处理MultiPolygon(如包含岛屿的省份:海南、浙江等)
  else if (feature.geometry.type === 'MultiPolygon') {
    feature.geometry.coordinates.forEach(polygon => {
      const shape = createShape(polygon[0], centerX, centerY, scale, offset);
      if (shape) shapes.push(shape);
    });
  }

  // 为每个Shape创建Mesh(填充)和Line(边框)
  shapes.forEach(shape => {
    // 1. 创建填充几何体
    const geometry = new THREE.ShapeGeometry(shape);
    // 2. 填充材质(蓝色,双面渲染)
    const material = new THREE.MeshBasicMaterial({
      color: 0x3b82f6,
      side: THREE.DoubleSide
    });
    // 3. 创建省份Mesh
    const mesh = new THREE.Mesh(geometry, material);
    
    // 关键:绑定省份信息到userData(交互时使用)
    mesh.userData = {
      provinceName: feature.properties.name,
      originalColor: 0x3b82f6,
      isHighlighted: false
    };

    scene.add(mesh);
    provinceMeshes.push(mesh); // 加入数组,用于射线检测

    // 4. 创建白色边框
    const borderGeo = new THREE.BufferGeometry().setFromPoints(shape.getPoints());
    const borderMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
    const border = new THREE.Line(borderGeo, borderMat);
    mesh.border = border; // 边框绑定到Mesh,方便后续修改颜色
    scene.add(border);
  });
});

// 创建Shape:将GeoJSON多边形转换为Three.js Shape
function createShape(ring, centerX, centerY, scale, offset = { dx: 0, dy: 0 }) {
  if (ring.length < 3) return null; // 少于3个点无法构成多边形
  const shape = new THREE.Shape();
  // 转换所有点为平面坐标,并应用居中+缩放+偏移
  const points = ring.map(([lon, lat]) => {
    const { x, y } = mercator(lon, lat);
    const shiftedX = x + offset.dx;
    const shiftedY = y + offset.dy;
    return {
      x: (shiftedX - centerX) * scale, // 居中后缩放
      y: (shiftedY - centerY) * scale
    };
  });
  // 绘制Shape:移动到第一个点,依次连线
  shape.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) {
    shape.lineTo(points[i].x, points[i].y);
  }
  return shape;
}
4.2 关键逻辑解析
  • Shape创建THREE.Shape是Three.js的2D形状对象,通过moveTo+lineTo绘制多边形;
  • MultiPolygon处理:包含多个多边形的省份(如海南=海南岛+南海诸岛),需为每个多边形创建独立Shape;
  • userData绑定:Three.js的Object3D对象可通过userData存储自定义数据,这里绑定省份名称/原始颜色,是交互的核心;
  • 边框创建:通过shape.getPoints()获取Shape的顶点,创建Line实现边框效果。

步骤5:Raycaster射线检测(鼠标点击高亮)

5.1 核心代码
let highlightedProvince = null; // 记录当前高亮的省份

// 鼠标点击事件处理
window.addEventListener('click', onDocumentMouseDown, false);

function onDocumentMouseDown(event) {
  // 1. 将鼠标屏幕坐标转换为NDC(归一化设备坐标,范围-1~1)
  const mouse = new THREE.Vector2();
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  // 2. 创建Raycaster(射线检测)
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(mouse, camera); // 设置射线:从相机到鼠标位置

  // 3. 检测射线与所有省份Mesh的相交
  const intersects = raycaster.intersectObjects(provinceMeshes);

  if (intersects.length > 0) {
    // 点击到省份:切换高亮
    const clickedMesh = intersects[0].object;

    // 取消之前的高亮
    if (highlightedProvince) {
      highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
      if (highlightedProvince.border) {
        highlightedProvince.border.material.color.set(0xffffff);
      }
      highlightedProvince.userData.isHighlighted = false;
    }

    // 高亮当前点击的省份
    clickedMesh.material.color.set(0xffd700); // 金色填充
    if (clickedMesh.border) {
      clickedMesh.border.material.color.set(0xff0000); // 红色边框
    }
    clickedMesh.userData.isHighlighted = true;
    highlightedProvince = clickedMesh;
    console.log('点击了:', clickedMesh.userData.provinceName);
  } else {
    // 点击空白处:取消所有高亮
    if (highlightedProvince) {
      highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
      if (highlightedProvince.border) {
        highlightedProvince.border.material.color.set(0xffffff);
      }
      highlightedProvince.userData.isHighlighted = false;
      highlightedProvince = null;
    }
  }
}
5.2 射线检测核心原理
  • NDC坐标转换:屏幕坐标(clientX/clientY)转换为归一化设备坐标(-1~1),是Raycaster的标准输入;
  • 射线创建raycaster.setFromCamera(mouse, camera) 生成从相机位置指向鼠标位置的射线;
  • 相交检测raycaster.intersectObjects(provinceMeshes) 返回射线与Mesh的相交结果,优先返回最近的Mesh;
  • 高亮逻辑:通过修改材质颜色实现高亮,利用userData存储原始颜色,保证切换回退。

步骤6:窗口适配与渲染循环

6.1 核心代码
// 窗口大小适配
window.addEventListener('resize', () => {
  const aspect = window.innerWidth / window.innerHeight;
  const frustumSize = 800;
  // 更新正交相机边界
  camera.left = -frustumSize * aspect / 2;
  camera.right = frustumSize * aspect / 2;
  camera.top = frustumSize / 2;
  camera.bottom = -frustumSize / 2;
  camera.updateProjectionMatrix(); // 必须更新投影矩阵
  renderer.setSize(window.innerWidth, window.innerHeight); // 更新渲染器尺寸
});

// 渲染循环(持续渲染场景)
function animate() {
  requestAnimationFrame(animate); // 浏览器刷新率同步
  controls.update(); // 更新控制器(阻尼/平移/缩放)
  renderer.render(scene, camera); // 渲染场景
}
animate();

// 启动地图加载
loadAndDrawMap().catch(err => console.error('加载失败:', err));
6.2 关键注意点
  • 投影矩阵更新:正交相机参数修改后,必须调用camera.updateProjectionMatrix()使修改生效;
  • 渲染循环:Three.js需要持续调用renderer.render()才能显示画面,requestAnimationFrame保证帧率稳定。

核心方法/参数速查表

1. 核心类/函数参数

类/函数 关键参数 说明
OrthographicCamera left/right/top/bottom/near/far 正交相机边界,near/far控制可见距离
mercator(lon, lat) lon(经度)、lat(纬度) 返回{x,y}平面坐标,R=6378137(地球半径)
traverseCoordinates(coords, callback) coords(GeoJSON坐标)、callback(遍历回调) 递归处理嵌套坐标,回调参数为lon/lat
createShape(ring, centerX, centerY, scale, offset) ring(多边形坐标环)、centerX/Y(地图中心)、scale(缩放)、offset(偏移) 返回THREE.Shape,实现坐标居中+缩放
Raycaster.setFromCamera(mouse, camera) mouse(NDC坐标)、camera(相机) 创建从相机到鼠标的射线

2. 交互核心参数

参数 作用
mesh.userData 存储省份名称/原始颜色/高亮状态,交互时读取
intersects[0].object 射线检测命中的第一个Mesh(最近的省份)
controls.enableRotate 禁用旋转(false),保证2D地图体验

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 中国地图 - 2D矢量版</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      background: #f8fafc;
    }
    /* 可选:添加加载提示 */
    .loading {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 18px;
      color: #666;
    }
  </style>
</head>
<body>
  <div class="loading">加载地图中...</div>
<script type="module">
  import * as THREE from 'https://esm.sh/three@0.174.0';
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

  // 全局变量:存储所有省份Mesh(用于射线检测)
  const provinceMeshes = [];
  // 全局变量:记录当前高亮的省份
  let highlightedProvince = null;
  // 全局变量:地图边界(用于居中缩放)
  let bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };

  // ========== 1. 初始化基础环境 ==========
  // 场景:所有元素的容器
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf8fafc); // 浅灰背景

  // 正交相机:2D地图核心(无透视)
  const aspect = window.innerWidth / window.innerHeight;
  const frustumSize = 800; // 相机视口高度(控制初始显示范围)
  const camera = new THREE.OrthographicCamera(
    -frustumSize * aspect / 2,
    frustumSize * aspect / 2,
    frustumSize / 2,
    -frustumSize / 2,
    1, 1000
  );
  camera.position.set(0, 0, 10); // 相机在Z轴,看向场景中心
  camera.lookAt(0, 0, 0);

  // 渲染器:抗锯齿+高清适配
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 轨道控制器:自定义2D交互规则
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableRotate = false; // 禁用旋转(纯2D)
  controls.enablePan = true;     // 启用平移(拖拽)
  controls.enableZoom = true;    // 启用缩放(滚轮)
  controls.zoomSpeed = 1.5;      // 缩放速度适配

  // ========== 2. 墨卡托投影函数 ==========
  /**
   * 墨卡托投影:经纬度转平面坐标
   * @param {Number} lon - 经度(-180~180)
   * @param {Number} lat - 纬度(-90~90)
   * @returns {Object} {x, y} 平面坐标(米)
   */
  function mercator(lon, lat) {
    const R = 6378137; // WGS84地球半径
    const x = (lon * Math.PI / 180) * R;
    const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) * R;
    return { x, y };
  }

  // ========== 3. GeoJSON加载与绘制 ==========
  /**
   * 递归遍历GeoJSON坐标(处理Polygon/MultiPolygon嵌套)
   * @param {Array} coords - GeoJSON坐标数组
   * @param {Function} callback - 遍历回调(lon, lat)
   */
  function traverseCoordinates(coords, callback) {
    if (typeof coords[0] === 'number') {
      callback(coords[0], coords[1]);
    } else {
      coords.forEach(c => traverseCoordinates(c, callback));
    }
  }

  /**
   * 创建Three.js Shape(多边形)
   * @param {Array} ring - 单个多边形坐标环
   * @param {Number} centerX - 地图中心X
   * @param {Number} centerY - 地图中心Y
   * @param {Number} scale - 缩放比例
   * @param {Object} offset - 位置偏移(dx, dy)
   * @returns {THREE.Shape|null} 形状对象
   */
  function createShape(ring, centerX, centerY, scale, offset = { dx: 0, dy: 0 }) {
    if (ring.length < 3) return null; // 最少3个点构成多边形
    const shape = new THREE.Shape();
    const points = ring.map(([lon, lat]) => {
      const { x, y } = mercator(lon, lat);
      const shiftedX = x + offset.dx;
      const shiftedY = y + offset.dy;
      // 居中+缩放:转换为相机视口坐标
      return {
        x: (shiftedX - centerX) * scale,
        y: (shiftedY - centerY) * scale
      };
    });
    // 绘制Shape
    shape.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length; i++) {
      shape.lineTo(points[i].x, points[i].y);
    }
    return shape;
  }

  /**
   * 加载GeoJSON并绘制地图
   */
  async function loadAndDrawMap() {
    try {
      // 1. 加载GeoJSON数据
      const response = await fetch('./china.json');
      const geojson = await response.json();

      // 2. 港澳位置偏移(校正投影偏差)
      const SPECIAL_REGION_OFFSETS = {
        '香港特别行政区': { dx: 80000, dy: -60000 },
        '澳门特别行政区': { dx: 100000, dy: -80000 }
      };

      // 3. 遍历所有坐标,计算全局边界
      geojson.features.forEach(feature => {
        traverseCoordinates(feature.geometry.coordinates, (lon, lat) => {
          const { x, y } = mercator(lon, lat);
          bounds.minX = Math.min(bounds.minX, x);
          bounds.maxX = Math.max(bounds.maxX, x);
          bounds.minY = Math.min(bounds.minY, y);
          bounds.maxY = Math.max(bounds.maxY, y);
        });
      });

      // 4. 计算地图中心和缩放比例
      const centerX = (bounds.minX + bounds.maxX) / 2;
      const centerY = (bounds.minY + bounds.maxY) / 2;
      const width = bounds.maxX - bounds.minX;
      const height = bounds.maxY - bounds.minY;
      const scale = 700 / Math.max(width, height); // 适配视口

      // 5. 遍历每个省份,创建Shape和Mesh
      geojson.features.forEach(feature => {
        const shapes = [];
        const provinceName = feature.properties.name;
        const offset = SPECIAL_REGION_OFFSETS[provinceName] || { dx: 0, dy: 0 };

        // 处理Polygon(单多边形)
        if (feature.geometry.type === 'Polygon') {
          const shape = createShape(feature.geometry.coordinates[0], centerX, centerY, scale, offset);
          if (shape) shapes.push(shape);
        }
        // 处理MultiPolygon(多多边形,如海南)
        else if (feature.geometry.type === 'MultiPolygon') {
          feature.geometry.coordinates.forEach(polygon => {
            const shape = createShape(polygon[0], centerX, centerY, scale, offset);
            if (shape) shapes.push(shape);
          });
        }

        // 为每个Shape创建填充和边框
        shapes.forEach(shape => {
          // 填充Mesh
          const geometry = new THREE.ShapeGeometry(shape);
          const material = new THREE.MeshBasicMaterial({
            color: 0x3b82f6,
            side: THREE.DoubleSide
          });
          const mesh = new THREE.Mesh(geometry, material);

          // 绑定自定义数据(交互用)
          mesh.userData = {
            provinceName: provinceName,
            originalColor: 0x3b82f6,
            isHighlighted: false
          };

          scene.add(mesh);
          provinceMeshes.push(mesh);

          // 白色边框
          const borderGeo = new THREE.BufferGeometry().setFromPoints(shape.getPoints());
          const borderMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
          const border = new THREE.Line(borderGeo, borderMat);
          mesh.border = border;
          scene.add(border);
        });
      });

      // 隐藏加载提示
      document.querySelector('.loading').style.display = 'none';
    } catch (err) {
      console.error('地图加载失败:', err);
      document.querySelector('.loading').textContent = '加载失败,请检查GeoJSON文件';
    }
  }

  // ========== 4. 鼠标点击交互(Raycaster) ==========
  /**
   * 鼠标点击事件处理:省份高亮
   * @param {MouseEvent} event - 鼠标事件
   */
  function onDocumentMouseDown(event) {
    // 1. 转换鼠标坐标为NDC(-1~1)
    const mouse = new THREE.Vector2();
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    // 2. 创建射线检测
    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(mouse, camera);

    // 3. 检测与省份Mesh的相交
    const intersects = raycaster.intersectObjects(provinceMeshes);

    if (intersects.length > 0) {
      // 点击到省份:切换高亮
      const clickedMesh = intersects[0].object;

      // 取消之前的高亮
      if (highlightedProvince) {
        highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
        highlightedProvince.border.material.color.set(0xffffff);
        highlightedProvince.userData.isHighlighted = false;
      }

      // 高亮当前省份
      clickedMesh.material.color.set(0xffd700); // 金色填充
      clickedMesh.border.material.color.set(0xff0000); // 红色边框
      clickedMesh.userData.isHighlighted = true;
      highlightedProvince = clickedMesh;
      console.log('点击省份:', clickedMesh.userData.provinceName);
    } else {
      // 点击空白处:取消所有高亮
      if (highlightedProvince) {
        highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
        highlightedProvince.border.material.color.set(0xffffff);
        highlightedProvince.userData.isHighlighted = false;
        highlightedProvince = null;
      }
    }
  }
  window.addEventListener('click', onDocumentMouseDown, false);

  // ========== 5. 窗口适配与渲染循环 ==========
  // 窗口大小变化适配
  window.addEventListener('resize', () => {
    const aspect = window.innerWidth / window.innerHeight;
    camera.left = -frustumSize * aspect / 2;
    camera.right = frustumSize * aspect / 2;
    camera.top = frustumSize / 2;
    camera.bottom = -frustumSize / 2;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  // 渲染循环
  function animate() {
    requestAnimationFrame(animate);
    controls.update(); // 更新控制器
    renderer.render(scene, camera); // 渲染场景
  }
  animate();

  // 启动地图加载
  loadAndDrawMap();
</script>
</body>
</html>

总结与扩展建议

核心总结

  1. 2D地图核心:正交相机(OrthographicCamera)是实现无透视2D效果的关键,区别于透视相机;
  2. 坐标转换:墨卡托投影是地理数据可视化的基础,需掌握经纬度→平面坐标的转换逻辑;
  3. GeoJSON处理:递归遍历嵌套坐标、计算全局边界是地图居中缩放的核心;
  4. 交互实现Raycaster射线检测是Three.js实现鼠标点击交互的标准方式,userData是存储自定义数据的最佳实践;
  5. 性能优化:复用几何体/材质、减少不必要的顶点数,可提升大地图的渲染性能。

扩展建议

  1. 添加省份标签:基于省份中心坐标创建CSS2DLabel,显示省份名称;
  2. hover高亮:监听mousemove事件,实现鼠标悬浮高亮;
  3. 数据可视化:根据省份数据(如GDP、人口)动态修改填充颜色;
  4. 层级优化:为南海诸岛等小区域单独缩放,提升显示效果;
  5. 性能优化:使用BufferGeometry替代ShapeGeometry,减少内存占用;
  6. 地图交互增强:添加缩放限制(最小/最大缩放)、地图复位按钮。

CSS-深度解析伪类与伪元素

2026年1月26日 14:56

前言

在 CSS 的世界里,单冒号 : 和双冒号 :: 并不是随心所欲使用的。它们分别代表了伪类伪元素。理解它们的区别,不仅能帮你写出更优雅的选择器,还能在面试中展示扎实的基础。

一、 核心区别:它们到底是什么?

  • 伪类 (Pseudo-classes)

    • 本质:代表一种状态
    • 作用:当元素处于某种特定状态时(如鼠标悬停、获得焦点、被选中),为其添加样式。它选择的是已存在的元素
    • 语法:使用单冒号 :(如 :hover)。
  • 伪元素 (Pseudo-elements)

    • 本质:代表一种虚拟内容
    • 作用:创建一个不在 DOM 树中的“虚拟元素”。例如,为一个段落的开头添加一个字母,或者在元素前后插入修饰性的内容。
    • 语法:CSS3 规范建议使用双冒号 ::(如 ::after),以区分伪类。

二、 常用伪类:状态与结构选择

伪类可以分为行为伪类结构伪类表单伪类

1. 行为与状态伪类

选择器 含义
E:link 匹配未访问的链接
E:visited 匹配已访问的链接
E:hover 鼠标悬停在元素上
E:active 鼠标按下尚未释放(激活状态)
E:focus 元素获得焦点(如输入框点击后)

2. 结构伪类

选择器 含义
E:first-child 匹配父元素的第一个子元素,且该子元素必须是 E
E:last-child 匹配父元素的最后一个子元素,且该子元素必须是 E
E:nth-child(n) 匹配父元素中第 n 个子元素(n 可以是数字、关键字或公式)
E:first-of-type 匹配同类型兄弟元素中的第一个 E
E:only-child 匹配父元素中唯一的子元素

3. 表单相关伪类

选择器 含义
E:enabled 匹配可用的表单元素
E:disabled 匹配被禁用的表单元素
E:checked 匹配单选框或复选框被选中的状态

三、 常用伪元素:内容与样式扩展

伪元素在 CSS3 规范中统一使用双冒号 ::

选择器 含义 场景
::before 在 E 元素内容的最前面插入内容 字体图标、修饰性装饰
::after 在 E 元素内容的最后面插入内容 清除浮动、对话框小箭头
::first-line 匹配 E 元素的第一行文字 杂志风格的排版
::first-letter 匹配 E 元素的第一个字母 首字下沉效果
::selection 匹配用户在页面上选中的文本 自定义选中文本的颜色

四、 深度辨析:nth-child vs nth-of-type

这是面试中最常被问到的细节:

  • p:nth-child(2) :选择父元素的第二个子元素,如果这个子元素是 <p>,则应用样式。如果第二个子元素是 <div>,则选择失败。
  • p:nth-of-type(2) :选择父元素下第二个 <p> 类型的子元素,不考虑非 <p> 标签。

💡 总结

  • 伪类是用来增强选择器的,描述的是状态(如被点击、被移动)。
  • 伪元素是用来创建新内容的,虽然在 HTML 里看不到,但在 CSS 中可以操作。
  • 写代码时,尽量遵循 CSS3 规范:状态用单冒号,内容用双冒号

CSS属性 - 文本属性

作者 GinoWi
2026年1月26日 14:53

CSS属性 - 文本属性

文本装饰属性

  • 格式:text-decoration:underline;

  • 取值:

    • underline:下划线
    • line-through:删除线
    • overline:上划线
    • none:什么都没有,最常见的用途就是用于去掉超链接的下划线。
  • 快捷键:

    • 输入td + 按tab键:text-decoration: none;
    • 输入tdu + 按tab键 : text-decoration: underline;
    • 输入tdl + 按tab键 : text-decoration: line-through;
    • 输入tdo + 按tab键 : text-decoration: overline;
  • 样式效果:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>文本属性练习</title>
            <style type="text/css">
                .setUnderline {
                    text-decoration: underline;
                }
                .setOverline {
                    text-decoration: overline;
                }
                .setLineThrought {
                    text-decoration: line-through;
                }
            </style>
        </head>
        <body>
            <p class="non">New York</p>
            <p class="setUnderline">New York</p>
            <p class="setLineThrought">New York</p>
            <p class="setOverline">New York</p>
        </body>
    </html>
    

    text-decoration属性

二、文本水平对齐的属性

  • 格式:text-align: center;

  • 取值:如果文本方向是从左到右,则默认为左对齐;如果文本方向是从右到左,则默认是右对齐。

    • left:左。
    • right:右。
    • center:中。
  • 快捷键:

    • 输入ta + 按tab 键:text-align: left;
    • 输入tar+ 按tab 键: text-align: right;
    • 输入tac+ 按tab键: text-align: center;
  • 样式效果:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>文本属性练习</title>
            <style type="text/css">
                .setCenter {
                    text-align: center;
                }
                .setRight {
                    text-align: right;
                }
            </style>
        </head>
        <body>
            <h1>文本左对齐</h1>
            <p class="non">New York</p>
            <hr>
            <h1>文本居中</h1>
            <p class="setCenter">New York</p>
            <hr>
            <h1>文本右对齐</h1>
            <p class="setRight">New York</p>
        </body>
    </html>
    

    text-align属性

文本缩进的属性

  • 格式:text-indent: 2em;

  • 取值:(均为具体数值 + 单位)

    • 可以以em为单位,一个em代表缩进一个文字的宽度。
    • 可以以px为单位。
  • 快捷键:

    • 输入ti + 按tab键:text-indent: ;
    • 输入ti2e + 按tab键: text-indent: 2em;
  • 样式效果:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>文本属性练习</title>
            <style type="text/css">
                .setTextIndent {
                    text-indent: 2em;
                }
            </style>
        </head>
        <body>
            <p class="non">New York</p>
            <p class="setTextIndent">New York</p>
        </body>
    </html>
    

    text-indent属性

修改文本颜色属性

  • 格式:color: #ff0000;

  • 取值:

    • 可以通过英文单词赋值

      • 一般情况下,常见的颜色都有对应的英文单词,英文单词能够表达的颜色是有限制的,也就是说不是所有的颜色都能通过英文单词来做表达。
    • 可以通过rgb赋值

      • rgb其实就是三原色,r-redg-greenb-blue
      • 格式:rgb(红色, 绿色, 蓝色)。那么这个格式中的第一个数字就是用来设置三原色的光源元件红色显示的亮度;第二个数字设置三原色的光源元件绿色显示的亮度;第三个数字设置三原色的光源元件蓝色显示的亮度。
      • 这其中的每一个数字的取值是0-255之间,0代表不发光,255代表发光,值越大就越亮。
      • 只要让红色、绿色、蓝色的值都一样就是灰色,而且这个值越小越偏向黑色,值越大越偏向白色。
    • 可以通过rgba赋值(CSS3推出)

      • rgba中的rgb和前面讲解的一样,只不过多了一个a
      • 那么这个a代表透明度,取值0-1,取值越小越透明。
    • 可以通过十六进制赋值

      • 在前端开发中,通过16进制来表示颜色, 其实本质就是rgb,十六进制中是通过每两位表示一个颜色。
      • 例如:#FFEE00 FF表示r, EE表示g,00表示b
    • 可以通过十六进制缩写赋值

      • 在CSS中只要十六进制的颜色每两位的值都是一样的就可以简写为一位。
      • 例如:#FFEE00 ==> #FE0
      • 这里需要注意:如果当前颜色对应的两位数字不一样,就不能简写;如果两位相同的数字不是属于同一个颜色的,也不能简写。

参考链接:

W3School官方文档:www.w3school.com.cn

# 手把手教你实现“左右拖动布局”:打造丝滑的 Vue 分屏体验

2026年1月26日 14:44

在开发像考试系统、代码编辑器或者对比工具这类 Web 应用时,左右分屏且可自由拖动调整宽度的布局是一种非常常见且高频的需求。

效果演示

我们需要实现的效果如下:

  1. 页面分为“左侧内容区”、“中间拖动条”、“右侧内容区”。
  2. 用户按住中间的拖动条(Resizer)左右拖动,实时改变左右两边的宽度比例。
  3. 拖动过程中禁止文字选中,避免体验跳脱。
  4. 设置最小/最大宽度限制,防止某一边被挤压得看不见。

核心思路

我们的实现核心在于 Flex 布局 配合 百分比宽度

  • 布局:父容器使用 display: flex

  • 宽度控制:使用一个响应式变量 leftPercentage 来控制左侧容器的宽度。右侧容器的宽度就是 100% - leftPercentage

  • 交互逻辑

    1. mousedown:在拖动条上按下鼠标,标记开始拖动,并注册全局 mousemove 和 mouseup 事件。
    2. mousemove:计算鼠标当前位置相对于父容器的百分比,动态更新 leftPercentage
    3. mouseup:松开鼠标,移除事件监听,结束拖动。

代码实现

以下以 Vue 2 为例(Vue 3 原理完全相同,只是语法稍有区别)。

1. HTML 结构

结构非常简单,经典的“三明治”夹心结构。

<template>
  <!-- 父容器 -->
  <div ref="splitPane" class="split-pane-container">
    
    <!-- 左侧面板 -->
    <div class="left-pane" :style="{ width: leftPercentage + '%' }">
      <div class="content">
        <!-- 插槽或具体内容 -->
        <slot name="left">Left Content</slot>
      </div>
    </div>
    <!-- 拖动条 (Resizer) -->
    <div class="resizer" @mousedown="startResize">
      <!-- 可以放一个拖拽图标,增加可识别性 -->
      <img src="@/assets/icon_handler.png" class="icon-handler" />
    </div>
    <!-- 右侧面板 -->
    <div class="right-pane" :style="{ width: (100 - leftPercentage) + '%' }">
      <div class="content">
         <slot name="right">Right Content</slot>
      </div>
    </div>
  </div>
</template>

2. CSS 样式

关键点在于 resize 的样式设置,以及 cursor: col-resize 提示用户可以左右拖动。

.split-pane-container {
  display: flex;
  height: 100vh; /* 或指定高度 */
  overflow: hidden;
}
.left-pane, .right-pane {
  overflow-y: auto; /* 内容溢出滚动 */
  height: 100%;
}
/* 拖动条样式 */
.resizer {
  width: 14px;  /* 拖动条宽度 */
  cursor: col-resize; /* 鼠标样式变为左右拖动箭头 */
  background-color: #f5f7fa;
  border-left: 1px solid #e4e7ed;
  border-right: 1px solid #e4e7ed;
  
  /* 居中内部图标 */
  display: flex;
  justify-content: center;
  align-items: center;
  
  /* 防止 Flex 压缩拖动条宽度 */
  flex-shrink: 0; 
  
  transition: background-color 0.3s;
}
.resizer:hover {
  background-color: #e6e8eb; /* 悬停高亮 */
}
.icon-handler {
  width: 20px;
  pointer-events: none; /* 防止拖动图片本身 */
  user-select: none;
}

3. JavaScript 核心逻辑

这里是灵魂所在。特别需要注意的是事件监听必须绑定在 document 上,而不是 resizer 上。因为用户拖动过快时,鼠标可能会移出拖动条范围,如果绑定在 resizer 上会导致拖动断触。

export default {
  data() {
    return {
      leftPercentage: 50, // 初始左侧宽度占比 50%
      isResizing: false,  // 是否正在拖动标志位
    }
  },
  methods: {
    // 1. 开始拖动
    startResize() {
      this.isResizing = true
      
      // 添加全局事件监听
      document.addEventListener('mousemove', this.doResize)
      document.addEventListener('mouseup', this.stopResize)
      
      // 关键优化:拖动时禁止选中文字,避免变蓝
      document.body.style.userSelect = 'none'
      document.body.style.cursor = 'col-resize' // 强制全局鼠标样式
    },
    // 2. 执行拖动
    doResize(e) {
      if (!this.isResizing) return
      const splitPane = this.$refs.splitPane
      if (!splitPane) return
      // 获取父容器的位置信息
      const containerRect = splitPane.getBoundingClientRect()
      const containerWidth = containerRect.width
      const containerLeft = containerRect.left
      // 计算鼠标相对于父容器左侧的距离 (X轴)
      const mouseX = e.clientX - containerLeft
      // 转换为百分比
      let newLeftPercentage = (mouseX / containerWidth) * 100
      // 边界限制:建议设置 20% ~ 80%,防止某一边被完全遮挡
      if (newLeftPercentage < 20) newLeftPercentage = 20
      if (newLeftPercentage > 80) newLeftPercentage = 80
      this.leftPercentage = newLeftPercentage
    },
    // 3. 结束拖动
    stopResize() {
      this.isResizing = false
      
      // 移除事件监听
      document.removeEventListener('mousemove', this.doResize)
      document.removeEventListener('mouseup', this.stopResize)
      
      // 恢复样式
      document.body.style.userSelect = ''
      document.body.style.cursor = ''
    }
  }
}

遇到的“坑”与优化点

1. 拖动卡顿与丢帧

问题:如果在 

doResize 中做复杂的 DOM 操作,会导致拖动卡顿。 

解决方案:我们只改变了一个响应式变量 leftPercentage,Vue 的 Diff 算法足够快。如果依然卡顿,可以使用 requestAnimationFrame 进行节流。

2. 鼠标移出拖动条失效

问题:鼠标拖得太快,离开了 .resizer 元素,拖动就停止了。 

解决方案:如上代码所示,使用 document.addEventListener 监听 mousemove,确保鼠标在页面任何位置都能响应。

3. 文字选中干扰

问题:拖动时如果不小心选中了左右两边的文字,体验非常差,甚至会自动触发浏览器的原生拖拽。 

解决方案:在 startResize 中设置 document.body.style.userSelect = 'none',拖动结束后恢复。

4. Iframe 遮挡问题(进阶)

问题:如果你的左右面板里嵌入了 <iframe>(例如显示 PDF 或外部网页),鼠标滑过 iframe 时,mousemove 事件会被 iframe 吞掉,导致拖动失效。 

解决方案:在 startResize 时,给所有的 iframe 上面覆盖一层透明的 div,或者设置 pointer-events: none,拖动结束后恢复。

总结

通过简单的 Flex 布局和数十行 JS 代码,我们就能实现一个高性能、兼容性好的分屏组件。这个方案不依赖任何重型第三方库(如 split.js),非常适合不仅需要轻量,又需要高度定制 UI 的场景。

花了两天,让Trae,给我用魔珐星云数字人写了个项目!

作者 PBitW
2026年1月26日 14:28

最近参加了 pre.xingyun3d.com/hackathon20… 活动,这个由魔珐星云组织的数字人开发活动!

菜鸟感觉我这个想法的商业价值还是挺大的!

下面是背景、需求分析!

二、 项目背景与痛点

2.1 行业与社会背景

随着人工智能、大模型与数字人技术的快速发展,人机交互正从“屏幕点击式”向“自然语言式、具身交互式”演进。语音识别、语义理解与数字人形象的成熟,使 AI 不再只是信息工具,而逐步成为能够融入现实场景、主动提供服务的“智能伴随者”。

与此同时,大型公共空间与商业空间正变得愈发复杂。以医院、综合商场为代表的场所,普遍存在空间结构复杂、信息分散、服务节点多等特征。用户在进入这些场所后,往往面临“找不到路、问不到人、不知道下一步该做什么”的问题。

从社会结构来看,老龄化趋势明显。大量老年人对智能设备操作不熟悉,在医院等高频公共场景中,仍高度依赖人工指引甚至医陪服务,增加了家庭与社会成本。

在消费层面,用户对效率、体验与个性化服务的期待持续提升。传统依赖指示牌、人工咨询、被动检索的服务方式,已难以满足用户对“即时、准确、自然交互”的需求。

在此背景下,一个能够感知用户位置、理解用户意图,并以自然方式进行引导与服务的 AI 数字人交互系统,具备明确的现实价值与应用空间。

2.2 现有场景的核心痛点分析

(一)医院场景:信息复杂,对弱势群体不友好

医院是信息密度极高的公共空间,但其服务体系长期以“懂规则的人”为默认用户。

挂号流程复杂:科室划分专业性强,普通患者尤其是老年人,很难根据症状准确判断应挂哪个科室。

挂错号成本高:一旦挂错科室,往往需要重新排队、重新缴费,甚至改期就诊,造成时间与精力的双重浪费。

老年人操作困难:大量医院已转向自助机、App 挂号,但对老年人并不友好,催生了高成本的“医陪”需求。

人工咨询压力大:导医台与人工窗口长期处于高负荷状态,服务质量难以保证。

痛点本质:医院缺少一个能够“用人话解释规则、替用户思考路径”的智能引导角色。

(二)大型商场场景:空间大、选择多、决策成本高

现代商业综合体体量不断扩大,但用户体验并未同步提升。

门店分布复杂:楼层多、动线绕,用户往往需要反复查看地图或询问工作人员。

找店效率低:想找某一家店,常常需要“走错—返回—再确认”,体验割裂。

消费决策困难:面对大量餐饮与品牌,用户不知道“哪家好吃”“哪家适合自己”。

用户粘性不足:商场难以持续与用户产生互动,只能依赖静态导览与被动推荐。

痛点本质:商场缺乏一个“能随时被询问、能结合场景做推荐”的智能交互入口。

(三)试衣与消费体验:流程繁琐,心理与时间成本高

在服装消费场景中,试衣环节长期存在明显痛点:

试衣过程麻烦:反复穿脱衣物耗时耗力,尤其在客流高峰期体验较差。

心理压力存在:部分用户对在公共试衣间反复试穿存在心理负担。

体验割裂:线下试衣与线上浏览、搭配之间缺乏连续性。

转化率受限:用户因试衣不便而放弃购买的情况普遍存在。

痛点本质:传统试衣方式效率低、体验重,不符合当前“便捷化、数字化”的消费趋势。

(四)公共空间中的“不知道该做什么”

在医院或商场中,用户经常并非目标明确,而是处于一种“被动探索”状态:

  1. 不知道下一步该去哪
  2. 不清楚当前有哪些服务或活动
  3. 不确定自己的选择是否最优

现有系统只能提供静态信息展示,无法主动理解用户需求并给出引导。

痛点本质:场所与用户之间缺乏持续、自然的互动连接。

2.3 项目切入价值总结

综合以上痛点可以看出,无论是医院还是商场,本质问题都不在于“信息不存在”,而在于:

  1. 信息分散、表达方式不友好
  2. 服务依赖人工,成本高且不可规模化
  3. 用户需要的是“被理解、被引导”,而非“自己去找答案”

AI 伴你「衣食行」项目,通过引入具备语音交互能力的 AI 数字人,将复杂的空间信息与服务流程,转化为自然对话式的引导体验,为用户提供:

  1. 随问随答的空间指引
  2. 基于语义理解的决策辅助
  3. 贴近真实人的交互方式
  4. 覆盖“衣、食、行、医”等高频生活场景的一体化服务

从而有效降低公共空间的使用门槛,提升服务效率与用户体验,同时为场地方构建新的智能服务入口与商业延展可能。

三、 产品核心功能

3.1 语音驱动的自然交互数字人

产品以 AI 数字人为核心交互载体,采用语音作为主要交互方式,用户无需复杂操作,仅通过自然语言即可完成信息获取与服务请求。

  1. 支持实时语音唤醒与连续对话
  2. 能理解口语化、非标准表达的用户提问
  3. 通过拟人化数字人形象进行反馈,降低技术使用门槛
  4. 特别适配老年人等对智能设备不熟悉的群体

功能价值
将传统“点按钮、看说明”的操作模式,转变为“像问一个人一样问系统”,显著提升公共空间中的服务可达性与亲和力。

3.2 基于位置感知的智能导航与指路服务(行)

产品可结合用户当前位置,为其提供直观、可理解的空间引导服务。

  1. 自动识别或确认用户所处位置
  2. 支持语音查询目的地(如科室、门店、服务点)
  3. 提供清晰的路线指引与方向说明
  4. 可在医院、商场等复杂空间中使用

应用场景

  1. 医院内寻找挂号窗口、科室、检查区域
  2. 商场内寻找具体门店、餐饮区域、服务设施

功能价值
帮助用户在复杂空间中快速建立方向感,减少迷路、反复确认和对人工咨询的依赖。

3.3 智能科室引导与就医辅助(医)

针对医院高频痛点,产品提供基于症状描述的科室引导能力。

  1. 用户可通过语音描述自身症状或就诊目的
  2. 系统对语义进行解析,并匹配对应科室类型
  3. 以“引导建议”的形式告知推荐科室方向
  4. 明确提示结果为辅助参考,避免误导性医疗判断

功能价值
降低因不了解医学专业划分而造成的挂错号概率,减少患者时间成本,缓解医院导医压力,尤其对老年患者具有显著帮助。

3.4 商场智能推荐与即时问答(食)

在商业场景中,产品可作为“随行导购式数字人”,提供即时咨询与推荐服务。

  1. 支持询问商场内“吃什么”“去哪逛”等问题
  2. 可根据餐饮类别、位置等进行推荐
  3. 通过对话方式帮助用户快速做出选择
  4. 减少用户在商场内的决策成本与犹豫时间

功能价值
让用户在商场中“随时有人可问”,提升消费体验,同时为商场构建更具互动性的服务入口。

3.5 虚拟试衣与数字分身体验(衣)

产品支持通过数字人技术,构建用户的虚拟形象,实现虚拟试衣体验。

  1. 可生成与用户外形相近的数字分身
  2. 在无需实际更换衣物的情况下进行服装展示
  3. 支持多款式、多颜色的快速切换对比
  4. 可应用于服装零售、品牌展示等场景

功能价值
减少试衣流程带来的时间与心理成本,提升服装消费效率,为线下商业场景引入更具吸引力的数字化体验。

3.6 多场景融合的一体化服务能力(衣 · 食 · 行)

“AI 伴你『衣食行』”并非单一功能工具,而是围绕用户在公共与商业空间中的完整行动路径,提供连续性的服务体验。

  1. 同一交互入口,覆盖导航、咨询、推荐与体验
  2. 根据场景不同切换服务重点
  3. 数字人形象统一,降低用户学习成本
  4. 可根据不同场所进行定制化部署

功能价值
实现从“被动信息查询”到“主动智能陪伴”的转变,打造可持续扩展的场景级智能交互平台。

3.7 核心功能总结

通过将语音交互、位置感知、语义理解与数字人形态进行融合,产品构建了一个面向真实空间的智能服务系统,使用户在医院与商场等高频场景中:

  1. 找得到路
  2. 问得到人
  3. 做得出决定
  4. 得到更好的体验

开发

直接按照官网 xingyun3d.com/developers/… ,下载一个demo示例,解压后运行即可!

菜鸟本来是想接入大模型的,结果发现需要money就算了,还想着魔珐星云有AI大模型可以直接使用,结果这个活动原来只是提供一个数字人,要用大模型一样要花钱,所以就没搞了。

还有语音识别也是一样。

菜鸟做的第一件事就是试试水,看Trae能否帮我完成这个艰难的任务!

image.png

准备是看看能不能直接把Trae接入的,结果只是多了个下拉选项 (ˉ▽ˉ;)...

但是确实完成了任务,说明这种小问题还是可以解决,接下来就是开发了!

后续的一些需求沟通

image.png

image.png

image.png

image.png

image.png

菜鸟后续还提出了很多需求,基本上都能完成,有些需要人为判断一下是否正确,不正确的时候git撤销掉,然后重新描述一下,这样就能保证每一个需求都能准确完成

代码,大家可以直接访问获取,可以的话给个Star,感谢大家:

github.com/pbw-langwan…

视频演示见:

花了两天,让Trae,给我用星云AI数字人写了个项目-哔哩哔哩

【AI 编程实战】第 9 篇:Hooks 封装的艺术 - 让逻辑复用更优雅

作者 HashTang
2026年1月26日 14:07

组件封装解决了 UI 复用的问题,但还有一类复用更隐蔽——逻辑复用。同样的请求状态管理、同样的登录检查、同样的上传流程,在多个页面重复出现。这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,将重复逻辑封装成优雅、可复用、易测试的 Hooks。

系列专栏【AI 编程实战】专栏目录

本篇主题:Hooks 封装的艺术 - 让逻辑复用更优雅

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:为什么需要 Hooks

1.1 组件复用 vs 逻辑复用

上一篇讲了组件封装,解决的是 UI 复用

<!-- 同样的按钮样式,封装成 XButton -->
<XButton text="提交" @click="handleSubmit" />

但还有一类复用问题:逻辑复用

// 页面 A:请求用户信息
const loading = ref(false);
const error = ref(null);
const userInfo = ref(null);

const fetchUserInfo = async () => {
  loading.value = true;
  try {
    const res = await getUserInfo();
    userInfo.value = res.data;
  } catch (e) {
    error.value = e;
  } finally {
    loading.value = false;
  }
};

// 页面 B:请求商品列表(几乎一样的逻辑)
const loading = ref(false);
const error = ref(null);
const productList = ref([]);

const fetchProducts = async () => {
  loading.value = true;
  try {
    const res = await getProducts();
    productList.value = res.data;
  } catch (e) {
    error.value = e;
  } finally {
    loading.value = false;
  }
};

问题

  • 每个页面都要定义 loading、error、data
  • 每个页面都要写 try-catch-finally
  • 修改请求逻辑(如加超时)要改多处

1.2 Hooks 是什么

Hooks(也叫 Composables)是 Vue 3 组合式 API 的核心概念:

// 把重复的逻辑封装成函数
function useRequest<T>(fetcher: () => Promise<T>) {
  const loading = ref(false);
  const error = ref(null);
  const data = ref<T | null>(null);

  const run = async () => {
    loading.value = true;
    try {
      data.value = await fetcher();
    } catch (e) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  };

  return { loading, error, data, run };
}

// 使用时
const { loading, data, run } = useRequest(() => getUserInfo());

1.3 本文要封装的 Hooks

Hook 功能 解决的问题
useRequest 请求状态管理 loading/error/data 重复定义
useUpload 文件上传 选择、校验、上传流程复杂
useLoginFlow 登录流程 多平台登录逻辑不一致
useSystemInfo 系统信息 状态栏高度、页面高度计算
usePrivacyPolicy 隐私政策 App 端隐私弹窗管理

二、请求状态管理:useRequest

2.1 我的需求

我:每个页面都要写 loading、error、try-catch,太繁琐了。
    能不能封装一个通用的请求 Hook?

2.2 AI 询问细节

AI:好的,我来帮你设计 useRequest。先确认几个问题:

🤔 问题:
1. 是否需要"立即执行"选项?(页面加载时自动请求)
2. 是否需要初始数据?(用于列表的空数组等)
3. 返回值格式是什么?(直接返回 data 还是包装对象)

这些会影响接口设计。

2.3 我补充信息

我:需要"立即执行"选项,默认 false。
    需要初始数据,比如列表默认是空数组。
    返回值用包装对象 { loading, error, data, run }。

2.4 AI 设计接口

AI:明白了,我来设计接口:

📐 接口设计:

// 选项
interface UseRequestOptions<T> {
  immediate?: boolean;   // 是否立即执行,默认 false
  initialData?: T;       // 初始数据
}

// 返回值
interface UseRequestReturn<T> {
  loading: Ref<boolean>;
  error: Ref<Error | null>;
  data: Ref<T | undefined>;
  run: () => Promise<T | undefined>;
}

// 函数签名
function useRequest<T>(
  fetcher: () => Promise<IResData<T>>,
  options?: UseRequestOptions<T>
): UseRequestReturn<T>

🎯 设计要点:
1. 泛型 T 保证类型安全
2. run 返回 Promise,支持链式调用
3. fetcher 返回 IResData<T>,自动提取 .data

2.5 AI 生成代码

// src/hooks/useRequest.ts
import type { Ref } from 'vue';

interface IUseRequestOptions<T> {
  /** 是否立即执行 */
  immediate?: boolean;
  /** 初始化数据 */
  initialData?: T;
}

interface IUseRequestReturn<T> {
  loading: Ref<boolean>;
  error: Ref<boolean | Error>;
  data: Ref<T | undefined>;
  run: () => Promise<T | undefined>;
}

/**
 * 请求状态管理 Hook
 * @param func 请求函数,返回 Promise<IResData<T>>
 * @param options 配置选项
 */
export default function useRequest<T>(
  func: () => Promise<IResData<T>>,
  options: IUseRequestOptions<T> = { immediate: false },
): IUseRequestReturn<T> {
  const loading = ref(false);
  const error = ref<boolean | Error>(false);
  const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>;

  const run = async () => {
    loading.value = true;
    return func()
      .then((res) => {
        data.value = res.data;
        error.value = false;
        return data.value;
      })
      .catch((err) => {
        error.value = err;
        throw err;
      })
      .finally(() => {
        loading.value = false;
      });
  };

  // 立即执行
  if (options.immediate) {
    run();
  }

  return { loading, error, data, run };
}

2.6 使用示例

// 基础用法:手动触发
const { loading, data, run } = useRequest(() => getUserInfo());

onMounted(() => {
  run();
});

// 立即执行
const { loading, data } = useRequest(
  () => getProductList(),
  { immediate: true, initialData: [] }
);

// 带参数的请求
const productId = ref(1);
const { data, run: fetchProduct } = useRequest(
  () => getProductDetail(productId.value)
);

// 参数变化时重新请求
watch(productId, () => {
  fetchProduct();
});

2.7 我追问:为什么用 Promise 链式写法

我:为什么用 .then().catch().finally(),而不是 try-catch?
AI:两种写法都可以,但 Promise 链式有个优势:

📊 对比:

【try-catch 写法】
const run = async () => {
  loading.value = true;
  try {
    const res = await func();
    data.value = res.data;
    error.value = false;
    return data.value;
  } catch (err) {
    error.value = err;
    throw err;
  } finally {
    loading.value = false;
  }
};

【Promise 链式写法】
const run = async () => {
  loading.value = true;
  return func()
    .then((res) => { ... })
    .catch((err) => { ... })
    .finally(() => { ... });
};

📌 链式写法的优势:
1. 更函数式,流程更清晰
2. 返回 Promise,调用方可以继续 .then()
3. finally 语义更明确

两种写法效果一样,看团队习惯选择。

三、文件上传:useUpload

3.1 我的需求

我:项目里有几个地方要上传图片:头像、聊天图片、反馈截图。
    每次都要写选择文件、校验大小、上传、处理结果,太繁琐了。

3.2 AI 分析复杂度

AI:文件上传确实复杂,需要处理几个问题:

🔍 复杂度分析:

1. 多平台差异
   - 小程序:uni.chooseMedia(新 API)
   - H5:uni.chooseImage
   - App:uni.chooseImage

2. 文件校验
   - 大小限制
   - 类型限制

3. 上传流程
   - 选择 → 校验 → 上传 → 回调

4. 状态管理
   - loading、error、data

📐 接口设计:

interface UseUploadOptions {
  maxSize?: number;        // 最大文件大小,默认 5MB
  accept?: string[];       // 允许的文件类型
  fileType?: 'image' | 'file';  // 文件类型
  formData?: object;       // 额外的表单数据
  success?: (data) => void;  // 成功回调
  error?: (err) => void;     // 失败回调
}

3.3 AI 生成关键代码

// src/hooks/useUpload.ts
import { ref } from 'vue';
import { getEnvBaseUploadUrl } from '@/utils';

const UPLOAD_URL = getEnvBaseUploadUrl();

type TFileType = 'image' | 'file';

interface UseUploadOptions<T extends TFileType> {
  formData?: Record<string, any>;
  maxSize?: number;  // 默认 5MB
  accept?: string[];
  fileType?: T;
  success?: (data: any) => void;
  error?: (err: any) => void;
}

export default function useUpload<T extends TFileType>(
  options: UseUploadOptions<T> = {}
) {
  const {
    formData = {},
    maxSize = 5 * 1024 * 1024,
    fileType = 'image',
    success,
    error: onError,
  } = options;

  const loading = ref(false);
  const error = ref<Error | null>(null);
  const data = ref<any>(null);

  // 处理文件选择后的逻辑
  const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string; size: number }) => {
    // 校验文件大小
    if (size > maxSize) {
      uni.showToast({
        title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
        icon: 'none',
      });
      return;
    }

    loading.value = true;

    // 上传文件
    uni.uploadFile({
      url: UPLOAD_URL,
      filePath: tempFilePath,
      name: 'file',
      formData,
      success: (res) => {
        try {
          const result = JSON.parse(res.data);
          data.value = result.data;
          success?.(result.data);
        } catch (err) {
          error.value = err as Error;
          onError?.(err);
        }
      },
      fail: (err) => {
        error.value = err as Error;
        onError?.(err);
      },
      complete: () => {
        loading.value = false;
      },
    });
  };

  // 触发文件选择
  const run = () => {
    const chooseOptions = {
      count: 1,
      success: (res: any) => {
        // 多平台适配
        let tempFilePath = '';
        let size = 0;

        // #ifdef MP-WEIXIN
        tempFilePath = res.tempFiles[0].tempFilePath;
        size = res.tempFiles[0].size;
        // #endif

        // #ifndef MP-WEIXIN
        tempFilePath = res.tempFilePaths[0];
        size = res.tempFiles[0].size;
        // #endif

        handleFileChoose({ tempFilePath, size });
      },
      fail: (err: any) => {
        error.value = err;
        onError?.(err);
      },
    };

    if (fileType === 'image') {
      // #ifdef MP-WEIXIN
      uni.chooseMedia({
        ...chooseOptions,
        mediaType: ['image'],
      });
      // #endif

      // #ifndef MP-WEIXIN
      uni.chooseImage(chooseOptions);
      // #endif
    } else {
      uni.chooseFile({
        ...chooseOptions,
        type: 'all',
      });
    }
  };

  return { loading, error, data, run };
}

3.4 使用示例

<template>
  <view class="avatar-upload" @click="handleUpload">
    <image v-if="avatarUrl" :src="avatarUrl" class="avatar" />
    <view v-else class="placeholder">
      <text v-if="loading">上传中...</text>
      <text v-else>点击上传</text>
    </view>
  </view>
</template>

<script setup>
import useUpload from '@/hooks/useUpload';

const avatarUrl = ref('');

const { loading, run: handleUpload } = useUpload({
  maxSize: 2 * 1024 * 1024,  // 2MB
  fileType: 'image',
  success: (data) => {
    avatarUrl.value = data.url;
    toast.success('上传成功');
  },
  error: (err) => {
    toast.error('上传失败');
  },
});
</script>

3.5 我追问多平台适配

我:为什么小程序和 H5 的代码不一样?
AI:微信小程序和 H5/App 的 API 返回值格式不同:

📊 返回值对比:

【小程序 chooseMedia】
res = {
  tempFiles: [{
    tempFilePath: 'wxfile://xxx',
    size: 12345,
    fileType: 'image'
  }]
}

【H5/App chooseImage】
res = {
  tempFilePaths: ['blob:http://xxx'],
  tempFiles: [File]  // File 对象
}

📌 差异点:
1. 小程序返回 tempFilePath 在对象内
2. H5/App 返回 tempFilePaths 数组

所以需要用条件编译区分:
// #ifdef MP-WEIXIN
tempFilePath = res.tempFiles[0].tempFilePath;
// #endif

// #ifndef MP-WEIXIN
tempFilePath = res.tempFilePaths[0];
// #endif

四、登录流程:useLoginFlow

4.1 我的需求

我:项目要同时支持小程序和 App。
    小程序用弹窗登录,App 用页面登录。
    能不能统一封装?

4.2 AI 设计多平台适配

AI:可以用条件编译实现多平台统一:

📐 设计思路:

useLoginFlow
├── checkAuth()       // 检查登录态,未登录则触发登录
├── openLoginModal()  // 打开登录(自动判断平台)
│   ├── 小程序 → 打开 LoginModal 弹窗
│   └── App → 跳转登录页面
└── handleLoginSuccess()  // 登录成功回调

📐 接口设计:

function useLoginFlow() {
  return {
    checkAuth: (options?) => Promise<boolean>,
    openLoginModal: (options?) => void,
    handleLoginSuccess: (userInfo?) => void,
  };
}

4.3 AI 生成代码

// src/composables/useLoginFlow.ts
import type { UserInfo } from 'shared-types';
import { useUserStore } from '@/store/user';
import { useLoginModalStore } from '@/store/loginModal';

type LoginSuccessHandler = (userInfo?: UserInfo) => void | Promise<void>;

/**
 * 统一登录流程 Hook
 * - App 端:跳转到登录页
 * - 小程序端:打开 LoginModal 弹窗
 */
export function useLoginFlow() {
  const loginModalStore = useLoginModalStore();
  const userStore = useUserStore();

  // 基础登录成功处理
  const baseHandleLoginSuccess = async (loginUserInfo?: UserInfo) => {
    if (loginUserInfo) {
      userStore.setUserInfo(loginUserInfo);
    }
    await userStore.loadUserInfo();
  };

  // 合并自定义回调和基础回调
  const combineHandlers = (handler?: LoginSuccessHandler): LoginSuccessHandler => {
    if (!handler) {
      return baseHandleLoginSuccess;
    }
    return async (userInfo) => {
      await baseHandleLoginSuccess(userInfo);
      await handler(userInfo);
    };
  };

  /**
   * 打开登录(自动判断平台)
   */
  const openLoginModal = async (options?: { onSuccess?: LoginSuccessHandler }) => {
    // #ifdef APP || APP-HARMONY
    // App 端:跳转到登录页
    uni.navigateTo({
      url: '/pages/login/index',
    });
    return;
    // #endif

    // 小程序端:打开 LoginModal 弹窗
    loginModalStore.open({
      onSuccess: combineHandlers(options?.onSuccess),
    });
  };

  /**
   * 检查登录态,未登录则触发登录
   * @returns 是否已登录
   */
  const checkAuth = async (options?: { onSuccess?: LoginSuccessHandler }) => {
    if (!userStore.isLoggedIn) {
      await openLoginModal(options);
      return false;
    }
    return true;
  };

  return {
    checkAuth,
    openLoginModal,
    handleLoginSuccess: baseHandleLoginSuccess,
  };
}

4.4 使用示例

<script setup>
import { useLoginFlow } from '@/composables/useLoginFlow';

const { checkAuth } = useLoginFlow();

// 需要登录才能执行的操作
const handleSendMessage = async () => {
  const isLoggedIn = await checkAuth({
    onSuccess: () => {
      console.log('登录成功,继续发送');
    },
  });

  if (!isLoggedIn) {
    // 用户正在登录流程中,等待回调
    return;
  }

  // 已登录,直接发送
  doSendMessage();
};

// 直接触发登录
const handleLoginClick = () => {
  openLoginModal({
    onSuccess: (userInfo) => {
      toast.success(`欢迎回来,${userInfo?.username}`);
    },
  });
};
</script>

4.5 我追问:回调合并的设计

我:combineHandlers 是做什么的?为什么要合并回调?
AI:combineHandlers 确保"基础处理"和"自定义处理"都执行:

📊 场景分析:

【只有基础处理】
用户登录成功 → setUserInfo → loadUserInfo

【有自定义处理】
用户登录成功 → setUserInfo → loadUserInfo → 自定义逻辑

📐 合并逻辑:

const combineHandlers = (handler?) => {
  if (!handler) {
    return baseHandleLoginSuccess;  // 只有基础
  }
  return async (userInfo) => {
    await baseHandleLoginSuccess(userInfo);  // 先执行基础
    await handler(userInfo);  // 再执行自定义
  };
};

📌 好处:
1. 基础处理(设置用户信息)不会被遗漏
2. 自定义处理可以使用最新的用户信息
3. 调用方不用关心基础处理的细节

五、系统信息:useSystemInfo

5.1 我的需求

我:很多页面需要获取状态栏高度、计算页面可用高度。
    每次都 uni.getSystemInfoSync() 太繁琐,而且可能有性能问题。

5.2 AI 设计单例模式

AI:系统信息只需要获取一次,可以用单例模式:

📐 设计思路:

1. 模块级变量存储结果(只初始化一次)
2. 多个 Hook 共享同一份数据
3. 提供计算属性(如页面高度)

📐 Hook 拆分:

useSystemInfo()     // 获取完整系统信息
useStatusBarHeight()  // 只获取状态栏高度
usePageHeightStyle()  // 计算页面高度样式

5.3 AI 生成代码

// src/composables/useSystemInfo.ts
import { computed, ref, type Ref } from 'vue';
import { tabbarConfig } from '@/tabbar/config';

// 模块级变量(单例)
let initialized = false;
const statusBarHeightRef = ref(0);
const systemInfoRef = ref<UniApp.GetSystemInfoResult | null>(null);

// 确保只初始化一次
function ensureSystemInfo() {
  if (!initialized) {
    initialized = true;
    try {
      systemInfoRef.value = uni.getSystemInfoSync();
    } catch {
      systemInfoRef.value = null;
    }
    statusBarHeightRef.value = systemInfoRef.value?.statusBarHeight ?? 0;
  }
  return systemInfoRef.value;
}

/**
 * 获取系统信息
 */
export function useSystemInfo() {
  const info = ensureSystemInfo();
  return {
    systemInfo: info,
    statusBarHeight: statusBarHeightRef,
  };
}

/**
 * 只获取状态栏高度
 */
export function useStatusBarHeight() {
  ensureSystemInfo();
  return statusBarHeightRef;
}

/**
 * 计算页面可用高度样式
 */
interface PageHeightOptions {
  includeStatusBar?: boolean;  // 是否减去状态栏,默认 true
  headerHeight?: number;       // 顶部固定头部高度
  includeTabbar?: boolean;     // 是否减去 TabBar,默认 true
  includeBottomSafeArea?: boolean;  // 是否减去底部安全区
  extraOffset?: string | number;    // 额外偏移
}

export function usePageHeightStyle(options: PageHeightOptions = {}) {
  ensureSystemInfo();

  const {
    includeStatusBar = true,
    headerHeight = 0,
    includeTabbar = true,
    includeBottomSafeArea = true,
    extraOffset,
  } = options;

  // 获取 TabBar 高度
  const tabbarHeight = (() => {
    const h = tabbarConfig?.height;
    if (!h) return 0;
    if (typeof h === 'number') return h;
    if (typeof h === 'string' && h.endsWith('px')) {
      return parseFloat(h) || 0;
    }
    return 0;
  })();

  return computed(() => {
    const segments: string[] = ['100vh'];

    // 减去状态栏
    if (includeStatusBar) {
      segments.push(`- ${statusBarHeightRef.value}px`);
    }

    // 减去头部
    if (headerHeight > 0) {
      segments.push(`- ${headerHeight}px`);
    }

    // 减去 TabBar
    if (includeTabbar && tabbarHeight > 0) {
      segments.push(`- ${tabbarHeight}px`);
    }

    // 减去额外偏移
    if (extraOffset !== undefined) {
      if (typeof extraOffset === 'string') {
        segments.push(`- ${extraOffset}`);
      } else {
        segments.push(`- ${extraOffset}px`);
      }
    }

    // 减去底部安全区
    if (includeBottomSafeArea) {
      segments.push('- env(safe-area-inset-bottom)');
    }

    return {
      height: `calc(${segments.join(' ')})`,
    };
  });
}

5.4 使用示例

<template>
  <!-- 滚动区域,自动计算高度 -->
  <scroll-view :style="pageHeightStyle" scroll-y>
    <view v-for="item in list" :key="item.id">
      {{ item.name }}
    </view>
  </scroll-view>
</template>

<script setup>
import { usePageHeightStyle } from '@/composables/useSystemInfo';

// 自动计算:100vh - 状态栏 - TabBar - 安全区
const pageHeightStyle = usePageHeightStyle();

// 有自定义导航栏的页面
const pageHeightStyle = usePageHeightStyle({
  headerHeight: 44,  // 自定义导航栏高度
});

// 不需要减去 TabBar 的页面
const pageHeightStyle = usePageHeightStyle({
  includeTabbar: false,
});
</script>

5.5 我追问单例模式

我:为什么用模块级变量而不是 ref?
AI:为了实现"跨组件共享"和"只初始化一次":

📊 对比:

【方式一:Hook 内部 ref】
export function useSystemInfo() {
  const systemInfo = ref(null);  // 每次调用都创建新的
  systemInfo.value = uni.getSystemInfoSync();  // 每次都调用
  return { systemInfo };
}
// 问题:调用 100 次就初始化 100 次

【方式二:模块级变量(单例)】
let initialized = false;
const systemInfoRef = ref(null);  // 模块级,只有一份

export function useSystemInfo() {
  if (!initialized) {
    initialized = true;
    systemInfoRef.value = uni.getSystemInfoSync();  // 只调用一次
  }
  return { systemInfo: systemInfoRef };
}
// 优点:无论调用多少次,只初始化一次

📌 适用场景:
- 系统信息(不会变)
- 全局配置(加载一次)
- 共享状态(多组件访问)

六、隐私政策:usePrivacyPolicy

6.1 我的需求

我:App 端要在启动时显示隐私政策弹窗,用户同意后才能继续使用。
    小程序不需要这个弹窗。

6.2 AI 设计条件编译方案

AI:用条件编译隔离平台差异:

📐 设计思路:

1. 使用条件编译,小程序端直接返回 false
2. App 端检查本地存储,决定是否显示
3. 提供同意/拒绝方法

📐 接口设计:

function usePrivacyPolicy() {
  return {
    showPrivacyModal: Ref<boolean>,  // 是否显示弹窗
    checkPrivacyAgreement: () => void,  // 检查并触发弹窗
    handlePrivacyAgree: () => void,   // 用户同意
    handlePrivacyDisagree: () => void, // 用户拒绝
  };
}

6.3 AI 生成代码

// src/composables/usePrivacyPolicy.ts
import { ref } from 'vue';

const PRIVACY_AGREED_KEY = 'privacy_policy_agreed';

// 检查是否需要显示弹窗
const shouldShowPrivacyModal = () => {
  // #ifdef APP || APP-HARMONY
  const agreed = uni.getStorageSync(PRIVACY_AGREED_KEY);
  return !agreed;
  // #endif

  // #ifndef APP || APP-HARMONY
  return false;  // 非 App 平台不显示
  // #endif
};

// 全局状态
const showPrivacyModal = ref(false);
const privacyChecked = ref(false);

export const usePrivacyPolicy = () => {
  /**
   * 检查是否需要展示隐私政策弹窗
   */
  const checkPrivacyAgreement = () => {
    if (privacyChecked.value) return;

    // #ifdef APP || APP-HARMONY
    const needShow = shouldShowPrivacyModal();
    if (needShow) {
      // 延迟显示,让页面先完成加载
      setTimeout(() => {
        showPrivacyModal.value = true;
      }, 800);
    }
    privacyChecked.value = true;
    // #endif
  };

  /**
   * 用户同意隐私政策
   */
  const handlePrivacyAgree = () => {
    // #ifdef APP || APP-HARMONY
    uni.setStorageSync(PRIVACY_AGREED_KEY, 'true');
    showPrivacyModal.value = false;
    // #endif
  };

  /**
   * 用户拒绝隐私政策
   */
  const handlePrivacyDisagree = () => {
    // #ifdef APP-PLUS || APP-HARMONY
    if (typeof plus !== 'undefined' && plus.runtime) {
      plus.runtime.quit();  // 退出应用
    } else {
      uni.navigateBack({ delta: 999 });
    }
    // #endif
  };

  return {
    showPrivacyModal,
    checkPrivacyAgreement,
    handlePrivacyAgree,
    handlePrivacyDisagree,
  };
};

6.4 使用示例

<!-- App.vue -->
<template>
  <PrivacyPolicyModal
    v-model="showPrivacyModal"
    @agree="handlePrivacyAgree"
    @disagree="handlePrivacyDisagree"
  />
</template>

<script setup>
import { usePrivacyPolicy } from '@/composables/usePrivacyPolicy';

const {
  showPrivacyModal,
  checkPrivacyAgreement,
  handlePrivacyAgree,
  handlePrivacyDisagree,
} = usePrivacyPolicy();

onLaunch(() => {
  checkPrivacyAgreement();
});
</script>

七、Hooks 封装的核心经验

7.1 命名规范

类型 命名 示例
状态管理 Hook use + 名词 useRequest, useUpload
行为 Hook use + 动词 useLoginFlow, useCheckAuth
数据获取 Hook use + 数据名 useUserInfo, useSystemInfo

7.2 返回值设计

// ✅ 推荐:返回对象,解构使用
function useRequest() {
  return { loading, error, data, run };
}
const { loading, data, run } = useRequest();

// ❌ 不推荐:返回数组,位置敏感
function useRequest() {
  return [loading, error, data, run];
}
const [loading, _, data] = useRequest();  // 需要占位符

7.3 状态隔离 vs 共享

// 隔离状态:每次调用都是独立的
function useRequest() {
  const loading = ref(false);  // 组件级状态
  return { loading };
}

// 共享状态:多次调用共享同一份
const globalLoading = ref(false);  // 模块级状态
function useGlobalLoading() {
  return { loading: globalLoading };
}

7.4 平台适配模式

// ✅ 推荐:条件编译隔离
const openLoginModal = () => {
  // #ifdef APP
  uni.navigateTo({ url: '/pages/login' });
  // #endif

  // #ifdef MP-WEIXIN
  loginModalStore.open();
  // #endif
};

// ❌ 不推荐:运行时判断
const openLoginModal = () => {
  if (process.env.UNI_PLATFORM === 'app') {
    uni.navigateTo({ url: '/pages/login' });
  } else {
    loginModalStore.open();
  }
};

八、总结:Hooks 封装方法论

8.1 何时封装 Hook

需要封装 Hook 吗?
    ├── 同样的状态管理出现 3 次以上 → ✅ 封装
    ├── 复杂的异步流程 → ✅ 封装
    ├── 多平台差异逻辑 → ✅ 封装
    ├── 只用一次的简单逻辑 → ❌ 不封装
    └── 纯计算逻辑 → ⚠️ 考虑用普通函数

8.2 本文封装的 Hooks 清单

Hook 类型 核心功能 设计要点
useRequest 状态管理 请求状态封装 泛型、立即执行、初始数据
useUpload 流程封装 文件上传 多平台适配、大小校验
useLoginFlow 行为封装 登录流程 条件编译、回调合并
useSystemInfo 数据获取 系统信息 单例模式、计算属性
usePrivacyPolicy 状态管理 隐私弹窗 条件编译、全局状态

8.3 下一篇预告

《【AI 编程实战】第 10 篇:错误处理与边界情况 - 让应用更健壮》

下一篇展示如何处理各种异常情况:

  • 网络错误的统一处理
  • Token 过期的自动刷新
  • 边界情况的防御性编程

Hooks 不是"把代码挪到另一个文件",而是封装可复用的逻辑单元。 好的 Hook 应该:职责单一、接口清晰、状态隔离、平台无关。

如果这篇文章对你有帮助,请点赞、收藏、转发!

前端向架构突围系列 - 浏览器网络 [5 - 2]:缓存策略、DNS 与请求优先级

2026年1月26日 14:06

写在前面

许多前端优化手段(比如 Preload, Prefetch, HTTP Cache)之所以经常失效,是因为开发者把浏览器当成了一个黑盒。

你是否遇到过:明明配置了强缓存,为什么 Service Worker 还是拦截了请求?明明加了 async,为什么脚本还是阻塞了 LCP 图片的加载?

本篇我们将拆解浏览器的资源加载模型(Resource Loading Model) 。在这个模型中,缓存不仅仅是 Cache-Control,DNS 不仅仅是域名解析,而请求优先级则是控制渲染顺序的终极权杖。

image.png


一、 纵深防御:四级缓存体系的决策顺序

当浏览器发起一个请求时(比如 GET /style.css),它绝不会直接发给网络。它会按顺序询问四位“守门员”。这个顺序至关重要,决定了你的缓存策略是否生效。

1.1 第一道门:Service Worker (可编程代理)

这是浏览器暴露给开发者的最大权限。

  • 机制: SW 是运行在浏览器后台的独立线程。它拦截所有网络请求。
  • 特权: 即使网络断开,或者服务器返回 500,SW 也可以直接返回缓存的内容。
  • 架构意义: 它是实现 离线优先 (Offline First) 应用的基石。如果你的 SW 逻辑写得有问题(比如不更新缓存),那么后面所有的 HTTP 缓存策略都会失效。

1.2 第二道门:Memory Cache (短期记忆)

  • 机制: 浏览器内存中的缓存。
  • 特点: 极快,但易失。 当你关闭 Tab 页,缓存即清空。
  • 场景: 页面上引入了两次相同的 <img src="logo.png">,第二次请求直接从 Memory Cache 读取,耗时 0ms。这通常由浏览器的 Preloader 自动管理,开发者很难干预。

1.3 第三道门:Disk Cache (HTTP Cache - 长期记忆)

这是我们最熟悉的领域,也是硬盘上的缓存。

  • 强缓存 (Strong Cache): Cache-Control: max-age=31536000。浏览器根本不向服务器发送请求,直接返回 200 (from disk cache)。
  • 协商缓存 (Negotiated Cache): ETag / Last-Modified。浏览器发送请求询问:“这文件过期了吗?”服务器回答:“没过期 (304 Not Modified)”。
  • 架构陷阱: 千万不要给 index.html 设置强缓存!否则用户永远看不到新版本。

1.4 第四道门:Push Cache (HTTP/2 - 昙花一现)

这是 HTTP/2 连接中的缓存。它的生命周期仅限于当前的传输连接。由于兼容性和实现复杂性问题,目前在 Chrome 中很少被利用,架构设计时可忽略。


二、 隐形的时间偷窃者:DNS 解析与连接复用

缓存没命中?那就必须联网了。但联网的第一步不是发数据,而是“找路”。

2.1 DNS 的代价

www.google.com 转换为 142.250.xxx.xxx 需要时间。通常是 20ms - 100ms,移动端甚至更久。 如果你的页面资源分散在 5 个不同的 CDN 域名上,光是 DNS 解析就要浪费几百毫秒。

2.2 架构师的预判:Resource Hints

浏览器很聪明,但它不能预测未来。架构师需要通过“暗示”来告诉浏览器。

  • dns-prefetch: “浏览器,待会儿我要用这个域名,你先帮我查查 IP。”

    <link rel="dns-prefetch" href="//third-party-analytics.com">
    

    适用场景: 不确定是否会用到,但解析一下不花钱的第三方资源(如埋点)。

  • preconnect: “浏览器,不仅查 IP,把 TCP 握手和 TLS 握手也做完!”

    <link rel="preconnect" href="https://cdn.mysite.com" crossorigin>
    

    适用场景: 确定马上就要用的关键 CDN 域名。警告: 不要滥用。保持开放的 Socket 连接是很消耗资源的。


三、 调度的艺术:请求优先级 (Priority) 与关键渲染路径

这是本篇的核心。 浏览器虽然是并发请求(H2/H3),但带宽是有限的。浏览器必须给每个请求打上标签:Highest, High, Medium, Low, Lowest

3.1 浏览器的默认分级逻辑 (Chrome 为例)

  • Highest: HTML、CSS(关键渲染路径)、字体。
  • High: 预加载资源 (Preload)、视口内的图片 (LCP Candidate)、Script (如果没加 async/defer)。
  • Medium/Low: async/defer 的脚本。
  • Lowest: 视口外的图片、prefetch 的资源。

3.2 架构师的微操:fetchpriority

以前,我们很难干预这个顺序。现在,我们有了 Priority Hints

场景一:拯救 LCP (最大内容绘制) 浏览器默认会给图片较低的优先级。如果你的一张大图是首屏的核心(LCP),你应该手动提权:

<img src="hero.jpg" fetchpriority="high" alt="Hero">

这能让 LCP 指标提升 20% 以上。

场景二:降级非关键脚本 轮播图的 JS 逻辑、第三方的广告脚本,不应该抢占带宽。

<script src="carousel.js" fetchpriority="low"></script>

四、 综合实战:设计完美的加载瀑布流

结合缓存、DNS 和优先级,一个完美的资源加载模型应该是这样的:

  1. HTML (入口):

    • 缓存: Cache-Control: no-cache (必须走协商缓存,保证版本更新)。
    • 预连接: 头部加入 <link rel="preconnect" href="cdn.com">
  2. CSS/JS (静态资源):

    • 文件名: 带 Hash 指纹 (main.a1b2c3.js)。
    • 缓存: max-age=1年, immutable (永久强缓存)。
    • 优先级: 关键 CSS 内联或 High 优先级;非关键 JS 使用 defer 降级。
  3. LCP 图片:

    • 优先级: fetchpriority="high"
    • 预加载: <link rel="preload" as="image"> (防止被 CSS 背景图深埋)。
  4. API 请求:

    • 时机: 尽量在 JS 执行早期发起,或者利用 React Server Components (RSC) 在服务端直接获取,避免客户端的“请求瀑布流”。

结语

浏览器不再是黑盒。它有着严格的等级制度和晋升机制。 作为架构师,你的任务不是简单地把文件扔给浏览器,而是要像指挥交通一样,规划好每一比特数据的 流向(DNS)、车道(优先级)和停车场(缓存)

Next Step: 资源已经顺利通过了检查,进入了浏览器的内存。接下来,这些文本格式的 HTML、CSS 代码,是如何在几毫秒内变成屏幕上绚丽的像素的? 下一节,我们将进入渲染引擎的内部—— 《第三篇:画师——像素的旅程:关键渲染路径 (CRP) 与分层合成机制》

Monorepo的实现原理

作者 不灵不灵
2026年1月26日 14:06

Monorepo的实现需要

  1. 基础层:包管理器的Workspace功能

  2. 管理层:Lerna等版本和发布管理

  3. 构建层:Turborepo/Nx等高性能构建工具

  4. 架构层:合理的项目结构和依赖管理策略

  5. 工程层:CI/CD、代码质量、团队协作流程


Monorepo的实现原理(底层机制详解)

Monorepo的本质

  • 链接机制:通过符号链接/硬链接连接本地包
  • 依赖提升:共享相同版本的依赖,减少冗余
  • 统一入口:通过根package.json管理公共配置
  • 原子操作:跨包的操作(构建、测试、发布)保持一致性
  • 依赖图感知:理解包之间的关系,优化执行顺序

一、最核心原理:符号链接(Symlink)

1. 基本概念

# 传统多仓库
project-a/
├── node_modules/
│   └── project-b/     # 从npm安装
project-b/             # 独立的仓库

# Monorepo实现后
monorepo/
├── packages/
│   ├── project-a/
│   │   └── node_modules/
│   │       └── project-b -> ../../project-b  # 符号链接!
│   └── project-b/

2. 符号链接如何工作

# 创建符号链接的底层实现
ln -s ../../packages/utils packages/ui/node_modules/@company/utils

# 结果:
# packages/ui/node_modules/@company/utils -> packages/utils

# 验证链接
ls -l packages/ui/node_modules/@company/
# lrwxr-xr-x  1 user  group  20 Jan 25 10:00 utils -> ../../../utils

二、依赖解析机制

1. Node.js的模块解析算法

// Node.js查找模块的顺序:
1. 当前目录的node_modules
2. 父目录的node_modules(递归向上)
3. 全局node_modules

// Monorepo利用这个机制:
monorepo/
├── node_modules/                    # 根级(提升的依赖)
│   ├── react/
│   └── lodash/
├── packages/
│   ├── ui/
│   │   └── node_modules/
│   │       ├── @company/utils -> ../../utils  # 符号链接
│   │       └── local-dep/          # 本地特有依赖
│   └── utils/

2. 包管理器的工作原理

// npm/yarn/pnpm在Monorepo中的解析流程
function resolvePackage(packageName, currentDir) {
  // 1. 检查是否是工作区包
  if (isWorkspacePackage(packageName)) {
    // 返回本地路径而不是从npm下载
    return getLocalPackagePath(packageName);
  }

  // 2. 正常解析npm包
  return resolveFromNpm(packageName);
}

三、工作区(Workspace)的实现

1. 配置识别

// package.json
{
  "workspaces": ["packages/*", "apps/*"]
}

// 包管理器读取后:
const workspaces = ["packages/*", "apps/*"];
const packages = glob(workspaces); // 展开所有匹配的包

2. 依赖图构建

// 构建包之间的依赖关系图
class DependencyGraph {
  constructor() {
    this.nodes = new Map(); // 包名 -> 包信息
    this.edges = new Map(); // 包名 -> 依赖的包列表
  }

  buildFromWorkspaces() {
    // 1. 扫描所有workspace包
    for (const pkg of this.findAllPackages()) {
      this.nodes.set(pkg.name, pkg);

      // 2. 解析依赖
      const deps = this.extractDependencies(pkg);
      this.edges.set(pkg.name, deps);
    }
  }

  // 关键:检测内部依赖
  extractDependencies(pkg) {
    const deps = [];

    for (const [depName, version] of Object.entries(pkg.dependencies)) {
      if (version.startsWith('workspace:') || this.isLocalPackage(depName)) {
        // 这是工作区内部的依赖
        deps.push({
          name: depName,
          type: 'internal',
          path: this.findLocalPath(depName)
        });
      } else {
        // 外部npm依赖
        deps.push({
          name: depName,
          type: 'external',
          version: version
        });
      }
    }

    return deps;
  }
}

四、依赖提升(Hoisting)

1. 如何实现依赖提升

class HoistingResolver {
  hoistDependencies(packages) {
    // 1. 收集所有依赖
    const allDeps = this.collectAllDependencies(packages);

    // 2. 找出可提升的依赖(版本一致)
    const hoistable = this.findHoistableDeps(allDeps);

    // 3. 在根目录安装
    this.installAtRoot(hoistable);

    // 4. 从子包中移除重复依赖
    this.removeDuplicatesFromChildren(packages, hoistable);
  }

  findHoistableDeps(allDeps) {
    const depVersions = new Map();

    // 统计每个依赖的版本
    for (const {name, version} of allDeps) {
      if (!depVersions.has(name)) {
        depVersions.set(name, new Set());
      }
      depVersions.get(name).add(version);
    }

    // 只有所有包使用相同版本的依赖才可提升
    const hoistable = [];
    for (const [name, versions] of depVersions) {
      if (versions.size === 1) {
        // 所有包使用相同版本
        hoistable.push({name, version: Array.from(versions)[0]});
      }
    }

    return hoistable;
  }
}

五、构建和任务执行原理

1. 任务依赖图

// 计算任务执行顺序
class TaskScheduler {
  scheduleTasks(packages, taskName) {
    // 1. 构建任务图
    const taskGraph = this.buildTaskGraph(packages, taskName);

    // 2. 拓扑排序(确保依赖先执行)
    const executionOrder = this.topologicalSort(taskGraph);

    // 3. 并行执行无依赖的任务
    return this.executeInParallel(executionOrder);
  }

  buildTaskGraph(packages, taskName) {
    const graph = new Map();

    for (const pkg of packages) {
      const dependencies = this.getPackageDeps(pkg);
      const dependentTasks = [];

      // 找出依赖此包的其他包
      for (const otherPkg of packages) {
        if (otherPkg.dependencies[pkg.name]) {
          dependentTasks.push(`${otherPkg.name}:${taskName}`);
        }
      }

      graph.set(`${pkg.name}:${taskName}`, {
        dependencies: dependencies.map(dep => `${dep}:${taskName}`),
        dependents: dependentTasks
      });
    }

    return graph;
  }
}

2. 增量构建的实现

class IncrementalBuilder {
  constructor(cacheDir = '.cache') {
    this.cacheDir = cacheDir;
  }

  shouldBuild(packagePath, task) {
    // 1. 计算输入哈希
    const inputHash = this.calculateInputHash(packagePath, task);

    // 2. 检查缓存
    const cacheKey = this.getCacheKey(packagePath, task);
    const cachedHash = this.readCache(cacheKey);

    // 3. 比较哈希
    if (cachedHash === inputHash) {
      // 缓存命中,跳过构建
      return false;
    }

    // 4. 需要重新构建
    this.writeCache(cacheKey, inputHash);
    return true;
  }

  calculateInputHash(packagePath, task) {
    // 包括:
    // - 源代码文件
    // - 依赖项版本
    // - 构建配置
    // - 环境变量
    const sources = glob(`${packagePath}/src/**/*`);
    const pkgJson = require(`${packagePath}/package.json`);

    return hash({
      sources: sources.map(f => hashFile(f)),
      deps: pkgJson.dependencies,
      config: this.getBuildConfig(task),
      env: process.env.NODE_ENV
    });
  }
}

六、跨包引用的编译时处理

1. TypeScript路径映射

// tsconfig.base.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@company/*": ["packages/*/src"]
    }
  }
}

// 编译时,TypeScript将:
// import { utils } from '@company/utils';
// 转换为:
// import { utils } from '../packages/utils/src';

2. Babel/Webpack模块解析插件

// 自定义模块解析器
class MonorepoResolver {
  apply(resolver) {
    resolver.plugin('module', (request, callback) => {
      if (request.request.startsWith('@company/')) {
        // 重写请求路径
        const newRequest = Object.assign({}, request, {
          path: this.resolveLocalPackage(request.request)
        });
        return resolver.doResolve('module', newRequest, callback);
      }
      callback();
    });
  }

  resolveLocalPackage(packageName) {
    // @company/utils -> packages/utils/src
    const pkg = packageName.replace('@company/', '');
    return path.join(__dirname, 'packages', pkg, 'src');
  }
}

七、版本管理和发布的原理

1. 版本依赖更新算法

class VersionManager {
  updateVersions(packages, versionMap) {
    for (const pkg of packages) {
      // 1. 更新自己的版本
      pkg.version = versionMap[pkg.name];

      // 2. 更新依赖中的内部包版本
      this.updateDependencies(pkg, versionMap);
    }
  }

  updateDependencies(pkg, versionMap) {
    const depTypes = ['dependencies', 'devDependencies', 'peerDependencies'];

    for (const depType of depTypes) {
      if (!pkg[depType]) continue;

      for (const [depName, currentVersion] of Object.entries(pkg[depType])) {
        if (versionMap[depName]) {
          // 这是内部依赖,需要更新版本
          pkg[depType][depName] = `^${versionMap[depName]}`;
        }
      }
    }
  }
}

2. 发布时的包过滤

function getChangedPackages(commitRange) {
  // 1. 获取变更的文件
  const changedFiles = git.getChangedFiles(commitRange);

  // 2. 映射文件到包
  const changedPackages = new Set();

  for (const file of changedFiles) {
    const pkg = findOwningPackage(file);
    if (pkg) {
      changedPackages.add(pkg);

      // 3. 递归添加依赖此包的包
      const dependents = findDependents(pkg);
      for (const dep of dependents) {
        changedPackages.add(dep);
      }
    }
  }

  return Array.from(changedPackages);
}

端侧AI实战:Flutter离线语音识别的工程化落地

作者 明君87997
2026年1月26日 13:57

工地无信号?我用端侧AI实现了离线语音识别

当你的用户在地下三层的施工现场,手机信号只有一格甚至没有,却需要快速记录质检问题时,云端语音识别就成了一句空话。

从一个真实的痛点说起

作为一名移动端开发者,我负责一款工程质检类 App 的开发。这款应用的核心场景是:质检员在施工现场巡检时,需要快速记录发现的问题——钢筋间距不合规、混凝土浇筑质量问题、安全隐患等。

传统的做法是手动输入文字,但在工地环境下这几乎是一种折磨:

  • 戴着安全手套操作手机键盘,效率极低
  • 灰尘、噪音、光线等环境因素干扰
  • 质检员往往需要同时观察、拍照、记录,腾不出双手

语音输入是显而易见的解决方案。然而,当我兴冲冲地接入某云厂商的语音识别 API 后,现实给了我当头一棒:

工地没有信号。

是的,无论是高层建筑的电梯井道、地下室基坑,还是偏远郊区的新建工地,网络信号都是一个奢侈品。我们的用户反馈里,"语音功能不可用"成了高频词。

技术选型:端侧 AI 的崛起

既然云端不可靠,那就把 AI 搬到端侧。

经过调研,我将目光锁定在了 阿里达摩院的 Paraformer 模型上。这是一款专门针对中文优化的语音识别模型,具备以下特点:

特性 说明
离线运行 无需网络,本地推理
中文优化 针对普通话深度优化,识别准确率高
模型轻量 量化后约 70MB,移动端可接受
开源免费 Apache 2.0 协议,商用友好

配合 sherpa-onnx 推理引擎,可以在 iOS/Android 双平台实现高性能的本地语音识别。

架构设计:不只是能用,还要好用

技术可行性验证通过后,我开始思考如何设计一个对开发者友好、对用户体验友好的组件架构。

分层架构

┌─────────────────────────────────────────────┐
│              UI 组件层                       │
│  VoiceRecordButton  │  VoiceRecordOverlay   │
├─────────────────────────────────────────────┤
│              服务管理层                      │
│         VoiceRecognizerRegistry             │
├─────────────────────────────────────────────┤
│              核心服务层                      │
│  AudioRecorderService │ ParaformerRecognizer│
├─────────────────────────────────────────────┤
│              推理引擎层                      │
│            sherpa-onnx                      │
└─────────────────────────────────────────────┘

UI 组件层:开箱即用的长按录音按钮和语音输入弹窗,类似微信的交互体验。

服务管理层:这是我后来重构加入的一层,解决了一个关键的用户体验问题——下文详述。

核心服务层:音频采集与语音识别的核心逻辑。

推理引擎层:sherpa-onnx 提供的跨平台 ONNX 推理能力。

一个被忽视的体验问题

组件的第一版很快完成了,功能测试一切正常。然而在实际使用中,我发现了一个严重的体验问题:

每次打开语音功能,都要等待 2-3 秒的初始化时间。

这是因为 ONNX 模型需要从 assets 复制到沙盒目录,然后加载到内存。对于心急的质检员来说,这几秒钟的等待足以消磨他们的耐心。

于是我设计了 VoiceRecognizerRegistry —— 一个全局的服务注册中心:

// 应用启动时,异步预加载(不阻塞启动)
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // 后台静默初始化语音识别服务
  VoiceRecognizerRegistry.instance.preInitialize();
  
  runApp(MyApp());
}

核心思想很简单:把初始化前置到应用启动时。用户从打开 App 到真正使用语音功能,通常会有几秒到几十秒的间隔,足够完成模型加载。当用户真正点击麦克风按钮时,服务已经就绪,即点即用。

class VoiceRecognizerRegistry extends ChangeNotifier {
  static VoiceRecognizerRegistry? _instance;
  static VoiceRecognizerRegistry get instance {
    _instance ??= VoiceRecognizerRegistry._();
    return _instance!;
  }
  
  // 初始化状态
  InitializationStatus _status = InitializationStatus.notStarted;
  
  // 预初始化(异步,不阻塞)
  Future<bool> preInitialize() async {
    if (_status == InitializationStatus.ready) return true;
    if (_status == InitializationStatus.initializing) {
      return _initCompleter!.future;
    }
    
    _status = InitializationStatus.initializing;
    // ... 加载模型
  }
}

这个设计带来了几个好处:

  1. 零等待体验:用户感知不到初始化过程
  2. 资源复用:全局单例,避免重复加载模型
  3. 优雅降级:如果用户很快就使用语音功能,也能正常等待完成
  4. 状态可观测:提供完整的状态查询 API

交互设计:向微信学习

在交互层面,我选择了用户最熟悉的模式——长按说话,松开识别。这种交互方式有几个优点:

  1. 符合直觉:微信已经教育了用户
  2. 明确的开始/结束信号:不需要语音唤醒词或手动点击停止
  3. 可取消:手指滑开即可取消,容错性好

配合丰富的视觉反馈:

  • 🔵 脉冲呼吸动画:表示正在监听
  • 🌊 波纹扩散效果:增强"录音中"的感知
  • 📊 实时音量指示:让用户知道声音被采集到了
  • ⏱️ 录音时长显示:还剩多少时间
VoiceRecordButton(
  maxDuration: 10,
  showRipple: true,
  showDuration: true,
  enableHaptic: true,  // 触感反馈
  onResult: (text) {
    // 识别结果
  },
)

实际效果

经过两个版本的迭代,这套离线语音识别方案已经在生产环境稳定运行。来看一些数据:

指标 数值
模型加载时间 ~2s(首次)/ 0ms(预加载后)
识别延迟 < 500ms
识别准确率 ~95%(安静环境)/ ~85%(工地噪音)
内存占用 ~150MB
包体积增加 ~70MB

在工地的实际测试中,即使在地下两层、完全无信号的环境下,语音识别功能依然可以正常使用。质检员的记录效率提升了约 40%。

集成踩坑实录

这部分是我花了最多时间的地方。sherpa-onnx + Paraformer 的组合虽然强大,但集成过程中遇到了不少"暗坑"。希望我的经验能帮你少走弯路。

坑1:音频格式转换——PCM 16bit 到 Float32

问题现象:录音能正常采集,但识别结果总是空的,或者输出一堆乱码。

原因分析:Paraformer 模型要求输入的音频格式是 16kHz、Float32、单声道。而 record 插件输出的是 PCM 16bit 有符号整数。如果直接把 PCM 数据喂给模型,识别结果必然是错的。

解决方案:手动做格式转换,将 Int16 归一化到 [-1.0, 1.0] 的浮点数范围:

Float32List _pcm16ToFloat32(Uint8List pcm16) {
  final length = pcm16.length ~/ 2;
  if (length == 0) return Float32List(0);
  
  // 将字节数组视为 Int16 数组
  final int16Data = Int16List.view(pcm16.buffer, 0, length);
  final float32Data = Float32List(length);
  
  for (var i = 0; i < length; i++) {
    // Int16 范围是 -32768 ~ 32767,归一化到 -1.0 ~ 1.0
    float32Data[i] = int16Data[i] / 32768.0;
  }
  
  return float32Data;
}

注意事项:除数是 32768.0 而不是 32767.0,这是因为负数的范围比正数多一个(-32768 到 32767)。用 32768 可以保证归一化后的范围对称。


坑2:模型文件加载——Assets 到沙盒的复制

问题现象:sherpa-onnx 初始化失败,报错 "file not found" 或 "invalid model"。

原因分析:Flutter 的 assets 文件不能直接通过文件路径访问,必须先通过 rootBundle.load() 读取,然后写入到应用沙盒目录。sherpa-onnx 需要的是真实的文件系统路径

解决方案

Future<String?> _prepareModelFiles() async {
  try {
    final tempDir = await getTemporaryDirectory();
    final modelDir = Directory('${tempDir.path}/paraformer_model');
    
    // 确保目录存在
    if (!await modelDir.exists()) {
      await modelDir.create(recursive: true);
    }
    
    // 复制模型文件
    await _copyAssetToFile(
      'packages/voice_recognizer/assets/audio_model/model.int8.onnx',
      '${modelDir.path}/model.int8.onnx',
    );
    
    // 复制词表文件
    await _copyAssetToFile(
      'packages/voice_recognizer/assets/audio_model/tokens.txt',
      '${modelDir.path}/tokens.txt',
    );
    
    return modelDir.path;
  } catch (e) {
    debugPrint('准备模型文件失败: $e');
    return null;
  }
}

Future<void> _copyAssetToFile(String assetPath, String destPath) async {
  final file = File(destPath);
  // 避免重复复制
  if (!await file.exists()) {
    final data = await rootBundle.load(assetPath);
    await file.writeAsBytes(data.buffer.asUint8List());
  }
}

踩坑点

  • 路径前缀要用 packages/voice_recognizer/ 而不是 assets/,因为这是一个独立的 package
  • 首次复制 70MB 的模型文件需要 1-2 秒,要做好加载状态提示
  • 建议在开发阶段清除缓存重新复制,避免旧模型干扰调试

坑3:流式识别 vs 非流式识别——模型类型的选择

问题现象:使用 OnlineRecognizer(流式)初始化失败,或者识别效果很差。

原因分析:Paraformer 模型分为两种:

  • Paraformer-large:非流式,准确率高,但必须等整段音频录完才能识别
  • Paraformer-streaming:流式,可以边录边识别,但需要专门的流式模型

我最初下载的是非流式模型,却用流式 API 去加载,自然会出问题。

解决方案:根据模型类型选择对应的 API:

// 非流式识别(推荐,准确率更高)
final config = sherpa.OfflineRecognizerConfig(
  model: sherpa.OfflineModelConfig(
    paraformer: sherpa.OfflineParaformerModelConfig(
      model: '$modelDir/model.int8.onnx',
    ),
    tokens: '$modelDir/tokens.txt',
    numThreads: 2,
    provider: 'cpu',
  ),
);
_recognizer = sherpa.OfflineRecognizer(config);

// 使用时:先录完,再一次性识别
final stream = _recognizer.createStream();
stream.acceptWaveform(sampleRate: 16000, samples: allSamples);
_recognizer.decode(stream);
final result = _recognizer.getResult(stream);

我的选择:最终采用非流式识别。虽然不能边录边出结果,但准确率明显更高。对于质检场景,用户说完一句话通常也就几秒钟,等待是可以接受的。


坑4:Tokens 文件格式——JSON vs TXT

问题现象:模型加载成功,但识别结果全是乱码或者空白。

原因分析:sherpa-onnx 要求的 tokens 文件是纯文本格式(每行一个 token),而有些模型下载下来的是 JSON 格式。

错误的格式(JSON)

{"你": 0, "好": 1, "世": 2, "界": 3, ...}

正确的格式(TXT)

你
好
世
界
...

解决方案:写个脚本转换一下,或者直接去 ModelScope/HuggingFace 下载正确格式的 tokens.txt。


坑5:iOS 真机调试——动态库签名问题

问题现象:模拟器运行正常,真机运行崩溃,报错 "code signature invalid"。

原因分析:sherpa-onnx 的 iOS 动态库需要正确签名才能在真机运行。

解决方案

  1. 在 Xcode 中选择正确的开发者证书
  2. 确保 Podfile 中配置了动态库嵌入:
post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'YES'
    end
  end
end
  1. Clean Build Folder 后重新编译

坑6:Android 64位兼容——armeabi-v7a 的缺失

问题现象:部分 Android 设备启动崩溃,报错 "couldn't find libsherpa-onnx-jni.so"。

原因分析:sherpa-onnx 默认只提供 arm64-v8a 的 so 库,而一些老设备是 32 位的。

解决方案:在 android/app/build.gradle 中限制 ABI:

android {
    defaultConfig {
        ndk {
            abiFilters 'arm64-v8a', 'x86_64'  // 只支持64位
        }
    }
}

或者去 sherpa-onnx 官方下载 32 位的库(如果需要兼容老设备)。


坑7:内存泄漏——Stream 对象未释放

问题现象:多次录音后,内存占用持续上涨,最终 OOM 崩溃。

原因分析:每次调用 recognizer.createStream() 都会创建新的 Stream 对象,但 Dart 的 GC 不会自动释放 native 内存。

解决方案:识别完成后主动释放资源,并复用 Recognizer 实例:

// 全局复用 Recognizer
class VoiceRecognizerRegistry {
  SimpleParaformerRecognizer? _recognizer;  // 单例复用
  
  // 每次识别创建新的 Stream,用完即弃
  // Stream 是轻量级的,Recognizer 是重量级的
}

// 必要时释放全部资源
void reset() {
  _recognizer?.dispose();
  _recognizer = null;
}

坑8:采样率不匹配——16kHz 的强制要求

问题现象:识别结果偶尔正确,偶尔完全错误,没有规律。

原因分析record 插件在不同设备上的默认采样率不同(44.1kHz、48kHz 等),而 Paraformer 只接受 16kHz。

解决方案:强制指定采样率:

await _recorder.start(
  const RecordConfig(
    encoder: AudioEncoder.pcm16bits,
    sampleRate: 16000,  // 必须是 16000
    numChannels: 1,      // 单声道
  ),
  path: '', // 流式录制,不保存文件
);

坑9:权限处理——iOS 的特殊性

问题现象:Android 正常,iOS 首次使用时直接崩溃。

原因分析:iOS 对麦克风权限非常严格,必须在 Info.plist 中声明,且需要在使用前检查权限状态。

解决方案

<!-- Info.plist -->
<key>NSMicrophoneUsageDescription</key>
<string>需要使用麦克风进行语音输入</string>
// 使用前检查权限
Future<bool> _checkPermission() async {
  var status = await Permission.microphone.status;
  if (status.isDenied) {
    status = await Permission.microphone.request();
  }
  return status.isGranted;
}

用户体验建议:不要在 App 启动时就申请权限,而是在用户首次点击麦克风按钮时再申请。这样用户更容易理解为什么需要这个权限。


坑10:调试困难——如何定位识别失败的原因

问题现象:识别结果为空,但不知道是录音问题还是模型问题。

解决方案:我加了一套完整的调试日志:

// 1. 检查音频数据
debugPrint('音频样本数: $totalLength, 时长约 ${totalLength / sampleRate} 秒');

// 2. 检查音频数据范围(应该在 -1.0 ~ 1.0 之间)
double minVal = 0, maxVal = 0;
for (var sample in allSamples) {
  if (sample < minVal) minVal = sample;
  if (sample > maxVal) maxVal = sample;
}
debugPrint('音频数据范围: min=$minVal, max=$maxVal');

// 3. 保存 WAV 文件用于人工检查
// 录下来的音频如果人耳都听不清,那模型肯定也识别不了

终极调试手段:把录制的音频保存成 WAV 文件,用电脑播放听一下。如果人耳都听不清楚,那就是录音环节的问题;如果听得清但识别不出,那就是模型或参数的问题。

写在最后

端侧 AI 正在重塑移动应用的能力边界。曾经必须依赖云端的能力——语音识别、图像识别、自然语言处理——现在都可以在用户的设备上本地运行。

对于我们这种特定场景的应用(弱网/无网环境),端侧 AI 不是"可选项",而是"必选项"。它让技术真正服务于用户,而不是让用户迁就技术的局限。

如果你也在开发类似场景的应用,希望这篇文章能给你一些启发。完整的组件代码已开源,欢迎 Star 和 PR。

pub.dev链接

技术栈:Flutter + Dart + sherpa-onnx + Paraformer-zh

写于 2026 年 1 月,一个终于不用担心"无信号"的夜晚。

了解JS中的对象

作者 随逸177
2026年1月26日 13:54

JS中的对象

对象介绍

在JavaScript中的对象是可变的键控集合。在JS中,数组是对象,函数是对象,正则表达是对象,同时,对象自然也是对象。简单点来讲,JS就像是一个"收纳盒",在气里面可以装各种类型的数据,每一个数据都有一个"名字"(键),方便用来查找和使用。

对象字面量

JS中的对象字面量就是用{}(花括号)直接创建 JS 对象的语法形式,是一种简洁、直观的 “字面量语法”,不需要借助构造函数或类(ES6 class),直接写出对象的属性和方法。 -基本语法与示例

// 这就是一个对象字面量,直接用{}定义属性

const person = {
   // 键(属性名): 值(属性值),多个键值对用逗号分隔
   "first-name":"yjy",// 特殊属性名必须用引号包裹
   name: "yjy",    // 字符串属性
   age: 22,         // 数字属性
   };
   // 访问对象属性
   console.log(person["first-name"]);//输出:yjy
   console.log(person.name); // 输出:yjy
   console.log(person.age);  // 输出:22

console.log(person.age); // 输出:22

在这里面值得一提的是,在对象字面量中,如果属性名是一个合法的JS标识符且不是保留字,则并不强制要求用引号括住属性名,其中"first-name"是特殊属性名,所以用引号括住"first-name"是必需的。

JS与传统面向对象语言(Java/C++/C#)创建对象的不同

  • 如(Java)对象是类的实例,类是对象的 “模板”,先定义类才能创建对象
  • JS对象是无类型的键值对集合,本身就是基础单元,可直接创建,无需先定义 “模板”
  • 两者实例对比
  • Java创建对象

// 第一步:定义类

class Person {
    String name;
    int age;
    // 构造方法
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
      }
    }

// 第二步:创建类的实例(对象) Person p = new Person("yjy", 22);

  • JS创建对象(直接创建,无需类)
// 直接创建对象,无类的约束
const person = {
name: "yjy",
age: 22,
sayHi: function() {
    console.log("你好");
  }
};

JS对象的核心操作

要操作JS对象,首先便是要系统的了解JS对象的核心操作:检索、更新、引用等,了解它们才能去掌控对象的使用和管理。

  • 检索:检索就是获取对象中属性的值,其可采用[]后缀包括一个字符串表达式的方式。如果字符串是一个字符串字面量,且是一个合法的JS标识符且不是保留字,可以用点语法(.)代替

   const obj = { name: "yjy", "user-age": 22 };
   console.log(obj.name);       // 点语法:yjy,适合属性名是合法标识符(无特殊字符、不以数字开头)的场景,简洁直观。
   console.log(obj["user-age"]); // 方括号语法:22,适合属性名含特殊字符(如-、空格)、动态生成属性名的场景,是通用方式。
  • 更新(修改、新增属性):JS 对象是动态的,更新包含 “修改已有属性” 和 “新增属性”,语法和检索对应,对象的值可通过赋值更新,如果属性名已经存在与对象里,那么这个属性的值会被替换,如果对象之前没有这个属性名,那将会扩充到对象中。

   const obj = { name: "yjy" };
   obj.name = "yjy_new"; // 修改已有属性
   obj.age = 22;         // 新增属性
   console.log(obj); // 输出:{ name: 'yjy_new', age: 22}
  • 引用:对象通过引用传递。它们将永远不会被复制

   const a = { name: "yjy" };
   const b = a; // b复制的是a的引用,而非对象本身
   b.name = "李四";//修改b的属性,a也会变(指向同一个对象)
   console.log(a.name); // 输出:李四(a和b指向同一对象)
  
   const c = { ...a }; // 浅拷贝解除引用
   c.name = "王五";
   console.log(a.name); // 输出:李四(原对象不受影响)
  • 删除:用delete操作符或Reflect.deleteProperty删除对象的自有属性(无法删除原型属性)
   const user = { name: "yjy", age: 22 };
   delete user.age; // delete操作符
   console.log(user.age); // 输出:undefined
   const res = Reflect.deleteProperty(user, "name"); // 反射删除
   console.log(res, user); // 输出:true {}(删除成功)
   console.log(user.name); // 输出:undefined

除这些还有原型、枚举等操作,我们还需要对它们有着熟练的掌握,才能去掌控对象使用和管理。

声明文件.d.ts:编写自己的类型定义

作者 wuhen_n
2026年1月26日 13:46

在 TypeScript 中,声明文件 .d.ts 就像是 JavaScript 库的"使用说明书"。它告诉 TypeScript 如何理解没有类型信息的代码。本篇文章将深入探讨如何编写高质量的声明文件,为任何 JavaScript 库添加类型安全。

声明文件基础

什么是声明文件

声明文件(.d.ts 是只包含类型信息的 TypeScript 文件。它不包含具体的实现代码,只告诉 TypeScript 某个值的存在和它的类型。

我们可以看一个简单的例子,比如有一个JavaScript库,但没有类型信息:

window.myLibrary = {
  version: "1.0.0",
  greet(name) {
    return `Hello, ${name}!`;
  }
};

此时,我们需要为 window 创建一个类型声明:global.d.ts,用来告诉 TypeScript:window 上有一个 myLibrary 对象:

interface MyLibrary {
  version: string;
  greet(name: string): string;
}

declare global {
  interface Window {
    myLibrary: MyLibrary;
  }
}

加上声明文件后,TypeScript 就能理解这个库了,我们就可以安全地使用:

console.log(window.myLibrary.version);  // ✅ 类型安全
const greeting = window.myLibrary.greet("zhangsna");  // ✅ 知道返回string
// window.myLibrary.unknownMethod();  // ❌ 编译错误:方法不存在

为什么需要声明文件?

  • 为纯 JavaScript 库添加类型支持。
  • 描述已有 JavaScript 代码的类型。
  • 共享类型定义(通过 @types 包)。
  • 提前声明尚未实现的接口。

在后面的文章中,会详细讲解 @types 包的工作原理与最佳实践。

全局声明 vs 模块声明

全局声明:会影响整个项目

全局声明 意味着在项目的任何地方都可以访问,不需要导入。通常用于:

  • 全局变量(如window、document)。
  • 库直接暴露在全局作用域。
  • 自定义全局类型。

模块声明:需要导入才能使用

模块声明 需要通过 import 语句导入才能使用。现在大多数npm包使用的就是这种方式。

模块声明导出

// my-library.d.ts:描述一个外部模块

// 导出类型
export interface User {
  id: number;
  name: string;
  email: string;
}

export type UserRole = "admin" | "user" | "guest";

// 导出函数
export function createUser(name: string, email: string): User;
export function getUserById(id: number): Promise<User>;

// 导出类
export class UserManager {
  private users: User[];
  addUser(user: User): void;
  removeUser(id: number): boolean;
  getAllUsers(): User[];
}

// 导出常量
export const DEFAULT_ROLE: UserRole;

// 默认导出
export default UserManager;

模块声明导入

// 使用模块声明
import UserManager, { User, createUser } from "my-library";

const manager = new UserManager();
const user: User = createUser("zhangsan", "zhangsan@example.com");
manager.addUser(user);

混合声明:同时支持全局和模块

有些库既支持全局使用,也支持模块导入。

// jquery.d.ts 的简化示例
// 模块导出
declare module "jquery" {
  interface JQuery {
    // jQuery方法
    hide(): JQuery;
    show(): JQuery;
    css(property: string, value: string): JQuery;
    // ... 更多方法
  }
  
  function $(selector: string): JQuery;
  
  export = $;
}

// 全局声明(当通过script标签引入时)
declare global {
  interface JQuery {
    hide(): JQuery;
    show(): JQuery;
    css(property: string, value: string): JQuery;
  }
  
  const $: (selector: string) => JQuery;
}

// 使用方式1:模块导入
// import $ from "jquery";
// $("#myElement").hide();

// 使用方式2:全局使用(通过script标签引入后)
// $("#myElement").show();

declare关键字全解

declare的基本用法

declare 关键字告诉 TypeScript:"这个值在别处已经存在,我只是告诉你它的类型"。

// 声明变量
declare const VERSION: string;
declare let config: AppConfig;  // 使用let表示可以重新赋值

// 声明函数
declare function calculate(x: number, y: number): number;

// 声明类
declare class Person {
  name: string;
  age: number;
  constructor(name: string, age: number);
  greet(): string;
}

// 声明枚举
declare enum Status {
  Pending,
  Active,
  Inactive
}

// 声明命名空间
declare namespace MyApp {
  interface Config {
    apiUrl: string;
    timeout: number;
  }
  
  function initialize(config: Config): void;
}

// 声明模块
declare module "my-module" {
  export function doSomething(): void;
  export const importantValue: number;
}

declare的进阶用法

声明全局增强:给已有类型添加新属性

declare global {
  // 给String添加自定义方法
  interface String {
    toCamelCase(): string;
    toSnakeCase(): string;
  }
  
  // 给Array添加方法
  interface Array<T> {
    findBy(predicate: (item: T) => boolean): T | undefined;
    groupBy(key: keyof T): Record<string, T[]>;
  }
}

声明合并:多次声明同一个接口,TypeScript会自动合并

interface User {
  id: number;
  name: string;
}

// 在另一个文件中可以扩展
interface User {
  email: string;  // 添加到User接口
}

// 最终User接口有:id, name, email

条件声明:根据不同环境声明不同的类型

declare const process: {
  env: {
    NODE_ENV: "development" | "production" | "test";
    API_URL?: string;
    DEBUG?: string;
  };
};

类型守卫声明:声明一个函数是类型守卫

declare function isString(value: unknown): value is string;
declare function isUser(value: unknown): value is User;

declare的特殊语法

declare const vs declare let

// const:声明常量,不能重新赋值
declare const API_KEY: string;
// API_KEY = "new";  // ❌ 错误

// let:声明变量,可以重新赋值
declare let currentUser: User | null;
currentUser = { id: 1, name: "Alice" };  // ✅ 可以

declare function 的函数重载

declare function createElement(tag: "div"): HTMLDivElement;
declare function createElement(tag: "span"): HTMLSpanElement;
declare function createElement(tag: string): HTMLElement;

declare class 的抽象类

declare abstract class Animal {
  abstract makeSound(): void;
  move(): void {
    console.log("Moving...");
  }
}

declare namespace 的嵌套

declare namespace Geometry {
  export interface Point {
    x: number;
    y: number;
  }
  
  export namespace Shapes {
    export interface Circle {
      center: Point;
      radius: number;
    }
    
    export interface Rectangle {
      topLeft: Point;
      width: number;
      height: number;
    }
  }
}

// 使用:Geometry.Shapes.Circle

为无类型库添加类型支持

分析JavaScript库的API:了解库的使用方式

// 全局变量形式
window.calculator = {
  PI: 3.14159,
  add: function(a, b) { return a + b; },
  subtract: function(a, b) { return a - b; },
  multiply: function(a, b) { return a * b; },
  divide: function(a, b) { return a / b; },
  // 高级功能
  calculate: function(operation, a, b) {
    switch(operation) {
      case 'add': return a + b;
      case 'subtract': return a - b;
      case 'multiply': return a * b;
      case 'divide': return a / b;
      default: throw new Error('Unknown operation');
    }
  }
};

创建类型声明文件:根据API编写声明文件

interface Calculator {
  // 常量
  readonly PI: number;
  
  // 基本运算方法
  add(a: number, b: number): number;
  subtract(a: number, b: number): number;
  multiply(a: number, b: number): number;
  divide(a: number, b: number): number;
  
  // 高级方法
  calculate(
    operation: "add" | "subtract" | "multiply" | "divide",
    a: number,
    b: number
  ): number;
  
  // 可能还有错误处理
  lastError?: string;
  clearError(): void;
}

// 全局声明
declare global {
  // 挂载在window上
  interface Window {
    calculator: Calculator;
  }
  
  // 也可以直接暴露为全局变量(如果库也这样做了)
  const calculator: Calculator;
}

// 如果库也支持模块导入,添加模块声明
declare module "simple-calculator" {
  const calculator: Calculator;
  export = calculator;
}

// 现在TypeScript就能理解calculator库了

发布和维护类型声明

发布到 @types 仓库

// package.json配置示例
{
  "name": "@types/my-library",
  "version": "1.0.0",
  "description": "TypeScript definitions for my-library",
  "license": "MIT",
  "contributors": [
    {
      "name": "Your Name",
      "githubUsername": "yourusername"
    }
  ],
  "main": "",
  "types": "index.d.ts",
  "repository": {
    "type": "git",
    "url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git"
  },
  "scripts": {},
  "dependencies": {
    // 如果有依赖的类型包
    "@types/node": "*"
  },
  "typesPublisherContentHash": "",
  "typeScriptVersion": "4.5"
}

与源库捆绑发布

// 在库的package.json中
{
  "name": "my-library",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",  // 指向类型声明文件
  "files": [
    "dist",
    "*.d.ts"  // 包含声明文件
  ],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  }
}

常见问题与解决方案

如何处理过于动态的API?

// 使用索引签名或any类型
interface VeryDynamicAPI {
  [key: string]: any;  // 允许任意字符串键,任意值
  [key: number]: string;  // 允许数字键,必须是string值
  
  // 或者更精确的
  [key: `get${string}`]: () => any;  // 模板字面量类型
  [key: `set${string}`]: (value: any) => void;
}

// 或者使用类型断言
declare const dynamicLib: any;  // 整个库都是any
const result = (dynamicLib as { doSomething: () => number }).doSomething();

如何处理依赖的类型?

// 在声明文件中声明依赖
declare module "my-library" {
  import { Request, Response } from "express";
  
  // 使用依赖的类型
  function middleware(req: Request, res: Response): void;
  
  export { middleware };
}

如何保持与源库的同步?

  • 自动化工具:使用dts-gen生成基础声明。
  • 版本控制:声明文件版本与源库版本保持一致。
  • 持续集成:测试类型声明与最新版本兼容性。
  • 社区协作:鼓励用户提交类型改进。

结语

声明文件是 TypeScript 生态系统的关键部分,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

根据《JavaScript语言精粹》了解JavaScript函数

作者 暗不需求
2026年1月26日 13:35

《JavaScript语言精粹》第四章:函数——JavaScript的精髓与艺术

在《JavaScript语言精粹》第四章的开篇,Douglas Crockford就明确指出:

“JavaScript中最好的特性就是它对函数的实现。它几乎无所不能。”

这一章不仅仅是在讲“如何写函数”,而是在揭示JavaScript这门语言的核心编程范式——函数作为一等公民,是如何渗透在语言的每一个角落,成为构建可靠、可维护、富有表现力代码的基石。

作为JavaScript初学者,我最近深入阅读了《JavaScript语言精粹》第四于对象的章节。函数在JavaScript中无处不在,是构建一切的基础。

4.1 函数对象:函数也是对象

在JavaScript中,函数就是对象。这一点是理解JavaScript函数所有高级特性的起点。

每个函数对象在创建时都会连接到Function.prototype(该原型又连接到Object.prototype),并且附带两个隐藏属性:

  • 函数的上下文
  • 实现函数行为的代码

更重要的是,每个函数都有一个prototype属性,其值是一个包含constructor属性且指向该函数的对象。这种设计为后续的原型继承构造器模式奠定了基础。

由于函数是对象,它们可以:

  • 被赋值给变量或数组元素
  • 作为参数传递
  • 作为返回值
  • 拥有自己的属性和方法
// 函数作为值传递
var add = function(a, b) {
    return a + b;
};

// 函数作为方法
var calculator = {
    add: function(a, b) { return a + b; },
    multiply: function(a, b) { return a * b; }
};

4.2 函数字面量

函数字面量是创建函数对象的主要方式,它包含四个部分。

第一部分,保留字function 告诉JavaScript解释器:接下来的代码是一个函数定义。这是一个语法标记,就像var用于变量声明一样。

第二部分,函数名 是可选的,但这不影响函数的本质。匿名函数在JavaScript中非常常见,特别是在函数式编程和回调模式中。当函数有名字时,这个名字可以在函数内部用于递归调用,也可以被调试工具用于识别函数。但有趣的是,即使函数没有名字,它仍然是一个完整的函数对象,这体现了JavaScript对匿名函数的友好支持。

第三部分,参数列表 定义了函数接收的输入。JavaScript的参数处理非常灵活:参数不需要类型声明,实参和形参的数量可以不匹配。这种灵活性既是优点也是缺点——它让函数接口变得简单,但也增加了运行时错误的风险。

第四部分,函数体 包含了函数要执行的语句。这里有一个重要特性:函数体内部可以访问外部函数的变量,这就是闭包的基础。函数体中的变量和参数构成了函数的局部作用域,这是JavaScript实现信息隐藏和模块化的关键机制。

// 具名函数字面量
var factorial = function factorial(n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1); // 可递归调用
};

// 匿名函数字面量
var greet = function(name) {
    return "Hello, " + name + "!";
};

4.3 调用

函数的调用方式决定了this的绑定值,JavaScript有四种调用模式:

方法调用模式

当一个函数被保存为一个对象的一个属性时,我们称它为方法。当一个方法被调用时,this被绑定到该对象。如果调用表达式包含一个提取属性的动作(.[]),那么它就是被当作一个方法来调用的。

var myObject = {
    value: 0,
    increment: function(inc) {
        this.value += typeof inc === 'number' ? inc : 1;
    }
};

myObject.increment();  // this指向myObject
console.log(myObject.value); // 1

函数调用模式

当一个函数并非一个对象的属性时,那么他就是被当作一个函数来调用的。此模式调用函数时,this被绑定到全局对象

注意:这是一个在语言上设计的缺陷,倘若设计正确,当内部函数被调用时,this应该绑定带为不含念书的this变量。这个设计导致方法不能利用内部函数来帮助它工作,因此内部函数的this被绑定错误的值。所以不能共享该方法对对象的访问权。幸运的是,该方法可以定义一个变量并且把它赋值为this,那么内部函数可以通过那个变量访问到this。我们一般将这个变量约定为that

var sum = add(3, 4); // this指向全局对象

// 解决方法:在方法内部捕获this
myObject.double = function() {
    var that = this; // 捕获this
    
    var helper = function() {
        that.value = add(that.value, that.value);
    };
    
    helper(); // 函数调用,但能访问that
};

构造器调用模式

JavaScript是一门基于原型继承的语言。这意味着对象可以直接从其他对象继承属性。该语 言是无类别的。

这偏离了当今编程语言的主流。当今大多数语言都是基于类的语言。尽管原型继承有着强 大的表现力,但它并不被广泛理解。JavaScript本身对其原型的本质也缺乏信心,所以它提 供了一套和基于类的语言类似的对象构建语法。有类型化语言编程经验的程序员们很少有 愿意接受原型继承的,并且认为借鉴类型化语言的语法模糊了这门语言真实的原型本质。 真是两边都不讨好。

如果在一个函数前面带上 new 来调用,那么将创建一个隐藏连接到该函数的 prototype 成员的新对象,同时 this 将会被绑定到那个新对象上。

var Quo = function(string) {
    this.status = string;
};

Quo.prototype.get_status = function() {
    return this.status;
};

var myQuo = new Quo("confused");
console.log(myQuo.get_status()); // "confused"

Apply调用模式

因为JavaScript 是一门函数式的面向对象编程语言,所以函数可以拥有方法。

apply 方法让我们构建一个参数数组并用其去调用函数。它也允许我们选择 this的值。 apply方法接收两个参数。第一个是将被绑定给 this的值。第二个就是一个参数数组。

var array = [3, 4];
var sum = add.apply(null, array); // sum = 7

var statusObject = { status: 'A-OK' };
var status = Quo.prototype.get_status.apply(statusObject); // 'A-OK'

4.4 参数

JavaScript的函数参数处理非常灵活:

  • 实参与形参数量不匹配不会报错
  • 多出的参数被忽略,缺少的参数为undefined
  • 没有类型检查
  • 所有函数都可访问arguments伪数组
var sum = function() {
    var total = 0;
    for (var i = 0; i < arguments.length; i++) {
        total += arguments[i];
    }
    return total;
};

console.log(sum(4, 8, 15, 16, 23, 42)); // 108

4.5 返回

每个函数调用都会返回一个值:

  • 使用return显式返回值
  • return语句时返回undefined
  • 构造器调用时,如果返回值不是对象,则返回this

4.6 异常

JavaScript使用try-catch机制处理异常:

var add = function(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
        throw {
            name: 'TypeError',
            message: 'add needs numbers'
        };
    }
    return a + b;
};

var try_it = function() {
    try {
        add("seven");
    } catch (e) {
        console.log(e.name + ': ' + e.message); // TypeError: add needs numbers
    }
};

4.7 给类型增加方法

JavaScript允许给基本类型添加方法,这是非常强大的特性

我们可以通过function.prototype增加一个method方法

由于JavaScript并没有单独的整数类型,有时候只提取数字中的整数部分是必要的。我们也可以通过number.prototype添加一个integer方法来改善。

同时缺少一个移除字符串末端空白的方法,可以通过string.trim

// 给Function.prototype添加method方法
Function.prototype.method = function(name, func) {
    this.prototype[name] = func;
    return this;
};

// 给Number添加integer方法
Number.method('integer', function() {
    return Math[this < 0 ? 'ceil' : 'floor'](this);
});

console.log((-10 / 3).integer()); // -3

// 给String添加trim方法
String.method('trim', function() {
    return this.replace(/^\s+|\s+$/g, '');
});

console.log('"' + "   neat   ".trim() + '"'); // "neat"

4.8 递归

递归是一种强大的编程技术,它将问题分解为相似的子问题。在JavaScript中,递归特别适合处理树形结构(如DOM)或数学上递归定义的问题(如斐波那契数列)。

递归函数有两个关键部分:基本情况(base case)和递归情况(recursive case)。基本情况处理最简单的情况并直接返回结果,递归情况则将问题分解并调用自身。

递归函数直接或间接调用自身,适合解决分治类问题:

// 汉诺塔问题
var hanoi = function(disc, src, aux, dst) {
    if (disc > 0) {
        hanoi(disc - 1, src, dst, aux);
        console.log('Move disc ' + disc + ' from ' + src + ' to ' + dst);
        hanoi(disc - 1, aux, src, dst);
    }
};

hanoi(3, 'Src', 'Aux', 'Dst');

4.9 作用域

编程语言中,作用域控制着变量与参数的可见性及生命周期。对程序员来说这是一个重要的帮助,因为它减少了名称冲突,并且提供了自动内存管理。

var foo = function() {
    var a = 3, b = 5;
    
    var bar = function() {
        var b = 7, c = 11;
        // 此时a为3,b为7,c为11
        a += b + c;
        // 此时a为21,b为7,c为11
    };
    
    bar();
    // 此时a为21,b为5
};

4.10 闭包

闭包是内部函数可以访问外部函数的变量,即使外部函数已经返回。

之前,我们构造了一个myobject 对象,它拥有一个 value 属性和一个 increment 方法。假定我们希望保护该值不会被非法更改。 和以对象字面量形式去初始化 myobject 不同,我们通过调用一个函数的形式去初始化myObject ,该函数将返回一个对象字面量。此函数定义了一个 value 变量。该变量对increment 和 getValue 方法总是可用的,但函数的作用域使得它对其他的程序来说是不可见的。

var myObject = function() {
    var value = 0;
    
    return {
        increment: function(inc) {
            value += typeof inc === 'number' ? inc : 1;
        },
        getValue: function() {
            return value;
        }
    };
}();

myObject.increment(2);
console.log(myObject.getValue()); // 2

经典的循环中的闭包问题及解决方案:

// 错误的方式:所有事件处理器都显示i的最终值
var add_the_handlers_bad = function(nodes) {
    var i;
    for (i = 0; i < nodes.length; i++) {
        nodes[i].onclick = function(e) {
            alert(i); // 总是显示nodes.length
        };
    }
};

// 正确的方式:使用闭包捕获每个i的值
var add_the_handlers_good = function(nodes) {
    var i;
    for (i = 0; i < nodes.length; i++) {
        nodes[i].onclick = function(i) {
            return function(e) {
                alert(i);
            };
        }(i);
    }
};

4.11 回调

回调函数使得处理不连续事件成为可能,是异步编程的核心:

// 同步请求(会阻塞)
request = prepare_the_request();
response = send_request_synchronously(request);
display(response);

// 异步请求(更好的方式)
request = prepare_the_request();
send_request_asynchronously(request, function(response) {
    display(response);
});

4.12 模块

模块是一个提供接口却隐藏状态与实现的函数或者对象。

String.method('deentityify', function() {
    var entity = {
        quot: '"',
        lt: '<',
        gt: '>'
    };
    
    return function() {
        return this.replace(/&([^&;]+);/g, 
            function(a, b) {
                var r = entity[b];
                return typeof r === 'string' ? r : a;
            }
        );
    };
}());

console.log('&lt;&quot;&gt;'.deentityify()); // <">

4.13 级联

通过让方法返回this而不是undefined,可以实现优雅的链式调用。有一些方法没有返回值。

例如,一些设置或修改对象的某个状态却不返回任何值的方法就是典型的例子。如果我们让这些方法返回 this 而不是 undefined,就可以启用级联。在一个级联中,我们可以在单独一条的语句中依次调用同一个对象的很多方法。一个启用级联的Ajax类库可能允许我们以这样的形式去编码:

// 假设的Ajax库API
getElement('myBoxDiv')
    .move(350, 150)
    .width(100)
    .height(100)
    .color('red');

4.14 套用

套用允许我们将函数与部分参数结合,产生新函数。

curry 方法通过创建一个保存着原始函数和被套用的参数的闭包来工作。它返回另一个函数,该函数被调用时,会返回调用原始函数的结果,并传递调用curry时的参数加上当前调用的参数的所有参数。它使用 Array 的concat 方法去连接两个参数数组。

Function.method('curry', function() {
    var args = arguments, that = this;
    return function() {
        return that.apply(null, args.concat(arguments));
    };
});

var add1 = add.curry(1);
console.log(add1(6)); // 7

4.15 记忆

函数可以用对象去记住先前操作的结果,从而能避免无谓的运算。这种优化被称为记忆。

记忆通过缓存先前计算结果来优化性能:

var fibonacci = function() {
    var memo = [0, 1];
    var fib = function(n) {
        var result = memo[n];
        if (typeof result !== 'number') {
            result = fib(n - 1) + fib(n - 2);
            memo[n] = result;
        }
        return result;
    };
    return fib;
}();

// 通用的memoizer函数
var memoizer = function(memo, formula) {
    var recur = function(n) {
        var result = memo[n];
        if (typeof result !== 'number') {
            result = formula(recur, n);
            memo[n] = result;
        }
        return result;
    };
    return recur;
};

var fibonacci = memoizer([0, 1], function(recur, n) {
    return recur(n - 1) + recur(n - 2);
});

var factorial = memoizer([1, 1], function(recur, n) {
    return n * recur(n - 1);
});

总结:掌握函数,掌握JavaScript

第四章向我们展示了JavaScript函数的全貌——从基础的对象特性到高级的函数式编程概念。理解这些知识点意味着:

  1. 理解JavaScript的面向对象:函数作为构造器、原型继承的基础
  2. 掌握异步编程:回调函数、闭包的应用
  3. 编写模块化代码:利用闭包实现信息隐藏
  4. 优化性能:记忆、延迟计算等技术
  5. 提升代码表现力:级联、套用等模式让代码更优雅

正如Crockford所言,函数是JavaScript的精华所在。所以一定要认真负责的学好函数。

在AI时代下,技术人做自己的产品要遵循30天原则来执行

2026年1月26日 13:28

30 天执行拆解(周级)

这30天不是自由发挥。

它是一个被刻意压缩、用于逼出真实反馈到路径。

你不需要灵感,只需要按周执行。


Week 1:确定“有人愿意付钱的事”

第一周只做一件事情: 找出你现在就能卖的东西。

不是你“想做的”,而是你已经做过的事情。

行动只有三步:

  • 回顾你过去解决过的问题
  • 提炼一个“可售承诺”
  • 写一句话描述 + 定价价格

这个承诺必须满足一个条件: 你现在就能交付。

如果你写的是:“未来我会把它做成一个平台”。

那不是承诺,那是逃避定价。


Week 2:最小交付准备

第二周,你要为一个假设准备:

如果今天真的有人付钱,我怎么交付?

注意,这一步不是做产品。

行动重点:

  • 准备最小交付方式

  • 不自动化

  • 可手动完成

这一步是为“交付”服务,不是为“产品”。

如果你开始纠结架构、复用、扩展性,说明你已经偏航了。


Week 3:真实曝光

第三周是一整条路径中“心理阻力”最大的一步。

行动很简单:

  • 把链接发给真实的人看

  • 不解释

  • 不说“还在优化”

📌 规则: 你只能给链接,不能给理由。

任何的解释本质上是在保护自己的自尊,提前为失败找借口。


Week 4:交付 & 复盘

第四周不管结果如何,都需要收尾。

如果有人付钱:

  • 完成交付
  • 记录过程
  • 写复盘

如果没人付钱:

  • 复盘为什么没人动
  • 判断问题出在“承诺/定价/曝光”的哪一步

这一步只回答一个问题“是否值得继续投入”。


第一次现金流的真正意义 它改变的不是收入 第一次现金流,金额几乎不重要。

真正的改变:

  • 身份
  • 心态
  • 决策方式

一块钱的真实收入,胜过一万个“将来会赚”。

因为这一块钱证明了一件事情:你和世界完成了一次交换。


为什么这一步会“解锁”你? 一旦完成过真实的交易,你会明显改观

  • 不再幻想用户

  • 不再害怕发布

  • 不再盲目构建

你开始的问题会变成:

  • 别人为什么愿意付钱?

  • 他们真正想要什么?

从这之后,你就不会问自己“我的产品酷不酷”。


失败兜底机制(非常重要)

如果 30 天没有任何收入? 这是非常常见的一件事情,完全可以接受。

因为你做的事情,是绝大多数技术人一辈子没做过的。


你至少得到的三样东西

即使没有收入,你也已经获得了:

1️⃣ 定价经验

2️⃣ 暴露经验

3️⃣ 真实反馈

📌 核心: 你现在拥有“继续构建”的现实基础。

这不是失败,这是你第一次站在了现实里。


本章总结

你不需要成为创业者 你不需要融资、增长、规模化。

你只需要成为: 一个完成过价值交换的构建者

这个过程一旦发生过,你之后做的所有项目都不一样。


明天的第一步

不用等30天。

明天就做一件事,写一句你愿意为之收费的承诺,并标一个价格。

哪怕没人买,你也迈出了最重要的一步。


压轴句

收入并不能证明成功与否,只证明了它与显示的联系。

安全学习笔记:Goldeneye靶机记录 基本信息收集与分析

作者 古手羽入
2026年1月26日 11:46

Author:FrancesBeatrice 2026.1 参照Mapsec大佬的视频跟做的,十分感谢大佬的指导。 第一次写这样的,刚刚开始入坑Web攻防,请大家有意见提出。

最终目的:收集信息,获得管理员账号密码进行反弹Shell,根据信息搜索提权CVE,传入靶机运行获取Flag

本次靶机的漏洞原因以及破解、修复对策:

POP3

账号密码直接在网络中明文传输,认证简单,并且抓包就能看到密码,本靶机中没有其他防护。 攻击方法:Hydra 不需要“解密”,它只是模拟合法客户端反复试密码。我们固定的账号,剩下的工作就是遍历弱密码字典反复试密码。 该方法在本靶机非常有效,在这里知道邮箱号就能得到密码,使部分流程简化了找邮箱号。 修复对策:强制使用 POP3S(端口 995) 加密传输,防止中间人窃听。同时部署 fail2ban 防爆破,使其监控 POP3 日志,自动封禁多次失败的 IP。

Moodle 2.2.3 存在已知的 CVE-2013-3630 远程代码执行漏洞

该版本 Moodle 在调用拼写检查时未对输入进行安全过滤,调用外部Shell命令,导致命令直接在服务器执行,从而实现远程代码执行。 攻击方法:直接搜索msfconsole模块,选用moodle_spelling_binary_rce 模块进行RCE攻击。 修复对策:Spell engine禁用PSpellShell。

Linux CVE-2015-1328 提权漏洞

源于OverlayFS 在创建上层副本时,未正确重置 setuid/setgid 位和文件能力,导致普通用户可继承特权文件的权限。 攻击方法:下载提权CVE-2015-1328,反弹Shell传入,编译运行攻击。 修复对策:禁用 OverlayFS或者限制挂载权限。

由该靶场可知,我们攻击者要训练的意识:

要足够敏锐, 1遇到网页要看源代码。 2看到图片要扫String。 3进入页面看指纹看到CMS、版本相关的要记下来查漏洞。 4看到看不懂的一串代码要考虑解密编码。 5记住任何名词,提示会在里面,试的时候统一小写。

靶场过程:

首先我们得让kali连到靶机,找到靶机的IP和Port让Kali能够互动。

IP

我们能看到靶机的MAC,我们知道网段内MAC和IP是绑定的,启动后通过MAC找到靶机的IP, 使用netdiscover进行搜索。

sudo su # 需要管理员权限
netdiscover -r 10.0.0.0/24

Pasted image 20260120145953.png 扫出网段内的IP,发现靶机的MAC对应10.0.0.139,锁定IP

PORT

使用nmap扫描刚刚获得的IP

nmap -p- 10.0.0.139

Pasted image 20260120151109.png 可以看到有四个端口

那怎么判断哪个端口才是我们要的呢?

可以看到80是http服务,浏览器直接访问

Pasted image 20260120151526.png 可以看到,有用的信息只有/sev-home/可以登录

开始常规渗透,开始搜集

但这是网页,可以看源代码。 此页和登录页都看

Pasted image 20260120151907.png css文件和js文件都看,有什么看什么

Pasted image 20260120151935.png 在JS文件中发现了惊喜,发现两个名字Natalya和Boris还有一串看不懂的东西(这个人说是Boris的密码):InvincibleHack3r

从文字内容中可看出这两个名字都是在这个网站上有密码 问ai得知是Html编码,解码得到InvincibleHack3r,并且指向Boris

我们尝试登录,账号名:Boris,boris,Natalya,natalya 都试试,密码:InvincibleHack3r

可以知道对应账号boris

成功登入最初的网页

Pasted image 20260120153045.png

前面是一段很中二的情景导入,但后面提到了当Adminstrator需要搞定他们搞的pop3服务,还是高端口的。 那我们要当管理员,应该就按他们说的去搞 刚刚我们扫到了55006和55007两个大端口

那怎么搞定pop3呢?

遇到问题就直接搜索 经过查询可知Hydra暴力破解是一个好方法,所以我们可以尝试

echo -e 'natalya\nboris\nBoris\nNatalya' > key.txt  先写一个账号本
hydra -L key.txt -P /usr/share/wordlists/fasttrack.txt 10.0.0.139 -s 55007 pop3 -vV //-L文本 -l 是用户名 - p密码 -vV 会显示每一条线程前面是账号本,后面是密码本

55007和55006都试试 爆出来了账号密码 用户:boris 密码:secret1! 用户:natalya 密码:bird 看来这是个稳定的方案,以后见到邮箱名都可以爆破找密码!!!

登录看邮件

POP3是邮件协议,知道账号密码便可以登录看邮件

nc 10.0.0.139 55007 ---登录邮箱 
user boris ---登录用户 
pass secret1! ---登录密码 
list ---查看邮件数量 
retr 1 ---查看邮件内容

依然是注意名词和长串,可以看到两者邮件中都出现了 xenia 甚至还有密码 password: RCP90rulez! severnaya-station.com/gnocertdi

有域名应该怎么访问呢

vi /etc/hosts 
 在ipv4部分添加,把域名定向到ip
10.0.0.139 severnaya-station.com

Pasted image 20260121012930.png

进入网站,观测到CMS版本

从页面能够直接看到系统以及版本,后续可以搜索与之相关的漏洞库,进行攻击 登录账号,搜刮信息,搜到一封邮件

Pasted image 20260121013359.png 里面出现了一个邮件名,doak,应该可以pop3 hydra搜刮密码

Pasted image 20260121015910.png

Pasted image 20260121013637.png 尝试后得到密码goat 登录搜刮

Pasted image 20260121014304.png

得到账号密码,直接叫登网站了,登上继续搜,发现右侧有文件

Pasted image 20260121014357.png

分析文件和图片

下载得到图片

Pasted image 20260121015255.png 里面提到了admin的凭证,并且直接说这个jpg图片里有东西了 还有RCP-90,现实中是一种突击步枪,在《007》电影《杀人执照》中由邦德使用,是标志性武器。但在这里也不知道是什么,先记下来。看来搞安全还得多看电影。

Pasted image 20260121014738.png

Pasted image 20260121014655.png

复制进URL下载图片,用strings分析字符串或者直接打开图片看

可以发现这一段神秘字符,看见后面两个等号估计是base64,ai也说确实是

Pasted image 20260121014828.png

至此得到了账号和密码

最后的搜刮

得到网站的后台,我们自然是要拿到Webshell。 由网页内容可知其使用的是Moodle的CMS,所以可以直接搜索版本漏洞进行攻击。 --该图是搜索模块搜出来的列表,我们用序号为1的那个模块。

Pasted image 20260121020526.png


msfconsole 使用msf攻击
search moodle 搜索攻击模块
use 1 使用第一个

然后show options,set相关信息。 --该图为设置信息

Pasted image 20260121020624.png 此外还需调整网站的spell引擎为PSpellshell,CMS通过外部Shell命令来调用检查,使攻击者能控制拼写检查的词典路径或输入内容,就可能注入任意命令。便于该模块攻击。

Pasted image 20260121021007.png

直接run,运行开始攻击

攻入后想办法提权,怎么提权呢?

攻入后会发现命令行不太好用,不好输入命令,而且sudo等指令会检测自己是不是在正常的命令行工作,否则他们不会工作。 于是我们引入Python创建伪终端来优化流程。

Pasted image 20260121021929.png

输入id可以看到我们现在拿到了www-data权限,已拥有本地Shell。

使用uname -a获取系统信息,方便我们查找漏洞。 我们得知Ubuntu版本,直接搜索ubuntu 3.13,选择得到提权CVE-2015-1328,直接复制下来,传给靶机运行就告破了。

但是我们还得考虑靶机能不能编译运行

进入tmp文件夹方便提权,暂时文件也不容易被发现。

Pasted image 20260121022847.png 发现只有cc,那就需要去修改CVE使其能够被cc编译

传入CVE,得到提权

在 Kali 上启动 HTTP 服务(如 python3 -m http.server 8000)。 在靶机用 wget从 Kali 下载 exp 源码。

Pasted image 20260121023132.png

编译成exp

Pasted image 20260121023454.png

给exp脚本执行权限(这里写错了,但还是能用)

Pasted image 20260121023838.png

列出文件和隐藏文件以及详细信息

Pasted image 20260121023934.png

可以看到我们exp可执行,都是rwx。 直接执行


./exp

提权成功

直接看id,看看是不是root了,是就直接进root文件夹看Flag

Pasted image 20260121024319.png 成功得到Flag,靶场攻破!!!!

Pasted image 20260121024434.png

THREE.js 3D智慧车站数字孪生项目

作者 FI_NE
2026年1月26日 11:35

3D智慧车站数字孪生项目

版本: 1.2 日期: 2026-01-26 概要: 本文档详细阐述了项目前端 3D 模块的核心架构与技术实现。系统采用 Model-View 分离 的设计模式,将数据逻辑 (model.js) 与视图交互 (view.js) 解耦,结合 Service Worker 离线缓存Three.js 渲染引擎,构建了高性能、可交互的 Web3D 可视化平台。


0. 技术架构概览

系统整体架构分为视图层、逻辑层与数据层,各层职责如下:

graph TD
    A[视图层 View.js] -->|用户交互/指令| B(逻辑层 Model.js)
    B -->|状态更新/回调| A
    B -->|渲染指令| C{Three.js 渲染引擎}
    D[后端 API] -->|JSON 数据| B
    E[Service Worker] -->|资源拦截/缓存| C
  • 视图层 (view.js): 基于 Vue.js,负责 UI 组件渲染、用户事件捕获(点击、悬停)及业务弹窗管理。
  • 逻辑层 (model.js): 核心 3D 控制器,管理场景图谱 (Scene Graph)、相机系统、光照环境及模型生命周期。
  • 数据层 (models/*.js): 定义业务实体(如点位 Mark、路径 Path),封装数据解析与实例化逻辑。

1. 离线缓存与性能优化 (PWA)

核心文件: public/sw.js

该模块实现了 PWA (Progressive Web App) 的核心离线缓存功能,采用 Cache First (缓存优先) 策略,显著提升大屏 3D 资源的加载速度与稳定性。

1.1 缓存策略与生命周期

  • Install: 初始化静态资源缓存 (STATIC_CACHE)。
  • Activate: 自动清理旧版缓存 (MODEL_CACHE),确保用户访问最新资源。
  • Fetch: 拦截网络请求,优先读取本地缓存,未命中则发起请求并自动写入缓存。
// public/sw.js
const CACHE_VERSION = 'v1';
const MODEL_CACHE = 'model-cache-' + CACHE_VERSION;
const STATIC_CACHE = 'static-cache-' + CACHE_VERSION;

self.addEventListener('install', event => {
    event.waitUntil(caches.open(STATIC_CACHE));
});

self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(keys => {
            return Promise.all(
                keys.filter(key => key !== MODEL_CACHE && key !== STATIC_CACHE)
                    .map(key => caches.delete(key))
            );
        })
    );
});

self.addEventListener('fetch', event => {
    // 缓存优先策略实现
    event.respondWith(
        caches.match(event.request).then(response => {
            if (response) return response;
            return fetch(event.request).then(networkResponse => {
                // ... 缓存写入逻辑
                return networkResponse;
            });
        })
    );
});

2. 3D 核心引擎与场景管理

核心文件: model.js

作为 3D 场景的“大脑”,负责初始化渲染环境、管理渲染循环及全局光照系统。

2.3 模型异步加载系统 (loadTargetModel)

封装了 GLTFLoaderDRACOLoader,提供健壮的异步加载能力,包含 DRACO 压缩支持异步竞态处理 (Token)

// model.js
async loadTargetModel(targetData) {
    const loadStartTime = performance.now();
    const loadPath = targetData.objPath;
    const loadUrl = targetData.objUrl;
    
    // Token 机制:防止快速切换导致的资源竞争
    this._currentLoadToken++;
    const loadToken = this._currentLoadToken;
    
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath(globelvar.projectUrl + 'js/draco/');
    
    const gltfLoader = new GLTFLoader();
    gltfLoader.setDRACOLoader(dracoLoader);
    
    return await new Promise((resolve) => {
        gltfLoader.setPath(loadPath).load(loadUrl, (gltf) => {
            // 闭包校验:如果 Token 不一致,说明用户已切换场景,丢弃当前结果
            if (loadToken !== this._currentLoadToken) return resolve(null);
            
            targetData.modelScene = gltf.scene;
            // 材质修正:确保模型在暗场景下可见
            gltf.scene.traverse((o) => {
                if (o.isMesh) {
                    o.material.emissive = o.material.color;
                    o.material.emissiveMap = o.material.map;
                }
            });
            this.scene.add(gltf.scene);
            resolve(gltf.scene);
        });
    });
}

3. 楼层管理与可视化交互

核心文件: model.js

实现了建筑结构的纵向展开与合并功能,提供直观的楼层内部查看体验。

3.1 展开逻辑 (toggleExpand)

基于 TWEEN 库实现平滑的楼层分离动画,并同步切换光照模式以增强内部细节可见性。

// model.js
async toggleExpand(projectName) {
    this.isExpanded = !this.isExpanded;
    const group = this.modelGroupsMap.get(projectName);
    const gap = 600;

    if (this.isExpanded) {
        // 展开模式:切换光照,计算目标 Y 轴位置
        this._applyDirLightMode('alt');
        for (let i = 0; i < group.childrenLevel.length; i++) {
            const floor = group.childrenLevel[i];
            const targetY = (i - (group.childrenLevel.length - 1) / 2) * gap;
            
            let scene = floor.modelScene;
            if (!scene) {
                await this.loadTargetModel(floor); // 按需加载
                scene = floor.modelScene;
            }
            scene.visible = true;
            
            // TWEEN 动画
            const tween = new TWEEN.Tween(scene.position)
                .to({ y: targetY }, 1000)
                .easing(TWEEN.Easing.Quadratic.Out)
                .start();
            this._expandTweens.push(tween);
        }
    } else {
        // 合并模式:恢复光照,重置位置
        this._applyDirLightMode('main');
        this._stopExpandTweensAndResetFloors();
        // ...
    }
}

4. 视图交互与事件系统

核心文件: view.js & model.js

构建了连接用户操作与 3D 逻辑的桥梁,支持点选与框选交互。

4.1 鼠标交互逻辑

利用 THREE.Raycaster 将屏幕坐标转换为 3D 射线,实现高精度拾取。

// model.js
handleMouseMove(event) {
    if (this.currentScene == 'INSIDE') {
        if (this.hoverUserEnable) {
            // 范围选择模式:更新选框位置
            this.setMousePosition(event);
            this.raycaster.setFromCamera(this.mouse, this.camera);
            const hits = this.raycaster.intersectObject(model, true);
            if (hits && hits.length) {
                this.hoverCube.position.copy(hits[0].point);
                this.hoverCube.visible = true;
            }
        }
    } else {
        // 楼层选择模式:高亮悬停楼层
        if (!this.isExpanded) return;
        this.setMousePosition(event);
        this.raycaster.setFromCamera(this.mouse, this.camera);
        const intersects = this.raycaster.intersectObjects(floorGroups, true);
        if (intersects.length > 0) {
            this.applyHoverEffect(intersects[0].object); // 应用高亮材质
        }
    }
}

5. 动态数据可视化系统

核心文件: model.js & models/mark.js

负责业务数据(设备点位、标签)在 3D 空间中的动态映射与展示,包含性能优化与并发控制。

5.1 精灵模型创建与纹理缓存

通过纹理缓存 (_markTextureCache) 减少显存占用,利用 THREE.Sprite 实现始终面向相机的点位图标。

// models/mark.js
createSprite(option) {
    let texture = this.techin._markTextureCache.get(option.info.image);
    if (!texture) {
        texture = new THREE.TextureLoader().load(option.info.image);
        this.techin._markTextureCache.set(option.info.image, texture);
    }
    const material = new THREE.SpriteMaterial({
        map: texture,
        transparent: true,
        depthTest: false // 确保图标不被模型遮挡
    });
    const sprite = new THREE.Sprite(material);
    sprite.scale.set(option.zoom, option.zoom, 1);
    return sprite;
}

5.2 加载并发控制 (Token)

// model.js
async showMarkers(targetData) {
    this._currentPointsToken = (this._currentPointsToken || 0) + 1;
    const startToken = this._currentPointsToken;
    
    // ... 异步查询接口 ...
    
    if (startToken !== this._currentPointsToken) return; // 校验 Token,防止数据错乱
    
    // 分帧渲染,避免卡顿
    for (let i = 0; i < items.length; i++) {
        if (i % 20 === 0) await new Promise(r => requestAnimationFrame(r));
        // ... 创建点位 ...
    }
}

6. 路径规划与导视系统

核心文件: model.js & effect/PathEffect.js

6.1 逃生路线可视化 (showEscapeRoute)

解析路径坐标,生成动态流光效果。

// model.js
async showEscapeRoute(domainUnid) {
    const res = await queryEscRouteList({ domainUnid });
    const routesArr = JSON.parse(res.data.list[0].routes);
    
    routesArr.forEach(item => {
        const pathEffect = new PathEffect(this);
        const points = item.points.map(p => ({ x: p.x, y: p.y, z: p.z }));
        
        pathEffect.create({ points }); // 创建几何体
        
        // 添加到动画管理器
        this.animationManager.add(pathEffect.animate.bind(pathEffect), "pathEffect" + item.id);
        this._escapePathEffects.push({ effect: pathEffect });
    });
}

7. 导航与场景跳转逻辑

核心文件: model.js & view.js

7.1 设备定位与跳转 (setMapPosition)

实现从 2D 列表到 3D 场景的精准跳转,包含楼层切换与相机飞行。

// model.js
async setMapPosition(data) {
    const domainUnid = data.domainUnid;
    const targetData = this.findSceneByDomainUnid(domainUnid);
    
    // 切换楼层
    await this.changeFloor(targetData, { skipCamera: true });
    
    // 查找点位并飞行
    if (data.unid) {
        let marker = this.markerObjects.find(m => m.info.uuid === data.unid);
        if (marker) {
            const pos = marker.position.clone();
            flyToLabel(this, { position: pos, offset: 200 }); // 智能聚焦
        }
    }
}

8. 视觉特效组件库

核心文件: component/THREE/

  • 指南针 (Compass.js): 实时计算相机方位角。
  • 设备报警 (setDeviceAlarm):
// model.js
async setDeviceAlarm(option) {
    // 1. 环境重置
    this.clearAlarmIcons();
    
    // 2. 空间定位与飞行
    await this.setMapPosition({ domainUnid: option.domainUnid, unid: option.unid });
    
    // 3. 添加报警特效
    const marker = this.markerObjects.find(m => m.info.uuid === option.unid);
    this._addWarningForMarker(marker, marker.position); // 动态光圈
}

9. 相机控制系统

核心文件: utils/util.js

基于 GSAP 封装的高性能相机运镜工具库。

9.1 通用飞行 (flyTo)

// utils/util.js
export function flyTo(techin, options, fun = function () { }) {
    const duration = options.duration || 1.5;
    
    // 禁用控制器,避免动画冲突
    techin.controls.enabled = false;
    
    const tl = gsap.timeline({
        onUpdate: () => { techin.camera.lookAt(techin.controls.target); }, // 保持注视目标
        onComplete: () => {
            techin.controls.enabled = true;
            fun();
        }
    });
    
    tl.to(techin.camera.position, options.position, 0);
    tl.to(techin.controls.target, options.target, 0);
}

性能暴涨 1200 倍!我用 TypeScript 重构了这个经典项目

2026年1月25日 21:33

中国亲戚关系计算器 TypeScript 重构版:性能暴涨 1200 倍,完美支持 React Native

一个 LRU 缓存,让性能从 44 QPS 飙升到 54,000 QPS

GitHub: github.com/ExploringTh…

你是否也有过这样的经历?过年回家面对七大姑八大姨,却叫不上来对方该喊什么称呼?"中国家庭称谓计算器"这个项目就是为了解决这个问题而生的。

最近,我对这个经典项目进行了 TypeScript 重构优化,在保持功能完整的同时,实现了显著的性能提升和更好的兼容性。今天就来分享一下这次重构的亮点和收获。

为什么需要重构?

起因:React Native 的"坑"

在将原版 relationship.js 集成到 React Native 移动端应用时,遇到了一个棘手的报错:

ERROR [RangeError: Property storage exceeds 196607 properties]

这是因为原版 JS 在初始化时创建了大量的对象属性,超出了 JavaScriptCore 引擎 的属性数量限制(约 196607 个)。

技术选型

为了彻底解决这个问题,我决定从以下几个方向进行重构:

  1. TypeScript 重写 - 提供类型安全和更好的开发体验
  2. Map 替代 Object - 利用 Map 的特性优化数据存储
  3. LRU 缓存机制 - 提升重复查询性能
  4. 模块化架构 - 更好的代码组织和 Tree Shaking 支持

核心优化点

1. 数据结构优化:Map vs Object

原版实现:

// 使用 Object 存储称谓映射
const cacheData = {
  'f': ['父亲', '老爸', '爹地'],
  'm': ['母亲', '老妈', '妈咪'],
  // ... 数千个属性
};

TS 版本优化:

class OptimizedCache {
  private _titleToChain: Map<string, Chain[]> = new Map();
  private _chainToTitle: Map<string, string[]> = new Map();

  getTitleToChain(title: string): Chain[] | undefined {
    return this._titleToChain.get(title);  // O(1) 查找
  }
}

优势:

  • Map 提供 O(1) 的查找性能
  • 避免了 Object 的属性数量限制
  • 更好的内存管理

2. LRU 缓存实现

这是我这次重构最满意的部分之一。针对亲戚关系查询场景,大量查询是重复的(比如"爸爸的爸爸"这种高频查询),非常适合使用缓存。

export class LRUCache<K, V> {
  private capacity: number;
  private cache: Map<K, V>;

  get(key: K): V | undefined {
    const value = this.cache.get(key);
    if (value !== undefined) {
      // 更新访问顺序:删除并重新插入 - O(1)
      this.cache.delete(key);
      this.cache.set(key, value);
    }
    return value;
  }
}

巧妙点: 利用 Map 的有序性(按插入顺序迭代),通过 delete + set 实现 O(1) 的访问顺序更新,无需额外的双向链表结构。

3. 分层缓存架构

export class QueryCache {
  private selectorCache: LRUCache<string, string[]>;   // 中文转选择器
  private idCache: LRUCache<string, string[]>;         // ID转中文
  private chainCache: LRUCache<string, string>;        // 关系链缓存
}

三个独立的缓存层,针对不同类型的查询结果进行缓存,互不干扰。

4. 类型安全

export interface RelationshipOptions {
  text: string;           // 目标对象的称谓
  target?: string;        // 相对对象的称谓
  sex?: -1 | 0 | 1;      // 本人性别:-1未知,0女性,1男性
  type?: 'default' | 'chain' | 'pair';
  reverse?: boolean;
  mode?: string;
  optimal?: boolean;
}

完整的 TypeScript 类型定义,开发时获得智能提示和类型检查。

性能基准测试

测试环境

  • Node.js: v22.12.0
  • 平台: Windows x64
  • 测试方法: 运行 10,000 次重复查询

测试脚本

/**
 * 中国亲戚关系计算器性能测试脚本
 * 对比原版 JS 和 TypeScript 版本的性能
 */

import { performance } from 'perf_hooks';
import fs from 'fs';

// 测试用例 - 涵盖不同复杂度的查询
const testCases = [
  { text: '父亲', desc: '简单查询' },
  { text: '爸爸的妈妈', desc: '两层关系' },
  { text: '妈妈的妈妈的哥哥', desc: '三层关系' },
  { text: '爸爸的哥哥的妻子的弟弟', desc: '复杂关系链' },
  { text: '父亲的父亲的父亲的父亲', desc: '四层关系(缓存)' },
];

// 格式化数字
function formatNumber(num) {
  return new Intl.NumberFormat('zh-CN').format(num);
}

// 格式化时间
function formatTime(ms) {
  if (ms < 0.001) return `${(ms * 1000000).toFixed(2)} μs`;
  if (ms < 1) return `${(ms * 1000).toFixed(2)} ms`;
  return `${ms.toFixed(2)} ms`;
}

// 获取文件大小
function getFileSize(filePath) {
  const stats = fs.statSync(filePath);
  return stats.size;
}

// 性能测试函数
async function runBenchmark(name, relationshipFunc) {
  console.log(`\n${'='.repeat(60)}`);
  console.log(`测试: ${name}`);
  console.log('='.repeat(60));

  const results = {
    coldStart: [],
    warmStart: [],
    queries: {},
  };

  // 预热 - 确保模块初始化
  relationshipFunc({ text: '父亲' });

  // 1. 冷启动测试 - 测试不同查询的首次性能
  console.log('\n🔵 冷启动测试 (各查询类型的首次性能)...');
  for (const tc of testCases) {
    const times = [];
    for (let i = 0; i < 50; i++) {
      // 使用不同的查询来避免缓存
      const start = performance.now();
       relationshipFunc({ text: tc.text + (i > 0 ? '的' + '父亲'.repeat(i % 3) : '') });
      const end = performance.now();
      times.push(end - start);
    }
    const avg = times.reduce((a, b) => a + b, 0) / times.length;
    results.queries[tc.desc + '(冷)'] = avg;
    console.log(`   ${tc.desc.padEnd(20)}: ${formatTime(avg)}`);
  }

  // 2. 热启动测试 - 重复同一查询(测试缓存效果)
  console.log('\n🟢 热启动测试 (重复查询,命中缓存)...');
  const warmQuery = '父亲的父亲的父亲的父亲';
  const warmTimes = [];
  for (let i = 0; i < 10000; i++) {
    const start = performance.now();
     relationshipFunc({ text: warmQuery });
    const end = performance.now();
    warmTimes.push(end - start);
  }
  const avgWarm = warmTimes.reduce((a, b) => a + b, 0) / warmTimes.length;
  const minWarm = Math.min(...warmTimes);
  const maxWarm = Math.max(...warmTimes);

  // 排除异常值(取中位数附近)
  const sorted = [...warmTimes].sort((a, b) => a - b);
  const medianWarm = sorted[Math.floor(sorted.length / 2)];

  results.avgWarm = avgWarm;
  results.minWarm = minWarm;
  results.maxWarm = maxWarm;
  results.medianWarm = medianWarm;
  results.qps = 1000 / medianWarm;

  console.log(`   平均耗时: ${formatTime(avgWarm)}`);
  console.log(`   中位数耗时: ${formatTime(medianWarm)} (去除异常值)`);
  console.log(`   最小耗时: ${formatTime(minWarm)}`);
  console.log(`   最大耗时: ${formatTime(maxWarm)}`);
  console.log(`   QPS: ${formatNumber(1000 / medianWarm)} req/s`);

  return results;
}

// 主测试函数
async function main() {
  console.log('\n' + '█'.repeat(60));
  console.log('█' + ' '.repeat(20) + '性能测试基准测试' + ' '.repeat(20) + '█');
  console.log('█'.repeat(60));
  console.log(`\n📊 测试环境:`);
  console.log(`   Node.js: ${process.version}`);
  console.log(`   平台: ${process.platform} ${process.arch}`);
  console.log(`   热启动迭代: 10000 次`);

  // 包体积对比
  console.log(`\n📦 包体积对比:`);
  const jsSize = getFileSize('E:/github-project/relationship/dist/relationship.min.js');
  const tsSize = getFileSize('E:/github-project/relationship-ts/dist/relationship.min.js');
  const jsSizeKb = (jsSize / 1024).toFixed(2);
  const tsSizeKb = (tsSize / 1024).toFixed(2);
  const diff = ((tsSize - jsSize) / jsSize * 100).toFixed(2);

  console.log(`   原版 (JS):     ${jsSizeKb} KB (${formatNumber(jsSize)} bytes)`);
  console.log(`   优化版 (TS):   ${tsSizeKb} KB (${formatNumber(tsSize)} bytes)`);
  console.log(`   差异:         ${diff > 0 ? '+' : ''}${diff}%`);

  // 导入两个版本
  console.log(`\n📥 加载模块...`);

  // 分别导入两个版本
  const relationshipJS = (await import('file:///E:/github-project/relationship/dist/relationship.min.mjs')).default;
  const relationshipTS = (await import('file:///E:/github-project/relationship-ts/dist/relationship.min.mjs')).default;

  console.log(`   ✅ 原版 (JS) 加载完成`);
  console.log(`   ✅ 优化版 (TS) 加载完成`);

  // 运行测试
  const jsResults = await runBenchmark('原版 (relationship.js)', relationshipJS);
  const tsResults = await runBenchmark('优化版 (relationship-ts)', relationshipTS);

  // 生成对比报告
  console.log('\n' + '='.repeat(60));
  console.log('📊 性能对比报告');
  console.log('='.repeat(60));

  // 冷启动对比
  console.log('\n🔵 冷启动对比 (首次查询,无缓存):');
  console.log(`   ${'查询类型'.padEnd(20)} ${'原版'.padEnd(15)} ${'优化版'.padEnd(15)} ${'差异'.padEnd(10)}`);
  console.log('   ' + '-'.repeat(60));

  let coldImprovements = [];
  for (const [desc, jsTime] of Object.entries(jsResults.queries)) {
    if (desc.includes('(冷)')) {
      const tsTime = tsResults.queries[desc];
      const improvement = ((jsTime - tsTime) / jsTime * 100);
      coldImprovements.push(improvement);
      const arrow = improvement > 0 ? '🚀' : (improvement < 0 ? '📈' : '➡️');
      console.log(`   ${desc.replace('(冷)', '').padEnd(20)} ${formatTime(jsTime).padEnd(15)} ${formatTime(tsTime).padEnd(15)} ${arrow} ${(improvement > 0 ? '+' : '') + improvement.toFixed(1)}%`);
    }
  }

  // 热启动对比
  console.log('\n🟢 热启动对比 (重复查询,命中 LRU 缓存):');
  console.log(`   原版中位数:    ${formatTime(jsResults.medianWarm)}`);
  console.log(`   优化版中位数:  ${formatTime(tsResults.medianWarm)}`);
  console.log(`   原版 QPS:      ${formatNumber(jsResults.qps)} req/s`);
  console.log(`   优化版 QPS:    ${formatNumber(tsResults.qps)} req/s`);

  const warmImprovement = ((jsResults.medianWarm - tsResults.medianWarm) / jsResults.medianWarm * 100);
  const qpsImprovement = ((tsResults.qps - jsResults.qps) / jsResults.qps * 100);
  const speedup = (jsResults.medianWarm / tsResults.medianWarm).toFixed(1);

  console.log(`   耗时减少:     ${warmImprovement > 0 ? warmImprovement.toFixed(1) : '0'}%`);
  console.log(`   QPS 提升:     ${qpsImprovement > 0 ? '+' : ''}${formatNumber(Math.round(qpsImprovement))}%`);
  console.log(`   性能倍数:     🚀 ${speedup}x 更快`);

  // 总结
  console.log('\n' + '='.repeat(60));
  console.log('📝 总结');
  console.log('='.repeat(60));

  console.log(`
✅ 优化版主要改进:
   • 包体积:        ${diff.startsWith('-') ? '⬇️ 减少 ' + Math.abs(diff) + '%' : '⬆️ 增加 ' + diff}
   • 热启动性能:    🚀 ${speedup}x 更快 (得益于 LRU 缓存机制)
   • QPS 提升:      ${qpsImprovement > 0 ? '+' : ''}${formatNumber(Math.round(qpsImprovement))}% (从 ${formatNumber(Math.round(jsResults.qps))}${formatNumber(Math.round(tsResults.qps))} req/s)
   • 类型安全:      ✅ 完整的 TypeScript 类型定义
   • 架构优化:      ✅ 模块化设计,更好的 Tree Shaking 支持
   • 兼容性:        ✅ 解决 React Native 属性存储超限问题
  `);

  return {
    jsSizeKb,
    tsSizeKb,
    diff,
    jsQps: jsResults.qps,
    tsQps: tsResults.qps,
    speedup,
    jsMedian: jsResults.medianWarm,
    tsMedian: tsResults.medianWarm,
  };
}

main().catch(console.error);

测试结果

image.png

指标 原版 (JS) 优化版 (TS) 提升
包体积 81.60 KB 77.17 KB ⬇️ 5.4%
热启动响应时间 ~23 ms ~0.02 ms 🚀 1200x
热启动 QPS ~44 req/s ~54,000 req/s 🚀 122,000%
冷启动响应时间 ~23 ms ~30 ms 持平
React Native ⚠️ 属性溢出风险 ✅ 完美支持 -

核心发现:

  • 冷启动(首次查询)性能基本持平,因为都需要遍历数据
  • 热启动(重复查询)性能提升 1200 倍,得益于 LRU 缓存机制
  • 包体积减少 5.4%,虽然加了缓存代码,但 TypeScript 的类型擦除和 Tree Shaking 带来了优化

项目结构对比

原版结构

relationship/
├── src/
│   ├── relationship.js       # 主入口 (单文件)
│   ├── relationship-mode.js  # 模式相关
│   ├── locale/               # 语言包
│   └── module/               # 模块
└── package.json

TS 版本结构

relationship-ts/
├── src/
│   ├── core/                 # 核心模块
│   │   ├── cache.ts          # 缓存系统 (Map优化)
│   │   ├── lru.ts            # LRU缓存实现
│   │   ├── id.ts             # 关系链转中文
│   │   ├── mode.ts           # 模式管理
│   │   └── selector.ts       # 中文转关系链
│   ├── data/                 # 数据文件
│   ├── rules/                # 规则文件
│   ├── utils/                # 工具函数
│   ├── locale/               # 方言数据
│   ├── types.ts              # 类型定义
│   └── index.ts              # 主入口
├── docs/                     # VitePress 文档
├── benchmark/                # 性能测试
└── package.json

改进点:

  • 更清晰的模块划分
  • 独立的类型定义文件
  • 完善的文档系统
  • 性能基准测试

使用示例

安装

npm install relationship-ts

基本用法

import relationship from 'relationship-ts';

// 查询称谓
relationship({ text: '爸爸的妈妈' });
// => ['奶奶', '祖母']

// 多层关系查询
relationship({ text: '妈妈的妈妈的哥哥' });
// => ['舅外公']

// 反向查询:对方称呼我什么?
relationship({ text: '外婆', reverse: true, sex: 1 });
// => ['外孙']

// 关系链查询
relationship({ text: '舅公', type: 'chain' });
// => ['爸爸的妈妈的兄弟', '妈妈的妈妈的兄弟']

自然语言模式

// 支持自然语言表达式
relationship('舅妈如何称呼外婆?');
// => ['婆婆']

relationship('外婆和奶奶之间是什么关系?');
// => ['儿女亲家']

自定义方言

relationship.setMode('northern', {
  'm,f': ['姥爷'],
  'm,m': ['姥姥'],
  'm,xb,s&o': ['表哥'],
  'm,xb,s&l': ['表弟'],
});

relationship({ text: '妈妈的妈妈', mode: 'northern' });
// => ['姥姥']

兼容性说明

TS 版本保持了与原版 100% 的 API 兼容性,你可以无缝替换:

// 原版引入方式仍然支持
import relationship from 'relationship.js';

// 替换为 TS 版本
import relationship from 'relationship-ts';

// 代码无需任何修改

总结

这次 TypeScript 重构主要带来了以下收益:

  1. 性能提升 - LRU 缓存让重复查询性能提升 1200 倍
  2. 兼容性解决 - 使用 Map 彻底解决 React Native 属性溢出问题
  3. 类型安全 - 完整的 TypeScript 类型定义
  4. 代码质量 - 模块化架构,更易维护和扩展
  5. 包体积优化 - 压缩后体积减少约 5.4%

如果你在项目中需要使用亲戚关系计算功能,尤其是在 React NativeTypeScript 项目中,不妨试试这个优化版本。

致谢

本项目基于 mumuy/relationship 进行重构,感谢原作者 mumuy 的优秀工作!


GitHub: github.com/ExploringTh… 在线演示: example.qinyimo.cn/ 文档地址: docs.qinyimo.cn/

如果这个项目对你有帮助,欢迎给个 Star ⭐️

一次彻底搞懂 Four Sum(四数之和):排序 + 双指针的终极形态

作者 YukiMori23
2026年1月25日 11:10

LeetCode 18|中等
核心思想:排序 + 枚举 + 双指针 + 去重
本文目标:让你不仅写得出,还能一眼看穿这一类题


一、题目描述

给定一个整数数组 nums 和一个目标值 target,判断数组中是否存在 四个不同下标 的元素,使得它们的和等于 target,并返回所有 不重复的四元组

示例:

nums = [1,0,-1,0,-2,2], target = 0
输出:
[
  [-2,-1,1,2],
  [-2,0,0,2],
  [-1,0,0,1]
]

二、暴力解法为什么行不通?

最直观的思路是四重循环:

for a
  for b
    for c
      for d

时间复杂度:

O(n^4)

n 稍微一大,直接超时。这也是 Four Sum 这道题存在的意义:逼你系统性掌握降维思路


三、核心优化思路:降维 + 双指针

观察等式:

a + b + c + d = target

我们可以把它拆成:

(a + b) + (c + d) = target

思路就非常清晰了:

  1. 固定前两个数 ab
  2. 剩下的 c + d双指针(Two Sum) 解决
  3. 通过排序解决双指针和去重问题

这正是 Two Sum → Three Sum → Four Sum 的统一解题套路。


四、完整 Java 实现

class Solution {
    public List<List<Integer>> fourSum(int[] nums, int target) {
        List<List<Integer>> res = new ArrayList<>();
        int n = nums.length;
        if (n < 4) return res;

        Arrays.sort(nums);

        // 第一层枚举 a
        for (int i = 0; i < n - 3; i++) {
            if (i > 0 && nums[i] == nums[i - 1]) continue;

            // 第二层枚举 b
            for (int j = i + 1; j < n - 2; j++) {
                if (j > i + 1 && nums[j] == nums[j - 1]) continue;

                int left = j + 1;
                int right = n - 1;

                // 双指针找 c + d
                while (left < right) {
                    long sum = (long) nums[i] + nums[j] + nums[left] + nums[right];

                    if (sum == target) {
                        res.add(Arrays.asList(
                                nums[i], nums[j], nums[left], nums[right]
                        ));

                        while (left < right && nums[left] == nums[left + 1]) left++;
                        while (left < right && nums[right] == nums[right - 1]) right--;

                        left++;
                        right--;
                    } else if (sum < target) {
                        left++;
                    } else {
                        right--;
                    }
                }
            }
        }
        return res;
    }
}

五、为什么一定要排序?

排序在这道题中起到了决定性作用

  1. 让双指针成为可能
  2. 为去重提供天然条件
  3. 让结果具备统一顺序,避免重复解

可以说:没有排序,Four Sum 根本写不干净。


六、去重逻辑是整道题的灵魂

Four Sum 的难点不在算法,而在 不重不漏

1. 第一层去重(a)

if (i > 0 && nums[i] == nums[i - 1]) continue;

避免相同的 a 被重复枚举。


2. 第二层去重(b)

if (j > i + 1 && nums[j] == nums[j - 1]) continue;

注意这里是 j > i + 1,而不是 j > 0,这是很多人第一次写时会踩的坑。


3. 双指针去重(c、d)

while (left < right && nums[left] == nums[left + 1]) left++;
while (left < right && nums[right] == nums[right - 1]) right--;

只在 找到一个合法解之后 去重,这是关键。


七、为什么 sum 要用 long?

long sum = (long) nums[i] + nums[j] + nums[left] + nums[right];

原因只有一个:防止整数溢出

  • nums[i] 可能是 10^9
  • 四个 int 相加很容易溢出

这是 Four Sum 的经典细节坑,面试和刷题都很爱考。


八、时间与空间复杂度分析

时间复杂度:

O(n^3)
  • 两层枚举:O(n²)
  • 内层双指针:O(n)

空间复杂度:

O(1)(不计结果集)

九、Four Sum 的本质总结

这一类题的本质其实非常统一:

题目 固定元素个数 剩余解法
Two Sum 0 双指针 / 哈希
Three Sum 1 双指针
Four Sum 2 双指针

一句话总结:

固定 k − 2 个数,把 k Sum 问题降维为 Two Sum。

前端指南:那些深藏不露的冷知识

作者 dreamcat
2026年1月23日 09:56

平时写代码,大家聊的不是 React Hooks 就是 Webpack 优化。但有时候,真正能体现一个前端“底蕴”的,反而是那些藏在规范角落里的冷门细节。今天翻箱底找了几个有趣的冷知识,内容涵盖从浏览器考古到现代 CSS 骚操作,建议收藏,关键时刻能拿出来“装一装”。

1. Doctype:其实是个“复古”开关

现在大家写 HTML,第一行雷打不动都是 <!DOCTYPE html>。很多人以为这是在声明 HTML5 版本,其实并不是。

早期的浏览器为了兼容那些远古时期的网页,搞出了怪异模式(Quirks Mode) 。如果你不写这行 Doctype,或者写错了一个字符,浏览器就会瞬间“穿越”回 IE5 时代。这时候你会发现:盒模型变了(width 竟然包含了 padding)、行内元素高度乱了。所以,这行代码的真实作用是给浏览器打个信号:“别整那些老古董渲染了,按现代标准来”。

2. 文本描边的“正确姿势”:paint-order

如果你用过 -webkit-text-stroke 给文字做描边,肯定被它坑过:边框一旦粗一点,就把文字颜色给“吃”掉了,因为它的描边是往内伸缩的。

其实 CSS 里藏着一个从 SVG 借来的属性:paint-order

.hero-text {
  -webkit-text-stroke: 8px #000;
  paint-order: stroke fill; /* 先画边框,再填颜色 */
  color: #fff;
}

加上 paint-order: stroke fill;,浏览器就会先画边框再填色,这样不管你把描边设多粗,文字本身都能清晰露出来。

3. inert:封印元素的终极杀招

做弹窗(Modal)最头疼的就是“焦点陷阱”——用户按 Tab 键,焦点会穿过弹窗跑到背景页面的按钮上。以前我们得手写一大堆逻辑去拦截键盘事件,或者手动改 tabindex

现在 HTML 出了个原生属性 inert。只要给父容器加上它,这个容器里所有的链接、按钮就全部被“封印”了:点不动、Tab 跳不过去,甚至连屏幕阅读器也会直接无视它。简单粗暴,非常适合处理那种全局禁用的场景。

<main id="content" inert>
  <!-- 当弹窗出现时,这里的一切交互都被冻结了 -->
</main>

4. sendBeacon:埋点统计的“离别礼”

在用户关闭页面时发最后一条埋点,是出了名的“玄学”。用 fetchaxios 很容易被浏览器直接掐断,用同步请求又会让页面关不掉,用户体验极差。

navigator.sendBeacon 就是专门干这活的。它的牛逼之处在于:即使页面已经销毁了,浏览器也会在后台默默把数据传完,而且完全不占主线程。这才是处理页面卸载数据上报的“正道”。

5. UA 字符串里的“套娃”大戏

如果你去打印一下 Chrome 的 User-Agent,会发现它长得像一段乱码,里面竟然还写着 SafariAppleWebKit 甚至 Mozilla

这其实是一部长达 20 年的“欺诈史”。当年 IE 为了骗过只支持 Mozilla 的网站,自称是 Mozilla;后来 Chrome 为了骗过只支持 Safari 的网站,把自己也加上了 Safari 的标识。就这样层层套娃,导致现在的 UA 字符串成了一个谁都不敢乱动的历史遗迹。

6. 终于能告别 Padding Hack 的 aspect-ratio

以前为了做一个自适应比例的容器(比如 16:9 的视频),我们得用 padding-top: 56.25% 这种极其别扭的“Padding Hack”。

现在现代浏览器都支持 aspect-ratio 了。代码直观得感人:

.video-box {
  width: 100%;
  aspect-ratio: 16 / 9;
}

再也不用去算那个该死的百分比了。

结语: 前端不只有那些大而全的架构,这些细碎的、带着历史气息或奇思妙想的小知识,才是这个领域有趣的地方。下次和人讨论技术时,不妨顺带聊聊这些。

❌
❌