阅读视图

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

每日一题-矩阵中和能被 K 整除的路径🔴

给你一个下标从 0 开始的 m x n 整数矩阵 grid 和一个整数 k 。你从起点 (0, 0) 出发,每一步只能往  或者往  ,你想要到达终点 (m - 1, n - 1) 。

请你返回路径和能被 k 整除的路径数目,由于答案可能很大,返回答案对 109 + 7 取余 的结果。

 

示例 1:

输入:grid = [[5,2,4],[3,0,5],[0,7,2]], k = 3
输出:2
解释:有两条路径满足路径上元素的和能被 k 整除。
第一条路径为上图中用红色标注的路径,和为 5 + 2 + 4 + 5 + 2 = 18 ,能被 3 整除。
第二条路径为上图中用蓝色标注的路径,和为 5 + 3 + 0 + 5 + 2 = 15 ,能被 3 整除。

示例 2:

输入:grid = [[0,0]], k = 5
输出:1
解释:红色标注的路径和为 0 + 0 = 0 ,能被 5 整除。

示例 3:

输入:grid = [[7,3,4,9],[2,3,6,2],[2,3,7,0]], k = 1
输出:10
解释:每个数字都能被 1 整除,所以每一条路径的和都能被 k 整除。

 

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 5 * 104
  • 1 <= m * n <= 5 * 104
  • 0 <= grid[i][j] <= 100
  • 1 <= k <= 50

SwiftUI 手势冲突:修复 Navigation 返回手势

欢迎大家给我点个 star!Github: RickeyBoy

问题背景

在开发过程中遇到一个体验上的冲突问题,当用户在使用可横向翻页的视图(如 TabView 的 page 样式)时,第一页无法从屏幕边缘滑动返回上一页。返回手势总是被 TabView 的手势拦截,具体表现可以看下面这个 gif 图:

failure.gif

原因分析

为什么会这样?

  1. 手势竞争问题:
- Navigation Controller:提供边缘滑动返回手势
- TabView:拥有用于页面切换的横向拖动手势

2. 优先级冲突:

- 两个手势都识别横向滑动
- TabView 的手势先捕获触摸
- Navigation 手势永远没有机会响应

SwiftUI 的局限性

SwiftUI 没有内置的方式来协调这些手势,解决冲突,所以我们必须深入到 UIKit,自行解决冲突。

如何解决

关键点:在第一页时,我们需要两个手势同时激活,但响应不同的方向:

  • 向右滑动(从左边缘) → Navigation 返回手势
  • 向左滑动 → TabView 翻页

当然,这个要实现上述的逻辑,需要通过 UIKit 来进行手势冲突的逻辑处理。

解决方案

完整实现:NavigationSwipeBackModifier.swift

步骤 1:识别手势

获取到互相冲突的两个手势:

  • Navigation Gesture:位于 UINavigationController.interactivePopGestureRecognizer
  • Content Gesture:位于可滚动内容上(如 UIScrollView.panGestureRecognizer)
.introspect(.viewController, on: .iOS(.v16, .v17, .v18)) { viewController in
    guard let navigationController = viewController.navigationController,
          let interactivePopGesture = navigationController.interactivePopGestureRecognizer else {
        return
    }
    coordinator.configure(with: interactivePopGesture)
}
.introspect(.scrollView, on: .iOS(.v16, .v17, .v18)) { scrollView in
    coordinator.conflictingGesture = scrollView.panGestureRecognizer
}

步骤 2:创建 Coordinator

构建一个实现 UIGestureRecognizerDelegate 的 Coordinator,他的职责如下:

  • 存储两个手势
  • 通过 Delegate 回调管理它们的交互
  • 处理生命周期(设置和清理)
public final class NavigationSwipeBackCoordinator: NSObject, UIGestureRecognizerDelegate {
    /// Closure that determines whether swipe-back should be enabled
    public var shouldEnableSwipeBack: (() -> Bool)?

    /// The conflicting gesture that should work simultaneously
    public weak var conflictingGesture: UIPanGestureRecognizer?

    private weak var interactivePopGesture: UIGestureRecognizer?
    private weak var originalDelegate: UIGestureRecognizerDelegate?

    public func configure(with gesture: UIGestureRecognizer) {
        guard interactivePopGesture == nil else { return }
        interactivePopGesture = gesture
        originalDelegate = gesture.delegate
        gesture.delegate = self
    }
    // ... cleanup and delegate methods
}

步骤 3:启用同时识别 RecognizeSimultaneously

实现 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)

  • 当两个手势需要同时工作时返回 true
  • 允许两者检测触摸而不会互相拦截
public func gestureRecognizer(
    _: UIGestureRecognizer,
    shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
    // Only allow simultaneous recognition with the conflicting gesture we're managing
    return otherGestureRecognizer == conflictingGesture
}

步骤 4:添加条件逻辑

实现 gestureRecognizerShouldBegin(_:)

  • 检查当前状态(例如检查是否位于第一页)
  • 只在适当的时候允许 Navigation 手势
  • 在用户应该滚动内容时阻止返回手势
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer else {
        return true
    }

    // Check swipe direction
    let translation = panGesture.translation(in: panGesture.view)
    let velocity = panGesture.velocity(in: panGesture.view)
    let isSwipingRight = translation.x > 0 || velocity.x > 0

    // Only allow back gesture for right swipes
    guard isSwipingRight else { return false }

    // Check app-specific condition (e.g., "am I on the first page?")
    return shouldEnableSwipeBack?() ?? false
}

步骤 5:管理生命周期

  • 设置:保存原始状态,安装自定义 Delegate
  • 清理:恢复原始状态以避免副作用
public func cleanup() {
    interactivePopGesture?.delegate = originalDelegate
    interactivePopGesture = nil
    originalDelegate = nil
    shouldEnableSwipeBack = nil
    conflictingGesture = nil
}

步骤 6:封装为 SwiftUI Modifier

创建可复用的 ViewModifier:

  • 封装所有 UIKit 复杂性
  • 提供简洁的 SwiftUI API
  • 响应式更新状态
public extension View {
    func enableNavigationSwipeBack(when condition: @escaping () -> Bool) -> some View {
        modifier(NavigationSwipeBackModifier(shouldEnable: condition))
    }
}
// Usage
.enableNavigationSwipeBack(when: { selectedIndex == 0 })

实现模式

  ┌─────────────────────────────────────┐
  │   SwiftUI View                      │
  │   .enableSwipeBack(when: condition) │
  └────────────┬────────────────────────┘
               │
               ▼
  ┌─────────────────────────────────────┐
  │   ViewModifier                      │
  │   - Manages lifecycle               │
  │   - Updates condition reactively    │
  └────────────┬────────────────────────┘
               │
               ▼
  ┌─────────────────────────────────────┐
  │   Gesture Coordinator               │
  │   - Implements delegate callbacks   │
  │   - Coordinates both gestures       │
  │   - Stores original state           │
  └─────────────────────────────────────┘

使用方法

在任何会阻止 Navigation 返回手势的横向滑动视图上,应用 enableNavigationSwipeBack modifier。

基本语法

.enableNavigationSwipeBack(when: { condition })

when 闭包用于判断何时应该启用返回手势。它在手势开始时实时计算,确保能响应最新的状态。

示例:分页 TabView

TabView(selection: $selection) {
    ForEach(items) { item in
        ItemView(item: item)
    }
}
.tabViewStyle(.page(indexDisplayMode: .never))
.enableNavigationSwipeBack(when: { selectedItemIndex == 0 })

注意:此方案需要 SwiftUIIntrospect 库来访问底层 UIKit 视图。

效果

当用户位于第一页时,自动允许边缘滑动返回手势

success.gif

今年广州车展,主角不是车企

上周五广州车展媒体日结束后的回家路上,我刷到了一条小红书帖子。

一位当天在展台担任接待的女孩发问:「做『小蜜蜂』是不是比站展台轻松一点?」

我一时间没明白「小蜜蜂」是什么,搜索了一圈才明白,「小蜜蜂」指的是那些在展馆里举着牌子、派发礼品,为自家品牌展台引流的兼职人员。

我把帖子转给朋友,他回了一句:「这么说,去年雷军、余承东这些车企老总,才是最大的『小蜜蜂』。」

确实如此。去年广州车展首日,以雷军、余承东为代表的车圈大佬,四处串场,所到之处被围的水泄不通。

▲ 24 年广州车展,雷军和苏炳添在乐道展台

而今年,场面明显安静许多。蔚来李斌、小鹏何小鹏、长城魏建军等少数几位高管现身,也未带来重磅新车发布。

「但更好逛了,追星和领礼品的人少了,大家的关注点回到了车本身,浮躁感减轻了不少。」一位车圈同行评价道。

▲ 观众们在等待比亚迪发布会开始

车展的热度正在悄然退潮

这不仅是观众流量或布展规模的变化,更是汽车行业从高增长迈入存量竞争的缩影。

超豪华品牌销量跌至两位数已不再引发关注;曾经热衷造势的新能源车企,在同质化加剧与监管趋严的双重压力下,也开始将重心重新放回产品与销量。

但产品力的定义权,很大一部分却早已不再掌握在整车厂自己手中。

主角变了

如果要用一个展台看懂本届广州车展的变化,那一定是华为乾崑智驾。

这里聚集了奥迪、长安、方程豹、传祺等近 20 款合作车型,密密匝匝环绕两圈,几乎撑满整个展位。据统计,本届车展 13 个乘用车展馆中,有 10 个出现了搭载乾崑智驾的车型——无论走到哪个馆,都很难错过。

若再留意其他展台和发布会,则会发现,无论是日产、丰田、宝马、奔驰等合资品牌,还是奇瑞、昊铂等自主品牌,发布会和销售话术中都在反复出现几个关键词,「华为」「宁德时代」「Momenta」。

这就是在本届广州车展上正在发生的变化——

主机厂的名字正悄然退居幕后,而这些技术供应商的名字,却成为产品力的核心背书,频繁出现在主屏幕、宣传材料乃至用户口碑中。

▲ 东风日产和宁德时代展台

甚至更进一步,一场发布会的主角不再是整车,而是一款大模型。

在广州车展首日,上汽荣威便以「豆包深度思考大模型及场景」为主题召开专场活动,宣布该技术将首发搭载于旗下中大型长续航轿车荣威 M7 DMH。

当一项能力足够差异化,它本身就成了值得单独亮相的产品。因此技术供应商的名字,正前所未有地闪耀在聚光灯下。

回望燃油车时代,汽车产业的话语权长期被博世、大陆、电装等传统 Tier 1 巨头牢牢掌控。整车厂纵然拥有品牌号召力与制造体系,却不得不依赖这些供应商提供的封闭式「黑盒」方案,在关键技术上缺乏自主空间。

当电动化浪潮初起,人们曾期待,软件定义汽车、芯片直采、垂直整合似乎预示着一个去中心化的未来,整车厂将重掌技术主权,产业链权力结构或将彻底重构。

然而现实走向了另一种路径。新能源汽车并未终结 Tier 1 主导的局面,旧的巨头逐渐淡出,但新的主导者接续登场。

以华为、宁德时代、Momenta 为代表的科技型 Tier 1,凭借在智能驾驶、三电系统、智能座舱等关键领域的全栈能力与生态整合力,迅速建立起堪比甚至超越传统 Tier 1 的产业影响力。

它们不再只是零部件供应商,而是体验的定义者、标准的制定者和生态的绑定者。

典型如华为,其「不造车」战略反而加速了技术平台的开放与复用。

截至 2025 年 10 月底,华为乾崑智驾已与 14 家车企达成合作,覆盖 33 款量产车型,价格带横跨 15 万元至百万元级;国内 TOP10 车企集团中,仅剩吉利、长城尚未加入。在广州车展前夕的乾崑生态大会上,华为与东风、广汽联合打造的「奕境」「启境」正式亮相,加上此前与上汽通用五菱合作的「华境」,其「五界三境」双线布局已初具规模。

同样,Momenta 凭借可扩展的自动驾驶方案,成为跨国车企智能化转型的关键支点。

从德系 BBA、日系丰田/日产/本田,到美系别克、凯迪拉克,多家百年车企选择其作为智能驾驶合作伙伴,一度被业内称为「合资车救星」。

这种广泛采纳的背后,是 Momenta 将复杂感知-决策-控制链路封装为标准化、模块化的软件栈,使不同开发节奏与技术储备的主机厂都能快速集成前沿能力。

地平线则凭借征程系列车载计算平台和 HSD 城区辅助驾驶系统,构建了一套覆盖低、中、高全阶智能辅助驾驶的量产解决方案,目前,征程芯片累计出货量已突破 1000 万套,成为国内首家实现千万级交付的智驾科技企业。

 

新能源不再拥有特权

而这种由新 Tier 1 主导的技术供给模式,推动了智能化能力的快速下放。

通过规模化部署与标准化接口,它们大幅降低了技术集成的门槛与成本,将原本高不可攀的技术功能转化为「即插即用」的解决方案。使得不同定位、不同规模乃至不同动力形式的整车厂都能以较低代价快速引入前沿功能。

曾几何时,高速 NOA、自动泊车、大模型语音交互和 OTA 远程升级等智能化功能,还是 30 万元以上高端新能源车型的专属标签。如今,随着激光雷达、城市智驾方案及 AI 驱动的智能座舱被科技型 Tier 1 以模块化、平台化的方式打包输出,这些能力正迅速从「高配选装」转变为「入门标配」。

广州车展上的诸多新车,如星途 ET5、深蓝 L06、iCAR V27 等都是借由地平线 HSD 与征程 6P 芯片的能力,快速实现了全场景类人的智能辅助驾驶功能。

▲ 采用地平线方案的深蓝 L06和星途 ET5

而入门车型长安启源 Q05 与零跑 A10 两款新车甚至将配备激光雷达的车型售价压低至了 10 万元以内,反映出智能化配置正从高端溢价项转变为大众车型的基础竞争力。

以大众、日产、奇瑞为首的传统车企们,在展台上不再回避「智能」标签,反而主动将其融入燃油车的产品叙事。它们在发布会上频频强调 L2 级辅助驾驶、数字座舱、车联网服务等配置,这些曾被视为新能源专属的卖点,如今已成为燃油车宣传的常规内容。

最具代表性的案例来自东风日产。其在广州车展推出的天籁·鸿蒙座舱版,起售价仅 13 万元,却搭载了 15.6 英寸 HUAWEI 车载智慧屏、鸿蒙座舱 5.0 系统,并支持跨设备无缝流转、AI 语音助手、无麦 K 歌等智能功能。

分化与洗牌

未来的整车企业,正加速分化为两类截然不同的发展路径。

一类是极少数「全能型选手」。如特斯拉、比亚迪、小米、蔚来,坚它们选择了一条行业中最艰难、但也最具长期确定性的道路。

它们不仅在芯片、算法、电池、座舱系统、操作系统等底层技术上坚持全栈自研,更牢牢掌控从核心零部件到整车制造的完整供应链体系。

「全能型选手」的核心能力,早已超越单一技术模块的突破,而是一套覆盖研发、生产、供应链管理、质量控制乃至软件生态的全局性统筹能力。这种端到端的垂直整合,构成了其难以被复制的竞争壁垒。

另一类则是数量更为庞大的「集成型品牌」。对它们而言,从零构建自研体系既不现实,也非必要。

将智能驾驶、三电系统、数字座舱等核心子系统交由华为、Momenta、宁德时代等新兴 Tier 1 托管,转而聚焦于产品定义、用户体验与市场运营,成为一条更务实且高效的发展路径。

这类企业的真正竞争力,在于能否精准理解用户需求、把握市场节奏,并快速将行业最先进的技术能力转化为用户可感知、可信赖、愿付费的产品体验。

然而,产业链分工的细化绝不意味着造车门槛的降低,恰恰相反,它标志着竞争重心已从前台硬件转向更复杂、更抽象的「体验战场」。

相反,当硬件的门槛被抹平、智能化能力被快速下放后,整车厂所站立的「前台战场」变得比过去任何时候都更难打。

过去,差异化可以通过堆砌传感器、扩大电池容量或罗列参数实现;如今,高速 NOA、大模型语音交互、OTA 升级、甚至激光雷达,都在以惊人的速度从「高端配置」变为「基础标配」。当主流车型普遍搭载相似的智驾方案、接近的智能座舱、乃至共用同一套 Tier 1 技术栈时,「智能化」本身已不再是护城河,而仅仅是一张入场券。

真正的较量,正在转向那些无法被采购、也无法靠一次技术升级解决的能力维度:产品定义的精准度、人机交互的细腻感、品牌心智的塑造力、生态服务的延展性,以及贯穿用户全生命周期的运营深度。

▲ 广汽丰田在车展上推出了「厂家三担责政策」

这些能力没有标准答案,也无法外包,它们依赖于企业对用户行为的深刻洞察、对使用场景的敏锐捕捉、对产品节奏的精准把控,以及组织内部长期积累的协同效率。

智能手机行业已经证明了这一点,并非所有手机厂商都需自研基带芯片,但能活下来的,无一不是在设计、交互、服务或生态上做到了极致且足够有差异化。

今天,汽车行业正站在同样的历史拐点。市场的淘汰机制正在加速运转——大家比拼的,早已不是「能不能造出一辆合格的车」,而是「能否持续提供不断进化的优质体验」。

那些既缺乏底层技术纵深、又不具备用户运营精度的企业,终将在新一轮残酷洗牌中黯然退场。

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

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


Code Review 惊魂:同事的“优雅”重构,差点让管理员全部掉线

差点就酿成了“生产事故”

昨天下午,组里的实习生小李提交了一个 PR,说是把权限管理模块的代码“优化”了一下。

我扫了一眼 Diff,绿油油的一片,看起来确实清爽了不少。代码风格从原本的命令式变成了函数式,逻辑似乎也没啥大问题。手指悬在 "Approve" 按钮上,正准备点下去,突然看到一行对数组的处理,心里咯噔了一下。

赶紧把代码拉到本地跑了一遍——果然,原本正常的权限列表,重构后直接变成了空数组!

如果这行代码真的上线了,明天所有的管理员都会因为没有权限而被拦在后台门外,那我们组这个季度的绩效怕是要集体泡汤。

小李改动的地方很简单,他觉得原来的 push 写法太繁琐,想用 concat 让他变得“优雅”一点。

于是代码从:

// 老代码         
defaultRoles.forEach(role => {
  userPermissions.push(role);
});

变成了:

// 新代码:
userPermissions.concat(defaultRoles);

我把他叫到工位上,指着这行代码问他:“你觉得这两段代码等价吗?”

他一脸茫然:“不都是把数组拼起来吗?concat 不是更符合函数式编程习惯吗?”

看来,是时候聊聊 JS 数组方法里那些容易让人“阴沟里翻船”的返回值陷阱了。

还原一下“案发现场”

原始逻辑(正常工作)

我们的业务逻辑大概是这样的:从后端拉取用户的角色,然后把对应的权限塞到一个数组里。

// 伪代码:将嵌套的权限数组扁平化
// 以前是用 reduce + push 写的,虽然丑点,但能用
export const flatPermissions = allPermissions.reduce((acc, role) => {
    // ... 省略中间处理逻辑
    // 把处理好的权限 push 进累加器
    acc.push(...processedPermissions); 
    return acc;
}, []);

这段代码跑了一年多,稳如老狗。

重构后的代码(引入 Bug)

小李为了追求代码整洁,在重构时把 push 换成了 concat

export const flatPermissions = allPermissions.reduce((acc, role) => {
    // ... 省略中间处理逻辑
    
    // 他以为这行代码会把新权限加到 acc 里
    acc.concat(processedPermissions); 
    
    return acc;
}, []);

看起来逻辑没变,对吧?

但在 JS 引擎眼里,这行代码的意思是:

  1. 拿出 acc 数组。
  2. 创建一个新数组,内容是 acc + processedPermissions
  3. 把这个新数组扔掉(因为没有接收返回值)。
  4. 返回原本的、没有任何变化的 acc

结果就是:flatPermissions 永远是个空数组。

到底哪里出了问题?

我给小李画了张图,解释这俩方法的本质区别。

concat vs push:本质的区别

这俩方法的区别,不仅仅是写法不同,而是设计哲学完全不同。

1. concat:我只产生新东西,不碰旧东西

concat 是**非变异(Non-mutating)**方法。它不会修改调用它的数组,而是返回一个新的。

const arr1 = [1, 2];
const arr2 = [3, 4];

// 错误用法:以为 arr1 变了
arr1.concat(arr2); 
console.log(arr1); // [1, 2] -> 根本没动!

// 正确用法:必须接收返回值
const result = arr1.concat(arr2);
console.log(result); // [1, 2, 3, 4]

2. push:我就改旧东西

push变异(Mutating)方法。它直接修改原数组,返回的是新数组的长度(这个返回值也经常坑人)。

const arr1 = [1, 2];
const arr2 = [3, 4];

// push 修改了 arr1
const length = arr1.push(...arr2); 

console.log(arr1); // [1, 2, 3, 4] -> 变了!
console.log(length); // 4 -> 返回的是长度

内存和引用对比

为了加深印象,咱们看个图:

graph TD
    subgraph Push操作
    A[原数组 arr1] -->|push| A
    A -.->|变大了| A
    end

    subgraph Concat操作
    B[原数组 arr1] -->|concat| C[新数组 result]
    B -.->|保持原样| B
    end
  • Push: 在原数组内存地址上扩容。
  • Concat: 申请新内存,复制旧数据,复制新数据,返回新地址。

怎么修?这有三招

方案一:继续用 push,配合展开运算符

这是性能最好的改法,虽然看起来稍微没那么“函数式”。

export const flatPermissions = allPermissions.reduce((acc, role) => {
    // 使用展开运算符 ... 把新数组打散塞进去
    acc.push(...role.permissions);
    return acc;
}, []);

优点:性能好,内存抖动少。 缺点:修改了 acc(但在 reduce 内部通常是可以接受的)。

方案二:正确使用 concat

如果你坚持要用 concat,记得把返回值接住。

export const flatPermissions = allPermissions.reduce((acc, role) => {
    // 返回新的数组给下一次迭代
    return acc.concat(role.permissions);
}, []);

优点:纯函数式,不修改原数组。 缺点:在循环中频繁创建新数组,可能带来额外的内存开销。

方案三:用 flatMap(推荐)

既然是“映射”+“扁平化”,JS 早就给我们准备好了专用 API。

export const flatPermissions = allPermissions.flatMap(role => role.permissions);

优点:代码最简洁,语义最清晰,专门干这事的。 缺点:老旧浏览器(如 IE)不支持,需要 Polyfill。

避坑指南:还有哪些方法是“只读”的?

concat 只是冰山一角。JS 的数组方法里,修改原数组返回新数组的方法经常让人晕头转向。

我整理了一份清单,建议背下来,或者贴在电脑屏幕旁边:

⚠️ 会修改原数组(Mutating)

  • push() / pop()
  • unshift() / shift()
  • splice() (这个最容易混淆!)
  • sort()
  • reverse()
  • fill()

✅ 只返回新数组(Non-Mutating)

  • concat()
  • slice() (注意是 slice 不是 splice)
  • map() / filter()
  • reduce()
  • toSorted() / toReversed() / toSpliced() (ES2023 新出的“安全版”方法)

写在最后

那天下午,我让小李把代码改了回去,并顺便给他科普了一波数组方法的副作用。

Code Review 的意义其实就在这里:不仅是找 Bug,更是团队技术栈的校准和经验的传承。

对于每一个开发者来说,写代码不能光图“看着顺眼”。在把 push 换成 concat 之前,先问自己两个问题:

  1. 我接收返回值了吗?
  2. 这代码是在循环里吗?

希望这次“险些发生”的生产事故,能给你的 Code Review 清单里增加一项检查点。


相关阅读

React 渲染两次:是 Bug 还是 Feature?聊聊严格模式的“良苦用心”

React 为啥老是渲染两次?——聊聊 Strict Mode 的那些事

看控制台的时候,有没有怀疑人生?

写 React 的时候,你有没有遇到过这种场景:

明明只写了一行 console.log,结果控制台“刷刷”给你印出来两条一模一样的。或者发送网络请求,明明只调用了一次,Network 里却躺着两个请求。

第一反应通常是:“完了,我是不是哪里写出 Bug 了?组件是不是在哪里被意外卸载又挂载了?”

别慌,大概率不是你的锅,而是 React 故意的。

罪魁祸首:Strict Mode

赶紧去你的入口文件(通常是 main.tsxindex.tsx)看一眼,是不是长这样:

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

那个 <React.StrictMode> 就是“幕后黑手”。

它的中文名叫“严格模式”。这玩意儿只在 开发环境(Development) 下生效,到了 生产环境(Production) 就会自动隐身,不会对用户产生任何影响。

为什么要搞这么个“恶作剧”?

React 团队并不是闲着没事干,非要让你的控制台变脏。这么做的核心目的是:帮你揪出不纯的函数和有副作用的代码

在 React 的设计哲学里,组件的渲染过程(Render Phase)应该是 纯粹(Pure) 的。

所谓的“纯”,就是说:

  1. 给定相同的输入(Props 和 State),必须返回相同的输出(JSX)。
  2. 不能改变作用域之外的变量,不能有副作用(Side Effects)。

如果你的组件不纯,比如你在渲染函数里偷偷修改了一个全局变量:

let count = 0;

function BadComponent() {
  count++; // ❌ 这是一个副作用!
  return <div>Count: {count}</div>;
}

在单次渲染下,你可能看不出问题。但如果 React 决定并发渲染、或者为了优化跳过某些渲染,这个全局 count 就会变得不可预测。

为了让你在开发阶段就发现这种隐患,React 采取了最简单粗暴的办法:把你的组件渲染两次

如果你的组件是纯的,渲染一次和渲染两次,对外部世界的影响应该是一样的(零影响),返回的结果也是一致的。但如果你在里面搞了小动作(比如上面的 count++),两次渲染就会导致 count 加了 2,结果就不对劲了,你立马就能发现问题。

具体哪些东西会执行两次?

在严格模式下,React 会特意重复调用以下内容:

  • 函数组件体(Function Component body)
  • useState, useMemo, useReducer 传递的初始化函数
  • 类组件的 constructor, render, shouldComponentUpdate 等生命周期

注意,这仅仅是 “调用” 两次,并不是把你的组件在 DOM 上真的挂载两次。它主要是在内存里跑两遍逻辑,看看有没有奇奇怪怪的副作用发生。

useEffect 的“挂载 -> 卸载 -> 挂载”

除了渲染过程,从 React 18 开始,Strict Mode 还加了一个更狠的检查机制,针对 useEffect

你可能会发现,组件初始化时,useEffect 里的代码也跑了两次。

严格来说,它的执行顺序是这样的:

  1. Mount(挂载) -> 执行 Effect
  2. Unmount(卸载) -> 执行 Cleanup(清除函数)
  3. Remount(挂载) -> 执行 Effect
graph LR
    A[组件挂载] --> B[执行 Effect]
    B --> C{严格模式?}
    C -- 是 --> D[模拟卸载: 执行 Cleanup]
    D --> E[再次挂载: 执行 Effect]
    C -- 否 --> F[结束]

这又是为了啥?

这是为了帮你检查 Cleanup 函数写没写对

很多时候我们写了订阅(subscribe),却忘了取消订阅(unsubscribe);写了 setInterval,却忘了 clearInterval。这种内存泄漏在单次挂载中很难发现,但在页面快速切换时就会爆雷。

通过强制来一次“挂载->卸载->挂载”的演习,React 逼着你必须把 Cleanup 逻辑写好。如果你的 Effect 写得没问题,那么“执行->清除->再执行”的结果,应该和“只执行一次”在逻辑上是闭环的。

比如一个聊天室连接:

  1. connect() (连接)
  2. disconnect() (断开)
  3. connect() (连接)

用户最终还是连接上了,中间的断开重连不应该导致程序崩溃或产生两个连接。

怎么解决?

1. 接受它,不要关掉它

最好的办法是适应它。既然 React 告诉你这里有副作用,那就去修复代码,而不是解决提出问题的人。

  • 把副作用挪到 useEffect 里去,别放在渲染函数体里。
  • 确保 useEffect 有正确的 Cleanup 函数。

2. 使用 useRef 解决数据重复请求

经常有人问:“我的请求在 useEffect 里发了两次,导致服务器存了两条数据,怎么办?”

如果你无法把后端接口改成幂等(Idempotent)的,可以使用 useRef 来标记请求状态:

import { useEffect, useRef } from 'react';

function DataFetcher() {
  const hasFetched = useRef(false);

  useEffect(() => {
    if (hasFetched.current) return; // 如果已经请求过,直接返回

    hasFetched.current = true;
    fetchData();
  }, []);

  return <div>Loading...</div>;
}

不过 React 官方更推荐使用像 React Query (TanStack Query) 或 SWR 这样的库来管理数据请求,它们内部已经处理好了这些去重逻辑。

对于Strict Mode,我的理解是:

原理层面

  • 渲染双倍:为了检测渲染逻辑是否纯粹。
  • Effect 挂载-卸载-挂载:为了检测 Effect 的清除逻辑是否正确。
  • 仅限开发环境:生产环境完全无副作用。

实用层面

  • 它是 React 自带的“代码质量检测员”。
  • 看到日志打印两次不要慌,先想想是不是 Strict Mode 的锅。
  • 千万别在渲染函数里写副作用(比如修改外部变量、直接发请求)。

使用建议

  1. 调试时:如果在排查 Bug,可以留意一下是不是因为两次渲染导致的逻辑错误。
  2. 写 Effect 时:脑子里模拟一下“连上-断开-连上”的过程,看看代码能不能扛得住。
  3. 请求处理:尽量用成熟的请求库(React Query/SWR),或者确保接口幂等。

写在最后

Strict Mode 就像一个严格的健身教练,刚开始你会觉得它很烦,总是挑你的刺,让你做重复动作。但长远来看,它能帮你练就一身“健壮”的代码体格,避免在未来复杂的并发渲染中受内伤。

下次看到控制台的双重日志,别再骂 React 了,那是它在默默守护你的代码质量。

BaseObject 及其子类的完整继承关系 ASCII 树

BaseObject 及其子类的完整继承关系 ASCII 树:

BaseObject
├── AbstractBaseView<A, E>
│   └── DeclarativeBaseView<A, E>
│       ├── ViewContainer<A, E>
│       │   ├── ComposeView<A, E>
│       │   │   ├── Pager
│       │   │   ├── ButtonView
│       │   │   ├── SliderView
│       │   │   ├── SwitchView
│       │   │   ├── CheckBoxView
│       │   │   ├── DatePickerView
│       │   │   ├── ScrollPickerView
│       │   │   └── [其他ComposeView子类...]
│       │   ├── RefreshView
│       │   ├── MaskView
│       │   ├── TransitionView
│       │   ├── HoverView
│       │   ├── ScrollerContentView
│       │   ├── FooterRefreshView
│       │   ├── TabItemView
│       │   ├── LiquidGlassView
│       │   ├── GlassEffectContainerView
│       │   ├── GroupView<A, E>
│       │   ├── LayoutView<A, E>
│       │   ├── ModalView
│       │   └── SafeAreaView
│       ├── TextView
│       ├── ImageView
│       ├── InputView
│       ├── CanvasView
│       ├── ActivityIndicatorView
│       ├── VideoView
│       ├── APNGVView
│       ├── BlurView
│       ├── PAGView
│       ├── RichTextView
│       ├── TextAreaView
│       ├── iOSSlider
│       ├── iOSSegmentedControlView
│       ├── iOSSwitch
│       └── [其他DeclarativeBaseView子类...]
├── BaseEvent
│   ├── Event
│   │   ├── TextEvent
│   │   ├── ImageEvent
│   │   ├── InputEvent
│   │   ├── VideoEvent
│   │   ├── ScrollerEvent
│   │   ├── ListEvent
│   │   ├── ModalEvent
│   │   ├── RefreshEvent
│   │   ├── TransitionEvent
│   │   └── [其他Event子类...]
│   ├── ComposeEvent
│   │   ├── ButtonEvent
│   │   ├── SliderEvent
│   │   ├── SwitchEvent
│   │   ├── CheckBoxEvent
│   │   ├── DatePickerEvent
│   │   └── [其他ComposeEvent子类...]
│   ├── VisibilityEvent
│   └── FrameEvent
├── Props
│   └── Attr
│       ├── ContainerAttr
│       │   ├── ScrollerAttr
│       │   ├── ListAttr
│       │   ├── TabsAttr
│       │   ├── ModalAttr
│       │   ├── SafeAreaAttr
│       │   └── RefreshAttr
│       ├── ComposeAttr
│       │   ├── ButtonAttr
│       │   ├── SliderAttr
│       │   ├── SwitchAttr
│       │   ├── CheckBoxAttr
│       │   ├── DatePickerAttr
│       │   └── [其他ComposeAttr子类...]
│       ├── TextAttr
│       ├── ImageAttr
│       ├── InputAttr
│       ├── VideoAttr
│       ├── ActivityIndicatorAttr
│       ├── APNGAttr
│       ├── BlurAttr
│       ├── PAGViewAttr
│       ├── RichTextAttr
│       ├── TextAreaAttr
│       └── [其他Attr子类...]
├── ListItem (demo)
├── ListItemExample (demo)
├── GoodsData (demo)
├── GlobalData (demo)
├── WaterFallItem (demo)
└── [其他业务数据类...]

这个继承树展示了 KuiklyUI 框架的核心架构:

主要分支说明:

  1. 视图分支 (BaseObjectAbstractBaseViewDeclarativeBaseView)

    • 负责UI组件的显示和交互
    • ViewContainer 支持子视图管理
    • ComposeView 支持声明式UI构建
  2. 事件分支 (BaseObjectBaseEvent)

    • 负责事件处理和分发
    • Event 处理传统视图事件
    • ComposeEvent 处理组合视图事件
  3. 属性分支 (BaseObjectPropsAttr)

    • 负责组件属性管理
    • ContainerAttr 管理容器属性
    • ComposeAttr 管理组合组件属性
  4. 数据分支 (BaseObject → 业务数据类)

    • 各种业务数据模型
    • 主要在 demo 中使用

这种设计实现了清晰的职责分离和良好的扩展性。

你真的懂递归吗?没那么复杂,但也没那么简单

大家好,我是大华。 很多初学者都觉得简单的递归还可以看得懂,稍微复杂些的复杂就觉得很难,甚至有些工作几年的同事也对其避而远之。 其实,只要掌握了正确的方法,递归并没有那么可怕!

一、什么是递归?

打个比方:想象一下,你站在一排长长的队伍里,你想知道你前面有几个人。 但你只能看到你前面那个人,看不到更前面的人。怎么办? 你问前面那个人:“兄弟,你前面有几个人?” 他也不知道,于是他又问更前面的人:“兄弟,你前面有几个人?” 就这样一直往前问…… 直到问到排在最前面的那个人,他说:“我前面没人,是0个。” 然后,这个答案开始往回传:

最前面的人说:“0个” 他后面的人说:“我前面有1个(就是他)” 再后面的人说:“我前面有2个”… 最后传到你这里:“你前面有 N 个” 这个过程,就是递归!

递归的本质就是: 把一个大问题,拆解成相同的小问题,直到遇到最简单的情况(边界),然后从最简单的情况开始,一层层把结果返回回去,最终解决大问题。

二、递归的两大核心要素

任何正确的递归函数,都必须包含两个关键部分:

1. 递归终止条件(Base Case)

这是递归的“刹车”,防止无限循环。

当问题小到不能再拆时,直接返回结果。

没有它,程序就会无限调用自己,最终导致栈的溢出(Stack Overflow)

2. 递归调用(Recursive Case)

函数调用自己,但传入的参数是更小规模的问题。

每次调用都在向终止条件靠近。

三、从经典例子开始:计算阶乘

先看最简单的阶乘:5! = 5 × 4 × 3 × 2 × 1

/**
 * 计算阶乘的递归函数
 * @param {number} n - 要计算阶乘的数字
 * @returns {number} - n的阶乘结果
 */
function factorial(n) {
    // 1. 基准条件:0的阶乘是1,1的阶乘也是1
    if (n === 0 || n === 1) {
        console.log(`到达基准条件:factorial(${n}) = 1`);
        return 1;
    }
    
    // 2. 递归条件:n! = n × (n-1)!
    // 3. 递归调用:问题规模从n变成n-1
    console.log(`计算 factorial(${n}) = ${n} × factorial(${n - 1})`);
    const result = n * factorial(n - 1);
    console.log(`得到结果:factorial(${n}) = ${result}`);
    
    return result;
}

// 测试
console.log("最终结果:5的阶乘 =", factorial(5));

运行结果:

计算 factorial(5) = 5 × factorial(4)
计算 factorial(4) = 4 × factorial(3)
计算 factorial(3) = 3 × factorial(2)
计算 factorial(2) = 2 × factorial(1)
到达基准条件:factorial(1) = 1
得到结果:factorial(2) = 2
得到结果:factorial(3) = 6
得到结果:factorial(4) = 24
得到结果:factorial(5) = 120
最终结果:5的阶乘 = 120

看到这个调用过程,是不是对递归有了直观感受?

四、理解递归的关键:调用栈

要真正理解递归,必须明白调用栈的概念。

调用栈就像叠汉堡:每次函数调用就加一片面包,函数返回就拿走一片。

/**
 * 演示递归调用栈
 */
function understandCallStack() {
    function recursiveDemo(level, maxLevel) {
        // 打印当前栈深度
        const indent = "  ".repeat(level);
        console.log(`${indent}进入第 ${level} 层`);
        
        // 基准条件:达到最大深度时停止
        if (level >= maxLevel) {
            console.log(`${indent}${level} 层:到达基准条件,开始返回`);
            return;
        }
        
        // 递归调用
        recursiveDemo(level + 1, maxLevel);
        
        console.log(`${indent}离开第 ${level} 层`);
    }
    
    console.log("=== 递归调用栈演示 ===");
    recursiveDemo(0, 3);
}

understandCallStack();

运行结果:

=== 递归调用栈演示 ===
进入第 0 层
  进入第 1 层
    进入第 2 层
      进入第 3 层
      第 3 层:到达基准条件,开始返回
    离开第 2 层
  离开第 1 层
离开第 0 层

这就是为什么递归深度太大会"栈溢出"——汉堡叠得太高,倒掉了!

五、实际应用:文件系统遍历

递归在实际开发中非常实用,比如遍历文件夹:

/**
 * 模拟文件系统结构
 */
const fileSystem = {
    name: "根目录",
    type: "folder",
    children: [
        {
            name: "文档",
            type: "folder",
            children: [
                { name: "简历.pdf", type: "file" },
                { name: "报告.docx", type: "file" }
            ]
        },
        {
            name: "图片", 
            type: "folder",
            children: [
                { 
                    name: "旅行照片", 
                    type: "folder", 
                    children: [
                        { name: "海滩.jpg", type: "file" }
                    ]
                },
                { name: "头像.png", type: "file" }
            ]
        },
        { name: "README.txt", type: "file" }
    ]
};

/**
 * 递归遍历文件系统
 * @param {object} node - 当前节点
 * @param {string} indent - 缩进字符串
 */
function traverseFileSystem(node, indent = "") {
    // 基准条件:空节点直接返回
    if (!node) return;
    
    // 打印当前节点
    const icon = node.type === 'folder' ? '📁' : '📄';
    console.log(`${indent}${icon} ${node.name}`);
    
    // 递归条件:如果是文件夹且有子节点,递归遍历
    if (node.type === 'folder' && node.children) {
        node.children.forEach(child => {
            traverseFileSystem(child, indent + "  ");
        });
    }
}

console.log("=== 文件系统遍历 ===");
traverseFileSystem(fileSystem);

运行结果:

=== 文件系统遍历 ===
📁 根目录
  📁 文档
    📄 简历.pdf
    📄 报告.docx
  📁 图片
    📁 旅行照片
      📄 海滩.jpg
    📄 头像.png
  📄 README.txt

六、递归的适用场景

1. 树形结构操作

  • 文件系统遍历
  • DOM树操作
  • 组织架构图
  • 菜单导航

2. 数学问题

  • 阶乘计算
  • 斐波那契数列
  • 汉诺塔问题

3. 分治算法

  • 归并排序
  • 快速排序

4. 回溯算法

  • 迷宫求解
  • 数独解题

七、递归的优缺点

优点:

  • 代码简洁:复杂问题简单化
  • 思路清晰:符合人类思维方式
  • 数学表达直接:数学公式容易转换

缺点:

  • 性能开销:函数调用有成本
  • 栈溢出风险:递归太深会崩溃
  • 调试困难:调用链长难跟踪

八、重要改进:避免重复计算

我们来看斐波那契数列的例子,并解决性能问题:

/**
 * 斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13...
 * 规律:每个数是前两个数之和
 */

// 原始版本:性能很差,有大量重复计算
function fibonacciSlow(n) {
    if (n === 0) return 0;
    if (n === 1) return 1;
    return fibonacciSlow(n - 1) + fibonacciSlow(n - 2);
}

// 优化版本:使用备忘录避免重复计算
function fibonacciMemo(n, memo = {}) {
    if (n in memo) return memo[n];
    if (n === 0) return 0;
    if (n === 1) return 1;
    
    memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
    return memo[n];
}

// 迭代版本:性能最好,不会栈溢出
function fibonacciIterative(n) {
    if (n === 0) return 0;
    if (n === 1) return 1;
    
    let prev = 0;
    let curr = 1;
    
    for (let i = 2; i <= n; i++) {
        const next = prev + curr;
        prev = curr;
        curr = next;
    }
    
    return curr;
}

// 性能测试
console.log("斐波那契数列第10项:");
console.log("慢速版本:", fibonacciSlow(10));
console.log("备忘录版本:", fibonacciMemo(10));
console.log("迭代版本:", fibonacciIterative(10));

九、常见错误和解决方案

错误1:忘记基准条件

// 错误:无限递归!
function infiniteRecursion(n) {
    return n * infiniteRecursion(n - 1); // 没有停止条件!
}

// 正确:必须有基准条件
function correctRecursion(n) {
    if (n <= 1) return 1; // 基准条件
    return n * correctRecursion(n - 1);
}

错误2:问题规模没有减小

// 错误:问题规模没有变小
function wrongRecursion(n) {
    if (n <= 1) return 1;
    return n * wrongRecursion(n); // 还是n,没有减小!
}

//  正确:每次递归问题规模都要减小
function correctRecursion(n) {
    if (n <= 1) return 1;
    return n * correctRecursion(n - 1); // n-1,问题规模减小
}

十、调试技巧:

  1. 打印日志:跟踪递归过程
  2. 使用调试器:观察调用栈变化
  3. 先写基准条件:确保不会无限递归
  4. 小数据测试:先用小数据验证正确性

十一、什么时候该用递归?

适合用递归的情况:

  • 问题可以分解为相似的子问题
  • 数据结构本身是递归的(如树、图)
  • 解决方案需要回溯

不适合用递归的情况:

  • 性能要求极高
  • 递归深度可能很大
  • 可以用简单循环解决

十二、实际例子:计算数组深度

让我们用递归解决一个实际问题:

/**
 * 计算嵌套数组的深度
 * 例如:[1, [2, [3, [4]]]] 的深度是4
 */
function calculateDepth(arr) {
    // 基准条件:如果不是数组,深度为0
    if (!Array.isArray(arr)) {
        return 0;
    }
    
    // 基准条件:空数组深度为1
    if (arr.length === 0) {
        return 1;
    }
    
    // 递归条件:深度 = 1 + 子元素的最大深度
    let maxChildDepth = 0;
    for (const item of arr) {
        const childDepth = calculateDepth(item);
        if (childDepth > maxChildDepth) {
            maxChildDepth = childDepth;
        }
    }
    
    return 1 + maxChildDepth;
}

// 测试
const testArrays = [
    [1, 2, 3],                   // 深度1
    [1, [2, 3]],                 // 深度2  
    [1, [2, [3, [4]]]],          // 深度4
    []                           // 深度1
];

testArrays.forEach((arr, index) => {
    console.log(`数组${index + 1}:`, JSON.stringify(arr));
    console.log(`深度:`, calculateDepth(arr));
    console.log("---");
});

总结

递归的核心思想:把大问题分解成相似的小问题

三个关键点:

  1. 基准条件 - 知道什么时候停止
  2. 递归条件 - 知道如何分解问题
  3. 递归调用 - 自己调用自己

使用建议

  • 先确定基准条件
  • 确保每次递归问题规模都减小
  • 注意性能,必要时改用迭代
  • 复杂递归考虑使用备忘录优化

递归就像剥洋葱,一层一层往里剥,直到找到核心。掌握了这个方法,你就能优雅地解决很多复杂问题了!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3 + ElementPlus 动态菜单实现:一套代码完美适配多角色权限系统》

从0-1封装一个React组件

第一步:初始化与安装依赖

创建一个空文件夹并初始化:

mkdir my-button
cd my-button
pnpm init

接下来安装依赖。对于组件库,核心原则是:

  1. react 和 react-dom 应该是 Peer Dependencies(宿主环境提供),而不是打包进去。
  2. 构建工具和类型定义是 Dev Dependencies

执行下面命令

# 1. 安装构建工具、node类型定义、TS、Sass、类型定义生成插件 和 自动注入样式
pnpm add -D vite@5 @types/node typescript sass vite-plugin-dts vite-plugin-lib-inject-css

# 2. 安装 React 的类型定义 (开发时需要用到类型提示)
pnpm add -D @types/react @types/react-dom

# 3. (可选) 如果你开发时需要用到 react 的具体代码提示,也可以装一下,但最终不会打包
pnpm add -D react react-dom

第二步:手动创建配置文件

1. 创建 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler" /* Vite 5 推荐 */,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx" /* 关键:支持 React */,
    "strict": true,
    "declaration": true /* 生成类型文件 */,
    "declarationDir": "dist"
  },
  "include": ["src"]
}

2. 创建 vite.config.ts

// 导入 Vite 配置函数和所需插件
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import { libInjectCss } from "vite-plugin-lib-inject-css";
import { resolve } from "path";

// 使用 defineConfig 定义 Vite 构建配置
export default defineConfig({
  // 配置使用的插件
  plugins: [
    // 注入 CSS 到库中的插件
    libInjectCss(),
    // 生成类型声明文件(.d.ts)的插件
    dts({
      include: ["src/**/*.ts", "src/**/*.tsx"], // 包含的 TypeScript 文件类型
      outDir: "dist", // 输出目录
      rollupTypes: true, // 使用 Rollup 打包类型
    }),
  ],
  // 构建配置
  build: {
    // 库模式配置
    lib: {
      entry: resolve(__dirname, "src/index.ts"), // 库的入口文件
      name: "MyButton", // UMD 格式的全局变量名
      fileName: (format) => `index.${format}.js`, // 输出文件名格式
    },
    // Rollup 打包配置
    rollupOptions: {
      // 外部化依赖,不打包进库
      external: ["react", "react-dom", "react/jsx-runtime"],
      output: {
        // 配置 UMD 格式的全局变量名
        globals: {
          react: "React",
          "react-dom": "ReactDOM",
        },
      },
    },
    sourcemap: true, // 生成 sourcemap 便于调试
    emptyOutDir: true, // 构建前清空输出目录
  },
});

第三步:构建目录结构与源码

my-button/
├── src/
│ ├── components/ 
│ │ └── MyButton/ 
│ │   ├── index.tsx 
│ │   └── index.module.scss 
│ └── index.ts <-- 统一出口 
├── package.json 
├── tsconfig.json 
└── vite.config.ts

编写组件 src/components/Button/index.tsx

import styles from "./index.module.scss";

export interface MyButtonProps {
  label: string;
  onClick?: () => void;
}

export const MyButton = ({ label, onClick }: MyButtonProps) => {
  return (
    <button className={styles["my-btn"]} onClick={onClick}>
      {label}
    </button>
  );
};

编写样式 src/components/Button/index.module.scss

@use "sass:color";

.my-btn {
  background-color: #007bff;
  color: white;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  &:hover {
    background-color: color.adjust(#007bff, $lightness: -10%);
  }
}

编写入口 src/index.ts

export * from "./components/MyButton";

第四步:配置 package.json (关键)

{
  "name": "my-button",
  "version": "1.0.0",
  "description": "A lightweight React component library",
  "main": "dist/index.umd.js",
  "module": "dist/index.es.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "exports": {
    ".": {
      "import": "./dist/index.es.js",
      "require": "./dist/index.umd.js",
      "types": "./dist/index.d.ts"
    },
    "./index.css": "./dist/index.css"
  },
  "sideEffects": [
    "**/*.css"
  ],
  "scripts": {
    "build": "tsc && vite build"
  },
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  },
  "keywords": [
    "my-button"
  ],
  "author": "hql",
  "license": "ISC",
  "packageManager": "pnpm@10.14.0",
  "devDependencies": {
    // ... 这里是刚才 pnpm add -D 安装的那些
  }
}

在项目代码中使用

import { Button } from 'my-button';

function App() {
  return <Button label="点击我" onClick={() => alert('Works!')} />;
}

本地调试的时候可以在项目中,使用Vite Alias 映射

alias: { 
    // 关键配置:将包名映射到组件库的【源码入口】 
    'my-button': '系统路径/组件包的文件夹名称(my-button)/src/components/MyButton/index.tsx',
},

修改业务项目的 tsconfig.json (关键)

{
  "compilerOptions": {
    // ...其他配置
    "baseUrl": ".", // 启用 paths 必须配置 baseUrl
    "paths": {
      "my-button": [
        // 这里也要填绝对路径
        "系统路径/组件包的文件夹名称(my-button)/src/index.ts"
      ]
    }
  }
}

效果

  • 无需打包:不需要在组件库里运行 pnpm build
  • 实时热更:在 my-button 里改了 SCSS 颜色,Ctrl+S 保存,my-app 页面毫秒级自动刷新样式。
  • 源码调试:在浏览器的 DevTools 里看到的源码是 TS 原文件,而不是打包后的 JS,断点调试非常方便。
  • 避免 React 冲突:因为直接编译源码,组件库会直接使用业务项目的 React 实例,完美避开 "Invalid Hook Call" 问题。

第五步:打包与本地验证

  1. 打包 pnpm build
  2. 本地模拟发布 (最稳妥的测试方式) pnpm pack
  3. 在业务项目中测试,找一个你本地其他的 React 项目(或者随便新建一个测试项目):
# 假设你的 tgz 文件路径是 /Users/xxx/code/my-button/my-button-1.0.0.tgz
pnpm add /绝对路径/my-button-1.0.0.tgz

在代码中使用

import { Button } from 'my-button';

function App() {
  return <Button label="点击我" onClick={() => alert('Works!')} />;
}

第六阶段:发布到 NPM

1. 准备工作

确保 package.json 中的 name 是唯一的(去 npmjs.com 搜一下)。 确保没有私有配置(如 .npmrc 指向了公司私有源),发布需要指向官方源:

npm config set registry https://registry.npmjs.org/

2. 登录与发布

# 登录 npm (如果没有账号需先注册)
npm login

# 升级版本号 (patch: 1.0.0 -> 1.0.1)
npm version patch

# 发布
npm publish

3. 验证

发布成功后,在你的业务项目中把之前的本地引用改回 npm 引用:

pnpm remove my-button
pnpm add my-button

脚本猫中的新建脚本:定时脚本、后台脚本、普通脚本,三个区别

image.png

大致可以这么理解:

1️⃣ 普通脚本(新建普通脚本)

  • 运行位置:在网页里运行,相当于给某个页面“塞一段 JS”

  • 触发方式:当你打开/刷新匹配的网址时自动注入执行

  • 能做的事

    • 操作当前页面 DOM(增删元素、自动点击、填表单……)
    • 监听用户操作
    • 跟页面里的 JS 互动
  • 典型用途:页面改造、自动化操作、加按钮、加 iframe 等

你现在写的“在某个系统页面插 iframe”这种,就是用 普通脚本


2️⃣ 后台脚本(新建后台脚本)

  • 运行位置:扩展的后台环境,不依附具体网页

  • 触发方式:浏览器启动、扩展加载时就可以常驻;也可以被其它脚本消息唤起

  • 特点

    • 不直接操作任何网页 DOM
    • 适合做全局逻辑:统一请求、数据中转、长连接、全局状态、与浏览器 API 交互
    • 可以和普通脚本通过 GM_* 或消息通信协作
  • 典型用途

    • 做一个“总控中枢”:管理多个页面脚本的配置、共享登录状态
    • 长连接 / WebSocket 客户端
    • 统一的任务队列、请求转发、存储

你可以理解为:像浏览器扩展的 background.js


3️⃣ 定时脚本(新建定时脚本)

  • 运行位置:也是在扩展环境中运行,不依附具体网页

  • 触发方式:按你在脚本设置里配置的时间/间隔自动执行

  • 特点

    • 不需要打开某个页面也能跑
    • 每次到点就执行一次脚本的入口函数
  • 典型用途

    • 定时请求某个接口(签到、拉取数据)
    • 定时发通知、检测状态
    • 配合后台脚本/存储做“定时任务中心”

简单记忆版

  • 普通脚本:👉“绑在网页上的脚本”,随页面一起跑,最适合 DOM 操作。
  • 后台脚本:👉“常驻后台的服务脚本”,做全局逻辑、通信、长连接。
  • 定时脚本:👉“闹钟脚本”,按时间点自动执行任务。

🍎🍎🍎🍎 Chrome扩展后台脚本不完全指南:从Manifest V2到V3版本,background.js的运行机制有哪些变化?**

点击查看学习链接

image.png

🍎🍎🍎🍎GPT回答: 定时拉接口、跨多个页面共享状态、自动操作页面。帮我画一个“普通脚本 + 后台脚本 + 定时脚本”的组合结构。

那我给你搭一个「三合一」的脚本系统架构,直接围绕你说的三件事:

  • ✅ 定时拉接口
  • ✅ 跨多个页面共享状态
  • ✅ 进入页面后自动操作 DOM

一、整体结构图(文字版)

                  ┌───────────────────────────┐
                  │        定时脚本(Timer)   │
                  │  - 每 X 分钟调用接口       │
                  │  - 处理/整理数据           │
                  │  - 写入全局存储            │
                  └─────────────┬─────────────┘
                                │ GM_setValue / 存储
                                ▼
          ┌──────────────────────────────┐
          │      脚本猫全局存储/GM_xxx    │
          │  - lastFetchTime             │
          │  - apiData                   │
          │  - userConfig                │
          └─────────────▲───────────────┘
                        │ GM_getValue / 监听变化
        ┌───────────────┴────────────────────┐
        │                                    │
┌───────┴────────────────┐        ┌──────────┴──────────────┐
│   后台脚本(Background)│        │   普通脚本(Page Script)│
│ - 作为中枢服务          │        │ - 匹配具体系统页面       │
│ - 维护内存缓存          │        │ - 读取全局数据           │
│ - 处理复杂逻辑/长连接    │        │ - 根据数据自动操作DOM    │
│ - 接收页面脚本消息      │        │ - 向后台脚本请求服务     │
└────────────────────────┘        └─────────────────────────┘

核心思想:

  • 定时脚本 专心负责:定时拉接口 + 写入全局状态
  • 后台脚本 做“中控”:读写存储、处理复杂逻辑、对外提供服务
  • 普通脚本 只管:在页面里读状态 + 操作 DOM

二、三个需求是怎么串起来的?

1. 定时拉接口 —— 用「定时脚本」

**触发:**你在脚本猫里设置每 5 分钟 / 每小时运行一次。

流程:

  1. 定时脚本触发 → 通过 GM_xmlhttpRequest / fetch 调你的接口。
  2. 把返回数据整理成你想要的结构。
  3. GM_setValue('apiData', data) 存到全局。
  4. 顺便存个时间:GM_setValue('lastFetchTime', Date.now())

这样就算浏览器里没有开具体页面,数据也会一直“被喂新”。


2. 跨多个页面共享状态 —— 用「全局存储 + 后台脚本」

多个页面 + 多个普通脚本,如何共享同一份数据?

  • 所有脚本(定时、后台、普通)都用 同一个脚本猫存储空间(同一个“脚本项目”)。

  • 统一用 GM_getValue/GM_setValue 读写,比如:

    • apiData:最近一次接口数据
    • lastFetchTime:更新时间
    • userConfig:你在配置页改的配置(比如“开关”“阈值”)

后台脚本 做两件事:

  1. 缓存 + 快速响应:

    • 在启动时读取一次 GM_getValue('apiData') 放到内存。
    • 普通脚本通过消息(比如 GM 通信或 ScriptCat 自带 API)问后台要数据,后台直接从内存/存储里回。
  2. 监听变化:

    • 使用 GM_addValueChangeListener('apiData', ...)
    • 一旦定时脚本更新了 apiData,后台脚本能第一时间感知,有需要时再推送给页面脚本(可选)。

这样你在 A 页面用到这份数据,B 页面、C 页面也能读同一套最新的内容。


3. 自动操作页面 —— 用「普通脚本」

普通脚本挂在具体业务页面上,比如:

  • 系统的订单列表页
  • 工单详情页
  • 通话界面等

流程:

  1. 页面加载完成 → 普通脚本启动。

  2. 先从全局存储中读取数据:

    const data = GM_getValue('apiData');
    
  3. 如果需要更复杂的处理(比如多接口组合、数据清洗),可以:

    • 直接给后台脚本发消息:request('processData', { pageInfo })
    • 后台脚本返回处理好的结果。
  4. 拿到数据后,开始 DOM 自动化:

    • 根据接口返回的数据,自动填表、自动点击、自动切换 tab……
    • 数据不够新时,也可以请求后台脚本「临时拉一次接口」。

三、三个脚本的简单骨架示例(伪代码)

1️⃣ 定时脚本(拉接口 + 写入状态)

// ==UserScript==
// @name         定时拉接口脚本
// @namespace    your-namespace
// @version      1.0
// @description  定时请求接口并更新全局数据
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(async function () {
  // 这里是定时任务触发时的主函数
  const url = 'https://api.example.com/data';

  GM_xmlhttpRequest({
    method: 'GET',
    url,
    onload: (res) => {
      try {
        const data = JSON.parse(res.responseText);

        // 处理数据,比如只保留关键字段
        const simplified = {
          updatedAt: Date.now(),
          list: data.items || [],
        };

        GM_setValue('apiData', simplified);
        GM_setValue('lastFetchTime', Date.now());
      } catch (e) {
        console.error('解析接口数据失败', e);
      }
    },
    onerror: (err) => {
      console.error('接口请求失败', err);
    },
  });
})();

定时逻辑由脚本猫“定时脚本”的配置控制,不需你写 setInterval。


2️⃣ 后台脚本(中枢服务)

// ==UserScript==
// @name         后台中枢脚本
// @namespace    your-namespace
// @version      1.0
// @description  统一管理全局状态,对普通脚本提供服务
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// ==/UserScript==

// 内存缓存
let apiCache = GM_getValue('apiData', null);

// 监听存储变化(定时脚本更新时会触发)
GM_addValueChangeListener('apiData', (name, oldValue, newValue, remote) => {
  apiCache = newValue;
  console.log('apiData 已更新:', newValue);
});

// 提供一个简单的消息处理(ScriptCat 有自己的消息 API 可以用)
// 这里伪代码,表示“接收页面脚本的请求”
window.addEventListener('message', (e) => {
  const { type, requestId } = e.data || {};
  if (type === 'getApiData') {
    // 回给页面脚本(同样用 postMessage 或 ScriptCat 自带 GM 通信)
    window.postMessage({
      type: 'getApiDataResult',
      requestId,
      data: apiCache,
    });
  }
});

实际通信 API 你可以用 ScriptCat 提供的方式,比如 GM_sendMessage / 自带的 ScriptCat 通信封装。


3️⃣ 普通脚本(页面自动化)

// ==UserScript==
// @name         页面自动操作脚本
// @namespace    your-namespace
// @version      1.0
// @description  在系统页面中自动操纵 DOM
// @match        https://system.example.com/*
// @grant        GM_getValue
// ==/UserScript==

(function () {
  'use strict';

  // 简单场景:直接从全局存储里读
  const apiData = GM_getValue('apiData', null);
  console.log('当前全局数据:', apiData);

  // 等页面加载完后再操作 DOM
  window.addEventListener('load', () => {
    if (!apiData || !apiData.list || apiData.list.length === 0) {
      console.log('暂无数据,不自动操作');
      return;
    }

    // 举例:根据某个字段自动点击按钮
    autoClickByData(apiData);
  });

  function autoClickByData(data) {
    // 这里写你的页面逻辑,比如:
    // 1. 找到需要处理的行
    // 2. 判断是否满足条件
    // 3. 触发点击 / 填写输入框等
    const buttons = document.querySelectorAll('.process-btn');
    data.list.forEach((item, idx) => {
      const btn = buttons[idx];
      if (!btn) return;
      if (item.needProcess) {
        btn.click();
      }
    });
  }
})();

如果你想走 “普通脚本向后台脚本要数据” 的路线,可以在普通脚本中:

  1. 给后台脚本发 postMessage: {type: 'getApiData', requestId}
  2. 监听 message 获取 getApiDataResult 再操作 DOM。

四、如何落地到你的项目?

你可以按这个思路拆三个“脚本项目”:

  1. 定时脚本

    • 只写请求接口 + GM_setValue
    • 在脚本猫的“定时任务”里设 每 X 分钟运行一次
  2. 后台脚本

    • 不匹配任何 URL(或只在 * 下启动)。
    • 负责监听 GM_addValueChangeListener + 消息通信。
  3. 一个或多个普通脚本

    • 分别匹配不同系统页面(列表页、详情页等)。
    • 统一从全局状态/后台脚本拿数据 → 自动操作 DOM。

如果你愿意,可以告诉我:

  • 你接口的大致返回结构(字段名、列表在哪个字段下)
  • 一个具体的系统页面 URL / 页面结构(大概有哪些按钮 / 列表)

我可以直接给你「可复制进脚本猫就能跑」的完整三脚本示例,帮你串起来。

涨见识了,Error.cause 让 JavaScript 错误调试更轻松

1. 前言

在 JavaScript 中,抛出错误很容易,但追溯原因却有些麻烦,这就是 cause属性的用武之地。

这是我们传统的处理错误的做法:

try {
  JSON.parse("{ bad json }");
} catch (err) {
  throw new Error("Something went wrong: " + err.message);
}

虽然包装了错误,但已经丢失了原始的堆栈信息和错误类型。

当问题发生时,你只能看到最顶层的错误信息,却不知道根本原因是什么。

你好,我是冴羽。前端资讯、前端干货,欢迎关注公众号:冴羽

2. 引入 Error.cause

ES2022 引入了 Error.cause 属性,可以保留原始错误信息:

try {
  try {
    JSON.parse("{ bad json }");
  } catch (err) {
    throw new Error("Something went wrong", { cause: err });
  }
} catch (err) {
  console.error(err.stack);
  console.error("Caused by:", err.cause.stack);
}

此时你可以看到完整的错误链:

Error: Something went wrong
    at ...
Caused by: SyntaxError: Unexpected token b in JSON at position 2
    at JSON.parse (<anonymous>)
    at ...

现在,你既保留了原始错误,又能提供清晰的顶层错误信息。

3. 实际应用示例

让我们看一个更实际的例子:

function fetchUserData() {
  try {
    JSON.parse("{ broken: true }"); // ← 这里会失败
  } catch (parseError) {
    throw new Error("Failed to fetch user data", { cause: parseError });
  }
}

try {
  fetchUserData();
} catch (err) {
  console.error(err.message); // "Failed to fetch user data"
  console.error(err.cause); // [SyntaxError: Unexpected token b in JSON]
  console.error(err.cause instanceof SyntaxError); // true
}

可以看到代码非常清晰直观。

而且 cause 属性被定义为不可枚举,因此它不会污染日志或 for...in 循环,除非你显式访问它。

4. 自定义错误类

你可以在自定义错误类中使用 cause 属性:

class DatabaseError extends Error {
  constructor(message, { cause } = {}) {
    super(message, { cause });
    this.name = "DatabaseError";
  }
}

如果你的运行环境是 ES2022+,这已经足够了:super(message, { cause }) 会自动处理一切。

对于 TypeScript 用户,确保 tsconfig.json 配置了:

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"]
  }
}

否则,在将 { cause } 传递给 Error 构造函数时可能会看到类型错误。

5. 更好的测试断言

假设你的服务抛出了一个 UserCreationError,这是由一个 ValidationError 引发的。

你可以这样写断言:

expect(err.cause).toBeInstanceOf(ValidationError);

这样测试会更清晰、更健壮。

6. 注意事项

默认情况下,console.error(err) 只会打印顶层错误。cause链不会自动显示,因此需要手动打印:

console.error(err);
console.error("Caused by:", err.cause);

尽管 cause 很好,但也不要滥用。每个小错误都包装可能更乱,因此只在真正需要上下文的时候使用。

7. 递归打印完整错误链

这是一个安全遍历错误链的工具函数:

function logErrorChain(err, level = 0) {
  if (!err) return;
  console.error(" ".repeat(level * 2) + `${err.name}: ${err.message}`);

  if (err.cause instanceof Error) {
    logErrorChain(err.cause, level + 1);
  } else if (err.cause) {
    console.error(" ".repeat((level + 1) * 2) + String(err.cause));
  }
}

如果需要完整堆栈信息:

function logFullErrorChain(err) {
  let current = err;
  while (current) {
    console.error(current.stack);
    current = current.cause instanceof Error ? current.cause : null;
  }
}

对于结构复杂、可能在不同层级出现多种故障的系统来说,这非常有用。

8. 跨层错误链示例

假设调用流程如下:

  1. 数据库连接失败,抛出 ConnectionTimeoutError
  2. 捕获后包装成 DatabaseError
  3. 再次捕获并包装成 ServiceUnavailableError
class ConnectionTimeoutError extends Error {}
class DatabaseError extends Error {}
class ServiceUnavailableError extends Error {}

try {
  try {
    try {
      throw new ConnectionTimeoutError("DB connection timed out");
    } catch (networkErr) {
      throw new DatabaseError("Failed to connect to database", { cause: networkErr });
    }
  } catch (dbErr) {
    throw new ServiceUnavailableError("Unable to save user data", { cause: dbErr });
  }
} catch (finalErr) {
  logErrorChain(finalErr);
}

控制台输出:

ServiceUnavailableError: Unable to save user data
  DatabaseError: Failed to connect to database
    ConnectionTimeoutError: DB connection timed out

可以看到,错误链提供了一个清晰的视图,告诉你发生了什么以及在哪里发生的。

9. 支持度

.cause 参数在所有现代环境中都支持:

  • ✅ Chrome 93+、Firefox 91+、Safari 15+、Edge 93+
  • ✅ Node.js 16.9+
  • ✅ Bun 和 Deno(当前版本)

需要注意的是,开发者工具可能不会自动显示 cause。

所以需要显式记录它(console.error('Caused by:', err.cause))。

还要注意:如果使用 Babel 或 TypeScript 进行转译,此功能不会被 polyfill。

10. 异步操作中的错误处理

Error.cause 同样适用于异步操作。结合 async/await 可以这样使用:

async function fetchData() {
  try {
    const response = await fetch("/api/data");
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error("数据获取失败:", error);
    throw new Error("Failed to fetch data", { cause: error });
  }
}

这种方式让异步代码的错误处理逻辑看起来与同步代码无异,大大提升了可读性和可维护性。

11. 总结

总结一下,现代错误链处理的最佳实践

  • 使用 new Error(message, { cause }) 保留上下文
  • 适用于内置错误类和自定义错误类
  • 所有现代运行时环境都支持(浏览器、Node.js、Deno、Bun)
  • 可以改善日志、调试和测试断言
  • 注意 TypeScript:设置 "target": "es2022""lib": ["es2022"]
  • 注意记录 err.cause 或手动遍历错误链

从而实现更清晰的堆栈跟踪、更好的上下文、更愉快的调试体验。

Error.cause 就是你错误处理中缺少的那一环。

12. 参考链接

  1. allthingssmitty.com/2025/11/10/…
  2. developer.mozilla.org/zh-CN/docs/…

Ant Design Vue 日期选择器英文不变更中文问题

Ant Design Vue 日期选择器中英文混杂问题分析与解决

项目背景

  • 技术栈:Vue 3.5.24 + Ant Design Vue 4.2.6
  • 日期库:从 v3 起 Ant Design Vue 默认使用 dayjs

问题描述

在全局已经配置中文(ConfigProvider + dayjs.locale('zh-cn'))的情况下,DatePicker 组件仍出现“中英文混杂”:

  • “年”“今天”等字样为中文
  • 月份(Jan/Feb…)与星期(Mon/Tue…)依旧显示英文
  • 无论全局注入还是局部覆盖 locale 均无效 企业微信截图_1b148f4c-d467-42a8-a4b8-ae1c3b81d0eb.png

深层原因剖析

  1. dayjs 版本过旧
    早期 dayjszh-cn 语言包缺失 months/weekdays 的中文定义,或补丁未完全下发。

  2. 多版本 dayjs 共存
    pnpm 的去重策略可能导致锁文件里存在多个 dayjs 版本,入口文件设置的 dayjs.locale 未必作用于 Ant Design Vue 内部使用的实例。

  3. 执行顺序/Tree-shaking 问题
    Vite 的懒加载或 chunk 切分可能使 import 'dayjs/locale/zh-cn' 未及时执行;若没有紧跟 dayjs.locale('zh-cn'),组件渲染阶段仍使用默认英文。

  4. 语言包字段缺失
    旧版本 dayjszh-cn 语言包里 weekdaysShortmonthsShort 等字段为空,Antd 组件 fallback 为英文。

排查步骤(建议流程)

  1. 确认全局中文配置

    import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn');

  2. 检查 dayjs 版本

    pnpm list dayjs pnpm why dayjs 关注是否存在多个版本或锁定在 1.11.0 之前。

  3. 查看本地语言包
    打开 node_modules/dayjs/locale/zh-cn.js,确认 monthsweekdays 等数组是否为中文。

解决方案

  • 结论:升级 dayjs 至 ≥ 1.11.19

  • 操作步骤: 企业微信截图_bf2e42f6-e949-4fb7-8c95-51029d1df296.png pnpm add dayjs@1.11.19 -w

    import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn');

    • 重启 dev server 并清理缓存,确认 DatePicker 面板的月份、星期、按钮均已中文化。

可选补充

  • Ant Design Vue 的日期国际化完全依赖 dayjs,语言异常优先排查 dayjs 版本和语言包
  • Monorepo/多包环境需确保 dayjs 版本统一,避免多版本导致的 locale 失效
  • import 'dayjs/locale/zh-cn' 后务必紧接 dayjs.locale('zh-cn'),并确保在入口同步执行

参考资料

🌹🌹🌹bro,AntD 6.0.0 来了

写前端写久了,我们对 UI 组件升级这事似乎再熟悉不过——
但只要一升级大版本,你就会发现:

它像在考试,你像在裸奔。

AntD 6.0.0 发布之后,
我一边看更新日志,一边感觉官方在说:

“我们这次真的没乱改 API(大部分)……
至于改了的部分,你再忍忍吧。”

这篇文章算是我对 6.0 版本 的一次“补坑 + 吐槽 + 解析”合集:
系统地串起本次升级到底动了哪些刀子、砍在你哪里、如何避免当场去世。

看完你会更清楚:

  • AntD 6到底改了什么
  • 哪些会影响你项目
  • 哪些会让你怀疑人生
  • 哪些会让你想尊敬作者
  • 以及为什么你要升级
  • 升级注意事项

image.png


一、从 6.0 开始:AntD 终于把旧锅清干净了

每个 UI 框架大版本的第一件事就是:

清垃圾。

这次 AntD 6 做了 4 类大收拾:

① 彻底移除 IE —— 终于

IE 死透了之后,AntD 也不装了:

  • reset.css 不给 IE 面子了
  • 构建目标提升
  • 各种兼容性逻辑直接移除

一句话:

以后再有客户说要兼容 IE,就让他自己兼容自己。


② 清退废弃 API

比如:

  • Dropdown.Button:直接没了
  • Icon 占位组件:没了
  • BackTop:没了
  • List 组件:整段 remove

这些组件早就劝你不用了,
你不用,官方就把它“自然淘汰”了。


③ React 19:默认支持

React 19 一堆 break change。
但 AntD 官方一句:

“我们全都处理好了。”

非常稳。


④ 内部库大换血

classNames → clsx
copy-to-clipboard → 移除

一句话版本:

AntD 6/ 不只是 UI 组件升级,
是整个底层工程体系换代。

从现在开始,你用 AntD 的项目才算“站在 2025 年前端的主流线上”。


二、ConfigProvider:从“老好人”变成“全村指挥中心”

AntD 6 里 ConfigProvider 已经不是个配置组件了,
而是 中央政务大厅

什么都归它管。

比如:

  • Table 的 rowKey
  • Tooltip / Popover / Popconfirm 的箭头
  • Card.Meta 的样式
  • Space 的 root
  • Modal 的按钮属性
  • 全局主题 token
  • zeroRuntime 关闭 CSS-in-JS
  • tooltip.unique 支持平滑移动

简单说:

以前你在每个组件上写 props,
现在你在 ConfigProvider 一次性“拍板”。

这次更新官方主打一件事:

“所有配置,能收敛就收敛,别让你每个组件都粘粘糊糊地写 props。”

这一点给我一种非常舒服的感觉。
就像写一个业务页面时终于不必:

<Tooltip arrow={{ pointAtCenter: true }} />
<Popover arrow={{ pointAtCenter: true }} />
<Popconfirm arrow={{ pointAtCenter: true }} />

现在直接:

<ConfigProvider
  componentSize="middle"
  tooltip={{ unique: true }}
  popover={{ arrow: { offset: 6 } }}
/>

UI 管理这事终于逻辑清晰了。


三、性能优化:这波是真的有感

AntD 每次说“优化性能”,
我都会下意识怀疑:

“你是不是只是把一个变量换了个名字。”

但这次是真的狠。

比如 Tooltip 优化开发模式性能,官方说:

提升大约 40%

40% 是什么概念?
就是你在调试 Tooltip 的时候,延迟从“卡得你怀疑人生”变成“至少不卡了”。

Form 也优化了大量字段卸载时 useWatch 的性能。
Form 重、复杂,一直是大家吐槽对象,
现在能感受到明显顺滑。


四、新组件:Masonry 瀑布流

没想到 AntD 终于把瀑布流组件给补上了。

而且不是什么半吊子,
是真的独立组件 Masonry
自带:

  • 自动排布
  • 响应式
  • 插槽
  • 逻辑位置支持(RTL)

一句话:

再也不用自己写 column-count hack 或者用三方库了。

瀑布流终于不是前端黑魔法了。

image.png


五、组件语义化结构:前端生态卷到头了(重点)

这是 Ant Design 6.0 的魂。
是整个大版本最重大的变革。

如果你没升级 6,但你是写 AntD 的,这段必须看。

AntD 把所有组件的 DOM 结构统一成一种语义化结构规则,例如:

  • icon
  • header
  • container
  • actions
  • meta
  • wrapper
  • content
  • control
  • list
  • panel

以前组件之间 DOM 结构差异巨大,
写样式像在开盲盒:

  • 有时候叫 .ant-card-body
  • 有时候叫 .ant-card-content
  • 有时候是 inline 样式压不下去
  • 有时候 className 名字长得像报错日志

统一语义化之后:

  • DOM 结构可预测
  • className 可枚举
  • 定制能力统一
  • 样式覆盖清晰
  • configProvider 遍历配置变得可能
  • 个性化主题更可控

更绝的是:

官方搞了“语义结构生成函数”

也就是说 DOM 结构不是写死的,
而是通过 props 动态生成。

你可以理解成:

AntD 的 DOM 是“模板 + 逻辑”,不是“硬写在源码里”。

这代表什么?

→ 更灵活的主题

可以随便改节点结构。

→ 更易维护

以后搞一个“大型企业主题改造”,你不会改到吐。

→ 更易适配 RTL、多语言

逻辑位置直接跟着结构走。

→ 更容易写自动化 UI 测试

因为 DOM 结构更可预测。

语义化结构本质是在说:

“AntD 的 DOM,我们终于不再乱搞了。”


六、逻辑位置(logical placement):从 left/right 到 start/end

AntD 全面替换:

  • left → start
  • right → end
  • expandIconPosition → expandIconPlacement
  • pagination.position → pagination.placement
  • dotPosition → dotPlacement

为什么?
因为为了 RTL(阿拉伯语等)

以前 RTL 要:

  • 改方向
  • 改布局
  • 改翻转图标
  • 写一堆 override CSS

现在你只写:

placement="start"

浏览器自然判断方向。

这是国际化大厂常用的布局写法,
AntD 这次完全对齐了未来趋势。


七、一堆“终于支持了”的功能

这次更新日志很长,但我总结一下:

✔ Drawer 支持拖拽大小(resizable)

这功能多少人自己 hack 过?

✔ DatePicker 加预览值(hover preview)

巨实用。

✔ Pagination 输入框只能输入数字

谢谢你们终于发现用户不是工程师。

✔ Cascader 支持 aria- /data-

可访问性终于跟上。

✔ Tag 支持 disabled / href

以前 Tag 想当链接自己写半天。

✔ Modal / Image 遮罩支持模糊

UI 更现代。

✔ Notification 支持自定义进度条颜色

意味可以做渐变主题。

✔ Alert closable 支持回调

总算可以「关闭动画结束再干活」了。

✔ Segmented 支持 tooltip

这终于成“按钮组”而不是“看上去像按钮的东西”。

✔ Splitter 自定义拖拽图标

重度后台 UI 的福音。

一句话:

AntD 6 加的不只是新功能,而是“实现了以前你以为它早就有的功能”。


八、CSS 变量 & zeroRuntime:样式体系升级

AntD 6 的主题系统真正步入现代化:

√ 默认使用 CSS variables

终于从 emotion / css-in-js 中解放一部分性能。

√ 新 token:colorBorderDisabled

一致的禁用状态样式。

√ zeroRuntime

完全禁用 css-in-js,
也就是 ——

样式只靠 CSS,不靠 JS 动态生成。

你可以:

  • 减少 JS 包体积
  • 减少样式计算开销
  • 加速 SSR
  • 加速 hydration

AntD 6 的主题系统已经接近 Tailwind / Radix 那种“现代架构”了。


九、为什么我推荐升级?

你可能会问:

“那 6.0 值得升级吗?项目会不会炸?”

非常诚恳的回答:

值得,也不会那么炸。

原因:

① 95% 以上 API 是平滑升级

破坏性更新都集中在:

  • 废弃组件
  • 部分位置 API
  • 样式结构变化

业务层代码影响小。

② 性能是真的提升

不仅仅是理论。

③ 现代化工程趋势

写 AntD 6 的项目,
你整套结构、体验都比以前“干净一大截”。

④ Theme & DOM 语义化结构是质变

未来大项目会非常依赖它。


十、从 v5 → v6:升级你不能忽视的细节(重点)

下面这一段是你真正必须看的。

1. React 版本必须 ≥ 18

React 17 没救了。

你还在 17,也别想上 v6。

顺便:

- import '@ant-design/v5-patch-for-react-19';

删了。

v6 原生支持 React 19。


2. IE 全面退役,CSS Variables 必须支持

如果你们还有“客户的电脑装的是 Windows XP + IE 8”这种用户群:

别升级,真的会出事。

国产浏览器老版本(早期双核)也可能炸。

升级前请确认:

  • Chrome ≥ 79
  • Edge ≥ 79
  • Safari ≥ 13.1
  • Firefox ≥ 72

3. 自定义样式若依赖 antd 内部 DOM → 必须自查

v6 动了非常多 DOM:

⚠️ Collapse icon 结构改了
⚠️ Modal DOM 改了
⚠️ Tag DOM 改了
⚠️ Form、Table 内部 className 整理
⚠️ Tooltip DOM 结构变化
⚠️ Pagination input 限制只能输入数字

如果你的项目里出现这种写法:

.ant-modal > div > .ant-modal-content > .ant-modal-header { … }

祝你好运 😅

升级必删这些 hack。


4. 弹层默认 mask blur(我再强调一次,很关键)

升级后突然出现模糊效果,不要以为浏览器坏了。

关掉模糊:

<ConfigProvider modal={{ mask: { blur: false } }} />

5. Tag margin 被移除

如果你布局像下面这样:

TagA TagB TagC

升级后会变成:

TagATagBTagC

补救方案见上面。


6. Form onFinish 不再返回 Form.List 未注册的值

v5 的行为有点奇怪:

哪怕 Form.List 子项没有真正注册 Form.Item,onFinish 也会把整个结构返回给你。

v6 改对了。

旧写法(v5)

const real = getFieldsValue({ strict: true });

新写法(v6)

const real = values;

清爽了。

十一、升级 Checklist(实战角度)注意点

我给你强化版 checklist:

✔ React ≥ 18

必要条件。

✔ 移除 React 19 patch 包

已经内置支持。

✔ 检查浏览器兼容性(CSS variables)

如果 Target 覆盖移动端旧机型,请特别留意。

✔ 手动查一遍自定义样式

重点组件:

  • Modal
  • Drawer
  • Collapse
  • Tooltip
  • Form
  • Table
  • Tag

✔ 检查 mask blur 是否符合设计稿

不符合就禁用。

✔ 构建配置检查

必须确保 CSS-in-JS 正常工作:

  • vite + swc
  • webpack + babel
  • rspack

✔ 处理所有 console warning

v5 的 deprecated API 你必须提前清理。

全面检查完成后

npm install --save antd@6
# 或
yarn add antd@6
# 或
pnpm add antd@6

ok,完成升级,升级后也别忘了检查下页面

十二、写在最后:当你再看“AntD 更新日志”

AntD 从 4 → 5 → 6 的过程里,
可以看到这套设计体系从“能用” → “好用” → “可扩展”的完整升级链。

6.0 明显把重点从“功能性组件”转向了“工程体系完善”:

  • 语义化结构
  • CSS 变量
  • zeroRuntime
  • 全局配置体系
  • RTL 逻辑位置
  • 全量优化性能
  • 更易维护
  • 更深度主题化

这次是一次真正意义上的“大版本重构”,
不是换皮。

作为前端工程师,我们当然可以继续:

复制 UI → 调接口 → 写页面 → 交付上线。

但如果你真的想变强,
你需要知道:

  • 框架为什么这样设计
  • DOM 结构为什么要语义化
  • 为什么 UI 库要用 RTL
  • 为什么 CSS-in-JS 要被部分取代
  • 为什么配置要靠 provider 收敛
  • 为什么组件属性要统一成 placement

这些“看起来只是升级日志的小点”,
背后都是前端生态发展的方向。

别再说“写 UI 没技术含量”这种低情商话了。
UI 库的技术含量,你甚至想象不到。

Electron 第一步

Build cross-platform desktop apps with JavaScript, HTML, and CSS。 这就是 Electron 让开发者用 Web 技术(HTML/CSS/JavaScript)  就能构建出和原生应用(如 Windows 的 .exe、macOS 的 .app)体验一致的桌面软件,无需学习 C++、Swift 等原生开发语言 代码参考: github.com/kejuqu/elec…

Electron 的核心组成部分

主要分成如下三个部分:

组成部分 核心作用 技术依赖 / 本质
1. 主进程(Main Process) 负责管理全局资源和原生能力调用,是应用的入口点。 Node.js 运行时(基于 V8 引擎)
2. 渲染进程(Renderer Process) 应用的 “界面”,负责渲染用户看到的 UI(即 Web 页面),可有多进程。 Chrome 浏览器内核(Blink 引擎)
3. 预加载脚本(Preload Script) 主进程和渲染进程的 “桥梁”,解决两者通信和权限隔离问题。

Electron 的核心逻辑可以理解为:用 Chrome 内核做界面渲染(渲染进程),用 Node.js 做原生能力支撑(主进程),用预加载脚本解决两者的通信和安全问题—— 最终实现 “Web 技术写桌面应用” 的目标,是 Web 开发者切入桌面开发的最低门槛工具

开始 Electron

初始化项目

// 1. 创建一个项目名字为 electron-app
mkdir electron-app && cd electron-app

// 2. 初始化项目,自动创建 package.json
npm init 
// package.json
{
  "name": "electron-app",
  "version": "1.0.0",
  "description": "to develop an electron app",
  "main": "main.js", // 这是需要自己加的, main 为入口文件
  "scripts": {
    "dev": "electron .", // 这是需要自己加的
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "jakequc",
  "license": "MIT",
  "packageManager": "pnpm@10.22.0",
  "devDependencies": {
    "electron": "^39.2.3"
  }
}

// 3. 安装 electron 为开发依赖,因为 electron 不会在运行时用到
pnpm install electron --save-dev

// 4. 在根目录下创建入口文件 main.js,内容为
console.log("Hello from Electron 👋");

// 5. npm run dev
// 控制台出现 Hello from Electron 👋 表示初始化项目成功 🎉🎉🎉

可能遇到或疑惑

为啥 electron 安装到 devDependencies?

Electron 的角色是「开发 / 构建工具」,而非最终产品运行时必需的依赖,Electron 的作用仅体现在开发调试最终打包两个阶段,用户拿到的「桌面应用」里,根本不需要 Electron 本身,也可以说 Electron 被嵌入到了产物中

安装失败

方法1: 可以参考这个 www.electronjs.org/docs/latest… 链接

方法2: 去 # electron always "Electron failed to install correctly, please delete node_modules/electron and try installing again"

// 在你的安装终端执行
node node_modules/electron/install.js

方法3: 更改 nodeLinker 保证安装 npm 包是实际存在在磁盘上的,而不是软链接或者替代的安装策略

// 如果是 pnpm 包管理工具,在项目更目录的 pnpm-workspace.yaml
nodeLinker: "hoisted"

// 如果是 yarn,通过命令自动生成配置
yarn config set nodeLinker node-modules

加载 web 页面到浏览器窗口

Electron 的每个 window 窗口都可以加载一个 本地的 HTML 文件或者是一个远程的地址

创建 web page HTML 文件

恭喜你已经基本搭建了 Electron,现在我们让 Electron 加载 web 页面,我们先从一个 local HTML 文件开始,在根目录下创建一个 index.html 文件

// index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <meta
      http-equiv="X-Content-Security-Policy"
      content="default-src 'self'; script-src 'self'"
    />
    <title>Hello from Electron renderer!</title>
  </head>
  <body>
    <h1>Hello from Electron renderer!</h1>
    <p>👋</p>
  </body>
</html>

使用 Electron main.js 来加载 html 文件到 BrowerWindow 中

electron 包使用的模块:

  • app 控制一个应用的事件生命周期
  • BrowserWindow,它用于创建和管理应用程序窗口。每个 web page 就是一个渲染进程,每个渲染进程可以使用 js APIs 和任何前端开发技术,比如 React、Vite 等
// main.js 替换为:
const { app, BrowserWindow } = require('electron')

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600
  })

  win.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()
})

操作系统平台判断

注意在不同的操作系统中应用窗口可能有不同的行为,因此可以借助 process.platform 变量来帮助你条件性的在不同操作系统上做不同的事情, process.platform 有三个值:

  • win32 (Windows)
  • linux(Linux)
  • darwin (macOS)

退出整个应用

在 Windows 和 Linux 关闭所有的 窗口后将会自动退出整个应用,但是 macOs 上需要单独调用 app.quit() 才能实现,此时需要监听 electron 的 app 模块中的 window-all-closed 事件

app.on("window-all-closed", () => {
  // 如果是 macOS 系统,需要显示的调用 app.quit() 才能退出整个应用
  if (process.platform !== "darwin") {
    app.quit();
  }
});

打开一个可运行的窗口

相比之下,macOS 应用通常即使在没有任何窗口打开的情况下也会继续运行。当没有可用窗口时激活该应用,应该打开一个新窗口。当窗口激活的时候可以监听 app 模块的 activate 事件,因为 windows 不能在 ready 事件前创建 BrowserWindow, 所以我们应该在 whenReady 里监听 activate 事件

app.whenReady().then(() => {
  createWindow();

  // windows 必须等待 应用 ready 之后才能创建窗口,当前没有窗口打开时,应该创建一个窗口
  app.on("activate", () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

从数字到版面:得物数据产品里数字格式化的那些事

一、前 言

做数据前端,你会很快建立一个共识:

怎样把枯燥的数字用合适的方式展示出来,是我们的第一要务,但这只是起点。

如果说规范的数字排版是中后台系统的“地基” ,保证了信息的准确传达;那么可视化图表就是地基之上的“建筑” 。地基稳固,建筑才能发挥其功能——让用户从微观的读数中解放出来,更快速地识别趋势、定位异常,从而真正从数据中获取规律。

但这篇主要想聊的,不是那座“建筑”,而是这块往往被忽视,却决定了整个系统专业度的“地基”——数字格式化。

请看得物各业务线里的这些日常场景:

  • 商品详情页:券后价、折扣、分期单价等;
  • 智珠(得物社区运营平台)、得数(自助取数平台)、智能运营(得物交易运营平台)里的 GMV、转化率、留存率、PVCTR等;
  • 社区里帖子阅读量、点赞数、粉丝数。

这些数字表面上只是 number,一旦出现在屏幕上,就自动变成版面的一部分:

对齐方式、位数长短、小数几位、有没有“万 / 亿”、单位怎么放——都会影响到整个页面的节奏和专业感。

排版本质上是在管理信息和秩序:层级、节奏、对齐、留白。而数字不是排版的“附属品”,恰恰是这些原则最密集的载体。

本文不发散去谈数据可视化,只专注于“数字格式化”这一件事:

  1. 什么是数字格式化?它背后有哪些鲜为人知的文化差异和技术细节?
  2. 如果没有统一方案,失控的数字会给产品决策、UI 排版和工程维护带来什么麻烦?
  3. 得物数据前端在得数 / 智珠 / 智能运营里,是怎么把这件事从“工具函数”做成“基础设施”的?
  4. 我们基于Intl.NumberFormat和@dfx/number-format 的方案,架构是怎样的,带来了哪些实际收益?

二、什么是“数字格式化”?不只是toFixed(2)

一提到提到数字格式化,第一反应是:toFixed(2)、拼个%、加个¥就完事了或者通过正则来拼接千分符。但在真实世界里,“同一个数长成什么样”远比想象复杂。

2.1 数字写法本身就是“文化差异”

zh.wikipedia.org/wiki/小数点

小数分隔符的符号演变史

据Florian Cajori 1928年的著作《数学符号史》记载,小数分隔符(旧称 separatrix)的演变经历了一个漫长的标准化过程。

在早期数学文献中,不同地区对小数的处理方式各异。以数值 34.65 为例,中世纪文献常采用在整数与小数间加横线或在个位数字上方加注标记的方式。英国早期曾采用竖线“|”作为分隔,后在印刷中逐渐简化为逗号或点,这与当时阿拉伯数学家主要使用逗号的习惯相呼应。

“点”与“逗号”分流的历史成因

17 世纪末至 18 世纪初是符号标准化的关键时期,也是英美体系与欧洲大陆体系产生分歧的起点。这一差异在很大程度上受到了微积分发明者及其符号体系的影响:

  1. 欧洲大陆(莱布尼茨的影响) :德国数学家莱布尼茨提议使用“点”作为乘法符号。这一提议经由克里斯蒂安·沃尔夫等学者的推广,在欧洲大陆教科书中广泛普及。为了避免符号含义的冲突,欧洲大陆数学家普遍采用了“逗号”作为小数分隔符。
  2. 英国(牛顿体系的延续) :英国数学界未采纳莱布尼茨的乘法符号,而是沿用“X”表示乘法。因此,“点”在英国并未被乘法占用,得以继续作为小数分隔符使用。据统计,18 世纪初的英国教科书中,约 60% 使用点,40% 使用逗号;而到了 18 世纪末,点已成为英国的绝对主流。

标准的确立

尽管比利时和意大利等国曾长期坚持使用点作为小数分隔符,但最终均向欧洲大陆的主流标准靠拢,改用了逗号。至此,英美使用“小数点”、欧洲大陆使用“小数逗号”的格局基本定型。

值得注意的是,直至20世纪初,符号的统一仍未完全完成,各类文献中仍可见等非标准写法。

  • 英语系 / 中国常见写法:1,234,567.89
  • 很多欧洲国家:1.234.567,89(点是千分位,逗号是小数)
  • 瑞士习惯:1'234.56或1'234,56,用撇号 ' 做分组。

这些规则都已经被整理进Unicode CLDR和ICU数据库,现代浏览器的Intl.NumberFormat就是建立在这套数据之上,能根据 locale 自适应这些写法。

所以,一个简单的1,000:

  • 在美国人或中国人眼里是“ one thousand / 一千”;
  • 在某些欧洲语境下可能被读成“保留三位小数的一点零”。

一旦你做电商、跨境、数据产品,这种“写法”的差异,就不再是小问题,而是直接影响决策和合规的东西。

2.2 数字不只是“大小”还有语义和语气

在 UI 里,我们其实经常在表达“数字的语气”:

  • +12.3%:不是纯数学“加号”,而是一种“上涨”的信号,排版上常常配合红/绿颜色;
  • 1.2M / 120 万:为了节省空间和降低认知负担,用缩写表示量级;
  • < 0.01:极小数值,让用户知道“接近 0”,而不是盯着一串 0.000032 的数字尾巴发呆;
  • — /N/A:告诉用户“这里是没数据 / 异常”,而不是“就是 0”。

这些都属于“数字的表达”而非“运算结果”。

如果我们只用toFixed和拼字符串,很难让这些语气在整个系统内保持一致。

2.3浏览器和 Node 已经给了我们一个“引擎”

ECMAScript 402标准中提供的 Intl.NumberFormat,是一个专门做本地化数字格式化的构造器。

它支持:

  • 根据 locale 切换小数点、分组规则、数字符号;
  • 货币格式:style: "currency" + currency: "CNY" | "JPY" | ...;
  • 百分比:style: "percent",自动乘 100 并加 %;
  • 紧凑表示:notation: "compact",输出 9.9M、9.9亿等缩写;
  • formatToParts():把数字拆成整数、小数点、小数、货币符号等片段,方便做更精细的排版。

所以,数字格式化的“引擎问题”其实已经有人帮我们解决了——真正难的是:

  • 怎么结合业务语义;
  • 怎么结合排版规范;
  • 怎么在多个系统之间做到一致;
  • 怎么治理“不乱写”的工程实践。

这就回到我们自己的故事。

三、如果没有统一方案:三个数据产品的日常

下面这几个场景,相信你在得物或类似电商/数据平台里一定见过。

想象一张典型后台页面:

  • 顶部是活动看板 KPI 卡片;
  • 中间是按品类/渠道拆开的表格;
  • 下方是创作者表现列表。

3.1 价格:同一张订单,不同系统“长得不同”

那么,以前我们是怎么做的?

在没有统一规范的“蛮荒时代”,面对一个数字,我们的第一反应往往是:“这还不简单?拼个字符串不就完事了?”

这种代码,你一定写过,或者在项目的某个角落实实在在地见过:

场景一:简单粗暴的拼接一把梭

// "我管你什么场景,先拼上去再说"
function formatPrice(value: number) {
  // 隐患埋雷:这里是全角¥还是半角¥?null 会变成 "¥null" 吗?
  return '¥' + value.toFixed(2);
}

场景二:为了千分位,手写正则“炫技”

// "网上一搜一大把的正则,看着能跑就行"
export const formatNumberWithCommas = (number: number | string) => {
  const parts = number.toString().split('.');
  
  // 经典的正则替换,但这真的覆盖了所有负数、极大值场景吗?
  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  
  // 手动补零逻辑,不仅难维护,还容易在边界情况(如 undefined)下报错
  if (parts.length > 1 && parts[1]?.length === 1) {
    parts[1] = ${parts[1]}0;
  }
  return parts.join('.');
};

这看起来似乎“能用”,并且用起来貌似也没啥问题。但当业务复杂度稍微上来一点或者需要做国际化的时候,那接手这个需求的同学只能发出一句『哦吼』。

  1. 视觉上的“各自为政”
    1. 符号打架: 商品卡片用半角 ⁠¥,详情页用全角 ⁠¥,甚至有的地方混用了 ⁠CNY。
    2. 精度随心: 有的开发觉得 ⁠toFixed(2) 严谨,有的觉得 ⁠。00 太多余直接砍掉,导致同一个页面里,导致同一个页面里,数字像锯齿一样参差不齐。
  2. 排版上的“秩序崩塌”
    1. 试想一个表格列:⁠1299、⁠1,299.00、⁠1299.0 混在一起。
    2. 对齐基准线完全错乱,尤其在表格里,就和『狗啃过一样』,犬牙交错,参差不齐,用户的眼睛需要在不同的小数位之间来回跳跃,阅读体验极差。
  3. 国际化上的“死胡同”
    1. 这种硬编码逻辑(Hardcode),完全堵死了国际化的路。
    2. 一旦业务要支持 USD(美元)、JPY(日元,默认无小数)、EUR(欧元,部分国家用逗号做小数点)。
    3. 比如 ⁠1,234,567.89(英美)和 ⁠1.234.567,89(德意),这完全不是改个符号就能解决的,而是整个数字书写逻辑的根本差异。

我们原本想省事写的小函数,最终变成了阻碍系统演进的技术债务。

3.2看似智能的“万/亿”缩写,其实是硬编码的陷阱

在创作者中心或内容社区,我们希望阅读量(PV)能符合用户的直觉认知:

  • 小数值:⁠< 10,000 时,显示完整数字,精确到个位;
  • 中量级:⁠≥ 10,000 时,缩写为 ⁠X.X 万;
  • 海量级:≥ 100,000,000 时,缩写为 ⁠X.X 亿。

如果是海外版,需求又变成了:⁠1.2k/⁠3.4M/5.6B。

于是,我们写出了那段经典的⁠formatPv

function formatPv(count: number) {
  if (count < 10000) return String(count);
  if (count < 100000000) return (count / 10000).toFixed(1) + '万';
  return (count / 100000000).toFixed(1) + '亿';
}

这段代码逻辑清晰,看似解决了问题。但在真实场景中,它却是一个“Bug 制造机”:

1.临界点的“视觉突变”

  • 9999 下一秒变成 ⁠1.0 万?产品会立刻找你:“这个数展示是不是不大对,能不能9999之后显示 1.0w 而不是 1.0 万?”
  • 99950 四舍五入变成 ⁠10.0 万,要不要显示成更直观的 ⁠10 万?这些微小的细节,需要堆砌大量的 ⁠if-else 来修补。

2.维护上的“复制粘贴地狱”

  • 中文版写一套,英文版 ⁠formatPvEn 再写一套,等咱们的业务做往世界各地的时候,那得要多少 formatPv(xx)呢?
  • A 项目拷一份,B 项目拷一份。等哪天产品说“万”后面不留小数了,你得去 N 个仓库里把这行代码找出来改一遍。最终结果就是:全站的缩写策略处于一种“相似但不一致”的薛定谔状态。

3.文化适配上的“盲区”

  • 这套逻辑是典型的“简体中文中心主义”。
  • 印度用户看到⁠100k 是没感觉的,他们习惯用 ⁠Lakh (10 万) 和 ⁠Crore (1000 万);
  • 阿拉伯语区可能需要用东阿拉伯数字。

这不仅仅是把“万”翻译成“Wan”的问题,而是数字分级逻辑在不同文化中完全不同,在前面针对符号系统我们也提到过。

我们在 UI 上硬塞了一套“只能在简体中文里自洽”的规则,这在国际化产品中是行不通的。

3.3 被“抹平”的语义—0、空值与极小值

在得数(自助取数平台)、智珠(得物社区运营平台)、智能运营(得物交易运营平台)这类重度数据看板中,我们充斥着各种比率型指标

  • 风控类:鉴别通过率、拦截率;
  • 履约类:超时率、投诉成功率;
  • 增长类:活动转化率、复购率。

面对这些指标,以前最常见的处理方式是写一个通用的 formatPercent:

function formatPercent(value: number | null | undefined) {
  // "没数据就当 0 算呗,省得报错"
  return ((value || 0) * 100).toFixed(2) + '%';
}

这行代码虽然只有一句话,却在业务层面犯了三个严重的错误:

1. 混淆了“事实”与“缺失”

  • ⁠null 代表数据未产出或链路异常(就是没数),而 ⁠0 代表业务结果确实为零。
  • 代码粗暴地将 ⁠null 转为 ⁠0.00%,会让用户误以为“今天没人投诉”或“转化率为 0”,从而掩盖了背后的系统故障或数据延迟。

2. “抹杀”了长尾数据的价值

  • 对于 AB 或算法模型来说,⁠0.000032 是一个具备统计意义的概率值。
  • 被这行代码强行截断为 ⁠0.00% 后,业务同学会困惑:“为什么 P 值还能是 0 的嘛?”这直接损害了数据的公信力,严重点来说,都会影响业务决策。

3. 阻断了精细化表达的可能

当你想把极小值优化为 ⁠< 0.01% 这种更科学的表达时,你通过 vscode 一搜代码,500+ 文件,直接就两眼一黑。

从排版设计的角度看,这三者本应拥有完全不同的视觉层级:

  • 空值(No Data) :使用 ⁠— 或灰色占位符,表示“此处无信息”,降低视觉干扰;
  • 异常值(Error) :使用 ⁠N/A 或警示色,提示用户“数据有问题”;
  • 极小值(Tiny Value) :使用 ⁠< 0.01% 或者≈ 0,保留数据的存在感,同时传达“接近于零”的准确语义。

得数DataHub在治理展示层时发现,大量“同一个指标在不同页面长得不一样”,根源就在于这些各自为政、缺乏语义区分的格式化规则。

四、从“写工具函数”到“定义展示语义”

回顾上述场景,透过那些混乱的代码片段,可以发现三个共性的系统性难题:

  1. 逻辑熵增:每个业务线、甚至每个页面都在重复造轮子。⁠formatPrice、⁠formatPercent 遍地开花,前后端逻辑割裂,维护成本随着业务扩张呈指数级上升。
  2. 无法治理:想把全站的“万 / 亿”阈值统一?或者把某种费率的精度从两位改成四位?这几乎是不可能的任务。
  3. 体验失控:设计规范虽然写着“空值用 —”,但落实到代码里全看开发心情。结果就是用户在不同系统间切换时,看到的是一种“似是而非”的统一,严重影响了产品的专业感。

为了解决这些问题,在数据域产品中,我们对“格式化”这件事进行了重新定义:

它不只是前端的 UI 渲染逻辑,而是指标定义的一部分。

我们不仅要定义“指标的计算口径”,更要定义“指标的展示语义”。

在 Galaxy(指标管理平台)定义好“数是怎么算出来的”之后,得数 DataHub 承担起了定义“数该怎么被看见”的职责。我们将这层逻辑抽象为 “展示语义”(Visualization Semantics)

  • 定类型(Type) :它是金额(Currency)、比率(Ratio),还是计数(Integer)?
  • 定单位(Unit) :默认是元 / 万元,还是 %、‰ (千分比) 或 bp (基点)?
  • 定精度(Precision) :小数位是固定保留两位,还是根据数值大小动态截断?
  • 定状态(State) :遇到 Null(空值)、Error(异常)或 Epsilon(极小值),展示层该如何兜底?
  • 定场景(Context) : 在空间局促的 KPI 卡片里(追求简洁),和需要财务核对的 明细表格里(追求精确),是否应用不同的渲染策略?

这是一种架构上的升维:

这些关于“长什么样”的逻辑,从此不再散落在业务代码的 ⁠if-else 里,而是被统一收拢到元数据系统中进行管理。

从这一刻起,数字格式化不再是前端模板里的一个小工具,而是成为了得数体系的一项基础设施——一层独立、可配置、可治理的数据领域服务

五、开始造轮子:站在Intl.NumberFormat 肩膀上

在着手开发之前,首先确立了一个原则:底层能力不造轮子,拥抱 Web 标准。

ECMAScript 402 标准中的 ⁠Intl.NumberFormat 已经为我们提供了一个极其强大的本地化格式化引擎。它的能力远超大多数手写的正则替换:

const n = 123456.789;


// 德国:点号分组、逗号小数
new Intl.NumberFormat('de-DE').format(n);
// "123.456,789"


// 印度:lakh/crore 分组
new Intl.NumberFormat('en-IN').format(n);
// "1,23,456.789"


// 日元货币:默认 0 位小数
new Intl.NumberFormat('ja-JP', {
  style'currency',
  currency'JPY',
}).format(n);
// "¥123,457"

它完美解决了那些最让前端头疼的国际化底层问题

  • 文化差异:全球各地的千分位、小数点、数字符号习惯;
  • 货币规则:不同币种的标准小数位(如日元 0 位,美元 2 位)和符号位置;
  • 多态支持:内置了百分比、紧凑缩写(Compact Notation)、科学计数法等模式;
  • 排版能力:通过 ⁠formatToParts() 将数字拆解为数组(整数部分、小数部分、符号等),为精细化排版提供了可能,比如在小数或百分比符号比整数小 2 个字号。

但是,它天然“不懂”业务:

  • 它不懂中文的习惯:无法直接实现“兆/京/垓”这种中文超大数缩写逻辑(标准最多支持到亿);
  • 它不懂得数的规范:不知道 ⁠*_rate 类型的指标在空值时要显示 ⁠"-",在极小值时要显示 ⁠< 0.01%;
  • 它不懂业务的上下文:不知道 GMV 在 KPI 卡片里要按“万元”展示,而在财务报表里必须精确到“厘”。

因此,我们的最终的架构策略是:

以 ⁠Intl.NumberFormat 为底层的“渲染引擎”;

在其之上搭建一层“数字领域层”(Domain Layer);

专门用于转译得物的业务规则和排版语义。

六、@dfx/number-format:构建数字的“领域层”

在得物数据前端的大仓里,我们把这层能力实现为一个独立包:@dfx/number-format。专门服务得数、智珠、智能运营等内部系统。

可以把它理解为三件事的组合:

  • 一个统一封装了Intl.NumberFormat的核心引擎(含缓存、解析);
  • 一套可以被配置的业务规则 / 预设 (preset)和插件系统
  • 一组面向 React 和 Node 环境的接口(组件 + Hook + FP 函数)。

6.1 声明式开发:把“数字长什么样”抽象成规则

在业务开发侧,最终期望的目标是:让开发者只关心“这是什么指标”,而不关心“它该怎么展示”。

场景一:基础格式化

我们可以直接使用组件,以声明式的方式调用:

import { NumberFormat } from '@dfx/number-format';
// 无论在哪个页面,价格都只需这样写
<NumberFormat
  value={price}
  options={{ style: 'currency', currency: 'CNY' }}
/>

场景二:基于语义的自动格式化

更进阶的用法是,在系统层面定义好“规则集”,业务组件只需传入指标名称:

import { NumberFormatProviderAutoMetricNumber } from '@dfx/number-format';
// 1. 定义规则:所有 "price.cny" 类型的指标,都遵循人民币格式
const rules = [
  { name'price.cny'options: { style'currency'currency'CNY' } },
];
// 2. 注入上下文
<NumberFormatProvider options={{ rules }}>
  {/* 3. 业务使用:完全解耦,只传语义 */}
  <AutoMetricNumber name="price.cny" value={price} />
</NumberFormatProvider>

收益显而易见:

  • 自动化:CNY 自动带两位小数,切到 JPY 自动变 0 位小数,逻辑完全由底层接管。
  • 一致性:全站所有 ⁠price.cny 的地方,千分位、符号位置严格统一,版面节奏自然对齐。

场景三:批量匹配比率指标

对于成百上千个转化率指标,我们不需要逐一定义,只需一条正则规则:

const rules = [
  // 匹配所有以 _rate 结尾的指标,自动转为百分比,保留2位小数
  { pattern/_rate$/ioptions: { style'percent'maximumFractionDigits2 } },
];


// 页面代码极其干净
<AutoMetricNumber name="conversion_rate" value={conversionRate} />

空值与异常值如何展示、极小值是否显示 < 0.01%、两位小数从哪里来,全部在规则 + 插件里处理。

6.2插件系统:收口那些“奇怪但常见”的需求

得物的业务场景中,存在大量 ⁠Intl 标准无法直接覆盖的边缘需求。我们将这些需求统一建模为插件(Format Plugin) ,介入格式化的生命周期:

  • 千分比 / 基点 (bps) :需在 ⁠pre-process 阶段将数值乘 1000 或 10000;
  • 中文大写金额:会计与合同场景的特殊转换;
  • 极小值兜底:设定阈值,当数值小于 ⁠0.0001 时,⁠post-process 阶段输出 ⁠< 0.01%;
  • 会计格式:负数使用括号 ⁠(1,234.56) 而非负号;
  • 动态精度策略:根据数值大小动态决定保留几位小数。

这套插件机制的意义在于“治理”:

它让“在某个业务仓库里偷偷写一个特殊正则”成为过去式。任何新的格式化需求,都必须以插件形式接入,由数据前端统一评审、沉淀,最终复用到全站。

6.3 全链路打通:从Galaxy元数据到UI渲染

最后,将这套系统与得数的中台能力打通(也是目前我们正在做的),形成了一条完整的渲染链路。

在Galaxy和得数的元数据里,指标本身已经有code、label、type 等字段。我们只需要再加一点约定:

{
  "metricCode": "gmv",
  "label": "GMV",
  "format": "currency", // 基础类型
  "meta": {
    "formatConfig": {"style": "currency", "maximumFractionDigits": 2}, 
    "category": ["交易指标"],
    "displayConfig": { "table": "precise", "card": "compact" }
  }
}

前端渲染时

  • 配置下发:页面加载时,获取指标的 ⁠Code 及元信息,转换为前端的 ⁠MetricFormatRule[]。
  • 上下文注入:在应用根节点通过 ⁠NumberFormatProvider 注入规则集。
  • 傻瓜式使用:表格、卡片、图表组件只需消费指标 ID:

这样一来就实现了真正的『数据驱动 UI』:

  • “指标长什么样”只在得数元数据管理中定义一次。
  • 修改展示规则(如调整精度),只需改配置,无需批量修改前端代码,更无需重新发版;
  • 前台业务与中台报表看到的同一个指标,格式永远是物理上的一致。

七、统一之后:重塑设计、工程与业务的价值

从我们的视角出发,这套统一数字格式方案的落地,带来的收益远不止“代码整洁”那么简单,它在三个层面产生了深远的影响。

7.1 对设计与排版:版面终于可控了

曾经,产品需要在每个页面的验收中反复纠结数字的对齐和精度。现在,设计规范只需在文档中定义一次:

  • 金额类:统一保留两位小数,强制右对齐,货币符号半角化;
  • 比率类:空值兜底为 ⁠—,异常值显示 ⁠N/A,极小值转译为 ⁠< 0.01%;
  • 缩写策略:全站统一遵循“中文万/亿、英文 k/M/B”的梯度逻辑。

开发同学不再是『古法手搓』,而是直接接入统一的 Preset 和插件。

无论是看板、详情页,还是导出的 Excel 报表,数字风格保持了像素级的一致。从视觉角度看,我们相当于给数字建立了一套Design Token:精度、单位、占位符都有了标准索引,让整个平台呈现出高度统一的专业感和节奏感

7.2 对工程质量与效率:从“搜索toFixed”到“改一处生效全站”

所有数字格式逻辑集中在一个领域层和配置中心。

一类指标的规则变更(精度、单位、空值策略)可以配置化调整。

规约可以进入 Lint(数字格式化限制) / Code Review:

  • 禁止直接在业务代码中new Intl.NumberFormat、toFixed拼字符串;
  • 鼓励所有数字展示全部走@dfx/number-format与 DataHub 规则。

这就是工程治理层面的价值:它将“依赖开发者自觉”的软约束,变成了 “可配置、可维护、可迭代”的系统能力

7.3 对业务和数据信任:从“怀疑这数有问题”到“愿意用来决策”

统一数字格式还有一个最隐性、却最核心的价值:重构数据信任。

  • 消除歧义:前台商品价格与中台报表金额完全一致,运营不再因为“显示精度不同”而去质疑数据准确性;
  • 精准语义:空值(Null)和零(Zero)被清晰区分,避免了因格式问题导致的错误决策。

在得物,严谨是需要刻在基因里的关键词

作为数据前端,我们深知:懂数据,并能用最专业的方式呈现数据,是这一岗位的核心素养。 当我们不仅能交付代码,还能通过精准的数字展示消除歧义、传递信任时,技术与业务之间就建立起了一种深层的默契。

这种对数字细节的极致追求,不仅是对用户体验的尊重,更是对得物“正品感”品牌心智在数字世界的延伸。

八、数字,是版面的『最后一公里』

回头看,得物数据前端在得数、智珠智能运营这条线做的,其实有两件事:

  • 给予数字应有的“设计尊严”

我们不再将数字视为模板字符串里一个随时可替换的“黑洞”,而是将其视作与字体、色彩、间距同等重要的排版元素,纳入统一的设计语言系统。

  • 为数字构建专属的“基础设施”

我们以 Web 标准⁠Intl.NumberFormat为引擎,以 ⁠@dfx/number-format为领域层,以得数 Schema 为配置中枢,将“数字如何展示”这一命题,从硬编码的泥潭中解放出来,转变为一种可配置、可治理、可演进的系统能力。

当你再回头看公司产品的各个页面——

  • 前台商品详情页的价格和券后价;
  • 社区里的阅读和点赞数字;
  • 智珠的策略看板;
  • 得数的自助分析报表。

你会发现它们拥有了一个共同的特征:

这些数字不再是各说各话的噪点,而是共同操着同一种精准、优雅的“排版语言”。

这就是我们做“数字格式化”的初衷:用技术的确定性,换取业务的专注力。

此时此刻,彼时彼刻,作为前端开发,你可以坚定地说出:我这展示的没问题,是数出问题了~

九、走出数据域:从内部门户到全业务

前面的篇幅,更多围绕着得数、智珠、智能运营等中后台系统展开。在这些场景下,数字格式化的核心用户是运营、分析师和算法同学——帮助他们透过数据看清业务走势。

但这套能力并不应局限于“后台”。它完全具备走出数据域的潜力,成为得物全业务线共享的一层关键基础设施

9.1 跨业务线:从“运营看板”走向“交易链路”

目前@dfx/number-format 虽然生长于数据土壤,但其解决的问题是通用的。未来,它可以自然地渗透到更广阔的业务场景:

  • 交易域: 统一商品详情页、结算页的价格、分期费率、税费展示逻辑;
  • 社区域: 标准化社区详情页中的风险分、阈值、命中率精度;
  • 客服域: 规范赔付金额、工单时长的展示口径。

这在工程上意味着一次“升维”:

  • 依赖升级:将 ⁠@dfx/number-format 从数据域私有包提升为公司级的基础依赖;
  • 开发范式:新业务在处理数字展示时,默认动作不再是“手写一个 format 函数”,而是查阅现有的 Preset 和插件;

最终带来的体验质变是:

用户无论是在得物 App的前台页面,还是在内部的各类管理系统中,看到的数字都拥有同一套呼吸感和视觉习惯。这种跨端的一致性,是品牌专业感最直接的体现。

9.2 跨地区与汇率:构建独立的“全球化价格能力”

随着得物业务的出海,多地区、多币种是绕不开的挑战。此时,数字领域层不仅要负责“长什么样”,更要承担起“展示策略”的职责。

我们可以设想一个**「全球化价格组件」**:

<MultiRegionPrice
  skuId="123456"
  basePriceCny={price}
  regions={[
    { locale: 'zh-CN', currency: 'CNY' },
    { locale: 'en-US', currency: 'USD' },
    { locale: 'ja-JP', currency: 'JPY' },
  ]}
/>

这个组件将负责两层逻辑的解耦:

  • 计算层: 负责实时汇率换算、多币种价格计算。
  • 展示策略层
    • 对照展示: 是否显示“原币种 + 本地参考价”(如 ⁠JP¥ 20,000 (≈ $135.00));
    • 文化适配: 是否遵循当地的“心理价位”策略(如 ⁠199.99vs199.99 vs ⁠200);
    • 格式渲染: 最终调用 ⁠@dfx/number-format,确保日元无小数、美元有小数、分节号正确。

从工程视角看,这避免了“汇率算对了、数字排版全乱了”的尴尬;从产品视角看,这正是得物沉淀“出海技术套件”的重要一步(比如国际智能运营)。

9.3 时间与日期:用同样的思路,去做第二条“格式化主线”

数字是一类挑战,“时间”则是另一类。跨时区转换、相对时间(此刻)、不同地区的日期写法(⁠DD/MM vs ⁠MM/DD),其复杂度丝毫不亚于数字。

既然浏览器提供了Intl.DateTimeFormat,我们完全可以复刻数字领域层的成功路径,再赢一次:

  • 基础设施:构建 ⁠@dfx/date-format,统一封装时间格式化与相对时间逻辑;
  • 预设管理:定义标准 Preset(如“运营报表用 ⁠YYYY-MM-DD”、“C 端动态用 ⁠今天 12:30”);
  • 组件化:提供 ⁠ 和 ⁠ 组件;
  • 元数据打通:在得数的元数据中,针对时间型的维度也同样进行配置。

这样,数据前端的展示层就拥有了两条清晰的主线:

  • 数值主线:⁠Intl.NumberFormat → ⁠@dfx/number-format → 数字展示规范
  • 时间主线:⁠Intl.DateTimeFormat → ⁠@dfx/date-format → 时间展示规范

未来,我们甚至可以抽象出更上层的 ⁠@dfx/data-format,让“任何字段该怎么展示”,完全由 Schema 配置 + 领域层规则共同决定。

十、最后:把“展示”这件事做到极致

如果只看代码,我们做的事情似乎很简单:

把 ⁠Intl 标准用到极致,封装了一层领域库,并接通了元数据配置,写了个工具库。

但如果从产品体验和工程演进的角度看,我们其实完成了一次基础设施的升级

把前端开发中最琐碎、最容易被忽视的“数字与时间展示”,从“到处粘贴的小工具”,升级成了“有统一规范、有可观测性、有迭代空间的系统能力”。

现在,这套能力已经让得数、智珠、智能运营的部分模块长出了统一的“数字气质”。

未来,期望它能够走出数据平台,支撑得物更广泛的业务场景,让同一件商品,在不同地区、不同语言、不同终端上,既算得对,又看得顺。

参考资料:

往期回顾

  1. 一文解析得物自建 Redis 最新技术演进

  2. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

  3. RN与hawk碰撞的火花之C++异常捕获|得物技术

  4. 得物TiDB升级实践

  5. 得物管理类目配置线上化:从业务痛点到技术实现

文 /柏锐

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Vue 跨组件通信底层:provide/inject 原理与实战指南

一、provide/inject 的核心设计思想

provide/inject 是 Vue 实现依赖注入(Dependency Injection)的核心 API,其设计目标是:

  • 解决跨层级组件通信问题(props 逐级透传的痛点)
  • 实现组件间的松耦合(后代组件无需知道依赖的具体来源)
  • 维持响应式数据传递

其核心机制是:每个组件实例都维护一个 provides 对象,子组件的 provides 原型链指向父组件的 provides,形成一个原型链查找机制

二、底层实现原理的代码模拟

为了让你直观理解,我将用 JavaScript 模拟 Vue 组件实例的 provides 链和 provide/inject 方法的实现。

1. 组件实例的基础结构

// 模拟 Vue 组件实例的构造函数
class ComponentInstance {
  constructor(parent) {
    this.parent = parent; // 父组件实例引用
    this.props = {};
    this.data = {};
    
    // 核心:构建 provides 原型链
    // 如果有父组件,当前组件的 provides 继承自父组件的 provides
    // 如果没有父组件(根组件),创建一个空对象
    this.provides = parent ? Object.create(parent.provides) : Object.create(null);
  }

  // 实现 provide 方法
  provide(key, value) {
    // 将提供的键值对存储到当前组件的 provides 对象上
    this.provides[key] = value;
  }

  // 实现 inject 方法
  inject(key, defaultValue = undefined) {
    // 从当前组件的 provides 开始查找
    let provides = this.provides;
    
    // 沿着原型链向上查找(直到根组件)
    while (provides) {
      if (Object.prototype.hasOwnProperty.call(provides, key)) {
        // 找到则返回对应的值
        return provides[key];
      }
      // 找不到则继续向上查找父组件的 provides
      provides = Object.getPrototypeOf(provides);
    }
    
    // 如果最终没找到,返回默认值
    return typeof defaultValue === 'function' ? defaultValue() : defaultValue;
  }
}

2. 原型链查找机制的验证

// 创建根组件实例
const root = new ComponentInstance(null);
// 根组件提供数据
root.provide('theme', 'dark');
root.provide('user', { name: 'admin' });

// 创建子组件实例(父组件为 root)
const child = new ComponentInstance(root);
// 子组件提供自己的数据
child.provide('lang', 'zh-CN');

// 创建孙组件实例(父组件为 child)
const grandChild = new ComponentInstance(child);

// 孙组件注入数据
console.log(grandChild.inject('theme')); // 'dark' (从根组件找到)
console.log(grandChild.inject('lang'));  // 'zh-CN'(从子组件找到)
console.log(grandChild.inject('user'));  // { name: 'admin' }(从根组件找到)
console.log(grandChild.inject('age', 18)); // 18(使用默认值)
console.log(grandChild.inject('gender', () => 'male')); // 'male'(函数默认值)

3. 响应式数据的传递原理

provide/inject 本身不处理响应式,它只是传递数据引用。响应式由 Vue 的响应式系统(ref/reactive)保证:

// 模拟 Vue 的 ref 实现
class Ref {
  constructor(value) {
    this._value = value;
  }
  
  get value() {
    console.log('触发依赖收集');
    return this._value;
  }
  
  set value(newValue) {
    this._value = newValue;
    console.log('触发更新');
  }
}

// 创建响应式数据
const themeRef = new Ref('light');

// 根组件提供响应式数据
root.provide('theme', themeRef);

// 孙组件获取
const injectedTheme = grandChild.inject('theme');
console.log(injectedTheme.value); // 'light'(触发依赖收集)

// 修改值会触发响应式更新
injectedTheme.value = 'dark'; // 触发更新

三、Vue 源码中的真实实现(简化版)

下面是从 Vue 3 源码中提取的核心逻辑,展示真实的实现方式:

1. 组件实例的 provides 初始化

// 源码位置:packages/runtime-core/src/component.ts
export function createComponentInstance(vnode, parent, suspense) {
  const instance = {
    vnode,
    parent,
    provides: parent ? Object.create(parent.provides) : Object.create(appContext.provides),
    // ...其他属性
  };
  return instance;
}

2. provide 函数的实现

// 源码位置:packages/runtime-core/src/apiInject.ts
export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
  if (!currentInstance) {
    if (__DEV__) {
      warn(`provide() can only be used inside setup().`);
    }
    return;
  }
  // 获取当前组件的 provides 对象
  const provides = currentInstance.provides;
  // 获取父组件的 provides 对象(原型)
  const parentProvides =
    currentInstance.parent && currentInstance.parent.provides;

  // 如果是首次提供该 key,或者 key 的值发生变化
  if (parentProvides === provides) {
    // 继承父组件的 provides 并创建新对象
    provides = currentInstance.provides = Object.create(parentProvides);
  }
  // 将值存入 provides
  provides[key as string] = value;
}

3. inject 函数的实现

// 源码位置:packages/runtime-core/src/apiInject.ts
export function inject<T>(
  key: InjectionKey<T> | string | number,
  defaultValue?: T | (() => T),
  treatDefaultAsFactory = false
): T | undefined {
  // 获取当前组件实例
  const instance = currentInstance || currentRenderingInstance;
  
  if (instance) {
    // 优先从组件自身的 provides 查找,否则从 appContext 查找
    const provides = instance.provides || instance.appContext.provides;
    
    if (provides && (key as string | symbol) in provides) {
      // 找到则返回值
      return provides[key as string];
    } 
    // 处理默认值
    else if (arguments.length > 1) {
      return treatDefaultAsFactory && typeof defaultValue === 'function'
        ? (defaultValue as () => T)()
        : defaultValue;
    } 
    // 开发环境警告
    else if (__DEV__) {
      warn(`injection "${String(key)}" not found.`);
    }
  }
}

四、实际项目中的高级应用(结合原理)

理解原理后,我们可以更灵活地使用 provide/inject

场景:创建可复用的组件上下文

// 创建上下文的组合函数
import { provide, inject, reactive, readonly } from 'vue';

// 使用 Symbol 作为唯一键(避免命名冲突)
const TABLE_CONTEXT_KEY = Symbol('table-context');

// 父组件提供上下文
export function useTableProvider(props) {
  const tableState = reactive({
    data: props.data,
    loading: false,
    pagination: {
      page: 1,
      pageSize: 10
    },
    // 方法
    fetchData: () => {
      tableState.loading = true;
      // 实际请求逻辑...
    },
    changePage: (page) => {
      tableState.pagination.page = page;
      tableState.fetchData();
    }
  });

  // 提供只读的上下文(防止子组件修改)
  provide(TABLE_CONTEXT_KEY, readonly(tableState));
  
  return tableState;
}

// 子组件注入上下文
export function useTableInject() {
  const context = inject(TABLE_CONTEXT_KEY, () => {
    throw new Error('useTableInject must be used within a Table component');
  });
  
  return context;
}

组件中使用

<!-- Table.vue(父组件) -->
<script setup>
import { defineProps } from 'vue';
import { useTableProvider } from './useTable';

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  }
});

const tableState = useTableProvider(props);
</script>

<!-- TablePagination.vue(子组件) -->
<script setup>
import { useTableInject } from './useTable';

const tableContext = useTableInject();

// 使用上下文数据
console.log(tableContext.pagination.page);
// 调用上下文方法
const handlePageChange = (page) => {
  tableContext.changePage(page);
};
</script>

总结

provide/inject 的底层原理核心要点:

  1. 原型链继承:每个组件实例的 provides 对象通过 Object.create() 继承自父组件的 provides,形成链式查找结构。
  2. 查找机制inject 时会从当前组件的 provides 开始,沿着原型链向上查找,直到找到匹配的键或到达根组件。
  3. 响应式传递provide/inject 仅传递数据引用,响应式由 Vue 的响应式系统(ref/reactive)保证。
  4. 作用域隔离:每个组件的 provides 是独立的,但通过原型链共享父组件的提供值,实现隔离与共享的平衡。

浏览器渲染原理

在日常面试中,我们经常会遇到这样一个问题: “在浏览器输入 URL 后,页面是如何展现出来的?”
这个看似简单的问题,其实背后涉及浏览器渲染、网络请求、解析执行等一系列复杂流程。本文将聚焦于 浏览器渲染原理,带你梳完整过程(本文主要讲解渲染进程的工作,网络进程这里不详细解释)。


整个过程可以分为两个关键步骤:

  1. 浏览器解析 URL 并发起请求 —— 浏览器首先会解析你在地址栏输入的 URL,经过 DNS 查询、TCP/TLS 连接建立等步骤,将请求发送到服务器。
  2. 解析和渲染返回结果 —— 浏览器收到服务器返回的 HTML、CSS、JS 等资源后,会依次解析、构建 DOM、CSSOM等,最终通过渲染引擎将页面呈现出来。

浏览器解析 URL

参考资料:Chrome 官方博客:导航过程中会发生什么

第 1 步:处理输入

当用户在地址栏输入内容时,浏览器进程中的 界面线程 会立即启动,判断用户输入的是搜索关键词还是网址,并据此决定:要么将请求定向到搜索引擎,要么直接访问指定网站。

第 2 步:开始导航

用户按下 Enter 键后,UI 线程会发起网络请求以获取网站内容。此时,标签页角落会显示加载旋转图标,提示页面正在加载。

网络线程会按照网络协议处理请求,包括 DNS 查询 和建立 TLS 连接(如果是 HTTPS)。

若服务器返回 HTTP 301 等重定向响应,网络线程会通知 UI 线程,并根据重定向地址发起新的请求,完成导航流程。

第 3 步:读取响应

网络线程开始接收服务器返回的响应数据(载荷),并查看前几个字节以判断内容类型。虽然响应头中的 Content-Type 应指明数据类型,但由于可能缺失或错误,浏览器会执行 MIME 类型嗅探

  • 如果响应是 HTML,数据会传递给渲染进程进行渲染。
  • 如果是 ZIP 或其他文件,则交由下载管理器处理。

同时,浏览器会进行安全检查:

第 4 步:查找渲染进程

完成安全检查后,如果网络线程确认导航可继续,它会通知界面线程,界面线程随后查找或启动合适的 渲染进程,以准备渲染网页。

优化机制:由于网络请求可能需要数百毫秒,浏览器会在第 2 步发起请求时,就尝试并行启动渲染进程。这意味着当数据到达时,渲染进程通常已处于待机状态。


解析和渲染返回结果

当数据和渲染进程都准备就绪后,浏览器进程会向渲染进程发送一次 提交导航(Commit Navigation) 的 IPC 信号,并将来自服务器的 HTML 数据流交给渲染进程。
渲染进程在接收到该指令后,会将“解析 HTML”的工作加入自身 渲染主线程(Main Thread) 的消息队列。

在事件循环机制的调度下,渲染主线程从队列中取出该任务并开始执行,正式进入渲染流程。

整体的渲染步骤如下图所示:

image.png


第 1 步:解析 HTML 与构建 DOM / CSSOM

服务器返回的 HTML 本质上是 字节流,浏览器会将其解码为 字符串,再进一步解析成可操作的数据结构——这就是我们熟悉的 DOM 树。同样,CSS 也会被解析成对应的 CSSOM 树

在解析过程中:

  • 遇到 HTML 标签 ⇒ 主线程将其转换为 DOM 节点并加入 DOM 树中(树的根节点是 document)。
  • 遇到 CSS ⇒ 根据层叠规则解析生成 CSSOM 树(根节点为 StyleSheetList)。

在浏览器解析 HTML 时,即使遇到 <link><style> 标签,主线程也会继续向下解析 HTML,而不会被阻塞。这是因为浏览器会启动一个 预解析线程(Preload Scanner),提前发现并下载 CSS 等外部资源。 CSS 的下载和准备工作在这个预解析线程中完成,不会占用主线程,所以CSS 不会阻塞 HTML 解析。只有当 HTML 构建完成、CSS 下载完毕后,浏览器才会利用 CSS 生成 CSSOM 树,与 DOM 树结合形成 渲染树,用于后续页面绘制。 通过这种方式,浏览器实现了 DOM 构建与 CSS 下载的并行,提高了解析效率,同时保证页面渲染的正确性。

当浏览器解析到 <script> 标签时,会暂停 DOM 的构建,等待外部脚本下载完成,并在主线程中完成解析与执行。
原因在于:JavaScript 有能力通过 document.write、DOM API 等方式直接修改正在构建的 DOM 结构
如果不暂停解析,一边构建 DOM、一边执行 JS,就可能导致 DOM 状态出现不一致。 因此,为了保证 DOM 构建过程的正确性和可预测性,浏览器必须中断 HTML 解析,优先执行脚本。这就是 JavaScript 会阻塞 DOM 构建的根本原因

在解析结束之后,会得到:DOM 树CSSOM 树


第 2 步:样式计算

主线程会遍历构建完成的 DOM 树,并为树中每一个节点计算出它的最终样式,这个过程称为 样式计算(Computed Style)
样式计算结束后,我们获得的是一棵“附带最终样式信息的 DOM 树”,为后续布局阶段提供基础。

关于样式计算的具体细节,可以参考我另一篇文章:样式计算


第 3 步:布局

布局(Layout)是浏览器确定页面中每个元素几何位置和大小的过程。主线程会遍历 DOM 树,结合计算后的样式信息,生成包含 坐标、边界框尺寸 等几何信息的 布局树(Layout Tree)

布局树与 DOM 树的结构类似,但只包含 实际在页面中显示的内容

  • 应用 display: none 的元素不会出现在布局树中,因为它们没有几何信息。
  • 应用 visibility: hidden 的元素仍然会在布局树中保留位置和尺寸信息。
  • 伪元素(如 p::before { content: "Hi!"; })虽然不在 DOM 树中,但具有几何信息,因此会出现在布局树中。
  • 其他情况如匿名块盒、匿名行盒等,也会导致布局树与 DOM 树不完全一一对应。

因此,大多数情况下,DOM 树和布局树并非严格对应,布局树只关注用于渲染的几何信息,而非完整的 DOM 结构。


第 4 步:分层

为了确定哪些元素需要位于哪些层,浏览器主线程会遍历 布局树 并生成 层树(Layer Tree) 。在 Chrome DevTools 的性能面板中,这一阶段通常显示为“更新层树”。

分层的核心目的是 提升渲染效率。浏览器会根据提示提前为元素分配独立层,优化动画或滚动等操作的渲染效率。
将页面划分为独立的层后,如果某个层的内容发生变化,浏览器只需要重绘该层,而无需重新渲染整个页面。

如下图所示:
image.png

某些 CSS 属性和布局特性会自动触发分层,例如:

  • transformopacityfilter
  • position: fixed / sticky
  • overflow: scroll / auto
  • z-index / 堆叠上下文

此外,如果希望浏览器为某些元素创建独立层,可以使用 will-change 属性向浏览器发出提示:

.menu {
  will-change: transform;
}
补充说明
  • 分层不会改变 DOM 结构或布局,只是为渲染过程划分优化单位。
  • 虽然分层可以提高性能,但过度分层会增加 GPU 内存占用,因此应谨慎使用 will-change,仅对频繁变化的元素设置。

第 5 步:绘制(生成绘制指令)& 分块

  • 绘制阶段(主线程)

    • 主线程会为每个独立层生成对应的 绘制指令集(Paint Commands),用于描述这一层应该如何渲染,包括颜色、边框、文字、图片等。
    • 绘制指令通常以矢量或命令序列的形式存在,而不是直接生成位图,这样可以提高渲染效率和重绘灵活性。
    • 绘制完成后,主线程将每个层的绘制信息 提交给合成线程(Compositor Thread),主线程任务结束。
  • 分块阶段(合成线程)

    • 合成线程为了优化 GPU 渲染,会将每个图层划分为更小的 绘制块(Tiles)

    • 分块的优势:

      • 局部更新:只重绘变化的块,减少不必要的 GPU 负担。
      • 并行处理:可从线程池中获取多个线程同时处理不同块,提高处理速度。
      • 内存优化:分块渲染可以让 GPU 更好地管理显存,避免一次性加载大图层导致内存峰值过高。

image.png


第 6 步:光栅化

在渲染流程中,合成线程会将页面的图层信息(Layer / Tile)交给 GPU 进程,以高效完成 光栅化

光栅化的本质是将矢量图形、文本、图层等内容转化为 像素位图(bitmap) ,方便最终在屏幕上显示。

  • GPU 进程通常会开启多个线程并行处理不同的瓦片(Tile),提升渲染效率。
  • 浏览器会优先处理靠近视口(Viewport)区域的块,以确保用户能尽快看到可视内容,提高 首屏渲染性能(FCP / First Contentful Paint)
  • 光栅化完成后,每个图层或瓦片都会生成一块位图,为最终的 合成(Compositing) 做准备。

第 7 步:页面绘制

在光栅化完成后,合成线程(Compositor Thread) 会拿到每个图层(Layer)和瓦片(Tile)的位图,并生成对应的 绘制指引(quad)

  • Quad 的作用:指示每块位图在屏幕上的显示位置,同时包含旋转、缩放、透明度等变换信息。
  • 高效变换:变换(transform)操作发生在合成线程,而不需要经过渲染主线程。这就是 CSS transformopacity 能够实现 GPU 加速、渲染性能高的原因。

合成线程生成 quad 后,会将其提交给 GPU 进程。GPU 进程通过系统调用与 GPU 硬件交互,完成最终的像素合成,将页面内容呈现在屏幕上。

补充总结:

  • 渲染主线程负责构建 DOM、CSSOM、渲染树、布局和绘制图层内容;
  • 合成线程只负责图层组合和变换操作;
  • GPU 负责将最终像素输出到显示器,实现高效渲染。

流程总结:

第一步 浏览器解析 URL:

image.png

第二步:解析和渲染:

image.png

前端跨页面通讯终极指南①:postMessage 用法全解析

前言

公司后台项目微前端是使用iframe方式,跨页面通讯postMessage就需要我们必须掌握。比如,弹窗页与父页面的数据同步、多个标签页间的状态共享、嵌入的iframe与宿主页面的交互等。

本文将从基础原理到实战场景,全面解析 postMessage 的用法,帮你轻松搞定各类跨页面通讯需求。

先看一张总结图,了解通讯的几种场景:

image.png

1. 什么是postMessage

postMessage 是用于在不同源的窗口、iframe、Worker之间安全地传递数据。打破了浏览器的“同源策略”限制,让跨源页面之间能够实现数据传递和事件通信。

postMessage 的使用逻辑非常简单,分为“发送数据”和“接收数据”两个步骤,本质是基于“消息发布-订阅”模式。

1.1 发送数据:targetWindow.postMessage()

发送数据的操作由“发送方窗口”调用 postMessage 方法完成,该方法挂载在窗口对象(window)上,语法如下:

targetWindow.postMessage(message, targetOrigin, [transfer]);

参数的含义和使用要点:

  1. targetWindow(必选) :接收消息的目标窗口对象,即“谁要接收这个消息”。常见的获取方式有: iframe的contentWindow:document.getElementById('iframeId').contentWindow(父页向子页发消息);
  2. window.opener:通过window.open()打开的新窗口,其内部通过opener获取父窗口(子页向父页发消息);
  3. window.parent:iframe内部通过parent获取父窗口(子页向父页发消息);
  4. message(必选) :要发送的数据,可以是字符串、数字、对象、数组等几乎所有类型。但需要注意: 数据会被隐式序列化为JSON格式传递,接收方需要自行解析(部分浏览器会自动反序列化,但建议显式处理以兼容);
  5. 避免发送过大的数据(如超过10MB),可能导致性能问题或传输失败。
  6. targetOrigin(必选) :目标窗口的“源”(协议+域名+端口),用于安全校验,即“只有该源的窗口才能接收消息”。取值规则: 具体源:如'https://www.example.com:8080',仅该源的窗口能接收;
  7. 通配符'*':允许所有源接收消息(极度危险,仅开发测试时临时使用);
  8. 空字符串'':仅适用于发送给file://协议的窗口(实际开发中极少用)。
  9. transfer(可选) :是一个包含可转移对象的数组,这些对象的所有权会从发送方转移到接收方,发送方后续无法再使用这些对象(如ArrayBuffer)。该参数使用场景较少,一般无需关注。

1.2 接收数据:监听 message 事件

接收数据的窗口需要监听自身的message事件,当有其他窗口通过postMessage发送消息时,该事件会被触发。语法如下:

window.addEventListener('message', (event) => {
  // 处理接收的消息
}, false);

核心是解析事件对象event的三个关键属性:

  1. event.data:发送方传递的消息数据(即postMessage的第一个参数);
  2. event.origin:发送消息的窗口的“源”(协议+域名+端口),用于校验发送方身份;
  3. event.source:发送消息的窗口对象,可用于向发送方回传数据。

接收方必须通过event.origin校验发送方的合法性,避免接收恶意源发送的消息,这是与targetOrigin对应的双重安全保障。

2. 实战案例:同页面iframe通讯

父页面嵌入iframe,两者需要实现数据交互(如父页向子页传用户信息,子页向父页传操作结果)。

2.1 父->子

发送端(父页面):

// 发送到指定 iframe
iframe1.contentWindow.postMessage({
    from: 'Parent (父页面)',
    message: '消息内容'
}, '*');

接收端(子页面):

// 在 Vue 组件中监听消息
window.addEventListener('message', function(event) {
});

接收的数据:

image.png

2.2 子->父

发送端(子页面):

// 从子页面发送消息到父页面
window.parent.postMessage({
    target: 'parent',
    from: 'Home (iframe1)',
    message: '消息内容'
}, '*');

接收端(父页面):

// 父页面监听消息
window.addEventListener('message', function(event) {
    if (event.data.target === 'parent') {
        console.log('父页面处理消息:', event.data.message);
        // 在页面上显示日志
    }
});

父接收数据:

image.png

2.3 兄弟

对于兄弟页面,无法通过postMessage直接通讯,只能通过父页面进行中转,可以根据特定的类型,让父元素进行转发。具体不作介绍。

3. 实战案例:window.open打开方式通讯

通过window.open()打开新窗口后,父页可通过返回的窗口对象发送消息,子页通过window.opener获取父页窗口。

3.1 父->子

发送端(父页面):

const child = window.open(url)
child.contentWindow.postMessage({
    from: 'Parent (父页面)',
    message: '消息内容'
}, '*');

接收端(子页面):

window.addEventListener('message', function(event) {
});

3.2 子->父

发送端(子页面):

// 从子页面发送消息到父页面
window.opener.postMessage({
    message: '消息内容'
}, '*');

接收端(父页面):

// 父页面监听消息
window.addEventListener('message', function(event) {
});

3.3 兄弟

对于兄弟页面,无法通过句柄直接进行通讯,因为对于各自单独打开的页面,没法获取到窗口的句柄,也就没法进行消息的发送和监听。只能通过父页面进行中转,可以根据特定的类型,让父元素进行转发。具体不作介绍。

总结

最后总结一下:postMessage通过targetWindow.postMessage发送数据,其中targetWindow可以是iframe 的 contentWindow 属性或者执行window.open返回的窗口对象,通过监听message事件接收消息。

下一篇,我们将了解前端跨页面通讯的其他方案Broadcast Channel

如有错误,请指正O^O!

2025 年值得关注的 CSS 新属性与功能

截至 2025 年 11 月,CSS 标准(主要由 W3C 和 WHATWG 推进)在近年新增了多个实用且强大的 CSS 属性和功能。虽然这些并非全部在“2025 年当年”首次引入,但许多已在 2024–2025 年间进入主流浏览器稳定支持阶段,成为现代前端开发的常用工具。

以下是 2025 年开发者应重点关注的新增或广泛支持的 CSS 属性与特性,适合用于技术文章、教程或项目实践:


🎨 2025 年值得关注的 CSS 新属性与功能

1. :has() —— “父选择器”终于来了!

状态:Chrome 105+、Safari 15.4+、Firefox 121+(2024 年全面支持)

/* 当 .card 内包含 .image 时,为其添加阴影 */
.card:has(.image) {
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}

/* 表单验证:输入无效时高亮标签 */
label:has(input:invalid) {
  color: red;
}

意义:打破“只能向下选择”的限制,实现基于子元素状态的父级样式控制。


2. @layer —— 样式层管理

解决痛点:第三方库(如 Bootstrap)与自定义样式的优先级冲突

@layer reset, base, components, utilities;

@layer base {
  body { font-family: sans-serif; }
}

@layer components {
  .button { padding: 8px; }
}
  • 层内规则遵循正常优先级;
  • 层之间按 @layer 声明顺序决定优先级(后声明 > 先声明);
  • 未命名的样式默认在最高优先级层。

适用场景:大型项目、设计系统、组件库开发。


3. container-type + 容器查询(Container Queries)

替代媒体查询的局部响应式方案

<div class="card">
  <h2>标题</h2>
  <p>内容...</p>
</div>
.card {
  container-type: inline-size; /* 创建容器上下文 */
}

/* 当卡片宽度 < 400px 时隐藏标题 */
@container (max-width: 400px) {
  .card h2 { display: none; }
}

优势:组件可独立响应自身尺寸,不再依赖视口宽度,真正实现“组件级响应式”。


4. scrollbar-gutter —— 预留滚动条空间

解决页面因滚动条显示/隐藏导致的布局抖动

html {
  scrollbar-gutter: stable;
}
  • stable:始终预留滚动条位置;
  • stable both-edges:双侧预留(适用于双向滚动)。

用户体验提升:避免内容在 macOS(自动隐藏滚动条)与 Windows 间跳动。


5. accent-color —— 统一表单控件主色

一键定制复选框、单选按钮、范围滑块等颜色

input[type="checkbox"] {
  accent-color: #6366f1; /* Tailwind indigo-500 */
}

input[type="range"] {
  accent-color: tomato;
}

无需复杂 hack,原生支持跨浏览器一致的 UI 主题色。


6. color-mix() —— 原生颜色混合函数

无需 Sass/PostCSS,直接在 CSS 中混合颜色

.button {
  background-color: color-mix(in srgb, blue 70%, white 30%);
  border-color: color-mix(in srgb, var(--primary) 50%, transparent);
}

支持色彩空间:srgblchoklch 等,配合现代调色更精准。


7. @property(CSS Houdini 自定义属性类型)

让 CSS 自定义属性具备类型、初始值和继承性

@property --gradient-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: true;
}

.gradient {
  --gradient-angle: 45deg;
  background: linear-gradient(var(--gradient-angle), blue, purple);
  transition: --gradient-angle 0.3s; /* ✅ 现在可以动画了! */
}

突破限制:以前 transition: --my-var 无效,现在可对自定义属性做插值动画!


8. font-palette —— 控制彩色字体调色板

适用于支持 COLRv1 的彩色字体(如 Emoji、图标字体)

.icon {
  font-family: "ColorFont";
  font-palette: --my-palette;
}

@font-palette-values --my-palette {
  font-family: "ColorFont";
  base-palette: 1;
  override-colors:
    0 #ff6b6b,
    1 #4ecdc4;
}

设计师友好:动态切换图标主题色,无需多套 SVG。


9. view-timelineanimation-timeline(滚动驱动动画)

用滚动代替时间轴触发动画

.hero {
  animation: fadeUp linear;
  animation-timeline: view();
}

@keyframes fadeUp {
  0% { opacity: 0; transform: translateY(50px); }
  100% { opacity: 1; transform: translateY(0); }
}
  • view():基于元素在视口中的可见比例;
  • 还支持 scroll() 时间线。

无需 JS 实现视差、渐显、进度条等交互动效。


10. :popover-open 伪类 + <dialog> 增强

原生弹窗交互更强大

<div popover id="tooltip">提示内容</div>
<button popovertarget="tooltip">悬停</button>
[popover]:not(:popover-open) {
  display: none;
}

[popover]:popover-open {
  animation: fadeIn 0.2s;
}

语义化 + 可访问性:浏览器自动处理焦点管理、ESC 关闭、点击外部关闭。


🌐 浏览器支持速查(2025 年 11 月)

特性 Chrome Firefox Safari Edge
:has() ✅ 105+ ✅ 121+ ✅ 15.4+
容器查询 ✅ 105+ ✅ 110+ ✅ 16+
@layer ✅ 99+ ✅ 97+ ✅ 15.4+
scrollbar-gutter ✅ 94+ ✅ 97+ ✅ 15.4+
accent-color ✅ 93+ ✅ 92+ ✅ 15.4+
color-mix() ✅ 111+ ✅ 113+ ✅ 16.4+
@property ✅ 78+ ⚠️ 部分 ✅ 16.4+
滚动驱动动画 ✅ 115+ ✅ 118+ ✅ 16.4+

💡 大部分特性已进入 Can I Use 的“安全使用”区间,生产环境可放心采用。


✅ 结语

2025 年的 CSS 不再只是“装饰语言”,而是具备逻辑能力、响应能力、动画能力和工程化能力的现代样式系统。从 :has() 到容器查询,从 @layer 到滚动驱动动画,这些新特性正在重塑我们构建 Web 界面的方式。

ECMAScript 2025 正式发布:10 个让你眼前一亮的 JavaScript 新特性!


🚀 ECMAScript 2025 正式发布:10 个让你眼前一亮的 JavaScript 新特性!

2025 年 6 月 26 日,ECMA 国际正式批准 ECMAScript 2025(第 16 版) 规范。作为 JavaScript 演进的重要里程碑,ES2025 引入了多项实用且强大的新特性,涵盖异步处理、集合操作、模块加载、正则表达式等多个核心领域。

本文将带你快速掌握 ES2025 最值得关注的 10 个新 API 和语法特性,助你写出更简洁、高效、可维护的现代 JavaScript 代码!


1️⃣ Promise.try():统一同步与异步错误处理

问题背景

以往封装一个可能抛错的同步函数时,常需用 Promise.resolve().then(fn),但这会引入不必要的微任务延迟,且异常捕获逻辑割裂。

新方案

function mightThrow() {
  if (Math.random() > 0.5) throw new Error("Oops");
  return "Success";
}

Promise.try(mightThrow)
  .then(console.log)
  .catch(console.error);

优势

  • 同步错误自动转为 Promise reject;
  • 避免微任务延迟,执行更及时;
  • 统一 .catch 处理所有异常。

✅ 适用于封装第三方同步 API,提升错误调试效率。


2️⃣ Set 集合运算方法:告别手写交并差!

ES2025 为 Set 新增 7 个原生方法,支持标准集合论操作:

const A = new Set([1, 2, 3]);
const B = new Set([3, 4, 5]);

console.log(A.union(B));              // Set {1, 2, 3, 4, 5}
console.log(A.intersection(B));       // Set {3}
console.log(A.difference(B));         // Set {1, 2}
console.log(A.symmetricDifference(B)); // Set {1, 2, 4, 5}

console.log(A.isSubsetOf(B));         // false
console.log(A.isSupersetOf(B));       // false
console.log(A.isDisjointFrom(B));     // false

💡 这些方法让 JS 的集合操作终于媲美 Python!适用于权限管理、标签筛选、数据去重等场景。


3️⃣ 原生 JSON 模块导入(Import Attributes)

无需 fetch,直接把 .json 文件当作模块导入!

静态导入

import config from './config.json' with { type: 'json' };
console.log(config.apiKey);

动态导入

const { default: data } = await import('./data.json', {
  with: { type: 'json' }
});

🔒 浏览器通过 with { type: 'json' } 显式声明资源类型,提升安全性与可读性。


4️⃣ 同步迭代器链式操作(Iterator Helpers)

现在所有可迭代对象(如数组、Set、Map、字符串)的迭代器都支持链式方法:

const arr = ['a', '', 'b', '', 'c', 'd'];
const result = arr.values()
  .filter(x => x)
  .map(x => x.toUpperCase())
  .toArray();

console.log(result); // ['A', 'B', 'C', 'D']

支持的方法包括:

  • .filter(), .map(), .flatMap()
  • .some(), .every(), .find()
  • .reduce(), .forEach()
  • .drop(n), .take(n), .toArray()

⚡ 惰性求值 + 内存友好,特别适合处理大型数据流或生成器。


5️⃣ RegExp.escape():安全转义正则字符串

动态构建正则时,再也不用手动转义特殊字符!

const raw = "(foo)*+?";
const escaped = RegExp.escape(raw);
console.log(escaped); // "\(foo\)\*\+\?"

🛡️ 有效防止正则注入漏洞,替代自定义 escapeRegExp 函数。


6️⃣ 正则表达式内联标志(局部修饰符)

可在正则内部局部启用/禁用标志,如 i(忽略大小写):

const re = /^x(?i:HELLO)x$/;
console.log(re.test('xHELLOx')); // true
console.log(re.test('xhellox')); // true
console.log(re.test('XhelloX')); // false ← 外围仍区分大小写

🎯 精准控制匹配行为,避免全局标志污染。


7️⃣ 重复命名捕获组

不同分支可复用相同捕获组名,简化日期、ID 等多格式解析:

const DATE_REGEX = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})
  |(?<month>\d{2})/(?<day>\d{2})/(?<year>\d{4})$/;

const match = '2025-11-25'.match(DATE_REGEX);
const { year, month, day } = match.groups; // 直接解构,无需判断分支

🧩 极大简化多格式文本解析逻辑。


8️⃣ 延迟模块加载(defer import

预加载但延迟执行,优化首屏性能:

defer import { heavyModule } from './heavy.js';

button.onclick = async () => {
  await heavyModule.run(); // 此时模块已加载完毕,仅执行代码
};

vs 动态 import()

特性 defer import import()
加载时机 声明时并行加载 调用时加载
执行时机 首次访问导出时 加载后立即执行
适用场景 高频交互模块(弹窗、编辑器) 路由级懒加载

🚀 实现“预加载 + 按需执行”的完美平衡。


9️⃣ BigInt 增强支持

虽然 BigInt 在 ES2020 已引入,但 ES2025 进一步优化其与标准库的兼容性,例如:

  • 支持 BigInt 作为 Array.prototype.sort 的比较值;
  • 更完善的 JSON.stringify 行为(需配合 reviver 使用)。

🔟 其他改进

  • Symbol.prototype.description 属性更稳定;
  • 更严格的模块解析错误提示;
  • 性能优化:减少闭包内存占用、提升 Promise 链执行效率。

🌐 浏览器支持情况(截至 2025 年 11 月)

特性 Chrome 128+ Firefox 130+ Safari 18+ Node.js 22+
Promise.try()
Set 方法
JSON 模块导入 ⚠️(实验) ✅(需 flag)
Iterator Helpers
RegExp.escape()
defer import ⚠️ ❌(暂未支持)

💡 建议搭配 Babel 或 TypeScript 编译以兼容旧环境。


✅ 结语

ECMAScript 2025 不仅延续了 JavaScript “渐进增强” 的设计哲学,更在开发体验、性能、安全性上迈出坚实一步。无论是简化日常编码,还是优化大型应用架构,这些新特性都值得你立即尝试!

❌