MoreRSS

site iconZhulin | 竹林里有冰修改

大三学生,技术博主,Fedora与Arch用户,Hexo撰稿人。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Zhulin | 竹林里有冰的 RSS 预览

2025 年,如何为 web 页面上展示的视频选择合适的压缩算法?

2025-06-02 20:59:10

事情的起因是需要在网页上展示一个时长约为 5 分钟的产品展示视频,拿到的 H264 编码的原文件有 60MB 大。高达 1646 Kbps 码率的视频文件通过网络传输,烧 cdn 流量费用不说,对于弱网环境下的用户体验也绝对不会好。因此必须在兼顾浏览器兼容性(太好了不用管 IE)的情况下,使用更现代的视频压缩算法进行压缩。

哪些压缩算法是目前的主流?

AV1

AV1 作为目前压缩效率最高的主流视频编码格式,在 2025 年的今天已经在 YouTube、Netflix、Bilibili 等视频网站全面铺开,毫无疑问是最值得优先考虑的选择;除了优异的压缩效率以外,AV1 免版税的优势使得各硬件厂商和浏览器内核开发者可以无所顾忌的将 AV1 编码的支持添加到自己的产品中。

可惜的是,Safari 并没有对 AV1 编码添加软解支持,只有在搭载 Apple M3 及后续生产的 Mac 和 iPhone 15 Pro 后续的机型才拥有硬解 AV1 的能力,在此之前生产的产品均无法使用 Safari 播放 AV1 编码的视频。我宣布 Safari 已经成为当代 IE,妥妥阻碍 Web 发展的绊脚石

Safari 在搭载 M2Pro 处理器的 Macbook Pro 上直接罢工了

除此之外,AV1 在压制视频时对设备的要求较高。在桌面端的消费级显卡中,目前只有 NVIDIA RTX 40 系、AMD Radeon RX 7000 系、IntelArc A380 及后续的产品拥有 AV1 的编码(encode)支持。而 Apple M 系列芯片至今没有任何一款产品拥有对 AV1 编码的硬件支持。这也导致我在我搭载 Intel Core i7-1165G7 的 ThinkPad 上使用 AV1 编码压缩视频时被迫使用 libaom-av1 进行软件编码,1080p 的视频压缩效率为 0.0025x 的速率,五分钟的视频要压一天多的时间。

H.265 / HEVC

作为 H.264 / AVC 的下一代继任者,H.265(又称 HEVC)的表现可谓是一手好牌打得稀巴烂。HEVC 由多个专利池(如 MPEG LA、HEVC Advance 和 Velos Media)管理,授权费用高且分散,昂贵的专利授权费用严重限制了它的普及速度和范围,尤其是在开放生态和网页端应用中。

Chromium / Firefox 不愿意当承担专利授权费的冤大头,拒绝在当今世界最大的两个开源浏览器内核中添加默认的 H.265 软解支持,目前主流浏览器普遍采用能硬解就硬解,硬解不了就摆烂的支持策略。Firefox on Linux 倒是另辟蹊径,不仅会尝试使用硬解,还会尝试使用用户在电脑上装的 ffmpeg 软解曲线救国。不过好在毕竟是 2013 年就确定的标准,现在大部分硬件厂商都集体被摁着脖子交了专利授权费以保证产品竞争力,Apple 更是 HEVC 的一等公民,保证了全系产品的 HEVC 解码能力。

目前未覆盖到的场景主要是 Chromium / Firefox on Windows 7 和 Chromium on Linux(包括 UOS、麒麟等一众国产 Linux 发行版)。

在 Linux 上不支持硬解 H.265 的 Chrome 直接把视频当作音频播放了

VP9

VP9 是 Google 于 2013 年推出的视频编码格式,作为 H.264 的继任者之一,在压缩效率上接近 H.265(HEVC),但最大的杀手锏是——彻底免专利费。这也让 VP9 成为 Google 对 HEVC 高额授权费用的掀桌式回应:你们慢慢吃,我开一桌免费的。

借着免专利的东风和 Google 自家产品矩阵的强推,VP9 在 YouTube、WebRTC 乃至 Chrome 浏览器中迅速站稳了脚跟。特别是在 AV1 普及之前,VP9 几乎是网页视频播放领域的事实标准,甚至逼得苹果这个“编解码俱乐部元老”在 macOS 11 Big Sur 和 iOS 14 上的 Safari 破天荒地加入了 VP9 支持(尽管 VP9 in webm 的支持稍晚一些,具体见上表)。

VP9 的软解码支持基本无死角:Chromium、Firefox、Edge 都原生支持,Safari 也一反常态地“从了”。硬件解码方面,从 Intel Skylake(第六代酷睿)开始,NVIDIA GTX 950 及以上、AMD Vega 和 RDNA 系显卡基本都具备完整的 VP9 解码能力——总之,只要不是博物馆级别的老电脑,就能愉快播放 VP9 视频。

当然,编码仍是 VP9 的短板。Google 官方提供的开源实现 libvpx,速度比不上 x264/x265 等老牌选手,在缺乏硬件加速的场景下,仍然属于“关机前压一宿”的那种体验。不过相比 AV1 的 libaom-av1,VP9 至少还能算“可用”,适合轻量化应用、实时通信或是对压制速度敏感的用户,而早在 7 代 Intel 的 Kaby Lake 系列产品就已经引入了 VP9 的硬件编码支持,各家硬件厂商对 VP9 硬件编码的支持发展到今天还算不错。

H.264 / AVC

作为“老将出马一个顶俩”的代表,H.264 / AVC 无疑是过去二十年视频编码领域的霸主。自 2003 年标准确定以来,凭借良好的压缩效率、广泛的硬件支持和相对合理的专利授权策略,H.264 迅速成为从网络视频、蓝光光盘到直播、监控乃至手机录像的默认选择。如果你打开一个视频网站的视频流、下载一个在线视频、剪辑一个 vlog,大概率都绕不开 H.264 的身影。

H.264 的最大优势在于——兼容性无敌。不夸张地说,只要是带屏幕的设备,就能播放 H.264 视频。软解?早在十几年前的浏览器和媒体播放器中就已普及;硬解?从 Intel Sandy Bridge、NVIDIA Fermi、AMD VLIW4 这些“史前”架构开始就已加入对 H.264 的完整支持——你甚至可以在树莓派、智能冰箱上流畅播放 H.264 视频。

虽然 H.264 同样存在和 H.265 相同的专利问题,但其授权策略明显更温和——MPEG LA 提供的专利池授权门槛较低,且不向免费网络视频收取费用,使得包括 Chromium、Firefox 在内的浏览器都默认集成了 H.264 的软解功能。Apple 和 Microsoft 更是早早将其作为视频编码和解码的第一公民,Safari 和 Edge 天生支持 H.264,不存在任何兼容性烦恼。

当然,作为一项 20 多年前的技术,H.264 在压缩效率上已经明显落后于 VP9、HEVC 和 AV1。相同画质下,H.264 的码率要比 AV1 高出 30~50%,在追求极致带宽利用或存储节省的应用场景中就显得有些力不从心。然而在今天这个“能播比好看更重要”的现实环境中,H.264 依然是默认方案,是“稳健老哥”的代名词。

所以,即便 AV1、HEVC、VP9 各有亮点,H.264 依旧凭借“老、稳、全”三大核心竞争力,在 2025 年依然牢牢占据着视频生态链的中枢地位——只要这个世界还有浏览器不支持 AV1(可恶的 Safari 不支持软解),服务器不想烧钱转码视频,或用户设备太老,H.264 就不会退场。

小结

在视频编码方面,浏览器不再是那个能靠一己之力抹平硬件和系统差异的超人,所以总有一些特殊情况是表格中无法涵盖的。

编解码器 压缩效率 浏览器 桌面端支持 移动端支持 备注
AV1 ★★★ Chrome / Chromium 是 (v70+,发布于 2018 年 10 月) 是 (v70+,发布于 2018 年 10 月) 硬解优先,软解后备
Firefox 是 (v67+,发布于 2019 年 5 月) 是 (v113+,发布于 2023 年 5 月) 硬解优先,软解后备
Safari 不完全支持 (仅近两年的产品支持) 不完全支持 (仅近两年的产品支持) 仅支持硬解 (M3, A17 Pro 系芯片后开始支持),无软解支持
HEVC (H.265) ★★☆ Chrome / Chromium 不完全支持 不完全支持 仅支持硬解,无软解支持(Windows 可从微软商店安装付费的软解插件)
Firefox 不完全支持 不完全支持 仅支持硬解,无软解支持(Linux 可依赖系统 ffmpeg 实现软解)
Safari 近期设备全部支持 (macOS High Sierra+,发布于 2017 年 6 月) 近期设备全部支持 (iOS 11+,发布于 2017 年 10 月) 苹果是 H.265 一等公民
VP9 ★★☆ Chrome / Chromium 支持良好
Firefox 支持良好
Safari 是 (v14.1+,发布于 2021 年 4 月) 是 (iOS 17.4+,发布于 2024 年 3 月) 支持稍晚(此处指兼容 vp9 的 webm 时间,vp9 in WebRTC 的兼容时间更早)
H.264 (AVC) ★☆☆ Chrome / Chromium 通用
Firefox 通用
Safari 通用

怎么选?

我们不是专业的视频托管平台,不像 YouTube、Bilibili 那样专业到可以向用户提供多种分辨率、压缩算法的选择。

Bilibili 为用户提供了三种压缩算法的视频源

最终的选择策略,必须在压缩效率、播放兼容性、编码耗时等维度之间做出权衡。

选择一:AV1 挑大梁,H.264 保兼容

现代浏览器支持在 <video> 标签中使用 <source> 标签和 MIME type 让浏览器按需播放

<video controls poster="preview.jpg">  <source src="video.av1.webm" type='video/webm; codecs="av01"' />  <source src="video.h264.mp4" type='video/mp4' />  当前浏览器不支持视频播放</video>

通过这样的写法,浏览器会自动选择最先能解码的 source,无需写复杂的判断逻辑或使用 JavaScript 动态切换。默认的 AV1 编码在最大程度上减少了传输流量降低成本,享受现代浏览器与设备的压缩红利;而 H.264 则作为兜底方案,保证了在不支持 AV1 的 Safari 等老旧设备上的回放兼容性。

然而这个选择可能并不是太合适,一方面我手上最先进的处理器 Apple M4 并不支持硬件编码 AV1 视频,5 分钟的视频压完需要整整 3 个小时,如果还需要视压缩质量来回调整压缩参数重新压上几次,那可真是遭老罪了;另一方面,即使 Chromium / Firefox 等主流浏览器内核现在都支持 AV1 的软解,但在一些硬件较老的设备上播放 AV1 编码的视频可能让用户的电脑风扇原地起飞,这一点在 YouTube 大力推广 AV1 的时候就曾遭到不少用户的诟病。

选择二:VP9 独挑大梁

考虑到 AV1 编码的高昂成本和用户电脑风扇原地起飞的风险,VP9 也是一个非常具有竞争力的选择。VP9 在主流浏览器中得到了非常好的兼容,因此可以考虑放弃 H.264 的 fallback 方案独挑大梁。而 VP9 硬件编码在近几年的硬件设备上的普遍支持也给足了我勇气,让我可以多次调整压缩质量重新压缩,找一个在文件体积和画面清晰度之间的 sweet point。

由于是 VP9 独挑大梁,因此大多数人可能会考虑使用与 VP9 最为适配的 webm 格式封装视频。但目前在 webm 中最广泛使用的音频编码 opus 在 Safari 上的兼容性并不是太好(在 2024 年 3 月发布的 Safari 17.4 才开始支持),建议斟酌一下是不是继续用回 AAC 编码,并将视频封装在 mp4 中。

https://caniuse.com/opus

音频码率太高?再砍一刀

上面说了那么多的视频压缩算法,其实只是局限于视频画面的压缩,音频这一块其实还能再压一点出来。

Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 128 kb/s (default)

一个介绍产品的视频,在音频部分采用了 48000 Hz 双声道采样,码率高达 128 kbps,说实话有点奢侈。我直接砍成 64 kbps 单声道,又省下 2MB 的文件大小。

写在最后

对于前端开发者来说,视频压缩算法的选择早已不是单纯的“压得小不小”问题,而是一场在设备能力、浏览器兼容性、用户体验与开发成本之间的博弈。我们既要跟上技术演进的节奏,拥抱 AV1、VP9 等更高效的编解码器,也要在实际项目中照顾到现实中的设备分布和播放环境。

在理想与落地之间,我们所能做的,就是充分利用 HTML5 提供的容错机制,搭配好合适的编码策略和封装格式,让网页上的每一段视频都能在合适的设备上、以合理的代价播放出来。

毕竟,Web 从来不缺“能不能做”,缺的是“做得优雅”。如果说编码器是硬件工程师和视频平台的战场,那 <video> 标签下的这几行 <source>,才是属于我们前端工程师的战壕。

参见

el-image 和 el-table 怎么就打架了?Stacking Context 是什么?

2025-05-31 00:29:40

这是精弘内部的图床开发时遇到的事情,大一的小朋友反馈说 el-image 和 el-table 打架了。这里专门提供一个 demo 来直观展示情况,下面是截图。

看到后面的表格透出 el-image 的预览层,我的第一反应是叫小朋友去检查 z-index 是否正确,el-image 的 mask 遮罩的 z-index 是否大于表格。

经过我本地调试,发现 z-index 的设置确实没问题,但后面的元素为什么会透出来?谷歌搜索一番,找到了这篇文章

给 el-table 加一行如下代码即可

.el-table__cell {    position: static !important;}

经本地调试确认,这一方案确实能解决问题,但为什么呢?这就涉及到 Stacking Context (层叠上下文)了。

Stacking Context(层叠上下文)究竟是什么?

简单来说,Stacking Context 可以被类比成画布。在同一块画布上,z-index 值越高的元素就处于越上方,会覆盖掉 z-index 较低的元素,这也是为什么我最开始让检查 z-index 的设置是否有问题。但问题出在 Stacking Context 也是有上下顺序之分的。

现在假设我们有 A、B 两块画布,在 A 上有一个设置了 z-index 为 1145141919810 的元素。那这个元素具备非常高的优先级,理应出现在浏览器窗口的最上方。但如果 B 画布的优先级高于 A 画布,那么 B 元素上的所有元素都会优先显示(当了躺赢狗)。那么画布靠什么来决定优先级呢?

  • 处于同级的 Stacking Context 之间靠 z-index 值来区分优先级
  • 对于 z-index 值相同的 Stacking Context,在 html 文档中位置靠后的元素拥有更高的优先级

第二条规则也能解释为什么在上面的 demo 中,只有在表格中位置排在图片元素后面的元素出现了透出来的情况。

所以为什么 el-image 和 el-table 打架了?

这次的冲突主要是下面两个因素引起的

  1. el-table 给每个 cell 都设置了 position: relative 的 css 属性,而 position 被设为 relative 时,当前元素就会生成一个 Stacking Context。

    image-20250531013029154

    所以我们这么一个有十个格子的表格,其实就生成了十个画布。而这其中每个画布 z-index 都为 1。根据刚才的规则,在图片格子后面的那些格子对应的 html 代码片段在整体的 html 文档中更靠后,所以他们的优先级都高于图片格子。

  2. el-image 的预览功能所展开的遮罩层处于 el-image 标签内部

    上图中橙色部分是 el-image 在预览时提供的遮罩,可以看到 element-plus 组件的 image 预览的默认行为是将预览时所需要的遮罩层直接放在 <el-image> </el-image> 标签内部,这导致 el-image 的遮罩层被困在一个低优先级的 Stacking Context 中,后面的格子里的内容就是能凭借高优先级透过来。

所以解决方案是什么?

更改 position 值在这里确实是可行的

上面我谷歌搜到的将 el-table 中 cell 的 position 值强制设为 static 确实是有效的,因为 static 不会创建新的 Stacking Context,这样就不会有现在的问题。

将需要出现在最顶层的代码放置在优先级最大的位置是更常见的方案

但别的组件库在处理这个需求时,一般会将预览时提供的遮罩的 html 代码片段直接插入到 body 标签内部的最尾部,并设置一个相对比较大的 z-index 值,以确保这个遮罩层能够获得最高的优先级,以此能出现在屏幕的最上方。(像一些 dialog 对话框、popover 悬浮框也都是这个原理)。

事实上,element-plus 组件库也提供了这个功能

preview-teleported: image-viewer 是否插入至 body 元素上。嵌套的父元素属性会发生修改时应该将此属性设置为 true

所以在使用 el-image 时传入一个 :preview-teleported="true" 是一个更普适的方案,因为我们并不能确保 el-image 的父元素除了 el-table 的 cell 以外还有什么其他的父元素会创建新的 Stacking Context。

参见

2025年,前端如何使用 JS 将文本复制到剪切板?

2025-04-21 19:48:05

基础原理

如果你尝试在搜索引擎上检索本文的标题,你搜到的文章大概会让你使用下面两个 API。我希望你用的搜索引擎不至于像某度一样灵车到 2025 年还在让你使用基于 Flash 的 ZeroClipboard 方案

document.execCommand

2012 年不止有世界末日,还有 IE 10。随着 IE 10 在当年 9 月 4 日发布,execCommand 家族迎来了两个新的成员—— copy/cut 命令(此说法来自 Chrome 的博客,而 MDN 认为 IE 9 就已经支持了)。三年之后,随着 Google Chrome 在 2015 年 4 月 14 日的发布的 42 版本对 execCommand 的 copy/cut 跟进,越来越多的浏览器厂商开始在自家的浏览器中跟进这个实现标准。最终在 2016 年 9 月 13 日发布的 Safari 10 on IOS 后,WEB 开发者们总算获得了历史上第一个非 Flash 实现的 js 复制到剪切板的方案。

当 document.execCommand 的第一个参数为 copy 时,可以将用户选中的文本复制到剪切板。基于这个 API 实现,很快便有人研究出了当今 web 下最常见的 js 实现——先创建一个不可见的 dom,用 js 操作模拟用户选中文本,并调用 execCommand(‘copy’) 将文本复制到用户的剪切板。大致的代码实现如下:

// 来自「JS复制文字到剪贴板的坑及完整方案。」一文,本文结尾有跳转链接const textArea = document.createElement("textArea");textArea.value = val;textArea.style.width = 0;textArea.style.position = "fixed";textArea.style.left = "-999px";textArea.style.top = "10px";textArea.setAttribute("readonly", "readonly");document.body.appendChild(textArea);textArea.select();document.execCommand("copy");document.body.removeChild(textArea);

尽管这个 API 早已被 w3c 弃用,在 MDN 被标注为 Deprecated,但这仍然是市面上最常见的方案。在编写本文的时候,我扒了扒 MDN 的英文原始页面在 archive.org 的存档及其在 Github 的变更记录,这个 API 在 2020 年 1~2 月被首次标记为 Obsolete(过时的),在 2021 年 1 月被首次标记为 Deprecated(已弃用),并附上了红色 Section Background Color 提示开发者该 API 可能随时无法正常工作。但截至本文发布,所有的常用浏览器都保留着对该 API 的兼容,起码在 copy 命令下是这样的。

这个 API 被广泛应用在了太多站点,以至于移除对该 API 的支持将会导致大量的站点异常,我想各家浏览器内核在短期内恐怕都没有动力以丢失兼容性为代价去移除这个 API,这也意味着这个创建一个不可见的 dom 代替用户选中文本并执行 execCommand 复制到用户剪切板的(看似奇葩的)曲线救国方案已然在前端开发的历史上留下了浓墨重彩的一笔。

Clipboard.writeText()

随着原生 JS 一步步被增强,开发者们总算补上了 Clipboard 这一块的拼图。2018 年 4 月 17 日,Chrome 66 率先迈出了这一步;同年 10 月 23 日,Firefox 跟进了 ClipBoard API 的实现。最终在 2020 年 3 月 24 日,随着 Apple 自家 Safari 13.4 的姗姗来迟,前端开发者门总算喘了口气,再一次得到了一个主流浏览器通用的复制方案。

那么 execCommand 明明已经实现了纯 js 实现的复制文本到剪切板了,为什么我们还需要 Clipboard API ?或者说,这个特意去实现的 Clipboard API 到底有什么优势?

  1. 传统的 execCommand 方案在使用的时候通常需要创建一个临时的不可见的 DOM,放入文本、用 JS 选中文本、执行 copy 命令。我们暂且不说这种 hacky 的方式在代码编写时是多么不优雅,但一个使用 JS 去选中文本这个操作就会修改用户当前的文本选择状态,在某些时候导致一些用户体验的下降。
  2. Clipboard API 是异步的,这意味着其在复制大量文本时不会阻塞主线程。
  3. Clipboard API 提供了更多的能力,比如 write()read() 允许对剪切板读写更复杂的数据,比如富文本或图片。
  4. Clipboard API 具有更现代、更明确的权限控制—— write 操作需要由用户的主动操作来调用,read 操作则需要用户在浏览器 UI 上明确授予权限。这些权限控制给予了用户更大的控制权,因此,当 execCommand 退出历史的舞台后,WEB 的安全性将得到进一步提升。

不过在现阶段,Clipboard.writeText() 未必就能解决所有的问题。抛开旧版浏览器的兼容性问题不谈,navigator.clipboard 仅在通过 https 访问的页面中可用(或是 localhost),如果你的项目部署在局域网,你试图通过 192.18.1.x 的 ip + port 直接访问,那么 navigator.clipboard 将会是 undefined 状态。

除此之外,安卓原生的 Webview 还有因为 Permissions API 没实现而用不了 Clipboard API 的问题。

基于以上原因,很多网站现在都会优先尝试使用 navigator.clipboard.writeText(),失败后再转去使用 execCommand('copy')。大致的代码实现如下:

// 来自「JS复制文字到剪贴板的坑及完整方案。」一文,本文结尾有跳转链接const copyText = async val => {  if (navigator.clipboard && navigator.permissions) {    await navigator.clipboard.writeText(val);  } else {    const textArea = document.createElement("textArea");    textArea.value = val;    textArea.style.width = 0;    textArea.style.position = "fixed";    textArea.style.left = "-999px";    textArea.style.top = "10px";    textArea.setAttribute("readonly", "readonly");    document.body.appendChild(textArea);    textArea.select();    document.execCommand("copy");    document.body.removeChild(textArea);  }};

Flash 方案(ZeroClipboard

其实上面两个 API 差不多就把基础原理讲完了,不过我在查资料的时候发现,在 execCommand 方案之前,前端居然大多是依靠 Flash 来实现复制文本到剪切板的,这不得拿出来讲讲?

目前在 ZeroClipboard 的 Github 仓库能找到的最老的 tag 是 v1.0.7,发布于 2012 年 6 月 9 日。我打赌这个项目不是第一个通过 Flash 实现复制文本到剪切板的,在此之前肯定有人使用 Flash 实现过这个功能,只是没单独拎出来作为一个库开源出来。

ZeroClipboard 通过创建一个透明的 Flash Movie 覆盖在触发按钮上,当用户点击按钮时,实际上点到的是 Flash Movie,随后 JavaScript 与 Flash Movie 通过 ExternalInterface 进行通信,将需要复制的文本传递给 Flash,再经 Flash 的 API 将文本写入用户的剪切板。

在当时的时代背景下,这是唯一一个能够跨浏览器实现复制文本到剪切板的方案(尽管并不是每台电脑都装有 Flash,尽管 IOS 并不支持 Flash),6.6k star 的 Github 仓库见证了那个各家浏览器抱着各家私有 API 的混沌时代,最终随着 execCommand 方案的崛起,ZeroClipboard 与 Flash 一同落幕。

其他不完美的方案

window.clipboardData.setData

该 API 主要在 2000 年 —2010 年前后被使用,仅适用于 IE 浏览器。Firefox 在这段时间里还不支持纯 js 实现的复制文本至浏览器的操作;Chrome 第一个版本在 2008 年才发布,尚未成为主流。

window.clipboardData.setData("Text", text2copy);

摆烂(prompt)

调 prompt 弹窗让用户自己复制。

prompt('Press Ctrl + C, then Enter to copy to clipboard','copy me')

第三方库封装

由于 execCommand 的方案过于抽象,不够优雅,所以我们有一些现成的第三方库对复制到剪切板的代码进行了封装。

clipboard.js

clipboard.js 是最负盛名的一款第三方库,截至本文完成时间,在 Github 共收获 34.1k 的 star。最早的一个 tag 版本发布于 2015 年 10 月 28 日,也就是 Firefox 支持 execCommand、PC 端三大浏览器巨头全面兼容的一个月后。

clipboard.js 仅使用 execCommand 实现复制到剪切板的操作,项目的 owner 希望开发者自行使用 ClipboardJS.isSupported() 来判断用户的浏览器是否支持 execCommand 方案,并根据命令执行的返回值自行安排成功/失败后的动作。。

不过让我感到奇怪的是,clipboard.js 在实例化时会要求开发者传入一个 DOM 选择(或者是 HTML 元素/元素列表)。它一定要有一个实体的 html 元素,用设置事件监听器来触发复制操作,而不是提供一个 js 函数让开发者来调用——尽管这不是来自 execCommand 的限制。示例如下

<!-- Target --><input id="foo" value="text2copy" /><!-- Trigger --><button class="btn" data-clipboard-target="#foo"></button><script>new ClipboardJS('.btn');</script>

对,就一行 js 就能给所有带有 btn class 的 dom 加上监听器。或许这就是为什么这个仓库能获得 34.1k star 的原因,在 2015 年那个大多数人还在用三件套写前端的时代,clipboard.js 能够降低代码量,不用开发者自行设置监听器。

clipboard.js 当然也提供了很多高级选项来满足不同开发者的需求,比如允许你通过传入一个 function 来获取你需要让用户复制的文本而,或是通过 Event 监听器来反馈是否复制成功,总之灵活性是够用的。

copy-to-clipboard

同样是一款利用 execCommand 的第三方库,虽然只有 1.3k star。第一个 tag 版本发布于 2015 年的 5 月 24 日,比 clipboard.js 还要早。相比起 clipboard.js,copy-to-clipboard 不依赖 html 元素,可以直接在 js 中被调用,我个人是比较喜欢这个的。在 vue/react 等现代化的前端框架中,我们一般不直接操作 dom,因此并不是很适合使用 clipboard.js,这个 copy-to-clipboard 就挺好的。此外,除了 execCommand 与方案,copy-to-clipboard 还对老版本的 IE 浏览器针对性的适配了 window.clipboardData.setData 的方案,并且在两者都失败时会调用 prompt 窗口让用户自主复制实现最终的兜底。

示例如下:

import copy from 'copy-to-clipboard';copy('Text');

相比起 clipboard.js 的使用思路是更加直观了,可惜生不逢时,不如 clipboard.js 出名(也可能有取名的原因在里面)。

VueUse - useClipboard

VueUse 实现的这个 useClipboard 是令我最为满意的一个。useClipboard 充分考虑了浏览器的兼容性,在检测到满足 navigator.clipboard 的使用条件时优先使用 navigator.clipboard.writeText() ,在不支持 navigator.clipboard 或者 navigator.clipboard.writeText() 复制失败时转去使用 execCommand 实现的 legacyCopy,并且借助 Vue3 中的 Composables 实现了一个 1.5 秒后自动恢复初始状态的 copied 变量,算是很有心了。

const { text, copy, copied, isSupported } = useClipboard({ source })</script><template>  <div v-if="isSupported">    <button @click="copy(source)">      <!-- by default, `copied` will be reset in 1.5s -->      <span v-if="!copied">Copy</span>      <span v-else>Copied!</span>    </button>    <p>Current copied: <code>{{ text || 'none' }}</code></p>  </div>  <p v-else>    Your browser does not support Clipboard API  </p></template>

React 相关生态

React 这边不像 VueUse 一家独大,出现了很多可用的 hooks 库,那就全都过一遍

react-use - useCopyToClipboard

react-use 是我能搜到的目前最大的 React Hooks 库,42.9k star。采用的复制方案是直接依赖上面介绍过的 copy-to-clipboard,也就是 execCommand 方案。

const Demo = () => {  const [text, setText] = React.useState('');  const [state, copyToClipboard] = useCopyToClipboard();  return (    <div>      <input value={text} onChange={e => setText(e.target.value)} />      <button type="button" onClick={() => copyToClipboard(text)}>copy text</button>      {state.error        ? <p>Unable to copy value: {state.error.message}</p>        : state.value && <p>Copied {state.value}</p>}    </div>  )}

Ant Design - Typography

ahooks 是小麦茶第一个报出来的 react hooks 库,由 Ant Design 原班人马维护。不过其在仓库中并没有对剪贴板的封装,因此在小麦茶的建议下我跑去翻了 Ant Design 中的 Typography 对复制能力的实现。和上面的 react-use 一样,都是直接用 copy-to-clipboard,属于 execCommand 方案。

usehooks - useCopyToClipboard

这个库是我问 llm 知道的,现在有 10.5k star。非常逆天的一点在于它的所有逻辑代码都是在 index.js 这样一个单文件里实现的,属实是看不懂了。会先采用 navigator.clipboard.writeText() 尝试写入,失败后再换用 execCommand 的方案。hooks 的用法和上面的 react-use 大差不差。

usehooks-ts - useCopyToClipboard

不知道是不是为了解决上面那玩意儿不支持 ts 才开的库。只使用 navigator.clipboard.writeText() 尝试写入剪切板,失败后直接 console.warn 报错,没有 fallback 方案。

结语

从结果上来看,VueUse 的封装无疑是最令我满意的。优先尝试性能最好的 Clipboard API,再尝试 execCommand 作为回落,同时辅以多个响应式变量帮助开发,但又不擅作主张地使用 prompt 作为保底,最大程度地把操作空间留给开发者。

站在 2025 年的节点回望,前端剪切板操作技术的演进轨迹清晰可见:从早期依赖 Flash 的脆弱方案,到 execCommand 的曲线救国,最终迈向标准化 Clipboard API 的优雅实现。这段历程不仅是技术迭代的缩影,更折射出前端开发中独特的「妥协艺术」。

在未来的很长一段时间里,或许我们还是会在「优雅实现」与「向下兼容」之间寻找平衡点、在浏览器沙箱里戴着镣铐跳芭蕾,但那些为兼容性而生的临时方案,终将成为见证前端进化史的珍贵注脚。

参见

ssh 拯救世界——通过 ssh 隧道在内网服务器执行 APT 更新

2025-03-30 21:45:24

事情的起因是因为精弘的前技术总监抱怨学校的内网服务器无法连接外网,从而导致 apt 安装与更新异常困难,需要手动从源中下载软件包、软件包的依赖及其依赖的依赖。。。然后将这些包通过 sftp/rsync 一类的手段传到服务器上手动安装。

于是本文应运而生,我们可以在本机使用 Caddy (Nginx 当然也行)反代一个 APT 源镜像站,通过 ssh 隧道建立端口转发,这样就可以在内网服务器上访问到本地的 Caddy 服务器,进而访问到外网的镜像站。

前提条件

  • 主控机(你自己的电脑)能够通过 ssh 直接连接电脑(可以是使用一些网络工具),而不是先通过 ssh 登陆到一台中转机,再从中转机登陆到目标服务器。后面这种情况当然也可以使用类似的手段实现我们的目标,但会更复杂一些。
  • 主控机(你自己的电脑)在连接内网服务器的同时,能够连接公网镜像站(不行的话要不然你提前本地同步一份镜像做离线镜像站)。

反代镜像站

我这里选择了 Caddy 而非 Nginx,一方面是 Caddy 的配置文件写起来简单,另一方面 Caddy 是 Golang 编写,一个二进制走天下,Windows 也能直接下载运行。

我们以最常见的清华 tuna 镜像站为例,一个简单的 caddy 配置文件是这样的

:8080 {    reverse_proxy https://mirrors.tuna.tsinghua.edu.cn {        header_up Host {http.reverse_proxy.upstream.hostport}    }}

将上面这段代码保存为 Caddyfile 文件名,随后使用 caddy 命令在保存路径运行

caddy run --config ./Caddyfile

如果没有报错,那你应该能在本地的 8080 端口看到清华的镜像站

你可能注意到,反代后的页面和清华的镜像站有些许差异,没有清华的 logo,这大概是因为页面的 js 对 host 进行了判断,如果不是清华或者北外的页面,就不会添加学校的名称,但这不影响我们从这些镜像站获取更新。

建立 ssh 隧道

建立隧道时,需要使用如下的命令

ssh -R 8085:localhost:8080 [email protected]

-R 表示建立反向隧道,其他的参数选项可以参考这一篇博客「SSH 隧道技术」,也是精弘的学长写的。

此时,我们建立了一个内网服务器 8085 端口到本机 8080 端口的 ssh 端口转发。(使用 8085 端口是我为了区分其和 8080 端口,实际上可以使用任何空余端口)

我们可以在服务器上使用 curl 来测试一下是否能够正常访问,我这里简单访问了下 Debian 源根目录下的一个 README 文件。

curl http://localhost:8085/debian/README

换源

所以现在我们在内网服务器的 8085 端口上有一个清华开源镜像站的反代,我们可以通过 8085 端口访问镜像站中的所有内容。

先遵循清华开源镜像站的指示,进行换源,记得一定要勾选「强制安全更新使用镜像」

随后,我们将源中的所有 https://mirrors.tuna.tsinghua.edu.cn 替换成 http://localhost:8085

sed -i 's|https\?://mirrors\.tuna\.tsinghua\.edu\.cn|http://localhost:8085|g' `grep -rlE 'http(s)?://mirrors\.tuna\.tsinghua\.edu\.cn' /etc/apt/`

执行 apt update

使用 apt 安装 unzip

可以看到,我们通过 ssh 隧道实现了在内网服务器执行 APT 更新及安装软件。

温馨提示,ssh 隧道在本世纪 10 年代初经常被用来进行搭建一些跨境访问,但因为其独特的流量特征很快淡出了历史舞台,因此不要使用 ssh 进行大量的跨境网络传输,容易被封禁。

当然,实现这一目标的方法是很多的,其他一些例如 frp 的工具同样能做到这种效果,只不过 ssh 隧道这种方案随开随用,随关随停,不需要更多的配置,因此我主要推荐。

Cudy TR3000 吃鹅(daed)记

2025-02-28 21:18:34

缘起

前不久在京东自营看到我馋了很久的 Cudy TR3000 有 ¥153 的折扣价,虽然比起 ¥130 的史低价(甚至 ¥110 的凑单史低价)还有些距离,但已经到我的可接受范围内了,于是果断下单剁手了这台我心心念念的 Cudy TR3000 迷你路由器,以此来缓解我的开学前综合症(一种精神性疾病)

这台路由器使用 Type-C 供电,拥有一个 2.5Gbps 的 WAN 口和一个 1Gbps 的 LAN 口,在此基础上还有一个 USB 口可用于打印机共享、挂载外接存储、安卓手机 USB 共享网络等多种用途。更让我心动的地方在于其小巧的体型,非常适合出差、旅行、短期租房等场景。考虑到接下来一段实习可能会有租房需求,于是便趁此机会果断下单了。

与一台小米8的宽高对比

官方系统是基于 openwrt 定制的,功能比较单一,因此考虑刷入 openwrt 原版系统增加可玩性。在恩山无限论坛上发现已经有人编译了基于 Linux 6.6 版本的 OpenWRT 系统,这已经满足了 dae 的 Bind to LAN 功能的内核版本要求( >= 5.17 ),且 512MB 的内存大小刚好达到了推荐的最小内存大小,于是这 dae 肯定是要试着吃一吃的。如果成功了,这就是我手上第一台吃上大鹅的硬路由。


开始刷机

路由器官方系统的后台管理地址是 192.168.10.1,初次进入会要求你设置密码,然后就是一路随便点,完成初始化,随后就进入到主页。我手上这台的 FW 版本号是 2.3.2-20241226,不清楚后续的版本能不能仍然使用这套方案。

过渡固件

首先我们需要先刷入所谓的「过渡固件」。刷入过渡固件的意义在于,这个过渡固件能被官方系统的升级程序所承认,这样就允许我们进行后续的操作。

过渡固件的文件名和 md5 值如下:

b8333d8eebd067fcb43bec855ac22364  cudy_tr3000-v1-sysupgrade.bin

随后我们可以在路由器的管理页面的基本设置中找到固件升级的地方,在本地更新一栏中选择过渡固件上传更新即可。

刷入解锁 FIP 分区写入权限的固件

刷入过渡固件后稍等大约一分钟,路由器的 DHCP 重新工作,我们就可以通过 192.168.1.1 进入过渡固件的管理页面。

初次登陆时没有密码,随便输就能登陆成功。考虑到后续可能会有恢复出厂的需求,建议在这一步对 FIP 分区进行备份。

这次我们需要刷入下面这个 LEDE 固件来解锁 FIP 分区的写入权限,文件名和 md5 仍然放在下面

4af5129368cbf0d556061f682b1614f2  openwrt-mediatek-filogic-cudy_tr3000-v1-squashfs-sysupgrade.bin

在下方选择刷入固件,上传我们本次需要刷入的固件,刷入。

刷入 uboot

再等待一分钟左右,电脑重新连接上路由器后,我们可以进入到这个解锁了 FIP 分区写入权限的固件,默认密码是 password

在侧栏选择文件传输,将本次要刷入的 uboot 上传,文件名和 md5 还是放在下面。注意 zip 包要解压

e5ff31bac07108b6ac6cd63189b4d113  dhcp-mt7981_cudy_tr3000-fip-fixed-parts-multi-layout.bin

随后侧栏进入 TTYD 终端,输入默认的用户名密码 root / password,执行命令刷入 uboot

mtd write /tmp/upload/dhcp-mt7981_cudy_tr3000-fip-fixed-parts-multi-layout.bin FIP

刷入自编译的 immortalwrt

刷入 uboot 以后,给路由器断电,确保网线分别连接电脑和路由器 LAN 口后,按住 reset 键再插入电源键,直至白灯闪烁四次后转为红灯后松开 reset 键,即可进入 uboot。

我编译的是 112m 的布局,因此需要选择 mod-112m 这个 mtd 布局后上传固件刷入。

8c9a44f29c8c5a0617e61d49bf8ad45d  112m-immortalwrt-cudy_tr3000-ebpf_by_zhullyb_20250325-squashfs-sysupgrade.bin

再次等待电脑重新连接路由器,这是最终吃上 daed 的系统了,依然是没有默认密码,随便输入即可进入。在连接上网络后,在系统 - 软件包页面,更新软件包列表。

随后就可以安装 dae / daed 相关软件了,可视需求选择 luci-i18n-dae-zh-cn 或者 luci-i18n-daed-zh-cn,其他包会作为依赖一同被安装。我这里安装的是 daed。

安装后刷新界面,我们就可以在顶栏的服务板块看到 daed。

daed 正常运行,能正常跑满我家的 300Mbps 宽带下行(单线程实测 250Mbps),速度峰值时 CPU 占用图如下。

多线程测速

单线程测速

文章中提到的文件

https://www.123684.com/s/gfprVv-wEQ8d

https://www.123912.com/s/gfprVv-wEQ8d

参见

使用 Cloudflare Workers 监控 Fedora Copr 构建状态

2025-02-23 12:12:53

确信,是 cloudflare workers 用上瘾了

「使用 Github Action 更新用于 rpm 打包的 spec 文件」一文中,我利用 Github Action 实现了自动化的 spec 版本号更新,配合 Fedora Copr 的 webhook 就可以实现 Copr 软件包更新的自动化构建。看似很完美,但缺少了一个构建状态的监控机制,这导致出现构建错误的时候我不能及时得到通知(无论构建错误是 spec 本身的问题或者是构建时的网络环境问题)。

西木野羰基 提出 notifications.fedoraproject.org 可以配置通知,Filters 的 Applications 选项中有 copr,但很可惜,实测没有效果。这里的通知配置的似乎只是邮件的过滤规则——如果 copr 本来就没打算构建失败的时候给你发邮件,那即使建立了过滤规则,依然是不可能收到邮件的。

不过好在 Fedora Copr 本身有非常完备的 api 文档/monitor 这个 API 能用来获取软件包最新的构建情况。

因此,我们就可以通过 Cloudflare 的 cronjob 定时请求这个接口,查询是否有软件包构建失败。

先来编写打请求的部分

async function fetchCopr() {    const ownername = "zhullyb";    const projectname = "v2rayA";    const url = new URL("https://copr.fedorainfracloud.org/api_3/monitor")    url.searchParams.set("ownername", ownername)    url.searchParams.set("projectname", projectname)    const response = await fetch(url)    const data = await response.json()    if (data.output !== "ok") {        throw new Error("Failed to fetch COPR data")    }    return data}

随后编写通知部分,我这里采用的是飞书的 webhook 机器人

async function notify(text) {    const webhook = "https://open.feishu.cn/open-apis/bot/v2/hook/ffffffff-ffff-ffff-ffff-ffffffffffff"    const body = {        msg_type: "text",        content: {            text: text        }    }    const response = await fetch(webhook, {        method: "POST",        headers: {            "Content-Type": "application/json"        },        body: JSON.stringify(body)    })    console.log(response)}

最后就是 cronjob 的调用部分和构建状态解析部分

export default {    async fetch(request, env, ctx) {      return new Response('Hello World!');    },    async scheduled(event, env, ctx) {        const data = await fetchCopr()        const errorPackages = new Array()        for (const pkg of data.packages) {            for (const chroot of Object.values(pkg.chroots)) {                if (chroot.state == "failed") {                    errorPackages.push(pkg.name)                    break                }            }        }        if (errorPackages.length > 0) {            await notify(`COPR 以下包发生构建失败:\n${errorPackages.join("\n")}`)        } else {            console.log("COPR 所有包构建成功")        }    }};

随后在 Cloudflare Workers 的 Settings 部分设置好 Cron 表达式即可,我这里选择在每小时的 55 分进行一次检测,这样下来一天只会消耗 24 次 workers 次数,简直毫无压力。

缺点: 我懒得使用持久化数据库记录软件包构建的成功状态,这会导致出现一个包构建失败后,每隔 1 小时都会有一条提醒,什么夺命连环 call。我目前不想修复这个问题,要不然还是降低 cron 的触发频率好了。