阅读视图

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

每日一题-矩阵中和能被 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

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 “渐进增强” 的设计哲学,更在开发体验、性能、安全性上迈出坚实一步。无论是简化日常编码,还是优化大型应用架构,这些新特性都值得你立即尝试!

在数据录入、指标补录、表单填报场景中,SpreadJS 具备哪些优势和价值

在数据录入、指标补录、表单填报场景中,SpreadJS 具备哪些优势和价值

在数字化转型加速的今天,表格作为企业数据管理与分析的核心载体,其工具选择直接影响业务效率与用户体验。作为一款基于 HTML5 的纯前端表格控件,SpreadJS 凭借"高速低耗、纯前端、零依赖"的产品特色 ,为数据录入、指标补录、表单填报等场景提供了完整的解决方案。本文将从技术架构、开发效率和实际应用价值三个维度,深入解析 SpreadJS 如何助力企业实现高效的数据处理与填报。

一、数据录入场景:纯前端架构赋能高效录入体验

数据录入是企业日常运营的基础环节,尤其在电商、物流、财务等数据密集型行业,录入效率直接影响整体业务流程。 SpreadJS 在这一场景中展现出三大核心优势。

高性能数据处理能力

是 SpreadJS 的首要价值。通过采用稀疏矩阵存储技术和 HTML5 Canvas 绘制方案 ,SpreadJS 有效解决了传统表格控件在处理大数据量时的性能瓶颈。在实际测试中, SpreadJS 能够在 2.4 秒内完成 50 万行×20 列的分组交叉统计数据加载 ,这一性能在浏览器端尤为突出。相比之下,传统表格控件在处理类似规模数据时,往往需要 10 秒以上,且内存占用可能高达 1.2GB,而 SpreadJS 通过稀疏矩阵技术将内存占用控制在 80MB 以内,实现了数量级的性能提升 。

#SpreadJS 导出不同大小文件所需时间和内存

文件类型 性能指标 (单元格数量) 50万 2.5万行 * 20 列 100万 5万行 * 20 列 500万 25万行 * 20 列 1,000 万 50万行 * 20 列
Excel 导出时间 2,004 ms 3,802 ms 18,814 ms 42,786 ms
所需内存 91.9 MB 258.8 MB 1,010.9 MB 1286.9 MB
SJS 导出时间 742 ms 1,443 ms 7,554 ms 15,729 ms
所需内存 61.6 MB 88.3 MB 479.6 MB 966.4 MB

离线+在线双模填报

是 SpreadJS 的另一重要特性。在实际业务场景中,网络环境不稳定是数据录入的常见痛点。 SpreadJS 支持离线模式下数据录入,用户可在无网络环境下完成数据填写,联网后自动同步至服务器,有效避免了数据丢失风险 。同时, SpreadJS 提供四重数据校验机制:在线校验(输入时实时提示)、提交校验(提交前全表检查)、前端 JavaScript 校验和后端校验,确保数据质量 。例如,某省级统计局经济数据采集系统采用 SpreadJS 后,数据填报错误率从 12%显著降至 1.5%,数据汇总效率提升 70% 。

类 Excel 操作体验

极大降低了用户学习成本。 SpreadJS 完全复刻了 Excel 的操作习惯,支持拖拉拽数据绑定、单元格格式设置、条件格式应用等熟悉的功能 。业务人员无需专业培训,即可像使用 Excel 一样操作 SpreadJS,减少了用户迁移成本。例如,某企业供应链技术专家反馈:"简单的一百多行代码配合 SpreadJS 的类 Excel 操作习惯,让我们的用户就像使用 Excel 一样使用内部系统,为用户迁移工作节约了大量培训时间。"

二、指标补录场景:复杂计算与动态分析的完美结合

指标补录是企业数据治理的关键环节,尤其在审计、财务、风险管控等领域,指标的准确性和时效性直接关系到决策质量。 SpreadJS 在指标补录场景中展现出强大的计算引擎和灵活的数据分析能力。

强大的公式计算引擎

支持 450+种 Excel 公式,包括动态数组函数(XMATCH/LET/XLOOKUP)和异步函数 。这一特性使得企业能够在 Web 系统中直接复用现有的 Excel 商业模型,无需重新开发。例如,某零售集团通过 SpreadJS 将大量在 Excel 中建立的商业分析经营模型迁移至线上系统,实现了客流与租赁系统的数据清洗和整合,建立了基于交易数额和频次的客户分级体系,成功吸引了 34 个每月百万级交易的 VIP 客户,直接带来超 1 亿元的营收增长 。

在这里插入图片描述

数据透视表与集算表

是 SpreadJS 在指标补录场景中的核心功能。作为业内唯一兼容 Excel 的 Web 端数据透视表控件 , SpreadJS 支持拖拽字段、数据聚合、分组排序,并可将计算结果直接导出 Excel。集算表插件则提供了更高效的大数据处理能力,即使面对百万级数据,也能实现流畅的分析体验。在实际应用中, SpreadJS 的集算表插件将数据加载时间从 45 秒优化到 2.1 秒,筛选响应时间从 15 秒优化到 0.3 秒,性能提升显著 。

在这里插入图片描述

AI 智能分析

是 SpreadJS V18.1 版本新增的重要特性。通过 AI 插件,用户可以输入自然语言需求(如"计算 A 列与 B 列的乘积和"),系统自动生成对应公式(如 SUM(A1:A10*B1:B10))并解释公式原理 。在立信智能审计云平台中, SpreadJS 的 AI 功能帮助审计团队实现了财务指标的快速生成和验证,审计周期缩短 40%,同时满足了证监会对审计轨迹可追溯至单元格级的合规要求 。

在这里插入图片描述

三、表单填报场景:复杂模板与多人协作的无缝衔接

表单填报是企业跨部门协同的重要环节,尤其在预算管理、项目申报、审批流程等领域,表单的复杂性和协作需求日益增加。 SpreadJS 在表单填报场景中展现出灵活的模板设计能力和强大的协同编辑功能。

可视化模板设计器

极大提升了开发效率。通过在线表格编辑器,业务人员无需代码即可设计填报模板,支持下拉框、复选框、按钮等 20+种组件嵌入,模板可保存为 JSON 格式复用 。与传统原生 JS+POI 开发方案相比, SpreadJS 将报表模板设计效率提升了 86.7%,10 张表的设计工作从 15 人天缩短至 2 人天,按人均日薪 2000 元计算,节省了 26000 元的开发成本 。

在这里插入图片描述

跨部门协同填报

是 SpreadJS 的另一重要价值。通过单元格级权限控制和实时协作功能, SpreadJS 支持多部门同时在线填报数据,确保各部门仅能编辑自身负责的字段,避免数据修改混乱 。在实时协同方面, SpreadJS V18.2 版本引入了协同插件(Beta),基于自研协作框架打造,支持多人实时编辑同一工作簿,系统通过 OT 算法自动合并操作,延迟低于 300ms 。例如,某大型制造企业在全面预算管理中引入 SpreadJS 后,实现了多部门预算数据的实时汇总和协同审核,预算编制周期缩短 25%,预算执行偏差率降低 15% 。

移动端适配与跨平台一致性

确保了表单填报的灵活性和便捷性。 SpreadJS 适配 PC(6 大浏览器)+移动端,提供了与桌面端一致的操作体验和视觉效果 。同时, SpreadJS 支持将填报数据与企业现有系统(如 ERP、CRM、BI)无缝集成,通过 API 接口实现数据的自动同步和共享,打破了数据孤岛。在甘棠软件生产采购系统中, SpreadJS 通过稀疏矩阵存储技术,成功承载了 8 大类零件成本数据(原材料、加工、运输等),单表处理 10 万+条报价记录,内存占用控制在 200MB 以内,确保了移动端的流畅操作体验 。

四、技术实现与开发效率:从代码层面看 SpreadJS 的优势

对于开发者而言, SpreadJS 不仅提供了丰富的功能,更在技术实现和开发效率方面展现出显著优势。

前后端协同架构

使 SpreadJS 能够轻松应对各种复杂场景。前端采用 SpreadJS 实现交互和展示,后端使用 GcExcel 处理批量任务,形成了完整的全栈解决方案 。例如,对于超过百万行的超大型数据集,可以采用"前端加载部分数据或空表格用于字段选择+后端 GcExcel 进行实际的数据透视分析计算+结果传输至前端展示"的模式,既保证了分析性能,又减少了网络传输压力 。

主流框架深度集成

是 SpreadJS 的重要技术优势。 SpreadJS 支持 Vue、React、Angular 等 6 大主流前端框架 ,提供了完整的集成指南和 API 文档。以下是一个简单的 React 集成示例:

import React, { Component } from 'react';
import { SpreadSheets, Worksheet, Column } from '@grapecity-software/spread-sheets-react';
import * as GC from "@grapecity-software/spread-sheets";

class APP extends Component {
  constructor(props) {
    super(props);
    this.spreadBackColor = 'aliceblue';
    this.sheetName = 'Goods List';
    this.hostStyle =
    {
      width: '800px',
      height: '600px'
    };
    this.data = [
      {
        Name: "Apple",
        Category: "Fruit",
        Price: 1,
        "Shopping Place": "Wal-Mart",
      },
      {
        Name: "Potato",
        Category: "Fruit",
        Price: 2.01,
        "Shopping Place": "Other",
      },
      {
        Name: "Tomato",
        Category: "Vegetable",
        Price: 3.21,
        "Shopping Place": "Other",
      },
      {
        Name: "Sandwich",
        Category: "Food",
        Price: 2,
        "Shopping Place": "Wal-Mart",
      },
      {
        Name: "Hamburger",
        Category: "Food",
        Price: 2,
        "Shopping Place": "Wal-Mart",
      },
      {
        Name: "Grape",
        Category: "Fruit",
        Price: 4,
        "Shopping Place": "Sun Store",
      },
    ];
    this.columnWidth = 100;
  }
  render() {
    return (
      <div>
        <SpreadSheets backColor={this.spreadBackColor} hostStyle={this.hostStyle}>
          <Worksheet name={this.sheetName} dataSource={this.data}>
            <Column dataField='Name' width={300}></Column>
            <Column dataField='Category' width={this.columnWidth}></Column>
            <Column dataField='Price' width={this.columnWidth}
              formatter="$#.00"></Column>
            <Column dataField='Shopping Place' width={this.columnWidth}></Column>
          </Worksheet>
        </SpreadSheets>
      </div>
    )
  }
}
export default APP    

权限控制与数据校验的 API 设计简洁而强大。 SpreadJS 提供了单元格级的权限控制和校验规则配置接口,开发者可以通过几行代码实现复杂的权限管理:

// 启用无效数据高亮显示
spread.options.highlightInvalidData = true;
// 创建日期验证器:允许输入 2017年12月31日至2018年12月31日之间的日期
var dv = GC.Spread.Sheets.DataValidation.createDateValidator(
    GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, 
    new Date(2017, 12, 31), 
    new Date(2018, 12, 31)
);
// 启用输入提示
dv.showInputMessage(true);
// 设置输入提示内容
dv.inputMessage("请输入 2017年12月31日 至 2018年12月31日 之间的日期。");
// 设置输入提示标题
dv.inputTitle("提示");
// 在视图区域的(1,1)单元格(第2行第2列)应用日期验证器
activeSheet.setDataValidator(1, 1, 1, 1, dv, GC.Spread.Sheets.SheetArea.viewport);

数据绑定与导出功能的 API 设计同样高效。 SpreadJS 支持多种数据源格式(数组、JSON、HTTP 请求等) ,并提供便捷的导出接口:

<!DOCTYPE html>
<html lang="en">
<head>
    <link href="css/gc.spread.sheets.excel2013white.x.x.x.css" rel="stylesheet" />
    <script src="scripts/gc.spread.sheets.all.x.x.x.min.js" type="application/javascript"></script>
    <script src="scripts/gc.spread.sheets.io.x.x.x.min.js"></script>
    <script src="scripts/FileSaver.js"></script>
    <script>
        var spread;
        window.onload = function () {
            // 初始化 SpreadJS 工作簿
            spread = new GC.Spread.Sheets.Workbook(document.getElementById("ss"));
        }
        // 将 xlsx、ssjson、csv 文件导入到 spread 中
        function ImportFile() {
            var file = document.getElementById("fileDemo").files[0];
            spread.import(file, function () {
                // 成功回调函数,可在此处执行后续操作
            }, function (e) {
               console.log(e); // 错误回调函数,打印错误信息
            }, {
               fileType: GC.Spread.Sheets.FileType.excel // 指定文件类型为 Excel
            });
        }
        // 将 spread 导出为 xlsx、ssjson、csv 文件
        function ExportFile() {
            var fileName = "fileNamehere.xlsx";      
            spread.export(function (blob) {
                // 将 blob 对象保存为文件
                saveAs(blob, fileName);
            }, function (e) {
               console.log(e); // 错误回调函数,打印错误信息
            }, {
               fileType: GC.Spread.Sheets.FileType.excel // 指定文件类型为 Excel
            });
        }
    </script>
</head>
<body>
    <div>
        <input type="file" name="files[]" id="fileDemo" accept=".xlsx" />
        <input type="button" id="loadExcel" value="Import" onclick="ImportFile()" />
        <input type="button" class="btn btn-default" id="saveExcel" value="Export" onclick="ExportFile()" />
        <div id="ss" style="width:100%; height:500px"></div>
    </div>
</body>
</html>

五、企业应用案例与实际价值

SpreadJS 已在多个行业得到广泛应用,以下是几个典型企业的应用案例及其带来的实际价值。

财务报表领域:华融科技应用 SpreadJS 开发了"风险指标补录系统",通过在线表格编辑器实现模板设计,支持业务人员自行设计填报模板并发布,大幅降低了对 IT 部门的依赖 。系统结合 SpreadJS 提供的高度类似 Excel 的展示方式和 Excel 的导入导出功能,简化了系统录入流程,提升了数据处理效率。

审计领域:某会计师事务所应用 SpreadJS 开发了"智能审计云平台"(SACP),实现了在线远程协同作业,提供了近乎与 Excel 一致的功能体验 。审计人员可以无缝衔接不同地点、不同项目组的工作模式,无需业务人员重新上传文件,在线即可设计电子底稿,并与文件系统快速交互。审核人员能够远程复核、汇总问题点、在线穿透至批注处,极大提升了审计效率和质量。

制造业领域:甘棠软件基于 SpreadJS 开发了生产采购系统,通过稀疏矩阵存储技术,成功处理了 10 万+条零件成本报价记录,单表内存占用控制在 200MB 以内 。系统支持多结构数据录入、单元格级校验及公式联动计算,打通了"数据展示-填报采集-流程对接"全链路,显著提升了生产采购数据的处理效率和准确性。

物流运输领域:东普科技应用 SpreadJS 推动韵达集团物流信息化发展,通过 SpreadJS 的模板设计和数据校验功能,实现了物流单据的快速录入和审核,大幅降低了物流信息处理的人力成本和错误率。系统还支持移动端填报,使一线人员能够在任何环境下完成数据录入,提高了数据采集的灵活性和及时性。

国资云平台:陕数集团作为陕西省属信息化企业,利用 SpreadJS 构建了陕西国资云平台的数据填报模块,实现了省属企业数据的集中管理和利用 。平台按照全栈信创、等保 2.0 三级标准和国产化商用密码要求建成,具备高安全性、高可靠性和高可用性等特点,为省属企业提供全面的数字化解决方案 。

六、未来趋势与 SpreadJS 的发展方向

随着企业数字化转型的深入,表格工具也在不断演进。 SpreadJS 作为一款专业的表格控件,也在持续创新和优化,以适应未来的发展趋势。

AI 深度整合

将是 SpreadJS 的重要发展方向。随着 AI 技术的成熟,表格工具将不再是简单的数据录入工具,而是能够理解业务需求、自动生成公式和报表的智能助手。 SpreadJS 已开始探索这一方向,其 AI 插件支持自然语言生成透视表和回答数据相关问题,未来将进一步增强 AI 能力,实现更智能的数据分析和预测。

实时协作与协同办公

将成为表格工具的核心功能。随着远程办公和跨团队协作的普及,表格工具需要支持多人实时编辑、冲突自动处理和版本管理。 SpreadJS V18.2 版本引入的协同插件(Beta)已经在这方面取得了突破,支持单元格级冲突处理、用户存在感可视化和权限管控 ,未来将进一步完善这一功能,提供更强大的协同办公体验。

与企业系统的无缝集成

将增强表格工具的价值。 SpreadJS 已经支持与多种企业信息系统(如 ERP、CRM、BI)的集成,未来将进一步扩展集成范围,提供更丰富的 API 和更便捷的集成方式,使表格工具能够更好地融入企业现有的业务流程。

低代码/无代码开发支持

将降低表格应用的开发门槛。 SpreadJS 的在线表格编辑器和可视化设计器已经支持业务人员参与模板设计,未来将进一步增强低代码/无代码开发能力,使更多非技术人员能够参与表格应用的开发和维护,提高企业数据管理的敏捷性。

七、结论与价值总结

SpreadJS 作为一款基于 HTML5 的纯前端表格控件,凭借其"高性能、跨平台、与 Excel 高度兼容"的产品特性 ,在数据录入、指标补录、表单填报等场景中展现出显著优势。

技术价值

SpreadJS 通过稀疏矩阵存储技术和 HTML5 Canvas 绘制方案,解决了传统表格控件在处理大数据量时的性能瓶颈 ;通过自研计算引擎,实现了对 450+种 Excel 公式的兼容,并支持单元格级的公式计算和依赖链运算 ;通过协同插件,提供了强大的实时协作能力,支持多人同时编辑同一工作簿,系统通过 OT 算法自动合并操作,解决了版本混乱和冲突难解决的问题 。

业务价值

SpreadJS 通过类 Excel 的填报体验,零培训上手 ,降低了用户迁移成本;通过四重数据校验机制,在线校验、提交校验、前端 JavaScript 校验和后端校验,确保了数据质量 ;通过可视化模板设计器和 API 集成能力,提升了开发效率,使企业能够快速构建符合业务需求的表格应用 ;通过移动端适配和跨平台一致性,提供了灵活便捷的数据填报体验 。

投资回报

SpreadJS 帮助企业节省了大量开发成本和培训成本。例如,某企业通过 SpreadJS 将报表模板设计效率提升了 86.7%,10 张表的设计工作从 15 人天缩短至 2 人天,按人均日薪 2000 元计算,节省了 26000 元的开发成本 ;某省级统计局经济数据采集系统采用 SpreadJS 后,数据填报错误率从 12%显著降至 1.5%,数据汇总效率提升 70% ;某大型制造企业引入 SpreadJS 后,预算编制周期缩短 25%,预算执行偏差率降低 15% 。

未来展望

SpreadJS 将继续深化 AI 能力、增强实时协作功能、扩展与企业系统的集成范围、提升低代码/无代码开发支持,为企业提供更智能、更高效、更便捷的数据处理与填报解决方案。

总之, SpreadJS 不仅是一款功能强大的表格控件,更是企业数字化转型中的重要工具。通过 SpreadJS,企业可以构建高效、安全、易用的数据录入、指标补录、表单填报系统,提升数据处理效率,降低开发成本,优化用户体验,为企业的数字化转型提供有力支持。

扩展链接

可嵌入您系统的在线Excel

JavaScript 中的 Symbol:特性与实战应用

JavaScript 中的 Symbol:特性与实战应用

在 JavaScript 的世界里,数据类型是构建一切的基础。ES6 的出现为我们带来了两种新的简单数据类型,其中之一就是 Symbol。本文将结合实例,详细解析 Symbol 的特性、用法及实际价值。

一、Symbol 是什么?数据类型的新成员

JavaScript 共有 8 种数据类型,可分为 "简单数据类型" 和 "复杂数据类型" 两大类:

  • 复杂数据类型:仅object一种

  • 简单数据类型:

    • 传统类型:numberbooleanstringnullundefined
    • ES6 新增:bigint(大数)、symbol(符号)

Symbol 的核心定义是:一种独一无二的值,它的出现主要是为了解决对象属性名冲突的问题。

二、Symbol 的声明方式

Symbol 通过Symbol()函数声明(注意:虽然使用函数形式,但它是简单数据类型,而非对象),语法如下:

// 基本声明
const sym = Symbol();
// 带描述符的声明(描述符仅用于标识,不影响唯一性)
const symWithDesc = Symbol('这是一个描述');

代码示例解析(symbol/1.js)

// 声明两个Symbol类型变量
const id1 = Symbol();
console.log(typeof id1); // 输出:"symbol",证明是简单数据类型

const id2 = Symbol();
// 核心特性:独一无二,即使无参数,两个Symbol也不相等
console.log(id1 === id2); // 输出:false

从代码可见,即使两个 Symbol 变量没有任何参数,它们也绝对不相等,这是 Symbol 最核心的特性。

三、Symbol 作为对象的唯一键:解决命名冲突

在多人协作开发中,对象的动态特性可能导致属性名冲突(后定义的属性会覆盖先定义的)。而 Symbol 作为对象的键(key)时,能完美避免这个问题,因为它是独一无二的。

代码示例解析(symbol/2.js)

// 两个描述符相同的Symbol,依然不相等
const s1 = Symbol('二哈');
const s2 = Symbol('二哈');
console.log(s1 === s2); // 输出:false

// 声明一个用于"秘密信息"的Symbol键
const secretKey = Symbol('secret');
console.log(secretKey, '//////'); // 输出:Symbol(secret) //////

// 演示Symbol作为对象键的优势
const a = "ecut";
const user = {
    [secretKey]: '111222', // Symbol作为键,避免被意外覆盖
    email: '123@qq.com',
    name: '曹仁',
    "a": 456, // 字符串键"a"
    [a]: 123  // 变量a(值为"ecut")作为键,等价于"ecut":123
};

// 访问对象属性
console.log(user.ecut, user[a]); // 输出:123 123(两种方式访问同一个键)

// 普通字符串键可以被修改
user.email = 'ren@qq.com';

在上述代码中,secretKey作为 Symbol 键,即使其他开发者在对象中添加同名字符串键,也不会影响它的值,确保了数据的安全性。

四、Symbol 键的不可枚举性与访问方式

与普通字符串键不同,Symbol 作为对象的键时,不会被for...in循环枚举,也不会被Object.keys()等方法获取。这一特性让 Symbol 键适合存储 "私有" 或 "辅助" 信息。

若要访问对象中的 Symbol 键,需使用Object.getOwnPropertySymbols()方法,该方法会返回对象中所有 Symbol 键组成的数组。

代码示例解析

// 声明两个Symbol变量
const wes = Symbol('Wes');
const person = Symbol('Wes');
// console.log(wes === person); // 输出:false(即使描述相同也不相等)

// 定义包含Symbol键的对象
const classRoom = {
    [Symbol('Mark')]: { grade: 50, gender: 'male' },
    [Symbol('oliva')]: { grade: 80, gender: 'female' },
    [Symbol('oliva')]: { grade: 85, gender: 'female' }, // 不会覆盖前一个oliva,因为是不同Symbol
    "dl": ["张三", "李四"] // 普通字符串键
};

// 1. 测试for...in循环:仅能枚举字符串键
for (const person in classRoom) {
    console.log(person, '////'); // 仅输出:dl ////
}

// 2. 获取所有Symbol键
const syms = Object.getOwnPropertySymbols(classRoom);
console.log(syms); // 输出:[Symbol(Mark), Symbol(oliva), Symbol(oliva)]

// 3. 访问Symbol键对应的值
const data = syms.map(sym => classRoom[sym]);
console.log(data); 
// 输出:[
//   { grade: 50, gender: 'male' },
//   { grade: 80, gender: 'female' },
//   { grade: 85, gender: 'female' }
// ]

代码中,classRoom对象包含 3 个 Symbol 键和 1 个字符串键。for...in循环仅能获取到字符串键dl,而Object.getOwnPropertySymbols()则能准确获取所有 Symbol 键,再通过映射即可访问对应的值。

五、总结

Symbol 作为 ES6 新增的简单数据类型,凭借 "独一无二" 和 "不可枚举" 的特性,在实际开发中有着重要作用:

  1. 作为对象键,避免多人协作时的命名冲突
  2. 存储 "私有" 信息,不被常规枚举方法获取
  3. 描述符仅用于标识,不影响其唯一性

掌握 Symbol 的用法,能让我们在处理对象属性时更加灵活、安全,尤其适合大型项目和多人协作场景。

❌