阅读视图

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

马斯克:SpaceX估值8000亿美元不准确;传阿里系App禁豆包手机登录;140 万,领克 03+ TCR开售秒光

马斯克:SpaceX 正以 8000 亿美元估值融资的消息并不准确

华尔街日报此前报道称 SpaceX 正启动新一轮二次股票出售,若交易完成,其公司估值有望翻倍至 8000 亿美元(现汇率约合 5.66 万亿元人民币),一举超越 OpenAI 成为全美最有价值的私营公司。与此同时,SpaceX 高管层明确表示,公司正在评估于 2026 年进行首次公开募股(IPO)的可能性。

对此,马斯克于当地时间周六否认 SpaceX 正以 8000 亿美元估值融资,但未回应关于该公司计划出售内部股份的报道。

马斯克在其社交媒体平台 X 的一篇帖子中表示:「SpaceX 以 8000 亿美元估值融资的消息并不准确。SpaceX 多年来一直保持现金流为正,且每年会定期进行两次股票回购,为员工和投资者提供流动性。估值的提升取决于星舰(Starship)和星链(Starlink)的推进进展,以及获得全球直连手机网络的频谱,这将大幅扩大公司可服务市场的规模。」(来源:IT 之家)

美国知名机器人公司iRobot爆雷

据报道,美国消费机器人巨头iRobot被曝陷入严重财务危机。截至11月24日,该公司欠中国深圳代工厂杉川机器人超3.5亿美元,折合人民币约25亿元。而其现金储备仅剩2480万美元,已处于技术性破产边缘。

iRobot于1990年由麻省理工学院教授创立。2002年推出首款Roomba扫地机器人,曾占据美国扫地机器人市场80%以上份额,产品还广泛应用于军事、救援等领域。

2022年,公司迎来转折点,全球各区域市场全面下滑。营收同比下降24%至11.83亿美元,净亏损2.863亿美元。当年亚马逊提出收购计划,但因反垄断审查失败。此后,iRobot裁员350人,占总人数的31%,CEO辞职,战略收缩,财务状况进一步恶化。

其定价策略僵化,产品竞争力持续下降。今年全球市场份额已跌至7.9%。截至今年9月,公司总资产4.81亿美元,总负债5.08亿美元,股东权益为-2680万美元,已正式资不抵债。

目前iRobot正与杉川就债务解决方案“积极磋商”。业内普遍认为杉川的介入是「iRobot唯一可能的生存机会」,但也意味着公司控制权将落入中国企业手中。(来源:新浪财经)

 

Meta 收购 AI 创企 Limitless 热门记忆挂件停售

Meta 近日宣布收购人工智能初创公司 Limitless,此举将使后者旗下备受关注的 AI 记忆穿戴设备 Limitless Pendant 停止对新用户销售。Limitless 专注于通过软件与专用硬件增强人类记忆与认知能力,其主打产品是一款体积小巧、外观简洁的 AI 挂件,可持续记录和处理用户的日常信息。

业内认为这次收购有助于 Meta 在 AI 可穿戴设备领域与正与 Jony Ive 合作开发新形态设备的 OpenAI 展开更直接的竞争。

Limitless 在官网公告中表示,公司已被 Meta 收购,将携手推动「令人惊叹的 AI 可穿戴设备」以及「个人超级智能」的愿景落地。收购完成后,Limitless 将不再面向新用户销售 Pendant 硬件产品,但现有用户的设备在未来至少一年内仍将继续获得官方支持。此外,原本需要付费订阅的 Unlimited Plan 也已对所有现有客户免费开放,无需再支付订阅费用,作为对产品即将退出市场的一种补偿安排。(来源:cnBeta)

 

大众集团 CEO 奥博穆:到 2030 年前将为新车、技术等领域投入 1600 亿欧元

大众集团 CEO 奥博穆今天在《法兰克福汇报》的采访中表示,大众将在 2030 年前投入 1600 亿欧元(现汇率约合 1.32 万亿元人民币),和前几版五年规划比起来略有收紧。

此前的投资安排为 2025-2029 年的 1650 亿欧元(现汇率约合 1.36 万亿元人民币),以及 2024-2028 年的 1800 亿欧元(现汇率约合 1.48 万亿元人民币),其中 2024 年达到最高点。

过去一段时间,大众旗下保时捷与奥迪同时受到美国加征关税以及中国市场竞争加剧的挤压。保时捷的利润受影响尤为显著,原因在于其过半销量来自这两个市场,且电动汽车战略被迫退回。

奥博穆在表示,新一轮投资将重点投向德国与欧洲,包括产品、技术以及基础设施。他预计,围绕保时捷更大规模的节约方案的谈判会延续到 2026 年。(来源:IT 之家)

继微信出现「被动下线」之后,阿里系 App 禁止豆包手机登录

 12 月 6 日消息,豆包与中兴合作的首款「豆包助手」手机一经发售就引发热议,继微信出现被动下线后,经搜狐科技测试,阿里系多款应用——淘宝、淘宝闪购、闲鱼、大麦等 App 已开始拒绝「豆包手机」登录。

据悉,若用户手动操作打开上述 App,将触发安全机制,弹出登录受阻提示。与此同时,手机银行和金融类 App,也先后出现了针对 AI 、屏幕共享的监测以及风控措施。多名用户反馈,在使用豆包 AI 手机助手时,遭到了农行、建行 App 内的强弹窗提醒,要求关闭 AI 手机助手后再进行使用。

此外,游戏类 App 如《王者荣耀》同样能监测到 AI 控制,目前仅支持手动打开,AI 助手无法对其进行开启或控制。(来源:IT 之家)

 

微软确认:Win11 用户将可彻底移除文件资源管理器右键菜单中的「AI 操作」选项

 12 月 7 日消息,据 WindowsLatest 报道,微软已回应用户呼声,将在 Windows 11 的下一次更新中使文件资源管理器右键菜单中的「AI 操作」(AI Actions)功能完全可选。此前,即使用户已在系统设置中关闭该功能,「AI 操作」仍会出现在右键上下文菜单中;如今这一问题即将得到解决。

除了使「AI 操作」变为可选外,微软还在测试一个更简洁的上下文菜单(即右键菜单)。例如,「压缩为…」和「复制为路径」等选项将被移至一个名为「管理文件」(Manage file)的新子菜单中,该菜单还整合了 TAR、RAR、ZIP 等压缩格式选项。这会使菜单杂乱程度显著降低,整体视觉上也更为紧凑。

此外,微软还将所有与 OneDrive 相关的选项(如「始终保留在本设备」和「释放空间」)统一归入单一的「OneDrive」主项下,进一步简化菜单结构。(来源:IT 之家)

140 万元,领克 03+ TCR 赛车首批订单开售即售罄

领克汽车近日宣布领克 03+ TCR 赛车首批订单已售罄(2026 年 4 月前产能)。该车是全球首台中国品牌量产 TCR 赛车并于今晚开售,价格为 140 万元。

这款新车的具体配置,IT之家此前进行了详细报道。该车的动力系统基于 T6 发动机升级了散热部件,搭载 1046 六速序列式变速箱,采用拨片换挡设计。

制动系统方面,新车搭载前六活塞一体卡钳 380mm 制动盘、后两活塞 290mm 制动盘。此外,这款新车还匹配全新设计的 Gen3 空气动力学套件,车内设有防滚架、专业赛车座椅等设施。

具体动力方面,新车峰值马力 350Ps,峰值扭矩 420N・m,包含车手的目标重量为 1265kg。值得一提的是,该车还更换涡轮并重新标定程序,可全面优化直线动力输出。(来源:IT之家)

 

迈凯伦 750S 艺术特别版亮相:首创「色彩分层」涂装,24K 纯金车标

汽车媒体 CarBuzz 12 月 6 日发布博文,报道称在迈阿密艺术周(Miami Art Week)上,迈凯伦(McLaren)揭晓基于高性能超跑 750S 打造的「色彩学项目」(Project Chromology)。

该项目由迈凯伦特别定制中心(MSO)与知名艺术家 Nat Bowen 深度合作完成。据博文介绍,Bowen 以树脂艺术创作和「色彩学」(即色彩心理学)研究闻名,此次合作希望探索色彩如何塑造情绪与身份,将原本冰冷的机械载体转化为具有生命力的艺术表达。

为了实现这一理念,MSO 工程师与 Bowen 共同研发了全新的「色彩分层饰面」(Chromatic Layered Finish)。该工艺利用多层半透明涂料进行精密叠加,创造出随光线变化而流动的色调深度。

外观极具艺术气息外,这辆 750S 的性能核心依然强悍。车辆搭载 4.0 升双涡轮增压 V8 发动机,匹配 7 速自动变速箱,采用后轮驱动布局。该动力系统可爆发出 740 马力(hp)的最大功率和 590 lb-ft 的峰值扭矩。

迈凯伦目前尚未公开「色彩学项目」的具体售价,但明确表示这并非量产车型。该项目仅面向「特定的 MSO 客户群」开放,采取按需委托(Commission)的模式进行生产。考虑到复杂的涂装工艺、纯金材质的应用以及包含的原创艺术品,其造价预计将远超普通版 750S,成为顶级收藏家的专属目标。(来源:IT 之家)

家猫在唐朝前后抵达中国

根据发表在《Cell Genomics》的一项遗传分析,今天的家猫(Felis catus)是在唐朝前后抵达中国。此前中国出现的捕鼠动物是豹猫(Prionailurus bengalensis)。现代家猫起源于近东的非洲野猫,经过驯化后传播至全球。中国最早的猫科动物考古记录来自距今五千多年的陕西泉护村遗址,一具猫类遗骸显示其与人类关系密切,曾被认为可能是家猫,但后被确认为体型与家猫相近的本土猫科动物豹猫。因此家猫何时以及如何传入中国的问题一直悬而未解。

为解答这一问题,北大博士后韩雨和同事采集和分析了来自人居环境、时间跨度超过五千年的 22 份小型猫科动物骨骼样本,涵盖了中国已知大部分的古代猫类遗存。通过古 DNA 技术获得了全部 22 份线粒体基因组和 7 份全基因组。其中 7 份为豹猫样本,年代从 5400 年前新石器时代晚期的仰韶文化延续至 1800 年前的东汉末年,揭示了豹猫与人类持续 3500 多年的密切关系。研究中 14 份样本鉴定为家猫,均来自唐代及其后的时期。

中国迄今最早的家猫遗骸出土于陕西靖边唐朝统万城遗址,碳14测年为公元 706 至 883 年,距今约 1200 年。基因组表型复原显示,该猫为雄性,毛色可能为纯白或白斑狸花,短毛、长尾,且不携带现代家猫常见的遗传缺陷。

结合文献记载与考古图像,家猫传入中国的时间应早于出土遗存的年代,可能在公元 6—7 世纪唐代前后。基因组分析进一步确定了家猫传入中国的路线。

中国唐代家猫与哈萨克斯坦占肯特遗址出土的同时期家猫,以及近东黎凡特地区的非洲野猫和家猫遗传关系紧密。这三地恰位于陆上丝绸之路的重要枢纽,表明家猫很可能随商旅往来,经由丝绸之路自地中海东岸途经中亚传入中国。(来源:solidot)

# Vue 渲染系统的四个关键阶段:从模板编译到新旧 VDOM Patch 的完整机制解析

现代 UI 框架的核心目标是在数据变化时以最小代价更新视图。Vue 通过将整个渲染流程拆分为四个阶段,使得 UI 架构具备可操作性、可维护性与高性能。本文将系统介绍 Vue 中:

  • 模板如何生成渲染逻辑
  • 组件如何组织渲染过程
  • 响应式系统如何触发更新
  • 新旧 VDOM 如何对比和更新 DOM

你将清楚理解 渲染函数来自哪里,渲染 effect 如何被触发,以及新旧 VDOM 在整个生命周期中如何被获取和使用。


第一阶段:模板编译(Template Compilation)——从静态模板到可执行渲染逻辑

所有 Vue 组件都从 template 开始,但模板本质上只是字符串,Vue 无法直接执行,因此编译阶段负责将模板转化为最终的渲染函数(render)。

以一个简单模板为例:

<div>{{ count }}</div>

编译器会将其解析为 AST(抽象语法树),再转换为具有结构化意义的渲染代码,最终生成的渲染函数类似:

function render(_ctx) {
  return createElementBlock("div", null, _ctx.count, 1 /* TEXT */)
}

在这一阶段,有三项关键优化能力:

1. 静态与动态节点区分

静态节点被提升,只生成一次;动态节点会带有 PatchFlag,如 TEXT、PROPS 等,用于精确更新。

2. 渲染函数是纯 JS,不包含 DOM 操作

渲染函数只会构建 VNode(虚拟 DOM),不会直接访问浏览器 DOM。

3. 结构化信息为后续的 diff 提供优势

编译后的代码比运行时解释模板更快,可直接参与 diff 计算。

编译阶段的产物 render() 是 UI 渲染的核心入口,也是所有 VDOM 的唯一来源。


第二阶段:组件渲染 effect(Render Effect)——渲染函数的执行与 VDOM 的生成者

每个 Vue 组件实例在初次挂载时都会创建一个 渲染 effect。这是 Vue 中真正负责“渲染 UI”与“响应式驱动更新”的核心单元。

Vue 内部会执行:

instance.update = effect(() => {
  const subTree = render(instance.ctx)
  patch(instance.vnode, subTree)
  instance.vnode = subTree
})

这个渲染 effect 具备以下职责:

1. 执行渲染函数并生成新的 VNode(新 VDOM)

渲染函数访问 ctx 中的响应式数据,从而生成一套全新的虚拟 DOM 树。

2. patch 负责向真实 DOM 提交变化

渲染 effect 本身不处理 DOM,它只是负责调用 patch 让 DOM 更新。

3. effect 会在执行过程中记录依赖

访问 _ctx.xxx 时,响应式 getter 会执行 track,将渲染 effect 与数据绑定。

4. 渲染 effect 是组件级而非节点级

无论模板多大,组件始终只有一个渲染 effect。

渲染 effect 是“新 VDOM 的来源者”,并同时保存上一次渲染的旧 VDOM。

这一点非常关键,下一阶段我们将看到如何利用它。


第三阶段:响应式触发(Reactive Trigger)——数据变化驱动重新渲染的机制核心

当响应式数据修改时:

state.count++

Vue 的响应式系统会执行:

1. setter → trigger(target, key)

找到依赖该 key 的所有 effect,此处即 渲染 effect

2. 调度渲染 effect 进入队列(scheduler)

Vue 不会立即重新渲染,而是进行批处理,以优化性能。

3. 下一轮事件循环执行渲染 effect

重新执行:

render(ctx)
patch(oldVNode, newVNode)

这是一次完整的 UI 更新动作:

  • 获取新 VDOM(新 vnode)
  • 与旧 VDOM(上次 vnode)进行 diff
  • 将真实 DOM 局部更新到最新状态

响应式系统的核心作用不是更新 DOM,而是触发渲染 effect。

DOM 更新发生在下一阶段。


第四阶段:虚拟 DOM Diff 与 DOM Patch —— 新旧 VDOM 的来源与交替

这部分是你最关心的:“新旧 VDOM 分别从哪里来?Vue 是如何获取它们并做 diff 的?”

我们先给出答案:


新 VDOM 的来源:由当前执行的 render() 生成

在渲染 effect 内:

const newVNode = render(instance.ctx)

每次执行 render,都会返回一棵全新的 VNode 树,这就是 新 VDOM

它不来自缓存、不来自 DOM,而是 render 的直接产物。


旧 VDOM 的来源:组件实例 instance.vnode 中保存的上一次结果

第一次渲染时,Vue 会执行:

instance.vnode = render(instance.ctx)
patch(null, instance.vnode)

第二次更新时:

const newVNode = render(ctx)
patch(instance.vnode, newVNode)
instance.vnode = newVNode

可以总结:

  • 旧 VDOM = 上一次渲染函数生成的 VNode,被 Vue 存储在 instance.vnodeinstance.subTree
  • 新 VDOM = 当前渲染函数生成的 VNode

它们都来自渲染函数,不通过 DOM 查询获得。

完整的生命周期结构如下:

第一次:
newVNode = render()
patch(null, newVNode)
oldVNode ← newVNode

第二次:
newVNode = render()
patch(oldVNode, newVNode)
oldVNode ← newVNode

第三次:
newVNode = render()
patch(oldVNode, newVNode)
oldVNode ← newVNode

它们在每次更新中不断交替,像接力棒一样传递。


总结:Vue 渲染系统的完整四阶段链路

最终我们可以将 Vue 的渲染管线抽象为四个连续的大阶段:

1) Template Compile
   模板 → 渲染函数(纯 JS、无 DOM)

2) Render Effect
   执行渲染函数 → 得到新 VDOM
   保存旧 VDOM → instance.vnode

3) Reactivity Trigger
   数据变化 → 触发渲染 effect 重新执行

4) Patch (Diff → DOM Update)
   patch(oldVNode, newVNode)
   最小成本更新真实 DOM

其中:

  • 新 VDOM 来自 render()
  • 旧 VDOM 来自组件实例保存的上一次 render() 结果
  • patch 是最终落实 DOM 更新的唯一阶段

这就是 Vue 数据变化后自动更新 UI 的真正内部机制。

uni-app 也能远程调试?使用 PageSpy 打开调试的新大门!

大家好,我是不如摸鱼去,wot-ui 的“主理人”,欢迎来到我的 uni-app 分享专栏。

如果你是一个前端开发,我想你肯定经历过测试、项目验收时报障,本地却无法复现的情况,又或者本地无法满足复现环境的情况。这时候的你是不是抓耳挠腮不知所措?

等到测试反馈,流程慢如闪电?

当然,解决方案肯定是有的,埋点、vConsole 以及远程调试工具可以帮我们解决这一问题,本文将会使用 PageSpy 结合 uni-app 模板 wot-starter 实践 uni-app 的远程调试。

为什么要远程调试?

以下内容来自 PageSpy 官网,我认为非常有道理。

任何无法在本地使用控制台调试的场景,都是 PageSpy 可以大显身手的时候! 一起来看下面的几个场景案例:

  • 本地调试 H5、Webview 应用:移动端屏幕太小,传统调试面板操作不便、显示不友好,且容易出现信息截断;
  • 远程办公、跨地区协同:传统沟通方式(邮件、电话、视频会议)效率低,故障信息不完整,容易误解误判;
  • 用户终端白屏问题排查:数据监控、日志分析等传统方式依赖排障人员对业务和技术的深入理解,定位效率低;

PageSpy 是什么?

PageSpy 是一款用来调试 Web / ReactNative / 小程序 / 鸿蒙 APP 等平台项目的工具。支持 Web 的远程调试工具不少,但能支持 uni-app 的调试工具真的是凤毛麟角了。

PageSpy 如何使用?

部署

部署文档见部署说明:www.pagespy.org/#/docs/depl… Docker 部署(正好买了个轻量级服务器没用):

docker run -d --restart=always -v ./log:/app/log -v ./data:/app/data -p 6752:6752 --name="pageSpy" ghcr.io/huolalatech/page-spy-web:latest

执行完成后,打开浏览器访问 http://localhost:6752 即可访问服务。

使用

参见小程序快速上手文档:www.pagespy.org/#/docs/mini…

在 wot-starter 中安装 @huolala-tech/page-spy-uniapp

pnpm add @huolala-tech/page-spy-uniapp@latest

在 main.ts 中引入 SDK 并实例化,可通过配置项 www.pagespy.org/#/docs/api 自定义 SDK 的行为:

// 引入 SDK
import PageSpy from '@huolala-tech/page-spy-uniapp'
import { createSSRApp } from 'vue'
import App from './App.vue'
import router from './router'

import 'uno.css'

const $pageSpy = new PageSpy({
  api: '<your-pagespy-host>', // 这里替换成你的 pagespy 地址
  enableSSL: false, // 视情况开启
})

const pinia = createPinia()
pinia.use(persistPlugin)
export function createApp() {
  const app = createSSRApp(App)
  // 全局挂载
  app.config.globalProperties.$pageSpy = $pageSpy
  app.use(router)
  app.use(pinia)
  return {
    app,
  }
}

执行 pnpm dev:mp-weixin后,我们打开部署的 PageSpy 点击开始调试就可以使用了!

我们可以看到有输出、网络、存储等内容。

总结

今天我们在 wot-starter 中简单尝试了下 PageSpy 的功能,其实 PageSpy 在 web 上有更强大的能力,不过我们主要关注它在小程序/uni-app 上的表现,更多能力大家可以自行探索了。还有大家可以多多关注 PageSpy 和 wot-starter 为他们点赞哦👍。

参考资料

PageSpy: www.pagespy.org/#/
wot-starter: starter.wot-ui.cn/

往期精彩

Trae SOLO 正式发布了?我用它将像老乡鸡那样做饭小程序开源了!

老乡鸡也开源?我用 Trae SOLO 做了个像老乡鸡那样做饭小程序!

当年偷偷玩小霸王,现在偷偷用 Trae Solo 复刻坦克大战

告别 HBuilderX,拥抱现代化!这个模板让 uni-app 开发体验起飞

uni-app 还在手写请求?alova 帮你全搞定!

uni-app 无法实现全局 Toast?这个方法做到了!

Vue3 uni-app 主包 2 MB 危机?1 个插件 10 分钟瘦身

欢迎评论区沟通、讨论👇👇

【Virtual World 03】上帝之手

这是纯前端手搓虚拟世界第三篇。

小小抱怨一下,感觉没啥人关注这种哇。

悲伤~

a4.jpg

本期代码量超标!!慎重!

gogogo!

欸,朋友,我们已经在前面构建了点(Point2D)和线(Segment)这两个类了嘛。

image.png

咳咳~这两天看新疆风味视频有点多了。脑子里大概模仿了下。

回归正题,在前两篇中,我们搞出了一个分形树还有一个喷绘效果。但那些玩意,从艺术角度来说就很空洞。

为什么?因为它无法交互,光看不能摸

一个真正的虚拟世界,没有“上帝”,那是不完美的。本篇要解决的,就是给这个虚拟世界装上“上帝之手”,而这个上帝之手的连接线,就是鼠标。这样,就能从“看画模式”进化到“编辑模式”。


战略思考

好了,暂停下吹牛,先思考下,如果是鼠标交互目前的虚拟世界,该怎么弄???

98b9a8d586cc43f2a6248b86dab2de2b.gif

思考完毕,我给出我的方案。再撸一个类**图形编辑器(Graph Editor)**,总体如下:

  1. 数据容器(graph):我们需要一个容器,专门管理所有的Point(点)和Segment(线段)。不能再像画分型图那样写死坐标了。
  2. 交互控制器(graphEditor):监听鼠标的点击(Left Click)、移动(Move)、右键(Right Click)。
  3. 视觉交互
    • 鼠标悬停在点上,点要变亮(Hover 态)。
    • 选中一个点后,鼠标移动要带出一条虚线(Intention,意图)。
    • 点击右键,取消操作或删除元素。

嗯~跟你想的差不多吧?

战术制定

好了,思路有了,开始具体的代码实现。

为了代码的健壮性,不能把代码全堆在 index.js 里,不然后续,就会变成屎山,身为一个有抱负的前端佬,我们得先抽个象。

详细步骤如下:

  • Graph。它像一个数据库,只管存点、存线、加、删、画。
  • GraphEditor。它是逻辑大脑,负责处理整个鼠标事件和交互,判断“我现在点到了谁”。
  • 构建一个数学工具。计算鼠标靠近哪个点以及距离,用于判断是否选中(也就是高中数学:两点间距离)。
  • 重写 World。启用 requestAnimationFrame 动画循环,因为交互是实时的,要绘制线的拉伸效果,画面需要每次都去更新,目前定为每秒刷新 60 次,后续会重点说明这个。

先不解释,上代码!

数据容器:Graph

src 下新建 math 文件夹,创建 graph.js。 它的职责非常单纯:管数据

// src/math/graph.js
export default class Graph {
  constructor(points = [], segments = []) {
    this.points = points;
    this.segments = segments;
  }

  // 添加点
  addPoint(point) {
    this.points.push(point);
  }

  // 判断是否已经有点在这个位置了(防止重叠点)
  containsPoint(point) {
    return this.points.find((p) => p.equals(point));
  }

  // 添加线段
  addSegment(seg) {
    this.segments.push(seg);
  }

  // 判断线段是否已存在
  containsSegment(seg) {
    return this.segments.find((s) => s.equals(seg));
  }

  // 尝试添加点(去重)
  tryAddPoint(point) {
    if (!this.containsPoint(point)) {
      this.addPoint(point);
      return true;
    }
    return false;
  }

  // 尝试添加线段(去重)
  tryAddSegment(seg) {
    if (!this.containsSegment(seg)) {
      this.addSegment(seg);
      return true;
    }
    return false;
  }

  // 删除点(非常重要:删点的时候,连着这个点的线也要一起删掉!)
  removePoint(point) {
    // 1. 从 points 数组移除
    this.points.splice(this.points.indexOf(point), 1);
    // 2. 过滤掉所有包含这个点的线段
    const segs = this.segments.filter((s) => s.includes(point));
    for (const seg of segs) {
      this.removeSegment(seg);
    }
  }

  // 删除线
  removeSegment(seg) {
    this.segments.splice(this.segments.indexOf(seg), 1);
  }

  // 这里的 draw 只是一个简单的代理,把任务分发给具体的元素
  draw(ctx) {
    for (const seg of this.segments) {
      seg.draw(ctx);
    }
    for (const point of this.points) {
      point.draw(ctx);
    }
  }
}

补充修改: 我们的 PointSegment 类之前比较简单,为了支持上面的去重和删除,我们需要在 Point2D.jsSegment.js 加两个小 方法(不用重写,加方法即可)。

手动给 src/primitives/point2D.js 添加:

  // 判断两个点坐标是否一样
  equals(point) {
    return this.x === point.x && this.y === point.y;
  }

手动给 src/primitives/segment.js 添加:

  // 判断线段是否包含某个点
  includes(point) {
    return this.p1.equals(point) || this.p2.equals(point);
  }
  
  // 判断两条线是否一样(方向不同也算同一条线)
  equals(seg) {
    return this.includes(seg.p1) && this.includes(seg.p2);
  }

交互控制器:GraphEditor

这是今天的重头戏。在 src 下新建 editors 文件夹,创建 graphEditor.js

逻辑有点绕,我给你理一下:

  1. 鼠标移动 (Move) -> 检查附近有没有点 -> 有就高亮 (hovered)。
  2. 鼠标点击 (Down) ->
    • 左键
      • 如果点在空地 -> 创建新点。
      • 如果之前选中了一个点 (selected) -> 连线。
      • 最后把当前点设为 selected(作为下一次连线的起点)。
    • 右键
      • 如果正在连线(有 selected) -> 取消选中(停止连线)。
      • 如果没有连线,但鼠标下有点 (hovered) -> 删除这个点。
// src/editors/graphEditor.js
import Point2D from "../primitives/point2D.js";
import Segment from "../primitives/segment.js";

export default class GraphEditor {
  constructor(canvas, graph) {
    this.canvas = canvas;
    this.graph = graph;

    this.ctx = canvas.getContext("2d");

    // 状态机
    this.selected = null; // 当前选中的点(用于连线起点)
    this.hovered = null;  // 鼠标悬停的点
    this.dragging = false; // 预留给未来拖拽用
    this.mouse = null;    // 当前鼠标位置

    // 启动监听
    this.#addEventListeners();
  }

  #addEventListeners() {
    // 1. 鼠标按下事件
    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有左键(0)和右键(2)才处理
      if (evt.button == 2) { 
        // 右键逻辑
        if (this.selected) {
           this.selected = null; // 取消当前选中,停止连线
        } else if (this.hovered) {
           this.#removePoint(this.hovered); // 删除点
        }
      } 
      
      if (evt.button == 0) {
        // 左键逻辑
        // 如果鼠标在某个点上,就选中它;如果不在,就新建一个点并选中它
        if (this.hovered) {
          this.#select(this.hovered);
          this.dragging = true;
          return;
        }
        this.graph.tryAddPoint(this.mouse);
        this.#select(this.mouse); // 自动选中新点,方便连续画线
        this.hovered = this.mouse;
        this.dragging = true;
      }
    });

    // 2. 鼠标移动事件
    this.canvas.addEventListener("mousemove", (evt) => {
      // 获取鼠标在 Canvas 里的坐标(即使 Canvas 缩放或偏移也能用)
      // 这里先简化处理,假设 Canvas 铺满或者无偏移
      // 实际上我们应该写个 getViewportPoint,但暂时先直接读取 offsetX/Y
      this.mouse = new Point2D(evt.offsetX, evt.offsetY);
      
      // 检查鼠标有没有悬停在某个点上
      this.hovered = this.#getNearestPoint(this.mouse);
      
      // 移动的时候不需要重绘吗?需要的,但我们会在 World 里统一驱动动画循环
    });
    
    // 3. 禁止右键菜单弹出
    this.canvas.addEventListener("contextmenu", (evt) => evt.preventDefault());
    
    // 4. 鼠标抬起(结束拖拽状态)
    this.canvas.addEventListener("mouseup", () => this.dragging = false);
  }

  #select(point) {
    // 如果之前已经选中了一个点,现在又选了一个点,说明要连线
    if (this.selected) {
      // 尝试添加线段
      this.graph.tryAddSegment(new Segment(this.selected, point));
    }
    this.selected = point;
  }

  #removePoint(point) {
    this.graph.removePoint(point);
    this.hovered = null;
    if (this.selected == point) {
        this.selected = null;
    }
  }

  // 辅助函数:找离鼠标最近的点
  #getNearestPoint(point, minThreshold = 15) {
     let nearest = null;
     let minDist = Number.MAX_SAFE_INTEGER;
     
     for (const p of this.graph.points) {
       const dist = Math.hypot(p.x - point.x, p.y - point.y);
       if (dist < minThreshold && dist < minDist) {
         minDist = dist;
         nearest = p;
       }
     }
     return nearest;
  }

  // 专门负责画编辑器相关的 UI(比如高亮、虚线)
  display() {
    this.graph.draw(this.ctx);

    // 如果有悬停的点,画个特殊的样式
    if (this.hovered) {
      this.hovered.draw(this.ctx, { outline: true });
    }
    
    // 如果有选中的点,也高亮一下
    if (this.selected) {
        // 获取鼠标位置作为意图终点
        const intent = this.hovered ? this.hovered : this.mouse;
        // 画出“虚拟线条”:从选中点 -> 鼠标位置
        new Segment(this.selected, intent).draw(this.ctx, { color: "rgba(0,0,0,0.5)", width: 1, dash: [3, 3] });
        this.selected.draw(this.ctx, { outline: true, outlineColor: "blue" });
    }
  }
}

注意:上面的代码里用到了 Segmentdash 属性,你需要去 segment.jsdraw 方法里微调一下,加个 setLineDash

微调 src/primitives/segment.js 的 draw 方法:

  draw(ctx, { width = 2, color = "black", dash = [] } = {}) {
    ctx.beginPath();
    ctx.lineWidth = width;
    ctx.strokeStyle = color;
    ctx.setLineDash(dash); // 新增:支持虚线
    ctx.moveTo(this.p1.x, this.p1.y);
    ctx.lineTo(this.p2.x, this.p2.y);
    ctx.stroke();
    ctx.setLineDash([]); // 重置,防止影响其他绘制
  }

组装世界:重构 World

现在我们把 GraphGraphEditor 装进 World 里,并启动动画循环。

修改 src/index.js

import Point2D from "./primitives/point2D.js";
import Segment from "./primitives/segment.js";
import Graph from "./math/graph.js";
import GraphEditor from "./editors/graphEditor.js";

export default class World {
  constructor(canvas, width = 600, height = 600) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    this.canvas.width = width;
    this.canvas.height = height;

    // 1. 初始化空图
    this.graph = new Graph();
    // 2. 初始化编辑器
    this.editor = new GraphEditor(this.canvas, this.graph);

    // 3. 启动动画循环
    this.animate();
  }

  animate() {
    // 清空画布(重要!否则画面会重叠)
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // 让编辑器去决定画什么(它包含图和交互UI)
    this.editor.display();

    // 递归调用,保持 60FPS
    requestAnimationFrame(() => this.animate());
  }
  
  // 原来的 display 方法可以删了,或者留着作纪念
}

效果讲解

现在,当你运行页面时,用鼠标点点看看:

  1. 高亮 (Hover):创建一个点后,鼠标移到点上会有蓝色高亮。
  2. 虚拟连线 (Intent):会有虚拟连线,引导直线方向。
  3. 删除点和线:当你右键删除点时,Graph 类的 removePoint 不仅删了点,还顺手把连接这个点的所有线段都干掉了。

如果看解释有点晕,试一试就知道了!!!!

image.png


最后秀一把?微抖的上帝之手。

实在没啥可以秀的,非要秀,我直接给你画个爱心:

image.png

vxe-gantt 甘特图实现产品进度列表,自定义任务条样式和提示信息

vxe-gantt 甘特图实现产品进度列表,自定义任务条样式和提示信息

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

效果

image

代码

通过 task-view-config.viewStyle.cellStyle 设置任务视图单元格样式,使用 taskBar、taskBarTooltip 插槽来自定义模板

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions">
      <template #task-bar="{ row }">
        <div class="custom-task-bar" :style="{ backgroundColor: row.bgColor }">
          <div class="custom-task-bar-img">
            <vxe-image :src="row.imgUrl" width="60" height="60"></vxe-image>
          </div>
          <div>
            <div>{{ row.title }}</div>
            <div>开始日期:{{ row.start }}</div>
            <div>结束日期:{{ row.end }}</div>
            <div>进度:{{ row.progress }}%</div>
          </div>
        </div>
      </template>

      <template #task-bar-tooltip="{ row }">
        <div>
          <div>任务名称:{{ row.title }}</div>
          <div>开始时间:{{ row.start }}</div>
          <div>结束时间:{{ row.end }}</div>
          <div>进度:{{ row.progress }}%</div>
        </div>
      </template>
    </vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const ganttOptions = reactive({
  border: true,
  height: 600,
  cellConfig: {
    height: 100
  },
  taskViewConfig: {
    tableStyle: {
      width: 380
    },
    showNowLine: true,
    scales: [
      { type: 'month' },
      {
        type: 'day',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      },
      {
        type: 'date',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      }
    ],
    viewStyle: {
      cellStyle ({ dateObj }) {
        // 周日高亮
        if (dateObj.e === 0) {
          return {
            backgroundColor: '#f9f0f0'
          }
        }
        return {}
      }
    }
  },
  taskBarConfig: {
    showTooltip: true,
    barStyle: {
      round: true
    }
  },
  columns: [
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: '任务1', start: '2024-03-03', end: '2024-03-10', progress: 20, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product9.png' },
    { id: 10002, title: '任务2', start: '2024-03-05', end: '2024-03-12', progress: 15, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10003, title: '任务3', start: '2024-03-10', end: '2024-03-21', progress: 25, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product1.png' },
    { id: 10004, title: '任务4', start: '2024-03-15', end: '2024-03-24', progress: 70, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product3.png' },
    { id: 10005, title: '任务5', start: '2024-03-20', end: '2024-04-05', progress: 50, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10006, title: '任务6', start: '2024-03-22', end: '2024-03-29', progress: 38, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product7.png' },
    { id: 10007, title: '任务7', start: '2024-03-28', end: '2024-04-04', progress: 24, bgColor: '#78e6d1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10008, title: '任务8', start: '2024-04-05', end: '2024-04-18', progress: 65, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' }
  ]
})
</script>

<style lang="scss" scoped>
.custom-task-bar {
  display: flex;
  flex-direction: row;
  padding: 8px 16px;
  width: 100%;
  font-size: 12px;
}
.custom-task-bar-img {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  width: 70px;
  height: 70px;
}
</style>

gitee.com/x-extends/v…

玩转小程序AR-实战篇

《玩转小程序AR》系列教程

声明: 本文所载内容仅限于学习交流之目的。所有抓包内容、敏感网址及数据接口均已进行脱敏处理,严禁将其用于商业或非法用途。任何因此产生的后果,作者不承担任何责任。若涉及侵权,请及时联系作者以便立即删除。

逆向小程序

接着前文: 《玩转小程序AR-基础篇》,体验过原神官方AR小程序后,我也比较好奇他们实现AR Live2d 动画的原理。

123

出于技术学习的目的,在开源社区搜寻微信小程序反编译工具,发现 KillWxapkgunveilr 暂时仍可使用

逆向 某神AR 小程序

首先我们需要找到某神AR微信小程序的本地小程序包地址

注意:本人电脑 Mac (Windows电脑同理)

  1. 进入电脑微信小程序目录
# Mac 目录地址
cd /Users/你的电脑用户名/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/radium/Applet/packages

# Windows 目录地址
cd C:\Users\你的电脑用户名\AppData\Roaming\Tencent\xwechat\radium\Applet\packages


# 打开文件夹
open .

如果发现目录下有较多文件夹,建议先把所有的文件夹都删除,为后续定位要逆向的小程序做好准备

screenshot-20251125-212338.png

  1. 用电脑微信打开需要逆向的小程序

screenshot-20251125-211252.png

  1. 再次打开微信小程序目录

screenshot-20251125-213047.png

这时候,恭喜你已经定位到了小程序的AppID了!

  1. 反编译小程序

screenshot-20251125-213556.png

小程序文件夹下的__APP__.wxapkg就是编译后的小程序,现在我们需要使用工具反编译它了

  • unveilr 工具

安装地址

# 安装反编译工具
npm i unveilr -g

# 运行反编译命令
unveilr wx -i wxb2618d769d6f5143  "/Users/你的电脑用户名/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/radium/Applet/packages/wxb2618d769d6f5143/3/__APP__.wxapkg" -f
  • KillWxapkg 工具

安装地址

./KillWxapkg -id="wxb2618d769d6f5143" -in="/Users/你的电脑用户名/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/radium/Applet/packages/wxb2618d769d6f5143/3"  -restore

于是在小程序文件夹下,就新生成了一个反编译后的源码文件夹

screenshot-20251125-214601.png

当然,反编译后的源码也不是100%还原:

  • 缺失 wxml
  • js 代码被babel转码后,语义不是特别清晰

screenshot-20251201-195547.png

screenshot-20251201-200158.png

当然,在AI的加持下,如今这些问题已经完全难不倒我们了。AI分分钟就能根据混淆过的js原始逻辑,还原清晰可读的语义化代码

screenshot-20251201-200736.png

从源码中可以看到,原神AR小程序使用的正是XR-FRAME框架

逆向 某cube 小程序插件

官方提供的kivicube插件

{
  "usingComponents": {
    "kivicube-scene": "plugin://kivicube/kivicube-scene"
  },
  "disableScroll": true,
  "navigationStyle": "custom"
}
<kivicube-scene
  wx:if="{{showAR}}"
  class="kivicube"
  scene-id="{{sceneId}}"
  bind:ready="ready"
  bind:error="error"
  bind:downloadAssetStart="downloadStart"
  bind:downloadAssetProgress="downloadProgress"
  bind:downloadAssetEnd="downloadEnd"
  bind:loadSceneStart="loadStart"
  bind:loadSceneEnd="loadEnd"
  bind:sceneStart="sceneStart"
  bind:openUrl="openUrl"
  bind:photo="photo"
/>

使用同样的方法,我们可以得到小程序插件逆向的源码

screenshot-20251201-201949.png

通过源码分析可得知,Kivicube 使用的是底层VisionKit + 自研的Threejs封装

screenshot-20251201-202405.png

着色器

Shader,中文称为“着色器”,是一种在图形处理单元(GPU)上运行的计算机程序,用于定义和控制图形渲染过程中的各种视觉效果

使用 GLSL 的着色器(shader),GLSL 是一门特殊的有着类似于 C 语言的语法,在图形管道 (graphic pipeline) 中直接可执行的 OpenGL 着色语言。

更多详情见MDN上的解释:GLSL Shaders

XR-FRME 提供了自定义效果的能力: 定制一个效果

序列帧 SHADER

XR-FRAME 官方的《序列帧动画(雪碧图、GIF)》示例,实现了一个简单的可配置的序列帧效果

而原神小程序AR的源码中,正是使用了这个序列帧效果实现了伪Live2d

首先我们需要将序列帧动画合成到一张M行*N列大小的PNG图片上(注意:微信小程序最大能渲染8000x8000左右分辨率的序列帧图片)

1png

如上图所示,我们这次的图片是8行x4列,共32张序列帧图片合成而来

源码示例: 序列帧 SHADER

<xr-scene bind:ready="handleReady">
  <xr-assets></xr-assets>
  <xr-node>
    <xr-node node-id="center" />
    <xr-mesh visible="{{meshesVisible}}" id="animation-mesh" node-id="animation-mesh" position="0 0 0" scale="1 1 1.3" rotation="90 0 0" geometry="plane" />
  </xr-node>
  <xr-camera target="center" clear-color="0.4 0.8 0.6 1" position="0 0 2.5" camera-orbit-control />
</xr-scene>
Component({
  /**
   * 组件的初始数据
   */
  data: {
    meshesVisible: false
  },

  /**
   * 组件的方法列表
   */
  methods: {
    handleReady: function ({ detail }) {
      const xrFrameSystem = wx.getXrFrameSystem()
      const createFrameEffect = (scene) => {
        return scene.createEffect({
          name: 'frame-effect',
          properties: [
            {
              key: 'columCount', // 列数
              type: xrFrameSystem.EUniformType.FLOAT,
              default: 1
            },
            {
              key: 'rowCount', // 行数
              type: xrFrameSystem.EUniformType.FLOAT,
              default: 1
            },
            {
              key: 'during', // 持续时间
              type: xrFrameSystem.EUniformType.FLOAT,
              default: 1
            }
          ],
          images: [
            {
              key: 'u_baseColorMap',
              default: 'white',
              macro: 'WX_USE_BASECOLORMAP'
            }
          ],
          // 透明物体需要大于`2500`!
          defaultRenderQueue: 2501,
          passes: [
            {
              renderStates: {
                blendOn: false,
                depthWrite: true,
                cullOn: false,
                // 基础库 v3.0.1 开始 默认的 plane 切为适配 cw 的顶点绕序
              },
              lightMode: 'ForwardBase',
              useMaterialRenderStates: true,
              shaders: [0, 1]
            }
          ],
          shaders: [
            // 顶点着色器 Vertex shaders
            `#version 100

          precision highp float;
          precision highp int;
    
          attribute vec3 a_position;
          attribute highp vec2 a_texCoord;
      
          uniform mat4 u_view;
          uniform mat4 u_projection;
          uniform mat4 u_world;
          varying highp vec2 v_uv;
          void main()
          {
            v_uv = a_texCoord;
            gl_Position = u_projection * u_view * u_world * vec4(a_position, 1.0);
          }`,
            // 片段着色器 Fragment shaders
            `#version 100
            precision highp float;
            precision highp int;

            uniform sampler2D u_baseColorMap;
            uniform highp float u_gameTime;
            uniform highp float rowCount;
            uniform highp float columCount;
            uniform highp float during;
            varying highp vec2 v_uv;
            void main()
            {
              float loopTime = mod(u_gameTime, during);

              float tickPerFrame = during / (columCount * rowCount);
              
              float columTick = mod(floor(loopTime / tickPerFrame), columCount);
              float rowTick = floor(loopTime / tickPerFrame / columCount);

              vec2 texCoord = vec2(v_uv.x / columCount + (1.0 / columCount) * columTick , v_uv.y / rowCount + (1.0 / rowCount) * rowTick);
              vec4 color = texture2D(u_baseColorMap, texCoord);
              gl_FragColor = color;
            }`
          ],
        });
      }
      xrFrameSystem.registerEffect('frame-effect', createFrameEffect)
      this.scene = detail.value

      this.loadAsset()
    },

    async loadAsset() {
      const xrFrameSystem = wx.getXrFrameSystem();
      const xrScene = this.scene;

      await xrScene.assets.loadAsset({
        type: 'texture',
        assetId: 'lzy',
        src: 'https://assets.xxxx.com/resources/cdn/20251022/0ac5e7c80c0fc262.png',
      })

      // 第一个参数是效果实例的引用,第二个参数是默认`uniforms`
      const frameMaterial = xrScene.createMaterial(
        // 使用定制的效果
        xrScene.assets.getAsset('effect', 'frame-effect'),
        { u_baseColorMap: xrScene.assets.getAsset('texture', 'lzy') }
      )

      // 可以将其添加到资源系统中备用
      xrScene.assets.addAsset('material', 'frame-effect', frameMaterial)

      const meshElement = xrScene.getElementById('animation-mesh').getComponent(xrFrameSystem.Mesh)
      frameMaterial.setFloat('columCount', 4)
      frameMaterial.setFloat('rowCount', 8)
      frameMaterial.setFloat('during', 1)
      frameMaterial.alphaMode = "BLEND"
      meshElement.material = frameMaterial

      this.setData({
        meshesVisible: true
      })
    },
  }
})

frame.gif

透明视频 SHADER

一般的透明视频:

  1. 自带透明通道的视频格式: mov (小程序默认不支持mov格式播放)
  2. 特殊处理后的左右分屏视频格式: mp4 (小程序默认支持mp4格式播放)
  • 左边是视频的 RGB
  • 右边是视频的 Alpha
  • 左右叠加即可渲染透明视

更多详情见前文《更高效的web动效解决方案 - 背景视频》

v.gif

XR-FRAME 官方的《过滤黑色背景视频》示例,正好演示了左右分屏视频的过滤黑色背景能力

源码示例: 透明视频 SHADER

<xr-scene bind:ready="handleReady">
  <xr-assets bind:progress="handleAssetsProgress" bind:loaded="handleAssetsLoaded">
    <xr-asset-load type="video-texture" asset-id="lzy" src="https://assets.xxxx.com/resources/cdn/20251022/bd7cb6ba6546d697.mp4" options="autoPlay:true,loop:true" />
    <xr-asset-material asset-id="removeBlack-mat" effect="removeBlack" />
  </xr-assets>
  <xr-node>
    <xr-node node-id="center" />
    <xr-node wx:if="{{loaded}}">
      <xr-mesh node-id="video-item" position="0 0 0" rotation="90 0 0" scale="1 1 1.3" geometry="plane" material="removeBlack-mat" uniforms="u_videoMap: video-lzy" />
    </xr-node>
  </xr-node>
  <xr-camera target="center" clear-color="0.4 0.8 0.6 1" position="0 0 3" camera-orbit-control />
</xr-scene>
const xrFrameSystem = wx.getXrFrameSystem();

xrFrameSystem.registerEffect('removeBlack', scene => scene.createEffect({
  name: "removeBlack",
  images: [{
    key: 'u_videoMap',
    default: 'white',
    macro: 'WX_USE_VIDEOMAP'
  }],
  defaultRenderQueue: 2000,
  passes: [{
    "renderStates": {
      cullOn: false,
      blendOn: true,
      blendSrc: xrFrameSystem.EBlendFactor.SRC_ALPHA,
      blendDst: xrFrameSystem.EBlendFactor.ONE_MINUS_SRC_ALPHA,
      cullFace: xrFrameSystem.ECullMode.BACK,
    },
    lightMode: "ForwardBase",
    useMaterialRenderStates: true,
    shaders: [0, 1]
  }],
  shaders: [
    // 顶点着色器 Vertex shaders
    `#version 100

uniform highp mat4 u_view;
uniform highp mat4 u_viewInverse;
uniform highp mat4 u_vp;
uniform highp mat4 u_projection;
uniform highp mat4 u_world;

attribute vec3 a_position;
attribute highp vec2 a_texCoord;

varying highp vec2 v_UV;

void main()
{
  v_UV = a_texCoord;
  vec4 worldPosition = u_world * vec4(a_position, 1.0);
  gl_Position = u_projection * u_view * worldPosition;
  }`,
    // 片段着色器 Fragment shaders
    `#version 100

precision mediump float;
precision highp int;
varying highp vec2 v_UV;

#ifdef WX_USE_VIDEOMAP
  uniform sampler2D u_videoMap;
#endif

void main()
{
#ifdef WX_USE_VIDEOMAP
  // 左右分屏透明视频处理:
  // 左半边 (0-0.5) 为彩色内容,右半边 (0.5-1.0) 为透明度遮罩
  
  // 1. 采样左半边获取 RGB 颜色
  vec2 colorUV = vec2(v_UV.x * 0.5, v_UV.y);
  vec4 color = texture2D(u_videoMap, colorUV);
  
  // 2. 采样右半边获取 Alpha 遮罩
  vec2 alphaUV = vec2(v_UV.x * 0.5 + 0.5, v_UV.y);
  vec4 alphaSample = texture2D(u_videoMap, alphaUV);
  float alpha = alphaSample.r; // 使用红色通道作为透明度(灰度值)
  
  // 3. 输出颜色 + 遮罩透明度(不做伽马校正,避免变暗)
  gl_FragData[0] = vec4(color.rgb, alpha);
#else
  gl_FragData[0] = vec4(1.0, 1.0, 1.0, 1.0);
#endif
}
`],
}));

Component({
  /**
   * 组件的初始数据
   */
  data: {

  },

  /**
   * 组件的方法列表
   */
  methods: {
    handleReady({
      detail
    }) {
      console.log('handleReady', detail.value)
    },
    handleAssetsProgress({ detail }) {
      console.log('assets progress', detail.value)
    },
    handleAssetsLoaded({ detail }) {
      console.log('assets loaded', detail.value)
      this.setData({ loaded: true })
    },
  }
})

v2.gif

实战案例

work.gif

源码示例: 序列帧 SHADER

同层渲染

目前XR-FRAME尚未支持和小程序的UI元素混写,但我们可以使用同层方案

<view>
  <demo8
    disable-scroll
    id="main-frame"
    width="{{renderWidth}}"
    height="{{renderHeight}}"
    style="width:{{width}}px;height:{{height}}px;top:{{top}}px;left:{{left}}px;"
    bind:arTrackerSwitch="handleTrackerSwitch"
    markerImg="{{markerImg}}"
  />
  <view class="marker-tip-container" hidden="{{hiddenTip}}">
    <view class="marker-img-container">
      <image mode="aspectFit" class="marker-img" src="{{markerImg}}" />
    </view>
    <view class="marker-text-container">
      <text class="marker-text">请对准识别图</text>
    </view>
  </view>
</view>

demo8是XR-FRAME组件,它和viewUI组件处在同一层,这既是所谓的同层渲染方案。而同层方案,就必然涉及到组件通信

XR-FRAME组件需要将AR的识别状态同步给父级,父级根据不同的AR状态,展示不同的UI界面。而这些通信方式,和传统的组件通信方式基本一致:父级传递函数和属性到子级,子级通过执行回调函数传递数据

框架维护

比较尴尬的是,核心技术负责人已离开团队,XR-FRAME框架处于暂停维护状态

screenshot-20251201-214919.png

资料

JavaScript 今天30 岁了,但连自己的名字都不属于自己

image.png

12 月 4 号,JavaScript 迎来 30 岁生日。

一门 10 天赶出来的语言,现在跑在 98.9% 的网站上,有 1650 万开发者在用它。从浏览器脚本到服务端运行时,从桌面应用到移动端,甚至嵌入式设备都有它的身影。TIOBE 2024 年度编程语言排行榜上,JavaScript 排第 6。

但 30 周年这天,社区没怎么庆祝。大家更关心的是另一件事:JavaScript 这个名字,到底能不能从 Oracle 手里抢回来。


10 天写出来的语言

1995 年 5 月,Netscape 的工程师 Brendan Eich 接到一个任务:给浏览器加一门脚本语言。

时间表很紧——Navigator 2.0 Beta 版要发布了,必须赶上。

Eich 花了 10 天(据他回忆是 5 月 6 日到 15 日),搞出了第一个原型。这不是夸张,是真的 10 天。

他后来自己说:

当你看我 10 天写的东西,它像一颗种子。是一种有力的妥协,但仍然是一个非常强大的内核,后来长成了一门更大的语言。

这门语言最开始叫 Mocha,后来改叫 LiveScript,最后因为市场原因蹭了 Java 的热度,改名 JavaScript。

1995 年 12 月 4 日,Netscape 和 Sun 联合发布公告,宣布 JavaScript 正式诞生。28 家公司为这门新语言背书,包括 America Online、Apple、AT&T、Borland、HP、Oracle、Macromedia、Intuit、Toshiba 等科技巨头。

有意思的是,Oracle 当时是 JavaScript 的支持者之一,新闻稿的媒体联系人里还有 Mark Benioff(后来创办了 Salesforce)。没想到 30 年后,Oracle 成了社区想要摆脱的"商标持有者"。

Sun 联合创始人 Bill Joy 说:

JavaScript 是 Java 平台的完美补充,天生就是为互联网和全球化设计的。

America Online 技术总裁 Mike Connors:

JavaScript 带来了跨平台的快速多媒体应用开发能力。

HP 的 Jan Silverman:

JavaScript 代表了专门为互联网设计的下一代软件。

Netscape 和 Sun 还计划把 JavaScript 提交给 W3C 和 IETF 作为开放标准。后来 JavaScript 确实标准化了,但官方名字叫 ECMAScript——因为商标问题。

1996 年 3 月发布 1.0 版本后,JavaScript 的野心远不止当初设想的"胶水语言"。


从玩具到基础设施

当年 JavaScript 的定位是"胶水语言",让不会编程的人也能在网页上加点交互。

没人想到它会变成今天这样。

几个关键节点:

2009 年 - Node.js 诞生

Ryan Dahl 把 V8 引擎搬到服务端,JavaScript 不再只是浏览器里的玩具。前后端同构成为可能。

2015 年 - ES6 发布

let/const 替代 var,箭头函数,Promise,Class 语法... JavaScript 终于像个正经语言了。

2012 年 - TypeScript 发布

微软给 JavaScript 加了类型系统。2017 年只有 12% 的 JavaScript 开发者用 TypeScript,到 2024 年这个数字涨到了 35%。现在大型项目几乎都是 TypeScript。

框架时代

React、Vue、Angular 轮番登场。整个前端生态围绕 JavaScript 建立起来。现在有人的整个职业生涯都建立在某个特定的 JS 框架上。

嵌入式领域

JavaScript 甚至跑到了微控制器上。Espruino 项目让你可以在 24.95 美元的小板子上写 JavaScript,功耗低到 0.06mA,还能跑蓝牙。有个智能手表 Bangle.js 2,一块电池能用 4 周,上面跑的就是 JavaScript。


名字的问题

JavaScript 这个名字,商标属于 Oracle。

Oracle 2009 年收购 Sun 的时候一起拿到的。但 Oracle 自己根本不做 JavaScript 相关的产品,商标就这么放着。

问题来了:因为商标在 Oracle 手里,社区做事很尴尬。

  • 不能叫 JavaScript Conference,只能叫 JSConf
  • 官方规范叫 ECMAScript,不叫 JavaScript
  • 写书、办会议、做项目,用 JavaScript 这个词都有法律风险

Brendan Eich 2006 年写过:"ECMAScript 一直是个没人想要的商业名称,听起来像皮肤病。"

讽刺的是,Oracle 甚至不是 OpenJS Foundation 的成员,跟 Node.js 的开发也没有任何关系。

Node.js 和 Deno 的创始人 Ryan Dahl 看不下去了。2024 年 9 月他发起了 "Free the Mark" 运动,发布了一封公开信,28,600 多名开发者签名支持。

image.png 签名的人里有几个重量级的:

  • Brendan Eich - JavaScript 创造者本人
  • Ryan Dahl - Node.js 创造者
  • Michael Ficarra、Shu-yu Guo - JavaScript 规范编辑
  • Rich Harris - Svelte 作者
  • Isaac Z. Schlueter - npm 创始人
  • James M Snell - Node.js TSC 成员
  • Jordan Harband - JavaScript 规范荣誉编辑
  • Matt Pocock - Total TypeScript 课程作者
  • Wes Bos、Scott Tolinski - Syntax.fm 播客主持人

11 月正式向美国专利商标局提交申请,要求撤销 Oracle 的商标。

理由有三:

  1. 通用化 - JavaScript 已经变成通用名词了,就像 aspirin(阿司匹林)一样
  2. 弃用 - Oracle 三年多没用这个商标做任何商业用途
  3. 欺诈 - Oracle 2019 年续期商标时,提交的使用证据是 Node.js 的截图。Node.js 跟 Oracle 没有半毛钱关系

公开信里说得很直白:

Oracle 从来没有认真推出过叫 JavaScript 的产品。GraalVM 的产品页面甚至都没提"JavaScript"这个词,得翻文档才能找到它支持 JavaScript。

公开信还指出,Oracle 2019 年续期商标时提交的"使用证据"是 nodejs.org 的截图和 Oracle JET 库。Node.js 根本不是 Oracle 的产品,JET 只是 Oracle Cloud 服务的一个 JavaScript 库,跟市面上成千上万的 JS 库没什么区别。

按美国法律,商标 3 年不用就算放弃。Oracle 既没用这个商标,又眼睁睁看着它变成通用名词,两条都占了。

image.png

2025 年 2 月,Oracle 申请驳回诉讼中的欺诈指控。6 月,商标审判和上诉委员会驳回了欺诈指控,但撤销申请继续审理。8 月,Oracle 首次正式回应,否认 JavaScript 是通用名词。

官司预计要打到 2026 年。

Deno 团队正在众筹 20 万美元的法律费用,用于发现阶段的调查取证,包括做公众调查来证明普通人不会把 JavaScript 和 Oracle 联系在一起。


30 年后的 JavaScript

现在的 JavaScript 和 1995 年的已经是两门语言了。

当年的 varlet/const 取代。当年的原型继承有了 Class 语法糖。当年的回调地狱有了 Promise 和 async/await。

ES2025 刚发布,又加了一堆新特性。

工具链也完全不同了:

  • 打包器从 webpack 到 Vite,Vite 8 刚用上 Rolldown,速度又快了一大截
  • 运行时从只有浏览器,到 Node.js、Deno、Bun 三足鼎立
  • TypeScript 成了事实上的标准
  • 1650 万开发者,比很多国家的人口都多

Brendan Eich 当年 10 天写的种子,长成了一片森林。


顺手推几个项目

既然聊到 JavaScript 生态,推一下我做的几个开源项目:

chat_edit - 一个双模式 AI 应用,聊天 + 富文本编辑。Vue 3.5 + TypeScript + Vite 8 技术栈,可以自己配 API key 部署。

code-review-skill - Claude Code 的代码审查技能,覆盖 React、Vue、TypeScript 等主流技术栈,按需加载不浪费 token。

5-whys-skill - 根因分析技能,排查问题的时候用"5 个为什么"方法论。

first-principles-skill - 第一性原理思考技能,适合架构设计和技术方案选型。帮你拆解问题本质。

感兴趣可以去 GitHub 看看。


相关链接

Vite8来啦,告别 esbuild + Rollup,Vite 8 统一用 Rolldown 了

vite8.webp

用 Vite 做项目的应该都有感觉:开发体验一直很顺,但生产构建这块,项目一大就开始拉胯。

问题出在 Vite 的双引擎架构——开发用 esbuild,生产用 Rollup。两套东西,行为不一致,偶尔还会出现"本地没问题,构建就炸"的玄学 bug。

12 月 3 号,Vite 8 Beta 发布了,底层换成了 Rolldown。我第一时间把手上的项目升级了,踩了几个坑,这里记录一下。


Rolldown 是什么

简单说:一个 Rust 写的打包器,目标是同时替代 esbuild 和 Rollup。

尤雨溪的 VoidZero 团队搞的,拿了 1700 多万美金融资。整个工具链是这样的:

  • Vite(构建工具)
  • Rolldown(打包器)
  • Oxc(编译器、压缩器)

三个项目同一个团队维护,行为一致性有保障。

Rolldown 和 esbuild 速度差不多,比 Rollup 快 10-30 倍。尤雨溪自己测 Vue 核心代码的打包,Rolldown 比 Rollup 快 7 倍,比 esbuild 还快将近 2 倍。


真实项目数据

看看早期用户的反馈:

项目 构建时间变化 提升倍数
Linear 46s → 6s 7.6x
Excalidraw 22.9s → 1.4s 16x
GitLab 2.5min → 40s 3.75x
Beehiiv - 64% 更快

GitLab 还有个离谱的数据:内存占用降了 100 倍。


升级步骤

升级本身不复杂:

pnpm add -D vite@8.0.0-beta.0 @vitejs/plugin-vue@latest

Node.js 版本要求 20.19+ 或 22.12+,18 不支持了。

装完大概率能跑,但如果你用了 manualChunks,会遇到问题。


踩坑:manualChunks 不能用了

我的项目之前用对象形式配置 chunk 分割:

// 旧写法,Vite 8 直接报错
rollupOptions: {
  output: {
    manualChunks: {
      'vue-vendor': ['vue', 'pinia'],
      'monaco': ['monaco-editor'],
    }
  }
}

跑构建直接炸:TypeError: manualChunks is not a function

Rolldown 不支持对象形式的 manualChunks,得改成 advancedChunks

// 新写法
rollupOptions: {
  output: {
    advancedChunks: {
      groups: [
        { name: 'vue-vendor', test: /[\\/]node_modules[\\/](vue|pinia)[\\/]/ },
        { name: 'monaco', test: /[\\/]node_modules[\\/]monaco-editor[\\/]/ },
      ]
    }
  }
}

用正则匹配模块路径,比之前的数组形式更灵活,但迁移需要手动改一遍。


其他变化

配置项重命名

build.rollupOptions 以后要改成 build.rolldownOptions,目前还兼容但会有警告。

CSS 压缩

默认从 esbuild 换成了 Lightning CSS。想换回去可以设置 build.cssMinify: 'esbuild'

JS 压缩

从 esbuild 换成了 Oxc Minifier。

插件兼容性

大部分 Vite 插件直接能用。少数依赖 esbuild 特定选项的需要适配。


开发服务器

开发服务器启动速度没太大变化,本来就快。

但后面会有个 Full Bundle Mode,开发阶段也打包。官方初步数据:

  • 启动快 3 倍
  • 热更新快 40%
  • 网络请求少 10 倍

大型项目福音,不过目前还没正式发布。


要不要升

我的建议:

现在可以升的

  • 个人项目、实验性项目
  • 构建时间已经成为痛点的大型项目
  • 愿意踩坑反馈问题的

等等再升的

  • 生产环境稳定性优先的项目
  • 依赖大量 Rollup 特定配置的
  • 等正式版发布(估计一两个月内)

顺手推几个开源项目

既然聊到 Vite 和前端工具链,推一下我做的几个开源项目:

chat_edit - 双模式 AI 应用,聊天 + 富文本编辑整合在一起。技术栈刚升级到 Vue 3.5 + TypeScript + Vite 8,可以作为 Vite 8 实战参考。自己配置 API key 就能部署,支持导出 PDF、DOCX、Markdown。

code-review-skill - Claude Code 的代码审查技能,覆盖 React 19、Vue 3、Rust、TypeScript 等主流技术栈。采用按需加载设计,审查 React 代码时只加载 React 相关规则,不浪费 token。大概 9000 行最佳实践。

5-whys-skill - "5 个为什么"根因分析技能。遇到 bug 或者系统问题时,说"找根因"就能自动激活,输出结构化的分析报告。排查问题挺好用的。

first-principles-skill - 第一性原理思考技能,适合架构设计和技术方案选型。不是套模板,是真的帮你拆解问题本质。

感兴趣的可以去 GitHub 看看。


相关链接

每日一题-在区间范围内统计奇数数目🟢

给你两个非负整数 low 和 high 。请你返回 low  high 之间(包括二者)奇数的数目。

 

示例 1:

输入:low = 3, high = 7
输出:3
解释:3 到 7 之间奇数数字为 [3,5,7] 。

示例 2:

输入:low = 8, high = 10
输出:1
解释:8 到 10 之间奇数数字为 [9] 。

 

提示:

  • 0 <= low <= high <= 10^9

O(1) 数学解(Python/Java/C++/C/Go/JS/Rust)

$[\textit{low},\textit{high}]$ 中的正奇数个数,等于 $[1,\textit{high}]$ 中的正奇数个数,减去 $[1,\textit{low}-1]$ 中的正奇数个数。(这个想法类似 前缀和

正奇数可以表示为 $2k-1$,其中 $k$ 是正整数。

$[1,n]$ 中的正奇数满足 $1\le 2k-1\le n$,解得

$$
1\le k \le \left\lfloor\dfrac{n+1}{2}\right\rfloor
$$

这有 $\left\lfloor\dfrac{n+1}{2}\right\rfloor$ 个整数 $k$。

所以答案为

$$
\left\lfloor\dfrac{\textit{high}+1}{2}\right\rfloor - \left\lfloor\dfrac{\textit{low}}{2}\right\rfloor
$$

###py

class Solution:
    def countOdds(self, low: int, high: int) -> int:
        return (high + 1) // 2 - low // 2

###java

class Solution {
    public int countOdds(int low, int high) {
        return (high + 1) / 2 - low / 2;
    }
}

###cpp

class Solution {
public:
    int countOdds(int low, int high) {
        return (high + 1) / 2 - low / 2;
    }
};

###c

int countOdds(int low, int high) {
    return (high + 1) / 2 - low / 2;
}

###go

func countOdds(low, high int) int {
return (high+1)/2 - low/2
}

###js

var countOdds = function(low, high) {
    return Math.floor((high + 1) / 2) - Math.floor(low / 2);
};

###rust

impl Solution {
    pub fn count_odds(low: i32, high: i32) -> i32 {
        (high + 1) / 2 - low / 2
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(1)$。
  • 空间复杂度:$\mathcal{O}(1)$。

思考题

  1. 计算 $[\textit{low},\textit{high}]$ 中每一位都是奇数的整数个数。
  2. 计算 $[\textit{low},\textit{high}]$ 中每一位都是奇数的整数之和。

欢迎在评论区分享你的思路/代码。

分类题单

如何科学刷题?

  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站@灵茶山艾府

很显然【看不懂这个解释,建议退出这一行】

假设一个区间【0,3】,序列是0,1,2,3 奇数个数是3+1/2=2,区间【0,4】,序列是0,1,2,3,4 奇数个数4+1/2=2。
所以,所以,所以,high3或者4,加个1,然后除以2,奇数个数都是2,然后,请自己推【0,5】和【0,6】,奇数个数都是3。
得出公式 high+1/2是区间【0,high】的奇数个数

因为low,左边界是可以改变的,所以先求【0,high】的奇数个数,然后在求【0,low】的奇数个数,然后做差得到总奇数个数。
注意,注意,注意,把+1看成右边区间增大,这里low是相当于在【0,high】里面的,你别加1,右边界high增大就行了。

**举个例子:**区间【3,7】,high的奇数个数 7+1/2=4,,如果此时3+1/2=2,4-2=2,答案就错了,要3/2=1,最后答案才等于3。

high+1奇数个数 - low奇数个数 **=**总奇数个数。

用公式表示 (high+1)/2 - low/2

int countOdds(int low, int high){
return ((high+1)/2)-((low)/2); //严谨
}

滑稽.png

在区间范围内统计奇数数目

方法一:前缀和思想

思路与算法

如果我们暴力枚举 ${\rm [low, high]}$ 中的所有元素会超出时间限制。

我们可以使用前缀和思想来解决这个问题,定义 ${\rm pre}(x)$ 为区间 $[0, x]$ 中奇数的个数,很显然:

$${\rm pre}(x) = \lfloor \frac{x + 1}{2} \rfloor$$

故答案为 $\rm pre(high) - pre(low - 1)$。

代码

###C++

class Solution {
public:
    int pre(int x) {
        return (x + 1) >> 1;
    }
    
    int countOdds(int low, int high) {
        return pre(high) - pre(low - 1);
    }
};

###Java

class Solution {
    public int countOdds(int low, int high) {
        return pre(high) - pre(low - 1);
    }

    public int pre(int x) {
        return (x + 1) >> 1;
    }
}

###Python

class Solution:
    def countOdds(self, low: int, high: int) -> int:
        pre = lambda x: (x + 1) >> 1
        return pre(high) - pre(low - 1)

###C#

public class Solution {
    public int Pre(int x) {
        return (x + 1) >> 1;
    }
    
    public int CountOdds(int low, int high) {
        return Pre(high) - Pre(low - 1);
    }
}

###Go

func pre(x int) int {
    return (x + 1) >> 1
}

func countOdds(low int, high int) int {
    return pre(high) - pre(low - 1)
}

###C

int pre(int x) {
    return (x + 1) >> 1;
}

int countOdds(int low, int high) {
    return pre(high) - pre(low - 1);
}

###JavaScript

var countOdds = function(low, high) {
    return pre(high) - pre(low - 1);
};

function pre(x) {
    return (x + 1) >> 1;
}

###TypeScript

function pre(x: number): number {
    return (x + 1) >> 1;
}

function countOdds(low: number, high: number): number {
    return pre(high) - pre(low - 1);
}

###Rust

impl Solution {
    fn pre(x: i32) -> i32 {
        (x + 1) >> 1
    }
    
    pub fn count_odds(low: i32, high: i32) -> i32 {
        Self::pre(high) - Self::pre(low - 1)
    }
}

复杂度分析

  • 时间复杂度:$O(1)$。

  • 空间复杂度:$O(1)$。

全球最大云厂商,将「最牛马」的工作交给了 AI Agent

全球最大的云厂商,正在重构其在 Agent 时代的云计算核心。

美国东部时间 2025 年 12 月 2 日上午(北京时间 12 月 3 日凌晨),在拉斯维加斯举行的 re:Invent 大会上,亚马逊云科技宣布了一系列围绕 AI Agent 的重大更新:一方面是面向客户的 Agent 应用和平台工具,如 Amazon Connect、Kiro 等;另一方面是面向未来的底层基础设施,包括新一代 Trn4 AI 芯片以及 Trn3 超级服务器等。

今年以来,AI Agent 的爆发正在深刻影响企业工作流和生产力模式。亚马逊云科技的这些新产品和发布,似乎在回答两个关键的行业问题:如何确保 Agent 安全合规地「用好」,以及如何让 Agent 以经济可行的方式「跑起来」。正如亚马逊云科技 CEO Matt Garman 在主题演讲中所强调的,最终目标是实现「将 Agent 投入工作」(Put Agent into Work)。

如何让 Agent 可用丨来自:2025 re:Invent

有意思的是,在长达 2 个小时的主题演讲中,Matt Garman 用了 1 小时 50 分钟的时间介绍 AI 基础设施和全新的 Agent 产品,而只用了 10 分钟的时间提及传统云产品升级(如实例、存储、数据库等)。11:1 的时间分配对比,也印证了 AI Agent 及其背后的基础设施,已成为当下云厂商们最重要的战略核心。

Agent 应用与治理:从模型到可控的「数字员工」

AI Agent 的价值不再是传统的「聊天」,而在于「行动」。今年 re:Invent 期间,AWS 发布了一系列围绕 Agent 构建的产品和应用。

一方面,在企业服务领域,AWS 将 Agent 融入到各类传统的平台和工具之中,对后者实现技术升级从而提高生产效率。今年,针对企业数字化经营中最耗时、最复杂的三个领域:代码运维、应用现代化和客户服务,AWS 推出了一系列 Agent 产品。

三个不同场景的 Agent丨来自:2025 re:Invent

首先是在代码和运维领域,AWS 发布了多个 Agent 产品。其中 Kiro Autonomous Agent 值得关注。以前的 AI 编程工具虽然能写代码,但往往把开发者变成了忙于搬运上下文和协调工具的「助理」。而 Autonomous Agent 解决的就是这个问题,它可以被视为团队中一位 24 小时待命、过目不忘的「影子开发者」。

开发者只需专注于核心难题,把修 Bug、跨库变更等繁琐任务直接丢给它。它不仅能在后台自主规划并执行,还能像真人一样记住跨会话的上下文,通过连接 Jira 和 Slack 深度学习团队的业务逻辑与协作规范。随着每一次代码审查,它会越来越懂你们的产品和标准,真正成为团队共享的「超级大脑」,让开发者只做决策,不再打杂。

还有 DevOps Agent。从国内的「双 11」到海外的「黑五」,大型数字营销活动背后,技术运营人员熬夜加班已经成为常态。当下的模式是系统监测预警,技术人员需要随时待命处理每一个问题。而 AWS DevOps Agent 像是一个永不眠的运维员工,可以 24 小时调查事故和识别运营问题,从而大幅减少需要提报告给运营人员的警报数量,让后者可以更轻松更高效地处理那些真正有价值的问题,而非一直被一些没什么意义的警报轰炸。

「炸掉」技术债务丨来自:2025 re:Invent

同时,Agent 还在重塑企业的核心资产——传统应用。今天很多企业面临沉重的「技术债务」,很多传统应用的工作负载都运行在大型机、VMware 等传统服务器上,云迁移虽是趋势,但也是巨大的负担。而 Amazon Transform Agent 就像一位专业的「全能代码重构工程师」,能够对任何代码、API、语言、甚至企业自有的编程语言或框架进行定制化转换。这种能力将传统应用现代化的速度提升至 5 倍,减少 80% 的时间和成本。

在客服领域,Amazon Connect 的新 Agent 也帮助这个产品实现了能力飞跃。此次 Amazon Connect 一口气发布了四项更新,包括用更先进的语言模型提供更加自然、类人的对话体验;同时让 AI 掌握工具从而完成整理材料、执行常规流程等工作,让其与真人员工更好地协作;以及基于历史行为和点击流等构建客户画像,让 AI 可以提供更加个性化的推荐。

另外,随着企业部署越来越多 AI Agent 参与客户交互,理解其决策过程对保障服务质量与合规至关重要。Amazon Connect 还新增 AI Agent 可观测性功能,为企业提供高度透明度——清晰呈现 AI 的理解内容、使用的工具以及决策过程。这种可见性帮助企业优化性能、确保合规,并增强对 AI 交互体验的信心。

除了在已有的服务场景中引入 Agent 能力,另一方面,AWS 也对构建 Agent 的平台工具进行了升级。其中最核心的是加强了 Agent 的治理与评估,让客户可以为 Agent 行为设置「红线」。

让 Agent 可评估和被约束丨来自:2025 re:Invent

随着 Agent 获得执行企业操作的权限,可控性和可信赖性成为其规模化落地的首要前提。AWS 本次发布的重点在于:对 Agent 的约束,必须从传统的「内容安全」转向更高级的「行为治理」

AWS 推出的 Policy in AgentCore 功能,正是针对 Agent 行为治理的新工具。它允许用户使用自然语言来设定 Agent 的行为边界。这与传统安全护栏(Guardrails)仅过滤语言内容不同,Policy 可以简单地创建和管理 Agent 运行策略,并在 Agent 工作流中进行实时检查,确保 Agent 的操作始终在预设的权限范围内。

此外,为了确保 Agent 在实际工作中的表现,AWS 还推出了 AgentCore Evaluations。这项服务允许基于真实世界的行为对 Agent 的工作质量进行持续检查和评估,为企业规模化部署 Agent 提供了可靠的性能衡量标准。

通过这一系列治理工具,AWS 试图告诉企业:AI Agent 是可以信任的。只要设置了明确的「红线」,平台就能保证 Agent 的行为始终在安全边界之内。

基础设施升级:构建 AI 应用生态的算力和模型「基石」

如果说 Agent 是台前的「数字员工」,那么支撑它们日夜运转的基础设施就是幕后的「超级工厂」。

今年 AWS 在基础设施层面的动作,似乎在向行业传达一个信号:要让 Agent 真正普及,不仅要让它变得聪明,更要让企业「用得起」且「不论用什么模型都能跑得好」。

首先是算力层面。Agent 时代对算力的消耗模式发生了根本改变。过去,企业关注的是「训练」一个大模型需要多少张卡;而在 Agent 时代,成千上万个 Agent 需要 24 小时在线,进行持续的推理、规划和工具调用。推理成本如果居高不下,Agent 就无法大规模落地。

Trn3 UltraServer丨来自:2025 re:invent

Matt Garman 在会上宣布,AWS 正式推出了由第四代 AI 芯片驱动的 Amazon EC2 Trn3 UltraServer。作为 AWS 首款采用 3nm 工艺制造的 AI 芯片,Trainium3 实际上是在构建一种比通用 GPU 更具性价比的算力替代方案。每颗芯片提供 2.52 PFLOPs 的 FP8 计算能力,配合 144 GB 内存(比上一代提升 1.5 倍),它完美契合了 Agent 应用中长上下文架构和多模态推理的需求。

Trn3 UltraServer 服务器最多可以集成 144 颗 Trn3 芯片,并可以通过 EC2 UltraClusters 扩展至数十万颗。而对于企业最关心的成本问题,Trn3 在 Bedrock 上的表现是:相比 Trn2,性能提升 3 倍,每 MW 能耗的输出 Tokens 数提升超过 5 倍。对于目前 AI 算力昂贵的挑战,Trn3 的推出可以起到明显的降低作用。

而在模型层面,AWS 再次证明了自己是「最开放的 AI 平台」。通过 Amazon Bedrock,AWS 打出了一套「自研强模型 + 全球全明星模型托管」的组合拳。

自研模型方面,AWS 正式发布了下一代 Amazon Nova 2 模型家族。其中包括了针对 Agent 语音交互优化的 Nova 2 Sonic——这是一款新一代的 speech-to-speech 模型,它不仅具备行业领先的对话质量和更低延迟,还能实现实时、人类般的语音对话。

此外,AWS 还推出了 Amazon Nova Forge,首次引入了「开放式训练模型」理念。它解决了企业「既想深度定制又怕灾难性遗忘」的痛点。与今天大多专有模型依赖后训练的精调或者接入专用数据库不同,Forge 允许开发者访问 Nova 训练检查点,并在训练的每一个阶段将自有数据与 Amazon 精选的数据集深度融合,从而训练出既理解业务又保留大模型智能水平的专属模型。

Bedrock 的模型合作伙伴丨来自:2025 re:invent

另外值得关注的是,今年 AWS 展现出对中国本土 AI 模型前所未有的拥抱。在 Bedrock 新增的 18 个完全托管模型名单中,三家中国公司的四个模型名列其中:

  • 月之暗面的 Kimi K2 思考模型:具备深度推理能力,能在使用工具的同时进行思考。
  • MiniMax AI 的 MiniMax M2 模型:适合 Coding 和自动化场景,擅长多文件编辑和长工具调用链,被视为开发者 Agent 的强力大脑。
  • 阿里巴巴的 Qwen 模型:其中的 Qwen3-VL 能将屏幕截图直接转换为可运行的代码,并自动执行界面点击操作,是自动化 UI 操作的神器。

过去,全球云厂商往往更倾向于绑定少数几家欧美头部模型厂商。而 AWS 此次将 Kimi、MiniMax、Qwen 等中国顶尖模型纳入核心库,不仅是因为这些模型在性能上已经具备了全球竞争力,更显示了 AWS「互联互通」的生态格局。

对于企业而言,这意味着选择权的极大丰富。无论是需要 Mistral Large 3 这样的长文档处理专家,还是需要中国本土的优秀模型,AWS Bedrock 正在变成一个打破地域和技术栈隔阂的「万能转换插座」,让算力和模型真正成为像水电一样的资源。

当喧嚣退去,AWS 正在为 AI Agent 制定「基本法」

乍看之下,今年的 re:Invent 似乎显得有些「波澜不惊」。这里没有令人瞠目结舌的参数大跃进,也没有颠覆认知的「黑科技」突袭。

Trn3 的性能提升固然强悍,但基本也在预期之内;更有性价比的 Nova 2 和首次推出的语音模型,虽然让人眼前一亮,但在如今 SOTA 模型遍地跑、参数竞赛白热化的行业背景下,似乎也算不上「核弹级」的重磅发布。即便是最受关注的 AI 编程工具 Kiro 和 Agent 开发平台 AgentCore,也多是基于既有产品的更新——这些关于安全性、可观测性或辅助功能的修修补补,难免让人产生「没什么大动作」的错觉。

然而,这种「平淡」或许正是 AWS 最厉害的地方。当我们将视线从单个产品移开,投向整个产业,会发现 AWS 其实在定义下一代基础设施的路上,迈出了极为关键的一步。

AWS 做对的第一件事,是率先打破了 Agent 的「空谈」阶段。在大多数平台还在比拼 Agent 框架的灵活性、推理速度时,AWS 敏锐地意识到:企业需要的不是一个能聊天的机器人,而是一个能干活的员工。于是我们看到,Transform Agent 被用来解决棘手的技术债务,DevOps Agent 被用来处理繁琐的运维报警。

AWS 不再只是提供一个简单的 LLM 接口,而是将行业 Know-how(如 19 年的运维经验、代码迁移经验)封装进 Agent,将其打造成了真正能解决具体业务痛点的「成品工具」。这种「将能力封装为产品」的思路,标志着 Agent 从技术玩具正式迈向了商业实战。

更深层的变革在于 Agent 治理。Agent 的运行范式与过去的 Chat 类应用和传统的云计算业务有着本质区别。传统的云关注「资源」,Chat 应用关注「内容」,而 Agent 关注的是「行动」。将一个拥有自主决策权的 Agent 放入企业的核心业务流,其风险不亚于招聘一名不受控的员工。难点不在于如何让 Agent 跑起来,而在于如何让它不乱跑

Policy in AgentCore丨来自:2025 re:invent

AWS 在本次大会上展示的 Policy 功能,实际上是在尝试重新定义一套 Agent 时代的治理范式。这种用自然语言设定边界的方式,不再是死板的代码约束,而更像是给数字员工颁布一套「法律」。它让管理者可以用人类的逻辑(如「退款金额不得超过 1000 元」)来约束 AI 的行为。这种治理模式的建立,比单一模型的性能提升更具战略意义——因为只有解决了「可控性」和「合规性」这两个拦路虎,企业才敢真正让 Agent 接入核心业务。

最终,当我们重新审视这次 re:Invent,会发现它的意义不在于某个单品的参数碾压,而在于生态位的抢先占领。当大多数玩家还沉浸在解决架构优化和算力堆叠的「基建期」时,AWS 已经通过一系列真实的落地案例和完善的治理技术栈,开始为行业「打样」——它展示了一个 Agent 在真实企业环境中,应该如何被构建、如何被管理、以及如何产生价值。

这或许不是一场充满噱头的发布会,但对于渴望用 AI 提效的实体产业而言,AWS 正在构建的那套让 Agent「可用、可控、可信」 的基础设施,可能是通往未来的真正门票。

在拉斯维加斯,我看到了体育的未来

今年在拉斯维加斯举行的「云计算春晚」——re:Invent,新增了一个非常特殊的板块:体育论坛(Sports Forum)。

如果你是 re:Invent 的常客,大概会对其典型的「硬核技术风」印象深刻:在威尼斯人酒店望不到头的长廊里,数百个会议室密集输出着关于架构、代码和 AI 的硬核干货;展区里则密密麻麻排列着大大小小的展位,一张桌子、一台演示 demo 的笔记本,往往就代表着一个复杂的 ToB 产品。

这里的空气,充斥着「计算存储」、「云原生」、「Agentic AI」等术语。走廊里随处可见行色匆匆的开发者,或是盘腿坐在地毯上敲代码的极客,抑或是坐在简易塑料桌椅上低声洽谈百万级合作的行业伙伴。

Sports Forum 丨来自:2025 re:Invent

但当你推开 Sports Forum 的大门时,画风发生了一百八十度的大转弯。我甚至一度怀疑自己是不是走错了片场。这里不再是严肃的技术讨论场,而是一个充满活力的「主题乐园」。眼前不再是枯燥的代码屏幕和架构图,而是投篮机、正规尺寸的半场篮球场、乒乓球台,以及轰鸣声不断的 F1 模拟器和激战正酣的电竞舞台。

但如果你认为这仅仅是个活跃气氛的「游乐场」,那就被骗了。事实上,这可能是整个 re:Invent 技术含量最高的区域之一。揭开这些娱乐设施的幕布,背后全是硬核的算力和算法。亚马逊云科技正在用云和 AI,在体育行业里掀起新一轮技术革命。

Sports Forum 里的 VR 观赛体验区丨来自:2025 re:Invent

展馆中央的 NBA VR 体验区格外引人注目。戴上头显后,你不再是个被固定在座位上观看 360 度全景的观众,而是能以裁判甚至球员的视角,自由观看比赛名场面。更令人惊喜的是,系统还能实时展示投篮难度、防守统计等高端数据分析。

我瞬间意识到:这不仅是显示技术的进步,更是 AI 技术的深度应用。

NBA 的数据革命:从「统计结果」到「理解过程」

NBA 名宿、怒吼天尊拉希德·华莱士有一个标志性台词:「篮球不会说谎。」

这个名言广泛流传于 NBA 文化圈,并被勒布朗·詹姆斯等明星球员在比赛中多次引用的背后,其实道出的是职业球员的无奈:数据很多时候并不能反映真实的比赛过程。而这一现象,正在随着科技的发展迎来改变。

2025 年 10 月 2 日,当 NBA 宣布与亚马逊云科技达成战略合作的那一刻,篮球这项拥有百年历史的运动,悄然迎来改变。

对于资深球迷而言,我们习惯了用数据去评价球员:得分、篮板、助攻,进阶一点的看 PER 值、正负值。但坦白讲,这些传统的高阶数据依然停留在「统计学」的范畴——它们记录的是结果,而不是真正体现出比赛过程。

这就导致了「数据刷子」的存在,也导致了许多隐形价值被忽略:库里无球跑动时对防线的巨大牵制力,在统计表里是 0;一位防守悍将对持球人的窒息逼抢,只要没产生抢断或盖帽,在数据栏里也是空白。而即使效率值和正负值这种高阶数据,实际上也很难完美体现出每一个不同个性和特点的球员在场上的真正作用。

而亚马逊云科技带来的技术解法,是让机器真正「看懂」比赛。

通过计算机视觉和机器学习技术,现在的系统不再只是记录「球进没进」,而是以每秒 60 次的高频率,实时捕捉并分析球员身上 29 个骨骼点的移动轨迹。

这标志着体育数据从「结构化统计」迈向了「多模态理解」。基于此,NBA 在 2025-26 赛季能够推出三项全新的高阶数据:

投篮难度指数丨来自:亚马逊云科技

第一,防守数据统计(Defensive Box Score)。防守一直是篮球场上的「玄学」。过去我们评价追梦格林防守好,全凭印象流。现在,AI 算法能实时识别每一秒钟「场上谁在防谁」,并计算防守施压频率、协防质量等。这意味着,防守端的贡献第一次有了客观的数据标尺。

第二,投篮难度指数(Shot Difficulty)。不是所有的两分球都生而平等。空位吃饼的 50% 命中率,和在双人包夹下后仰跳投的 45% 命中率,含金量截然不同。新系统通过分析投篮时的身体平衡、防守干扰距离等因素,计算出每一次出手的「难度分」。它能有效区分「体系球员」和「巨星硬解」,还原球星的真实价值。

第三,引力指标(Gravity)。这可能是最令战术分析师兴奋的指标。它通过复杂的三角函数运算,量化一名无球球员吸引了多少防守注意力,以及为队友拉扯出了多大的空间。库里那种「虽然没拿球,但整个防线都因我而动」的影响力,终于变成了可视化的数据。

除了赛场上数据统计规则的重塑,场下的训练和观赛体验也在被改写。

数字投篮实验室丨来自:2025 re:Invent

在 Sports Forum 现场,NBA 多伦多猛龙队展示了他们的「数字投篮实验室」。利用先进的摄像机网络和 AI,系统能实时捕捉每一次投篮的详细生物力学数据,即时分析姿势、轨迹和发力机制。这相当于给每位球员配备了一个拥有「火眼金睛」的 AI 助教,能精确指出哪怕 1 度的姿态偏差。

亚马逊云科技的体育科技:当 AI 介入毫秒级的竞赛

不仅是 NBA,如果我们把视野拉宽,会发现亚马逊云科技构建的这套技术栈,正在重塑整个职业体育的「竞技」与「体验」。

首先是在残酷的职业赛场,比如毫秒必争的 F1、NFL 等比赛,AI 正在成为球队的新助教。

模拟 F1 车队进站换胎丨来自:2025 re:Invent

以 F1 法拉利车队为例,进站换胎是 0.1 秒级的战争。法拉利利用亚马逊云科技 SageMaker 开发了一套进站分析系统,将完成单次进站分析的时间从数小时压缩到了 60-90 秒。系统通过 AI 视觉识别,能自动分析换胎工的每一个动作细节,帮助车队在每一场比赛中寻找那微小的效率提升空间。同时,在车辆设计上,亚马逊云科技的高性能计算如同「数字风洞」,通过千万次的流体力学模拟替代昂贵的物理测试,让赛车设计的迭代速度提升了 70%。

而在对抗激烈的 NFL(职业橄榄球大联盟),亚马逊云科技协助创建了「数字运动员」(Digital Athlete())平台。这实际上是在云端构建了球员的「数字双胞胎」。系统运行了数百万次比赛场景模拟,涵盖了相当于 10000 个赛季的数据,以此来预测受伤风险。NFL 最近修改的开球规则,正是基于这些模拟数据,在保护球员安全与保证比赛观赏性之间找到了最优解。

而对于屏幕前的观众,AI 正在将「看热闹」升级为「看门道」。

今年 re:Invent 期间,亚马逊云科技 CEO Matt Garman 发布了新一代自研的 Amazon Nova2() 的系列模型,不仅有高性价比的推理模型 Lite、处理复杂推理的 Pro, 语音模型 Sonic, 这次还推出了业界首个真正统一的多模态模型 Omni。

而在过去一年里,Nova 模型正在悄然改变着体育行业的内容生态。

AI 辅助生成的德甲短内容丨来自:2025 re:Invent

比如德甲联赛,就在尝试利用亚马逊云科技的技术能力,成为最受球迷欢迎的足球联赛。其负责人在 Sports Forum 上分享了德甲联赛如何利用 Nova 改造了其内容生产的工作流,包括帮助编辑节省时间的「自动化战报」、「德甲故事」,翻译和转录来实现视频本地化,在保持比赛原声和氛围的同时,自动完成多语言转换, 以及满足球迷查阅和聊天需求的「AI 球迷助手」。

现代体育赛事本就是一个多维度信息的融合:从实况解说的语音,到精彩瞬间的画面,从战术数据的分析,到球员表情的特写,每一个环节都在传递着比赛的张力与故事。Nova 的多模态处理能力恰恰可以满足这种复杂场景的需求,精准处理这些交织在一起的文本、图像、视频和音频信息,为球迷带来更丰富的观赛体验。

还有更早推出的「比赛事实」(Match Facts)。AI 实时计算「预期进球概率」(xGoals),让观众直观地知道,这个球没进究竟是运气太差,还是射术不精。更有趣的是「技能角色卡」功能,AI 能自动分析出谁是「终结者」,谁是「策动者」,让伪球迷也能瞬间秒懂场上球员的战术定位。

通过这些措施,德甲编辑可以在人手不变的情况下,几倍增加生成内容,不论是海外球迷、新球迷还是硬核球迷,都能有更好的观赛体验。

而伴随技术进步,生成式 AI 也在改变我们观看比赛的互动逻辑。比如开头提到的 VR 观赛,就用到了 NBA 最新的 "战术探索"(Play Finder())功能,允许球迷用自然语言搜索视频。你不用再输入复杂的关键词,只需说一句「帮我找东契奇所有的后撤步三分」,AI 不仅能理解语义,还能结合对球员骨骼移动轨迹的分析,从海量历史视频库中精准匹配出相关片段。

AI 改变竞技体育丨来自:2025 re:Invent

结语

走出 Sports Forum,我不禁思考:为什么亚马逊、微软、谷歌,以及国内的阿里云、腾讯云等科技巨头,都要在体育领域卷得这么厉害?

仅仅是为了卖云服务给体育联盟吗?我想这只是商业的一面。

从技术演进的角度看,体育正在成为 AI 的终极试炼场

历史上,F1 赛车一直是汽车工业的试验场,如今的民用车技术许多都源自赛道;NBA 和世界杯则是鞋服科技的试验田。而现在,体育场景拥有最极端的要求:毫秒级的低延迟、物理世界的极端复杂性、以及难以预测的球员动作。

如果亚马逊云科技的 AI 能力,能在 NBA 总决赛中提供毫秒级的投篮概率预测,能在 F1 赛车 300 公里时速下完成实时推理,能在 NFL 的肌肉丛林中准确预测人体风险……经历过"魔鬼级"应用场景下一系列的"抗压测试", 那么,证明这套技术在物理世界中具有了极强的鲁棒性。

这种溢出效应的价值前景非常可观。今天我们在 Sports Forum 里看到的、用来保护 NFL 球员膝盖的算法,明天可能就会应用在老人的康复医疗中;今天用来分析 F1 赛车流体力学的算力,明天可能就会用于设计更高效的新能源汽车。

我们在 re:Invent 现场看到的,不仅仅是更精彩的比赛,更是 AI 技术通过体育这一载体,向物理世界和人体奥秘深度渗透的预演。

当科技的终极命题遇上人类最纯粹的竞技热情,一个由数据驱动、AI 赋能的新纪元,正在加速到来。

Event Loop 教你高效 “划水”:JS 单线程的“摸鱼”指南

前言

各位前端打工人,有没有过这种经历:明明写了 setTimeout(() => console.log('摸鱼')),结果同步代码还没跑完,摸鱼计划就被打断?其实 JS 单线程就像一个只能专注干一件事的打工人,而 Event Loop 就是它的 “高效摸鱼手册”—— 既能按时完成核心工作,又能把耗时任务 “挂起摸鱼”,今天咱们就一起好好聊聊这份手册!

一、先搞懂:JS 打工人为啥不能 “硬卷”?(进程线程的底层逻辑)

要想摸鱼,得先知道 “工作台” 的规矩:

  • 进程:好比公司的独立部门 —— 比如浏览器开个新标签页,就是开了个新部门,每个部门都有自己的办公资源(电脑、文件)。

  • 线程:部门里真正干活的打工人 —— 浏览器部门里就有三个核心员工:

    1. 渲染线程(负责画页面,比如给按钮上色、排版文字);
    2. JS 引擎线程(咱们的主角,负责跑代码);
    3. HTTP 请求线程(负责发接口,比如向服务器要数据)。

但这里有个 “办公室规定”:JS 引擎线程和渲染线程是 “互斥同事” ——JS 能修改 DOM(比如把按钮改成红色),要是它俩同时干活,页面就会出现 “排版错乱”(比如按钮画到一半被改成红色),所以必须 “你歇我干”。

更关键的是:JS 引擎线程是个 “独生子” (V8 引擎默认只开一个线程)。这就意味着:如果 JS 遇到一个耗时 10 秒的计算任务(比如统计 100 万条数据),它就会一直死磕这个任务,导致渲染线程没法干活,页面直接卡成 “PPT”—— 这就是 “硬卷” 的下场!

所以 JS 打工人的生存法则是:能摸鱼就不硬卷,耗时任务先 “挂起”,等核心工作做完再处理—— 这就是 “异步摸鱼” 的核心逻辑。

二、Event Loop:摸鱼任务的 “优先级排序”

JS 里的 “摸鱼任务”(异步任务) 分两类,就像公司里的 “紧急任务”“常规任务”,得按顺序处理,不能乱摸鱼:

  • 微任务:紧急摸鱼任务(优先级高)—— 比如 Promise.then()async/await 后续代码、process.nextTick()(Node 环境),相当于 “老板临时交代的小任务,必须在下班前做完”;
  • 宏任务:常规摸鱼任务(优先级低)—— 比如 setTimeoutsetInterval、ajax 请求、I/O 操作、UI 渲染,相当于 “下周要交的报告,先放一放”;
  • 还有个特殊角色:同步任务—— 核心工作(比如写代码、算结果),必须优先做完,相当于 “当天要交的核心 KPI”。

Event Loop 就是这套摸鱼规则的 “监督者”,它的工作流程就像打工人的一天,记好这 4,摸鱼不翻车:

  1. 先清核心 KPI:先把当天的同步任务 (核心工作) 全部做完,遇到异步任务 (摸鱼任务),就按类型扔进 “微任务队列” (紧急摸鱼) 和 “宏任务队列” (常规摸鱼)
  2. 再处理紧急摸鱼:核心 KPI 做完后,把 “微任务队列” 里的所有任务一次性清完(比如老板临时交代的 3 个小任务,必须连续做完,不能中途打断);
  3. 中场休息(渲染页面) :紧急摸鱼任务处理完,浏览器会进行 “页面渲染”(比如更新 DOM、刷新页面),相当于打工人喝杯咖啡歇一歇;
  4. 开启下一轮摸鱼:从 “宏任务队列” 里拿一个任务执行,然后重复 1-3 步,直到所有任务做完。

三、实战摸鱼:用代码例子验证规则

光说不练假把式,咱们用真实代码模拟 JS 打工人的 “摸鱼一天”,看看 Event Loop 是怎么安排任务的!

例子 1:setTimeout为啥 “跑不赢” 同步代码?

先看这串经典代码:

let a = 1;
setTimeout(() => {
    a = 2
}, 1000)
console.log(a);

分析摸鱼过程

  • 同步代码(属于宏任务)先跑:let a=1 → 执行console.log(a),此时a还是 1;
  • setTimeout是宏任务,被扔进 “宏任务队列” 排队;
  • 同步跑完后,微任务队列为空,直接执行下一个宏任务(也就是 1 秒后的a=2)。

所以结果是:先输出 1,1 秒后a才变成 2

image.png

例子 2:Promise.then的 “VIP 特权”

我们看一道经典面试题:

console.log(1);
new Promise((resolve) => {
    console.log(2);
    resolve();
})
.then(() => {
    console.log(3);
    setTimeout(() => {
        console.log(4);
    }, 0)
})
setTimeout(() => {
    console.log(5);
    setTimeout(() => {
        console.log(6);
    }, 0)
}, 0)
console.log(7);

是不是已经头皮发麻了?根本不清楚打印顺序是啥,但是这道面试题我们必须拿下!

摸鱼步骤拆解

  1. 常规摸鱼(宏任务)开跑

    • 先执行console.log(1) → 输出1
    • 遇到new PromisePromise 构造函数里的代码是同步的,执行console.log(2) → 输出2,然后resolve()
    • then是微任务,扔进 “微任务队列”;
    • 遇到外层setTimeout:宏任务,扔进 “宏任务队列”;
    • 最后执行console.log(7) → 输出7
  2. 紧急摸鱼(微任务)接棒

    • 微任务队列里只有then的回调,执行它:console.log(3) → 输出3
    • 回调里的setTimeout(4)是宏任务,扔进 “宏任务队列”。
  3. 宏任务队列开跑(下一轮摸鱼)

    • 先拿第一个宏任务(外层setTimeout):执行console.log(5) → 输出5
    • 里面的setTimeout(6)扔进宏任务队列;
    • 再拿下一个宏任务(then里的setTimeout(4)):执行console.log(4) → 输出4
    • 最后拿setTimeout(6):执行console.log(6) → 输出6

最终输出顺序1 → 2 → 7 → 3 → 5 → 4 → 6

image.png

上图更清晰:

image.png

例子 3:async/await 是 “优雅摸鱼” 的语法糖

async/await 本质是 Promise 的语法糖,相当于给摸鱼任务加了 “自动排队” 功能,先搞懂它的用法

console.log('script start');
async function async1() {
    await async2()
    console.log('async1 end');
}
async function async2() {
    console.log('async2 end');
}
async1();

关键规则

  • async函数本身相当于 “返回 Promise 的函数”;
  • await fn()的本质是:await后面的代码,塞进了fn()返回的 Promise 的then里(也就是微任务队列)

拿这段代码分析:

  1. 同步执行console.log('script start') → 输出;

  2. 执行async1()

    • 进入async1,遇到await async2() → 先执行async2()(同步),输出async2 end
    • await把后续的console.log('async1 end')扔进微任务队列
  3. 继续执行同步代码

image.png

OK既然知道了原理我们就实战摸鱼

// 模拟耗时任务:向服务器要数据(宏任务)
function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('常规摸鱼:发接口请求(耗时 1 秒)');
            resolve('接口返回数据:用户列表');
        }, 1000);
    });
}
// 核心工作函数(async 标记为异步函数)
async function work() {
    console.log('核心工作:开始处理用户数据');
    // await 相当于“等待摸鱼任务完成,再继续核心工作”
    const data = await fetchData();
    // 这行代码会被扔进微任务队列,相当于“紧急摸鱼后的收尾工作”
    console.log(`核心工作:使用${data}完成报表`);
}
// 执行核心工作
work();
// 其他同步任务
console.log('核心工作:处理其他紧急事务');

摸鱼流程拆解:

  1. 执行同步任务:

    • 调用 work() 函数,打印 核心工作:开始处理用户数据
    • 遇到 await fetchData(),先执行 fetchData(),里面的 setTimeout 被扔进 “宏任务队列”(常规摸鱼);
    • await 会暂停 work 函数,跳出去执行其他同步任务,打印 核心工作:处理其他紧急事务 → 同步任务完成。
  2. 微任务队列为空,直接进入中场休息。

  3. 处理宏任务队列(常规摸鱼):

    • 1 秒后,执行 setTimeout 回调,打印 常规摸鱼:发接口请求(耗时 1 秒)Promise resolve 后,await 后面的代码被扔进 “微任务队列”。
  4. 再次处理微任务队列:

    • 执行 console.log(核心工作:使用 ${data} 完成报表) → 核心工作收尾。

image.png

这里的关键是:await 后面的代码会被自动塞进微任务队列,相当于 “摸鱼结束后,优先处理收尾工作”,不用手动写 then 回调,摸鱼更优雅!

大家可以复制代码去运行一下,时间延迟照片体现不出来~~

四、摸鱼避坑:这些误区千万别踩

  1. 误区 1:setTimeout 延迟时间是 “准确时间”

错! setTimeout(() => {}, 1000) 不是 “1 秒后立即执行”,而是 “1 秒后把任务扔进宏任务队列”,得等同步任务和微任务全部完成后才会执行。如果前面的任务耗时 2 秒,那摸鱼就得等 2 秒后才开始。

  1. 误区 2:Promise 构造函数里的代码是异步的

错! new Promise((resolve) => { 同步代码 }) 里的代码是同步执行的,只有 thencatch 回调才是微任务(异步)。比如下面的代码,会先打印 同步代码,再打印 微任务

new Promise((resolve) => {
    console.log('同步代码');
    resolve();
})
.then(() => {
    console.log('微任务')
});

image.png 3. 误区 3:async 函数返回值是 “原始数据”

错! async 函数默认返回一个 Promise 对象,哪怕你写 async function fn() { return 1; },调用 fn() 得到的也是 Promise { 1 },需要用 await 或 then 才能拿到值。

五、总结:Event Loop 摸鱼口诀(记熟直接用)

同步任务先干完,微任务队列清干净;

渲染页面歇一歇,宏任务来轮着干;

await 后藏微任务,Promise 构造是同步;

Event Loop 掌节奏,摸鱼工作两不误!

结语

其实 JS 单线程的 “摸鱼哲学”,本质是 “优先级管理”—— 核心工作优先做,耗时任务排队做,既不耽误事,又不浪费时间。掌握了 Event Loop,你不仅能看懂 JS 异步代码的执行顺序,还能写出更高效的代码,就像打工人掌握了摸鱼技巧,工作效率翻倍,摸鱼也不心慌!

Vue 3 Keep-Alive 深度实践:从原理到最佳实践

Vue 3 Keep-Alive 深度实践:从原理到最佳实践

前言

初入职场,我被安排用 Vue3 制作公司官网,有 5-6 个静态页面。开发完成后,领导在测试时提出一个问题:“为什么页面滑动后再切换到其它页面,返回时没有回到顶部?”调试后发现,是因为使用了 <keep-alive> 组件缓存页面导致的。这引发了我对 Vue 3 Keep-Alive 的浓厚兴趣。Keep-Alive 能帮助我们在页面间切换时保留组件的状态,使用户体验更加流畅。特别是在带有筛选和滚动列表的页面中,使用 Keep-Alive 可以在返回时保留用户之前的筛选条件和滚动位置,无需重新加载或初始化。

在本文中,我将结合实例,从基础到深入地解析 Vue 3 中的 Keep-Alive 组件原理、常见问题及最佳实践,帮助大家全面掌握这一功能。


一、了解 Keep-Alive:什么是组件缓存?

1.1 Keep-Alive 的本质

<keep-alive> 是 Vue 的内置组件,用于缓存组件实例,避免在切换时重复创建和销毁组件实例。换言之,当组件被包裹在 <keep-alive> 中离开视图时,它不会被销毁,而是进入缓存;再次访问时,该组件实例会被重新激活,状态依然保留。

示例场景:用户从列表页进入详情页后再返回列表页。

没有 Keep-Alive 的情况

  • 用户操作:首页 → 探索页 → 文章详情 → 探索页

  • 组件生命周期:

    • 首页:创建 → 挂载 → 销毁
    • 探索页:创建 → 挂载 → 销毁 → 重新创建 → 重新挂载
    • 文章详情:创建 → 挂载 → 销毁
    • 探索页(再次):重新创建 → 重新挂载(状态丢失)

有 Keep-Alive 的情况

  • 用户操作:首页 → 探索页 → 文章详情 → 探索页

  • 组件生命周期:

    • 首页:创建 → 挂载 → 停用(缓存)
    • 探索页:创建 → 挂载 → 停用(缓存)
    • 文章详情:创建 → 挂载 → 销毁
    • 探索页(再次):激活(从缓存恢复,状态保持)

使用 <keep-alive> 包裹的组件,在离开时不会销毁,而是进入「停用(deactivated)」状态;再次访问时触发「激活(activated)」状态,原先所有的响应式数据都仍然保留。这意味着,探索页中的筛选条件和滚动位置都还能保留在页面返回时显示,提高了用户体验。

1.2 Keep-Alive 的工作原理

Keep-Alive 通过以下机制来实现组件缓存:

  • 缓存机制:当组件从视图中被移除时,如果包裹在 <keep-alive> 中,组件实例不会被销毁,而是存放在内存中。下次访问该组件时,直接复用之前缓存的实例。
  • 生命周期钩子:被缓存组件在进入和离开时,会触发两个特殊的钩子 —— onActivated / onDeactivatedactivated / deactivated。可以在这些钩子中执行恢复或清理操作,例如刷新数据或保存状态。
  • 组件匹配<keep-alive> 默认会缓存所有包裹其中的组件实例。但如果需要精确控制,就会用到 includeexclude 属性,匹配组件的 name 选项来决定是否缓存。注意,这里的匹配依赖于组件的 name 属性,与路由配置无关。

1.3 核心属性

  • include:字符串、正则或数组,只有 name 匹配的组件才会被缓存。
  • exclude:字符串、正则或数组,name 匹配的组件将不会被缓存。
  • max:数字,指定最多缓存多少个组件实例,超过限制时会删除最近最少使用的实例。

注意:include/exclude 匹配的是组件的 name 选项。在 Vue 3.2.34 及以后,如果使用了 <script setup>,组件会自动根据文件名推断出 name,无需手动声明。


二、使用 Keep-Alive:基础到进阶

2.1 基础使用

最简单的使用方式是将动态组件放在 <keep-alive> 里面:

<template>
  <keep-alive>
    <component :is="currentComponent" />
  </keep-alive>
</template>

这样每次切换 currentComponent 时,之前的组件实例会被缓存,状态不会丢失。

2.2 在 Vue Router 中使用

在 Vue Router 配置中,为了让路由页面支持缓存,需要将 <keep-alive> 放在 <router-view> 的插槽中:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

这样 <keep-alive> 缓存的是路由对应的组件,而非 <router-view> 自身。不要包裹整个 <router-view>,而是通过插槽嵌套其渲染的组件。

2.3 使用 include 精确控制

如果只想缓存特定组件,可利用 include 属性:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive include="Home,Explore">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

include 中的名称必须与组件的 name 完全一致,否则不起作用。

2.4 滑动位置缓存示例

以“探索”列表页为例:用户在该页设置筛选条件并滚动列表后,跳转到文章详情页,再返回“探索”页。如果没有使用 Keep-Alive,列表页组件会被重新创建,筛选条件和滚动位置会重置。

使用 <keep-alive> 缓存“探索”页后,返回时组件从缓存中激活,之前的 ref 值和 DOM 滚动位置依然保留。这保证了用户回到列表页时,能够看到原先浏览到的内容和筛选状态。

可以在组件中配合路由导航守卫保存和恢复滚动条位置:

  • onBeforeRouteLeave 钩子中记录 scrollTop
  • onActivated 钩子中恢复滚动条位置。

三、使用中的问题:Name 匹配的陷阱

3.1 问题场景

我们经常希望缓存某些页面状态,同时让某些页面不被缓存,例如:

  • “探索”列表页:需要缓存。
  • 登录/注册页:不需要缓存。
  • 文章详情页:通常不缓存。

3.2 第一次尝试:手动定义 Name

<script setup>
defineOptions({ name: 'Explore' })
</script>

然后在主组件中使用 include 指定名称:

<router-view v-slot="{ Component }">
  <keep-alive include="Home,Explore,UserCenter">
    <component :is="Component" />
  </keep-alive>
</router-view>

理论上只缓存 HomeExploreUserCenter

3.3 问题出现:为什么 Include 不生效?

  • 组件名称不匹配:include/exclude 匹配的是组件自身的 name 属性,而非路由配置中的 name
  • 自动生成的 Name:Vue 3.2.34+ 使用 <script setup> 会自动根据文件路径生成组件名,手动写的 name 可能与自动生成冲突。
  • 路由包装机制:Vue Router 渲染组件时可能进行包装,导致组件实际名称与原始组件不同。

依赖组件名匹配容易出错,需要更灵活的方法。


四、解决方式:深入理解底层逻辑

4.1 理解组件 Name 的生成机制

Vue 3.2.34+ 使用 <script setup> 的单文件组件会自动根据文件名推断组件的 name

  • src/pages/Explore/index.vue → 组件名 Explore
  • src/pages/User/Profile.vue → 组件名 Profile

无需手动定义 name,避免与自动推断冲突。

4.2 问题根源分析

  • 自动 Name 与路由名不一致。
  • Router 的组件包装可能导致 <keep-alive> 无法捕获组件原始 name。

4.3 解决方案:路由 Meta 控制缓存

  1. 移除手动定义的 Name
<script setup lang="js">
// Vue 会自动根据路径生成 name
</script>
  1. 在路由配置中设置 Meta
const routes = [
  {
    path: '/explore',
    name: 'Explore',
    component: () => import('@/pages/Explore/index.vue'),
    meta: { title: '探索', keepAlive: true }
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/pages/Auth/index.vue'),
    meta: { title: '登录', keepAlive: false }
  },
  {
    path: '/article/:id',
    name: 'ArticleDetail',
    component: () => import('@/pages/ArticleDetail/index.vue'),
    meta: { title: '文章详情', keepAlive: false }
  }
]
  1. 在 App.vue 中根据 Meta 控制
<script setup lang="js">
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const shouldCache = computed(() => route.meta?.keepAlive !== false)
</script>

<template>
  <router-view v-slot="{ Component }">
    <keep-alive v-if="shouldCache">
      <component :is="Component" />
    </keep-alive>
    <component v-else :is="Component" />
  </router-view>
</template>

默认缓存所有页面,只有 meta.keepAlive 明确为 false 时才不缓存。

4.4 方案优势

  • 灵活性强:缓存策略直接写在路由配置中。
  • 可维护性好:缓存策略集中管理。
  • 避免匹配失败:不依赖手动 name。
  • 默认友好:设置默认缓存,仅对不需要缓存页面标记即可。

五、最佳实践总结

5.1 缓存策略建议

页面类型 是否缓存 缓存原因
首页(静态) ❌ 不缓存 内容简单,一般无需缓存
列表/浏览页 ✅ 缓存 保持筛选条件、分页状态、滚动位置等
详情页 ❌ 不缓存 每次展示不同内容,应重新加载
表单页 ❌ 不缓存 避免表单数据残留
登录/注册页 ❌ 不缓存 用户身份相关,每次重新初始化
个人中心/控制台 ✅ 缓存 保留子页面状态,提升体验

5.2 代码规范

  • 不要手动定义 Name,在 Vue 3.2.34+ 中自动推断。
<script setup>
// Vue 会自动推断 name
</script>
  • 使用路由 Meta 控制缓存。
  • 统一在 App.vue 中处理缓存逻辑。

5.3 生命周期钩子的使用

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  console.log('组件被激活(从缓存恢复)')
})

onDeactivated(() => {
  console.log('组件被停用(进入缓存)')
})
</script>

5.4 性能考虑

  • 内存占用:不要无限制缓存过多页面,可使用 max 限制。
  • 数据刷新:在 onActivated 中进行必要更新。
  • 缓存清理:登出或不常用页面可手动清除缓存。
  • 动画与过渡:确保 <keep-alive><transition> 嵌套顺序正确。

六、总结

6.1 关键要点

  • <keep-alive> 缓存组件实例,通过停用保留状态。
  • include/exclude 功能依赖组件 name
  • 推荐使用路由 meta.keepAlive 控制缓存。
  • 缓存组件支持 onActivated / onDeactivated 钩子。
  • 默认缓存大部分页面,只对需刷新页面明确禁用。

6.2 技术演进

手动定义 Name → 自动 Name → Meta 控制

  • 冗长易错 → 简化代码 → 灵活可靠

6.3 最终方案

  • 利用自动生成的组件名取消手动命名。
  • 通过路由 meta.keepAlive 控制缓存。
  • 在根组件统一处理缓存逻辑。
  • 默认缓存,明确例外。

这样既保持了代码简洁,又实现了灵活可控的缓存策略,确保用户在页面切换时能获得更好的体验。


参考资料

  • Vue 3 Keep-Alive 官方文档
  • Vue Router 官方文档
  • Vue 3.2.34 更新日志

【基础】Unity着色器网格和计算对象介绍

【Unity Shader Graph 使用与特效实现】专栏-直达

Mesh网格定义与核心概念

顶点(Vertex)的本质与特性

顶点是构成3D模型的基本几何单元,每个顶点在三维空间中具有明确的坐标位置(x,y,z)。在Unity中,顶点不仅包含位置信息,还承载着模型渲染所需的多维数据:

  • 法线(Normal):垂直于表面的单位向量,决定光照计算的反射方向。平滑着色时,法线通过相邻面计算;硬边着色则直接使用面法线。
  • UV坐标:二维纹理映射坐标,将2D纹理精准贴合到3D表面。UV值范围通常为0-1,超出部分通过纹理环绕模式处理。
  • 顶点颜色:支持RGBA通道的颜色数据,常用于实现渐变纹理或动态光照效果。

程序化顶点生成

通过Shader Graph的Position节点和数学运算,可动态生成顶点位置。例如,创建波浪效果:

// 伪代码示例:顶点位置偏移

float4 position = TransformPosition(float4(input.position.x, sin(input.position.x * 10) * 0.1, input.position.z, 1));

此代码通过正弦函数沿X轴生成周期性波动,实现水面扭曲效果。

面(Face)的构成与渲染优化

三角形面片的优势

三角形作为3D建模的最小单位,具有以下核心特性:

  • 平面性:三个顶点必然共面,简化碰撞检测和光照计算。
  • 固定朝向:通过顶点顺序(顺时针/逆时针)定义正面/背面,支持背面剔除提升渲染效率。
  • 计算高效:三角形仅需3个顶点和3条边,比多边形更适合GPU并行处理。

多边形的实现原理

虽然多边形面片(如四边形)在建模中更直观,但渲染时会被分解为三角形。例如,Unity的网格渲染器会自动将四边形拆分为两个三角形,确保硬件兼容性。

URP Shader Graph中的网格数据处理

顶点属性节点详解

在Shader Graph中,通过以下节点访问顶点数据:

  • Position:获取模型空间或世界空间坐标。
  • Normal:读取法线向量,用于光照计算。
  • UV:访问纹理坐标,支持多通道UV(如UV1、UV2)。
  • Color:读取顶点颜色,支持与纹理混合。

示例:动态法线修改

创建凹凸效果时,可通过修改法线改变光照表现:

// 伪代码示例:法线扰动

float3 normal = normalize(input.normal + float3(0, sin(input.position.x * 10) * 0.1, 0));

此代码沿Y轴添加正弦波动,模拟表面起伏。

纹理映射与UV坐标实践

UV坐标的工作原理

UV坐标通过将3D表面展开为2D平面实现纹理映射。例如,立方体需6组UV坐标,而球体通常使用球形投影或立方体映射。

多通道UV应用

复杂模型可能使用多组UV坐标:

  • UV1:主纹理通道。
  • UV2:辅助纹理(如法线贴图)。
  • UV3:顶点动画或动态遮罩。

在Shader Graph中,通过UV节点选择通道,结合Sample Texture 2D实现多纹理混合。

顶点颜色与动态效果

顶点颜色的应用场景

  • 渐变纹理:通过顶点颜色控制材质过渡。
  • 动态光照:结合顶点颜色实现局部光照变化。
  • 调试工具:可视化法线或UV坐标。

示例:顶点颜色驱动透明度

创建渐隐效果时,可通过顶点颜色控制透明度:

// 伪代码示例:颜色驱动透明度

float4 color = input.color * float4(1, 1, 1, smoothstep(0.5, 0.8, input.color.a));

此代码根据顶点Alpha值平滑调整透明度,实现边缘渐隐。

URP Shader Graph的优化技巧

性能优化策略

  • 减少动态计算:将顶点属性计算移至顶点着色器。
  • 合并属性:通过Attributes节点打包数据,减少采样次数。
  • 使用LOD:根据距离简化网格复杂度。

移动端适配

  • 简化着色器:避免复杂数学运算。
  • 压缩纹理:使用ASTC或ETC2格式。
  • 动态批处理:启用URP的自动批处理功能。

进阶应用:程序化网格生成

动态网格创建

通过Create Mesh节点和Set Mesh节点,可在运行时生成网格:

// 伪代码示例:生成平面网格

Mesh mesh = new Mesh(); 
mesh.vertices = new Vector3[] {
          Vector3.zero,
          Vector3.right,
          Vector3.up,
          Vector3.right + Vector3.up
          };
mesh.triangles = new int[] { 0, 1, 2, 0, 2, 3 };

此代码创建了一个包含两个三角形的平面。

实例化渲染

使用Instancing节点和Set Mesh节点,可高效渲染大量相同网格:

// 伪代码示例:实例化渲染` 

MaterialPropertyBlock props = new MaterialPropertyBlock();
props.SetVector("_Color", Color.red);
Renderer renderer = GetComponent<Renderer>();
renderer.SetPropertyBlock(props); 
renderer.SetMaterial(material, 0);

此代码为所有实例设置统一颜色,减少Draw Calls。

常见问题与解决方案

法线错误

  • 现象:模型出现光照异常。
  • 解决:检查法线方向,使用Normalize节点修正。

UV拉伸

  • 现象:纹理在模型表面扭曲。
  • 解决:优化UV展开,或使用Tiling And Offset节点调整。

性能瓶颈

  • 现象:帧率下降。
  • 解决:简化着色器,减少动态计算,启用批处理。

总结与最佳实践

URP Shader Graph通过可视化节点系统,大幅降低了着色器开发门槛。掌握网格数据处理的核心要点:

  • 顶点属性:灵活运用位置、法线、UV和颜色。
  • 三角形优势:利用其平面性和计算效率优化渲染。
  • 程序化生成:通过动态创建实现复杂效果。
  • 性能优化:减少计算,合并数据,适配移动端。

结合URP的渲染管线特性和Shader Graph的节点化设计,开发者可快速实现从简单材质到复杂视觉效果的全方位创作。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Vue3 + Keep-Alive:实习中遇到的 window 滚动问题与实践

Vue3 + Keep-Alive:实习中遇到的 window 滚动问题与实践

前景:实习项目中的困扰

在实习期间,我参与了公司项目的前端开发,页面主要包括首页(Home)和探索页(Explore)。在项目中,这两个页面都使用 window 作为滚动容器。测试时发现一个问题:

首页和探索页都使用 window 作为滚动容器
↓
它们共享同一个 window.scrollY(全局变量)
↓
用户在探索页滚动到 500px
↓
window.scrollY = 500(全局状态)
↓
切换到首页(首页组件被缓存,状态保留)
↓
但 window.scrollY 仍然是 500(全局共享)
↓
首页显示时,看起来也在 500px 的位置 ❌

这个问题的原因在于:

  • <keep-alive> 只缓存组件实例和 DOM,不管理滚动状态。
  • window.scrollY 是全局浏览器状态,不会随组件缓存自动恢复。
  • 结果就是组件被缓存后,滚动位置被错误共享,导致用户体验不佳。

我的思路:滚动位置管理工具

为了在自己的项目中解决类似问题,我考虑了手动管理滚动位置的方案:

/**
 * 滚动位置管理工具
 * 用于在 keep-alive 缓存页面时,为每个路由独立保存和恢复滚动位置
 */
const scrollPositions = new Map()

export function saveScrollPosition(routePath) {
  const y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop
  scrollPositions.set(routePath, y)
}

export function restoreScrollPosition(routePath, defaultY = 0) {
  const saved = scrollPositions.get(routePath) ?? defaultY
  requestAnimationFrame(() => {
    window.scrollTo(0, saved)
    document.documentElement.scrollTop = saved
    document.body.scrollTop = saved
  })
}

在组件中配合 Vue 生命周期钩子使用:

import { onActivated, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { saveScrollPosition, restoreScrollPosition } from './scrollManager'

export default {
  setup() {
    const route = useRoute()

    // 组件激活时恢复滚动
    onActivated(() => {
      restoreScrollPosition(route.path, 0)
    })

    // 组件离开前保存滚动
    onBeforeUnmount(() => {
      saveScrollPosition(route.path)
    })
  }
}

公司项目的简化处理

在公司项目中,由于页面结构简单,不需要为每个路由保存独立滚动位置,因此我采用了统一重置滚动到顶部的方式:

// 路由切换后重置滚动位置
router.afterEach((to, from) => {
  if (to.path !== from.path) {
    setTimeout(() => {
      window.scrollTo(0, 0)
      document.documentElement.scrollTop = 0
      document.body.scrollTop = 0
    }, 0)
  }
})

这样可以保证:

  • 切换页面时始终从顶部开始。
  • 简单易维护,符合公司项目需求。
  • 避免了 Keep-Alive 缓存滚动穿透的问题。

总结

  1. <keep-alive> 缓存组件实例,但不管理 window 滚动状态,导致全局滚动共享问题。
  2. 自己项目中,可以通过滚动位置管理工具为每个路由独立保存和恢复滚动。
  3. 公司项目中,为简化处理,只需在路由切换后重置滚动到顶部即可。
  4. 总体经验:滚动管理要根据项目复杂度和需求选择方案,既保证用户体验,又保证可维护性。
❌