每日一题-二进制求和🟢
给你两个二进制字符串 a 和 b ,以二进制字符串的形式返回它们的和。
示例 1:
输入:a = "11", b = "1" 输出:"100"
示例 2:
输入:a = "1010", b = "1011" 输出:"10101"
提示:
1 <= a.length, b.length <= 104-
a和b仅由字符'0'或'1'组成 - 字符串如果不是
"0",就不含前导零
给你两个二进制字符串 a 和 b ,以二进制字符串的形式返回它们的和。
示例 1:
输入:a = "11", b = "1" 输出:"100"
示例 2:
输入:a = "1010", b = "1011" 输出:"10101"
提示:
1 <= a.length, b.length <= 104a 和 b 仅由字符 '0' 或 '1' 组成"0" ,就不含前导零无论你是刚学 JavaScript 的小白,还是已经写了几年代码的前端,只要在写后台管理系统,大概率都踩过 this 和箭头函数的坑。
这篇文章不讲特别玄学的底层原理,只回答三个问题:
先看一段后台管理系统里常见的代码:
// 表格操作列有个「删除」按钮
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 通常是 window 或 undefined,而不是你组件里的 this。
| 对比项 | 普通函数 | 箭头函数 |
|---|---|---|
| this | 有属于自己的 this,由调用方式决定 | 没有自己的 this,使用外层作用域的 this |
| arguments | 有 | 没有(可用 ...args 替代) |
| 能否 new | 可以 | 不可以 |
| 能否作为构造函数 | 可以 | 不可以 |
一句话区分:
this,谁调我,this 就指向谁。this,用的是「定义时所在作用域」的 this。因此,在需要「继承」外层 this 的场景(例如 Promise、setTimeout 回调),用箭头函数;在对象方法、构造函数等需要「自己的」this 的场景,用普通函数。
// ❌ 错误写法:在模板里用箭头函数包装,可能拿不到正确的 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 会在定义时就固定,可能指向 window 或 undefined,反而拿不到组件实例。
结论: 模板事件尽量直接写方法名,或写 (arg) => this.method(arg),不要在模板里随便包箭头函数。
// ❌ 错误:.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,即组件实例。
结论: 在 Promise、async/await、setTimeout 等异步回调里,需要访问组件/外层 this 时,用箭头函数。
// ❌ 错误:箭头函数作为对象方法,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 时,用普通函数,不要用箭头函数。
// 场景:监听 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 或普通函数,并把引用存到实例上,保证添加和移除用的是同一个函数。
forEach、map、filter 等)// 在 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。用箭头函数可以继承 processList 的 this。
结论: 在 forEach、map、filter、reduce 等回调里需要访问外层 this 时,用箭头函数;不需要 this 时,两者都可以。
可以按下面几条来选:
(arg) => this.method(arg),避免乱包箭头函数。...args。bind 或保存同一引用,保证添加和移除是同一个函数。this,谁调我,this 就指向谁。this,用的是「定义时所在外层」的 this。需要「动态 this」用普通函数,需要「固定外层 this」用箭头函数。
this 和箭头函数本身不复杂,容易出错的是「在错误场景选错写法」。后台项目里,最容易踩坑的就是:Promise 回调、对象方法、模板事件、事件监听器这几处。记住「谁在调用」「外层 this 是谁」,选普通函数还是箭头函数就不容易错。
以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。
我是 Eugene,你的电子学友。
如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~
在 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 函数组件的渲染机制:
每次组件重新渲染时:
useState 返回的状态值是当前最新的值useEffect 会根据依赖数组决定是否重新执行闭包是 JavaScript 中的一个核心概念,指的是函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。
在 React 中,每次渲染都会创建一个新的"快照",包含当时的所有状态、props 和函数。当 useEffect 依赖数组为空时,它只在第一次渲染时执行,因此它捕获的是第一次渲染时的闭包,里面的所有变量都是初始值。
这是最简单也是最推荐的解决方案。通过将 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 返回的对象在组件的整个生命周期中保持不变,我们可以用它来存储最新的状态值。
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 可以缓存函数,结合 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 在 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 用于缓存计算结果,同样可以配合 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 是另一种状态管理方式,它的 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 用于自定义暴露给父组件的 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 可以跨组件传递状态,避免 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 用于在 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 是 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 也是 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 在 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);
}
}, []);
}
闭包陷阱不仅仅出现在定时器中,还可能出现在以下场景:
useEffect 中添加事件监听器useEffect 中发起网络请求requestAnimationFrame 等 APIeslint-plugin-react-hooks 可以帮助你发现遗漏的依赖项希望这篇文章能帮助你彻底理解 React 闭包陷阱!🎉
学习Web框架的Python玩家大多应该都听过:Django 性能不行”、“高并发场景根本用不了 Django”。但有趣的是,在TIOBE、PyPI下载量、企业技术栈选型中,Django始终稳居Python后端框架第一梯队,甚至是很多公司的首选。
这背后的矛盾,恰恰折射出工业级开发的核心逻辑:性能从来不是唯一的衡量标准,生产力和工程化能力才是。
首先要纠正一个认知偏差:Django的 “慢” 是相对的,而非绝对的。
Django被吐槽“慢”,主要集中在这几个点:
绝大多数业务场景下,Django的性能完全够用:
企业选框架,本质是选“性价比”——开发效率、维护成本、团队协作成本,远比单点性能重要。而这正是Django的核心优势。
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]
对于创业公司或快速迭代的业务,“快上线、少踩坑”比“多10%的性能” 重要得多——Django能让团队用最少的人力,在最短时间内搭建起稳定的业务系统。
个人项目可以用Flask自由发挥,但团队项目需要“规范”。Django的“约定优于配置”理念,强制规范了项目结构、代码组织、数据库迁移等流程:
makemigrations/migrate 完美解决数据库版本管理问题,避免团队协作中的数据结构混乱。Django 诞生于2005年,经过近20年的迭代,已经成为一个极其稳定的框架:
面对性能吐槽,Django团队也一直在迭代优化:
Django的 “慢”,是为 “全栈、工程化、生产力” 付出的合理代价;而它能稳居第一梯队,核心原因是:
最后想说:框架没有好坏,只有适配与否。如果是做高并发的API服务(如直播、秒杀),FastAPI/Tornado 可能更合适;但如果是做内容管理、电商、企业后台等需要快速落地、长期维护的业务,Django依然是Python后端的最优解之一。
这也是为什么,即便有层出不穷的新框架,Django依然能稳坐第一梯队——因为它抓住了工业级开发的核心:让开发者把精力放在业务上,而非重复造轮子。
在 React 开发中,表单处理是高频场景——登录注册、评论提交、信息录入,几乎每个项目都会用到。但很多新手都会困惑:同样是获取表单输入值,为什么有的用 useState,有的用 useRef?这其实对应了 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 原生控制。
受控组件:表单元素的值由 React 状态(useState)完全控制,输入框的显示值 = 状态值,输入行为通过 onChange 事件更新状态,从而实现“状态 ↔ 输入框”的联动。
核心逻辑:状态驱动 DOM,符合 React 单向数据流原则——数据从状态流向 DOM,DOM 输入行为通过事件反馈给状态,形成闭环。
实现一个受控组件,必须满足两个条件:
value={状态值},让状态决定输入框显示内容;onChange 事件,通过 e.target.value 获取输入值,调用 setState 更新状态。实际开发中,表单往往有多个字段(如用户名、密码),此时可以用一个对象状态管理所有字段,配合事件委托简化代码:
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={注册
)
}
value={状态} 不写 onChange,输入框会变成「只读」——因为状态无法更新,输入框值永远固定;非受控组件:表单元素的值由 DOM 原生控制,React 不干预输入过程,而是通过 useRef 获取 DOM 元素,再读取其 current.value 获取输入值。
核心逻辑:DOM 驱动数据,和原生 HTML 表单逻辑一致,React 只做“被动获取”,不主动控制输入值。
实现一个非受控组件,只需一步:
useRef(null) 创建 Ref 对象,绑定到表单元素的 ref 属性;ref.current.value 读取(通常在提交、点击等事件中获取)。可选:用 defaultValue 设置初始值(仅首次渲染生效,后续修改不影响)。
评论框、搜索框等“一次性提交”场景,无需实时监控输入,用非受控组件更简洁高效:
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' }}
>
提交评论
)
}
value 绑定状态(否则会变成受控组件),初始值用 defaultValue;current 在组件首次渲染后才会指向 DOM,因此不能在组件渲染时直接读取 textareaRef.current.value(会报错);很多人纠结“该用哪个”,其实核心看「是否需要实时控制输入」,用表格清晰对比两者差异,一目了然:
| 对比维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 值的控制者 | React 状态(useState) | DOM 原生控制 |
| 核心依赖 | useState + onChange | useRef |
| 值的获取方式 | 直接读取状态(如 form.username) | ref.current.value |
| 初始值设置 | useState 初始值(如 useState("")) | defaultValue 属性 |
| 是否触发重渲染 | 输入时触发(onChange 更新状态) | 输入时不触发(无状态变化) |
| 适用场景 | 实时校验、表单联动、实时展示 | 一次性提交、文件上传、性能敏感场景 |
| 优点 | 可实时控制,符合 React 单向数据流,易维护 | 简洁高效,无需频繁更新状态,性能更好 |
| 缺点 | 频繁触发重渲染,代码量稍多 | 无法实时控制,需手动操作 DOM,不易做联动 |
不用死记硬背,记住两个核心原则,就能快速判断:
value 又绑定 ref,会导致逻辑混乱;ref.current 是否存在,避免报错;受控组件和非受控组件没有“谁更好”,只有“谁更合适”:
✅ 受控组件是 React 表单处理的「主流方式」,符合单向数据流,适合复杂表单、需要实时控制的场景;
✅ 非受控组件更「简洁高效」,贴近原生 HTML,适合简单场景、性能敏感场景和文件上传;
记住:判断的核心是「是否需要实时控制输入值」。掌握两者的用法和区别,就能轻松应对 React 中的所有表单场景,写出简洁、高效、可维护的代码。
在 React 开发中,组件通信是绕不开的核心问题。父子组件通信可以通过 props 轻松实现,但当组件层级嵌套较深(比如爷爷 → 父 → 子 → 孙),或者需要跨多个组件共享数据时,单纯依靠 props 传递就会变得繁琐又低效——这就是我们常说的“prop drilling(props 透传)”。
就像《长安的荔枝》里,荔枝从岭南运往长安,需要层层传递、处处协调,耗时耗力还容易出问题。React 的 Context API 就是为了解决这个痛点而生,它能让数据在组件树中“全局共享”,无需手动层层透传,让跨层级通信变得简洁高效。
本文将从「痛点分析」→「Context 核心原理」→「基础用法」→「实战案例」,带你彻底掌握 React Context 的使用,看完就能直接应用到项目中。
先看一个常见的场景: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>
)
}
这段代码的问题很明显:
而 Context API 就能完美解决这个问题——它让数据“悬浮”在组件树的顶层,任何层级的组件,只要需要,都能直接“取用”,无需中间组件透传。
React Context 的核心思想很简单:创建一个“数据容器”,在组件树的顶层提供数据,底层组件按需取用。整个过程只需 3 步,记牢就能轻松上手。
首先,我们需要用 React 提供的 createContext 方法,创建一个 Context 容器,用于存储需要共享的数据。可以把它理解为一个“全局数据仓库”。
import { createContext } from 'react';
// 创建 Context 容器,默认值为 null(可选,可根据需求设置)
// 导出 Context,供其他组件取用
export const UserContext = createContext(null);
注意:默认值只有在“组件没有找到对应的 Provider”时才会生效,实际开发中一般设置为 null 或初始数据即可。
创建好 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>
)
}
关键细节:
value 发生变化时,所有使用该 Context 的组件都会自动重新渲染;底层组件想要使用 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 的核心用法。
我们创建一个 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>
);
}
在 App 组件中,用 ThemeProvider 包裹整个组件树,让所有子组件都能取用主题相关数据。
// App.js
import ThemeProvider from "./contexts/ThemeContext";
import Page from './pages/Page';
export default function App() {
return (
<ThemeProvider>
<Page />
</ThemeProvider>
);
}
在 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>
);
}
通过 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;
}
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>
);
}
初始状态:白天模式,背景为白色,文字为深灰色,按钮为蓝色;
点击“切换主题”按钮:主题变为夜间模式,背景变为黑色,文字变为白色,按钮颜色变浅;
再次点击:切换回白天模式,所有样式平滑过渡。
实际开发中,我们可能需要共享多种数据(比如用户信息、主题、权限),此时可以嵌套多个 Provider:
<UserContext.Provider value={user}>
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<Page />
</ThemeContext.Provider>
</UserContext.Provider>
底层组件可以分别用 useContext 取用不同的 Context 数据,互不影响。
当 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>
);
Context 适合共享「全局且变化不频繁」的数据(如主题、用户信息、权限),但不适合用来传递频繁变化的局部数据(如表单输入值)。
如果数据只在父子组件之间传递,且层级较浅,优先使用 props;如果数据需要跨多层级共享,再考虑 Context。
createContext 的默认值,只有在组件没有被对应的 Provider 包裹时才会生效。通常用于开发环境下的 fallback(降级),或者测试组件时避免报错。
React Context API 是解决跨层级组件通信的最优方案之一,它的核心是“创建容器 → 提供数据 → 取用数据”,三步就能实现全局数据共享,彻底解决 prop drilling 的痛点。
通过本文的基础讲解和主题切换实战,你应该已经掌握了 Context 的核心用法:
在React Native近期的更新中,比较大的就是GlassEffect,就是iOS的液态玻璃效果。其实具体啥样我也没具体关注,主要是没有iOS设备,我的音乐播放器应用也一直是在Android上调试。而且Android上的blur效果和GlassEffect效果是有明显差异的。那么提到blur,前端er应该立马能想到CSS中的滤镜,blur毛玻璃效果在web端实现非常轻松,而在android则是实验性功能,它还很年轻,在开发过程中我尝试了很多依赖库,下面会简单介绍在使用过程中踩过的坑。下面先放图,使用的位置是页面顶部和底部tab,看一下大致效果:
效果非常哇塞的一个依赖库!amazing!但是它有些大坑,而且难以逾越,在github仓库中有很多issue,而且我发现有些并没有明确解决,但是关闭了。最近也是尝试了挺多依赖库,也提了一些issue,有些依赖库作者会回复,但是总体感受就是不耐烦,而且大部分是急不可耐的关闭,比如:我们测没有问题,你的问题我从来没遇到过,关闭。这个问题不多说。具体说我发现的问题:
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半透明背景色。。。
这个依赖库很早我就安装过,,当时只是做了简单的效果,没有仔细看文档,其实它的文档非常简单,配置项很少,使用极其简单,效果其实也还不错,没有像第一个依赖库那样的硬伤bug,但是最近我要在应用中明确实现图中的效果,但是,但是由于我忽略了最重要的配置,导致我只能实现rgba效果!,后面我会重点分析,但是并不是说它就没有问题:
其它目前我没有发现异常,而且就像expo-audio兜兜转转又回到原点一样,我千辛万苦尝试了这么多依赖库,因为这些依赖库都是需要原生代码,因此想要调试不能使用expo go,必须执行开发构建才能调试,这个坑还能接受,但是你还要知道:不同的blur依赖库内部可能都使用了同样的依赖库,调用了同样的原生功能,如果你把所有功能相似的依赖库都安装并开发构建,你大概率会失败,所以在尝试一些依赖库时就提心吊胆:构建次数有限,每次同类库只能安装一个,比较耗时,大概30分钟以内,如果排队的话,可能光排队就要30分钟!
刚才也提到了expo-blur使用非常简单,配置项很少,非常符合直觉,文档的介绍也比较简单:它继承扩展了ViewProps,就是它具有View组件的属性,除此之外还有以下属性:
就这么点配置项,我天真的以为核心是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上的播放器控制栏上露头的图片布局非常有利:
下面是没有开启experimentalBlurMethod="dimezisBlurView"的效果:
下图是在亮模式下blur导致的异常阴影效果问题:
这个问题应该调整intensity的值低一点应该会改善,在暗模式下不是很明显。[项目地址](expo rn: expo创建的react native的音乐播放器应用,专注视频转歌和本地歌曲播放)欢迎讨论交流!
二进制的加法怎么算?
和十进制的加法一样,从低到高(从右往左)计算。
示例 1 是 $11+1$,计算过程如下:
由此可见,需要在计算过程中维护进位值 $\textit{carry}$,每次计算
$$
\textit{sum} = a\ 这一位的值 + b\ 这一位的值 + \textit{carry}
$$
然后答案这一位填 $\textit{sum}\bmod 2$,进位更新为 $\left\lfloor\dfrac{\textit{sum}}{2}\right\rfloor$。例如 $\textit{sum} = 10$,那么答案这一位填 $0$,进位更新为 $1$。
把计算结果插在答案的末尾,最后把答案反转。
###py
class Solution:
def addBinary(self, a: str, b: str) -> str:
ans = []
i = len(a) - 1 # 从右往左遍历 a 和 b
j = len(b) - 1
carry = 0 # 保存进位
while i >= 0 or j >= 0 or carry:
x = int(a[i]) if i >= 0 else 0
y = int(b[j]) if j >= 0 else 0
s = x + y + carry # 计算这一位的加法
# 例如 s = 10,把 '0' 填入答案,把 carry 置为 1
ans.append(str(s % 2))
carry = s // 2
i -= 1
j -= 1
return ''.join(reversed(ans))
###java
class Solution {
public String addBinary(String a, String b) {
StringBuilder ans = new StringBuilder();
int i = a.length() - 1; // 从右往左遍历 a 和 b
int j = b.length() - 1;
int carry = 0; // 保存进位
while (i >= 0 || j >= 0 || carry > 0) {
int x = i >= 0 ? a.charAt(i) - '0' : 0;
int y = j >= 0 ? b.charAt(j) - '0' : 0;
int sum = x + y + carry; // 计算这一位的加法
// 例如 sum = 10,把 '0' 填入答案,把 carry 置为 1
ans.append(sum % 2);
carry = sum / 2;
i--;
j--;
}
return ans.reverse().toString();
}
}
###cpp
class Solution {
public:
string addBinary(string a, string b) {
string ans;
int i = a.size() - 1; // 从右往左遍历 a 和 b
int j = b.size() - 1;
int carry = 0; // 保存进位
while (i >= 0 || j >= 0 || carry) {
int x = i >= 0 ? a[i] - '0' : 0;
int y = j >= 0 ? b[j] - '0' : 0;
int sum = x + y + carry; // 计算这一位的加法
// 例如 sum = 10,把 '0' 填入答案,把 carry 置为 1
ans += sum % 2 + '0';
carry = sum / 2;
i--;
j--;
}
ranges::reverse(ans);
return ans;
}
};
###c
#define MAX(a, b) ((b) > (a) ? (b) : (a))
char* addBinary(char* a, char* b) {
int n = strlen(a);
int m = strlen(b);
char* ans = malloc((MAX(n, m) + 2) * sizeof(char));
int k = 0;
int i = n - 1; // 从右往左遍历 a 和 b
int j = m - 1;
int carry = 0; // 保存进位
while (i >= 0 || j >= 0 || carry) {
int x = i >= 0 ? a[i] - '0' : 0;
int y = j >= 0 ? b[j] - '0' : 0;
int sum = x + y + carry; // 计算这一位的加法
// 例如 sum = 10,把 '0' 填入答案,把 carry 置为 1
ans[k++] = sum % 2 + '0';
carry = sum / 2;
i--;
j--;
}
// 反转 ans
for (int l = 0, r = k - 1; l < r; l++, r--) {
char tmp = ans[l];
ans[l] = ans[r];
ans[r] = tmp;
}
ans[k] = '\0';
return ans;
}
###go
func addBinary(a, b string) string {
ans := []byte{}
i := len(a) - 1 // 从右往左遍历 a 和 b
j := len(b) - 1
carry := byte(0) // 保存进位
for i >= 0 || j >= 0 || carry > 0 {
// 计算这一位的加法
sum := carry
if i >= 0 {
sum += a[i] - '0'
}
if j >= 0 {
sum += b[j] - '0'
}
// 例如 sum = 10,把 '0' 填入答案,把 carry 置为 1
ans = append(ans, sum%2+'0')
carry = sum / 2
i--
j--
}
slices.Reverse(ans)
return string(ans)
}
###js
var addBinary = function(a, b) {
const ans = [];
let i = a.length - 1; // 从右往左遍历 a 和 b
let j = b.length - 1;
let carry = 0; // 保存进位
while (i >= 0 || j >= 0 || carry) {
const x = i >= 0 ? Number(a[i]) : 0;
const y = j >= 0 ? Number(b[j]) : 0;
const sum = x + y + carry; // 计算这一位的加法
// 例如 sum = 10,把 '0' 填入答案,把 carry 置为 1
ans.push(String(sum % 2));
carry = Math.floor(sum / 2);
i--;
j--;
}
return ans.reverse().join('');
};
###rust
impl Solution {
pub fn add_binary(a: String, b: String) -> String {
let a = a.as_bytes();
let b = b.as_bytes();
let mut ans = vec![];
let mut i = a.len() as isize - 1; // 从右往左遍历 a 和 b
let mut j = b.len() as isize - 1;
let mut carry = 0; // 保存进位
while i >= 0 || j >= 0 || carry > 0 {
let x = if i >= 0 { a[i as usize] - b'0' } else { 0 };
let y = if j >= 0 { b[j as usize] - b'0' } else { 0 };
let sum = x + y + carry; // 计算这一位的加法
// 例如 sum = 10,把 '0' 填入答案,把 carry 置为 1
ans.push(sum % 2 + b'0');
carry = sum / 2;
i -= 1;
j -= 1;
}
ans.reverse();
unsafe { String::from_utf8_unchecked(ans) }
}
}
直接填入答案,不反转。
###py
class Solution:
def addBinary(self, a: str, b: str) -> str:
# 保证 len(a) >= len(b),简化后续代码逻辑
if len(a) < len(b):
a, b = b, a
n, m = len(a), len(b)
ans = [0] * (n + 1)
carry = 0 # 保存进位
for i in range(n - 1, -1, -1):
j = m - (n - i)
y = int(b[j]) if j >= 0 else 0
s = int(a[i]) + y + carry
ans[i + 1] = str(s % 2)
carry = s // 2
ans[0] = str(carry)
return ''.join(ans[carry ^ 1:]) # 如果 carry == 0 则去掉 ans[0]
###java
class Solution {
public String addBinary(String a, String b) {
// 保证 a.length() >= b.length(),简化后续代码逻辑
if (a.length() < b.length()) {
return addBinary(b, a);
}
int n = a.length();
int m = b.length();
char[] ans = new char[n + 1];
int carry = 0; // 保存进位
for (int i = n - 1, j = m - 1; i >= 0; i--, j--) {
int x = a.charAt(i) - '0';
int y = j >= 0 ? b.charAt(j) - '0' : 0;
int sum = x + y + carry;
ans[i + 1] = (char) (sum % 2 + '0');
carry = sum / 2;
}
ans[0] = (char) (carry + '0');
// 如果 carry == 0 则去掉 ans[0]
return new String(ans, carry ^ 1, n + carry);
}
}
###cpp
class Solution {
public:
string addBinary(string a, string b) {
// 保证 a.size() >= b.size(),简化后续代码逻辑
if (a.size() < b.size()) {
swap(a, b);
}
int n = a.size(), m = b.size();
string ans(n + 1, 0);
int carry = 0; // 保存进位
for (int i = n - 1, j = m - 1; i >= 0; i--, j--) {
int x = a[i] - '0';
int y = j >= 0 ? b[j] - '0' : 0;
int sum = x + y + carry;
ans[i + 1] = sum % 2 + '0';
carry = sum / 2;
}
if (carry) {
ans[0] = '1';
} else {
ans.erase(ans.begin());
}
return ans;
}
};
###c
#define MAX(a, b) ((b) > (a) ? (b) : (a))
char* addBinary(char* a, char* b) {
int n = strlen(a);
int m = strlen(b);
char* ans = malloc((MAX(n, m) + 2) * sizeof(char));
ans[MAX(n, m) + 1] = '\0';
int carry = 0; // 保存进位
for (int i = n - 1, j = m - 1; i >= 0 || j >= 0; i--, j--) {
int x = i >= 0 ? a[i] - '0' : 0;
int y = j >= 0 ? b[j] - '0' : 0;
int sum = x + y + carry;
ans[MAX(i, j) + 1] = sum % 2 + '0';
carry = sum / 2;
}
ans[0] = carry + '0';
// 如果 carry == 0 则去掉 ans[0]
return ans + (carry ^ 1);
}
###go
func addBinary(a, b string) string {
// 保证 len(a) >= len(b),简化后续代码逻辑
if len(a) < len(b) {
a, b = b, a
}
n, m := len(a), len(b)
ans := make([]byte, n+1)
carry := byte(0) // 保存进位
for i := n - 1; i >= 0; i-- {
sum := a[i] - '0' + carry
if j := m - (n - i); j >= 0 {
sum += b[j] - '0'
}
ans[i+1] = sum%2 + '0'
carry = sum / 2
}
ans[0] = carry + '0'
// 如果 carry == 0 则去掉 ans[0]
return string(ans[carry^1:])
}
###js
var addBinary = function(a, b) {
// 保证 a.length >= b.length,简化后续代码逻辑
if (a.length < b.length) {
[a, b] = [b, a];
}
const n = a.length;
const m = b.length;
const ans = Array(n + 1);
let carry = 0; // 保存进位
for (let i = n - 1, j = m - 1; i >= 0; i--, j--) {
const x = Number(a[i]);
const y = j >= 0 ? Number(b[j]) : 0;
const sum = x + y + carry;
ans[i + 1] = String(sum % 2);
carry = Math.floor(sum / 2);
}
if (carry) {
ans[0] = '1';
} else {
ans.shift();
}
return ans.join('');
};
###rust
impl Solution {
pub fn add_binary(a: String, b: String) -> String {
// 保证 a.len() >= b.len(),简化后续代码逻辑
if a.len() < b.len() {
return Self::add_binary(b, a);
}
let a = a.as_bytes();
let b = b.as_bytes();
let n = a.len();
let m = b.len();
let mut ans = vec![0; n + 1];
let mut carry = 0; // 保存进位
for i in (0..n).rev() {
let x = a[i] - b'0';
let y = if n - i <= m { b[m - (n - i)] - b'0' } else { 0 };
let sum = x + y + carry;
ans[i + 1] = sum % 2 + b'0';
carry = sum / 2;
}
if carry > 0 {
ans[0] = b'1';
} else {
ans.remove(0);
}
unsafe { String::from_utf8_unchecked(ans) }
}
}
欢迎关注 B站@灵茶山艾府
考虑一个最朴素的方法:先将 $a$ 和 $b$ 转化成十进制数,求和后再转化为二进制数。利用 Python 和 Java 自带的高精度运算,我们可以很简单地写出这个程序:
###python
class Solution:
def addBinary(self, a, b) -> str:
return '{0:b}'.format(int(a, 2) + int(b, 2))
###Java
class Solution {
public String addBinary(String a, String b) {
return Integer.toBinaryString(
Integer.parseInt(a, 2) + Integer.parseInt(b, 2)
);
}
}
如果 $a$ 的位数是 $n$,$b$ 的位数为 $m$,这个算法的渐进时间复杂度为 $O(n + m)$。但是这里非常简单的实现基于 Python 和 Java 本身的高精度功能,在其他的语言中可能并不适用,并且在 Java 中:
Integer
Long
BigInteger
因此,为了适用于长度较大的字符串计算,我们应该使用更加健壮的算法。
思路和算法
我们可以借鉴「列竖式」的方法,末尾对齐,逐位相加。在十进制的计算中「逢十进一」,二进制中我们需要「逢二进一」。
具体的,我们可以取 $n = \max{ |a|, |b| }$,循环 $n$ 次,从最低位开始遍历。我们使用一个变量 $\textit{carry}$ 表示上一个位置的进位,初始值为 $0$。记当前位置对其的两个位为 $a_i$ 和 $b_i$,则每一位的答案为 $(\textit{carry} + a_i + b_i) \bmod{2}$,下一位的进位为 $\lfloor \frac{\textit{carry} + a_i + b_i}{2} \rfloor$。重复上述步骤,直到数字 $a$ 和 $b$ 的每一位计算完毕。最后如果 $\textit{carry}$ 的最高位不为 $0$,则将最高位添加到计算结果的末尾。
注意,为了让各个位置对齐,你可以先反转这个代表二进制数字的字符串,然后低下标对应低位,高下标对应高位。当然你也可以直接把 $a$ 和 $b$ 中短的那一个补 $0$ 直到和长的那个一样长,然后从高位向低位遍历,对应位置的答案按照顺序存入答案字符串内,最终将答案串反转。这里的代码给出第一种的实现。
代码
###Java
class Solution {
public String addBinary(String a, String b) {
StringBuffer ans = new StringBuffer();
int n = Math.max(a.length(), b.length()), carry = 0;
for (int i = 0; i < n; ++i) {
carry += i < a.length() ? (a.charAt(a.length() - 1 - i) - '0') : 0;
carry += i < b.length() ? (b.charAt(b.length() - 1 - i) - '0') : 0;
ans.append((char) (carry % 2 + '0'));
carry /= 2;
}
if (carry > 0) {
ans.append('1');
}
ans.reverse();
return ans.toString();
}
}
###C++
class Solution {
public:
string addBinary(string a, string b) {
string ans;
reverse(a.begin(), a.end());
reverse(b.begin(), b.end());
int n = max(a.size(), b.size()), carry = 0;
for (size_t i = 0; i < n; ++i) {
carry += i < a.size() ? (a.at(i) == '1') : 0;
carry += i < b.size() ? (b.at(i) == '1') : 0;
ans.push_back((carry % 2) ? '1' : '0');
carry /= 2;
}
if (carry) {
ans.push_back('1');
}
reverse(ans.begin(), ans.end());
return ans;
}
};
###Go
func addBinary(a string, b string) string {
ans := ""
carry := 0
lenA, lenB := len(a), len(b)
n := max(lenA, lenB)
for i := 0; i < n; i++ {
if i < lenA {
carry += int(a[lenA-i-1] - '0')
}
if i < lenB {
carry += int(b[lenB-i-1] - '0')
}
ans = strconv.Itoa(carry%2) + ans
carry /= 2
}
if carry > 0 {
ans = "1" + ans
}
return ans
}
###C
void reserve(char* s) {
int len = strlen(s);
for (int i = 0; i < len / 2; i++) {
char t = s[i];
s[i] = s[len - i - 1], s[len - i - 1] = t;
}
}
char* addBinary(char* a, char* b) {
reserve(a);
reserve(b);
int len_a = strlen(a), len_b = strlen(b);
int n = fmax(len_a, len_b), carry = 0, len = 0;
char* ans = (char*)malloc(sizeof(char) * (n + 2));
for (int i = 0; i < n; ++i) {
carry += i < len_a ? (a[i] == '1') : 0;
carry += i < len_b ? (b[i] == '1') : 0;
ans[len++] = carry % 2 + '0';
carry /= 2;
}
if (carry) {
ans[len++] = '1';
}
ans[len] = '\0';
reserve(ans);
return ans;
}
###Python
class Solution:
def addBinary(self, a: str, b: str) -> str:
ans = []
a = a[::-1]
b = b[::-1]
n = max(len(a), len(b))
carry = 0
for i in range(n):
carry += int(a[i]) if i < len(a) else 0
carry += int(b[i]) if i < len(b) else 0
ans.append(str(carry % 2))
carry //= 2
if carry:
ans.append('1')
return ''.join(ans)[::-1]
###C#
public class Solution {
public string AddBinary(string a, string b) {
char[] aArr = a.ToCharArray();
char[] bArr = b.ToCharArray();
Array.Reverse(aArr);
Array.Reverse(bArr);
int n = Math.Max(a.Length, b.Length);
int carry = 0;
List<char> ans = new List<char>();
for (int i = 0; i < n; i++) {
carry += i < aArr.Length ? (aArr[i] == '1' ? 1 : 0) : 0;
carry += i < bArr.Length ? (bArr[i] == '1' ? 1 : 0) : 0;
ans.Add((carry % 2) == 1 ? '1' : '0');
carry /= 2;
}
if (carry > 0) {
ans.Add('1');
}
ans.Reverse();
return new string(ans.ToArray());
}
}
###JavaScript
var addBinary = function(a, b) {
let ans = [];
a = a.split('').reverse().join('');
b = b.split('').reverse().join('');
const n = Math.max(a.length, b.length);
let carry = 0;
for (let i = 0; i < n; i++) {
carry += i < a.length ? parseInt(a[i]) : 0;
carry += i < b.length ? parseInt(b[i]) : 0;
ans.push((carry % 2).toString());
carry = Math.floor(carry / 2);
}
if (carry) {
ans.push('1');
}
return ans.reverse().join('');
};
###TypeScript
function addBinary(a: string, b: string): string {
let ans: string[] = [];
a = a.split('').reverse().join('');
b = b.split('').reverse().join('');
const n = Math.max(a.length, b.length);
let carry = 0;
for (let i = 0; i < n; i++) {
carry += i < a.length ? parseInt(a[i]) : 0;
carry += i < b.length ? parseInt(b[i]) : 0;
ans.push((carry % 2).toString());
carry = Math.floor(carry / 2);
}
if (carry) {
ans.push('1');
}
return ans.reverse().join('');
}
###Rust
impl Solution {
pub fn add_binary(a: String, b: String) -> String {
let mut a_chars: Vec<char> = a.chars().collect();
let mut b_chars: Vec<char> = b.chars().collect();
a_chars.reverse();
b_chars.reverse();
let n = a_chars.len().max(b_chars.len());
let mut carry = 0;
let mut ans = Vec::new();
for i in 0..n {
carry += if i < a_chars.len() { if a_chars[i] == '1' { 1 } else { 0 } } else { 0 };
carry += if i < b_chars.len() { if b_chars[i] == '1' { 1 } else { 0 } } else { 0 };
ans.push(if carry % 2 == 1 { '1' } else { '0' });
carry /= 2;
}
if carry > 0 {
ans.push('1');
}
ans.reverse();
ans.into_iter().collect()
}
}
复杂度分析
假设 $n = \max{ |a|, |b| }$。
思路和算法
如果不允许使用加减乘除,则可以使用位运算替代上述运算中的一些加减乘除的操作。
如果不了解位运算,可以先了解位运算并尝试练习以下题目:
我们可以设计这样的算法来计算:
answer = x ^ y
carry = (x & y) << 1
x = answer,y = carry
为什么这个方法是可行的呢?在第一轮计算中,answer 的最后一位是 $x$ 和 $y$ 相加之后的结果,carry 的倒数第二位是 $x$ 和 $y$ 最后一位相加的进位。接着每一轮中,由于 carry 是由 $x$ 和 $y$ 按位与并且左移得到的,那么最后会补零,所以在下面计算的过程中后面的数位不受影响,而每一轮都可以得到一个低 $i$ 位的答案和它向低 $i + 1$ 位的进位,也就模拟了加法的过程。
代码
###Java
import java.math.BigInteger;
class Solution {
public String addBinary(String a, String b) {
BigInteger x = new BigInteger(a, 2);
BigInteger y = new BigInteger(b, 2);
while (!y.equals(BigInteger.ZERO)) {
BigInteger answer = x.xor(y);
BigInteger carry = x.and(y).shiftLeft(1);
x = answer;
y = carry;
}
return x.toString(2);
}
}
###C++
class Solution {
public:
string addBinary(string a, string b) {
string result = "";
int i = a.length() - 1, j = b.length() - 1;
int carry = 0;
while (i >= 0 || j >= 0 || carry) {
int sum = carry;
if (i >= 0) {
sum += a[i--] - '0';
}
if (j >= 0) {
sum += b[j--] - '0';
}
result = char(sum % 2 + '0') + result;
carry = sum / 2;
}
return result;
}
};
###Go
func addBinary(a string, b string) string {
if a == "" {
return b
}
if b == "" {
return a
}
x := new(big.Int)
x.SetString(a, 2)
y := new(big.Int)
y.SetString(b, 2)
zero := new(big.Int)
for y.Cmp(zero) != 0 {
answer := new(big.Int)
answer.Xor(x, y)
carry := new(big.Int)
carry.And(x, y)
carry.Lsh(carry, 1)
x.Set(answer)
y.Set(carry)
}
return x.Text(2)
}
###C
char* addBinary(char* a, char* b) {
int len_a = strlen(a);
int len_b = strlen(b);
int max_len = (len_a > len_b ? len_a : len_b) + 2;
char* result = (char*)malloc(max_len * sizeof(char));
if (!result) {
return NULL;
}
int i = len_a - 1, j = len_b - 1;
int carry = 0;
int k = max_len - 2;
result[max_len - 1] = '\0';
while (i >= 0 || j >= 0 || carry) {
int sum = carry;
if (i >= 0) {
sum += a[i--] - '0';
}
if (j >= 0) {
sum += b[j--] - '0';
}
result[k--] = (sum % 2) + '0';
carry = sum / 2;
}
if (k >= 0) {
char* final_result = result + k + 1;
char* dup = strdup(final_result);
free(result);
return dup;
}
return result;
}
###Python
class Solution:
def addBinary(self, a, b) -> str:
x, y = int(a, 2), int(b, 2)
while y:
answer = x ^ y
carry = (x & y) << 1
x, y = answer, carry
return bin(x)[2:]
###C#
public class Solution {
public string AddBinary(string a, string b) {
if (string.IsNullOrEmpty(a)) {
return b;
}
if (string.IsNullOrEmpty(b)) {
return a;
}
BigInteger x = BigInteger.Parse("0" + a, System.Globalization.NumberStyles.AllowBinarySpecifier);
BigInteger y = BigInteger.Parse("0" + b, System.Globalization.NumberStyles.AllowBinarySpecifier);
while (y != 0) {
BigInteger answer = x ^ y;
BigInteger carry = (x & y) << 1;
x = answer;
y = carry;
}
if (x == 0) {
return "0";
}
string result = "";
while (x > 0) {
result = (x % 2).ToString() + result;
x /= 2;
}
return result;
}
}
###JavaScript
var addBinary = function(a, b) {
let x = BigInt('0b' + a);
let y = BigInt('0b' + b);
while (y !== 0n) {
let answer = x ^ y;
let carry = (x & y) << 1n;
x = answer;
y = carry;
}
return x.toString(2);
};
###TypeScript
function addBinary(a: string, b: string): string {
let x = BigInt('0b' + a);
let y = BigInt('0b' + b);
while (y !== 0n) {
let answer = x ^ y;
let carry = (x & y) << 1n;
x = answer;
y = carry;
}
return x.toString(2);
}
###Rust
impl Solution {
pub fn add_binary(a: String, b: String) -> String {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let mut i = a_chars.len() as i32 - 1;
let mut j = b_chars.len() as i32 - 1;
let mut carry = 0;
let mut result = Vec::new();
while i >= 0 || j >= 0 || carry > 0 {
let mut sum = carry;
if i >= 0 {
sum += a_chars[i as usize].to_digit(2).unwrap_or(0);
i -= 1;
}
if j >= 0 {
sum += b_chars[j as usize].to_digit(2).unwrap_or(0);
j -= 1;
}
result.push(char::from_digit(sum % 2, 10).unwrap());
carry = sum / 2;
}
result.iter().rev().collect()
}
}
复杂度分析
整体思路是将两个字符串较短的用 $0$ 补齐,使得两个字符串长度一致,然后从末尾进行遍历计算,得到最终结果。
本题解中大致思路与上述一致,但由于字符串操作原因,不确定最后的结果是否会多出一位进位,所以会有 2 种处理方式:
时间复杂度:$O(n)$
###Java
class Solution {
public String addBinary(String a, String b) {
StringBuilder ans = new StringBuilder();
int ca = 0;
for(int i = a.length() - 1, j = b.length() - 1;i >= 0 || j >= 0; i--, j--) {
int sum = ca;
sum += i >= 0 ? a.charAt(i) - '0' : 0;
sum += j >= 0 ? b.charAt(j) - '0' : 0;
ans.append(sum % 2);
ca = sum / 2;
}
ans.append(ca == 1 ? ca : "");
return ans.reverse().toString();
}
}
###JavaScript
/**
* @param {string} a
* @param {string} b
* @return {string}
*/
var addBinary = function(a, b) {
let ans = "";
let ca = 0;
for(let i = a.length - 1, j = b.length - 1;i >= 0 || j >= 0; i--, j--) {
let sum = ca;
sum += i >= 0 ? parseInt(a[i]) : 0;
sum += j >= 0 ? parseInt(b[j]) : 0;
ans += sum % 2;
ca = Math.floor(sum / 2);
}
ans += ca == 1 ? ca : "";
return ans.split('').reverse().join('');
};
<
,
,
>
想看大鹏画解更多高频面试题,欢迎阅读大鹏的 LeetBook:《画解剑指 Offer 》,O(∩_∩)O
本人快 40 岁了。第一份工作是做网站编辑,那时候开始接触 jQuery,后来转做前端,一直做到现在。说实话,我对写程序谈不上特别热爱,所以技术水平一般。
年轻的时候如果做得不开心,就会直接裸辞。不过每次裸辞的那段时间,我都会拼命学习,这对我的成长帮助其实很大。
下面给年轻人几点个人建议,供参考:
以上只是个人经历和感受,不一定适用于所有人,但希望能给年轻的你一些参考。
国产大模型之中,字节是一个异类。
不像其他大模型轰轰烈烈、争夺眼球,它更低调,不引人注目。
但是,它做的事情反倒最多,大模型、Agent、开发工具、云服务都有独立品牌,遍地开花,一个都不缺,都在高速推进。

Seed 是字节的大模型团队,底下有好几条产品线,最近热得发烫的视频模型 Seedance 2.0 就是他们的产品。

今天,我就用字节的全家桶 ---- 刚刚发布的 Seed 2.0 模型和开发工具 TRAE ---- 写一篇 Skill 教程。
大家会看到,它们组合起来既强大,又简单好用,(个人用户)还免费。这也是我想写的原因,让大家知道有这个方案。
只要十分钟,读完这篇教程,你还会明白 Skill 是什么,怎么用,以及为什么一定要用它。
先介绍 Seed 2.0,它是 Seed 家族的基座模型。

所谓"基座模型"(foundation model),就是一种通用大模型,可用来构建其他各种下游模型。最大的两个特征有两个:一个是规模大,另一个是泛化能力强,这样才方便构建别的模型。
大家熟知的豆包,就是基于 Seed 模型,它也被称为"豆包大模型"。这次 Seed 2.0 包含 Pro、Lite、Mini 三款通用模型,以及专为开发者定制的 Seed 2.0 Code 模型。
由于各种用途都必须支持,Seed 2.0 的通用性特别突出,比以前版本都要强。
1、支持多模态,各种类型的数据都能处理:文字、图表、视觉空间、运动、视频等等。
2、具备各种 Agent 能力,方便跟企业工具对接:搜索、函数调用、工具调用、多轮指令、上下文管理等。
3、有推理和代码能力。
正因为最后一点,所以我们可以拿它来编程,尤其是生成前端代码。跟字节发布的 AI 编程工具 TRAE 配合使用,效果很好,特别方便全栈开发,个人用户还免费。
下载安装 TRAE 以后,它有两种模式,左上角可以切换:IDE 模型和 SOLO 模型。

选择 IDE 就可以了,SOLO 是 AI 任务的编排器,除非多个任务一起跑,否则用不到。
然后,按下快捷键 Ctrl + U(或者 Command + U),唤出对话框,用来跟 AI 对话。

我们要构建 Web 应用,左上角就选 @Builder 开发模式。右下角的模型就选 Seed-2.0-Code。

可以看到,TRAE 自带的国产开源编程模型很全,都是免费使用。
准备工作这样就差不多了。
我选了一个有点难度的任务,让 Seed 2.0 生成。
ASCII 图形是使用字符画出来的图形,比如下图。

我打算生成一个 Web 应用,用户在网页上输入 ASCII 图形,自动转成 Excalidraw 风格的手绘图形。
提示词如下:
"生成一个 Web 应用,可以将 ASCII 图形转为 Excalidraw 风格的图片,并提供下载。"

模型就开始思考,将这个任务分解为四步。

等到 Seed 2.0 代码生成完毕,TRAE 就会起一个本地服务 localhost:8080,同时打开了预览窗口。

生成的结果还挺有意思,上部的 ASCII 输入框提供了四个示例:Box、Tree、Flowchart、Smiley。下面是 Tree 的样子。

然后是 Excalidraw 参数的控制面板:线宽、粗糙度、弯曲度、字体大小。

点击 Convert(转换)按钮,马上得到手绘风格的线条图。

整个页面就是下面的样子。

这个页面的设计,感觉不是很美观,还可以改进。我打算为 Seed 2.0 加入专门的前端设计技能,使其能够做出更美观的页面。
所谓 Skill(技能),就是一段专门用途的提示词,用来注入上下文。
有时候,提示词很长,每次都输入,就很麻烦。我们可以把反复用到的部分提取出来,保存在一个文件里面,方便重复使用。这种提取出来的提示词,往往是关于如何完成一种任务的详细描述,所以就称为"技能文件"。
格式上,它就是一个 Markdown 文本文件,有一个 YAML 头,包含 name 字段和 description 字段。

name 字段是 Skill 的名称,可以通过这个名称调用该技能;description 字段则是技能的简要描述,模型通过这段描述判断何时自动调用该技能。
有些技能比较复杂,除了描述文件以外,还有专门的脚本文件、资源文件、模板文件等等,相当于一个代码库。

这些文件里面,SKILL.md 是入口文件,模型根据它的描述,了解何时何处调用其他各个文件。
这个库发到网上,就可以与其他人共享。如果你觉得 AI 模型处理任务时,需要用到某种技能,就可以寻找别人已经写好的 Skill 加载到模型。
下面,我使用 Anthropic 公司共享出来的前端设计技能,重构一下前面的页面。它只有单独一个 Markdown 文件,可以下载下来。
打开 TRAE 的"设置/规则和技能"页面。

点击技能部分的"+ 创建"按钮,打开创建技能的窗口。

你可以在这个窗口填写 SKill 内容,也可以上传现成的 Skill 文件。我选择上传,完成后,就可以看到列表里已经有 frontend-design 技能了。

然后,我就用下面的提示词,唤起这个技能来重构页面。
"使用 frontend-design 技能,重构这个页面,让其变得更美观易用,更有专业感。"
下面就是模型给出的文字描述和重构结果。


页面确实感觉变得高大上了!
最后,再看一个技能的例子。
代码生成以后,都是在本地机器上运行,能不能发布到网上,分享给更多的人呢?
回答是只要使用 Vercel 公司的 deploy 技能,就能一个命令将生成结果发布到 Vercel 的机器上。
在 Vercel 官方技能的 GitHub 仓库里,下载 Vercel-deploy 技能的 zip 文件。
然后,把这个 zip 文件拖到 TRAE 的技能窗口里面,就会自动加载了。

输入提示词:"将生成的网站发布到 Vercel"。
模型就会执行 vercel-deploy 技能,将网站发布到 Vercel,最后给出两个链接,一个是预览链接,另一个是发布到你个人账户的链接。

大家现在可以访问这个链接,看看网站的实际效果了。
如果你读到这里,应该会同意我的观点,Seed 2.0 的编程能力相当不错,跟自家的编程工具 TRAE 搭配起来,好用又免费。
Skill 则是强大的能力扩展机制,让模型变得无所不能,一定要学会使用。
(完)
在 React 函数式组件的开发过程中,开发者常会遭遇一种“幽灵般”的状态异常:页面 UI 已经正确响应并更新了最新的状态值,但在 setInterval 定时器、useEffect 异步回调或原生事件监听器中,打印出的变量却始终停滞在初始值。
这种现象通常被误认为是 React 的 Bug,但其本质是 JavaScript 语言核心机制——词法作用域(Lexical Scoping)与 React 函数式组件渲染特性发生冲突的产物。在社区中,这被称为“闭包陷阱”(Stale Closure)或“过期的闭包”。
本文将摒弃表象,从内存模型与执行上下文的角度,剖析这一问题的成因及标准解决方案。
要理解闭包陷阱,必须首先理解两个核心的前置概念:JavaScript 的词法作用域与 React 的快照渲染。
JavaScript 中的函数在定义时,其作用域链就已经确定了。闭包是指函数可以访问其定义时所在作用域中的变量。关键在于:闭包捕获的是函数创建那一刻的变量引用。如果该变量在后续没有发生引用地址的变更(如 const 声明的原始类型),闭包内访问的永远是创建时的那个值。
React 函数组件的每一次渲染(Render),本质上都是一次独立的函数调用。
虽然两次渲染中的变量名相同(例如都叫 count),但在内存中它们是完全不同、互不干扰的独立副本。每次渲染都像是一张“快照”,固定了当时的数据状态。
当我们将 useEffect 的依赖数组设置为空 [] 时,意味着该 Effect 只在组件挂载(Mount)时执行一次。
以下是一个经典的闭包陷阱反面教材。请注意代码注释中的内存快照分析。
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>
);
}
内存行为分析:
针对不同场景,我们有三种标准的架构方案来解决此问题。
遵循 React Hooks 的设计规范,诚实地将所有外部依赖填入依赖数组。
JavaScript
useEffect(() => {
const timer = setInterval(() => {
console.log('Current Count:', count);
}, 1000);
return () => clearInterval(timer);
}, [count]); // 将 count 加入依赖
如果逻辑仅仅是基于旧状态更新新状态,而不需要在副作用中读取状态值,可以使用 setState 的函数式更新。
JavaScript
useEffect(() => {
const timer = setInterval(() => {
// 这里的 c 是 React 内部传入的最新 state,不依赖闭包中的 count
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖依然为空,但逻辑正确
如果必须在 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);
}, []); // 依赖为空,且定时器不会重启
React 闭包陷阱的本质,是持久化的闭包引用了过期的快照变量。
这并非框架设计的缺陷,而是函数式编程模型与 JavaScript 语言特性的必然交汇点。作为架构师,在处理此类问题时应遵循以下建议:
在 React 的组件化架构中,性能优化往往不是一项大刀阔斧的重构工程,而是体现在对每一次渲染周期的精准控制上。作为一名拥有多年实战经验的前端架构师,我见证了无数应用因为忽视了 React 的渲染机制,导致随着业务迭代,页面交互变得愈发迟缓。
本文将深入探讨 React Hooks 中的两个关键性能优化工具:useMemo 和 useCallback。我们将透过现象看本质,理解它们如何解决“全量渲染”的痛点,并剖析实际开发中容易忽视的闭包陷阱。
想象一下,你正在建造一座摩天大楼(你的 React 应用)。每当大楼里的某一个房间(组件)需要重新装修(更新状态)时,整个大楼的施工队都要停下来,把整栋楼从地基到顶层重新刷一遍油漆。这听起来极度荒谬且低效,但在 React 默认的渲染行为中,这往往就是现实。
React 的核心机制是“响应式”的:当父组件的状态发生变化触发更新时,React 会默认递归地重新渲染该组件下的所有子组件。这种“全量渲染”策略保证了 UI 与数据的高度一致性,但在复杂应用中,它带来了不可忽视的性能开销:
性能优化的核心理念在于**“惰性”与“稳定”**:只在必要时进行计算,只在依赖变化时触发重绘。
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)。
useCallback 用于缓存“函数实例本身”。它的作用不是为了减少函数创建的开销(JS 创建函数的开销极小),而是为了保持函数引用地址的稳定性,从而避免下游子组件因为 props 变化而进行无效重渲染。
在 JavaScript 中,函数是引用类型,且 函数 === 对象。
在 React 函数组件中,每次重新渲染(Re-render)都会重新执行组件函数体。这意味着,定义在组件内部的函数(如事件回调)每次都会被重新创建,生成一个新的内存地址。
为了理解这个概念,我们可以通过“咖啡店点单”来比喻:
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);
}, []); // ❌ 依赖项缺失
诚实地填写依赖项(不推荐用于定时器):
将 [count] 加入依赖。但这会导致定时器在每次 count 变化时被清除并重新设定,违背了初衷。
函数式更新(推荐):
如果只是为了设置状态,使用 setState 的回调形式。
JavaScript
// 不需要依赖 count 也能实现累加
setCount(prevCount => prevCount + 1);
使用 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 性能优化的工具箱中,我们必须清晰区分这“三兄弟”的职责:
性能优化并非免费午餐。useMemo 和 useCallback 本身也有内存占用和依赖对比的计算开销。
请遵循以下原则:
掌握了这些原理与最佳实践,你便不再是盲目地编写 Hooks,而是能够像架构师一样,精准控制应用的每一次渲染脉搏。
类似 102. 二叉树的层序遍历,用一个 BFS 模拟香槟溢出流程:第一层溢出的香槟流到第二层,第二层溢出的香槟流到第三层,依此类推。
具体地:
###py
class Solution:
def champagneTower(self, poured: int, queryRow: int, queryGlass: int) -> float:
cur = [float(poured)]
for i in range(1, queryRow + 1):
nxt = [0.0] * (i + 1)
for j, x in enumerate(cur):
if x > 1: # 溢出到下一层
nxt[j] += (x - 1) / 2
nxt[j + 1] += (x - 1) / 2
cur = nxt
return min(cur[queryGlass], 1.0) # 如果溢出,容量是 1
###java
class Solution {
public double champagneTower(int poured, int queryRow, int queryGlass) {
double[] cur = new double[]{(double) poured};
for (int i = 1; i <= queryRow; i++) {
double[] nxt = new double[i + 1];
for (int j = 0; j < cur.length; j++) {
double x = cur[j] - 1;
if (x > 0) { // 溢出到下一层
nxt[j] += x / 2;
nxt[j + 1] += x / 2;
}
}
cur = nxt;
}
return Math.min(cur[queryGlass], 1); // 如果溢出,容量是 1
}
}
###cpp
class Solution {
public:
double champagneTower(int poured, int queryRow, int queryGlass) {
vector<double> cur = {1.0 * poured};
for (int i = 1; i <= queryRow; i++) {
vector<double> nxt(i + 1);
for (int j = 0; j < cur.size(); j++) {
double x = cur[j] - 1;
if (x > 0) { // 溢出到下一层
nxt[j] += x / 2;
nxt[j + 1] += x / 2;
}
}
cur = move(nxt);
}
return min(cur[queryGlass], 1.0); // 如果溢出,容量是 1
}
};
###c
#define MIN(a, b) ((b) < (a) ? (b) : (a))
double champagneTower(int poured, int queryRow, int queryGlass) {
double* cur = malloc(sizeof(double));
cur[0] = poured;
int curSize = 1;
for (int i = 1; i <= queryRow; i++) {
double* nxt = calloc(i + 1, sizeof(double));
for (int j = 0; j < curSize; j++) {
double x = cur[j] - 1;
if (x > 0) { // 溢出到下一层
nxt[j] += x / 2;
nxt[j + 1] += x / 2;
}
}
free(cur);
cur = nxt;
curSize = i + 1;
}
double ans = MIN(cur[queryGlass], 1); // 如果溢出,容量是 1
free(cur);
return ans;
}
###go
func champagneTower(poured, queryRow, queryGlass int) float64 {
cur := []float64{float64(poured)}
for i := 1; i <= queryRow; i++ {
nxt := make([]float64, i+1)
for j, x := range cur {
if x > 1 { // 溢出到下一层
nxt[j] += (x - 1) / 2
nxt[j+1] += (x - 1) / 2
}
}
cur = nxt
}
return min(cur[queryGlass], 1) // 如果溢出,容量是 1
}
###js
var champagneTower = function(poured, queryRow, queryGlass) {
let cur = [poured];
for (let i = 1; i <= queryRow; i++) {
const nxt = Array(i + 1).fill(0);
for (let j = 0; j < cur.length; j++) {
const x = cur[j] - 1;
if (x > 0) { // 溢出到下一层
nxt[j] += x / 2;
nxt[j + 1] += x / 2;
}
}
cur = nxt;
}
return Math.min(cur[queryGlass], 1); // 如果溢出,容量是 1
};
###rust
impl Solution {
pub fn champagne_tower(poured: i32, query_row: i32, query_glass: i32) -> f64 {
let mut cur = vec![poured as f64];
for i in 1..=query_row as usize {
let mut nxt = vec![0.0; i + 1];
for (j, x) in cur.into_iter().enumerate() {
if x > 1.0 { // 溢出到下一层
nxt[j] += (x - 1.0) / 2.0;
nxt[j + 1] += (x - 1.0) / 2.0;
}
}
cur = nxt;
}
cur[query_glass as usize].min(1.0) // 如果溢出,容量是 1
}
}
无需使用两个数组,可以像 0-1 背包那样,在同一个数组上修改。
###py
class Solution:
def champagneTower(self, poured: int, queryRow: int, queryGlass: int) -> float:
f = [0.0] * (queryRow + 1)
f[0] = float(poured)
for i in range(queryRow):
for j in range(i, -1, -1):
x = f[j] - 1
if x > 0:
f[j + 1] += x / 2
f[j] = x / 2
else:
f[j] = 0.0
return min(f[queryGlass], 1.0) # 如果溢出,容量是 1
###java
class Solution {
public double champagneTower(int poured, int queryRow, int queryGlass) {
double[] f = new double[queryRow + 1];
f[0] = poured;
for (int i = 0; i < queryRow; i++) {
for (int j = i; j >= 0; j--) {
double x = f[j] - 1;
if (x > 0) {
f[j + 1] += x / 2;
f[j] = x / 2;
} else {
f[j] = 0;
}
}
}
return Math.min(f[queryGlass], 1); // 如果溢出,容量是 1
}
}
###cpp
class Solution {
public:
double champagneTower(int poured, int queryRow, int queryGlass) {
vector<double> f(queryRow + 1);
f[0] = poured;
for (int i = 0; i < queryRow; i++) {
for (int j = i; j >= 0; j--) {
double x = f[j] - 1;
if (x > 0) {
f[j + 1] += x / 2;
f[j] = x / 2;
} else {
f[j] = 0;
}
}
}
return min(f[queryGlass], 1.0); // 如果溢出,容量是 1
}
};
###c
#define MIN(a, b) ((b) < (a) ? (b) : (a))
double champagneTower(int poured, int queryRow, int queryGlass) {
double* f = calloc(queryRow + 1, sizeof(double));
f[0] = poured;
for (int i = 0; i < queryRow; i++) {
for (int j = i; j >= 0; j--) {
double x = f[j] - 1;
if (x > 0) {
f[j + 1] += x / 2;
f[j] = x / 2;
} else {
f[j] = 0;
}
}
}
double ans = MIN(f[queryGlass], 1); // 如果溢出,容量是 1
free(f);
return ans;
}
###go
func champagneTower(poured, queryRow, queryGlass int) float64 {
f := make([]float64, queryRow+1)
f[0] = float64(poured)
for i := range queryRow {
for j := i; j >= 0; j-- {
x := f[j] - 1
if x > 0 {
f[j+1] += x / 2
f[j] = x / 2
} else {
f[j] = 0
}
}
}
return min(f[queryGlass], 1) // 如果溢出,容量是 1
}
###js
var champagneTower = function(poured, queryRow, queryGlass) {
const f = Array(queryRow + 1).fill(0);
f[0] = poured;
for (let i = 0; i < queryRow; i++) {
for (let j = i; j >= 0; j--) {
const x = f[j] - 1;
if (x > 0) {
f[j + 1] += x / 2;
f[j] = x / 2;
} else {
f[j] = 0;
}
}
}
return Math.min(f[queryGlass], 1); // 如果溢出,容量是 1
};
###rust
impl Solution {
pub fn champagne_tower(poured: i32, query_row: i32, query_glass: i32) -> f64 {
let query_row = query_row as usize;
let mut f = vec![0.0; query_row + 1];
f[0] = poured as f64;
for i in 0..query_row {
for j in (0..=i).rev() {
let x = f[j] - 1.0;
if x > 0.0 {
f[j + 1] += x / 2.0;
f[j] = x / 2.0;
} else {
f[j] = 0.0;
}
}
}
f[query_glass as usize].min(1.0) // 如果溢出,容量是 1
}
}
欢迎关注 B站@灵茶山艾府
我们把玻璃杯摆成金字塔的形状,其中 第一层 有 1 个玻璃杯, 第二层 有 2 个,依次类推到第 100 层,每个玻璃杯将盛有香槟。
从顶层的第一个玻璃杯开始倾倒一些香槟,当顶层的杯子满了,任何溢出的香槟都会立刻等流量的流向左右两侧的玻璃杯。当左右两边的杯子也满了,就会等流量的流向它们左右两边的杯子,依次类推。(当最底层的玻璃杯满了,香槟会流到地板上)
例如,在倾倒一杯香槟后,最顶层的玻璃杯满了。倾倒了两杯香槟后,第二层的两个玻璃杯各自盛放一半的香槟。在倒三杯香槟后,第二层的香槟满了 - 此时总共有三个满的玻璃杯。在倒第四杯后,第三层中间的玻璃杯盛放了一半的香槟,他两边的玻璃杯各自盛放了四分之一的香槟,如下图所示。

现在当倾倒了非负整数杯香槟后,返回第 i 行 j 个玻璃杯所盛放的香槟占玻璃杯容积的比例( i 和 j 都从0开始)。
示例 1: 输入: poured(倾倒香槟总杯数) = 1, query_glass(杯子的位置数) = 1, query_row(行数) = 1 输出: 0.00000 解释: 我们在顶层(下标是(0,0))倒了一杯香槟后,没有溢出,因此所有在顶层以下的玻璃杯都是空的。 示例 2: 输入: poured(倾倒香槟总杯数) = 2, query_glass(杯子的位置数) = 1, query_row(行数) = 1 输出: 0.50000 解释: 我们在顶层(下标是(0,0)倒了两杯香槟后,有一杯量的香槟将从顶层溢出,位于(1,0)的玻璃杯和(1,1)的玻璃杯平分了这一杯香槟,所以每个玻璃杯有一半的香槟。
示例 3:
输入: poured = 100000009, query_row = 33, query_glass = 17 输出: 1.00000
提示:
0 <= poured <= 1090 <= query_glass <= query_row < 100我们创建一个二维数组dp[i][j],其中,i表示行号,j表示酒杯编号。
根据题目描述,我们可以知道,针对于第row行第column列(dp[row][column])的这个酒杯,有机会能够注入到它的“上层”酒杯只会是dp[row-1][column-1]和dp[row-1][column],那么这里是“有机会”,因为只有这两个酒杯都满了(减1)的情况下,才会注入到dp[row][column]这个酒杯中,所以,我们可以得到状态转移方程为:
dp[row][column] = Math.max(dp[row-1][column-1]-1, 0)/2 + Math.max(dp[row-1][column]-1, 0)/2。
那么我们从第一行开始计算,逐一可以计算出每一行中每一个酒杯的容量,那么题目的结果就显而易见了。具体操作,如下图所示:

由于题目只需要获取第query_row行的第query_glass编号的酒杯容量,那么我们其实只需要关注第query_row行的酒杯容量即可,所以,用一维数组dp[]来保存最新计算的那个行中每个酒杯的容量。
计算方式与上面的解法相似,此处就不赘述了。
###java
class Solution {
public double champagneTower(int poured, int query_row, int query_glass) {
double[][] dp = new double[query_row + 2][query_row + 2];
dp[1][1] = poured; // 为了方式越界,下标(0,0)的酒杯我们存放在dp[1][1]的位置上
for (int row = 2; row <= query_row + 1; row++) {
for (int column = 1; column <= row; column++) {
dp[row][column] = Math.max(dp[row - 1][column - 1] - 1, 0) / 2 + Math.max(dp[row - 1][column] - 1, 0) / 2;
}
}
return Math.min(dp[query_row + 1][query_glass + 1], 1);
}
}

###java
class Solution {
public double champagneTower(int poured, int query_row, int query_glass) {
double[] dp = new double[query_glass + 2]; // 第i层中每个glass的容量
dp[0] = poured; // 第0层的第0个编号酒杯倾倒香槟容量
int row = 0;
while (row < query_row) { // 获取第query_row行,只需要遍历到第query_row减1行即可。
for (int glass = Math.min(row, query_glass); glass >= 0; glass--) {
double overflow = Math.max(dp[glass] - 1, 0) / 2.0;
dp[glass] = overflow; // 覆盖掉旧值
dp[glass + 1] += overflow; // 由于是倒序遍历,所以对于dp[glass + 1]要执行“+=”操作
}
row++; // 计算下一行
}
return Math.min(dp[query_glass], 1); // 如果倾倒香槟容量大于1,则只返回1.
}
}

今天的文章内容就这些了:
写作不易,笔者几个小时甚至数天完成的一篇文章,只愿换来您几秒钟的 点赞 & 分享 。
更多技术干货,欢迎大家关注公众号“爪哇缪斯” ~ \(^o^)/ ~ 「干货分享,每天更新」
为了方便,我们令 poured 为 k,query_row 和 query_glass 分别为 $n$ 和 $m$。
定义 $f[i][j]$ 为第 $i$ 行第 $j$ 列杯子所经过的水的流量(而不是最终剩余的水量)。
起始我们有 $f[0][0] = k$,最终答案为 $\min(f[n][m], 1)$。
不失一般性考虑 $f[i][j]$ 能够更新哪些状态:显然当 $f[i][j]$ 不足 $1$ 的时候,不会有水从杯子里溢出,即 $f[i][j]$ 将不能更新其他状态;当 $f[i][j]$ 大于 $1$ 时,将会有 $f[i][j] - 1$ 的水会等量留到下一行的杯子里,所流向的杯子分别是「第 $i + 1$ 行第 $j$ 列的杯子」和「第 $i + 1$ 行第 $j + 1$ 列的杯子」,增加流量均为 $\frac{f[i][j] - 1}{2}$,即有 $f[i + 1][j] += \frac{f[i][j] - 1}{2}$ 和 $f[i + 1][j + 1] += \frac{f[i][j] - 1}{2}$。
代码:
###Java
class Solution {
public double champagneTower(int k, int n, int m) {
double[][] f = new double[n + 10][n + 10];
f[0][0] = k;
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= i; j++) {
if (f[i][j] <= 1) continue;
f[i + 1][j] += (f[i][j] - 1) / 2;
f[i + 1][j + 1] += (f[i][j] - 1) / 2;
}
}
return Math.min(f[n][m], 1);
}
}
###TypeScript
function champagneTower(k: number, n: number, m: number): number {
const f = new Array<Array<number>>()
for (let i = 0; i < n + 10; i++) f.push(new Array<number>(n + 10).fill(0))
f[0][0] = k
for (let i = 0; i <= n; i++) {
for (let j = 0; j <= i; j++) {
if (f[i][j] <= 1) continue
f[i + 1][j] += (f[i][j] - 1) / 2
f[i + 1][j + 1] += (f[i][j] - 1) / 2
}
}
return Math.min(f[n][m], 1)
}
###Python3
class Solution:
def champagneTower(self, k: int, n: int, m: int) -> float:
f = [[0] * (n + 10) for _ in range(n + 10)]
f[0][0] = k
for i in range(n + 1):
for j in range(i + 1):
if f[i][j] <= 1:
continue
f[i + 1][j] += (f[i][j] - 1) / 2
f[i + 1][j + 1] += (f[i][j] - 1) / 2
return min(f[n][m], 1)
如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/
也欢迎你 关注我,提供写「证明」&「思路」的高质量题解。
所有题解已经加入 刷题指南,欢迎 star 哦 ~
<!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 内容动态更新 的方式,模拟了类似机械翻页牌的效果。下面从结构、样式和逻辑三方面详细分析其实现原理:
每个时间单位(小时、分钟、秒)由一个 .card-container 容器表示,内部包含 4 个 .card-item 元素:
<div class="card-container" id="second">
<div class="card1">00</div> <!-- 下层上半 -->
<div class="card2">00</div> <!-- 下层下半(初始翻转180°)-->
<div class="card3">00</div> <!-- 上层上半(当前显示)-->
<div class="card4">00</div> <!-- 上层下半 -->
</div>
.card3 和 .card4:组成当前显示的数字(上半+下半),正常显示。.card1 和 .card2:组成即将翻出的新数字,但初始时 .card2 被 rotateX(180deg) 翻转到背面(不可见)。::before 伪元素作为“折痕”,增强翻页视觉效果。.card-container {
perspective: 500px; /* 创建 3D 视角 */
}
perspective 让子元素的 3D 变换有景深感。.card2 {
transform-origin: center top;
transform: rotateX(180deg); /* 初始翻到背面 */
}
.card3 {
transform-origin: center bottom;
}
.card2 绕顶部边缘旋转 180°,藏在下方背面。.card3 绕底部边缘旋转,用于向上翻折。.flip 类触发)
.flip .card2 {
transform: rotateX(0deg); /* 展开新数字下半部分 */
}
.flip .card3 {
transform: rotateX(-180deg); /* 当前数字上半部分向上翻折隐藏 */
}
0.4s,使用 ease-in-out 缓动。.card1 和 .card4 始终保持静态,作为背景支撑。✅ 视觉效果:
- 上半部分(
.card3)向上翻走(像书页翻开)- 下半部分(
.card2)从背面转正,露出新数字- 中间的“折痕”让翻页更真实
setHTML(dom, nextValue)
.card1 和 .card2 显示 nextValue
.card3 和 .card4 显示 curValue
dom.classList.remove('flip');
void dom.offsetWidth; // 强制浏览器重排(关键!)
dom.classList.add('flip');
.flip,再强制重排(flush styles),再加回 .flip,确保动画重新触发。data-number 保存当前值。updateClock(),获取当前时分秒(两位数格式)。setHTML 更新三个容器。| 元素 | 初始状态 | 翻页后状态 | 视觉作用 |
|---|---|---|---|
.card3 |
显示旧数字上半 | 向上翻转 180° 隐藏 | 模拟“翻走”的上半页 |
.card2 |
旧数字下半(翻转180°藏起) | 转正显示新数字下半 | 模拟“翻出”的下半页 |
.card1 / .card4
|
静态背景 | 不变 | 提供视觉连续性 |
💡 关键技巧:
- 利用 两个完整数字(新+旧)叠加,通过控制上下半部分的旋转,制造“翻页”而非“淡入淡出”。
- 强制重排(
offsetWidth) 是确保 CSS 动画每次都能重新触发的经典 hack。
这个 Flip Clock 的精妙之处在于:
rotateX + transform-origin 实现真实翻页。这是一种典型的 “用 2D DOM 模拟 3D 物理效果” 的前端动画范例,既高效又视觉惊艳。