阅读视图

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

腾讯终于对个人开放了,5 分钟在 QQ 里养一只「真能干活」的 AI 😍😍😍

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。 很多人第一次打开 OpenClaw,会下意识把它当成"接在微信或 Slack 上的聊天机器人"。这种理解只对了一半。从架构上看,OpenClaw 更像一个网关:它站在你和一堆能力之间,负责路由、鉴权、记忆和工具调用。真正决定你能做多少事的,不是对话框有多好看,而是背后接了多少"身体"——也就是 Skills。

3月7日消息,腾讯面向个人用户开放了 QQ 开放平台的机器人创建权限。此前这项能力仅对企业开放,现在任何持有 QQ 账号的个人开发者都可以注册并创建自己的 QQ 机器人。配合 AI 智能体框架 OpenClaw,整个绑定流程只需不到五分钟。

注册与创建只需几分钟

打开 QQ 开放平台官网,用手机 QQ 扫码就能完成开发者账号注册,不需要填写任何企业资质或繁琐表单。

image.png

扫码登录后,点击一次"创建机器人"按钮,平台会立即为你生成一个专属的 AppIDAppSecret,这两个凭据就是后续与 OpenClaw 绑定的钥匙。

image.png

三条命令完成绑定

拿到 AppIDAppSecret 之后,切到 OpenClaw 控制台,依次执行以下三条命令:

openclaw plugins install @llvera/qqbot@latest
openclaw channels add --channel qqbot --token <你的 token>
openclaw gateway restart

第一条安装 QQ Bot 插件,第二条把你的 token 写入频道配置,第三条重启网关让配置生效。整个过程不超过五分钟。

真实上手效果

配置完成后,打开手机 QQ 直接和机器人说话就行,和普通聊天窗口没有任何区别。

问它"我现在有什么 agent",它会把 openclaw.json 里登记的所有 Agent 及其当前状态一次列出来,还会告知各 Agent 之间的通信是否已启用。

2c403f2be115f7b8c368884fca0b4bad

更有意思的是,它能直接操作你本机的文件系统。让它列出桌面 video 目录里有什么文件,它会按时间分组整理成一张表格返回给你——比如 32 个 .MOV 原始视频文件,按 2024 年、2025 年 1 月……逐年分组,每个文件名和体积一目了然,总计约 51.5 GB。

f8bf698675db3653529ac084d3d725fb

如果你进一步说"帮我截第 1 秒、第 5 秒、第 10 秒、第 14 秒的画面",它会直接调用本地工具提取视频帧,以 4K 原始分辨率(3840×2160)输出截图,保存到桌面的 frames 文件夹,然后把截图一张一张发回给你。

b44b8a9c44b816616cd784e4ada860bd

31f7d731b84e17995d10c99c10e12def

你说"把图片发给我,我要看",它就把文件复制到桌面再通过 QQ 直接发送过来,不需要你去文件管理器里翻。

2fd5481de03b4d885197ddb6f482b443

这些操作全程没有打开任何 App,只是在 QQ 聊天框里说了几句话。

OpenClaw 是什么

OpenClaw(曾用名 ClawdbotMoltbot)是一款开源 AI 智能体框架,由程序员彼得·斯坦伯格(Peter Steinberger)开发,核心语言为 TypeScript,采用标志性的"蓝色龙虾"图标设计。它的 slogan 是 "The AI that actually does things",意思是真正能干活的 AI。

与传统对话式 AI 不同,OpenClaw 不是只能回答问题的聊天机器人,而是能够执行任务的数字员工。它通过自然语言指令驱动,可在本地或私有云环境中完成文件管理、代码执行、网页抓取、API 调用等操作。

image.png

用一句话概括,它实现的是从"建议"到"执行"的跨越。

几个值得关注的细节

一个 QQ 号最多可以创建 5 个独立的 QQ 机器人。

绑定至 OpenClaw 环境后,机器人支持接收和发送 Markdown、图片、语音、文件等多种格式的消息,手机端 QQ 和桌面端 QQ 均可正常使用,不限终端。

另外,OpenClaw 的工具调用具备一定的容错能力。当某个工具不可用时,它会自动降级切换到备用工具。例如搜索插件不可用时,它会调用浏览器工具直接打开网站抓取内容,整理好再返回给你,整个过程无需人工干预。

OpenClaw 能帮自媒体人做什么

对自媒体从业者来说,OpenClaw 能覆盖的场景远不止联网搜索。从内容生产到数据复盘,它可以接管那些重复、耗时却省不了的环节。

image.png

目前能直接上手的场景包括:

  • 公众号写作,从选题调研到完整初稿,按模板排版,两小时内从灵感到成品
  • 小红书爆款,自动学习你的语气风格,一次给出 5 个备选标题
  • 视频内容拆解,转写文字稿、提取金句字幕、生成话题标签和简介文案,自动适配多个平台
  • 素材批量处理,一次处理 50 个文件,批量改名、自动归类
  • 品牌合作跟进,整理合作邀约信息,不让 offer 漏掉
  • 数据复盘报告,汇总阅读量和互动率,告诉你本周内容的表现和下一步方向

这些事情放在以前,要么手动一件一件做,要么需要在多个独立工具之间来回切换。现在通过自然语言指令,交给 OpenClaw 统一处理就行。

e42bf5f8926df2a849630c92eb53c146.png

目前我正在组建 OpenClaw 中文社区,如果你也在探索用 AI 工具提效、或者想一起玩转这套工作流,欢迎添加我的微信 yunmz777,拉你进群交流。

React 正在演变为一场不可逆的赛博瘟疫:AI 投毒、编译器迷信与装死的官方

React 正在演变为一场不可逆的赛博瘟疫:AI 投毒、编译器迷信与装死的官方

React 正在沦为前端圈的“孔乙己”:脱不下的长衫与失控的基建

React 团队一到前端圈,所有敲代码的人便都看着他笑,有的叫道:“React,你又悄摸摸搞出几个极其拧巴的缝合怪 API!”

他不回答,对柜里说:“加两套重型编译器,要一碟 useEffectEvent。”便排出几个难懂的心智模型。

他们又故意的高声嚷道:“你一定又在底层偷偷搞副作用(Side Effects)了!”

React 睁大眼睛说:“你怎么这样凭空污人清白……”

“什么清白?我前天亲眼见你的 useEffect 里异步网络请求满天飞,闭包陷阱套着闭包陷阱,连个最新状态都拿不到,还在 commit 阶段偷偷改 Ref,被全网吊着打。”

React 便涨红了脸,额上的青筋条条绽出,争辩道:“异步……异步的事怎么能叫副作用呢!……那叫代数效应(Algebraic Effects)!React 调度器里的事,能算不纯么?”接连便是些难懂的话,什么“Fiber 架构”,什么“并发渲染(Concurrent)”,什么“UI 是状态的纯函数映射”,引得整个 Web 社区内外充满了快活的空气。


你明明一身历史包袱,底层 DOM 突变和时序补丁糊了一层又一层,还要死死捂着那件打满补丁的「函数式编程」长衫装清高。你连业务代码里最基本的异步抓取和状态流转都做不到开箱即用,还天天搁这儿给开发者念经,说什么“要保持纯洁,要无副作用”。

既然你端着全球最大前端基建的架子,那我就得用配得上你这份傲慢的严苛标准来伺候你。我不听你那套自欺欺人的八股文,也不陪你玩“心智模型”的文字游戏。我只负责把你剥个精光,拿着放大镜逐行扒开你 ReactFiberCommitWork.js 的源码,把你装死关掉的 Issue 挨个掘出来。


一、useEffectEvent:设计拧巴,文档更拧巴

1.1 经典恶心场景:闭包逼你把「只想读一次」的东西写进依赖

所有写过 React 的人都遇到过这种事:

你写了一个聊天室组件,连接成功后要弹个提示,提示要用当前的主题色 theme

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('connected', () => {
      showToast('连接成功!', theme) // 这里用到了 theme
    })
    connection.connect()

    return connection.disconnect
    // ...
  }, [roomId, theme]) // 🚨 噩梦来了:React 逼你把 theme 加进依赖数组
}

问题在哪?

你只是想在弹窗时读取一下最新的颜色(theme),但因为 React 的闭包机制,你被迫把 theme 写进依赖数组。
结果就是:用户随便切个暗黑模式(theme 变了),你的聊天室就会断开重连一次。 这简直是灾难。

1.2 官方的解法:useEffectEvent

React 在 19.2 给出了 useEffectEvent:把「需要最新值、但不想加依赖」的逻辑包起来。

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showToast('连接成功!', theme) // 这里总能拿到最新的 theme
  })

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('connected', () => {
      onConnected()
    })
    connection.connect()
  }, [roomId]) // 🎉 roomId 变了才重连,theme 终于不用加进依赖了
}

听起来是个好东西。

1.3 槽点:极度反人类的调用限制

React 给这个 API 加了一条硬性限制

"A function wrapped in useEffectEvent can't be called during rendering."

也就是说:这个函数绝对不准在组件渲染过程中调用,只能在 Effect 或者事件里调用。
如果你把它传给子组件,子组件在 render 里 调了一下,整个应用直接报错白屏

为什么限制这么死?

源码里,这个函数的最新值是在 DOM 渲染完之后的 commit 阶段 才挂到内部 ref 上的(见 ReactFiberCommitWork.js)。渲染的时候它还是个空壳或旧值。官方解决不了这时序问题,于是粗暴地用报错来阻止开发者

相比之下社区是怎么做的?

社区早就用 useRef 自己封了一个 useLatestCallback(或等价物):包装函数引用稳定,调用时总是执行当前 ref 里的最新函数。
没有任何调用时机的限制,想在哪调在哪调——render、effect、事件、传给子组件,都不会因为「during rendering」被拦。

详见我的 useLatestCallback 实现:github.com/beixiyo/rea…

官方无视社区极其好用的作业不抄,偏要自己造一个内部实现极其拧巴、强加各种规则、心智负担极重的 useEffectEvent。这就是设计拧巴,文档更拧巴

引用:


二、赌徒的执念:从 Prepack 到 Compiler,用魔法掩盖缺陷

2.1 Prepack:React 团队的「前科」

一句话:Prepack 是 Meta(Facebook)在 2017 年左右搞的一个失败的 JavaScript 编译器项目

当时 Facebook 异想天开:既然 JS 运行慢,能不能在打包编译的时候就把能算出来的代码提前算好?

比如 let a = 1 + 2 在编译时直接变成 let a = 3。后来他们试图把 Prepack 用在 React 上,想在编译期把组件提前「折叠」优化。

但 JavaScript 太动态了,这个饼根本画不圆,项目黄了,被官方放弃

为什么文章里要提它?

因为 React 团队对「编译器」有一种病态的执念
当社区都在拥抱 Signal(用运行时的细粒度响应式解决性能问题)时,React 偏不。他们觉得:当年 Prepack 虽然失败了,但我现在搞个缩小版叫 React Compiler,继续搞编译期魔法。
提 Prepack 就是为了扒皮:React 宁可在「编译期优化」这条曾走过弯路的老树上吊死,也不肯听社区的意见去换一套更先进的响应式模型(Signal)。

2.2 不承认模型问题,用编译器堆屎山

面对依赖数组带来的灾难,社区呼吁引入 Signal 这种现代化的细粒度响应式。React 选了啥?选了他们当年失败过的「编译期优化」老路。

从当年胎死腹中的 Prepack,到如今的 React Compiler,官方展现出一种惊人的技术执念
我们宁可造一个巨型编译器来强行分析依赖、强行插入缓存,也绝不承认「组件级渲染 + 依赖数组」这个底层模型本身已经落后了。

这不叫优雅的工程演进,这叫为了掩盖第一代屎山的恶臭,强行喷香水

  • React Compiler 1.0(2025-10)称:"automatically optimizes components and hooks without requiring rewrites"。也就是:靠编译期自动优化兜住现有模型,而不是改模型。
  • React Labs 的 Automatic Effect Dependencies:嘴上说「effect 难理解」,手上在「依赖数组 + 组件树」上继续叠编译器与 IDE,而不是提供更简单的抽象(Signal 或至少 useLatestCallback 这类通用稳定回调)。

引用:


三、Signal:社区要,官方不接,关 Issue 不解释

3.1 社区直接问:为什么不做 Signal?

GitHub #27164"feature: make react more reactive (feedback for future)"):

  • 作者提出:useState/useEffect 本质是 observable + subscriber,但「读到的不是最新值」「依赖数组难写」,"Just implement signals. It will reduce complexity."
  • 结果没有任何 React 官方成员回复。Issue 被 stale bot 自动关掉(Resolution: Stale, closed as not_planned)。
    社区认真提的「换一种更简单的模型」被冷处理,连一句「我们考虑过,因为 XXX 所以选 Compiler」都没有。

GitHub #31393"React Why Not Consider Support Signals"):

  • 作者问:为什么官方不考虑 Signal 这种显而易见的方案?
  • 结果Joseph Savona(React Compiler 负责人)关闭,唯一一句回复
    "We covered this pretty thoroughly in our React Conf talk about performance." 附了一个 React Conf 演讲链接。
    也就是说:没有在 issue 里写任何「为什么不支持 Signal」「技术选型理由」,而是把问题推到「我们在一场演讲里讲过」——不写进文档、不写进 RFC、不留在 issue 里,等于让后来者自己去找视频听,且无法被搜索和引用。

3.2 这算啥?

  • #27164:零官方回复,stale 关掉 → 不接话、不解释
  • #31393:仅回复「Conf 里讲过」,不给正文、不给摘要 → 死鸭子嘴硬:既不承认「我们就是选 Compiler 不选 Signal」,也不在公开文本里说明选型理由。

身为被全世界当基建的库,对「为什么不做 Signal」这种级别的讨论,不在 issue / 博客 / RFC 里留下可检索的、负责任的说明,而是用「去听我们某次 Conf」打发,这是对社区反馈的轻慢,也是技术傲慢

替代品:满分作业拍在脸上,React 偏不抄

其实证明 React 底层不仅能上 Signal、而且能上得极其优雅的铁证,早就摆在那里了。

社区掏出的 Preact Signals,直接把运行时细粒度更新的满分作业拍在了官方脸上。它完美兼容现有的组件模型,直接证明了闭包陷阱完全可以靠一套现代化的响应式机制来根治。

最打脸的是什么?人家一个第三方库,在根本碰不到你 React 核心源码的情况下,都能靠外挂把这套机制跑得明明白白。而你官方握着 Fiber 调度器的生杀大权,却选择视而不见,死活说“做不了”。

只能说 React 团队这几年确实是写编译器写魔怔了。简单的运行时解法他们不屑于做,正路不走,非得拉坨大的出来,好像不搞个重型编译链就配不上大厂的 KPI 一样。

更可笑的是,如果你现在想用 Preact Signals,你会发现它跟官方硬推的 React Compiler 是直接冲突的。

为什么冲突?因为 React Compiler 根本就不是什么优雅的架构演进,它本质上就是一个极其自负的 AST 爆改插件。你原本干干净净的代码,被它过一遍,AST 树上全是被它强行塞进去的 useMemo 和缓存标记,代码执行轨迹完全成了一个黑盒。

搞得这么抽象,不知道的还以为你搁这做 JIT 呢。

(如果你也受够了官方这种强行喂屎的黑盒操作,想看看怎么在 React 屎山里自救,详见我踩坑两年写出的血泪总结:《花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案》)

引用:


四、技术选型:Compiler 而非 Signal,且不写清楚

从公开信息能拼出的「为什么是 Compiler 而不是 Signal」大致是:

  1. 架构和历史包袱:React 的调度、并发、SSR、reconciler 都是按「组件树 + 依赖数组」建的。原生 Signal 是细粒度订阅,要接进去等于在核心里再塞一套响应式模型,改动面巨大。
  2. 已经押注 Compiler:从 Prepack 到 React Compiler,团队长期押「用编译期优化」来逼近「少重渲染、少依赖心智」,而不是在 runtime 换一套响应式。公开承认「该上 Signal」等于承认这条路线不够,所以不会在官方叙事里这么说
  3. 生态与兼容:全世界都是 setState + deps,真要内置 Signal 要么长期双轨,要么 breaking 大改,政治和生态成本都高。

但这些没有在任何官方博客、RFC 或上述 issue 里被系统写出来
选型结果就是:Compiler + 更多工具链;对 Signal 的态度是:不计划、不接题、关 issue 时指到 Conf 视频
技术选型存在,但解释不透明;社区问「为啥不 Signal」得不到可检索的、负责任的答复——这就是技术傲慢:我们怎么做你们就怎么用,理由你们自己找。


五、为什么非要逮着 React 骂?

我可以不用 React,但躲不开

总有人问我:“既然你这么懂,自己封装一套解法不就行了?干嘛天天逮着骂?”

说实话,React 这一地鸡毛的闭包陷阱、渲染地狱,我早就摸透了,甚至有极其成熟的解法和替代方案。但这不代表我觉得它合理!这种开发模式简直蠢透了! 我一个做业务开发的,凭什么要天天搁这儿给框架擦屁股?

5.1 恶臭的鄙视链与叹息之墙:毒害行业新人

前端圈一直有股令人作呕的风气:“React 孝子”们看不起 Vue 等其它框架。他们把「用 React」当成政治正确,把对 React 的批评当成异端。结果就是烂设计没人敢往死里骂,屎山越堆越高。

它不仅折磨老手,更是在新人面前砌起了一堵叹息之墙。新人满怀热情想画个交互,结果光是搞懂 useEffect 为什么会死循环、定时器里的 State 为什么永远停留在上个世纪,就得先脱两层皮。 硬生生把一个前端门槛搞得如此畸形、反直觉,官方非但不反思,那一群孝子反而把这种极高的心智负担当成“技术深度”四处炫耀。把喂屎包装成“最佳实践”,这就是你们引以为傲的工程化?

5.2 AI 投毒:机传人的赛博瘟疫

进入 AI 时代,这场灾难彻底演变成了赛博瘟疫。全网投喂的语料导致现在的 AI 写前端默认就是 React,十有八九是 JSX + hooks。

我现在日常开发主要靠 AI 写代码,但面对 React 这个奇葩,即便我在 Prompt 和工作流里写了上百行的防坑铁律严防死守,AI 还是会时不时被 React 那套反人类的阴间规则绕晕,悄无声息地给你拉一坨极其隐蔽的屎。 到头来,我还得停下手中的活,亲自下场 Debug,拨开那一层层令人窒息的依赖数组,去查看到底是底层哪个 Hook 又在发癫。这不是在写代码,这是在做赛博排雷。整个 Web 社区的代码基建正在不可逆转地走向失控的屎山化。

5.3 SDK 绑架:强买强卖的生态流氓

你以为你不用 React 就能独善其身?直到有一天,你接一个核心的 SDK,点开文档一看:对不起,只有 React 版本。 为了用这一个组件,你被迫在项目里引入整套 React 运行时,被迫去吃那一套恶心的 Hooks 闭包。这不叫技术选型,这叫强买强卖的生态流氓。

所以,你问我为什么逮着 React 骂? 因为他早已不是一个你可以躲开的工具,而是一场避无可避的生态瘟疫。骂 React,不是在骂「一个你可以不用的库」,而是在骂已经失控的基建


狂热粉丝只会告诉你 useEffectEvent 怎么用,而我会翻出 ReactFiberCommitWork.js 的源码告诉你它为什么这么难用; 小白面对被关掉的 Issue 只会觉得是自己提错了,而我会顺着线索找到那场企图搪塞一切的 React Conf 视频。

我拿着所有的官方日志、源码和 Issue 链接站在这里,指着这些拧巴的设计说:作为基建,你现在的傲慢、闭门造车和对社区声音的冷处理,真的很难看。

这不是毫无逻辑的狂喷,而是学霸拿着满分试卷在教训连及格线都没达到的出题人—— 用详实的论据、严密的逻辑和底层的代码把你锤得体无完肤。


引用链接汇总

类型 内容 链接
官方博客 React 19.2 发布(Activity, useEffectEvent, cacheSignal 等) react.dev/blog/2025/1…
官方博客 React Compiler 1.0 react.dev/blog/2025/1…
官方博客 React Labs: View Transitions, Activity, Automatic Effect Dependencies react.dev/blog/2025/0…
官方文档 useEffectEvent Reference react.dev/reference/r…
GitHub #27164 – make react more reactive / implement signals(无官方回复,stale 关闭) github.com/facebook/re…
GitHub #31393 – Why Not Consider Support Signals(Joseph Savona 回复 Conf 链接后关闭) github.com/facebook/re…
源码 ReactFiberHooks.js(useEffectEventImpl, 渲染期禁止调用) github.com/facebook/re…
源码 ReactFiberCommitWork.js(commit 阶段更新 effect event ref.impl) github.com/facebook/re…
官方 Joseph Savona 在 #31393 中指向的 React Conf performance 演讲 www.youtube.com/watch?v=zyV…

GPT-5.4 Computer Use 实战:3 步让 AI 操控浏览器帮你干活 🖥️

上周五 OpenAI 发布 GPT-5.4 的时候,我盯着 Computer Use 的 demo 看了整整半小时——AI 自己打开浏览器、点按钮、填表单、截图验证结果,全程不需要人干预。

说实话,之前 Claude 的 Computer Use 我就体验过,但那个延迟和准确率劝退了不少人。这次 GPT-5.4 直接把 OSWorld 基准测试干到 75%,超过人类的 72.4%。我当天晚上就开始折腾 API,踩了不少坑,今天把完整的接入流程和代码分享出来。

先说结论

特性 GPT-5.4 Computer Use Claude Computer Use
OSWorld 准确率 75.0% 22.0%
上下文窗口 1M tokens 200K tokens
响应速度 较快(结构化动作) 较慢(截图循环多)
API 价格 $2.50/M 输入 $3/M 输入
适用场景 浏览器自动化/桌面操控 通用桌面操控

简单说:GPT-5.4 的 Computer Use 目前是最能打的方案,尤其适合浏览器自动化场景。

什么是 Computer Use?

Computer Use 不是传统意义上的 Selenium/Playwright 自动化。传统方案你需要写选择器、处理各种异常,一旦页面改版就全废了。

GPT-5.4 的 Computer Use 完全不同——它看截图,然后告诉你该点哪里、该输入什么。就像一个远程协助的真人,只不过反应速度比真人快几十倍。

工作流程是这样的:

你发任务 → 模型看截图 → 返回操作指令(点击/输入/滚动)
    ↑                                    ↓
    └──── 执行操作,再截一张图 ←──────────┘

这个循环一直转,直到任务完成。

第 1 步:环境准备

安装依赖

pip install openai playwright
playwright install chromium

配置 API Key

export OPENAI_API_KEY="sk-proj-xxxxx"

如果你在国内,直接调 OpenAI 官方 API 延迟会比较高,可以用兼容 OpenAI 协议的中转服务,改个 base_url 就行:

from openai import OpenAI

# 方式一:直连 OpenAI(需要网络条件)
client = OpenAI()

# 方式二:用 ofox.ai 的聚合接口,国内直连低延迟
client = OpenAI(
    base_url="https://api.ofox.ai/v1",
    api_key="你的 ofox key"
)

启动隔离浏览器

这一步很关键——一定要用隔离环境。Computer Use 会操控你的浏览器,如果用日常浏览器,AI 一不小心就把你的 GitHub 仓库删了(别问我怎么知道的)。

from playwright.async_api import async_playwright
import asyncio

async def launch_browser():
    pw = await async_playwright().start()
    browser = await pw.chromium.launch(
        headless=False,  # 设 True 跑生产,False 方便调试
        args=[
            "--disable-extensions",
            "--no-first-run",
            "--disable-default-apps",
        ]
    )
    context = await browser.new_context(
        viewport={"width": 1440, "height": 900}  # 推荐分辨率
    )
    page = await context.new_page()
    return pw, browser, page

为什么推荐 1440x900?因为 OpenAI 官方文档明确说这个分辨率下模型的点击准确率最高。

第 2 步:核心代码——截图-操作循环

这是完整的 Computer Use 调用代码:

import base64
import asyncio
from openai import OpenAI
from playwright.async_api import async_playwright

client = OpenAI()  # 或指向你的中转服务

async def take_screenshot(page) -> str:
    """截图并转 base64"""
    screenshot_bytes = await page.screenshot()
    return base64.b64encode(screenshot_bytes).decode("utf-8")

async def execute_action(page, action):
    """执行模型返回的操作指令"""
    action_type = action.get("type")

    if action_type == "click":
        x, y = action["x"], action["y"]
        button = action.get("button", "left")
        await page.mouse.click(x, y, button=button)
        print(f"  🖱️ 点击 ({x}, {y})")

    elif action_type == "type":
        text = action["text"]
        await page.keyboard.type(text, delay=50)
        print(f"  ⌨️ 输入: {text[:30]}...")

    elif action_type == "keypress":
        keys = action["keys"]
        for key in keys:
            await page.keyboard.press(key)
        print(f"  ⌨️ 按键: {keys}")

    elif action_type == "scroll":
        x = action.get("x", 0)
        y = action.get("y", 0)
        await page.mouse.wheel(x, y)
        print(f"  📜 滚动 ({x}, {y})")

    elif action_type == "drag":
        start = action["start"]
        end = action["end"]
        await page.mouse.move(start["x"], start["y"])
        await page.mouse.down()
        await page.mouse.move(end["x"], end["y"])
        await page.mouse.up()
        print(f"  🔀 拖拽 ({start['x']},{start['y']}) → ({end['x']},{end['y']})")

    elif action_type == "wait":
        ms = action.get("ms", 1000)
        await asyncio.sleep(ms / 1000)
        print(f"  ⏳ 等待 {ms}ms")

    elif action_type == "screenshot":
        print("  📸 模型请求截图")

async def computer_use_loop(page, task: str, max_turns: int = 20):
    """Computer Use 主循环"""

    # 第一步:截图 + 发送任务
    screenshot_b64 = await take_screenshot(page)

    response = client.responses.create(
        model="gpt-5.4",
        tools=[{"type": "computer"}],
        input=[
            {
                "role": "user",
                "content": task
            }
        ],
        # 附带初始截图
        truncation="auto"
    )

    for turn in range(max_turns):
        # 检查是否有 computer_call
        computer_calls = [
            item for item in response.output
            if item.type == "computer_call"
        ]

        if not computer_calls:
            # 没有操作指令了,任务可能完成
            text_outputs = [
                item for item in response.output
                if hasattr(item, "content")
            ]
            if text_outputs:
                print(f"\n✅ 任务完成!模型回复:")
                for t in text_outputs:
                    print(t.content)
            break

        # 执行所有操作
        for call in computer_calls:
            print(f"\n[Turn {turn + 1}] 执行操作批次:")
            for action in call.actions:
                await execute_action(page, action)
                await asyncio.sleep(0.3)  # 操作间隔,模拟真人

            # 执行完截图,发回给模型
            await asyncio.sleep(1)  # 等页面渲染
            screenshot_b64 = await take_screenshot(page)

            response = client.responses.create(
                model="gpt-5.4",
                previous_response_id=response.id,
                input=[{
                    "type": "computer_call_output",
                    "call_id": call.call_id,
                    "output": {
                        "type": "computer_screenshot",
                        "image_url": f"data:image/png;base64,{screenshot_b64}",
                        "detail": "original"  # 保持原始分辨率
                    }
                }]
            )
    else:
        print("⚠️ 达到最大轮次限制")

# 使用示例
async def main():
    pw, browser, page = await launch_browser()

    await page.goto("https://www.google.com")
    await asyncio.sleep(2)

    await computer_use_loop(
        page,
        "搜索 'Python FastAPI tutorial 2026',打开第一个结果,总结页面内容"
    )

    await browser.close()
    await pw.stop()

asyncio.run(main())

第 3 步:实际跑起来看效果

我用上面的代码跑了几个实际场景:

场景 1:自动填写表单

await computer_use_loop(
    page,
    "打开 https://httpbin.org/forms/post,填写表单:Customer 填 'Zhang San',Size 选 Medium,Topping 选 Bacon,点击提交"
)

模型一共用了 4 轮截图循环就搞定了:截图→定位输入框→逐个填写→点提交。全程大概 15 秒。

场景 2:抓取动态加载的数据

之前用 Selenium 写爬虫,最烦的就是等 AJAX 加载完成。Computer Use 天然解决这个问题——它看截图判断页面是否加载完,不用写一堆 WebDriverWait

await computer_use_loop(
    page,
    "打开 GitHub Trending 页面,找到今天 Star 最多的 Python 项目,告诉我项目名和 Star 数"
)

场景 3:跨页面工作流

await computer_use_loop(
    page,
    """
    1. 打开 GitHub,搜索 'fastapi'
    2. 进入 tiangolo/fastapi 仓库
    3. 查看最新的 Release 版本号
    4. 回到搜索结果,查看第二个结果的 Star 数
    5. 对比两个项目,告诉我哪个更活跃
    """
)

这种多步骤跨页面的任务,传统自动化写起来巨麻烦,Computer Use 就是一段自然语言描述。

踩坑记录

坑 1:分辨率很重要

一开始我用 1920x1080,模型经常点歪,后来换成 1440x900 好了很多。OpenAI 文档里说可以缩放截图,但一定要重新映射坐标

# 如果你缩放了截图,坐标也要按比例调整
scale_x = original_width / screenshot_width
scale_y = original_height / screenshot_height
actual_x = action["x"] * scale_x
actual_y = action["y"] * scale_y

坑 2:detail 参数别省

发送截图的时候,detail 一定要设成 "original",不然模型看到的是压缩后的模糊图,点击位置会偏。虽然 "original" 会多消耗 token,但省这点钱不值得。

坑 3:操作间隔不能太快

每个操作之间至少加 300ms 延迟。不是因为模型需要,而是浏览器渲染需要时间。点击一个按钮后页面可能要弹窗、跳转、加载数据,太快截图会截到中间状态,模型就懵了。

坑 4:安全隔离是认真的

千万不要在你的日常浏览器里跑 Computer Use。用一个干净的 Playwright 浏览器实例,或者更稳妥——用 Docker 跑个隔离环境:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y xvfb x11vnc firefox xdotool
ENV DISPLAY=:99
CMD Xvfb :99 -screen 0 1440x900x24 & x11vnc -display :99 -nopw -forever &
EXPOSE 5900

然后 VNC 连进去观察 AI 在干嘛,眼见为实。

坑 5:费用控制

Computer Use 每轮都要发截图(按 image_url 计费),一个复杂任务跑 15-20 轮,token 消耗蹭蹭涨。建议:

  • 简单任务设 max_turns=10
  • 截图前缩放到合理尺寸(但不能太小)
  • previous_response_id 利用缓存,缓存命中的输入 token 只要 $0.25/M,比原价省 90%

GPT-5.4 三个版本怎么选?

版本 价格 (输入/输出) 适用场景
GPT-5.4 2.50/2.50 / 15 日常 Computer Use,性价比最高
GPT-5.4 Thinking 同上 + 推理token 复杂决策、多步规划
GPT-5.4 Pro 30/30 / 180 极端复杂任务,一般用不上

我的建议是先用标准版跑,90% 的场景够用了。只有那种需要"想一想再做"的复杂工作流,才需要 Thinking 版。

小结

GPT-5.4 的 Computer Use 确实是一个质变——从"AI 只能聊天"到"AI 能帮你操作电脑"。虽然现在还有一些限制(延迟、费用、偶尔点歪),但已经能覆盖很多实际场景了:表单填写、数据抓取、跨应用工作流。

我现在已经把一些重复性的浏览器操作都用 Computer Use 自动化了,真的省了不少时间。如果你也想尝试,建议先从简单的单页面任务开始,熟悉了截图-操作的循环模式,再逐步上复杂场景。

完整代码我放在 Gist 上了,有问题评论区见 👇

Service Worker 离线缓存这事,没你想的那么简单

Service Worker 离线缓存这事,没你想的那么简单

上个月接了个需求:把公司的 B 端管理系统做成"弱网可用"。产品说得轻巧——"加个离线缓存就行了嘛"。

我当时心想,行,上 Workbox,配几个路由策略,半天搞定。

结果呢?搞了整整一周。

问题不在"能不能缓存",而在"缓存了之后怎么更新"。用户打开页面用的是旧版本、新版本发上去了但 SW 还抱着老文件不放、偶尔还会出现半新半旧的"弗兰肯斯坦"状态——页面一半是新的一半是旧的,直接白屏。

这篇聊聊我最后是怎么用 Workbox 把这套离线缓存做到"能用、能更新、不炸"的。

先搞清楚 SW 的更新机制,不然后面全是坑

很多人对 Service Worker 的生命周期理解停留在 install → activate → fetch,觉得新文件上去了浏览器自动就换了。

没那么简单。

// SW 更新的真实流程:
// 1. 浏览器发现 sw.js 文件内容变了(逐字节比对)
// 2. 下载新 SW,触发 install 事件
// 3. 新 SW 进入 waiting 状态 —— 注意,不是直接激活
// 4. 等所有标签页都关了,新 SW 才 activate
// 5. 下次打开页面,才用新的缓存

// 问题来了:用户不关标签页怎么办?
// 答:新 SW 就一直 waiting,用户一直用旧缓存

这就是经典的"我明明发了新版本,用户看到的还是旧的"。

很多文章教你在 install 里加 skipWaiting(),activate 里加 clients.claim(),一步到位。

self.addEventListener('install', () => {
  self.skipWaiting() // 跳过 waiting,直接激活
})

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim()) // 立刻接管所有页面
})

能用,但粗暴。想象一下:用户正在填一个复杂表单,填了半天,SW 突然切了,页面资源全换成新版本,某个接口的响应格式变了——表单直接废了。B 端系统这么搞,会被投诉的。

用 Workbox 搭一套分层缓存策略

Workbox 提供了五种缓存策略,但不是选一种就完事了。不同资源该用不同策略,这事得想清楚。

我最后的分层方案长这样:

import { precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import {
  CacheFirst,
  StaleWhileRevalidate,
  NetworkFirst,
} from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'

// 第一层:构建产物 → precache(预缓存)
// hash 文件名的 JS/CSS,内容变了 hash 就变,天然版本控制
precacheAndRoute(self.__WB_MANIFEST)

// 第二层:图片/字体等静态资源 → CacheFirst
// 这些东西基本不变,命中缓存直接用,省带宽
registerRoute(
  ({ request }) =>
    request.destination === 'image' ||
    request.destination === 'font',
  new CacheFirst({
    cacheName: 'static-assets-v1',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,       // 最多缓存 100 个
        maxAgeSeconds: 30 * 24 * 3600, // 30 天过期
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200],    // 0 是 opaque response,跨域资源
      }),
    ],
  })
)

// 第三层:API 请求 → NetworkFirst
// 优先拿新数据,网络挂了才用缓存兜底
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3, // 3 秒没响应就用缓存
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // API 缓存只留 5 分钟
      }),
    ],
  })
)

// 第四层:HTML 页面 → StaleWhileRevalidate
// 先给旧的用着,后台偷偷更新
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new StaleWhileRevalidate({
    cacheName: 'pages-cache',
  })
)

三个关键决策说一下:

API 用 NetworkFirst 而不是 StaleWhileRevalidate。 B 端系统数据一致性很重要,审批状态、订单数据这些,给用户看过期的可能出事。宁可慢一点,也要优先拿最新的。

HTML 用 StaleWhileRevalidate。 这里比较纠结,我一开始用的 NetworkFirst,但弱网下页面加载体验太差。后来改成先给旧页面、后台更新,配合后面说的版本控制机制,体验好了不少。

静态资源设了 maxEntries 上限。 之前没设,缓存越积越多,有个用户的 Cache Storage 膨胀到 800MB,手机直接卡死。

版本控制:怎么让更新不翻车

分层缓存解决了"缓存什么"的问题,但核心难题还没解决:怎么让新版本平滑上去,不出现半新半旧的状态?

Workbox 的 precache 机制本身带版本控制。构建时会生成一个 manifest:

// 构建产物大概长这样:
self.__WB_MANIFEST = [
  { url: '/js/app.3a7b2c.js', revision: null },  // 文件名带 hash,revision 不需要
  { url: '/js/vendor.9f8e1d.js', revision: null },
  { url: '/index.html', revision: 'v28' },         // 没 hash 的文件需要 revision
  { url: '/manifest.json', revision: 'v3' },
]

文件名带 hash 的,内容一变 hash 就变,precache 自动处理增量更新——只下载变了的文件,没变的直接跳过。这部分 Workbox 做得挺好,不用操心。

麻烦的是 index.html 这类没有 hash 的文件。revision 字段本质上是内容的 hash,靠构建工具生成。但问题在于,index.html 是入口,它引用了哪些 JS/CSS 文件决定了用户加载哪个版本。

如果 SW 更新了 JS 但还在用旧的 index.html,旧 HTML 里引用的是旧 JS hash,新 JS 缓存了但压根不会被加载——经典的版本不一致。

我的处理方式是,在主线程加一层更新检测:

// main.ts —— 应用入口
if ('serviceWorker' in navigator) {
  const registration = await navigator.serviceWorker.register('/sw.js')

  // 检测到新 SW 在 waiting
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing
    if (!newWorker) return

    newWorker.addEventListener('statechange', () => {
      if (
        newWorker.state === 'installed' &&
        navigator.serviceWorker.controller // 说明不是首次安装
      ) {
        // 新版本就绪,通知用户
        showUpdateNotification({
          onConfirm: () => {
            newWorker.postMessage({ type: 'SKIP_WAITING' })
          },
        })
      }
    })
  })

  // SW 控制权切换后刷新页面
  let refreshing = false
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (refreshing) return
    refreshing = true
    window.location.reload() // 刷新拿新资源
  })
}

SW 那边对应地处理消息:

// sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting() // 用户确认后才跳过 waiting
  }
})

核心思路:不自动 skipWaiting,让用户决定什么时候更新。

弹个不起眼的提示条——"有新版本可用,点击刷新",用户手头事忙完了自己点,不打断操作流。这比强制刷新友好太多了。

增量更新:别让用户每次都全量下载

Precache 的增量更新是文件级别的:100 个文件只改了 3 个,就只下载那 3 个。但有个前提——你的构建配置得配合

踩过一个坑:项目用 Vite 打包,每次构建所有 chunk 的 hash 都变了。明明只改了一行代码,用户得重新下载全部 JS。

原因是 Vite 默认的 manualChunks 配置没做好,所有代码打成几个大 chunk,任何改动都会导致 chunk 内容变化。

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 细粒度拆包,让改动的影响范围最小化
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把大的第三方库单独拆出来
            if (id.includes('echarts')) return 'vendor-echarts'
            if (id.includes('lodash')) return 'vendor-lodash'
            if (id.includes('antd') || id.includes('ant-design'))
              return 'vendor-antd'
            return 'vendor' // 其余第三方统一放
          }
          // 业务代码按路由拆
        },
      },
    },
  },
})

拆完之后效果明显:改一个页面组件,只有对应的路由 chunk 的 hash 变了,其他 chunk 不受影响。增量更新从"几乎全量"变成了"真的增量"。

还有一个容易忽略的点:运行时缓存(Runtime Cache)没有增量更新的概念。CacheFirst 策略下,一个 2MB 的图片只要 URL 没变就永远用缓存。这大部分时候是对的,但如果你的图片 URL 不带版本号,改了图片但 URL 一样,用户永远看到旧图。

解法也简单,要么 URL 带 hash/版本号,要么把这类资源的策略从 CacheFirst 改成 StaleWhileRevalidate。

缓存清理:没人提但迟早会炸的事

缓存只进不出,Storage 迟早满。浏览器对 Cache Storage 有配额限制(Chrome 大概是磁盘空间的 60%,但不保证),超了会整个 origin 的数据被清——包括 IndexedDB、localStorage,全没。

ExpirationPlugin 能解决一部分问题,但老版本的 precache 缓存不会自动清理。

Workbox 的 precache 在 activate 阶段会清理旧版本的缓存条目,这部分是自动的。但如果你手动管理了一些缓存,或者 cacheName 改了(比如从 static-assets-v1 升到 v2),旧的 cache 不会自己消失。

// sw.js activate 阶段,手动清理废弃的 cache
self.addEventListener('activate', (event) => {
  const currentCaches = [
    'static-assets-v2',  // 当前版本
    'api-cache',
    'pages-cache',
  ]

  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames
          .filter((name) => !currentCaches.includes(name))
          .filter((name) => !name.startsWith('workbox-precache')) // precache 的让 Workbox 自己管
          .map((name) => {
            console.log('[SW] 删除旧缓存:', name)
            return caches.delete(name)
          })
      )
    )
  )
})

另外一个实用的做法是加个 Storage 用量监控,快满的时候主动清理低优先级缓存:

async function checkStorageQuota() {
  if (!navigator.storage?.estimate) return

  const { usage, quota } = await navigator.storage.estimate()
  const usageRatio = (usage || 0) / (quota || 1)

  if (usageRatio > 0.8) {
    // 用了 80% 以上,清掉过期的运行时缓存
    const cache = await caches.open('static-assets-v2')
    const keys = await cache.keys()
    // 按时间删掉最老的一半
    const toDelete = keys.slice(0, Math.floor(keys.length / 2))
    await Promise.all(toDelete.map((key) => cache.delete(key)))
  }
}

灰度更新:线上不敢一把梭的时候

这是后来加的需求。有一次发版改了个核心组件,结果新版本有 bug,但 SW 已经把新资源 precache 了,用户刷新就加载新版本——回都回不来。

后来加了个简单的灰度机制。SW 安装前先问服务端:"我该不该用新版本?"

// sw.js install 阶段
self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const resp = await fetch('/api/sw-config').catch(() => null)

      if (resp?.ok) {
        const config = await resp.json()
        // { version: "2.3.1", rolloutPercent: 30, forceUpdate: false }

        if (!shouldActivate(config)) {
          // 不在灰度范围内,不装新版本
          // 注意:这里不调 skipWaiting,新 SW 会被丢弃
          return
        }
      }

      // 正常执行 precache
      // workbox 的 precache 逻辑在这之后
    })()
  )
})

function shouldActivate(config) {
  // 用 clientId 或者随机数做灰度分桶
  const bucket = Math.random() * 100
  return bucket < config.rolloutPercent
}

说实话这个方案有点糙。Math.random() 每次 install 都重新算,同一个用户可能一会在灰度内一会不在。更好的做法是用 IndexedDB 存一个固定的 clientId 做分桶。但对于我们当时的场景(内部 B 端系统,用户量不大),够用了。

有个问题我到现在也没完全想明白

SW 的 install 事件里如果 precache 失败了(比如某个文件 404),整个 SW 安装就失败了。这意味着一个文件挂了,所有缓存更新都不生效

Workbox 没有提供"部分成功"的能力。要么全装,要么不装。

这在 CDN 发布的时候偶尔会出问题——新文件还没全部同步到 CDN 节点,SW 就开始装了,某个文件 404,安装失败,用户卡在旧版本。下次再访问的时候可能 CDN 同步好了,又能装成功了。但这个时间窗口里的用户体验是不可控的。

我的临时方案是 precache 的文件列表尽量精简,只放入口必须的文件,其他的用运行时缓存按需加载。减少 precache 失败的概率。但根本问题还是没解决。如果有人有更好的方案,真的想听听。

聊到这

SW 离线缓存这套东西,原理不复杂,但工程化做起来全是细节。分层策略、版本控制、增量更新、缓存清理、灰度发布——每一块都不难,串起来就有得折腾了。

我的经验是:先把更新机制想清楚,再去配缓存策略。 大部分线上事故不是"缓存没命中",而是"缓存了但更新不了"。

还有一点,workbox-webpack-pluginvite-plugin-pwa 能帮你省掉很多手动配置的活,但别完全当黑盒用。至少把生成的 sw.js 打开看一眼,知道它干了什么。不然出了问题连排查方向都没有。

使用 clip-path: shape() 创建 Squircle 形状

你是否厌倦了传统的方形和圆形元素?想要为你的网页或设计增添一些独特的曲线美?今天,就让我们一起来探索如何使用CSS的clip-path属性来创建一个时尚的 Squircle 形状吧!

什么是Squircle?

Squircle,顾名思义,是Square(方形)和Circle(圆形)的结合体。它既有方形的棱角感,又融入了圆形的柔和曲线,给人一种既现代又舒适的视觉感受。

核心思路

由于corner-shape(专门实现Squircle的属性)目前浏览器支持度有限,我们通过clip-path: shape()来模拟实现,核心是用CSS变量统一控制弧度,通过数学计算让边角的曲线过渡更自然,变量可直接用百分比或像素值,灵活度拉满。

完整实现代码

直接复制这段代码,就能快速实现基础的Squircle效果,关键变量--r可直接调整,0%为纯正方形,50%为极致的Squircle效果,中间数值可按需自定义。

/* 基础Squircle样式,直接复用 */
.squircle {
  --r: 50%; /* 控制弧度,0%=正方形,50%=Squircle,支持像素值如20px */
  --_r: clamp(0%,var(--r)/2,25%);
  --_v: calc(var(--_r)*(1 - sqrt(2)/4));
  --_p: calc(var(--_v) - var(--_r)/2);
  clip-path: shape(
    from var(--_v) var(--_p),
    curve to 50% 0 with var(--_r) 0,
    curve to calc(100% - var(--_v)) var(--_p) with calc(100% - var(--_r)) 0,
    curve to calc(100% - var(--_p)) var(--_v) with calc(100% - 2*var(--_p)) calc(2*var(--_p)),
    curve to 100% 50% with 100% var(--_r),
    curve to calc(100% - var(--_p)) calc(100% - var(--_v)) with 100% calc(100% - var(--_r)),
    curve to calc(100% - var(--_v)) calc(100% - var(--_p)) with calc(100% - 2*var(--_p)) calc(100% - 2*var(--_p)),
    curve to 50% 100% with calc(100% - var(--_r)) 100%,
    curve to var(--_v) calc(100% - var(--_p)) with var(--_r) 100%,
    curve to var(--_p) calc(100% - var(--_v)) with calc(2*var(--_p)) calc(100% - 2*var(--_p)),
    curve to 0 50% with 0 calc(100% - var(--_r)),
    curve to var(--_p) var(--_v) with 0 var(--_r),
    curve to var(--_v) var(--_p) with calc(2*var(--_p)) calc(2*var(--_p))
  );
}

Demo 地址:codepen.io/editor/aire…

对比演示:clip-path实现 vs corner-shape原生

为了让大家直观看到效果差异,我们做一个可交互的对比demo,一边是clip-path: shape()的模拟实现,一边是corner-shape的原生实现,还能通过滑块实时调整弧度,代码如下(可直接运行测试)。

.squircle {
  --r: 40%;
  --_r: clamp(0%,var(--r)/2,25%);
  --_v: calc(var(--_r)*(1 - sqrt(2)/4));
  --_p: calc(var(--_v) - var(--_r)/2);
        clip-path: shape(
    from var(--_v) var(--_p),
    curve to 50% 0 with var(--_r) 0,
    curve to calc(100% - var(--_v)) var(--_p) with calc(100% - var(--_r)) 0,
    curve to calc(100% - var(--_p)) var(--_v) with calc(100% - 2*var(--_p)) calc(2*var(--_p)),
    curve to 100% 50% with 100% var(--_r),
    curve to calc(100% - var(--_p)) calc(100% - var(--_v)) with 100% calc(100% - var(--_r)),
    curve to calc(100% - var(--_v)) calc(100% - var(--_p)) with calc(100% - 2*var(--_p)) calc(100% - 2*var(--_p)),
    curve to 50% 100% with calc(100% - var(--_r)) 100%,
    curve to var(--_v) calc(100% - var(--_p)) with var(--_r) 100%,
    curve to var(--_p) calc(100% - var(--_v)) with calc(2*var(--_p)) calc(100% - 2*var(--_p)),
    curve to 0 50% with 0 calc(100% - var(--_r)),
    curve to var(--_p) var(--_v) with 0 var(--_r),
    curve to var(--_v) var(--_p) with calc(2*var(--_p)) calc(2*var(--_p))
  );
}

.corner-shape {
  --r: 40%;
  border-radius: var(--r);
  corner-shape: squircle;
}

Demo 地址:codepen.io/editor/aire…

总结

这次的技巧核心是用clip-path: shape()弥补corner-shape的兼容性不足,通过 CSS 变量让 Squircle 效果的调节更简单,一行代码修改弧度,适配各类前端样式开发需求。

这种丝滑的圆角效果,用在按钮、卡片、头像等元素上,能让页面的视觉质感提升一个档次,大家赶紧把代码收藏起来,下次开发直接复用~

扩展阅读

React Hook 到底是干嘛的?

一、什么是 React Hook?

React Hook,本质上是一套 让函数组件拥有更多能力的机制

你可以先记住一句最核心的话:

Hook 的作用,就是让函数组件也能拥有状态、生命周期、副作用处理、逻辑复用等能力。

通俗一点说:

以前 React 的函数组件,只会干一件事:

根据数据,把页面渲染出来。

但是现实开发中,一个组件往往不只是“显示页面”这么简单,它还需要做很多事情,比如:

  • 记住用户输入的内容
  • 控制弹窗开关
  • 发送接口请求
  • 监听页面变化
  • 获取 DOM 元素
  • 复用一段公共逻辑

而 Hook,就是 React 提供给函数组件的一套“能力插件”。

你可以把它理解成:

Hook = 给函数组件装功能的工具箱。

二、为什么会有 Hook?

要理解 Hook,先得知道 React 以前是怎么写的。

在 Hook 出现之前,React 中如果你想让组件拥有状态、生命周期这些能力,通常要使用 类组件(Class Component)

比如一个最简单的计数器,早期可能要这样写:

import React, { Component } from "react";

class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  render() {
    return (
      <button onClick={() => this.setState({ count: this.state.count + 1 })}>
        点击了 {this.state.count} 次
      </button>
    );
  }
}

这段代码没有错,但很多初学者会觉得有点麻烦:

  • 要写 class
  • 要写 constructor
  • 要写 super
  • 要写 this.state
  • 要写 this.setState
  • this 指向有时候还容易出问题

随着项目越来越复杂,类组件还会涉及各种生命周期函数,比如:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

代码会越来越分散,逻辑也越来越不容易维护。

于是 React 官方推出了 Hook。

它的目标非常明确:

让函数组件也能完成类组件的大部分能力,而且写起来更简洁、更清晰、更容易复用逻辑。

三、你可以怎么理解 Hook?

你可以把一个 React 组件想象成一个员工。

一开始,这个员工只会一件事:

把页面画出来。

比如:

function Hello() {
  return <h1>Hello React</h1>;
}

这就是一个最普通的函数组件。

但如果你想让这个员工更能干一点,比如:

  • 记住一个数字
  • 页面加载后发请求
  • 监听窗口变化
  • 找到某个输入框并让它自动获取焦点

那就需要给他配工具。

而 Hook,就是这些工具。

比如:

  • useState:给员工一个记事本,让他能记住东西
  • useEffect:给员工一个任务清单,让他在页面渲染后做额外事情
  • useRef:给员工一个抽屉,可以放东西,也能找到页面里的某个元素

所以,Hook 并不神秘。

它就是:

让函数组件从“只能展示页面”,升级成“能真正干活的组件”。

四、最常见的 Hook:useState

在 React 中,最常用的 Hook 之一,就是 useState

它的作用非常简单:

让组件记住一个值。

比如,我们写一个最基础的计数器:

import React, { useState } from "react";

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

  return (
    <button onClick={() => setCount(count + 1)}>
      点击了 {count} 次
    </button>
  );
}

export default Counter;

这段代码怎么理解?

最关键的是这句:

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

你可以把它翻译成一句人话:

React,帮我准备一个状态,它的初始值是 0,当前值叫 count,修改它的方法叫 setCount。

于是:

  • count 是当前的状态值
  • setCount 是修改状态的方法
  • useState(0) 里的 0 是初始值

点击按钮后,执行:

setCount(count + 1);

React 就会更新状态,然后重新渲染页面。

为什么不能直接用普通变量?

很多初学者可能会写出这样的代码:

function Counter() {
  let count = 0;

  return (
    <button onClick={() => count++}>
      点击了 {count} 次
    </button>
  );
}

看起来你在修改 count,但页面并不会按预期更新。

原因很简单:

普通变量的变化,React 感知不到。

React 只会对“状态”的变化做出响应,而 useState 正是告诉 React:

这个值是组件状态,请你帮我管理它。

所以你可以这样记:

普通变量是你自己偷偷记,React 不知道;useState 是你正式告诉 React,这个值要参与页面更新。

五、第二个非常重要的 Hook:useEffect

除了状态,组件还经常需要做一些“额外的事情”。

比如:

  • 页面加载后请求接口
  • 设置定时器
  • 监听滚动事件
  • 修改页面标题
  • 组件销毁时做清理

这些事情并不是“渲染 UI”本身,而是渲染之外的行为。

React 把这类操作叫做 副作用(Effect)

听起来有点专业,其实你完全可以把它理解成:

组件渲染完后,顺手做的事。

这时候就要用到 useEffect

先看一个例子:

import React, { useEffect } from "react";

function Demo() {
  useEffect(() => {
    console.log("组件渲染完成了");
  }, []);

  return <div>你好,React Hook</div>;
}

export default Demo;

这段代码的意思就是:

页面渲染出来以后,执行一次里面的代码。

[] 是什么意思?

很多人第一次学 useEffect,最容易懵的地方就是第二个参数。

useEffect(() => {
  console.log("执行了");
}, []);

这里的 [] 叫做 依赖数组

它决定这个副作用什么时候执行。

1. 传空数组 []

useEffect(() => {
  console.log("只执行一次");
}, []);

表示:

组件第一次渲染完成后执行一次,以后不再执行。

这很像类组件里的 componentDidMount

2. 不传第二个参数

useEffect(() => {
  console.log("每次渲染都执行");
});

表示:

组件每次渲染后都会执行。

3. 传入依赖项

useEffect(() => {
  console.log("count 变化了");
}, [count]);

表示:

首次渲染会执行,以后只有 count 变化时才执行。

这个机制非常重要,写业务代码时经常会用到。

六、useEffect 最经典的应用:请求接口

比如页面加载后获取用户列表:

import React, { useEffect, useState } from "react";

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
      });
  }, []);

  return (
    <div>
      <h2>用户列表</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default UserList;

逻辑很清晰:

  1. useState([]) 准备一个用户列表状态
  2. useEffect 在组件第一次渲染后发请求
  3. 请求回来后通过 setUsers(data) 更新状态
  4. 页面自动重新渲染,显示数据

这就是 Hook 配合使用的典型场景。

七、第三个常见 Hook:useRef

接下来再说一个很常用的 Hook:useRef

它主要有两个用途:

  • 获取 DOM 元素
  • 保存一个不会触发页面重新渲染的值

先看第一个用途:获取 DOM。

比如页面加载后让输入框自动聚焦:

import React, { useEffect, useRef } from "react";

function InputFocus() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} placeholder="请输入内容" />;
}

export default InputFocus;

这里的逻辑是:

  • useRef(null) 创建一个引用对象
  • ref={inputRef} 把这个引用绑定到 input 上
  • 页面渲染后,通过 inputRef.current 拿到真实 DOM
  • 调用 focus() 让输入框自动获取焦点

你可以把 useRef 理解成:

给页面元素贴了个标签,方便以后找到它。

useRef 的第二个用途

useRef 还可以用来保存一些值,而且这些值变化时不会导致页面重新渲染。

比如保存一个定时器 id:

import React, { useEffect, useRef } from "react";

function TimerDemo() {
  const timerRef = useRef(null);

  useEffect(() => {
    timerRef.current = setInterval(() => {
      console.log("定时器执行中");
    }, 1000);

    return () => {
      clearInterval(timerRef.current);
    };
  }, []);

  return <div>定时器示例</div>;
}

export default TimerDemo;

这里的 timerRef.current 就像一个小盒子,可以存放数据。

useState 的区别是:

  • useState 变化会触发重新渲染
  • useRef 变化不会触发重新渲染

所以如果你只是想保存一个值,但这个值不需要展示到页面上,useRef 很合适。

八、React Hook 到底解决了什么问题?

这是很多面试中也会问到的问题。

React Hook 主要解决了三个问题。

1. 让函数组件拥有状态和副作用能力

以前函数组件只能负责展示 UI,复杂逻辑很多都要写在类组件里。

Hook 出现后,函数组件也能做这些事了,开发体验更统一。

2. 逻辑复用更方便

以前如果你想复用一段组件逻辑,常见方法有:

  • mixin
  • 高阶组件(HOC)
  • Render Props

这些方案不是不能用,但随着项目变复杂,代码嵌套会越来越深,理解成本也越来越高。

而 Hook 可以把一段逻辑直接提取成一个自定义 Hook。

比如获取窗口宽度:

import React, { useEffect, useState } from "react";

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => {
      setWidth(window.innerWidth);
    };

    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return width;
}

function Page() {
  const width = useWindowWidth();

  return <div>当前窗口宽度:{width}</div>;
}

export default Page;

这里 useWindowWidth() 就是一个自定义 Hook。

你会发现,这种写法真的很舒服:

  • 逻辑抽离清晰
  • 复用方便
  • 代码结构更自然

3. 让组件代码更简洁、更易维护

类组件中,一个功能的相关代码可能分散在多个生命周期里。

而用 Hook,可以把“相关逻辑”写在一起。

比如接口请求、事件绑定、清理逻辑,都可以围绕一个功能集中组织,这对维护大型项目非常友好。

九、为什么 Hook 都要以 use 开头?

你肯定发现了,React 官方提供的 Hook 都叫:

  • useState
  • useEffect
  • useRef
  • useMemo
  • useCallback
  • useContext

这是 React 的约定。

凡是 Hook,名字都要以 use 开头。

包括你自己写的自定义 Hook,也最好遵守这个规则:

function useWindowWidth() {
  // ...
}

为什么这么要求?

因为 React 和 ESLint 插件会根据 use 开头来识别:

这是不是一个 Hook。

这样可以更好地检查代码是否符合 Hook 的使用规则。

十、React Hook 的使用规则

Hook 虽然很好用,但它有两条非常重要的规则。

1. 只能在函数组件或自定义 Hook 中调用

你不能在普通的 JavaScript 函数中乱用 Hook。

错误示例:

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

这是不允许的。

正确示例:

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

或者:

function useCounter() {
  const [count, setCount] = useState(0);
  return { count, setCount };
}

2. 不要在条件语句、循环、嵌套函数中调用 Hook

错误示例:

function Demo({ flag }) {
  if (flag) {
    const [count, setCount] = useState(0);
  }

  return <div>Demo</div>;
}

为什么不行?

因为 React 是按照 Hook 的调用顺序来管理状态的。

如果你把 Hook 写在 if 里面,那么某次渲染执行了,某次渲染又没执行,顺序就乱了,React 就无法正确知道:

哪个 useState 对应哪个状态。

所以一定要记住:

Hook 要写在组件顶层,不能乱嵌套。

十一、再来理解一下 Hook 的本质

很多同学学 Hook 时,最容易卡住的一点是:

函数组件不是每次渲染都会重新执行吗?那它是怎么“记住状态”的?

这个问题问得特别好。

确实,函数组件每次渲染都会重新执行一遍。

但是 React 内部会帮你“记住”每个 Hook 对应的数据。

比如:

function Demo() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Tom");

  return <div>{count} - {name}</div>;
}

React 内部会根据 Hook 的调用顺序,记录:

  • 第一个 useState 对应 count
  • 第二个 useState 对应 name

下次渲染时,再按照同样顺序把数据取出来。

这就是为什么 Hook 必须按固定顺序调用。

所以从本质上说:

Hook 是 React 在函数组件中“挂接状态和副作用管理能力”的一种机制。

十二、常见 Hook 快速总结

下面把几个常见 Hook 用最简单的话概括一下。

1. useState

作用:管理状态

适合场景:

  • 计数器
  • 输入框内容
  • 弹窗开关
  • 列表数据
  • 当前页码

示例:

const [visible, setVisible] = useState(false);

2. useEffect

作用:处理副作用

适合场景:

  • 请求接口
  • 事件监听
  • 定时器
  • 手动操作 DOM
  • 修改页面标题

示例:

useEffect(() => {
  document.title = "首页";
}, []);

3. useRef

作用:获取 DOM 或保存不会引起重渲染的值

适合场景:

  • input 自动聚焦
  • 获取滚动容器
  • 保存定时器 id
  • 保存上一次的值

示例:

const inputRef = useRef(null);

4. useMemo

作用:缓存计算结果

适合场景:

  • 复杂计算
  • 避免重复计算
  • 优化性能

示例:

const total = useMemo(() => {
  return list.reduce((sum, item) => sum + item.price, 0);
}, [list]);

5. useCallback

作用:缓存函数

适合场景:

  • 把函数传给子组件时避免重复创建
  • 配合 React.memo 做性能优化

示例:

const handleClick = useCallback(() => {
  console.log("点击了");
}, []);

6. useContext

作用:跨层级共享数据

适合场景:

  • 主题切换
  • 用户信息共享
  • 全局配置共享

示例:

const theme = useContext(ThemeContext);

十三、初学者最容易犯的几个错误

1. 把 useEffect 当成“任何逻辑都往里塞”

有些初学者一学会 useEffect,就恨不得什么都丢进去。

其实不是所有逻辑都要写进 useEffect

原则是:

只有那些“渲染之后要做的事”,才适合写进 useEffect。

如果只是简单计算数据,很多时候直接在组件里写就可以。

2. 在 useEffect 里漏掉依赖

比如:

useEffect(() => {
  console.log(count);
}, []);

如果你的副作用里用到了 count,通常就应该把它写进依赖数组里:

useEffect(() => {
  console.log(count);
}, [count]);

否则可能会出现数据不是最新值的问题。

3. 把 useRef 和 useState 搞混

记住一句非常关键的话:

  • 需要更新页面的,用 useState
  • 只是存值但不需要更新页面的,用 useRef

这个区别一定要分清。

4. 在 if 中使用 Hook

这个是典型错误。

if (flag) {
  useEffect(() => {}, []);
}

千万别这么写。

Hook 一定要放在组件最外层。

十四、一个完整小案例:用 Hook 写一个待办事项列表

下面我们用 Hook 写一个简单的 Todo List,帮助你把 useStateuseEffect 串起来理解。

import React, { useEffect, useState } from "react";

function TodoApp() {
  const [inputValue, setInputValue] = useState("");
  const [list, setList] = useState([]);

  useEffect(() => {
    const localData = localStorage.getItem("todo-list");
    if (localData) {
      setList(JSON.parse(localData));
    }
  }, []);

  useEffect(() => {
    localStorage.setItem("todo-list", JSON.stringify(list));
  }, [list]);

  const handleAdd = () => {
    if (!inputValue.trim()) return;
    setList([...list, inputValue]);
    setInputValue("");
  };

  const handleDelete = (index) => {
    const newList = list.filter((_, i) => i !== index);
    setList(newList);
  };

  return (
    <div>
      <h2>Todo List</h2>
      <input
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="请输入待办事项"
      />
      <button onClick={handleAdd}>添加</button>

      <ul>
        {list.map((item, index) => (
          <li key={index}>
            {item}
            <button onClick={() => handleDelete(index)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoApp;

这个案例里用到了什么?

useState

管理两个状态:

  • 输入框内容 inputValue
  • 待办列表 list

第一个 useEffect

页面第一次加载时,从本地缓存读取数据。

第二个 useEffect

每次 list 变化时,把最新数据存到 localStorage

这个例子特别适合初学者练手,因为它同时涉及:

  • 表单输入
  • 列表渲染
  • 状态更新
  • 副作用处理
  • 本地存储

十五、React Hook 和类组件相比,到底哪个好?

现在大多数新项目,几乎都更倾向于:

函数组件 + Hook

原因很简单。

Hook 的优点

1. 代码更简洁

不用写很多类组件模板代码。

2. 逻辑更聚合

一个功能相关的代码可以写在一起,不容易分散。

3. 更方便复用逻辑

自定义 Hook 非常适合抽离通用能力。

4. 更符合现在 React 的主流生态

很多现代 React 项目、组件库、教程,默认都基于 Hook。

当然,这并不是说类组件完全没用了。

只是从开发趋势来看,Hook 已经成为 React 的核心写法之一。

十六、面试中怎么回答“React Hook 是什么”?

如果你面试时被问到这个问题,可以参考下面这段回答:

React Hook 是 React 16.8 引入的一套新特性,它允许我们在函数组件中使用状态、生命周期、副作用处理、引用、上下文等能力。Hook 的出现让函数组件不再只是无状态组件,同时也让逻辑复用变得更加方便,比如可以通过自定义 Hook 抽离公共逻辑。常见的 Hook 有 useState、useEffect、useRef、useMemo、useCallback、useContext 等。

如果你想回答得更通俗一点,也可以说:

Hook 就是 React 给函数组件提供的一套能力扩展机制,让函数组件也能记数据、发请求、操作 DOM、复用逻辑。

十七、总结

学 React Hook,最重要的不是一开始就把所有 Hook 全背下来,而是先把最核心的三个搞懂:

  • useState
  • useEffect
  • useRef

你只要先彻底理解这三个,React Hook 的大门基本就打开了。

最后我们再用最简单的话总结一次:

useState 是什么?

让组件记住数据。

useEffect 是什么?

让组件在渲染后执行额外操作。

useRef 是什么?

让组件获取 DOM,或者保存不会触发重渲染的值。

Hook 是什么?

Hook 就是让 React 函数组件拥有状态管理、副作用处理、DOM 操作和逻辑复用能力的一套机制。

如果你之前一直觉得 Hook 很抽象,那看到这里,你至少应该已经明白:

Hook 并不是什么高深魔法,它就是 React 给函数组件配的工具箱。

十八、写在最后

对于 React 初学者来说,Hook 一开始确实会有点绕,尤其是:

  • 为什么要用 useState
  • 为什么 useEffect 有依赖数组
  • 为什么 Hook 不能写在 if 里
  • 为什么函数组件每次重新执行却还能记住状态

这些问题,几乎每个 React 学习者都会遇到。

但只要你多写几个小例子,比如:

  • 计数器
  • 输入框联动
  • Todo List
  • 页面请求数据
  • 输入框自动聚焦

Hook 很快就会从“抽象概念”变成“顺手工具”。

建议你下一步重点练这几个方向:

  1. useState 控制表单和列表
  2. useEffect 做接口请求和事件监听
  3. useRef 获取 DOM 和保存临时值
  4. 尝试自己写一个简单的自定义 Hook

当你把这些练熟以后,再去学 useMemouseCallbackuseContext,会轻松很多。

HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT

HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT

上个月灰度上了 HTTP/3,盯着 Grafana 看了一周的数据。LCP 掉了 200ms 左右,移动端弱网环境下收益更明显,某些场景甚至能砍掉 400ms。

但说实话,这个结果来之前我心里也没底。HTTP/3 的宣传材料看了不少,"队头阻塞解决了"、"握手更快了"——这些都是正确的废话。真正上线的时候,你关心的是:我的业务场景能吃到多少红利?哪些地方可能翻车?

这篇就聊聊实际落地的体感。

先说清楚 HTTP/3 改了什么

HTTP/2 的多路复用有个硬伤——它跑在 TCP 上。

TCP 是个"有序"协议,丢一个包,后面所有包都得等着。你在一条 TCP 连接上跑 6 个请求,其中一个请求丢了个包,其余 5 个请求也被卡住了。这就是 TCP 层面的队头阻塞。

TCP 连接(HTTP/2):
  请求A: [包1] [包2] [包3✗] ← 丢了
  请求B: [包1] [包2] ...等着  ← 被连坐
  请求C: [包1] ...等着        ← 也被连坐

QUIC 连接(HTTP/3):
  流A: [包1] [包2] [包3✗] ← 丢了,只有流A等重传
  流B: [包1] [包2] [包3]  ← 该干嘛干嘛
  流C: [包1] [包2]        ← 完全不受影响

QUIC 把多路复用下沉到了传输层。每个流(stream)独立管理丢包和重传,互不干扰。

这在理想网络下差别不大——丢包率 0.1% 的时候你根本感知不到。但一旦丢包率上去(移动网络切基站、地铁里、电梯口),差距就出来了。

0-RTT 握手到底省了什么

TCP + TLS 1.3 握手要 2-RTT(TCP 一次,TLS 一次)。QUIC 把传输层握手和加密握手合并了,首次连接 1-RTT,重连 0-RTT。

// TCP + TLS 1.3 (首次)
客户端 → SYN                     → 服务端     // RTT 1: TCP
客户端 ← SYN-ACK                 ← 服务端
客户端 → ClientHello             → 服务端     // RTT 2: TLS
客户端 ← ServerHello + 证书 + Finished ← 服务端
客户端 → Finished + 请求数据      → 服务端     // 终于可以发请求了

// QUIC (首次)
客户端 → Initial(ClientHello)    → 服务端     // RTT 1: QUIC + TLS 合并
客户端 ← Initial(ServerHello...) ← 服务端
客户端 → 请求数据                 → 服务端     // 直接发

// QUIC (重连, 0-RTT)
客户端 → Initial + 0-RTT数据     → 服务端     // 第一个包就带请求数据

0-RTT 是说:如果之前连过这个服务器,客户端缓存了一些加密参数,下次直接把请求数据塞进第一个包里发出去。服务端收到就能直接处理,不用等握手完成。

省下的这一个 RTT,在跨地域访问的时候特别值钱。北京到广州的 RTT 大概 30-40ms,到美西 150-200ms。对于一个首屏需要 3-4 个串行请求的页面,0-RTT 能直接砍掉一次握手延迟。

但 0-RTT 有个安全问题——重放攻击。

// ⚠️ 0-RTT 的数据可能被中间人截获并重放
// 所以只能用于幂等请求

// ✅ 适合 0-RTT 的:
fetch('/api/product/123', { method: 'GET' })  // 幂等,重放无副作用

// ❌ 不适合 0-RTT 的:
fetch('/api/order', { method: 'POST', body: orderData })  // 非幂等,重放会重复下单

服务端要自己判断哪些请求接受 0-RTT early data,哪些必须等握手完成。Nginx 的 ssl_early_data on 打开后,还得配合 Early-Data header 让后端知道这是 0-RTT 请求,由业务层决定是否处理。

连接迁移:移动端的大杀器

这个特性说出来简单,实际体感却最明显。

TCP 连接靠四元组标识(源 IP、源端口、目标 IP、目标端口)。手机从 WiFi 切到 4G,IP 变了,所有 TCP 连接全部断开,需要重新建连、重新握手、重新请求。

QUIC 用 Connection ID 标识连接,跟 IP 无关。网络切换时,换了 IP 没关系,Connection ID 还在,连接直接迁移过去。

// 模拟一个典型场景:用户在地铁里刷信息流

// HTTP/2 (TCP) 的表现:
// 1. 进隧道 → 信号丢失 → TCP 超时断开
// 2. 出隧道 → 重新 TCP 握手 (1 RTT)
// 3. 重新 TLS 握手 (1 RTT)
// 4. 重新发请求
// 用户感知:卡了 2-3 秒,页面白一下

// HTTP/3 (QUIC) 的表现:
// 1. 进隧道 → 信号丢失 → QUIC 探测包持续发送
// 2. 出隧道 → 探测包通了 → 连接恢复,继续传输
// 用户感知:卡了一下就好了

之前我们 App 里的 WebView 页面,在弱网环境下的白屏率有 8% 左右。上了 HTTP/3 之后降到 5% 出头。不全是连接迁移的功劳,但占了很大一块。

前端资源加载的实际收益量化

光说原理没用,得看数据。我们做了个 A/B 测试,对照组走 HTTP/2,实验组走 HTTP/3,跑了两周。

测试环境:
- CDN 已支持 HTTP/3 (Cloudflare)
- 页面资源:1 个 HTML + 3 个 JS bundle + 2 个 CSS + 12 张图片
- 样本量:各组约 50 万 PV

结果(中位数):

                    HTTP/2    HTTP/3    提升
DNS + 连接建立      120ms     68ms     -43%    ← 0-RTT 贡献最大
首字节 (TTFB)       210ms     155ms    -26%
LCP                 1420ms    1230ms   -13%
FCP                 890ms     780ms    -12%

按网络类型拆分 LCP:
  4G 稳定网络        1350ms    1250ms   -7%     ← 好网络下差距不大
  4G 弱信号          2100ms    1650ms   -21%    ← 弱网收益明显
  WiFi               1180ms    1100ms   -7%
  网络切换期间        3200ms    1800ms   -44%    ← 连接迁移的功劳

几个观察:

好网络下提升有限,大概 7% 左右。丢包率低的时候,队头阻塞本来就不是瓶颈。

弱网才是 HTTP/3 的主场。丢包率 2% 以上的时候,QUIC 的独立流控优势就很明显了。

连接迁移的收益最夸张,但触发频率不高。不过对于那些被影响到的用户来说,体验是质变。

怎么在项目里落地

CDN 侧

大部分情况你不需要自己部署 QUIC,CDN 厂商基本都支持了。Cloudflare 默认开启,Akamai 和 AWS CloudFront 也都有。

关键是确认你的 CDN 在响应头里带了 Alt-Svc

// 服务端响应头,告诉浏览器"我支持 HTTP/3,你可以来"
Alt-Svc: h3=":443"; ma=86400

浏览器首次还是走 HTTP/2,看到 Alt-Svc 后下次才会尝试 HTTP/3。所以第一次访问是吃不到 HTTP/3 红利的。

Nginx 自建的情况

server {
    # HTTP/3 需要 UDP 443
    listen 443 quic reuseport;
    # 同时保留 HTTP/2 做降级
    listen 443 ssl;

    http2 on;
    http3 on;

    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    # 0-RTT 开启(注意重放风险)
    ssl_early_data on;

    # 告知浏览器支持 HTTP/3
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # 防火墙别忘了放行 UDP 443,这个坑我踩过
    # 当时排查了半天,curl 死活握不上,最后发现安全组只开了 TCP 443
}

前端代码层面

前端代码基本不需要改。HTTP/3 是传输层的升级,fetch/XHR 的 API 没有变化。

但有几个地方值得注意:

// 检测当前连接是否走了 HTTP/3
// Performance API 可以拿到协议信息
const entries = performance.getEntriesByType('resource')
entries.forEach(entry => {
  // nextHopProtocol 会告诉你实际用的协议
  console.log(entry.name, entry.nextHopProtocol)
  // "h3" → HTTP/3
  // "h2" → HTTP/2
})

// 统计 HTTP/3 的覆盖率,塞到你的监控里
const h3Ratio = entries.filter(e => e.nextHopProtocol === 'h3').length / entries.length
reportMetric('h3_coverage', h3Ratio)
// 资源加载提示,帮浏览器更快建立 QUIC 连接
// preconnect 对 HTTP/3 同样有效
const link = document.createElement('link')
link.rel = 'preconnect'
link.href = 'https://cdn.example.com'
document.head.appendChild(link)

// 更激进的做法:dns-prefetch + preconnect 一起上
// <link rel="dns-prefetch" href="https://cdn.example.com">
// <link rel="preconnect" href="https://cdn.example.com">

资源打包策略可能要调整

HTTP/2 时代的"拆小包"策略在 HTTP/3 下更合理了。

// webpack / vite 配置思路

// HTTP/1.1 时代:合并成大文件减少请求数
// HTTP/2 时代:拆成中等大小,利用多路复用
// HTTP/3 时代:可以拆得更碎,因为不会有 TCP 队头阻塞

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 拆包粒度可以更细
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 按包名拆,别一股脑塞进 vendor
            const name = id.split('node_modules/')[1].split('/')[0]
            return `vendor/${name}`
          }
        },
        // HTTP/3 下小文件的传输惩罚更低
        // 但也别拆太碎,每个文件还是有解析开销
        experimentalMinChunkSize: 5 * 1024, // 5KB 兜底
      }
    }
  }
}

不过这块我个人觉得不用太激进。除非你的页面资源特别多(50+ 个请求),否则 HTTP/2 和 HTTP/3 在打包策略上的差异不大。

几个容易踩的坑

UDP 被墙了。 不少企业网络、学校网络会封 UDP 443。浏览器会自动降级到 HTTP/2,但这个降级过程本身有延迟——浏览器得先尝试 QUIC 握手,超时后才回退。Chrome 默认等 300ms。

// 如果你发现某些用户的连接建立时间反而变长了
// 大概率是 QUIC 被封,降级到 HTTP/2 多了 300ms

// 可以通过 Performance API 监控降级情况
const nav = performance.getEntriesByType('navigation')[0]
if (nav.nextHopProtocol === 'h2' && someHeuristic()) {
  // 记录下来,看看降级比例
  reportMetric('quic_fallback', 1)
}

0-RTT 没生效。 0-RTT 需要浏览器缓存 TLS session ticket。如果用户清了缓存、换了浏览器、或者 session ticket 过期了,就退化成 1-RTT。实测下来 0-RTT 的命中率大概在 60-70%,没有想象中那么高。

服务端没准备好。 开了 HTTP/3 之后,服务端的 CPU 开销会涨一些。QUIC 的加密是逐包的,不像 TCP+TLS 可以批量处理。我们上线初期 CPU 涨了约 15%,后来升级了 Nginx 版本(用上了 kernel 的 UDP GSO)才压下去。

回过头看

HTTP/3 不是银弹。好网络下它的收益有限,可能就快那么几十毫秒。但在弱网、网络切换这些"极端"场景下,体验提升是实打实的。

而且说实话,这事儿的投入产出比很高——大部分工作在运维侧(CDN 开个开关、Nginx 加几行配置),前端代码几乎不用改。加个监控统计一下 HTTP/3 覆盖率和性能数据,基本就完事了。

值不值得搞?如果你的用户主要在桌面端、好网络,优先级可以放低。但如果移动端占比高、有海外用户、或者对弱网体验有要求,那值得尽早推。

使用 Vite Mode 实现客户端与管理端的物理隔离

一、背景与目标

在我们的项目中,客户端管理端共享绝大部分的组件、工具和样式代码,但两者的登录入口业务路由完全不同。过去,我们将它们放在同一个Vue项目中,通过同一套路由混合管理。

目标

我们希望在不拆分代码仓库、保持公共代码高效复用的前提下,实现彻底的隔离:

  • 执行 pnpm dev:client 时,我们得到一个仅包含客户端路由和页面的纯净开发环境
  • 执行 pnpm dev:admin 时,我们得到一个仅包含管理端路由和页面的纯净开发环境
  • 在执行构建时,能产出两个互不包含对方代码的独立部署包。

实现这一目标的核心,便是利用 Vite 的 --mode 参数在构建时区分应用,并动态决定最终生效的路由配置。

二、核心机制:命令行参数驱动

整个方案的核心是利用 Vite 的 --mode 参数,在启动和构建时向应用注入一个“身份标识”。

  1. 定义启动与构建命令 (package.json)

    我们通过不同命令传递不同的 mode值。为了能在单个终端窗口同时启动或构建两个应用,我们使用 concurrently 工具。

    {
      "scripts": {
        // 1. 独立操作命令
        "dev:client": "vite --mode client",
        "dev:admin": "vite --mode admin",
        "build:client": "vite build --mode client",
        "build:admin": "vite build --mode admin",
    
        // 2. 核心效率工具:使用 concurrently 一键操作两端
        "dev:all": "concurrently "pnpm dev:client" "pnpm dev:admin"",
        "build:all": "concurrently "pnpm build:client" "pnpm build:admin""
      },
      "devDependencies": {
        "concurrently": "^9.1.2", // 需要安装此依赖
      }
    }
    
  2. 动态Vite配置 (vite.config.js)

    Vite 配置函数能接收到 mode 参数,我们据此动态设置所有差异化配置。

    import { defineConfig } from 'vite';
    
    export default defineConfig(({ mode }) => {
      const isAdmin = mode === 'admin';
      const appType = isAdmin ? 'admin' : 'client';
    
      return {
        // 核心:将应用类型注入为全局常量 APP_TYPE
        define: {
          APP_TYPE: JSON.stringify(appType) // 关键:必须用JSON.stringify
        },
        server: {
          port: isAdmin ? 3001 : 3000, // 为不同端分配不同端口,避免冲突
          open: true
        },
        build: {
          outDir: isAdmin ? 'dist-admin' : 'dist-client' // 构建输出到不同目录
        }
        // ... 其他公共配置
      };
    });
    

三、核心难点与解决方案

  1. 全局常量替换的“坑”:为什么必须用 JSON.stringify

    Vite 的 define配置本质是字符串级别的查找替换,并非 JavaScript 的变量赋值。理解“替换成什么文本”是关键。

    • 错误配置define: { APP_TYPE: 'admin' }

      • 替换过程:代码中 'console.log(APP_TYPE)'里的 APP_TYPE会被直接替换为文本 'admin'
      • 结果代码:'console.log(admin)'
      • 问题admin没有引号,会被 JavaScript 引擎当作一个变量名,导致报错 admin is not defined
    • 正确配置define: { APP_TYPE: JSON.stringify('admin') }

      • JSON.stringify('admin')的执行结果是字符串 '"admin"'(包含双引号)。
      • 替换过程:代码中的 APP_TYPE会被替换为文本 "admin"
      • 结果代码:console.log("admin")
      • 正确"admin"是一个字符串值,能正确打印。

    结论JSON.stringify的作用是把值转换成其对应的、有效的 JSON 字符串表示形式,确保替换后的代码语法正确。

  2. 路由动态配置:通过 APP_TYPE分离两套路由

    这是本方案的核心应用之一。在路由定义文件中,我们准备两套完全独立的路由数组,并通过 APP_TYPE 全局常量来决定最终使用哪一套。

    // src/router/index.js
    import { createRouter, createWebHistory } from 'vue-router'
    
    // 1. 定义客户端路由
    const clientRoutes = [
      { path: '/', component: () => import('@/views/client/Home.vue') },
      { path: '/profile', component: () => import('@/views/client/Profile.vue') }
    ]
    
    // 2. 定义管理员端路由
    const adminRoutes = [
      { path: '/admin', component: () => import('@/views/admin/Dashboard.vue') },
      { path: '/admin/users', component: () => import('@/views/admin/UserList.vue') }
    ]
    
    // 3. 根据 APP_TYPE 动态选择路由
    const routes = APP_TYPE === 'client' ? clientRoutes : adminRoutes
    
    export default createRouter({
      history: createWebHistory(),
      routes // 使用确定后的路由
    })
    
  3. TypeScript 支持:声明全局常量

    在项目文件中使用 APP_TYPE 时,TypeScript 会因找不到定义而报错。需在类型声明文件中声明此全局常量。

    // src/env.d.ts
    
    // 声明通过 define 注入的全局常量
    declare const APP_TYPE: 'client' | 'admin';
    

    此声明为 APP_TYPE 提供了类型支持,使其在代码中具备完整的类型提示与检查。

四、完整工作流程

  1. 安装依赖pnpm add -D concurrently

  2. 配置命令:按上文修改 package.json

  3. 配置Vite:按上文创建动态的 vite.config.js

  4. 声明类型:创建 src/env.d.ts文件声明 APP_TYPE

  5. 代码中区分逻辑:在任何需要区分两端的地方使用 APP_TYPE常量。

    // 例如在路由、组件、API配置中
    if (APP_TYPE === 'admin') {
      // 管理员端逻辑
    } else {
      // 客户端逻辑
    }
    
  6. 运行

    • pnpm dev:all一键启动两个端,分别访问 http://localhost:3000(client) 和 http://localhost:3001(admin)。
    • pnpm build:all一键构建两个端,产物分别输出到 dist-clientdist-admin 目录。

五、方案对比与更优方案

在理解了通过 define 配置全局常量的原理后,你会发现 Vite 本身就提供了更简洁的内置方案。

更优方案:直接使用 import.meta.env.MODE

Vite 会自动将 --mode参数的值注入到 import.meta.env.MODE这个内置环境变量中。这意味着你可以完全省略配置 define 和声明 .d.ts 文件的步骤,直接使用它。

在代码的任何地方,直接判断 import.meta.env.MODE即可。

// 路由配置中直接判断
const routes = import.meta.env.MODE === 'client' ? clientRoutes : adminRoutes;

// 在组件或逻辑中
if (import.meta.env.MODE === 'admin') {
  // 管理员端专属逻辑
}

一次视频会议的“生命旅程”:从点击加入到大屏相见,Mediasoup 背后发生了什么?

一、故事的开端:你有没有想过?

当你在腾讯会议、Zoom、飞书会议里点击"加入会议"后,几秒钟内就能看到其他人的画面、听到他们的声音——这背后发生了什么?

微信图片_20260307224848_5604_6.png 最简单的方案是"点对点"连接,但10个人开会就需要45个连接!更好的方案是 SFU(选择性转发单元) :大家把视频发给服务器,服务器转发给其他人。Mediasoup 就是这样的服务器。本文讲基于Mediasoup讲述这背后服务之间是如何进行配合的。

二、三个角色,各司其职

image.png

服务 比喻 职责
mediasoup-ui 电视机 采集画面、播放声音、用户交互
signal-bridge 信号转换器 协议翻译(JSON ↔ protoo)
signal-server 播控中心 管理房间、转发媒体流

三、一次视频会议的"生命旅程"

让我们跟随一个用户"小马"的视角,看看他从加入会议到看到其他人画面的完整过程:

第一步:小马打开网页 📺

sequenceDiagram
小马->>UI: 点击加入会议
UI->>Server: 建立websocket连接
Server-->>小马: 准备好接收和发送媒体流

第二步:获取"电视频道列表" 📋

// 小马问服务器:你们支持哪些视频格式?
const routerRtpCapabilities = await this.signaling.request('getRouterRtpCapabilities');

// 小马的浏览器检查:这些格式我支持吗?
this.device = new mediasoupClient.Device();
await this.device.load({ routerRtpCapabilities });
// 如果没有报错,说明可以正常通信!

通俗解释:就像你买了一个新电视,先要检查能不能收到当地电视台的信号格式(高清还是标清)。

第三步:铺设"信号线" 🔌

小马需要两条"线":

  • 发送线:把小马的画面传给服务器
  • 接收线:从服务器接收其他人的画面
async createTransports() {
    // 📤 创建发送线
    const sendInfo = await this.signaling.request('createWebRtcTransport', {
        forceTcp: false,
        appData: { direction: 'producer' },  // 我是生产者
    });

    this.sendTransport = this.device.createSendTransport({
        id: sendInfo.transportId,
        iceParameters: sendInfo.iceParameters,      // 冰块参数(网络地址)
        iceCandidates: sendInfo.iceCandidates,      // 候选地址列表
        dtlsParameters: sendInfo.dtlsParameters,    // 加密参数
    });

    // 📥 创建接收线(代码类似)
    const recvInfo = await this.signaling.request('createWebRtcTransport', {
        appData: { direction: 'consumer' },  // 我是消费者
    });
    this.recvTransport = this.device.createRecvTransport({...});
}

Transport: 就像一根水管,你需要两根——一根往里注水(发送),一根往外放水(接收)。

第四步:服务器端铺设"水管" 🏗️

服务器收到请求后,在 mediasoup 里创建真正的 Transport:

// signal-server/Room.ts
const transport = await mediasoupRouter.createWebRtcTransport({
    webRtcServer: mediasoupWebRtcServer,  // 共享端口服务器
    enableUdp: true,   // 支持UDP(更快)
    enableTcp: true,   // 支持TCP(更稳定)
    appData: { direction },  // 记录这是发送还是接收
});

// 返回给客户端
resolve({
    transportId: transport.id,
    iceParameters: transport.iceParameters,
    iceCandidates: transport.iceCandidates,
    dtlsParameters: transport.dtlsParameters,
});

第五步:小马打开摄像头 📹

async enableMic({ stream } = {}) {
    // 1. 向浏览器申请摄像头/麦克风权限
    const localStream = await navigator.mediaDevices.getUserMedia({ 
        audio: true, 
        video: false 
    });
    const track = localStream.getAudioTracks()[0];

    // 2. 通过发送线,把画面发出去
    this.micProducer = await this.sendTransport.produce({ track });
}

关键来了!  当调用 produce() 时,会触发一个事件:

// 监听 'produce' 事件 - 这是 WebRTC 的核心!
this.sendTransport.on('produce', async ({ kind, rtpParameters }, callback) => {
    // 通知服务器:我要发送一个媒体流
    const { producerId } = await this.signaling.request('produce', {
        transportId: this.sendTransport.id,
        kind,              // 'audio' 或 'video'
        rtpParameters,     // 编码参数
    });
    
    // 告诉本地 Transport:服务器已经准备好了
    callback({ id: producerId });
});

第六步:服务器创建 Producer 🎙️

服务器收到请求后,创建一个"生产者"对象:

// signal-server/Peer.ts
case 'produce': {
    const { transportId, kind, rtpParameters, appData } = data;
    const transport = this.getTransport(transportId);
    
    // 🎯 核心API:创建 Producer
    const producer = await transport.produce({
        kind,           // 音频还是视频
        rtpParameters,  // 编码参数
        appData: { 
            peerId: this.id,    // 是谁发的
            source: 'mic',      // 来源是什么
        },
    });

    // 🔔 重要:触发事件,通知房间里其他人
    this.emit('new-producer', { producer });
    
    // 返回 Producer ID 给客户端
    accept({ producerId: producer.id });
}

第七步:其他用户收到小马的画面 👥

Room 监听到 new-producer 事件后,会为其他用户创建 Consumer:

// signal-server/Room.ts
peer.on('new-producer', async ({ producer }) => {
    // 获取房间里除了小明以外的所有人
    const otherPeers = this.getOtherPeers(peer);
    
    // 为每个人创建 Consumer(消费者)
    for (const otherPeer of otherPeers) {
        await otherPeer.consume({ producer });
    }
});

创建 Consumer 的详细过程:

// signal-server/Peer.ts
async consume({ producer }) {
    const transport = this.getRecvTransport();
    
    // 🎯 创建消费者(初始暂停状态)
    const consumer = await transport.consume({
        producerId: producer.id,
        rtpCapabilities: this.rtpCapabilities,
        paused: true,  // 先暂停,等客户端准备好
    });

    // 📢 通知客户端:有新的媒体流可以消费
    await this.request('newConsumer', {
        peerId: producer.appData.peerId,   // 谁发的
        consumerId: consumer.id,
        producerId: producer.id,
        kind: consumer.kind,               // 音频还是视频
        rtpParameters: consumer.rtpParameters,
    });

    // 客户端确认后,恢复传输
    await consumer.resume();
}

第八步:小王的浏览器显示小马的画面 🖥️

// mediasoup-ui 处理 newConsumer 请求
async handleServerRequest(request) {
    if (request.method === 'newConsumer') {
        const { consumerId, producerId, kind, rtpParameters } = request.data;
        
        // 📥 消费这个媒体流
        const consumer = await this.recvTransport.consume({
            id: consumerId,
            producerId,
            kind,
            rtpParameters,
        });

        // 🎬 获取媒体轨道,创建可播放的流
        const stream = new MediaStream([consumer.track]);
        
        // 把流绑定到 video/audio 标签
        const videoElement = document.getElementById('remote-video');
        videoElement.srcObject = stream;
        
        // 接受请求,服务器开始传输
        request.accept();
    }
}

四、完整流程图

sequenceDiagram
    participant UI as mediasoup-ui<br/>(小马浏览器)
    participant Bridge as signal-bridge<br/>(协议转换)
    participant Server as signal-server<br/>(媒体服务器)

    Note over UI,Server: 1️⃣ 建立连接
    UI->>Bridge: WebSocket 连接
    Bridge->>Server: protoo 连接
    Server-->>Bridge: 连接成功
    Bridge-->>UI: protooOpen

    Note over UI,Server: 2️⃣ 获取路由能力
    UI->>Bridge: getRouterRtpCapabilities
    Bridge->>Server: 转发请求
    Server-->>Bridge: router.rtpCapabilities
    Bridge-->>UI: 返回能力
    UI->>UI: Device.load()

    Note over UI,Server: 3️⃣ 创建传输通道
    UI->>Bridge: createWebRtcTransport
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Transport
    Server-->>UI: {transportId, iceParams...}
    UI->>UI: 创建 SendTransport/RecvTransport

    Note over UI,Server: 4️⃣ 加入房间
    UI->>Bridge: join {displayName, rtpCapabilities}
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Peer
    Server-->>UI: {peers: [已在线用户]}

    Note over UI,Server: 5️⃣ 打开摄像头
    UI->>UI: getUserMedia()
    UI->>UI: sendTransport.produce()
    UI->>Bridge: produce {kind, rtpParameters}
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Producer
    Server-->>UI: {producerId}

    Note over UI,Server: 6️⃣ 其他用户接收
    Server->>Server: 触发 new-producer 事件
    Server->>Server: 为其他 Peer 创建 Consumer
    Server-->>UI: newConsumer 请求
    UI->>UI: recvTransport.consume()
    UI-->>Server: accept
    Server->>Server: consumer.resume()

五、媒体流路由示意图

image.png

六、信令 vs 媒体

flowchart TB
    subgraph Signaling[信令通道 - 控制面]
        S1[WebSocket]
        S2[JSON/protoo 协议]
        S3[传输控制消息]
    end

    subgraph Media[媒体通道 - 数据面]
        M1[WebRTC]
        M2[ICE/DTLS/SRTP]
        M3[传输音视频数据]
    end

    Client[客户端] --> S1
    Client --> M1
    S1 --> Server[服务器]
    M1 --> Server
类型 协议 传输内容
信令 WebSocket + JSON 控制消息(加入房间、创建Transport等)
媒体 WebRTC (ICE/DTLS/SRTP) 音视频数据流

七 关键 API 速查表

mediasoup-client(浏览器端)

API 说明 使用场景
new Device() 创建设备对象 初始化时
device.load({ routerRtpCapabilities }) 加载服务器能力 加入房间前
device.createSendTransport() 创建发送通道 准备发送媒体
device.createRecvTransport() 创建接收通道 准备接收媒体
transport.produce({ track }) 生产媒体流 打开摄像头/麦克风
transport.consume({ id, ... }) 消费媒体流 接收远程媒体

mediasoup(服务器端)

API 说明 使用场景
worker.createRouter({ mediaCodecs }) 创建路由器 创建房间时
router.createWebRtcTransport() 创建传输通道 用户加入时
transport.produce({ kind, rtpParameters }) 创建生产者 用户发送媒体
transport.consume({ producerId, rtpCapabilities }) 创建消费者 分发媒体给其他人
router.pipeToRouter({ producerId, router }) 跨路由传输 高级场景,分离生产/消费

八、写在最后

理解 Mediasoup 的关键点:

  1. SFU 架构:服务器只转发,不编解码,所以延迟低
  2. Transport 是核心:一切媒体传输都通过 Transport
  3. Producer/Consumer 模式:一人生产,多人消费
  4. 信令与媒体分离:WebSocket 传控制消息,WebRTC 传媒体数据
  5. 事件驱动new-producer 事件触发 consume,形成完整链路

从递归组件到 DSL 引擎:我造了一个让 AI 能"搭 UI"的运行时

从递归组件到 DSL 引擎:我造了一个让 AI 能"搭 UI"的运行时

我最初只是想用 Vue 递归组件做动态渲染,后来发现这条路的天花板比想象中低得多。这篇文章记录了我从零设计一个 Schema-Driven 渲染引擎的过程——踩过的坑、做过的取舍、以及为什么我认为这种架构天然适合 AI 时代。


一、起点:递归组件的天花板

故事的起点很简单。我要做一个低代码平台,需要根据 JSON 配置动态渲染 UI。最直觉的方案是 Vue 的递归组件:

<template>
  <component :is="node.type" v-bind="node.props">
    <DynamicRenderer
      v-for="child in node.children"
      :key="child.id"
      :node="child"
    />
  </component>
</template>

一开始能跑通。但随着需求复杂度上升,问题一个接一个冒出来:

性能没法深入优化——每个递归组件都是一个完整的 Vue 组件实例,有自己的生命周期、reactive 系统开销。100 个节点就是 100 个组件实例,1000 个节点时页面已经开始卡了。你没有办法跳过没变化的子树,因为 Vue 的响应式系统是按组件粒度工作的。

事件处理不好做——JSON 里写的是 { event: 'click', handler: 'submitForm' },但递归组件要把这个字符串映射成真实的函数调用,你得自己写一套 $emit 转发链,越写越像在造一个 mini 框架。

双向绑定更麻烦——v-model 在递归组件里要一层层 $emit('update:modelValue') 往上冒泡,或者搞一个全局 store 做中间层,写法又丑又容易出 bug。

表达式求值是个坑——JSON 里写 "disabled": "{{ !isValid }}",你要么 eval() 一下(安全隐患),要么自己写个表达式解析器(工作量巨大),反正递归组件本身帮不了你。

我意识到,递归组件方案的本质问题是:它还是在用"组件"的粒度思考,但 Schema 驱动的 UI 需要的是"节点"粒度的控制权

于是我开始想:如果不用递归组件,而是直接把 Schema 编译成 VNode 呢?如果把"事件处理"抽成一个指令集虚拟机呢?如果把表达式解析做成一个安全沙箱呢?

这就是 Vario 的起点。


二、Vario 全貌:三层解耦的 Schema 渲染运行时

先交代 Vario 的完整架构。它不是一个组件库,不是一个低代码平台,是一个 Schema 渲染运行时——由 4 个包组成的 monorepo,总共约 10,000 行 TypeScript 源码,579 个单元/集成测试全部通过。

@variojs/types   — 跨包共享类型(无业务逻辑,消除循环依赖)
@variojs/core    — Action VM + 表达式引擎 + RuntimeContext(零 Vue 依赖)
@variojs/schema  — defineSchema + 验证 + 规范化
@variojs/vue     — useVario composable + VNode 渲染器

数据流是单向的:

Schema (JSON 对象)
     ↓  normalizeSchemaNode()  规范化(空格/格式统一,WeakMap 缓存)
     ↓  validateSchema()       结构验证 + 表达式 AST 白名单校验
     ↓
@variojs/core
     ↓  createRuntimeContext()  创建状态上下文(Proxy 保护系统 API)
     ↓  evaluate()             表达式求值(Babel AST → 白名单 → 编译/解释)
     ↓  execute()              Action VM 执行指令序列(超时 5s,最大 10000 步)
     ↓
@variojs/vue
     ↓  useVario()             Composition API 入口
     ↓  VueRenderer.render()   Schema 递归 → VNode 树
     ↓  Path Memo              缓存无变化的子树 VNode
     ↓
Vue 3 接管渲染

关键架构约束@variojs/core 零 Vue 依赖,这是从第一天就定下的硬性要求。Core 里的 Action VM、表达式引擎、RuntimeContext 完全不知道 Vue 的存在——这意味着将来换成 React、Solid、甚至 Node.js 服务端渲染,Core 层不需要改一行代码。


三、先看看 Vario 写出来长什么样

直接上代码。一个带交互逻辑的表单:

import { useVario } from '@variojs/vue'

const { vnode, state } = useVario({
  type: 'ElForm',
  props: { labelWidth: '100px' },
  children: [
    {
      type: 'ElFormItem', props: { label: '姓名' },
      children: [{ type: 'ElInput', model: 'name', props: { clearable: true } }]
    },
    {
      type: 'ElFormItem', props: { label: '邮箱' },
      children: [{ type: 'ElInput', model: 'email', props: { type: 'email' } }]
    },
    {
      type: 'ElButton',
      props: { type: 'primary', disabled: '{{ !(name && email) }}' },
      events: { 'click.prevent': [{ type: 'call', method: 'submit' }] },
      children: '提交'
    }
  ]
}, {
  state: { name: '', email: '' },
  computed: { isValid: (s) => !!(s.name && s.email) },
  methods: {
    submit: ({ state }) => { console.log('提交:', state.name, state.email) }
  }
})

如果你写过 Vue,你会发现:ElInputElButtonElFormItem 就是 Element Plus 的组件名,model: 'name' 就是 v-modelclick.prevent 就是 @click.preventuseVario() 返回的 { vnode, state } 就是标准的 Composition API 用法。

这是有意为之的设计。


四、深入 VueRenderer——Schema 如何变成 VNode

VueRenderer 是整个渲染链的核心,638 行代码,内部采用 DI 风格拆分为 9 个专职模块:

模块 职责
ComponentResolver 组件类型解析(80+ 原生 HTML 标签 Set + 全局组件 Map 缓存)
ModelPathResolver model 路径解析(228 行,支持嵌套循环变量 $item 解析、路径栈拼接)
ExpressionEvaluator 表达式求值(桥接 @variojs/core 的 evaluate)
EventHandler 事件绑定(366 行,6 种事件处理器格式规范化,修饰符解析)
AttrsBuilder 属性构建(props 表达式求值 + model 绑定 + 事件合并)
LoopHandler 循环渲染(createLoopContext 对象池复用 + Fragment 包裹)
ChildrenResolver 子节点解析(文本插值 / 作用域插槽 / VNode 子树)
LifecycleWrapper 生命周期包装(6 个 Vue 生命周期钩子 + provide/inject)
PathMemoCache VNode 缓存(路径 + schemaId + 依赖键三级缓存键)

一个 createVNode() 调用的完整流程(20 个步骤):

createVNode(schema, ctx, path)
 1. ─ 验证 schema.type 存在
 2. ─ cond 条件渲染:表达式 falsy → return null
 3. ─ show 预求值:计算可见性用于依赖追踪
 4. ─ Path Memo 判断:无 loop/model/表达式的静态子树 → 直接返回缓存
 5. ─ 子树组件化判断:shouldComponentize() → VarioNode 独立组件
 6. ─ Loop 处理:委托 LoopHandler → Fragment(循环项VNode[])
 7. ─ 组件解析:原生标签返回字符串,自定义组件 markRaw() 防响应式
 8. ─ Model 路径栈更新:嵌套 model 路径拼接
 9. ─ 属性构建:props 表达式求值 + model 双向绑定 + 事件处理器
10. ─ 子节点解析:递归 VNode / 插值文本 / 作用域插槽
11. ─ show 可见性:{display: 'none'} 合并到 style
12. ─ Children 格式化:原生元素用数组,组件用函数插槽
13. ─ 生命周期/provide-inject:有则创建 LifecycleWrapper 组件
14. ─ ref 绑定:attachRef 到 RefsRegistry
15. ─ 自定义指令:withDirectives() 应用
16. ─ KeepAlive 包裹
17. ─ Transition 包裹
18. ─ Teleport 包裹
19. ─ Path Memo 写入缓存
20. ─ 返回 VNode

这 20 步的排列顺序不是随意的——Teleport 必须是最外层包裹(否则内部元素不会被传送),KeepAlive 必须在 Transition 之前(Vue 的渲染约束),Path Memo 的缓存判断必须在 Loop 之前(带循环的子树不能缓存)。

双向绑定是怎么做的

createModelBinding() 是整个渲染器最复杂的单个函数(310 行),需要处理:

  • 原生表单元素 (input/textarea/select)——不同元素用不同事件名和属性名
  • Vue 3 组件——modelValue + update:modelValue 协议
  • 具名 model——model:checkedmodel:value 支持一个组件绑定多个 model
  • 修饰符——.trim(去空格),.number(parseFloat),.lazy(change 替代 input)
  • lazy 模式——setTimeout(() => isActive = true, 0) 延迟激活,挂载期间不写 state
  • 自定义绑定协议——通过 registerModelConfig() 注册

ctx ↔ Vue 状态同步——ReactiveAdapter 单一数据源

早期版本中,useVario() 需要在 RuntimeContext 的 plain object 和 Vue 的 reactive state 之间维护双向同步,靠三把锁(syncing / syncingPaths / watchSyncing)防止循环触发。这套机制能跑,但脆弱且难以理解。

当前版本已经用 ReactiveAdapter 协议彻底消灭了这个问题。核心思路受 Zustand 启发——状态只有一份:

// @variojs/types 中定义协议
interface ReactiveAdapter {
  get(path: string): unknown
  set(path: string, value: unknown): void
  getProperty(key: string): unknown
  setProperty(key: string, value: unknown): void
  has(key: string): boolean
  keys(): string[]
}

Vue 层提供 createVueReactiveAdapter(reactiveState),内部直接操作 reactive() 对象。Core 的 createRuntimeContext 接受 adapter 参数后,_get/_set 通过 adapter 读写,Proxy 的 5 个 trap(get/set/has/ownKeys/getOwnPropertyDescriptor)也路由到 adapter。

// useVario 中,三重锁被替换为两行代码:
const adapter = createVueReactiveAdapter<TState>(reactiveState)
const ctx = createRuntimeContext<TState>({}, { adapter, onStateChange, ... })

没有双份状态 = 没有同步 = 没有循环 = 不需要锁。 ctx._set('name', 'Alice') 直接写入 Vue 的 reactive 对象,onStateChange 只做缓存失效和渲染调度,不再做状态搬运。useVario 从 636 行减到 570 行,核心同步逻辑从 ~65 行减到 ~10 行。


五、Action VM:不用 eval 的动作执行引擎

传统方案处理"交互逻辑"的方式是往框架里挂副作用——watch、reaction、onChange。Vario 走的是完全不同的路:指令集虚拟机

当前支持 13 种指令,分 5 个类别:

类别 指令
状态 set { type: 'set', path: 'user.name', value: '{{ input }}' }
数组 push pop shift unshift splice { type: 'push', path: 'todos', value: { text: '{{ newText }}' } }
调用 call { type: 'call', method: 'submit', params: { id: '{{ userId }}' }, resultTo: 'result' }
流控 if loop batch { type: 'if', cond: '{{ isValid }}', then: [...], else: [...] }
通信 emit navigate log { type: 'navigate', to: '{{ targetUrl }}' }

这些指令之间是正交组合的关系——ifthen/else 分支里可以嵌套任何指令,loopbody 里也可以,batch 可以包裹一组指令并做错误聚合(所有指令都执行,收集所有错误,最后统一抛出 BatchError)。

执行器的核心设计:不是 switch/case——所有动作(包括内置的 13 种)通过 ctx.$methods[action.type] 统一分派。这意味着你可以注册自定义指令类型,和内置指令完全平等。

一个真实的 Todo App 中"按下 Enter 添加待办"的事件定义:

{
  "events": {
    "keyup": [{
      "type": "if",
      "cond": "{{ $event.key === 'Enter' }}",
      "then": [{ "type": "call", "method": "addTodo" }]
    }]
  }
}

这里 $event 是运行时注入的 DOM 事件对象。if 指令先用表达式引擎求值 cond,为 true 时执行 then 分支里的 call 指令。整个过程不需要一行 JavaScript 事件处理代码。

call 指令的三种参数形式

// 字符串表达式——整个 params 是一个表达式求值结果
{ "type": "call", "method": "search", "params": "{{ keyword }}" }

// 对象命名参数——逐属性求值
{ "type": "call", "method": "addToCart", "params": { "id": "{{ product.id }}", "qty": 1 } }

// 数组位置参数——逐元素求值
{ "type": "call", "method": "calc", "params": ["{{ a }}", "{{ b }}"] }

resultTo 字段可以把方法返回值写回状态:{ type: 'call', method: 'fetchUser', resultTo: 'currentUser' } —— 这让你可以在纯 JSON 中编排异步数据流。

安全保护

  • 超时 5 秒(AbortController + Date.now 双重保护)
  • 最大执行步数 10000 步
  • 独立的错误类型层级:VarioError → ActionError / ExpressionError / ServiceError / BatchError
  • 18 个标准错误码(ACTION_TIMEOUTSERVICE_NOT_FOUNDEXPRESSION_UNSAFE_ACCESS 等)

Schema 和 methods 的刻意分离

这里要说清楚一个设计边界——Schema 是"做什么"(纯数据,可序列化),methods 是"怎么做"(JS 函数,在代码库里,走 git 管理)。

{ type: 'call', method: 'addTodo' } 这条指令可以存进数据库、被 AI 生成、被服务端下发。但 addTodo 这个函数本身不在 Schema 里——它是你预先注册的业务代码。这不是缺陷,这是安全边界。 如果函数也能动态下发执行,等于在数据库里存了可执行代码,这是经典的安全漏洞。


六、表达式沙箱:Babel AST + 白名单 + 编译器 + LRU 缓存

在 Schema 里你可以写表达式:

{ "children": "Hello {{ name }}" }
{ "props": { "disabled": "{{ !(name && email) }}" } }
{ "cond": "{{ user.role === 'admin' }}" }
{ "children": "{{ items.filter(i => i.active).length }} 项激活" }

表达式引擎是整个 Core 里最大的模块(1,450 行),完整的处理流水线是:

"{{ user.name || 'Guest' }}"
    ↓ extractExpression()
"user.name || 'Guest'"
    ↓ getCachedExpression() → 命中? → 直接返回
    ↓ parseExpression() → @babel/parser
AST: LogicalExpression { left: MemberExpression, right: StringLiteral }
    ↓ validateAST() → 白名单逐节点检查
    ↓ compileSimpleExpression() → 简单表达式? → (ctx) => ctx._get("user.name") 快速路径
    ↓ evaluateExpression() → 复杂表达式? → AST 解释执行(682 行完整求值器)
    ↓ extractDependencies() + setCachedExpression() → LRU 缓存
→ "Alice"

白名单验证——逐 AST 节点检查

允许的(17 种节点类型)MemberExpressionOptionalMemberExpressionArrayExpressionObjectExpressionIdentifierBinaryExpressionLogicalExpressionUnaryExpressionConditionalExpressionCallExpressionTemplateLiteral 等。

永久禁止的(10 种节点类型)AssignmentExpression(赋值)、ArrowFunctionExpression(箭头函数)、ThisExpressionNewExpressionAwaitExpressionImportExpressionUpdateExpression++/--)、YieldExpressionMetaPropertySpreadElement

函数调用安全模型

  • 白名单全局函数:Math.*(abs/round/floor/ceil/random/max/min)、Array.isArrayObject.isNumber.isFinite/isInteger/isNaNDate.now
  • 数组实例方法:30 个安全方法(filter/map/find/includes/slice/concat/join/sort/at 等),push/pop/splice 等修改型方法被排除
  • 全局对象访问:window/document/global/ globalThis/self 引用被永久阻止
  • 危险属性:constructor/prototype/__proto__ 访问被禁止
  • 危险函数:eval/Function/setTimeout/setInterval 被永久禁止

编译器——简单表达式的快速路径

对于 {{ count }}{{ user.name }}{{ 42 }} 这种简单表达式,不需要走完整的 AST 解释器。编译器会把它们直接编译为:

// {{ count }}  →  (ctx) => ctx._get("count")
// {{ user.name }}  →  (ctx) => ctx._get("user.name")
// {{ 42 }}  →  () => 42

这些编译后的函数缓存在 Map<string, CompiledExpression> 中,后续调用直接执行函数,跳过 AST 解析和解释,执行耗时 <1ms。

缓存系统——按上下文隔离的 LRU

WeakMap<RuntimeContext, Map<string, ExpressionCache>>
  • 每个 RuntimeContext 有独立缓存,上下文被 GC 时缓存自动回收
  • 最大 100 条,超限 LRU 淘汰
  • 依赖驱动失效:invalidateCache('user.name', ctx) 会遍历缓存,清除所有依赖链中包含 user.nameuser.* 的条目

实际的 trade-off

要诚实面对:

  • 你不能在 {{ }} 里写 (() => { ... })(),因为箭头函数被禁了
  • 数组的修改型方法(push/pop)不能在表达式里用,要搬到 Action 指令或 methods 里
  • 没有 Formily 的 x-reactions 那种开箱即用的联动语法

这些限制是刻意的。 如果 Schema 是开发者手写的,限制确实增加了摩擦。但如果 Schema 来自数据库、AI 生成、用户可视化配置——白名单就是最后的安全防线。


七、Path Memo——让"1000 个节点只更新 1 个"成为可能

这是我在性能优化上投入最多的部分。Vario 提供 4 层可组合的渲染优化策略:

方案 A:Path Memo(默认启用)

核心思路:缓存每个路径的 VNode,下次渲染时判断依赖有没有变,没变直接返回缓存

Schema 树                    依赖追踪
───────────                  ──────────
root                         [](无依赖,静态容器)
├── header                   [](纯静态)
├── form
│   ├── input[username]      ["username"]
│   ├── input[email]         ["email"]
│   └── submit-btn           ["isValid"]
└── footer                   [](纯静态)

当 username 变化时:
→ input[username] → 依赖命中 → 重渲染
→ header/footer/email/submit-btn → 依赖未变 → 走缓存 ✅

哪些子树不能缓存:三个递归检测函数——hasExpressionInSubtree()hasLoopInSubtree()hasModelInSubtree()。任何含动态绑定的子树都跳过缓存。

缓存键由三部分组成:path + buildSchemaId(type|cond|show|loop|childrenLen) + buildDepsKey(condValue, showValue) ——确保同一路径在不同条件分支下不会返回错误的缓存。

方案 B:LoopItemAsComponent(循环场景推荐)

循环每项渲染为独立的 LoopItemCell 组件(82 行的 defineComponent),Vue 对 props 未变的组件自动跳过 re-render。

循环上下文通过 createLoopContext() 创建——使用 Object.create(parentCtx) 原型链继承,对象池复用(maxSize=10),finally 块确保归还。

方案 C:SubtreeComponent(大规模深嵌套场景)

每个 Schema 节点(或组件边界)渲染为 VarioNode 独立 Vue 组件(350 行),shouldComponentize() 根据粒度('all''boundary')和 maxDepth 决定哪些节点升级为组件。

方案 D:SchemaFragment(实验性,精确 Schema 更新)

不给整棵 Schema 树套一个大 reactive(),而是按路径碎片化存储:path → shallowReactive(node)patch(path, partialNode) 只触发依赖该 path 的 Vue effect。

实测数据

场景 无优化 Path Memo 加速
100 静态 + 1 动态 全量 只渲 1 个 88x
复杂嵌套表单 基线 缓存命中 2-15x
大表格单行更新 基线 精准行更新 4-29x

1772387082094-dflyfiu5.png

▲ 内置的性能测试仪表盘,可以对比开关各种优化策略的渲染耗时


八、Vue 开发者的上手成本——四种方案写同一个表单

这是 Vario 最在意的一件事:渐进式接入,对 Vue 开发者来说切换到 Schema 写法的心智负担应该尽可能低。

同一个表单,四种方案对比:

原生 Vue 3

<template>
  <el-form label-width="100px">
    <el-form-item label="姓名">
      <el-input v-model="name" clearable />
    </el-form-item>
    <el-button @click.stop="submit" :disabled="!isValid">提交</el-button>
  </el-form>
</template>
<script setup>
const name = ref('')
const isValid = computed(() => !!name.value)
const submit = () => { /* ... */ }
</script>

Formily

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "title": "姓名",
      "x-decorator": "FormItem",
      "x-component": "Input",
      "x-component-props": { "clearable": true }
    }
  }
}

还需要 createForm()FormProviderSchemaField 等包裹层。组件名是 Formily 注册名(Input),不是 Element Plus 原生名。

amis

{
  "type": "form",
  "body": [
    { "type": "input-text", "name": "name", "label": "姓名" }
  ]
}

极简,但组件名是 amis 自己的类型系统(input-text)。

Vario

const { vnode, state } = useVario({
  type: 'ElForm', props: { labelWidth: '100px' },
  children: [
    {
      type: 'ElFormItem', props: { label: '姓名' },
      children: [{ type: 'ElInput', model: 'name', props: { clearable: true } }]
    },
    {
      type: 'ElButton',
      props: { disabled: '{{ !isValid }}' },
      events: { 'click.stop': [{ type: 'call', method: 'submit' }] },
      children: '提交'
    }
  ]
}, {
  state: { name: '' },
  computed: { isValid: (s) => !!s.name },
  methods: { submit: ({ state }) => { /* ... */ } }
})

Vario 对齐了 Vue 的哪些概念:

Vue 概念 Vario 对应 说明
v-model="name" model: 'name' 一个字符串搞定
@click.stop.prevent events: { 'click.stop.prevent': [...] } 点语法完全一致
ref="myInput" ref: 'myInput' 模板引用同名
Element Plus ElInput type: 'ElInput' 直接用注册的组件名
:disabled="!isValid" props: { disabled: '{{ !isValid }}' } 表达式换了个括号
computed computed: { isValid: (s) => ... } Options 风格函数
v-show show: '{{ condition }}' 条件显示
v-if cond: '{{ condition }}' 条件渲染
v-for loop: { items: '{{ list }}', itemKey: 'item' } 循环渲染
provide/inject provide: {...} / inject: [...] 依赖注入
<Teleport> teleport: '#target' 传送门
<Transition> transition: { name: 'fade' } 过渡动画
<KeepAlive> keepAlive: true 组件缓存
生命周期 onMounted: 'initMethod' 6 个 Vue 生命周期钩子
useVario() 返回值 { vnode, state, ctx, refs, error, stats, retry, find, findAll, findById } 完整的 Composition API

你只需要接受一个新概念:把模板写成 JS 对象。 其他所有东西——组件名、prop 名、事件名、修饰符——都跟你平时写 Vue 一模一样。

代价也要说清楚:

  • IDE 支持弱于 .vue 文件——只有类型提示,没有模板语法高亮和组件标签补全
  • 比 amis 啰嗦——同样的表单 amis 4 行搞定
  • 校验联动目前要手动实现——Formily 的 x-validatorx-reactions 是开箱即用的

九、为什么不直接用 h() 函数?

这个问题是理解 Vario 架构的关键。

Vue 的 h() 函数完全可以做到 Schema → VNode 的映射:

// h() 写法
const vnode = h('div', {}, [
  h(ElInput, { modelValue: state.name, 'onUpdate:modelValue': v => state.name = v }),
  h(ElButton, { onClick: () => submit() }, '提交')
])

渲染结果完全一样。h() 更直接,TypeScript 支持更好(完整的 prop 类型推导),性能也更好(少了一层解析)。

那 Schema 多这一层解析换来了什么?

答案是:h() 是代码,Schema 是数据。

h() 函数 Schema 对象
本质 函数调用——指令 普通 JS 对象——描述
能否 JSON.stringify ❌ 函数不可序列化 ✅ 纯 JSON
静态分析 ❌ 必须执行才知道结构 ✅ 不执行就能遍历、验证、转换
AI 生成 ⚠️ 要生成合法 JS ✅ 生成 JSON,格式可约束
运行时增量修改 ⚠️ 重新组装函数 SchemaStore.patch('children.0.props', { disabled: true })
路径级缓存 ❌ 每次全量重执行 ✅ Path Memo 跳过未变子树
存数据库 / 服务端下发 ❌ 不能下发代码 ✅ 下发 JSON
查询 / 检索 ❌ 无法对函数调用做 findById find(n => n.type === 'ElInput') 查询引擎

如果你的 Schema 永远只在 .ts 文件里手写,那 h() 确实更直接。 但如果 Schema 来自数据库、来自 AI 生成、来自可视化配置后台——"数据 vs 代码"的区别就是一切。

Path Memo、SchemaStore.patch、QueryEngine、Schema 验证器——这些能力全都依赖于"Schema 是数据"这个基础假设。


十、AI + Schema:为什么这个架构天然适合 AI 时代

这是我做 Vario 最深层的动机,也是我认为它最大的潜力所在。

现在 AI 生成代码已经很成熟了。但你让 AI 生成一个完整的 Vue SFC——template、script、style——它经常会出错:import 写错、ref 和 reactive 混淆、生命周期用错地方、组件名不存在……

但如果让 AI 生成的不是代码,而是 JSON 呢?

{
  "type": "ElCard",
  "children": [
    { "type": "ElInput", "model": "keyword", "props": { "placeholder": "搜索..." } },
    {
      "type": "div",
      "loop": { "items": "{{ results }}", "itemKey": "item" },
      "children": [{ "type": "span", "children": "{{ item.title }}" }]
    }
  ]
}

这个 JSON:

  1. 格式可约束——你可以给 AI 一个 SchemaNode 的类型定义,生成结果一定符合格式
  2. 可校验——validateSchema() 会对每个节点做结构验证 + 表达式 AST 白名单校验,不存在的组件类型、非法表达式都会被捕获
  3. 安全——即使这个 JSON 来自用户对话、来自远程接口,AST 白名单保证它不能执行 eval()、不能访问 window、不能 import() 动态加载
  4. 可增量修改——AI 不需要每次重新生成整个 UI,通过 SchemaStore.patch(path, partialNode) 做外科手术式更新,只触发依赖该 path 的 Vue effect

你可以想象这样一个工作流:

用户说:「帮我做一个商品搜索页面」
    ↓
AI 生成一份 Schema JSON
    ↓
validateSchema() 验证结构和表达式安全性
    ↓
Vario 运行时直接渲染
    ↓
用户说:「把搜索结果改成卡片布局」
    ↓
AI 生成一个 patch(只修改 layout 相关的节点)
    ↓
SchemaStore.patch() 增量更新,只有受影响的 VNode 重渲染

这个工作流中,AI 从头到尾不需要生成一行 JavaScript——它只生成 JSON 结构和指令序列。业务逻辑函数(methods)是人预先注册好的,AI 通过 { type: 'call', method: 'search' } 去调用。

方法层扮演的角色类似于 AI Agent 的 "Tools"——预定义好的能力接口,AI 只负责编排调用顺序和参数。


十一、竞品横向对比

做之前我认真看了现有的方案。这里不是要说"我比他们好"——他们是大厂几百人维护了好几年的项目,我一个人做的东西没资格这样说。但设计选择确实不同,值得讨论。

维度 Vario Formily(阿里) amis(百度)
GitHub Stars 新项目 12.6k ⭐ 18.8k ⭐
贡献者 个人 207 266
定位 Schema 渲染运行时 Schema Form 引擎 低代码平台
组件名 Vue 原生组件名 Formily 注册名 amis 类型系统
接入方式 渐进式(单页可用) 需包裹 Provider All-in-one
表单校验 手动 内置 x-validator 内置
表达式 AST 白名单沙箱 reaction 副作用 公式引擎
动作模型 13 指令正交组合 x-reactions 60+ actionType
渲染优化 4 层可组合优化 React/Vue 各自机制 内部优化
Schema 可序列化 ✅ 纯 JSON ✅ 基本支持 ✅ 纯 JSON
Bundle 大小 轻量 中等 ≈2MB
适合谁 搭平台的技术团队 复杂表单场景 快速交付内部工具

如果你要做复杂表单,Formily 的 x-validator + x-reactions 开箱即用,比 Vario 省力得多。选 Formily。

如果你要快速交付内部运营工具,amis 的 4 行 JSON 出页面是真实的生产力。选 amis。

如果你要在自己的项目里引入 Schema 驱动能力、保持对技术栈的完全控制、或者在构建一个低代码平台需要底层渲染引擎——Vario 提供的是一个干净的、可嵌入的运行时。


十二、测试与质量

┌──────────────────────────────────────────────────────┐
│  Test Files  50 passed (50)                          │
│       Tests  579 passed (579)                        │
│   跨 5 个包:types / core / schema / vue / cli       │
│   含 3 个集成测试文件(core↔schema / schema↔vm / vue↔element-plus)│
│   性能基准测试覆盖 4 种优化策略对比                     │
└──────────────────────────────────────────────────────┘

集成测试覆盖了三层的打通:

// basic-integration.test.ts — core 和 schema 能协作
const view = defineSchema({ state: { count: 0 }, schema() { return { type: 'div', children: [] } } })
const ctx = createRuntimeContext(view.stateType)
expect(ctx.count).toBe(0)

// schema-vm-integration.test.ts — Schema 中定义的 Action 能被 VM 执行
const instructions = view.schema.events?.click || []
await execute(instructions, ctx)
expect(ctx.count).toBe(1)

// vue-element-plus.test.ts — Vue 渲染器能正确处理 Element Plus 组件
const renderer = new VueRenderer()
const vnode = renderer.render(view.schema, ctx)
expect(vnode.props.modelValue).toBeDefined()
expect(vnode.props['onUpdate:modelValue']).toBeDefined()

十三、Demo 展示

1772387082111-j3lto3xl.png▲ play 演示站首页

下载.png▲ 内置了 Todo App、购物车、搜索过滤、表单、ECharts 图表等完整示例,每个示例可切换"预览"和"Schema JSON"视图

1772387082112-t7nh8y5j.png

▲ 代码靶场——浏览器里直接编辑 Schema,实时预览渲染结果

1772387082113-rbus1bmb.png

▲ 独立的文档站(VitePress),覆盖 API 文档、架构说明、表达式语法、性能调优指南


十四、自问自答——预判你心里可能已经有的问题

Q1:Schema 驱动和"把 template 写成 JSON"有什么本质区别?如果只是换了个语法糖,那工程价值在哪?

这是最核心的问题。如果 Schema 只是 template 的另一种写法,那确实没有意义——反而丢掉了 SFC 的 IDE 支持、语法高亮、组件类型推导。

区别在于 Schema 是可操作的数据,template 是编译后消失的 DSL

Vue 的 <template> 经过编译器后变成 render function,在运行时你拿不到"这里有一个 <ElInput>,它的 model 绑定到 name"这个结构信息了。但 Schema 始终存在于内存里,你可以在运行时做这些事:

  1. findAll(n => n.model) ——找出所有有双向绑定的节点,自动生成表单校验规则
  2. patch('children.2.props', { disabled: true }) ——服务端推送一条消息就能禁用某个按钮
  3. analyzeSchema(){ nodeCount: 234, maxDepth: 8 } ——统计 Schema 复杂度,自动决定启用哪种优化策略
  4. JSON.stringify(schema) → 存 DB → 下次 JSON.parse() → 直接渲染 ——零代码生成,零编译

这不是"换了个语法糖",这是从"编译时产物"变成了"运行时一等公民"的根本转变。

Q2:表达式白名单会不会过于严格?实际项目中遇到需要写复杂逻辑的表达式怎么办?

会。你不能在表达式里写 items.sort((a, b) => a.price - b.price),因为箭头函数被禁了。

设计意图是"表达式只做读取和条件判断,逻辑在 methods 和 computed 里"。 这意味着你需要:

// 不能这样写
{ children: '{{ items.sort((a, b) => a.price - b.price) }}' }

// 要这样写
computed: { sortedItems: (s) => [...s.items].sort((a, b) => a.price - b.price) }
// Schema 里用 {{ sortedItems }}

这多了一步,但换来的是:表达式永远是"安全的只读求值",不需要人工 review 每个 {{ }} 里写了什么。对于 Schema 来源不可信的场景(AI 生成、用户配置),这是刚性需求。

对于开发者手写 Schema 的场景,这确实增加了摩擦。如果你 100% 确定 Schema 只会出现在你的代码仓库里,白名单的安全价值就不那么明显了。这是一个架构赌注,赌的是 Schema 将来会来自更多来源。

Q3:双向绑定的"三重锁"是怎么被消灭的?

早期版本中,useVario 靠三把布尔锁(syncing / syncingPaths / watchSyncing)在 RuntimeContext 和 Vue reactive 之间做双向同步。能跑,但本质是 hack——三把锁意味着有三种循环路径需要手动屏蔽。

问题的根因不是"锁不够精确",而是存在两份状态本身就是错误。Core 的 RuntimeContext 维护一份 plain object,Vue 维护一份 reactive(),任何一侧修改都要同步到另一侧——这就是经典的"双写一致性"问题,在分布式系统里也没有优雅解法。

唯一真正优雅的方案是:消灭第二份状态。

受 Zustand 启发(一个 store 接口 + 各框架各自适配),当前版本引入了 ReactiveAdapter 协议,已经在源码中实现并通过全部 590 个测试

// @variojs/types/src/runtime.ts — 真实代码
export interface ReactiveAdapter {
  get(path: string): unknown        // 路径读取('user.name')
  set(path: string, value: unknown): void  // 路径写入
  getProperty(key: string): unknown  // 顶层属性读(Proxy get trap)
  setProperty(key: string, value: unknown): void  // 顶层属性写(Proxy set trap)
  has(key: string): boolean          // 属性存在检查(Proxy has trap)
  keys(): string[]                   // 所有 key(Proxy ownKeys trap)
}

改动涉及 5 个文件,核心变化:

1. @variojs/corecreateRuntimeContext 接受可选 adapter 参数。当 adapter 存在时:

  • _get(path)adapter.get(path),直接从 Vue reactive 读
  • _set(path, value)adapter.set(path, value),直接写入 Vue reactive
  • 初始状态不拷贝到 ctx 对象上(adapter ? {} : initialState

2. @variojs/core 的 Proxy 5 个 trap 全部路由到 adapter:

  • getadapter.getProperty(key)
  • setadapter.setProperty(key, value)
  • hasadapter.has(key)
  • ownKeys → 合并 adapter.keys() 与系统 API keys
  • getOwnPropertyDescriptor → 为 adapter 管理的 key 返回正确的描述符

3. @variojs/vuecreateVueReactiveAdapter 将 Vue reactive() 对象适配为协议:

// packages/vario-vue/src/adapter.ts — 真实代码
export function createVueReactiveAdapter<TState extends Record<string, unknown>>(
  state: TState
): ReactiveAdapter {
  return {
    get: (path) => getPathValue(state, path),
    set: (path, value) => setPathValue(state, path, value, {
      createObject: () => reactive({}),
      createArray: () => reactive([]),
      createIntermediate: true
    }),
    getProperty: (key) => state[key],
    setProperty: (key, value) => { state[key] = value },
    has: (key) => key in state,
    keys: () => Object.keys(state)
  }
}

4. useVario 从 636 行减至 570 行,删除了:

  • 3 个同步锁变量(syncing / syncingPaths / watchSyncing
  • onStateChange 中 20 行的 setPathValue 同步逻辑
  • watch(reactiveState) 中 20 行的 syncStateToContext 反向同步
  • syncStateToContext() 函数本身(16 行 + 深度比较)
  • 初始状态拷贝循环(5 行)

替换后的 onStateChange 只有 4 行——缓存失效 + 渲染调度:

onStateChange: (path, _value, runtimeCtx) => {
  invalidateCache(path, runtimeCtx)
  scheduleRender()
}

数据流变化:

重构前:ctx._set('x', 1) → 写入 ctx 内部 → onStateChange → setPathValue(reactive) → 触发 watch → 🔒 被锁拦截
重构后:ctx._set('x', 1) → adapter.set('x', 1) → 直接写入 reactive → onStateChange → invalidateCache + scheduleRender → 完毕

向后兼容: 当不传 adapter 时,行为与旧版完全一致——所有 153 个 Core 测试无需修改。adapter 是纯增量,不是 breaking change。

额外收益: 这个协议直接为 React Renderer 铺路(见 Q7)。React 侧只需实现一个基于不可变快照的 ReactReactiveAdapter,Core 层完全不用动。

Q4:Schema 存数据库之后,版本迁移怎么办?老版本的 Schema 在新版本的渲染引擎上能跑吗?

这是一个真实的工程问题,而且 Vario 目前没有完整的答案。

Schema 的结构由 SchemaNode 接口定义,这是一个 readonly 接口。新版本如果加了新字段(比如已经有的 transitionkeepAlive),老 Schema 没有这些字段,渲染器会按默认值处理,通常不会挂。

但如果某个字段的语义变了(比如 model 从只支持字符串变成支持 { path, scope, default, modifiers } 对象),normalizeSchemaNode() 需要处理兼容性转换。当前的规范化器已经在做这件事——它处理字符串 model 和对象 model 两种形态,统一为标准格式。

真正危险的是 Action 指令集的变更。 如果某个指令的参数结构变了,存在数据库里的 Schema 中引用的旧格式指令就会执行出错。Action VM 的错误保护(超时、步数限制、类型化错误码)可以兜底不让程序崩溃,但业务逻辑会失效。

长期来看,需要的是一个 Schema 版本号 + 迁移脚本的机制(类似数据库 migration),但这目前还在规划中。

Q5:你自己在实际项目中用 Vario 了吗?踩过什么真实的坑?

用了。Vario 最初就是从实际的低代码平台项目中抽出来的。踩过的最大的坑是 model 路径在嵌套循环中的解析

考虑这个场景:

{
  "loop": { "items": "{{ categories }}", "itemKey": "cat" },
  "children": [{
    "loop": { "items": "{{ cat.products }}", "itemKey": "product" },
    "children": [{
      "type": "ElInput",
      "model": "product.name"
    }]
  }]
}

product.name 需要解析为 categories.0.products.2.name 这样的绝对路径,才能正确写回状态。这需要一个路径栈(modelPathStack),每层循环压一层,每次解析 model 路径时从栈顶开始拼接。

ModelPathResolver 的 228 行代码大部分在处理这个问题的各种边界情况:"." 表示当前路径栈(循环项是基本类型时绑定自身)、$item 动态解析、-1 索引(动态数组追加)、表达式内嵌的 model 路径(model: '{{ dynamicField }}')。

vario-vue 有 750 行专门测试 model 路径解析的测试用例(model-path-comprehensive.test.ts),这是项目里最长的单个测试文件。

Q6:对比大厂的 Formily 和 amis,你一个人做的项目,凭什么让别人用?

这个问题的诚实答案是:如果有人问"我要选一个做生产项目用",我没有立场推荐 Vario 而不推荐 Formily。

Formily 有 207 位贡献者、多年的生产环境打磨、完整的表单验证/联动生态。amis 有百度内部大量业务场景验证、几百个内置组件类型。这些是个人项目无法比拟的。

Vario 的价值不在于"比他们好",而在于:

  1. 不同的抽象层次——Formily 是"表单引擎",amis 是"低代码平台",Vario 是"渲染运行时"。如果你要自己搭平台、自己做编辑器,你需要的是运行时这一层,而不是一个成品平台。
  2. 完全的控制权——Vario 不绑定任何组件库、不内置任何业务组件,你的组件就是你的。amis 接受就要全盘接受它的组件体系。
  3. 作为学习和参考——从零造一个 Schema 渲染引擎的过程中,我理解了为什么 Formily 要那样设计 x-reactions、为什么 amis 要搞 60+ 种 actionType。这个过程本身就值得分享。

如果你在选型——评估你的场景,做表单选 Formily,做内部工具选 amis,做平台底座或者想深入理解这个领域,来看看 Vario。

Q7:如果 Core 层零 Vue 依赖,那 React Renderer 真的能做出来吗?代价是什么?

架构上已经预留了。Core 层的所有 API——createRuntimeContext()execute()evaluate()——不依赖任何 UI 框架。但上一版的回答太保守了,只列了"React 缺什么"。深入想之后,我认为这件事比"能做但体验差"要更乐观。

VNode 创建层——映射是直接的:

Vue 的 h() 和 React 的 createElement() 在 API 层面几乎同构:

// Vue
h('div', { class: 'box', onClick: handler }, [h('span', {}, 'text')])

// React
createElement('div', { className: 'box', onClick: handler }, createElement('span', {}, 'text'))

差异只在属性名(class → classNamefor → htmlFor、事件名大小写),用一个 20 行的 prop adapter 就能搞定。当前 VueRenderer 的 638 行代码中,真正 Vue 特有的与其说是 h() 调用,不如说是围绕 h() 的那些 Vue 特性包裹(Teleport / Transition / KeepAlive / v-show / withDirectives)。

Vue 特性的 React 对应物——比想象中完整:

Vue 特性 React 对应 实现复杂度
h() createElement() 低(prop 名映射)
Teleport ReactDOM.createPortal() 低(API 对等)
Transition react-transition-group 或 Framer Motion 中(API 不同但能力对等)
KeepAlive 无原生等价物 高(需手动 display:none + 状态缓存,或用 react-activation)
v-show style={{ display: 'none' }} 低(trivial)
v-model value + onChange 低(React 反而更简单,不需要 onUpdate:modelValue 这种协议)
withDirectives 无等价物 高(需要自实 ref callback pattern)
provide/inject React.createContext + useContext 中(概念对等,API 不同)

真正的难题不在 API 映射,在状态同步——而 Q3 的 ReactiveAdapter 已经落地解决了这个问题。

Core 的 createRuntimeContext 现在接受 ReactiveAdapter 参数。Vue 侧的 createVueReactiveAdapter 已经证明了这个协议的可行性(590 个测试全部通过)。React 侧只需实现同一接口的不可变快照版本:

function createReactAdapter<T>(initialState: T): ReactiveAdapter & { getSnapshot: () => T, subscribe: (l: () => void) => () => void } {
  let state = structuredClone(initialState)
  const listeners = new Set<() => void>()

  return {
    get: (path) => getPathValue(state, path),
    set: (path, value) => {
      // 不可变更新——新引用触发 React re-render
      state = produce(state, draft => { setPathValue(draft, path, value) })
      listeners.forEach(l => l())
    },
    getProperty: (key) => state[key],
    setProperty: (key, value) => {
      state = { ...state, [key]: value }
      listeners.forEach(l => l())
    },
    has: (key) => key in state,
    keys: () => Object.keys(state),
    subscribe: (listener) => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
    getSnapshot: () => state
  }
}

React 侧的 useVario Hook:

function useVario(schema, options) {
  const adapter = useMemo(() => createReactAdapter(options.state), [])
  const state = useSyncExternalStore(adapter.subscribe, adapter.getSnapshot)
  const ctx = useMemo(() => createRuntimeContext({}, { adapter }), [adapter])

  return useMemo(() => {
    const renderer = new ReactRenderer()
    return renderer.render(schema, ctx)
  }, [schema, state])  // state 引用变化时触发重渲染
}

注意 createReactAdapter 实现的 get/set/getProperty/setProperty/has/keys 与 Vue 侧的 createVueReactiveAdapter 签名完全一致——因为它们实现的是同一个 ReactiveAdapter 接口。差异只在实现策略:Vue 用可变 reactive proxy,React 用不可变快照 + useSyncExternalStore

useSyncExternalStore(React 18+)是关键。 它是 React 官方提供的"外部状态 → React 渲染"的标准桥接方案,不需要 deep reactive proxy,也不需要 useEffect + 手动 diff。每次 set() 产生新的不可变快照,useSyncExternalStore 检测到引用变化,触发组件 re-render。

这里借鉴了 Zustand 的核心设计:store 是外部的,React 通过 useSyncExternalStore 订阅。但 Zustand 的 store 是用户手写的,Vario 的 store 是 RuntimeContext——由 Schema 驱动、Action VM 修改。

我现在的判断是:React Renderer 的工程量大约是 Vue Renderer 的 60%——不是因为 React 比 Vue 简单,而是因为 React 不需要三重锁。 Vue 的 deep reactive 带来了自动依赖追踪的便利,但也引入了双向同步的复杂度;React 的不可变模型虽然需要多写 immutable update,但状态流向是单向的——不存在回声问题。

具体的实施路线:

  1. 第一步:从 Core 中抽取 RendererProtocol 接口(createElement / createFragment / createPortal / wrapTransition),让 VueRenderer 和 ReactRenderer 都实现同一接口
  2. 第二步:实现 ReactReactiveAdapter,基于 useSyncExternalStore + 不可变快照
  3. 第三步:实现 ReactRenderer 基础版(createElement + 事件 + model 绑定),跳过 KeepAlive / Directive
  4. 第四步:补齐 Transition(react-transition-group)和 KeepAlive(react-activation 或自实现)

最大的技术风险不是"能不能做",而是性能。Vue 的 watch(state, { deep: true }) 可以精确知道哪个 path 变了(配合 Path Memo 做精准跳过),React 的不可变快照每次都是完整引用比较。在大规模 Schema(1000+ 节点)下,React 的渲染粒度控制可能不如 Vue fine-grained。这需要实际 benchmark 验证——理论推演到这一步就到极限了。


十五、欢迎参与

Vario 目前已开源,文档和示例都比较完整。但一个人做的项目终归有视野和精力的局限。如果你对 Schema 驱动 UI、AI + 低代码、渲染引擎设计这些方向感兴趣,非常欢迎参与:

🔧 提 Issue

  • 发现 bug?Schema 验证/表达式引擎/双向绑定/循环渲染——任何场景的问题都欢迎报告
  • 有功能建议?比如新增白名单函数、新的 Action 指令类型、更好的错误提示
  • 文档不清楚的地方?告诉我哪里看不懂

🚀 提 Pull Request

  • Good First Issues 适合初次贡献
  • 新的 Action 指令处理器(在 packages/vario-core/src/vm/handlers/ 下添加)
  • 新的表达式白名单函数(在 packages/vario-core/src/expression/whitelist.ts 中注册)
  • play 示例(在 play/src/examples/ 下添加 .vario.ts 文件)
  • 文档改进(在 docs/ 下修改 Markdown)
  • React Renderer(这是最大的待做项)

💬 参与讨论

  • 架构决策讨论——比如"表达式白名单应不应该开放 .sort() 带回调的用法?"
  • 性能优化方向——比如"SchemaFragment 方案的 API 应该怎么设计?"
  • AI 集成方案——比如"怎么为 Schema 生成约束 AI 的 JSON Schema 定义文件?"
git clone https://github.com/YuluoY/vario.git
cd vario
pnpm install
pnpm start  # 构建 + 启动 play(:5173) 和 docs(:5174)
pnpm test   # 跑一遍 579 个测试,确认环境正常

GitHub:github.com/YuluoY/vari…

在线演示:yuluoy.github.io/vario/

文档:yuluoy.github.io/vario/docs/


5 分钟快速上手

pnpm add @variojs/vue @variojs/core @variojs/schema
<template>
  <component :is="vnode" />
</template>

<script setup>
import { useVario } from '@variojs/vue'

const { vnode, state } = useVario({
  type: 'div',
  children: [
    { type: 'input', model: 'name', props: { placeholder: '你的名字' } },
    { type: 'p', children: 'Hello {{ name }}!' }
  ]
}, {
  state: { name: '' }
})
</script>

就这样。没有 Provider,没有额外的 store,没有新的模板语法——Schema 即 UI,状态即数据。


更多文章

前端架构模式思考

首先,如何开始一个项目?

在一个项目开始前,一般会得到一个需求文档,一个设计文档,知道项目用户,项目的目标,项目截止日期等信息。当拥有了这些信息后,就需要做一些关键决策了,如何部署,采用单页面应用还是多页面应用,是否需要服务端渲染,使用哪个框架,React/Vue/Angular等,使用哪种架构模式。

然后,需要考虑一个核心问题,项目的可扩展性。项目后续可能会如何发展。

在一般的工作流程中,很多人在面对一个新项目时,几乎是下意识的根据公式技术栈,使用脚手架工具,直接创建一个新项目,并进入开发,并没有项目架构,只是根据经验进行需求开发。

那为什么需要架构呢?

在解释这个问题之前,需要明确 React/Vue/Angular 这些现在前端开发框架都是负责组件渲染、局部状态管理、用户交互和用户界面生命周期,但是它们不提供构建完整应用所需的所有内容。例如:数据转换的模式、身份验证、日志记录等。

但在实际开发中,框架负责的组件所负责的工作早已超出了UI渲染这个范围,经常会把业务逻辑放在组件中,比如:API调用,执行业务规则。随着应用程序的增长,这会使维护变得更加困难。如果需要进行业务逻辑的测试,在执行测试用例时也需要挂载组件,需要一个完整的React环境。如果需要切换框架,那么在业务逻辑和组件耦合的情况下,需要重写所有逻辑。

什么样的架构才是理想的架构?

  • 可以在没有用户界面组件的情况下进行业务逻辑测试
  • 可以随意替换框架,并且不用重写业务逻辑
  • 新加入的开发者可以快速理解代码并开始执行开发任务
  • 数据源改变不会影响业务逻辑
  • 添加新的功能不会影响现有的功能
  • 不同的团队可以在不同的层和模块上独立工作
  • 工具选择根据团队需求决定,不会因为架构强制要求使用特定工具

如何尽量靠拢理想架构?

要做到以上几点,需要团队的技术自律。

  • 业务逻辑应该存在于框架无关的模型里,只有使用TyprScript或者JavaScript实现的纯逻辑
  • 要根据实际的复杂度进行工具选择而不是盲目跟随社区推荐
  • 数据访问和业务逻辑隔离
  • 业务逻辑在任何框架中都一样适用
  • 视图层只负责展示,组件要保持简洁,它们只负责接收数据,展示数据,获取用户输入,并将这些输入传递给下一个环节
  • CLI test:要做到可以在不重复逻辑的情况下更换用户界面

架构模型

在逻辑上,可以把前端项目分为3层。视图层、逻辑层、数据层。

image.png

视图层负责UI组件的处理,这一层用于显示信息并捕捉用户的交互。这一层没有业务逻辑和API调用,纯粹用于展示。

逻辑层负责管理业务逻辑和规则。这一层负责验证业务逻辑和协调操作。

数据层负责管理API的集成。同时也负责外部数据源的状态管理。这一层会获取数据并对其进行转换并进行缓存。

这样分层的好处是,缩小了每一层关注点,在每一层只需关注一个问题,测试独立,每一层可以单独测试。让代码库更容易维护。

这里有一个重要的概念,这些分层不是物理分层而是逻辑分层。它们可以分布在不同的设备上。可以视图层在浏览器中,逻辑层和数据层在服务器上,也可以都在浏览器中部署。这需要根据业务需求灵活调整。

对于前端应用逻辑层和数据层是可选的,应该在需要它们解决实际问题时再加上,而不是为了分层而分层,比如一个静态网站,逻辑层和数据层明显就是不需要的。

同时在一个团队中,一致性是非常重要的,一致性让代码库变得更可预测,团队间沟通更清晰。

架构扩展

扩展方式主要有两种:垂直扩展和水平扩展

这两个术语来自后端开发,在需要扩展时,后端可以通过增加更多服务器来实现水平扩展,或者在一个服务器上增加CPU或者内存来实现垂直扩展。是物理层面的扩展。

在前端场景,无法在用户的设备上增加更多的CPU或内存。这里主要指架构的可扩展性,是逻辑层面的扩展。如果代码库可以很容易的增加功能,那么说明代码库的可扩展性就很好。

垂直扩展指增加更多的层次,让层次更深,在以上三个层次中再细分层次。

水平扩展就是增加更多的切片,增加宽度。根据功能进行区分。

垂直扩展和水平扩展可以同时使用,在多数情况下,水平扩展更有价值,因为它可以实现并行开发,这在团队开发中尤其重要。

这样的方式在一定程度上让代码库向高内聚和低耦合靠拢。

前端工程化落地指南:pnpm workspace + Monorepo 核心用法与实践

在前端工程化落地过程中,pnpm workspace + Monorepo 已成为主流架构方案,有效解决了公共组件版本污染、定制化困难的行业痛点。本文,系统分享这一套技术方案的核心用法,聚焦 pnpm workspace、Monorepo 与 shadcn 的核心逻辑,帮大家快速掌握并落地到实际项目中。

本文将用「新手视角+实操例子」,拆解这三个核心概念的底层逻辑,全程大白话+可直接复制的JS demo,兼顾专业性与易懂性,帮大家快速搞懂 pnpm workspace + Monorepo + shadcn,轻松落地到实际开发中。

一、先解决核心困惑:3个概念到底是什么?(新手友好版)

很多开发者初次接触这三个概念时会觉得抽象,结合实际项目结构就能快速理解——核心就是“一个仓库装所有,工具帮你管依赖,源码可控不踩坑”,这也是选择这套架构的核心原因。

1. Monorepo:不是“随便一个文件夹”,是前端团队的“统一代码仓库”

很多人初次接触 Monorepo 会有这样的疑问:不就是一个文件夹里放了所有项目、工具、代码吗?要用直接 import 就行~

✅ 纠正+补充:大方向完全对,但不是“随便放”,是有规范结构+工具加持的「单Git仓库」,用来管理团队所有相关的项目和代码,区别于传统的“一个项目一个仓库”(Multirepo)。Monorepo核心是解决多项目复用、版本协同、跨团队协作的效率问题。

举个生活化类比(秒懂):

  • Multirepo(传统方式):你有多个抽屉,每个抽屉只放一类东西(袜子、内衣、裤子),拿裤子要开裤子抽屉,找袜子要开袜子抽屉,跨抽屉拿东西超麻烦;
  • Monorepo(现在主流):你有一个超大衣柜,里面分区域(袜子区、内衣区、裤子区),所有东西都在一个衣柜里,搭一身衣服伸手就能拿,不用来回开关多个抽屉。

前端实操例子:

your-team-monorepo/  # 这就是Monorepo根目录(一个Git仓库)
├── apps/           # 业务项目区(团队所有业务都在这,大厂通常按业务域划分)
│   ├── admin-web/  # 后台管理系统(完整前端项目)
│   └── shopping-web/ # 购物H5项目
├── packages/       # 公共代码区(可复用的组件、工具,大厂核心复用层)
│   ├── ui-components/ # 公共UI组件(按钮、表格等,统一设计规范)
│   ├── utils/         # 工具函数(时间格式化、请求封装,跨项目复用)
│   └── hooks/         # 公共Hooks(useRequest、useStorage,统一逻辑)
├── .eslintrc.js    # 根目录统一ESLint配置(大厂规范,统一代码风格)
├── pnpm-workspace.yaml # pnpm的Monorepo配置文件(关键!)
└── README.md       # 仓库说明(大厂必备,含架构文档、启动指南)

核心好处:apps里的两个项目,想用到packages里的组件,直接 import 就行,不用去npm下载,改组件源码也能实时生效,不用发包升级;同时统一代码规范、依赖版本,避免跨项目“重复造轮子”,提升团队协作效率。

2. pnpm workspace:pnpm自带的“Monorepo管家”

很多开发者只用过 pnpm install 装依赖,对 --filter 功能很陌生,其实它是 pnpm 专门用来管理 Monorepo 的“神器”,相比npm/yarn,pnpm 的软链接机制、依赖复用能力,更适配中大型团队的 Monorepo 场景。

✅ 大白话定义:pnpm(比npm/yarn更快、更节省空间的包管理器)内置的功能,帮你解决“一个仓库里多项目、多包”的依赖安装、脚本执行、包引用问题,无需额外安装lerna等Monorepo工具。

核心作用(实操,一看就会):

  1. 统一安装依赖:在Monorepo根目录执行 pnpm install,pnpm会自动识别所有子项目的依赖,只装一次(避免重复安装,节省磁盘空间,速度翻倍);
  2. 软链接关联内部包:apps/admin-web 引用 packages/ui-components 时,pnpm不会复制代码,而是建一个“快捷方式”,改组件源码,业务项目实时生效(核心诉求:快速迭代、实时同步);
  3. 精准执行脚本(重点!--filter 用法):用 pnpm --filter 子项目名 脚本名,实现多项目独立运行,互不干扰(同时维护多个业务项目,精准启动/构建)。

举个实操例子(对应上面的项目结构):

# 只启动 admin-web 开发服务(不影响shopping-web,大厂日常开发常用)
pnpm --filter admin-web dev

# 只启动 shopping-web 开发服务
pnpm --filter shopping-web dev

# 只给 packages/ui-components 装依赖(比如lodash)
pnpm --filter ui-components add lodash

# 批量构建所有业务项目(大厂部署常用)
pnpm --filter "./apps/*" run build

很多人会疑惑:“两个项目分开独立执行,直接各自启动不行吗?” 其实不然:如果没有 --filter,在根目录执行 pnpm dev,会同时启动所有子项目,既浪费资源,也没必要——--filter 就是帮你“精准操作”,想动哪个项目就动哪个,这也是提升开发、部署效率的关键技巧。

3. shadcn/ui:不是“装包”,是“复制源码”的组件库

很多开发者初次接触 shadcn/ui,会误以为它是普通组件库(比如Antd、Element UI),直到实际使用才发现,它的复用模式和传统组件库完全不同——这种“源码级复用”模式,正是解决“公共组件定制化困难”的常用方案(业务复杂,传统组件库难以满足所有定制需求)。

✅ 核心区别(用表格对比,一目了然,结合大厂实践补充):

对比维度 传统组件库(Antd/Element UI) shadcn/ui(源码级复用)
使用方式 npm install 下载包,引用 node_modules 里的产物 npx 复制源码到项目,引用项目内的源码文件
源码可控性 源码不在项目里,改不了,只能等组件库更新,定制化困难 源码在项目里,可直接修改,完全可控,适配定制化需求
版本问题 可能出现多项目版本不一致,导致冲突,维护成本高 无版本概念,直接用源码,无冲突,适配Monorepo复用场景
大厂适配度 适合快速开发,定制化场景需二次封装,维护成本高 适合中大型团队,可统一定制,适配多业务线差异化需求

实操例子(shadcn/ui 引入按钮组件,贴合实际用法):

# 执行命令,复制button源码到项目(常用:按需引入,避免冗余)
npx shadcn-ui add button

执行后,你的项目里会多出这些文件(源码直接在你项目里,可直接定制):

your-project/
└── components/
    └── ui/
        ├── button.js  # button组件源码(可直接改,适配业务定制需求)
        └── button.css  # 样式文件(可同步修改,统一设计规范)

引用时直接 import 项目内的源码:import { Button } from '@/components/ui/button' —— 这种模式的核心是“可控”,不用依赖npm包,改源码直接生效,避免版本污染,完美适配多业务线、高定制化的需求。

二、新手最常踩的3个坑(结合实践,帮你避坑)

结合开源文档和实际项目实践,整理了3个新手最容易困惑的点,每个点都配JS实操解答,看完少走弯路,贴合大厂实际开发场景!

坑1:node_modules 里的包也能改,为什么还要用 shadcn 这种方式?

很多新手会有这样的疑问:“node_modules 下载的不也是源码吗?找到文件改了不就行了?”

❌ 大错特错!node_modules 里的包,90%都是“编译后的产物”(压缩、混淆后的js),不是可直接改的源码;就算是源码包,改了也没用——别人拉代码、重新 install、部署环境,都会覆盖你的修改,相当于白改,完全不可控,这也是大厂绝对禁止的操作。

✅ 正确做法:像 shadcn 那样,把源码复制到项目里(或Monorepo的packages里),改了提交到Git,所有人拉代码都能看到,部署也会生效,这才是真的“可控”;同时配合代码审核,确保修改符合团队规范。

坑2:两个项目引用同一个公共组件,代码层面怎么实现?

这是实际开发中最常遇到的实操问题:packages/ui-components 里的Button,怎么让 admin-web 和 shopping-web 都能正常引用,且实时同步更新?

步骤1:完善配置文件

① 根目录 pnpm-workspace.yaml(告诉pnpm哪些是子项目):

packages:
  - "apps/**"    # 匹配所有业务项目
  - "packages/**" # 匹配所有公共包
  - "!**/node_modules" # 排除node_modules
  - "!**/dist" # 排除构建产物(避免依赖污染)

② packages/ui-components/package.json(公共组件包配置,关键是包名,常用@团队名/包名规范):

{
  "name": "@your-team/ui-components", // 包名,引用时要用,规范命名
  "version": "1.0.0",
  "main": "src/index.js", // 入口文件,改为JS
  "type": "module",
  "scripts": {
    "lint": "eslint src/**/*.js" // 新增lint脚本,代码规范必备
  }
}

③ packages/ui-components/src/index.js(组件导出,改为JS,规范导出):

// 导出Button,供业务项目引用,规范:统一导出,便于维护
export { Button } from './button/button';

④ packages/ui-components/src/button/button.js(Button组件源码,改为JS,删除TS类型,可直接运行):

import './button.css';

// 去掉TS接口,直接定义组件,贴合JS项目实操
export const Button = ({ children, type = 'default' }) => {
  return (
    <button className={{children}
  );
};

⑤ apps/admin-web/package.json(声明内部包依赖,核心!):

{
  "name": "admin-web",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint src/**/*.js"
  },
  "dependencies": {
    "@your-team/ui-components": "workspace:*", // 关键语法!引用内部包
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "vite": "^5.0.0",
    "@vitejs/plugin-react": "^4.2.1",
    "eslint": "^8.57.0" // 新增ESLint,代码规范必备
  }
}

✨ 重点:workspace:* 是pnpm的特殊语法,表示“引用仓库内这个包的最新版本”,不用写具体版本号,自动同步最新源码,这是Monorepo内部包引用的标准写法。

步骤2:业务项目引用组件

apps/admin-web/src/App.js(修复语法错误,可直接运行):

import { Button } from '@your-team/ui-components'; // 直接引用内部包

function App() {
  return (
    后台管理系统
      <Button type="primary">提交</Button>
    
  );
}

export default App;

apps/shopping-web/src/App.js 引用方式完全一样,改 packages 里的Button源码,两个项目会实时同步更新,不用重启、不用发包,这正是多项目复用的核心效率优势!

坑3:内部包版本只在仓库内生效,怎么就不会冲突了?

很多开发者会疑惑:“项目是独立的,引入的包版本不都一样吗?怎么会冲突?”

✅ 用“可乐类比”秒懂(:

  • 传统npm方式:给admin-web买一瓶可乐(1.0.0版本),给shopping-web买另一瓶可乐(2.0.0版本),两瓶独立,口味可能不一样,维护起来麻烦,多项目场景下会出现版本混乱;
  • Monorepo方式:只有一瓶可乐(packages里的组件源码),两个项目都直接喝这一瓶,口味完全一样,改可乐配方(改源码),两边喝到的都是新口味,从根源避免版本冲突,这也是选择Monorepo的核心原因之一。

核心逻辑:内部包不用发布到npm,版本只在仓库内生效,所有业务项目引用的是“同一个源码文件”,不存在“多版本”的可能,从根源避免版本污染;同时配合PR审核,确保源码修改可追溯、可控制。

三、团队协作必看:如何避免模块污染+同步更新通知?

实际团队开发中,难免会遇到“其他业务组改了公共组件,未及时通知,导致项目异常”的问题,以下是常用的解决方案。

1. 避免模块污染:规范+工具双保险

  • 规范层面:公共组件(packages/)的代码必须走 PR 审核,任何人改组件,都要提合并请求,由组件维护者(或架构组)审核,禁止乱改;同时制定公共组件开发规范(如组件命名、参数设计),均有明确的规范文档。
  • 工具层面:用 changesets 工具(pnpm生态标配),改组件时执行 pnpm changeset,选择修改的包、版本类型(major/minor/patch),自动生成变更日志;合并代码时,changesets 会自动更新包版本,所有人都能看到“谁改了什么、改了哪个版本”。
  • 禁止业务项目直接改 packages 里的代码:如果业务有特殊需求,先提需求文档,由组件维护者统一评估、修改公共组件,避免各改各的导致污染,这是Monorepo维护的核心规范。

2. 有效通知:让所有人知道组件更新(大厂实操方案)

  1. Git机器人通知:在Gitlab/Github配置机器人(如飞书机器人、企业微信机器人),packages目录代码合并后,自动推送到团队群,通知“xx组件已更新,变更内容:xxx,影响项目:xxx”,大厂均采用这种自动化通知方式。
  2. 组件文档站:用Storybook部署公共组件文档,更新组件时同步更新文档,添加“更新公告”,业务开发时能直接看到组件的最新用法、变更记录。
  3. 变更日志强制写:所有改组件的PR,必须写清晰的变更日志(比如“Button新增disabled属性,修复圆角样式问题”),不写不让合并,确保变更可追溯。

四、总结:实践总结+快速上手指南

pnpm workspace + Monorepo + shadcn 这套方案,核心是“高效复用、源码可控、协作便捷”,也是中大型前端团队的主流选择,核心就3句话,记下来就能快速上手,落地到实际项目:

  1. Monorepo:一个Git仓库装所有项目和公共代码,解决多项目复用、跨团队协作难题,核心诉求是“统一规范、提升效率”;
  2. pnpm workspace:帮你管理这个仓库,用 --filter 精准操作子项目,用 workspace:* 关联内部包,轻量配置、高效运行,首选Monorepo工具;
  3. shadcn模式:公共代码走“源码级复用”,不用发包,完全可控,适配大厂多业务线定制化需求,解决传统组件库定制困难的痛点。

这些看似抽象的概念,只要结合实际项目结构和JS实操例子,其实很简单——而且这确实是现在前端团队的主流架构,掌握后能有效提升项目维护效率,减少依赖冲突,适配中大型团队协作需求,也是前端工程师必备的技能之一。

补充:开源参考项目(可直接参考学习):字节 monorepo-template、阿里 umi-monorepo、腾讯 tencent/monorepo,可直接克隆源码,学习大厂的配置规范和实践细节。

Three.js多视口渲染:如何在一个屏幕上同时展示三个视角

前言

客户说:“我要一个监控大屏,左边看整体,中间看特写,右边看俯视图。” 我说:“行,加钱就行。”

上次写了篇画中画,没想到反响还不错。评论区有人问:“能不能一个屏幕放三个视角?像监控室那种。”

我心想这不就是多视口渲染的升级版吗?一个画中画不够,那就来三个。

其实原理都一样:一个场景,多个相机,分区域渲染。只不过从两个变成三个,需要多处理一些布局和交互细节。

今天就用一个监控大屏的例子,把多视口渲染讲透。最终效果:左边是全局俯视,中间是自由跟随相机,右边是某个设备的特写。三个视角实时更新,互不干扰。


一、最终效果预览

先描述一下我们要实现的效果:

  • 左侧视口:固定俯视视角,看整个车间布局。
  • 中间视口:自由相机,可以拖拽旋转,观察任意角度。
  • 右侧视口:特写某个设备,相机始终盯着它,跟随移动。

三个视口共用同一个场景,但各有各的相机和控制逻辑。运行起来就像监控室里的多块屏幕。


二、核心思路

Three.js 的渲染器允许我们在同一帧里多次调用 render() 方法,只要每次渲染前用 setViewportsetScissor 设置好渲染区域就行。

关键点:

  1. 创建多个相机,分别设置位置和朝向。
  2. 在动画循环里,依次设置视口并渲染。
  3. 处理深度清除:第二个及之后的视口渲染前要清除深度缓冲区,否则画面会错乱。
  4. 如果有交互(比如控制器),需要判断鼠标落在哪个视口,激活对应的控制器。

三、代码实现

1. 基础设置

先搭好场景、光照和几个简单的物体(用立方体和球体模拟车间设备)。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);

// 添加一些物体
const gridHelper = new THREE.GridHelper(20, 20, 0x4db8ff, 0x2266aa);
scene.add(gridHelper);

const cube = new THREE.Mesh(
  new THREE.BoxGeometry(2, 2, 2),
  new THREE.MeshStandardMaterial({ color: 0xff8844 })
);
cube.position.set(2, 1, 2);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);

const sphere = new THREE.Mesh(
  new THREE.SphereGeometry(1.5, 32, 16),
  new THREE.MeshStandardMaterial({ color: 0x44aaff })
);
sphere.position.set(-2, 1.5, -1);
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add(sphere);

const cylinder = new THREE.Mesh(
  new THREE.CylinderGeometry(1, 1, 3, 32),
  new THREE.MeshStandardMaterial({ color: 0x88cc44 })
);
cylinder.position.set(0, 1.5, -3);
cylinder.castShadow = true;
cylinder.receiveShadow = true;
scene.add(cylinder);

// 灯光
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

2. 创建三个相机

每个相机负责一个视角。

// 相机1:俯视固定
const cameraTop = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraTop.position.set(0, 15, 0);
cameraTop.lookAt(0, 0, 0);

// 相机2:自由视角
const cameraFree = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraFree.position.set(5, 5, 10);
cameraFree.lookAt(0, 2, 0);

// 相机3:特写立方体
const cameraCloseup = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraCloseup.position.set(4, 3, 4);
cameraCloseup.lookAt(cube.position); // 盯着立方体

注意:宽高比我们还没设置,等渲染时根据视口大小动态更新。

3. 设置控制器

自由视角的相机需要控制器,其他两个不需要(或者也可以加,但本例中俯视和特写是固定的)。

const controlsFree = new OrbitControls(cameraFree, renderer.domElement);
controlsFree.enableDamping = true;
controlsFree.target.set(0, 2, 0);

但控制器会监听整个画布的鼠标事件,我们需要判断鼠标当前在哪个视口,只有落在自由视口时才让 controlsFree 生效。后面会处理。

4. 定义视口布局

假设屏幕宽度为 window.innerWidth,高度为 window.innerHeight。我们分成三等份,每个视口占三分之一宽度,高度占满。

const viewports = [
  { left: 0, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraTop },
  { left: window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraFree },
  { left: 2 * window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraCloseup }
];

5. 处理鼠标事件,激活对应控制器

我们需要知道鼠标当前落在哪个视口,然后决定哪个控制器应该启用。其他控制器的 enabled 设为 false

function onMouseClick(event) {
  const mouseX = event.clientX;
  const mouseY = event.clientY;

  // 遍历视口,判断鼠标是否在内部
  let activeIndex = -1;
  viewports.forEach((vp, index) => {
    if (mouseX >= vp.left && mouseX <= vp.left + vp.width &&
        mouseY >= window.innerHeight - vp.bottom - vp.height && mouseY <= window.innerHeight - vp.bottom) {
      activeIndex = index;
    }
  });

  // 根据 activeIndex 启用/禁用控制器
  // 这里我们只有自由相机需要控制器,其他两个不需要
  if (activeIndex === 1) {
    controlsFree.enabled = true;
  } else {
    controlsFree.enabled = false;
  }
}

renderer.domElement.addEventListener('click', onMouseClick);

注意坐标转换:屏幕坐标系原点在左上角,而 setViewport 用的是左下角原点,所以判断时需要转换。上面代码中的 mouseY 判断已转换。

6. 动画循环:多视口渲染

这是核心。每帧先更新控制器(如果启用),然后依次渲染每个视口。

function animate() {
  requestAnimationFrame(animate);

  // 更新自由相机的控制器
  controlsFree.update();

  // 让特写相机始终盯着立方体(如果立方体在动)
  cameraCloseup.lookAt(cube.position);

  // 为每个视口设置视口并渲染
  viewports.forEach((vp) => {
    // 设置视口
    renderer.setViewport(vp.left, vp.bottom, vp.width, vp.height);
    
    // 设置剪裁区域(可选,避免渲染到其他区域)
    renderer.setScissor(vp.left, vp.bottom, vp.width, vp.height);
    renderer.setScissorTest(true);

    // 更新相机的宽高比
    const aspect = vp.width / vp.height;
    vp.camera.aspect = aspect;
    vp.camera.updateProjectionMatrix();

    // 如果是第一个视口,清除颜色和深度;后面的只清除深度
    if (vp === viewports[0]) {
      renderer.clear();
    } else {
      renderer.clearDepth();
    }

    // 渲染当前相机
    renderer.render(scene, vp.camera);
  });

  // 渲染完成后关闭剪裁测试(可选)
  renderer.setScissorTest(false);
}
animate();

这里使用了 setScissorsetScissorTest(true) 来确保每个相机的渲染只在自己区域内,防止绘制到其他区域。同时用 clearDepth 避免深度冲突。

7. 窗口大小变化时更新布局

window.addEventListener('resize', () => {
  renderer.setSize(window.innerWidth, window.innerHeight);

  viewports[0].width = window.innerWidth / 3;
  viewports[0].height = window.innerHeight;
  viewports[1].left = window.innerWidth / 3;
  viewports[1].width = window.innerWidth / 3;
  viewports[1].height = window.innerHeight;
  viewports[2].left = 2 * window.innerWidth / 3;
  viewports[2].width = window.innerWidth / 3;
  viewports[2].height = window.innerHeight;
});

四、完整代码

把上面的代码片段组合起来,就是一个完整的多视口示例。为了方便你直接运行,我整理成一个完整的 HTML 文件,并加了一点动画让立方体旋转,让效果更生动。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 多视口渲染:三屏监控</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: 'Microsoft YaHei'; }
        #info {
            position: absolute; top: 20px; left: 20px;
            background: rgba(0,0,0,0.7); color: white;
            padding: 8px 16px; border-radius: 20px;
            z-index: 100; pointer-events: none;
        }
        .label {
            position: absolute; bottom: 20px;
            background: rgba(0,0,0,0.5); color: white;
            padding: 4px 12px; border-radius: 12px;
            font-size: 14px; pointer-events: none;
            z-index: 200;
        }
        #label-left { left: calc(16.67% - 50px); }
        #label-center { left: 50%; transform: translateX(-50%); }
        #label-right { right: calc(16.67% - 60px); }
    </style>
</head>
<body>
    <div id="info">🎥 三视口监控:俯视 | 自由 | 特写</div>
    <div class="label" id="label-left">📐 俯视固定</div>
    <div class="label" id="label-center">🎮 自由视角 (点击激活)</div>
    <div class="label" id="label-right">🔍 设备特写</div>

    <!-- 引入 Three.js 核心库和 OrbitControls -->
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.128.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.128.0/examples/jsm/"
            }
        }
    </script>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        // --- 初始化场景 ---
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x111122);

        // 网格地面
        const gridHelper = new THREE.GridHelper(20, 20, 0x4db8ff, 0x2266aa);
        scene.add(gridHelper);

        // 添加一些物体
        const cubeGeo = new THREE.BoxGeometry(2, 2, 2);
        const cubeMat = new THREE.MeshStandardMaterial({ color: 0xff8844, emissive: 0x221100 });
        const cube = new THREE.Mesh(cubeGeo, cubeMat);
        cube.position.set(2, 1, 2);
        cube.castShadow = true;
        cube.receiveShadow = true;
        scene.add(cube);

        const sphereGeo = new THREE.SphereGeometry(1.5, 32, 16);
        const sphereMat = new THREE.MeshStandardMaterial({ color: 0x44aaff, emissive: 0x001122 });
        const sphere = new THREE.Mesh(sphereGeo, sphereMat);
        sphere.position.set(-2, 1.5, -1);
        sphere.castShadow = true;
        sphere.receiveShadow = true;
        scene.add(sphere);

        const cylinderGeo = new THREE.CylinderGeometry(1, 1, 3, 32);
        const cylinderMat = new THREE.MeshStandardMaterial({ color: 0x88cc44, emissive: 0x112200 });
        const cylinder = new THREE.Mesh(cylinderGeo, cylinderMat);
        cylinder.position.set(0, 1.5, -3);
        cylinder.castShadow = true;
        cylinder.receiveShadow = true;
        scene.add(cylinder);

        // 添加一个移动的小球作为动态元素
        const ballGeo = new THREE.SphereGeometry(0.5, 16);
        const ballMat = new THREE.MeshStandardMaterial({ color: 0xffaa33 });
        const ball = new THREE.Mesh(ballGeo, ballMat);
        ball.castShadow = true;
        scene.add(ball);

        // 灯光
        const ambientLight = new THREE.AmbientLight(0x404060);
        scene.add(ambientLight);

        const dirLight = new THREE.DirectionalLight(0xffffff, 1);
        dirLight.position.set(5, 10, 7);
        dirLight.castShadow = true;
        dirLight.shadow.mapSize.width = 1024;
        dirLight.shadow.mapSize.height = 1024;
        scene.add(dirLight);

        const fillLight = new THREE.PointLight(0x4466aa, 0.5);
        fillLight.position.set(-3, 2, 4);
        scene.add(fillLight);

        // --- 渲染器 ---
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);

        // --- 三个相机 ---
        // 1. 俯视相机
        const cameraTop = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraTop.position.set(0, 15, 0);
        cameraTop.lookAt(0, 0, 0);

        // 2. 自由相机
        const cameraFree = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraFree.position.set(5, 5, 10);
        cameraFree.lookAt(0, 2, 0);

        // 3. 特写相机
        const cameraCloseup = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraCloseup.position.set(4, 3, 4);
        cameraCloseup.lookAt(cube.position);

        // --- 控制器(只给自由相机)---
        const controlsFree = new OrbitControls(cameraFree, renderer.domElement);
        controlsFree.enableDamping = true;
        controlsFree.target.set(0, 2, 0);

        // --- 视口定义 ---
        const viewports = [
            { left: 0, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraTop },
            { left: window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraFree },
            { left: 2 * window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraCloseup }
        ];

        // --- 鼠标点击激活对应控制器 ---
        function onMouseClick(event) {
            const mouseX = event.clientX;
            const mouseY = event.clientY;

            let activeIndex = -1;
            for (let i = 0; i < viewports.length; i++) {
                const vp = viewports[i];
                // 转换鼠标坐标(左下原点)
                const vpLeft = vp.left;
                const vpRight = vp.left + vp.width;
                const vpBottom = vp.bottom;
                const vpTop = vp.bottom + vp.height;

                if (mouseX >= vpLeft && mouseX <= vpRight && 
                    mouseY >= window.innerHeight - vpTop && mouseY <= window.innerHeight - vpBottom) {
                    activeIndex = i;
                    break;
                }
            }

            // 自由相机索引为1,其他相机没有控制器
            controlsFree.enabled = (activeIndex === 1);
        }
        renderer.domElement.addEventListener('click', onMouseClick);

        // --- 窗口大小自适应 ---
        window.addEventListener('resize', () => {
            renderer.setSize(window.innerWidth, window.innerHeight);

            viewports[0].width = window.innerWidth / 3;
            viewports[0].height = window.innerHeight;
            viewports[1].left = window.innerWidth / 3;
            viewports[1].width = window.innerWidth / 3;
            viewports[1].height = window.innerHeight;
            viewports[2].left = 2 * window.innerWidth / 3;
            viewports[2].width = window.innerWidth / 3;
            viewports[2].height = window.innerHeight;
        });

        // --- 动画变量 ---
        let time = 0;

        // --- 动画循环 ---
        function animate() {
            requestAnimationFrame(animate);

            // 让小球围绕中心旋转
            time += 0.01;
            ball.position.x = Math.sin(time) * 3;
            ball.position.z = Math.cos(time) * 3;
            ball.position.y = 0.5 + Math.sin(time * 2) * 0.5;

            // 让立方体旋转
            cube.rotation.y += 0.01;

            // 更新自由相机的控制器
            controlsFree.update();

            // 让特写相机始终盯着立方体
            cameraCloseup.lookAt(cube.position);

            // 依次渲染每个视口
            viewports.forEach((vp, index) => {
                // 设置视口
                renderer.setViewport(vp.left, vp.bottom, vp.width, vp.height);
                renderer.setScissor(vp.left, vp.bottom, vp.width, vp.height);
                renderer.setScissorTest(true);

                // 更新相机宽高比
                vp.camera.aspect = vp.width / vp.height;
                vp.camera.updateProjectionMatrix();

                // 第一个视口清除颜色和深度,后续只清除深度
                if (index === 0) {
                    renderer.clear();
                } else {
                    renderer.clearDepth();
                }

                renderer.render(scene, vp.camera);
            });

            renderer.setScissorTest(false);
        }

        animate();
    </script>
</body>
</html>

五、坑点总结

  1. 视口坐标setViewportsetScissor 用的都是左下角原点,而鼠标事件是左上角原点,转换时要注意。
  2. 深度清除:多视口渲染时,第二个及之后的视口必须调用 clearDepth(),否则旧深度会导致新画面显示不全。
  3. 控制器冲突:多个控制器同时监听同一个画布会互相干扰,必须根据鼠标位置动态启用/禁用。
  4. 性能:渲染多个视口意味着每帧多次渲染,对性能有影响。可以适当降低分辨率或关闭阴影来优化。
  5. 宽高比:每个相机要单独设置 aspect 并调用 updateProjectionMatrix

六、拓展想法

这个多视口技术还有很多玩法:

  • 给每个视口添加不同的后期效果(比如一个泛光,一个黑白)。
  • 实现分屏游戏(比如左右分屏的双人竞技)。
  • 结合 CSS 把视口放在 HTML 元素上,实现 3D 画中画嵌套 HTML。

我正准备写一篇《Three.js 后期处理进阶:给每个视口加上不同滤镜》,感兴趣的话可以关注后续。


互动

你用过 Three.js 的多视口渲染吗?实现了什么有趣的效果?欢迎评论区晒出来,让我也开开眼 😏

JSX & ReactElement 核心解析

在 React 面试中,JSX 与 ReactElement 是基础且高频的考点——难度低、记忆点集中,掌握后能轻松拿下基础分,尤其适合面试突击复习。本文以「通俗解读+专业拆解」的方式,帮你理清核心逻辑,所有内容均适配面试答题场景,可直接背诵套用,同时补充高频考题及标准答案,助力高效备考。

一、核心结论(面试开门见山必备)

面试时遇到相关问题,先抛出以下结论,能快速建立专业认知,给面试官留下清晰印象,直接背诵即可:

  • JSX 只是语法糖,核心作用是简化 UI 描述,编译后会转化为 React.createElement 函数的调用。

  • ReactElement 是一次「UI 描述快照」,本质是一个不可变、轻量的 JavaScript 对象,用于精准描述你想要的 UI 结构。

  • ReactElement 既不是 DOM,也不是 Fiber;真正参与 React 调度、虚拟 DOM 比对与页面更新的,是 Fiber(React 内部的运行时工作单元)。

  • 面试标准答句:JSX → React.createElement → ReactElement(UI 描述);渲染器(如 ReactDOM)和 React 内部的 Fiber,会把这份描述落地为真实 DOM 并完成更新。

二、JSX 编译后是什么?(通俗+具体,易理解好背诵)

通俗解读

很多初学者会误以为 JSX 是 HTML 的延伸,或是 React 独有的语法,其实都不对。JSX 本质就是「长得像 HTML 的语法糖」——我们写 JSX,只是为了摆脱繁琐的 React.createElement 写法,让 UI 描述更直观、更简洁,就像用“简化版代码”代替“完整版代码”,核心功能没有变化。

专业拆解

JSX 本身无法被浏览器直接识别,必须经过 Babel 等编译器编译,最终转化为 React.createElement 的函数调用,而这个函数的返回值,就是我们下一节要讲的 ReactElement。

具体示例(面试可直接举例,加分项):

// 我们写的 JSX
const el = <App name="x" />;

// 经过 Babel 编译后,转化为
const el = React.createElement(App, { name: 'x' }, null);

补充记忆点(易混淆,必背):React.createElement 的第一个参数(type),决定了元素的类型——当 type 是字符串(如 'div'、'span')时,表示原生 DOM 节点;当 type 是函数或类时,表示 React 组件(如上述示例中的 App 组件)。

三、ReactElement 是什么数据结构?(面试必背,精准踩分)

ReactElement 是 React 描述 UI 结构的基础数据结构,核心是「纯 JavaScript 对象」,可以理解为 UI 的“静态快照”,不包含真实 DOM、不存储组件状态,也不参与任何更新操作,仅用于描述“UI 长什么样”。

典型结构(面试可直接背诵,绝对踩分)

const el = {
  $$typeof: Symbol(react.element), // 类型标签,标记这是 React 元素,避免与普通对象混淆
  type: App,                        // 元素类型:字符串(原生DOM)或函数/类组件
  props: { name: 'x' },             // 元素属性:传入的 props、children 也包含在其中
  key: null,                        // 列表渲染的唯一标识,用于优化 diff 算法
  ref: null                         // 用于获取真实 DOM 或组件实例
}

核心特性(通俗+专业,帮你加深记忆)

  • 轻量:仅包含 UI 描述所需的核心信息,不占用浏览器额外资源,也不包含状态、生命周期等逻辑。

  • 不可变:一旦创建,就无法修改其属性(如 props、type);组件更新时,会创建一个新的 ReactElement,而非修改原有对象。

  • 核心作用:作为 React diff 算法的“对比依据”,React 会通过对比前后两个 ReactElement 树的差异,决定哪些部分需要更新。

常见误解(避坑必记)

很多面试者会混淆“ReactElement”与“组件实例”“DOM 节点”,这里明确区分:ReactElement 只是「UI 描述」,既不是组件实例(组件实例包含状态、生命周期),也不是真实 DOM 节点(DOM 是浏览器中可渲染的实体),它只是告诉 React“该如何构建 UI”。

四、ReactElement ≠ DOM ≠ Fiber(三者职责+关系,面试高频易错点)

这三个概念是面试必问的易错点,很多人会将三者混淆,其实它们的职责、生命周期完全不同,用“通俗定位+专业职责”的方式,一次性记牢:

1. ReactElement:静态 UI 描述

  • 通俗定位:UI 的“设计图纸”,只记录“要做什么”,不负责“怎么做”。

  • 专业职责:描述 UI 的结构、属性和类型,是声明式的数据,创建后就固定不变,不参与 React 的调度和更新流程。

2. DOM:真实 UI 呈现

  • 通俗定位:“设计图纸”落地后的“实体建筑”,是浏览器中真实可见、可交互的节点。

  • 专业职责:承载页面的视觉呈现和用户交互(如点击、输入),占用浏览器资源;由 React 渲染器(如 ReactDOM)负责根据 Fiber 和 ReactElement 的描述,创建、更新或删除 DOM 节点。

3. Fiber:React 内部运行时单元

  • 通俗定位:“施工队长”,负责统筹调度、拆分任务,确保“建筑”(DOM)能高效更新。

  • 专业职责:React 16+ 引入的核心结构,是 React 内部调度、协调更新的最小单位,包含组件状态、更新优先级、指向子/兄弟节点的指针等信息;负责实现虚拟 DOM diff、时间切片(可中断渲染),是真正参与 React 更新流程的“主角”。

三者关系总结(面试必背)

  1. JSX 编译后生成 ReactElement(UI 描述);

  2. React 的协调算法(Reconciliation)读取 ReactElement,构建或更新 Fiber 树(补充状态、副作用等信息);

  3. Fiber 树驱动渲染器(如 ReactDOM),将更新应用到真实 DOM,最终完成页面渲染。

五、为什么要区分这些概念?(面试拓展加分点)

很多面试会追问“为什么 React 要拆分这三个概念”,记住以下3个核心要点,无需拓展,直接背诵即可加分:

  • 支持可中断渲染:Fiber 可以将大型更新任务拆分为多个小任务,避免阻塞浏览器主线程,提升页面响应性(这一功能与 ReactElement 无关,核心依赖 Fiber 的设计)。

  • 简化 diff 算法:ReactElement 的不可变性,让 React 对比前后两棵 UI 树的差异时更高效,无需遍历所有属性,只需对比核心标识即可。

  • 解耦渲染目标:ReactElement、Fiber 与真实宿主环境(DOM、React Native)分离,让 React 可以适配不同的渲染场景(如网页、移动端),只需更换渲染器即可。

六、从 到浏览器显示的完整流程(简化版,易背诵)

面试时若被问到“JSX 如何渲染到页面”,按以下步骤回答,逻辑清晰、重点突出:

  1. 开发者编写 JSX:<App name="x" />

  2. Babel 编译 JSX,转化为 React.createElement(App, { name: 'x' })

  3. 调用 React.createElement,返回 ReactElement(UI 描述对象);

  4. React 协调阶段(Reconciliation):对比 ReactElement 与当前 Fiber 树,创建/更新 Fiber 节点,确定需要执行的更新操作(插入、修改、删除);

  5. Commit 阶段:Fiber 应用副作用,调用渲染器 API(如 ReactDOM),创建或更新真实 DOM;

  6. 浏览器渲染 DOM,最终呈现出页面效果。

七、面试够用的补充要点(精准踩分,可直接背诵)

  • $$typeof:用于标记对象类型,值为 Symbol(react.element),防止外部伪造 React 元素,避免安全风险。

  • key:列表渲染时的唯一标识,帮助 React 在列表重排时复用已有节点,避免不必要的 DOM 重建,优化性能。

  • ref:用于获取真实 DOM 节点或组件实例,注意函数组件本身没有实例,需使用 forwardRef 才能接收 ref。

  • props.children:所有 JSX 子元素(如 <App>孩子</App>),都会被挂载到 props.children 上,包含在 ReactElement 的 props 中。

  • ReactElement 不包含状态(state)、生命周期方法和内部指针,这些信息都存储在 Fiber 节点上。

  • 更新机制:React 会对比新旧两个 ReactElement 树,生成副作用列表(插入、更新、删除),再通过 Fiber 执行这些副作用,最终更新 DOM。

八、简短口语版答案(面试应急,自然不生硬)

若面试时紧张,可用以下口语化表述,既专业又易懂,避免卡顿:

  • “JSX 是语法糖,编译后会变成 React.createElement 的调用,返回 ReactElement——一个不可变的 JS 对象,用来描述 UI 长什么样。React 会根据这个描述做 diff,内部构建 Fiber 树来调度和执行更新,最后由渲染器把变化用到 DOM 上。”

  • “简单说,ReactElement 是‘描述’,DOM 是‘呈现’,Fiber 是‘执行单元’,三者各司其职,互不相同。”

九、面试常考问题(带要点提示,可直接背诵答案)

以下是该考点 90% 以上的高频考题,每个问题均搭配“核心要点+标准答句”,无需拓展,背诵即可直接答题:

1. 问:JSX 和 React.createElement 有什么关系?

答:JSX 是 React.createElement 方法的语法糖,目的是简化 UI 描述的编写;经过 Babel 编译后,JSX 会直接转化为 React.createElement 的函数调用,二者本质是同一功能的不同写法。

2. 问:ReactElement 是什么?包含哪些核心字段?

答:ReactElement 是一个描述 UI 结构的纯 JavaScript 对象,本质是 UI 的“静态快照”;核心字段有5个:$$typeof(标记 React 元素)、type(元素类型)、props(元素属性)、key(列表唯一标识)、ref(获取 DOM/组件实例)。

3. 问:ReactElement 和 DOM 的区别是什么?

答:① ReactElement 是纯 JS 对象,仅用于描述 UI 信息,不参与渲染和更新,是“设计图纸”;② DOM 是浏览器中的真实节点,是“图纸落地后的实体”,承载页面呈现和用户交互;③ DOM 由 React 渲染器根据 ReactElement 和 Fiber 的描述创建/更新,二者本质不同。

4. 问:ReactElement、Fiber、DOM 三者的关系与职责分别是什么?

答:① 职责:ReactElement 负责描述 UI(静态快照),Fiber 负责 React 内部调度、协调更新(运行时单元),DOM 负责真实 UI 呈现;② 关系:JSX 编译生成 ReactElement,React 根据 ReactElement 构建/更新 Fiber 树,Fiber 驱动渲染器生成/更新 DOM。

5. 问:为什么 React 要用 Fiber?解决了什么问题?

答:Fiber 是 React 内部的更新单元,核心解决了“大型更新任务阻塞浏览器主线程”的问题;它可以将大任务拆分为多个小任务,实现可中断、可恢复的渲染,支持优先级调度,提升页面响应性。

6. 问:key 是什么,为什么重要?

答:key 是 ReactElement 的核心字段之一,是列表渲染时的唯一标识;它的重要性在于,帮助 React 在列表重排时快速识别哪些节点可以复用,避免不必要的 DOM 重建,从而优化渲染性能。

7. 问:ReactElement 可变吗?组件更新时会怎样?

答:ReactElement 是不可变的,一旦创建就无法修改其属性;组件更新时,React 会创建一个新的 ReactElement(携带新的 props、type 等信息),再通过 Fiber 对比新旧 ReactElement 的差异,执行相应的更新操作。

8. 问:ReactElement 的 type 可以是什么类型?

答:type 的类型主要有4种:① 字符串(如 'div'、'span'),表示原生 DOM 节点;② 函数或类,表示 React 组件;③ React.Fragment(碎片,用于包裹多个元素);④ Context、Portals 等特殊类型。

9. 问:ReactDOM.render 时,是如何把 ReactElement 转为 DOM 的?

答:分为两个核心阶段:① 协调阶段(Reconciliation):React 根据 ReactElement 与当前 Fiber 树对比,创建/更新 Fiber 节点,确定需要执行的更新操作;② Commit 阶段:Fiber 应用副作用,调用 ReactDOM 的 API,根据 Fiber 信息创建或更新真实 DOM,最终完成渲染。

10. 问:ref 存放在哪里?函数组件怎样获取 ref?

答:ref 是 ReactElement 的核心字段之一,用于存储对真实 DOM 节点或组件实例的引用;函数组件本身没有实例,无法直接接收 ref,需要使用 forwardRef 高阶组件,将 ref 转发到组件内部的 DOM 节点或子组件上。

面试背诵提示:答题时,优先用“JSX 是语法糖 → ReactElement 是描述 → Fiber 是执行单元”这条主线,串联三者的关系;遇到涉及 ReactElement 结构的问题,直接背诵其5个核心字段;所有答案无需过度拓展,精准踩中要点即可,既节省时间,又能体现专业性。

React:一个例子讲清楚 useEffect 和 useReducer

资料参考来源:小满zs

前言

这个例子,我将使用一个例子,讲解useEffectuseReducer。内容包含useEffect实现防抖,通过useReducer管理复杂的数据。

基础知识

useEffect

语法

useEffect(setup, dependencies?)
// dependencies 
  • dependencies:依赖项数组,控制副作用函数的执行时机。
dependencies依赖项 副作用功函数的执行时机
初始渲染一次 + 组件更新时执行
[] 初始渲染一次
指定依赖项 初始渲染一次 + 依赖项变化时执行

案例

useEffect是副作用函数,在组件渲染完之后执行。

  • 获取DOM
const abc = document.querySelector('abc');
console.log(abc) // null
useEffect(()=>{
  console.log(abc) // 可以获取
})

消除副作用

return一个函数。在副作用函数运行之前,会清除上一次的副作用函数。

据此,我们可以依照他写防抖(防抖相当于在王者荣耀里回城,只会执行最后一次)函数和节流(节流相当于在王者荣耀使用技能,使用完需要等待技能cd结束才能再次使用)函数。

  • 防抖
// 适合输入框下方的推荐
useEffect(() => {
  if(!props.id)  return;  // 这样只有在有输入的情况下进行fetch
  let timer = setTimeout(() => {
    fetch(`http://localhost:5174/?id=${props.id}`)
  }, 500)
  return () => {
clearTimeout(timer)
  }
},[props.id])
  • 节流
// 适合搜索
const timer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
  if (!props.id) return;
  if (!timer.current) {
    timer.current = setTimeout(() => {
      fetch(`http://localhost:5174/?id=${props.id}`);
      timer.current = null; // 允许下一次触发
    }, 500);
  }
  return () => {
    if (timer.current) clearTimeout(timer.current);
  }
}, [props.id]);

useReducer

语法

const [state, dispatch] = useReducer(reducer, initialArg, init?)
参数 作用
state 当前状态
dispatch 修改状态的方法,唯一方式,需要传入action(即进行那一项操作)
reducer 定义dispatch如何修改状态,传入stateaction
initialArg 初始状态
init? 可选初始化函数

购物车案例

import { useReducer } from "react";

// 初始值
const initData = [
  { name: "苹果", price: 100, count: 1, id: 1, isEdit: false },
  { name: "香蕉", price: 200, count: 1, id: 2, isEdit: false },
  { name: "梨", price: 300, count: 1, id: 3, isEdit: false },
];

// 定义 state 的数据类型
type State = typeof initData;

// reducer 定义 dispatch 如何修改状态
// state 当前状态
// action 
// - type 通过dispatch获得具体指令,定位具体进行什么操作
// - 其他 从外界获得的动态的参数
const reducer = (
  state: State,
  action: {
    type: "ADD" | "SUB" | "DELETE" | "EDIT" | "UPDATE_NAME";
    id: number;
    newName?: string;
  },
) => {
  // 通过传入 id 找到具体修改的一项
  const item = state.find((i) => i.id === action.id);
  if (!item) return state;
  switch (action.type) {
    case "ADD":
      return state.map((i) =>
        i.id === action.id ? { ...i, count: item.count + 1 } : i,
      );
    // 不太规范的写法如下:
    // item.count++;
    // return [...state];
    case "SUB":
      item.count--;
      if (item.count === 0) {
        return [...state.filter((i) => i.id !== action.id)];
      }
      return [...state];
    case "DELETE":
      return [...state.filter((i) => i.id !== action.id)];
    case "EDIT":
      item.isEdit = !item.isEdit;
      return [...state];
    case "UPDATE_NAME":
      if (action.newName !== undefined) item.name = action.newName.trim();
      return [...state];
    default:
      return [...state];
  }
};

const App = () => {
  const [state, dispatch] = useReducer(reducer, initData);

  return (
    <div className="abc">
      <div className="">
        <table>
          <thead>
            <tr>
              <th>名称</th>
              <th>单价</th>
              <th>数量</th>
              <th>总价</th>
              <th>操作</th>
            </tr>
          </thead>
          <tbody>
            {state.map((i) => (
              <tr key={i.id}>
                <td>
                  {i.isEdit ? (
                    <input
                      type="text"
                      value={i.name}
                      onChange={(e) => {
                        dispatch({
                          type: "UPDATE_NAME",
                          id: i.id,
                          newName: e.target.value,
                        });
                      }}
                    />
                  ) : (
                    <p>{i.name}</p>
                  )}
                </td>
                <td>{i.price}</td>
                <td className="flex">
                  <button
                    className=""
                    onClick={() => dispatch({ type: "SUB", id: i.id })}
                  >
                    -
                  </button>
                  <div className="">{i.count}</div>
                  <button
                    className=""
                    onClick={() => dispatch({ type: "ADD", id: i.id })}
                  >
                    +
                  </button>
                </td>
                <td>{i.price * i.count}</td>
                <td>
                  <button onClick={() => dispatch({ type: "EDIT", id: i.id })}>
                    修改
                  </button>
                  <button
                    onClick={() => dispatch({ type: "DELETE", id: i.id })}
                  >
                    删除
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
};

export default App;

案例

  • 通过fetch(https://jsonplaceholder.typicode.com/users/${state.userId}),获得一份userData的数据。
  • 案例内容为使用useReduceruseEffect,通过输入框输入userId,调用api获得UserData的数据。
import { useEffect, useReducer } from "react";

interface UserData {
  name: string;
  email: string;
  username: string;
  phone: string;
  website: string;
}

type State = UserData & {
  userId?: string;
};

type Action =
  | { type: "INPUT"; userId: string }
  | { type: "FETCH_DATA"; payload: UserData };

const initState: State = {
  userId: "",
  name: "",
  email: "",
  username: "",
  phone: "",
  website: "",
};

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "INPUT":
      return {
        ...state,
        userId: action.userId,
      };

    case "FETCH_DATA":
      return {
        ...state,
        ...action.payload,
      };

    default:
      return state;
  }
};

const App = () => {
  const [state, dispatch] = useReducer(reducer, initState);

  useEffect(() => {
    if (!state.userId) return;
    let timer = setTimeout(() => {
      const fetchData = async () => {
        try {
          const resp = await fetch(
            `https://jsonplaceholder.typicode.com/users/${state.userId}`,
          );
          if (!resp.ok) {
            throw new Error("网络响应不正常");
          }
          const data = await resp.json();
          dispatch({
            type: "FETCH_DATA",
            payload: {
              name: data.name,
              email: data.email,
              username: data.username,
              phone: data.phone,
              website: data.website,
            },
          });
        } catch (error) {
          console.log(error);
        }
      };
      fetchData();
    }, 500);
    return () => clearTimeout(timer);
  }, [state.userId]);

  return (
    <div style={{ padding: 40 }}>
      <h2>输入 User ID</h2>
      <input
        type="text"
        value={state.userId}
        onChange={(e) => dispatch({ type: "INPUT", userId: e.target.value })}
      />
      <h3>User Info</h3>
      <div>name: {state.name}</div>
      <div>username: {state.username}</div>
      <div>email: {state.email}</div>
      <div>phone: {state.phone}</div>
      <div>website: {state.website}</div>
    </div>
  );
};

export default App;

每日一题-找出不同的二进制字符串🟡

给你一个字符串数组 nums ,该数组由 n互不相同 的二进制字符串组成,且每个字符串长度都是 n 。请你找出并返回一个长度为 n 且 没有出现nums 中的二进制字符串如果存在多种答案,只需返回 任意一个 即可。

 

示例 1:

输入:nums = ["01","10"]
输出:"11"
解释:"11" 没有出现在 nums 中。"00" 也是正确答案。

示例 2:

输入:nums = ["00","01"]
输出:"11"
解释:"11" 没有出现在 nums 中。"10" 也是正确答案。

示例 3:

输入:nums = ["111","011","001"]
输出:"101"
解释:"101" 没有出现在 nums 中。"000"、"010"、"100"、"110" 也是正确答案。

 

提示:

  • n == nums.length
  • 1 <= n <= 16
  • nums[i].length == n
  • nums[i] '0''1'
  • nums 中的所有字符串 互不相同

5851. 找出不同的二进制字符串 - 模拟

5851. 找出不同的二进制字符串

第二道题。

给了咱几个二进制的字符串,要我们返回一个没有出现等长二进制字符串

想法比较简单,我们把出现的字符串都放进set里方便查找,然后遍历长度为n的二进制字符串,输出第一个没出现的即可。

因为遍历时要做二进制字符串的+1操作,涉及一点string的大数模拟,做过的应该都觉得蛮简单的。

才第二题,别想太多,写就完事了!

模拟

`
###c++

class Solution {
public:
    int n;
    string findDifferentBinaryString(vector<string>& nums) {
        n = nums.size();
        set<string> myhash;  //字符串集合
        for(auto s: nums) myhash.insert(s);  //把每个字符串都放入set
        string ans(n, '0');
        while(myhash.find(ans) != myhash.end()) add(ans);  //遍历到第一个找不到的字符串
        return ans;
    }
    void add(string& s){
        bool flag = true;  //表示进位
        for(int i = 0; i < n; ++i){
            if(flag){
                if(s[i] == '0'){  //0的进位处理
                    s[i] = '1';
                    flag = false;
                }else s[i] = '0';  //1的进位处理
            }else break; //没有进位,退出循环
        }
    }
};

康托对角线

解题思路

只要和第i个串下标i的字符nums[i][i]不同,构造出来的串就和所有的串都不同。

只限于串数不超过串长的情况。

时间复杂度O(n)

代码

###cpp

class Solution {
public:
    string findDifferentBinaryString(vector<string>& nums) {
        string ans;
        int n = nums.size();
        for (int i = 0; i < n; i++) {
            if (nums[i][i] == '0') {
                ans += '1';
            } else {
                ans += '0';
            }
        }
        return ans;
    }
};

两种方法:暴力枚举 / 康托对角线(Python/Java/C++/Go)

方法一:暴力枚举

把 $\textit{nums}$ 中的字符串转成二进制整数,保存到一个哈希集合中。

枚举 $\textit{ans} = 0,1,2,\ldots$ 直到 $\textit{ans}$ 不在哈希集合中,即为答案。

方法二告诉我们,满足要求的答案是一定存在的。

class Solution:
    def findDifferentBinaryString(self, nums: List[str]) -> str:
        st = {int(s, 2) for s in nums}

        ans = 0
        while ans in st:
            ans += 1

        n = len(nums)
        return f"{ans:0{n}b}"
class Solution {
    public String findDifferentBinaryString(String[] nums) {
        Set<Integer> set = new HashSet<>();
        for (String s : nums) {
            set.add(Integer.parseInt(s, 2));
        }

        int ans = 0;
        while (set.contains(ans)) {
            ans++;
        }

        String bin = Integer.toBinaryString(ans);
        return "0".repeat(nums.length - bin.length()) + bin;
    }
}
class Solution {
public:
    string findDifferentBinaryString(vector<string>& nums) {
        unordered_set<int> st;
        for (auto& s : nums) {
            st.insert(stoi(s, nullptr, 2));
        }

        int ans = 0;
        while (st.contains(ans)) {
            ans++;
        }

        int n = nums.size();
        return bitset<32>(ans).to_string().substr(32 - n);
    }
};
func findDifferentBinaryString(nums []string) string {
n := len(nums)
has := make(map[int]bool, n)
for _, s := range nums {
x, _ := strconv.ParseInt(s, 2, 64)
has[int(x)] = true
}

ans := 0
for has[ans] {
ans++
}

return fmt.Sprintf("%0*b", n, ans)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$,其中 $n$ 是 $\textit{nums}$ 的长度。把长为 $n$ 的字符串转成整数需要 $\mathcal{O}(n)$ 的时间。
  • 空间复杂度:$\mathcal{O}(n)$。

方法二:康托对角线

这个方法灵感来自数学家康托关于「实数是不可数无限」的证明。

例如 $\textit{nums} = [\texttt{111}, \texttt{011}, \texttt{000}]$。我们可以构造一个字符串 $\textit{ans}$,满足:

  • $\textit{ans}[0] = \texttt{0} \ne \textit{nums}[0][0]$。
  • $\textit{ans}[1] = \texttt{0} \ne \textit{nums}[1][1]$。
  • $\textit{ans}[2] = \texttt{1} \ne \textit{nums}[2][2]$。

$\textit{ans} = \texttt{001}$ 和每个 $\textit{nums}[i]$ 都至少有一个字符不同,满足题目要求。

一般地,令 $\textit{ans}[i] = \textit{nums}[i][i]\oplus 1$,即可满足要求。其中 $\oplus$ 是异或运算。

class Solution:
    def findDifferentBinaryString(self, nums: List[str]) -> str:
        ans = [''] * len(nums)
        for i, s in enumerate(nums):
            ans[i] = '1' if s[i] == '0' else '0'
        return ''.join(ans)
class Solution {
    public String findDifferentBinaryString(String[] nums) {
        int n = nums.length;
        char[] ans = new char[n];
        for (int i = 0; i < n; i++) {
            ans[i] = (char) (nums[i].charAt(i) ^ 1);
        }
        return new String(ans);
    }
}
class Solution {
public:
    string findDifferentBinaryString(vector<string>& nums) {
        int n = nums.size();
        string ans(n, 0);
        for (int i = 0; i < n; i++) {
            ans[i] = nums[i][i] ^ 1;
        }
        return ans;
    }
};
func findDifferentBinaryString(nums []string) string {
ans := make([]byte, len(nums))
for i, s := range nums {
ans[i] = s[i] ^ 1
}
return string(ans)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。注意这个方法没有遍历整个字符串,只访问了每个字符串的其中一个字符。
  • 空间复杂度:$\mathcal{O}(1)$,返回值不计入。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

❌