阅读视图

发现新文章,点击刷新页面。

基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交

基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交

QQ20251030-162229-HD-soConvert.webp

上一篇文章我们实现了六边形蜂窝的区域客流,这一篇我们的目标是实现 **实时公交**。实现实时公交的方式我们需要得数据:实时更新位置的公交车、 当前公交车排班路线, 还有就是线路所经过的站台

一般公交车更新实时位置都是socket,这里为了方便我们使用历史行进轨迹数据traceData.json进行调试,站台我们采用glb模型,并将模型朝向路线保证视觉正确,公交车运行时需要沿路行驶并且需要正确的朝向,开搞。


数据获取

基于上一篇文章的初始化地图代码,我们需要以下数据文件(文件在下方仓库地址):

  • 线路数据(routeData)这是公交车司机当天排班的公交车指定行进线路,比如101路线路
  • 站点数据(stand2Data)当天排班的公交车指定行进线路所经过的上下行站点,每个站点最少站台
  • 轨迹数据(traceData)公交车的实际行进线路数据,一般都在指定行进线路上

资源文件准备:

import traceData from '@/resources/trace/reace1.json';
import routeData from '@/resources/route/route2.json';
import stand2Data from '@/resources/stand/stand2.json';

routeData线路数据是上/下行两组 WGS84 点位:

{
    "up": [ { "lat": 33.27627349350771, "lon": 117.32730936865975 }, ... ],
    "down": [ ... ]
}

stand2Data站点数据结构与此相近,但包含名称、分组等属性,

{
    "up": [
            {
                "id": 862,
                "name": "小学",
                "remarks": "北",
                "lat": 33.3333833,
                "lon": 117.3255,
                ...
            }
    ],
    "down": [ ... ]
}

轨迹数据则提供车辆按时间序列格式的数据。

[
    {

        "routeName": "101路",
        "driverName": "张xx",
        "start": "2025-10-13 07:40:02",
        "end": "2025-10-13 09:39:50",
        "locations": [
            {
                "lineId": 15,
                "driverId": 37,
                "posTime": "2025-10-13 07:41:03", //上报时间
                "latitude1": 33.33392333984375,
                "longitude1": 117.32551574707031,
                "speed": 7000,  //当前速度
                "gpsMileage": 35010000,
            },
            ...
            ]
    }
]


车辆模型加载

image.png

开始,我们用mapvthree.gltfLoader把公交车模型加载,分三步:定位、调整缩放和朝向、加入场景,这里需要注意three默认的加载地址在根目录public。

加载与首帧定位:

//加载模型
mapvthree.gltfLoader.load('/model/bus.glb', (gltf: any) => {
    busModel = gltf.scene;

    // 取轨迹起点作为起始位置(WGS84 -> BD09 -> 墨卡托)
    const firstLocation = locations[0];
    if (firstLocation.latitude1 && firstLocation.longitude1) {
        const [bdLon, bdLat] = wgs84tobd09(firstLocation.longitude1, firstLocation.latitude1);
        const [x, y, z] = bd09ToMercator(bdLon, bdLat);
        busModel.position.set(x, y, z);
    }

    // 方向、大小合适
    busModel.rotateX(Math.PI / 2);
    busModel.rotateY(Math.PI * 3 / 2);
    busModel.scale.setScalar(0.9);

    engine.add(busModel);

    // 创建车上放的提示框,后续会更随车辆一起移动
    const tooltipDom = createBusTooltip();
    const firstLocationForTooltip = locations[0];
    if (firstLocationForTooltip.latitude1 && firstLocationForTooltip.longitude1) {
        const [bdLon, bdLat] = wgs84tobd09(firstLocationForTooltip.longitude1, firstLocationForTooltip.latitude1);
        busTooltip = engine.add(new mapvthree.DOMOverlay({
            point: [bdLon, bdLat, 50], //抬高50
            dom: tooltipDom
        }));
    }
});

这里的提示框,我们用自带的 DOMOverlay,传入位置和dom即可;它的跟随逻辑会在raf动画段落里和公交车同步更新。


站台模型加载与朝向设置

我们不仅需要加载站台模型,还需要设置站台的朝向设,一般的站台都是面朝马路的,所以这里我们需要首先找到站台和线路中最近的交点A ,然后计算出 站点 和 A点 为站点的朝向向量,这样就能让站台正确的朝向马路了。示例图如下

image.png

清楚怎么设置之后,首先我们处理好我们的站点和线路的经纬度格式:

// 创建站点标记
const createStationMarkers = () => {
    const standData = stand2Data as any;
    if (!standData || (!Array.isArray(standData.up) && !Array.isArray(standData.down))) {
        console.warn('站点数据格式不正确');
        return;
    }

    // 合并上行和下行站点数据
    const allStations = [
        ...(Array.isArray(standData.up) ? standData.up : []),
        ...(Array.isArray(standData.down) ? standData.down : [])
    ];

    // 转换为 GeoJSON 格式(wgs转 BD09)
    const features = allStations
        .filter((station: any) => typeof station?.lon === 'number' && typeof station?.lat === 'number')
        .map((station: any) => ({
            type: 'Feature',
            geometry: {
                type: 'Point',
                coordinates: [wgs84tobd09(station.lon, station.lat)[0], wgs84tobd09(station.lon, station.lat)[1]],
            },
            properties: {
                id: station.id,
                name: station.name,
                remarks: station.remarks,
                up: station.up || 0,
                down: station.down || 0,
                groupId: station.groupId,
                groupName: station.groupName || '',
                longitude: station.lon,
                latitude: station.lat,
            },
        }));

    const geojson = { type: 'FeatureCollection', features } as const;
    const dataSource = mapvthree.GeoJSONDataSource.fromGeoJSON(geojson as any);

    // 创建公交线路的 Turf LineString
    const routeLineString = createRouteLineString();

接着我们再加载站台模型,设置好大小、初始化朝向和位置

// 加载车站模型
let busStopModel: any = null;
const stationModels: any[] = [];

mapvthree.gltfLoader.load('/model/bus_stop.glb', (gltf: any) => {
    busStopModel = gltf.scene;

    // 初始化朝向
    busStopModel.rotateX(Math.PI / 2);
    busStopModel.scale.set(7, 7, 7);

    // 为每个站点创建模型实例
    features.forEach((feature: any) => {
        const originalLon = feature.properties.longitude;
        const originalLat = feature.properties.latitude;
        const [bdLon, bdLat] = wgs84tobd09(originalLon, originalLat);
        const [x, y, z] = bd09ToMercator(bdLon, bdLat);

        const stationModel = busStopModel.clone();
        stationModel.position.set(x, y, z);

        engine.scene.add(stationModel);
        stationModels.push(stationModel);
    });
});

最后我们来设置站台的朝向,朝向马路,代码如下:

// 如果存在公交线路,计算站点到线路的最近点并设置模型朝向
if (routeLineString) {
        const stationPoint = turf.point([bdLon, bdLat]);
        //找到最近的点
        const nearestPoint = turf.nearestPointOnLine(routeLineString, stationPoint);
        const nearestCoords = nearestPoint.geometry.coordinates;

        // 计算方位角
        const bearing = turf.bearing(stationPoint, turf.point(nearestCoords));

        // 转换角度(正北为0、顺时针为正) → Three Y轴旋转
        const rotationY = (bearing - 180) * Math.PI / 180;
        stationModel.rotateY(rotationY);

}

站台model调整前后对比效果图如下:

image.png


沿路行驶与转向动画

因为原数据的行驶速度过慢,我们采用固定速度,让车辆匀速的方式沿线前进,同时在转弯处做平滑的朝向过渡,避免瞬间旋转。

核心变量与参数:

// 动画参数
const speedKmh = 100;                // 行驶速度
const speedMs = speedKmh * 1000 / 3600; // m/s
const totalTimeSeconds = totalDistance / speedMs; // 总行驶时间

// 角度过渡
let currentRotationY = 0;            // 当前朝向(弧度)
const rotationSpeed = 2;             // 最大旋转速度(弧度/秒)

动画主循环负责两件事:位置插值与朝向插值。

  1. 位置插值(沿线段线性插值)
const elapsed = (Date.now() - startTime) / 1000;
const progress = Math.min(elapsed / totalTimeSeconds, 1);
const currentDistance = progress * totalDistance;

// 通过累计里程数组 `distances` 定位当前所在的线段
let pointIndex = 0;
for (let i = 0; i < distances.length - 1; i++) {
  if (currentDistance >= (distances[i] || 0) && currentDistance <= (distances[i + 1] || 0)) {
    pointIndex = i; break;
  }
}

// 计算线段内比例,并对两端墨卡托坐标做线性插值
const segmentStart = distances[pointIndex] || 0;
const segmentEnd = distances[pointIndex + 1] || 0;
const t = segmentEnd > segmentStart ? (currentDistance - segmentStart) / (segmentEnd - segmentStart) : 0;

// WGS84 转 墨卡托
const [startBdLon, startBdLat] = wgs84tobd09(startLocation.longitude1, startLocation.latitude1);
const [endBdLon, endBdLat]   = wgs84tobd09(endLocation.longitude1, endLocation.latitude1);
const [startX, startY] = bd09ToMercator(startBdLon, startBdLat);
const [endX, endY]     = bd09ToMercator(endBdLon, endBdLat);

const currentX = startX + (endX - startX) * t;
const currentY = startY + (endY - startY) * t;
busModel.position.set(currentX, currentY, 0);
  1. 朝向角插值(平滑转向,避免突变)

算法:用当前线段向量 end - start 求出航向角 targetAngle = atan2(dy, dx);再用“夹角归一到 ([-π, π])”与“最大角速度”把 currentRotationYtargetAngle 推进。这样在急转弯处也会过渡自然,当然你也可以直接用gsap过渡

const directionX = endX - startX;
const directionY = endY - startY;
const directionLength = Math.sqrt(directionX * directionX + directionY * directionY);

if (directionLength > 0) {
  const targetAngle = Math.atan2(directionY, directionX);

  // 夹角归一 [-π, π]
  let angleDiff = targetAngle - currentRotationY;
  while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
  while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;

  // 最大角速度限制
  const deltaTime = 1 / 60;
  const maxRotationChange = rotationSpeed * deltaTime;
  if (Math.abs(angleDiff) > maxRotationChange) {
    currentRotationY += Math.sign(angleDiff) * maxRotationChange;
  } else {
    currentRotationY = targetAngle;
  }

  busModel.rotation.y = currentRotationY;
}

效果示例:

QQ20251030-164053-HD-soConvert.webp

相机跟随与视角策略

我们需要提供“自由”和“跟随”两种视角模式的切换,在跟随时的时候我们只跟随距离,就像FPS游戏中TPP视角一样,吃鸡PUBG就是这种策略。

跟随实现:每帧在更新车辆位置中用mapthree中的 lookAt 把镜头看向车辆当前位置,设置固定距离 range: 300

//raf动画循环
if (cameraFollowMode) {
  const [currentBdLon, currentBdLat] = mercatorToBd09(currentX, currentY);
  engine.map.lookAt([currentBdLon, currentBdLat], {
    range: 300
  } as any);
}

自由视角直接将cameraFollowMode设为false即可;

停之停之!文章写到这里我我发现百度地图的开发者更新了新功能,他们支持了自定义镜头动画,赶紧换上

image.png

现在只需要传入model即可,不需要再每一帧手动更新相机位置,相比于之前的拖动丝滑的很多,lock设置为true即可固定视角

const tracker = engine.add(new mapvthree.ObjectTracker())
tracker.track(busModel, {
  range: 300,
  pitch: 80,
  heading: 10,
  lock: false, //不锁定视角
})

QQ20251030-175611-HD-soConvert.webp

高级参数可视化

以上实现就已经完成实时公交的基本形式的可视化了,但是我们要做就要做的更详细一点,加入类似于电子公交屏的功能:距离下一站距离当前到达下一站运行状态

  1. 运行状态:公交车运行状态机,包含四个状态
    • driving:正常行驶
    • approaching:减速进站(距离站点 < 200米)
    • stopped:站台停靠(距离站点 < 30米,停留3秒)
    • departing:启动离站
  2. 距离计算:使用 Turf.js 计算车辆当前位置到下一站的直线距离(可以用线段截取计算的方式实际剩余距离)
  3. 进度显示:基于距离计算的到站进度
  4. 站点状态管理
    • passedStations:已通过站点数组
    • currentStationIndex:当前最接近的站点
    • nextStationIndex:下一站
    • 站点状态分为:已通过、当前站、下一站、未到达

通过在 updateBusStatus 函数每帧去更新:计算车辆到所有站点的距离,找到最近站点,更新到站状态,并计算到下一站的距离和进度即可~

// raf更新函数....
// 更新公交车位置和状态
const updateBusStatus = (currentLon: number, currentLat: number) => {
    // 计算到所有站点的距离
    const currentPoint = turf.point([currentLon, currentLat]);
    const stationDistances = currentStations.value.map((station: any, index: number) => {
        const [bdLon, bdLat] = wgs84tobd09(station.lon, station.lat);
        const stationPoint = turf.point([bdLon, bdLat]);
        const distance = turf.distance(currentPoint, stationPoint, { units: 'meters' });
        return { index, distance, station };
    });

    // 找到最近的站点
    const nearestStation = stationDistances.reduce((min: any, current: any) =>
        current.distance < min.distance ? current : min
    );

    // 到站判断(距离小于50米认为到站)
    const stationThreshold = 50;
    const isAtStation = nearestStation.distance < stationThreshold;

    // 处理到站状态
    handleStationStateMachine(nearestStation.index, nearestStation.distance);

    // 计算到下一站的距离
    const nextStation = currentStations.value[busStatus.value.nextStationIndex];
    if (nextStation) {
        const [nextBdLon, nextBdLat] = wgs84tobd09(nextStation.lon, nextStation.lat);
        const nextStationPoint = turf.point([nextBdLon, nextBdLat]);
        busStatus.value.distanceToNext = turf.distance(currentPoint, nextStationPoint, { units: 'meters' });
    }
};

效果图:

QQ20251031-142631-HD-soConvert.webp

其实上面的数据在实际业务中是后端不会再前端去计算,这里也只是阐述一下业务逻辑,实现一下效果,还有就是实际业务是要接入实时位置更新的,我们需要实时去更新公交车的位置,简单的阐述一下业务,一般的做法是每辆车需要维护一个信号队列,然后逐个去执行队列,这样车辆的延迟是第一个和第二个信号之间的时间差,画了一个逻辑图:

image.png

而且实际中实时数据是会抖动的,出现长时间没信号、信号批量涌入、gps信号乱跳这些都会出现,若接入真实 GPS,可对点做卡尔曼等滤波处理,减少抖动,让公交车的行进看起来更自然更流畅一些。


好了,以上就是线路客流、区域客流和实时公交的所有内容了,本人技术十分有限,如有不合理或者错误的地方还望指出

代码仓库:zuo-wentao/bmap-demo: bmap demp

基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流

基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流

image.png

前言

在上一篇我们实现了公交线路客流,通过飞线效果展示了站点间的客流流向。现在我们来搞一下区域客流可视化,采用六边形蜂窝网格来展示不同区域的客流热力图,除了保证数据更加直观外,当然也要利用JSAPIThree高灵活自定义的优势来搞点帅的东西。

在公交行业的区域客流可视化主要的是:

  • 哪些区域的公交客流最密集
  • 通过热力图快速识别热点区域
  • 面子攻城(bushi)

与线路客流相比,区域客流更注重空间分布特征这块。我们使用六边形蜂窝网格将城市区域进行规则划分(也支持正方形、三角形),每个六边形代表一个单元,通过统计单元内的公交站点数量和客流数据,生成蜂窝热力图来直观展示每块区域的客流密度分布。

技术实现

数据准备

基于上一篇文章的初始化地图代码,我们需要以下数据文件(文件在下方仓库地址):

  1. 边界数据 (guzhen.json) - 城市或区域边界数据
  2. 站点数据 (stands.json) - 公交站点位置和客流数据

边界数据采用标准的 GeoJSON 格式(这种数据推荐去阿里的datav中可以直接获取包括省市区)。站点数据包含每个站点的经纬度坐标和客流统计信息。

蜂窝网格生成

六边形相对比矩形和三角形看起来更专业一点。我们使用 Turf.js 的 hexGrid 函数来生成蜂窝网格(truf也支持三角形和矩形)。

网格生成原理:

  1. 边界框计算:使用 bbox 函数计算多边形的包围盒
  2. 网格生成 :- 在边界框内生成指定radius的蜂窝网格
  3. 空间裁剪:使用 booleanIntersects 过滤与目标区域相交的六边形,也就是整个区域内的蜂窝
import { bbox, polygon, hexGrid, booleanIntersects, booleanContains } from '@turf/turf'

// 生成 1.5km 六边形蜂窝并裁剪到目标边界
const hexLinesFC = () => {
  const boundary = guzhen.features[0].geometry
  const wgsPolygon = polygon([boundary.coordinates[0]])
  const box = bbox(wgsPolygon)

  // 生成 1.5公里半径的六边形网格
  const grid = hexGrid(box, 1.5, { units: 'kilometers' })

  // 过滤与边界之外的六边形
  const features = grid.features.filter((cell) => booleanIntersects(cell, wgsPolygon))

  return { type: 'FeatureCollection', features }
}

booleanIntersects 函数是空间相交判断,booleanContains函数是判断否在空间内,我们只保留与目标区域重叠的六边形

站点数据统计

为每个六边形计算站点数量和总客流数据,这是为了生成热力图用的的数值。

统计原理:

  1. 包含判断 - 使用 booleanContains 函数判断站点是否在六边形内
  2. 数据聚合 - 累加六边形内所有站点的客流数据
// 计算每个六边形内的站点数据
const calculateHexagonData = (hexagon) => {
  let totalUp = 0 //六边形内所有站点的上车人数总和
  let stationCount = 0 // 六边形内包含的站点数量

  // 遍历所有站点,检查是否在六边形内
  for (const station of stands) {
    for (const stand of station.stands) {
      const standPoint = point([stand.lon, stand.lat])

      //是否在内部
      if (booleanContains(hexagon, standPoint)) {
        totalUp += stand.up || 0
        stationCount++
      }
    }
  }

  return { totalUp, stationCount }
}

然后我们可以用使用处理好的所有数据使用mapvthree.Polyline进行预览,代码如下:

// 生成六边形蜂窝并裁剪到边界
const hexLinesFC = (): any => {
  const g = (guzhen as any)?.features?.[0]?.geometry
  if (!g) return { type: 'FeatureCollection', features: [] }
  // 使用边界外环构造 turf 多边形
  let wgsOuter: [number, number][] = []
  if (g.type === 'Polygon') {
    wgsOuter = (g.coordinates?.[0] || []) as [number, number][]
  } else if (g.type === 'MultiPolygon') {
    wgsOuter = (g.coordinates?.[0]?.[0] || []) as [number, number][]
  }
  if (!wgsOuter || wgsOuter.length < 3) return { type: 'FeatureCollection', features: [] }

  const wgsPolygon = turfPolygon([wgsOuter])
  const box = turfBbox(wgsPolygon)

  const radius = 1.5
  // 生成 5 公里六边形网格
  const grid = turfHexGrid(box, radius, { units: 'kilometers' } as any)
  // 过滤与多边形相交的六边形
  const features: any[] = []
  for (const cell of grid.features || []) {
    try {
      if (turfBooleanIntersects(cell as any, wgsPolygon as any)) {
        const ring: [number, number][] = (cell.geometry as any)?.coordinates?.[0] || []
        if (Array.isArray(ring) && ring.length > 0) {
          // 计算六边形内的站点数据
          const hexData = calculateHexagonData(cell)

          const bdCoords = ring.map(([lon, lat]) => wgs84tobd09(lon, lat))
          features.push({
            type: 'Feature',
            geometry: { type: 'LineString', coordinates: bdCoords },
            properties: {
              type: 'hex',
              radius_km: radius,
              totalUp: hexData.totalUp,
              stationCount: hexData.stationCount,
              hexagonId: features.length, 
            },
          })
        }
      }
    } catch (_e) {}
  }
  return { type: 'FeatureCollection', features }
}

//传入数据
const hexSource = mapvthree.GeoJSONDataSource.fromGeoJSON(hexLinesFC() as any)
const hexLayer = engine.add(
  new mapvthree.Polyline({
    flat: true,
    lineWidth: 1.5,
    keepSize: true,
    color: '#7A7AFF',
  }),
)
hexLayer.dataSource = hexSource

目前的基础效果就是这个样子:

image.png

蜂窝区块可视化

现在我们要让这些六边形更加的层次分明,要用颜色和透明度来直观展示客流密度分布,让数据更可视化。我们使用 THREE.js 的 LineSegments 来绘制六边形边框,为了实现更吊的热力图效果。上面的 mapvthree 蜂窝可以暂时隐藏,专注于我们自定义效果的实现。

1. 六边形边框着色

接着我们使用 HSL色彩空间实现根据蜂窝内的总下车人数从绿色到红色的自然过渡

const createHexagonLineSegments = () => {
    const hexData = hexLinesFC()
    const vertices = []
    const colors = []

    // 找到客流最大的六边形作为基准
    const maxTotalUp = Math.max(...hexData.features.map((f) => f.properties.totalUp))

    for (const feature of hexData.features) {
        const { totalUp, stationCount } = feature.properties
        const coords = feature.geometry.coordinates

        // 根据客流数据调色
        let heatColor = new THREE.Color()

        if (stationCount > 0) {
            const intensity = totalUp / maxTotalUp
            // 从绿色到红色渐变
            heatColor.setHSL(0.33 - intensity * 0.33, 1.0, 0.5)
        } else {
            // 没有站点的区域保持灰色
            heatColor.setHSL(0, 0, 0.3)
        }

        // 设置颜色
        for (let i = 0; i < coords.length - 1; i++) {
            const [x1, y1] = bd09ToMercator(coords[i][0], coords[i][1])
            const [x2, y2] = bd09ToMercator(coords[i + 1][0], coords[i + 1][1])

            vertices.push(x1, y1, 0, x2, y2, 0)
            colors.push(heatColor.r, heatColor.g, heatColor.b)
            colors.push(heatColor.r, heatColor.g, heatColor.b)
        }
    }

    // 创建几何体,让每条线都有颜色
    const geometry = new THREE.BufferGeometry()
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
    geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))

    const material = new THREE.LineBasicMaterial({
        vertexColors: true,
        transparent: true,
        opacity: 0.8,
    })

    return new THREE.LineSegments(geometry, material)
}

好的,上色之后我们可以很直观的看到哪里的客流多:

image.png

2. 填充六边形

光有边框还不够,我们再来给热力图填充颜色。半透明的填充让整个热力图数据效果看起来更加直观,视觉层次也更丰富。

const createHexagonFillPolygons = () => {
  const hexData = hexLinesFC()
  const polygons = []

  for (const feature of hexData.features) {
    const { totalUp, stationCount } = feature.properties
    const coords = feature.geometry.coordinates

    
    if (stationCount === 0) continue

    // 填充着色
    const intensity = totalUp / maxTotalUp
    const heatColor = new THREE.Color()
    heatColor.setHSL(0.33 - intensity * 0.33, 1.0, 0.5)

    // 创建三角形面片
    const vertices = []
    const center = calculateCenter(coords)

    for (let i = 0; i < coords.length - 1; i++) {
      const [x1, y1] = bd09ToMercator(coords[i][0], coords[i][1])
      const [x2, y2] = bd09ToMercator(coords[i + 1][0], coords[i + 1][1])

      vertices.push(center.x, center.y, 0, x1, y1, 0, x2, y2, 0)
    }

    const geometry = new THREE.BufferGeometry()
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))

    const material = new THREE.MeshBasicMaterial({
      color: heatColor,
      transparent: true,
      opacity: 0.2,
      side: THREE.DoubleSide,
    })

    polygons.push(new THREE.Mesh(geometry, material))
  }

  return polygons
}

这样,我们的热力图就有了底层边框和内部填充:

image.png

看起来蛮吊的,还能不能更唬人一点

3. 添加扫光shader效果

扫描效果还是非常适合这种网格的面,采用从左上到右下的渐变矩形扫光 大致效果如图所示:

image.png

const createSweepShaderMaterial = () => {
    return new THREE.ShaderMaterial({
        uniforms: {
            time: { value: 0.0 },
            sweepColor: { value: new THREE.Color(0x00ffa8) },
            sweepSpeed: { value: 0.5 },
        },
        vertexShader: `
      attribute vec3 color;
      varying vec3 vColor;
      varying vec2 vUv;

      void main() {
        vColor = color;
        vUv = position.xy;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `,
        fragmentShader: `
      uniform float time;
      uniform vec3 sweepColor;
      uniform float sweepSpeed;
      varying vec3 vColor;
      varying vec2 vUv;

      void main() {
        uniform float time;
                uniform vec3 sweepColor;
                uniform float sweepWidth;
                uniform float sweepSpeed;
                uniform float glowIntensity;

                varying vec2 vUv;
                varying vec3 vColor;
                varying float vOpacity;

                void main() {
                    float sweepPos = mod(time * sweepSpeed, 2.0);
                  float diagonalDist = (vUv.x + (1.0 - vUv.y)) * 0.5;
                  float dist = abs(diagonalDist - sweepPos);

                  // 光衰减和柔尾
                  float gradient = 1.0 - smoothstep(0.0, sweepWidth, dist);
                  float softGlow = exp(-dist / (sweepWidth * 0.3));
                  float sweep = mix(gradient, softGlow, 0.5);

                  // 脉冲
                  sweep *= 0.7 + 0.3 * sin(time * 8.0);
                  sweep = clamp(sweep, 0.0, 1.0);

                  // 混色 和 发光
                  vec3 finalColor = mix(vColor, sweepColor, sweep);
                  finalColor += sweepColor * sweep * glowIntensity;

                  // bloom 触发 
                  finalColor *= 10.0;

                gl_FragColor = vec4(finalColor, vOpacity);
                      }
                    `,
        transparent: true,
        blending: THREE.AdditiveBlending,
    })
}

扫光效果就出来了,看起来很科幻,这领导看不得拍手叫好?

QQ20251029-15237-HD-soConvert.webp

区域掩膜效果

为了突出更加沉浸的显示目标区域,我们创建一个黑色掩膜来遮挡区域外的内容,让观众的注意力集中在目标区域。

实现的步骤:

  1. 世界矩形 - 创建覆盖整个地球的大矩形
  2. 区域掏空 - 将目标区域从世界矩形中挖出
  3. 黑色填充 - 使用黑色填充
// 创建区域掩膜
const buildMaskFC = () => {
  const boundary = guzhen.features[0].geometry

  // 世界矩形
  const worldRect = [
    [-180, -85],
    [180, -85],
    [180, 85],
    [-180, 85],
    [-180, -85],
  ]

  // 目标区域作为洞
  const hole = boundary.coordinates[0].map(([lon, lat]) => wgs84tobd09(lon, lat))

  return {
    type: 'Feature',
    geometry: {
      type: 'Polygon',
      coordinates: [worldRect, hole], // 外环 + 内环
    },
  }
}

const maskLayer = engine.add(
  new mapvthree.Polygon({
    flat: true,
    color: '#0D161C',
    opacity: 1,
  }),
)
maskLayer.dataSource = mapvthree.GeoJSONDataSource.fromGeoJSON(buildMaskFC())

效果如图,会更专注聚焦这个区域

image.png

站点粒子效果

最后为所有公交站点添加发光粒子效果,能够清晰的看到站点分布在蜂窝的情况,我们使用threejs的粒子Points,并让他发光以增强效果,首先将Point位置投影到站点实际的位置 然后使用canvas为粒子创建纹理材质,最后增加亮度触发Bloom即可~


const createStationParticles = () => {
  const positions = []

  // 收集坐标
  for (const station of stands) {
    for (const stand of station.stands) {
      const [x, y] = bd09ToMercator(
        wgs84tobd09(stand.lon, stand.lat)[0],
        wgs84tobd09(stand.lon, stand.lat)[1],
      )
      positions.push(x, y, 0)
    }
  }

  // 创建粒子
  const geometry = new THREE.BufferGeometry()
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))

  // 创建纹理
  const texture = createCircleTexture(64, '#ffffff')

  // 创建发光材质
  const material = new THREE.PointsMaterial({
    size: 5,
    map: texture,
    transparent: true,
    blending: THREE.AdditiveBlending,
  })

  // 触发泛光
  material.color.setRGB(4, 4, 4)

  return new THREE.Points(geometry, material)
}

// 生成纹理
const createCircleTexture = (size, color) => {
  const canvas = document.createElement('canvas')
  canvas.width = canvas.height = size
  const ctx = canvas.getContext('2d')

  ctx.fillStyle = color
  ctx.beginPath()
  ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
  ctx.fill()

  return new THREE.CanvasTexture(canvas)
}

image.png

技术要点

1. 空间计算

主要的实现还是靠turf,turf真是对数学不好的开发的一种福音啊,好用爱用, 拿到边界数据使用bbox计算出边界,然后在这个包围盒通过turfHexGrid生成Hexagon蜂窝,最后用booleanContains裁剪掉地区边界外的蜂窝。

2. 颜色生成

先将客流的数据都维持在0-1之间,这里也叫数据归一化,然后更具数值为设置HSL颜色也就是类似css的rab(255,255,255) 这种写法。

3. Shader

shader glsl不像js那样可以打印调试,完全靠抽象的脑补,这里主要的步骤: 位置计算 → 距离场 → 光脉冲 → 合成

总结

区域客流可视化通过六边形蜂窝网格和热力效果,除了能把复杂的空间数据转化为直观的视效,还结合扫光动画和粒子效果增加视觉体验。

下一篇我们将继续实现实时公交

代码仓库:zuo-wentao/bmap-demo: bmap demp

实现无缝滚动无滚动条的 Element UI 表格(附完整代码)

实现无缝滚动无滚动条的 Element UI 表格(附完整代码)

在后台管理系统或数据监控场景中,经常需要实现表格无缝滚动展示数据,同时希望隐藏滚动条保持界面整洁。本文将基于 Element UI 实现一个 无滚动条、无缝循环、hover 暂停、状态高亮 的高性能滚动表格,全程流畅无卡顿,适配多浏览器。

1.gif

最终效果

  • 🚀 无缝循环滚动,无停顿、无跳跃
  • 🚫 视觉上完全隐藏滚动条,保留滚动功能
  • 🛑 鼠标悬浮自动暂停,离开恢复滚动
  • 🌈 支持状态字段高亮(如不同状态显示不同颜色)
  • 🎨 美观的表格样式,hover 行高亮反馈
  • 🛠 高度可配置(行高、滚动速度、表格高度等)

技术栈

  • Vue 2 + Element UI(适配 Vue 2 项目,Vue 3 可快速迁移)
  • SCSS(样式模块化,便于维护)

实现思路

  1. 无缝滚动核心:通过「数据拼接」(原数据 + 原数据副本)实现视觉上的无限循环,滚动到原数据末尾时瞬间重置滚动位置,无感知切换
  2. 隐藏滚动条:多浏览器兼容 CSS 屏蔽滚动条样式,同时预留滚动条宽度避免内容裁剪
  3. 流畅滚动优化:避免 DOM 频繁重绘,用 scrollTop 控制滚动,关闭平滑滚动避免停顿
  4. 交互增强:hover 暂停滚动、行 hover 高亮、状态字段颜色区分

配置说明

参数名 类型 默认值 说明
tableData Array [] 表格数据源(必传)
columns Array [] 列配置(必传,支持 statusConfig 状态样式)
rowHeight Number 36 行高(单位:px)
scrollSpeed Number 20 滚动速度(毫秒 / 像素),值越小越快
scrollPauseOnHover Boolean true 鼠标悬浮是否暂停滚动
tableHeight Number 300 表格高度(父组件配置)

完整代码实现

1. 滚动表格组件(SeamlessScrollTable.vue)

<template>
  <div class="tableView">
    <el-table
      :data="combinedData"
      ref="scrollTable"
      style="width: 100%"
      height="100%"
      @cell-mouse-enter="handleMouseEnter"
      @cell-mouse-leave="handleMouseLeave"
      :cell-style="handleCellStyle"
      :show-header="true"
    >
      <el-table-column
        v-for="(column, index) in columns"
        v-bind="column"
        :key="index + (column.prop || index)"
        :min-width="column.minWidth || '100px'"
      >
        <template slot-scope="scope">
          <span v-if="column.statusConfig" :class="getColumnStatusClass(column, scope.row)">
            {{ scope.row[column.prop] }}
          </span>
          <span v-else>
            {{ scope.row[column.prop] }}
          </span>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
  export default {
    name: 'SeamlessScrollTable',
    props: {
      tableData: {
        type: Array,
        required: true,
        default: () => [],
      },
      columns: {
        type: Array,
        required: true,
        default: () => [],
      },
      rowHeight: {
        type: Number,
        default: 36,
      },
      scrollSpeed: {
        type: Number,
        default: 20, // 滚动速度(毫秒/像素),20-40ms
      },
      scrollPauseOnHover: {
        type: Boolean,
        default: true,
      },
    },
    data() {
      return {
        autoPlay: true,
        timer: null,
        offset: 0,
        combinedData: [], // 拼接后的数据,用于实现无缝滚动
      }
    },
    computed: {
      // 计算表格可滚动的总高度(仅当数据足够多时才滚动)
      scrollableHeight() {
        return this.tableData.length * this.rowHeight
      },
      // 表格容器可视高度
      viewportHeight() {
        return this.$refs.scrollTable?.$el.clientHeight || 0
      },
    },
    watch: {
      tableData: {
        handler(newVal) {
          // 数据变化时,重新拼接数据
          this.combinedData = [...newVal, ...newVal]
          this.offset = 0
          this.restartScroll()
        },
        immediate: true,
        deep: true,
      },
      autoPlay(newVal) {
        newVal ? this.startScroll() : this.pauseScroll()
      },
    },
    mounted() {
      this.$nextTick(() => {
        // 只有当数据总高度 > 可视高度时,才启动滚动
        if (this.scrollableHeight > this.viewportHeight) {
          this.startScroll()
        }
      })
    },
    beforeDestroy() {
      this.pauseScroll()
    },
    methods: {
      handleMouseEnter() {
        this.scrollPauseOnHover && (this.autoPlay = false)
      },
      handleMouseLeave() {
        this.scrollPauseOnHover && (this.autoPlay = true)
      },
      startScroll() {
        this.pauseScroll()

        const tableBody = this.$refs.scrollTable?.bodyWrapper
        if (!tableBody || this.tableData.length === 0) return

        this.timer = setInterval(() => {
          if (!this.autoPlay) return

          this.offset += 1
          tableBody.scrollTop = this.offset

          // 关键:当滚动到原数据末尾时,瞬间重置滚动位置到开头
          if (this.offset >= this.scrollableHeight) {
            this.offset = 0
            tableBody.scrollTop = 0
          }
        }, this.scrollSpeed)
      },
      pauseScroll() {
        this.timer && clearInterval(this.timer)
        this.timer = null
      },
      restartScroll() {
        this.pauseScroll()
        if (this.scrollableHeight > this.viewportHeight) {
          this.startScroll()
        }
      },
      getColumnStatusClass(column, row) {
        const statusKey = column.statusField || column.prop
        const statusValue = row[statusKey]
        return typeof column.statusConfig === 'function'
          ? column.statusConfig(statusValue, row)
          : column.statusConfig[statusValue] || ''
      },
      handleCellStyle() {
        return {
          padding: '4px 0',
          height: `${this.rowHeight}px`,
          lineHeight: `${this.rowHeight}px`,
        }
      },
    },
  }
</script>

<style scoped lang="scss">
  .tableView {
    width: 100%;
    height: 100%;
    overflow: hidden;

    ::v-deep .el-table {
      background-color: transparent;
      color: #303133;
      border-collapse: separate;
      border-spacing: 0;

      &::before {
        display: none;
      }

      th.el-table__cell.is-leaf {
        border-bottom: 1px solid rgba(0, 0, 0, 0.1);
        background: transparent !important;
        font-weight: 500;
        color: rgba(0, 0, 0, 0.6);
        padding: 8px 0;
      }

      tr.el-table__row {
        background-color: transparent;
        transition: background-color 0.2s ease;

        &:hover td {
          background-color: rgba(0, 0, 0, 0.02) !important;
        }
      }

      .el-table__cell {
        border: none;
        padding: 4px 0;

        .cell {
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          padding: 0 8px;
        }
      }

      .el-table__body-wrapper {
        height: 100%;
        scroll-behavior: auto;
        &::-webkit-scrollbar {
          display: none !important;
          width: 0 !important;
          height: 0 !important;
        }
        scrollbar-width: none !important;
        -ms-overflow-style: none !important;
      }
    }

    ::v-deep .status-warning {
      color: #e6a23c;
      font-weight: 500;
    }

    ::v-deep .status-danger {
      color: #f56c6c;
      font-weight: 500;
    }

    ::v-deep .status-success {
      color: #67c23a;
      font-weight: 500;
    }

    ::v-deep .status-info {
      color: #409eff;
      font-weight: 500;
    }
  }
</style>

2. 父组件使用示例(TableIndex.vue)

<template>
  <div class="table-container">
    <h2 class="table-title">设备状态监控表格</h2>
    <div class="table-wrapper" :style="{ height: tableHeight + 'px' }">
      <!-- 配置滚动参数 -->
      <seamless-scroll-table
        :table-data="tableData"
        :columns="columns"
        :row-height="36"
        :scroll-speed="30"
      />
    </div>
  </div>
</template>

<script>
  import SeamlessScrollTable from './SeamlessScrollTable.vue'

  export default {
    name: 'DeviceStatusTable',
    components: { SeamlessScrollTable },
    data() {
      return {
        tableHeight: 300, // 表格高度可配置
        // 表格数据
        tableData: [
          { id: '1001', name: '设备A', type: '温度', state: '待检查' },
          { id: '1002', name: '设备B', type: '压力', state: '已超期' },
          { id: '1003', name: '设备C', type: '湿度', state: '已完成' },
          { id: '1004', name: '设备D', type: '电压', state: '超期完成' },
          { id: '1005', name: '设备E', type: '电流', state: '待检查' },
          { id: '1006', name: '设备F', type: '电阻', state: '已超期' },
          { id: '1007', name: '设备G', type: '功率', state: '已完成' },
        ],
        // 列配置
        columns: [
          { prop: 'id', label: '编号', minWidth: '140px' },
          { prop: 'name', label: '名称', width: '100px' },
          { prop: 'type', label: '设备类型', width: '120px' },
          {
            prop: 'state',
            label: '状态',
            width: '100px',
            statusField: 'state',
            // 状态样式配置(支持对象/函数)
            statusConfig: {
              待检查: 'status-warning',
              已超期: 'status-danger',
              已完成: 'status-success',
              超期完成: 'status-info',
            },
          },
        ],
      }
    },
    methods: {
      getStatusClass(state) {
        const statusMap = {
          待检查: 'status-warning',
          已超期: 'status-danger',
          已完成: 'status-success',
          超期完成: 'status-info',
        }
        return statusMap[state] || ''
      },
    },
  }
</script>

<style scoped lang="scss">
  .table-container {
    width: 100%;
    max-width: 500px;
    margin: 0 auto;
    padding: 20px;
    box-sizing: border-box;
  }

  .table-title {
    color: #303133;
    margin-bottom: 16px;
    font-size: 18px;
    font-weight: 500;
    text-align: center;
    position: relative;
  }

  .table-wrapper {
    background-color: #ffffff;
    border-radius: 8px;
    padding: 16px;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
    box-sizing: border-box;
  }
</style>

一个 Vite 打包配置,引发的问题—— global: 'globalThis'

1、问题:今天在发开中要对一个包版本升级,使用新的版本,安装官方文档进行了引用配置,

image.png

image.png 根据文档,配置完毕后,本地运行项目一切正常,但到打包构建时却出现了一下问题。

Could not resolve "./lib/globalThis-analytics-helper"
from "node_modules/@segment/analytics-next/dist/pkg/index.js"

一开始没有发现问题所在,就去 node_modules/@segment/analytics-next/dist/pkg/index.js 文件里面查找,是否引用了./lib/globalThis-analytics-helper,只发现了

export { getGlobalAnalytics } from './lib/global-analytics-helper';

这个的引用,没有 globalThis-analytics-helper为什么global会变成globalThis内,我就把 vite.config.ts 中的define: { global: 'globalThis' }配置注释,再次打包竟然成功了,这里我就发现了是这里的问题导致的,后面就改成了

optimizeDeps: {
    esbuildOptions: {
      // Node.js global to browser globalThis
      define: {
        global: "globalThis",
      },
      // Enable esbuild polyfill plugins
      plugins: [],
    },
  },

打包就成功了,而且本地也可以正常运行了。

2、根本原因分析

  • define.global 的作用:
    • Vite在构建阶段(包括devbuild)会 把项目中所有出现的global替换成globalThis
    • 这种全局替换会影响第三方库的内部模块解析逻辑。
  • optimizeDeps.esbuildOptions.define 的区别:
    • 只在开发模式下预构建依赖时生效,不影响打包。
    • 安全地解决浏览器对Node.js库的global兼容性问题。
    • 不会破坏第三方库的模块解析,因此不会触发类似错误。

3、错误分析

  • 使用 define: { global: 'globalThis'},配置后,导致和@segment/analytics-next内的global冲突,在打包时把@segment/analytics-next路径中global替换成了globalThis导致路径解析时,找不到路径而失败。

Vue3 选择弹窗工厂函数:高效构建可复用数据选择组件

在 Vue 项目开发中,数据选择弹窗是高频出现的交互组件,比如用户选择、角色选择、部门选择等场景。如果每个选择场景都重复开发弹窗逻辑,不仅会导致代码冗余,还会增加维护成本。本文将深入解析一个基于 Vue 3 和 Arco Design 的选择弹窗工厂函数,带你理解其设计思想、实现细节与应用方式,助力提升组件复用效率。

完整源码展示

import type { Component } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { h, ref } from 'vue'

interface CreateSelectDialogParams {
  title: string
  component: Component
  componentProps?: Record<string, any>
  tip?: string
}

/**
 * 选择弹窗配置选项接口
 * @template T 选中数据的类型
 */
interface SelectDialogOptions<T> {
  /** 弹窗标题(会覆盖创建参数中的title) */
  title?: string
  /** 是否允许多选 */
  multiple?: boolean
  /** 查询参数,通常用于初始化数据 */
  queryParams?: Record<string, any>
  /** 传递给组件的额外属性 */
  componentProps?: Record<string, any>
  /** 点击确定按钮后的回调函数 */
  onOk?: (data: T) => void
  /** 点击确定前的校验函数,返回Promise<boolean>决定是否允许确定 */
  onBeforeOk?: (data: T) => Promise<boolean>
}

/**
 * 创建一个选择类型的弹窗工厂函数
 *
 * 该函数返回一个创建特定类型选择弹窗的方法,适用于需要从列表中选择数据的场景。
 * 内部使用Vue的createVNode动态渲染组件,并通过ref获取组件实例的方法。
 *
 * @template T 选中数据的类型
 * @param {CreateSelectDialogParams} params 创建弹窗所需的基本参数
 * @returns {(options: SelectDialogOptions<T>) => void} 可配置的弹窗创建函数
 *
 * @example
 * // 创建一个用户选择弹窗
 * const selectUserDialog = createSelectDialog({
 *   title: '选择用户',
 *   component: UserSelectComponent,
 *   tip: '请至少选择一个用户'
 * })
 *
 * // 打开弹窗并处理选择结果
 * selectUserDialog({
 *   multiple: true,
 *   queryParams: { status: 'active' },
 *   onOk: (selectedUsers) => {
 *     console.log('已选择用户:', selectedUsers)
 *   }
 * })
 */
export const createSelectDialog = <T = any>(params: CreateSelectDialogParams) => {
  return (options: SelectDialogOptions<T>) => {
    const TableRef = ref<any>()
    Modal.open({
      // 优先使用options中的title,否则使用params中的title
      title: options.title || params.title,
      // 动态渲染传入的组件,设置ref引用并合并属性
      content: () => h(params.component, {
        ref: (e: any) => (TableRef.value = e),
        multiple: options.multiple,
        queryParams: options.queryParams,
        ...params.componentProps,
        ...options.componentProps
      }),
      // 设置弹窗宽度自适应
      width: 'calc(100% - 20px)',
      modalStyle: { maxWidth: '1000px' },
      bodyStyle: { overflow: 'hidden', height: '500px', padding: 0 },
      onBeforeOk: async () => {
        // 检查组件是否暴露了必要的getSelectedData方法
        if (!TableRef.value?.getSelectedData) {
          Message.warning('组件必须暴露getSelectedData方法')
          return false
        }

        // 获取选中的数据
        const data = TableRef.value?.getSelectedData?.() || []

        // 验证是否选择了数据
        if (!data.length) {
          Message.warning(params.tip || '请选择数据')
          return false
        }

        // 如果提供了前置校验函数,则调用并根据结果决定是否继续
        if (options?.onBeforeOk) {
          return await options.onBeforeOk(data)
        }

        // 调用确定回调函数,传递选中的数据
        options.onOk?.(data)
        return true
      }
    })
  }
}

一、函数设计背景与核心目标

在中后台系统中,数据选择弹窗通常具备以下共性需求:

  1. 统一的弹窗容器(标题、确认 / 取消按钮、尺寸控制);
  1. 动态嵌入不同的选择组件(如用户列表、角色表格);
  1. 支持单选 / 多选切换、初始化查询参数传递;
  1. 选中数据校验、前置拦截与结果回调;
  1. 组件间通信与方法调用(如获取选中数据)。

传统开发方式中,这些需求往往通过 “复制粘贴 + 修改” 实现,导致代码重复率高、

逻辑分散而本文解析的createSelectDialog工厂函数,正是为解决这些痛点而生,其核心目标是:封装共性逻辑,暴露个性化配置,实现 “一次定义,多场景复用”

二、核心架构与类型定义解析

在理解函数实现前,我们先梳理其类型接口与整体架构,这是保障代码健壮性和可维护性的基础。

1. 关键接口定义

函数通过 TypeScript 接口明确了参数与配置的结构,避免类型混乱,提升开发体验。

(1)创建弹窗的基础参数接口:CreateSelectDialogParams

该接口定义了创建特定类型选择弹窗的 “固定属性”,是工厂函数的 “原料”:

interface CreateSelectDialogParams {
  title: string; // 弹窗默认标题
  component: Component; // 嵌入弹窗的选择组件(如用户列表)
  componentProps?: Record<string, any>; // 传递给选择组件的默认属性
  tip?: string; // 未选择数据时的提示文本
}

  • component:核心属性,指定弹窗内渲染的选择组件(需暴露getSelectedData方法,用于获取选中数据);
  • componentProps:为选择组件设置默认属性,如表格的border、rowKey等通用配置。

(2)弹窗配置选项接口:SelectDialogOptions

该接口定义了每次打开弹窗时的 “动态配置”,支持个性化调整,泛型T用于指定选中数据的类型,提升类型安全性:

interface SelectDialogOptions<T> {
  title?: string; // 覆盖默认标题
  multiple?: boolean; // 单选/多选切换
  queryParams?: Record<string, any>; // 初始化查询参数(如筛选“活跃用户”)
  componentProps?: Record<string, any>; // 覆盖默认组件属性
  onOk?: (data: T) => void; // 确定按钮回调(返回选中数据)
  onBeforeOk?: (data: T) => Promise<boolean>; // 确定前校验(如“最多选择10个用户”)
}
  • 泛型T:解决不同选择场景下数据类型不一致的问题(如用户类型User、角色类型Role);
  • onBeforeOk:支持异步校验(如调用接口检查选中数据合法性),返回Promise决定是否允许关闭弹窗。

2. 工厂函数整体架构

createSelectDialog是一个高阶函数,其核心逻辑分为两步:

  1. 接收CreateSelectDialogParams参数,封装弹窗的 “固定逻辑”(如组件渲染、基础样式);
  1. 返回一个新函数,该函数接收SelectDialogOptions参数,处理弹窗的 “动态配置”(如单选 / 多选、回调函数),并打开弹窗。

这种设计的优势在于:将 “固定共性” 与 “动态个性” 分离,一次创建可多次调用,且每次调用可灵活配置。

三、核心功能实现细节

接下来,我们深入函数内部,解析关键功能的实现逻辑,理解其如何解决数据选择弹窗的核心痛点。

1. 动态组件渲染与 Ref 引用

弹窗内容通过 Vue 的h函数(创建虚拟 DOM)动态渲染传入的component,并通过ref获取组件实例,实现方法调用:

content: () => h(params.component, {
  ref: (e: any) => (TableRef.value = e), // 绑定组件Ref
  multiple: options.multiple, // 传递单选/多选配置
  queryParams: options.queryParams, // 传递查询参数
  ...params.componentProps, // 合并默认组件属性
  ...options.componentProps // 合并动态组件属性(优先级更高)
})
  • 属性合并规则:options.componentProps > params.componentProps,支持动态覆盖默认属性;
  • Ref 引用核心作用:通过TableRef.value获取选择组件实例,调用其暴露的getSelectedData方法,这是 “获取选中数据” 的关键。

2. 选中数据校验与前置拦截

onBeforeOk是弹窗的 “核心校验逻辑”,负责确保选中数据合法,并支持自定义拦截,流程如下:

onBeforeOk: async () => {
  // 1. 检查组件是否暴露getSelectedData方法
  if (!TableRef.value?.getSelectedData) {
    Message.warning('组件必须暴露getSelectedData方法');
    return false;
  }
  // 2. 获取选中数据
  const data = TableRef.value?.getSelectedData?.() || [];
  // 3. 校验是否选择数据
  if (!data.length) {
    Message.warning(params.tip || '请选择数据');
    return false;
  }
  // 4. 自定义前置校验(如异步接口校验)
  if (options?.onBeforeOk) {
    return await options.onBeforeOk(data);
  }
  // 5. 触发确定回调,返回选中数据
  options.onOk?.(data);
  return true;
}
  • 强制接口约束:要求嵌入的选择组件必须暴露getSelectedData方法,否则弹窗无法正常工作,这是 “组件间通信” 的约定;
  • 异步校验支持:onBeforeOk返回Promise,支持调用接口进行校验(如 “检查选中用户是否已被占用”);
  • 友好提示:通过Message组件提供明确的错误提示,提升用户体验。

3. 弹窗样式自适应

为适配不同屏幕尺寸,函数对弹窗样式做了精细化控制:

width: 'calc(100% - 20px)', // 宽度自适应(左右各留10px边距)
modalStyle: { maxWidth: '1000px' }, // 最大宽度限制(避免大屏下过宽)
bodyStyle: { overflow: 'hidden', height: '500px', padding: 0 } // 固定高度+隐藏滚动
  • 自适应宽度:在小屏设备(如平板)上占满屏幕,大屏设备上限制最大宽度;
  • 固定 body 高度:避免选择组件(如长表格)导致弹窗过高,同时通过overflow: hidden配合组件内部滚动,保证弹窗整体美观。

四、使用示例与场景拓展

理解了函数设计后,我们通过实际示例,看如何在项目中应用该工厂函数。

1. 基础使用:创建用户选择弹窗

假设我们有一个UserSelectComponent(用户选择组件,已暴露getSelectedData方法),通过以下步骤创建用户选择弹窗:

// 1. 导入依赖与组件
import { createSelectDialog } from './createSelectDialog';
import UserSelectComponent from './UserSelectComponent.vue';
// 2. 创建用户选择弹窗函数(固定配置)
const selectUserDialog = createSelectDialog({
  title: '选择用户', // 默认标题
  component: UserSelectComponent, // 嵌入的用户选择组件
  tip: '请至少选择一个用户', // 未选择时的提示
  componentProps: { // 传递给用户组件的默认属性
    border: false,
    showSearch: true
  }
});
// 3. 在业务组件中调用(动态配置)
const handleSelectUser = () => {
  selectUserDialog({
    multiple: true, // 允许多选
    queryParams: { status: 'active' }, // 初始化查询“活跃用户”
    onBeforeOk: async (selectedUsers) => {
      // 自定义校验:最多选择5个用户
      if (selectedUsers.length > 5) {
        Message.warning('最多只能选择5个用户');
        return false;
      }
      // 异步校验:检查选中用户是否已关联角色
      const res = await checkUserRole(selectedUsers.map(u => u.id));
      return res.data.isValid;
    },
    onOk: (selectedUsers) => {
      // 确定后的逻辑:如渲染选中用户列表
      console.log('已选择用户:', selectedUsers);
      // 业务逻辑:更新页面状态、提交表单等
    }
  });
};

2. 场景拓展:支持不同类型的选择弹窗

除了用户选择,该工厂函数还可用于角色选择、部门选择等场景,只需替换component参数即可:

// 角色选择弹窗
const selectRoleDialog = createSelectDialog({
  title: '选择角色',
  component: RoleSelectComponent,
  tip: '请选择角色'
});
// 部门选择弹窗
const selectDeptDialog = createSelectDialog({
  title: '选择部门',
  component: DeptSelectComponent,
  tip: '请选择部门'
});

通过这种方式,我们无需重复开发弹窗逻辑,只需关注 “选择组件本身”,极大提升开发效率。

五、优势总结与优化方向

1. 核心优势

  • 高复用性:一次定义工厂函数,支持多类型选择弹窗(用户、角色、部门等);
  • 类型安全:通过 TypeScript 泛型与接口,明确参数类型与返回值,减少运行时错误;
  • 灵活配置:支持动态覆盖标题、单选 / 多选、查询参数等,适配不同业务场景;
  • 约定式通信:通过 “组件暴露getSelectedData方法” 的约定,简化组件间通信逻辑。

2. 优化方向

  • 支持自定义弹窗样式:当前弹窗宽度、高度为固定配置,可新增style参数,允许动态调整;
  • 添加加载状态:在onBeforeOk异步校验时,添加弹窗加载状态(如禁用确认按钮),避免重复点击;
  • 支持弹窗销毁回调:新增onClose参数,处理弹窗关闭后的逻辑(如清理组件缓存、重置状态);
  • 类型强化:将TableRef的类型从any改为泛型,明确组件实例的方法与属性,提升类型安全性。

六、总结

createSelectDialog工厂函数通过 “封装共性、暴露个性” 的设计思想,解决了 Vue 项目中数据选择弹窗的复用问题。其核心在于:

  1. 用 TypeScript 接口规范参数结构,保障代码健壮性;
  1. 用 Ref 引用实现组件间方法调用,简化通信逻辑;
  1. 用高阶函数分离固定配置与动态配置,提升复用效率。

在实际项目中,我们可以基于该函数的设计思路,进一步拓展弹窗的功能(如自定义按钮、支持分页),也可将其封装为 Vue 插件,在全局范围内复用。这种 “抽象共性、灵活扩展” 的组件设计思想,不仅适用于弹窗,也适用于表单、表格等其他高频组件,是提升前端开发效率的关键。

Vue3 中 watch 第三个参数怎么用?6 大配置属性 + 场景指南

在 Vue3 中,watch 的第三个参数是一个 配置选项对象(可选),用于精细控制监听行为。它支持多个属性,各自对应不同的功能,核心作用是调整监听的触发时机、深度监听、防抖节流等逻辑。以下是完整的配置属性及详细说明:

一、核心配置属性(常用)

1. immediate: boolean(默认 false)

  • 作用:控制监听是否在 初始渲染时立即执行一次回调函数(无需等待被监听值变化)。
  • 场景:需要页面加载时就根据初始值执行逻辑(例如:初始化时请求接口、设置默认状态)。
  • 示例
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// 初始渲染时立即执行一次,之后 count 变化时再执行
watch(count, (newVal) => {
  console.log('count 值:', newVal) // 初始输出 0,后续修改时输出新值
}, { immediate: true })
</script>

2. deep: boolean(默认 false)

  • 作用:控制是否 深度监听引用类型(对象 / 数组)的内部属性变化
  • 注意
    • Vue3 的 watch 对 响应式对象(如 reactive 创建的) 默认会进行 “深度监听”(无需手动设 deep: true),但仅监听 “被访问过的属性”(基于 Proxy 代理的惰性监听);
    • 非响应式对象(如 ref 包裹的普通对象)需要监听所有内部属性(包括未访问过的) 时,必须手动设置 deep: true。
  • 场景:监听对象的嵌套属性(如 user.info.age)、数组的元素变化。
  • 示例
<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: '张三', info: { age: 20 } })
// 监听 user 内部属性变化(需 deep: true)
watch(user, (newUser) => {
  console.log('age 变化:', newUser.info.age) // 修改 user.value.info.age 时触发
}, { deep: true })
// 触发监听:修改嵌套属性
setTimeout(() => {
  user.value.info.age = 21
}, 1000)
</script>

3. flush: 'pre' | 'post' | 'sync'(默认 'pre')

  • 作用:控制回调函数的 执行时机(即:回调在 Vue 组件更新周期的哪个阶段运行)。
  • 三个取值说明:
取值 执行时机 场景示例
'pre'(默认) 组件更新 之前 执行 需在 DOM 更新前修改数据(避免 DOM 闪烁)
'post' 组件更新 之后 执行(DOM 已更新) 需操作更新后的 DOM(如获取元素尺寸、滚动位置)
'sync' 被监听值变化时 同步立即执行 需实时响应变化(极少用,可能影响性能)
  • 示例(操作更新后的 DOM):
<template>
  <div ref="box" :style="{ width: `${count * 100}px` }"></div>
</template>
<script setup>
import { ref, watch } from 'vue'
const count = ref(1)
const box = ref(null)
// 回调在 DOM 更新后执行,可获取最新的元素宽度
watch(count, () => {
  console.log('box 宽度:', box.value.offsetWidth) // 正确输出更新后的宽度
}, { flush: 'post' })
// 触发监听:修改 count
setTimeout(() => {
  count.value = 2
}, 1000)
</script>

二、其他实用配置(Vue3.2+ 支持)

4. once: boolean(默认 false)

  • 作用:控制监听是否 只触发一次(触发后自动停止监听)。
  • 场景:只需响应第一次变化(如:首次加载后的一次性初始化、仅需执行一次的回调)。
  • 示例
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// 仅在 count 第一次变化时触发(之后再修改不触发)
watch(count, (newVal) => {
  console.log('count 首次变化:', newVal) // 仅输出 1(第一次修改时)
}, { once: true })
// 触发两次修改
setTimeout(() => { count.value = 1 }, 1000)
setTimeout(() => { count.value = 2 }, 2000) // 无输出
</script>

5. debounce: number(默认 undefined)

  • 作用:给监听添加 防抖(延迟 n 毫秒后执行回调,若期间值多次变化,仅执行最后一次)。
  • 单位:毫秒(ms)。
  • 场景:监听输入框输入(避免频繁触发接口请求)、频繁变化的数值(如滚动位置)。
  • 示例(输入框防抖):
<template>
  <input v-model="keyword" placeholder="搜索...">
</template>
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
// 输入停止 500ms 后才执行回调(避免输入时频繁触发)
watch(keyword, (newVal) => {
  console.log('搜索关键词:', newVal) // 输入停止 500ms 后输出
}, { debounce: 500 })
</script>

6. throttle: number(默认 undefined)

  • 作用:给监听添加 节流(每隔 n 毫秒最多执行一次回调,期间值多次变化仅执行一次)。
  • 单位:毫秒(ms)。
  • 场景:监听滚动事件、窗口大小变化等高频触发的场景。
  • 示例(滚动节流):
<script setup>
import { ref, watch } from 'vue'
const scrollTop = ref(0)
// 监听滚动位置,每隔 300ms 最多执行一次
window.addEventListener('scroll', () => {
  scrollTop.value = window.scrollY
})
watch(scrollTop, (newVal) => {
  console.log('滚动位置:', newVal) // 每 300ms 输出一次
}, { throttle: 300 })
</script>

三、配置属性总结表

属性名 类型 默认值 核心作用
immediate boolean false 初始渲染时是否立即执行回调
deep boolean false 是否深度监听引用类型内部属性变化
flush string 'pre' 回调执行时机(pre/post/sync)
once boolean false 是否只触发一次回调(触发后停止监听)
debounce number undefined 防抖延迟(ms),多次变化仅最后一次执行
throttle number undefined 节流间隔(ms),高频变化时限制执行频率

四、注意事项

  1. deep 的使用场景
    • 监听 reactive 对象时,默认会 “惰性深度监听”(仅监听被访问过的属性),若需监听所有属性(包括未访问的),仍需设置 deep: true;
    • 监听 ref 包裹的对象时,必须设置 deep: true 才能监听内部属性变化。
  1. debounce / throttle 的限制:仅 Vue3.2+ 版本支持,低版本需手动通过 lodash 等库实现。
  1. 性能优化:避免不必要的 deep: true(会增加监听开销),尽量精准监听具体属性(如 () => user.info.age)。

如何使用 Vuex 设计你的数据流

前端数据管理

解决这个问题的最常见的一种思路就是:专门定义一个全局变量,任何组件需要数据的时候都去这个全局变量中获取。一些通用的数据,比如用户登录信息,以及一个跨层级的组件通信都可以通过这个全局变量很好地实现。在下面的代码中我们使用 _store 这个全局变量存储数据。

// 比如挂一个全局的变量
window._store = {}

其他组件和这个量的交互

image.png

但这样就会产生一个问题,window._store 并不是响应式的,如果在 Vue 项目中直接使用,那么就无法自动更新页面。所以我们需要用 ref 和 reactive 去把数据包裹成响应式数据,并且提供统一的操作方法,这其实就是数据管理框架 Vuex 的雏形了。

Vuex是什么

Vuex 就相当于我们项目中的大管家,集中式存储管理应用的所有组件的状态。

// 安装
npm install vuex@next
// store/index.js

import { createStore } from 'vuex'
const store = createStore({
state () {
return {
count: 666
}
},
mutations: {
add (state) {
state.count++
    }
}
})

在项目入口文件 src/main.js 中,使用 app.use(store) 进行注册,这样 Vue 和 Vuex就连接上了。

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

const app = createApp(App)
app.use(store)
.use(router)
.mount('#app')

新增一个 components/Count.vue

<template>
<div @click="add">
{{count}}
</div>
</template>

<script setup>
import { computed } from 'vue'
import {useStore} from 'vuex'
let store = useStore()
let count = computed(()=>store.state.count)
function add(){
store.commit('add')
}
</script>

实现一个 简单的点击累加器

此处有点区别在于 count 不是使用 ref 直接定义,而是使用计算属性返回了 store.state.count,也就是刚才在 src/store/index.js 中定义的 count。add 函数是用来修改数据,这里我们不能直接去操作 store.state.count +=1,因为这个数据属于 Vuex 统一管理,所以我们要使用 store.commit(‘add’) 去触发 Vuex 中的 mutation 去修改数据。

数据什么时候用 Vuex 来管理 什么时候用 ref 这样区分

对于一个数据,如果只是组件内部使用就是用 ref 管理;如果我们需要跨组件,跨页面共享的时候,我们就需要把数据从 Vue 的组件内部抽离出来,放在 Vuex 中去管理。

比如项目中的登录用户名,页面的右上角需要显示,有些信息弹窗也需要显示。这样的数据就需要放在 Vuex 中统一管理,每当需要抽离这样的数据的时候,我们都需要思考这个数据的初始化和更新逻辑。

image.png

手写mini Vuex

// src/store/gvuex.js

import { inject, reactive } from 'vue'
const STORE_KEY = '__store__'
function useStore() {
    return inject(STORE_KEY)
}
function createStore(options) {
return new Store(options)
}
class Store {
constructor(options) {
this._state = reactive({
data: options.state()
})
this._mutations = options.mutations
}
}
export { createStore, useStore }

上面的代码还暴露了 createStore 去创建 Store 的实例,并且可以在任意组件的 setup 函数内,使用 useStore 去获取 store 的实例

class Store {
// main.js入口处app.use(store)的时候,会执行这个函数
install(app) {
app.provide(STORE_KEY, this)
}
}

Store 类内部变量 _state 存储响应式数据,读取 state 的时候直接获取响应式数据 _state.data,并且提供了 commit 函数去执行用户配置好的 mutations。

import { inject, reactive } from 'vue'
const STORE_KEY = '__store__'
function useStore() {
return inject(STORE_KEY)
}
function createStore(options) {
return new Store(options)
}
class Store {
constructor(options) {
this.$options = options
this._state = reactive({
data: options.state
})
this._mutations = options.mutations
}
get state() {
return this._state.data
}
commit = (type, payload) => {
const entry = this._mutations[type]
entry && entry(this.state, payload)
}
install(app) {
app.provide(STORE_KEY, this)
}
}
export { createStore, useStore }

使用 这个 gvuex

import {useStore} from '../store/gvuex'
let store =useStore()
let count = computed(()=>store.state.count)
function add(){
store.commit('add')
}

Vuex 使用

Vuex 就是一个公用版本的 ref,提供响应式数据给整个项目使用

使用 getters 类似于 Vue 中的 computed

import { createStore } from 'vuex'
const store = createStore({
state () {
return {
count: 666
}
},
getters:{
double(state){
return state.count*2
}
},
mutations: {
add (state) {
state.count++
}
}
})
export default store

组件中使用 时

let double = computed(()=>store.getters.double)

异步数据获取

在 Vuex 中,mutation 的设计就是用来实现同步地修改数据。如果数据是异步修改的,我们需要一个新的配置action。现在我们模拟一个异步的场景,就是点击按钮之后的 1 秒,再去做数据的修改。

import { createStore } from 'vuex'

const store = createStore({
state () {
return {
count: 666
}
},
// ...
actions:{
asyncAdd({commit}){
setTimeout(()=>{
commit('add')
},1000)
}
}
})

export default store

action 并不是直接修改数据,而是通过 mutations 去修改,actions 的调用方式是使用 store.dispatch

function asyncAdd(){
store.dispatch('asyncAdd')
}

Vuex 在整体上的逻辑如下图所示,从宏观来说,Vue 的组件负责渲染页面,组件中用到跨页面的数据,就是用 state 来存储,但是 Vue 不能直接修改 state,而是要通过actions/mutations 去做数据的修改。

image.png

image.png

在决定一个数据是否用Vuex 来管理的时候,核心就是要思考清楚,这个数据是否有共享给其他页面或者是其他组件的需要。如果需要,就放置在 Vuex 中管理;如果不需要,就应该放在组件内部使用ref 或者 reactive 去管理。

Pinia

Vuex 由于在 API 的设计上,对 TypeScript 的类型推导的支持比较复杂,用起来很是痛苦。Pinia 的 API 的设计非常接近 Vuex5 的提案,首先,Pinia 不需要Vuex 自定义复杂的类型去支持 TypeScript,天生对类型推断就非常友好,并且对 VueDevtool 的支持也非常好

Vue 团队成员又搞了个 "新玩具"!

Vue 团队成员真的是太卷了,又搞了个新玩具:markdown-exit——markdown-itTypeScript「重构版」来了!

如果你用过 VitePressSlidev、或者任何 Vue 系文档站,你一定对 markdown-it 不陌生:它就是把 Markdown 变成 HTML 的那台“发动机”。

问题是,这台发动机是 2014 年的设计,纯 JavaScript 写成,类型靠社区维护,异步插件更是想都别想。

于是,Vue 团队的核心成员 Anthony Fu 在推特上“许愿”:

“我找这东西太久了!缺少异步插件限制了我们太多玩法,真希望 VitePressSlidev 能用上。”

不到两周,SerKo(@serkodev)直接甩出一个新仓库——markdown-exit

它到底干了啥?

把 Markdown 转成 HTML,但换了颗**“TypeScript 心脏”**。

能力 markdown-it markdown-exit
输出 HTML ✅(100% 兼容)
同步插件
异步插件 ✅(官方支持)
类型定义 社区 @types 原生自带
现代构建 Vite + Vitest + pnpm monorepo
代码高亮 靠插件 异步高亮一句话搞定

对 Vue 生态意味着什么?

  • VitePress:以后可以在编译阶段做“异步代码拉取”、“远程片段嵌入”而不用写脏脚本。
  • Slidev:幻灯片里直接引用 GitHub 文件、CodePen、CodeSandbox,实时渲染。
  • 普通 Vue 项目:服务端渲染(SSR)或边缘函数(Vite SSR / Nuxt)里,再也不用 require('@types/markdown-it') 一把梭。

Anthony Fu 在转发里写:

“等不及想在 VitePress、Slidev 上落地了!”

两分钟上手

# 最新特性版(公测中)
npm i markdown-exit

# 求稳用兼容版
npm i markdown-exit@legacy
// 命名导入,Tree-shaking 友好
import { createMarkdownExit } from 'markdown-exit'

const md = createMarkdownExit()
console.log(md.render('# Hello markdown-exit'))

异步高亮示例(以前做不到):

md.use(asyncPlugin, async (code, lang) => {
  const html = await fetchHighlighter(lang) // 任意异步操作
  return html
})

迁移零成本

旧项目里全局替换即可:

- import MarkdownIt from 'markdown-it'
+ import MarkdownExit from 'markdown-exit'

所有插件配置渲染结果一行不改,直接运行。

时间线 & 路线图

  • 现在v1.0.0-beta 公测,功能已齐,API 锁定中。
  • 未来 1 ~ 2 个月:发布正式版 v1.0.0;VitePressSlidev 官方模板同步切换。
  • 长期:探索 AST 级别缓存、WebAssembly 加速、流式渲染等高级玩法。

结语

vue/corevite,再到现在的 markdown-exit,Vue 团队及其周边伙伴似乎一直在做同一件事:
把“底层基础设施”重新洗一遍牌,让上层开发者爽到飞起。

所以,下次你要在 Vue 项目里“整点 Markdown”时,不妨试试这颗新心脏——
npm i markdown-exit,然后跟过去的类型报错、异步限制说拜拜吧!

  • markdown-exit Github 地址:https://github.com/serkodev/markdown-exit

「周更第10期」实用JS库推荐:VueUse

引言

在Vue 3的生态系统中,组合式API(Composition API)为我们带来了更灵活的代码组织方式。而VueUse作为Vue生态中最受欢迎的工具库之一,为开发者提供了200多个实用的组合式函数,极大地简化了日常开发工作。本期我们将深入探索VueUse这个强大的工具库。

库介绍

基本信息

  • 库名称:VueUse
  • GitHub Stars:19.5k+ ⭐
  • 维护状态:活跃维护,定期更新
  • 兼容性:Vue 2.7+ / Vue 3.0+
  • 包大小:Tree-shakable,按需引入
  • 依赖关系:基于Vue响应式系统

核心特点

VueUse是一个基于Vue组合式API的实用工具库,它将常见的开发需求封装成可复用的组合式函数。主要特点包括:

  1. 丰富的功能集合:提供200+个实用函数,涵盖状态管理、浏览器API、动画、网络请求等各个方面
  2. 类型安全:完整的TypeScript支持,提供优秀的开发体验
  3. Tree-shakable:支持按需引入,不会增加不必要的包体积
  4. SSR友好:完美支持服务端渲染
  5. 灵活可配置:大部分函数都提供丰富的配置选项
  6. 响应式设计:充分利用Vue的响应式系统

安装使用

安装命令

# 使用 pnpm(推荐)
pnpm add @vueuse/core

# 安装额外的集成包
pnpm add @vueuse/components @vueuse/integrations

基础使用示例

<template>
  <div>
    <p>鼠标位置: {{ x }}, {{ y }}</p>
    <p>窗口大小: {{ width }} x {{ height }}</p>
    <button @click="toggle">
      {{ isOnline ? '在线' : '离线' }}
    </button>
  </div>
</template>

<script setup>
import { useMouse, useWindowSize, useOnline } from '@vueuse/core'

// 获取鼠标位置
const { x, y } = useMouse()

// 获取窗口大小
const { width, height } = useWindowSize()

// 检测网络状态
const { isOnline, toggle } = useOnline()
</script>

VueUse API分类详解

1. 状态管理类

useLocalStorage / useSessionStorage

// 响应式的本地存储
const name = useLocalStorage('user-name', '默认值')
const settings = useSessionStorage('app-settings', { theme: 'light' })

useToggle

// 布尔值切换
const [isVisible, toggle] = useToggle()
const [status, toggleStatus] = useToggle('active', 'inactive')

useCounter

// 计数器管理
const { count, inc, dec, set, reset } = useCounter(0, { min: 0, max: 100 })

2. 浏览器API类

useMouse / useMousePressed

// 鼠标位置和状态
const { x, y } = useMouse()
const { pressed } = useMousePressed()

useKeyboard / useKeyModifier

// 键盘事件
const { ctrl, shift, alt, meta } = useKeyModifier()
const keys = useKeyboard()

useClipboard

// 剪贴板操作
const { text, copy, copied, isSupported } = useClipboard()

useFullscreen

// 全屏控制
const { isFullscreen, enter, exit, toggle } = useFullscreen()

3. 传感器类

useDeviceOrientation

// 设备方向
const { alpha, beta, gamma, absolute } = useDeviceOrientation()

useGeolocation

// 地理位置
const { coords, locatedAt, error } = useGeolocation()

useBattery

// 电池状态
const { charging, level, dischargingTime } = useBattery()

4. 网络类

useFetch

// HTTP请求
const { data, error, isFetching } = useFetch('/api/users')

useWebSocket

// WebSocket连接
const { status, data, send, open, close } = useWebSocket('ws://localhost:8080')

5. 动画类

useTransition

// 数值过渡动画
const source = ref(0)
const output = useTransition(source, {
  duration: 1000,
  transition: TransitionPresets.easeInOutCubic
})

useInterval / useTimeout

// 定时器管理
const { pause, resume, isActive } = useInterval(() => {
  console.log('每秒执行')
}, 1000)

6. 元素操作类

useElementSize

// 元素尺寸监听
const el = ref()
const { width, height } = useElementSize(el)

useIntersectionObserver

// 元素可见性检测
const target = ref()
const { isIntersecting } = useIntersectionObserver(target)

useResizeObserver

// 元素大小变化监听
const el = ref()
useResizeObserver(el, (entries) => {
  console.log('元素大小改变')
})

7. 实用工具类

useDebounce / useThrottle

// 防抖和节流
const input = ref('')
const debouncedValue = useDebounce(input, 500)
const throttledValue = useThrottle(input, 1000)

useAsyncState

// 异步状态管理
const { state, isReady, isLoading, error, execute } = useAsyncState(
  () => fetchUserData(),
  null
)

useVModel

// 双向绑定辅助
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const data = useVModel(props, 'modelValue', emit)

实际应用场景

1. 响应式布局系统

<template>
  <div class="responsive-layout">
    <aside v-if="!isMobile" class="sidebar">
      侧边栏内容
    </aside>
    <main :class="{ 'full-width': isMobile }">
      <header>
        <button @click="toggleTheme">
          {{ isDark ? '🌙' : '☀️' }}
        </button>
      </header>
      <div class="content">
        主要内容区域
      </div>
    </main>
  </div>
</template>

<script setup>
import { useBreakpoints, useDark, useToggle } from '@vueuse/core'

// 响应式断点
const breakpoints = useBreakpoints({
  mobile: 768,
  tablet: 1024,
  desktop: 1280
})

const isMobile = breakpoints.smaller('tablet')

// 暗黑模式
const isDark = useDark()
const toggleTheme = useToggle(isDark)
</script>

2. 实时数据监控面板

<template>
  <div class="dashboard">
    <div class="status-bar">
      <span :class="{ online: isOnline, offline: !isOnline }">
        {{ isOnline ? '在线' : '离线' }}
      </span>
      <span>电池: {{ batteryLevel }}%</span>
      <span>{{ formattedTime }}</span>
    </div>
    
    <div class="charts">
      <canvas ref="chartCanvas"></canvas>
    </div>
    
    <div class="controls">
      <button @click="startMonitoring" :disabled="isMonitoring">
        开始监控
      </button>
      <button @click="stopMonitoring" :disabled="!isMonitoring">
        停止监控
      </button>
    </div>
  </div>
</template>

<script setup>
import { 
  useOnline, 
  useBattery, 
  useNow, 
  useInterval,
  useElementSize 
} from '@vueuse/core'

// 网络状态
const isOnline = useOnline()

// 电池状态
const { level: batteryLevel } = useBattery()

// 实时时间
const now = useNow()
const formattedTime = computed(() => 
  now.value.toLocaleTimeString()
)

// 图表容器
const chartCanvas = ref()
const { width, height } = useElementSize(chartCanvas)

// 监控控制
const isMonitoring = ref(false)
const { pause, resume } = useInterval(() => {
  // 更新图表数据
  updateChartData()
}, 1000, { immediate: false })

const startMonitoring = () => {
  isMonitoring.value = true
  resume()
}

const stopMonitoring = () => {
  isMonitoring.value = false
  pause()
}
</script>

3. 高级表单处理

<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <input 
        v-model="form.email" 
        type="email" 
        placeholder="邮箱"
        :class="{ error: emailError }"
      >
      <span v-if="emailError" class="error-text">
        {{ emailError }}
      </span>
    </div>
    
    <div class="form-group">
      <input 
        v-model="form.password" 
        type="password" 
        placeholder="密码"
      >
    </div>
    
    <button 
      type="submit" 
      :disabled="!isFormValid || isSubmitting"
    >
      {{ isSubmitting ? '提交中...' : '登录' }}
    </button>
    
    <div v-if="submitError" class="error">
      {{ submitError }}
    </div>
  </form>
</template>

<script setup>
import { 
  useVModel, 
  useAsyncValidator, 
  useAsyncState,
  useDebounce 
} from '@vueuse/core'

// 表单数据
const form = reactive({
  email: '',
  password: ''
})

// 邮箱验证(防抖)
const debouncedEmail = useDebounce(() => form.email, 500)
const { pass: isEmailValid, errorMessage: emailError } = useAsyncValidator(
  debouncedEmail,
  async (value) => {
    if (!value) return '邮箱不能为空'
    if (!/\S+@\S+\.\S+/.test(value)) return '邮箱格式不正确'
    
    // 异步验证邮箱是否已注册
    const exists = await checkEmailExists(value)
    if (!exists) return '邮箱未注册'
    
    return true
  }
)

// 表单验证
const isFormValid = computed(() => 
  isEmailValid.value && form.password.length >= 6
)

// 提交处理
const { 
  state: submitResult, 
  isLoading: isSubmitting, 
  error: submitError, 
  execute: submitForm 
} = useAsyncState(
  () => loginUser(form),
  null,
  { immediate: false }
)

const handleSubmit = () => {
  if (isFormValid.value) {
    submitForm()
  }
}
</script>

优缺点分析

优势

  1. 开发效率高:提供大量开箱即用的功能,减少重复代码编写
  2. 类型安全:完整的TypeScript支持,减少运行时错误
  3. 性能优秀:基于Vue响应式系统,性能表现良好
  4. 文档完善:官方文档详细,示例丰富
  5. 社区活跃:持续更新,bug修复及时
  6. 灵活性强:大部分函数都支持自定义配置

局限性

  1. 学习成本:函数众多,需要时间熟悉各个API
  2. 包体积:虽然支持tree-shaking,但完整引入会增加包体积
  3. Vue绑定:仅适用于Vue项目,不能在其他框架中使用
  4. 版本依赖:需要Vue 2.7+或Vue 3.0+

最佳实践建议

1. 按需引入

// 推荐:按需引入
import { useMouse, useLocalStorage } from '@vueuse/core'

// 避免:全量引入
import * as VueUse from '@vueuse/core'

2. 合理使用响应式

// 合理利用响应式特性
const { x, y } = useMouse()
const position = computed(() => `${x.value}, ${y.value}`)

// 避免不必要的响应式转换
const staticConfig = { timeout: 5000 } // 静态配置无需响应式

3. 错误处理

const { data, error, isFinished } = useFetch('/api/data')

watchEffect(() => {
  if (error.value) {
    console.error('请求失败:', error.value)
    // 处理错误逻辑
  }
})

总结

VueUse是Vue生态系统中不可或缺的工具库,它通过提供丰富的组合式函数,极大地提升了Vue开发的效率和体验。无论是处理浏览器API、状态管理,还是实现复杂的交互效果,VueUse都能提供优雅的解决方案。

推荐使用场景:

  • Vue 3项目的快速开发
  • 需要大量浏览器API交互的应用
  • 追求代码复用性和可维护性的项目
  • 需要响应式状态管理的复杂应用

VueUse不仅是一个工具库,更是学习Vue组合式API最佳实践的优秀范例。通过研究其源码和使用方式,能够帮助开发者更好地理解和运用Vue 3的核心特性。

相关链接

多窗口数据实时同步常规方案举例

要实现多窗口(或多标签页)数据实时同步(无需刷新),核心是利用 浏览器跨窗口通信能力 结合 状态管理,让一个窗口的数据变化实时通知到其他窗口。以下是具体实现方案,按「通用性 + 复杂度」排序: 一、核

Vue3 + Pinia 移动端Web应用:页面缓存策略解决方案💡

写在开头

Hello,各位UU们好呀!😋

嘿嘿,此时此刻,有点开心。

01FD6B7F.gif

因为,小编下周休了两天年假,连着四天小长假,准备回老家吃大席,又有老朋友要结婚了。😂

然后呢,最近小编在开发移动端 Web 应用时,遇到了一个问题:页面缓存!😅 (这往深了挖掘,绝对是一个让人头疼的问题)

经过一番折腾和还有AI的加持,小编也顺利解决了,今天就来和大家分享这个基于导航栈的智能缓存策略,请诸君按需食用哈。

🎯 第1步:缓存策略的目的与意义

在移动端Web应用中,咱们经常遇到这样的用户场景:

  • 📱 商品列表 → 商品详情 → 返回列表:用户希望回到之前浏览的位置。
  • 🔄 菜单 → 商品列表:用户希望看到最新的商品信息。
  • 💾 表单填写过程:用户在多个步骤(子页面)间切换时保持已填写的数据。

传统方案的痛点:

// 传统keep-alive方案的问题
<keep-alive :include="['ProductList', 'UserProfile']">
  <router-view />
</keep-alive>
  • 问题1:无法区分进入方式(从菜单进入 vs 从其他页面返回)。
  • 问题2:缓存策略过于粗暴,要么全缓存要么不缓存。
  • 问题3:无法根据业务逻辑动态控制缓存行为。

🌟 第2步:实现原理与核心设计思想

核心设计理念

咱们的方案基于一个简单的思想:模拟整个应用的"导航栈"行为。💯

用户操作流程:菜单页 → 页面A → 页面B → 返回A → 返回菜单页 → 重新进入A
期望行为:  清空 → 新建A → 缓存A → 恢复A → 清空 → 新建A

菜单页概念说明

在开始介绍下面内容之前,咱们先明确一个重要概念:菜单页

什么是菜单页?

答:它类似于App的首页或主导航页面。

为什么需要菜单页?

答:定期清理缓存,避免内存泄漏,确保用户每次从主入口开始都是干净的状态。

原理流程图

一图胜千言,瞧瞧:

原理流程图.png

关键技术点

实时导航栈管理

// 检查是否为返回到已存在的路由
const isBackToExistingRoute = (targetFullPath) => {
  return state.navigationStack.some(route => route.fullPath === targetFullPath);
};

// 通过检查导航栈判断是前进还是返回
const isBackNavigation = isBackToExistingRoute(to.fullPath);

if (isBackNavigation) {
  // 返回操作:移除目标路由之后的所有路由
  backToRoute(to.fullPath);
} else {
  // 前进操作:添加新路由到栈中,缓存离开的页面
  pushRoute(to.fullPath, to.name);
  if (from.meta?.keepAlive && from.name) {
    state.cachedComponents.add(from.name);
  }
}

缓存时机

  • 前进导航:缓存离开的页面(如果配置了keepAlive)
  • 返回导航:清理目标页面之后的所有缓存
  • 菜单重置:清空所有缓存,确保新鲜状态

💻 第3步:核心代码实现

在实际项目中,小编是使用 Pinia 来完成这块缓存功能的开发,具体如下:

import { reactive, toRefs, computed } from "vue";
import { defineStore } from "pinia";

export const useRouteCacheStore = defineStore("routeCache", () => {
  const state = reactive({
    navigationStack: [],           // 导航历史栈 [{fullPath, name}]
    cachedComponents: new Set(),   // 缓存的组件名称列表
    menuPath: '/menu'             // 菜单页面路径
  });

  /**
   * 计算属性:缓存组件列表
   * @description 将 Set 类型的缓存组件转换为数组格式
   * @returns {string[]} 缓存的组件名称数组
   */
  const cacheIncludeList = computed(() => {
    return Array.from(state.cachedComponents);
  });

  /**
   * 检查是否为返回到已存在的路由
   * @description 通过检查导航栈中是否存在目标路径来判断是否为返回操作
   * @param {string} targetFullPath - 目标路由的完整路径
   * @returns {boolean} 如果路由已存在于导航栈中返回 true,否则返回 false
   */
  const isBackToExistingRoute = (targetFullPath) => {
    return state.navigationStack.some(route => route.fullPath === targetFullPath);
  };

  /**
   * 添加路由到栈中
   * @description 如果路由不存在于导航栈中,则将其添加到栈顶
   * @param {string} fullPath - 路由的完整路径
   * @param {string} name - 路由的名称
   * @returns {void}
   */
  const pushRoute = (fullPath, name) => {
    if (!isBackToExistingRoute(fullPath)) {
      state.navigationStack.push({ fullPath, name });
    }
  };

  /**
   * 返回到指定路由,移除后续路由
   * @description 找到目标路由在栈中的位置,移除其后的所有路由及其缓存,然后截断导航栈
   * @param {string} targetFullPath - 目标路由的完整路径
   * @returns {void}
   */
  const backToRoute = (targetFullPath) => {
    const targetIndex = state.navigationStack.findIndex(route => route.fullPath === targetFullPath);
    if (targetIndex !== -1) {
      // 移除目标路由之后的所有路由的缓存
      const removedRoutes = state.navigationStack.slice(targetIndex + 1);
      removedRoutes.forEach(route => {
        if (route.name) {
          state.cachedComponents.delete(route.name);
        }
      });
      // 截断导航栈
      state.navigationStack = state.navigationStack.slice(0, targetIndex + 1);
    }
  };

  /**
   * 处理路由导航
   * @description 根据导航类型(前进/返回)处理路由栈和组件缓存,进入菜单页时清空所有缓存
   * @param {Object} to - 目标路由对象
   * @param {string} to.fullPath - 目标路由的完整路径
   * @param {string} to.name - 目标路由的名称
   * @param {Object} to.meta - 目标路由的元信息
   * @param {Object} from - 来源路由对象
   * @param {string} from.fullPath - 来源路由的完整路径
   * @param {string} from.name - 来源路由的名称
   * @param {Object} from.meta - 来源路由的元信息
   * @param {boolean} from.meta.keepAlive - 是否需要缓存该组件
   * @returns {void}
   */
  const handleNavigation = (to, from) => {
    const isBackNavigation = isBackToExistingRoute(to.fullPath);
    
    if (isBackNavigation) {
      // 返回操作:移除目标路由之后的所有路由
      backToRoute(to.fullPath);
    } else {
      // 前进操作:添加新路由到栈中
      pushRoute(to.fullPath, to.name);
      if (from.meta?.keepAlive && from.name) {
        state.cachedComponents.add(from.name);
      }
    }

    // 进入菜单页时,清空所有缓存
    if (to.fullPath === state.menuPath) {
      state.cachedComponents.clear();
    }
  };

  return {
    ...toRefs(state),
    cacheIncludeList,
    handleNavigation,
  };
});

没多少代码哈,小编也都贴心写明了详细注释,应该都能读懂哈。😋

然后,就是在路由守卫这里统一拦截所有的路由进行处理:

import router from "@/router";
import { useRouteCacheStore } from '@/stores/modules/routeCache'

router.beforeEach((to, from, next) => {
  const routeCacheStore = useRouteCacheStore()
  
  // 处理路由缓存逻辑
  routeCacheStore.handleNavigation(to, from)
  
  next()
})

最后,在组件视图这块应用动态缓存数据:

<!-- src/layouts/BasicLayout.vue -->
<template>
  <div class="basic-layout">
    <keep-alive :include="cacheIncludeList">
      <router-view />
    </keep-alive>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useRouteCacheStore } from '@/stores/modules/routeCache'

const routeCacheStore = useRouteCacheStore()

/** @description 获取需要缓存的组件列表 */
const cacheIncludeList = computed(() => routeCacheStore.cacheIncludeList)
</script>

在路由配置中,记得标明哪些路由是需要缓存的即可,示例:

// src/router/index.js
const routes = [
  {
    path: '/menu',
    name: 'Menu',
    component: () => import('@/views/menu/index.vue')
  },
  {
    path: '/product-list',
    name: 'ProductList',
    component: () => import('@/views/product/list.vue'),
    meta: {
      keepAlive: true // 标记需要缓存
    }
  },
  {
    path: '/product-detail/:id',
    name: 'ProductDetail',
    component: () => import('@/views/product/detail.vue')
    // 详情页不需要缓存
  }
]

🔍 第4步:技术细节解析

fullPath的重要性

在移动端应用中,同一个页面可能因为不同的查询参数而展示不同的内容:

// 场景1:不同分类的商品列表
/product-list?category=phone&sort=price
/product-list?category=laptop&sort=sales

// 场景2:不同页码的列表
/product-list?page=1
/product-list?page=3

使用fullPath可以正确区分这些页面,避免缓存混乱。

实时栈管理的优势

传统方案:栈不实时更新,容易出现判断错误。如:

菜单 → A → 菜单 → A (第二次进入A时,栈中还有A,误判为返回操作)

咱们的方案:实时移除离开的页面。如:

菜单 → A(栈:[A]) → 菜单(栈:[],A被移除) → A(栈:[A],正确判断为新进入)

🎉 总结

这套基于导航栈的智能缓存策略完美解决了移动端页面缓存难题!核心优势包括精准控制缓存时机、保持页面状态同时确保数据新鲜度,应该比较适合那些移动端Web应用。





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

vue和react实例简单对比

多年前在网上看到一场讨论, # 代码约架?Vue.js和Binding.scala两大框架作者的PK 如果确实有优秀工具能简化代码量,那自然是好事, 借助AI, 对vue和react进行一个简单比较.

2025年,我为什么建议你先学React再学Vue?

你是不是刚准备入门前端开发,面对React和Vue两个热门框架却不知道如何选择?

看着招聘网站上React和Vue的职位要求,担心选错方向影响未来发展?

别担心,这篇文章就是为你准备的。我会用最直白的语言,带你快速体验两大框架的魅力,并告诉你为什么在2025年的今天,我强烈建议从React开始学起。

读完本文,你将获得两大框架的完整入门指南,还有可以直接复用的代码示例,帮你节省大量摸索时间。

先来看看React:简洁就是美

React的核心思想非常直接——用JavaScript构建用户界面。它不会强迫你学习太多新概念,而是充分利用你已经掌握的JavaScript知识。

让我们看一个最简单的计数器组件:

// 引入React和useState钩子
import React, { useState } from 'react';

// 定义计数器组件
function Counter() {
  // useState是React的核心特性,用于管理组件状态
  // count是当前状态值,setCount是更新状态的函数
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>你点击了 {count} 次</p>
      {/* 点击按钮时调用setCount更新状态 */}
      <button onClick={() => setCount(count + 1)}>
        点我加一
      </button>
    </div>
  );
}

export default Counter;

这段代码展示了React的几个关键特点:组件化、状态管理、声明式编程。你发现了吗?几乎就是纯JavaScript,加上一点类似HTML的JSX语法。

React的学习曲线相对平缓,因为你主要是在写JavaScript。这也是为什么很多公司在新项目中仍然首选React——它更接近编程的本质。

再看看Vue:贴心但需要适应

Vue的设计哲学完全不同,它提供了一套更完整的解决方案,包括模板语法、响应式系统等。

同样的计数器,用Vue 3的Composition API实现:

<template>
  <div>
    <p>你点击了 {{ count }} 次</p>
    <!-- 模板语法更接近原生HTML -->
    <button @click="increment">
      点我加一
    </button>
  </div>
</template>

<script setup>
// 引入ref函数
import { ref } from 'vue'

// 定义响应式数据
const count = ref(0)

// 定义方法
const increment = () => {
  count.value++
}
</script>

Vue的模板语法对初学者很友好,特别是如果你有HTML基础。但注意看,这里出现了新的概念:ref、.value、@click指令等。Vue创造了自己的一套规则,你需要先理解这些概念才能上手。

为什么我推荐先学React?

在2025年的今天,前端技术生态已经相当成熟。基于我的观察和实际项目经验,有三个理由支持先学React:

就业机会更多:打开任何招聘平台,React的职位数量通常是Vue的1.5-2倍。大型科技公司更倾向于使用React,这意味着更好的职业发展空间。

技术迁移成本低:学完React后,你会发现很多概念在其他框架中也通用。状态管理、组件化思想、虚拟DOM等知识都是可以迁移的。反过来,从Vue转到React会困难一些。

更接近现代JavaScript:React鼓励你使用最新的JavaScript特性,而不是框架特定的语法。这对你的长远发展更有帮助,毕竟框架会过时,但JavaScript不会。

真实项目中的代码对比

让我们看一个更实际的例子:用户列表组件。

React版本:

import React, { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  // useEffect处理副作用
  useEffect(() => {
    fetchUsers();
  }, []);

  const fetchUsers = async () => {
    try {
      const response = await fetch('/api/users');
      const data = await response.json();
      setUsers(data);
    } catch (error) {
      console.error('获取用户失败:', error);
    } finally {
      setLoading(false);
    }
  };

  if (loading) return <div>加载中...</div>;

  return (
    <div>
      <h2>用户列表</h2>
      {users.map(user => (
        <div key={user.id}>
          <span>{user.name}</span>
          <span>{user.email}</span>
        </div>
      ))}
    </div>
  );
}

Vue版本:

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <h2>用户列表</h2>
      <div v-for="user in users" :key="user.id">
        <span>{{ user.name }}</span>
        <span>{{ user.email }}</span>
      </div>
    </div>
  </div>
</template>

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

const users = ref([])
const loading = ref(true)

const fetchUsers = async () => {
  try {
    const response = await fetch('/api/users')
    const data = await response.json()
    users.value = data
  } catch (error) {
    console.error('获取用户失败:', error)
  } finally {
    loading.value = false
  }
}

// onMounted是生命周期钩子
onMounted(() => {
  fetchUsers()
})
</script>

注意到区别了吗?React更倾向于用JavaScript解决问题,而Vue提供了更多专用语法。从长远看,深入理解JavaScript比掌握框架语法更有价值。

学习路径建议

如果你决定接受我的建议从React开始,这是最有效的学习路径:

第一周:掌握React基础概念。JSX语法、组件定义、props传递、useState状态管理。不要急着学太多,把基础打牢固。

第二周:深入学习Hooks。useEffect、useContext、useReducer,理解React的数据流和生命周期。

第三周:构建完整项目。找一个实际需求,比如个人博客或者待办事项应用,把学到的知识用起来。

第四周:学习状态管理。了解Redux Toolkit或者Zustand,理解在复杂应用中如何管理状态。

完成这个月的学习后,你再回头看Vue,会发现很多概念都是相通的,学习成本大大降低。

但Vue就一无是处吗?

绝对不是。Vue在某些场景下表现非常出色:

如果你要快速开发中小型项目,Vue的完整生态和约定式配置能显著提升开发效率。

如果你的团队中新手开发者较多,Vue的模板语法和学习曲线确实更容易上手。

在2025年,Vue 3的Composition API让代码组织更加灵活,性能也相当优秀。它仍然是一个很棒的选择,只是从学习路径和职业发展的角度,我更推荐先掌握React。

实际开发中的小技巧

无论你选择哪个框架,这些技巧都能帮你少走弯路:

代码组织:保持组件小而专一。如果一个组件超过100行,考虑拆分。

状态管理:不要过度设计。先从useState开始,真正需要时再引入状态管理库。

性能优化:使用React.memo或Vue的computed属性避免不必要的重新渲染,但不要过早优化。

错误处理:一定要有错误边界,给用户友好的错误提示而不是白屏。

下一步该怎么做?

现在你应该对两大框架有了基本认识。我的建议是:

今天就创建一个React项目,把文章中的计数器例子跑起来。不要只看不练,亲手写代码的感觉完全不同。

遇到问题时,记住这是学习过程的正常部分。React和Vue都有优秀的官方文档和活跃的社区,你遇到的问题很可能已经有人解决过了。

学习框架只是开始,更重要的是理解背后的编程思想和设计模式。这些知识会让你在任何技术变革中都能快速适应。

技术会更新,生态会变化,但解决问题的能力才是你真正的核心竞争力。

❌