普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月28日掘金 前端

SEO听不懂?看完这篇你明天就能优化网站了

2025年11月28日 12:23

SEO.jpg

引言

去年有个做手工的朋友小王跟我吐槽,说他花了三个月搭建了一个自己的作品展示网站,设计特别用心,产品照片也拍得很专业。结果呢?每个月访问量只有可怜的100多人,还有一大半是他自己点进去的。他当时特别沮丧,问我:"是不是我的东西不够好?"

其实不是。我看了他的网站,发现问题根本不在内容质量,而是SEO做得太差了——更直白点说,搜索引擎根本找不到他的网站。后来我教他做了一些最基本的优化,两个月后月访问量涨到了接近10000。他兴奋地给我发消息:"这SEO也太神了吧!"

说实话,SEO听起来挺高深,什么"爬虫"、"索引"、"权重"一堆专业术语。但我今天想告诉你的是:SEO其实没那么神秘,很多技巧今天学了明天就能用。

这篇文章会用最接地气的话解释SEO是什么,然后给你5个配了真实案例的优化技巧。看完你就知道怎么让自己的网站在搜索结果里排得更靠前,还能避开3个新手最容易踩的坑。

SEO到底是什么?(白话解释)

我们先不讲那些术语,我用个简单的比喻。

你去图书馆找书,是不是要么问管理员,要么查电脑目录?搜索引擎就像这个图书馆管理员,它的工作是帮用户找到最合适的"书"(网页)。那问题来了:凭什么把你的网页推荐给用户,而不是别人的?

这就是SEO要解决的问题——让搜索引擎更容易"找到"和"推荐"你的内容。

具体点说,当你在百度或Google搜索"北京好吃的火锅",为什么海底捞、小龙坎这些餐厅排在前面?不只是因为它们有名,更重要的是它们的网站做了优化:标题写得清楚、内容详细、用户评价多、网站打开速度快。搜索引擎会根据这些信号判断:"嗯,这家店应该比较靠谱,推荐给用户吧。"

Google在2022年更新了一个挺重要的标准,叫E-E-A-T。我第一次看到这四个字母也懵了,后来发现其实好理解:

  • Experience(经验):你有没有真实体验过?比如你写"北京火锅推荐",你真的去吃过吗?
  • Expertise(专业):你在这个领域懂不懂行?
  • Authority(权威):别人认不认可你?有没有其他网站链接到你的内容?
  • Trust(可信度):你的信息准确吗?有没有误导用户?

我自己理解下来,就是一句话:搜索引擎现在越来越聪明,它要的是真正对用户有帮助的内容,而不是那些专门为了排名硬凑出来的文章。

2025年最重要的SEO趋势

老实讲,SEO这东西一直在变。我记得2020年的时候,还有人在拼命堆关键词,现在这招早就不管用了,反而会被搜索引擎惩罚。

2024年3月,Google做了一次挺大的核心更新,目标是减少40%的"垃圾内容"。什么叫垃圾内容?就是那种看起来像文章,但读起来完全没营养,纯粹为了凑字数的东西。这个更新之后,很多低质量的内容网站排名直接掉没了。

还有一个趋势你必须知道:网站速度现在成了排名因素。Google推出了一个叫Core Web Vitals的指标,听起来挺技术对吧?其实说白了就是衡量"你的网页卡不卡"。具体来说有三个指标:

  • LCP(最大内容绘制):网页主要内容加载完成要多久?理想状态是2.5秒以内
  • FID(首次交互延迟):用户点击按钮后,网页反应快不快?应该在100毫秒以内
  • CLS(累积布局偏移):你有没有遇到过那种情况——刚想点个按钮,页面突然跳了一下,你点到广告上了?这就是布局偏移,越少越好

我有个做电商的朋友,他的网站之前LCP是4.5秒,用户跳出率(就是打开后马上关掉的比例)高达62%。后来他优化了一下图片压缩,用了CDN(内容分发网络),LCP降到了2.1秒,跳出率直接降到35%,转化率提升了40%。你说这重不重要?

另外,AI对SEO的影响也越来越大。现在搜索引擎能更好地理解用户到底想要什么。比如你搜"怎么让孩子爱上阅读",它不只是匹配关键词,而是真的理解你是个家长,想找实用的育儿方法。所以现在做内容,不能只想着塞关键词,要真的去解决用户的问题。

5个立即可用的SEO技巧(重点来了)

技巧1:关键词不是"塞进去"的,是"融进去"的

我见过太多新手犯这个错误了。我自己刚开始做网站那会儿,也以为SEO就是把关键词往文章里多塞几遍。结果呢?文章标题写成"SEO SEO SEO优化技巧大全",读起来完全不通顺,用户看了就想关掉。

正确的做法是什么?用长尾关键词,自然地融入内容。

什么是长尾关键词?就是那种更具体、更长的搜索词。比如:

  • 大词:"川菜"(竞争激烈,很难排上去)
  • 长尾词:"上班族15分钟能做的川菜家常菜"(竞争小,而且搜这个的人意图明确)

我有个写美食的朋友,原来她的文章标题都是"川菜推荐"、"川菜做法"这种。后来改成"上班族15分钟能做的川菜家常菜"、"新手也能学会的麻婆豆腐",结果流量涨了3倍。为什么?因为搜索这些长尾词的人,就是真的想学做菜的,而不是随便逛逛。

还有个概念叫"关键词集群",听起来挺专业,其实就是一篇文章解决一类相关问题。比如你写"如何选购笔记本电脑",文章里可以自然地涉及"学生笔记本推荐"、"游戏本性能对比"、"轻薄本续航测试"这些相关词。搜索引擎现在挺聪明,能理解这些词是相关的,会给你的文章更高的权重。

错误做法:文章标题"SEO SEO SEO优化技巧" 正确做法:标题"新手网站没流量?这5个SEO技巧帮你解决"

看出区别了吗?第二个标题自然、有吸引力,而且解决了具体问题。关键词"SEO"和"优化"都在里面,但读起来完全不生硬。

技巧2:标题和描述,决定用户点不点你

说真的,这个太重要了。

你的网站排在搜索结果第三位,但如果标题写得不吸引人,用户照样不点你,反而点了排第五的那个。这就是CTR(点击率)的作用。

我给你看个对比:

  • 普通标题:"Python教程"
  • 优化后:"Python入门教程:3天学会写第一个爬虫程序"

哪个更吸引你?肯定是第二个对吧。因为它告诉你:1)适合入门新手;2)只需要3天;3)能学会具体的东西(爬虫)。

有个数据我印象特别深:某个技术博客把标题从"JavaScript基础"改成"JavaScript零基础教程:7天从入门到做出第一个网页",CTR从1.5%直接跳到6.2%,排名还提升了。

那怎么写好标题呢?我的经验是:

  1. 包含核心关键词(让搜索引擎知道你讲什么)
  2. 说明具体价值(用户能得到什么)
  3. 最好有数字(3个方法、5个技巧,人对数字特别敏感)
  4. 不要超过60个字符(太长会被截断)

Meta Description(就是搜索结果下面的那段描述文字)也一样重要。很多人不写,让搜索引擎自己抓取,结果抓的内容乱七八糟。你应该自己写一段150字左右的描述,总结文章核心价值,同时自然地包含关键词。

技巧3:内容质量 > 内容数量(10X内容策略)

我之前也陷入过误区,觉得文章越多越好,一天发三篇。结果呢?每篇都写得很浅,用户看了没收获,排名也上不去。

后来我了解到一个概念叫"10X内容"——就是比竞争对手好10倍的内容。听起来夸张对吧?但这是真的有效。

举个例子。假设你要写"如何选购笔记本电脑",你搜一下这个关键词,看看排名前面的文章都写了什么。

  • A文章:500字,泛泛而谈"要看处理器、内存、硬盘",没有具体型号推荐
  • B文章:3000字,包含15款笔记本实测数据、详细的价格对比表、按使用场景(学生、设计、游戏)分类推荐,还有购买避坑指南

你觉得哪个会排名更高?肯定是B对吧。事实也是这样,我观察了很多竞争激烈的关键词,排在第一页的几乎都是这种深度长文。

什么是好内容?我的理解是:

  1. 深度:不只停留在表面,深入讲清楚原理和细节
  2. 实用:有具体的操作步骤、数据对比、工具推荐
  3. 独特:有你自己的经验和见解,不是复制粘贴别人的内容

我自己写文章的时候,会先搜索这个关键词,看排名前10的文章都讲了什么,然后问自己:"我能提供什么他们没有的价值?"可能是更新的数据、更详细的案例,或者是自己的实践经验。

记住:宁可一个月出一篇高质量文章,也不要一周出七篇水文。

技巧4:外链不是买的,是"赚"来的

外链这个东西,很多人理解错了。

我刚开始的时候,看到一些服务商说"花1000块给你100个外链",我还心动了。幸好我没买,后来才知道那些垃圾外链不仅没用,反而可能害你被搜索引擎惩罚。

**什么是高质量外链?**就是有权威的网站主动链接到你的内容,因为你的内容确实有价值。

比如,某个行业研究机构发布了一份独家的市场分析报告,数据详实、观点独到。结果有50多个行业网站、新闻媒体引用了这份报告,并且附上了来源链接。这50个外链的价值,远远超过你花钱买的1000个垃圾链接。

那怎么"赚"到外链呢?

  1. 做真正有价值的内容:行业报告、深度教程、实用工具,这些东西别人自然会想分享
  2. 客座博客:在相关领域的知名博客上发文章,署名里带上你网站的链接
  3. 行业合作:跟同行互换优质资源,前提是双方内容相关且质量都过关
  4. 社交媒体传播:LinkedIn、Facebook、知乎这些平台也能带来流量,而且有些平台的链接权重还不错

我有个朋友做了一个特别实用的Excel模板工具,免费分享出来,结果被很多办公类公众号、知乎专栏引用,自然带来了几百个外链,网站权重噌噌往上涨。

避坑指南:千万别花钱买那种批量外链服务。搜索引擎现在能识别出来,一旦被发现,你的网站可能直接被降权。

技巧5:技术优化,让网站"跑得快"

这个技巧说起来有点技术,但其实操作起来不难。

我前面提到过,网站速度现在是排名因素。你想啊,用户打开一个网页,等了10秒还在加载,早就烦了关掉了。Google的数据显示,网页加载时间每增加1秒,转化率下降7%。

你可以做这几件事:

  1. 压缩图片:这个最简单也最有效。很多人上传图片都是原图,一张5MB,网页当然慢。用TinyPNG这种工具压缩一下,画质基本没影响,大小能减少70%

  2. 使用CDN:CDN就是内容分发网络,简单说就是把你的网站内容分布到全国各地的服务器上。用户访问的时候,自动从最近的服务器加载,速度当然快

  3. 减少JS和CSS文件:如果你用WordPress或者其他建站工具,很可能加载了一堆用不到的脚本文件。用一些优化插件可以合并、压缩这些文件

  4. 移动端适配:现在超过60%的流量来自手机,如果你的网站在手机上显示错乱、按钮点不到,用户体验差,排名肯定上不去

我之前帮一个做教育的朋友优化过网站。他的网站之前LCP(最大内容绘制时间)是4.5秒,用户跳出率62%。我们做了这些优化:把首页的大图从3MB压缩到500KB,换了个更快的主机,用上了CDN,结果LCP降到2.1秒,跳出率降到35%,最关键的是转化率提升了40%。

你不需要是技术大神,就用这个简单的检查清单:

  • ✓ 所有图片都压缩了吗?
  • ✓ 网站在手机上显示正常吗?
  • ✓ 首页加载时间在3秒以内吗?(用Google PageSpeed Insights测一下)
  • ✓ 有没有死链接或404错误页面?

这些做好了,你的技术SEO基本就及格了。

新手最容易踩的3个坑

说了这么多技巧,我还想提醒你三个常见的错误,我自己当初也踩过。

坑1:过度优化(关键词堆砌)

有个做护肤品的老板,听说SEO重要,就把关键词往文章里猛塞。标题是"护肤品护肤品推荐最好的护肤品",文章每两句话就出现一次"护肤品"。结果呢?网站被Google降权了,排名从第一页直接掉到找不着。

**记住:关键词密度控制在2-3%就够了,最重要的是自然。**如果你读着都觉得别扭,搜索引擎肯定也觉得不对劲。

坑2:忽视用户体验,只为SEO而SEO

我见过一些文章,明明是写给搜索引擎看的,不是给人看的。通篇都是关键词和术语,读起来像机器人写的,完全没有可读性。

这就本末倒置了。**SEO的最终目的是什么?是让真实的用户找到你的内容,并且觉得有用。**如果用户点进来看了10秒就关掉,跳出率高得吓人,搜索引擎会判断"这内容不行",你的排名照样掉。

所以我的建议是:**先写给人看,再考虑SEO。**写完之后再自然地优化一下关键词、标题,而不是为了SEO牺牲可读性。

坑3:期望立竿见影

这个我太理解了。我刚开始做SEO的时候,优化了一周,天天去查排名,发现一点变化都没有,就开始怀疑"是不是方法不对?"

后来我了解到,**SEO是长期投资,通常需要3-6个月才能看到明显效果。**这不是一个快速见效的东西。你今天发了一篇文章,搜索引擎要先爬取、索引、评估,然后慢慢调整排名。

我的建议是:**制定一个至少3个月的SEO计划,持续优化,别急着看短期结果。**很多人就是因为太急,一两周没效果就放弃了,太可惜。

总结一下

说了这么多,我们回顾一下这5个技巧:

  1. 关键词要"融进去":用长尾关键词,自然地融入内容,别硬塞
  2. 标题和描述很关键:决定用户点不点你,要写得有吸引力
  3. 质量大于数量:一篇10X内容胜过十篇水文
  4. 外链要"赚"来的:做有价值的内容,让别人主动链接你
  5. 技术优化要做好:网站速度、移动端适配,这些基础必须有

**SEO是个持续优化的过程,不是一次性工程。**但好消息是,你不需要一次性做完所有事情。从最简单的开始:

  • 今天就检查一下你的网站标题是否优化了
  • 用Google Search Console(免费工具)看看自己的网站数据
  • 优先做好这3件事:关键词、标题、内容质量

如果你现在还没有网站也没关系,把这些概念记住,等你开始做内容的时候,从第一篇文章就把SEO考虑进去,比后期再补救容易多了。

最后想说,SEO没有想象中那么难,也没有捷径。踏踏实实做好内容,用心优化细节,3个月后你回头看,一定会发现变化。

你现在就可以动手试试,挑一个你最想优化的页面,用今天学到的技巧改一改。有问题随时交流,我也一直在学习和实践中。加油!

本文首发自个人博客

Windows BLE 开发指南(Rust windows-rs)

作者 zengyuhan503
2025年11月28日 11:21

Windows BLE 开发指南(Rust windows-rs)

本演示在 Windows 平台使用 Rust 的 windows-rs 库进行 BLE(低功耗蓝牙)开发:扫描设备、连接与服务发现、选择特征、启用通知(CCCD)、发送与接收数据、断开与清理,同时给出“已配对设备重启”场景的稳态策略


依赖与准备

Cargo.toml 添加依赖:

[dependencies]
windows = "0.56"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
uuid = "1"

常用导入(放在你的模块或文件顶部):

use windows::{
    core::Result as WinResult,
    Devices::Bluetooth::Advertisement::{
        BluetoothLEAdvertisementWatcher, BluetoothLEAdvertisementReceivedEventArgs,
    },
    Devices::Bluetooth::{BluetoothLEDevice, BluetoothConnectionStatus},
    Devices::Bluetooth::GenericAttributeProfile::{
        GattDeviceService, GattCharacteristic, GattCharacteristicProperties,
        GattClientCharacteristicConfigurationDescriptorValue, GattCommunicationStatus,
        GattValueChangedEventArgs, GattProtectionLevel,
    },
    Devices::Enumeration::{
        DeviceInformationCustomPairing, DevicePairingKinds, DevicePairingRequestedEventArgs,
        DevicePairingResultStatus,
    },
    Foundation::TypedEventHandler,
    Storage::Streams::DataReader,
};

辅助函数:UUID 字符串转 Windows GUID:

fn guid_from_str(s: &str) -> windows::core::GUID {
    let u = uuid::Uuid::parse_str(s).expect("uuid parse error");
    let (d1, d2, d3, d4) = u.as_fields();
    windows::core::GUID::from_values(d1, d2, d3, *d4)
}

扫描与筛选设备

扫描 5 秒并返回设备列表(MAC 为 12 位十六进制字符串):

#[derive(Debug, Clone)]
struct BleDevice { mac: String, name: String, rssi: Option<i16> }

async fn scan_devices(timeout_ms: u64) -> Vec<BleDevice> {
    use std::{collections::HashMap, sync::Arc, time::Duration};
    use tokio::sync::Mutex as AsyncMutex; // 广播事件是异步回调,这里用异步互斥保护聚合表

    // address→设备信息 聚合表(Windows 提供 64 位地址,不是文本 MAC)
    let map = Arc::new(AsyncMutex::new(HashMap::<u64, BleDevice>::new()));
    let watcher = BluetoothLEAdvertisementWatcher::new().unwrap(); // 创建广播监听器
    let map_cb = map.clone();
    // 注册 Received 事件:每条广播提取地址/名称/RSSI 并写入聚合表
    let _token = watcher.Received(&TypedEventHandler::new(
        move |_sender: &Option<BluetoothLEAdvertisementWatcher>, args: &Option<BluetoothLEAdvertisementReceivedEventArgs>| -> WinResult<()> {
            if let Some(args) = args.as_ref() {
                let addr = args.BluetoothAddress()?; // 64 位地址(数值)
                let mac = format!( // 转为 12 位十六进制字符串,便于统一筛选
                    "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
                    (addr >> 40) & 0xff, (addr >> 32) & 0xff, (addr >> 24) & 0xff,
                    (addr >> 16) & 0xff, (addr >> 8) & 0xff, addr & 0xff
                );
                let name = args.Advertisement()?.LocalName()?.to_string(); // 本地名(可能空)
                let rssi = args.RawSignalStrengthInDBm()?; // 信号强度(dBm)
                if let Ok(mut m) = map_cb.try_lock() { // 非阻塞写入,避免回调卡住
                    m.entry(addr).and_modify(|d| { if d.name.is_empty() && !name.is_empty() { d.name = name.clone(); } d.rssi = Some(rssi); })
                        .or_insert(BleDevice { mac, name, rssi: Some(rssi) });
                }
            }
            Ok(())
        }
    )).unwrap();
    watcher.Start().unwrap(); // 开始监听广播
    tokio::time::sleep(Duration::from_millis(timeout_ms)).await;
    watcher.Stop().unwrap(); // 停止监听
    let locked = map.lock().await; // 收敛为列表
    let mut list: Vec<_> = locked.values().cloned().filter(|d| !d.name.is_empty()).collect();
    list.sort_by_key(|d| d.mac.clone());
    list
}

async fn filter_device(mac: &str, list: Vec<BleDevice>) -> Option<BleDevice> {
    // 支持 "AA:BB:..." 或无分隔符/大小写不一致的输入
    let mut s = mac.replace(":", "").replace('-', "").to_lowercase();
    if s.len() != 12 || s.chars().any(|c| !c.is_ascii_hexdigit()) {
        if let Ok(addr_hex) = u64::from_str_radix(&s, 16) { s = format!("{:012x}", addr_hex); }
        else if let Ok(addr_dec) = s.parse::<u64>() { s = format!("{:012x}", addr_dec); }
    }
    list.into_iter().find(|d| d.mac == s)
}

为什么这么做:

  • Windows 广播提供的是数值地址,统一转为 12 位十六进制更利于设备筛选与日志定位。
  • 事件回调中尽量使用 try_lock,避免广播高频导致锁争用。

常见坑:

  • 某些设备广播不带本地名,需允许空名称并在后续才筛掉。
  • 扫描过短可能错过目标设备;根据场景调整 timeout_ms

连接与服务发现

async fn connect_and_list_services(device: &BleDevice) -> BluetoothLEDevice {
    let addr = u64::from_str_radix(&device.mac.replace(":", "").to_lowercase(), 16).unwrap(); // 文本 MAC → 数值地址
    let dev = BluetoothLEDevice::FromBluetoothAddressAsync(addr).unwrap().await.unwrap(); // WinRT 异步获取设备对象
    // 等待连接就绪:避免紧接着读服务/写 CCCD 命中未连接状态
    for _ in 0..10 {
        if dev.ConnectionStatus().unwrap() == BluetoothConnectionStatus::Connected { break; }
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    }
    // 列出服务(可选):用于确认目标服务是否存在与刷新完成
    let services = dev.GetGattServicesAsync().unwrap().await.unwrap();
    if services.Status().unwrap() == GattCommunicationStatus::Success {
        let list = services.Services().unwrap();
        for s in list.into_iter() { println!("service uuid {:?}", s.Uuid().unwrap()); }
    }
    dev
}
  • 某些设备在建立物理连接后,需要数百毫秒才进入 Connected;过早操作常导致不可达或读空服务。

常见坑:

  • 忽略连接就绪轮询会触发后续“Unreachable”或启用通知中止(E_ABORT)。

特征选择(通知/写入)

按 GUID 过滤,否则枚举回退,并根据属性判定:

async fn select_notify(service: &GattDeviceService, guid: windows::core::GUID) -> Option<GattCharacteristic> {
    let res = service.GetCharacteristicsForUuidAsync(guid).unwrap().await.unwrap();
    if res.Status().unwrap() == GattCommunicationStatus::Success {
        let list = res.Characteristics().unwrap();
        for c in list.into_iter() {
            let props = c.CharacteristicProperties().unwrap(); // 判定具备 Notify 或 Indicate
            let ok = (props.0 & GattCharacteristicProperties::Notify.0) != 0 || (props.0 & GattCharacteristicProperties::Indicate.0) != 0;
            if ok { return Some(c); }
        }
    }
    // GUID 过滤失败时,枚举全部特征并匹配 GUID
    let all = service.GetCharacteristicsAsync().unwrap().await.unwrap().Characteristics().unwrap();
    for c in all.into_iter() { if c.Uuid().unwrap() == guid { return Some(c); } }
    None
}

async fn select_write(service: &GattDeviceService, guid: windows::core::GUID) -> Option<GattCharacteristic> {
    let res = service.GetCharacteristicsForUuidAsync(guid).unwrap().await.unwrap();
    if res.Status().unwrap() == GattCommunicationStatus::Success {
        let list = res.Characteristics().unwrap();
        for c in list.into_iter() {
            let p = c.CharacteristicProperties().unwrap(); // 判定具备 Write 或 WriteWithoutResponse
            let ok = (p.0 & GattCharacteristicProperties::Write.0) != 0 || (p.0 & GattCharacteristicProperties::WriteWithoutResponse.0) != 0;
            if ok { return Some(c); }
        }
    }
    let all = service.GetCharacteristicsAsync().unwrap().await.unwrap().Characteristics().unwrap();
    for c in all.into_iter() { if c.Uuid().unwrap() == guid { return Some(c); } }
    None
}
  • 设备端在刷新期间可能返回空列表,枚举回退能避免漏选;属性判定可避免误选不可用特征。

常见坑:

  • 仅按 GUID 命中但属性不满足(无 Notify/Write),后续启用或写入会失败。

启用通知(CCCD)与回调注册

async fn enable_notify_with_retry(ch: &GattCharacteristic) -> bool {
    tokio::time::sleep(std::time::Duration::from_millis(1000)).await; // 预延时,避开设备忙/栈刷新
    for _ in 0..3 {
        if let Ok(op) = ch.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Notify) {
            if let Ok(status) = op.await { if status == GattCommunicationStatus::Success { return true; } } // 首选 Notify
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    for _ in 0..2 {
        if let Ok(op) = ch.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Indicate) {
            if let Ok(status) = op.await { if status == GattCommunicationStatus::Success { return true; } } // 回退 Indicate
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    false
}

fn register_value_changed(ch: &GattCharacteristic, mut on_notify: impl FnMut(Vec<u8>) + Send + 'static) {
    let handler = TypedEventHandler::<GattCharacteristic, GattValueChangedEventArgs>::new(
        move |_sender: &Option<GattCharacteristic>, args: &Option<GattValueChangedEventArgs>| {
            if let Some(args) = args.as_ref() {
                if let Ok(buf) = args.CharacteristicValue() {
                    if let Ok(reader) = DataReader::FromBuffer(&buf) { // IBuffer → Vec<u8>
                        if let Ok(len) = buf.Length() {
                            let mut data = vec![0u8; len as usize];
                            let _ = reader.ReadBytes(&mut data);
                            on_notify(data);
                        }
                    }
                }
            }
            Ok(())
        }
    );
    let _ = ch.ValueChanged(&handler);
}
  • Notify 不需要 ACK,实时性好;设备只支持 Indicate 时需回退。
  • IBuffer 转字节后交给上层解析,避免 WinRT 读取阻塞。

常见坑:

  • 启用通知过早会被中止(E_ABORT);加延时与重试能显著降低概率。

写入(带响应优先,失败回退无响应)

async fn write_with_result_and_fallback(ch: &GattCharacteristic, data: &[u8]) -> GattCommunicationStatus {
    use windows::Storage::Streams::{InMemoryRandomAccessStream, DataWriter};
    let stream = InMemoryRandomAccessStream::new().unwrap(); // 构造内存流
    let out = stream.GetOutputStreamAt(0).unwrap(); // 取输出流
    let writer = DataWriter::CreateDataWriter(&out).unwrap(); // 创建写入器
    writer.WriteBytes(data).unwrap(); // 写入字节
    let buf = writer.DetachBuffer().unwrap(); // 拆出 IBuffer

    let res = ch.WriteValueWithResultAsync(&buf).unwrap().await.unwrap(); // 带响应写入
    let status = res.Status().unwrap(); // 读取通信状态(可结合 ProtocolError 定位 ATT 错误)
    if status != GattCommunicationStatus::Success {
        let fb = ch.WriteValueAsync(&buf).unwrap().await.unwrap(); // 回退:无响应写入
        return fb;
    }
    status
}

为什么这么做:

  • 带响应写可获 ATT 错码(权限/长度/不允许等);设备仅支持无响应写时自动回退。

常见坑:

  • 未加密/未配对时常见 Insufficient Authentication;需先建立加密会话。

断开与清理(顺序至关重要)

async fn disconnect_cleanup(dev: &BluetoothLEDevice, notify_char: &GattCharacteristic, token: windows::Foundation::EventRegistrationToken) {
    let _ = notify_char.RemoveValueChanged(token); // 先移除通知事件,避免回调残留
    if let Ok(op) = notify_char.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::None) { let _ = op.await; } // 关闭订阅(CCCD=None)
    if let Ok(svc) = notify_char.Service() { if let Ok(sess) = svc.Session() { let _ = sess.SetMaintainConnection(false); } } // 取消保持连接
    let _ = dev.Close(); // 关闭设备句柄
}
  • 不正确的清理顺序会导致下次连接启用通知失败或会话不可达。

常见坑:

  • 忘记 RemoveValueChanged 导致 ValueChanged 持有对象,写入 None 时报错。

已配对设备重启的稳态策略(清理 + 重试)

设备已在系统层“配对且连接”,当设备重启进入配对模式时,Windows 保留旧连接/GATT 缓存,常见错误:

  • “notify characteristic not found”
  • “enable notify/indicate failed” 或 E_ABORT(0x80004004)

建议在连接前执行 OS 级清理:

async fn unpair_and_close(address: u64) {
    let dev = BluetoothLEDevice::FromBluetoothAddressAsync(address).unwrap().await.unwrap();
    if let Ok(di) = dev.DeviceInformation() {
        if let Ok(pairing) = di.Pairing() {
            if pairing.IsPaired().unwrap_or(false) {
                let _ = pairing.UnpairAsync().unwrap().await;
            }
        }
    }
    let _ = dev.Close();
    tokio::time::sleep(std::time::Duration::from_millis(800)).await;
}

随后再进行连接、服务发现、特征选择与 CCCD 启用,并在各步骤加入适度延时与重试。


组合示例:连接→订阅→发送→接收→断开

#[tokio::main]
async fn main() {
    let list = scan_devices(5000).await;
    let dev = filter_device("208B37997529", list).expect("device not found");

    let addr = u64::from_str_radix(&dev.mac, 16).unwrap();
    unpair_and_close(addr).await; // 已配对场景建议先清理

    let device = connect_and_list_services(&dev).await;
    let service = {
        let guid = guid_from_str("01000100-0000-1000-8000-009078563412");
        let list = device.GetGattServicesAsync().unwrap().await.unwrap().Services().unwrap();
        list.into_iter().find(|s| s.Uuid().unwrap() == guid).expect("service not found")
    };

    tokio::time::sleep(std::time::Duration::from_millis(800)).await;
    let notify = select_notify(&service, guid_from_str("02000200-0000-1000-8000-009178563412")).expect("notify not found");
    let writec = select_write(&service, guid_from_str("03000300-0000-1000-8000-009278563412")).expect("write not found");

    let _ = notify.SetProtectionLevel(GattProtectionLevel::EncryptionRequired);
    let _ = writec.SetProtectionLevel(GattProtectionLevel::EncryptionRequired);

    if !enable_notify_with_retry(&notify).await { panic!("enable notify failed"); }
    register_value_changed(&notify, |data| { println!("notify: {} bytes", data.len()); });

    let payload = b"example payload";
    let status = write_with_result_and_fallback(&writec, payload).await;
    println!("write status: {:?}", status);

    // 清理
    // 注意:真实项目中保存 ValueChanged 注册的 token,并在断开时传入
    let dummy_token = windows::Foundation::EventRegistrationToken { value: 0 };
    disconnect_cleanup(&device, &notify, dummy_token).await;
}

故障排查与最佳实践

  • WinRT await 与并发:将涉及 WinRT await 的代码放到阻塞线程或在当前线程执行,避免 Send 约束问题
  • 连接就绪:轮询 BluetoothConnectionStatus::Connected 再进行特征与 CCCD 操作
  • 特征与 CCCD:获取服务后延时、特征选择重试;CCCD 启用延时、Notify→Indicate 回退;必要时重新抓取一次特征再启用
  • 已配对设备重启:务必先执行 UnpairAsync + Close + 延时 再连接,显著降低缓存不一致导致的失败
  • 断开顺序:移除事件→CCCD=None→取消保持连接→Close 设备;不当的顺序会让下次连接启用通知失败

小结

本文给出了一套完整的、可直接复制的 Windows BLE 开发代码片段与操作步骤,涵盖扫描、连接与服务发现、特征选择、启用通知、写入与接收、断开清理,以及已配对设备重启场景的稳态策略。将这些片段按需组合,即可搭建稳定的 BLE 通信链路。


带详注的代码片段(逐行说明)

1) 扫描与筛选(详注版)

use windows::{
    core::Result as WinResult,
    Devices::Bluetooth::Advertisement::{
        BluetoothLEAdvertisementWatcher, BluetoothLEAdvertisementReceivedEventArgs,
    },
    Foundation::TypedEventHandler,
};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::Mutex as AsyncMutex;

#[derive(Debug, Clone)]
struct BleDevice { mac: String, name: String, rssi: Option<i16> }

// 扫描指定时长,聚合设备到列表
async fn scan_devices(timeout_ms: u64) -> Vec<BleDevice> {
    // 使用共享 HashMap 聚合:Windows 广播给出的是 64 位地址(非文本 MAC)
    let map = Arc::new(AsyncMutex::new(HashMap::<u64, BleDevice>::new()));

    // 创建广播监听器
    let watcher = BluetoothLEAdvertisementWatcher::new().unwrap();
    let map_cb = map.clone();

    // 注册接收事件:每条广播中提取地址、设备名与 RSSI
    let _token = watcher.Received(&TypedEventHandler::new(
        move |_sender: &Option<BluetoothLEAdvertisementWatcher>, args: &Option<BluetoothLEAdvertisementReceivedEventArgs>| -> WinResult<()> {
            if let Some(args) = args.as_ref() {
                // 设备地址为 64 位整型,转为 12 位十六进制字符串(不含分隔符)
                let addr = args.BluetoothAddress()?;
                let mac = format!(
                    "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
                    (addr >> 40) & 0xff, (addr >> 32) & 0xff, (addr >> 24) & 0xff,
                    (addr >> 16) & 0xff, (addr >> 8) & 0xff, addr & 0xff
                );
                // 广告包中的本地名
                let name = args.Advertisement()?.LocalName()?.to_string();
                // 信号强度(可能不存在)
                let rssi = args.RawSignalStrengthInDBm()?;

                // 聚合到共享表:若已有记录则更新更有价值的信息(非空名称、最新 RSSI)
                if let Ok(mut m) = map_cb.try_lock() {
                    m.entry(addr)
                        .and_modify(|d| { if d.name.is_empty() && !name.is_empty() { d.name = name.clone(); } d.rssi = Some(rssi); })
                        .or_insert(BleDevice { mac, name, rssi: Some(rssi) });
                }
            }
            Ok(())
        }
    )).unwrap();

    // 开始监听指定时间窗口
    watcher.Start().unwrap();
    tokio::time::sleep(Duration::from_millis(timeout_ms)).await;
    watcher.Stop().unwrap();

    // 收敛为列表并按 MAC 排序,过滤空名称
    let locked = map.lock().await;
    let mut list: Vec<_> = locked.values().cloned().filter(|d| !d.name.is_empty()).collect();
    list.sort_by_key(|d| d.mac.clone());
    list
}

// 按 MAC 文本筛选设备:支持去分隔符与大小写统一,兼容十六/十进制
async fn filter_device(mac: &str, list: Vec<BleDevice>) -> Option<BleDevice> {
    let mut s = mac.replace(":", "").replace('-', "").to_lowercase();
    if s.len() != 12 || s.chars().any(|c| !c.is_ascii_hexdigit()) {
        if let Ok(addr_hex) = u64::from_str_radix(&s, 16) { s = format!("{:012x}", addr_hex); }
        else if let Ok(addr_dec) = s.parse::<u64>() { s = format!("{:012x}", addr_dec); }
    }
    list.into_iter().find(|d| d.mac == s)
}

2) 连接与服务发现(详注版)

use windows::Devices::Bluetooth::{BluetoothLEDevice, BluetoothConnectionStatus};
use windows::Devices::Bluetooth::GenericAttributeProfile::{GattCommunicationStatus};

// 建立到设备的连接,并打印服务列表(用于诊断)
async fn connect_and_list_services(device: &BleDevice) -> BluetoothLEDevice {
    // 文本 MAC → 64 位地址(十六进制解析)
    let addr = u64::from_str_radix(&device.mac.replace(":", "").to_lowercase(), 16).unwrap();
    // 异步获取设备对象(WinRT)
    let dev = BluetoothLEDevice::FromBluetoothAddressAsync(addr).unwrap().await.unwrap();

    // 连接就绪等待:部分设备需要一点时间进入 Connected 状态
    for _ in 0..10 {
        if dev.ConnectionStatus().unwrap() == BluetoothConnectionStatus::Connected { break; }
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    }

    // 枚举 GATT 服务并打印 UUID(便于确认服务是否刷新与存在)
    let services = dev.GetGattServicesAsync().unwrap().await.unwrap();
    if services.Status().unwrap() == GattCommunicationStatus::Success {
        let list = services.Services().unwrap();
        for s in list.into_iter() { println!("service uuid {:?}", s.Uuid().unwrap()); }
    }
    dev
}

3) 特征选择(详注版)

use windows::Devices::Bluetooth::GenericAttributeProfile::{
    GattDeviceService, GattCharacteristic, GattCharacteristicProperties, GattCommunicationStatus,
};

// 选择通知特征:先 GUID 过滤 + 按属性检查;失败枚举全部回退
async fn select_notify(service: &GattDeviceService, guid: windows::core::GUID) -> Option<GattCharacteristic> {
    let res = service.GetCharacteristicsForUuidAsync(guid).unwrap().await.unwrap();
    if res.Status().unwrap() == GattCommunicationStatus::Success {
        let list = res.Characteristics().unwrap();
        for c in list.into_iter() {
            let props = c.CharacteristicProperties().unwrap();
            let ok = (props.0 & GattCharacteristicProperties::Notify.0) != 0 || (props.0 & GattCharacteristicProperties::Indicate.0) != 0;
            if ok { return Some(c); }
        }
    }
    // 枚举回退:某些设备在刷新期间 GUID 过滤可能返回空
    let all = service.GetCharacteristicsAsync().unwrap().await.unwrap().Characteristics().unwrap();
    for c in all.into_iter() { if c.Uuid().unwrap() == guid { return Some(c); } }
    None
}

// 选择写特征:同理,需具备 Write 或 WriteWithoutResponse 属性
async fn select_write(service: &GattDeviceService, guid: windows::core::GUID) -> Option<GattCharacteristic> {
    let res = service.GetCharacteristicsForUuidAsync(guid).unwrap().await.unwrap();
    if res.Status().unwrap() == GattCommunicationStatus::Success {
        let list = res.Characteristics().unwrap();
        for c in list.into_iter() {
            let p = c.CharacteristicProperties().unwrap();
            let ok = (p.0 & GattCharacteristicProperties::Write.0) != 0 || (p.0 & GattCharacteristicProperties::WriteWithoutResponse.0) != 0;
            if ok { return Some(c); }
        }
    }
    let all = service.GetCharacteristicsAsync().unwrap().await.unwrap().Characteristics().unwrap();
    for c in all.into_iter() { if c.Uuid().unwrap() == guid { return Some(c); } }
    None
}

4) 启用通知与注册回调(详注版)

use windows::Devices::Bluetooth::GenericAttributeProfile::{
    GattCharacteristic, GattClientCharacteristicConfigurationDescriptorValue, GattCommunicationStatus,
};
use windows::Devices::Bluetooth::GenericAttributeProfile::{GattValueChangedEventArgs};
use windows::Storage::Streams::DataReader;
use windows::Foundation::TypedEventHandler;

// 启用通知:优先 Notify,失败回退 Indicate;加入预延时与重试以跨过设备刷新窗口
async fn enable_notify_with_retry(ch: &GattCharacteristic) -> bool {
    // 预延时:避免立即写 CCCD 命中设备忙或栈刷新
    tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
    // 尝试 Notify 多次
    for _ in 0..3 {
        if let Ok(op) = ch.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Notify) {
            if let Ok(status) = op.await { if status == GattCommunicationStatus::Success { return true; } }
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    // 回退 Indicate
    for _ in 0..2 {
        if let Ok(op) = ch.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Indicate) {
            if let Ok(status) = op.await { if status == GattCommunicationStatus::Success { return true; } }
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    false
}

// 注册通知回调:将 IBuffer 转为 Vec<u8> 并交给调用者处理
fn register_value_changed(ch: &GattCharacteristic, mut on_notify: impl FnMut(Vec<u8>) + Send + 'static) {
    let handler = TypedEventHandler::<GattCharacteristic, GattValueChangedEventArgs>::new(
        move |_sender: &Option<GattCharacteristic>, args: &Option<GattValueChangedEventArgs>| {
            if let Some(args) = args.as_ref() {
                if let Ok(buf) = args.CharacteristicValue() {
                    if let Ok(reader) = DataReader::FromBuffer(&buf) {
                        if let Ok(len) = buf.Length() {
                            let mut data = vec![0u8; len as usize];
                            let _ = reader.ReadBytes(&mut data);
                            on_notify(data);
                        }
                    }
                }
            }
            Ok(())
        }
    );
    let _ = ch.ValueChanged(&handler);
}

5) 写入(详注版)

use windows::Devices::Bluetooth::GenericAttributeProfile::{GattCharacteristic, GattCommunicationStatus};
use windows::Storage::Streams::{InMemoryRandomAccessStream, DataWriter};

// 写入封装:优先带响应写入(便于获取协议错误),失败回退无响应
async fn write_with_result_and_fallback(ch: &GattCharacteristic, data: &[u8]) -> GattCommunicationStatus {
    // WinRT 写入 API 需要 IBuffer;这里通过内存流 + DataWriter 构造缓冲区
    let stream = InMemoryRandomAccessStream::new().unwrap();
    let out = stream.GetOutputStreamAt(0).unwrap();
    let writer = DataWriter::CreateDataWriter(&out).unwrap();
    writer.WriteBytes(data).unwrap();
    let buf = writer.DetachBuffer().unwrap();

    // 带响应写入:可读取状态与协议错误码(ATT),便于定位权限或长度问题
    let res = ch.WriteValueWithResultAsync(&buf).unwrap().await.unwrap();
    let status = res.Status().unwrap();
    if status != GattCommunicationStatus::Success {
        // 回退为无响应写:部分设备仅允许无响应写
        let fb = ch.WriteValueAsync(&buf).unwrap().await.unwrap();
        return fb;
    }
    status
}

6) 断开与清理(详注版)

use windows::Devices::Bluetooth::BluetoothLEDevice;
use windows::Devices::Bluetooth::GenericAttributeProfile::{GattCharacteristic, GattClientCharacteristicConfigurationDescriptorValue};

// 断开顺序:RemoveValueChanged → CCCD=None → MaintainConnection(false) → Close
async fn disconnect_cleanup(dev: &BluetoothLEDevice, notify_char: &GattCharacteristic, token: windows::Foundation::EventRegistrationToken) {
    let _ = notify_char.RemoveValueChanged(token);
    if let Ok(op) = notify_char.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::None) { let _ = op.await; }
    if let Ok(svc) = notify_char.Service() { if let Ok(sess) = svc.Session() { let _ = sess.SetMaintainConnection(false); } }
    let _ = dev.Close();
}

7) 已配对设备重启的清理(详注版)

use windows::Devices::Bluetooth::BluetoothLEDevice;

// 在已配对且系统持有旧连接的情况下:先 Unpair + Close 再连接,降低缓存不一致导致的失败
async fn unpair_and_close(address: u64) {
    let dev = BluetoothLEDevice::FromBluetoothAddressAsync(address).unwrap().await.unwrap();
    if let Ok(di) = dev.DeviceInformation() {
        if let Ok(pairing) = di.Pairing() {
            if pairing.IsPaired().unwrap_or(false) {
                let _ = pairing.UnpairAsync().unwrap().await; // 解除配对,释放旧权限与密钥
            }
        }
    }
    let _ = dev.Close(); // 关闭旧设备句柄
    tokio::time::sleep(std::time::Duration::from_millis(800)).await; // 等待栈刷新
}

8) 组合流程(详注版)

#[tokio::main]
async fn main() {
    // 1) 扫描并选择目标设备
    let list = scan_devices(5000).await;
    let dev = filter_device("208B37997529", list).expect("device not found");

    // 2) 已配对场景建议先清理旧状态(Unpair + Close + 延时)
    let addr = u64::from_str_radix(&dev.mac, 16).unwrap();
    unpair_and_close(addr).await;

    // 3) 连接并输出服务列表(诊断用途)
    let device = connect_and_list_services(&dev).await;

    // 4) 获取目标服务(按 GUID 匹配)
    let service = {
        let guid = guid_from_str("01000100-0000-1000-8000-009078563412");
        let list = device.GetGattServicesAsync().unwrap().await.unwrap().Services().unwrap();
        list.into_iter().find(|s| s.Uuid().unwrap() == guid).expect("service not found")
    };

    // 5) 特征选择前短暂等待,随后选择通知与写特征
    tokio::time::sleep(std::time::Duration::from_millis(800)).await;
    let notify = select_notify(&service, guid_from_str("02000200-0000-1000-8000-009178563412")).expect("notify not found");
    let writec = select_write(&service, guid_from_str("03000300-0000-1000-8000-009278563412")).expect("write not found");

    // 6) 若设备要求加密,设置保护级别为加密
    let _ = notify.SetProtectionLevel(windows::Devices::Bluetooth::GenericAttributeProfile::GattProtectionLevel::EncryptionRequired);
    let _ = writec.SetProtectionLevel(windows::Devices::Bluetooth::GenericAttributeProfile::GattProtectionLevel::EncryptionRequired);

    // 7) 启用通知(带重试/回退),并注册通知回调
    if !enable_notify_with_retry(&notify).await { panic!("enable notify failed"); }
    register_value_changed(&notify, |data| { println!("notify: {} bytes", data.len()); });

    // 8) 写入示例负载(带响应优先,失败回退)
    let payload = b"example payload";
    let status = write_with_result_and_fallback(&writec, payload).await;
    println!("write status: {:?}", status);

    // 9) 断开与清理(真实项目中保存并传入 ValueChanged 的 token)
    let dummy_token = windows::Foundation::EventRegistrationToken { value: 0 };
    disconnect_cleanup(&device, &notify, dummy_token).await;
}

OrbitControls 的完整原理

作者 big男孩
2025年11月28日 11:20

🎯 OrbitControls 的完整原理(最精简 + 最准确版)

OrbitControls 的核心目标很简单:

让相机围绕一个目标点(target)做旋转、缩放、平移,同时保持视角稳定。****

它的实现不是直接修改相机的 rotation,而是:


✅ 1. 使用球坐标系存储相机相对 target 的位置

OrbitControls 内部用 球坐标系 (spherical) 表示相机在球面上的位置:

spherical 属性 代表含义 决定什么
radius 相机到 target 的距离 缩放(远近)
theta 水平旋转(绕 Y 轴) 左右旋转
phi 垂直旋转(从 Y+ 向下量角) 上下旋转

OrbitControls 并不直接存相机 position,而是依赖 spherical。


✅ 2. 每次更新,都会根据 spherical 重新计算 camera.position

伪代码:

// 球坐标转换为世界坐标
offset.setFromSpherical(spherical);

// camera.position = target + offset
camera.position.copy(target).add(offset);

// 相机永远朝向 target
camera.lookAt(target);

也就是说:

相机的位置永远落在一个以 target 为圆心的球面上。****


✅ 3. 用户的三种交互对应修改 spherical 或 target

✔(1)旋转(左键)

修改 spherical 的角度:

theta ← 左右拖动
phi   ← 上下拖动

效果:相机绕 target 转圈。


✔(2)缩放(滚轮)

修改 spherical.radius:

radius += delta

效果:相机沿着射线(camera → target)前进或后退。


✔(3)平移(右键)

修改 target(以及 camera.position 同步移动):

target += panDelta
camera.position += panDelta

这样保持相机与 target 的相对距离不变,视野整体平移。


📌 Why?为什么不直接改 camera.rotation?

因为:

  • 欧拉角 rotation 会出现万向节锁 (gimbal lock)

  • 旋转结果顺序依赖 Euler 的 order

  • 不能保证相机固定绕某点旋转

  • 平移与旋转混合会很混乱

使用 spherical + lookAt(target) 是最稳定、最可控的方案。


🎨 图示(概念图)

          Y+
          |
          |      camera ●
          |       (theta, phi, radius)
          |        /
          |       /
          |      /
          |     /
          ●----+------------------ X+
         target

📌 相机始终在半径为 radius 的球面上

📌 用户的操作实质是改变球坐标的位置


🎯 OrbitControls 本质上做的两件事

无论用户怎么操作,都只是修改:

  1. camera.position****

  2. controls.target

最终调用:

camera.lookAt(target);

而不动 rotation。


📦 大总结(你可以直接放到文档里)

OrbitControls = 基于球坐标的相机系统,通过改变 spherical(旋转/缩放)和 target(平移),生成 camera.position,然后使用 lookAt 保持视角稳定。****

  • 旋转 = 改 theta 和 phi

  • 缩放 = 改 radius

  • 平移 = 改 target

相机不会直接修改 rotation,而是由 target 和 spherical 决定最终的相机朝向和位置。


Harmony os——AbilityStage 组件管理器:我理解的 Module 级「总控台」

2025年11月28日 11:15

Harmony os——AbilityStage 组件管理器:我理解的 Module 级「总控台」

这一篇是我给 AbilityStage 写的学习笔记 + 博客稿。 可以和前面那几篇「应用模型 / UIAbility / 启动模式」放在一个 HarmonyOS 开发系列里。


1. AbilityStage 是什么?一句话版本

官方定义有点长,我自己总结一句:

AbilityStage = 一个 Module 级别的组件管理器,是这个 HAP「第一次被加载」时创建的总控类,用来做模块级初始化 + 全局事件监听。

几个关键点:

  • 跟 Module 一一对应

    • 一个 Module 对应一个 AbilityStage 实例;
    • 比如 entry module 就配一个 MyAbilityStage
  • 它是在该 HAP 第一个 Ability(UIAbility / ExtensionAbility)创建之前被拉起来的;

  • 适合做:资源预加载、线程创建、全局环境监听、内存回调处理、指定进程策略、关闭前拦截等

可以把它想象成:

「这个 Module 的总调度中心」。


2. AbilityStage 提供了哪些回调?

AbilityStage 有两类回调:

  • 生命周期回调:

    • onCreate()
    • onDestroy()
  • 事件回调:

    • onAcceptWant()
    • onConfigurationUpdate()
    • onMemoryLevel()
    • onNewProcessRequest()
    • onPrepareTermination()

我按「开发时常用程度」给它分个类。

2.1 onCreate():Module 级初始化入口

时机:

  • 对应 HAP 首次加载时;
  • 在创建这个 Module 的第一个 Ability(UIAbility 或某个 ExtensionAbility)之前,系统先创建 AbilityStage,然后调用 onCreate()

适合做的事情:

  • 模块级资源预加载;
  • 启动一些公共线程或任务调度器;
  • 注册 Application 级别的监听(比如环境变化、内存回调等)。

示例(简化版):

 import { AbilityStage } from '@kit.AbilityKit';
 
 export default class MyAbilityStage extends AbilityStage {
   onCreate(): void {
     // Module 级初始化,比如资源预加载、线程创建等
     console.info('MyAbilityStage onCreate');
   }
 }

2.2 onAcceptWant():和 UIAbility 指定实例模式配套使用

这个回调专门用在 UIAbility 的 specified 启动模式 场景下。

  • 当某个 UIAbility 以 launchType: "specified" 启动时,系统会:

    1. 先进入对应 Module 的 AbilityStage.onAcceptWant(want)

    2. 由你在这里返回一个字符串 Key;

    3. 系统用这个 Key 去匹配已有的 UIAbility 实例:

      • 有匹配的 → 复用旧实例,触发 onNewWant()
      • 无匹配的 → 创建一个新实例,走 onCreate() + onWindowStageCreate()

因此:

onAcceptWant 的返回值 = UIAbility 实例的标识字符串。

示例里的最简写法:

 import { AbilityStage, Want } from '@kit.AbilityKit';
 
 export default class MyAbilityStage extends AbilityStage {
   onAcceptWant(want: Want): string {
     // 指定实例模式下由你返回实例标识
     return 'MyAbilityStage';
   }
 }

实际业务中可以按 want.parameters 拼出更细的 key,前面 UIAbility 启动模式那篇里已经有详细例子。


2.3 onConfigurationUpdate():环境变化监听(语言 / 主题 / 方向)

这个和 EnvironmentCallback 搭配尤为常用,适合做「系统配置变化 → 应用整体适配」。

系统环境变化时会触发:

  • 语言变更;
  • 深色 / 浅色模式切换;
  • 屏幕方向变更;
  • 字号缩放;
  • 字重缩放等。

你可以在 AbilityStage 的 onCreate() 里注册环境监听:

 import { EnvironmentCallback, AbilityStage } from '@kit.AbilityKit';
 import { BusinessError } from '@kit.BasicServicesKit';
 
 export default class MyAbilityStage extends AbilityStage {
   onCreate(): void {
     console.info('AbilityStage onCreate');
 
     const envCallback: EnvironmentCallback = {
       onConfigurationUpdated(config) {
         console.info(`onConfigurationUpdated: ${JSON.stringify(config)}`);
         const language = config.language;           // 当前语言
         const colorMode = config.colorMode;         // 深 / 浅色模式
         const direction = config.direction;         // 屏幕方向
         const fontSizeScale = config.fontSizeScale; // 字体大小缩放
         const fontWeightScale = config.fontWeightScale; // 字体粗细缩放
         // 可以在这里触发一些全局 UI 适配逻辑
       },
       onMemoryLevel(level) {
         console.info(`onMemoryLevel level: ${level}`);
       }
     };
 
     try {
       const applicationContext = this.context.getApplicationContext();
       const callbackId = applicationContext.on('environment', envCallback);
       console.info(`env callbackId: ${callbackId}`);
     } catch (error) {
       console.error(
         `env callback error: ${(error as BusinessError).code}, ${(error as BusinessError).message}`
       );
     }
   }
 
   onDestroy(): void {
     console.info('AbilityStage onDestroy');
   }
 }

小心得: 如果你的应用有「多语言切换、主题联动、字体大小适配」这类全局需求,用 AbilityStage + EnvironmentCallback 会比在单个 UIAbility 中东一块西一块强很多。


2.4 onMemoryLevel():系统内存吃紧时的自救机会

当系统内存紧张时会触发这个回调。

  • 比如应用退到后台后还占着一堆内存;
  • 系统会通过 onMemoryLevel() 通知你当前内存压力等级;
  • 你可以主动释放一些不关键的资源(缓存大图、预加载数据等)。

好处是:

主动配合系统回收内存,有助于避免进程被系统直接 kill,提升应用整体稳定性。

上面的 envCallback 中其实已经演示了 onMemoryLevel() 的写法。


2.5 onNewProcessRequest():控制 UIAbility 运行在哪个进程

这个回调是给 「多进程策略」 用的,场景会比较高级。

  • 当某个 UIAbility 启动时,系统会回调 onNewProcessRequest()

  • 你可以返回一个「进程标识字符串」:

    • 若该标识对应的进程已经存在 → 复用;
    • 若不存在 → 创建新进程;
  • 要启用这个能力,需要在 module.json5 里先配置:

 {
   "module": {
     // ...
     "isolationProcess": true
   }
 }

典型用途:

  • 把某些高风险 / 高资源消耗的 UIAbility 放到单独进程中跑;
  • 做类似「文档编辑进程」这种隔离。

2.6 onPrepareTermination():应用关闭前的最后挽留

当用户「关闭应用」时,系统会先回调 onPrepareTermination()

  • 你可以在这里决定:

    • 是否立刻允许关闭;
    • 还是告诉系统「先不要关」,比如先弹个对话框问用户是否保存草稿。
  • 返回值是 AbilityConstant.PrepareTermination 中的枚举,告诉系统继续关还是中断关闭动作。

典型场景:

  • 用户正在编辑重要内容(文档、填表、写作业…);
  • 用户从最近任务一划而过;
  • 你在 onPrepareTermination() 里检测到有未保存内容 → 先阻止关闭,给 UIAbility 发通知弹窗。

2.7 onDestroy():Module 正常销毁时的收尾

  • 当对应 Module 的 最后一个 Ability 实例退出,并且应用以「正常方式」销毁时触发;
  • 可以在这里做一些模块级的资源释放、取消注册等。

注意两个不会触发的情况:

  • 应用异常崩溃;
  • 被系统强制终止。

这两种情况onDestroy()都不会执行,所以特别关键的数据仍然要在各 Ability 自己的生命周期里保存。


3. 如何在工程中启用 AbilityStage?

默认模板里是不会自动创建 AbilityStage 的,需要你自己手动加一份。

3.1 创建 AbilityStage 文件

  1. 在某个 Module 的 ets 目录下新建目录 比如:ets/myabilitystage/
  2. 新建 ArkTS 文件:MyAbilityStage.ets
  3. 继承 AbilityStage,实现所需回调:
 import { AbilityStage, Want } from '@kit.AbilityKit';
 
 export default class MyAbilityStage extends AbilityStage {
   onCreate(): void {
     console.info('MyAbilityStage onCreate');
     // 模块初始化逻辑
   }
 
   onAcceptWant(want: Want): string {
     // 仅在 specified 启动模式下会触发
     return 'MyAbilityStage';
   }
 }

3.2 在 module.json5 里绑定入口

srcEntry 指定这个 Module 对应的 AbilityStage 入口:

 {
   "module": {
     "name": "entry",
     "type": "entry",
     "srcEntry": "./ets/myabilitystage/MyAbilityStage.ets",
     // ...
   }
 }

这样,当这个 HAP 第一次被加载时,就会先走 MyAbilityStage.onCreate(),你的模块级逻辑就能接管整体节奏了。


4. 我会怎么在项目里用 AbilityStage?

结合前面几篇 Stage 模型、UIAbility、ExtensionAbility 的内容,我自己会把 AbilityStage 看成一个「模块级中控」,用来集中处理:

  1. 模块初始化

    • 注册环境变化监听(语言/主题/字体等);
    • 初始化一些全局单例,比如日志、埋点、配置中心。
  2. 全局事件/配置监听

    • EnvironmentCallback 处理深浅色联动;
    • onMemoryLevel 做内存压缩策略。
  3. UIAbility 启动策略

    • 结合 onAcceptWant() + launchType: specified 做多文档/多实例管理;
    • 通过 onNewProcessRequest() 控制哪些 Ability 跑独立进程。
  4. 关闭前的拦截

    • onPrepareTermination() 给整个 App 做一次「善后机会」:

      • 未同步的数据提示用户;
      • 清理某些后台任务;
      • 重要日志 flush。

5. 小结

如果用一句比较形象的话来收尾:

UIAbility 负责「这一个窗口怎么跑」,ExtensionAbility 负责「某个扩展场景怎么跑」, 而 AbilityStage 则站在 Module 的视角,负责「这一整包能力怎么被初始化、管理和收尾」。

在 HarmonyOS NEXT 的 Stage 模型下, AbilityStage 其实是一个非常关键但容易被忽略的点—— 用好了,它能让你的应用模块结构更清晰,行为更可控,也更「系统级」。

vxe-gantt table 甘特图如何设置任务视图每一行的背景色

2025年11月28日 11:09

vxe-gantt table 甘特图如何设置任务视图每一行的背景色,例如给不同任务设置不同背景色。

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

效果

image

代码

通过 task-view-config.viewStyle.cellStyle 设置任务视图行样式,也可以用 task-view-config.viewStyle.rowClassName 设置任务视图行附加 className

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
const ganttOptions = reactive({
  taskBarConfig: {
    showProgress: true,
    barStyle: {
      round: true,
      bgColor: '#fca60b',
      completedBgColor: '#65c16f'
    }
  },
  taskViewConfig: {
    viewStyle: {
      rowStyle ({ row }) {
        if (row.progress < 10) {
          return {
            backgroundColor: '#f1ccef'
          }
        }
        if (row.progress < 50) {
          return {
            backgroundColor: '#f8e4e4'
          }
        }
        return {}
      }
    }
  },
  columns: [
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: 'A项目', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '城市道路修理进度', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: 'B大工程', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '超级大工程', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '地球净化项目', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '一个小目标项目', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '某某计划', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '某某科技项目', start: '2024-03-20', end: '2024-03-29', progress: 50 },
    { id: 10009, title: '地铁建设工程', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '铁路修建计划', start: '2024-03-12', end: '2024-03-20', progress: 10 }
  ]
})
</script>

gitee.com/x-extends/v…

一次从“卡顿地狱”到“丝般顺滑”的 React 搜索优化实战

2025年11月28日 11:08

author: 大布布将军

前言:万恶之源

本故事有虚构成分。但来源于现实开发场景。 事情是这样的。

某个周五下午 4 点,产品经理(PM)迈着六亲不认的步伐向我们前端走来。他满面春风地说:“哎,那个列表页,用户反馈说找不到想要的数据,加个实时搜索功能吧?要那种一边打字一边过滤的,丝般顺滑的感觉,懂我意思吧?”

前端心想:这还不简单?input 绑定 onChange,拿到 value 往列表里一 filter,完事儿。半小时搞定,还能赶上 6 点的地铁。

于是,前端写下了那段让我后来后悔不已的代码。


第一阶段:由于过度自信导致的翻车

为了还原案发现场,前端写了一个简化版的 Demo。假设我们有一个包含 10,000 条数据的列表(别问为什么前端要处理一万条数据,问就是后端甩锅)。

也就是这几行“天真”的代码:


// 假装这里有一万个商品
const generateProducts = () => {
  return Array.from({ length: 10000 }, (_, i) => `超级无敌好用的商品 #${i}`);
};

const dummyProducts = generateProducts();

export default function SearchList() {
  const [query, setQuery] = useState('');

  // 🔴 罪魁祸首在这里:每次 render 都要遍历一万次
  const filteredProducts = dummyProducts.filter(p => 
    p.toLowerCase().includes(query.toLowerCase())
  );

  const handleChange = (e) => {
    setQuery(e.target.value); // 这一步更新 state,触发重渲染
  };

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} 
        onChange={handleChange} 
        placeholder="搜索..." 
        className="border p-2 w-full"
      />
      <ul className="mt-4">
        {filteredProducts.map((p, index) => (
          <li key={index}>{p}</li>
        ))}
      </ul>
    </div>
  );
}

结果如何?

在前端的 ThinkBook 上跑了一下,输入的时候感觉像是在 PPT 里打字。每一个字符敲下去,都要顿个几百毫秒才会显示在输入框里。

为什么? 这是 React 的基本原理:

  1. 用户输入 'a' -> 触发 setQuery
  2. React 甚至还没来得及把 'a' 更新到 input 框里,就被迫去执行组件的 render 函数。
  3. render 函数里有一个极其昂贵的 filter 操作(遍历 10k 次)。
  4. 即使 filter 完了,React 还要把生成的几千个 DOM 节点和之前的做 Diff,然后挂载到页面上。
  5. JS 线程被堵死,UI 渲染被阻塞,用户看到的就是:卡顿

第二阶段:万金油防抖 (Debounce) —— 治标不治本

作为老油条,第一反应当然是:“切,防抖一下不就行了?”

只要让用户打字的时候不触发计算,停下来再计算,不就完了?

import { debounce } from 'lodash';

// ... 省略部分代码

const handleChange = debounce((e) => {
    setQuery(e.target.value);
}, 300);

效果: 输入框确实不卡了,打字很流畅。但是,当你停止打字 300ms 后,页面会突然“冻结”一下,然后列表瞬间刷新。

痛点: 这种体验就像是便秘。虽然没有一直在用力,但最后那一下还是很痛苦。而且,UI 的响应滞后感很强,依然没有达到 PM 要求的“丝般顺滑”。

第三阶段:祭出神器 useDeferredValue

React 18 发布这么久了,是时候让它出来干点活了。

这时候作为前端的我们需要引入一个概念:并发模式 (Concurrent Features)

简单来说,就是把更新任务分为“大哥”和“小弟”。

  • 大哥(紧急更新) :用户的打字输入、点击反馈。这玩意儿必须马上响应,不然用户会以为死机了。
  • 小弟(非紧急更新) :列表的过滤渲染。晚个几百毫秒没人在意。

React 18 给了我们一个 Hook 叫 useDeferredValue,专门用来处理这种场景。

改造后的代码:


export default function OptimizedSearchList() {
  const [query, setQuery] = useState('');
  
  //  魔法在这里:创建一个“滞后”的副本
  // React 会在空闲的时候更新这个值
  const deferredQuery = useDeferredValue(query);

  const handleChange = (e) => {
    // 这里依然是紧急更新,保证 input 框打字流畅
    setQuery(e.target.value); 
  };

  // 只有当 deferredQuery 变了,才去跑这个昂贵的 filter
  // 注意:这里要配合 useMemo,不然也没用
  const filteredProducts = useMemo(() => {
    return dummyProducts.filter(p => 
      p.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [deferredQuery]);

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} // 绑定实时的 query
        onChange={handleChange} 
        className="border p-2 w-full"
      />
      
      {/* 甚至可以加个 loading 状态,判断 query 和 deferredQuery 是否同步 */}
      <div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
        <ul className="mt-4">
          {filteredProducts.map((p, index) => (
            <li key={index}>{p}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

这一波操作到底发生了什么?

  1. 输入 'a' :React 此时收到了两个任务。

    • 任务 A(高优先级):更新 query,让 input 框显示 'a'。
    • 任务 B(低优先级):更新 deferredQuery,并重新计算列表。
  2. React 的调度

    • React 说:“任务 A 甚至关乎到我的尊严,马上执行!” -> Input 框瞬间变了。
    • React 接着说:“任务 B 嘛,我先切片执行一点点... 哎?用户又输入 'b' 了?那任务 B 先暂停,我去执行新的任务 A!”
  3. 结果

    • JS 线程没有被长列表渲染锁死。
    • 输入框始终保持 60fps 的响应速度。
    • 列表会在资源空闲时“不知不觉”地更新完成。

深度解析:React 原理是咋搞的?

为了不显得只是个 API 调用侠,这里必须装一波,讲讲原理。

1. 以前的 React (Stack Reconciler)

想象你在厨房切洋葱(渲染组件)。老板(浏览器)跟你说:“客人要加单!”。以前的 React 是个死脑筋,一旦开始切洋葱(比如那 10,000 条数据),天王老子来了它也得切完才肯抬头。这时候浏览器就卡死了,用户点击没反应。

2. 现在的 React (Fiber & Concurrency)

现在的 React 学聪明了,它把切洋葱分成了无数个小步骤(Time Slicing)。

  • 切两刀,抬头看看:“老板,有急事吗?”
  • 老板:“没事,你继续。” -> 继续切。
  • 切两刀,抬头:“老板?”
  • 老板:“有!客人要喝水(用户输入了)!”
  • React:“好嘞!”(放下菜刀,先去倒水,倒完水回来再继续切洋葱,或者如果洋葱不用切了直接扔掉)。

useDeferredValue 本质上就是告诉 React:“这个 state 的更新是切洋葱,可以往后稍稍,先去给客人倒水。”

总结 & 避坑指南

这波优化上线后,PM 拍了拍前端的肩膀说:“行啊,有点东西。”

但是,兄弟们,请注意以下几点(防杠声明):

  1. 不要滥用:这玩意儿是有 overhead(开销)的。如果你的列表只有 50 条数据,用 useDeferredValue 纯属脱裤子放屁,反而更慢。
  2. 配合 useMemo:就像代码里写的,过滤逻辑必须包裹在 useMemo 里。否则每次 Parent Render,列表过滤还是会执行,useDeferredValue 就白用了。
  3. 性能优化的尽头是虚拟列表:如果数据量真到了 10 万级,别折腾 Concurrent Mode 了,直接上 react-windowreact-virtualized 吧,DOM 节点的数量才是真正的瓶颈。

好了,今天的摸鱼时间结束,撤退 。 我是大布布将军,一个前端开发。


下一步建议:如果你的项目里也有这种“输入卡顿”或者“Tab 切换卡顿”的场景,别急着重构,先试着把那个导致卡顿的状态用 useDeferredValue 包一下,说不定有奇效。

HTML iframe 标签

作者 WILLF
2025年11月28日 10:57

一、什么是 <iframe> ?

<iframe> 是一个内联框架,用于在当前HTML文档中嵌入另一个独立的HTML页面。它就像一个“窗口”,通过它可以加载并显示另一个网页的内容,且该内容拥有独立的 DOMCSSJavaScript环境。

基本语法如下:

    <iframe src="https://example.com" width="600" height="400" frameborder="0"></iframe>

二、核心属性

  • src:嵌入页面的 URL
  • width / height:尺寸(可设为 100%)
  • title:描述 iframe内容
  • loading="lazy":懒加载
  • sandbox:启用安全沙箱
  • allowfullscreen:允许嵌入的网页全屏显示,需要全屏API的支持。
  • frameborder:是否绘制边框,0为不绘制,1为绘制(默认值)。建议尽量少用这个属性,而是在CSS里面设置样式。

三、sandbox 沙箱机制(安全核心!)

这是<iframe>最重要的安全特性。启用后,默认禁止几乎所有危险操作,除非显式授权。

常用sandbox指令:

  • (空值):最严格:禁止脚本、表单提交、弹窗、同源访问等。
  • allow-scripts:允许执行JavaScript
  • allow-same-origin:允许被视为同源(谨慎!若同时允许脚本,可能绕过沙箱)
  • allow-forms:允许提交表单
  • allow-popups:允许 window.open()
  • allow-top-navigation:允许跳转顶层页面(危险!)

四、跨域通信:postMessage API

由于同源策略,父页面与iframe不能直接访问对方DOMJS变量。但可通过postMessage安全通信。

父页面 -> iframe

// 父页面
const iframe = document.getElementById('my-iframe')
iframe.contentWindow.postMessage(
    { type: 'AUTH', token: 'xxx' },
    'https://trusted-oframe.com' //指定目标 origin,防泄漏
)

iframe -> 父页面

// iframe 内部
window.parent.postMessage(
    { type: 'RESIZE', height: 800 },
    '*' // 或指定父页面 origin
)

监听消息(双方都要)

window.addEventListener('message', (event) => {
    if (event.origin !== 'https://expected-parent.com') return
    if (event.data.type === 'AUTH') {
        // 处理token
    }
})

五、性能优化建议

  • 懒加载:<iframe loading="lazy"> 减少首屏压力
  • 按需加载:用户点击“展开”后再设置 src
  • 避免深层嵌套:iframe 嵌套 iframe 会导致性能雪崩

无界微前端:父子应用通信、路由与状态管理最佳实践

作者 LeonGao
2025年11月28日 10:51

基于Kimi的HTML知识分享页

1. 通信机制:构建灵活高效的父子应用交互

在微前端架构中,父子应用之间的通信是确保系统协同运作、数据流畅通的核心环节。无界(Wujie)微前端框架,凭借其基于 iframeWebComponent 的独特设计,为父子应用提供了多种灵活、高效且解耦的通信机制。这些机制不仅解决了传统 iframe 方案中通信困难、DOM隔离严重等问题,还通过去中心化的设计理念,赋予了各个微应用更高的自主性和灵活性 。无界框架的核心通信方式主要包括三种:Props 注入、Window 共享以及 EventBus 事件总线。这三种方式各有侧重,适用于不同的业务场景,共同构建了一个立体化的通信网络,使得主应用与子应用之间、乃至子应用相互之间,都能实现精准、高效的数据交换与方法调用,为构建复杂而健壮的微前端系统奠定了坚实的基础。

image.png

1.1 核心通信方式概览

无界微前端框架为父子应用间的交互提供了三种核心通信机制,旨在满足不同场景下的通信需求,从直接的数据传递到解耦的事件驱动,构建了一个灵活且强大的通信体系。这些机制的设计充分利用了无界框架的技术特性,如 iframe 的同源策略和 WebComponent 的封装能力,确保了通信的便捷性与安全性 。

image.png

1.1.1 Props 注入:父向子的数据与方法传递

Props 注入机制是无界框架中最直接、最常用的一种父向子通信方式。其设计思想借鉴了现代前端框架(如 Vue、React)中组件间通过 Props 传递数据的理念。主应用在加载子应用时,可以通过 props 属性,将任意类型的数据(包括对象、字符串、数字等)或方法(函数)注入到子应用中。子应用在运行时,可以通过无界提供的全局对象(如 window.$wujie.props)轻松访问这些注入的数据和方法 。这种方式的优势在于其直观性和类型安全性,父应用可以精确控制传递给子应用的数据,子应用也能清晰地定义其所需的数据接口。例如,主应用可以将当前登录用户的信息、全局配置、或者一个用于通知主应用状态变更的回调函数,通过 props 传递给子应用,从而实现对子应用的初始化和行为控制。

1.1.2 Window 共享:同源环境下的直接通信

无界框架利用 iframe 作为子应用的 JS 沙箱,并且这个 iframe 与主应用是同源的 。这一设计带来了巨大的通信便利,即父子应用可以直接通过 window 对象进行交互,无需复杂的序列化与反序列化过程。子应用可以通过 window.parent 直接访问主应用的 window 对象,从而读取主应用的全局变量或调用其全局方法。反之,主应用也可以通过获取子应用 iframecontentWindow 对象,来访问子应用内部的全局变量和方法 。这种通信方式几乎是零成本的,性能极高,非常适合需要频繁、快速交换简单数据的场景。例如,主应用可以将一个全局的日志记录函数挂载在 window 上,所有子应用都可以直接调用该函数,实现统一的日志上报。然而,这种方式也存在一定的风险,过度依赖全局变量可能导致命名冲突和代码耦合度增加,因此在使用时需要谨慎规划全局变量的命名空间。

1.1.3 EventBus:去中心化的事件驱动通信

为了实现应用间更彻底的解耦,无界框架内置了一个去中心化的事件总线(EventBus) 。这个 EventBus 实例会被注入到主应用和所有子应用中,使得任何一个应用都可以作为事件的发布者或订阅者。应用间不再直接相互调用,而是通过发送和监听事件来进行通信。例如,子应用 A 在完成某个操作后,可以发布一个名为 task-completed 的事件,而关心这个事件的主应用或其他子应用 B、C 只需提前订阅该事件,即可在事件发生时接收到通知并执行相应的逻辑 。这种事件驱动的模式极大地降低了应用间的耦合度,每个应用只需关心自己感兴趣的事件,而无需了解事件是由谁发出的。EventBus 是实现跨应用状态同步、广播通知、以及构建插件化架构的理想选择,它使得微前端系统的扩展性和可维护性得到了显著提升。

image.png

1.2 通信方式详解与最佳实践

无界微前端框架提供的三种核心通信机制——Props 注入、Window 共享和 EventBus,各自拥有独特的实现方式和适用场景。深入理解其内部工作原理和最佳实践,对于构建高效、稳定、可维护的微前端系统至关重要。本章节将对每种通信方式进行详细解析,并结合实际代码示例,阐述其在不同业务场景下的应用策略和注意事项,旨在为开发者提供一套完整的通信解决方案指南。

1.2.1 Props 注入机制

Props 注入机制是无界框架中实现父向子通信最直接、最推荐的方式之一。它借鉴了现代前端框架的组件化思想,通过声明式的方式将数据和方法从主应用传递到子应用,保证了通信的清晰性和可控性。这种方式不仅易于理解和使用,而且在类型安全和代码可维护性方面表现出色,是实现父子应用间初始化数据传递和回调函数注入的首选方案。

1.2.1.1 主应用配置与数据注入

在主应用中,使用无界组件(如 <WujieVue><WujieReact>)加载子应用时,可以通过 props 属性来注入数据和方法。这个 props 对象可以包含任意类型的数据,例如字符串、对象、数组,甚至是函数。主应用可以将需要共享给子应用的全局状态、配置信息、或者需要子应用触发的回调函数,统一封装在这个 props 对象中。例如,在一个 Vue 主应用中,可以这样配置子应用 :

<template>
  <WujieVue
    name="micro-app"
    url="http://localhost:8080"
    :props="{
      userInfo: currentUser,
      theme: 'dark',
      onTaskComplete: handleTaskComplete
    }"
  />
</template>

<script>
import WujieVue from 'wujie-vue3';

export default {
  components: { WujieVue },
  data() {
    return {
      currentUser: { id: 123, name: 'Alice' }
    };
  },
  methods: {
    handleTaskComplete(taskData) {
      console.log('子应用任务完成:', taskData);
      // 主应用可以在这里更新状态或执行其他逻辑
    }
  }
}
</script>

在这个例子中,主应用向名为 micro-app 的子应用注入了 userInfotheme 两个数据项,以及一个名为 onTaskComplete 的回调函数。这种方式使得主应用对传递给子应用的数据拥有完全的控制权,并且子应用的接口也一目了然。

1.2.1.2 子应用接收与调用

子应用在启动后,可以通过无界注入的全局对象 window.$wujie 来访问主应用传递的 props。具体来说,可以通过 window.$wujie.props 获取到整个 props 对象,然后像访问普通 JavaScript 对象的属性一样,读取数据或调用方法 。例如,在子应用中,可以这样使用主应用注入的数据和方法:

// 在子应用的某个组件或逻辑中
export default {
  mounted() {
    // 获取注入的用户信息
    const userInfo = window.$wujie?.props?.userInfo;
    console.log('当前用户:', userInfo);

    // 获取注入的主题配置
    const theme = window.$wujie?.props?.theme;
    this.applyTheme(theme);
  },
  methods: {
    completeTask() {
      // 任务完成后,调用主应用注入的回调函数
      const taskData = { id: 456, status: 'completed' };
      window.$wujie?.props?.onTaskComplete(taskData);
    },
    applyTheme(theme) {
      // 根据主题配置更新 UI
      document.body.className = `theme-${theme}`;
    }
  }
}

通过这种方式,子应用可以清晰地知道哪些数据是由父应用提供的,并且可以通过调用注入的方法,将内部发生的事件或状态变更通知给主应用,实现了父子应用间的双向交互。

1.2.1.3 适用场景:初始化数据、回调函数传递

Props 注入机制特别适用于以下几种场景:

  1. 初始化数据传递:当子应用加载时,需要一些初始数据才能正确渲染,例如用户信息、权限配置、应用主题等。通过 props 传递这些数据,可以确保子应用在启动时就拥有所需的一切,避免了额外的异步请求和状态同步的复杂性。
  2. 回调函数传递:主应用可以向子应用传递回调函数,允许子应用在特定事件发生时(如用户点击按钮、表单提交、任务完成等)主动调用这些函数,从而将信息传递回主应用。这是一种非常灵活的反向通信方式,相比于子应用直接操作主应用的 window 对象,这种方式的耦合度更低,逻辑更清晰。
  3. 配置化驱动:主应用可以根据不同的业务场景或用户角色,动态地向子应用传递不同的配置,从而控制子应用的行为和展示。例如,一个报表子应用,主应用可以通过 props 传递不同的报表 ID 和查询参数,使其展示不同的数据内容。

总而言之,Props 注入机制以其清晰、安全、易维护的特点,成为无界微前端中父子通信的首选方案之一,尤其适用于需要父应用对子应用进行初始化和行为控制的场景。

1.2.2 Window 共享机制

无界框架巧妙地利用了 iframe 的同源策略,为父子应用提供了一种近乎原生的、高性能的通信方式——Window 共享。由于承载子应用的 iframe 与主应用处于同一个源(Same-Origin)下,它们之间可以直接通过 window 对象进行交互,无需借助 postMessage 等跨域通信手段,从而避免了数据序列化和反序列化的开销,实现了真正的“无界”通信 。这种机制虽然强大且便捷,但也需要开发者谨慎使用,以避免全局命名空间的污染和代码的过度耦合。

1.2.2.1 主应用访问子应用全局变量

主应用可以通过获取子应用 iframecontentWindow 对象,来直接访问和操作子应用内部的全局变量和方法。无界框架为每个子应用的 iframe 设置了唯一的 name 属性,主应用可以通过这个 name 来定位到特定的 iframe,进而访问其 contentWindow 。例如:

// 在主应用中
// 假设子应用的 name 属性被设置为 'micro-app-1'
const microAppIframe = window.document.querySelector('iframe[name=micro-app-1]');
if (microAppIframe) {
  const microAppWindow = microAppIframe.contentWindow;
  
  // 访问子应用的全局变量
  console.log('子应用的全局变量:', microAppWindow.someGlobalVariable);
  
  // 调用子应用的全局方法
  microAppWindow.someGlobalMethod('来自主应用的数据');
}

这种方式使得主应用能够主动、直接地获取子应用的内部状态或触发其行为,适用于主应用需要监控或管理子应用特定行为的场景。然而,过度依赖这种直接访问会破坏子应用的封装性,增加主应用与子应用之间的耦合度,因此应谨慎使用。

1.2.2.2 子应用访问主应用全局变量

与主应用访问子应用类似,子应用也可以通过 window.parent 对象直接访问主应用的 window 对象,从而读取主应用的全局变量或调用其全局方法 。这种方式在子应用中实现起来非常简单直接:

// 在子应用中
// 访问主应用的全局变量
const mainAppGlobalData = window.parent.someGlobalData;
console.log('主应用的全局数据:', mainAppGlobalData);

// 调用主应用的全局方法
window.parent.someGlobalFunction('来自子应用的数据');

这种通信方式的性能极高,因为它仅仅是内存中的对象引用访问。它非常适合子应用需要获取主应用的共享服务(如全局的日志服务、配置服务、用户认证服务等)的场景。例如,主应用可以将一个统一的 axios 实例或一个全局的事件总线挂载在 window 上,所有子应用都可以直接复用这些实例,避免了重复创建和资源浪费。

1.2.2.3 适用场景:简单数据共享与快速调试

Window 共享机制虽然强大,但其“全局性”也带来了潜在的风险。因此,它最适用于以下场景:

  1. 简单数据共享:当需要在父子应用间共享一些简单的、不经常变化的全局常量或配置时,使用 window 共享是一种高效的选择。例如,共享应用的版本号、环境标识等。
  2. 共享工具函数/服务:主应用可以将一些通用的工具函数、API 请求库、或全局状态管理实例(如一个全局的 EventBus)挂载在 window 上,供所有子应用复用。这有助于减少代码冗余,保持工具库版本的一致性。
  3. 快速调试:在开发和调试阶段,通过 window 对象直接访问和修改父子应用的状态,可以快速定位和解决问题。例如,在浏览器的开发者工具中,可以直接在控制台通过 window.parentiframe.contentWindow 来操作应用,极大地提高了调试效率。
  4. 遗留系统集成:对于一些无法或不便进行大规模改造的遗留系统,如果它们已经依赖了某些全局变量,通过 window 共享机制可以方便地将这些系统集成到微前端架构中,而无需修改其内部代码。

最佳实践与注意事项

  • 命名空间管理:为了避免全局变量名冲突,强烈建议为所有共享在 window 上的变量和方法定义一个统一的、具有辨识度的命名空间,例如 window.MyMicroFrontendGlobal
  • 最小化共享:只共享那些真正需要全局访问的、稳定的数据或服务。避免将业务逻辑相关的、频繁变化的状态放在 window 上。
  • 文档化:清晰地记录哪些变量和方法被共享在 window 上,以及它们的用途和使用方式,这对于团队协作和系统维护至关重要。

总之,Window 共享机制是一把双刃剑。在享受其带来的高性能和便捷性的同时,必须清醒地认识到其潜在的风险,并通过良好的工程实践来规避这些问题,使其成为微前端通信体系中的有力补充。

1.2.3 EventBus 机制

EventBus(事件总线)是无界微前端框架中实现应用间解耦通信的核心机制。它采用发布-订阅(Publish-Subscribe)模式,提供了一个去中心化的通信渠道,使得主应用和各个子应用都可以作为独立的事件发布者或监听者 。通过 EventBus,应用之间不再需要进行直接的函数调用或对象引用,而是通过发送和监听事件来进行协作。这种松耦合的通信方式极大地提升了微前端系统的灵活性和可扩展性,是实现跨应用状态同步、广播通知和构建插件化架构的理想选择。

1.2.3.1 主应用事件监听与触发

无界框架会将一个 EventBus 实例注入到主应用中,主应用可以通过引入该实例来进行事件的监听和触发。例如,在使用 wujie-vue 的主应用中,可以这样操作 :

// 在主应用中 (例如 main.js 或某个组件中)
import WujieVue from 'wujie-vue';
const { bus } = WujieVue;

// 监听一个事件
bus.$on('user-logged-in', (userData) => {
  console.log('主应用收到用户登录事件:', userData);
  // 主应用可以在这里更新全局状态,或者通知其他子应用
});

// 触发一个事件
bus.$emit('global-theme-changed', 'dark');

主应用可以利用 EventBus 来广播全局事件,例如主题切换、语言变更、用户登录/登出等。所有关心这些事件的子应用只需监听相应的事件名,即可在事件发生时做出响应,而无需关心事件是由谁发出的。

1.2.3.2 子应用事件监听与触发

同样地,无界也会将 EventBus 实例注入到每个子应用中。子应用可以通过 window.$wujie.bus 来访问这个实例,并进行事件的监听和触发 :

// 在子应用中
// 监听主应用或其他子应用发出的事件
window.$wujie?.bus.$on('global-theme-changed', (theme) => {
  console.log('子应用收到主题变更事件:', theme);
  this.applyTheme(theme);
});

// 子应用向主应用或其他子应用发送事件
window.$wujie?.bus.$emit('user-logged-in', { userId: 123, username: 'Alice' });

// 在组件销毁时,记得取消事件监听,以避免内存泄漏
// beforeDestroy() {
//   window.$wujie?.bus.$off('global-theme-changed');
// }

通过 EventBus,子应用可以主动将自己的状态变更或发生的事件通知给系统中的其他部分。例如,一个用户管理子应用可以在用户创建成功后,发布一个 user-created 事件,而一个负责发送欢迎邮件的子应用可以监听这个事件,并在收到通知后执行发送邮件的逻辑。这种解耦的协作方式,使得各个微应用可以独立开发和部署,而不会相互影响。

1.2.3.3 适用场景:跨应用状态同步、解耦业务逻辑

EventBus 机制在以下场景中表现出色:

  1. 跨应用状态同步:当多个应用需要共享某个状态(如用户登录状态、全局主题、应用配置等)时,可以通过 EventBus 来广播状态的变更。任何一个应用修改了该状态,都会发布一个相应的事件,其他应用监听该事件并更新自己的本地状态,从而实现状态的最终一致性。
  2. 解耦业务逻辑:在复杂的业务流程中,一个操作可能会触发多个后续动作。使用 EventBus,可以将这些动作的执行者(子应用)与触发者(主应用或其他子应用)解耦。触发者只需发布一个事件,而无需关心后续有哪些动作需要执行,以及由谁来执行。
  3. 构建插件化架构:EventBus 是实现插件化架构的理想工具。主应用可以定义一套标准的事件接口,插件(子应用)可以通过监听这些事件来介入主应用的生命周期或业务流程,也可以通过发布事件来向主应用提供功能。
  4. 广播通知:当需要向所有或部分应用发送通知时(例如,系统即将维护,需要所有用户保存当前工作),可以通过 EventBus 广播一个通知事件,所有在线的应用都能收到并做出相应提示。

最佳实践与注意事项

  • 事件命名规范:为了避免事件名冲突,建议采用带有命名空间的、语义化的事件名,例如 app-name:event-name
  • 事件负载(Payload) :事件传递的数据(负载)应尽量保持简洁,只包含必要的信息。避免传递大型对象或复杂的结构,以减少性能开销。
  • 内存管理:在子应用中,尤其是在组件化的框架(如 Vue、React)中,务必在组件卸载时(如 beforeDestroyuseEffect 的 cleanup 函数中)取消事件监听,以防止内存泄漏。
  • 文档化:维护一份清晰的事件文档,列出所有可用的事件名、其触发时机、以及负载的数据结构,这对于团队协作和系统集成至关重要。

综上所述,EventBus 机制通过其强大的解耦能力,为无界微前端系统提供了一种灵活、可扩展的通信方式,是实现复杂微前端应用不可或缺的核心工具。

1.3 通信模式选型与高级实践

在掌握了无界微前端提供的三种核心通信机制(Props 注入、Window 共享、EventBus)之后,如何根据具体的业务场景进行合理的选型,并遵循最佳实践来构建健壮、高效的通信体系,是微前端架构成功的关键。本章节将对这三种通信方式进行深入的对比分析,并提供关于通信安全性、性能优化以及故障处理的高级实践指南,旨在帮助开发者做出明智的技术决策,并规避潜在的陷阱。

1.3.1 通信方式对比与选择

为了更直观地比较三种通信方式的特性,下表从多个维度进行了总结:

特性维度 Props 注入 Window 共享 EventBus
通信方向 父 -> 子(单向数据流,但可通过回调函数实现反向通知) 父 <-> 子(双向) 多对多(完全解耦)
耦合度 低(父应用明确知道子应用的接口) 高(直接依赖全局变量) 极低(发布-订阅模式)
性能 高(初始化时注入,无运行时开销) 极高(直接内存访问) 中等(事件分发有轻微开销)
数据类型 任意(包括函数) 任意(需可序列化,函数共享需谨慎) 任意(事件负载)
适用场景 初始化数据、配置传递、回调函数注入 简单数据共享、共享工具库、快速调试 跨应用状态同步、解耦业务逻辑、广播通知
安全性 高(接口明确,易于控制) 低(全局命名空间污染风险) 中等(需规范事件命名)
可维护性 高(接口清晰,易于追踪) 低(全局变量难以追踪) 中等(需维护事件文档)

选型建议

  • 优先使用 Props 注入:对于父应用向子应用传递初始化数据、配置或回调函数的场景,应首选 Props 注入。它提供了清晰的接口定义,易于测试和维护,是实现父子通信最规范、最安全的方式。
  • 谨慎使用 Window 共享:Window 共享的性能最高,但风险也最大。它应仅用于共享那些真正全局的、稳定不变的数据或服务,如全局配置、工具库实例等。在使用时,必须通过严格的命名空间管理来避免冲突。
  • 广泛使用 EventBus:对于应用间的解耦通信、状态同步和广播通知,EventBus 是最佳选择。它使得应用间的协作变得异常灵活,是构建大型、可扩展微前端系统的核心。但需要注意事件的命名规范和内存管理问题。

在实际项目中,这三种通信方式往往是结合使用的。例如,主应用在加载子应用时,通过 Props 注入一个全局的 EventBus 实例和一个共享的 axios 实例(通过 Window 共享),子应用则主要使用 EventBus 与其他应用进行交互。

1.3.2 通信安全性与性能优化

通信安全性

  • 数据校验:无论通过何种方式接收数据,子应用都应进行必要的数据校验,确保接收到的数据格式和类型符合预期,防止因恶意数据或数据格式错误导致的应用崩溃。
  • 最小权限原则:主应用传递给子应用的数据和方法应遵循最小权限原则,只提供子应用所必需的最小数据集和功能,避免暴露敏感信息或不必要的内部实现。
  • 避免直接执行字符串代码:绝对不要通过 Window 共享或 EventBus 传递并执行字符串形式的代码,这会引发严重的安全漏洞(如 XSS 攻击)。
  • HTTPS:确保主应用和所有子应用都通过 HTTPS 提供服务,以防止通信数据在传输过程中被窃听或篡改。

性能优化

  • Props 优化:避免在 props 中传递大型对象或频繁变化的数据。如果必须传递,可以考虑使用 computed 属性或 watch 来优化更新逻辑。
  • Window 共享优化:共享在 window 上的对象应尽量是单例或不可变对象,避免频繁修改。对于大型工具库,可以考虑使用动态导入(import())按需加载,而不是全部挂载在 window 上。
  • EventBus 优化
    • 事件节流/防抖:对于高频触发的事件(如窗口滚动、鼠标移动),应在发布端进行节流(throttle)或防抖(debounce)处理,减少事件分发的次数。
    • 事件负载精简:事件负载应尽量小,只包含必要的数据。
    • 避免内存泄漏:如前所述,务必在组件卸载时取消事件监听。
  • 预加载:无界框架支持子应用的预加载 。对于用户可能会访问到的子应用,可以在主应用空闲时提前加载其静态资源,从而缩短用户首次打开子应用的时间,提升用户体验。

1.3.3 通信故障处理与恢复策略

在复杂的微前端系统中,通信故障是不可避免的。例如,子应用加载失败、网络中断、或者应用崩溃等情况都可能导致通信中断。因此,建立一套完善的故障处理与恢复机制至关重要。

  • 子应用加载失败:主应用在加载子应用时,应提供 onError 或类似的错误处理钩子。当子应用加载失败时,可以捕获错误并向用户展示友好的错误提示,或者提供一个降级方案(如跳转到备用页面)。
  • 通信超时:对于通过 EventBus 触发的异步操作,应设置超时机制。如果在规定时间内没有收到响应,应视为通信失败,并进行相应的错误处理。
  • 状态一致性保证:当通信中断或应用重启后,可能会出现状态不一致的问题。为了解决这个问题,可以采用以下策略:
    • 状态持久化:将关键的全局状态(如用户登录信息)持久化到 localStoragesessionStorage 中。应用启动时,先从持久化存储中恢复状态。
    • 状态同步机制:在应用重新连接或加载后,主动进行一次状态同步。例如,主应用可以向所有子应用广播一个 request-state-sync 事件,子应用在收到事件后,将自己的关键状态通过 EventBus 发送回来,主应用据此更新全局状态。
    • 幂等性设计:确保通过 EventBus 触发的操作是幂等的,即多次执行同一操作,结果与执行一次相同。这可以避免因网络重试或重复事件导致的状态错误。
  • 日志与监控:建立完善的日志和监控体系,记录所有关键的通信事件和错误。当通信故障发生时,可以通过日志快速定位问题根源。同时,通过监控告警,可以在故障发生时第一时间通知开发人员。

通过以上高级实践,开发者可以构建一个不仅功能强大,而且安全、高效、健壮的微前端通信系统,为整个应用的稳定运行提供坚实的保障。

2. 路由管理:打造无缝的导航体验

在微前端架构中,路由管理是构建用户无缝导航体验的核心挑战之一。传统的单页应用(SPA)路由管理相对简单,但在微前端场景下,主应用和多个子应用各自拥有独立的路由系统,如何协调它们之间的关系,确保路由状态的正确同步、浏览器前进后退按钮的正常工作,以及在不同应用间实现流畅跳转,成为了一个复杂的技术难题。无界(Wujie)微前端框架通过其创新的路由同步机制,巧妙地解决了这些问题,为开发者提供了一套强大而简洁的路由管理方案 。

2.1 无界路由同步机制解析

无界框架的路由管理核心在于其独特的路由同步机制。该机制旨在解决微前端架构中一个普遍存在的痛点:子应用的路由状态在浏览器刷新、前进后退或分享链接时容易丢失的问题。通过将子应用的路由信息巧妙地与主应用的 URL 进行绑定,无界确保了子应用的路由状态可以被浏览器历史记录正确地保存和恢复,从而为用户提供了与单页应用无异的导航体验。

image.png

2.1.1 子应用路由状态丢失问题

在传统的 iframe 微前端方案中,子应用的路由系统完全运行在 iframe 内部,与主应用的路由是相互隔离的。这会导致一系列问题:

  • 浏览器刷新:当用户在子应用的某个页面(例如 /sub-app/product/123)刷新浏览器时,浏览器只会加载主应用的 URL(例如 https://main-app.com/sub-app),而 iframesrc 属性通常会恢复到初始值(例如 https://sub-app.com),导致用户丢失当前的页面状态,被重定向到子应用的首页。
  • 前进后退:浏览器的前进后退按钮操作的是浏览器顶层的历史记录(window.history),而子应用内部的路由变化(如 history.pushState)只会修改 iframe 内部的历史记录。因此,当用户点击前进后退按钮时,无法正确地导航到子应用的历史页面。
  • 链接分享:当用户试图分享一个在子应用内部的页面链接时,分享的 URL 只是主应用的 URL,接收者打开链接后无法看到分享者当时所在的子应用页面。

这些问题严重破坏了用户体验,使得基于 iframe 的微前端方案在实际应用中备受诟病。

2.1.2 无界路由同步原理

无界框架通过劫持 iframe 内部的 history.pushStatehistory.replaceState 方法,巧妙地解决了上述问题 。其核心原理如下:

  1. 劫持路由变化:当子应用内部进行路由跳转,调用 history.pushStatehistory.replaceState 时,无界框架的劫持逻辑会被触发。
  2. 同步到主应用 URL:劫持逻辑会将子应用当前的 location.pathnamelocation.search(即路由路径和查询参数)进行 URL encoding(编码),然后将其作为查询参数附加到主应用的 URL 上。例如,如果子应用的路由变为 /product/123?color=red,主应用的 URL 可能会被更新为 https://main-app.com/sub-app?sub-app-name=%2Fproduct%2F123%3Fcolor%3Dred。其中 sub-app-name 是子应用的唯一标识。
  3. 浏览器历史记录:由于主应用的 URL 发生了变化,这次变化会被浏览器记录到顶层的历史记录中。因此,浏览器的前进后退按钮现在可以正确地作用于子应用的路由历史。
  4. 状态恢复:当用户刷新浏览器或从外部访问带有同步路由信息的 URL 时,无界框架会在初始化子应用 iframe 时,从主应用 URL 的查询参数中解析出子应用的路由信息,然后使用 iframehistory.replaceState 将子应用的路由恢复到之前的状态。

通过这套机制,无界实现了子应用路由状态与浏览器历史记录的完全同步,解决了刷新、前进后退和链接分享等核心痛点 。此外,无界还支持多应用同时激活时的路由同步,并且提供了短路径配置能力,以应对子应用 URL 过长的问题 。

2.2 父子应用路由协同策略

在理解了无界的路由同步原理后,下一步是如何在实践中协同管理父子应用的路由。一个清晰的路由协同策略是确保整个微前端系统导航逻辑正确、用户体验流畅的关键。这通常涉及到主应用的统一路由管理、子应用的内部路由自治,以及两者之间的联动与跳转机制。

2.2.1 主应用统一路由管理

在微前端架构中,主应用通常扮演着“路由网关”的角色。它负责管理整个系统的顶层路由,并根据路由规则来决定加载哪个子应用。主应用的路由配置通常会定义一个通配符或参数化的路径,用于匹配所有子应用的路由。例如,在 Vue Router 中,可以这样配置:

// 主应用的路由配置
const routes = [
  {
    path: '/',
    component: MainLayout,
    children: [
      // 其他主应用自身的路由...
      {
        // 匹配所有以 /sub-app 开头的路径
        path: '/sub-app/:pathMatch(.*)*',
        name: 'SubAppContainer',
        component: () => import('./views/SubAppContainer.vue'),
      },
    ],
  },
];

SubAppContainer.vue 组件中,会根据当前的路由信息(如 params.path)来动态加载对应的子应用。主应用还负责将路由跳转函数(如 this.$router.push)通过 props 传递给子应用,以便子应用能够发起跨应用的导航 。

2.2.2 子应用内部路由自治

子应用在微前端架构中应保持其内部路由的完整性和自治性。这意味着子应用应该像独立开发时一样,使用自己的路由库(如 Vue Router, React Router)来管理其内部的页面跳转和状态。无界框架的设计保证了子应用的路由系统可以无侵入地、完整地运行在 iframe 沙箱中,无需任何特殊改造 。子应用内部的 <router-link> 或编程式导航(router.push)都可以正常工作,并且其路由变化会被无界的路由同步机制捕获并同步到主应用。

2.2.3 父子路由联动与跳转

实现父子应用间的路由联动和跳转是路由协同的核心。最常见的场景是:用户在子应用 A 的某个页面,希望跳转到子应用 B 的某个特定页面。实现这种跳转通常有以下几种方式:

  1. 主应用提供跳转方法:主应用将一个统一的跳转方法(如 jump)通过 props 注入到所有子应用中。当子应用需要跨应用跳转时,调用这个注入的方法,并将目标路由信息作为参数传递 。

    // 主应用注入跳转方法
    <WujieVue 
      name="sub-app-a" 
      url="http://localhost:8080" 
      :props="{ jump: this.handleCrossAppJump }" 
    />
    
    // 主应用中的跳转方法
    methods: {
      handleCrossAppJump(location) {
        // location 可以是 { path: '/sub-app-b/product/123' }
        this.$router.push(location);
      }
    }
    
    // 子应用 A 中调用跳转
    methods: {
      goToProduct() {
        window.$wujie?.props.jump({ path: '/sub-app-b/product/123' });
      }
    }
    
  2. 使用 EventBus 进行通信:当子应用需要跳转到另一个子应用时,可以发布一个特定的事件(如 navigate-to),并将目标路由信息作为事件负载。主应用或其他负责路由管理的模块监听这个事件,并执行实际的跳转逻辑 。这种方式的解耦程度更高。

    // 子应用 A 发布跳转事件
    window.$wujie?.bus.$emit('navigate-to', { path: '/sub-app-b/product/123' });
    
    // 主应用监听跳转事件并执行跳转
    bus.$on('navigate-to', (location) => {
      this.$router.push(location.path);
    });
    
  3. 直接操作主应用路由:在子应用中,也可以通过 window.parent 直接访问主应用的路由实例进行跳转,例如 window.parent.$router.push('/sub-app-b/product/123')。但这种方式耦合度较高,不推荐在复杂场景下使用。

通过上述策略,可以构建一个既统一又灵活的微前端路由管理体系,为用户提供无缝、连贯的导航体验。

2.3 高级路由场景实践

在掌握了基本的路由协同策略后,我们还需要应对一些更复杂的高级路由场景,例如子应用保活模式下的路由处理、多应用同时激活时的路由同步,以及路由嵌套与冲突的解决。这些场景对路由管理的灵活性和健壮性提出了更高的要求。

2.3.1 子应用保活模式下的路由处理

无界框架提供了强大的子应用保活(alive: true)模式,类似于 Vue 的 keep-alive 。在保活模式下,当用户从子应用 A 切换到子应用 B 时,子应用 A 的 iframe 和 DOM 结构会被保留在内存中,其内部的状态(包括路由状态)不会丢失。当用户再次切换回子应用 A 时,可以瞬间恢复,无需重新加载和渲染。

然而,保活模式也带来了新的路由挑战。由于子应用的路由状态被保留,如果主应用的路由发生变化(例如,用户通过浏览器地址栏直接修改了 URL),子应用的路由不会自动同步更新。为了解决这个问题,必须采用通信机制来显式地通知子应用进行路由跳转 。

最佳实践

  1. 主应用监听路由变化:主应用需要监听自身的路由变化。
  2. 通过 EventBus 通知子应用:当主应用的路由变化涉及到某个保活的子应用时,主应用应通过 EventBus 向该子应用发送一个路由变更事件,并将新的路由路径作为事件负载。
  3. 子应用接收并跳转:保活的子应用监听这个事件,并在收到通知后,使用自己的路由系统(如 router.push)跳转到对应的路径。
// 主应用监听路由变化
watch: {
  '$route.params.path': {
    handler(newPath) {
      // 假设当前激活的是保活的子应用 'sub-app-a'
      wujieVue.bus.$emit('sub-app-a-route-change', `/${newPath}`);
    },
    immediate: true,
  },
},

// 子应用 'sub-app-a' 监听并跳转
mounted() {
  window.$wujie?.bus.$on('sub-app-a-route-change', (path) => {
    if (this.$router.currentRoute.path !== path) {
      this.$router.push(path);
    }
  });
}

这种方式确保了即使在保活模式下,父子应用的路由也能保持同步。

2.3.2 多应用激活时的路由同步

无界框架支持在一个页面中同时激活多个子应用,并且能保持这些子应用的路由同步 。这在一些复杂的仪表板或门户页面中非常有用。实现这一功能的关键在于无界的路由同步机制本身就支持多应用。当页面上存在多个子应用时,每个子应用的路由变化都会被编码并附加到主应用的 URL 上,使用不同的 key(即子应用的 name)进行区分。

例如,主应用的 URL 可能看起来像这样: https://main-app.com/dashboard?app-a=%2Fchart%2Fpie&app-b=%2Ftable%2Fuser-list

在这个 URL 中,app-aapp-b 分别代表两个子应用的路由状态。当用户刷新页面时,无界框架会解析这个 URL,并同时恢复两个子应用的路由。开发者无需进行额外的编码,即可享受到多应用路由同步带来的便利。

2.3.3 路由嵌套与冲突解决

在微前端系统中,路由嵌套和冲突是常见的问题。例如,主应用有一个路径为 /user 的路由,而某个子应用内部也有一个 /user 的路由。当用户访问 /user 时,系统应该加载主应用的页面还是子应用的页面?

解决策略

  1. 主应用路由优先:通常,主应用的路由应该具有更高的优先级。主应用的路由配置应该放在子应用通配符路由之前。这样,当 URL 同时匹配主应用和子应用的路由时,会优先匹配主应用的路由。
  2. 明确的路由前缀:为每个子应用分配一个唯一的、不会与主应用或其他子应用冲突的路由前缀。例如,所有与用户管理相关的子应用功能都放在 /user-mgt/ 路径下,而主应用的用户中心则使用 /user-center/。这是一种通过设计来避免冲突的有效方法。
  3. 动态路由匹配:在主应用的路由守卫(beforeEach)中,可以进行更复杂的逻辑判断。例如,根据用户的权限或当前的系统状态,动态地决定将某个路径路由到主应用还是子应用。

通过合理的路由设计和配置,可以有效地解决路由嵌套和冲突问题,确保整个微前端系统的路由逻辑清晰、稳定。

3. 状态管理:实现跨应用的状态共享与隔离

在微前端架构中,状态管理是一个核心且复杂的议题。与单体应用不同,微前端系统由多个独立开发、部署和运行的微应用(包括主应用和多个子应用)组成。每个微应用理论上都应该拥有自己的、与其他应用隔离的运行时状态,以保证其独立性和可维护性 。然而,在实际业务中,总有一些状态是需要在多个应用之间共享的,例如当前登录用户的信息、全局的主题设置、应用级别的权限控制等。如何在保证应用间状态隔离的同时,优雅地实现必要的状态共享,是无界微前端框架在状态管理方面需要解决的关键问题。

image.png

3.1 无界状态管理哲学

无界微前端框架在状态管理上遵循一套清晰的设计哲学,这套哲学旨在平衡微应用的独立性与系统整体的协同性。它强调“独立运行时”和“状态隔离”作为基本原则,同时通过灵活的通信机制来满足“状态共享”的实际需求。

3.1.1 独立运行时与状态隔离

无界框架的核心理念之一是“独立运行时”,即每个微应用在运行时都应该是相互隔离的,拥有自己独立的 JS 执行环境(通过 iframe 沙箱实现)和独立的运行时状态 。这意味着,一个子应用的状态变更,默认情况下不应该直接影响到其他应用。这种设计带来了诸多好处:

  • 技术栈无关:每个子应用可以自由选择自己的技术栈(如 Vue, React, Angular)和状态管理库(如 Vuex, Redux, MobX),而无需考虑与其他应用的兼容性问题。
  • 独立开发与部署:由于状态是隔离的,各个团队可以独立地开发、测试和部署自己的微应用,而不会相互干扰,大大提高了开发效率和迭代速度。
  • 故障隔离:一个子应用的状态管理出现 bug 或崩溃,其影响范围被限制在该应用内部,不会扩散到整个系统,从而提高了整个微前端系统的健壮性和稳定性。

这种“状态隔离”的哲学,从根本上避免了微应用之间因状态耦合而可能引发的“牵一发而动全身”的混乱局面,是实现真正微服务化前端的关键。

3.1.2 状态共享的必要性与挑战

尽管状态隔离是基本原则,但在一个完整的业务系统中,完全的状态隔离是不现实的。总有一些“全局状态”或“跨应用状态”需要在多个微应用之间共享和同步。例如:

  • 用户认证状态:用户的登录信息、token、权限角色等,是几乎所有应用都需要访问的。
  • 全局配置:如 UI 主题(亮色/暗色)、语言偏好、应用布局设置等。
  • 跨应用业务流程:一个业务流程可能跨越多个子应用,需要共享一些流程相关的中间状态。

实现这些状态的共享面临着诸多挑战:

  • 耦合度:如何在共享状态的同时,尽可能地降低应用间的耦合度?
  • 数据一致性:如何保证共享状态在多个应用中的副本能够保持一致?
  • 性能:状态同步机制是否会引入额外的性能开销?
  • 调试复杂性:当共享状态出现问题时,如何快速定位和调试?

无界框架通过其灵活的通信机制(Props、Window、EventBus)来应对这些挑战,提供了一套行之有效的跨应用状态共享方案。

3.2 跨应用状态共享方案

基于无界框架提供的通信机制,开发者可以构建多种跨应用状态共享方案。这些方案各有侧重,适用于不同的场景,共同构成了无界微前端的状态管理体系。

3.2.1 基于 EventBus 的状态同步

这是无界微前端中最常用、最推荐的跨应用状态共享方案。它利用 EventBus 的发布-订阅机制,实现了状态的解耦同步。其核心思想是:将状态变更视为一种“事件”,当某个应用(状态所有者)修改了共享状态时,它会发布一个相应的事件,并将新的状态值作为事件负载。其他需要关心这个状态的应用(状态消费者)则监听这个事件,并在收到通知后,更新自己的本地状态副本 。

实现步骤

  1. 定义状态契约:首先,需要为每个共享状态定义一个清晰的事件名和数据结构(即“状态契约”)。例如,用户登录状态的事件名可以定义为 global:user:login,其负载为 { userId: number, username: string, token: string }
  2. 状态所有者发布事件:当状态发生变化时(例如,用户登录成功),负责用户认证的应用(可能是主应用或一个专门的认证子应用)会通过 EventBus 发布事件。
    // 在用户认证成功后
    window.$wujie?.bus.$emit('global:user:login', { 
      userId: 123, 
      username: 'Alice', 
      token: 'abc123...' 
    });
    
  3. 状态消费者监听事件:所有需要访问用户信息的子应用,在初始化时都会监听这个事件。
    // 在子应用的初始化逻辑中
    window.$wujie?.bus.$on('global:user:login', (userData) => {
      // 将接收到的用户数据存储到子应用自己的状态管理库中
      this.$store.commit('setUser', userData);
    });
    

这种方式的优点是解耦、灵活且易于扩展。新增一个需要共享状态的应用,只需让它监听相应的事件即可,无需修改其他应用的代码。

3.2.2 构建统一的全局状态仓库

对于状态共享需求非常复杂的系统,可以考虑在主应用中构建一个统一的全局状态仓库(Global State Store)。这个仓库负责管理所有需要跨应用共享的状态,并提供统一的接口(如 getState, setState)供其他应用访问。

实现方式

  1. 主应用创建仓库:主应用创建一个全局的状态管理对象,并将其挂载在 window 上,或者通过 props 注入到所有子应用中。
    // 在主应用中
    window.GlobalState = {
      state: {
        user: null,
        theme: 'light',
      },
      setState(key, value) {
        this.state[key] = value;
        // 状态变更后,通过 EventBus 通知所有子应用
        bus.$emit(`global:state:change:${key}`, value);
      },
      getState(key) {
        return this.state[key];
      }
    };
    
  2. 子应用访问仓库:子应用可以通过 window.GlobalState 直接读取状态,或通过调用 setState 方法来修改状态。状态的变更同样通过 EventBus 广播出去,以保证所有应用的状态副本同步更新。

这种方式的优点是状态管理集中化,便于统一控制和调试。但缺点是会增加主应用的复杂性,并且如果设计不当,可能会成为系统的性能瓶颈。

3.2.3 状态流转与同步机制

无论采用哪种共享方案,都需要关注状态的流转与同步机制。

  • 单向数据流:推荐采用类似 Flux 的单向数据流模式。状态变更只能由“状态所有者”发起,其他应用只能被动接收通知并更新本地副本,不能直接修改共享状态。这有助于保证状态变更的可预测性和可追踪性。
  • 状态版本控制:对于复杂的共享状态,可以引入版本号或时间戳。当状态变更时,版本号递增。状态消费者在更新本地副本时,可以检查版本号,以避免处理过期的状态更新。
  • 状态持久化:对于需要持久化的共享状态(如用户偏好设置),可以在状态变更时,将其同步到 localStorage 或发送到后端服务器。应用启动时,再从持久化存储中恢复状态。

3.3 状态管理最佳实践

为了构建一个健壮、可维护的跨应用状态管理体系,开发者应遵循以下最佳实践:

3.3.1 状态契约与接口定义

  • 文档化:为所有共享状态及其对应的事件创建一份详细的文档,明确事件名、负载的数据结构、触发时机和使用场景。
  • 类型安全:如果使用 TypeScript,应为共享状态定义清晰的接口(Interface)或类型(Type),并在发布和监听事件时使用这些类型,以获得编译时的类型检查和代码提示。
  • 命名规范:采用统一的、带有命名空间的事件命名规范,如 domain:entity:action(例如 user:profile:update),以避免命名冲突。

3.3.2 状态变更的追踪与调试

  • 日志记录:在状态所有者发布事件和状态消费者接收事件的地方,添加详细的日志记录,包括事件名、负载数据和发生时间。这有助于在出现问题时进行追踪和调试。
  • 开发工具:可以利用一些状态管理开发工具(如 Redux DevTools)的插件,或者自行开发一个简单的调试面板,来可视化地展示全局状态的变化历史和当前快照。
  • 错误处理:在状态同步的逻辑中,添加完善的错误处理机制。例如,当接收到格式错误的状态数据时,应记录错误日志,并使用一个默认值或回退逻辑,避免应用崩溃。

3.3.3 状态持久化与恢复

  • 选择性持久化:并非所有状态都需要持久化。只对那些用户希望跨会话保留的状态(如登录信息、主题偏好)进行持久化。
  • 序列化与反序列化:在将状态存入 localStorage 或从其中读取时,需要进行正确的序列化和反序列化。对于复杂对象,可以使用 JSON.stringifyJSON.parse
  • 版本控制:当共享状态的数据结构发生变更时(例如,新增了一个字段),需要考虑版本兼容性问题。可以在持久化的数据中包含一个版本号,在恢复状态时,根据版本号进行相应的数据迁移或转换。

通过遵循这些最佳实践,开发者可以在无界微前端框架下,构建一个既灵活又健壮的跨应用状态管理系统,为复杂业务场景的实现提供坚实的支撑。

image.png

如何自己构建一个Markdown增量渲染器

2025年11月28日 10:47

揭秘 Vue3 增量 Markdown 解析组件的神奇魔法

先上效果 演示demo

背景

相信很多大模型前端开发的小伙伴都已经处理过markdown实时解析翻译成html了,传统的方式类似使用Marked、markdown-it等组件全量渲染。但是全量渲染及其消耗性能,会造成大量的重排、重绘,导致页面抖动。

各位前端小伙伴们,今天我要给大家分享一个我最近开发的「Vue3 增量 Markdown 解析组件」。这个组件就像是一个「超级翻译官」,能把枯燥的 Markdown 文本瞬间变成生动的 HTML 页面,而且还支持数学公式、代码高亮等高级功能!废话不多说,让我们一起深入这个组件的「内部世界」吧!

开箱即用模式

# 安装命令
npm install v3-markdown-stream
# 或
yarn add v3-markdown-stream

组件使用示例

<template>
  <div>
    <MarkdownRender :markInfo="markdownContent" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { MarkdownRender } from 'v3-markdown-stream';
import 'v3-markdown-stream/dist/v3-markdown-stream.css';

// 静态内容
const markdownContent = ref('# Hello World\n\nThis is a simple markdown example.')
</script>

组件概览

首先,让我们来看看这个组件的「身份证」(都是站在各位巨人的肩膀上

import { h, defineComponent, computed } from "vue";
import { Fragment, jsxs, jsx } from "vue/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import remarkParse from "remark-parse";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import 'highlight.js/styles/github-dark.css';
import remarkMath from "remark-math";
import remarkRehype from "remark-rehype";
import rehypeRaw from 'rehype-raw';
import rehypeHighlight from 'rehype-highlight'
import remarkFlexibleContainers from 'remark-flexible-containers'
import remarkGfm from "remark-gfm";
import { VFile } from "vfile";
import { unified } from "unified";

// 定义组件
export default defineComponent({
    name: 'VueMarkdownStreamRender',
    props: {
        markstr: {
            type: String,
            required: true,
            default: ''
        }
    },
    // 其他实现...
})

这个组件就像是一个「瑞士军刀」,集成了多种功能,让 Markdown 解析变得异常强大!

核心功能包解析 - 武林高手们的各司其职

1. Vue 核心团队

  • vue : 提供 h , defineComponent , computed 等核心 API,是整个组件的「骨架」
  • vue/jsx-runtime : 提供 Fragment , jsxs , jsx ,让我们可以在 Vue 中优雅地使用 JSX 语法,相当于给 Vue 「装上了 React 的小翅膀」

2. Unified 解析系统 - 解析界的「中央司令部」

  • unified : 这是整个解析系统的「大脑」,负责协调各个插件的工作。想象一下,它就像是一个「指挥官」,指挥着一群「小兵」(插件)协同作战
  • vfile : 提供文件处理功能,把 Markdown 字符串转换成统一的文件格式,相当于给文本「穿上了标准化的衣服」

3. Remark 家族 - Markdown 的「魔法师」

  • remark-parse : 将 Markdown 文本解析成抽象语法树(AST),就像是「翻译官」把中文翻译成一种中间语言
  • remark-math : 处理数学公式,让你的文档可以「高大上」地展示复杂数学表达式
  • remark-rehype : 将 Markdown AST 转换成 HTML AST,相当于「转换器」把中间语言翻译成另一种中间语言
  • remark-gfm : 支持 GitHub 风格的 Markdown 扩展功能,比如表格、任务列表等,让你的 Markdown 「与时俱进」
  • remark-flexible-containers : 提供灵活的容器功能,让你的内容布局更加多样化,就像是给内容「准备了各种形状的容器」

4. Rehype 家族 - HTML 的「美容师」

  • rehype-raw : 保留原始 HTML,让你的 Markdown 中混合的 HTML 代码也能正常工作,相当于「允许特殊人才保留自己的特色」
  • rehype-katex : 将数学公式渲染成漂亮的 HTML,让数学表达式「穿上漂亮的衣服」
  • rehype-highlight : 为代码块提供语法高亮,让你的代码「光彩照人」

5. 样式支持 - 颜值担当

  • katex.min.css : 数学公式的「时尚服饰」
  • github-dark.css : 代码高亮的「炫酷皮肤」

实现原理大揭秘 - 从文本到页面的神奇旅程

1. 组件结构设计

组件使用 Vue3 的 defineComponent 定义,接收一个必须的 markstr 属性,这是要解析的 Markdown 字符串。整个组件的设计非常简洁,就像一个「专注的翻译官」,只做一件事,但要做到极致!

2. 解析器链的构建

let unifiedProcessor = computed(() => {
    const processor = unified()
        .use(remarkParse, { allowDangerousHtml: true})
        .use(remarkFlexibleContainers)
        .use(remarkRehype, { allowDangerousHtml: true})
        .use(rehypeRaw)
        .use(remarkGfm)
        .use(rehypeKatex)
        .use(remarkMath)
        .use(rehypeHighlight);

    return processor;
});

这部分代码构建了一个「解析流水线」,就像工厂里的生产线一样,Markdown 文本会依次经过各个「加工环节」。这里使用 computed 确保解析器只在必要时重新创建,提高了性能。

3. 文件转换与 AST 处理

const createFile = (markstr) => {
    const file = new VFile();
    file.value = markstr;
    return file;
};

const generateVueNode = (tree) => {
    const vueVnode = toJsxRuntime(tree, {
        Fragment,
        jsx: jsx,
        jsxs: jsxs,
        passNode: true,
    });
    return vueVnode;
};

这两个函数分别负责:

  • createFile : 将 Markdown 字符串包装成 VFile 对象,就像是给文本「准备好行李,准备出发」
  • generateVueNode : 将解析后的 AST 树转换成 Vue 的虚拟 DOM 节点,相当于「将中间语言翻译成最终的目标语言」

4. 响应式渲染

const computedVNode = computed(() => {
    const processor = unifiedProcessor.value;
    const file = createFile(props.markstr);
    let result = generateVueNode(processor.runSync(processor.parse(file), file));
    return result;
});

return () => {
    return h(computedVNode.value);
};

这里是整个组件的「核心驱动」:

  • 使用 computed 响应式地计算虚拟 DOM,当 markstr 变化时,会自动重新解析并渲染
  • processor.parse(file) 将文件解析成 AST
  • processor.runSync(...) 运行所有插件处理 AST
  • 最后通过 h() 函数将生成的虚拟 DOM 渲染到页面上

技术亮点与设计精髓

  1. 响应式设计 : 利用 Vue3 的 computed ,实现了 Markdown 字符串变化时的自动重新解析和渲染
  2. 模块化插件链 : 采用统一的插件系统,各功能模块解耦,可以灵活地添加或移除功能
  3. 高性能优化 : 通过 computed 缓存解析器和虚拟 DOM,避免不必要的重复计算
  4. 丰富的功能支持 : 支持数学公式、代码高亮、GitHub 风格扩展等高级功能
  5. 错误处理机制 : 提供了 errorCaptured 钩子,捕获并记录解析过程中的错误

代码优化建议

虽然这个组件已经相当优秀,但还有一些小地方可以进一步完善:

  1. 插件顺序优化 : 目前的插件顺序可能不是最优的,建议调整为更合理的顺序:
const processor = unified()
    .use(remarkParse, { allowDangerousHtml: true})
    .use(remarkGfm) // GFM 应该在 early 阶段
    .use(remarkMath) // 数学支持也应该 early
    .use(remarkFlexibleContainers)
    .use(remarkRehype, { allowDangerousHtml: true})
    .use(rehypeRaw)
    .use(rehypeHighlight) // 代码高亮应该在 katex 之前
    .use(rehypeKatex); // 数学渲染作为最后一步
  1. 异步解析支持 : 考虑添加异步解析模式,对于大型文档可以提高性能和用户体验
  2. 缓存机制 : 可以添加基于内容哈希的缓存,避免相同内容的重复解析
  3. 错误边界 : 增强错误处理,提供更友好的错误提示给用户

总结

这个 Vue3 Markdown 解析组件就像是一个「智能翻译官 + 高级排版师」,它不仅能准确地将 Markdown 转换成 HTML,还能让最终的展示效果既美观又功能丰富。通过巧妙地组合各种开源工具,它实现了一个功能完备、性能优良的 Markdown 解析渲染系统。

无论是构建博客、文档系统还是知识库,这个组件都能为你的项目增添强大的内容展示能力。希望这篇文章能帮助你理解这个组件的实现原理,也欢迎大家提出宝贵的改进建议!

最后,如果你觉得这个组件对你有帮助,不妨点个赞并分享给更多的开发者朋友,让我们一起让 Markdown 解析变得更简单、更强大!

GitHub源码仓库地址 如果觉得好用,欢迎给个Star ⭐️ 支持一下!

JavaScript 词法作用域、作用域链与闭包:从代码看机制

作者 ohyeah
2025年11月28日 10:35

在学习 JavaScript 的过程中,作用域 是一个绕不开的核心概念。很多人一开始会误以为“变量在哪调用,就在哪找”,但其实 JS 的作用域是 词法作用域(Lexical Scoping) ,也就是说,函数的作用域由它定义的位置决定,而不是调用位置。今天我们就通过几段简单的代码和图解,来深入浅出地理解 JavaScript 中的 词法作用域、作用域链 和 闭包 这三个重要机制。


一、什么是执行上下文?调用栈是如何工作的?

在 JavaScript 中,每当一个函数被调用时,都会创建一个「执行上下文」(Execution Context),并压入调用栈(Call Stack)。这个执行上下文包含两个关键部分:

  • 变量环境(Variable Environment) :存储用 var 声明的变量和函数声明。
  • 词法环境(Lexical Environment) :存储用 letconst 声明的块级作用域变量。

此外,每个执行上下文的词法环境中还有一个特殊的属性:outer,它指向该函数定义时所在的作用域的词法环境。

✅ 简单说:outer 指针决定了作用域查找路径,即“作用域链”

我们来看第一个例子(1.js):

function bar(){
  console.log(myName)
}

function foo(){
  var myName = '极客邦'
  bar()
}

var myName = '极客时间'
foo() // 输出: 极客时间

🤔 为什么输出的是 “极客时间” 而不是 “极客邦”?

很多人会误以为:bar() 是在 foo() 内部调用的,那它应该能访问到 foo() 里的 myName。但实际上,bar() 是在全局定义的,所以它的 outer 指向的是全局的词法环境。

bar() 执行时,它先在自己的词法环境中找 myName,没有;然后顺着 outer 指针去全局词法环境查找,找到了 var myName = '极客时间',于是打印出来。

👉 结论:作用域是由函数定义的位置决定的,而不是调用位置。这就是 词法作用域 的核心思想。


二、作用域链:查找变量的“路径”

作用域链就是由一个个执行上下文的 outer 指针串联而成的链条。我们可以通过以下代码进一步理解(2.js):

function bar(){
  var myName = '极客世界'
  let test1 = 100
  if(1){
    let myName = 'Chrome 浏览器'
    console.log(test) // ❌ 报错:test is not defined
  }
}

function foo(){
  var myName = '极客邦'
  let test = 2
  {
    let test = 3
    bar()
  }
}

var myName = '极客时间'

let myAge = 10

let test = 1

foo()

这段代码中,bar() 函数内部试图打印 test,但它找不到。

🔍 查找过程如下:

  1. bar() 的词法环境中找 test → 没有;
  2. bar()outer 指向的词法环境(全局)找 → 全局有 let test = 1,但注意!bar() 是在全局定义的,所以它只能访问全局的 test
  3. 但是 bar() 执行时,test 被重新赋值了吗?没有,因为 bar() 并不在 foo() 内部定义,所以它不会继承 foo() 的作用域。

因此,console.log(test) 实际上是在全局查找 test,结果是 1

⚠️ 注意:bar() 无法访问 foo() 中的 test,即使它是从 foo() 中调用的。这再次证明了:JS 是词法作用域,不是动态作用域

我们可以结合下面这张图来理解调用栈和作用域链的关系:

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png

  • bar() 的执行上下文在栈顶;
  • 它的 outer 指向全局;
  • 因此查找 test 时,直接跳到了全局词法环境。

三、闭包:函数的“专属背包”

接下来是最有意思的——闭包(Closure)。

闭包的本质是:一个函数能够访问并记住其外部函数的变量,即使外部函数已经执行完毕

我们来看第三个例子(3.js):

function foo(){
  var myName = '极客时间'
  let test1 = 1
  const test2 = 2
  var innerBar = {
    getName: function(){
      console.log(test1)
      return myName
    },
    setName: function(newName){
      myName = newName
    }
  }
  return innerBar
}

var bar = foo() // foo 执行完毕,出栈
//它已经出栈了 那bar里面的变量应该回收吧?
//代码的执行证明 它不会回收
//foo函数确实是出栈了 但是getName/setName还需要foo()函数里面的变量 所以它会'打个包' (如果一个变量被引用的话 那么它们就不能顺利的进行垃圾回收)
bar.setName('极客邦')
bar.getName() // 输出: 极客邦

🤯 为什么 foo() 已经出栈了,还能修改和读取里面的变量?

因为在 foo() 返回 innerBar 对象时,getNamesetName 这两个方法都引用了 foo() 内部的变量 myNametest1。V8 引擎发现这些变量被“外部引用”了,就不会回收它们。

于是,foo() 的执行上下文虽然出栈了,但它的 词法环境被保留了下来,形成了一个“闭包”。

💡 这个被保留下来的词法环境,就是闭包本身。而其中被引用的变量,叫做 自由变量

我们再看一张图:

536a315a83aa48b870d03dd921b6c02a.png

  • setName 执行时,它的 outer 指向 foo() 的词法环境;
  • 即使 foo() 已经执行结束,这个环境依然存在;
  • 所以 myName 可以被修改为 '极客邦'
  • 后续调用 getName() 时,依然能拿到更新后的值。

✅ 闭包的形成条件:

  1. 函数嵌套函数;
  2. 内部函数被返回或暴露到外部;
  3. 内部函数引用了外部函数的变量。

四、闭包的生命周期:什么时候释放?

闭包并不会一直占用内存。只有当外部仍然持有对闭包函数的引用时,闭包才会被保留。

比如:

var bar = foo()
bar = null // 此时,bar 不再引用 innerBar,闭包可以被垃圾回收

一旦 bar 被置为 nullgetNamesetName 就不再被引用,V8 引擎就会回收 foo() 的词法环境,释放内存。

🔒 闭包是一种“记忆”机制,但也会带来内存泄漏的风险。使用完后记得释放引用!


五、总结:词法作用域 vs 动态作用域

特性 词法作用域(JavaScript) 动态作用域
查找依据 函数定义的位置 函数调用的位置
是否依赖调用栈顺序
示例语言 JavaScript、Python、C++ Bash、一些脚本语言

JavaScript 是典型的词法作用域语言,这意味着:

  • 函数的 outer 指针在编译阶段就确定;
  • 不管你在哪调用,只要函数定义在全局,它的 outer 就指向全局;
  • 闭包的存在正是基于这种静态作用域的特性。

六、常见误区澄清

❌ 误区一:“在哪个函数里调用,就查哪个函数的作用域”

这是动态作用域的思维。JavaScript 不是这样工作的。

✅ 正确做法:看函数定义在哪outer 指向哪里,就从哪里开始查。

❌ 误区二:“函数执行完,里面的变量就没了”

不一定!如果函数返回了一个引用了内部变量的函数,那么这些变量会被保留,形成闭包。

❌ 误区三:“闭包就是匿名函数”

不对。闭包是一种现象,不一定是匿名函数。只要满足条件,任何函数都可以形成闭包。


七、图解回顾

我们再来快速回顾一下几张关键图:

图1:bar() 调用时的作用域链

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png

  • bar()outer 指向全局;
  • 查找 test 时,从全局找到 test = 1

图2:foo() 执行时的执行上下文

d70143c661ed9c209cdc5991f27fcab9.png

  • foo() 的词法环境包含 test1, test2
  • 变量环境包含 myName, innerBar

图3:闭包生效时的状态

lQLPJwCC0KWlAbPNA03NBHawINj2y-qMdT0JAv8hJbJKAA_1142_845.png

  • setName 执行时,outer 指向 foo() 的词法环境;
  • 即使 foo() 已出栈,数据依然可访问。

八、写在最后

JavaScript 的作用域机制看似复杂,但只要抓住一个核心:词法作用域 + outer 指针 + 闭包,就能轻松应对大多数场景。

记住一句话:

函数的作用域由它定义的位置决定,而不是调用的位置。

当你看到一个函数在别处被调用时,不要慌,先问一句:“它是在哪定义的?” 然后顺着 outer 指针去找,一切就清晰了。

闭包虽然强大,但也需要谨慎使用,避免不必要的内存占用。

希望这篇文章能帮你理清思路,下次遇到作用域问题时,不再迷茫!


📌 附注:本文所用代码和图解均来自个人学习笔记,图片仅为示意,实际运行时请自行验证逻辑。欢迎在评论区交流你的理解!

2025 年最新 Fabric.js 实战:一个完整可上线的图片选区标注组件(含全部源码).

2025年11月28日 10:34

# 从 0 到 1 用最新 Fabric.js 实现:背景图 + 手动画矩形 + 坐标 + 删除 + 高清截图导出(2025 最新版)

最近项目要做一个「图片自动化清洗工具」,核心需求如下(产品甩给我的原话):

  1. 支持上传任意尺寸的广告图
  2. 运营要在图上框出需要保留的区域(可画多个矩形)
  3. 框出来的区域右上角必须有 × 可以删除
  4. 实时显示鼠标在原图上的精确坐标
  5. 最后要能把每个选区裁成独立图片 + 导出坐标数据给后端
  6. 必须有一键清空功能

我调研了 Konva、PixiJS、ZRender,最终还是选择了最成熟、最好用的 **Fabric.js**,并且使用最新版 v6.7.1+ 完美实现!

本文所有代码基于 Fabric.js 6.7+,完全适配 Vue3/React/原生JS,已在生产环境稳定运行。

一、Fabric.js 官网 & 安装方式(2025 最新)

二、bash

npm install fabric@latest

三、创建画布 & 初始化 CanvasManager 类

<template>
  <canvas ref="canvasEl" id="canvas"></canvas>
</template>
// CanvasManager.js
import { Canvas, FabricImage, Rect, Control } from "fabric";

export class CanvasManager {
  constructor(canvasEl, options = {}) {
    this.canvas = new Canvas(canvasEl, {
      width: canvasEl.clientWidth || 1000,
      height: canvasEl.clientHeight ||  || 700,
      backgroundColor: "#f5f5f5",
      selection: false,           // 全局禁止多选框(我们自己控制选中态)
      preserveObjectStacking: true,
    });

    // 回调函数,用于通知外部(如 Pinia/Vuex)矩形增删改
    this.onRectangleAdded   = options.onRectangleAdded;
    this.onRectangleUpdated  = options.onRectangleUpdated;
    this.onRectangleDeleted  = options.onRectangleDeleted;

    this.isDrawing = false;
    this.startX = this.startY = 0;
    this.currentRect = null;
    this.rectangles = [];         // 所有已完成的矩形
    this.originalImageWidth = this.originalImageHeight = 0;

    this.initEvents();
  }

  initEvents() {
    this.canvas.on("mouse:down", this.handleMouseDown.bind(this));
    this.canvas.on("mouse:move", this.handleMouseMove.bind(this));
    this.canvas.on("mouse:up",   this.handleMouseUp.bind(this));
  }
}

四、上传背景图 + 完美适配画布(支持 5000×5000 大图不卡)

FabricImage 设置canvas画布背景


<!-- 上传按钮(Ant Design Vue) -->
<input
  type="file"
  ref="fileInput"
  @change="handleFileUpload"
  accept="image/*"
  style="display: none"
/>
<a-button type="primary" @click="$refs.fileInput.click()">
  上传图片
</a-button>
      
     
// 设置背景图(核心方法)
async setBackground(source) {
  try {
    const img = await FabricImage.fromURL(source, { crossOrigin: "anonymous" });

    // 保存原始尺寸(后面导出坐标要用)
    this.originalImageWidth  = img.width;
    this.originalImageHeight = img.height;

    // 等比缩放至画布宽度(也可改成 scaleToHeight)
    img.scaleToWidth(this.canvas.getWidth());

    this.canvas.setBackgroundImage(img, () => {
      this.canvas.requestRenderAll();
    });

    return {
      originalSize: { width: img.width, height: img.height },
      scaledSize: { width: img.getScaledWidth(), height: img.getScaledHeight() },
      scaleX: img.scaleX,
      scaleY: img.scaleY,
    };
  } catch (err) {
    console.error("背景图加载失败", err);
    throw err;
  }
}

五、手动画矩形 + 右上角删除按钮(最丝滑写法)

在图片标注类工具中,“按住鼠标拖拽绘制矩形选区 + 右上角一键删除”是核心交互体验。本方案基于 Fabric.js v6.7+ 官方推荐的事件机制与自定义 Control 体系,实现了一套高可维护性、视觉统

绘制矩形区域
// 开始绘制矩形选区(鼠标按下时触发)
startDrawing(opt) {
  // 获取当前鼠标在画布上的精确坐标(已自动处理缩放、平移、滚动偏移)
  // getPointer 是 Fabric.js 官方推荐方法,比 e.layerX/e.offsetX 更准确
  const pointer = this.canvas.getPointer(opt.e);

  this.startX = pointer.x;// 记录矩形起始点的 X 坐标(左上角)
  this.startY = pointer.y;// 记录矩形起始点的 Y 坐标(左上角)

  // 标记当前处于“正在绘制”状态,后续 mouse:move 和 mouse:up 会用到
  this.isDrawing = true;

  // 创建一个新的 Fabric Rect 实例,作为当前正在绘制的矩形
  this.currentRect = new Rect({
    left: this.startX,// 矩形左上角 X 坐标(起始点)
    top: this.startY,// 矩形左上角 Y 坐标(起始点)
    width: 0, // 初始宽高为 0,后续拖拽时动态更新
    height: 0,
    fill: "rgba(255,0,0,0.3)", // 半透明红色填充,便于区分选区
    stroke: "red",// 边框颜色
    strokeWidth: 2,// 边框粗细
    selectable: true, // 允许被选中和拖拽移动
    hasControls: true,// 显示八个控制点(调整大小用)
    hasRotatingPoint: false,  // 禁止旋转手柄(我们不需要旋转)
    cornerColor: "red",  // 控制点角(调整大小时的八个小方块)颜色设为红色,与主题保持一致
    transparentCorners: false, // 控制点不透明(默认是半透明的,这里改成实心)
    cornerStyle: "circle",// 控制点形状为圆形(比默认矩形更好看)
    cornerSize: 12,// 控制点大小(像素)
    strokeDashArray: [5, 5],// 边框虚线样式 [实线长度, 间隔长度]
  });

  // 创建右上角的“× 删除”自定义控制点(自定义控制点是 Fabric.js 高阶用法)
  const deleteControl = this.createDeleteControl();

  // 把自定义删除按钮挂载到当前矩形的 controls 上,键名可以自定义
  // 之后可以通过 rect.setControlVisible('deleteBtn', false) 控制显隐
  this.currentRect.controls.deleteBtn = deleteControl;

  // 隐藏默认的旋转控制点(mtr = middle top rotate)
  // 我们已经禁用了旋转,这里再保险隐藏一下
  this.currentRect.setControlVisible("mtr", false);

  // 立即把矩形添加到画布(此时宽高为0,看不到,但必须先加进去才能实时更新)
  this.canvas.add(this.currentRect);

  // 触发重绘,确保即使宽高为0也能看到光标变化
  this.canvas.requestRenderAll();
},

一、体验丝滑的绘制能力。

核心交互流程严格遵循经典三阶段模型:

  1. mouse:down → 开启绘制模式

    1. 调用 canvas.getPointer(e) 获取相对于画布的精准坐标(自动补偿缩放、平移、视口滚动)
    2. 初始化 isDrawing = true 状态标志
    3. 创建临时 fabric.Rect 实例(初始宽高为 0)并立即加入画布,确保后续 move 事件可实时更新
  2. mouse:move → 实时更新矩形尺寸与位置

    1. 动态计算宽度/高度取绝对值,支持四个方向拖拽
    2. 动态调整 left/top 为较小值,保证矩形左上角始终为起始点
    3. 每帧调用 canvas.requestRenderAll() 实现流畅预览
  3. mouse:up → 绘制完成 & 防御性收尾

    1. 自动过滤误触(宽或高 < 10px 的矩形直接丢弃)
    2. 为有效矩形添加自定义删除控件(controls.deleteBtn)
    3. 将矩形实例推入管理数组,便于后续批量操作与数据同步
    4. 触发外部回调 onRectangleAdded,实现与 Pinia/Vuex/React 状态的完美解耦
鼠标事件
 // 鼠标按下事件
  handleMouseDown(opt) {
    if (opt.target) {
      return; // 点击了已有对象,进入编辑模式
    }
    if (this.isDrawing) return;

    this.startDrawing(opt);
  }
   // 鼠标移动事件
  handleMouseMove(opt) {
    if (!this.isDrawing || !this.currentRect) return;

    const pointer = this.canvas.getPointer(opt.e);
    const w = Math.abs(pointer.x - this.startX);
    const h = Math.abs(pointer.y - this.startY);

    this.currentRect.set({
      width: w,
      height: h,
      left: Math.min(pointer.x, this.startX),
      top: Math.min(pointer.y, this.startY),
    });

    this.canvas.requestRenderAll();
  }

  // 鼠标松开事件
  handleMouseUp() {
    if (!this.isDrawing || !this.currentRect) return;
    this.isDrawing = false;

    // 检查矩形尺寸,太小则删除
    if (this.currentRect.width < 5 || this.currentRect.height < 5) {
      this.canvas.remove(this.currentRect);
      this.currentRect = null;
      this.canvas.requestRenderAll();
      return;
    }

    this.finalizeRectangle();
  }
自定义删除控件(Custom Control)实现亮点
  • 位置锚点:x: 0.5, y: -0.5 + offsetX/Y 微调,精准定位在矩形右上角
  • 视觉风格:红色圆形底 + 白色粗体 ×,与 Ant Design/ProComponents 设计语言完全一致
  • 交互体验:cornerSize: 28 扩大点击区域,老年模式也能轻松点中
  • 性能优化:仅使用 Canvas 2D 绘制,无额外 DOM 元素,无内存泄漏
  • 事件隔离:mouseUpHandler 内部直接 canvas.remove(target) 并通知外部删除回调

Control就是可以设置图形的点。

createDeleteControl() {
  /**
   * 创建 Fabric.js 自定义删除控件
   * 该控件会出现在选中对象的右上角(可通过 x/y 调整位置)
   */
  return new Control({
    x: 0.5,                 // 水平锚点:0.5 表示对象右边缘
    y: -0.5,                // 垂直锚点:-0.5 表示对象上边缘
    offsetX: 10,            // 水平偏移量(向右偏移 10px)
    offsetY: -10,           // 垂直偏移量(向上偏移 10px)
    cursorStyle: "pointer", // 鼠标悬停时显示手型光标
    cornerSize: 24,         // 可点击区域大小(24×24px)
    
    // 自定义绘制删除图标(× 或垃圾桶图标)
    render: this.renderDeleteIcon.bind(this),
    
    // 点击删除按钮后执行的回调
    mouseUpHandler: this.deleteHandler.bind(this),
  });
}

简单的删除操作就是在画布中remove(对应的矩形) requestRenderAll重新渲染

六、清空所有选取

清空操作就是获取所有的矩形实例然后给remove ,重新渲染requestRenderAll。

七、完整代码

import { Canvas, FabricImage, Rect, Control } from "fabric";

export class CanvasManager {
  // 创建初始画布
  constructor(canvasEl, options = {}) {
    // 保存回调函数
    this.onRectangleAdded = options.onRectangleAdded;
    this.onRectangleUpdated = options.onRectangleUpdated;
    this.onRectangleDeleted = options.onRectangleDeleted;

    this.canvas = new Canvas(canvasEl, {
      width: canvasEl.clientWidth || 800,
      height: canvasEl.clientHeight || 600,
      backgroundColor: "#f0f0f0",
      selection: false,
    });

    this.isDrawing = false;
    this.startX = 0;
    this.startY = 0;
    this.currentRect = null;
    this.deleteIconImg = null;
    this.rectangles = [];

    this.initEvents();
  }

  // 设置背景图片
  async setBackground(imageUrl) {
    try {
      // 保存图片URL/路径
      this.imageUrl =
        typeof imageUrl === "string"
          ? imageUrl
          : imageUrl?.default || imageUrl?.src || imageUrl;

      const img = await FabricImage.fromURL(this.imageUrl);

      // 保存原始图片尺寸
      this.originalImageWidth = img.width;
      this.originalImageHeight = img.height;

      // 缩放图片以适应画布宽度
      img.scaleToWidth(this.canvas.getWidth());

      // 保存缩放后的尺寸(实际显示尺寸)
      this.scaledImageWidth = img.width * (img.scaleX || 1);
      this.scaledImageHeight = img.height * (img.scaleY || 1);

      this.canvas.backgroundImage = img;
      this.canvas.requestRenderAll();

      return {
        image: img,
        imageUrl: this.imageUrl, // 返回图片路径
        originalSize: {
          width: this.originalImageWidth,
          height: this.originalImageHeight,
        },
        scaledSize: {
          width: this.scaledImageWidth,
          height: this.scaledImageHeight,
        },
        scaleX: img.scaleX,
        scaleY: img.scaleY,
      };
    } catch (error) {
      console.error("背景加载失败:", error);
      throw error;
    }
  }

  // 获取图片URL/路径
  getImageUrl() {
    return this.imageUrl || null;
  }

  // 获取图片尺寸信息
  getImageSize() {
    if (!this.canvas.backgroundImage) {
      return null;
    }

    return {
      original: {
        width: this.originalImageWidth || 0,
        height: this.originalImageHeight || 0,
      },
      scaled: {
        width: this.scaledImageWidth || 0,
        height: this.scaledImageHeight || 0,
      },
      scaleX: this.canvas.backgroundImage.scaleX || 1,
      scaleY: this.canvas.backgroundImage.scaleY || 1,
    };
  }

  // 加载删除图标
  async loadDeleteIcon(iconUrl) {
    return new Promise((resolve) => {
      const img = new Image();
      img.src = iconUrl;
      img.onload = () => {
        this.deleteIconImg = img;
        resolve(img);
      };
      img.onerror = () => {
        console.warn("删除图标加载失败,使用默认样式");
        resolve(null);
      };
    });
  }

  // 初始化事件
  initEvents() {
    this.canvas.on("mouse:down", this.handleMouseDown.bind(this));
    this.canvas.on("mouse:move", this.handleMouseMove.bind(this));
    this.canvas.on("mouse:up", this.handleMouseUp.bind(this));
    this.canvas.on("selection:created", this.handleSelectionCreated.bind(this));
  }

  // 鼠标按下事件
  handleMouseDown(opt) {
    if (opt.target) {
      return; // 点击了已有对象,进入编辑模式
    }
    if (this.isDrawing) return;

    this.startDrawing(opt);
  }

  // 开始绘制
  startDrawing(opt) {
    const pointer = this.canvas.getPointer(opt.e);
    this.startX = pointer.x;
    this.startY = pointer.y;
    this.isDrawing = true;

    this.currentRect = new Rect({
      left: this.startX,
      top: this.startY,
      width: 0,
      height: 0,
      fill: "rgba(255,0,0,0.3)",
      stroke: "red",
      strokeWidth: 2,
      selectable: true,
      hasControls: true,
      hasRotatingPoint: false,
      cornerColor: "red",
      transparentCorners: false,
      cornerStyle: "circle",
      cornerSize: 12,
      strokeDashArray: [5, 5],
    });

    // 添加删除控制点
    const deleteControl = this.createDeleteControl();
    this.currentRect.controls.deleteBtn = deleteControl;
    this.currentRect.setControlVisible("mtr", false);

    this.canvas.add(this.currentRect);
    this.canvas.requestRenderAll();
  }

  // 创建删除控制点
  createDeleteControl() {
    return new Control({
      x: 0.5,
      y: -0.5,
      offsetX: 10,
      offsetY: -10,
      cursorStyle: "pointer",
      cornerSize: 24,
      render: this.renderDeleteIcon.bind(this),
      mouseUpHandler: this.deleteHandler.bind(this),
    });
  }

  // 鼠标移动事件
  handleMouseMove(opt) {
    if (!this.isDrawing || !this.currentRect) return;

    const pointer = this.canvas.getPointer(opt.e);
    const w = Math.abs(pointer.x - this.startX);
    const h = Math.abs(pointer.y - this.startY);

    this.currentRect.set({
      width: w,
      height: h,
      left: Math.min(pointer.x, this.startX),
      top: Math.min(pointer.y, this.startY),
    });

    this.canvas.requestRenderAll();
  }

  // 鼠标松开事件
  handleMouseUp() {
    if (!this.isDrawing || !this.currentRect) return;
    this.isDrawing = false;

    // 检查矩形尺寸,太小则删除
    if (this.currentRect.width < 5 || this.currentRect.height < 5) {
      this.canvas.remove(this.currentRect);
      this.currentRect = null;
      this.canvas.requestRenderAll();
      return;
    }

    this.finalizeRectangle();
  }

  // CanvasManager.js
  finalizeRectangle() {
    const id = `rect_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    const coords = this.currentRect.getCoords();

    const rectData = {
      id,
      fabricObject: this.currentRect,
      coords: {
        tl: coords[0],
        tr: coords[1],
        br: coords[2],
        bl: coords[3],
      },
      left: this.currentRect.left,
      top: this.currentRect.top,
      width: this.currentRect.width,
      height: this.currentRect.height,
      angle: this.currentRect.angle,
    };

    // 保存到 rectangles 数组
    this.rectangles.push(rectData);

    // 直接调用回调函数添加到 store
    if (this.onRectangleAdded) {
      this.onRectangleAdded(rectData);
    }

    this.canvas.setActiveObject(this.currentRect);

    // 绑定实时更新事件
    this.currentRect.on("moving", () => this.updateRectCoords(rectData));
    this.currentRect.on("scaling", () => this.updateRectCoords(rectData));
    this.currentRect.on("rotating", () => this.updateRectCoords(rectData));

    this.currentRect = null;
    this.canvas.requestRenderAll();

    return rectData;
  }

  // 更新矩形坐标
  updateRectCoords(rectData) {
    const obj = rectData.fabricObject;
    const coords = obj.getCoords();

    rectData.coords = {
      tl: {
        x: Number(coords[0].x.toFixed(2)),
        y: Number(coords[0].y.toFixed(2)),
      },
      tr: {
        x: Number(coords[1].x.toFixed(2)),
        y: Number(coords[1].y.toFixed(2)),
      },
      br: {
        x: Number(coords[2].x.toFixed(2)),
        y: Number(coords[2].y.toFixed(2)),
      },
      bl: {
        x: Number(coords[3].x.toFixed(2)),
        y: Number(coords[3].y.toFixed(2)),
      },
    };

    rectData.left = Number(obj.left.toFixed(2));
    rectData.top = Number(obj.top.toFixed(2));
    rectData.width = Number(obj.width.toFixed(2));
    rectData.height = Number(obj.height.toFixed(2));
    rectData.angle = Number(obj.angle.toFixed(2));

    // 通知更新
    if (this.onRectangleUpdated) {
      this.onRectangleUpdated(rectData);
    }
  }

  // 选中事件
  handleSelectionCreated(opt) {
    const active = opt.target;
    const data = this.rectangles.find((r) => r.fabricObject === active);
    if (data) this.updateRectCoords(data);
  }

  // 删除处理
  deleteHandler(eventData, transform) {
    const target = transform.target;
    if (!target) return false;

    // 找到要删除的矩形数据
    const rectData = this.rectangles.find((r) => r.fabricObject === target);

    // 从 rectangles 中移除
    this.rectangles = this.rectangles.filter((r) => r.fabricObject !== target);

    // 调用删除回调
    if (rectData && this.onRectangleDeleted) {
      this.onRectangleDeleted(rectData.id);
    }

    this.canvas.remove(target);
    this.canvas.requestRenderAll();
    return true;
  }

  // 渲染删除图标
  renderDeleteIcon(ctx, left, top) {
    if (!this.deleteIconImg) {
      // 降级样式
      ctx.save();
      ctx.fillStyle = "red";
      ctx.beginPath();
      ctx.arc(left, top, 12, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = "white";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(left - 6, top - 6);
      ctx.lineTo(left + 6, top + 6);
      ctx.moveTo(left + 6, top - 6);
      ctx.lineTo(left - 6, top + 6);
      ctx.stroke();
      ctx.restore();
      return;
    }

    const size = 20;
    ctx.drawImage(
      this.deleteIconImg,
      left - size / 2,
      top - size / 2,
      size,
      size
    );
  }

  // 获取所有矩形数据
  getRectangles() {
    return this.rectangles.map((rect) => ({
      id: rect.id,
      coords: rect.coords,
      left: rect.left,
      top: rect.top,
      width: rect.width,
      height: rect.height,
      angle: rect.angle,
    }));
  }

  // 清空画布(保留背景图片)
  clear() {
    // 获取所有对象(不包括背景图片)
    const objects = this.canvas.getObjects();

    // 移除所有对象
    objects.forEach((obj) => {
      this.canvas.remove(obj);
    });

    // 清空矩形数组
    this.rectangles = [];

    // 重新渲染
    this.canvas.requestRenderAll();
  }

  // 清空所有(包括背景图片)
  clearAll() {
    // 清空所有对象
    this.clear();

    // 清空背景图片
    this.canvas.backgroundImage = null;

    // 重新渲染
    this.canvas.requestRenderAll();
  }

  // 调整画布大小
  resize(width, height) {
    this.canvas.setDimensions({ width, height });
    if (this.canvas.backgroundImage) {
      this.canvas.backgroundImage.scaleToWidth(width);
    }
    this.canvas.requestRenderAll();
  }

  // 销毁
  destroy() {
    this.canvas.off("mouse:down");
    this.canvas.off("mouse:move");
    this.canvas.off("mouse:up");
    this.canvas.off("selection:created");
    this.canvas.dispose();
  }
}

手搓一个简简单单进度条

作者 流星稍逝
2025年11月28日 10:31

懂得都懂就不细细解剖,直接上代码

也就是太无聊,心血来潮搓一个小玩意儿

image.png

image.png

div盒子

<div class="progress-box">
<div class="progress-bar" :style="{ width: `${progress.width}%` }">
<div class="progress-bar-inner">
<div class="progress-light"></div>
</div>
</div></div>
<div class="progress-text">
<span class="num" ref="countDemo"
:style="{ left: `calc(${progress.width}% - ${Amountspent < 99 ? 66 :Amountspent > 999 && progress.width < 50 ? 40 : 70}px)` }">
$ {{ Amountspent }}
</span>
</div>

js

<script setup>
import { onMounted,ref } from "vue"
        //进度条相关参数
        const progress = ref({
width: 0,
startValue: 0,
endValue: 0
})
        const Amountspent = ref(0)
        const progressVal = (start = 0, end = 0) => {
const startVal = Number(start ?? 0) || 0
const endVal = Number(end ?? 0) || 0
return {
width: endVal > 0 ? Math.min(Math.max((startVal / endVal) * 100, 0), 100).toFixed(2) : 0,
startValue: startVal,
endValue: endVal
}
}
        
        onMounted(() => {
                //接口请求获取参数
getprogressInfo({}, (res) => {
if (!res) return

if (res && res.data) {
let { totaAmount, amountspent } = res.data
Amountspent.value=amountspent?amountspent:0
progress.value = progressVal(amountspent, totaAmount)//进度条


}

})

})
</script>

css

<style lang="scss">
.progress-box {
position: relative;
border-radius: 500px;
background: rgba(255, 255, 255, 0.05);
padding: 2px;
height: 10px;
margin-bottom: 7px;

.progress-value {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: #fff;
text-align: center;
font-size: 12px;
font-weight: 800;
line-height: 1;
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
}

.progress-bar {
height: 100%;
width: 0;
border-radius: 500px;
background: #20ce2e;
position: relative;
box-shadow:
0 0 3px 0 #00ff9d inset,
0 0 7.5px 0 rgba(32, 206, 46, 0.6);

.progress-bar-inner {
position: absolute;
right: -11px;
top: -11px;
}

.progress-light {
width: 30px;
height: 30px;
position: relative;
border-radius: 50%;
background-color: rgba(205, 222, 221, 0.1);

&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 18px;
height: 18px;
border-radius: 50%;
background-color: rgba(205, 222, 221, 0.1);
}

&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #e7fefd;
}
}
}
}
                
                
.progress-text {
position: relative;
/* padding: 2px; */
height: 10px;
margin-bottom: 10px;
margin-right: 14px;

.num {
font-weight: 800;
font-style: ExtraBold;
font-size: 12px;
line-height: 1.5;
position: absolute;
color: rgba(172, 188, 208, 1);
width: 80px;
display: inline-block;
text-align: right;
}
}

详解 TypeScript 中,async 和 await

2025年11月28日 10:29

在 TypeScript 中,asyncawait 是处理异步操作的语法糖(Syntactic Sugar),本质上它们仍然是基于 Promise 的。

但在 TypeScript 的语境下,理解它们的核心在于掌握 返回类型推断解包(Unwrapping) 机制。

以下是 asyncawait 的详细解析:


1. async 关键字

async 用于修饰函数。它有两个核心规则:

  1. 强制返回 Promise:无论函数内部 return 什么,最终都会被封装成一个 Promise。
  2. 类型注解:在 TypeScript 中,async 函数的返回值类型必须明确写成 Promise<T>

示例代码

// 1. 自动推断
async function simple() {
  return 123; 
}
// TS 自动推断出 simple() 的返回类型是: Promise<number>


// 2. 显式注解 (推荐)
// 即使你 return 的是一个普通字符串,类型定义必须包裹在 Promise 中
async function getUserName(): Promise<string> {
  return "Alice";
}

// 3. 无返回值的异步函数
async function logData(): Promise<void> {
  console.log("Saving...");
  // 相当于 return Promise.resolve(undefined);
}

注意:你不能将 async 函数的返回类型写成 stringnumber,必须是 Promise<string>


2. await 关键字

await 只能在 async 函数内部使用(或者在支持 Top-level await 的模块中使用)。

它的作用是:

  1. 暂停执行:暂停当前 async 函数的执行,直到 Promise 状态变为 resolved 或 rejected。
  2. 自动解包 (Unwrapping):这是 TS 的强大之处。如果一个表达式是 Promise<string>await 之后得到的结果类型就是 string

类型解包示例

function fetchAge(): Promise<number> {
  return Promise.resolve(18);
}

async function main() {
  // TS 知道 fetchAge 返回 Promise<number>
  // 所以 age 被自动推断为 number 类型
  const age = await fetchAge(); 
  
  console.log(age + 1); // 这里的加法是合法的,因为 age 是 number
}

3. 错误处理 (try...catch)

await 后面的 Promise 被 reject 时,会抛出一个异常。因此,标准做法是使用 try...catch

在 TypeScript 中,catch(error) 中的 error 默认类型通常是 unknownany

async function requestData(): Promise<string> {
  throw new Error("网络连接断开");
}

async function handleTask() {
  try {
    const data = await requestData();
    console.log(data);
  } catch (error) {
    // 这里的 error 类型是 unknown (在 TS 4.4+ 开启 useUnknownInCatchVariables 时)
    
    // 最佳实践:进行类型守卫
    if (error instanceof Error) {
      console.error("错误消息:", error.message);
    } else {
      console.error("未知错误:", error);
    }
  }
}

4. 串行 vs 并行 (性能关键)

这是使用 async/await 最容易踩的坑。await 会阻塞后续代码,如果多个请求之间没有依赖关系,不要写成串行。

❌ 串行写法 (慢)

async function serial() {
  const start = Date.now();
  // 假设 getUser 耗时 1s, getPosts 耗时 1s
  const user = await getUser();   // 等待 1s
  const posts = await getPosts(); // 再等待 1s
  // 总耗时: 2s
}

✅ 并行写法 (快 - 使用 Promise.all)

async function parallel() {
  // 同时启动两个任务
  const userPromise = getUser();
  const postsPromise = getPosts();

  // 等待它们全部完成
  // TS 会自动推断 results 类型为 [User, Post[]]
  const [user, posts] = await Promise.all([userPromise, postsPromise]);
  // 总耗时: 1s
}

5. 高级用法与技巧

5.1 异步箭头函数

const login = async (username: string): Promise<boolean> => {
  return true;
};

5.2 Top-level Await (顶层 await)

在现代 TypeScript (ES2022 / ESNext 且 module 设置为 esnext/system 等) 中,可以在文件最外层直接使用 await,不需要包裹在 async function 中。这在初始化数据库连接等场景很有用。

// db.ts
import { createConnection } from 'typeorm';

// 直接等待连接建立
export const connection = await createConnection();

5.3 Promise<void> 的陷阱

有时候我们会把 async 函数作为回调传给 forEach,这通常是不对的,因为 forEach 不会等待 async 回调完成。

const ids = [1, 2, 3];

// ❌ 错误做法:forEach 不会等待 await
ids.forEach(async (id) => {
  await fetchUser(id); 
});
console.log('Done'); // 这行代码会在 fetchUser 完成之前就打印!

// ✅ 正确做法:使用 map + Promise.all
await Promise.all(ids.map(async (id) => {
  await fetchUser(id);
}));
console.log('Done'); // 确实等待所有都完成了

总结 TS 中的 Async/Await

特性 说明 对应的 TS 写法
定义 函数必须返回 Promise async function fn(): Promise<T> { ... }
调用 必须在 async 函数内 const val: T = await promise;
返回值 自动包装 return T 会变成 Promise<T>
异常 使用 try-catch 捕获 catch 块中的变量需注意类型检查

掌握这套机制后,你的异步代码会像同步代码一样清晰,同时拥有完整的类型检查保护。

JavaScript 中 this 指向问题

作者 uup
2025年11月28日 10:23

一、Bug 场景

在一个 JavaScript 的网页交互项目中,有一个构造函数定义了一个对象,该对象包含一个方法用于更新 DOM 元素的文本内容。同时,为了实现异步操作,在这个方法内部使用了 setTimeout 来模拟一些延迟任务。

二、代码示例

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale = 1.0">
    <title>this指向问题</title>
</head>

<body>
    <div id="target"></div>

    <script>
        function DOMUpdater() {
            this.targetElement = document.getElementById('target');
            this.updateText = function () {
                setTimeout(function () {
                    this.targetElement.textContent = '更新后的文本';
                }, 1000);
            };
        }

        const updater = new DOMUpdater();
        updater.updateText();
    </script>
</body>

</html>

三、问题描述

  1. 预期行为:等待 1 秒后,id 为 target 的 div 元素的文本内容应更新为 “更新后的文本”。
  2. 实际行为:在控制台中会报错 Uncaught TypeError: Cannot set property 'textContent' of null。这是因为在 setTimeout 内部的回调函数中,this 的指向发生了变化。在非严格模式下,setTimeout 回调函数中的 this 指向全局对象(在浏览器环境中是 window),而不是 DOMUpdater 实例对象。由于 window 中没有 targetElement 属性,所以会导致 this.targetElement 为 null,进而引发错误。

四、解决方案

  1. 使用箭头函数:箭头函数没有自己的 this,它的 this 继承自外层作用域,这样就可以保持 this 指向 DOMUpdater 实例对象。
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale = 1.0">
    <title>this指向问题 - 箭头函数解决</title>
</head>

<body>
    <div id="target"></div>

    <script>
        function DOMUpdater() {
            this.targetElement = document.getElementById('target');
            this.updateText = function () {
                setTimeout(() => {
                    this.targetElement.textContent = '更新后的文本';
                }, 1000);
            };
        }

        const updater = new DOMUpdater();
        updater.updateText();
    </script>
</body>

</html>
  1. 使用变量保存 this:在 updateText 方法内部,使用一个变量(通常命名为 self 或 that)来保存 this 的值,然后在 setTimeout 回调函数中使用这个变量。
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale = 1.0">
    <title>this指向问题 - 变量保存this解决</title>
</head>

<body>
    <div id="target"></div>

    <script>
        function DOMUpdater() {
            this.targetElement = document.getElementById('target');
            this.updateText = function () {
                const self = this;
                setTimeout(function () {
                    self.targetElement.textContent = '更新后的文本';
                }, 1000);
            };
        }

        const updater = new DOMUpdater();
        updater.updateText();
    </script>
</body>

</html>
  1. 使用 bind 方法bind 方法可以创建一个新的函数,在这个新函数中,this 被绑定到指定的对象。
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device - width, initial - scale = 1.0">
    <title>this指向问题 - bind解决</title>
</head>

<body>
    <div id="target"></div>

    <script>
        function DOMUpdater() {
            this.targetElement = document.getElementById('target');
            this.updateText = function () {
                setTimeout(function () {
                    this.targetElement.textContent = '更新后的文本';
                }.bind(this), 1000);
            };
        }

        const updater = new DOMUpdater();
        updater.updateText();
    </script>
</body>

</html>

TypeScript 中,Promise

2025年11月28日 10:19

在 TypeScript 中,Promise 是处理异步操作的核心机制。相比于 JavaScript,TypeScript 中的 Promise 最大的优势在于类型安全(Type Safety),它能让你明确知道异步操作返回的数据类型。

以下是关于 TypeScript 中 Promise 的详细介绍,从基础定义到高级用法。


1. 核心概念与泛型 (Promise<T>)

在 TypeScript 中,Promise 是一个泛型接口,定义为 Promise<T>

  • T: 代表 Promise 成功(Resolved) 时返回的数据类型。
  • 如果 Promise 不返回任何值(只处理过程),通常使用 Promise<void>

基本语法示例

// 显式声明返回类型为 string
const myPromise: Promise<string> = new Promise((resolve, reject) => {
  console.log("异步执行")
  const success = true;  //--------->New的时候 这块代码就被执行
  if (success) {
    resolve("操作成功!"); // 这里的参数必须是 string
  } else {
    reject(new Error("操作失败"));
  }
});
console.log("主程序执行1")
myPromise.then((data) => {   //--------->拿到执行结果
  console.log(data.length); // TS 知道 data 是 string,所以允许访问 .length
});
console.log("主程序执行2")

运行输出结果

[LOG]: "异步执行"  
[LOG]: "主程序执行1"  
[LOG]: "主程序执行2"  
[LOG]: 5

2. 创建 Promise 的方式

2.1 使用构造函数 (new Promise)

这是最基础的写法,构造函数接受一个执行器函数 (resolve, reject) => void

function fetchUser(id: number): Promise<{ id: number; name: string }> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 0) {
        // resolve 的参数必须符合 Promise<{...}> 中定义的结构
        resolve({ id, name: "Alice" });
      } else {
        reject("ID 无效");
      }
    }, 1000);
  });
}

2.2 使用 asyncawait (推荐)

这是现代开发的标准写法。async 函数的返回值永远是一个 Promise。TS 会根据 return 的值自动推断 Promise 的泛型类型。

// 写法 1: 自动推断
async function getUser() {
  return "Alice"; // TS 推断返回类型为 Promise<string>
}

// 写法 2: 显式注解 (推荐,更规范)
async function getScore(): Promise<number> {
  return 100;
}

// 使用 await
async function main() {
  const score = await getScore(); // score 被推断为 number 类型
  console.log(score);
}

3. Promise 的状态与回调

Promise 有三种状态:pending (进行中), fulfilled (已成功), rejected (已失败)。

在 TS 中,.then() 链式调用时,类型会自动流转。

const p = Promise.resolve(10); // 类型: Promise<number>

p.then((num) => {
  // num 是 number
  return num.toString(); // 返回 string
})
.then((str) => {
  // str 是 string (TS 自动推断)
  console.log(str);
  return true; // 返回 boolean
})
.then((bool) => {
  // bool 是 boolean
});

4. 静态方法 (Static Methods)

TypeScript 对 Promise 的静态方法也有很好的类型支持。

4.1 Promise.resolve<T>(value: T)

创建一个立即成功的 Promise。

const p1 = Promise.resolve(42); // Promise<number>
const p2 = Promise.resolve({ name: "Bob" }); // Promise<{ name: string }>

4.2 Promise.reject(reason: any)

创建一个立即失败的 Promise。

const pFail = Promise.reject("Error occurred"); // Promise<never>

4.3 Promise.all<T[]>(values: [])

等待所有 Promise 完成。返回值的类型是一个元组(Tuple)或数组。

const pString = Promise.resolve("Hello");
const pNumber = Promise.resolve(123);

// TS 能够推断出 results 的类型是 [string, number]
Promise.all([pString, pNumber]).then(([str, num]) => {
  console.log(str.toUpperCase()); // 正常
  console.log(num.toFixed(2));    // 正常
});

4.4 Promise.allSettled<T>(...)

等待所有 Promise 完成(无论成功或失败)。

const p1 = Promise.resolve(10);
const p2 = Promise.reject("error");

Promise.allSettled([p1, p2]).then((results) => {
  results.forEach((result) => {
    if (result.status === 'fulfilled') {
      console.log(result.value); // TS 知道这里有 value
    } else {
      console.log(result.reason); // TS 知道这里有 reason
    }
  });
});

4.5 Promise.race<T>(...)

返回第一个改变状态(成功或失败)的 Promise 的结果。


5. 常见实战场景

5.1 定义 API 响应接口

这是前端开发中最常用的模式。

// 1. 定义数据接口
interface User {
  id: number;
  name: string;
  email: string;
}

interface ApiResponse<T> {
  code: number;
  data: T;
  message: string;
}

// 2. 模拟请求函数
function request<T>(url: string): Promise<ApiResponse<T>> {
  return new Promise((resolve) => {
    // 模拟网络请求
    setTimeout(() => {
      resolve({
        code: 200,
        data: {} as T, // 这里只是为了演示,实际会有真实数据
        message: "success"
      });
    }, 500);
  });
}

// 3. 使用
async function fetchUserProfile() {
  // 明确告诉 request 返回的数据结构是 User
  const response = await request<User>('/api/user/1');
  
  if (response.code === 200) {
    // TS 这里会有自动提示:response.data.email
    console.log(response.data.email); 
  }
}

5.2 Promise<void> 用于不返回值的异步操作

例如:初始化数据库、发送日志、等待几秒钟。

function delay(ms: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, ms); // 不需要传递参数给 resolve
  });
}

async function run() {
  console.log("Start");
  await delay(1000);
  console.log("End after 1s");
}

6. 错误处理 (Error Handling)

在 TypeScript 中处理 Promise 错误时,有一个常见的痛点:catch 中的 error 类型通常是 anyunknown

async function task() {
  try {
    await someAsyncOperation();
  } catch (error) {
    // 默认情况下,error 是 unknown 类型 (TS 4.0+) 或 any
    // 你不能直接调用 error.message,除非你进行断言或类型收窄
    
    if (error instanceof Error) {
      console.error(error.message);
    } else {
      console.error("Unknown error:", error);
    }
  }
}

总结

  1. 类型注解:始终使用 Promise<Type> 来明确异步操作返回的数据结构。
  2. Async/Await:优先使用 async 函数,它让代码看起来像同步代码,且 TS 能自动推断返回类型。
  3. 泛型工具:利用 Promise.all 等组合工具时,TS 能够很好地处理元组类型推断。
  4. 错误处理:注意 catch 块中的类型检查,使用 instanceof Error 是个好习惯。

如果你有具体的代码场景需要分析,可以发给我,我帮你进一步讲解!

参考

详解 TypeScript 中,async 和 await

告别服务器!小程序纯前端“图片转 PDF”工具,隐私安全又高效

作者 小皮虾
2025年11月28日 10:11

1. 背景与痛点:纯前端实践的动力

在开发小程序时,实现如“图片转 PDF”这样的功能时,常常面临以下挑战:

  • 隐私担忧:将图片上传到服务器进行转换,用户担心图片内容泄露。对于个人证件、私密照片等敏感内容,这一顾虑尤为突出。
  • 网络依赖与效率:转换过程需要频繁与服务器交互,在弱网环境下速度慢、不稳定,甚至可能因上传大文件而失败。
  • 服务器成本:每一次转换都意味着服务器资源的消耗(存储、计算、带宽),对于开发者而言,成本不容忽视。

为了解决这些痛点,我们探索了一个更优的实现路径:纯前端、在小程序本地完成图片到 PDF 的转换

2. 核心思路:本地文件系统与 pdf-lib 的巧妙结合

在小程序中实现纯前端图片转 PDF,我们的核心思路是:

  1. 图片本地化处理:充分利用小程序强大的本地文件系统能力,将用户选择的图片读取到本地临时路径。
  2. PDF 文档构建:引入功能丰富的 JavaScript 库 pdf-lib,在小程序运行时直接在前端环境创建和操作 PDF 文件。
  3. 最终文件保存:将 pdf-lib 生成的 PDF 数据流保存为本地文件,供用户直接预览或分享。

这种方式让整个转换过程都在用户的小程序沙箱环境内完成,图片数据不会离开用户手机,极大保障了数据隐私和安全性,同时显著提升了转换效率并降低了服务器成本。

3. 技术核心:pdf-lib 的引入与应用

pdf-lib 是一个强大的纯 JavaScript PDF 库,支持在多种 JavaScript 环境下创建和修改 PDF 文件,完美契合小程序这种前端应用场景。

3.1 库的引入

你需要将 pdf-lib 的小程序兼容版本(通常是 pdf-lib.min.js)放置在你的项目目录中,并通过 require 引入:

const { PDFDocument, degrees, PageSizes } = require('./pdf-lib.min.js');
const fs = wx.getFileSystemManager(); // 小程序文件管理器实例

3.2 转换逻辑概览

整个图片转 PDF 的流程可分解为以下几个关键步骤:

  1. 图片预处理:获取每张图片的尺寸、类型 (wx.getImageInfo),并将其读取为 Base64 格式 (fs.readFile),这是 pdf-lib 嵌入图片所需的标准数据格式。
  2. 创建 PDF 文档:初始化一个空的 PDFDocument 对象。
  3. 逐页添加图片:遍历所有图片,为每张图片创建一个新的 PDF 页面。根据图片的原始尺寸和类型,将其嵌入到 PDF 中,并进行智能缩放、居中。对于横向图片,还会自动旋转页面 90 度以更好地适应 A4 纸张。
  4. 生成与保存:将构建好的 PDF 文档保存为 Base64 编码的字符串,再通过小程序文件系统的 fs.writeFile 接口,写入到本地的临时文件路径。
  5. 返回结果:将生成的 PDF 文件本地路径返回给业务层,用于后续的预览或分享。

4. 核心代码:img2pdf.js

以下是我们帮小忙工具箱实现图片转 PDF 功能的核心源代码。

const { PDFDocument, degrees, PageSizes } = require('./pdf-lib.min.js');
const fs = wx.getFileSystemManager()
/**
 * 把图片转成pdf
 * @param {Array} urls 图片url数组
 * @returns {String} pdfUrl pdf文件url
 */
export async function img2pdf(urls) {
if (typeof urls == 'string') {
urls = [urls]
}

// 图片信息
const imageInfo = urls.map((url) => {
return wx.getImageInfo({
src: url
});
});
const imageInfoRes = await Promise.all(imageInfo);
console.log(imageInfoRes);

// 图片base64
const imageBase64 = urls.map((url) => {
return readFile(url, "base64");
});
const imageBase64Res = await Promise.all(imageBase64);
console.log(imageBase64Res);

const pdfDoc = await PDFDocument.create();

for (let i = 0; i < imageInfoRes.length; i++) {
const {
type,
width,
height
} = imageInfoRes[i];
let pdfImage = "";
if (type === 'jpeg') {
pdfImage = await pdfDoc.embedJpg(imageBase64Res[i]);
} else if (type === 'png') {
pdfImage = await pdfDoc.embedPng(imageBase64Res[i]);
}

const page = pdfDoc.addPage(PageSizes.A4);
const {
width: pageWidth,
height: pageHeight
} = page.getSize(); // 获取页面尺寸

let drawOptions = {};

// 如果图片是宽大于高,则旋转
if (width > height) {
// 页面旋转后,可用于绘制的"宽度"实际上是原始页面的高度,"高度"是原始页面的宽度
const scaled = pdfImage.scaleToFit(pageHeight, pageWidth); // 注意参数顺序因为页面旋转了

drawOptions = {
// x: scaled.height + (pageWidth - scaled.height) / 2,   // 注意这里用的是 scaled.height
x: (pageWidth - scaled.height) / 2,
y: (pageHeight - scaled.width) / 2 + scaled.width,
width: scaled.width,
height: scaled.height,
rotate: degrees(270),
};
console.log('drawOptions', drawOptions);
} else {
// 图片是纵向或方形的
const scaled = pdfImage.scaleToFit(pageWidth, pageHeight);
drawOptions = {
x: (pageWidth - scaled.width) / 2, // 居中 X
y: (pageHeight - scaled.height) / 2, // 居中 Y
width: scaled.width,
height: scaled.height,
};
}
page.drawImage(pdfImage, drawOptions);
}

// 3. 获取 PDF 的 Uint8Array
const docBase64 = await pdfDoc.saveAsBase64();
const timestamp = Date.now();
const pdfPath = await base64ToFile(docBase64, `/${timestamp}.pdf`);


return pdfPath;
}

/**
 * base64转本地文件
 * @param {string} base64 base64字符串
 * @param {string} fileName  文件名
 * @returns {Promise} Promise 文件路径
 */
function base64ToFile(base64, fileName) {
const {
promise,
resolve,
reject
} = Promise.withResolvers();
const filePath = wx.env.USER_DATA_PATH + fileName;
fs.writeFile({
filePath,
data: base64,
encoding: "base64",
success: res => {
resolve(filePath)
},
fail: err => {
reject(err)
}
});
return promise;
}

/**
 * 使用Promise读取文件
 * @param {string} filePath 文件路径
 * @param {string} encoding 文件编码
 * @returns {Promise} Promise对象
 */
function readFile(filePath, encoding = 'utf8') {
const {
promise,
resolve,
reject
} = Promise.withResolvers();
fs.readFile({
filePath,
encoding,
success(fileRes) {
resolve(fileRes.data)
},
fail(err) {
reject(err)
}
});
return promise;
}

5. 小程序端应用示例

在页面中,可以通过简单的交互完成转换。

// pages/image-to-pdf/index.js
import { img2pdf } from '../../utils/img2pdf'; // 引入转换工具

Page({
  data: {
    selectedImages: [], // 用户选择的图片临时路径数组
    pdfPath: '',
    loading: false
  },

  // 触发图片选择
  async chooseImage() {
    const { tempFiles } = await wx.chooseMedia({
      count: 9, // 最多选择 9 张图片
      mediaType: ['image'],
      sizeType: ['original', 'compressed'], // 可以选择原图或压缩图
      sourceType: ['album', 'camera'],
    });
    this.setData({ selectedImages: tempFiles.map(file => file.tempFilePath) });
  },

  // 执行图片转 PDF 转换
  async convertToPdf() {
    if (this.data.selectedImages.length === 0) {
      wx.showToast({ title: '请先选择图片', icon: 'none' });
      return;
    }

    this.setData({ loading: true });
    wx.showLoading({ title: '转换中...' });

    try {
      const pdfFilePath = await img2pdf(this.data.selectedImages);
      this.setData({ pdfPath: pdfFilePath });
      wx.hideLoading();
      wx.showToast({ title: '转换成功!', icon: 'success' });
      
      // 转换成功后,自动打开 PDF 预览
      wx.openDocument({
        filePath: pdfFilePath,
        fileType: 'pdf',
        success: res => console.log('打开 PDF 成功', res),
        fail: err => console.error('打开 PDF 失败', err)
      });

    } catch (error) {
      wx.hideLoading();
      wx.showToast({ title: '转换失败!', icon: 'error' });
      console.error('图片转 PDF 发生错误', error);
    } finally {
      this.setData({ loading: false });
    }
  }
})

6. 经验总结与注意事项

  1. 文件体积与性能

    • pdf-lib 库本身有一定体积(通常在几百 KB),会增加小程序包体大小,我们是使用分包,所以不影响主包。
    • 图片数量越多、分辨率越高,转换耗时越长,内存占用越大。建议在选择图片时提示用户合理数量或适当压缩。
    • pdf横向图片旋转需要额外计算和处理,可能会略微增加复杂性,如果觉得复杂,也可以直接判断图片是否是纵向,如果是横向使用canvas旋转图片,逻辑上就毕竟简单了。
  2. Promise.withResolvers() 兼容性

    • 代码使用了 Promise.withResolvers(),目前大多数小程序环境和浏览器中兼容性可能不好,我自己做了兼容。
  3. 本地文件系统限制

    • wx.env.USER_DATA_PATH 路径下的文件是小程序沙箱环境特有的,用户无法直接在系统文件管理器中找到。
    • 生成的文件是临时文件,小程序关闭或长时间不用可能被系统清理。如果需要长期保存,需引导用户通过 wx.saveFile (保存到相册或本地文件) 或上传云存储。
  4. 图片类型支持pdf-lib 主要支持 JPEG 和 PNG 格式。其他格式(如 WebP、GIF)需要先转换为 JPEG/PNG 再进行嵌入,可以利用canvas实现,后面会分享。

写在最后

纯前端实现“图片转 PDF”功能,不仅提升了用户体验,更重要的是有效保护了用户的数字隐私。这在追求用户信任和数据安全的小程序生态中,无疑是一个值得推广的实践。

希望这次分享能为你带来启发,共同探索小程序前端能力的更多可能性!


我的变量去哪了?JS 作用域入门指南

作者 ohyeah
2025年11月28日 09:59

在 JavaScript 的学习过程中,作用域变量提升是两个绕不开的核心概念。它们不仅影响着代码的执行逻辑,也常常成为初学者“踩坑”的重灾区。本文将结合几段典型代码,从实际运行结果出发,梳理 JS 中作用域的演变过程,重点解释 var 的缺陷let/const 的改进,以及现代 JS 引擎如何通过执行上下文统一处理这两类变量声明。


一、变量提升:JS 的“历史包袱”

先看这段代码(1.js):

showName() // ✅ 正常执行
console.log(myname) // undefined

var myname = '路明非'

function showName(){
  console.log('函数showName 执行了')
}

这里体现了两个关键现象:

  • 函数声明提升showName 不仅声明被提升,函数体也被提升,因此可以在定义前调用。
  • 变量提升(仅声明)var myname 的声明被提升到顶部,但赋值仍在原位置执行,所以首次 console.log 输出 undefined

这就是经典的 hoisting(变量提升) 机制。它源于 JS 引擎的两阶段执行模型:编译阶段收集声明,执行阶段进行赋值和调用。

⚠️ 变量提升虽解决了早期 JS 的作用域问题,但也带来了不符合直觉的行为,被视为语言设计上的缺陷。


二、作用域链:全局 vs 局部

2.js 中:

var globalVar = '我是全局变量'

function myFunction(){
  var localVar = '我是局部变量'
  console.log(globalVar) // ✅ 打印全局变量
  console.log(localVar)  // ✅ 打印局部变量
}

myFunction()
console.log(globalVar) // ✅
console.log(localVar)  // ❌ ReferenceError

这展示了 作用域链 的查找规则:

  • 函数内部优先查找局部作用域
  • 若未找到,则沿作用域链向上查找至全局作用域
  • 全局无法访问函数内部的局部变量

这是 JS 作用域的基本规则,也是封装和避免命名冲突的基础。


三、var 的致命伤:不支持块级作用域

来看 3.js

var name = '刘锦苗'

function showName(){
  console.log(name) // undefined
  if(false){
    var name = '大厂的苗子'
  }
  console.log(name) // undefined
}

尽管 if(false) 块永远不会执行,但 var name 仍被提升到函数作用域顶部,导致函数内 name 被初始化为 undefined。这是因为 var 不支持块级作用域,其声明会被提升到最近的函数或全局作用域。

对比 4.js 使用 let

var name = '刘锦苗'

function showName() {
  console.log(name) // '刘锦苗'
  if (false) {
    let name = '大厂的苗子' // ❌ 不会影响外层
  }
}

由于 let 具有块级作用域if 内的 name 仅在该块中有效,不会污染函数作用域,因此外层仍能正确访问全局变量。


四、let/const 如何解决提升问题?

8.js 展示了一个关键特性:

let name = '刘锦苗'

{
  console.log(name) // ❌ ReferenceError: Cannot access 'name' before initialization
  let name = '大厂的苗子'
}

这里报错并非因为变量未声明,而是进入了 暂时性死区(Temporal Dead Zone, TDZ)
let/const 虽然也会“提升”,但不会像 var 那样初始化为 undefined,而是在声明前处于不可访问状态。

这正是 ES6 对变量提升缺陷的修正:提升存在,但禁止提前访问


五、执行上下文视角:变量环境 vs 词法环境

现代 JS 引擎(如 V8)通过 执行上下文(Execution Context) 统一管理变量:

  • 变量环境 :存放 var 声明的变量。
  • 词法环境 :存放 let/const 声明的变量,并支持块级作用域栈结构

7.js 为例:

function foo(){
  var a = 1
  let b = 2
  {
    let b = 3  // 新的 b,与外层无关
    var c = 4
    let d = 5
    console.log(a) // 1(从变量环境中找到)
    console.log(b) // 3(当前块级作用域栈顶)
  }
  console.log(b) // 2(块级作用域出栈,恢复外层 b)
  console.log(c) // 4(var 提升到函数作用域)
  console.log(d) // ❌ ReferenceError(d 已随块级作用域销毁)
}

这里的关键在于:

  • let 在块级作用域中创建独立的绑定,块执行完后自动出栈销毁;
  • var 无视块级作用域,始终属于函数或全局作用域;
  • 引擎通过词法环境的栈结构实现了对块级作用域的支持。

六、为什么早期 JS 要这样设计?

JavaScript 最初是“KPI 项目” ,设计周期极短,目标只是给网页加点动态效果。为了快速实现,设计者选择了最简单的方案:

  • 不引入复杂的块级作用域;
  • 用“变量提升”统一处理作用域问题;
  • 用函数模拟“类”,规避面向对象的复杂性。

这种设计在当时够用,但随着 JS 应用复杂度飙升,var 的缺陷日益凸显——变量覆盖、生命周期混乱、难以调试。

ES6 引入 let/const 和块级作用域,正是对这一历史问题的修复。


结语:拥抱 let/const,理解执行上下文

如今,我们应优先使用 letconst,避免 var 带来的陷阱。同时,理解 JS 引擎如何通过 变量环境 + 词法环境 的双轨机制,兼容新旧语法,是深入掌握作用域的关键。

JavaScript 的演进告诉我们:好的语言设计,既要向前兼容,也要勇于修正过去的错误

通过这几段小代码,我们不仅看到了变量提升的“坑”,更见证了 JS 如何在保持灵活性的同时,逐步走向严谨与规范。希望这篇文章能帮你理清思路,在掘金社区分享你的成长!

Vue 3 + Vite + Router + Pinia + Element Plus + Monorepo + qiankun 构建企业级中后台前端框架

作者 雅痞_yuppie
2025年11月28日 09:51

Vue 3 + Vite + Monorepo + Qiankun 微前端搭建指南

目录

  1. 环境准备
  2. 初始化 Monorepo 项目
  3. 安装公共依赖
  4. 配置代码规范和 TypeScript
  5. 创建共享库
  6. 创建主应用 (apps/main)
  7. 创建微应用 (apps/app1)
  8. 配置主应用和微应用的路由
  9. 测试和运行
  10. 构建和部署

一、技术选型考量

  1. 核心技术栈确定
  • Vue 3:作为核心框架,其组合式 API、更好的 TypeScript 支持以及优异的性能,为中后台项目的复杂逻辑处理和组件复用提供了强大基础。
  • Vite:替代传统的 Webpack 构建工具,以其极速的冷启动、按需编译和热更新能力,显著提升开发效率,尤其适合大型项目的开发流程。
  • Vue Router 4:与 Vue 3 深度适配,提供了更灵活的路由配置方式,支持动态路由、嵌套路由等功能,满足中后台系统复杂的页面跳转需求。
  • Pinia:作为 Vuex 的替代方案,Pinia 具有更简洁的 API 设计、更好的 TypeScript 兼容性,同时支持多 Store 架构,便于状态的模块化管理。
  • Element Plus:基于 Vue 3 的 UI 组件库,提供了丰富的中后台常用组件,如表格、表单、弹窗等,能够快速搭建美观、易用的界面。
  • Monorepo:采用单一代码仓库管理多个相关项目,实现代码共享、依赖统一管理,简化团队协作流程,尤其适合多项目、多模块的中后台系统。
  • qiankun:微前端框架,能够将多个独立的前端应用整合为一个整体,实现应用间的无缝切换、通信和资源共享,满足中后台系统的业务拆分和整合需求。
  1. 技术栈优势互补 这些技术的组合形成了强大的优势互补。Vue 3 和 Vite 保证了项目的性能和开发体验;Router 和 Pinia 实现了页面路由和状态的高效管理;Element Plus 加速了 UI 开发;Monorepo 优化了项目的组织和协作方式;qiankun 则为系统的微前端架构提供了支持,使得各业务模块可以独立开发、部署和维护,同时又能有机地结合在一起。

整体架构概览

我们将创建以下目录结构:

vue3-monorepo-qiankun/
├── apps/                # 应用目录
│   ├── main/            # 主应用 (qiankun 容器)
│   └── app1/            # 微应用 1
├── packages/            # 共享库目录
│   ├── components/      # 共享组件库
│   ├── hooks/           # 共享 Hooks 库
│   └── utils/           # 共享工具库
├── package.json         # 根项目配置
├── pnpm-workspace.yaml  # pnpm 工作区配置
└── tsconfig.json        # 全局 TypeScript 配置

第一步:环境准备

确保你已经安装了 pnpmnode (推荐 v16+)。

npm install -g pnpm

第二步:初始化 Monorepo 项目

  1. 创建并进入项目根目录

    mkdir -p vue3-monorepo-qiankun && cd vue3-monorepo-qiankun
    
  2. 初始化 package.json

    pnpm init -y
    
  3. 创建 pnpm-workspace.yaml 文件

    cat > pnpm-workspace.yaml << EOF
    packages:
      - 'apps/*'
      - 'packages/*'
    EOF
    
  4. 创建 .npmrc 文件 (推荐) 这个文件用于配置 pnpm 的行为,让它更像传统的 node_modules 结构,方便某些工具识别。

    cat > .npmrc << EOF
    shamefully-hoist=true
    EOF
    

第三步:安装公共依赖

这是本方案的核心。我们将所有共享的运行时依赖和开发依赖都安装在根目录。

  1. 安装公共运行时依赖 这些是主应用和微应用都需要用到的库,如 vue, element-plus 等。

    pnpm add -w vue vue-router pinia element-plus @element-plus/icons-vue axios qiankun
    

    说明: -w--workspace-root 是关键,它告诉 pnpm 把依赖安装到工作区的根目录,而不是当前目录(虽然这里我们就在根目录)。

  2. 安装公共开发依赖 这些是构建、 lint、测试等工具,如 vite, typescript, eslint 等。

    pnpm add -Dw @vitejs/plugin-vue @vitejs/plugin-vue-jsx vite typescript vue-tsc @types/node eslint prettier eslint-config-prettier eslint-plugin-prettier eslint-plugin-vue vue-eslint-parser @typescript-eslint/eslint-plugin @typescript-eslint/parser vite-plugin-qiankun
    

执行完毕后,你会发现根目录的 package.json 中已经包含了所有这些依赖,并且根目录下出现了一个 node_modules 文件夹。


第四步: 配置代码规范和 TypeScript

  1. 创建 tsconfig.json 这个文件为整个工作区提供基础的 TypeScript 配置,子项目可以继承它。

    cat > tsconfig.json << EOF
    {
      "compilerOptions": {
        "target": "ESNext",
        "useDefineForClassFields": true,
        "module": "ESNext",
        "moduleResolution": "Node",
        "strict": true,
        "jsx": "preserve",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "esModuleInterop": true,
        "lib": ["ESNext", "DOM"],
        "skipLibCheck": true,
        "noEmit": true,
        "baseUrl": ".",
        "paths": {
          "@/*": ["src/*"]
        }
      },
      "include": ["**/*.ts", "**/*.tsx", "**/*.vue"],
      "exclude": ["node_modules", "**/dist"]
    }
    EOF
    
  2. 创建 ESLint 和 Prettier 配置 在根目录创建这些配置文件,可以让所有子项目共享同一套代码规范。

    # 创建 .eslintrc.js
    cat > .eslintrc.js << EOF
    module.exports = {
      root: true,
      env: {
        browser: true,
        es2021: true,
        node: true,
      },
      extends: [
        'eslint:recommended',
        'plugin:vue/vue3-essential',
        'plugin:@typescript-eslint/recommended',
        'plugin:prettier/recommended',
      ],
      parser: 'vue-eslint-parser',
      parserOptions: {
        ecmaVersion: 'latest',
        parser: '@typescript-eslint/parser',
        sourceType: 'module',
      },
      plugins: ['vue', '@typescript-eslint', 'prettier'],
      rules: {
        'prettier/prettier': 'error',
        'vue/no-unused-vars': 'error',
        '@typescript-eslint/no-unused-vars': 'error',
        '@typescript-eslint/explicit-module-boundary-types': 'off',
      },
    };
    EOF
    
    # 创建 .prettierrc
    cat > .prettierrc << EOF
    {
      "printWidth": 100,
      "tabWidth": 2,
      "useTabs": false,
      "semi": true,
      "singleQuote": true,
      "quoteProps": "as-needed",
      "trailingComma": "es5",
      "bracketSpacing": true,
      "arrowParens": "avoid",
      "endOfLine": "auto",
      "vueIndentScriptAndStyle": false
    }
    EOF
    
    # 创建 .eslintignore 和 .prettierignore
    cat > .eslintignore << EOF
    node_modules/
    dist/
    *.d.ts
    EOF
    cp .eslintignore .prettierignore
    
  3. 更新根目录 package.jsonscripts 添加一些方便在根目录运行的脚本,比如全局 lint。

    {
      "scripts": {
        "dev": "pnpm -r dev",
        "build": "pnpm -r build",
        "lint": "eslint . --ext .vue,.js,.ts",
        "format": "prettier --write .",
        "clean": "pnpm -r --delete node_modules && rm -rf node_modules"
      }
    }
    

第五步:创建共享库 (packages)

共享库将直接使用根目录的依赖,它们自己的 package.json 只需要声明依赖即可。

5.1 公共工具库 (packages/utils)

  1. 创建目录并初始化

    mkdir -p packages/utils/src && cd packages/utils
    pnpm init -y
    
  2. 修改 package.json 关键是 dependencies 字段,我们声明需要 vue,但 pnpm 会自动从根目录查找。

    {
      "name": "@your-org/utils",
      "version": "1.0.0",
      "type": "module",
      "main": "src/index.ts",
      "types": "src/index.ts",
      "scripts": {
        "lint": "eslint . --ext .ts"
      },
      "dependencies": {
        "vue": "^3.4.21"
      }
    }
    
  3. 创建 tsconfig.json 继承根目录的配置。

    {
      "extends": "../../tsconfig.json",
      "compilerOptions": {
        "composite": true
      },
      "include": ["src/**/*.ts", "src/**/*.d.ts"]
    }
    
  4. 编写工具函数 创建 src/format.ts 并在 src/index.ts 中导出

    • src/format.ts
      export function formatDate(date: Date, fmt = 'YYYY-MM-DD HH:mm:ss') {
        const o = {
          'M+': date.getMonth() + 1,
          'D+': date.getDate(),
          'H+': date.getHours(),
          'm+': date.getMinutes(),
          's+': date.getSeconds(),
          'q+': Math.floor((date.getMonth() + 3) / 3),
          S: date.getMilliseconds()
        };
        if (/(Y+)/.test(fmt)) {
          fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length));
        }
        for (const k in o) {
          if (new RegExp('(' + k + ')').test(fmt)) {
            fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k as keyof typeof o]) : (('00' + o[k as keyof typeof o]).substr(('' + o[k as keyof typeof o]).length)));
          }
        }
        return fmt;
      }
      
    • src/index.ts
      export * from './format';
      

5.2 公共 Hooks 库 (packages/hooks)

  1. 创建目录并初始化

    cd ../../ && mkdir -p packages/hooks/src && cd packages/hooks
    pnpm init -y
    
  2. 修改 package.json

    {
      "name": "@your-org/hooks",
      "version": "1.0.0",
      "type": "module",
      "main": "src/index.ts",
      "types": "src/index.ts",
      "scripts": {
        "lint": "eslint . --ext .ts,.vue"
      },
      "dependencies": {
        "vue": "^3.4.21"
      }
    }
    
  3. 创建 tsconfig.json

    {
      "extends": "../../tsconfig.json",
      "compilerOptions": {
        "composite": true
      },
      "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
    }
    
  4. 编写 Hooks

    • src/useStorage.ts
      import { ref, watch, type Ref } from 'vue';
      
      export function useStorage<T>(key: string, defaultValue: T): Ref<T> {
        const storedValue = localStorage.getItem(key);
        const value = ref<T>(storedValue ? JSON.parse(storedValue) : defaultValue);
      
        watch(value, (newVal) => {
          localStorage.setItem(key, JSON.stringify(newVal));
        }, { deep: true });
      
        return value;
      }
      
    • src/index.ts
      export * from './useStorage';
      

5.3 公共 UI 组件库 (packages/components)

  1. 创建目录并初始化

    cd ../../ && mkdir -p packages/components/src && cd packages/components
    pnpm init -y
    
  2. 修改 package.json

    {
      "name": "@your-org/components",
      "version": "1.0.0",
      "type": "module",
      "main": "src/index.ts",
      "types": "src/index.ts",
      "scripts": {
        "lint": "eslint . --ext .ts,.vue"
      },
      "dependencies": {
        "vue": "^3.4.21",
        "element-plus": "^2.7.2"
      }
    }
    
  3. 创建 tsconfig.json

    {
      "extends": "../../tsconfig.json",
      "compilerOptions": {
        "composite": true
      },
      "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
    }
    
  4. 编写 UI 组件

    • src/CommonButton/CommonButton.vue
      <template>
        <el-button :type="type" :loading="loading" @click="onClick">
          <slot></slot>
        </el-button>
      </template>
      
      <script setup lang="ts">
      import { defineProps, emit } from 'vue';
      
      const props = defineProps<{
        type?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
        loading?: boolean;
      }>();
      
      const emit = defineEmits<{
        (e: 'click'): void;
      }>();
      
      const onClick = () => {
        emit('click');
      };
      </script>
      
    • src/index.ts
      export { default as CommonButton } from './CommonButton/CommonButton.vue';
      

5.4 公共请求封装 (packages/request)

  1. 创建目录并初始化

    cd ../../ && mkdir -p packages/request/src && cd packages/request
    pnpm init -y
    
  2. 修改 package.json

    {
      "name": "@your-org/request",
      "version": "1.0.0",
      "type": "module",
      "main": "src/index.ts",
      "types": "src/index.ts",
      "scripts": {
        "lint": "eslint . --ext .ts"
      },
      "dependencies": {
        "axios": "^1.6.8",
        "vue": "^3.4.21"
      }
    }
    
  3. 创建 tsconfig.json

    {
      "extends": "../../tsconfig.json",
      "compilerOptions": {
        "composite": true
      },
      "include": ["src/**/*.ts", "src/**/*.d.ts"]
    }
    
  4. 编写请求封装

    • src/index.ts
      import axios from 'axios';
      
      const service = axios.create({
        baseURL: import.meta.env.VITE_API_BASE_URL,
        timeout: 5000
      });
      
      // 请求拦截器
      service.interceptors.request.use(
        (config) => {
          // 可以添加 token 等逻辑
          return config;
        },
        (error) => {
          return Promise.reject(error);
        }
      );
      
      // 响应拦截器
      service.interceptors.response.use(
        (response) => {
          return response.data;
        },
        (error) => {
          return Promise.reject(error);
        }
      );
      
      export default service;
      

第六步:创建主应用 (apps/main)

主应用是一个标准的 Vite 应用,但它的依赖也将由根目录提供。

  1. 创建目录并初始化 我们使用 --template,但之后会修改依赖。

    cd ../../ && mkdir -p apps/main && cd apps/main
    pnpm create vite@latest . --template vue-ts
    rm -rf node_modules pnpm-lock.yaml
    

    执行后,根据提示操作,然后删除 apps/main 目录下的 node_modules 和 pnpm-lock.yaml 文件,因为我们将使用根目录的依赖。

  2. 修改 package.json 删除 dependenciesdevDependencies 下的所有依赖,然后根据需要重新声明它们。pnpm 会自动从根目录链接。

    {
      "name": "@your-org/main",
      "private": true,
      "version": "0.0.0",
      "type": "module",
      "scripts": {
        "dev": "vite",
        "build": "vue-tsc && vite build",
        "lint": "eslint . --ext .vue,.js,.ts",
        "preview": "vite preview"
      },
      "dependencies": {
        "@element-plus/icons-vue": "^2.3.1",
        "@your-org/components": "workspace:^*",
        "@your-org/hooks": "workspace:^*",
        "@your-org/utils": "workspace:^*",
        "@your-org/request": "workspace:^*",
        "element-plus": "^2.7.2",
        "pinia": "^2.1.7",
        "qiankun": "^2.10.16",
        "vue": "^3.4.21",
        "vue-router": "^4.3.0"
      },
      "devDependencies": {
        "@vitejs/plugin-vue": "^5.0.4",
        "@vitejs/plugin-vue-jsx": "^3.1.0",
        "vite": "^5.2.11",
        "vite-plugin-qiankun": "^1.0.11",
        "vue-tsc": "^1.8.27"
      }
    }
    

    注意: "@your-org/utils": "workspace:^*" 是引用工作区内部包的标准方式。

  3. 修改 vite.config.ts 主应用作为 qiankun 容器,需要配置 vite-plugin-qiankun。

    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import vueJsx from '@vitejs/plugin-vue-jsx'
    import qiankun from 'vite-plugin-qiankun'
    import path from 'path'
    
    export default defineConfig({
      plugins: [
        vue(),
        vueJsx(),
        qiankun('main-app', { useDevMode: true })
      ],
      resolve: {
        alias: {
          '@': path.resolve(__dirname, 'src')
        },
        preserveSymlinks: true,
        modules: [
          path.resolve(__dirname, 'node_modules'),
          path.resolve(__dirname, '../../node_modules')
        ]
      },
      server: {
        port: 8080,
        open: true,
        cors: true
      }
    })
    
  4. 修改 tsconfig.json 继承根目录的配置

    {
      "extends": "../../tsconfig.json",
      "compilerOptions": {
        "composite": true
         "baseUrl": ".", // 基础路径
         "paths": {
           "@/*": ["src/*"] // 映射 @/ 到 src/
         }
      },
      "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
    }
    
  5. 编写主应用代码

    • src/router/index.ts
      import { createRouter, createWebHistory } from 'vue-router'
      import Home from '@/views/Home/index.vue'
      import MicroApp from '@/views/MicroApp/index.vue'
      
      const routes = [
        { path: '/', component: Home },
        { path: '/app1', component: MicroApp }
      ]
      
      const router = createRouter({
        history: createWebHistory(),
        routes
      })
      
      export default router
      
    • src/main.ts
      import { createApp } from 'vue'
      import App from './App.vue'
      import router from './router'
      import { createPinia } from 'pinia'
      import ElementPlus from 'element-plus'
      import 'element-plus/dist/index.css'
      import * as ElementPlusIconsVue from '@element-plus/icons-vue'
      import { CommonButton } from '@your-org/components'
      
      const app = createApp(App)
      
      for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
        app.component(key, component)
      }
      
      app.component('CommonButton', CommonButton)
      app.use(createPinia())
      app.use(router)
      app.use(ElementPlus)
      
      app.mount('#app')
      
      // 暴露全局变量给微应用
      (window as any).Vue = app.config.globalProperties.constructor
      (window as any).ElementPlus = ElementPlus
      (window as any).Pinia = app.config.globalProperties.$pinia
      (window as any).VueRouter = router
      
    • src/views/MicroApp/index.vue
      <template>
        <div id="micro-app-container"></div>
      </template>
      
      <script setup lang="ts">
      import { onMounted, onUnmounted } from 'vue';
      import { loadMicroApp, unmountMicroApp } from 'qiankun';
      
      let microApp: any = null;
      
      onMounted(() => {
        microApp = loadMicroApp({
          name: 'app1',
          entry: '//localhost:8081',
          container: '#micro-app-container',
          props: { token: 'main-app-token' }
        })
      });
      
      onUnmounted(() => {
        unmountMicroApp('app1');
      });
      </script>
      

第七步:创建微应用 (apps/app1)

微应用的配置与主应用类似,但有一个关键区别:当它被 qiankun 加载时,需要通过 externals 排除已由主应用提供的依赖

  1. 创建目录并初始化 同样,删除自动生成的 node_modulespnpm-lock.yaml

    cd ../../ && mkdir -p apps/app1 && cd apps/app1
    pnpm create vite@latest . --template vue-ts
    rm -rf node_modules pnpm-lock.yaml
    
  2. 修改 package.json 与主应用类似,声明依赖

    {
      "name": "@your-org/app1",
      "private": true,
      "version": "0.0.0",
      "type": "module",
      "scripts": {
        "dev": "vite",
        "dev:qiankun": "vite --mode qiankun",
        "build": "vue-tsc && vite build",
        "build:qiankun": "vue-tsc && vite build --mode qiankun",
        "lint": "eslint . --ext .vue,.js,.ts",
        "preview": "vite preview"
      },
      "dependencies": {
        "@element-plus/icons-vue": "^2.3.1",
        "@your-org/components": "workspace:^*",
        "@your-org/hooks": "workspace:^*",
        "@your-org/utils": "workspace:^*",
        "@your-org/request": "workspace:^*",
        "element-plus": "^2.7.2",
        "pinia": "^2.1.7",
        "vue": "^3.4.21",
        "vue-router": "^4.3.0"
      },
      "devDependencies": {
        "@vitejs/plugin-vue": "^5.0.4",
        "@vitejs/plugin-vue-jsx": "^3.1.0",
        "vite": "^5.2.11",
        "vite-plugin-qiankun": "^1.0.11",
        "vue-tsc": "^1.8.27"
      }
    }
    
  3. 修改 vite.config.ts 这里是核心,我们需要根据运行模式来动态配置 build.rollupOptions.externals

    import { defineConfig, loadEnv } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import vueJsx from '@vitejs/plugin-vue-jsx'
    import qiankun from 'vite-plugin-qiankun'
    import path from 'path'
    
    export default defineConfig(({ mode }) => {
      const env = loadEnv(mode, process.cwd())
      const isQiankunMode = env.VITE_QIANKUN === 'true'
    
      return {
        plugins: [
          vue(),
          vueJsx(),
          qiankun('app1', { useDevMode: !isQiankunMode })
        ],
        resolve: {
          alias: {
            '@': path.resolve(__dirname, 'src')
          },
          preserveSymlinks: true,
          modules: [
            path.resolve(__dirname, 'node_modules'),
            path.resolve(__dirname, '../../node_modules')
          ]
        },
        server: {
          port: 8081,
          open: true,
          cors: true
        },
        base: isQiankunMode ? '/app1/' : '/',
        build: {
          rollupOptions: {
            external: isQiankunMode ? [
              'vue', 'vue-router', 'pinia', 'element-plus', 'axios',
              '@element-plus/icons-vue', '@your-org/utils', '@your-org/hooks', '@your-org/components', '@your-org/request'
            ] : [],
            output: {
              globals: isQiankunMode ? {
                vue: 'Vue',
                'vue-router': 'VueRouter',
                pinia: 'Pinia',
                'element-plus': 'ElementPlus',
                axios: 'axios',
                '@element-plus/icons-vue': 'ElementPlusIconsVue',
                '@your-org/utils': 'YourOrgUtils',
                '@your-org/hooks': 'YourOrgHooks',
                '@your-org/components': 'YourOrgComponents',
                '@your-org/request': 'YourOrgRequest'
              } : {}
            }
          }
        }
      }
    })
    
  4. 创建 apps/app1/.env.qiankun 创建 apps/app1/.env.qiankun 文件,用于 qiankun 模式

    cat > .env.qiankun << EOF
    NODE_ENV=development
    VITE_QIANKUN=true
    EOF
    
  5. 修改 tsconfig.json 同样继承根目录配置

    {
      "extends": "../../tsconfig.json",
      "compilerOptions": {
        "composite": true,
        "baseUrl": ".", // 基础路径
         "paths": {
           "@/*": ["src/*"] // 映射 @/ 到 src/
         }
      },
      "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
    }
    
  6. 编写微应用代码 微应用的入口文件 src/main.ts 需要遵循 qiankun 的协议,导出 bootstrap, mount, unmount 三个生命周期函数。

    • src/router/index.ts
      import { createRouter, createWebHistory } from 'vue-router'
      import Home from '@/views/Home/index.vue'
      import List from '@/views/List/index.vue'
      
      const routes = [
        { path: '/', redirect: '/home' },
        { path: '/home', component: Home },
        { path: '/list', component: List }
      ]
      
      const router = createRouter({
        history: createWebHistory(import.meta.env.BASE_URL),
        routes
      })
      
      export default router
      
    • src/main.ts
      import { createApp } from 'vue'
      import App from './App.vue'
      import router from './router'
      import { createPinia } from 'pinia'
      import ElementPlus from 'element-plus'
      import 'element-plus/dist/index.css'
      import * as ElementPlusIconsVue from '@element-plus/icons-vue'
      import { CommonButton } from '@your-org/components'
      import request from '@your-org/request'
      
      let app: any = null
      
      function render(props: any = {}) {
        const { container } = props
        app = createApp(App)
      
        for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
          app.component(key, component)
        }
      
        app.config.globalProperties.$request = request
        app.component('CommonButton', CommonButton)
        app.use(createPinia())
        app.use(router)
        app.use(ElementPlus)
      
        app.mount(container ? container.querySelector('#app') : '#app')
      }
      
      if (!(window as any).__POWERED_BY_QIANKUN__) {
        render()
      }
      
      export async function bootstrap() {
        console.log('微应用 app1 启动')
      }
      
      export async function mount(props: any) {
        console.log('微应用 app1 挂载', props)
        render(props)
      }
      
      export async function unmount() {
        console.log('微应用 app1 卸载')
        app.unmount()
        app = null
      }
      

    重要: 主应用需要在全局 (window) 上挂载微应用 externals 中声明的那些库,例如 window.Vue = Vue,这样微应用在运行时才能找到它们。这部分逻辑通常放在主应用的 src/main.ts 中。


第八步:配置主应用和微应用的路由

  • 主应用路由:负责加载微应用(如 /app1 路径)。
  • 微应用路由:内部路由相对独立(如 /home/list),会被 qiankun 自动拼接为 /app1/home/app1/list

第九步:测试和运行

  1. 安装所有依赖

    cd ../../..
    pnpm install
    
  2. 安装所有依赖 这会根据所有 package.json 的声明,从根目录统一安装和链接。

    pnpm install
    
  3. 启动项目

    # 同时启动所有应用
    pnpm dev
    
    # 或者单独启动
    pnpm --filter @your-org/main dev
    pnpm --filter @your-org/app1 dev:qiankun
    
  4. 访问地址


第十步. 构建和部署

  1. 构建项目

    pnpm build
    
  2. 部署配置(Nginx 示例)

    server {
      listen 80;
      server_name your-domain.com;
      root /path/to/vue3-monorepo-qiankun/apps/main/dist;
    
      location / {
        try_files $uri $uri/ /index.html;
      }
    
      location /app1 {
        alias /path/to/vue3-monorepo-qiankun/apps/app1/dist;
        try_files $uri $uri/ /app1/index.html;
      }
    }
    

总结

通过以上步骤,你已经成功搭建了一个完整的 Vue 3 + Vite + Monorepo + Qiankun 微前端项目,包括公共工具库、Hooks 库、UI 组件库和请求封装。所有公共依赖都提取到了根目录进行统一管理,实现了代码复用和版本统一。

🥁 用 HTML5 打造你的第一个“敲击乐” Web 应用

作者 今日无bug
2025年11月28日 01:45

在前端开发的世界里,HTML 是骨架,CSS 是皮肤,JavaScript 是灵魂。今天,我们将通过一个趣味十足的小项目——HTML5 敲击乐(Drum Kit) ,深入理解现代 Web 应用的构建逻辑:结构清晰、样式优雅、交互流畅,并实现真正的移动端适配。


一、项目目标:一个会“发声”的网页

想象一下:点击页面上的不同按键(如 Q、W、E、A、S 等),就能播放对应的鼓点或音效。这不仅是一个有趣的交互实验,更是对 HTML5 Audio API + 响应式布局 + 模块化开发思想 的综合实践。

而这一切,从一个静态页面开始。


二、HTML5 结构:语义化与职责分离

2.1 静态页面 = HTML + CSS

浏览器加载页面时:

  1. 下载并解析 HTML,构建 DOM 树;
  2. 遇到 <link> 标签,异步加载 CSS,构建 CSSOM;
  3. DOM + CSSOM 合并为 Render Tree,渲染出静态页面
  4. 最后执行 <script> 中的 JavaScript,赋予页面交互能力

最佳实践

  • CSS 放在 <head> 中,确保样式尽早加载;
  • JS 放在 </body> 前,避免阻塞 HTML 解析。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>HTML5 敲击乐</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="container">
    <div class="key" data-key="81">Q</div> <!-- ASCII: Q=81 -->
    <div class="key" data-key="87">W</div>
    <div class="key" data-key="69">E</div>
    <!-- 更多按键... -->
  </div>
  <script src="app.js"></script>
</body>
</html>

💡 使用 data-key 存储键盘 keyCode,便于 JS 绑定事件。


三、CSS 样式:从 Reset 到响应式

3.1 为什么需要 CSS Reset?

不同浏览器对 <h1><ul><button> 等元素有默认样式差异(如 margin、padding)。若不统一,会导致跨浏览器显示不一致。

避免使用 * { margin: 0; padding: 0 } —— 虽然简单,但性能差(匹配所有元素),且可能误伤第三方组件。

✅ 推荐做法:显式列出常用标签重置

/* style.css */
html, body, div, span, h1, h2, p, ul, li {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

box-sizing: border-box 让 width 包含 padding 和 border,布局更可控。


3.2 背景图与单位选择:为移动端而生

我们的“敲击乐”界面可以有一个炫酷背景:

body {
  background: url('drum-bg.jpg') no-repeat center bottom;
  background-size: cover; /* 覆盖整个视口,可能裁剪 */
  /* 或用 contain:完整显示图片,但可能留白 */
  min-height: 100vh;
  font-family: sans-serif;
}

关于单位:

  • px:绝对单位,不随设备变化 → 不适合移动端
  • rem:相对于 <html>font-size
  • vh/vw:相对于视口高度/宽度(1vh = 1% 视口高度)。

移动端适配黄金法则
设置 html { font-size: 10px; },则 1rem = 10px,计算方便且可缩放。

html {
  font-size: 10px;
}

.container {
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  gap: 2rem; /* 20px,但响应式 */
}

3.3 Flex 布局:让按键自动居中

我们希望 9 个 .key 按键在屏幕中央,无论手机还是平板:

.key {
  width: 15rem;      /* 150px 基准,但可随根字体缩放 */
  height: 15rem;
  background: rgba(255, 255, 255, 0.9);
  border-radius: 1rem;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 4rem;
  font-weight: bold;
  cursor: pointer;
  transition: transform 0.1s ease;
}

.key:active {
  transform: scale(0.95);
}

🔥 display: flex + justify-content: center + align-items: center = 万能居中大法

即使设备宽度变化,Flex 也能智能排列(配合 flex-wrap: wrap 可换行)。


四、JavaScript 交互:让页面“发声”

4.1 使用 HTML5 Audio API

每个按键对应一个音频文件(如 clap.wav, hihat.wav):

// app.js
const sounds = {
  81: new Audio('sounds/clap.wav'),   // Q
  87: new Audio('sounds/hihat.wav'),  // W
  69: new Audio('sounds/kick.wav'),   // E
  // ...其他键
};

function playSound(e) {
  const keyCode = e.type === 'keydown' ? e.keyCode : parseInt(e.target.dataset.key);
  const sound = sounds[keyCode];
  if (sound) {
    sound.currentTime = 0; // 重置播放位置
    sound.play();
  }
}

// 键盘按下
window.addEventListener('keydown', playSound);

// 鼠标点击
document.querySelectorAll('.key').forEach(key => {
  key.addEventListener('click', playSound);
});

⚠️ 注意:现代浏览器要求用户主动交互(点击/按键)后才能播放音频,防止自动播放骚扰。


4.2 增强反馈:点击动画 + 音效同步

我们可以给按键添加“按下”效果:

function addEffect(keyCode) {
  const key = document.querySelector(`.key[data-key="${keyCode}"]`);
  if (key) key.classList.add('playing');
  setTimeout(() => {
    if (key) key.classList.remove('playing');
  }, 100);
}

配合 CSS:

.key.playing {
  transform: scale(0.9);
  box-shadow: 0 0 20px rgba(0, 100, 255, 0.7);
}

这样,视觉 + 听觉双重反馈,体验更沉浸。


五、响应式与性能优化

5.1 移动端触控支持

除了鼠标点击,还需监听 touchstart

document.querySelectorAll('.key').forEach(key => {
  key.addEventListener('touchstart', (e) => {
    e.preventDefault(); // 阻止默认滚动等行为
    playSound(e);
  });
});

5.2 预加载音频(可选)

为避免首次点击延迟,可提前加载:

Object.values(sounds).forEach(sound => sound.load());

六、总结:从前端基础到工程思维

通过这个“敲击乐”小项目,我们实践了:

技术点 实践价值
HTML 语义化 结构清晰,利于 SEO 与可访问性
CSS Reset 消除浏览器差异,打下一致基础
Flex 布局 实现复杂居中与自适应排列
rem / vh 单位 真正响应式,适配各种手机
模块化引入 CSS 在 head,JS 在 body 底部
HTML5 Audio 原生音效,无需插件
事件委托 & 用户交互 键盘 + 鼠标 + 触屏全覆盖

🎯 前端的天职不是炫技,而是:快速、稳定、一致地呈现内容,并提供流畅交互。

这个项目虽小,却涵盖了现代 Web 开发的核心理念:关注用户体验、拥抱响应式、坚持职责分离


七、延伸思考

  • 能否加入“录音”功能,录制一段节奏?
  • 能否用 Web MIDI API 连接真实电子鼓?
  • 能否用 Service Worker 实现离线使用?

技术无止境,但从一个静态页面开始,一步步赋予它生命,正是前端开发的魅力所在。


❌
❌