阅读视图
国家统计局:1—11月份计算机、通信和其他电子设备制造业利润同比增长15.0%
国家统计局:1—11月份全国规模以上工业企业实现利润总额66268.6亿元,同比增长0.1%
JavaScript 有哪些数据类型?它们在内存里是怎么存的?
有时候对 JavaScript 的代码很是疑惑,我写过这样一段代码:
let user1 = { name: '小明' };
let user2 = user1;
user2.name = '小红';
console.log(user1.name); // 结果居然是 “小红”?
我当时愣住了:我只是改了 user2,为什么 user1 也跟着变了?
而换成数字时,却完全不是这样:
let a = 10;
let b = a;
b = 20;
console.log(a); // 还是 10,没变
明明都是赋值,行为怎么差这么多?
后来我才明白,问题的关键并不是代码的写法问题,而是在 JavaScript 的数据类型和它们在内存中的存储方式。
搞懂这一点,很多看似奇怪的行为就都顺理成章了。
一、JavaScript 有几种数据类型?
根据 ECMAScript 标准,JavaScript 的数据类型分为两大类:
1. 原始类型——共 7 种
这些类型的特点是:值不可变、直接表示数据本身。
| 类型 | 示例 | 说明 |
|---|---|---|
number |
42, 3.14, NaN
|
所有数字,包括整数、浮点数、特殊值 |
string |
'hello', "JS"
|
字符串,不可变 |
boolean |
true, false
|
布尔值 |
undefined |
let a; console.log(a); |
变量已声明但未赋值 |
null |
let b = null; |
表示空或无值(注意:它是原始类型!) |
symbol |
Symbol('id') |
ES6 引入,唯一且不可变的标识符 |
bigint |
123n, 9007199254740991n
|
ES2020 引入,用于表示任意大的整数 |
typeof null 返回 "object" 是 JavaScript 的一个历史性 bug,至今未修复。但从语言设计上,null 属于原始类型。
2. 引用类型——统称对象
除了上述 7 种,其他所有值都是对象,属于引用类型,包括:
- 普通对象:
{ name: 'Tom' } - 数组:
[1, 2, 3] - 函数:
function() {} - 日期:
new Date() - 正则:
/abc/ - 甚至
Map、Set、Promise等
引用类型的核心特点是:可变、通过引用来访问实际数据。
二、它们在内存中怎么存储?
这是理解一切行为差异的根源!
计算机内存大致分为两个区域:
- 栈(Stack):速度快,空间小,用于存储简单、固定大小的数据。
- 堆(Heap):空间大,速度稍慢,用于存储复杂、动态大小的数据。
原始类型:直接存在栈里
let age = 25;
let isStudent = true;
这两个变量就像两个小抽屉,值本身直接放在栈中:
栈(Stack)
┌─────────────────┐
│ age: 25 │
│ isStudent: true │
└─────────────────┘
当你复制一个原始值:
let myAge = age; // 把 25 复制一份
myAge = 30;
console.log(age); // 仍然是 25
因为 myAge 拿到的是 全新的副本,和 age 完全无关。
所以原始类型是按值进行传递(Pass by Value)。
引用类型:栈存地址,堆存真实对象
let user = { name: 'Alice', age: 25 };
这时发生了两件事:
- 对象
{ name: 'Alice', age: 25 }被创建,存放在堆中。 - 变量
user并不直接包含这个对象,而是保存一个指向堆中对象的引用地址,这个地址放在栈里。
图示如下:
栈(Stack) 堆(Heap)
┌──────────────┐ ┌──────────────────────────┐
│ user: [地址A] ├─────→│ { name: 'Alice', age: 25 } │
└──────────────┘ └──────────────────────────┘
当你赋值给另一个变量:
let admin = user; // 复制的是地址,不是对象!
admin.name = 'Bob';
console.log(user.name); // 输出 "Bob"!
因为 admin 和 user 指向同一个堆中的对象:
栈(Stack) 堆(Heap)
┌──────────────┐
│ user: [地址A] ├──┐
├──────────────┤ │ ┌──────────────────────────┐
│ admin: [地址A] ├──┼──→│ { name: 'Bob', age: 25 } │
└──────────────┘ │ └──────────────────────────┘
│
└───┘
所以引用类型是按共享的方式进行传递(Pass by Sharing)
注意:不是按引用传递,JS 中没有真正的按引用传递
三、深入对比
| 特性 | 原始类型 | 引用类型(对象) |
|---|---|---|
| 存储位置 | 栈(Stack) | 栈(存引用地址) + 堆(存实际对象) |
| 赋值行为 | 复制值本身 | 复制引用地址(多个变量共享同一对象) |
| 是否可变 | 不可变(操作生成新值) | 可变(可直接修改属性) |
| 比较方式 | 比较值是否相等 | 比较引用地址是否相同 |
| 内存占用 | 固定、较小 | 动态、可能很大 |
| 典型场景 | 数字、字符串、布尔值 | 对象、数组、函数 |
举个比较的例子:
// 原始类型比较
5 === 5; // true
'hi' === 'hi'; // true
// 引用类型比较
{} === {}; // false!两个不同对象
let a = {}; let b = a;
a === b; // true!同一个对象
四、常见问题
为什么字符串很长,也算原始类型?
虽然字符串可能很长,但 JS 引擎会做内部优化(比如使用指针),但从语言语义上,字符串是不可变的原始值。任何“修改”都会生成新字符串。
let s = 'hello';
s = s + ' world'; // 创建了新字符串,原 'hello' 未被修改
如何真正复制一个对象,而不是共享?
浅拷贝:只复制第一层属性
let newObj = { ...oldObj };
// 或
let newObj = Object.assign({}, oldObj);
深拷贝:递归复制所有层级(需注意循环引用等问题)
let deepCopy = JSON.parse(JSON.stringify(obj)); // 有局限(不能拷贝函数、undefined 等)
// 推荐使用 Lodash 的 _.cloneDeep()
五、总结
- 原始类型:值直接存在栈里,赋值是复制值,互不影响。
- 引用类型:栈里存地址,堆里存对象,赋值是复制地址,共享同一对象。
- 理解存储机制,是掌握 JS 赋值、函数传参、状态管理、性能优化的基础!
下次当你看到:
let a = obj;
a.x = 10;
console.log(obj.x); // 为什么变了?
你就知道:因为 a 和 obj 指向的是同一个对象!
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《代码里全是 new 对象,真的很 Low 吗?我认真想了一晚》
《Java 开发必看:什么时候用 for,什么时候用 Stream?》
美丽废物还是年轻人潮品?一款迷你AI手机靠情绪价值众筹千万|焦点分析
作者丨欧雪
编辑丨袁斯来
看上去,10年前,手机行业就对创业公司关上了大门。
但2025年,一家来自香港、毫无名气的公司,做了款AI手机,在kickstarter上众筹突破1150万港元。
它没有主流手机的大屏幕设计,反而采用了一块4.02英寸的正方形AMOLED显示屏,整体尺寸仅相当于传统手机的一半。此外,它的摄像头可以实现前后翻转,还能安装全键盘保护壳。
显然,这是一款轻配置、重巧思的产品。
IKKO的切入点,很符合当下的时代潮流——从做通用品转向挖掘利基用户。一些营销触觉敏锐的年轻团队,反而在点状爆发。
在手机行业,AI虽然是智能手机的标配,但并没有坚实的落地场景。用户能体验的无非是语音助手、录音整理等功能。行业早期,大厂也没能建立起某种行业规则。
这也是创业公司的机会。他们并不需要发布什么惊动世界的功能,而是找到某些细节做加成,就能拿下部分用户。
当然,一家硬件公司要生存下去,需要接连不断推出爆品,并保证持续、稳定交付。IKKO轻巧迈出第一步,但用户消耗完最初的好奇后,取胜的永远是产品本身。
更小更灵活的AI手机
在业内很多人看来,iKKO Mind One Pro的出圈,是精准踩中了“情绪价值”的红利。
在智能手机设计日趋同质化的当下,iKKO Mind One Pro以其独特的正方形卡片设计脱颖而出。
这款设备尺寸仅为86×72×8.9毫米,配备4.02英寸方形AMOLED屏幕,体积约相当于传统智能手机的一半,挑战了大屏当道的行业共识。
此外,它并不试图在4英寸屏幕上复制旗舰机功能,定位更像是“主力机的延伸与补位”。
比较创新性的设计是,IKKO一颗5000万像素的180度翻转索尼主摄像头,同一枚镜头既当后摄,也可一键变为前摄,这意味着自拍也能用上主摄的全部实力。对于小尺寸设备来说,这可以弥补前摄体验弱的共性短板。
系统方面,iKKO Mind One Pro采用了双系统设计——内置Android 15负责完整应用生态,iKKO AI OS则是一个更轻的“副系统”,用户可通过实体键快速切换。其中,AI OS主打专注模式,避免干扰,并内置翻译、语音转录、摘要等AI工具。
这是很聪明的聚焦。
目前主流AI手机的功能主要围绕影像增强、语音助手和系统优化三大领域展开。例如,OPPO的“一键闪记”可自动提取聊天关键信息并分类提醒;荣耀的YOYO主要面向生活场景,比如自动完成打车、领券等任务。
相比之下,iKKO Mind One Pro的AI路径更为聚焦。其双系统设计让用户能在常规模式和专注模式间一键切换,后者会限制通知和社交应用,形成无干扰的工作环境,是个非常垂直的场景。
在商业模式上,iKKO也展现出与主流厂商不同的灵活路径。目前安卓厂商除了硬件利润之外,主要靠应用市场抽成和周边硬件挣钱。iKKO延续类似思路,但更轻巧。它的Pro版本内置vSIM技术,在60多国提供专供AI功能使用的免费数据服务,实质上将网络成本纳入硬件售价,并为后续订阅服务预留了空间。
整体来看,虽然“硬件免费、服务盈利”模式在手机行业整体难以走通,但针对iKKO这样的便携设备细分市场,软硬一体化的订阅制仍具想象空间。
时尚配件还是实用工具?
IKKO Mind One Pro在引发业界好奇的同时,也面临着诸多质疑。
“它更像是一个时尚配件而非实用工具。”这是许多网络用户对它的评价。
在性能普遍过剩的当下,iKKO的配置落后了几代。其使用的联发科Helio G99处理器,还是2022年的产品,主要用在低端机上。而且,连千元机电池容量都到达6000毫安时,iKKO的电池不过2200毫安。这种配置,甚至无法作为日常主力机使用。
不少消费者对iKKO宣传的“免费全球网络”也存有疑虑。细究众筹平台上的条款不难发现,这一服务仅限于AI功能使用,常规上网、视频流媒体等流量密集型应用仍需依赖传统SIM卡。
而在AI功能的独特性方面,iKKO面临更大挑战。
其主打的实时翻译、语音转写等功能,目前在主流品牌手机上均已实现,且体验不断优化。随着巨头们将AI能力作为底层基础设施深度融入系统,一个独立硬件品牌在AI应用层打造差异化优势的难度正变得越来越高。
实际上,随着AI手机市场的不断成熟,分化已成为必然趋势。
一方面,主流厂商将继续推动AI与系统的深度融合。荣耀的生态战略、OPPO的智能体框架,都指向同一个方向:让AI成为操作系统的底层能力,而非表层功能。
另一方面,细分市场的创新不断涌现。iKKO代表的“智能伴侣”等方向,都会持续吸引小众玩家加入。
在技术战场上与大厂正面对决,小众品牌难有胜算。真正的机会恰恰存在于技术之外——那些尚未被满足的外观设计与情感需求,正成为它们的突破口。
未来几年,AI手机市场有望形成双层结构:顶层是苹果、三星与主流国产厂商的生态系统之争,底层则聚集了众多小众品牌的场景化创新。
对iKKO这类品牌而言,核心挑战在于如何平衡创新与实用、小众与规模。在成功验证产品概念后,它们逃不开性能、续航与生态建设的试炼场。
实际上,很多人认为,iKKO Mind One Pro让人们想起了早期的黑莓手机——虽不完美,但足够独特。这也表示,AI手机的未来,不只有更强大的芯片和更复杂的算法,还有对“设备如何真正融入生活”的重新思考。
“太空小鼠”返回地面后成功孕育幼仔
中国ETF规模达6.03万亿再创历史新高,年内增超6成
国际金银续创新高,白银本周累涨约18%
美股三大指数集体微跌,热门中概股多数上涨
9点1氪丨小米17 Ultra徕卡版被炒至2万元;周大福黄金吊坠被指阴阳打工人;和睦家医院回应女明星生产信息疑被泄露
今日热点导览
LV回应女子穿10分钟新鞋双脚受伤
抖音上线长图文功能
小米已申请多项Ultra系列相关知识产权
紫燕百味鸡致歉:10倍现金补偿消费者
小鹏正式进入毛里求斯市场
TOP 3大新闻
小米17 Ultra徕卡版被炒至最高2万元,顶配版多渠道缺货
12月26日,界面新闻获悉,发布不到一天的小米17 Ultra徕卡版,在预售开启后数小时内线上全渠道售罄,二手平台随即出现加价炒作现象,顶配版本报价最高达2万元,而官方定价为8999元起。
12月25日发布会结束后,小米17 Ultra徕卡版同步在官方商城、授权电商平台开启预售。截至25日22时30分,上述渠道各配置版本均显示“暂无库存”,京东平台相关预约量已突破百万量级。
目前小米17 Ultra徕卡版16GB+512GB版本在其天猫官方旗舰店正常预售,但16GB+1TB顶配版处于缺货状态。而在京东平台,无论是16GB+512GB版本还是16GB+1TB版,均处于无货状态。在小米官网,7999元的16GB+512GB版有现货可买,8999元的16GB+1TB版同样处于缺货状态。(界面新闻)
周大福回应牛马吊坠被指阴阳打工人
近日,广东一网友在社媒发帖吐槽周大福“牛马”方圆牌黄金吊坠,直言 “收了这个你就是金牌牛马”,此帖随即引发热议。12月26日,周大福官方客服表示,该款 “牛马” 黄金吊坠目前仅在线上渠道销售,近期销量较火;若顾客对款式有异议,会记录并向上反馈,后续会不断优化。此外,周大福某电商平台客服表示,该款产品寓意为不怕困难、好运“jia”倍,吊坠上“牛马”字样是繁体字。目前,该款产品官方小程序售价3044.7元起。(密度财经)
图源:网络
女明星生产信息疑被泄露?和睦家医院回应
12月26日,和睦家医院向界面新闻回应日前女明星生产信息疑似被泄露一事,称“无医疗纠纷或异议,我院没有医护人员参与泄露任何患者隐私的行为”。
日前,网传聊天截图称,一女明星在和睦家生产,并提及孩子健康状况不佳、转院等细节信息。从截图判断信息来自医护人员。因而有声音质疑和睦家工作人员泄露患者隐私,另有声音对和睦家产检结果产生疑问。
和睦家医疗成立近30年,以高端妇产科业务起家,后发展成综合型医院,现为国内知名高端私立医疗机构。其与公立医疗机构互为补充,以服务体验、高值药械等吸引患者,成为不少名人、高收入家庭的选择。(新京报)
大公司/大事件
小米已申请多项Ultra系列相关知识产权
36氪获悉,爱企查App显示,小米科技有限责任公司已申请多枚“XIAOMI ULTRA”商标,国际分类涉及科学仪器、教育娱乐、网站服务等,目前上述商标部分已注册成功;已为“XIAOMI SU7 Ultra特征线”“Xiaomi EV Ultra Club”登记作品著作权,作品类别均为美术。
抖音上线长图文功能
36氪获悉,近日,抖音长图文功能正式上线。同时,抖音发布长图文运营扶持政策。据了解,抖音长图文功能目前支持发表最多8000字的长文,可插入最多30张配图,并可搭配背景音乐。手机端长图文发布功能,预计也将于近期陆续上线。
小鹏正式进入毛里求斯市场
36氪获悉,12月26日,小鹏汽车公布了其在中东非市场的进展。继正式登陆卡塔尔市场后,小鹏汽车在12月与毛里求斯合作伙伴达成战略合作,正式进入毛里求斯市场,12月小鹏在阿联酋阿布扎比开设旗舰展厅,同期在埃及新开罗启用集展厅与服务于一体、占地2000平方米的区域最大专属服务中心。
LV回应女子穿10分钟新鞋双脚受伤
近日,来自上海的徐女士称,她于今年9月20日在上海市浦东新区前滩太古里LV线下门店,购买了一双价值9250元的LOUS露跟运动鞋。9月27日,徐女士第一次穿这双鞋仅步行约10分钟便发现双脚脚背皮肤严重破皮,发红肿胀。
此后,徐女士发现伤口始终无法愈合,直到10月22日,因伤情恶化,她前往医院进行治疗,被告知需通过整形手术处理疤痕。徐女士多次联系前滩太古里LV线下门店,要求退货并进行相应赔偿,对方仅同意更换鞋子。
LV官网线上工作人员表示,线上与线下订单目前是分开的,线上并不能查看线下支付购买的订单。售后情况具体以门店答复为准,如果产品已经使用,便不支持退货与换货。品牌方没有对该款运动鞋“最好穿袜子磨合”的情况进行说明,实际使用中,“一开始穿的时候可以先穿袜子磨合一下。”目前,双方尚未达成一致。(澎湃新闻)
面膜被指存在“禁用成分”,韩束回应正在核实
日前,据具备检验资质的第三方检测机构检测结果显示,在韩束丰盈紧致精华面膜中检测出0.07pg/g的表皮生长因子;而另一款韩束嫩白透亮面膜中,该成分数值更高,达到3.21pg/g。对此,记者致电韩束官方尝试采访核实,客服工作人员表示:“公司已关注到相关情况,正在进行核实,最新进展将在官方渠道进行同步。”记者进一步浏览后发现,在电商平台的韩束旗舰店中,上述两款产品均已被下架。(经济参考报)
霸王��姬称饮品咖啡因含量可查看
12月26日��“霸王茶姬 咖啡因”登上微博热搜,引发广泛讨论,有不少网友表示喝完之后会睡不着,还有网友曾表示喝完会心悸。对此,霸王茶姬的官方客服回应称,每款茶饮都选用原叶茶胚及天然花果料,天然茶叶中含有茶多酚和咖啡因,且目前每一款饮品都可通过小程序的“健康计算机”查到咖啡因含量,用户在购买前可先行查看。(财经网科技)
宇树CEO回应马斯克点赞,称大家的喜爱程度超过预估
12月26日消息,特斯拉CEO埃隆・马斯克在12月20日转发了一条宇树机器人为王力宏演唱会进行伴舞的视频,并评价称“令人印象深刻”,相关内容迅速引发传播,海外关注量超4000万。12月25日,宇树科技创始人王兴兴回应被马斯克点赞,王兴兴表示“大家对宇树机器人的喜爱程度超过我的预估”,对整个机器人行业都是非常好的事情。
据网传视频显示,在12月18日王力宏“最好的地方”巡回演唱会四川成都站现场,六台人形机器人随《火力全开》歌曲登台与歌手及舞者同台表演,并完成高难度“韦伯斯特”空翻动作,成为演唱会最受关注的瞬间之一。(新浪新闻、浙江经视、IT之家)
全球首个电动汽车电耗限值强制性标准明年起实施
记者今天了解到,《电动汽车能量消耗量限值 第1部分:乘用车》国家标准将自2026年1月1日起实施,该标准是全球首个电动汽车电耗限值强制性标准。该标准综合考虑纯电动乘用车电耗现状、节能技术潜力、成本控制、特殊车型电耗表现等,提出了不同车重下的电耗限值,较上一版推荐性标准加严约11%,并根据不同使用特征、不同技术特点的车型电耗差异,提出相适应的指标要求,有效兼顾了车型多元化发展需求,为后续节能技术的研发和应用提供指引。(央视新闻)
中国成功发射卫星互联网低轨17组卫星
12月26日7时26分,中国在海南商业航天发射场使用长征八号甲运载火箭,成功将卫星互联网低轨17组卫星发射升空,卫星顺利进入预定轨道,发射任务获得圆满成功。(新华社)
同仁堂处理磷虾油造假管理人员
由同仁堂磷虾油造假事件引发的争议又有新进展。12月25日,北京同仁堂集团发布声明称,集团工作组已全面接管北京同仁堂(四川)健康药业有限公司经营管理工作,责令李声义辞去四川健康总经理职务,并对其他相关管理人员全部予以停职。后续将根据政府监管部门及内部调查结论,对相关失职失责人员严肃处理。
同仁堂同时公布了一个调查结论:经查,同仁堂集团对四川健康“同仁堂”字号及商标授权已于2021年3月21日到期,未再授权。(第一财经)
紫燕百味鸡致歉:10倍现金补偿消费者
12月24日,江苏南通启东一家紫燕百味鸡门店被指店员偷盗顾客已经称好的牛肉,引发关注。25日下午,紫燕百味鸡官方微信公号发布致歉声明。
声明指出,南通启东市凯旋路加盟门店店员在销售过程中有不当行为。公司对此高度重视,第一时间成立专案小组,并连夜开展核查工作,确认具体情况,现经查实,确实有店员存在不当行为。
声明还指出,公司已迅速采取以下处理措施:涉事门店停业整顿、涉事店员立即开除、全国门店通报警示。此外,公司将依据该消费者当日消费金额的十倍进行现金补偿,并赠送1000元的品牌无门槛消费卡一张。同时,为向在南通启东市凯旋路加盟门店消费的其他消费者表达歉意,将开启为期10天的补偿回馈。(蓝鲸新闻)
燕云十六声被指存在擦边问题,客服回应
近日,网易游戏《燕云十六声》新外观“飞白成诗”被多位玩家指出存在擦边问题,燕云十六声被指低俗情趣内衣引发热议。随后,有媒体就此事询问燕云十六声相关客服,得到回复:在12月25日调整并优化了“飞白成诗”女款下装靴子的染色区域,以支持更自由的配色方案;修复了“飞白成诗”女款上身与裙摆部分区域染色效果异常的情况;修复了“飞白成诗”女款后背模型细节显示错误的情况。
财经网科技通过查询燕云十六声官方账号评论区发现,其曾在网友“请保持初心不要搞女性擦边”的评论下边回复“好的,亲爱的,鹅会乖乖的”。(财经网科技)
封关运作首周海南离岛免税购物金额11亿元
海南自由贸易港封关运作首周,离岛免税购物保持增长态势,消费热潮持续升温。据海口海关统计,封关运作首周(12月18日—24日),海口海关共监管离岛免税购物金额11亿元,同比增长54.9%。(上证报)
报告显示:超2.4亿人有大学文化程度
最新中国高等教育财政资金投入规模揭晓。近日,中国人大网公开了《国务院关于财政高等教育资金分配和使用情况的报告》(下称《报告》),其中披露了财政对高等教育投入最新数据。根据《报告》,目前高等学校(包括普通本科学校、高职学校、成人高等学校,以下简称高校)教育以政府投入为主。在政府财政对高等教育投入方面,2021—2024年,全国各级财政通过一般公共预算安排3.78万亿元;通过政府性基金预算安排2102亿元。
《报告》称,在财政资金支持下,我国已建成世界上规模最大的高等教育体系,进入世界公认的普及化阶段,2024年高等教育毛入学率60.8%,比2020年提高6.4个百分点,拥有大学文化程度人口超过2.4亿人。(第一财经)
富士通加入软银牵头的AI内存开发项目
12月26日消息,据报道,富士通将加入由软银等公司推动的下一代存储器研发项目,该项目将利用英特尔和东京大学的技术,目标是在2027财年实现技术商业化,并在2029财年建立量产体系。今年6月,英特尔已与软银宣布携手,合作开发一种堆叠式DRAM解决方案,以替代高带宽存储器。(界面新闻)
石头科技拟在香港联合交易所上市
36氪获悉,石头科技公告,公司于近日收到中国证监会出具的《关于北京石头世纪科技股份有限公司境外发行上市备案通知书》。公司拟发行不超过33,108,000股境外上市普通股并在香港联合交易所上市。公司本次境外发行上市尚需满足多项条件,并需综合考虑市场情况以及其他因素方可实施,该事项仍存在不确定性。
大烨智能:涉嫌信息披露违法违规被中国证监会立案
36氪获悉,大烨智能公告,公司于2025年12月26日收到中国证监会下发的《立案告知书》,因公司涉嫌信息披露违法违规,根据相关法律法规,中国证监会决定对公司立案。立案调查期间,公司将积极配合中国证监会的各项工作,并履行信息披露义务。目前,公司生产经营正常,上述事项不会对公司的正常生产经营活动产生重大影响。
中国首批L3级自动驾驶汽车开启规模化上路运行
12月26日下午,中国首批L3级自动驾驶专用正式号牌车辆在重庆正式规模化上路通行,本次上路车辆共46辆,它们是搭载L3级自动驾驶系统的长安深蓝汽车。该批车辆获准在重庆市内环快速路、新内环快速路(高滩岩立交—赖家桥立交)及渝都大道(人和立交—机场立交)开展上路通行。(央视新闻)
AI最前沿
阿里通义千问推出Qwen Code v0.5.0版本
36氪获悉,阿里通义千问宣布,Qwen Code推出v0.5.0版本。这次更新不仅包含了功能增强,更是Qwen Code从“命令行工具”向“开发生态”迈进的关键一步。
灵光公布最新数据:用户已成功创建1200万个闪应用
36氪获悉,12月26日,通用AI助手灵光宣布:灵光用户已成功创建1200万个闪应用。闪应用是灵光三大功能之一,用户无需任何编程基础,用自然语言描述自己的需求,灵光便可最快30秒生成一个可编辑、可交互、可分享的小应用。统计数据显示,灵光已进入QuestMobile 2025年AI原生App周活跃用户规模TOP10,上线以来的日人均使用次数达到了4.6次。
联想将推出超级AI智能体
36氪获悉,联想集团计划在CES期间发布首款面向全球市场的“AI超级智能体”,定位为公司战略级应用。该智能体品牌名称暂未对外公布。据了解,这一应用可实现的AI能力类似于豆包手机助手,后者是在豆包App的基础上,字节跳动公司与手机厂商在操作系统层面合作的AI助手软件。
MiniMax与快看漫画达成“AI+IP”合作,上线首个AI互动漫画
36氪获悉,12月26日,作为核心技术合作伙伴之一,MiniMax稀宇科技为快看漫画提供视频生成与语音生成模型支持。快看漫画上线首个AI互动漫画,率先将AI技术结合完整世界观、角色互动与沉浸式体验引入漫画领域。作为核心技术合作伙伴之一,MiniMax稀宇科技为快看漫画提供视频生成与语音生成模型支持。
深度学习模型可预测细胞每分钟发育变化
美国麻省理工学院、密歇根大学和东北大学联合团队在最新《自然·方法》杂志上发表论文,介绍了一种名为“MultiCell”的几何深度学习模型。该模型首次实现了在单细胞分辨率下,预测果蝇胚胎发育过程中,每个细胞在每分钟的行为变化。未来可在此基础上设计出通用的多细胞发育预测模型,构建“数字胚胎”,用于药物筛选甚至指导人工组织设计。(科技日报)
LG电子将于下月发布基于AI技术的新型游戏显示器
LG电子公司12月26日表示,将推出内置人工智能(AI)技术的新型游戏显示器,可以将内容的分辨率提高到5K。LG电子在一份新闻稿中表示,将于明年1月在美国拉斯维加斯举行的国际消费电子展(CES)上推出UltraGear evo显示器。(新浪财经)
投融资
“椭圆时空”完成数亿元C1轮融资
36氪获悉,近日,商业航天领域企业“椭圆时空”宣布,已于近日顺利完成数亿元人民币的C1轮融资。本轮融资将主要用于持续推动其核心战略项目——“星池计划”的建设与技术升级,构建“太空具身智能”体系。
“硕橙科技”完成D轮过亿元融资
36氪获悉,近日,人工智能企业“硕橙科技”完成过亿元D轮融资,国贸产业基金、渝富数智基金、飞图创投、兴证资本共同参与本轮投资。资金将重点投入九畴工业大模型与新一代智能多维采集器的技术研发,同时助力公司市场拓展、渠道建设及日常运营。
“卓谱微”完成数千万元融资
36氪获悉,据创东方投资消息,近期,专注于半导体先进制造检测设备的“卓谱微”完成数千万元人民币融资,本轮融资由创东方独家投资。融资资金将用于核心产品迭代升级、新产品线研发、生产规模扩大及高端人才团队扩充。
微纳星空2025全年完成15.6亿元股权融资
36氪获悉,微纳星空2025年全年共完成15.6亿元股权融资,将继续用于研发投入和产能扩大,加强自主可控能力。
“海四达钠星”获超亿元战略投资
36氪获悉,“海四达钠星”宣布完成超亿元战略融资,投资方包括白云金控、中财融商(扬州兴建股权投资基金)、广东国研产业投资等。
酷产品
华为将于2026年3月推出全新华为随行WiFi X
36氪获悉,近日,华为官宣将于2026年3月推出全新随行WiFi产品——华为随行WiFi X。据介绍,该产品搭载透明天线技术,采用第三代PLC千兆组网,无需额外布线。此外,其内置网关并支持星闪TM连接,可高效接入并调度全屋鸿蒙生态设备。
整理|晨曦
早报|2025明日产品榜单正式揭榜/小米17 Ultra徕卡版首发即售罄/马斯克亲测无安全员Robotaxi,直呼「完美」


2025 明日产品榜单 Tomorrow’s Product Awards 2025 正式揭榜

小米 17 Ultra 徕卡版首发即售罄

曝联想明年 CES 推出 AI 超级智能体,对标豆包手机助手

华为开出 300 万悬赏,寻找 AI 时代新一代存储方案

马斯克亲测无安全员 Robotaxi,直呼「完美」

ChatGPT 推出全新编辑界面,可按任务自适应布局

三星三折叠被「掰断」,极限测试暴露多处结构短板

阿维塔回应极寒测试争议:阿维塔 12 将在南极驻留 16 个月

电动汽车电耗首个强制国标明年实施:续航有望平均提升 7%

Cursor CEO:过度依赖 vibe coding 迟早会出事

2599 元起,荣耀 WIN 系列手机正式发布

智谱发布 AI 协同开发工具 Z Code

通义千问发布 Qwen Code 大更新

周大福「牛馬」吊坠惹「阴阳打工人」争议
周末也值得一看的新闻
2025 明日产品榜单 Tomorrow’s Product Awards 2025 正式揭榜
昨天,爱范儿正式发布「2025 明日产品榜单 Tomorrow’s Product Awards 2025」,涵盖「消费电子」「人工智能」「智能出行」三大前沿赛道,盘点过去一年最具标杆意义的「明日产品」。
消费电子类(Consumer Electronics):
- iPhone Air + Liquid Glass 液态玻璃
- 华为 MateBook Fold 非凡大师 + 鸿蒙 PC 操作系统
- vivo X200 Ultra + 蔡司长焦增距镜
- OPPO ColorOS 16 AI 一键闪记
- Meta Ray-Ban Display + Neural Band
- Mac Studio M3 Ultra
- 宇树 H2
- 小米玄戒 O1
- 影石影翎 A1
- 戴森 PencilVac
人工智能类(Artificial Intelligence):
- DeepSeek R1
- 豆包手机助手
- Google Gemini 3
- Manus
- Sora App
- Veo 3
- Cursor 2.0
- Nano Banana Pro
- OpenAI Apps inside ChatGPT
- 夸克「志愿报告」Agent
- 蚂蚁阿福
- 腾讯元宝
智能出行(Smart Mobility):
- 仰望 U9X
- 梅赛德斯-奔驰 MB.OS
- 全新小鹏 P7
- 理想 VLA 司机大模型
- 蔚来全新 ES8
- 小米 YU7
- 尊界 S800
- Momenta R6 飞轮大模型
- 高通骁龙 8775 舱驾一体芯片
- 宝马 Heart of Joy
今年的明日产品榜单由科技潮牌爱范儿团队联合全球意见领袖提名并评选,从上百款新品中选出代表性案例,评选标准聚焦「创新(Innovation)」、「改变(Change)」和「普惠(Inclusivity)」。
了解更多:揭榜|爱范儿 2025 明日产品榜单 Tomorrow’s Product Awards 2025
圆桌实录:当好产品与 AI 走到十字路口|2025 明日产品年度圆桌实录
小米 17 Ultra 徕卡版首发即售罄

小米 17 Ultra by Leica 在全渠道开售后迅速售罄,主流电商平台多款配置处于缺货或预售状态,而在闲鱼等二手平台上,该机被黄牛加价转卖,部分高配版本报价已突破 1 万元。
界面新闻报道称,有渠道显示,小米官网 16 GB+512 GB 版本仍有现货,但 16GB + 1TB 版本在天猫官方旗舰店、京东以及官网均显示缺货或无货状态。
在闲鱼等二手平台上,16GB + 512GB 基础版本普遍标价接近 9000 元左右,顶配 16 GB+1 TB 版本多被黄牛炒至 1 万至 1.1 万元区间,相比 8999 元的官方定价溢价超过 20%。
配置方面,小米 17 Ultra 徕卡版主打「徕卡影像」体验,在普通版基础上增加了多项徕卡专属设计,包括机身背部醒目的徕卡经典红标、机械变焦环、徕卡专属画质调校以及「徕卡一瞬」模式。
该版本在通信和安全上也配备了更高规格的配置,包括北斗 + 天通双卫星通信能力以及安全隐私加密芯片。在外观设计上,小米 17 Ultra 徕卡版延续经典双拼风格,灵感来源于徕卡 M 系列相机。
曝联想明年 CES 推出 AI 超级智能体,对标豆包手机助手

据新浪科技报道,联想集团计划在明年国际消费电子展(CES 2026)期间,发布面向全球市场的首款「AI 超级智能体」,定位为公司未来战略级产品,覆盖手机、PC、平板以及可穿戴设备等多终端生态,主打跨设备、跨系统的智能协同能力。
知情人士透露,从功能定位看,联想 AI 超级智能体被认为是对标字节跳动与中兴通讯联合推出的「豆包手机助手」。
豆包手机助手可实现跨 App 调用服务,帮助用户点外卖、订机票、比价购物并操作微信消息与小程序游戏等;
联想这套系统则在能力上进一步前移到系统层,计划通过文字、语音以及环境感知与用户交互,并在长期使用中学习个人习惯,提供更一致、更贯通的使用体验。
昨天,2025 联想天禧 AI 生态伙伴大会(LTPC 2025)在北京中关村国际创新中心举行。大会以「聚力智能体,共启新生态」为主题,具体介绍了个人 AI 的未来形态、智能体商业模式、跨端协同能力与生态建设。
大会明确提出,联想正以「天禧AI一体多端」为核心战略,加速构建以人为中心的个人超级智能体生态。
「一体」即天禧个人超级智能体(天禧 AI),目前已升级至天禧 AI 3.5,具备个性化、行动力与交互体验的三重飞跃;「多端」则通过跨平台、跨 OS 的连接能力,实现设备间的统一调度与协同体验。
IDC 数据显示,联想集团当前是全球市占率最高的 PC 厂商,最新季度市占率为 25.5%;其旗下摩托罗拉手机出货量超过 1600 万台,营收超过 39 亿美元,在全球智能手机市场排名第八。
华为开出 300 万悬赏,寻找 AI 时代新一代存储方案

据财联社报道,昨天,华为第六届「奥林帕斯」全球征集活动正式启动,面向全球发布悬赏,聚焦解决 AI 时代的存储难题。
本届活动延续往届设置,总奖金池为 300 万元人民币,面向高校科研机构、企业研发团队以及独立开发者征集创新方案,方向包括但不限于数据存储架构优化、高可靠性存储系统、面向大模型训练与推理的数据访问效率提升等前沿课题。
报道指出,华为希望通过这一长期机制,连接产业需求与科研创新,为 AI 时代海量数据的高效存储与调用寻找可落地的技术路径。
入选方案除有机会分享奖金外,还将获得与华为相关业务团队深入交流、在真实业务场景中试点验证的机会,有望加速从概念验证走向工程化与规模化应用。
马斯克亲测无安全员 Robotaxi,直呼「完美」

特斯拉 CEO 埃隆 · 马斯克与 AI 总监阿肖克 · 埃鲁斯瓦米(Ashok Elluswamy)近日在美国德州奥斯汀实测无安全员的 Robotaxi 网约车,视频显示实测场景覆盖真实交通环境与路口转向等复杂工况,车辆行进流畅。
马斯克称此次试乘为「完美驾驶」,埃鲁斯瓦米从后排发布第一视角视频,形容体验为「Amazing(令人惊叹)」。马斯克同时确认,奥斯汀正开展「无人车、无乘客」的道路测试。
马斯克在本月早些时候的 xAI Hackathon 活动上表示,计划在「三周内」移除奥斯汀 Robotaxi 运营中的安全监控环节,迈向「完全无人驾驶」,并直言「无人监管驾驶的问题目前已基本解决」。
ChatGPT 推出全新编辑界面,可按任务自适应布局

据 BleepingComputer 报道,OpenAI 近期低调在 ChatGPT 上线了名为「formatting blocks(格式化编辑模块)」的全新编辑器功能,可根据具体任务调整界面布局,使 ChatGPT 在执行文本生成任务时更加高效。
目前,当用户让 ChatGPT 撰写邮件、博客等内容时,生成内容不再以普通对话气泡呈现。系统会在新的富文本区域中以接近文档编辑器的形式呈现草稿,让用户在界面上更直观地处理邮件或文稿。
报道指出,当用户在这些富文本区域中选中内容时,会自动弹出一组迷你编辑工具栏,支持对选中文本进行格式调整,交互体验更接近 Word 或 Gmail 等常见文档、邮件编辑界面。
这意味着 ChatGPT 不再「一视同仁」地将所有输出当作纯文本聊天记录,而是根据任务类型,将内容以更贴合场景的方式展示出来,从而提升实际使用中的可编辑性和可用性。
这项功能目前正逐步向用户推送,OpenAI 计划在后续为更多内容类型提供「formatting blocks」支持,以覆盖除邮件、写作草稿之外的更多使用场景。
BleepingComputer 提到,OpenAI 近期还在测试包括 GPT-5.1、GPT-5.1 Reasoning 和 GPT-5.1 Pro 在内的新模型版本,并持续强化 ChatGPT 在不同任务形态下的表现,以提高其在复杂写作和任务执行场景中的产品化程度。
三星三折叠被「掰断」,极限测试暴露多处结构短板

近期,三星三折叠屏手机 Galaxy Z TriFold 在知名 YouTube 频道 JerryRigEverything 的耐久测试中「首秀」,但结果并不理想:在多轮刮擦、灰尘和暴力弯折测试中暴露出多处结构性弱点。
在常规刮擦和结构测试环节,Galaxy Z TriFold 外观一度表现「中规中矩」,但其 10 英寸内屏采用塑料材质,极易在较低硬度下就被划伤,而背部玻璃纤维材质同样容易留下划痕,整体耐磨性明显弱于金属或玻璃机身。
JerryRigEverything 的测试还显示,机身在加入大量灰尘后,双铰链会出现明显的「沙砾感」,提示用户在日常使用中需要比传统折叠屏更谨慎地防尘。
更致命的问题出现在弯折测试中:作为一款在最薄处仅约 3.8mm 厚的手机,Galaxy Z TriFold 在被反向掰折时迅速失去支撑力,机身骨架断裂、屏幕瞬间黑屏,成为该频道测试中首款未能撑过弯折测试的三星折叠屏手机。
作为参考,Galaxy Z Fold 7 等机型都成功撑过类似考验,对比之下,TriFold 的双铰链设计和超薄机身在极端场景下结构强度不足,JerryRigEverything 表示,三星三折叠若被坐压,极易损坏。
阿维塔回应极寒测试争议:阿维塔 12 将在南极驻留 16 个月

12 月 8 日,阿维塔宣布其旗舰车型阿维塔 12 已抵达南极中山站,并以「全球首辆登陆南极的新能源轿车」,宣称将「直面 -50℃ 极寒,勇闯『无人区』,开启科考之旅」。
不过,有网友指出,从气象和季节背景来看,阿维塔此次登陆时间正值南半球夏季。中山站当前实测气温约为零下 3.3℃ 左右,车辆抵达当日中山站实测气温约为 -1℃,同期北京气温则为 -6℃,实际体感甚至较北京更为温暖。
对此,阿维塔发文称,阿维塔 12 将不会短期撤离,而是随中国极地科考队共同驻留至 2027 年 4 月,在南极停留超过 16 个月,期间将经历极地不同季节与环境变化,开展长期适应性测试与技术验证。
不过,公开资料显示,中山站近 6 年来记录的最低气温约为零下 36℃,与阿维塔宣传中提到的「-50℃ 极寒」仍存在约 14℃ 的差距。阿维塔尚未就此作出回应。
电动汽车电耗首个强制国标明年实施:续航有望平均提升 7%

据新华社报道,国家强制性标准《电动汽车能量消耗量限值 第 1 部分:乘用车》(GB 36980.1-2025)将于明年 1 月 1 日正式实施。
这是全球首个针对电动汽车电耗限值的强制性国家标准,由工业和信息化部主管,并由国家市场监督管理总局、国家标准化管理委员会联合发布。
据介绍,新标准综合考虑了纯电动乘用车电耗现状、节能技术潜力、成本控制以及特殊车型电耗表现等因素,在不同整备质量区间提出了分档电耗限值,并在此前推荐性标准基础上整体收紧约 11%。
以整备质量约 2 吨的车型为例,标准要求百公里电耗不应超过 15.1 度电,在电池容量不变的前提下,通过整车能效优化,电动汽车的续航里程平均有望提升约 7%,驾驶体验被认为将得到明显改善。
同时,标准针对不同使用特征、不同技术路线的车型,提出了差异化指标要求,以兼顾车型多元化发展与节能约束,为后续节能技术研发和应用提供量化指引。
新标准实施后,车企需对新出厂车型进行必要的技术升级。监管部门表示,这将倒逼车企在电驱系统效率、整车轻量化、热管理以及整车控制策略等环节加大投入。
灵光闪应用创建量突破 1200 万,AI 小应用加速进入日常

近日,蚂蚁集团旗下通用 AI 助手「灵光」公布最新运营数据:自产品上线以来不到 1 个月,用户已成功创建 1200 万个闪应用。
这一增长速度较上线两周时的 330 万个显著提升,显示出该功能正在被普通用户快速接受,并形成持续使用。
闪应用是灵光的三大核心功能之一,用户无需具备编程基础,只需以自然语言描述需求,系统即可在约 30 秒内生成一个可编辑、可交互、可分享的小应用。
灵光方面表示,这一产品形态正在满足用户在生活与工作场景中的个性化、情境化与即时化需求。目前,用户创建的闪应用已覆盖娱乐与陪伴、生活服务、效率工具、教育与自我提升等主要场景。
QuestMobile 此前发布数据显示,灵光已进入今年 AI 原生 App 周活跃用户规模 TOP10,上线以来的日人均使用次数达到 4.6 次。
Cursor CEO:过度依赖 vibe coding 迟早会出事

据《财富》报道,AI 编码助手产品 Cursor CEO Michael Truell 日前在「Fortune Brainstorm AI」大会上警告,所谓「vibe coding(氛围编程)」正在为软件系统构建「摇摇欲坠的地基」,随着功能一层层叠加,「最终一切都会开始崩塌」。
Truell 指出,生成式 AI 让程序员可以「退后一步」把端到端任务交给 AI,但如果完全「闭着眼睛」不看代码本身,风险会快速累积。
Truell 将「vibe coding」描述为一种极端依赖 AI 的开发方式 —— 开发者几乎不审查底层实现,只是把需求丢给 AI 让其「把东西造出来」。
他将其比喻为:只是在地上搭起四面墙和屋顶,却不了解地板下面或电线系统在发生什么。他指出,这种方式或许适合快速搭建小游戏、简单网站等原型,但在复杂系统和长期维护场景中的风险巨大。
当你在这样的基础上不断加盖一层又一层时,问题终究会集中爆发。
在他看来,AI 辅助编码存在不同层级:从多行自动补全、函数生成,到完整模块实现,再到端到端交付整个功能。
Cursor 试图占据的并不是「闭眼托管」的极端一端,而是将 AI 深度嵌入集成开发环境,利用现有项目上下文来预测下一行代码、生成完整函数,并协助调试和解释报错,让有经验的工程师仍然掌握对代码结构的主导权。
在你想钻研细节时, Cursor 可以和你一起深入代码;如果你想「退后一步」,也可以选择让 AI 自己端到端完成任务。
Cursor 由 Truell 与另外三位麻省理工学院毕业生在 2022 年以项目形式起步,随后快速演变为最受欢迎的编码助手产品之一。
今年早些时候,Cursor 的日活跃用户据称已达 100 万;公司此后实现了约 10 亿美元的年度经常性收入,员工规模扩张至 300 人。
2599 元起,荣耀 WIN 系列手机正式发布

昨天,荣耀正式发布全新「荣耀 WIN」系列手机,包括荣耀 WIN RT 和荣耀 WIN,标配主动散热风扇、6.83 英寸 185Hz LTPS 直屏和 10000mAh 大电池,支持 5 年内不限次数风扇清灰保养。
荣耀WIN RT:
- 搭载骁龙 8 至尊版;
- 50MP IMX906 F1.95 OIS 主摄、12MP 超广角、50MP 前置;
- 支持 100W 有线快充和 27W 有线反向快充;
- 宽 76.6mm 厚 8.3mm,重 225 克;
- 支持 IP68/69/69K 防尘抗水和超声波屏下指纹。
荣耀WIN:
- 搭载第五代骁龙 8 至尊版;
- 50MP IMX906 F1.95 OIS 主摄、50MP IMX856 OIS 长焦(F/2.4)、12MP 超广角、50MP 前置;
- 支持 100W 有线和 80W 无线快充、27W 有线反向快充;
- 宽 76.6mm 厚 8.3mm,重 229 克;
- 支持 IP68/69/69K 防尘抗水和超声波屏下指纹。
售价方面,荣耀 WIN RT 和荣耀 WIN 起售价分别为 2599 元和 3999 元(12GB + 256GB),最高可选 16GB + 1TB。
智谱发布 AI 协同开发工具 Z Code

昨天,智谱正式发布 AI 协同开发工具「Z Code」,定位为一款轻量级 AI 代码编辑器,主打在统一的可视化桌面环境下,整合多家命令行 AI 编程工具的能力,借助自然语言对话降低使用门槛,面向开发者提供一站式 AI 辅助开发体验。
据悉,Z Code 集成 Claude Code、Codex 和 Gemini 等主流 Agent 工具,仅需配置一个 API key,即可在同一界面内丝滑切换不同 Agent,实现代码生成、逻辑分析与错误排查等任务。
Z Code 还引入了「思考模式」与「历史重构」两大能力:
- 「思考模式」允许 Agent 在给出最终回答前先进行自我分析与校正,用户可按任务复杂度动态调节该模式,以平衡响应质量和效率;
- 「历史重构」则支持像编辑代码一样编辑「对话流」,开发者可以修改历史消息或整个交互节点,并让 Agent 基于重写后的对话重新执行,从而精细调整 Agent 的行为逻辑和输出路径。
为应对 AI 自动编程带来的安全与可控性风险,Z Code 设计了细粒度的权限与版本控制体系。
工具对文件编辑、命令行执行和网络访问等关键操作设置权限模式,在 Agent 尝试执行高风险行为时需要用户手动确认;同时,它会自动追踪由 Agent 触发的文件修改,将对话历史作为版本时间线,支持将代码回滚到任意一次对话之前的状态。
目前,Z Code 处于 Alpha 阶段,支持 macOS 和 Windows 平台,可在官网下载和查阅相关文档。
通义千问发布 Qwen Code 大更新

昨天,通义千问发布 Qwen Code v0.5.0 更新,将这一 AI 编程工具从单纯的「命令行助手」升级为面向开发者的完整开发生态入口,围绕终端、多实例并发、IDE 插件和 SDK 等多个维度进行了强化。
据介绍,Qwen Code 依然以命令行工具形态为主,但通过轻量终端设计增强了对本地文件的快速访问与调用能力,支持在一个终端窗口中同时运行多个 Qwen Code 实例,从而在不依赖图形界面的情况下实现多任务并行「AI 助手」体验。
同时,千问团队推出 Qwen Code VSCode 插件,支持在 VSCode 环境中通过文件右上角图标直接唤起对话界面,与 Qwen Code 进行代码生成、修改和解释等交互。
在扩展性方面,通义千问发布 Qwen Code TypeScript SDK,基于其在 Agent 业务领域的实践,将通用 CodeAgent 封装为可编程接口,方便开发者将 Qwen Code 的智能能力集成进 Web 或移动应用。
除新增插件和 SDK 外,本次更新还包括多语言支持优化、稳定性提升和用户体验改进等细节更新。
团队表示,未来将继续围绕实际开发需求打磨产品形态,使 Qwen Code 从工具逐步成长为理解开发者、服务多场景的 AI 编程基础设施。
周大福「牛馬」吊坠惹「阴阳打工人」争议

据界面新闻报道,近日,周大福一款名为「牛馬」的方圆牌黄金吊坠在社交媒体引发争议,不少消费者质疑该产品设计「阴阳打工人」,认为其将原本自嘲意味的网络热词进行商业化。
报道指出,「牛馬」吊坠目前在周大福微信小程序商城已显示为「暂时没有库存然后下架」,但在周大福天猫官方旗舰店仍正常销售。
周大福官方客服对记者表示,这款「牛馬」黄金吊坠目前仅在电商等线上渠道销售,近期销量「比较火」;若顾客对款式有异议,会记录并向上反馈,后续会持续优化产品设计。
霸王茶姬回应咖啡因争议

昨天,Tag「#霸王茶姬 咖啡因#」登上微博热搜,引发广泛讨论。不少网友表示喝完之后会睡不着,还有网友曾表示喝完会心悸。
对此,霸王茶姬的官方客服回应称,每款茶饮都选用原叶茶胚及天然花果料,天然茶叶中含有茶多酚和咖啡因,且目前每一款饮品都可通过小程序的「健康计算机」查到咖啡因含量,用户在购买前可先行查看。
与此同时,霸王茶姬在其官方微博评论区中表示对于谣言与不实信息,将依法维权:
已经注意到近期网络中传播的针对我司的谣言与不实信息。对于此类恶意捏造事实、损害我司商誉的违法行为,我们法务部门已全面介入并启动法律程序,坚决依法维权。我们始终珍视来自社会各界的、基于事实的善意监督与建议,对于任何逾越法律红线的诽谤与中伤,也必将采取法律手段,维护自身的合法权益。
《怪奇物语》登顶 2025 全球盗版下载榜,Netflix 成最大「赢家」

昨天,TorrentFreak 发布 2025 年全球盗版电视剧下载量榜单。数据显示,Netflix 制作的《怪奇物语》(最终季)成为 2025 年下载量最高的剧集,同门《鱿鱼游戏》紧随其后,HBO Max 的《最后生还者》位列第三。
TorrentFreak 解释称,这份榜单统计的是 2025 年在全球范围内通过 BitTorrent 协议下载的热门剧集,每部剧以单集估算,结果仅能反映盗版生态中的一小部分,因为当前更多用户转向不公布具体观看数据的线上盗版流媒体网站和服务,且该榜单不包含动漫内容。
除《怪奇物语》、《鱿鱼游戏》和《最后生还者》外,榜单还包括:
- Apple TV+ 的《人生切割术》
- Disney+ 的《安多》
- Amazon Prime 的《侠探杰克》
- 同样来自 Apple TV+ 的《末日地堡》(Silo)
- 《同乐者》(Pluribus)
- Netflix 的《星期三》
- 由 FX / Hulu 播出的《异形:地球》
其中,Netflix 以两部「头部 IP」包揽前二,被 TorrentFreak 称为今年盗版市场的最大「赢家」。TorrentFreak 分析指出,多平台订阅「碎片化」是盗版再度抬头的重要诱因之一。
随着流媒体格局持续分化,用户若想追完整个年度的热门剧集,往往需要同时订阅多个平台,累积成本超出不少家庭的预算,因此部分已经拥有正版订阅的用户,在面对其他平台的独占内容时转向盗版下载。
《重返寂静岭》定档 2026 年 1 月 23 日

经典惊悚游戏改编电影《重返寂静岭》已官宣将于明年 1 月 23 日上映。影片改编自游戏《寂静岭 2》,该作在去年推出的重制版中,全球销量突破 250 万套,游戏平台好评率高达 96%。
《重返寂静岭》集合了游戏与电影双重粉丝熟悉的原班人马:曾执导首部《寂静岭》电影、被视为「经典游戏改编电影之一」的导演克里斯多夫 · 甘斯回归掌镜。
在叙事层面,影片延续游戏的核心设定:主角詹姆斯在一封来自已逝爱人的神秘信件召唤下,再次踏入被迷雾和警报包围的诡异小镇寂静岭。
是周末啊!
One Fun Thing|腾讯会议被玩家们玩明白了:靠 AI 纪要开黑《王者荣耀》

越来越多玩家发现了腾讯会议的新用法 —— 语音开黑《王者荣耀》,还能借助内置 AI 会议纪要功能「记录」整场游戏过程,并给出总结分析。
腾讯会议的 AI 纪要原本用于生成会议要点,服务于商务洽谈、技术评审和周报汇报等场景,如今却被玩家「跨界」用来自动整理游戏语音聊天。
该功能会将「救救救」「别送了」等情绪化、口语化语句「翻译」成带有职场话术风格的总结,例如「配合出现明显脱节,暴露出战术执行混乱的问题」等,形成强烈反差感。
玩家只需发起腾讯会议并保持后台运行,系统便会每隔约两分钟自动推送最新纪要,对对局中的抱怨网络卡顿、自嘲「我是人机」、互相指责操作失误等内容进行归纳,并给出诸如「对局失败被归因于网络与操作问题」「团队陷入集体焦虑」「某玩家心态率先崩溃」等结论。
这些原本会随一局游戏结束而消散的即时情绪,被 AI 转换成类似「战术复盘报告」的文字记录,成为玩家事后复盘甚至互相「甩锅」的佐证。
相比游戏内以 KDA、输出、排位等为主的冷冰冰数据反馈,腾讯会议 AI 纪要更侧重对对话氛围和参与者情绪的捕捉,被部分玩家视为一种「情感容器」和「友谊时光机」。
目前,腾讯会议在特定活动中为周末最多 5 人开黑提供时长不限的「免费会议」福利,进一步降低了玩家将其作为「开黑工具」的门槛。
相关阅读:笑拥了,现在打王者都在用腾讯会议,AI 这波操作是在大气层
周末看什么|《利刃出鞘3》

《利刃出鞘3》故事聚焦于一宗发生在小镇教会的「不可能犯罪」。
布兰科来到拥有黑暗历史的乡村教堂,与年轻神父 Jud Duplenticy(乔什·奥康纳 饰)合作调查案件。剧情在宗教背景与哥特式氛围中展开,通过角色之间的隐秘关系与层层反转推动叙事。
影片在结构上回归系列首作的古典本格风格,同时加入更强的宗教隐喻与心理戏,部分影评指出影片在信仰主题上的呈现较前作更为突出。
在豆瓣平台上,影片获得 7.6 分,近 4.9 万人参与评分,其中 4 星与 3 星评价占比超过 79%,整体口碑高于 80% 的剧情片与 88% 的喜剧片。
观众普遍认为影片在叙事节奏、角色表演与推理结构上较第二部更为稳健,但也有评论指出部分角色塑造略显薄弱。
此外,本片还入围第 50 届多伦多国际电影节观众选择奖提名,并被美国国家评论协会评为年度佳片之一。
买书不读指南|《怎样做一个幸福的人》

诗集《怎样做一个幸福的人》收录了美国诗人斯蒂芬 · 邓恩近半个世纪的创作轨迹,文本跨越亲密关系、伦理判断、日常经验与生命感知等主题。邓恩是普利策诗歌奖得主,他的写作以节制、理性与对日常经验的深度凝视著称。
与常见的情绪励志类读物不同,《怎样做一个幸福的人》并未提供可操作的「幸福指南」,而是通过诗歌呈现人如何在现实生活的不完整、不确定与失衡中保持判断与诚实。
他强调,幸福是一种有限的、情境性的感受,而非可被掌握的目标状态。
游戏推荐|《底特律:化身为人》

《底特律:化身为人》由 Quantic Dream 开发,最初于 2018 年在主机平台推出。Steam 版本于 2020 年上线,并针对 PC 进行了 4K、60 帧与键鼠操作优化。
故事背景设定在 2038 年的底特律,仿生人已普及至社会各个层面,但仍被视为工具。
玩家将分别扮演仿生人 Kara、Connor 与 Markus,从家庭逃亡、警务调查到社会运动等不同视角切入,逐步揭示仿生人觉醒与人类社会冲突的核心矛盾。
作为一款以「选择」为核心机制的互动叙事作品,游戏通过多线分支结构构建出高度可变的剧情走向,玩家的每一次操作都会影响三位主角及整座城市的命运。
游戏强调道德抉择与情感共鸣,部分选择甚至会导致角色永久死亡,但剧情仍会继续推进,形成高度个性化的流程体验。
IGN 对该作给出 8/10 分的「优秀」评价,认为其是一部「绝妙的科幻互动戏剧」,并强调玩家选择的重要性。Steam 玩家评价同样呈现压倒性正面,简体中文 6 万+ 玩家评出「好评如潮」。
目前,该作在 Steam 正举行一折特惠活动,2026 年 1 月 6 日前 13.6 元即可入库。
#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。
五年前端,我凌晨三点的电脑屏幕前终于想通了这件事
五年前端开发:那些加班到深夜的日子里,我终于找到了答案
转眼间,做前端已经五年了。回想起这些年的点点滴滴,有为了一个像素对不齐而折腾到凌晨的执着,也有终于解决了一个性能问题后的欣喜若狂。
💻 那些让我抓狂的瞬间
一个padding搞了我一晚上
记得刚入行的时候,有个布局问题让我头疼了一整晚。就是两个div之间的间距,怎么调都不对。那时候我还不知道浏览器默认样式这回事,对着Chrome开发者工具一遍遍地试,各种margin、padding组合,结果第二天早上一问资深同事,人家轻描淡写地说:"reset.css加了么?"
那一刻我才明白,很多你以为的技术难题,其实只是知识盲区而已。
"这个需求很简单"背后的深坑
产品经理说:"这个需求很简单,就是加个拖拽排序功能。"
我:"好的,应该一天就够了。"
然后我才发现,拖拽排序要考虑:
- 移动端的手势识别
- PC端的鼠标事件
- 不同浏览器的事件兼容性
- 拖拽过程中的视觉反馈
- 边界处理和碰撞检测
- 性能优化(防止频繁重绘)
- 可访问性支持
三天后,我终于交出了"看似简单"的功能。从那以后,我再也不轻易相信"这个需求很简单"这种话了。
🌱 那些让我成长的时刻
第一次重构老项目
接手一个三年前的老项目,代码里到处都是document.getElementById,jQuery和原生JS混用,全局变量满天飞。重构过程中,我发现了一些有意思的"黑历史":
// 当年的前辈们是怎么写代码的
function getData() {
if (data1 == null) {
data1 = [];
for (var i = 0; i < 100; i++) {
data1.push(i);
}
}
return data1;
}
// 还有这种神奇的操作
$("#button").click(function() {
setTimeout(function() {
location.reload();
}, 100);
});
重构那段时间,每天都在跟历史代码搏斗,但也正是这个过程,让我真正理解了什么叫"代码可维护性"。
学会了说"不"
以前刚入行时,产品提什么需求我都说"行"。直到有一次,为了赶一个不合理的deadline,我熬了好几个通宵,最后上线的版本还出了bug。
后来我学聪明了,开始跟产品和沟通:
- 这个需求的技术复杂度是多少
- 需要多少开发时间
- 如果一定要提前,哪些功能可以砍掉
- 当前技术方案的风险点在哪里
学会评估和沟通,比学会写代码更重要。
🤔 程序员的日常思考
关于加班的那些事
刚开始工作的时候,我觉得加班=努力。后来慢慢发现:
- 有效的时间管理比长时间工作更重要
- 会写代码不等于会解决问题
- 健康比KPI重要得多
我现在尽量不加班,不是因为懒,而是我学会了:
- 提前评估工作量
- 及时沟通风险
- 拒绝不合理的需求
- 保持专注,减少无效加班
关于技术焦虑
前端技术更新太快,Vue还没学完,React又出了新特性,CSS框架层出不穷。前两年我很焦虑,怕被淘汰。
现在我想通了:
- 基础永远是王道:HTML/CSS/JavaScript的核心不会变
- 学习要讲方法:不要追着新技术跑,要有选择地学
- 项目驱动学习:在实际项目中学习新技术效果最好
- 保持输出:写博客、做分享是最好的学习方式
💪 真正的成长是什么
从技术思维到产品思维
刚开始我只关心代码写得爽不爽,后来我开始思考:
- 用户真的需要这个功能吗?
- 这个交互体验够好吗?
- 性能优化能带来什么价值?
- 我的代码对团队协作友好吗?
技术是工具,不是目的。真正的前端开发,是用技术为用户创造价值。
找到了自己的节奏
现在的我:
- 不再盲目追新技术,而是选择适合自己的技术栈
- 重视代码质量,但不执着于完美
- 会主动沟通需求,而不是被动接受
- 保持学习的热情,但不焦虑
- 知道什么时候该努力,什么时候该休息
🎯 给自己的一些话
五年下来,我想对自己说:
- 保持好奇,但不要盲目跟风
- 写代码很重要,但解决问题更重要
- 技术要精进,但生活也要平衡
- 多分享,多交流,多思考
- 记住,你首先是一个人,其次才是程序员
✨ 下一个五年
技术这条路很长,但我不急了。慢慢地学习,稳稳地成长,踏实做好每一个项目。
毕竟,最好的代码不是最复杂的,而是最合适的。最好的程序员不是最聪明的,而是最懂得平衡的。
愿我们都能在这条路上,找到属于自己的节奏和答案。
你在前端路上有什么难忘的经历?欢迎在评论区分享你的故事。
#前端开发 #程序员成长 #技术感悟 #职场经验 #真实感受
Vue3-插槽slot
插槽是 Vue 组件中一个非常核心的概念,它允许你以一种灵活的方式将内容“插入”到子组件的指定位置,极大地提高了组件的复用性和灵TA性。插槽允许组件只负责渲染一个“框架”(比如边框、阴影),而把“内容”的决定权交给使用它的父组件。
1.默认插槽
最简单的插槽,子组件中只有一个未命名的 <slot> 出口。
子组件
<template>
<div class="card">
<h3>卡片标题</h3>
<slot></slot> </div>
</template>
<style scoped>
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
max-width: 300px;
}
</style>
父组件
<template>
<BaseCard>
<p>这是一父组件</p>
<img src="./assets/logo.png" alt="Vue Logo" style="width: 100px;">
</BaseCard>
</template>
<script setup lang="ts">
import BaseCard from './components/BaseCard.vue';
</script>
2.具名插槽
当子组件需要多个“坑位”时(例如,一个用于头部,一个用于底部),就需要使用具名插槽。
子组件:使用
name属性来区分不同的插槽。
<template>
<div class="modal">
<header class="modal-header">
<slot name="header"></slot> </header>
<main class="modal-body">
<slot></slot> </main>
<footer class="modal-footer">
<slot name="footer"></slot> </footer>
</div>
</template>
<style scoped>
.modal { background: #fff; border: 1px solid #ddd; }
.modal-header, .modal-footer { padding: 10px; background: #f4f4f4; }
.modal-body { padding: 20px; }
</style>
父组件:使用
<template>标签和v-slot指令(或其简写#)来指定要填充的插槽。
<template>
<ModalLayout>
<template v-slot = "header">
<h2>这是一个模态框标题</h2>
</template>
<p>这是模态框的主要内容...</p>
<template #footer>
<button>取消</button>
<button>确认</button>
</template>
</ModalLayout>
</template>
<script setup lang="ts">
import ModalLayout from './components/ModalLayout.vue';
</script>
3.作用域插槽
这是插槽最强大的功能。它允许子组件向父组件的插槽内容传递数据。这在处理列表渲染时非常有用,子组件负责数据迭代,而父组件负责定义每一项的渲染样式。
子组件:子组件通过在
<slot>标签上绑定属性,来将数据"暴露"给父组件。
<template>
<div class="user-list">
<p>用户列表:</p>
<ul>
<li v-for="user in users" :key="user.id">
<slot :user="user" :isAdmin="user.name === 'Alice'"></slot>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
interface User {
id: number;
name: string;
age: number;
}
const users = ref<User[]>([
{ id: 1, name: 'Alice', age: 30 },
{ id: 2, name: 'Bob', age: 25 },
]);
</script>
父组件:父组件通过
v-slot(或#) 接收子组件传递的数据,并且可以立即为这些数据添加 TypeScript 类型。
<template>
<UserList>
<template #default="{ user, isAdmin }: { user: User, isAdmin: boolean }">
<span>
{{ user.name }} ({{ user.age }}岁)
</span>
<span v-if="isAdmin" style="color: red; margin-left: 10px;">[管理员]</span>
</template>
</UserList>
</template>
<script setup lang="ts">
import UserList from './components/UserList.vue';
// 我们可以在父组件中也定义这个类型,以便复用
interface User {
id: number;
name: string;
age: number;
}
</script>
4.自定义插槽(Vue 3.3+)
在 Vue 3.3 及更高版本中, <script setup> 提供了 defineSlots 宏,这是在子组件中为插槽提供类型的官方方式。这极大地改善了开发体验,父组件不再需要手动声明类型,因为 TS 可以自动从子组件推断它们。
子组件:使用
defineSlots来声明插槽及其期望的props类型。
<template>
<div class="list">
<ul>
<li v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index"></slot>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// 1. 定义数据和类型
interface Item {
id: string;
text: string;
}
const items = ref<Item[]>([
{ id: 'a', text: '第一项' },
{ id: 'b', text: '第二项' },
]);
// 2. (重点) 使用 defineSlots
// 这是一个宏,无需导入
// 它定义了 'default' 插槽会接收一个对象,该对象包含一个 'item' 属性 (类型为 Item)
// 和一个 'index' 属性 (类型为 number)
defineSlots<{
default(props: { item: Item; index: number }): any;
// 如果有具名插槽,也可以在这里定义,比如:
// header(props: { title: string }): any;
}>();
</script>
父组件:父组件的
slotProps(或解构的变量) 会被自动推断出正确的类型
<template>
<TypedList>
<template #default="{ item, index }">
<strong>{{ index + 1 }}.</strong> {{ item.text.toUpperCase() }}
</template>
</TypedList>
</template>
<script setup lang="ts">
import TypedList from './components/TypedList.vue';
</script>
5.总结
-
布局组件 (
Layout.vue):- 场景: 定义网站的通用布局,如侧边栏、顶部导航和内容区域。
-
用法: 使用
header,sidebar,main等具名插槽,让不同页面填充自己的内容。
-
可复用 UI 元素 (
Modal.vue,Card.vue,Dropdown.vue):- 场景: 封装通用的交互和样式,但允许内容高度自定义。
-
用法:
Modal组件提供header(标题),body(内容),footer(按钮) 插槽。
-
列表渲染器 (
DataList.vue,ProductGrid.vue):- 场景: 组件负责获取和迭代数据(如 API 请求、分页),但把如何渲染每一项的控制权交给父组件。
-
用法: (核心) 使用作用域插槽,将
item(当前项数据) 传递给父组件。这是最灵活的模式。
-
提供者组件 (
Toggle.vue,MouseTracker.vue):-
场景: 组件管理某个状态(如
isOn)或逻辑(如鼠标位置),并通过作用域插槽将这些状态暴露出去,让父组件来决定如何渲染。 -
用法: 子组件
<slot :isOn="isOn" :toggle="toggleFunction"></slot>。
-
场景: 组件管理某个状态(如
esp32 小智AI 项目
#一、接入 MAX98357 MAX98357 是一款非常流行的 I2S 数字音频放大器模块,与 ESP32 搭配是制作数字音频项目的经典组合。控制它的核心在于:正确配置 ESP32 的 I2S 音频接口,并向其发送数字音频数据。为了方便你理解从代码到声音的完整流程,下图清晰地展示了控制 MAX98357 的核心工作流:
🔌 硬件连接(接线)
连接非常简单,因为 MAX98357 使用标准的 I2S 接口:
| ESP32 引脚 | MAX98357 引脚 | 作用 |
|---|---|---|
| 3.3V | VIN | 电源(3.3V-5V均可,与ESP32共用3.3V最方便) |
| GND | GND | 接地(必须共地) |
| GPIO 25 (或其它) | DIN | 串行数据输入,这是最重要的数据线。 |
| GPIO 26 (或其它) | BCLK | 位时钟,用于同步每一位数据。 |
| GPIO 27 (或其它) | LRC | 左右声道时钟,用于切换左右声道。 |
| (可选)GPIO(如15) | GAIN | 增益控制。不接时默认为高增益(15dB) 。如需低增益(9dB),将此脚接地。 |
| (不连接) | SD | 关断引脚,MAX98357 内部已下拉,正常工作时无需连接。 |
引脚选择提示:ESP32 有多个 I2S 引脚,你可以使用上表的引脚,也可以使用其他支持 I2S 的引脚(如 GPIO 5, 17, 18, 19, 21, 22, 23 等),只要在代码中统一即可。
🛠️ 软件配置与编程(PlatformIO)
在 PlatformIO 项目中,最便捷的方法是使用一个优秀的音频库:AudioTools。
-
安装库:
在 PlatformIO 的 “Library Manager” 中搜索AudioTools by pschatzmann并安装。这个库几乎囊括了所有音频功能,非常适合入门。 -
编写基础程序:
以下代码演示如何播放一个简单的 440Hz 正弦波(标准音A) ,这是测试音频系统的“Hello World”。
cpp
/**
* ESP32 + MAX98357 基础测试
* 播放一个440Hz的正弦波
*/
#include <Arduino.h>
#include "AudioTools.h"
// 1. 定义I2S输出接口,并指定引脚
// 参数解释:I2SStream(int data_pin, int clock_pin, int lr_pin)
I2SStream i2s;
const int data_pin = 25; // DIN 连接的GPIO
const int clock_pin = 26; // BCLK 连接的GPIO
const int lr_pin = 27; // LRC 连接的GPIO
// 2. 定义音频信号源:一个440Hz的正弦波
SineWaveGenerator<int16_t> sineWave(32000); // 生成16位有符号整数格式的正弦波,振幅32000
GeneratedSoundStream<int16_t> sound(sineWave); // 将正弦波包装成音频流
StreamCopy copier(i2s, sound); // 音频数据复制器:将声音源的数据复制到I2S输出
void setup() {
Serial.begin(115200);
// 3. 配置I2S音频参数
auto config = i2s.defaultConfig();
config.pin_data = data_pin;
config.pin_bck = clock_pin;
config.pin_ws = lr_pin;
config.sample_rate = 44100; // 标准采样率
config.bits_per_sample = 16; // 16位采样深度
config.channels = 2; // 立体声
config.i2s_format = I2S_STD_FORMAT; // 标准I2S格式
// 4. 初始化I2S
i2s.begin(config);
// 5. 配置正弦波参数:440Hz,采样率与I2S一致
sineWave.begin(config.channels, config.sample_rate, 440);
Serial.println("开始播放 440Hz 正弦波(标准音A)...");
}
void loop() {
// 不断将生成的音频数据复制到I2S接口
copier.copy();
}
🎵 播放实际音频文件(如WAV)
要播放SD卡或SPIFFS中的音频文件,你需要:
-
安装额外库:在 PlatformIO 中安装
AudioCodecs by pschatzmann。 - 准备音频文件:将其转换为 单声道或立体声、16位、采样率不超过44100Hz的WAV文件,并上传到ESP32的SPIFFS或SD卡中。
以下是播放SPIFFS中test.wav文件的示例代码框架:
cpp
#include <Arduino.h>
#include "AudioTools.h"
#include "AudioLibs/AudioSourceSPIFFS.h" // SPIFFS音频源
#include "AudioCodecs/CodecWAV.h" // WAV解码器
I2SStream i2s;
SPIFFSStream file; // SPIFFS文件流
WAVDecoder dec; // WAV解码器
EncodedAudioStream decoder(&i2s, &dec); // 解码后的音频流指向I2S
StreamCopy copier(decoder, file); // 将文件->解码->I2S
void setup() {
Serial.begin(115200);
// 初始化文件系统(需要先通过PlatformIO的“Upload Filesystem Image”上传文件)
if (!SPIFFS.begin()) {
Serial.println("SPIFFS初始化失败!");
while(1);
}
// 配置I2S(同上例,省略)
auto i2sConfig = i2s.defaultConfig();
i2sConfig.pin_data = 25;
// ... 其他配置
i2s.begin(i2sConfig);
// 配置音频流
file.begin("/test.wav"); // 打开文件
decoder.begin(); // 开始解码
Serial.println("开始播放WAV文件...");
}
void loop() {
if (copier.copy()) {
// 正常播放中
} else {
Serial.println("播放结束或出错");
delay(2000);
// 可在此循环播放或播放下一首
}
}
🔄 与之前的ST7789屏幕项目结合
你可以轻松地将音频控制和屏幕显示结合在一个ESP32项目中,因为两者使用不同的硬件接口(I2S vs. SPI)且库不冲突。例如,在屏幕上显示当前播放的歌曲名、音量或音频频谱。只需将两部分的setup()和loop()逻辑合理整合即可。
接入 SD卡
基于我们之前的讨论,以下是使用 ESP32 通过 SD 卡读取并播放 WAV 音频文件 的完整实现方案。这涵盖了硬件连接、PlatformIO 配置和可运行的代码。
📁 完整项目实现指南
1. 硬件连接 (ESP32 + SD卡模块 + MAX98357)
请确保你的硬件按此连接。电源稳定是关键,建议使用外部5V电源为音频部分独立供电,并与ESP32共地。
| ESP32 GPIO 引脚 | SD 卡模块引脚 | MAX98357 模块引脚 |
|---|---|---|
| 3.3V | VCC | VIN (可单独供电) |
| GND | GND | GND (必须共地) |
| GPIO 23 | MOSI | -- |
| GPIO 19 | MISO | -- |
| GPIO 18 | SCK | -- |
| GPIO 5 (示例) | CS (片选) | -- |
| GPIO 25 | -- | DIN |
| GPIO 26 | -- | BCLK |
| GPIO 27 | -- | LRC |
2. PlatformIO 项目配置 (platformio.ini)
创建或修改项目根目录下的 platformio.ini 文件。
ini
[env:esp32-dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
; 核心依赖库
lib_deps =
pschatzmann/ESP32-AudioTools @ ^1.0.8 # 主音频库
pschatzmann/arduino-audio-tools @ ^1.1.9
pschatzmann/arduino-audiocodecs @ ^1.0.6 # WAV解码器
; SD卡库已包含在框架中
; 优化构建
board_build.flash_mode = dio
build_flags =
-Wl,-Teagle.flash.4m32m.ld
3. 主程序代码 (src/main.cpp)
将以下代码复制到 src/main.cpp 中,并根据你的引脚定义进行修改。
cpp
/**
* ESP32 SD卡 WAV音频播放器
* 依赖:AudioTools库
*/
#include <Arduino.h>
#include "AudioTools.h"
#include "AudioLibs/AudioSourceSD.h" // SD卡音频源
#include "AudioCodecs/CodecWAV.h" // WAV解码器
#include <SD.h> // SD卡驱动
// ==================== 用户配置区域 ====================
// 1. SD卡引脚配置(根据实际接线修改!)
#define SD_CS_PIN 5 // SD卡模块的片选引脚
// 2. I2S引脚配置(根据实际接线修改!)
#define I2S_DIN_PIN 25 // MAX98357的DIN
#define I2S_BCLK_PIN 26 // MAX98357的BCLK
#define I2S_LRC_PIN 27 // MAX98357的LRC
// 3. 音频文件设置
const char* audioFilePath = "/test.wav"; // 放在SD卡根目录的测试文件
// ====================================================
// 创建音频对象
I2SStream i2s; // I2S输出流
SDStream file(SD_CS_PIN); // SD卡文件流,传入CS引脚号
WAVDecoder dec; // WAV解码器
EncodedAudioStream decoder(&i2s, &dec); // 解码后管道连接到I2S
StreamCopy copier(decoder, file); // 负责数据复制的“引擎”
void printAudioInfo(AudioInfo info) {
Serial.println("=== 音频信息 ===");
Serial.printf("采样率: %d Hz\n", info.sample_rate);
Serial.printf("声道数: %d\n", info.channels);
Serial.printf("位深度: %d-bit\n", info.bits_per_sample);
Serial.println("================");
}
void setup() {
Serial.begin(115200);
while (!Serial); // 等待串口连接(仅用于调试)
delay(500);
Serial.println("\n\nESP32 SD卡音频播放器启动...");
// === 第一步:初始化SD卡 ===
Serial.print("初始化SD卡...");
if (!SD.begin(SD_CS_PIN)) {
Serial.println("失败!请检查:");
Serial.println(" 1. SD卡是否插入?");
Serial.println(" 2. 引脚连接是否正确?");
Serial.println(" 3. SD卡格式是否为FAT32?");
while (true) { // 挂起
delay(100);
}
}
Serial.println("成功!");
// 可选:列出根目录文件
Serial.println("SD卡根目录内容:");
File root = SD.open("/");
while (File entry = root.openNextFile()) {
Serial.print(" ");
Serial.println(entry.name());
entry.close();
}
root.close();
// === 第二步:配置并初始化I2S ===
Serial.print("配置I2S音频接口...");
auto config = i2s.defaultConfig();
config.pin_data = I2S_DIN_PIN;
config.pin_bck = I2S_BCLK_PIN;
config.pin_ws = I2S_LRC_PIN;
config.sample_rate = 44100; // 初始采样率,解码后会根据文件自动调整
config.bits_per_sample = 16;
config.channels = 2;
config.i2s_format = I2S_STD_FORMAT;
// config.buffer_size = 1024; // 若出现爆音可尝试增大
// config.buffer_count = 8;
if (!i2s.begin(config)) {
Serial.println("I2S初始化失败!请检查引脚。");
while (true);
}
Serial.println("成功!");
// === 第三步:尝试打开并解码音频文件 ===
Serial.printf("尝试打开文件: %s\n", audioFilePath);
if (!file.begin(audioFilePath)) {
Serial.println("文件打开失败!请检查路径和文件名。");
while (true);
}
// 设置解码器输出信息回调(可选)
decoder.setInfoCallback(printAudioInfo);
Serial.println("开始解码并播放...");
decoder.begin();
// 可选:设置音量(0.0静音 ~ 1.0最大)
// i2s.setVolume(0.7);
}
void loop() {
// 核心:将文件数据复制到解码器,再送至I2S
if (copier.copy()) {
// 数据正在稳定传输中,可以在此添加播放状态指示(如点亮LED)
} else {
// 播放结束或发生错误
Serial.println("播放结束。");
// 简单示例:等待3秒后重新播放
delay(3000);
Serial.println("重新播放...");
// 重置文件流到开始位置
file.begin(audioFilePath);
decoder.begin();
}
// 可以在此处加入其他控制逻辑,如按键检测切换歌曲
}
🚀 如何运行
-
准备SD卡:格式化为FAT32,将转换好的16位、单声道/立体声、44100Hz的WAV文件(如
test.wav)复制到根目录。 - 连接硬件:按上述表格连接好所有线路,仔细检查电源和地线。
- 上传代码:在VS Code中,点击PlatformIO底部的 ✔️(编译) ,然后点击 ➡️(上传) 按钮。
- 查看日志:打开串口监视器(波特率115200),查看初始化状态和播放信息。
接入 INMP 441 麦克风
将 INMP441 数字麦克风模块连接到 ESP32 是进行高质量音频采集的经典方案。INMP441 是一款通过 I2S 接口 输出的底部进音 MEMS 麦克风,与 ESP32 的 I2S 外设完美兼容。
🔌 硬件连接
INMP441 需要标准的 I2S 连接,但与之前的 MAX98357(从设备)不同,INMP441 工作在“主模式” ,这意味着它负责生成主时钟(BCLK)和左右时钟(LRCLK)。因此,连接方式有特定要求。
请按以下表格连接(这是最常见的接法):
| ESP32 GPIO 引脚 | INMP441 模块引脚 | 信号说明 |
|---|---|---|
| 3.3V | VDD | 电源 (必须为 3.3V,5V会损坏麦克风) |
| GND | GND | 接地 |
| GPIO 32 (或其它) | SD | 串行数据输出 (数据线,从麦克风到ESP32) |
| GPIO 14 | WS | 字选择 (左右声道时钟) ,由麦克风输出给ESP32 |
| GPIO 15 | SCK | 串行时钟 (位时钟) ,由麦克风输出给ESP32 |
| (可选) GPIO 13 | L/R | 声道选择。接GND为左声道,接VDD为右声道。通常悬空或接地即可。 |
关键说明:
- 时钟方向:INMP441 是“主设备”,SCK 和 WS 是它的输出,必须连接到 ESP32 的对应 I2S 输入引脚。ESP32 在此配置中作为“从设备”接收时钟和数据。
- 引脚灵活性:上表中的 GPIO 32、14、15 是 ESP32 的默认 I2S 从设备接收引脚,推荐使用。理论上其他支持 I2S 的引脚也可用,但需要修改代码的引脚映射。
- 电源:务必使用 3.3V 供电。
📦 PlatformIO 配置 (platformio.ini)
继续使用强大的 AudioTools 库,它同样简化了录音流程。
ini
[env:esp32-dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
pschatzmann/ESP32-AudioTools @ ^1.0.8
pschatzmann/arduino-audio-tools @ ^1.1.9
💻 基础录音与串口绘图程序 (src/main.cpp)
以下代码将初始化 INMP441,连续采集音频数据,并将原始数据通过串口发送。你可以用 Arduino IDE 或 PlatformIO 的串口绘图器查看波形。
cpp
/**
* ESP32 + INMP441 基础录音测试
* 将音频原始数据通过串口输出,可用于绘图器查看波形
*/
#include <Arduino.h>
#include "AudioTools.h"
// ==================== 配置 ====================
// 定义I2S引脚 (根据你的实际连接调整!)
#define I2S_SD_IN 32 // INMP441的SD -> ESP32的GPIO 32
#define I2S_WS_IN 14 // INMP441的WS -> ESP32的GPIO 14
#define I2S_SCK_IN 15 // INMP441的SCK -> ESP32的GPIO 15
// 定义音频参数
const int sampleRate = 44100; // 采样率 (Hz)
const int bufferSize = 1024; // 缓冲区大小
// 创建I2S流对象用于输入(录音)
I2SStream i2sInput;
int16_t buffer[bufferSize]; // 用于存储音频样本的缓冲区(16位有符号整数)
void printAudioInfo(AudioInfo info) {
Serial.println("=== I2S麦克风初始化成功 ===");
Serial.printf("采样率: %d Hz\n", info.sample_rate);
Serial.printf("声道数: %d\n", info.channels);
Serial.printf("位深度: %d-bit\n", info.bits_per_sample);
Serial.println("开始采集音频...");
Serial.println("打开串口绘图器(Tools -> Serial Plotter)查看波形。");
}
void setup() {
Serial.begin(115200);
while (!Serial);
delay(500);
Serial.println("启动 INMP441 麦克风测试...");
// 配置I2S输入参数
auto i2sConfig = i2sInput.defaultConfig(RX_MODE);
i2sConfig.pin_data = I2S_SD_IN;
i2sConfig.pin_ws = I2S_WS_IN;
i2sConfig.pin_bck = I2S_SCK_IN;
i2sConfig.sample_rate = sampleRate;
i2sConfig.bits_per_sample = 16;
i2sConfig.channels = 1; // INMP441 单声道
i2sConfig.i2s_format = I2S_STD_FORMAT;
i2sConfig.is_master = false; // ESP32 作为从设备,使用麦克风提供的时钟!
i2sConfig.port_no = 0; // 使用I2S端口0
// 开始I2S输入
if (!i2sInput.begin(i2sConfig)) {
Serial.println("错误:I2S麦克风初始化失败!请检查接线和引脚定义。");
while (1); // 停止
}
printAudioInfo(i2sInput.audioInfo());
}
void loop() {
// 从I2S流读取一帧音频数据到缓冲区
size_t numSamplesRead = i2sInput.readBytes((uint8_t*)buffer, sizeof(buffer));
// 将读取到的每个16位样本通过串口发送,用于绘图
// 注意:对于串口绘图器,一次只需发送一个值(单声道)
for (int i = 0; i < numSamplesRead / sizeof(int16_t); i++) {
Serial.println(buffer[i]); // 将样本值直接打印
}
// 注意:高采样率下,Serial.print可能成为瓶颈,此代码仅用于演示。
// 实际应用时应处理或存储数据,而非全部打印。
}
🔬 如何测试与验证
- 上传代码:确保接线正确后,编译并上传代码到 ESP32。
- 打开串口监视器:波特率设为115200,查看初始化信息。
- 打开串口绘图器:在 PlatformIO 或 Arduino IDE 中,找到 Serial Plotter 功能并打开。你应该能看到随环境声音变化的实时波形图。对着麦克风说话或制造声音,观察波形变化。
📝 进阶应用:将录音保存到 SD 卡(WAV格式)
录制音频并保存是常见需求。以下是一个简化的框架,展示如何将 AudioTools 库的录音数据通过 WAVEncoder 保存为 WAV 文件到 SD 卡。
前提:你已经按之前指南接好 SD 卡模块并安装了所需库。
cpp
// 注意:此为高级示例框架,可能需要调整才能完全运行
#include <Arduino.h>
#include "AudioTools.h"
#include "AudioLibs/AudioSourceSD.h" // 用于SD卡写入
#include "AudioCodecs/CodecWAV.h"
#include <SD.h>
// ... 引脚定义和I2S输入配置与上文相同 ...
SDStream sd_out(5); // SD卡CS引脚为5
WAVEncoder wav_enc;
EncodedAudioStream encoder(&sd_out, &wav_enc); // 将WAV编码流指向SD卡
StreamCopy copier(encoder, i2sInput); // 将I2S输入复制到编码器
void setup() {
// ... 初始化串口、SD卡、I2S输入 ...
// 初始化SD卡输出流
if (!SD.begin(5)) { /* 错误处理 */ }
sd_out.begin("/recording.wav", FILE_WRITE); // 打开文件
encoder.begin(i2sInput.audioInfo()); // 以输入音频参数开始编码
wav_enc.begin(encoder); // 开始WAV编码
Serial.println("开始录音到SD卡...");
}
void loop() {
copier.copy(); // 持续录音
// 可以通过按钮或其他条件触发停止录音:encoder.end(); sd_out.end();
}
⚠️ 常见问题
| 问题 | 排查要点 |
|---|---|
| 没有数据/全是噪声 | 1. 时钟模式:确认 i2sConfig.is_master = false。 2. 引脚:最可能!反复检查 SCK, WS, SD 三条数据线是否接对。 3. 电源:确保是 3.3V,且 GND 已共地。 |
| 声音波形很小 | INMP441 灵敏度较高。尝试增大声源音量或调整代码中的增益(可对 buffer[i] 乘以一个系数后再发送)。 |
| 编译错误 | 确保 platformio.ini 中的 lib_deps 已正确添加 AudioTools 库。 |
| 程序运行不稳定 | 降低采样率(如改为 16000 Hz)或增加 bufferSize。 |
成功连接并采集到音频数据后,你可以将其用于语音唤醒、环境声分析、实时传输或与之前的播放功能结合实现回音消除等。如果你在具体实现中遇到问题,可以提供串口输出的错误信息,以便进一步诊断。
如何实现划词效果
最近做了一个划词的需求,想和大家分享一下。划词在富文本编辑器里比较常用,效果图如下:
本篇文章将一步一步带大家实现这个效果。
实现字数统计
前置知识
window.getSelection
window.getSelection 可获取选中文本信息和选择内容,示例代码如下:
document.addEventListener("mouseup", function () {
const selection = window.getSelection();
if (selection) {
console.log('selection', selection)
const selectedText = selection.toString();
console.log(selectedText);
}
});
执行结果截图:
实现输入框
主组件 TextCustom,包括两个组件:输入框和浮动工具栏。
这里的输入框是通过 div 元素模拟的,实现起来也很简单,只需要为 div 添加 contentEditable 属性 。
const TextCustom = () => {
return (
<div>
{/* 模拟输入框 */}
<div
id="editor" // 后面会用
contentEditable={true}
suppressContentEditableWarning={true} // suppressContentEditableWarning 是 React 框架中的一个特殊属性,用于抑制contentEditable 元素触发的常见警告
/>
</div>
);
};
实现浮动工具栏组件
浮动工具栏组件实现
浮动工具栏组件是通过固定定位实现的,工具栏的位置是跟随选中文本的位置改变而改变的,主要属性是 top 和 left,默认值都先设置成 0。
浮动工具栏组件的内容是选中文本的字数统计,先硬编码成“x 字”。
const FloatingToolbar = () => {
return (
<div
style={{
position: "fixed",
top: 0, // 在选中文本上方显示
left: 0, // 居中显示
transform: "translateX(-10%)", // 微调水平位置
background: "#fff",
border: "1px solid red",
zIndex: 10000,
}}
>
{/* 显示选中文本的字符数量 */}
<div>
📝 x 字
</div>
</div>
);
};
引入到主组件
const TextCustom = () => {
return (
<div>
{/* 模拟输入框 */}
<div
id="editor" // 后面会用
contentEditable={true}
suppressContentEditableWarning={true} // suppressContentEditableWarning 是 React 框架中的一个特殊属性,用于抑制contentEditable 元素触发的常见警告
/>
<FloatingToolbar />
</div>
);
};
实现自定义 hook
封装自定义 hook,可命名为 useTextSelection,入参是输入框 id,返回 selection 和 toolbarRef。
其中 selection 是用来获取文本选择信息,包括选中文本的位置和选中的文本内容;toolbarRef 是工具栏的引用,后面的鼠标事件会用到,大概结构如下。
const useTextSelection = (target) => {
// 存储文本选择信息
const [selection, setSelection] = useState(null);
// 标记用户是否正在与工具栏交互,比如点击工具栏
const isInToolbarRef = useRef(false);
// 工具栏 DOM 元素的引用
const toolbarRef = useRef(null);
useEffect(() => {
/**
* 处理文本选择变化事件
* 当用户在页面上选择文本时触发
*/
const handleSelectionChange = () => {
// 如果用户正在与工具栏交互,不处理选择变化
if (isInToolbarRef.current) return;
const selection = window.getSelection();
const selectedText = selection.toString().trim();
// 确保有选中文本并且选择范围有效
if (selectedText && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect(); // 获取选中文本的位置和尺寸
// 获取选中文本的锚点节点(选择的起始位置)
const anchorNode = selection.anchorNode;
// 获取目标编辑器元素
const targetElement = document.querySelector(target);
// 检查选择是否发生在目标编辑器内
const isInTarget =
targetElement &&
(targetElement.contains(anchorNode) || targetElement === anchorNode);
if (isInTarget) {
// 更新选择信息,显示工具栏
setSelection({
clientRect: rect, // 选中文本的位置信息
selectedText: selectedText, // 选中的文本内容
});
} else {
// 选择不在编辑器内,隐藏工具栏
setSelection(null);
}
} else {
// 没有选中文本,隐藏工具栏
setSelection(null);
}
};
/**
* 处理鼠标按下事件
* 用于检测用户是否点击了工具栏区域
* @param {MouseEvent} e - 鼠标事件对象
*/
const handleMouseDown = (e) => {
// 检查点击是否发生在工具栏区域内
if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
isInToolbarRef.current = true; // 用户正在与工具栏交互
} else {
isInToolbarRef.current = false; // 用户点击了工具栏外部
}
};
/**
* 处理鼠标抬起事件
* 用户完成选择操作后,延迟检查选择内容
*/
const handleMouseUp = () => {
// 延迟 100ms 执行,确保浏览器已完成选择操作
setTimeout(() => {
// 如果用户没有与工具栏交互,处理选择变化
if (!isInToolbarRef.current) {
handleSelectionChange();
}
}, 100);
};
/**
* 处理点击外部事件
* 当用户点击工具栏和编辑器外部时,隐藏工具栏
* @param {MouseEvent} e - 鼠标事件对象
*/
const handleClickOutside = (e) => {
// 检查点击是否发生在工具栏外部
if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
const targetElement = document.querySelector(target);
// 检查点击是否也发生在编辑器外部
if (targetElement && !targetElement.contains(e.target)) {
// 重置选择信息,隐藏工具栏
setSelection(null);
}
}
};
// 添加事件监听器
document.addEventListener("selectionchange", handleSelectionChange); // 监听文本选择变化
document.addEventListener("mousedown", handleMouseDown); // 监听鼠标按下
document.addEventListener("mouseup", handleMouseUp); // 监听鼠标抬起
document.addEventListener("mousedown", handleClickOutside); // 监听外部点击
// 清理函数:组件卸载时移除事件监听器
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [target]); // 依赖项:当 target 变化时重新运行 effect
return {
selection, // 当前的选择信息
toolbarRef, // 工具栏的 ref 引用
};
}
主组件修改如下:
const TextCustom = () => {
// 使用自定义 Hook 获取文本选择信息和工具栏引用
const { selection, toolbarRef } = useTextSelection("#editor");
return (
<div>
<div
id="editor"
contentEditable={true}
className={styles.editor}
suppressContentEditableWarning={true}
/>
<FloatingToolbar
selection={selection}
toolbarRef={toolbarRef}
/>
</div>
);
};
浮动工具栏修改如下:
const FloatingToolbar = ({
selection,
toolbarRef,
}) => {
// 如果没有选择信息,不渲染工具栏
if (!selection) return null;
const { clientRect, selectedText } = selection;
// 如果没有选中文本或位置信息,不渲染工具栏
if (!selectedText || !clientRect) return null;
return (
<div
ref={toolbarRef}
style={{
position: "fixed",
top: clientRect.top - 65, // 在选中文本上方显示,65 不是固定的,可以自行调整
left: clientRect.left + clientRect.width / 2, // 居中显示
transform: "translateX(-10%)", // 微调水平位置
background: "#fff",
border: "1px solid red",
zIndex: 10000,
}}
>
{/* 显示选中文本的字符数量 */}
<div>
📝 {selectedText.length} 字
</div>
</div>
);
};
实现加粗
前置知识
document.execCommand
document.execCommand 可对选中文本进行操作,比如加粗、斜体、下划线等,举个简单的例子。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文本编辑器示例</title>
<style>
.editor {
border: 1px solid #ccc;
padding: 10px;
min-height: 200px;
}
</style>
</head>
<body>
<div contentEditable="true" class="editor">
请在这里输入文本,然后选中一部分文本并点击相应的按钮。
</div>
<button onclick="handleBold()">加粗</button>
<button onclick="handleItalic()">斜体</button>
<button onclick="handleUnderline()">下划线</button>
<script>
function handleBold() {
document.execCommand("bold", false, null);
}
function handleItalic() {
document.execCommand("italic", false, null);
}
function handleUnderline() {
document.execCommand("underline", false, null);
}
</script>
</body>
</html>
document.queryCommandState("bold")
document.queryCommandState("bold") 可获取加粗状态,返回布尔值。当选择的文本,只有部分加粗时也会返回 true。
修改主组件
从自定义 Hook 中获取 isBoldActive 和 handleBoldClick,并传给浮动工具栏组件。
const TextCustom = () => {
// 使用自定义 Hook 获取文本选择信息
const {
selection,
toolbarRef,
isBoldActive,
handleBoldClick
} = useTextSelection("#editor");
return (
<div>
{/* 可编辑的文本区域 */}
<div
id="editor"
contentEditable={true}
suppressContentEditableWarning={true}
/>
{/* 浮动工具栏组件 */}
<FloatingToolbar
selection={selection}
toolbarRef={toolbarRef}
onBoldClick={handleBoldClick}
isBoldActive={isBoldActive}
/>
</div>
);
};
修改浮动工具栏
const FloatingToolbar = ({
selection,
toolbarRef,
onBoldClick,
isBoldActive = false,
}) => {
// 如果没有选择信息,不渲染工具栏
if (!selection) return null;
const { clientRect, selectedText, range } = selection;
// 如果没有选中文本或位置信息,不渲染工具栏
if (!selectedText || !clientRect || !range) return null;
return (
<div
ref={toolbarRef}
className="floating-toolbar"
style={{
position: "fixed",
top: clientRect.top - 65,
left: clientRect.left + clientRect.width / 2,
transform: "translateX(-10%)",
background: "#fff",
zIndex: 10000,
minWidth: "180px",
}}
>
{/* 显示选中文本的字符数量 */}
<div style={{ fontSize: "12px", fontWeight: "bold" }}>
📝 {selectedText.length} 字
</div>
{/* 加粗按钮 */}
<button
onClick={() => onBoldClick && onBoldClick(range)}
title="加粗"
style={{
background: isBoldActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
}}
>
<strong>B</strong>
</button>
</div>
);
};
修改自定义 Hook
const useTextSelection = (target) => {
const [selection, setSelection] = useState(null);
const [isBoldActive, setIsBoldActive] = useState(false);
// 标记用户是否正在与工具栏交互
const isInToolbarRef = useRef(false);
// 工具栏 DOM 元素的引用
const toolbarRef = useRef(null);
// 获取加粗状态
const getBoldState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("bold");
}, []);
useEffect(() => {
/**
* 处理文本选择变化事件
*/
const handleSelectionChange = () => {
// 如果用户正在与工具栏交互,不处理选择变化
if (isInToolbarRef.current) return;
const selection = window.getSelection();
const selectedText = selection.toString().trim();
// 确保有选中文本并且选择范围有效
if (selectedText && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const anchorNode = selection.anchorNode;
const targetElement = document.querySelector(target);
// 检查选择是否发生在目标编辑器内
const isInTarget =
targetElement &&
(targetElement.contains(anchorNode) || targetElement === anchorNode);
if (isInTarget) {
// 保存选择范围
// 更新选择信息,显示工具栏
setSelection({
clientRect: rect,
selectedText: selectedText,
range: range,
});
// 获取当前加粗状态
setIsBoldActive(getBoldState()); // 回显加粗使用
} else {
// 选择不在编辑器内,隐藏工具栏
setSelection(null);
}
} else {
// 没有选中文本,隐藏工具栏
setSelection(null);
}
};
/**
* 处理鼠标按下事件
*/
const handleMouseDown = (e) => {
// 检查点击是否发生在工具栏区域内
if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
isInToolbarRef.current = true;
} else {
isInToolbarRef.current = false;
}
};
/**
* 处理鼠标抬起事件
*/
const handleMouseUp = () => {
// 延迟执行,确保浏览器已完成选择操作
setTimeout(() => {
if (!isInToolbarRef.current) {
handleSelectionChange();
}
}, 100);
};
/**
* 处理点击外部事件
*/
const handleClickOutside = (e) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
const targetElement = document.querySelector(target);
if (targetElement && !targetElement.contains(e.target)) {
setSelection(null);
}
}
};
// 添加事件监听器
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("mousedown", handleClickOutside);
// 清理函数:组件卸载时移除事件监听器
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [target, getBoldState]);
// 处理加粗操作
const handleBoldClick = useCallback((range) => {
if (!range) return;
// 执行加粗命令
document.execCommand("bold", false, null);
setIsBoldActive(getBoldState());
}, [getBoldState]);
return {
selection,
toolbarRef,
isBoldActive,
handleBoldClick,
};
};
实现斜体
加粗理解了,斜体就简单了,流程是一模一样的。
修改主组件
从自定义 Hook 中获取 isItalicActive 和 handleItalicClick,并传给浮动工具栏组件。
const TextCustom = () => {
// 使用自定义 Hook 获取文本选择信息
const {
selection,
toolbarRef,
isBoldActive,
isItalicActive,
handleBoldClick,
handleItalicClick
} = useTextSelection("#editor");
return (
<div>
{/* 可编辑的文本区域 */}
<div
id="editor"
contentEditable={true}
suppressContentEditableWarning={true}
/>
{/* 浮动工具栏组件 */}
<FloatingToolbar
selection={selection}
toolbarRef={toolbarRef}
onBoldClick={handleBoldClick}
onItalicClick={handleItalicClick}
isBoldActive={isBoldActive}
isItalicActive={isItalicActive}
/>
</div>
);
};
修改浮动工具栏
增加斜体按钮。
const FloatingToolbar = ({
selection,
toolbarRef,
onBoldClick,
onItalicClick,
isBoldActive = false,
isItalicActive = false,
}) => {
// 如果没有选择信息,不渲染工具栏
if (!selection) return null;
const { clientRect, selectedText, range } = selection;
// 如果没有选中文本或位置信息,不渲染工具栏
if (!selectedText || !clientRect || !range) return null;
return (
<div
ref={toolbarRef}
className="floating-toolbar"
style={{
position: "fixed",
top: clientRect.top - 65,
left: clientRect.left + clientRect.width / 2,
transform: "translateX(-10%)",
background: "#fff",
zIndex: 10000,
minWidth: "180px",
}}
>
{/* 显示选中文本的字符数量 */}
<div style={{ fontSize: "12px", fontWeight: "bold" }}>
📝 {selectedText.length} 字
</div>
{/* 加粗按钮 */}
<button
onClick={() => onBoldClick && onBoldClick(range)}
title="加粗"
style={{
background: isBoldActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
}}
>
<strong>B</strong>
</button>
{/* 斜体按钮 */}
<button
onClick={() => onItalicClick && onItalicClick(range)}
title="斜体"
style={{
background: isItalicActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontStyle: "italic",
}}
>
I
</button>
</div>
);
};
修改自定义 Hook
主要有两处改动:
- 封装了
updateFormattingStates函数用于更新所有格式化状态,包括加粗和斜体。 - 增加处理斜体操作。
const useTextSelection = (target) => {
const [selection, setSelection] = useState(null);
const [isBoldActive, setIsBoldActive] = useState(false);
const [isItalicActive, setIsItalicActive] = useState(false);
// 标记用户是否正在与工具栏交互
const isInToolbarRef = useRef(false);
// 工具栏 DOM 元素的引用
const toolbarRef = useRef(null);
// 获取加粗状态
const getBoldState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("bold");
}, []);
// 获取斜体状态
const getItalicState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("italic");
}, []);
// 更新所有格式化状态
const updateFormattingStates = useCallback(() => {
setIsBoldActive(getBoldState());
setIsItalicActive(getItalicState());
}, [getBoldState, getItalicState]);
useEffect(() => {
/**
* 处理文本选择变化事件
*/
const handleSelectionChange = () => {
// 如果用户正在与工具栏交互,不处理选择变化
if (isInToolbarRef.current) return;
const selection = window.getSelection();
const selectedText = selection.toString().trim();
// 确保有选中文本并且选择范围有效
if (selectedText && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const anchorNode = selection.anchorNode;
const targetElement = document.querySelector(target);
// 检查选择是否发生在目标编辑器内
const isInTarget =
targetElement &&
(targetElement.contains(anchorNode) || targetElement === anchorNode);
if (isInTarget) {
// 保存选择范围
// 更新选择信息,显示工具栏
setSelection({
clientRect: rect,
selectedText: selectedText,
range: range,
});
// 获取当前格式化状态
updateFormattingStates();
} else {
// 选择不在编辑器内,隐藏工具栏
setSelection(null);
}
} else {
// 没有选中文本,隐藏工具栏
setSelection(null);
}
};
/**
* 处理鼠标按下事件
*/
const handleMouseDown = (e) => {
// 检查点击是否发生在工具栏区域内
if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
isInToolbarRef.current = true;
} else {
isInToolbarRef.current = false;
}
};
/**
* 处理鼠标抬起事件
*/
const handleMouseUp = () => {
// 延迟执行,确保浏览器已完成选择操作
setTimeout(() => {
if (!isInToolbarRef.current) {
handleSelectionChange();
}
}, 100);
};
/**
* 处理点击外部事件
*/
const handleClickOutside = (e) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
const targetElement = document.querySelector(target);
if (targetElement && !targetElement.contains(e.target)) {
setSelection(null);
}
}
};
// 添加事件监听器
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("mousedown", handleClickOutside);
// 清理函数:组件卸载时移除事件监听器
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [target, getBoldState]);
// 处理加粗操作
const handleBoldClick = useCallback((range) => {
if (!range) return;
// 执行加粗命令
document.execCommand("bold", false, null);
updateFormattingStates();
}, [updateFormattingStates]);
// 处理斜体操作
const handleItalicClick = useCallback((range) => {
if (!range) return;
// 执行斜体命令
document.execCommand("italic", false, null);
// 恢复选择范围并更新状态
updateFormattingStates();
}, [updateFormattingStates]);
return {
selection,
toolbarRef,
isBoldActive,
handleBoldClick,
};
};
实现下划线
修改主组件
从自定义 Hook 中获取 isUnderlineActive 和 handleUnderlineClick,并传给浮动工具栏组件。
const TextCustom = () => {
// 使用自定义 Hook 获取文本选择信息
const {
selection,
toolbarRef,
isBoldActive,
isItalicActive,
isUnderlineActive,
handleBoldClick,
handleItalicClick,
handleUnderlineClick,
} = useTextSelection("#editor");
return (
<div>
{/* 可编辑的文本区域 */}
<div
id="editor"
contentEditable={true}
suppressContentEditableWarning={true}
/>
{/* 浮动工具栏组件 */}
<FloatingToolbar
selection={selection}
toolbarRef={toolbarRef}
onBoldClick={handleBoldClick}
onItalicClick={handleItalicClick}
onUnderlineClick={handleUnderlineClick}
isBoldActive={isBoldActive}
isItalicActive={isItalicActive}
isUnderlineActive={isUnderlineActive}
/>
</div>
);
};
修改浮动工具栏
增加下划线按钮。
const FloatingToolbar = ({
selection,
toolbarRef,
onBoldClick,
onItalicClick,
onUnderlineClick,
isBoldActive = false,
isItalicActive = false,
isUnderlineActive = false,
}) => {
// 如果没有选择信息,不渲染工具栏
if (!selection) return null;
const { clientRect, selectedText, range } = selection;
// 如果没有选中文本或位置信息,不渲染工具栏
if (!selectedText || !clientRect || !range) return null;
return (
<div
ref={toolbarRef}
className="floating-toolbar"
style={{
position: "fixed",
top: clientRect.top - 65,
left: clientRect.left + clientRect.width / 2,
transform: "translateX(-10%)",
background: "#fff",
zIndex: 10000,
minWidth: "180px",
}}
>
{/* 显示选中文本的字符数量 */}
<div style={{ fontSize: "12px", fontWeight: "bold" }}>
📝 {selectedText.length} 字
</div>
{/* 加粗按钮 */}
<button
onClick={() => onBoldClick && onBoldClick(range)}
title="加粗"
style={{
background: isBoldActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
}}
>
<strong>B</strong>
</button>
{/* 斜体按钮 */}
<button
onClick={() => onItalicClick && onItalicClick(range)}
title="斜体"
style={{
background: isItalicActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontStyle: "italic",
}}
>
I
</button>
{/* 下划线按钮 */}
<button
onClick={() => onUnderlineClick && onUnderlineClick(range)}
title="下划线"
style={{
background: isUnderlineActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
textDecoration: "underline",
}}
>
U
</button>
</div>
);
};
修改自定义 Hook
主要有两处改动:
- 增加获取下划线状态
- 增加处理下划线操作
const useTextSelection = (target) => {
const [selection, setSelection] = useState(null);
const [isBoldActive, setIsBoldActive] = useState(false);
const [isItalicActive, setIsItalicActive] = useState(false);
const [isUnderlineActive, setIsUnderlineActive] = useState(false);
// 标记用户是否正在与工具栏交互
const isInToolbarRef = useRef(false);
// 工具栏 DOM 元素的引用
const toolbarRef = useRef(null);
// 获取加粗状态
const getBoldState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("bold");
}, []);
// 获取斜体状态
const getItalicState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("italic");
}, []);
// 获取下划线状态
const getUnderlineState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("underline");
}, []);
// 更新所有格式化状态
const updateFormattingStates = useCallback(() => {
setIsBoldActive(getBoldState());
setIsItalicActive(getItalicState());
setIsUnderlineActive(getUnderlineState());
}, [getBoldState, getItalicState, getUnderlineState]);
useEffect(() => {
/**
* 处理文本选择变化事件
*/
const handleSelectionChange = () => {
// 如果用户正在与工具栏交互,不处理选择变化
if (isInToolbarRef.current) return;
const selection = window.getSelection();
const selectedText = selection.toString().trim();
// 确保有选中文本并且选择范围有效
if (selectedText && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const anchorNode = selection.anchorNode;
const targetElement = document.querySelector(target);
// 检查选择是否发生在目标编辑器内
const isInTarget =
targetElement &&
(targetElement.contains(anchorNode) || targetElement === anchorNode);
if (isInTarget) {
// 保存选择范围
// 更新选择信息,显示工具栏
setSelection({
clientRect: rect,
selectedText: selectedText,
range: range,
});
// 获取当前格式化状态
updateFormattingStates();
} else {
// 选择不在编辑器内,隐藏工具栏
setSelection(null);
}
} else {
// 没有选中文本,隐藏工具栏
setSelection(null);
}
};
/**
* 处理鼠标按下事件
*/
const handleMouseDown = (e) => {
// 检查点击是否发生在工具栏区域内
if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
isInToolbarRef.current = true;
} else {
isInToolbarRef.current = false;
}
};
/**
* 处理鼠标抬起事件
*/
const handleMouseUp = () => {
// 延迟执行,确保浏览器已完成选择操作
setTimeout(() => {
if (!isInToolbarRef.current) {
handleSelectionChange();
}
}, 100);
};
/**
* 处理点击外部事件
*/
const handleClickOutside = (e) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
const targetElement = document.querySelector(target);
if (targetElement && !targetElement.contains(e.target)) {
setSelection(null);
}
}
};
// 添加事件监听器
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("mousedown", handleClickOutside);
// 清理函数:组件卸载时移除事件监听器
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [target, getBoldState]);
// 处理加粗操作
const handleBoldClick = useCallback((range) => {
if (!range) return;
// 执行加粗命令
document.execCommand("bold", false, null);
updateFormattingStates();
}, [updateFormattingStates]);
// 处理斜体操作
const handleItalicClick = useCallback((range) => {
if (!range) return;
// 执行斜体命令
document.execCommand("italic", false, null);
// 恢复选择范围并更新状态
updateFormattingStates();
}, [updateFormattingStates]);
// 处理下划线操作
const handleUnderlineClick = useCallback((range) => {
if (!range) return;
// 执行下划线命令
document.execCommand("underline", false, null);
// 更新状态
updateFormattingStates();
}, [updateFormattingStates]);
return {
selection,
toolbarRef,
isBoldActive,
handleBoldClick,
};
};
实现字号
修改主组件
从自定义 Hook 中获取 fontSize 和 handleFontSizeChange,并传给浮动工具栏组件。
const TextCustom = () => {
// 使用自定义 Hook 获取文本选择信息
const {
selection,
toolbarRef,
isBoldActive,
isItalicActive,
isUnderlineActive,
fontSize,
handleBoldClick,
handleItalicClick,
handleUnderlineClick,
handleFontSizeChange,
} = useTextSelection("#editor");
return (
<div>
{/* 可编辑的文本区域 */}
<div
id="editor"
contentEditable={true}
suppressContentEditableWarning={true}
/>
{/* 浮动工具栏组件 */}
<FloatingToolbar
selection={selection}
toolbarRef={toolbarRef}
onBoldClick={handleBoldClick}
onItalicClick={handleItalicClick}
onUnderlineClick={handleUnderlineClick}
onFontSizeChange={handleFontSizeChange}
isBoldActive={isBoldActive}
isItalicActive={isItalicActive}
isUnderlineActive={isUnderlineActive}
fontSize={fontSize}
/>
</div>
);
};
修改浮动工具栏
主要增加字号选择器。
const FloatingToolbar = ({
selection,
toolbarRef,
onBoldClick,
onItalicClick,
onUnderlineClick,
onFontSizeChange,
isBoldActive = false,
isItalicActive = false,
isUnderlineActive = false,
fontSize = "3",
}) => {
// 如果没有选择信息,不渲染工具栏
if (!selection) return null;
const { clientRect, selectedText, range } = selection;
// 如果没有选中文本或位置信息,不渲染工具栏
if (!selectedText || !clientRect || !range) return null;
return (
<div
ref={toolbarRef}
className="floating-toolbar"
style={{
position: "fixed",
top: clientRect.top - 65,
left: clientRect.left + clientRect.width / 2,
transform: "translateX(-10%)",
background: "#fff",
zIndex: 10000,
minWidth: "180px",
}}
>
{/* 显示选中文本的字符数量 */}
<div style={{ fontSize: "12px", fontWeight: "bold" }}>
📝 {selectedText.length} 字
</div>
{/* 加粗按钮 */}
<button
onClick={() => onBoldClick && onBoldClick(range)}
title="加粗"
style={{
background: isBoldActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
}}
>
<strong>B</strong>
</button>
{/* 斜体按钮 */}
<button
onClick={() => onItalicClick && onItalicClick(range)}
title="斜体"
style={{
background: isItalicActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontStyle: "italic",
}}
>
I
</button>
{/* 下划线按钮 */}
<button
onClick={() => onUnderlineClick && onUnderlineClick(range)}
title="下划线"
style={{
background: isUnderlineActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
textDecoration: "underline",
}}
>
U
</button>
{/* 字号选择器 */}
<select
value={fontSize}
onChange={(e) => onFontSizeChange && onFontSizeChange(e.target.value, range)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
style={{
background: "#999",
color: "white",
border: "1px solid #999",
borderRadius: "4px",
padding: "6px 10px",
cursor: "pointer",
fontSize: "12px",
width: "90px",
appearance: "auto",
}}
>
<option value="1">12px</option>
<option value="2">14px</option>
<option value="3">16px</option>
<option value="4">18px</option>
<option value="5">24px</option>
<option value="6">32px</option>
<option value="7">48px</option>
</select>
{/* 颜色选择器 */}
<input
type="color"
value={color}
onChange={(e) => onColorChange && onColorChange(e.target.value, range)}
title="文字颜色"
style={{
width: "36px",
height: "36px",
border: "2px solid #4a627a",
borderRadius: "6px",
cursor: "pointer",
padding: "0",
backgroundColor: color || "#ffffff",
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
};
修改自定义 Hook
字号和后面的颜色都是通过增加嵌套 span 标签实现的。
const useTextSelection = (target) => {
const [selection, setSelection] = useState(null);
const [isBoldActive, setIsBoldActive] = useState(false);
const [isItalicActive, setIsItalicActive] = useState(false);
const [isUnderlineActive, setIsUnderlineActive] = useState(false);
const [fontSize, setFontSize] = useState("3");
// 标记用户是否正在与工具栏交互
const isInToolbarRef = useRef(false);
// 工具栏 DOM 元素的引用
const toolbarRef = useRef(null);
// 获取加粗状态
const getBoldState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("bold");
}, []);
// 获取斜体状态
const getItalicState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("italic");
}, []);
// 获取下划线状态
const getUnderlineState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("underline");
}, []);
// 获取当前选择的字号
const getFontSizeState = useCallback(() => {
const selection = window.getSelection();
if (!selection.rangeCount) return "3";
const range = selection.getRangeAt(0);
let element = range.startContainer;
if (element.nodeType !== Node.ELEMENT_NODE) {
element = element.parentElement;
}
// 遍历父元素查找字体大小
while (element && element !== document.querySelector(target)) {
if (element.nodeType === Node.ELEMENT_NODE) {
const computedStyle = window.getComputedStyle(element);
const computedFontSize = computedStyle.fontSize;
if (computedFontSize) {
const sizeInPx = parseInt(computedFontSize);
if (sizeInPx >= 42) return "7";
else if (sizeInPx >= 28) return "6";
else if (sizeInPx >= 22) return "5";
else if (sizeInPx >= 17) return "4";
else if (sizeInPx >= 15) return "3";
else if (sizeInPx >= 13) return "2";
else return "1";
}
}
element = element.parentElement;
}
return "3";
}, [target]);
// 更新所有格式化状态
const updateFormattingStates = useCallback(() => {
setIsBoldActive(getBoldState());
setIsItalicActive(getItalicState());
setIsUnderlineActive(getUnderlineState());
setFontSize(getFontSizeState());
}, [getBoldState, getItalicState, getUnderlineState, getFontSizeState]);
useEffect(() => {
/**
* 处理文本选择变化事件
*/
const handleSelectionChange = () => {
// 如果用户正在与工具栏交互,不处理选择变化
if (isInToolbarRef.current) return;
const selection = window.getSelection();
const selectedText = selection.toString().trim();
// 确保有选中文本并且选择范围有效
if (selectedText && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const anchorNode = selection.anchorNode;
const targetElement = document.querySelector(target);
// 检查选择是否发生在目标编辑器内
const isInTarget =
targetElement &&
(targetElement.contains(anchorNode) || targetElement === anchorNode);
if (isInTarget) {
// 保存选择范围
// 更新选择信息,显示工具栏
setSelection({
clientRect: rect,
selectedText: selectedText,
range: range,
});
// 获取当前格式化状态
updateFormattingStates();
} else {
// 选择不在编辑器内,隐藏工具栏
setSelection(null);
}
} else {
// 没有选中文本,隐藏工具栏
setSelection(null);
}
};
/**
* 处理鼠标按下事件
*/
const handleMouseDown = (e) => {
// 检查点击是否发生在工具栏区域内
if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
isInToolbarRef.current = true;
} else {
isInToolbarRef.current = false;
}
};
/**
* 处理鼠标抬起事件
*/
const handleMouseUp = () => {
// 延迟执行,确保浏览器已完成选择操作
setTimeout(() => {
if (!isInToolbarRef.current) {
handleSelectionChange();
}
}, 100);
};
/**
* 处理点击外部事件
*/
const handleClickOutside = (e) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
const targetElement = document.querySelector(target);
if (targetElement && !targetElement.contains(e.target)) {
setSelection(null);
}
}
};
// 添加事件监听器
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("mousedown", handleClickOutside);
// 清理函数:组件卸载时移除事件监听器
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [target, getBoldState]);
// 设置字体大小
const setFontSizeCommand = useCallback((sizeValue, range) => {
if (!range) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
const newRange = selection.getRangeAt(0);
const span = document.createElement("span");
const sizeMap = {
"1": "12px",
"2": "14px",
"3": "16px",
"4": "18px",
"5": "24px",
"6": "32px",
"7": "48px",
};
span.style.fontSize = sizeMap[sizeValue] || "16px";
try {
// 检查选择范围是否已折叠(即没有选中文本,只有一个光标位置)
// 如果是折叠状态,不执行字体大小设置操作
if (newRange.collapsed) return;
const clonedRange = newRange.cloneRange();
const fragment = clonedRange.extractContents();
span.appendChild(fragment);
clonedRange.insertNode(span);
// 清除当前选择,然后重新选中刚刚插入的span元素内容
// 这样用户可以继续对同一段文本进行其他操作
selection.removeAllRanges();
const newSelectionRange = document.createRange();
newSelectionRange.selectNodeContents(span);
selection.addRange(newSelectionRange);
} catch (error) {
console.error("设置字体大小失败:", error);
}
// 更新状态
setFontSize(sizeValue);
}, []);
// 处理加粗操作
const handleBoldClick = useCallback((range) => {
if (!range) return;
// 执行加粗命令
document.execCommand("bold", false, null);
updateFormattingStates();
}, [updateFormattingStates]);
// 处理斜体操作
const handleItalicClick = useCallback((range) => {
if (!range) return;
// 执行斜体命令
document.execCommand("italic", false, null);
// 恢复选择范围并更新状态
updateFormattingStates();
}, [updateFormattingStates]);
// 处理下划线操作
const handleUnderlineClick = useCallback((range) => {
if (!range) return;
// 执行下划线命令
document.execCommand("underline", false, null);
// 更新状态
updateFormattingStates();
}, [updateFormattingStates]);
// 处理字号变化
const handleFontSizeChange = useCallback((sizeValue, range) => {
if (!range) return;
setFontSizeCommand(sizeValue, range);
updateFormattingStates();
}, [setFontSizeCommand, updateFormattingStates]);
return {
selection,
toolbarRef,
isBoldActive,
handleBoldClick,
};
};
实现颜色
修改主组件
从自定义 Hook 中获取 color 和 handleColorChange,并传给浮动工具栏组件。
const TextCustom = () => {
// 使用自定义 Hook 获取文本选择信息
const {
selection,
toolbarRef,
isBoldActive,
isItalicActive,
isUnderlineActive,
fontSize,
handleBoldClick,
handleItalicClick,
handleUnderlineClick,
handleFontSizeChange,
} = useTextSelection("#editor");
return (
<div>
{/* 可编辑的文本区域 */}
<div
id="editor"
contentEditable={true}
suppressContentEditableWarning={true}
/>
{/* 浮动工具栏组件 */}
<FloatingToolbar
selection={selection}
toolbarRef={toolbarRef}
onBoldClick={handleBoldClick}
onItalicClick={handleItalicClick}
onUnderlineClick={handleUnderlineClick}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
isBoldActive={isBoldActive}
isItalicActive={isItalicActive}
isUnderlineActive={isUnderlineActive}
fontSize={fontSize}
color={color}
/>
</div>
);
};
修改浮动工具栏
const FloatingToolbar = ({
selection,
toolbarRef,
onBoldClick,
onItalicClick,
onUnderlineClick,
onFontSizeChange,
onColorChange,
isBoldActive = false,
isItalicActive = false,
isUnderlineActive = false,
fontSize = "3",
color = "#000000",
}) => {
// 如果没有选择信息,不渲染工具栏
if (!selection) return null;
const { clientRect, selectedText, range } = selection;
// 如果没有选中文本或位置信息,不渲染工具栏
if (!selectedText || !clientRect || !range) return null;
return (
<div
ref={toolbarRef}
className="floating-toolbar"
style={{
position: "fixed",
top: clientRect.top - 65,
left: clientRect.left + clientRect.width / 2,
transform: "translateX(-10%)",
background: "#fff",
zIndex: 10000,
minWidth: "180px",
}}
>
{/* 显示选中文本的字符数量 */}
<div style={{ fontSize: "12px", fontWeight: "bold" }}>
📝 {selectedText.length} 字
</div>
{/* 加粗按钮 */}
<button
onClick={() => onBoldClick && onBoldClick(range)}
title="加粗"
style={{
background: isBoldActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
}}
>
<strong>B</strong>
</button>
{/* 斜体按钮 */}
<button
onClick={() => onItalicClick && onItalicClick(range)}
title="斜体"
style={{
background: isItalicActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontStyle: "italic",
}}
>
I
</button>
{/* 下划线按钮 */}
<button
onClick={() => onUnderlineClick && onUnderlineClick(range)}
title="下划线"
style={{
background: isUnderlineActive ? "#3498db" : "#999",
border: "none",
color: "white",
width: "36px",
height: "36px",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
textDecoration: "underline",
}}
>
U
</button>
{/* 字号选择器 */}
<select
value={fontSize}
onChange={(e) => onFontSizeChange && onFontSizeChange(e.target.value, range)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
style={{
background: "#999",
color: "white",
border: "1px solid #999",
borderRadius: "4px",
padding: "6px 10px",
cursor: "pointer",
fontSize: "12px",
width: "90px",
appearance: "auto",
}}
>
<option value="1">12px</option>
<option value="2">14px</option>
<option value="3">16px</option>
<option value="4">18px</option>
<option value="5">24px</option>
<option value="6">32px</option>
<option value="7">48px</option>
</select>
{/* 颜色选择器 */}
<input
type="color"
value={color}
onChange={(e) => onColorChange && onColorChange(e.target.value, range)}
title="文字颜色"
style={{
width: "36px",
height: "36px",
border: "2px solid #4a627a",
borderRadius: "6px",
cursor: "pointer",
padding: "0",
backgroundColor: color || "#ffffff",
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
};
修改自定义 Hook
在看具体实现前,先认识一个 API,document.createTreeWalke 是一个用于 深度优先遍历 DOM 树 的接口。
基本语法
const walker = document.createTreeWalker(
root, // 遍历的起始节点
whatToShow, // 要显示哪些类型的节点
filter // 可选的过滤器函数
);
简单示例
<div id="container">
<h1>标题</h1>
<p>段落 <span>文本</span></p>
<ul>
<li>项目1</li>
<li>项目2</li>
</ul>
</div>
<script>
const container = document.getElementById('container');
// 创建 TreeWalker: 从 container 开始,只遍历元素节点
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_ELEMENT, // 只显示元素节点
null // 不过滤
);
const nodes = [];
let node;
while (node = walker.nextNode()) {
nodes.push(node.tagName);
}
console.log(nodes); // 输出: ["H1", "P", "SPAN", "UL", "LI", "LI"] 深度优先遍历顺序
</script>
const useTextSelection = (target) => {
const [selection, setSelection] = useState(null);
const [isBoldActive, setIsBoldActive] = useState(false);
const [isItalicActive, setIsItalicActive] = useState(false);
const [isUnderlineActive, setIsUnderlineActive] = useState(false);
const [fontSize, setFontSize] = useState("3");
const [color, setColor] = useState("#000000");
// 标记用户是否正在与工具栏交互
const isInToolbarRef = useRef(false);
// 工具栏 DOM 元素的引用
const toolbarRef = useRef(null);
// RGB 转十六进制辅助函数
const rgbToHex = useCallback((rgb) => {
// 如果已经是十六进制格式,直接返回
if (rgb.startsWith("#")) return rgb;
// 匹配 RGB 或 RGBA 格式:rgb(255, 255, 255) 或 rgba(255, 255, 255, 0.5)
const match = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
if (match) {
const r = parseInt(match[1]);
const g = parseInt(match[2]);
const b = parseInt(match[3]);
// 将 RGB 值转换为十六进制,并确保两位显示
return (
"#" +
r.toString(16).padStart(2, "0") +
g.toString(16).padStart(2, "0") +
b.toString(16).padStart(2, "0")
);
}
// 无法解析的颜色,返回默认黑色
return "#000000";
}, []);
// 获取加粗状态
const getBoldState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("bold");
}, []);
// 获取斜体状态
const getItalicState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("italic");
}, []);
// 获取下划线状态
const getUnderlineState = useCallback(() => {
if (!document.queryCommandState) return false;
return document.queryCommandState("underline");
}, []);
// 获取当前选择的字号
const getFontSizeState = useCallback(() => {
const selection = window.getSelection();
if (!selection.rangeCount) return "3";
const range = selection.getRangeAt(0);
let element = range.startContainer;
if (element.nodeType !== Node.ELEMENT_NODE) {
element = element.parentElement;
}
// 遍历父元素查找字体大小
while (element && element !== document.querySelector(target)) {
if (element.nodeType === Node.ELEMENT_NODE) {
const computedStyle = window.getComputedStyle(element);
const computedFontSize = computedStyle.fontSize;
if (computedFontSize) {
const sizeInPx = parseInt(computedFontSize);
if (sizeInPx >= 42) return "7";
else if (sizeInPx >= 28) return "6";
else if (sizeInPx >= 22) return "5";
else if (sizeInPx >= 17) return "4";
else if (sizeInPx >= 15) return "3";
else if (sizeInPx >= 13) return "2";
else return "1";
}
}
element = element.parentElement;
}
return "3";
}, [target]);
// 获取当前选择的颜色
const getColorState = useCallback(() => {
const selection = window.getSelection();
if (!selection.rangeCount) return "#000000";
const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer;
let element = range.startContainer;
if (element.nodeType !== Node.ELEMENT_NODE) {
element = element.parentElement;
}
// 遍历父元素查找颜色
while (element && element !== document.querySelector(target)) {
if (element.nodeType === Node.ELEMENT_NODE) {
const computedStyle = window.getComputedStyle(element);
const computedColor = computedStyle.color;
// 检查颜色是否为有效值(非透明、非默认黑色)
if (
computedColor &&
computedColor !== "rgba(0, 0, 0, 0)" &&
computedColor !== "transparent" &&
!computedColor.startsWith("rgba(0, 0, 0, ")
) {
return rgbToHex(computedColor);
}
}
element = element.parentElement;
}
// 如果没有找到,检查选择范围内的元素
const walker = document.createTreeWalker(
commonAncestor,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: (node) =>
range.intersectsNode(node)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT,
},
);
// 当向上遍历找不到颜色时,它会深度遍历选择范围内的所有元素,找到第一个有效的文本颜色。
while ((element = walker.nextNode())) {
const computedStyle = window.getComputedStyle(element);
const computedColor = computedStyle.color;
if (
computedColor &&
computedColor !== "rgba(0, 0, 0, 0)" &&
computedColor !== "transparent" &&
!computedColor.startsWith("rgba(0, 0, 0, ")
) {
return rgbToHex(computedColor);
}
}
return "#000000";
}, [target, rgbToHex]);
// 更新所有格式化状态
const updateFormattingStates = useCallback(() => {
setIsBoldActive(getBoldState());
setIsItalicActive(getItalicState());
setIsUnderlineActive(getUnderlineState());
setFontSize(getFontSizeState());
setColor(getColorState());
}, [getBoldState, getItalicState, getUnderlineState, getFontSizeState, getColorState]);
useEffect(() => {
/**
* 处理文本选择变化事件
*/
const handleSelectionChange = () => {
// 如果用户正在与工具栏交互,不处理选择变化
if (isInToolbarRef.current) return;
const selection = window.getSelection();
const selectedText = selection.toString().trim();
// 确保有选中文本并且选择范围有效
if (selectedText && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const anchorNode = selection.anchorNode;
const targetElement = document.querySelector(target);
// 检查选择是否发生在目标编辑器内
const isInTarget =
targetElement &&
(targetElement.contains(anchorNode) || targetElement === anchorNode);
if (isInTarget) {
// 保存选择范围
// 更新选择信息,显示工具栏
setSelection({
clientRect: rect,
selectedText: selectedText,
range: range,
});
// 获取当前格式化状态
updateFormattingStates();
} else {
// 选择不在编辑器内,隐藏工具栏
setSelection(null);
}
} else {
// 没有选中文本,隐藏工具栏
setSelection(null);
}
};
/**
* 处理鼠标按下事件
*/
const handleMouseDown = (e) => {
// 检查点击是否发生在工具栏区域内
if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
isInToolbarRef.current = true;
} else {
isInToolbarRef.current = false;
}
};
/**
* 处理鼠标抬起事件
*/
const handleMouseUp = () => {
// 延迟执行,确保浏览器已完成选择操作
setTimeout(() => {
if (!isInToolbarRef.current) {
handleSelectionChange();
}
}, 100);
};
/**
* 处理点击外部事件
*/
const handleClickOutside = (e) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
const targetElement = document.querySelector(target);
if (targetElement && !targetElement.contains(e.target)) {
setSelection(null);
}
}
};
// 添加事件监听器
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("mousedown", handleClickOutside);
// 清理函数:组件卸载时移除事件监听器
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [target, getBoldState]);
// 设置字体大小
const setFontSizeCommand = useCallback((sizeValue, range) => {
if (!range) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
const newRange = selection.getRangeAt(0);
const span = document.createElement("span");
const sizeMap = {
"1": "12px",
"2": "14px",
"3": "16px",
"4": "18px",
"5": "24px",
"6": "32px",
"7": "48px",
};
span.style.fontSize = sizeMap[sizeValue] || "16px";
try {
// 检查选择范围是否已折叠(即没有选中文本,只有一个光标位置)
// 如果是折叠状态,不执行字体大小设置操作
if (newRange.collapsed) return;
const clonedRange = newRange.cloneRange();
const fragment = clonedRange.extractContents();
span.appendChild(fragment);
clonedRange.insertNode(span);
// 清除当前选择,然后重新选中刚刚插入的span元素内容
// 这样用户可以继续对同一段文本进行其他操作
selection.removeAllRanges();
const newSelectionRange = document.createRange();
newSelectionRange.selectNodeContents(span);
selection.addRange(newSelectionRange);
} catch (error) {
console.error("设置字体大小失败:", error);
}
// 更新状态
setFontSize(sizeValue);
}, []);
// 设置文本颜色
const setTextColor = useCallback((colorValue, range) => {
if (!range) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
const newRange = selection.getRangeAt(0);
const span = document.createElement("span");
span.style.color = colorValue;
try {
if (newRange.collapsed) return;
const clonedRange = newRange.cloneRange();
const fragment = clonedRange.extractContents();
span.appendChild(fragment);
clonedRange.insertNode(span);
selection.removeAllRanges();
const newSelectionRange = document.createRange();
newSelectionRange.selectNodeContents(span);
selection.addRange(newSelectionRange);
} catch (error) {
console.error("设置颜色失败:", error);
}
setColor(colorValue);
}, []);
// 处理加粗操作
const handleBoldClick = useCallback((range) => {
if (!range) return;
// 执行加粗命令
document.execCommand("bold", false, null);
updateFormattingStates();
}, [updateFormattingStates]);
// 处理斜体操作
const handleItalicClick = useCallback((range) => {
if (!range) return;
// 执行斜体命令
document.execCommand("italic", false, null);
// 恢复选择范围并更新状态
updateFormattingStates();
}, [updateFormattingStates]);
// 处理下划线操作
const handleUnderlineClick = useCallback((range) => {
if (!range) return;
// 执行下划线命令
document.execCommand("underline", false, null);
// 更新状态
updateFormattingStates();
}, [updateFormattingStates]);
// 处理字号变化
const handleFontSizeChange = useCallback((sizeValue, range) => {
if (!range) return;
setFontSizeCommand(sizeValue, range);
updateFormattingStates();
}, [setFontSizeCommand, updateFormattingStates]);
return {
selection,
toolbarRef,
isBoldActive,
handleBoldClick,
};
};
【节点】[NormalBlend节点]原理解析与实际应用
法线混合技术概述
在实时渲染中,法线贴图混合是增强表面细节表现的重要技术。Unity URP管线内置的NormalBlend节点通过数学运算实现两张法线贴图的平滑过渡,同时确保法线向量的物理正确性。该技术广泛应用于角色装备切换、地形材质融合、动态形变效果等场景,是现代游戏开发中不可或缺的材质处理工具。
节点核心功能解析
混合模式选择
NormalBlend节点提供两种混合算法:
- Default模式:采用分量混合策略,对法线贴图的RG通道进行加法混合,B通道进行乘法混合,最后通过标准化处理确保输出为单位向量。适用于简单表面细节的叠加,例如角色装备纹理的混合。
- Reoriented模式:通过重新定向算法维持法线方向一致性,采用齐次坐标系转换与向量投影计算,确保混合结果符合物理光照模型。适用于复杂表面处理,如布料模拟与动态形变效果。
端口与参数配置
![]()
-
输入端口:
- A:接收第一张法线贴图数据(Vector3类型)
- B:接收第二张法线贴图数据(Vector3类型)
-
输出端口:
- Out:输出混合后的标准化法线向量(Vector3类型)
-
控件参数:
- Mode:混合模式选择器(Default/Reoriented)
技术实现原理
法线混合数学基础
法线向量是表示表面朝向的数学实体,其核心属性包括:
- 单位向量性质:长度必须保持为1
- 插值特性:在片段着色器中由顶点法线插值获得
- 空间转换:可通过矩阵运算在不同坐标系间转换
标准化处理流程
混合后的法线向量必须经过标准化处理,以确保:
- 光照计算的准确性
- 阴影生成的正确性
- 表面交互的真实性
坐标空间转换机制
NormalBlend节点自动处理切线空间到世界空间的转换:
- 输入法线默认为切线空间坐标
- 输出法线根据材质设置自动转换至目标空间
- 支持对象空间、视图空间、世界空间和切线空间输出
典型应用场景与实现
角色装备法线混合
实现步骤:
- 准备角色基础法线贴图(A)
- 准备装备法线贴图(B)
- 使用Default模式进行混合
- 通过材质参数控制混合强度
优化技巧:
- 使用纹理采样节点控制混合区域
- 结合遮罩贴图实现非均匀混合
- 在关键区域采用Reoriented模式维持方向一致性
地形法线混合
实现步骤:
- 准备两种地形材质法线贴图(A和B)
- 创建混合遮罩纹理
- 根据遮罩值动态调整混合比例
- 使用Reoriented模式处理复杂过渡
优化技巧:
- 使用渐变纹理控制混合区域
- 结合高度图实现物理正确的混合
- 在斜坡区域增强混合强度
动态变形法线处理
实现步骤:
- 准备基础法线贴图(A)
- 准备变形影响法线贴图(B)
- 根据变形参数动态调整混合强度
- 使用Reoriented模式保持方向一致性
优化技巧:
- 结合顶点动画参数控制混合
- 使用噪声纹理丰富细节
- 在形变剧烈区域增加混合强度
性能优化策略
模式选择优化
- 优先使用Default模式:性能开销较小,适合简单混合
- 复杂表面使用Reoriented模式:维持方向一致性
- 混合强度控制:通过材质参数或遮罩贴图动态调整
计算资源优化
- 限制混合区域:使用遮罩贴图约束混合范围
- 简化混合模式:在非关键区域采用Default模式
- 预计算混合:在材质编辑器中预先计算部分结果
平台兼容性优化
- URP与HDRP差异:URP采用简化光照模型,HDRP支持物理精确材质
- 版本兼容性:不同Unity版本对ShaderGraph节点的支持可能存在差异
- 目标平台:移动端优先选用Default模式以降低计算量
常见问题解决方案
混合后出现伪影
原因:
- 混合区域边界处理不当
- 法线方向不一致
- 混合强度过高
解决方案:
- 使用遮罩贴图平滑过渡
- 在关键区域切换至Reoriented模式
- 降低混合强度或扩展混合区域
性能下降明显
原因:
- 混合区域过大
- 采用复杂混合模式
- 在移动端使用高精度混合
解决方案:
- 缩小混合区域
- 在非关键区域使用Default模式
- 针对移动端优化混合参数
光照表现异常
原因:
- 混合后法线未正确标准化
- 混合模式选择不当
- 法线贴图格式有误
解决方案:
- 确保输出法线经过标准化处理
- 根据表面复杂度选择合适的混合模式
- 检查法线贴图格式与生成方式
进阶应用案例
多层级法线混合
实现方法:
- 构建多个混合层级
- 使用遮罩贴图控制各层级混合区域
- 逐层混合法线贴图
优势:
- 实现更复杂的表面细节
- 可调控不同区域的混合强度
- 提升材质表现力
动态法线混合系统
实现方法:
- 依据动画参数动态调整混合强度
- 使用噪声纹理增添动态细节
- 结合顶点动画实现物理正确的混合
应用场景:
- 角色表情变化
- 布料模拟
- 动态环境变化
材质系统集成方案
实现方法:
- 将混合参数暴露给材质系统
- 创建材质参数集合以控制混合行为
- 实现动态材质切换
优势:
- 增强材质系统的灵活性
- 支持运行时动态调整
- 简化美术工作流程
最佳实践总结
- 模式选择原则:简单表面使用Default模式,复杂表面使用Reoriented模式
- 性能优化优先级:移动端优先考虑性能,PC端可适度增加细节
- 质量保障措施:使用标准化工具验证混合结果,确保法线方向正确
- 迭代开发流程:从简单混合起步,逐步提升复杂度,并持续验证效果
【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)
“药王”替尔泊肽降价七成,降糖减重市场变天了
文|胡香赟
编辑|海若镜
新晋“全球药王”替尔泊肽,在2025年医保新药目录谈判后,价格即将大幅下降。
近期结束的国家医保目录调整中,多款GLP-1产品被纳入,其中就包括礼来的替尔泊肽,用于成人2型糖尿病治疗。
截至目前,国家医保局并未公布谈判价格结果。但在多位内分泌科、代谢科医生的社交媒体中,流传出2026年1月1日后替尔泊肽将执行的新价格,同比大幅下降。不同用量的月费价格(每月4次)分别为:324.1元(2.5mg)、551元(5mg)、751.6元(7.5mg)和936.7元(10mg)。
网传替尔泊肽2026年即将执行的院内新价格
36氪就该价格向礼来方面求证,礼来未予置评,并表示以“医保局官方发布为准”。
另两家开发GLP-1新药的药企人士称,这组替尔泊肽价格数据与其了解的区间接近,“确实降了很多”。
此外,36氪注意到,多个主流电商平台,均已推出替尔泊肽新的预售价格,降幅显著。例如2.5mg*4次剂量的月费在450元-600元不等,较国谈前低了六、七成。一家医药电商平台直接打出了“医保国谈降价、惠及更多患者”的标语。
2025年前三季度,替尔泊肽的全球销售额高达248.37亿美元,同比增长125%,成功问鼎全球“药王”。当这样一款重磅药物大幅降价,进入以“放量”著称的中国医保市场,必将改写国内同类降糖、减重药物的市场格局。
当前,国内已批准的GLP-1类药物达到10款,后续排队等上市的国产品种还有更多。2026年,这一赛道的商业化考验,或更加残酷。
大降价、大放量
京东、美团、拼多多平台上,替尔泊肽新预售价都有了大幅下降,且自2026年1月开始执行,与国家医保目录的正式落地时间高度契合。
“美团官方补贴”入口中,替尔泊肽预售价示意(时间:2025年12月25日中午)
图2:京东上,替尔泊肽新版预售价示意(时间:2025年12月25日中午)
2025年1月,替尔泊肽在国内正式销售之初,2.5mg*4次剂型的院内月费定价约为1700-1800元。由于院外零售和线上电商渠道的价格影响因素更多,较之于院内价格,会略贵一些,同剂型产品的月费约在1800-2000多元不等。
两相对比,可推算替尔泊肽在本次医保中的降幅或达70%。
需要注意的是,无论替尔泊肽还是其他GLP-1类药物,都只有在治疗糖尿病时能使用医保,这体现的是医保持续纳入新型、优质治疗药物的初衷。
而减重适应症,则具有更强的消费属性,通常需要全自费。因此,有药企也会选择为GLP-1药物的减重适应症定更高的价格。
不过,前述GLP-1新药开发人士认为,尽管适应症、商品名不同,但它们本质上是一种药,二者价格存在相互影响。降糖适应症的价格调整,势必会传导至减重适应症领域,进而带动该品类药物价格的整体下行。
替尔泊肽不是最早进入中国市场的GLP-1药物,但凭借更强的减重效果,快速打开了市场认知,并开始普及。不过,由于价格昂贵,挡住了不少消费者的脚步,也让一些国内药企看到了通过价格策略突围的空间。
但医保目录调整后,替尔泊肽与同类型产品间的价差,骤然缩小。
同样以最小剂量在电商上的月费为参考,诺和诺德的司美格鲁肽降糖版、减重版价格,分别为320元和1260元左右。国产品种中,信达生物的玛仕度肽,由于降糖适应症获批时间晚于医保调整报名时间节点,因此还没有进医保,目前最小剂量月费约980多元。银诺医药的长效降糖药依苏帕格鲁肽α注射液,进入医保后的降价情况尚未在各电商渠道呈现。
疗效、医保、价格多重利好下,替尔泊肽是否会占领更多市场份额?
礼来并未在财报中披露产品的区域具体销售情况,但曾10月的财报会议上提到,2025年二、三两季度,替尔泊肽在中国的表现持续向好。11月底,礼来中国总裁德赫兰亲自带队参与医保国谈,可见其对医保市场放量的期待。
此外,替尔泊肽价格大幅降低,与外部竞争局面改变也息息相关。2026年3月,司美格鲁肽在中国的核心专利即将到期,这意味着大量仿制药即将涌入市场,如华东医药、丽珠集团等进展快者,已经处在上市审评阶段。
众多因素叠加,将真正带动GLP-1药物进入低价竞争时代。“比想象中要快。”前述药企人士提到。
后来者怎么办
速度、价格的双重竞争压力下,GLP-1药物的淘汰赛加速到来。
如何在要求更高的市场环境下提高自己的竞争壁垒、把药物卖得更好,是目前仍在牌桌上的GLP-1企业的核心命题。
押注迭代型药物研发和适应症拓展,是大部分药企的常规思路,而且颇有愈演愈烈的趋势。
几大巨头中,有企业强调自研,比如礼来的三靶点GLP-1新药retatrutide在临床试验中的减重效果已经达到29%,超过了替尔泊肽在3期临床中的表现。
也有企业开启大规模并购、BD之路。11月,辉瑞以百亿美元高价拿下Metsera,为的正是后者手里的GLP-1长效月制剂MET-097i。与传统GLP-1药物略有区别,它在激活疗效通路时能做到更精准,或许能达成更高的减重效率和更小的副作用。
值得一提的是,礼来、罗氏同样也有类似技术路线的产品在研。头部跨国药企的共同背书,或代表行业对下一代代谢疾病疗法已经具备初步的共识性判断。
但新药探索需要时间,在此之前,如何把现有药物卖得更好更为关键。2025年以来,GLP-1企业集体拥抱院外零售、线上电商渠道,抢场景、抢流量,赞助各类“全民减重”、“科学减重”活动势。
比如,今年年中,信达生物的玛仕度肽先后获批了降糖、减重两项适应症。产品上市申请获受理到正式获批的1年间,信达为此投入的开发、营销和商业化费用达到近亿元;形式上,则与抖音等平台开展相关打卡活动,增强产品曝光。
近期,信达生物在披露三季度产品收入的公告中称,第三季度产品总收入超33亿人民币,同比增长40%。玛仕度肽为代表的新产品,正是其收入增长的一个驱动力。
虽然产品和渠道竞争都日益激烈,但总量上来看,中国GLP-1药物市场的潜力仍然很大。
中信医药数据称,当前国内市场的渗透率仅为1%,而美国市场高达10%。因此,国内GLP-1市场尚未进入到此消彼长的存量博弈阶段。对那些兼具疗效和高效执行能力的企业来说,仍然存在一起携手做大“蛋糕”、共享红利的空间。
Next.js第十七章(Script脚本)
Script组件
Next.js允许我们使用Script组件去加载js脚本(外部/本地脚本),并且他还对Script组件进行优化。
基本使用
局部引入
src/app/home/page.tsx
在home路由引入一个远程的js脚本,他只会在切换到home路由时才会加载,并且只会加载一次,然后纳入缓存。
import Script from 'next/script' //引入Script组件
export default function HomePage() {
return (
<div>
<Script src="https://unpkg.com/vue@3/dist/vue.global.js" />
</div>
)
}
他的底层原理会把这个Script组件转换成<script>标签,然后插入到<head>标签中。
全局引入
src/app/layout.tsx
全局引入直接在app/layout.tsx中引入,他会自动在所有页面中引入,并且只会加载一次,然后纳入缓存。
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<Script src="https://unpkg.com/vue@3/dist/vue.global.js" />
</head>
<body>
{children}
</body>
</html>
)
}
加载策略
Next.js允许我们通过strategy属性来控制Script组件的加载策略。
-
beforeInteractive: 在代码和页面之前加载会阻塞页面渲染。 -
afterInteractive(默认值): 在页面渲染到客户端之后加载。 -
lazyOnload: 在浏览器空闲时稍后加载脚本。 -
worker(实验性特性): 暂时不建议使用。
<Script id="VGUBHJMK1" strategy="beforeInteractive" src="https://unpkg.com/vue@3/dist/vue.global.js" />
<Script id="VGUBHJMK2" strategy="afterInteractive" src="https://unpkg.com/vue@3/dist/vue.global.js" />
<Script id="VGUBHJMK3" strategy="lazyOnload" src="https://unpkg.com/vue@3/dist/vue.global.js" />
<Script id="VGUBHJMK4" strategy="worker" src="https://unpkg.com/vue@3/dist/vue.global.js" />
webWorker模式 尚不稳定,谨慎使用,小提示可以给Script组件添加id,Next.js会追踪优化。
内联脚本
即使不从外部文件载入脚本,Next.js也支持我们通过{}直接在Script组件编写代码。
import Script from "next/script";
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<Script id="VGUBHJMK5" strategy="beforeInteractive" src="https://unpkg.com/vue@3/dist/vue.global.js"></Script>
</head>
<body>
{children}
<div id="app"></div>
<Script id="VGUBHJMK6"
strategy="afterInteractive">
{
`
const {createApp} = Vue
createApp({
template: '<h1>{{ message }}</h1>',
setup() {
return {
message: 'Next.js + Vue.js'
}
}
}).mount('#app')
`
}
</Script>
</body>
</html>
);
}
第二种写法使用 dangerouslySetInnerHTML 属性来设置内联脚本。
<Script dangerouslySetInnerHTML={{__html: `
const {createApp} = Vue
createApp({
template: '<h1>{{ message }}</h1>',
setup() {
return {
message: 'Next.js + Vue.js'
}
}
}).mount('#app')
` }} strategy="afterInteractive">
</Script>
事件监听
- onload: 脚本加载完成时触发。
- onReady: 脚本加载完成后,且组件每次挂载的时候都会触发。
- onError: 脚本加载失败时触发。
Script组件只有在导入客户端的时候才会生效,所以需要使用'use client'声明这是一个客户端组件。
'use client'
import Script from 'next/script'
export default function Page() {
return (
<>
<Script
src="https://example.com/script.js"
onLoad={() => {
console.log('Script has loaded')
}}
/>
</>
)
}
Next.js第十六章(font字体)
font字体
next/font模块,内置了字体优化功能,其目的是防止CLS布局偏移。font模块主要分为两部分,一部分是内置的Google Fonts字体,另一部分是本地字体。
基本用法
Goggle字体
在使用google字体的时候,Google字体和css文件会在构建的时候下载到本地,可以与静态资源一起托管到服务器,所以不会向Google发送请求。
- 基本使用
import { BBH_Sans_Hegarty } from 'next/font/google' //引入字体库
const bbhSansHegarty = BBH_Sans_Hegarty({
weight: '400', //字体粗细
display: 'swap', //字体显示方式
})
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body className={bbhSansHegarty.className}> {/** bbhSansHegarty会返回一个类名,用于加载字体 */}
{children}
sdsadasdjsalkdjasl
你好
</body>
</html>
);
}
- 可变字体
可变字体是一种可以适应不同字重和样式的字体,它可以在不同的设备上自动调整字体大小和样式,以适应不同的屏幕大小和分辨率。
import { Roboto } from 'next/font/google'
const roboto = Roboto({
weight: ['400', '700'], //字体粗细 (不是所有字体都支持可变字体)
style: ['normal', 'italic'], //字体样式
subsets: ['latin'],
display: 'swap',
})
如何选择其他字体?可以参考Google Fonts
import { Inter,BBH_Sans_Bartle,Roboto_Slab,Rubik,Montserrat } from 'next/font/google' //引入其他字体库
API 参考
配置选项
| 属性 | 本地 | 类型 | 必填 | 说明 | |
|---|---|---|---|---|---|
src |
✗ | ✓ | String/Array | 是 | 字体文件路径 |
weight |
✓ | ✓ | String/Array | 可选 | 字体粗细,如 '400' |
style |
✓ | ✓ | String/Array | - | 字体样式,如 'normal' |
subsets |
✓ | ✗ | Array | - | 字符子集 |
axes |
✓ | ✗ | Array | - | 可变字体轴 |
display |
✓ | ✓ | String | - | 显示策略 |
preload |
✓ | ✓ | Boolean | - | 是否预加载 |
fallback |
✓ | ✓ | Array | - | 备用字体 |
adjustFontFallback |
✓ | ✓ | Boolean/String | - | 调整备用字体 |
variable |
✓ | ✓ | String | - | CSS 变量 |
declarations |
✗ | ✓ | Array | - | 自定义声明 |
style
字体样式,如 'normal' 'italic(斜体)' 'oblique(倾斜)' 等。
weight
字体粗细,如 '400' '700' '900' 等。
display
auto:浏览器默认(通常为 block)
block:空白 3s → 备用字体 → 自定义字体
swap:备用字体 → 自定义字体
fallback:空白 100ms → 备用字体,3s 内加载完成则切换
optional:空白 100ms,100ms 内加载完成则使用,否则用备用字体
本地字体
字体下载地址:免费可商用字体
本地字体需要通过src属性指定字体文件路径,字体文件路径可以是单个文件,也可以是多个文件。
import localFont from 'next/font/local'
const local = localFont({
src:'./font/zydtt.ttf', //本地字体文件路径
display: 'swap', //字体显示方式
})
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body className={local.className}>
{children}
sdsadasdjsalkdjasl
你好
</body>
</html>
);
}
