普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月5日技术

科技爱好者周刊(第 376 期):太空数据中心的争议

作者 阮一峰
2025年12月5日 08:09

这里记录每周值得分享的科技内容,周五发布。

本杂志开源,欢迎投稿。另有《谁在招人》服务,发布程序员招聘信息。合作请邮件联系(yifeng.ruan@gmail.com)。

封面图

杭州大运河门户桥是一座步行桥,位于大运河与钱塘江交汇处,即将开放。它的三段拱形设计象征杭州刺绣和钱塘江浪潮。(via

太空数据中心的争议

AI 大发展,数据中心不够用了,建造和运营成本飞涨。

越来越多的人提出,数据中心可以建在太空。

先是马斯克说,SpaceX 公司考虑在地球轨道上,建造数据中心。

然后本周,北京市科委、中关村科学城管委会发布了《太空数据中心建设规划方案》

目标是"在距地面700公里的轨道上,建设可容纳百万卡集群的数据中心,开展天基数据中继传输和计算服务"。

根据报道,专家认为太空数据中心"势在必行"(上图)。

(1)高轨太空数据中心可 7×24 小时使用高强度太阳能,且不受大气影响,发电效率可达95%。

(2)深空温度约为-270度,只需部署导热材料即可完成散热,无需部署大量液冷结构,成本优势显著。

我觉得,这是两个很大的优势,太空数据中心确实应该尽快建设。

但是,我紧接着又看到了一篇文章

作者是美国宇航局的资深工程师,曾经参与设计卫星。

他根据国际空间站的经验,认为现阶段技术制约太大,太空数据中心很难实现。真要建设的话,花费巨大,收益有限。

这可是我第一次看到,有人认真地质疑这件事。我不具备这方面的专业知识,不知道他的计算对不对,再说国际空间站完成于25年前,现在的技术早就超越了那时。

我把他的几点质疑分享出来,大家看看太空数据中心有没有前景。

(1)能源问题

太空能源主要来自太阳能。迄今为止最大的太空太阳能板就在国际空间站,峰值功率200​​千瓦以上,但是面积巨大,约为2500平方米,超过半个美式橄榄球场。

英伟达的 H200 显卡功耗约为 0.7kW,实际运行可能需要 1kW 的电源。国际空间站的太阳能板只够供电200个 H200。

作为比较,OpenAI 即将在挪威建设的数据中心计划容纳10万个 GPU,每个的功耗可能都比 H200 更高。

(2)散热问题

太空很冷,接近绝对零度,初看上去散热应该很容易。但是事实是,散热只有两种方式:要么通过介质散热,要么通过辐射散热。

太空没有空气,接近真空,根本没有介质,无法使用空气对流来散热,所以 GPU 的散热片和风扇不起作用。

唯一可用的是液冷,通过液体将热量传递到散热板,再辐射到太空(散热板必须放置在远离太阳的一面)。

国际空间站就采用辐射散热。它的散热系统非常复杂,散热上限为 16kW,大约相当于16个 H200,略多于一个地面服务器机架的四分之一。

国际空间站的散热板尺寸为13.6米×3.12米,即大约42.5平方米。如果要为200个 H200 散热,面积需要扩大12.5倍,即大约531平方米。这个面积是同样功率太阳能板的2.6倍。

这样一来,太空数据中心将变得非常庞大,远超国际空间站,而容量只相当于地面的三个标准机架。

(3)粒子射线问题

太空有各种高速粒子,由于没有大气层保护,它们可以直接撞击芯片材料造成损伤。最常见的后果是单粒子翻转(SEU),即粒子直接撞击晶体管,导致某个比特翻转。

太空数据中心必须长期运行,还存在总剂量效应,即反复的粒子撞击导致晶体管开关速度变慢,进而停止工作。

所以需要有一个屏蔽层,但是最强的宇宙射线可以穿透惊人厚度的铅层。而且受限于飞船的运送能力,太空中不可能部署很厚的屏蔽层。

为了增加 GPU 和内存的抗辐射能力,有必要为太空环境重新设计芯片,增加容错性能。但是,这样的芯片性能将远不及目前地球上的 GPU。

(4)通讯问题

大多数卫星通过无线电与地面通信,速率超过 1Gbps 都很困难。虽然有一些激光方案可以提高带宽,但需要良好的大气条件才能实现。

相比之下,地球上的数据中心之间的通讯,最低速率也能达到 100Gbps。

[本周软件] 七牛云的大模型接入服务

几周前,我在周刊推荐七牛云一个平台调用国内外各大 AI 模型,堪称中国的 OpenRouter。

但是,我没说清楚两点,导致很多网友遇到问题,我一直想找机会补充。

本周一,DeepSeek 发布 V3.2 的 Speciale 版和普通版,当天晚上我就看到七牛云上架了(下图)。他们更新这么快,我还是说一下吧,大家就不会遇到问题了。

(1)国外模型。七牛云目前提供国内外70多个模型,但由于种种原因,国外模型在官网查不到,实际上是支持的。

具体的模型清单,可以去这个网站查询,主流模型都有(下图)。

查到模型 ID 后(比如claude-4.5-opus),用它作为模型名参数,就可以在七牛云调用这个模型。

(2)请求频率。普通开发者"1分钟5次,1小时60次"足够使用了,如果超出这个速率,就会遇到报错(状态码429)。

大家不要觉得这个限制太严,作为对比,Claude 官方的 Pro 套餐是5小时45次。

我推荐七牛云,主要考虑他们是上市公司,服务有保障。另外,他们同时支持 OpenAI 和 Anthropic 两种调用格式,调用 API 很方便(下图)。

新用户有免费的 300万 Token,如果再邀请一位好友来使用,你会再得到 500万 Token,好友则得到 1000万 Token。注意,这些免费额度是通用的,可用于任何他们提供的模型。欢迎使用我的推广链接去注册。

科技动态

1、冰岛的洋流危机

冰岛政府宣布,大西洋的洋流变化是国家安全问题,威胁到冰岛的生存。

根据观测数据,随着全球变暖,大西洋环流正在减弱。这个环流是一条巨大的传送带,将温暖的海水从赤道向北输送,为大西洋北部带来温暖的气候。

一旦这个洋流消失,冰岛的气温将大幅下降,最坏的情况下可能会被冰川包围,真的变成"冰的岛",也就没法住人了。

2、火星声音

火星上有什么声音?

科学家也想知道。美国的"毅力号"火星车于2021年2月登陆火星时,就配备了一个麦克风,专门监听火星声音。

上图是火星车顶部跟摄像头配套的麦克风。

最近,一个研究团队分析了这个麦克风录制的28小时音频。

他们听到了有东西撞击地表的声音,同时还观测到了电信号,因此推测这是闪电的声音。

这是人们第一次知道,火星还有闪电,因为那里空气稀薄,没有水也就没有云,所以不存在云的放电。科学家判断,火星闪电应该是龙卷风导致的沙石摩擦的放电。

3、人造树叶

一个美国研究团队,将太阳能板做成了叶片形状。

这种叶片的底部,连接着化学催化剂,有了太阳能,就可以将二氧化碳和水转化为燃料。

如果把许多这样的叶片组装成一棵树,就是一个燃料生产装置。

这启示我们,太阳能装置的形式,远不止太阳能板一种,完全可以做成树的形状。

文章

1、关于近期安全报告的情况说明(中文)

一个国外安全团队近日报告,某些浏览器插件(如 Clean Master、WeTab)是恶意软件。本文是这些插件的中国开发团队的回应。

他们表示,Clean Master 去年已经卖掉了,现在跟他们无关,至于其他插件属于误报。建议先读安全报告,再读这篇回应。(@yiGmMk 投稿)

2、Electron vs Tauri(英文)

Electron 和 Tauri 是目前跨平台桌面应用的两种主流开发方案。本文是它们的详细对比,各自的优缺点。

3、我为什么要从 GitHub 迁移(英文)

作者感到 GitHub 不符合需要,太重了,就做了一个自己的代码仓库托管方案。如果你也想自己架设代码服务器,可以参考。

4、Protobuf 好于 JSON(英文)

本文呼吁使用 Protobuf 格式代替 JSON。作者认为,JSON 唯一的优点就是人类可以识读。

5、如何写一份好的 CLAUDE.md 文件(英文)

很多 AI 编程工具,使用一份文本文件,作为每次查询的上下文。本文以 CLAUDE.md 为例,介绍怎么用好这个文件。

6、ZFS 文件系统好于 Btrfs(英文)

ZFS 和 Btrfs 是两种流行的现代文件系统,本文介绍它们的特点,提出前者更可靠。

工具

1、Fizzy

37Signals 公司开源了他们内部使用的看板应用。

2、Fresh

一个基于终端的文本编辑器。

3、Gitmal

这个工具将 Git 仓库变成一个静态网站,网页内容就是文件、提交、代码高亮等。

4、GitHub Card

该网站可以生成 GitHub 用户和仓库的分享卡片。(@Cactusinhand 投稿)

5、EasyDB

跨平台的桌面应用,使用 SQL 查询数据文件,包括数据库、CSV、Excel、JSON 等。(@shencangsheng 投稿)

6、Webhooker

一个 Webhook 的转发网关,比如把 Slack 消息转发到飞书和钉钉。(@Disdjj 投稿)

7、PySInfo

一个 Python 脚本,用来在命令行显示系统信息,类似于 fastfetch。(@cycleuser 投稿)

8、PocketMocker

一个网页前端的 Mock 库,通过网页中拦截 fetch 和 XMLHttpRequest 来 Mock 数据,带有可视化控制台。(@tianchangNorth 投稿)

9、code996

一个命令行工具,通过分析 Git 仓库的提交时间,判断项目的编码工作强度和加班情况。(@hellodigua 投稿)

10、玉桃文飨轩

一款开源网页应用,将 Markdown 文本转成图片。(@nicejade 投稿)

AI 相关

1、ClipSketch AI

一个开源的 Web 应用,通过 Gemini 模型,将小红书和 B 站视频转成手绘故事。(@RanFeng 投稿)

2、Banana Prompt Quicker

一个开源的浏览器插件,收集 Nano Banana 模型的热门提示词,方便复用。(@glidea 投稿)

3、git-rewrite-commits

这个工具使用 AI 改写以前的 git 提交信息,让其变得更准确详细。

资源

1、百大年度照片

《时代》杂志评选的2025年100张照片,另有路透社的年度照片

2、维基百科2025年阅读量最高的文章

维基百科公布,2025年它的英文版阅读量最高的20篇文章,主要集中于政治、流行文化和逝者。

3、HummingbirdSpot

这是一个美国退休女教师的网站,她立志要拍摄所有种类的蜂鸟。全世界共有366种蜂鸟,她已经拍到了277种。

图片

1、小王子博物馆

经典童话《小王子》出版于1943年,畅销全世界。

为了纪念这本童话和作者圣埃克苏佩里,瑞士最近新开了一家小王子博物馆。

这个博物馆收藏了这本童话的各种版本和纪念品。

《小王子》的情节是,从前有一个 B612 小行星,上面有一个小王子。这个小行星面积不大,有两座活火山、一座死火山和一朵玫瑰。

小王子离开 B612 后,拜访了其他六颗小行星,分别住着国王、爱慕虚荣的人、酗酒的人、生意人、守时的人和地理学家。最后,小王子来到地球,访问后又回到了自己的行星。

文摘

1、为什么大公司的代码质量不佳?

跟大家想的不一样,大公司的代码质量其实不高。

这看上去违反常理,大型科技公司薪酬优厚,足以吸引众多优秀工程师。而且,大公司的工作环境、配套工具、开发节奏都很好,非常适合从容不迫地完成高质量的工作。

但是,事实就是他们的代码质量完全谈不上优秀。

原因很简单,大公司的大多数代码都是由相对的初学者完成的。

那些工程师并不是不优秀,而是被迫去开发非本领域的项目,属于相对的初学者。

现实生活中,大型科技公司的工程师,很少会一直干下去。事实上,大公司的薪酬方案通常都设定了工程师的四年任期,四年后初始授予的股份全部归属,工程师的收入可能就会大幅下降。这时,如果你没有得到晋升,显然可以考虑离开了。

如果算上内部流动,情况就更糟了。我自己在同一个团队或同一个代码库,停留的时间最长也只有三年,那还是我刚入职的时期。后来,我每年都至少经历一次重组,更换团队或项目。

当然,大公司的代码库寿命没有这么短,很多内部代码库都有十年甚至更久的历史。问题是,这么多年来,这些库经历了许多不同的所有者,不同的工程师都在不断地"摸索",相当高比例的代码变更是由"新手"完成的。这些人可能是在过去六个月内才加入公司、接触代码库。

你肯定会问,大公司的那些"老手"程序员难道不写代码吗?总有一些工程师在特定领域工作了足够长的时间,积累了真正的专业知识,会进行深入的代码审查,并能可靠地发现问题,这些人在干什么呢?

首先,大公司不在乎"老手"程序员。公司很少致力于培养特定专业的长期人才,而且似乎也根本不在乎留住这些人才。通常情况下,这些人迟早会被调到其他部门,成为一个全新系统的相对新手。

其次,"老手"工程师总是工作量巨大。作为少数精通特定服务的工程师之一,他们的工作非常繁忙。他没有足够的时间亲自审查每一次软件变更,或者积极参与每一个决策过程,他有自己的工作要做。

总之,大公司的现实就是,你总是被分配到新项目,几乎每天都在赶工,要赶上多个项目的截止日期。换句话说,工程师是在一个不利于编写高质量代码的环境中尽力而为。

这样情况下,就很难保证优秀的代码质量了。更常见的情况是,一位初级工程师接手了一个他几乎不熟悉的代码库中一个恼人 bug 的工单。他花了几天时间研究,最终想出了一个蹩脚的解决方案。如果幸运的话,一位"老手"在空闲的半小时里匆匆浏览了一下,否决了这个方案,并提出了一个稍微好一点、至少能用的方案。初级工程师尽力实现了这个方案,测试了它是否有效,经过简单的审查后发布,所有相关人员立即转而处理下一个高优先级的工作。

言论

1、

亚洲人民处在从艰苦的农业生活过渡到城市工厂生活,这种转变似乎带来了一种热情,一种愿意为在今天的欧洲被认为微不足道的东西而努力工作的意愿。

这对他们来说是好事。但在欧洲,我们已经经历过这种转变,变得无所事事,毫无士气。我们生活所需的一切都由他人制造时,这种情况是不可持续的。

-- 一个德国的 Hacker News 读者

2、

汽车的电子屏幕,夜间使用非常有害,尤其对于40岁以上的人。他们的视力开始下降,聚焦和光线平衡的反应速度都会减慢,使用电子屏幕会改变他们的夜视能力,不容易看清前方的道路。

-- Hacker News 读者

3、

普通人看不懂数学论文,但是圈外人不知道的是,数学家也看不懂很多数学论文。

-- 《科学》杂志

4、

大模型时代,我们正在失去一些珍贵的东西:自己独特的声音。

所有大模型生成的文章,看上去都像是同一个公共经理发布的。

如果你让大模型帮你写所有文章,你就放弃自己的声音了。你的声音是一种财富,是你一生的生活经历塑造而成,没有人的声音会和你完全一样。

-- 《大模型让我们失去声音》

往年回顾

示意图利器 D2(#329)

网络社区的悲剧(#279)

手机充电问题的解决(#229)

AR 技术的打开方式(#179)

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2025年12月 5日

[Python3/Java/C++/Go/TypeScript] 一题一解:前缀和(清晰题解)

作者 lcbin
2025年12月5日 07:44

方法一:前缀和

我们用两个变量 $l$ 和 $r$ 分别表示左子数组和右子数组的和,初始时 $l = 0$,而 $r = \sum_{i=0}^{n-1} \textit{nums}[i]$。

接下来,我们遍历前 $n - 1$ 个元素,每次将当前元素加到左子数组中,同时从右子数组中减去当前元素,然后判断 $l - r$ 是否为偶数,如果是则答案加一。

最后返回答案即可。

###python

class Solution:
    def countPartitions(self, nums: List[int]) -> int:
        l, r = 0, sum(nums)
        ans = 0
        for x in nums[:-1]:
            l += x
            r -= x
            ans += (l - r) % 2 == 0
        return ans

###java

class Solution {
    public int countPartitions(int[] nums) {
        int l = 0, r = 0;
        for (int x : nums) {
            r += x;
        }
        int ans = 0;
        for (int i = 0; i < nums.length - 1; ++i) {
            l += nums[i];
            r -= nums[i];
            if ((l - r) % 2 == 0) {
                ++ans;
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int countPartitions(vector<int>& nums) {
        int l = 0, r = accumulate(nums.begin(), nums.end(), 0);
        int ans = 0;
        for (int i = 0; i < nums.size() - 1; ++i) {
            l += nums[i];
            r -= nums[i];
            if ((l - r) % 2 == 0) {
                ++ans;
            }
        }
        return ans;
    }
};

###go

func countPartitions(nums []int) (ans int) {
l, r := 0, 0
for _, x := range nums {
r += x
}
for _, x := range nums[:len(nums)-1] {
l += x
r -= x
if (l-r)%2 == 0 {
ans++
}
}
return
}

###ts

function countPartitions(nums: number[]): number {
    let l = 0;
    let r = nums.reduce((a, b) => a + b, 0);
    let ans = 0;
    for (const x of nums.slice(0, -1)) {
        l += x;
        r -= x;
        ans += (l - r) % 2 === 0 ? 1 : 0;
    }
    return ans;
}

时间复杂度 $O(n)$,其中 $n$ 为数组 $\textit{nums}$ 的长度。空间复杂度 $O(1)$。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈 😄~

每日一题-统计元素和差值为偶数的分区方案🟢

2025年12月5日 00:00

给你一个长度为 n 的整数数组 nums 。

分区 是指将数组按照下标 i (0 <= i < n - 1)划分成两个 非空 子数组,其中:

  • 左子数组包含区间 [0, i] 内的所有下标。
  • 右子数组包含区间 [i + 1, n - 1] 内的所有下标。

对左子数组和右子数组先求元素 再做 ,统计并返回差值为 偶数分区 方案数。

 

示例 1:

输入:nums = [10,10,3,7,6]

输出:4

解释:

共有 4 个满足题意的分区方案:

  • [10][10, 3, 7, 6] 元素和的差值为 10 - 26 = -16 ,是偶数。
  • [10, 10][3, 7, 6] 元素和的差值为 20 - 16 = 4,是偶数。
  • [10, 10, 3][7, 6] 元素和的差值为 23 - 13 = 10,是偶数。
  • [10, 10, 3, 7][6] 元素和的差值为 30 - 6 = 24,是偶数。

示例 2:

输入:nums = [1,2,2]

输出:0

解释:

不存在元素和的差值为偶数的分区方案。

示例 3:

输入:nums = [2,4,6,8]

输出:3

解释:

所有分区方案都满足元素和的差值为偶数。

 

提示:

  • 2 <= n == nums.length <= 100
  • 1 <= nums[i] <= 100

脑筋急转弯(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2025年1月26日 12:06

设 $\textit{nums}$ 的元素和为 $S$,左子数组元素和为 $L$,那么右子数组的元素和为 $S-L$。

题目要求 $L - (S-L) = 2L - S$ 是偶数。由于 $2L$ 一定是偶数,所以只需关注 $S$ 的奇偶性:

  • 如果 $S$ 是奇数,偶数减奇数一定是奇数,答案是 $0$。
  • 如果 $S$ 是偶数,偶数减偶数一定是偶数,所有分区方案都符合要求,答案是 $n-1$。

上述结论与 $i$ 无关。

本题视频讲解,欢迎点赞关注~

###py

class Solution:
    def countPartitions(self, nums: List[int]) -> int:
        return 0 if sum(nums) % 2 else len(nums) - 1

###java

class Solution {
    public int countPartitions(int[] nums) {
        int s = Arrays.stream(nums).sum();
        return s % 2 != 0 ? 0 : nums.length - 1;
    }
}

###cpp

class Solution {
public:
    int countPartitions(vector<int>& nums) {
        int s = reduce(nums.begin(), nums.end());
        return s % 2 ? 0 : nums.size() - 1;
    }
};

###c

int countPartitions(int* nums, int numsSize) {
    int s = 0;
    for (int i = 0; i < numsSize; i++) {
        s += nums[i];
    }
    return s % 2 ? 0 : numsSize - 1;
}

###go

func countPartitions(nums []int) int {
s := 0
for _, x := range nums {
s += x
}
if s%2 == 0 {
return len(nums) - 1
}
return 0
}

###js

var countPartitions = function(nums) {
    return _.sum(nums) % 2 ? 0 : nums.length - 1;
};

###rust

impl Solution {
    pub fn count_partitions(nums: Vec<i32>) -> i32 {
        let s = nums.iter().sum::<i32>();
        if s % 2 != 0 { 0 } else { (nums.len() - 1) as _ }
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

专题训练

见下面贪心题单的「§5.2 脑筋急转弯」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

Trae Genimi3跟着官网学实时通信 Socket.io框架

2025年12月5日 02:30

3b74cd598a4f9c785f0fa84024044a3d.jpg

图片.pngsocket.io/zh-CN/docs/…

第1次对话:AI满意度⭐️⭐️

AI提示词:学习socket.io 请你按照这个文档 socket.io/docs/v4/tut… 创建一个中文网站,循序渐进的学习。

图片.png

图片.png

出现这个基于Node.js的Expressv5.2框架的聊天室,有点弱了,用户进入没有提示和昵称, 我的对AI表现的满意度是 ⭐️⭐️⭐️🈳🈳,可以给半⭐️给 版本比较新。

我需要的是一个学习Socekt.io的网站

AI给我一个Socekt.io开发的项目。

第2次对话:AI满意度⭐️⭐️⭐️⭐️

我需要的是一个 HTML网页文档来学习socket.io,并不是要用socket.io生成项目。使用工具pnpm,文档左侧应该有清晰的导航列表。如果涉及代码应该彩色高亮。

图片.png

第3次对话:AI满意度⭐️⭐️⭐️⭐️

对用 socket.on( ) 或者io 自定义的通道 事件、 关键字 都用emoji表情代替 ,同时 .getElementById 都用queryselector代替。并且列举出socket.io常用功能清单 仅对这个文件,尽可能用直观的中文+emoji

图片.png

图片.png

AI文档最终成品文档

1. 简介

Socket.IO 是一个基于事件的库,用于在浏览器和服务器之间实现实时双向低延迟的通信。

核心特性:

  • HTTP 长轮询作为回退机制(保证连接稳定性)
  • 自动重连
  • 数据包缓冲
  • 广播机制

2. 环境搭建 (pnpm)

首先,我们需要初始化一个新的 Node.js 项目。这里我们使用 pnpm 作为包管理工具。

初始化项目

打开终端,创建一个新目录并进入:

mkdir socket-chat-example
cd socket-chat-example

初始化 package.jsonpnpm init

安装依赖

在根目录下创建 package.json 文件,确保依赖项使用的是最新版本(如下所示):

{
  "dependencies": {
    "express": "^5.2.1",
    "socket.io": "^4.8.1"
  }
}

我们需要安装 Express 框架和 Socket.IO 库:

pnpm init

3. 创建服务器

创建 index.js 文件写入以下代码。我们需要设置一个基本的 Express 服务器,并将 Socket.IO 附加到它上面。

// index.js 文件代码
const express = require('express');
const { createServer } = require('node:http');
const { join } = require('node:path');
const { Server } = require('socket.io');

// 1. 创建 Express 实例
const app = express();
// 2. 创建 HTTP 服务器 (将 app 作为处理器)
const server = createServer(app);
// 3. 初始化 Socket.IO (传入 server)
const io = new Server(server);

// 路由:返回 HTML 文件
app.get('/', (req, res) => {
  res.sendFile(join(__dirname, 'index.html'));
});

// 监听连接事件 🔌
io.on('connection', (socket) => {
  console.log('👤 一个用户已连接');
  
  // 监听断开连接 ❌
  socket.on('disconnect', () => {
    console.log('👋 用户已断开连接');
  });
});

// 启动服务器
server.listen(3000, () => {
  console.log('🚀 服务器运行在 http://localhost:3000');
});

4. 创建客户端

创建 index.html 文件。这是用户看到的聊天界面。

<!DOCTYPE html>
<html>
<head>
    <title>Socket.IO 聊天室</title>
    <style>
        /* 简单的样式 */
        body { margin: 0; padding-bottom: 3rem; font-family: sans-serif; }
        #form { background: rgba(0, 0, 0, 0.15); padding: 0.25rem; position: fixed; bottom: 0; left: 0; right: 0; display: flex; height: 3rem; box-sizing: border-box; backdrop-filter: blur(10px); }
        #input { border: none; padding: 0 1rem; flex-grow: 1; border-radius: 2rem; margin: 0.25rem; }
        #input:focus { outline: none; }
        #form > button { background: #333; border: none; padding: 0 1rem; margin: 0.25rem; border-radius: 3px; outline: none; color: #fff; }
        #messages { list-style-type: none; margin: 0; padding: 0; }
        #messages > li { padding: 0.5rem 1rem; }
        #messages > li:nth-child(odd) { background: #efefef; }
    </style>
</head>
<body>
    <ul id="messages"></ul>
    <form id="form" action="">
      <input id="input" autocomplete="off" /><button>发送</button>
    </form>
</body>
</html>

5. 集成 Socket.IO

index.html</body> 标签之前,引入 Socket.IO 客户端脚本并初始化。

<!-- 自动提供的 Socket.IO 客户端库 -->
<script src="/socket.io/socket.io.js"></script>
<script>
  // 初始化连接 🔗
  const socket = io();
</script>

这里的 io() 默认会尝试连接当前主机。

6. 发送与接收消息

我们需要监听表单提交,将消息发送给服务器。注意这里使用了 querySelector 和 Emoji 事件名。

客户端发送 (Emit)

index.html 的 script 标签中添加:

// 使用 querySelector 获取元素 🔍
const form = document.querySelector('#form');
const input = document.querySelector('#input');

form.addEventListener('submit', (e) => {
  e.preventDefault(); // 阻止表单默认提交刷新页面
  if (input.value) {
    // 向服务器发送 '💬 聊天消息' 事件
    socket.emit('💬 聊天消息', input.value);
    input.value = ''; // 清空输入框
  }
});

服务器接收

index.js 中,我们需要监听这个 Emoji 事件:

io.on('connection', (socket) => {
  // 监听 '💬 聊天消息' 事件
  socket.on('💬 聊天消息', (msg) => {
    console.log('📩 收到消息: ' + msg);
  });
});

7. 广播消息

现在的代码只能在服务器端打印消息。我们需要把消息广播给所有连接的客户端。

服务器端广播

修改 index.js

io.on('connection', (socket) => {
  socket.on('💬 聊天消息', (msg) => {
    // io.emit 会发送给所有连接的客户端,包括发送者自己 📢
    io.emit('💬 聊天消息', msg);
  });
});

客户端显示消息

index.html 中,监听服务器发回的事件:

const messages = document.querySelector('#messages');

// 监听 '💬 聊天消息' 事件
socket.on('💬 聊天消息', (msg) => {
  const item = document.createElement('li');
  item.textContent = msg;
  messages.appendChild(item);
  window.scrollTo(0, document.body.scrollHeight); // 滚动到底部
});

8. Socket.IO 常用功能清单

图片.png

9. 总结

恭喜! 你已经完成了一个基本的实时聊天应用。

关键 API 回顾:

  • pnpm add socket.io - 安装服务端库。
  • const io = new Server(server) - 服务端初始化。
  • const socket = io() - 客户端初始化。
  • socket.emit('💬 消息', data) - 发送消息。
  • socket.on('💬 消息', callback) - 接收消息。
  • io.emit('💬 消息', data) - 广播给所有人。

附录:

注:差点点了自动翻译网页,官网其实有中文选项,但存在切换BUG(失效)和翻译不完整等问题。 你看教程这里又是英文了。 那么跟着

图片.png

终于找到你Gemini-3-Pro(200K),得取消 Auto Mode 并且不能用 Trae Solo模式才有 fab9b7c0a111a741fb37e9de10cef21a.png

JavaScript 拷贝全解析:从浅拷贝到深拷贝的完整指南

作者 1024肥宅
2025年12月4日 23:37

引言

在Javascript开发中,数据拷贝是我们每天都会遇到的基础操作。然而,这个看似简单的概念背后隐藏着许多陷阱和细节。错误的数据拷贝可能导致难以调试的bug、内存泄漏甚至程序崩溃。

你是否曾遇到过:

  • 修改一个对象后,另一个"独立"的对象也被意外修改?
  • 尝试复制包含函数、Date对象或循环引用的数据结构时失败?
  • 在处理大型数据集时,拷贝导致性能急剧下降?

本文将从基础概念出发,深入探讨JavaScript中的各种拷贝技术,提供完整的实现方案,并帮助你根据不同的场景选择最合适的拷贝策略。

一、理解JavaScript的数据类型

在深入拷贝之前,我们需要先理解JavaScript的数据类型,因为不同类型的数据在拷贝时有根本性的区别。

1.1 基本类型(Primitive Types)

JavaScript有7种基本数据类型:

// 基本类型 - 按值存储,拷贝时直接复制值
const str = 'Hello';      // String
const num = 42;           // Number
const bool = true;        // Boolean
const nullValue = null;   // Null
const undefinedValue;      // Undefined
const sym = Symbol('id'); // Symbol(ES6)
const bigInt = 123n;      // BigInt(ES2020)
1.2 引用类型(Reference Types)
// 引用类型 - 按引用存储,拷贝时复制引用
const obj = { name: 'John' }; // Object
const arr = [1, 2, 3];        // Array
const func = () => {};        // Function
const date = new Date();      // Date
const regex = /pattern/gi;    // RegExp
const map = new Map();        // Map
const set = new Set();        // Set
1.3 内存模型图解
// 基本类型 - 栈内存存储
let a = 10; // 栈: a = 10
let b = a;  // 栈: b = 10 (值的拷贝)
b = 20;     // 栈: b = 20, a 保持不变

// 引用类型 - 堆内存存储
let obj1 = { x: 10 }; // 栈: obj1 -> 堆地址1 {x: 10}
let obj2 = obj1;      // 栈: obj2 -> 同一个堆地址1
obj2.x = 20;          // 堆地址1: {x: 20}, obj1.x 也变为 20

理解这个区别是掌握拷贝技术的基础。接下来,我们开始探讨具体的拷贝方法。

二、浅拷贝(Shallow Copy)

浅拷贝创建一个新对象,复制原始对象的所有属性值到新对象。如果属性值是基本类型,则复制值; 如果是引用类型,则复制引用。

2.1 对象浅拷贝方法
方法1: 展开运算符(Spread Operator) - ES6+
const original = {
  name: 'John',
  age: 30,
  hobbies: ['reading', 'gaming'],
  address: {
    city: 'Shang Hai',
    zip: '120001'
  }
};

const shallowCopy = { ...original };

// 测试
console.log(shallowCopy === original); // false - 是新对象
console.log(shallowCopy.hobbies === original.hobbies); // true - 引用相同
console.log(shallowCopy.address === original.address); // true - 引用相同

// 修改嵌套对象会影响原对象
shallowCopy.hobbies.push("coding");
console.log(original.hobbies); // [ 'reading', 'gaming', 'coding' ] - 被影响
方法2: Object.assign() - ES6
const shallowCopy2 = Object.assign({}, original);

// Object.assign 可以合并多个对象
const obj1 = { a: 1 };
const obj2 = { b: 2 };
const obj3 = { c: 3 };
const merged = Object.assign({}, obj1, obj2, obj3);
console.log(merged); // { a: 1, b: 2, c: 3 }
方法3: 手动实现浅拷贝
function shallowCopy(obj) {
  // 处理 null 和 undefined
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // 处理数组
  if (Array.isArray(obj)) {
    return [...obj];
  }

  // 处理对象
  const copy = {};
  for (const key in obj) {
    // 只拷贝对象自身的属性(不包括原型链上的属性)
    if (obj.hasOwnProperty(key)) {
      copy[key] = obj[key];
    }
  }

  return copy;
}

// 测试
const testObj = { a: 1, b: { c: 2 } };
const copied = shallowCopy(testObj);
console.log(copied.b === testObj.b); // true - 浅拷贝
方法4: 使用 Object.create() (原型链拷贝)
// 这种方法会保持原型链
function shallowCopyWithPrototype(obj) {
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // 创建一个新对象,继承原对象的原型
  const copy = Object.create(Object.getPrototypeOf(obj));

  // 拷贝自有属性
  Object.getOwnPropertyNames(obj).forEach((prop) => {
    const descriptor = Object.getOwnPropertyDescriptor(obj, prop);
    Object.defineProperty(copy, prop, descriptor);
  });

  return copy;
}

// 测试
const protoObj = { inherited: "from prototype" };
const objWithProto = Object.create(protoObj);
objWithProto.own = "own prototype";

const copiedWithProto = shallowCopyWithPrototype(objWithProto);
console.log(copiedWithProto.inherited); // from prototype - 继承原型
console.log(copiedWithProto.own); // own prototype
2.2 数组浅拷贝方法
方法1: 展开运算符
const originalArray = [1, 2, 3, { x: 4 }];
const shallowArray = [...originalArray];

// 修改基本类型不会影响原数组
shallowArray[0] = 100;
console.log(originalArray[0]); // 1 - 不受影响

// 修改引用类型会影响原数组
shallowArray[3].x = 400;
console.log(originalArray[3].x); // 400 - 受影响!
方法2: slice()方法
const shallowArray2 = originalArray.slice();
// 效果与展开运算符相同
方法3: concat()方法
const shallowArray3 = originalArray.concat();
// 效果与展开运算符相同
方法4: Array.from() - ES6
const shallowArray4 = Array.from(originalArray);
方法5: 手动实现数组浅拷贝
function shallowCopyArray(arr) {
  if (!Array.isArray(arr)) {
    throw new TypeError("Excepted an array");
  }

  const copy = new Array(arr.length);
  for (let i = 0; i < arr.length; i++) {
    copy[i] = arr[i];
  }
  return copy;
}
2.3 浅拷贝的局限性

浅拷贝的主要问题是:

  1. 嵌套对象问题: 只拷贝一层,嵌套的对象仍然是共享的
  2. 对象共享问题: 修改浅拷贝对象的引用类型属性会影响原对象
  3. 特殊对象的引用共享: 对于Date、RegExp等特殊对象, 浅拷贝只复制引用, 不会创建新实例

三、深拷贝(Deep Copy)

深拷贝会创建一个完全独立的新对象, 递归复制所有嵌套的对象和数组, 使新对象与原对象完全分离。

3.1 使用 JSON 方法(最简单但有限制)
const deepCopyWithJSON = JSON.parse(JSON.stringify(original));

// 测试
const obj = {
  name: "John",
  date: new Date(),
  func: () => console.log("hello"),
  undef: undefined,
  symbol: Symbol("id"),
  infinity: Infinity,
  nan: NaN,
  regex: /pattern/gi,
  set: new Set([1, 2, 3]),
  map: new Map([["key", "value"]]),
  nested: { a: 1 },
};

const jsonCopy = JSON.parse(JSON.stringify(obj));
console.log(jsonCopy);
// 输出:
// {
//   name: 'John',
//   date: '2025-12-04T14:13:18.238Z', // Date 变成了字符串
//   func 不存在 // 函数被省略
//   undef 不存在 // undefined 被省略
//   symbol 不存在 // Symbol 被省略
//   infinity: null, // Infinity 变成了 null
//   nan: null, // NaN 变成了 null
//   regex: {}, // RegExp 变成了空对象
//   set: {}, // Set 变成了空对象
//   map: {}, // Map 变成了空对象
//   nested: { a: 1 }
// }

JSON方法的限制:

  • 无法拷贝函数
  • 无法拷贝undefined
  • 无法拷贝Symbol
  • 无法拷贝循环引用
  • 特殊对象(Date、RegExp、Set、Map等)会被错误处理
  • 会忽略原型链
3.2 递归实现深拷贝

基础递归实现

function deepClone(obj, hash = new WeakMap()) {
  // 处理基本类型和 null/undefined
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // 处理 Date 对象
  if (obj instanceof Date) {
    return new Date(obj.getTime());
  }

  // 处理正则表达式
  if (obj instanceof RegExp) {
    return new RegExp(obj.source, obj.flags);
  }

  // 处理数组
  if (Array.isArray(obj)) {
    return obj.map((item) => deepClone(item, hash));
  }

  // 处理普通对象 - 检查循环引用
  if (hash.has(obj)) {
    return hash.get(obj);
  }

  const clone = {};
  hash.set(obj, clone);

  // 拷贝所有属性, 包括不可枚举属性(可选)
  const props = Object.getOwnPropertyNames(obj);
  for (const prop of props) {
    clone[prop] = deepClone(obj[prop], hash);
  }

  return clone;
}

// 测试基础功能
const testObj = {
  name: "Test",
  date: new Date(),
  regex: /test/gi,
  arr: [1, 2, { nested: true }],
  nested: {
    level1: {
      level2: "deep",
    },
  },
};

const cloned = deepClone(testObj);
console.log(cloned.date instanceof Date); // true
console.log(cloned.regex instanceof RegExp); // true
console.log(cloned.arr[2] === testObj.arr[2]); // false - 深拷贝成功

支持更多数据类型的完整实现

function deepCloneComplete(target, map = new WeakMap()) {
  // 基本类型直接返回
  if (target === null || typeof target !== "object") {
    return target;
  }

  // 检查循环引用
  if (map.has(target)) {
    return map.get(target);
  }

  // 克隆特殊对象类型
  // Date
  if (target instanceof Date) {
    return new Date(target);
  }

  // RegExp
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }

  // Map
  if (target instanceof Map) {
    const clone = new Map();
    map.set(target, clone);
    target.forEach((value, key) => {
      clone.set(deepCloneComplete(key, map), deepCloneComplete(value, map));
    });
    return clone;
  }

  // Set
  if (target instanceof Set) {
    const clone = new Set();
    map.set(target, clone);
    target.forEach((value) => {
      clone.add(deepCloneComplete(value, map));
    });
    return clone;
  }

  // ArrayBuffer
  if (target instanceof ArrayBuffer) {
    return target.slice(0);
  }

  // TypedArrat (Int8Array, Uint8Array, etc.)
  if (ArrayBuffer.isView(target)) {
    return new target.constructor(target);
  }

  // Array
  if (Array.isArray(target)) {
    const arrClone = [];
    map.set(target, arrClone);
    for (let i = 0; i < target.length; i++) {
      arrClone[i] = deepCloneComplete(target[i], map);
    }
    return arrClone;
  }

  // 普通对象
  const objClone = Object.create(Object.getPrototypeOf(target));
  map.set(target, objClone);

  // 获取所有属性(包括 Symbol)
  const allKeys = Reflect.ownKeys(target);

  for (const key of allKeys) {
    const descriptor = Object.getOwnPropertyDescriptor(target, key);

    if (descriptor) {
      if (descriptor.hasOwnProperty("value")) {
        // 数据属性
        objClone[key] = deepCloneComplete(target[key], map);
      } else {
        // 访问器属性 (getter/setter)
        Object.defineProperty(objClone, key, descriptor);
      }
    }
  }

  return objClone;
}

// 测试完整功能
const complexObj = {
  string: "hello",
  number: 42,
  boolean: true,
  null: null,
  undefined: undefined,
  symbol: Symbol("test"),
  date: new Date(),
  regex: /test/gi,
  array: [1, 2, { nested: true }],
  map: new Map([["key", { value: "map value" }]]),
  set: new Set([1, 2, 3]),
  buffer: new ArrayBuffer(8),
  uintArray: new Uint8Array([1, 2, 3]),
  object: {
    nested: {
      deeply: "nested value",
    },
  },
  get computed() {
    return this.string.toUpperCase();
  },
  method() {
    return this.string;
  },
};

// 添加循环引用
complexObj.self = complexObj;
complexObj.circular = { parent: complexObj };

const completeClone = deepCloneComplete(complexObj);

console.log(completeClone.date instanceof Date); // true
console.log(completeClone.regex instanceof RegExp); // true
console.log(completeClone.map instanceof Map); // true
console.log(completeClone.uintArray instanceof Uint8Array); // true
console.log(completeClone.computed); // 'HELLO'
console.log(completeClone.method()); // 'hello'
console.log(completeClone.self === completeClone); // true - 循环引用正确处理
console.log(completeClone.circular.parent === completeClone); // true
3.3 使用 structuredClone API(现代浏览器)

HTML5规范引入了structuredClone()方法, 提供了一种标准化的深拷贝方法。

// 浏览器环境中的使用
const original = {
    name: 'John',
    date: new Date(),
    array: [1, 2, 3],
    nested: { value: 'test' }
};

try {
    const cloned = structuredClone(original);
    console.log(cloned.date instanceof Date); // true
    console.log(cloned.nested === original.nested); // false
} catch (err) {
    console.log('structuredClone not supported:', err);
}

// Node.js 中的使用(v17+)
if (typeof structuredClone === 'function') {
    const cloned = structuredClone(original);
}

// structuredClone 支持的数据类型:
// - 基本类型(除 Symbol)
// - Boolean、Number、String 对象
// - Date
// - RegExp
// - ArrayBuffer、TypedArray
// - Map、Set
// - Array、Object
// - 循环引用

// 不支持:
// - 函数
// - DOM 节点
// - Error 对象
// - 原型链
3.4 使用第三方库

对于生产环境, 使用成熟的第三方库通常是更好的选择: Lodash_.cloneDeep

// 使用 Lodash
const _ = require('lodash');

const obj = {
    date: new Date(),
    regex: /test/gi,
    func: () => console.log('hi'),
    nested: { a: 1 }
};

const cloned = _.cloneDeep(obj);
console.log(cloned.date instanceof Date); // true
console.log(cloned.regex instanceof RegExp); // true
console.log(typeof cloned.func); // 'function' - 函数被保留

自己实现类似LodashcloneDeep

function cloneDeep(value, stack = new Map()) {
    // 基本类型直接返回
    if (value === null || typeof value !== 'object') {
        return value;
    }
    
    // 检查循环引用
    if (stack.has(value)) {
        return stack.get(value);
    }
    
    let clone;
    
    // 处理特殊对象
    if (value instanceof Date) {
        clone = new Date(value.getTime());
        stack.set(value, clone);
        return clone;
    }
    
    if (value instanceof RegExp) {
        clone = new RegExp(value.source, value.flags);
        stack.set(value, clone);
        return clone;
    }
    
    if (value instanceof Map) {
        clone = new Map();
        stack.set(value, clone);
        value.forEach((val, key) => {
            clone.set(cloneDeep(key, stack), cloneDeep(val, stack));
        });
        return clone;
    }
    
    if (value instanceof Set) {
        clone = new Set();
        stack.set(value, clone);
        value.forEach(val => {
            clone.add(cloneDeep(val, stack));
        });
        return clone;
    }
    
    if (Array.isArray(value)) {
        clone = [];
        stack.set(value, clone);
        for (let i = 0; i < value.length; i++) {
            clone[i] = cloneDeep(value[i], stack);
        }
        return clone;
    }
    
    // 处理普通对象
    clone = Object.create(Object.getPrototypeOf(value));
    stack.set(value, clone);
    
    // 拷贝所有属性
    for (const key in value) {
        if (value.hasOwnProperty(key)) {
            clone[key] = cloneDeep(value[key], stack);
        }
    }
    
    return clone;
}

四、特殊场景和边缘情况

4.1 循环引用处理

循环引用是深拷贝中最棘手的问题之一, 处理不当会导致无限递归和栈溢出

// 循环引用示例
const circularObj = { name: 'Circular' };
circularObj.self = circularObj;
circularObj.ref = { parent: circularObj };

// 处理循环引用的深拷贝实现
function cloneDeepWithCircular(obj, cache = new WeakMap()) {
    // 非对象直接返回
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }
    
    // 检查缓存中是否已有该对象的拷贝
    if (cache.has(obj)) {
        return cache.get(obj);
    }
    
    // 根据对象类型创建相应的空结构
    let clone;
    if (obj instanceof Date) {
        clone = new Date(obj);
    } else if (obj instanceof RegExp) {
        clone = new RegExp(obj.source, obj.flags);
    } else if (obj instanceof Map) {
        clone = new Map();
    } else if (obj instanceof Set) {
        clone = new Set();
    } else if (Array.isArray(obj)) {
        clone = [];
    } else {
        clone = Object.create(Object.getPrototypeOf(obj));
    }
    
    // 将空结构存入缓存(在递归前存入,防止无限递归)
    cache.set(obj, clone);
    
    // 递归拷贝
    if (obj instanceof Map) {
        obj.forEach((value, key) => {
            clone.set(
                cloneDeepWithCircular(key, cache),
                cloneDeepWithCircular(value, cache)
            );
        });
    } else if (obj instanceof Set) {
        obj.forEach(value => {
            clone.add(cloneDeepWithCircular(value, cache));
        });
    } else if (Array.isArray(obj)) {
        for (let i = 0; i < obj.length; i++) {
            clone[i] = cloneDeepWithCircular(obj[i], cache);
        }
    } else {
        for (const key in obj) {
            if (obj.hasOwnProperty(key)) {
                clone[key] = cloneDeepWithCircular(obj[key], cache);
            }
        }
    }
    
    return clone;
}

// 测试循环引用
const testCircular = { a: 1 };
testCircular.b = testCircular;
testCircular.c = { ref: testCircular };

const clonedCircular = cloneDeepWithCircular(testCircular);
console.log(clonedCircular.b === clonedCircular); // true
console.log(clonedCircular.c.ref === clonedCircular); // true
console.log(clonedCircular !== testCircular); // true - 不是同一个对象
4.2 函数拷贝

函数拷贝是一个有争议的话题,因为函数可能依赖于闭包中的外部变量。

// 函数拷贝的几种方法
function cloneFunction(func) {
    // 方法1:使用 eval(不推荐,安全问题)
    const funcString = func.toString();
    
    // 方法2:使用 Function 构造函数
    const clonedFunc = new Function('return ' + funcString)();
    
    // 拷贝函数属性
    Object.getOwnPropertyNames(func).forEach(prop => {
        if (prop !== 'length' && prop !== 'name' && prop !== 'prototype') {
            Object.defineProperty(clonedFunc, prop, 
                Object.getOwnPropertyDescriptor(func, prop));
        }
    });
    
    // 拷贝原型
    clonedFunc.prototype = func.prototype;
    
    return clonedFunc;
}

// 实际使用中,通常不拷贝函数,而是保留引用
function cloneDeepWithFunction(obj, cache = new Map()) {
    if (typeof obj === 'function') {
        return obj; // 直接返回函数引用
    }
    
    // ... 其他类型的处理
}

// 测试
const objWithFunc = {
    name: 'Test',
    sayHello: function() {
        console.log(`Hello, ${this.name}`);
    },
    arrowFunc: () => console.log('Arrow')
};

const clonedWithFunc = cloneDeepWithFunction(objWithFunc);
clonedWithFunc.name = 'Cloned';
clonedWithFunc.sayHello(); // Hello, Cloned
4.3 DOM元素拷贝

DOM元素有特殊的拷贝需求:

function cloneDOMElement(element, deep = true) {
    // 使用 cloneNode 方法
    const cloned = element.cloneNode(deep);
    
    // 处理事件监听器
    // 注意:cloneNode 不会拷贝事件监听器
    
    // 处理数据属性
    if (element.dataset) {
        Object.assign(cloned.dataset, element.dataset);
    }
    
    // 处理自定义属性
    const attributes = element.attributes;
    for (let i = 0; i < attributes.length; i++) {
        const attr = attributes[i];
        if (attr.name.startsWith('data-') || attr.name.startsWith('aria-')) {
            cloned.setAttribute(attr.name, attr.value);
        }
    }
    
    return cloned;
}

// 使用示例
// const originalDiv = document.getElementById('original');
// const clonedDiv = cloneDOMElement(originalDiv, true);
// document.body.appendChild(clonedDiv);
4.4 性能优化技巧

深拷贝可能成为性能瓶颈,特别是处理大型对象时。

// 性能优化的深拷贝
function fastDeepClone(obj, cache = new WeakMap()) {
    // 快速路径:基本类型
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }
    
    // 检查缓存
    if (cache.has(obj)) {
        return cache.get(obj);
    }
    
    let clone;
    
    // 使用构造函数快速创建对象
    const Ctor = obj.constructor;
    
    switch (Ctor) {
        case Date:
            clone = new Date(obj);
            break;
        case RegExp:
            clone = new RegExp(obj);
            break;
        case Map:
            clone = new Map();
            cache.set(obj, clone);
            obj.forEach((value, key) => {
                clone.set(fastDeepClone(key, cache), fastDeepClone(value, cache));
            });
            return clone;
        case Set:
            clone = new Set();
            cache.set(obj, clone);
            obj.forEach(value => {
                clone.add(fastDeepClone(value, cache));
            });
            return clone;
        case Array:
            clone = new Array(obj.length);
            cache.set(obj, clone);
            for (let i = 0; i < obj.length; i++) {
                clone[i] = fastDeepClone(obj[i], cache);
            }
            return clone;
        default:
            // 普通对象
            clone = Object.create(Object.getPrototypeOf(obj));
            cache.set(obj, clone);
    }
    
    // 快速属性拷贝
    const keys = Object.keys(obj);
    for (let i = 0; i < keys.length; i++) {
        const key = keys[i];
        clone[key] = fastDeepClone(obj[key], cache);
    }
    
    return clone;
}

// 性能对比
const largeObj = {};
for (let i = 0; i < 10000; i++) {
    largeObj[`key${i}`] = {
        nested: { value: i },
        array: new Array(10).fill(i)
    };
}

console.time('JSON 深拷贝');
JSON.parse(JSON.stringify(largeObj));
console.timeEnd('JSON 深拷贝');

console.time('递归深拷贝');
fastDeepClone(largeObj);
console.timeEnd('递归深拷贝');

五、实践应用和最佳实践

5.1 何时使用浅拷贝

适合浅拷贝的场景:

  1. 简单数据结构: 对象只有一层,没有嵌套
  2. 性能敏感: 需要快速拷贝,不关心嵌套对象的独立性
  3. 不可变数据: 数据不会被修改,或修改时创建新对象
  4. 配置对象: 只需要修改顶层配置
// 浅拷贝适用场景
const config = {
    apiUrl: 'https://api.example.com',
    timeout: 5000,
    headers: {
        'Content-Type': 'application/json'
    }
};

// 只需要修改顶层配置时
const devConfig = { ...config, apiUrl: 'https://dev-api.example.com' };
// headers 对象仍然是共享的,但这通常是可以接受的
5.2 何时使用深拷贝

适合深拷贝的场景:

  1. 复杂嵌套结构: 对象有多层嵌套,需要完全独立
  2. 状态管理:ReduxVuex中修改状态时
  3. 不可变更新: 函数式编程中创建新状态
  4. 数据隔离: 防止原始数据被意外修改
  5. 缓存数据: 保存数据快照
// 深拷贝适用场景 - Redux reducer
function todoReducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            // 需要深拷贝来创建新状态
            return {
                ...state,
                todos: [
                    ...state.todos,
                    {
                        id: action.id,
                        text: action.text,
                        completed: false
                    }
                ]
            };
        case 'TOGGLE_TODO':
            // 深度更新嵌套对象
            return {
                ...state,
                todos: state.todos.map(todo =>
                    todo.id === action.id
                        ? { ...todo, completed: !todo.completed }
                        : todo
                )
            };
        default:
            return state;
    }
}
5.3 性能优化策略
  1. 按需拷贝: 只拷贝需要修改的部分
  2. 结构共享: 使用不可变数据结构库(如Immutable.js)
  3. 增量更新: 只更新变化的部分
  4. 缓存结果: 对于相同输入返回缓存的结果
// 按需拷贝示例
function updateUser(user, updates) {
    // 只深拷贝需要修改的部分
    const updatedUser = { ...user };
    
    if (updates.address) {
        updatedUser.address = { ...user.address, ...updates.address };
    }
    
    if (updates.preferences) {
        updatedUser.preferences = { ...user.preferences, ...updates.preferences };
    }
    
    // 其他属性直接浅拷贝
    Object.keys(updates).forEach(key => {
        if (key !== 'address' && key !== 'preferences') {
            updatedUser[key] = updates[key];
        }
    });
    
    return updatedUser;
}

// 使用代理实现惰性拷贝
function createLazyCopy(original) {
    const changes = new Map();
    const handler = {
        get(target, prop) {
            // 如果该属性有修改,返回修改后的值
            if (changes.has(prop)) {
                return changes.get(prop);
            }
            
            // 否则返回原始值
            const value = target[prop];
            
            // 如果是对象,则返回代理
            if (value && typeof value === 'object') {
                return new Proxy(value, handler);
            }
            
            return value;
        },
        set(target, prop, value) {
            // 记录修改
            changes.set(prop, value);
            return true;
        }
    };
    
    return new Proxy(original, handler);
}
5.4 安全性考虑
  1. 避免原型污染: 确保不会拷贝__proto__等特殊属性
  2. 防止恶意对象: 处理具有getter的执行可能引发副作用的对象
  3. 内存安全: 避免拷贝会导致内存泄漏的大型对象
// 安全的深拷贝实现
function safeDeepClone(obj, options = {}) {
    const {
        maxDepth = 100,
        maxSize = 10000,
        allowFunctions = false,
        allowSymbols = true
    } = options;
    
    let size = 0;
    
    function clone(current, depth, cache) {
        // 检查深度限制
        if (depth > maxDepth) {
            throw new Error('Maximum depth exceeded');
        }
        
        // 检查大小限制
        if (size > maxSize) {
            throw new Error('Maximum size exceeded');
        }
        
        // 基本类型处理
        if (current === null || typeof current !== 'object') {
            // 检查 Symbol
            if (typeof current === 'symbol' && !allowSymbols) {
                throw new Error('Symbols are not allowed');
            }
            return current;
        }
        
        // 检查函数
        if (typeof current === 'function') {
            if (!allowFunctions) {
                throw new Error('Functions are not allowed');
            }
            return current;
        }
        
        // 检查缓存(防止循环引用)
        if (cache.has(current)) {
            return cache.get(current);
        }
        
        // 根据类型创建空对象
        let clone;
        const Ctor = current.constructor;
        
        switch (Ctor) {
            case Date:
                clone = new Date(current);
                break;
            case RegExp:
                clone = new RegExp(current);
                break;
            case Map:
                clone = new Map();
                break;
            case Set:
                clone = new Set();
                break;
            case Array:
                clone = [];
                break;
            default:
                // 普通对象 - 避免原型污染
                clone = Object.create(null);
        }
        
        // 存入缓存
        cache.set(current, clone);
        
        // 递归拷贝
        if (current instanceof Map) {
            current.forEach((value, key) => {
                size++;
                clone.set(
                    clone(key, depth + 1, cache),
                    clone(value, depth + 1, cache)
                );
            });
        } else if (current instanceof Set) {
            current.forEach(value => {
                size++;
                clone.add(clone(value, depth + 1, cache));
            });
        } else if (Array.isArray(current)) {
            for (let i = 0; i < current.length; i++) {
                size++;
                clone[i] = clone(current[i], depth + 1, cache);
            }
        } else {
            for (const key in current) {
                // 避免拷贝原型链上的属性
                if (current.hasOwnProperty(key)) {
                    // 避免特殊属性
                    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
                        continue;
                    }
                    
                    size++;
                    clone[key] = clone(current[key], depth + 1, cache);
                }
            }
        }
        
        return clone;
    }
    
    return clone(obj, 0, new WeakMap());
}

六、现在JavaScript中的拷贝模式

6.1 不可变数据模式
// 使用 Object.freeze 实现浅不可变
const immutableConfig = Object.freeze({
    apiUrl: 'https://api.example.com',
    timeout: 5000,
    headers: Object.freeze({
        'Content-Type': 'application/json'
    })
});

// 深度冻结
function deepFreeze(obj) {
    Object.freeze(obj);
    
    Object.getOwnPropertyNames(obj).forEach(prop => {
        const value = obj[prop];
        
        if (value && typeof value === 'object' && !Object.isFrozen(value)) {
            deepFreeze(value);
        }
    });
    
    return obj;
}

// 使用 Proxy 实现不可变接口
function createImmutable(obj) {
    const handler = {
        get(target, prop) {
            const value = target[prop];
            
            // 如果是对象,返回代理
            if (value && typeof value === 'object') {
                return createImmutable(value);
            }
            
            return value;
        },
        set() {
            throw new Error('Cannot modify immutable object');
        },
        deleteProperty() {
            throw new Error('Cannot delete property from immutable object');
        }
    };
    
    return new Proxy(obj, handler);
}
6.2 结构共享(Persistent Data Structures)
// 简化的结构共享实现
class PersistentMap {
    constructor(data = {}) {
        this.data = data;
        this.version = 0;
    }
    
    set(key, value) {
        // 创建新版本,共享未修改的数据
        const newData = { ...this.data };
        newData[key] = value;
        
        const newMap = new PersistentMap(newData);
        newMap.version = this.version + 1;
        
        return newMap;
    }
    
    get(key) {
        return this.data[key];
    }
    
    // 比较两个版本是否相等
    equals(other) {
        if (this === other) return true;
        if (this.version !== other.version) return false;
        
        // 深度比较(简化版)
        return JSON.stringify(this.data) === JSON.stringify(other.data);
    }
}

// 使用示例
const map1 = new PersistentMap({ a: 1, b: 2 });
const map2 = map1.set('c', 3);
const map3 = map2.set('b', 20);

console.log(map1.get('b')); // 2
console.log(map3.get('b')); // 20
console.log(map1.data === map2.data); // false
6.3 使用现代API
// 使用 Object.groupBy 和 Map (ES2024)
const users = [
    { id: 1, name: 'Alice', group: 'admin' },
    { id: 2, name: 'Bob', group: 'user' },
    { id: 3, name: 'Charlie', group: 'admin' }
];

// 分组并创建不可变结构
const grouped = Object.groupBy(users, user => user.group);

// 转换为不可变 Map
const immutableGroups = new Map(Object.entries(grouped));

// 深度冻结
function deepFreezeMap(map) {
    map.forEach(value => {
        if (value && typeof value === 'object') {
            deepFreeze(value);
        }
    });
    Object.freeze(map);
}

deepFreezeMap(immutableGroups);

// 创建新版本
const updatedGroups = new Map(immutableGroups);
updatedGroups.set('moderator', [{ id: 4, name: 'David', group: 'moderator' }]);

七、总结与最佳实践建议

7.1 拷贝方法选择指南
场景 推荐方法 理由
简单对象,无嵌套 浅拷贝({...obj}) 快速、简单、高效
配置对象,少量嵌套 浅拷贝+手动处理嵌套 平衡性能和正确性
复杂嵌套对象 深拷贝(递归或sutrcturedClone) 确保完全独立
包含特殊类型(Date、RegExp) 自定义深拷贝或Lodash 正确处理特殊对象
性能关键路径 按需拷贝+结构共享 最大化性能
不可变数据 深拷贝+Object.freeze 确保数据不可变
生产环境 Lodash的_.cloneDeep 成熟、稳定、功能全
7.2 黄金法则
  1. 明确需求: 先确定是否需要深拷贝,很多时候浅拷贝就足够了
  2. 测试边界情况: 总是测试循环引用、特殊对象和大型数据结构
  3. 考虑性能: 对于频繁操作的数据,考虑使用不可变数据结构
  4. 保持简洁: 避免过度复杂的拷贝逻辑,必要时使用成熟的库
  5. 安全性第一: 处理用户输入时要特别小心,避免原型污染和其他安全问题
7.3 未来趋势
  1. 结构化克隆 API: structuredClone()将成为深拷贝的标准方式
  2. Records和Tuples: ES提案,提供原生不可变数据结构
  3. 更快的拷贝算法: WebAssembly和新的 JavaScript 引擎优化
  4. 编译时优化: 通过静态分析优化拷贝操作
7.4 最终建议代码
// 生产环境推荐的拷贝工具函数
class CloneUtils {
    // 简单的深拷贝(适合大多数场景)
    static deepClone(obj) {
        // 优先使用原生 API
        if (typeof structuredClone === 'function') {
            try {
                return structuredClone(obj);
            } catch (e) {
                // 如果失败,回退到其他方法
            }
        }
        
        // 回退到 JSON 方法(有限制)
        try {
            return JSON.parse(JSON.stringify(obj));
        } catch (e) {
            // 如果 JSON 方法失败,使用自定义实现
            return this.customDeepClone(obj);
        }
    }
    
    // 自定义深拷贝实现
    static customDeepClone(obj, cache = new WeakMap()) {
        // 基础类型和函数
        if (obj === null || typeof obj !== 'object') {
            return obj;
        }
        
        // 检查缓存
        if (cache.has(obj)) {
            return cache.get(obj);
        }
        
        // 处理特殊对象
        if (obj instanceof Date) {
            const cloned = new Date(obj);
            cache.set(obj, cloned);
            return cloned;
        }
        
        if (obj instanceof RegExp) {
            const cloned = new RegExp(obj);
            cache.set(obj, cloned);
            return cloned;
        }
        
        // 创建空对象/数组
        const cloned = Array.isArray(obj) ? [] : {};
        cache.set(obj, cloned);
        
        // 递归拷贝属性
        for (const key in obj) {
            if (obj.hasOwnProperty(key)) {
                cloned[key] = this.customDeepClone(obj[key], cache);
            }
        }
        
        return cloned;
    }
    
    // 安全的浅拷贝(防止原型污染)
    static safeShallowClone(obj) {
        if (obj === null || typeof obj !== 'object') {
            return obj;
        }
        
        const cloned = Array.isArray(obj) ? [] : {};
        
        for (const key in obj) {
            if (obj.hasOwnProperty(key) && 
                key !== '__proto__' && 
                key !== 'constructor' && 
                key !== 'prototype') {
                cloned[key] = obj[key];
            }
        }
        
        return cloned;
    }
    
    // 性能优化的拷贝(只拷贝需要修改的部分)
    static smartClone(original, modifications) {
        const result = { ...original };
        
        for (const key in modifications) {
            if (modifications.hasOwnProperty(key)) {
                const originalValue = original[key];
                const modifiedValue = modifications[key];
                
                if (originalValue && typeof originalValue === 'object' &&
                    modifiedValue && typeof modifiedValue === 'object' &&
                    !Array.isArray(originalValue) && !Array.isArray(modifiedValue)) {
                    // 递归处理嵌套对象
                    result[key] = this.smartClone(originalValue, modifiedValue);
                } else {
                    result[key] = modifiedValue;
                }
            }
        }
        
        return result;
    }
}

// 使用示例
const data = {
    user: {
        name: 'John',
        settings: {
            theme: 'dark',
            notifications: true
        }
    },
    items: [1, 2, 3]
};

// 简单深拷贝
const cloned1 = CloneUtils.deepClone(data);

// 智能拷贝(只修改部分)
const cloned2 = CloneUtils.smartClone(data, {
    user: {
        settings: {
            theme: 'light'
        }
    }
});

console.log(cloned2.user.settings.theme); // 'light'
console.log(cloned2.user.settings.notifications); // true(保持原值)
console.log(cloned2.items === data.items); // true(未修改的部分共享引用)

结语

JavaScript拷贝是一个看似简单实则复杂的话题。通过本文的学习,你应该能够:

  1. 理解浅拷贝和深拷贝的根本区别
  2. 根据不同的场景选择合适的拷贝策略
  3. 实现各种拷贝方法,处理边界情况
  4. 优化拷贝性能,避免常见陷阱 记住,没有一种拷贝方法是适用于所有场景的万能解决方案。最好的方法是理解每种技术的优缺点,根据具体需求做出明智的选择。

在实际开发中,当面临拷贝需求时,先问自己几个问题:

  • 我真的需要完全独立的数据吗?
  • 数据结构有多复杂?
  • 性能要求有多高?
  • 是否有特殊类型的对象需要处理?

通过回答这些问题,你将能够选择最合适的拷贝策略,写出更健壮、更高效的代码。

深入学习资源:

昨天 — 2025年12月4日技术

基于deepseek实现的ai问答小程序

2025年12月4日 19:02

功能预览

截屏2025-12-04 18.38.30.png

截屏2025-12-04 18.38.45.png

截屏2025-12-04 18.38.55.png

截屏2025-12-04 18.39.52.png

截屏2025-12-04 18.39.59.png

截屏2025-12-04 18.40.36.png

截屏2025-12-04 18.41.41.png

lovegif_1764845391382.gif

✨ 核心特性

🚀 流式数据处理

  • 由于微信小程序这里对SSE的流式处理不支持,只有chunks的模式,故采用H5的方式来实现。
  • SSE(Server-Sent Events)流式接收:实现了完整的流式数据处理机制,在收到第一块数据时立即渲染
  • 分块解析与缓冲:支持多行 SSE 事件的正确解析,处理不完整的数据块
  • 实时渲染反馈:使用 requestAnimationFrame 优化渲染性能,避免频繁 DOM 更新导致的卡顿

💬 对话管理

  • 消息追踪系统:自动生成唯一消息 ID,维护消息状态(pending/success/error)
  • 会话隔离:支持多会话并行,可创建、切换、管理历史会话
  • 消息快照存储:保存用户消息与 AI 回复的完整上下文

🎨 交互优化

  • 思考中加载动画:AI 回复延迟时的优雅 UX——流式数据到达时即刻消失
  • 自动滚动定位:智能滚动到最新消息,支持指定消息定位,避免内容变化导致的滚动位置偏移
  • 消息操作面板:支持复制、点赞、重新生成等交互功能

🎯 多模型支持

  • 模型切换器:支持在对话过程中动态选择不同 AI 模型
    • 快速问答模式(deepseek-chat)
    • 深度思考模式(deepseek-reasoner)

📝 Markdown 渲染

  • 完整的 Markdown 支持:包括代码块、表格、列表、引用等
  • 代码高亮:使用 highlight.js 实现多语言代码着色
  • HTML 安全渲染:配置化处理,支持链接自动新窗口打开

🎨 设计系统

  • 现代渐变设计:渐变色(紫→紫→粉)贯穿整个应用
  • 响应式布局:基于 CSS 变量的深浅主题支持
  • 动画细节:平滑的消息滑入、加载转圈、脉冲效果等

🏗️ 项目结构

src/
├── components/              # Vue 组件库
│   ├── MessageItem.vue      # 单条消息渲染组件(含 Markdown 解析、操作面板)
│   ├── InputArea.vue        # 输入框及工具栏(含模型选择、录音、新建会话)
│   ├── HistroySessions.vue  # 历史会话管理弹窗
│   └── HelloWorld.vue       # 示例组件
├── utils/
│   ├── type.ts              # TypeScript 类型定义(ChatMessage、Session 等)
│   ├── request.ts           # HTTP 请求封装(get/post)
│   ├── streamRequest.ts     # SSE 流式请求实现
│   └── markdown.ts          # Markdown 渲染引擎配置
├── assets/                  # 静态资源
│   ├── regenerate-icon.svg  # 重新生成按钮图标
│   ├── copy-icon.svg        # 复制按钮图标
│   ├── like-icon.svg        # 点赞按钮图标
│   ├── thinking-icon.svg    # 思考中加载动画
│   └── logo1.png            # AI 角色头像
├── App.vue                  # 主应用组件(核心业务逻辑)
├── main.ts                  # 应用入口
└── style.scss               # 全局样式与设计系统变量

🔧 技术栈

技术 版本 用途
Vue 3.5.13 前端框架
TypeScript 5.8 类型安全
Vite 6.3.5 构建工具
Markdown-it 14.1.0 Markdown 渲染
highlight.js 内置 代码高亮
SCSS 1.94.2 样式预处理
Element Plus 2.10.4 UI 组件库(可选)
socket.io-client 4.8.1 WebSocket 支持(预留)

📥 快速开始

前置要求

  • Node.js >= 16
  • npm 或 yarn

安装依赖

npm install

开发服务器

npm run dev

访问 http://localhost:5173

生产构建

npm run build

构建输出到 dist/ 目录

预览构建结果

npm run preview

🎯 核心业务流程

对话流程

用户输入 → 发送消息
    ↓
创建 User Message (pending)
显示在消息列表 → 自动滚动到底部
    ↓
调用 streamFetch(POST /api/ai/chat)
Assistant Message 创建 (pending)
    ↓
SSE 数据流开始接收
    ├─ 第一块数据到达 → status 变为 success(思考中 icon 消失)
    ├─ 持续接收 → 实时渲染 Markdown 内容
    └─ 滚动到最新消息
    ↓
流结束 (onDone) → 最终更新消息状态
    ↓
用户可进行操作:复制、点赞、重新生成

会话管理流程

新建会话 → POST /api/ai/sessions
    ↓
获取 session.id → 用于后续对话上下文关联
    ↓
切换历史会话 → GET /api/ai/sessions/{id}/messages
    ↓
加载历史消息 → 等待 DOM 稳定 → 智能滚动到底部

🔐 API 接口约定

对话接口

POST /api/ai/chat
Body: {
  messages: Array<{ role: string; content: string }>,
  sessionId?: string | number,
  stream: true,
  model: string  // "deepseek-chat" | "deepseek-reasoner"
}
Response: 流式 SSE
  data: 文本块
  [可选] event: 事件类型
  [可选] id: 事件 ID

会话接口

POST /api/ai/sessions
Body: { title: string; summary?: string }
Response: { session: { id: string | number; ... } }

GET /api/ai/sessions/{id}/messages
Response: { messages: Array<HistoryMessage> }
  HistoryMessage = {
    role: "assistant" | "user",
    content: string,
    created_at?: string
  }

💡 亮点分析

1. 高性能流式渲染

  • 使用 requestAnimationFrame 进行 Markdown 渲染节流,避免频繁重排/重绘
  • SSE 流数据的分块处理和缓冲机制确保即便数据包不完整也能正确解析
  • 流式更新时的自动滚动采用两帧 rAF 等待,确保 CSS 动画和布局稳定

2. 响应式适配

  • 使用rem单位做响应式适配,目前支持各种大小屏幕的设备,兼容PC端的良好显示。
关键代码

// 用于设置rem单位的字体大小(动态)
function setRem() {
    const baseWidth = 375; // 设计稿宽度
    const minFontSize = 6; // 最小字体
    const maxFontSize = 16; // 最大字体(PC屏或大屏限制)
    const html = document.documentElement;
    const width = html.clientWidth;

    let fontSize = (width / baseWidth) * 12;

    if (fontSize < minFontSize) fontSize = minFontSize;
    if (fontSize > maxFontSize) fontSize = maxFontSize;

    html.style.fontSize = fontSize + 'px';
}
setRem();
window.addEventListener('resize', setRem);


## 🔄 后续优化方向

*   [ ] 语音输入完整实现(当前预留)
*   [ ] 消息搜索与过滤
*   [ ] 导出对话记录(PDF、Markdown)
*   [ ] 数据加密传输
*   [ ] 更多 AI 模型集成

## 📄 相关文件说明

| 文件                 | 说明                               |
| ------------------ | -------------------------------- |
| `streamRequest.ts` | SSE 流式数据解析的核心实现,支持多行 data 字段     |
| `App.vue`          | 主业务逻辑,包含对话流程、会话管理、自动滚动优化         |
| `MessageItem.vue`  | 消息渲染与交互,思考中加载动画、Markdown 解析、操作按钮 |
| `InputArea.vue`    | 输入框、模型切换、会话操作                    |
| `markdown.ts`      | Markdown-it 配置,代码高亮、链接处理         |
| `style.scss`       | 全局设计系统,渐变、主题、动画                  |


深入执行上下文:JavaScript 中 this 的底层绑定机制

作者 xhxxx
2025年12月4日 18:29

深入理解 JavaScript 中的 this:设计初衷、绑定规则与常见陷阱

在 JavaScript 开发中,this 是一个既基础又容易让人困惑的概念。它看似简单,却因绑定规则依赖于函数调用方式而非声明位置,常常导致意料之外的行为。本文将从 this 的本质出发,结合语言设计背景、执行机制以及实际代码示例,系统性地梳理 this 的行为规律,并为后续讨论“绑定丢失”问题预留空间。


从自由变量说起:为什么需要 this

在深入 this 之前,不妨先回顾一个更基础的概念:自由变量(free variable)

考虑如下代码:

var name = "全局";

function greet() {
  console.log("你好," + name);
}

greet(); // 你好,全局

函数 greet 内部使用了变量 name,但它并未在函数内部声明。这个 name 就是一个自由变量。JavaScript 引擎会沿着词法作用域链(Lexical Scope Chain) 向外查找,最终在全局作用域中找到 name 的定义。

这种机制是静态的——变量的查找路径在代码书写时就已确定,与函数如何被调用无关。这也是 JavaScript 中绝大多数变量访问的行为模式。

然而,面向对象编程带来了一个新需求:同一个函数可能属于多个对象,希望在运行时动态地知道“当前是哪个对象在调用我”

例如:

var person1 = { name: "Alice", sayHi: greet };
var person2 = { name: "Bob",   sayHi: greet };

person1.sayHi(); // 期望输出:你好,Alice
person2.sayHi(); // 期望输出:你好,Bob

如果 greet 依然依赖词法作用域中的 name,它永远只能访问到全局的 "全局",而无法感知调用者是谁。词法作用域在此失效了

于是,JavaScript 引入了 this —— 一个不依赖词法作用域、而由调用方式决定的特殊关键字。它让函数能够在运行时动态获取“调用上下文”,从而实现对所属对象的自引用。

换句话说:自由变量靠“写在哪”决定值,this 靠“怎么调”决定值

正是这种设计,使得 this 成为了 JavaScript 执行模型中一个独特而关键的存在——它打破了静态作用域的规则,引入了动态上下文的能力,但也因此带来了理解上的挑战。

this 是什么?

在 JavaScript 中,this 是一个运行时绑定的上下文对象引用。它不是一个变量,而是一个关键字,其值在函数被调用时动态确定,取决于函数是如何被调用的,而不是在哪里定义的。

这与 JavaScript 中其他变量(如自由变量)的查找机制截然不同——后者遵循词法作用域(Lexical Scope) ,由函数声明的位置决定;而 this 则完全由调用方式决定,属于动态作用域的一种体现。


this 的设计初衷

JavaScript 最初被设计为一种轻量级脚本语言,用于在浏览器中操作 DOM。为了支持面向对象编程(OOP),即使在没有 class 的早期版本中,也需要一种机制让函数能够访问所属对象的属性和方法。

于是,this 被引入:当一个函数作为对象的方法被调用时,this 自动指向该对象。这样,开发者就可以在方法内部通过 this.xxx 访问对象自身的数据。

然而,由于 JavaScript 函数是一等公民(first-class citizens),可以被赋值、传递、独立调用,这就导致同一个函数在不同调用场景下 this 指向可能完全不同——这种灵活性也带来了复杂性。


varlet 声明对全局对象的影响

这一点与 this 的默认绑定密切相关:

  • 使用 var 在全局作用域声明的变量,会自动挂载到全局对象上(如 window.myVar = ...)。
  • 使用 letconst 声明的变量则不会挂载到全局对象
var a = 1;
let b = 2;

console.log(window.a); // 1
console.log(window.b); // undefined

因此,在非严格模式下,若 this 指向 window,通过 this.a 可以访问到 var a,但无法访问 let b。这也解释了为什么在某些代码中 this.xxx 能“神奇地”访问到全局变量——其实是访问了挂载在 window 上的属性。

使用var声明挂载变量到window对象上并不是一件好的事情,他会污染全局环境


、JavaScript 执行机制与 this 的“例外性”

JavaScript 引擎在执行代码前会经历编译阶段(包括词法分析、作用域构建等)。变量和函数的作用域链在编译阶段就已确定,这就是词法作用域的基础。

然而,this 是一个例外:它的值无法在编译阶段确定,必须等到运行时根据调用栈和调用方式动态计算。这意味着:

  • 即使两个完全相同的函数体,只要调用方式不同,this 就可能指向完全不同的对象。
  • this 与作用域链无关,它属于执行上下文(Execution Context) 的一部分,而非词法环境。

2e1b50313261ecf6e1348b4e76311bff.png


this 指向的几种典型情况

根据调用方式,this 的绑定可分为以下几类:

1. 作为对象的方法调用

var myObj = {
    name:"极客时间",
    showThis:function(){
        console.log(this);//this->myObj
    }
}
    myObj.showThis();

作为对象的方法调用时,它指向调用该函数的对象

2. 作为普通函数调用

function print() {
  console.log(this); // 非严格模式:window;严格模式:undefined
}
print();

作为普通函数调用时,它指向全局对象window(非严格模式)/undefined(严格模式)

3. 构造函数调用

function CreateObj(){
            // var tempObj = {};
            //CreateObj.call(tempObj);
            //tempObj.__proto__ = CreateObj.prototype;
            //return tempObj;
            console.log(this);
            this.name="极客时间";

        }
        var myObj = new CreateObj();
        console.log(myObj);

作为构造函数调用时,它指向当前的构造函数的实例化对象

4. 使用 call / apply 绑定this

 let bar ={
            myName:"极客邦",
            test:1
        }
        function foo(){
            this.myName="极客时间";
        }
        // 接受指定this为第一个参数,并运行
        foo.call(bar);// this 被指定为bar 
        // 和call 一样
        foo.apply(bar);// this 被指定为bar 
        console.log(bar);

call和apply都能够改变this的指向,他们接受指定this为第一个参数,我的理解:你可以认为指定一个对象来调用这个函数。值得注意的是,在这段代码中二者似乎是等价的,但实际上二者在参数上有差异,在这里就不深入讨论

5. 事件处理函数中的 this

在 DOM 事件监听器中,this 默认指向触发事件的元素

<a href="#" id="link">点击我</a>
    <script>
        document.getElementById("link").addEventListener("click",function(){
            console.log(this);
        })

image.png触发事件后,可以看到,this指向的是当前触发改事件的DOM元素


、this的绑定规则

默认绑定

一般存在于最常用的函数调用类型;独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则

考虑这样一段代码:


function foo(){
console.log(this.a)
}
var a =2;
foo();//2

我们能看到,当我们调用foo()函数时,this.a被解析成了全局变量a。这是为什么?是的,这就是我们说的默认绑定,this指向全局对象,当然这必须是在非严格模式下,严格模式下则会绑定到undefined。

一句话总结默认绑定:非严格模式下,当函数独立调用时,this自动绑定到全局对象上

⚠️ 这种设计其实暴露了早期 JavaScript 的一个“历史包袱”:在非严格模式下,意外的全局 this绑定可能导致隐式创建全局变量,污染全局命名空间。

隐式绑定

当函数调用的位置存在上下文对象,或者说该函数被调用时被某个对象“拥有”或“包含”,隐式绑定会把函数调用中的this绑定到这个上下文对象

考虑这样一段代码:

function foo ()
{
console.log(this.a);
}
var obj ={
a:2,
foo:foo// 实际上是对foo的引用

}
obj.foo()//2

我们能看到 foo()函数调用时,能够访问到obj的内部属性a,这是因为它由obj调用,所以它被obj所包含。
值得注意的是,对象引用链中只有上一层或者说最后一层在调用位置起作用


function foo ()
{
console.log(this.a);
}
var obj2 ={
a:42,
foo:foo
}
var obj1 ={
a:2,
obj2:obj2
}
obj1.obj2.foo();//42

因为最后调用foo的是obj2,所以 foo 的 this.a 指向 obj 2中的 a

显式绑定

JS中绝大多数函数以及你自己创建的函数,都可以使用call()和apply()方法,你可以使用他们来直接指定this的绑定对象,因此我们称为显示绑定

考虑这样一段代码

function foo ()
{
console.log(this.a);
}
var obj ={
a:2
}
foo.call(obj);//2

按照前面的理解:foo()在全局中被调用,那么this应该默认被绑定到全局,但是这里却能够访问到obj中的a,这就是 call() 的作用 -->我们可以在调用foo时强制把它的this绑定到obj上

从this绑定的角度出发,call()和apply()是一样的,都用来强制绑定this到指定对象,他们的区别体现在其他参数上,我们这里不考虑

如果你传入了一个原始值(字符串,布尔值,数字)来当作this的绑定对象,这个值会被转换为它的对象形式(new String(),new Boolean(),new Number())。这个过程被称为“装箱”

new绑定

使用new来调用foo()时,我们会构造一个新对象并把它绑定到foo()调用中的this上。我们称为new绑定

首先我们需要重新定义JS中的构造函数-->构造函数只是一些使用new操作符时被调用的普通函数。
所以实际上,并不存在所谓的构造函数,只有对于函数的构造调用
接下来考虑这样一段代码:

function foo (a)
{
  this.a = a;
}
var bar =new foo(2);
console.log(bar.a);//2

当我们使用new时,就会自动执行这样一些操作:

  1. 创建一个新对象{}
  2. 新对象被执行与[[prototype]]连接
  3. 将函数调用的this绑定到该对象
  4. 如果没有返回其他对象,则自动返回这个新对象

所以上述代码的实际底层是:

function foo (a)
{
 var bar ={};
 bar.__proto__ = foo().prototype
 foo.call(bar);
 return bar;
  this.a = a;
}

绑定丢失

隐式丢失

一个常见的绑定问题就是被隐式绑定的函数会丢失绑定对象,会重新应用为默认绑定,从而使得this绑定到全局会undefined

function foo(){
console.log(this.a)
}
var obj = {
  a:2,
  foo:foo
  
};


var bar = obj.foo;//函数别名
var a = "global";
bar();//global

这里导致绑定丢失的原因是bar实际上也是对foo的引用,而当bar()调用时,它其实是一个函数的独立调用,所以执行了默认绑定

再看另外一种情况,在传入回调函数时:

function foo(){
console.log(this.a)
}
function doFoo(fn)
{
    fn();
}
var obj = {
  a:2,
  foo:foo
  
};

// 把方法赋值给一个变量 —— 绑定丢失!
var bar = obj.foo;//函数别名
var a = "global";
doFoo(obj.foo);//global

这样同样导致了绑定丢失,那么造成这种情况的原因是?
foo()函数的执行实际上转交由doFoo来执行了,而在它的执行上下文中没有a这个变量,所以沿着作用域链查找到全局中的a

这样的绑定丢失的核心是:回调函数的执行权被移交到了其他函数手中

即使是显示绑定也无法避免绑定丢失

function foo() {
  console.log("this.a =", this.a); // 期望this指向obj,输出2
}

var obj = { a: 2 };

// 定义一个接收回调的函数
function doCallback(callback) {
  callback(); // 这里执行回调,call的绑定丢失
}

// 用call显式绑定foo的this到obj,作为回调传递
doCallback(function() {
  foo.call(obj); // 看似绑定了obj
});

// 改造:故意制造绑定丢失(更直观)
function doLostBind(callback) {
  // 模拟实际场景中对回调的二次调用,绑定丢失
  const temp = callback;
  temp(); // 执行时丢失原call绑定
}

// 传递用call绑定的函数,最终绑定丢失
doLostBind(foo.call.bind(foo, obj)); // 非严格模式下输出this.a = undefined(指向window)

doLostBind(foo.call.bind(foo, obj))这种写法看似传入时做了绑定,但其实这只是生成了一个“准备绑定的函数”,并没有真正的执行绑定逻辑


怎么解决绑定丢失的问题?

  • 硬绑定
function foo(){
console.log(this.a)
}
var obj = {
  a:2,
};

var bar = function () {
    foo.call(obj);
    
}
bar();//2
setTimeout(bar,2);//2

bar.call(window);//2
  1. 在bar函数的内部,我们把foo的this强制绑定在了obj上,无论之后怎么调用bar,他都会手动在obj上调用foo
  2. 硬绑定不可能再修改它的this,我们想要通过bar.call(window); 修改绑定对象,但无论你怎么修改,最后都会执行 foo.call(obj);把this重新绑定到obj上

由于硬绑定是一种很常用的模式,所以ES5提供了它的内置方法bind(),用法如下

function foo(temp){
console.log(this.a,temp);
return this.a+ temp
}
var obj = {
  a:2,
};


var bar = foo.bind(obj)
var b = bar(3);//2 3
console.log(b);// 5

bind()会返回一个硬编码的新函数,他会把你指定的参数设置为this的上下文并调用原始函数

结语

this 是 JavaScript 中一个强大但需要谨慎使用的机制。理解它的设计初衷、绑定规则以及与作用域系统的差异,是写出健壮、可维护代码的关键。掌握 this,不仅有助于避免常见 bug,也能更深入地理解 JavaScript 的执行模型。

在下一篇文章中,我们将了解更多关于this的底层

企业级 Vue 3 基础数据管理方案:从混乱到统一

作者 狗弟
2025年12月4日 18:13

作者: 狗弟 发布日期: 2025-12-04
技术栈: Vue 3 + TypeScript + Composition API + Element Plus
阅读时长: 约 15 分钟


📌 引言

在大型企业应用中,基础数据(字典、港口、货币、国家等)的管理往往是一个被忽视但至关重要的领域。一个设计良好的基础数据方案可以:

  • 🚀 减少 70% 以上的重复代码
  • 降低 API 请求次数 80%+
  • 🎯 提升开发效率和代码可维护性
  • 🌍 无缝支持国际化切换

本文将分享我们在航运物流系统中设计和实现的统一基础数据管理方案,涵盖架构设计、性能优化、缓存策略和最佳实践。


🤔 问题背景:野蛮生长的痛点

最初的混乱

在项目初期,每个开发者按自己的方式获取和使用基础数据:

// 🔴 问题代码示例:每个组件各自为政

// 组件 A:直接调用 API
const res = await api.getDictList('ORDER_STATUS')
const statusList = res.data

// 组件 B:使用 hooks 但没有缓存
const { data } = useAllDict('ORDER_STATUS') // 每次调用都请求 API

// 组件 C:在 Vuex 中存储
store.dispatch('loadDictData', 'ORDER_STATUS')
const statusList = store.state.dict.ORDER_STATUS

// 组件 D:硬编码
const statusList = [
  { value: 1, label: '待处理' },
  { value: 2, label: '已完成' },
  // ...
]

这导致了严重的问题

问题 影响
API 请求爆炸 同一个字典在 10 个组件中被请求 10 次
数据不一致 硬编码的数据与后端不同步
国际化困难 中英文切换需要手动处理每个地方
代码重复 格式化、查找 label 的逻辑到处都是
类型缺失 没有 TypeScript 类型,IDE 无法提示

🏗️ 架构设计:统一数据源

核心设计理念

我们采用单一数据源 + 工厂模式的架构:

┌─────────────────────────────────────────────────────────┐
│                    业务组件层                             │
│   ┌─────────┐   ┌─────────┐   ┌─────────┐              │
│   │ 下拉框   │   │ 表格列   │   │ 标签     │              │
│   └────┬────┘   └────┬────┘   └────┬────┘              │
│        │             │             │                    │
│        └─────────────┴─────────────┘                    │
│                      │                                  │
├──────────────────────▼──────────────────────────────────┤
│              Composables 统一入口                        │
│   ┌─────────────────────────────────────────────────┐   │
│   │  import { useDictType, usePorts } from          │   │
│   │         '~/composables/basicData'               │   │
│   └─────────────────────────────────────────────────┘   │
├─────────────────────────────────────────────────────────┤
│                    模块内部架构                          │
│                                                         │
│   ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│   │  hooks   │  │ adapters │  │  cache   │            │
│   │ 业务封装  │  │ 数据适配  │  │ 缓存管理  │            │
│   └────┬─────┘  └────┬─────┘  └────┬─────┘            │
│        │             │             │                    │
│        └─────────────┴─────────────┘                    │
│                      │                                  │
│              ┌───────▼───────┐                          │
│              │     API       │                          │
│              │  统一数据获取   │                          │
│              └───────────────┘                          │
└─────────────────────────────────────────────────────────┘

目录结构

src/composables/basicData/
├── index.ts          # 统一导出入口
├── hooks.ts          # 业务数据 Hooks(港口、船舶、航线等)
├── useDict.ts        # 字典数据 Hooks
├── cache.ts          # 缓存管理(TTL、清理策略)
├── adapters.ts       # 数据适配器(API → 标准格式)
├── api/              # API 封装
│   └── index.ts
└── types/            # TypeScript 类型定义
    └── index.ts

💡 核心实现

1. 工厂函数:统一的 Hook 创建模式

不同类型的基础数据(港口、船舶、货币等)有相同的使用模式,我们用工厂函数消除重复:

/**
 * 创建基础数据 Hook 的工厂函数
 * 所有基础数据 Hook 共享相同的接口和行为
 */
function createBaseDataHook<T extends BaseDataItem, R>(
  fetchFn: () => Promise<R>,
  transformFn: (response: R) => T[],
  cacheConfig: CacheConfig,
): (params?: QueryParams) => BaseDataHookResult<T> {
  
  return (params: QueryParams = {}): BaseDataHookResult<T> => {
    const { useEnglish = false } = params

    // 使用缓存系统
    const { data, loading, error, refresh, clearCache } = useBasicDataCache(
      cacheConfig.key,
      async () => transformFn(await fetchFn()),
      { ttl: cacheConfig.ttl },
    )

    // 根据参数过滤数据
    const filteredData = computed(() => {
      let result = data.value || []
      
      if (params.keyword) {
        result = BaseAdapter.filterByKeyword(result, params.keyword)
      }
      if (params.enabledOnly) {
        result = BaseAdapter.filterByEnabled(result, true)
      }
      
      return result
    })

    // Element Plus 格式的选项
    const options = computed(() => 
      BaseAdapter.toOptions(filteredData.value, useEnglish)
    )

    return {
      data: filteredData,
      loading,
      error,
      options,
      isEmpty: computed(() => filteredData.value.length === 0),
      isReady: computed(() => !loading.value && !error.value),
      refresh,
      search: (keyword) => BaseAdapter.filterByKeyword(data.value, keyword),
      getByCode: (code) => data.value?.find(item => item.code === code),
      clearCache,
    }
  }
}

// 一行代码创建新的基础数据 Hook
export const usePorts = createBaseDataHook(
  queryPortList,
  PortAdapter.transform,
  { key: 'PORTS', ttl: 10 * 60 * 1000 }
)

export const useVessels = createBaseDataHook(
  queryVesselList,
  VesselAdapter.transform,
  { key: 'VESSELS', ttl: 15 * 60 * 1000 }
)

2. 字典数据:专为 UI 组件优化

字典数据是最常用的基础数据类型,我们为其设计了专门的 API:

/**
 * 特定字典类型的组合式函数
 * 提供开箱即用的下拉选项和 label 查询
 */
export function useDictType(dictType: string) {
  const { locale } = useI18n()
  const { data: dictMap, loading, error, refresh } = useAllDictData()

  // 响应式的选项列表,自动根据语言切换
  const options = computed(() => {
    const items = dictMap.value?.[dictType] || []
    return items.map(item => ({
      label: locale.value === 'en' ? item.labelEn : item.label,
      value: item.value,
    }))
  })

  // 根据 code 获取 label,支持国际化
  function getLabel(code: string): string {
    const items = dictMap.value?.[dictType] || []
    const item = items.find(i => i.value === code)
    if (!item) return code
    return locale.value === 'en' ? item.labelEn : item.label
  }

  return {
    options,
    items: computed(() => dictMap.value?.[dictType] || []),
    loading,
    error,
    getLabel,
    getLabels: (codes: string[]) => codes.map(getLabel),
    refresh,
  }
}

3. 智能缓存:TTL + 全局共享

缓存是性能优化的关键,我们实现了带 TTL 的响应式缓存:

/**
 * 带 TTL 的响应式缓存 Hook
 * 支持过期自动刷新、手动清除
 */
export function useBasicDataCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: { ttl: number }
) {
  // 使用 VueUse 的 useStorageAsync 实现持久化
  const cached = useStorageAsync<CacheEntry<T> | null>(
    `basic-data:${key}`,
    null,
    localStorage
  )

  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  // 检查缓存是否过期
  const isExpired = computed(() => {
    if (!cached.value) return true
    return Date.now() - cached.value.timestamp > options.ttl
  })

  // 加载数据(带去重)
  let loadingPromise: Promise<void> | null = null
  
  async function load() {
    if (loadingPromise) return loadingPromise
    
    if (!isExpired.value && cached.value) {
      data.value = cached.value.data
      return
    }

    loading.value = true
    loadingPromise = fetcher()
      .then(result => {
        data.value = result
        cached.value = { data: result, timestamp: Date.now() }
      })
      .catch(err => {
        error.value = err
        // 如果有旧缓存,降级使用
        if (cached.value) {
          data.value = cached.value.data
        }
      })
      .finally(() => {
        loading.value = false
        loadingPromise = null
      })

    return loadingPromise
  }

  // 自动加载
  load()

  return {
    data: computed(() => data.value),
    loading: computed(() => loading.value),
    error: computed(() => error.value),
    refresh: () => {
      cached.value = null
      return load()
    },
    clearCache: () => {
      cached.value = null
      data.value = null
    }
  }
}

🎯 使用示例

场景 1:下拉选择器

<template>
  <el-select v-model="form.status" placeholder="请选择状态">
    <el-option
      v-for="item in statusOptions"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </el-select>
</template>

<script setup lang="ts">
import { useDictType } from '~/composables/basicData'

const { options: statusOptions } = useDictType('ORDER_STATUS')
</script>

场景 2:表格列显示 label

<template>
  <el-table :data="tableData">
    <el-table-column prop="code" label="编号" />
    <el-table-column label="状态">
      <template #default="{ row }">
        <el-tag :type="getStatusColor(row.status)">
          {{ getStatusLabel(row.status) }}
        </el-tag>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup lang="ts">
import { useDictType } from '~/composables/basicData'

const { getLabel: getStatusLabel, getColorType: getStatusColor } = 
  useDictType('ORDER_STATUS')
</script>

场景 3:港口选择(带搜索)

<template>
  <el-select
    v-model="selectedPort"
    filterable
    remote
    :remote-method="handleSearch"
    :loading="loading"
    placeholder="搜索港口..."
  >
    <el-option
      v-for="port in portOptions"
      :key="port.value"
      :label="port.label"
      :value="port.value"
    />
  </el-select>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePorts } from '~/composables/basicData'

const keyword = ref('')
const searchParams = computed(() => ({
  keyword: keyword.value,
  enabledOnly: true
}))

const { options: portOptions, loading } = usePorts(searchParams)

function handleSearch(query: string) {
  keyword.value = query
}
</script>

场景 4:获取关联数据

import { usePorts, useCountries } from '~/composables/basicData'

const { getByCode: getPort } = usePorts()
const { getByCode: getCountry } = useCountries()

// 获取港口及其所属国家信息
function getPortWithCountry(portCode: string) {
  const port = getPort(portCode)
  if (!port) return null
  
  const country = port.countryCode ? getCountry(port.countryCode) : null
  
  return {
    ...port,
    countryName: country?.nameCn || '',
    countryNameEn: country?.nameEn || '',
  }
}

⚡ 性能优化效果

Before vs After

指标 优化前 优化后 提升
字典 API 请求次数/页 15-20 次 1 次 95%↓
首屏加载时间 3.2s 1.8s 44%↓
内存占用(字典数据) 分散存储 统一缓存 60%↓
代码行数(基础数据相关) ~2000 行 ~500 行 75%↓

缓存命中率

┌────────────────────────────────────────────────────┐
│                  缓存命中情况                        │
├────────────────────────────────────────────────────┤
│ 字典数据 ████████████████████████████████ 98%       │
│ 港口数据 ██████████████████████████████░░ 92%       │
│ 货币数据 ████████████████████████████████ 99%       │
│ 国家数据 ████████████████████████████████ 99%       │
└────────────────────────────────────────────────────┘

🔧 最佳实践

✅ 推荐做法

// 1. 使用解构获取需要的方法
const { options, getLabel, loading } = useDictType('STATUS')

// 2. 使用 computed 传递动态参数
const params = computed(() => ({ keyword: search.value }))
const { data } = usePorts(params)

// 3. 处理加载状态
<template v-if="loading">加载中...</template>
<template v-else>{{ getLabel(code) }}</template>

// 4. 统一从入口导入
import { useDictType, usePorts } from '~/composables/basicData'

❌ 避免做法

// 1. 不要在循环中调用 Hook
// ❌ 错误
tableData.forEach(row => {
  const { getLabel } = useDictType('STATUS') // 每次循环都创建新实例
  row.statusLabel = getLabel(row.status)
})

// ✅ 正确
const { getLabel } = useDictType('STATUS')
tableData.forEach(row => {
  row.statusLabel = getLabel(row.status)
})

// 2. 不要忽略加载状态
// ❌ 错误
const label = getLabel(code) // 数据可能还未加载

// ✅ 正确
const label = computed(() => loading.value ? '加载中' : getLabel(code))

📦 扩展:添加新的基础数据类型

添加新的基础数据类型非常简单,只需 3 步:

// 1. 定义 API
// api/index.ts
export async function queryNewDataList() {
  return request.get('/api/new-data/list')
}

// 2. 定义适配器
// adapters.ts
export const NewDataAdapter = {
  transform(response: ApiResponse): BaseDataItem[] {
    return response.data.map(item => ({
      code: item.id,
      nameCn: item.name,
      nameEn: item.nameEn,
      enabled: item.status === 1,
    }))
  }
}

// 3. 创建 Hook
// hooks.ts
export const useNewData = createBaseDataHook(
  queryNewDataList,
  NewDataAdapter.transform,
  { key: 'NEW_DATA', ttl: 10 * 60 * 1000 }
)

// 4. 导出
// index.ts
export { useNewData } from './hooks'

🎓 总结

通过这套基础数据管理方案,我们实现了:

  1. 统一入口 - 所有基础数据从 ~/composables/basicData 导入
  2. 自动缓存 - TTL 机制 + 全局共享,避免重复请求
  3. 类型安全 - 完整的 TypeScript 类型定义
  4. 国际化 - 自动根据语言环境切换中英文
  5. 开箱即用 - Element Plus 格式的选项,直接用于组件
  6. 易于扩展 - 工厂模式,添加新类型只需几行代码

这套方案已在我们的航运物流系统中稳定运行,支撑着日均数万次的基础数据查询,希望能给正在处理类似问题的团队一些启发。


📚 相关资源


💬 欢迎在评论区交流讨论,如果觉得有帮助,请点赞收藏~

ScaleSlider 组件实现

作者 Syron
2025年12月4日 18:01

需求分析

功能需求

  • ✅ 支持纵向和横向两种方向
  • ✅ 显示刻度线(主刻度和次刻度)
  • ✅ 实时显示当前值(带单位)
  • ✅ 箭头指向滑块位置
  • ✅ 值显示框跟随滑块移动
  • ✅ 支持鼠标拖拽和点击跳转
  • ✅ 受控和非受控模式
  • ✅ 禁用状态支持

交互需求

  • ✅ 点击轨道跳转到目标位置
  • ✅ 拖拽滑块平滑移动
  • ✅ 值实时更新
  • ✅ 过渡动画(点击时平滑,拖拽时即时)
  • ✅ 悬停效果

视觉需求

css
纵向布局:
  刻度    轨道    箭头+值
  ═══     ║      ← [1.60mm]
  ═══     ●      
  ═══     ║      
  
横向布局:
  刻度   ═══ ══ ═══
  轨道   ●═════
  箭头      ↑
  值      [50%]

设计思路

1. 组件结构设计

初始方案(V1)

typescript
<Container>
  <ValueDisplay />  // 左侧/上方
  <SliderWrap>
    <Scales />
    <Track />
  </SliderWrap>
</Container>

问题:

  • ❌ 值显示位置固定,不跟随滑块
  • ❌ 布局不够灵活

改进方案(V2)

typescript
<Container>
  <ValueDisplay />  // 在另一侧
  <SliderWrap>
    <Scales />
    <Track />
    <ValueWrapper />  // 跟随滑块
  </SliderWrap>
</Container>

问题:

  • ❌ ValueWrapper 嵌套过深
  • ❌ 鼠标事件复杂,容易误触发

最终方案(V3 - CSS Grid)

typescript
<Container> // Grid 布局
  <ScalesContainer />    // 独立区域
  <TrackContainer />     // 独立区域
  <ValueContainer />     // 独立区域
</Container>

优势:

  • ✅ 三个区域完全独立
  • ✅ 鼠标事件精确隔离
  • ✅ 定位清晰简单

2. 布局方案对比

方案 A:Flexbox + Padding

css
.sliderWrap {
  padding-right: 100px; /* 为值显示预留空间 */
}

.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);
}

问题:

  • ❌ padding 区域仍会捕获鼠标事件
  • ❌ 定位复杂,容易出错

方案 B:CSS Grid(最终选择)

css
.container {
  display: grid;
  grid-template-columns: auto 8px auto; /* 纵向 */
  grid-template-rows: auto 8px auto;    /* 横向 */
}

优势:

  • ✅ 每个区域独立,互不干扰
  • ✅ 宽度/高度自动计算
  • ✅ 响应式友好

实现过程

阶段 1:基础滑动条(V1)

代码实现

typescript
export function ScaleSlider({ orientation = 'vertical', ...props }) {
  const [value, setValue] = useState(defaultValue)
  const trackRef = useRef<HTMLDivElement>(null)

  const handleMouseDown = (e: React.MouseEvent) => {
    // 计算点击位置
    const rect = trackRef.current.getBoundingClientRect()
    const percent = orientation === 'vertical'
      ? (rect.bottom - e.clientY) / rect.height
      : (e.clientX - rect.left) / rect.width
    
    const newValue = min + percent * (max - min)
    setValue(newValue)
  }

  return (
    <div>
      <div ref={trackRef} onMouseDown={handleMouseDown}>
        <div className="fill" style={{ height: `${percentage}%` }} />
        <div className="thumb" style={{ bottom: `${percentage}%` }} />
      </div>
    </div>
  )
}

实现效果

  • ✅ 点击跳转
  • ✅ 基础拖拽
  • ❌ 没有刻度
  • ❌ 没有值显示

阶段 2:添加刻度和值显示(V2)

代码实现

typescript
// 生成刻度
const scales = Array.from({ length: scaleCount + 1 }, (_, i) => ({
  position: (i / scaleCount) * 100,
  isMain: i % 2 === 0,
}))

return (
  <div className={styles.container}>
    <div className={styles.valueDisplay}>
      {formatValue(value)}
    </div>
    <div className={styles.sliderWrap}>
      {/* 刻度 */}
      <div className={styles.scales}>
        {scales.map((scale, i) => (
          <div key={i} className={styles.scale} 
               style={{ bottom: `${scale.position}%` }} />
        ))}
      </div>
      {/* 轨道 */}
      <div ref={trackRef} className={styles.track}>
        <div className={styles.fill} />
        <div className={styles.thumb} />
      </div>
    </div>
  </div>
)

CSS 实现

css
.container {
  display: flex;
  flex-direction: row; /* 纵向 */
  gap: 12px;
}

.scales {
  position: absolute;
  right: calc(100% + 4px);
}

.scale {
  position: absolute;
  width: 6px;
  height: 1px;
  bottom: X%;
}

实现效果

  • ✅ 刻度显示正确
  • ✅ 值显示在左侧
  • ❌ 值不跟随滑块
  • ❌ 没有箭头指示

阶段 3:值显示跟随滑块(V3)

代码改进

typescript
// 将 valueWrapper 移到 sliderWrap 内部
<div className={styles.sliderWrap}>
  <div className={styles.scales}>...</div>
  <div ref={trackRef} className={styles.track}>...</div>
  
  {/* 值显示跟随滑块 */}
  <div className={styles.valueWrapper} 
       style={{ bottom: `calc(${percentage}% - 0.5rem)` }}>
    <svg>箭头</svg>
    <div className={styles.valueDisplay}>
      {formatValue(value)}
    </div>
  </div>
</div>

CSS 定位

css
.sliderWrap {
  position: relative;
  padding-right: 100px; /* 为值显示预留空间 */
}

.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);
  bottom: calc(X% - 0.5rem); /* 跟随滑块 */
}

实现效果

  • ✅ 值跟随滑块位置
  • ✅ 箭头指向滑块
  • ❌ 出现严重 Bug

遇到的问题与解决方案

❌ 问题 1:纵向滑块鼠标事件异常

问题描述

现象:鼠标在纵向滑块附近移动(不点击),滑块也会跟随移动
影响:组件完全无法使用,交互体验极差
触发条件:只在纵向布局出现,横向布局正常

问题排查

Step 1:检查事件绑定

typescript
// ✅ 事件绑定正确
<div ref={trackRef} onMouseDown={handleMouseDown}>

Step 2:检查拖拽逻辑

typescript
useEffect(() => {
  const handleMouseMove = (e: MouseEvent) => {
    if (isDraggingRef.current) { // ✅ 有拖拽状态判断
      updateValue(e.clientX, e.clientY)
    }
  }
  // ...
}, [])

Step 3:检查 DOM 结构

html
<!-- ❌ 发现问题:valueWrapper 嵌套在 sliderWrap 内 -->
<div class="sliderWrap" style="padding-right: 100px">
  <div class="track"></div>
  <div class="valueWrapper"></div> <!-- 可能捕获事件 -->
</div>

Step 4:检查 CSS 布局

css
/* ❌ 发现问题:padding 导致可交互区域过大 */
.sliderWrap {
  padding-right: 100px; /* 这个区域可能捕获事件 */
}

Step 5:添加调试代码

typescript
const handleMouseDown = (e: React.MouseEvent) => {
  console.log('MouseDown triggered')
  console.log('Target:', e.target)
  console.log('CurrentTarget:', e.currentTarget)
}

// 发现:有时 e.target 不是 track 元素

根本原因分析

scss
问题 1:DOM 嵌套层级过深
Container
└── sliderWrap (padding-right: 100px)
    ├── scales
    ├── track ← 应该只有这里响应
    └── valueWrapper ← 嵌套在内部,可能干扰事件

问题 2:可交互区域不明确
═══  ║                 [值]
     └── track (8px)
     └────────────────────┘
          padding (100px)
         ↑ 这个区域可能误触发

问题 3pointer-events 控制不精确
.sliderWrap { }           // 没有禁用
.track { }                // 响应事件
.valueWrapper { }         // 没有明确禁用

✅ 解决方案演进

方案 1:添加 pointer-events(失败)

css
.sliderWrap {
  pointer-events: none;
}

.track {
  pointer-events: auto;
}

.valueWrapper {
  pointer-events: none;
}

结果:

  • ❌ 仍然有问题
  • ❌ 原因:valueWrapper 仍在 sliderWrap 内部

方案 2:调整 DOM 结构(部分成功)

typescript
<Container>
  <div className={styles.sliderWrap}>
    <Scales />
    <Track />
  </div>
  <div className={styles.valueWrapper}> {/* 移到外部 */}
    ...
  </div>
</Container>

结果:

  • ✅ 减少了误触发
  • ❌ 仍有边缘情况
  • ❌ 定位复杂

方案 3:CSS Grid 重构(最终成功)

核心思路:三个区域完全独立

typescript
<Container> // Grid 布局
  <ScalesContainer />    // 区域 1:刻度(不可交互)
  <TrackContainer />     // 区域 2:轨道(唯一可交互)
  <ValueContainer />     // 区域 3:值显示(不可交互)
</Container>

CSS Grid 配置

css
.container {
  display: grid;
  position: relative;
}

/* 纵向:三列布局 */
.vertical {
  grid-template-columns: auto 8px auto;
  /* 刻度(auto) | 轨道(8px) | 值显示(auto) */
}

/* 横向:三行布局 */
.horizontal {
  grid-template-rows: auto 8px auto;
  /* 刻度(auto) | 轨道(8px) | 值显示(auto) */
}

精确控制交互区域

css
/* ✅ 只有轨道响应鼠标 */
.trackContainer {
  pointer-events: auto;
  width: 8px;  /* 纵向 */
  height: 8px; /* 横向 */
}

/* ❌ 其他区域不响应 */
.scalesContainer,
.valueContainer,
.thumb,
.fill {
  pointer-events: none;
}

视觉对比

scss
修正前(有问题):
┌────────────────────────────────┐
│ sliderWrap (可能误触)           │
│  ┌────┐  ┌──────────┐         │
│  │轨道│  │ 值显示    │         │
│  └────┘  └──────────┘         │
│  8px      padding 100px        │
└────────────────────────────────┘

修正后(正确):
┌──────┬────┬──────────┐
│ 刻度  │轨道│  值显示   │
│(no)  │YES│  (no)    │
│      │8px│          │
└──────┴────┴──────────┘

❌ 问题 2:值显示定位复杂

问题描述

需求:值显示框要跟随滑块位置
难点:同时要保持在固定区域内

解决方案:双层定位

typescript
// 外层容器:跟随滑块位置
<div className={styles.valueContainer} 
     style={{ bottom: `${percentage}%` }}>
  
  // 内层内容:在容器中居中
  <div className={styles.valueContent}>
    <Arrow />
    <ValueDisplay />
  </div>
</div>
css
/* 外层:跟随滑块 */
.valueContainer {
  position: relative;
  bottom: X%; /* 动态值 */
}

/* 内层:居中对齐 */
.valueContent {
  position: absolute;
  bottom: 0;
  transform: translateY(50%); /* 垂直居中 */
}

效果对比

css
单层定位(复杂):
.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);        // 横向固定
  bottom: calc(X% - 0.5rem);      // 纵向跟随
  transform: translateY(50%);     // 居中
}

双层定位(清晰):
.valueContainer {
  bottom: X%;  // 跟随滑块
}
.valueContent {
  transform: translateY(50%);  // 居中
}

❌ 问题 3:拖拽性能优化

问题描述

css
现象:拖拽时有轻微延迟或卡顿
原因:CSS transition 在拖拽时不应该生效

解决方案:动态禁用过渡

typescript
const [isDragging, setIsDragging] = useState(false)

const fillClasses = `${styles.fill} ${
  isDragging ? styles.fillNoDrag : ''
}`
css
.fill {
  transition: height 0.15s ease, width 0.15s ease;
}

/* 拖拽时禁用过渡 */
.fillNoDrag {
  transition: none !important;
}

.container:active .valueContainer {
  transition: none !important;
}

效果对比

ini
点击跳转(平滑):
时间 0ms:    ●  [50%]
时间 75ms:      ●  [60%]  ← 平滑过渡
时间 150ms:        ●  [70%]

拖拽移动(即时):
时间 0ms:    ●  [50%]
时间 1ms:        ●  [70%]  ← 立即跟随

最终架构

1. 组件结构

arduino
ScaleSlider
├── types.ts           // TypeScript 类型定义
├── ScaleSlider.tsx    // 组件主逻辑
├── ScaleSlider.module.css  // 样式文件
└── index.ts           // 导出

2. DOM 结构

html
<div class="container vertical"> <!-- Grid 容器 -->
  
  <!-- 区域 1:刻度(pointer-events: none) -->
  <div class="scalesContainer">
    <div class="scale scaleMain" style="bottom: 0%"></div>
    <div class="scale scaleMinor" style="bottom: 10%"></div>
    <div class="scale scaleMain" style="bottom: 20%"></div>
    ...
  </div>
  
  <!-- 区域 2:轨道(pointer-events: auto) -->
  <div class="trackContainer" onMouseDown={...}>
    <div class="track">
      <div class="fill" style="height: 60%"></div>
      <div class="thumb" style="bottom: 60%"></div>
    </div>
  </div>
  
  <!-- 区域 3:值显示(pointer-events: none) -->
  <div class="valueContainer" style="bottom: 60%">
    <div class="valueContent">
      <svg class="arrow"></svg>
      <div class="valueDisplay">1.60mm</div>
    </div>
  </div>
  
</div>

3. CSS Grid 布局

css
/* 纵向布局 */
.vertical {
  display: grid;
  grid-template-columns: 
    auto    /* 刻度区域(自适应宽度) */
    8px     /* 轨道区域(固定 8px) */
    auto;   /* 值显示区域(自适应宽度) */
  gap: 8px;
}

/* 横向布局 */
.horizontal {
  display: grid;
  grid-template-rows: 
    auto    /* 刻度区域(自适应高度) */
    8px     /* 轨道区域(固定 8px) */
    auto;   /* 值显示区域(自适应高度) */
  gap: 8px;
}

4. 交互区域示意图

scss
纵向滑块:
┌──────────┬────┬──────────┐
│  刻度区   │轨道│  值显示   │
│ (14px)   │8px │ (100px)  │
│          │    │          │
│   ═══    │    │          │
│   ═══    │    │          │
│   ═══    │ ║  │  ← 值    │
│   ═══    │ ●  │          │ ← 跟随滑块
│   ═══    │ ║  │          │
│   ═══    │    │          │
│          │    │          │
│ 不响应   │响应│  不响应   │
└──────────┴────┴──────────┘

技术总结

1. 核心技术要点

✅ CSS Grid 布局

css
优势:
- 区域完全独立
- 自动计算尺寸
- 响应式友好
- 代码简洁

适用场景:
- 需要精确控制区域边界
- 需要独立控制交互行为
- 需要灵活的响应式布局

✅ pointer-events 精确控制

css
核心策略:
1. 容器默认 pointer-events: none
2. 只有交互区域 pointer-events: auto
3. 其他元素明确 pointer-events: none

防止误触发:
- 刻度不响应
- 滑块不响应(通过轨道控制)
- 值显示不响应

✅ 双层定位策略

typescript
外层:控制位置(跟随滑块)
<div style={{ bottom: `${percentage}%` }}>
  内层:控制对齐(居中)
  <div style={{ transform: 'translateY(50%)' }}>
    ...
  </div>
</div>

✅ 受控/非受控模式

typescript
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : internalValue

if (!isControlled) {
  setInternalValue(newValue)
}
onChange?.(newValue) // 总是触发回调

2. 性能优化

动画优化

typescript
// 点击时:使用 CSS transition
<div className={styles.fill} />

// 拖拽时:禁用 transition
<div className={`${styles.fill} ${styles.fillNoDrag}`} />

事件优化

typescript
// 使用 ref 存储拖拽状态,避免闭包问题
const isDraggingRef = useRef(false)

useEffect(() => {
  const handleMouseMove = (e: MouseEvent) => {
    if (isDraggingRef.current) { // 直接读取 ref
      updateValue(e.clientX, e.clientY)
    }
  }
  // ...
}, []) // 空依赖数组

计算优化

typescript
// 预计算刻度位置
const scales = useMemo(() => 
  Array.from({ length: scaleCount + 1 }, (_, i) => ({
    position: (i / scaleCount) * 100,
    isMain: i % 2 === 0,
  })),
  [scaleCount]
)

3. 关键经验教训

❌ 避免的坑

  1. 过度嵌套 DOM
typescript
// ❌ 错误
<div class="wrapper">
  <div class="inner">
    <div class="track"></div>
    <div class="value"></div> <!-- 嵌套过深 -->
  </div>
</div>

// ✅ 正确
<div class="container">
  <div class="track"></div>
  <div class="value"></div> <!-- 扁平化 -->
</div>
  1. 不明确的交互区域
css
/* ❌ 错误 */
.wrapper {
  padding: 100px; /* 大面积可能误触 */
}

/* ✅ 正确 */
.trackContainer {
  width: 8px; /* 精确宽度 */
  pointer-events: auto;
}
  1. 忽视 pointer-events
css
/* ❌ 错误:没有明确禁用 */
.thumb { }

/* ✅ 正确:明确禁用 */
.thumb {
  pointer-events: none;
}

✅ 最佳实践

  1. 用 Grid 代替复杂的 Flex + Position
  2. 精确控制每个元素的 pointer-events
  3. 双层定位处理跟随+居中
  4. 用 ref 管理事件状态,避免闭包
  5. 动态控制过渡动画

4. 可扩展性设计

支持的功能扩展

typescript
// ✅ 自定义刻度渲染
renderScale?: (value: number, isMain: boolean) => ReactNode

// ✅ 自定义值显示
renderValue?: (value: number) => ReactNode

// ✅ 范围滑动条(双滑块)
type?: 'single' | 'range'

// ✅ 垂直文字(纵向布局)
valueOrientation?: 'horizontal' | 'vertical'

// ✅ 触摸支持
onTouchStart, onTouchMove, onTouchEnd

// ✅ 键盘控制
onKeyDown: (e) => {
  if (e.key === 'ArrowUp') setValue(v => v + step)
  if (e.key === 'ArrowDown') setValue(v => v - step)
}

完整示例

基础使用

typescript
<ScaleSlider
  value={layerHeight}
  onChange={setLayerHeight}
  min={0}
  max={3}
  step={0.1}
  unit="mm"
  precision={2}
  orientation="vertical"
  scaleCount={10}
/>

高级配置

typescript
<ScaleSlider
  value={temperature}
  onChange={setTemperature}
  min={-20}
  max={100}
  step={1}
  unit="°C"
  precision={1}
  orientation="horizontal"
  scaleCount={12}
  showValue={true}
  showArrow={true}
  disabled={false}
  size={300}
/>

总结

核心突破

  1. 使用 CSS Grid 解决布局隔离问题

    • 三个区域完全独立
    • 交互区域精确可控
  2. pointer-events 精确控制

    • 只有轨道响应鼠标
    • 消除所有误触发
  3. 双层定位策略

    • 外层跟随滑块
    • 内层居中对齐

技术价值

  • ✅ 可复用的组件架构
  • ✅ 清晰的代码结构
  • ✅ 良好的性能表现
  • ✅ 完善的交互体验
  • ✅ 易于扩展和维护

适用场景

  • ✅ 3D 打印参数调节
  • ✅ 音量/亮度控制
  • ✅ 温度/压力调节
  • ✅ 任何需要精确刻度的滑动条

最终成果:一个生产级的刻度滑动条组件!  🎉

哈希指针,什么是区块链,genesis blcok,most recent block,tamper-evident log,merkle tree,binar

作者 前端涂涂
2025年12月4日 18:28

区块链中最核心的底层概念 系统性总结成一份清晰易懂的知识体系,所有内容都提供简明解释 + 用法 + 举例


✅ 1. 哈希指针(Hash Pointer)

概念: 一种特殊的指针,它不仅包含 指向某个数据的地址,还包含该数据的 哈希值(Hash)

用途: 可用于创建不可篡改的数据结构,例如链表、区块链。

举例: 一个区块记录:

Hash Pointer → 指向上一个区块,同时包含上一个区块内容的 hash

如果有人篡改了上一个区块内容,哈希值就会变,链条就断裂 → 篡改可被检测


✅ 2. 什么是区块链(Blockchain)

概念: 区块链是一种结构:

  • 数据被分成多个 区块(Block)
  • 每个区块包含自己的数据和一个 哈希指针,指向上一个区块
  • 所有区块串联成一个链

特点:

  • 不可篡改(修改一个区块会导致链断裂)
  • 分布式(多个节点都保存账本)
  • 去中心化
  • 可追溯

✅ 3. 创世区块(Genesis Block)

概念: 区块链中的第一个区块。 它没有上一个区块,所以哈希指针通常填空值或 0。

特点:

  • 整条链的起点
  • 硬编码在区块链系统中

✅ 4. 最新区块(Most Recent Block)

概念: 链上最末尾的区块,也叫 HEAD block。 新区块会接在它后面添加。


✅ 5. 可检测篡改日志(Tamper-evident Log)

概念: 一种日志结构,只要有人修改历史记录,就能被检测出来。

区块链本质上就是一个巨型的 tamper-evident log

使用场景:

  • 审计日志
  • 金融系统交易历史
  • 区块链账本

✅ 6. Merkle Tree(默克尔树)

概念: 一种树形哈希结构

  • 叶子节点存数据的 Hash
  • 上层节点是左右子节点哈希拼接后再 hash
  • 树顶产生 根哈希(Root Hash)

用途:

  • 快速证明某条数据是否存在
  • 大数据集的完整性验证
  • 区块链交易数据结构

举例:

   Root Hash
     /  \
    H1  H2
   / \  / \
 D1 D2 D3 D4

✅ 7. 二叉树(Binary Tree)

概念: 每个节点最多只有两个子节点的树结构。

Merkle Tree 是一种特殊的二叉树(只不过子节点是哈希)。


🔥 Merkle Tree vs Binary Tree 区别

维度 Merkle Tree Binary Tree
主要用途 数据完整性验证 泛用数据结构
节点内容 存哈希(Hash) 存任意数据
优势 修改易检测、快速证明 结构简单,操作方便
区块链使用 是核心组成 不一定

✅ 8. Data Block(数据区块)

概念: 叶子节点存放的实际数据(或数据的哈希)。

在区块链中,Data block 即:“某个交易数据”。


✅ 9. 根哈希(Root Hash)

概念: Merkle 树最上层生成的唯一哈希值。

用途:

  • 代表整个数据集
  • 任何数据改动,root hash 就会改变
  • 区块链的 block header 中包含 root hash

✅ 10. 区块头(Block Header)

包含区块的元数据,如:

  • 上一个区块的哈希(hash pointer)
  • Merkle Root(本区块所有交易的摘要)
  • 时间戳
  • 难度值/随机数(如 PoW)

区块头非常小,用于 共识验证


✅ 11. 区块体(Block Body)

包含实际数据,如:

  • 多笔交易列表
  • 附属数据

体积大,不参与共识算法的哈希计算。


🔥 12. Merkle Proof(默克尔证明)

概念: 证明一种数据是否在 Merkle Tree 中,只需提供:

  • 要验证的数据的 hash
  • 若干个兄弟节点(siblings)的哈希构建路径

从而重建 root hash 看是否吻合。

特点:

  • 证明很短(O(logN))
  • 区块链轻节点(Light Client)常用

13. Proof of Membership(成员证明)

概念: 证明“某条数据在集合中”。

Merkle Proof 是一种典型的 membership proof。

例子: 证明交易 tx3 存在于区块中,只需提供 tx3 的 merkle 路径(比如 H4、H12 等)。


14. Proof of Inclusion(包含证明)

这是 membership proof 的另一种叫法。 Meaning:数据被包含在集合中。

在区块链文献中 Inclusion = Membership


15. Proof of Non-Membership(非包含证明)

概念: 证明“某条数据不在集合中”。

Merkle Tree 原生不支持,需要用 Merkle Patricia Trie(MPT)Sorted Merkle Tree 才能证明不包含。

例如: 查询一个地址从未发生过交易 → 需要 non-membership proof。


📦 总结(结构化知识框架)

一页脑图式总结:

Blockchain = Blocks + Hash Pointers + Tamper-evident log
     |
     |-- Genesis Block (first block)
     |-- Most Recent Block (head)
     |
     |-- Block Structure
     |       |-- Block Header (prev hash, merkle root, timestamp)
     |       |-- Block Body (transactions)
     |
     |-- Merkle Tree
             |-- Binary tree storing hashes
             |-- Leaf: data block
             |-- Output: root hash
             |-- Proof:
                    |-- Merkle Proof
                           |-- Proof of Membership (Inclusion)
                           |-- Proof of Non-membership

在这里插入图片描述

树结构、区块结构

下面给你 最清晰、最标准的区块链结构 + Merkle 树结构 ASCII 可视化示意图,适合学习与文档引用。


📦 一、区块链结构(Blockchain Structure)示意图

┌──────────────────────────────────────────┐
              Genesis Block               
   (第一个区块,没有前哈希 Prev Hash)        
                                          
   Block Header:                          
      Prev Hash: 0000000000000000         
      Merkle Root: A1B2C3...              
      Timestamp: T0                       
   Block Body:                            
      [tx1, tx2, tx3...]                  
└──────────────────────────────────────────┘
                      Hash Pointer
                     
┌──────────────────────────────────────────┐
                Block #2                  │
                                          
   Block Header:                          
      Prev Hash: HASH(Genesis Block)      
      Merkle Root: D4E5F6...              
      Timestamp: T1                       
   Block Body:                            
      [tx4, tx5, tx6...]                  
└──────────────────────────────────────────┘
                      Hash Pointer
                     
┌──────────────────────────────────────────┐
                Block #3                  │
                                          
   Block Header:                          
      Prev Hash: HASH(Block #2)           │
      Merkle Root: XYZ789...              
      Timestamp: T2                       
   Block Body:                            
      [tx7, tx8...]                       
└──────────────────────────────────────────┘
                      Hash Pointer
                     
              (Most Recent Block)

👉 关键看点

  • 每个区块通过 Prev Hash 指向上一个区块
  • 区块头(Header)非常小
  • 区块体(Body)存交易
  • 修改任意历史区块会使链断裂 → tamper-evident

🌲 二、Merkle Tree(默克尔树)可视化结构

                        ┌────────────────────┐
                        │      Root Hash      │
                        └───────────┬────────┘
                                    │
               ┌────────────────────┴─────────────────────┐
               │                                          │
        ┌──────┴──────┐                            ┌──────┴──────┐
        │     H12      │                            │     H34      │
        └──────┬──────┘                            └──────┬──────┘
               │                                          │
        ┌──────┴──────┐                            ┌──────┴──────┐
        │   Hash1      │                            │   Hash3      │
        │ (tx1+tx2)    │                            │ (tx3+tx4)    │
        └──────┬──────┘                            └──────┬──────┘
               │                                          │
     ┌─────────┴──────────┐                    ┌─────────┴──────────┐
     │        H1           │                    │        H3           │
     │    Hash(tx1)        │                    │    Hash(tx3)        │
     └─────────────────────┘                    └─────────────────────┘

     ┌─────────┐                                ┌─────────┐
     │   H2     │                                │   H4     │
     │Hash(tx2) │                                │Hash(tx4) │
     └─────────┘                                └─────────┘

👉 结构说明

  • 最底层叶子是 交易哈希(H1, H2, H3, H4)
  • 上一层将相邻两个叶子拼接后 hash(Hash1、Hash3)
  • 最上层再 hash 得到 Merkle Root
  • 任何底层数据变化都会引起 Root Hash 变化

🔥 三、Merkle Proof(证明路径)示意图

以证明 tx3(H3)存在于树中 为例:

要验证的目标: H3 (tx3)

提供的 Merkle Proof:
   - H4 (兄弟节点)
   - H12(另一个分支的 hash)

验证过程结构(构建路径):

                       Root Hash ?
                            ▲
                  ┌─────────┴─────────┐
                  │        H12         │   ← 提供
                  └─────────┬─────────┘
                            │
                  ┌─────────┴─────────┐
                  │      Hash3         │
                  └───────┬───────────┘
                          │
                  ┌───────┴───────┐
                  │   H3H4    │   ← H3 已知 / H4 提供
                  └────────┴────────┘

用户只需提供 log(N) 个哈希,就能重建 root hash。


📘 四、区块结构(Block Structure)内部示意图

┌───────────────────────────── Block ─────────────────────────────┐
│                                                                  │
│   ┌────────────────────── Block Header ───────────────────────┐  │
│   │  Version                                                  │  │
│   │  Prev Block Hash (hash pointer)                           │  │
│   │  Merkle Root (所有交易的摘要)                                │  │
│   │  Timestamp                                                │  │
│   │  Nonce                                                    │  │
│   └───────────────────────────────────────────────────────────┘  │
│                                                                  │
│   ┌────────────────────── Block Body ─────────────────────────┐ │
│   │  Transaction 1                                             │ │
│   │  Transaction 2                                             │ │
│   │  Transaction 3                                             │ │
│   │  ...                                                       │ │
│   └────────────────────────────────────────────────────────────┘ │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

👉 Header 用来做哈希计算(PoW 等) 👉 Body 是实际内容,不参与主链哈希计算


this 不是你想的 this:从作用域迷失到调用栈掌控

作者 有意义
2025年12月4日 17:41

一、自由变量 vs 对象属性:

一段代码揭示 JavaScript 作用域的本质

我们从一段看似简单、却常被用作教学陷阱的 JavaScript 代码开始:

var bar = { 
  myName: "time.geekbang.com",
  printName: function() {
    console.log(myName); // 注意:这里没有 this,也没有声明 myName
  }
}

function foo() {
  let myName = '极客时间';
  return bar.printName;
}

let _printName = foo();
_printName(); // ❌ ReferenceError: myName is not defined

初看之下,这段代码似乎只是想“打印某个名字”。但运行后,控制台会抛出一个错误:

ReferenceError: myName is not defined

image.png

为什么会这样?
这背后涉及 JavaScript 中两个极易混淆的核心概念:词法作用域中的自由变量查找对象属性访问。它们看似相似,实则天差地别。


🔍 myName 到底是谁?

关键问题在于:printName 函数体中的 myName 指的是什么?

  • 不是 this.myName —— 代码中压根没写 this
  • 它也不是局部变量 —— 函数内部没有用 varlet 或 const 声明 myName

于是,JavaScript 引擎启动标识符解析(Identifier Resolution) 机制:沿着词法作用域链(Lexical Scope Chain) 向上查找这个变量。

📌 词法作用域由函数“定义的位置”决定,而非“调用的位置”

查找路径如下:

  1. 在 printName 自身的作用域中查找 → 未找到
  2. 跳转到该函数定义时的外层作用域 → 这里是全局作用域
  3. 在全局作用域中查找名为 myName 的绑定。

然而,在初始版本中,全局作用域确实没有 myName 变量

⚠️ 重要区分:对象属性 ≠ 变量
尽管 bar 对象有一个 myName 属性,但 bar.myName属性访问表达式,而 myName 是一个自由变量(Free Variable)
JavaScript 不会自动将对象属性当作同名变量来解析。

因此,引擎找不到 myName,抛出 ReferenceError


✅ 添加全局变量后:为什么输出 “极客邦”?

现在我们在全局作用域添加一行:

let myName = '极客邦';

完整代码变为:

<script>
var bar = { 
  myName: "time.geekbang.com",
  printName: function() {
    console.log(myName); // 自由变量查找
  }
}

function foo() {
  let myName = '极客时间';
  return bar.printName;
}

let myName = '极客邦'; // ← 全局词法环境中的绑定
let _printName = foo();
_printName(); // 输出:'极客邦'
</script>

此时,printName 执行时:

  • 自身作用域无 myName
  • 沿词法作用域链向上 → 找到全局作用域中的 myName 绑定
  • 成功解析为 '极客邦'

image.png

💡 补充说明:
虽然 let 声明的全局变量不会挂载到 window 对象上(即 window.myName === undefined),但它仍然存在于全局词法环境(Global Lexical Environment)中,对自由变量查找完全可见。
这正是 ES6 引入块级作用域后的设计:作用域查找 ≠ 全局对象属性查找


🎯 如何真正访问 "time.geekbang.com"

我们的目标其实是 bar 对象上的属性值 "time.geekbang.com"。既然 console.log(myName) 打印的是全局变量 '极客邦',那该如何正确访问对象属性?

答案很简单:显式通过对象引用访问

console.log(bar.myName); // ✅ 输出 "time.geekbang.com"

这行代码之所以成功,是因为它直接执行了属性访问操作。只要 bar 在当前作用域可见(这里是全局),就能稳定、可靠地获取其属性。

🗣️ 虽说“直呼其名有点不礼貌”?
其实在编程中,清晰比委婉更重要
显式写出 bar.myName,是对代码可读性和可维护性的最大尊重。


⚠️ 那 this.myName 为什么不行?

第三行代码试图用 this 来访问:

console.log(this.myName); // ❌ 输出 undefined

问题出在 this 的绑定方式 上。

虽然 printNamebar 的方法,但我们是这样调用它的:

let _printName = foo();
_printName(); // 直接调用函数,没有通过 bar

这种调用方式下,this 指向全局对象(如浏览器中的 window)。

但全局变量是用 let 声明的:

let myName = '极客邦';

let 声明的变量不会成为全局对象的属性,所以:

this.myName   // 等价于 window.myName → undefined

🔁 对比实验:
如果改成 var myName = '极客邦',那么 this.myName 会输出 '极客邦' ——
但请注意,这仍然是全局变量不是 bar.myName

因此,this.myName 在这里既不可靠,也不是你真正想访问的值。


🧩 小结:三种访问方式的本质区别

写法 机制 是否依赖作用域 能否访问 bar.myName
myName 自由变量(词法作用域) ❌(除非全局巧合)
bar.myName 对象属性访问 ❌(只需 bar 可见)
this.myName 动态上下文绑定 ❌(除非通过 bar.printName() 调用)

对象属性是数据,变量是绑定;前者靠引用访问,后者靠作用域查找。二者在 JavaScript 中属于完全不同的命名空间。

理解这一点,是避免“我以为它能找到”的关键。下一部分,我们将深入探讨 this 的动态绑定规则——为什么它如此“善变”,又该如何掌控它。

二、this 的真相:

动态上下文如何由调用方式决定?

在第一节中,我们厘清了:

  • 变量查找是静态的(词法作用域)
  • 对象属性 ≠ 变量
  • 自由变量沿定义时的作用域链向上查找

而本节要揭示的是另一个平行但常被混淆的机制:

this 的值与作用域无关,它完全由函数的“调用方式”决定——它是动态的、运行时的上下文引用。

这正是初学者甚至中级开发者频繁踩坑的根源:this 当作“当前对象”或“作用域”的同义词

🎯 this 到底是谁?

在 JavaScript 中,this 是一个既基础又令人困惑的概念。很多开发者误以为 this 和“函数定义的位置”或“当前作用域”有关,但事实恰恰相反:this 的值完全由函数的调用方式决定,与词法作用域无关

本文将通过三个典型场景,带你一步步揭开 this 的真实面目,并建立一套可靠的判断逻辑。


第一部分:普通函数调用 → 默认绑定

function foo() {
    console.log(this);  // 输出 window(在浏览器中)
}
foo();  // 普通函数调用

非严格模式下,当你直接调用一个函数(如 foo()),JavaScript 引擎会将该函数的 this 默认绑定到全局对象

  • 在浏览器环境中,全局对象是 window
  • 在 Node.js 中,则是 global

因此,上述代码会输出 window

image.png

❓ 为什么这看起来“不合理”?
因为我们本能地认为:“这个函数写在全局,那 this 应该代表‘当前上下文’。”
但 JavaScript 的设计哲学是:this 不是静态的,而是动态的——它取决于“怎么调用”,而不是“在哪定义”。

小结

普通函数调用(foo()) → this 指向全局对象
(非严格模式下是 window;严格模式下是 undefined


第二部分:call / apply → 显式绑定

let bar = {
    myName: "极客邦",
    test1: "1"
};

function foo() {
    this.myName = "极客时间";
}

foo.apply(bar);
console.log(bar); // { myName: "极客时间", test1: "1" }

这里我们重新定义了 foo,它的作用是给 this 对象设置 myName 属性。

关键在于这一行:

foo.apply(bar);

Function.prototype.apply(以及 callbind)允许我们显式指定函数执行时的 this
foo.apply(bar) 的含义是:

“调用 foo 函数,并强制让函数内部的 this 指向 bar 对象。”

于是,this.myName = "极客时间" 实际上等价于 bar.myName = "极客时间",成功修改了 bar 的属性。

image.png

小结

foo.call(obj)foo.apply(obj)this 被显式绑定为 obj
这是控制 this 最直接、最可靠的方式之一。


第三部分:对象方法调用 → 隐式绑定

function foo() {
  console.log(this.a);
}

var obj = {
  a: 2,
  foo: foo
};

obj.foo(); // 输出 2
  • 关键:通过 obj.foo() 调用函数

  • 在 JavaScript 中,当一个函数作为对象的方法被调用时,函数内部的 this 会自动绑定到该对象(即 obj)。

  • 所以在 foo 执行时:

    • this 指向 obj
    • this.a 就是 obj.a,即 2
  • 因此输出:2

小结

作为对象的方法调用(obj.method()) → this 指向 obj
但仅限于“点调用”形式,解构或赋值后调用会失效。


理解 this,是迈向 JavaScript 运行时高手的关键一步。下一次当你看到 this 时,别再问“它应该是什么”,而是问: “它是怎么被调用的?” ——答案就在调用栈中。

第四部分:隐式绑定丢失

第三部分讲到了隐式绑定的概念,现在我们来讲讲隐式丢失

var myObj = {
            name : "极客时间",
            showThis: function(){
                this.name = '极客邦';
                console.log(this);
            }
        }
        var foo = myObj.showThis;   // window.foo

⚠️ 隐式绑定为什么会“丢失”?

现在看你的代码:

var foo = myObj.showThis; // 仅获取函数引用,未调用
foo(); // ❌ this 不再是 myObj!

这一步发生了什么?

  1. myObj.showThis 是一个函数引用,它本身只是一个普通的函数值;
  2. 将其赋值给变量 foo 后,foo 和 myObj 彻底断开联系
  3. 当你执行 foo() 时,这是一个普通函数调用(没有通过对象),因此触发的是 默认绑定规则

在非严格模式下:

foo(); // 相当于 window.foo() → this = window

所以,在 showThis 内部:

this.name = '极客邦'; // 实际上是 window.name = '极客邦'
console.log(this);    // 输出 window 对象

🔥 这就是“隐式绑定丢失”:
原本属于对象的方法,一旦被当作普通函数调用,就失去了与原对象的上下文关联,this 回退到全局对象(或 undefined)。

第五部分:new 调用 → new 绑定(最高优先级)

你可能写过这样的代码:

function CreateObj() {
    this.name = "极客时间";
}

var myObj = new CreateObj();
console.log(myObj.name); // "极客时间"

但你是否想过:为什么 this 在这里指向新创建的对象?

这背后是 new 操作符在运行时完成的一系列精密步骤。我们可以通过“手动实现 new”来还原其本质:

✅ 正确的手写 new 实现(教学版)
function myNew(Constructor, ...args) {
    // 1. 创建一个新对象
    const obj = {};

    // 2. 将新对象的 [[Prototype]] 链接到构造函数的 prototype
    obj.__proto__ = Constructor.prototype;

    // 3. 将构造函数内的 this 绑定到这个新对象,并执行构造函数
    const result = Constructor.apply(obj, args);

    // 4. 如果构造函数返回的是引用类型,则返回该值;否则返回新对象
    return (typeof result === 'object' && result !== null) ? result : obj;
}

🧠 new 绑定的核心规则:

当使用 new CreateObj() 时,JavaScript 引擎会按以下顺序执行

  1. 创建一个全新的 空对象
  2. 将该对象的内部 [[Prototype]] 链接到 CreateObj.prototype
  3. 将 CreateObj 函数体内的 this 绑定到这个新对象;
  4. 执行构造函数体**(即 this.name = "极客时间");
  5. 如果构造函数没有显式返回一个对象,则自动返回新创建的对象。

第六部分:DOM 事件监听器中的 this 

考虑这段常见代码:

<a href="#" id="link">点击我</a>
<script>
document.getElementById('link')
  .addEventListener('click', function() {
    console.log(this); // 👉 输出 <a id="link">点击我</a>
  });
</script>

尽管这是一个普通函数作为回调传入,且以普通方式被调用(我们并没有写 handler.call(element)

但它的 this 却神奇地指向了触发事件的 DOM 元素。

🔍 这是怎么发生的?

答案是:浏览器在内部调用你的回调函数时,显式绑定了 this

也就是说,浏览器大致做了这样的事:

// 浏览器内部伪代码
const element = document.getElementById('link');
const handler = function(event) { console.log(this); };

// 当点击发生时:
handler.call(element, event); // ← 显式将 this 设为 element!

因此,虽然你写的是一个“普通函数”,但调用者(浏览器)使用了 .call() ,使得 this 指向事件目标(event target)。

✅ 这不是 JavaScript 引擎的默认行为,而是 DOM API 的设计约定


⚠️ 注意:箭头函数会破坏这一行为!

如果你改用箭头函数:

document.getElementById('link')
  .addEventListener('click', () => {
    console.log(this); // 👉 输出 window(非严格模式)
  });

你会发现 this 变成了全局对象。为什么?

因为:

  • 箭头函数没有自己的 this
  • 它的 this 继承自外层词法作用域(这里是全局作用域);
  • 浏览器即使想通过 .call() 绑定 this,也对箭头函数无效(规范规定箭头函数忽略 this 参数)。

📌 所以:如果你想在事件回调中使用 this 指向元素,必须使用普通函数。

💡 记住:this 的值永远取决于“谁在调用”以及“怎么调用”——即使是浏览器,也在遵守这一原则。

🧩 总结:this 绑定规则优先级(从高到低)

绑定类型 调用形式 this 指向
new 绑定 new Fn() 新创建的实例对象
显式绑定 fn.call(obj)fn.bind(obj) 指定的对象 obj
隐式绑定 obj.method() obj
默认绑定 fn() 全局对象 / undefined
箭头函数 任意 继承外层词法作用域的 this

💡 终极心法
不要问“this 应该是什么”,而要问“它是怎么被调用的?”
答案,永远藏在调用栈中。

第2讲:BTC-密码学原理 北大肖臻老师客堂笔记

作者 前端涂涂
2025年12月4日 17:39

这节课系统讲清了支撑比特币的两大密码学支柱:哈希函数和公钥密码/数字签名,并用这些工具解释“比特币为什么能防伪、防篡改、防冒充”。

一、密码学在比特币中的角色

  • 课程先强调电子货币系统必须解决四个核心问题:如何保密、如何防篡改、如何确认“你就是你”、以及如何防止事后否认自己转过账。密码学正是用来给这些问题提供“工程级”的技术解法的基础设施。
  • 讲课中多次提醒:不要带着“传统银行系统”的思路看比特币,而要意识到在完全开放的网络环境下,只能依赖数学和算法来建立信任,这为后面所有技术细节定下了基调。

例子:如果你在QQ群里转账给别人,没有任何中心机构背书,那么其他人如何确认这笔“转账记录”是真的?这就需要密码学来保证这条记录既不能被别人随便伪造,也不能被你自己事后篡改或否认。

二、哈希函数:区块链的“指纹机”

  • 课程用“给任意数据生成短小指纹”的比喻解释哈希函数:无论原始数据多长,丢进哈希函数得到的值长度固定、看起来随机,却对输入极度敏感,一点点改动都会导致截然不同的输出。
  • 重点强调哈希的三大性质:单向性(从结果几乎推不回输入)、抗碰撞(几乎不可能找到两个不同输入有同样输出)、以及雪崩效应(输入微变输出大变),这些性质保证了“只看指纹就能检查内容是否被篡改”。

例子:

  • 把“转账给张三 10 BTC”文本当作输入,计算出哈希值 H1。哪怕只是偷偷把“10”改成“11”,重新算出的哈希 H2 与 H1 完全不同。于是网络里的节点只要对收到的交易重新算一遍哈希,就能立刻发现数据有没有被动过手脚。

三、哈希在比特币中的具体用法

  • 讲到比特币时,老师把哈希函数变成一个“命名工具”:每笔交易、每个区块都通过哈希得到一个“名字”,这个名字长度固定、便于传播,又很难和别的数据重复,因此非常适合在点对点网络中当作标识符使用。
  • 此外,课程还会提到:区块头里包含上一个区块的哈希,从而形成一条哈希链;区块内部为了一次性概括所有交易,会用到更复杂的结构(默克尔树),让一个哈希值就能代表“这一整个交易集合”。

例子:

  • 某一区块包含 2000 笔交易。通过默克尔树把这 2000 条记录逐层哈希,最终得到一个“总哈希”写在区块头中。以后任何人若想证明“某笔交易确实包含在这个区块内”,只要提供一条从这笔交易到树根的哈希路径即可,验证效率非常高。

四、公钥密码与数字签名:谁在花钱

  • 在解决“谁在花钱”之前,老师先介绍公钥密码体系:每个人有一对密钥,私钥自己保存,公钥可以公开。用私钥做的数学运算(签名),只有对应公钥能验证;反之,没有私钥的人无法伪造同样的结果。
  • 课程以“签名”而不是“加密”来解释比特币的用法:用户用自己的私钥对交易内容签名,所有节点利用公开的公钥来检验这笔交易是不是由该用户发起,从而实现“谁对转账负责”的认证和不可否认性。

例子:

  • 你要把 1 BTC 转给小王。你构造一条交易信息:“从我的某个 UTXO 转出 1 BTC 给小王的地址”,然后用私钥对这条消息做数字签名。全网节点收到后,用你的公钥验证签名。如果签名对得上,说明确实是你发的,而不是别人冒充你乱花你的钱。

五、比特币地址、私钥、公钥的关系

  • 老师会说明:普通用户看到的是“地址”,但底层是从公钥经过一系列哈希和编码得到的结果,这样做既缩短了长度,又通过多次哈希增加了安全性和容错性。
  • 这一设计带来两个效果:一方面,拿到地址无法反推出公钥甚至私钥,保护了用户安全;另一方面,一旦用户用对应私钥签名,网络就能通过地址映射出公钥,再进一步验证签名是否有效,从而把“地址”和“控制权”对应起来。

例子:

  • 可以把地址理解成“银行卡号”,公钥类似“卡的某些公开参数”,私钥则是“只有你知道的 PIN+签名设备”。别人看到你的地址可以给你转账,但无法从地址推回你的私钥;只有你能对“从这个地址花钱”的交易签字。

六、两大工具如何共同保证安全

  • 课程最后把哈希和数字签名串联起来:哈希保证了“账本上写的内容一旦确定,任何微小篡改都会暴露”;数字签名保证了“只有真正所有者才能发起有效的转账”。合在一起,就构成了一个无需中心机构也能维持的“公开、可验证的账本系统”。
  • 进一步讲,比特币通过“哈希链”让区块彼此捆绑,通过“全网节点验证签名”来过滤非法交易,再在后续章节用工作量证明、共识协议等机制解决“谁来写下一个区块”的问题,这一讲则是后面所有机制的密码学地基。

概念总结

这一讲的内容可以按你说的三块来记,既有概念,也有内在结构,还有“为啥这套东西能跑起来”的直觉。

一、记概念:几个核心密码学积木

  • 哈希函数:输入任意长度数据,输出固定长度“指纹”,特点是单向(不能反推原文)、抗碰撞(几乎找不到两个不同输入同哈希)、雪崩效应(改一位结果大变),在比特币里用来给交易、区块、交易集合“起名字”和做完整性校验。
  • 公钥 / 私钥:每个用户有一对密钥,私钥自己保管,公钥可以公开;任何人能用公钥验证“这条消息确实由对应私钥签过名”,但不能从公钥算出私钥。
  • 数字签名:用私钥对“具体的交易内容”做数学运算,生成一段签名;别人拿到交易内容和签名,用公钥一验就知道是不是你签的,既可以防伪造,又让你事后不能否认自己发过这笔交易。
  • 地址:对公钥做多次哈希和编码得到的短字符串,用来当“收款账号”;别人看到地址可以给你打币,但既不能从地址推出私钥,也不能单凭地址花你的钱,必须配合私钥签名才行。

小例子:

  • 你写了一条消息:“给张三 1 BTC”,算出哈希是 H1,后来有人把 1 改成 2,再算一次变成 H2。由于 H1≠H2,任何节点都能一眼知道中间有人动过手脚。
  • 你用私钥对“从地址 A 给地址 B 1 BTC”签名,得到 Sig。全网节点只需拿交易内容 + Sig + 你的公钥,就能验证是否真是你签的,而不是别人伪造的。

二、记关系:交易、签名与哈希链

  • 交易和签名的绑定:一笔比特币交易里,写清楚“花掉的是哪些之前收到的币、要转给谁、转多少”,然后对“这整条消息”做数字签名;因为签名和具体内容强绑定,任何人想改金额、改收款人,都会导致原来的签名立即失效,只能重新让持有私钥的人再签一次。
  • 区块和哈希的绑定:每个区块除了交易,还包含上一个区块头的哈希值,这样一来,如果你改动历史上任何一个区块的数据,对应哈希就变了、后面所有区块里“引用的那个哈希”也会错,从而整条链都露馅,形成所谓“哈希链”。

小例子:

  • A 在第 100 号区块里给 B 转了 1 BTC。有人想把这条记录改成“转 10 BTC”,那么 100 号区块的哈希会变,101 号区块里“前一区块哈希”字段就不对了,必须连带修改 101、102、103……所有后续区块,代价巨大。
  • 一棵默克尔树可以把几千笔交易“压缩”成一个哈希根写入区块头,只要任何一笔交易被改动,重新计算出的树根就不一致,说明区块被动过手脚。

三、记直觉:数学+广播网络=去中心化信任

  • 数学替代人治:传统银行系统里,是银行这个中心机构告诉你“这笔账是真的”;在比特币里,哈希和数字签名保证“只要式子对上,这就是原始持币人亲自授权、且记录未被篡改”,不需要相信某个机构,只需要相信算法的难解性。
  • 广播+多数验证替代单点记账:每笔交易发到全网,节点各自用公钥验证签名、用哈希检查数据一致性;谁来写入下一个区块交给后面的共识和挖矿机制,但所有人都用同一套数学规则来判断“哪条链、哪些交易是有效的”,于是形成一种“公共账本”,任何人都能检验、没人能轻易作假。

直觉例子:

  • 想象一群人围成一圈记账,任何人都可以大声宣布“我给小王 1 BTC”;但只有你能拿出针对这句话的正确数字签名,别人无法伪造你的“声音”,而所有人都用同样的哈希和签名规则来判断这句话是否被篡改、是否真是你说的。
  • 久而久之,这群人按照统一规则选出“本轮记账代表”写下一个区块;但即便如此,他也改不了既有记录,因为一改就会破坏哈希链,被其他人立即识破。
    在这里插入图片描述

基于uniapp的PDA手持设备红外扫码方案

2025年12月4日 17:24

前言

在本篇文章中,可以学习到基于uniapp平台的移动端app应用,通过uts插件调用安卓sdk实现红外扫码功能。

背景

移动端最近有一个需求,需要使用PDA的红外扫码功能来识别一些二维码。识别成功后,再进行一些业务操作。因为公司移动端的技术用的是uniapp,没有找到合适的现成库或工具,于是决定自己造,顺带写了这篇文章。

名词解释

PDA设备

PDA的英文全称叫Personal Digital Assistant,现代意义上的PDA终端手持机是一种集成了数据采集、处理、传输功能的智能终端设备。 如下图就是一个在使用红外扫码功能的PDA手持设备。

UTS插件

uts,全称uni type script,统一、强类型、脚本语言,是uni提供的接入原生api、SDK、插件的DSL。

它会被编译为不同平台的编程语言,如:

  • web平台,编译为JavaScript
  • Android平台,编译为Kotlin
  • iOS平台,编译为Swift(HX 3.6.7+ 版本支持)
  • harmonyOS平台,编译为ArkTS(HX 4.22+ 版本支持)在现有架构下,ArkTS和JS在同一环境下执行,不涉及通讯等问题。

uts插件,指利用uts语法,操作原生的API(包括手机os的api或三方sdk),并封装成一个uni_modules插件,供前端调用。

如果有想要详细了解的同学可以看看官网介绍:Uniapp UTS插件


需求分析

本文最终采用的是PDA厂商SDK方案,使用UTS插件接入SDK,最后封装成业务组件。(不关心方案选型,想直接看PDA厂商SDK接入的同学,可以点击目录【具体实现】跳转)

方案分析

方案 优点 缺点
隐藏input代理
  1. 通用方案,不需要针对不同的PDA设备编写独立代码。

  2. 开发方案熟悉,基本还是基于web开发的思维。
  1. 移动端input聚焦的时候会唤起键盘,关闭键盘方案不够好的情况下会导致页面闪烁。

  2. 需要针对处理不同厂商(甚至是同一厂商不同型号)设备的红外扫码按键监听事件。
PDA系统设置扫码广播
  1. 用户体验好,效果PDA厂商SDK方案没有差别。

  2. 代码量小,开发简单。
  1. 需要针对每一台PDA手持设备进行特殊设置,客户体量大的情况下配置PDA手持设备极度麻烦。

  2. 极度依赖PDA厂商的系统设置。
PDA厂商SDK方案
  1. 用户体验好,完全静默的红外扫码,依靠消息订阅实现。

  2. 不需要引入额外的js库。
  1. 针对不同的PDA品牌,需要额外特殊处理。

  2. 较前两个方案复杂,原生SDK接入不是web开发的舒适区。

隐藏input代理方案

如果要使用隐藏input代理方案,有两个必须要面对的问题:

  • 第一个是input聚焦时会唤起键盘
  • 第二个是需要监听PDA手持设备的红外扫码功能的开始与结束。

开始具体分析这两个问题。

如何避免input聚焦唤起键盘

目前有三种方案可以隐藏唤起键盘或者避免键盘被唤起。隐藏唤起键盘会带来无法避免的页面闪烁问题,所以避免键盘被唤起是最优解。

  1. uniapp提供了hideKeyboard方法用来隐藏键盘,hideKeyboard方法会带来页面闪烁问题。检索到有结合使用setInterval不断隐藏键盘的方案,但是测试结果依旧是会有页面闪烁的情况,不建议使用。如果有同学想了解该方案可以看看uniapp的官网

  2. 使用input组件的属性,来达到聚焦input组件时,不唤起键盘的效果。

    1. inputmode属性,可以完全屏蔽唤起键盘,但是会丢失PDA扫码结果。
    2. readonly属性,app端测试没有效果,无法屏蔽唤起键盘。(有资料说在小程序端有效果,这个我没有做测试,有兴趣的同学可以自己试试。)
  3. 通过原生插件或者uniapp的native.js来修改页面级(activity级别)的键盘唤起模式。(这个方案没有深入尝试,因为既然都需要编写原生相关代码。那使用PDA厂商的SDK,明显获得更好的用户体验,)

如何监听PDA手持设备的红外扫码功能

  1. 监听PDA的键盘事件,需要依赖uniapprender.js来获取addEventListener,监听键盘事件。
  2. 监听PDA手持设备红外扫码功能的广播。(可以询问适配的PDA厂商,有没有开放红外扫码的安卓公共广播。如果没有那就只能针对SDK进行一个二次开发)

隐藏input代理方案是一个不错的通用解决方案。但是隐藏键盘带来的页面闪烁问题,会带给用户较差的体验,所以最后的实现方案没有采用该方案。

PDA系统设置扫码广播解解

不同的PDA手持设备有着不同的设置方式,这里是一个其他同学的方案,想了解该方案的同学可以去看看。

需要针对使用的PDA设备进行独立配置红外扫码功能的系统设置,如果在客户体量大、数量多的情况下,会有大量PDA设置需求。会带来两个问题:

  • 第一,客户大概率是不愿意自己进行PDA设置,那么这个设置过程将消耗己方的大量人力。
  • 第二,不是谁谁来设置这些PDA手持设备,都没有办法保证每一台PDA都一定设置正确。

这个是无法接受的缺点,所以最终也没有采用该方案。

PDA厂商SDK方案

红外扫码作为PDA的原生能力,那么自然会对有需求的开发者提供相关的SDK。我们只需要调用这些SDK,就可以轻松完成红外扫码识别。

步骤拆解:

  1. 接入PDA手持设备的官方SDK。
  2. 在插件中完成,对红外扫码的监听。
  3. 封装相关业务组件,提供给项目使用。

具体实现

当前实现过程使用的是霍尼韦尔PDA设备,接下来的实现过程将以该品牌提供的SDK作为例子。大家在自己开发、接入SDK时,需要向适配的PDA厂商索要红外扫码SDK的相关文档、示例。

如何获取SDK

获取sdk有多种途径,这个同学们可以询问一下公司的采购,或者向上级反映情况。

  • 可以去各个品牌的官网下载。
  • 找购买渠道的渠道商要相关型号品牌的sdk。

了解SDK

大致有两个比较重要类需要我们在编写代码前了解一下:

  • AidcManager这个是扫码模块的管理类。
// 红外扫码模块的管理类
public final class AidcManager extends java.lang.Object {

    /**
     * 这个接口提供了AidcManager创建成功的回调方法。
     */
    public static interface AidcManager.CreatedCallback {
    
        /**
         * 需要我们未来实现的方法,实现红外扫码功能。
         * aidcManager是红外扫码管理实例
         */
        void onCreated(AidcManager aidcManager)
    }

    /* 
     * 创建红外扫码管理器的静态类,需用通过该方法创建AidcManager的实例。
     * context 是安卓引用的上下文对象
     * callback 将在这个类里面实现红外扫码的基本功能。
     *
     * 我们不关心其内部实现
     */
    public static void create(Context context, AidcManager.CreatedCallback callback)
    
    /**
     * 用于创建红外扫码读取器对象
     * 不需要关心其内部实现。
     */
    public BarcodeReader createBarcodeReader()
    
    // 关闭红外扫码服务的连接。
    public void close()

}
  • BarcodeReader这个是红外扫码的读取器类。
// 红外扫码的读取器类
public final class BarcodeReader extends java.lang.Object {

    /**
     * 红外扫码事件的接口,包含红外扫码事件的回调事件
     * 红外扫码的成功响应回调方法
     * 红外扫码的失败响应回调方法
     */
    public static interface BarcodeReader.BarcodeListener extends java.util.EventListener {
    
        // 红外扫码的成功响应回调方法
        void onBarcodeEvent(BarcodeReadEvent event)
        
        // 红外扫码的失败响应回调方法
        void onFailureEvent(BarcodeFailureEvent event)
    }
    
    // 红外扫码读取器 绑定响应事件回调
    public void addBarcodeListener(BarcodeReader.BarcodeListener listener)
    
    // 红外扫码读取器 移除响应事件回调
    public void removeBarcodeListener(BarcodeReader.BarcodeListener listener)
    
    //  红外扫码读取器 开始响应扫码结果的事件
    public void claim()
    
    // 红外扫码读取器 停止响应扫码结果的事件
    public void release()
    
    // 销毁红外扫码读取器
    public void close()
}

创建uts插件

创建uts插件有两种方法:

  • 第一种,是手动创建uts插件的目录结构(不建议,容易有遗漏)。
  • 第二种如下图,依赖HBuilderX。右键uni_modules文件夹,点击新建uni_modules插件。选择第三项uts插件-api插件

uts插件目录

PDA厂商提供的sdk放置在utssdk/app-android/libs文件夹下就可以,uts插件支持以下三种类型文件:

  • jar
  • aar
  • so库

大致的目录结构如下

├─static                          // 静态资源
├─utssdk                          // 插件主要功能的代码目录
│        ├─app-android                 //Android平台目录
│        │        ├─assets                  //Android原生assets资源目录,可选
│        │        ├─libs                    //Android原生库目录,可选
│        │        ├─res                     //Android原生res资源目录,可选
│        │        └─index.uts               //需要我们实现的插件功能入口,必须
│        ├─app-ios                     //iOS平台目录 不在本文的讨论范围内。
│        ├─interface.uts               // 声明插件对外暴露的API,必需
│        └─unierror.uts                // 定义插件对外暴露的错误信息,可选
├─changelog.md                   // 说明文件
├─readme.md                      // 说明文件
└─package.json                    // 插件清单文件,必需

实现uts插件

utssdk/app-android/目录下新建barcode.uts文件,用于实现红外扫码的AidcManager.CreatedCallback接口,完成以下功能:

  • 完成aidcManager实例的绑定。
  • 创建红外扫码读取器实例。
  • 红外扫码读取器实例绑定响应事件。
  • 开启红外扫码读取器实例对红外扫码结果的响应
  • 销毁红外扫扫码管理实例、读取器实例

export class BarcodeManager implements CreatedCallback {
  /** 扫码模块的管理实例 */
  manager: AidcManager | null = null
  /** 扫码的读取器实例 */
  reader: BarcodeReader | null = null
  /** 扫码的监听器实例 */
  listener: BarcodeReader.BarcodeListener | null

  // 绑定红外扫码读取器的监听器实例
  constructor(listener: BarcodeReader.BarcodeListener) {
    this.listener = listener
  }

  /** 扫码模块的初始化完成后,绑定红外扫码监听事件, */
  override onCreated(aidcManager: AidcManager) {
  
    // 绑定红外扫码模块实例
    this.manager = aidcManager

    if (this.manager !== null) {
      // 创建读取器
      this.reader = this.manager?.createBarcodeReader()

      if (this.reader !== null) {
        try {
          // 扫码读取器的一些配置
          this.reader?.setProperty(BarcodeReader.PROPERTY_CODE_128_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_GS1_128_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_QR_CODE_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_CODE_39_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_DATAMATRIX_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_UPC_A_ENABLE, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_EAN_13_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_AZTEC_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_CODABAR_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_INTERLEAVED_25_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_PDF_417_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_CODE_39_MAXIMUM_LENGTH, 10)
          this.reader?.setProperty(BarcodeReader.PROPERTY_CENTER_DECODE, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_NOTIFICATION_BAD_READ_ENABLED, true)

          this.reader?.setProperty(BarcodeReader.PROPERTY_INTERLEAVED_25_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_INTERLEAVED_25_REDUNDANCY_MODE, 10)
          this.reader?.setProperty(BarcodeReader.PROPERTY_LINEAR_VOID_HANDLING, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_DATA_PROCESSOR_LAUNCH_BROWSER, false)

          // ocr设置
          const comreg: string =
            '[{"enabled":true,"key":"chanum","regexValue":"[A-Z a-z]{2}\\d{3}","type":"CUSTOMIZED"},' +
            '{"enabled":true,"key":"OCR_CONTENT_REGEX_IP_ADDRESS","type":"EMBEDDED"}]'
          this.reader?.setProperty(BarcodeReader.PROPERTY_OCR_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_OCR_EXCLUSIVE, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_OCR_CONTENT_REGEX_SEQUENCE, comreg)

          // 按键触发模式
          this.reader?.setProperty(
            BarcodeReader.PROPERTY_TRIGGER_CONTROL_MODE,
            BarcodeReader.TRIGGER_CONTROL_MODE_AUTO_CONTROL
          )
        } catch (e) {
          console.error(`${this} Failed to apply properties`)
        }

        // 注册响应事件
        this.reader?.addBarcodeListener(this.listener)
        // 读取器开启红外扫码结果响应
        this.reader?.claim();
      }
    }
  }

  // 提供红外扫码管理模块的注销方法
  destroyManager() {
    // 注销读取器实例
    if (this.reader != null) {
      // 停止读取器的响应
      this.reader?.release();
      // 注销监听事件
      this.reader?.removeBarcodeListener(this.listener)
      this.listener = null

      // 关闭扫码读取器
      this.reader?.close()
      this.reader = null
    }
    
    // 注销红外扫码管理模块
    if (this.manager != null) {
      // 关闭红外扫码模块连接
      this.manager?.close()
      this.manager = null
    }
  }
}

utssdk/app-android/目录下新建listener.uts文件,实现BarcodeReader.BarcodeListener接口,给读取器实例绑定不同的响应事件。


export class Listener implements BarcodeReader.BarcodeListener {
  onScanBarcode: (barcode: string) => void
  onScanFail: () => void

  // 绑定外部的红外扫码结果响应事件
  constructor(onScanBarcode: (barcode: string) => void, onScanFail: () => void) {
    this.onScanBarcode = onScanBarcode
    this.onScanFail = onScanFail
  }

  // 扫码成功回调事件方法
  override onBarcodeEvent(event: BarcodeReadEvent) {
    const barcode = event.getBarcodeData()
    this.onScanBarcode(barcode)
  }

  // 扫码失败的回调事件方法
  override onFailureEvent(event: BarcodeFailureEvent) {
    this.onScanFail()
  }
}

最后实现入口文件index.uts,这个文件给uniapp组件、页面提供了可调用的方法:

  • initSilenceScan:用于初始化红外扫码对象
  • destroySilenceScan:用于销毁红外扫码对象,释放内存
// 扫码模块实例
let barcodeManager: BarcodeManager | null = null

// 初始化红外扫码相关功能
@UTSJS.keepAlive
export function initSilenceScan ({ onScanBarcode, onScanFail }: InitSilenceScanOpt) {
  // 判断红外扫码模块是否存在,如果存在则不重复创建。
  if (barcodeManager?.manager == null) {
  
    // 创建红外扫码响应事件实例
    const listener = new Listener(onScanBarcode, onScanFail)
    
    // 创建红外扫码功能模块
    const barcodeManager = new BarcodeManager(listener)
    
    // 获取安卓app的上下文对象
    const context = UTSAndroid.getAppContext()
    
    // 调用AidcManager.create抽象方法,创建红外扫码管理实例
    AidcManager.create(context, barcodeManager)
  }
}

export function destroySilenceScan() {

  // 销毁红外扫码模块
  barcodeManager?.destroyManager()
}

封装红外扫码业务组件SilenceScan。后续需要使用红外扫码的页面组件,只需要引入SilenceScan组件,即可丝滑享受红外扫码功能。

<script setup lang="ts">
import * as SilenceScan from '@/uni_modules/silence-scan'
import { onHide, onShow } from '@dcloudio/uni-app'
import { onBeforeMount, onBeforeUnmount, onUnmounted } from 'vue'

/** 对外抛出的ScanCode事件 */
const emit = defineEmits<{ scanCode: [code: string] }>()

/** 扫码成功事件 */
const onSilenceScanCode = (code: string) => {
  emit('scanCode', code)
}

/** 扫码失败事件 */
const onScanFail = () => {
  uni.showToast({ icon: 'none', title: '请检查扫描的条码、二维码' })
}

// 组件所在页面显示,初始化扫码对象
onShow(() => {
  SilenceScan.initSilenceScan({ onScanBarcode: onSilenceScanCode, onScanFail })
})

// 当前组件所在页面隐藏,销毁扫码。
onHide(() => {
  SilenceScan.destroySilenceScan()
})

// 当前组件销毁,销毁扫码对象
onUnmounted(() => {
  SilenceScan.destroySilenceScan()
})
</script>

<template>
  <view />
</template>

<style scoped></style>

最后

文中如有错误或不严谨的地方,请给予指正,十分感谢。

代码地址

红外扫码uts插件 github

做中国人自己的视频编辑UI框架,WebCut正式开源

作者 否子戈
2025年12月4日 17:22

项目地址:github.com/tangshuang/…

朋友们晚上好啊,这段时间我在忙着完成最新的开源项目WebCut。这个项目是我这小半年来唯一的开源新项目,这对比我过去几年的一些开源事迹来说,真的是一段低产荒。不过这是正常的,没有任何人可以长时间的一直发布新项目,这种沉寂,正是因为我把时间和精力都投入在其他事情上,所以其实是好事。之所以要发起和开源这个项目,说起来还是有些背景,下面我会来聊一聊关于这个项目的一些背景,以及过程中在技术上的一些探索。

没有合适的视频编辑UI框架😭

过去半年,我连续发布了多款与视频相关的产品,这些产品或多或少都需要用户参与视频编辑操作,比如给视频添加字幕、对视频进行裁剪、给视频配音等等,这些在视频编辑器中常见的视频处理工具,在Web端其实需求非常巨大,特别是现在AI领域,制作各种个样的视频的需求非常多。而这些需求,可能并不需要在产品中载入一整个视频编辑器,而是只需要几个简单的部件来实现编辑功能。然而回望开源市场,能够支持这种编辑能力的项目少之又少,虽然有一些项目呈现了视频编辑的能力,然而,要么项目太大,一个完整的视频编辑器甩在开发者脸上,要么过于底层,没有UI界面。如果我只是想有一个视频预览,再有轨道和几个配置界面,就没法直接用这些项目。包括我自己在内,每次有与视频相关的功能,都要把之前在另外一个产品中实现的编辑能力移植到新产品中,而且要调的细节也很多。正是这种求而不得的现状,促使我打算自己写一个视频编辑器项目。

初始想法💡:拼积木

我可不是从0开始的,因为我已经开发过很多次视频编辑相关的功能了。我还在Videa项目中完整实现了一个视频编辑器。因此,我的想法是把我之前做过的功能,整理一遍,就可以得到想要的组件或逻辑。有了这个工具包之后,我只需要在将来的新产品中复用这些代码即可。于是我建立了一个独立的npm包,来把所有功能集中放在一起。随着持续的迭代,我发现其实这里面是有规律的。

视频编辑器我们都用过,像剪映一样,有各种功能,细节也很多。但是,当我们把视频编辑的功能放到某个具体的产品中时,我们是不可能直接把整个编辑器给用户的。实际上,我们最终呈现的产品形态,基本上都是剪映的子集,而且是很小很小的子集,可能只是整个剪映1%的功能,最终给到用户操作可能只是非常简单的一次性操作,而页面也很轻量,属于用户即用即走,用完就关再也不会来第二次的那种。正是这种看似功能点很小,但实际上需要为它单独定制,技术上的成本可以用巨大来描述的场景,让我觉得这是一个需要认真对待的点。

我的计划是采用组件化的思想,把一个视频编辑器拆成一个一个的组件,把一个完整的剪映编辑器,拆成一个按钮一个按钮的积木。当我们面对产品需求时,就从这些积木中挑选,然后组合成产品经理所描述的功能,同时,具体这些积木怎么布局,则根据设计稿调整位置,还可以用CSS来覆盖组件内部的样式,达到与设计稿媲美的效果。

上面是我用AI做的一张示意图,大概就是这个意思,把一个编辑器拆的细碎,然后要什么功能就把对应的组件拿来拼凑一下。比如要对视频进行静音裁剪,就只要把预览区和轨道区拿出来,然后自己再增加一些能力上去。这样,开发者在面对各种各样的需求时,就能快速搭建起界面效果,而不需要从头实现界面和视频处理能力。

通过不同的组合方式,配合开发者自己的布局,就可以创建符合需求的视频编辑界面。

没那么容易😥:外简内繁的接口艺术

虽然想法很容易有,但是要做成成品,并且发布为可用的库可没那么轻松。要知道视频编辑器的功能非常多,多到我们无法用简单的文字描述它们。那么,如何才能站在开发者的角度,让这件事变得简单呢?

开发库永远面临着一个矛盾:灵活性和规范性之间的矛盾。灵活性就是暴露又多又细的接口,让开发者可以用不同参数,玩出花活,例如知名项目echarts早期就是这种。这种对于刚上手的开发者而言,无言是一场灾难,他们甚至不知道从哪里开始,但是一旦完全掌握,就相当于拥有了一个武器库,可以为所欲为。而规范性则是强制性规则比较多,只能暴露少量接口,避免开发者破坏这种规范,例如很多前端UI组件库就是这样。这两者之间的矛盾,是每一个库或框架开发者最难平衡的。

在视频编辑器上,我认为有些东西是固定的,几乎每一个需求都会用到,例如视频预览播放器、控制按钮等,但是有些功能是低频功能,例如媒体库、字幕编辑等。因此,对于高频和低频的功能,我用两种态度去处理。

高频的功能,拆的很细,拆到一个按钮为止,例如视频播放器,看上去已经很单一了,但是,我还要把预览屏幕和播放按钮拆开,屏幕和按钮如何布局就可以随意去处理。类似的还有导出按钮、分割片段按钮等等。把这些工具拆分为一个一个最小的单元,由开发者自由布局,这样就可以很好的去适配产品需求。

对于低频功能,则直接导出为大组件,这样就可以在一个组件内完成复杂的逻辑,减少开发这些组件时需要跨组件控制状态的心智成本。比如媒体库,里面包含了媒体的管理、上传、本地化等等,这些逻辑还是很复杂的,如果还是按照细拆思路,那么实现起来就烦得要死。因此,这类工具我都是只暴露为一个大的组件。

同时,为了方便开发者在某些情况下快捷接入,我会设计一些自己看到的不错的编辑器UI,然后用上诉的工具把它们搭出来,这样,开发者如果在产品需求中发现仓库里已经有功能一致的组件,就不需要自己去组合,直接使用对应组件即可。

以模仿剪映为例,开发者只需要接入WebCutEditor这个组件即可:

<script setup>
import { WebCutEditor } from 'webcut';
import 'webcut/esm/style.css';
</script>

<template>
    <WebCutEditor />
</template>

这样就可以得到一个界面接近于剪映的视频编辑器。

数据驱动,视频编辑的DSL

经过我的研究,发现对于单纯的视频编辑而言,编辑器其实只需要两份数据,就可以解决大部分场景下的需求。一份是编辑的素材数据,一份是视频的配置数据。

素材数据

它包含素材文件本身的信息、素材的组织信息、素材的属性信息。

文件信息

我通过opfs将文件存在本地,并且在indexedDB中存储每一个文件的关联信息。包含文件的类型、名称、大小等。在一个域名下,每一个文件的实体(也就是File对象)只需要一份,通过file-md5值作为索引存在opfs中。而一个文件可能会在多处被使用,indexedDB中则是记录这些信息,多个关联信息可同时链接到同一个File。另外,基于indexedDB的特性,还可以实现筛选等能力。

素材组织信息

主要是指当把素材放在视频时间轨道中时,所需要的数据结构。包含轨道列表、素材所对应的文件、素材对应时间点、播放时的一些属性等等信息。这些信息综合起来,我们就知道,在视频的某一个时刻,应该播放什么内容。

素材属性信息

在播放中,素材以什么方式呈现,如文本的样式、视频音频的播放速度、动画、转场等。

配置数据

主要指视频本身的信息,在导出时这些配置可以直接体现出来,例如视频的分辨率、比例、速率等,视频是否要添加某些特殊的内容,例如水印等。

基于素材数据和配置数据,我们基本上可以完整的知道当前这个视频的编辑状态。通过数据来恢复当前的编辑状态,变得可行,这可以抵消用户在浏览器中经常执行“刷新”操作带来的状态丢失。同时,这份数据也可以备份到云端,实现多端的同步(不过需要同时同步File,速度肯定会受影响)。而且由于数据本身是纯序列化的,因此,可以交给AI来进行处理,例如让AI调整一些时间、样式等可基于纯序列化数据完成的功能。这就让我们的编辑器变得有更多的玩法。

发布上线🌏

经过几天的工作,我终于把代码整理完整,经过调试之后,基本可用了,便迫不及待的准备与大家分享。现在,你可以使用这个项目了。

由于底层是由Vue3作为驱动的,因此,在Vue中使用有非常大的优势,具体如下:

npm i webcut

先安装,安装之后,你就可以在Vue中如下使用。

<script setup>
import { WebCutEditor } from 'webcut';
import 'webcut/esm/style.css';
</script>

<template>
    <WebCutEditor />
</template>

或者如果你的项目支持typescript,你可以直接从源码进行引入,这样就不必主动引入css:

<script setup lang="ts">
import { WebCutEditor } from 'webcut/src';
</script>

如果是非Vue的项目,则需要引用webcomponents的构建产物:

import 'webcut/webcomponents';
import 'webcut/webcomponents/style.css';

export default function Some() {
    return <webcut-editor></webcut-editor>;
}

如果是直接在HTML中使用,可以直接引入webcomponents/bundle,这样包含了Vue等依赖,就不需要另外构建。

<script src="https://unpkg.com/webcut/webcomponents/bundle/index.js"></script>
<link rel="stylesheet" href="https://unpkg.com/webcut/webcomponents/bundle/style.css" />

<webcut-editor></webcut-editor>

如果是想自己布局,则需要引入各个很小的组件来自己布局。在这种情况下,你必须引入WebCutProvider组件,将所有的子组件包含在内。

<webcut-provider>
    <webcut-player></webcut-player>
    <webcut-export-button></webcut-export-button>
</webcut-provider>

未来展望

当前,WebCut还是处于很初级的阶段,实现了最核心的能力,我的目标是能够为开发者们提供一切需要的组件,并且不需要复杂的脚本处理就可以获得视频编辑的全部功能。还有很多功能没有实现,在计划中:

  • 历史记录功能,包含撤销和重做功能
  • 内置样式的字体
  • 花字,比内置样式更高级的文本
  • 轨道里的素材吸附能力
  • 视频的轨道分离(音频分离)
  • 音视频的音量调节
  • 单段素材的下载导出
  • 整个视频导出时可以进行分辨率、码率、速率、编码、格式的选择,支持只导出音频

以上这些都是编辑的基本功能。还有一些是从视频编辑定制化的角度思考的能力:

  • 动画(帧)支持
  • 转场过渡效果支持
  • 扩展功能模块,这部分可能会做成收费的,下载模块后,通过一个接口安装一下,就可以支持某些扩展功能
  • AI Agent能力,通过对话来执行视频编辑,降低视频编辑的门槛
  • 视频模板,把一些流行的视频效果片段做成模板,在视频中直接插入后,与现有视频融合为模板中的效果
  • 基于AI来进行短剧创作的能力

要实现这些能力,需要大量的投入,虽然现在AI编程非常火热,但是真正能够完美实现的,其实还比较少,因此,这些工作都需要小伙伴们的支持,如果你对这个项目感兴趣,可以通过DeepWiki来了解项目代码的底层结构,并fork项目后,向我们提PR,让我们一起共建一个属于我们自己的视频编辑UI框架。

最后,你的支持是我前进的动力,动动你的小手,到我们的github上给个start,让我们知道你对此感兴趣。

深入理解 Vue keep-alive:缓存本质、触发条件与生命周期对比

2025年12月4日 17:14

一、先明确核心结论

keep-alive 是 Vue 内置的抽象组件(不渲染真实 DOM),它的核心作用是缓存被包裹的组件实例,缓存的关键数据结构如下:

  • 缓存容器:keep-alive 实例上的 this.cache(一个对象,key 是组件的「缓存标识」,value 是组件实例);
  • 辅助记录:this.keys(一个数组,存储缓存组件的 key,用于实现 max 缓存数量限制);
  • 挂载关系:被缓存的组件实例 → 作为 this.cache 对象的属性值 → 挂在 keep-alive 组件实例上,而非被缓存组件自己的实例上。

二、keep-alive 挂载缓存的完整过程(分步骤拆解)

以 Vue 2 为例(Vue 3 逻辑一致,仅源码实现细节略有差异),核心流程如下:

步骤 1:keep-alive 初始化,创建缓存容器

keep-alive 组件初始化时,会在自身实例上创建两个核心属性,用于存储缓存:

// keep-alive 组件的初始化逻辑(简化版)
export default {
  name: 'keep-alive',
  abstract: true, // 抽象组件,不参与DOM渲染
  props: {
    include: [String, RegExp, Array], // 需缓存的组件
    exclude: [String, RegExp, Array], // 排除缓存的组件
    max: [String, Number] // 最大缓存数量
  },
  created() {
    this.cache = Object.create(null); // 缓存容器:{ key: 组件实例 }
    this.keys = []; // 缓存key列表:[key1, key2...]
  },
  // ...其他生命周期
}
  • this.cache :空对象,后续用来存「缓存标识 → 组件实例」的映射;
  • this.keys :空数组,记录缓存 key 的顺序,用于 LRU 淘汰(超出 max 时删除最久未使用的缓存)。

步骤 2:组件首次渲染,判断是否缓存

当 keep-alive 包裹的组件首次渲染时,keep-alive 的 render 函数会执行核心逻辑:

  1. 获取被包裹组件的**「缓存标识」**(key):
    • 默认 key:组件名 + 组件实例的uid(避免同组件不同实例冲突);
    • 自定义 key:可通过 key 属性指定(如 <keep-alive><component :is="comp" :key="compKey" /></keep-alive>)。
  1. 判断是否符合缓存规则(include / exclude):
    • 若符合:将组件实例存入 this.cache,并把 key 加入 this.keys
    • 若不符合:不缓存,直接渲染组件(和普通组件一样)。

举个例子:

<keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
</keep-alive>

步骤 3:缓存组件实例,挂载到 keep-alive 上

核心逻辑简化如下:

// keep-alive 的 render 函数核心逻辑(简化版)
render() {
  const slot = this.$slots.default;
  const vnode = getFirstComponentChild(slot); // 获取被包裹的第一个组件vnode
  const componentOptions = vnode && vnode.componentOptions;
  
  if (componentOptions) {
    // 1. 生成缓存key(核心:唯一标识组件实例)
    const key = this.getCacheKey(vnode);
    const { cache, keys } = this;

    // 2. 判断是否需要缓存(符合include,不符合exclude)
    if (this.shouldCache(componentOptions)) {
      // 3. 若缓存中已有该组件实例,直接复用
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance;
        // 更新key顺序(LRU:把当前key移到最后,标记为最近使用)
        remove(keys, key);
        keys.push(key);
      } else {
        // 4. 首次渲染:将组件vnode(包含实例)存入缓存
        cache[key] = vnode;
        keys.push(key);
        // 5. 超出max时,删除最久未使用的缓存
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
      }
      // 标记组件为“被缓存”,避免重复初始化
      vnode.data.keepAlive = true;
    }
  }
  return vnode;
}

关键挂载动作cache[key] = vnode → 组件的 vnode(包含 componentInstance 即组件实例)被作为 cache 对象的属性值,挂载到 keep-alive 实例的 this.cache 上。

步骤 4:组件再次渲染,复用缓存实例

当被缓存的组件需要再次渲染时(比如路由切换后返回):

  1. keep-alive this.cache 中根据 key 取出对应的组件实例;
  2. 将缓存的实例赋值给新的 vnode 的 componentInstance
  3. 直接复用该实例渲染,不再执行组件的 created / mounted 等生命周期(而是触发 activated 钩子)。

三、关键细节:为什么缓存不挂在被缓存组件自己的实例上?

  1. 逻辑合理性keep-alive 是「缓存管理者」,应该由管理者统一存储和管理所有被缓存的组件,而非让组件自己存储;
  2. 避免内存泄漏:若缓存挂在被缓存组件实例上,组件实例本身无法被销毁(因为缓存引用了它),而 keep-alive 统一管理可通过 maxexclude 主动清理缓存;
  3. 多实例隔离:多个 keep-alive 组件的缓存是隔离的(比如页面 A 和页面 B 各有一个 keep-alive),每个 keep-alive 实例有自己的 cache,不会互相干扰。

总结:

一、先肯定你的正确认知

  1. ✅ keep-alive 是 Vue 官方内置的抽象组件(不渲染真实 DOM),被包裹的组件实例 / 状态会挂载在keep-alive 实例的 cache 对象上(而非组件自身);
  2. ✅ 缓存的核心内容是组件的 VNode(包含组件实例、DOM 节点描述、数据状态如 data/props/ 输入框值等);
  3. ✅ 组件的缓存触发和「组件失活」强相关(比如路由跳转导致组件被隐藏)。

二、需要修正 / 补充的关键细节

细节 1:“组件经过 keep-alive 时被缓存” → 不是 “经过”,而是 “组件被 keep-alive 包裹且失活时才缓存”

keep-alive 不会 “主动拦截” 组件,而是当被包裹的组件从「激活状态」变为「失活状态」时,才会将其 VNode 存入缓存(而非加载时就缓存)。

  • 激活状态:组件在页面中可见(比如当前路由匹配的 Home 组件);
  • 失活状态:组件被隐藏(比如跳转到 List 路由,Home 组件被 router-view 卸载)。

简单说:keep-alive 是 “挽留” 即将被销毁的组件 —— 默认情况下,组件失活会被销毁,而 keep-alive会把它存入缓存,避免销毁。

细节 2:“只有页面跳转才缓存” → 不完全对,「组件失活」都触发缓存(不止路由跳转)

路由跳转是最常见的 “组件失活” 场景,但不是唯一场景:

  • 场景 1(路由跳转):/home → /list,Home 组件失活 → 被缓存;
  • 场景 2(条件渲染):<keep-alive><component :is="compName" /></keep-alive>,当compName 从 Home 改为 List 时,Home 失活 → 被缓存;
  • 场景 3(v-if 隐藏):<keep-alive><div v-if="show">Home组件</div></keep-alive>,当show 从 true 改为 false 时,Home 失活 → 被缓存。

核心:只要 keep-alive 包裹的组件从 “渲染在页面上” 变为 “不渲染”,且符合 include/exclude 规则,就会被缓存。

细节 3:“只加载页面不跳转,不会缓存” → 准确说:“组件未失活,缓存容器中已有该组件的 VNode,但未触发「缓存复用」”

即使不跳转,只要组件被 keep-alive 包裹并完成首次渲染:

  1. keep-alive cache 已经存入了该组件的 VNode (可以通过前面的代码查到);
  2. 只是因为组件未失活,所以不会触发 activated 钩子,也不会体现出 “缓存效果”(比如输入框输入内容,不跳转的话,内容本来就在,看不出缓存);
  3. 只有当组件失活后再次激活(比如跳转回来),才会从缓存中复用 VNode,此时能看到 “状态保留”(比如输入框内容还在)—— 这才是缓存的 “可见效果”。

举个直观例子:

  • 步骤 1:访问 /home,Home 组件渲染(激活),keep-alive.cache 中已有 Home 的 VNode(但未体现缓存);
  • 步骤 2:在 Home 输入框输入 “123”,跳转到 /list(Home 失活),keep-alive 保留 Home 的 VNode(包含输入框的 “123”);
  • 步骤 3:跳回 /home(Home 激活),keep-alive 复用缓存的 VNode,输入框仍显示 “123”—— 这就是缓存的效果。

如果只停留在步骤 1(不跳转),虽然缓存容器中有 Home 的 VNode,但因为没有 “失活→激活” 的过程,所以看不到缓存的效果,并非 “没有缓存”。

细节 4:缓存的 VNode 包含什么?→ 不止节点 / 属性,还有组件的「完整实例状态」

VNode 是组件的 “虚拟描述”,缓存 VNode 本质是缓存组件实例:

  • 包含 DOM 结构描述(比如 <div class="home">);
  • 包含组件的响应式数据data/computed/props);
  • 包含组件的 DOM 状态(输入框值、滚动条位置、复选框勾选状态);
  • 包含组件的生命周期状态不会再执行 created / mounted ,而是执行 activated)。

三、总结:精准理解 keep-alive 的缓存逻辑

  1. 挂载关系keep-alive 是 “缓存管理者”,被包裹组件的 VNode(含实例 / 状态)挂载在 keep-alive实例的 cache 对象上;
  2. 缓存触发:组件被 keep-alive 包裹 + 组件从「激活→失活」(路由跳转 / 条件隐藏等)→ 存入缓存;
  3. 缓存复用:组件从「失活→激活」→ 从 cache 中取出 VNode 复用(不重新创建实例,保留状态);
  4. 可见效果:只有 “失活→激活” 的过程,才能体现缓存(状态保留),仅加载组件不跳转,缓存存在但无 “可见效果”。

简单记: keep-alive 的核心是 “保活”—— 不让失活的组件销毁,而是存入缓存,下次激活时直接复用,避免重复创建 / 销毁,同时保留组件状态。


keep-alive组件加载生命周期对比

阶段 Vue 2 生命周期 Vue 3 组合式 API 核心特点
首次加载 beforeCreate → created → beforeMount → mounted → activated setup → onBeforeMount → onMounted → onActivated 完整生命周期,最后触发激活钩子
失活缓存 deactivated onDeactivated 仅触发失活钩子,不销毁组件
二次加载 activated onActivated 仅触发激活钩子,跳过创建 / 挂载
缓存销毁 deactivated → beforeDestroy → destroyed onDeactivated → onBeforeUnmount → onUnmounted 先失活,再销毁

深入理解 JavaScript 原型链与继承机制:从 instanceof 到多种继承模式

作者 Yira
2025年12月4日 17:06

深入理解 JavaScript 原型链与继承机制:从 instanceof 到多种继承模式

在 JavaScript 的面向对象编程(OOP)体系中,原型(prototype)和原型链是核心机制。不同于 Java、C++ 等基于类的语言,JavaScript 采用基于原型的继承模型。理解这一机制,不仅能正确使用 instanceof 运算符,还能灵活实现各种继承方式。本文将从原型链本质出发,手写 instanceof,并系统梳理三种经典继承模式。


一、原型与原型链:JavaScript OOP 的基石

每个 JavaScript 对象都有一个内部属性 [[Prototype]](可通过 __proto__ 访问),它指向另一个对象——即该对象的“原型”。当访问一个对象的属性时,若自身没有,引擎会沿着原型链向上查找,直到 null

const arr = [];
console.log(arr.__proto__ === Array.prototype); // true
console.log(arr.__proto__.__proto__ === Object.prototype); // true
console.log(arr.__proto__.__proto__.__proto__ === null); // true

这条从实例 → 构造函数的 prototype → 更上层原型 → null 的链条,就是原型链

constructor 属性的作用

每个原型对象默认有一个 constructor 属性,指回其构造函数:

arr.constructor === Array; // true
Array.prototype.constructor === Array; // true

二、手写 instanceof:理解其本质

A instanceof B 的语义是:判断构造函数 B 的 prototype 是否出现在 A 的原型链上

我们可以手动实现:

function isInstanceOf(left, right) {
  let proto = left.__proto__;
  while (proto) {
    if (proto === right.prototype) {
      return true;
    }
    proto = proto.__proto__; // 向上遍历原型链
  }
  return false;
}

验证示例

function Animal() {}
function Dog() {}
Dog.prototype = new Animal();

const dog = new Dog();
console.log(isInstanceOf(dog, Dog));     // true
console.log(isInstanceOf(dog, Animal));  // true(因为 Dog.prototype = new Animal())
console.log(isInstanceOf(dog, Object));  // true(所有对象最终继承自 Object)

✅ 这说明 instanceof基于血缘关系(原型链) 的判断,而非简单的类型匹配。


三、JavaScript 继承的三种经典模式

继承的本质是:子类能访问父类的属性和方法。由于 JS 没有类(ES6 之前),我们通过函数和原型模拟继承。


模式一:构造函数绑定继承(借用构造函数)

通过 callapply 在子类构造函数中调用父类构造函数,实现属性继承

function Animal() {
  this.species = '动物';
}

function Cat(name, color) {
  Animal.call(this); // 借用父类构造函数
  this.name = name;
  this.color = color;
}

const cat = new Cat('小黑', '黑色');
console.log(cat.species); // '动物'
优点:
  • 每个实例拥有独立的父类属性(避免引用共享问题)
  • 可以向父类构造函数传参
缺点:
  • 无法继承父类原型上的方法
  • 父类构造函数每次都会执行,浪费性能(若父类有复杂初始化)

❌ 此模式仅实现“属性继承”,未实现“方法继承”。


模式二:原型链继承(prototype 模式)

将父类的实例赋值给子类的 prototype,使子类原型链指向父类实例。

function Animal() {
  this.species = '动物';
}
Animal.prototype.say = function() { console.log('I am an animal'); };

function Cat(name, color) {
  this.name = name;
  this.color = color;
}

// 关键:子类原型 = 父类实例
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat; // 修复 constructor 指向

const cat = new Cat('小黑', '黑色');
console.log(cat.species); // '动物'
cat.say(); // 'I am an animal'
优点:
  • 子类可继承父类属性 + 原型方法
缺点:
  • 所有子类实例共享父类实例的属性(若属性是引用类型,会互相影响)
  • 无法向父类构造函数传参(new Animal() 无参数)

✅ 这是真正意义上的“原型链继承”,但存在共享状态风险。


模式三:组合继承(推荐)

结合前两种模式:构造函数继承属性 + 原型链继承方法

function Animal(name) {
  this.species = '动物';
  this.name = name;
}

Animal.prototype.say = function() {
  console.log(`I am ${this.name}`);
};

function Cat(name, color) {
  Animal.call(this, name); // 继承属性(可传参,独立副本)
  this.color = color;
}

// 继承方法
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

const cat1 = new Cat('咪咪', '白色');
const cat2 = new Cat('小黑', '黑色');
优点:
  • 属性独立,方法共享
  • 支持传参
  • 符合 OOP 直觉
缺点:
  • 父类构造函数被调用了两次(一次在 new Animal(),一次在 Animal.call(this)

💡 尽管有小瑕疵,这是 ES5 时代最常用的继承模式。


模式四:寄生组合继承(优化版)

为避免组合继承中父类构造函数重复调用,引入“空中介对象”:

function inheritPrototype(Child, Parent) {
  const prototype = Object.create(Parent.prototype); // 创建空对象,原型指向 Parent.prototype
  prototype.constructor = Child;
  Child.prototype = prototype;
}

function Animal(name) {
  this.name = name;
}
Animal.prototype.say = function() { console.log(this.name); };

function Cat(name, color) {
  Animal.call(this, name);
  this.color = color;
}

inheritPrototype(Cat, Animal); // 关键优化

const cat = new Cat('小花', '橘色');
cat.say(); // '小花'
优点:
  • 只调用一次父类构造函数
  • 原型链完整,instanceof 和 isPrototypeOf 正常工作

✅ 这是 ES5 中最理想的继承方式,也是 class extends 的底层原理之一。


四、错误示范:直接继承 prototype

Cat.prototype = Animal.prototype; // 危险!

这会导致父子类共享同一个原型对象,修改子类原型会影响父类:

Cat.prototype.meow = function() {};
console.log(Animal.prototype.meow); // function() {} ← 父类被污染!

❌ 绝对不要这样做!


五、为什么 instanceof 在大型项目中很重要?

在多人协作或复杂框架中,对象来源多样,类型模糊。instanceof 能可靠判断对象“血缘”:

if (obj instanceof Array) { ... }
if (element instanceof HTMLElement) { ... }

相比 typeof(对数组、null 返回 "object")或 constructor(易被覆盖),instanceof 基于不可篡改的原型链,更安全可靠。


六、总结

继承模式 属性继承 方法继承 传参 属性独立 推荐度
构造函数绑定 ⭐⭐
原型链继承
组合继承 ⭐⭐⭐
寄生组合继承 ⭐⭐⭐⭐

掌握原型链和 instanceof 的本质,是理解 JavaScript OOP 的关键。虽然 ES6 引入了 class 语法糖,但其底层仍是原型机制。深入这些原理,才能写出健壮、可维护的代码。

你可能不知道 react 组件中受控和非受控的秘密!

作者 孟祥_成都
2025年12月4日 17:03

前言

在 React.js 中,涉及到处理表单的所有组件,你都不得不先弄清楚一个问题,什么是表单组件的受控和非受控模式!

以下是我的 headless 组件库新增的 Checkbox 组件(这个组件主要参考了字节的 arco-design、阿里的 ant-design 和国外的 shadcn/ui 源码),网站也有详细的组件教程,欢迎点赞交流。

关于受控和非受控的应用具体案例,可以看我的 headless 组件库的 checkbox

其中两个核心概念是 受控组件(Controlled Components) 和 非受控组件(Uncontrolled Components)。它们决定了表单数据在 React 组件中是如何被管理的。简单来说:

  • 受控组件 完全依赖 React 的 state 来存储和更新表单数据。

  • 非受控组件 则依赖原生 DOM 自身来管理表单数据。

本文将带你理解这两类组件的区别、实现方式,并提供在实际应用中使用它们的最佳实践。

什么是受控组件?

受控组件指由 React state 完全管理 的表单元素(如 input、textarea、select 等)。 也就是说,该表单元素的值完全由 React state 决定,React 成为表单数据的 唯一数据源。

通过使用 state 控制表单,你可以:

  • 更精确地控制用户输入行为

  • 轻松执行校验逻辑

  • 格式化输入等等

我们举个例子:

import React, { useState } from 'react';

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

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    alert('A name was submitted: ' + value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input type="text" value={value} onChange={handleChange} />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}

export default ControlledComponent;

在这个示例中:

  • value 用来保存输入框的当前值。

  • handleChange 在用户输入时更新该 state。

  • handleSubmit 在提交时使用 state 中的值。

我们小小总结一下:受控组件的核心在于:表单数据完全由 React state 管理。

使用受控组件的优势

一般情况下,复杂g场景都会使用受控模式,因为两个很明显的好处:

  • 更容易与复杂 UI 库集成
  • 更容易实现表单校验

与常见组件库集成

国内主流组件库的表单,基本都是来自于 ant-design 的 Form 组件逻辑, From 组件说白了,就是一个中介者模式,也就是说,Form 组件内部维护了一个收集表单数据的 store,当表单数据修改的时候, From 中的 store 会同步修改,并且刷新视图。

所以这里我们必须要知道什么时候数据被修改了,这就是受控组件的优势,onChange 事件触发,就意味着数据很可能被修改了

更容易实现表单校验

正因为能够显式的收集表单数据的变化(第三方 UI 库),才能做到在提交表单的时候,我们能够有机会来统一校验值是否符合业务要求。

或者在填写表单的时候,就显示错误信息。

举例:

import React, { useState } from 'react';

function ValidatedForm() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const handleChange = (event) => {
    const value = event.target.value;
    setEmail(value);

    if (!value.includes('@')) {
      setError('Invalid email address');
    } else {
      setError('');
    }
  };

  return (
    <div>
      <input type="email" value={email} onChange={handleChange} />
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

上述例子会在用户输入的每一步进行校验,并提供即时反馈。

最后我们可以再来看两个封装一个受控组件的案例,体会其用法:

文本输入框

import React, { useState } from 'react';

function TextInput() {
  const [text, setText] = useState('');

  const handleChange = (event) => {
    setText(event.target.value);
  };

  return (
    <div>
      <label>
        Text:
        <input type="text" value={text} onChange={handleChange} />
      </label>
      <p>Entered Text: {text}</p>
    </div>
  );
}

export default TextInput;

复选框(Checkbox)

import React, { useState } from 'react';

function Checkbox() {
  const [isChecked, setIsChecked] = useState(false);

  const handleChange = (event) => {
    setIsChecked(event.target.checked);
  };

  return (
    <div>
      <label>
        Accept Terms:
        <input type="checkbox" checked={isChecked} onChange={handleChange} />
      </label>
      <p>Checked: {isChecked ? 'Yes' : 'No'}</p>
    </div>
  );
}

export default Checkbox;

什么是非受控组件

非受控组件的状态由 DOM 自己管理,而不是 React state。 这种方式通常依赖 ref 来读取 DOM 中的值。我们来举个例子:

import React, { Component } from 'react';

class UncontrolledComponent extends Component {
  constructor(props) {
    super(props);
    this.inputRef = React.createRef();
  }

  handleSubmit = () => {
    console.log(this.inputRef.current.value);
  }

  render() {
    return (
      <div>
        <input 
          type="text"
          ref={this.inputRef}
        />
        <button onClick={this.handleSubmit}>Submit</button>
      </div>
    );
  }
}

从上面我们可以看到非受控组件的特点:

  1. 内部管理状态

表单的状态存储在 DOM 中,而非 React state。

  1. 直接访问 DOM

通过 ref.current.value 获取值。

  1. 更简单

适用于无需实时校验的场景

什么时候适合非受控组件

简单单表单

适用情况包括:

  • 表单结构简单,字段少

  • 不需要复杂校验或联动逻辑

  • 不希望 state 频繁更新导致 re-render

注意

这里特别注意一个优点,就是不希望 state 频繁更新导致 re-render,这让非受控组件的性能非常高。

国外流行的 React-hook-form 的性能高的原因,就是主要以非受控为主。

其实 ant-design 内部为了避免 setState 每次全量刷新表单造成性能问题,主要的方式是采用了发布订阅模式,把所有表单都订阅到了 store 里,当一个表单发生变化时,只刷新这个表单的 state,从而减少了性能损耗。

组件库如何合并受控和非受控状态

我们的组件库使用了 useMergeValue hook, 它可以用来合并受控和非受控模式.

这个 hook 大家可以复制下来,在写表单组件的时候,非常好用(在各种 ui 库中广泛存在)。首先我们先看一下简单用法:

假设我们在处理一个 radio 组件,radio 组件默认是不被选中的,所以 useMergeValue 的默认值,也就是第一个参数是 false, 然后 value 表示是否是受控模式,如果是,外界就传了 value,代表外界接管了 radio 的 state 变化。当然非受控模式一般也支持传一个 defaultValue,所以 defaultValue 代表了外界采用的是非受控模式:

  const [checked, setChecked] = useMergeValue(false, {
    value: props.checked,
    defaultValue: props.defaultChecked,
  });

那么我们合并受控和非受控的思路是什么呢?

完整代码如下,先别急,后面会有详细解释:

'use client';

import React, { useState, useEffect, useRef } from 'react';
import { isUndefined } from '../utils';
import { usePrevious } from './use-previous';

export function useMergeValue<T>(
  defaultStateValue: T,
  props?: {
    defaultValue?: T;
    value?: T;
  },
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
  const { defaultValue, value } = props || {};
  const firstRenderRef = useRef(true);
  const prevPropsValue = usePrevious(props?.value);

  const [stateValue, setStateValue] = useState<T>(
    !isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
  );

  // 受控转为非受控的时候,需要做转换处理
  useEffect(() => {
    if (firstRenderRef.current) {
      firstRenderRef.current = false;
      return;
    }
    if (value === undefined && prevPropsValue !== value) {
      setStateValue(value);
    }
  }, [value]);

  const mergedValue = isUndefined(value) ? stateValue : value;

  return [mergedValue, setStateValue, stateValue];
}

我们逐条拆解:

1. 初始化:判断当前是受控还是非受控

const [stateValue, setStateValue] = useState(
  value ?? defaultValue ?? defaultStateValue
);

顺序解释:

  1. 如果用户传了 value → 是受控组件
  2. 否则如果传了 defaultValue → 是非受控组件
  3. 否则 → 使用组件内部默认值 defaultStateValue

👉 这一步决定了初始模式是 Controlled 还是 Uncontrolled。

2. 运行时:保持跟随受控 value 的变化

const mergedValue = isUndefined(value) ? stateValue : value;

意思是:

  • 如果外面传了 value → 受控,永远以外界 value 为准
  • 如果外界没传 value → 非受控,使用内部 state

👉 组件内部不需要关心模式切换,只看 mergedValue 就够了。

3. 特殊处理:当“受控 → 非受控”时,需要同步内部 state

这是最难理解的部分,也是很多 UI 库都踩过的坑:

useEffect(() => {
  if (value === undefined && prevPropsValue !== value) {
    setStateValue(value);
  }
}, [value]);

说明:

  • 如果上一次是受控(有 value)

  • 现在变成非受控(value = undefined)

  • 那么内部 state 应该更新为 value(undefined)

    • 这等价于“清空或重置内部状态”

也就是切换受控和非受控的状态

例如:

<Checkbox checked={true} />   // 受控,永远选中
// 某个时刻用户把 checked 用 undefined 覆盖掉,比如执行了 reset
<Checkbox checked={undefined} />  // 变成非受控

如果不特殊处理,组件内部 state 还是旧的 true,就会产生 UI 错乱。

欢迎加入交流群

欢迎加入我的技术交流群,一起进步!

❌
❌