普通视图
2026全球开发者先锋大会本月将在上海举办
上交所总经理蔡建春:科创板要落实好关键核心技术领域企业常态化上市机制
华软科技拟3.24亿元收购莱恩光电67.411%股权
wc Cheatsheet
Basic Syntax
Core wc command forms.
| Command | Description |
|---|---|
wc file.txt |
Show lines, words, and bytes for a file |
wc file1 file2 |
Show counts per file and a total line |
wc *.log |
Count all matching files |
command | wc |
Count output from another command |
wc --help |
Show available options |
Common Count Flags
Use flags to request specific counters.
| Command | Description |
|---|---|
wc -l file.txt |
Count lines only |
wc -w file.txt |
Count words only |
wc -c file.txt |
Count bytes only |
wc -m file.txt |
Count characters only |
wc -L file.txt |
Show longest line length |
Useful Pipelines
Practical wc combinations for quick shell checks.
| Command | Description |
|---|---|
ls -1 | wc -l |
Count directory entries (one per line) |
grep -r "ERROR" /var/log | wc -l |
Count matching log lines |
find . -type f | wc -l |
Count files recursively |
ps aux | wc -l |
Count process list lines |
cat file.txt | wc -w |
Count words from stdin |
Multi-File Counting
Summarize multiple files and totals.
| Command | Description |
|---|---|
wc -l *.txt |
Line count for each file plus total |
wc -w docs/*.md |
Word count per Markdown file and total |
wc -c part1 part2 part3 |
Byte count per file and combined total |
wc -m *.csv |
Character count for each CSV file |
wc -L *.log |
Max line length per file and max total |
Script-Friendly Patterns
Extract numeric output safely in scripts.
| Command | Description |
|---|---|
count=$(wc -l < file.txt) |
Capture pure line count without filename |
words=$(wc -w < file.txt) |
Capture word count only |
bytes=$(wc -c < file.txt) |
Capture byte count only |
if [ "$(wc -l < file.txt)" -gt 1000 ]; then ... fi |
Threshold check in scripts |
printf '%s\n' "$text" | wc -m |
Count characters in a variable |
Troubleshooting
Quick checks for common wc confusion.
| Issue | Check |
|---|---|
| Count includes filename | Use input redirection: wc -l < file
|
| Unexpected word count | Confirm whitespace and delimiters in the file |
| Character and byte counts differ | Use -m for characters and -c for bytes |
| Total line missing with many files | Ensure shell glob matches at least one file |
| Pipeline count seems off by one | Some commands add headers; account for that |
Related Guides
Use these guides for complete text-processing workflows.
| Guide | Description |
|---|---|
| linux wc Command | Full wc guide with detailed examples |
| head Command in Linux | Show first lines of files |
| tail Command in Linux | Show last lines and follow logs |
| grep Command in Linux | Search and filter matching lines |
| find Files in Linux | Build file lists for counting |
打造你的HTML5打地鼠游戏:零基础入门实践
打造你的HTML5打地鼠游戏:零基础入门实践
创建游戏结构
1. HTML布局
首先,我们需要创建一个基本的HTML页面,它将包含游戏的布局和地鼠洞。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>洛可可白⚡️打地鼠</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="game-container">
<div class="wam-hole">
<div class="wam-mole"></div>
</div>
<div class="wam-hole">
<div class="wam-mole"></div>
</div>
<!-- 更多地鼠洞 -->
</div>
<script src="script.js"></script>
</body>
</html>
设计游戏样式
2. CSS样式
接下来,我们将使用CSS来美化我们的游戏界面。
/* styles.css */
* {
box-sizing: border-box;
}
h1 {
text-align: center;
line-height: 30px;
}
.bigBox {
width: 60%;
height: 400px;
margin: 20px auto;
background-color: #cbbb3e;
}
.wam-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
height: 260px;
}
.wam-hole {
position: relative;
width: 100px;
height: 100px;
margin: 0 20px;
background-color: #f5732d;
}
.wam-mole {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* 地鼠 */
background-image: url("https://pic.52112.com/180516/EPS180516_57/9jagBhddHW_small.jpg");
background-size: 100% 100%;
display: none;
}
.wam-mole--up {
display: block;
}
.wam-score {
font-size: 2rem;
text-align: center;
}
.wam-message {
font-size: 1rem;
text-align: center;
margin-top: 20px;
cursor: pointer;
}
/* 你可以添加更多的CSS来美化地鼠洞和地鼠 */
实现游戏逻辑
3. JavaScript编程
现在,我们将使用JavaScript来添加游戏逻辑。
const container = document.querySelector(".wam-container");
const scoreBoard = document.querySelector(".wam-score");
const message = document.querySelector(".wam-message");
const moles = Array.from(container.querySelectorAll(".wam-hole"));
let lastHole;
let score = 0;
let isPlaying = false;
let timeUp = false;
// 随机时间生成地鼠
function popUpMole() {
if (timeUp) return;
const time = Math.random() * (1500 - 500) + 500;
const hole = randomHole(moles);
hole.querySelector("div").classList.add("wam-mole--up");
setTimeout(() => {
hole.querySelector("div").classList.remove("wam-mole--up");
if (!timeUp) popUpMole();
}, time);
}
// 随机选择一个地鼠洞
function randomHole(holes) {
const idx = Math.floor(Math.random() * holes.length);
const hole = holes[idx];
if (hole === lastHole) return randomHole(holes);
lastHole = hole;
return hole;
}
// 点击地鼠
function whackMole(e) {
if (!e.isTrusted) return; // 防止作弊
if (!isPlaying) return;
if (!e.target.matches(".wam-mole")) return;
score++;
scoreBoard.textContent = `分数: ${score}`;
e.target.parentNode
.querySelector("div")
.classList.remove("wam-mole--up");
}
// 开始游戏
function startGame() {
score = 0;
scoreBoard.textContent = "分数: 0";
isPlaying = true;
timeUp = false;
message.textContent = "";
popUpMole();
setTimeout(() => {
isPlaying = false;
timeUp = true;
message.textContent = `一分钟您的得分是: ${score};点我再来一次!`;
}, 60000);
}
// 初始化地鼠洞
moles.forEach((mole) => mole.addEventListener("click", whackMole));
document
.querySelector(".wam-message")
.addEventListener("click", startGame);
这段代码创建了一个简单的游戏循环,每秒钟随机显示一个地鼠,并在用户点击地鼠时给予反馈。你可以根据需要调整地鼠出现的速度和游戏的其他方面。
全部代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>洛可可白⚡️打地鼠</title>
<style>
* {
box-sizing: border-box;
}
h1 {
text-align: center;
line-height: 30px;
}
.bigBox {
width: 60%;
height: 400px;
margin: 20px auto;
background-color: #cbbb3e;
}
.wam-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
height: 260px;
}
.wam-hole {
position: relative;
width: 100px;
height: 100px;
margin: 0 20px;
background-color: #f5732d;
}
.wam-mole {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* 地鼠 */
background-image: url("https://pic.52112.com/180516/EPS180516_57/9jagBhddHW_small.jpg");
background-size: 100% 100%;
display: none;
}
.wam-mole--up {
display: block;
}
.wam-score {
font-size: 2rem;
text-align: center;
}
.wam-message {
font-size: 1rem;
text-align: center;
margin-top: 20px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>打地鼠</h1>
<div class="bigBox">
<div class="wam-container">
<div class="wam-hole">
<div class="wam-mole"></div>
</div>
<div class="wam-hole">
<div class="wam-mole"></div>
</div>
<div class="wam-hole">
<div class="wam-mole"></div>
</div>
<div class="wam-hole">
<div class="wam-mole"></div>
</div>
<div class="wam-hole">
<div class="wam-mole"></div>
</div>
</div>
<div class="wam-score">分数: 0</div>
<div class="wam-message">准备好了吗?点击我开始</div>
</div>
<script>
const container = document.querySelector(".wam-container");
const scoreBoard = document.querySelector(".wam-score");
const message = document.querySelector(".wam-message");
const moles = Array.from(container.querySelectorAll(".wam-hole"));
let lastHole;
let score = 0;
let isPlaying = false;
let timeUp = false;
// 随机时间生成地鼠
function popUpMole() {
if (timeUp) return;
const time = Math.random() * (1500 - 500) + 500;
const hole = randomHole(moles);
hole.querySelector("div").classList.add("wam-mole--up");
setTimeout(() => {
hole.querySelector("div").classList.remove("wam-mole--up");
if (!timeUp) popUpMole();
}, time);
}
// 随机选择一个地鼠洞
function randomHole(holes) {
const idx = Math.floor(Math.random() * holes.length);
const hole = holes[idx];
if (hole === lastHole) return randomHole(holes);
lastHole = hole;
return hole;
}
// 点击地鼠
function whackMole(e) {
if (!e.isTrusted) return; // 防止作弊
if (!isPlaying) return;
if (!e.target.matches(".wam-mole")) return;
score++;
scoreBoard.textContent = `分数: ${score}`;
e.target.parentNode
.querySelector("div")
.classList.remove("wam-mole--up");
}
// 开始游戏
function startGame() {
score = 0;
scoreBoard.textContent = "分数: 0";
isPlaying = true;
timeUp = false;
message.textContent = "";
popUpMole();
setTimeout(() => {
isPlaying = false;
timeUp = true;
message.textContent = `一分钟您的得分是: ${score};点我再来一次!`;
}, 60000);
}
// 初始化地鼠洞
moles.forEach((mole) => mole.addEventListener("click", whackMole));
document
.querySelector(".wam-message")
.addEventListener("click", startGame);
</script>
</body>
</html>
如何实现一个网页版的剪映(三)使用fabric.js绘制时间轴
前言
《实践论》中讲认识从实践始,经过实践得到了理论的认识,还须再回到实践去。
理论的东西之是否符合于客观真理性这个问题,在前面说的由感性到理性之认识运动中是没有完全解决的,也不能完全解决的。
要完全地解决这个问题,只有把理性的认识再回到社会实践中去,应用理论于实践,看它是否能够达到预想的目的。
时间轴
根据mdn文档所述,canvas有最大的宽高的限制
![]()
我们的视频缩略图和音频波形图是通过canvas绘制的,如果缩放时间轴,可能会超过这个最大宽度(画布会崩溃)
有如下方案:
- 无界云剪是将缩略图通过图片拼接成一个很长的图片
- 剪映是通过将canvas固定在一个最大宽度内,然后通过滚动+translate使canvas一直显示在视口
- clideo是拆分成多个canvas
- pro.diffusion.studio是整个时间轴通过canvas绘制出来
本文最终选取使用canvas把整个时间轴画出来这种方案
本文最终实现的效果如下
- 时间轴缩放(ctrl+滑轮)
- 视频轴、音频轴、文本轴的裁剪
- 轨道的对齐
- 视频缩略图、音频波形图的实现
![]()
视频轨道
本节将实现基本的视频轨道绘制、视频缩略图的绘制
![]()
本节将使用上一篇文章介绍的mediabunny来进行视频抽帧
mediabunny最大的亮点是:将webcodecs回调模式读取VideoFrame转换为迭代器模式
const sink = new CanvasSink(videoTrack, {
width: this.thumbnailWidth,
height: Math.round(thumbHeight),
fit: 'contain'
});
for (let t = 0; t <= this.duration; t += DEFAULT_THUMBNAIL_STEP) {
const result = await sink.getCanvas(t);
}
我们选取1s为间隔抽取缩略图,并将缩略图转为ImageBitmap存在map中(这一步还能进行优化,可以将ImageBitmap降低分辨率,可以节省更多内存)
时间轴进行缩放时,取最近的缓存时间点缩略图,避免重复解码
const key = Math.round(time / step) * step;
const img = this.thumbnailCache.get(key);
完整代码如下:
import { Rect } from 'fabric';
import { ALL_FORMATS, BlobSource, CanvasSink, Input } from 'mediabunny';
import { ClipType } from '../types';
/** 默认缩略图高度(像素) */
const DEFAULT_THUMBNAIL_HEIGHT = 52;
/** 默认视频宽高比 */
const DEFAULT_ASPECT_RATIO = 16 / 9;
/** 缩略图抽帧步长(秒) */
const DEFAULT_THUMBNAIL_STEP = 1;
/** 默认视频 URL */
const DEFAULT_VIDEO_URL = new URL(
'../../../assets/test.mp4',
import.meta.url
).toString();
/** 视频背景色 */
const VIDEO_BACKGROUND = '#1e1b4b';
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;
type VideoClipOptions = {
id: string;
left: number;
top: number;
width: number;
height: number;
src?: string;
};
export class VideoClip extends Rect {
clipType: ClipType = 'video';
elementId: string;
/** 视频资源地址 */
src: string;
/** 视频源总时长(秒),用于裁剪边界约束 */
sourceDuration = 0;
/** 当前裁剪起点(秒),相对视频源时间轴 */
trimStart = 0;
/** 当前裁剪终点(秒),相对视频源时间轴 */
trimEnd = 0;
/** 预解码的缩略图列表与缓存 */
private thumbnails: Array<{ time: number; image: CanvasImageSource }> = [];
private thumbnailCache = new Map<number, CanvasImageSource>();
/** 避免重复请求与解码 */
private isLoading = false;
/** 视频真实时长 */
private duration = 0;
/** 真实宽高比(用于缩略图铺排) */
private aspectRatio = DEFAULT_ASPECT_RATIO;
/** 单张缩略图宽度(像素) */
private thumbnailWidth = 0;
constructor(options: VideoClipOptions) {
super({
left: options.left,
top: options.top,
width: options.width,
height: options.height,
fill: VIDEO_BACKGROUND,
stroke: null,
strokeWidth: 0,
rx: 6,
ry: 6,
selectable: true,
hasControls: true,
lockRotation: true,
lockScalingY: true,
lockScalingFlip: true,
objectCaching: false,
hoverCursor: 'move'
});
this.elementId = options.id;
this.src = options.src ?? DEFAULT_VIDEO_URL;
this.thumbnailWidth = Math.max(
1,
Math.round(
(options.height || DEFAULT_THUMBNAIL_HEIGHT) * this.aspectRatio
)
);
// 仅保留左右缩放控制点
this.setControlsVisibility({
tl: false,
tr: false,
bl: false,
br: false,
mt: false,
mb: false,
mtr: false,
ml: true,
mr: true
});
// 初始化缩略图加载,完成后会触发重绘
this.loadThumbnails();
}
async loadThumbnails() {
if (this.isLoading) return;
this.isLoading = true;
try {
const response = await fetch(this.src);
const blob = await response.blob();
const input = new Input({
formats: ALL_FORMATS,
source: new BlobSource(blob)
});
// 读取视频真实时长,并同步裁剪边界
this.duration = (await input.computeDuration()) || 0;
this.sourceDuration = this.duration;
// 初始化 trimEnd 为源时长,避免裁剪窗口超出视频长度
if (this.trimEnd === 0 || this.trimEnd > this.sourceDuration) {
this.trimEnd = this.sourceDuration;
}
// 若 trimStart 越界,则回退到 0
if (this.trimStart > this.trimEnd) {
this.trimStart = 0;
}
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) return;
const canDecode = await videoTrack.canDecode();
if (!canDecode) return;
if (videoTrack.displayWidth && videoTrack.displayHeight) {
this.aspectRatio = videoTrack.displayWidth / videoTrack.displayHeight;
}
const thumbHeight = this.height || DEFAULT_THUMBNAIL_HEIGHT;
this.thumbnailWidth = Math.max(
1,
Math.round(thumbHeight * this.aspectRatio)
);
const sink = new CanvasSink(videoTrack, {
width: this.thumbnailWidth,
height: Math.round(thumbHeight),
fit: 'contain'
});
// 均匀采样缩略图并缓存,避免每次 render 重复解码
const thumbnails: Array<{ time: number; image: CanvasImageSource }> = [];
const thumbnailCache = new Map<number, CanvasImageSource>();
for (let t = 0; t <= this.duration; t += DEFAULT_THUMBNAIL_STEP) {
const result = await sink.getCanvas(t);
if (!result) continue;
const canvas = result.canvas;
const image = await createImageBitmap(canvas);
const time = result.timestamp ?? t;
thumbnails.push({ time, image });
const key =
Math.round(time / DEFAULT_THUMBNAIL_STEP) * DEFAULT_THUMBNAIL_STEP;
thumbnailCache.set(key, image);
}
this.thumbnails = thumbnails;
this.thumbnailCache = thumbnailCache;
this.canvas?.requestRenderAll();
} catch (error) {
console.error('VideoClip loadThumbnails error:', error);
} finally {
this.isLoading = false;
}
}
_render(ctx: CanvasRenderingContext2D) {
ctx.save();
// 反向缩放,让绘制逻辑用屏幕像素坐标
const scaleX = this.scaleX || 1;
const scaleY = this.scaleY || 1;
ctx.scale(1 / scaleX, 1 / scaleY);
const width = (this.width || 0) * scaleX;
const height = (this.height || 0) * scaleY;
const radius = this.rx || 6;
// 以圆角矩形作为裁剪区域
ctx.beginPath();
ctx.roundRect(-width / 2, -height / 2, width, height, radius);
ctx.clip();
// 绘制底色,缩略图缺失时仍有可视背景
ctx.fillStyle = VIDEO_BACKGROUND;
ctx.fillRect(-width / 2, -height / 2, width, height);
if (this.thumbnails.length > 0 && width > 0 && height > 0) {
// 以裁剪窗口作为缩略图采样范围
const trimStart = Math.max(0, this.trimStart || 0);
const trimEnd = Math.max(trimStart, this.trimEnd || 0);
const trimDuration = trimEnd - trimStart;
if (trimDuration <= 0) {
ctx.restore();
return;
}
// 依据显示高度与视频宽高比计算单张缩略图宽度
const thumbWidth = Math.max(1, Math.round(height * this.aspectRatio));
// 根据显示宽度计算可容纳的缩略图数量
const visibleCount = Math.max(1, Math.ceil(width / thumbWidth));
const step = DEFAULT_THUMBNAIL_STEP;
// 在裁剪区间内均匀采样对应数量的时间点
const timeStep = trimDuration / visibleCount;
for (let i = 0; i < visibleCount; i += 1) {
const time = trimStart + i * timeStep;
// 取最近的缓存时间点缩略图,避免重复解码
const key = Math.round(time / step) * step;
const img = this.thumbnailCache.get(key);
if (!img) continue;
// 缩略图按等宽平铺,保持宽高比不变
const x = -width / 2 + i * thumbWidth;
const drawWidth = Math.min(thumbWidth, width - i * thumbWidth);
if (drawWidth <= 0) continue;
ctx.drawImage(img, x, -height / 2, drawWidth, height);
}
}
ctx.restore();
// 绘制边框(在裁剪区域外,确保边框宽度不随缩放变化)
ctx.save();
ctx.scale(1 / scaleX, 1 / scaleY);
ctx.beginPath();
ctx.roundRect(-width / 2, -height / 2, width, height, radius);
ctx.strokeStyle = BORDER_COLOR;
ctx.lineWidth = BORDER_WIDTH;
ctx.stroke();
ctx.restore();
}
}
最小使用demo:
import { Canvas } from 'fabric';
import { useEffect, useRef } from 'react';
import { VideoClip } from '../../core/timeline/clips/video-clip';
export default function VideoClipDemo() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = new Canvas(canvasRef.current, {
width: 800,
height: 200,
backgroundColor: '#0f172a'
});
const videoClip = new VideoClip({
id: 'demo-video-1',
left: 50,
top: 70,
width: 300,
height: 60
});
canvas.add(videoClip);
canvas.setActiveObject(videoClip);
return () => {
canvas.dispose();
};
}, []);
return <canvas ref={canvasRef} />;
}
音频轨道
上篇文章中,我们使用konva完成了音频波形图的绘制,在这一节中将会对它进行优化
原始音频本质是 PCM 采样数据(一秒可能 44100 个点),
如果你直接一个点一个点画,性能会炸。
所以这里做了一件非常关键的事:降采样 + 取峰值
在 extractWaveformData() 里做了三件事:
- 只取第一个声道
- 每秒固定抽 100 个“波形点”
- 每个点不存所有数据,而是只存:这一小段里的 最小值和最大值
[min, max, min, max, min, max...]
这样做的好处是:数据量大幅减少,并且视觉上还能保留波形“形状”
![]()
import { Rect } from 'fabric';
import { ALL_FORMATS, BlobSource, Input } from 'mediabunny';
import { ClipType } from '../types';
/** 默认音频文件 URL */
const DEFAULT_AUDIO_URL = new URL(
'../../../assets/1.wav',
import.meta.url
).toString();
/** 波形颜色(绿色) */
const WAVEFORM_COLOR = '#22c55e';
/** 波形背景颜色(深绿色) */
const WAVEFORM_BACKGROUND = '#14532d';
/** 每秒采样的波形数据点数 */
const WAVEFORM_SAMPLES_PER_SECOND = 100;
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;
/** AudioClip 构造选项 */
type AudioClipOptions = {
id: string;
left: number;
top: number;
width: number;
height: number;
src?: string;
};
export class AudioClip extends Rect {
clipType: ClipType = 'audio';
/** 对应业务 Clip 的唯一标识 */
elementId: string;
/** 音频资源地址 */
src: string;
/** 音频源总时长(秒),用于裁剪边界约束 */
sourceDuration = 0;
/** 当前裁剪起点(秒),相对音频源时间轴 */
trimStart = 0;
/** 当前裁剪终点(秒),相对音频源时间轴 */
trimEnd = 0;
/** 预解码的波形数据(每个采样点包含 min 和 max 两个值) */
private waveformData: Float32Array | null = null;
/** 加载状态标记,避免重复加载 */
private isLoading = false;
/** 音频缓冲区,用于提取波形数据 */
private audioBuffer: AudioBuffer | null = null;
constructor(options: AudioClipOptions) {
super({
left: options.left,
top: options.top,
width: options.width,
height: options.height,
fill: WAVEFORM_BACKGROUND,
stroke: null,
strokeWidth: 0,
rx: 6,
ry: 6,
selectable: true,
hasControls: true,
lockRotation: true,
lockScalingY: true,
lockScalingFlip: true,
objectCaching: false,
hoverCursor: 'move'
});
this.elementId = options.id;
this.src = options.src ?? DEFAULT_AUDIO_URL;
// 仅保留左右缩放控制点,允许裁剪式缩放
this.setControlsVisibility({
tl: false,
tr: false,
bl: false,
br: false,
mt: false,
mb: false,
mtr: false,
ml: true,
mr: true
});
this.loadAudio();
}
async loadAudio() {
if (this.isLoading) return;
this.isLoading = true;
try {
const response = await fetch(this.src);
const blob = await response.blob();
const input = new Input({
formats: ALL_FORMATS,
source: new BlobSource(blob)
});
this.sourceDuration = (await input.computeDuration()) || 0;
// 初始化裁剪窗口,确保不超过音频时长
if (this.trimEnd === 0 || this.trimEnd > this.sourceDuration) {
this.trimEnd = this.sourceDuration;
}
if (this.trimStart > this.trimEnd) {
this.trimStart = 0;
}
const arrayBuffer = await blob.arrayBuffer();
const audioContext = new AudioContext();
this.audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
audioContext.close();
// 提取波形数据
this.extractWaveformData();
this.canvas?.requestRenderAll();
} catch (error) {
console.error('AudioClip loadAudio error:', error);
} finally {
this.isLoading = false;
}
}
/**
* 从音频缓冲区提取波形数据
* 将原始音频采样降采样为固定数量的峰值点,用于高效渲染
*/
private extractWaveformData() {
if (!this.audioBuffer || this.sourceDuration <= 0) return;
// 获取第一个声道的音频数据
const channelData = this.audioBuffer.getChannelData(0);
const samples = channelData.length;
// 计算目标采样点数(每秒 100 个点)
const targetSamples = Math.ceil(
this.sourceDuration * WAVEFORM_SAMPLES_PER_SECOND
);
// 每个采样点存储 min 和 max 两个值
this.waveformData = new Float32Array(targetSamples * 2);
// 计算每个目标采样点对应的原始采样数
const samplesPerPeak = Math.floor(samples / targetSamples);
// 遍历所有目标采样点,计算每个区间的峰值
for (let i = 0; i < targetSamples; i++) {
const start = i * samplesPerPeak;
const end = Math.min(start + samplesPerPeak, samples);
let min = 0;
let max = 0;
// 在当前区间内查找最小值和最大值
for (let j = start; j < end; j++) {
const value = channelData[j];
if (value < min) min = value;
if (value > max) max = value;
}
// 存储峰值数据
this.waveformData[i * 2] = min;
this.waveformData[i * 2 + 1] = max;
}
}
/**
* 重写渲染逻辑,绘制音频波形
* 根据裁剪窗口只显示 trimStart 到 trimEnd 区间的波形
*/
_render(ctx: CanvasRenderingContext2D) {
ctx.save();
// 反向缩放,让绘制逻辑用屏幕像素坐标
const scaleX = this.scaleX || 1;
const scaleY = this.scaleY || 1;
ctx.scale(1 / scaleX, 1 / scaleY);
const width = (this.width || 0) * scaleX;
const height = (this.height || 0) * scaleY;
const radius = this.rx || 6;
// 以圆角矩形作为裁剪区域
ctx.beginPath();
ctx.roundRect(-width / 2, -height / 2, width, height, radius);
ctx.clip();
// 绘制背景色
ctx.fillStyle = WAVEFORM_BACKGROUND;
ctx.fillRect(-width / 2, -height / 2, width, height);
// 绘制波形数据
if (this.waveformData && this.sourceDuration > 0) {
// 获取裁剪窗口
const trimStart = Math.max(0, this.trimStart || 0);
const trimEnd = Math.max(trimStart, this.trimEnd || 0);
const trimDuration = trimEnd - trimStart;
if (trimDuration > 0) {
const totalSamples = this.waveformData.length / 2;
// 计算裁剪区间对应的采样点范围
const startSample = Math.floor(
(trimStart / this.sourceDuration) * totalSamples
);
const endSample = Math.ceil(
(trimEnd / this.sourceDuration) * totalSamples
);
const visibleSamples = endSample - startSample;
const centerY = 0;
const halfHeight = height / 2 - 4;
ctx.fillStyle = WAVEFORM_COLOR;
// 绘制裁剪区间内的波形
for (let i = 0; i < visibleSamples; i++) {
const sampleIndex = startSample + i;
if (sampleIndex * 2 + 1 >= this.waveformData.length) break;
const min = this.waveformData[sampleIndex * 2];
const max = this.waveformData[sampleIndex * 2 + 1];
// 计算当前波形条的 x 坐标
const x = -width / 2 + (i / visibleSamples) * width;
const barWidth = Math.max(1, width / visibleSamples);
// 计算波形条的 y 坐标范围
const minY = centerY + min * halfHeight;
const maxY = centerY + max * halfHeight;
// 绘制波形条
ctx.fillRect(x, minY, barWidth, maxY - minY);
}
}
} else if (this.isLoading) {
// 加载中显示提示文字
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '12px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Loading...', 0, 0);
}
ctx.restore();
// 绘制边框(在裁剪区域外,确保边框宽度不随缩放变化)
ctx.save();
ctx.scale(1 / scaleX, 1 / scaleY);
ctx.beginPath();
ctx.roundRect(-width / 2, -height / 2, width, height, radius);
ctx.strokeStyle = BORDER_COLOR;
ctx.lineWidth = BORDER_WIDTH;
ctx.stroke();
ctx.restore();
}
/**
* 获取音频缓冲区
* 可用于音频播放等功能
*/
getAudioBuffer(): AudioBuffer | null {
return this.audioBuffer;
}
/**
* 获取音频源总时长
* 用于裁剪边界约束
*/
getSourceDuration(): number {
return this.sourceDuration;
}
}
import { Canvas } from 'fabric';
import { useEffect, useRef } from 'react';
import { AudioClip } from '../../core/timeline/clips/audio-clip';
export default function AudioClipDemo() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = new Canvas(canvasRef.current, {
width: 800,
height: 200,
backgroundColor: '#0f172a'
});
const audioClip = new AudioClip({
id: 'demo-audio-1',
left: 50,
top: 70,
width: 300,
height: 60
});
canvas.add(audioClip);
canvas.setActiveObject(audioClip);
return () => {
canvas.dispose();
};
}, []);
return <canvas ref={canvasRef} />;
}
文本轨道
import { Rect } from 'fabric';
import { ClipType } from '../types';
/** 文本 Clip 背景色 */
const TEXT_CLIP_BACKGROUND = '#134e4a';
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;
export class TextClip extends Rect {
clipType: ClipType = 'text';
elementId: string;
/** 显示在块内的文字内容 */
label: string;
constructor(options: {
id: string;
text: string;
left: number;
top: number;
width: number;
height: number;
}) {
super({
left: options.left,
top: options.top,
width: options.width,
height: options.height,
fill: TEXT_CLIP_BACKGROUND,
stroke: null,
strokeWidth: 0,
rx: 8,
/** 圆角 Y */
ry: 8,
selectable: true,
hasControls: true,
lockRotation: true,
/** 锁定纵向缩放 */
lockScalingY: true,
/** 禁止缩放翻转(避免控制块反向导致的 clip 翻转) */
lockScalingFlip: true,
/** 禁用缓存,保证 _render 反向缩放逻辑直接作用于主画布 */
objectCaching: false,
hoverCursor: 'move'
});
this.elementId = options.id;
this.label = options.text;
// 仅保留左右缩放控制点,避免垂直方向缩放
this.setControlsVisibility({
tl: false,
tr: false,
bl: false,
br: false,
mt: false,
mb: false,
mtr: false,
ml: true,
mr: true
});
}
/**
* 重写渲染逻辑,在矩形块中绘制文本
* 手动绘制圆角矩形背景和边框,确保缩放时不变形
*/
_render(ctx: CanvasRenderingContext2D) {
ctx.save();
// 反向缩放,让绘制逻辑用屏幕像素坐标
const scaleX = this.scaleX || 1;
const scaleY = this.scaleY || 1;
ctx.scale(1 / scaleX, 1 / scaleY);
const width = (this.width || 0) * scaleX;
const height = (this.height || 0) * scaleY;
const radius = this.rx || 8;
// 手动绘制圆角矩形背景,确保圆角不随缩放变形
ctx.beginPath();
ctx.roundRect(-width / 2, -height / 2, width, height, radius);
ctx.fillStyle = TEXT_CLIP_BACKGROUND;
ctx.fill();
// 绘制边框,确保边框宽度不随缩放变化
ctx.strokeStyle = BORDER_COLOR;
ctx.lineWidth = BORDER_WIDTH;
ctx.stroke();
// 绘制文本
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.font = '12px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
// 移动到左边缘 8 像素,垂直居中位置
ctx.fillText(this.label, -width / 2 + 8, 0);
ctx.restore();
}
}
滚动条
![]()
滑块宽度怎么算?barWidth = (视口宽度 / 内容宽度) * 轨道宽度
同时还加了:minWidth = 40防止内容太多时滑块小到点不到
滑块位置怎么算?leftOffset = (当前滚动 / 最大滚动距离) * 可滑动距离
可滑动距离 = 轨道总宽度 - 滑块自身宽度,可滑动距离也就是:滑块在轨道上“真正能移动的那一段距离”
import { Canvas } from 'fabric';
import { ITimeline, PointerEventLike } from '../types';
export type ScrollbarBar = {
/** 滑块左边界 X 坐标 */
left: number;
/** 滑块右边界 X 坐标 */
right: number;
/** 滑块上边界 Y 坐标 */
top: number;
/** 滑块下边界 Y 坐标 */
bottom: number;
/** 最大可滚动距离(内容宽度 - 视口宽度) */
maxOffset: number;
/** 滚动轨道总宽度 */
trackWidth: number;
/** 滑块宽度 */
barWidth: number;
};
/**
* 1. 滚动条绘制在 Canvas 的顶层上下文(contextTop)上,不受 viewportTransform 影响
* 2. 通过拦截 Canvas 的鼠标事件实现滚动条的拖拽交互
* 3. 滑块宽度根据内容与视口的比例自动计算
* 4. 当内容完全在视口内时自动隐藏滚动条
*/
export class HorizontalScrollbar {
timeline: ITimeline;
/** 滚动条滑块的高度(像素) */
size = 8;
/** 滚动条与画布边缘的间距(像素) */
scrollSpace = 4;
/** 滑块最小宽度,确保滑块始终可点击 */
minWidth = 40;
/** 滑块填充颜色 */
fill = 'rgba(255,255,255,0.3)';
/** 滑块边框颜色 */
stroke = 'rgba(255,255,255,0.1)';
/** 边框线宽 */
lineWidth = 1;
bar: ScrollbarBar | null = null;
/** 是否处于拖拽滚动条状态 */
dragging = false;
/** 拖拽开始时的鼠标 X 坐标 */
dragStartX = 0;
/** 拖拽开始时的滚动位置 */
dragStartScroll = 0;
private originalMouseDown: ((e: PointerEventLike) => void) | null = null;
private originalMouseMove: ((e: PointerEventLike) => void) | null = null;
private originalMouseUp: ((e: PointerEventLike) => void) | null = null;
constructor(timeline: ITimeline) {
this.timeline = timeline;
const canvas = timeline.canvas;
const canvasInternal = canvas as unknown as {
__onMouseDown?: (e: PointerEventLike) => void;
_onMouseMove?: (e: PointerEventLike) => void;
_onMouseUp?: (e: PointerEventLike) => void;
};
this.originalMouseDown = canvasInternal.__onMouseDown || null;
this.originalMouseMove = canvasInternal._onMouseMove || null;
this.originalMouseUp = canvasInternal._onMouseUp || null;
canvasInternal.__onMouseDown = this.mouseDownHandler.bind(this);
canvasInternal._onMouseMove = this.mouseMoveHandler.bind(this);
canvasInternal._onMouseUp = this.mouseUpHandler.bind(this);
this.beforeRenderHandler = this.beforeRenderHandler.bind(this);
this.afterRenderHandler = this.afterRenderHandler.bind(this);
canvas.on('before:render', this.beforeRenderHandler);
canvas.on('after:render', this.afterRenderHandler);
}
dispose() {
const canvas = this.timeline.canvas;
const canvasInternal = canvas as unknown as {
__onMouseDown?: (e: PointerEventLike) => void;
_onMouseMove?: (e: PointerEventLike) => void;
_onMouseUp?: (e: PointerEventLike) => void;
};
if (this.originalMouseDown)
canvasInternal.__onMouseDown = this.originalMouseDown;
if (this.originalMouseMove)
canvasInternal._onMouseMove = this.originalMouseMove;
if (this.originalMouseUp) canvasInternal._onMouseUp = this.originalMouseUp;
// 移除渲染事件监听
canvas.off('before:render', this.beforeRenderHandler);
canvas.off('after:render', this.afterRenderHandler);
}
/**
* 渲染前处理
*
* 重置 Canvas 顶层上下文的变换矩阵为单位矩阵。
*
* 为什么需要这样做?
*
* Fabric.js 在渲染时会应用 viewportTransform(用于实现滚动效果),
* 这个变换会影响所有后续的绘制操作。但滚动条应该始终固定在视口底部,
* 不应该随着内容滚动而移动。
*
* 通过在渲染前重置变换矩阵,我们确保滚动条的绘制坐标系
* 始终与视口坐标系一致,不受滚动影响。
*/
beforeRenderHandler() {
const ctx = this.timeline.canvas.contextTop;
if (!ctx) return;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.restore();
}
/**
* 渲染后处理 - 绘制滚动条
*
* 在 Canvas 主内容渲染完成后,在顶层上下文绘制滚动条滑块。
* 滑块的宽度和位置根据内容与视口的比例计算。
*
* 计算公式:
* 滑块宽度 = (视口宽度 / 内容宽度) * 轨道宽度
* 滑块位置 = (当前滚动位置 / 最大滚动距离) * 可滑动距离
*/
afterRenderHandler() {
const canvas = this.timeline.canvas;
const ctx = canvas.contextTop;
if (!ctx) return;
const contentWidth = this.timeline.contentWidth;
/**
* 当内容宽度不超过视口宽度时,隐藏滚动条
* 这意味着所有内容都可见,不需要滚动。
*/
if (contentWidth <= canvas.width) {
this.bar = null;
// 清除之前可能绘制的滚动条区域
ctx.clearRect(
0,
canvas.height - this.size - this.scrollSpace - this.lineWidth,
canvas.width,
this.size + this.scrollSpace + this.lineWidth
);
return;
}
/**
* 计算滚动轨道宽度
* 轨道是滑块可滑动的区域,两侧留出间距
*/
const trackWidth = canvas.width - this.scrollSpace * 2;
/**
* 计算滑块宽度
* 滑块宽度反映视口占内容的比例:
* - 内容越多,滑块越小
* - 但最小不低于 minWidth,确保始终可点击
*/
const barWidth = Math.max(
Math.floor((canvas.width / contentWidth) * trackWidth),
this.minWidth
);
/**
* 计算最大可滚动距离
* 即内容超出视口的部分
*/
const maxOffset = contentWidth - canvas.width;
/**
* 计算滑块位置
* 滑块位置 = 间距 + (滚动比例 * 可滑动距离)
* 滚动比例 = 当前滚动位置 / 最大滚动距离
* 可滑动距离 = 轨道宽度 - 滑块宽度
*/
const leftOffset =
(this.timeline.scrollX / maxOffset) * Math.max(0, trackWidth - barWidth);
const left = this.scrollSpace + leftOffset;
/**
* 计算滑块垂直位置
* 滑块位于画布底部,与底部边缘保持间距
*/
const top = canvas.height - this.size - this.scrollSpace;
/**
* 保存滚动条几何信息
* 用于后续的命中检测(判断鼠标是否点击在滑块上)
*/
this.bar = {
left,
right: left + barWidth,
top,
bottom: top + this.size,
maxOffset,
trackWidth,
barWidth
};
ctx.clearRect(
0,
canvas.height - this.size - this.scrollSpace - this.lineWidth,
canvas.width,
this.size + this.scrollSpace + this.lineWidth
);
ctx.save();
ctx.fillStyle = this.fill;
ctx.strokeStyle = this.stroke;
ctx.lineWidth = this.lineWidth;
ctx.beginPath();
ctx.roundRect(left, top, barWidth, this.size, this.size / 2);
ctx.fill();
ctx.stroke();
ctx.restore();
}
/**
* 鼠标按下事件处理
* 判断鼠标是否点击在滚动条滑块上:
* - 如果是,进入拖拽模式,阻止事件继续传播
* - 如果不是,调用 Canvas 原始的鼠标按下处理
*
*/
mouseDownHandler(e: PointerEventLike) {
const canvas = this.timeline.canvas;
/**
* 获取鼠标在视口坐标系中的位置
* getViewportPoint 返回的是相对于画布左上角的坐标,
* 不受 viewportTransform 影响,适合用于滚动条命中检测
*/
const p = canvas.getViewportPoint(e);
if (this.bar) {
/**
* 命中检测:判断鼠标坐标是否在滑块矩形范围内
*/
const hit =
p.x >= this.bar.left &&
p.x <= this.bar.right &&
p.y >= this.bar.top &&
p.y <= this.bar.bottom;
if (hit) {
/**
* 进入拖拽模式
* 记录拖拽起始状态:
* - dragStartX: 鼠标起始 X 坐标
* - dragStartScroll: 起始滚动位置
*
* 后续在 mouseMoveHandler 中根据鼠标移动距离计算新的滚动位置
*/
this.dragging = true;
this.dragStartX = p.x;
this.dragStartScroll = this.timeline.scrollX;
return; // 阻止事件继续传播,不调用原始处理函数
}
}
/**
* 未命中滚动条,调用 Canvas 原始的鼠标按下处理
* 通过原型链调用原始方法,确保 Fabric.js 的正常交互(如选择对象)不受影响
*/
const proto = Canvas.prototype as unknown as {
__onMouseDown: (e: PointerEventLike) => void;
};
return proto.__onMouseDown.call(canvas, e);
}
/**
* 鼠标移动事件处理
* 如果处于拖拽模式,根据鼠标移动距离更新滚动位置;
* 否则调用 Canvas 原始的鼠标移动处理。
*/
mouseMoveHandler(e: PointerEventLike) {
/**
* 非拖拽状态,调用原始处理函数
*/
if (!this.dragging || !this.bar) {
const proto = Canvas.prototype as unknown as {
_onMouseMove: (e: PointerEventLike) => void;
};
return proto._onMouseMove.call(this.timeline.canvas, e);
}
const canvas = this.timeline.canvas;
const p = canvas.getViewportPoint(e);
/**
* 计算滚动位置
* 滚动距离映射:
* - 鼠标移动距离(像素) -> 滚动距离(像素)
* - 比例 = 鼠标移动距离 / 可滑动距离
* - 滚动距离 = 比例 * 最大滚动距离
*
* 这样可以实现滑块移动 1 像素,内容滚动相应比例的距离
*/
const delta = p.x - this.dragStartX;
const maxOffset = this.bar.maxOffset;
const trackAvailable = Math.max(1, this.bar.trackWidth - this.bar.barWidth);
const scrollDelta = (delta / trackAvailable) * maxOffset;
/**
* 更新滚动位置
* setScrollX 内部会处理边界约束(不超过最大滚动距离)
*/
this.timeline.setScrollX(this.dragStartScroll + scrollDelta);
}
/**
* 鼠标抬起事件处理
* 如果处于拖拽模式,结束拖拽;
* 否则调用 Canvas 原始的鼠标抬起处理。
*/
mouseUpHandler(e: PointerEventLike) {
/**
* 非拖拽状态,调用原始处理函数
*/
if (!this.dragging) {
const proto = Canvas.prototype as unknown as {
_onMouseUp: (e: PointerEventLike) => void;
};
proto._onMouseUp.call(this.timeline.canvas, e);
}
/**
* 重置 dragging 标志,后续鼠标移动不再触发滚动
*/
this.dragging = false;
}
}
import { Canvas, Rect } from 'fabric';
import { useEffect, useRef } from 'react';
import { HorizontalScrollbar } from '../../core/timeline/scrollbar';
export default function ScrollBarDemo() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = new Canvas(canvasRef.current, {
width: 800,
height: 200,
backgroundColor: '#0f172a',
selection: false
});
const timeline = {
canvas,
contentWidth: 2000,
scrollX: 0,
setScrollX(x: number) {
this.scrollX = Math.max(
0,
Math.min(x, this.contentWidth - canvas.width)
);
canvas.setViewportTransform([1, 0, 0, 1, -this.scrollX, 0]);
canvas.requestRenderAll();
}
} as any;
const scrollbar = new HorizontalScrollbar(timeline);
const rect1 = new Rect({
left: 50,
top: 50,
width: 200,
height: 60,
fill: '#134e4a',
rx: 6,
ry: 6,
selectable: true,
hasControls: true
});
rect1.on('moving', () => {
const right = rect1.left! + rect1.width!;
const newContentWidth = Math.max(canvas.width, right + 50);
timeline.contentWidth = newContentWidth;
canvas.requestRenderAll();
});
canvas.add(rect1);
return () => {
scrollbar.dispose();
canvas.dispose();
};
}, []);
return <canvas ref={canvasRef} />;
}
参考线绘制
![]()
整体流程是怎样的?可以理解成 5 步:
- 清掉旧的辅助线
- 收集画布上所有“可当参照物”的边
- 计算当前拖拽物体的边
- 找最近的一条线(距离小于 10px)
- 画辅助线 + 修正位置(吸附)
第一步:清理旧辅助线
clearAuxiliaryObjects()每次拖动都会重新计算吸附线,所以必须先把旧的删掉,避免画布上越画越多线,它的做法是:
- 遍历所有对象
- 找到带
isAlignmentAuxiliary标记的 - 删除
第二步:收集“所有可吸附的边”
getLineGuideStops()它做的事情是:
- 遍历画布所有可见对象
- 跳过当前拖动对象
- 跳过辅助线本身
- 获取每个对象的 boundingRect
最终得到一个列表:
[
{ val: 100 },
{ val: 250 },
{ val: 300 },
...
]
第三步:计算当前对象的吸附边
getObjectSnappingEdges()它只算两个东西:当前对象的左边、当前对象的右边
并记录:
guide // 当前边的位置
offset // 实际坐标偏移
snap // 是 start 还是 end
第四步:找最近的一条线
diff = Math.abs(lineGuide.val - itemBound.guide)
如果:diff < 10说明已经足够接近,然后把所有满足条件的候选放进数组进行排序,取最小的那个,这样可以避免多条线同时吸附导致抖动
resultV.sort((a, b) => a.diff - b.diff)[0]
第五步:画对齐线
new Line([x, 0, x, 2000])
import { Line, type Canvas, type FabricObject } from 'fabric';
import { AlignmentAuxiliary, LineGuide, TimelineObject, Guide } from '../types';
/**
* 清除画布上的所有辅助对齐线
*/
export const clearAuxiliaryObjects = (
canvas: Canvas,
allObjects: FabricObject[]
) => {
allObjects.forEach(obj => {
if ((obj as AlignmentAuxiliary).isAlignmentAuxiliary) canvas.remove(obj);
});
};
/**
* 计算对象的对齐停靠点
* 返回对象左边界与右边界的可吸附位置
*/
export const getStopsForObject = (
start: number,
distance: number,
drawStart: number,
drawDistance: number
) => {
const stops = [start, start + distance];
return stops.map(stop => ({
val: stop,
start: drawStart,
end: drawStart + drawDistance
}));
};
/**
* 获取画布上所有可用作对齐基准的停靠点
* 仅收集可见的 Clip,对齐线本身不会参与计算
*/
export const getLineGuideStops = (skipShapes: FabricObject[], canvas: Canvas) => {
const vertical: LineGuide[] = [];
canvas
.getObjects()
.filter(o => o.visible && (o as TimelineObject).elementId)
.forEach(guideObject => {
if (
skipShapes.includes(guideObject) ||
(guideObject as AlignmentAuxiliary).isAlignmentAuxiliary
) {
return;
}
const box = guideObject.getBoundingRect();
vertical.push(
...getStopsForObject(box.left, box.width, box.top, box.height)
);
});
return { vertical, horizontal: [] as LineGuide[] };
};
/**
* 获取当前拖拽对象的吸附边缘
* 只计算水平吸附(左边界、右边界)
*/
export const getObjectSnappingEdges = (target: FabricObject) => {
const rect = target.getBoundingRect();
return {
vertical: [
{
guide: Math.round(rect.left),
offset: Math.round((target.left || 0) - rect.left),
snap: 'start'
},
{
guide: Math.round(rect.left + rect.width),
offset: Math.round((target.left || 0) - rect.left - rect.width),
snap: 'end'
}
],
horizontal: [] as Array<{ guide: number; offset: number; snap: string }>
};
};
/**
* 计算当前位置最接近的引导对齐线
* 仅返回最接近的垂直引导,避免多条线干扰
*/
export const getGuides = (
lineGuideStops: { vertical: LineGuide[]; horizontal: LineGuide[] },
itemBounds: {
vertical: { guide: number; offset: number; snap: string }[];
horizontal: { guide: number; offset: number; snap: string }[];
}
) => {
const resultV: Array<{ lineGuide: number; diff: number; offset: number }> =
[];
lineGuideStops.vertical.forEach(lineGuide => {
itemBounds.vertical.forEach(itemBound => {
const diff = Math.abs(lineGuide.val - itemBound.guide);
if (diff < 10) {
resultV.push({
lineGuide: lineGuide.val,
diff,
offset: itemBound.offset
});
}
});
});
const guides: Guide[] = [];
const minV = resultV.sort((a, b) => a.diff - b.diff)[0];
if (minV) {
guides.push({
lineGuide: minV.lineGuide,
offset: minV.offset,
orientation: 'V'
});
}
return guides;
};
/**
* 在画布上绘制对齐线
* 线条绘制在主画布之上,并标记为辅助对象
*/
export const drawGuides = (guides: Guide[], canvas: Canvas) => {
guides.forEach(lineGuide => {
if (lineGuide.orientation === 'V') {
const line = new Line(
[lineGuide.lineGuide, 0, lineGuide.lineGuide, 2000],
{
strokeWidth: 2,
stroke: '#ffffff',
strokeLineCap: 'square',
selectable: false,
evented: false,
objectCaching: false
}
);
(line as AlignmentAuxiliary).isAlignmentAuxiliary = true;
canvas.add(line);
}
});
};
import { Canvas, Rect } from 'fabric';
import { useEffect, useRef } from 'react';
import {
clearAuxiliaryObjects,
drawGuides,
getGuides,
getLineGuideStops,
getObjectSnappingEdges
} from '../../core/timeline/utils/guidelines';
export default function GuidelinesDemo() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = new Canvas(canvasRef.current, {
width: 800,
height: 300,
backgroundColor: '#0f172a',
selection: false
});
const rect1 = new Rect({
left: 100,
top: 100,
width: 150,
height: 60,
fill: '#134e4a',
rx: 6,
ry: 6,
selectable: true,
hasControls: false
});
(rect1 as any).elementId = 'rect1';
const rect2 = new Rect({
left: 350,
top: 100,
width: 200,
height: 60,
fill: '#14532d',
rx: 6,
ry: 6,
selectable: true,
hasControls: false
});
(rect2 as any).elementId = 'rect2';
const rect3 = new Rect({
left: 600,
top: 100,
width: 120,
height: 60,
fill: '#1e1b4b',
rx: 6,
ry: 6,
selectable: true,
hasControls: false
});
(rect3 as any).elementId = 'rect3';
canvas.add(rect1, rect2, rect3);
canvas.on('object:moving', e => {
const target = e.target;
if (!target) return;
clearAuxiliaryObjects(canvas, canvas.getObjects());
const lineGuideStops = getLineGuideStops([target], canvas);
const itemBounds = getObjectSnappingEdges(target);
const guides = getGuides(lineGuideStops, itemBounds);
if (guides.length > 0) {
const guide = guides[0];
target.set({
left: guide.lineGuide + guide.offset
});
target.setCoords();
drawGuides(guides, canvas);
}
});
canvas.on('mouse:up', () => {
clearAuxiliaryObjects(canvas, canvas.getObjects());
});
return () => {
canvas.dispose();
};
}, []);
return <canvas ref={canvasRef} />;
}
时间轴缩放
核心代码:
const timeAtMouse = mouseX / oldPixelsPerSecond;
const newMouseX = timeAtMouse * this.pixelsPerSecond;
const newScrollX = newMouseX - (mouseX - this.scrollX);
第一步:算出鼠标指向的时间点时间 = 像素 / 像素每秒
第二步:缩放后,这个时间应该在哪个像素?新像素 = 时间 * 新像素每秒
第三步:算需要补偿多少滚动newScrollX = 新像素位置 - 视口中的鼠标位置
// 监听滚轮事件,支持横向滚动与 Ctrl + 滚轮缩放
this.canvas.on('mouse:wheel', opt => {
const e = opt.e;
if (e.ctrlKey) {
// Ctrl + 滚轮:以鼠标位置为锚点缩放,保持时间点对齐
const delta = e.deltaY;
const pointer = this.canvas.getPointer(e);
this.handleZoom(delta, pointer.x);
} else {
// 普通滚轮:横向滚动(优先横向 delta)
const delta =
Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
this.setScrollX(this.scrollX + delta);
}
e.preventDefault();
e.stopPropagation();
});
/**
* 处理时间轴缩放逻辑
* @param delta 滚轮增量
* @param mouseX 鼠标在画布上的 X 坐标(包含滚动偏移)
*/
handleZoom(delta: number, mouseX: number) {
const zoomFactor = 1.1;
const oldPixelsPerSecond = this.pixelsPerSecond;
// 计算新的缩放比例
if (delta > 0) {
this.pixelsPerSecond /= zoomFactor;
} else {
this.pixelsPerSecond *= zoomFactor;
}
/** 最小缩放(像素/秒) */
const minPixelsPerSecond = 10;
/** 最大缩放(像素/秒),用于支持帧级显示 */
const maxPixelsPerSecond = 3000;
this.pixelsPerSecond = Math.max(
minPixelsPerSecond,
Math.min(maxPixelsPerSecond, this.pixelsPerSecond)
);
if (Math.abs(oldPixelsPerSecond - this.pixelsPerSecond) < 0.01) return;
// 关键逻辑:保持鼠标指针下的时间点在缩放后位置不变
// 时间点 = (mouseX) / oldPixelsPerSecond
// 缩放后的像素位置 = 时间点 * newPixelsPerSecond
// 滚动补偿 = 缩放后的像素位置 - (mouseX - scrollX)
const timeAtMouse = mouseX / oldPixelsPerSecond;
const newMouseX = timeAtMouse * this.pixelsPerSecond;
const newScrollX = newMouseX - (mouseX - this.scrollX);
// 更新所有 Clip 的位置和宽度
this.updateClipsVisualsFromTime();
// 更新内容宽度(轨道背景也会随之更新)
this.updateContentWidth();
// 应用新的滚动位置
this.setScrollX(newScrollX);
this.canvas.requestRenderAll();
}
/**
* 设置时间轴横向滚动位置
* 通过 viewportTransform 将所有对象整体平移
*/
setScrollX(value: number) {
const maxScroll = Math.max(0, this.contentWidth - this.canvas.width);
const next = Math.max(0, Math.min(maxScroll, value));
if (Math.abs(next - this.scrollX) < 0.5) return;
this.scrollX = next;
const vpt = (
this.canvas.viewportTransform || ([1, 0, 0, 1, 0, 0] as Mat2D)
).slice(0) as Mat2D;
// 使用 viewportTransform 平移内容
vpt[4] = -this.scrollX;
vpt[5] = 0;
this.canvas.setViewportTransform(vpt);
// this.canvas.getObjects().forEach(obj => {
// // 修正控制点位置,避免滚动时偏移
// if (obj.hasControls) obj.setCoords();
// });
if (this.ruler) this.ruler.render(); // 同步更新刻度尺
this.canvas.requestRenderAll();
}
拖拽的核心代码(包括轨道的裁剪)
/**
* 配置所有拖拽、缩放交互逻辑及约束
* 包含缩放约束、防重叠、对齐辅助线与轨道吸附
*/
setupDragSnapping() {
/**
* 缩放事件处理
* 核心功能:
* 1. 约束最小宽度,避免 Clip 过小
* 2. 防止 Clip 跨越相邻 Clip(防重叠)
* 3. 对于视频/音频 Clip,实现裁剪式缩放(拖动端点改变裁剪窗口)
* 4. 约束裁剪范围不超过媒体源时长
*/
this.canvas.on('object:scaling', opt => {
const target = opt.target as TimelineObject;
if (!target || !target.elementId) return;
const transform = opt.transform;
if (!transform) return;
// 只处理左右控制点
const corner = transform.corner;
if (corner !== 'ml' && corner !== 'mr') return;
const originalWidth = target.width || 0;
if (originalWidth === 0) return;
const timelineTarget = target as TimelineObject;
const isMediaClip = ['video', 'audio'].includes(timelineTarget.clipType);
if (isMediaClip) {
const mediaTarget = target as TimelineObject;
if (mediaTarget.trimStart === undefined) mediaTarget.trimStart = 0;
if (mediaTarget.trimEnd === undefined || mediaTarget.trimEnd === 0) {
mediaTarget.trimEnd = mediaTarget.duration ?? 0;
}
// 记录缩放开始时的裁剪窗口,用于计算裁剪增量
// 这样可以确保"回拉"操作不会超过原始裁剪量
if (mediaTarget.__trimStartOriginal === undefined) {
mediaTarget.__trimStartOriginal = mediaTarget.trimStart ?? 0;
}
if (mediaTarget.__trimEndOriginal === undefined) {
mediaTarget.__trimEndOriginal = mediaTarget.trimEnd ?? 0;
}
}
// 获取同一轨道上的其他 Clip,用于防重叠检测
const trackIndex = this.getTrackIndexForObject(target);
const siblings = this.canvas
.getObjects()
.filter(obj => (obj as TimelineObject).elementId && obj !== target)
.map(obj => obj as TimelineObject)
.filter(obj => this.getTrackIndexForObject(obj) === trackIndex)
.map(obj => ({ obj, ...this.getClipBounds(obj) }))
.sort((a, b) => a.left - b.left);
// 记录缩放开始时的位置和尺寸
const startLeft = transform.original.left;
const startScaleX = transform.original.scaleX || 1;
const startRight = startLeft + originalWidth * startScaleX;
// 查找左右相邻的 Clip
let leftNeighbor: { left: number; right: number } | null = null;
let rightNeighbor: { left: number; right: number } | null = null;
for (const clip of siblings) {
if (clip.left < startLeft) {
leftNeighbor = clip;
continue;
}
rightNeighbor = clip;
break;
}
// 计算最小缩放比例,确保 Clip 不会太小
const minScale = MIN_CLIP_WIDTH / originalWidth;
// ========== 右侧控制点缩放(mr)==========
// 拖动右侧控制点:左边界固定,改变右边界
// 对于媒体类型:trimStart 保持不变,trimEnd 随宽度变化
if (corner === 'mr') {
// 计算最大右边界(受相邻 Clip 或内容宽度限制)
const maxRight = rightNeighbor ? rightNeighbor.left : this.contentWidth;
const maxWidth = maxRight - startLeft;
let maxScale = maxWidth / originalWidth;
// 媒体类型额外约束:不能超过源文件末尾
if (isMediaClip) {
const mediaTarget = target as TimelineObject;
const baseTrimStart = mediaTarget.__trimStartOriginal ?? 0;
const sourceDuration = mediaTarget.sourceDuration || 0;
if (sourceDuration > 0) {
// 从当前 trimStart 到源文件末尾的剩余时长
const maxDurationBySource = sourceDuration - baseTrimStart;
const maxScaleBySource =
(maxDurationBySource * this.pixelsPerSecond) / originalWidth;
maxScale = Math.min(maxScale, maxScaleBySource);
}
}
// 约束缩放比例在有效范围内
let newScaleX = timelineTarget.scaleX || 1;
if (newScaleX < minScale) newScaleX = minScale;
if (newScaleX > maxScale) newScaleX = maxScale;
// 应用缩放:左边界锚定,只改变宽度
target.set({
scaleX: newScaleX,
left: startLeft
});
if (isMediaClip) {
const mediaTarget = target as TimelineObject;
const baseTrimStart = mediaTarget.__trimStartOriginal ?? 0;
const sourceDuration = mediaTarget.sourceDuration || 0;
const finalWidth =
(timelineTarget.width || 0) * (timelineTarget.scaleX || 1);
const finalDuration = finalWidth / this.pixelsPerSecond;
// 右侧缩放:trimStart 固定,trimEnd 随宽度增加
mediaTarget.trimStart = baseTrimStart;
mediaTarget.trimEnd =
sourceDuration > 0
? Math.min(baseTrimStart + finalDuration, sourceDuration)
: baseTrimStart + finalDuration;
}
} else if (corner === 'ml') {
// ========== 左侧控制点缩放(ml)==========
// 拖动左侧控制点:右边界固定,改变左边界
// 对于媒体类型:trimEnd 保持不变,trimStart 随宽度变化
// 计算最小左边界(受相邻 Clip 或 0 限制)
const minLeft = leftNeighbor ? leftNeighbor.right : 0;
const maxWidth = startRight - minLeft;
let maxScale = maxWidth / originalWidth;
// 媒体类型额外约束:不能超过源文件开头
if (isMediaClip) {
const mediaTarget = target as TimelineObject;
const baseTrimEnd = mediaTarget.__trimEndOriginal ?? 0;
const sourceDuration = mediaTarget.sourceDuration || 0;
// 从源文件开头到当前 trimEnd 的最大可用时长
const maxDurationBySource = sourceDuration
? Math.min(baseTrimEnd || sourceDuration, sourceDuration)
: baseTrimEnd;
if (maxDurationBySource > 0) {
const maxScaleBySource =
(maxDurationBySource * this.pixelsPerSecond) / originalWidth;
maxScale = Math.min(maxScale, maxScaleBySource);
}
}
// 约束缩放比例在有效范围内
let newScaleX = timelineTarget.scaleX || 1;
if (newScaleX < minScale) newScaleX = minScale;
if (newScaleX > maxScale) newScaleX = maxScale;
// 应用缩放:右边界锚定,改变左边界位置
target.set({
scaleX: newScaleX,
left: startRight - originalWidth * newScaleX
});
// 更新媒体类型的裁剪窗口
if (isMediaClip) {
const mediaTarget = target as TimelineObject;
const baseTrimEnd = mediaTarget.__trimEndOriginal ?? 0;
const finalWidth =
(timelineTarget.width || 0) * (timelineTarget.scaleX || 1);
const finalDuration = finalWidth / this.pixelsPerSecond;
if (baseTrimEnd > 0) {
// 左侧缩放:trimEnd 固定,trimStart 随宽度变化
// 向左拖动 = 扩展开头 = trimStart 减小
// 向右拖动 = 裁剪开头 = trimStart 增加
mediaTarget.trimEnd = baseTrimEnd;
mediaTarget.trimStart = Math.max(0, baseTrimEnd - finalDuration);
}
}
}
// 同步更新时间属性(将像素转换为秒)
const finalWidth = (target.width || 0) * (target.scaleX || 1);
target.startTime = (target.left || 0) / this.pixelsPerSecond;
target.duration = finalWidth / this.pixelsPerSecond;
// 更新内容宽度并重新渲染
this.updateContentWidth();
target.setCoords();
this.canvas.requestRenderAll();
});
// 2. 移动过程中:执行辅助线吸附和重叠修正
this.canvas.on('object:moving', opt => {
const target = opt.target as TimelineObject;
if (!target || !target.elementId) return;
// 辅助对齐线吸附逻辑
const allObjects = this.canvas.getObjects();
const lineGuideStops = getLineGuideStops([target], this.canvas);
const itemBounds = getObjectSnappingEdges(target);
const guides = getGuides(lineGuideStops, itemBounds);
clearAuxiliaryObjects(this.canvas, allObjects);
if (guides.length > 0) drawGuides(guides, this.canvas);
guides.forEach(lineGuide => {
if (lineGuide.orientation === 'V') {
target.set('left', lineGuide.lineGuide + lineGuide.offset);
}
});
// 实时防重叠修正
const previousLeft = target.__prevLeft;
const currentLeft = target.left || 0;
const direction =
previousLeft === undefined || currentLeft >= previousLeft ? 1 : -1;
this.resolveClipOverlap(target, direction);
target.__prevLeft = target.left || 0;
// 同步更新时间属性
target.startTime = (target.left || 0) / this.pixelsPerSecond;
this.updateContentWidth(); // 拖拽时实时更新内容宽度
target.setCoords();
this.canvas.requestRenderAll();
});
// 3. 交互结束后:处理轨道增删、回弹及坐标校准
this.canvas.on('object:modified', (opt: TimelineEvent) => {
const target = opt.target as TimelineObject;
if (!target || !target.elementId) return;
const width = (target.width || 0) * (target.scaleX || 1);
const height = (target.height || 0) * (target.scaleY || 1);
const centerY = (target.top || 0) + height / 2;
// --- 动态轨道判定逻辑 ---
const firstTrackTop = this.trackTops[0];
const lastTrackTop = this.trackTops[this.trackCount - 1];
if (centerY < firstTrackTop) {
// 拖动到顶部边缘以上:在最上方插入新轨道
this.canvas.getObjects().forEach(obj => {
const t = obj as TimelineObject;
if (t.elementId && t.trackIndex !== undefined) {
t.trackIndex += 1;
}
});
target.trackIndex = 0;
} else if (centerY > lastTrackTop + TRACK_HEIGHT) {
// 拖动到底部边缘以下:在最下方新增轨道
target.trackIndex = this.trackCount;
} else {
// 落在现有轨道范围内:吸附到最近轨道
target.trackIndex = this.getClosestTrackIndex(centerY);
}
const trackTop = this.getTrackTop(target.trackIndex);
target.set({
width: Math.max(MIN_CLIP_WIDTH, width),
top: trackTop + (TRACK_HEIGHT - CLIP_HEIGHT) / 2,
scaleX: 1
});
// 最终重叠检测:若空间仍不足,触发回弹逻辑
const fits = this.resolveClipOverlap(target, 1);
if (!fits && target.__originalLeft !== undefined) {
target.set({
left: target.__originalLeft,
top: target.__originalTop
});
// 恢复后同步 trackIndex 并执行对齐
const oldCenterY = (target.top || 0) + height / 2;
target.trackIndex = this.getClosestTrackIndex(oldCenterY);
this.resolveClipOverlap(target, 1);
}
// 执行轨道清理及重新排列
this.syncTrackIndices();
this.updateContentWidth(); // 交互结束后同步内容宽度
// 同步最终的时间属性
const finalWidth = (target.width || 0) * (target.scaleX || 1);
target.startTime = (target.left || 0) / this.pixelsPerSecond;
target.duration = finalWidth / this.pixelsPerSecond;
// 清理交互临时属性
target.__originalLeft = undefined;
target.__originalTop = undefined;
target.__prevLeft = undefined;
// 清理裁剪交互基准,避免影响下一次缩放
target.__trimStartOriginal = undefined;
target.__trimEndOriginal = undefined;
target.setCoords();
this.canvas.requestRenderAll();
});
// 4. 鼠标抬起:清除辅助线
this.canvas.on('mouse:up', () => {
clearAuxiliaryObjects(this.canvas, this.canvas.getObjects());
this.canvas.requestRenderAll();
});
}
/**
* 核心防重叠逻辑:
* 在移动或缩放过程中,检测并修正位置,确保 Clip 不会与其他 Clip 发生重叠
* @param target 当前操作的对象
* @param direction 移动方向(1:向右,-1:向左)
* @returns 是否能完整放下该对象
*/
resolveClipOverlap(target: TimelineObject, direction: number): boolean {
const trackIndex = this.getTrackIndexForObject(target);
const bounds = this.getClipBounds(target);
// 获取同一轨道上的所有其他 Clip 并按左边界排序
const siblings = this.canvas
.getObjects()
.filter(obj => (obj as TimelineObject).elementId && obj !== target)
.map(obj => obj as TimelineObject)
.filter(obj => this.getTrackIndexForObject(obj) === trackIndex)
.map(obj => ({ obj, ...this.getClipBounds(obj) }))
.sort((a, b) => a.left - b.left);
let leftNeighbor: { left: number; right: number } | null = null;
let rightNeighbor: { left: number; right: number } | null = null;
// 寻找左右最近邻居
for (const clip of siblings) {
if (clip.left < bounds.left) {
leftNeighbor = clip;
continue;
}
rightNeighbor = clip;
break;
}
// 计算可用空间范围
const leftBound = leftNeighbor ? leftNeighbor.right : 0;
const rightBound = rightNeighbor
? rightNeighbor.left - bounds.width
: Number.POSITIVE_INFINITY;
let nextLeft = bounds.left;
/** 检测空间是否足够 */
const fits = rightBound >= leftBound;
if (!fits) {
// 空间不足时,根据移动方向推送到边界
nextLeft = direction >= 0 ? rightBound : leftBound;
} else {
// 空间足够时,确保不越过邻居边界
if (nextLeft < leftBound) nextLeft = leftBound;
if (nextLeft > rightBound) nextLeft = rightBound;
}
// 时间轴总范围约束(允许拖拽到整个时间轴容量范围)
// const absoluteMaxRight = this.contentWidth;
// const maxLeft = Math.max(0, absoluteMaxRight - bounds.width);
if (nextLeft < 0) nextLeft = 0;
// if (nextLeft > maxLeft) nextLeft = maxLeft;
target.set('left', nextLeft);
return fits;
}
Vue生态精选篇:Element Plus 的“企业后台常用组件”用法扫盲
同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
一、选型与定位
- Element Plus:面向 Vue 3 + TypeScript 的 UI 组件库,适合管理后台、中台、后台系统。
- 为什么用组件库而不是手写? 统一规范、减少重复开发、内置表单校验、表格、弹窗等常见能力。
- 本文涉及组件:Form、Table、Dialog、Message/MessageBox、Upload。
二、表单 Form:数据收集与校验
2.1 核心概念
Form 的作用:收集、校验、提交 数据,包含输入框、选择器、日期等。
表单的三层结构:
- el-form:表单容器,绑定数据和校验规则
- el-form-item:单个表单项,承载 label、校验、布局
- el-input / el-select 等:具体输入控件
2.2 正确用法示例
<template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
@submit.prevent
>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="请输入密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">提交</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formRef = ref()
const form = reactive({
username: '',
password: ''
})
// 校验规则:字段名要与 form 中的属性、el-form-item 的 prop 完全一致
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' }
]
}
const handleSubmit = async () => {
// validate 返回 Promise,通过则无参数,失败则返回校验错误
try {
await formRef.value.validate()
console.log('校验通过,提交数据:', form)
// 这里调用接口提交
} catch (error) {
console.log('校验失败')
}
}
const handleReset = () => {
formRef.value.resetFields()
}
</script>
说明要点:
-
:model="form"绑定表单数据,注意是 :model,不是v-model -
:rules="rules"绑定校验规则 -
prop="username"绑定到表单项,用于关联 rules 中的字段 -
@submit.prevent防止回车键意外提交表单
2.3 常见踩坑
| 坑 | 错误写法 | 正确写法 |
|---|---|---|
| Form 绑定 | v-model="form" |
:model="form" |
| 不写 prop |
<el-form-item> 无 prop |
<el-form-item prop="username"> |
| prop 写错位置 | 写在 el-input 上 |
必须写在 el-form-item 上 |
| prop 与 rules 不一致 | rules 里是 name,prop 是 username
|
两者字段名完全一致 |
记住:el-form 用 :model、el-form-item 必须有 prop、prop 与 rules 字段名一致。
2.4 常用 API
-
validate():整表校验 -
validateField(prop):校验单个字段 -
resetFields():重置表单 -
clearValidate():清除校验状态
三、表格 Table:列表展示
3.1 核心概念
Table 用于展示列表数据,支持排序、分页、选择、展开等。
3.2 基础用法示例
<template>
<el-table
:data="tableData"
stripe
border
style="width: 100%"
@selection-change="handleSelectionChange"
>
<!-- 多选列 -->
<el-table-column type="selection" width="55" />
<!-- 普通列 -->
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="age" label="年龄" width="80" />
<el-table-column prop="address" label="地址" show-overflow-tooltip />
<!-- 自定义列 -->
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
import { ref } from 'vue'
const tableData = ref([
{ id: 1, name: '张三', age: 28, address: '上海市浦东新区某某路100号', status: 1 },
{ id: 2, name: '李四', age: 32, address: '北京市朝阳区某某大街200号', status: 0 }
])
const handleSelectionChange = (selection) => {
console.log('选中的行:', selection)
}
const handleEdit = (row) => {
console.log('编辑', row)
}
const handleDelete = (row) => {
console.log('删除', row)
}
</script>
说明要点:
-
:data绑定数据数组,每一行是一个对象 -
prop对应数据字段名,决定显示哪个字段 -
show-overflow-tooltip:内容过长时显示省略号并悬浮显示完整内容 -
#default="{ row }":插槽提供当前行数据
3.3 配置选型建议
| 场景 | 推荐配置 |
|---|---|
| 数据较多 | 加 height 或 max-height 固定高度,出现纵向滚动 |
| 树形数据 | 使用 row-key + tree-props
|
| 需要合计 |
show-summary + summary-method
|
| 列宽不稳定 | 设置 width 或 min-width,避免抖动 |
| 多选 |
type="selection" + @selection-change
|
3.4 常见踩坑
-
表格数据不更新:确保
tableData是响应式的(如ref),修改后要触发更新 -
树形表格:必须设置
row-key为唯一字段(如id) -
固定列:
fixed="right"或fixed="left"时,注意右侧固定列写在最后
四、弹窗 Dialog:模态对话框
4.1 核心概念
Dialog 用于在保留当前页面的前提下,弹出一个模态层展示内容,常用于表单弹窗、详情、确认等。
4.2 基础用法示例
<template>
<el-button @click="dialogVisible = true">打开弹窗</el-button>
<el-dialog
v-model="dialogVisible"
title="编辑用户"
width="500px"
:close-on-click-modal="false"
:before-close="handleBeforeClose"
@opened="handleOpened"
>
<!-- 弹窗内容 -->
<el-form ref="formRef" :model="form" :rules="rules">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
const dialogVisible = ref(false)
const formRef = ref()
const form = reactive({ username: '' })
const rules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }] }
// 弹窗关闭前:可做二次确认、校验等
const handleBeforeClose = (done) => {
// 简单示例:直接关闭
done()
// 如需确认:ElMessageBox.confirm('确定关闭?').then(() => done()).catch(() => {})
}
// 弹窗打开动画结束后
const handleOpened = () => {
formRef.value?.clearValidate()
}
// 关闭时清空表单(按需)
watch(dialogVisible, (val) => {
if (!val) {
form.username = ''
}
})
const handleConfirm = async () => {
try {
await formRef.value.validate()
// 提交逻辑
dialogVisible.value = false
} catch (e) {
// 校验失败
}
}
</script>
说明要点:
-
v-model="dialogVisible"控制显示/隐藏 -
:close-on-click-modal="false":点击遮罩不关闭,避免误关 -
before-close:可做二次确认、阻止关闭 -
#footer:自定义底部按钮
4.3 常见配置选型
| 配置 | 说明 | 建议 |
|---|---|---|
| destroy-on-close | 关闭时销毁内容 | 表单弹窗建议开启,避免数据残留 |
| close-on-click-modal | 点击遮罩关闭 | 表单弹窗建议关闭 |
| append-to-body | 挂载到 body | 有嵌套弹窗时建议开启 |
五、消息 Message 与 MessageBox
5.1 ElMessage:轻量提示
用于操作后的简单反馈(成功、失败、警告等),通常显示几秒后自动消失。
import { ElMessage } from 'element-plus'
// 成功
ElMessage.success('保存成功')
// 错误
ElMessage.error('保存失败,请重试')
// 警告
ElMessage.warning('请先填写必填项')
// 自定义
ElMessage({
message: '操作成功',
type: 'success',
duration: 3000,
showClose: true
})
5.2 ElMessageBox:确认与输入
用于需要用户确认或输入的场景,比 Dialog 更轻量。
import { ElMessageBox } from 'element-plus'
// 确认删除
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确定要删除「${row.name}」吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
// 用户点击确定
await deleteApi(row.id)
ElMessage.success('删除成功')
} catch (e) {
// 用户点击取消或关闭
}
}
// 简单提示(类似 alert)
ElMessageBox.alert('操作完成', '提示')
5.3 选型建议
| 场景 | 用 Message | 用 MessageBox |
|---|---|---|
| 保存成功、失败提示 | ✅ | |
| 删除前确认 | ✅ | |
| 需要用户输入 | ✅(prompt) | |
| 复杂表单、多内容 | 改用 Dialog |
六、上传 Upload:文件上传
6.1 核心概念
Upload 支持自动上传和手动上传:自动上传是选完即传,手动上传是选完后由按钮触发上传。
6.2 自动上传(选完即传)
<template>
<el-upload
action="/api/upload"
:headers="uploadHeaders"
:on-success="handleSuccess"
:on-error="handleError"
:before-upload="beforeUpload"
>
<el-button type="primary">点击上传</el-button>
</el-upload>
</template>
<script setup>
import { reactive } from 'vue'
// 请求头,常用于 Token
const uploadHeaders = reactive({
Authorization: `Bearer ${localStorage.getItem('token')}`
})
// 上传前:校验格式、大小
const beforeUpload = (file) => {
const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG) {
ElMessage.error('只能上传 JPG/PNG 格式')
return false // 阻止上传
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB')
return false
}
return true
}
const handleSuccess = (response, file, fileList) => {
ElMessage.success('上传成功')
// response 一般为后端返回的 URL 等
}
const handleError = () => {
ElMessage.error('上传失败')
}
</script>
6.3 手动上传(和表单一起提交)
<template>
<el-form :model="form">
<el-form-item label="附件">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="3"
:on-exceed="handleExceed"
:on-change="handleChange"
>
<el-button type="primary">选择文件</el-button>
</el-upload>
</el-form-item>
<el-button @click="submitForm">提交表单(含文件)</el-button>
</el-form>
</template>
<script setup>
import { ref } from 'vue'
const uploadRef = ref()
const form = ref({ files: [] })
// 手动上传时,选中的文件会进入 fileList,需要自己调用接口上传
const handleChange = (file, fileList) => {
form.value.files = fileList
}
const handleExceed = () => {
ElMessage.warning('最多上传 3 个文件')
}
const submitForm = async () => {
const formData = new FormData()
form.value.files.forEach(f => {
formData.append('files', f.raw)
})
// 再 append 其他表单字段...
// await uploadApi(formData)
}
</script>
说明要点:
-
:auto-upload="false"关闭自动上传 -
on-change拿到选中的文件列表 - 手动上传时用
FormData组装并调用自己的接口
6.4 常见踩坑
| 坑 | 原因 | 处理 |
|---|---|---|
| before-upload 返回 false 仍上传 | 理解错误 | 返回 false 或 Promise.reject() 会阻止上传 |
| 上传后列表不更新 | 未绑定 file-list | 用 v-model:file-list 或 :file-list 绑定 |
| 跨域、Cookie | 未带凭证 | 设置 :with-credentials="true"
|
| 需要 Token | 接口要鉴权 | 通过 :headers 传入 |
七、小结
-
Form:用
:model+prop+rules,三者字段名一致 -
Table:
prop对数据字段,复杂展示用#default插槽 -
Dialog:用
v-model控制显隐,表单弹窗建议destroy-on-close - Message:轻量提示;MessageBox:确认、输入
-
Upload:自动上传用
action+ 钩子;手动上传用:auto-upload="false"+ 自定义提交
按上述方式选型和编码,可以避开大部分常见坑。如果你希望我按某一块(比如 Form、Table、Upload)再单独细化成一篇更长的教程,可以说明一下侧重点(例如:复杂表单、动态表格、多图上传等)。
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~
JavaScript回归基本功之---类型判断--typeof篇
在学习JavaScript的过程中,我们不难发现一件事,类型判断在我们日常的开发中是最基础也最容易踩坑的知识点之一,我们有时会产生一些困惑
1. 明明被判断的那个数据是一个数组,但 typeof 却返回了 object
2. 使用 instanceof 判断基本类型时得到了一个 false
3. 不知道该用什么方法能够精准的判断一个值的类型
别急,本系列文章中我将带你重新复习一遍 JavaScript 中类型判断的各种方法,从 typeof 到 Object.prototype.toString.call()这些使用场景及底层实现原理,以及开发过程会遇到的难题,让你能够 回归基本功 ,再一次重新认识我们的老朋友 --JavaScript
一、学会类型判断首先我们来了解一下JS中的各种数据类型
1.1 基本类型(原始类型)
- string : 字符串类型,如: "hello world"
- number : 数字类型 "123" , "1.1"
- boolean : 布尔类型 ,true 或 false,
- undefined : 未定义类型,变量声明但为赋值时的类型
- null : 空类型,表示一个空对象指针
- symbol :符号类型,表示唯一且不可变的值
- bigint: 大整数类型,用于表示超过 number 范围的整数
1.2 引用类型
- Object :普通对象
- Array:数组对象
- Function:函数对象
- Date:日期对象
1.3 基本类型与引用类型的区别
- 基本类型存储在栈的内存中,值不可改变。
- 引用类型存储在堆内存中,变量存储的是指向堆内存的引用地址
- 基本类型的比较是值的比较,引用类型的比较是引用的比较
二、了解了各种数据类型后,让我们来了解一些常用的类型判断方法
js中的类型判断方法有:
- typeof
- instanceof
- Object.prototype.toString.call()
- constructor
- Array.isArray
三、本篇文章着重要讲的就是 typeof 这一类型判断方法,typeof 是 JavaScript 中最基础的类型判断操作符,传入一个数据后,它会返回一个表示该传入数据类型的字符串。
![]()
typeof的工作原理
1. typeof 的判断基于 JavaScript 引擎内部的类型标签,每个值在内存中都有一个类型标签:
0: undefined
1: null
2: boolean
3: number
4: string
5: symbol
6: bigint
7: object
8: function
这就能解释为什么 typeof(null) "object" ,因为在js中,null的类型标签为 0 ,于是返回了"object" ,这是一个远古的 bug ,为了向后兼容一直保留至今没有进行修改
2. 什么时候选择使用typeof,为什么?
- 对于基本类型进行判断时(null与array除外),如:string、number、boolean、undefined、symbol 或 bigint 类型时,优先使用 typeof。
原因:typeof 对这些基本类型的判断精准且可靠,语法简单,可读性高,且由于typeof 能直接访问 js 引擎内部的类型标签,是最快的类型判断方法
- 当你需要判断一个值是否为函数类型时
原因: typeof 可以精确识别所有函数类型 ,包括普通函数,箭头函数,生成器函数,异步函数和类,相比于instanceof等其它判断方法,typeof 不需要考虑原型链的问题,且语法更简洁
3. 当你需要检查一个变量是否已经被声明,避免抛出 ReferenceError时
原因:typeof 是唯一可以安全检查未声明变量的方法,不会抛出 ReferenceError ,这是 typeof 独有的特性,是其它的类型判断方法都不具备的
- 当遇到性能敏感的场景时,即需要进行频繁的类型判断且对性能要求高的时候时
原因:typeof 对这些基本类型的判断精准且可靠,语法简单,可读性高,且由于typeof 能直接访问 js 引擎内部的类型标签,是最快的类型判断方法
3. 什么时候我们不使用typeof ?
- 当需要对 null 类型进行判断时
![]()
- 当需要对不同类型的对象进行区分时
![]()
四、总结一下typeof该类型判断方法
- 优先用于基本类型判断(除了null 以外)
- 用于函数类型判断
- 用于检查变量是否已经声明
- 性能敏感场景时优先使用
- 避免在对复杂对象类型判断时使用(优先使用 instanceof 或 Object.prototype.toString.call())
微前端沙箱隔离:qiankun 和 wujie 到底在争什么
微前端沙箱隔离:qiankun 和 wujie 到底在争什么
上个月接手一个老项目,四个团队各写各的,技术栈从 Vue2 到 React18 都有。领导一句"用微前端整合一下",我就开始了长达两周的沙箱隔离踩坑之旅。
问题的起点很简单:子应用 A 往 window 上挂了个 globalConfig,子应用 B 也挂了一个,然后就打架了。更离谱的是,子应用 C 的 CSS 里写了个 body { font-size: 14px !important },直接把主应用的样式干碎了。
这两个问题,一个是 JS 沙箱的事,一个是 CSS 隔离的事。qiankun 和 wujie 给出了完全不同的解法,背后的设计哲学也截然不同。
JS 沙箱:快照、代理、还是直接换个 window?
最朴素的思路:快照沙箱
qiankun 最早的沙箱方案简单粗暴——进子应用之前,把 window 上所有属性拍个快照存起来;子应用卸载时,把 window 恢复回去。
class SnapshotSandbox {
private snapshot: Record<string, any> = {}
private modifications: Record<string, any> = {}
activate() {
// 进场前:把当前 window 拍个照
for (const key in window) {
this.snapshot[key] = (window as any)[key]
}
}
deactivate() {
// 离场时:记录子应用改了啥,然后恢复 window
for (const key in window) {
if ((window as any)[key] !== this.snapshot[key]) {
this.modifications[key] = (window as any)[key] // 存下改动
;(window as any)[key] = this.snapshot[key] // 还原
}
}
}
}
能跑。但问题也明显——同一时间只能激活一个子应用。因为大家共用一个 window,你在上面改,我也在上面改,没法并行。
这就是 qiankun 早期单实例模式的限制来源。
Proxy 代理沙箱:qiankun 的主力方案
为了支持多个子应用同时运行,qiankun 搞了 ProxySandbox。思路是给每个子应用造一个"假 window":
class ProxySandbox {
private fakeWindow: Record<string, any> = {}
proxy: WindowProxy
constructor() {
const fakeWindow = this.fakeWindow
this.proxy = new Proxy(fakeWindow, {
get(target, key) {
// 先从 fakeWindow 找,找不到再去真 window
return key in target ? target[key] : (window as any)[key]
},
set(target, key, value) {
target[key] = value // 写操作全部拦截到 fakeWindow
return true
},
has(target, key) {
return key in target || key in window
}
})
}
}
子应用里写 window.xxx = 123,实际写到了 fakeWindow 上,不会污染真正的 window。读的时候先找 fakeWindow,找不到再降级到真 window。
这个方案解决了多实例问题,但有个绕不开的麻烦:子应用的代码怎么让它用 proxy 而不是真 window?
qiankun 的做法是拿到子应用的 JS 代码文本,用 (function(window, self, globalThis) { ... }).call(proxy, proxy, proxy, proxy) 包一层执行。等于在运行时把子应用代码的 window 引用偷梁换柱了。
听着挺巧妙,但实际用起来坑不少。
我在项目里踩过的 Proxy 沙箱的坑
有一次子应用里用了个第三方地图 SDK,它内部用 window.addEventListener 绑了一堆事件。问题是这个 SDK 的代码不是通过 qiankun 的 entry 加载的,而是在 HTML 里用 <script> 标签直接引的 CDN。
结果这部分代码跑在真 window 上,而子应用自己的代码跑在 proxy 上。两边的 window 不是同一个对象,事件监听和业务逻辑之间怎么通信就成了问题。排查了大半天,最后的解法是把 SDK 改成动态 import 的方式加载,让它也走 qiankun 的沙箱。
还有个经典问题:window.location 是不能被 Proxy 完整代理的(涉及到浏览器安全策略),qiankun 对这块做了特殊处理,但偶尔还是会有奇怪的表现。
wujie 的思路:直接用 iframe 的 window
wujie 选了一条完全不同的路——用 iframe 来跑 JS。
不是把子应用渲染在 iframe 里(那就回到原始时代了),而是创建一个隐藏的 iframe,让子应用的 JS 在 iframe 的 window 环境下执行,但 DOM 操作代理到主应用的文档上。
// wujie 的核心思路(简化版)
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)
// JS 跑在 iframe 里 → 天然隔离,每个 iframe 有自己的 window
const sandboxWindow = iframe.contentWindow
// 但 DOM 操作要代理出去:
// sandboxWindow.document → 指向主应用中子应用的挂载容器
Object.defineProperty(sandboxWindow, 'document', {
get: () => proxyDocument // 指向主应用中的 shadow DOM 容器
})
这个设计很聪明。iframe 的 window 是浏览器原生隔离的,不需要自己实现沙箱逻辑。setTimeout、setInterval、事件监听、location 这些全都是天然独立的。
qiankun 用 Proxy 模拟了一个不完美的 window,wujie 直接拿了一个真的。
CSS 隔离:这块的差距更大
JS 沙箱好歹都有方案,CSS 隔离才是真正让人头疼的地方。
qiankun 的 CSS 隔离:三种模式
动态样式表切换(默认):子应用激活时插入样式,卸载时移除。能防止子应用之间互相影响,但子应用的样式可能影响主应用。
Scoped CSS(experimentalStyleIsolation):运行时给子应用的所有 CSS 选择器加前缀。
/* 原始样式 */
.header { color: red; }
body { font-size: 14px; }
/* 加前缀后 */
div[data-qiankun="app1"] .header { color: red; }
div[data-qiankun="app1"] body { font-size: 14px; } /* body 选择器加前缀后其实没啥用 */
这个方案的问题:运行时解析和改写 CSS 有性能开销,而且有些选择器处理不了——比如 body、html、@keyframes 名字冲突。我之前项目里子应用用了 Ant Design,那个全局样式改写出来的效果,一言难尽。
Shadow DOM(strictStyleIsolation):用 Shadow DOM 包裹子应用。理论上完美隔离,但实际上问题更多:
// qiankun 的 Shadow DOM 模式
const container = document.getElementById('app-container')
const shadow = container.attachShadow({ mode: 'open' })
// 子应用渲染到 shadow 内部
// 问题来了:
// 1. 子应用里的弹窗(Modal)通常 append 到 document.body
// → 跑到 Shadow DOM 外面了 → 样式丢失
// 2. 子应用里用 document.querySelector → 查不到 shadow 内的元素
// 3. React 17 之前的事件委托挂在 document 上 → shadow 内事件冒泡有问题
所以 qiankun 官方文档对 Shadow DOM 模式的态度是"谨慎使用"。很多团队实际上在用的是动态样式表 + BEM 命名约定这种半自动的隔离。
wujie 的 CSS 隔离:Web Component + Shadow DOM
wujie 用的也是 Shadow DOM,但配合它的 iframe JS 执行方案,体验好很多。
子应用的 DOM 渲染在一个 Web Component 的 Shadow DOM 里,JS 跑在 iframe 里。iframe 里的 document 被代理到 Shadow DOM 容器上,所以子应用调 document.querySelector 查到的是 Shadow DOM 内的元素,弹窗也能 append 到正确的位置。
// wujie 的 Web Component(简化)
class WujieApp extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' })
// 子应用的 HTML/CSS 都渲染在这个 shadow 里
}
}
customElements.define('wujie-app', WujieApp)
// iframe 里的 document 操作被劫持:
// document.body.appendChild(modal)
// → 实际 append 到 shadow DOM 内的 body 容器
// → 样式不会丢失
弹窗问题解决了吗?大部分场景是的。但也不是完全没坑——有些组件库会往 window.document.body(注意是 window 上取的 document)上挂东西,如果恰好绕过了 proxy,还是会逃逸。
设计哲学对比
两个框架的取舍逻辑,核心就一句话:
qiankun 选择在同一个页面上下文里做隔离,wujie 选择用原生隔离能力再做桥接。
qiankun 的路线:共享 window → Proxy 拦截 → 运行时改写 CSS → 在一个上下文里模拟多个沙箱。好处是子应用和主应用天然在同一个 DOM 树里,通信方便、路由同步简单。代价是隔离不彻底,边界情况多,要处理的 hack 也多。
wujie 的路线:iframe 跑 JS → Shadow DOM 渲染 UI → 通过 proxy 桥接两侧。好处是隔离干净,很多 qiankun 的历史坑天然不存在。代价是架构复杂度高,iframe 和主应用之间的 DOM 代理逻辑如果出 bug,排查成本不低。
画个表可能更清楚:
| 维度 | qiankun | wujie |
|---|---|---|
| JS 隔离 | Proxy 模拟 fakeWindow | iframe 原生 window |
| CSS 隔离 | 动态样式 / Scoped / Shadow DOM | Web Component Shadow DOM |
| 多实例 | ProxySandbox 支持 | 天然支持(每个 iframe 独立) |
| 弹窗逃逸 | Shadow DOM 模式下有问题 | 基本解决(document 被代理) |
| 子应用改造成本 | 需要导出生命周期钩子 | 相对较低 |
| 通信复杂度 | 低(同上下文) | 中(跨 iframe) |
| 社区生态 | 成熟,用的人多 | 较新,踩坑资料少 |
选型的时候怎么想
我个人的判断标准比较简单粗暴:
子应用技术栈比较统一(比如都是 React 或都是 Vue),团队对微前端有经验,选 qiankun。它的坑多但资料也多,大部分问题都有现成的解法。
子应用技术栈混乱(jQuery、Vue2、React18 啥都有),或者子应用是那种不太可能配合改造的老系统,wujie 的隔离能力会省很多事。iframe 原生隔离把很多脏活揽过去了。
如果是新项目,说实话我会先考虑要不要用微前端。Module Federation 或者简单的 iframe 嵌入能不能解决问题?微前端引入的复杂度不低,别为了用而用。
还有一点——沙箱隔离只是微前端的一个维度。路由同步、应用通信、公共依赖管理、构建部署流程,这些加在一起才是完整的工程决策。只看沙箱就选型,容易后面翻车。
回过头看
沙箱隔离这个问题,本质上是在问:多个独立应用塞到一个页面里,怎么让它们互不干扰?
qiankun 的答案是"我在 JS 层面给你隔开",wujie 的答案是"我让浏览器帮你隔开"。两个思路没有绝对的高下,只有场景的匹配度。
不过有一点是确定的:不管用哪个框架,上线前一定要跑一轮子应用并行加载的测试,重点关注全局变量污染和样式冲突。这两个问题不在开发阶段暴露,就一定会在生产环境暴露。到时候定位起来,比一开始就解决要痛苦十倍。
深交所理事长沙雁:做好创业板改革的准备工作
金隅集团与河北港口集团签订合作协议
美团联合联想百应上线OpenClaw远程部署服务
林俊旸告别千问:今天 Last day,不是这几天我不知道这世界这么多人爱我
蜂巢能源1月全球动力电池装机量升至第八
西井科技更新IPO辅导备案
俄研究机构训练人工智能识别早期乳腺癌
后端字段又改了?我撸了一个 BFF 数据适配器,从此再也不怕接口“屎山”!
副标题:适配 Bun 运行时,万级数据映射性能提升 200%,前后端解耦的终极方案。
01. 痛点共鸣:你是不是也在写这种代码?
很多前端同学在处理接口时,业务代码里塞满了这种逻辑:
const name = res.data.u_info_v2_name || "未知";
const status = res.data.state === 1;
const tags = res.data.raw_str ? res.data.raw_str.split(",") : [];
这种硬编码的后果:
- 脆弱:后端改一个字段名,前端全屏报错。
- 难看:业务逻辑被数据清洗逻辑淹没。
- 性能:大规模循环转换时,Node.js 的 GC 压力巨大。
02. 核心方案:BFFDataAdapter 架构逻辑
这不是简单“封装几个工具函数”,而是一层可维护、可演进的数据契约层:
- Schema 驱动:字段映射配置化,后端字段变化时优先改配置。
- 双向转换:
toClient负责展示态,toAPI负责提交态。 - 内置校验:字段缺失、格式错误直接在转换层拦截。
flowchart LR
A[后端原始 JSON] --> B[BFFDataAdapter]
B --> C[Schema 映射]
C --> D[Transformer 转换]
D --> E[Validator 校验]
E --> F[前端干净对象]
F -->|提交| B
B --> G[API 请求体]
03. 场景化 Demo:电商订单数据处理
我们把后端杂乱字段映射为前端可读结构,同时做金额格式化和手机号校验。
核心代码
1-bff/BFFDataAdapter.js 提供适配器能力:
-
registerSchema(name, schema):注册数据契约。 -
registerTransformer(name, fn):注册转换器。 -
registerValidator(name, fn):注册校验器。 -
toClient(schema, payload):后端 -> 前端。 -
toAPI(schema, payload):前端 -> 后端。
同时提供 TypeScript 类型定义文件:1-bff/BFFDataAdapter.d.ts。
1-bff/example-order.js 是完整示例,直接运行可看到双向转换效果。
JSON 自动生成 Schema
为了减少手写映射成本,提供了 CLI:1-bff/generate-schema.js。
# 方式 1:直接传 JSON 字符串
bun 1-bff/generate-schema.js '{"order_id_long":"123","amount_fen":19900,"user":{"profile":{"nickname":"iDao"}}}' --name orderDetail
# 方式 2:从文件读取 JSON
bun 1-bff/generate-schema.js --file ./payload.json --name orderDetail
输出是可直接复制的 schema JSON(包含 schemaName 和 schema.fields)。
运行方式
# 运行业务示例
bun 1-bff/example-order.js
# JSON 自动生成 Schema
bun 1-bff/generate-schema.js '{"foo_bar":1,"user":{"name":"A"}}' --name demoSchema
# 跑万级数据压测(默认 10000 条)
bun 1-bff/benchmark.js
# 自定义条数
bun 1-bff/benchmark.js 50000
如果你想和 Node.js 对比:
node 1-bff/benchmark.js 10000
bun 1-bff/benchmark.js 10000
04. 性能进阶:为什么 Bun 环境更猛?
在 BFF 层做大批量数据映射时,性能瓶颈通常来自两块:
- JSON 解析与对象分配
- 字段级转换与校验循环
在这类场景里,Bun 在 JSON 处理和整体运行时开销上通常更有优势。你可以直接用 1-bff/benchmark.js 在本机得到真实数字,避免“玄学优化”。
提示:真实性能和机器配置、数据形态、转换逻辑复杂度相关。建议在你的真实数据样本上测。
05. 代码示例(节选)
import { createBFFAdapter } from "./BFFDataAdapter.js";
const adapter = createBFFAdapter();
adapter.registerTransformer("money", (val) => `¥${(val / 100).toFixed(2)}`);
adapter.registerSchema("orderDetail", {
fields: {
id: { source: "order_id_long", required: true },
price: { source: "amount_fen", transform: "money" },
customerName: { source: "user.profile.nickname", default: "匿名用户" },
contact: { source: "service_phone", validate: "phone" },
statusText: {
source: "state_code",
transform: (val) => (val === 1 ? "待发货" : "已完成"),
},
},
});
获取完整“全栈提效包”
我已经把这套适配器整理成支持 TypeScript 自动推导的进阶版(告别 any)。
资料包内含:
-
BFFDataAdapter完整源码及 TS 类型定义。 - 自动生成工具:输入一段 JSON,自动生成 Schema 配置。
- 性能压测脚本:亲自对比 Node vs Bun 的极限。
关注我的掘金/公众号 [iDao技术魔方],后台私信回复关键字 "BFF",立刻获取隐藏仓库地址。