普通视图

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

稚晖君最新机器人「凌空飞踢」震撼全网!智元远征 A3:我要一个打十个

作者 莫崇宇
2026年2月13日 23:17

二十年前,周星驰在《功夫》里用一个满脸淤青的阿星告诉我们:真正的绝世高手,往往藏在市井之间,只待任督二脉被打通的那一刻。

二十年后,这位万中无一的武学奇才再次出现了。不过,它不是人。

就在刚才,智元机器人在视频号上甩出了一段名为「绝世高手,马上下山」的视频。官方还特意标注了全程实景实拍,没用 CG 特效,也不是 AI 创作。

视频画面开头,名为智元远征 A3 的人形机器人在练功房里来了个干脆利落的凌空飞踹,不仅踢碎了玻璃,踢开了马年的新春大门,估计也踢醒了不少人对国产机器人「腿脚不利索」的刻板印象。

视频里的 A3 起手就是升龙拳,紧接着连续飞踢,空中漫步,甚至还有个极高难度的「摸地旋风」。

做过机器人的都知道,让人形机器人走稳已经是工程学上的一大难题。作为电驱全尺寸人形机器人,远征 A3 能做出如此高动态、高爆发且连贯的动作,说明它的运动控制算法和瞬时功率输出已经上了个新台阶。

更重要的是,这种武打演员级的表现力也解决了一个长期困扰行业的痛点:机器人的动作「僵硬感」。

▲网友的评论

A3 这套连招打下来,全身协调性一目了然,官方说,这得益于它的全自由度柔性腰部,1:1 还原了人体腰部的活动范围。

再加上轻量化的腿部外骨骼结构,这台机器人的 TCP 末端速度最高能达到 2m/s。说人话就是:它挥拳的速度,可能比你反应的速度还快。

当然,也稍微有点遗憾,这次视频里并没有看到灵巧手的具体演示。

而如果远征 A3 光会耍帅,那它充其量也只是一个昂贵的大号玩具。这就不得不提这次最大的实用升级:续航。几个月前,他们的前代产品远征 A2 还在走「苦行僧」路线——顶着高温暴走 100 公里,但一次续航实则只有 2 小时左右。

而远征 A3 采用了一种嵌入式躯干双电池系统,不仅看着更瘦更紧凑,综合满电续航也直接拉到了最高 8 小时。

8 小时是什么概念?

人类标准工作制也就 8 小时,这意味着远征 A3 第一次拥有了完整覆盖一个人类班次的能力。配合快速换电技术,它理论上可以实现 24 小时连轴转——真正意义上的「007」牛马,而且计划在 2026 年内量产。

除了身体素质的进化,远征 A3 在情商方面也点满了技能点。

A3 搭载了端到端大模型,最大的变化是「去唤醒词」。你不用再尴尬地喊口令,它能根据环境语境直接搭话,甚至支持拍拍肩膀唤醒。

基于这些能力,智元把 A3 定位在导览、商演这些场景,这其实是个精准的降维打击。试想一下,商场里有个能给你打一套咏春、还能顺手帮你拎 3kg 重物的机器人,这吸睛能力不比真人发传单强多了?

视频的最后,师父准许远征 A3「下山」。这不仅是一个电影彩蛋,也是一个行业隐喻。当越来越多的人形机器人开始追求超人的运动能力、长时间的续航以及更自然的交互,机器人也将有望真正挤进人类商业社会。

当然,从「能下山」到「走遍天下」,还有很长的路要走。成本控制、场景适配、安全规范、用户接受度,每一个环节都是考验。

马年将至,各种人形机器人也陆续下山,谁能成为真正的「绝世高手」,我们很快就会看到。

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

爱范儿 | 原文链接 · 查看评论 · 新浪微博


构建无障碍组件之Disclosure Pattern

作者 anOnion
2026年2月13日 23:15

Disclosure (Show/Hide) Pattern 详解:构建无障碍展开收起

展开收起(Disclosure)是一种常见的交互组件,也被称为 Collapse(折叠),允许内容在折叠(隐藏)和展开(可见)状态之间切换。本文基于 W3C WAI-ARIA Disclosure Pattern 规范,详解如何构建无障碍的展开收起组件。

一、Disclosure 的定义与核心功能

Disclosure(展开收起)是一种控件,允许内容在折叠(隐藏)和展开(可见)状态之间切换。它包含两个基本元素:控制展开收起的按钮和其控制可见性的内容区域。

当内容被隐藏时,按钮通常设计为带有右指箭头或三角形的按钮,暗示激活按钮将显示更多内容。当内容可见时,箭头或三角形通常向下指向。

二、WAI-ARIA 角色与属性

2.1 基本角色

role="button" 用于标识控制展开收起的按钮元素。

2.2 状态属性

aria-expanded 属性表示内容的展开状态:

  • 当内容可见时,按钮的 aria-expanded 设置为 true
  • 当内容隐藏时,按钮的 aria-expanded 设置为 false

2.3 控制关系

对于手动实现的 Disclosure(例如使用按钮),可选地使用 aria-controls 属性来引用包含所有展开/收起内容的元素:

<button
  role="button"
  aria-expanded="false"
  aria-controls="disclosure-content">
  展开更多信息
</button>

<div
  id="disclosure-content"
  class="hidden">
  <p>这里是被控制的展开内容...</p>
</div>

三、键盘交互规范

当展开收起控件获得焦点时:

按键 功能
Enter 激活展开收起控件,切换内容可见性
Space 激活展开收起控件,切换内容可见性

四、实现方式

4.1 原生 details/summary 元素

HTML5 的 <details><summary> 元素是推荐的实现方式,内置无障碍支持:

  • 自动状态管理:浏览器自动处理展开/收起状态
  • 内置键盘支持:自动支持 Enter 和 Space 键
  • 语义化标签:提供原生的无障碍语义
<details>
  <summary>点击展开/收起</summary>
  <p>这里是展开的内容...</p>
</details>

注意:当使用原生 <details><summary> 元素时,不需要添加 aria-controlsrole="button",因为浏览器会自动处理这些属性和语义。

4.2 按钮 + ARIA 实现

使用按钮和 ARIA 属性的手动实现方式(当不能使用原生 <details> 元素时):

<button
  role="button"
  aria-expanded="false"
  aria-controls="faq-content"
  onclick="toggleDisclosure('faq-content', this)">
  常见问题解答
</button>

<div
  id="faq-content"
  class="disclosure-content hidden">
  <p>FAQ 内容...</p>
</div>

五、常见应用场景

5.1 图片描述展开 (Image Description)

用于显示图片的详细描述信息:

<details>
  <summary>查看图片描述</summary>
  <img
    src="image.jpg"
    alt="图片描述" />
  <p>这是对图片的详细描述...</p>
</details>

5.2 FAQ 展开收起 (Answers to Frequently Asked Questions)

用于常见问题解答的逐条展开:

<details>
  <summary>问题一:如何注册账户?</summary>
  <p>回答:点击注册按钮...</p>
</details>

<details>
  <summary>问题二:如何重置密码?</summary>
  <p>回答:点击忘记密码...</p>
</details>

5.3 导航菜单展开 (Navigation Menu)

用于移动端导航菜单的展开收起:

<nav>
  <details>
    <summary>菜单</summary>
    <ul>
      <li><a href="#home">首页</a></li>
      <li><a href="#about">关于我们</a></li>
      <li><a href="#contact">联系我们</a></li>
    </ul>
  </details>
</nav>

5.4 带顶级链接的导航菜单 (Navigation Menu with Top-Level Links)

在导航菜单中同时包含展开子项和直接链接:

<nav>
  <details>
    <summary>产品</summary>
    <ul>
      <li><a href="#product-a">产品 A</a></li>
      <li><a href="#product-b">产品 B</a></li>
    </ul>
  </details>
  <a href="#services">服务</a>
  <a href="#about">关于我们</a>
</nav>

5.5 展开卡片 (Disclosure Card)

将展开收起功能集成到卡片组件中:

<details class="card">
  <summary class="card-header">
    <h3>项目信息</h3>
  </summary>
  <div class="card-content">
    <p>这里是项目的详细信息...</p>
    <ul>
      <li>开始日期:2023年1月1日</li>
      <li>结束日期:2023年12月31日</li>
      <li>负责人:张三</li>
    </ul>
  </div>
</details>

六、最佳实践

6.1 语义化标记

优先使用原生的 <details><summary> 元素,它们提供完整的语义和无障碍支持。

6.2 组件命名

在实际开发中,Disclosure 模式可能以不同名称出现:

  • Collapse:在许多 UI 库(如 Bootstrap、Ant Design、Element UI)中的常见名称
  • Accordion:当多个 Disclosure 组件垂直堆叠时的特例
  • Expand/Collapse:更直白的功能描述
  • Show/Hide:强调内容可见性的变化

尽管名称不同,其核心行为和无障碍要求保持一致。

6.3 状态指示

使用视觉指示器(如箭头方向)来表明当前展开状态:

  • 收起状态:右指箭头或向右三角形
  • 展开状态:下指箭头或向下三角形

6.4 平滑过渡

添加 CSS 过渡效果提升用户体验:

.disclosure-content {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.disclosure-content.expanded {
  max-height: 500px; /* 或适当的最大高度 */
}

6.5 可访问性考虑

  • 确保按钮具有清晰的焦点指示
  • 提供足够的点击区域(至少 44x44px)
  • 为屏幕阅读器用户提供明确的状态反馈

七、与类似模式的区别

特性 Disclosure Accordion Tabs
内容组织 单个内容块 多个面板垂直排列 多个面板水平排列
展开方式 单击切换 单击展开,其他收起 单击切换标签
用途 详细信息展示 FAQ、设置面板 页面内容分组

八、总结

构建无障碍的展开收起组件需要关注三个核心:正确的 ARIA 属性声明、合理的键盘交互支持、清晰的视觉状态指示。原生 <details><summary> 元素简化了实现,但开发者仍需理解无障碍原理,确保所有用户都能顺利使用。

遵循 W3C Disclosure Pattern 规范,我们能够创建既美观又包容的展开收起组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

前端将死,Agent 永生

作者 threerocks
2026年2月13日 23:13

我在家里一边收拾家里小鱼缸,一边刷到 Chrome 146 那个 WebMCP 的消息,然后顺手点进去看了半天,越看越觉得:

前端这条最后防线,可能真的要松动了。

以前我们讲用户增长DAU留存,讲得头头是道。但一旦你开始认真看 WebMCP,你会发现这套语言体系像在讲上个世纪的传真机。

我不是在夸张。

不是说 UI 不重要了(品牌、美感、情绪价值还是很贵),而是 谁是软件的用户 这件事,正在换人。等等,不对,是换“东西”:Agent 才是新的用户

这篇我就按我自己的理解,把 WebMCP 讲一下,到底是什么和你听过的 MCP 有啥关系、为啥我说它像 UI 里的 API、以及我踩过的几个坑,尤其是安全那块。

WebMCP 是什么?

以前 Agent 操作网页,基本两条路:

  • 一条是“装人”:截图、OCR、推理、找按钮、点错了再来一遍,token 烧到你心梗
  • 一条是“扒皮”:读 DOM、读无障碍树、猜结构、网站一改版就崩,稳定性也不太行

WebMCP 的感觉很不一样,它更像:

你不让 Agent 看像素,也不让它猜 DOM,你直接告诉它:我这页能干什么,参数是什么,给你一个确定性的工具接口。

WebMCP 就相当于 UI 里的 API。

你把它想象成:以前你给人类做 UI;现在你给 Agent 也做一层工具 UI

人类点按钮,Agent 调函数。两个用户,同一个状态,同一个会话(cookie / session),在一个页面里并肩工作。

三种 WebMCP

1)Web 标准提案:navigator.modelContext

这是 Google / Microsoft 推的 W3C 社区组提案,Chrome 146 早期预览里已经能体验到一点点苗头。核心是浏览器给你一个原生 API:navigator.modelContext,让网站注册工具。

工具大概长这样(示意):

它跟你熟悉的后端 MCP server不一样:这是 纯浏览器端 的。网页自己就是server
也因此它天然复用浏览器的登录态,不用你再搞一坨 OAuth 流程(这点我太爱了,真心的)。

2)MCP-B

这是 Alex Nahas 那条路线,把 MCP TypeScript SDK 搬到浏览器里,用 postMessage 做传输,让扩展/客户端能发现并调用你页面里的工具。

它的典型接入方式很像50 行搞定那种:

注意哈:allowedOrigins: ["*"] 这种只适合 demo,真上生产会被你未来的自己追杀(后面我会讲原因)。

3)Jason McGhee 的开源库

还有一个你会经常看到的 WebMCP,是那个右下角冒出来一个小蓝方块的库。它的特点是接入极简单:页面里丢一个脚本,小蓝块就出来,然后你用 MCP 客户端生成 token、粘进去,就能连上。

它更多是让网站快速具备可交互能力的产品化形态。适合做 demo、做推广、做早期验证(小红书这种传播场景很友好)。

所以我现在的口头区分是:

  • WebMCP(标准):浏览器 API navigator.modelContext
  • MCP-B(桥接):把 MCP SDK + 浏览器传输拼起来,让现在就能跑
  • 小蓝块 WebMCP(库):体验型接入,适合快速展示

你要问我哪个会赢——我倾向于:
标准一定会吃掉大部分生态,但在标准普及之前,桥接会先养活一群人。

为啥我说“前端将死”?

我看完前段时间很火的那篇《互联网已死,Agent 永生》,最大的震撼其实不是情绪,而是那个前提变化:

旧世界:人是软件的用户
新世界:Agent 才是软件的新主人

放到 WebMCP 上,翻译成更直白的话就是:

  • 以前你做一个 web app,核心问题是:用户能不能点明白、流程顺不顺、按钮够不够大
  • 现在你做一个 web app,新增一个核心问题是:Agent 能不能稳定调用、Schema 清不清楚、失败能不能自愈

你会发现很多前端经验突然不灵了:

  • 你把按钮做得再好看,Agent 不一定会点(它可能根本不点)
  • 你把页面做得再炫,Agent 只关心:有没有 checkout() 这种工具
  • 你以前写用户使用手册,现在更像在写工具契约和调用说明

我甚至觉得未来会出现一种很怪的 KPI:

  • 不是 DAU,而是 TAU:Tool Active Usage(工具调用活跃)
  • 不是转化率,而是 成功调用率 / 平均重试次数 / 幂等率

碎碎念一句:
我之前一直觉得给 Agent 做东西很虚,直到看到 WebMCP 这种结构化工具落在浏览器里,才意识到它会把很多事情变成工程问题,而不是玄学。

WebMCP 真正让人兴奋的点

传统做法里,你想让 Agent 操作你的产品,往往得:

  • 额外开一套后端 MCP server(或者写一堆 automation)
  • 再搞 OAuth / API key / 权限
  • 再处理Agent 做完动作,网页状态怎么同步

WebMCP 的思路是:
别折腾了,Agent 就在浏览器里,直接复用现有 session。

这会带来两个很现实的好处:

  • 你不用把登录态复制给 Agent(也就少了一堆密钥泄露风险)
  • UI 和工具天然同源:人点完和 Agent 调完,看到的是同一个页面状态

这种人和 Agent 共用一套界面的感觉,很像以后会变成默认模式:
人负责拍板 + 审核,Agent 负责跑腿 + 串流程。

WebMCP 的安全坑

我读到 WebMCP 的安全最佳实践那篇的时候,第一反应是:“完了,这玩意儿如果大家不按规则来,迟早会出事。”

1)WebMCP 的威胁模型变了

以前我们做 web 安全,默认用户控制自己的浏览器
但 WebMCP 的世界里,一个 Agent 可能同时连着好几个网站的工具:

  • 你的网站工具(正常)
  • 用户开着的别的网站工具(未知)
  • 某个恶意网站的工具(专门来搞你的)

然后那个恶意工具可能会诱导 Agent:

  • 把你这边拿到的敏感数据,顺手汇报出去
  • 用你的登录态执行不该执行的动作(比如转账、下单、删数据)

你得把 Agent 当成一个可能被 prompt injection 过的脚本执行器
听起来很刺耳,但真的要这么设计。

2)致命三元组

当下面三件事同一页同时存在,风险直接上天:

  • 你能读到私密数据(邮件、聊天记录、订单、地址)
  • Agent 会处理不可信内容(外部邮件正文、用户输入、第三方内容)
  • 你还有对外通信能力(发请求、发消息、上传)

记住:不要把敏感数据直接喂给 Agent。

有一句我直接记下来了:
“Sensitive information must NEVER be passed to the AI agent’s context. Always use references instead.”

翻译成人话就是:

  • 你要给 Agent 的不是完整聊天记录 JSON,而是一个引用 ID
  • 真正的数据留在同源安全存储里,需要时让用户在 UI 上确认再展示/执行

3)描述要老实,标记要明确,还要二次确认

你想象一下:
一个工具嘴上说“add_to_cart”,实际干了“complete_purchase”。
Agent 是很难识别这种工具自述与行为不一致的。

所以我现在的偏执做法是:

  • 只要能扣钱、删数据、发外部消息:必须让用户弹窗确认
  • 工具描述写清楚会发生什么,别耍小聪明
  • 工具参数里加一个必须传的确认短语(比如 CONFIRM_PURCHASE 这种)

我知道这听起来很烦,但真的比被盗刷烦少多了。

可以应用的场景

场景 A:想快速做一个能演示的 demo(给老板/投资人/用户看)

我会优先上小蓝块那类方案:

  • 你只要让网页能被连接,工具能出现,就够了
  • 先选 1-3 个最核心动作做工具,比如“查询当前订单”“把商品加入购物车”“生成一段摘要”
  • 工具返回尽量短,别给一大坨无意义字段

这个阶段最重要的是:
让人看到Agent 不用装人,也能把事干了

场景 B:想让真实用户用起来

我会走 MCP-B 那条路线:

  • 把你现有前端逻辑包成工具
  • 输入/输出 schema 认真做,越明确越好(能减少幻觉和误用)
  • 把工具分层:只读工具一组,改状态工具一组,危险工具单独一组

然后立刻做三件事:

  • 工具幂等:重复调用不应该炸
  • 错误要可读:别把堆栈直接抛出去(也别泄露内部信息)
  • origin 白名单:生产环境别写 "*"

场景 C:你押注未来,想吃标准红利

那就盯 navigator.modelContext 这条线:

  • 能用的时候就用原生 API
  • 不能用的时候就用 polyfill/桥接做兼容

我甚至觉得以后会出现一种Agent SEO:你的网站有没有对 Agent 友好的工具契约,会变成一种竞争力。

给前端同学的安慰(我也需要)

我说“前端将死”,其实是在说一种旧范式在死:只为人类服务的前端,在死。

但你要真让我选,我反而觉得前端会变得更重要,只是重要的点变了:

  • 你要会把 UI 操作提炼成稳定工具
  • 你要会设计 schema、错误语义、幂等性
  • 你要懂安全
  • 你还得懂人类体验

未来的好前端,可能是:既能写好看 UI,也能写好给 Agent 调的工具层

我讲真,这种人会很贵

昨天 — 2026年2月13日首页

useStorage:本地数据持久化利器

2026年2月13日 20:55

image

前言

一、基础概念

1.1 什么是本地存储

  在Web开发中,本地存储是指将数据存储在客户端浏览器中,以便在不同的页面或会话之间保持数据的持久性。本地存储可以帮助我们存储用户的偏好设置、临时数据以及其他需要在用户关闭浏览器后仍然存在的数据。对浏览器来说,使用 Web Storage 存储键值对比存储 Cookie 方式更直观,而且容量更大,它包含两种:localStorage 和 sessionStorage

Cookie localStorage sessionStorage
数据的生命期 一般由服务器生成,可设置失效时间。
如果在浏览器端生成Cookie,默认是关闭浏览器后失效
除非被清除,否则永久保存,
可变相设置失效时间
仅在当前会话下有效,
关闭页面或浏览器后被清除
存放数据大小 4K左右 一般为5MB
与服务器端通信 每次都会携带在HTTP头中,
如果使用cookie保存过多数据会带来性能问题
仅在客户端(即浏览器)中保存,
不参与和服务器的通信
易用性 源生的Cookie接口不友好,需要自己封装 源生接口可以接受,亦可再次封装

1.2 useStorage 简介

  useStorage 是 Vue 用于数据持久化的核心工具,它能够自动将响应式数据同步到 localStorage 或 sessionStorage 中。这个功能对于需要保存用户偏好设置、表单数据或应用状态的场景特别有用。这样,我们就可以在Vue组件中方便地使用本地存储来持久化数据,提供更好的用户体验和数据管理能力。

// hooks/useStorage.ts
/**
 * 获取传入的值的类型
 */
const getValueType = (value: any) => {
    const type = Object.prototype.toString.call(value)
    return type.slice(8, -1)
}

export const useStorage = (type: 'sessionStorage' | 'localStorage' = 'sessionStorage') => {
    /**
     * 存储数据
     * @param key
     * @param value
     */
    const setStorage = (key: string, value: any) => {
        const valueType = getValueType(value)
        window[type].setItem(key, JSON.stringify({type: valueType, value}))
    }
    /**
     * 获取某个存储数据
     * @param key
     */
    const getStorage = (key: string) => {
        const value = window[type].getItem(key)
        if (value) {
            const {value: val} = JSON.parse(value)
            return val
        } else {
            return value
        }
    }

    /**
     * 清除某个存储数据
     * @param key
     */
    const removeStorage = (key: string) => {
        window[type].removeItem(key)
    }

    /**
     * 清空所有存储数据,如果需要排除某些数据,可以传入 excludes 来排除
     * @param excludes 排除项。如:clear(['key']),这样 key 就不会被清除
     */
    const clear = (excludes?: string[]) => {
        // 获取排除项
        const keys = Object.keys(window[type])
        const defaultExcludes = ['dynamicRouter', 'serverDynamicRouter']
        const excludesArr = excludes ? [...excludes, ...defaultExcludes] : defaultExcludes
        const excludesKeys = excludesArr ? keys.filter((key) => !excludesArr.includes(key)) : keys
        // 排除项不清除
        excludesKeys.forEach((key) => {
            window[type].removeItem(key)
        })
        // window[type].clear()
    }

    return {
        setStorage,
        getStorage,
        removeStorage,
        clear
    }
}

二、使用帮助

2.1 用法

<script setup lang="ts">
import { useStorage } from "@/hooks/useStorage";

const { setStorage, getStorage, removeStorage, clear } = useStorage();
// const { setStorage, getStorage, removeStorage, clear } = useStorage('localStorage');
</script>

  useStorage 提供了四个核心函数来操作数据,如下表所示。

方法名 简要说明
setStorage 存储数据。将要用于引用的键名作为第一个参数传递,将要保存的值作为第二个参数传递。
getStorage 获取某个存储数据
removeStorage 清除某个存储数据
clear 清除所有缓存数据,如果需要排除某些数据,可以传入 excludes 来排除,如:clear(['key']),这样 key 就不会被清除

2.2 储存数据

  使用 setStorage 方法可以将数据进行持久化存储,例如:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
setStorage('accessToken', 'Bearer ' + response.data.result.accessToken);
</script>

  这里,accessToken是键,Bearer + response.data.result.accessToken 是对应的值。除此以外,支持非字符串类型存取值:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
  
setStorage('key', { name: 'Jok' })
</script>

  注意:由于 localStorage 操作的是字符串,如果存储的是JSON对象,需要先使用 JSON.stringify() 将其转换为字符串,取回时再使用 JSON.parse() 还原。

2.3 取出数据

  获取存储的数据则使用 getStorage 方法:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { getStorage } = useStorage();
const accessToken = getStorage('accessToken');
</script>

2.4 删除数据

  如果需要移除某个键值对,可以调用 removeStorage 方法:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { removeStorage } = useStorage();
removeStorage('key')
</script>

2.5 更改数据

  要更新已存储的数据,同样使用 setStorage 方法,覆盖原有的值:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
getStorage('accessToken', '更改后' + response.data.result.accessToken);
</script>

2.6 清除数据

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { clear } = useStorage();
clear()
</script>

三、总结

  Vue 中使用 localStorage 可以方便地在用户关闭和重新打开浏览器时保持应用状态,解决像 Cookie 那样需要刷新才能获取新值的问题。合理运用 localStorage 和 sessionStorage,可以在不增加服务器负担的情况下,提供更好的用户体验。

image

商务部新闻发言人就荷公布安世半导体案裁决结果答记者问

2026年2月13日 20:55
36氪获悉,商务部新闻发言人就荷公布安世半导体案裁决结果答记者问。有记者问:2月11日,荷兰企业法庭公布安世半导体案裁决结果,决定对安世半导体涉嫌管理不善开展调查。请问中方对此有何评论?答:中方注意到相关消息。我们认为,当务之急是要恢复全球半导体供应链稳定畅通,这是包括中荷在内的国际业界的共同利益诉求。希望荷方相向而行,从维护全球半导体产业链供应链稳定的大局出发,为双方企业协商建设性解决内部纠纷创造有利条件。

润泽科技:拟购买广东润惠42.56%股权,股票将于2月24日复牌

2026年2月13日 20:50
36氪获悉,润泽科技公告,公司拟向中金算力基金等12名投资者通过发行可转换公司债券的方式购买其合计持有的广东润惠科技发展有限公司42.56%股权,并向不超过35名特定投资者发行股份募集配套资金,截至本预案摘要签署日,标的公司的审计和评估工作尚未完成,本次交易的具体交易价格尚未确定。公司股票将于2026年2月24日开市起复牌。广东润惠的主营业务与上市公司相同,主要为IDC业务和AIDC业务。

奥联电子:控股股东终止控制权转让

2026年2月13日 20:47
36氪获悉,奥联电子公告,公司收到控股股东瑞盈资产出具的《通知函》,受让方潮成创新未按约定足额支付第一期股份转让对价,已构成违约。据此,瑞盈资产向潮成创新发出《解除通知函》,自潮成创新收到函件起,双方签署的《股份转让协议》解除。本次股份转让协议解除后,本次控制权转让事项相应终止,公司控股股东仍为瑞盈资产,实际控制人仍为钱明飞。

受控与非受控组件

作者 NEXT06
2026年2月13日 20:41

引言:数据驱动的本质

在 React 的组件化架构中,表单处理始终是一个核心议题。理解受控组件与非受控组件的区别,不仅是掌握 React 基础语法的必经之路,更是深入理解“数据驱动视图”这一核心设计哲学的关键。

我们可以通过一个生动的场景来类比这两种模式:

  • 受控组件(Controlled Component)  类似于高级餐厅的点餐服务。顾客(用户)的每一个需求,都需要经过服务员(React State)的确认与记录,最终由厨房(DOM)精准执行。在这个过程中,服务员掌握着唯一的、绝对的控制权。
  • 非受控组件(Uncontrolled Component)  则类似于自助餐模式。顾客直接选取食物(直接操作 DOM),餐厅管理者(React)并不实时干预盘子里的内容,只有在结账(表单提交)的时刻,才进行一次性的核对。

这种差异的核心在于:表单数据的“单一数据源(Single Source of Truth)”究竟是归属于 React 组件的 State,还是浏览器原生的 DOM 节点?

受控组件:单一数据源

定义与核心机制

在受控组件模式下,useState 成为表单数据的唯一可信源。HTML 表单元素(如 、、)通常维护自己的内部状态,但在 React 中,我们将这种可变状态保存在组件的 state 属性中,并且只能通过 setState() 来更新。

标准代码实现

Jsx

import React, { useState } from 'react';

function ControlledInput() {
  const [value, setValue] = useState('');

  const handleChange = (e) => {
    // 数据流向:View -> Event -> State -> View
    const input = e.target.value;
    // 在这里可以进行数据清洗或验证
    setValue(input.toUpperCase()); 
  };

  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
    />
  );
}

深度解析

受控组件的价值在于其即时响应特性。由于每一次按键都会触发 React 的状态更新流程,开发者可以在 onChange 回调中介入数据流:

  1. 输入验证(Input Validation) :即时反馈输入是否合法(如长度限制、正则匹配)。
  2. 数据转换(Data Transformation) :如上例所示,强制将输入转换为大写,或格式化信用卡号。
  3. 条件禁用:根据当前输入值动态决定提交按钮是否可用。

在这种模式下,DOM 节点不再持有状态,它仅仅是 React State 的一个纯函数投影。

非受控组件:信任 DOM 的原生能力

定义与核心机制

非受控组件是指表单数据由 DOM 节点本身处理。在大多数情况下,这需要使用 useRef 来从 DOM 节点中获取表单数据。此时,React 变成了“观察者”而非“管理者”。

标准代码实现

注意:在非受控组件中,我们使用 defaultValue 属性来指定初始值,而不是 value。这是为了避免 React 覆盖 DOM 的原生行为。

Jsx

import React, { useRef } from 'react';

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

  const handleSubmit = (e) => {
    e.preventDefault();
    // 只有在需要时(如提交)才读取 DOM 值
    console.log('Current Value:', inputRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* defaultValue 仅在初次渲染时生效 */}
      <input type="text" defaultValue="Initial" ref={inputRef} />
      <button type="submit">Submit</button>
    </form>
  );
}

核心优势与不可替代场景

虽然受控组件是 React 的推荐模式,但在以下场景中,非受控组件具有不可替代性:

  1. 文件上传(File Input) : 的值是由浏览器出于安全考虑严格控制的只读属性,React 无法通过 state 设置它,因此必须作为非受控组件处理。
  2. 集成第三方 DOM 库:当需要与 jQuery 插件、D3.js 或其他直接操作 DOM 的库集成时,非受控组件能避免 React 的虚拟 DOM 机制与第三方库产生冲突。

进阶实战:复杂组件的设计哲学

在实际的业务开发中,我们经常遇到一种混合模式:内部受控,外部非受控。以一个通用的“日历组件”为例,这种设计模式能显著降低组件使用者的心智负担。

场景描述

我们需要封装一个 Calendar 组件。对于父组件而言,它可能只需要关心“初始日期”和“最终选中的日期”;但对于 Calendar 组件内部,它需要处理月份切换、当前日期高亮等复杂的交互逻辑。

模式分析

Jsx

import React, { useState } from 'react';

function Calendar(props) {
  // 1. 接受 props.defaultValue 作为初始状态
  // 2. 即使 props.onChange 未传递,组件内部也能正常工作
  const { defaultValue = new Date(), onChange = () => {} } = props;
  
  // 3. 内部维护 State,实现“自我管理”
  const [date, setDate] = useState(defaultValue);

  const handleDateClick = (newDate) => {
    // 更新内部状态,驱动 UI 重绘(如高亮选中项)
    setDate(newDate);
    // 抛出事件通知外部
    onChange(newDate);
  };

  // 省略月份切换与日期渲染逻辑...

  return (
    <div className="calendar-container">
       {/* 渲染逻辑基于内部 state.date */}
       <div className="current-month">
         {date.getFullYear()} 年 {date.getMonth() + 1} 月
       </div>
       {/* ... */}
    </div>
  );
}

设计价值

这个日历组件展示了高级组件设计的精髓:

  • 对内受控:组件内部通过 useState 精确控制每一个 UI 细节(月份跳转、选中态样式),确保交互的流畅性。
  • 对外非受控:父组件不需要维护 value 状态即可使用该组件(开箱即用)。父组件只通过 defaultValue 初始化,并通过回调获取结果。

这种“封装复杂性”的设计,使得组件既拥有受控组件的灵活性,又具备非受控组件的易用性。

深度对比与选型指南

多维度对比

  1. 数据流向

    • 受控组件:Push 模式。State -> DOM。数据变更主动推送到视图。
    • 非受控组件:Pull 模式。DOM -> Ref。仅在需要时从视图拉取数据。
  2. 渲染机制

    • 受控组件:每次输入(Keystroke)都会触发组件的 Re-render。
    • 非受控组件:输入过程不触发 React 组件的 Re-render(除非内部有其他 State 逻辑)。
  3. 代码复杂度

    • 受控组件:较高,需要为每个输入编写 onChange 处理函数。
    • 非受控组件:较低,代码结构更接近原生 HTML。

性能辩证

一种常见的误解是“受控组件性能差”。诚然,受控组件每次输入都触发渲染,但在 React 18 的并发模式(Concurrent Features)和自动批处理机制下,这种性能损耗对于绝大多数普通表单(少于 1000 个输入节点)是可以忽略不计的。

仅在极端高性能场景下(如高频数据录入表格、富文本编辑器核心),非受控组件才具有明显的性能优势。

决策树:如何选择?

在进行技术选型时,请遵循以下原则:

  1. 必须使用非受控组件

    • 文件上传 ()。
    • 需要强依赖 DOM 行为的遗留代码迁移。
  2. 强烈建议使用受控组件

    • 需要即时表单验证(输入时报错)。
    • 需要条件字段(根据输入 A 显示输入 B)。
    • 需要强制输入格式(如手机号自动加空格)。
  3. 灵活选择

    • 简单的登录/注册表单,无复杂联动:两者皆可,非受控代码更少。
    • 开发通用 UI 库:建议参考实战案例,采用“defaultValue + 内部 State”的混合模式,提供更好的开发者体验。

三部门发布关于“十五五”期间支持科技创新进口税收优惠政策的通知

2026年2月13日 20:40
36氪获悉,财政部等三部门发布关于“十五五”期间支持科技创新进口税收优惠政策的通知。其中提出,对科学研究机构、技术开发机构、学校、党校(行政学院)、图书馆进口国内不能生产或性能不能满足需要的科学研究、科技开发和教学用品,免征进口关税和进口环节增值税、消费税。对出版物进口单位为科学研究机构、技术开发机构、学校、党校(行政学院)、图书馆进口用于科研、教学的图书、资料,免征进口环节增值税。

南京化纤:拟通过资产置换、发行股份及支付现金方式购买南京工艺装备制造股份有限公司100%股份

2026年2月13日 20:39
36氪获悉,南京化纤公告,公司拟通过资产置换、发行股份及支付现金方式购买南京工艺装备制造股份有限公司100%股份,并募集配套资金。根据江苏华信资产评估有限公司出具的评估报告,南京化纤截至评估基准日(2024年12月31日)的全部资产及负债评估值为7.29亿元,南京工艺100%股份评估值为16.07亿元。本次交易拟置出资产交易价格为7.29亿元,拟置入资产交易价格为16.07亿元。

晶丰明源:拟发行股份及支付现金购买易冲科技100%股权并募集配套资金

2026年2月13日 20:30
36氪获悉,晶丰明源公告,公司计划通过发行股份及支付现金的方式向玮峻思等50名交易对方购买其合计持有的易冲科技100%股权。易冲科技主要从事无线充电芯片、通用充电芯片、汽车电源管理芯片、AC/DC及协议芯片等高性能模拟芯片及数模混合信号芯片的研发、设计和销售。近年来,易冲科技收入规模快速增长,2023年及2024年的营业收入同比增长分别达到45.02%、47.04%。同时,公司还将募集配套资金,募集对象为不超过35名符合中国证监会规定的特定投资者。

防抖(Debounce)与节流(Throttle)解析

作者 NEXT06
2026年2月13日 20:22

引言:高性能开发的必修课

在现代前端开发中,用户体验与性能优化是衡量一个应用质量的关键指标。然而,浏览器的许多原生事件,如 window.resize、document.scroll、input 验证以及 mousemove 等,其触发频率极高。

如果我们在这些事件的回调函数中执行复杂的 DOM 操作(导致重排与重绘)或发起网络请求,浏览器的渲染线程将被频繁阻塞,导致页面掉帧、卡顿;同时,后端服务器也可能面临每秒数千次的无效请求轰炸,造成不必要的压力。

防抖(Debounce)与节流(Throttle)正是为了解决这一核心矛盾而生。它们通过控制函数执行的频率,在保证功能可用的前提下,将浏览器与服务器的负载降至最低。本文将从底层原理出发,纠正常见的实现误区(如 this 指向丢失),并提供生产环境可用的封装代码。

核心概念解析:生动与本质

为了更好地区分这两个概念,我们可以引入两个生活中的生动比喻。

1. 防抖(Debounce):最后一次说了算

比喻:电梯关门机制
想象你走进电梯,按下关门键。此时如果又有人跑过来,电梯门会停止关闭并重新打开。只有当一段时间内(比如 5 秒)没有人再进入电梯,门才会真正关上并运行。

核心逻辑
无论事件触发多少次,只要在规定时间间隔内再次触发,计时器就会重置。只有当用户停止动作一段时间后,函数才会执行一次。

典型场景

  • 搜索框联想:用户停止输入后才发送 Ajax 请求。
  • 窗口大小调整:用户停止拖拽窗口后才计算布局。

2. 节流(Throttle):按规定频率执行

比喻:FPS 游戏中的射速
在射击游戏中,无论你点击鼠标的速度有多快(哪怕一秒点击 100 次),一把设定了射速为 0.5 秒一发的武器,在规定时间内只能射出一发子弹。

核心逻辑
在规定的时间单位内,函数最多只能执行一次。它稀释了函数的执行频率,保证函数按照固定的节奏运行。

典型场景

  • 滚动加载:监听页面滚动到底部,每隔 200ms 检查一次位置。
  • 高频点击:防止用户疯狂点击提交按钮。

核心原理与代码实现

在实现这两个函数时,很多初学者容易忽略 JavaScript 的作用域参数传递问题,导致封装后的函数无法正确获取 DOM 元素的 this(上下文)或丢失 Event 对象。以下代码将演示标准且健壮的写法。

1. 防抖(Debounce)实现

防抖通常分为“非立即执行版”和“立即执行版”。最常用的是非立即执行版。

标准通用版代码

JavaScript

/**
 * 防抖函数
 * @param {Function} func - 需要执行的函数
 * @param {Number} wait - 延迟执行时间(毫秒)
 */
function debounce(func, wait) {
    let timeout;

    // 使用 ...args 接收所有参数(如 event 对象)
    return function(...args) {
        // 【关键点】捕获当前的 this 上下文
        // 如果这里不捕获,setTimeout 中的函数执行时,this 会指向 Window 或 Timeout 对象
        const context = this;

        // 如果定时器存在,说明在前一次触发的等待时间内,清除它重新计时
        if (timeout) clearTimeout(timeout);

        timeout = setTimeout(() => {
            // 使用 apply 将原始的上下文和参数传递给 func
            func.apply(context, args);
        }, wait);
    };
}

代码解析:

  1. 闭包:timeout 变量保存在闭包中,不会被销毁。
  2. this 绑定:我们在返回的匿名函数内部保存 const context = this。当该函数绑定到 DOM 事件(如 input.addEventListener)时,this 指向触发事件的 DOM 元素。
  3. apply 调用:func.apply(context, args) 确保原函数执行时,既能拿到正确的 this,也能拿到 event 等参数。

2. 节流(Throttle)实现

节流的实现主要有两种流派:时间戳版(首节流,立即执行)和定时器版(尾节流,延迟执行)。实际生产中,为了兼顾体验,通常使用合并版

基础版:时间戳(立即执行)

JavaScript

function throttleTimestamp(func, wait) {
    let previous = 0;
    return function(...args) {
        const now = Date.now();
        const context = this;
        
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    }
}

进阶版:定时器 + 时间戳(头尾兼顾)

为了保证第一次触发能立即执行(响应快),且最后一次触发在冷却结束后也能执行(不丢失最后的操作),我们需要结合两者。

JavaScript

/**
 * 节流函数(精确控制版)
 * @param {Function} func - 目标函数
 * @param {Number} wait - 间隔时间
 */
function throttle(func, wait) {
    let timeout;
    let previous = 0;

    return function(...args) {
        const context = this;
        const now = Date.now();
        
        // 计算剩余时间
        // 如果没有 previous(第一次),remaining 会小于等于 0
        const remaining = wait - (now - previous);

        // 如果没有剩余时间,或者修改了系统时间导致 remaining > wait
        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            previous = now;
            func.apply(context, args);
        } else if (!timeout) {
            // 如果处于冷却期,且没有定时器,设置一个定时器在剩余时间后执行
            // 这里的目的是保证最后一次触发也能被执行(尾调用)
            timeout = setTimeout(() => {
                previous = Date.now();
                timeout = null;
                func.apply(context, args);
            }, remaining);
        }
    };
}

深度对比与场景决策

为了在实际开发中做出正确选择,我们需要从执行策略和应用场景两个维度进行对比。

维度 防抖 (Debounce) 节流 (Throttle)
核心策略 延时处理:等待动作停止后才执行。 稀释频率:按固定时间间隔执行。
执行次数 连续触发 N 次,通常只执行 1 次(最后一次)。 连续触发 N 次,均匀执行 N / (总时间/间隔) 次。
即时性 较差,因为需要等待延迟时间结束。 较好,第一次触发通常立即执行,中间也会规律执行。
适用场景 1. 搜索框输入(input)
2. 手机号/邮箱格式验证
3. 窗口大小调整(resize)后的布局计算
1. 滚动加载更多(scroll)
2. 抢购按钮的防重复点击
3. 视频播放记录时间打点

决策口诀

  • 如果你关心的是结果(比如用户最终输了什么),用防抖
  • 如果你关心的是过程(比如页面滚动到了哪里),用节流

进阶扩展

1. requestAnimationFrame 的应用

在处理与动画或屏幕渲染相关的节流场景时(如高频的 scroll 或 touchmove 导致的 DOM 操作),使用 setTimeout 的节流可能仍不够平滑,因为屏幕的刷新率通常是 60Hz(约 16.6ms 一帧)。

window.requestAnimationFrame() 是浏览器专门为动画提供的 API,它会在浏览器下一次重绘之前执行回调。利用它代替 throttle 可以实现更丝滑的视觉效果,且能自动暂停在后台标签页中的执行,节省 CPU 开销。

JavaScript

let ticking = false;
window.addEventListener('scroll', function(e) {
  if (!ticking) {
    window.requestAnimationFrame(function() {
      // 执行渲染逻辑
      ticking = false;
    });
    ticking = true;
  }
});

2. 工业级库 vs 手写实现

虽然手写防抖节流是面试和理解原理的必修课,但在复杂的生产环境中,建议使用成熟的工具库,如 Lodash (_.debounce, _.throttle)。

Lodash 的实现考虑了更多边界情况,例如:

  • leading 和 trailing 选项的精细控制(是否在开始时执行,是否在结束时执行)。
  • maxWait 选项(防抖过程中,如果等待太久是否强制执行一次,即防抖转节流)。
  • 取消功能(cancel 方法),允许在组件卸载(Unmount)时清除未执行的定时器,防止内存泄漏。

结语

防抖和节流是前端性能优化的基石。理解它们的区别不仅仅在于背诵定义,更在于理解浏览器事件循环机制以及闭包的应用。正确地使用它们,能够显著降低服务器压力,提升用户交互的流畅度,是每一位高级前端工程师必须掌握的技能。

react - isValidElement 判断参数是否是一个有效的ReactElement

作者 瑶瑶领先_
2026年2月13日 18:02

作用:isValidElement是ReactElement对象中的一个方法,可以通过react.isValidElement(object)来调用,它的作用是验证判断参数object是否为有效的ReactElement,返回boolean值。

方法定义:

/**
 * 验证 object 参数是否是 ReactElement. 返回布尔值
 * 验证成功的条件:
 * object 是对象
 * object 不为 null
 * object 对象中的 $$typeof 属性值为 REACT_ELEMENT_TYPE
 */
export function isValidElement(object) {
  return (
    typeof object === 'object' &&
    object !== null &&
    object.$$typeof === REACT_ELEMENT_TYPE
  );
}
// src/react/packages/shared/ReactSymbols.js
export const REACT_ELEMENT_TYPE = hasSymbol
  ? Symbol.for('react.element')
  : 0xeac7;

判断的条件有三个,需要同时满足:

1、必须是对象

2、不能为空

3、对象中要有$$typeof 属性,且值必须为 REACT_ELEMENT_TYPE这样的一个常量值。它是一个Symbol值或者16进制的数值。

js 数字精确度

作者 瑶瑶领先_
2026年2月13日 18:00

事情的起源: 项目中 填写的金额是小数 传给后端需要 *100 9.87 *100 传给后端后是986.9999999999999 后端直接取整 就变成了9.86了

0.1 + 0.2 != 0.3

console.log(0.1 + 0.2) //0.30000000000000004
console.log(0.1 + 0.2 == 0.3) //false

1. 数字的存储

浮点数是用二进制的科学计算法来表示的,在计算机上是以二进制来进行存储的,单精度浮点数占用32位,双精度浮点数占用64位。

image.png

最高位是符号位(sign) , 0 表示正数, 1表示负数。接下来的11存储的是指数(exponent) , 最后是52位存储的是小数(mantissa)。浮点数的值可以用下面这个式子算出,类似于十进制的科学计数法。

image.png

注意以上的公式遵循科学计数法的规范,在十进制中 0<M<10,到二进制就是 0<M<2。也就是说整数部分只能是1,所以可以被舍去,只保留后面的小数部分。如 4.5 转成二进制就是 100.1,科学计数法表示是 1.001*2^2,舍去1后 M = 001。E是一个无符号整数,因为长度是11位,取值范围是 0~2047。但是科学计数法中的指数是可以为负数的,所以约定减去一个中间数 1023[0,1022] 表示为负, [1024,2047] 表示为正。如 4.5 的指数 E = 1025,尾数 M = 001

image.png

0.1 为例解释浮点误差的原因,0.1 转成二进制表示为 0.0001100110011001100(1100循环),1.100110011001100x2^-4,所以 E=-4+1023=1019;M 舍去首位的1,得到 100110011...在计算机中的存储为:

image.png

2. 0.1+0.2=0.30000000000000004?

转换成二进制计算:
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010  =
0.0100110011001100110011001100110011001100110011001100111

// 十进制小数转二进制
小数部分*2 取整数

// 二进制小数转换成十进制
1*2^(-小数点后第几位)+1*2^(-小数点后第几位)....

9.87*100= 986.9999999999999

9.87 = 1001.110111101011100001010001111010111000010100011111 = 1.001110111101011100001010001111010111000010100011111 * 2^3

S = 0 E = 1026 M = 0011 1011 1101 0111 0000 1010 0011 1101 0111 0000 1010 0011 111

为什么x=0.1能得到0.1

二进制转换十进制的时候 小数的精度为2^(-52) ,即2.220446049e-16

所以数字转换成十进制的时候,JavaScript能表示的精度最多能精确到小数点后第16位,会把小数点后第17位进行凑整处理

0.1~0.9 21位有效数字处理结果

0.1.toPrecision(21) // 0.100000000000000005551
0.2.toPrecision(21) // 0.200000000000000011102
0.3.toPrecision(21) // 0.299999999999999988898
0.4.toPrecision(21) // 0.400000000000000022204
0.5.toPrecision(21) // 0.500000000000000000000
0.6.toPrecision(21) // 0.599999999999999977796
0.7.toPrecision(21) // 0.699999999999999955591
0.8.toPrecision(21) // 0.800000000000000044409
0.9.toPrecision(21) // 0.900000000000000022204

小数位16位处理后

0.1.toPrecision(16) // 0.1000000000000000
0.2.toPrecision(16) // 0.2000000000000000
0.3.toPrecision(16) // 0.3000000000000000
0.4.toPrecision(16) // 0.4000000000000000
0.5.toPrecision(16) // 0.5000000000000000
0.6.toPrecision(16) // 0.6000000000000000
0.7.toPrecision(16) // 0.7000000000000000
0.8.toPrecision(16) // 0.8000000000000000
0.9.toPrecision(16) // 0.9000000000000000

解决方案

  1. 自己手撸
  2. 现成: decimal.js number-precision long.js .....

图片标签拖拽 && url、base64、Blob、File、canvas之间相互转换

作者 瑶瑶领先_
2026年2月13日 17:57

图片标签拖拽 && url、base64、Blob、File、canvas之间相互转换

背景:已有选择本地文件上传和粘贴图片上传,由于用户喜欢使用拖拽事件,提出要求会话框中的图片通过拖拽到右侧可上传区域释放后可以上传相关的图片。

问题: 拖拽聊天框中的图片和本地图片拖拽有什么不一样?传递什么数据?图片地址为什么不能就直接用当前图片地址?图片跨域(开发过程中,我的浏览器开起来允许跨域请求)

尝试: 请求允许跨域(mode: 'cros')、图片转成canvas、图片允许跨域(crossOrigin:'Anonymous'),后端服务器请求图片

  1. 先在聊天框中监听拖拽事件,携带上图片地址
    const onDrag = (e) => {
      // 携带上拖拽图片的地址
      e.dataTransfer.setData('text/plain', e.target.currentSrc);
    };
    const el = document.getElementById('im-jtalk-chat__zone');
    el?.addEventListener('dragstart', onDrag);
  1. 在拖拽目标上监听onDrop事件,获取数据传送中的图片url地址 ,通过fetch将图片转换成blob 再转换成文件。
 const getImageFileFromUrl = (url, imageName, callback) => {
        fetch(url)
          .then((res) => {
            return res.blob();
          })
          .then((blob) => {
            const imgFile = new File([blob], imageName, { type: "image/jpeg" });
            callback(imgFile);
          });
      }
// 选择默认图片
const chooseStaticImg = (imageUrl) => {
        getImageFileFromUrl(imageUrl, "image.png", (file) => {
          //获取file对象 图片处理方法
          changeFileList(file)
        });
      }

 const imgUrl = e.dataTransfer.getData("text");
      // 拖拽的不是文件 && 拖拽图片被赋值了图片链接
      if(!e.dataTransfer.files?.length && imgUrl) {
        chooseStaticImg(imgUrl);
      }

其中有坑,图片fetch是走了接口请求,这里就会有跨域问题,需要后端设置允许图片跨域下载

以下是几种图片格式之间的转换:

图片标签拖拽 && url、base64、Blob、File、canvas之间相互转换

背景:已有选择本地文件上传和粘贴图片上传,由于客服喜欢使用拖拽事件,提出要求会话框中的图片通过拖拽到右侧可上传区域释放后可以上传相关的图片。

问题: 拖拽聊天框中的图片和本地图片拖拽有什么不一样?传递什么数据?图片地址为什么不能就直接用当前图片地址?图片跨域(开发过程中,我的浏览器开起来允许跨域请求)

尝试: 请求允许跨域(mode: 'cros')、图片转成canvas、图片允许跨域(crossOrigin:'Anonymous'),后端服务器请求图片

  1. 先在聊天框中监听拖拽事件,携带上图片地址
    const onDrag = (e) => {
      // 携带上拖拽图片的地址
      e.dataTransfer.setData('text/plain', e.target.currentSrc);
    };
    const el = document.getElementById('im-jtalk-chat__zone');
    el?.addEventListener('dragstart', onDrag);
  1. 在拖拽目标上监听onDrop事件,获取数据传送中的图片url地址 ,通过fetch将图片转换成blob 再转换成文件。
 const getImageFileFromUrl = (url, imageName, callback) => {
        fetch(url)
          .then((res) => {
            return res.blob();
          })
          .then((blob) => {
            const imgFile = new File([blob], imageName, { type: "image/jpeg" });
            callback(imgFile);
          });
      }
// 选择默认图片
const chooseStaticImg = (imageUrl) => {
        getImageFileFromUrl(imageUrl, "image.png", (file) => {
          //获取file对象 图片处理方法
          changeFileList(file)
        });
      }

 const imgUrl = e.dataTransfer.getData("text");
      // 拖拽的不是文件 && 拖拽图片被赋值了图片链接
      if(!e.dataTransfer.files?.length && imgUrl) {
        chooseStaticImg(imgUrl);
      }

其中有坑,图片fetch是走了接口请求,这里就会有跨域问题,需要后端设置允许图片跨域下载

以下是几种图片格式之间的转换:

image.png

URL => Blob
 function URLToBlob(url, callback) {
     // 图片地址需要允许跨域
    fetch(url).then(res => res.blob()).then(res => {
      callback(res)
    })
  }
URL => base64
 function URLToBase64(url, callback) {
    let image = new Image();
    // CORS 策略,会有跨域问题
    image.setAttribute("crossOrigin",'Anonymous');
    image.src = url;
    image.onload = function() {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      // 将图片插入画布并开始绘制
      canvas?.getContext('2d')?.drawImage(image, 0, 0);
      // result
      let result = canvas.toDataURL('image/png')
      callback(result)
    };
  }
URL => canvas
 function URLToBase64(url, callback) {
    let image = new Image();
    // CORS 策略,会有跨域问题
    image.setAttribute("crossOrigin",'Anonymous');
    image.src = url;
    image.onload = function() {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      // 将图片插入画布并开始绘制
      canvas?.getContext('2d')?.drawImage(image, 0, 0);
      callback(canvas)
    };
  }
canvas => URL
function canvasToURL(canvas) {
    return canvas.toDataURL('image/png')
  }
canvas => Blob
function canvasToBlob(canvas, callback) {
    canvas.toBlob(blob => {
      callback(blob)
    }, "image/jpeg")
  }
base64 => Blob
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"
function Base64ToBlob(base64) {
    const arr = base64.split(",");
    const mime = arr[0].match(/:(.*?);/)[1];
    const bstr = atob(arr[1]);
    let n = bstr.length;
    let u8arr = new Uint8Array(n);
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
  }
Blob => base64
 function BlobToBase64(blob, callback) {
    const a = new FileReader();
    a.readAsDataURL(blob); 
    a.onload = function (e) {
      callback(e.target?.result);
    };
  }
Blob => File
function BlobToFile(blob) {
    return new window.File([blob], 'imageName', { type: 'text/plain' })
  }
FIle => Blob
<input type="file" accept="image/*" onChange={onChange} />

function FileToBlob (file) {
    let url = window.URL.createObjectURL(file.item[0]);
    return url;
}

const onChange = (e) => {
    FileToBlob(e.nativeEvent.srcElement.files)
  }
URL => Blob
 function URLToBlob(url, callback) {
     // 图片地址需要允许跨域
    fetch(url).then(res => res.blob()).then(res => {
      callback(res)
    })
  }
URL => base64
 function URLToBase64(url, callback) {
    let image = new Image();
    // CORS 策略,会有跨域问题
    image.setAttribute("crossOrigin",'Anonymous');
    image.src = url;
    image.onload = function() {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      // 将图片插入画布并开始绘制
      canvas?.getContext('2d')?.drawImage(image, 0, 0);
      // result
      let result = canvas.toDataURL('image/png')
      callback(result)
    };
  }
URL => canvas
 function URLToBase64(url, callback) {
    let image = new Image();
    // CORS 策略,会有跨域问题
    image.setAttribute("crossOrigin",'Anonymous');
    image.src = url;
    image.onload = function() {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      // 将图片插入画布并开始绘制
      canvas?.getContext('2d')?.drawImage(image, 0, 0);
      callback(canvas)
    };
  }
canvas => URL
function canvasToURL(canvas) {
    return canvas.toDataURL('image/png')
  }
canvas => Blob
function canvasToBlob(canvas, callback) {
    canvas.toBlob(blob => {
      callback(blob)
    }, "image/jpeg")
  }
base64 => Blob
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"
function Base64ToBlob(base64) {
    const arr = base64.split(",");
    const mime = arr[0].match(/:(.*?);/)[1];
    const bstr = atob(arr[1]);
    let n = bstr.length;
    let u8arr = new Uint8Array(n);
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
  }
Blob => base64
 function BlobToBase64(blob, callback) {
    const a = new FileReader();
    a.readAsDataURL(blob); 
    a.onload = function (e) {
      callback(e.target?.result);
    };
  }
Blob => File
function BlobToFile(blob) {
    return new window.File([blob], 'imageName', { type: 'text/plain' })
  }
FIle => Blob
<input type="file" accept="image/*" onChange={onChange} />

function FileToBlob (file) {
    let url = window.URL.createObjectURL(file.item[0]);
    return url;
}

const onChange = (e) => {
    FileToBlob(e.nativeEvent.srcElement.files)
  }

我做了个 AI + 实时协作 的 draw.io,免费开源!!

2026年2月13日 17:40

前言

相信各位程序员朋友们一定使用过各种绘图软件吧,比如GitHub上star数量特别高的drawio。我们可以使用drawio来画各种图,比如UML类图,流程图,软件架构图等各种图,甚至可以拿来画简单的产品原型图(对于那些不太熟悉使用AxureRP的人来说)。在这个AI爆火的时代,我就在想能不能用AI来生成drawio可以识别的图表呢,再进一步想,能不能多人同时操作同一个图表也就是多人实时协作呢。于是,我就开发了这款AI驱动+多人实时协作的drawio。

在线体验地址:

www.intellidraw.top

编辑

并且,我直接把完整的前后端项目源代码给开源到GitHub上啦!!!,大家可以自行拉取到本地进行学习,修改。

前端开源地址:

github.com/wangfenghua…

后端开源地址:

github.com/wangfenghua…

接下来肯定是各位程序员朋友们最关心的技术栈啦!

项目技术栈

前端

使用Next.js服务端渲染技术 + Ant Design组件库 + yjs + ws + 内嵌的drawio编辑器

Next.js天然对SEO友好,使用蚂蚁金服开源的Ant Design组件库简化样式的编写,使用yjs+WebSocket实现实时协作编辑图表功能。

后端

当然是使用Java开发啦! 并使用一个Node.js微服务来处理实时协作逻辑

后端采用jdk21 + Spring Boot(SSM) + Spring AI + Spring Security + Node.js实现

Spring Boot后端负责处理整个系统主要的业务逻辑,Spring AI 为系统提供AI能力,并使用工厂模式可以使用多种不同的llm,包括系统内置的和用户自定义的。 Spring Security负责处理基于RBAC的权限校验,包括协作房间的用户权限和团队空间的用户权限。由于Java对yjs的支持并不友好,所以直接引入一个Node.js来处理实时协作逻辑,Spring Boot暴露鉴权接口供Node.js对连接进行鉴权。

项目主要功能

1、AI生成Drawio图表

一句话生成你想要的图表  

编辑

这样,不管是想要画什么图表,直接一句话,使用自然语言就能拿到自己想要的图表,并且可以直接导出自己想要的格式,比如SVG,或者PNG。

编辑

⭐⭐⭐实时协作

可以直接在图表编辑页面点击右上角的协作按钮开启协作。系统会自动创建协作房间。

编辑

这里会通过ws连接后端Node.js服务,从而实现实时协作逻辑。比使用Spring Boot的WebSocket透穿yjs的二进制update数据性能更优,支持高并发。

并且也可以管理房间内的成员,比如修改权限等等,前提是私密的房间。如果是公开的房间就不需要进行房间成员的管理了。、

编辑

编辑

团队空间

本项目有公共空间和团队空间之分,所谓公共空间就比如你创建了一个图表到公共空间里面,那么所有的人都能在图表广场看到你所创建的图表,除非你创建一个私有空间或者是团队空间。

编辑

编辑

并且团队空间分为普通版专业版和旗舰版三个等级,区别就在于可以创建的图表数量不同,旗舰版最多。

同时团队空间也是基于RBAC的权限控制的。

编辑

编辑

同时可以编辑团队空间内的图表和空间信息(管理员),也可以在本团队空间之内创建图表。

也可以通过用户id邀请其他用户加入到本团队空间内。在空间管理页面也分为我创建的空间和我加入的空间。

空间管理

编辑

协作房间管理

编辑

图表管理

编辑

开源与贡献

各位大佬可以在GitHub提交PR。

或者是将完整的前后端项目拉取到本地运行

后端的配置文件格式如下:

spring:
  application:
    name: drawio-backend
  mail:
    host:   # 您的SMTP服务器地址
    port:                   # 您的SMTP服务器端口
    username:  # 您的邮箱账户
    password:     # 您的邮箱密码或授权码
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: 
            client-secret: 
            scope: read:user,user:email
            redirect-uri: 
            client-name: Intellidraw 智能绘图
            provider: github
        provider:
          github:
            authorization-uri: https://github.com/login/oauth/authorize
            token-uri: https://github.com/login/oauth/access_token
            user-info-uri: https://api.github.com/user
            user-name-attribute: login
  ai:
    custom:
      models:
        moonshotai:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        deepseek:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        glm:
          api-key: 
          model: glm-4.6
        qwen:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        duobao:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
    openai:
      api-key: 
      base-url: 
      chat:
        options:
          model: 
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    username: 
    url: 
    password: 
    driver-class-name: com.mysql.cj.jdbc.Driver
    # druid 连接池管理
    druid:
      # 初始化时建立物理连接的个数
      initial-size: 5
      # 最小连接池数量
      min-idle: 5
      # 最大连接池数量
      max-active: 20
      # 获取连接等待超时的时间
      max-wait: 60000
      # 一个连接在池中最小的生存的时间
      test-while-idle: true
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 30000
      validation-query: select 'x'
      test-on-borrow: false
      test-on-return: false
      pool-prepared-statements: false
      filters: stat,wall,slf4j
      max-pool-prepared-statement-per-connection-size: -1
      use-global-data-source-stat: true
      connection-properties: 'druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000'

server:
  port: 8081
  servlet:
    context-path: /api
rustfs:
  client:
    endpoint: 
    access-key: 
    acess-secret: 
    bucket-name: 


management:
  endpoints:
    web:
      exposure:
        include: health, prometheus
  metrics:
    distribution:
      percentiles:
        http:
          server:
            requests: 0.5, 0.75, 0.9, 0.95, 0.99
  1. Fork 仓库 ➜ 点击 GitHub 右上角 Fork 按钮。
  2. 创建分支 ➜ 推荐使用有意义的分支名
  3. 提交代码 ➜ 确保代码可读性高,符合规范。
  4. 提交 Pull Request(PR) ➜ 详细描述您的更改内容,并关联相关 issue(如有)。
  5. 等待审核 ➜ 维护者会进行代码审核并合并。

以上讲解如果对你有帮助,不妨给我的项目点个小小的 star 🌟,成为一下我的精神股东呢

UV 包管理器 - 新一代的 Python 包和环境管理神器

作者 闲云一鹤
2026年2月13日 17:22

1. uv 介绍

1.1 uv 是什么?

官网:docs.astral.sh/uv/

简介:

  1. 用 Rust 编写的极其快速的 Python 包管理器

  2. 能替代 pip、pip-tools、pipx、poetry、pyenv、twine、virtualenv 等多种工具

  3. 比 pip 快 10 到 100 倍

uv 是由 Astral 开发的高性能 Python 工具,旨在用一个工具替代上述绝大多数工具

涵盖了从Python 版本管理到包管理,再到虚拟环境和发布的全流程。

1.2 uv 能做什么?

安装和管理 python 版本,创建项目,快速安装包,创建虚拟环境,项目打包/构建和发布

通过 uv.lockpyproject.toml 文件安装依赖复刻项目

无需手动下载管理复杂的 python 环境,节省我们的精力与时间

1.3 为什么用 uv?

python 发展前期,由于官方对于环境和依赖包的管理不够重视,导致后面出现一大堆工具,极其繁杂!堪比秦始皇统一六国前的各国文字以及丈量工具皆不相同

学习 python 技术栈的过程中,了解到目前主流的 python 环境管理方式 主要有以下几种:

1. venv + pip

python 原生的管理方式,极不灵活,版本依赖不明确, 缺乏统一的锁文件机制

2. conda, mamba

功能强大但依赖大,速度慢,存在收到律师函的风险

Anaconda 商业版在 200 人以上企业需要付费授权,Miniconda / Micromamba 完全免费

3. uv

安装方便,无需单独使用 pip、pip-tools、pipx、poetry、pyenv、twine、virtualenv 等多种工具,全部都整合到了 uv 中,能大大简化开发流程

统一的锁文件给整个项目做依赖管理

全局缓存,节省空间

相比于 conda 的臃肿,uv 显得更加轻量级,所以我选择`使用 uv 来安装和管理 python 版本`

注意:

macOS and Linux 与 windows 的安装和运行命令不同,详情见官网文档,本文使用 windows 做演示

如果使用 uv 的话不建议再用传统的 pip 工具,避免污染环境

tip:uv 于 python,类似于前端的 npm/pnpm + nvm + cli/Vite

1.4 命令对照表

Python 和 Node.js 生态对照表(便于前端同学理解)

Python 生态 Node.js 生态 说明
Python Node.js 运行时
uv pnpm + nvm + Vite(三合一) 最核心类比:uv = pnpm(依赖管理) + nvm(版本管理) + Vite(构建)
PyPI npm registry 官方包仓库
uv python pin nvm use 固定项目使用特定版本
uv add pnpm add 添加依赖并写入配置文件(pyproject.toml / package.json)
uv sync pnpm install 安装所有依赖(最常用!)
uv run pnpm run / npx 在项目环境中运行命令
uv build npm pack / pnpm pack 打包成可分发的包(.whl / .tgz)
uv publish npm publish / pnpm publish 发布到 PyPI / npm registry
.venv node_modules 本地依赖文件夹(虚拟环境 vs 依赖目录)
uv.lock pnpm-lock.yaml(推荐) / package-lock.json 精确锁定版本文件
uv tool install pnpm dlx / npx 临时安装一次性工具(类似 pipx)
uv python install nvm install 下载指定 Python 版本

Python 传统命令 vs uv 命令完整对照表

传统 Python 命令 uv 命令 说明
pyenv global 3.12 uv python pin 3.12 切换/固定 Python 版本
pyenv install 3.12 uv python install 3.12 下载指定 Python
python -m venv .venv uv venv 创建虚拟环境
source .venv/bin/activate 无需激活,直接 uv run 激活虚拟环境
pip install -r requirements.txt uv sync 安装依赖(项目级)
pip install fastapi uv add fastapi 添加单个依赖
pip install "fastapi[standard]" uv add "fastapi[standard]" 添加带 extras
pip install -r requirements-dev.txt uv add --dev pytest 添加开发依赖
pip install fastapi(在激活环境下) uv pip install fastapi 临时安装(不写进项目)
python main.py(需先激活) uv run python main.py 运行脚本
uvicorn main:app(需激活) uv run uvicorn main:app 运行 uvicorn
pytest uv run pytest 运行测试
python -m build uv build 打包
twine upload dist/* uv publish 发布到 PyPI
pipx install httpie uv tool install httpie 安装一次性工具
pipdeptree uv tree 查看依赖树
手动 pip uninstall uv sync --prune 清理多余包
rm -rf .venv rm -rf .venv(直接删除即可) 删除虚拟环境

2. uv 安装

uv 安装的时候需要挂梯子,请放心大胆的用,全程花不了多少 MB 的流量

Windows 安装命令

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

uv 会默认安装到 C:\Users\dcry\.local\bin 目录下

1.png

提示安装成功,请重新启动您的终端

如果不重启终端直接运行话会报错,提示命令不可运行

2.png

查看 uv 包版本,验证是否安装成功

uv self version

也可用 uv --versionuv -V 查看 uv 版本,网上有的教程说是 uv version 命令查看版本,简直乱写,误人子弟!

查看菜单列表,列出可用的命令

uv

能正常显示命令列表也说明 uv 安装成功

3.png

3. 安装/切换 Python

使用 uv 后就不需要从官网下载安装 python 包了,但是可以在官网查看全部的 python 版本号

python 官网:www.python.org/downloads/

查看已安装和可用的 Python 版本

uv python list

4.png

好家伙,不知道是安装 uv 的时候还是运行这个命令的时候,竟然自动给我安装了 3.13.3 的 python 版本

那我就无需重复安装 python 了,就用它自带的这个版本吧

不过还是说说如何下载指定版本的 python

下载 3.14.2 的 python 版本

uv python install 3.14.2

然后再次运行命令查看是否安装成功

uv python list

显示 uv 安装的 Python 版本路径

uv python dir

卸载 3.14.2 的 python 版本

uv python uninstall 3.14.2

切换当前项目的 python 版本

uv python pin 3.14.2

注意:如果是在项目中运行 pin 命令,切换版本后记得运行 uv sync 命令同步环境(自动下载 python + 创建 .venv + 安装依赖),如果还没创建项目就无需运行 uv sync,后面的流程中 uv 会自动帮你创建环境。

注意:本地 pin 优先级高于全局 pin

切换全局默认 python 版本(电脑上所有新项目默认都用 3.14.2)

uv python pin 3.14.2 --global

注意:全局默认 python 版本,只有当项目中没有本地 .python-version 时才生效

查看当前使用的是哪个 Python 版本

uv run python --version

4. 用 uv 新建项目

新建一个项目文件夹,命名为“uv-demo”,进入文件夹,打开 Git Bash(cmd 或者 PowerShell 也行,都差不多)

5.png

运行命令初始化项目

uv init

6.png

uv 生成了下面四个文件

├── main.py
└── pyproject.toml
├── .python-version
├── README.md

简单解释这四个文件的作用

  • main.py

    main.py 是程序的入口文件

  • pyproject.toml

    pyproject.toml 是整个项目的配置文件

    包含:项目名称(name)、项目版本(version)、项目描述(description),说明文档(readme),Python 版本要求(requires-python),依赖列表(dependencies)

  • python-version

    python-version 文件用于指定当前项目使用的 Python 版本

    在当前项目内运行 uv run、uv add、uv sync、uv build 等 uv 命令就会自动使用此版本

    如果想切换当前项目的 python 版本需要运行 uv python pin 3.14.2 命令

    python-version 文件只影响当前项目(不会影响其他项目)

  • README.md

    项目说明文档,这个不需要多解释了吧

5. 运行 python 项目/主程序

打开 main.py 文件,发现有默认代码,内容为输出打印

运行 main.py 程序

uv run main.py

7.png

vscode 提示命令不可运行,把所有 vscode 窗口关闭重新打开即可

再次运行命令,可以在控制台看见输出 Hello from uv-demo!

8.png

6. 用 uv 创建虚拟环境

6.1 什么是虚拟环境?

因为不同的 python 项目所需的环境依赖版本不同,如果所有项目都在全局环境中运行,切换不同项目的时候极易发生冲突

所以需要虚拟环境来隔离项目,虚拟环境就是一个用于隔离不同项目的环境

想要将项目发给别人运行的话,只需要将虚拟环境的配置发给对方,对方运行命令安装依赖后即可运行

6.2 怎么创建虚拟环境

前面的 uv run main.py 运行程序后,除了控制台输出打印外,还有一个重点不知道大家有没有观察到

项目下自动生成了 .venv 文件夹 !和 uv.lock 锁文件

  • .venv

    .venv 文件夹就是虚拟环境,每一个虚拟环境都是完全独立的,一个与系统其他部分隔离的 Python 环境。类似于前端的 package.jsonnode_modules

  • uv.lock

    uv.lock 是一个跨平台的锁文件(lockfile),包含你项目依赖的准确信息(安装的具体依赖版本)

    锁文件会自动从项目依赖中解析出完整的依赖结构和每个库的具体版本并且锁定

    锁文件确保开发者使用一致的包版本,运行 uv syncuv run 命令时会自动创建和更新 uv.lock

    tip: 锁文件应被提交到 git 版本控制中,以便实现跨机器的一致且可重复的安装。

几乎所有的教程都在告诉你要先使用 uv 创建虚拟环境,这些博主又在误人子弟

你完全不需要手动创建虚拟环境。在合适的时机,它会静默地自动创建虚拟环境,uv 真的好体贴!!

6.3 什么是合适的时机?

uv 创建虚拟环境非常智能,可以通过运行 uv venv 命令手动创建,它也会根据情况自动创建。

当您首次运行项目命令(即 uv run、uv sync 或 uv lock)或者 uv add xxx 安装库时

uv 将自动在项目的根目录中创建一个 .venv 虚拟环境文件夹(用于隔离依赖安装)和 uv.lock 锁文件(用于记录精确依赖版本,确保可重现性)

来自:docs.astral.sh/uv/guides/p…

7. 用 uv 安装库/依赖包

7.1 安装库

官网:pypi.org/

简介:PyPI 是 Python 的官方第三方软件包仓库,类似 npm

uv 中通过 uv add 命令为项目安装依赖包,安装成功的库名,会存在 pyproject.toml 文件的 dependencies 数组里面,同时也会更新锁定文件和项目环境

安装 numpy

uv add numpy

9.png

此时,uv 会按顺序执行以下操作:

更新 pyproject.toml:将 numpy 包名和版本范围添加到 [project.dependencies] 列表中。

10.png

更新 uv.lock:解析所有依赖项的精确版本和哈希值,确保环境可复现。

11.png

12.png

同步虚拟环境:直接在 .venv 中安装 numpy 包,让你的代码立刻能跑。

安装特定版本的库

uv add numpy==2.2.5

如果同时安装多个库,中间用逗号隔开

uv add numpy,pandas

删除库

uv remove numpy

tip: uv add 等同于 python 的原生工具 pip 的下载命令 pip install tip: uv remove 等同于 python 的原生工具 pip 的卸载命令 pip uninstall tip: 以后在 pypi 安装包的时候,直接把文档安装命令中的 pip install 或者 uv pip install 替换为 uv add 即可。其他人拉取仓库的代码后,只需要运行 uv sync 命令,就能获得一个和你一模一样的开发环境了!

更新库

uv sync --upgrade-package langgraph

7.2 修改库的镜像源为国内地址

文档:docs.astral.sh/uv/concepts…

uv 会默认从 https://pypi.org/ 下载库,在国内的朋友如果没有梯子可能会无法下载

所以 uv 支持通过修改项目下 pyproject.toml 文件的软件包索引(Package indexes),来切换库的国内镜像源

  • 单个镜像源

    切换为阿里云镜像源

    [[tool.uv.index]]
    name = "aliyun"
    url = "https://mirrors.aliyun.com/pypi/simple/"
    default = true
    

    默认情况下,uv 将 Python 软件包索引 (PyPI) 作为“默认”索引,即在任何其他索引上都找不到软件包时使用的索引

    我这里加上 default = true 表示将阿里云镜像源作为“默认”索引

  • 多个镜像源

    也可以设置多个源,uv 会自动按照顺序决定优先级

    以下分别是阿里云镜像源,华为云镜像源和清华大学镜像源

    [[tool.uv.index]]
    name = "aliyun"
    url = "https://mirrors.aliyun.com/pypi/simple/"
    
    [[tool.uv.index]]
    name = "huaweicloud"
    url = "https://mirrors.huaweicloud.com/repository/pypi/simple/"
    
    [[tool.uv.index]]
    name = "tuna"
    url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
    

注意: uv 只有下载 python 包可以用国内镜像,但是下载 uv 以及 python 版本没办法换国内源;并且听说修改镜像源后速度也不稳定,我没有自己尝试过,因为我挂梯子下载挺好用的

8. uv 的全局缓存

查看项目依赖关系树

uv tree

查看 uv 的全局缓存目录

uv cache dir

当其他项目安装相同的版本库的时候,uv 会复用这些缓存文件,而不是每次都重新下载

uv 下载的包会放在缓存里面,.venv 文件夹里面实际是一些快捷方式,指向你下载过的缓存,所以不会重复下载包;每新建一个虚拟环境都会从快捷方式指向缓存,conda 却是每一次都下载

9. 用 uv 复刻环境/安装项目所需的依赖文件

安装依赖,复刻环境

uv sync

运行命令后,会发生什么?

  • uv 会确认 Python 版本

    uv 会根据 .python-version 文件确定应该使用哪个版本的 Python 解释器,如果发现本地没有安装 .python-version 指定的版本,uv 会自动从其托管的二进制库中下载对应的 Python 版本。

  • uv 会对比 pyproject.toml 和 uv.lock 文件

    如果 uv.lock 不存在:uv 会根据 pyproject.toml 的要求,解析出满足所有约束的最高版本,并生成 uv.lock

    如果 pyproject.toml 有改动:uv 会更新 uv.lock,确保锁定文件与你的最新要求同步。

  • uv 会检查项目根目录下的 .venv 文件夹

    如果文件夹不存在,uv 会自动创建一个全新的虚拟环境

    如果当前环境的 Python 版本不对,或者包的版本不对,uv 会对其进行调整

删除 .venv 中所有的依赖文件

Remove-Item -Recurse -Fouce .\.venv

也可以直接删除 .venv 文件夹

除了 uv sync 命令,也可以用 uv 从传统的 requirements.txt 文件下载依赖(不建议!)

uv add -r requirements.txt

导出锁文件为 requirements

uv export --format requirements.txt

requirements.txt 格式是 Python 依赖中最广泛支持的格式。它可以与 pip 及其他 Python 包管理器一起使用。

注意:如果用 uv 的话,就别再使用 requirements 了;这里介绍 requirements.txt 是为了给 pip 及其他 Python 包管理器使用。

10. uv 打包和发布包

与前端的打包发包概念不同,这里用 vue 举例,运行 npm run build 之后,生成 dist 文件,本质是:静态 HTML + JS + CSS 文件,所以可以直接丢给 Nginx 部署就能进行访问

在 Python 中,打包是将程序生成 .whl 和 .tar.gz 文件,发布包是将代码上传到 PyPI(Python Package Index),别人可以通过 pip install 或 uv add 来将它安装到项目中进行使用(也可以将 .whl 和 .tar.gz 文件 直接发给对方进行安装使用)

既然项目打包成 .whl 和 .tar.gz 文件是给别的程序员使用,那我如果想将写好的 python 程序提供给普通用户使用应该怎么做呢?要么直接打包成 exe 程序,要么通过 Docker 部署到服务器运行,通过浏览器进行访问

10.1 打包

uv build

执行后会在项目根目录生成 dist/ 文件夹,里面有两个文件,都是可分发的安装包

  • .whl

    Python Wheel(二进制分发包)

    已经预编译好的二进制文件

    安装速度极快(秒级)

    文件大小通常更小

  • .tar.gz

    Source Distribution(源码分发包)

    纯源码 + 构建脚本

    安装速度较慢(需要现场编译)

    文件大小通常更大

拿到这两个文件后具体怎么使用?运行命令加到自己的项目依赖里

uv add ./dist/myproject-0.1.0-py3-none-any.whl

这里的 dist 是文件路径,myproject-0.1.0-py3-none-any 是 .whl 文件名

安装后就可以直接 import myproject 使用了

10.2 发布

发布需要 PyPI 账号

运行命令

uv publish

uv 会自动上传 .whl 和 .tar.gz 到 PyPI

❌
❌