阅读视图

发现新文章,点击刷新页面。

每日一题-二进制求和🟢

给你两个二进制字符串 ab ,以二进制字符串的形式返回它们的和。

 

示例 1:

输入:a = "11", b = "1"
输出:"100"

示例 2:

输入:a = "1010", b = "1011"
输出:"10101"

 

提示:

  • 1 <= a.length, b.length <= 104
  • ab 仅由字符 '0''1' 组成
  • 字符串如果不是 "0" ,就不含前导零

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

前言

无论你是刚学 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 闭包陷阱详解🎯

🔍 什么是闭包陷阱

在 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后端第一梯队呢?

学习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 非受控组件:一篇吃透表单处理精髓》

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 极简实战:解决跨层级通信》

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的液态玻璃效果

前言

在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的音乐播放器应用,专注视频转歌和本地歌曲播放)欢迎讨论交流!

模拟加法计算过程(Python/Java/C++/C/Go/JS/Rust)

二进制的加法怎么算?

和十进制的加法一样,从低到高(从右往左)计算。

示例 1 是 $11+1$,计算过程如下:

  1. 计算最低位,$1+1=10$,所以答案的最低位填 $0$,进位是 $1$。
  2. 计算下一位,$a$ 中的 $1$ 和进位的 $1$ 相加,$1+1=10$,所以答案的下一位填 $0$,进位是 $1$。现在答案是 $00$。
  3. 虽然 $a$ 和 $b$ 都遍历完了,但计算并未结束。还剩下进位 $1$,填入答案的下一位(从低到高第三位)。最终答案为 $100$。

由此可见,需要在计算过程中维护进位值 $\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) }
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\max(n,m))$,其中 $n$ 是 $a$ 的长度,$m$ 是 $b$ 的长度。
  • 空间复杂度:$\mathcal{O}(\max(n,m))$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 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 中:

  • 如果字符串超过 $33$ 位,不能转化为 Integer
  • 如果字符串超过 $65$ 位,不能转化为 Long
  • 如果字符串超过 $500000001$ 位,不能转化为 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| }$。

  • 时间复杂度:$O(n)$,这里的时间复杂度来源于顺序遍历 $a$ 和 $b$。
  • 空间复杂度:$O(1)$,除去答案所占用的空间,这里使用了常数个临时变量。

方法二:位运算

思路和算法

如果不允许使用加减乘除,则可以使用位运算替代上述运算中的一些加减乘除的操作。

如果不了解位运算,可以先了解位运算并尝试练习以下题目:

我们可以设计这样的算法来计算:

  • 把 $a$ 和 $b$ 转换成整型数字 $x$ 和 $y$,在接下来的过程中,$x$ 保存结果,$y$ 保存进位。
  • 当进位不为 $0$ 时
    • 计算当前 $x$ 和 $y$ 的无进位相加结果:answer = x ^ y
    • 计算当前 $x$ 和 $y$ 的进位:carry = (x & y) << 1
    • 完成本次循环,更新 x = answery = carry
  • 返回 $x$ 的二进制形式

为什么这个方法是可行的呢?在第一轮计算中,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()
    }
}

复杂度分析

  • 时间复杂度:$O(|a| + |b| + X \cdot \max ({|a| + |b|}))$,字符串转化成数字需要的时间代价为 $O(|a| + |b|)$,计算的时间代价为 $O(\max { |a|, |b| })$,$X$ 为位运算所需的时间,因为这里用到了高精度计算,所以位运算的时间不一定为 $O(1)$。
  • 空间复杂度:这里使用了 $x$ 和 $y$ 来保存 $a$ 和 $b$ 的整数形式,如果用 Python 实现,这里用到了 Python 的高精度功能,实际的空间代价是 $O(|a| + |b|)$。

画解算法:67. 二进制求和

解题思路

整体思路是将两个字符串较短的用 $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('');
};

画解

<frame_00001.png,frame_00002.png,frame_00003.png>

想看大鹏画解更多高频面试题,欢迎阅读大鹏的 LeetBook:《画解剑指 Offer 》,O(∩_∩)O

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

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

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

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

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

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

字节全家桶 Seed 2.0 + TRAE 玩转 Skill

一、引言

国产大模型之中,字节是一个异类。

不像其他大模型轰轰烈烈、争夺眼球,它更低调,不引人注目。

但是,它做的事情反倒最多,大模型、Agent、开发工具、云服务都有独立品牌,遍地开花,一个都不缺,都在高速推进。

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

今天,我就用字节的全家桶 ---- 刚刚发布的 Seed 2.0 模型和开发工具 TRAE ---- 写一篇 Skill 教程。

大家会看到,它们组合起来既强大,又简单好用,(个人用户)还免费。这也是我想写的原因,让大家知道有这个方案。

只要十分钟,读完这篇教程,你还会明白 Skill 是什么,怎么用,以及为什么一定要用它。

二、Seed 2.0 简介

先介绍 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 的准备工作

下载安装 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(转换)按钮,马上得到手绘风格的线条图。

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

六、Skill 简介

这个页面的设计,感觉不是很美观,还可以改进。我打算为 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 公司的 deploy 技能,就能一个命令将生成结果发布到 Vercel 的机器上。

在 Vercel 官方技能的 GitHub 仓库里,下载 Vercel-deploy 技能的 zip 文件。

然后,把这个 zip 文件拖到 TRAE 的技能窗口里面,就会自动加载了。

输入提示词:"将生成的网站发布到 Vercel"。

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

大家现在可以访问这个链接,看看网站的实际效果了。

九、总结

如果你读到这里,应该会同意我的观点,Seed 2.0 的编程能力相当不错,跟自家的编程工具 TRAE 搭配起来,好用又免费。

Skill 则是强大的能力扩展机制,让模型变得无所不能,一定要学会使用。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2026年2月14日

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

在 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 的原理与最佳实践

在 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,而是能够像架构师一样,精准控制应用的每一次渲染脉搏。

BFS 模拟 + 优化(Python/Java/C++/C/Go/JS/Rust)

类似 102. 二叉树的层序遍历,用一个 BFS 模拟香槟溢出流程:第一层溢出的香槟流到第二层,第二层溢出的香槟流到第三层,依此类推。

具体地:

  • 处理第一层到第二层,从 $(0,0)$ 溢出的香槟流到 $(1,0)$ 和 $(1,1)$。设溢出的香槟量为 $x$,那么 $(1,0)$ 和 $(1,1)$ 的香槟量都增加 $\dfrac{x}{2}$。
  • 处理第二层到第三层,从 $(1,0)$ 溢出的香槟流到 $(2,0)$ 和 $(2,1)$,从 $(1,1)$ 溢出的香槟流到 $(2,1)$ 和 $(2,2)$。
  • 依此类推。一般地,从 $(i,j)$ 溢出的香槟流到 $(i+1,j)$ 和 $(i+1,j+1)$。设溢出的香槟量为 $x$,那么下一层的 $j$ 和 $j+1$ 的香槟量都增加 $\dfrac{x}{2}$。用一个数组保存下一层每个玻璃杯的香槟量。

优化前

###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
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\textit{queryRow}^2)$。
  • 空间复杂度:$\mathcal{O}(\textit{queryRow})$。

相似题目

118. 杨辉三角

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

每日一题-香槟塔🟡

我们把玻璃杯摆成金字塔的形状,其中 第一层 有 1 个玻璃杯, 第二层 有 2 个,依次类推到第 100 层,每个玻璃杯将盛有香槟。

从顶层的第一个玻璃杯开始倾倒一些香槟,当顶层的杯子满了,任何溢出的香槟都会立刻等流量的流向左右两侧的玻璃杯。当左右两边的杯子也满了,就会等流量的流向它们左右两边的杯子,依次类推。(当最底层的玻璃杯满了,香槟会流到地板上)

例如,在倾倒一杯香槟后,最顶层的玻璃杯满了。倾倒了两杯香槟后,第二层的两个玻璃杯各自盛放一半的香槟。在倒三杯香槟后,第二层的香槟满了 - 此时总共有三个满的玻璃杯。在倒第四杯后,第三层中间的玻璃杯盛放了一半的香槟,他两边的玻璃杯各自盛放了四分之一的香槟,如下图所示。

现在当倾倒了非负整数杯香槟后,返回第 ij 个玻璃杯所盛放的香槟占玻璃杯容积的比例( ij 都从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 <= 109
  • 0 <= query_glass <= query_row < 100

【爪哇缪斯】图解LeetCode

解题思路

1> 采用二维dp[][]计算

我们创建一个二维数组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

那么我们从第一行开始计算,逐一可以计算出每一行中每一个酒杯的容量,那么题目的结果就显而易见了。具体操作,如下图所示:

image.png

2> 采用一维dp[]计算

由于题目只需要获取第query_row行的第query_glass编号的酒杯容量,那么我们其实只需要关注第query_row行的酒杯容量即可,所以,用一维数组dp[]来保存最新计算的那个行中每个酒杯的容量。

计算方式与上面的解法相似,此处就不赘述了。

代码实现

1> 采用二维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);
    }
}

image.png

2> 采用一维dp[]计算

###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.
    }
}

image.png

今天的文章内容就这些了:

写作不易,笔者几个小时甚至数天完成的一篇文章,只愿换来您几秒钟的 点赞 & 分享

更多技术干货,欢迎大家关注公众号“爪哇缪斯” ~ \(^o^)/ ~ 「干货分享,每天更新」

【宫水三叶】简单线性 DP 运用题

线性 DP

为了方便,我们令 pouredkquery_rowquery_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)
  • 时间复杂度:$O(n^2)$
  • 空间复杂度:$O(n^2)$

最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

html翻页时钟 效果

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Flip Clock</title>
  <style>
    body {
      background: #111;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      margin: 0;
      font-family: 'Courier New', monospace;
      color: white;
    }

    .clock {
      display: flex;
      gap: 20px;
    }

    .card-container {
      width: 80px;
      height: 120px;
      position: relative;
      perspective: 500px;
      background: #2c292c;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.5);
    }

    /* 中间分割线 */
    .card-container::before {
      content: "";
      position: absolute;
      left: 0;
      top: 50%;
      width: 100%;
      height: 4px;
      background: #120f12;
      z-index: 10;
    }

    .card-item {
      position: absolute;
      width: 100%;
      height: 50%;
      left: 0;
      overflow: hidden;
      background: #2c292c;
      color: white;
      text-align: center;
      font-size: 64px;
      font-weight: bold;
      backface-visibility: hidden;
      transition: transform 0.4s ease-in-out;
    }

    /* 下层数字:初始对折(背面朝上) */
    .card1 { /* 下层上半 */
      top: 0;
      line-height: 120px; /* 整体高度对齐 */
    }
    .card2 { /* 下层下半 */
      top: 50%;
      line-height: 0;
      transform-origin: center top;
      transform: rotateX(180deg);
      z-index: 2;
    }

    /* 上层数字:当前显示 */
    .card3 { /* 上层上半 */
      top: 0;
      line-height: 120px;
      transform-origin: center bottom;
      z-index: 3;
    }
    .card4 { /* 上层下半 */
      top: 50%;
      line-height: 0;
      z-index: 1;
    }

    /* 翻页动画触发 */
    .flip .card2 {
      transform: rotateX(0deg);
    }
    .flip .card3 {
      transform: rotateX(-180deg);
    }

    /* 冒号分隔符 */
    .colon {
      font-size: 64px;
      display: flex;
      align-items: center;
      color: #aaa;
    }
  </style>
</head>
<body>
  <div class="clock">
    <div class="card-container flip" id="hour" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>

    <div class="colon">:</div>

    <div class="card-container flip" id="minute" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>

    <div class="colon">:</div>

    <div class="card-container flip" id="second" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>
  </div>

  <script>
    function setHTML(dom, nextValue) {
      const curValue = dom.dataset.number;
      if (nextValue === curValue) return;

      // 更新 DOM 结构:下层为新值,上层为旧值
      dom.innerHTML = `
        <div class="card1 card-item">${nextValue}</div>
        <div class="card2 card-item">${nextValue}</div>
        <div class="card3 card-item">${curValue}</div>
        <div class="card4 card-item">${curValue}</div>
      `;

      // 触发重绘以重启动画
      dom.classList.remove('flip');
      void dom.offsetWidth; // 强制重排
      dom.classList.add('flip');

      dom.dataset.number = nextValue;
    }

    function updateClock() {
      const now = new Date();
      const h = String(now.getHours()).padStart(2, '0');
      const m = String(now.getMinutes()).padStart(2, '0');
      const s = String(now.getSeconds()).padStart(2, '0');

      setHTML(document.getElementById('hour'), h);
      setHTML(document.getElementById('minute'), m);
      setHTML(document.getElementById('second'), s);
    }

    // 初始化
    updateClock();
// setTimeout(updateClock,1000)

    setInterval(updateClock, 1000);
  </script>
</body>
</html>

在这里插入图片描述 这个翻页时钟(Flip Clock)通过 CSS 3D 变换 + 动画类切换 + DOM 内容动态更新 的方式,模拟了类似机械翻页牌的效果。下面从结构、样式和逻辑三方面详细分析其实现原理:


🔧 一、HTML 结构设计

每个时间单位(小时、分钟、秒)由一个 .card-container 容器表示,内部包含 4 个 .card-item 元素

<div class="card-container" id="second">
  <div class="card1">00</div> <!-- 下层上半 -->
  <div class="card2">00</div> <!-- 下层下半(初始翻转180°)-->
  <div class="card3">00</div> <!-- 上层上半(当前显示)-->
  <div class="card4">00</div> <!-- 上层下半 -->
</div>

四个卡片的作用:

  • .card3.card4:组成当前显示的数字(上半+下半),正常显示。
  • .card1.card2:组成即将翻出的新数字,但初始时 .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 物理效果” 的前端动画范例,既高效又视觉惊艳。

❌