普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月15日首页

flutter布局(列表组件)

2026年4月15日 11:14

通用ScrollController

  • 控制器加上必先挂载销毁

      /// 初始化
      ScrollController _scrollController = ScrollController();
      
      /// 销毁控制器
      @override
      void dispose(){
          _scrollController.dispose();
          super.dispose();
      }
      
      /// 绑定
      ListView(
          controller:_scrollController, //绑定控制器
      )
    
  • 滚动到顶部/指定位置

      /// 安全判断
      if(!_scrollController.hasClients) return;
      /// 滚动到顶部
      _scrollController.jumpTo(0); /// 跳转到顶部,无动画
      _scrollController.animateTo( /// 动画滚动
          0,
          duration: const Duration(molliseconds:300), //
          curve:Curves.easeOut, // 动画曲线
      )
      
      /// 滚动至底部(_scrollController.position.maxScrollExtent)
      final maxExtent = _scrollController.position.maxScrollExtent;
      _scrollController.jumpTo(maxExtent);
      ...
      
    
  • 滚动吸顶/隐藏导航栏/下拉更多等

      /// 监听+状态
      _scrollController.addListener((){
          /// 底部200px 触发加载
          double maxExtent = _scrollController.position.maxScrollExtent;
          double currentOffset = _scrollController.offset;/// 滚动到的位置
          
          if(currentOffset >= maxExtent - 200 && !isloading) {
              loadMoreDate();
          }
          
          /// 吸顶
          if(currentOffset <= 50){
              setState({
                  isCeilingMounted = true;
              })
          }else{
              setState({
                  isCeilingMounted = false;
              })        
          }
      })
    
  • 常用api

    • 判定是否有挂载

        _scrollController.hasClients
      
    • 卸载

        _scrollController.dispose();
        
      
    • 当前滚动位置

        _scrollController.offset
        _scrollController.position.pixels
      
    • 列表最大滚动位置

        _scrollController.position.maxScrollExtent;
      
    • 滚动

        _scrollController.jumpTo();
        _scrollController.animateTo(
            0,
            duration:const Duration(milliseconds:300),
            curve: Curves.ease
        )
      
    • 滚动监听

        _scrollController.addListener((){
            
        })
      

列表组件

ListView/ListView.builder/ListView.separated

  • 共用api

      scrollDirection //默认Axis.vertical(垂直) Axis.horizontal(水平)
      reverse // 默认false
      padding // 默认null
      shrinkWrap // 高度自适应 默认false,性能慎用!
      controller // 控制器
      itemExtent //固定子项高度/宽度
      cacheExtent // 预渲染缓存区域大小
      prototypeItem // 按照样本组件自适应高度
      addautomaticKeepAlives //默认true 保持已经加载好的子项状态,防止滑出屏幕后重建
      physics // 滚动物理效果 
      ///ClampingScrollPhysics(边界“撞墙”+微光效果 安卓默认)
      ///BouncingScrollPhysics(弹性回弹效果 IOS默认)
      ///NeverScrollableScrollPhysics(禁止滚动)
      ///AlwaysScrollableScrollPhysics(强制可滚动)
      ///PageScrollPhysics(以整页滑动、吸附效果明显)
      
    

补充全局效果设置:MaterialApp( scrollBehavior: MaterialScrollBehavior().copyWith( physics: const ClampingScrollPhysics(), // 全平台强制使用Android效果 ), );

  • 列表不超过15条可用ListView

  • 重点属性

    • ListView.builder——只创建/渲染屏幕可见区域+附近区域(超过15条考虑使用)
    • itemExtent——告知每一项的宽度/高度,提高命中率和性能(必用)
    • control——控制滚动
    • scrollDirection——Axis.horizontal/Axis.vertical控制滚动方向
    • itemExtent——列表长度
    • builder/separated专属(itemCount+itemBuilder)
  • ListView.separated

     ListView.separated(
         itemExtent:,
         itemCount:,
         itemBuilder:(context,index),
         separatorBuilder:(context,index){
             return Padding()
         }
     )
     
    
  • 性能优化相关

    • ListView.builder/ListView.separated(必需)
    • itemExtent (尽量必需,性能最好)
    • cacheExtent (必需,但值不能太大)
    • addAutomaticKeepAlives: true (通常必需)
    • shrinkWrap:true (性能消耗大,慎用!!!)

GridView/GridView.builder/GridView.extent

  • gridDelegate

    SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2, // 强制2列
        mainAxisSpacing: 10, // 上下间距10px
        crossAxisSpacing: 10, // 左右间距10px
        childAspectRatio: 1.5, // 子项宽高比
    )
    SliverGridDelegateWithMaxCrossAxisExtent( // 设置子项的最大宽度,即如果屏幕宽度有500,则(150*n)+(10*(n-1))<=500
        maxCrossAxisExtent: 150, // 子项最大宽度150px
        mainAxisSpacing: 10, // 上下间距10px
        crossAxisSpacing: 10, // 左右间距10px
        childAspectRatio: 1.0, // 正方形
    )
    
  • GridView.extent 即透传了 SliverGridDelegateWithMaxCrossAxisExtent,可以直接在GridView.extent中写SliverGridDelegateWithMaxCrossAxisExtent的属性

    GridView.extent(
        controller:_controller,
        children:<Widget>[],
        double maxCrossAxisExtent,
        double mainAxisSpacing,
        double crossAxisSpacing,
        double? childAspectRatio 
    )
    

SingleChildScrollView

  • 较短的滚动页面(长列表情况性能消耗较大)
  • 通常用作页面防溢出或短滚动

CustomScrollView

  • 大部分属性跟ListView和GridView一样,下面只例举较常用的api
  • cacheExtent(默认250)可设置150左右
  • shrinkWrap(是否自适应子组件高度)——会破坏懒加载,性能变差
  • slivers
    • SliverAppBar——折叠式标题栏,支持悬浮、吸顶、折叠
    • SliverList——对应ListView
    • SliverGrid——对应GridView
    • SliverToBoxAdapter——将普通Widget作为child传入Sliver
    • SliverPadding——内边距
    • SliverFillRemaining——填充页面剩余空间

一千块的录音卡片,凭什么还敢每年多收一千块钱?

作者 苏伟鸿
2026年4月15日 10:41

编者按:
当 AI 开始寻找自己的形状,有些选择出人意料。
AI 在智能手机上生出了一颗独立按键,似乎让智能手机找回了久违的进化动力。眼镜凭借着视觉和听觉的天然入口,隐隐有了下一代个人终端的影子。一些小而专注的设备,在某些瞬间似乎比 All in one 的设备更为可靠。与此同时,那些寄望一次性替代手机的激进尝试,却遭遇了现实的冷遇。
技术的落地,从来不只是功能的堆叠,更关乎人的习惯、场景的契合,以及对「好用」的重新定义。
爱范儿推出「AI 器物志」栏目,想和你一起观察:AI 如何改变硬件设计,如何重塑人机交互,以及更重要的——AI 将以怎样的形态进入我们的日常生活?
这是 AI 器物志第 9 篇文章。

最近几年「AI 硬件」很火,但似乎总是不可避免滑向「一锤子买卖」的叙事。

发布会抛出一个宏大的概念,用概念和想象力完成第一轮说服,真正交付到用户手中后,才发现它的能力匹配不上它卖的故事。

于是乎,产品的使用频率直线下滑,更没人愿意为它的后续服务持续付费,最终用户日活几百人,落得一个黯淡收场。

但 Plaud 打破了这样的死循环——它不仅赚了我录音笔的钱,还打算每年继续赚我 1000 元,然后一年收入 2.5 亿美元。

更重要的是,我真的愿意给,在 AI 硬件这个概念还备受质疑的当下,它直接跑通了一整个商业模式。

硬件是入口,App 是核心

Plaud 免费的「入门版」方案,包含 300 分钟的转录时长。

但对于一个需要有着专业录音需求,以至于需要买一支录音笔的用户来说,300 分钟只是一个试用装。

再往上,专业版价格为 339 元一年,每月享有 1200 分钟的转录时长,而 1099 元一年的「卓越」会员则拥有无限时长。

事情到这里就变得很有意思了。因为一支 Plaud Note 录音笔本体的价格,大约也就在 1049 元左右。

也就是说,订阅一年的费用,甚至可能比硬件本身还贵。

所以,Plaud 究竟有什么魔力,让人心甘情愿先花 1000 元买个录音笔,再每年继续花 1000 元来使用?

实际上,构成 Plaud 核心体验的,并不是录音笔硬件本身,而是和它配套使用的 App。

这个 App 本身相当纯粹,所有功能都只围绕处理「录音」进行服务,显得高级而专业。

它核心功能是 「笔记」 :Plaud 会用大模型对录音内容进行处理,你可以把一切交给 Plaud 一键生成,也可以选取转写的语言、AI 模型、模板。

但让「结果」这件事进一步拉开差距的,是 Plaud 的模板功能,也是我认为它最值得我花钱的功能。

官方首先提供了大量的现成模板,涵盖会议、演讲、通话、采访、医疗、金融、法律等多种场景和专业,也有一些「功能性」的模板,针对录音中发言人的意图、权力动态进行分析,甚至还能分析发言者的心理动态和诚实度。

其实本质上,Plaud 的模板就是一长串的固定提示词(prompt),利用大模型对录音进行定向整理,Plaud 在卖的,就是自己提前编写好的一套套提示词。

既然是提示词,那自然也允许用户自己编写模板——这点很像一个真正懂工作的人做出来的产品,因为很多时候,通用模板都不够贴身,真正值钱的是你自己那套处理信息的逻辑。

比如,我们每天早上都会开选题会,涉及到选题内容、选题核心、重点信息、负责编辑这些要点,普通转写能够记录,但很难成为一份能直接执行的东西。

后来我干脆给 Plaud 写了一个模板,让它按照这些维度去整理。最后出来的结果非常清楚,每个选题被单独拆开,每项任务也都准确落位。

通过换用不同的模板,同一段录音就能以不同的方式被分析、蒸馏,通过这样反复整理,我们就能得出录音中的最有价值的信息。

Plaud 给我的一个感觉,是它很以「人」为中心。

很多录音产品,例如飞书妙记,其实是围绕「会议录音」本身,每次完成录音,都会自动生成笔记,可以直接用来分配任务。

但 Plaud 只是围绕「人」听到的话进行构建,聚焦在人和人之间的交互,首先是 100% 记录,然后再通过摘要、模板,保留和树立其中最有价值 50% 内容,并将 10% 的精华呈现出来。

不同的模板,就是看待原始信息的不同方式,你关心什么,就用什么模板——这不能代替人的思考,但能带来不同的启迪。

保留最精华的原始信息,再加上人脑在对谈时产生的记忆和经验,交流能达到的理解,才真正实现最大化。

AI 时代的「电和电灯」

明明是一个「AI 硬件」,但 Plaud 并不靠「硬件」本身赚钱,收入的大头都是来自订阅。

这就是经典的「剃须刀-刀片」商业模式:剃须刀厂家卖的不只是刀本身,而是可替换的刀片,在用户购买硬件后,还需要持续付费,并且被绑定在一个生态系统之中。

放在今天,它又很像一套标准的 「AI 式收费」 逻辑。不同等级会员的核心差别,并不在于质量和速度,而在于时长,在于额度,在于你愿意为多少 「Token」 买单。

当然,这不是说 Plaud 的录音笔一无是处,既然是重度的录音用户,一个专门用来录音的物理外挂,对体验的提升是巨大的。

那 Plaud 和 OpenAI 一样,是靠「AI 模型」赚钱的吗?似乎也不是。

Plaud 的优秀体验,确实是靠大语言模型支撑的,并且可以选用从 DeepSeek、千问、豆包等多个模型中选择,但默认的「自动模式」,才是 Plaud 的精髓:让你少管让你少管技术细节,直接拿到结果。

这也是 AI 时代最显著的特征,我们为结果付费,而不是为工具本身,或者过程付费。

Plaud 贩卖的不是「AI」本身,它不做自己的大模型,而是卖一种「使用 AI 的方法」。

本质上,AI 大模型只是一种源动力,是类似水和电的存在,它有巨大的能量,但我们目前对它的开发还太少,还基本没太突破聊天机器人的范畴。

就像 200 年前,大家都不知道法拉第发现的电有什么用,然后,直到电灯、电话等等电器来到世人面前。

Plaud,就是 AI 时代的「电灯」。

野心很大的 Rabbit R1、Humane Ai Pin,更像是在生成式 AI 浪潮下「带着 AI 去找问题」的产物。

说句不好听的,它们本质上仍是「为了 AI 而 AI」的硬件形态,并没有真正锚定一个明确的用户场景,用 AI 去解决实际问题。

对 Plaud 来说,AI 反而并不重要,这家企业从一开始,只是想做好「录音」这件事,AI 大模型,不过是一条通向这个目标最近的道路。

就像是亘古以来就一直困扰着人类的照明问题,在电灯之前人类也已经发明了蜡烛,但电灯的效率和照明效果都要远超以往,所以我们选择了电灯。

Plaud 的逻辑也很类似,我们有了 AI 大模型,它拥有着前所未有的语言处理能力,因此用它来解决录音场景的问题,让「录音」这件事变得前所未有的好。

我们当然用现有的 ChatGPT 或者 Gemini 这些聊天机器人,帮助我们处理这些录音,但为了获得一个好的结果,我们需要优质的提示词,如果模型不够好还要换模型,并不是一个符合直觉的使用方式。

而 Plaud 不需要用户考虑使用什么大模型,不需要用户给更好的提示词,它是一种一键就能将好结果带到用户面前的确定性。

并且,Plaud 在大量雷同产品中,是最具有「确定性」的那个。

而我们愿意付费的,就是这个确定性,不用「抽奖」,一键直达的确定性。

AI 硬件,应该吞掉「复杂性」

很多人其实不会用 AI。

这里说的「不会」,一方面是不会主动去接触 AI 产品,但更是不知道要怎么用 AI,以为在对话框扔一句短短的提示词,它就能全面理解你的意图,交付一个马上能用的结果。

问题就出在这里,现在真正愿意花时间研究提示词、工作流、模型差异的人,也就是「会用 AI 的人」,确实少之又少。大部分用户既没有这个耐心,也没必要有这个耐心。他们需要的是结果,并不想把自训练成半个提示词工程师。

毕竟,我们对 AI 的期待,本身就是花最少的精力,获得最多最好的成果。

不会用 AI,那就更不知道,一个合格好用的 AI 产品,应该是一个什么模样——所以现在的科技圈,充满了各种只有噱头,没有价值;空有 AI,没有能力的所谓「AI 硬件」。

大家看多了宣传,很容易把会聊天、会生成、会调用模型,当成产品已经成立,其实远远不够。

一个好用的 AI 产品,至少得做到两件事:第一,它知道用户大概率不会用 AI,所以不会把学习成本甩给用户;第二,它能把原本飘忽不定的模型能力,压缩成一个相对稳定的结果。

这就是 Plaud 的价值所在,它解决的是一个非常真实的问题:不是每个人都想学会 AI,可每个人都想把事做完。

在 Plaud 面前,众生平等,所有人都按下一样的按键,获得一样的高质量内容,区别只在于订阅后能获得的内容多与少。

更进阶的「模板」,就是 Plaud 官方提供的「小抄」,直接让你根据需求选用合适的提示词;这还不够的话,来自用户的各种模板就是更强大的补充。你不用钻研,Plaud 直接把 AI 红利喂到你的嘴边。

就像用氪金的方式,让 Plaud 帮你研究日新月异的大语言模型,帮你构思更多更好的 Prompt,彻底摆脱被甩开的焦虑。

替你将复杂吞掉,将差距抹平,Plaud 在卖的,其实就是一个「速通 AI」的外挂而已。

说起来不难,但能真正能只做这么一件事,将所有资源倾注其中的企业,寥寥无几——大而全的厂商,他们要做的是「全能」而不是「专才」,不可能愿意投这么资源去只做一个录音的功能;而小而新的初创者缺少了积累

这是为什么 Plaud 能成为行业的第一,无非就是因为它足够纯粹,只关心录音,于是所有的资源都用在了每一天磨砺更好的效果,比竞争者做得更早,也更专注。

它没有发明一个全新的世界,也没有端出特别夸张的未来想象。它只是证明了一件很关键的事:在这个阶段,最有价值的 AI 产品,往往不是能力最张扬的那个,而是最能给普通人提供确定性的那个。

我希望能见到越来越多的「Plaud」。

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

粗门完成数千万A+轮融资,在AI时代让更多人“出门”|36氪首发

2026年4月15日 10:39

36氪获悉,线下兴趣活动社区“粗门”已完成新一轮融资,投资方为一家长期关注消费与生活方式领域的香港家族基金。 本轮资金将主要用于扩大品牌影响力、提升优质主理人供给,并加速社区生态与玩法体系建设。这也是粗门继2023年获得Keep战略投资后的又一次融资。

粗门正在试图重构一件逐渐被忽视的事情——人们“如何在现实世界一起玩”。

当线上娱乐极度丰富,线下体验反而变稀缺

过去二十年,互联网解决的是“链接”和“内容”。但今天,一个新的问题正在出现:线上娱乐越丰富,线下真实体验反而越稀缺。

短视频、游戏、社交平台占据了大量时间,但人与人之间面对面的真实互动却在减少。粗门创始人湘翁曾负责闲鱼产品与支付宝B端产品,他在多年互联网经验后,做出了一个不那么“主流”的判断:“AI可以替代效率,但无法替代体验。人和人最真实的连接,依然在线下。”

不想做平台,做一种新的“玩法生产方式”

粗门成立于2022年,定位为一个连接真实世界的兴趣活动社区。创业之初以“活动报名”工具切入,但不仅仅局限于工具。粗门鼓励用户不只做参与者,主理人不只做组织者,活动不只是单次的消费,而是进行更深的参与。湘翁希望:“玩法可以被设计,活动可以被生产,体验可以被创造。”

围绕“组局”这一核心行为,粗门希望每一个用户都可以:发起一场活动、组织一群人、创造一次体验。

截至2025年12月: 粗门累计用户超2000万,主理人及俱乐部超过10万个,月活动规模超过10万场。从飞盘、徒步、骑行、匹克球,到阿卡贝拉、即兴戏剧,再到“躲猫猫”“乱讲PPT”“偷甘蔗”等新玩法,这些活动共同构成了一种新的线下生活方式。

粗门数据

让“带人玩”的主理人,成为一门新职业

在粗门上,很多主理人有着严肃的“三次元”标签:如退役运动员、退休教授、职业户外选手,也有很多只是普通兴趣爱好者。他们都能通过组织活动获得收入,同时也在创造体验。

粗门致力于让“带人玩”这件事,从少数人的能力变成一种可以规模化的机会。通过个人能力为体验带来更丰富的感受,比如:有人夏天带用户去感受萤火虫的浪漫;退休教授在古蜀道上讲述历史故事;厨艺爱好者在家里组织一场“家宴”;植物学家在公园里讲述植物的变迁;剧院运营者将话剧排练开放给用户参与。

这些原本稀缺的体验被粗门重新组织。湘翁认为:“主理人收钱不是在卖服务,而是在创造情绪价值和人与人的连接。”

从“内容种草”到“体验入坑”

在商业化层面,粗门不希望走互联网平台的老路。与传统互联网依赖流量不同,粗门更像一个“线下体验分发系统”。品牌投放广告不只是发布内容,而是进入真实场景让用户去体验产品,让“体验入坑”成为一种新的品牌推广路径。

  • Salomon通过一场场越野赛和嘉年华,让更多用户感受产品理念;
  • 在牛背山顶租赁OPPO手机拍照的登山者,在亲身体验中被国产品牌的崛起所震撼;
  • 峨眉山设计的打卡挑战赛,让户外人乐此不疲地刷新纪录。

粗门目前月均活动超10万场,品牌可以通过兴趣、场景或人群等标签精准链接合作方案。

主理人小会

当“玩”成为生活的一部分

湘翁提到一个设想:如果未来每一天都有百万场真实发生的活动,那么“玩”将不再只是线上娱乐,而会成为人们日常生活的重要组成部分。他希望能实现:任何时间、任何城市,打开粗门都可以找到一件值得参与的事。这不仅是一个产品目标,更是希望促进社会产生变化,让大家:

  • 从线上娱乐 到 真实体验
  • 从被动消费 到 主动参与
  • 从内容观看 到 体验创造

更长期的选择

尽管已经实现盈利,粗门并没有急于规模化扩张。这轮融资也对投资方提出了特殊的要求:不进入董事会、无对赌协议、无短期业绩压力。湘翁的判断是:“线下的事情,是需要时间积累的。”

相比快速增长,粗门更看重:主理人生态、玩法创造、真实体验。他希望主理人在快乐中赚到钱,成为一个为社会带去快乐价值的新职业。

重新回到真实世界

在AI快速发展的时代,人们需要重新思考:什么是不可替代的?什么是真实的?什么值得参与?

粗门试图给出的答案是:真实的体验、人与人的互动,以及一起发生的瞬间。如果说互联网改变了人与信息的关系,那么粗门的目标就是改变人与人之间的连接方式。

或许未来某一天,“出门玩”不再需要思考——就像打车用滴滴、买卖二手用闲鱼一样:出门玩打开粗门,就够了。

 

微软想给所有 Windows 电脑预装龙虾

作者 马扶摇
2026年4月15日 10:30

在这个 AI 如火如荼的时候,「桌面端」似乎显得有些冷清。

归根结底,对于 LLM 类 AI 应用来说,你只需要一个对话框就可以完成交互,在 app、在浏览器,还是在桌面端完全没有区别。

而 OpenClaw 的出现,多少改变了这一点——这种本地部署的软硬件结合方式,重新将「电脑」这一载体扔回了 AI 漩涡的中心。

图|封面新闻

然而 Open-Claw 长期存在着一个底层缺陷:它是基于类 Unix 环境构建的,天生对 Linux 和 macOS 比较友好,在 Windows 上安装起来很麻烦。

由于 OpenClaw 高度依赖类 Unix 环境,许多底层脚本也是基于 Darwin 或 Linux 写的,因此想要在 Windows 上得到一个能用的龙虾助手,光是折腾 WSL2、Docker 和 Nix 就足够劝退 90% 的尝鲜用户了。

面对这种用户需求和系统环境的矛盾,「从善如流」的微软敏锐地察觉到了这之中隐藏的需求,提出了一个雷霆方案:

我们要给 Copilot 也加上类 OpenClaw 能力。

从副驾驶到代理人

虽然我们曾经调侃过微软滥用 Copilot 导致它变「Microslop」的问题,但时至今日,Copilot 的确依然是 Windows 内建的最主要的 AI 方案之一。

在经历过普遍的对于 Copilot 的反对声音之后,微软似乎终于打算做一些有意义的工作了。

根据最新的行业消息,微软全球资深副总裁(CVP)之一奥马尔·沙欣(Omar Shahine)受命组建一支新的「精锐团队」,挖掘 OpenClaw 在企业环境中的潜力,以及将「类 OpenClaw 能力」集成进 Copilot 的可能性。

奥马尔·沙欣(中间)|LinkedIn

长期以来,微软服务、尤其是 Windows 用户,对 Copilot 的评价始终呈现出一种诡异的两极分化。

对于微软自己来说,它在财报中自豪地宣称 Copilot 拥有近 1500 万付费用户(大约相当于 Office 365 用户的 3%),还拥有「巨大的增长空间」。

然而市场却对 Copilot 充满了寒意:微软的股价在 26 年表现惨淡,甚至在大型科技股中垫底,跌幅一度达到了 24% ——

图|Analytics Insight

投资者和股市的逻辑很直白:

如果 Copilot 继续作为一个需要用户不断喂提示词、总结两页文档能反向吐出四页废话的「对话框」,那它永远无法产生真正意义上的生产力溢价。

尤其当隔壁的 Claude 已经能「连接」PowerPoint 和 Excel 来代替用户操作复杂内容的时候,微软必须拿出一些更硬核的东西来证明自己。

这也是微软通过「Copilot 风味 OpenClaw」期望达到的效果,我们可以给它起名叫「MS-Claw」。

图|TechCrunch

毕竟纯粹基于 LLM 的 Copilot 本身实在是太废物了,虽然权限极高,但几乎无法实现任何具备 agentic 能力的代理操作功能。

如果说现有的 Copilot 是一个听命行事的速记员,那么正在开发的 MS-Claw 则是一个全天待命的「数字分身」,旨在让 Copilot 实现 24/7 自主运作电脑的效果。

图|Jukka Niiranen

换言之,MS-Claw 和直接从 GitHub 上部署的开源版本 OpenClaw 能力差不多——

它不再被动地等待用户输入指令,而是主动筛选你的 Outlook 收件箱、梳理日历、在后台自动重组 Excel 数据,在你每天打开电脑之前就准备好待办清单和当日简报。

这种从以 LLM 为代表的「反应式 AI」向「代理式 AI」的跨越,正是为了解决企业级用户最头疼的安全与效率平衡问题,以及挽回 Copilot 的口碑。

图|Microsoft

为了「和 Anthropic 抢时间」,微软 CEO 萨蒂亚·纳德拉(Satya Nadella)重组了工程架构,将消费者与企业版的 Copilot 开发团队合并,并提拔了多位高管直接向其汇报。

比如原本带领 Agent 365 的查尔斯·拉曼纳(Charles Lamanna),就负责监督在现有 365 Copilot 服务中构建 MS-Claw 的工作,奥马尔·沙欣也在他的团队中。

查尔斯·拉曼纳|Microsoft

除了前面提到的代用户操作 Microsoft 365 应用套件之外,微软的另一个目标是让 MS-Claw(以及整个 365 Copilot)更好地在后台参与 Microsoft 应用程序里面工作,无需用户的持续监督。

例如,MS-Claw 可以在用户手动编辑新 Excel 工作表的时候,根据之前的命令,在后台静默整理其他工作表的格式或者信息。

图|Microsoft

另外据知情人士透露,365 Copilot 的产品负责人也讨论直接过为 MS-Claw 构建一些具体的代理(agents),比如市场经理、销售总监或会计。

比起接入外部代理或者不划分角色,这么做可以让每个代理的权限控制变得更简单,增加在处理企业内部敏感信息时的安全性。

总之,抛开混乱的命名不谈,微软希望通过 MS-Claw 实现的——不管套着 365 Copilot 还是 Microsoft 365 的皮——本质上就是借 Copilot 给用户提供一个「开箱即用」的 Agentic AI 解决方案。

Copilot 一共有多少种?|Tey Bannerman

更多厂商,都在预装龙虾

除了微软正在尝试向 Windows 中集成 OpenClaw 类似物之外,越来越多的笔记本厂商也开始「越俎代庖」,先一步为 PC 集成了各种各样的龙虾。

这其中就包括爱范儿先前报道过的联想天禧 Claw,以及荣耀前两天刚刚发布的 YOYO Claw,都属于内置在 OEM 厂商自己 app 中的「预制菜」式龙虾,主打一个开箱即用。

毕竟 OpenClaw 及其变体能在 Mac 生态率先引爆,很大程度上得益于 macOS 的类 Unix 环境。

对于开发者和极客用户来说,各种自动化脚本和权限管理几乎是开箱即用的。

图|YouTube @Andres Vidoza

相比之下,在 Windows 手动部署原版的 OpenClaw 简直是一场噩梦。

不仅需要先配置好 WSL2 子系统,搭建出一个 Linux 虚拟环境,最后还可能因为 Windows 11 混乱的权限设置和各种 bug,导致 AI 代理无法顺利模拟鼠标点击之类的难绷问题。

图|Windows Central

作为 OEM 厂商,联想和荣耀的切入点精准地踩在了这道「部署门槛」上——

对于绝大多数「有点需求又不那么精通技术」的消费者而言,只需要买一台带着天禧 Claw 或者 YOYO Claw 的电脑回家,开机登陆完就能直接开始帮自己整理数据。

厂商通过在 Windows 里预装、预配置、预处理一个 OpenClaw 工具,其实就是在向大众用户售卖这种「AI 便利性」。

这样做有用吗?还真有用。

至少对于普通用户来说,多一个「开箱即用」的功能总归不是一件坏事,哪怕上面写着的是 Copilot。

微软或者 OEM 厂商预装各类 Claw,相当于帮用户完成了最脏最累的底层适配工作——

只有这种时候,AI PC 才真正从贴着炫彩标签的笔记本,变成了内置了数字助理的生产力终端。

另外对于很多用户很重要的,则是「端云混合」的部署逻辑,天生为风险隔离和算力成本提供了解决方案。

不止天禧 Claw 和 YOYO Claw 已经标明端云混合,微软实际选择的,其实也是和之前「Azure 云电脑 – Agent 365 – 365 Copilot」相同的路径。

图|Microsoft Learn

这样一来,「某某 Claw」与各种搭配的代理就可以优先在本地处理敏感的个人数据或屏幕截图,只在涉及复杂逻辑推理时才请求云端模型。

这种隐私保护与性能的平衡,是目前几乎纯云端的 Copilot 365 难以实现的。

最重要、同时也是对用户钱包最友好的一点,是这种由厂商主导的「帮你部署」和「端云混合」的龙虾方案可以非常有效地节省 Token 开销。

图|Notebookcheck

比如通过模型分级路由、本地 RAG(压缩对话历史)、智能提示词缓存等等手段,端云混合的 Claw 方案据估算可以将 token 消耗量压缩到此前的 50% 甚至更低,有效避免「Claw 跑一晚,卡里少三千」的情况。

针对那些 API 开销极度敏感的企业和个人用户来说,这种「省钱办大事」的产品才是最具有吸引力的那个。

AIPC 的终点,是预制菜

站在 2026 这个时间点上,我们可以大胆得出一个结论:

未来的 AI PC,如果做不到出厂预装代理 AI(Agentic AI),那它根本就不配被称为 AI 生产工具。

我们必须意识到——在声势浩大的 LLM 游戏之后,FOMO 的无限叠加已经让我们对 AI 的耐心彻底耗尽。

图|TNW

绝大多数普通用户已经玩腻了「我问你答」的顾问游戏,然而公司却变本加厉地要求人们继续用 AI 提高自己的效率。

做不到,就掉下斩杀线。

正因如此,人们如今需要电脑做到的,不是更快的 CPU 主频,也不是更薄的机身,而是它能否像一个真正的「合伙人」或者「副驾驶」那样,直接帮我执行和解决任务。

图|Itequia

因此,无论是微软这样的 Windows 源头厂商,还是联想、荣耀这种笔记本 OEM 厂商,出厂预装「龙虾」或类似物,将成为未来衡量 PC 厂商核心竞争力的硬指标。

未来的操作系统不会只是一个运行软件的平台,而会变成一种「代理调度中心」。

与此同时,硬件厂商的角色也将发生巨大的转变:它们不再只作为零件的「方案整合商」,而是会兼任「AI 工作流」的定义者——

比如我们可以想象,未来联想预装的 Claw 可能更偏向商务协作,荣耀的 Claw 可能更擅长跨设备调控,而微软的 MS-Claw 则能够一条龙服务代理整个 Office 全家桶……

这种代理式 AI 角色的差异化,将成为 AI PC 品牌忠诚度的新来源。

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

拳打丰田,脚踢本田,吉利发布 i-HEV 技术,百公里油耗仅 2.22L

作者 芥末
2026年4月15日 10:30

中国乘用车市场的新能源渗透率在过去两年持续突破,但有一个品类始终是例外——HEV(混合动力汽车)。

与带有大容量电池、支持长距离纯电行驶且可以插电的 PHEV 和增程式不同,HEV 的电池小巧,仅作为能量缓冲,发动机才是绝对的主力。

然而,正是这种不插电、不依赖充电桩的特性,让它在保持传统用车习惯的同时,实现了远低于纯燃油车的油耗。

长期以来,这片市场几乎被丰田双擎和本田 e-HEV 牢牢把控。国产品牌在 PHEV 和增程赛道上高歌猛进,却鲜少在 HEV 领域投入重兵。

但在新能源市场竞争不断加剧的当下,主机厂们没有理由对这样一个规模不小的市场视而不见。

于是在昨天,吉利发布了他们的 i-HEV 智擎混动系统。

搭载该系统的帝豪 i-HEV 智擎混动在海南环岛高速高速路况下,百公里综合油耗为 2.22 升,低于丰田普锐斯在 2024 年美国创下的 2.52 L 纪录;其配套发动机的热效率达到了 48.41%,刷新了量产发动机全球纪录;驱动电机峰值功率 230 kW,也明显高于传统日系双擎体系。

吉利这套 HEV 系统的构型与丰田有着显著差异。

丰田 THS 采用的是 P1 + P2 的行星齿轮功率分流结构。这套方案在中低速城市工况下效率很高,但在高速巡航阶段。部分动力必须经过发电机和驱动电机的两次能量转换,这在物理层面上不可避免地会带来一定的能量损耗。

吉利 i-HEV 则采用了混动专用变速器配合双电机布局的架构。该架构提供三种不同的内燃机选项,并统一匹配混动专用电驱系统。

其工作逻辑是:在低速拥堵时主要依靠纯电驱动,中速时在串联和并联模式间切换,而在高速巡航阶段,则允许发动机直接驱动车轮。这种高速直驱模式的设计,主要目的在于规避二次能量转换带来的效率衰减,从而提升整体系统的能耗表现。

在混动系统的节能核心指标,发动机热效率上,吉利此次也在此刷新了行业记录,来到了 48.41%。

按照吉利在系统研发中的能量损耗模型测算,一升燃油所蕴含的约 32.3 兆焦能量,在经过发动机转化后,可利用的机械能约为 12.9 兆焦。

而在实际复杂的城市路况中,由于低速和非高效率区间的工作,最终驱动车辆的能量可能降至 7 兆焦左右。这意味着绝大部分能量以热能形式流失,其中主要来源于发动机自身的热力学损耗。

因此,提升发动机热效率是降低油耗的前置条件。

为了达到 48.41% 的热效率,研发团队在三个层面上进行了技术优化。

在燃烧系统方面,依托 AI 模型优化了气缸内部结构,配合 1.39 的行程缸径比、15.5 高压缩比,以及超高压燃油喷射和高能点火技术,结合米勒循环让油气混合更均匀、燃烧更充分。

在机械结构上,通过引入精磨抛光、类金刚石涂层和低黏度机油等工艺,有效降低了发动机内部接触面的摩擦损耗。此外,系统将制动能量回收作为降低能耗的核心闭环,通过优化回收效率和整车协同控制,进一步拉低了实际的油耗表现。

为了解决混动车型加速「肉」的问题,吉利 i-HEV 提升了电驱的参与度,整个系统有较高比例的运行时间处于电驱状态。

在纯电模式下,车辆能在市区道路达到最高 66 公里/小时的行驶速度。在起步加速阶段,由于电动机无需像内燃机那样等待转速攀升即可输出峰值扭矩,其 0 至 30 公里/小时的加速时间为 1.84 秒。

为了匹配这种高频的电驱需求,同时控制系统重量,该系统采用了被称为「黄金一度电」的小电池策略。

其搭载的混动专用电池容量在 1.83 度左右,重量控制在 30 公斤,但放电功率可达 110 千瓦,并支持 60C 的高倍率能量回收。

在充放电效率提升后,电池能够快速吸收制动回收的能量并在加速时迅速释放,同时也兼顾了驻车用电和对外放电的实用功能。

在 NVH 表现层面,吉利通过停机位置预测技术,让电驱系统可在发动机启动瞬间将其拉升至适宜的点火转速,以减少启动振动,并辅以主动噪声控制技术,降低发动机运转时的噪音感知。

在雷神电混中已经亮相的 AI 能量管理功能自然也没有在 i-HEV 系统中缺席。

传统混动多依赖固定的预设标定来应对平均工况,而 AI 模型则能够结合环境温度、湿度、海拔及道路坡度等实时数据,动态调整油电切换和能量调度策略。

而且,基于专属的电子电气架构,系统的能量管理策略后续还可以在线 OTA 升级。

整套系统的耐久性和安全性同样经历了严格的验证。

吉利介绍,在台架测试中,该系统完成了等效约 480 万公里的耐久试验,在更极端的环境测试中,车辆需连续通过包括摄氏零下 40 度低温、摄氏 50 度以上高温暴晒、高湿度交变以及跨度超过 3000 米的海拔交变等多种复杂工况,以验证其在极端条件下的工作稳定性。

此外,系统还具备自适应油品识别功能,通过调整点火提前角来降低爆震风险,以适应不同地区的燃油品质差异。

整套油、电、水及排气系统在物理结构上实现了独立通道隔离。动力系统采用了发动机与双电机完全解耦的设计,当某一动力源失效时,剩余部分仍可维持车辆行驶。

而在电池安全方面,i-HEV 采用了平板液冷方案和小容量功率型电芯,防尘防水等级达到 IP68,电量管理策略倾向于浅充浅放,并辅以云端算力对电池状态进行实时监测。

当然这些技术指标的最终落地,依赖于制造环节的精度控制。

吉利在在发动机核心部件的生产中,采用了微米级的智能选配系统,曲轴及轴瓦等部件的分组精度有显著提升,并配备了全线质量防错系统。

曲轴部件需经过高温中频感应淬火处理,以兼顾核心韧性与表面硬度,提升耐磨寿命。装配过程则引入了等离子清洗工序,以确保高密封要求部件的表面洁净度。

目前,这套 i-HEV 智擎混动系统已投入量产,并陆续搭载于星瑞、星越 L、博越 L 及帝豪等多款市场保有量较大的车型上。

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

用wagmi v2构建DeFi前端:从连接钱包到读取合约数据的完整实战与避坑指南

作者 竹林818
2026年4月15日 10:01

背景

上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。老代码用的是 ethers.js + 自己封装的钱包连接逻辑,维护起来非常头疼,尤其是多链支持和交易状态跟踪的部分,bug频出。团队决定迁移到更现代的 wagmi v2 搭配 viem,希望利用其声明式的Hooks来简化状态管理。我的任务很明确:用 React + wagmi v2 搭建一个新的前端基础框架,核心要搞定钱包连接、实时读取用户在不同链上的资产余额、以及一个关键合约(质押池)的数据。

一开始我以为照着官方文档拼凑一下就行,结果在实际开发中,从钱包连接状态同步到合约数据读取,我踩了一路的坑。这篇文章就是我解决这些问题的完整记录。

问题分析

我最开始的思路很简单:按照wagmi官方示例,配置好WagmiProvider,用useConnect连接钱包,用useAccount获取账户,然后用useReadContract读取数据。但一上手就发现了问题。

首先,当用户在MetaMask里切换网络时,前端应用的状态并没有立即同步更新。用户从以太坊主网切换到Arbitrum,但UI上显示的链ID还是1,这会导致后续所有针对错误链的合约调用失败。其次,在读取用户在不同链上的ERC20代币余额时,我需要根据当前激活的链动态切换合约地址,但最初的实现里,链切换后合约查询并没有自动重新执行。最后,在用户进行质押操作后,我需要准确监听交易状态(提交、打包、成功/失败),并实时更新UI上的余额数据,避免用户看到陈旧信息。

排查过程让我意识到,wagmi虽然抽象得很好,但如果不理解其内部的状态更新机制和Hooks的依赖关系,很容易写出看起来能跑但实际上有隐性bug的代码。问题的核心在于如何让React组件状态与钱包的外部状态(链、账户)保持强同步,以及如何正确构造依赖数组以触发查询的重新执行。

核心实现

1. 配置Provider与多链支持

第一步是正确配置WagmiProvider。这里我选择了项目需要支持的四个链:Ethereum, Arbitrum, Optimism和Polygon。我使用viem提供的预定义链配置,并创建了一个自定义的wagmi配置对象。这里有个关键点config对象必须被稳定地引用,最好在React组件外部创建,或者用useMemo包裹,防止它在每次渲染时重新创建,导致不必要的上下文重置。

// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi';
import { mainnet, arbitrum, optimism, polygon } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { injected } from 'wagmi/connectors';

// 创建稳定的查询客户端和配置
const queryClient = new QueryClient();

const config = createConfig({
  chains: [mainnet, arbitrum, optimism, polygon],
  connectors: [injected()], // 主要支持注入式钱包(如MetaMask)
  transports: {
    [mainnet.id]: http(),
    [arbitrum.id]: http(),
    [optimism.id]: http(),
    [polygon.id]: http(),
  },
});

export function WagmiProvider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProviderCore config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProviderCore>
  );
}

2. 实现可靠的钱包连接与链状态同步

接下来是连接组件。我不仅需要连接按钮,还需要一个实时显示当前网络和账户的组件。useAccount Hook提供了address, chainId, connector等信息,并且会响应钱包扩展程序的状态变化。但为了处理网络切换,我必须结合使用useSwitchChain

踩过的一个坑:最初我试图用useAccountchainId直接作为读取合约数据的链依据,但当用户拒绝网络切换请求时,chainId可能处于“期望切换”但“实际未变”的中间状态。更好的做法是,对于关键操作(如发送交易),始终使用useAccount返回的chain对象,并结合错误处理。

// src/components/ConnectButton.tsx
import { useConnect, useAccount, useDisconnect, useSwitchChain } from 'wagmi';

export function ConnectButton() {
  const { connect, connectors, isPending } = useConnect();
  const { address, chain, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const { switchChain } = useSwitchChain();

  const handleConnect = () => {
    // 默认连接第一个注入式连接器(如MetaMask)
    connect({ connector: connectors[0] });
  };

  const handleSwitchChain = (targetChainId: number) => {
    switchChain({ chainId: targetChainId });
  };

  if (!isConnected) {
    return (
      <button onClick={handleConnect} disabled={isPending}>
        {isPending ? 'Connecting...' : 'Connect Wallet'}
      </button>
    );
  }

  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <p>Network: {chain?.name} (ID: {chain?.id})</p>
      <div>
        <button onClick={() => handleSwitchChain(arbitrum.id)}>Switch to Arbitrum</button>
        <button onClick={() => disconnect()}>Disconnect</button>
      </div>
    </div>
  );
}

3. 动态读取多链合约数据

这是DeFi前端的核心。我需要读取用户在某条链上的特定代币余额。合约地址因链而异。useReadContract Hook接收一个配置对象,当其中的addresschainIdaccount发生变化时,它会自动重新获取数据。

注意这个细节useReadContractquery选项中的enabled属性非常有用。我可以设置enabled: !!address && !!chainId,这样只有当用户钱包已连接且链ID明确时,才会发起查询,避免了不必要的错误请求和日志噪音。

// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi';
import { erc20Abi } from 'viem';

// 不同链上的USDC合约地址映射
const USDC_ADDRESS: Record<number, `0x${string}`> = {
  1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum Mainnet
  42161: '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', // Arbitrum
  10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism
  137: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', // Polygon
};

export function useTokenBalance() {
  const { address, chainId } = useAccount();

  const { data: balance, isLoading, error, refetch } = useReadContract({
    abi: erc20Abi,
    address: chainId ? USDC_ADDRESS[chainId] : undefined,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    query: {
      enabled: !!address && !!chainId, // 关键:确保条件满足才查询
      refetchInterval: 10000, // 每10秒自动刷新一次
    },
  });

  return {
    balance,
    isLoading,
    error,
    refetch, // 暴露手动刷新函数,用于交易后更新
  };
}

4. 执行合约写入与交易状态监听

用户操作,比如质押代币,需要发送交易。我使用useWriteContract来发起交易,但更重要的是监听交易状态。wagmi v2 通过useWaitForTransactionReceipt Hook提供了优雅的解决方案。

这里有个大坑useWriteContract返回的writeContractAsync函数在调用时,必须明确指定chainId。即使你的config里配置了多链,且用户当前已切换到目标链,如果你不传chainId,它有时会默认使用配置中的第一个链(比如主网),导致交易发错链。务必显式传递accountchainId

// src/components/StakeForm.tsx
import { useState } from 'react';
import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi';
import { parseUnits } from 'viem';

const stakingPoolAbi = [ /* 你的质押合约ABI */ ] as const;
const STAKING_POOL_ADDRESS = '0x...'; // 你的质押合约地址

export function StakeForm() {
  const [amount, setAmount] = useState('');
  const { address, chainId } = useAccount();

  const {
    writeContractAsync,
    isPending: isWritePending,
    data: hash,
    error: writeError,
    reset: resetWrite,
  } = useWriteContract();

  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: receiptError,
  } = useWaitForTransactionReceipt({
    hash,
    query: {
      enabled: !!hash, // 只有有交易哈希时才启动监听
    },
  });

  const handleStake = async () => {
    if (!address || !chainId) return;
    try {
      resetWrite(); // 重置上一次的写入状态
      await writeContractAsync({
        abi: stakingPoolAbi,
        address: STAKING_POOL_ADDRESS,
        functionName: 'stake',
        args: [parseUnits(amount, 18)], // 假设代币精度18
        account: address,
        chainId: chainId, // !!!务必显式指定链ID
      });
      // 交易哈希已提交,状态由 useWaitForTransactionReceipt 监听
    } catch (err) {
      console.error('Stake failed:', err);
    }
  };

  return (
    <div>
      <input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="Amount to stake" />
      <button onClick={handleStake} disabled={isWritePending || !amount}>
        {isWritePending ? 'Confirming in wallet...' : 'Stake'}
      </button>
      {isConfirming && <p>Transaction is being confirmed...</p>}
      {isConfirmed && <p>Stake successful! <button onClick={() => refetchBalance()}>Refresh Balance</button></p>}
      {writeError && <p style={{ color: 'red' }}>Error: {writeError.message}</p>}
    </div>
  );
}

完整代码示例

下面是一个整合了以上关键部分的简化版主应用组件,你可以直接复制到一个新的React项目中运行(需先安装依赖)。

// App.tsx
import { WagmiProvider } from './providers/WagmiProvider';
import { ConnectButton } from './components/ConnectButton';
import { useTokenBalance } from './hooks/useTokenBalance';
import { StakeForm } from './components/StakeForm';

function AppContent() {
  const { balance, isLoading, error, refetch } = useTokenBalance();

  return (
    <div style={{ padding: '20px' }}>
      <h1>DeFi Staking Dashboard</h1>
      <ConnectButton />
      <hr />
      <h2>Your USDC Balance</h2>
      {isLoading && <p>Loading balance...</p>}
      {error && <p>Error loading balance: {error.message}</p>}
      {balance !== undefined && (
        <p>Balance: {balance.toString()} units (raw)</p>
        // 实际应用中,这里需要根据代币精度格式化显示
      )}
      <button onClick={() => refetch()}>Refresh Balance</button>
      <hr />
      <h2>Stake Tokens</h2>
      <StakeForm onSuccess={refetch} /> {/* 传入刷新余额的回调 */}
    </div>
  );
}

export default function App() {
  return (
    <WagmiProvider>
      <AppContent />
    </WagmiProvider>
  );
}

踩坑记录

  1. useReadContract 不自动更新:当用户切换钱包账户后,余额查询没有更新。原因:我忘记将address作为args的一部分。args: [address]必须依赖address变量,当address变化时,查询才会重新执行。解决:确保args正确绑定到响应式变量(如来自useAccountaddress)。

  2. 交易发错链:用户在Arbitrum上点击质押,交易却发到了以太坊主网,导致失败和Gas费损失。原因:调用writeContractAsync时没有显式传递chainId参数。解决:始终从useAccount中获取当前的chainId,并在写入合约时明确指定chainId: currentChainId

  3. “RPC Error: Rate Limited”:在开发时频繁刷新页面,快速连接/断开钱包,导致Infura或Alchemy的RPC端点报速率限制错误。原因wagmihttp()传输层默认没有配置请求节流或重试。解决:为生产环境配置更健壮的RPC提供商,或者使用viemfallback传输层,设置多个RPC端点作为备用。例如:transport: fallback([http('https://mainnet.infura.io/v3/your-key'), http()])

  4. **TypeScript类型错误:0xstring:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是0ˋx{string}`”`**:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是`\`0x{string}`类型。**原因**:viemwagmi为了类型安全,要求地址是严格的以0x开头的十六进制字符串类型。**解决**:使用类型断言as `0x${string}``,或者确保你的地址常量符合该模板字面量类型。

小结

通过这一轮实战,我深刻体会到在Web3前端开发中,状态同步的可靠性远比功能实现更重要。wagmi v2配合viem提供了强大的基础,但开发者必须清晰地理解:账户、链ID、合约地址如何作为Hooks的依赖项驱动数据流。下一步,我计划深入研究wagmi的存储持久化和自定义缓存策略,以进一步提升复杂DeFi应用的用户体验。

别再用 JSON.parse 深拷贝了,聊聊 StructuredClone

作者 ErpanOmer
2026年4月15日 09:58

临近下班,我们业务线出了一个极度无语的线上 Bug。

产品侧反馈,在一个非常核心的财务表单里,用户明明选择了 2026-04-14 作为结算日期,但点击提交后,整个页面直接白屏崩溃。

我打开错误监控看了一眼日志,立刻就把组里那个刚入职不久的小伙子叫了过来。 原因极其经典:他在把表单的原始状态同步给历史快照时,为了图省事,顺手写了一段几乎所有前端都写过的代码:

// 模拟用户表单数据
const formData = {
  amount: 1000,
  date: new Date("2026-04-14"), // 用户选的结算日期(Date对象)
};

// 深拷贝
const snapshot = JSON.parse(JSON.stringify(formData));

console.log("原始:", formData.date, typeof formData.date); 
// Date object

console.log("快照:", snapshot.date, typeof snapshot.date); 
// "2026-04-14T00:00:00.000Z" string

// 后续业务代码
function calcSettlementTime(data) {
  // 这里默认 date 是 Date 对象
  return data.date.getTime();
}

// 页面直接崩溃😢
try {
  const time = calcSettlementTime(snapshot);
  console.log("时间戳:", time);
} catch (err) {
  console.error("页面崩溃:", err);
}

他满脸委屈:老大,大家平时深拷贝不都是这么写的吗?🤷‍♂️

我让他自己把这段代码在控制台跑一遍。 当他看到表单里原本好好的 Date 对象,经过这一进一出,硬生生变成了一串 ISO 格式的字符串,导致后面调用 snapshot.date.getTime() 直接抛出 TypeError 时,他自己也沉默了。

作为前端老油条,这种因为 JSON.parse(JSON.stringify()) 引发的血案,我见过太多了。 它不仅会把 Date 变成字符串,还会把 MapSet 变成空对象 {},会把 undefinedSymbol 以及函数直接活生生抹除,更别提遇到循环引用时,它会当场抛出异常让你的主线程直接崩溃。

以前,我们为了解决这个破事,不得不在每个项目里老老实实 npm install lodash,然后引入那个笨重的 cloneDeep

但现在是 2026 年了。浏览器早就原生内置了完美的终极解药——structuredClone

今天咱们不聊虚的架构,就花三分钟,把这个原生 API 的底层逻辑讲清楚。


它是怎么解决历史遗留问题的?

structuredClone 不是什么语法糖,它是浏览器底层暴露出来的 结构化克隆算法(Structured Clone Algorithm)。这就意味着,它在 C++ 引擎层面的处理逻辑,远比 JS 业务层面的递归拷贝要深得多。

看一下原生 API 的用法:

const original = {
  date: new Date(),
  set: new Set([1, 2, 3]),
  map: new Map([['key', 'value']]),
  regex: /hello/i,
  buffer: new Uint8Array([1, 2, 3]).buffer,
};

// 制造一个循环引用
original.self = original;

// 一行代码,原生搞定
const cloned = structuredClone(original);

console.log(cloned.date instanceof Date); // true
console.log(cloned.set instanceof Set);   // true
console.log(cloned.self === cloned);      // true 完美处理循环引用!

发现没有?它不仅完美保留了所有的内置对象类型,连 JSON.parse 绝对搞不定的循环引用,它都处理得游刃有余。由于是在引擎底层运行,不需要像 Lodash 那样在 JS 运行时里疯狂压栈递归,它的执行效率在大部分复杂场景下都具有压倒性优势👍👍👍。


零拷贝转移 (Transferable Objects)

如果你以为 structuredClone 只是为了少引入一个 Lodash,那你就太小看浏览器的底层野心了。

它藏着一个 90% 的前端都不知道的极其硬核的功能:内存转移(Transfer)

在前端处理音视频、WebGL、或者读取几十 MB 的大文件时,我们经常会生成巨大的 ArrayBuffer。如果你用传统的深拷贝,内存瞬间翻倍,几十兆的内存分配极容易引起页面的掉帧卡顿。

structuredClone 提供了一个极其变态的第二个参数配置:{ transfer }

// 假设这是一个极大的 50MB 数据内存块
const u8Array = new Uint8Array(1024 * 1024 * 50);
const hugeBuffer = u8Array.buffer;

// 传统的深拷贝:内存翻倍,耗时极长
// const badCopy = lodash.cloneDeep(hugeBuffer); 

// 直接内存转移
const fastClone = structuredClone(hugeBuffer, { transfer: [hugeBuffer] });

console.log(fastClone.byteLength); // 52428800 (50MB 完美转移)
console.log(hugeBuffer.byteLength); // 0 (原对象的内存地址被转移)

这段代码的核心在于:它压根没有复制数据。 它直接在内存层面,把这块 50MB 数据的所有权,从 hugeBuffer 强行转移给了 fastClone。原对象被彻底掏空(变成了 detached 状态)。

这种零拷贝机制,在结合 Web Worker 处理复杂后台计算时,是打破性能瓶颈的绝对神器。这是任何第三方 JS 库都做不到的底层API。


一些坑要讲清楚🤔

既然这么牛,是不是以后项目里所有的拷贝闭着眼睛用它就行了? 作为一个踩过无数坑的老兵,我必须点出它的几个致命死角。如果你在真实的业务架构里滥用,下场比用 JSON.parse 还要惨。

对于函数和 DOM 节点的处理

JSON.parse 遇到函数,它会默默地忽略掉,至少不报错。 但 structuredClone 很直接。只要你的对象树里藏着一个方法,或者藏着一个 DOM 节点的引用,它会直接给你抛出 DataCloneError

const objWithFunc = {
  data: 123,
  onClick: () => console.log('click')
};

// 只要带有函数,直接抛同步错误
// DOMException: () => console.log('click') could not be cloned.
const copy = structuredClone(objWithFunc); 

这就意味着,如果你要拷贝的是一个 Vue/React 的响应式组件实例,或者是带有业务方法的数据模型,绝对不能用它👋。

原型链的断裂

不管你原本是一个通过 class 实例化的多么高级的业务对象,经过 structuredClone 的洗礼后,它都会变成一个普通的纯对象(Plain Object)。

class User {
  constructor(name) { this.name = name; }
  sayHi() { console.log('hi'); }
}

const user = new User('前端');
const cloneUser = structuredClone(user);

console.log(cloneUser instanceof User); // false 
cloneUser.sayHi(); // TypeError: cloneUser.sayHi is not a function

原型链上的所有方法全部丢失。它只关心纯粹的数据,不关心你的面向对象架构。‘


需要时收藏起来⭐⭐⭐

这几年,前端的工具链卷得飞起,大家的 package.json 越来越臃肿。遇到数组去重找库,遇到时间格式化找库,遇到深拷贝也要找库。

如果你只是单纯地处理一些后端传过来的嵌套数据,或者表单的复杂配置结构,完全可以直接把 structuredClone 敲在你的代码里。不用担心兼容性,目前主流浏览器(包括 Node.js)的支持率早就达到了工业级使用的标准了。

image.png

下次 Code Review 时,别再让我看到满屏的 JSON.parse 了 (玩笑😁😁😁)。

分享完毕,谢谢大家🙌

Suggestion.gif

最前线|追觅生态企业娲宝科技发布宠物智能项圈,布局“项圈+宠粮”数据闭环

2026年4月15日 09:43

作者 | 乔钰杰

编辑 | 袁斯来

4月13日,娲宝科技正式推出新一代宠物智能项圈,并同步披露其技术架构及后续产品布局。

图源企业

近年来,宠物相关消费持续增长,行业正由基础饲养逐步向精细化健康管理延伸。在宠物智能硬件领域,现有产品多集中于定位、围栏及运动记录等功能,部分产品开始引入心率、体温等基础生理指标监测,但整体仍以单点数据为主,数据维度与应用场景相对有限,尚未形成完整的健康管理闭环。

在此背景下,娲宝科技将产品定位为长期健康数据采集终端,尝试通过硬件、数据、宠粮与后续服务结合的方式,拓展应用场景。

此次发布的智能项圈,采用声、光、力多模态传感方案,结合骨传导、光谱与柔性传感技术,对宠物生理与行为数据进行采集。

从系统架构来看,产品由端侧设备、云端算力平台、多模态模型及专家系统组成:项圈负责数据采集,云端完成存储与计算,模型进行分析处理,最终输出健康相关建议。

在监测指标方面,产品覆盖心率、体温、呼吸频率、运动姿态等多项数据。与行业中以单点、间歇性数据为主的方案不同,其重点在于连续数据采集与趋势分析,通过模型将原始数据转化为活动变化、夜间行为等结构化指标,并结合数据库进行异常识别。

图源企业

与此同时,项圈还加入了宠物语言翻译功能,试图打通人宠交互壁垒,让项圈兼具健康与情感价值。

除硬件外,娲宝科技还提出将数据与宠物食品结合的思路。根据规划,后续将基于监测数据开发分阶段、分功能的宠粮产品,并探索与宠物医院、保险机构的数据合作,以形成“监测—分析—干预”的应用闭环。

 

 

 

总篇:iframe沙盒存储隔离:从紧急补丁到企业级防御体系的完整指南

作者 禅思院
2026年4月15日 09:42

在微前端与第三方组件集成的浪潮下,iframe 沙盒环境中的 SessionStorage 安全问题,正从一个隐秘的"技术细节"演变为可能引发数据泄露、权限逃逸的"阿喀琉斯之踵"。本系列文章将为你完整呈现:我们如何从一次真实攻防演练中发现致命漏洞,到构建一套经过生产验证的三层纵深防御体系的全过程。 在这里插入图片描述


缘起:一次攻防演练暴露的"沙盒幻象"

在一次内部红蓝对抗中,一个看似平常的设定引发了我们的警觉:一个嵌入在同源 iframe 中、完全受控的第三方图表组件,竟能悄无声息地读取并篡改主应用存储在 sessionStorage 中的用户令牌(authToken)、管理员权限(userRole)及核心业务数据。

核心漏洞

浏览器同源策略保护的是"源"而非"上下文"。当 iframe 的 sandbox 属性包含 allow-same-origin 时,它与主应用被视为同一源,从而共享同一份 sessionStorage 物理存储

这不是浏览器的 Bug,而是其安全模型的一个特性——却也成了攻击者眼中的"特性":

// 恶意代码可轻易在 iframe 内执行
const stolenToken = sessionStorage.getItem('authToken');  // 窃取令牌
sessionStorage.setItem('userRole', 'super_admin');        // 权限提升

我们意识到:这不仅是代码冲突,更是严重的安全漏洞。任何一个被嵌入的第三方组件(即使来源可信),一旦被 XSS 攻击或自身存在恶意代码,都可能成为突破"沙盒"的跳板。


破局:构建纵深防御的思维演进

面对这一问题,简单的"禁用某个属性"或"期望对方整改"并不可靠。我们需要的是一套可自主掌控、可持续演进的技术方案。解决思路经历了三次关键进化:

层级 策略 核心手段 定位
L1 快速止血 通信层修复 移除 allow-same-origin,通过严格的 postMessage 替代直接存储访问 紧急响应,治标不治本
L2 核心隔离 代理层隔离 运行时拦截:动态代理 sessionStorage API,自动添加命名空间前缀(如 ns_app1_ 性价比最高的方案
L3 体系防御 监控层防护 存储访问代理层 + 运行时行为监控 + 安全策略执行层,支持异常检测、自动阻断、灰度发布 企业级基础设施

系列导航:你将在这三篇文章中获得什么

本系列分为上、中、下三篇,由浅入深,带你走完从认知漏洞到建立堡垒的完整路径。

📘 上篇(总篇):《iframe沙盒存储隔离:从紧急补丁到企业级防御体系的完整指南》

(首篇发布)

  • 核心价值:建立完整认知,提供可立即执行的紧急修复方案
  • 你会学到
    • 同源策略与存储共享机制的底层原理
    • allow-same-origin + allow-scripts 组合的致命风险
    • L1 快速止血方案:postMessage 通信改造的最佳实践
    • 如何评估现有系统的暴露面与风险等级
  • 适合读者:所有使用 iframe 的前端开发者、技术经理、安全工程师

🔰 中篇:《手把手拦截——iframe 沙盒 SessionStorage 隔离的轻量级实践》

(第二篇发布)

  • 核心价值:给你一套"开箱即用"的代码,立即解决数据污染问题
  • 你会学到
    • Monkey Patch(猴子补丁)技术:优雅劫持 iframe 内的存储 API
    • 完整的 Vue/React 示例代码(前缀隔离、安全的 clear 方法改造)
    • 嵌套 iframe、Storage 事件监听等边界情况的处理
  • 适合读者:一线前端工程师、团队技术骨干,寻求快速有效解决方案的实践者

🛡️ 下篇:《从漏洞到堡垒——构建企业级 iframe 存储安全纵深防御体系》

(第三篇发布)

  • 核心价值:呈现可应对复杂攻击、支撑大型工程的安全架构蓝本
  • 你会学到
    • 基于 Proxy 与 MutationObserver 的健壮代理实现(防绕过)
    • 生产级部署:灰度发布、监控指标、性能测试与回滚方案
    • 与 W3C Storage Access API 的对比与融合路径
    • 开源安全框架的设计思路
  • 适合读者:前端架构师、技术负责人、安全工程师,关注高可用、高安全、可演进架构的决策者

为什么你需要关注这个系列?

  1. 问题普遍性:只要你使用了同源 iframe 嵌入(微前端、第三方 SDK、多团队协作),就可能面临此风险
  2. 方案完整性:从"救火"的 50 行代码,到"防火"的系统工程,提供不同阶段的解决方案
  3. 实战参考性:所有方案均源于真实攻防演练与生产环境迭代,包含踩坑记录与决策权衡
  4. 视野前瞻性:不止于解决当下问题,更探讨与 Web 标准接轨的未来演进路径

安全不是可选项,而是现代 Web 应用的默认值。 对 iframe 沙盒存储漏洞的忽视,可能让精心构建的应用防线从内部被攻破。

本系列文章正是为你厘清风险、提供武器、建立防线的实战指南。敬请期待后续的深度解析。


[下篇预告]:,我们将直接切入实战,剖析漏洞原理,并附上一段可直接复制使用的代码,让你能在半小时内为你的 iframe 应用穿上第一件"隔离衣"。


你的 Vue 3 生命周期,VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月15日 09:22

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的生命周期钩子经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中生命周期钩子例如 onMounted、onBeforeMount、onUpdated、onBeforeUpdate、onBeforeUnmount、onUnmounted 的 API 用法与核心行为。

编译对照

Vue onMounted() → React useMounted()

onMounted 是 Vue 3 中用于组件首次挂载后执行逻辑的生命周期钩子,适合放初始化请求、订阅启动、DOM 相关准备等操作。VuReact 会将它编译为 useMounted,让 React 端也能在组件挂载后执行一次性副作用。

  • Vue 代码:
<script setup>
  import { onMounted } from 'vue';

  onMounted(() => {
    console.log('组件已挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useMounted } from '@vureact/runtime-core';

useMounted(() => {
  console.log('组件已挂载');
});

从示例可以看到:Vue 的 onMounted() 被翻译为 useMounted。VuReact 提供的 useMountedonMounted 的适配 API完全模拟 Vue onMounted 的首次挂载后执行时机

Vue onBeforeMount() → React useBeforeMount()

onBeforeMount 是 Vue 3 中用于组件挂载前执行逻辑的钩子,适合放需要在布局阶段之前准备的内容。VuReact 会将它编译为 useBeforeMount,基于 React 的布局效果在挂载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeMount } from 'vue';

  onBeforeMount(() => {
    console.log('组件即将挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeMount } from '@vureact/runtime-core';

useBeforeMount(() => {
  console.log('组件即将挂载');
});

VuReact 提供的 useBeforeMountonBeforeMount 的适配 API完全模拟 Vue onBeforeMount 的首次挂载前时机

Vue onBeforeUpdate() → React useBeforeUpdate()

onBeforeUpdate 是 Vue 3 中用于跳过首次挂载,仅在组件更新前执行的钩子,适合放变更前校验、记录旧值、提前准备等逻辑。VuReact 会将它编译为 useBeforeUpdate,并支持依赖数组以控制触发时机。

  • Vue 代码:
<script setup>
  import { reactive, onBeforeUpdate } from 'vue';

  const state = reactive({ count: 0 });

  onBeforeUpdate(() => {
    console.log('更新前,当前 count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useBeforeUpdate } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useBeforeUpdate(
  () => {
    console.log('更新前,当前 count:', state.count);
  },
  [state.count],
);

从示例可以看到:Vue 的 onBeforeUpdate() 被翻译为 useBeforeUpdate。VuReact 提供的 useBeforeUpdateonBeforeUpdate 的适配 API完全模拟 Vue onBeforeUpdate 的更新前触发时机。当 React 对应 API 需要依赖数组时,deps 数组可用于只在指定值变化时触发,VuReact 会在编译阶段自动分析依赖并映射到对应依赖数组,避免开发者手动管理依赖

Vue onUpdated() → React useUpdated()

onUpdated 是 Vue 3 中用于组件更新后执行逻辑的钩子,适合放读取最新渲染结果、执行后续同步等操作。VuReact 会将它编译为 useUpdated,并支持可选依赖数组来精确控制触发条件。

  • Vue 代码:
<script setup>
  import { reactive, onUpdated } from 'vue';

  const state = reactive({ count: 0 });

  onUpdated(() => {
    console.log('组件更新后,count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useUpdated } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useUpdated(
  () => {
    console.log('组件更新后,count:', state.count);
  },
  [state.count],
);

VuReact 提供的 useUpdatedonUpdated 的适配 API完全模拟 Vue onUpdated 的更新后执行时机。如果 React API 使用 deps 数组,VuReact 会自动分析依赖并生成对应的数组,无需开发者手动维护依赖

Vue onBeforeUnmount() → React useBeforeUnMount()

onBeforeUnmount 是 Vue 3 中用于组件卸载前执行的钩子,适合放动画停止、资源解绑、日志上报等清理前逻辑。VuReact 会将它编译为 useBeforeUnMount,在卸载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeUnmount } from 'vue';

  onBeforeUnmount(() => {
    console.log('组件即将卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeUnMount } from '@vureact/runtime-core';

useBeforeUnMount(() => {
  console.log('组件即将卸载');
});

VuReact 提供的 useBeforeUnMountonBeforeUnmount 的适配 API完全模拟 Vue onBeforeUnmount 的卸载前时机

Vue onUnmounted() → React useUnmounted()

onUnmounted 是 Vue 3 中用于组件卸载时执行逻辑的钩子,适合放最终资源释放、异步取消、上报日志等收尾逻辑。VuReact 会将它编译为 useUnmounted,在组件卸载时执行。

  • Vue 代码:
<script setup>
  import { onUnmounted } from 'vue';

  onUnmounted(() => {
    console.log('组件已卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useUnmounted } from '@vureact/runtime-core';

useUnmounted(() => {
  console.log('组件已卸载');
});

VuReact 提供的 useUnmountedonUnmounted 的适配 API完全模拟 Vue onUnmounted 的卸载时机

🔗 相关资源

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

QML 最佳实践写出高质量、可维护、高性能的代码(十二)

作者 HelloReader
2026年4月15日 00:59

适合人群: 已能独立写 QML 应用,想提升代码质量和性能的开发者

前言

会写 QML 和写好 QML 之间,有一段不小的距离。本文覆盖 Qt 官方推荐的 QML 最佳实践,涉及类型安全、属性绑定、JavaScript 使用边界、组件封装、可维护性和性能优化六大主题,每条都配有"反例 vs 正例"的对比代码。


一、使用强类型属性声明

问题:var 类型丢失所有静态检查

// 不推荐:var 类型
property var name        // 是字符串?整数?对象?
property var count       // 无法做类型检查
property var config      // 工具无法推断类型

var 属性:

  • 无法被 qmllint 静态分析
  • 无法被 Qt Quick Compiler 编译优化
  • 赋值类型错误时,报错指向声明处而非赋值处,难以定位

解决:始终使用具体类型

// 推荐:强类型声明
property string  userName: ""
property int     itemCount: 0
property real    progress: 0.0
property bool    isLoading: false
property color   accentColor: "#4A90E2"
property url     avatarSource: ""
property date    createdAt
property var     rawData       // 只有真正需要动态类型时才用 var

强类型的好处:

强类型属性
    ├── qmllint 可静态分析       → 编码阶段发现错误
    ├── Qt Quick Compiler 可编译  → 绑定表达式运行更快
    ├── 错误信息指向赋值处        → 调试更容易
    └── 代码即文档               → 阅读者一眼知道期望类型

二、避免非限定访问(Unqualified Access)

问题:直接访问父级属性,不带 id 前缀

// 不推荐:非限定访问
Item {
    property int fontSize: 16

    Item {
        Text {
            font.pixelSize: fontSize    // 非限定访问!
            // qmllint 警告:[unqualified]
            // Qt Quick Compiler 无法编译此绑定
        }
    }
}

非限定访问的问题:

  • 运行时动态查找,性能差
  • 工具链(qmllint、编译器)无法静态确认访问是否合法
  • 当嵌套层级复杂时,fontSize 到底来自哪里?——代码难以阅读

解决:始终通过 id 限定访问

// 推荐:限定访问
Item {
    id: root
    property int fontSize: 16

    Item {
        Text {
            font.pixelSize: root.fontSize    // 限定访问,清晰明确
        }
    }
}

在 Delegate 中用 required property 替代非限定访问:

// 不推荐:Delegate 直接访问 model 角色(非限定)
ListView {
    delegate: Text {
        text: name       // 非限定访问 model 角色
        color: isActive ? "green" : "gray"
    }
}

// 推荐:required property 显式声明
ListView {
    delegate: Text {
        required property string name
        required property bool   isActive

        text: name
        color: isActive ? "green" : "gray"
    }
}

三、理解并正确使用属性绑定

3.1 声明式绑定 vs 命令式赋值

// 不推荐:在 Component.onCompleted 中命令式设置初始值
Rectangle {
    id: box
    color: "blue"

    Component.onCompleted: {
        box.width = parent.width / 2    // 命令式赋值
        box.height = parent.height / 2  // 这会破坏任何后续绑定
    }
}

// 推荐:声明式绑定,始终保持响应式
Rectangle {
    id: box
    width: parent.width / 2     // 声明式绑定:parent 宽度变化时自动更新
    height: parent.height / 2
    color: "blue"
}

3.2 在 JS 代码块中赋值会打断绑定

Rectangle {
    id: box
    width: parent.width    // 绑定

    MouseArea {
        anchors.fill: parent
        onClicked: {
            box.width = 200    // 赋值后,上面的绑定被永久打断!
                               // 之后 parent.width 变化,box.width 不再跟随
        }
    }
}

如果必须在事件中重新建立绑定,使用 Qt.binding()

onClicked: {
    box.width = Qt.binding(function() { return parent.width })
}

3.3 避免绑定循环

// 错误:绑定循环,会产生运行时警告
Item {
    property int a: b + 1    // a 依赖 b
    property int b: a + 1    // b 依赖 a → 循环!
}

// 正确:其中一个属性改为普通赋值或由外部驱动
Item {
    property int a: 0
    property int b: a + 1    // 单向依赖,安全
}

3.4 保持绑定表达式简单

// 不推荐:绑定中包含复杂逻辑
Text {
    text: {
        var result = ""
        for (var i = 0; i < model.count; i++) {
            result += model.get(i).name + ", "
        }
        return result.slice(0, -2)
    }
}

// 推荐:复杂逻辑提取到函数,绑定只调用函数
Item {
    function buildNameList() {
        var names = []
        for (var i = 0; i < model.count; i++) {
            names.push(model.get(i).name)
        }
        return names.join(", ")
    }

    Text {
        text: buildNameList()    // 绑定表达式简洁
    }
}

四、JavaScript 的使用边界

QML 中的 JavaScript 是把双刃剑,用好了事半功倍,滥用了则带来维护噩梦。

4.1 适合用 JavaScript 的场景

// ✅ 简单的条件表达式(三元运算符)
color: isActive ? "#4A90E2" : "#CCCCCC"

// ✅ 简单计算
width: parent.width * 0.8

// ✅ 事件处理(onClicked 等)
onClicked: {
    model.remove(index)
    showToast("已删除")
}

// ✅ 辅助函数(封装复杂逻辑,供绑定调用)
function formatDate(dateStr) {
    var d = new Date(dateStr)
    return d.getFullYear() + "-" + (d.getMonth()+1) + "-" + d.getDate()
}

4.2 不适合用 JavaScript 的场景

// ❌ 在绑定中做大量数据处理(每次绑定求值都会执行)
ListView {
    model: {
        var filtered = []
        for (var i = 0; i < sourceModel.count; i++) {
            if (sourceModel.get(i).price > 100)
                filtered.push(sourceModel.get(i))
        }
        return filtered    // 每次 sourceModel 变化都重新过滤,性能差
    }
}

// ✅ 用 C++ 代理模型或专门的过滤函数,不放在绑定里
// ❌ 用 JS 模拟属性绑定(既不响应式,也不可读)
Component.onCompleted: {
    labelText.text = "Hello " + userName    // 只执行一次,userName 变化后不更新
}

// ✅ 直接用绑定
Text {
    id: labelText
    text: "Hello " + userName    // 声明式,自动响应
}

4.3 复杂逻辑放到 C++ 或独立 .js 文件

// utils.js — 独立的工具函数库
.pragma library    // 共享模式,只加载一次

function formatCurrency(amount, symbol) {
    return symbol + amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}

function timeAgo(dateStr) {
    var diff = (Date.now() - new Date(dateStr)) / 1000
    if (diff < 60)    return "刚刚"
    if (diff < 3600)  return Math.floor(diff / 60) + " 分钟前"
    if (diff < 86400) return Math.floor(diff / 3600) + " 小时前"
    return Math.floor(diff / 86400) + " 天前"
}
import "utils.js" as Utils

Text { text: Utils.formatCurrency(price, "¥") }
Text { text: Utils.timeAgo(createdAt) }

五、属性遮蔽(Property Shadowing)陷阱

问题:子组件定义了与父组件同名的属性

// 危险:属性遮蔽
Rectangle {
    property color color: "blue"    // 遮蔽了 Rectangle 自带的 color 属性!
    // 此时 color 既指自定义属性,又指 Rectangle.color
    // 绑定行为变得不可预测
}
// 危险:在 Delegate 中声明与 model 角色同名的属性
ListView {
    delegate: Rectangle {
        property string name: "默认"    // 遮蔽了 model 的 name 角色!
        Text { text: name }             // 显示的是 "默认",而不是 model 数据
    }
}

解决:使用不会冲突的命名,或改用 required property

// 推荐:使用不冲突的命名
Rectangle {
    property color backgroundColor: "blue"    // 不与内置属性冲突
    color: backgroundColor
}

// 推荐:Delegate 用 required property 而不是声明同名属性
ListView {
    delegate: Rectangle {
        required property string name    // 明确声明来自 model
        Text { text: name }
    }
}

六、组件封装原则

6.1 单一职责:一个组件做一件事

// 不推荐:一个组件承担太多职责
// UserCard.qml — 包含数据获取、显示、编辑、删除...

// 推荐:拆分为职责单一的小组件
// UserAvatar.qml  — 只负责头像显示
// UserInfo.qml    — 只负责用户信息文本
// UserCard.qml    — 组合 Avatar + Info,加入卡片样式
// UserActions.qml — 只负责操作按钮区域

6.2 明确暴露的接口:property + signal

// 好的组件接口设计
// SearchBar.qml
Rectangle {
    id: root

    // 对外暴露的属性(接口)
    property string placeholder: "搜索..."
    property alias  searchText: field.text     // alias 透传内部属性
    property int    maxLength: 100

    // 对外发出的信号(接口)
    signal searchSubmitted(string query)
    signal cleared()

    // 内部实现细节(不对外暴露)
    TextField {
        id: field
        placeholderText: root.placeholder
        maximumLength: root.maxLength
        onAccepted: root.searchSubmitted(text)
    }

    Button {
        text: "清除"
        onClicked: {
            field.clear()
            root.cleared()
        }
    }
}

6.3 不要在组件内部直接访问外部 id

// 不推荐:组件直接引用外部 id(强耦合,组件无法复用)
// MyButton.qml
Button {
    onClicked: mainWindow.showDialog()    // 直接访问外部 id!
}

// 推荐:通过信号解耦
// MyButton.qml
Button {
    signal buttonClicked()
    onClicked: buttonClicked()           // 发出信号,由外部决定做什么
}

// main.qml
MyButton {
    onButtonClicked: mainWindow.showDialog()    // 外部连接信号
}

七、代码组织:QML 文件内部的书写顺序

Qt 官方推荐的 QML 文件内部属性书写顺序:

Rectangle {
    // 1. id(第一行,方便快速定位)
    id: root

    // 2. 属性声明(property / required property / readonly property)
    property string title: ""
    required property int index
    readonly property int maxCount: 10

    // 3. 信号声明
    signal itemSelected(int idx)

    // 4. JavaScript 函数
    function doSomething() { }

    // 5. 对象属性赋值(x, y, width, height, color…)
    x: 0; y: 0
    width: 200; height: 100
    color: "#f5f5f5"

    // 6. 子对象
    Text {
        anchors.centerIn: parent
        text: root.title
    }

    // 7. 状态和过渡
    states: [ State { name: "active" } ]
    transitions: [ Transition { } ]
}

八、性能最佳实践

8.1 使用 Loader 延迟加载非关键内容

ApplicationWindow {
    // 主内容立即加载
    MainContent { anchors.fill: parent }

    // 设置页面、帮助面板等用 Loader 延迟加载
    Loader {
        id: settingsLoader
        active: false    // 默认不加载
        sourceComponent: SettingsPanel {}
    }

    Button {
        text: "设置"
        onClicked: settingsLoader.active = true    // 第一次点击时才加载
    }
}

8.2 避免在 Delegate 中使用 Layouts 和 Anchors

// 不推荐:Delegate 中使用 ColumnLayout(创建和销毁开销大)
delegate: ColumnLayout {
    Text { text: name }
    Text { text: description }
}

// 推荐:Delegate 中用简单的 x/y/width/height 定位
delegate: Item {
    width: ListView.view.width; height: 60
    Text {
        x: 16; y: 8
        text: name
        font.pixelSize: 15; font.bold: true
    }
    Text {
        x: 16; y: 32
        text: description
        font.pixelSize: 13; color: "#888"
    }
}

8.3 使用 qmllint 进行静态检查

在 Qt Creator 终端运行:

# 检查单个文件
qmllint Main.qml

# 检查整个项目(编译警告级别)
qmllint --compiler warning *.qml

qmllint 能发现:

  • 非限定访问 [unqualified]
  • 未声明的属性
  • 废弃的 API 用法
  • 信号处理器参数未命名

8.4 使用 QML Profiler 定位性能瓶颈

在 Qt Creator 中:Analyze → QML Profiler

QML Profiler 时间线视图:
┌─────────────────────────────────────────────────────┐
│ Animations    ████░░░████░░░████░░░                 │
│ Compiling     █░░░░░░░░░░░░░░░░░░░░░░░             │
│ Creating      ██░░░░░░░░░░░░░░░░░░░░░░             │
│ Binding       ░░░██░░░██░░░██░░░                   │
│ Handling Sig  ░░░░░█░░░░░█░░░░░█░░░                │
│ JavaScript    ░░░░░░█████░░░░░░████                │
│                                                     │
│  ← 帧时间不应超过 16ms(60fps)→                   │
└─────────────────────────────────────────────────────┘

重点关注:JavaScript 函数执行时间是否超过 16ms,Binding 是否被频繁触发。


九、可维护性:做好国际化准备

从第一行代码起就养成用 qsTr() 包裹用户可见字符串的习惯:

// 不推荐:硬编码字符串(之后国际化要改遍全部文件)
Button { text: "确认" }
Label  { text: "请输入用户名" }

// 推荐:从一开始就用 qsTr()
Button { text: qsTr("确认") }
Label  { text: qsTr("请输入用户名") }

lupdate 提取所有 qsTr() 字符串到 .ts 翻译文件:

lupdate MyProject.pro -ts translations/app_zh_CN.ts

总结

最佳实践 核心要点
强类型属性 int/string/bool 而不是 var
限定访问 通过 id.property 访问,避免裸用父级属性名
required property Delegate 中声明 model 角色的推荐方式
声明式绑定 能用 : 绑定就不用 = 赋值
简单绑定表达式 复杂逻辑提取为函数,不放在绑定中
避免属性遮蔽 不要用与父级或内置属性同名的属性名
单一职责组件 每个 .qml 文件只做一件事
Loader 延迟加载 非关键 UI 按需加载,减少启动时间
Delegate 简化定位 x/y 代替 Layouts,减少对象创建开销
qmllint 静态检查 每次提交前运行,发现潜在问题
qsTr() 国际化 从第一行起包裹所有用户可见字符串

面试官视角:TypeScript Pick 工具类型深度解析与手写实现

作者 Yira
2026年4月15日 00:58

在字节、阿里等大厂的 TypeScript 面试中,考察工具类型(Utility Types)是一个非常经典的环节。面试官并不只是想看你背诵 Pick 或 Omit 的用法,而是想通过“手写 MyPick”这道题,考察你对泛型(Generics)、索引类型查询(keyof)、映射类型(Mapped Types)以及类型约束(extends)的深度理解。

这篇文章将带你从“知其然”到“知其所以然”,用幽默且硬核的方式彻底拿下这个知识点。


为什么我们需要 Pick?(面试官的潜台词)

在写代码时,我们经常会遇到这种情况:后端定义了一个巨大的 User 对象,包含 idnameagepasswordcreatedAt 等十几个字段。但在前端的一个小卡片组件里,我只需要展示 name 和 avatar

如果不使用 Pick,你可能需要重新定义一个接口,或者手动去 extends 然后重写属性。这不仅啰嗦,而且一旦后端改了字段,你的代码维护起来就是灾难。

Pick 的本质:它就像是一个“类型级的过滤器”。你给它一个完整的对象类型,再给它几个你想要的字段名,它就能给你吐出一个全新的、精简的类型。


庖丁解牛:手写 MyPick 的三步走战略

面试官让你在 type MyPick<T, K> = any 的 any 处填空,你该如何思考?我们可以把这个过程拆解为三个步骤:

第一步:明确原材料(泛型参数)

我们需要两个参数:

  • T:原始的、完整的对象类型(比如 User)。
  • K:我们想要挑选出来的属性名(比如 'name' | 'age')。

第二步:加上安全锁(类型约束)

这是面试中最容易丢分的地方。如果用户传了一个 T 中不存在的属性怎么办?比如 Pick<User, 'nonExistentField'>
为了防止这种情况,我们必须限制 KK 必须是 T 中所有键的集合的子集。

这就引入了 keyof T 和 extends

  • keyof T:获取 T 所有属性名组成的联合类型(例如 'id' | 'name' | 'age')。
  • K extends keyof T:这句话的意思是,“K 必须是 keyof T 的一部分”。如果传了不存在的属性,TypeScript 会直接报错,这就是类型安全。

第三步:加工生产(映射类型)

拿到了合法的 K,我们需要构建新对象。这里要用到映射类型
语法结构是:{ [P in K]: ... }
这就像是一个 for...in 循环,遍历 K 中的每一个属性 P,然后去原始类型 T 中查找 P 对应的类型(即 T[P],这叫索引访问类型)。


核心代码实现与逐行精讲

结合上述思路,我们可以写出以下完美的实现代码:

1// 1. 定义原始类型
2interface User {
3    id: number;
4    age: number;
5    name: string;
6    password: string; // 敏感字段
7}
8
9// 2. 手写 MyPick
10// T: 源类型
11// K: 需要挑选的键,且 K 必须受限于 keyof T (即 K 必须是 T 中存在的属性)
12type MyPick<T, K extends keyof T> = {
13    // 映射类型:遍历 K 中的每一个属性 P
14    [P in K]: T[P]; // T[P] 表示取出 T 中 P 属性对应的类型
15}
16
17// 3. 测试
18type UserName = MyPick<User, 'name'>; 
19// 结果:{ name: string }
20
21type UserPublicInfo = MyPick<User, 'id' | 'name'>;
22// 结果:{ id: number; name: string; }
23
24// 4. 错误测试 (TypeScript 会报错,因为 'hack' 不在 User 中)
25// type ErrorCase = MyPick<User, 'hack'>; 

关键知识点深度解析

为了在面试中对答如流,你需要理解以下几个核心概念:

keyof 操作符
它的作用是“取键”。对于一个对象类型,keyof 会返回它所有属性名的联合类型。

  • 例子:keyof User 得到 'id' | 'age' | 'name' | 'password'

索引访问类型
语法是 T[P]。它的作用是“取值”。

  • 例子:如果 P 是 'name',那么 User['name'] 就是 string

映射类型
语法是 { [P in K]: ... }。它允许你将一个联合类型转换为一个新的对象类型。

  • 在 MyPick 中,我们遍历的是 K(用户想要的键),而不是 keyof T(所有的键),这就是“挑选”的精髓。

extends 关键字
在这里它不是“继承”,而是“约束”。K extends keyof T 保证了传入的键是合法的。


举一反三:Omit 与 Partial

面试官通常会接着问:“那你能手写一下 Omit 吗?”
其实 Omit 就是 Pick 的反面。Omit 是“排除”某些字段。
它的实现思路是:先利用 Exclude 工具类型从 keyof T 中剔除掉 K,剩下的就是我们要保留的,然后再用 Pick 的逻辑。

1// 手写 Omit
2// Exclude<UnionType, ExcludedMembers> 用于从联合类型中排除某项
3type MyOmit<T, K extends keyof T> = MyPick<T, Exclude<keyof T, K>>;

Partial
Partial 则是将所有属性变为可选。

1type MyPartial<T> = {
2    [P in keyof T]?: T[P];
3}

总结

在面试中回答这道题,建议遵循以下逻辑流:

  1. 定义泛型:声明 T 和 K
  2. 添加约束:使用 K extends keyof T 确保类型安全。
  3. 构建映射:使用 { [P in K]: T[P] } 完成类型的重组。

掌握了这个模板,你不仅搞定了 Pick,也顺手拿下了 OmitReadonly 和 Partial,它们是 TypeScript 高级类型编程的基石。

Qt Quick Controls 全览控件、弹窗、导航与样式定制(十一)

作者 HelloReader
2026年4月15日 00:40

适合人群: 已掌握基础 QML 语法,想系统掌握完整控件库的开发者 > 预计耗时: 90 分钟


前言

Qt Quick Controls 提供了构建完整应用界面所需的全套控件——从最基础的按钮、输入框,到菜单、抽屉、页面导航。本文系统梳理每一类控件的完整用法,并深入讲解样式系统和自定义控件外观。


一、控件分类总览

Qt Quick Controls 的控件按功能分为六大类:

QtQuick.Controls
├── 按钮类     Button · CheckBox · RadioButton · Switch · RoundButton
├── 输入类     TextField · TextArea · Slider · Dial · SpinBox · ComboBox · Tumbler
├── 显示类     Label · ProgressBar · BusyIndicator · DelayButton
├── 容器类     Frame · GroupBox · ScrollView · Pane · Page · TabBar · ToolBar
├── 弹窗类     Dialog · Drawer · Menu · Popup · ToolTip
└── 导航类     StackView · SwipeView · PageIndicator

二、内置样式一览

Qt Quick Controls 内置多套样式,一行代码即可切换全局外观。

Basic 样式(默认,跨平台)

Basic 样式控件展示

图片来源:Qt 官方文档 — Basic Style

轻量极简,性能最佳,适合作为自定义样式的起点。

Material 样式(Google Material Design)

Material 样式浅色主题

图片来源:Qt 官方文档 — Material Style

适合移动端和现代桌面应用,视觉效果丰富。

Fusion 样式(桌面风格)

传统桌面应用外观,与 Qt Widgets 视觉语言一致,适合企业桌面工具。

各平台默认样式

操作系统 默认样式
Android Material
iOS iOS Style
macOS macOS Style
Windows Windows Style
Linux / 其他 Fusion

设置样式的三种方式

方式一:编译时导入(推荐,性能最优)

// 必须在所有其他 QtQuick.Controls 导入之前
import QtQuick.Controls.Material

ApplicationWindow {
    Material.theme: Material.Light
    Material.accent: Material.Blue
}

方式二:运行时 C++ 设置

#include <QQuickStyle>
QQuickStyle::setStyle("Material");

方式三:配置文件 qtquickcontrols2.conf

[Controls]
Style=Material

[Material]
Theme=Light
Accent=Blue

三、按钮类控件完整用法

3.1 Button 的状态属性

Button {
    text: "操作按钮"

    // 核心状态属性(只读,反映当前交互状态)
    // pressed    — 正在按下
    // hovered    — 鼠标悬停
    // checked    — 已选中(checkable 时有效)
    // enabled    — 是否可用
    // highlighted — 强调样式(Material 下显示 accent 色)
    // flat       — 扁平样式(无背景边框)

    highlighted: true
    flat: false
    checkable: true     // 允许切换选中状态
    icon.source: "images/send.svg"
    icon.width: 18
    icon.height: 18
}

3.2 DelayButton — 长按确认按钮

需要长按才能触发,适合危险操作(删除、格式化):

DelayButton {
    text: "长按删除"
    delay: 1500     // 需要按住 1.5 秒

    onActivated: console.log("确认删除!")

    // 进度条自动显示按压进度
}

3.3 RoundButton — 圆形按钮

RoundButton {
    text: "+"
    font.pixelSize: 20
    highlighted: true

    // 或使用图标
    icon.source: "images/add.svg"
}

四、输入类控件完整用法

4.1 Dial — 旋钮控件

适合音量、亮度等环形调节:

import QtQuick
import QtQuick.Controls

Column {
    spacing: 8

    Dial {
        id: volumeDial
        from: 0; to: 100; value: 50
        stepSize: 1

        // 旋转模式
        inputMode: Dial.Circular      // 圆形拖动(默认)
        // inputMode: Dial.Horizontal // 水平拖动
        // inputMode: Dial.Vertical   // 垂直拖动
    }

    Label {
        anchors.horizontalCenter: parent.horizontalCenter
        text: "音量:" + Math.round(volumeDial.value)
    }
}

4.2 Tumbler — 滚筒选择器

适合时间、日期选择:

Row {
    spacing: 0

    Tumbler {
        id: hourTumbler
        model: 24
        delegate: Label {
            required property int index
            text: index.toString().padStart(2, "0")
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
            opacity: 1.0 - Math.abs(Tumbler.displacement) / (Tumbler.tumbler.visibleItemCount / 2)
            font.pixelSize: Math.abs(Tumbler.displacement) < 0.5 ? 18 : 14
        }
    }

    Label {
        anchors.verticalCenter: parent.verticalCenter
        text: ":"
        font.pixelSize: 18
        font.bold: true
    }

    Tumbler {
        id: minuteTumbler
        model: 60
        delegate: Label {
            required property int index
            text: index.toString().padStart(2, "0")
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
            opacity: 1.0 - Math.abs(Tumbler.displacement) / (Tumbler.tumbler.visibleItemCount / 2)
            font.pixelSize: Math.abs(Tumbler.displacement) < 0.5 ? 18 : 14
        }
    }
}

五、容器类控件

5.1 Frame 与 GroupBox

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Column {
    spacing: 16
    width: 300

    // Frame:带边框的容器
    Frame {
        width: parent.width
        ColumnLayout {
            width: parent.width
            Label { text: "账号信息"; font.bold: true }
            TextField { Layout.fillWidth: true; placeholderText: "用户名" }
            TextField { Layout.fillWidth: true; placeholderText: "邮箱" }
        }
    }

    // GroupBox:带标题的 Frame
    GroupBox {
        width: parent.width
        title: "通知设置"
        ColumnLayout {
            width: parent.width
            CheckBox { text: "邮件通知" }
            CheckBox { text: "短信通知" }
            CheckBox { text: "推送通知"; checked: true }
        }
    }
}

5.2 TabBar + StackLayout — 选项卡导航

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Column {
    width: 400

    TabBar {
        id: tabBar
        width: parent.width

        TabButton { text: "首页" }
        TabButton { text: "发现" }
        TabButton { text: "消息" }
        TabButton { text: "我的" }
    }

    StackLayout {
        width: parent.width
        height: 300
        currentIndex: tabBar.currentIndex    // 与 TabBar 绑定

        Rectangle { color: "#E6F1FB"; Label { anchors.centerIn: parent; text: "首页内容" } }
        Rectangle { color: "#E1F5EE"; Label { anchors.centerIn: parent; text: "发现内容" } }
        Rectangle { color: "#FAEEDA"; Label { anchors.centerIn: parent; text: "消息内容" } }
        Rectangle { color: "#FAECE7"; Label { anchors.centerIn: parent; text: "我的内容" } }
    }
}

5.3 ToolBar — 工具栏

ApplicationWindow {
    width: 500; height: 400
    visible: true

    header: ToolBar {
        RowLayout {
            anchors.fill: parent

            ToolButton {
                icon.source: "images/menu.svg"
                onClicked: drawer.open()
            }

            Label {
                text: "应用标题"
                font.pixelSize: 16
                font.bold: true
                Layout.fillWidth: true
                horizontalAlignment: Text.AlignHCenter
            }

            ToolButton {
                icon.source: "images/search.svg"
            }

            ToolButton {
                icon.source: "images/more.svg"
            }
        }
    }
}

六、弹窗类控件

6.1 Dialog — 标准对话框

Dialog {
    id: confirmDialog
    anchors.centerIn: parent
    title: "确认操作"
    modal: true
    width: 280

    // 内容区
    contentItem: Label {
        text: "确定要删除这条记录吗?此操作不可撤销。"
        wrapMode: Text.Wrap
        padding: 8
    }

    // 标准按钮
    standardButtons: Dialog.Ok | Dialog.Cancel

    onAccepted: console.log("用户点击了确定")
    onRejected: console.log("用户取消了操作")
}

Button {
    text: "删除"
    onClicked: confirmDialog.open()
}

6.2 自定义 Dialog 内容

Dialog {
    id: inputDialog
    anchors.centerIn: parent
    title: "重命名"
    modal: true
    width: 300

    contentItem: ColumnLayout {
        spacing: 12
        width: parent.width

        Label { text: "请输入新名称:" }

        TextField {
            id: nameField
            Layout.fillWidth: true
            placeholderText: "名称"
            focus: true    // 对话框打开时自动聚焦
        }
    }

    footer: DialogButtonBox {
        Button {
            text: "取消"
            DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
        }
        Button {
            text: "确认"
            enabled: nameField.text.length > 0
            highlighted: true
            DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
        }
    }

    onAccepted: console.log("新名称:" + nameField.text)
}

6.3 Drawer — 侧滑抽屉

ApplicationWindow {
    id: window
    width: 400; height: 600
    visible: true

    Drawer {
        id: drawer
        width: 260
        height: window.height
        edge: Qt.LeftEdge    // Qt.RightEdge / Qt.TopEdge / Qt.BottomEdge

        // 抽屉内容
        ColumnLayout {
            anchors.fill: parent
            anchors.margins: 0
            spacing: 0

            // 用户信息头部
            Rectangle {
                Layout.fillWidth: true
                height: 120
                color: "#4A90E2"

                Column {
                    anchors.centerIn: parent
                    spacing: 6
                    Rectangle {
                        width: 56; height: 56; radius: 28
                        color: "white"
                        anchors.horizontalCenter: parent.horizontalCenter
                        Label {
                            anchors.centerIn: parent
                            text: "用"
                            font.pixelSize: 22
                            font.bold: true
                            color: "#4A90E2"
                        }
                    }
                    Label {
                        text: "用户名"
                        color: "white"
                        font.pixelSize: 14
                        anchors.horizontalCenter: parent.horizontalCenter
                    }
                }
            }

            // 菜单列表
            Repeater {
                model: ["首页", "收藏", "历史记录", "设置", "帮助"]
                delegate: ItemDelegate {
                    required property string modelData
                    Layout.fillWidth: true
                    text: modelData
                    onClicked: {
                        console.log("导航到:" + modelData)
                        drawer.close()
                    }
                }
            }

            Item { Layout.fillHeight: true }

            ItemDelegate {
                Layout.fillWidth: true
                text: "退出登录"
            }
        }
    }

    Button {
        text: "打开抽屉"
        anchors.centerIn: parent
        onClicked: drawer.open()
    }
}

6.4 Menu — 上下文菜单

Menu {
    id: contextMenu

    MenuItem {
        text: "复制"
        shortcut: "Ctrl+C"
        onTriggered: console.log("复制")
    }

    MenuItem {
        text: "粘贴"
        shortcut: "Ctrl+V"
        onTriggered: console.log("粘贴")
    }

    MenuSeparator {}    // 分割线

    Menu {
        title: "导出为"
        MenuItem { text: "PDF" }
        MenuItem { text: "PNG" }
        MenuItem { text: "SVG" }
    }

    MenuSeparator {}

    MenuItem {
        text: "删除"
        enabled: false    // 禁用状态
    }
}

// 右键触发
MouseArea {
    anchors.fill: parent
    acceptedButtons: Qt.RightButton
    onClicked: contextMenu.popup()    // 在鼠标位置弹出
}

6.5 ToolTip — 悬停提示

Button {
    text: "保存"
    icon.source: "images/save.svg"

    // 方式一:附加属性(最简单)
    ToolTip.visible: hovered
    ToolTip.text: "保存文件 (Ctrl+S)"
    ToolTip.delay: 800    // 悬停 800ms 后显示

    // 方式二:独立 ToolTip 组件(可自定义外观)
}

七、导航类控件

7.1 StackView — 页面栈导航

StackView 实现类似移动端的前进/后退页面导航:

// 页面切换时间线:
// push()  → 新页面从右侧滑入
// pop()   → 当前页面向右滑出
// replace() → 替换当前页面(无返回)

StackView {
    id: stackView
    anchors.fill: parent

    // 初始页面
    initialItem: homePage
}

Component {
    id: homePage
    Rectangle {
        color: "#f5f5f5"
        Column {
            anchors.centerIn: parent
            spacing: 12

            Label { text: "首页"; font.pixelSize: 24; font.bold: true }

            Button {
                text: "进入详情页"
                onClicked: stackView.push(detailPage, { title: "详情内容" })
            }
        }
    }
}

Component {
    id: detailPage
    Rectangle {
        property string title: ""
        color: "#E6F1FB"
        Column {
            anchors.centerIn: parent
            spacing: 12

            Label { text: title; font.pixelSize: 20 }

            Button {
                text: "← 返回"
                onClicked: stackView.pop()
            }
        }
    }
}

StackView 页面切换动画流程:

┌─────────────┐  push()   ┌─────────────┬─────────────┐
│   首页      │ ────────▶ │   首页      │   详情页    │
│  (当前)   │           │  (历史)   │  (当前)   │
└─────────────┘           └─────────────┴─────────────┘

                 pop()    ┌─────────────┐
                ────────▶ │   首页      │
                          │  (当前)   │
                          └─────────────┘

7.2 SwipeView + PageIndicator — 横划导航

适合引导页、图片轮播、多步骤表单:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Column {
    width: 360
    spacing: 0

    SwipeView {
        id: swipeView
        width: parent.width
        height: 280

        // 第一页
        Rectangle {
            color: "#4A90E2"
            Label {
                anchors.centerIn: parent
                text: "欢迎使用"
                font.pixelSize: 24
                font.bold: true
                color: "white"
            }
        }

        // 第二页
        Rectangle {
            color: "#1D9E75"
            Label {
                anchors.centerIn: parent
                text: "功能介绍"
                font.pixelSize: 24
                font.bold: true
                color: "white"
            }
        }

        // 第三页
        Rectangle {
            color: "#E2934A"
            Label {
                anchors.centerIn: parent
                text: "开始使用"
                font.pixelSize: 24
                font.bold: true
                color: "white"
            }
        }
    }

    // 页面指示点
    PageIndicator {
        anchors.horizontalCenter: parent.horizontalCenter
        count: swipeView.count
        currentIndex: swipeView.currentIndex    // 双向绑定
        interactive: true    // 点击圆点可跳转
    }
}

八、自定义控件外观

8.1 替换 background 和 contentItem

每个控件的外观由 background(背景)和 contentItem(内容)组成,单独替换其中任意一个即可改变外观:

// 自定义圆角按钮,保留所有交互行为
Button {
    id: btn
    text: "自定义按钮"
    width: 140; height: 44

    background: Rectangle {
        radius: btn.height / 2      // 完全圆角
        color: btn.pressed   ? "#2C72C7" :
               btn.hovered   ? "#5BA3E8" :
               btn.enabled   ? "#4A90E2" : "#AAAAAA"

        Behavior on color {
            ColorAnimation { duration: 120 }
        }

        border.width: 0
    }

    contentItem: Text {
        text: btn.text
        color: "white"
        font.pixelSize: 14
        font.bold: true
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
    }
}

8.2 自定义 ProgressBar

ProgressBar {
    id: bar
    width: 300
    value: 0.65

    background: Rectangle {
        implicitWidth: 200; implicitHeight: 8
        color: "#e0e0e0"
        radius: 4
    }

    contentItem: Item {
        implicitWidth: 200; implicitHeight: 8

        Rectangle {
            width: bar.visualPosition * parent.width
            height: parent.height
            radius: 4

            // 渐变进度条
            gradient: Gradient {
                orientation: Gradient.Horizontal
                GradientStop { position: 0.0; color: "#4A90E2" }
                GradientStop { position: 1.0; color: "#1D9E75" }
            }

            Behavior on width {
                NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
            }
        }
    }
}

8.3 封装统一风格的自定义控件

把自定义样式封装到独立的组件文件,在整个项目复用:

// PrimaryButton.qml
import QtQuick
import QtQuick.Controls

Button {
    id: root
    height: 44

    property color primaryColor: "#4A90E2"

    background: Rectangle {
        radius: 8
        color: root.pressed ? Qt.darker(root.primaryColor, 1.2)
             : root.hovered ? Qt.lighter(root.primaryColor, 1.1)
             : root.enabled ? root.primaryColor
             :                "#cccccc"
        Behavior on color { ColorAnimation { duration: 100 } }
    }

    contentItem: Text {
        text: root.text
        color: root.enabled ? "white" : "#888888"
        font.pixelSize: 14
        font.bold: true
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
    }
}

使用:

PrimaryButton { text: "确认"; width: 120 }
PrimaryButton { text: "危险操作"; width: 120; primaryColor: "#E24A4A" }
PrimaryButton { text: "成功"; width: 120; primaryColor: "#1D9E75" }

九、综合示例:设置页面

整合本文所有控件,构建一个完整的应用设置页面:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    width: 400; height: 650
    visible: true
    title: "设置"

    ScrollView {
        anchors.fill: parent
        contentWidth: availableWidth

        ColumnLayout {
            width: parent.width
            spacing: 0

            // 外观设置
            GroupBox {
                Layout.fillWidth: true
                Layout.margins: 16
                title: "外观"

                ColumnLayout {
                    width: parent.width
                    spacing: 4

                    RowLayout {
                        Layout.fillWidth: true
                        Label { text: "深色模式"; Layout.fillWidth: true }
                        Switch { id: darkSwitch }
                    }

                    RowLayout {
                        Layout.fillWidth: true
                        Label { text: "主题色"; Layout.fillWidth: true }
                        ComboBox {
                            model: ["蓝色", "绿色", "橙色", "紫色"]
                            Layout.preferredWidth: 100
                        }
                    }

                    RowLayout {
                        Layout.fillWidth: true
                        Label { text: "字体大小"; Layout.fillWidth: true }
                        Slider {
                            from: 12; to: 20; value: 15
                            stepSize: 1
                            Layout.preferredWidth: 120
                        }
                    }
                }
            }

            // 通知设置
            GroupBox {
                Layout.fillWidth: true
                Layout.leftMargin: 16
                Layout.rightMargin: 16
                title: "通知"

                ColumnLayout {
                    width: parent.width
                    spacing: 4

                    Repeater {
                        model: ["接收推送通知", "邮件提醒", "声音提示", "震动反馈"]
                        delegate: RowLayout {
                            required property string modelData
                            required property int index
                            Layout.fillWidth: true
                            Label { text: modelData; Layout.fillWidth: true }
                            Switch { checked: index < 2 }
                        }
                    }
                }
            }

            // 存储设置
            GroupBox {
                Layout.fillWidth: true
                Layout.leftMargin: 16
                Layout.rightMargin: 16
                title: "存储与数据"

                ColumnLayout {
                    width: parent.width
                    spacing: 8

                    Label {
                        text: "已用空间:1.2 GB / 5 GB"
                        font.pixelSize: 13; color: "#666"
                    }

                    ProgressBar {
                        Layout.fillWidth: true
                        value: 0.24
                    }

                    Button {
                        Layout.fillWidth: true
                        text: "清理缓存"
                        onClicked: cacheDialog.open()
                    }
                }
            }

            // 账号操作
            GroupBox {
                Layout.fillWidth: true
                Layout.leftMargin: 16
                Layout.rightMargin: 16
                Layout.bottomMargin: 16
                title: "账号"

                ColumnLayout {
                    width: parent.width
                    spacing: 8

                    Button {
                        Layout.fillWidth: true
                        text: "修改密码"
                    }

                    Button {
                        Layout.fillWidth: true
                        text: "退出登录"
                        onClicked: logoutDialog.open()
                    }
                }
            }
        }
    }

    // 清理缓存确认对话框
    Dialog {
        id: cacheDialog
        anchors.centerIn: parent
        title: "清理缓存"
        modal: true
        standardButtons: Dialog.Ok | Dialog.Cancel
        contentItem: Label {
            text: "确认清理所有缓存数据?"
            padding: 8
        }
        onAccepted: console.log("缓存已清理")
    }

    // 退出登录确认对话框
    Dialog {
        id: logoutDialog
        anchors.centerIn: parent
        title: "退出登录"
        modal: true
        standardButtons: Dialog.Yes | Dialog.No
        contentItem: Label {
            text: "确认退出当前账号?"
            padding: 8
        }
        onAccepted: console.log("已退出登录")
    }
}

总结

控件 用途 关键属性
Dial 旋钮调节 inputModestepSize
Tumbler 滚筒选择 modelvisibleItemCount
TabBar + StackLayout 选项卡切换 currentIndex 双向绑定
ToolBar 应用顶部工具栏 放在 header 属性
Dialog 模态对话框 standardButtonsmodal
Drawer 侧滑抽屉导航 edgeopen() / close()
Menu 上下文菜单 popup()MenuSeparator
ToolTip 悬停提示 delayvisible: hovered
StackView 页面栈前进/后退 push() / pop()
SwipeView 横划页面切换 配合 PageIndicator 使用
background / contentItem 自定义控件外观 替换任意一个,保留交互行为

从零搭建 Monorepo 自动发布工作流(GitHub Actions + pnpm + Lerna)

作者 donecoding
2026年4月14日 23:09

🚀 省流助手 (速通结论)

如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面最后的 GitHub Actions 配置即可开箱即用:

三个核心要点

  1. 只监听 PR 合并事件,避免手动推送误触发。
  2. 发布前先将 master 同步到 release,确保基于最新主干代码发版。
  3. 发布后使用 --ff-only 快进 master,保持历史线性且零冲突。

如果你想知道为什么这么设计、如何避坑,请继续阅读全文。

1. 引言:为什么要折腾这套流程?

在 Monorepo 项目中,包的版本管理和发布往往是最繁琐的环节。手动执行 lerna publish 不仅容易忘记切换 Node 版本、打错标签,还可能在多人协作时出现版本冲突或漏发包的情况。

本文将手把手带你用 GitHub Actions 搭建一套完全自动化的发布流水线,实现以下效果:

  • ✅ 开发者只需将 PR 合并到 release 分支,剩下的全部交给机器人。
  • ✅ 自动计算版本号,自动生成 CHANGELOG,自动推送 Git 标签。
  • ✅ 发布完成后自动将 master 分支同步到最新状态,保持双分支一致。

2. 触发时机:如何精确捕获“PR 合并”事件?

很多同学一开始会写成这样:

on:
  push:
    branches:
      - release

问题:任何向 release 分支的推送都会触发(包括手动 git pushgit commit),不符合“只有 PR 合并才发布”的规范。

正确姿势是监听 pull_request 事件的 closed 类型:

on:
  pull_request:
    types:
      - closed
    branches:
      - release

closed 事件包含两种情形:合并后关闭直接关闭(未合并)。因此我们还需要在 Job 级别加一个条件过滤:

jobs:
  publish:
    if: github.event.pull_request.merged == true

这样就能精准命中“PR 已合并”的场景,完美避开直接关闭的空跑。

3. 环境配置:锁定 Node 与 pnpm 版本

为了避免因环境差异导致的构建失败,强烈建议将 Node.js 和 pnpm 的版本写死在环境变量中:

env:
  NODE_VERSION: "22"
  PNPM_VERSION: "10.33.0"

后续步骤通过 ${{ env.NODE_VERSION }}${{ env.PNPM_VERSION }} 引用,日后升级只需改一处即可。

- uses: pnpm/action-setup@v4
  with:
    version: ${{ env.PNPM_VERSION }}

- uses: actions/setup-node@v4
  with:
    node-version: ${{ env.NODE_VERSION }}
    registry-url: "https://registry.npmjs.org"

4. Git 身份配置:为什么必须用 [bot] 邮箱?

在 CI 中生成的提交需要有一个明确的作者身份。如果随意填写 ci@localhost,GitHub 会将其显示为灰色头像的“幽灵提交”,无法关联到任何账户,也不利于审计追溯。

正确做法是使用 GitHub Actions 官方的 Bot 身份:

- name: Configure Git
  run: |
    git config --global user.name "github-actions[bot]"
    git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

其中 41898282 是 GitHub Actions App 的唯一数字 ID,加上这串数字后提交会明确归属给机器人。

5. 分支同步策略:为什么发布前要合并 master

很多团队允许紧急 Hotfix 直接合并到 master 上线。如果 release 分支长期未更新,就可能基于过时代码发布,导致线上问题复现。

因此我们在发布前增加一步:

- name: Sync master into release
  run: |
    git fetch origin master
    git merge origin/master --no-ff -m "chore: sync master into release [skip ci]"
  • --no-ff 保留合并历史,清晰记录本次同步动作。
  • 提交信息中带上 [skip ci] 是一个防御性习惯:即使未来因某种原因推送了这个合并提交,也不会触发额外的工作流。

6. Lerna 发布:本地生成提交,不着急推送

核心发布命令如下:

- name: Publish packages
  run: |
    npx lerna publish --yes \
      --conventional-graduate \
      --no-push \
      --message "chore(release): publish [skip ci]"
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
    GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}

参数解释:

  • --yes:跳过所有交互式确认,全自动执行。
  • --conventional-graduate:自动将当前为 alpha/beta 的预发布包“毕业”为正式版本(例如 1.0.0-alpha.01.0.0)。
  • --no-push禁止 Lerna 自动推送,改为后续手动推送。这样可以在 npm 发布成功后再推送 Git 标签,保证原子性。
  • --message:自定义提交信息,包含 [skip ci] 防止推送后再次触发本工作流。

7. 推送与主干快进:如何让 master 历史保持一条直线?

发布完成后,我们分两步推送:

第一步:推送 release 分支及标签

- name: Push release and tags
  run: git push --follow-tags origin release

第二步:将 master 快进到 release

- name: Fast-forward master
  run: |
    git fetch origin master
    git checkout master
    git merge --ff-only origin/release
    git push origin master

由于发布前我们已经将 master 合并到了 release,加上发布提交,release 必然比 master 多一个新提交。此时使用 --ff-only(仅快进)可以将 master 指针直接移动到 release 的位置,不会产生额外的合并提交,历史图谱干净如线。

8. 并发控制与安全兜底

concurrency:
  group: release-publish
  cancel-in-progress: false

这一配置确保同一时刻只有一个发布任务运行,新触发的任务会排队等待,避免多人同时合并 PR 造成 Git 推送冲突。

同时,工作流顶部声明权限:

permissions:
  contents: write

配合 Personal Access Token(需具备 Contents 读写权限),保证 Git 推送操作万无一失。

9. 结语

通过以上配置,我们实现了一套高内聚、低心智负担的 Monorepo 自动发布流水线。开发者只需专注于代码本身,合并 PR 后喝杯咖啡,机器人会自动完成剩下的所有脏活累活。

完整配置文件,欢迎直接复制使用。

如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面这份 GitHub Actions 配置即可开箱即用:

name: Publish from Release
env:
  NODE_VERSION: "22"
  PNPM_VERSION: "10.33.0"

on:
  pull_request:
    types: [closed]
    branches: [release]

concurrency:
  group: release-publish
  cancel-in-progress: false

jobs:
  publish:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.RELEASE_GITHUB_TOKEN }}

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          registry-url: "https://registry.npmjs.org"

      - name: Configure Git
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Sync master into release
        run: |
          git fetch origin master
          git merge origin/master --no-ff -m "chore: sync master into release [skip ci]"

      - name: Install dependencies
        run: pnpm install

      - name: Publish packages
        run: |
          npx lerna publish --yes --conventional-graduate --no-push --message "chore(release): publish [skip ci]"
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
          GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}

      - name: Push release and tags
        run: git push --follow-tags origin release

      - name: Fast-forward master
        run: |
          git fetch origin master
          git checkout master
          git merge --ff-only origin/release
          git push origin master

下一篇我们将深入探讨 Lerna 版本计算的底层逻辑,以及如何解决令人头疼的 bad revision 'undefined' 错误——敬请期待。

当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

作者 guojb824
2026年4月14日 22:20

当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

摘要:本文结合 Vue3 $attrs 特性与桥接模式,详细解析如何优雅解耦虚拟滚动容器与复杂业务组件。通过拆分抽象与实现,实现属性事件无缝透传,告别臃肿代码。

在企业级前端开发中,长列表渲染是一个永远绕不开的性能瓶颈。在 Vue 技术栈中,我们经常会使用 vue3-virtual-scroll-list 这样的第三方库来实现虚拟滚动,从而保证页面在面对万条甚至十万条数据时依然如丝般顺滑。

但是,随着业务复杂度的提升,一个棘手的设计问题往往会浮出水面:如何在使用第三方虚拟滚动库时,优雅地实现基础组件与业务组件的解耦与隔离?

今天,我们就来详细拆解这个场景,并探讨在 Vue 3 下利用 $attrs 透传机制实现完美隔离的设计思路。

一、 场景痛点与需求分析

想象一下这样一个典型的开发场景:

你正在负责一个大型后台管理系统。项目中有多处需要用到虚拟滚动列表:有的是简单的文本日志列表,有的是复杂的商品卡片列表,还有的是带有各种交互按钮(点赞、删除、编辑)的用户评论列表。

为了复用代码,你决定封装一个基础虚拟滚动组件(VirtualScrollerBasic),它负责引入第三方库,设定预估高度。同时,你还需要一个基础列表项组件(ItemBasic),它负责最基本的数据渲染和样式布局。

但是,业务部门的需求是千变万化的:

  • 场景 A 的商品列表需要传入一个特殊的业务参数 customText 来显示促销信息。
  • 场景 B 的评论列表需要在点击时触发一个专属的业务事件 @customEvent
  • 场景 C 的日志列表需要在每一项的底部插入一段自定义的 DOM 结构(使用插槽)。

如果直接在基础组件里把这些业务参数和事件全部写死,基础组件就会变得无比臃肿,甚至最终沦为一个不可维护的“大泥球”。

我们的核心诉求是:基础列表和基础 Item 只关心自己该关心的事情(比如基础的布局、基础的数据 source),而业务列表和业务 Item 可以自由地增加属性、监听事件、甚至传递插槽,且这一切对基础组件来说必须是“无感”的。

二、 方案设计思路:桥接模式与职责分离

为了解决上述痛点,我们需要引入**桥接模式(Bridge Pattern)**的思想。

桥接模式的核心是“将抽象部分与实现部分分离,使它们都可以独立地变化”。在虚拟滚动的场景中:

  • 抽象部分(Abstraction):是列表的容器(如 VirtualScrollerBasic),负责虚拟滚动的核心机制、数据调度和预估高度计算。
  • 实现部分(Implementor):是具体的列表项渲染器接口,负责单条数据的 UI 展示和交互。

这两部分通过一个“桥梁”(即动态传入的 listComponent 属性)连接起来。在此基础上,业务组件只需要处理自己的业务逻辑,剩下的不属于自己范围的基础属性和事件,通过 Vue 3 的 $attrs(在组合式 API 中通过 useAttrs() 获取)完美透传给基础组件。

Vue 3 的 $attrs 有一个非常棒的特性:它不仅包含了外部传入的非 Props 属性,还包含了绑定的事件(自动转化为 onXxx 形式)。这为我们实现属性和事件的跨层透传提供了天然的便利。

设计架构图

classDiagram
    class VirtualScrollerBasic {
        +items: Array
        +listComponent: Component
        +basicText: String
        +render()
    }

    class VirtualScrollerList {
        +customText: String
        +handleCustomEvent()
        +render()
    }

    class ItemBasic {
        +index: Number
        +basicText: String
        +render()
    }

    class Item {
        +customText: String
        +handleEvent()
        +render()
    }

    VirtualScrollerBasic o-- ItemBasic : Bridge (通过 listComponent 桥接)
    VirtualScrollerBasic <|-- VirtualScrollerList : 扩展 (组合包裹)
    ItemBasic <|-- Item : 扩展 (组合包裹)

三、 Vue 3 下的代码实现

让我们来看看这套设计模式在 Vue 3 中是如何落地的。

1. 基础列表组件 (VirtualScrollerBasic.vue)

基础列表组件的职责是封装第三方库 vue3-virtual-scroll-list,并且负责将外部传入的 $attrs 整合后向下透传。

<template>
  <div class="virtual-scroller-container">
    <virtual-list
      class="virtual-list"
      :data-key="'id'"
      :data-sources="items"
      :data-component="listComponent"
      :estimate-size="50"
      :extra-props="{
        // 关键点1:合并透传所有外部传入的业务属性和业务事件(onXxx)
        ...attrs,
        // 关键点2:基础层私有的参数和事件,互不干扰
        basicText: '这是基础层参数',
        onBasicEvent: handleBasicEvent,
      }"
    >
      <template #header>
        <slot name="header"></slot>
      </template>
    </virtual-list>
  </div>
</template>

<script setup>
import { ref, useAttrs } from "vue";
import VirtualList from "vue3-virtual-scroll-list";
import ItemBasic from "./ItemBasic.vue";

const attrs = useAttrs();

const props = defineProps({
  listComponent: {
    type: Object,
    default: () => ItemBasic,
  },
});

// 关键点3:阻止属性直接绑定到根节点 div 上,防止 DOM 污染和事件重复触发
defineOptions({
  inheritAttrs: false,
});

const items = ref([
  /* 模拟数据 */
]);
const handleBasicEvent = (source) => {
  console.log("基础事件触发");
};
</script>

2. 基础 Item 组件 (ItemBasic.vue)

基础 Item 组件负责渲染列表项的最基本信息。它只关心基础的 UI 和数据结构,不知道任何关于业务层的特殊参数。

<template>
  <div class="basic-item">
    <div class="basic-content">
      <span>#{{ index }} - ID: {{ source.id }}</span>
      <span v-if="basicText" class="basic-text">({{ basicText }})</span>
      <button @click="handleClick">触发基础事件</button>
    </div>
    <!-- 留出插槽供业务层扩展 -->
    <slot name="footer"></slot>
  </div>
</template>

<script setup>
const props = defineProps({
  source: {
    type: Object,
    required: true,
  },
  index: {
    type: Number,
    default: 0,
  },
  basicText: {
    type: String,
    default: "",
  },
});

const emit = defineEmits(["basicEvent"]);

const handleClick = () => {
  emit("basicEvent", props.source);
};
</script>

<style scoped>
.basic-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
.basic-text {
  color: #888;
  margin-left: 10px;
}
</style>

3. 业务 Item 组件 (Item.vue)

业务 Item 组件的职责是拦截并消费属于业务层的属性(customText)和事件(customEvent),并将剩下的属性通过 v-bind 透传给基础 Item 组件。

<template>
  <div class="custom-item">
    <!-- 关键点1:v-bind="attrs" 将没被当前组件消费的属性和事件透传给基础组件 -->
    <item-basic v-bind="attrs" :source="source">
      <template #footer>
        <div class="custom-footer">
          自定义footer <span v-if="customText"> - {{ customText }}</span>
          <el-button type="primary" @click="handleEvent(source)">
            触发业务事件 customEvent
          </el-button>
        </div>
      </template>
    </item-basic>
  </div>
</template>

<script setup>
import { useAttrs } from "vue";
import ItemBasic from "./ItemBasic.vue";

const attrs = useAttrs();

// 关键点2:只声明业务层自己需要消费的属性。
// 如果业务层确实需要用到基础层的参数,就需要手动传给基础组件
const props = defineProps({
  source: { type: Object, required: true }, // 点击事件需使用,所以保留
  customText: { type: String, default: "" }, // 业务专属属性
});

const emit = defineEmits(["customEvent"]);

// 关键点3:同样需要阻止属性绑定到根节点
defineOptions({
  inheritAttrs: false,
});

const handleEvent = (source) => {
  // 触发业务层专属事件
  emit("customEvent", source);
};
</script>

4. 业务列表组件 (VirtualScrollerList.vue)

在最外层的业务列表中,我们就可以像使用普通组件一样,随心所欲地传递业务参数和监听业务事件了,底层的一切复杂透传对它来说都是透明的。

<template>
  <div class="virtual-scroller-list-wrapper">
    <virtual-scroller-basic
      :list-component="Item"
      :customText="'这是通过透传传入的业务参数 customText'"
      @customEvent="handleCustomEvent"
    >
      <template #header>
        <div class="custom-header">自定义业务 Header 内容</div>
      </template>
    </virtual-scroller-basic>
  </div>
</template>

<script setup>
import VirtualScrollerBasic from "./VirtualScrollerBasic.vue";
import Item from "./Item.vue";

const handleCustomEvent = (source) => {
  alert(`业务层成功拦截 customEvent,Item ID: ${source.id}`);
};
</script>

四、 Vue 3 与 Vue 2 的实现区别解析

如果你还在使用 Vue 2,或者刚从 Vue 2 迁移过来,可能会对上面的实现感到一些疑惑。这里有必要重点强调一下 Vue 3 和 Vue 2 在透传机制上的巨大差异。

Vue 2:属性与事件是分离的

在 Vue 2 中,组件的“属性”和“事件”是严格区分开的:

  • 传递的数据和非 Props 属性会被收集到 $attrs 中。
  • 通过 @v-on 绑定的事件会被收集到 $listeners 中。

所以在 Vue 2 中,如果你想把业务组件绑定的 @customEvent 透传给底层的 vue-virtual-scroll-listextra-props,你必须手动去遍历 $listeners,把它们转换成 onXxx 格式的函数,然后再和 $attrs 合并:

// Vue 2 下的 Hack 写法
computed: {
  mergedExtraProps() {
    const listenersAsProps = {};
    for (const eventName in this.$listeners) {
      const propName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
      listenersAsProps[propName] = this.$listeners[eventName];
    }
    return { ...this.$attrs, ...listenersAsProps };
  }
}

不仅如此,在 Vue 2 中,由于底层组件接收到的 extra-props 只能以 Props 的形式被子组件接收,为了让子组件能像普通组件一样响应 @事件,我们往往还需要引入一个中间包装组件(Wrapper),利用函数式组件将 onXxx 的 Props 重新还原为真正的 $listeners 并绑定到实际渲染的组件上。

例如,我们需要定义一个 VirtualScrollerItemWrapper.vue

<script>
// Vue 2 函数式组件 Wrapper
export default {
  name: "VirtualScrollerItemWrapper",
  functional: true,
  render(h, context) {
    const { props, data } = context;
    const originalComponent = props.originalComponent; // 真实的业务组件
    const attrs = {};
    const on = {};

    // 遍历 props,将 onXxx 还原为事件监听器
    for (const key in props) {
      if (key === "originalComponent") continue;

      if (key.startsWith("on") && typeof props[key] === "function") {
        const eventName = key.charAt(2).toLowerCase() + key.slice(3);
        on[eventName] = props[key];
      } else {
        attrs[key] = props[key];
      }
    }

    return h(originalComponent, {
      attrs,
      on, // 重新绑定事件
      scopedSlots: data.scopedSlots,
    });
  },
};
</script>

然后在基础滚动组件中,我们不能直接渲染业务组件,而是必须把这个 Wrapper 传给 vue-virtual-scroll-listdata-component 属性,并将实际的业务组件通过 extra-props 传进去:

<template>
  <virtual-list
    :data-key="'id'"
    :data-sources="items"
    :data-component="VirtualScrollerItemWrapper" <!-- 使用包装组件 -->
    :estimate-size="50"
    :extra-props="{
      ...mergedExtraProps,
      originalComponent: listComponent // 将真实的渲染组件传给包装器
    }"
  />
</template>

可以看到,在 Vue 2 中为了实现这一套隔离与透传机制,代码非常冗长且绕脑。

Vue 3:大一统的 $attrs

Vue 3 进行了一次非常优雅的底层重构。它移除了 $listeners 对象,将所有通过 @event 绑定的事件,在编译时自动转换成了以 onXxx 开头的属性名(例如 @custom-event 变成了 onCustomEvent),并且统一收集到了 $attrs

正因为 Vue 3 的这个特性,我们在 VirtualScrollerBasic 中只需要写一句 ...attrs,就同时完成了属性和事件的透传!这与 vue3-virtual-scroll-list 要求的 extra-props 接收对象的 API 设计简直是天作之合。

五、 运行效果与总结

当代码运行起来后,你会看到:

  1. 列表顶部正确渲染了“自定义业务 Header 内容”。
  2. 每一项都正确渲染了基础数据(如 #0)和基础参数(如 基础参数)。
  3. 每一项的 Footer 都正确渲染了业务参数 这是通过透传传入的业务参数 customText

image.png

  1. 点击“触发业务事件 customEvent”按钮,外层的业务列表组件成功弹出了 Alert 提示框,拦截到了事件。

image-1.png

image-2.png

总结:用到的设计模式

通过这次重构,我们实际上是在前端工程中落地了以下几种经典的设计模式:

  1. 桥接模式 (Bridge Pattern):这是本文的核心架构。将“虚拟滚动容器(抽象层)”与“列表项渲染(实现层)”彻底解耦。通过 listComponent 这一桥梁连接,使得业务列表可以随意更换基础滚动机制,业务项也可以在不同的列表中复用,两者独立变化。
  2. 装饰器模式 (Decorator Pattern) / 高阶组件模式 (HOC):业务 Item 没有去修改基础 ItemBasic 的内部代码,而是通过包裹对其进行了增强(增加了业务插槽和事件),并通过 $attrs 将基础属性完美透传。
  3. 模板方法模式 (Template Method Pattern)VirtualScrollerBasic 定义了虚拟滚动的算法骨架(如何引入库、设定高度等),具体的 UI 表现通过插槽和动态组件延迟到了业务层去实现。

优雅的架构设计,往往不需要多么高深的语法,而是对框架特性(如 $attrs)的深刻理解,以及对“单一职责”原则的坚守。希望这篇文章能为你在处理复杂 Vue 组件封装时带来一些启发!

NestJs--Prisma 7的安装与数据库配置(超完整)

作者 Ticnix
2026年4月14日 21:02

前言

在现代后端开发中,NestJS `凭借优雅的架构、TypeScript 强类型支持成为企业级 Node.js 框架首选;Prisma 7 作为新一代 ORM,彻底解决了传统数据库操作的类型不安全、语法繁琐问题。

本文从零到一,超完整讲解 NestJS 集成 Prisma 7 的全流程:安装、初始化、配置、CRUD 实战、连接池优化、生产环境配置,一步到位直接落地项目。

什么是 Prisma 7 ORM?

ORM(Object-Relational Mapping)的本质是把数据库表映射为代码中的对象,让开发者用面向对象方式操作数据库,不用手写原生 SQL。Prisma 7 不是传统 ORM 的 “升级版”,而是重构式新一代 ORM

  • 核心架构:完全抛弃 Rust 查询引擎,重写为纯 TypeScript 运行时 + 查询编译器,消除 JS 与 Rust 跨进程通信瓶颈,适配 Serverless / 边缘环境(Cloudflare Workers、Vercel Edge)Prisma
  • 工作流:Schema 优先(Schema-First) —— 用 Prisma Schema 定义数据模型 → 自动生成类型安全的 Prisma Client → 用 Client 做 CRUD → 自动生成数据库迁移 → 可视化管理数据
  • 核心价值:全程 TypeScript 类型安全、零冗余 SQL、版本化迁移、极简 API、全环境兼容

ORM 说白了就是:不用写复杂的数据库 SQL 语句,用写代码的方式操作数据库

你可以把它理解成:

  • 一个自动帮你建表、改表的工具
  • 一个自动帮你写 SQL的工具
  • 一个自带智能提示、几乎不会写错代码的工具
  • 一个自带可视化面板,能直接看库里数据的工具

安装教程

一、环境准备

开始前确保本地已安装必备环境:

  1. Node.js:v18.18.0+ /v20+(LTS 版本)
  2. 包管理器:npm /yarn/pnpm(本文以 npm 为例)
  3. 数据库:MySQL / PostgreSQL / SQLite

二、创建 NestJS 项目

如果没有NestJS 项目,首先初始化一个全新的 NestJS 项目:

# 创建项目 
npx create-next-app@latest 你的项目名称

# 进入项目目录 
cd 你的项目名称

等待依赖安装完成,项目基础结构就搭建好了。

三、安装 Prisma

Prisma 7 分为两部分:

  • prisma:开发依赖,用于生成客户端、执行迁移
  • @prisma/client:生产依赖,项目中操作数据库的核心
# 安装 Prisma 7(开发依赖) 
npm install prisma --save-dev

# 安装 Prisma Client(生产依赖) 
npm install @prisma/client --save

这个prisma依赖安装可能会需要一些时间

四、初始化 Prisma

在 Nest 项目中初始化 Prisma,自动生成配置文件:

npx prisma init

初始化成功后,项目根目录会生成:

  • prisma 文件夹

    • schema.prisma:Prisma 核心配置文件(数据源、模型、生成器)
  • .env 环境变量文件(如果没有这个文件可以手动添加,注意要跟src在同级目录)

    • 存储数据库连接地址
  • prisma.config.ts 配置文件

    • 指定 schema.prisma 文件路径

    • 配置种子文件、格式化、生成器等全局选项

    • prisma 命令自动读取配置,不用每次手动传参

image.png

五、配置 Prisma Schema

1. 配置数据源(database)

打开 prisma/schema.prisma,这是 Prisma 的核心文件,打开会发现系统已经自动生成好了基础的配置,只需按照自己的需求调整一下就好了,在datasource db中选择需要的数据类型,本文用postgresql作为演示。

image.png

这是 Prisma 数据库工具 的两个核心配置说明(辅助大家理解每一个配置的含义):

1. generator client

作用:告诉 Prisma 如何生成「Prisma Client」—— 这是你在代码里用来操作数据库的 TypeScript/JavaScript 客户端库。

  • 关键字段说明

1、provider = "prisma-client":指定使用官方的 Prisma Client 生成器(唯一官方选项)。

2、output = "../src/generated/prisma":自定义生成的客户端代码的输出路径(默认是 ./node_modules/.prisma/client,这里改到了项目源码目录,方便本地开发调试)。

  • 执行时机:运行 npx prisma generate 命令时,Prisma 会根据这个配置,基于你定义的数据模型,生成类型安全的数据库操作代码
2. datasource db

作用:告诉 Prisma 你要连接的数据库类型和连接信息,是 Prisma 与数据库通信的入口。

  • 关键字段说明

provider = "postgresql":指定数据库类型,支持 mysqlpostgresqlsqlitesqlservermongodb 等(图中标注了常见的 3 种)。

  • 执行时机:所有 Prisma 命令(prisma migrate devprisma db pushprisma studio 等)都会读取这个配置,来连接目标数据库。

注意:这里不要加url(从环境变量读取数据库连接字符串)这个配置,不然会报错,Prisma 7的url配置已经在 prisma.config.ts 里自动配置好了,不需要再手动配置,这是安装Prisma 7依赖时自动生成的,如果没有这个文件的同学可以按照我这个配置手动添加上去。

image.png

2. 修改 .env 数据库连接地址

打开根目录 .env 文件,修改为 PostgreSQL 配置:

# PostgreSQL 配置
# DATABASE_URL="postgresql://用户名:密码@localhost:5432/数据库名?schema=public"

# SQLite 配置(备用) 
DATABASE_URL="file:./dev.db" 

# MySQL 配置(备用) 
# DATABASE_URL="mysql://用户名:密码@localhost:3306/数据库名?schema=public" 

3. 创建数据模型(Model)

我们以最常用的 User 表为例,在 schema.prisma 末尾添加模型:

image.png

模型写完后,后续所有数据库操作都有完整的 TypeScript 类型提示

六、生成 Prisma Client

模型定义完成后,生成 TypeScript 客户端:

npx prisma generate

执行成功后,@prisma/client 会自动生成对应模型的类型和方法,直接在代码中调用。 注意:每次修改了数据模型以后,都需要重新运行这个命令,及时更新客户端代码。

七、创建 Prisma 模块与服务(Nest 标准写法)

Nest 推崇模块化,我们需要创建一个 Prisma 全局模块,方便整个项目调用。 (这一步不一定按照我的写法,自己想怎么调用就怎么调用就行,这里只是给个例子,也跟后续的数据库连接无关)

1. 创建 Prisma Service

创建文件 src/prisma/prisma.service.ts

import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  // 模块初始化时连接数据库
  async onModuleInit() {
    await this.$connect();
  }

  // 模块销毁时断开连接
  async onModuleDestroy() {
    await this.$disconnect();
  }
}

2. 创建 Prisma Module

创建文件 src/prisma/prisma.module.ts

import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

// 声明为全局模块,无需在其他模块重复导入
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService], // 导出服务,供全项目使用
})
export class PrismaModule {}

3. 注册到根模块

打开 src/app.module.ts,导入 Prisma 模块:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';

@Module({
  imports: [PrismaModule], // 注册 Prisma 全局模块
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

至此,Prisma 7 在 NestJS 中配置完成

八、数据库迁移(创建表)

我们定义的 User 模型需要同步到数据库,执行迁移命令:

# 生成迁移文件并同步到数据库 
npx prisma migrate dev --name init

执行成功后:

  1. prisma/migrations 生成版本化迁移文件(可追踪表结构变更)
  2. 数据库自动创建 User
  3. PostgreSQL 生成 dev.db 文件

小技巧:执行 npx prisma studio 可打开可视化数据库工具,直接查看 / 编辑数据。

image.png

可能出现的错误

image.png 说明你的 Docker PostgreSQL 容器没在运行,或者端口没通,Prisma 连不上数据库 所以迁移的时候别忘检查一下Docker 里的 你的项目名称-db 容器启动没,确保打开以后再运行一遍迁移命令

image.png

也可以用命令查看Docker的运行状态

1、打开 CMD / PowerShell,执行:

docker ps
  • 如果看不到 ai-engine-db 容器,说明容器没运行

  • 如果能看到,但状态不是 Up,说明容器异常

  1. 启动容器(如果没运行)
# 启动之前创建的容器 
docker start ai-engine-db
  1. 再次检查状态
docker ps

具体操作过程如下:

image.png

看到 ai-engine-db 状态为 Up,端口 0.0.0.0:5432->5432/tcp,说明数据库正常运行了

容器启动后,回到项目终端,重新执行迁移命令:

npx prisma migrate dev --name init

成功后会看到:

Your database is now in sync with your schema.

直到没有新的数据需要迁移

image.png 补充:如果容器被误删,重新创建

docker run -d --name ai-engine-db -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=123456 -e POSTGRES_DB=ai_engine pgvector/pgvector:pg16

一键重建数据库容器,然后再执行迁移命令。

排查完这些问题

Prisma 7的安装与数据库配置就已经全部成功了

从浏览器到 Node.js,这一次彻底搞懂 Event Loop 与异步模型

作者 San30
2026年4月14日 20:53

引言

很多前端同学在向全栈(BFF层)或者 Node.js 进阶时,都会遇到一个绕不开的坎——Event Loop(事件循环)

面试时,面对一段穿插着 setTimeoutPromiseasync/await 甚至 process.nextTick 的代码,往往容易被绕晕。更别提浏览器和 Node.js 在事件循环的底层实现上还有着本质的区别。

本文将结合我个人的工程经验,带你从零开始,由浅入深地拆解 Event Loop。我们不仅要会做面试题,更要知道这种异步非阻塞的模型,为什么能让 Node.js 在服务器端扛住成千上万的并发。

一、 为什么我们需要 Event Loop?

JavaScript 诞生之初是作为浏览器的脚本语言,为了避免复杂的 DOM 渲染冲突,它被设计成了单线程。也就是说,同一时间只能干一件事。

但是,网页中有大量需要等待的任务:网络请求(Ajax)、定时器、图片加载。如果所有的操作都是同步阻塞的,用户点一个按钮发起请求,整个页面就会卡死,直到请求返回。

为了解决这个问题,消息队列(Message Queue) + Event Loop 诞生了。

它的核心思想是:把耗时的任务先扔到一边(交给宿主环境如浏览器或操作系统的其他线程处理),主线程继续飞速往下跑。等那些耗时任务有了结果,再通知主线程来执行回调。

二、 浏览器的 Event Loop:宏任务与微任务的交响乐

在浏览器的一次工作中,JS 的执行是从一个 script 宏任务开始的。当同步代码执行完后,会产生两种不同的异步任务:宏任务(Macrotask)微任务(Microtask)

1. 任务分类

  • 宏任务队列setTimeoutsetInterval、事件绑定回调、Ajax 回调等。
  • 微任务队列Promise.then/catch/finallyasync/await 的后续代码、queueMicrotask、以及前端特有的 DOM 监听类微任务 MutationObserver

2. 执行机制(核心运转规律)

浏览器的 Event Loop 遵循以下严格的顺序:

  1. 执行并清空当前宏任务(一开始是整个 script 标签内的同步代码)。
  2. 清空整个微任务队列(如果执行微任务时又产生了新的微任务,会继续在当前阶段清空)。
  3. 检查是否需要进行页面渲染(GUI 渲染线程介入,重排重绘)。
  4. 开始下一轮 Event Loop,取出一个新的宏任务执行。

3. 终极实战拆解

来看一段经典的测试代码:

console.log('同步代码 1');

setTimeout(() => {
    console.log('setTimeout 1');
    Promise.resolve().then(() => {
        console.log('setTimeout 1 内部微任务');
    });
}, 0);

const promise1 = new Promise((resolve) => {
    console.log('Promise 构造函数');
    resolve();
    console.log('Promise 构造函数内 resolve 后');
});

promise1.then(() => {
    console.log('Promise.then 1');
    setTimeout(() => {
        console.log('Promise.then 1 内部 setTimeout');
    }, 0);
});

async function asyncFn() {
    console.log('async 函数同步部分');
    await Promise.resolve(); // 异步变同步的语法糖
    console.log('await 后微任务');
}

asyncFn();

console.log('同步代码 2');

queueMicrotask(() => {
    console.log('queueMicrotask 微任务');
});

// 前端特有微任务
const observer = new MutationObserver(() => {
    console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1'); 

执行脉络分析:

  1. 同步代码一路推平

    先打印 同步代码 1。遇到 setTimeout 放入宏任务队列。遇到 new Promise(注意:构造函数内部是同步执行的),依次打印 Promise 构造函数Promise 构造函数内 resolve 后,并将它的 .then 推入微任务队列。遇到 asyncFn 执行,打印 async 函数同步部分,并将 await 后的代码推入微任务队列。接着打印 同步代码 2。最后触发 MutationObserver 进入微任务队列。

  2. 第一波微任务清空

    依次打印 Promise.then 1await 后微任务queueMicrotask 微任务MutationObserver 微任务。需要注意的是,在此执行期间,Promise.then 1 内部产生了一个新的 setTimeout,它会被放入宏任务队列等待。

  3. 开启下一轮宏任务

    拿出首个宏任务 setTimeout 1 执行并打印,同时将其内部的 Promise 推入微任务队列。当前宏任务结束后,立刻清空刚刚产生的微任务,打印 setTimeout 1 内部微任务

  4. 最后的宏任务

    执行剩余的宏任务,打印 Promise.then 1 内部 setTimeout

三、 Node.js 的 Event Loop:更复杂的阶段调度

如果你觉得浏览器的 Event Loop 已经懂了,那来到 Node.js 的世界,你需要暂时放下前面的“偏见”。

相比于浏览器主要处理 DOM 和交互,Node.js 运行在服务器端,需要处理大量的文件 I/O、网络请求、数据库连接。因此,Node.js 的事件循环基于 libuv 库,被划分为多个阶段(Phases)

1. Node.js 事件循环的 6 大阶段

在每次循环中,Node.js 会按顺序经过以下核心阶段(我们主要关注标粗的三个):

  1. Timers(定时器阶段) :执行 setTimeoutsetInterval 的回调。
  2. Pending Callbacks:执行系统级别操作的回调(如 TCP 错误)。
  3. Idle, Prepare:内部使用。
  4. Poll(轮询阶段) :检索新的 I/O 事件,执行与 I/O 相关的回调(比如读取文件、网络请求返回)。这是 Node.js 最重要的阶段。
  5. Check(检查阶段) :专门执行 setImmediate 的回调。
  6. Close Callbacks:执行关闭资源的回调。

2. Node 中的“特权”微任务

在 Node.js 中,微任务不仅有 Promise,还有一个拥有绝对特权的 VIP:process.nextTick

  • 触发时机:同步代码执行完后、或者每个阶段完成后、甚至在 Node 11+ 版本中每个回调执行完后,都会立刻去检查并清空微任务队列。
  • 优先级process.nextTick 的优先级永远高于 Promise

3. 核心实战:I/O 内部的执行顺序反转

这是面试中最容易挂掉的一道题,也是理解 Node.js 调度的分水岭:

const fs = require('fs')

console.log('start')

setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})

fs.readFile(__filename, () => {
  console.log('readFile')
  
  setTimeout(() => {
    console.log('timeout in I/O')
  }, 0)

  setImmediate(() => {
    console.log('immediate in I/O')
  })
})

Promise.resolve().then(() => { console.log('promise') })
process.nextTick(() => { console.log('nextTick') })
console.log('end')

深度拆解:为什么在 I/O 里 setImmediate 永远比 setTimeout 先执行?

  1. 同步先行:打印 startend。注册各个异步任务。

  2. 清空首次微任务:先看 VIP,打印 nextTick,再看 Promise,打印 promise

  3. 进入事件循环

    • Timers 阶段setTimeout(..., 0) 到期,打印 timeout
    • Poll 阶段:此时文件可能还没读完,跳过。
    • Check 阶段:执行外层的 setImmediate,打印 immediate
  4. I/O 改变战局

    • fs.readFile 完成,它的回调会在 Poll 阶段执行!打印 readFile
    • 在回调内部,又注册了一个 setTimeout 和一个 setImmediate
    • 划重点:我们现在处于 Poll 阶段!Event Loop 顺时针往下转,下一个阶段是谁?是 Check 阶段
    • 所以,刚刚注册的 setImmediate 会在接下来的 Check 阶段被立刻执行(打印 immediate in I/O)。
    • 而那个 setTimeout 怎么办?它只能苦苦等待这一轮循环跑完,在下一轮的 Timers 阶段才能被执行(打印 timeout in I/O)。

四、 核心对比:浏览器 vs Node.js

特性 浏览器 (HTML5标准) Node.js (基于 libuv)
底层驱动 浏览器内核 (V8 + GUI等) V8引擎 + libuv
任务模型 宏任务 -> 微任务 -> 渲染 划分为 6 个阶段,按阶段推进
微任务清空时机 每个宏任务结束后 早期为每个阶段结束,Node 11+ 后与浏览器一致,每个回调结束后
特有 API MutationObserver, requestAnimationFrame process.nextTick, setImmediate
微任务优先级 正常队列 (Promise, queueMicrotask) process.nextTick 绝对优先于 Promise

六、 总结

1. 单线程高并发的秘密

相比于 Java、Go 传统的多线程阻塞模型,Node.js 借助事件循环实现了异步非阻塞 I/O。这意味着,当 Node.js 处理网络请求、查询 MySQL/PostgreSQL 数据库、或者读写文件时,线程不会卡在那里等待。它会把任务扔给底层,立刻切回去处理下一个用户的 HTTP 请求。

这种特性,使得服务器开销极低,少量线程就能扛住成千上万的并发连接。

无论你是沉浸在 Vue/React 的前端开发者,还是在使用 Nestjs 探索后端的全栈工程师,深刻理解 Event Loop 都是一次思维的跨越:

  1. 在前端,你要关注宏任务和微任务的交替,警惕长任务阻塞渲染导致的页面掉帧。
  2. 在 Node.js,你要关注各个阶段(Timers、Poll、Check)的流转,善用异步流和缓冲,发挥其高并发 I/O 的优势。

前端项目K8S配置

作者 opteOG
2026年4月14日 20:23
  1. deploy/base
    放通用基础资源:ServiceDeploymentHPA,不区分环境。
  2. deploy/overlays/{dev|release|prod}
    每个环境只写差异项(补丁),通过 patchesStrategicMerge 覆盖 base。
  3. deploy/jobs-pre/base
    放一次性 Job 的通用配置(同步脚本任务)。
  4. deploy/jobs-pre/overlays/{dev|release|prod}
    各环境对 Job 的差异化补丁(命名空间、镜像拉取密钥、环境标签)。

一、应用服务配置(release 环境,按实际顺序)

1) base/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - service.yaml
  - deployment.yaml
  - autoscaling.yaml

namespace: default

关键含义:

  • resources 定义基础资源装配顺序:先服务、再部署、再弹性伸缩。
  • namespace: default 是 base 默认值,后续会被 release overlay 覆盖。

2) base/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: <app-name>
  labels:
    name: <app-name>
    app: <app-name>
    language: js
spec:
  ports:
    - port: 80
      name: http
      targetPort: 80
      protocol: TCP
      appProtocol: http
  selector:
    app: <app-name>

关键含义:

  • selector.app 决定流量转发到哪些 Pod。
  • port/targetPort 表示集群内访问 80,转发到容器 80。
  • appProtocol: http 便于网关/观测系统识别协议。

3) base/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: <app-name>
spec:
  strategy:
    rollingUpdate:
      maxSurge: 20%
      maxUnavailable: 20%
    type: RollingUpdate
  replicas: 1
  selector:
    matchLabels:
      app: <app-name>
  template:
    metadata:
      labels:
        app: <app-name>
        service-type: assets
        language: js
    spec:
      securityContext:
        runAsUser: 0
      hostAliases:
        - ip: "<fixed-ip>"
          hostnames:
            - "<external-domain>"
      containers:
        - name: assets
          image: imageName
          readinessProbe:
            tcpSocket:
              port: 80
            initialDelaySeconds: 20
            periodSeconds: 10
          livenessProbe:
            tcpSocket:
              port: 80
            initialDelaySeconds: 20
            periodSeconds: 10
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: 800m
              memory: 512Mi
          ports:
            - containerPort: 80
              name: http
          volumeMounts:
            - mountPath: /data/app/platform-admin-nfs
              name: nfs
      volumes:
        - name: nfs
          persistentVolumeClaim:
            claimName: platform-admin-nfs

关键含义(代码级):

  • rollingUpdate:平滑发布策略,避免全量中断。
  • selector.matchLabelstemplate.labels 必须匹配,否则 Deployment 无法管理 Pod。
  • readinessProbelivenessProbe 分别控制“是否接流量”和“是否重启修复”。
  • resources.requests/limits 影响调度与资源上限,避免 Pod 抢占失控。
  • hostAliases 是容器内静态 hosts 映射,通常用于固定解析。
  • volumeMounts + PVC 把共享存储挂进容器,适合静态资源同步场景。

4) base/autoscaling.yaml

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: <app-name>-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: <app-name>
  minReplicas: 1
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

关键含义:

  • scaleTargetRef 指向要扩缩容的 Deployment。
  • averageUtilization: 70 表示 CPU 均值目标 70%,超出倾向扩容。
  • min/maxReplicas 控制弹性边界。

5) overlays/release/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

patchesStrategicMerge:
  - deployment.yaml
  - service.yaml
  - autoscaling.yaml

namespace: release

关键含义:

  • resources: ../../base 引用基础模板。
  • patchesStrategicMerge 按资源类型做“局部覆盖”。
  • namespace: release 把整套资源落到 release 命名空间。

6) overlays/release/deployment.yaml(补丁)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: <app-name>
spec:
  selector:
    matchLabels:
     env: release
  template:
    metadata:
      labels:
        env: release
    spec:
      imagePullSecrets:
        - name: <registry-secret>
      containers:
        - name: assets
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: 800m
              memory: 512Mi

关键含义:

  • 增加 env: release 标签,做环境隔离。
  • imagePullSecrets 指定私有镜像仓库凭据。
  • 可在此覆盖 base 的资源限制(当前值与 base 一致,便于后续单独调优)。

7) overlays/release/service.yaml(补丁)

apiVersion: v1
kind: Service
metadata:
  name: <app-name>
spec:
  selector:
    env: release

关键含义:

  • 给 Service selector 增加环境维度,确保只路由到 release Pod。
  • 与 base 的 app selector 合并后,形成更精确匹配。

8) overlays/release/autoscaling.yaml(补丁)

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: <app-name>-hpa
spec:
  minReplicas: 1

关键含义:

  • 覆盖最小副本数,按环境控制基础容量。

二、预处理任务配置(release 环境,按实际顺序)

1) jobs-pre/base/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - job-assets-sync.yaml

namespace: default

2) jobs-pre/base/job-assets-sync.yaml

apiVersion: batch/v1
kind: Job
metadata:
  name: <app-name>-sync
spec:
  completions: 1
  parallelism: 1
  backoffLimit: 0
  ttlSecondsAfterFinished: 1800
  template:
    metadata:
      labels:
        job-name: <app-name>-sync
    spec:
      restartPolicy: Never
      securityContext:
        runAsUser: 0
      containers:
        - name: sync
          image: imageName
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          command:
            - /bin/sh
            - -c
            - /app/script/assets-sync.sh
          volumeMounts:
            - mountPath: /data/app/platform-admin-nfs
              name: nfs
      volumes:
        - name: nfs
          persistentVolumeClaim:
            claimName: platform-admin-nfs

关键含义(代码级):

  • completions: 1 + parallelism: 1:单次串行执行。
  • backoffLimit: 0:失败不自动重试,便于快速暴露问题。
  • ttlSecondsAfterFinished:完成后自动回收 Job 资源。
  • command:入口脚本就是同步任务本体。
  • PVC:任务和应用共享存储,常见于资源预同步。

3) jobs-pre/overlays/release/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

patchesStrategicMerge:
  - job-assets-sync.yaml

namespace: release

4) jobs-pre/overlays/release/job-assets-sync.yaml(补丁)

apiVersion: batch/v1
kind: Job
metadata:
  name: <app-name>-sync
spec:
  template:
    metadata:
      labels:
        env: release
    spec:
      imagePullSecrets:
        - name: <registry-secret>
      containers:
        - name: sync
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

关键含义:

  • 同样通过 env: release 与镜像仓库凭据做环境隔离。
  • Job 的资源也可独立于应用服务调优。

三、这套配置的关键机制(总结)

  • base 负责“公共能力”,overlay 负责“环境差异”,降低重复配置。
  • Service selector + Pod labels 的一致性是流量路由正确的前提。
  • HPAresources 要配套,不然扩缩容效果会失真。
  • JobDeployment 分离是合理设计:一个常驻服务,一个一次性流程。
  • 通过 namespace + env label + imagePullSecrets 三件套实现环境隔离与发布安全。

CDP、Puppeteer 与无头浏览器:它们到底什么关系?

作者 Bacon
2026年4月14日 20:09

一分钟速览

概念 类比 角色
无头浏览器 一台"看不见屏幕"的电脑 运行环境 / 硬件
CDP 电脑的控制接口(USB / 串口协议) 通信协议
Puppeteer 你写的自动化脚本 / 遥控器 App 高层 SDK

1. 什么是无头浏览器(Headless Browser)

无头浏览器指没有图形界面 (GUI) 的浏览器,它拥有完整的浏览器引擎(HTML 解析、CSS 渲染、JS 执行),但不会在屏幕上绘制任何窗口。

chrome --headless --disable-gpu https://example.com

典型用途:

  • 自动化测试(截图、E2E 测试)
  • 爬虫 / 数据抓取
  • 服务端渲染 SSR 预渲染
  • 生成 PDF / 截图
  • Agent 的浏览器工具调用

常见实现:Chrome/Chromium headless、Firefox headless(历史上还有 PhantomJS,已停维护)。


2. 什么是 CDP(Chrome DevTools Protocol)

CDP 是 Chromium 团队定义的一套用于程序化控制浏览器的通信协议,本质是一套基于 WebSocket + JSON-RPC 的 API 集合。

你在 Chrome DevTools(F12)里做的一切——查看 DOM、网络请求、调试 JS、截图——背后都是 CDP 在驱动。

WS 消息示例(发送):
{
  "id": 1,
  "method": "Page.navigate",
  "params": { "url": "https://example.com" }
}

WS 消息示例(响应):
{
  "id": 1,
  "result": { "frameId": "...", "loaderId": "..." }
}

CDP 核心域(Domain):

Domain 能力
Page 页面导航、截图、PDF
Network 拦截请求、修改 Header
DOM 查询 / 操作 DOM 节点
Runtime 执行任意 JS、获取返回值
Input 模拟鼠标点击、键盘输入
Target 多标签页 / 多 iframe 管理

3. 什么是 Puppeteer

Puppeteer 是 Google 官方出品的 Node.js 库,它封装了 CDP 的所有细节,暴露出人类友好的 API。

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://example.com');
  const title = await page.title();
  console.log(title);
  await browser.close();
})();

一行 page.goto() 背后,Puppeteer 帮你发了十几条 CDP 命令。


4. 三者关系:分层架构图

graph TB
    subgraph USER["👨‍💻 开发者代码层"]
        A["你的 Node.js / Python 代码<br/>业务逻辑、Agent 工具调用"]
    end

    subgraph SDK["📦 高层 SDK 层"]
        B["Puppeteer"]
        B2["Playwright"]
        B3["selenium-webdriver<br/>(通过 WebDriver 协议)"]
    end

    subgraph PROTOCOL["🔌 协议层"]
        C["CDP\nChrome DevTools Protocol\nWebSocket + JSON-RPC"]
    end

    subgraph BROWSER["🌐 浏览器层"]
        D["Chrome / Chromium 内核"]
        E["无头模式\nHeadless"]
        F["有头模式\nHeaded(可见窗口)"]
    end

    A --> B
    A --> B2
    A --> B3
    B -->|"封装 CDP 调用"| C
    B2 -->|"封装 CDP 调用"| C
    B3 -->|"WebDriver 协议\n(另一套协议)"| D
    C -->|"WebSocket 通信"| D
    D --> E
    D --> F

    style USER fill:#dbeafe,stroke:#3b82f6
    style SDK fill:#d1fae5,stroke:#10b981
    style PROTOCOL fill:#fef3c7,stroke:#f59e0b
    style BROWSER fill:#fce7f3,stroke:#ec4899

层级关系: 无头浏览器是运行环境,CDP 是控制协议,Puppeteer 是对 CDP 的高层封装。


5. 一次页面访问的时序图

page.goto('https://example.com') 为例,看看底层发生了什么。

sequenceDiagram
    autonumber
    participant User as 开发者代码
    participant PP as Puppeteer
    participant WS as WebSocket 连接
    participant CDP as CDP 协议层
    participant Chrome as Chrome (无头)
    participant Net as 网络 / DNS

    User->>PP: page.goto('https://example.com')
    PP->>PP: 内部构建 CDP 命令

    PP->>WS: 发送 Page.navigate 消息
    WS->>CDP: JSON-RPC: { method: "Page.navigate", params: {url} }
    CDP->>Chrome: 触发导航

    Chrome->>Net: DNS 解析 + TCP 握手 + TLS
    Net-->>Chrome: 建立连接
    Chrome->>Net: 发送 HTTP GET 请求
    Net-->>Chrome: 返回 HTML 响应

    Chrome->>Chrome: HTML 解析 → DOM 树
    Chrome->>Chrome: CSS 解析 → CSSOM
    Chrome->>Chrome: JS 执行(同步脚本)
    Chrome->>Chrome: 触发 DOMContentLoaded

    CDP-->>WS: 事件推送: Page.loadEventFired
    WS-->>PP: 接收事件,Promise resolve
    PP-->>User: goto() 完成,返回 Response

6. CDP 连接建立时序图

Puppeteer launch() 时,如何与 Chrome 建立 CDP 连接。

sequenceDiagram
    autonumber
    participant PP as Puppeteer
    participant OS as 操作系统
    participant Chrome as Chrome 进程
    participant WS as WebSocket

    PP->>OS: 启动子进程: chrome --headless --remote-debugging-port=9222
    OS->>Chrome: 创建 Chrome 进程
    Chrome-->>OS: 监听 9222 端口,打印 WS 调试地址

    PP->>Chrome: HTTP GET /json/version
    Chrome-->>PP: 返回 { webSocketDebuggerUrl: "ws://localhost:9222/..." }

    PP->>WS: 建立 WebSocket 连接到调试地址
    WS-->>PP: 连接建立成功

    PP->>WS: 发送 Target.getTargets
    WS-->>PP: 返回已有标签页列表

    PP->>WS: 发送 Target.createTarget(新建标签页)
    WS-->>PP: 返回 targetId

    PP-->>PP: 封装为 Page 对象,供用户使用

7. 核心能力对比

quadrantChart
    title 浏览器自动化工具能力对比
    x-axis 学习曲线低 --> 学习曲线高
    y-axis 能力弱 --> 能力强
    quadrant-1 专家工具
    quadrant-2 首选工具
    quadrant-3 入门工具
    quadrant-4 高风险区
    Puppeteer: [0.35, 0.72]
    Playwright: [0.40, 0.88]
    CDP 原生: [0.80, 0.95]
    Selenium: [0.55, 0.55]
    PhantomJS: [0.30, 0.30]

8. Puppeteer vs 直接用 CDP

flowchart LR
    subgraph RAW["直接使用 CDP(原始方式)"]
        R1["手动管理 WebSocket"]
        R2["手动序列化 JSON 命令"]
        R3["手动等待事件"]
        R4["手动管理多标签页"]
        R5["需要熟记每个 Domain 命令"]
        R1 --> R2 --> R3 --> R4 --> R5
    end

    subgraph PPT["使用 Puppeteer(推荐)"]
        P1["puppeteer.launch()"]
        P2["browser.newPage()"]
        P3["page.goto(url)"]
        P4["page.click(selector)"]
        P5["page.screenshot()"]
        P1 --> P2 --> P3 --> P4 --> P5
    end

    RAW -- "Puppeteer 帮你封装了这些" --> PPT

9. 典型使用场景流程图

flowchart TD
    Start([需要自动化浏览器?]) --> Q1{是否需要\n真实浏览器渲染?}

    Q1 -- 否 --> Axios["使用 axios / fetch\n直接 HTTP 请求更简单"]
    Q1 -- 是 --> Q2{是否需要\n可见界面调试?}

    Q2 -- 是,开发阶段 --> Headed["headless: false\n有头模式,肉眼观察"]
    Q2 -- 否,生产环境 --> Headless["headless: true\n无头模式,服务端运行"]

    Headed --> Q3{用哪个库?}
    Headless --> Q3

    Q3 -- 简单任务 --> Puppeteer2["Puppeteer\n(Google 维护,API 简洁)"]
    Q3 -- 多浏览器兼容 --> Playwright2["Playwright\n(微软维护,支持 Firefox/Safari)"]
    Q3 -- 精细控制 --> CDP2["直接操作 CDP\n(需要深度定制时使用)"]

    Puppeteer2 --> Done["完成自动化任务 🎉"]
    Playwright2 --> Done
    CDP2 --> Done

10. 生态关系图

graph LR
    subgraph Google["Google 生态"]
        Chromium["Chromium 开源浏览器"]
        CDP["CDP 协议"]
        Puppeteer3["Puppeteer"]
        Chromium --> CDP
        CDP --> Puppeteer3
    end

    subgraph Microsoft["Microsoft 生态"]
        Playwright3["Playwright"]
        Playwright3 -->|"复用 CDP"| CDP
        Playwright3 -->|"Firefox Protocol"| FF["Firefox"]
        Playwright3 -->|"WebKit Protocol"| Safari["WebKit/Safari"]
    end

    subgraph W3C["W3C 标准"]
        WebDriver["WebDriver 协议\n(W3C 标准)"]
        Selenium3["Selenium"]
        WebDriver --> Selenium3
    end

    subgraph Agent["AI Agent 工具"]
        BrowserUse["browser-use"]
        LangChain["LangChain Browser Tool"]
        BrowserUse -->|"底层使用"| Playwright3
        LangChain -->|"底层使用"| Puppeteer3
    end

    style Google fill:#e0f2fe
    style Microsoft fill:#e8f5e9
    style W3C fill:#fff8e1
    style Agent fill:#f3e5f5

11. 一句话总结

无头浏览器  是一台"无屏幕的 Chrome"
     ↑
    CDP     是它暴露的"远程控制接口(协议)"
     ↑
 Puppeteer  是对 CDP 的"人性化封装库"
     ↑
 你的代码   调用 Puppeteer 实现自动化 / AI Agent 工具

12. 常见误区澄清

误区 正确理解
Puppeteer = 无头浏览器 ❌ Puppeteer 是库,无头浏览器是 Chrome
无头模式性能更好 ✅ 省去 GPU 渲染管线,内存和 CPU 更低
CDP 只有 Puppeteer 能用 ❌ Playwright、DevTools、各种调试工具都用 CDP
Headless Chrome 和普通 Chrome 行为不同 ⚠️ 部分 CSS / JS 行为有细微差异,需测试覆盖
Puppeteer 只能跑 Chrome ✅ 是的(官方支持 Chromium 和 Edge),跨浏览器用 Playwright

参考资料

❌
❌