阅读视图

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

高并发没那么神秘:用人话讲清系统是怎么被打爆的

提到“高并发”,很多开发者都会觉得神秘又可怕——“系统被高并发打爆了”“QPS太高扛不住了”“雪崩了,救急!”。其实高并发一点都不神秘,说白了就是“请求太多,资源太少,一个环节堵死,全链路崩溃”。

今天就用人话,把“系统被高并发打爆的全过程”讲透,再分享5个直接能用的防御技巧,让你以后遇到高并发,不用慌,知道问题出在哪、该怎么解决。

一、先搞懂:高并发打爆系统,本质是什么?

用一个生活中的例子就能讲明白:服务器就像一个食堂,请求就像来吃饭的人,CPU、内存、数据库、缓存,就像食堂的窗口、桌子、厨师。

平时人少的时候,一切正常;一旦到了饭点,上千人同时涌进食堂,窗口不够、厨师不够、桌子不够,大家挤在一起,没人能吃上饭,最后食堂彻底混乱——这就是高并发打爆系统的本质:请求无限,资源有限,短板效应+连锁反应,最终导致系统宕机

更关键的是:系统被打爆,从来都不是“突然崩了”,而是有一个“循序渐进”的过程,只要能抓住这个过程中的关键节点,就能提前预防,避免崩溃。

二、系统被打爆的4个经典场景(90%的崩溃都在这)

不管是电商秒杀、热搜爆发,还是爬虫攻击,系统被打爆的场景其实就4种,搞懂这4种场景,就能应对大部分高并发问题。

(1)数据库被打崩:最常见的“死法”

数据库是系统的“软肋”,也是最容易被高并发打崩的环节,尤其是没有做缓存的系统,几乎一冲就垮。

崩溃原因(用人话讲):无数请求同时查数据库、数据库连接池被耗尽、SQL写得太烂(没有索引、全表扫描)、多个请求同时修改一条数据(锁竞争)。

连锁反应:数据库响应变慢 → 应用线程一直等待数据库返回结果,导致线程池满 → 应用无法处理新的请求 → 更多用户重试,请求量翻倍 → 数据库压力更大,最终宕机 → 全站卡死。

一句话总结:数据库就一个收银台,1000人同时排队,直接挤爆,后面的人连队都排不上。

(2)缓存雪崩/击穿/穿透:最坑的“死法”

很多人做了缓存,还是被打崩,原因就是没处理好缓存的3个常见问题:雪崩、击穿、穿透。这三个问题,本质都是“缓存没起到作用,请求全砸到了数据库上”。

  • 缓存雪崩:大量缓存Key在同一时间过期 → 所有请求都缓存未命中,直接砸向数据库 → 数据库瞬间被压垮;
  • 缓存击穿:一个超级热点Key(比如首页Banner、热门商品)过期 → 上万个请求同时查询这个Key,缓存未命中,全部打向数据库 → 数据库宕机;
  • 缓存穿透:查询的是不存在的数据(比如爬虫伪造的用户ID、不存在的商品ID) → 缓存不命中,每次都要查数据库 → 大量无效请求持续冲击数据库,最终把数据库打崩。

一句话总结:本来有保安(缓存)拦着歹徒(请求),结果保安突然集体下班(雪崩)、单个保安请假(击穿)、歹徒绕开保安(穿透),歹徒直接冲进银行(数据库),把银行搞垮。

(3)线程/连接池耗尽:应用“自杀式”崩溃

应用的线程池、数据库连接池,都是有上限的,就像食堂的窗口,数量是固定的。一旦请求太多,或者处理请求太慢,就会导致线程/连接池耗尽,应用自己“自杀”。

崩溃原因:请求量突增、接口处理太慢(比如同步调用第三方接口,等待时间太长)、线程没有及时释放(比如代码有死循环、锁没有释放)。

连锁反应:线程池满 → 新的请求无法获取线程,只能排队 → 排队时间过长,请求超时 → 用户反复重试,请求量翻倍 → 线程池彻底耗尽 → 应用无响应,最终崩溃。

一句话总结:食堂只有10个窗口,来了10000人,全堵在门口,后面的人永远进不来,窗口也被占满,最后食堂无法正常运转。

(4)依赖雪崩:第三方/下游拖死你

很多系统都要依赖第三方服务(比如支付接口、短信接口、地图接口),或者下游服务(比如订单服务依赖库存服务),一旦这些依赖的服务出问题,你的系统也会被拖垮。

崩溃原因:第三方/下游服务响应太慢、服务宕机、接口报错 → 你的系统线程一直等待依赖服务返回结果,无法释放 → 线程池耗尽 → 你的系统也无法处理新请求,最终崩溃。

一句话总结:你在等外卖,外卖小哥迷路了,你啥也干不了,后面一堆事(比如做饭、洗碗)全堵死,最后你也没法正常生活。

三、高并发“打爆链”:标准流程(背下来,提前预防)

不管是哪种场景,系统被高并发打爆,都遵循一个固定的“打爆链”,只要能在某个环节打断这个链条,就能避免系统崩溃:

  1. 流量突增:比如秒杀活动开始、热搜爆发、爬虫攻击,请求量瞬间翻倍甚至十倍;
  2. 缓存失效/不够:缓存没有挡住足够的请求,大量请求直接砸向数据库;
  3. 数据库压力飙升:慢查询增多、锁等待时间变长、数据库连接池满;
  4. 应用线程池占满:应用线程一直等待数据库或依赖服务返回,无法释放;
  5. 超时风暴:新请求排队超时,用户和前端反复重试,请求量再次翻倍;
  6. 全链路崩溃:整个集群的CPU、内存、连接池全部耗尽,系统宕机、重启,甚至反复崩溃。

四、人话版:高并发防御“5板斧”(直接用,不用复杂配置)

搞懂了崩溃的原因和流程,防御就很简单了。下面这5个技巧,不用复杂的架构设计,中小团队半天就能落地,能应对90%的高并发场景。

(1)缓存挡在最前面,命中率90%+才算合格

缓存是防御高并发的“第一道防线”,也是最有效的一道防线。核心就是“能缓存就缓存,能不查DB就不查DB”。

具体做法:

  • 热点数据全缓存:商品、用户、配置、排行榜等,只要查询频率高,就放进Redis;
  • 优化缓存策略:过期时间加随机值(防雪崩)、热点Key本地缓存+Redis双缓存(防击穿)、不存在的数据缓存空值(防穿透);
  • 监控缓存命中率:至少保证命中率在90%以上,低于90%就说明缓存策略有问题,需要优化。

(2)数据库绝对不能裸奔,做好3个基础优化

就算缓存挡住了大部分请求,还是会有少量请求进入数据库,所以数据库的优化也必不可少,核心是“减少数据库的压力”。

具体做法:

  • 索引必须优化:禁止无索引查询,常用查询字段(比如商品ID、用户ID、订单号)必须建索引,避免全表扫描;
  • 读写分离:主库写、从库读,把读压力分散到从库,主库只负责写操作,提升写性能;
  • 控制连接池上限:给数据库连接池设置合理的上限,避免连接池耗尽,导致数据库无法处理请求。

(3)限流:直接扔掉多余的流量,别硬扛

高并发场景下,“硬扛”只会让系统崩溃,不如主动“扔掉”多余的流量,保证系统能正常处理核心请求。

具体做法:

  • 实现双重限流:单机限流(控制单个应用的请求量)+ 分布式限流(控制整个集群的请求量),推荐用Redis+Lua实现分布式限流;
  • 设置合理阈值:根据系统的承载能力,设置每秒能处理的最大请求数,超过阈值直接返回“系统繁忙,请稍后再试”,不要让多余的请求进入系统,消耗资源。

(4)熔断+降级:保核心,弃次要,留一线生机

高并发高峰期,与其让整个系统崩溃,不如主动关掉非核心功能,优先保证核心功能可用——这就是降级;遇到依赖服务挂了,就及时切断调用,避免拖垮自己——这就是熔断。

具体做法:

  • 熔断:给依赖的服务设置超时时间和失败次数阈值,一旦超时次数或失败次数达到阈值,就自动熔断,停止调用该服务,快速返回失败结果,等依赖服务恢复后,再自动恢复调用;
  • 降级:高峰期关掉非核心功能,比如电商的评论、推荐、统计、历史订单查询,把所有资源都用来支撑下单、支付、登录这些核心操作,等高峰期过后,再恢复非核心功能。

(5)异步削峰:别同步硬扛,让系统“喘口气”

很多系统被打崩,是因为大量请求同步处理,导致线程一直被占用,无法释放。异步削峰,就是让请求“快速进入、慢慢处理”,给系统“喘口气”的时间。

具体做法:

  • 能异步的全异步:下单、支付通知、短信发送、日志记录等操作,都用异步处理,前端快速返回“请求已接收”,后台用消息队列(Kafka/RocketMQ)慢慢处理;
  • 用消息队列削峰:请求高峰期,消息队列先接收所有请求,再按照系统的处理能力,慢慢把请求分发到应用中,避免请求瞬间冲击系统。

最后总结

高并发没那么神秘,本质就是“请求太多,资源太少”。系统被打爆,不是突然发生的,而是有一个循序渐进的过程,只要做好“缓存挡、数据库护、限流卡、熔断降、异步削”这5件事,就能让你的系统在高并发下稳稳运行。

对普通开发者来说,不用追求“高大上”的架构,先把这些基础的防御技巧做好,就能应对大部分高并发场景,避免系统被打爆。收藏这篇文章,下次遇到高并发问题,直接对照着做,不用慌!

不用学微服务,也能设计不崩的系统:最小可行思路

做开发的都有个误区:觉得系统要稳定,就必须上微服务。尤其是刚接触架构设计的同学,总觉得“微服务=高级、稳定”,哪怕是小团队、小项目,也硬着头皮拆微服务,最后运维搞崩、调试搞崩、部署搞崩,反而比单体系统更不稳定。

其实,对90%的中小团队、普通项目来说,稳定 ≠ 微服务;稳定 = 简单 + 边界清晰 + 防雪崩设计。先把单体做扎实、做好“防崩三板斧”,比硬上微服务强10倍。今天就分享一套“最小可行稳定架构”(MVA),不用微服务,也能让你的系统扛住百万级DAU、万级QPS,稳稳当当不崩溃。

一、为什么不用急着上微服务?

先明确一个核心认知:微服务是“解决方案”,不是“标配”。它的核心作用是解决“系统庞大、业务复杂、高并发、多团队协作”的问题,就像一剂猛药,对症才有效,乱喝只会伤身。

对小团队、小项目来说,硬上微服务只会带来3个“爆炸式”麻烦:

  • 运维爆炸:需要部署多个服务、管理服务间通信、处理服务降级熔断,运维成本直接翻倍;
  • 调试爆炸:一个问题可能跨多个服务,排查起来要翻多个服务的日志,效率极低;
  • 部署爆炸:多个服务需要协调部署顺序,稍有不慎就会出现服务依赖失败,导致整个系统不可用。

更关键的是:90%的系统崩溃,不是因为单体架构,而是因为没做缓存、没做限流、没做隔离,或是数据库设计得太烂。与其花精力搞微服务,不如先把这些基础工作做扎实。

二、最小可行稳定架构(MVA):5条铁律,直接套用

这套思路的核心是“简单、可控、防崩”,不需要复杂的架构设计,中小团队半天就能落地,落地后就能解决大部分系统稳定问题。

(1)模块化单体,不是“一坨代码”

很多人吐槽单体架构,其实吐槽的是“混乱的单体”——所有代码堆在一起,没有分层、没有模块,改一个地方牵一发而动全身。真正好用的单体,是“模块化单体”,核心是“分层清晰、模块隔离”。

具体做法很简单:

  • 代码严格分层:Controller(接收请求)→ Service(业务逻辑)→ Dao(数据访问)→ DB(数据库),每层只做自己的事,不跨层调用;
  • 业务拆分成独立模块:比如用户模块、订单模块、商品模块、支付模块,模块间只通过接口调用,绝对不允许直接操作其他模块的数据库或方法;
  • 好处:部署简单(只部署一个应用)、调试简单(问题定位在单个模块内)、扩容简单(单机扩容就能扛住大部分流量);更重要的是,以后业务增长了,要拆微服务,直接把模块拆出来就行,不用重构代码。

(2)必须加缓存:挡住80%~90%的查询请求

数据库是系统的“性能瓶颈”,尤其是读请求,一旦并发量上来,数据库很容易被打崩。而缓存,就是挡住这些读请求的“第一道防线”,能直接挡住80%~90%的查询,让数据库只处理少量的写请求和缓存未命中的读请求。

缓存的核心用法(不用复杂,做到这3点就够):

  • 热点数据全进Redis:商品信息、用户信息、配置信息、排行榜、热门文章等,只要是查询频率高、更新频率低的数据,都放进Redis;
  • 核心规则:能缓存就缓存,能不查DB就不查DB,优先从缓存获取数据,缓存未命中再查DB,查完后同步更新缓存;
  • 简单防崩技巧:缓存预热(系统启动时,提前把热点数据加载到缓存)、过期时间加随机值(避免大量缓存同一时间过期,导致缓存雪崩)、热点Key永不过期(比如首页Banner、热门商品,直接设置永不过期,更新时手动刷新)。

(3)必须做限流、降级、熔断:系统的“安全气囊”

哪怕你做了缓存,也难免遇到流量突增(比如活动、热搜、爬虫),这时候限流、降级、熔断就是系统的“安全气囊”,能在流量过载时保护系统不崩溃。

用大白话讲清楚三者的区别和用法:

  • 限流:相当于给系统“设上限”,每秒只允许一定数量的请求进入,超过这个上限就直接返回“系统繁忙,请稍后再试”,比如设置每秒最多处理1000个请求,多余的请求直接拒绝,避免系统被压垮;
  • 降级:高峰期“弃卒保车”,关掉非核心功能,优先保证核心功能可用。比如电商高峰期,关掉评论、推荐、统计这些非核心功能,把所有资源都用来支撑下单、支付、登录这些核心操作;
  • 熔断:当依赖的服务(比如第三方支付、短信接口)挂了或者响应太慢时,自动切断调用,不反复重试,快速返回失败结果,避免因为等待依赖服务而耗尽系统线程,导致整个系统卡死。

(4)资源隔离:别让一个功能拖垮整个系统

很多系统崩溃,都是“连锁反应”——一个功能出问题,导致整个系统不可用。比如订单接口慢,导致线程池满,进而导致登录、商品查询等所有接口都慢,最后全站卡死。而资源隔离,就是避免这种连锁反应的关键。

核心隔离手段(3个最实用的):

  • 线程池隔离:给不同的业务模块分配独立的线程池,比如订单模块用一个线程池,支付模块用一个线程池,查询模块用一个线程池,一个模块的线程池满了,不会影响其他模块;
  • 数据库隔离:核心业务数据库(比如订单库、用户库)和非核心数据库(比如日志库、统计库)分开,不共用一个连接池,避免非核心业务的查询拖慢核心数据库;
  • 读写分离:数据库主库负责写操作(新增、修改、删除),从库负责读操作(查询),把读压力分散到从库,避免读请求影响写请求的性能。

(5)监控+告警:从第一天就必须有

很多系统崩溃后,开发者还不知道,直到用户投诉才发现——这就是没有监控和告警的后果。监控和告警,是系统稳定的“眼睛”,能让你在问题出现的第一时间发现,及时处理,避免问题扩大。

必看的7个核心指标(少一个都不行):

  • 系统指标:CPU使用率、内存使用率、磁盘使用率;
  • 接口指标:QPS(每秒请求数)、响应时间、错误率;
  • 数据库指标:数据库连接数、慢查询数量、锁等待时间;
  • 缓存指标:缓存命中率、缓存过期数量、缓存报错数量。

告警规则:给每个指标设置阈值,超过阈值就立即告警(比如CPU使用率超过80%、响应时间超过500ms、错误率超过1%),告警方式优先选短信+企业微信/钉钉,确保能第一时间收到通知。

三、最小可行架构Checklist(直接复制使用)

落地完成后,对照这个Checklist检查一遍,确保没有遗漏,做到这些,你的系统基本不会崩:

  • ✅ 采用模块化单体,分层清晰、模块隔离
  • ✅ Redis缓存全覆盖热点数据,缓存命中率≥90%
  • ✅ 接口实现单机+分布式限流,设置合理阈值
  • ✅ 实现熔断降级,优先保证核心业务可用
  • ✅ 数据库做好索引优化、读写分离,控制连接池上限
  • ✅ 部署监控+告警,核心指标全覆盖
  • ✅ 关键组件(数据库、Redis)做主备,避免单点故障

最后总结一句:对中小团队来说,“简单可控”比“高大上”更重要。不用盲目追求微服务,先把上面这7条做好,你的系统在百万级DAU、万级QPS下,基本能稳稳运行,比硬上微服务更省心、更稳定。

类型与语法的“直觉对齐”:TS 切入的 Go 语言初体验

🚀 省流助手(速通结论)

  • 物理顺序声明:Go 没有任何形式的声明置后。:= 是声明+推导+赋值的原子操作,它必须在逻辑读取前完成“内存占位”。
  • 零值机制:彻底消灭 undefined。变量永远有初值(0, "", false),这种“零值机制”让防御性编程不再靠猜。
  • 引号分级:单引号 ' 是数字(Rune),双引号 " 是字符串,反引号 ` 只是纯文本“复印机”,不支持 ${} 插值。
  • Map 门槛:Go 的 map 行为对标 new Map()。禁止在 nil(未 make)状态下写入,否则程序直接崩溃。

1. 变量声明与提升:绝对的物理顺序

在 TS 中,由于其复杂的编译背景,我们有时会下意识地混淆“声明”与“可见性”。但在 Go 面前,物理行号即是编译器的唯一识别边界。Go 编译器是单向阅读的,不存在任何形式的标识符预扫描。

TypeScript(现代规范:先声明后使用)

function initSystem() {
    // 现代 TS 严格要求先声明后使用,否则触发暂时性死区 (TDZ)
    const isDevelopment = process.env.NODE_ENV === 'development';
    
    if (isDevelopment) { 
        console.log("Debug Mode");
    }
}

Go(严格逻辑:物理行号即生死线)

func main() {
    // 虽然 TS 也要先声明,但 Go 的 := 是一种更彻底的“原地占位”
    // 在这一行之前,isDev 这个标识符在当前作用域内完全不存在
    
    isDev := os.Getenv("ENV") == "dev" 
    if isDev {
        fmt.Println("Debug Mode")
    }
}

🪝 思维钩子:Go 没有“回头路”。:= 不仅是赋值,它是声明+推导+内存分配的原子操作。在执行这一行前,该变量名在编译器眼里尚未被“拨备”,这要求你在重构代码块时必须保持极强的线性逻辑。


2. 零值 vs Undefined:再见,运行时空指针

TS 程序员的一半生命都在处理 Cannot read property of undefined。Go 认为“不可预测的空”是程序不稳定的根源,因此引入了强悍的零值机制。

TypeScript(不可预测的初始状态)

let count: number;
// 在 TS 中,仅声明不赋值会导致变量处于 undefined
// 即使开启了 strictPropertyInitialization,也常在复杂场景下产生运行时不确定性

Go(确定的物理起始点)

var count int
fmt.Println(count) // ✅ 0 (内存已自动初始化填零)

var name string
fmt.Println(name)  // ✅ "" (空字符串,不是 nil)

🪝 思维钩子:在 Go 中,有类型必有初值。变量被创造的那一刻,它就处于一个可预测、可参与运算的起始状态。这种“内存填零”的承诺,让你不再需要猜测变量是否被“填充”过。


3. 引号的阶级森严:被类型锁死的语义

在 TS 中,引号是风格问题(Linter 说了算);在 Go 中,引号是指令(编译器说了算)。

TypeScript(风格自由)

const s = 'Hello';          // ✅ 常用
const message = `Value: ${v}`; // ✅ 模板字符串插值

Go(类型锁死)

s := "Hello" // ✅ 字符串必须双引号

// char := 'Hello' // ❌ 编译报错:单引号不能包多个字符
char := 'H'        // ✅ 这是 int32 类型 (代表数字 72)

raw := `Raw Text`  // ✅ 原始文本,但不支持 ${} 插值

🪝 思维钩子:单引号 = 数字。如果你在 Go 里用单引号包了一串字符,编译器会认为你试图在一个存储单个字符(Rune)的容器里强塞一段序列。


4. 集合的真身:Map 的内存门槛

在 TS 中,对象 {} 可以承载绝大部分映射需求。但在 Go 中,必须区分“标识符声明”与“内存空间分配”。

TypeScript(动态初始化)

const cache: Record<string, number> = {};
cache["token"] = 123; // ✅ 随时随地,直接写入

Go(引用类型需 make)

var cache map[string]int // ⚠️ 只是声明了名字,内存指针仍是 nil

// cache["token"] = 123  // ❌ 运行时崩溃 (Panic!)

cache = make(map[string]int) // ✅ 必须使用 make 分配底层哈希表空间
cache["token"] = 123

🪝 思维钩子:Go 的 map 对标的是 TS 的 new Map()。在 TS 里 {} 是个空盒子;在 Go 里 nil map 是一个尚未制造出来的盒子。向一个不存在的地方放东西,程序直接奔着崩溃去。


下篇预告:
下一篇我们将进入 Go 逻辑组织的核心:结构体方法(Receiver)、权限控制(大小写)以及最关键的内存控制——指针。为什么 a := b 后改 a 却不动 b?我们下一篇见。

你的 Vue v-for,VuReact 会编译成什么样的 React 代码?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-for 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-for 指令用法。

编译对照

基础数组遍历

最简单的 v-for 指令,用于遍历数组并渲染列表项。

  • Vue 代码:
<li v-for="(item, i) in list" :key="item.id">{{ i }} - {{ item.name }}</li>
  • VuReact 编译后 React 代码:
{
  list.map((item, i) => (
    <li key={item.id}>
      {i} - {item.name}
    </li>
  ));
}

从示例可以看到:Vue 的 v-for 指令被编译为 React 的 map 函数。VuReact 采用 数组映射编译策略,将模板指令转换为 JSX 数组表达式,完全保持 Vue 的列表渲染语义——遍历数组中的每个元素,生成对应的 JSX 元素,并自动处理 key 属性以保证 React 的渲染性能。


对象遍历

v-for 也可以用于遍历对象的属性和值。

  • Vue 代码:
<li v-for="(val, key, i) in obj" :key="key">{{ i }} - {{ key }}: {{ val }}</li>
  • VuReact 编译后 React 代码:
{
  Object.entries(obj).map(([key, val], i) => (
    <li key={key}>
      {i} - {key}: {val}
    </li>
  ));
}

对于对象遍历,VuReact 采用 Object.entries 转换策略,将 Vue 的对象遍历语法转换为 Object.entries(obj).map() 形式。这种编译方式完全模拟 Vue 的对象遍历语义——按顺序遍历对象的键值对,保持 (值, 键, 索引) 的参数顺序,确保数据渲染的一致性。


嵌套 v-for 循环

复杂的嵌套列表渲染,使用多层 v-for 循环。

  • Vue 代码:
<div v-for="category in categories" :key="category.id">
  <h3>{{ category.name }}</h3>
  <ul>
    <li v-for="product in category.products" :key="product.id">
      {{ product.name }} - ${{ product.price }}
    </li>
  </ul>
</div>
  • VuReact 编译后 React 代码:
{
  categories.map((category) => (
    <div key={category.id}>
      <h3>{category.name}</h3>
      <ul>
        {category.products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  ));
}

对于嵌套循环,VuReact 采用 嵌套 map 函数编译策略,将 Vue 的嵌套 v-for 转换为嵌套的 map 函数调用。这种编译方式完全保持 Vue 的嵌套循环语义——外层循环的每个迭代都会创建内层循环的完整列表,保持组件结构的层次关系。


v-if + v-for

实际业务中经常需要结合条件进行列表渲染。

  • Vue 代码:
<template v-if="cond" v-for="user in users" :key="user.id">
  <img :src="user.avatar" :alt="user.name" />
  <div class="user-info">
    <h4>{{ user.name }}</h4>
    <p>{{ user.email }}</p>
    <span class="role-badge">{{ user.role }}</span>
  </div>
  <div class="user-actions">
    <button @click="editUser(user.id)">编辑</button>
    <button @click="deleteUser(user.id)" class="danger">删除</button>
  </div>
</template>
  • VuReact 编译后 React 代码:
{
  cond
    ? users.map((user) => (
        <div key={user.id} className="user-card">
          <img src={user.avatar} alt={user.name} />
          <div className="user-info">
            <h4>{user.name}</h4>
            <p>{user.email}</p>
            <span className="role-badge">{user.role}</span>
          </div>
          <div className="user-actions">
            <button onClick={() => editUser(user.id)}>编辑</button>
            <button onClick={() => deleteUser(user.id)} className="danger">
              删除
            </button>
          </div>
        </div>
      ))
    : null;
}

对于带条件的列表渲染,VuReact 展示了智能的条件编译能力

  1. 优先条件编译:将 v-if 转换为三元表达式,包裹整个 v-for 渲染结果
  2. 自动提取 key:当 <template> 标签上存在 :key 属性时,会自动将其传递给内部的第一个子元素
  3. 事件绑定处理@click 转换为 onClick,并自动包装为箭头函数以传递参数
  4. 属性绑定转换:src:alt 等转换为 React 属性语法
  5. 样式类名处理class 转换为 className,符合 React 规范

VuReact 的编译策略完全保持 Vue 的列表渲染语义,同时生成符合 React 最佳实践的代码。


使用 v-for 范围值

Vue 的 v-for 也支持使用数字范围进行迭代。

  • Vue 代码:
<span v-for="n in 5" :key="n">{{ n }}</span>
  • VuReact 编译后 React 代码:
{
  Array.from({ length: 5 }, (_, n) => (
    <span key={n + 1}>{n + 1}</span>
  ));
}

对于范围值迭代,VuReact 采用 Array.from 转换策略,将 Vue 的数字范围语法转换为数组生成和映射。这种编译方式完全模拟 Vue 的范围迭代语义——从 1 开始到指定数字结束(包含),保持迭代顺序和数值的一致性。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

零成本打造专业域名邮箱:Cloudflare + Gmail 终极配置保姆级全攻略

大家好,我是 Immerse

专注分享 AI 玩法独立开发AI 出海的 AGI 实践者,更多干货欢迎关注公众号 #沉浸式AI 或访问 yaolifeng.com


如果你手上有自己的域名,只是想把 hi@你的域名.com 用起来收发邮件,又不想每年给 Google Workspace 或 Microsoft 365 交钱,这套 Cloudflare Email Routing + Gmail 现在依然够用。

Cloudflare Email Routing 负责把别人发到你域名邮箱的邮件转进 Gmail。Gmail 负责从这个地址回信。

它不是完整企业邮箱,但拿来放在博客、作品集、简历、联系页、独立开发者官网上,已经很够用了。

主要边界

  • 能做到:别人发到你的域名邮箱,你在 Gmail 里收到;你也能在 Gmail 里用这个域名地址发信和回信
  • 做不到:Cloudflare 不给你真正的邮箱空间,也不提供发信服务器;多人协作、共享日历、管理员后台,这套都没有
  • 要注意:Cloudflare 开启 Email Routing 时会接管你这个域名的邮件 MX 记录。如果你这个域名已经在用腾讯企业邮、阿里企业邮、Google Workspace,不要直接点开通

如果你要一个看起来专业、能正常往来邮件的联系邮箱,这套很合适。你要的是团队邮箱系统,那就别省这点钱,直接上正式服务。

准备资料

  1. 一个已经接入 Cloudflare DNS 的域名
  2. 一个你能正常登录的 Gmail 账号
  3. 能开启 Google 两步验证
  4. 一个备用邮箱用来做测试,QQOutlook、另一个 Gmail 都行

部分参考文档:

先把收件打通

先做最小可用版:让别人发到 hi@你的域名.com 的邮件,能够进你的 Gmail。

  1. 登录 Cloudflare,进入你的域名,打开 Email -> Email Routing
  2. 第一次开通时,Cloudflare 会让你添加或替换邮件相关的 MXTXT 记录。这里要认真看一眼,如果面板提示要删除旧的 MX,就代表旧邮箱服务会一起停掉
  3. 开通后,去 Routing rules 里创建一个自定义地址。比如把 hi@你的域名.com 转发到 yourname@gmail.com
  4. Cloudflare 会往你的 Gmail 发一封验证邮件。点掉验证之前,这条转发规则不算真的生效
  5. 验证完成后,用另一个邮箱发一封测试邮件到你的域名邮箱,不要直接用同一个 Gmail 自己给自己发,很多时候会把你绕晕

你看到的正确结果应该是这样的:

  • Gmail 收到了这封信
  • 收件人还是你的域名邮箱,不是裸露的 Gmail
  • Cloudflare 面板里的规则状态是已启用

小建议:有建明确地址,不要一上来开 Catch-allCatch-all 很方便,但也很容易把拼错地址、垃圾邮件、爬虫乱发的邮件一起吞进来。大多数人先用 hicontacthello 这种固定地址就够了。

再把发件打通

收件只是前半段。那怎么用对应的这个域名发出去。

注意,Cloudflare 不负责发信。它只是把邮件转进来。真正往外发信的,还是 Gmail,或者你后面换掉的别家 SMTP 服务。

开 Google 两步验证,生成应用专用密码

Gmail 不会让你直接拿账号密码去配 SMTP。你要用的是应用专用密码。

  1. 打开 Google 账号的安全页,先把两步验证开起来
  2. 开完以后,再进入 应用专用密码
  3. 新建一个密码,名字随便写,比如“域名邮箱”
  4. Google 会给你一串 16 位密码,保存下来

如果找不到“应用密码”入口,可能的两个原因:

  • 你的账号还没开两步验证
  • 你开了 Advanced Protection,Google 官方说明里明确写了,这种模式下应用专用密码不可用

碰到第二种情况,就别死磕了。要么退出 Advanced Protection,要么直接跳到后面的“换第三方 SMTP”方案。

在 Gmail 里添加对应的发件域名

  1. 打开 Gmail,点右上角设置,进入“查看所有设置”
  2. 打开 账号和导入
  3. 在“用这个地址发送邮件”里点“添加其他电子邮件地址”
  4. 名字填你想展示给别人的名字,邮箱填你的域名地址,比如 hi@你的域名.com
  5. Treat as an alias 这个选项,默认保留勾选就行。你本来就是在给同一个人加另一个发件地址
  6. SMTP 配成下面这样
SMTP 服务器:smtp.gmail.com
端口:465
用户名:你的 Gmail 完整地址
密码:刚刚生成的 16 位应用专用密码
加密方式:SSL

如果 465 + SSL 连不上,再试一次 587 + TLS。这是 Google 官方文档里也支持的组合。

  1. 提交后,Gmail 会往你的域名邮箱发一封确认邮件
  2. 这封确认邮件会先到 Cloudflare,再转发进你的 Gmail
  3. 点掉确认链接,或者把验证码填回去,这个发件地址就能用了

配置自动选择回复邮件

注意,如果你想要别人给你发了邮件,你想用对应的邮件回复,这一步设置是必须的。

设置下面这一项

测试整个流程

用其他邮箱,比如 QQ,163 邮箱测试一遍

  1. 用另一个邮箱发信到 hi@你的域名.com
  2. 去 Gmail 收件箱里打开这封信,直接点回复
  3. 发出去之前,看一眼 From,确认显示的是你的域名邮箱
  4. 回到对方邮箱,确认能收到回复

到这一步,主流程就已经跑通了。

对方为什么还会看到 via gmail.com 或者 on behalf of

这不是你哪里配错了,很多时候是 Gmail 这条发信链路本来就有这个边界。

你现在这套方案里:

  • Cloudflare 负责收件转发
  • Gmail 负责真正把邮件发出去

所以在部分收件客户端里,对方可能会看到你的 Gmail 痕迹,比如 via gmail.com,或者 yourname@gmail.com on behalf of hi@你的域名.com

这在个人沟通、博客联系邮箱、独立开发者业务往来里通常问题不大

最容易踩的几个坑

  • Cloudflare 规则建好了但一直收不到邮件。先看目标 Gmail 有没有点验证邮件,没验证就不会真正转发
  • 一开通 Email Routing,原来的企业邮箱突然废了。因为 MX 已经被 Cloudflare 接管了
  • Gmail 一直提示用户名或密码错误。这里填的不是你的 Google 登录密码,是 16 位应用专用密码
  • Google 账号里根本没有应用专用密码。先查两步验证,再查是不是开了 Advanced Protection
  • 你给自己发测试邮件,觉得没收到。先换一个别的邮箱测,自己给自己发最容易被 Gmail 的会话和去重逻辑干扰判断
  • 对方看到 via gmail.com。这通常不是配置错,是 Gmail SMTP 的边界

真要说这套配置最难的地方,也就两个:一个是别把 MX 记录改糊涂,另一个是 Gmail 一定要用应用专用密码。

大人工智能时代下前端界面全新开发模式的思考(五)

第五章:角色的重构——AI时代前端工程师的核心竞争力

当工具改变时,工作方式必然改变;当工作方式改变时,职业定位必然重构。在AI时代,前端工程师的角色正在经历深刻的转变。这一章我们将探讨这种转变的内涵、必要的能力模型,以及未来的发展方向。


5.1 能力模型的转变:从"写代码"到"驾驭AI"

5.1.1 传统能力 vs 新兴能力对比

传统能力 重要性变化 新兴能力 培养方式
手写代码速度 ↓↓ 大幅降低 需求理解与拆解 ↑↑ 业务分析训练
语法记忆 ↓↓↓ 几乎不再重要 Prompt Engineering ↑ 系统学习 + 实践
框架熟练度 ↓ 适度下降 架构设计能力 ↑↑ 理论学习 + 项目实践
Debug能力 → 保持重要 AI结果审查能力 ↑↑↑ Code Review训练
CSS细节掌握 ↓ 适度下降 用户体验敏感度 ↑↑ 设计思维训练
业务理解 ↑ 更加重要 系统思维能力 ↑↑ 跨领域学习
学习能力 ↑↑ 至关重要 AI工具学习能力 ↑↑↑ 持续实践
代码优化 ↓ 适度下降 质量把控能力 ↑↑ 建立检查清单
文档编写 ↓ 适度下降 知识萃取能力 ↑ 写作训练
团队协作 ↑ 更加重要 AI协作能力 ↑↑ 流程设计

5.1.2 为什么这些能力在变化?

1. 手写代码速度不再重要

AI可以在几秒内生成几十行代码。人类的打字速度不再是瓶颈。重点转向"写什么"而非"怎么写"。

案例

任务:写一个表单验证函数

传统方式:
- 思考验证逻辑(5分钟)
- 手写代码(10分钟)
- 调试(5分钟)
- 总计:20分钟

AI方式:
- 写Prompt(1分钟)
- AI生成(10秒)
- 审查修改(3分钟)
- 总计:4分钟

效率提升:5倍

2. Prompt Engineering成为核心技能

与AI沟通需要学习新的"语言"。好的Prompt可以让AI输出质量提升10倍。

Prompt质量的差异

❌ 低效Prompt(输出质量60分):
"写一个登录组件"

✅ 高效Prompt(输出质量90分):
"创建一个企业级登录表单组件

技术栈:React 18 + TypeScript + Tailwind CSS + shadcn/ui

功能要求:
1. 表单字段:邮箱(必填,验证格式)、密码(必填,8+字符)
2. 实时验证:失去焦点时验证,显示错误信息
3. 记住我功能:使用localStorage持久化
4. 加载状态:提交时显示Spinner,禁用按钮
5. 错误处理:显示API返回的错误信息

UI要求:
1. 居中卡片布局,最大宽度400px
2. 深色主题支持
3. 移动端适配

可访问性:
1. 所有输入框关联label
2. 错误信息使用aria-describedby
3. 支持键盘导航

代码规范:
1. 使用函数组件和Hooks
2. 完整TypeScript类型定义
3. 添加JSDoc注释
4. 导出为可复用组件"

3. AI结果审查能力至关重要

AI生成的代码可能有错误、安全漏洞、性能问题。能够快速识别这些问题成为核心竞争力。

这需要:

  • 深厚的技术功底(知道什么是好的代码)
  • 安全意识(识别潜在漏洞)
  • 性能敏感度(发现性能陷阱)
  • 可访问性知识(检查a11y问题)

5.1.3 新能力培养路线图

短期(1-3个月):掌握基础

## 第一个月:工具熟练

Week 1-2: IDE集成工具
├─ 安装并配置Cursor或GitHub Copilot
├─ 学习快捷键和核心功能
├─ 建立个人Prompt库
└─ 目标:AI生成代码占日常编码30%

Week 3-4: 设计转代码工具
├─ 注册v0.dev账号
├─ 实践5个不同类型的UI生成
├─ 学习如何将v0代码集成到项目
└─ 目标:原型开发速度提升50%

## 第二个月:Prompt工程

Week 5-6: Prompt基础
├─ 学习结构化Prompt写法
├─ 掌握Few-shot示例技巧
├─ 理解上下文管理
└─ 目标:Prompt质量达到80分

Week 7-8: 高级Prompt技巧
├─ 链式Prompt(Chain-of-Thought)
├─ 多模态Prompt(图文混合)
├─ Prompt模板化
└─ 目标:建立团队Prompt库

## 第三个月:质量把控

Week 9-10: 代码审查
├─ 建立个人审查清单
├─ 学习识别AI常见错误模式
├─ 实践审查20+ AI生成代码
└─ 目标:审查时间<10分钟/组件

Week 11-12: 安全与性能
├─ 学习常见安全漏洞
├─ 掌握性能优化技巧
├─ 建立安全检查自动化
└─ 目标:AI代码零安全漏洞上线

中期(3-12个月):深化能力

## 第4-6个月:架构设计

1. AI-Native应用架构
   ├─ 学习Vercel AI SDK深度使用
   ├─ 掌握Streaming UI设计模式
   ├─ 理解Tool Calling系统设计
   └─ 实践项目:构建AI聊天应用

2. 多Agent协作
   ├─ 学习Agent设计模式
   ├─ 理解Orchestrator架构
   ├─ 实践Multi-Agent系统
   └─ 实践项目:构建AI工作流系统

## 第7-9个月:质量体系建设

1. 自动化质量门禁
   ├─ 建立CI/CD流水线
   ├─ 集成安全扫描(SAST)
   ├─ 集成性能测试(Lighthouse CI)
   └─ 实践项目:建立团队质量标准

2. Prompt资产管理
   ├─ 建立团队Prompt库
   ├─ 制定Prompt编写规范
   ├─ 建立Prompt版本管理
   └─ 实践项目:构建Prompt管理系统

## 第10-12个月:领导与影响

1. 团队赋能
   ├─ 培训团队成员使用AI工具
   ├─ 建立AI使用最佳实践
   ├─ 推动AI流程标准化
   └─ 目标:团队效率提升30%

2. 行业影响
   ├─ 撰写技术博客分享经验
   ├─ 参与开源项目贡献
   ├─ 技术演讲和分享
   └─ 目标:建立个人技术品牌

长期(1-3年):成为专家

## Year 1: AI系统架构师

目标:能够设计AI原生前端系统

关键里程碑:
- 主导2+ AI原生项目架构
- 设计并实施团队AI开发流程
- 建立可复用的AI组件库
- 发表3+ 技术文章或演讲

## Year 2: 跨领域专家

目标:融合技术、产品、设计能力

关键里程碑:
- 具备产品思维,能独立设计产品
- 具备设计能力,能进行UI/UX设计
- 理解业务,能与业务方深度沟通
- 建立跨领域影响力

## Year 3: 行业引领者

目标:成为行业认可的AI前端专家

关键里程碑:
- 出版技术书籍或课程
- 在顶级技术会议演讲
- 建立行业标准或规范
- 培养下一代AI前端工程师

5.2 从"创造者"到"策展人"的角色转变

5.2.1 角色的本质转变

传统角色:代码的创造者(Creator)
├─ 从0开始编写每一行代码
├─ 对代码有完全的控制权
├─ 工作是线性的:需求 → 编码 → 测试 → 交付
├─ 技能重点:语法、框架、算法
└─ 价值体现:编码速度和代码质量

新角色:AI输出的策展人(Curator)
├─ 指导AI生成代码,然后筛选、修改、整合
├─ 对代码有审核和决策权
├─ 工作是循环的:需求 → AI生成 → 审查 → 反馈 → 再生成 → ...
├─ 技能重点:需求理解、Prompt工程、质量把控
└─ 价值体现:决策质量和最终交付质量

5.2.2 策展人的核心工作

1. 需求翻译(Requirement Translation)

将业务需求转化为AI可以理解的Prompt。

业务需求(模糊):
"做一个用户管理功能"

策展人拆解:
1. 功能需求:
   ├─ 展示用户列表(姓名、邮箱、角色、状态)
   ├─ 支持搜索(按姓名、邮箱)
   ├─ 支持筛选(按角色、状态)
   ├─ 支持分页(每页10/20/50条)
   ├─ 支持行内编辑(姓名、邮箱、角色)
   └─ 支持删除(带确认对话框)

2. 技术需求:
   ├─ 使用React + TypeScript
   ├─ 使用shadcn/ui组件库
   ├─ 使用React Query进行数据获取
   ├─ 实现乐观更新
   └─ 处理加载状态和错误状态

3. 可访问性需求:
   ├─ 所有交互元素支持键盘导航
   ├─ 屏幕阅读器友好
   ├─ 颜色对比度符合WCAG 2.1 AA
   └─ 焦点管理正确

4. 性能需求:
   ├─ 列表虚拟滚动(数据量大时)
   ├─ 搜索防抖(300ms)
   ├─ 图片懒加载
   └─ Bundle大小<100KB

转化为Prompt:
"创建一个企业级用户管理表格组件...(详细Prompt见第三章)"

2. 质量把控(Quality Assurance)

审查AI生成代码的正确性、性能、安全、可维护性。

## 质量把控检查清单

### 功能正确性
- [ ] 是否实现了所有需求?
- [ ] 边界情况是否处理?
- [ ] 错误处理是否完善?
- [ ] 状态管理是否正确?

### 代码质量
- [ ] 是否符合团队代码规范?
- [ ] 是否有重复代码?
- [ ] 命名是否清晰?
- [ ] 注释是否充分?

### 性能
- [ ] 是否有不必要的重渲染?
- [ ] 是否正确使用useMemo/useCallback?
- [ ] 是否有内存泄漏风险?
- [ ] Bundle大小是否合理?

### 安全
- [ ] 是否有XSS风险?
- [ ] 用户输入是否验证?
- [ ] 敏感操作是否有权限检查?
- [ ] 是否有CSRF防护?

### 可访问性
- [ ] 是否有alt属性?
- [ ] 是否有label关联?
- [ ] 是否支持键盘导航?
- [ ] 颜色对比度是否足够?

3. 创意指导(Creative Direction)

提供设计方向和用户体验建议,在多个AI生成方案中做出选择。

场景:AI生成了3个不同的按钮设计方案

方案A:传统扁平设计
方案B:新拟物化设计(Neumorphism)
方案C:玻璃拟态设计(Glassmorphism)

策展人决策:
├─ 考虑品牌调性:企业级产品,需要稳重感
├─ 考虑用户群体:B端用户,注重效率而非视觉
├─ 考虑可维护性:方案A最成熟,生态最好
├─ 考虑实现成本:方案A开发成本最低
└─ 决策:选择方案A,但在hover状态添加微动效提升体验

4. 最终决策(Final Decision)

决定哪些代码可以进入生产环境,对技术选型和架构方案拍板。

决策责任:
- 代码是否合并到主分支?
- 技术选型是否采用?
- 架构方案是否可行?
- 项目是否按期交付?

承担最终的质量责任和业务责任

5.2.3 角色转变的挑战

挑战1:控制欲的释放

传统思维:"我自己写代码,完全可控"
新思维:"我指导AI写代码,信任但要验证"

困难:
- 不放心AI生成的代码
- 总想手动修改每一处
- 效率提升不明显

解决方案:
- 建立质量门禁,信任检查结果
- 从小功能开始,逐步建立信心
- 记录AI错误模式,针对性改进Prompt

挑战2:价值感的重构

传统价值:"我写了1000行代码,很有成就感"
新价值:"我通过AI完成了一个功能,效率提升5倍"

困难:
- 感觉"不是自己写的"
- 成就感降低
- 职业价值感危机

解决方案:
- 重新定义价值:从"写代码"到"解决问题"
- 关注业务价值:功能上线、用户满意
- 关注团队价值:知识分享、流程优化

挑战3:学习焦虑

焦虑:
"每天都有新工具,学不过来"
"不学AI会被淘汰,学了又担心基础退化"
"不知道应该学哪个工具"

解决方案:
- 聚焦核心:掌握1-2个主力工具即可
- 基础为王:技术基础比工具更重要
- 持续学习:建立学习习惯,而非突击学习

5.3 人机协作的新模式:70/30法则

5.3.1 70/30法则的定义

AI负责"从0到70%":
- 快速原型搭建
- 样板代码生成
- 文档和注释编写
- 测试用例生成
- 常见功能实现
- 格式化代码
- 简单重构

人类负责"70到100%":
- 业务逻辑打磨
- 边缘情况处理
- 性能优化
- 安全加固
- 可访问性完善
- 架构设计
- 最终质量把控

这个比例不是固定的,而是根据任务复杂度和团队成熟度动态调整:

简单任务(如工具函数):
AI: 90% → 人类: 10%(主要是审查)

中等任务(如表单组件):
AI: 70% → 人类: 30%(审查+优化)

复杂任务(如状态管理库):
AI: 50% → 人类: 50%(AI辅助,人类主导)

核心任务(如架构设计):
AI: 30% → 人类: 70%(AI参考,人类决策)

5.3.2 协作流程示例

任务:开发一个电商购物车功能

Step 1: 人类拆解需求(人类主导)
策展人:
├─ 功能拆解:
│   ├─ 添加商品到购物车
│   ├─ 修改商品数量
│   ├─ 删除商品
│   ├─ 计算总价(含优惠)
│   ├─ 持久化(localStorage + API同步)
│   └─ 购物车UI(侧边栏/页面)
├─ 技术选型:
│   ├─ 状态管理:Zustand
│   ├─ UI库:shadcn/ui
│   ├─ 动画:Framer Motion
│   └─ 数据获取:React Query
└─ 生成详细Prompt

Step 2: AI生成初稿(AI主导 70%)
AI生成:
├─ CartContext和Provider
├─ useCart Hook(基础CRUD)
├─ CartItem组件
├─ CartSummary组件
├─ 基础样式
└─ 简单测试用例

Step 3: 人类审查和补充(人类主导 30%)
策展人:
├─ 审查AI代码
├─ 添加优惠计算逻辑(满减、折扣码)
├─ 处理库存不足情况
├─ 添加错误边界
├─ 优化动画细节
├─ 完善可访问性
└─ 优化性能(虚拟列表、防抖)

Step 4: AI辅助优化(AI辅助)
AI帮助:
├─ 优化useMemo/useCallback使用
├─ 生成更多测试用例
├─ 优化类型定义
└─ 生成使用文档

Step 5: 人类最终确认(人类主导)
策展人:
├─ 最终Code Review
├─ 功能测试
├─ 性能测试
├─ 合并到主分支
└─ 部署上线

总耗时:
传统方式:3天
AI协作方式:1天
效率提升:3

5.3.3 协作模式演进

阶段1: AI辅助(Assisted)
├─ AI帮助代码补全
├─ AI帮助文档生成
├─ 核心逻辑人工编写
└─ 比例:AI 30% : 人类 70%

阶段2: AI协作(Collaborative)← 当前推荐
├─ AI生成工具函数
├─ AI帮助重构
├─ 人工审查和修改
└─ 比例:AI 70% : 人类 30%

阶段3: AI主导(AI-Driven)- 谨慎采用
├─ AI自动生成大部分代码
├─ 人工主要审查
├─ 适用于探索性项目
└─ 比例:AI 90% : 人类 10%

阶段4: AI自主(Autonomous)- 未来展望
├─ AI理解需求自动完成
├─ 人工只需最终确认
├─ 适用于标准化任务
└─ 比例:AI 95% : 人类 5%

建议:大多数团队应保持在阶段2

5.4 不可替代的人类价值

尽管AI能力越来越强,但在前端领域,仍有一些价值是AI难以替代的。

5.4.1 用户体验的守护者

AI能生成"能用"的UI,但生成不了"好用"的UI。

AI能做到:
✓ 按照设计稿精确还原界面
✓ 实现交互逻辑
✓ 保证功能正确
✓ 遵循设计规范

AI难以做到:
✗ 理解用户的情感需求
✗ 感知微妙的体验细节
✗ 权衡不同设计方案的优劣
✗ 创造令人惊喜的体验
✗ 处理文化差异和本地化

案例分析

场景:设计一个支付流程

AI生成版本:
├─ 功能完整:选择支付方式、输入信息、确认支付
├─ 逻辑正确:验证、扣款、跳转
└─ 但:没有任何情感设计,冷冰冰的流程

人类优化版本:
├─ 添加信任元素:安全标识、加密提示
├─ 减少焦虑:进度指示、明确反馈
├─ 错误友好:具体错误提示、解决方案
├─ 成功庆祝:动画反馈、感谢语
├─ 情感化文案:"感谢您的支持"而非"支付成功"
└─ 结果:转化率提升15%

5.4.2 审美与同理心

这是人类相对于AI的最后堡垒。

审美判断

AI可以生成:
- 符合设计规范的配色
- 对齐良好的布局
- 标准的字体层级

但无法判断:
- 这个蓝色是否"太冷"了?
- 这个间距是否"太紧"了?
- 这个动画是否"太花哨"了?
- 整体感觉是否"优雅"?

同理心

场景:设计一个面向老年人的健康应用

AI会:
- 按照标准设计规范生成界面
- 使用常见的交互模式

人类会考虑:
- 字体要更大(老年人视力下降)
- 对比度要更高
- 按钮要更大,容易点击
- 操作步骤要简化
- 错误提示要明确,给出具体指引
- 避免使用技术术语
- 考虑网络环境,支持离线使用

5.4.3 创造性突破

AI擅长模式化工作,但不擅长创新性设计。

AI擅长(模式化):
✓ 标准组件的搭建
✓ 常见布局的实现
✓ 已有功能的复制
✓ 基于示例的生成

人类擅长(创造性):
✓ 创新性的交互模式
✓ 突破性的视觉设计
✓ 跨领域的灵感融合
✓ 颠覆性的产品概念
✓ 解决新问题的新方法

创造性案例

案例:Apple的3D Touch

这不是AI能想到的:
- 重新定义了手机交互
- 创造了Peek和Pop模式
- 启发了整个行业的交互创新

需要:
- 对用户行为的深度观察
- 对技术可能性的创造性思考
- 对体验的极致追求
- 勇于尝试和冒险

5.4.4 复杂的权衡决策

工程决策往往涉及多因素的复杂权衡:

场景:选择前端框架

AI可以列出:
- React的优缺点
- Vue的优缺点
- Angular的优缺点
- Svelte的优缺点

但难以做出最终决策,因为需要权衡:

技术因素:
├─ 团队现有技术栈和经验
├─ 项目的长期演进方向
├─ 性能需求
└─ 与后端技术的配合

业务因素:
├─ 招聘市场的技术人才分布
├─ 第三方生态的成熟度
├─ 商业支持(如Vercel对Next.js的支持)
└─ 客户或监管要求

团队因素:
├─ 团队成员的学习成本
├─ 现有代码库的迁移成本
├─ 团队的技术偏好
└─ 团队规模和发展阶段

时间因素:
├─ 项目deadline
├─ 技术债的累积速度
└─ 市场窗口期

需要综合考虑所有这些因素,做出最优决策

5.5 小结:重新定义前端工程师

在AI时代,前端工程师的定义正在从"实现UI的开发者"扩展为"连接用户与系统的体验设计师"。

5.5.1 新的定位

不只是写代码,更是

  • 设计体验
  • 创造价值
  • 技术决策
  • 质量把控
  • 团队协作

5.5.2 核心竞争力公式

AI时代前端工程师价值 = 
  技术深度 × AI工具熟练度 × 业务理解 × 创造力 × 学习能力

各项满分10分:
- 技术深度:8分(扎实的基础)
- AI工具熟练度:7分(熟练使用主流工具)
- 业务理解:7分(理解业务逻辑)
- 创造力:6分(有创新思维)
- 学习能力:9分(持续学习)

总分 = 8 × 7 × 7 × 6 × 9 = 21,168

如果某项为0,总分就是0!

5.5.3 未来的前端工程师画像

画像:AI时代的全栈体验工程师

技能:
├─ 扎实的前端基础(JS/CSS/React)
├─ 熟练使用AI工具(Cursor/v0/Vercel AI SDK)
├─ Prompt工程能力
├─ 架构设计能力
├─ 产品思维能力
├─ UI/UX设计能力
└─ 跨领域学习能力

工作内容:
├─ 30%:需求分析和拆解
├─ 20%:Prompt工程和AI协作
├─ 20%:代码审查和质量把控
├─ 15%:架构设计和技术决策
├─ 10%:用户体验设计
└─ 5%:手写核心代码

价值体现:
├─ 解决复杂问题
├─ 创造优秀体验
├─ 提升团队效率
├─ 推动技术创新
└─ 引领行业发展

那些只关注"写代码"的工程师,可能会逐渐被AI取代。而那些能够将技术、设计、业务、AI工具融会贯通的工程师,将在新时代大放异彩。

记住

在AI时代,最重要的能力不是"会写代码",而是"会解决问题"。

AI是工具,你是主人。

用AI放大你的能力,但不要被AI定义你的价值。


下章预告

第六章《未来的图景——AI-Native Frontend的愿景》将展望:

  • 2024-2030演进路线图(短期/中期/长期)
  • AI-Native Frontend的四大特征
  • 可能的颠覆性变化(Low-Code终结、外包重构等)
  • 挑战与应对策略
  • 在变革中坚守本质

Vue v-if 转 React:VuReact 怎么处理?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-if/v-else/v-else-if 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的条件指令用法。

编译对照

基础 v-if 条件渲染

最简单的 v-if 指令,用于根据条件显示或隐藏元素。

  • Vue 代码:
<div v-if="cond">内容</div>
  • VuReact 编译后 React 代码:
{
  cond ? <div>内容</div> : null;
}

从示例可以看到:Vue 的 v-if 指令被编译为 React 的三元表达式。VuReact 采用 条件表达式编译策略,将模板指令转换为 JSX 内联表达式,完全保持 Vue 的条件渲染语义——当 cond 为真时渲染 <div>,为假时渲染 null(React 中 null 不会被渲染到 DOM)。


v-if 与 v-else 组合

v-ifv-else 组合使用,实现二选一的条件渲染。

  • Vue 代码:
<div v-if="cond">内容</div>
<div v-else>其他内容</div>
  • VuReact 编译后 React 代码:
{
  cond ? <div>内容</div> : <div>其他内容</div>;
}

VuReact 将 v-if/v-else 组合编译为完整的三元表达式完全模拟 Vue 的条件分支语义——两个分支互斥,确保同一时间只有一个元素被渲染。这种编译方式保持了代码的简洁性和可读性,同时与 React 的表达式渲染模式完美契合。


多条件 v-else-if 链

复杂的多条件判断链,使用 v-ifv-else-ifv-else 组合。

  • Vue 代码:
<div v-if="type === 'A'">内容A</div>
<div v-else-if="type === 'B'">内容B</div>
<div v-else>其他内容</div>
  • VuReact 编译后 React 代码:
{
  type === 'A' ? <div>内容A</div> : type === 'B' ? <div>内容B</div> : <div>其他内容</div>;
}

对于多条件链,VuReact 采用嵌套三元表达式编译策略,将 Vue 的 v-else-if 链转换为嵌套的条件表达式。这种编译方式完全保持 Vue 的条件链语义——按顺序检查条件,第一个满足条件的分支被渲染,后续分支被跳过。


复杂业务场景条件渲染

实际业务中的复杂条件渲染,包含嵌套条件、事件绑定、插值表达式等。

  • Vue 代码:
<div v-if="user.role === 'admin' && (user.permissions.includes('write') || isSuperAdmin)">
  <h1>管理员控制面板</h1>
  <button @click="deleteAll">删除所有数据</button>
</div>
<div v-else-if="user.role === 'editor' && articles.length > 0 && !isSuspended">
  <h2>编辑文章 (共{{ articles.length }}篇)</h2>
  <ul>
    <li v-for="article in articles" :key="article.id">{{ article.title }}</li>
  </ul>
</div>
<div v-else-if="user.role === 'viewer' && hasSubscription">
  <h3>订阅用户视图</h3>
  <p>您的订阅将于{{ subscriptionEndDate }}到期</p>
</div>
<div v-else-if="user.role === 'guest' && showTrial">
  <div class="trial-banner">
    <p>试用用户,剩余{{ trialDays }}天</p>
    <button @click="upgrade">升级账户</button>
  </div>
</div>
<div v-else>
  <div class="error-state">
    <p v-if="isLoading">加载中...</p>
    <p v-else-if="errorMessage">{{ errorMessage }}</p>
    <p v-else>无访问权限或账户状态异常</p>
    <button @click="retry">重试 ({{ retryCount }}/3)</button>
  </div>
</div>
  • VuReact 编译后 React 代码:
{
  user.role === 'admin' && (user.permissions.includes('write') || isSuperAdmin) ? (
    <div>
      <h1>管理员控制面板</h1>
      <button onClick={deleteAll}>删除所有数据</button>
    </div>
  ) : user.role === 'editor' && articles.length > 0 && !isSuspended ? (
    <div>
      <h2>编辑文章 (共{articles.length}篇)</h2>
      <ul>
        {articles.map((article) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </div>
  ) : user.role === 'viewer' && hasSubscription ? (
    <div>
      <h3>订阅用户视图</h3>
      <p>您的订阅将于{subscriptionEndDate}到期</p>
    </div>
  ) : user.role === 'guest' && showTrial ? (
    <div>
      <div className="trial-banner">
        <p>试用用户,剩余{trialDays}天</p>
        <button onClick={upgrade}>升级账户</button>
      </div>
    </div>
  ) : (
    <div>
      <div className="error-state">
        {isLoading ? (
          <p>加载中...</p>
        ) : errorMessage ? (
          <p>{errorMessage}</p>
        ) : (
          <p>无访问权限或账户状态异常</p>
        )}
        <button onClick={retry}>重试 ({retryCount}/3)</button>
      </div>
    </div>
  );
}

对于复杂的业务场景,VuReact 展示了完整的条件编译能力

  1. 复杂条件表达式:将 Vue 的复杂条件逻辑(&&||、函数调用等)原样转换为 JSX 表达式
  2. 事件绑定转换@click 转换为 onClick,保持事件语义
  3. 插值表达式{{ }} 转换为 { },保持数据绑定
  4. 样式类名转换class 转换为 className,符合 React 规范

VuReact 的编译策略完全保持 Vue 的条件渲染语义,同时生成符合 React 最佳实践的代码,提高可维护性。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue 项目高德地图性能优化实战:从卡死到丝滑的完整过程

几千个点位一次渲染就卡爆浏览器?路由切换越用越慢直到内存崩溃?本文将完整还原 Vue 3 + 高德地图项目的优化实战过程,附详细代码示例。

一、问题来了:地图怎么就卡死了?

去年接手了一个数据可视化大屏项目,核心功能是在高德地图上展示全国范围内的实时业务数据点位,大概有三四千个。开发阶段本地测试一切正常,上了测试环境之后,问题一个接一个地冒出来了:页面首次加载白屏时间长、地图缩放拖拽掉帧、切换页面之后浏览器越来越卡,跑几个来回就崩溃了

更让人头疼的是,这个问题在 Chrome 和 Edge 上特别明显,Firefox 反而相对平稳,说明这绝不是简单的代码 bug,而是涉及到浏览器渲染机制、地图 SDK 加载策略和 Vue 生命周期管理的综合性能问题。

经过反复排查,我总结出了导致地图卡顿的几个核心原因:

问题维度 典型表现 排查工具
加载策略 首屏白屏长,LCP 差 Lighthouse、Network 瀑布流
渲染性能 平移/缩放掉帧,交互卡顿 Performance 面板、FPS 监控
内存管理 多页面切换后越用越慢 Memory 面板、堆快照分析

接下来,我按实际优化顺序,逐一给出解决方案。

二、优化一:异步加载,别让地图拖垮首屏

很多教程和快速示例都建议在 index.html 里直接用 <script> 标签同步引入高德地图 JS API。这种方式虽然简单,但有一个致命问题:它会阻塞 HTML 的解析和渲染。在网络不好或 API 服务器响应慢的情况下,用户面对的就是一个长时间的空白页面。

更糟糕的是,整个高德 SDK 会在应用初始化时全部加载,不管当前页面是否真的需要地图。

改进方案:使用 AMapLoader 动态加载

高德官方提供了 @amap/amap-jsapi-loader,但要注意不能在文件顶部直接 import,否则 Vite 打包时这个依赖会被打进首屏 bundle,一样会增加首包体积。

正确的做法是在 onMounted 中动态导入:

// ❌ 错误:在文件顶部 import,会被打进首屏 bundle
import AMapLoader from '@amap/amap-jsapi-loader'

// ✅ 正确:在函数内部动态 import,单独拆分成 chunk
async function initMap() {
  const { default: AMapLoader } = await import('@amap/amap-jsapi-loader')
  
  window._AMapSecurityConfig = {
    securityJsCode: import.meta.env.VITE_AMAP_SECURITY_CODE
  }
  
  const AMap = await AMapLoader.load({
    key: import.meta.env.VITE_AMAP_KEY,
    version: '2.0',
    plugins: ['AMap.Scale', 'AMap.MarkerCluster', 'AMap.Geolocation']
  })
  
  // 初始化地图
  const map = new AMap.Map('map-container', {
    zoom: 10,
    center: [116.397428, 39.90923]
  })
  
  return { AMap, map }
}

这样做的好处是:高德相关代码会被打包成单独的 chunk,只有执行到这个函数时才会加载,首屏 bundle 体积更小,页面渲染更快。而且 SSR 场景下 Node 环境不会执行 onMounted 钩子,自然也就避开了 window is not defined 的问题。

三、优化二:shallowRef,不让 Vue 响应式拖后腿

地图实例、Marker 对象这些数据非常庞大,如果直接用 Vue 的 ref 存储,Vue 会递归地给它们的每个属性添加响应式代理,这会导致严重的性能损耗。

一个真实踩坑场景:有次我把地图实例直接放进了 ref,结果地图缩放时明显感觉掉帧,排查了半天才发现是 Vue 在给地图对象做响应式劫持。

解决方案:用 shallowRef 替代 ref

import { shallowRef, onMounted, onUnmounted } from 'vue'

// ✅ 使用 shallowRef 存储地图相关实例
const map = shallowRef(null)
const currentLocationMarker = shallowRef(null)
const cluster = shallowRef(null)

async function initMap() {
  const { AMap, mapInstance } = await loadMap()
  map.value = mapInstance
  
  // 存储插件实例也要用 shallowRef
  const markerCluster = new AMap.MarkerCluster(mapInstance, points, {
    gridSize: 60,
    maxZoom: 18
  })
  cluster.value = markerCluster
}

shallowRef 只追踪 .value 的访问,不会对其内部属性进行响应式代理,对于地图实例这种大型对象来说非常合适。

四、优化三:点聚合,把几千个点变成几十个簇

当数据量超过 500 个点时,直接渲染所有 Marker 会让地图操作变得卡顿。我手上的项目有 8000+ 个点位,首次进入时地图直接崩了。

救星就是 AMap.MarkerCluster 点聚合插件。

它的原理很简单:当地图缩放到较低级别时,把距离相近的点合并成一个带数字的聚合点;放大地图时再自动展开成独立的点标记。官方宣称可以支持 10 万以内的点位保持较好性能。

async function initMapWithCluster(pointsData) {
  const { AMap, map } = await initMap()
  
  // 准备点位数据,格式必须包含 lnglat
  const points = pointsData.map(item => ({
    lnglat: [item.lng, item.lat],
    weight: item.value, // 可选权重
    extData: item       // 附加业务数据
  }))
  
  // 初始化点聚合
  const markerCluster = new AMap.MarkerCluster(map, points, {
    gridSize: 60,        // 聚合网格大小,默认60
    maxZoom: 18,         // 最大聚合级别,18级以上不聚合
    minClusterSize: 2,   // 至少2个点才聚合
    renderClusterMarker: (context) => {
      // 自定义聚合点样式
      const div = document.createElement('div')
      const count = context.count
      div.style.backgroundColor = `rgba(66, 133, 244, ${Math.min(0.8, 0.3 + count / 100)})`
      div.style.width = `${Math.min(60, 30 + count / 5)}px`
      div.style.height = `${Math.min(60, 30 + count / 5)}px`
      div.style.borderRadius = '50%'
      div.style.display = 'flex'
      div.style.alignItems = 'center'
      div.style.justifyContent = 'center'
      div.style.color = 'white'
      div.style.fontWeight = 'bold'
      div.style.fontSize = `${Math.min(18, 12 + count / 10)}px`
      div.innerHTML = count > 99 ? '99+' : count
      context.marker.setContent(div)
    }
  })
  
  return { map, markerCluster }
}

点聚合配置的几个关键参数:

  • gridSize:聚合网格的像素大小,调大可以让更多点被聚合,调小则保留更多独立点
  • minClusterSize:至少多少个点才触发聚合,设置为 2 表示单个点不聚合
  • maxZoom:在哪个缩放级别以上停止聚合,让用户可以查看独立点位

聚合前后的性能差异非常明显:8000 个独立 Marker 会导致地图完全卡死,而通过聚合只需要渲染几十个聚合点和当前视野内的少量 Marker,流畅度天差地别。

五、优化四:动态更新聚合图层,别让旧图层“粘”在地图上

一个容易被忽略的坑:当查询条件变化、点位数据需要全部更新时,旧的聚合图层很难彻底清除。直接调用 map.clearMap() 对聚合图层内部生成的 Marker 无效,因为那些 Marker 是 MarkerCluster 实例管理的,不是直接添加到地图上的。

正确的做法是销毁旧的聚合实例,再重新创建:

// ❌ 错误:直接清地图,聚合图层还在
function updatePointsWrong(newPoints) {
  map.value.clearMap()  // 对聚合图层无效!
  initMapWithCluster(newPoints)  // 新旧图层叠加,地图混乱
}

// ✅ 正确:先销毁旧聚合实例
function updatePointsCorrect(newPoints) {
  // 1. 销毁旧的聚合实例
  if (cluster.value) {
    cluster.value.setMap(null)  // 从地图上移除
    cluster.value = null
  }
  
  // 2. 重新创建聚合图层
  const { map: newMap, markerCluster } = await initMapWithCluster(newPoints)
  map.value = newMap
  cluster.value = markerCluster
}

如果只是部分点位数据变化,不需要完全重建,可以用 setData 方法更新:

// 动态更新点位数据
function refreshPoints(newPointsData) {
  const newPoints = newPointsData.map(item => ({
    lnglat: [item.lng, item.lat],
    extData: item
  }))
  cluster.value.setData(newPoints)  // 高效更新,无需重建实例
}

关键要点MarkerCluster 是一个管理器,它内部生成的 Marker 由它自己管理。要清除聚合图层,必须调用 setMap(null) 销毁整个聚合实例,或者用 setData 更新数据,而不是试图用 map.clearMap() 手动清理。

六、优化五:视口裁剪 + 防抖,只渲染用户看得见的点

即使使用了点聚合,在聚合层级以下(比如缩放到某个城市级别时)仍然可能需要渲染成百上千个独立 Marker。这些点如果不在当前视口内,渲染它们完全是浪费资源。

优化思路:只渲染当前地图视野内的点位,并监听地图的缩放/移动事件动态更新。

import { ref, onMounted, onUnmounted } from 'vue'

const map = ref(null)
const visiblePoints = ref([])      // 当前视野内的点位
const allPoints = ref([])          // 全量点位数据
let renderTimer = null

// 计算当前视野内的点位
function updateVisibleMarkers() {
  if (!map.value) return
  
  const bounds = map.value.getBounds()  // 获取当前地图边界
  const sw = bounds.getSouthWest()      // 西南角坐标
  const ne = bounds.getNorthEast()      // 东北角坐标
  
  // 筛选视野内的点
  visiblePoints.value = allPoints.value.filter(point => {
    return point.lng >= sw.lng && point.lng <= ne.lng &&
           point.lat >= sw.lat && point.lat <= ne.lat
  })
  
  // 重新渲染 Marker
  renderMarkersInViewport()
}

// 防抖处理:用户停止操作后再渲染
function onMapViewChange() {
  if (renderTimer) clearTimeout(renderTimer)
  renderTimer = setTimeout(() => {
    updateVisibleMarkers()
  }, 200)  // 200ms 防抖延迟
}

onMounted(() => {
  initMap().then(({ map: mapInstance }) => {
    map.value = mapInstance
    map.value.on('moveend', onMapViewChange)   // 移动结束
    map.value.on('zoomend', onMapViewChange)   // 缩放结束
    updateVisibleMarkers()
  })
})

onUnmounted(() => {
  if (map.value) {
    map.value.off('moveend', onMapViewChange)
    map.value.off('zoomend', onMapViewChange)
    map.value.destroy()  // 组件卸载时销毁地图实例,释放内存
  }
})

这种做法的核心思想是 只渲染用户看得见的内容,配合 200ms 的防抖处理,避免在用户快速拖动时频繁触发渲染。实测下来,地图交互的帧率从 20fps 提升到了 55fps 以上。

七、容易被忽视的两个细节

1. 自定义图标尺寸别太大

如果用了自定义的 Marker 图标,高德官方强烈建议将图标尺寸控制在 60px × 60px 以内。图标太大不仅占内存,每次缩放时的重绘开销也成倍增加。

2. 组件销毁时务必清理干净

Vue 项目中非常隐蔽的一个问题:地图实例、Marker、事件监听器如果在组件销毁时没有正确清理,就会一直驻留在内存中。用户在有地图的多个路由间来回切换几次,内存占用就会像滚雪球一样越来越大,最终触发频繁的 GC 停顿,导致页面卡顿。

onUnmounted(() => {
  // 1. 移除所有事件监听
  if (map.value) {
    map.value.off('moveend', onMapViewChange)
    map.value.off('zoomend', onMapViewChange)
  }
  
  // 2. 销毁聚合实例
  if (cluster.value) {
    cluster.value.setMap(null)
    cluster.value = null
  }
  
  // 3. 销毁地图实例
  if (map.value) {
    map.value.destroy()
    map.value = null
  }
})

八、总结:这些优化让地图起飞了

经过以上几轮优化,项目的性能数据有了质的提升:

  • 首屏加载时间:从 4.2 秒降到 1.5 秒(减少约 65%)
  • 地图操作帧率:从 20fps 左右提升到 55fps 以上
  • 内存占用:路由切换 5 次后内存从 350MB 降到 120MB
  • 最大支持点位:从 2000 个提升到 50000 个

最后把这些要点总结成一张速查表:

优化手段 适用场景 核心代码/配置
动态加载 SDK 首屏优化 await import('@amap/amap-jsapi-loader')
shallowRef 存储 地图实例、Marker const map = shallowRef(null)
MarkerCluster 点聚合 点位 > 500 new AMap.MarkerCluster(map, points, { gridSize: 60 })
销毁旧聚合实例 动态更新点位 cluster.setMap(null) → 重建
视口裁剪 + 防抖 缩放/拖拽时 bounds 筛选 + setTimeout 200ms
组件销毁清理 路由切换 map.destroy() + 解绑事件

性能优化的核心原则其实很朴素:能异步加载的绝不同步加载,能按需渲染的绝不全量渲染,能复用的实例绝不重复创建。希望这篇文章能帮你少踩一些我踩过的坑。

Solana前端开发:从连接钱包到发送交易,我如何用@solana/web3.js搞定第一个DApp

背景

上个月,团队接了一个Solana生态的NFT项目,需要开发一个允许用户连接钱包、查看余额并铸造NFT的前端界面。作为一个在以太坊和EVM兼容链上摸爬滚打了五年的前端,我的工具箱里装满了ethers.jsviemwagmi。当任务切换到Solana时,我意识到得从头学起。核心的挑战很明确:我需要快速掌握@solana/web3.js这个官方SDK,用它来实现钱包连接、读取链上数据和发送交易这些基础但至关重要的功能。一开始我以为这和以太坊开发大同小异,结果一脚踩进了好几个坑里。

问题分析

我的第一反应是去翻@solana/web3.js的官方文档和示例。文档结构清晰,但当我试图把文档里的代码片段拼凑成一个完整的React应用时,问题来了。首先,钱包连接逻辑和以太坊的window.ethereum完全不同,Solana主流钱包如Phantom将接口注入到window.solana。其次,账户模型差异巨大:Solana使用公钥(PublicKey)作为地址,交易需要“最近区块哈希”和“手续费支付者”等概念,这让我一开始构建交易时屡屡失败。最初的几次尝试,不是钱包弹不出连接框,就是交易签名后发送失败,控制台报错信息又比较晦涩。我意识到,不能只是机械地复制代码,必须理解Solana交易构建的基本流程。

核心实现

1. 环境搭建与钱包连接

首先,我创建了一个新的React + TypeScript项目,并安装核心依赖:

npm install @solana/web3.js @solana/wallet-adapter-base @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets

这里有个关键点:单纯用@solana/web3.js也能连接钱包,但社区更推荐使用@solana/wallet-adapter-*这一套工具库,它封装了连接逻辑和UI组件,能省不少事。

接下来,我设置钱包上下文。这是整个应用能调用钱包功能的基础:

// App.tsx
import React, { useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets';
import { clusterApiUrl } from '@solana/web3.js';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { MyComponent } from './MyComponent';

// 导入默认样式
import '@solana/wallet-adapter-react-ui/styles.css';

function App() {
  // 配置网络。开发时通常用devnet或testnet,这里用devnet
  const network = WalletAdapterNetwork.Devnet;
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);

  // 配置支持的钱包列表
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      // 可以继续添加其他钱包适配器,如Solflare
    ],
    []
  );

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          <MyComponent />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

export default App;

注意这个细节ConnectionProviderendpoint参数是必须的,它指定了你的应用要连接哪个Solana集群(主网、测试网等)。autoConnect属性会在页面加载时尝试重新连接上次的钱包,提升用户体验。

2. 获取钱包地址与余额

在子组件MyComponent中,我使用适配器提供的钩子来获取钱包状态和连接信息。

// MyComponent.tsx
import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';

export const MyComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);

  // 当钱包连接状态或公钥变化时,获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (connected && publicKey) {
        setLoading(true);
        try {
          // 注意:getBalance返回的是lamports,1 SOL = 10^9 lamports
          const lamportsBalance = await connection.getBalance(publicKey);
          setBalance(lamportsBalance / LAMPORTS_PER_SOL); // 转换为SOL单位
        } catch (error) {
          console.error('获取余额失败:', error);
          setBalance(null);
        } finally {
          setLoading(false);
        }
      } else {
        setBalance(null);
      }
    };

    fetchBalance();
  }, [connection, publicKey, connected]);

  return (
    <div>
      <p>钱包状态: {connected ? '已连接' : '未连接'}</p>
      {publicKey && <p>钱包地址: {publicKey.toBase58()}</p>}
      {loading && <p>查询余额中...</p>}
      {balance !== null && !loading && <p>余额: {balance} SOL</p>}
    </div>
  );
};

这里有个坑connection.getBalance()返回的单位是lamports,而不是SOL。直接显示这个数字会非常大,必须除以LAMPORTS_PER_SOL(10^9)来转换。我一开始没注意,显示了一个9位数的“余额”,闹了笑话。

3. 构建并发送一笔SOL转账交易

这是最核心也最容易出错的部分。在Solana上,一笔交易可以包含多个指令,我们需要构建一个“系统程序”的转账指令。

// 在MyComponent.tsx中添加发送交易函数
import { SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';

const sendTransaction = async () => {
  // 1. 基础校验
  if (!publicKey || !connected) {
    alert('请先连接钱包');
    return;
  }
  if (!connection) {
    alert('连接异常');
    return;
  }

  // 2. 构建交易指令
  // 假设我们向这个地址转账0.01 SOL
  const toPublicKey = new PublicKey('接收方的Solana地址(Base58格式)');
  const transferAmount = 0.01; // SOL
  const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;

  const transferInstruction = SystemProgram.transfer({
    fromPubkey: publicKey,
    toPubkey: toPublicKey,
    lamports: lamportsToSend,
  });

  // 3. 创建交易并添加指令
  const transaction = new Transaction().add(transferInstruction);

  // 4. 获取“最近区块哈希”(Recent Blockhash)——这是Solana交易必需的
  let blockhash;
  try {
    const { blockhash: recentBlockhash } = await connection.getLatestBlockhash();
    blockhash = recentBlockhash;
    transaction.recentBlockhash = blockhash;
    // 5. 设置交易的费用支付者(Fee Payer)
    transaction.feePayer = publicKey;
  } catch (error) {
    console.error('获取区块哈希失败:', error);
    alert('获取网络信息失败,请重试');
    return;
  }

  // 6. 请求钱包签名并发送
  try {
    // 这里使用了wallet-adapter的signTransaction方法
    // 注意:在真实场景中,我们通常使用wallet-adapter提供的sendTransaction方法,它内部处理了签名和发送。
    // 但为了演示底层过程,这里先展示需要手动签名的流程,后面会给出更优方案。
    const signedTransaction = await signTransaction(transaction); // 假设signTransaction来自useWallet
    const signature = await connection.sendRawTransaction(signedTransaction.serialize());
    console.log('交易已发送,签名:', signature);

    // 7. 确认交易
    const confirmation = await connection.confirmTransaction(signature);
    if (confirmation.value.err) {
      throw new Error('交易确认失败');
    }
    alert(`转账成功!交易签名: ${signature}`);
  } catch (error: any) {
    console.error('发送交易失败:', error);
    alert(`交易失败: ${error.message}`);
  }
};

注意这个细节recentBlockhashfeePayer是Solana交易对象必须设置的两个属性,缺一不可。忘记设置feePayer是我遇到的第一个报错。recentBlockhash用于防止交易重放,并让验证者知道交易的有效期。

4. 使用Wallet Adapter优化交易发送

上面的手动签名流程比较繁琐,而且useWallet钩子并不直接暴露signTransaction方法。实际上,@solana/wallet-adapter-react提供了更优雅的sendTransaction方法。

// 这是更推荐的实践,修改MyComponent.tsx
import { useConnection, useWallet } from '@solana/wallet-adapter-react';

const { connection } = useConnection();
const { publicKey, sendTransaction } = useWallet(); // 使用钩子提供的sendTransaction

const sendTransactionEasy = async () => {
  if (!publicKey) return;

  const toPublicKey = new PublicKey('接收方地址');
  const lamportsToSend = 0.01 * LAMPORTS_PER_SOL;

  const transaction = new Transaction().add(
    SystemProgram.transfer({
      fromPubkey: publicKey,
      toPubkey: toPublicKey,
      lamports: lamportsToSend,
    })
  );

  // 关键步骤:获取区块哈希并设置
  const { blockhash } = await connection.getLatestBlockhash();
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = publicKey;

  try {
    // 一行代码搞定:钱包适配器会处理弹窗签名、发送、获取签名结果
    const signature = await sendTransaction(transaction, connection);
    console.log('交易签名:', signature);

    // 可选:等待交易确认
    const result = await connection.confirmTransaction(signature, 'confirmed');
    console.log('确认结果:', result);
    alert('转账成功!');
  } catch (error: any) {
    console.error('交易出错:', error);
    alert(`用户拒绝或交易失败: ${error.message}`);
  }
};

这里有个巨大的进步:使用钱包适配器提供的sendTransaction方法,我们不需要手动处理签名、序列化、发送原始交易这些底层细节。它会自动触发钱包的签名请求,并返回交易签名。代码简洁且健壮。

完整代码

以下是一个整合了所有功能、可以直接运行的MyComponent.tsx示例:

import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { SystemProgram, Transaction, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';

export const MyComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected, sendTransaction } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [sending, setSending] = useState(false);
  const [recipient, setRecipient] = useState('');

  // 获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (connected && publicKey) {
        setLoading(true);
        try {
          const lamportsBalance = await connection.getBalance(publicKey);
          setBalance(lamportsBalance / LAMPORTS_PER_SOL);
        } catch (error) {
          console.error('获取余额失败:', error);
          setBalance(null);
        } finally {
          setLoading(false);
        }
      } else {
        setBalance(null);
      }
    };
    fetchBalance();
  }, [connection, publicKey, connected]);

  // 发送SOL交易
  const handleSendSol = async () => {
    if (!publicKey || !recipient) {
      alert('请先连接钱包并填写接收地址');
      return;
    }
    let toPubkey;
    try {
      toPubkey = new PublicKey(recipient);
    } catch {
      alert('接收地址格式无效');
      return;
    }

    const transferAmount = 0.01; // 固定转账0.01 SOL,实际项目可以做成输入框
    const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;

    const transaction = new Transaction().add(
      SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: toPubkey,
        lamports: lamportsToSend,
      })
    );

    try {
      const { blockhash } = await connection.getLatestBlockhash();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = publicKey;

      setSending(true);
      const signature = await sendTransaction(transaction, connection);
      console.log('交易完成,签名:', signature);

      // 等待最终确认,提供更好反馈
      await connection.confirmTransaction(signature, 'confirmed');
      alert(`成功转账${transferAmount} SOL!交易签名: ${signature}`);
      setRecipient(''); // 清空输入框
      // 重新获取余额
      const newBalance = await connection.getBalance(publicKey);
      setBalance(newBalance / LAMPORTS_PER_SOL);
    } catch (error: any) {
      console.error('交易失败:', error);
      if (error.message.includes('User rejected')) {
        alert('您拒绝了交易签名。');
      } else {
        alert(`交易失败: ${error.message}`);
      }
    } finally {
      setSending(false);
    }
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>Solana Web3.js 入门实战</h1>
      <div style={{ marginBottom: '20px' }}>
        <WalletMultiButton />
      </div>

      {connected && publicKey && (
        <div>
          <p>
            <strong>钱包地址:</strong> {publicKey.toBase58()}
          </p>
          <p>
            <strong>余额:</strong>{' '}
            {loading ? '加载中...' : balance !== null ? `${balance.toFixed(4)} SOL` : '--'}
          </p>

          <hr style={{ margin: '20px 0' }} />

          <h3>发送 SOL 测试</h3>
          <div>
            <input
              type="text"
              placeholder="输入接收方Solana地址"
              value={recipient}
              onChange={(e) => setRecipient(e.target.value)}
              style={{ width: '400px', padding: '8px', marginRight: '10px' }}
            />
            <button onClick={handleSendSol} disabled={sending || !recipient}>
              {sending ? '发送中...' : '发送 0.01 SOL'}
            </button>
            <p style={{ fontSize: '0.9em', color: '#666', marginTop: '5px' }}>
              请确保在Devnet网络,并使用Devnet的SOL进行测试。
            </p>
          </div>
        </div>
      )}
      {!connected && <p>请点击上方按钮连接钱包(推荐Phantom)。</p>}
    </div>
  );
};

踩坑记录

  1. “Cannot read properties of undefined (reading ‘solana’)”:这是我遇到的第一个错误。原因是我在没有安装Phantom钱包(或任何Solana钱包)的浏览器中运行代码。window.solana对象不存在。解决方法:在代码中增加判断,或者引导用户安装钱包。钱包适配器的UI按钮会自动处理这个状态。

  2. “Transaction recentBlockhash required”:构建交易后发送失败。我忘记给交易对象transaction设置recentBlockhash属性。解决方法:在发送交易前,必须调用connection.getLatestBlockhash()并赋值给transaction.recentBlockhash

  3. “FeePayer must be a PublicKey”:设置了recentBlockhash后依然报错。因为我连feePayer也没设置。解决方法:将当前用户的公钥publicKey赋值给transaction.feePayer。记住,这两个属性是Solana Transaction对象的必选项。

  4. 交易签名成功但链上确认失败:在测试网发送交易,钱包签名弹窗成功了,但最后交易失败。原因是我用的RPC节点不稳定或响应慢。解决方法:更换更稳定、快速的RPC端点。对于开发,可以使用Solana基金会提供的公共端点clusterApiUrl(‘devnet’),但对于生产环境,需要考虑使用付费的私有RPC服务以获得更好的可靠性。

小结

通过这个从零到一的实践,我深刻体会到Solana前端开发在交易构建细节上与EVM的差异。核心收获是:理解Solana交易必须包含recentBlockhashfeePayer,并善用@solana/wallet-adapter系列工具库能极大提升开发效率。下一步,我可以基于此继续探索如何与SPL代币(类似ERC20)交互、如何解析NFT元数据,以及如何与自定义的智能合约(Solana上称为程序)进行交互。

在 React Native 中集成 MinIO 对象存储(图片/文件上传服务)

前言

在移动应用开发中,文件上传和存储是一个常见需求。无论是用户头像、签名图片还是各类文档,都需要一个可靠的存储方案。MinIO 作为一个高性能的对象存储服务,完全兼容 AWS S3 API,成为了许多开发者的首选。

本文将详细介绍如何在 React Native 项目中集成 MinIO,包括环境配置、SDK 集成、实际代码示例以及最佳实践。

为什么选择 MinIO?

MinIO 的优势

  1. 完全兼容 S3 API - 可以直接使用 AWS SDK,无需学习新的 API
  2. 高性能 - 基于 Go 语言开发,性能优异
  3. 自托管 - 可以部署在自己的服务器上,数据完全可控
  4. 开源免费 - 基于 Apache License 2.0 开源
  5. 简单易用 - 配置简单,上手快速

与其他方案对比

方案 优势 劣势
MinIO 自托管、高性能、免费 需要自己维护服务器
AWS S3 无需维护、全球分发 需要付费、数据在云端
阿里云 OSS 国内访问快、功能丰富 需要付费、厂商锁定
本地存储 无需网络、速度快 存储空间有限、无法跨设备

技术方案

使用 AWS S3 SDK

由于 MinIO 完全兼容 S3 API,我们可以直接使用 AWS 官方的 JavaScript SDK:

npm install @aws-sdk/client-s3
# 或
yarn add @aws-sdk/client-s3

同时需要安装 react-native-config 来管理环境变量:

npm install react-native-config
# 或
yarn add react-native-config

环境配置

1. 配置环境变量

在项目根目录创建 .env 文件:

# MinIO 配置
MINIO_ENDPOINT='http://xxx:xxx'
MINIO_ACCESS_KEY='your_access_key'
MINIO_SECRET_KEY='your_secret_key'
MINIO_BUCKET='your_bucket_name'
MINIO_USE_SSL=false

2. 初始化 S3 客户端

创建一个自定义 Hook 来封装 MinIO 操作:

// src/hooks/useMinio.js
import {useState, useEffect, useCallback, useRef} from 'react';
import {S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand} from '@aws-sdk/client-s3';
import Config from 'react-native-config';

const useMinio = () => {
  const [client, setClient] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const bucketName = Config.MINIO_BUCKET || 'default-bucket';
  const clientRef = useRef(null);

  // 初始化 S3 客户端
  useEffect(() => {
    if (!clientRef.current) {
      try {
        const endpoint = Config.MINIO_ENDPOINT || 'http://localhost:9000';
        
        const s3Client = new S3Client({
          endpoint: endpoint,
          forcePathStyle: true, // MinIO 需要路径风格
          region: 'us-east-1',
          credentials: {
            accessKeyId: Config.MINIO_ACCESS_KEY || '',
            secretAccessKey: Config.MINIO_SECRET_KEY || '',
          },
        });
        
        clientRef.current = s3Client;
        setClient(s3Client);
      } catch (err) {
        setError(err);
        console.error('Error initializing S3 client:', err);
      }
    }
  }, []);

  return {
    loading,
    error,
    bucketName,
    client,
  };
};

export default useMinio;

关键配置说明

  • forcePathStyle: true - MinIO 必须使用路径风格(/bucket/object),而不是虚拟主机风格
  • region - MinIO 默认使用 us-east-1,可以自定义
  • endpoint - MinIO 服务器地址,包含端口

核心功能实现

1. 上传文件

上传文件是最常用的功能。在 React Native 中,我们通常处理的是 Buffer 或 Base64 格式的数据。

const uploadImageFromBuffer = useCallback(async (buffer, objectName, contentType = 'image/jpeg') => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  setLoading(true);
  setError(null);

  try {
    const command = new PutObjectCommand({
      Bucket: bucketName,
      Key: objectName,
      Body: buffer,
      ContentType: contentType,
    });

    await client.send(command);
    console.log(`File uploaded successfully as ${objectName}`);
    
    return objectName;
  } catch (err) {
    setError(err);
    console.error('Error uploading file:', err);
    throw err;
  } finally {
    setLoading(false);
  }
}, [client, bucketName]);

2. 获取文件 URL

获取已上传文件的访问 URL:

const getImageUrl = useCallback(async (objectName) => {
  try {
    const endpoint = Config.MINIO_ENDPOINT || 'http://localhost:9000';
    
    // 构建简单 URL 格式:endpoint/bucket/objectName
    const url = `${endpoint}/${bucketName}/${objectName}`;
    
    console.log('Generated image URL:', url);
    return url;
  } catch (err) {
    setError(err);
    console.error('Error getting image URL:', err);
    throw err;
  }
}, [bucketName]);

3. 删除文件

const deleteImage = useCallback(async (objectName) => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  try {
    const command = new DeleteObjectCommand({
      Bucket: bucketName,
      Key: objectName,
    });

    await client.send(command);
    console.log(`File ${objectName} deleted successfully`);
  } catch (err) {
    setError(err);
    console.error('Error deleting file:', err);
    throw err;
  }
}, [client, bucketName]);

4. 检查文件是否存在

const objectExists = useCallback(async (objectName) => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  try {
    const command = new HeadObjectCommand({
      Bucket: bucketName,
      Key: objectName,
    });

    await client.send(command);
    return true;
  } catch (err) {
    if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
      return false;
    }
    throw err;
  }
}, [client, bucketName]);

实际应用示例

场景:电子签名上传

以下是一个完整的电子签名上传示例,包括 Base64 转换、上传和 URL 获取:

import React, {useState} from 'react';
import {View, TouchableOpacity, Text, ActivityIndicator} from 'react-native';
import useMinio from '../../hooks/useMinio';

const SignatureUpload = () => {
  const {uploadImageFromBuffer, getImageUrl, loading} = useMinio();
  const [signatureUrl, setSignatureUrl] = useState(null);

  const handleSignatureUpload = async (base64Signature) => {
    try {
      // 1. 提取 base64 数据
      let base64Data = base64Signature;
      if (base64Data.includes('base64,')) {
        base64Data = base64Data.split('base64,')[1];
      }

      // 2. 将 base64 转换为 Uint8Array(React Native 兼容方式)
      const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
      const decodeLength = (base64Data.length * 3) / 4;
      const bytes = new Uint8Array(decodeLength);
      let bufferIndex = 0;
      
      for (let i = 0; i < base64Data.length; i += 4) {
        const enc1 = base64Chars.indexOf(base64Data[i]);
        const enc2 = base64Chars.indexOf(base64Data[i + 1]);
        const enc3 = base64Chars.indexOf(base64Data[i + 2] || '=');
        const enc4 = base64Chars.indexOf(base64Data[i + 3] || '=');
        
        bytes[bufferIndex++] = (enc1 << 2) | (enc2 >> 4);
        if (enc3 !== 64) {
          bytes[bufferIndex++] = ((enc2 & 15) << 4) | (enc3 >> 2);
        }
        if (enc4 !== 64) {
          bytes[bufferIndex++] = ((enc3 & 3) << 6) | enc4;
        }
      }

      const actualBytes = bytes.slice(0, bufferIndex);

      // 3. 生成唯一的对象名称
      const timestamp = Date.now();
      const userId = 'user123'; // 实际项目中从用户信息获取
      const objectName = `${userId}/${timestamp}.png`;

      // 4. 上传到 MinIO
      await uploadImageFromBuffer(actualBytes, objectName, 'image/png');

      // 5. 获取在线 URL
      const imageUrl = await getImageUrl(objectName);
      
      setSignatureUrl(imageUrl);
      console.log('Signature uploaded successfully:', imageUrl);
      
      return imageUrl;
    } catch (error) {
      console.error('Error uploading signature:', error);
      throw error;
    }
  };

  return (
    <View>
      <TouchableOpacity onPress={() => handleSignatureUpload('your_base64_data')}>
        <Text>上传签名</Text>
      </TouchableOpacity>
      
      {loading && <ActivityIndicator />}
      
      {signatureUrl && (
        <Image source={{uri: signatureUrl}} style={{width: 200, height: 100}} />
      )}
    </View>
  );
};

最佳实践

1. 对象命名规范

建议使用有层次结构的命名方式:

{userId}/{type}/{timestamp}.{extension}

示例:

  • user123/avatar/1713456789000.jpg
  • user123/signature/1713456789001.png
  • user456/document/1713456789002.pdf

2. 文件大小限制

在上传前检查文件大小,避免上传过大的文件:

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

const uploadWithSizeCheck = async (buffer, objectName) => {
  if (buffer.length > MAX_FILE_SIZE) {
    throw new Error('File size exceeds 5MB limit');
  }
  return uploadImageFromBuffer(buffer, objectName);
};

3. 错误处理

完善的错误处理机制:

const handleUpload = async () => {
  try {
    setLoading(true);
    const url = await uploadImageFromBuffer(buffer, objectName);
    Toast.success('上传成功');
    return url;
  } catch (error) {
    if (error.name === 'NetworkError') {
      Toast.error('网络错误,请检查网络连接');
    } else if (error.name === 'AccessDenied') {
      Toast.error('权限不足,请联系管理员');
    } else {
      Toast.error('上传失败,请重试');
    }
    console.error('Upload error:', error);
  } finally {
    setLoading(false);
  }
};

4. 进度显示

对于大文件上传,可以添加进度显示(需要使用分片上传):

// 使用 @aws-sdk/lib-storage 支持进度显示
import {Upload} from '@aws-sdk/lib-storage';

const uploadWithProgress = async (buffer, objectName, onProgress) => {
  const upload = new Upload({
    client,
    params: {
      Bucket: bucketName,
      Key: objectName,
      Body: buffer,
    },
  });

  upload.on('httpUploadProgress', (progress) => {
    const percentage = Math.round((progress.loaded / progress.total) * 100);
    onProgress(percentage);
  });

  await upload.done();
};

5. 缓存策略

对于频繁访问的图片,可以实现本地缓存:

import {AsyncStorage} from 'react-native';

const getCachedOrUpload = async (localPath, objectName) => {
  const cacheKey = `cached_${objectName}`;
  const cachedUrl = await AsyncStorage.getItem(cacheKey);
  
  if (cachedUrl) {
    return cachedUrl;
  }
  
  const url = await uploadImageFromBuffer(buffer, objectName);
  await AsyncStorage.setItem(cacheKey, url);
  return url;
};

常见问题

Q1: 为什么需要 forcePathStyle: true

MinIO 使用路径风格的 URL(/bucket/object),而 AWS S3 默认使用虚拟主机风格(bucket.s3.amazonaws.com/object)。设置 forcePathStyle: true 可以确保 SDK 使用正确的 URL 格式。

Q2: 如何处理网络中断?

实现重试机制:

const uploadWithRetry = async (buffer, objectName, maxRetries = 3) => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await uploadImageFromBuffer(buffer, objectName);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
};

Q3: 如何实现文件预签名 URL?

对于需要临时访问的文件,可以使用预签名 URL:

import {getSignedUrl} from '@aws-sdk/s3-request-presigner';

const getPresignedUrl = async (objectName, expiresIn = 3600) => {
  const command = new GetObjectCommand({
    Bucket: bucketName,
    Key: objectName,
  });
  
  return await getSignedUrl(client, command, {expiresIn});
};

Q4: React Native 中如何处理文件选择?

可以使用 react-native-document-pickerreact-native-image-picker

npm install react-native-image-picker
import {launchImageLibrary} from 'react-native-image-picker';

const pickAndUpload = async () => {
  const result = await launchImageLibrary({mediaType: 'photo'});
  
  if (result.assets && result.assets[0]) {
    const asset = result.assets[0];
    // asset.uri 是本地文件路径
    // 需要转换为 Buffer 后再上传
  }
};

性能优化

1. 并发上传

对于多个文件,使用并发上传:

const uploadMultiple = async (files) => {
  const uploadPromises = files.map(file => 
    uploadImageFromBuffer(file.buffer, file.objectName)
  );
  
  return Promise.all(uploadPromises);
};

2. 压缩图片

上传前压缩图片以减少带宽:

npm install react-native-image-resizer
import ImageResizer from 'react-native-image-resizer';

const compressAndUpload = async (imagePath, objectName) => {
  const compressed = await ImageResizer.createResizedImage(
    imagePath,
    800, // 宽度
    600, // 高度
    'JPEG',
    80 // 质量
  );
  
  // 读取压缩后的文件并上传
  const buffer = await readFile(compressed.uri);
  return uploadImageFromBuffer(buffer, objectName, 'image/jpeg');
};

3. CDN 加速

如果 MinIO 服务器在国内,可以考虑配置 CDN 加速:

const getImageUrl = useCallback(async (objectName) => {
  const cdnEndpoint = Config.MINIO_CDN_ENDPOINT || Config.MINIO_ENDPOINT;
  const url = `${cdnEndpoint}/${bucketName}/${objectName}`;
  return url;
}, [bucketName]);

安全建议

1. 环境变量管理

  • 不要将敏感信息提交到代码仓库
  • 使用 .env.local 存储本地开发配置
  • 生产环境使用安全的密钥管理方案

2. 访问控制

  • 为不同用户创建不同的 Access Key
  • 设置合理的 Bucket 策略
  • 定期轮换密钥

3. 数据加密

  • 敏感数据上传前加密
  • 使用 HTTPS 传输
  • MinIO 支持服务器端加密

总结

MinIO 是一个优秀的对象存储解决方案,在 React Native 中集成也非常简单。通过使用 AWS S3 SDK,我们可以快速实现文件上传、下载、删除等功能。

本文介绍了从环境配置到实际应用的完整流程,包括核心功能实现、最佳实践和常见问题解决方案。希望这些内容能帮助你在 React Native 项目中更好地使用 MinIO。

参考资源

搞懂 package.json 和 package-lock.json

"我本地跑得好好的啊,怎么上线就崩了?"

如果你是一名 Node.js 开发者,这句话你一定说过,或者听过。而造成这种"薛定谔的 bug"的罪魁祸首之一,就是对 package.jsonpackage-lock.json 的理解不到位。

本文将带你从原理到实战,彻底搞懂这两个文件,以及它们在 Git 中应该如何管理。读完你会明白:

  • 这两个文件到底有什么区别?
  • 为什么要同时存在两个?
  • 到底哪个需要提交到 Git?
  • npm installnpm ci 的本质区别
  • 团队协作中遇到 lock 文件冲突怎么办?

一、先搞清楚这两个文件到底是什么

1.1 package.json —— 你的"购物清单"

这是你手动维护(或通过 npm install xxx 间接修改)的依赖声明文件,是整个 Node.js 项目的核心配置。

{
  "name": "my-app",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.18.0",
    "lodash": "~4.17.21",
    "mongoose": "7.0.0"
  }
}

关键点在于版本号前的符号,这决定了依赖的"弹性":

符号 示例 含义 实际匹配范围
^ ^4.18.0 兼容主版本(Caret) >=4.18.0 <5.0.0
~ ~4.17.21 兼容小版本(Tilde) >=4.17.21 <4.18.0
无符号 4.18.0 精确版本 只能是 4.18.0
* * 任意版本 任意(⚠️ 危险)
>= >=4.0.0 大于等于 >=4.0.0

重点:package.json 描述的是"范围",不是"确定版本"。

这就埋下了一个伏笔——如果只靠它来安装依赖,不同时间、不同机器上装出来的版本可能完全不一样

1.2 package-lock.json —— 你的"购物小票"

这是 npm 自动生成精确快照,它记录了:

  • 每个依赖的精确版本号(如 4.18.2,不是范围)
  • 每个依赖的完整依赖树(包括子依赖的子依赖的子依赖……)
  • 每个包的 integrity hash(防篡改校验)
  • 下载地址(resolved URL)
{
  "name": "my-app",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/express": {
      "version": "4.18.2",
      "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
      "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
      "dependencies": {
        "accepts": "~1.3.8",
        "array-flatten": "1.1.1"
        // ...完整的依赖树
      }
    }
  }
}

一个真实项目的 package-lock.json 通常有几千到几万行,因为它记录了所有依赖的完整拓扑结构。

二、用类比秒懂两者的关系

🍜 想象你开了一家餐厅连锁店

  • package.json = 菜谱:"需要酱油(任何品牌都行)、面条(宽面即可)"
  • package-lock.json = 采购清单:"李锦记金标生抽 500ml、陈克明宽面 3mm"
  • node_modules/ = 仓库里的实物

如果只给新店菜谱,每家店可能买不同品牌的食材,做出来味道不一样——这就是"本地能跑,线上崩"。

采购清单也给他们,就能保证全球分店做出一模一样的菜。

这个类比基本能解释 99% 的疑惑。

三、核心问题:到底要不要提交到 Git?

✅ 结论先行

文件 是否提交 Git 原因
package.json 必须提交 项目依赖声明,核心配置
package-lock.json 必须提交 锁定版本,保证环境一致性
node_modules/ 绝对不提交 体积巨大、可重新生成、跨平台差异

❌ 常见误区(我见过太多开发者踩坑)

误区 1:"lock 文件会自动生成,不用提交吧?"

→ 大错特错。不提交的话,每个同事、每台 CI 机器、每次部署都会根据 package.json版本范围重新解析,可能装到不同版本的包

误区 2:"我本地删了 lock 重装没事啊"

→ 那是你运气好。一旦某个依赖发了新的小版本(比如 ^4.18.0 解析出了 4.18.5),而这个新版本恰好引入了 bug,你就会经历经典名场面:

"我电脑上好好的啊?!"

误区 3:"lock 文件冲突太烦了,干脆 .gitignore 掉"

→ 这是在用"省事"换"生产事故"。正确做法是学会解决冲突(后文会讲)。

四、标准 .gitignore 写法

# 依赖目录(必须忽略)
node_modules/

# 环境变量(避免泄露密钥)
.env
.env.local
.env.*.local

# 日志文件
logs/
*.log
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*

# 构建产物
dist/
build/
.next/
.nuxt/

# 编辑器与 IDE
.vscode/
.idea/
*.swp

# 系统文件
.DS_Store
Thumbs.db

# 测试与覆盖率
coverage/
.nyc_output/

⚠️ 划重点:package-lock.json 绝对不能加进 .gitignore

五、不同场景下的操作规范

场景 1:新增依赖

npm install express

此时会同时修改 package.jsonpackage-lock.json

git add package.json package-lock.json
git commit -m "feat: add express for HTTP server"

两个文件必须一起提交,否则同事拉代码后装不上你的新依赖,或者装到不同版本。

场景 2:升级依赖

# 方式 A:升级到 package.json 允许范围内的最新版
npm update lodash

# 方式 B:升级到最新版(并修改 package.json 的版本号)
npm install lodash@latest

两种方式都会修改 lock 文件,都需要提交

建议:升级关键依赖后,务必跑一遍测试,不然你可能在无意中引入了 breaking changes。

场景 3:仅手动修改了 package.json

# 你手动把 "express": "^4.18.0" 改成 "^4.19.0"
# 此时 lock 文件还没变!

⚠️ 千万不要只提交 package.json,要先同步:

npm install   # 根据新的 package.json 更新 lock 文件
git add package.json package-lock.json
git commit -m "chore: bump express to 4.19"

场景 4:拉取同事代码后

git pull

# 发现 package.json 或 package-lock.json 有变化
npm install   # 立即同步依赖

黄金法则:package-lock.json 一变,立刻 npm install

否则你会遇到一堆找不到模块的错误,或者运行时诡异的 bug。

场景 5:CI/CD 和生产部署(重要!)

生产环境应该用 npm ci 而不是 npm install:

# ❌ 开发环境
npm install

# ✅ CI/CD 和生产环境
npm ci

两者的本质区别:

对比项 npm install npm ci
依据文件 package.json package-lock.json(严格)
lock 文件不一致时 自动修改 lock 直接报错退出
速度 较慢 快 2-10 倍
安装前 增量更新 删除整个 node_modules 重装
可重复性 可能不同 100% 可重复
修改 lock 文件 可能 绝不

👉 这也从侧面证明:如果不提交 lock 文件,npm ci 根本跑不起来

Dockerfile 最佳实践:

FROM node:20-alpine

WORKDIR /app

# 先拷贝依赖文件(利用 Docker 层缓存)
COPY package.json package-lock.json ./

# 生产环境用 ci,不装 devDependencies
RUN npm ci --omit=dev

# 再拷贝源代码
COPY . .

CMD ["node", "server.js"]

六、高阶:lock 文件冲突怎么解决?

团队协作时,两个同事都装了新包,合并时 package-lock.json 几乎必然冲突,满屏红色让人崩溃。

❌ 错误做法

手动去编辑 lock 文件里的 JSON——绝对不要这样做,几千行嵌套的依赖关系,手工合并几乎一定会搞出不一致。

✅ 正确做法

# 1. 先解决 package.json 的冲突(手动合并)
# 手动编辑 package.json,保留双方需要的依赖

# 2. 删除冲突的 lock 文件
rm package-lock.json

# 3. 重新生成
npm install

# 4. 提交
git add package.json package-lock.json
git commit -m "merge: resolve lock conflicts"

进阶方案:使用 npm 的合并驱动

npm 7+ 提供了更优雅的方案:

# 全局安装合并驱动
npx npm-merge-driver install --global

之后再遇到 lock 文件冲突,npm 会自动帮你合并。

七、延伸:yarn 和 pnpm 怎么办?

包管理器 lock 文件名 是否提交
npm package-lock.json
yarn yarn.lock
pnpm pnpm-lock.yaml

⚠️ 一个项目只选一种包管理器,不要同时存在多个 lock 文件,否则会造成严重的依赖不一致。

如果想强制团队使用统一的包管理器,可以在 package.json 中配置:

{
  "packageManager": "pnpm@8.15.0",
  "engines": {
    "node": ">=18.0.0",
    "npm": ">=9.0.0"
  }
}

或使用 only-allow:

{
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}

八、常见问题 FAQ

Q1:为什么我的 package-lock.json 每次 npm install 都会变?

可能原因:

  • 使用了不同版本的 npm(不同的 lockfileVersion)
  • 私有源和公共源的 resolved 地址不同
  • 有依赖标注了 latest* 这种不精确的版本

解决:团队统一 npm 版本,统一 registry,避免 *latest

Q2:可以不用 lock 文件吗?

技术上可以,但严重不推荐。没有 lock 文件 = 放弃依赖一致性保证,等于把线上稳定性交给运气。

Q3:package-lock.jsonnpm-shrinkwrap.json 有什么区别?

  • package-lock.json:只对当前项目生效,发布到 npm 时不会带上
  • npm-shrinkwrap.json:用于发布 CLI 工具时锁定依赖,会被发布到 npm

普通业务项目用前者即可。

Q4:monorepo 项目怎么管理 lock 文件?

使用 pnpm workspace / yarn workspaces / npm workspaces,根目录只有一个 lock 文件,所有子包共享。

九、一句话总结

package.json 声明"我想要什么",package-lock.json 记录"我实际装了什么"。

两者必须一起提交 Git,node_modules 永远不提交,生产部署用 npm ci

十、工程化 Checklist

在你的下一个 Node.js 项目中,检查一下这些项:

  • package.json 已提交到 Git
  • package-lock.json 已提交到 Git
  • .gitignore 中包含 node_modules/
  • .gitignore不包含 package-lock.json
  • CI/CD 部署脚本使用 npm ci 而非 npm install
  • Dockerfile 中先拷贝 lock 文件,再 npm ci
  • 团队统一了 Node.js 和 npm 版本
  • 项目中只有一种 lock 文件(npm / yarn / pnpm 三选一)
  • 拉取代码后养成 npm install 的习惯

做到这些,你就避开了 90% 的"依赖诡异问题"。


如果这篇文章对你有帮助,欢迎点赞、收藏、转发三连。

你在依赖管理上踩过哪些坑?欢迎在评论区交流 👇

你的 Vue 路由,VuReact 会编译成什么样的 React 路由?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天我们从 Vue Router 宏观对照入手,看看 Vue 中的路由组件、API 与入口结构,经过 VuReact 编译后会变成什么样的 React 路由代码。

另外,本文仅展示部分路由组件与 API,实际上完整适配还包括路由类型接口等更多内容,详情请查阅 VuReact Router 文档。

前置约定

为避免示例冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue Router API 用法与核心行为。

编译对照

router 组件:<router-link> / <router-view>

Vue 的路由组件在 React 中被映射为 @vureact/router 提供的适配组件。

  • Vue 代码:
<template>
  <router-link to="/home">Home</router-link>
  <router-view />
</template>
  • VuReact 编译后 React 代码:
import { RouterLink, RouterView } from '@vureact/router';

return (
  <>
    <RouterLink to="/home">Home</RouterLink>
    <RouterView />
  </>
);

RouterLink 在 React 中同样支持字符串 to、对象 toactiveClassNamecustomRender 等 Vue 风格用法;RouterView 负责渲染当前匹配路由组件,并保持嵌套路由、路由守卫与元字段的执行顺序。


路由配置:createRouter + history

Vue Router 的创建方式在 VuReact 中保持语义一致,但依赖会替换为 @vureact/router

  • Vue 代码:
import { createRouter, createWebHistory } from 'vue-router';
import Home from './views/Home.vue';

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
  ],
});
  • VuReact 编译后 React 代码:
import { createRouter, createWebHistory } from '@vureact/router';
import Home from './views/Home';

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
  ],
});

这说明:

  • createRouter / createWebHistory 等 API 名称保持不变;
  • 仅依赖路径会被替换成 @vureact/router
  • Vue Router 的路由记录、嵌套路由、meta 字段可直接保留。

入口注入:RouterProvider

如果启用了自动适配,VuReact 会在编译后自动调整入口文件,将原 <App /> 替换为路由实例的 RouterProvider

  • 生成后的 React 入口文件:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import RouterInstance from './router/index';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterInstance.RouterProvider />
  </StrictMode>,
);

该入口结构体现了 Vue 路由到 React 路由适配的宏观变化:

  • Vue 的路由配置文件继续作为路由实例入口;
  • React 入口通过 RouterProvider 挂载路由上下文;
  • 因此无需手动改写业务路由逻辑,只需保证路由定义规范。

运行时 API:useRouter / useRoute

Vue 的组合式路由 API 在 React 中仍保留相同语义。

  • Vue 代码:
const router = useRouter();
const route = useRoute();

const goHome = () => {
  router.push('/home');
};
  • VuReact 编译后 React 代码:
import { useRouter, useRoute } from '@vureact/router';

const router = useRouter();
const route = useRoute();

const goHome = useCallback(() => {
  router.push('/home');
}, [router]);

useRouter()useRoute() 仍然支持编程式导航、参数读取、meta 等字段,且使用方式与 Vue Router 组合式 API 语义保持一致。


自动适配

当编译器检测到项目中使用 Vue Router 时,会自动:

  • import ... from 'vue-router' 替换为 import ... from '@vureact/router'
  • 将路由配置文件产物变更为 @vureact/router 的路由实例;
  • 将入口文件自动改写为 RouterProvider 渲染。

配置示例:

import { defineConfig } from '@vureact/compiler-core';

export default defineConfig({
  router: {
    // 路由入口文件路径(即调用并默认导出 createRouter() 的地方)
    configFile: 'src/router/index.ts',
  },
});

手动适配

以下方案为通用建议,具体实现细节请开发者根据实际项目需求进行调整。

当选项 output.bootstrapVite 或者 router.autoSetupfalse 时,自动适配不可用,需要手动完成:

  • 导出 Vue Router 的 createRouter() 实例;
  • 在 React 入口文件中,将原本渲染 <App /> 的代码替换为 @vureact/router 路由实例所提供的 <RouterProvider /> 组件。

手动适配的核心是:保留 Vue Router 的路由定义与嵌套路由结构,导出路由器实例,替换 React 入口渲染方式。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

你的 Vue 3 defineAsyncComponent(),VuReact 会编译成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中用于异步组件的 defineAsyncComponent() 经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 defineAsyncComponent 的 API 用法与核心行为。

编译对照

Vue defineAsyncComponent() → React defineAsyncComponent()

defineAsyncComponent 是 Vue 3 中用于定义异步组件的 API,它允许你按需加载组件,优化应用性能。VuReact 会将其编译为同名的 defineAsyncComponent,让 React 中也能获得同样的异步组件能力。

  • Vue 代码:
<script setup>
  import { defineAsyncComponent } from 'vue';

  const AsyncComponent = defineAsyncComponent(() =>
    import('./components/AsyncComponent.vue')
  );
</script>

<template>
  <AsyncComponent />
</template>
  • VuReact 编译后 React 代码:
import { defineAsyncComponent } from '@vureact/runtime-core';

const AsyncComponent = defineAsyncComponent(() =>
  import('./components/AsyncComponent')
);

function MyComponent() {
  return <AsyncComponent />;
}

VuReact 提供的 defineAsyncComponentVue defineAsyncComponent 的适配 API,可理解为「React 版的 Vue defineAsyncComponent」,完全模拟 Vue defineAsyncComponent 的异步加载行为——支持懒加载、加载状态处理、错误处理等完整功能。

defineAsyncComponent 高级用法

defineAsyncComponent 在 Vue 3 中支持多种配置选项,如加载状态组件、错误处理组件、超时设置等。VuReact 会将其编译为相应的 React 配置,保持功能一致性。

  • Vue 代码:
<script setup>
  import { defineAsyncComponent } from 'vue';

  const AsyncComponent = defineAsyncComponent({
    loader: () => import('./components/HeavyComponent.vue'),
    loadingComponent: LoadingSpinner,
    errorComponent: ErrorDisplay,
    delay: 200,
    timeout: 3000,
    suspensible: true,
  });
</script>
  • VuReact 编译后 React 代码:
import { defineAsyncComponent } from '@vureact/runtime-core';
import LoadingSpinner from './components/LoadingSpinner';
import ErrorDisplay from './components/ErrorDisplay';

const AsyncComponent = defineAsyncComponent({
  loader: () => import('./components/HeavyComponent'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,
  timeout: 3000,
  suspensible: true,
});

VuReact 提供的 defineAsyncComponent 支持 所有 Vue defineAsyncComponent 的配置选项,包括 loaderloadingComponenterrorComponentdelaytimeoutsuspensible 等,完全模拟 Vue defineAsyncComponent 的高级功能——在 React 中实现与 Vue 一致的异步组件体验。

请注意,hydrate 选项不支持,但保留了该选项进行兼容,无实际功能。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

使用 zed 和 使用 vscode 开发 flutter

发现 zed 安装完 dart 扩展,配置好 snippets 以后,

首先启动 ios 模拟器

open -a simulator

打开 zed 终端,部署 flutter 代码到模拟器

flutter run

image.png

vscode 保存完代码立即出发热重载,zed 需要在 终端里面 按下 字母 r 自己手动刷一下。

另外就是 zed 没有 Flutter Inspector 功能,

image.png

android studio 的 Flutter Inspector 功能,也比较蛋疼, 入口在调试面板有一个小图标 ,要借助浏览器窗口看。

写 flutter 的 编辑器 vscode 是最全的,也是最强大的 ,zed 和 android studio 也可以做,

为 Zed 编辑器 添加 flutter dart snippets

zed 添加 snippets 和 vscode 的 snippets 看起来非常的像,官方文档示例地址如下:zed.dev/docs/snippe…

参考了 宁浩 老师的 vscode snippets : github.com/ninghao/vsc…

为 zed 添加 snipptes 步骤如下:

  • command + shift + p 或者用 菜单 go-> command palette..., 然后输入 snippets: configure snippets 回车确定

image.png

image.png

  • select snippets scope 输入 dart

image.png

回车确定会打开 dart.json, 把如下代码复制粘贴进入你 zed 配置文件里面

{
  "Flutter Stateless Widget": {
    "prefix": "sl",
    "body": [
      "class ${1:MyWidget} extends StatelessWidget {",
      "\tconst ${1:MyWidget}({super.key});",
      "\t@override",
      "\tWidget build(BuildContext context) {",
      "\t\treturn const ${2:Placeholder}();",
      "\t}",
      "}"
    ],
    "description": "Flutter Stateless Widget"
  },
  "Flutter Stateful Widget": {
    "prefix": "sf",
    "body": [
      "class ${1:MyWidget} extends StatefulWidget {",
      "\tconst ${1:MyWidget}({super.key});\n",
      "\t@override",
      "\tState<${1:MyWidget}> createState() => _${1:MyWidget}State();",
      "}\n",
      "class _${1:MyWidget}State extends State<${1:MyWidget}> {",
      "\t@override",
      "\tWidget build(BuildContext context) {",
      "\t\treturn const ${2:Placeholder}();",
      "\t}",
      "}"
    ],
    "description": "Flutter Stateful Widget"
  },
  "Flutter Widget with AnimationController": {
    "prefix": "wwa",
    "body": [
      "class ${1:MyWidget} extends StatefulWidget {",
      "\tconst ${1:MyWidget}({super.key});\n",
      "\t@override",
      "\tState<${1:MyWidget}> createState() => _${1:MyWidget}State();",
      "}\n",
      "class _${1:MyWidget}State extends State<${1:MyWidget}>",
      "  with SingleTickerProviderStateMixin {",
      "\tlate AnimationController _controller;\n",
      "\t@override",
      "\tvoid initState() {",
      "\t\tsuper.initState();",
      "\t\t_controller = AnimationController(vsync: this);",
      "\t}\n",
      "\t@override",
      "\tvoid dispose() {",
      "\t\t_controller.dispose();",
      "\t\tsuper.dispose();",
      "\t}\n",
      "\t@override",
      "\tWidget build(BuildContext context) {",
      "\t\treturn const ${2:Placeholder}();",
      "\t}",
      "}"
    ],
    "description": "Flutter Stateful Widget"
  },
  "StatelessWidget with Scaffold": {
    "prefix": "sls",
    "body": [
      "class ${1:WidgetName} extends StatelessWidget {",
      "\t@override",
      "\tWidget build(BuildContext context) {",
      "\t\treturn ${Scaffold}(",
      "\t\t\tappBar: AppBar(",
      "\t\t\t\ttitle: Text('${1:WidgetName}'),",
      "\t\t\t\televation: 0.0,",
      "\t\t\t),${2}",
      "\t\t);",
      "\t}",
      "}"
    ],
    "description": "StatelessWidget with Scaffold"
  },
  "StatefulWidget with Scaffold": {
    "prefix": "sfs",
    "body": [
      "class ${1:WidgetName} extends StatefulWidget {",
      "\t@override",
      "\t_${1:WidgetName}State createState() => _${1:WidgetName}State();",
      "}\n",
      "class _${1:WidgetName}State extends State<${1:WidgetName}> {",
      "\t@override",
      "\tWidget build(BuildContext context) {",
      "\t\treturn ${Scaffold}(",
      "\t\t\tappBar: AppBar(",
      "\t\t\t\ttitle: Text('${1:WidgetName}'),",
      "\t\t\t\televation: 0.0,",
      "\t\t\t),${2}",
      "\t\t);",
      "\t}",
      "}"
    ],
    "description": "StatefulWidget with Scaffold"
  },
  "InheritedWidget": {
    "prefix": "ih",
    "body": [
      "class ${1:WidgetName} extends InheritedWidget {",
      "\tfinal Widget child;",
      "\t${2}",
      "\t${1:WidgetName}({",
      "\t\tthis.child,",
      "\t\t${2}",
      "\t}) : super(child: child);\n",
      "\tstatic ${1:WidgetName} of(BuildContext context) =>",
      "\t\t\tcontext.inheritFromWidgetOfExactType(${1:WidgetName});\n",
      "\t@override",
      "\tbool updateShouldNotify(${1:WidgetName} oldWidget) {",
      "\t\treturn true;",
      "\t}",
      "}"
    ],
    "description": "InheritedWidget"
  },
  "setState": {
    "prefix": "ss",
    "body": ["setState(() {${1}});"],
    "description": "setState"
  },
  "build": {
    "prefix": "build",
    "body": [
      "@override",
      "Widget build(BuildContext context) {",
      "\treturn ${1:Container}(${2});",
      "}"
    ],
    "description": "Build Method"
  }
}


其中前三个是 和 vscodeflutter 扩展带来的 snippets 完全一致, 即在vscode里面打开 .dart为后缀的文件,输入小写 字母 s 触发,新建的 .dart 文件需要保存一下, 你要安装的有 flutter 扩展。

image.png

Element Plus 组件库实战技巧与踩坑记录

🎨 Element Plus 组件库实战技巧与踩坑记录

分享我在Vue 3项目中使用Element Plus的经验技巧和踩坑记录

前言

Element Plus是Vue 3生态中最流行的UI组件库之一,提供了丰富的组件和良好的设计。在开发博客项目的过程中,我积累了很多使用Element Plus的经验和技巧,也踩过一些坑。本文将分享这些实战经验。

快速上手

1. 安装与配置

# 安装Element Plus
npm install element-plus

# 安装图标库
npm install @element-plus/icons-vue
// main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'

const app = createApp(App)

// 注册所有组件
app.use(ElementPlus)

// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.mount('#app')

2. 按需引入(推荐)

为了减小包体积,建议按需引入组件:

# 安装按需引入插件
npm install -D unplugin-vue-components unplugin-auto-import
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})

这样配置后,使用组件时会自动按需引入,无需手动import。

常用组件技巧

1. 表单组件

el-form深度验证
<template>
  <el-form
    ref="formRef"
    :model="formData"
    :rules="rules"
    label-width="120px"
  >
    <el-form-item label="标题" prop="title">
      <el-input v-model="formData.title" />
    </el-form-item>

    <el-form-item label="邮箱" prop="email">
      <el-input v-model="formData.email" />
    </el-form-item>

    <el-form-item label="密码" prop="password">
      <el-input
        v-model="formData.password"
        type="password"
        show-password
      />
    </el-form-item>

    <el-form-item>
      <el-button type="primary" @click="handleSubmit">
        提交
      </el-button>
      <el-button @click="handleReset">
        重置
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'

const formRef = ref<FormInstance>()

const formData = reactive({
  title: '',
  email: '',
  password: ''
})

const rules = reactive<FormRules>({
  title: [
    { required: true, message: '请输入标题', trigger: 'blur' },
    { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
  ],
  email: [
    { required: true, message: '请输入邮箱地址', trigger: 'blur' },
    { type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' }
  ]
})

const handleSubmit = async () => {
  if (!formRef.value) return

  await formRef.value.validate((valid, fields) => {
    if (valid) {
      // 验证通过,提交表单
      console.log('提交:', formData)
    } else {
      console.log('验证失败:', fields)
    }
  })
}

const handleReset = () => {
  formRef.value?.resetFields()
}
</script>
动态表单
<template>
  <el-form :model="formData">
    <el-form-item
      v-for="(item, index) in formData.items"
      :key="index"
      :label="'项目 ' + (index + 1)"
    >
      <el-input v-model="item.value" />
      <el-button
        @click="removeItem(index)"
        icon="Delete"
        type="danger"
      >
        删除
      </el-button>
    </el-form-item>

    <el-button @click="addItem" icon="Plus">
      添加项目
    </el-button>
  </el-form>
</template>

<script setup lang="ts">
const formData = reactive({
  items: [{ value: '' }]
})

const addItem = () => {
  formData.items.push({ value: '' })
}

const removeItem = (index: number) => {
  formData.items.splice(index, 1)
}
</script>

2. 表格组件

表格排序和筛选
<template>
  <el-table
    :data="filteredData"
    :default-sort="{ prop: 'date', order: 'descending' }"
    @sort-change="handleSortChange"
  >
    <el-table-column prop="title" label="标题" sortable />
    <el-table-column
      prop="category"
      label="分类"
      :filters="categoryFilters"
      :filter-method="filterCategory"
    />
    <el-table-column prop="views" label="浏览量" sortable />
    <el-table-column prop="date" label="日期" sortable />
  </el-table>
</template>

<script setup lang="ts">
const articles = ref<Article[]>([])

const filteredData = computed(() => {
  return articles.value
})

const categoryFilters = [
  { text: 'Vue', value: 'Vue' },
  { text: 'React', value: 'React' },
  { text: 'TypeScript', value: 'TypeScript' }
]

const filterCategory = (value: string, row: Article) => {
  return row.category === value
}

const handleSortChange = (sort: any) => {
  console.log('排序改变:', sort)
}
</script>
表格分页
<template>
  <el-table :data="paginatedData">
    <!-- 列定义 -->
  </el-table>

  <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :total="total"
    :page-sizes="[10, 20, 50, 100]"
    layout="total, sizes, prev, pager, next, jumper"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />
</template>

<script setup lang="ts">
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)

const paginatedData = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return articles.value.slice(start, end)
})

const handleSizeChange = (size: number) => {
  pageSize.value = size
}

const handleCurrentChange = (page: number) => {
  currentPage.value = page
}
</script>

3. 弹窗组件

对话框嵌套
<template>
  <el-button @click="showDialog = true">打开对话框</el-button>

  <el-dialog v-model="showDialog" title="父对话框">
    <p>这是父对话框的内容</p>

    <el-button @click="showChildDialog = true">
      打开子对话框
    </el-button>

    <el-dialog
      v-model="showChildDialog"
      title="子对话框"
      append-to-body
    >
      <p>这是子对话框的内容</p>
    </el-dialog>
  </el-dialog>
</template>

<script setup lang="ts">
const showDialog = ref(false)
const showChildDialog = ref(false)
</script>

注意:嵌套对话框时,子对话框需要添加append-to-body属性。

4. 树形组件

异步加载树
<template>
  <el-tree
    :props="defaultProps"
    :load="loadNode"
    lazy
    show-checkbox
  />
</template>

<script setup lang="ts">
const defaultProps = {
  label: 'name',
  children: 'children',
  isLeaf: 'leaf'
}

const loadNode = async (node: Node, resolve: (data: TreeData[]) => void) => {
  if (node.level === 0) {
    // 加载根节点
    const data = await loadRootNodes()
    resolve(data)
  } else {
    // 加载子节点
    const data = await loadChildNodes(node.data.id)
    resolve(data)
  }
}

const loadRootNodes = async () => {
  // 异步加载数据
  return [
    { name: '节点1', id: 1 },
    { name: '节点2', id: 2 }
  ]
}
</script>

主题定制

1. 使用CSS变量

// styles/theme.scss
:root {
  --el-color-primary: #409eff;
  --el-color-success: #67c23a;
  --el-color-warning: #e6a23c;
  --el-color-danger: #f56c6c;
  --el-color-info: #909399;
}

// 使用自定义主题
$--color-primary: var(--el-color-primary);

2. SCSS变量覆盖

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ElementPlus from 'unplugin-element-plus/vite'

export default defineConfig({
  plugins: [
    vue(),
    ElementPlus({
      // 使用scss样式
      useSource: true
    })
  ]
})
// styles/element-variables.scss
/* 改变主题色变量 */
$--color-primary: #1890ff;
$--color-success: #52c41a;
$--color-warning: #faad14;
$--color-danger: #f5222d;
$--color-info: #909399;

/* 改变icon字体路径变量,必需 */
$--font-path: '~element-plus/lib/theme-chalk/fonts';

@import "~element-plus/packages/theme-chalk/src/index";

3. 暗黑模式

<template>
  <el-switch
    v-model="isDark"
    @change="toggleDark"
    inline-prompt
    active-text="暗"
    inactive-text="亮"
  />
</template>

<script setup lang="ts">
const isDark = ref(false)

const toggleDark = (value: boolean) => {
  if (value) {
    document.documentElement.classList.add('dark')
  } else {
    document.documentElement.classList.remove('dark')
  }
}
</script>

<style>
/* 暗黑模式样式 */
html.dark {
  --el-bg-color: #141414;
  --el-text-color-primary: #e5eaf3;
  --el-border-color: #4c4d4f;
  --el-border-color-light: #414243;
}
</style>

性能优化

1. 图标按需加载

// utils/icons.ts
import { registerIcons } from 'element-plus/es/components/icon'

// 只注册需要的图标
export function lazyRegisterIcons() {
  const icons = [
    'Edit',
    'Delete',
    'View',
    'Download',
    'Share',
    'Star',
    'Plus',
    'Search',
    'Home'
  ]

  // 使用requestIdleCallback在空闲时注册
  const idleCallback = window.requestIdleCallback || window.setTimeout

  idleCallback(() => {
    registerIcons(icons)
  })
}

// main.ts
import { lazyRegisterIcons } from './utils/icons'
lazyRegisterIcons()

2. 虚拟滚动

<template>
  <el-virtual-list
    :data="items"
    :height="400"
    :item-size="50"
  >
    <template #default="{ item, index }">
      <div class="item">
        {{ index }} - {{ item.name }}
      </div>
    </template>
  </el-virtual-list>
</template>

<script setup lang="ts">
import { ElVirtualList } from 'element-plus'

// 生成大量数据
const items = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`
}))
</script>

踩坑记录

1. Dialog关闭不触发事件

问题:点击遮罩层关闭Dialog时,没有触发关闭事件。

解决:使用before-close属性:

<el-dialog
  v-model="visible"
  :before-close="handleClose"
>
  <template #header>
    <span>标题</span>
  </template>
</el-dialog>

<script setup lang="ts">
const handleClose = (done: () => void) => {
  // 执行关闭前的逻辑
  done()
}
</script>

2. Table固定列错位

问题:表格固定列在滚动时出现错位。

解决:监听窗口大小变化,调用doLayout方法:

<template>
  <el-table
    ref="tableRef"
    :data="tableData"
  >
    <el-table-column prop="date" label="日期" fixed />
    <el-table-column prop="name" label="姓名" />
  </el-table>
</template>

<script setup lang="ts">
const tableRef = ref()

onMounted(() => {
  window.addEventListener('resize', () => {
    tableRef.value?.doLayout()
  })
})
</script>

3. Select下拉框显示位置错误

问题:Select组件的下拉框在页面滚动后显示位置错误。

解决:使用popper-options配置:

<el-select
  v-model="value"
  :popper-options="{
    modifiers: [
      {
        name: 'flip',
        options: {
          fallbackPlacements: ['bottom-start', 'top-start']
        }
      }
    ]
  }"
>
  <el-option
    v-for="item in options"
    :key="item.value"
    :label="item.label"
    :value="item.value"
  />
</el-select>

4. DatePicker时间格式问题

问题:DatePicker返回的日期格式不符合预期。

解决:使用value-format属性:

<el-date-picker
  v-model="date"
  type="datetime"
  value-format="YYYY-MM-DD HH:mm:ss"
  placeholder="选择日期时间"
/>

5. Upload组件上传失败

问题:Upload组件在某些情况下上传失败。

解决:正确处理on-successon-error回调:

<el-upload
  action="/api/upload"
  :on-success="handleSuccess"
  :on-error="handleError"
  :before-upload="beforeUpload"
>
  <el-button type="primary">上传文件</el-button>
</el-upload>

<script setup lang="ts">
const handleSuccess = (response: any, file: any) => {
  if (response.code === 200) {
    ElMessage.success('上传成功')
  } else {
    ElMessage.error(response.message)
  }
}

const handleError = (error: any) => {
  ElMessage.error('上传失败:' + error.message)
}

const beforeUpload = (file: File) => {
  const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
  const isLt2M = file.size / 1024 / 1024 < 2

  if (!isJPG) {
    ElMessage.error('只能上传JPG/PNG图片!')
  }
  if (!isLt2M) {
    ElMessage.error('图片大小不能超过2MB!')
  }
  return isJPG && isLt2M
}
</script>

最佳实践

1. 统一配置

// config/element-plus.ts
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'

export default {
  locale: zhCn,
  size: 'default',
  zIndex: 3000
}
<!-- App.vue -->
<template>
  <el-config-provider :locale="locale">
    <router-view />
  </el-config-provider>
</template>

<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const locale = zhCn
</script>

2. 封装常用组件

<!-- components/SearchInput.vue -->
<template>
  <el-input
    v-model="searchText"
    :placeholder="placeholder"
    clearable
    @clear="handleClear"
    @input="handleInput"
  >
    <template #prefix>
      <el-icon><Search /></el-icon>
    </template>
    <template #suffix>
      <el-button
        v-if="searchText"
        link
        icon="Close"
        @click="handleClear"
      />
    </template>
  </el-input>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'

interface Props {
  modelValue: string
  placeholder?: string
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入搜索内容'
})

const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
  (e: 'search', value: string): void
}>()

const searchText = ref(props.modelValue)

watch(() => props.modelValue, (val) => {
  searchText.value = val
})

watch(searchText, (val) => {
  emit('update:modelValue', val)
})

const handleClear = () => {
  searchText.value = ''
  emit('search', '')
}

const handleInput = debounce((value: string) => {
  emit('search', value)
}, 300)
</script>

3. 全局样式覆盖

// styles/element-overrides.scss

// 全局修改el-button样式
.el-button {
  border-radius: 4px;
  font-weight: 500;

  &--primary {
    background-color: #1890ff;
    border-color: #1890ff;

    &:hover {
      background-color: #40a9ff;
      border-color: #40a9ff;
    }
  }
}

// 修改el-dialog样式
.el-dialog {
  border-radius: 8px;
  overflow: hidden;

  .el-dialog__header {
    padding: 20px 20px 10px;
    border-bottom: 1px solid #f0f0f0;
  }

  .el-dialog__body {
    padding: 20px;
  }
}

总结

Element Plus是一个功能强大、设计优秀的UI组件库,掌握以下要点可以更好地使用它:

  1. 按需引入 - 减小包体积
  2. 主题定制 - 符合项目风格
  3. 性能优化 - 图标懒加载、虚拟滚动
  4. 踩坑经验 - 了解常见问题和解决方案
  5. 最佳实践 - 封装常用组件、统一配置

希望这些经验能帮助你在Vue 3项目中更好地使用Element Plus!


标签:#ElementPlus #Vue3 #UI组件库 #前端 #实战技巧

点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!

Vue 3项目架构设计:从2200行单文件到24个组件

🏗️ Vue 3项目架构设计:从2200行单文件到24个组件

分享我在Vue 3博客项目中的架构重构经验,代码可维护性大幅提升

前言

在项目初期,为了快速实现功能,我把大部分代码都写在了App.vue中,导致单文件达到了2200多行。随着功能增多,代码越来越难以维护。于是我开始进行架构重构,将代码拆分成24个独立组件,最终实现了更好的代码组织和可维护性。

重构前后对比

代码结构对比

重构前:

App.vue (2200+ 行)
├── 布局代码
├── 业务逻辑
├── 组件代码
└── 工具函数

重构后:

src/
├── components/
│   ├── layout/        (5个组件)
│   ├── features/      (4个组件)
│   ├── gamification/  (4个组件)
│   └── article/       (6个组件)
├── composables/       (5个组合函数)
├── utils/             (3个工具模块)
└── views/             (5个页面组件)

数据对比

指标 重构前 重构后 改善
单文件最大行数 2200+ 400 ⬇️ 82%
组件数量 1 24 ⬆️ 24倍
代码复用率 0% 40%+ ⬆️ 40%
可维护性 ⬆⬆⬆

架构设计原则

1. 单一职责原则

每个组件只负责一个功能模块。

<!-- ❌ 错误:一个组件包含多个职责 -->
<template>
  <div>
    <Header />
    <ArticleList />
    <Sidebar />
    <MusicPlayer />
    <Notification />
    <Footer />
  </div>
</template>

<!-- ✅ 正确:每个组件单一职责 -->
<template>
  <div>
    <AppBackground />
    <TheHeader />
    <TheMain>
      <RouterView />
    </TheMain>
    <TheFooter />
    <BackToTop />
    <Notification />
  </div>
</template>

2. 开闭原则

通过props和emits扩展组件功能,不修改组件内部代码。

<!-- ArticleCard.vue -->
<template>
  <article :class="['article-card', variant]">
    <ArticleMeta :article="article" />
    <ArticleContent :article="article" />
    <slot name="actions">
      <ArticleActions :article="article" />
    </slot>
  </article>
</template>

<script setup lang="ts">
interface Props {
  article: Article
  variant?: 'default' | 'compact' | 'featured'
}

defineProps<Props>()
</script>

3. 依赖倒置原则

组件依赖于抽象的接口(props/emits),而非具体实现。

// composables/usePagination.ts
export function usePagination(options: PaginationOptions) {
  const currentPage = ref(options.page || 1)
  const pageSize = ref(options.pageSize || 10)

  const nextPage = () => {
    currentPage.value++
  }

  const prevPage = () => {
    currentPage.value--
  }

  return {
    currentPage,
    pageSize,
    nextPage,
    prevPage
  }
}

组件分类体系

1. 布局组件(5个)

AppBackground
<!-- components/layout/AppBackground.vue -->
<template>
  <div class="app-background">
    <div class="gradient-bg"></div>
    <div class="particles"></div>
  </div>
</template>

<script setup lang="ts">
// 背景动画逻辑
</script>

<style scoped>
.app-background {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
}
</style>
TheHeader
<!-- components/layout/TheHeader.vue -->
<template>
  <header class="header">
    <Logo />
    <Navigation />
    <SearchTrigger />
    <SettingsTrigger />
    <NotificationTrigger />
  </header>
</template>

<script setup lang="ts">
import Logo from './Logo.vue'
import Navigation from './Navigation.vue'
import SearchTrigger from './SearchTrigger.vue'
</script>
TheFooter
<!-- components/layout/TheFooter.vue -->
<template>
  <footer class="footer">
    <Copyright />
    <SocialLinks />
    <Links />
  </footer>
</template>
BackToTop
<!-- components/layout/BackToTop.vue -->
<template>
  <transition name="fade">
    <button
      v-show="visible"
      @click="scrollToTop"
      class="back-to-top"
    >
      <el-icon><ArrowUp /></el-icon>
    </button>
  </transition>
</template>

<script setup lang="ts">
const visible = ref(false)

onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

const handleScroll = () => {
  visible.value = window.scrollY > 300
}

const scrollToTop = () => {
  window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
ReadingProgressBar
<!-- components/layout/ReadingProgressBar.vue -->
<template>
  <div class="reading-progress">
    <div
      class="progress-bar"
      :style="{ width: progress + '%' }"
    ></div>
  </div>
</template>

<script setup lang="ts">
const progress = ref(0)

const updateProgress = () => {
  const scrollTop = window.scrollY
  const docHeight = document.documentElement.scrollHeight - window.innerHeight
  progress.value = (scrollTop / docHeight) * 100
}

onMounted(() => {
  window.addEventListener('scroll', updateProgress)
})
</script>

2. 功能组件(4个)

Notification
<!-- components/features/Notification.vue -->
<template>
  <transition-group name="notification">
    <div
      v-for="notif in notifications"
      :key="notif.id"
      :class="['notification', notif.type]"
    >
      <el-icon><component :is="notif.icon" /></el-icon>
      <span>{{ notif.message }}</span>
      <el-button
        icon="Close"
        @click="remove(notif.id)"
      />
    </div>
  </transition-group>
</template>

<script setup lang="ts">
import { useNotification } from '@/composables/useNotification'

const { notifications, remove } = useNotification()
</script>
SearchPanel
<!-- components/features/SearchPanel.vue -->
<template>
  <div class="search-panel">
    <el-input
      v-model="searchText"
      placeholder="搜索文章..."
      @input="handleSearch"
    >
      <template #prefix>
        <el-icon><Search /></el-icon>
      </template>
    </el-input>

    <div class="search-results">
      <ArticleCard
        v-for="article in results"
        :key="article.id"
        :article="article"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
const searchText = ref('')
const results = ref<Article[]>([])

const handleSearch = debounce(async (text: string) => {
  if (!text) {
    results.value = []
    return
  }
  results.value = await searchArticles(text)
}, 300)
</script>
SettingsPanel
<!-- components/features/SettingsPanel.vue -->
<template>
  <div class="settings-panel">
    <SettingSection title="主题">
      <ThemeToggle />
    </SettingSection>

    <SettingSection title="字体">
      <FontSizeSlider />
    </SettingSection>

    <SettingSection title="其他">
      <el-checkbox v-model="settings.enableMusic">
        启用背景音乐
      </el-checkbox>
    </SettingSection>
  </div>
</template>

<script setup lang="ts">
const settings = useSettings()
</script>
KeyboardHints
<!-- components/features/KeyboardHints.vue -->
<template>
  <div class="keyboard-hints">
    <kbd v-for="hint in hints" :key="hint.key">
      {{ hint.key }}
      <span>{{ hint.action }}</span>
    </kbd>
  </div>
</template>

<script setup lang="ts">
const hints = [
  { key: 'K', action: '搜索' },
  { key: 'N', action: '下一篇' },
  { key: 'P', action: '上一篇' }
]
</script>

3. 游戏化组件(4个)

EnergyDisplay
<!-- components/gamification/EnergyDisplay.vue -->
<template>
  <div class="energy-display">
    <div class="energy-bar">
      <div
        class="energy-fill"
        :style="{ width: energyPercentage + '%' }"
      ></div>
    </div>
    <div class="energy-value">{{ energy }}/100</div>
  </div>
</template>

<script setup lang="ts">
const { energy } = useEnergy()
const energyPercentage = computed(() => energy.value)
</script>
SignDialog
<!-- components/gamification/SignDialog.vue -->
<template>
  <el-dialog v-model="visible" title="每日签到">
    <div class="sign-calendar">
      <div
        v-for="day in 7"
        :key="day"
        :class="['sign-day', signedDays.includes(day) ? 'signed' : '']"
      >
        {{ day }}
      </div>
    </div>

    <el-button
      type="primary"
      :disabled="signedToday"
      @click="handleSign"
    >
      {{ signedToday ? '已签到' : '签到' }}
    </el-button>
  </el-dialog>
</template>

<script setup lang="ts">
const { signedDays, signedToday, sign } = useSign()
const visible = ref(false)

const handleSign = () => {
  sign()
}
</script>
MusicPlayer
<!-- components/gamification/MusicPlayer.vue -->
<template>
  <div class="music-player">
    <div class="player-info">
      <img :src="currentTrack.cover" :alt="currentTrack.name" />
      <div class="track-info">
        <div class="track-name">{{ currentTrack.name }}</div>
        <div class="track-artist">{{ currentTrack.artist }}</div>
      </div>
    </div>

    <div class="player-controls">
      <button @click="prevTrack">
        <el-icon><DArrowLeft /></el-icon>
      </button>
      <button @click="togglePlay">
        <el-icon><component :is="isPlaying ? VideoPause : VideoPlay" /></el-icon>
      </button>
      <button @click="nextTrack">
        <el-icon><DArrowRight /></el-icon>
      </button>
    </div>

    <div class="player-progress">
      <div
        class="progress-bar"
        :style="{ width: progress + '%' }"
      ></div>
    </div>
  </div>
</template>

<script setup lang="ts">
const {
  currentTrack,
  isPlaying,
  progress,
  togglePlay,
  prevTrack,
  nextTrack
} = useMusicPlayer()
</script>

4. 文章组件(6个)

ArticleCard
<!-- components/article/ArticleCard.vue -->
<template>
  <article class="article-card">
    <ArticleMeta :article="article" />
    <ArticleContent :article="article" />
    <ArticleActions :article="article" />
  </article>
</template>

<script setup lang="ts">
import ArticleMeta from './ArticleMeta.vue'
import ArticleContent from './ArticleContent.vue'
import ArticleActions from './ArticleActions.vue'

defineProps<{ article: Article }>()
</script>
ArticleMeta
<!-- components/article/ArticleMeta.vue -->
<template>
  <div class="article-meta">
    <div class="meta-row">
      <span class="author">{{ article.author }}</span>
      <span class="date">{{ formatDate(article.date) }}</span>
    </div>

    <div class="tags">
      <el-tag
        v-for="tag in article.tags"
        :key="tag"
        size="small"
      >
        {{ tag }}
      </el-tag>
    </div>
  </div>
</template>

<script setup lang="ts">
import { formatDate } from '@/utils/format'

defineProps<{ article: Article }>()
</script>

Composables设计

useArticle

// composables/useArticle.ts
export function useArticle() {
  const articles = ref<Article[]>([])
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const fetchArticles = async () => {
    loading.value = true
    try {
      articles.value = await getArticles()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  const getArticleById = (id: number) => {
    return articles.value.find(a => a.id === id)
  }

  return {
    articles,
    loading,
    error,
    fetchArticles,
    getArticleById
  }
}

useTheme

// composables/useTheme.ts
export function useTheme() {
  const isDark = ref(false)

  const toggleTheme = () => {
    isDark.value = !isDark.value
    document.documentElement.classList.toggle('dark')
  }

  return {
    isDark,
    toggleTheme
  }
}

useLocalStorage

// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = ref<T>(defaultValue)

  // 初始化时读取
  const init = () => {
    const item = localStorage.getItem(key)
    if (item) {
      try {
        stored.value = JSON.parse(item)
      } catch (e) {
        console.error('Failed to parse localStorage', e)
      }
    }
  }

  // 监听变化并保存
  watch(stored, (value) => {
    localStorage.setItem(key, JSON.stringify(value))
  }, { deep: true })

  init()

  return stored
}

组件通信方式

1. Props Down

<!-- 父组件 -->
<ArticleCard :article="article" variant="featured" />

<!-- 子组件 -->
<script setup lang="ts">
interface Props {
  article: Article
  variant?: 'default' | 'compact' | 'featured'
}

defineProps<Props>()
</script>

2. Emits Up

<!-- 子组件 -->
<script setup lang="ts">
const emit = defineEmits<{
  (e: 'like', articleId: number): void
  (e: 'collect', articleId: number): void
}>()

const handleLike = () => {
  emit('like', props.article.id)
}
</script>

<!-- 父组件 -->
<ArticleCard @like="handleLike" />

3. Provide/Inject

// 祖先组件
provide('theme', isDark)

// 后代组件
const theme = inject('theme')

4. Event Bus

// utils/eventBus.ts
import mitt from 'mitt'

export const eventBus = mitt<{
  notification: NotificationEvent
  refresh: void
}>()

// 发送事件
eventBus.emit('notification', { type: 'success', message: '操作成功' })

// 监听事件
eventBus.on('notification', (event) => {
  // 处理通知
})

性能优化

1. 组件懒加载

const HeavyComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000
})

2. 虚拟滚动

<VirtualList
  :data-sources="articles"
  :data-key="'id'"
  :keeps="30"
/>

3. 计算属性缓存

const hotArticles = computed(() => {
  return articles.value
    .filter(a => a.views > 1000)
    .sort((a, b) => b.views - a.views)
})

最佳实践

1. 组件命名

  • 使用PascalCase
  • 组件名与文件名保持一致
  • 使用语义化的名称

2. Props定义

  • 明确定义类型
  • 提供合理的默认值
  • 使用TypeScript类型检查

3. 样式管理

  • 使用scoped CSS
  • 避免样式污染
  • 使用CSS变量

总结

通过合理的架构设计和组件拆分,我们实现了:

  1. 更好的代码组织 - 职责清晰,易于理解
  2. 更高的可维护性 - 修改某个功能只需修改对应组件
  3. 更强的可复用性 - 组件可在多个页面中复用
  4. 更好的可测试性 - 独立组件更容易编写单元测试
  5. 更高的开发效率 - 团队成员可同时开发不同组件

标签:#Vue3 #组件化 #架构设计 #前端 #代码重构

点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!

m3u8 视频怎么下载?为什么 B 站只给你一个 blob:把 HLS、DASH、MSE 这条前端链路讲透

写“怎么下载 m3u8 视频”最容易犯的错,就是把所有网页视频都当成同一种东西。

这也是为什么很多教程看着有用,一到真实站点就突然失灵。

尤其你拿 B 站做例子的时候,这个错会暴露得特别明显。你在 DOM 里看到的是:

<video src="blob:https://www.bilibili.com/..."></video>

然后第一反应通常是两种:

  • 视频源地址被藏在 blob: 里了。
  • 只要想办法把这个 blob: 复制出来,我就能把视频下走。

这两个判断,基本都不对。

先把结论说死一点

如果你只记一句,我希望是这句:

blob: 通常不是视频源地址,而是播放器把内存里的 BlobMediaSource 挂到 <video> 之后生成的本地对象 URL。

更进一步:

  • 普通 m3u8/HLS 场景,核心难点是拿到 playlist、分片、密钥,然后按顺序拉流。
  • 像 B 站这种网页播放场景,你看到 blob: 时,背后常常已经不是单纯的 m3u8,而是 DASH + MSE + 音视频分离 + Range 请求 + 索引解析

这两类问题,不是一个难度。

我为什么说 B 站这个例子更能说明问题

为了不空讲,我在 2026-04-17 直接抓了一条公开的 B 站视频页:https://www.bilibili.com/video/BV1xx411c7mD/

这条页里最关键的东西不是 <video src>

而是页面内嵌的:

window.__playinfo__;

我实际拿到的数据里,有几个信息特别关键:

  • window.__playinfo__.data.dash.video[]
  • window.__playinfo__.data.dash.audio[]
  • videoCount = 6
  • audioCount = 2
  • 第一条视频轨是 video/mp4
  • 第一条音频轨是 audio/mp4
  • 每条轨道都有 SegmentBase.Initialization
  • 每条轨道都有 indexRange

我把其中一部分精简成这样:

{
  "format": "flv480",
  "quality": 32,
  "duration": 2056,
  "videoCount": 6,
  "audioCount": 2,
  "firstVideo": {
    "mimeType": "video/mp4",
    "codecs": "hev1.1.6.L120.90",
    "width": 512,
    "height": 384,
    "init": "0-1021",
    "indexRange": "1022-5985"
  },
  "firstAudio": {
    "mimeType": "audio/mp4",
    "codecs": "mp4a.40.2",
    "bandwidth": 68646,
    "init": "0-932",
    "indexRange": "933-5908"
  }
}

这串信息已经够把问题讲透了。

它说明至少 4 件事:

  1. 这不是“一个 mp4 链接”的故事。
  2. 这也不是“一个 m3u8 里面列一串 ts 分片”的最简单故事。
  3. 视频和音频是分开的。
  4. 页面里给你的不只是资源地址,还有初始化区间和索引区间,后面明显还有一层调度逻辑。

所以你在 B 站页面上看到 blob:,不该问“blob 怎么下载”。

你真正该问的是:

这个播放器到底在把什么东西拼成 blob:

blob: 到底是什么,为什么它一眼就把人带偏

MDN 对 blob: URL 的定义很直接:它是浏览器给 BlobMediaSource 生成的对象 URL。

语法一般像这样:

blob:<origin>/<uuid>

这类 URL 有几个容易被忽略的特点:

  • 它依赖当前页面里的 JS 对象存在。
  • 它不是公开 CDN 资源地址。
  • 它通常只能在当前上下文里被消费。
  • 它生成得很晚,往往已经是播放器装配完成之后的最后一跳。

所以你复制一个 B 站 blob: 地址去新标签页,很多时候没有意义。那不是“站点藏起来的原始文件”,而是“当前页面这个播放器实例手里握着的一块对象引用”。

说得再直白一点:

blob: 比较像门牌号,不像仓库地址。

真正的链路,从来不是 <video src> 开始的

拿 B 站这类网页播放器来说,一条更接近真实的前端链路,通常是这样:

HTML / SSR 页面
  -> window.__playinfo__ 或 /x/player/wbi/playurl
  -> dash.video[] / dash.audio[]
  -> 选择一组可播放的音视频轨道
  -> 按 Initialization / indexRange 先拿初始化数据和索引
  -> 再按 Range 请求拿媒体片段
  -> 通过 MediaSource / SourceBuffer 追加
  -> 最后 video.src = URL.createObjectURL(mediaSource)

我抓到的 B 站播放器脚本里,也能看到这些关键词:

  • /x/player/wbi/playurl
  • dash.mediaplayer.min.js
  • MediaSource
  • sourceopen
  • SourceBuffer

这已经足够说明:网页端的真正工作重心,是播放内核和片段调度,不是把一个现成 mp4 直接塞给 <video>

为什么很多 m3u8 教程一到 B 站就不够用了

因为很多教程讲的是 HLS,但 B 站这个例子明显更接近 DASH + MSE

两者都属于分片流媒体,但落到前端实现,差别不小。

维度 HLS / m3u8 DASH / B站这类网页链路
播放入口 m3u8 playlist mpd 或页面返回的 dash 信息
分片描述方式 清单里显式列片段 可能是 SegmentTemplate,也可能是 SegmentBase + sidx
容器常见形态 tsm4s fMP4 / .m4s 更常见
音视频关系 有时合在一起 经常分轨
浏览器表现 可原生播,也可走 hls.js 大多走 MediaSource
你在 DOM 里看到的 可能是原始 URL,也可能是 blob: 很容易就是 blob:

也就是说,你如果只是学会了:

  1. m3u8
  2. 解析 #EXTINF
  3. .ts
  4. 拼文件

这套方法处理普通 HLS 没问题。

但碰到 B 站这种页面,你会突然发现根本没有一个现成的 m3u8 在那里等你。

它给你的,是另一套描述播放资产的方式。

B 站这个例子真正复杂在哪

我觉得至少有 6 层。

1. 你拿到的不是一个文件,而是一组轨道候选

同一条视频页里,我实际抓到的是多条 video[] 和多条 audio[]

这代表播放器在做的不只是“取视频”,而是“选轨道”:

  • 选哪个分辨率
  • 选哪个编码
  • 选哪条音频
  • 当前浏览器是否支持这条编码

所以你不能再把“下载视频”理解成“抓一个链接”。

你先得决定抓哪一组。

2. 签名 URL 本身就是临时资产

抓到的 B 站资源 URL 里,明显带着这些参数:

  • deadline
  • upsig
  • uparams
  • platform
  • mid

这说明它们不是长期裸链,更像带有效期的播放令牌。

你今天抓到的地址,过一会儿可能就过期了。

这也是为什么很多人保存链接到第二天再跑,直接 403。

3. 音视频分离,意味着“下完一个链接”根本还没结束

你只拉视频轨,通常没有音频。 你只拉音频轨,也不会变成完整视频。

播放器播放时,是两个 SourceBuffer 同时在喂:

  • 一个给 video track
  • 一个给 audio track

这件事在在线播放时很自然,到了“我要导出成一个完整文件”,难度就突然上来了。

因为播放不等于封装。

浏览器会播,不代表浏览器已经顺手帮你 mux 成一个标准 MP4。

4. SegmentBase 说明后面还有一层索引解析

这是很多人第一次碰到会卡住的点。

我抓到的 B 站数据里,每条轨道都有:

"SegmentBase": {
  "Initialization": "0-1021",
  "indexRange": "1022-5985"
}

这意味着什么?

意味着播放器并不是拿到一张清单,里面已经把所有分片 URL 全列出来了。

它拿到的是:

  • 初始化区间
  • 索引区间

然后还要继续解析索引,才能知道真正的媒体片段边界。

这个索引,在 fMP4 里通常对应 sidx box。

也就是说,B 站这种链路不是:

m3u8 -> 分片 URL 列表 -> 下载

而更像:

playinfo -> representation -> init range -> sidx/index range -> 计算媒体 range -> Range 请求 -> appendBuffer

这就比“解析 m3u8”多了一层盒子结构理解。

5. 浏览器里真正做事的是 MSE,不是 blob:

网页播放器的核心不是“我拿到了一个 blob 文件”。

而是:

const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);

然后在 sourceopen 之后:

  • 创建 SourceBuffer
  • 逐段 appendBuffer
  • 维护 readyState、buffered、seeking、waiting

所以 blob: 只是最后对 <video> 暴露出来的消费接口。

你抓到它的时候,真正复杂的事情其实已经在前面做完了。

6. 这还是没把解码和自定义播放内核全算进去

我抓到的 B 站播放器脚本里,除了 dash 相关逻辑,还能看到 bwphevc 和 wasm 相关资源。

这说明有些路径里,站点甚至不只是“调浏览器原生视频能力”,还可能上了自己的播放内核、worker、offscreen canvas 或额外解码链路。

所以别把“网页视频播放”想得太轻。

很多时候它根本不是一个原生 <video src="xxx.mp4"> 的故事。

那普通的 m3u8 下载,到底简单在哪

反过来讲,普通 HLS 的最小模型其实挺朴素:

  1. 找到 m3u8
  2. 如果是 master playlist,选一个 media playlist
  3. 把清单里的片段 URL 解析出来
  4. 如果没加密,就顺序取回
  5. 拼接或交给转封装工具

它最大的好处,就是“清单本身已经把故事讲得比较明白了”。

你大多不需要再去理解 sidx。 也不一定需要自己实现 Range 级别的索引调度。

所以如果你的目标真的是:

  • 自家站点
  • 测试环境
  • 内网课件
  • 明确授权的 HLS 流

那从 m3u8 下手是对的。

但如果你拿 B 站网页端去套这套心智模型,你会一直觉得奇怪:

为什么我没找到 m3u8? 为什么 DOM 里只有 blob? 为什么分离流这么多?

因为那本来就不是一条单纯的 m3u8 教程能讲完的链路。

这种方案的优点和缺点,别只盯着“能不能下载”

很多人一提分片流媒体,就只剩“是不是在防下载”这个视角。

其实站在平台和播放器一侧,这套方案很现实。

优点

  • 支持自适应码率,网络波动时不至于整段卡死。
  • 首屏启动更快,不用先等一个完整大文件。
  • 失败重试颗粒度小,坏一个片段不等于整条流都重来。
  • 能把音频、视频、字幕、不同编码策略拆开做更细的选择。
  • 方便播放器内核自己控缓冲区、seek、恢复和 ABR 策略。

缺点

  • 你在 DOM 里看到的 blob: 非常迷惑人。
  • 前端排查门槛高,不懂 MSEDASHRangefMP4 很容易卡住。
  • 音视频分轨后,离线导出变复杂。
  • 浏览器能播放,不代表浏览器天然会帮你封成单文件。
  • 一旦叠加签名 URL、鉴权、跨域、加密甚至 DRM,脚本复杂度会暴涨。

说白了:

它是一套很好的在线播放方案,不一定是一套很友好的“我要另存为单文件”方案。

真要自己实现,应该怎么拆

到这里再写代码,才不算飘。

但我先把边界说清楚:

  • 下面代码只讨论你有权访问和保存的资源。
  • 我不写指向 B 站内容的专用抓取脚本。
  • 代码展示的是“同类前端链路怎么实现”,不是“帮你绕过站点授权”。

先分两条线。

路线 A:普通 HLS / m3u8,最小实现其实不难

这个版本只处理:

  • 非 DRM
  • fetch
  • 可直接列出片段的 HLS
function parseAttributeList(raw: string) {
  const result: Record<string, string> = {};
  const regex = /([A-Z0-9-]+)=("(?:[^"]*)"|[^,]*)/g;

  for (const match of raw.matchAll(regex)) {
    result[match[1]] = match[2].replace(/^"|"$/g, '');
  }

  return result;
}

function toAbsoluteUrl(baseUrl: string, maybeRelativeUrl: string) {
  return new URL(maybeRelativeUrl, baseUrl).toString();
}

function parseHlsPlaylist(text: string, playlistUrl: string) {
  const lines = text
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter(Boolean);

  const variants: Array<{ url: string; BANDWIDTH?: string }> = [];
  const segments: string[] = [];
  let mapUrl = '';
  let encrypted = false;
  let pendingVariant: Record<string, string> | null = null;

  for (const line of lines) {
    if (line.startsWith('#EXT-X-STREAM-INF:')) {
      pendingVariant = parseAttributeList(
        line.slice('#EXT-X-STREAM-INF:'.length),
      );
      continue;
    }

    if (pendingVariant && !line.startsWith('#')) {
      variants.push({
        ...pendingVariant,
        url: toAbsoluteUrl(playlistUrl, line),
      });
      pendingVariant = null;
      continue;
    }

    if (line.startsWith('#EXT-X-MAP:')) {
      const attrs = parseAttributeList(line.slice('#EXT-X-MAP:'.length));
      if (attrs.URI) {
        mapUrl = toAbsoluteUrl(playlistUrl, attrs.URI);
      }
      continue;
    }

    if (line.startsWith('#EXT-X-KEY:')) {
      const attrs = parseAttributeList(line.slice('#EXT-X-KEY:'.length));
      if ((attrs.METHOD || 'NONE') !== 'NONE') {
        encrypted = true;
      }
      continue;
    }

    if (!line.startsWith('#')) {
      segments.push(toAbsoluteUrl(playlistUrl, line));
    }
  }

  return { variants, segments, mapUrl, encrypted };
}

async function fetchText(
  url: string,
  credentials: RequestCredentials = 'include',
) {
  const response = await fetch(url, { mode: 'cors', credentials });
  if (!response.ok) {
    throw new Error(`读取清单失败: ${response.status} ${url}`);
  }
  return response.text();
}

async function fetchBuffer(
  url: string,
  credentials: RequestCredentials = 'include',
) {
  const response = await fetch(url, { mode: 'cors', credentials });
  if (!response.ok) {
    throw new Error(`读取分片失败: ${response.status} ${url}`);
  }
  return response.arrayBuffer();
}

export async function downloadHlsAsBlob(m3u8Url: string) {
  const firstText = await fetchText(m3u8Url);
  let playlist = parseHlsPlaylist(firstText, m3u8Url);
  let finalPlaylistUrl = m3u8Url;

  if (playlist.variants.length > 0) {
    const selected = [...playlist.variants].sort(
      (a, b) => Number(b.BANDWIDTH || 0) - Number(a.BANDWIDTH || 0),
    )[0];

    finalPlaylistUrl = selected.url;
    const mediaText = await fetchText(selected.url);
    playlist = parseHlsPlaylist(mediaText, selected.url);
  }

  if (playlist.encrypted) {
    throw new Error('这个最小示例不处理加密 HLS。');
  }

  const buffers: ArrayBuffer[] = [];

  if (playlist.mapUrl) {
    buffers.push(await fetchBuffer(playlist.mapUrl));
  }

  for (const segmentUrl of playlist.segments) {
    buffers.push(await fetchBuffer(segmentUrl));
  }

  const type = playlist.mapUrl ? 'video/mp4' : 'video/mp2t';
  const ext = playlist.mapUrl ? 'mp4' : 'ts';
  const blob = new Blob(buffers, { type });
  const blobUrl = URL.createObjectURL(blob);

  const link = document.createElement('a');
  link.href = blobUrl;
  link.download = `video.${ext}`;
  link.click();

  setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}

这段代码的主线很清楚:

  • 找清单
  • 选变体
  • 拉分片
  • 拼成 Blob
  • 触发保存

如果你的目标真是普通 m3u8,这就是正确思路。

路线 B:像 B 站这种 blob + DASH + MSE,重点反而是“复刻播放器”

到了这一步,你的心智得变一下。

你不再是在“下载一个文件”。 你是在“自己拼一个最小播放器链路”。

关键步骤一般是:

  1. 先拿到 dash.video[] / dash.audio[]
  2. 选一条视频轨和一条音频轨
  3. 读取 Initialization
  4. 读取 indexRange
  5. 解析 sidx
  6. 算出媒体 byte range
  7. MediaSourceSourceBuffer 追加

下面这段代码,展示的就是这个过程。

它不依赖 B 站专用字段,只依赖:

  • baseUrl
  • mimeType
  • codecs
  • SegmentBase.Initialization
  • SegmentBase.indexRange
type DashTrack = {
  baseUrl: string;
  mimeType: string;
  codecs: string;
  SegmentBase: {
    Initialization: string;
    indexRange: string;
  };
};

function parseByteRange(spec: string) {
  const [start, end] = spec.split('-').map(Number);
  return { start, end };
}

function readUint64(view: DataView, offset: number) {
  return Number(
    (BigInt(view.getUint32(offset)) << 32n) |
      BigInt(view.getUint32(offset + 4)),
  );
}

async function fetchRange(
  url: string,
  range: { start: number; end: number },
  credentials: RequestCredentials = 'include',
) {
  const response = await fetch(url, {
    mode: 'cors',
    credentials,
    headers: {
      Range: `bytes=${range.start}-${range.end}`,
    },
  });

  if (!response.ok && response.status !== 206) {
    throw new Error(`Range 请求失败: ${response.status} ${url}`);
  }

  return response.arrayBuffer();
}

function parseSidx(buffer: ArrayBuffer, absoluteOffset: number) {
  const view = new DataView(buffer);
  let offset = 0;

  while (offset + 8 <= view.byteLength) {
    const size = view.getUint32(offset);
    const type = String.fromCharCode(
      view.getUint8(offset + 4),
      view.getUint8(offset + 5),
      view.getUint8(offset + 6),
      view.getUint8(offset + 7),
    );

    if (!size) {
      break;
    }

    if (type === 'sidx') {
      const version = view.getUint8(offset + 8);
      const timescale = view.getUint32(offset + 16);
      let cursor = offset + 20;
      let earliestPresentationTime = 0;
      let firstOffset = 0;

      if (version === 0) {
        earliestPresentationTime = view.getUint32(cursor);
        firstOffset = view.getUint32(cursor + 4);
        cursor += 8;
      } else {
        earliestPresentationTime = readUint64(view, cursor);
        firstOffset = readUint64(view, cursor + 8);
        cursor += 16;
      }

      cursor += 2; // reserved
      const referenceCount = view.getUint16(cursor);
      cursor += 2;

      let currentOffset = absoluteOffset + offset + size + firstOffset;
      const references: Array<{
        referenceType: number;
        referencedSize: number;
        duration: number;
        start: number;
        end: number;
      }> = [];

      for (let i = 0; i < referenceCount; i += 1) {
        const referenceInfo = view.getUint32(cursor);
        const referenceType = referenceInfo >>> 31;
        const referencedSize = referenceInfo & 0x7fffffff;
        const subsegmentDuration = view.getUint32(cursor + 4);

        references.push({
          referenceType,
          referencedSize,
          duration: subsegmentDuration,
          start: currentOffset,
          end: currentOffset + referencedSize - 1,
        });

        currentOffset += referencedSize;
        cursor += 12;
      }

      return {
        timescale,
        earliestPresentationTime,
        firstOffset,
        references,
      };
    }

    offset += size;
  }

  throw new Error('没有在 indexRange 里解析到 sidx box。');
}

function appendBuffer(sourceBuffer: SourceBuffer, buffer: ArrayBuffer) {
  return new Promise<void>((resolve, reject) => {
    const cleanup = () => {
      sourceBuffer.removeEventListener('updateend', handleUpdateEnd);
      sourceBuffer.removeEventListener('error', handleError);
    };

    const handleUpdateEnd = () => {
      cleanup();
      resolve();
    };

    const handleError = () => {
      cleanup();
      reject(new Error('SourceBuffer append 失败'));
    };

    sourceBuffer.addEventListener('updateend', handleUpdateEnd, { once: true });
    sourceBuffer.addEventListener('error', handleError, { once: true });
    sourceBuffer.appendBuffer(buffer);
  });
}

async function appendDashTrack(
  mediaSource: MediaSource,
  track: DashTrack,
  credentials: RequestCredentials = 'include',
) {
  const mimeCodec = `${track.mimeType}; codecs="${track.codecs}"`;

  if (!MediaSource.isTypeSupported(mimeCodec)) {
    throw new Error(`浏览器不支持 ${mimeCodec}`);
  }

  const sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);

  const initRange = parseByteRange(track.SegmentBase.Initialization);
  const indexRange = parseByteRange(track.SegmentBase.indexRange);

  const initBuffer = await fetchRange(track.baseUrl, initRange, credentials);
  await appendBuffer(sourceBuffer, initBuffer);

  const indexBuffer = await fetchRange(track.baseUrl, indexRange, credentials);
  const sidx = parseSidx(indexBuffer, indexRange.start);

  for (const ref of sidx.references) {
    if (ref.referenceType !== 0) {
      throw new Error('这个最小示例不处理层级 sidx。');
    }

    const mediaBuffer = await fetchRange(
      track.baseUrl,
      { start: ref.start, end: ref.end },
      credentials,
    );

    await appendBuffer(sourceBuffer, mediaBuffer);
  }

  return sourceBuffer;
}

function pickBestTrack<T extends DashTrack & { bandwidth?: number }>(
  tracks: T[],
) {
  return [...tracks].sort(
    (a, b) => Number(b.bandwidth || 0) - Number(a.bandwidth || 0),
  )[0];
}

export async function playDashFromPlayinfo(
  videoElement: HTMLVideoElement,
  dash: {
    video: Array<DashTrack & { bandwidth?: number }>;
    audio: Array<DashTrack & { bandwidth?: number }>;
  },
) {
  const videoTrack = pickBestTrack(dash.video);
  const audioTrack = pickBestTrack(dash.audio);

  if (!videoTrack || !audioTrack) {
    throw new Error('缺少可用的音频或视频轨道');
  }

  const mediaSource = new MediaSource();
  const blobUrl = URL.createObjectURL(mediaSource);
  videoElement.src = blobUrl;

  await new Promise<void>((resolve, reject) => {
    mediaSource.addEventListener(
      'sourceopen',
      async () => {
        try {
          await Promise.all([
            appendDashTrack(mediaSource, videoTrack),
            appendDashTrack(mediaSource, audioTrack),
          ]);

          mediaSource.endOfStream();
          resolve();
        } catch (error) {
          reject(error);
        }
      },
      { once: true },
    );
  });

  return () => URL.revokeObjectURL(blobUrl);
}

这段代码真正有价值的地方,不是“马上拿来跑 B 站”。

而是它把 blob: 背后的前端过程拆开了:

  • 为什么要 Range
  • 为什么要 Initialization
  • 为什么要 indexRange
  • 为什么要 sidx
  • 为什么要两个 SourceBuffer

你把这些串起来,再回头看 B 站网页里的 blob:,就不会再觉得神秘。

这段 DASH 代码为什么比 HLS 难这么多

因为它面对的是两个完全不同的世界:

HLS 的世界

清单已经把“去哪拿片段”讲得很明白。

你大部分时间只是在处理:

  • URL 解析
  • 分片顺序
  • 可能的 AES-128

DASH + MSE 的世界

你在自己接近播放器内核。

你要面对的是:

  • representation 选择
  • 音视频分离
  • 初始化段
  • 索引段
  • sidx 解析
  • Range 调度
  • SourceBuffer 状态机

所以很多人会误以为“B 站只是把链接藏起来了”。

其实不是。

它是整条播放链路本来就更复杂。

那离线导出怎么办

这里再补一个经常被混在一起的概念:

能播放,和能导出成单一 MP4,不是一回事。

像上面这段 DASH + MSE 代码,能把播放过程复刻出来。 但它不等于自动帮你做音视频封装。

如果你真要落成一个标准单文件,通常要再做一层“离线导出”:

  • 把视频轨和音频轨各自保存下来。
  • init 和媒体片段按顺序还原成完整轨道。
  • 再走一次 mux / remux,把两条轨道装进一个 mp4 容器里。

先把边界写死一点:

  • 我不写指向 B 站内容的专用导出脚本。
  • 下面这段 Node 代码只适用于你已经合法拿到的、自有或已授权的 fMP4 分片资源。
  • 如果你的资源来源还是“网页里现抓”,那一步本身就已经是另一道题了,这里不展开。

离线导出真正分成 3 步

1. 物化片段

先把资源变成你自己能稳定管理的本地文件。

最理想的输入不是一个网页里的 blob:,而是这种明确的片段目录:

input/
  video-init.mp4
  video-0001.m4s
  video-0002.m4s
  video-0003.m4s
  audio-init.mp4
  audio-0001.m4s
  audio-0002.m4s
  audio-0003.m4s

如果你手里拿到的是:

  • 一个大文件 + 多段 byte range
  • 或者一组已经下载好的 m4s

都可以先整理成这个目录结构。

2. 还原轨道

fMP4 来说,一条完整轨道通常就是:

init.mp4 + fragment-1.m4s + fragment-2.m4s + fragment-3.m4s + ...

这一步不需要浏览器。 用 Node 在磁盘上按顺序拼起来就行。

3. 封装输出

当你得到:

  • 一条完整的视频轨
  • 一条完整的音频轨

最后再交给 ffmpeg 做容器封装:

ffmpeg -i video-track.mp4 -i audio-track.mp4 -c copy output.mp4

为什么我更推荐这一步放到 Node + ffmpeg

  • 浏览器里做大文件离线拼接,内存压力很大。
  • 文件系统、重试、临时目录、失败恢复,这些都是 Node 更擅长的事。
  • ffmpeg 对时间戳、容器和轨道兼容性处理明显更稳。

一份够用的 Node 脚本

下面这份脚本做的事很克制:

  • 从本地目录读取已经整理好的 init + .m4s
  • 先拼出 video-track.mp4
  • 再拼出 audio-track.mp4
  • 最后调用 ffmpeg 输出 output.mp4

假设目录结构是:

input/
  video-init.mp4
  video-0001.m4s
  video-0002.m4s
  audio-init.mp4
  audio-0001.m4s
  audio-0002.m4s

文件名最好零填充,比如 00010002。因为脚本是按字典序排序的。

#!/usr/bin/env node

import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';

function fail(message) {
  throw new Error(message);
}

async function ensureDir(dirPath) {
  await fsp.mkdir(dirPath, { recursive: true });
}

async function collectTrackFiles(inputDir, prefix) {
  const files = await fsp.readdir(inputDir);
  const initFile = `${prefix}-init.mp4`;
  const initPath = path.join(inputDir, initFile);

  if (!files.includes(initFile)) {
    fail(`缺少 ${initFile}`);
  }

  const mediaFiles = files
    .filter((fileName) => {
      return (
        fileName.startsWith(`${prefix}-`) &&
        fileName.endsWith('.m4s') &&
        fileName !== initFile
      );
    })
    .sort((left, right) => left.localeCompare(right, 'en'));

  if (mediaFiles.length === 0) {
    fail(`没有找到 ${prefix} 的媒体片段`);
  }

  return {
    initPath,
    mediaPaths: mediaFiles.map((fileName) => path.join(inputDir, fileName)),
  };
}

async function appendFileToWriteStream(writeStream, filePath) {
  return new Promise((resolve, reject) => {
    const readStream = fs.createReadStream(filePath);

    const cleanup = () => {
      readStream.removeAllListeners();
      writeStream.removeListener('error', handleError);
    };

    const handleError = (error) => {
      cleanup();
      reject(error);
    };

    readStream.on('error', handleError);
    writeStream.on('error', handleError);
    readStream.on('end', () => {
      cleanup();
      resolve();
    });

    readStream.pipe(writeStream, { end: false });
  });
}

async function stitchTrack(outputPath, initPath, mediaPaths) {
  await ensureDir(path.dirname(outputPath));

  const writeStream = fs.createWriteStream(outputPath);

  try {
    await appendFileToWriteStream(writeStream, initPath);

    for (const mediaPath of mediaPaths) {
      await appendFileToWriteStream(writeStream, mediaPath);
    }
  } finally {
    writeStream.end();
    await once(writeStream, 'close');
  }
}

async function runFfmpeg(args) {
  return new Promise((resolve, reject) => {
    const child = spawn('ffmpeg', args, {
      stdio: 'inherit',
    });

    child.on('error', reject);
    child.on('close', (code) => {
      if (code === 0) {
        resolve();
        return;
      }

      reject(new Error(`ffmpeg 退出码异常: ${code}`));
    });
  });
}

async function muxToMp4(videoTrackPath, audioTrackPath, outputPath) {
  try {
    await runFfmpeg([
      '-y',
      '-fflags',
      '+genpts',
      '-i',
      videoTrackPath,
      '-i',
      audioTrackPath,
      '-c',
      'copy',
      '-movflags',
      '+faststart',
      outputPath,
    ]);
  } catch (error) {
    console.warn('[warn] 直接 copy 失败,尝试只重编码音频一次...');

    await runFfmpeg([
      '-y',
      '-fflags',
      '+genpts',
      '-i',
      videoTrackPath,
      '-i',
      audioTrackPath,
      '-c:v',
      'copy',
      '-c:a',
      'aac',
      '-b:a',
      '192k',
      '-movflags',
      '+faststart',
      outputPath,
    ]);
  }
}

async function main() {
  const inputDir = process.argv[2];
  const outputDir = process.argv[3] || path.resolve('dist-offline-export');

  if (!inputDir) {
    fail('用法: node authorized-offline-export.mjs <inputDir> [outputDir]');
  }

  const absInputDir = path.resolve(inputDir);
  const absOutputDir = path.resolve(outputDir);

  const video = await collectTrackFiles(absInputDir, 'video');
  const audio = await collectTrackFiles(absInputDir, 'audio');

  const videoTrackPath = path.join(absOutputDir, 'video-track.mp4');
  const audioTrackPath = path.join(absOutputDir, 'audio-track.mp4');
  const finalOutputPath = path.join(absOutputDir, 'output.mp4');

  console.log('[1/3] 还原视频轨...');
  await stitchTrack(videoTrackPath, video.initPath, video.mediaPaths);

  console.log('[2/3] 还原音频轨...');
  await stitchTrack(audioTrackPath, audio.initPath, audio.mediaPaths);

  console.log('[3/3] 封装 mp4...');
  await muxToMp4(videoTrackPath, audioTrackPath, finalOutputPath);

  console.log(`完成: ${finalOutputPath}`);
}

main().catch((error) => {
  console.error('[error]', error.message);
  process.exitCode = 1;
});

运行方式:

node authorized-offline-export.mjs ./input ./dist-offline-export

前提:

  • 机器上已经安装 ffmpeg
  • 片段顺序是正确的
  • 视频轨和音频轨来自同一个时间轴
  • 这些资源是你有权离线导出的

这段代码为什么够用

因为它把离线导出最容易混淆的两件事拆开了:

  • stitchTrack() 只负责“把同一轨的 init 和媒体片段按顺序拼回去”
  • muxToMp4() 只负责“把音频轨和视频轨装进一个容器”

这两个步骤不要混在一起。

很多人一上来就试图“边下载边生成 mp4”,最后代码会非常乱。因为你同时在处理:

  • 网络
  • 顺序
  • 轨道
  • 时间戳
  • 容器

而离线导出的工程做法通常是:

先把字节拿准
再把轨道还原
最后再封装

这样哪一步坏了都容易排查。

真正容易翻车的地方

这部分我建议你第一次就注意,不然脚本看着跑完了,文件还是可能不对。

1. 片段顺序错了

如果文件名不是 00010002 这种零填充,字典序会把:

video-1.m4s
video-10.m4s
video-2.m4s

排成错误顺序。

这个坑特别常见。

2. 音频轨和视频轨不是同一套 representation

如果它们不是同一时间轴,最后即使 ffmpeg 能出文件,也可能音画不同步。

3. 不是所有 m4s 都能“裸拼完就直接 copy”

有些片段时间戳比较乱,-c copy 会失败。

所以上面的代码才会多一个兜底:

  • 先尝试 copy
  • 不行就只重编码音频一次

这是个很实用的折中。

4. 大文件不要全读进内存

这也是为什么我这里写的是 stream 版本,而不是:

const buf = Buffer.concat(allBuffers);

几十 MB 你还能扛。 几百 MB 往上,这种写法就开始不舒服了。

如果你的输入不是 .m4s 文件,而是 byte range

那就先把 byte range 物化成文件,再走上面这套。

顺序不要反。

也就是说,别直接把“range 请求逻辑”和“封装逻辑”耦在一起。

更稳妥的流程是:

range 清单
  -> 导出本地片段
  -> 还原单轨
  -> ffmpeg mux

你会发现,一旦拆成这 3 层,离线导出这件事就不神秘了。

它难的地方从来不是 blob:。 而是你有没有把“资产获取”“轨道还原”“容器封装”三件事拆开。

最后

所以回到最开始那个问题。

“m3u8 视频怎么下载”,如果只是写成“找到 m3u8 然后拉分片”,其实只讲对了一半。

另一半是:

很多真实网页视频,根本不在那条最短路径上。

以 B 站为例,你在页面里看到 blob:,真正该想到的不是“这个 blob 怎么扒”。

你该想到的是:

  • 页面是不是已经拿到 playinfo
  • 背后是不是 dash.video[] / audio[]
  • 资源是不是签名 URL
  • 是不是音视频分离
  • 有没有 InitializationindexRange
  • 这是不是一条需要 MediaSourceSourceBuffer 才能拼起来的链路

把这些问题问对之后,很多“为什么看着能播却找不到文件”的困惑,基本都会自己散开。

说到底,blob: 不是谜底。

它只是播放器把所有复杂度吃进去之后,留在 DOM 里的那个结果。

参考资料

深度解密 Rollup 插件开发:核心钩子函数全生命周期图鉴

前言

Rollup 的强大在于其精简的插件系统。一个 Rollup 插件本质上就是一个包含各种“钩子函数”的对象。理解这些钩子的执行时序,是编写高性能插件、优化构建流程的关键。本文将带你深度复盘 Rollup 的两大核心阶段:构建 (Build)输出 (Output)


一、构建阶段钩子函数(核心阶段)

构建阶段主要负责模块的解析、加载和转换,最终完成模块依赖图的构建,是Rollup打包的基础。该阶段可细分为5个小阶段,钩子执行顺序固定为:

初始化阶段(options、buildStart)→ 模块加载阶段(resolveId、load)→ 模块转换阶段(transform、moduleParsed)→ 代码生成阶段(augmentChunkHash、resolveDynamicImport)→ 代码构建阶段(buildEnd)

1. 初始化阶段钩子(options、buildStart)

options

  • 执行时机:在读取用户配置之后、构建开始之前执行。

  • 作用:可以添加或修改默认配置项(如调整input、output、plugins等Rollup核心配置)。

  • 注意:仅支持同步执行,无法进行异步操作;此钩子修改的配置会覆盖用户默认配置,需谨慎使用。

buildStart

  • 执行时机:开始解析模块前执行(构建流程启动的第一个核心钩子)。

  • 作用:用于初始化插件状态(如重置计数器、初始化缓存)、读取外部文件(如配置文件、静态资源清单)等。

  • 支持:同步、异步执行(可返回Promise);此钩子可访问传递给rollup.rollup()的最终配置,包含所有options钩子的转换结果和默认值。

2. 模块加载阶段钩子(resolveId、load)

resolveId(source, importer)

  • 执行时机:它是在Rollup遇到一个 import 语句时(如 import foo from './foo.js')执行,是模块解析的核心钩子。

  • 作用:它可以将模块标识符(如 './foo.js''vue')解析为绝对路径或模块 ID,返回一个解析后的路径ID(返回值可以是 null、string 或者一个对象,如果返回false则视为外部模块,不打包)。支持同步、异步执行。

  • 入参说明:

    • source:表示 import 的内容(字符串),即模块标识符;
    • importer:表示导入该模块的文件路径(绝对路径),入口文件的importer为 null。

如果多个插件都定义了resolveId,会按插件配置顺序执行,直到某个插件返回非null/undefined的值(表示解析完成);也可通过配置order: 'pre'调整钩子执行优先级,实现优先解析特定模块。

示例:拦截虚拟模块导入,自定义模块解析逻辑:

resolveId(source) {
  if (source === 'virtual-module') {
    // 表示rollup不应询问其他插件或从文件系统检查此ID
    return source;
  }
  return null; // 其他ID按正常逻辑处理
}

load(id)

  • 执行时机:它在 resolveId 返回一个 ID 后执行,是模块加载的核心钩子。

  • 作用:用于获取对应模块的源码,并返回这个源码给transform钩子进行后续转换。

  • 支持:同步、异步执行;若返回null,Rollup会默认从文件系统读取该ID对应的文件内容,也可通过this.load在其他钩子中触发模块预加载。

示例:自定义虚拟模块的源码加载:

load(id) {
  if (id === 'virtual-module') {
    // 返回虚拟模块的源码
    return 'export default "This is virtual!"';
  }
  return null; // 其他ID按正常逻辑加载
}

3. 模块转换阶段钩子(transform、moduleParsed)

transform(code, id)

  • 执行时机:它在模块源码加载后执行,紧随load钩子之后。

  • 作用:它用于将模块源码中的ts、tsx等非标准JS语法转换为标准的js语法,也可对源码进行压缩、注入代码等自定义处理。支持同步、异步执行

  • 入参说明:

    • code:模块的源码字符串(load钩子返回的内容);
    • id:模块 ID(通常是文件路径,与resolveId返回的ID一致)。
  • 返回值:{ code: '修改后的代码', map: 'sourcemap' },其中sourcemap可选,用于关联转换后的代码与原始源码,方便调试。

moduleParsed

  • 执行时机:在模块被 Rollup 解析为 AST(抽象语法树)后执行。

  • 作用:可以用于分析模块信息(如导入导出关系)、收集元数据(如模块依赖、变量声明),实际开发中较少使用。

  • 支持:同步、异步执行;入参为moduleInfo,包含当前模块的详细信息,执行完成后会并行解析模块中所有静态和动态导入的依赖。

4. 代码生成阶段钩子(augmentChunkHash、resolveDynamicImport)

augmentChunkHash

  • 执行时机:在生成 chunk 哈希前执行(chunk 哈希用于实现静态资源长效缓存)。

  • 作用:可以向 chunk 哈希添加额外信息(如插件版本、配置参数),确保当这些信息变化时,chunk 哈希也会更新,避免缓存失效不及时。

resolveDynamicImport

执行时机:当遇到动态导入语句时(如import('./foo.js'))执行。

作用:处理动态导入的解析,作用和resolveId类似,但专用于动态导入场景,可自定义动态导入的模块解析规则。

5. 代码构建阶段钩子(buildEnd)

  • 执行时机:构建结束(无论成功或失败)执行,是构建阶段的最后一个钩子。

  • 作用:用于清理资源(如关闭文件流、清空缓存)、上报错误(如构建失败日志上报)等。

  • 支持:同步、异步执行。

二、打包阶段钩子函数(产物输出阶段)

打包阶段主要负责将构建阶段处理后的模块,生成最终的可部署产物,并写入磁盘,钩子执行顺序固定为:

输出生成(renderStart→renderChunk→generateBundle)→ 输出写入(writeBundle→closeBundle)

1. 输出生成阶段钩子(renderStart、renderChunk、generateBundle)

renderStart

  • 执行时机:开始生成 chunk 内容前执行,是打包阶段的第一个钩子。

  • 作用:初始化输出相关状态(如初始化产物计数器、设置输出格式相关参数)。

  • 支持:同步、异步执行。

renderChunk(code, chunk, options)

  • 执行时机:它是在每个 chunk 生成后、写入磁盘前执行。

  • 作用:它可以对生成的chunk 中的JS 代码进行最后处理(例如注入版权注释、补充全局变量、代码压缩优化等)。支持同步、异步执行。

  • 入参说明:

    • code:当前chunk生成后的JS代码字符串;
    • chunk:当前chunk的详细信息(如chunk名称、包含的模块、依赖关系等);
    • options:当前的输出配置(与output配置一致)。

generateBundle

  • 执行时机:它是在所有 chunk 和 asset 生成完毕,即将写入磁盘前执行。

  • 作用:这个钩子的入参里面会包含所有的打包产物信息,包括 chunk (打包后的代码)、asset(最终的静态资源文件)。可以在这里检查、修改、添加最终输出文件(例如删除无用 chunk、合并CSS、注入 preload 链接到 HTML)。

  • 支持同步、异步执行;是打包阶段最常用的钩子之一,可用于最终产物的自定义优化。

2. 输出写入阶段钩子(writeBundle、closeBundle)

writeBundle

  • 执行时机:它是在bundle 已写入磁盘后执行,仅在调用bundle.generate()bundle.write() 时触发。

  • 作用:可以在这执行写入后的操作(如将产物上传 CDN、生成产物清单、通知部署服务等)。

  • 支持:同步、异步执行。

closeBundle

  • 执行时机:它是在整个构建完全结束执行,是Rollup打包流程的最后一个钩子。

  • 作用:可以在这做一些全局清理操作(如关闭数据库连接、清空临时文件、终止子进程等)。

  • 支持:同步、异步执行;无论构建成功或失败,都会执行此钩子。

三、钩子执行顺序(核心重点)

// 完整执行顺序
options → buildStart → resolveId → load → transform → moduleParsed → 
augmentChunkHash → resolveDynamicImport → buildEnd → renderStart → 
renderChunk → generateBundle → writeBundle → closeBundle

注意:所有钩子均支持同步执行,标注“支持异步”的钩子可返回Promise,实现异步操作(如读取外部文件、请求接口);多个插件定义同一钩子时,按插件配置顺序执行。

四、补充

  1. 钩子使用场景:开发Rollup插件时,可根据需求选择对应阶段的钩子(如语法转换用transform、产物优化用generateBundle、CDN上传用writeBundle);

  2. 与Vite关联:Vite生产环境基于Rollup打包,Vite插件可直接使用Rollup的所有钩子,同时Vite会自动注入内置插件,无需手动配置基础钩子(如resolveId、load);

  3. 调试技巧:可在钩子中打印日志(如console.log('钩子执行:', id)),查看钩子执行顺序和入参信息,快速排查插件问题。

❌