普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月30日首页

iptables Command in Linux: Manage Firewall Rules

When a packet arrives at a Linux machine, the kernel decides what to do with it based on a set of firewall rules. Those rules live in the kernel’s Netfilter framework, and for more than two decades the standard way to edit them from user space has been the iptables command.

iptables controls packet filtering, network address translation, and packet mangling. Higher-level front ends such as ufw and firewalld manage the same Netfilter firewall stack through simpler interfaces, although many modern systems use nftables underneath. Understanding the underlying command is still valuable, especially when you inherit a server that was set up by someone else. This guide walks through the concepts and the commands you need to read, edit, and persist firewall rules.

Tables and Chains

Before you write a rule, you need to know where it goes. iptables is organized into tables, and each table contains chains.

The three tables you will use most often are:

  • filter - the default table, used for allowing and blocking traffic
  • nat - used for network address translation, such as port forwarding and masquerading
  • mangle - used to alter packet headers, for example to set QoS marks

Each table has a set of built-in chains that correspond to moments in the life of a packet. In the filter table:

  • INPUT - packets destined for the local machine
  • OUTPUT - packets originating from the local machine
  • FORWARD - packets routed through the machine

A rule says: for packets that enter this chain and match these criteria, take this action. The action is called a target and is usually ACCEPT, DROP, REJECT, or the name of another chain.

iptables Syntax

The general form of the command is:

txt
iptables [-t TABLE] COMMAND CHAIN [MATCH] [-j TARGET]

If -t is omitted, iptables uses the filter table. Common commands include -A (append a rule), -I (insert), -D (delete), -L (list), -F (flush), and -P (set default policy).

All commands that change the firewall require root privileges. Run them with sudo or as root.

Warning
It is easy to lock yourself out of a remote server with a single wrong rule. Before you apply a restrictive ruleset over SSH, either test on a local machine first or use iptables-apply, which rolls back automatically if you lose access.

List Rules

To print every rule in the filter table, use the -L option:

Terminal
sudo iptables -L

The default output shows service names, resolves IP addresses, and hides packet and byte counters. For real work, add -n to keep numeric output and -v to show counters and interface information:

Terminal
sudo iptables -L -n -v
output
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
1234 98K ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22
0 0 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:23
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination

To list a single chain, append its name:

Terminal
sudo iptables -L INPUT -n -v

To number the rules so you can reference them by index when deleting, add --line-numbers:

Terminal
sudo iptables -L INPUT -n -v --line-numbers

Add and Remove Rules

New rules are added to the end of a chain with -A (append) or at a specific position with -I (insert). The difference matters because iptables evaluates rules top to bottom and stops at the first match.

To allow incoming SSH connections:

Terminal
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT

To allow HTTP and HTTPS:

Terminal
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

To insert a rule as the first one in the chain, use -I CHAIN 1:

Terminal
sudo iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT

This is important when you are working over SSH. If a broad DROP rule already appears earlier in the chain, appending the accept rule after it would not help because the packet would be dropped first.

To delete a rule, either repeat the exact specification with -D:

Terminal
sudo iptables -D INPUT -p tcp --dport 23 -j DROP

Or delete by line number, which is easier when the rule has many options:

Terminal
sudo iptables -D INPUT 3

Allow and Block Specific IPs

To block all traffic from a single IP address:

Terminal
sudo iptables -A INPUT -s 203.0.113.10 -j DROP

To block a range using CIDR notation:

Terminal
sudo iptables -A INPUT -s 203.0.113.0/24 -j DROP

To allow SSH only from a trusted subnet:

Terminal
sudo iptables -A INPUT -p tcp -s 192.168.1.0/24 --dport 22 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j DROP

The first rule accepts SSH from the local subnet. The second drops SSH from everywhere else. Order matters: if you reversed the two lines, every SSH attempt would be dropped before the accept rule had a chance to match.

Allow Established Connections

Most firewall setups include a rule that accepts traffic belonging to an already established connection. This lets return traffic through without needing a matching rule for each outbound request:

Terminal
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

Put this rule near the top of the INPUT chain so it matches early. Without it, the default DROP policy breaks outbound connections that expect responses.

Set a Default Policy

Each built-in chain has a default policy that applies when no rule matches. The -P option changes it:

Terminal
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT

Switching INPUT to DROP is the foundation of a deny-by-default firewall: nothing gets in unless an explicit rule allows it. Before you flip the policy, make sure you have already added the rules that allow SSH, established connections, and anything else you need.

Flush Rules

To remove every rule from every chain in the current table:

Terminal
sudo iptables -F

To flush a specific chain only:

Terminal
sudo iptables -F INPUT

Flushing does not reset the default policies. If you have set INPUT to DROP, flushing will leave it at DROP with no rules, which blocks all inbound traffic. Reset the policy to ACCEPT first if that is not what you want:

Terminal
sudo iptables -P INPUT ACCEPT
sudo iptables -F

Save and Restore Rules

Rules added with iptables live in kernel memory only. They disappear on reboot unless you save them.

On Ubuntu, Debian, and Derivatives, the iptables-persistent package saves rules to /etc/iptables/rules.v4 and reloads them at boot:

Terminal
sudo apt install iptables-persistent

The installer asks whether to save the current rules. To update the saved copy later:

Terminal
sudo netfilter-persistent save

On Fedora, RHEL, and Derivatives, the equivalent service is iptables-services:

Terminal
sudo dnf install iptables-services
sudo systemctl enable --now iptables
sudo service iptables save

Independent of the distribution, you can dump and restore rules manually with iptables-save and iptables-restore:

Terminal
sudo iptables-save -f /etc/iptables/rules.v4
sudo iptables-restore /etc/iptables/rules.v4

This is also the recommended way to edit a large ruleset: save to a file, edit the file, then restore it atomically.

Troubleshooting

Rules disappear after a reboot
iptables rules are not persistent by default. Install iptables-persistent on Debian-based systems or iptables-services on RHEL-based ones, and save the ruleset.

SSH stops working after setting a DROP policy
You switched INPUT to DROP without an ACCEPT rule for port 22, or the ACCEPT rule is positioned after a more general DROP rule. Connect through the console, add the rule with -I INPUT 1, and save.

A rule looks correct but does not match
Check the order. iptables walks the chain top to bottom and stops at the first match, so an earlier accept or drop may be catching the packet first. Use iptables -L INPUT -n -v --line-numbers to inspect the order.

Changes are silently ignored
You may be editing the wrong table. A rule in filter does not affect NAT, and vice versa. Pass -t TABLE explicitly when you are not working in filter.

iptables: command not found
On some modern distributions, only nftables is installed by default. Install iptables with your package manager, or use nft directly.

Quick Reference

For a printable quick reference, see the iptables cheatsheet .

Action Command
List rules (verbose, numeric) iptables -L -n -v --line-numbers
Allow port iptables -A INPUT -p tcp --dport PORT -j ACCEPT
Block IP iptables -A INPUT -s IP -j DROP
Allow established connections iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
Insert rule at top iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT
Delete rule by number iptables -D INPUT N
Set default policy iptables -P INPUT DROP
Flush all rules iptables -F
Save rules iptables-save -f /etc/iptables/rules.v4
Restore rules iptables-restore /etc/iptables/rules.v4

FAQ

Is iptables still relevant in 2026?
It is still installed and widely used, but it is being replaced by nftables, which offers a cleaner syntax and better performance. On recent distributions, the iptables command is often a compatibility front end that writes nftables rules underneath.

Should I use iptables, ufw, or firewalld?
If you are managing rules by hand on Debian or Ubuntu, ufw is simpler and covers most cases. On Fedora and RHEL, firewalld is the default. Reach for raw iptables when you need fine-grained control that the front ends do not expose, or when you are troubleshooting an existing ruleset.

What is the difference between DROP and REJECT?
DROP silently discards the packet; the sender sees a timeout. REJECT sends an ICMP error back (or a TCP reset for TCP), so the sender gets immediate feedback. DROP is often preferred on public interfaces because it does not confirm that the port exists.

How do I block a country or a list of IPs?
For a handful of addresses, add one -s rule per entry. For larger lists, use the ipset tool to manage the addresses and reference the set from a single iptables rule.

Does iptables handle IPv6?
No. Use ip6tables for IPv6 rules. It has the same syntax and the same tables and chains, but operates on a separate rule set.

Conclusion

iptables is a low-level but reliable way to read and shape the Linux firewall. Once you have a working ruleset, save it with iptables-save and commit the file so the next person to touch the server has a clear starting point.

昨天以前首页

iOS AutoFix Agent 阶段性收尾:可迁移的 Agent 工程经验沉淀

作者 wyanassert
2026年5月29日 20:00

前言


从最早思考《为什么要做一个 iOS bug 自动修复的 agent 程序》,到 V3 把单文件原型重构成 AgentEngine 引擎、V4 把领域知识结构化、V5 把「完成需求开发」提成唯一 P0 并引入 Pipeline 编排基座,这个项目断断续续做了两个月。之前我写过一篇《从 Bug 修复到需求开发:iOS AutoFix Agent 的 V3-V5 演进之路》,把版本演进的脉络讲清楚了。

接下来我的重心会转到和其他同学共建另一个 Agent 项目上。所以趁记忆还热乎,给 iOS AutoFix 做一份阶段性收尾——这篇不再复述版本演进,而是把这一年踩过坑、验证过的与领域无关、可以直接搬到下一个 Agent 项目的工程经验沉淀下来。

一句话定位整个项目的终态:

V4 解决「能不能定位/修复一个 Bug」,V5 把「完成一个需求开发」提升为唯一 P0,并引入 Pipeline Orchestrator(流水线编排基座),让 分析 → 设计 → 闸门 → 实现 → 验证 → 评审 以声明式、可配置、支持闸门与智能回退的流水线串起来。

V5 走到了哪里


V5 围绕三条主线展开:

主线 名称 角色
主线 0 Pipeline Orchestrator 架构基座:声明式流水线引擎 + 闸门(Gate)+ 智能回退(Rollback)+ 状态持久化 + 可观测性
主线 A 定位与修复质量 继续打磨 Bug 定位/修复/经验复用,在 V5 中承担「基础设施 + 壁垒」角色
主线 B 需求开发能力 从需求分析、改动点识别、多文件写入到限定场景的完整需求实现 —— V5 唯一 P0

落地形态是一条六阶段需求开发流水线,以一个真实 TAPD 需求为例,全程约 65 分钟、中间两次人工确认:

1
2
① 需求分析 → ② 方案设计 [HumanGate] → ③ 影响评估
→ ④ 代码实现 [事务写入 + 自动回滚] → ⑤ 编译验证 → ⑥ 代码审查

到 V6 雏形,需求开发已经从「勾哪端就各自独立跑 Pipeline」演进到「一个需求、一次整体分析、按涉及端分别落地」——iOS / Android / Kuikly 三端共享一次跨端分析,再并发分端实现,保证跨端协议字段/事件名一致。

当前的分层架构


收尾时整个系统稳定在 5 层。理解这张图,就理解了这个 Agent 的全部骨架:

1
2
3
4
5
6
7
8
9
入口层(CLI / GUI / 企微机器人)

Pipeline Orchestrator —— 按什么顺序跑、卡在哪个闸门、错了怎么退

AgentEngine —— 单个 Agent 怎么跑:循环控制 / 工具分发 / Scratchpad / 对话压缩 / 置信度退出

TaskProfile —— 这一步跑什么:BugLocate / Fix / FeatureAnalyze / FeatureDesign / FeatureImplement / CodeReview / ImpactAnalysis

知识 & RAG 层 —— ModuleDoc + CaseDoc + FixRecord,7 路并行召回 + Knot 知识库

最关键的一组抽象,也是这一年最值钱的设计决策:

  • Engine 管「怎么跑」,Profile 管「跑什么」。通用的 Agentic Loop(循环、工具分发、压缩、容错)沉到 Engine,场景差异(角色、工具集、退出条件、领域知识)放进 Profile。十几种 Profile 复用同一个 Engine,新增一种任务类型只要写一个新 Profile。
  • Pipeline 管「编排」。Engine 只关心单个 Agent 跑完一轮,Pipeline 负责把多个 Stage 串起来,并在阶段转换处插闸门。

可迁移的工程经验


下面这些是我打算带去下一个 Agent 项目的「行李」。它们大多与「修 iOS Bug」这件具体的事无关,是做任何 LLM Agent 都会遇到的问题。

1. 先有编排框架,再往里插能力

最容易犯的错是:先把一个个能力(分析、设计、实现)写成独立脚本,最后再用胶水代码串起来。结果就是回退、重试、断点续跑这些横切逻辑散落在各处。

V5 的做法是反过来——先建 Pipeline 编排基座,每个能力作为一个 Stage 插进去。回退、状态持久化、崩溃恢复、可观测性都由编排层统一提供。Pipeline First 是 V5 九条核心原则里的第一条,事后看这个顺序定对了。

2. 闸门是一等公民 + 智能回退

关键阶段转换处一定要设闸门(Gate),不通过则携带反馈智能回退,而不是直接失败。闸门分三类:

  • MetricGate:置信度 / 编译结果等硬指标
  • AIGate:用一次独立的 LLM 调用评估上一阶段产物质量
  • HumanGate:人工确认(GUI 里做成倒计时确认弹窗)

回退用的是循环模型而非递归:每个 Gate 维护独立的回退计数,避免「设计→实现→验证失败→回设计→又失败」无限套娃。状态用原子写入持久化,进程崩溃能恢复到中断处。

3. 上下文工程:三个反直觉的结论

这是 Agent 工程里水最深的部分,几条经验都和直觉相反:

  • 超长 system prompt 会「中段失忆」(Lost in the Middle)。主 Orchestrator 的 prompt 一度超过 350 行,模型对中间部分的遵循率明显下降。解法是分阶段注入:探索阶段只给角色+工具+方向,搜索完才注入评估规则和退出条件,准备提交时才注入「自我质疑」清单。瘦身后 exploration prompt 减了约 70%。
  • 压缩会丢掉关键证据。每 5 轮压缩一次对话,会把搜索过程中发现的文件路径、行号、调用链一起压没,导致 Agent 重复搜已经找过的文件。解法是给 Agent 配一个不会被压缩的小本本(Scratchpad):通过 note_finding 工具写入关键发现,标记 _isScratchpad,压缩时跳过,每轮作为 system message 重新注入。
  • 结构化数据 > 报告文本。子代理给主代理传「文本报告」会有信息损失且容易被误解,改成结构化的 keyFiles / codeSnippets / callChains / hypotheses / coverage 后,主代理可以直接引用具体发现。

4. 置信度驱动早停,而不是机械计时器

最初的轮次控制是「第 8 轮提醒、第 12 轮强制提交」这种机械计时器——Agent 可能第 3 轮就找到答案还在空转烧 token,也可能第 12 轮没找到被强行提交。

改成置信度驱动早停:每轮自评置信度,但触发早停要三个条件同时满足——置信度 ≥ 0.8、总发现 ≥ 3、剩余方向全为低优先级。任一不满足就继续,宁可多跑也不「差点找到」漏掉根因。强制提交时打 low_confidence 标记,让下游知道定位可能不准。

5. 工具使用纪律:双层约束

让 Agent 别乱用工具,单靠 prompt 不够。有效的是双层约束协同

  • 工具 description 层(「什么时候该用」):写前置条件「使用前先 ripgrep 确认目标位置」、范围指引「命中行 ±20 行」、红线「禁止无目标读取 >100 行」。
  • Prompt 层(「什么不能做」):明确列反模式——没 grep 就直接读大段、对低相关匹配反复扩大读取范围、重复读已读过的区域。

两层叠加后,ripgrep 调用从 7 次降到 4 次(**-43%**),平均每次工具调用数从 7 降到 5。

6. 写入安全是红线

只要 Agent 会改用户的代码,写入安全就不能妥协。V5 的红线是四件套:**--confirm 确认 + 事务写入 + 编译验证 + 自动回滚**。代码实现阶段所有写入打包成事务,编译不过就整体回滚,绝不留下半成品。这条在「修 Bug」时还能商量,到「需求开发」要改多文件时就是底线。

7. 改动的「精准性」比「成功率」更重要

修 Bug 最大的风险不是没修好,而是改出新 Bug 或改错位置。让 LLM 生成 diff 时,强制 search 块带上前后 1-2 行上下文确保唯一匹配,而不是只给变更行(LLM 从记忆重建代码会有细微偏差,导致静默匹配失败)。

就这一个约束 + 「最小修改原则」,让修复阶段 Token 从 60K 降到 26K(**-57%**),LLM 调用从 6 次降到 3 次。

8. 可观测性要覆盖事前/事中/事后

  • 事前:靠工具 description 和 prompt 预防低效行为;
  • 事中:检测工具使用比例(read_file / ripgrep > 2:1 报警)、重复搜索同一文件;
  • 事后earlyTerminationSnapshot 记录早停时跳过了哪些方向,配合 evidenceHitRate / searchEfficiencyRatio / evidenceConsistencyScore 等场景级指标,做基线保存和回归检测。

一组综合优化前后的实测对比(质量指标全部持平的前提下):

指标 优化前 优化后 改善
总 Token 107,951 67,098 -37.9%
总耗时 272.9s 191.0s -30.0%
LLM 调用 18 13 -27.8%
主循环收敛轮次 3 2 -33.3%

9. 多模型评测与降级链

不要押注单一模型。V5 建了一套 10 分制的多模型评测框架(评估维度:文件定位 25% / 分析深度 25% / 实现计划 20% / 风险识别 15% / 执行效率 15%),跑同一个需求对比:

配置 综合得分 特点
claude-opus + 内置引擎 8.8 覆盖最广,风险识别最深
deepseek-4 + ACP 8.5 行号级精度,零幻觉
claude-4.6 + claude-internal 8.2 流程最稳,但会跑偏去搜 Android 代码(项目类型上下文注入不足)
glm-5 + CodeBuddy SDK 6.7 最快(105s),质量尚可

线上则用降级链(CodeBuddy Claude → GLM → DeepSeek)保可用性,并对不支持 function calling 的模型做 JSON 解析降级。评测决定选型,降级链保底

10. 知识沉淀要分类,并设晋升路径

知识库不是把文档堆一起。V5 把知识分四类——案例 / 经验 / 记忆 / 索引,共享知识库走独立 git 仓、个人记忆落本地,每条知识带 confidence 和晋升路径(被多次验证的经验才升级为共享知识),并设入库准入条件和触发器。这样知识库才不会越长越脏。

诚实的局限:为什么在这个点暂停


核心闭环已经成形,剩下的是打磨,所以这是个合理的暂停点。但有几处确实没做完,留个记录:

  • 跨端 analyze 还没真正复用(Phase B):V6 目前是把跨端整体方案作为 additionalContext 注入各端,各端仍会冗余跑一遍自己的 analyze,时间有浪费。彻底的做法是用 preloadedAnalysis 直接跳过各端 analyze stage,但那会侵入 PipelineEngine 内部,风险大,所以先用了轻耦合方案。
  • 粗筛权重是手工拍的:5 策略加权(直接路径 100 / SQLite 索引 10-40 / 目录推断 8 / Git 热点 5 / Bug 类型专项 12-15)很难对所有项目通用。正解是用历史定位数据做权重调优,或者干脆让 LLM 直接做粗筛——现在的模型能力够用了。
  • 并发统计口径会串:多端并发时 token / 日志统计可能互相累加,不影响功能,但口径乱。
  • 旧入口还没收尾:三个分端独立 Tab 仍可见,等新的统一入口稳定后再隐藏。

下一站:共建另一个 Agent


回头看,这一年真正沉淀下来的,不是「怎么修 iOS Bug」,而是「怎么造一个能干活的 Agent」。这两件事可迁移的程度完全不同:

  • 可以直接搬过去的(与领域无关):Pipeline 编排 + 闸门 + 智能回退、Engine/Profile 解耦、上下文工程那三条、置信度早停、写入安全四件套、事前/事中/事后可观测性、多模型评测与降级、知识分类沉淀。
  • 必须重做的(iOS 特化):仓库索引与页面映射表、编译验证(xcodebuild / gradle / KMP)、各端的 Profile 与领域知识。

下一个项目是和其他同学一起共建,正好可以把上面这套「与领域无关的 Agent 骨架」当成共识的起点,少走一遍弯路。iOS AutoFix 这边先告一段落,等下一个 Agent 跑起来,应该还有新的东西可以反哺回来。

总结


如果只能带走三句话:

  1. 先有编排,再插能力——横切逻辑(回退/续跑/可观测)必须沉到编排层。
  2. Engine 管怎么跑,Profile 管跑什么——这组解耦让一套引擎服务十几种任务。
  3. 上下文工程 + 写入安全是 Agent 能不能用的两道生死线——前者决定它聪不聪明,后者决定你敢不敢让它动你的代码。

国产电车抄不来法拉利 Luce,也千万别抄

作者 马扶摇
2026年5月29日 10:30

过去十年里,几乎没有一辆超跑可以像今天的法拉利 Luce 一样,激发出如此多的讨论。

不仅仅因为它是法拉利——一家将性能、设计和格调刻进 DNA 的超跑厂商——有史以来第一款纯电动车,更是因为它那让人一言难尽的外观

图|Ferrari

在意大利语中,luce 的意思是「光」,不仅寓意着这辆电动车照亮前路,更是为法拉利的首款纯电车加上了一层宗教的神圣性——

Dio disse: « Sia la luce! »(神说:要有光)

Luce 还是法拉利的首款 5 座车型,采用 4 轮电机驱动,122 千瓦时电池组可以提供约 530 公里的满电续航1050 马力的峰值功率

即使车重来到了 2.2 吨(接近小米 SU7 Max),Luce 依然有着约 310 公里/时的极速,零百加速也在 2.5 秒左右。

教皇利奥十四世观赏法拉利 Luce|TheVerge

价格方面,法拉利宣布 Luce 的起步价为 55 万欧元(约合人民币 432.9 万元),可以说无论性能还是价格,都很符合车头的跃马标。

然而也是这个跃马标,为 Luce 带来了正式亮相之后网络上铺天盖地的争论。

不纯血的马

比 KOL 和媒体试驾更早传出的,是网友们层不出穷的梗图创意。

菲亚特 Luce|Threads

菲亚特 Multipla Luce|Threads

纯粹「车」身攻击|Threads

然而就在两个月前,法拉利刚刚公布 Luce 的内饰由前苹果设计主管乔尼·艾弗领衔 LoveFrom 设计时,大家对它的评价还是相当正面的:

图|TopGear

但当人们看到这辆由乔尼·艾弗与马克·纽森(Marc Newson)两位传奇工业设计师设计出来的 Luce 全车的时候,评价来了个 180 度大转弯,似乎也情有可原——

Luce 是一辆很好看的电车,但完全不是一辆好看的法拉利。

图|Ferrari

简单来说,Luce 是一辆在电车设计上追求极致的车:一体化座舱、极致降低风阻、继承自纯血马(Purosangue)的对开式马车门等等。

然而 Luce 不仅与混动的纯血马,甚至与法拉利此前的所有设计语言都是不同的——

它既没有古典法拉利的圆润,也没有经典法拉利的方正,更没有现代法拉利的凌厉造型,反而像个消费电子产品。

Ferrari Purosangue 2026|TopGear

雪上加霜的是,就在 Luce 正式公布之后,意媒 Askanews 在采访法拉利前主席 Luca Cordero di Montezemolo 时询问了他对 Luce 的看法。

这位担任法拉利公司主席长达 23 年,任期内经历过恩佐(Enzo)、458、拉法(LaFerrari)几代传奇车款的 78 岁老人的回答很无奈:

如果我把真实想法说出来,会伤害到法拉利,我们正在冒险毁掉一个神话,我感到非常遗憾……希望他们至少把跃马标从 Luce 上摘下来。

图|Askanews

显然蒙特泽莫罗最后还是没憋住,在离开镜头之前补了一句:

……连中国车厂都不想借鉴它的设计。

如果冷静下来,去法拉利官网上看看 Luce 不太常规的法拉利 logo 选配位置,就能理解 Luce 得到这样的评价其实一点也不奇怪——

Luce 是一辆需要通过「在侧门上贴跃马标」来缓解自己的身份认同危机的法拉利。

图|Ferrari

然而问题真的出在法拉利 Luce 的「法拉利」部分吗?其实不然。

就像蒙特泽莫罗说的,只需要把 Luce 车身上的跃马标换成一个更具有代表性的 logo,Luce 的设计瞬间就变得合理了起来。

图|Threads

当然,对于那个电车最重要的问题,整活网友们当然也想到了解决方案:

图|Threads

Luce 长得像 iCar

归根结底,让 Luce 的设计在整个法拉利家族里面格格不入的根本原因,还是因为它是法拉利的第一辆纯电。

对于这种产品线上「前所未有」的产品,法拉利极有可能从最开始就没有让 Luce 与传统动力车型外观相近的想法。

能够侧面印证这种观点的,是法拉利官网上对 Luce 的设计介绍:

「我们与 LoveFrom 共同研发的,远不止是一辆电动法拉利,」约翰·埃尔坎表示。「我们在此所做的,不仅在构思上极其困难,在实现上同样充满挑战。这将打造出独一无二的法拉利 Luce」

图|Ferrari

这也就带来了一个问题:

由乔尼·艾弗、马克·纽森与 LoveFrom 工作室主导设计的法拉利 Luce,究竟是一辆更像消费品的未来法拉利,还是一辆更像未来法拉利的消费品

可以看到的是,二月 Luce 内饰设计公布之后,大家都对它兼顾屏幕和实体按键的先锋式设计表示赞赏,却在看到它同样超脱的设计之后骂声一片。

图|Ferrari

这种评价叠加在同一辆车上本身就是矛盾的:你不能同时要求 Luce 在设计理念上既先锋又保守。

就拿纯血马来说,虽然它是一辆混动车、保留着现代法拉利的凌厉设计风格,但把 Luce 的智能内饰平移到 Purosangue 上,依然会格格不入。

Ferrari Purosangue 内饰|TopGear

与此同时,这种对于 Luce 产品性质的讨论也会触及到那个终极问题:

法拉利 Luce 究竟是给谁准备的?

55 万欧元、430 万人民币,这个数字本身就已经排除掉 99.9% 的地球居民了——

Luce 的目标客户,是那些车库里已经停满其他豪车的人:

图|Rockstar Games

虽然法拉利还没有介绍 Luce 的购车模式,但从过往历史就能知道:法拉利是要对车主做背调之后才会提供购车资格的。

根据统计,在 2025 年全球购买法拉利的近 14000 人中,其中超过 80% 已经拥有一辆法拉利了。

在我们调侃 Luce 需要翻过来充电、承载力不如菲亚特的时候,那些已经有了一堆 V12 引擎的客户们,很有可能单纯因为「Luce 长得和传统法拉利不一样」而直接下单。

图|Car Magazine

毕竟 Luce 作为一辆电车,外观设计主要追求低风阻系数,和传统跑车(以及 FUV)追求下压力的设计指标并不完全相同。

此外,电车的底盘结构与重心分布也决定了在去掉引擎和变速箱空间之后,座舱部位无论怎样设计,最终都会呈现出一个类似水滴形的轮廓。

图|Ferrari

这也侧面解释了为什么 Luce 不像法拉利,反而长得像国产新能源:

在设计现实产品时,物理学决定的最优解有时候就是唯一解,追求极致的结果经常是趋同的。

还有别的方案吗

无论广大车迷们认为法拉利 Luce 怎样「数典忘祖」,无法改变事实就是:它的外观已经定型了,Luce 就在那里,稳稳的接住你。

图|Threads

然而我们还是不禁想要问一句:哪怕是乔尼·艾弗亲自操刀,Luce 真的就是「法拉利电车」的最优解吗?

答案显然是否定的——你虽然不能要求一个产品的设计既先锋又保守,但却有办法通过设计,在先锋的同时保留那些标志性的品牌 DNA。

最优秀的「先锋派标志性设计」例子之一,就是现代 N Vision 74。

图|Threads

虽然 N Vision 74 和 Luce 的定位非常不同,两者面临的设计挑战却是高度相似的:

怎样通过一个全新的设计,为品牌开发全新动力技术(氢燃料电池 vs 纯电)提供一个足够具有辨识度的新车型。

而选择从 1974 款现代 Pony 概念车上汲取灵感的 N Vision 74,在传达创新性和品牌风格方面,显然比 Luce 更被人们所广泛接受。

在接受科技博主 Cleo Abram 采访时,法拉利设计总监 Flavio Manzoni 在评价人们的反响时,提到了奥地利作曲家 Gustav Mahler 的一句话:

传统不是对灰烬的崇拜,而是对火种的传承。

换言之,Luce 在法拉利内部看来,即使不再继承现代法拉利的标志性设计,也依然传承着法拉利的创新和技术火种。

恩佐·法拉利|Camisasca Automotive Manufacturing Inc.

然而「传承」从来都不是一个容易的事,把品牌基因继承得好才叫传承,继承不好变成了当年可口可乐的 New Coke,那叫翻车。

正因如此,Cleo Abram 在采访视频的最后做出了一个比较中肯的总结:

我们不知道 Luce 最终会成功还是失败,但不管是哪种结局,我们都能从里面学到东西——

如果 Luce 失败了,我们可以学习到怎么改良;如果 Luce 成功了,我们就能看到整个行业开始学习 Luce,以至于影响到我们未来十年能够买到的电车。

而对于国产新能源来说,还是蒙特泽莫罗那句话说得对——

外观就不用借鉴了,多学习一下 Luce 的内饰设计就行。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

Deno 2.8 正式发布,再次超越 Bun,史上最大的次版本升级诞生!

作者 Web情报局
2026年5月28日 19:50

👇 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

最近 Bun 效仿 Deno,要从 Zig 语言移植到 Rust “锈化“重写,源码 PR 已经合并了,正式官宣指日可待。

Deno 也不甘示弱,Deno 团队官宣 v2.8 正式发布,号称 Deno 进化史上最大的次版本升级,主要包括:

  • Node 兼容性远超 Bun,测试率超过七成
  • Deno CLI 新增命令,可以替换 pnpm install
  • 新增 JS Stage3 import defer 导入延迟提案
  • TS 更新到 v6 主版本,支持类型剥离

deno

👉 Node 兼容性超过 Bun

之前 Deno 2.7 针对 Node 官方测试的通过率约为 42%,勉强超过了 Bun 1.3.14 的 40.6%。

Deno 2.8 更进一步,几乎涵盖了所有 node: 模块,测试率飙升到 76.4%,大幅领先 Bun,Deno 和 Bun 的“Rust 竞赛“预计会愈演愈烈。

bun.png

👉 新增子命令

Deno CLI 新增了几个命令。

deno audit fix 能将漏洞模块升级到最新的补丁版,同时满足我们配置的主/次版本限制,任何需要升级主版本的模块都会单独列出,方便你决定升级与否。

deno bump-version 能更新 package.jsondeno.json 中的 version 字段。它也适用于 workspace 工作区模式,在根目录运行能将更新应用到每个模块。

image.png

还有其他几个命令,我把它们浓缩为下列表格:

命令 作用
deno ci 根据 lockfile 执行安装
deno pack 约等于 tsc + npm pack
deno transpile TSX 类型剥离,输出 JS
deno why 等价于 npm explain / pnpm why

👉 包管理变更

Deno CLI 不再要求 deno adddeno install 命令添加 npm: 前缀,默认将无前缀的包名视为 npm 模块。

image.png

注意,CLI 中的 JSR 注册源仍需要 jsr: 前缀,ESM 模块中的 import 语句也要求 npm: 前缀。

这样,deno install 能取代 npm installpnpm install 等命令,允许你使用 Deno 取代 npm 作为包管理器,但项目还是跑在 Node 上,既符合 Node 开发者的肌肉记忆,又提升了安装速度。

过去,monorepo 跨包共享依赖需要手动协调版本,当共享依赖更新时,每个模块的 package.json 必须同步更新。

Deno 2.8 采用 pnpm 的 catalog: 协议,这允许在 workspace 根目录中声明一个默认的 "catalog" 字段:

image.png

然后只需使用 catalog: 说明符,就能从任意工作区模块同步依赖版本:

image.png

此外,类似 pnpm 的模块隔离结构,Deno 默认的 node_modules 目录结构是隔离的,每个模块都有自己的符号链接解析树,因此它只能看到自己显式声明的依赖。

但一些旧工具仍然依赖 npm install 生成依赖提升的扁平目录结构,每个模块都位于 node_modules 顶层,并且可以 require() 它找到的任何依赖。

deno.json 新增了 nodeModulesLinker 字段,默认值是 "isolated"(隔离目录):

image.png

设置 "nodeModulesLinker": "hoisted",可以移植一个依赖 npm 扁平目录的现有 Node 项目。

还有,Deno 2.6 就新增了 min-release-age 最小发布时限配置,来拦截大多数供应链攻击。Deno 2.8 支持通过 .npmrc 配置:

image.png

👉 JS 新功能

Deno 支持 JS Stage3 的 import defer(延迟导入提案),模块能不运行其顶层代码加载,这样该模块只在首次访问其导出成员时才被执行。

举个栗子,模块先导入,但可以延迟执行:

image.png

这样,模块求值会延迟到访问导出成员的那个时间点。当模块求值成本高昂、但又不常使用时,import defer 新特性能缩短启动时间。

👉 TS 更新

TS 编译器更新到 v6.0.3 版本了,这是为了对齐 ts-go(TS 7.x) 的过渡版本,包括类型系统支持 ES2026 的最新功能等大量改动。

此外,deno check 默认包含 lib.node,不需要在 deno.json 中的 compilerOptions.lib 手动添加 "node" 了。

image.png

如上,Deno 自动支持 process / Buffer 等 Node 专属的全局变量和类型。lib.node 基于 @types/node 实现,Deno 会从 npm 拉取该模块,process.versions.node 匹配 Node 的主版本,目前是 v24.x

如果你希望使用其他版本的 @types/node,比如仍在维护的更低版本 Node 22,可以在 package.json 中将其安装为开发依赖:

image.png

然后在 deno.json 中让 Deno 导入对应版本的模块:

image.png

👉 开发体验

Deno 2.8 支持让 Chrome DevTools(开发者工具)检查网络流量:

  1. 运行程序时添加 --inspect-wait 等参数
  2. 在 Chromium 中打开 chrome://inspect
  3. 点击 Deno 目标上的 Inspect(检查)

image.png

开发者工具的“Network“网络选项卡会显示客户端请求和响应头等所有内容:

network

相同的事件也会通过 node:inspector 客户端和 VS Code 的 JavaScript 调试器等工具显示出来。

此外,Deno 2.8 上线了一个与 Node --cpu-prof 匹配的内置 CPU 分析器,当程序退出时,Deno 会将 V8 的 CPU 分析结果写入磁盘。

image.png

.cpuprofile 文件可以在 Chrome DevTools 中直接打开,也可以输出为另外两种格式:

  • --cpu-prof-flamegraph 会生成一个独立的交互式 SVG 图片,可以在浏览器中打开
  • --cpu-prof-md 会生成一份人类可读的 Markdown 报告,包含最热门的函数等详细信息

image.png

👇 重点总结

Deno 2.8 是 Deno 进化史上最大的次版本升级,主要包括:

  • Deno CLI 新增了若干命令,Node 兼容性远超 Bun
  • 新增 JS Stage3 的 import defer 延迟导入提案
  • 包管理器对齐 npm 行为,支持模块提升的扁平化目录
  • TS 更新到 v6 主版本,支持类型剥离和 Node 专属类型

除此之外,Deno 官方博客还展示了 Deno 2.8 的性能提升,Web API 新功能等,更多技术细节另请参阅官方博客。

以上就是今日《前端快讯》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

🙏 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

👇 参考文献:

React 19.x 的 lazy 与 Suspense

作者 米丘
2026年5月28日 18:26

React.lazy

在构建大型 React 应用时,打包体积过大往往会影响首屏加载速度。React.lazy 正是为了解决这一问题而诞生的内置函数,它让你可以将组件动态导入(code splitting),并按需加载,从而显著提升应用性能。

为何使用 React.lazy?

  1. 减少初始包体积:应用的首屏可能不需要所有组件。通过代码分割,只加载当前路由或交互所需的组件。
  2. 提升首屏加载速度:更少的 JavaScript 意味着更快的解析和执行时间,改善用户体验。
  3. 优化缓存与带宽:用户可能只使用部分功能,懒加载未使用的代码可节省流量。
  4. 与 Suspense 天然集成:React.lazy 配合 Suspense 可以优雅地显示加载状态(如 Loading 动画)。

渲染阶段流程

React.lazy 组件加载完成的触发机制依赖于 Promise 的 resolve 回调 和 React 内部的 Suspense 重试(ping)机制

一、 初始化

调用 React.lazy(() => import('./Component')) 会生成一个特殊的“懒加载对象”,内部包含 _status(初始为 Uninitialized)和 _result(存储加载器函数)。

二、 首次渲染

当 React 遇到这个懒加载对象时,会调用内部函数 lazyInitializer

  • 执行 _result()(即 import()),得到一个 Promise(thenable)。
  • 将 _status 更新为 Pending_result 指向该 Promise。
  • 抛出该 Promise 以触发最近的 <Suspense> 边界.

三、 suspense 捕获与监听

React 捕获抛出的 Promise,向上查找 Suspense 边界:

  • 调用 attachPingListener 为该 Promise 添加一个 then 回调(即 ping 函数)。
  • 该边界立即渲染 fallback UI。

四、 加载完成(Promise resolve)

当动态导入的模块成功加载后,Promise 被 resolve,模块对象作为结果返回。Promise 的回调执行:

  • 将 lazy 对象的 _status 更新为 Resolved_result 替换为模块对象。
  • 调用之前附加的 ping 回调(实际上是 pingSuspendedRoot)。

五、 触发重新渲染

pingSuspendedRoot 会标记对应根节点的优先级车道(pingedLanes),并调用 ensureRootIsScheduled,重新调度整个应用的渲染(或仅重试该 Suspense 边界)。

六、 二次渲染

React 再次执行该组件的渲染逻辑,此时 lazyInitializer 发现 _status === Resolved,直接返回 _result.default(真正的组件),从而正常完成渲染,替换 fallback。

注意事项

  1. 避免在渲染函数内动态调用 lazylazy 应在模块顶层定义,确保每次渲染都得到相同的引用。
  2. 重复导入优化:同一个 lazy 组件在多处使用时,内部会共享相同的 Promise,不会重复加载。
  3. 错误处理:懒加载可能因网络问题失败,建议结合错误边界(Error Boundary)捕获加载失败错误。
  4. 命名导出问题:默认导出是 lazy 的约定,非默认导出需手动转换。
  5. 避免在 Suspense 外部调用 lazy 组件:否则无法捕获挂起。

示例 懒加载组件

lazy 接收一个函数,该函数必须返回一个动态 import() 调用(返回 Promise,其 resolve 值为包含 React 组件的模块)。Suspense 用于包裹懒加载组件,并在等待期间渲染 fallback 内容。

import { lazy, Suspense, useState } from "react";

const Card = lazy(() => import("./Card"));
const SuspenseB = () => {
  const [num, setNum] = useState(0);
  return (
    <div className="suspense-b">
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>click</button>
      <Suspense fallback={<div className="suspense-b-fallback">Loading...</div>}>
        <Card />
      </Suspense>
    </div>
  );
};
export default SuspenseB;

懒加载组件开始到成功的 三个阶段

初始化 状态 -1

image.png

加载中 状态 0

image.png

加载完成 状态 1

image.png

import {  useState, } from "react";

const Card = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="card">
      <p>Count: {count}</p>
      <button onClick={() => setCount((val) => val + 1)}>Click me</button>
    </div>
  );
};

export default Card;

调用 React.lazy(() => import('./Component')) 会生成一个特殊的“懒加载对象”,内部包含 _status(初始为 Uninitialized)和 _result(存储加载器函数)。

image.png

image.png

beginWork

image.png

fiber.elementType

image.png

resolveLazy 解析 lazy 组件

image.png

lazy._init(lazy._payload) 执行初始化函数

image.png

回到 resolveLazy 解析 lazy 组件

全局变量 suspendedThenable 为 promise pending状态

image.png

 const SuspenseException: mixed = new Error(
  "Suspense Exception: This is not a real error! It's an implementation " +
    'detail of `use` to interrupt the current render. You must either ' +
    'rethrow it immediately, or move the `use` call outside of the ' +
    '`try/catch` block. Capturing without rethrowing will lead to ' +
    'unexpected behavior.\n\n' +
    'To handle async errors, wrap your component in an error boundary, or ' +
    "call the promise's `.catch` method and pass the result to `use`.",
);

handleThrow 负责处理渲染过程中抛出的各种异常(包括普通错误和 Suspense 挂起)

image.png

getSuspendedThenable

全局变量重置 为 null ,返回 promise pending

image.png

回到 handleThrow

image.png

renderRootSync

全局变量 workInProgressSuspendedReason 为 3, 代表 SuspendedOnImmediate 因任务立即挂起

image.png

找到边界

image.png

// 未挂起,正常渲染
const NotSuspended: SuspendedReason = 0;
// 渲染过程中抛出异常
const SuspendedOnError: SuspendedReason = 1;
// 等待异步数据
const SuspendedOnData: SuspendedReason = 2;
// 因立即任务挂起
const SuspendedOnImmediate: SuspendedReason = 3;
// 因实例挂起
const SuspendedOnInstance: SuspendedReason = 4;
// 因实例挂起但准备继续
const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
// 因废弃的 Promise 挂起
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
// 挂起准备继续
const SuspendedAndReadyToContinue: SuspendedReason = 7;
// 因 hydration 挂起
const SuspendedOnHydration: SuspendedReason = 8;
// 因 action 挂起
const SuspendedOnAction: SuspendedReason = 9;

throwAndUnwindWorkLoop 处理渲染过程中的异常/挂起,展开栈并找到处理边界

image.png

throwException 处理渲染阶段的异常和 Suspense

image.png

attachPingListener 为 Suspense 边界的挂起 Promise(wakeable)添加“ping”监听器

image.png

渲染 fallback

再次进入 ,lazy组件还是加载中

image.png

懒加载组件加载完成

image.png

加载完毕是一个函数组件

image.png

示例 命名导出组件的懒加载

const InfoCard = lazy(() =>
  import("./Card").then((mod) => ({ default: mod.InfoCard })),
);
export const InfoCard = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="info-card">
      <p>Info Card</p>
      <button onClick={() => setCount((val) => val + 1)}>
        InfoCard-Click me
      </button>
      <p>InfoCard Count: {count}</p>
    </div>
  );
};

beginWork 阶段

fiber.tag = 16 , 代表 LazyComponent

case LazyComponent: {
  const elementType = workInProgress.elementType;
  return mountLazyComponent(
    current,
    workInProgress,
    elementType,
    renderLanes,
  );
}

image.png

completeWork 阶段

case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
  bubbleProperties(workInProgress);
  return null;

源码

const Uninitialized = -1; // 未初始化,未调用
const Pending = 0; // 加载中
const Resolved = 1; // 加载成功
const Rejected = 2; // 加载失败
function lazy<T>(
  ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {

  // 创建 payload 对象
  const payload: Payload<T> = {
    // We use these fields to store the result.
    _status: Uninitialized, // 初始化,未调用
    _result: ctor, // 存储工厂函数
  };

  // 创建 lazyType 对象   
  const lazyType: LazyComponent<T, Payload<T>> = {
    $$typeof: REACT_LAZY_TYPE, // 标识是 lazy 组件
    _payload: payload, // 存储 payload 对象,加载信息
    _init: lazyInitializer, // 初始化函数
  };

  return lazyType;
}
  • 未初始化状态(_status === Uninitialized),状态变为 Pending,执行 throw payload._result;,抛出 thenable。这正是 React Suspense 的触发点。
  • 成功回调:当模块加载成功时,将 payload._status 设置为 Resolvedpayload._result 设置为模块对象。
  • 失败回调:将 payload._status 设置为 Rejectedpayload._result 设置为错误对象。如果是 Rejected,抛出错误,由最近的错误边界(Error Boundary)捕获
function lazyInitializer<T>(payload: Payload<T>): T {
  // 未初始化处理
  if (payload._status === Uninitialized) {
    let resolveDebugValue: (void | T) => void = (null: any);
    let rejectDebugValue: mixed => void = (null: any);
    const ctor = payload._result; // 加载器函数 () => import("")
    const thenable = ctor(); // 加载器函数返回的 Thenable 对象

    // 监听 Promise 状态变化
    thenable.then(
      moduleObject => { // 加载成功
        // 正在加载、未初始化
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          // Transition to the next state.
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved; // 设置状态为加载成功
          resolved._result = moduleObject; // 设置结果为模块对象


          if (thenable.status === undefined) {
            const fulfilledThenable: FulfilledThenable<{default: T, ...}> =
              (thenable: any);
            fulfilledThenable.status = 'fulfilled'; // 设置状态为加载成功
            fulfilledThenable.value = moduleObject; // 设置值为模块对象
          }
        }
      },
      // 加载失败
      error => {
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          // Transition to the next state.
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected; // 设置状态为加载失败
          rejected._result = error; // 设置结果为错误对象
 
          if (thenable.status === undefined) {
            const rejectedThenable: RejectedThenable<{default: T, ...}> =
              (thenable: any);
            rejectedThenable.status = 'rejected';
            rejectedThenable.reason = error;
          }
        }
      },
    );


    // 未初始化
    if (payload._status === Uninitialized) {
      const pending: PendingPayload = (payload: any);
      pending._status = Pending;
      pending._result = thenable;
    }
  }
  // 加载成功
  if (payload._status === Resolved) {
    const moduleObject = payload._result;
    return moduleObject.default; // 返回模块对象的默认导出
    
  } else {
   // 抛出 thenable。这正是 React Suspense 的触发点
    throw payload._result;
  }
}

Suspense

Suspense 是 React 内置的组件,用于包裹那些可能“挂起”(Suspend)的子组件。当子组件抛出 Promise(或 React 内部的 Suspense 异常)时,Suspense 会捕获并渲染 fallback 属性指定的占位内容,直到 Promise 解决后重新渲染子组件。

suspense 能够实现:

  • 并行等待多个资源。
  • 避免加载闪烁(快速加载时不显示 fallback)。
  • 与错误边界(Error Boundary)无缝集成。

注意事项

  1. 避免在 fallback 中再使用 Suspense
  2. Suspense 不能捕获错误 。它只处理 Promise 挂起,普通错误(如运行时错误)需要 Error Boundary。

beginWork

Suspense 组件会根据是否已捕获挂起(DidCapture 标记)或需要停留在 fallback 状态,决定本次渲染显示 fallback 还是 primary 内容:

  • 若需显示 fallback,则创建 fallback 子树并将 primary 子树包裹为隐藏的 Offscreen 组件以保留状态。
  • 否则正常渲染 primary 子树。
case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);

completeWork

Suspense 组件负责完成水合收尾(处理 SSR 脱水节点)、处理 DidCapture 标记以触发重新渲染 fallback、调度重试队列(为等待的 Promise 附加 ping 监听器),并标记因 fallback/primary 切换而产生的副作用(如添加 Visibility 或 Passive 标记),最后向上冒泡 childLanes

commit 阶段

Mutation 子阶段

  • 通过 Offscreen 组件的 Visibility 标记,对 primary 树执行 display: none(隐藏)或恢复显示。
  • 处理边界删除时的清理工作(解绑 ref、调用 componentWillUnmount)。
  • 清空已完成的重试队列。

Layout 子阶段

  • 执行 scheduleRetryEffect 中调度的重试回调:为 retryQueue 中的每个 Promise 附加 ping 监听器(pingSuspendedRoot)。
  • 允许子组件(primary 或 fallback)正常执行 useLayoutEffect 和 componentDidMount/Update

Passive 阶段(异步):

  • 执行 Offscreen 子树上因可见性变化而挂起的 useEffect 清理和回调。

示例 代码分割

import { lazy, Suspense, useState } from "react";

const Card = lazy(() => import("./Card"));
const SuspenseB = () => {
  const [num, setNum] = useState(0);
  return (
    <div className="suspense-b">
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>click</button>
      <Suspense fallback={<div className="suspense-b-fallback">Loading...</div>}>
        <Card />
      </Suspense>
    </div>
  );
};
export default SuspenseB;

beginWork updateSuspenseComponent

当前正在处理 workInprocess 是 suspense 组件,有属性pendingProps包含children 和 fallback,current为 null

image.png

image.png

mountSuspensePrimaryChildren

直接渲染 primary fiber

mountWorkInProgressOffscreenFiber 创建 Offscreen fiber

image.png

这里return,结束当前的beginWork

image.png

来到 beginWork updateOffscreenComponent

此时 workInProcess 为 Offscreen fiber,(之前 suspense要渲染的primary fiber),current 为 null

image.png

首次挂载创建 Offscreen 实例,用于存储 Offscreen 的可见性、待处理的标记、重试缓存及相关 transitions

image.png

image.png

reconcileChildren 子节点

createFiberFromTypeAndProps 创建 lazy fiber

return,又结束此次 beginWork

image.png

来到 beginWrok lazy fiber

image.png

在解析懒加载组件时,会 有微任务产生

进入 beginWork tag 为13 suspense

image.png

支持显示 fallback

挂载 primary fiber,mode 为隐藏状态

image.png

创建 fallback fiber,类型为 Fragment

image.png

关系

workInProcess.childprimary fiber

primary fibersiblingfallback fiber

image.png

image.png

image.png

beginWork 结束,再次进入beginWork 处理 fallback fiber

当 lazy 加载完成后,继续处理

示例 数据获取 use

import { use, useState, Suspense } from "react";

const fetchData = (async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
  return response.json();
})();

const SuspenseC = () => {
  const result = use(fetchData);
  const [count, setCount] = useState(0);

  console.log("state-a-render-----");

  return (
    <div className="state-a">
      <h3>StateA</h3>
      <p>{result?.title}</p>
      <p>当前count: {count}</p>
      <button onClick={() => setCount(count + 1)}>SuspenseC -点击增加</button>
    </div>
  );
};

const App = () => {
  return (
    <Suspense fallback={<div className="suspense-c-fallback">Loading...</div>}>
      <SuspenseC />
    </Suspense>
  );
};
export default App;

最后

手写虚拟DOM后,我反问面试官:key为什么不能用index?

作者 kyriewen
2026年5月28日 18:12

前言

虚拟DOM和diff算法是React面试的“进阶题”,一般不会让手写完整实现,但一旦遇到,就是区分“会用React”和“懂React”的分水岭。大部分前端能说出虚拟DOM的好处,但真要写一个mini版,很多人会卡在diff的key逻辑上。

今天我就还原那次面试:AI生成的虚拟DOM核心代码、我是如何解释diff的、以及为什么“key不能用index”这个问题能让我反客为主。最后附完整代码,你可以直接拿去跑,也可以用来准备面试。

一、AI生成的虚拟DOM核心代码

我在Cursor里输入:

用原生JavaScript实现一个简易虚拟DOM库,包含:

  • h(type, props, ...children) 创建虚拟节点
  • render(vnode) 将虚拟节点转为真实DOM
  • patch(oldVnode, newVnode) 对比并更新真实DOM,支持key属性,实现最小化更新

AI输出的核心结构如下(精简后):

// 创建虚拟节点
function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}

// 渲染虚拟DOM到真实DOM
function render(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode);
  const el = document.createElement(vnode.type);
  for (let key in vnode.props) {
    el.setAttribute(key, vnode.props[key]);
  }
  vnode.children.forEach(child => el.appendChild(render(child)));
  return el;
}

// 简易diff(带key优化)
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
  // 如果是文本节点
  if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
    if (oldVnode !== newVnode) {
      parent.replaceChild(render(newVnode), oldVnode);
    }
    return;
  }
  // 不同类型,直接替换
  if (oldVnode.type !== newVnode.type) {
    parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  // 相同类型,更新属性(省略细节)
  // 然后递归处理children,这里重点演示key的作用
  const oldChildren = oldVnode.children;
  const newChildren = newVnode.children;
  const keyedOld = new Map();
  // 将旧节点按key建立索引
  oldChildren.forEach((child, idx) => {
    if (child.props && child.props.key) keyedOld.set(child.props.key, { child, idx });
  });
  // 遍历新节点,复用key相同的节点
  newChildren.forEach((newChild, newIdx) => {
    if (newChild.props && newChild.props.key) {
      const matched = keyedOld.get(newChild.props.key);
      if (matched) {
        // 复用该DOM节点,递归更新子内容
        patch(matched.child, newChild, parent);
        // 移动位置(这里省略,示意核心)
        return;
      }
    }
    // 没有匹配,插入新节点
    parent.appendChild(render(newChild));
  });
}

二、我反问了面试官一个问题

等代码展示完,面试官还没开口,我说:“这个diff算法里用key来匹配节点。很多前端都用过key,但有一个经典误区——把数组索引当key用。您知道为什么这样会有问题吗?”

他来了兴趣:“你说说看。”

我解释:

  • diff算法通过key判断节点是否“相同”。如果用索引,比如列表顺序变了,索引0可能原来对应A,现在对应B,但key相同(都是0),React会认为这两个节点相同,不重新创建,只是更新内容。这样本应销毁A、创建B的场景,变成了复用A并修改内容。如果组件有复杂状态(比如动画、输入框焦点),就会出现状态错乱。
  • 更严重的是,在列表头部插入一个元素,所有后续节点的索引都变了,每个节点都会被“原地修改”,性能反而比不用key还差。
  • 正确做法是用数据中唯一稳定的标识(如id)作为key。

他点头:“这才是我想听到的答案。”

三、为什么面试官认可这种“反客为主”?

他后来告诉我:“你能自己生成正确的diff逻辑,还能主动抛出常见的误区,说明你不仅会写,还真的思考过生产中的坑。这种深度,比背代码有价值。”

所以这道题的关键不是完美写出所有diff逻辑,而是理解key的真实作用。AI帮你搭了骨架,你用自己的理解填充了灵魂。

四、完整可运行的迷你虚拟DOM代码

我把面试中使用的完整代码放在这里,你可以在浏览器控制台运行测试:

// 完整示例(带简版diff和key复用)
function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}
function render(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode);
  const el = document.createElement(vnode.type);
  for (let k in vnode.props) el.setAttribute(k, vnode.props[k]);
  vnode.children.forEach(c => el.appendChild(render(c)));
  return el;
}
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
  if (oldVnode === newVnode) return;
  // 文本节点
  if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
    if (oldVnode !== newVnode) parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  if (oldVnode.type !== newVnode.type) {
    parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  // 更新属性(略)
  // 处理children(简易版:只演示替换,不移动)
  const oldChildren = oldVnode.children;
  const newChildren = newVnode.children;
  const maxLen = Math.max(oldChildren.length, newChildren.length);
  for (let i = 0; i < maxLen; i++) {
    if (i < oldChildren.length && i < newChildren.length) {
      patch(oldChildren[i], newChildren[i], parent.childNodes[i]);
    } else if (i < newChildren.length) {
      parent.appendChild(render(newChildren[i]));
    } else {
      parent.removeChild(parent.childNodes[i]);
    }
  }
}

你可以用这段代码测试列表渲染,尝试改变顺序或插入头节点,观察不用key vs 用index vs 用id的区别。

五、写在最后

虚拟DOM和diff是React的根基,手写一遍能让你对性能优化有更深的体感。AI能帮你快速生成模板,但真正拉开差距的,是对“为什么key不能用index”这种问题的思考深度。

Redux 中间件作用(redux-thunk/redux-saga)

作者 光影少年
2026年5月28日 16:59

Redux 中间件(Middleware)本质上是:
dispatch(action) 到达 reducer 之前,对 action 做增强处理的一层机制。

它主要解决:

  • 异步请求
  • 日志打印
  • 权限校验
  • 接口调用
  • 延迟 dispatch
  • 副作用管理

一、Redux 默认的问题

Redux 原生规定:

store.dispatch({
  type: 'ADD'
})

dispatch 只能发送:

  • 普通对象 action

而且:

  • reducer 必须是纯函数
  • reducer 不能写异步

所以:

setTimeout()
axios()
fetch()

这些都不能直接写进 reducer。

这时候就需要:

中间件 Middleware


二、中间件执行流程

Redux 数据流:

dispatch(action)
   ↓
middleware
   ↓
reducer
   ↓
store 更新
   ↓
view 更新

多个中间件:

dispatch
  ↓
thunk
  ↓
logger
  ↓
saga
  ↓
reducer

三、redux-thunk

1. thunk 是什么

Redux Thunk

Thunk 是 Redux 最常用的异步中间件。

它允许:

dispatch(function)

而不是只能:

dispatch(object)

四、redux-thunk 核心思想

普通 Redux:

dispatch({
  type: 'GET_USER'
})

Thunk:

dispatch(async function(dispatch){
   const res = await axios.get('/user')

   dispatch({
      type:'SET_USER',
      payload: res.data
   })
})

也就是:

dispatch 一个函数

函数内部:

  • 可以写异步
  • 可以再次 dispatch
  • 可以拿到 store

五、thunk 工作原理

内部核心思想:

const thunk = store => next => action => {

   if(typeof action === 'function'){
      return action(store.dispatch, store.getState)
   }

   return next(action)
}

意思:

  • 如果 dispatch 的是函数

    • 就执行它
  • 如果是普通对象

    • 继续传给 reducer

六、thunk 使用流程

1. 安装

npm install redux-thunk

2. 注册 middleware

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'

const store = createStore(
  reducer,
  applyMiddleware(thunk)
)

3. 编写异步 action

export const getUser = () => {

  return async (dispatch) => {

    const res = await axios.get('/api/user')

    dispatch({
      type:'SET_USER',
      payload:res.data
    })
  }
}

4. 页面调用

dispatch(getUser())

七、redux-thunk 优缺点

优点

简单易学

适合:

  • 小项目
  • 中型项目
  • 简单异步

缺点

大型项目容易:

  • 回调地狱
  • action 逻辑混乱
  • 难维护
  • 副作用分散

例如:

dispatch(async ()=>{
   await api1()
   await api2()
   await api3()
})

会越来越复杂。


八、redux-saga

Redux-Saga

Saga 是:

更强大的异步流程管理方案

核心思想:

把异步逻辑单独管理

类似:

  • 后台任务
  • 事件监听
  • 协程
  • generator

九、saga 最大特点

它使用:

Generator

例如:

function* getUserSaga() {

   const res = yield call(api.getUser)

   yield put({
      type:'SET_USER',
      payload:res
   })
}

十、saga 工作流程

dispatch(action)
    ↓
saga监听
    ↓
执行异步任务
    ↓
put(action)
    ↓
reducer

十一、核心 API

takeEvery

监听每次 action

yield takeEvery('GET_USER', getUserSaga)

takeLatest

只保留最后一次请求

适合搜索:

yield takeLatest('SEARCH', searchSaga)

put

等于 dispatch

yield put({
  type:'SET_USER'
})

call

调用异步函数

yield call(api.getUser)

select

获取 store 数据

const state = yield select()

十二、saga 使用流程

1. 安装

npm install redux-saga

2. 创建 sagaMiddleware

import createSagaMiddleware from 'redux-saga'

const sagaMiddleware = createSagaMiddleware()

3. 注册

const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
)

4. 启动 saga

sagaMiddleware.run(rootSaga)

5. 编写 saga

function* getUserSaga(){

   const res = yield call(api.getUser)

   yield put({
      type:'SET_USER',
      payload:res
   })
}

function* rootSaga(){
   yield takeEvery('GET_USER', getUserSaga)
}

十三、thunk vs saga

对比 thunk saga
学习成本
异步方式 函数 Generator
复杂流程 一般
维护性
适合项目 小中型 大型
取消请求 不方便 容易
并发控制
副作用管理 分散 集中

十四、实际项目怎么选

小项目

直接:

  • Redux Toolkit
  • thunk

现在主流:

Redux Toolkit

因为 RTK 默认内置 thunk。


大型项目

复杂场景:

  • websocket
  • mqtt
  • 长连接
  • 多请求编排
  • 权限流
  • 工作流

适合:

  • saga

十五、你现在前端开发里更应该学什么

结合你现在 React + 平台开发经验:

建议优先级:

Redux Toolkit
    ↓
RTK Query
    ↓
redux-thunk
    ↓
redux-saga

因为现在很多公司:

  • 已经不用传统 redux
  • 更偏 RTK

十六、现代 Redux 已经变成这样

以前:

redux
redux-thunk
action
reducer
constants
types

现在:

Redux Toolkit
createSlice
createAsyncThunk
RTK Query

代码量减少很多。


十七、现代写法(推荐)

import { createAsyncThunk } from '@reduxjs/toolkit'

export const getUser = createAsyncThunk(
  'user/getUser',
  async ()=>{

     const res = await axios.get('/user')

     return res.data
  }
)

萌新小白基础理解篇之 this 关键字

作者 biubiubiu_LYQ
2026年5月27日 18:35

前言

  早在我们前几篇文章中,就有出现过 this ,但是我们一直没有详细解释 this 是什么,this 可以出现在哪,this 的用法又是如何?那这篇文章我们一起来看看吧!

一、为什么要有this?

  this 是 js 中的一个关键字,它提供了一种更优雅的方式隐式的传递一个对象的引用,可以让代码更简洁易于复用,js 关键字是内置好的,拥有特殊语法含义的词,不能作为变量名,函数名,还有if,else,for等等关键字。我们来看一段代码感受一下。

 function identify(context) {
   return context.name.toUpperCase()  //.toUpperCase()  让小写全转化为大写 
 }

 function speek(context) {
   var greeting = 'hello, I am ' + identify(context)
   console.log(greeting);
 }

 var me = {
   name: 'tom'
 }

 speek(me)  

  当代码运行到14行时带来speek()函数的调用,把me作为实参传进去,此时运行speek()函数又带来了identify() 的调用,将me作为实参传进去,返回得到大写的 TOM ,console.log(greeting)得到 hello,I am TOM。

image.png

  如果用 this 我们可以怎么写

function identify() {
  return this.name.toUpperCase()
}

function speek() {
  var greeting = 'hello, I am ' + identify.call(this)
  console.log(greeting);
}

var me = {
  name: 'tom'
}

speek.call(me)

  我们可以看到,上述结果是相同的,函数 speek 和 identify 不再接收 context 参数。

  • 使用 this 关键字直接访问调用上下文中的属性(如 this.name)。
  • 调用时,通过 .call(me) 显式绑定 this 指向目标对象。下文会详细解释.call()用法
  • 逻辑链条变为:对象 → 绑定为 this → 函数内部直接通过 this 访问

  它提供了一种更优雅的方式隐式的传递一个对象的引用,可以让代码更简洁易于复用。

image.png

二、this 可以出现在哪?

  • 1.全局 (this === window
  • 2.函数体内

  理论上,this 可以出现在任何地方,如果出现在全局,那么 统一代指 的是window,所以我们主要区分函数体内的 this 代指的是哪个,this 用在不同的地方,代指的内容是不一样的。

this 可以出现在块级作用域但是毫无意义

三、 this的绑定规则

1.默认绑定 --- 当函数独立调用时,函数中的 this 指向 window
var a = 1   //  ===window:{a:1}  等于往window里面增加了 a为1 

function foo(){
    console.log(this.a)  // 1
}

function bar () {
    var a = 2
    foo() //独立调用
}

bar()

   this 如果出现在全局,那么它代指 window , 此时 this 出现在foo函数内,但是这个foo函数是被独立调用的,那么此时 this 依旧指向 windowconsole.log(this.a) 为 1,什么叫独立调用呢? 独立调用 = 函数名直接加括号执行,没有任何对象或上下文“牵着”它。

2.隐式绑定 --- 当一个函数被一个上下文对象所拥有并被该对象调用,那么函数中的 this 指向该对象
var a = 1   //  ===window:{a:1}  等于往window里面增加了 a为1 

function foo(){
    console.log(this.a)   //3
}

function bar () {
    var a = 2
    foo() //独立调用
}

bar()

var test = {
    a : 3,
    foo :foo  //引用函数
}

test.foo()  //隐式绑定

  我们可以看到 前面的 foo() 就是单独的函数名+括号的形式, 后面的为 test.foo() ,打个比方,就像你一个人逛街和你女朋友牵着你逛街的区别,你一个人逛街就叫独立调用,有女朋友牵着就不叫独立调用,此处我们称之为隐式绑定,而此时 this 指向的对象 就是 test ,所以此时 console.log(this.a) 为3

3.隐式丢失 --- 当一个函数被多层对象调用,函数的 this 指向最近的对象
function foo(){
    console.log(this.a)
}
var obj = {
    a:1, 
    foo : foo   //key :value  ,key 的名字可以随便取, 但 value 不可以随便
}
var oo = {
    a : 2,
    foo : obj
}

oo.foo.foo()  //this 指向 obj

  我们来捋一捋这个代码的逻辑,v8运行这段代码,运行到13行前,知道有一个 foo函数 ,有一个obj 对象,一个 oo 对象,当运行到13行时,有函数的调用,才开始读取它们的内容,那代码是从左往右执行,先读取 oo.foo ,那v8就要去oo里面找这个 foo 是什么,我们可以看到此时的 foo 值为 obj 对象,那就相当于 obj.foo(), 在去obj中 找 foo 是什么,此时 foo 的值 为foo 函数 ,然后() 开始foo函数的调用,所以相当于是 obj 调用了这个函数,此时 this 指向 obj ,也即当一个函数被多层对象调用,函数的 this 指向最近的对象。

4.显示绑定 --- 强行''掰弯'' this 指向一个对象 (三种方法)
  • fn.call(obj, x, y)

  • fn.apply(obj, [x,y])

  • fn.bind(obj, x, y)()

function foo(x,y){
    console.log(this.a, x+y)
}

var A = {
    a : 1
}

foo() //独立调用 指向 window
foo.call(A,1,2)  //this 指向A  传递参数 1,2
foo.apply(A,[2,3])  // this 指向A 传入参数2,3 
foo.bind(A,1,2)() //this 指向A,传入参数1,2 

.call( obj, x, y) : 让 this 强行指向 A,可以逐个传递参数 (较为零散的方式传递参数)

.apply( obj, [x,y] ) : 让 this 强行指向 A,以数组的模式逐个传递参数 (较为集中的方式传递参数)

.bind( obj, x, y)() : 让 this 强行指向 A,但是执行完后一定会返回一个函数出来,并且要把它触发掉,也是零散的传递参数,也可以 const bar 来接收 返回的函数 再调用触发,可以分开传参

const bar = foo.bind(obj,x,y)   const bar = foo.bind (obj,x)   const bar = foo.bind (obj) 
bar()                            bar(y)                        bar(x,y)
5.new 绑定 --- new 的原理会导致函数的 this 指向实例对象
function Person(){
    // var obj = {}      //1
    //Person.call(obj)   //2
    this.name = '杰哥'    //3   等同于  obj.name = '杰哥'
    // obj.__proto__ = Person.prototype    //4
    //return obj         //5
}

const p = new Person()  //此时的 p = obj
console.log(p)   // {name : 杰哥}

  我们在万物皆对象那篇文章中有讲到过 new 的工作原理,但当时并没有详细解释 this 所以表述其实并不准确,new 的具体工作原理应该是这样

  • 创建一个空对象 即 var obj = {}

  • 让函数体的 this 强行指向 实例对象 即 Person.call(obj)

  • 运行函数内的代码逻辑

  • 让对象的原型等于函数的原型 即 obj.proto = Person.prototype

  • 返回这个对象 即 return obj

四、箭头函数

  箭头函数没有 this 这个概念,写在箭头函数中的 this,也是它外层那个非箭头函数的

var bar = function(){     //函数表达式

}
bar()

var baz = (x,y) => {     //函数表达式
   
}

  如果不用到 this ,两种写法都是可以的,但如果用到 this 那我们需要注意一下了

function foo(){
    var fn = () =>{   //箭头函数没有 this 这个概念
        this.a = 2
    }
    fn()
}

var obj = {
    a : 1,
    bar:foo
}
obj.bar()
console.log(obj)

  由于箭头函数没有 this 这个概念,写在箭头函数中的 this,也是它外层那个非箭头函数的,所以此时 this 是 foo的 ,而foo是通过obj.bar()调用的,所以 foo 的 this 指向 obj 对象,console.log(obj) 得到 { a : 1, bar : foo }

image.png

箭头函数不可以被new调用 (new的第二步无法执行,用了就会报错)

(如有补充,请大佬指点)

3fd2900e2e696b2fa8e8cedf528d1195.jpg

在 React 里写动画又不跟渲染周期较劲:useRafFn、useRafState、useFps、useDevicePixelRatio、useUpdate

2026年5月27日 16:04

React 用一套时钟,浏览器用另一套。React 的协调器根据 state 更新、effect、调度器对"尽快"的理解来决定何时重新渲染组件。浏览器的合成器则按显示器能撑住的速度刷屏——大多数显示器是 60Hz,少数是 120Hz。两套时钟并不同步。state 更新会落在两次绘制之间被合并;庞大的渲染树可能整个错过一帧;setInterval(handler, 16) 一分钟下来会漂移几百毫秒,因为它根本不关心 GPU 在干嘛。

标准解法是 requestAnimationFrame。它在下一次绘制之前调用你的回调,附带一个高精度时间戳,并且在标签页隐藏时自动节流。它就是所有要看起来"丝滑"的东西该用的原语。但它在 React 里手工接线很繁琐:你需要一个 ref 存帧 ID、一个 effect 启动循环、一段清理函数在卸载时取消、一个 useLatest 让回调看到最新的 props,再加一个 ref 才能做暂停/恢复。每个动画组件都重写一遍这套脚手架,而大多数人第一次写都会漏掉某个清理。

ReactUse 把这套脚手架收进了五个共享同一底层循环的 hook。本文逐个走读——useRafFn 提供循环本身,useRafState 做随循环更新的 state,useFps 量化这个循环,useDevicePixelRatio 让你在循环里以正确分辨率绘制,useUpdate 应付那些"需要推一下 React 但又没 state 可改"的场景。合起来基本能覆盖你在专门的动画库之外要做的所有事。

一个组件里的 bug

一张跟随鼠标的浮卡:

function FloatingCard() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const move = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', move);
    return () => window.removeEventListener('mousemove', move);
  }, []);

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
      }}
    >
      card
    </div>
  );
}

看上去没毛病。打开 devtools 性能面板,鼠标在屏幕上甩一遍。在一台快点的笔记本上,mousemove 每秒触发 120 到 500 次,看输入设备和 OS。每次都会调用 setPos,每次都触发一次重渲染调度,React 把它们合并到下一个 microtask。你在做屏幕能展示的两到八倍的协调工作,多出来的渲染全是纯开销——真正有意义的只是下一次绘制之前的最后一次。

useRafState 把这件事压缩成每帧一次,不管事件多快。原地替换,同样的 [state, setState] API,每次鼠标抖动少三次协调。本文剩下的 hook 都遵循同一个模式:保留 React 风格的 API,把 requestAnimationFrame 的管道藏起来。

1. useRafFn——带暂停/恢复的循环

useRafFn 是其他一切的基石。它接收一个回调,在每个 requestAnimationFrame tick 上调用,并把高精度时间戳传进去。返回 [stop, start, isActive],让你可以在标签页失焦、用户交互或任何其他信号上暂停循环:

import { useRef } from 'react';
import { useRafFn } from '@reactuses/core';

function StarField({ count = 200 }: { count?: number }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const starsRef = useRef(
    Array.from({ length: count }, () => ({
      x: Math.random(),
      y: Math.random(),
      z: Math.random() * 0.5 + 0.5,
    })),
  );

  const [stop, start, isActive] = useRafFn((time) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d')!;
    const { width, height } = canvas;

    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, width, height);

    const t = time / 1000;
    for (const star of starsRef.current) {
      const x = ((star.x + t * 0.02 * star.z) % 1) * width;
      const y = star.y * height;
      ctx.fillStyle = `rgba(255, 255, 255, ${star.z})`;
      ctx.fillRect(x, y, 2, 2);
    }
  });

  return (
    <>
      <canvas ref={canvasRef} width={600} height={400} />
      <button onClick={() => (isActive() ? stop() : start())}>
        {isActive() ? '暂停' : '继续'}
      </button>
    </>
  );
}

这个 hook 有四个设计选择值得理解。回调在下一次绘制之前运行——这是 requestAnimationFrame 的语义——所以回调里做的任何 DOM 读取看到的都是即将绘制时的布局,不会额外触发强制回流。回调引用被 useLatest 包了一层,所以你可以闭包到新鲜的 props(count、作用域里任何东西)而不必重启循环。循环挂载时自动启动;第二个参数传 false 则从第一帧起就停在手动控制状态。清理注册在 effect 上,所以卸载时会取消挂起的帧——不会有野回调在死掉的组件上跑。

isActive 返回的是函数而不是布尔。在事件处理器里调用它总能拿到当前值;在渲染里调用只能看到渲染时的值。这种不对称容易踩。如果你要把激活标志用在 JSX 的 disabled={} 这种 prop 上,配合 useUpdatestop/start 调用方里手动 update()——上面示例没这么做是因为按钮文案下一次点击时本来就会重算。

useRafFn 真实场景下还有不少 canvas 之外的用法:任何要在两次事件之间追踪时间的活儿都用得到。一个要按 delta time 积分速度的物理模拟。一个 scrub bar 想紧跟媒体元素的 currentTime,而不是等那个粗糙的 timeupdate 事件(它按编解码器心情触发,不按你心情)。一个用弹簧拖尾跟随真实鼠标的自定义指针——useRafFn 读最新的目标位置,跑一步弹簧迭代,把结果写到 CSS 变量。这些都在替代那些会漂移、又会在后台标签里烧电池的 setInterval 模式。

2. useRafState——按帧合并的 useState

useRafState 是那张浮卡你真正会发布的版本:

import { useRafState } from '@reactuses/core';
import { useEventListener } from '@reactuses/core';

function FloatingCard() {
  const [pos, setPos] = useRafState({ x: 0, y: 0 });

  useEventListener('mousemove', (e) => {
    setPos({ x: e.clientX, y: e.clientY });
  });

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
        transition: 'transform 0.1s',
      }}
    >
      card
    </div>
  );
}

API 完全是 useState——同样的 setter 签名,同样支持 updater 函数——但写入会被 requestAnimationFrame 排队。同一帧内的五次 setPos 合并为一次 React 更新;React 更新每次绘制最多 flush 一次;DOM 更新的频率正好与屏幕刷新同步。mousemove 监听还是按 500Hz 触发,开销几乎等同于调一个空函数。协调成本掉到 60Hz,正好是屏幕能展示的。

几点要知道。这个 hook 给每个 state 槽位维护一个挂起的 requestAnimationFrame ID,所以同一帧内连续的 setter 是替换,不是排队——最后一个值赢。视觉 state 几乎总是想要这个语义:你不在乎中间的鼠标位置,只在乎绘制那一刻光标在哪。如果你真的在乎——比如你在采样传感器数据每个值都要——那就用普通 useState 并接受重渲染成本,或者写到 ref 里然后用 useRafFn tick 来 flush。

清理细节和 useRafFn 一样:挂起的帧在卸载时取消,所以快速点击-拖拽-卸载的连击不会冒出 setState on unmounted component 警告。内部实现是 useState + useRef(存帧 ID) + useUnmount 清理,总共大概二十行。你自己写得出来;这个 hook 只是省下了你每次都写一遍。

有个坑。因为 state 比事件慢一帧,调用 setter 立刻读 state 还是旧值:

setPos({ x: 100, y: 100 });
console.log(pos); // 还是 { x: 0, y: 0 } —— 更新还没跑

普通 useState 在同一次渲染周期内也是这样,但慢整整一帧这件事在拼命令式代码时容易让你意外。要回读这个值,旁边再放一个 ref 同步存。

3. useFps——量化你做出来的东西

useRafFnuseRafState 都在改善流畅度,但流畅度是一个可量化的指标,不是感觉。useFps 返回当前帧率(数字),通过统计底层 requestAnimationFrame 回调触发的频率算出来:

import { useFps } from '@reactuses/core';

function FpsOverlay() {
  const fps = useFps();
  const color = fps >= 55 ? 'green' : fps >= 30 ? 'orange' : 'red';

  return (
    <div
      style={{
        position: 'fixed',
        top: 8,
        right: 8,
        padding: '4px 8px',
        background: 'rgba(0,0,0,0.7)',
        color,
        fontFamily: 'monospace',
      }}
    >
      {fps} fps
    </div>
  );
}

丢进 dev build,你就有了平时要打开 Chrome rendering 面板才能看的 FPS 计数器。hook 接受一个 every 选项(默认 10),控制平均多少帧;小数字对卡顿响应快但抖动多,大数字读数更平滑但对突然掉帧反应慢。角落的常驻 overlay 用 10 很合适;如果你在调一段具体的卡顿过场动画,就用 1 或 2。

更有意思的用法是自适应渲染。读 FPS,掉到阈值以下就减少要做的事:

function ParticleSystem({ baseCount = 1000 }: { baseCount?: number }) {
  const fps = useFps({ every: 30 });
  const count =
    fps >= 55 ? baseCount : fps >= 40 ? baseCount / 2 : baseCount / 4;

  return <Particles count={count} />;
}

这正是 3A 游戏引擎在帧预算吃紧时的做法——降粒子数、调阴影分辨率、把流体模拟换成更粗的网格。对一个 React 应用来说,通常把动画背景的粒子数减半,或者干脆停掉一个非关键的 useRafFn 循环,就足够了。阈值数字凭口味;60Hz 显示器上 55 是一条合理的"我们基本还行"的线,因为平均值光被 GC 拽一下就能掉进 55 到 60 区间,没人会注意到。

关于 SSR:hook 在服务端返回 0,所以别把关键 UI 卡在"值非零"上。客户端第一次渲染在首个测量窗口结束前也是 0,下个 tick 才跳到真实值。如果你拿它做自适应渲染,第一个测量到达之前默认走"高保真"分支。

4. useDevicePixelRatio——以正确分辨率绘制

Canvas 元素有两套尺寸:CSS 尺寸决定它在页面上看起来多大;像素缓冲尺寸决定它看起来多精细。在 Retina 屏上设备像素比是 2,于是一个 CSS 尺寸 600px × 400px<canvas width="600" height="400"> 会显得糊——600×400 的像素缓冲被浏览器合成器拉伸到 1200×800 的物理像素上。修法是把缓冲设为 cssWidth × dprcssHeight × dpr,再把绘图上下文按 dpr 缩放,这样坐标还是按 CSS 单位写。

useDevicePixelRatio 响应式地追踪当前像素比——包括用户把窗口从 Retina 笔记本屏拖到外接 1x 显示器时:

import { useRef, useEffect } from 'react';
import { useDevicePixelRatio } from '@reactuses/core';

function CrispCanvas({ width, height, draw }: {
  width: number;
  height: number;
  draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { pixelRatio } = useDevicePixelRatio();

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.width = width * pixelRatio;
    canvas.height = height * pixelRatio;
    const ctx = canvas.getContext('2d')!;
    ctx.scale(pixelRatio, pixelRatio);
    draw(ctx, width, height);
  }, [width, height, pixelRatio, draw]);

  return (
    <canvas
      ref={canvasRef}
      style={{ width, height }}
    />
  );
}

三行命令式 setup,但这三行恰好是几乎所有 React canvas 教程都写错的三行:把缓冲尺寸设为 css × dpr,再用内联 style 把 CSS 尺寸设回原始值,最后缩放上下文。这个 hook 让第三个依赖——像素比——变成响应式,所以把窗口从一个显示器拖到另一个会触发以新密度重绘。

内部用的是 matchMedia,针对当前像素比的 (resolution: <ratio>dppx) query。比率变化时 matchMedia 监听器触发,hook 重渲染,你的 effect 拿到新值再跑一次。监听器在挂载时加一次、卸载时移除——和本文所有 hook 一样的生命周期。

同样的模式适用于一切要画像素的东西:图像 canvas、WebGL 上下文、视频帧抽取。对 <img>srcset 选择也有意义,但浏览器会自动处理;只有你自己在做渲染时才需要这个 hook。SSR 返回 1,让服务端的布局计算保持合理,hydration 后第一次绘制时再更新到真实值。

5. useUpdate——一次无 state 的重渲染

本文最怪也是你最少用到的 hook。useUpdate 返回一个引用稳定的函数,调用时强制组件重渲染:

import { useRef } from 'react';
import { useUpdate, useRafFn } from '@reactuses/core';

function StopwatchDisplay() {
  const startRef = useRef(performance.now());
  const update = useUpdate();

  useRafFn(() => {
    update();
  });

  const elapsed = ((performance.now() - startRef.current) / 1000).toFixed(2);
  return <div>{elapsed}s</div>;
}

这个秒表每帧更新一次,并不把已用时间放到 React state 里。真相来源是 performance.now(),每次渲染重新读;useUpdate 的存在只是为了调度渲染。六行,没有 setState,没有对过期时间的闭包。你也可以用 useState((s) => s + 1) 做同样的事,但用 useUpdate 意图更清楚——"再渲一次这玩意",而不是"为了让它再渲一次而递增一个计数器"。

更实用的用法是和那些 React 不追踪其变化的命令式 API 互通。一个通过引用暴露当前相机位置的 WebGL 渲染器;一个 Three.js 场景图;一个你拿来当 state 用、但不想每次改都重建的 SetMap。改完之后调一下 update() 告诉 React 这个组件脏了:

function FavoritesList({ favorites }: { favorites: Set<string> }) {
  const update = useUpdate();

  return (
    <ul>
      {[...favorites].map((id) => (
        <li key={id}>
          {id}{' '}
          <button onClick={() => {
            favorites.delete(id);
            update();
          }}>
            remove
          </button>
        </li>
      ))}
    </ul>
  );
}

直接改 Set 再重渲,对大集合来说比 setFavorites(new Set([...favorites].filter(x => x !== id))) 快,还能让 Set 的引用在多次渲染间保持稳定,下游 memoize 的子组件就不用重算。它当然也是个一脚踏入坑里的好办法——React 的优化假设不可变,凡是靠引用变化检测更新的地方都会默默失灵。要刻意用、用要标注清楚、性能压不出问题就老老实实 useState

useUpdate 也常和 useTextSelection 这类与可变平台对象打交道的 hook 搭档(事件 hooks 那篇覆盖了这种情况)。如果底层对象在多次调用间是同一个引用,setState 是个空操作;useUpdate 就是绕路办法。

凑齐:60fps 弹簧拖尾指针

一次用上五个里的四个。一个用弹簧拖尾跟随真实鼠标的自定义指针,在 Retina 上以正确分辨率绘制,角落显示自己的 FPS,标签页隐藏时暂停:

import { useRef } from 'react';
import {
  useRafFn,
  useRafState,
  useFps,
  useDevicePixelRatio,
  useEventListener,
} from '@reactuses/core';

function SpringCursor() {
  const target = useRef({ x: 0, y: 0 });
  const [pos, setPos] = useRafState({ x: 0, y: 0 });
  const velocity = useRef({ x: 0, y: 0 });
  const fps = useFps();
  const { pixelRatio } = useDevicePixelRatio();

  useEventListener('mousemove', (e: MouseEvent) => {
    target.current = { x: e.clientX, y: e.clientY };
  });

  useRafFn(() => {
    const dx = target.current.x - pos.x;
    const dy = target.current.y - pos.y;
    const stiffness = 0.15;
    const damping = 0.7;
    velocity.current.x = velocity.current.x * damping + dx * stiffness;
    velocity.current.y = velocity.current.y * damping + dy * stiffness;
    setPos({
      x: pos.x + velocity.current.x,
      y: pos.y + velocity.current.y,
    });
  });

  useEventListener('visibilitychange', () => {
    if (document.hidden) velocity.current = { x: 0, y: 0 };
  });

  const size = 24;
  return (
    <>
      <div
        style={{
          position: 'fixed',
          left: pos.x,
          top: pos.y,
          width: size,
          height: size,
          marginLeft: -size / 2,
          marginTop: -size / 2,
          borderRadius: '50%',
          background: 'currentColor',
          pointerEvents: 'none',
          imageRendering: pixelRatio >= 2 ? 'auto' : 'pixelated',
        }}
      />
      <div style={{ position: 'fixed', top: 8, left: 8, fontFamily: 'monospace' }}>
        {fps} fps @ {pixelRatio}x
      </div>
    </>
  );
}

四个 hook 各干各的。useEventListener 以原生速率把鼠标坐标读到 ref——不触发 React 渲染。useRafFn 每帧跑一次弹簧积分,读最新目标位置、写当前弹簧位置。useRafState 把每帧的位置更新合并成一次渲染。useFps 反馈当前帧率。useDevicePixelRatio 影响 image-rendering 的选择(小细节,但正好是那种没人注意到、直到 1x 显示器上的用户来投诉的细节)。

朴素版本要么在每个 mousemove 上 setState(500Hz 渲染,烧电池),要么靠 setInterval(handler, 16)(漂移,并且在后台标签里继续跑),要么干脆不要弹簧、看上去很廉价。用这些 hook 之后,读取频率就是问题本身的频率——每帧一次,React 树永远不会以快于用户能看到的速度重渲染。

何时用哪个

你想
每个动画帧跑一个回调 useRafFn
每次绘制最多更新一次 state useRafState
测当前帧率 useFps
以显示器原生分辨率绘制 useDevicePixelRatio
改了 React 看不到的东西之后重新渲染 useUpdate

两条非规则。useRafFn 不是 setInterval 的替代——它按显示器刷新率跑,ProMotion 屏上是 120Hz,省电模式标签里是 30Hz。如果你要严格的"每秒 N 次"节拍,用 useInterval 然后接受视觉代价。还有 useUpdate 是逃生舱——一份代码库里反复用它超过一两次,背后的真问题往往是"我为了性能把 state 放到了 React 之外",正确的修法是修那个性能问题,而不是把逃生舱当常规。

安装

npm install @reactuses/core
# 或
pnpm add @reactuses/core
# 或
yarn add @reactuses/core

五个 hook 都是单独 tree-shake——引 useRafState 不会把 useDevicePixelRatio 拖进来。每个都带 TypeScript 类型,在客户端渲染应用和 SSR 框架(Next.js、Remix、Astro)里都能用;基于循环的 hook 在服务端是 no-op,useDevicePixelRatiouseFps 在 hydration 之前返回安全默认值(分别是 10)。

相关 hook

如果你想要的渲染循环 hook 不在这份名单里,三篇邻居博客可以一起看。ref 逃生舱 那篇讲 useLatest——它就是 useRafFn 内部用来让回调看到新鲜闭包又不重启循环的那个 trick——如果你想理解这些 hook 怎么实现而不只是怎么用,从这一篇开始。事件 hooksuseEventListeneruseThrottleFn,它们和 useRafFn 在输入驱动的动画上配合得很自然。滚动效果 那篇讲的是在这些原语之上更高一层的滚动联动动画 hook。

reactuse.com 浏览完整列表,或者直接打开上面任意一个 hook 读源码——它们大多不到 40 行,五个 hook 底下的循环原语都是同一个八行的 useRef + useEffect 模式,你大概率已经自己写过半打了。

深度解析 JS 中的 this 指向:从底层逻辑到实战规则

作者 甜味弥漫
2026年5月27日 14:34

前言

在 JavaScript 的面试和日常开发中,this 绝对是一个绕不开的“大山”。很多初学者会被它忽左忽右的指向搞得晕头转向。今天我结合自己的学习笔记,把 this 的来龙去脉和绑定规则彻底理清楚。希望对同样在进阶路上的你有所帮助!

一、 为什么我们需要 this?

很多同学会问:既然我可以直接引用对象名,为什么还要用 this? 核心价值:隐式传递对象引用。 this 提供了一种更优雅的方式来传递引用,使得代码更简洁、易于复用。

function identify() {
    return this.name.toUpperCase();
}

var me = { name: "Kyle" };
var you = { name: "Reader" };

identify.call(me);  // KYLE
identify.call(you); // READER

如果不使用 this,你就需要显式地将对象作为参数传递,代码会变得冗余且难以维护。

二、 this 到底出现在哪?

在 JavaScript 中,this 主要出现在两个地方:

  1. 全局环境:在浏览器环境下,this 直接指向 window 对象。
  2. 函数体内:这是最复杂的地方,this 的指向不是在函数创建时决定的,而是在函数被调用时决定的

三、 五大绑定规则

掌握了下面这五条规则,你就掌握了 this 的“密码”:

1. 默认绑定

当函数被独立调用(不带任何修饰的函数调用)时,函数中的 this 指向全局对象 window。

function foo() {
    console.log(this); 
}
foo(); // window

2. 隐式绑定

当函数被一个上下文对象所拥有,并被该对象调用时,this 指向该对象。

var obj = {
    a: 2,
    foo: function() { console.log(this.a); }
};
obj.foo(); // 2

3. 隐式丢失(就近原则)

这是一个细节:当函数被多层对象嵌套调用时,this 指向离它最近的那个对象。

var obj2 = {
    a: 42,
    foo: function() { console.log(this.a); }
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo(); // 42 (指向 obj2)

4. 显式绑定 (Explicit Binding)

显式绑定就像是给函数下达“死命令”,强制它在执行时将 this 指向我们指定的对象。

① call —— 逐个传参的“指挥官”

call 会立即执行函数。它的第一个参数是 this 的指向,后面的参数需要一个一个列出来。

function greet(skill, hobby) {
    console.log(`我是${this.name},我会${skill},喜欢${hobby}`);
}

const user = { name: "阿强" };

// 语法:fn.call(thisArg, arg1, arg2, ...)
greet.call(user, "JavaScript", "代码"); 
// 输出:我是阿强,我会JavaScript,喜欢代码

② apply —— 数组传参的“打包员”

apply 的功能和 call 完全一样,唯一的区别是:它接收参数的方式是数组。这在处理动态参数(如获取数组最大值)时非常有用。

const user = { name: "阿珍" };

// 语法:fn.apply(thisArg, [argsArray])
greet.apply(user, ["Python", "看书"]);
// 输出:我是阿珍,我会Python,喜欢看书

③ bind —— 延后执行的“契约书”

bind 不会立即执行函数,而是返回一个绑定了新 this 的新函数。你可以随时在需要的时候调用它。

const user = { name: "老王" };

// 语法:const newFn = fn.bind(thisArg, arg1, ...)
const bindGreet = greet.bind(user, "Vue", "钓鱼");

// 此时不会有输出,直到你手动调用它
bindGreet(); 
// 输出:我是老王,我会Vue,喜欢钓鱼

💡 快速对比表

为了方便记忆,我总结了一个对比表,大家可以直接保存:

方法 立即执行 传参方式 常用场景
call 参数列表 (arg1, arg2) 对象的属性继承、借用构造函数
apply 数组形式 ([args]) 与 arguments 配合、操作数组
bind 参数列表 (arg1, arg2) React/Vue 中的回调函数绑定、延迟执行

面试小贴士: 如果 call/apply/bind 的第一个参数传入了 null 或 undefined,那么在非严格模式下,this 会自动指向全局对象 window。

5. new 绑定

使用 new 关键字调用构造函数时,JS 内部会创建一个新对象,并把构造函数里的 this 绑定到这个新对象上。

function Person(name) {
    this.name = name;
}
var me = new Person("Jay");
console.log(me.name); // Jay

四、 特殊存在的箭头函数

箭头函数没有自己的 this! 这是它和普通函数最大的区别。箭头函数的 this 是在定义时捕获自外层(父级)非箭头函数的作用域。

注意: 箭头函数的 this 一旦确定,就无法通过 call/apply/bind 再次修改。

总结

  • 独立调用看 window。
  • 对象调用看对象。
  • 多层对象看最近。
  • call/apply/bind 看第一个参数。
  • new 看实例。
  • 箭头函数看它亲爹(外层作用域)。

JavaScript 中的 this 关键字

作者 riuphan
2026年5月27日 00:15

一、为什么需要 this?

  • this 是 js 中的一个关键字,它提供了一种更优雅的方式隐式地传递一个对象的引用,可以让代码更简洁

  • 如果没有 this,我们在编写面向对象的代码时,每一次都需要显式地将对象作为参数传入函数,这样不仅增加了代码的复杂度,也让复用和维护变得困难。

下面通过一个例子来直观感受 this 带来的便利。首先看没有使用 this 的写法:

function identify(context) {
    return context.name.toUpperCase();//将上下文中的name转换为大写
}

function speak(context) {
    var greeting = 'hello, I am ' + identify(context);
    console.log(greeting);
}

var me = { name: 'tom' };
speak(me); // 输出: hello, I am TOM

可以看到,每次调用函数都需要显式传递 context 对象。而使用 this 之后:

function identify() {
    return this.name.toUpperCase();
}

function speak() {
    var greeting = 'hello, I am ' + identify.call(this);
    console.log(greeting);
}

var me = { name: 'tom' };
speak.call(me); // 输出: hello, I am TOM

两段代码的输出结果完全相同,但第二种写法更加简洁,函数不再需要额外的参数context来接收对象,代码的复用性和可读性也更高。

二、this 可以出现在哪里?

this 的值取决于它出现的上下文:

  1. 全局作用域下this 指向 window 对象。例如在全局直接打印 this,得到的就是整个浏览器窗口对象。(Node.js下运行不同)

image.png

  1. 函数体内this 的指向取决于函数的调用方式,这是 this 最为复杂也最为重要的部分。

三、this 的绑定规则

1. 默认绑定

当一个函数被独立调用时(即直接调用函数,不依附于任何对象),函数内部的 this 指向 window 对象。这种情况称为默认绑定

var a = 1;
function foo(){
    console.log(this.a);
}
function bar(){
    var a = 2;
    foo(); // 独立调用,this 指向 window
}
bar();

当全局var a = 1时,相当于window.a = 1, 且this指向window,所以打印的是全局的a = 1

2. 隐式绑定

当一个函数被某个上下文对象所拥有,并通过该对象调用时,函数中的 this 会指向这个对象。这就是隐式绑定的核心含义。

function foo(){
    console.log(this);
}
var obj = {
    a: 1,
    foo: foo
};
obj.foo(); // this 指向 obj,打印 {a: 1, foo: f}

3. 隐式丢失

如果一个函数被多层对象引用并调用,this 只会指向距离函数最近的那个对象,这就是隐式丢失现象。

function foo(){
    console.log(this);
}
var obj = {
    a: 1,
    foo: foo
};
var oo = {
    a: 2,
    foo: obj
};
oo.foo.foo(); // this 指向 obj,而不是 oo

4. 显式绑定

有时候我们需要手动指定函数中 this 的指向,JavaScript 提供了三种方法来实现这一点:

  • call:立即调用函数,并指定 this 的值,可以逐个传递参数。
  • apply:与 call 类似,但参数必须以数组形式传递。
  • bind:不立即调用函数,而是返回一个新的函数,且参数可以分开传递
function foo(x, y){
    console.log(this.a, x + y);
}
var l = { a: 1 };

foo.call(l, 1, 2);          // this 指向 l,1和2是传递给函数的参数,打印 1 3
foo.apply(l, [1, 2]);       // this 指向 l,打印 1 3
const bar = foo.bind(l, 1); // 返回新函数,两个参数可以分开传递
bar(2);                      // this 指向 l,打印 1 3

5. new 绑定

使用 new 关键字调用构造函数时,函数内部的 this 会指向由 new 创建的实例对象。这是 JavaScript 实现面向对象编程的核心机制之一。

function Person(){
    this.name = '张三';
    this.age = 18;
}
const p = new Person(); // p 是 Person 构造函数的实例

new 的执行过程可以分解为以下步骤:

  • 创建一个空对象,var obj = {}
  • 将构造函数的 this 指向这个空对象,Person.call(obj)
  • 将空对象的原型指向构造函数的原型,obj.__proto__ = Person.prototype
  • 返回这个对象,return obj

四、箭头函数与 this

箭头函数是 ES6 引入的新语法,它与普通函数有一个关键区别:箭头函数没有 this 的概念。如果在箭头函数中使用 this,相当于是在外层第一个非箭头函数中的 this

function foo(){
    var fn = () => {
        this.a = 2; // this 相当于外层 foo 的 this
    }
    fn();
}
var obj = {
    a: 1,
    bar: foo
};
obj.bar();
console.log(obj.a); // 打印 2

解析:用obj调用,为隐式绑定,故foo的this指向obj,又因为箭头函数的 this 相当于外层 foo 的 this,所以this.a即obj.a,obj中a的值改为2,故打印2。

这个特性使得箭头函数在需要保持 this 上下文时非常有用,比如在回调函数中。同时也要注意,箭头函数不能用 new 关键字调用,因为它没有自己的 this 绑定机制。

五分钟带你深入了解 this

作者 掰头战士
2026年5月27日 00:02

五分钟带你深入了解 this

为什么要有 this

  • this是 js 中的一个关键字,它能做到隐式地传递一个对象的引用,可以让代码更高效、更简洁,易于复用。

this 用在哪

  • 有域的地方就可以用
  1. 全局 this === window

thisNode中指向{};在网页中输出,则指向window

我们在网页端输出this:

console.log(this)

image.png

  1. 函数体内
function foo(){
   var a = 0
   console.log(this.a)
}

块级作用域内this无意义,因为this的绑定只发生在函数调用和全局作用域中

this 用在不同的地方,代指的内容是不一样的

this的绑定规则

默认绑定

  • 当函数独立调用时,函数中的 this指向window对象,如果console.log(this)会输出undefined

什么是独立调用

function foo(){
    console.log(this)
}
foo()   

这里会输出 undefined,像这样声明一个函数,然后没用什么前缀来调用,就是独立调用

隐式绑定规则

  • 当一个函数被一个上下文对象所拥有,并被该对象调用,函数中的this指向该对象
function foo(){
    console.log(this)
}
var obj = {
    a: 1,
    foo: foo      
}
obj.foo()  

调用点有.[],就是非独立调用

隐式丢失(隐式绑定)

  • 当一个函数被赋值给变量独立调用时,原本的隐式绑定会丢失,退化为默认绑定(指向 windowundefined)
function foo(){
    console.log(this.a)
}
var obj = {
    a: 1,
    foo: foo
}
var oo = {
    a: 2,
    foo: obj      
}
oo.foo.foo()     

oo.foo 指向 obj,不是foo

显式绑定

显式绑定有三种类型

  1. fn.call(obj) 可以把函数的this强行绑定到obj中去,并执行

call的源代码会触发fn()

function foo(x, y){
    console.log(this.a, x + y)   
}

var liu = { a: 1 }
foo.call(liu, 1, 2)             

会直接输出 1 3

  1. fn.apply(obj,[x,y])
var jie = { a: 2 }
foo.apply(jie, [2, 3])           

输出 2 5

call大部分一样,但apply接受参数方式不一样,要用数组传递

  1. var bar = fn.bind(obj,x,y) bar()
var fufu = { a: 3 }
const bar = foo.bind(fufu, 1, 4)  
bar()                                  

输出 3 5

也可以分步传参

const bar2 = foo.bind(fufu, 1)
bar2(4)                             

如果我多写一个参数

const bar2 = foo.bind(fufu, 1,4)
bar2(5)            

输出结果不变,因为会优先找 bind() 里的参数

bind执行后一定返回一个新参数,不会立刻执行

  1. new 绑定
  • new 的原理会导致函数的 this指向实例对象obj
function Person(){
    this.name = 'jie'
}
const p = new Person()

让我们复习一下new的工作原理:

  1. 创建一个空对象 {}
  2. this指向这个空对象
  3. 执行构造函数中的代码
  4. 对象.__proro__==Person.prototype

你会发现,这和# 万物皆对象?带你梳理JS原型及其查找链机制讲的new的工作原理不太一样?这才是更细节的版本。

new 的原理会导致函数的 this 指向实例对象。

箭头函数

  • 箭头函数没有自己的this
  • 写在箭头函数内的 this是其外部非箭头函数的this
  • 箭头函数不能作为构造函数来使用,new的执行步骤中用到了把this指向其prototype

例如:

function foo(){
    var fn = ()=>{
        this.a = 2

    }
    fn()
}
var obj ={
    a: 1,
    bar: foo
}
obj.bar()
console.log(obj);

输出{ a: 2, bar: [Function: foo] }

总结

1.看到this时候做两个判断:这个this是谁的,这个this代指的是谁

2.一图让你明白:

调用方式 绑定规则 this指向
foo() 默认绑定 window/ undefined
obj.foo() 隐式绑定 obj
foo.call(obj) 显式绑定 obj
new Person() new绑定 实例对象
()=>{} 箭头函数 外层函数的this

讯飞首款 AI 眼镜,用 40 克撬动 AI 工作流

作者 李超凡
2026年5月28日 17:27

2026 还没过半,已经有 30 多款 AI 眼镜亮相了。

除了华为、阿里千问、Rokid、雷鸟、小米这些老玩家,连老板电器都推出 AI 烹饪眼镜,京东方也做了骑行眼镜……百镜齐放,但大体上在围着三件事卷:谁能做得更轻、谁的摄像头更清晰、谁的镜片上能塞进更大更好的显示屏。

热闹之下,有一个数据通常会被忽略。

目前主流电商平台上,AI 眼镜的退货率高得惊人,普遍在 30% 左右,在冲动消费占大头的直播渠道,退货率甚至能飙到 40% 到 50%

用户因为新鲜感下单,戴了几天,默默点了退货。销量高开低走、退货率居高不下,就是众多 AI 眼镜的「生命周期」。

说白了,尝鲜期过去后,消费者就会开始产生这样的疑惑:戴上之后它到底能帮我干什么。

就在今天,科大讯飞在澳门发布了旗下首款 AI 眼镜。这副 40 克的眼镜没有卷像素、卷全彩大屏、卷时尚联名,把核心能力押注在一个看起来十分常见的能力上——翻译

但翻译只是它的入口,不是终点。

发布会前夕,APPSO 和科大讯飞副总裁王玮、穿戴设备业务部总经理林会杰聊了聊。聊到后半段,话题从具体的产品技术转移到了一个更大的命题上:AI 眼镜下半场的关键,到底在硬件层面还是在 AI 工作流?

AI 眼镜的「奇点」,是让人愿意一直戴

百镜大战之后,在现有供应链里攒出一款智能眼镜真不难,华强北两周就能给你出一个样机。但难的是,你怎么给用户一个「一直佩戴」的理由。

对于第一次做 AI 眼镜的讯飞来说,这也是个绕不开的坎。

王玮跟 APPSO 聊起讯飞做 AI 眼镜的起点,其实源于一个非常具体的画面:你想啊,翻译机在展会、小型商务洽谈这些场景里是很好用的,甚至公司共用一台就够。但总有一些时刻,当你在国外旅游或者某些场合,你不方便掏出设备和低头看屏幕,不想等翻译结果打断说话的自然节奏。

你希望交流是「沉浸式」的,眼神始终对着彼此,对话顺畅流动,翻译像空气一样感受不到存在。这副眼镜的起点,就是用户对那种「无感」体验的期待。

这种「物理中断」,是讯飞看了无数个翻译机用户的真实反馈后,攒下来的痛点。做 AI 眼镜的公司可以一夜之间冒出来,但做翻译的底子,真的没法速成。

讯飞翻译机卖了 100 万台、翻译了 10 亿次。讯飞同传跑了 42 万场国际会议,覆盖 50 多个国家,触达 4 亿观众,连续 8 年服务全国两会。

这些数字沉淀下来的不只是算法,还有对真实场景里那些琐碎问题的感知:什么时候用户会嫌翻译慢,什么场景下手持设备让人尴尬,什么噪音条件会让准确率断崖……

去年 10 月讯飞已经推出了一款翻译耳机。耳机验证了两件事:用户确实需要释放双手的穿戴式翻译;端到端的同传在穿戴设备上是跑得通的,反应速度能掐在 2 秒以内。

但耳机只管耳朵,在林会杰看来,耳机的局限在于它是一个「听觉」设备(现在也开始加摄像头了),眼镜则可以增加视觉的模态,多种模态叠加在一起,跨语言沟通的信息输入就丰富多了。

说白了,眼镜上有摄像头可以拍照翻译,有显示可以投射字幕让你不用低头看手机,还能放更多的麦克风做定向降噪。

用王玮的话说就是,「眼镜离人的眼睛、耳朵、嘴巴最近,它是物理世界与数字世界天然的桥梁,让翻译像呼吸一样自然发生。」

而到了 2026 年,供应链成本开始被拉下来了,国补也首次把智能眼镜纳了进来,再加上星火 X2 大模型云端翻译能力的提升,天时地利凑齐了。

林会杰倒挺坦率:「我们选择这个节点,是因为看到了增速才刚刚开始。」王玮更直接:我们不想用「iPhone 时刻」这个词,但实际上就是这个意思,眼镜马上到了奇点临近的时候。

40 克,一道系统工程题

讯飞这款 AI 眼镜,我戴上之后第一反应是比想象中轻。它集成了微型显示屏、摄像头、5+1 麦克风矩阵、喇叭,但整机重量被死死卡在了 40 克。

这个数字可能很多人没概念,我们来横向对比一下:

  • Meta Ray-Ban 是 49 克,但它没有显示屏;
  • Rokid Glasses 也是 49 克,带显示,但比讯飞重了将近 25%;
  • 华为 AI 眼镜确实轻,35.5 克,但它没有显示屏。

在「带显示屏」的智能眼镜阵营里,讯飞目前几乎做到了行业最轻。

为什么非得是 40 克?林会杰说,这个数字是他们用模拟仿真和海量调研死磕出来的。欧美人的头型和体型对重量的钝感力比较强,Meta 做到 50 多克他们依然觉得能接受。但亚洲人的颅骨结构和鼻梁高度不同,对重量极度敏感。

对于中国用户来说,45 克是一道分水岭,超过这个分量,戴久了就会有明显的压迫感。40 克,是长时间佩戴的「舒适阈值」。

为了抠掉这几克,团队在工程上跟供应链磨了很久。最关键的一招,是用树脂镜片替代了传统的玻璃镜片。

传统近视眼镜早就是树脂的天下了,但为什么智能眼镜一直不用?因为工艺太搞心态了。智能眼镜的镜片需要做「全贴合」,把显示层和镜片压在一起。树脂材料在成型和加热时极易产生微小的气泡,胶水一旦有一丝一毫的空隙,光线的折射曲率就偏了,整个镜片就废了,良率控制比玻璃难得多。

林会杰透露,讯飞应该是行业里第一个在带显示的智能眼镜上把全贴合树脂工艺跑通的。研发过程中经历了非常多尝试和失败,才最终把树脂材料用在了显示镜片上,但回报是巨大的,单靠镜片这一项,就比玻璃方案轻了 30% 到 40%。

再加上定制的 0.15CC 微型光机、微型摄像头模组,镜框镜腿一体成型。芯片选型和算法做了深度耦合:同样的功能别家可能要 100mAh 电池,讯飞可能 50mAh 就够了。

所以最后我们看到的讯飞 AI 眼镜,整机重量更轻,续航却没打折。

这是一道系统工程题,没有捷径,每一环都要跟供应链反复磨合良品率。树脂镜片、微型光机、低功耗芯片、算法-硬件耦合,哪一环掉链子,重量都得回到 50 克以上。

唇动识别降噪,用眼睛帮耳朵听

翻译固然是讯飞的舒适区,但这副眼镜上,讯飞还首发了一个有点科幻的技术——唇动识别降噪,这是多模态降噪系统的核心部分

这是唇动识别降噪首次搭载到 AI 眼镜上,实现逻辑是眼镜的前置摄像头会死死锁定对面说话人的嘴唇。同时,眼镜上的 5 颗气导麦克风和 1 颗骨传导麦克风组成了一个六通道的音频流。

系统实时通过「看到谁的嘴在动」,来辅助判断「该听谁的声音」, 从而在嘈杂的多人混声中,精准地把目标人物的语音「抠」出来。

这就实现了「看谁翻谁」的效果,你的眼镜盯着谁,耳边响起的、镜片上跳出来的,就是谁的翻译字幕。

这个技术直接决定很多场景的翻译质量,因为翻译准不准,有一个重要的前提听得清不清。

安静的会议室里,其实目前的翻译软硬件都可以较好处理交流问题。但讯飞 AI 眼镜重要的一个用户群是商务人士,他们真正需要用到的翻译场景是什么?是展会、商务酒会、机场,环境噪音随便都能 80 到 90 分贝。传统翻译工具在这种环境下,准确率直接掉进马里亚纳海沟。

高噪场景下,唇动识别降噪让识别准确率提升了 50% 以上。林会杰解释说,这并不是单纯看口型,它融合了声源位置增强、目标人锁定等一整套多模态降噪系统,各路信号在实际使用中自动协同。

这个能力也不是拍脑袋想出来的。讯飞在大型会议系统和汽车智能座舱里,搞这种多通道语音分离和多模态降噪已经很多年了,在 CHiME 国际语音分离大赛上拿过 6 连冠。

王玮还给我们分享了一个几年前的内部 Demo:几个研究员同时讲话,人耳完全分不清,系统把每个人的声音干净利落分离出来,谁说了什么都清清楚楚。

这事儿有趣的地方在于,以前开大会,你有足够的物理空间塞麦克风,有服务器的算力,有插座供电。现在,你要把这套复杂的视觉-音频融合算法,塞进一副 40 克、算力和功耗被极度压榨的眼镜里。

王玮觉得,这恰恰是讯飞在硬件上秀出的「肌肉」:怎么把大设备上的硬核算法做高倍率的压缩,移植到小尺寸、轻量化的移动端侧,而且还能离线实时处理多路语音数据

讯飞做硬件的路径是「大设备验证、小设备迁移」。在会议系统和汽车上跑通的算法压缩到眼镜端侧。唇动识别降噪需要视觉-音频配对数据、端侧实时处理能力、多麦克风硬件的联合调优,单靠现成算法集成难以实现。

降噪的准确性直接决定翻译的准确性,这也是整条工作流的第一道关卡。

「全能翻译」背后的基础设施

听得清之后,才是译得准的问题。

讯飞 AI 眼镜支持 122 种语言的实时互译,划分了同声传译(听演讲)、面对面翻译(商务洽谈)、通话翻译(跨国电话)和线上同传(接腾讯会议或 Zoom)等四种模式,摄像头还能直接拍 PPT, 做外文资料翻译。

在现场体验中最让我觉得有意思的是通话翻译

这大概是目前市面上唯一一款能在你打电话时,同时帮你做跨国翻译和记录的眼镜。它的路径是这样的:眼镜通过蓝牙挂载在手机上,捕捉到电话那头的英语,端到端同传模型全自动翻译,再把你的中文回答翻译成英文顶回去,延迟在秒级。

也就是说,你在电话这头说中文,老外在那头听到的是你的音色克隆出来的英文。电话一挂,眼镜甚至能帮你把一份结构化的会议纪要发你。

过去,传统的翻译系统是「老三样」:语音识别(ASR)→文本翻译(MT)→语音合成(TTS)。这套方案最大的毛病就是延迟大,而且每过一个环节,信息的「语义损失」就多一层。

讯飞这次在眼镜上搭载端到端的语音同传大模型,跳过了中间的文本转译步骤,直接实现「语音进、语音出」,把首字响应时间压进了 2 秒。云端撑腰的是星火 X2 模型(293B 参数的 MoE 架构,基于华为昇腾训练)。

林会杰说,他们把翻译场景切分得极其细微,因为不同场景下需要的行业知识库和降噪模式是完全不一样的。

讯飞这款眼镜在翻译功能上花的功夫,这有点像手机行业卷影像,拍照功能谁都有,但我有 2 亿像素,有10 倍长焦,有4K live 图,甚至能覆盖专业摄影场景。

翻译之后,AI 工作流才开始

到了这一步,你会发现,讯飞想做的已经不只是「翻译工具」了。这大概也是为什么它不叫「翻译眼镜」,而被视「眼前的超级 AI 助理」。

林会杰认为,「眼镜更像是一个戴在眼前的超级计算机,带有显示、摄像头、语音能力,它的配置跟手机、PC 基本一样。

承载这层能力的是讯飞的 GlassClaw,这个 Agent 能调用大模型能力、接入生态服务、做多模态理解,把从听懂到干活的整个过程打通,同时也支持 OpenClaw 等第三方 Agent 接入。

你没看错,这还是一副「龙虾」眼镜。

林会杰分享了他自己使用 GlassClaw 的日常工作流:他出门不用频繁掏手机。在路上走着,可以直接用语音唤醒 GlassClaw,让它调取手机通讯录、找客户拨号,电话接通自动开翻译。

跟客户面对面聊天时,突然需要查阅之前的某份合同纪要,直接盲操吩咐眼镜,眼镜会去检索他的电脑资料,提取出要点并同步到镜片上。

甚至开完会后,眼镜自动做完多模态的角色区分(谁说了什么),输出结构化纪要,他直接语音:「把纪要以邮件形式发给项目组,并把下周三的复盘会同步到日历上。」

如果你也养过虾对这些功能肯定不陌生,只不过这次交互发生在你的脸上。

当初让AI 眼镜出圈的提词器功能,在这款眼镜里也迎来升级。

讯飞 AI 眼镜的智能提词器功能做到了语义跟随,说到哪跟到哪,不再是机械按速度滚动,可以做自然的智能语义理解和跟随。配套的充电胶囊可以当遥控器,按键切换和暂停文稿。

这就是 AstronClaw 架构在底层玩的「端-边-云」三级协同:眼镜端侧负责环境感知和预处理,边缘侧做决策,复杂的推理丢给云端的星火 X2。GlassClaw 基于讯飞自研的 Agent 能力,同时也支持 OpenClaw 等第三方 Agent 接入。

王玮的判断是,未来的眼镜不再只是很简单的一副眼镜,而是你穿戴最方便的一个随身助理

市场上单做翻译或单做 AI 助手的产品不少,但把「翻译 + 记录 + 纪要 + 跨端执行」串成顺滑的工作流,需要语音、翻译、大模型、智能体(Agent)四种底层能力同时在线,且环环相扣

讯飞这种全栈的技术能力,恰好在眼镜这个载体上找到了合适的闭环。

AI 眼镜的下半场,拼的是什么

过去两年,AI 浪潮裹挟着整个硬件行业寻找那个所谓的「Next Gen」入口。

AI Pin 翻车了,各种智能吊坠无疾而终,虽然 AI 硬件的产品形态和技术路线各异,但行业也逐渐形成一些共识: AI 需要眼睛,它必须能实时感知人类所处的三维物理世界。

AI 眼镜未必不是最终形态,但它是目前唯一能够全天候、第一视角承载视觉与听觉输入的形态。

王玮在采访里提到一个挺有意思的预判:「未来的数字生活三件套,大概率是电脑、手机和眼镜。眼镜不是手机的配件,它自己就是一台架在鼻梁上的独立主机。」

眼镜天然适合做连接物理世界和数字世界的设备。而且硬件本身还有很长的迭代空间:显示会从单色走向全彩、从 2K 走向 4K;摄像头和麦克风还会向 AI 原生的 token 编码方式升级。王玮说这些技术路径已经开始有比较明晰的发展方向了。

林会杰透露,讯飞的第二代 AI 眼镜已经在规划中,最快 2026 年秋季能看到,面向更多不同人群,也在摸索一些细分的垂直场景。

过去一年 APPSO 测过、写过不少 AI 眼镜。回头看百镜大战,行业其实已经分化出了两条不同的路:

一条是「做最好的眼镜,让 AI 成为加分项」。 Meta Ray-Ban 是这个逻辑:用时尚设计和品牌文化来对冲用户对 AI 能力的低频刚需。

另一条是「做更深的 AI 工作流,让眼镜成为新的电脑」。 讯飞选择的就是这条路。两条路指向不同的竞争维度,但后一条更难走,因为它要求你同时具备硬件工程能力和 AI 全栈能力,缺一不可。

AI 眼镜的下半场,真正的分水岭在于,谁能把 AI 揉进高度细分的真实场景里,替用户把一件件琐碎任务给办了。

让眼镜回归眼镜, AI 老老实实当「牛马」。

最后能留下来的设备,我想大概是这样的:当你摘下它的时候,会突然觉得眼前的世界变得沉重而低效。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

国产AI编程冲上全球第二!实测五大模型,谁才是Vibe Coding神器

作者 张子豪
2026年5月28日 12:02

超越 GPT-5.5、Gemini 3.5 Flash、DeepSeek V4 Pro,阿里的最新旗舰模型 Qwen3.7 Max 在编程竞技榜拿下第二名,仅次于 Claude Opus 4.7。

▲5.26 榜单截图

除了真实场景的用户选择,在传统的大模型固定评测榜单上,像是终端能力 Terminal Bench、编程能力 SWE Bench 等,Qwen3.7 Max 的表现也是拿下了国产模型的冠军。

虽然现在大模型四年,我们已经对这些排行榜的刷新屡见不鲜,但还是忍不住想要体验一下,能够超越 GPT 5.5 的 Qwen 模型,实际能力到底如何。

要知道,现在最火的 Coding Agent 组合,大概就是搭配了 GPT 5.5 的 Codex。

如果我们把 Codex 里面的默认模型修改成 Qwen3.7 Max,再用 Codex 来完成一些日常的任务,会不会比 GPT 5.5 还好用呢。

获取 Qwen3.7 Max

趁着现在各家都在推出一些 Token 优惠活动,阿里云也提供了 100 万 Token 的免费使用,可在阿里云百炼平台使用。

Qwen3.7 Max 的定价,在阿里云官网,目前是限时五折,输入 6 元/每百万 tokens,输出 18 元/每百万 tokens。新用户还可以 5 折充值节省计划,以 10 元每月的价格获得 20 元的 Token 额度,而 Token Plan 标准档目前是 198 元/月。

总体来说,根据大模型聚合平台 OpenRouter 显示的数据,Qwen3.7 Max 的价格属于中规中矩的一档,对比 DeepSeek 的骨折价肯定比不上,但和 Opus 4.7、GPT 5.5 相比还是优惠不少。

我们直接充值了「入门首选」这档全模型通用抵扣 20 元。但这里需要注意的是,五折优惠仅支持一个套餐,即购买了 10 元的,就不能再购买 50、250 的半价优惠计划了。

DeepSeek、Claude、GPT、Gemini、Qwen 一起来测试

拿到了 API Key 和百万免费使用 Token,我们先是在阿里云百炼平台、以及千问官网,使用 Qwen3.7 Max 做了一些常见的前端网页设计来测试它的开发能力。

像是比较能直观的看到差别的物理模拟测试,我们就用一段简单的提示词「用 HTML+CSS+JS 做一个模拟液体在容器里晃动的动画,拖动容器可以改变倾斜角度。」

▲ Qwen3.7-Max,千问官网生成

Qwen3.7 Max 的表现可以说是顺利完成了这个模拟挑战,同时还增加了颜色的自定义、摇晃、液体量调节等功能。

DeepSeek 就比较简单,但是也没出错。

▲ DeepSeek V4,官网生成

GPT-5.5 生成的液体有点奇怪,虽然做到了会随着角度的切换,流向对应的方向,但是整个波浪很出戏。

▲ GPT-5.5 超高,Codex 生成

Gemini 3.5 Flash 生成网页似乎是有点 Bug,那个瓶子一直会被隐藏到控制面板背后,必须得自己拖出来。但是同样一句提示词,它给的自定义东西是真的多,不仅提供了瓶子的类型,还有液体的颜色,各种设置都能自定义。

▲Gemini 3.5 Flash,官网生成,选择 Canvas 选项

Claude Opus 4.7 这个瓶子过于简陋了,而且模拟的液体晃动效果在剧烈状态下,很像是音波的跳动。

▲ Claude Opus 4.7,使用 Claude Code 应用生成

接着我们尝试让它生成一个小游戏试试,虽然游戏的测试已经是去年 Vibe Coding 的常见测试项目了。但这次我们要 AI 做一个六宫格的 2048 游戏,输入提示词「做一个可以玩的 2048,但格子是六边形的。」

Qwen3.7 Max 生成的页面还是很好看的,能看到它的参考来源 10 条信息里面,大部分都是来自 CSDN 的 2048 游戏生成教程。

最终的游戏也能玩,但还是偶尔有不按常理出牌的时刻,例如同一方向上,相同数字叠加,没有叠加在该有的位置。

▲ Qwen3.7 Max,官网生成

DeepSeek V4 的表现和上一轮差不多,但是明明是六边形,给出的键盘控制却只有 WASD 来滑动。

▲DeepSeek V4,官网生成

这一轮表现最好的大概就是 Claude 的 Opus 4.7,它真的理解了这个游戏应该怎么设置,格子的移动是符合这个蜂巢的规则,不会让人感觉找不着北。

▲ Claude Opus 4.7,使用 Claude Code 应用生成

GPT 5.5 依托 Codex 的能力,在生成了游戏之后还能自己打开浏览器预览是否有问题,抓取控制台的信息来修复项目代码。最后生成的网页也很优秀,不过对于监控鼠标在屏幕上的移动方向,还是没有 Opus 4.7 的表现出色。

▲GPT-5.5 超高,Codex 生成

Gemini 3.5 Flash 则是一如既往地给我加了很多东西。游戏的主题风格它就写了赛博、暗金和马卡三种背景,甚至还加上了「内置高品质合音器」。

游玩过程配有原生 Web Audio 生成的复古 8-bit 太空音效(合并、滑动、过关、死亡),体验感瞬间拉满。

▲Gemini 3.5 Flash,官网生成,选择 Canvas 选项

再回到一些普通网页的设计上,我们要求它做一个地铁博物馆的网站,输入的提示词也只有一句话「设计一个名为地铁博物馆的主题网站,要求沉浸感强。」

本意上我们希望这些大模型可以尽可能多地罗列不同城市的地铁信息,世界地铁的 Logo,以及整个网站的风格应该是艺术性的,有专门的风格和充分的特效来呈现。

先看Qwen3.7 Max,说实话有点难评,把文字竖排放着是很像地铁列车,但是整个网站给人的感觉是很乱。

▲ Qwen3.7-Max,千问官网生成

而 Gemini 继续做了很多,声效再次用上,比较有意思的是,它还做了一个地铁文创,定制纪念票根生成器。我们可以输入名字、选择车站,实时生成一张高颜值、复古风的地铁纪念乘车票。

▲ Gemini 3.5 Flash,官网生成,选择 Canvas 选项

DeepSeek 选择的项目和 Gemini 类似,一样有票务纪念和驾驶体验,但是它在最后交付的成果中,似乎并没有呈现这些功能。

▲ DeepSeek V4,官网生成

GPT 5.5 现在生成的网页风格很不错,虽然也有明显的套用模板,但是整体的设计是在线的,遗憾就是信息量太少了。它似乎没有理解地铁博物馆应该是一个介绍地铁信息的网站。

▲GPT-5.5 超高,使用 Codex 生成

继续用之前的提示词像是让它做一个 macOS/Windows 的操作系统,这次我们输入「用 HTML 构建一个完整的浏览器操作系统。」

DeepSeek V4 的表现很简单,同样简单的是 Qwen3.7 Max,不过这次 Qwen3.7 Max 额外给了一张不错的桌面风景图片。

▲ DeepSeek V4,官网生成

▲ Qwen3.7-Max,千问官网生成

但在这个测试中真正让我觉得表现不错的,还是 Gemini 3.5 Flash 和 GPT 5.5。

▲ Gemini 3.5 Flash,官网生成,选择 Canvas 选项

和 Gemini 3.5 Flash 一样,GPT 5.5 也对整个 OS 进行了详细的设计,有专门的风格。

▲ GPT-5.5 超高,使用 Codex 生成

在 Codex 里使用 Qwen3.7 Max

一轮测试下来,好像 Qwen3.7 Max 在通过对话生成小网页项目的测试表现上,很难说每一次都超越 Gemini、GPT 5.5,但对比前代,我相信是已经有了很大的提升。

我们在千问官网看到有一些给出的代码案例,像是 3D 地球,食物链排序,可视化,个人博客等内容,但是这些网页项目的提示词都比较长,而不是像我们所测试的简单一句话。

▲在输入提示词之后,千问也提供了「优化指令」的选项

我们把 3D 地球这个项目的提示词也扔给了 DeepSeek V4、Gemini 3.5 Flash,得到的效果几乎和 Qwen3.7 Max 是一样的。

这意味着提示词在当前阶段,对能否发挥 Qwen3.7 Max 的能力,还是起着相当重要的作用。

而减少用户优化提示词压力的方式,大概就是接入 Agent 产品,利用他们的 Skills 以及 Agents 协作等能力,来发挥模型的真正实力。

按照阿里云官方的教程,我们把 Qwen3.7 Max 成功接入到了 Codex 终端助手里。

不过这里容易出现 BUG,即 Codex 会不断提醒你「CODEX Missing environment variable」。

按照官方的教程,我们修改完 ~/.codex/config.toml 配置文件之后,还需要修改电脑的环境变量。

即模型的 API KEY 信息是保存在电脑的环境变量(需要查看自己电脑的 Shell 类型,修改对应的环境变量文件,如 .bash_profile 或 .zshrc)中,而不是在 Codex 的 config.toml 配置文件里。

修改完成之后,在终端输入 Codex,我们就能看到 Qwen3.7 Max,重新打开 Codex App,主界面的模型也会从之前的 GPT-5.5 切换为自定义的 Custom。

用同样的方法,我们可以把 DeepSeek、MiniMax、Kimi、智谱等模型,都接入到 Codex 中。

前段时间在 GitHub 上有一个前端的 Skill 收获了两万多个 Star,它主打让 AI 生成的前端界面更好看,这和 Qwen3.7 Max 拿下第二名的榜单任务类似。

我们先安装这个 Skill 到 Codex 中,然后尝试结合 Skill 看看是否能有更好的效果。

▲ 地址:https://github.com/Leonxlnx/taste-skill

输入同样的提示词,Codex 会自动调用前端设计、头脑风暴等 Skill 来完成设计的定位和构思,并且严格按照 Codex 的流程控制来监控项目生成。

最后,同样一个模型,在 Codex 里面的表现要比直接在千问官网好上不少。

但是这里还是会容易遇到一个问题「stream disconnected before completion: <400> InternalError.Algo.InvalidParameter: The “function.arguments” parameter of the code model must be in JSON format.」

当模型需要调用专门的工具时,就无法再和模型取得连接。我们在互联网上找到了相关的问题案例,原因可归结为「模型部署厂商针对流式输出格式有问题,不是标准 OpenAI 协议,所以不支持 API 调用,出现 400 报错。」

要求 Codex 解释这个问题时,Codex 也是说模型的问题。

不是你配置错了,而是 Qwen3.7 Max / 百炼 Responses API 对 Codex agent 工具调用还不够稳。能对话不代表能稳定跑 Codex,长任务、改代码、频繁读文件时,切回 OpenAI 官方模型会稳定很多。

所以如果你也遇到了这个问题,大概只有等 Qwen 团队自己去修复,或者重新开一个会话试试。

▲ 阿里云官方有出现不同错误码的解决方案指南

去年我们还在说模型即产品,一个足够好的模型就是一个好产品,现在看来,单靠模型是远远不够的。

记忆、Harness、Agents 编排、验证、推理的可持续性等等,随着模型能力的增加,这套架构也在持续扩充,但只有都做好了,我们或许才愿意说「这是一个好模型」。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

StoreKit 知识总结

作者 charmson
2026年5月26日 12:10

一、什么是 StoreKit 配置文件

StoreKit 配置文件(.storekit)是 Apple 提供的本地测试环境配置文件,用于在 Xcode 中模拟 App Store 内购行为,无需连接真实的 App Store 服务器。

支持的产品类型

类型 说明
Consumable 消耗型,如游戏币
Non-Consumable 非消耗型,如解锁功能
Auto-Renewable Subscription 自动续期订阅
Non-Renewing Subscription 非自动续期订阅

创建方式

Xcode → File → New → File → StoreKit Configuration File
Edit Scheme → Run → Options → StoreKit Configuration(指定文件)

二、本地配置 vs 沙盒测试

对比项 StoreKit 配置(本地) 沙盒测试(Sandbox)
需要网络
需要 App Store Connect 配置
速度 更快 较慢
适合阶段 开发早期 上线前验证

三、清除本地购买记录(重置初始态)

方法一:Xcode 菜单(推荐)

Debug → StoreKit → Clear Purchased Products

清除后重新运行 App 即可,无需重启模拟器。

方法二:重置整个模拟器

Xcode → Window → Devices and Simulators
→ 选中模拟器 → Erase All Content and Settings

方法三:代码同步(仅供参考)

#if DEBUG
try await AppStore.sync()
#endif

四、正式上线后的工作原理

.storekit 配置文件只在开发阶段生效,打包上线后自动忽略。

用户点击购买
    ↓
StoreKit 框架(代码不变)
    ↓
请求发往 Apple 生产服务器
    ↓
Apple 处理支付,返回 Transaction
    ↓
App 验证收据 → 解锁内容

三种环境对比

环境 数据来源 购买记录存储
本地开发 StoreKit 配置文件(.storekit) 本地模拟器沙盒
沙盒测试 App Store Connect(测试环境) Apple 服务器(沙盒)
正式上线 App Store Connect(生产环境) Apple 服务器(生产)

关键点

  • .storekit 文件是"假数据源",上线后真实数据全部走 Apple 服务器
  • StoreKit 是框架(API) ,在三种环境下代码本身不变,变的是背后连接的服务器
  • 用户购买记录永久保存在 Apple 账户中,restore purchases 从 Apple 服务器拉取
  • 上线后需要对 Apple 服务器签发的收据进行验证(客户端或服务端)

法拉利发布首款纯电车型!外形酷似理想 i6,约合人民币 435 万元

作者 李华
2026年5月28日 00:21

去年 11 月,理想汽车高级设计总监 Benjamin Baum 在接受媒体采访时,抛出了一段在当时显得有些匪夷所思的言论。

等着看法拉利纯电车的外观设计吧,它的形式实际上和我们的 i 系列非常相似,因为他们理解,一辆电车就必须是这个形状。

当时,互联网上充斥着对这段话的嘲讽。在大部分人的既有认知中,法拉利永远和低趴的姿态和引擎的咆哮绑定在一起。把高傲的意大利超跑和主打空间与舒适的家用车放在同一个语境下讨论,听起来多少有些荒谬。

时间给出了答案。

今天凌晨,法拉利在罗马正式发布了他们的首款纯电车型——Luce。这个单词在意大利语中意为「光」,寓意照亮前路。Luce 在意大利本土起售价高达 55 万欧元,约合人民币 435 万元。

当 Luce 在发布会上褪去伪装,Benjamin 的预言得到了验证。这辆长达 5 米的纯电车采用了类似理想 i 系列的单厢式结构,是法拉利有史以来的第一款 5 座车型,也是这个意大利超跑品牌对电动化时代最安静的回应。

驭光而来的跃马,是机械艺术,而非电子产品

打造一辆如此出挑的汽车,法拉利做了一个不寻常的决定。

这个极其重要的纯电首作项目,并没有交由 Flavio Manzoni 领导的法拉利自家设计工作室,而是联手了一个叫 LoveFrom 的设计工作室。这支团队的创始人,是缔造了苹果工业设计黄金时代的乔纳森·伊夫(Sir Jonathan Ive)和马克·纽森(Marc Newson)。

法拉利希望借助 LoveFrom 在奢侈品与科技行业的积淀,打开一个全新的视角。

受智能设备极简美学的启发,Luce 采用了一种被称为「玻璃屋」的设计概念。车身上半部分、挡风玻璃以及中控台大面积使用了康宁的大猩猩玻璃。为了追求极致的工艺感,引擎盖后缘与挡风玻璃的接缝精度达到了毫米级。雨刷也并没有像常规车辆那样隐藏在中间,而是分别停留在挡风玻璃的两侧。

这是一个非常微妙的手法,让人联想到了法拉利早期的经典赛车元素。

空间的分配同样打破了跑车的常规。

传动与排气系统的移除,让 Luce 的乘员舱获得了极大的解放。它采用了掀背设计,两侧是型面很有雕塑感的后铰链式对开门,当四扇车门同时打开时,你会看到一个宽敞到足以让三位成年人并排而坐的后排空间。

当然,支撑起这副前卫躯壳的,依然是一套强悍的底盘。

Luce 拥有四台电机,这些电机能够在一秒内把转速提升到每分钟 30000 转,爆发出 1050 马力的最大功率以及 7750 牛·米的峰值扭矩。对于一辆整备质量仅有 2260 公斤的纯电车而言,这个数据非常夸张。它只需 2.5 秒就能从静止加速到 100km/h,最高时速可达 310km/h。

它的底盘平铺了一块 122kWh 的电池组,采用 800V 电气架构,满电状态下的 WLTP 续航里程为 530km。

为了驾驭这种狂暴的动力,法拉利开发了一套全新的车辆控制单元。

这套系统能够以每秒 200 次的频率不断更新数据,配合虚拟差速器、四轮扭矩矢量分配以及最新版本的侧滑控制系统,在横向、纵向和垂直三个轴向上对每个车轮进行精准的控制。

坐进车内,乔纳森·伊夫对细节打磨得很到位。

在这个大屏泛滥的时代,Luce 的内饰却出人意料地保留了大量机械结构。三辐式方向盘由再生铝打造,内部由 19 个经 CNC 精密加工的部件组成。翻转空调出风口的铝制挡板,能听见清脆的机械回馈。

车钥匙的交互也充满仪式感。它采用 E-ink 电子墨水技术,插入中控台凹槽的瞬间,法拉利徽标上的跃马黄会像液体般流淌而下,点亮下方的挡位选择器。

驾驶员前方的 OLED 仪表采用凸透镜视差技术,铝合金与聚碳酸酯打造的物理指针带有背光。头顶的起跑控制杆,灵感则来自直升机操控面板。

在人们最关心的声浪问题上,法拉利并没有使用音响来播放 V12 发动机的录音。工程师在 Luce 后轴中心布置了一枚加速度传感器,实时捕捉电机等部件的振动频率。而后,系统会像处理电吉他信号那样,对这些真实的机械振动做均衡与放大,再由车外扬声器释放到街道上。

倘若切换到性能模式,这股源自电机的声音同样会涌入座舱,加之反潮流的内饰风格和实体按键的交互,你能够感知到,法拉利在极力阻止 Luce 沦为一件枯燥的电子产品。

把超豪华纯电的定义权握在手里

放眼当下的超豪华汽车圈,电动化进程正在经历一场倒春寒。

兰博基尼已经叫停了自家的纯电计划,首席执行官斯蒂芬·温克尔曼在面对媒体时表示超跑买家对电动车的兴趣「几乎为零」。与此同时,迈凯伦的高管对纯电路线态度暧昧,阿斯顿·马丁也将首款电动车的发布时间线延后了三年。

市场给出的结论是,处于金字塔尖的消费者依然迷恋汽油燃烧的味道。

在这样的行业退潮期,法拉利逆势推出 Luce,看起来像是一场不计后果的冒险。他们完全可以像其他品牌一样,继续享受内燃机带来的丰厚利润,将电动化的任务推迟到下个十年。

但法拉利有自己的考量,为的就是「超豪华纯电」的定义权。

现在这个赛道已经不止有保时捷 Taycan 了,中国那批势头正猛的豪华品牌也在虎视眈眈。法拉利希望能够通过 Luce 来向行业证明:没有 V12,也照样能造出让人热血沸腾的车。

为什么这台承载着品牌野心的新车,最终会呈现出类似理想 i 系列的轮廓,答案自然是——物理规律。

在燃油车时代,设计师拥有极大的自由度。想要更嚣张的线条?风阻大一点也无妨。但到了纯电时代,风阻系数直接掐住了续航的命门,每一丝气流都和里程数严格挂钩,设计师再也没法任性。

把 Luce 做成类似于水滴的流线型单厢结构,成为了空气动力学上的最优解。

底盘架构的演变也推动了这种外形的诞生。纯电滑板底盘不再需要庞大的前置或中置引擎,因此将座舱整体前移,把底盘面积尽量让渡给乘客,顺理成章地成为了设计师的首选。

乔纳森与部分国产新势力车企在面对着同一套关于空间、风阻和重量的物理题时,推导出了相似的几何轮廓。

但他们最终还是走向了不同的分叉。

法拉利显然早就准备好去面对激进外观带来的舆情。对于这家超跑品牌而言,特立独行本就是溢价的一部分。而背负着销量压力的新势力车企,终究还要考虑如何把车卖给更多的主流大众。

比如,在后来实际落地的 i 系列上,理想就选择向市场低头,将车尾重新改回了大众更习惯的传统 SUV 的模样。

法拉利不需要向市场低头

美国工业设计先驱雷蒙德·罗维曾提出过著名的 MAYA 原则,即极度先进,但又可被接受(Most Advanced, Yet Acceptable)。

他观察到一种普遍的消费心理。人们既渴望拥抱新奇的科技,又对完全脱离既有认知框架的事物充满恐惧。一款成功的工业产品,必须在这两种情绪之间找到平衡点。要么用未来的技术包装出人们熟悉的模样,要么在熟悉的模样中一点点注入未来。

现在市场上已经有对照组了。

一类产品选择顺应大众审美。它们保留了很长的 L113——也就是前轮轴心到驾驶踏板之间的距离。

在过去的一百年里,这种修长的车头是用来安置大排量发动机的,久而久之,这种因功能而生的比例,反而成了身份与财富的象征。保留它,就是在迎合人们对豪车的固有认知。

奔驰 EQS 则走向了另一个极端。为了追求极低的风阻系数,工程师采用了非常前卫的弓形车身。这种设计抹平了 S 级轿车应有的威严感,让它看起来像一个放大版的鼠标,市场反响冷淡。

奔驰首席技术官马库斯·谢弗后来反思过:早期的电车用户恨不得全世界都知道自己开的是电动车,但当电车真正变成主流,消费者反而不想被当成异类——他们只想要熟悉的造型。

大众的审美习惯往往会滞后于技术的迭代速度,这在行为经济学里叫做「现状偏见」。

那法拉利 Luce 会不会重蹈 EQS 的覆辙?

这种担忧实际上忽略了法拉利的受众画像。

EQS 面对的是主流富裕阶层,他们需要用一辆稳重的行政座驾来展示自己的社会地位,他们没有试错的成本。法拉利的客户群体不太一样,愿意花几百万买一台电动玩具的人,车库里大概率也不会缺 V12。他们或许更需要一种足够先锋、足够破格的产品,来证明自己跟得上时代。

颠覆传统的外形,对他们而言不是冒险,而是一张极具吸引力的社交名片。

乔纳森·伊夫那些复杂而昂贵的细节,正是这张社交名片的底气所在。24 寸的超大轮毂、对开门,每一处都在提醒旁人:这车不便宜。

法拉利董事长约翰·埃尔坎在谈及这辆车时表达了他的立场:

当汽车电动化时,并不意味着它必须成为一件消费电子产品,这大概是过去十年里业界一直在犯的错误之一。

这句略带锋芒的论断,恰恰解释了 Luce 为什么长这样。它不想随波逐流,更不想变成一台没有温度的电子快消品。

当大多数车企还在向保守审美低头时,法拉利选择用品牌号召力,强行把汽车设计往前推了一步。

更何况,实在不行,他们还能卖 V6 和 V12 嘛。

带轮子的都关注,欢迎交流。 邮箱:tanjiewen@ifanr.com

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

用自定义 Layout 化解 SwiftUI List 的行高与间距跳变

作者 Fatbobman
2026年5月27日 22:00

动画的声明式表达是 SwiftUI 的核心优势之一。但在某些场景里,结果并不总像我们期待的那样平滑。一个典型例子是:当 `List` 行内的内容高度发生动态变化——副标题从空变为非空、文本因更新而导致行数变化——系统自带的布局引擎往往无法给出连续的过渡动画。本文从这个现象出发,逐层拆解原因,给出一种完全基于 SwiftUI 原生能力的解决方案;也借这条路径回看 SwiftUI 在布局机制层面的几个关键约束。

最好的手机 AI,是仿佛没有 AI|AI 器物志

作者 马扶摇
2026年5月27日 18:00

智能手机统治了过去十几年的数字生态,它是注意力的黑洞,是我们最私密的随身之物。但手机从设计之初就是为「人盯着它」而生的——它的全部逻辑,都止于屏幕。

AI 的需求却恰恰相反:它需要持续感知物理世界——见你所见,听你所闻,随时在场,而非等你解锁屏幕才醒来。

当 AI 真正成为一种基础能力,它迟早要从屏幕里破壳而出,寻找属于它自己的形状。这将是一个漫长的探索和演化过程。

「AI 器物志」栏目由此而来,爱范儿想和你一起持续观察:AI 如何改变硬件设计,如何重塑人机交互,以及更重要的——AI 将以怎样的形态进入我们的日常生活?

这是「AI 器物志」的第 14 篇文章。

时至今日,已经没有人可以否认:手机正在成为我们生活中最重要的 AI 枢纽。

无论是给智能穿戴作数据中枢,还是作为独立智能终端,手机都承担着比以往任何时候都重的连接和处理任务。

而手机的操作系统,就是这些连接和处理的基础,是一切 AI 功能的舞台。

图|OPPO

承认吧,无论你是否喜欢手机智能助手,手机系统的 AI 化都是不可避免的,连谷歌都在说要把 Android「操作系统」进化成「智能系统」了。

与其一股脑地反对手机 AI 化,我们面对这种变革的最好方式,莫过于秉承「拿来主义」的原则:

主动发掘那些最好用的手机 AI 功能,让它们成为日常使用中的润滑剂。

而口号「超流畅更 AI」的 OPPO ColorOS 16,就是这样一个难得的 AI 功能不喧宾夺主、反而让使用体验变得润滑的手机系统。

一键闪记:AI All in one

虽然记忆功能各个品牌都有,但小布记忆仍然是目前为止我们体验到功能最丰富、用法最直观、生态最完善的那个。

毕竟 OPPO 在开发这项功能的时候,背后的思路很清晰:

我要的不是截图本身,而是屏幕上的信息……灵魂抽走之后,那个枯萎的实体照片就不重要了,因为信息已经被提取了。

在最新版本的 ColorOS 16 里,「一键闪记」的能力进一步加强,开始和小布记忆里面的多模态功能、流体云等有机结合,变成了一个比截图更好用的超级记忆工具。

视频闪记

让一个 AI 工具好用的重点,从来都是「工具多走一步,让用户少走一步」。

「视频闪记」功能,正是在记录和理解屏幕信息的基础上主动多走一步,让用户不必额外操作一次的典型代表。

这个功能之所以好用,在于它解决了以前要总得复制链接发给 AI、或者在视频播放界面喊小布的「多步骤」操作。

相比之下,视频闪记只需要在播放的时候按一下快捷键,小布就会自动识别视频、自动执行总结,一下减少了 50% 的手动工作量。

尤其如今各种学习视频——网课、生活技巧、产品教程等等——大爆发,OPPO 的视频闪记可以起到非常好的内容整理作用:

而进入小布记忆里面还会预留带超链接的时间戳,跳转的视频甚至可以免开屏动画(和广告)

此外,小布记忆还会根据总结出来的视频内容,主动关联之前记忆的其他视频,形成自动收藏夹的效果。

但它目前只支持国内主流视频平台、不支持 YouTube 和微信视频号,仅支持中英文、不支持小语种,以及部分竖屏视频无法触发总结等等。

但总的来说,「视频记忆」依然是 ColorOS 中体验最好的 AI 功能之一,就因为它把两个最常见的 AI 操作整合在一起、做出了 1+1>2 的体验。

快速记账

除了视频总结之外,另一个我们意料之外好用的「闪记类」功能,则是小布的自动记账。

当然,OPPO 这个自动记账并非百分百自动,而是与更常用的「闪记上岛」整合在了一起,严格来说依然是个需要手动执行的操作:

但「闪记上岛」本身足够优秀,几乎可以说是目前适配性最强大的「灵动岛」类功能。

将记账和这个高频功能组合在一起,应该就是目前最无感的方案了。

当然记账的方式很多,微信还是支付宝都有和账单相关的智能功能,ColorOS 最大的优势依然是前面提到的「流程顺畅,体验无感」。

小布记忆目前只支持导入微信和支付宝的 Excel/CSV 对账单,如果是云闪付或者其他平台的话,就要导出 Excel 之后修改排版才能导入了:

除了单纯记账之外,ColorOS 的账单分析功能也做得很不错。

在小布记忆首页就可以看到当月支出的柱状图,点进去还能看到流水明细和日周月平均:

单这些功能,基本上就可以满足 95% 的日常记账需求了,可以省下相当多付费记账 app 的开销。

更重要的是在「我的账单」页面,你还可以和小布讨论已有的收支数据——

不过 LLM 的数学推理能力都比较一般,小布给出的结果最好还是「仅供参考」。

系统功能:最爱抠细节的 AI

除了上面的「英雄场景」之外,ColorOS 很多 AI 功能也是整合进系统 app 里的,在使用过程中经常会有「原来这里也能用小布」的感叹。

并且 AI 功能集成在系统应用中,也变相提升了它们的留存度、让很多「到手就删」的 app 有了用武之地。

菜单翻译

作为 ColorOS 16 重点宣传的功能之一,智能翻译 + AI 菜单可以说是让我们印象最深刻的优秀 AI 整合案例了。

它为一个门槛颇高的问题,提供了一种极为接地气的解决方案——

把高大上的洋文菜单,直接 vibe coding 成微信点餐小程序。

和小布记忆不同,这个 AI 菜单翻译入口藏得比较深,需要在预装的翻译 app 里面的「拍照翻译」中激活:

在拍照翻译时,无论直接拍摄,还是导入相册图片,在读取到翻译内容是菜单之后,ColorOS 就会提示这个新的「AI 图文菜单」入口。

在 AI 图文菜单里面,系统会把所有识别到的菜品转换成我们最熟悉的点单小程序布局:

并且系统还会为每道菜配上一个 AI 预览图、原料和做法,甚至还有过敏原提示价格换算

我们只需要像小程序点菜一样选择,然后选择右下角的「向店员展示」,它就能提供文字和语音两种展示形式。

更细节的是,ColorOS 为一些主流外国菜系定制了不同风格的菜单界面,比如日料就是红底配富士山,泰国菜就是黄底配大象等等——

并且除了出国旅游,AI 菜单功能还有一个小众用法:去酒吧的时候用它翻译一下,就能清楚自己在喝什么东西了。

不少酒吧用的都是双语菜单,用 ColorOS 的 AI 菜单翻译一下,不仅能看到大概的样子,还能看到制作方法,准确度不错:

AI 帮写

ColorOS 的「AI 帮写」也是一个用之前没有感觉、一开始用就容易形成习惯的小功能。

和其他厂商喜欢把 AI 写作功能绑定进预装输入法不同,ColorOS 的 AI 帮写与输入法是独立的。

也就是说无论你用搜狗输入法、微信输入法还是 Gboard,AI 帮写都能正常使用:

而 ColorOS 实现 AI 帮写的方式也很有意思:

它的触发检测基于应用白名单,但提示词却是通过屏幕内容识别读取的。

换言之,AI 帮写只会在特定 app(美团大众、淘宝京东、小红书朋友圈等等)里弹出,在不支持的软件里面(比如酷安)只能手动呼出小布帮忙。

微信朋友圈(左)和酷安(右)

而 AI 帮写具体写什么东西,是根据它识别到的屏幕内容决定的,有时候会导致一些 bug——

比方说在小红书里面,只要屏幕上有缩略图,AI 就知道我想要的是和猫相关的内容:

但大众点评里,由于输入框上移会挡住照片,AI 帮写就不知道内容是什么了。

如果碰巧没显示关联话题,AI 帮写就会写出一些不知所云的东西:

但在不出 bug 的时候,ColorOS 的 AI 帮写功能都是相当好用的。

虽然它生成的东西很难说有什么个人特色,但用来应付那些「写 100 字点评领优惠券」的场景来说,简直就是解放生产力的终极工具。

需要改进的问题

除了上面的有趣用法以外,ColorOS 目前的 AI 功能也存在着一些问题和短板。

首先是普及性的问题——前面列举出的大部分功能,其算力重心都是云端服务器,手机本身只需要承担一小部分算法开销。

在这样的前提下,以云端功能为主的 AI 更新应该很容易下放到较老的机型上才对

但事实上不是如此。

就拿我们手里的 Find N3 和 N5 为例:即使是最新版本的系统和 app,也没有更新前面提到的 AI 菜单功能——

其次,用小布记忆来记账的确很方便,但它的操作方式有些过于粗犷了。每次都得在订单界面闪记一下,自动化程度还是有些不足。

更要命的是,它作为一个记账功能,居然不支持外币或汇率转换

哪怕 AI 识别到小票上的币种是泰铢(THB),数字也会按人民币入账:

在 OPPO 国际版逐渐打开销路、出国旅游愈发普遍的今天,这种基础功能的缺失是很难让人接受的。

并且相比友商的 AI 助理,小布能够支持的「代操作」功能还是比较原始——

用支付宝给谁发个红包可以,去淘宝再买一单上次的咖啡豆就不行了。

操作系统就该是智能系统

归根结底,我们还是要回到之前 Android Show 上谷歌对 Android 系统的新的定义:

Android 将会从一个操作系统(operating system, OS)转变成一个智能系统(intelligence system, IS)。

过去几年间,无论是华为鸿蒙、豆包手机,还是 OPPO 的小布身上,我们其实都看到了:

所有的手机 OS 都在变成 AI OS(或者 IS),这种趋势是无法反转的。

相比谷歌在 Android 里面到处推销 Gemini,OPPO 做 AI 的特点是很鲜明、也很不同的——

OPPO AI 的本质不是卖模型,而是卖装着 OPPO AI 的手机、卖一个有软件加持的硬件产品。

OPPO 设计这些功能的底层逻辑,不是希望用户去买「小布 Premium」,而是追求在很多实用场景里面「比别人多走一步」,把用户的体验给圆上。

这也是我们在使用 ColorOS 的 AI 功能时感受最深的一点:

与其削尖脑袋推销 AI 订阅,反而是把 AI 智能做得「无感」更容易让人接受。

毕竟真正优秀的 AI 技术,就应该像电力、自来水一样无感:用户只有在它不在的时候,才应该意识到它的存在。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

❌
❌