普通视图

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

《this、箭头函数与普通函数:后台项目里最容易写错的几种场景》

作者 SuperEugene
2026年2月14日 23:47

前言

无论你是刚学 JavaScript 的小白,还是已经写了几年代码的前端,只要在写后台管理系统,大概率都踩过 this 和箭头函数的坑。

这篇文章不讲特别玄学的底层原理,只回答三个问题:

  1. 日常写代码该怎么选?(普通函数 vs 箭头函数)
  2. 为什么这么选?
  3. 坑最容易出在哪里?

一、一个真实的报错场景

先看一段后台管理系统里常见的代码:

// 表格操作列有个「删除」按钮
methods: {
  handleDelete(id) {
    this.$confirm('确定删除吗?').then(() => {
      this.deleteApi(id);  // ❌ 报错:Cannot read property 'deleteApi' of undefined
    });
  }
}

很多人会疑惑:我明明在 methods 里写的,this 怎么会是 undefined

问题在于:this 不是由「你在哪写的」决定的,而是由「谁在调用这个函数」决定的。$confirm().then() 里的回调,是 Promise 内部在调用,普通函数不会自动带上 Vue 实例的 this

如果把 .then() 里的回调改成箭头函数,就不会报错了。后面会详细说明原因。

二、基础扫盲:this 到底是谁决定的

核心结论:this 由「调用方式」决定,而不是由「定义位置」决定。

调用方式 this 指向 典型场景
作为对象方法调用 该对象 obj.fn() → this 是 obj
直接调用 fn() 严格模式:undefined;非严格:window 孤立的函数调用
new 调用 新创建的对象 new Foo()
call/apply/bind 传入的第一个参数 显式指定 this
作为回调传入 谁调就指向谁,通常丢 this setTimeout(fn)、Promise.then(fn)

关键点:当函数被当作回调传给别人时,谁调这个函数,this 就由谁决定。 比如 setTimeout(fn) 里,是浏览器在调 fn,所以 this 通常是 windowundefined,而不是你组件里的 this

三、箭头函数 vs 普通函数:本质区别

对比项 普通函数 箭头函数
this 有属于自己的 this,由调用方式决定 没有自己的 this,使用外层作用域的 this
arguments 没有(可用 ...args 替代)
能否 new 可以 不可以
能否作为构造函数 可以 不可以

一句话区分:

  • 普通函数:有「自己的」this,谁调我,this 就指向谁。
  • 箭头函数:没有「自己的」this,用的是「定义时所在作用域」的 this

因此,在需要「继承」外层 this 的场景(例如 PromisesetTimeout 回调),用箭头函数;在对象方法、构造函数等需要「自己的」this 的场景,用普通函数。

四、后台项目里最容易写错的 5 种场景

场景 1:Element UI / Ant Design 表格里的回调

// ❌ 错误写法:在模板里用箭头函数包装,可能拿不到正确的 this
<el-table-column label="操作">
  <template slot-scope="scope">
    <el-button @click="() => this.handleEdit(scope.row)">编辑</el-button>
  </template>
</el-table-column>

// ✅ 正确写法:直接传方法引用,Vue 会帮你绑定 this
<el-button @click="handleEdit(scope.row)">编辑</el-button>

原因: 模板里的事件绑定,Vue 会自动把组件的 this 绑定到方法上。用箭头函数包装后,this 会在定义时就固定,可能指向 windowundefined,反而拿不到组件实例。

结论: 模板事件尽量直接写方法名,或写 (arg) => this.method(arg),不要在模板里随便包箭头函数。

场景 2:Promise / async 里的 this

// ❌ 错误:.then 里用普通函数,this 丢失
handleSubmit() {
  this.validateForm().then(function(res) {
    this.submitForm();  // this 是 undefined!
  });
}

// ✅ 正确:用箭头函数,继承外层的 this
handleSubmit() {
  this.validateForm().then((res) => {
    this.submitForm();  // this 正确指向组件实例
  });
}

原因: .then() 的回调是 Promise 内部调用的,普通函数不会自动绑定组件 this。用箭头函数可以继承 handleSubmit 所在作用域的 this,即组件实例。

结论:Promiseasync/awaitsetTimeout 等异步回调里,需要访问组件/外层 this 时,用箭头函数。

场景 3:对象方法 / API 封装

// ❌ 错误:箭头函数作为对象方法,this 指向外层(window)
const api = {
  baseUrl: '/api',
  getList: () => {
    return axios.get(this.baseUrl + '/list');  // this.baseUrl 是 undefined!
  }
};

// ✅ 正确:用普通函数
const api = {
  baseUrl: '/api',
  getList() {
    return axios.get(this.baseUrl + '/list');
  }
};

原因: 箭头函数没有自己的 this,会去外层找。这里的 getList 定义在对象字面量里,外层是全局,this 就是 window(或 undefined),自然拿不到 baseUrl

结论: 对象方法、Class 方法需要用到 this 时,用普通函数,不要用箭头函数。

场景 4:事件监听器(addEventListener)

// 场景:监听 window 滚动,组件销毁时需要移除监听

// ❌ 错误:箭头函数每次都是新引用,无法正确 removeEventListener
mounted() {
  window.addEventListener('scroll', () => this.handleScroll());
},
beforeDestroy() {
  window.removeEventListener('scroll', () => this.handleScroll());  // 移除失败!引用不同
}

// ✅ 正确:保存同一个函数引用
mounted() {
  this.boundHandleScroll = this.handleScroll.bind(this);
  window.addEventListener('scroll', this.boundHandleScroll);
},
beforeDestroy() {
  window.removeEventListener('scroll', this.boundHandleScroll);
}

原因: removeEventListener 必须传入和 addEventListener 时完全相同的函数引用。每次写 () => this.handleScroll() 都会生成新函数,所以无法正确移除。

结论: 需要手动移除监听时,用 bind 或普通函数,并把引用存到实例上,保证添加和移除用的是同一个函数。

场景 5:数组方法的回调(forEachmapfilter 等)

// 在 Vue 组件里
methods: {
  processList() {
    const list = [1, 2, 3];
    
    // ❌ 错误:普通函数作为 forEach 回调,this 会丢
    list.forEach(function(item) {
      this.doSomething(item);  // this 是 undefined
    });
    
    // ✅ 正确:箭头函数继承外层的 this
    list.forEach((item) => {
      this.doSomething(item);
    });
  }
}

原因: forEach 等方法的回调是由数组方法内部调用的,普通函数不会绑定组件 this。用箭头函数可以继承 processListthis

结论:forEachmapfilterreduce 等回调里需要访问外层 this 时,用箭头函数;不需要 this 时,两者都可以。

五、决策清单:什么时候用谁

可以按下面几条来选:

  1. 对象方法、Class 方法、构造函数 → 用普通函数。
  2. Promise、setTimeout、数组方法等回调里要访问外层 this → 用箭头函数。
  3. Vue 模板事件 → 直接写方法名,或 (arg) => this.method(arg),避免乱包箭头函数。
  4. 需要 arguments → 用普通函数,或箭头函数 + ...args
  5. addEventListener / removeEventListener → 用 bind 或保存同一引用,保证添加和移除是同一个函数。

六、一句话口诀

  • 普通函数:有自己的 this,谁调我,this 就指向谁。
  • 箭头函数:没有自己的 this,用的是「定义时所在外层」的 this

需要「动态 this」用普通函数,需要「固定外层 this」用箭头函数。

总结

this 和箭头函数本身不复杂,容易出错的是「在错误场景选错写法」。后台项目里,最容易踩坑的就是:Promise 回调、对象方法、模板事件、事件监听器这几处。记住「谁在调用」「外层 this 是谁」,选普通函数还是箭头函数就不容易错。

以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

【React-11/Lesson95(2026-01-04)】React 闭包陷阱详解🎯

作者 Jing_Rainbow
2026年2月14日 23:15

🔍 什么是闭包陷阱

在 React 函数组件开发中,闭包陷阱是一个非常经典且常见的问题。要理解闭包陷阱,我们首先需要理解闭包的形成条件。

闭包的形成条件

闭包的形成通常出现在以下场景:

  • 函数组件嵌套了定时器、事件处理函数等
  • 使用 useEffect 且依赖数组为空
  • 使用 useCallback 缓存函数
  • 词法作用域链的作用

让我们看一个典型的闭包陷阱示例:

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

在这个例子中,useEffect 的依赖数组是空的,这意味着它只会在组件挂载时执行一次。setInterval 回调函数中引用了 count 变量,由于闭包的特性,这个回调函数会捕获到初始渲染时的 count 值(也就是 0)。即使后续我们通过 setCount 更新了 count 的值,定时器回调中的 count 仍然会保持初始值 0,这就是闭包陷阱!

💡 深入理解 React 的渲染机制

要彻底明白闭包陷阱,我们需要理解 React 函数组件的渲染机制:

React 函数组件的重新渲染

每次组件重新渲染时:

  1. 函数组件会重新执行
  2. useState 返回的状态值是当前最新的值
  3. 所有在组件内部定义的函数、变量都会被重新创建
  4. useEffect 会根据依赖数组决定是否重新执行

闭包的工作原理

闭包是 JavaScript 中的一个核心概念,指的是函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。

在 React 中,每次渲染都会创建一个新的"快照",包含当时的所有状态、props 和函数。当 useEffect 依赖数组为空时,它只在第一次渲染时执行,因此它捕获的是第一次渲染时的闭包,里面的所有变量都是初始值。

🛠️ 解决闭包陷阱的 12 种方案

方案一:将依赖项加入到依赖数组中【推荐】

这是最简单也是最推荐的解决方案。通过将 count 加入到依赖数组中,每当 count 变化时,useEffect 都会重新执行,从而捕获到最新的 count 值。

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, [count]);
}

重要提示:不只是组件卸载时才会执行清理函数,每次 effect 重新执行之前,都会先执行上一次的清理函数。这样可以确保不会有多个定时器同时运行。

方案二:使用 useRef 引用变量

useRef 返回的对象在组件的整个生命周期中保持不变,我们可以用它来存储最新的状态值。

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', countRef.current);
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

这种方法的优势是 useEffect 不需要重新执行,避免了频繁创建和清理定时器的开销。

方案三:使用 useCallback 缓存函数

useCallback 可以缓存函数,结合 useRef 一起使用:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案四:使用 useLayoutEffect 代替 useEffect

useLayoutEffect 在 DOM 更新后同步执行,虽然它不能直接解决闭包问题,但在某些场景下配合其他方法使用会更合适:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案五:使用 useMemo 缓存变量

useMemo 用于缓存计算结果,同样可以配合 useRef 使用:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案六:使用 useReducer 管理状态

useReducer 是另一种状态管理方式,它的 dispatch 函数具有稳定的引用,可以避免闭包问题:

function App() {
  const [count, setCount] = useReducer((state, action) => {
    switch (action.type) {
      case 'increment':
        return state + 1;
      case 'decrement':
        return state - 1;
      default:
        return state;
    }
  }, 0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案七:使用 useImperativeHandle 暴露方法

useImperativeHandle 用于自定义暴露给父组件的 ref 实例值:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案八:使用 useContext 传递状态

useContext 可以跨组件传递状态,避免 prop drilling:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案九:使用 useDebugValue 调试状态

useDebugValue 用于在 React DevTools 中显示自定义 Hook 的标签:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案十:使用 useTransition 处理异步更新

useTransition 是 React 18 引入的 Hook,用于标记非紧急更新:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案十一:使用 useDeferredValue 处理异步更新

useDeferredValue 也是 React 18 引入的 Hook,用于延迟更新某些值:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

方案十二:使用 useLayoutEffect 处理同步更新

useLayoutEffect 在 DOM 更新后同步执行,可以用于处理需要立即反映到 DOM 上的操作:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
  const logCount = useCallback(() => {
    console.log('Current count:', countRef.current);
  }, []);
  useLayoutEffect(() => {
    const timer = setInterval(() => {
      logCount();
    }, 1000);
    return () => {
      clearInterval(timer);
    }
  }, []);
}

📝 实际应用场景

闭包陷阱不仅仅出现在定时器中,还可能出现在以下场景:

  1. 事件处理函数:在 useEffect 中添加事件监听器
  2. 异步请求:在 useEffect 中发起网络请求
  3. 动画:使用 requestAnimationFrame 等 API
  4. WebSocket:建立长连接
  5. 防抖节流函数:在组件中使用防抖或节流

🎓 最佳实践建议

  1. 优先使用依赖数组:这是最直观、最符合 React 设计理念的方案
  2. 合理使用 useRef:当不需要频繁重新执行 effect 时,useRef 是很好的选择
  3. 理解清理函数的重要性:始终正确清理定时器、事件监听器等资源
  4. 使用 ESLint 插件eslint-plugin-react-hooks 可以帮助你发现遗漏的依赖项

希望这篇文章能帮助你彻底理解 React 闭包陷阱!🎉

为什么Django这么慢,却还是Python后端第一梯队呢?

作者 Sheffield
2026年2月14日 22:59

学习Web框架的Python玩家大多应该都听过:Django 性能不行”、“高并发场景根本用不了 Django”。但有趣的是,在TIOBE、PyPI下载量、企业技术栈选型中,Django始终稳居Python后端框架第一梯队,甚至是很多公司的首选。

这背后的矛盾,恰恰折射出工业级开发的核心逻辑:性能从来不是唯一的衡量标准,生产力和工程化能力才是

一、先澄清:Django 的“慢”,到底慢在哪?

首先要纠正一个认知偏差:Django的 “慢” 是相对的,而非绝对的。

1. 所谓“慢”的本质

Django被吐槽“慢”,主要集中在这几个点:

  • 全栈特性的代价:Django是“电池已内置”的全栈框架,ORM、表单验证、认证授权、Admin后台、缓存、国际化等功能开箱即用,这些内置组件会带来一定的性能开销,对比Flask、FastAPI这类轻量框架,纯接口响应速度确实稍慢(基准测试中,简单接口QPS约为FastAPI的1/3-1/2)。
  • 同步 IO 的天然限制:Django默认是同步架构,在高并发IO密集型场景(如大量请求等待数据库/第三方接口响应)下,线程/进程池容易被打满,吞吐量受限。
  • ORM 的 “便利税” :自动生成的SQL可能不够优化,新手容易写出N+1查询,进一步放大性能问题。

2. 但这些“慢”,大多是“伪问题”

绝大多数业务场景下,Django的性能完全够用:

  • 普通中小网站(日活10万以内):Django+合理缓存+数据库优化,能轻松支撑业务,性能瓶颈根本不在框架本身。
  • 所谓 “慢” 的对比场景:大多是“裸框架接口跑分”,而真实业务中,接口响应时间的80%以上消耗在数据库、缓存、网络IO上,框架本身的耗时占比不足5%。
  • 性能可优化空间大:通过异步改造(Django 3.2+原生支持ASGI)、缓存层(Redis)、数据库读写分离、CDN、Gunicorn+Nginx部署等方式,完全能把Django的性能提升到满足中高并发的水平。

二、Django能稳居第一梯队,核心是“降本增效”

企业选框架,本质是选“性价比”——开发效率、维护成本、团队协作成本,远比单点性能重要。而这正是Django的核心优势。

1. 极致的开发效率:“开箱即用” 的工业级体验

Django的设计哲学是 “不要重复造轮子”,一个命令就能生成完整的项目骨架,几行代码就能实现核心功能:

# 5行代码实现带权限的REST接口(Django+DRF)
from rest_framework import viewsets
from .models import Article
from .serializers import ArticleSerializer

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    permission_classes = [IsAuthenticated]
  • 内置Admin后台:无需写一行前端代码,就能实现数据的增删改查,调试和运营效率拉满。
  • 完善的认证授权:Session、Token、OAuth2等认证方式开箱即用,不用自己造权限轮子。
  • 表单验证&CSRF防护:自动处理表单校验、跨站请求伪造,减少安全漏洞。
  • ORM的价值:虽然有性能损耗,但大幅降低了数据库操作的学习成本和出错概率,新手也能快速写出安全的数据库逻辑。

对于创业公司或快速迭代的业务,“快上线、少踩坑”比“多10%的性能” 重要得多——Django能让团队用最少的人力,在最短时间内搭建起稳定的业务系统。

2. 成熟的工程化体系:适合团队协作

个人项目可以用Flask自由发挥,但团队项目需要“规范”。Django的“约定优于配置”理念,强制规范了项目结构、代码组织、数据库迁移等流程:

  • 统一的项目结构:新人接手项目,不用花时间理解自定义的目录结构,直接就能上手。
  • 内置的数据库迁移工具:makemigrations/migrate 完美解决数据库版本管理问题,避免团队协作中的数据结构混乱。
  • 丰富的中间件和扩展生态:缓存中间件、跨域中间件、日志中间件等开箱即用,DRF(Django REST Framework)、Celery、Django Channels等扩展几乎能覆盖所有业务场景。
  • 完善的文档和社区:官方文档堪称 “教科书级别”,遇到问题能快速找到解决方案,招聘时也更容易找到有经验的开发者。

3. 稳定可靠:经得起生产环境的考验

Django 诞生于2005年,经过近20年的迭代,已经成为一个极其稳定的框架:

  • 长期支持版本(LTS):每2-3年发布一个LTS版本,提供3年以上的安全更新和bug修复,企业不用频繁升级框架。
  • 安全特性完善:自动防御XSS、CSRF、SQL注入等常见攻击,官方会及时修复安全漏洞,这对企业来说是“刚需”。
  • 大量知名案例背书:Instagram、Pinterest、Mozilla、Spotify、国内的知乎(早期)、豆瓣等,都在用 Django支撑核心业务——这些产品的规模,足以证明Django的可靠性。

三、Django 的 “破局之路”:性能短板正在被补齐

面对性能吐槽,Django团队也一直在迭代优化:

  • 异步支持:Django 3.0引入ASGI,3.2+完善异步视图、异步ORM,能直接对接WebSocket、长连接,IO密集型场景的并发能力大幅提升。
  • 性能优化:新版本持续优化ORM、模板引擎、中间件,减少不必要的开销,比如Django 4.0+的ORM支持批量更新/插入,性能提升显著。
  • 生态适配:可以和FastAPI混合部署(比如核心接口用FastAPI,管理后台用Django),兼顾性能和生产力;也可以通过Gunicorn+Uvicorn+异步工作进程,充分利用多核CPU。

四、总结:选框架,本质是选 “适配性”

Django的 “慢”,是为 “全栈、工程化、生产力” 付出的合理代价;而它能稳居第一梯队,核心原因是:

  1. 匹配绝大多数业务场景:90%的中小业务不需要 “极限性能”,但都需要 “快速开发、稳定运行、易维护”。
  2. 降低团队成本:统一的规范、丰富的内置功能、完善的文档,能大幅降低招聘、培训、协作成本。
  3. 生态和稳定性兜底:成熟的生态能解决几乎所有业务问题,长期支持版本让企业不用频繁重构。

最后想说:框架没有好坏,只有适配与否。如果是做高并发的API服务(如直播、秒杀),FastAPI/Tornado 可能更合适;但如果是做内容管理、电商、企业后台等需要快速落地、长期维护的业务,Django依然是Python后端的最优解之一。

这也是为什么,即便有层出不穷的新框架,Django依然能稳坐第一梯队——因为它抓住了工业级开发的核心:让开发者把精力放在业务上,而非重复造轮子

《React 受控组件 vs 非受控组件:一篇吃透表单处理精髓》

作者 随逸177
2026年2月14日 21:09

React 受控组件 vs 非受控组件:一篇吃透表单处理精髓

在 React 开发中,表单处理是高频场景——登录注册、评论提交、信息录入,几乎每个项目都会用到。但很多新手都会困惑:同样是获取表单输入值,为什么有的用 useState,有的用 useRef?这其实对应了 React 表单处理的两种核心方式:受控组件非受控组件

很多人分不清两者的区别,盲目使用导致表单出现“无法输入”“值获取不到”“性能冗余”等问题。本文将从「核心疑问出发」,拆解两者的定义、用法、区别,结合实战代码演示,帮你彻底搞懂什么时候用受控、什么时候用非受控,看完直接落地项目。

一、核心疑问:怎么拿到 React 表单的值?

原生 HTML 中,我们可以通过 DOM 直接获取表单元素的值,比如 document.querySelector('input').value。但 React 遵循“单向数据流”原则,不推荐直接操作 DOM,因此提供了两种更规范的方式获取表单值,对应两种组件类型。

先看一个最基础的示例,直观感受两者的差异:

import { useState, useRef } from 'react';

export default function App() {
  // 受控组件:用状态控制输入框
  const [value, setValue] = useState("")
  // 非受控组件:用 ref 获取 DOM 值
  const inputRef = useRef(null);

  // 表单提交逻辑
  const doLogin = (e) => {
    e.preventDefault(); // 阻止页面刷新
    console.log("非受控输入值:", inputRef.current.value); // 非受控获取值
    console.log("受控输入值:", value); // 受控获取值
  }

  return (
    <form onSubmit={
      {/* 受控输入框:value 绑定状态,onChange 更新状态 */}
      <input 
        type="text" 
        value={) => setValue(e.target.value)} 
        placeholder="受控输入框"
      />
      {/* 非受控输入框:ref 关联 DOM,无需绑定状态 */}
      <input 
        type="text" 
        ref={受控输入框"
        style={{ marginLeft: '10px' }}
      />
      <button type="submit" style={提交
  )
}

上面的代码中,两个输入框分别对应受控和非受控两种方式,核心差异在于「值的控制者」不同——一个由 React 状态控制,一个由 DOM 原生控制。

二、逐字拆解:什么是受控组件?

1. 核心定义

受控组件:表单元素的值由 React 状态(useState)完全控制,输入框的显示值 = 状态值,输入行为通过 onChange 事件更新状态,从而实现“状态 ↔ 输入框”的联动。

核心逻辑:状态驱动 DOM,符合 React 单向数据流原则——数据从状态流向 DOM,DOM 输入行为通过事件反馈给状态,形成闭环。

2. 核心用法(必记)

实现一个受控组件,必须满足两个条件:

  • 给表单元素绑定 value={状态值},让状态决定输入框显示内容;
  • 绑定 onChange 事件,通过 e.target.value 获取输入值,调用 setState 更新状态。

3. 实战:多字段受控表单(登录注册场景)

实际开发中,表单往往有多个字段(如用户名、密码),此时可以用一个对象状态管理所有字段,配合事件委托简化代码:

import { useState } from "react"

export default function LoginForm() {
  // 用对象状态管理多个表单字段
  const [form, setForm] = useState({
    username: "",
    password: ""
  });

  // 统一处理所有输入框的变化
  const handleChange = (e) => {
    // 解构事件目标的 name 和 value(输入框需设置 name 属性)
    const { name, value } = e.target;
    // 更新状态:保留原有字段,修改当前输入字段(不可直接修改原对象)
    setForm({
      ...form, // 展开原有表单数据
      [name]: value // 动态更新对应字段
    })
  }

  // 表单提交
  const handleSubmit = (e) => {
    e.preventDefault();
    // 直接从状态中获取所有表单值,无需操作 DOM
    console.log("表单数据:", form);
    // 实际开发中:这里可做表单校验、接口请求等逻辑
  }

  return (
    <form onSubmit={<div style={<input 
          type="text" 
          placeholder="请输入用户名" 
          name="username" /Change}
          value={form.username} // 绑定状态值
          style={{ padding: '6px' }}
        />
      <div style={>
        <input 
          type="password" 
          placeholder="请输入密码" 
          name="password" / 绑定状态值
          style={{ padding: '6px' }}
        />
      <button type="submit" style={注册
  )
}

4. 受控组件的关键细节

  • ⚠️ 只写 value={状态} 不写 onChange,输入框会变成「只读」——因为状态无法更新,输入框值永远固定;
  • 状态更新是异步的,但不影响表单输入(React 会批量处理状态更新,保证输入流畅);
  • 适合做「实时操作」:比如实时表单校验、输入内容实时展示、表单字段联动(如密码强度提示)。

三、逐字拆解:什么是非受控组件?

1. 核心定义

非受控组件:表单元素的值由 DOM 原生控制,React 不干预输入过程,而是通过 useRef 获取 DOM 元素,再读取其 current.value 获取输入值。

核心逻辑:DOM 驱动数据,和原生 HTML 表单逻辑一致,React 只做“被动获取”,不主动控制输入值。

2. 核心用法(必记)

实现一个非受控组件,只需一步:

  • useRef(null) 创建 Ref 对象,绑定到表单元素的 ref 属性;
  • 需要获取值时,通过 ref.current.value 读取(通常在提交、点击等事件中获取)。

可选:用 defaultValue 设置初始值(仅首次渲染生效,后续修改不影响)。

3. 实战:非受控评论框(一次性提交场景)

评论框、搜索框等“一次性提交”场景,无需实时监控输入,用非受控组件更简洁高效:

import { useRef } from 'react';

export default function CommentBox() {
  // 创建 Ref 对象,关联 textarea 元素
  const textareaRef = useRef(null);

  // 提交评论逻辑
  const handleSubmit = () => {
    // 防御性判断:避免 ref.current 为 null(极端场景)
    if (!textareaRef.current) return;
    // 获取输入值
    const comment = textareaRef.current.value.trim();
    // 表单校验
    if (!comment) return alert('请输入评论内容!');
    // 提交逻辑
    console.log("评论内容:", comment);
    // 提交后清空输入框(直接操作 DOM)
    textareaRef.current.value = "";
  }

  return (
    <div style={<textarea 
        ref={        placeholder="输入评论..."
        style={{ width: '300px', height: '100px', padding: '10px' }}
        defaultValue="请输入你的看法..." // 初始值(可选)
      />
      <button 
        onClick={={{ padding: '6px 16px', marginTop: '10px' }}
      >
        提交评论
      
  )
}

4. 非受控组件的关键细节

  • ⚠️ 不要用 value 绑定状态(否则会变成受控组件),初始值用 defaultValue
  • Ref 对象的 current 在组件首次渲染后才会指向 DOM,因此不能在组件渲染时直接读取 textareaRef.current.value(会报错);
  • 适合做「一次性操作」:比如文件上传( 必须用非受控)、简单搜索框、一次性提交的表单。

四、核心对比:受控组件 vs 非受控组件(必背)

很多人纠结“该用哪个”,其实核心看「是否需要实时控制输入」,用表格清晰对比两者差异,一目了然:

对比维度 受控组件 非受控组件
值的控制者 React 状态(useState) DOM 原生控制
核心依赖 useState + onChange useRef
值的获取方式 直接读取状态(如 form.username) ref.current.value
初始值设置 useState 初始值(如 useState("")) defaultValue 属性
是否触发重渲染 输入时触发(onChange 更新状态) 输入时不触发(无状态变化)
适用场景 实时校验、表单联动、实时展示 一次性提交、文件上传、性能敏感场景
优点 可实时控制,符合 React 单向数据流,易维护 简洁高效,无需频繁更新状态,性能更好
缺点 频繁触发重渲染,代码量稍多 无法实时控制,需手动操作 DOM,不易做联动

五、实战总结:什么时候该用哪个?(重点)

不用死记硬背,记住两个核心原则,就能快速判断:

1. 优先用受控组件的情况

  • 表单需要「实时校验」(如用户名长度限制、密码强度提示);
  • 表单字段需要「联动」(如勾选“记住密码”才显示“密码确认”);
  • 需要「实时展示输入内容」(如输入时同步显示剩余字符数);
  • 表单数据需要和其他组件共享、联动(如跨组件传递表单值)。

2. 优先用非受控组件的情况

  • 表单是「一次性提交」(如评论、搜索,无需实时监控);
  • 需要处理「文件上传」( 是天然的非受控组件,无法用状态控制);
  • 追求「性能优化」(避免频繁的状态更新和组件重渲染);
  • 简单表单(如单个输入框,无需复杂逻辑)。

3. 避坑提醒

  • 不要混合使用:同一个表单元素,不要既绑定 value 又绑定 ref,会导致逻辑混乱;
  • 非受控组件必做防御:获取值时,先判断 ref.current 是否存在,避免报错;
  • 多字段表单优先受控:用对象状态管理,代码更规范、易维护。

六、最终总结

受控组件和非受控组件没有“谁更好”,只有“谁更合适”:

✅ 受控组件是 React 表单处理的「主流方式」,符合单向数据流,适合复杂表单、需要实时控制的场景;

✅ 非受控组件更「简洁高效」,贴近原生 HTML,适合简单场景、性能敏感场景和文件上传;

记住:判断的核心是「是否需要实时控制输入值」。掌握两者的用法和区别,就能轻松应对 React 中的所有表单场景,写出简洁、高效、可维护的代码。

《React Context 极简实战:解决跨层级通信》

作者 随逸177
2026年2月14日 20:41

React Context 极简实战:解决跨层级通信

在 React 开发中,组件通信是绕不开的核心问题。父子组件通信可以通过 props 轻松实现,但当组件层级嵌套较深(比如爷爷 → 父 → 子 → 孙),或者需要跨多个组件共享数据时,单纯依靠 props 传递就会变得繁琐又低效——这就是我们常说的“prop drilling(props 透传)”。

就像《长安的荔枝》里,荔枝从岭南运往长安,需要层层传递、处处协调,耗时耗力还容易出问题。React 的 Context API 就是为了解决这个痛点而生,它能让数据在组件树中“全局共享”,无需手动层层透传,让跨层级通信变得简洁高效。

本文将从「痛点分析」→「Context 核心原理」→「基础用法」→「实战案例」,带你彻底掌握 React Context 的使用,看完就能直接应用到项目中。

一、痛点:prop drilling 有多麻烦?

先看一个常见的场景:App 组件持有用户信息,需要传递给嵌套在 Page → Header → UserInfo 里的最内层组件,用于展示用户名。

用传统 props 传递的代码如下:

// App 组件(数据持有者)
export default function App() {
  const user = {name:"Andrew"}; // 登录后的用户数据
  return (
    <Page user={user} />
  )
}

// Page 组件(中间层,仅透传 props)
import Header from './Header';
export default function Page({user}) {
  return (
    <Header user={user}/>
  )
}

// Header 组件(中间层,继续透传 props)
import UserInfo from './UserInfo';
export default function Header({user}) {
  return (
    <UserInfo user={user}/> 
  )
}

// UserInfo 组件(最终使用数据)
export default function UserInfo({user}) {
  return (
    <div>{user.name}</div>
  )
}

这段代码的问题很明显:

  • Page、Header 组件本身不需要使用 user 数据,却要被迫接收和传递 props,增加了冗余代码;
  • 如果组件层级再多几层(比如 5 层、10 层),props 透传会变得异常繁琐,后续维护时,修改数据传递路径也很容易出错;
  • 数据的“持有权”和“使用权”分离,但传递过程中没有统一的管理,可读性差。

而 Context API 就能完美解决这个问题——它让数据“悬浮”在组件树的顶层,任何层级的组件,只要需要,都能直接“取用”,无需中间组件透传。

二、Context 核心原理:3 个关键步骤

React Context 的核心思想很简单:创建一个“数据容器”,在组件树的顶层提供数据,底层组件按需取用。整个过程只需 3 步,记牢就能轻松上手。

1. 创建 Context 容器(createContext)

首先,我们需要用 React 提供的 createContext 方法,创建一个 Context 容器,用于存储需要共享的数据。可以把它理解为一个“全局数据仓库”。

import { createContext } from 'react';

// 创建 Context 容器,默认值为 null(可选,可根据需求设置)
// 导出 Context,供其他组件取用
export const UserContext = createContext(null);

注意:默认值只有在“组件没有找到对应的 Provider”时才会生效,实际开发中一般设置为 null 或初始数据即可。

2. 提供数据(Provider)

创建好 Context 容器后,需要在组件树的“顶层”(通常是 App 组件),用 Context.Provider 组件将数据“提供”出去。Provider 是 Context 的内置组件,它会将数据传递给所有嵌套在它里面的组件。

import { UserContext } from './contexts/UserContext';
import Page from './views/Page';

export default function App() {
  const user = { name: "Andrew" }; // 需要共享的数据

  return (
    // Provider 包裹需要共享数据的组件树
    // value 属性:设置 Context 中要共享的数据
    <UserContext.Provider value={user}>
      <Page /> {/* Page 及其子组件都能取用 user 数据 */}
    </UserContext.Provider>
  )
}

关键细节:

  • Provider 可以嵌套使用(比如同时提供用户信息、主题信息两个 Context);
  • 当 Provider 的 value 发生变化时,所有使用该 Context 的组件都会自动重新渲染;
  • 数据的“持有权”仍在顶层组件(App),符合 React “单向数据流”的原则——只有顶层组件能修改数据,底层组件只能读取。

3. 取用数据(useContext)

底层组件想要使用 Context 中的数据,只需用 React 提供的 useContext Hook,传入对应的 Context 容器,就能直接获取到共享数据,无需任何 props 透传。

import { useContext } from 'react';
// 导入创建好的 Context
import { UserContext } from '../contexts/UserContext';

export default function UserInfo() {
  // 用 useContext 取用 Context 中的数据
  const user = useContext(UserContext);
  
  return (
    <div>当前登录用户:{user.name}</div>
  )
}

此时,Page、Header 组件就可以完全去掉 user props,专注于自己的功能即可:

// Page 组件(无需透传 props)
import Header from './Header';
export default function Page() {
  return <Header />;
}

// Header 组件(无需透传 props)
import UserInfo from './UserInfo';
export default function Header() {
  return <UserInfo />;
}

是不是简洁多了?无论 UserInfo 组件嵌套多深,只要它在 Provider 的包裹范围内,就能直接取用数据。

三、实战案例:全局主题切换(Context + 状态管理)

上面的案例只是“读取静态数据”,实际开发中,我们更常需要“共享可修改的状态”(比如全局主题、用户登录状态)。下面我们用 Context 实现一个「白天/夜间主题切换」功能,完整覆盖 Context 的核心用法。

需求说明

  • 实现白天(light)/ 夜间(dark)主题切换;
  • 主题状态全局共享,Header 组件显示当前主题并提供切换按钮;
  • 页面背景色、文字色随主题变化,支持平滑过渡。

步骤 1:创建 ThemeContext 并提供状态

我们创建一个 ThemeProvider 组件,负责管理主题状态(theme)和切换方法(toggleTheme),并通过 Provider 提供给整个组件树。

// contexts/ThemeContext.js
import { useState, createContext, useEffect } from 'react';

// 1. 创建 Context 容器
export const ThemeContext = createContext(null);

// 2. 创建 Provider 组件,管理状态并提供数据
export default function ThemeProvider({ children }) {
  // 主题状态:默认白天模式
  const [theme, setTheme] = useState('light');

  // 主题切换方法:切换 light/dark
  const toggleTheme = () => {
    setTheme((prevTheme) => prevTheme === 'light' ? 'dark' : 'light');
  };

  // 副作用:主题变化时,修改 html 标签的 data-theme 属性(用于 CSS 样式切换)
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  // 3. 提供数据:将 theme 和 toggleTheme 传递给子组件
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children} {/* children 是嵌套的组件树 */}
    </ThemeContext.Provider>
  );
}

步骤 2:顶层组件引入 ThemeProvider

在 App 组件中,用 ThemeProvider 包裹整个组件树,让所有子组件都能取用主题相关数据。

// App.js
import ThemeProvider from "./contexts/ThemeContext";
import Page from './pages/Page';

export default function App() {
  return (
    <ThemeProvider>
      <Page />
    </ThemeProvider>
  );
}

步骤 3:底层组件取用主题并实现切换

在 Header 组件中,用 useContext 取用 theme 和 toggleTheme,实现主题显示和切换功能。

// components/Header.js
import { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';

export default function Header() {
  // 取用主题状态和切换方法
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <div style={{ marginBottom: 24, padding: 20 }}>
      <h2>当前主题:{theme === 'light' ? '白天模式' : '夜间模式'}</h2>
      <button 
        className="button" 
        onClick={toggleTheme}
      >
        切换主题
      </button>
    </div>
  );
}

步骤 4:CSS 样式配合主题切换

通过 CSS 变量和属性选择器,实现主题切换时的样式变化,配合 transition 实现平滑过渡。

/* theme.css */
/* 全局 CSS 变量:默认白天模式 */
:root {
  --bg-color: #ffffff;
  --text-color: #222222;
  --primary-color: #1677ff;
}

/* 夜间模式:修改 CSS 变量 */
[data-theme='dark'] {
  --bg-color: #141414;
  --text-color: #f5f5f5;
  --primary-color: #4e8cff;
}

/* 全局样式 */
body {
  margin: 0;
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: all 0.3s ease; /* 平滑过渡 */
  font-family: 'Arial', sans-serif;
}

/* 按钮样式 */
.button {
  padding: 8px 16px;
  background-color: var(--primary-color);
  color: #ffffff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.button:hover {
  opacity: 0.9;
}

步骤 5:Page 组件整合

Page 组件作为中间层,无需关心主题数据,只需渲染 Header 即可。

// pages/Page.js
import Header from '../components/Header';

export default function Page() {
  return (
    <div style={{ padding: 24 }}>
      <Header />
      <h3>主题切换实战演示</h3>
      <p>当前页面背景色、文字色会随主题变化哦~</p>
    </div>
  );
}

效果演示

  1. 初始状态:白天模式,背景为白色,文字为深灰色,按钮为蓝色;

  2. 点击“切换主题”按钮:主题变为夜间模式,背景变为黑色,文字变为白色,按钮颜色变浅;

  3. 再次点击:切换回白天模式,所有样式平滑过渡。

四、Context 实用技巧与注意事项

1. 多个 Context 共存

实际开发中,我们可能需要共享多种数据(比如用户信息、主题、权限),此时可以嵌套多个 Provider:

<UserContext.Provider value={user}>
  <ThemeContext.Provider value={{ theme, toggleTheme }}>
    <Page />
  </ThemeContext.Provider>
</UserContext.Provider>

底层组件可以分别用 useContext 取用不同的 Context 数据,互不影响。

2. 避免不必要的渲染

当 Provider 的 value 发生变化时,所有使用该 Context 的组件都会重新渲染。如果 value 是一个对象,每次渲染都会创建新对象,会导致不必要的渲染。

解决方案:用 useMemo 缓存 value 对象(如果有状态变化):

import { useMemo } from 'react';

// 缓存 value,只有 theme 或 toggleTheme 变化时才更新
const contextValue = useMemo(() => ({
  theme,
  toggleTheme
}), [theme]);

return (
  <ThemeContext.Provider value={contextValue}>
    {children}
  </ThemeContext.Provider>
);

3. Context 不是万能的

Context 适合共享「全局且变化不频繁」的数据(如主题、用户信息、权限),但不适合用来传递频繁变化的局部数据(如表单输入值)。

如果数据只在父子组件之间传递,且层级较浅,优先使用 props;如果数据需要跨多层级共享,再考虑 Context。

4. 默认值的使用场景

createContext 的默认值,只有在组件没有被对应的 Provider 包裹时才会生效。通常用于开发环境下的 fallback(降级),或者测试组件时避免报错。

五、总结

React Context API 是解决跨层级组件通信的最优方案之一,它的核心是“创建容器 → 提供数据 → 取用数据”,三步就能实现全局数据共享,彻底解决 prop drilling 的痛点。

通过本文的基础讲解和主题切换实战,你应该已经掌握了 Context 的核心用法:

  • 用 createContext 创建数据容器;
  • 用 Context.Provider 在顶层提供数据(可包含状态和方法);
  • 用 useContext 在底层组件取用数据;
  • 结合 useState、useEffect 可以实现可修改的全局状态管理。

React Native中实现仿iOS的液态玻璃效果

作者 sure282
2026年2月14日 20:18

前言

在React Native近期的更新中,比较大的就是GlassEffect,就是iOS的液态玻璃效果。其实具体啥样我也没具体关注,主要是没有iOS设备,我的音乐播放器应用也一直是在Android上调试。而且Android上的blur效果和GlassEffect效果是有明显差异的。那么提到blur,前端er应该立马能想到CSS中的滤镜,blur毛玻璃效果在web端实现非常轻松,而在android则是实验性功能,它还很年轻,在开发过程中我尝试了很多依赖库,下面会简单介绍在使用过程中踩过的坑。下面先放图,使用的位置是页面顶部和底部tab,看一下大致效果:

微信图片_20260214174329_233_23.jpg微信图片_20260214174330_234_23.jpg

微信图片_20260214174331_235_23.jpg

使用过的依赖库

@sbaiahmed1/react-native-blur

效果非常哇塞的一个依赖库!amazing!但是它有些大坑,而且难以逾越,在github仓库中有很多issue,而且我发现有些并没有明确解决,但是关闭了。最近也是尝试了挺多依赖库,也提了一些issue,有些依赖库作者会回复,但是总体感受就是不耐烦,而且大部分是急不可耐的关闭,比如:我们测没有问题,你的问题我从来没遇到过,关闭。这个问题不多说。具体说我发现的问题:

  1. 当页面中使用了该依赖库创建blur效果后当前页面内触发的页面跳转:比如模态页,新页面是动画划出,在新打开的页面中会蒙上一层白雾,不影响页面操作
  2. 当一个页面使用了blur效果,在该页面中跳转一个从页面侧面划出的模态页(抽屉效果)时没有问题,但是当页面收起时会再次闪烁一下这个模态页

3.自带overflow:'hidden'且无法使用自定义样式覆盖,这使得你想让容器内的一个元素以定位的方式'超出容器'变的很困难,你需要改布局和样式来实现,这在我的音乐播放器的应用中

其它问题没有具体测试,但是已经让我够崩溃的了,因为我用的expo搭建的rn项目,并且没有本地构建环境,只能使用EAS build,而expo的每个月免费构建次数是有上限的,Android和iOS各是15次。

@danielsaraldi/react-native-blur-view

这个依赖库也是个很amazing的依赖库,但是amazing的不是效果,是它的使用方式比较奇特,readme文档中写道:

import {
  BlurView,
  BlurTarget,
  VibrancyView,
} from '@danielsaraldi/react-native-blur-view';
// ...

export default function App() {
  // ...

  return (
    <>
      <BlurView targetId="target" style={styles.blurView}>
        <Text style={styles.title}>BlurView</Text>
      </BlurView>

      <VibrancyView style={styles.vibrancyView}>
        <Text style={styles.title}>VibrancyView</Text>
      </VibrancyView>

      <BlurTarget id="target" style={styles.main}>
        <ScrollView
          style={styles.main}
          contentContainerStyle={styles.content}
          showsVerticalScrollIndicator={false}
        >
          {/* ... */}
        </ScrollView>
      </BlurTarget>
    </>
  );
}

它有一个比较奇怪的targetId,而且它的使用不太符合直觉,而且目前大部分blur依赖库的使用方式就是BlurView容器组件内部children使我们真正想要渲染的元素。由于我没有深入研究该依赖库,而且做出了半透明背景色效果,就和我当初初用expo-blur一样,实现了rgba半透明背景色。。。

  1. 自带overflow:'hidden',无法覆盖
  2. 使用方式复杂,不够直观
  3. 接收的style样式奇奇怪怪各种无效

expo-blur

这个依赖库很早我就安装过,,当时只是做了简单的效果,没有仔细看文档,其实它的文档非常简单,配置项很少,使用极其简单,效果其实也还不错,没有像第一个依赖库那样的硬伤bug,但是最近我要在应用中明确实现图中的效果,但是,但是由于我忽略了最重要的配置,导致我只能实现rgba效果!,后面我会重点分析,但是并不是说它就没有问题:

  1. 容器中的文字,icon图标外围可能会出现模糊阴影:仔细看第三张图的'我的'文字,它周围产生了阴影!而且不是总会产生!就是容器内的元素可能因为BlurView的影响产生了朦胧美!当然你觉得它美也行,但是它时有时无,可以确定是bug

其它目前我没有发现异常,而且就像expo-audio兜兜转转又回到原点一样,我千辛万苦尝试了这么多依赖库,因为这些依赖库都是需要原生代码,因此想要调试不能使用expo go,必须执行开发构建才能调试,这个坑还能接受,但是你还要知道:不同的blur依赖库内部可能都使用了同样的依赖库,调用了同样的原生功能,如果你把所有功能相似的依赖库都安装并开发构建,你大概率会失败,所以在尝试一些依赖库时就提心吊胆:构建次数有限,每次同类库只能安装一个,比较耗时,大概30分钟以内,如果排队的话,可能光排队就要30分钟!

expo-blur的使用

刚才也提到了expo-blur使用非常简单,配置项很少,非常符合直觉,文档的介绍也比较简单:它继承扩展了ViewProps,就是它具有View组件的属性,除此之外还有以下属性:

  1. intensity,强度值 1~100,模糊强度,值越大越模糊
  2. tint 模糊效果类型,它内置了很多通用的模糊效果类型,比如systemMaterialLight和systemMaterialDark,有一些是成对的dark和light,这在做亮暗模式切换时很有用
  3. blurReductionFactor区分模糊强度的数值,主要用于模拟调整效果,使模糊效果接近iOS,文档没有提到具体值的范围,默认值是4,我测试超过100就没有变化了
  4. experimentalBlurMethod,在安卓上实验性的模糊方法也就是底层到底使用何种方式让UI模糊,它有两个可选值,默认none,另一个是dimezisBlurView

就这么点配置项,我天真的以为核心是intensity强度值,结果发现设置为100效果也只是rgba,这让我很恼火,以为依赖库效果不行,就换换换,其实呢是没有设置experimentalBlurMethod:'dimezisBlurView',如果你看了我上一篇文章,里面的半透明效果使用的是@sbaiahmed1/react-native-blur,效果不正确应该还是有些配置或者使用方式不对,也不研究了,因为expo-blur满足我想要的效果。除此之外,你是不是想过做一个半透明毛玻璃效果的图片作为背景图充当blurView容器呢? 我要告诉你,这是行不通的,明明在ps中调试的不错,但是真正使用就会发现效果约等于rgba!

为了让应用中效果比较统一,我做了简单封装:

import type { FC, PropsWithChildren } from "react";
import { BlurView } from "expo-blur";
import type { StyleProp, ViewStyle } from "react-native";
import { useThemeConfig } from "@/hooks/useTheme";
interface Props extends PropsWithChildren {
    style?: StyleProp<ViewStyle>;
}

/**
 * @param children 子组件,显示在模糊层上方的实际内容
 * @returns jsx 组件
 */
const BlurContainer: FC<Props> = ({
    children, style,
}) => {
    const { tabbarBlurType } = useThemeConfig();
    return (
        <BlurView
            intensity={60}
            style={[{ flex: 1 }, style]}
            blurReductionFactor={4}
            tint={tabbarBlurType}
            experimentalBlurMethod="dimezisBlurView"
        >
            {children}
        </BlurView>
    );
};
export default BlurContainer;

让tint跟随主题切换,而且expo-blur没有强制overflow:'hidden',这在tabbar上的播放器控制栏上露头的图片布局非常有利:

Snipaste_2026-02-14_19-11-25.png

下面是没有开启experimentalBlurMethod="dimezisBlurView"的效果:

Snipaste_2026-02-14_19-14-54.png

下图是在亮模式下blur导致的异常阴影效果问题:

Snipaste_2026-02-14_19-28-56.png

这个问题应该调整intensity的值低一点应该会改善,在暗模式下不是很明显。[项目地址](expo rn: expo创建的react native的音乐播放器应用,专注视频转歌和本地歌曲播放)欢迎讨论交流!

写给年轻程序员的几点小建议

2026年2月14日 23:07

本人快 40 岁了。第一份工作是做网站编辑,那时候开始接触 jQuery,后来转做前端,一直做到现在。说实话,我对写程序谈不上特别热爱,所以技术水平一般。

年轻的时候如果做得不开心,就会直接裸辞。不过每次裸辞的那段时间,我都会拼命学习,这对我的成长帮助其实很大。

下面给年轻人几点个人建议,供参考:

  • 不要被网上“35 岁就失业”的说法吓到。很多人是在贩卖焦虑。我都快 40 了还能拿到 offer,只是这些 offer 薪资不到 30K。
  • 基础真的很重要。我靠着基础吃香了十几年,在公司里也解决过不少疑难问题。就算现在有 AI,你也要有能力判断它写得对不对,还要知道如何向 AI 提问。
  • 适不适合做程序员,其实几年之后就能看出来:你能不能当上 Leader,或者至少能不能独当一面。如果你觉得自己确实不太适合,可以趁早考虑转行,或者下班后发展一些副业。大千世界,行行出状元,能赚钱的行业很多,不必只盯着程序员这一条路。
  • 如果你觉得自己资质一般,但又真的喜欢写程序,那也没关系。《刻意练习》里提到,一个人能不能成为行业顶尖,关键在于后天练习的方式,而不是天赋本身。
  • 程序员做到后面,最大的挑战其实是身体机能,而不是技术。一定要多锻炼身体。在还没有小孩之前,尽量把自己的技术水平拉到一个相对高的位置。结婚有家庭之后,学习时间会明显减少,而且年龄增长、抗压能力下降,而程序员本身又是高度用脑的职业。如果你的技术储备够高,就能在一定程度上缓冲项目压力,让自己工作更从容。
  • React、Vue、Angular 等框架都可以尝试做做项目。不同框架背后的设计思路,对思维成长很有帮助。前端很多理念本身就借鉴了后端的逻辑,多接触不同体系,会让你看问题更立体。
  • 可以在 GitHub 上做一些开源小项目。素材从哪里来?其实就来自你在公司做过的项目。把其中一块通用能力抽出来,沉淀成一个独立组件或工具库,再整理发布到 GitHub。与此同时,多写一些技术文章进行总结和输出。等到找工作时,简历里可以写上类似“全网阅读量几万+”这样的成果展示,这些都会成为你的加分项,让你在竞争中更有优势。
  • 35 岁以上,竞争力通常体现在两个方向:要么技术水平足够强,能够解决复杂问题;要么具备一定的管理能力,能够带团队。有人说那我以前就带过一两个徒弟,怎么办,那你得学会包装,哈哈。
  • 35岁以上,面试对技术广度要求更高,所以不要太深入挖掘某一项技术了。
  • 打工人比较麻烦的事就是简历太 "花"。频繁跳槽,在一个公司没干几个月就走,或者长期待业太久。如果岗位需要背调,简历造假会很麻烦,虽然有些小公司或外包公司不做背调。所以这方面自己要想想办法,你懂得。
  • 另外要认清一个现实:单纯打工,很难真正发财。这件事越早想明白越好。多读一些关于认知、资产配置的书,弄清楚什么是资产,什么是消费。哪怕这些认知在你有生之年未必能带来巨大财富,也可以传递给下一代,让他们少走弯路。

以上只是个人经历和感受,不一定适用于所有人,但希望能给年轻的你一些参考。

昨天 — 2026年2月14日掘金 前端

React 闭包陷阱深度解析:从词法作用域到快照渲染

作者 NEXT06
2026年2月14日 21:59

在 React 函数式组件的开发过程中,开发者常会遭遇一种“幽灵般”的状态异常:页面 UI 已经正确响应并更新了最新的状态值,但在 setInterval 定时器、useEffect 异步回调或原生事件监听器中,打印出的变量却始终停滞在初始值。

这种现象通常被误认为是 React 的 Bug,但其本质是 JavaScript 语言核心机制——词法作用域(Lexical Scoping)与 React 函数式组件渲染特性发生冲突的产物。在社区中,这被称为“闭包陷阱”(Stale Closure)或“过期的闭包”。

本文将摒弃表象,从内存模型与执行上下文的角度,剖析这一问题的成因及标准解决方案。

核心原理:陷阱是如何形成的

要理解闭包陷阱,必须首先理解两个核心的前置概念:JavaScript 的词法作用域与 React 的快照渲染。

1. JavaScript 的词法作用域 (Lexical Scoping)

JavaScript 中的函数在定义时,其作用域链就已经确定了。闭包是指函数可以访问其定义时所在作用域中的变量。关键在于:闭包捕获的是函数创建那一刻的变量引用。如果该变量在后续没有发生引用地址的变更(如 const 声明的原始类型),闭包内访问的永远是创建时的那个值。

2. React 的快照渲染 (Rendering Snapshots)

React 函数组件的每一次渲染(Render),本质上都是一次独立的函数调用。

  • Render 1:React 调用 Component 函数,创建了一组全新的局部变量(包括 props 和 state)。
  • Render 2:React 再次调用 Component 函数,创建了另一组全新的局部变量。

虽然两次渲染中的变量名相同(例如都叫 count),但在内存中它们是完全不同、互不干扰的独立副本。每次渲染都像是一张“快照”,固定了当时的数据状态。

3. 致命结合:持久化闭包与过期快照

当我们将 useEffect 的依赖数组设置为空 [] 时,意味着该 Effect 只在组件挂载(Mount)时执行一次。

  1. Mount (Render 1) :count 初始化为 0。useEffect 执行,创建一个定时器回调函数。该回调函数通过闭包捕获了 Render 1 作用域中的 count (0)。
  2. Update (Render 2) :状态更新,count 变为 1。React 再次调用组件函数,产生了一个新的 count 变量 (1)。
  3. Conflict:由于依赖数组为空,useEffect 没有重新运行。内存中运行的依然是 Render 1 时创建的那个回调函数。该函数依然持有 Render 1 作用域的引用,因此它看到的永远是 count: 0。

代码实战与剖析

以下是一个经典的闭包陷阱反面教材。请注意代码注释中的内存快照分析。

JavaScript

import { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 闭包陷阱发生地
    const timer = setInterval(() => {
      // 这里的箭头函数在 Render 1 时被定义
      // 根据词法作用域,它捕获了 Render 1 上下文中的 count 常量
      // Render 1 的 count 值为 0
      console.log('Current Count:', count); 
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 依赖数组为空,导致 effect 不会随组件更新而重建

  return (
    <div>
      <p>UI Count: {count}</p>
      {/* 点击按钮触发重渲染 (Render 2, 3...) */}
      <button onClick={() => setCount(count + 1)}>Add</button>
    </div>
  );
}

内存行为分析:

  • Render 1: count (内存地址 A) = 0。setInterval 创建闭包,引用地址 A。
  • User Click: 触发更新。
  • Render 2: count (内存地址 B) = 1。组件函数重新执行,创建了新变量。
  • Result: 此时 UI 渲染使用的是地址 B 的数据,但后台运行的定时器依然死死抓住地址 A 不放。

解决方案:逃离陷阱的三个层级

针对不同场景,我们有三种标准的架构方案来解决此问题。

方案一:规范依赖 (The Standard Way)

遵循 React Hooks 的设计规范,诚实地将所有外部依赖填入依赖数组。

JavaScript

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current Count:', count);
  }, 1000);

  return () => clearInterval(timer);
}, [count]); //  将 count 加入依赖
  • 原理:每当 count 变化,React 会先执行清除函数(clearInterval),然后重新运行 Effect。这将创建一个新的定时器回调,新回调捕获的是当前最新渲染作用域中的 count。
  • 代价:定时器会被频繁销毁和重建。如果计时精度要求极高,这种重置可能会导致时间偏差。

方案二:函数式更新 (The Functional Way)

如果逻辑仅仅是基于旧状态更新新状态,而不需要在副作用中读取状态值,可以使用 setState 的函数式更新。

JavaScript

useEffect(() => {
  const timer = setInterval(() => {
    //  这里的 c 是 React 内部传入的最新 state,不依赖闭包中的 count
    setCount(prevCount => prevCount + 1);
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依赖依然为空,但逻辑正确
  • 原理:React 允许将回调函数传递给 setter。执行时,React 内部会将最新的 State 注入该回调。这种方式绕过了当前闭包作用域的限制,直接操作 React 的状态队列。

方案三:Ref 引用 (The Ref Way)

如果必须在 useEffect 中读取最新状态,且不希望重启定时器,useRef 是最佳逃生舱。

JavaScript

const [count, setCount] = useState(0);
const countRef = useRef(count);

// 同步 Ref:每次渲染都更新 ref.current
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    //  访问 ref.current。
    // ref 对象在组件生命周期内引用地址不变,但其 current 属性是可变的。
    // 闭包捕获的是 ref 对象的引用,因此总能读到最新的 current 值。
    console.log('Current Count:', countRef.current);
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依赖为空,且定时器不会重启
  • 原理:useRef 创建了一个可变的容器。闭包虽然被锁死在首次渲染,但它锁死的是这个“容器”的引用。容器内部的内容(current)是随渲染实时更新的,从而实现了“穿透”闭包读取最新数据。

总结

React 闭包陷阱的本质,是持久化的闭包引用了过期的快照变量

这并非框架设计的缺陷,而是函数式编程模型与 JavaScript 语言特性的必然交汇点。作为架构师,在处理此类问题时应遵循以下建议:

  1. 诚实对待依赖数组:绝大多数闭包问题源于试图欺骗 React,省略依赖项。ESLint 的 react-hooks/exhaustive-deps 规则应当被严格遵守。
  2. 理解引用的本质:清楚区分什么是不可变的快照(State/Props),什么是可变的容器(Ref)。在跨渲染周期的副作用中共享数据,Ref 是唯一的桥梁。

useMemo 与 useCallback 的原理与最佳实践

作者 NEXT06
2026年2月14日 21:36

在 React 的组件化架构中,性能优化往往不是一项大刀阔斧的重构工程,而是体现在对每一次渲染周期的精准控制上。作为一名拥有多年实战经验的前端架构师,我见证了无数应用因为忽视了 React 的渲染机制,导致随着业务迭代,页面交互变得愈发迟缓。

本文将深入探讨 React Hooks 中的两个关键性能优化工具:useMemo 和 useCallback。我们将透过现象看本质,理解它们如何解决“全量渲染”的痛点,并剖析实际开发中容易忽视的闭包陷阱。

引言:React 的渲染痛点与“摩天大楼”困境

想象一下,你正在建造一座摩天大楼(你的 React 应用)。每当大楼里的某一个房间(组件)需要重新装修(更新状态)时,整个大楼的施工队都要停下来,把整栋楼从地基到顶层重新刷一遍油漆。这听起来极度荒谬且低效,但在 React 默认的渲染行为中,这往往就是现实。

React 的核心机制是“响应式”的:当父组件的状态发生变化触发更新时,React 会默认递归地重新渲染该组件下的所有子组件。这种“全量渲染”策略保证了 UI 与数据的高度一致性,但在复杂应用中,它带来了不可忽视的性能开销:

  1. 昂贵的计算重复执行:与视图无关的复杂逻辑被反复计算。
  2. DOM Diff 工作量激增:虽然 Virtual DOM 很快,但构建和对比庞大的组件树依然消耗主线程资源。

性能优化的核心理念在于**“惰性”“稳定”**:只在必要时进行计算,只在依赖变化时触发重绘。


第一部分:useMemo —— 计算结果的缓存(值维度的优化)

核心定义

useMemo 可以被视为 React 中的 computed 计算属性。它的本质是“记忆化”(Memoization):在组件渲染期间,缓存昂贵计算的返回值。只有当依赖项发生变化时,才会重新执行计算函数的逻辑。

场景与反例解析

让我们看一个典型的性能瓶颈场景。假设我们有一个包含大量数据的列表,需要根据关键词过滤,同时组件内还有一个与列表无关的计数器 count。

未优化的代码(性能痛点)

JavaScript

import { useState } from 'react';

// 模拟昂贵的计算函数
function slowSum(n) {
  console.log('执行昂贵计算...');
  let sum = 0;
  // 模拟千万级循环,阻塞主线程
  for(let i = 0; i < n * 10000000; i++) {
    sum += i;
  }
  return sum;
}

export default function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const [num, setNum] = useState(10);
  const list = ['apple', 'banana', 'orange', 'pear']; // 假设这是个大数组

  // 痛点 1:每次 App 渲染(如点击 count+1),filter 都会重新执行
  // 即使 keyword 根本没变
  const filterList = list.filter(item => {
    console.log('列表过滤执行');
    return item.includes(keyword);
  });
  
  // 痛点 2:每次 App 渲染,slowSum 都会重新运行
  // 导致点击 count 按钮时页面出现明显卡顿
  const result = slowSum(num);

  return (
    <div>
      <p>计算结果: {result}</p>
      {/* 输入框更新 keyword */}
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      
      {/* 仅仅是更新计数器,却触发了上面的重计算 */}
      <button onClick={() => setCount(count + 1)}>Count + 1 ({count})</button>
      
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

在上述代码中,仅仅是为了更新 UI 上的 count 数字,主线程却被迫去执行千万次的循环和数组过滤,这是极大的资源浪费。

优化后的代码

利用 useMemo,我们可以将计算逻辑包裹起来,使其具备“惰性”。

JavaScript

import { useState, useMemo } from 'react';

// ... slowSum 函数保持不变

export default function App() {
  // ... 状态定义保持不变

  // 优化 1:依赖为 [keyword],只有关键词变化时才重算列表
  const filterList = useMemo(() => {
    console.log('列表过滤执行');
    return list.filter(item => item.includes(keyword));
  }, [keyword]);
  
  // 优化 2:依赖为 [num],点击 count 不会触发此处的昂贵计算
  const result = useMemo(() => {
    return slowSum(num);
  }, [num]);

  return (
    // ... JSX 保持不变
  );
}

底层解析

useMemo 利用了 React Fiber 节点的内部存储(memoizedState)。在渲染过程中,React 会取出上次存储的 [value, deps],并将当前的 deps 与上次的进行浅比较(Shallow Compare)。

  • 如果依赖项完全一致,直接返回存储的 value,跳过函数执行。
  • 如果依赖项发生变化,执行函数,更新缓存。

第二部分:useCallback —— 函数引用的稳定(引用维度的优化)

核心定义

useCallback 用于缓存“函数实例本身”。它的作用不是为了减少函数创建的开销(JS 创建函数的开销极小),而是为了保持函数引用地址的稳定性,从而避免下游子组件因为 props 变化而进行无效重渲染。

痛点:引用一致性问题

在 JavaScript 中,函数是引用类型,且 函数 === 对象。
在 React 函数组件中,每次重新渲染(Re-render)都会重新执行组件函数体。这意味着,定义在组件内部的函数(如事件回调)每次都会被重新创建,生成一个新的内存地址。

比喻:咖啡店点单

为了理解这个概念,我们可以通过“咖啡店点单”来比喻:

  • 未优化的情况:你每次去咖啡店点单,都派一个替身去。虽然替身说的台词一模一样(“一杯拿铁,加燕麦奶”),但对于店员(子组件)来说,每次来的都是一个陌生人。店员必须重新确认身份、重新建立订单记录。这就是子组件因为函数引用变化而被迫重绘。
  • 使用 useCallback:你本人亲自去点单。店员一看:“还是你啊,老样子?”于是直接复用之前的订单记录,省去了沟通成本。这就是引用稳定带来的性能收益。

实战演示:父子组件的协作

失效的优化(反面教材)

JavaScript

import { useState, memo } from 'react';

// 子组件使用了 memo,理论上 Props 不变就不应该重绘
const Child = memo(({ handleClick }) => {
  console.log('子组件发生渲染'); // 目标:不希望看到这行日志
  return <button onClick={handleClick}>点击子组件</button>;
});

export default function App() {
  const [count, setCount] = useState(0);

  // 问题所在:
  // 每次 App 渲染(点击 count+1),handleClick 都会被重新定义
  // 生成一个新的函数引用地址 (fn1 !== fn2)
  const handleClick = () => {
    console.log('子组件被点击');
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>父组件 Count + 1</button>
      
      {/* 
        虽然 Child 加了 memo,但 props.handleClick 每次都变了
        导致 Child 认为 props 已更新,强制重绘
      */}
      <Child handleClick={handleClick} />
    </div>
  );
}

正确的优化

我们需要使用 useCallback 锁定函数的引用,并配合 React.memo 使用。

JavaScript

import { useState, useCallback, memo } from 'react';

const Child = memo(({ handleClick }) => {
  console.log('子组件发生渲染'); 
  return <button onClick={handleClick}>点击子组件</button>;
});

export default function App() {
  const [count, setCount] = useState(0);

  // 优化:依赖项为空数组 [],表示该函数引用永远不会改变
  // 无论 App 渲染多少次,handleClick 始终指向同一个内存地址
  const handleClick = useCallback(() => {
    console.log('子组件被点击');
  }, []); 

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>父组件 Count + 1</button>
      
      {/* 
        现在:
        1. handleClick 引用没变
        2. Child 组件检测到 props 未变
        3. 跳过渲染 -> 性能提升
      */}
      <Child handleClick={handleClick} />
    </div>
  );
}

关键结论

useCallback 必须配合 React.memo 使用
如果在没有 React.memo 包裹的子组件上使用 useCallback,不仅无法带来性能提升,反而因为增加了额外的 Hooks 调用和依赖数组对比,导致性能变为负优化。


第三部分:避坑指南 —— 闭包陷阱与依赖项管理

在使用 Hooks 进行优化时,开发者常遇到“数据不更新”的诡异现象,这通常被称为“陈旧闭包”(Stale Closures)。

闭包陷阱的概念

Hooks 中的函数会捕获其定义时的作用域状态。如果依赖项数组没有正确声明,Memoized 的函数就会像一个“时间胶囊”,永远封存了旧的变量值,无法感知外部状态的更新。

典型场景与解决方案

场景:定时器或事件监听

假设我们希望在 useEffect 或 useCallback 中打印最新的 count。

JavaScript

// 错误示范
useEffect(() => {
  const timer = setInterval(() => {
    // 陷阱:这里的 count 永远是初始值 0
    // 因为依赖数组为空,闭包只在第一次渲染时创建,捕获了当时的 count
    console.log('Current count:', count); 
  }, 1000);
  return () => clearInterval(timer);
}, []); // ❌ 依赖项缺失

解决方案

  1. 诚实地填写依赖项(不推荐用于定时器):
    将 [count] 加入依赖。但这会导致定时器在每次 count 变化时被清除并重新设定,违背了初衷。

  2. 函数式更新(推荐):
    如果只是为了设置状态,使用 setState 的回调形式。

    JavaScript

    //  不需要依赖 count 也能实现累加
    setCount(prevCount => prevCount + 1);
    
  3. 使用 useRef 逃生舱(推荐用于读取值):
    useRef 返回的 ref 对象在组件整个生命周期内保持引用不变,且 current 属性是可变的。

    codeJavaScript

    const countRef = useRef(count);
    
    // 每次渲染更新 ref.current
    useEffect(() => {
      countRef.current = count;
    });
    
    useEffect(() => {
      const timer = setInterval(() => {
        //  总是读取到最新的值,且不需要重建定时器
        console.log('Current count:', countRef.current);
      }, 1000);
      return () => clearInterval(timer);
    }, []); // 依赖保持为空
    

总结:三兄弟的协作与克制

在 React 性能优化的工具箱中,我们必须清晰区分这“三兄弟”的职责:

  1. useMemo缓存值。用于节省 CPU 密集型计算的开销。
  2. useCallback缓存函数。用于维持引用稳定性,防止下游组件无效渲染。
  3. React.memo缓存组件。用于拦截 Props 对比,作为重绘的最后一道防线。

架构师的建议:保持克制

性能优化并非免费午餐。useMemo 和 useCallback 本身也有内存占用和依赖对比的计算开销。

请遵循以下原则:

  • 不要预先优化:不要默认给所有函数套上 useCallback。
  • 不要优化轻量逻辑:对于简单的 a + b 或原生 DOM 事件(如 
    ),原生 JS 的执行速度远快于 Hooks 的开销。
  • 先定位,后治理:使用 React DevTools Profiler 找出真正耗时的组件,再针对性地使用上述工具进行“外科手术式”的优化。

掌握了这些原理与最佳实践,你便不再是盲目地编写 Hooks,而是能够像架构师一样,精准控制应用的每一次渲染脉搏。

html翻页时钟 效果

作者 大时光
2026年2月14日 17:47
<!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:组成即将翻出的新数字,但初始时 .card2rotateX(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)

步骤分解:

  1. 对比新旧值:如果相同,不更新(避免无谓动画)。
  2. 重写整个容器的 HTML
    • 下层(新值).card1.card2 显示 nextValue
    • 上层(旧值).card3.card4 显示 curValue
  3. 触发动画
    dom.classList.remove('flip');
    void dom.offsetWidth; // 强制浏览器重排(关键!)
    dom.classList.add('flip');
    
    • 先移除 .flip,再强制重排(flush styles),再加回 .flip,确保动画重新触发。
  4. 更新 data-number 保存当前值。

时间更新:

  • 每秒调用 updateClock(),获取当前时分秒(两位数格式)。
  • 分别调用 setHTML 更新三个容器。

🌟 四、为什么能实现“翻页”错觉?

元素 初始状态 翻页后状态 视觉作用
.card3 显示旧数字上半 向上翻转 180° 隐藏 模拟“翻走”的上半页
.card2 旧数字下半(翻转180°藏起) 转正显示新数字下半 模拟“翻出”的下半页
.card1 / .card4 静态背景 不变 提供视觉连续性

💡 关键技巧

  • 利用 两个完整数字(新+旧)叠加,通过控制上下半部分的旋转,制造“翻页”而非“淡入淡出”。
  • 强制重排(offsetWidth 是确保 CSS 动画每次都能重新触发的经典 hack。

✅ 总结

这个 Flip Clock 的精妙之处在于:

  1. 结构设计:4 个卡片分工明确,上下层分离。
  2. CSS 3D:利用 rotateX + transform-origin 实现真实翻页。
  3. JS 控制:动态替换内容 + 巧妙触发动画。
  4. 性能优化:仅在值变化时更新,避免无效渲染。

这是一种典型的 “用 2D DOM 模拟 3D 物理效果” 的前端动画范例,既高效又视觉惊艳。

js 封装 动画效果

作者 大时光
2026年2月14日 17:42
/**
 * 通用动画函数
 * @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) => {}
}) => { ... }

亮点分析:

  • 双模式驱动
    1. 时长优先:如果你提供了 duration,动画将严格在指定时间内完成。
    2. 速度优先:如果你未提供 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 动画实现。它展示了如何通过:

  1. 参数解构与默认值 来提升 API 易用性。
  2. 防御性编程 来处理无效输入。
  3. 时间轴插值算法 来保证动画平滑度。
  4. 闭包与高阶函数 来管理状态和副作用。

掌握了这个函数的实现,你就掌握了前端动画引擎的基石。

gsap 配置解读 --7

作者 大时光
2026年2月14日 16:28

什么是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. 封装复杂动画逻辑

把重复的 fromTotimeline 等逻辑打包成一个“黑盒”。

2. 支持参数配置

通过 config 对象传入自定义参数(如 durationstagger)。

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(...)
参数处理需自己写 ✅ 自动合并 configdefaults
团队协作需文档说明 ✅ 效果名称即文档(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 等插件,需确保已注册。

📚 官方文档:

👉 greensock.com/docs/v3/GSA…


✅ 总结:

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>

ScrollTriggerGSAP(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 被固定住
  }
});
用户体验流程:
  1. 向下滚动,当 #pin 到达屏幕中央时 →
  2. #pin 被固定在当前位置(不再随页面滚动而移动);
  3. 继续滚动的 400px 过程中,#pin 逐渐缩小并旋转scale: 0.8, rotation: 10);
  4. 滚动超过 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: stickyfixed,无需手动写 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)
});

⚠️ 注意事项:

  • toggleClassScrollTrigger内置功能,无需额外插件;
  • 类名切换是双向的:进入区间加类,离开区间去类;
  • 如果 startend 顺序颠倒(如 start: "top 40%", end: "top 70%"),则行为反转(常用于“离开时激活”);
  • 移动端兼容性良好,支持触摸滚动。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

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()

🔍 作用:

获取当前动画目标属性的瞬时速度(单位/秒)。

对于多属性动画(如同时动 xy),默认返回第一个属性的速度;也可指定属性:

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,无需额外插件。

📚 官方文档:


✅ 总结:

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.random
  • gsap.utils.wrap
  • gsap.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)计算 startend 之间的中间值。

📌 语法:
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]

📚 官方文档:

👉 greensock.com/docs/v3/GSA…


✅ 最终总结:

gsap.utils.randomwrapinterpolate 是 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) 中,timeScaleyoyoEase 是两个用于精细控制动画播放行为的强大特性:


✅ 一、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 让你像“调速旋钮”一样控制动画节奏,而 yoyoEase 则赋予往返动画“去程与回程不同性格”的能力——两者结合,可构建出既灵活又富有表现力的交互动效,是 GSAP 高级动画控制的标志性特性。

做了个 EPUB 阅读器,被「阅读进度同步」折磨了一周,总结 4 个血泪教训

作者 PTC
2026年2月13日 14:22

你做过"打开一本书,自动回到上次阅读位置"这个功能吗?

听起来很简单对吧——存个页码,下次打开翻过去就行。我一开始也是这么想的,直到在 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% 的坑。

LeetCode 25. K个一组翻转链表:两种解法详解+避坑指南

作者 Wect
2026年2月14日 15:04

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 为例)

  1. 初始状态:dummy(0)->1->2->3->4,preGroup=dummy,prev=dummy,curr=1,count=0;

  2. 遍历curr=1:count≠2,翻转1(1.next=prev=dummy),prev=1,curr=2,count=1;

  3. 遍历curr=2:count≠2,翻转2(2.next=prev=1),prev=2,curr=3,count=2;

  4. 凑够k=2个节点:调整组间连接——preGroup.next=prev=2(dummy->2),原组头lastNode=1,1.next=curr=3(2->1->3);更新preGroup=1,prev=1,count=0;

  5. 继续遍历curr=3:重复步骤2-3,翻转3、4,凑够k=2个节点,调整连接(1->4,3.next=null);

  6. 循环结束,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 为例)

  1. 初始状态:dummy(0)->1->2->3->4,preGroup=dummy;

  2. 找第一组边界:groupTail从preGroup开始移动2次,找到groupTail=2(确认够k个节点);记录groupHead=1,nextGroupHead=3;

  3. 单独翻转当前组(1->2):prev初始化为nextGroupHead=3,curr=groupHead=1;循环翻转,直到curr=nextGroupHead,翻转后变成2->1;

  4. 连接组间:preGroup.next=groupTail=2(dummy->2),preGroup更新为groupHead=1(下一组的前置节点);

  5. 找第二组边界:groupTail从preGroup=1移动2次,找到groupTail=4;记录groupHead=3,nextGroupHead=null;

  6. 单独翻转当前组(3->4),连接组间;

  7. 下一次找组边界:移动不足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题的核心是「组内翻转+组间连接」,两种解法的本质都是通过指针操作实现,但思路的高效性有差异。

无论哪种解法,都要记住三个核心要点:① 用虚拟头节点简化头节点处理;② 明确每组的边界(头、尾、下一组头);③ 翻转时避免链表环和空指针。

刷题不是背代码,而是理解思路、掌握技巧。建议大家多调试、多画图,熟练掌握指针操作,下次遇到类似的链表翻转题(比如两两翻转、指定区间翻转),就能举一反三、轻松应对!

Claude Code 作者再次分享 Anthropic 内部团队使用技巧

作者 Immerse
2026年2月14日 14:52

大家好,我是 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

作者 大时光
2026年2月14日 14:52

什么是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>

ScrollToPluginGSAP(GreenSock Animation Platform) 提供的一个轻量级但非常实用的插件,用于实现 平滑、可控的页面滚动动画——无论是滚动到页面某个元素、指定坐标,还是精确控制滚动行为。


📌 简单定义:

ScrollToPlugin 让你用 GSAP 的动画语法(如 durationease)驱动浏览器窗口或任意可滚动容器,平滑滚动到目标位置。

它解决了原生 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

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

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>

SplitTextGSAP(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 不变,搜索引擎仍能读取完整文本。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

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>

TextPluginGSAP(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 的标准免费插件(无需会员)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

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>

RoughEaseSlowMoExpoScaleEaseGSAP(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);
  • 它会将动画值从 startValueendValue指数曲线映射。
  • 通常配合 scaleopacity 或自定义属性使用。

🎯 虽然用于 y,但效果仍体现非线性:

  • 小球 C 的下落速度先快后慢(或反之,取决于范围),但变化是非线性的,比 power2 更“陡峭”。

💡 更典型用法:

gsap.to(circle, {
  scale: 5,
  ease: ExpoScaleEase.config(1, 5) // 从 1 倍到 5 倍的指数缩放
});

💡 适合:放大镜效果、爆炸扩散、雷达扫描、声波可视化。


🆚 对比总结:

缓动类型 视觉特点 典型用途
RoughEase 随机抖动、不规则 故障风、手绘感、震动反馈
SlowMo 中间慢、两头快 强调关键帧、优雅停顿
ExpoScaleEase 指数级加速/减速 缩放、对数感知属性、物理模拟

⚠️ 注意事项:

  • 这些缓动都来自 EasePack,需单独引入(如你代码中已做);
  • 它们可以像普通 ease 一样用在任何 GSAP 动画中;
  • 结合 Timeline 可创建复杂节奏组合。

📚 官方文档:


✅ 总结:

RoughEaseSlowMoExpoScaleEase 是 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>

CustomEaseGSAP(GreenSock Animation Platform) 提供的一个强大插件,允许你通过自定义贝塞尔曲线(cubic-bezier)来创建完全个性化的缓动函数(easing function),从而精确控制动画的速度变化节奏。


📌 简单定义:

CustomEase 让你像在 CSS 或设计工具中那样,用 4 个控制点定义一条缓动曲线,并将其注册为可复用的 GSAP 缓动名称,用于任何动画。

它打破了内置缓动(如 power2.inOutelastic)的限制,实现电影级、品牌专属或物理拟真的运动节奏


✅ 核心原理:贝塞尔曲线

缓动曲线本质是一条 三次贝塞尔曲线(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 很低),然后突然减速并平稳结束,形成一种“急冲后刹车”的效果。


🔧 使用步骤:

  1. 引入插件

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
    
  2. 注册自定义缓动

    CustomEase.create("myEase", "0.25,0.1,0.25,1");
    
    • 第一个参数:缓动名称(字符串,如 "myEase"
    • 第二个参数:贝塞尔控制点(4 个 0~1 之间的数字,用逗号分隔)
  3. 在动画中使用

    gsap.to(block, {
      x: 470,
      duration: 1.4,
      ease: "myEase" // 直接使用注册的名称
    });
    

🌟 优势 vs 其他方式:

方式 灵活性 可视化 复用性 性能
CSS cubic-bezier() ✅(开发者工具) ❌(需重复写)
手动计算进度 ⚠️
CustomEase ✅✅✅ ✅(配合 GSAP 工具) ✅✅✅(全局注册) ✅✅(预计算优化)

💡 CustomEase预计算并缓存曲线数据,运行时性能极高,适合复杂动画。


🛠️ 如何获取贝塞尔值?

  1. 使用 GSAP 官方工具(推荐!)
    👉 GSAP Ease Visualizer

    • 拖动控制点实时预览动画
    • 自动生成 CustomEase 代码
  2. 从 CSS 复制
    如果你在 CSS 中写了:

    transition: all 1s cubic-bezier(0.25, 0.1, 0.25, 1);
    

    那么值就是 "0.25,0.1,0.25,1"

  3. 从 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 会员);
  • 若需更复杂曲线(如多段),可结合 CustomWiggleCustomBounce(它们依赖 CustomEase)。

📚 官方资源:


✅ 总结:

CustomEase 是 GSAP 中实现“精准运动设计”的终极工具——它把缓动从“选择预设”升级为“自由创作”,让开发者和设计师能用同一套语言定义品牌专属的动画节奏,是打造高端用户体验的核心技术之一。

React 性能优化双子星:深入、全面解析 useMemo 与 useCallback

作者 AAA阿giao
2026年2月14日 14:51

引言

在现代 React 应用开发中,随着组件逻辑日益复杂、状态管理愈发庞大,性能问题逐渐成为开发者绕不开的话题。幸运的是,React 提供了两个强大而精巧的 Hooks —— useMemouseCallback,它们如同“缓存魔法”,帮助我们在不牺牲可读性的前提下,显著提升应用性能。

本文将结合完整代码示例,逐行解析、对比说明、深入原理,带你彻底掌握 useMemouseCallback 的使用场景、工作机制、常见误区以及最佳实践。文章内容力求全面、准确、生动有趣,并严格保留原始代码一字不变,确保你既能理解理论,又能直接复用实战。


一、为什么需要 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 失效 → 子组件重渲染!

即使你只改了 numChild 也会重渲染,尽管它只关心 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 重渲染(因为 counthandleClick 都变了);
  • 如果 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(闭包陷阱)。

✅ 正确做法:所有外部变量都必须出现在依赖数组中

✅ 最佳实践

  1. 先写逻辑,再优化:不要过早优化。
  2. 配合 React DevTools Profiler:定位真实性能瓶颈。
  3. useMemo 用于值,useCallback 用于函数
  4. 依赖项要完整且精确:使用 ESLint 插件 eslint-plugin-react-hooks 自动检查。

六、总结:性能优化的哲学

useMemouseCallback 并非银弹,而是 React 赋予我们的精细控制权。它们让我们能够:

  • 隔离变化:让无关状态的更新不影响其他部分;
  • 减少冗余:避免重复计算和渲染;
  • 提升用户体验:使应用更流畅、响应更快。

正:

“count 和 keyword 不相关”
“某一个数据改变,只想让相关的子组件重新渲染”

这正是 React 性能优化的核心思想:局部更新,全局协调


附:完整代码地址

源码地址:react/memo/memo/src/App.jsx · Zou/lesson_zp - 码云 - 开源中国

🎉 掌握 useMemouseCallback,你已经迈入 React 性能优化的高手之列!
下次遇到“为什么子组件总在乱渲染?”或“计算太慢怎么办?”,你就知道答案了。

Happy coding! 🚀

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2025详细解读

作者 shadowingszy
2026年2月14日 14:37

往期文章:

【前端趋势调查系列】带你看看前端生态圈的技术趋势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% ,已经成为事实上的标准,这说明开发者在处理 nullundefined 时,迫切需要一种比 || 更严谨、更明确的工具来避免将 0false 等有效值意外覆盖,在日常开发中,我们应当优先使用 ?? 来处理默认值赋值,以增强代码的稳定性和可预测性。
  • 动态导入( Dynamic Import 66% 的使用率紧随其后,反映出代码分割和按需加载已是现代 Web 应用性能优化的核心实践,在构建大型应用、特别是需要考虑首屏加载速度的场景时,动态导入几乎是必修课。
  • 类私有字段( Private Fields 逻辑赋值 运算符 Logical Assignment 的使用率分别为 43%35% ,表明封装和代码简写同样是开发者追求的目标,尤其是私有字段,为在团队协作中保护内部状态、减少意外修改提供了语言层面的保障。

Array、Set、Object的特性

今年对 ArraySetObject 数据结构的新特性调查,揭示了不可变性(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-fnsDay.js 等第三方库来填补标准库短板。
  • 同时,ESM CJS 的兼容问题标准库整体匮乏 等历史包袱也依然是工程实践中的绊脚石,这些痛点共同构成了“JS 好用但不够省心”的真实写照。

浏览器的痛点

当我们把视线从语言本身转向其运行环境——浏览器时,痛点显得更具工程现实感:

  • 跨浏览器支持(Browser support)31% 的提及率稳居首位,说明即便现代浏览器在标准实现上趋于一致,边缘行为差异、新特性落地节奏和兼容性策略仍是困扰前端团队的主要问题。
  • 浏览器测试(Browser testing)13% 位列第二,本质上是跨浏览器差异在测试和回归成本上的放大反馈
  • 而被单独点名的 Safari7% 成为第三大痛点,很多团队已经默认把它视作“新时代的 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%
  • AstroSvelteKit 则是近年最值得关注的两颗新星,前者在内容密集型站点中大放异彩,后者与 Svelte 深度绑定,为全栈应用提供了端到端的极致体验。

后端框架

在 Node.js 后端框架领域,我们不难看出,还是有些新面孔:

  • 老牌选手 Express 仍以 80%+ 的使用率稳居第一,作为“薄核心 + 丰富中间件”的事实标准难以被完全替代,但 81% 的满意度也表明开发者正在寻找更现代的方案
  • tRPC 是过去两年最耀眼的新星,通过直接在 TypeScript 中实现端到端类型安全调用,大幅简化了前后端联调与接口演进的成本。

测试框架

JavaScript 测试生态正在经历一场“现代化重构”:

  • 在单元与集成测试层面,Jest75% 的使用率独占鳌头。
  • 端到端测试领域则被 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 为优先设计的校验库 Zod48% 的使用率登顶,成为“运行时数据校验 + 类型推导”领域的绝对主角,反映出大家在 API 返回、表单输入等链路上,对类型与数据一致性的强烈诉求。
  • 传统工具库 Lodash (39%) 依然宝刀不老,仍在大量项目中承担通用数据处理职责。
  • 而在日期处理上,date-fns (39%)Moment (25%)Day.js (24%) 等多家共存,本质上是对 JS 原生日期能力长期缺位的弥补
  • 即便是已经被视作“老古董”的 jQuery (16%) ,也仍凭借海量遗留项目保持着不可忽视的存在感。

AI使用情况

AI 工具已经深度嵌入前端开发者的日常工作流,成为新的基础设施:

  • ChatGPT60% 的使用率位居首位,承担了问答、代码草稿生成、调试思路辅助等“外脑”角色。
  • 深度集成 IDE 的 GitHub Copilot 使用率也达 51% ,更偏向于在写代码时提供上下文感知补全与重构建议,两者形成“离线思考 + 在线自动补全”的互补关系
  • 与此同时,Claude (44%)Google Gemini (28%) 等通用大模型产品也在快速补位,说明开发者愿意多源头对比体验
  • 值得注意的是 AI-native 编辑器 Cursor 已有 26% 的使用率,一部分人开始直接迁移到“以 AI 为核心交互对象”的编辑环境中,这预示着未来开发工具形态本身也会被 AI 重塑。
  • 另外,国产大模型 Deepseek 也榜上有名,占据了 8% 的使用率。

其它编程语言使用情况

这张图展示了 JS 开发者的多语言画像:

  • Python41% 的占比成为最常见的第二语言,依托后端开发、自动化脚本、数据分析与 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% 的人视其为痛点,Promiseasync/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 年的变化吧。我期待着更快的工具、更好的开发体验,以及技术真正成为能力放大器,强化我们自身的判断与选择。

父传子全解析:从基础到实战,新手也能零踩坑

2026年2月14日 14:30

在 Vue3 组件化开发中,父传子是最基础、最常用的组件通信方式,也是新手入门组件通信的第一步。无论是传递简单的字符串、数字,还是复杂的对象、数组,甚至是方法,父传子都有清晰、规范的实现方式。

不同于 Vue2 选项式 API 中 props 的写法,Vue3 组合式 API(

一、核心原理:单向数据流 + Props 传值

Vue3 父传子的核心逻辑只有两个关键词:Props单向数据流

  • Props:父组件通过在子组件标签上绑定属性(类似 HTML 标签属性),将数据传递给子组件;子组件通过定义 props,接收父组件传递过来的数据,相当于子组件的「输入参数」。
  • 单向数据流:数据只能从父组件流向子组件,子组件不能直接修改父组件传递过来的 props 数据(否则会报错)。如果子组件需要修改 props 数据,必须通过子传父的方式,通知父组件修改原始数据。

记住一句话:Props 是只读的,修改需找父组件。这是 Vue 组件通信的核心规范,也是避免数据混乱的关键。

父传子的核心流程(3步走):

  1. 父组件:在使用子组件的标签上,通过 :属性名="要传递的数据" 绑定数据;
  2. 子组件:通过 defineProps 定义要接收的 props(声明属性名和类型,可选但推荐);
  3. 子组件:在模板或脚本中,直接使用 props 中的数据(无需额外导入,直接通过 props.属性名 或 直接写属性名使用)。

二、基础用法:最简洁的父传子实现(必学)

我们用一个「父组件传递基本数据,子组件展示」的简单案例,讲解最基础的父传子写法,代码可直接复制到项目中运行,零门槛上手。

1. 父组件(Parent.vue):绑定数据并传递

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <p>父组件的基本数据:{{ parentName }}、{{ parentAge }}</p>
    <p>父组件的数组:{{ parentList.join('、') }}</p>
    <p>父组件的对象:{{ parentObj.name }} - {{ parentObj.gender }}</p>

    <!-- 1. 核心:在子组件标签上,通过 :属性名 绑定要传递的数据 -->
    <Child 
      :name="parentName"  // 传递字符串
      :age="parentAge"    // 传递数字
      :list="parentList"  // 传递数组
      :user-info="parentObj"  // 传递对象推荐用短横线命名)
    />
  </div>
</template>

<script setup>
// 引入子组件(Vue3 <script setup> 中,引入后可直接在模板中使用)
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 父组件要传递的数据(涵盖基本类型、数组、对象)
const parentName = ref('张三') // 字符串
const parentAge = ref(25)     // 数字
const parentList = ref(['苹果', '香蕉', '橙子']) // 数组
const parentObj = reactive({  // 对象
  name: '李四',
  gender: '男',
  age: 30
})
</script>

2. 子组件(Child.vue):定义Props并使用

<template>
  <div class="child">
    <h4>我是子组件(接收父组件传递的数据)</h4>
    <p>接收的字符串:{{ name }}</p>
    <p>接收的数字:{{ age }} 岁</p>
    <p>接收的数组:{{ list.join('、') }}</p>
    <p>接收的对象:{{ userInfo.name }}({{ userInfo.gender }})</p>
  </div>
</template>

<script setup>
// 2. 核心:通过 defineProps 定义要接收的 props
// 写法1:数组形式(简单场景,只声明属性名,不限制类型)
// const props = defineProps(['name', 'age', 'list', 'userInfo'])

// 写法2:对象形式(推荐,可限制类型、设置默认值、必填校验)
const props = defineProps({
  // 字符串类型
  name: {
    type: String,
    default: '默认用户名' // 默认值(父组件未传递时使用)
  },
  // 数字类型
  age: {
    type: Number,
    default: 18
  },
  // 数组类型(注意:数组/对象的默认值必须用函数返回,避免复用污染)
  list: {
    type: Array,
    default: () => [] // 数组默认值:返回空数组
  },
  // 对象类型(同理,默认值用函数返回)
  userInfo: {
    type: Object,
    default: () => ({}) // 对象默认值:返回空对象
  }
})

// 3. 在脚本中使用 props 数据(通过 props.属性名)
console.log('脚本中使用props:', props.name, props.age)
</script>

3. 基础细节说明(新手必看)

  • defineProps 是 Vue3 内置宏,无需导入,可直接在
  • 父组件传递数据时,属性名推荐用 kebab-case(短横线命名),比如 :user-info,子组件接收时用 camelCase(小驼峰命名),比如 userInfo,Vue 会自动做转换;
  • 数组/对象类型的 props,默认值必须用 函数返回(比如 default: () => []),否则多个子组件会复用同一个默认值,导致数据污染;
  • 子组件模板中可直接使用 props 的属性名(比如{{ name }}),脚本中必须通过 props.属性名 使用(比如 props.name)。

三、进阶用法:优化父传子的体验(实战常用)

基础用法能满足简单场景,但在实际开发中,我们还会遇到「必填校验」「类型多可选」「props 数据转换」等需求,这部分进阶技巧能让你的代码更规范、更健壮,避免后续维护踩坑。

1. Props 校验:必填项 + 多类型 + 自定义校验

通过 defineProps 的对象形式,我们可以对 props 进行全方位校验,避免父组件传递错误类型、遗漏必填数据,提升代码可靠性。

<script setup>
const props = defineProps({
  // 1. 必填项校验(required: true)
  username: {
    type: String,
    required: true, // 父组件必须传递该属性,否则控制台报警告
    default: '' // 注意:required: true 时,default 无效,可省略
  },

  // 2. 多类型校验(type 为数组)
  id: {
    type: [Number, String], // 允许父组件传递数字或字符串类型
    default: 0
  },

  // 3. 自定义校验(validator 函数)
  score: {
    type: Number,
    default: 0,
    // 自定义校验规则:分数必须在 0-100 之间
    validator: (value) => {
      return value >= 0 && value <= 100
    }
  }
})
</script>

说明:校验失败时,Vue 会在控制台打印警告(不影响代码运行),但能帮助我们快速定位问题,尤其适合团队协作场景。

2. Props 数据转换:computed 处理 props 数据

子组件不能直接修改 props 数据,但可以通过 computed 对 props 数据进行转换、格式化,满足子组件的展示需求,不影响原始 props 数据。

<template>
  <div class="child">
    <p>父组件传递的分数:{{ score }}</p>
    <p>转换后的等级:{{ scoreLevel }}</p>
    <p>父组件传递的姓名(大写):{{ upperName }}</p>
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  score: {
    type: Number,
    default: 0
  },
  name: {
    type: String,
    default: ''
  }
})

// 对 props 分数进行转换:0-60 不及格,60-80 及格,80-100 优秀
const scoreLevel = computed(() => {
  const { score } = props
  if (score >= 80) return '优秀'
  if (score >= 60) return '及格'
  return '不及格'
})

// 对 props 姓名进行格式化:转为大写
const upperName = computed(() => {
  return props.name.toUpperCase()
})
</script>

3. 传递方法:父组件给子组件传递回调函数

父传子不仅能传递数据,还能传递方法(回调函数)。核心用途:子组件通过调用父组件传递的方法,通知父组件修改数据(解决子组件不能直接修改 props 的问题)。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <p>父组件计数器:{{ count }}</p>
    <!-- 传递方法::方法名="父组件方法" -->
    <Child 
      :count="count"
      :addCount="handleAddCount"  // 传递父组件的方法
    />
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { ref } from 'vue'

const count = ref(0)

// 父组件的方法(将被传递给子组件)
const handleAddCount = () => {
  count.value++
}
</script>
<!-- 子组件(Child.vue) -->
<template>
  <div class="child">
    <p>子组件接收的计数器:{{ count }}</p>
    <!-- 调用父组件传递的方法 -->
    <button @click="addCount">点击让父组件计数器+1</button>
  </div>
</template>

<script setup>
const props = defineProps({
  count: {
    type: Number,
    default: 0
  },
  // 声明接收父组件传递的方法(type 为 Function)
  addCount: {
    type: Function,
    required: true
  }
})

// 也可以在脚本中调用父组件的方法
const callParentMethod = () => {
  props.addCount()
}
</script>

注意:传递方法时,父组件只需写 :addCount="handleAddCount"(不带括号),子组件调用时再带括号 addCount();如果父组件写 :addCount="handleAddCount()",会导致方法立即执行,而非传递方法本身。

4. 批量传递 props:v-bind 绑定对象

如果父组件需要给子组件传递多个 props,逐个绑定会比较繁琐,这时可以用 v-bind 批量绑定一个对象,子组件只需对应接收即可。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 批量传递:v-bind="对象",等价于逐个绑定对象的属性 -->
    <Child v-bind="userObj" />
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { reactive } from 'vue'

// 要批量传递的对象
const userObj = reactive({
  name: '张三',
  age: 25,
  gender: '男',
  address: '北京'
})
</script>
<!-- 子组件(Child.vue) -->
<script setup>
// 逐个接收父组件批量传递的 props,和普通 props 接收一致
const props = defineProps({
  name: String,
  age: Number,
  gender: String,
  address: String
})
</script>

四、实战场景:父传子的高频应用(贴合实际开发)

结合实际开发中的高频场景,补充 3 个常用案例,覆盖大部分父传子需求,直接套用即可。

场景1:父组件控制子组件弹窗显示/隐藏

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <button @click="visible = true">打开子组件弹窗</button>
    <!-- 传递弹窗显示状态 + 关闭弹窗的方法 -->
    <ChildModal 
      :visible="visible"
      :closeModal="handleCloseModal"
    />
  </div>
</template>

<script setup>
import ChildModal from './ChildModal.vue'
import { ref } from 'vue'

const visible = ref(false)

// 关闭弹窗的方法
const handleCloseModal = () => {
  visible.value = false
}
</script>
<!-- 子组件(ChildModal.vue) -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <h4>子组件弹窗</h4>
      <button @click="closeModal">关闭弹窗</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  closeModal: {
    type: Function,
    required: true
  }
})
</script>

场景2:父组件给子组件传递接口数据

实际开发中,父组件通常会请求接口,将接口返回的数据传递给子组件展示,这是最常见的场景之一。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 加载中状态 -->
    <div v-if="loading">加载中...</div>
    <!-- 接口数据请求成功后,传递给子组件 -->
    <ChildList :list="goodsList" v-else />
  </div>
</template>

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

const goodsList = ref([])
const loading = ref(false)

// 父组件请求接口
onMounted(async () => {
  loading.value = true
  try {
    const res = await fetch('https://api.example.com/goods')
    const data = await res.json()
    goodsList.value = data.list // 接口返回的列表数据
  } catch (err) {
    console.error('接口请求失败:', err)
  } finally {
    loading.value = false
  }
})
</script>

场景3:子组件复用,父组件传递不同配置

子组件复用是组件化开发的核心优势,通过父传子传递不同的配置,让同一个子组件实现不同的展示效果。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 同一个子组件,传递不同配置,展示不同效果 -->
    <Button 
      :text="按钮1"
      :type="primary"
      :disabled="false"
    />
    <Button 
      :text="按钮2"
      :type="default"
      :disabled="true"
    />
  </div>
</template>

<script setup>
import Button from './Button.vue'
</script>
<!-- 子组件(Button.vue) -->
<template>
  <button 
    class="custom-btn"
    :class="type === 'primary' ? 'btn-primary' : 'btn-default'"
    :disabled="disabled"
  >
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
    required: true
  },
  type: {
    type: String,
    default: 'default',
    validator: (val) => {
      return ['primary', 'default', 'danger'].includes(val)
    }
  },
  disabled: {
    type: Boolean,
    default: false
  }
})
</script>

五、常见坑点避坑指南(新手必看)

很多新手在写父传子时,会遇到「props 接收不到数据」「修改 props 报错」「方法传递后无法调用」等问题,以下是最常见的 5 个坑点,帮你快速避坑。

坑点1:父组件传递数据时,忘记加冒号(:)

错误写法:<Child name="parentName"></Child>(没有冒号,传递的是字符串 "parentName",而非父组件的 parentName 变量);

正确写法:<Child :name="parentName"></Child>(加冒号,传递的是父组件的变量)。

坑点2:子组件直接修改 props 数据

错误写法:props.name = '李四'(直接修改 props,会报错);

正确写法:通过父传子的方法,通知父组件修改原始数据(参考「传递方法」章节),或通过 computed 转换数据(不修改原始 props)。

坑点3:数组/对象 props 的默认值未用函数返回

错误写法:list: { type: Array, default: [] }(直接写数组,会导致多个子组件复用同一个数组,数据污染);

正确写法:list: { type: Array, default: () => [] }(用函数返回数组,每个子组件都会得到一个新的空数组)。

坑点4:传递方法时,父组件带了括号

错误写法:<Child :addCount="handleAddCount()"></Child>(方法立即执行,传递的是方法的返回值,而非方法本身);

正确写法:<Child :addCount="handleAddCount"></Child>(不带括号,传递方法本身)。

坑点5:props 命名大小写不一致

错误写法:父组件 :userInfo="parentObj",子组件接收 userinfo(小写 i);

正确写法:父组件用 kebab-case(:user-info),子组件用 camelCase(userInfo),或保持大小写一致(不推荐)。

六、总结:父传子核心要点回顾

Vue3 父传子的核心就是「Props 传值 + 单向数据流」,记住以下 4 个核心要点,就能应对所有父传子场景:

  1. 基础流程:父组件 :属性名="数据" 绑定 → 子组件 defineProps 接收 → 子组件使用数据;
  2. 核心规范:Props 是只读的,子组件不能直接修改,修改需通过父传子的方法通知父组件;
  3. 进阶技巧:props 校验提升可靠性,computed 转换数据,v-bind 批量传值,传递方法实现双向交互;
  4. 避坑关键:加冒号传递变量、不直接修改 props、数组/对象默认值用函数返回、传递方法不带括号。

父传子是 Vue3 组件通信中最基础、最常用的方式,掌握它之后,再学习子传父、跨层级通信(provide/inject)、全局通信(Pinia)会更轻松。

❌
❌