阅读视图

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

构建无障碍组件之Disclosure Pattern

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 永生

我在家里一边收拾家里小鱼缸,一边刷到 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 调的工具层

我讲真,这种人会很贵

useStorage:本地数据持久化利器

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

受控与非受控组件

引言:数据驱动的本质

在 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”的混合模式,提供更好的开发者体验。

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

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

在现代前端开发中,用户体验与性能优化是衡量一个应用质量的关键指标。然而,浏览器的许多原生事件,如 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

作用: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 数字精确度

事情的起源: 项目中 填写的金额是小数 传给后端需要 *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之间相互转换

图片标签拖拽 && 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,免费开源!!

前言

相信各位程序员朋友们一定使用过各种绘图软件吧,比如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 包和环境管理神器

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

【ThreeJS实战】5个让我帧率翻倍的小技巧,不用改模型

前言:最近项目优化进入瓶颈,模型不想动(或者动不了),但帧率就是上不去。正当我准备上Blender、搞重型优化的时候,老大扔过来一句话:"先改代码,5分钟见效的那种。"我将信将疑试了试,好家伙,帧率从30fps直接干到60fps,GPU占用从100%降到60%,而且一行模型都没改!今天就把这5个代码小技巧分享出来,每个技巧的前因后果都讲清楚,看完立刻就能用。

如果你也遇到过这种情况:场景复杂、模型动不了,但性能就是差。别急着上重型优化,先看看这5个纯代码技巧,可能改几行就解决了。


技巧1:限制像素比,4K屏手机秒变流畅

问题现象

iPhone 14 Pro的屏幕像素比是3,Three.js默认按window.devicePixelRatio渲染。这意味着什么?

具体计算

  • 你的canvas在CSS里设了width: 1920px; height: 1080px
  • Three.js内部会乘以devicePixelRatio(iPhone 14 Pro是3)
  • 实际渲染分辨率 = 1920 × 3 = 5760px宽,1080 × 3 = 3240px高
  • 总像素数 = 5760 × 3240 = 1860万像素

为什么GPU会去世

  • 普通显示器1920×1080 = 207万像素
  • iPhone 14 Pro实际渲染1860万像素,是普通的9倍
  • 每帧要填充1860万个像素,片元着色器跑9次工作量
  • RTX 3080都顶不住,手机GPU直接爆炸

验证方法

console.log('devicePixelRatio:', window.devicePixelRatio); // iPhone输出3
console.log('canvas实际尺寸:', renderer.domElement.width, 'x', renderer.domElement.height); // 输出5760 x 3240

解决方案

限制最大像素比为2,超过2的按2算:

// 原来(默认,坑!)
renderer.setPixelRatio(window.devicePixelRatio); // iPhone上=3,渲染5760x3240

// 优化后(限制最大2x)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // iPhone上=2,渲染3840x2160

为什么2x是甜点

  • 2x = 3840×2160 = 829万像素,是3x的44%
  • 人眼在手机上超过2x基本看不出区别(视网膜屏极限)
  • GPU负担减半,帧率翻倍

效果对比

指标 原来(3x) 优化后(2x)
实际渲染分辨率 5760×3240 3840×2160
总像素数 1860万 829万
GPU像素填充工作量 100% 44%
帧率 25fps 60fps
肉眼观感 极致清晰 几乎没区别

为什么 iPhone 14 Pro 的像素比是 3?
苹果为了 Retina 清晰度,把 852×393 的逻辑分辨率,做成了 2556×1179 的物理分辨率(3×3 倍)。人眼根本看不出 2x 和 3x 的区别,但 GPU 要多算 125% 的像素。限制 2x 是白捡的性能。


技巧2:静态场景关闭矩阵自动更新

问题现象

场景里有2000个设备,只有相机在动,设备本身完全不动。但Three.js默认每帧做这件事:

// Three.js内部每帧自动执行(你没写,但它做了)
scene.traverse((obj) => {
  if (obj.matrixAutoUpdate) {
    obj.updateMatrix(); // 重新计算位置/旋转/缩放的矩阵
  }
});

为什么这是浪费

  • 2000个设备 × 每帧计算矩阵 = 2000次矩阵运算/帧
  • 矩阵运算涉及16个浮点数的乘加,CPU算力白白消耗
  • 设备根本没动,算出来的矩阵和上一帧一模一样

矩阵是什么

  • 3D物体的位置、旋转、缩放,最终都要转成4×4的矩阵传给GPU
  • position.set(10, 0, 0)只是设置属性,矩阵才是GPU认识的格式
  • 每帧把属性转矩阵,就是updateMatrix()在做的事

解决方案

物体初始化后,如果确定不动,关闭自动更新:

// 场景加载完成后,冻结所有静态物体
scene.traverse((obj) => {
  if (obj.isMesh) {
    obj.matrixAutoUpdate = false; // 关闭自动计算
    obj.updateMatrix(); // 手动算最后一次,之后frozen
  }
});

// 如果某个设备后期需要动了,再单独打开
function moveDevice(device) {
  device.matrixAutoUpdate = true; // 恢复自动更新
  device.position.x += 10; // 现在改动会生效
}

为什么先updateMatrix()一次

  • 关闭matrixAutoUpdate后,属性改动不会自动转矩阵
  • 必须先手动调用一次,把当前属性转成矩阵存起来
  • 之后GPU一直用这个矩阵,直到你重新打开matrixAutoUpdate

效果对比

指标 原来 优化后
CPU每帧矩阵计算 2000次 0次(静态物体)
CPU占用 35% 8%
帧率 45fps 60fps

适用场景

  • 智慧站房、数字孪生(设备基本不动)
  • 建筑可视化(墙体、地板静态)
  • 不适用:游戏、动画(物体频繁动)

技巧3:强制计算包围球,视锥剔除生效

问题现象

相机只看向10个设备,但Three.js渲染了全部2000个。为什么?

视锥剔除(Frustum Culling)原理

  • 相机有个视野范围(视锥体,像个四棱锥)
  • 物体在视锥体内 → 渲染
  • 物体在视锥体外 → 跳过,省GPU

但视锥剔除有个前提:知道物体的位置和大小,即包围盒(Bounding Box)包围球(Bounding Sphere)

问题所在

  • 有些模型加载后,geometry.boundingSpherenull
  • Three.js无法判断"这个物体在视野外",只能保守地渲染
  • 结果:视野外的1900个设备全渲染了

验证方法

loader.load('model.glb', (gltf) => {
  gltf.scene.traverse((obj) => {
    if (obj.isMesh) {
      console.log('包围球:', obj.geometry.boundingSphere); 
      // 如果输出null,说明视锥剔除失效
    }
  });
});

解决方案

手动计算包围球:

loader.load('model.glb', (gltf) => {
  gltf.scene.traverse((obj) => {
    if (obj.isMesh) {
      // 关键!手动计算包围球
      obj.geometry.computeBoundingSphere();
      
      // 确保视锥剔除开启(默认true,但确认一下)
      obj.frustumCulled = true;
    }
  });
  scene.add(gltf.scene);
});

computeBoundingSphere做了什么

  • 遍历几何体所有顶点,找中心点和最大半径
  • 生成一个球体(中心+半径),刚好包住整个物体
  • Three.js用这个球体快速判断"在不在视野内"

为什么用包围球不用包围盒

  • 球体判断快(距离公式简单),包围盒要判断6个面
  • 球体旋转后不变,包围盒旋转后要重新计算
  • 精度稍差(球体可能包住更多空白),但性能更好

效果对比

场景 原来(无包围球) 优化后(有包围球)
相机看向10个设备 渲染2000个 渲染10个
GPU顶点处理 10亿顶点 5000万顶点
帧率 12fps 55fps

技巧4:关闭色调映射,后处理开销砍半

问题现象

用了MeshStandardMaterial,帧率比MeshBasicMaterial低很多,为什么?

色调映射(Tone Mapping)是什么

  • 物理渲染(PBR)的亮度范围是0到无限大(HDR)
  • 显示器只能显示0到255(8位,LDR)
  • 色调映射把HDR的亮度"压"进LDR范围,同时保持对比度

Three.js默认行为(r150+):

renderer.toneMapping = THREE.ACESFilmicToneMapping; // 电影级色调映射
renderer.toneMappingExposure = 1.0;

为什么耗性能

  • 每像素都要算ACES曲线(复杂数学公式)
  • 涉及对数、幂运算、颜色空间转换
  • 1920×1080画面 = 207万次复杂计算/帧

ACESFilmicToneMapping具体做什么

  1. 把线性颜色转对数空间
  2. 应用S型曲线(亮部压缩,暗部提升)
  3. 转回线性,再转sRGB输出
  4. 每帧每像素都算,GPU负担大

解决方案

工业可视化场景不需要电影感,直接关闭或换简单的:

// 方案A:完全关闭(最快)
renderer.toneMapping = THREE.NoToneMapping;

// 方案B:简单线性(稍好,但快)
renderer.toneMapping = THREE.LinearToneMapping;

// 方案C:Reinhard(平衡质量和速度)
renderer.toneMapping = THREE.ReinhardToneMapping;

不同色调映射对比

类型 视觉效果 性能 适用场景
ACESFilmicToneMapping 电影感,对比强 影视、游戏
ReinhardToneMapping 自然,稍平淡 中等 一般3D
LinearToneMapping 线性,最平淡 数据可视化
NoToneMapping 原始颜色 最快 工业可视化

效果对比

指标 ACESFilmic(默认) NoToneMapping
片元着色器指令数 ~50条 ~5条
帧率 48fps 60fps
视觉效果 电影感 稍微平淡

技巧5:后台标签页节流,不抢资源

问题现象

用户切到别的标签页聊微信,你的Three.js场景还在后台疯狂渲染,风扇狂转。

浏览器行为

  • requestAnimationFrame 在后台标签页不会暂停,只是降频到1fps或保持
  • Three.js继续渲染,CPU/GPU 100%占用
  • 笔记本发热、耗电、风扇噪音

为什么这是问题

  • 用户看不见,渲染完全浪费
  • 后台标签页抢资源,前台标签页变卡
  • 笔记本用户直接骂娘

解决方案

用Page Visibility API检测标签页是否可见:

let animationId;
let isRunning = true;

function animate() {
  if (!isRunning) return; // 暂停时不渲染
  
  animationId = requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

// 监听标签页可见性变化
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    // 后台:停止渲染
    isRunning = false;
    cancelAnimationFrame(animationId);
    console.log('后台暂停,省电模式');
  } else {
    // 前台:恢复渲染
    isRunning = true;
    animate();
    console.log('前台恢复,正常渲染');
  }
});

为什么不用renderer.setAnimationLoop(null)

  • r160版本setAnimationLoop行为稳定,但r180+可能有WebXR相关改动
  • requestAnimationFrame + cancelAnimationFrame是浏览器标准,版本无关
  • 代码更可控,暂停/恢复逻辑清晰

进阶:后台降频(不暂停,只降速)

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    // 后台:每100ms渲染一帧(10fps,省90%资源)
    clearInterval(window.bgInterval);
    window.bgInterval = setInterval(() => {
      renderer.render(scene, camera);
    }, 100);
  } else {
    // 前台:恢复60fps
    clearInterval(window.bgInterval);
    animate();
  }
});

效果对比

场景 原来 优化后
后台标签页渲染 60fps狂跑 0fps(暂停)或10fps(降频)
CPU/GPU占用 100% 5%
笔记本温度 烫手 正常
电池续航 2小时 6小时

总结:5个技巧对比表

技巧 改动成本 效果 核心原理 适用场景
限制像素比 1行代码 ⭐⭐⭐⭐⭐ 减少像素填充工作量 所有项目,尤其移动端
关闭矩阵更新 5行代码 ⭐⭐⭐⭐ 跳过不必要的矩阵计算 静态场景,设备不动
计算包围球 5行代码 ⭐⭐⭐⭐⭐ 启用视锥剔除,视野外不渲染 大场景,视野外物体多
关闭色调映射 1行代码 ⭐⭐⭐ 跳过后处理色调映射 工业可视化,不需要电影感
后台节流 10行代码 ⭐⭐⭐ 看不见时不浪费资源 长时间运行,笔记本用户

核心认知

  • 不改模型,纯代码优化,5分钟见效
  • 限制像素比视锥剔除是性价比最高的,必做
  • 其他三个看场景需求,静态场景做矩阵冻结,长运行做后台节流

不用Blender,不用压模型,改几行代码就搞定,这才是工程师的浪漫。

下篇预告:《【ThreeJS实战》6个内存泄漏大坑,让你的场景越用越卡(附检测工具)》

互动:这5个技巧你用过几个?在评论区报数,让我看看谁是"优化老司机"😏

深入 ahooks 3.0 useRequest 源码:插件化架构的精妙设计

深入 ahooks 3.0 useRequest 源码:插件化架构的精妙设计

ahooks 的 useRequest 是一个强大的异步数据管理 Hook,它不仅处理 loading、data、error 等基础状态,还支持轮询、防抖、节流、屏幕聚焦重新请求等高级功能。这一切都建立在一套精妙的插件化架构之上。

一、核心架构:Fetch 类 + Plugin 机制

useRequestImplement.ts 可以看出,核心实现分为三部分:

// 1. 使用 useLatest 保持 service 引用不变
const serviceRef = useLatest(service);

// 2. 使用 useCreation 确保 Fetch 实例只创建一次
const fetchInstance = useCreation(() => {
  const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);
  return new Fetch<TData, TParams>(
    serviceRef,
    fetchOptions,
    update,
    Object.assign({}, ...initState),
  );
}, []);

// 3. 运行所有插件钩子
fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));

为什么这样做?

  • useLatest:保持函数引用地址不变,但内部始终指向最新的 service 函数
  • useCreation:类似 useMemo,但保证引用稳定,避免 Fetch 实例重复创建
  • 插件化:将非核心功能(防抖、轮询、缓存等)交给插件处理,核心类保持简洁

二、请求竞态问题:count 计数器方案

当用户快速发起多个请求时,可能出现后发起的请求先返回的情况。ahooks 通过 count 计数器解决:

// Fetch 内部实现(简化版)
class Fetch {
  count = 0;  // 请求计数器

  async run(...params) {
    this.count += 1;
    const currentCount = this.count;  // 记录当前请求的 count

    const result = await this.serviceRef.current(...params);

    // 只有最新的请求结果才会被接受
    if (currentCount !== this.count) return;

    this.setState({ data: result });
  }
}

原理:每次发起请求时 count + 1,请求返回后检查 currentCount === this.count,不匹配则说明已被新请求覆盖,直接丢弃旧结果。

三、组件卸载保护:unmountedRef 标记

避免在组件卸载后执行 setState 导致的内存泄漏警告:

class Fetch {
  unmountedRef = { current: false };

  cancel() {
    this.unmountedRef.current = true;
  }
}

// useRequestImplement.ts 中
useUnmount(() => {
  fetchInstance.cancel();  // 卸载时标记
});

// runAsync 方法中
if (this.unmountedRef.current) return;

通过 unmountedRef 标记位,在请求返回时检查组件是否已卸载,卸载则跳过状态更新。

四、返回方法的引用稳定性:useMemoizedFn

用户可能将 runrefresh 等方法传递给子组件或放入依赖数组,如果引用不稳定会导致无限重渲染:

return {
  run: useMemoizedFn(fetchInstance.run.bind(fetchInstance)),
  refresh: useMemoizedFn(fetchInstance.refresh.bind(fetchInstance)),
  // ...
};

useMemoizedFn 确保无论 Fetch 实例内部如何变化,返回给用户的方法引用始终不变。

五、插件机制的实现

插件通过生命周期钩子介入请求流程,Plugin 类型定义如下:

type Plugin<TData, TParams> = {
  onInit?: (options: Options<TData, TParams>) => any;
  onBefore?: (context: Context<TData, TParams>) => void | Stop;
  onRequest?: (context: Context<TData, TParams>, params: TParams) => void;
  onSuccess?: (data: TData, params: TParams) => void;
  onError?: (error: Error, params: TParams) => void;
  onFinally?: (params: TParams, data?: TData, error?: Error) => void;
  onUnmount?: () => void;
};

runPluginHandler 统一执行插件:

const runPluginHandler = (event: keyof Plugin) => {
  // @ts-ignore
  this.pluginImpls.forEach((impl) => {
    const handler = impl?.[event];
    if (handler) {
      handler(...args);
    }
  });
};

8 个默认插件

ahooks 内置了 8 个插件实现常用功能:

  • useDebouncePlugin:防抖
  • useThrottlePlugin:节流
  • useRetryPlugin:错误重试
  • useCachePlugin:请求缓存
  • usePollingPlugin:轮询
  • useRefreshOnWindowFocusPlugin:聚焦重新请求
  • useAutoRunPlugin:依赖变化自动请求
  • useLoadingDelayPlugin:延迟 loading

每个插件只关注自己的职责,通过生命周期钩子介入请求流程,实现了高度的可扩展性。

总结

ahooks useRequest 的设计精髓在于:

  1. 引用稳定:useLatest、useCreation、useMemoizedFn 三管齐下
  2. 请求安全:count 计数器解决竞态,unmountedRef 防止卸载后更新
  3. 插件化架构:核心类保持简洁,功能扩展通过插件实现

这种设计思想值得在自己的项目中借鉴——核心逻辑稳定可靠,扩展功能灵活可插拔。


参考链接

当vue.diff遇上了扩展运算符(...)

这是一个非常经典的 Vue 响应式性能陷阱。为了完善这个故事背景并深入剖析事故原因,我们可以将其扩写为一个实际开发中的“性能优化事故”案例。 以下是扩写后的完整内容:

故事背景

项目背景: 某电商后台管理系统正在开发“商品批量编辑”功能。该页面表单极其复杂,包含几十个字段,分散在不同的业务逻辑域中。 业务场景: 开发人员需要将“基础信息”(如商品名称、编号)和“价格库存”(如售价、库存量)两部分数据合并传递给一个通用的 LogPreview 组件(即文中的 ChildItem),用于实时生成变更日志预览。 代码场景复刻: 为了快速上线,开发人员简单复刻了业务代码,写了一个 Demo。逻辑看似清晰:父组件维护三个响应式对象 abc,通过 v-model 绑定输入框,并将 ab 合并传给子组件展示。

事故原因

1. 事故现象

在测试过程中,发现了一个诡异的现象:

  • 当用户在输入框 c(对应数据 ref({c: 3}))中输入内容时,原本应该毫无关联的 ChildItem 子组件竟然发生了重新渲染
  • 子组件内的“当前时间”一直在刷新,说明子组件在不断更新。
  • 在真实业务中,由于 ChildItem 内部包含了复杂的计算和大量 DOM 节点,这种不必要的重渲染导致了输入时明显的卡顿性能损耗

2. 核心原因剖析

问题的根源在于父组件模板中这行代码:

<ChildItem :data="{...a, ...b}"></ChildItem>
原因一:模板中的“隐形”新对象

在 Vue 的模板编译机制中,:data="{...a, ...b}" 并不是一个静态的引用。 每当父组件因为任何原因重新渲染时(即使只是修改了无关变量 c),模板中的表达式 "{...a, ...b}" 都会被重新执行。 这行代码在 JavaScript 运行时层面等同于:

// 每次渲染都创建一个全新的堆内存对象
const newData = new Object({ ...a.value, ...b.value }); 

因此,虽然 ab 的内容没变,但每次渲染都会生成一个引用地址完全不同的新对象。

原因二:Props 的浅比较

Vue 的响应式系统判断 Props 是否变化,对于对象类型,采用的是引用比较

  • 第一次渲染:传递对象引用地址 ADDR_1
  • 修改 c 触发父组件更新:模板重新执行,生成新对象引用地址 ADDR_2
  • Diff 算法判定ADDR_1 !== ADDR_2,Vue 认为 Props 发生了变化。
  • 结果:子组件被迫更新。
原因三:不必要的渲染扩散

本案例中,c 的变化导致了 ChildItem 的更新,这是典型的“过度渲染”。由于 ChildItem 接收的 data 在逻辑上并未改变(值没变,只是引用变了),这种更新完全是多余的。

3. 扩展实验证明

可以通过在子组件中打印日志来验证:

// 在 ChildItem.vue 中
import { watch } from 'vue';
watch(() => props.data, (newVal, oldVal) => {
  console.log('Props data changed');
  console.log('Old Reference:', oldVal);
  console.log('New Reference:', newVal);
  console.log('Is Same Object?', newVal === oldVal); // 结果永远是 false
}, { deep: true });

你会发现,只要父组件有任何风吹草动(比如 c 变化),控制台就会输出 Props data changed,证明了引用地址的变更触发了更新。

4. 正确的解决方案

要避免这个问题,必须保证传递给子组件的对象引用稳定。应当使用 computed 对数据进行缓存。 修正后的父组件代码:

<script setup lang="ts">
import { ref, computed } from "vue";
import ChildItem from "@/components/ChildItem.vue";
const a = ref({a:1});
const b = ref({b: 2});
const c = ref({c: 3});
// 核心修改:使用 computed 缓存对象
// 只有当 a 或 b 真正发生变化时,computed 才会返回新的对象引用
const mergedData = computed(() => ({
  ...a.value,
  ...b.value
}));
</script>
<template>
  <div class="main">
    <div class="div1">{{a}} <input v-model="a.a"/></div>
    <div class="div2">{{b}} <input v-model="b.b"/></div>
    <div class="div3">{{c}} <input v-model="c.c"/></div>
    <!-- 使用计算属性传递 -->
    <ChildItem :data="mergedData"></ChildItem>
  </div>
</template>

修正后的效果:

  • 修改 c 时,父组件渲染,但 computed 依赖的 ab 未变,因此 mergedData 返回的是缓存的旧对象引用
  • 子组件检测到 Props 引用未变,跳过渲染
  • 性能问题解决。

总结

在 Vue 模板中直接传递内联创建的对象(如 {...obj}[])是引诱“不必要更新”的常见陷阱。永远不要在 props 中直接传递内联生成的复杂数据类型,务必使用 computed 进行包装。

在任意网页里“召唤”一个火柴人:一次有趣的 JavaScript Hack

在任意网页里“召唤”一个火柴人:一次有趣的 JavaScript Hack

有时候,写点“没什么用但很好玩”的代码,比写业务代码更能提升对浏览器底层的理解。

这次分享的是一个小脚本:
只需要把它保存为书签(Bookmarklet),点击一下,就能在当前任意网页上生成一个可操控的火柴人。
它能跳跃、移动、下落穿透平台,还能拾取道具、回血、加速、增强跳跃甚至短暂无敌。

更重要的是——它不依赖任何框架,不污染页面结构,不影响交互。 先上完整代码(压缩后)

javascript:(function(){const I="__stickman_anim__";if(window[I]){window[I].destroy();delete window[I];return}const c=document.createElement("canvas");c.style.cssText="position:fixed;left:0;top:0;pointer-events:none;z-index:999999";document.body.appendChild(c);const x=c.getContext("2d");function r(){c.width=innerWidth;c.height=innerHeight}r();addEventListener("resize",r);let g=.6,f=.85,ms=3,jp=-12,k={},t=0,a,dead=!1;function centerSpawn(){let cx=innerWidth/2,cy=innerHeight/2,el=document.elementFromPoint(cx,cy);if(el){let r=el.getBoundingClientRect();return{x:r.left+r.width/2-10,y:r.top+r.height/2-20}}return{x:cx-10,y:cy-20}}let sp=centerSpawn();const p={x:sp.x,y:sp.y,vx:0,vy:0,w:20,h:40,onGround:!1,dropping:!1,hp:100,maxHp:100,inv:!1},it=[],M=5;let et=0;function spawn(){if(it.length>=M||dead)return;it.push({x:Math.random()*(c.width-30),y:Math.random()*(c.height-200),s:15,t:Math.floor(Math.random()*4)})}const si=setInterval(spawn,4e3);function eff(n){et=300;n==0&&(p.hp=Math.min(p.maxHp,p.hp+30));n==1&&(ms=6);n==2&&(jp=-20);n==3&&(p.inv=!0)}function reset(){ms=3;jp=-12;p.inv=!1}function plats(){return[...document.querySelectorAll("body *")].map(e=>e.getBoundingClientRect()).filter(r=>r.width>60&&r.height>20)}function col(){p.onGround=!1;for(let r of plats())if(p.x+p.w>r.left&&p.x<r.right&&p.y+p.h>r.top&&p.y+p.h<r.top+15&&p.vy>=0&&!p.dropping){p.y=r.top-p.h;p.vy=0;p.onGround=!0}}function pick(){for(let i=it.length-1;i>=0;i--){let o=it[i];if(p.x<o.x+o.s&&p.x+p.w>o.x&&p.y<o.y+o.s&&p.y+p.h>o.y){eff(o.t);it.splice(i,1)}}}function destroy(){dead=!0;cancelAnimationFrame(a);clearInterval(si);removeEventListener("keydown",kd);removeEventListener("keyup",ku);removeEventListener("resize",r);c.remove();delete window[I]}function upd(){if(dead)return;if(!p.inv)p.hp-=.01;if(p.hp<=0)return destroy();k.ArrowLeft&&(p.vx=-ms);k.ArrowRight&&(p.vx=ms);p.vx*=f;p.vy+=g;p.x+=p.vx;p.y+=p.vy;col();pick();k.ArrowDown||(p.dropping=!1);p.y>innerHeight+200&&(p.y=-100,p.vy=0);if(et>0&&!--et)reset();t+=Math.abs(p.vx)*.2}function limb(X,Y,l,a2){x.beginPath();x.moveTo(X,Y);x.lineTo(X+Math.cos(a2)*l,Y+Math.sin(a2)*l);x.stroke()}function draw(){x.clearRect(0,0,c.width,c.height);x.lineWidth=2;for(let o of it){x.fillStyle=o.t==0?"lime":o.t==1?"orange":o.t==2?"cyan":"gold";x.fillRect(o.x,o.y,o.s,o.s)}x.font="12px monospace";x.fillStyle="black";x.fillText(Math.floor(p.hp)+" / "+p.maxHp,c.width-100,c.height-15);let px=p.x,py=p.y;x.beginPath();x.arc(px+10,py+8,6,0,2*Math.PI);x.stroke();x.beginPath();x.moveTo(px+10,py+14);x.lineTo(px+10,py+30);x.stroke();let as=0,ls=0;p.onGround?(as=Math.sin(t)*.8,ls=Math.sin(t)): (as=-.5,ls=.5);limb(px+10,py+18,12,Math.PI/2+as);limb(px+10,py+18,12,Math.PI/2-as);limb(px+10,py+30,14,Math.PI/2+ls);limb(px+10,py+30,14,Math.PI/2-ls)}function loop(){upd();draw();a=requestAnimationFrame(loop)}function kd(e){["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.code)&&e.preventDefault();k[e.code]=!0;if(e.code=="ArrowUp"&&p.onGround)p.vy=jp;if(e.code=="ArrowDown"&&p.onGround){p.dropping=!0;p.vy=5}}function ku(e){k[e.code]=!1}addEventListener("keydown",kd,{passive:!1});addEventListener("keyup",ku);a=requestAnimationFrame(loop);window[I]={destroy}})();

20260213161751_rec_-convert.gif 我们来拆解一下它背后的设计思路。


一、Bookmarklet:最轻量的“外挂”形式

整个脚本是一个立即执行函数:

javascript:(function(){ ... })();

它的运行机制是:

  • javascript: 开头
  • 作为浏览器书签保存
  • 点击时在当前页面上下文执行

为了支持“再次点击销毁”,脚本挂载了一个全局标记:

const I = "__stickman_anim__";
if (window[I]) {
  window[I].destroy();
  delete window[I];
  return;
}

这段设计非常关键,它让这个脚本具备:

  • 可重复执行
  • 可优雅卸载
  • 不会重复创建实例

这是写任何“注入型脚本”时都应该养成的习惯。


二、Canvas 覆盖层:不干扰页面交互的核心技巧

脚本通过创建一个全屏 canvas 作为渲染层:

const c = document.createElement("canvas");
c.style.cssText = `
  position:fixed;
  left:0;
  top:0;
  pointer-events:none;
  z-index:999999
`;

这里有两个关键点:

1.pointer-events: none

这保证:

  • 鼠标点击仍然穿透 canvas
  • 不影响网页原有交互

这是实现“外挂层”体验的关键。

2.超高 z-index

确保无论什么网站,都能显示在最上层。


三、物理系统:一个极简 2D 引擎

火柴人的运动核心参数:

let g = 0.6;     // 重力
let f = 0.85;    // 摩擦
let ms = 3;      // 移动速度
let jp = -12;    // 跳跃初速度

每一帧的更新逻辑:

p.vx *= f;
p.vy += g;
p.x += p.vx;
p.y += p.vy;

这就是一个最基础的:

  • 重力模拟
  • 摩擦减速
  • 速度积分

虽然简单,但已经足够流畅。


四、网页即平台:

function plats(){
  return [...document.querySelectorAll("body *")]
    .map(e => e.getBoundingClientRect())
    .filter(r => r.width > 60 && r.height > 20)
}

把页面中所有尺寸足够大的 DOM 元素当成“平台”。

然后做简单的底部碰撞检测:

if (
  p.x + p.w > r.left &&
  p.x < r.right &&
  p.y + p.h > r.top &&
  p.y + p.h < r.top + 15 &&
  p.vy >= 0 &&
  !p.dropping
)

这意味着:

  • 页面中的 div
  • 卡片
  • 图片
  • 区块

全部变成可以踩的平台。

整个网页变成关卡。


五、道具系统:状态增强机制

每 4 秒生成一个道具:

setInterval(spawn, 4000);

道具类型:

类型 效果
0 回血
1 加速
2 超级跳跃
3 无敌

效果持续时间:

et = 300; // 帧计时

然后自动恢复默认参数。

这是一个非常典型的 Buff 状态机。


六、动画:用数学让火柴人活起来

腿部和手臂摆动基于:

Math.sin(t)

在地面时:

as = Math.sin(t) * 0.8;
ls = Math.sin(t);

在空中时:

as = -0.5;
ls = 0.5;

通过正弦函数驱动摆动,几乎零成本实现动态感。

但其实逐帧图片动画效果可能更好。


七、生命周期管理:

destroy 方法做了完整清理:

  • cancelAnimationFrame
  • clearInterval
  • removeEventListener
  • remove canvas
  • 删除 window 挂载

八、为什么这个小玩意很有价值?

它锻炼了:

  • DOM API 熟练度
  • 物理运动理解
  • 碰撞检测思路
  • 游戏循环结构
  • 浏览器渲染机制
  • 事件管理与清理

九、如果要继续进阶

可以考虑增加一些丰富的功能:

  • 攻击系统
  • 敌人 AI
  • 音效
  • 粒子爆炸效果
  • 真正的碰撞分离算法
  • 使用 QuadTree 优化平台检测
  • 使用 requestIdleCallback 优化扫描频率

十、总结

这段代码本质上做了三件事:

  1. 创建一个不影响页面的渲染层
  2. 把 DOM 元素当作游戏世界
  3. 用最小物理模型驱动角色运动

下班。

【AI 编程实战】第 11 篇:让小程序飞起来 - 性能优化实战指南

功能都做完了,但小程序体验评分只有 70 分?首次打开要等 3 秒?这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,系统性地进行分包优化、图片优化、渲染优化,让小程序飞起来。

系列专栏【AI 编程实战】专栏目录

本篇主题:让小程序飞起来 - 性能优化实战指南

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:性能问题的发现

1.1 体验评分告警

我:小程序提审时,体验评分只有 70 分。
    主要问题:
    - 代码包体积过大(2.1MB)
    - 首屏渲染时间过长(2.8s)
    - 图片资源未优化

    能帮我系统性地优化一下吗?

1.2 AI 给出优化路线图

AI:性能优化需要系统性进行,我给你一个完整的路线图:

📊 性能指标目标:
- 主包体积 < 2MB(当前 2.1MB)
- 首屏加载 < 1.5s(当前 2.8s)
- 体验评分 > 90 分(当前 70 分)

📋 优化路线:

1. 分包优化
   - 主包只放核心页面
   - 非核心功能放分包
   - 配置分包预加载

2. 构建优化
   - 使用 bundle-optimizer 插件
   - 配置按需编译
   - 移除未使用的依赖

3. 图片优化
   - 压缩图片资源
   - 使用 CDN 加载
   - 实现懒加载

4. 渲染优化
   - 减少首屏渲染节点
   - 合理使用 computed
   - 避免不必要的响应式

5. 网络优化
   - 请求合并
   - 数据缓存
   - 预加载策略

我们按这个顺序来,先从分包开始。

二、分包优化:减少主包体积

2.1 我的需求

我:主包体积超了,有哪些页面可以放到分包里?

2.2 AI 分析页面结构

AI:分析你的 pages.json,建议这样分包:

📊 页面分析:

【主包页面】(必须放主包)
- pages/index/index        → 首页,TabBar 页面
- pages/chase/chase        → 帮你追,TabBar 页面
- pages/my/my              → 我的,TabBar 页面
- pages/login/index        → 登录页,高频访问

【可分包页面】
- pages/feedback/index     → 反馈页,低频
- pages/plan-result/*      → 计划结果,二级页面
- pages/plan-continue/*    → 计划续期,二级页面

【建议分包结构】
subPackages/
├── agreement/     → 协议页面(用户协议、隐私政策)
├── vip/           → VIP 相关页面
└── planForm/      → 计划表单页面

📌 分包原则:
1. TabBar 页面必须在主包
2. 高频访问页面放主包
3. 低频、大体积页面放分包
4. 相关功能聚合到同一分包

2.3 分包配置

// pages.json
{
  "pages": [
    // 主包:核心 TabBar 页面
    { "path": "pages/index/index", "type": "home" },
    { "path": "pages/chase/chase" },
    { "path": "pages/my/my" },
    { "path": "pages/login/index" }
  ],
  "subPackages": [
    // 分包1:协议相关
    {
      "root": "subPackages/agreement",
      "pages": [
        { "path": "pages/agreement/user" },
        { "path": "pages/agreement/privacy" },
        { "path": "pages/agreement/member" }
      ]
    },
    // 分包2:VIP 功能
    {
      "root": "subPackages/vip",
      "pages": [
        {
          "path": "pages/index/index",
          "style": {
            "navigationStyle": "custom",
            "navigationBarTitleText": "VIP会员"
          }
        }
      ]
    },
    // 分包3:计划表单
    {
      "root": "subPackages/planForm",
      "pages": [
        {
          "path": "pages/index/index",
          "style": {
            "navigationStyle": "custom",
            "navigationBarTitleText": "定制追求计划"
          }
        }
      ]
    }
  ]
}

2.4 分包预加载

我:用户从首页跳转 VIP 页面,能不能提前加载分包?
AI:可以配置分包预加载,在用户访问主包页面时,
静默下载可能用到的分包:
// pages.json
{
  "preloadRule": {
    // 访问首页时,预加载 VIP 分包
    "pages/index/index": {
      "network": "all",
      "packages": ["subPackages/vip"]
    },
    // 访问"我的"页面时,预加载协议分包
    "pages/my/my": {
      "network": "wifi",
      "packages": ["subPackages/agreement"]
    }
  }
}
AI:预加载配置说明:

📊 network 选项:
- "all":所有网络环境都预加载
- "wifi":仅 WiFi 环境预加载

📌 预加载策略:
1. VIP 页面用户可能随时访问,用 "all"
2. 协议页面不紧急,用 "wifi" 节省流量
3. 不要预加载太多分包,反而影响首屏

三、构建优化:使用 Bundle Optimizer

3.1 我的需求

我:听说有个 bundle-optimizer 插件可以优化分包,
    能帮我配置一下吗?

3.2 AI 配置优化插件

AI:@uni-ku/bundle-optimizer 可以做以下优化:

🔧 插件功能:
1. 分包优化:自动分析依赖,减少重复
2. 异步跨包调用:模块按需加载
3. 异步组件引用:组件懒加载

我来帮你配置:
// vite.config.ts
import Optimization from '@uni-ku/bundle-optimizer';

export default ({ mode }) => {
  const { UNI_PLATFORM } = process.env;

  return defineConfig({
    plugins: [
      // ... 其他插件

      // 分包优化插件(App 端不支持)
      UNI_PLATFORM !== 'app' && UNI_PLATFORM !== 'app-plus' && Optimization({
        enable: {
          optimization: true,        // 启用分包优化
          'async-import': true,      // 启用异步导入
          'async-component': true,   // 启用异步组件
        },
        dts: {
          base: 'src/types',         // 类型声明目录
        },
        logger: false,               // 关闭日志
      }),
    ],
  });
};

3.3 我追问优化效果

我:这个插件具体优化了什么?
AI:主要优化三个方面:

📊 1. 分包依赖优化(optimization)
- 分析主包和分包的依赖关系
- 公共依赖提取到主包
- 分包独有依赖留在分包

【优化前】
主包:vue + lodash + dayjs + moment
分包A:moment(重复)
分包B:lodash(重复)

【优化后】
主包:vue + lodash + dayjs + moment(只打包一次)
分包A:无重复
分包B:无重复

📊 2. 异步导入(async-import)
// 优化前:同步导入,增加主包体积
import { heavyUtils } from '@/utils/heavy'

// 优化后:异步导入,按需加载
const { heavyUtils } = await import('@/utils/heavy')

📊 3. 异步组件(async-component)
// 优化前:组件全量打包
import HeavyComponent from '@/components/HeavyComponent.vue'

// 优化后:组件懒加载
const HeavyComponent = defineAsyncComponent(
  () => import('@/components/HeavyComponent.vue')
)

📌 优化效果:
- 主包体积减少 15-30%
- 分包体积减少 10-20%
- 首屏加载时间减少 20-40%

四、图片优化:从源头减负

4.1 我的需求

我:项目里有很多图片,如何优化?

4.2 AI 给出图片优化策略

AI:图片优化分几个层面:

📊 图片优化策略:

1. 压缩图片(构建时)
   - PNG/JPG 压缩
   - 转 WebP 格式(兼容性好的平台)

2. 图片懒加载(运行时)
   - 首屏图片立即加载
   - 非首屏图片滚动时加载

3. CDN 加速(网络层)
   - 静态资源上 CDN
   - 图片裁剪服务

4. 占位图处理(体验层)
   - 加载中显示骨架屏
   - 加载失败显示默认图

我来给你演示实际代码:

4.3 图片组件优化

<!-- src/components/ImageGrid.vue -->
<template>
  <view class="grid grid-cols-5 gap-4">
    <!-- 已选图片 -->
    <view
      v-for="(image, index) in images"
      :key="image.id || index"
      class="relative bg-gray-100 rounded-2 overflow-hidden aspect-[53/66]"
    >
      <image
        :src="image.url || image.tempFilePath"
        mode="aspectFill"
        class="w-full h-full"
        @load="onImageLoad"
        @error="onImageError"
      />
      <!-- 上传中遮罩 -->
      <view
        v-if="image.uploading"
        class="absolute inset-0 bg-black/30 flex items-center justify-center"
      >
        <u-loading mode="circle" color="#ffffff" :size="28" />
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
export interface ImageItem {
  id: string;
  url: string;
  tempFilePath?: string;
  uploading?: boolean;
}

interface Props {
  images: ImageItem[];
  maxCount?: number;
}

const props = withDefaults(defineProps<Props>(), {
  maxCount: 5,
});

const emit = defineEmits<{
  (e: 'imageLoad', event: any): void;
  (e: 'imageError', event: any): void;
}>();

// 图片加载成功
const onImageLoad = (event: any) => {
  emit('imageLoad', event);
};

// 图片加载失败
const onImageError = (event: any) => {
  console.error('图片加载失败:', event);
  emit('imageError', event);
};
</script>

4.4 我追问懒加载实现

我:image 组件怎么实现懒加载?
AI:微信小程序的 image 组件原生支持懒加载:
<template>
  <scroll-view scroll-y class="h-full">
    <view v-for="item in list" :key="item.id" class="mb-4">
      <!-- lazy-load 属性实现懒加载 -->
      <image
        :src="item.imageUrl"
        mode="aspectFill"
        lazy-load
        class="w-full h-40"
        @load="handleImageLoad(item.id)"
        @error="handleImageError(item.id)"
      />
    </view>
  </scroll-view>
</template>

<script setup lang="ts">
// 跟踪图片加载状态
const loadedImages = ref<Set<string>>(new Set());
const failedImages = ref<Set<string>>(new Set());

const handleImageLoad = (id: string) => {
  loadedImages.value.add(id);
};

const handleImageError = (id: string) => {
  failedImages.value.add(id);
};

// 获取图片显示地址(失败时显示默认图)
const getImageSrc = (item: any) => {
  if (failedImages.value.has(item.id)) {
    return '/static/images/default.png';  // 默认图
  }
  return item.imageUrl;
};
</script>
AI:lazy-load 的工作原理:

📊 懒加载行为:
1. 图片进入可视区域前不加载
2. 距离可视区域 150px 时开始加载
3. 加载完成触发 load 事件

📌 注意事项:
1. lazy-load 仅在 scroll-view 中生效
2. 首屏关键图片不要用懒加载
3. 建议配合占位图使用

五、渲染优化:减少不必要的计算

5.1 合理使用 computed

我:页面数据经常需要格式化,怎么优化?
AI:用 computed 缓存计算结果,避免重复计算:
// ❌ 错误:每次渲染都重新计算
const formatPrice = (price: number) => {
  return (price / 100).toFixed(2);
};
// 模板中:{{ formatPrice(item.price) }}

// ✅ 正确:computed 缓存结果
const formattedList = computed(() =>
  list.value.map(item => ({
    ...item,
    formattedPrice: (item.price / 100).toFixed(2),
    formattedDate: formatDate(item.createTime),
  }))
);
// 模板中:{{ item.formattedPrice }}

5.2 v-show vs v-if 的选择

AI:根据切换频率选择:
<!-- 频繁切换:用 v-show(只是 CSS 隐藏) -->
<view v-show="isExpanded" class="detail-panel">
  {{ detailContent }}
</view>

<!-- 条件渲染:用 v-if(销毁/重建 DOM) -->
<LoginModal v-if="showLogin" @close="showLogin = false" />

<!-- 多分支条件:用 v-if + v-else-if -->
<LoadingState v-if="loading" />
<EmptyState v-else-if="list.length === 0" />
<ContentList v-else :list="list" />
AI:选择原则:

📊 使用 v-show 的场景:
- Tab 切换
- 折叠面板
- 下拉菜单
→ 切换频繁,保留 DOM 状态

📊 使用 v-if 的场景:
- 权限控制
- 弹窗组件
- 条件渲染
→ 切换少,节省初始渲染成本

5.3 避免响应式陷阱

我:有些静态配置数据,需要 ref 包装吗?
AI:静态数据不需要响应式:
// ❌ 不必要的响应式
const menuItems = ref([
  { icon: 'home', text: '首页', path: '/pages/index' },
  { icon: 'user', text: '我的', path: '/pages/my' },
]);

// ✅ 静态数据直接定义
const menuItems = [
  { icon: 'home', text: '首页', path: '/pages/index' },
  { icon: 'user', text: '我的', path: '/pages/my' },
];

// ✅ 需要响应式的数据才用 ref
const selectedIndex = ref(0);
const userInfo = ref<UserInfo | null>(null);
AI:响应式的开销:

📊 ref/reactive 的成本:
1. 创建 Proxy 代理对象
2. 依赖收集(track)
3. 触发更新(trigger)

📌 优化建议:
- 静态配置:普通对象/数组
- 列表数据:ref([])
- 表单状态:ref() 或 reactive()
- 大对象频繁更新:shallowRef()

5.4 列表渲染优化

// ❌ 错误:使用 index 作为 key
<view v-for="(item, index) in list" :key="index">

// ✅ 正确:使用唯一 ID 作为 key
<view v-for="item in list" :key="item.id">

// ✅ 复合 key(当 id 不唯一时)
<view v-for="(item, index) in list" :key="`${item.type}-${item.id}`">

六、网络优化:请求合并与缓存

6.1 请求合并

我:首页有 5 个接口同时请求,能不能优化?
AI:有几种优化策略:
// 策略1:接口合并(需要后端配合)
// 一个接口返回多种数据
const fetchHomeData = async () => {
  const res = await httpGet<{
    userInfo: UserInfo;
    vipInfo: VipInfo;
    replyQuota: QuotaInfo;
  }>('/api/home/init');

  return res.data;
};

// 策略2:并行请求(前端优化)
const fetchHomeDataParallel = async () => {
  const [userRes, vipRes, quotaRes] = await Promise.all([
    getUserInfo(),
    getVipInfo(),
    getReplyQuota(),
  ]);

  return {
    userInfo: userRes.data,
    vipInfo: vipRes.data,
    quota: quotaRes.data,
  };
};

// 策略3:按优先级加载
const fetchHomeDataPriority = async () => {
  // 首屏必须数据:立即加载
  const essential = await httpGet('/api/home/essential');
  renderEssential(essential.data);

  // 次要数据:延迟加载
  setTimeout(async () => {
    const extra = await httpGet('/api/home/extra');
    renderExtra(extra.data);
  }, 100);
};

6.2 数据缓存

// 本地缓存工具
const cache = {
  set(key: string, data: any, ttl: number = 5 * 60 * 1000) {
    const item = {
      data,
      expireAt: Date.now() + ttl,
    };
    uni.setStorageSync(key, JSON.stringify(item));
  },

  get<T>(key: string): T | null {
    const raw = uni.getStorageSync(key);
    if (!raw) return null;

    try {
      const item = JSON.parse(raw);
      if (Date.now() > item.expireAt) {
        uni.removeStorageSync(key);
        return null;
      }
      return item.data as T;
    } catch {
      return null;
    }
  },
};

// 带缓存的请求
const getUserInfoCached = async () => {
  const cacheKey = 'user_info';

  // 先读缓存
  const cached = cache.get<UserInfo>(cacheKey);
  if (cached) return cached;

  // 缓存未命中,请求接口
  const res = await getUserInfo();
  cache.set(cacheKey, res.data, 10 * 60 * 1000); // 缓存 10 分钟

  return res.data;
};

七、Loading 状态:提升感知性能

7.1 加载指示器组件

<!-- src/components/LoadingIndicator.vue -->
<template>
  <view class="loading-wrapper">
    <view class="dot-group">
      <view v-for="dot in 3" :key="dot" class="loading-dot" />
    </view>
    <text v-if="text" class="loading-text">{{ text }}</text>
  </view>
</template>

<script setup lang="ts">
defineProps<{
  text?: string;
}>();
</script>

<style scoped>
.loading-wrapper {
  display: flex;
  align-items: center;
  gap: 16rpx;
  color: #706df7;
}

.dot-group {
  display: flex;
  gap: 6rpx;
}

.loading-dot {
  width: 12rpx;
  height: 12rpx;
  border-radius: 9999px;
  background-color: #706df7;
  animation: dotPulse 1.2s ease-in-out infinite;
}

.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }

@keyframes dotPulse {
  0%, 80%, 100% {
    transform: scale(0.6);
    opacity: 0.2;
  }
  40% {
    transform: scale(1);
    opacity: 1;
  }
}
</style>

7.2 骨架屏方案

我:首屏加载时能不能显示骨架屏?
AI:骨架屏可以提升用户感知速度,
让用户知道"内容正在加载"而不是"页面卡住了":
<template>
  <!-- 骨架屏 -->
  <view v-if="loading" class="skeleton">
    <view class="skeleton-header">
      <view class="skeleton-avatar animate-pulse" />
      <view class="skeleton-info">
        <view class="skeleton-line w-24 animate-pulse" />
        <view class="skeleton-line w-16 animate-pulse" />
      </view>
    </view>
    <view class="skeleton-content">
      <view v-for="i in 3" :key="i" class="skeleton-card animate-pulse" />
    </view>
  </view>

  <!-- 实际内容 -->
  <view v-else class="content">
    <UserHeader :user="userInfo" />
    <ReplyList :list="replies" />
  </view>
</template>

<style scoped lang="scss">
.skeleton-avatar {
  @apply w-16 h-16 rounded-full bg-gray-200;
}

.skeleton-line {
  @apply h-4 rounded bg-gray-200 mb-2;
}

.skeleton-card {
  @apply h-24 rounded-xl bg-gray-200 mb-4;
}

.animate-pulse {
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}
</style>

八、性能优化最佳实践

8.1 优化清单

优化项 措施 效果
分包 非核心页面放分包 主包体积 -30%
预加载 配置 preloadRule 分包加载 -50%
构建 bundle-optimizer 重复代码 -20%
图片 懒加载 + CDN 首屏加载 -40%
渲染 computed + v-show 渲染时间 -30%
网络 请求合并 + 缓存 请求数 -60%

8.2 性能监控

// 页面加载耗时监控
const trackPageLoad = () => {
  const startTime = Date.now();

  onReady(() => {
    const loadTime = Date.now() - startTime;

    // 上报性能数据
    uni.reportAnalytics('page_load', {
      page: getCurrentPages().pop()?.route,
      loadTime,
    });

    // 超过阈值告警
    if (loadTime > 2000) {
      console.warn(`页面加载过慢: ${loadTime}ms`);
    }
  });
};

8.3 持续优化

AI:性能优化不是一次性工作,需要持续关注:

📊 优化周期:
1. 开发期:遵循性能规范
2. 提审前:体验评分检查
3. 上线后:监控关键指标
4. 定期:分析慢请求和慢渲染

📌 核心指标:
- 首屏加载时间 < 1.5s
- 页面切换时间 < 300ms
- 体验评分 > 90 分
- 内存占用 < 100MB

九、总结:性能优化方法论

9.1 优化顺序

📊 优先级排序:

1. 分包优化(收益最大,成本最低)
2. 图片优化(效果明显,易于实施)
3. 构建优化(一次配置,长期受益)
4. 渲染优化(需要理解原理)
5. 网络优化(可能需要后端配合)

9.2 本文优化清单

优化项 类型 核心技术 效果
分包配置 构建 subPackages + preloadRule 主包体积 -30%
Bundle Optimizer 构建 异步导入、依赖优化 代码体积 -20%
图片懒加载 渲染 lazy-load + CDN 首屏加载 -40%
computed 缓存 渲染 避免重复计算 渲染时间 -30%
请求缓存 网络 本地存储 + TTL 请求数 -60%
骨架屏 体验 感知优化 用户体验 +50%

9.3 下一篇预告

《【AI 编程实战】第 12 篇:项目总结与 AI 协作心得》

最后一篇是整个系列的总结:

  • 项目从 0 到 1 的完整回顾
  • AI 辅助开发的效率提升
  • 与 AI 协作的最佳实践

性能优化不是"锦上添花",而是用户体验的基础保障

通过和 AI 对话,系统性地分析问题、制定方案、逐一优化。

如果这篇文章对你有帮助,请点赞、收藏、转发!

【LangChain.js学习】大模型分类

大模型按功能和使用场景可分为三大核心类型,以下结合 LangChain.js 调用通义千问的实操代码,分别讲解各类模型的定位、用法和适用场景:

模型分类 核心定位 典型能力 适用场景
大语言模型(LLM) 基础文本生成引擎 文本补全、续写、单一问答 简单文本生成、基础问答
对话模型(Chat Model) 多轮交互式对话引擎 多角色交互、上下文理解 智能客服、翻译、多轮对话
嵌入模型(Embedding Model) 文本向量化引擎 语义转化、相似度计算 知识库检索、文本聚类、推荐

对话模型(Chat Model)

核心特点

专为多轮对话设计,支持 system/human/ai 多角色设定,能理解上下文并按预设规则生成响应,是交互类场景的核心模型。

调用代码

import { ChatOpenAI } from "@langchain/openai"

const chatModel = new ChatOpenAI({
    model: "qwen-max",
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        apiKey: "[你的阿里百炼API Key]",
    },
})

const response = await chatModel.invoke([
    {
        // system角色:预设模型行为和能力
        role: "system",
        content: "你是一个专业的翻译,你可以将中文翻译成英文",
    },
    {
        // user角色:输入具体用户指令
        role: "user",
        content: "请帮我翻译成英文:你好",
    },
])
console.log(response.content) // 输出示例:Hello

嵌入模型(Embedding Model)

核心特点

将文本转化为固定维度的数值向量(通义千问 text-embedding-v2 为768维),向量间的距离可表征文本语义相似度,是语义检索、知识库的基础。

调用代码

import { OpenAIEmbeddings } from "@langchain/openai"

const embeddingsModel = new OpenAIEmbeddings({
    model: "text-embedding-v2",
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        apiKey: "[你的阿里百炼API Key]",
    },
})

// 单文本向量化:返回一维向量数组
const queryEmbedding = await embeddingsModel.embedQuery("你好")
console.log(queryEmbedding) // 输出示例:[0.012, -0.045, 0.078, ...]

// 扩展:多文本批量向量化(适用于知识库构建)
// const batchEmbeddings = await embeddingsModel.embedDocuments(["你好", "世界"])

大语言模型(LLM)

核心特点

最基础的大模型类型,以「文本补全」为核心能力,无多角色交互设计,输入单一文本指令,返回对应的生成结果。

与对话模型的区别

  • 大语言模型:输入输出均为纯文本,无角色区分,适合简单的文本生成/问答;
  • 对话模型:基于大语言模型封装,支持多角色、多轮上下文,交互性更强。

调用代码

import { OpenAI } from "@langchain/openai"

const llm = new OpenAI({
    model: "qwen-plus",
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        apiKey: "[你的阿里百炼API Key]",
    },
})

// 输入单一文本指令,直接返回结果
const response = await llm.invoke("翻译“你好”成英文")
console.log(response) // 输出示例:Hello

gsap 配置解读 --2

yoyo 是什么意思

在 GSAP(GreenSock Animation Platform)中,yoyo: true 是一个控制重复动画播放方向的选项

const tween = gsap.to(slider, {
x: 420,
duration: 1.2, 
ease: "power1.inOut",
repeat: -1, // 无限重复
yoyo: true, // ← 关键:开启“来回”模式
paused: true
});

这段代码的作用是:

让 slider 元素在 x: 0 和 x: 420 之间无限来回移动(像钟摆一样),每次往返耗时 2.4 秒(1.2 秒去 + 1.2 秒回)。


✅ yoyo: true 的核心行为

yoyo 动画重复时的行为
false(默认) 每次重复都从头开始0 → 420,然后重置回 0 再 0 → 420(会有“跳回”感)
true 每次重复都反向播放0 → 420,然后 420 → 0,再 0 → 420……形成平滑来回
对比示例:
  • repeat: -1, yoyo: false
    0 → 420 → 瞬间跳回 0 → 0 → 420 → 瞬间跳回 0 …(不连贯)
  • repeat: -1, yoyo: true
    0 → 420 → 420 → 0 → 0 → 420 → 420 → 0 …(流畅往复)

💡 yoyo 这个名字来源于“悠悠球”——扔出去再自动收回来,形象地表达了“来回”运动。


🔧 配合 repeat 使用

  • repeat: 1 + yoyo: true → 播放一次正向 + 一次反向(共 2 次)
  • repeat: -1 + yoyo: true → 无限循环往复(最常见用法)
  • repeat: 0(默认)→ yoyo 无效(因为没有重复)

🎯 常见应用场景

  1. 背景元素左右/上下浮动

    js

    gsap.to(".cloud", { x: 100, repeat: -1, yoyo: true, duration: 3 });
    
  2. 指示器、光标闪烁或晃动

    js

    gsap.to(".cursor", { opacity: 0, repeat: -1, yoyo: true, duration: 0.5 });
    
  3. 机械式往复运动(如风扇叶片微调、机器人手臂)


⚠️ 注意事项

  • yoyo 只影响动画的播放方向,不会改变 ease(缓动)。

    • 正向:使用你指定的 ease(如 "power1.inOut"
    • 反向:GSAP 会自动反转缓动曲线,确保运动自然(无需手动设置)
  • 如果你用了 keyframesyoyo 会反转整个关键帧序列。


✅ 总结

配置 含义
repeat: -1 无限重复动画
yoyo: true 每次重复时反向播放,实现“来回”效果

你的代码创建了一个水平方向无限来回滑动的动画,常用于:

  • 轮播图自动滚动(配合暂停/继续)
  • 装饰性动态元素
  • 游戏中的巡逻敌人/NPC

配合 paused: true,你可以在需要时(如鼠标悬停、点击按钮)通过 tween.play() 启动这个循环动画。

onStart onUpdate onComplete 是什么意思

在 GSAP(GreenSock Animation Platform)中,onStartonUpdate 和 onComplete 是 动画生命周期回调函数(callback functions) ,用于在动画的特定时刻执行自定义 JavaScript 代码

 const tween = gsap.to(dot, {
        x: 460,
        duration: 1.4,
        ease: "power2.out",
        paused: true,
        onStart: () => {
          log.textContent = "动画开始...";
        },
        onUpdate: () => {
          const progress = Math.round(tween.progress() * 100);
          log.textContent = `进行中:${progress}%`;
        },
        onComplete: () => {
          log.textContent = "动画完成!";
        }
      });

这段代码为动画定义了三个关键时机的“监听器”:


✅ 各回调函数的作用

回调 触发时机 常见用途
onStart 动画刚开始播放的第一帧(在第一次渲染前) 显示提示、启动计时器、添加 class 等
onUpdate 每一帧动画更新时都会触发(每秒约 60 次) 实时更新进度条、显示百分比、同步其他元素状态
onComplete 动画完全结束(到达最后一帧) 隐藏元素、跳转页面、播放音效、触发下一个动画

🔍 详细说明

1. onStart
  • 只会执行 一次
  • 在动画真正开始移动/变化之前触发。
  • 适合做“初始化”操作。

✅ 示例:

js

onStart: () => {
  dot.classList.add('animating');
  console.log('Dot animation started!');
}

2. onUpdate
  • 高频触发(每帧一次),性能敏感,避免做 heavy 操作(如 DOM 查询、复杂计算)。
  • 常配合 tween.progress()(返回 0~1 的进度值)或 tween.time() 使用。

✅ 你的代码:

js

onUpdate: () => {
  const progress = Math.round(tween.progress() * 100); // 转为 0~100%
  log.textContent = `进行中:${progress}%`;
}

→ 实时显示动画完成百分比。

💡 提示:如果只是想读取当前属性值,也可以用 gsap.getProperty(dot, "x")


3. onComplete
  • 动画正常播放完毕时触发(不包括被暂停、反向或 killed 的情况)。
  • 如果设置了 repeat: -1onComplete 永远不会触发(因为动画永不结束)。

✅ 示例:

js

onComplete: () => {
  setTimeout(() => dot.style.display = 'none', 500);
  playNextAnimation();
}

⚠️ 其他相关回调(扩展)

回调 说明
onReverseComplete 反向播放完成时触发(比如调用了 tween.reverse() 并回到起点)
onRepeat 每次重复开始时触发(需配合 repeat 使用)
onInterrupt 动画被kill 或 stop 时触发(GSAP 3.10+)

✅ 总结

回调 何时运行 你的代码作用
onStart 动画刚开始 显示 “动画开始...”
onUpdate 每一帧更新 实时显示进度百分比
onComplete 动画彻底结束 显示 “动画完成!”

这些回调让你能精确控制动画与 UI、逻辑、数据的同步,是实现高级交互动效的关键工具。

💡 小技巧:即使 paused: true,一旦你调用 tween.play(),这些回调仍会按规则触发。

tween.play tween.pause tween.reverse tween.restart是什么意思

在 GSAP(GreenSock Animation Platform)中,tween.play()tween.pause()tween.reverse() 和 tween.restart() 是 控制动画播放状态的核心方法。它们让你可以像操作视频播放器一样,灵活地控制动画的播放、暂停、倒放和重播。

 const ship = document.querySelector("#ship");
      const progress = document.querySelector("#progress");
      const playButton = document.querySelector("#play");
      const pauseButton = document.querySelector("#pause");
      const reverseButton = document.querySelector("#reverse");
      const restartButton = document.querySelector("#restart");

      // 创建一个补间并绑定更新进度条
      const tween = gsap.to(ship, {
        x: 470,
        duration: 3,
        ease: "power1.inOut",
        paused: true,
        onUpdate: () => {
          progress.value = Math.round(tween.progress() * 100);
        }
      });

      playButton.addEventListener("click", () => tween.play());
      pauseButton.addEventListener("click", () => tween.pause());
      reverseButton.addEventListener("click", () => tween.reverse());
      restartButton.addEventListener("click", () => tween.restart());

      // 拖动进度条,手动 seek 到指定位置
      progress.addEventListener("input", (event) => {
        const value = Number(event.target.value) / 100;
        tween.progress(value).pause();
      });

假设你有如下动画:

js

const tween = gsap.to(box, {
  x: 300,
  duration: 2,
  paused: true // 初始暂停,等待手动控制
});

此时动画已创建但未播放。你可以通过以下方法控制它:


✅ 1. tween.play()

作用:从当前进度开始正向播放动画。

  • 如果是第一次播放 → 从头开始(进度 0 → 1)
  • 如果之前被暂停在 50% → 从 50% 继续播放到 100%

js

tween.play(); // 开始或继续播放

💡 相当于点击“播放”按钮 ▶️


✅ 2. tween.pause()

作用暂停动画,停留在当前帧。

  • 动画状态被冻结,不会继续更新。
  • 可随时用 play() 或 reverse() 恢复。

js

tween.pause(); // 暂停动画
console.log(tween.progress()); // 比如输出 0.6(60% 进度)

💡 相当于点击“暂停”按钮 ⏸️


✅ 3. tween.reverse()

作用反向播放动画(倒放)。

  • 如果当前在正向播放 → 立即掉头往回走
  • 如果已暂停 → 从当前位置倒放到起点
  • 再次调用 reverse() 会切回正向

js

tween.reverse(); // 开始倒放
// 再次调用:
tween.reverse(); // 又变回正向播放

💡 相当于“倒带” ◀️,常用于 hover 离开时还原状态


✅ 4. tween.restart()

作用重置并重新播放动画(从头开始)。

  • 无论当前在什么进度,都会跳回 0%  并开始正向播放。
  • 相当于 tween.progress(0).play()

js

tween.restart(); // 从头开始播放

💡 相当于“重新开始” 🔁


🔄 状态变化图示

假设动画总时长 2 秒:

方法 当前进度 调用后行为
初始 0%
.play() 0% → 正向播放 → 100%
播放到 60% 时 .pause() 停在 60%
.play() 60% → 继续正向 → 100%
.reverse() 60% → 倒放 → 0%
.restart() 无论在哪 → 跳回 0% → 正向播放

💡 实际应用场景

场景 1:Hover 效果

js

box.addEventListener('mouseenter', () => tween.play());
box.addEventListener('mouseleave', () => tween.reverse());
场景 2:按钮控制

js

playBtn.onclick = () => tween.play();
pauseBtn.onclick = () => tween.pause();
resetBtn.onclick = () => tween.restart();
场景 3:滚动触发动画

js

ScrollTrigger.create({
  trigger: ".section",
  onEnter: () => tween.play(),
  onLeaveBack: () => tween.reverse()
});

⚠️ 注意事项

  • 这些方法返回 tween 自身,支持链式调用:

    js

    tween.play().timeScale(2); // 2倍速播放
    
  • reverse() 不会改变 duration,只是反向运行。

  • 如果动画已完成(100%),调用 play() 不会重播(需用 restart())。


✅ 总结

方法 作用 类比
.play() 从当前位置正向播放 ▶️ 播放
.pause() 暂停在当前帧 ⏸️ 暂停
.reverse() 从当前位置反向播放 ◀️ 倒放
.restart() 重置到开头并播放 🔁 重播

这些方法赋予你对 GSAP 动画完全的程序化控制能力,是实现交互动效的基础。

gsap.defaults 是什么

在 GSAP(GreenSock Animation Platform)中,gsap.defaults()  是一个全局配置方法,用于为所有后续创建的 GSAP 动画(tween 或 timeline)设置默认参数,避免重复书写相同的配置项(如 durationease 等)。

   const boxes = gsap.utils.toArray(".box");
      const playButton = document.querySelector("#play");

      // 设置默认动画参数
      gsap.defaults({
        duration: 0.8,
        ease: "power2.out"
      });

      const timeline = gsap.timeline({ paused: true });

      timeline.to(boxes[0], { x: 220, background: "#22d3ee" });
      timeline.to(boxes[1], { x: 180, background: "#a3e635" }, "<0.1");
      timeline.to(boxes[2], { x: 140, background: "#f472b6" }, "<0.1");

      playButton.addEventListener("click", () => {
        timeline.restart();
      });

这行代码的意思是:

从此以后,所有通过 gsap.to()gsap.from()gsap.timeline().to() 等创建的动画,如果没有显式指定 duration 和 ease,就会自动使用 duration: 0.8 和 ease: "power2.out"


✅ 作用:减少重复代码,统一动效风格

在你的后续代码中:

js

timeline.to(boxes[0], { x: 220, background: "#22d3ee" });
timeline.to(boxes[1], { x: 180, background: "#a3e635" }, "<0.1");
timeline.to(boxes[2], { x: 140, background: "#f472b6" }, "<0.1");

虽然你没有写 duration 和 ease,但由于前面设置了 gsap.defaults(),这三个 .to() 动画会自动继承

  • duration: 0.8
  • ease: "power2.out"

等价于:

js

timeline.to(boxes[0], { x: 220, background: "#22d3ee", duration: 0.8, ease: "power2.out" });
// ...其他同理

🔧 gsap.defaults() 支持的常用属性

属性 说明
duration 默认动画时长(秒)
ease 默认缓动函数(如 "power2.out""elastic""back.inOut" 等)
delay 默认延迟时间
overwrite 默认覆盖行为(如 "auto"
opacity 默认透明度(较少用)

⚠️ 注意:局部配置会覆盖默认值
例如:gsap.to(el, { x: 100, duration: 2 }) 中的 duration: 2 会覆盖 defaults 中的 0.8


🎯 使用场景

  1. 项目级动效规范
    整个网站/应用使用统一的动画节奏和缓动曲线。
  2. 快速原型开发
    先用默认值快速搭建动画,后期再微调个别参数。
  3. 减少样板代码
    避免在几十个动画中反复写 duration: 0.8, ease: "power2.out"

⚠️ 注意事项

  • gsap.defaults() 只影响调用之后创建的动画,对已存在的 tween/timeline 无效。
  • 它设置的是全局默认值,会影响所有 GSAP 动画(包括 gsap.totimeline.to 等)。
  • 如果你在团队协作中使用,建议在项目入口文件(如 main.js)中统一设置。

gsap.quickTo 和 gsap.utils.clamp 是什么

GSAP 的两个非常高效且实用的工具:gsap.quickTo()  和 gsap.utils.clamp() 。它们常用于高性能指针/光标跟随、拖拽、实时交互等场景。下面分别详细解释:


  const stage = document.querySelector("#stage");
      const cursor = document.querySelector("#cursor");

      // quickTo 可以高频率更新属性,且保持平滑
      const moveX = gsap.quickTo(cursor, "x", { duration: 0.3, ease: "power3.out" });
      const moveY = gsap.quickTo(cursor, "y", { duration: 0.3, ease: "power3.out" });

      // utils.clamp 限制数值范围
      const clampX = gsap.utils.clamp(0, stage.clientWidth - 36);
      const clampY = gsap.utils.clamp(0, stage.clientHeight - 36);

      stage.addEventListener("mousemove", (event) => {
        const rect = stage.getBoundingClientRect();
        const x = clampX(event.clientX - rect.left - 18);
        const y = clampY(event.clientY - rect.top - 18);
        moveX(x);
        moveY(y);
      });

✅ 1. gsap.quickTo()

🔍 是什么?

gsap.quickTo() 是 GSAP 提供的一个高性能属性更新器,它会预先创建一个轻量级的 tween(动画) ,然后你可以通过调用返回的函数高频次地更新目标值,而无需反复创建新动画。

📌 你的代码:

js

const moveX = gsap.quickTo(cursor, "x", { duration: 0.3, ease: "power3.out" });
const moveY = gsap.quickTo(cursor, "y", { duration: 0.3, ease: "power3.out" });
  • 创建了两个“快速更新器”:moveX 和 moveY
  • 它们分别控制 cursor 元素的 x 和 y 属性
  • 每次调用 moveX(100),就会让 cursor 的 x 平滑地动画到 100(耗时 0.3 秒,带缓动)

🎯 在事件中使用:

js

stage.addEventListener("mousemove", (event) => {
  // ...计算 x, y
  moveX(x); // ← 高频调用(每秒可能几十次)
  moveY(y);
});

✅ 为什么用 quickTo 而不用 gsap.to

表格

方式 问题
gsap.to(cursor, { x: newX, duration: 0.3 }) 每次 mousemove 都新建一个 tween,内存和性能开销大,容易卡顿
gsap.quickTo(...) 只创建一次 tween,后续只是更新它的目标值,极其高效 ✅

💡 quickTo 内部会自动处理“中断上一帧动画、平滑过渡到新目标”的逻辑,非常适合鼠标跟随、拖拽预览等场景。


✅ 2. gsap.utils.clamp()

🔍 是什么?

clamp(钳制/限制)是一个数值范围限制工具函数,确保一个值不会超出指定的最小值和最大值

📌 你的代码:

js

const clampX = gsap.utils.clamp(0, stage.clientWidth - 36);
const clampY = gsap.utils.clamp(0, stage.clientHeight - 36);
  • clampX 是一个函数,它接收一个数字,返回被限制在 [0, stage.clientWidth - 36] 范围内的值
  • -36 是因为你的 cursor 元素宽高为 36px(假设),要防止它超出容器右/下边缘

🎯 在事件中使用:

js

const x = clampX(event.clientX - rect.left - 18); // -18 是 cursor 宽度的一半(居中对齐)

✅ 作用:防止光标移出舞台区域

比如:

  • 舞台宽度是 500px,cursor 宽 36px → 最大允许 x = 500 - 36 = 464
  • 如果用户把鼠标移到 500px 处,clampX(500 - 18) = clampX(482) → 返回 464
  • 这样 cursor 就不会“跑出”舞台右边

🔁 等价于手写:

js

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

但 GSAP 的版本更简洁、可复用。


🧩 整体逻辑总结

“受限区域内的平滑自定义光标”

  1. 监听 #stage 的鼠标移动
  2. 计算鼠标相对于舞台的坐标clientX - rect.left
  3. 减去 18px 使光标中心对齐鼠标(假设光标是 36×36)
  4. 用 clamp 限制坐标,不让光标超出舞台边界
  5. 用 quickTo 高性能、平滑地更新光标位置

✅ 优势

技术 好处
gsap.quickTo 高频更新不卡顿,动画流畅,内存友好
gsap.utils.clamp 一行代码实现边界限制,代码清晰
GSAP 的 x/y 自动使用 transform,性能优于 left/top

💡 扩展建议

  • 如果想让光标在离开舞台时隐藏,可加:

    js

    stage.addEventListener("mouseleave", () => gsap.set(cursor, { autoAlpha: 0 }));
    stage.addEventListener("mouseenter", () => gsap.set(cursor, { autoAlpha: 1 }));
    
  • quickTo 也支持其他属性,如 scalerotationbackgroundColor 等。


✅ 总结

方法 作用
gsap.quickTo(target, prop, vars) 创建高性能属性更新器,适合高频调用
gsap.utils.clamp(min, max) 生成一个限制数值范围的函数,防止越界

这两个工具组合起来,是实现专业级交互动效(如自定义光标、拖拽预览、游戏 UI)的黄金搭档!

Draggable 是什么

  <div class="card">
      <h1>案例 14:Draggable 拖拽</h1>
      <p>拖动方块,体验 Draggable 的基础能力。</p>
      <div class="stage">
        <div class="drag" id="drag">拖我</div>
      </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Draggable.min.js"></script>
    <script>
      const drag = document.querySelector("#drag");

      // 注册 Draggable 插件
      gsap.registerPlugin(Draggable);

      // 创建可拖拽对象并限制在父容器内
      Draggable.create(drag, {
        type: "x,y",
        bounds: ".stage",
        inertia: false
      });
    </script>

DraggableGSAP(GreenSock Animation Platform)官方提供的一个强大插件,用于快速创建高性能、可定制的拖拽交互(drag-and-drop)。它不仅支持鼠标拖动,还完美兼容触摸设备(手机/平板),并内置了惯性滚动、边界限制、对齐吸附、旋转拖拽等高级功能。


📌 你的代码解释:

Draggable.create(drag, {
  type: "x,y",      // 允许水平和垂直拖动
  bounds: ".stage", // 限制拖拽范围在 .stage 容器内
  inertia: false    // 禁用惯性(松手后不会继续滑动)
});

这段代码的作用是:

#drag 元素变成一个可在 .stage 区域内自由拖动的方块,且松手后立即停止(无惯性滑动)。


Draggable 的核心能力

功能 说明
跨平台支持 自动适配鼠标 + 触摸(无需额外代码)
高性能 使用 transform(非 left/top),60fps 流畅拖拽
边界限制 (bounds) 可限制在父容器、自定义矩形区域或另一个元素内
惯性动画 (inertia) 松手后根据速度继续滑动(类似 iOS 滚动)
多种拖拽类型 (type) x(水平)、y(垂直)、x,y(自由)、rotation(旋转)、scroll(模拟滚动)等
事件回调 onDragStart, onDrag, onDragEnd 等,便于扩展逻辑
与其他 GSAP 动画无缝集成 拖拽过程中可触发 timeline、tween 等

🔧 常见配置项详解

1. type
  • "x":仅水平拖动
  • "y":仅垂直拖动
  • "x,y":自由二维拖动(最常用)
  • "rotation":围绕中心点旋转(适合旋钮、转盘)
  • "scrollTop" / "scrollLeft":模拟滚动条
2. bounds
  • 字符串选择器:".container" → 限制在该元素内
  • DOM 元素:document.body
  • 对象:{ top: 0, left: 0, width: 500, height: 300 }
3. inertia
  • true:松手后根据拖拽速度继续滑动(需加载 InertiaPlugin
  • false:松手立即停止(默认)

🎯 实际应用场景

场景 配置示例
可拖拽卡片 type: "x,y", bounds: ".card-container"
滑块/进度条 type: "x", bounds: ".slider-track"
旋转调节器 type: "rotation", bounds: { minRotation: 0, maxRotation: 180 }
图片裁剪框 type: "x,y", bounds: ".image"
游戏人物移动 type: "x,y", onDrag: updateCharacterPosition

💡 事件回调示例

Draggable.create(drag, {
  type: "x,y",
  bounds: ".stage",
  onDragStart: () => console.log("开始拖拽"),
  onDrag: () => console.log("正在拖拽", drag._gsap.x, drag._gsap.y),
  onDragEnd: () => console.log("拖拽结束")
});

📌 拖拽过程中的位置可通过 element._gsap.x / element._gsap.y 实时获取(GSAP 自动维护)。


⚠️ 注意事项

  1. 必须注册插件
    gsap.registerPlugin(Draggable);
    
  2. 被拖拽元素需定位:建议设置 position: absolutefixed,否则可能布局错乱。
  3. 避免与原生滚动冲突:在移动端可能需要 touch-action: none
    .drag {
      touch-action: none; /* 禁用浏览器默认拖拽/缩放 */
    }
    

当然可以!在 GSAP 的 Draggable 插件中,type 选项决定了拖拽行为的类型。以下是多个常见的 type 配置及其实际应用场景和代码示例,帮助你快速掌握不同拖拽模式的用法。


✅ 1. type: "x" —— 仅水平拖动

适用于滑块、时间轴、横向卡片流等。

Draggable.create(".slider-handle", {
  type: "x",
  bounds: ".slider-track" // 限制在轨道内
});

📌 效果:只能左右拖,不能上下移动。


✅ 2. type: "y" —— 仅垂直拖动

适用于音量条、滚动预览、垂直进度条。

Draggable.create(".volume-knob", {
  type: "y",
  bounds: ".volume-bar"
});

📌 效果:只能上下拖,不能左右移动。


✅ 3. type: "x,y" —— 自由二维拖动(最常用)

适用于可移动窗口、拖拽图标、自定义光标、游戏人物。

Draggable.create(".draggable-box", {
  type: "x,y",
  bounds: ".container" // 限制在容器内
});

📌 效果:可任意方向拖动,但不会超出 .container 边界。


✅ 4. type: "rotation" —— 旋转拖拽

适用于旋钮、转盘、角度调节器、图片旋转工具。

Draggable.create(".knob", {
  type: "rotation",
  bounds: { minRotation: 0, maxRotation: 270 } // 限制旋转角度
});

📌 效果:鼠标/手指绕元素中心旋转,值为角度(°)。

💡 元素需设置 transform-origin: center(默认即是)。


✅ 5. type: "scrollTop" —— 模拟垂直滚动

适用于自定义滚动条、迷你地图导航。

// 拖动小方块控制大内容区滚动
Draggable.create(".scroll-thumb", {
  type: "scrollTop",
  scrollElement: document.querySelector(".content") // 要滚动的目标元素
});

📌 效果:拖动 .scroll-thumb 时,.content 区域会同步垂直滚动。


✅ 6. type: "scrollLeft" —— 模拟水平滚动

适用于横向长图浏览、时间线导航。

Draggable.create(".horizontal-thumb", {
  type: "scrollLeft",
  scrollElement: document.querySelector(".timeline")
});

📌 效果:拖动 thumb 控制 .timeline 水平滚动。


✅ 7. type: "top,left" —— 使用 top/left 定位(不推荐)

⚠️ 性能较差,仅用于特殊布局(如非 transform 兼容场景)。

Draggable.create(".legacy-element", {
  type: "top,left",
  bounds: ".parent"
});

📌 区别

  • 默认 x,y 使用 transform: translate()(高性能、不影响文档流)
  • top,left 直接修改 CSS top/left(触发重排,性能低)

建议优先使用 x,y


✅ 8. 组合类型(GSAP 3.12+ 支持)—— type: "x,rotation"

同时支持水平移动 + 旋转(高级交互)。

Draggable.create(".dial", {
  type: "x,rotation",
  bounds: { minX: 0, maxX: 300 }
});

📌 效果:左右拖动改变位置,同时可旋转(需配合手势或逻辑判断)。

🔔 注意:组合类型需明确指定每个维度的行为,实际使用较少。


🎯 补充:如何读取拖拽状态?

无论哪种 type,你都可以通过以下方式获取实时值:

const drag = Draggable.get(".element");

console.log(drag.x);        // 当前 x 位移(px)
console.log(drag.y);        // 当前 y 位移(px)
console.log(drag.rotation); // 当前旋转角度
console.log(drag.scrollY);  // 当前 scrollTop 值(如果 type 是 scrollTop)

✅ 总结:常用 type 对照表

type 用途 是否常用
"x" 水平滑块 ✅ 高频
"y" 垂直滑块 ✅ 高频
"x,y" 自由拖拽(窗口/图标) ✅ 最常用
"rotation" 旋钮、转盘 ✅ 中频
"scrollTop" 自定义垂直滚动条 ✅ 中频
"scrollLeft" 自定义水平滚动条 ✅ 中频
"top,left" 兼容旧布局(不推荐) ❌ 少用

通过合理选择 type,你可以用极少的代码实现丰富的交互效果。结合 boundsinertia、事件回调(onDrag 等),Draggable 几乎能满足所有拖拽需求!

✅ 总结

术语 含义
Draggable GSAP 官方拖拽插件,提供高性能、跨平台、可定制的拖拽交互能力

代码是一个典型的 “受限区域内的基础拖拽” 示例,非常适合入门。通过组合 boundstypeinertia 和事件回调,你可以轻松实现从简单 UI 控件到复杂游戏交互的各种需求。

Angular 中的增量水合:构建“秒开且可交互”的 SSR 应用

原文:Incremental Hydration In Angular Apps

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

增量水合封面图

Angular 的增量水合(Incremental Hydration)通过把“可见”与“可交互”的成本拆开:页面仍然用 SSR 很快渲染出完整 HTML(有利于 LCP/SEO),但把某些区域的客户端激活(事件绑定、变更检测等)推迟到「空闲 / 进入视口 / 交互 / 定时」等触发时机,从而减少主线程阻塞(TBT)并让首屏更“顺滑”。

目录

    1. 性能悖论:看起来好了,但还不能用
    1. 演进与术语:从“破坏式”到“非破坏式”再到“增量”
    1. 深入:@defer 的加载与水合双触发
    1. 实现与配置:开启增量水合与事件回放
    1. 架构:嵌套块、层级规则与 HTML 约束
    1. 排错与调试:水合不匹配(Hydration Mismatch)
  • 常见问题
  • 总结与行动清单

1. 性能悖论

现代 Web 应用经常陷入一个悖论:

  • **业务与指标(Core Web Vitals)**希望尽快看到内容:通过 SSR 改善 LCP(Largest Contentful Paint)。
  • 用户体验希望像 SPA 一样顺滑可交互:事件绑定、变更检测、路由与各种组件逻辑都要跑起来。

问题在于:“看起来 ready”与“用起来 ready”之间存在时间差

在传统水合(hydration)里,浏览器需要在主线程上启动框架、遍历 DOM、挂载监听器等。用户看到页面已经“完整”,但点击按钮没有反应、菜单卡住——这段时间就像性能的“恐怖谷”,通常发生在 LCP(内容已绘制)到 TTI(Time to Interactive)之间。

Angular 的增量水合把应用视为一组相对独立的“岛屿”:不是启动时把整棵组件树一次性激活,而是让某些部分在合适的时机再醒来。收益通常体现在更低的 TBT(Total Blocking Time)与更快的“体感可用”。

标准水合 vs 增量水合

2. 演进与术语

理解 Angular 的水合语法之前,先把“hydration”在不同阶段的含义理清:

2.1 破坏式水合(早期/遗留)

在一些旧方案里,“水合”更像是误用:

  1. 服务端返回 HTML。
  2. 浏览器先把它画出来。
  3. 客户端框架丢弃这份 DOM,再用 JS 从头重建。

这会导致闪烁(flicker)与大量计算开销。

2.2 非破坏式水合(Angular 16+)

Angular 16 引入非破坏式水合:

  • 启动后遍历已有的 SSR DOM;
  • 将 DOM 节点与组件树匹配;
  • 在复用 DOM 的前提下挂载事件监听。

这是巨大进步,但仍是“一刀切”:启动时仍要把整棵树都水合。

2.3 增量水合(Incremental Hydration)

增量水合建立在非破坏式水合之上,但进一步提供“粒度”。它基于 @defer 块作为边界:可以让某些组件子树先以静态 HTML(dehydrated)呈现,等触发条件满足时再执行客户端逻辑并挂载监听。

它与“懒加载(lazy loading)”的关键差异是:

  • 懒加载(常见于 CSR):代码晚点加载,DOM 往往也是晚点渲染(可能先显示骨架/占位)。
  • 增量水合(SSR):内容先由服务端渲染出来,用户立刻能看到;只是先不激活交互,等触发再水合。

因此它更像是在“保持视觉完整”的前提下,优化主线程执行成本。

3. 深入:@defer 的加载与水合双触发

在增量水合语境里,一个 @defer 块实际控制两件事:

  1. **Loading:**什么时候去拉取对应的 JS chunk。
  2. **Hydrating:**什么时候执行逻辑、把监听器挂到已存在的 HTML 上。

这意味着你可以做出更“精细”的性能画像:先把代码悄悄拉下来,但把激活推迟到真正需要的时候。

3.1 双触发示例

@defer (on idle; hydrate on interaction) {
  <app-heavy-chart />
}
  • **首屏(SSR):**服务端会渲染 <app-heavy-chart />,用户立刻看到内容。
  • on idle:浏览器空闲时在后台拉取图表的 JS。
  • hydrate on interaction:先不执行图表逻辑,让它保持“静态壳”;主线程保持轻。
  • **当用户交互(点击/触摸/键盘等):**触发水合,组件“醒来”。
  • 如果是 CSR 路由进入(没有 SSR):on idle 会影响该块什么时候真正渲染。

3.2 常见水合触发方式

下面这些是更偏“水合时机”的触发类型(不同版本/文档里表述略有差异,核心思想一致):

  1. hydrate on idle(默认型优化)

    • 行为:在浏览器空闲时水合(概念上类似 requestIdleCallback 的时机)。
    • 适合:大多数非关键区域。
  2. hydrate on viewport(首选的“屏外内容”策略)

    • 行为:进入视口才水合(基于 IntersectionObserver)。
    • 适合:长列表、评论区、页脚等。
  3. hydrate on interaction(重组件“按需启动”)

    • 行为:点击/触摸/键盘等交互触发。
    • 适合:地图、复杂日期选择器等“看得见但不一定会用”的部件。
  4. hydrate on hover(提前一点点)

    • 行为:鼠标悬停 / focus 触发。
    • 适合:下拉菜单等,鼠标靠近时提前准备。
  5. hydrate on timer (X)(按时间排队)

    • 行为:延迟 X 毫秒后水合。
    • 适合:你想明确安排启动顺序,比如侧边栏 500ms、广告 2000ms。
  6. hydrate on immediate(关键交互)

    • 行为:在非延迟内容渲染完之后尽快水合。
    • 适合:首屏必须马上可点的关键按钮。
@defer (hydrate on immediate) {
  <hero-cta-button />
} @placeholder {
  <div>Loading...</div>
}
  1. hydrate when <condition>(条件门控)

    • 行为:当某个信号或布尔条件变为真时水合。
    • 适合:例如「只有管理员才需要的仪表盘组件」。
    • 注意:条件通常只能在最外层尚未水合的 @defer 上可靠评估;父块还没水合时,子块条件也无法被计算。
  2. hydrate never(纯静态块)

    • 行为:服务端渲染后永不水合。
    • 适合:完全没有交互需求的内容(条款、静态介绍等)。

4. 实现与配置:开启增量水合与事件回放

开启增量水合通常只是一处配置,但真正的关键点在于:交互触发的那一下不能丢

4.1 基本配置

app.config.ts 中启用客户端水合,并开启增量能力:

import { ApplicationConfig, provideZoneChangeDetection } from "@angular/core";
import {
  provideClientHydration,
  withIncrementalHydration,
} from "@angular/platform-browser";

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideClientHydration(withIncrementalHydration()),
  ],
};

4.2 自动事件回放(Event Replay)

很多人第一反应是:

如果我用了 hydrate on interaction,那用户第一次点击是不是会被吞掉?

Angular 的思路是:在真正框架逻辑还没起来时,先用一段轻量脚本捕获事件,把它们缓冲起来,等对应块水合完成后再“回放”。在启用 withIncrementalHydration() 时,这类事件回放能力通常会一并启用(文档中也常提到 withEventReplay())。

事件回放大致流程:

  1. **捕获:**在文档根部注册全局事件分发器。
  2. **缓冲:**如果事件发生在尚未水合的 @defer 区域内,就先暂存。
  3. **触发水合:**事件本身触发 hydrate on interaction
  4. **回放:**代码加载 + 水合完成后,把事件交给新挂载的监听器执行。

Angular 水合与事件回放示意

5. 架构:嵌套块与约束

增量水合带来收益,也带来一些你必须遵守的“架构规则”。忽略它们会导致退化(de-opt),甚至回落到破坏式重渲染。

5.1 层级规则:自上而下

Angular 水合是有层级的:

  • 父级必须先水合(或同时水合),子级才能可靠水合。
  • 子组件依赖父组件的变更检测与输入绑定;如果父级仍是“脱水”状态,子级很难独立激活。

实践建议:把 @defer 块设计得更“自包含”,避免出现点了叶子节点却把整棵树都连锁唤醒的“瀑布效应”。

5.2 HTML 结构必须有效且一致

非破坏式水合依赖“复用 DOM”:服务端输出的 DOM 结构需要与浏览器最终 DOM、以及客户端期望结构严格一致。

常见坑:

  • <a> 嵌套 <a>
  • <p> 里塞了 <div> 这类块级元素
  • <table><tbody>
  • 由于无效 HTML 导致浏览器自动修复、从而改变了 DOM

这些都会导致水合复用失败,进而触发重建。

5.3 SEO 影响?通常不会

有人担心 @defer 会伤害 SEO。增量水合的前提是 SSR:主内容在服务端模板里已经输出成语义化 HTML,搜索引擎拿到的就是完整内容。

水合触发控制的是“什么时候执行 JS 让它可交互”,不是“内容什么时候出现”。

5.4 @placeholder 仍然需要

即使 SSR 会把真实内容渲染出来,@placeholder 仍然很重要——主要用于 CSR 路由导航 的场景。

当用户通过 routerLink 在客户端导航进入某页时,该页的 @defer 更像常规延迟块:

  • 会先显示 @placeholder
  • 然后根据触发条件加载并渲染真实内容
@defer (on viewport; hydrate on interaction) {
  <comments-section />
} @placeholder {
  <div class="comments-skeleton">Loading comments...</div>
}

建议:给占位提供接近真实内容的尺寸,减少 CSR 下的布局抖动(CLS)。

6. 排错与调试:Hydration Mismatch

最常见的问题是 Hydration Mismatch(水合不匹配)

服务端生成的 HTML 与客户端期望的 DOM 不一致。

本质原因是:客户端在水合时要求“可复用的 DOM”必须匹配预期;哪怕是一个文本节点差异,都会出问题。

6.1 常见原因

  1. **动态日期:**模板里直接 new Date(),服务端与客户端时间不同。
  2. **随机 ID:**用 Math.random() 之类生成随机值。
  3. **浏览器规范化:**无效 HTML 被浏览器修复后结构变了。

6.2 调试手段

  • **控制台日志:**Angular 通常会提示具体不匹配的节点。
  • **Angular DevTools:**可以查看组件树;在较新版本里也能看到组件的水合状态(Hydrated / Skipped / Dehydrated)。
  • **可视化标记:**临时用 CSS(如 .ng-hydrating)给“醒来”的组件加高亮,观察时序。

常见问题

增量水合解决了什么问题?

它减少了 SSR 应用里“内容已出现但还不能交互”的间隙,通过延迟/分批激活交互逻辑降低启动期主线程压力。

它和标准水合有什么区别?

标准水合倾向于启动时激活整棵组件树;增量水合根据触发条件只激活需要的部分。

它等同于懒加载吗?

不等同。懒加载往往会推迟渲染;增量水合强调 SSR 先渲染出内容,再推迟交互激活。

会影响 SEO 吗?

通常不会。SSR 已输出完整内容,触发控制的是 JS 执行时机。

@defer 的作用是什么?

它定义水合边界,并控制“什么时候加载代码 / 什么时候激活交互”。

总结与行动清单

增量水合的核心很简单:

  • 服务端把内容都渲染出来(用户立刻看到、SEO 友好)
  • 客户端只在需要时才水合(主线程更清爽、体感更快)

你可以从这份行动清单开始:

  1. **做一次页面盘点:**哪些在首屏?哪些在首屏下方?哪些必须立即可点?
  2. 为不同区域选择触发:
    • Hero + CTA:hydrate on immediate
    • 评论区/长列表:hydrate on viewport
    • 重型但不一定会用的组件:hydrate on interaction
    • 纯静态块:hydrate never
  3. **CSR 场景别忘 @placeholder:**占位尽量稳定尺寸,避免 CLS。
  4. **在真实设备上验证:**用 DevTools 观察水合状态与事件回放是否符合预期。

一句话结论:不要在启动时把所有东西一次性“唤醒”。让组件在用户需要时再启动,你会得到更快、更稳、更顺滑的 Angular SSR 体验。

TypeScript 类型体操:如何为 SDK 编写优雅的类型定义

背景

作为一款 SDK,提供完善的 TypeScript 类型定义(.d.ts)是对用户最基本的尊重。 AutoForm 的配置项非常复杂,且存在很多联动关系。如何用 TS 准确描述这些关系?

今天带大家做一套类型体操。

挑战一:互斥属性

如果配置了 mode: 'auto',则 interval 必填;如果 mode: 'manual',则 interval 不可填。

错误写法:

interface Config {
  mode: 'auto' | 'manual';
  interval?: number;
}

正确写法(Discriminated Unions):

type AutoConfig = {
  mode: 'auto';
  interval: number;
};

type ManualConfig = {
  mode: 'manual';
  interval?: never; // 关键:禁止出现
};

type Config = AutoConfig | ManualConfig;

挑战二:事件回调的类型推导

我们希望用户在监听事件时,能自动推导出 event 对象的类型。

sdk.on('success', (e) => {
  console.log(e.data); // e 应该是 SuccessEvent
});

sdk.on('error', (e) => {
  console.log(e.message); // e 应该是 ErrorEvent
});

实现:

type EventMap = {
  success: { any };
  error: { message: string; code: number };
};

class SDK {
  on<K extends keyof EventMap>(
    type: K, 
    handler: (event: EventMap[K]) => void
  ) {
    // ...
  }
}

挑战三:深度 Partial

用户配置时,通常只需要覆盖默认配置的一部分。我们需要一个递归的 Partial

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

总结

TypeScript 不仅仅是类型检查工具,更是最好的文档。写好类型定义,能让用户在使用 SDK 时获得极致的智能提示体验,减少查阅文档的时间。

👉 官网地址:51bpms.com

❌