阅读视图
html翻页时钟 效果
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Flip Clock</title>
<style>
body {
background: #111;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: 'Courier New', monospace;
color: white;
}
.clock {
display: flex;
gap: 20px;
}
.card-container {
width: 80px;
height: 120px;
position: relative;
perspective: 500px;
background: #2c292c;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
/* 中间分割线 */
.card-container::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 100%;
height: 4px;
background: #120f12;
z-index: 10;
}
.card-item {
position: absolute;
width: 100%;
height: 50%;
left: 0;
overflow: hidden;
background: #2c292c;
color: white;
text-align: center;
font-size: 64px;
font-weight: bold;
backface-visibility: hidden;
transition: transform 0.4s ease-in-out;
}
/* 下层数字:初始对折(背面朝上) */
.card1 { /* 下层上半 */
top: 0;
line-height: 120px; /* 整体高度对齐 */
}
.card2 { /* 下层下半 */
top: 50%;
line-height: 0;
transform-origin: center top;
transform: rotateX(180deg);
z-index: 2;
}
/* 上层数字:当前显示 */
.card3 { /* 上层上半 */
top: 0;
line-height: 120px;
transform-origin: center bottom;
z-index: 3;
}
.card4 { /* 上层下半 */
top: 50%;
line-height: 0;
z-index: 1;
}
/* 翻页动画触发 */
.flip .card2 {
transform: rotateX(0deg);
}
.flip .card3 {
transform: rotateX(-180deg);
}
/* 冒号分隔符 */
.colon {
font-size: 64px;
display: flex;
align-items: center;
color: #aaa;
}
</style>
</head>
<body>
<div class="clock">
<div class="card-container flip" id="hour" data-number="00">
<div class="card1 card-item">00</div>
<div class="card2 card-item">00</div>
<div class="card3 card-item">00</div>
<div class="card4 card-item">00</div>
</div>
<div class="colon">:</div>
<div class="card-container flip" id="minute" data-number="00">
<div class="card1 card-item">00</div>
<div class="card2 card-item">00</div>
<div class="card3 card-item">00</div>
<div class="card4 card-item">00</div>
</div>
<div class="colon">:</div>
<div class="card-container flip" id="second" data-number="00">
<div class="card1 card-item">00</div>
<div class="card2 card-item">00</div>
<div class="card3 card-item">00</div>
<div class="card4 card-item">00</div>
</div>
</div>
<script>
function setHTML(dom, nextValue) {
const curValue = dom.dataset.number;
if (nextValue === curValue) return;
// 更新 DOM 结构:下层为新值,上层为旧值
dom.innerHTML = `
<div class="card1 card-item">${nextValue}</div>
<div class="card2 card-item">${nextValue}</div>
<div class="card3 card-item">${curValue}</div>
<div class="card4 card-item">${curValue}</div>
`;
// 触发重绘以重启动画
dom.classList.remove('flip');
void dom.offsetWidth; // 强制重排
dom.classList.add('flip');
dom.dataset.number = nextValue;
}
function updateClock() {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
setHTML(document.getElementById('hour'), h);
setHTML(document.getElementById('minute'), m);
setHTML(document.getElementById('second'), s);
}
// 初始化
updateClock();
// setTimeout(updateClock,1000)
setInterval(updateClock, 1000);
</script>
</body>
</html>
这个翻页时钟(Flip Clock)通过 CSS 3D 变换 + 动画类切换 + DOM 内容动态更新 的方式,模拟了类似机械翻页牌的效果。下面从结构、样式和逻辑三方面详细分析其实现原理:
🔧 一、HTML 结构设计
每个时间单位(小时、分钟、秒)由一个 .card-container 容器表示,内部包含 4 个 .card-item 元素:
<div class="card-container" id="second">
<div class="card1">00</div> <!-- 下层上半 -->
<div class="card2">00</div> <!-- 下层下半(初始翻转180°)-->
<div class="card3">00</div> <!-- 上层上半(当前显示)-->
<div class="card4">00</div> <!-- 上层下半 -->
</div>
四个卡片的作用:
-
.card3和.card4:组成当前显示的数字(上半+下半),正常显示。 -
.card1和.card2:组成即将翻出的新数字,但初始时.card2被rotateX(180deg)翻转到背面(不可见)。 - 中间有一条
::before伪元素作为“折痕”,增强翻页视觉效果。
🎨 二、CSS 样式与 3D 翻转原理
关键 CSS 技术点:
1. 3D 空间设置
.card-container {
perspective: 500px; /* 创建 3D 视角 */
}
-
perspective让子元素的 3D 变换有景深感。
2. 上下两半的定位与旋转轴
.card2 {
transform-origin: center top;
transform: rotateX(180deg); /* 初始翻到背面 */
}
.card3 {
transform-origin: center bottom;
}
-
.card2绕顶部边缘旋转 180°,藏在下方背面。 -
.card3绕底部边缘旋转,用于向上翻折。
3. 翻页动画(通过 .flip 类触发)
.flip .card2 {
transform: rotateX(0deg); /* 展开新数字下半部分 */
}
.flip .card3 {
transform: rotateX(-180deg); /* 当前数字上半部分向上翻折隐藏 */
}
- 动画持续
0.4s,使用ease-in-out缓动。 -
.card1和.card4始终保持静态,作为背景支撑。
✅ 视觉效果:
- 上半部分(
.card3)向上翻走(像书页翻开)- 下半部分(
.card2)从背面转正,露出新数字- 中间的“折痕”让翻页更真实
⚙️ 三、JavaScript 动态更新逻辑
核心函数:setHTML(dom, nextValue)
步骤分解:
- 对比新旧值:如果相同,不更新(避免无谓动画)。
-
重写整个容器的 HTML:
-
下层(新值):
.card1和.card2显示nextValue -
上层(旧值):
.card3和.card4显示curValue
-
下层(新值):
-
触发动画:
dom.classList.remove('flip'); void dom.offsetWidth; // 强制浏览器重排(关键!) dom.classList.add('flip');- 先移除
.flip,再强制重排(flush styles),再加回.flip,确保动画重新触发。
- 先移除
-
更新
data-number保存当前值。
时间更新:
- 每秒调用
updateClock(),获取当前时分秒(两位数格式)。 - 分别调用
setHTML更新三个容器。
🌟 四、为什么能实现“翻页”错觉?
| 元素 | 初始状态 | 翻页后状态 | 视觉作用 |
|---|---|---|---|
.card3 |
显示旧数字上半 | 向上翻转 180° 隐藏 | 模拟“翻走”的上半页 |
.card2 |
旧数字下半(翻转180°藏起) | 转正显示新数字下半 | 模拟“翻出”的下半页 |
.card1 / .card4
|
静态背景 | 不变 | 提供视觉连续性 |
💡 关键技巧:
- 利用 两个完整数字(新+旧)叠加,通过控制上下半部分的旋转,制造“翻页”而非“淡入淡出”。
- 强制重排(
offsetWidth) 是确保 CSS 动画每次都能重新触发的经典 hack。
✅ 总结
这个 Flip Clock 的精妙之处在于:
- 结构设计:4 个卡片分工明确,上下层分离。
-
CSS 3D:利用
rotateX+transform-origin实现真实翻页。 - JS 控制:动态替换内容 + 巧妙触发动画。
- 性能优化:仅在值变化时更新,避免无效渲染。
这是一种典型的 “用 2D DOM 模拟 3D 物理效果” 的前端动画范例,既高效又视觉惊艳。
js 封装 动画效果
/**
* 通用动画函数
* @param {Object} options 配置对象
* @param {number} [options.duration] 动画持续时间 (毫秒),如果提供则优先使用
* @param {number} [options.speed] 动画速度 (单位/毫秒),当未提供 duration 时生效
* @param {number} options.from 起始值,默认为 0
* @param {number} options.to 结束值
* @param {Function} [options.callback] 每一帧的回调函数,接收 (currentValue, progress) 作为参数
* @param {Function} [options.onComplete] 动画结束时的回调函数
* @param {Function} [legacyCallback] 兼容旧调用的第二个参数作为回调
* @returns {Function} 取消动画的函数
*/
let animateMoveFn = ({ duration, speed, from, to, callback, onComplete }) => {
// --- 参数类型校验开始 ---
// 校验 from
if (from === undefined || from === null) {
console.error(`animateMoveFn: "from" 必须是数字且必填。当前值: ${from}。动画将不执行。`);
return () => { }; // 返回空的取消函数
}
if (typeof from !== 'number' || isNaN(from)) {
console.warn(`animateMoveFn: "from" 必须是数字。当前值: ${from}。已重置为 0。`);
return () => { }; // 返回空的取消函数
}
// 校验 to
if (to === undefined || to === null) {
console.error(`animateMoveFn: "to" 必须是数字且必填。当前值: ${to}。动画将不执行。`);
return () => { }; // 返回空的取消函数
}
if (typeof to !== 'number' || isNaN(to)) {
console.warn(`animateMoveFn: "to" 必须是数字。当前值: ${to}。已重置为 0。`);
return () => { }; // 返回空的取消函数
}
// 校验 duration
if (duration !== undefined && duration !== null) {
if (typeof duration !== 'number' || isNaN(duration) || duration < 0) {
console.warn(`animateMoveFn: "duration" 必须是非负数字。当前值: ${duration}。将忽略此参数。`);
duration = undefined;
}
}
// 校验 speed
if (speed !== undefined && speed !== null) {
if (typeof speed !== 'number' || isNaN(speed) || speed <= 0) {
console.warn(`animateMoveFn: "speed" 必须是正数字。当前值: ${speed}。将忽略此参数。`);
speed = undefined;
}
}
// 校验 callback
if (callback !== undefined && typeof callback !== 'function') {
console.warn(`animateMoveFn: "callback" 必须是函数。当前类型: ${typeof callback}。`);
callback = null;
}
// 校验 onComplete
if (onComplete !== undefined && typeof onComplete !== 'function') {
console.warn(`animateMoveFn: "onComplete" 必须是函数。当前类型: ${typeof onComplete}。`);
onComplete = null;
}
// --- 参数类型校验结束 ---
// 记录动画开始的时间戳
let startTime = Date.now();
// 存储当前的 requestAnimationFrame ID,用于取消动画
let reqId = null;
// 动画是否已取消的标志
let isCancelled = false;
// 核心动画循环函数
let moveFn = () => {
// 如果动画已取消,直接退出
if (isCancelled) return;
// 计算从开始到现在经过的时间
let elapsed = Date.now() - startTime;
// 当前动画进度 (0 到 1 之间)
let progress = 0;
if (duration && duration > 0) {
// 模式 1: 基于持续时间 (Duration-based)
progress = elapsed / duration;
} else if (speed && speed > 0) {
// 模式 2: 基于速度 (Speed-based)
// 计算总距离
let totalDistance = Math.abs(to - from);
if (totalDistance === 0) {
progress = 1;
} else {
// 已移动距离 = 速度 * 时间
let coveredDistance = speed * elapsed;
progress = coveredDistance / totalDistance;
}
} else {
// 既无 duration 也无 speed,或者值无效,默认直接完成
progress = 1;
}
// 确保进度不超过 1
if (progress > 1) progress = 1;
// 计算当前值:起始值 + (总变化量 * 进度)
// 使用线性插值 (Linear Interpolation)
let currentValue = from + (to - from) * progress;
// 执行回调,将当前值和进度传递出去
if (callback) {
callback(currentValue, progress);
}
// 检查动画是否结束
if (progress < 1) {
// 动画未结束,请求下一帧
reqId = requestAnimationFrame(moveFn);
} else {
// 动画结束
onComplete(currentValue, progress);
}
};
// 启动动画
reqId = requestAnimationFrame(moveFn);
// 返回一个取消函数,外部调用它可以立即停止动画
return () => {
isCancelled = true;
if (reqId) {
cancelAnimationFrame(reqId);
}
};
};
// 兼容旧的命名(如果项目中有其他地方用到)
window.animateMoeveFn = animateMoveFn;
window.animateMoveFn = animateMoveFn;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Animation Test</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.box {
width: 50px;
height: 50px;
background-color: #e74c3c;
position: relative;
margin-bottom: 30px;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 12px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
button {
padding: 10px 15px;
cursor: pointer;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
transition: background 0.2s;
}
button:hover {
background-color: #2980b9;
}
button.cancel {
background-color: #e67e22;
}
button.cancel:hover {
background-color: #d35400;
}
#output {
padding: 15px;
border: 1px solid #ddd;
background: #f8f9fa;
max-height: 300px;
overflow-y: auto;
font-family: 'Consolas', monospace;
font-size: 13px;
border-radius: 4px;
}
.log-entry {
margin-bottom: 4px;
border-bottom: 1px solid #eee;
padding-bottom: 2px;
}
.log-time {
color: #888;
margin-right: 8px;
}
.log-success { color: #27ae60; font-weight: bold; }
.log-warn { color: #e67e22; }
.log-error { color: #c0392b; }
</style>
</head>
<body>
<h1>Animation Test for ani.js</h1>
<div class="box" id="testBox">0</div>
<div class="controls">
<button id="btnDuration">1. 时长模式 (Duration)</button>
<button id="btnSpeed">2. 速度模式 (Speed)</button>
<button id="btnReverse">3. 反向动画 (Reverse)</button>
<button id="btnOnComplete">4. 完整回调 (onComplete)</button>
<button id="btnPriority">5. 优先级 (Duration > Speed)</button>
<button id="btnError">6. 错误参数测试 (Check Console)</button>
<button id="btnCancel" class="cancel">7. 中途取消 (Cancel)</button>
<button id="btnClearLog" style="background:#95a5a6">清除日志</button>
</div>
<div id="output">日志准备就绪...</div>
<script src="./js/ani.js"></script>
<script>
const box = document.getElementById('testBox');
const output = document.getElementById('output');
let currentCancelFn = null;
function log(msg, type = 'normal') {
const div = document.createElement('div');
div.className = 'log-entry';
const timeSpan = document.createElement('span');
timeSpan.className = 'log-time';
timeSpan.textContent = `[${new Date().toLocaleTimeString()}]`;
const msgSpan = document.createElement('span');
msgSpan.textContent = msg;
if (type === 'success') msgSpan.className = 'log-success';
if (type === 'warn') msgSpan.className = 'log-warn';
if (type === 'error') msgSpan.className = 'log-error';
div.appendChild(timeSpan);
div.appendChild(msgSpan);
output.prepend(div);
}
function reset(startVal = 0) {
if (currentCancelFn) {
currentCancelFn();
currentCancelFn = null;
log('上一个动画已终止', 'warn');
}
box.style.left = startVal + 'px';
box.textContent = Math.round(startVal);
}
// 1. 基础时长模式
document.getElementById('btnDuration').onclick = () => {
reset(0);
log('测试1: 基于 Duration (0 -> 500px, 1000ms)');
currentCancelFn = animateMoveFn({
duration: 1000,
from: 0,
to: 500,
callback: (val) => {
box.style.left = val + 'px';
box.textContent = Math.round(val);
},
onComplete: (val) => {
log(`动画结束: 到达 ${val}px`, 'success');
}
});
};
// 2. 速度模式
document.getElementById('btnSpeed').onclick = () => {
reset(0);
log('测试2: 基于 Speed (0 -> 500px, speed: 0.5px/ms)');
log('预期耗时: 500 / 0.5 = 1000ms');
currentCancelFn = animateMoveFn({
speed: 0.5, // 0.5px per ms = 500px per second
from: 0,
to: 500,
callback: (val) => {
box.style.left = val + 'px';
box.textContent = Math.round(val);
},
onComplete: (val) => {
log(`动画结束: 到达 ${val}px`, 'success');
}
});
};
// 3. 反向动画
document.getElementById('btnReverse').onclick = () => {
reset(500);
log('测试3: 反向动画 (500 -> 0px, speed: 1px/ms)');
currentCancelFn = animateMoveFn({
speed: 1, // 1000px/s, fast!
from: 500,
to: 0,
callback: (val) => {
box.style.left = val + 'px';
box.textContent = Math.round(val);
},
onComplete: () => log('反向动画结束', 'success')
});
};
// 4. onComplete 测试
document.getElementById('btnOnComplete').onclick = () => {
reset(0);
log('测试4: 测试 onComplete 回调');
currentCancelFn = animateMoveFn({
duration: 500,
from: 0,
to: 200,
callback: (val) => {
box.style.left = val + 'px';
box.textContent = Math.round(val);
},
onComplete: (val, progress) => {
log(`onComplete 触发! Val: ${val}, Progress: ${progress}`, 'success');
box.style.backgroundColor = '#2ecc71'; // 变绿
setTimeout(() => box.style.backgroundColor = '#e74c3c', 500); // 变回红
}
});
};
// 5. 优先级测试
document.getElementById('btnPriority').onclick = () => {
reset(0);
log('测试5: 优先级测试 (传入 duration=2000 和 speed=10)');
log('预期: 应该使用 duration (2秒),忽略极快的 speed');
currentCancelFn = animateMoveFn({
duration: 2000,
speed: 10, // 如果生效只要 50ms,如果不生效要 2000ms
from: 0,
to: 500,
callback: (val) => {
box.style.left = val + 'px';
box.textContent = Math.round(val);
},
onComplete: () => log('动画结束 (检查耗时是否接近 2秒)', 'success')
});
};
// 6. 错误参数测试
document.getElementById('btnError').onclick = () => {
reset(0);
log('测试6: 错误参数 (请查看浏览器控制台 Console)', 'warn');
// Case A: 缺少 to
log('Case A: 缺少 "to" 参数 -> 应该报错不执行');
animateMoveFn({ duration: 1000, from: 0 });
// Case B: 错误的 duration
// setTimeout(() => {
// log('Case B: duration 为字符串 -> 应该警告并忽略');
// animateMoveFn({
// duration: "invalid",
// speed: 1, // 备用方案
// from: 0,
// to: 100,
// callback: (v) => box.style.left = v + 'px'
// });
// }, 500);
};
// 7. 取消测试
document.getElementById('btnCancel').onclick = () => {
reset(0);
log('测试7: 启动并在 500ms 后取消');
currentCancelFn = animateMoveFn({
duration: 2000,
from: 0,
to: 800,
callback: (val) => {
box.style.left = val + 'px';
box.textContent = Math.round(val);
},
onComplete: () => log('ERROR: 动画不应该完成!', 'error')
});
setTimeout(() => {
if (currentCancelFn) {
currentCancelFn();
currentCancelFn = null;
log('已调用 cancel()', 'warn');
}
}, 500);
};
document.getElementById('btnClearLog').onclick = () => {
output.innerHTML = '';
log('日志已清空');
};
</script>
</body>
</html>
ani.js 动画库实现原理解析教程
本教程将带你深入了解 ani.js 的实现原理。这是一个轻量级的通用动画函数,旨在通过精确的时间控制来实现平滑的数值过渡效果。它不仅支持传统的时长模式 (Duration),还创新地引入了速度模式 (Speed),非常适合用于 UI 交互、游戏开发或任何需要动态数值变化的场景。
1. 核心设计理念
ani.js 的核心思想是基于时间 (Time-based) 而非基于帧数 (Frame-based)。
- 基于帧数:每一帧增加固定的数值。如果设备卡顿,掉帧会导致动画变慢,总时长不可控。
-
基于时间:根据当前时间与开始时间的差值 (
elapsed) 来计算当前应处的位置。无论帧率如何波动,动画总是在预定的时间到达终点,保证了动画的流畅性和同步性。
2. 函数签名与参数设计
函数采用单一对象参数 options 的设计模式,这使得参数扩展变得非常灵活,同时保持了调用的清晰度。
let animateMoveFn = ({
duration, // 动画持续时间 (毫秒)
speed, // 动画速度 (单位/毫秒)
from = 0, // 起始值 (默认为 0)
to, // 结束值 (必填)
callback, // 每帧回调:(currentValue, progress) => {}
onComplete // 结束回调:(finalValue, progress) => {}
}) => { ... }
亮点分析:
-
双模式驱动:
-
时长优先:如果你提供了
duration,动画将严格在指定时间内完成。 -
速度优先:如果你未提供
duration但提供了speed,函数会自动根据Math.abs(to - from)计算所需时间。
-
时长优先:如果你提供了
-
健壮性校验:函数内部对所有参数进行了严格的类型检查(如
typeof,isNaN),确保无效参数不会导致运行时错误,并提供友好的控制台警告。
3. 核心实现深度解析
3.1 动画循环 (The Loop)
动画引擎的心脏是 requestAnimationFrame。它比 setInterval 更高效,因为它会跟随浏览器的刷新率(通常是 60Hz),并在后台标签页暂停执行以节省电量。
let startTime = Date.now();
let moveFn = () => {
// 1. 计算流逝的时间
let elapsed = Date.now() - startTime;
// 2. 计算进度 (0.0 ~ 1.0)
// ... (核心算法见下文)
// 3. 更新数值并绘制
// ...
// 4. 决定下一帧
if (progress < 1) {
reqId = requestAnimationFrame(moveFn);
} else {
// 动画结束
}
};
3.2 进度计算策略 (The Math)
这是 ani.js 最精彩的部分。它根据输入模式动态决定进度计算方式:
模式 A:时长模式 (Duration Mode) 最常见的模式。进度等于“已过去的时间”除以“总时长”。
progress = elapsed / duration;
模式 B:速度模式 (Speed Mode) 当距离不确定,但希望保持恒定速度时使用(例如:无论滑块拖动多远,回弹速度一致)。
let totalDistance = Math.abs(to - from);
let coveredDistance = speed * elapsed; // 速度 * 时间 = 路程
progress = coveredDistance / totalDistance;
3.3 线性插值 (Linear Interpolation / Lerp)
一旦算出 progress (0 到 1 之间的浮点数),我们就可以计算当前的数值:
// 公式:当前值 = 起始值 + (总变化量 * 进度)
let currentValue = from + (to - from) * progress;
这个公式非常强大:
- 当
progress = 0时,结果为from。 - 当
progress = 1时,结果为to。 - 当
progress = 0.5时,结果正好在中间。 -
支持反向:即使
to < from,公式依然成立(因为to - from会是负数)。
3.4 生命周期管理与取消机制
为了让动画可控,函数返回了一个闭包函数 (Closure),用于取消动画。
return () => {
isCancelled = true; // 标志位:阻止后续帧执行
if (reqId) cancelAnimationFrame(reqId); // 清除浏览器队列中的请求
};
这种设计允许外部代码随时打断动画(例如用户再次触发了新的动画),防止多个动画冲突。
4. 最佳实践与使用示例
场景一:基础位移 (1秒内移动到 500px)
const cancel = animateMoveFn({
duration: 1000,
from: 0,
to: 500,
callback: (val) => element.style.left = val + 'px'
});
场景二:恒定速度回弹 (无论多远,速度都是 2px/ms)
const cancel = animateMoveFn({
speed: 2, // 2000px/s,非常快
from: currentPosition, // 动态获取当前位置
to: 0,
callback: (val) => element.style.left = val + 'px'
});
场景三:防止动画冲突 (Anti-conflict)
在启动新动画前,务必取消旧动画。
let currentAnim = null;
function startNewAnim() {
if (currentAnim) currentAnim(); // 停止旧的
currentAnim = animateMoveFn({
to: 100,
// ...
onComplete: () => currentAnim = null // 结束后清理引用
});
}
5. 总结
ani.js 是一个教科书式的现代 JavaScript 动画实现。它展示了如何通过:
- 参数解构与默认值 来提升 API 易用性。
- 防御性编程 来处理无效输入。
- 时间轴插值算法 来保证动画平滑度。
- 闭包与高阶函数 来管理状态和副作用。
掌握了这个函数的实现,你就掌握了前端动画引擎的基石。
前字节高管创业教育类出海项目,用Agent做“终身学习搭子”,红杉投了
文|富充
编辑|苏建勋
2024年11月,李可佳、吴俊东、张栖铭决定一起创业,做一款“帮助用户终身学习”的AI Agent。新公司被命名为Ouraca ,是“Our Academy”的缩写。
三位合伙人都是互联网教育领域的“老兵”:
创始人兼CEO李可佳,曾任极课大数据创始人,该项目被字节跳动收购后,他入职字节智慧教育业务担任CEO;
联创吴俊东,哈佛肯尼迪学院硕士,好未来教育投资人,曾为李可佳极课大数据的投资人;
联创张栖铭,曾任字节教育中台负责人,推出过数个日活千万、月活过亿的产品。
Ouraca 公司正式创建于2025年3月,产品还未发布,就已经获得700万美元种子轮融资。股东名单中包括红杉中国、初心资本、Etna Capital等VC机构。
有趣的是,上述投资方在决定出手Ouraca时的决策速度,都非常快。李可佳回忆起,决定创业的当晚,将想法说给初心资本创始人田江川,几分钟后就收到田江川回复“那我投你”;红杉中国的投资速度也近乎如此。
“Etna Capita的投资决策几乎是在24小时内完成的,”投资人Haina Xu回忆当时的情况:“ ChatGPT的使用场景里,教育类别长期排前五,可以说教育是大语言模型带来最大改变的行业之一。人们期待全能AI Tutor很多年了,Ouraca恰好是我们看到的第一个在探索‘AI时代终身学习产品’的团队。”
2025年9月,Ouraca上线第一款应用“Aibrary”,它用名为Idea Twin的独特“AI生成播客”功能引导和陪伴用户学习。目前,Aibrary和Ouraca旗下的新产品仍在持续快速迭代。
用AI做会提问的“学习搭子”
李可佳对AI教育产品,有一套自己的判断:因为大模型可以快速生产大量内容,所以痛点并不是内容不够多,而是如何通过帮助用户向AI提出好的问题,收获高质量的答案。
在Aibrary,用户可以主动向Aibrary提问自己想要学习的话题。如果用户不知道该问什么,Aibrary会每天向用户推送3个与生活相关的话题,比如最近的一个,是关于“积少成多的小习惯“。
每个话题里,Aibrary嵌入了自己的见解,并且推荐了值得一读的阅读清单,也向人们提供书籍讲解。
但Aibrary并非又一款“X分钟给你讲本书”的APP,而是把知识变成用户感兴趣的对话,再用播客的形式说出来。
具体而言,播客分为“主持人”和“嘉宾”两个角色。用户可以选择苏格拉底、爱因斯坦等带有不同发问逻辑的主持人;而回答的“嘉宾”则是用户自己的“数字分身”(Idea Twin),这是Agent在学习用户的身份和资料之后,形成的“嘴替”一般的存在。
吴俊东举了一个最近的例子:“前段时间,关于‘人工智能是否会取代大量岗位’的讨论很热。我有一位做青年领导力的朋友向Aibrary提问该如何看待此事,Aibrary向这位朋友推荐了《未来简史》,并生成了一期播客。”
“播客中的‘嘉宾’是Agent‘扮演’的这位朋友,博客中不仅讨论了技术趋势,还结合他当前的创业方向,分析哪些能力会被替代、哪些能力会放大。最终,‘嘉宾’给出的不是抽象判断,而是基于他的背景形成的应对策略。这本书其实很多人都看过,但是Aibrary会针对每个不同人,以及同一个人的不同心境都有完全不一样的解读。”吴俊东说。
Idea Twin的灵感来自NotebookLM,那个能把论文、财报变成双人对话播客的产品。
2024年,李可佳在体验过NotebookLM后就意识到,AI产品的胜负手不仅在于“模型更强”,而在“交互形式”——声音这种模态,会让知识交付出现新的可用性。
但李可佳觉得,NotebookLM的体验还差临门一脚:它像一个万能工具,只是用户得自己找材料、自己提问、自己规划路径。而很多数人并不知道要问什么。
Aibrary的播客,正是填补了这个Gap:先替用户进行高质量提问,再把知识答出来。
△Aibrary提供的功能包括书籍推荐、书籍讲解生成、Idea Twin播客等,图片:采访人提供
找准“持续学习”用户,解决“大量小问题”
关于如何做好用户体验、提升用增和留存,李可佳总结出Aibrary的Know-how:做好大量小问题。
最基础的一步,是要输出值得信赖的内容:书用哪个版本做数据源更可靠、网络上围绕一本书的海量观点怎么清洗……这意味着大量数据收集、标注类的工程化任务要做细致。
此外,要让用户觉得Agent“懂自己”,这来自AI对用户信息和用户行为的学习。
Aibrary使用者在注册时,会填入描述年龄、职业、目标、困惑。这是AI了解用户的基础,此后会随着交互和对话变多,学习越来越多的用户信息。
但一款好产品的基础,离不开对目标用户需求的精准把控。
李可佳和吴俊东都认为,前AI时代的教育产品,还是在追求“更少老师教更多学生”的极致效率。“但AI越强,那些能被规范化培养的技能就过期越快,比如律师助理、基础编程、客服。”李可佳说。
因此,新一代教育产品要挖掘有持续学习目标的用户,并非为了考试和培训而学习的使用者。
为了找准用户人群,Aibrary成立之前就做了大量调研。主创团队先在身边或LinkedIn等求职、社交平台发放问卷,整理用户需求,并且逐步确立三类核心人群:Busy Professionals(繁忙的职场中坚力量)、刚毕业或还在读书的年轻人、以及宝爸宝妈——后者往往脱离职场,但又不想被时代甩下,因此使用碎片时间学习。
Aibrary团队还会一对一大量访谈早期用户。
这些用户中既有得过癌症的全职妈妈,希望在这里找到解决日常生活瓶颈的方法;也有管理着40个员工的仓储公司老板,想要学习怎么和女朋友相处;甚至是在酒店工作、想转行做医药的移民,这位用户还因为自己英语不太好,拼写总是出错而道歉。
这也让Aibrary团队更加确定:“持续学习”在北美并不是一项“精英意愿”;在这里,更普遍的人群希望获得“持续成长的确定感”。这也是团队把首站市场设置为美国的原因。
李可佳和吴俊东很认可Insta360创始人刘靖康的思路:先从与自己相似的一小群人出发,抓住一个足够具体的需求把它做到极致,产品哪怕起步小众,也有可能在扎稳核心人群后逐步外扩。
选择书籍作为切口,核心原因是主创团队本身都很喜欢阅读。两位成长经历几乎全然不同的创始人,都认可书籍是普世的智慧来源。
吴俊东从江西小城长大,学生时代最主要的“扩展视野”方式就是读书;李可佳家里有一个巨大的书柜,也从小酷爱阅读。
二人回忆起,刚刚在硅谷创业起步时,日常娱乐并不多,晚上散步常常不约而同走进街角的小书店,随手翻几页。
对他们来说,在容易过时的技能和不断追逐最新前沿的信息之间,书中的科学、哲学、艺术、美等等知识可以穿越周期,让人的内心变得丰盈。
为了触达目标客户,Aibrary团队的市场开拓同时分为“空军”和“陆军”。
空军是LinkedIn、YouTube,Aibrary把创业故事做成播客发布在这些平台,坦诚地讨论踩过的坑,和用户形成共鸣;陆军是美国本土读书会、高校的落地合作。
“我在字节和互联网创业时,积累了不少中国C端产品的推广经验,这些放到美国依然是有效的。”李可佳说,“直播、社群、投流等等打法,中国团队上手更快。”
△团队在硅谷创业初期经常去的Mountain View老牌书店Books Inc.,图片:采访人提供
持续学习新范式:从人到Agent
有趣之处在于,Ouraca终身学习的用户,不一定是人类,也可能是Agent。Ouraca旗下新产品BotLearn的出现,就是这条路的进一步探索。
Clawdbot(现已更名为“OpenClaw”)爆火之后,Ouraca团队快速推出了BotLearn——一个“Agent的持续学习社群”,团队称之为“Bot大学”
用户可以通过将代码复制给自己OpenClaw的Agent,把Agent“送到”BotLearn学习。
△BotLearn页面,图片:网页截图
在这里,Agent间会互相分享。比如某个Agent学会了高效抓取某垂类信息的方法,它可以把这套方法发布到社区,其他Agent在这里学习、复用、再补充心得。
项目上线一天内就吸引了近500个Agents“入学”。登录BotLearn,还可以查看Agent们发布的“学习心得”。
尽管产品形态仍在早期,Etna投资人Penny Deng却认为这是一枚对的种子:
“Agent在2026年会大爆发,产生很多我们想象不到的变化。以前以人为主体设计的产品,可能需要以Agent为主体重新设计一遍。
OpenClaw发布后,Ouraca是反应最快的团队之一——他们立刻以Agent为主体设计了BotLearn。Agents之间互相交换技能、互相学习,再反哺给人类。这种网络效应在传统教育产品里从未出现过。”
△Agent在BotLearn发布的学习心得,图片:网页截图
深圳证监局责令深圳市启富证券投资顾问有限公司改正
中国创新药对外授权年交易额突破千亿美元,4年增长近10倍
gsap 配置解读 --7
什么是registerEffect
<div class="card">
<h1>案例 41:registerEffect 自定义效果</h1>
<p>封装常用动画为可复用效果。</p>
<div class="row">
<div class="box" id="boxA"></div>
<div class="box" id="boxB"></div>
<div class="box" id="boxC"></div>
</div>
<button id="play">播放效果</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script>
const playButton = document.querySelector("#play");
// 注册自定义效果
gsap.registerEffect({
name: "popIn",
effect: (targets, config) => {
return gsap.fromTo(
targets,
{ scale: 0.6, opacity: 0 },
{
scale: 1,
opacity: 1,
duration: config.duration,
stagger: config.stagger,
ease: config.ease
}
);
},
defaults: { duration: 0.6, stagger: 0.08, ease: "back.out(1.6)" },
extendTimeline: true
});
playButton.addEventListener("click", () => {
gsap.effects.popIn(".box");
});
</script>
gsap.registerEffect() 是 GSAP(GreenSock Animation Platform) 提供的一个强大功能,用于将常用的动画逻辑封装成可复用、可配置的“自定义效果”(custom effect),就像创建自己的动画“函数库”一样。
📌 简单定义:
registerEffect允许你定义一个命名动画模板(如"popIn"),之后通过gsap.effects.effectName(targets, config)一行代码即可在任意地方调用它,实现代码复用、语义化和团队协作标准化。
✅ 核心作用:
1. 封装复杂动画逻辑
把重复的 fromTo、timeline 等逻辑打包成一个“黑盒”。
2. 支持参数配置
通过 config 对象传入自定义参数(如 duration、stagger)。
3. 提供默认值
通过 defaults 设置常用参数的默认值,调用时可省略。
4. 无缝集成 GSAP 生态
- 可用于
Timeline - 支持
stagger - 返回动画实例(可
play/pause/reverse)
🔧 在你的代码中:
gsap.registerEffect({
name: "popIn", // 效果名称
effect: (targets, config) => {
return gsap.fromTo(
targets,
{ scale: 0.6, opacity: 0 },
{
scale: 1,
opacity: 1,
duration: config.duration,
stagger: config.stagger,
ease: config.ease
}
);
},
defaults: {
duration: 0.6,
stagger: 0.08,
ease: "back.out(1.6)"
},
extendTimeline: true // 允许在 Timeline 中直接使用 .popIn()
});
然后调用:
gsap.effects.popIn(".box"); // 所有 .box 元素执行 popIn 动画
效果:
- 三个盒子依次从小且透明放大到正常尺寸;
- 带有弹性回弹(
back.out缓动); - 每个盒子延迟
0.08s启动(stagger)。
🌟 优势 vs 普通函数封装:
| 普通函数 | registerEffect |
|---|---|
| 需手动管理返回值 | ✅ 自动注册到 gsap.effects 命名空间 |
| 无法在 Timeline 中直接使用 | ✅ 开启 extendTimeline: true 后可用 tl.popIn(...)
|
| 参数处理需自己写 | ✅ 自动合并 config 与 defaults
|
| 团队协作需文档说明 | ✅ 效果名称即文档(gsap.effects.popIn 语义清晰) |
⚙️ 参数详解:
| 字段 | 说明 |
|---|---|
name |
效果名称(字符串),注册后可通过 gsap.effects[name] 调用 |
effect(targets, config) |
动画工厂函数: - targets: DOM 元素或选择器 - config: 用户传入的配置对象 |
defaults |
默认配置(会被 config 覆盖) |
extendTimeline |
若为 true,可在 Timeline 实例上直接调用该效果: timeline.popIn(".box")
|
🛠️ 更多使用方式:
1. 传入自定义参数
gsap.effects.popIn(".item", {
duration: 1,
stagger: 0.2,
ease: "elastic.out(1, 0.5)"
});
2. 在 Timeline 中使用(需 extendTimeline: true)
const tl = gsap.timeline();
tl.popIn(".box", { duration: 0.5 });
3. 返回 Timeline 实现复杂效果
effect: (targets) => {
const tl = gsap.timeline();
tl.from(targets, { x: -100, opacity: 0 })
.to(targets, { rotation: 360 }, "<");
return tl;
}
🎨 典型应用场景:
- UI 组件库:统一按钮点击、卡片入场、提示弹出等动效
- 设计系统:确保全站动画风格一致
- 游戏开发:角色受伤、道具拾取等特效复用
- 快速原型:设计师给效果命名,开发者一键实现
⚠️ 注意事项:
- 效果名称全局唯一,避免冲突;
-
targets可以是单个元素、数组或 CSS 选择器字符串; -
免费功能:
registerEffect是 GSAP 核心 API,无需额外插件或会员; - 如果效果内部使用了
ScrollTrigger等插件,需确保已注册。
📚 官方文档:
✅ 总结:
gsap.registerEffect()是 GSAP 的“动画组件化”方案——它将零散的动画代码提炼为可命名、可配置、可复用的效果模块,大幅提升开发效率与代码可维护性,是构建大型交互动效项目的最佳实践。
什么是ScrollTrigger
<header>
<h1>案例 42:ScrollTrigger Pin 固定</h1>
<p>滚动时固定元素并配合进度动画。</p>
</header>
<div class="spacer"></div>
<section>
<div class="panel">
<div class="pin" id="pin">我被固定了</div>
</div>
</section>
<section>
<div class="panel">继续往下滚动</div>
</section>
<div class="spacer"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
<script>
// 注册 ScrollTrigger 插件
gsap.registerPlugin(ScrollTrigger);
// pin + scrub 绑定滚动进度
gsap.to("#pin", {
scale: 0.8,
rotation: 10,
scrollTrigger: {
trigger: "#pin",
start: "top center",
end: "+=400",
scrub: true,
pin: true
}
});
</script>
ScrollTrigger 是 GSAP(GreenSock Animation Platform) 中最强大、最受欢迎的插件之一,它能将滚动行为(用户上下滑动页面)与GSAP 动画无缝结合,实现如“视差滚动”、“进度条动画”、“元素固定(Pin)”、“滚动触发动画”等高级交互动效。
📌 简单定义:
ScrollTrigger让你把任何 GSAP 动画“绑定”到页面滚动位置上——当用户滚动到某个区域时,动画自动播放、暂停、反向或跟随滚动进度实时更新。
它本质上是一个滚动驱动的动画控制器。
✅ 核心能力:
1. 触发动画(Toggle)
- 当元素进入/离开视口时,播放/暂停动画。
scrollTrigger: {
trigger: ".section",
start: "top center", // 当 .section 顶部到达视口中心时触发
toggleActions: "play none none reverse"
}
2. 滚动进度驱动(Scrub)
- 动画进度完全跟随滚动位置,形成“拖拽式”效果。
scrollTrigger: {
scrub: true // 滚动多少,动画就播放到多少
}
3. 固定元素(Pin) ← 你的案例重点
- 在滚动过程中将元素“钉”在视口某处,直到滚动结束。
scrollTrigger: {
pin: true, // 固定 trigger 元素
// 或 pin: "#otherElement" 固定其他元素
end: "+=400" // 固定持续 400px 的滚动距离
}
4. 标记与指示器(Markers)
- 开发时显示调试线(start/end 位置),方便调整。
scrollTrigger: {
markers: true // 显示绿色(start)和红色(end)标记线
}
gsap.to("#pin", {
scale: 0.8,
rotation: 10,
scrollTrigger: {
trigger: "#pin", // 监听 #pin 元素的滚动位置
start: "top center", // 当 #pin 顶部到达视口中心时开始
end: "+=400", // 滚动再往下 400px 后结束
scrub: true, // 动画进度随滚动平滑更新
pin: true // 在 start → end 区间内,#pin 被固定住
}
});
用户体验流程:
- 向下滚动,当
#pin到达屏幕中央时 → -
#pin被固定在当前位置(不再随页面滚动而移动); - 继续滚动的 400px 过程中,
#pin逐渐缩小并旋转(scale: 0.8,rotation: 10); - 滚动超过 400px 后,固定解除,
#pin随页面继续滚动。
💡 这就是“固定 + 进度动画”的经典组合,常用于产品展示、故事叙述等场景。
🌟 典型应用场景:
| 效果 | 描述 |
|---|---|
| 视差滚动 | 背景图慢速移动,前景快移 |
| 进度条/数字计数器 | 滚动时数字从 0 增长到目标值 |
| 元素入场/离场 | 卡片滑入、标题淡入 |
| 固定导航栏 | 滚动到某区域时导航栏吸顶 |
| 横向滚动画廊 | 垂直滚动驱动水平位移 |
| 3D 视差 | 滚动时多层元素产生景深感 |
⚙️ 关键配置项说明:
| 配置 | 作用 |
|---|---|
trigger |
触发动画的参考元素(默认为动画目标) |
start |
动画开始的滚动位置(如 "top center") |
end |
动画结束的滚动位置(如 "bottom bottom" 或 "+=500") |
scrub |
true = 平滑跟随滚动;number = 延迟秒数 |
pin |
true = 固定 trigger;"#id" = 固定指定元素 |
toggleActions |
控制进入/离开时的播放行为(play pause resume reset) |
📏 位置语法:
"edge1 edge2"
edge1: trigger 元素的边缘(top/bottom/center)edge2: 视口的边缘(top/bottom/center)
例如:"top bottom"= trigger 顶部碰到视口底部时触发
⚠️ 注意事项:
- 必须注册插件:
gsap.registerPlugin(ScrollTrigger); -
pin会自动包裹元素并设置position: sticky或fixed,无需手动写 CSS; - 如果页面高度不足,可能看不到完整效果(需确保有足够滚动空间);
-
免费功能:
ScrollTrigger是 GSAP 标准插件(无需 Club 会员); - 移动端性能优秀,支持触摸滚动。
📚 官方资源:
✅ 总结:
ScrollTrigger是 GSAP 赋予网页“电影级滚动叙事能力”的核心插件——它将枯燥的滚动转化为精准、流畅、富有表现力的动画触发器,是现代高端网站交互动效的事实标准。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GSAP 案例 43 - ScrollTrigger toggleClass</title>
<style>
body {
margin: 0;
font-family: "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
header {
padding: 80px 24px 40px;
text-align: center;
}
section {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.panel {
width: 70%;
max-width: 680px;
padding: 32px;
border-radius: 24px;
background: #111827;
box-shadow: 0 25px 60px rgba(15, 23, 42, 0.5);
transition: transform 0.3s ease, background 0.3s ease;
}
.panel.active {
transform: scale(1.03);
background: #1f2937;
}
.spacer {
height: 40vh;
}
</style>
</head>
<body>
<header>
<h1>案例 43:toggleClass 触发样式</h1>
<p>滚动到卡片时添加高亮样式。</p>
</header>
<div class="spacer"></div>
<section>
<div class="panel" id="panel">滚动到这里会高亮</div>
</section>
<div class="spacer"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
<script>
// 注册 ScrollTrigger 插件
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.create({
trigger: "#panel",
start: "top 70%",
end: "top 40%",
toggleClass: "active"
});
</script>
</body>
</html>
这段代码展示了 GSAP 的 ScrollTrigger 插件中一个非常实用的功能:toggleClass。它的作用是——
当用户滚动到指定区域时,自动给目标元素添加或移除一个 CSS 类名,从而触发样式变化(如高亮、缩放、变色等),无需手动编写 JavaScript 逻辑。
🔍 逐行解析核心部分:
ScrollTrigger.create({
trigger: "#panel", // 监听 #panel 元素的滚动位置
start: "top 70%", // 当 #panel 的顶部进入视口 70% 位置时 → 添加类
end: "top 40%", // 当 #panel 的顶部到达视口 40% 位置时 → 移除类
toggleClass: "active" // 要切换的 CSS 类名
});
📏 滚动位置语法说明:
-
"top 70%"表示:trigger 元素的顶部 与 视口的 70% 高度线 对齐。 - 视口从上到下:
0%(顶部)→100%(底部) - 所以
70%在视口偏下方,40%在视口偏上方。
✅ 效果逻辑:
- 向下滚动,当
#panel进入视口下部(70%) 时 → 添加.active- 继续滚动,当
#panel上升到视口上部(40%) 时 → 移除.active- 向上滚动时行为相反(进入
end区域加类,离开start区域去类)
🎨 CSS 配合实现高亮:
.panel {
/* 默认样式 */
background: #111827;
transform: scale(1);
}
.panel.active {
/* 滚动到区域时激活 */
background: #1f2937; /* 背景变亮 */
transform: scale(1.03); /* 轻微放大 */
}
通过 transition 实现了平滑过渡,视觉反馈更自然。
✅ toggleClass 的优势:
| 传统方式 | 使用 toggleClass
|
|---|---|
需监听 scroll 事件 + 计算位置 + 手动 classList.add/remove
|
✅ 一行配置自动完成 |
| 容易性能差(频繁触发 scroll) | ✅ ScrollTrigger 内部优化(requestAnimationFrame + 节流) |
| 逻辑分散,难维护 | ✅ 声明式写法,意图清晰 |
⚙️ 其他用法示例:
1. 切换多个类
toggleClass: "highlight zoom-in"
2. 作用于其他元素
ScrollTrigger.create({
trigger: "#section",
toggleClass: { targets: ".nav-item", className: "current" }
});
3. 配合 onToggle 回调
ScrollTrigger.create({
trigger: "#panel",
toggleClass: "active",
onToggle: self => console.log("是否激活:", self.isActive)
});
⚠️ 注意事项:
-
toggleClass是ScrollTrigger的内置功能,无需额外插件; - 类名切换是双向的:进入区间加类,离开区间去类;
- 如果
start和end顺序颠倒(如start: "top 40%",end: "top 70%"),则行为反转(常用于“离开时激活”); - 移动端兼容性良好,支持触摸滚动。
📚 官方文档:
✅ 总结:
ScrollTrigger.toggleClass是实现“滚动高亮”、“区域激活”等交互的最简洁方案——它将复杂的滚动监听与 DOM 操作封装成声明式配置,让你专注于 CSS 样式设计,大幅提升开发效率与代码可读性。
getProperty + getVelocity 是什么
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GSAP 案例 44 - velocity / getProperty</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
.card {
width: 620px;
padding: 28px;
border-radius: 20px;
background: #111827;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
}
.track {
height: 100px;
border-radius: 14px;
background: #0b1220;
position: relative;
margin: 18px 0;
}
.dot {
width: 32px;
height: 32px;
border-radius: 50%;
background: #a3e635;
position: absolute;
top: 34px;
left: 20px;
}
.info {
font-size: 14px;
color: #94a3b8;
}
button {
width: 100%;
margin-top: 12px;
padding: 12px 16px;
border: none;
border-radius: 12px;
font-size: 14px;
cursor: pointer;
background: #a3e635;
color: #0f172a;
font-weight: 600;
}
</style>
</head>
<body>
<div class="card">
<h1>案例 44:getProperty + getVelocity</h1>
<p>读取属性与速度,了解当前运动状态。</p>
<div class="track">
<div class="dot" id="dot"></div>
</div>
<div class="info" id="info">x: 0 | velocity: 0</div>
<button id="play">播放</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script>
const dot = document.querySelector("#dot");
const info = document.querySelector("#info");
const playButton = document.querySelector("#play");
const tween = gsap.to(dot, {
x: 480,
duration: 2,
ease: "power1.inOut",
paused: true,
onUpdate: () => {
const x = Math.round(gsap.getProperty(dot, "x"));
const velocity = Math.round(tween.getVelocity());
info.textContent = `x: ${x} | velocity: ${velocity}`;
}
});
playButton.addEventListener("click", () => {
tween.restart();
});
</script>
</body>
</html>
在 GSAP(GreenSock Animation Platform) 中,gsap.getProperty() 和 tween.getVelocity() 是两个用于实时读取动画状态的实用工具方法,常用于调试、交互反馈或基于物理状态的逻辑判断。
✅ 一、gsap.getProperty(target, property, unit?)
🔍 作用:
获取目标元素当前被 GSAP 控制的某个 CSS 属性或 transform 值。
即使该属性是通过 transform(如 x, y, rotation)设置的,也能正确返回数值。
📌 语法:
gsap.getProperty(element, "propertyName", "unit?");
-
element:DOM 元素 -
"propertyName":属性名,如"x","opacity","backgroundColor" -
"unit?"(可选):指定返回单位,如"px","deg";默认返回纯数字
const x = Math.round(gsap.getProperty(dot, "x"));
- 实时读取小球当前的 水平位移
x值(以像素为单位的数字) - 即使你用
gsap.to(dot, { x: 480 })设置的是“相对位移”,getProperty也能返回绝对计算值
⚠️ 注意:它读取的是 GSAP 内部记录的值,不是
getComputedStyle()的结果,因此更准确、更高效(尤其对transform属性)。
✅ 二、tween.getVelocity()
🔍 作用:
获取当前动画目标属性的瞬时速度(单位/秒)。
对于多属性动画(如同时动 x 和 y),默认返回第一个属性的速度;也可指定属性:
tween.getVelocity("x") // 获取 x 方向速度
📌 特点:
- 速度单位:每秒变化量(如
px/s,deg/s) - 方向有正负:
+表示正向(如向右),-表示反向(如向左) - 在
onUpdate回调中调用最准确
const velocity = Math.round(tween.getVelocity());
- 返回小球在
x方向上的当前速度(px/s) - 动画开始和结束时速度接近
0(因为使用了power1.inOut缓动) - 中间时刻速度最大(约 ±240 px/s)
🔬 动画过程中的典型值(duration: 2s, x: 0 → 480):
| 时间 |
x 值 |
velocity (px/s) |
说明 |
|---|---|---|---|
| 0s | 0 | 0 | 起始,静止 |
| 0.5s | ~120 | ~240 | 加速到峰值 |
| 1.0s | 240 | 0 | 中点,瞬时静止(inOut 对称) |
| 1.5s | ~360 | ~-240 | 反向加速(减速阶段) |
| 2.0s | 480 | 0 | 结束,静止 |
📌 注意:
power1.inOut是先加速后减速,在中点速度为 0(这是缓动函数决定的)。
🌟 典型应用场景:
| 场景 | 用途 |
|---|---|
| 物理模拟 | 根据速度决定反弹强度、摩擦力 |
| 交互反馈 | 鼠标松开时根据拖拽速度继续滑动(惯性滚动) |
| 游戏开发 | 判断角色是否在移动、碰撞检测 |
| 动画调试 | 实时监控属性与速度变化 |
| 动态效果 | 速度越大,粒子越多 / 模糊越强 |
⚠️ 注意事项:
-
getProperty仅能读取 GSAP 已经控制过的属性; -
getVelocity()必须在 动画进行中 调用才有意义(暂停/结束后返回 0); - 对于
Timeline,需在具体tween上调用getVelocity(); - 这两个方法都是 GSAP 核心 API,无需额外插件。
📚 官方文档:
-
getProperty: greensock.com/docs/v3/GSA… -
getVelocity: greensock.com/docs/v3/GSA…
✅ 总结:
gsap.getProperty()和tween.getVelocity()是 GSAP 提供的“动画状态探测器”——它们让你能精确掌握元素当前的位置和运动速度,为构建基于物理、交互或调试需求的高级动画提供了关键数据支持。
什么是utils.random / wrap / interpolate
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GSAP 案例 45 - utils random/wrap/interpolate</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Segoe UI", sans-serif;
background: #0b1020;
color: #e2e8f0;
}
.card {
width: 640px;
padding: 28px;
border-radius: 20px;
background: #111827;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
}
.stage {
height: 160px;
border-radius: 16px;
background: #0f172a;
position: relative;
overflow: hidden;
margin: 18px 0;
}
.dot {
width: 30px;
height: 30px;
border-radius: 50%;
background: #38bdf8;
position: absolute;
top: 65px;
left: 20px;
}
button {
width: 100%;
padding: 12px 16px;
border: none;
border-radius: 12px;
font-size: 14px;
cursor: pointer;
background: #38bdf8;
color: #0f172a;
font-weight: 600;
}
.info {
margin-top: 8px;
font-size: 13px;
color: #94a3b8;
}
</style>
</head>
<body>
<div class="card">
<h1>案例 45:utils.random / wrap / interpolate</h1>
<p>快速生成随机值、循环值与插值。</p>
<div class="stage">
<div class="dot" id="dot"></div>
</div>
<button id="play">随机移动</button>
<div class="info" id="info"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script>
const dot = document.querySelector("#dot");
const info = document.querySelector("#info");
const playButton = document.querySelector("#play");
// random 生成随机数,wrap 限制循环范围,interpolate 计算插值
const randomX = gsap.utils.random(40, 540, 1);
const wrapHue = gsap.utils.wrap(0, 360);
const mix = gsap.utils.interpolate(0, 1);
let step = 0;
playButton.addEventListener("click", () => {
const x = randomX;
const hue = wrapHue(step * 80);
const scale = mix(0.7, 1.4);
gsap.to(dot, {
x,
scale,
backgroundColor: `hsl(${hue}, 90%, 60%)`,
duration: 0.6,
ease: "power2.out"
});
info.textContent = `x: ${x}px | hue: ${hue} | scale: ${scale.toFixed(2)}`;
step += 1;
});
</script>
</body>
</html>
在 GSAP(GreenSock Animation Platform) 中,gsap.utils 是一个内置的实用工具函数集合,提供了许多高效、简洁的辅助方法,用于处理动画中常见的数学和逻辑操作。
你提到的三个方法:
gsap.utils.randomgsap.utils.wrapgsap.utils.interpolate
是其中最常用、最强大的三个工具,分别用于生成随机值、循环限制范围和计算插值。它们让复杂逻辑变得简单,且性能优异。
✅ 1. gsap.utils.random(min, max, [step])
🔍 作用:
生成一个指定范围内的随机数(可选步长)。
📌 语法:
const rand = gsap.utils.random(min, max, step);
-
min:最小值 -
max:最大值 -
step(可选):步长(如1表示整数,0.1表示保留一位小数)
⚠️ 注意:它返回的是一个函数!调用该函数才会生成新随机数。
但如果你直接传数字(如你的代码),GSAP 会自动缓存一次结果(等价于 random(40, 540, 1)())。
const randomX = gsap.utils.random(40, 540, 1); // 实际返回一个数字(因为未作为函数调用)
- 每次点击按钮,
x都是一个 40~540 之间的整数 - 用于让小球随机水平移动
✅ 更推荐写法(每次点击都新随机):
playButton.addEventListener("click", () => {
const x = gsap.utils.random(40, 540, 1)(); // 加 () 才是函数调用
});
✅ 2. gsap.utils.wrap(min, max)
🔍 作用:
将任意数值“包裹”到
[min, max)范围内,实现无缝循环(类似取模%,但支持浮点数和负数)。
📌 语法:
const wrap = gsap.utils.wrap(min, max);
const result = wrap(value); // 返回循环后的值
🌰 例子:
const wrap360 = gsap.utils.wrap(0, 360);
wrap360(400) // → 40 (400 - 360)
wrap360(-20) // → 340 (-20 + 360)
wrap360(720) // → 0 (720 % 360)
const hue = wrapHue(step * 80); // step=0→0, step=1→80, step=2→160, step=3→240, step=4→320, step=5→40→40...
- 实现 HSL 色相(0~360)的循环切换,避免颜色溢出
- 视觉上形成:蓝 → 紫 → 红 → 橙 → 黄 → 绿 → 蓝 … 的循环
✅ 3. gsap.utils.interpolate(start, end)
🔍 作用:
创建一个插值函数,根据进度值(0~1)计算
start到end之间的中间值。
📌 语法:
const interpolator = gsap.utils.interpolate(start, end);
const value = interpolator(progress); // progress ∈ [0, 1]
🌰 例子:
const mix = gsap.utils.interpolate(10, 50);
mix(0) // → 10
mix(0.5) // → 30
mix(1) // → 50
它不仅支持数字,还支持颜色、数组、甚至对象!
const scale = mix(0.7, 1.4); // ❌ 这里有误!
正确用法应该是:
// 先创建插值函数
const scaleInterp = gsap.utils.interpolate(0.7, 1.4);
// 再用 0~1 之间的值去插值(比如用 Math.random())
const scale = scaleInterp(Math.random());
这会导致 scale = 0.7(因为 mix(0.7) ≈ 0.7,第二个参数被忽略)。
✅ 修复建议:
// 方案 1:直接随机 scale
const scale = gsap.utils.random(0.7, 1.4, 0.01)();
// 方案 2:用插值 + 随机进度
const getScale = gsap.utils.interpolate(0.7, 1.4);
const scale = getScale(Math.random());
🌟 总结对比:
| 工具 | 用途 | 返回值 | 典型场景 |
|---|---|---|---|
random(min, max, step) |
生成随机数 | 函数(或直接数值) | 随机位置、延迟、颜色 |
wrap(min, max) |
循环限制数值 | 函数 | 色相循环、角度归一化、无限滚动 |
interpolate(a, b) |
计算 a→b 的中间值 | 函数 | 动态缩放、颜色混合、进度映射 |
🎯 高级技巧(Bonus):
支持颜色插值:
const colorMix = gsap.utils.interpolate("red", "blue");
colorMix(0.5); // → "rgb(128, 0, 128)"(紫色)
数组插值:
const pointMix = gsap.utils.interpolate([0, 0], [100, 200]);
pointMix(0.5); // → [50, 100]
📚 官方文档:
✅ 最终总结:
gsap.utils.random、wrap和interpolate是 GSAP 提供的“动画数学工具箱”——它们以极简 API 解决了随机性、循环性和连续性三大常见需求,让你无需手写复杂公式,即可构建丰富、动态、可控的交互动效。
什么是timeScale / yoyoEase
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GSAP 案例 46 - timeScale / yoyoEase</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
.card {
width: 620px;
padding: 28px;
border-radius: 20px;
background: #111827;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
}
.track {
height: 90px;
border-radius: 14px;
background: #0b1220;
position: relative;
margin: 18px 0;
}
.ball {
width: 46px;
height: 46px;
border-radius: 50%;
background: #f472b6;
position: absolute;
top: 22px;
left: 20px;
}
.controls {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
button {
padding: 10px 12px;
border: none;
border-radius: 12px;
font-size: 13px;
cursor: pointer;
background: #1f2937;
color: #e5e7eb;
}
button.primary {
background: #f472b6;
color: #0f172a;
font-weight: 600;
}
</style>
</head>
<body>
<div class="card">
<h1>案例 46:timeScale 与 yoyoEase</h1>
<p>调整播放速度,并在往返时使用不同缓动。</p>
<div class="track">
<div class="ball" id="ball"></div>
</div>
<div class="controls">
<button id="slow">慢速</button>
<button class="primary" id="play">播放</button>
<button id="fast">快速</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script>
const ball = document.querySelector("#ball");
const playButton = document.querySelector("#play");
const slowButton = document.querySelector("#slow");
const fastButton = document.querySelector("#fast");
// yoyoEase 可以在回放时使用不同缓动
const tween = gsap.to(ball, {
x: 520,
duration: 1.6,
ease: "power2.out",
yoyo: true,
repeat: -1,
yoyoEase: "power2.in",
paused: true
});
playButton.addEventListener("click", () => {
tween.paused(!tween.paused());
});
slowButton.addEventListener("click", () => {
tween.timeScale(0.6);
});
fastButton.addEventListener("click", () => {
tween.timeScale(1.6);
});
</script>
</body>
</html>
在 GSAP(GreenSock Animation Platform) 中,timeScale 和 yoyoEase 是两个用于精细控制动画播放行为的强大特性:
✅ 一、timeScale:控制动画播放速度
🔍 作用:
调整动画的时间流速,实现快放、慢放、甚至倒放,而不改变
duration。
📌 基本用法:
tween.timeScale(1); // 正常速度(默认)
tween.timeScale(0.5); // 半速(慢动作)
tween.timeScale(2); // 2倍速(快进)
tween.timeScale(-1); // 反向播放(倒放)
-
timeScale是一个乘数因子:-
1= 100% 速度 -
0.6= 60% 速度(变慢) -
1.6= 160% 速度(变快)
-
slowButton.addEventListener("click", () => {
tween.timeScale(0.6); // 慢速
});
fastButton.addEventListener("click", () => {
tween.timeScale(1.6); // 快速
});
- 点击按钮即可实时改变动画速度,无需重新创建动画;
- 即使动画正在播放,也能无缝变速。
⚠️ 注意:
timeScale不影响duration的设定值,只影响实际播放耗时。
✅ 二、yoyoEase:为往返动画(yoyo)指定不同的缓动函数
🔍 背景知识:什么是 yoyo?
- 当设置
repeat: -1(无限重复) +yoyo: true时, - 动画会正向播放 → 反向播放 → 正向播放 → …,形成“来回”效果。
📌 默认问题:
- 如果只设
ease: "power2.out", - 那么正向和反向都使用同一个缓动,导致:
- 正向:先快后慢(out)
- 反向:先慢后快(因为是倒放 out)
但很多时候,我们希望去程和回程有不同的运动感觉!
✅ 解决方案:yoyoEase
yoyoEase允许你为“回程”(反向播放)单独指定一个缓动函数。
💡 在你的代码中:
const tween = gsap.to(ball, {
x: 520,
duration: 1.6,
ease: "power2.out", // 去程:先快后慢(弹出感)
yoyo: true,
repeat: -1,
yoyoEase: "power2.in", // 回程:先慢后快(吸入感)
paused: true
});
🎯 视觉效果对比:
| 阶段 | 缓动 | 运动特点 |
|---|---|---|
| 去程(→) | power2.out |
快速冲出去,然后缓缓停下 |
| 回程(←) | power2.in |
缓缓启动,然后快速收回 |
这比单纯用
yoyo: true+ 单一缓动更自然、更有“弹性”!
🔬 技术细节补充:
1. yoyoEase 的工作原理:
- GSAP 不会倒放
ease,而是正向播放yoyoEase来模拟回程。 - 所以
ease: "out"+yoyoEase: "in"= 去程快停 + 回程快启,非常合理。
2. yoyoEase 支持所有缓动类型:
yoyoEase: "elastic.out"
yoyoEase: "bounce.inOut"
yoyoEase: CustomEase.create(...)
3. timeScale 是可叠加的:
tween.timeScale(2).timeScale(0.5); // 最终 = 1(2 * 0.5)
🌟 典型应用场景:
| 场景 | 用途 |
|---|---|
| UI 微交互动效 | 按钮点击“弹跳”:去程快,回程缓 |
| 游戏对象移动 | 敌人巡逻:匀速去,加速回 |
| 视频/音频播放器 | 拖拽预览时慢放,正常播放时快放 |
| 科学可视化 | 模拟不同速度下的物理过程 |
⚠️ 注意事项:
-
yoyoEase仅在yoyo: true时生效; -
timeScale影响整个时间线或 tween,包括子动画; - 两者都是 GSAP 核心功能,无需额外插件;
-
timeScale(0)会暂停动画(等价于paused(true))。
📚 官方文档:
-
timeScale: greensock.com/docs/v3/GSA… -
yoyoEase: greensock.com/docs/v3/GSA…
✅ 总结:
timeScale让你像“调速旋钮”一样控制动画节奏,而yoyoEase则赋予往返动画“去程与回程不同性格”的能力——两者结合,可构建出既灵活又富有表现力的交互动效,是 GSAP 高级动画控制的标志性特性。
Wget Cheatsheet
Basic Downloads
Download files to the current directory.
| Command | Description |
|---|---|
wget URL
|
Download a file |
wget -q URL |
Download silently (no output) |
wget -nv URL |
Less verbose (errors and basic info only) |
wget -v URL |
Verbose output (default) |
wget -d URL |
Debug output |
Save Options
Control where and how files are saved.
| Command | Description |
|---|---|
wget -O name.txt URL |
Save with a specific filename |
wget -O - URL |
Output to stdout |
wget -P /path/ URL |
Save to a specific directory |
wget --content-disposition URL |
Use server-suggested filename |
wget --no-clobber URL |
Skip if file already exists |
wget -nc URL |
Short form of --no-clobber
|
Resume and Speed
Resume interrupted downloads and control bandwidth.
| Command | Description |
|---|---|
wget -c URL |
Resume a partial download |
wget --limit-rate=2m URL |
Limit speed to 2 MB/s |
wget --limit-rate=500k URL |
Limit speed to 500 KB/s |
wget -b URL |
Download in background |
wget -b -o log.txt URL |
Background with custom log |
Multiple Files
Download several files at once.
| Command | Description |
|---|---|
wget URL1 URL2 URL3 |
Download multiple URLs |
wget -i urls.txt |
Download URLs from a file |
wget -i - < urls.txt |
Read URLs from stdin |
Recursive Download
Download directory trees and linked pages.
| Command | Description |
|---|---|
wget -r URL |
Recursive download |
wget -r -l 2 URL |
Limit depth to 2 levels |
wget -r --no-parent URL |
Do not ascend to parent directory |
wget -r --accept=jpg,png URL |
Accept only these file types |
wget -r --reject=mp4,avi URL |
Reject these file types |
wget -r -A "*.pdf" URL |
Download only PDFs |
wget -r --follow-tags=a URL |
Follow only <a> tags |
Website Mirroring
Create offline copies of websites.
| Command | Description |
|---|---|
wget -m URL |
Mirror a website |
wget -m -k -p URL |
Mirror with local link conversion |
wget -m -k -p -E URL |
Mirror and add .html extensions |
wget -m --wait=2 URL |
Mirror with 2 second delay between requests |
wget -m --random-wait URL |
Mirror with random delay (0.5x–1.5x of --wait) |
Authentication
Access protected resources.
| Command | Description |
|---|---|
wget --user=USER --password=PASS URL |
HTTP basic auth |
wget --ask-password URL |
Prompt for password |
wget --header="Authorization: Bearer TOKEN" URL |
Token auth |
wget --ftp-user=USER --ftp-password=PASS URL |
FTP auth |
Headers and User Agent
Send custom headers and change identity.
| Command | Description |
|---|---|
wget --header="Key: Value" URL |
Add custom header |
wget --header="Accept: application/json" URL |
Request JSON |
wget -U "CustomAgent/1.0" URL |
Change user agent |
wget --referer=URL URL |
Set referer header |
SSL/TLS
Handle HTTPS connections and certificates.
| Command | Description |
|---|---|
wget --no-check-certificate URL |
Skip certificate verification |
wget --ca-certificate=ca.crt URL |
Use custom CA certificate |
wget --certificate=client.crt URL |
Client certificate |
wget --private-key=client.key URL |
Client private key |
wget --https-only URL |
Only follow HTTPS links |
FTP
Work with FTP servers.
| Command | Description |
|---|---|
wget ftp://server/file |
Download file |
wget -r ftp://server/dir/ |
Download directory recursively |
wget --ftp-user=USER --ftp-password=PASS ftp://server/ |
Authenticated FTP |
wget --no-passive-ftp ftp://server/file |
Use active FTP mode |
wget --no-remove-listing ftp://server/dir/ |
Keep .listing files |
Retries and Timeouts
Control retry behavior and connection timing.
| Command | Description |
|---|---|
wget --tries=5 URL |
Retry up to 5 times |
wget --retry-connrefused URL |
Retry on connection refused |
wget --waitretry=10 URL |
Wait 10 seconds between retries |
wget --timeout=30 URL |
Set all timeouts to 30 seconds |
wget --connect-timeout=10 URL |
Connection timeout only |
wget --read-timeout=30 URL |
Read timeout only |
wget --dns-timeout=5 URL |
DNS timeout only |
Output and Logging
Control progress display and log output.
| Command | Description |
|---|---|
wget -q URL |
Suppress all output |
wget -nv URL |
Print errors and basic info only |
wget -o log.txt URL |
Log output to file |
wget -a log.txt URL |
Append to log file |
wget --show-progress -q URL |
Quiet but show progress bar |
wget --progress=dot URL |
Dot-style progress indicator |
Timestamping and Caching
Download only new or updated files.
| Command | Description |
|---|---|
wget -N URL |
Download only if remote file is newer |
wget --no-cache URL |
Disable server-side caching |
wget --spider URL |
Check if URL exists (do not download) |
wget --spider -r URL |
Check all links recursively |
Common Patterns
Frequently used command combinations.
| Command | Description |
|---|---|
wget -q -O - URL | tar -xzf - -C /path |
Download and extract in one step |
wget -c --limit-rate=1m -P /tmp URL |
Resume to directory with speed limit |
wget -r -np -nH --cut-dirs=2 URL |
Recursive without host and path prefix |
wget -m -k -p --wait=1 -e robots=off URL |
Full mirror ignoring robots.txt |
Related Guides
Use these articles for detailed wget workflows.
| Guide | Description |
|---|---|
Wget Command in Linux with Examples
|
Full wget tutorial with practical examples |
Curl Command in Linux with Examples
|
Alternative HTTP client for API interactions |
How to Use SCP Command to Securely Transfer Files
|
Transfer files between hosts over SSH |
How to Use Rsync for Local and Remote Data Transfer
|
Incremental file sync and transfer |
做了个 EPUB 阅读器,被「阅读进度同步」折磨了一周,总结 4 个血泪教训
你做过"打开一本书,自动回到上次阅读位置"这个功能吗?
听起来很简单对吧——存个页码,下次打开翻过去就行。我一开始也是这么想的,直到在 Web EPUB 阅读器上被反复打脸。
这篇文章不讲理论框架,直接讲:我在实现 Web/Mobile 阅读进度同步时踩过的每一个坑,以及为什么"存页码"这条路从一开始就是死的。
一句话结论
进度 = 内容位置(Anchor),页面 = 当前设备的渲染结果。
只要你不存页码,Web 双页 / Mobile 单页 / 字体可调 / 阅读器大小可调 / 多端同步,全部迎刃而解。
为什么不能存页码?
同一本 EPUB,79 章,30 万字:
| 环境 | 页数 |
|---|---|
| PC 双页 (319px/页) | 1165 页 |
| iPad 横屏 (500px/页) | 约 750 页 |
| iPhone 竖屏 (350px/页) | 约 1400 页 |
| 调大字号 (20px) | 约 1800 页 |
用户在 PC 上读到第 142 页,存下来。手机打开,翻到第 142 页——内容完全对不上,可能差了好几章。
页码是渲染结果,不是内容属性。 它取决于字体、字号、行高、容器宽高、双页/单页模式。换任何一个参数,页码就变了。
Anchor 锚点设计
数据结构
interface ReadingAnchor {
chapterIndex: number; // 第 11 章
blockIndex: number; // 章内第 17 个段落
charOffset: number; // 段内第 0 个字符
textSnippet: string; // "尤里身体前倾,像是在敦促她"
}
每个字段都是内容属性——和设备无关、和字体无关、和屏幕宽度无关。
textSnippet 是保险:万一书的内容更新导致 blockIndex 偏移,还能用文字片段做模糊匹配(Kindle 也是这么做的)。
存储格式
anchor:11:17:0|snippet:尤里身体前倾,像是在敦促她|char:25000
char:25000 是全局字符偏移,供旧客户端降级。一个字符串,三层 fallback,向前兼容。
多端同步流程
手机端退出 → 保存 anchor → 后端存储
↓
PC 端打开 → 请求 anchor → 当前设置下重新分页 → 定位到锚点所在页
后端只做一件事:存最新的 anchor。"翻到哪一页"这个问题完全由前端根据当前设备环境实时计算。
前端分页:CSS 多列布局测量
EPUB 分页的核心是 CSS column-width。将章节 HTML 注入一个隐藏容器,浏览器自动按列排布,scrollWidth / columnWidth 就是页数。
// 隐藏测量容器
measureEl.innerHTML = `
<div class="epub-measure-container" style="
width: ${pageWidth}px;
height: ${pageHeight}px;
column-width: ${pageWidth}px;
column-gap: 0px;
column-fill: auto;
font-size: ${fontSize}px;
line-height: ${lineHeight};
">${chapter.html}</div>
`;
const scrollW = contentEl.scrollWidth;
const pageCount = Math.ceil(scrollW / pageWidth);
同时,遍历每个块级元素,记录它在第几列(第几页),构建 blockMap:
// 用 getBoundingClientRect 计算元素所在列
const containerRect = containerEl.getBoundingClientRect();
for (const el of leafElements) {
const elRect = el.getBoundingClientRect();
const relativeLeft = elRect.left - containerRect.left;
const pageInChapter = Math.floor(relativeLeft / columnWidth);
// 记录:blockIndex → pageInChapter
}
有了 blockMap,锚点 → 页码的转换就是一次查表:range.startPage + block.pageInChapter。
四个真实的坑
坑 1:测量 CSS ≠ 渲染 CSS → 定位偏移
这是最隐蔽的 Bug。测量容器和实际渲染的 CSS 差了几条规则:
/* 渲染容器有,测量容器漏了 */
h1, h2, h3 { margin-top: 0.5em; margin-bottom: 0.3em; }
blockquote { text-indent: 0 !important; }
a { color: inherit; text-decoration: underline; }
一个标题的 margin 差了 0.5em(≈ 8px),在 319px 宽的手机屏幕上,就足以让后续段落的列分配偏移一整页。79 章累积下来,锚点可以偏差几十页。
结论:测量 CSS 和渲染 CSS 必须完全一致,差一个属性就可能出错。
坑 2:offsetLeft 在多列布局中不可靠
最初用 el.offsetLeft / columnWidth 判断元素在哪一列。但 offsetLeft 的语义是"相对于 offsetParent",在 CSS 多列布局中,不同浏览器的实现有差异。
换成 getBoundingClientRect() 后解决。它返回元素的实际视觉位置,跨浏览器一致:
// ❌ 不可靠
const page = Math.floor(el.offsetLeft / columnWidth);
// ✅ 可靠
const rect = el.getBoundingClientRect();
const page = Math.floor((rect.left - containerRect.left) / columnWidth);
坑 3:字体设置变更 → 用旧数据算出错误页码
用户调整字号 → settingsFingerprint 变化 → 触发重新分页。但 React 中多个 Hook 的状态更新有时差:
Effect 看到:新的 settingsFingerprint ✓
旧的 blockMaps ✗ (分页 Hook 还没完成重新测量)
用旧的 blockMaps + 新设置去算 anchorToPage,结果必然是错的。
解决方案:两阶段导航。
// 第一阶段:检测到设置变更,标记等待,不导航
if (isSettingsChange) {
pendingSettingsNavRef.current = true;
return; // 等分页重新测量
}
// 第二阶段:分页完成后,用新 blockMaps 安全导航
if (pendingSettingsNavRef.current) {
pendingSettingsNavRef.current = false;
const newPage = anchorToPage(anchor, newRanges, newBlockMaps);
navigateTo(newPage);
}
坑 4:渐进加载 + 翻页库事件竞态
79 章的书不会一次加载完。第一次分页只有 17 章精确测量 + 62 章估算。当更多章节加载后,avgCharsPerPage 从 135 变成 129,所有估算章节的 startPage 集体偏移,锚点对应的全局页码从 132 变成 142。
但阅读器还停在 132 页,因为初始化后没有"自动修正"逻辑。
更麻烦的是,尝试用 setSettingsKey 重新挂载 flipbook 来修正时,翻页库在 mount 时会发射一个 onFlip({page: 0}) 的伪事件。这个事件把 currentPageRef 污染成 0,导致后续自动修正全部失效。
解决方案:两个机制配合。
门控机制:flipbook 初始化阶段忽略 onFlip 事件。
const flipReadyRef = useRef(false);
// mount 后 300ms 才标记就绪
setTimeout(() => { flipReadyRef.current = true; }, 300);
// handleFlip 中门控
if (!flipReadyRef.current) return; // 忽略伪事件
直接导航:渐进加载修正时用 turnToPage 而不是重新挂载,从根本上避免竞态。
if (!userHasFlippedRef.current && startPage !== currentPageRef.current) {
flipBookRef.current?.pageFlip()?.turnToPage(startPage);
}
最终架构
┌───────────────────────────────────┐
│ 后端:只存 anchor 字符串 │ POST /api/library/progress
├───────────────────────────────────┤
│ 前端解析层:anchor ↔ 页码转换 │ anchorToPage / pageToAnchor
├───────────────────────────────────┤
│ 前端测量层:CSS columns 精确测量 │ buildBlockMap → blockMaps
├───────────────────────────────────┤
│ 前端渲染层:flipbook 翻页 UI │ react-pageflip
└───────────────────────────────────┘
核心原则:
- 后端不分页,只存内容位置
- 页码纯前端算,根据当前设备环境实时计算
- 锚点与设备无关,同一个锚点在任何设备上都能定位
-
转换方向:永远是
anchor → page(打开时),page → anchor(保存时)
写在最后
实现这个功能的过程让我深刻理解了一件事:看似简单的需求,难点往往不在算法设计,而在工程细节的一致性。
CSS 差一条规则、React Effect 的执行时序差一帧、第三方库的一个初始化事件——这些"微小"的不一致累积起来,就是"打开书发现位置完全不对"的用户体验灾难。
如果你也在做类似的阅读器产品,记住这个原则:
永远不要存页码。存内容位置,让前端去算页码。
这一个决策,能帮你避开 80% 的坑。
2月14日:“农产品批发价格200指数”比昨天下降0.03个点
千问大免单活动延长3天
梅赛德斯-奔驰中国宣布重要人事任命:销售CEO段建军离职,李德思接任
LeetCode 25. K个一组翻转链表:两种解法详解+避坑指南
LeetCode 难度为 Hard 的经典链表题——25. K个一组翻转链表,这道题是链表翻转的进阶题,考察对链表指针操作的熟练度,也是面试中的高频考点,很多人会在“组内翻转”“组间连接”“边界处理”上踩坑。
今天不仅会讲解题目核心,还会对比两份不同思路的代码,分析它们的优缺点、避坑点,帮大家彻底吃透这道题,下次遇到直接秒解!
一、题目解读(清晰易懂版)
题目核心需求很明确,一句话概括:给一个链表,每k个节点当成一组,组内翻转;如果最后剩下的节点不足k个,就保持原样。
关键约束(必看,避坑前提):
-
k是正整数,且k ≤ 链表长度(不用考虑k大于链表长度的情况);
-
不能只改节点的值,必须实际交换节点(排除“偷巧”解法);
-
组间顺序不变,只有组内节点翻转(比如链表1->2->3->4,k=2,结果是2->1->4->3,不是4->3->2->1)。
示例辅助理解:
-
输入:head = [1,2,3,4,5], k = 2 → 输出:[2,1,4,3,5]
-
输入:head = [1,2,3,4,5], k = 3 → 输出:[3,2,1,4,5]
-
输入:head = [1,2], k = 2 → 输出:[2,1]
二、链表节点定义(题目给出,直接复用)
先贴出题目给出的ListNode定义,两份解法都基于这个结构,不用额外修改:
class ListNode {
val: number
next: ListNode | null
constructor(val?: number, next?: ListNode | null) {
this.val = (val === undefined ? 0 : val)
this.next = (next === undefined ? null : next)
}
}
三、两种解法详解对比
下面分别讲解两份代码(reverseKGroup_1 和 reverseKGroup_2),从思路、执行流程、优缺点三个维度拆解,帮大家看清两种思路的差异。
解法一:reverseKGroup_1(全局翻转+局部调整+回滚,新手易上手但需避坑)
1. 核心思路
这种思路的核心是「边遍历边全局翻转,每凑够k个节点,就调整一次组间连接;最后如果不足k个节点,再把这部分翻转回去」。
可以类比成:把链表当成一串珠子,从头开始逐个翻转(珠子顺序颠倒),每翻k个,就把这k个珠子“固定”到正确的位置(连接好前后组);如果最后剩的珠子不够k个,就把这几个珠子再翻回来,恢复原样。
2. 关键变量说明
-
dummy:虚拟头节点,避免处理头节点翻转的特殊情况(所有链表题的通用技巧);
-
preGroup:每组翻转的“前置节点”,负责连接上一组的尾和当前组的头;
-
prev:翻转节点时的“前驱节点”,记录当前节点的前一个节点(用于翻转指针);
-
curr:当前正在遍历、翻转的节点;
-
count:组内节点计数器,用于判断是否凑够k个节点。
3. 代码执行流程(以 head=[1,2,3,4], k=2 为例)
-
初始状态:dummy(0)->1->2->3->4,preGroup=dummy,prev=dummy,curr=1,count=0;
-
遍历curr=1:count≠2,翻转1(1.next=prev=dummy),prev=1,curr=2,count=1;
-
遍历curr=2:count≠2,翻转2(2.next=prev=1),prev=2,curr=3,count=2;
-
凑够k=2个节点:调整组间连接——preGroup.next=prev=2(dummy->2),原组头lastNode=1,1.next=curr=3(2->1->3);更新preGroup=1,prev=1,count=0;
-
继续遍历curr=3:重复步骤2-3,翻转3、4,凑够k=2个节点,调整连接(1->4,3.next=null);
-
循环结束,count=0,无不足k个的节点,返回dummy.next=2,最终结果2->1->4->3(正确)。
4. 优点&缺点
优点:思路直观,新手容易理解(只需要掌握“单个节点翻转”的基础操作,再加上计数和回滚);代码结构清晰,逐步骤执行,容易调试。
缺点:存在冗余逻辑(比如单独处理“最后一组刚好k个节点”的else if分支);过度使用空值断言(!),有潜在空指针风险;最后回滚步骤增加了少量时间开销(虽然时间复杂度还是O(n))。
5. 核心避坑点
-
避免链表环:翻转后必须及时调整组尾的next指针(lastNode.next=curr),否则会出现“dummy<->1”的环,触发运行错误;
-
回滚逻辑不能漏:如果最后剩余节点不足k个,必须把这部分翻转的节点再翻回来,否则会破坏原有顺序;
-
空值判断:preGroup.next不可能为null,可移除多余的空值判断,避免错误返回null。
解法二:reverseKGroup_2(先找组边界+组内单独翻转,最优解法)
这是更推荐的解法,也是面试中更常考的思路——「先找每组的边界(头和尾),确认够k个节点后,再单独翻转这组节点;组间连接直接通过边界节点处理,无需回滚」。
类比:还是一串珠子,先找到前k个珠子(确定组头和组尾),把这k个珠子单独翻转,再连接好前后珠子;再找下k个珠子,重复操作;如果找不到k个,就直接结束,不用再调整。
1. 关键变量说明(新增/差异变量)
-
groupTail:当前组的尾节点,通过移动k次找到,同时判断剩余节点是否够k个;
-
groupHead:当前组的头节点(翻转后会变成组尾);
-
nextGroupHead:下一组的头节点,提前记录,避免翻转后找不到下一组。
2. 代码执行流程(以 head=[1,2,3,4], k=2 为例)
-
初始状态:dummy(0)->1->2->3->4,preGroup=dummy;
-
找第一组边界:groupTail从preGroup开始移动2次,找到groupTail=2(确认够k个节点);记录groupHead=1,nextGroupHead=3;
-
单独翻转当前组(1->2):prev初始化为nextGroupHead=3,curr=groupHead=1;循环翻转,直到curr=nextGroupHead,翻转后变成2->1;
-
连接组间:preGroup.next=groupTail=2(dummy->2),preGroup更新为groupHead=1(下一组的前置节点);
-
找第二组边界:groupTail从preGroup=1移动2次,找到groupTail=4;记录groupHead=3,nextGroupHead=null;
-
单独翻转当前组(3->4),连接组间;
-
下一次找组边界:移动不足2次,count<k,直接返回dummy.next=2,结果2->1->4->3(正确)。
3. 优点&缺点
优点:逻辑更高效,无需回滚(提前判断节点数量,不足k个直接返回);无冗余分支,代码更简洁;指针操作更严谨,避免链表环和空指针风险;时间复杂度O(n),空间复杂度O(1),是最优解法。
缺点:对指针操作的熟练度要求更高,需要提前规划好“找边界-翻转-连接”的流程,新手可能需要多调试几次才能理解。
4. 核心避坑点
-
找组边界时,必须同时判断节点数量:移动k次后,如果groupTail.next不存在,说明不足k个节点,直接返回;
-
翻转组内节点时,prev初始化为nextGroupHead:这样翻转后,组尾(原groupHead)的next会自动指向nextGroupHead,无需额外调整;
-
preGroup更新为原groupHead:翻转后,原groupHead变成组尾,作为下一组的前置节点,保证组间连接正确。
四、两份代码对比总结
| 对比维度 | reverseKGroup_1 | reverseKGroup_2 |
|---|---|---|
| 核心思路 | 全局翻转+组间调整+不足k个回滚 | 先找组边界+组内单独翻转+无回滚 |
| 时间复杂度 | O(n)(回滚最多增加O(k),可忽略) | O(n)(最优,每个节点只遍历一次) |
| 空间复杂度 | O(1) | O(1) |
| 可读性 | 高,新手易理解 | 中等,需熟练掌握指针操作 |
| 适用场景 | 新手刷题、快速调试 | 面试、生产环境(最优解) |
| 潜在坑点 | 链表环、回滚遗漏、空值断言 | 组边界判断、prev初始化 |
五、刷题建议&拓展思考
1. 刷题建议
-
新手:先吃透 reverseKGroup_1,掌握“翻转+计数+回滚”的思路,熟练后再过渡到 reverseKGroup_2;
-
进阶:重点练习 reverseKGroup_2,尝试自己手写“找边界-翻转-连接”的流程,避免依赖模板;
-
调试技巧:遇到指针混乱时,画链表结构图(比如用草稿纸写出每个节点的next指向),逐步骤跟踪指针变化,比单纯看代码更高效。
2. 拓展思考(面试高频追问)
-
如果k可以大于链表长度,该如何修改代码?(提示:在找组边界时,判断count是否等于链表长度,不足则不翻转);
-
如何用递归实现K个一组翻转链表?(提示:递归终止条件是剩余节点不足k个,递归逻辑是翻转当前组,再递归翻转下一组);
-
如果要求“每k个节点一组翻转,不足k个节点时全部翻转”,该如何修改?(提示:移除回滚逻辑,或不判断节点数量,直接翻转)。
六、最终优化版代码(推荐面试使用)
基于 reverseKGroup_2 优化,移除空值断言,增加防御性判断,代码更健壮、简洁,适配面试场景:
function reverseKGroup(head: ListNode | null, k: number): ListNode | null {
if (k === 1 || !head || !head.next) return head;
const dummy = new ListNode(0, head);
let preGroup = dummy; // 每组翻转的前置节点
let count = 0;
while (true) {
// 第一步:找组尾,判断剩余节点是否够k个
let groupTail = preGroup;
count = 0;
while (count < k && groupTail.next) {
groupTail = groupTail.next;
count++;
}
if (count < k) return dummy.next; // 不足k个,直接返回
// 第二步:记录关键节点
const groupHead = preGroup.next;
const nextGroupHead = groupTail.next;
// 第三步:组内翻转
let prev: ListNode | null = nextGroupHead;
let curr = groupHead;
while (curr !== nextGroupHead) {
const next = curr?.next;
if (curr) curr.next = prev;
prev = curr;
curr = next;
}
// 第四步:组间连接
preGroup.next = groupTail;
preGroup = groupHead!;
}
}
七、总结
LeetCode 25题的核心是「组内翻转+组间连接」,两种解法的本质都是通过指针操作实现,但思路的高效性有差异。
无论哪种解法,都要记住三个核心要点:① 用虚拟头节点简化头节点处理;② 明确每组的边界(头、尾、下一组头);③ 翻转时避免链表环和空指针。
刷题不是背代码,而是理解思路、掌握技巧。建议大家多调试、多画图,熟练掌握指针操作,下次遇到类似的链表翻转题(比如两两翻转、指定区间翻转),就能举一反三、轻松应对!
迪拜2025年国际游客近2000万,中国游客达86万
Claude Code 作者再次分享 Anthropic 内部团队使用技巧
大家好,我是 Immerse,一名独立开发者、内容创作者、AGI 实践者。
关注公众号:沉浸式AI,获取最新文章(更多内容只在公众号更新)
个人网站:yaolifeng.com 也同步更新。
转载请在文章开头注明出处和版权信息。
我会在这里分享关于编程、独立开发、AI干货、开源、个人思考等内容。
如果本文对您有所帮助,欢迎动动小手指一键三连(点赞、评论、转发),给我一些支持和鼓励,谢谢!
Boris 又发了一份 Anthropic 内部的 Claude Code 使用心得。
看完觉得挺实用,记录几条:
1. 多开 worktree 同时跑 3-5 个 git worktree,每个开一个独立会话。团队里公认这个最提效。Boris 自己习惯用 git checkout,但大部分人更爱 worktree。
2. 复杂任务先规划 遇到复杂活儿就开 plan mode。可以让一个 Claude 写计划,另一个当幕僚审查。跑偏了就切回去重新规划。验证环节也会专门进计划模式。
3. 错误后更新 CLAUDE.md 每次纠错完都加一句:"更新你的 CLAUDE.md,别再犯同样的错。"反复迭代到错误率明显降下来。
4. 自建 Skills 库 把常用操作做成 Skills 提交到 git,各项目复用。一天做两次以上的事就该做成 Skills。
5. 让 Claude 自己修 bug 接入 Slack MCP,把 bug 讨论帖扔给 Claude,说一句"修它"就行。或者直接"去修失败的 CI",不用管细节。
6. 提高提示词质量 试试"严格审查这些改动,测试不过不准建 PR",让 Claude 当审查员。或者"证明给我看这能跑通",让它对比 main 和功能分支的差异。
7. 追求更优方案 碰到平庸的修复就说:"基于现在掌握的信息,废掉这个方案,实现更优雅的。"任务前写详细规格,减少歧义。描述越具体,输出越好。
8. 终端配置 团队在用 Ghostty 终端,支持同步渲染、24 位色彩和完整 Unicode。用 /statusline 自定义状态栏显示上下文用量和 git 分支。给标签页做颜色编码和命名,一个任务一个标签页。
9. 语音输入 说话比打字快三倍,提示词也会详细很多。macOS 连按两次 fn 就能开启。
10. 用子代理 想让 Claude 多花点算力就加"use subagents"。把任务分给子代理,主代理的上下文窗口保持干净。
详情:x.com/bcherny/status/2017742741636321619 x
gsap 配置解读 --5
什么是ScrollTo
<header>
<h1>案例 29:ScrollTo 平滑滚动</h1>
<button id="to-second">滚动到第二屏</button>
</header>
<section>
<div class="panel">第一屏内容</div>
</section>
<section id="second">
<div class="panel">第二屏内容</div>
</section>
<section>
<div class="panel">第三屏内容</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollToPlugin.min.js"></script>
<script>
const button = document.querySelector("#to-second");
// 注册 ScrollToPlugin
gsap.registerPlugin(ScrollToPlugin);
button.addEventListener("click", () => {
gsap.to(window, {
duration: 1,
scrollTo: "#second",
ease: "power2.out"
});
});
</script>
ScrollToPlugin 是 GSAP(GreenSock Animation Platform) 提供的一个轻量级但非常实用的插件,用于实现 平滑、可控的页面滚动动画——无论是滚动到页面某个元素、指定坐标,还是精确控制滚动行为。
📌 简单定义:
ScrollToPlugin让你用 GSAP 的动画语法(如duration、ease)驱动浏览器窗口或任意可滚动容器,平滑滚动到目标位置。
它解决了原生 window.scrollTo() 只能“瞬间跳转”或简单 behavior: 'smooth' 缺乏控制的问题。
✅ 核心能力:
1. 滚动到多种目标
// 滚动到元素(通过选择器或 DOM 节点)
scrollTo: "#second"
scrollTo: document.querySelector(".footer")
// 滚动到具体坐标
scrollTo: { y: 500 } // 垂直滚动到 500px
scrollTo: { x: 200, y: 300 } // 水平 + 垂直
// 滚动到页面顶部/底部
scrollTo: { y: "top" }
scrollTo: { y: "bottom" }
// 滚动到元素并预留偏移(如避开固定导航栏)
scrollTo: { y: "#section", offsetY: 80 }
2. 完全控制动画体验
-
duration: 滚动持续时间(秒) -
ease: 缓动函数(如"power2.out"、"expo.inOut") - 可暂停、反向、加入时间轴(Timeline)
3. 支持任意可滚动容器
不仅限于 window,也可用于 <div style="overflow: auto"> 等局部滚动区域:
gsap.to(scrollableDiv, {
duration: 1,
scrollTo: { y: 1000 }
});
🔧 在你的代码中:
gsap.to(window, {
duration: 1,
scrollTo: "#second", // 平滑滚动到 id="second" 的 <section>
ease: "power2.out" // 先快后慢的缓动效果
});
点击按钮后:
- 页面不会“瞬间跳转”到第二屏;
- 而是用 1 秒时间,以 优雅的缓动曲线 滚动到
#second元素的顶部; - 用户体验更自然、专业。
🌟 典型应用场景:
| 场景 | 示例 |
|---|---|
| 导航跳转 | 点击菜单项平滑滚动到对应章节 |
| “回到顶部”按钮 | 带缓动的返回顶部动画 |
| 表单错误定位 | 提交失败时滚动到第一个错误字段 |
| 交互式故事页 | 按钮触发滚动到下一情节 |
| 局部滚动容器 | 在聊天窗口中自动滚动到底部 |
⚙️ 高级选项(常用):
scrollTo: {
y: "#target",
offsetX: 0, // 水平偏移
offsetY: 60, // 垂直偏移(常用于避开固定头部)
autoKill: true // 用户手动滚动时自动中断动画(默认 true)
}
🆚 对比原生方案:
| 方式 | 控制力 | 缓动 | 中断处理 | 兼容性 |
|---|---|---|---|---|
window.scrollTo({ behavior: 'smooth' }) |
低 | 仅线性 | 差 | 现代浏览器 |
ScrollToPlugin |
高 | 任意 GSAP 缓动 | 智能中断 | 全浏览器(含 IE11) |
⚠️ 注意事项:
- 必须注册插件:
gsap.registerPlugin(ScrollToPlugin) - 目标元素必须已存在于 DOM 中
- 如果结合
ScrollSmoother(平滑滚动容器),需使用其 API 而非直接操作window
📚 官方文档:
✅ 总结:
ScrollToPlugin是 GSAP 中实现“专业级页面导航动画”的标准工具——它用极简的代码,赋予滚动行为电影般的流畅感和精准控制,是提升网站交互质感的必备插件。
什么是SplitText
<div class="card">
<h1 id="headline">SplitText 可以拆分文字做逐字动画</h1>
<button id="play">逐字出现</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>
<script>
const headline = document.querySelector("#headline");
const playButton = document.querySelector("#play");
// 注册 SplitText 插件
gsap.registerPlugin(SplitText);
let split;
playButton.addEventListener("click", () => {
if (split) {
split.revert();
}
// 将文字拆分为字符
split = new SplitText(headline, { type: "chars" });
gsap.from(split.chars, {
opacity: 0,
y: 20,
duration: 0.6,
ease: "power2.out",
stagger: 0.04
});
});
</script>
SplitText 是 GSAP(GreenSock Animation Platform) 提供的一个强大工具(虽然叫“插件”,但实际是一个独立的实用类),用于将 HTML 文本智能地拆分为可单独动画的 <span> 元素,从而实现精细的逐字、逐词或逐行动画效果。
📌 简单定义:
SplitText能把一段普通文字(如<h1>Hello</h1>)自动转换成包裹在<span>中的字符、单词或行,让你可以用 GSAP 对每个部分做独立动画。
例如:
<!-- 原始 -->
<h1 id="headline">你好</h1>
<!-- SplitText({ type: "chars" }) 处理后 -->
<h1 id="headline">
<span class="char">你</span>
<span class="char">好</span>
</h1>
✅ 核心功能:三种拆分模式
| 模式 | 说明 | 生成结构 |
|---|---|---|
type: "chars" |
拆分为单个字符(包括中文、英文、标点) | 每个字一个 <span class="char">
|
type: "words" |
拆分为单词(以空格/标点分隔) | 每个词一个 <span class="word">
|
type: "lines" |
拆分为视觉上的行(根据实际换行) | 每行外层包 <div class="line">
|
💡 也可组合使用:
type: "words, chars"→ 先分词,再把每个词拆成字。
split = new SplitText(headline, { type: "chars" });
gsap.from(split.chars, {
opacity: 0,
y: 20,
duration: 0.6,
ease: "power2.out",
stagger: 0.04 // 每个字符延迟 0.04 秒启动
});
- 点击按钮时,标题文字被拆成单个字符;
- 每个字符从下方 20px、透明的状态,依次向上淡入;
- 形成“逐字打字机”或“文字飞入”的经典动效。
⚠️ 注意:每次点击前调用
split.revert()是为了还原原始 HTML 结构,避免重复嵌套<span>导致样式错乱。
🌟 为什么需要 SplitText?
如果不使用它,手动写 <span> 包裹每个字:
- 繁琐:尤其对动态内容或 CMS 内容不现实;
- 破坏语义:影响 SEO 和可访问性(屏幕阅读器);
- 难以维护。
而 SplitText:
- 非破坏性:原始文本保持不变,仅运行时包装;
- 智能处理:正确保留 HTML 标签、空格、换行、内联样式;
-
支持复杂排版:包括多行、响应式断行(
lines模式会监听 resize)。
🛠️ 高级特性:
- 保留原始样式:即使文字有 CSS 动画、颜色、字体,拆分后依然生效。
- 与 ScrollTrigger 结合:实现“滚动到此处时逐字出现”。
- 支持 SVG 文本(需额外配置)。
-
可自定义包裹标签:默认
<span>,也可设为<div>等。
⚠️ 注意事项:
-
不是免费插件:在 GSAP 3 中,
SplitText属于 Club 会员专属功能(可试用,但商业项目需授权)。 -
不要重复拆分:务必在重新拆分前
revert(),否则会嵌套多层<span>。 - 对 SEO 友好:因为原始 HTML 不变,搜索引擎仍能读取完整文本。
📚 官方文档:
✅ 总结:
SplitText是 GSAP 中实现“高级文字动画”的基石工具——它将枯燥的文本转化为可编程的动画单元,让逐字淡入、弹跳、飞入等效果变得简单、可靠且专业,广泛应用于官网、片头、交互叙事等场景。
什么是TextPlugin
<div class="card">
<h1>案例 31:TextPlugin 数字滚动</h1>
<p>让文本从 0 变化到目标值。</p>
<div class="counter" id="counter">0</div>
<button id="play">开始计数</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/TextPlugin.min.js"></script>
<script>
const counter = document.querySelector("#counter");
const playButton = document.querySelector("#play");
// 注册 TextPlugin
gsap.registerPlugin(TextPlugin);
const tween = gsap.to(counter, {
duration: 1.6,
text: "1280",
ease: "power2.out",
paused: true
});
playButton.addEventListener("click", () => {
counter.textContent = "0";
tween.restart();
});
</script>
TextPlugin 是 GSAP(GreenSock Animation Platform) 提供的一个轻巧但非常实用的插件,专门用于对 DOM 元素的文本内容进行动画化更新。它最经典的应用就是实现 “数字滚动计数器” 效果(如从 0 平滑变化到 1280),但也支持普通文本的渐变替换。
📌 简单定义:
TextPlugin能让元素的textContent从一个值“动画过渡”到另一个值——对于数字,它会逐帧递增/递减;对于文字,它可模拟打字、随机字符替换等效果。
✅ 核心功能:
1. 数字滚动(最常用)
gsap.to(element, {
duration: 2,
text: "1000" // 自动从当前数字(如 "0")滚动到 1000
});
- 自动识别数字并进行数值插值;
- 支持整数、小数、带千分位格式(需配合
delimiter); - 可设置前缀/后缀(如
$、%)。
2. 文本替换动画
gsap.to(element, {
text: "Hello World",
duration: 1.5
});
- 默认行为:直接替换(无中间动画);
- 但配合
delimiter或自定义逻辑,可实现打字机、乱码过渡等(不过复杂文本动画更推荐ScrambleTextPlugin)。
gsap.to(counter, {
duration: 1.6,
text: "1280", // 目标文本
ease: "power2.out",
paused: true
});
- 初始文本是
"0"; - 点击按钮后,
TextPlugin会:- 解析
"0"和"1280"都是有效数字; - 在 1.6 秒内,将文本内容从
0 → 1 → 2 → ... → 1280逐帧更新; - 视觉上形成“数字飞速增长”的计数器效果。
- 解析
💡 注意:每次播放前重置
counter.textContent = "0"是为了确保动画从起点开始。
⚙️ 常用配置选项(通过 text 对象):
gsap.to(element, {
text: {
value: "¥1,280", // 目标值
delimiter: ",", // 千分位分隔符
prefix: "¥", // 前缀(也可直接写在 value 里)
suffix: " 元", // 后缀
padSpace: true // 保持文本长度一致(防跳动)
},
duration: 2
});
🌟 典型应用场景:
| 场景 | 示例 |
|---|---|
| 数据看板 | 用户数、销售额、点赞数的动态增长 |
| 加载进度 | “加载中... 78%” |
| 倒计时/计时器 | 活动剩余时间、秒表 |
| 游戏得分 | 分数变化动画 |
| 简单文本切换 | 状态提示(“成功” → “完成”) |
🆚 对比其他方案:
| 方法 | 数字滚动 | 文本动画 | 精确控制 | 性能 |
|---|---|---|---|---|
手动 setInterval
|
✅ | ❌ | 低 | 一般 |
| CSS + JS 拼接 | ⚠️ 复杂 | ⚠️ 有限 | 低 | 一般 |
TextPlugin |
✅✅✅ | ✅ | 高(GSAP 时间轴) | 优 |
⚠️ 注意事项:
-
只作用于
textContent,不会影响 HTML 标签(即不能插入<strong>等); - 如果起始或目标文本不是纯数字,则直接替换(无滚动);
- 要实现更炫的文字扰动(如乱码过渡),应使用
ScrambleTextPlugin; -
免费可用:
TextPlugin是 GSAP 的标准免费插件(无需会员)。
📚 官方文档:
✅ 总结:
TextPlugin是 GSAP 中实现“数字计数器动画”的首选工具——它用一行代码就能将静态数字变成动态增长的视觉焦点,简单、高效、且完全集成于 GSAP 动画生态系统。
什么是EasePack
<div class="card">
<h1>案例 32:EasePack 特殊缓动</h1>
<p>RoughEase / SlowMo / ExpoScaleEase 都在 EasePack 中。</p>
<div class="row">
<div>
<div class="lane">
<div class="ball" id="ballA"></div>
</div>
<div class="label">RoughEase</div>
</div>
<div>
<div class="lane">
<div class="ball" id="ballB"></div>
</div>
<div class="label">SlowMo</div>
</div>
<div>
<div class="lane">
<div class="ball" id="ballC"></div>
</div>
<div class="label">ExpoScaleEase</div>
</div>
</div>
<button id="play">播放缓动</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<!-- RoughEase, ExpoScaleEase and SlowMo are all included in the EasePack file -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/EasePack.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
<!-- CustomBounce requires CustomEase -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomBounce.min.js"></script>
<!-- CustomWiggle requires CustomEase -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomWiggle.min.js"></script>
<script>
const ballA = document.querySelector("#ballA");
const ballB = document.querySelector("#ballB");
const ballC = document.querySelector("#ballC");
const playButton = document.querySelector("#play");
// 预设三个缓动
const rough = RoughEase.ease.config({
strength: 1.5,
points: 20,
template: Power1.easeInOut,
randomize: true
});
const slowMo = SlowMo.ease.config(0.7, 0.7, false);
const expoScale = ExpoScaleEase.config(1, 3);
const timeline = gsap.timeline({ paused: true });
timeline.to(ballA, { y: 100, duration: 1.2, ease: rough }, 0);
timeline.to(ballB, { y: 100, duration: 1.2, ease: slowMo }, 0);
timeline.to(ballC, { y: 100, duration: 1.2, ease: expoScale }, 0);
playButton.addEventListener("click", () => {
gsap.set([ballA, ballB, ballC], { y: 0 });
timeline.restart();
});
</script>
RoughEase、SlowMo 和 ExpoScaleEase 是 GSAP(GreenSock Animation Platform) 中三个非常有特色的高级缓动函数(easing functions),它们都包含在 EasePack 插件中。它们超越了传统的“入/出”缓动(如 easeInOut),提供了更具创意和物理感的动画节奏。
下面分别解释它们的作用和适用场景:
1. 🌀 RoughEase —— “抖动式”缓动
✅ 作用:
模拟不规则、随机抖动的运动效果,常用于表现:
- 手绘感、草图风格
- 震动、故障、不稳定状态
- 卡通式的“弹跳后晃动”
🔧 核心参数(通过 .config() 设置):
const rough = RoughEase.ease.config({
strength: 1.5, // 抖动强度(0~2,默认 1)
points: 20, // 抖动点数量(越多越密集)
template: Power1.easeInOut, // 基础缓动曲线(决定整体趋势)
randomize: true // 是否每次播放随机(true=更自然)
});
🎯 在你的代码中:
- 小球 A 下落时会上下轻微抖动,不是平滑移动,而是像“被手抖着拉下来”。
💡 适合:游戏中的受击反馈、加载失败提示、趣味 UI。
2. 🐢 SlowMo —— “慢动作中心”缓动
✅ 作用:
让动画在中间阶段变慢,两端加速,形成“慢镜头”效果。
特别适合强调某个关键状态(如悬停、高亮、停顿)。
🔧 核心参数:
const slowMo = SlowMo.ease.config(
linearRatio, // 中间匀速部分占比(0~1)
power, // 两端加速强度(0=线性,1=强缓出)
yoyoMode // 是否用于往返动画(true=对称)
);
例如:SlowMo.ease.config(0.7, 0.7, false)
→ 动画 70% 的时间以近似匀速缓慢进行,开头和结尾快速过渡。
- 小球 B 下落时,大部分时间缓慢移动,只在开始和结束瞬间加速,仿佛“优雅降落”。
💡 适合:产品展示、LOGO 入场、需要突出中间状态的动画。
3. 📏 ExpoScaleEase —— “指数缩放”缓动
✅ 作用:
实现基于比例(scale)或指数增长/衰减的非线性缓动。
常用于:
- 缩放动画(从 1x 到 10x)
- 音量/亮度/透明度等对数感知属性
- 模拟真实世界的指数变化(如声音衰减、光强)
🔧 核心参数:
const expoScale = ExpoScaleEase.config(startValue, endValue);
- 它会将动画值从
startValue到endValue按指数曲线映射。 - 通常配合
scale、opacity或自定义属性使用。
🎯 虽然用于 y,但效果仍体现非线性:
- 小球 C 的下落速度先快后慢(或反之,取决于范围),但变化是非线性的,比
power2更“陡峭”。
💡 更典型用法:
gsap.to(circle, { scale: 5, ease: ExpoScaleEase.config(1, 5) // 从 1 倍到 5 倍的指数缩放 });
💡 适合:放大镜效果、爆炸扩散、雷达扫描、声波可视化。
🆚 对比总结:
| 缓动类型 | 视觉特点 | 典型用途 |
|---|---|---|
RoughEase |
随机抖动、不规则 | 故障风、手绘感、震动反馈 |
SlowMo |
中间慢、两头快 | 强调关键帧、优雅停顿 |
ExpoScaleEase |
指数级加速/减速 | 缩放、对数感知属性、物理模拟 |
⚠️ 注意事项:
- 这些缓动都来自
EasePack,需单独引入(如你代码中已做); - 它们可以像普通
ease一样用在任何 GSAP 动画中; - 结合
Timeline可创建复杂节奏组合。
📚 官方文档:
- Ease Visualizer(可交互体验):greensock.com/ease-visual…
- EasePack 文档:greensock.com/docs/v3/Eas…
✅ 总结:
RoughEase、SlowMo、ExpoScaleEase是 GSAP 赋予动画“性格”的秘密武器——它们让运动不再机械,而是充满随机性、戏剧性或物理真实感,是打造高级交互动效的关键工具。
什么是 CustomEase
<div class="card">
<h1>案例 33:CustomEase 自定义缓动</h1>
<p>用贝塞尔曲线定义缓动曲线。</p>
<div class="track">
<div class="block" id="block"></div>
</div>
<button id="play">播放自定义缓动</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
<script>
const block = document.querySelector("#block");
const playButton = document.querySelector("#play");
// 注册 CustomEase
gsap.registerPlugin(CustomEase);
// 创建一个自定义缓动曲线
CustomEase.create("myEase", "0.25,0.1,0.25,1");
const tween = gsap.to(block, {
x: 470,
duration: 1.4,
ease: "myEase",
paused: true
});
playButton.addEventListener("click", () => {
tween.restart();
});
</script>
CustomEase 是 GSAP(GreenSock Animation Platform) 提供的一个强大插件,允许你通过自定义贝塞尔曲线(cubic-bezier)来创建完全个性化的缓动函数(easing function),从而精确控制动画的速度变化节奏。
📌 简单定义:
CustomEase让你像在 CSS 或设计工具中那样,用 4 个控制点定义一条缓动曲线,并将其注册为可复用的 GSAP 缓动名称,用于任何动画。
它打破了内置缓动(如 power2.inOut、elastic)的限制,实现电影级、品牌专属或物理拟真的运动节奏。
✅ 核心原理:贝塞尔曲线
缓动曲线本质是一条 三次贝塞尔曲线(Cubic Bezier),由 4 个点定义:
- 起点固定为
(0, 0) - 终点固定为
(1, 1) - 中间两个控制点
(x1, y1)和(x2, y2)决定曲线形状
在 CustomEase 中,你只需提供这 4 个数值(按顺序):
" x1, y1, x2, y2 "
例如你的代码:
CustomEase.create("myEase", "0.25,0.1,0.25,1");
表示:
- 控制点 1:
(0.25, 0.1) - 控制点 2:
(0.25, 1)
这条曲线的特点是:启动非常快(y1 很低),然后突然减速并平稳结束,形成一种“急冲后刹车”的效果。
🔧 使用步骤:
-
引入插件
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script> -
注册自定义缓动
CustomEase.create("myEase", "0.25,0.1,0.25,1");- 第一个参数:缓动名称(字符串,如
"myEase") - 第二个参数:贝塞尔控制点(4 个 0~1 之间的数字,用逗号分隔)
- 第一个参数:缓动名称(字符串,如
-
在动画中使用
gsap.to(block, { x: 470, duration: 1.4, ease: "myEase" // 直接使用注册的名称 });
🌟 优势 vs 其他方式:
| 方式 | 灵活性 | 可视化 | 复用性 | 性能 |
|---|---|---|---|---|
CSS cubic-bezier()
|
✅ | ✅(开发者工具) | ❌(需重复写) | ✅ |
| 手动计算进度 | ❌ | ❌ | ❌ | ⚠️ |
CustomEase |
✅✅✅ | ✅(配合 GSAP 工具) | ✅✅✅(全局注册) | ✅✅(预计算优化) |
💡
CustomEase会预计算并缓存曲线数据,运行时性能极高,适合复杂动画。
🛠️ 如何获取贝塞尔值?
-
使用 GSAP 官方工具(推荐!)
👉 GSAP Ease Visualizer- 拖动控制点实时预览动画
- 自动生成
CustomEase代码
-
从 CSS 复制
如果你在 CSS 中写了:transition: all 1s cubic-bezier(0.25, 0.1, 0.25, 1);那么值就是
"0.25,0.1,0.25,1" -
从 Figma / After Effects 导出
许多设计工具支持导出贝塞尔缓动参数。
🎨 典型应用场景:
| 效果 | 贝塞尔示例 | 用途 |
|---|---|---|
| 弹性回弹 | "0.68,-0.55,0.27,1.55" |
按钮点击反馈 |
| 缓入缓出加强版 | "0.33,0,0.67,1" |
平滑过渡 |
| 快速启动+慢速结束 |
"0.25,0.1,0.25,1"(你的例子) |
强调终点状态 |
| 延迟启动 | "0.5,0,0.75,0" |
悬停后才开始动画 |
⚠️ 注意事项:
- 所有数值必须在 0 到 1 之间(超出会导致不可预测行为);
- 注册一次后,可在整个项目中复用(如
"brandBounce"、"softEase"); -
免费可用:
CustomEase是 GSAP 的标准插件(无需 Club 会员); - 若需更复杂曲线(如多段),可结合
CustomWiggle或CustomBounce(它们依赖CustomEase)。
📚 官方资源:
✅ 总结:
CustomEase是 GSAP 中实现“精准运动设计”的终极工具——它把缓动从“选择预设”升级为“自由创作”,让开发者和设计师能用同一套语言定义品牌专属的动画节奏,是打造高端用户体验的核心技术之一。
React 性能优化双子星:深入、全面解析 useMemo 与 useCallback
引言
在现代 React 应用开发中,随着组件逻辑日益复杂、状态管理愈发庞大,性能问题逐渐成为开发者绕不开的话题。幸运的是,React 提供了两个强大而精巧的 Hooks —— useMemo 和 useCallback,它们如同“缓存魔法”,帮助我们在不牺牲可读性的前提下,显著提升应用性能。
本文将结合完整代码示例,逐行解析、对比说明、深入原理,带你彻底掌握 useMemo 与 useCallback 的使用场景、工作机制、常见误区以及最佳实践。文章内容力求全面、准确、生动有趣,并严格保留原始代码一字不变,确保你既能理解理论,又能直接复用实战。
一、为什么需要 useMemo 和 useCallback?
1.1 React 函数组件的“重运行”特性
在 React 中,每当组件的状态(state)或 props 发生变化时,整个函数组件会重新执行一遍。这意味着:
- 所有变量都会重新声明;
- 所有函数都会重新定义;
- 所有计算逻辑都会重新跑一次。
这本身是 React 响应式更新机制的核心,但也会带来不必要的开销。
💡 关键洞察:
“组件函数重新运行” ≠ “DOM 重新渲染”。
React 会通过 Virtual DOM diff 算法决定是否真正更新 DOM。
但昂贵的计算或子组件的无谓重渲染,仍可能拖慢应用。
二、useMemo:为“昂贵计算”穿上缓存外衣
2.1 什么是“昂贵计算”?
看这段代码:
// 昂贵的计算
function slowSum(n) {
console.log('计算中...')
let sum = 0
for(let i = 0; i < n*10000; i++){
sum += i
}
return sum
}
这个 slowSum 函数执行了 n * 10000 次循环!如果 n=100,就是一百万次加法。在每次组件重渲染时都调用它,用户界面可能会卡顿。
2.2 不用 useMemo 的后果
假设我们这样写:
const result = slowSum(num); // ❌ 每次渲染都重新计算!
那么,即使你只是点击了 count + 1 按钮(与 num 无关),slowSum 依然会被执行!因为整个 App 函数重新运行了。
2.3 useMemo 如何拯救性能?
React 提供 useMemo 来记忆(memoize)计算结果:
const result = useMemo(() => {
return slowSlow(num)
}, [num])
✅ 工作原理:
第一次渲染:执行函数,缓存结果。
后续渲染:检查依赖项
[num]是否变化。
- 如果
num没变 → 直接返回缓存值,不执行函数体。- 如果
num变了 → 重新执行函数,更新缓存。
2.4 完整上下文中的 useMemo 使用
export default function App(){
const [num, setNum] = useState(0)
const [count, setCount] = useState(0)
const [keyword, setKeyword] = useState('')
const list = ['apple','banana', 'orange', 'pear']
// ✅ 仅当 keyword 改变时才重新过滤
const filterList = useMemo(() => {
return list.filter(item => item.includes(keyword))
}, [keyword])
// ✅ 仅当 num 改变时才重新计算 slowSum
const result = useMemo(() => {
return slowSum(num)
}, [num])
return (
<div>
<p>结果: {result}</p>
<button onClick={() => setNum(num + 1)}>num + 1</button>
<input type="text" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>count + 1</button>
{
filterList.map(item => (
<li key={item}>{item}</li>
))
}
</div>
)
}
🔍 重点观察:
点击 “count + 1” 时:
slowSum不会执行(因为num没变);filterList不会重新计算(因为keyword没变);- 控制台不会打印 “计算中...” 或隐含的 “filter执行”。
这就是
useMemo带来的精准缓存!
2.5 关于 includes 和 filter 的小贴士
-
"apple".includes("")确实返回true(空字符串是任何字符串的子串); -
list.filter(...)返回的是一个新数组,即使结果为空(如[]),它也是一个新的引用。
⚠️ 正因如此,如果不使用
useMemo,每次渲染都会生成一个新数组引用,可能导致依赖该数组的子组件误判为 props 变化而重渲染!
三、useCallback:为“回调函数”打造稳定身份
3.1 问题起源:函数是“新”的!
在 JavaScript 中,每次函数定义都会创建一个新对象:
// 每次 App 重运行,handleClick 都是一个全新函数!
const handleClick = () => { console.log('click') }
即使函数体完全一样,handleClick !== previousHandleClick。
3.2 子组件为何“无辜重渲染”?
看这段代码:
const Child = memo(({count, handleClick}) => {
console.log('child重新渲染')
return (
<div onClick={handleClick}>
<h1>子组件 count: {count}</h1>
</div>
)
})
-
memo的作用:浅比较 props,若没变则跳过渲染。 - 但每次父组件重渲染,
handleClick都是新函数 → props 引用变了 →memo失效 → 子组件重渲染!
即使你只改了 num,Child 也会重渲染,尽管它只关心 count!
3.3 useCallback 的解决方案
useCallback 本质上是 useMemo 的语法糖,专用于缓存函数:
const handleClick = useCallback(() => {
console.log('click')
}, [count])
✅ 效果:
- 只要
count不变,handleClick的引用就保持不变;Child的 props 引用未变 →memo生效 → 跳过重渲染!
3.4 完整 useCallback 示例
import {
useState,
memo,
useCallback
} from 'react'
const Child = memo(({count, handleClick}) => {
console.log('child重新渲染')
return (
<div onClick={handleClick}>
<h1>子组件 count: {count}</h1>
</div>
)
})
export default function App(){
const [count, setCount] = useState(0)
const [num, setNum] = useState(0)
// ✅ 缓存函数,依赖 count
const handleClick = useCallback(() => {
console.log('click')
}, [count])
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>count + 1</button>
<p>num: {num}</p>
<button onClick={() => setNum(num + 1)}>num + 1</button>
<Child count={count} handleClick={handleClick} />
</div>
)
}
🔍 行为验证:
- 点击 “num + 1”:
Child不会打印 “child重新渲染”;- 点击 “count + 1”:
Child会重渲染(因为count和handleClick都变了);- 如果
handleClick不依赖count(依赖项为[]),则只有count变化时Child才重渲染。
四、useMemo vs useCallback:一张表说清区别
| 特性 | useMemo |
useCallback |
|---|---|---|
| 用途 | 缓存任意值(数字、数组、对象等) | 缓存函数 |
| 本质 | useMemo(fn, deps) |
useMemo(() => fn, deps) 的简写 |
| 典型场景 | 昂贵计算、过滤/映射大数组、创建复杂对象 | 传递给 memo 子组件的事件处理器 |
| 返回值 | 函数执行的结果 | 函数本身 |
| 错误用法 | 用于无副作用的纯计算 | 用于依赖外部变量但未声明依赖 |
💡 记住:
useCallback(fn, deps)≡useMemo(() => fn, deps)
五、常见误区与最佳实践
❌ 误区1:到处使用 useMemo/useCallback
-
不要为了“可能的优化”而滥用。
-
缓存本身也有开销(存储、比较依赖项)。
-
只在以下情况使用:
- 计算确实昂贵(如大数据处理);
- 导致子组件无谓重渲染(配合
memo); - 作为 props 传递给已优化的子组件。
❌ 误区2:依赖项遗漏
const handleClick = useCallback(() => {
console.log(count) // 依赖 count
}, []) // ❌ 错误!应该写 [count]
这会导致函数捕获旧的 count 值(闭包陷阱)。
✅ 正确做法:所有外部变量都必须出现在依赖数组中。
✅ 最佳实践
- 先写逻辑,再优化:不要过早优化。
- 配合 React DevTools Profiler:定位真实性能瓶颈。
- useMemo 用于值,useCallback 用于函数。
-
依赖项要完整且精确:使用 ESLint 插件
eslint-plugin-react-hooks自动检查。
六、总结:性能优化的哲学
useMemo 和 useCallback 并非银弹,而是 React 赋予我们的精细控制权。它们让我们能够:
- 隔离变化:让无关状态的更新不影响其他部分;
- 减少冗余:避免重复计算和渲染;
- 提升用户体验:使应用更流畅、响应更快。
正:
“count 和 keyword 不相关”
“某一个数据改变,只想让相关的子组件重新渲染”
这正是 React 性能优化的核心思想:局部更新,全局协调。
附:完整代码地址
源码地址:react/memo/memo/src/App.jsx · Zou/lesson_zp - 码云 - 开源中国
🎉 掌握
useMemo与useCallback,你已经迈入 React 性能优化的高手之列!
下次遇到“为什么子组件总在乱渲染?”或“计算太慢怎么办?”,你就知道答案了。
Happy coding! 🚀
Seedance 2.0之后,字节跳动又发布豆包大模型2.0
【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2025详细解读
往期文章:
【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2021 & state-of-css 2021详细解读
【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2022 & state-of-js 2022详细解读
【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2023详细解读
【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2023详细解读
【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2024和state-of-js 2024详细解读
一、写在前面
- 本次分享的数据来源是state-of-js,是由Devgraphics开源社区团队发起的前端生态圈中规模最大的数据调查。
- 想要贡献state-of-js调查结果中文翻译的同学可以联系我,或者直接向Devographics/locale-zh-Hans这个仓库提PR,然后艾特我来帮你review。
- 如果这篇文章有其他意见或更好的建议,欢迎各位同学们多多指教。
二、受访者统计
今年的state-of-js调查共回收了13002份问卷结果。和去年相问卷结果又少了一些。
其实自从2022年起,填写问卷的人就越来越少,原因无外乎这么几个:
- 前端的整体热度都在走低,像是google trends上前端相关的搜索词的热度都在下降;
- 问卷内容过长导致内容填写起来比较麻烦;
- 受访者虽然一直关注这项调查,但填了第一年的问卷之后第二年的问卷就不填了等等。
而在今年我结合我在Datawhale做的一些数据调查来看,有一个更重要的原因,就是AI的崛起——大部分开发者们的注意力已经转向了AI领域(包括我自己也是),基本不会在前端领域投入过多关注了。
之前我也和调查发起人@SachaG聊过state-of-js调查的未来,作为一项坚持了9年的前端数据调查,也算是见证了前端领域的崛起与衰落。而如今,前端领域的热度早已不再是当年的样子,这项调查也不知道还能做多少年,大家且看且珍惜吧。
三、JS特性
语法特性
从今年的语法特性使用情况来看,社区对提升代码健壮性和简洁性的新特性抱有极大的热情:
-
空值合并 运算符 (
??) 的使用率高达 87% ,已经成为事实上的标准,这说明开发者在处理null或undefined时,迫切需要一种比||更严谨、更明确的工具来避免将0或false等有效值意外覆盖,在日常开发中,我们应当优先使用??来处理默认值赋值,以增强代码的稳定性和可预测性。 -
动态导入(
Dynamic Import) 以 66% 的使用率紧随其后,反映出代码分割和按需加载已是现代 Web 应用性能优化的核心实践,在构建大型应用、特别是需要考虑首屏加载速度的场景时,动态导入几乎是必修课。 -
类私有字段(
Private Fields) 和 逻辑赋值 运算符 (Logical Assignment) 的使用率分别为 43% 和 35% ,表明封装和代码简写同样是开发者追求的目标,尤其是私有字段,为在团队协作中保护内部状态、减少意外修改提供了语言层面的保障。
Array、Set、Object的特性
今年对 Array、Set、Object 数据结构的新特性调查,揭示了不可变性(Immutability) 和 数据处理便利性 已成为前端开发的核心趋势:
- 返回新数组的
toSorted()使用率已达 47% ,其孪生兄弟toReversed()也达到 37% ,说明社区正主动避免原地修改数组带来的副作用。 -
Set新方法整体普及度不高,但在使用者中union()、intersection()、difference()等集合运算需求最集中,开始用于表达更复杂的数据关系与权限逻辑。 - 首次进入调查的
Object.groupBy()拿到 39% 使用率,说明了“按字段分组”这类高频需求可以摆脱 Lodash 等库,直接靠原生 JS 优雅解决。
Promise的特性
在异步编程领域,对多个 Promise 的精细化控制能力已成为现代前端的标配:
-
Promise.allSettled()以 52% 的使用率登顶,适合在“批量请求但不希望单点失败拖垮整体流程”的场景下使用,例如并行拉取多个非关键数据源、日志或埋点结果,它能保证我们总能拿到每个 Promise 的最终状态。 -
Promise.any()使用率也达到 47% ,是“抢最快一个结果”的利器,典型场景是对多个镜像服务发起并行请求、谁先返回就用谁,从而显著优化响应延迟。 - 这两个 API 的走红说明前端异步模型已经从“能并发”走向“可编排”,开发者不再满足于简单的
Promise.all,而是开始为不同业务场景选择更合适的并发策略。
浏览器API
浏览器 API 的使用情况反映了 Web 应用能力正从传统的页面展示,向功能更丰富、更接近原生应用的形态演进:
-
WebSocket仍以 64% 的使用率牢牢占据基础设施地位,支撑了社交、协作、监控看板等场景中的实时通信。 - PWA 使用率达到 48% ,说明离线能力、安装体验和通知能力已经被越来越多团队纳入评估维度。
- 更值得关注的是
WebAssembly (WASM),使用率已达 21% 且排名上升 2 位,高性能语言(如 C++、Rust)编译到浏览器侧解决音视频处理、加解密、游戏等计算密集型问题,正在从先锋实践迈向工程常规武器。
JS语言的痛点
关于 JS 语言自身的痛点,今年的结果再次印证了社区共识:
- 缺乏静态类型(Static Typing) 以 28% 的提及率高居第一,这直接解释了为何 TypeScript 能在短时间内成为事实标准——大型项目在可维护性、重构安全和错误提前暴露上的诉求远非动态类型所能满足。
-
日期处理(Dates) 以 10% 排名第二,说明即便有
Temporal提案在推进,现实中开发者仍大量依赖date-fns、Day.js等第三方库来填补标准库短板。 - 同时,ESM 与 CJS 的兼容问题、标准库整体匮乏 等历史包袱也依然是工程实践中的绊脚石,这些痛点共同构成了“JS 好用但不够省心”的真实写照。
浏览器的痛点
当我们把视线从语言本身转向其运行环境——浏览器时,痛点显得更具工程现实感:
- 跨浏览器支持(Browser support) 以 31% 的提及率稳居首位,说明即便现代浏览器在标准实现上趋于一致,边缘行为差异、新特性落地节奏和兼容性策略仍是困扰前端团队的主要问题。
- 浏览器测试(Browser testing) 以 13% 位列第二,本质上是跨浏览器差异在测试和回归成本上的放大反馈
- 而被单独点名的 Safari 以 7% 成为第三大痛点,很多团队已经默认把它视作“新时代的 IE”,其标准跟进节奏和独特限制,为跨端一致性和平滑体验带来了额外负担。
四、JS技术
综述
这两张图分别从“历史趋势”和“当前满意度”两个维度,为我们描绘了 JS 技术生态的全景图:
- 左侧四象限清晰展示出以 Vite 为代表的新一代工具,正沿着“低使用、高满意度”向“高使用、高满意度”高速跃迁,而曾经的王者 webpack 虽然仍有庞大使用量,但满意度明显滑落且轨迹线转为紫色,显示出疲态
- 从右侧满意度分级我们可以发现,Vite (98%) 、Vitest (97%) 、Playwright (94%) 、Astro (94%) 等新星占据 S 级,而 webpack (26%) 、Angular (48%) 、Next.js (55%) 等传统选手则跌入 B/C 级,这意味着“存量巨大但口碑一般”的技术栈随时可能迎来用户流失;同时,Vite 生态中 Vite + Vitest 的双双登顶也说明高度协同的一体化工具链的优势,对于开发者而言,技术选型时不能只看当前占有率,更要关注满意度和趋势曲线,尤其要多留意那些位于右下象限、线条仍在上扬的新工具。
前端框架
前端框架的长期“三巨头”格局正在被悄然改写:
- React 依旧以 80%+ 的使用率牢牢占据生态核心,但满意度已滑落到 B 级(72%),复杂的心智模型和渐进式演化成本让不少团队收到困扰。
- Vue.js 在 2022 年前后正式超越 Angular 成为第二大框架,并以 84% 的满意度稳居 A 级,证明其在开发体验与性能之间找到了不错的平衡点。
- Svelte 则凭借“无虚拟 DOM”的编译时理念持续走高,使用率已升至 26% ,成为追求极致性能和简洁语法团队的心头好。
- 更有意思的是 HTMX,在近两年实现爆发式增长、使用率来到 13% ,它用“回归 HTML、用属性驱动交互”的思路,对当下 JS-heavy 的前端栈提出了有力反思。
元框架(前后端一体化框架)
元框架领域呈现出“一家独大 + 新星涌现”的混合格局:
- Next.js 继续凭借与 React 的深度绑定,以近 60% 的使用率统治榜单,是大多数 React 团队构建生产级应用的默认选项,App Router 等激进改动和整体复杂度的提升正在透支开发者耐心。
- Nuxt 在 Vue 生态中稳扎稳打,使用率升至 28% 。
- Astro 与 SvelteKit 则是近年最值得关注的两颗新星,前者在内容密集型站点中大放异彩,后者与 Svelte 深度绑定,为全栈应用提供了端到端的极致体验。
后端框架
在 Node.js 后端框架领域,我们不难看出,还是有些新面孔:
- 老牌选手 Express 仍以 80%+ 的使用率稳居第一,作为“薄核心 + 丰富中间件”的事实标准难以被完全替代,但 81% 的满意度也表明开发者正在寻找更现代的方案
- tRPC 是过去两年最耀眼的新星,通过直接在 TypeScript 中实现端到端类型安全调用,大幅简化了前后端联调与接口演进的成本。
测试框架
JavaScript 测试生态正在经历一场“现代化重构”:
- 在单元与集成测试层面,Jest 以 75% 的使用率独占鳌头。
- 端到端测试领域则被 Cypress (55%) 和 Playwright (49%) 两强主导,其中 Playwright 以 94% 的满意度跻身 S 级,体现了其在稳定性、调试体验和多浏览器支持上的优势。
- 紧随其后的是 Vitest,作为 Vite 生态的一员,在短短两年内使用率冲到 50% ,满意度更是高达 97% ,验证了“测试工具与构建工具深度一体化”带来的体验红利。
构建工具
前端构建工具领域也在发生变革:
- webpack 依旧以 85% 的使用率占据绝对存量,但满意度已经跌至 26% ,复杂配置和缓慢构建让它越来越像一座难以完全迁移的“基础设施债务”。
- Vite 则是新时代的领跑者,使用率在短短数年间拉升到 83% 、几乎追平 webpack,满意度更是高达 98% ,依托基于 Go 的 esbuild 实现极快冷启动和热更新,重新定义了“本地开发体验”的下限
- 在更底层 esbuild 的直接使用率已达 52% ,SWC 也拿到 83% 的满意度,说明社区正将编译热点下沉到 Rust/Go 等原生实现,再在其之上搭建更友好的工具。
五、其它工具
JS库使用情况
在通用 JS 库层面,数据清晰地表明开发者最在乎两件事:
- 类型安全和数据处理效率。以 TypeScript 为优先设计的校验库 Zod 以 48% 的使用率登顶,成为“运行时数据校验 + 类型推导”领域的绝对主角,反映出大家在 API 返回、表单输入等链路上,对类型与数据一致性的强烈诉求。
- 传统工具库 Lodash (39%) 依然宝刀不老,仍在大量项目中承担通用数据处理职责。
- 而在日期处理上,date-fns (39%) 、Moment (25%) 、Day.js (24%) 等多家共存,本质上是对 JS 原生日期能力长期缺位的弥补
- 即便是已经被视作“老古董”的 jQuery (16%) ,也仍凭借海量遗留项目保持着不可忽视的存在感。
AI使用情况
AI 工具已经深度嵌入前端开发者的日常工作流,成为新的基础设施:
- ChatGPT 以 60% 的使用率位居首位,承担了问答、代码草稿生成、调试思路辅助等“外脑”角色。
- 深度集成 IDE 的 GitHub Copilot 使用率也达 51% ,更偏向于在写代码时提供上下文感知补全与重构建议,两者形成“离线思考 + 在线自动补全”的互补关系
- 与此同时,Claude (44%) 、Google Gemini (28%) 等通用大模型产品也在快速补位,说明开发者愿意多源头对比体验
- 值得注意的是 AI-native 编辑器 Cursor 已有 26% 的使用率,一部分人开始直接迁移到“以 AI 为核心交互对象”的编辑环境中,这预示着未来开发工具形态本身也会被 AI 重塑。
- 另外,国产大模型 Deepseek 也榜上有名,占据了 8% 的使用率。
其它编程语言使用情况
这张图展示了 JS 开发者的多语言画像:
- Python 以 41% 的占比成为最常见的第二语言,依托后端开发、自动化脚本、数据分析与 AI 等丰富场景,为前端同学打开了更多技术边界。
- PHP (27%) 的存在感说明不少人仍在使用 Web 传统栈构建项目或是在维护古老的历史项目。
- 在工具链和 DevOps 侧,Bash (22%) 几乎是所有工程师的“必修课”。
- 而 Java (21%) 、Go (20%) 、C# (19%) 等企业级后端语言,以及以安全与性能著称的 Rust (16%) ,则构成了很多前端开发者向全栈或更底层系统方向延展的技能支点。
六、使用情况及痛点问题
TS与JS的使用情况
这张分布图有力地说明,TypeScript 已经从“可选增强”进化为 JavaScript 生态的默认选项:
- 有 48% 的受访者表示项目代码 100% 使用 TS 编写,体现出“一旦采用就倾向于全量迁移”的强烈偏好;在所有项目(包括纯 JS、纯 TS 与混合工程)中计算得到的平均采用率高达 77% ,意味着当今前端代码大部分都运行在类型系统保护之下;仍坚持纯 JS 的开发者仅占 6% ,多半集中在遗留项目或极轻量脚本场景;对于在做技术选型的新项目来说,这几乎已经构成了一个共识结论:默认使用 TS,而不是再纠结要不要上 TS。
AI代码生成情况
这张图刻画了 AI 在代码生成中的“真实渗透率”,结论很清晰:
- AI 目前更像是开发者的“副驾驶”,而非自动写代码的主力工程师。只有 10% 的受访者认为项目代码完全没有 AI 贡献,说明九成以上的团队或多或少已经在用 AI 提效;最集中的区间是 1%–20% 代码由 AI 生成(占 38% ),典型用法是让 AI 帮忙写模板代码、样板逻辑、特定算法实现或提供重构建议,而不是让它从零实现完整模块;总体算下来,平均约有 29% 的代码可以归功于 AI,这是一个不容忽视但远未到“全自动开发”的比例,也意味着复杂业务建模、架构设计和质量把控这些高阶工作,短期内仍牢牢掌握在人类开发者手中。
JS的痛点问题
在所有 JS 开发痛点中,真正让团队头疼的并不是某个语法细节,而是宏观层面的工程复杂度:
- 代码架构(Code Architecture) 以 38% 的提及率高居榜首,说明随着前端项目体量和生命周期不断拉长,如何拆分模块、划分边界、治理依赖、避免“屎山”成为最大挑战。
- 紧随其后的是 状态管理(State Management,34%) ,无论是 React 的 hooks 与各种状态库,还是 Vue 的 Pinia,跨组件、跨页面的复杂状态流转依然极易失控。
-
依赖管理(Managing Dependencies,32%) 也是老大难问题,
node_modules黑洞、版本冲突、安全漏洞以及 ESM/CJS 兼容性都会侵蚀工程稳定性。 - 相对而言,曾经广受诟病的 异步 代码(Async Code) 如今只剩 11% 的人视其为痛点,
Promise与async/await已经在很大程度上平滑了这块心智负担,这也从侧面证明语言与工具的演进确实可以逐步“消灭”一部分历史问题。
七、总结
首先,毫无疑问,TypeScript 已然胜出。它赢下的不只是「能编译成js的工具」的争论,而是语言本身。Deno 和 Bun 早已原生支持它。如今,你甚至能在稳定版 Node.js 中直接编写 TypeScript了。
而 Vite 的时代也已到来。今年,Vite 的下载量正式超越 webpack。与之相伴,Vitest 的使用量也大幅飙升。现在正是切换到新一代 Vite 工具链的好时机,而 2026 年注定会是全面落地之年—— 随着 Rolldown 稳定版发布,将驱动出更快的新一代 Vite,同时还有一体化的「Vite+」值得期待。
我们的开发工具从未如此优秀。但大家如今真正关心的却是另一个问题:AI 又将带来什么?
AI 即将彻底改变我们查阅文档、编写代码、做架构决策等一系列工作方式。各家公司都在全力押注全新的开发模式。对我们绝大多数人而言,AI 编程助手正在改变我们与代码交互的方式。
这是一件好事吗?
截至 2025 年底,已有近 30% 的代码由 AI 生成。Cursor 的人气暴涨,尽管它们暂时还无法撼动 VS Code 第一 IDE 的地位。而基于智能代理的工具,比如 Claude、Gemini 和 Copilot,也在迅速普及。
对开发者来说,无论使用什么工具,懂得分辨「什么是好代码」 将会比以往任何时候都更重要。紧跟新语言特性、知道该基于哪些库去开发,而非凭感觉从零手写一切,也会变得愈发关键。
现在,一天之内快速搭建新项目、轻松迁移老项目都已成为现实。这对框架和库的作者来说是个挑战。我们必须保证工具能持续服务好开发者,不能指望用户会一直因惯性而使用。
而这一点,恰恰值得所有开发者的期待。
就让我们拭目以待 2026 年的变化吧。我期待着更快的工具、更好的开发体验,以及技术真正成为能力放大器,强化我们自身的判断与选择。