阅读视图

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

双座敞篷传奇落幕,宝马 Z4 即将停产

继丰田宣布 GR Supra 停产之后,其「孪生兄弟」宝马 Z4 也迎来了终章。

近日,宝马确认将在 2026 年春季正式结束 Z4(G29 世代)的生产。自 1995 年 Z3 问世以来,宝马持续近三十年的双座敞篷跑车产品线,或将就此画上句号。

回顾 Z 系列的历史,几乎就是一部宝马对驾驶乐趣的执着书写。

1995 年,Z3 凭借圆润线条和詹姆斯·邦德电影《黄金眼》一炮而红;2002 年推出的 Z4(E85 ) 则转向锋利设计,并诞生了搭载 3.2 升直六引擎、输出 336 马力的 Z4 M Roadster——至今仍被不少车迷奉为经典。

▲ 2007 BMW Z4 M Roadster

2009 年的第二代 Z4(E89)首次采用折叠硬顶,试图兼顾日常实用性;而 2018 年回归的第三代 Z4(G29),则重新拥抱轻量化的织物软顶,并与丰田联合开发,共享平台与动力总成,催生出如今广为人知的 GR Supra。

▲ 采用折叠硬顶的 BMW Z4 E89

事实上,早在 2022 年,坊间便有传闻称 Z4 将在当前一代生命周期结束后停产。尽管宝马决定让这款车多延续一个年款,但始终未公布任何换代计划。

Z4 之所以能「续命」至今,很大程度上要归功于手动挡的坚持。宝马原本认为这种传统配置在当今市场已无多少受众,但美国用户的实际选择给出了截然不同的答案:只要有手动挡,就仍有人愿意为它买单。

作为告别之作,宝马最后为 Z4 打造了一款名为「Final Edition」的限量特别版,将将高配选项整合为出厂即满配的状态,让最后一台 Z4 拥有最完整的姿态。

Z4 Final Edition 将全系标配宝马 Individual 部门调制的 Frozen Black 哑光黑车漆,搭配 M Shadowline 高光黑套件和红色 M Sport 刹车卡钳,以及熏黑格栅、外后视镜、进气口及排气尾喉,软顶则采用 Moonlight Black 深灰黑色,试图营造一种低调而凌厉的告别氛围。

车辆内饰则以黑色 Vernasca 真皮与 Alcantara 材质为主调,辅以贯穿仪表台、中控、门板、座椅乃至脚垫的红色缝线,专属门槛饰板刻有「Final Edition」字样。

配置方面,Harman Kardon 音响、抬头显示、环境氛围灯、驾驶辅助系统等此前需额外选装的高端功能,全部作为标准装备提供。

不过,这款特别版仅基于顶配 M40i 打造,将由奥地利麦格纳斯太尔工厂在 2026 年 2 月至 4 月间小批量生产,主要在欧洲和北美市场提供。

Z4 Final Edition 将继续搭载 3.0 升涡轮增压直列六缸发动机,最大输出 382 马力、500 牛·米扭矩,用户可在八速 Steptronic 自动变速箱与六速手动变速箱之间选择。

选择手动挡的用户将获得「Edition Handschalter」专属套件,包含重新调校的减震器阻尼、转向逻辑、牵引力控制系统,以及强化型防倾杆支架,这些细节虽不提升账面性能,却能显著增强人车沟通感,满足纯粹驾驶爱好者的需求。

自动挡车型虽无缘这套底盘优化,却可以获得前后轮尺寸不同的「Staggered」轮圈设定(前 19 英寸/后 20 英寸),此前这一设定在普通 M40i 上仅限手动挡可选。

性能表现上,Z4 Final Edition 自动挡零百加速为 3.9 秒,手动挡为 4.2 秒。

在北美市场,Z4 Final Edition 起售价为 78,675 美元,相比普通版 M40i 的 69,575 美元高出约 9,100 美元。

但若将 Final Edition 所含的 Individual 哑光漆、Shadowline 套件、手动挡专属底盘调校、Premium 包、驾驶辅助包等逐一加到普通 M40i 上,最终价格将与 Final Edition 相差无几。

目前,宝马中国官网的 Z4 车型并未下架,sDrive 25i M 运动曜夜套装的指导价为 52.39 万元,有 197 马力和 340 马力两种动力版本可选,不过仅提供仅 8 速运动型手自一体变速箱。

Z4 的停产,标志着一个时代的落幕。过去三十年,它承载了无数人对敞篷、后驱、直六引擎的浪漫想象。但在当下,这类小众跑车在全球市场的销量始终有限,Z4 与 Supra 的联合项目,也从未真正「卖爆」。

不过,宝马并未彻底关闭跑车的大门。一位高管近期向媒体透露,基于全新「Neue Klasse」(新世代)纯电平台开发一款全新运动车型「是可行的」。

▲ 网友渲染的 「Neue Klasse」版 Z4

这意味着,Z4 的精神或许将以纯电身份延续,只是不再与丰田共享,也不再是那台熟悉的燃油敞篷。

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

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


北京启动专项行动重点整治六类金融领域网络乱象

36氪获悉,据网信北京消息,当前,部分金融领域“黑灰产”借助网络平台滋生蔓延,通过假冒身份、隐蔽引流、非法推介等手段误导公众、欺诈敛财,严重侵害人民群众财产安全,扰乱金融市场秩序。为有效遏制金融领域突出网络乱象,11月26日,北京市网信办会同北京金融监管局联合部署开展为期3个月的“清朗京华·金融守护”金融领域网络乱象治理专项行动,以高压态势严打网上非法金融活动及“黑灰产”乱象。此次专项行动聚焦短视频、社交、直播等重点网站平台,集中整治假冒仿冒专业人员误导公众、以隐蔽方式违规引流、为非法存贷款中介提供推介服务、开展非法代理维权“黑灰产”活动、恶意抹黑诋毁金融机构、捏造虚假信息唱衰经济等六类突出问题。

商务部:敦促荷兰政府尽快提出建设性的解决方案,为企业开展内部协商创造有利条件

36氪获悉,据商务部官网,11月26日,商务部部长王文涛与欧盟委员会贸易和经济安全委员谢夫乔维奇举行视频会谈,就安世半导体等经贸问题深入交换意见。王文涛表示,关于安世半导体问题,造成当前全球半导体产供链混乱的源头和责任在荷兰方面。中国政府始终本着负责任的态度,及时采取切实措施,努力维护和恢复全球半导体产供链稳定。近期,荷方宣布暂停行政令,向妥善解决问题迈出了一小步,但是荷方对企业的不当行政和司法干预仍未取消,全球半导体产供链尚未恢复正常,依然面临较大不确定性。希望欧方发挥积极作用,敦促荷兰政府尽快提出建设性的解决方案,为企业开展内部协商创造有利条件。

德意志银行上调2026年金价预期

德意志银行上调了其对明年金价的预测,理由是持续的投资资金流入和各国央行坚挺的需求。该行目前预计,黄金均价将达到每金衡盎司4450美元,高于此前预期的4000美元,并预测金价将在3950美元至4950美元之间波动。(新浪财经)

北大医药:相关流感药物尚未组织生产销售

36氪获悉,北大医药发布股票交易异常波动的公告,公司注意到近期部分地区可能存在流感高发情况,引发资本市场关注,经自查,公司相关流感药物尚未组织生产销售,预计未来相当长一段时间不会对公司经营业绩产生相关影响。

南方精工:股东拟合计减持不超过2.54%公司股份

36氪获悉,南方精工公告,公司收到控股股东一致行动人浙江银万私募基金管理有限公司-银万全盈30号私募证券投资基金和史建仲的减持计划告知函。浙江银万私募基金管理有限公司-银万全盈30号私募证券投资基金持有公司1.97%的股份,史建仲持有0.57%的股份,计划在未来三个月内通过集中竞价及大宗交易方式合计减持不超过8,957,230股,即不超过公司总股本的2.54%。减持原因为自身资金需求。

华盛锂电:股东敦行价值拟减持不超0.63%股份

36氪获悉,华盛锂电公告,股东敦行价值计划通过集中竞价方式减持不超过100万股,占公司总股本比例不超过0.63%,减持期间为2025年12月18日至2026年3月17日。减持原因为自身资金需求。

大道至简-Shadcn/ui设计系统初体验(下):Theme与色彩系统实战

大道至简-Shadcn/ui设计系统初体验(下):Theme与色彩系统实战

前言

在上篇文章中,我们探讨了shadcn/ui的安装、组件引入和基础定制。本文将继续深入,关注一个更核心的话题——主题系统设计。作为前端工程师,我们都明白一个好的设计系统不仅要有美观的组件,更需要一套完整、可维护、可扩展的色彩体系。本文将通过实际项目实践,详细分析shadcn/ui如何通过CSS变量和TailwindCSS构建这套体系。

一、自定义主题配置:从CSS变量到TailwindCSS

1.1 shadcn/ui的主题系统原理

shadcn/ui采用了基于CSS自定义属性(CSS Variables)的设计模式。每个主题实际上就是一套CSS变量的集合。不同于传统组件库通过JavaScript动态计算颜色值,shadcn/ui选择在CSS层面定义好所有颜色状态,然后通过类名切换来实现主题变换。

这种设计的优势显而易见:

  • 无需JavaScript计算,避免频繁的重排重绘
  • 颜色值在构建阶段就已经确定,性能更好
  • CSS变量天然支持继承和级联,便于管理复杂的色彩体系

让我们查看项目的核心配置文件:

/* src/index.css */
@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

@theme {
  --color-border: hsl(var(--border));
  --color-input: hsl(var(--input));
  --color-ring: hsl(var(--ring));
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  /* ... 更多颜色变量 */
}

注意这里使用TailwindCSS 4的新语法 @theme 替代了传统的 tailwind.config.js 配置。这种方式将主题配置直接内联到CSS文件中,更加直观。

1.2 主题色彩定义

shadcn/ui使用HSL色彩空间来定义颜色。HSL由色相(Hue)、饱和度(Saturation)、亮度(Lightness)三个分量组成,相比RGB更容易理解和调整。

我们项目的实际配色:

:root {
  /* 背景与前景色 */
  --background: 210 20% 96%;
  --foreground: 222 15% 15%;

  /* 主色调 - 浅蓝色系 */
  --primary: 205 85% 60%;
  --primary-foreground: 210 40% 98%;

  /* 次要色 - 浅绿色系 */
  --secondary: 145 65% 60%;
  --secondary-foreground: 222 15% 15%;

  /* 强调色 - 青绿色系 */
  --accent: 175 70% 55%;
  --accent-foreground: 210 40% 98%;
}

.dark {
  /* 深色主题配色 */
  --background: 210 15% 10%;
  --foreground: 210 15% 92%;
  --primary: 205 85% 65%;
  --secondary: 145 60% 65%;
  --accent: 175 65% 60%;
  /* ... */
}

配色方案的设计遵循以下原则:

  1. 语义化命名:每个颜色都有明确的语义(background、primary、secondary等)
  2. 状态配套:每个主要颜色都有对应的foreground色,保证可读性
  3. 明暗适配:深色模式下适当调整亮度和饱和度

1.3 扩展色系:成功与警告

除了标准的设计语言色彩,shadcn/ui还允许定义扩展色系,用于表达特定状态:

:root {
  --success: 145 60% 50%;
  --success-light: 145 65% 60%;
  --success-dark: 145 55% 45%;

  --warning: 45 85% 60%;
  --warning-light: 45 90% 65%;
  --warning-dark: 45 80% 55%;
}

这种命名方式(基础色-light-dark)为每个语义色提供了三个亮度级别,在实际开发中可以根据不同场景选择合适的深浅。

二、颜色系统设计:CSS变量与OKLCH色彩空间

2.1 CSS变量的高级特性

CSS自定义属性(CSS Variables)不仅仅是简单的键值对,它具备许多强大的特性:

1. 继承性

.card {
  background: hsl(var(--primary));
}

.card-header {
  /* 自动继承父元素的 --primary */
  color: hsl(var(--primary));
}

2. 动态计算

:root {
  --primary-light: 205 85% calc(60% + 10%);
}

3. 作用域控制

/* 全局作用域 */
:root {
  --global-primary: blue;
}

/* 局部作用域 */
.theme-dark {
  --local-primary: red;
}

这些特性使得CSS变量非常适合构建复杂的颜色系统。

2.2 OKLCH色彩空间:下一代色彩标准

传统的HSL色彩空间有一个明显缺陷:感知不均匀性。也就是说,在HSL中同样数值的变化,人眼感知的差异并不一致。例如,HSL中饱和度从50%到60%的变化,看起来比60%到70%的变化更明显。

OKLCH(Lightness-Chroma-Hue)色彩空间解决了这个问题。OKLCH是基于CIELAB色彩空间的现代色彩模型,具有以下优势:

  • 感知均匀:数值的微小变化对应人眼感知的微小变化
  • 色域更广:支持更多可见色彩
  • 对比度可控:更容易满足WCAG可访问性标准

虽然浏览器对OKLCH的支持还在逐步完善中,但TailwindCSS已经开始采用OKLCH。未来shadcn/ui很可能会迁移到OKLCH色彩空间。

2.3 构建语义化颜色系统

一个好的颜色系统需要避免直接使用底层色彩值,而是通过语义化变量来使用:

/* ❌ 不好的做法 - 直接使用底层颜色 */
.button {
  background: rgb(59, 130, 246);
}

/* ✅ 好的做法 - 使用语义化变量 */
.button {
  background: hsl(var(--primary));
}

这种设计的好处:

  1. 可维护性强:修改主题时只需更改CSS变量定义
  2. 一致性保证:全站使用统一的语义化色彩
  3. 灵活性高:可以针对不同区域覆盖特定变量

三、项目实践:TodoList的主题更新实现

3.1 ThemeProvider设计

shadcn/ui提供了一个独立的ThemeProvider实现,位于 src/components/theme-provider.tsx。这个实现替代了传统的next-themes,更轻量且完全基于原生Web API。

核心实现分析:

export function ThemeProvider({
  children,
  defaultTheme = 'system',
  storageKey = 'vite-ui-theme',
}: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>(
    () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
  )

  useEffect(() => {
    const root = window.document.documentElement

    root.classList.remove('light', 'dark')

    if (theme === 'system') {
      const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
        .matches
        ? 'dark'
        : 'light'

      root.classList.add(systemTheme)
      return
    }

    root.classList.add(theme)
  }, [theme])

  const value = {
    theme,
    setTheme: (theme: Theme) => {
      localStorage.setItem(storageKey, theme)
      setTheme(theme)
    },
  }

  return (
    <ThemeProviderContext.Provider {...props} value={value}>
      {children}
    </ThemeProviderContext.Provider>
  )
}

关键点分析:

  1. 三种主题模式

    • light: 强制使用浅色主题
    • dark: 强制使用深色主题
    • system: 跟随系统设置
  2. 本地存储持久化 使用localStorage保存用户偏好,应用重启后自动恢复。

  3. 类名切换机制 通过操作documentElement的classList来切换主题,避免频繁的style重写。

3.2 TodoList中的主题切换按钮

在TodoList组件中,主题切换按钮的实现:

import { useTheme } from './theme-provider'

function TodoList() {
  const { theme, setTheme } = useTheme()

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light')
  }

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={toggleTheme}
      aria-label="切换主题"
    >
      {theme === 'light' ? (
        <Moon className="h-4 w-4" />
      ) : (
        <Sun className="h-4 w-4" />
      )}
    </Button>
  )
}

注意这里的实现细节:

  • 使用aria-label提升可访问性
  • 根据当前主题显示对应图标(月亮/太阳)
  • variant设为ghost保持视觉简洁

3.3 主题变量的实际应用

在TodoList组件中,我们看到各种shadcn/ui组件都使用了语义化的颜色变量:

<div className="min-h-screen bg-background text-foreground transition-colors">
  <Card className="border-border">
    <CardHeader>
      <CardTitle className="bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent">
        待办事项列表
      </CardTitle>
    </CardHeader>
  </Card>
</div>

关键点:

  • bg-backgroundtext-foreground:使用语义变量确保文本可读性
  • border-border:边框颜色随主题变化
  • 渐变色使用CSS变量,保持主题一致性

四、shadcn/ui的设计哲学总结

通过上下两篇文章的分析,我们可以总结shadcn/ui的设计哲学:

4.1 零抽象成本

shadcn/ui不将组件封装为黑盒,而是提供完整源代码。这种"代码所有权"模式让开发者可以:

  • 任意修改组件实现
  • 深入理解组件逻辑
  • 无框架依赖,便于迁移

4.2 原子化设计

每个组件都是独立的、无样式基础的(headless),样式完全通过TailwindCSS类控制。这带来:

  • 样式完全可控
  • 避免CSS优先级冲突
  • 更好的Tree-shaking效果

4.3 设计令牌驱动

通过CSS变量系统,shadcn/ui建立了完整的设计令牌(Design Tokens)体系:

  • 颜色、字体、间距等都有对应的令牌
  • 令牌支持层级继承
  • 便于实现设计系统的一致性

4.4 可访问性优先

基于Radix UI构建,所有组件都具备:

  • 完整的键盘导航支持
  • 正确的ARIA属性
  • 语义化的HTML结构

4.5 现代化工具链

shadcn/ui深度集成了现代前端工具:

  • TailwindCSS 4(最新语法)
  • TypeScript(完整类型定义)
  • Vite(快速构建)
  • ESLint(代码规范)

5.成果展示

让我们看看最终的成果吧。

LightMode.png

NightMode.png

结语

shadcn/ui不仅仅是一个组件库,更是一套完整的设计系统实现方案。它通过CSS变量、TailwindCSS和现代React模式的结合,为我们提供了一种全新的组件库构建思路。

这种"大道至简"的设计理念——将复杂的UI抽象还原为简单的CSS变量和可组合的组件——或许正是前端开发的一种新范式。在AI编程工具日益成熟,Vibe Coding愈发普遍的今天,一个开放、可定制、无黑盒的组件库将更具生命力。


参考:

栈(Stack)详解:从原理到实现,再到括号匹配应用

栈(Stack)详解:从原理到实现,再到括号匹配应用

栈是一种基础而重要的线性数据结构,在计算机科学中被广泛应用于函数调用、表达式求值、回溯算法等场景。本文将围绕栈的定义、抽象数据类型(ADT)、ES6 实现方式(数组与链表)、性能对比,以及一个经典应用场景——括号匹配问题,进行系统讲解。


一、什么是栈?

栈(Stack)是一种遵循 先进后出(First In Last Out, FILO) 原则的线性数据结构。你可以把它想象成一摞盘子:你只能从顶部放入(入栈)或取出(出栈)盘子,不能从中间或底部操作。

栈的核心操作包括:

  • push(x) :将元素 x 压入栈顶。
  • pop() :弹出并返回栈顶元素。
  • peek() / top() :查看栈顶元素但不移除。
  • isEmpty() :判断栈是否为空。
  • size() :获取栈中元素个数。

二、栈的抽象数据类型(ADT)

抽象数据类型(Abstract Data Type, ADT)是对数据结构行为的规范描述,不涉及具体实现。栈的 ADT 应包含以下属性和方法:

属性/方法 描述
size 只读属性,返回当前栈的大小
isEmpty() 判断栈是否为空
push(val) 入栈操作
pop() 出栈操作,若栈空则抛出异常
peek() 返回栈顶元素,若栈空则抛出异常
toArray() (可选)将栈内容转换为数组,便于调试或输出

三、ES6 Class 实现栈

ES6 引入了 class 语法,使面向对象编程更加清晰。结合私有字段(#)、get 访问器等特性,可以优雅地封装栈的实现细节。

1. 基于链表实现栈(LinkedListStack)

链表天然适合动态增长,每个节点包含值和指向下一个节点的指针。

class ListNode {
    constructor(val) {
        this.val = val;
        this.next = null;
    }
}

class LinkedListStack {
    #stackPeek = null; // 私有栈顶指针
    #size = 0;

    push(num) {
        const node = new ListNode(num);
        node.next = this.#stackPeek;
        this.#stackPeek = node;
        this.#size++;
    }

    pop() {
        if (!this.#stackPeek) throw new Error('栈为空');
        const val = this.#stackPeek.val;
        this.#stackPeek = this.#stackPeek.next;
        this.#size--;
        return val;
    }

    peek() {
        if (!this.#stackPeek) throw new Error('栈为空');
        return this.#stackPeek.val;
    }

    get size() { return this.#size; }
    isEmpty() { return this.#size === 0; }

    toArray() {
        let node = this.#stackPeek;
        const res = new Array(this.#size);
        for (let i = res.length - 1; i >= 0; i--) {
            res[i] = node.val;
            node = node.next;
        }
        return res;
    }
}

优点:动态扩容,无空间浪费;插入/删除均为 O(1)
缺点:每个节点需额外存储指针,内存开销略大


2. 基于数组实现栈(ArrayStack)

利用 JavaScript 数组的 pushpop 方法,可快速实现栈。

class ArrayStack {
    #stack = [];

    get size() { return this.#stack.length; }
    isEmpty() { return this.size === 0; }

    push(num) {
        this.#stack.push(num);
    }

    pop() {
        if (this.isEmpty()) throw new Error("栈为空");
        return this.#stack.pop();
    }

    peek() {
        if (this.isEmpty()) throw new Error("栈为空");
        return this.#stack[this.size - 1];
    }

    toArray() {
        return [...this.#stack]; // 返回副本更安全
    }
}

优点:缓存友好,访问快;代码简洁
缺点:扩容时需复制整个数组(O(n)),但均摊时间复杂度仍为 O(1)


四、数组 vs 链表实现栈:性能对比

维度 数组实现 链表实现
时间效率 平均 O(1),扩容时 O(n) 稳定 O(1)
空间效率 可能有预分配空间浪费 每个节点多一个指针开销
内存布局 连续内存,缓存命中率高 离散内存,缓存局部性差
适用场景 数据量可预估、追求速度 动态性强、内存敏感

💡 在大多数实际应用中,数组实现的栈更常用,因为其简单高效,且现代 JavaScript 引擎对数组优化极佳。


五、实战应用:括号匹配问题

栈的经典应用场景之一是验证括号字符串是否合法。例如:"([{}])" 合法,而 "([)]" 不合法。

解题思路:

  1. 遇到左括号 ([{,将其对应的右括号压入栈;
  2. 遇到右括号,检查是否与栈顶元素匹配;
  3. 若不匹配或栈为空,则非法;
  4. 遍历结束后,栈必须为空才合法。

代码实现:

const leftToRight = {
    "(": ")",
    "[": "]",
    "{": "}"
};

function isValid(s) {
    if (!s) return true;
    const stack = [];
    for (const ch of s) {
        if (ch in leftToRight) {
            stack.push(leftToRight[ch]); // 压入期望的右括号
        } else {
            if (!stack.length || stack.pop() !== ch) {
                return false; // 不匹配或栈空
            }
        }
    }
    return stack.length === 0; // 必须完全匹配
}

✅ 时间复杂度:O(n)
✅ 空间复杂度:O(n)(最坏情况全为左括号)


六、总结

  • 栈是一种 FILO 的线性结构,核心操作为 pushpoppeek
  • ES6 的 class、私有字段 #get 访问器,让栈的实现更安全、更清晰。
  • 数组实现简单高效,适合大多数场景;链表实现动态灵活,适合不确定规模的场景。
  • 栈在算法中用途广泛,如括号匹配、表达式求值、深度优先搜索(DFS)等。

掌握栈,不仅是理解数据结构的第一步,更是打开算法世界大门的钥匙。


📌 提示:在实际开发中,除非有特殊需求(如限制使用内置数组方法),否则直接使用 Arraypush/pop 即可高效模拟栈行为。

前端日常工作开发技巧汇总

一、JS篇

1. structuredClone 深拷贝

JavaScript 内置了一个 structuredClone() 的方法, 此方法提供了一种简单有效的方法来深度克隆对象,支持复杂数据类型,包括 DateRegExpMapSetArrayBufferBlobFile 等。浏览器底层实现,通常比手动递归或 JSON 方法更高效。

兼容性 image.png

2. 函数式编程

ES14 更新了许多数组方法或者为原有的数组方法增加不会带来突变(without mutation) 的互补方法。意味着它们会基于原数组创建新的数组,而不是直接修改原数组。

新增的互补方法有

  • Array.sort() -> Array.toSorted()
  • Array.splice() -> Array.toSpliced()
  • Array.reverse() -> Array.toReversed()

新增的新数组方法有:Array.with()Array.findLast()Array.findLastIndex()

  • Array.with()
    返回一个新数组,将原数组中指定索引 index 的元素替换为 value不修改原数组

语法
index:要替换的元素的索引(可以是负数,表示从末尾开始计数)。
value:替换后的新值

const newArray = array.with(index, value)

const arr = [1, 2, 3, 4];
const newArr = arr.with(1, "hello"); // 替换索引 1 的元素 

console.log(arr);    // [1, 2, 3, 4](原数组不变)
console.log(newArr); // [1, "hello", 3, 4](新数组)

// 支持负数索引(从末尾开始)
const newArr2 = arr.with(-2, "world"); // 替换倒数第 2 个元素
console.log(newArr2); // [1, 2, "world", 4]
  • Array.findLast()
    从数组末尾向前查找第一个满足 callback 条件的元素,并返回该元素。如果未找到,返回 undefined

  • Array.findLastIndex()
    从数组末尾向前查找第一个满足 callback 条件的元素,并返回其索引。如果未找到,返回 -1

3. 惰性函数

JavaScript 中的 惰性函数(Lazy Function) 是一种优化技术,其核心思想是:函数在第一次调用时执行一些初始化或判断逻辑,并在执行后将自身重定义为一个更高效或更简单的版本,后续调用就直接使用这个新版本,避免重复开销

普通写法

function copyToClipboard(text) {
    // 优先使用Clipboard API
    if (navigator.clipboard) {
      return navigator.clipboard
        .writeText(text)
        .then(() => {
          Message.success('复制成功')
          return true
        })
        .catch((err) => {
          console.error('使用Clipboard API复制失败: ', err)
          // 如果Clipboard API失败,尝试使用降级方案
          return copyUsingExecCommand(text)
        })
    } else {
      // 如果不支持Clipboard API,直接使用降级方案
      return copyUsingExecCommand(text)
    }
}

惰性写法

function copyToClipboard(text) {
  // 第一次调用时进行能力检测,并重定义自身
  if (navigator.clipboard) {
    // 支持 Clipboard API
    copyToClipboard = function (text) {
      return navigator.clipboard
        .writeText(text)
        .then(() => {
          console.log('文本已成功复制到剪贴板');
          return true;
        })
        .catch((err) => {
          console.error('Clipboard API 复制失败:', err);
          return false;
        });
    };
  } else {
    // 不支持 Clipboard API,使用 execCommand 降级方案
    copyToClipboard = function (text) {
      return copyUsingExecCommand(text);
    };
  }

  // 执行第一次调用
  return copyToClipboard(text);
}

二、CSS篇

1. 滚动吸附

<template>
  <div>
    <div class="container">
      <div class="item">1</div>
      <div class="item">2</div>
      <div class="item">3</div>
    </div>
  </div>
</template>

<script setup name="Snap"></script>

<style lang="scss" scoped>
.container {
  width: 100%;
  height: 300px;
  display: flex;
  overflow-x: scroll;
  // 吸附效果 mandatory: 必须吸附  proximity: 靠近时吸附
  scroll-snap-type: x mandatory;
  .item {
    flex-shrink: 0;
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 30px;
    color: #fff;
    background-color: #ccc;
    // 吸附位置
    scroll-snap-align: start;
    scroll-snap-stop: always;
    &:nth-child(1) {
      background-color: #f56c6c;
    }
    &:nth-child(2) {
      background-color: #67c23a;
    }
    &:nth-child(3) {
      background-color: #409eff;
    }
  }
}
</style>
兼容性较高

image.png

2. 字体自适应容器大小

<template>
  <div>
    <div class="container">
      <p>字体自适应容器大小</p>
    </div>
  </div>
</template>

<script setup name=""></script>

<style lang="scss" scoped>
.container {
  width: 500px;
  height: 300px;
  padding: 15px;
  resize: both;
  overflow: hidden;
  background-color: aquamarine;
  container-type: inline-size; // 启用容器查询 size:基于宽高 / inline-size 基于宽度 / normal 不启用
  p {
    font-size: 5cqh;
  }
}
</style>
兼容性还行

image.png

3. 选择器

  • 选择器特定性(通常叫做选择器权重)

当希望某个css属性优先级高于其他属性值时,尽量不要使用!important!important会打破这些固有的级联规则,使得样式的应用变得不那么可预测。这可能会导致样式表难以维护和理解,尤其是在大型项目中。增加调试难度,也限制了样式的灵活性。

替代方案: 通过编写更具体(或更精确)的选择器来覆盖样式,或者叠加选择器,比如:222

.el-button.el-button {
    color: red;
}
  • 新型选择器

:has()选择器: 根据一个元素是否包含某些特定的后代元素,或者其后的同级元素是否满足某些条件,来选中该元素本身。这实现了“向下”观察的能力。

示例1: 选择包含 <img><div>

<div>这个 div 没有图片,不会被选中</div>
<div>
    <img src="example.jpg" alt="示例图片">
    这个 div 包含图片,会被红色边框包围
</div>
/* 选择包含 <img> 的 div */ 
div:has(img) {
    border: 3px solid red; padding: 10px;
}

示例2: 选择紧跟着 <p><h2>

<h2>这个 h2 后面没有紧跟着 p,不会被选中</h2>
<div>分隔内容</div>
<h2>这个 h2 后面紧跟着 p</h2>
<p>这个 p 是 h2 的紧邻兄弟元素,因此 h2 会变成蓝色斜体</p>
/* 选择后面紧跟着 <p> 的 h2 */
h2:has(+ p) {
    color: blue; font-style: italic;
}

兼容性 image.png

:is()选择器: 它接受一个逗号分隔的选择器列表作为参数,并匹配其中任意一个选择器。这有助于减少冗余代码,提高可读性。:is() 的权重等于它括号里所有选择器中权重最高的那个。

示例:

<header>
    <h1>这是 header 的 h1(紫色)</h1>
</header>
<main>
    <h1>这是 main 的 h1(紫色)</h1>
</main>
<footer>
    <h1>这是 footer 的 h1(紫色)</h1>
</footer>
<section>
    <h1>这个 h1 不在 :is() 范围内,保持默认颜色</h1>
</section>
/* 统一设置 header、main、footer 下的 h1 样式 */
:is(header, main, footer) h1 {
    color: purple; font-family: Arial, sans-serif;
}

兼容性 image.png

:where()选择器: 与 :is() 类似,但权重永远为 0,适合默认样式。

兼容性 image.png

三、VUE篇

1. v-memo

Vue 3 提供的性能优化指令,其作用是通过缓存模板子树的渲染结果,仅在依赖项变化时重新渲染,从而减少不必要的虚拟 DOM 计算和更新操作。

v-memo 接收一个依赖数组,只有当数组中的值发生变化时才会重新渲染。

示例:优化大型列表渲染,避免全量更新。 当 item.id 或 item.status 变化时,仅更新对应项;其他项复用缓存结果

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.status]">
  {{ item.content }}
  <StatusBadge :status="item.status" />
</div>

2. watch —— 副作用和深度监听

3. customRef ——— 自定义响应式依赖追踪

4. 组件的二次封装

四、Chrome浏览器调试技巧

1. $0

2. 模拟聚焦网页

3. 重放XHR

五、VSCode编辑器插件分享

1. i18n Ally

  • 代码内直接预览翻译文本
  • 快速生成初始翻译
  • 一键跳转至对应翻译条目
  • 集中管理
  "i18n-ally.localesPaths": ["./src/i18n/lang/locales"], // 翻译文件夹路径
  "i18n-ally.pathMatcher": "{locale}/**/{namespace}.json", // 翻译目标文件路径匹配
  "i18n-ally.keystyle": "nested", // 翻译路径格式,
  "i18n-ally.sourceLanguage": "zh-CN", // 翻译源语言
  "i18n-ally.displayLanguage": "zh-CN", //显示语言, 这里也可以设置显示英文为en
  "i18n-ally.sortKeys": true, // 是否自动排序
  "i18n-ally.namespace": false, // 是否启用命名空间,一般在积攒多个待翻译文案时启用,可以自动编辑至对应文件中
  "i18n-ally.enabledParsers": ["ts", "js", "json"], // 翻译文件可允许的格式,默认json

2. koroFileHeader @4.9.2

用于生成文件头部注释和函数注释的插件

快捷键‌:

  • 头部注释:Ctrl+Win+I(Windows/Linux)或 Ctrl+Cmd+I(Mac)
  • 函数注释:Ctrl+Win+T(Windows/Linux)或 Ctrl+Cmd+T(Mac)
// 头部注释
"fileheader.customMade": {
"Author": "git config user.name && git config user.email", // 同时获取用户名与邮箱
"Date": "Do not edit", // 文件创建时间
"LastEditors": "git config user.name && git config user.email", // 文件最后编辑者 与Author字段一致
"LastEditTime": "Do not edit", // 文件最后编辑时间
"FilePath": "Do not edit", // 文件在项目中的相对路径 自动更新
"Description": "" // 文件描述
},

通义千问Qwen3-Coder模型帮我总结的typescript笔记

简单学了学typescript,但是又觉得做笔记麻烦,于是找AI帮帮忙,提示词为“帮我总结typescript的常用知识点,并生成一篇markdown。” 生成了下面的内容。


TypeScript 常用知识点速查

TypeScript (TS) 是 JavaScript 的超集,添加了静态类型系统,有助于提高代码质量和开发效率。以下是其核心和常用的特性总结。

目录

  1. 基础类型
  2. 变量声明与类型注解
  3. 接口 (Interfaces)
  4. 类型别名 (Type Aliases)
  5. 联合类型 (Union Types) & 交叉类型 (Intersection Types)
  6. 数组与元组 (Tuples)
  7. 函数类型
  8. 类 (Classes)
  9. 泛型 (Generics)
  10. 模块 (Modules) 和 导入/导出
  11. 装饰器 (Decorators)
  12. 实用工具类型 (Utility Types)

基础类型

TypeScript 提供了一系列基础数据类型:

  • number: 数字类型 (整数或浮点数)。
  • string: 字符串类型。
  • boolean: 布尔值 (truefalse)。
  • nullundefined: 它们有自己的类型 nullundefined。但在 strictNullChecks 模式下(推荐),它们只能赋值给 any 和各自的类型;否则(非严格模式),它们是所有类型的子类型。
  • symbol: ES6 新增的原始数据类型,表示独一无二的值。
  • bigint: 大整数类型。
  • void: 通常用于表示没有返回值的函数的返回类型。
  • any: 允许任何类型的值,会跳过类型检查(不推荐滥用)。
  • unknown: 代表任何值。与 any 不同的是,在对 unknown 类型的值执行操作前,必须进行类型检查或类型断言。
  • never: 表示永不存在的值的类型。例如,总是抛出异常或根本不可能有返回值的函数表达式的返回类型。
  • object: 非原始类型(即除 number, string, boolean, symbol, null, undefined, bigint 之外的类型)。注意:它不同于 {}

变量声明与类型注解

使用 let, const, var 声明变量,并通过冒号 : 添加类型注解。

let isDone: boolean = false;
let decimal: number = 6;
let color: string = "blue";
let list: number[] = [1, 2, 3];
let u: undefined = undefined;
let n: null = null;

// 函数参数和返回值的类型注解
function add(x: number, y: number): number {
    return x + y;
}

接口 (Interfaces)

接口用于定义对象的结构(Shape),是一种契约。

interface Person {
    name: string;
    age: number;
    address?: string; // 可选属性
    readonly id: number; // 只读属性
}

const user: Person = {
    name: "Alice",
    age: 30,
    id: 1
};
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.

// 函数类型接口
interface SearchFunc {
    (source: string, subString: string): boolean;
}
let mySearch: SearchFunc;
mySearch = function(src, sub) { // 参数名不必与接口中定义的名字相匹配
    let result = src.search(sub);
    return result > -1;
}

类型别名 (Type Aliases)

类型别名为类型创建一个新的名称。它可以用于原始值、联合类型、元组以及其它任何你需要手写的类型。

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;

function getName(n: NameOrResolver): Name {
    if (typeof n === 'string') {
        return n;
    } else {
        return n();
    }
}

// 也可以像接口一样描述对象形状
type Point = {
    x: number;
    y: number;
};

// 类型别名可以使用交集和联合
type PartialPoint = { x: number; } | { y: number; };

接口 vs 类型别名:

  • 接口可以“打开”并扩展(Declaration Merging)。
  • 类型别名不能被重新打开以添加新的属性。
  • 接口通常用于定义对象的结构,而类型别名更通用。

联合类型 (Union Types) & 交叉类型 (Intersection Types)

  • 联合类型: 表示一个值可以是几种类型之一。使用 | 分隔每个类型。

    let value: string | number;
    value = "hello"; // OK
    value = 42;      // OK
    // value = true; // Error
    
  • 交叉类型: 将多个类型合并为一个类型。使用 & 连接。

    interface Colorful {
        color: string;
    }
    interface Circle {
        radius: number;
    }
    
    type ColorfulCircle = Colorful & Circle;
    
    const cc: ColorfulCircle = {
        color: "red",
        radius: 10
    }; // 必须同时满足 Colorful 和 Circle 的要求
    

数组与元组 (Tuples)

  • 数组: 存储相同类型的元素。

    let list1: number[] = [1, 2, 3];
    let list2: Array<number> = [1, 2, 3]; // 泛型语法
    
  • 元组: 允许表示一个已知元素数量和类型的数组,各元素的类型不必相同。

    let tuple: [string, number] = ['hello', 10];
    // tuple[0] = 10; // Error: Type 'number' is not assignable to type 'string'.
    

函数类型

  • 函数声明/表达式中的类型注解:

    function greet(name: string): string {
        return "Hello, " + name;
    }
    
    const greeter = function(name: string): string {
         return "Hello, " + name;
    };
    
    const arrowGreeter = (name: string): string => {
        return "Hello, " + name;
    };
    
  • 可选参数和默认参数:

    function buildName(firstName: string, lastName?: string) { ... } // lastName 是可选的
    function buildNameWithDefault(firstName: string, lastName = "Smith") { ... } // 默认参数
    
  • 剩余参数:

    function buildNameRest(firstName: string, ...restOfName: string[]) {
        return firstName + " " + restOfName.join(" ");
    }
    

类 (Classes)

TypeScript 支持面向对象编程的类。

class Animal {
    name: string;
    private age: number; // 私有属性
    protected species: string; // 受保护的属性
    readonly legs: number = 4; // 只读属性

    constructor(theName: string, theAge: number, theSpecies: string) {
        this.name = theName;
        this.age = theAge;
        this.species = theSpecies;
    }

    move(distanceInMeters: number = 0) {
        console.log(`${this.name} moved ${distanceInMeters}m.`);
    }
}

class Dog extends Animal {
    breed: string;

    constructor(name: string, age: number, species: string, breed: string) {
        super(name, age, species); // 调用父类构造函数
        this.breed = breed;
    }

    bark() {
        console.log('Woof! Woof!');
    }

    move(distanceInMeters = 5) { // 重写父类方法
        console.log("Running...");
        super.move(distanceInMeters);
    }
}

泛型 (Generics)

泛型允许你编写可复用的组件,这些组件可以工作在多种类型之上。

// 泛型函数
function identity<T>(arg: T): T {
    return arg;
}
let output1 = identity<string>("myString"); // type argument specified
let output2 = identity("myString"); // type argument inferred

// 泛型接口
interface GenericIdentityFn<T> {
    (arg: T): T;
}
let myIdentity: GenericIdentityFn<number> = identity;

// 泛型类
class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

// 泛型约束
interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length); // Now we know it has a .length property, so no more error
    return arg;
}
// loggingIdentity(3); // Error, number doesn't have a .length property
loggingIdentity({length: 10, value: 3}); // OK

模块 (Modules) 和 导入/导出

将代码组织到不同的文件中,并控制它们之间的可见性。

// math.ts
export const pi = 3.14;
export function add(x: number, y: number) {
    return x + y;
}
// 或者统一导出
// const pi = 3.14;
// function add(x: number, y: number) { return x + y; }
// export { pi, add };

// main.ts
import { pi, add } from './math';
console.log(pi); // 3.14
console.log(add(1, 2)); // 3

// 导入重命名
import { add as sum } from './math';

// 导入整个模块
import * as math from './math';
console.log(math.pi);

// 默认导出
// export default class Calculator { ... }
// import Calculator from './Calculator'; // 注意没有花括号 {}

装饰器 (Decorators)

一种特殊类型的声明,可以被附加到类声明,方法,访问符,属性或参数上。装饰器使用 @expression 这种形式,expression 求值后必须为一个函数,该函数会在运行时被调用。

需要启用实验性支持 ("experimentalDecorators": true in tsconfig.json)。

// 类装饰器示例
function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
}

@sealed
class Greeter {
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    greet() {
        return "Hello, " + this.greeting;
    }
}

实用工具类型 (Utility Types)

TypeScript 内置了一些非常有用的工具类型来简化常见的类型转换。

  • Partial<T>: 构造类型 T,并将它所有的属性设置为可选的。
  • Readonly<T>: 构造类型 T,并将它所有的属性设置为只读的。
  • Record<K, T>: 构造一个类型,其属性名的类型是 K,属性值的类型是 T
  • Pick<T, K>: 从类型 T 中选择出属性 K,构造成一个新的类型。
  • Omit<T, K>: 从类型 T 中排除掉属性 K,构造成一个新的类型。
  • Exclude<T, U>: 从 T 中排除掉那些可以赋值给 U 的类型。
  • Extract<T, U>: 从 T 中提取那些可以赋值给 U 的类型。
  • NonNullable<T>: 从 T 中排除 nullundefined
  • Parameters<T>: 获取函数类型的参数类型组成的元组类型。
  • ReturnType<T>: 获取函数类型的返回值类型。
  • InstanceType<T>: 获取构造函数类型的实例类型。
interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, 'title' | 'completed'>; // { title: string; completed: boolean; }

type TodoInfo = Omit<Todo, 'description'>; // { title: string; completed: boolean; }

type Status = 'pending' | 'fulfilled' | 'rejected';
type SyncStatus = Exclude<Status, 'pending'>; // 'fulfilled' | 'rejected'

function f1(s: string) { return { a: 1, b: s }; }
type F1Params = Parameters<typeof f1>; // [s: string]
type F1ReturnType = ReturnType<typeof f1>; // { a: number; b: string; }

打个广告,帮忙招一个iOS开发的扛把子~

打破 35 + 职业魔咒|AI 出海创业梦之队诚招 iOS 技术负责人

我们拒绝「35 岁职场干电池」标签,坚信经验是最宝贵的财富 —— 诚邀深耕 iOS 领域的技术大佬,与我们并肩开拓 AI 出海新赛道,在碰撞中创新,在实战中共同成长!

关于我们:无短板的出海「六边形战士」梦之队

  • 核心成员均来自陌陌、米可、莱熙等一线出海团队,深耕泛娱乐赛道多年,打造过多个非游出海明星产品;
  • 运营端手握千万级优质资源,技术核心源自红客联盟,擅长落地黑科技创新玩法;
  • 市场团队是流量运营专家,仅靠出海 0-1 阶段顾问服务,不到两年便实现年营收破百万;
  • 项目已跑通商业闭环,数据表现亮眼,无需依赖融资即可稳定自造血,创业路上底气十足。

我们需要这样的你:iOS 技术领路人

岗位职责

  1. 主导搭建创业公司 iOS 技术体系,负责 AI 驱动型 App 核心架构设计与关键模块开发,深度集成 OpenAI 等第三方 AI 服务;
  2. 攻克海外业务适配难题:完成多语言本地化落地,合规适配 GDPR/CCPA 等海外法规,解决跨地区网络稳定性问题;
  3. 统筹海外 App Store 上架全流程,精准解读审核规则,保障版本顺利上线,高效排查线上突发问题;
  4. 搭建轻量化工程化流程,聚焦 App 启动速度、崩溃率等核心指标,实现性能攻坚与优化。

任职要求

  1. 本科及以上学历,5-10 年 iOS 开发经验,有创业公司或海外 App 完整开发 / 落地经历;
  2. 精通 Swift/Objective-C 及 iOS 核心框架,具备扎实的架构设计能力与复杂项目把控经验;
  3. 有 AI 服务移动端集成实战经验,熟悉接口调用逻辑与数据处理全流程;
  4. 深谙海外 iOS 生态,对 App Store 审核规则、海外合规要求有清晰认知;
  5. 适应创业快节奏,能快速响应并解决性能优化、跨地区适配等复杂技术问题。

加分项

  • 主导过 AI 驱动型 App 海外上架,成功落地美区、欧区等核心市场;
  • 有海外合规改造或性能优化标杆案例,能提供明确数据成果(如崩溃率降低 X%、启动速度提升 X%);
  • 熟悉 Stripe/PayPal 支付集成、Firebase 等海外常用第三方服务,或具备 Flutter 混合开发经验。

投递须知

  1. 工作地点:北京(可出厂开发优先考虑),技术过硬可以接受远程 / 异地;
  2. 为高效匹配,确保你对出海 AI 赛道有强烈意愿,且符合上述核心要求后再投递;
  3. 简历投递邮箱:1689630415@qq.com,邮件主题建议注明「iOS 技术负责人 + 姓名 + 工作年限」。

我们不设年龄焦虑,只看能力与潜力;这里没有层级束缚,只有并肩作战的伙伴。期待你加入,成为我们不可或缺的核心力量,一起在 AI 出海赛道共创下一个爆款!

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

Swift UI 状态管理

一、@State State修饰的属性是值传递

SwiftUI管理声明为state的存储属性。当值发生变化时,SwiftUI会更新视图层次结构中依赖于该值的部分。对这个属性进行赋值的操作将会触发 View 的刷新,它的 body 会被再次调用,底层渲染引擎会找出界面上被改变的部分,根据新的属性值计算出新的 View,并进行刷新。

struct JLStateView: View {
    @State var count = 0
    var body: some View {
        VStack {
            Text("\(count)")
            Button("按钮点击加1") {
                count += 1
            }
            .background(.orange)
            
        }
    }
}

通过@State定义变量count,点击按钮会触发Text中数字的显示

  • 不要在视图层次结构中实例化视图的位置初始化视图的状态属性,因为这可能与SwiftUI提供的存储管理冲突。

  • 为了避免这种情况,总是将state声明为private,并将其放在视图层次结构中需要访问该值的最高视图中。

@State private var count = 0

二、@Binding

@State修饰的属性是值传递,因此在父视图和子视图之间传递属性时。子视图针对属性的修改无法传递到父视图上。

Binding修饰后会将属性会变为一个引用类型,视图之间的传递从值传递变为了引用传递,将父视图和子视图的属性关联起来。这样子视图针对属性的修改,会传递到父视图上。

需要在属性名称前加上一个美元符号$来获得这个值。

被声明为 @Binding 的属性进行赋值,改变的将不是属性本身,而是它的引用,这个改变将被向外传递.

import SwiftUI
struct JLBtnView: View {
    @Binding var isShowText: Bool
    var body: some View {
        Button("按钮点击") {
            isShowText.toggle()
        }
    }
}

struct JLContentView: View {
    @State private var isShowText: Bool = true
    var body: some View {
        VStack {
            if isShowText{
                Text("点击后会被隐藏")
            }else{
                Text("点击后会被显示")
            }
            /// $isShowText 双向绑定
            JLBtnView(isShowText: $isShowText)
        }
    }
}
  • 按钮在JLBtnView视图中,并且通过点击,修改isShowText的值。

  • 将jLBtnView视图添加到JLContentView上作为它的子视图。并且传入isShowText。

  • 此时的传值是指针传递,会将点击后的属性值传递到父视图上。

  • 父视图拿到后也作用在自己的属性,因此他的文本视图会依据该属性而隐藏或显示

  • 如果将@Binding改为@State,会发现点击后不起作用。这是因为值传递子视图的更改不会反映到父视图上

struct JLContentView: View {
    @State private var name: String = ""
    var body: some View {
        VStack {
            TextField("请输入您的名字",text: $name)
            Text(name)
            
        }
    }
}
  • 在文本输入框中输入的数据,就会传入到name中

  • 同时name又绑定在文本视图上,所以会将文本输入框输入的文本显示到文本视图上

  • 这就是数据绑定的快捷实现。

三、@ObservedObject

如果说 @State 是全自动驾驶的话,ObservableObject 就是半自动,它需要一些额外的声明。ObservableObject 协议要求实现类型是 class,它只有一个需要实现的属性:objectWillChange。在数据将要发生改变时,这个属性用来向外进行“广播”,它的订阅者 (一般是 View 相关的逻辑) 在收到通知后,对 View 进行刷新。
创建 ObservableObject 后,实际在 View 里使用时,我们需要将它声明为 @ObservedObject。这也是一个属性包装,它负责通过订阅 objectWillChange 这个“广播”,将具体管理数据的 ObservableObject 和当前的 View 关联起来。

  • 绑定的数据是一个对象。

  • 被修饰的对象,其类必须遵守ObservableObject协议

  • 此时这个类中被@Published修饰的属性都会被绑定

  • 使用@ObservedObject修饰这个对象,绑定这个对象。

  • 被@Published修饰的属性发生改变时,SwiftUI就会进行更新。

import SwiftUI
internal import Combine

class Persion: ObservableObject{
    /// 属性只有被@Published修饰时,属性的值修改时,才能被监听到
    @Published var name = ""
}

struct JLContentView: View {
    @ObservedObject var p = Persion()
    var body: some View {
        VStack {
            Text(p.name)
                .padding()
            Button("修改") {
                p.name = "哈哈"
            }
            
        }
    }
}

@ObservedObject修饰的必须是遵守ObservableObject 协议的class对象
class对象的属性只有被@Published修饰时,属性的值修改时,才能被监听到

四、@EnvironmentObject

在多视图中,为了避免数据的无效传递,可以直接将数据放到环境中,供多个视图进行使用

在 SwiftUI 中,View 提供了 environmentObject( 方法,来把某个 ObservableObject 的值注入到当前 View 层级及其子层级中去。在这个 View 的子层级中,可以使用 @EnvironmentObject 来直接获取这个绑定的环境值。

extension View {

    @inlinable nonisolated public func environmentObject<T>(_ object: T) -> some View where T : ObservableObject
}

final class Persion: ObservableObject{
    @Published var name = "哈哈"
}
struct MapView: View {
    @EnvironmentObject var p : Persion
    var body: some View {
        VStack {
            Text(p.name)
            Button("点击") {
                p.name = "呵呵"
            }
        }
    }
}

struct JLContentView: View {
    
    var body: some View {
        VStack {
            let p = Persion()
            MapView().environmentObject(p)
        }
    }
}

@EnvironmentObject 修饰器是针对全局环境的。通过它,我们可以避免在初始 View 时创建 ObservableObject, 而是从环境中获取 ObservableObject
可以看出我们获取 p这个 ObservableObject 是通过 @EnvironmentObject 修饰器,但是在入口需要传入 .environmentObject(p) 。@EnvironmentObject 的工作方式是在 Environment 查找 Person 实例。

import SwiftUI
internal import Combine

final class Persion: ObservableObject{
    @Published var name = "哈哈"
}

struct EnvView: View {
    @EnvironmentObject var p : Persion
    var body: some View {
        Text(p.name)
    }
}

struct BtnView: View {
    @EnvironmentObject var p: Persion
    var body: some View {
        Text(p.name)
        Button("修改") {
            p.name = "1123"
        }
    }
}


struct JLContentView: View {
    let p = Persion()
    var body: some View {
        VStack {
            EnvView().environmentObject(p)
            BtnView().environmentObject(p)
        }
    }
}
  • 给属性添加@EnvironmentObject修改,就将其放到了环境中。

  • 其他视图中想要获取该属性,可以通过.environmentObject从环境中获取。

  • 可以看到分别将EnvView和BtnvView的属性分别放到了环境中

  • 之后我们ContentView视图中获取数据时,可以直接通过环境获取。

  • 不需要将数据传递到ContentView,而是直接通过环境获取,这样避免了无效的数据传递,更加高效

  • 如果是在多层级视图之间进行传递,会有更明显的效果。

import SwiftUI
internal import Combine

final class Persion: ObservableObject{
    @Published var name = 1
    deinit{
        print("被销毁了")
    }
}

struct MapView: View {
    @ObservedObject var p = Persion()
    var body: some View {
        VStack{
            Text("\(p.name)")
            Button("+1") { //添加一个按钮,指定标题文字为 First button
                p.name += 1
            }
        
        }
    }
}

struct JLContentView: View {
    @State var count = 0
    var body: some View {
        VStack {
            Text("刷新:\(count)")
            Button("刷新"){
                count += 1
            }
            
            MapView()

        }
    }
}

点击刷新时,Person 的deinit方法被调用,说明p对象被销毁;
先连续点击+1,Text上的数字在一直递增,当点击刷新时Text上的数字恢复为1,这个现象也说明p对象被销毁

import SwiftUI
internal import Combine


final class Persion: ObservableObject{
    @Published var name = 1
    deinit{
        print("被销毁了")
    }
}

struct MapView: View {
    
    @StateObject var p = Persion()
    var body: some View {
        VStack{
            Text("\(p.name)")
            Button("+1") { //添加一个按钮,指定标题文字为 First button
                p.name += 1
            }
        
        }
    }
}

struct JLContentView: View {
    @State var count = 0
    var body: some View {
        VStack {
            Text("刷新:\(count)")
            Button("刷新"){
                count += 1
            }
            
            MapView()

        }
    }
}

和例1不同的是怎么操作,p都不会销毁

@StateObject的声明周期与当前所在View生命周期保持一致,即当View被销毁后,StateObject的数据销毁,当View被刷新时,StateObject的数据会保持;而ObservedObject不被View持有,生命周期不一定与View一致,即数据可能被保持或者销毁;

《Flutter全栈开发实战指南:从零到高级》- 19 -手势识别

引言

在移动应用开发中,流畅自然的手势交互是提升用户体验的关键。今天我们来深入探讨Flutter中的手势识别,带你从0-1掌握这个强大的交互工具。

1. GestureDetector

1.1 GestureDetector原理

下面我们先通过一个架构图来加深理解GestureDetector的工作原理:

graph TB
    A[触摸屏幕] --> B[RawPointerEvent事件产生]
    B --> C[GestureDetector接收事件]
    C --> D[手势识别器分析]
    D --> E{匹配手势类型}
    E -->|匹配成功| F[触发对应回调]
    E -->|匹配失败| G[事件传递给其他组件]
    F --> H[更新UI状态]
    G --> I[父组件处理]

核心原理解析:

  1. 事件传递机制

    • Flutter使用冒泡机制传递触摸事件
    • 从最内层组件开始,向外层组件传递
    • 每个GestureDetector都可以拦截和处理事件
  2. 多手势竞争

    • 多个手势识别器竞争处理同一组触摸事件
    • 通过规则决定哪个识别器获胜
    • 获胜者将处理后续的所有相关事件
  3. 命中测试

    • 确定触摸事件发生在哪个组件上
    • 通过HitTestBehavior控制测试行为

1.2 基础手势识别

下面演示一个基础手势识别案例:

class BasicGestureExample extends StatefulWidget {
  @override
  _BasicGestureExampleState createState() => _BasicGestureExampleState();
}

class _BasicGestureExampleState extends State<BasicGestureExample> {
  String _gestureStatus = '等待手势...';
  Color _boxColor = Colors.blue;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('基础手势识别')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 手势检测区域
            GestureDetector(
              onTap: () {
                setState(() {
                  _gestureStatus = '单击 detected';
                  _boxColor = Colors.green;
                });
              },
              onDoubleTap: () {
                setState(() {
                  _gestureStatus = '双击 detected';
                  _boxColor = Colors.orange;
                });
              },
              onLongPress: () {
                setState(() {
                  _gestureStatus = '长按 detected';
                  _boxColor = Colors.red;
                });
              },
              onPanUpdate: (details) {
                setState(() {
                  _gestureStatus = '拖拽中: ${details.delta}';
                  _boxColor = Colors.purple;
                });
              },
              onScaleUpdate: (details) {
                setState(() {
                  _gestureStatus = '缩放: ${details.scale.toStringAsFixed(2)}';
                  _boxColor = Colors.teal;
                });
              },
              child: Container(
                width: 200,
                height: 200,
                decoration: BoxDecoration(
                  color: _boxColor,
                  borderRadius: BorderRadius.circular(16),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black26,
                      blurRadius: 10,
                      offset: Offset(0, 4),
                    )
                  ],
                ),
                child: Icon(
                  Icons.touch_app,
                  color: Colors.white,
                  size: 50,
                ),
              ),
            ),
            SizedBox(height: 30),
            // 状态显示
            Container(
              padding: EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.grey[100],
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                _gestureStatus,
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
              ),
            ),
            SizedBox(height: 20),
            // 手势说明
            _buildGestureInstructions(),
          ],
        ),
      ),
    );
  }

  Widget _buildGestureInstructions() {
    return Container(
      padding: EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildInstructionItem('单击', '快速点击一次'),
          _buildInstructionItem('双击', '快速连续点击两次'),
          _buildInstructionItem('长按', '按住不放'),
          _buildInstructionItem('拖拽', '按住并移动'),
          _buildInstructionItem('缩放', '双指捏合或展开'),
        ],
      ),
    );
  }

  Widget _buildInstructionItem(String gesture, String description) {
    return Padding(
      padding: EdgeInsets.symmetric(vertical: 8),
      child: Row(
        children: [
          Text(gesture, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          SizedBox(width: 16),
          Text(description, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
        ],
      ),
    );
  }
}

1.3 手势识别器类型总结

下面我们总结下手势识别器都包含哪些类型,并了解各种手势识别器的特性:

手势类型 识别器 触发条件 应用场景
点击 onTap 快速触摸释放 按钮点击、项目选择
双击 onDoubleTap 快速连续两次点击 图片放大/缩小、点赞
长按 onLongPress 长时间按住 显示上下文菜单、拖拽准备
拖拽 onPanUpdate 按住并移动 滑动删除、元素拖拽
缩放 onScaleUpdate 双指捏合/展开 图片缩放、地图缩放
垂直拖拽 onVerticalDragUpdate 垂直方向拖拽 滚动列表、下拉刷新
水平拖拽 onHorizontalDragUpdate 水平方向拖拽 页面切换、轮播图

1.4 多手势间竞争规则

我们先来演示下不同手势的触发效果 在这里插入图片描述

  • 竞争规则

竞争核心规则.png

2. 拖拽与缩放

2.1 实现原理

拖拽功能的实现基于以下事件序列:

sequenceDiagram
    participant U as 用户
    participant G as GestureDetector
    participant S as State
    
    U->>G: 手指按下 (onPanStart)
    G->>S: 记录起始位置
    Note over S: 设置_dragging = true
    
    loop 拖拽过程
        U->>G: 手指移动 (onPanUpdate)
        G->>S: 更新位置数据
        S->>S: setState() 触发重建
        Note over S: 根据delta更新坐标
    end
    
    U->>G: 手指抬起 (onPanEnd)
    G->>S: 结束拖拽状态
    Note over S: 设置_dragging = false

2.2 拖拽功能

下面是拖拽功能核心代码实现:

class DraggableBox extends StatefulWidget {
  @override
  _DraggableBoxState createState() => _DraggableBoxState();
}

class _DraggableBoxState extends State<DraggableBox> {
  // 位置状态
  double _positionX = 0.0;
  double _positionY = 0.0;
  
  // 拖拽状态
  bool _isDragging = false;
  double _startX = 0.0;
  double _startY = 0.0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('拖拽盒子')),
      body: Stack(
        children: [
          // 背景网格
          _buildBackgroundGrid(),
          
          // 拖拽盒子
          Positioned(
            left: _positionX,
            top: _positionY,
            child: GestureDetector(
              onPanStart: _handlePanStart,
              onPanUpdate: _handlePanUpdate,
              onPanEnd: _handlePanEnd,
              child: AnimatedContainer(
                duration: Duration(milliseconds: 100),
                width: 120,
                height: 120,
                decoration: BoxDecoration(
                  color: _isDragging ? Colors.blue[700] : Colors.blue[500],
                  borderRadius: BorderRadius.circular(12),
                  boxShadow: _isDragging ? [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.3),
                      blurRadius: 15,
                      offset: Offset(0, 8),
                    )
                  ] : [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.2),
                      blurRadius: 8,
                      offset: Offset(0, 4),
                    )
                  ],
                  border: Border.all(
                    color: Colors.white,
                    width: 2,
                  ),
                ),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(
                      _isDragging ? Icons.touch_app : Icons.drag_handle,
                      color: Colors.white,
                      size: 40,
                    ),
                    SizedBox(height: 8),
                    Text(
                      _isDragging ? '拖拽中...' : '拖拽我',
                      style: TextStyle(color: Colors.white),
                    ),
                  ],
                ),
              ),
            ),
          ),
          
          // 位置信息
          Positioned(
            bottom: 20,
            left: 20,
            child: Container(
              padding: EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.black.withOpacity(0.7),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                '位置: (${_positionX.toStringAsFixed(1)}, '
                    '${_positionY.toStringAsFixed(1)})',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _handlePanStart(DragStartDetails details) {
    setState(() {
      _isDragging = true;
      _startX = details.globalPosition.dx - _positionX;
      _startY = details.globalPosition.dy - _positionY;
    });
  }

  void _handlePanUpdate(DragUpdateDetails details) {
    setState(() {
      _positionX = details.globalPosition.dx - _startX;
      _positionY = details.globalPosition.dy - _startY;
      
      // 限制在屏幕范围内
      final screenWidth = MediaQuery.of(context).size.width;
      final screenHeight = MediaQuery.of(context).size.height;
      
      _positionX = _positionX.clamp(0.0, screenWidth - 120);
      _positionY = _positionY.clamp(0.0, screenHeight - 200);
    });
  }

  void _handlePanEnd(DragEndDetails details) {
    setState(() {
      _isDragging = false;
    });
  }

  Widget _buildBackgroundGrid() {
    return Container(
      width: double.infinity,
      height: double.infinity,
      child: CustomPaint(
        painter: _GridPainter(),
      ),
    );
  }
}

class _GridPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.grey[300]!
      ..strokeWidth = 1.0
      ..style = PaintingStyle.stroke;

    // 绘制网格
    const step = 40.0;
    for (double x = 0; x < size.width; x += step) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
    }
    for (double y = 0; y < size.height; y += step) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

2.3 缩放功能

缩放功能涉及到矩阵变换,下面是核心代码实现:

class ZoomableImage extends StatefulWidget {
  final String imageUrl;
  
  const ZoomableImage({required this.imageUrl});

  @override
  _ZoomableImageState createState() => _ZoomableImageState();
}

class _ZoomableImageState extends State<ZoomableImage> {
  // 变换控制器
  Matrix4 _transform = Matrix4.identity();
  Matrix4 _previousTransform = Matrix4.identity();
  
  // 缩放限制
  final double _minScale = 0.5;
  final double _maxScale = 4.0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('可缩放图片')),
      body: Center(
        child: GestureDetector(
          onScaleStart: _onScaleStart,
          onScaleUpdate: _onScaleUpdate,
          onDoubleTap: _onDoubleTap,
          child: Transform(
            transform: _transform,
            child: Container(
              width: 300,
              height: 300,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(12),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black26,
                    blurRadius: 10,
                    offset: Offset(0, 4),
                  )
                ],
                image: DecorationImage(
                  image: NetworkImage(widget.imageUrl),
                  fit: BoxFit.cover,
                ),
              ),
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _resetTransform,
        child: Icon(Icons.refresh),
      ),
    );
  }

  void _onScaleStart(ScaleStartDetails details) {
    _previousTransform = _transform;
  }

  void _onScaleUpdate(ScaleUpdateDetails details) {
    setState(() {
      // 计算新的缩放比例
      double newScale = _getScale(_previousTransform) * details.scale;
      newScale = newScale.clamp(_minScale, _maxScale);
      
      // 创建变换矩阵
      _transform = Matrix4.identity()
        ..scale(newScale)
        ..translate(
          details.focalPoint.dx / newScale - details.localFocalPosition.dx,
          details.focalPoint.dy / newScale - details.localFocalPosition.dy,
        );
    });
  }

  void _onDoubleTap() {
    setState(() {
      // 双击切换原始大小和放大状态
      final currentScale = _getScale(_transform);
      final targetScale = currentScale == 1.0 ? 2.0 : 1.0;
      
      _transform = Matrix4.identity()..scale(targetScale);
    });
  }

  void _resetTransform() {
    setState(() {
      _transform = Matrix4.identity();
    });
  }

  double _getScale(Matrix4 matrix) {
    // 从变换矩阵中提取缩放值
    return matrix.getMaxScaleOnAxis();
  }
}

3. 手势冲突解决

3.1 手势冲突类型分析

手势冲突主要分为三种类型,我们可以用下面的UML图来表示:

classDiagram
    class GestureConflict {
        <<enumeration>>
        ParentChild
        Sibling
        SameType
    }
    
    class ParentChildConflict {
        +String description
        +Solution solution
    }
    
    class SiblingConflict {
        +String description
        +Solution solution
    }
    
    class SameTypeConflict {
        +String description
        +Solution solution
    }
    
    GestureConflict <|-- ParentChildConflict
    GestureConflict <|-- SiblingConflict
    GestureConflict <|-- SameTypeConflict

具体冲突类型说明:

  1. 父子组件冲突

    • 现象:父组件和子组件都有相同类型的手势识别
    • 案例:可点击的卡片中包含可点击的按钮
    • 解决方法:使用HitTestBehavior控制事件传递
  2. 兄弟组件冲突

    • 现象:相邻组件的手势区域重叠
    • 案例:两个重叠的可拖拽元素
    • 解决方法:使用Listener精确控制事件处理
  3. 同类型手势冲突

    • 现象:同一组件注册了多个相似手势
    • 案例:同时监听点击和双击
    • 解决方法:设置手势识别优先级

3.2 冲突解决具体方案

方案1:使用HitTestBehavior
class HitTestBehaviorExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        // 父组件手势
        onTap: () => print('父组件点击'),
        behavior: HitTestBehavior.translucent, // 关键设置
        child: Container(
          color: Colors.blue[100],
          padding: EdgeInsets.all(50),
          child: GestureDetector(
            // 子组件手势
            onTap: () => print('子组件点击'),
            child: Container(
              width: 200,
              height: 200,
              color: Colors.red[100],
              child: Center(child: Text('点击测试区域')),
            ),
          ),
        ),
      ),
    );
  }
}
方案2:使用IgnorePointer和AbsorbPointer
class PointerControlExample extends StatefulWidget {
  @override
  _PointerControlExampleState createState() => _PointerControlExampleState();
}

class _PointerControlExampleState extends State<PointerControlExample> {
  bool _ignoreChild = false;
  bool _absorbPointer = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('指针控制案例')),
      body: Column(
        children: [
          // 控制面板
          _buildControlPanel(),
          
          Expanded(
            child: Stack(
              children: [
                // 底层组件
                GestureDetector(
                  onTap: () => print('底层组件被点击'),
                  child: Container(
                    color: Colors.blue[200],
                    child: Center(child: Text('底层组件')),
                  ),
                ),
                
                // 根据条件包装子组件
                if (_ignoreChild)
                  IgnorePointer(
                    child: _buildTopLayer('IgnorePointer'),
                  )
                else if (_absorbPointer)
                  AbsorbPointer(
                    child: _buildTopLayer('AbsorbPointer'),
                  )
                else
                  _buildTopLayer('正常模式'),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildControlPanel() {
    return Container(
      padding: EdgeInsets.all(16),
      color: Colors.grey[100],
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = false;
              _absorbPointer = false;
            }),
            child: Text('正常'),
          ),
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = true;
              _absorbPointer = false;
            }),
            child: Text('IgnorePointer'),
          ),
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = false;
              _absorbPointer = true;
            }),
            child: Text('AbsorbPointer'),
          ),
        ],
      ),
    );
  }

  Widget _buildTopLayer(String mode) {
    return Positioned(
      bottom: 50,
      right: 50,
      child: GestureDetector(
        onTap: () => print('顶层组件被点击 - $mode'),
        child: Container(
          width: 200,
          height: 150,
          color: Colors.red[200],
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('顶层组件'),
                Text('模式: $mode', style: TextStyle(fontWeight: FontWeight.bold)),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

4. 自定义手势识别

4.1 架构图

自定义手势识别器的实现基于以下类结构:

graph TD
    A[GestureRecognizer] --> B[OneSequenceGestureRecognizer]
    B --> C[自定义识别器]
    
    C --> D[addPointer]
    C --> E[handleEvent]
    C --> F[resolve]
    
    D --> G[开始跟踪指针]
    E --> H[处理事件序列]
    F --> I[决定竞争结果]
    
    H --> J{Ptr Down}
    H --> K{Ptr Move}
    H --> L{Ptr Up}
    
    J --> M[记录起始状态]
    K --> N[更新手势数据]
    L --> O[触发最终回调]

4.2 实现自定义滑动手势

// 自定义滑动手势
class SwipeGestureRecognizer extends OneSequenceGestureRecognizer {
  final VoidCallback? onSwipeLeft;
  final VoidCallback? onSwipeRight;
  final VoidCallback? onSwipeUp;
  final VoidCallback? onSwipeDown;
  
  // 配置参数
  static const double _minSwipeDistance = 50.0;    // 最小滑动距离
  static const double _minSwipeVelocity = 100.0;   // 最小滑动速度
  
  // 状态变量
  Offset? _startPosition;
  Offset? _currentPosition;
  int? _trackedPointer;
  DateTime? _startTime;

  @override
  void addPointer(PointerDownEvent event) {
    print('跟踪指针: ${event.pointer}');
    
    startTrackingPointer(event.pointer);
    _startPosition = event.position;
    _currentPosition = event.position;
    _trackedPointer = event.pointer;
    _startTime = DateTime.now();
    
    // 声明参与竞争
    resolve(GestureDisposition.accepted);
  }

  @override
  void handleEvent(PointerEvent event) {
    if (event.pointer != _trackedPointer) return;
    
    if (event is PointerMoveEvent) {
      _currentPosition = event.position;
    } else if (event is PointerUpEvent) {
      _evaluateSwipe();
      stopTrackingPointer(event.pointer);
      _reset();
    } else if (event is PointerCancelEvent) {
      stopTrackingPointer(event.pointer);
      _reset();
    }
  }

  void _evaluateSwipe() {
    if (_startPosition == null || _currentPosition == null || _startTime == null) {
      return;
    }

    final offset = _currentPosition! - _startPosition!;
    final distance = offset.distance;
    final duration = DateTime.now().difference(_startTime!);
    final velocity = distance / duration.inMilliseconds * 1000;

    print('滑动评估 - 距离: ${distance.toStringAsFixed(1)}, '
        '速度: ${velocity.toStringAsFixed(1)}, 方向: $offset');

    // 检查是否达到滑动阈值
    if (distance >= _minSwipeDistance && velocity >= _minSwipeVelocity) {
      // 判断滑动方向
      if (offset.dx.abs() > offset.dy.abs()) {
        // 水平滑动
        if (offset.dx > 0) {
          print('向右滑动');
          onSwipeRight?.call();
        } else {
          print('向左滑动');
          onSwipeLeft?.call();
        }
      } else {
        // 垂直滑动
        if (offset.dy > 0) {
          print('向下滑动');
          onSwipeDown?.call();
        } else {
          print('向上滑动');
          onSwipeUp?.call();
        }
      }
    } else {
      print('滑动未达到阈值');
    }
  }

  void _reset() {
    _startPosition = null;
    _currentPosition = null;
    _trackedPointer = null;
    _startTime = null;
  }

  @override
  void didStopTrackingLastPointer(int pointer) {
    print('停止跟踪指针: $pointer');
  }

  @override
  String get debugDescription => 'swipe_gesture';

  @override
  void rejectGesture(int pointer) {
    super.rejectGesture(pointer);
    stopTrackingPointer(pointer);
    _reset();
  }
}

// 使用自定义手势的组件
class SwipeDetector extends StatelessWidget {
  final Widget child;
  final VoidCallback? onSwipeLeft;
  final VoidCallback? onSwipeRight;
  final VoidCallback? onSwipeUp;
  final VoidCallback? onSwipeDown;

  const SwipeDetector({
    Key? key,
    required this.child,
    this.onSwipeLeft,
    this.onSwipeRight,
    this.onSwipeUp,
    this.onSwipeDown,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: {
        SwipeGestureRecognizer: GestureRecognizerFactoryWithHandlers<
          SwipeGestureRecognizer>(
          () => SwipeGestureRecognizer(),
          (SwipeGestureRecognizer instance) {
            instance
              ..onSwipeLeft = onSwipeLeft
              ..onSwipeRight = onSwipeRight
              ..onSwipeUp = onSwipeUp
              ..onSwipeDown = onSwipeDown;
          },
        ),
      },
      child: child,
    );
  }
}

// 调用规则
class SwipeExample extends StatefulWidget {
  @override
  _SwipeExampleState createState() => _SwipeExampleState();
}

class _SwipeExampleState extends State<SwipeExample> {
  String _swipeDirection = '等待滑动手势...';
  Color _backgroundColor = Colors.white;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('自定义滑动手势')),
      body: SwipeDetector(
        onSwipeLeft: () => _handleSwipe('左滑', Colors.red[100]!),
        onSwipeRight: () => _handleSwipe('右滑', Colors.blue[100]!),
        onSwipeUp: () => _handleSwipe('上滑', Colors.green[100]!),
        onSwipeDown: () => _handleSwipe('下滑', Colors.orange[100]!),
        child: Container(
          color: _backgroundColor,
          width: double.infinity,
          height: double.infinity,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.swipe, size: 80, color: Colors.grey),
              SizedBox(height: 20),
              Text(
                _swipeDirection,
                style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
              ),
              SizedBox(height: 10),
              Text(
                '在任意位置滑动试试',
                style: TextStyle(fontSize: 16, color: Colors.grey),
              ),
              SizedBox(height: 30),
              _buildDirectionIndicators(),
            ],
          ),
        ),
      ),
    );
  }

  void _handleSwipe(String direction, Color color) {
    setState(() {
      _swipeDirection = '检测到: $direction';
      _backgroundColor = color;
    });
    
    // 2秒后恢复初始状态
    Future.delayed(Duration(seconds: 2), () {
      if (mounted) {
        setState(() {
          _swipeDirection = '等待滑动手势...';
          _backgroundColor = Colors.white;
        });
      }
    });
  }

  Widget _buildDirectionIndicators() {
    return Container(
      padding: EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.black12,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        children: [
          Icon(Icons.arrow_upward, size: 40, color: Colors.green),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              Icon(Icons.arrow_back, size: 40, color: Colors.red),
              Text('滑动方向', style: TextStyle(fontSize: 16)),
              Icon(Icons.arrow_forward, size: 40, color: Colors.blue),
            ],
          ),
          Icon(Icons.arrow_downward, size: 40, color: Colors.orange),
        ],
      ),
    );
  }
}

5. 交互式画板案例

5.1 画板应用架构设计

graph TB
    A[DrawingBoard] --> B[Toolbar]
    A --> C[CanvasArea]
    
    B --> D[ColorPicker]
    B --> E[BrushSizeSlider]
    B --> F[ActionButtons]
    
    C --> G[GestureDetector]
    G --> H[CustomPaint]
    
    H --> I[DrawingPainter]
    I --> J[Path数据]
    
    subgraph 状态管理
        K[DrawingState]
        L[Path列表]
        M[当前设置]
    end
    
    J --> L
    D --> M
    E --> M

5.2 画板应用实现

// 绘图路径数据类
class DrawingPath {
  final List<Offset> points;
  final Color color;
  final double strokeWidth;
  final PaintMode mode;

  DrawingPath({
    required this.points,
    required this.color,
    required this.strokeWidth,
    this.mode = PaintMode.draw,
  });
}

enum PaintMode { draw, erase }

// 主画板组件
class DrawingBoard extends StatefulWidget {
  @override
  _DrawingBoardState createState() => _DrawingBoardState();
}

class _DrawingBoardState extends State<DrawingBoard> {
  // 绘图状态
  final List<DrawingPath> _paths = [];
  DrawingPath? _currentPath;
  
  // 画笔设置
  Color _selectedColor = Colors.black;
  double _strokeWidth = 3.0;
  PaintMode _paintMode = PaintMode.draw;
  
  // 颜色选项
  final List<Color> _colorOptions = [
    Colors.black,
    Colors.red,
    Colors.blue,
    Colors.green,
    Colors.orange,
    Colors.purple,
    Colors.brown,
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('交互式画板'),
        backgroundColor: Colors.deepPurple,
        actions: [
          IconButton(
            icon: Icon(Icons.undo),
            onPressed: _undo,
            tooltip: '撤销',
          ),
          IconButton(
            icon: Icon(Icons.delete),
            onPressed: _clear,
            tooltip: '清空',
          ),
        ],
      ),
      body: Column(
        children: [
          // 工具栏
          _buildToolbar(),
          
          // 画布区域
          Expanded(
            child: Container(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                  colors: [Colors.grey[100]!, Colors.grey[200]!],
                ),
              ),
              child: GestureDetector(
                onPanStart: _onPanStart,
                onPanUpdate: _onPanUpdate,
                onPanEnd: _onPanEnd,
                child: CustomPaint(
                  painter: _DrawingPainter(_paths),
                  size: Size.infinite,
                ),
              ),
            ),
          ),
          
          // 状态栏
          _buildStatusBar(),
        ],
      ),
    );
  }

  Widget _buildToolbar() {
    return Container(
      padding: EdgeInsets.all(12),
      color: Colors.white,
      child: Column(
        children: [
          // 颜色选择
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text('颜色:', style: TextStyle(fontWeight: FontWeight.bold)),
              Wrap(
                spacing: 8,
                children: _colorOptions.map((color) {
                  return GestureDetector(
                    onTap: () => setState(() {
                      _selectedColor = color;
                      _paintMode = PaintMode.draw;
                    }),
                    child: Container(
                      width: 32,
                      height: 32,
                      decoration: BoxDecoration(
                        color: color,
                        shape: BoxShape.circle,
                        border: Border.all(
                          color: _selectedColor == color ? 
                                Colors.black : Colors.transparent,
                          width: 3,
                        ),
                      ),
                    ),
                  );
                }).toList(),
              ),
              // 橡皮擦按钮
              GestureDetector(
                onTap: () => setState(() {
                  _paintMode = PaintMode.erase;
                }),
                child: Container(
                  padding: EdgeInsets.all(8),
                  decoration: BoxDecoration(
                    color: _paintMode == PaintMode.erase ? 
                          Colors.grey[300] : Colors.transparent,
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Icon(
                    Icons.auto_fix_high,
                    color: _paintMode == PaintMode.erase ? 
                          Colors.red : Colors.grey,
                  ),
                ),
              ),
            ],
          ),
          
          SizedBox(height: 12),
          
          // 笔刷大小
          Row(
            children: [
              Text('笔刷大小:', style: TextStyle(fontWeight: FontWeight.bold)),
              Expanded(
                child: Slider(
                  value: _strokeWidth,
                  min: 1,
                  max: 20,
                  divisions: 19,
                  onChanged: (value) => setState(() {
                    _strokeWidth = value;
                  }),
                ),
              ),
              Container(
                padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                decoration: BoxDecoration(
                  color: Colors.grey[200],
                  borderRadius: BorderRadius.circular(16),
                ),
                child: Text(
                  '${_strokeWidth.toInt()}px',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildStatusBar() {
    return Container(
      padding: EdgeInsets.all(8),
      color: Colors.black87,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            _paintMode == PaintMode.draw ? '绘图模式' : '橡皮擦模式',
            style: TextStyle(color: Colors.white),
          ),
          Text(
            '路径数量: ${_paths.length}',
            style: TextStyle(color: Colors.white),
          ),
        ],
      ),
    );
  }

  void _onPanStart(DragStartDetails details) {
    setState(() {
      _currentPath = DrawingPath(
        points: [details.localPosition],
        color: _paintMode == PaintMode.erase ? Colors.white : _selectedColor,
        strokeWidth: _paintMode == PaintMode.erase ? _strokeWidth * 2 : _strokeWidth,
        mode: _paintMode,
      );
      _paths.add(_currentPath!);
    });
  }

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      _currentPath?.points.add(details.localPosition);
    });
  }

  void _onPanEnd(DragEndDetails details) {
    _currentPath = null;
  }

  void _undo() {
    if (_paths.isNotEmpty) {
      setState(() {
        _paths.removeLast();
      });
    }
  }

  void _clear() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('清空画板'),
        content: Text('确定要清空所有绘图吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('取消'),
          ),
          TextButton(
            onPressed: () {
              setState(() {
                _paths.clear();
              });
              Navigator.pop(context);
            },
            child: Text('清空'),
          ),
        ],
      ),
    );
  }
}

// 绘图绘制器
class _DrawingPainter extends CustomPainter {
  final List<DrawingPath> paths;

  _DrawingPainter(this.paths);

  @override
  void paint(Canvas canvas, Size size) {
    // 绘制背景网格
    _drawBackgroundGrid(canvas, size);
    
    // 绘制所有路径
    for (final path in paths) {
      final paint = Paint()
        ..color = path.color
        ..strokeWidth = path.strokeWidth
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round
        ..style = PaintingStyle.stroke;

      // 绘制路径
      if (path.points.length > 1) {
        final pathPoints = Path();
        pathPoints.moveTo(path.points[0].dx, path.points[0].dy);
        
        for (int i = 1; i < path.points.length; i++) {
          pathPoints.lineTo(path.points[i].dx, path.points[i].dy);
        }
        
        canvas.drawPath(pathPoints, paint);
      }
    }
  }

  void _drawBackgroundGrid(Canvas canvas, Size size) {
    final gridPaint = Paint()
      ..color = Colors.grey[300]!
      ..strokeWidth = 0.5;
    
    const gridSize = 20.0;
    
    // 绘制垂直线
    for (double x = 0; x < size.width; x += gridSize) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint);
    }
    
    // 绘制水平线
    for (double y = 0; y < size.height; y += gridSize) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

6. 性能优化

6.1 手势性能优化策略

下面我们可以详细了解各种优化策略的效果:

优化策略 解决方法 应用场景
减少GestureDetector嵌套 合并相邻手势检测器 复杂布局、列表项
使用InkWell替代 简单点击使用InkWell 按钮、列表项点击
合理使用HitTestBehavior 精确控制命中测试范围 重叠组件、透明区域
避免频繁setState 使用TransformController 拖拽、缩放操作
列表项手势优化 使用NotificationListener 长列表、复杂手势

6.2 实际案例优化

class OptimizedGestureExample extends StatefulWidget {
  @override
  _OptimizedGestureExampleState createState() => _OptimizedGestureExampleState();
}

class _OptimizedGestureExampleState extends State<OptimizedGestureExample> {
  final TransformationController _transformController = TransformationController();
  final List<Widget> _items = [];

  @override
  void initState() {
    super.initState();
    // 初始化
    _initializeItems();
  }

  void _initializeItems() {
    for (int i = 0; i < 50; i++) {
      _items.add(
        OptimizedListItem(
          index: i,
          onTap: () => print('Item $i tapped'),
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('优化手势')),
      body: Column(
        children: [
          // 可缩放拖拽区域
          Expanded(
            flex: 2,
            child: InteractiveViewer(
              transformationController: _transformController,
              boundaryMargin: EdgeInsets.all(20),
              minScale: 0.1,
              maxScale: 4.0,
              child: Container(
                color: Colors.blue[50],
                child: Center(
                  child: FlutterLogo(size: 150),
                ),
              ),
            ),
          ),
          
          // 优化列表
          Expanded(
            flex: 3,
            child: NotificationListener<ScrollNotification>(
              onNotification: (scrollNotification) {
                // 可以在这里处理滚动优化
                return false;
              },
              child: ListView.builder(
                itemCount: _items.length,
                itemBuilder: (context, index) => _items[index],
              ),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _transformController.dispose();
    super.dispose();
  }
}

// 优化的列表项组件
class OptimizedListItem extends StatelessWidget {
  final int index;
  final VoidCallback onTap;

  const OptimizedListItem({
    required this.index,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
      child: Material(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
        elevation: 2,
        child: InkWell(  
          onTap: onTap,
          borderRadius: BorderRadius.circular(8),
          child: Container(
            padding: EdgeInsets.all(16),
            child: Row(
              children: [
                Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                    color: Colors.primaries[index % Colors.primaries.length],
                    shape: BoxShape.circle,
                  ),
                  child: Center(
                    child: Text(
                      '$index',
                      style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
                    ),
                  ),
                ),
                SizedBox(width: 16),
                Expanded(
                  child: Text(
                    '优化列表项 $index',
                    style: TextStyle(fontSize: 16),
                  ),
                ),
                Icon(Icons.chevron_right, color: Colors.grey),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

总结

至此,手势识别相关知识点全部讲完了,通过本节的学习,我们掌握了Flutter手势识别的完整知识体系:GestureDetector拖拽与缩放手势冲突解决自定义手势识别

对于不同阶段的开发者,建议按以下路径学习:

graph LR
    A[初学者] --> B[基础手势]
    B --> C[拖拽缩放]
    
    C --> D[中级开发者]
    D --> E[手势冲突解决]
    E --> F[性能优化]
    
    F --> G[高级开发者]
    G --> H[自定义手势]
    H --> I[复杂交互系统]

如果觉得这篇文章对你有帮助,别忘了一键三连(点赞、关注、收藏)!你的支持是我持续创作的最大动力!有任何问题欢迎在评论区留言,我会及时解答!

iOS 内存管理深度解析:从原理到实践

前言

内存管理是 iOS 开发中最核心的知识点之一,理解透彻的内存管理机制不仅能帮助我们写出高质量的代码,还能有效避免内存泄漏、野指针等常见问题。本文将从底层原理到实际应用,全面剖析 iOS 的内存管理机制。


一、内存管理的演进历程

1.1 MRC 时代(Manual Reference Counting)

在 iOS 5 之前,开发者需要手动管理对象的生命周期:

// MRC 时代的内存管理
NSObject *obj = [[NSObject alloc] init]; // retainCount = 1
[obj retain];                             // retainCount = 2
[obj release];                            // retainCount = 1
[obj release];                            // retainCount = 0,对象被销毁

黄金法则:谁创建(alloc/new/copy/mutableCopy),谁释放(release)。

1.2 ARC 时代(Automatic Reference Counting)

iOS 5 引入 ARC 后,编译器自动在适当位置插入 retain/release 代码:

// ARC 时代 - 编译器自动管理
func createObject() {
    let obj = MyClass()  // 编译器插入 retain
    // 使用 obj
}  // 函数结束,编译器插入 release

⚠️ 重要提示:ARC 不是垃圾回收(GC),它是编译时特性,不会带来运行时开销。


二、引用计数的底层实现

2.1 isa 指针与 SideTable

在 64 位系统中,苹果对 isa 指针进行了优化,采用了 Non-pointer isa 结构:

┌─────────────────────────────────────────────────────────────────┐
                        isa 指针结构(64位)                       
├─────────────────────────────────────────────────────────────────┤
 0       indexed       0: 纯指针  1: 优化的isa              
 1       has_assoc     是否有关联对象                        
 2       has_cxx_dtor  是否有C++析构函数                     
 3-35    shiftcls      类指针(33位)                        
 36-41   magic         用于调试                             
 42      weakly_ref    是否有弱引用                          
 43      deallocating  是否正在释放                          
 44      has_sidetable│ 引用计数是否存储在SideTable           
 45-63   extra_rc      额外的引用计数(19位)                 
└─────────────────────────────────────────────────────────────────┘

2.2 SideTable 结构

当引用计数超出 isa 的存储范围时,会使用 SideTable:

struct SideTable {
    spinlock_t slock;           // 自旋锁,保证线程安全
    RefcountMap refcnts;        // 引用计数表(哈希表)
    weak_table_t weak_table;    // 弱引用表
};

系统维护了一个 SideTables 哈希表,通过对象地址快速定位到对应的 SideTable:

// 获取对象的引用计数
static inline RefcountMap::iterator 
getRefcountMap(objc_object *obj) {
    SideTable& table = SideTables()[obj];
    return table.refcnts.find(obj);
}

2.3 retain 和 release 的源码分析

// objc_object::retain() 简化实现
id objc_object::retain() {
    // 1. TaggedPointer 直接返回
    if (isTaggedPointer()) return (id)this;
    
    // 2. 尝试在 isa 的 extra_rc 中增加引用计数
    if (fastpath(!ISA()->hasCustomRR())) {
        if (fastpath(bits.extra_rc++ < RC_HALF)) {
            return (id)this;
        }
    }
    
    // 3. extra_rc 溢出,转移到 SideTable
    return sidetable_retain();
}

三、四种引用类型详解

3.1 Strong(强引用)

class Person {
    var name: String
    var apartment: Apartment?  // 强引用
    
    init(name: String) {
        self.name = name
        print("\(name) is initialized")
    }
    
    deinit {
        print("\(name) is deinitialized")
    }
}

3.2 Weak(弱引用)

弱引用不会增加引用计数,对象释放时自动置为 nil:

class Apartment {
    let unit: String
    weak var tenant: Person?  // 弱引用,避免循环引用
    
    init(unit: String) {
        self.unit = unit
    }
}

弱引用的底层实现

// weak_table_t 结构
struct weak_table_t {
    weak_entry_t *weak_entries;  // 弱引用入口数组
    size_t    num_entries;        // 弱引用数量
    uintptr_t mask;               // 哈希掩码
    uintptr_t max_hash_displacement; // 最大哈希偏移
};

// 当对象被释放时,清理所有弱引用
void weak_clear_no_lock(weak_table_t *weak_table, id referent) {
    weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
    if (entry == nil) return;
    
    // 将所有指向该对象的弱引用置为 nil
    weak_referrer_t *referrers = entry->referrers;
    for (size_t i = 0; i < entry->num_refs; i++) {
        *referrers[i] = nil;
    }
    
    weak_entry_remove(weak_table, entry);
}

3.3 Unowned(无主引用)

class Customer {
    let name: String
    var card: CreditCard?
    
    init(name: String) {
        self.name = name
    }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer  // 无主引用
    
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
}
特性 weak unowned
引用计数 不增加 不增加
对象释放时 自动置 nil 不处理(悬垂指针)
声明类型 Optional Non-optional
性能 略低(需维护weak表) 较高
安全性 安全 需保证生命周期

3.4 闭包中的引用

class HTMLElement {
    let name: String
    let text: String?
    
    // ❌ 循环引用
    lazy var asHTML: () -> String = {
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    // ✅ 使用捕获列表打破循环
    lazy var asHTMLFixed: () -> String = { [weak self] in
        guard let self = self else { return "" }
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    // ✅ 或使用 unowned(确保闭包执行时 self 存在)
    lazy var asHTMLUnowned: () -> String = { [unowned self] in
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
    }
}

四、常见内存问题与解决方案

4.1 循环引用

场景一:Delegate 模式

// ❌ 错误示例
protocol DownloadDelegate: AnyObject {  // 注意这里必须用 AnyObject
    func downloadDidComplete()
}

class DownloadManager {
    var delegate: DownloadDelegate?  // ❌ 强引用导致循环
}

// ✅ 正确示例
class DownloadManager {
    weak var delegate: DownloadDelegate?  // ✅ 弱引用
}

场景二:闭包捕获

class NetworkManager {
    var completionHandler: (() -> Void)?
    
    func fetchData() {
        // ❌ 循环引用
        completionHandler = {
            self.handleData()
        }
        
        // ✅ 解决方案1:weak
        completionHandler = { [weak self] in
            self?.handleData()
        }
        
        // ✅ 解决方案2:在不需要时置空
        defer { completionHandler = nil }
    }
    
    func handleData() {
        print("Handle data")
    }
}

场景三:Timer

class TimerHolder {
    var timer: Timer?
    
    func startTimer() {
        // ❌ Timer 对 target 强引用
        timer = Timer.scheduledTimer(
            timeInterval: 1.0,
            target: self,
            selector: #selector(tick),
            userInfo: nil,
            repeats: true
        )
        
        // ✅ 解决方案:使用 block API
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }
    
    @objc func tick() {
        print("Tick")
    }
    
    deinit {
        timer?.invalidate()
        print("TimerHolder deinit")
    }
}

4.2 内存泄漏检测

使用 Instruments - Leaks

步骤:
1. Xcode -> Product -> Profile (⌘I)
2. 选择 Leaks
3. 运行并操作 App
4. 查看泄漏点和调用栈

使用 Debug Memory Graph

// 在特定点触发内存警告,观察对象是否正确释放
#if DEBUG
extension UIViewController {
    func checkMemoryLeak() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
            if self != nil {
                print("⚠️ 可能存在内存泄漏: \(type(of: self!))")
            }
        }
    }
}
#endif

自定义泄漏检测工具

class LeakDetector {
    static let shared = LeakDetector()
    private var trackedObjects: [ObjectIdentifier: WeakBox<AnyObject>] = [:]
    private let queue = DispatchQueue(label: "com.app.leakdetector")
    
    struct WeakBox<T: AnyObject> {
        weak var value: T?
        let className: String
    }
    
    func track(_ object: AnyObject, file: String = #file, line: Int = #line) {
        let id = ObjectIdentifier(object)
        let className = String(describing: type(of: object))
        
        queue.async {
            self.trackedObjects[id] = WeakBox(value: object, className: className)
            print("📍 Tracking: \(className) at \(file):\(line)")
        }
    }
    
    func checkLeaks() {
        queue.async {
            for (id, box) in self.trackedObjects {
                if box.value != nil {
                    print("⚠️ Potential leak: \(box.className)")
                } else {
                    self.trackedObjects.removeValue(forKey: id)
                }
            }
        }
    }
}

五、Autorelease Pool 深度解析

5.1 工作原理

┌──────────────────────────────────────────────────────────────────┐
│                    Autorelease Pool 结构                          │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐            │
│   │   Page 1    │──>│   Page 2    │──>│   Page 3    │            │
│   │  (4096 B)   │   │  (4096 B)   │   │  (4096 B)   │            │
│   └─────────────┘   └─────────────┘   └─────────────┘            │
│         │                 │                 │                     │
│         ▼                 ▼                 ▼                     │
│   ┌───────────┐     ┌───────────┐     ┌───────────┐              │
│   │  obj1     │     │  obj5     │     │  obj9     │              │
│   │  obj2     │     │  obj6     │     │  obj10    │              │
│   │  obj3     │     │  obj7     │     │  ...      │              │
│   │  obj4     │     │  obj8     │     │           │              │
│   │ SENTINEL  │     │           │     │           │              │
│   └───────────┘     └───────────┘     └───────────┘              │
│                                              ▲                    │
│                                              │                    │
│                                           hotPage                 │
│                                          (当前页)                  │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

5.2 源码分析

class AutoreleasePoolPage {
    static size_t const SIZE = PAGE_MAX_SIZE;  // 4096 bytes
    static size_t const COUNT = SIZE / sizeof(id);
    
    magic_t const magic;
    id *next;                    // 下一个可存放对象的位置
    pthread_t const thread;      // 所属线程
    AutoreleasePoolPage *parent; // 父节点
    AutoreleasePoolPage *child;  // 子节点
    uint32_t depth;              // 深度
    
    // 添加对象到 pool
    static inline id *autoreleaseFast(id obj) {
        AutoreleasePoolPage *page = hotPage();
        if (page && !page->full()) {
            return page->add(obj);
        }
        return autoreleaseFullPage(obj, page);
    }
    
    // Pool 的 pop 操作
    static inline void pop(void *token) {
        AutoreleasePoolPage *page = pageForPointer(token);
        id *stop = (id *)token;
        
        // 释放对象
        page->releaseUntil(stop);
        
        // 删除空页
        if (page->child) {
            page->child->kill();
            page->child = nil;
        }
    }
};

5.3 主线程 RunLoop 与 Autorelease Pool

┌──────────────────────────────────────────────────────────────────┐
                    RunLoop  AutoreleasePool                     
├──────────────────────────────────────────────────────────────────┤
                                                                   
   ┌─────────────────────────────────────────────────────────┐    
                        Main RunLoop                             
   └─────────────────────────────────────────────────────────┘    
                                                                  
        ┌─────────────────────┼─────────────────────┐             
                                                               
   ┌─────────┐          ┌─────────┐          ┌─────────┐         
     Entry             Before              Exit            
    (Push)             Waiting             (Pop)           
    Order:             (Pop +             Order:           
     高优先              Push)              低优先           
   └─────────┘          └─────────┘          └─────────┘         
                                                                   
   时机说明:                                                       
   1. kCFRunLoopEntry: 创建 AutoreleasePool (push)                
   2. kCFRunLoopBeforeWaiting: 释放旧pool (pop),创建新pool (push) 
   3. kCFRunLoopExit: 释放 AutoreleasePool (pop)                  
                                                                   
└──────────────────────────────────────────────────────────────────┘

5.4 手动使用 Autorelease Pool

// 场景:大量临时对象的循环
func processLargeData() {
    for i in 0..<100000 {
        // ❌ 不使用 autoreleasepool,临时对象会累积
        let data = createTemporaryData(index: i)
        process(data)
    }
    
    for i in 0..<100000 {
        // ✅ 使用 autoreleasepool,每次迭代后释放临时对象
        autoreleasepool {
            let data = createTemporaryData(index: i)
            process(data)
        }
    }
    
    // ✅ 更优化的方案:批量处理
    let batchSize = 1000
    for batch in stride(from: 0, to: 100000, by: batchSize) {
        autoreleasepool {
            for i in batch..<min(batch + batchSize, 100000) {
                let data = createTemporaryData(index: i)
                process(data)
            }
        }
    }
}

六、Tagged Pointer 优化

6.1 什么是 Tagged Pointer

对于小对象(如小的 NSNumber、NSDate),苹果使用 Tagged Pointer 直接在指针中存储数据:

┌──────────────────────────────────────────────────────────────────┐
│                    Tagged Pointer 结构                            │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│   普通对象指针:                                                   │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │         64位地址指向堆中的对象                            │    │
│   └─────────────────────────────────────────────────────────┘    │
│                              │                                    │
│                              ▼                                    │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │                    堆中的对象                             │    │
│   │  ┌──────┬──────────┬──────────┬─────────────────────┐   │    │
│   │  │ isa  │ refCount │ 其他信息  │      实际数据       │   │    │
│   │  └──────┴──────────┴──────────┴─────────────────────┘   │    │
│   └─────────────────────────────────────────────────────────┘    │
│                                                                   │
│   Tagged Pointer:                                                │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │ 1 │ 类型标记(3位) │           数据值(60位)              │    │
│   └─────────────────────────────────────────────────────────┘    │
│     ↑                                                             │
│   标记位(表明这是Tagged Pointer)                                   │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

6.2 判断 Tagged Pointer

// 通过内存地址判断(仅供理解,实际开发中不需要关心)
func isTaggedPointer(_ obj: AnyObject) -> Bool {
    let pointer = Unmanaged.passUnretained(obj).toOpaque()
    let value = UInt(bitPattern: pointer)
    
    // 在 arm64 上,最高位为 1 表示 Tagged Pointer
    // 在 x86_64 上,最低位为 1 表示 Tagged Pointer
    #if arch(arm64)
    return (value >> 63) == 1
    #else
    return (value & 1) == 1
    #endif
}

6.3 性能优势

// Tagged Pointer 的优势演示
func performanceTest() {
    let iterations = 1_000_000
    
    // 小数字 - 使用 Tagged Pointer
    let start1 = CFAbsoluteTimeGetCurrent()
    for _ in 0..<iterations {
        let num = NSNumber(value: 42)  // Tagged Pointer
        _ = num.intValue
    }
    let time1 = CFAbsoluteTimeGetCurrent() - start1
    
    // 大数字 - 使用普通对象
    let start2 = CFAbsoluteTimeGetCurrent()
    for _ in 0..<iterations {
        let num = NSNumber(value: Int64.max)  // 普通对象
        _ = num.int64Value
    }
    let time2 = CFAbsoluteTimeGetCurrent() - start2
    
    print("Tagged Pointer: \(time1)s")  // 明显更快
    print("普通对象: \(time2)s")
}

七、实战:内存优化最佳实践

7.1 图片内存优化

class ImageLoader {
    // 使用 NSCache 自动管理内存
    private let cache = NSCache<NSString, UIImage>()
    
    init() {
        // 设置缓存限制
        cache.countLimit = 100
        cache.totalCostLimit = 50 * 1024 * 1024  // 50MB
        
        // 监听内存警告
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleMemoryWarning),
            name: UIApplication.didReceiveMemoryWarningNotification,
            object: nil
        )
    }
    
    // 下采样加载大图
    func loadDownsampledImage(at url: URL, targetSize: CGSize) -> UIImage? {
        let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        
        guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else {
            return nil
        }
        
        let maxDimension = max(targetSize.width, targetSize.height) * UIScreen.main.scale
        let downsampledOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize: maxDimension
        ] as CFDictionary
        
        guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampledOptions) else {
            return nil
        }
        
        return UIImage(cgImage: downsampledImage)
    }
    
    @objc private func handleMemoryWarning() {
        cache.removeAllObjects()
    }
}

7.2 大数据处理

class DataProcessor {
    // 分批处理大数组,避免内存峰值
    func processBatched<T>(_ array: [T], batchSize: Int = 1000, handler: ([T]) -> Void) {
        let totalCount = array.count
        var processedCount = 0
        
        while processedCount < totalCount {
            autoreleasepool {
                let endIndex = min(processedCount + batchSize, totalCount)
                let batch = Array(array[processedCount..<endIndex])
                handler(batch)
                processedCount = endIndex
            }
        }
    }
    
    // 使用流式读取大文件
    func processLargeFile(at url: URL, lineHandler: (String) -> Void) {
        guard let fileHandle = try? FileHandle(forReadingFrom: url) else { return }
        defer { try? fileHandle.close() }
        
        let bufferSize = 4096
        var buffer = Data()
        
        while autoreleasepool(invoking: {
            guard let chunk = try? fileHandle.read(upToCount: bufferSize), !chunk.isEmpty else {
                return false
            }
            
            buffer.append(chunk)
            
            while let range = buffer.range(of: Data("\n".utf8)) {
                let lineData = buffer.subdata(in: 0..<range.lowerBound)
                if let line = String(data: lineData, encoding: .utf8) {
                    lineHandler(line)
                }
                buffer.removeSubrange(0..<range.upperBound)
            }
            
            return true
        }) {}
        
        // 处理最后一行
        if let lastLine = String(data: buffer, encoding: .utf8), !lastLine.isEmpty {
            lineHandler(lastLine)
        }
    }
}

7.3 ViewController 内存管理

class BaseViewController: UIViewController {
    // 所有需要取消的任务
    private var cancellables = Set<AnyCancellable>()
    private var tasks = [Task<Void, Never>]()
    
    deinit {
        // 取消所有订阅
        cancellables.removeAll()
        
        // 取消所有 Task
        tasks.forEach { $0.cancel() }
        
        print("\(type(of: self)) deinit")
    }
    
    // 安全地添加通知观察者
    func observe(_ name: Notification.Name, handler: @escaping (Notification) -> Void) {
        NotificationCenter.default.publisher(for: name)
            .sink { [weak self] notification in
                guard self != nil else { return }
                handler(notification)
            }
            .store(in: &cancellables)
    }
    
    // 安全地执行异步任务
    func performTask(_ operation: @escaping () async -> Void) {
        let task = Task { [weak self] in
            guard self != nil else { return }
            await operation()
        }
        tasks.append(task)
    }
}

八、调试技巧

8.1 LLDB 命令

# 查看对象引用计数
(lldb) p CFGetRetainCount(obj as CFTypeRef)

# 查看对象的弱引用
(lldb) p _objc_rootRetainCount(obj)

# 查看所有内存分配
(lldb) memory history <address>

# 查看 Autorelease Pool 中的对象
(lldb) po [NSAutoreleasePool showPools]

# 查看对象的 isa 信息
(lldb) p/x (uintptr_t)object_getClass(obj)

8.2 环境变量

在 Scheme 的 Environment Variables 中添加:

MallocStackLogging = 1          # 记录内存分配堆栈
MallocStackLoggingNoCompact = 1 # 不压缩堆栈信息
OBJC_DEBUG_POOL_ALLOCATION = YES # 调试 Autorelease Pool
NSZombieEnabled = YES           # 检测野指针

8.3 自定义内存追踪

#if DEBUG
class MemoryTracker {
    static let shared = MemoryTracker()
    
    private var allocations: [String: Int] = [:]
    private let queue = DispatchQueue(label: "memory.tracker")
    
    func trackAlloc(_ className: String) {
        queue.async {
            self.allocations[className, default: 0] += 1
        }
    }
    
    func trackDealloc(_ className: String) {
        queue.async {
            self.allocations[className, default: 0] -= 1
        }
    }
    
    func report() {
        queue.async {
            print("=== Memory Report ===")
            for (className, count) in self.allocations where count > 0 {
                print("\(className): \(count) instances")
            }
            print("====================")
        }
    }
}

// 使用方式
class TrackedObject {
    init() {
        MemoryTracker.shared.trackAlloc(String(describing: Self.self))
    }
    
    deinit {
        MemoryTracker.shared.trackDealloc(String(describing: Self.self))
    }
}
#endif

总结

iOS 内存管理是一个深度话题,本文从以下几个方面进行了详细解析:

  1. 引用计数原理:从 MRC 到 ARC 的演进,以及底层 SideTable 的实现
  2. 四种引用类型:strong、weak、unowned 的区别和适用场景
  3. 循环引用:常见场景和解决方案
  4. Autorelease Pool:工作原理和使用时机
  5. Tagged Pointer:小对象优化机制
  6. 实战优化:图片处理、大数据处理等场景的最佳实践
  7. 调试技巧:常用的调试命令和工具

参考资料

Swift UI数据存储

一. @StateObject 数据存储机制

@StateObject 保存的数据存储在设备内存(RAM)中,是临时存储

import SwiftUI
internal import Combine
class BloodGlucoseStore: ObservableObject{
    @Published var count = 0 // 存储在内存中
    
}

struct JLHomeView: View {
    @StateObject private var store = BloodGlucoseStore()// 对象存在于内存中
    var body: some View {
        Text("记录数量:\(store.count)")
        Button("点击") {
            store.count += 1
        }
        
    }
}

数据生命周期

  • 创建时机:视图第一次被创建时
  • 保持时机:视图重新渲染时数据保持不变
  • 销毁时机:视图被销毁时数据丢失
struct ContentView: View {
    @State private var showHomeView = false
    
    var body: some View {
        VStack {
            Button("显示/隐藏 HomeView") {
                showHomeView.toggle()
            }
            
            if showHomeView {
                JLHomeView()  // 创建时:数据在内存中创建
            }              // 销毁时:数据从内存中清除
        }
    }
}

二. UserDefaults 存储机制

存储位置

  • 📁 应用沙盒中的 .plist 文件
  • 路径:/Library/Preferences/[Bundle-ID].plist

UserDefaults 数据安全性

✅ 不会丢失的情况
  • 应用更新:数据保持不变
  • 应用重启:数据依然存在
  • 设备重启:数据保持不变
  • iOS 系统更新:数据通常保持
❌ 会丢失的情况
  • 卸载应用:整个应用沙盒被删除
  • 恢复设备但不恢复备份:数据丢失
  • 手动清除应用数据:通过系统设置清除
class SettingStore: ObservableObject{
    
    @Published var isDarmMode: Bool{
        didSet{
            /// 保存数据
            UserDefaults.standard.set(isDarmMode, forKey: "isDarmMode")
            UserDefaults.standard.synchronize()
        }
    }
    
    init(){
        /// 读数数据
        isDarmMode = UserDefaults.standard.bool(forKey: "isDarmMode")
    }
    deinit{
        /// 删除数据
        UserDefaults.standard.removeObject(forKey: "isDarmMode")
        UserDefaults.standard.synchronize()
    }
}

三. @Published 属性包装器

核心作用

@Published 的主要作用是自动触发 UI 更新

class CounterStore: ObservableObject {
    var count = 0  // 普通属性
    
    func increment() {
        count += 1  // UI 不会更新!
    }
}

实际应用示例

class BloodGlucoseStore: ObservableObject {
    @Published var records: [BloodGlucoseRecord] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var selectedDate = Date()
    @Published var filterType: FilterType = .all
    
    // 计算属性也会响应 @Published 属性的变化
    var filteredRecords: [BloodGlucoseRecord] {
        switch filterType {
        case .all:
            return records
        case .today:
            return records.filter { Calendar.current.isDateInToday($0.date) }
        case .thisWeek:
            return records.filter { $0.date.isInCurrentWeek }
        }
    }
    
    func addRecord(_ record: BloodGlucoseRecord) {
        records.append(record)  // 触发 UI 更新
    }
    
    func setFilter(_ filter: FilterType) {
        filterType = filter  // 触发筛选更新
    }
}

高级用法

自定义 setter
class UserStore: ObservableObject {
    @Published var username: String = "" {
        didSet {
            validateUsername()
            saveToUserDefaults()
        }
    }
    
    @Published var isUsernameValid = false
    
    private func validateUsername() {
        isUsernameValid = username.count >= 3
    }
}

级联更新
class ShoppingCartStore: ObservableObject {
    @Published var items: [CartItem] = [] {
        didSet {
            updateTotalPrice()  // items 变化时自动更新总价
        }
    }
    
    @Published var totalPrice: Double = 0
    @Published var discountCode: String = "" {
        didSet {
            updateTotalPrice()  // 折扣码变化时也更新总价
        }
    }
    
    private func updateTotalPrice() {
        let subtotal = items.reduce(0) { $0 + $1.price * Double($1.quantity) }
        let discount = calculateDiscount(for: discountCode)
        totalPrice = subtotal - discount
    }
}

这 5 个冷门的 HTML 标签,能让你少写 100 行 JS

image.png

大家好!😁。

Code Review 的时候,我最怕看到什么?

不是复杂的算法,也不是什么正则。而是明明一个 HTML 标签就能搞定的事,有人非要写几百行 JS + CSS 去重新发明轮子

前几天,我看到一个新同学为了写一个折叠面板(Accordion),引入了一个重型的第三方库,还写了一堆 useStateonClick 和动画逻辑。

我默默地把他的代码全删了,换成了 3 行 <details>。他看我的眼神,仿佛在看一个外星人🤣。

在 2025 年的今天,浏览器原生 HTML 的能力早已今非昔比。很多我们习惯用 JS 去模拟的交互,现在不仅有原生支持,而且性能更好、兼容性更强、无障碍(a11y)更完善

今天,我就来盘点 5 个被严重低估的HTML标签👇。


<details> & <summary>:折叠组件

你是不是还在写这样的 React 代码?

// JS 模拟版
const [isOpen, setIsOpen] = useState(false);
return (
  <div className="accordion">
    <div className="header" onClick={() => setIsOpen(!isOpen)}>
      点击展开 {isOpen ? '⬆️' : '⬇️'}
    </div>
    {isOpen && <div className="content">...</div>}
  </div>
);

为了这个功能,你还得写 CSS 动画,还得处理键盘事件(Tab 键能不能选到?回车能不能展开?等等)。

HTML 原生写法:

<details>
  <summary>点击展开</summary>
  <div class="content">
    这里是展开后的内容,原生支持 Ctrl+F 页内搜索!
  </div>
</details>
  1. 没有任何JS:自带点击展开/收起交互。
  2. 无障碍(a11y)满分:屏幕阅读器能完美识别,Tab 键、回车键原生支持。
  3. 页内搜索:这是 JS 模拟版最大的痛点。如果内容被 JS 隐藏了(display: none),浏览器的 Ctrl+F 往往搜不到。但 <details> 里的内容,即使折叠,浏览器也能搜到并自动展开!

Untitled ‑ Made with FlexClip.gif

配合 CSS 👇

details {
  border: 1px solid #ccc;
  border-radius: 6px;
  padding: 8px;
}

summary {
  cursor: pointer;
  font-weight: bold;
}

/* 包住内容,让它能动画高度 */
details > .content {
  overflow: hidden;
  max-height: 0;
  opacity: 0;
  transition: max-height .45s ease, opacity .3s ease;
}

/* details 处于 open 状态时 */
details[open] > .content {
  max-height: 200px; /* 你内容高度大概多少设多少,足够大即可 */
  opacity: 1;
}

依然可以做动画。


<dialog>:弹窗组件

写模态框(Modal)是前端最大的坑之一。你需要考虑:

  • z-index 层级会不会被遮挡?
  • 点击遮罩层关闭?
  • Focus Trap(焦点锁定) :打开弹窗后,Tab 键不能跑到底层页面去。
  • 按下 Esc 键关闭?

为了解决这些,我们通常会引入 Antd Modal 或者 React Portal。但在轻量级场景下,原生 <dialog> 才是神🫅。

HTML 原生:

<dialog id="myModal">
  <form method="dialog">
    <p>这是一个原生模态框</p>
    <button>关闭(自动)</button>
  </form>
</dialog>

<button onclick="myModal.showModal()">打开弹窗⏏</button>

Untitled ‑ Made with FlexClip.gif

  1. Top Layer(顶层特性) :浏览器会把它渲染在所有 DOM 的最上层,彻底无视父元素的 z-indexoverflow: hidden
  2. ::backdrop 伪元素:直接用 CSS 定制遮罩层样式。
/* 背景遮罩 */
dialog::backdrop {
    background: rgba(0, 0, 0, 0.45);
    backdrop-filter: blur(3px);
    transition: opacity .3s ease;
}
  1. 原生交互:自带 Esc 关闭,自带焦点管理,表单提交自动关闭。

<datalist>:搜索自动补全

当产品经理要求做一个带搜索建议的输入框时,你的第一反应是不是:“快!引入 Select2 或者 Antd AutoComplete!😖

且慢。如果只是简单的建议列表,几 KB 的 JS 库都显得太重了。

HTML 原生版:

<label>选择你喜欢的框架:</label>
<input list="frameworks" />

<datalist id="frameworks">
  <option value="React">
  <option value="Vue">
  <option value="Svelte">
  <option value="Angular">
  <option value="Solid">
</datalist>
  1. 模糊搜索:浏览器原生支持模糊匹配(打 u 会出来 Vue)。
  2. 响应式:在手机上,它会调用系统原生的下拉选择 UI,体验比网页模拟的更顺滑。
  3. 解耦:它只是一个建议列表,用户依然可以输入列表里没有的值(这点和 Select 不同)。

<fieldset> & disabled:一键禁用整个表单

场景:用户点击提交按钮后,为了防止重复提交,我们需要禁用表单里的所有输入框

JS 笨办法:

// 还要一个个去拿 DOM,或者维护一个 loading 状态传给所有组件
inputs.forEach(input => input.disabled = true);
buttons.forEach(btn => btn.disabled = true);

HTML 原生写法:

<form>
  <fieldset disabled id="login-group">
    <legend>登录</legend>
    <input type="text" placeholder="用户名">
    <input type="password" placeholder="密码">
    <button>提交</button>
  </fieldset>
</form>

<script>
  // 一行代码搞定状态切换
  document.getElementById('login-group').disabled = true; 
</script>

clideo_editor_c3a7f45a392f482ea0added4098a5be3.gif

这是一个极好的分组思维。通过给 <fieldset> 设置 disabled,浏览器会自动禁用内部所有的 <input>, <select>, <button>。不用写循环,不用维护复杂的 State。


<input type="file" capture>:H5 调用手机相机

场景:业务需要用户上传一张照片,可以是相册选的,也可以是当场拍的

很多新手的反应是:是不是要接微信 JSSDK?是不是要写个 Bridge 调原生 App 能力?

答案是不需要!

HTML 原生:

<input type="file" capture="environment" accept="image/*">

只要加上 capture 属性,在移动端(iOS/Android)点击上传时,系统会直接拉起相机,而不是让你去选文件。拍完照后,你拿到的就是一个标准的 File 对象。

不需要什么 JS SDK,实现原生级体验👍。


我总是强调 最好的代码,是没有代码!

HTML 标准一直在进化,很多曾经需要重型 JS 才能实现的功能,现在已经成了浏览器的出厂设置了。

使用这些原生标签,不仅能减少打包体积,更能让你的应用在可访问性性能上天然领先。

下次再想 npm install 一个 UI 库之前,先查查 MDN。说不定,HTML 早就帮你做好了🤔。

❌