普通视图
用 Three.js 打造炫酷波浪粒子背景动画:从原理到实现
纯前端提取图片颜色插件Color-Thief教学+实战完整指南
基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交
基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交
![]()
上一篇文章我们实现了六边形蜂窝的区域客流,这一篇我们的目标是实现 **实时公交**。实现实时公交的方式我们需要得数据:实时更新位置的公交车、
当前公交车排班路线,
还有就是线路所经过的站台
一般公交车更新实时位置都是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,
},
...
]
}
]
车辆模型加载
![]()
开始,我们用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点 为站点的朝向向量,这样就能让站台正确的朝向马路了。示例图如下
![]()
清楚怎么设置之后,首先我们处理好我们的站点和线路的经纬度格式:
// 创建站点标记
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调整前后对比效果图如下:
![]()
沿路行驶与转向动画
因为原数据的行驶速度过慢,我们采用固定速度,让车辆匀速的方式沿线前进,同时在转弯处做平滑的朝向过渡,避免瞬间旋转。
核心变量与参数:
// 动画参数
const speedKmh = 100; // 行驶速度
const speedMs = speedKmh * 1000 / 3600; // m/s
const totalTimeSeconds = totalDistance / speedMs; // 总行驶时间
// 角度过渡
let currentRotationY = 0; // 当前朝向(弧度)
const rotationSpeed = 2; // 最大旋转速度(弧度/秒)
动画主循环负责两件事:位置插值与朝向插值。
- 位置插值(沿线段线性插值)
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);
- 朝向角插值(平滑转向,避免突变)
算法:用当前线段向量 end - start 求出航向角 targetAngle = atan2(dy, dx);再用“夹角归一到 ([-π, π])”与“最大角速度”把 currentRotationY 朝 targetAngle 推进。这样在急转弯处也会过渡自然,当然你也可以直接用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;
}
效果示例:
![]()
相机跟随与视角策略
我们需要提供“自由”和“跟随”两种视角模式的切换,在跟随时的时候我们只跟随距离,就像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即可;
停之停之!文章写到这里我我发现百度地图的开发者更新了新功能,他们支持了自定义镜头动画,赶紧换上
![]()
现在只需要传入model即可,不需要再每一帧手动更新相机位置,相比于之前的拖动丝滑的很多,lock设置为true即可固定视角
const tracker = engine.add(new mapvthree.ObjectTracker())
tracker.track(busModel, {
range: 300,
pitch: 80,
heading: 10,
lock: false, //不锁定视角
})
![]()
高级参数可视化
以上实现就已经完成实时公交的基本形式的可视化了,但是我们要做就要做的更详细一点,加入类似于电子公交屏的功能:距离下一站距离、当前到达、下一站和运行状态。
-
运行状态:公交车运行状态机,包含四个状态
-
driving:正常行驶 -
approaching:减速进站(距离站点 < 200米) -
stopped:站台停靠(距离站点 < 30米,停留3秒) -
departing:启动离站
-
- 距离计算:使用 Turf.js 计算车辆当前位置到下一站的直线距离(可以用线段截取计算的方式实际剩余距离)
- 进度显示:基于距离计算的到站进度
-
站点状态管理:
-
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' });
}
};
效果图:
![]()
其实上面的数据在实际业务中是后端不会再前端去计算,这里也只是阐述一下业务逻辑,实现一下效果,还有就是实际业务是要接入实时位置更新的,我们需要实时去更新公交车的位置,简单的阐述一下业务,一般的做法是每辆车需要维护一个信号队列,然后逐个去执行队列,这样车辆的延迟是第一个和第二个信号之间的时间差,画了一个逻辑图:
![]()
而且实际中实时数据是会抖动的,出现长时间没信号、信号批量涌入、gps信号乱跳这些都会出现,若接入真实 GPS,可对点做卡尔曼等滤波处理,减少抖动,让公交车的行进看起来更自然更流畅一些。
好了,以上就是线路客流、区域客流和实时公交的所有内容了,本人技术十分有限,如有不合理或者错误的地方还望指出
基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流
基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流
![]()
前言
在上一篇我们实现了公交线路客流,通过飞线效果展示了站点间的客流流向。现在我们来搞一下区域客流可视化,采用六边形蜂窝网格来展示不同区域的客流热力图,除了保证数据更加直观外,当然也要利用JSAPIThree高灵活自定义的优势来搞点帅的东西。
在公交行业的区域客流可视化主要的是:
- 哪些区域的公交客流最密集
- 通过热力图快速识别热点区域
- 面子攻城(bushi)
与线路客流相比,区域客流更注重空间分布特征这块。我们使用六边形蜂窝网格将城市区域进行规则划分(也支持正方形、三角形),每个六边形代表一个单元,通过统计单元内的公交站点数量和客流数据,生成蜂窝热力图来直观展示每块区域的客流密度分布。
技术实现
数据准备
基于上一篇文章的初始化地图代码,我们需要以下数据文件(文件在下方仓库地址):
-
边界数据 (
guzhen.json) - 城市或区域边界数据 -
站点数据 (
stands.json) - 公交站点位置和客流数据
边界数据采用标准的 GeoJSON 格式(这种数据推荐去阿里的datav中可以直接获取包括省市区)。站点数据包含每个站点的经纬度坐标和客流统计信息。
蜂窝网格生成
六边形相对比矩形和三角形看起来更专业一点。我们使用 Turf.js 的 hexGrid 函数来生成蜂窝网格(truf也支持三角形和矩形)。
网格生成原理:
-
边界框计算:使用
bbox函数计算多边形的包围盒 - 网格生成 :- 在边界框内生成指定radius的蜂窝网格
-
空间裁剪:使用
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函数是判断否在空间内,我们只保留与目标区域重叠的六边形
站点数据统计
为每个六边形计算站点数量和总客流数据,这是为了生成热力图用的的数值。
统计原理:
-
包含判断 - 使用
booleanContains函数判断站点是否在六边形内 - 数据聚合 - 累加六边形内所有站点的客流数据
// 计算每个六边形内的站点数据
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
目前的基础效果就是这个样子:
![]()
蜂窝区块可视化
现在我们要让这些六边形更加的层次分明,要用颜色和透明度来直观展示客流密度分布,让数据更可视化。我们使用 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)
}
好的,上色之后我们可以很直观的看到哪里的客流多:
![]()
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
}
这样,我们的热力图就有了底层边框和内部填充:
![]()
看起来蛮吊的,还能不能更唬人一点
3. 添加扫光shader效果
扫描效果还是非常适合这种网格的面,采用从左上到右下的渐变矩形扫光 大致效果如图所示:
![]()
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,
})
}
扫光效果就出来了,看起来很科幻,这领导看不得拍手叫好?
![]()
区域掩膜效果
为了突出更加沉浸的显示目标区域,我们创建一个黑色掩膜来遮挡区域外的内容,让观众的注意力集中在目标区域。
实现的步骤:
- 世界矩形 - 创建覆盖整个地球的大矩形
- 区域掏空 - 将目标区域从世界矩形中挖出
- 黑色填充 - 使用黑色填充
// 创建区域掩膜
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())
效果如图,会更专注聚焦这个区域
![]()
站点粒子效果
最后为所有公交站点添加发光粒子效果,能够清晰的看到站点分布在蜂窝的情况,我们使用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)
}
![]()
技术要点
1. 空间计算
主要的实现还是靠turf,turf真是对数学不好的开发的一种福音啊,好用爱用, 拿到边界数据使用bbox计算出边界,然后在这个包围盒通过turfHexGrid生成Hexagon蜂窝,最后用booleanContains裁剪掉地区边界外的蜂窝。
2. 颜色生成
先将客流的数据都维持在0-1之间,这里也叫数据归一化,然后更具数值为设置HSL颜色也就是类似css的rab(255,255,255) 这种写法。
3. Shader
shader glsl不像js那样可以打印调试,完全靠抽象的脑补,这里主要的步骤: 位置计算 → 距离场 → 光脉冲 → 合成
总结
区域客流可视化通过六边形蜂窝网格和热力效果,除了能把复杂的空间数据转化为直观的视效,还结合扫光动画和粒子效果增加视觉体验。
下一篇我们将继续实现实时公交
实现无缝滚动无滚动条的 Element UI 表格(附完整代码)
实现无缝滚动无滚动条的 Element UI 表格(附完整代码)
在后台管理系统或数据监控场景中,经常需要实现表格无缝滚动展示数据,同时希望隐藏滚动条保持界面整洁。本文将基于 Element UI 实现一个 无滚动条、无缝循环、hover 暂停、状态高亮 的高性能滚动表格,全程流畅无卡顿,适配多浏览器。
![]()
最终效果
- 🚀 无缝循环滚动,无停顿、无跳跃
- 🚫 视觉上完全隐藏滚动条,保留滚动功能
- 🛑 鼠标悬浮自动暂停,离开恢复滚动
- 🌈 支持状态字段高亮(如不同状态显示不同颜色)
- 🎨 美观的表格样式,hover 行高亮反馈
- 🛠 高度可配置(行高、滚动速度、表格高度等)
技术栈
- Vue 2 + Element UI(适配 Vue 2 项目,Vue 3 可快速迁移)
- SCSS(样式模块化,便于维护)
实现思路
- 无缝滚动核心:通过「数据拼接」(原数据 + 原数据副本)实现视觉上的无限循环,滚动到原数据末尾时瞬间重置滚动位置,无感知切换
- 隐藏滚动条:多浏览器兼容 CSS 屏蔽滚动条样式,同时预留滚动条宽度避免内容裁剪
-
流畅滚动优化:避免 DOM 频繁重绘,用
scrollTop控制滚动,关闭平滑滚动避免停顿 - 交互增强: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、问题:今天在发开中要对一个包版本升级,使用新的版本,安装官方文档进行了引用配置,
![]()
根据文档,配置完毕后,本地运行项目一切正常,但到打包构建时却出现了一下问题。
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在构建阶段(包括dev和build)会 把项目中所有出现的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
}
})
}
}
一、函数设计背景与核心目标
在中后台系统中,数据选择弹窗通常具备以下共性需求:
- 统一的弹窗容器(标题、确认 / 取消按钮、尺寸控制);
- 动态嵌入不同的选择组件(如用户列表、角色表格);
- 支持单选 / 多选切换、初始化查询参数传递;
- 选中数据校验、前置拦截与结果回调;
- 组件间通信与方法调用(如获取选中数据)。
传统开发方式中,这些需求往往通过 “复制粘贴 + 修改” 实现,导致代码重复率高、
逻辑分散而本文解析的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是一个高阶函数,其核心逻辑分为两步:
- 接收CreateSelectDialogParams参数,封装弹窗的 “固定逻辑”(如组件渲染、基础样式);
- 返回一个新函数,该函数接收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 项目中数据选择弹窗的复用问题。其核心在于:
- 用 TypeScript 接口规范参数结构,保障代码健壮性;
- 用 Ref 引用实现组件间方法调用,简化通信逻辑;
- 用高阶函数分离固定配置与动态配置,提升复用效率。
在实际项目中,我们可以基于该函数的设计思路,进一步拓展弹窗的功能(如自定义按钮、支持分页),也可将其封装为 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),高频变化时限制执行频率 |
四、注意事项
- deep 的使用场景:
-
- 监听 reactive 对象时,默认会 “惰性深度监听”(仅监听被访问过的属性),若需监听所有属性(包括未访问的),仍需设置 deep: true;
-
- 监听 ref 包裹的对象时,必须设置 deep: true 才能监听内部属性变化。
- debounce / throttle 的限制:仅 Vue3.2+ 版本支持,低版本需手动通过 lodash 等库实现。
- 性能优化:避免不必要的 deep: true(会增加监听开销),尽量精准监听具体属性(如 () => user.info.age)。
如何使用 Vuex 设计你的数据流
前端数据管理
解决这个问题的最常见的一种思路就是:专门定义一个全局变量,任何组件需要数据的时候都去这个全局变量中获取。一些通用的数据,比如用户登录信息,以及一个跨层级的组件通信都可以通过这个全局变量很好地实现。在下面的代码中我们使用 _store 这个全局变量存储数据。
// 比如挂一个全局的变量
window._store = {}
其他组件和这个量的交互
![]()
但这样就会产生一个问题,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 中统一管理,每当需要抽离这样的数据的时候,我们都需要思考这个数据的初始化和更新逻辑。
![]()
手写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 去做数据的修改。
![]()
![]()
在决定一个数据是否用Vuex 来管理的时候,核心就是要思考清楚,这个数据是否有共享给其他页面或者是其他组件的需要。如果需要,就放置在 Vuex 中管理;如果不需要,就应该放在组件内部使用ref 或者 reactive 去管理。
Pinia
Vuex 由于在 API 的设计上,对 TypeScript 的类型推导的支持比较复杂,用起来很是痛苦。Pinia 的 API 的设计非常接近 Vuex5 的提案,首先,Pinia 不需要Vuex 自定义复杂的类型去支持 TypeScript,天生对类型推断就非常友好,并且对 VueDevtool 的支持也非常好
Vue 团队成员又搞了个 "新玩具"!
Vue 团队成员真的是太卷了,又搞了个新玩具:markdown-exit——markdown-it 的 TypeScript「重构版」来了!
![]()
如果你用过 VitePress、Slidev、或者任何 Vue 系文档站,你一定对 markdown-it 不陌生:它就是把 Markdown 变成 HTML 的那台“发动机”。
问题是,这台发动机是 2014 年的设计,纯 JavaScript 写成,类型靠社区维护,异步插件更是想都别想。
于是,Vue 团队的核心成员 Anthony Fu 在推特上“许愿”:
![]()
“我找这东西太久了!缺少异步插件限制了我们太多玩法,真希望 VitePress、Slidev 能用上。”
不到两周,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;VitePress、Slidev官方模板同步切换。 -
长期:探索
AST级别缓存、WebAssembly加速、流式渲染等高级玩法。
结语
从 vue/core 到 vite,再到现在的 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的实用工具库,它将常见的开发需求封装成可复用的组合式函数。主要特点包括:
- 丰富的功能集合:提供200+个实用函数,涵盖状态管理、浏览器API、动画、网络请求等各个方面
- 类型安全:完整的TypeScript支持,提供优秀的开发体验
- Tree-shakable:支持按需引入,不会增加不必要的包体积
- SSR友好:完美支持服务端渲染
- 灵活可配置:大部分函数都提供丰富的配置选项
- 响应式设计:充分利用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>
优缺点分析
优势
- 开发效率高:提供大量开箱即用的功能,减少重复代码编写
- 类型安全:完整的TypeScript支持,减少运行时错误
- 性能优秀:基于Vue响应式系统,性能表现良好
- 文档完善:官方文档详细,示例丰富
- 社区活跃:持续更新,bug修复及时
- 灵活性强:大部分函数都支持自定义配置
局限性
- 学习成本:函数众多,需要时间熟悉各个API
- 包体积:虽然支持tree-shaking,但完整引入会增加包体积
- Vue绑定:仅适用于Vue项目,不能在其他框架中使用
- 版本依赖:需要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的核心特性。
相关链接
基于Vue的数字输入框指令
解决前端项目中大数据复杂列表场景的完美方案
多窗口数据实时同步常规方案举例
实现公历和农历日期选择组件(用于选择出生日期)
Vue3 + Pinia 移动端Web应用:页面缓存策略解决方案💡
写在开头
Hello,各位UU们好呀!😋
嘿嘿,此时此刻,有点开心。
![]()
因为,小编下周休了两天年假,连着四天小长假,准备回老家吃大席,又有老朋友要结婚了。😂
然后呢,最近小编在开发移动端 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的首页或主导航页面。
为什么需要菜单页?
答:定期清理缓存,避免内存泄漏,确保用户每次从主入口开始都是干净的状态。
原理流程图
一图胜千言,瞧瞧:
关键技术点
实时导航栈管理
// 检查是否为返回到已存在的路由
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应用。
至此,本篇文章就写完啦,撒花撒花。
![]()
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。
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都有优秀的官方文档和活跃的社区,你遇到的问题很可能已经有人解决过了。
学习框架只是开始,更重要的是理解背后的编程思想和设计模式。这些知识会让你在任何技术变革中都能快速适应。
技术会更新,生态会变化,但解决问题的能力才是你真正的核心竞争力。