普通视图

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

🌐 阿里云 Linux 服务器 Let's Encrypt 免费 SSL 证书完整部署指南

2025年11月23日 13:36

🌐 阿里云 Linux 服务器 Let's Encrypt 免费 SSL 证书完整部署指南

适用系统:Alibaba Cloud Linux 3(兼容 CentOS/RHEL)
Web 服务器:Nginx
更新时间:2025 年 11 月
作者:DevOps Guide


✅ 一、前提条件

在开始前,请确保满足以下条件:

要求 说明
1. 阿里云 ECS 实例 已创建,操作系统为 Alibaba Cloud Linux 3
2. 域名已解析 yourdomain.com 的 A 记录指向 ECS 公网 IP
3. 安全组开放端口 入方向允许 80 (HTTP)443 (HTTPS)(来源:0.0.0.0/0
4. 域名备案(中国大陆地域) 若 ECS 位于中国内地(如杭州、北京),必须完成 ICP 备案
5. 已安装 Nginx 且能通过 http://yourdomain.com 访问

🔍 验证域名解析:

dig yourdomain.com +short
# 应返回你的 ECS 公网 IP

🚀 二、完整操作流程

步骤 1:安装 Nginx(如未安装)

# 安装 Nginx
sudo dnf install -y nginx

# 启动并设置开机自启
sudo systemctl start nginx
sudo systemctl enable nginx

步骤 2:配置 Nginx 站点(添加 server_name)

sudo vim /etc/nginx/conf.d/yourdomain.com.conf

写入以下内容:

server {
    listen 80;
    server_name yourdomain.com;  # ← 必须包含你要申请证书的域名
    root /usr/share/nginx/html;
    index index.html;
}

测试并重载:

sudo nginx -t
sudo systemctl reload nginx

✅ 此时应能通过浏览器访问 http://yourdomain.com


步骤 3:安装 Certbot

# 安装 EPEL 仓库
sudo dnf install -y epel-release

# 安装 Certbot 及 Nginx 插件
sudo dnf install -y certbot python3-certbot-nginx

步骤 4:申请并安装 SSL 证书

sudo certbot --nginx -d yourdomain.com

执行时会提示:

  • 输入邮箱(用于过期提醒)
  • 同意服务条款(按 A
  • 是否重定向 HTTP → HTTPS(建议选 Yes

✅ 成功后,Nginx 会自动启用 HTTPS,访问 https://yourdomain.com 应显示安全锁图标。


步骤 5:验证证书信息

sudo certbot certificates

输出示例:

Certificate Name: yourdomain.com
    Expiry Date: 2026-02-20 12:34:56+00:00 (VALID: 89 days)
    Certificate Path: /etc/letsencrypt/live/yourdomain.com/fullchain.pem
    Private Key Path: /etc/letsencrypt/live/yourdomain.com/privkey.pem

步骤 6:配置自动续期(关键!)

6.1 测试续期流程(安全,不会真续)
sudo certbot renew --dry-run

✅ 应看到:Congratulations, all simulated renewals succeeded.

6.2 设置定时任务
sudo crontab -e

在打开的编辑器中i 进入插入模式,粘贴以下内容:

0 2 * * * /usr/bin/certbot renew --quiet --post-hook "systemctl reload nginx"

保存退出:

  • ESC
  • 输入 :wq 并回车
6.3 验证 cron 是否设置成功
sudo crontab -l

应输出:

0 2 * * * /usr/bin/certbot renew --quiet --post-hook "systemctl reload nginx"

🔍 三、常见问题排查

❌ 问题 1:申请证书时超时(Timeout during connect)

错误示例

Fetching http://yourdomain.com/.well-known/acme-challenge/...: Timeout

原因与解决

原因 解决方案
阿里云安全组未开 80 端口 控制台 → 安全组 → 添加 80 入站规则
域名未备案(中国内地 ECS) 备案域名,或改用 DNS 验证(见附录)
DNS 未解析到公网 IP 检查 A 记录:dig yourdomain.com
本地防火墙阻止 检查:sudo firewall-cmd --list-ports(Alibaba Cloud Linux 默认关闭)

❌ 问题 2:Certbot 找不到 server_name

错误No matching server blocks located

解决

  • 确保 Nginx 配置中 server_name 精确包含 yourdomain.com
  • 配置文件必须在 /etc/nginx/conf.d/ 或被 nginx.confinclude 包含

❌ 问题 3:自动续期失败

排查步骤

# 查看 cron 执行日志
sudo grep CRON /var/log/cron

# 查看 certbot 日志
sudo tail -n 20 /var/log/letsencrypt/letsencrypt.log

# 手动运行续期(不静默)
sudo certbot renew

📎 附录:替代方案 —— DNS 验证(无需 80 端口)

适用于:未备案域名无法开放 80 端口 的场景

步骤 A:获取阿里云 AccessKey

  1. 进入 RAM 控制台
  2. 创建用户,授权 AliyunDNSFullAccess
  3. 获取 AccessKey IDAccessKey Secret

步骤 B:配置 DNS 插件

# 安装插件
sudo dnf install -y certbot-dns-alidns

# 创建凭证文件
mkdir -p ~/.secrets
cat > ~/.secrets/alidns.ini <<EOF
dns_alidns_access_key = YOUR_ACCESS_KEY_ID
dns_alidns_secret_key = YOUR_ACCESS_KEY_SECRET
EOF

chmod 600 ~/.secrets/alidns.ini

步骤 C:申请证书

sudo certbot certonly \
  --dns-alidns \
  --dns-alidns-credentials ~/.secrets/alidns.ini \
  -d yourdomain.com

步骤 D:手动配置 Nginx HTTPS

编辑 /etc/nginx/conf.d/yourdomain.com.conf,添加:

server {
    listen 443 ssl;
    server_name yourdomain.com;
    root /usr/share/nginx/html;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
}

重载 Nginx:

sudo nginx -t && sudo systemctl reload nginx

⚠️ DNS 验证方式 仍需配置自动续期 cron(同上)


✅ 四、总结

步骤 命令/操作 状态
1. 安装 Nginx sudo dnf install nginx
2. 配置站点 创建 /etc/nginx/conf.d/*.conf
3. 安装 Certbot sudo dnf install certbot...
4. 申请证书 sudo certbot --nginx -d yourdomain.com
5. 设置自动续期 sudo crontab -e + 添加任务
6. 测试续期 sudo certbot renew --dry-run

🎉 完成!你的网站现在拥有免费、自动更新的 HTTPS 加密。


🔗 官方参考

💡 提示:每 3 个月手动运行一次 sudo certbot certificates 检查到期时间,确保万无一失。

游戏框架文档

2025年11月23日 13:31

框架启用和功能介绍

alt

将框架预制体拖入Hierarchy中即可,脚本中使用时using JKframe命名空间,框架的github地址,为了避免框架中UI部分对Scene场景中交互产生干扰,建议把框架交互屏蔽掉。

alt

框架类似工具箱和插件,除了UI窗口外的大多情况下,并不需要继承什么类或接口,直接通过XXXSystem调用即可。主要功能系统:

  1. 对象池系统:重复利用GameObject或普通class实例,并且支持设置对象池容量

  2. 事件系统:解耦工具,不需要持有引用来进行函数的调用

  3. 资源系统

    • Resources版本:关联对象池进行资源的加载卸载
    • Addressables版本:关联对象池进行资源的加载卸载,可结合事件工具做到销毁时自动从Addressables Unload
  4. MonoSystem:为不继承MonoBehaviour的对象提供Update、FixedUpdate、协程等功能

  5. 音效系统:背景音乐、背景音乐轮播、特效音乐、音量全局控制等

  6. 存档系统:

    • 支持多存档
    • 自动缓存,避免频繁读磁盘
    • 存玩家设置类数据,也就是不关联任何一个存档
    • 支持二进制和Json存档,开发时使用Json调试,上线后使用二进制加密与减少文件体积
  7. 日志系统:日志控制、保存等

  8. UI系统:UI窗口的层级管理、Tips功能

  9. 场景系统:对Unity场景加载封装了一层,主要用于监听场景加载进度

  10. 本地化系统:分为全局配置和局部配置(随GameObject加载)、UI自动本地化收集器(Text、Image组件无需代码即可自动本地化)

其他功能:

  1. 状态机:脚本逻辑状态机。
  2. 事件工具: 给物体添加点击、鼠标进入、鼠标拖拽、碰撞、触发、销毁等事件,而不需要额外在该物体上添加脚本等。
  3. 协程工具:协程避免GC。

对象池

在Unity中,对象的生成、销毁都需要性能开销,在一些特定的应用场景下需要进行大量重复物体的克隆,因此需要通过设计对象池来实现重复利用对象实例,减少触发垃圾回收。常用在频繁创建、销毁对象的情况下,比如子弹、AI生成等等、背包格子。

本框架的对象池系统有两类对象池(GameObject对象池和Object对象池)分别负责对需要在场景中实际激活/隐藏的GameObject和不需要显示在场景里的对象(脚本类、材质资源)进行管理。

本框架提供对象池容量的限制,且初始化时,可以预先传入要放入的对象根据默认容量实例化放入对象池,比如场景中默认使用20发子弹,可以在对象池初始化时就实例化好20枚子弹放入对象池。

如有特殊需求,可以通过持有PoolMoudle层来单独构建一个不同于全局对象池PoolSystem的Pool,默认正常情况下使用全局对象池PoolSystem即可。

GameObject对象池(GOP)

用于管理实际存在场景中并出现在Hierarchy窗口上的GameObject对象。

初始化GOP

// API
//根据keyName初始化GOP
//(string keyName, int maxCapacity = -1,GameObject prefab = null,int defaultQuantity = 0)
PoolSystem.InitGameObjectPool(keyName, maxCapacity, prefab, defaultQuanity);
PoolSystem.InitGameObjectPool(keyName, maxCapacity);
PoolSystem.InitGameObjectPool(keyName);
//根据prefab.name初始化GOP
//  //(GameObject prefab = null, int maxCapacity = -1,GameObject prefab = null,int defaultQuantity = 0)
PoolSystem.InitGameObjectPool(prefab, maxCapacity, defaultQuanity);
PoolSystem.InitGameObjectPool(prefab, maxCapacity);
PoolSystem.InitGameObjectPool(prefab);
//根据GameObject数组大小进行默认容量设置,并将数组对象作为默认对象全部置入对象池
//(string keyName, int maxCapacity = -1, GameObject[] gameObjects = null)
PoolSystem.InitGameObjectPool(keyName, maxCapacity, gameObject);


//简单示例
// 设定一个子弹Bullet对象池,最大容量30,默认填满
Gameobject bullet = GameObject.Find("bullet");
PoolSystem.InitGameObjectPool("Bullet", 30, bullet, 30);
PoolSystem.InitGameObjectPool(bullet, 30, 30);


//最简形式
PoolSystem.InitGameObjectPool(“对象池名字”);
  • 通过keyName或者直接传入prefab根据prefab.name 指定对象池的名字。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 初始化并不向构建出的空对象池填入内容,但可通过prefab和defaultQuanity设置默认容量填充空对象池(初始化时会自动按默认容量和最大容量的最小值自动生成GameObject放入对象池)。
  • 可通过传入GameObject数组初始化对象池的默认容量并放入对象填充空对象池。
  • maxCapacity, prefab, defaultQuantity可不填,默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。

将对象放入GOP

// API
//根据keyName/obj.name放入对象池
//(string assetName, GameObject obj)
PoolSystem.PushGameObject(keyName, obj);
PoolSystem.PushGameObject(obj);


//简单示例
//将一个子弹对象bullet放入Bullet对象池
PoolSystem.PushGameObject("Bullet", bullet);


// 扩展方法
bullet.GameObjectPushPool();
  • 通过keyName指定对象池名字放入对象obj,keyName不填则默认对象池名字为obj.name。
  • 封装了拓展方法,可以通过对象.GameObjectPushPool()简便地将GameObject放入对象池。
  • 可以使用拓展方法直接将对象放入同名对象池内。
  • 如果传入的keyName/prefab找不到对应的对象池(未Init),则会直接初始化生成一个同名的,无限容量的对象池并放入本次对象。
  • obj为null时本次放入操作无效,会进行报错提示。

将对象从GOP中取出

// API
//根据keyName加载GameObject
//(string keyName, Transform parent)
PoolSystem.GetGameObject(keyName, parent);
PoolSystem.GetGameObject(keyName);
//根据keyName和T加载GameObject并获取组件,返回值类型为T
PoolSystem.GetGameObject<T>(keyName, parent);
PoolSystem.GetGameObject<T>(keyName);


//简单实例
//将一个子弹对象从对象池中取出
GameObject bullet = PoolSystem.GetGameObject("Bullet");
//将一个子弹对象从对象池中取出并获取其刚体组件
GameObject bullet = PoolSystem.GetGameOjbect<Rigidbody>("Bullet");
  • 通过keyName指定对象池名字取出GameObject对象并设置父物体为parent,parent不填则默认无父物体在最顶层。
  • 可以通过传参获取对象上的某个组件,组件依托于GameObject存在,因此物体此时也已被从对象池中取出。
  • 当某个对象池内无对象时,其对象池仍会被保存,只有通过Clear才能彻底清空对象池。
  • 当对象池中无对象仍要取出时,会返回null。

清空GOP对象池

//API
//清空(GameObject/Object)对象池
//(bool clearGameObject = true, bool clearCSharpObject = true)
PoolSystem.ClearAll(true, false);
//清空GameObject类对象池中keyName索引的对象池
//(string assetName)
PoolSystem.ClearGameObject(keyName);


//简单实例
//清空所有GOP对象池
PoolSystem.ClearAll(true,false);
//清空Bullet对象池
PoolSystem.ClearGameObject("Bullet");
  • ClearAll方法用于清空所有GOP/OP对象池,两个bool参数是否清空GOP、是否清空OP。
  • 清空某一类GOP通过传入keyName对象池名索引。
  • 清空所有对象池时(ClearAll),所有资源都会被释放。
  • 清空某一类对象池时,GameObject中的数据载体和根节点会被放回对象池重复利用(使用时无需关心,底层实现)。

Object对象池(OP)

用于管理脚本类对象等非游戏物体对象,OP的API和GOP类似,只不过在传参部分OP支持更多方式

初始化OP

// API
//根据keyName初始化OP
//(string keyName, int maxCapacity = -1, int defaultQuantity = 0)
PoolSystem.InitObjectPool<T>(keyName, maxCapacity, defaultQuanity);
PoolSystem.InitObjectPool<T>(keyName, maxCapacity);
PoolSystem.InitObjectPool<T>(keyName);
//根据T的类型名初始化OP
PoolSystem.InitObjectPool<T>(maxCapacity, defaultQuanity);
PoolSystem.InitObjectPool<T>(maxCapacity);
PoolSystem.InitObjectPool<T>();
//根据keyName初始化OP,不考虑默认容量,无需传T
PoolSystem.InitObjectPool(keyName, maxCapacity);
PoolSystem.InitObjectPool(keyName);
//根据type类型名初始化OP
//System.Type type, int maxCapacity = -1
PoolSystem.InitObjectPool(type, maxCapacity);
PoolSystem.InitObjectPool(type);




//简单示例
// 设定一个Data数据类对象池,最大容量30,默认填满
PoolSystem.InitObjectPool<Data>("myData",30,30); //对象池名为myData
PoolSystem.InitObjectPool<Data>(30, 30); //对象池池名为Data
PoolSystem.InitObjectPool(xx.GetType()); //对象池名为xx的类型名
  • 通过keyName或者直接传入T根据T的类型名指定对象池的名字,优先使用keyName,在没有keyName的情况下以T类型名作为对象池名称。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过T和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动生成Object放入对象池),对应GameObject通过prefab和defaultQuanity设置默认容量。
  • 泛型T起两个作用,一个是不指定keyName时用于充当type名称,另一个是进行默认容量设置时指定预先放入对象池的对象类型,所以如果不想用默认容量功能可以使用不传T的API。
  • maxCapacity, prefab, defaultQuantity可不填,默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
  • OP的初始化和GOP略有不同,使用了泛型T传递类型,参数列表更加精简,但只有有泛型参数的重载方法可以进行默认容量的初始化(需要指定泛型T进行类型转换)。
  • 可以选择通过传入某个实例的type类型,初始化同名的无限容量OP。

将对象放入OP

// API
//根据keyName/obj.getType().FullName即obj对应的类型名放入对象池
//(object obj, string keyName)
PoolSystem.PushObject(obj, keyName);
PoolSystem.PushObject(obj);


//简单示例
//将一个Data数据类对象data放入Data对象池
PoolSystem.PushObject(data, "Data";
PoolSystem.PushObject(data);


// 扩展方法
bullet.ObjectPushPool();
  • 通过keyName指定对象池名字放入对象obj,keyName不填则默认对象池名字为obj.name。
  • 封装了拓展方法,可以通过对象.GameObjectPushPool()简便地将GameObject放入对象池。
  • 可以使用拓展方法直接将对象放入同名对象池内。
  • 如果传入的keyName/obj找不到对应的对象池(未Init),则会直接初始化生成一个同名的,无限容量的对象池并放入本次对象。
  • obj为null时本次放入操作无效,会进行报错提示。

将对象从OP中取出

// API
//根据keyName返回System.object类型对象
//(string keyName)
PoolSystem.GetObject(keyName);
//根据keyName返回T类型的对象
PoolSystem.GetObject<T>(keyName);
//根据T类型名称返回对象
PoolSystem.GetObject<T>();
//根据type类型名返回对象
//(System.Type type)
PoolSystem.GetObject(xx.getType());




//简单实例
//将一个Data数据类对象data从对象池中取出
Data data = PoolSystem.GetObject("Data");
Data data = PoolSystem.GetObject<Data>();
  • 通过keyName,泛型T,type类型指定对象池名字取出Object对象。
  • 优先根据keyName索引,不存在keyName时,则通过泛型T的反射类型和type类型名索引
  • 推荐使用泛型方法,否则返回值是object类型还需要手动进行转换。

清空OP对象池

//API
//清空(GameObject/Object)对象池
//(bool clearGameObject = true, bool clearCSharpObject = true)
PoolSystem.ClearAll(false, true);
//清空Object类对象池下keyName/T类型名/type类型名对象池
//(string keyName)
PoolSystem.ClearObject(keyName);
PoolSystem.ClearObject<T>();
//(System.Type type)
PoolSystem.ClearObject(xx.getType());


//简单实例
//清空所有OP对象池
PoolSystem.ClearAll(false,true);
//清空Data对象池
PoolSystem.ClearObject("Data");
PoolSystem.ClearObject<Data>();
  • ClearAll方法用于清空所有GOP/OP对象池,两个bool参数是否清空GOP、是否清空OP。
  • 清空某一类OP通过传入keyName/泛型T的反射类型名/type类型名索引。
  • 清空所有对象池时(ClearAll),所有资源都会被释放。
  • 清空某一类对象池时,Object中的数据载体会被放回对象池重复利用(使用时无需关心,底层实现)。

对象池可视化

alt

可以通过PoolSystemViewer观察OP和GOP。

alt

注意

  • 对象池的名字可以和放入的对象名字不同,并且每一个放入对象池的对象名词也可以不同(只要类型一致),但为了避免混淆,我们推荐同名(同类名或者同GameObject名)或者使用配置、枚举来记录对象池名。
  • PoolSystem可以直接使用,但大多情况下,推荐使用ResSystem来获取GameObject/Object对象来保证返回值不为null。

资源系统

资源系统实现了Unity资源、游戏对象、类对象的获取、异步加载,并在加载游戏对象和类对象资源时优先从对象池中获取资源来优化性能,若对象池不存在对应资源再通过资源加载方法进行实例化(因为在直接使用对象池时,返回值允许为null,但)。提供Resources和Addressables两种版本:

  • Resources版本,关联对象池进行资源的加载、卸载。
  • Addressables版本,除关联对象池进行资源的加载、卸载外,结合事件工具实现对象Destroy时Adressables自动unload。

alt

两种版本在框架设置里进行切换,。

Resources版本

普通类对象(obj)

类对象资源不涉及异步加载、Resources和Addressables的区别,直接走对象池系统。

初始化

资源系统的底层基于对象池系统,所以在资源系统层面也开放对对象池的初始化设置,API和PoolSystem一致。

// API
//根据keyName初始化OP
//(string keyName, int maxCapacity = -1, int defaultQuantity = 0)
ResSystem.InitObjectPool<T>(keyName, maxCapacity, defaultQuanity);
ResSystem.InitObjectPool<T>(keyName, maxCapacity);
ResSystem.InitObjectPool<T>(keyName);
//根据T的类型名初始化OP
ResSystem.InitObjectPool<T>(maxCapacity, defaultQuanity);
ResSystem.InitObjectPool<T>(maxCapacity);
ResSystem.InitObjectPool<T>();
//根据keyName初始化OP,不考虑默认容量,无需传T
ResSystem.InitObjectPool(keyName, maxCapacity);
ResSystem.InitObjectPool(keyName);
//根据type类型名初始化OP
//System.Type type, int maxCapacity = -1
ResSystem.InitObjectPool(type, maxCapacity);
ResSystem.InitObjectPool(type);


//简单示例
// 设定一个Data数据类对象池,最大容量30,默认填满
ResSystem.InitObjectPool<Data>("myData",30,30);
ResSystem.InitObjectPool<Data>(30, 30);
ResSystem.InitObjectPool(xx.GetType());
  • 通过keyName或者直接传入T根据T的类型名指定对象池的名字。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过T和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动生成T类型的对象放入对象池)。
  • 泛型T起两个作用,一个是不指定keyName时用于充当type名称,另一个是进行默认容量设置时指定预先放入对象池的对象类型,所以如果不想用默认容量功能可以使用不传T的API。
  • maxCapacity, prefab, defaultQuantity可不填,默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
  • 只有有泛型参数的重载方法可以进行默认容量的初始化(需要指定泛型T进行类型转换)。
  • 可以选择通过传入某个实例的type类型,初始化同名的无限容量OP。
obj加载
// API
//根据keyName从对象池中获取一个T类型对象,没有则new
//string keyName
ResSystem.GetOrNew<T>(keyName);
//根据T类型名从对象池中获取一个T类型对象,没有则new
ResSystem.GetOrNew<T>();


//简单示例,获取Data数据类的一个对象
GameObject go1 = ResSystem.GetOrNew<Data>("Data");
  • 通过keyName指定加载的类对象名,不填keyName则按照T的类型名加载。
  • 加载时优先通过对象池获取,如果对象池中无对应资源,自动new一个类对象返回,保证返回值不为null,这点体现了资源系统比对象池更完善,对象池get不存在的obj资源返回Null。
obj卸载

卸载obj即将obj放回对象池进行资源回收。

//API
//根据keyName/obj类型名将obj放回对象池
//object obj, string keyName
ResSystem.PushObjectInPool(obj);
ResSystem.PushObjectInPool(obj, string keyName);


//简单示例,卸载Data类的对象data
ResSystem.PushObjectInPool(data, "Data");
  • 通过obj指定卸载的对象,keyName指定对象池名,不填则按照obj的类型名卸载。
  • 卸载对象时如果没有初始化过对象池,则对应自动创建一个同名无限量对象池并将obj放入,保证对象卸载成功,这点体现了资源系统比对象池更完善,对象池push未初始化的对象池资源会报错。

游戏对象(GameObject)

初始化

资源系统的底层基于对象池系统,所以在资源系统层面也开放对对象池的初始化设置,API和PoolSystem大体一致,在prefab部分传参略有不同,通过传Resources下对应的路径由资源系统获得预制体,并克隆出来放入对象池。

//API
//根据keyName初始化GOP
//(string keyName, int maxCapacity = -1, string assetPath = null, int defaultQuantity = 0)
ResSystem.InitGameObjectPool(keyName, maxCapacity, assetPath, defaultQuantity);
ResSystem.InitGameObjectPool(keyName, maxCapacity);
ResSystem.InitGameObjectPool(keyName);
//根据assetPath切割的资源名初始化GOP
//(string assetPath, int maxCapacity = -1, int defaultQuantity = 0)
ResSystem.InitGameObjectPool(string assetPath, maxCapacity, defaultQuantity);
ResSystem.InitGameObjectPool(string assetPath, maxCapacity);
ResSystem.InitGameObjectPool(string assetPath);




//简单示例
// 设定一个子弹Bullet对象池(假设Bullet的路径在Resources文件夹下),最大容量30,默认填满
Gameobject bullet = GameObject.Find("bullet");
ResSystem.InitGameObjectPool("Bullet", 30, bullet, 30);
ResSystem.InitGameObjectPool(bullet, 30, 30);


//最简形式
ResSystem.InitGameObjectPool(“对象池名字”);
  • 通过keyName或者直接传入assetPath(完整资源路径)根据切割的资源名指定对象池的名字。
  • 传入的assetPath会自动切割获得资源名。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过assetPath获取的资源和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动生成GameObject放入对象池)。
  • 默认无限容量maxCapacity = -1,不预先放入对象,assetPath = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
  • 注意加载到内存的对象在被实例化之后会被自动释放。
GameObject加载并实例化
//API
//加载游戏物体
//(string assetPath, Transform parent = null,string keyName=null)
ResSystem.InstantiateGameObject(assetPath, parent, keyName);
ResSystem.InstantiateGameObject(assetPath, parent);
ResSystem.InstantiateGameObject(assetPath);
ResSystem.InstantiateGameObject(parent, keyName);
//加载游戏物体并获取组件T
ResSystem.InstantiateGameObject<T>(assetPath, parent, keyName);
ResSystem.InstantiateGameObject<T>(assetPath, parent);
ResSystem.InstantiateGameObject<T>(assetPath);
ResSystem.InstantiateGameObject<T>(parent, keyName);
//异步加载(void)游戏物体
//(string path, Action<GameObject> callBack = null, Transform parent = null, string keyName = null)
ResSystem.InstantiateGameObjectAsync(assetPath, Action<GameObject> callBack, parent, keyName);
ResSystem.InstantiateGameObjectAsync(assetPath, Action<GameObject> callBack, parent);
ResSystem.InstantiateGameObjectAsync(assetPath, Action<GameObject> callBack);
ResSystem.InstantiateGameObjectAsync(assetPath);
//异步加载(void)游戏物体并获取组件T
ResSystem.InstantiateGameObjectAsync<T>(assetPath, Action<GameObject> callBack, parent, keyName);
ResSystem.InstantiateGameObjectAsync<T>(assetPath, Action<GameObject> callBack, parent);
ResSystem.InstantiateGameObjectAsync<T>(assetPath, Action<GameObject> callBack);
ResSystem.InstantiateGameObjectAsync<T>(assetPath);


//简单示例
//实例化一个子弹对象(假设Bullet路径在Resources下)
GameObject bullet = ResSystem.InstantiateGameObject("Bullet");
//实例化一个子弹对象取出并获取其刚体组件
Rigidbody rb = ResSystem.InstantiateGameObject<Rigidbody>("Bullet");
//异步实例化一个子弹对象,并在其加载完后坐标归零
void getBullet(GameObject bullet)
{
    bullet.transform.position = Vector3.zero;
    }
ResSystem.InstantiateGameObjectAsync("Bullet", getBullet);
  • 通过assetPath加载游戏物体并实例化返回。
  • 实例化的游戏物体会设置父物体为parent,不填则默认为null无父物体在最顶层。
  • 实例化的物体名称优先为keyName,keyName为null时则为assetName。
  • 优先根据keyName从对象池获取,不填keyName则根据path加载的资源名在对象池中查找。
  • 对象池中找不到根据assetpath走Resources加载出对象,不填assetPath时则通过keyName查询路径加载对象。
  • 可以通过传参获取对象上的某个组件,组件依托于GameObject存在,因此物体此时也已被从对象池中取出。
  • 异步加载游戏物体及其组件的方法返回值为void类型,无法即时快速加载的游戏物体,需要通过callback回调函数获取加载的GameObject对象并进行使用。
  • 资源系统如果资源路径正确,则返回值必不为空,优先从对象池中获取,对象池中不存在则根据Load的对象进行实例化返回。
GameObject卸载

卸载GameObject即将GameObject放回对象池进行资源回收。

//API
//根据keyName/gameObject.name回收gameObject
//string keyName, GameObject gameObject
ResSystem.PushGameObjectInPool(string keyName, gameObject);
ResSystem.PushGameObjectInPool(gameObject);




//简单示例,卸载子弹对象bullet
ResSystem.PushGameObjectInPool(bullet, "Bullet");
  • 通过gameObject指定卸载的对象,keyName指定对象池名,不填则按照gameObject的对象名卸载。
  • 卸载对象时如果没有初始化过对象池,则对应自动创建一个同名无限量对象池并将gameObject放入。

Unity资源

这类资源不需要进行实例化,所以不需要过对象池,只需要直接使用数据或者引用,比如AudioClip,Sprite,prefab。

加载Asset
//API
//根据assetPath异步加载T类型资源
//(string assetPath, Action<T> callBack)
ResSystem.LoadAssetAsync<T>(assetPath, callBack);
//根据assetPath加载T类型资源
ResSystem.LoadAsset<T>(assetPath);
//加载指定路径的所有资源,返回object数组
ResSystem.LoadAssets(assetPath);
//加载指定路径的所有资源返回T类型
ResSystem.LoadAssets<T>(assetPath);


//简单示例,加载Resources下的clip音频资源
ResSystem.LoadAssets<AudioClip>("Resources/clip");
  • 通过assetPath路径加载资源,T用来指明加载的资源类型。
  • 异步加载资源需要通过传入callback回调获取加载的资源并进行使用。
  • 加载所有资源时不指定T则返回object数组。
  • 注意加载的资源不会被自动释放。
卸载Asset
//API
//卸载某个资源
//(UnityEngine.Object assetToUnload)
ResSystem.UnloadAsset(assetToUnload);
//卸载所有资源
ResSystem.UnloadUnusedAssets();

卸载资源实际指释放内存中的asset。

对象池是帮做资源回收利用的,避免频繁GC,对象池管理不了Asset资源。而释放是资源不用了也不需要回收卸载掉就行了,GO的自动释放资源系统已经做好了,Asset需要你根据自己的需求来释放,因为Asset也没有生命周期,只能自己释放。

Addressables版本

普通类对象(obj)

类对象资源不涉及异步加载、Resources和Addressables的区别,直接走对象池系统,。

初始化

资源系统的底层基于对象池系统,所以在资源系统层面也开放对对象池的初始化设置,API和PoolSystem一致。

// API
//根据keyName初始化OP
//(string keyName, int maxCapacity = -1, int defaultQuantity = 0)
ResSystem.InitObjectPool<T>(keyName, maxCapacity, defaultQuanity);
ResSystem.InitObjectPool<T>(keyName, maxCapacity);
ResSystem.InitObjectPool<T>(keyName);
//根据T的类型名初始化OP
ResSystem.InitObjectPool<T>(maxCapacity, defaultQuanity);
ResSystem.InitObjectPool<T>(maxCapacity);
ResSystem.InitObjectPool<T>();
//根据keyName初始化OP,不考虑默认容量,无需传T
ResSystem.InitObjectPool(keyName, maxCapacity);
ResSystem.InitObjectPool(keyName);
//根据type类型名初始化OP
//System.Type type, int maxCapacity = -1
ResSystem.InitObjectPool(type, maxCapacity);
ResSystem.InitObjectPool(type);


//简单示例
// 设定一个Data数据类对象池,最大容量30,默认填满
ResSystem.InitObjectPool<Data>("myData",30,30);
ResSystem.InitObjectPool<Data>(30, 30);
ResSystem.InitObjectPool(xx.GetType());
  • 通过keyName或者直接传入T根据T的类型名指定对象池的名字。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过T和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动生成T类型的对象放入对象池)。
  • 泛型T起两个作用,一个是不指定keyName时用于充当type名称,另一个是进行默认容量设置时指定预先放入对象池的对象类型,所以如果不想用默认容量功能可以使用不传T的API。
  • maxCapacity, prefab, defaultQuantity可不填,默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
  • 只有有泛型参数的重载方法可以进行默认容量的初始化(需要指定泛型T进行类型转换)。
  • 可以选择通过传入某个实例的type类型,初始化同名的无限容量OP。
obj加载
// API
//根据keyName从对象池中获取一个T类型对象,没有则new
//string keyName
ResSystem.GetOrNew<T>(keyName);
//根据T类型名从对象池中获取一个T类型对象,没有则new
ResSystem.GetOrNew<T>();


//简单示例,获取Data数据类的一个对象
GameObject go1 = ResSystem.GetOrNew<Data>("Data");
  • 通过keyName指定加载的类对象名,不填keyName则按照T的类型名加载。
  • 加载时优先通过对象池获取,如果对象池中无对应资源,自动new一个类对象返回,保证返回值不为null。
obj卸载

卸载obj即将obj放回对象池进行资源回收。

//API
//根据keyName/obj类型名将obj放回对象池
//object obj, string keyName
ResSystem.PushObjectInPool(obj);
ResSystem.PushObjectInPool(obj, string keyName);


//简单示例,卸载Data类的对象data
ResSystem.PushObjectInPool(data, "Data");
  • 通过obj指定卸载的对象,keyName指定对象池名,不填则按照obj的类型名卸载。
  • 卸载对象时如果没有初始化过对象池,则对应自动创建一个同名无限量对象池并将obj放入。

游戏对象(GameObject)

初始化

资源系统的底层基于对象池系统,所以在资源系统层面也开放对对象池的初始化设置,API和PoolSystem有区别,Addressables版本通过Addressables name来获取prefab(参考副本),Res需要传路径来获取prefab(参考副本)。

//API
//根据keyName初始化GOP
//(string keyName, int maxCapacity = -1, string assetName = null, int defaultQuantity = 0)
ResSystem.InitGameObjectPoolForKeyName(keyName, maxCapacity,assetName, defaultQuantity);
ResSystem.InitGameObjectPoolForKeyName(keyName, maxCapacity);
ResSystem.InitGameObjectPoolForKeyName(keyName);
//根据assetName在Addressables中的资源名初始化GOP
//(string assetName, int maxCapacity = -1, int defaultQuantity = 0)
ResSystem.InitGameObjectPoolForAssetName(assetName, maxCapacity, defaultQuantity);
ResSystem.InitGameObjectPoolForAssetName(assetName, maxCapacity);
ResSystem.InitGameObjectPoolForAssetName(assetName);




//简单示例
// 设定一个子弹Bullet对象池(假设Addressable资源名称为Bullet),最大容量30,默认填满
Gameobject bullet = GameObject.Find("bullet");
ResSystem.InitGameObjectPool("Bullet", 30, bullet, 30);
ResSystem.InitGameObjectPool(bullet, 30, 30);


//最简形式
ResSystem.InitGameObjectPool(“对象池名字”);
  • 通过keyName或者直接传入assetName(Addressable资源的名称)根据获取的资源名指定对象池的名字,优先keyName,没有keyName则使用assetName。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过assetName和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动加载GameObject放入对象池)。
  • 默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
GameObject加载并实例化

Addressable版本中游戏物体参数通过Addressable资源名assetName(Res是资源路径assetPath)指定,支持加载出的对象Destroy时在Addressables中自动释放。

//API
//加载游戏物体
//(string assetName, Transform parent = null, string keyName = null, bool autoRelease = true)
ResSystem.InstantiateGameObject(assetName, parent, keyName, autoRelease);
ResSystem.InstantiateGameObject(assetName, parent, keyName);
ResSystem.InstantiateGameObject(assetName, parent);
ResSystem.InstantiateGameObject(assetName);
ResSystem.InstantiateGameObject(parent, keyName, autoRelease);
ResSystem.InstantiateGameObject(parent, keyName);
//加载游戏物体并获取组件T
ResSystem.InstantiateGameObject<T>(assetName, parent, keyName, autoRelease);
ResSystem.InstantiateGameObject<T>(assetName, parent, keyName);
ResSystem.InstantiateGameObject<T>(assetName, parent);
ResSystem.InstantiateGameObject<T>(assetName);
ResSystem.InstantiateGameObject<T>(parent, keyName, autoRelease);
ResSystem.InstantiateGameObject<T>(parent, keyName);
//异步加载(void)游戏物体
//(string assetName, Action<GameObject> callBack = null, Transform parent = null, string keyName = null, bool autoRelease = true)
ResSystem.InstantiateGameObjectAsync(assetName, callBack, parent, keyName, autoRelease);
ResSystem.InstantiateGameObjectAsync(assetName, callBack, parent, keyName);
ResSystem.InstantiateGameObjectAsync(assetName, callBack, parent);
ResSystem.InstantiateGameObjectAsync(assetName, callBack);
ResSystem.InstantiateGameObjectAsync(assetName);
//异步加载(void)游戏物体并获取组件T
//(string assetName, Action<T> callBack = null, Transform parent = null, string keyName = null, bool autoRelease = true)
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack, parent, keyName, autoRelease);
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack, parent, keyName);
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack, parent);
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack);
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack);
ResSystem.InstantiateGameObjectAsync<T>(assetName);


//简单示例
//实例化一个子弹对象(假设AB资源名称为Bullet)
GameObject bullet = ResSystem.InstantiateGameObject("Bullet");
//实例化一个子弹对象取出并获取其刚体组件
Rigbody rb = ResSystem.InstantiateGameObject<Rigbody>("Bullet");
//异步实例化一个子弹对象,并在其加载完后坐标归零
void getBullet(GameObject bullet)
{
    bullet.transform.position = Vector3.zero;
    }
ResSystem.InstantiateGameObjectAsync("Bullet", getBullet);
  • 通过assetName加载游戏物体并实例化返回
  • 实例化的游戏物体会设置父物体为parent,不填则默认为null无父物体在最顶层。
  • 实例化的物体名称优先为keyName,keyName为null时则为assetName。
  • 优先根据keyName从对象池获取,不填keyName则根据assetName在对象池中查找。
  • 对象池中无缓存,则根据assetName从Addressable中获取资源,不填assetName则根据keyName从Addressable中获取资源。
  • 可以通过传参获取对象上的某个组件,组件依托于GameObject存在,因此物体此时也已被从对象池中取出。
  • autoRelease为true则通过事件工具为加载出的对象添加事件监听,会在其Destroy时自动调用Addressables的Release API。
  • 异步加载游戏物体及其组件的方法返回值为void类型,无法即时直接加载的游戏物体,需要通过callback回调获取加载的GameObject对象并进行使用。
  • 如果资源路径正确,则返回值必不为空,优先从对象池中获取,对象池中不存在则根据Load的对象进行实例化返回。
GameObject卸载

卸载GameObject即将GameObject放回对象池进行资源回收。

//API
//根据keyName/gameObject.name卸载gameObject
//(string keyName, GameObject gameObject)
ResSystem.PushGameObjectInPool(keyName, gameObject);
ResSystem.PushGameObjectInPool(gameObject);


//简单示例,卸载子弹对象bullet
ResSystem.PushGameObjectInPool(bullet, "Bullet");
  • 通过gameObject指定卸载的对象,keyName指定对象池名,不填则按照gameObject的对象名卸载。
  • 卸载对象时如果没有初始化过对象池,则对应自动创建一个同名无限量对象池并将gameObject放入。

Unity资源

这类资源不需要进行实例化,所以不需要过对象池,只需要使用数据或者引用,比如AudioClip,Sprite,prefab(没有经过实例化的GameObject原本)。

加载Asset
//API


//根据assetName加载T类型资源
ResSystem.LoadAsset<T>(assetName);
//根据keyName批量加载所有资源(IList<T>)
//(string keyName, out AsyncOperationHandle<IList<T>> handle, Action<T> callBackOnEveryOne = null)
ResSystem.LoadAssets<T>(keyName, handle, callBackOnEveryOne);


//根据assetName异步加载T类型资源(void)
//(string assetName, Action<T> callBack)
ResSystem.LoadAssetAsync<T>(string assetName, Action<T> callBack);
//根据keyName批量异步加载所有资源(void)
//(string keyName, Action<AsyncOperationHandle<IList<T>>> callBack, Action<T> callBackOnEveryOne = null)
ResSystem.LoadAssetsAsync<T>(keyName, callBack, callBackOnEveryOne);


//简单示例,加载Addressable clip音频资源
ResSystem.LoadAssets<AudioClip>("clip");
  • 通过path路径加载资源,T用来指明加载的资源类型。
  • 异步加载单个资源需要通过传入callback回调获取加载的资源并进行使用。
  • 批量加载资源时keyName是Addressable中的Labels。
  • handle用于释放资源,批量加载时,如果释放资源要释放掉handle,直接去释放资源是无效的.
  • Addressable加载指定keyName的所有资源时,支持每加载一个资源调用一次callBackOnEveryOne。
  • 异步加载完指定keyName所有资源时,调用callback获取加载的资源集合并进行使用。
  • 注意加载的资源不会被自动释放。
卸载Asset
//API
//释放某个资源
//(T obj)
ResSystem.UnloadAsset<T>(T obj);
//销毁对象并释放资源
//(GameObject obj)
ResSystem.UnloadInstance(obj);
//卸载因为批量加载而产生的handle
//(AsyncOperationHandle<TObject> handle)
UnLoadAssetsHandle<TObject>(handle);

卸载Asset即释放资源,可以在Destroy游戏对象的同时释放Addressable资源。

资源系统-自动生成资源引用代码

针对Addressables版本,使用字符串来加载资源方式比较麻烦,而且容易输错,框架提供一种基于引用加载的方式。

alt

通过Editor工具会在指定路径下生成资源引用代码R。

alt

// API
//返回一个资源
R.GroupName.AddressableName;
//返回一个资源的实例
//(Transform parent = null,string keyName=null,bool autoRelease = true)
R.GroupName.AddressableName(parent, keyName, autoRelease);
R.GroupName.AddressableName(parent, keyName);
R.GroupName.AddressableName(parent);


//使用示例
//获取一个Bullet预制体资源(不实例化)
Gameobject bullet = R.DefaultLocalGroup.Bullet;
//获取一个Bullet实例
Gameobject bullet = R.DefaultLocalGroup.Bullet(x.transform);


//释放
ResSystem.UnloadAsset<GameObject>(bullet);
  • R是资源脚本的命名空间,固定。
  • GroupName是Addressable的组名。
  • AddressableName是资源名。
  • 如果填写keyName,则先去对象池中找资源实例,找不着再通过Addressable获取资源并实例化。
  • parent为实例的父物体。
  • autoRelease为true则实例会在Destroy时自动释放Addressable中对应的资源。

alt

对于Sprite的子图,也支持直接引用。

alt

//子图
R.LV2.Img_Img_0;
//总图
R.Lv2.Img;

事件系统

框架的事件系统主要负责高效的方法调用与数据传递,实现各功能之间的解耦,通常在调用某个实例的方法时,必须先获得这个实例的引用或者新实例化一个对象,低耦合度的框架结构希望程序本身不去关注被调用的方法所依托的实例对象是否存在,通过事件系统做中转将功能的调用封装成事件,使用事件监听注册、移除和事件触发完成模块间的功能调用管理。常用在UI事件、跨模块事件上。

事件系统支持无返回值的Action,Func实际应用意义不大。

事件监听添加

//API
//添加无参数的事件监听
//string eventName, Action action
EventSystem.AddEventListener(eventName, action); 
//添加多个参数的事件监听
//string eventName
EventSystem.AddEventListener<T>(eventName, action);
EventSystem.AddEventListener<T0, T1>(eventName, action);
EventSystem.AddEventListener<T0, T1, T2>(eventName, action);
...
EventSystem.AddEventListener<T0, T1, ..., T15>(eventName, action);


//简单示例
//添加无参数的事件监听,Doit方法对应名称为Test的事件
EventSystem.AddEventListener("Test", Doit);
void Doit()
{
    Debug.Log("Doit");
}
//添加多个参数的事件监听,Doit2对应名称为TestM的事件,参数为int,string
EventSystem.AddEventListener<int, string>("TestM", Doit2);
void Doit2(int a, string b)
{
    Debug.Log(a);
    Debug.Log(b); 
}
  • eventName是解耦执行的方法的标记,即事件名,是触发事件时的唯一依据。
  • action传无返回值方法。
  • T0~T15是泛型,用于指定参数表,支持最多16个参数的action。

事件监听移除

//API
//添加无参数的事件监听
//string eventName, Action action
EventSystem.RemoveEventListener(eventName, action); 
//添加多个参数的事件监听
//string eventName
EventSystem.RemoveEventListener<T>(eventName, action);
EventSystem.RemoveEventListener<T0, T1>(eventName, action);
EventSystem.RemoveEventListener<T0, T1, T2>(eventName, action);
...
EventSystem.RemoveEventListener<T0, T1, ..., T15>(eventName, action);


//简单示例
//移除无参数的事件监听,Doit方法对应名称为Test的事件
EventSystem.RemoveEventListener("Test", Doit);
void Doit()
{
    Debug.Log("Doit");
}
//移除多个参数的事件监听,Doit2对应名称为TestM的事件,参数为int,string
EventSystem.RemoveEventListener<int, string>("TestM", Doit2);
void Doit2(int a, string b)
{
    Debug.Log(a);
    Debug.Log(b); 
}
  • eventName是解耦执行的方法的标记,即事件名,是触发事件时的唯一依据。
  • action传无返回值方法。
  • T0~T15是泛型,用于指定参数表,支持最多16个参数的action。

事件触发

//API
//触发无参数事件
EventSystem.EventTrigger(string eventName);
//触发多个参数事件
EventSystem.EventTrigger<T>(string eventName, T arg);
EventSystem.EventTrigger<T0, T1>(string eventName, T0 arg0, T1 arg1);
EventSystem.EventTrigger<T0, T1,..., T15>(string eventName, T0 arg0, T1 arg1, ..., T15 arg15);
 
//简单示例,使用添加监听的方法例子
EventSystem.EventTrigger("Test");
EventSystem.EventTrigger<int,string>("TestM",1,"test");
  • eventName是解耦执行的方法的标记,即事件名,是触发事件时的唯一依据。
  • T0~T15是泛型,用于指定参数表,支持最多16个参数的action。
  • 事件的查询底层使用TryGetValue所以触发不存在的事件并不会报错。

事件移除

事件移除和事件监听移除的区别参与:

  • 事件监听移除只移除一条Action,比如添加了3次同名事件监听,则移除一次后触发还是会执行两次,且eventName记录不会被移除。
  • 事件移除会将事件中心字典中有关eventName的记录连带存储的Action一同清空。
//API
//移除一类事件
//(string eventName)
EventSystem.RemoveEvent(eventName);
//移除事件中心中所有事件
EventSystem.Clear();
  • eventName是解耦执行的方法的标记,即事件名,是触发事件时的唯一依据。

类型事件

支持对参数进行封装为一个struct传递,简化参数列表。

//API
//添加类型事件的监听
//Action<T> action
AddTypeEventListener<T>(action);
//移除类型事件的监听
RemoveTypeEventListener<T>(action);
//移除/删除一个类型事件
RemoveTypeEvent<T>();
//触发类型事件
// (T arg)
TypeEventTrigger<T>(arg);
  • T是封装的参数列表,一般为struct类型。
  • action设计使用封装参数T的事件。
  • arg是封装的参数。

注意

事件系统的运行逻辑是,预先添加/移除事件监听,再在能够获取相应参数的类内触发事件。

音效系统

音效服务集成了背景、特效音乐播放,音量、播放控制功能。包含了全局音量globalVolume、背景音量bgVolume、特效音量effectVolume、静音布尔量isMute、暂停布尔量isPause等音量相关的属性,播放背景音乐的PlayBGAudio方法且,播放特效音乐PlayOnShot方法且重载后支持在指定位置或绑定游戏对象播放特定的音乐,特效音乐由于要重复使用,可以从对象池中获取播放器并自动回收,支持播放后执行回调事件。

播放背景音乐

音量、播放属性控制

音效服务支持在Inspector面板上的值发生变化时自动执行相应的方法更新音量属性,也可以在属性值变化时自动调用相应的更新方法。

//API & 示例
//全局音量(float,0~1),音量设定为50%
AudioSystem.GlobalVolume = 0.5f;
//背景音乐音量(float,0~1),音量设定为50%
AudioSystem.BGVolume = 0.5f;
//特效音乐音量(flaot,0~1)
AudioStystem.EffectVolume = 0.5f;
//是否全局静音(bool),true则静音
AudioSystem.IsMute = true;
//背景音乐是否循环,true则循环
AudioSystem.IsLoop = true;
//背景音乐是否暂停,true则暂停
AudioSystem.IsPause = true;
  • GlobalVolume是全局音量,同时影响背景、特效音乐音量。
  • BGVolume是背景音乐音量
  • EffectVolume是特效音乐音量。
  • IsMute控制全局音量是否静音。
  • IsLoop控制背景音乐是否循环。
  • IsPause控制背景音乐是否暂停。

支持通过面板更新音量属性。

alt

播放背景音乐

//API
//播放背景音乐
//(AudioClip clip, bool loop = true, float volume = -1, float fadeOutTime = 0, float fadeInTime = 0)
AudioSystem.PlayBGAudio(clip, loop, volume, fadeOutTime, fadeInTime);
//轮播多个背景音乐
//(AudioClip[] clips, float volume = -1, float fadeOutTime = 0, float fadeInTime = 0);
AudioSystem.PlayBGAudioWithClips(clips, volume, fadeOutTime, fadeInTime);
//停止当前背景音乐
AudioSystem.StopBGAudio();
//暂停当前背景音乐
AudioSystem.PauseBGAudio();
//取消暂停当前音乐
AudioSystem.UnPauseBGAudio();


//简单示例
AudioClip clip = ResSystem.LoadAsset<AudioClip>("music");
AudioSystem.PlayBGAudio(clip);
  • clip是音乐片段,可以传clip数组来轮播音乐。
  • volume是音乐的音量,不指定则按原来的背景音量。
  • fadeOutTime是渐出音乐的时间。
  • fadeInTime是渐入音乐的时间。
  • 停止当前背景音乐会将当前背景音乐置空。
  • 暂停音乐可取消暂停恢复。

播放特效音乐

//API
//播放一次音效并绑定到游戏物体上,位置随物体变化
//(AudioClip clip, Component component = null, bool autoReleaseClip = false, float volumeScale = 1, bool is3d = true, Action callBack = null)
audioSystem.PlayOneShot(clip, component, autoReleaseClip, volumeScale, is3d, callBack);
audioSystem.PlayOneShot(clip, component, autoReleaseClip, volumeScale, is3d);
audioSystem.PlayOneShot(clip, component, autoReleaseClip, volumeScale);
audioSystem.PlayOneShot(clip, component, autoReleaseClip);
audioSystem.PlayOneShot(clip, component);
audioSystem.PlayOneShot(clip);
//在指定位置上播放一次音效
//(AudioClip clip, Vector3 position, bool autoReleaseClip = false, float volumeScale = 1, bool is3d = true, Action callBack = null)
audioSystem.PlayOneShot(clip, position, autoReleaseClip, volumeScale, is3d, callBack);
audioSystem.PlayOneShot(clip, position, autoReleaseClip, volumeScale, is3d);
audioSystem.PlayOneShot(clip, position, autoReleaseClip, volumeScale);
audioSystem.PlayOneShot(clip, position, autoReleaseClip);
audioSystem.PlayOneShot(clip, position);
//简单示例
//在玩家位置播放一次音效
AudioClip clip = ResSystem.LoadAsset<AudioClip>("music");
audioSystem.PlayOneShot(clip,player.transform.position);
//绑定玩家组件播放一次音效(等同于玩家位置)
audioSystem.PlayOneShot(clip,player.transform);
  • clip是音乐片段,音效系统中特效音乐在每次播放时优先从对象池中取出挂载了AudioSource的GameObject实例生成并会在音效播放完成后自动回收。
  • postion是播放的位置,必填。
  • component是绑定的组件,这个API的目的是让音效随着物体移动一起移动,不填则默认不绑定。
  • autoReleaseClip代表是否需要在音乐播放结束后自动释放clip资源,Res和Addressable均可。
  • volumeScle是音乐的音量,不指定默认按最大音量。
  • is3D是启用空间音效,默认开启。
  • callBack是回调事件,会在音效播放完执行一个无参无返回值方法。

使用Compoent绑定播放音效时,如果绑定物体如果在播放中被销毁了,那么AudioSource会提前解绑避免一同被销毁(通过事件工具提前添加监听),之后播放完毕会自动回收。

存档系统

完成对存档的创建,获取,保存,加载,删除,缓存,支持多存档。存档有两类,一类是用户型存档,存储着某个游戏用户具体的信息,如血量,武器,游戏进度,一类是设置型存档,与任何用户存档都无关,是通用的存储信息,比如屏幕分辨率、音量设置等。

alt

存档系统支持两类本地文件:,两者通过框架设置面板进行切换,切换时,原本地文件存档会清空!二进制流文件可读性较差不易修改,Json可读性较强,易修改,存档的数据存在Application.persistentDataPath下。

alt

alt

SaveData和setting分别存储用户存档和设置型存档。

alt

用户存档下根据saveID分成若干文件夹用于存储具体的对象。

设置型存档

设置存档实际就是一个全局唯一的存档,可以向其中存储全局通用数据。

保存设置

//API
//保存设置到全局存档
//(object saveObject, string fileName)
SaveSystem.SaveSetting(saveObject, fileName);
SaveSystem.SaveSetting(saveObject)
//简单示例
//见下一小节结合加载说明
  • saveObject是要保存的对象,System.Object类型。
  • fileName是保存的文件名称,不填默认取saveObject的类型名。

加载设置

///API
//从设置存档中加载设置
// string fileName
SaveSystem.LoadSetting<T>(fileName);
SaveSystem.LoadSetting<T>();


//简单示例
// GameSetting类中存储着游戏名称,作为全局数据
[Serializable]
public class GameSetting
{
    public string gameName;
}
GameSetting gameSetting = new GameSetting();
gameSetting.gameName = "测试";
//保存设置
SaveSystem.SaveSetting(gameSetting);
//取出来用
String gameName = SaveSystem.LoadSetting<gameSetting>().gameName;

删除设置

//API
//删除用户存档和设置存档
SaveSystem.DeleteAll();
  • fileName是加载设置存档的文件名,T限定了所存储的数据类型,不填fileName则默认以T的类型名作为文件名加载。

用户存档

用户存档与具体的用户相关,不同用户存档位置不同,数据也不同,索引为SaveID。

创建用户存档

创建的存档索引默认自增。

//API
SaveSystem.CreateSaveItem();


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();

获取用户存档

存档层面
获取所有用户存档

根据一定规则获取所有用户存档,返回List。

//API
//最新的在最后面
SaveSystem.GetAllSaveItem();
//最近创建的在最前面
SaveSystem.GetAllSaveItemByCreatTime();
//最近更新的在最前面
SaveSystem.GetAllSaveItemByUpdateTime();
//万能解决方案,自定义规则
GetAllSaveItem<T>(Func<SaveItem, T> orderFunc, bool isDescending = false)


//简单示例,万能方案,按照SaveID倒序获得存档
GameSetting gameSetting = new GameSetting();
List<SaveItem> testList = SaveSystem.GetAllSaveItem<int>(oderFunc, true);
//List<SaveItem> testList = SaveSystem.GetAllSaveItem();
foreach (var item in testList)
{
    Debug.Log(item.saveID);
}
//排序依据Func
int oderFunc(SaveItem item)
{
    return item.saveID;
}
  • 提供多种重载方法获取存档List。
  • 支持自定义排序依据的万解决方案,T传比较参数类型,orderFunc传比较方法。
获取某一项用户存档
//API
//(int id, SaveItem saveItem)
SaveSystem.GetSaveItem(id);
SaveSystem.GetSaveItem(saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
SaveSystem.GetSaveItem(saveItem);
  • id是用户存档的编号,存档系统会在创建时指定默认ID,使用时透明,因此推荐使用saveItem传参,saveItem是可维护的。
删除用户存档
删除所有用户存档
//API
//删除所有用户存档
SaveSystem.DeleteAllSaveItem();
删除某一项用户存档
//API
//(int id, SaveItem saveItem)
SaveSystem.DeleteSaveItem(id);
SaveSystem.DeleteSaveItem(saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
SaveSystem.DeleteSaveItem(saveItem);
  • id是用户存档的编号,存档系统会在创建时指定默认ID,使用时透明,因此推荐使用saveItem传参,saveItem是可维护的。

存档对象层面

保存用户存档中某一对象
//API
//(object saveObject, string saveFileName, SaveItem saveItemint, saveID = 0)
SaveSystem.SaveObject(saveObject, saveFileName, saveID);
SaveSystem.SaveObject(saveObject, saveFileName, saveItem);
SaveSystem.SaveObject(saveObject, saveID);
SaveSystem.SaveObject(saveObject, saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
GameSetting gameSetting = new GameSetting();
SaveSystem.SaveObject(gameSetting, saveItem);
  • saveObject是要保存的对象。
  • saveFileName是保存后生成的本地文件名(对象会单独作为一个文件存储在对应saveID的文件夹下),不填则以对象的类型名为文件名。
  • saveID/SaveItem是对象存储的存档。
  • 保存对象时会更新用户存档缓存。
获取用户存档中某一对象
//API
//(string saveFileName, SaveItem saveItem, int saveID = 0)
SaveSystem.LoadObject<T>(saveFileName, saveID);
SaveSystem.LoadObject<T>(saveFileName, saveItem);
SaveSystem.LoadObject<T>(saveID);
SaveSystem.LoadObject<T>(saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
GameSetting gameSetting = new GameSetting();
SaveSystem.SaveObject(gameSetting, saveItem);
GameSetting gameSetting = SaveSystem.LoadObject<GameSetting>(saveItem);
  • T指定获取对象类型。
  • saveFileName是获取对象的文件名,不填则默认以T的类型名作为文件名。
  • saveID/SaveItem是对象存储的存档。
  • 获取对象优先从缓存中读取,不存在则IO读文件获取,并加入缓存。
删除用户存档中某一对象
//API
//(string saveFileName, SaveItem saveItem, int saveID = 0)
SaveSystem.DeleteObject<T>(saveFileName, saveID);
SaveSystem.DeleteObject<T>(saveFileName, saveItem);
SaveSystem.DeleteObject<T>(saveID);
SaveSystem.DeleteObject<T>(saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
GameSetting gameSetting = new GameSetting();
SaveSystem.DeleteObject(gameSetting, saveItem);
GameSetting gameSetting = SaveSystem.DeleteObject<GameSetting>(saveItem);
  • T指定获取对象类型。
  • saveFileName是获取对象的文件名,不填则默认以T的类型名作为文件名。
  • saveID/SaveItem是对象存储的存档。
  • 删除某一对象时,如果存在对应的缓存,则一并删除。
注意

在从用户存档中取出对象时,底层优先从缓存中读取,避免读时IO,使用时无需关注。

序列化字典,vector,color

框架提供了字典的二进制序列化方法以进行存档,给字典包了一层壳,在序列化和反序列化时自动拆分成List存储、组合成Dictionary使用。同时将Color,vector2,vector3单独封装成结构体进行存储,舍弃掉Unity数据类型中自带的额外方法和属性,只保留rgba和xyz坐标。

//API
Vector3 -> Serialized_Vector3
Vector2 -> Serialized_Vector2
Color -> Serialized_Color
Dictionary->Serialized_Dic

在使用时,将原先定义字典等数据的语句关键字进行替换即可,框架重载了赋值运算符,构造函数以及类型转换方法,使得序列化的数据类型可以自动跟原生的Vector2,Vector3,Vector2Int,Vector3Int,Color互转,在使用体验上与原生的关键词无异。

UI框架

UI框架实现对窗口的生命周期管理,层级遮罩管理,按键物理响应等功能,对外提供窗口的打开、关闭、窗口复用API,对内优化好窗口的缓存、层级问题,能够和场景加载、事件系统联动,将Model、View、Controller完全解耦。通过与配置系统、脚本可视化合作,实现新UI窗口对象的快速开发和已有UI窗口的方便接入。

数据结构

虽然本文档使用手册,但为了便于上手理解,简单对UI框架的数据结构进行解释。

    //UI窗口数据字典
    Dictionary<string, UIWindowData> UIWindowDataDic;


    //UI窗口数据类
    public class UIWindowData
    {
        [LabelText("是否需要缓存")] public bool isCache;
        [LabelText("预制体Path或AssetKey")] public string assetPath;
        [LabelText("UI层级")] public int layerNum;
        /// <summary>
        /// 这个元素的窗口对象
        /// </summary>
        [LabelText("窗口实例")] public UI_WindowBase instance;


        public UIWindowData(bool isCache, string assetPath, int layerNum)
        {
            this.isCache = isCache;
            this.assetPath = assetPath;
            this.layerNum = layerNum;
            instance = null;
        }
    }

UI框架的核心在于维护字典UIWindowDataDic,通过windowKey索引了不同的UI窗口数据UiWindowData,其中包含了窗口是否要缓存,资源路径,UI层级,以及窗口类实例(脚本作为窗口对象的组件,持有他就相当于持有了窗口gameObject),UIWindowData可以通过运行时动态加载也可以在Editor时通过特性静态加载,设计windowKey的原因是如果不额外标定windowKey直接用资源路径作为索引,则同一个窗口资源无法复用,换句话说,同一个UI窗口游戏对象及窗口类,通过不同的windowKey和实例可以进行重用。

UI窗口对象及类配置

使用UI框架需要先为UI窗口游戏对象添加控制类,该类继承自UI_WindowBase,并将UI窗口游戏对象加入Addressable列表/Resources文件夹下。

UI窗口特性-Editor静态加载

可以选择为UI窗口类打上UIWindowData特性(Attribute可省略)用于配置数据。
alt

UIWindowDataAttribute(string windowKey, bool isCache, string assetPath, int layerNum){}
UIWindowDataAttribute(Type type,bool isCache, string assetPath, int layerNum){}
  • 特性中windowKey是UI窗口的名字唯一索引,可以直接传string也可以传Type使用其FullName。
  • isCache指明UI窗口游戏对象是否需要缓存重用,true则在窗口关闭时不会被销毁,下次使用时可以通过windowKey调用且不需要实例化。
  • assetPath是资源的路径,在Resources中是UI窗口对象在Resources文件夹下的路径,Addressable中是UI窗口对象的Addressable Name。
  • layerNum是UI窗口对象的层级,从0开始,越大则越接近顶层。
  • 支持一个窗口类多特性,复用同一份窗口类资源,n个特性,则有n份UI窗口数据,本质上对应了多个windowKey,因此windowKey必须不同。

经过配置后,在Editor模式下该UI类特性数据及UI窗口游戏对象(此时还没有实例化为空)会自动保存到GameRoot的配置文件中,即静态加载。

UI窗口运行时动态加载

在运行时动态加载UI窗口,不需要给窗口类打特性,窗口数据直接给出,与Onshow/OnClose不同,其不包含窗口游戏物体对象的显示/隐藏/销毁逻辑。

//API
//(string windowKey, UIWindowData windowData, bool instantiateAtOnce = false)
UISystem.AddUIWindowData(windowKey, windowData, instantiateAtOnce);
UISystem.AddUIWindowData(windowKey, windowData, instantiateAtOnce);
//(Type type, UIWindowData windowData, bool instantiateAtOnce = false)
UISystem.AddUIWindowData(type, windowData, instantiateAtOnce);
UISystem.AddUIWindowData(type, windowData);
UISystem.AddUIWindowData<T>(windowData, instantiateAtOnce);
UISystem.AddUIWindowData<T>(windowData);


//简单实例
UISystem.AddUIWindowData("Test1", new UIWindowData(true, "TestWindow", 1));
//上一步只添加了数据,显示在面板上还需要激活
UISystem.Show<TestWindow>("Test1");
  • 通过泛型T指定UI窗口子类类型,windowKey为UI窗口类的索引,对应UIWindowData中的windowKey,不指定则使用T的类型名作为索引。
  • instantiateAtOnce指明窗口对象及其类是否要进行实例化,默认为null,会在窗口打开时加载资源进行实例化且设置为不激活,若窗口资源较大,可以提前在动态加载时就进行实例化,如图。

alt

UI窗口数据管理

获取UI窗口数据,其中包含UI的windowKey,层级,资源路径,以及对象实例,可以对其进行操作。

//获取UI窗口数据
//(string windowKey) (Type windowType)
UISystem.GetUIWindowData(windowKey);
UISystem.GetUIWindowData<T>();
UISystem.GetUIWindowData(windowType);
//尝试获取UI窗口数据,返回bool
//(string windowKey, out UIWindowData windowData
UISystem.TryGetUIWindowData(windowKey, windowData);
//移除某条UI窗口数据
//(string windowKey, bool destoryWidnow = false)
UISystem.RemoveUIWindowData(windowKey, destoryWidnow);
//清除所有UI窗口数据
UISystem.ClearUIWindowData();


//简单实例
//获取testWindow的层级
UISystem.GetUIWindowData<testWindow>().layerNum;
  • 通过windowKey/泛型类型名/窗口对象类型传索引。
  • 支持Try方式获取窗口数据,成功返回true并将数据赋给输出参数。
  • 移除UI窗口数据,已存在的窗口对象实例会被强行删除。

UI窗口对象管理

这里的UI窗口对象只UI窗口数据UIWIndowData持有的那一份窗口脚本对象实例,其生命周期由框架管理,整体分为打开和关闭。

UI窗口打开

加载UI窗口对象并显示。

//API
//返回值为UI窗口类T,T受泛型约束必须为UI窗口基类子类
//(string windowKey, int layer = -1)
UISystem.Show<T>(windowKey, layer);
UISystem.Show<T>(windowKey);
UISystem.Show<T>(layer);
UISystem.Show<T>();
//返回值为UI_WindowBase类,对应不能确定窗口类型的情况, xx是窗口类的对象
//(Type type, int layer = -1)
UISystem.Show(xx.getType(), layer);
UISystem.Show(xx.getType());
//(string windowKey, int layer = -1)
UISystem.Show(windowKey, layer);
UISystem.Show(windowKey);


//简单实例,打开窗口UI_WindowTest并置于第三层
UISystem.Show<UI_WindowTest>(2);
  • 通过泛型T指定UI窗口子类类型,windowKey为UI窗口类的索引,对应UIWindowData中的windowKey,不指定则使用T的类型名作为索引,layer代表UI的层级,不填则默认-1表示使用数据中原有的层级(通过静态配置或者动态加载指定)。
  • 在明确UI窗口类型的时候可以直接通过泛型T指定,不明确则可以通过传对象反射来获取类型。
  • 简单解释逻辑为根据windowKey找到对应的窗口数据UIWindowData,根据数据中的assetPath加载UI窗口对象并根据T返回窗口类,无T则返回UI_WindowBase类。

由于UI窗口类继承了UIWIndowBase,其中提供了一些可供重写的方法,这些方***在UI窗口打开时自动执行。

    //初始化相关方法,只有在窗口第一次打开时执行
    public override void Init()
    {
        base.Init();
    }


    //窗口每次打开时执行,可用于数初始化,并会自动调用事件监听注册方法
    public override void OnShow()
    {
        base.OnShow();
    }
    //事件监听注册
    protected override void RegisterEventListener()
    {
        base.RegisterEventListener();
    }

UI窗口关闭

//API
//(Type type) (string windowKey)
UISystem.Close<T>();
UISystem.Close(type);
UISystem.Close(windowKey);
UISystem.TryClose(windowKey);
UISystem.CloseAllWindow();


//简单实例,关闭窗口UI_WindowTest
UISystem.Close<UI_WindowTest>();
  • 相比打开,关闭不需要返回值也不需要管理层级,通过T/Type/windowKey传入窗口的索引即可。
    • TryClose API在遇到窗口已关闭或不存在时并不会warning,而其他API会报warning。

由于UI窗口类继承了UIWIndowBase,其中提供了一些可供重写的方法,这些方法在UI窗口关闭时自动执行。

    //窗口每次关闭时执行,会动调用事件监听注销方法
    public override void OnClose()
    {
        base.OnClose();
    }


    //事件监听注销
    protected override void RegisterEventListener()
    {
        base.RegisterEventListener();
    }

获取/销毁UI窗口对象

获取/销毁UIWindowData持有的UI窗口对象实例,与Onshow/OnClose不同,其只获取实例,不包含窗口游戏物体对象的显示/隐藏/销毁逻辑。

//API
//返回值为UI窗口类T,T受泛型约束必须为UI窗口基类子类
//(string windowKey)
UISystem.GetWindow<T>(windowKey);
UISystem.GetWindow<T>(Type windowType);
UISystem.GetWindow<T>();
//返回值为UI_WindowBase类,对应不能确定窗口类型的情况
UISystem.GetWindow(windowKey);
//返回值为bool,表示窗口对是否存在
//(string windowKey, out T window)
UISystem.TryGetWindow(windowKey, window);
//(string windowKey, out T window)
UISystem.TryGetWindow<T>(windowKey, window);
//销毁窗口对象
UISystem.DestroyWindow(windowKey);


//简单实例,获取TestWindow上的UI Text组件Name
Text name = UISystem.GetWindow<TestWindow>().Name;
  • 通过windowKey/type Name/T类型名查找窗口对象。
  • 支持Try方式,查询成功则对象传递到输出参数out上,并返回bool为true,否则输出参数为null并返回false。
  • 销毁窗口对象API会直接销毁游戏内的窗口gameObject、控制类,但UIWindowData还存在。

UI层级管理

框架内部实现了对UI的层级管理,可以在面板的UISystem上每一层是否启用遮罩,默认每一层UI是层层堆叠覆盖的,一旦某一层中有UI窗口对象,则层级比它低的层级都不可以交互,同一层级中比它早打开的UI窗口不可以交互(保证每一层内最顶层只有一个窗口),可以勾选不启用遮罩,则这一层层内和层外都不存在遮罩关系。

启用遮罩如下图。

alt

Mask保证了每一层内最顶层只有一个窗口进行交互。

alt

另外框架单独提供了最顶层dragLayer,用于拖拽时临时需要把某个UI窗口置于最上层,可以通过UISystem.dragLayer获取。

UISystem.dragLayer;

UI Tips

弹窗工具。

//API
// 在窗口右下角弹出字符串tips提醒。
//(string tips)
UISystem.AddTips(tips)

判断鼠标是否在UI上

返回当前鼠标位置是否在UI上,(用于替换EventSystem.current.IsPointerOverGameObject(),避免当前窗口因启用交互或同时需要考虑多层UI的层级关系,而启用覆盖全屏幕的遮罩Mask的RaycastTaret,使得鼠标处于UI窗口外时,Unity API一直错误的返回在UI上)。

//bool
UISystem.CheckMouseOnUI();

日志系统

日志系统用于在控制台输出Log、Success、Error、Warning的提示信息(用白色、绿色、红色、黄色加以区分),并可以进行本地自定义命名保存,可以在面板上勾选是否启用日志输出、写入时间(毫秒级定位)、线程ID、堆栈(定位提示代码行)、本地保存。

alt

保留Unity提示自带的代码连接跳转功能。

alt

本地保存的日志可以用于在打包后进行调试输出。

alt

//API
//输出日志测试信息,等同于Debug.Log
JKLog.Log("测试Log");
//输出Warning类型的提示信息
JKLog.Warning("测试Warning");
//输出Error类型的提示信息
JKLog.Error("测试Error");
//输出Succeed类型的输出信息
JKLog.Succeed("测试Succeed");
  • 在方法参数部分传入要输出的字符串信息即可。

事件工具

用于给游戏对象快速绑定事件,而无需手动给游戏对象挂载脚本,功能逻辑在当前脚本实现。与事件系统区分:事件系统重点在于提供了一个事件监听添加和事件触发解耦的中间模块,使得事件的触发无需关注依赖的对象,但事件执行的功能逻辑还是要实现在对象挂载的脚本上的。而事件工具重点在于快速为游戏对象绑定常见的响应事件,这类事件不由脚本触发(后续支持自定义脚本触发条件),而是在特定的时机如碰撞、鼠标点击、对象销毁时自动触发,因此重点关注事件监听添加的简化,所有逻辑在当前脚本完成。

框架内置事件绑定与移除

鼠标相关事件

鼠标进入、鼠标移出、鼠标点击、鼠标按下、鼠标抬起、鼠标拖拽、鼠标拖拽开始、鼠标拖拽结束事件的绑定与移除。

//鼠标进入
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnMouseEnter<TEventArg>(action, args);
xx.OnMouseEnter(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnMouseEnter(action); //无参Action
xx.RemoveOnMouseEnter<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标移出
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnMouseExit<TEventArg>(action, args);
xx.OnMouseExit(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnMouseExit(action); //无参Action
xx.RemoveOnMouseExit<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标点击
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnClick<TEventArg>(action, args);
xx.OnClick(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnClick(action); //无参Action
xx.RemoveOnClick<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标按下
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnClickDown<TEventArg>(action, args);
xx.OnClickDown(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnClickDown(action); //无参Action
xx.RemoveOnClickDown<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标抬起
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnClickUp<TEventArg>(action, args);
xx.OnClickUp(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnClickUp(action); //无参Action
xx.RemoveOnClickUp<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标拖拽
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnDrag<TEventArg>(action, args);
xx.OnDrag(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnDrag(action); //无参Action
xx.RemoveOnDrag<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标拖拽开始
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnBeginDrag<TEventArg>(action, args);
xx.OnBeginDrag(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnBeginDrag(action); //无参Action
xx.RemoveOnBeginDrag<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标拖拽结束
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnEndDrag<TEventArg>(action, args);
xx.OnEndDrag(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnEndDrag(action); //无参Action
xx.RemoveOnEndDrag<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//使用示例
Transform cube;
void Start()
{
    cube.OnClick<int>(Test1,1);
}
private void Test1(PointerEventData arg1, int arg2)
{
    Debug.Log(1);
    cube.RemoveOnClick<int>(Test1);
}
  • xx为绑定事件的对象组件,事件工具基于拓展方法调用,xx使用游戏对象的transform即可。
  • TEventArg指定事件的参数类型,添加监听时可以不填,可以通过参数args推断出,移除监听时则必须显示指出。
  • action是绑定的事件,根据事件类型(鼠标、碰撞、自定义事件),其方法的参数列表包含两部分,第一部分是事件本身的参数(PointerEventData、Collision),第二部分是参数列表TEventArg,可以通过值元组传入多个参数。

碰撞相关事件

2D、3D相关的碰撞事件绑定与移除。

//API
//3D碰撞进入
//(this Component com, Action<Collision, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionEnter<TEventArg>(action, args);
xx.OnCollisionEnter(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionEnter(action); //无参Action
xx.RemoveOnCollisionEnter<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//3D碰撞持续
//(this Component com, Action<Collision, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionStay<TEventArg>(action, args);
xx.OnCollisionStay(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionStay(action); //无参Action
xx.RemoveOnCollisionStay<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//3D碰撞脱离
//(this Component com, Action<Collision, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionExit<TEventArg>(action, args);
xx.OnCollisionExit(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionExit(action); //无参Action
xx.RemoveOnCollisionExit<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D碰撞进入
//(this Component com, Action<Collision2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionEnter2D<TEventArg>(action, args);
xx.OnCollisionEnter2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionEnter2D(action); //无参Action
xx.RemoveOnCollisionEnter2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D碰撞持续
//(this Component com, Action<Collision2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionStay2D<TEventArg>(action, args);
xx.OnCollisionStay2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionStay2D(action); //无参Action
xx.RemoveOnCollisionStay2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D碰撞脱离
//(this Component com, Action<Collision2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionExit2D<TEventArg>(action, args);
xx.OnCollisionExit2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionExit2D(action); //无参Action
xx.RemoveOnCollisionExit2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//简单示例
void Start()
{
    cube.OnCollisionEnter(Test2, 2);
}


private void Test2(Collision arg1, int arg2)
{
    Debug.Log(arg2);
    cube.RemoveOnCollisionEnter<int>(Test2);
}
  • 碰撞事件和鼠标事件的API类似,区别参与action的第一个事件本身参数不同,为Collision/Collision2D。
  • xx为绑定事件的对象组件,使用游戏对象的transform即可。
  • TEventArg指定事件的参数类型,添加监听时可以不填,可以通过参数args推断出,移除监听时则必须显示指出。
  • action是绑定的事件,根据事件类型(鼠标、碰撞、自定义事件),其方法的参数列表包含两部分,第一部分是事件本身的参数(PointerEventData、Collision),第二部分是参数列表TEventArg,可以通过值元组传入多个参数。

触发相关事件

2D、3D相关的触发事件绑定。

//API
//3D触发进入
//(this Component com, Action<Collider, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerEnter<TEventArg>(action, args);
xx.OnTriggerEnter(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerEnter(action); //无参Action
xx.RemoveOnTriggerEnter<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//3D触发持续
//(this Component com, Action<Collider, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerStay<TEventArg>(action, args);
xx.OnTriggerStay(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerStay(action); //无参Action
xx.RemoveOnTriggerStay<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//3D触发脱离
//(this Component com, Action<Collider, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerExit<TEventArg>(action, args);
xx.OnTriggerExit(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerExit(action); //无参Action
xx.RemoveOnTriggerExit<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D触发进入
//(this Component com, Action<Collider2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerEnter2D<TEventArg>(action, args);
xx.OnTriggerEnter2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerEnter2D(action); //无参Action
xx.RemoveOnTriggerEnter2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D碰撞持续
//(this Component com, Action<Collider2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerStay2D<TEventArg>(action, args);
xx.OnTriggerStay2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerStay2D(action); //无参Action
xx.RemoveOnTriggerStay2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D触发脱离
//(this Component com, Action<Collider2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerExit2D<TEventArg>(action, args);
xx.OnTriggerExit2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerExit2D(action); //无参Action
xx.RemoveOnTriggerExit2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//简单示例


void Start()
{
    cube.OnTriggerEnter(Test3, 2);
}


private void Test3(Collider arg1, int arg3)
{
    Debug.Log(arg3);
    cube.RemoveOnTriggerEnter<int>(Test3);
}
  • 触发事件和碰撞事件的API类似,区别参与action的第一个事件本身参数不同,为Collider/Collider2D。
  • xx为绑定事件的对象组件,使用游戏对象的transform即可。
  • TEventArg指定事件的参数类型,添加监听时可以不填,可以通过参数args推断出,移除监听时则必须显示指出。
  • action是绑定的事件,根据事件类型(鼠标、碰撞、自定义事件),其方法的参数列表包含两部分,第一部分是事件本身的参数(PointerEventData、Collision),第二部分是参数列表TEventArg,可以通过值元组传入多个参数。

资源相关事件

资源释放,对象销毁时绑定的事件。

//API
//资源释放(Addressable)
//(this Component com, Action<GameObject, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnReleaseAddressableAsset<TEventArg>(action, args);
xx.OnReleaseAddressableAsset(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnReleaseAddressableAssetOnReleaseAddressableAsset(action); //无参Action
xx.RemoveOnReleaseAddressableAsset<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//对象销毁
//(this Component com, Action<GameObject, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnDestroy<TEventArg>(action, args);
xx.OnDestroy(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnDestroy(action); //无参Action
xx.RemoveOnDestroy<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//简单实例
void Start()
{
    cube.OnDestroy(Test4, 4);
}
private void Test4(GameObject arg1, int arg4)
{
    Debug.Log(arg4);
    cube.RemoveOnDestroy<int>(Test4);
}
  • xx为绑定事件的对象组件,使用游戏对象的transform即可。
  • TEventArg指定事件的参数类型,添加监听时可以不填,可以通过参数args推断出,移除监听时则必须显示指出。
  • action是绑定的事件,根据事件类型(鼠标、碰撞、自定义事件),其方法的参数列表包含两部分,第一部分是事件本身的参数(PointerEventData、Collision),第二部分是参数列表TEventArg,可以通过值元组传入多个参数。

移除一类事件

移除鼠标/碰撞/触发/资源的一类所有事件。

//API
//int customEventTypeInt, JKEventType eventType
RemoveAllListener(customEventTypeInt);
RemoveAllListener(eventType);
RemoveAllListener();
  • customEventTypeInt/eventType为事件的类型,对应碰撞、鼠标事件对应的枚举类型或自定义事件的类型int值。
  • 不填则移除所有事件。

使用值元组传递多个事件参数

通过ValueTuple封装一个简单的参数列表结构体。

    void Start()
    {
        cube.OnClick(Test5, (arg1: 2, arg2: "test", arg3: true));
        //等同于下一行代码,上一行更简便,参数类型可以自动推断出
        cube.OnClick(Test5, ValueTuple.Create<int,string,bool>(1,"test",true));
    }


    private void Test5(PointerEventData arg1, (int arg1, string arg2, bool arg3) args)
    {
        Debug.Log($"{args.arg1},{args.arg2},{args.arg3}");
        cube.RemoveOnClick<(int arg1, string arg2, bool arg3)>(Test5);
    }

自定义事件类型

以上鼠标、碰撞等事件的触发由事件工具结合特定的时机自动完成,如果希望自定义事件的触发逻辑,则需要添加新的事件类型,对应在适合的地方触发事件,此时事件工具的作用与事件系统类似,区别在于不需要为对象挂载脚本。

//API
//(this Component com, int customEventTypeInt, Action<T, TEventArg> action, TEventArg args = default(TEventArg))
xx.AddEventListener<T, TEventArg>(customEventTypeInt, action, args);
xx.RemoveEventListener<T, TEventArg>(customEventTypeInt, action);
cube.TriggerCustomEvent<Transform>((int)myType.CustomType, transform);


//使用示例
    void Start()
    {
        cube.AddEventListener<Transform, int>((int)myType.CustomType, Test6, 1);
    }


    private void Test6(Transform arg1, int arg2)
    {
        Debug.Log(arg1.position = Vector3.zero);
        cube.RemoveEventListener<Transform, int>((int)myType.CustomType, Test6);
    }
    enum myType
    {
        CustomType = 0,
    }
  • customEventTypeInt是自定义的事件类型,是一个int值,可以使用枚举对应事件的类型。
  • T指明了自定义事件所使用的eventData,可以在触发的时候传入T以供使用,等同于Collision/Collider/PointerEventData。
  • args是参数列表。

补充说明

  • 事件工具针对的事件触发时都会提供eventData用于获取触发时的特定数据用于操作(有点类似于异步callback回调时传的那个参数),比如PointerEventData,因此他们与对象绑定,就算不传任何参数,触发时还是可以根据eventData去获取一些信息,比如碰撞发生的位置。
  • 开发事件工具的目的在于快速为游戏对象添加一类事件的监听,而不需要为其手动挂载脚本(类似于button.OnClick.AddEventListener,但Unity只支持按钮的自动添加,而事件工具支持常见的所有事件类型),实际上会自动为其自动挂载JKEventListener脚本,其中有对应事件的监听方法以及内置碰撞/鼠标等事件的触发方法。
  • 自定义事件类型是支持的,但此时事件的类型,触发需要自己实现。
  • 事件系统作用在于解耦对象和事件触发的逻辑,让事件中心保存监听的方法,触发时不需要访问对象。而事件工具所负责的是一类与对象强关联的事件,用于解耦对象和事件监听添加的逻辑,不需要手动挂载脚本。二者联动的效果就是A使用事件工具直接为B添加事件监听C,C内部再通过事件系统包一层添加事件监听D,这样外界就可以通过直接访问事件中心触发D(可能的应用场景比如要给所有子弹添加碰撞分解效果,这样无论是事件监听添加还是事件的触发都可以在一个脚本中完成,不需要手动给所有子弹度挂载脚本,也不需要触发时访问所有子弹对象)。

状态机

游戏中的状态机(state machine)是一种在编程中常用的概念,它用于表示对象或系统的状态以及从一个状态到另一个状态的转换。在游戏中,状态机通常被用于表示游戏对象的状态,例如玩家角色的行动状态,或者敌人的攻击状态。每个状态都有一个特定的行为或属性,而状态之间的转换通常是由特定的事件触发的,例如按下某个按钮或达到某个条件。框架中提供了状态基类和转换功能逻辑。

状态机的初始化

使用状态机,本质就是持有状态机脚本的引用与其绑定,为此控制脚本(比如角色控制器PlayerController)需要持有状态机对象,其也是一类脚本资源,可以通过资源系统进行回收管理。

//API
stateMachine.Init<T>(owner);
stateMachine.Init(owner);
//简单实例
public class PlayerController : MonoBehaviour, IStateMachineOwner
{
    StateMachine stateMachine;
    private void Start()
    {
        stateMachine = ResSystem.GetOrNew<StateMachine>();
        //初始化时进入默认状态Idle
        stateMachine.Init<PlayerIdleState>(this);
  
        //初始化时不进入默认状态
        stateMachine.Init(this);
    }
}
  • PlayerController需要继承IStateMachineOwner接口,目的是限制Init中填入的对象必须为接口子类;
  • 宿主owner用于将PlayerController作为引用传递给stateMachine,因为stateMachine不继承MonoBehavior,想要获取PlayerController的引用相对麻烦,所以直接传递引用。
  • T(PlayerIdleState)是初始的状态类,使用参数时会自动进入T状态,不填则状态机待机,等待进入新状态。

状态类

每一个状态实际都是一个状态类脚本,状态机通过切换调用其中的方法完成状态逻辑的切换,状态类脚本继承自状态基类StateBase,包含状态的初始化、进入、退出、Unity生命周期函数(虽然不继承MonoBehaviour,通过托管系统可以实现),初始化只执行一次,可以通过StateMachine调用state的Init方法传递过来的owner获取宿主的信息,比如在角色移动的相关状态中就可以获取Player的Transform组件。

public class PlayerIdleState:StateBase
{
    Transform transform;
    public override void Init(IStateMachineOwner owner)
    {
        transform = ((PlayerController)owner).transform;
    }


    public override void Enter()
    {
        base.Enter();
    }
    public override void Exit()
    {
        base.Exit();
    }
    public override void Update()
    {
        base.Update();
    }
    public override void FixedUpdate()
    {
        base.FixedUpdate();
    }


    public override void LateUpdate()
    {
        base.LateUpdate();
    }
}

状态的切换

在切换状态时,状态类会先被获取并存储起来供下次重复使用,当前状态的所有方法停止工作,切换到新状态的方法执行,在实际使用时,可以再封装一层ChangeState逻辑使用枚举与不同的状态类对应,简化代码。

//API
//(bool reCurrstate = false)
stateMachine.ChangeState<T>(reCurrstate)
  • reCurrstate指当前状态和要切换的状态相同时是否还要切换,默认为False相同状态不执行切换。

状态共享数据

为owner-stateMachine下的所有状态提供共享字典。

public void ShareData()//状态类内
{
    //(string key, object data)
    RemoveShareData("test");
    ContainsShareData("test");
    AddShareData("test",1);
    int result = 0;
    TryGetShareData<int>("test", out result);
    UpdateShareData("test", 1);
    CleanShareData();
  
    stateMachine.CleanShareData();
  
}
  • key,data是需要共享的字典数据,提供CRUD的API,共享数据可以在状态类内使用,也可以通过stateMachine调用API进行CRUD。

状态机状态清空与销毁

  • 状态机停止工作Stop时会清空所保存的所有状态类放入对象池,状态机本身与宿主的引用仍旧保留,可供下次直接使用。
  • 状态机销毁时Destroy除停止工作外会释放与宿主的引用关系,并将自己放回对象池回收利用。
stateMachine.Stop();
stateMachine.Destroy();

场景系统

场景系统提供了正常加载场景和异步加载的若干API。

正常加载场景

//API
//(string sceneName, LoadSceneMode mode = LoadSceneMode.Single)
SceneSystem.LoadScene(sceneName, LoadSceneMode mode = LoadSceneMode.Single);
//(int sceneBuildIndex, LoadSceneMode mode = LoadSceneMode.Single)
SceneSystem.LoadScene(sceneBuildIndex, LoadSceneMode mode = LoadSceneMode.Single);
//(string sceneName, LoadSceneParameters loadSceneParameters)
SceneSystem.LoadScene(sceneName, loadSceneParameters);
//(int sceneBuildIndex, LoadSceneParameters loadSceneParameters)
SceneSystem.LoadScene(sceneBuildIndex, loadSceneParameters);


//使用实例 加载SampleScene场景并Destroy当前场景
SceneSystem.LoadScene("SampleScene",LoadSceneMode.Single);
  • sceneName对应BuildSetting中的场景名。
  • sceneBuildIndex对应BuildSetting中的场景索引号。
  • mode是场景的加载模式,默认为Single表示加载新场景会销毁当前场景,Additive则保留当前场景,将新场景加入到当前场景中。
  • loadSceneParameters是场景加载的参数,除可指定加载模式外,还可指定优先级等,具体如图。

alt

异步加载场景

异步加载场景可在加载大规模场景时不阻塞主线程,而是通过协程或回调等方式在后台加载场景资源,并在加载完成后通知游戏主线程。

异步加载过程中主线程获取进度的方式有两种:

  • 场景系统异步加载时会将加载进度传递到事件中心中,可以通过监听"LoadingSceneProgress"、"LoadSceneSucceed"事件获取加载进度。
  • 异步加载提供了回调事件参数,可以通过传入回调函数获取加载进度。
//API
//(string sceneName, Action<float> callBack = null, LoadSceneMode mode = LoadSceneMode.Single)
SceneSystem.LoadSceneAsync(sceneName, callBack, mode);
//(int sceneBuildIndex, Action<float> callBack = null, LoadSceneMode mode = LoadSceneMode.Single)
SceneSystem.LoadSceneAsync(sceneBuildIndex, callBack, mode);


//简单实例 异步加载场景SampleScene并实时输出加载进度,在加载完成时输出Success
//方式1 监听事件获取加载进度
SceneSystem.LoadSceneAsync("SampleScene");
//(float不写也行,"LoadingSceneProgress"、"LoadSceneSucceed"为固定名称)
EventSystem.AddEventListener<float>("LoadingSceneProgress", LoadProgress);
EventSystem.AddEventListener("LoadSceneSucceed");


//方式2 传入回调事件callBack
SceneSystem.LoadSceneAsync("SampleScene", LoadProgress);
EventSystem.AddEventListener("LoadSceneSucceed");


void LoadProgress(float progress)
{
    Debug.Log(progress);
}
void LoadSceneSucceed()
{
    Debug.Log("Success");
}
  • sceneName对应BuildSetting中的场景名。
  • sceneBuildIndex对应BuildSetting中的场景索引号。
  • mode是场景的加载模式,默认为Single表示加载新场景会销毁当前场景,Additive则保留当前场景,将新场景加入到当前场景中。
  • callBack是float参数无返回值回调事件,用于获取加载进度。

Mono代理系统

Mono代理系统用于不继承MonoBehavior的脚本启用mono生命周期函数和协程,比如状态机里的状态类,场景系统异步加载时的协程,除代理系统外框架中的各系统都是静态工具类,需要使用Mono的相关方法则通过代理系统完成,因此也只有MonoSystem挂载在面板上,其内部实现是单例。

alt

Mono生命周期函数

将需要在Update、LateUpdate。FixedUpdate实际执行的逻辑托管给MonoSystem。

//(Action action)
MonoSystem.AddUpdateListener(action);
MonoSystem.RemoveUpdateListener(action);
MonoSystem.AddLateUpdateListener(action);
MonoSystem.RemoveLateUpdateListener(action);
MonoSystem.AddFixedUpdateListener(action);
MonoSystem.RemoveFixedUpdateListener(action);
  • action是要在生命周期执行的无参无返回值方法。

协程

启动/停止协程。

//API
//启动/停止一个协程
//(IEnumerator coroutine)
MonoSystem.Start_Coroutine(coroutine);
//(Coroutine routine)
MonoSystem.Stop_Coroutine(routine);


//启动/停止一个协程序并且绑定某个对象
//(object obj,IEnumerator coroutine)
MonoSystem.Start_Coroutine(obj, coroutine);
//(object obj,Coroutine routine)
MonoSystem.Stop_Coroutine(obj, routine);


//停止某个对象绑定的所有协程
MonoSystem.StopAllCoroutine(obj);


//停止所有协程
MonoSystem.StopAllCoroutine();
  • coroutine是一个迭代器,定义了协程。
  • routine是要停止的协程。
  • obj是与协程绑定的对象,可以用于区分不同对象上的相同协程。

协程工具

提前new好协程所需要的WaitForEndOfFrame、WaitForFixedUpdate、YieldInstruction类的对象,避免GC。

CoroutineTool.WaitForEndOfFrame();
CoroutineTool.WaitForFixedUpdate();
//(float time)
CoroutineTool.WaitForSeconds(time);
//(float time)不受TimeScale影响
CoroutineTool.WaitForSecondsRealtime(time);
//(int count=1)
CoroutineTool.WaitForFrames(count);
CoroutineTool.WaitForFrame();


//使用示例
private static IEnumerator DoLoadSceneAsync(...)
{
    yield return CoroutineTool.WaitForFrame();
}

扩展方法

框架提供了若干扩展方法用于快速调用与对象强关联的系统方法。

//API
//比较两个对象数组,返回bool
//(this object[] objs, object[] other)
xx.ArraryEquals(other);


//调用MonoSystem添加/移除生命周期函数
//(this object obj, Action action)
xx.AddUpdate(action);
xx.removeUpdate(action);
xx.AddLateUpdate(action);
xx.RemoveLateUpdate(action);
xx.AddFixedUpdate(action);
xx.RemoveLateUpdate(action);


//调用MonoSystem启动/停止协程(绑定此对象)
//(this object obj, IEnumerator routine)
xx.StartCoroutine(routine);
//(this object obj, Coroutine routine)
xx.StopCoroutine(routine);
xx.StopAllCoroutine();


//判断GameObject是否为空,返回bool
xx.IsNull();


//当前对象放入对象池
//(this GameObject go)
xx.GameObjectPushPool();
//(this Component com)
xx.GameObjectPushPool();
//(this object obj)
xx.ObjectPushPool();

本地化系统

用于切换不同语言对应的文字素材和图片素材,主要用于UI。

本地化配置文件的创建

project面板右键创建Localzation Config,通过SO的方式记录语言配置,红色框为一类素材的key,对应下属若干不同语言的素材,支持文字string或者图片Sprite内容,使用时通过调用这里的资源进行切换,可以作为全局或者专属于某一UI对象的本地化配置文件(即持有此config的SO并通过key和languagetype获取本地化内容)。

alt

全局配置

alt

将创建的Config拖拽给JKFrame下的LocalizationSystem组件,全局的本地化配置绑定完成,通过修改LocalizationSystem的LanguageType修改语言。(可以在运行时下修改全局配置)

API

//切换全局配置的当前语言类型(面板上显示的那个)
LocalizationSystem.LanguageType = LanguageType.SimplifiedChinese;
//注册/注销语言更新时触发的事件(含有LanguageType参数的无返回值方法)
LocalizationSystem.RegisterLanguageEvent(Action<LanguageType> action)
LocalizationSystem.UnregisterLanguageEvent(Action<LanguageType> action)
//获取全局配置文件的某一语言下的数据(文本/图片)
LocalizationSystem.GetContent<T>(string key, LanguageType languageType)
//继承UIWindowBase的窗口脚本可以重写语言更新事件
override void OnUpdateLanguage(LanguageType languageType)


//使用案例
//1.通过脚本直接获得全局本地化配置的数据内容
//文本string
string info = LocalizationSystem.GetContent<LocalizationStringData>("标题", LanguageType.SimplifiedChinese).content;//指定中文
string info = LocalizationSystem.GetContent<LocalizationStringData>("标题", LocalizationSystem.LanguageType).content;//当前全局本地化配置的语言
//Sprite图片
Sprite image = LocalizationSystem.GetContent<LocalizationImageData>("标题图片", LanguageType.SimplifiedChinese).content;


//2.通过Collecter由拖拽的方式绑定UI组件和语言配置(见下一小节)
//3.通过重写OnUpdateLanguage定制语言更新时的事件触发(见下一小节)
   
  • action是一个含有languageType的单参数无返回值方法,用于结合传入的语言类型定制触发事件逻辑。
  • 泛型T(LocalizationStringData/LocalizationImageData)用于限定GetContent返回的数据类型,目前支持string和Sprite,可以进行拓展。
  • key是本地化配置文件SO中的数据key。
  • LocalizationSystem.LanguageType是当前游戏的语言类型,修改会触发索引中的语言更新方法,进而触发所有窗口的语言更新事件修改语言类型。

UI特化工具及局部配置

alt

  • 在UI框架中继承UIWindowBase的窗口类会自动持有一个本地化配置A用于窗口的局部配置(可用可不用,只是提供了一个数据传入的接口)。
  • 方便起见,,直接通过面板拖拽的方式转递对象和其对应的配置数据key(任一语言即可),即完成了本地化配置,无需通过脚本访问(比如Title文本组件对应配置中的标题key)。注意,此时的Localization Config是一个专属于此UI的局部配置文件(且与持有的本地化配置A可以不同)。

PS:当局部配置找不到对应的key时,底层规定会去全局本地化配置表里寻找。

alt

集成了UI_WindowBase的UI窗口类可以通过重写OnUpdateLanguage方法定制语言更新时的事件触发,比如文字拼接部分更新。

    public Text test;
    protected override void OnUpdateLanguage(LanguageType languageType) {
        string info = LocalizationSystem.GetContent<LocalizationStringData>("标题", languageType).content;
        info += "test";
        test.text = info;
    }

拓展

尽管本地化系统目前仅支持文字和Sprite的切换,但是对于音效,配音等资源的切换也可以很方便拓展,这部分功能就不做预制了,由开发者自行拓展,以下是拓展思路。

//拓展API
//获取全局配置文件的某一语言下的数据(文本/图片)
LocalizationSystem.GetContent<T>(string key, LanguageType languageType)


//拓展位置  LocalizationData.cs
public abstract class LocalizationDataBase
{
}
public class LocalizationStringData : LocalizationDataBase
{
    public string content;
}
public class LocalizationImageData : LocalizationDataBase
{
    public Sprite content;
}
  • 在本地化系统的内部实现中,GetContent的泛型参数T指定了本地化保存的数据类型,内置了LocalizationStringData和LocalIzationImageData两种数据类型,分别持有string成员和Sprite成员,对应文本和图片数据。
  • 有两种修改思路,一种是在已有的两个个数据类型添加额外的数据成员,比如一个武器在UI上的显示除了有描述内容还有武器的类型string。
public class LocalizationStringData : LocalizationDataBase
{
    public string content;
    public string type;
}
  • 另外一种是直接继承抽象类LocalizationDataBase写一个新类,比如还是武器类型和武器描述的UI本地化数据,其实两种方式本质也没啥区别,只是说明数据类数量和类内的数据成员都可以扩展满足开发者想要的需求。
public class LocalizationWeaponData : LocalizationDataBase
{
    public string content;
    public string type;
}

浅入理解流式SSR的性能收益与工作原理

2025年11月23日 12:51

什么是流式 SSR

流式 SSR(Streaming Server-Side Rendering)是一种将服务端渲染和流式传输结合起来的技术。与传统的 SSR 不同,流式 SSR 可以在服务端渲染的同时,逐步将渲染结果传输到客户端,实现页面的渐进式展示。

在流式 SSR 中,服务端会根据客户端的请求,逐步生成页面内容,并将它们作为流式数据流式传输到客户端。客户端可以在接收到一部分数据后,就开始逐步显示页面,而不需要等待整个页面渲染完成。这种方式可以有效提高页面的加载速度和用户体验。

20251111002406.jpg (流式 SSR 的页面加载过程)

使用流式 SSR 的收益

  • 减少设备和网络情况的副作用

    这是 SSR 渲染模式相比于传统 CSR 渲染模式带来的优势,对于流式 SSR 应用同样适用。 CSR 渲染模式需要在终端设备上完成资源加载、数据加载以及整个渲染过程,受端侧设备自身 CPU 及网络性能影响大,如设备 CPU 性能不足或网络波动较大,则此时整个页面加载性能将严重下降,这也是为什么很多页面在高端设备上加载速度较快,但在低端设备上需要7-8秒的原因。

    而 SSR 的渲染模式,则在服务端提供了高性能的渲染容器及网络环境,服务端的渲染,不受端侧设备 CPU 或网络影响,始终提供稳定的渲染性能表现

    fa9a1944-b6b4-4e22-980a-47bf604c2e2c.png

  • 减少接口过慢对首屏性能的影响

    通常,页面会由多个区块组成,其中一些区块不依赖于数据,而其他区块可能依赖于快速或缓慢响应的接口。

    现有 SSR 渲染模式存在的一个不足是,整个渲染过程是同步的,需要在页面渲染之前完成所有数据请求,并一次性返回整个页面的 HTML。如果页面的某些接口响应过慢,将会导致整个页面的响应时间过长。

    流式渲染的最大好处在于它可以分块返回页面内容。例如,当请求进入时,它可以首先完成页面静态内容的渲染并响应给端侧进行渲染,等待其他依赖于数据的区块完成渲染后再分块返回。这样整个页面的渲染过程不再绑定在一起,而是一个异步的过程,先完成渲染的部分将先返回,从而优化了页面响应速度。

  • 提前资源的加载时机

    流式 SSR 相比传统 SSR 应用有另一个额外的收益是,由于 HTML 可以分块返回,页面的资源信息可以随第一个 HTML 片段一起下发,从而尽快开始加载。相比之下,在传统 SSR 应用中,资源信息必须等待整个 HTML 完成渲染后下发,请求开始的时机会受到渲染过程的阻塞。

    采用流式 SSR,可以使资源请求和页面渲染过程并行进行,进一步提升了页面的性能表现。

    752472f2-cf39-4c89-9738-e4ad3e084ba9.png

  • 提升页面的可交互时间

    在 SSR 渲染模式下,将页面节点达到可交互状态的过程称为 Hydrate,它需要在端侧执行 JavaScript。

    由于页面资源可以提前下发,并且 React 18 对 Hydrate 进行了异步化处理,在流式 SSR 应用中,可以进一步实现先渲染的页面先达到可交互状态的效果。对于部分首屏接口较慢的应用,这将进一步提升页面的可交互体验。

    20251111002828.jpg

基本工作原理

流式 SSR 的实现,最基本的原理是:

  • 基于 HTTP 协议中的 chunked 编码规范,设置响应头的 Transfer-Encoding 为 chunked 对 HTML 内容进行分块传输。
  • 在浏览器侧,流式地读取数据并进行渲染,这是主流浏览器默认支持的。

结合 Node.js 内置的 HTTP 模块,实现一个最简单的流式 DEMO 示例如下:

const http = require('http');

const server = http.createServer(async (req, res) => {
  res.setHeader('Content-Type', 'text/html');
  res.setHeader('Transfer-Encoding', 'chunked')

  // 分区块的传输页面内容
  res.write('<html>');
  res.write('<head><title>Stream Demo</title><head>');
  res.write('<body>');

  // 模拟服务端暂停
  await sleep(3000);
  res.write('<h2>Hello</h2>');

  await sleep(3000);
  res.write('<h2>ICE 3</h2>');
  res.write('</body></html>');
  res.end();
});

server.listen(3000);

基于这个基本原理,将页面分为骨架屏和几个区块,并行地渲染这些区块,然后将渲染好的区块分段返回,就可以实现基本的流式 SSR 。

WebView 接收流式Chunk渲染的实现原理(iOS)

核心组件

iOS WebView 接收流式 Chunk 渲染基于 NSURLProtocol 拦截机制 + NSURLProtocolClient 回调机制 实现。

NSURLProtocol(请求拦截器)

// 拦截 WebView 的网络请求
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    return [self shouldInterceptRequest:request];
}

- (void)startLoading {
    // 转发给自定义处理器
    [[SSRHandler shareInstance] sendRequest:self.request delegate:self];
}

NSURLProtocolClient(数据传递桥梁)

// 系统提供的协议,用于向 WebView 传递数据
@protocol NSURLProtocolClient
- (void)URLProtocol:didReceiveResponse:cacheStoragePolicy:;  // 响应头
- (void)URLProtocol:didLoadData:;                           // 数据块(可多次)
- (void)URLProtocolDidFinishLoading:;                       // 完成
- (void)URLProtocol:didFailWithError:;                      // 错误
@end

渲染流程

  • 阶段一:请求拦截

    WebView 发起请求 → NSURLProtocol 拦截 → 转发给 SSR 处理器
    
  • 阶段二:响应处理

    // 1. 返回响应头
    [client URLProtocol:protocol didReceiveResponse:response cacheStoragePolicy:policy];
    
    // 2. 流式返回数据(关键步骤)
    [client URLProtocol:protocol didLoadData:chunk1];  // 第1块
    [client URLProtocol:protocol didLoadData:chunk2];  // 第2块
    [client URLProtocol:protocol didLoadData:chunkN];  // 第N块
    
    // 3. 标记完成
    [client URLProtocolDidFinishLoading:protocol];
    
  • 阶段三:WebView 渲染

    每次 didLoadData 调用 → WebView 增量解析 HTML → 实时渲染到页面
    

数据流图示如下

┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│   WebView   │───▶│NSURLProtocol │───▶│ SSR Handler │
│             │    │              │    │             │
│             │◀───│              │◀───│             │
└─────────────┘    └──────────────┘    └─────────────┘
       ▲                   │
       │                   ▼
   增量渲染          NSURLProtocolClient
       ▲                   │
       │                   ▼
   ┌─────────────────────────────┐
   │     didLoadData (chunk1)    │
   │     didLoadData (chunk2)    │
   │     didLoadData (chunkN)    │
   │  URLProtocolDidFinishLoading │
   └─────────────────────────────┘

Vue3 自定义指令深度解析:从基础到高级应用的完整指南

作者 北辰alk
2025年11月23日 11:31

摘要

自定义指令是 Vue.js 中一个强大而灵活的特性,它允许开发者直接对 DOM 元素进行底层操作。Vue3 在保留自定义指令核心概念的同时,对其 API 进行了调整和优化,使其更符合组合式 API 的设计理念。本文将深入探讨 Vue3 中自定义指令的定义方式、生命周期钩子、使用场景和最佳实践,通过丰富的代码示例和清晰的流程图,帮助你彻底掌握这一重要特性。


一、 什么是自定义指令?为什么需要它?

1.1 自定义指令的概念

在 Vue.js 中,指令是带有 v- 前缀的特殊属性。除了 Vue 内置的指令(如 v-modelv-showv-if 等),Vue 还允许我们注册自定义指令,用于对普通 DOM 元素进行底层操作。

1.2 使用场景

自定义指令在以下场景中特别有用:

  1. DOM 操作:焦点管理、文本选择、元素拖拽
  2. 输入限制:格式化输入内容、阻止无效字符
  3. 权限控制:根据权限显示/隐藏元素
  4. 集成第三方库:与 jQuery 插件、图表库等集成
  5. 性能优化:图片懒加载、无限滚动
  6. 用户体验:点击外部关闭、滚动加载更多

1.3 Vue2 与 Vue3 自定义指令的区别

特性 Vue2 Vue3
生命周期钩子 bind, inserted, update, componentUpdated, unbind created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted
参数传递 el, binding, vnode, oldVnode el, binding, vnode, prevVnode
注册方式 全局 Vue.directive(),局部 directives 选项 全局 app.directive(),局部 directives 选项
与组合式API集成 有限 更好,可在 setup 中使用

二、 自定义指令的基本结构

2.1 指令的生命周期钩子

Vue3 中的自定义指令包含一系列生命周期钩子,这些钩子在指令的不同阶段被调用:

流程图:自定义指令生命周期

flowchart TD
    A[指令创建] --> B[created<br>元素属性/事件监听器应用之前]
    B --> C[beforeMount<br>元素挂载到DOM之前]
    C --> D[mounted<br>元素挂载到DOM之后]
    D --> E{指令绑定值变化?}
    E -- 是 --> F[beforeUpdate<br>元素更新之前]
    F --> G[updated<br>元素更新之后]
    E -- 否 --> H[元素卸载]
    H --> I[beforeUnmount<br>元素卸载之前]
    I --> J[unmounted<br>元素卸载之后]

2.2 钩子函数参数

每个生命周期钩子函数都会接收以下参数:

  • el:指令绑定的元素,可以直接操作 DOM
  • binding:一个对象,包含指令的相关信息
  • vnode:Vue 编译生成的虚拟节点
  • prevVnode:上一个虚拟节点(仅在 beforeUpdateupdated 中可用)

binding 对象结构:

{
  value:        any,        // 指令的绑定值,如 v-my-directive="value"
  oldValue:     any,        // 指令绑定的前一个值
  arg:          string,     // 指令的参数,如 v-my-directive:arg
  modifiers:    object,     // 指令的修饰符对象,如 v-my-directive.modifier
  instance:     Component,  // 使用指令的组件实例
  dir:          object      // 指令的定义对象
}

三、 定义自定义指令的多种方式

3.1 全局自定义指令

全局指令在整个 Vue 应用中都可用。

方式一:使用 app.directive()

// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 定义全局焦点指令
app.directive('focus', {
  mounted(el) {
    el.focus()
    console.log('元素获得焦点')
  }
})

// 定义全局颜色指令(带参数和值)
app.directive('color', {
  beforeMount(el, binding) {
    el.style.color = binding.value
  },
  updated(el, binding) {
    el.style.color = binding.value
  }
})

app.mount('#app')

方式二:使用插件形式

// directives/index.js
export const focusDirective = {
  mounted(el) {
    el.focus()
  }
}

export const colorDirective = {
  beforeMount(el, binding) {
    el.style.color = binding.value
  },
  updated(el, binding) {
    el.style.color = binding.value
  }
}

// 注册所有指令
export function registerDirectives(app) {
  app.directive('focus', focusDirective)
  app.directive('color', colorDirective)
}

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { registerDirectives } from './directives'

const app = createApp(App)
registerDirectives(app)
app.mount('#app')

3.2 局部自定义指令

局部指令只在特定组件中可用。

选项式 API

<template>
  <div>
    <input v-focus-local placeholder="局部焦点指令" />
    <p v-color-local="textColor">这个文本颜色会变化</p>
    <button @click="changeColor">改变颜色</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      textColor: 'red'
    }
  },
  methods: {
    changeColor() {
      this.textColor = this.textColor === 'red' ? 'blue' : 'red'
    }
  },
  directives: {
    // 局部焦点指令
    'focus-local': {
      mounted(el) {
        el.focus()
      }
    },
    // 局部颜色指令
    'color-local': {
      beforeMount(el, binding) {
        el.style.color = binding.value
      },
      updated(el, binding) {
        el.style.color = binding.value
      }
    }
  }
}
</script>

组合式 API

<template>
  <div>
    <input v-focus-local placeholder="局部焦点指令" />
    <p v-color-local="textColor">这个文本颜色会变化</p>
    <button @click="changeColor">改变颜色</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const textColor = ref('red')

const changeColor = () => {
  textColor.value = textColor.value === 'red' ? 'blue' : 'red'
}

// 局部自定义指令
const vFocusLocal = {
  mounted(el) {
    el.focus()
  }
}

const vColorLocal = {
  beforeMount(el, binding) {
    el.style.color = binding.value
  },
  updated(el, binding) {
    el.style.color = binding.value
  }
}
</script>

四、 完整生命周期示例

让我们通过一个完整的示例来演示所有生命周期钩子的使用:

<template>
  <div class="demo-container">
    <h2>自定义指令完整生命周期演示</h2>
    
    <div>
      <button @click="toggleDisplay">{{ isVisible ? '隐藏' : '显示' }}元素</button>
      <button @click="changeMessage">改变消息</button>
      <button @click="changeColor">改变颜色</button>
    </div>

    <div v-if="isVisible" v-lifecycle-demo:arg.modifier="directiveValue" 
         class="demo-element" :style="{ color: elementColor }">
      {{ message }}
    </div>

    <div class="log-container">
      <h3>生命周期日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const isVisible = ref(false)
const message = ref('Hello, Custom Directive!')
const elementColor = ref('#333')
const logs = ref([])

const directiveValue = reactive({
  text: '指令值对象',
  count: 0
})

// 添加日志函数
const addLog = (hookName, el, binding) => {
  const log = `[${new Date().toLocaleTimeString()}] ${hookName}: value=${JSON.stringify(binding.value)}, arg=${binding.arg}`
  logs.value.push(log)
  // 保持日志数量不超过20条
  if (logs.value.length > 20) {
    logs.value.shift()
  }
}

// 完整的生命周期指令
const vLifecycleDemo = {
  created(el, binding) {
    addLog('created', el, binding)
    console.log('created - 指令创建,元素还未挂载')
  },
  
  beforeMount(el, binding) {
    addLog('beforeMount', el, binding)
    console.log('beforeMount - 元素挂载前')
    el.style.transition = 'all 0.3s ease'
  },
  
  mounted(el, binding) {
    addLog('mounted', el, binding)
    console.log('mounted - 元素挂载完成')
    console.log('修饰符:', binding.modifiers)
    console.log('参数:', binding.arg)
    
    // 添加动画效果
    el.style.opacity = '0'
    el.style.transform = 'translateY(-20px)'
    
    setTimeout(() => {
      el.style.opacity = '1'
      el.style.transform = 'translateY(0)'
    }, 100)
  },
  
  beforeUpdate(el, binding) {
    addLog('beforeUpdate', el, binding)
    console.log('beforeUpdate - 元素更新前')
  },
  
  updated(el, binding) {
    addLog('updated', el, binding)
    console.log('updated - 元素更新完成')
    
    // 更新时的动画
    el.style.backgroundColor = '#e3f2fd'
    setTimeout(() => {
      el.style.backgroundColor = ''
    }, 500)
  },
  
  beforeUnmount(el, binding) {
    addLog('beforeUnmount', el, binding)
    console.log('beforeUnmount - 元素卸载前')
    
    // 卸载动画
    el.style.opacity = '1'
    el.style.transform = 'translateY(0)'
    el.style.opacity = '0'
    el.style.transform = 'translateY(-20px)'
  },
  
  unmounted(el, binding) {
    addLog('unmounted', el, binding)
    console.log('unmounted - 元素卸载完成')
  }
}

const toggleDisplay = () => {
  isVisible.value = !isVisible.value
}

const changeMessage = () => {
  message.value = `消息已更新 ${Date.now()}`
  directiveValue.count++
}

const changeColor = () => {
  const colors = ['#ff4444', '#44ff44', '#4444ff', '#ff44ff', '#ffff44']
  elementColor.value = colors[Math.floor(Math.random() * colors.length)]
}
</script>

<style scoped>
.demo-container {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.demo-element {
  padding: 20px;
  margin: 20px 0;
  border: 2px solid #42b983;
  border-radius: 8px;
  background: #f9f9f9;
}

.log-container {
  margin-top: 20px;
  padding: 15px;
  background: #f5f5f5;
  border-radius: 8px;
  max-height: 400px;
  overflow-y: auto;
}

.log-item {
  padding: 5px 10px;
  margin: 2px 0;
  background: white;
  border-radius: 4px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
}

button {
  margin: 5px;
  padding: 8px 16px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background: #369870;
}
</style>

五、 实用自定义指令示例

5.1 点击外部关闭指令

<template>
  <div class="click-outside-demo">
    <h2>点击外部关闭演示</h2>
    
    <button @click="showDropdown = !showDropdown">
      切换下拉菜单 {{ showDropdown ? '▲' : '▼' }}
    </button>

    <div v-if="showDropdown" v-click-outside="closeDropdown" class="dropdown">
      <div class="dropdown-item">菜单项 1</div>
      <div class="dropdown-item">菜单项 2</div>
      <div class="dropdown-item">菜单项 3</div>
    </div>

    <div v-if="showModal" v-click-outside="closeModal" class="modal">
      <div class="modal-content">
        <h3>模态框</h3>
        <p>点击模态框外部可以关闭</p>
        <button @click="showModal = false">关闭</button>
      </div>
    </div>

    <button @click="showModal = true" style="margin-left: 10px;">
      打开模态框
    </button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const showDropdown = ref(false)
const showModal = ref(false)

// 点击外部关闭指令
const vClickOutside = {
  mounted(el, binding) {
    el._clickOutsideHandler = (event) => {
      // 检查点击是否在元素外部
      if (!(el === event.target || el.contains(event.target))) {
        binding.value(event)
      }
    }
    document.addEventListener('click', el._clickOutsideHandler)
  },
  unmounted(el) {
    document.removeEventListener('click', el._clickOutsideHandler)
  }
}

const closeDropdown = () => {
  showDropdown.value = false
}

const closeModal = () => {
  showModal.value = false
}
</script>

<style scoped>
.dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  background: white;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  z-index: 1000;
  margin-top: 5px;
}

.dropdown-item {
  padding: 10px 20px;
  cursor: pointer;
  border-bottom: 1px solid #eee;
}

.dropdown-item:hover {
  background: #f5f5f5;
}

.dropdown-item:last-child {
  border-bottom: none;
}

.modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0,0,0,0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2000;
}

.modal-content {
  background: white;
  padding: 30px;
  border-radius: 8px;
  max-width: 400px;
  width: 90%;
}
</style>

5.2 输入限制指令

<template>
  <div class="input-restriction-demo">
    <h2>输入限制指令演示</h2>
    
    <div class="input-group">
      <label>仅数字输入:</label>
      <input v-number-only v-model="numberInput" placeholder="只能输入数字" />
      <span>值: {{ numberInput }}</span>
    </div>

    <div class="input-group">
      <label>最大长度限制:</label>
      <input v-limit-length="10" v-model="limitedInput" placeholder="最多10个字符" />
      <span>值: {{ limitedInput }}</span>
    </div>

    <div class="input-group">
      <label>禁止特殊字符:</label>
      <input v-no-special-chars v-model="noSpecialInput" placeholder="不能输入特殊字符" />
      <span>值: {{ noSpecialInput }}</span>
    </div>

    <div class="input-group">
      <label>自动格式化手机号:</label>
      <input v-phone-format v-model="phoneInput" placeholder="输入手机号" />
      <span>值: {{ phoneInput }}</span>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const numberInput = ref('')
const limitedInput = ref('')
const noSpecialInput = ref('')
const phoneInput = ref('')

// 仅数字输入指令
const vNumberOnly = {
  mounted(el) {
    el.addEventListener('input', (e) => {
      e.target.value = e.target.value.replace(/[^\d]/g, '')
      // 触发 v-model 更新
      e.dispatchEvent(new Event('input'))
    })
  }
}

// 长度限制指令
const vLimitLength = {
  mounted(el, binding) {
    const maxLength = binding.value
    el.setAttribute('maxlength', maxLength)
    
    el.addEventListener('input', (e) => {
      if (e.target.value.length > maxLength) {
        e.target.value = e.target.value.slice(0, maxLength)
        e.dispatchEvent(new Event('input'))
      }
    })
  }
}

// 禁止特殊字符指令
const vNoSpecialChars = {
  mounted(el) {
    el.addEventListener('input', (e) => {
      e.target.value = e.target.value.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '')
      e.dispatchEvent(new Event('input'))
    })
  }
}

// 手机号格式化指令
const vPhoneFormat = {
  mounted(el) {
    el.addEventListener('input', (e) => {
      let value = e.target.value.replace(/\D/g, '')
      
      if (value.length > 3 && value.length <= 7) {
        value = value.replace(/(\d{3})(\d+)/, '$1-$2')
      } else if (value.length > 7) {
        value = value.replace(/(\d{3})(\d{4})(\d+)/, '$1-$2-$3')
      }
      
      e.target.value = value
      e.dispatchEvent(new Event('input'))
    })
  }
}
</script>

<style scoped>
.input-restriction-demo {
  padding: 20px;
}

.input-group {
  margin: 15px 0;
}

label {
  display: inline-block;
  width: 150px;
  font-weight: bold;
}

input {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin: 0 10px;
  width: 200px;
}

span {
  color: #666;
  font-size: 14px;
}
</style>

5.3 权限控制指令

<template>
  <div class="permission-demo">
    <h2>权限控制指令演示</h2>
    
    <div class="user-info">
      <label>当前用户角色:</label>
      <select v-model="currentRole" @change="updatePermissions">
        <option value="guest">游客</option>
        <option value="user">普通用户</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
    </div>

    <div class="permission-list">
      <h3>可用功能:</h3>
      
      <button v-permission="'view'" class="feature-btn">
        🔍 查看内容
      </button>
      
      <button v-permission="'edit'" class="feature-btn">
        ✏️ 编辑内容
      </button>
      
      <button v-permission="'delete'" class="feature-btn">
        🗑️ 删除内容
      </button>
      
      <button v-permission="'admin'" class="feature-btn">
        ⚙️ 系统管理
      </button>
      
      <button v-permission="['edit', 'delete']" class="feature-btn">
        🔄 批量操作
      </button>
    </div>

    <div class="current-permissions">
      <h3>当前权限:</h3>
      <ul>
        <li v-for="permission in currentPermissions" :key="permission">
          {{ permission }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

// 角色权限映射
const rolePermissions = {
  guest: ['view'],
  user: ['view', 'edit'],
  editor: ['view', 'edit', 'delete'],
  admin: ['view', 'edit', 'delete', 'admin']
}

const currentRole = ref('user')
const currentPermissions = ref(['view', 'edit'])

// 权限控制指令
const vPermission = {
  mounted(el, binding) {
    checkPermission(el, binding)
  },
  updated(el, binding) {
    checkPermission(el, binding)
  }
}

// 检查权限函数
const checkPermission = (el, binding) => {
  const requiredPermissions = Array.isArray(binding.value) 
    ? binding.value 
    : [binding.value]
  
  const hasPermission = requiredPermissions.some(permission => 
    currentPermissions.value.includes(permission)
  )
  
  if (!hasPermission) {
    el.style.display = 'none'
  } else {
    el.style.display = 'inline-block'
  }
}

// 更新权限
const updatePermissions = () => {
  currentPermissions.value = rolePermissions[currentRole.value] || []
}
</script>

<style scoped>
.permission-demo {
  padding: 20px;
  max-width: 600px;
  margin: 0 auto;
}

.user-info {
  margin: 20px 0;
}

select {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-left: 10px;
}

.permission-list {
  margin: 30px 0;
}

.feature-btn {
  display: inline-block;
  padding: 12px 20px;
  margin: 5px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.3s;
}

.feature-btn:hover {
  background: #369870;
}

.current-permissions {
  margin-top: 30px;
  padding: 15px;
  background: #f5f5f5;
  border-radius: 8px;
}

.current-permissions ul {
  list-style: none;
  padding: 0;
}

.current-permissions li {
  padding: 5px 10px;
  background: white;
  margin: 5px 0;
  border-radius: 4px;
  border-left: 4px solid #42b983;
}
</style>

六、 高级技巧与最佳实践

6.1 指令参数动态化

<template>
  <div>
    <input v-tooltip="tooltipConfig" placeholder="悬浮显示提示" />
    
    <div v-pin="pinConfig" class="pinned-element">
      可动态配置的固定元素
    </div>
    
    <button @click="updateConfig">更新配置</button>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

// 动态提示指令
const vTooltip = {
  mounted(el, binding) {
    const config = binding.value
    el.title = config.text
    el.style.cursor = config.cursor || 'help'
    
    if (config.position) {
      el.dataset.position = config.position
    }
  },
  updated(el, binding) {
    const config = binding.value
    el.title = config.text
  }
}

// 动态固定指令
const vPin = {
  mounted(el, binding) {
    updatePinPosition(el, binding)
  },
  updated(el, binding) {
    updatePinPosition(el, binding)
  }
}

const updatePinPosition = (el, binding) => {
  const config = binding.value
  el.style.position = 'fixed'
  el.style[config.side] = config.distance + 'px'
  el.style.zIndex = config.zIndex || 1000
}

const tooltipConfig = reactive({
  text: '这是一个动态提示',
  cursor: 'help',
  position: 'top'
})

const pinConfig = reactive({
  side: 'top',
  distance: 20,
  zIndex: 1000
})

const updateConfig = () => {
  tooltipConfig.text = `更新后的提示 ${Date.now()}`
  pinConfig.side = pinConfig.side === 'top' ? 'bottom' : 'top'
  pinConfig.distance = Math.random() * 100 + 20
}
</script>

6.2 指令组合与复用

// directives/composable.js
export function useClickHandlers() {
  return {
    mounted(el, binding) {
      el._clickHandler = binding.value
      el.addEventListener('click', el._clickHandler)
    },
    unmounted(el) {
      el.removeEventListener('click', el._clickHandler)
    }
  }
}

export function useHoverHandlers() {
  return {
    mounted(el, binding) {
      el._mouseenterHandler = binding.value.enter
      el._mouseleaveHandler = binding.value.leave
      
      if (el._mouseenterHandler) {
        el.addEventListener('mouseenter', el._mouseenterHandler)
      }
      if (el._mouseleaveHandler) {
        el.addEventListener('mouseleave', el._mouseleaveHandler)
      }
    },
    unmounted(el) {
      if (el._mouseenterHandler) {
        el.removeEventListener('mouseenter', el._mouseenterHandler)
      }
      if (el._mouseleaveHandler) {
        el.removeEventListener('mouseleave', el._mouseleaveHandler)
      }
    }
  }
}

// 组合指令
export const vInteractive = {
  mounted(el, binding) {
    const { click, hover } = binding.value
    
    if (click) {
      el.addEventListener('click', click)
      el._clickHandler = click
    }
    
    if (hover) {
      el.addEventListener('mouseenter', hover.enter)
      el.addEventListener('mouseleave', hover.leave)
      el._hoverHandlers = hover
    }
  },
  unmounted(el) {
    if (el._clickHandler) {
      el.removeEventListener('click', el._clickHandler)
    }
    if (el._hoverHandlers) {
      el.removeEventListener('mouseenter', el._hoverHandlers.enter)
      el.removeEventListener('mouseleave', el._hoverHandlers.leave)
    }
  }
}

七、 总结

7.1 核心要点回顾

  1. 生命周期钩子:Vue3 提供了 7 个生命周期钩子,覆盖了指令的完整生命周期
  2. 参数传递:通过 binding 对象可以访问指令的值、参数、修饰符等信息
  3. 多种定义方式:支持全局注册和局部注册,兼容选项式 API 和组合式 API
  4. 灵活性:指令可以接收动态参数、对象值,支持复杂的交互逻辑

7.2 最佳实践

  1. 命名规范:使用小写字母和连字符命名指令
  2. 内存管理:在 unmounted 钩子中清理事件监听器和定时器
  3. 性能优化:避免在指令中进行昂贵的 DOM 操作
  4. 可复用性:将通用指令提取为独立模块
  5. 类型安全:为指令提供 TypeScript 类型定义

7.3 适用场景

  • DOM 操作:焦点管理、元素定位、动画控制
  • 输入处理:格式化、验证、限制
  • 用户交互:点击外部、滚动加载、拖拽
  • 权限控制:基于角色的元素显示/隐藏
  • 第三方集成:包装现有的 JavaScript 库

自定义指令是 Vue.js 生态中一个非常强大的特性,合理使用可以极大地提高代码的复用性和可维护性。希望本文能帮助你全面掌握 Vue3 中的自定义指令!


如果这篇文章对你有帮助,欢迎点赞、收藏和评论!有任何问题都可以在评论区讨论。

【uniapp】小程序实现自由控制组件JSON文件配置

2025年11月23日 11:29

前言

最近在 uniapp ask社区 回答问题,不少开发者都咨询到了如何配置 组件 生成的 json文件 这个问题,在经过跟同事的交流、自己的实践以及开发者的反馈后,我整理了下面的解决思路和方案。

前置知识

uniapp 编译到 小程序,一个 vue 组件编译结束之后会生成四个文件,以微信小程序举例

image.png

生成的 json文件 的内容存储着小程序页面或组件相关的配置,必不可少

方案一 脚本

这个思路是在框架打包结束之后通过 nodejs 或者 shell 去修改产物

以占位组件为例,做分包异步化降低主包体积不可或缺的一个配置项

image.png

开发者只需要事先统计好需要配置的 componentPlaceholder 和 产物路径,在打包结束之后通过脚本把内容写入到对应的 json文件 就可以了

这种思路适合 cli项目,可以很轻松把脚本添加到 package.json 中的 scripts 中,并且不受 nodejs 版本限制;但是有些开发者的项目是 hx项目,就不能通过脚本的形式来操作了。

方案二 插件

这个思路是通过 webpack 或者 vite 插件来解决相关问题

我们以我写的 vite 插件 为例 (github 地址 github.com/uni-toolkit…

插件源码很简单,五十多行代码

import { getOutputJsonPath, isMiniProgram } from '@uni_toolkit/shared';
import type { PluginOption } from 'vite';
import { createFilter, type FilterPattern } from '@rollup/pluginutils';
import path from 'node:path';
import fs from 'node:fs';
import { parseJson } from '@dcloudio/uni-cli-shared';
import { merge } from 'lodash-es';

export interface ComponentConfigPluginOptions {
  include?: FilterPattern;
  exclude?: FilterPattern;
}

export default function vitePluginComponentConfig(
  options: ComponentConfigPluginOptions = {
    include: ['**/*.{vue,nvue,uvue}'],
    exclude: [],
  },
): PluginOption {
  const map: Map<string, Record<string, any>> = new Map();
  return {
    name: 'vite-plugin-component-config',
    enforce: 'pre',
    transform(code, id) {
      if (!isMiniProgram()) {
        return;
      }
      if (!createFilter(options.include, options.exclude)(id)) {
        return;
      }
      const matches = code.match(/<component-config>([\s\S]*?)<\/component-config>/g);
      if (!matches) {
        return;
      }

      matches.forEach((match) => {
        const content = match.replace(/<component-config>|<\/component-config>/g, '');
        const componentConfig = parseJson(content.toString(), true, path.basename(id));
        map.set(getOutputJsonPath(id), componentConfig);
      });

      return code.replace(/<component-config>[\s\S]*?<\/component-config>/g, '');
    },
    closeBundle() {
      if (map.size === 0) {
        return;
      }
      for (const [outputPath, config] of map) {
        if (!fs.existsSync(outputPath)) {
          continue;
        }
        const content = fs.readFileSync(outputPath, 'utf-8');
        const json = JSON.parse(content);
        fs.writeFileSync(outputPath, JSON.stringify(merge(json, config), null, 2));
      }
    },
  };
}

思路就是需要开发者在具体的 vue文件 中添加一个 component-config 代码块,类如

// #ifdef MP
<component-config>
{
  "usingComponents": {
    "custom-button": "/components/custom-button"
  },
  "styleIsolation": "apply-shared",
  "componentPlaceholder": {  
    "test": "view",  
  }  
}
</component-config>
// #endif

在编译期间,会通过正则表达式提取 component-config 代码块中的配置,存储到 hashMap 中,在 bundle 生成结束后遍历 hashMap 中的数据,然后修改对应路径的 json文件

同时还考虑到有些开发者会针对不同的小程序平台添加不同的配置,component-config 代码块中的内容也支持 条件编译

如果你是 vue2 项目,可以使用这个插件 github 地址 github.com/uni-toolkit…

结语

如果这些插件帮助到了你,可以点个 star✨ 支持一下开源工作。

如果你有什么好的想法或者建议,欢迎在 github.com/uni-toolkit… 提 issue 或者 pr;也可以加我的 微信 13460036576 沟通。

Vue3 异步组件深度解析:提升大型应用性能与用户体验的完整指南

作者 北辰alk
2025年11月23日 11:12

Vue3 异步组件深度解析:提升大型应用性能与用户体验的完整指南

摘要

在大型 Vue.js 应用中,组件异步加载是优化性能、提升用户体验的关键技术。Vue3 提供了全新且更强大的异步组件机制,支持 defineAsyncComponent、组合式 API 与 Suspense 配合等现代化方案。本文将深入探讨 Vue3 中异步组件的各种实现方式,通过详细的代码示例、执行流程分析和最佳实践,帮助你彻底掌握这一重要特性。


一、 为什么需要异步组件?

1.1 性能瓶颈与解决方案

在传统单页面应用(SPA)中,所有组件通常被打包到一个 JavaScript 文件中,导致:

  • 首屏加载缓慢:用户需要等待整个应用下载完成才能看到内容
  • 资源浪费:用户可能永远不会访问某些页面,但依然加载了对应的代码
  • 用户体验差:特别是对于移动端用户和网络条件较差的场景

异步组件通过代码分割(Code Splitting)解决了这些问题:

  • 按需加载:只在需要时加载组件代码
  • 减小初始包体积:显著降低首屏加载时间
  • 优化缓存:独立 chunk 可以更好地利用浏览器缓存

1.2 Vue3 异步组件的新特性

Vue3 在异步组件方面进行了重要改进:

  • 更简洁的 APIdefineAsyncComponent 替代 Vue2 的复杂配置
  • 更好的 TypeScript 支持:完整的类型推断
  • 与 Suspense 集成:更优雅的加载状态处理
  • 组合式 API 配合:更灵活的异步逻辑组织

二、 基础异步组件加载

2.1 使用 defineAsyncComponent

Vue3 引入了 defineAsyncComponent 函数来创建异步组件,这是最基础的用法。

流程图:基础异步组件加载流程

flowchart TD
    A[父组件渲染] --> B{遇到异步组件}
    B --> C[显示Loading占位]
    C --> D[开始加载组件]
    D --> E{加载成功?}
    E -- 是 --> F[渲染异步组件]
    E -- 否 --> G[显示Error组件]
    F --> H[组件完全激活]

代码示例:基础用法

<template>
  <div class="app">
    <h2>异步组件基础示例</h2>
    <button @click="showAsyncComponent = true">加载异步组件</button>
    
    <div v-if="showAsyncComponent">
      <!-- 异步组件在这里渲染 -->
      <AsyncUserProfile />
    </div>
  </div>
</template>

<script setup>
import { defineAsyncComponent, ref } from 'vue'

// 基础异步组件定义
const AsyncUserProfile = defineAsyncComponent(() =>
  import('./components/UserProfile.vue')
)

const showAsyncComponent = ref(false)
</script>

2.2 模拟异步组件内容

UserProfile.vue(被异步加载的组件):

<template>
  <div class="user-profile" style="border: 2px solid #42b983; padding: 20px; margin: 10px 0;">
    <h3>用户信息组件 (异步加载)</h3>
    <div>姓名: 张三</div>
    <div>邮箱: zhangsan@example.com</div>
    <div>角色: 管理员</div>
    <div>组件加载时间: {{ new Date().toLocaleTimeString() }}</div>
  </div>
</template>

<script setup>
import { onMounted, onUnmounted } from 'vue'

console.log('UserProfile 组件被加载了')

onMounted(() => {
  console.log('UserProfile 组件挂载完成')
})

onUnmounted(() => {
  console.log('UserProfile 组件已卸载')
})
</script>

三、 高级配置:加载与错误处理

在实际应用中,我们需要处理加载状态和错误情况,提供更好的用户体验。

3.1 完整的配置选项

<template>
  <div class="app">
    <h2>高级异步组件示例</h2>
    
    <!-- 加载状态控制按钮 -->
    <div style="margin-bottom: 20px;">
      <button @click="loadComponent">加载高级组件</button>
      <button @click="unloadComponent" style="margin-left: 10px;">卸载组件</button>
    </div>
    
    <!-- 异步组件渲染区域 -->
    <AdvancedAsyncComponent 
      v-if="showAdvancedComponent" 
      :user-id="currentUserId" 
    />
  </div>
</template>

<script setup>
import { defineAsyncComponent, ref } from 'vue'

// 模拟网络延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))

// 加载状态
const isLoading = ref(false)
const loadError = ref(null)

// 完整配置的异步组件
const AdvancedAsyncComponent = defineAsyncComponent({
  // 加载器函数
  loader: async () => {
    console.log('开始加载高级组件...')
    isLoading.value = true
    loadError.value = null
    
    try {
      // 模拟网络延迟
      await delay(2000)
      
      // 动态导入组件
      const component = await import('./components/AdvancedFeatures.vue')
      console.log('高级组件加载成功')
      return component
    } catch (error) {
      console.error('组件加载失败:', error)
      loadError.value = error
      throw error
    } finally {
      isLoading.value = false
    }
  },
  
  // 加载中显示的组件
  loadingComponent: {
    template: `
      <div class="loading-container" style="padding: 40px; text-align: center; border: 2px dashed #ccc;">
        <div class="spinner" style="width: 40px; height: 40px; border: 4px solid #f3f3f3; border-top: 4px solid #42b983; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto;"></div>
        <p style="margin-top: 16px; color: #666;">组件加载中,请稍候...</p>
        <p style="font-size: 12px; color: #999;">这通常需要 2-3 秒</p>
      </div>
    `
  },
  
  // 加载失败显示的组件
  errorComponent: {
    props: ['error'],
    template: `
      <div class="error-container" style="padding: 40px; text-align: center; border: 2px solid #f56c6c; background: #fef0f0;">
        <div style="font-size: 48px; color: #f56c6c;">❌</div>
        <h3 style="color: #f56c6c;">组件加载失败</h3>
        <p style="color: #666;">抱歉,无法加载请求的组件</p>
        <p style="font-size: 12px; color: #999; margin-top: 10px;">错误信息: {{ error.message }}</p>
        <button @click="$emit('retry')" style="margin-top: 16px; padding: 8px 16px; background: #42b983; color: white; border: none; border-radius: 4px;">重试加载</button>
      </div>
    `,
    emits: ['retry']
  },
  
  // 延迟显示加载状态(避免闪烁)
  delay: 200,
  
  // 超时时间(毫秒)
  timeout: 5000,
  
  // 是否可挂起(Suspense 相关)
  suspensible: false
})

// 组件状态控制
const showAdvancedComponent = ref(false)
const currentUserId = ref('12345')

const loadComponent = () => {
  showAdvancedComponent.value = true
}

const unloadComponent = () => {
  showAdvancedComponent.value = false
}
</script>

<style>
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

3.2 高级功能组件示例

AdvancedFeatures.vue

<template>
  <div class="advanced-features" style="border: 2px solid #e6a23c; padding: 20px; margin: 10px 0; background: #fdf6ec;">
    <h3>高级功能组件 (异步加载)</h3>
    <p>组件ID: {{ props.userId }}</p>
    
    <div class="features">
      <div v-for="feature in features" :key="feature.id" class="feature-item">
        <strong>{{ feature.name }}</strong>: {{ feature.description }}
      </div>
    </div>
    
    <div style="margin-top: 20px;">
      <button @click="simulateAction" style="padding: 8px 16px; background: #e6a23c; color: white; border: none; border-radius: 4px;">模拟操作</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const props = defineProps({
  userId: {
    type: String,
    required: true
  }
})

const features = ref([
  { id: 1, name: '数据分析', description: '实时数据可视化分析' },
  { id: 2, name: '报表生成', description: '自动生成详细业务报表' },
  { id: 3, name: '权限管理', description: '细粒度的权限控制系统' }
])

const simulateAction = () => {
  console.log('执行高级操作,用户ID:', props.userId)
}

onMounted(() => {
  console.log('高级功能组件已挂载,用户ID:', props.userId)
})
</script>

<style scoped>
.feature-item {
  padding: 8px;
  margin: 4px 0;
  background: white;
  border-radius: 4px;
}
</style>

四、 结合 Suspense 的现代方案

Vue3 的 <Suspense> 组件提供了更声明式的异步处理方式。

4.1 Suspense 基础用法

流程图:Suspense 异步加载流程

flowchart TD
    A[Suspense组件] --> B[渲染default插槽]
    B --> C{异步依赖<br>是否解析?}
    C -- 否 --> D[显示fallback内容]
    C -- 是 --> E[显示异步内容]
    D --> F[异步依赖解析完成]
    F --> E
    E --> G[可触发resolved事件]
<template>
  <div class="app">
    <h2>Suspense 异步组件示例</h2>
    
    <Suspense>
      <!-- 主要内容 -->
      <template #default>
        <SuspenseUserDashboard :user-id="userId" />
      </template>
      
      <!-- 加载状态 -->
      <template #fallback>
        <div class="suspense-loading" style="padding: 60px; text-align: center;">
          <div class="loading-indicator" style="display: inline-block;">
            <div style="display: flex; align-items: center; gap: 12px;">
              <div class="spinner" style="width: 32px; height: 32px; border: 3px solid #e0e0e0; border-top: 3px solid #42b983; border-radius: 50%; animation: spin 1s linear infinite;"></div>
              <div>
                <p style="margin: 0; font-weight: bold;">仪表板加载中</p>
                <p style="margin: 4px 0 0 0; font-size: 12px; color: #666;">正在准备您的数据...</p>
              </div>
            </div>
          </div>
        </div>
      </template>
    </Suspense>
    
    <button @click="reloadDashboard" style="margin-top: 20px;">重新加载</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 异步组件(注意:需要设置 suspensible: true)
const SuspenseUserDashboard = defineAsyncComponent(() =>
  import('./components/UserDashboard.vue')
)

const userId = ref('user-001')

const reloadDashboard = () => {
  // 通过改变 key 强制重新加载组件
  userId.value = 'user-' + Date.now()
}
</script>

4.2 支持异步设置的组件

UserDashboard.vue

<template>
  <div class="user-dashboard" style="border: 2px solid #409eff; padding: 20px; margin: 10px 0; background: #ecf5ff;">
    <h3>用户仪表板 (Suspense 加载)</h3>
    
    <!-- 用户信息 -->
    <div class="user-info" style="margin-bottom: 20px;">
      <h4>用户信息</h4>
      <div v-if="userData">姓名: {{ userData.name }}</div>
      <div v-if="userData">等级: {{ userData.level }}</div>
    </div>
    
    <!-- 统计卡片 -->
    <div class="stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 20px;">
      <div v-for="stat in stats" :key="stat.name" class="stat-card" style="padding: 16px; background: white; border-radius: 8px; text-align: center;">
        <div style="font-size: 24px; font-weight: bold; color: #409eff;">{{ stat.value }}</div>
        <div style="font-size: 12px; color: #666;">{{ stat.name }}</div>
      </div>
    </div>
    
    <!-- 最近活动 -->
    <div class="recent-activity">
      <h4>最近活动</h4>
      <ul>
        <li v-for="activity in activities" :key="activity.id" style="margin: 8px 0;">
          {{ activity.action }} - {{ activity.time }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const props = defineProps({
  userId: {
    type: String,
    required: true
  }
})

// 模拟异步数据获取
const userData = ref(null)
const stats = ref([])
const activities = ref([])

// 模拟 API 调用
const fetchUserData = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        name: '李四',
        level: 'VIP',
        joinDate: '2023-01-15'
      })
    }, 1500)
  })
}

const fetchStats = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { name: '项目数量', value: 24 },
        { name: '完成任务', value: 89 },
        { name: '团队排名', value: '前 5%' }
      ])
    }, 1000)
  })
}

const fetchActivities = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { id: 1, action: '创建了新项目', time: '2小时前' },
        { id: 2, action: '完成了任务', time: '5小时前' },
        { id: 3, action: '加入了团队', time: '1天前' }
      ])
    }, 800)
  })
}

// 使用 async setup(Suspense 会自动等待)
const setupData = async () => {
  console.log('开始加载仪表板数据...')
  
  // 并行加载所有数据
  const [user, statistics, recentActivities] = await Promise.all([
    fetchUserData(),
    fetchStats(),
    fetchActivities()
  ])
  
  userData.value = user
  stats.value = statistics
  activities.value = recentActivities
  
  console.log('仪表板数据加载完成')
}

// 执行异步设置
await setupData()

onMounted(() => {
  console.log('UserDashboard 组件已挂载,用户ID:', props.userId)
})
</script>

五、 路由级别的异步加载

在实际项目中,我们经常需要在路由级别进行代码分割。

5.1 Vue Router 4 中的异步路由

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // 路由级别代码分割
    component: () => import('../views/About.vue')
  },
  {
    path: '/user/:id',
    name: 'UserProfile',
    // 带有加载状态的异步路由
    component: defineAsyncComponent({
      loader: () => import('../views/UserProfile.vue'),
      loadingComponent: LoadingSpinner,
      errorComponent: ErrorDisplay,
      delay: 200,
      timeout: 3000
    })
  },
  {
    path: '/admin',
    name: 'Admin',
    // 条件性异步加载(基于用户权限)
    component: () => {
      const user = store.getters.currentUser
      if (user?.isAdmin) {
        return import('../views/AdminDashboard.vue')
      } else {
        return import('../views/AccessDenied.vue')
      }
    }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

5.2 路由组件示例

views/UserProfile.vue

<template>
  <div class="user-profile-page">
    <div class="header">
      <h1>用户详情</h1>
      <p>用户ID: {{ $route.params.id }}</p>
    </div>
    
    <Suspense>
      <template #default>
        <UserDetailContent :user-id="$route.params.id" />
      </template>
      <template #fallback>
        <div class="page-loading">
          <h3>加载用户信息...</h3>
        </div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import { defineAsyncComponent } from 'vue'

// 异步加载用户详情内容
const UserDetailContent = defineAsyncComponent({
  loader: () => import('../components/UserDetailContent.vue'),
  loadingComponent: {
    template: '<div>加载用户详情...</div>'
  }
})
</script>

六、 高级模式与最佳实践

6.1 预加载策略

<template>
  <div class="app">
    <nav>
      <router-link to="/">首页</router-link>
      <router-link to="/about" @mouseenter="preloadAbout">关于</router-link>
      <router-link to="/contact" @touchstart="preloadContact">联系</router-link>
    </nav>
    <router-view />
  </div>
</template>

<script setup>
import { defineAsyncComponent } from 'vue'

// 预加载函数
const preloadAbout = () => {
  // 预加载关于页面
  import('./views/About.vue').then(module => {
    console.log('关于页面预加载完成')
  })
}

const preloadContact = () => {
  // 预加载联系页面(移动端 touchstart 事件)
  import('./views/Contact.vue')
}

// 关键组件预加载(在空闲时间)
const preloadCriticalComponents = () => {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      import('./components/CriticalComponent.vue')
    })
  }
}

// 应用启动后预加载关键组件
onMounted(() => {
  preloadCriticalComponents()
})
</script>

6.2 错误边界与重试机制

<template>
  <div>
    <ErrorBoundary>
      <template #default>
        <UnstableAsyncComponent />
      </template>
      <template #fallback="{ error, reset }">
        <div class="error-boundary">
          <h3>组件加载失败</h3>
          <p>{{ error.message }}</p>
          <button @click="reset">重试</button>
        </div>
      </template>
    </ErrorBoundary>
  </div>
</template>

<script setup>
import { defineAsyncComponent, ref, onErrorCaptured } from 'vue'

// 错误边界组件
const ErrorBoundary = {
  setup(props, { slots }) {
    const error = ref(null)
    
    const reset = () => {
      error.value = null
    }
    
    onErrorCaptured((err) => {
      error.value = err
      return false // 阻止错误继续向上传播
    })
    
    return () => {
      if (error.value) {
        return slots.fallback?.({ error: error.value, reset })
      }
      return slots.default?.()
    }
  }
}

// 不稳定的异步组件(模拟可能失败)
const UnstableAsyncComponent = defineAsyncComponent({
  loader: async () => {
    // 模拟随机失败
    if (Math.random() > 0.5) {
      throw new Error('随机加载失败')
    }
    await new Promise(resolve => setTimeout(resolve, 1000))
    return import('./components/UnstableComponent.vue')
  },
  onError: (error, retry, fail, attempts) => {
    if (attempts <= 3) {
      // 重试最多3次
      console.log(`重试加载,尝试次数: ${attempts}`)
      retry()
    } else {
      fail()
    }
  }
})
</script>

七、 性能优化与调试技巧

7.1 Webpack Bundle Analyzer

分析打包结果,优化代码分割:

npm install --save-dev webpack-bundle-analyzer
// vue.config.js
const { defineConfig } = require('@vue/cli-service')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = defineConfig({
  transpileDependencies: true,
  configureWebpack: {
    plugins: [
      new BundleAnalyzerPlugin({
        analyzerMode: process.env.NODE_ENV === 'production' ? 'static' : 'disabled',
        openAnalyzer: false
      })
    ],
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all'
          }
        }
      }
    }
  }
})

7.2 性能监控

// utils/performance.js
export const trackComponentLoad = (componentName, startTime) => {
  const loadTime = performance.now() - startTime
  console.log(`🚀 ${componentName} 加载耗时: ${loadTime.toFixed(2)}ms`)
  
  // 发送到监控系统
  if (loadTime > 2000) {
    console.warn(`⚠️ ${componentName} 加载较慢`)
  }
}

// 在异步组件中使用
const startTime = performance.now()
const AsyncComponent = defineAsyncComponent({
  loader: async () => {
    const component = await import('./components/HeavyComponent.vue')
    trackComponentLoad('HeavyComponent', startTime)
    return component
  }
})

八、 总结

Vue3 的异步组件系统提供了强大而灵活的工具来优化应用性能:

核心优势

  1. 减小初始包体积:显著提升首屏加载速度
  2. 按需加载:只在需要时加载代码,节省带宽
  3. 更好的缓存:独立 chunk 可独立缓存
  4. 提升用户体验:合理的加载状态和错误处理

技术选择指南

场景 推荐方案 优点
简单异步加载 defineAsyncComponent(() => import()) 简洁直观
需要加载状态 defineAsyncComponent 完整配置 完整的状态管理
现代应用 <Suspense> + 异步组件 声明式、更优雅
路由级别 Vue Router 动态导入 天然的路由分割
复杂异步逻辑 组合式函数 + 异步组件 最大灵活性

最佳实践提醒

  • 合理设置 delay 避免加载闪烁
  • 始终处理加载错误情况
  • 使用预加载提升关键路径性能
  • 监控组件加载性能
  • 合理划分代码分割点

通过合理运用 Vue3 的异步组件特性,你可以构建出既快速又用户体验良好的现代 Web 应用。


希望这篇深度解析能帮助你全面掌握 Vue3 的异步组件加载!如有任何问题,欢迎在评论区讨论。

JS 引擎赛道中的 Rust 角色

作者 Yanni4Night
2025年11月23日 11:10

引言

JavaScript 引擎作为现代 Web 开发的核心基础设施,一直在性能优化和安全性之间寻找平衡。随着 Rust 语言的崛起,这个以内存安全和高性能著称的系统级编程语言正在为 JavaScript 引擎带来新的可能性。本文将深入探讨 Rust 在 JavaScript 引擎赛道中的角色,分析当前主要的 Rust 实现的 JavaScript 引擎,以及它们如何影响未来 JavaScript 的执行效率和安全性。

一、JavaScript 引擎的演进与 Rust 的机遇

JavaScript 引擎从最初的简单解释器,发展到如今复杂的 JIT 编译执行系统,经历了显著的技术变革。传统的 JavaScript 引擎如 V8(Chrome/Node.js)、SpiderMonkey(Firefox)和 JavaScriptCore(Safari)主要由 C++ 实现,虽然性能卓越,但在内存安全性和并发控制方面仍面临挑战。

Rust 语言的出现为这一领域带来了新的解决方案。Rust 凭借其所有权系统、借用检查器和零成本抽象等特性,能够在编译时捕获内存错误,同时保持与 C++ 相当的性能。这使得 Rust 成为构建新一代 JavaScript 引擎的理想选择。

二、主要的 Rust 实现的 JavaScript 引擎

2.1 Boa:纯 Rust 实现的实验性引擎

Boa 是目前最知名的纯 Rust 实现的 JavaScript 引擎之一。

技术特点:

  • 纯 Rust 实现,代码仓库约 6.1K Stars,208 贡献者
  • 最小编译后体积约 1MB,内存占用通常低于 5MB
  • 提供完整的词法分析器、解析器和解释器实现
  • 支持 ES5 完整特性,ES6+ 部分特性,兼容性约 85%
  • 提供 C API 和 WebAssembly 构建目标

使用案例:

  • Rust 嵌入式项目中的脚本扩展
  • Boa 引擎自身的测试和示例应用
  • 教育领域用于教学 JavaScript 引擎内部工作原理

评分:3.5/5 - 作为纯 Rust 引擎的先驱者,在安全性和轻量级方面表现出色,但在功能完整性和性能方面仍有提升空间。

2.2 Yavashark:轻量级 Rust JavaScript 引擎

Yavashark 是一个新兴的 Rust 实现的 JavaScript 引擎,专注于轻量级应用场景。

技术特点:

  • 纯 Rust 实现,代码库约 1225 Stars
  • 编译后体积约 5.6MB,启动时间小于 10ms
  • 支持 ES2023 完整标准,ES2025 兼容性达 98%
  • 基于标记-清除垃圾收集器,内存占用峰值通常低于 20MB
  • 提供多线程执行支持,利用 Rust 的安全并发特性

使用案例:

  • 资源受限的嵌入式设备
  • 需要快速启动的边缘计算应用
  • 部分实验性 WebAssembly 运行时的 JavaScript 支持

评分:4.0/5 - 在标准兼容性方面表现突出,轻量级设计使其适合嵌入式场景,但社区规模相对较小影响其生态建设。

2.3 Nova:新一代 Rust JavaScript 引擎

Nova 是另一个值得关注的 Rust 实现的 JavaScript 引擎,在性能和标准支持方面都有不俗表现。

技术特点:

  • 纯 Rust 实现,采用分层架构设计
  • 字节码解释器针对热点路径优化,比同类解释器快约 15-20%
  • 内存占用基准测试显示比 Boa 低约 30%
  • 提供插件系统支持自定义内置对象和函数
  • 启动时间平均低于 5ms,适合快速启动场景

使用案例:

  • 部分实时应用的脚本引擎
  • 需要高性能响应的桌面应用插件系统
  • 某些实验性的微前端框架

评分:3.8/5 - 在性能优化方面有亮点,模块化设计提升了扩展性,但项目成熟度和生态系统仍在发展中。

2.4 Brimstone:专注性能的 Rust JavaScript 引擎

Brimstone 是一个专注于性能优化的 Rust 实现的 JavaScript 引擎。

技术特点:

  • 纯 Rust 实现,包含基础的 JIT 编译能力
  • 热点路径执行速度比纯解释器快 2-3 倍
  • 提供 WebAssembly 模块加载和通信接口
  • 内存占用约 7-15MB,取决于执行的 JavaScript 复杂度
  • 支持 SIMD 指令集优化数值计算密集型操作

使用案例:

  • 需要高性能数值计算的科学计算应用
  • 游戏引擎中的脚本系统
  • WebAssembly 运行时中的 JavaScript 互操作层

评分:4.2/5 - 在性能方面表现突出,特别是在数值计算领域,与 WebAssembly 的集成也很有优势,但功能覆盖范围仍需扩展。

2.5 SpiderMonkey 生态中的 Rust 项目

Mozilla 的 SpiderMonkey 引擎虽然主要用 C++ 实现,但在其生态系统中有两个重要的 Rust 项目:

2.5.1 Mozjs:SpiderMonkey 与 Rust 的结合

Mozjs 提供了 SpiderMonkey 引擎的完整 Rust 绑定,使开发者能够在 Rust 项目中无缝集成成熟的 SpiderMonkey 引擎。

技术特点:

  • 完整封装 SpiderMonkey C++ API,提供约 200 个 Rust 结构体和特征
  • 支持 ECMAScript 2023 完整标准
  • 内存占用约 50-150MB,取决于执行的 JavaScript 复杂度
  • 提供完整的 JIT 编译、垃圾回收和并发支持
  • 构建大小约 20-30MB,取决于编译选项

使用案例:

  • Mozilla Firefox 的部分 Rust 组件与 SpiderMonkey 的交互
  • 一些使用 Rust 构建的需要完整 JavaScript 支持的桌面应用
  • 服务端应用中的 JavaScript 插件系统

评分:4.5/5 - 在功能完整性和性能方面表现出色,依托成熟的 SpiderMonkey 引擎,但集成复杂度较高,且并非纯 Rust 实现。

2.5.2 Jsparagus:Rust 构建的 JavaScript 解析器

Jsparagus 是一个由 Mozilla 开发的使用 Rust 构建的 JavaScript 解析器,它的目标是逐步替换 SpiderMonkey 中的现有解析器。

技术特点:

  • 纯 Rust 实现,基于 PEG (Parsing Expression Grammar) 解析器生成器
  • 解析速度比传统递归下降解析器快约 10-15%
  • 内存占用峰值通常低于 5MB
  • 生成的解析树支持增量更新,适合编辑器实时解析场景
  • 代码覆盖率达 95% 以上

使用案例:

  • Mozilla Firefox 的 JavaScript 解析器组件
  • 一些需要高性能 JavaScript 解析的开发工具
  • 部分 IDE 的 JavaScript 代码分析功能

评分:4.3/5 - 在解析性能和准确性方面表现出色,作为专业解析器有很高的技术水准,但功能仅限于解析阶段,不包含执行环境。

2.6 其他 Rust 相关的 JavaScript 引擎项目

2.6.1 Rustyscript

技术特点:

  • 纯 Rust 实现的轻量级 JavaScript 执行环境
  • 提供简化的 API,最小化集成复杂度
  • 内存占用通常在 3-8MB 范围内
  • 支持基础的 ES5 特性和部分 ES6 特性
  • 编译后体积约 3.5MB

使用案例:

  • 简单的脚本自动化任务
  • 配置文件解析和执行
  • 小型应用的插件系统

评分:3.0/5 - 作为轻量级解决方案具有一定优势,但功能覆盖范围有限,适合简单场景使用。

2.6.2 其他相关项目

  • Wasmer 的 JavaScript 支持:WebAssembly 运行时对 JavaScript 的集成支持,允许在 WebAssembly 环境中嵌入 JavaScript 代码
  • js2rust:探索 JavaScript 到 Rust 的编译转换,试图将 JavaScript 代码自动转换为 Rust 代码以提高性能和安全性

三、Rust 为 JavaScript 引擎带来的技术优势

3.1 内存安全与漏洞减少

Rust 的所有权系统和借用检查器能够在编译时捕获大量内存相关的错误,这对于 JavaScript 引擎这类复杂系统尤为重要。根据研究数据,使用 Rust 可以显著减少内存安全漏洞。例如,华为将 OpenEuler 内核驱动 Rust 化后,减少了 67% 的内存安全漏洞 1。

3.2 并发性能的提升

Rust 的无数据竞争保证使得 JavaScript 引擎能够更安全地利用多核处理器。与传统的基于锁的并发模型相比,Rust 的所有权系统提供了更细粒度的并发控制,减少了死锁和资源争用的可能性。

3.3 资源占用优化

Rust 程序通常具有较小的内存占用和启动时间。这使得 Rust 实现的 JavaScript 引擎在嵌入式环境和资源受限设备上具有明显优势。例如,类似 Tauri 这样的基于 Rust 的桌面应用框架,通过使用 Rust 和 Webview2,成功解决了 Electron 的包体积大、内存占用高的问题 4。

四、代码示例:Rust 与 JavaScript 引擎的集成

本节提供几个实用的代码示例,展示如何在 Rust 项目中集成和使用不同的 JavaScript 引擎。

4.1 使用 Boa 引擎执行表达式和函数

Boa 提供了简洁的 API 来执行 JavaScript 代码。下面是一个更全面的示例,展示了如何:初始化引擎、执行表达式、调用函数、处理错误,以及在 Rust 和 JavaScript 之间传递数据。

use boa_engine::{Context, Source, js_string};
use boa_engine::property::Attribute;
use boa_engine::value::StringOrSymbol;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 创建 JavaScript 执行上下文
    let mut context = Context::default();
    
    // 1. 执行简单表达式
    let result = context.eval(Source::from_bytes("2 + 2 * 10"))?;
    println!("数学表达式结果: {}", result);
    
    // 2. 定义和调用函数
    let js_code = r#"
        function calculateFactorial(n) {
            if (n <= 1) return 1;
            return n * calculateFactorial(n - 1);
        }
        
        // 返回一个对象
        {
            factorial: calculateFactorial,
            version: "1.0.0",
            processData: function(data) {
                return data.map(item => item * 2).filter(item => item > 10);
            }
        }
    "#;
    
    let js_module = context.eval(Source::from_bytes(js_code))?;
    
    // 3. 调用 JavaScript 函数
    let args = [20.into()]; // 计算 20 的阶乘
    let factorial_result = context.call_property(
        &js_module, 
        js_string!("factorial"), 
        &args
    )?;
    println!("20 的阶乘: {}", factorial_result);
    
    // 4. 从 Rust 向 JavaScript 传递数据
    let data = vec![1, 5, 7, 12, 3];
    let js_array = context.construct_array(data.into_iter())?;
    
    let process_args = [js_array];
    let processed_data = context.call_property(
        &js_module, 
        js_string!("processData"), 
        &process_args
    )?;
    
    // 5. 获取 JavaScript 对象的属性
    let version = context.get_property(
        &js_module, 
        StringOrSymbol::String(js_string!("version"))
    )?;
    println!("模块版本: {}", version);
    
    // 6. 注册 Rust 函数到 JavaScript 环境
    context.register_global_function(
        "logFromRust", 
        1, 
        |_, args, _| {
            if let Some(arg) = args.get(0) {
                println!("JavaScript 调用 Rust 函数: {}", arg);
            }
            Ok(().into())
        }
    )?;
    
    context.eval(Source::from_bytes("logFromRust('Hello from JavaScript!')"))?;
    
    Ok(())
}

4.2 使用 Mozjs (SpiderMonkey 的 Rust 绑定)

Mozjs 提供了 SpiderMonkey 引擎的完整 Rust 绑定,下面是一个更实用的示例,展示如何执行 JavaScript 代码、处理值和错误:

use mozjs::jsapi::{JSContext, JS_NewRuntime, JS_EnterCompartment, JS_Init, JS::RootedValue, JS::RootedObject};
use mozjs::jsval::UndefinedValue;
use mozjs::rust::{Runtime, Context, JSEngine};
use std::ptr;

fn main() {
    // 初始化 SpiderMonkey 引擎
    let engine = JSEngine::init().unwrap();
    
    // 创建运行时环境(设置 8MB 堆大小)
    let runtime = Runtime::new(engine.handle(), 8 * 1024 * 1024).unwrap();
    
    // 创建 JavaScript 上下文
    let context = Context::new(&runtime).unwrap();
    
    // 进入 JavaScript 上下文的执行区域
    let _ac = context.acquire().unwrap();
    
    // 获取全局对象
    let global = context.global_object().unwrap();
    
    // 创建一个 JavaScript 值用于存储执行结果
    let mut result = RootedValue::new(context.as_ptr());
    
    // 1. 执行简单的 JavaScript 表达式
    let js_code = "2 + 3 * 4";
    println!("执行表达式: {}", js_code);
    
    match context.evaluate_script(js_code, "<test>", 1, &mut result) {
        Ok(_) => {
            println!("执行结果: {}", result);
        },
        Err(e) => {
            println!("执行错误: {}", e);
        }
    }
    
    // 2. 定义和调用 JavaScript 函数
    let function_code = r#"
        function greet(name) {
            return 'Hello, ' + name + '! Welcome to SpiderMonkey via Rust!';
        }
        
        // 导出函数到全局对象
        this.greet = greet;
        true; // 表达式返回值
    "#;
    
    println!("\n注册 JavaScript 函数");
    if context.evaluate_script(function_code, "<functions>", 1, &mut result).is_ok() {
        println!("函数注册成功");
        
        // 3. 准备调用函数的参数
        let name_value = context.string_value("Rust Developer");
        let mut args = [name_value];
        
        // 4. 调用全局函数
        if let Ok(greet_func) = context.get_property(&global, "greet") {
            println!("\n调用 greet 函数");
            if context.call_function(None, &greet_func, &mut args, &mut result).is_ok() {
                println!("函数返回: {}", result);
            } else {
                println!("函数调用失败");
            }
        }
    }
    
    println!("\nSpiderMonkey 引擎使用完成");
}

4.3 使用 Rustyscript 执行简单脚本

Rustyscript 是一个轻量级的 JavaScript 执行环境,下面是如何使用它的示例:

use rustyscript::{json_args, Runtime, Script, Module, Error};
use std::collections::HashMap;

fn main() -> Result<(), Error> {
    // 1. 创建基本运行时
    println!("=== 创建基本运行时 ===");
    let mut runtime = Runtime::new()?;
    
    // 执行简单表达式
    let result: i64 = runtime.execute_expression("5 + 5 * 2")?;
    println!("表达式结果: {}", result);
    
    // 2. 创建包含自定义函数的脚本
    println!("\n=== 执行包含函数的脚本 ===");
    let script = Script::from_string(r#"
        function calculateDiscount(price, rate) {
            return price * (1 - rate / 100);
        }
        
        function formatCurrency(amount) {
            return '$' + amount.toFixed(2);
        }
        
        // 返回可用函数的对象
        {
            calculateDiscount,
            formatCurrency
        }
    "#);
    
    // 3. 执行脚本并获取导出的函数
    let module: Module = runtime.execute_module(script)?;
    
    // 4. 调用导出的函数
    println!("\n=== 调用 JavaScript 函数 ===");
    let price = 100.0;
    let discount_rate = 20.0;
    
    // 调用计算折扣的函数
    let discounted_price: f64 = module.call("calculateDiscount", json_args!(price, discount_rate))?;
    println!("原价 ${}, 折扣 {}%, 折后价: ${:.2}", 
             price, discount_rate, discounted_price);
    
    // 调用格式化货币的函数
    let formatted: String = module.call("formatCurrency", json_args!(discounted_price))?;
    println!("格式化后的价格: {}", formatted);
    
    // 5. 注册 Rust 函数到 JavaScript 环境
    println!("\n=== 注册 Rust 函数到 JavaScript ===");
    let mut runtime_with_functions = Runtime::new()?;
    
    // 注册一个日志函数
    runtime_with_functions.register_function("logMessage", move |args: Vec<String>| -> Result<String, Error> {
        let message = args.get(0).unwrap_or(&"No message".to_string());
        println!("[Rust] JavaScript 调用: {}", message);
        Ok(format!("Rust 收到: {}", message))
    })?;
    
    // 在 JavaScript 中调用 Rust 函数
    let js_result: String = runtime_with_functions.execute_expression(r#"
        logMessage('Hello from JavaScript to Rust!')
    "#)?;
    println!("JavaScript 收到 Rust 函数返回: {}", js_result);
    
    // 6. 使用 Rust 的类型系统与 JavaScript 交互
    println!("\n=== 类型交互示例 ===");
    let mut type_runtime = Runtime::new()?;
    
    // 创建包含数据的 JavaScript 对象
    let data = HashMap::from([
        ("name", "Product A"),
        ("price", "29.99"),
        ("inStock", "true")
    ]);
    
    // 将数据注入到 JavaScript 环境
    type_runtime.insert_values(data)?;
    
    // 在 JavaScript 中使用这些值
    let total_price: String = type_runtime.execute_expression(r#"
        const taxRate = 0.1;
        const price = parseFloat(price);
        const tax = price * taxRate;
        
        `${name} - 含税总价: $${(price + tax).toFixed(2)}`
    "#)?;
    
    println!("类型交互结果: {}", total_price);
    
    Ok(())
}

五、JavaScript 引擎技术对比

5.1 语言实现对比

特性 Rust 实现 (如 Boa) C++ 实现 (如 V8) C 实现 (如 QuickJS)
内存安全 编译时检查,几乎无内存错误 依赖手动内存管理,存在潜在风险 有限的内存安全保证
性能 良好,但尚未完全优化 优异,高度优化 轻量级但性能相对较弱
生态系统 新兴,正在发展 成熟,工具丰富 稳定但扩展有限
学习曲线 陡峭 较陡 中等
内存占用 中高 极低
线程安全 内置保证 需额外机制 有限支持

5.2 主要 Rust JS 引擎项目对比

引擎/项目 类型 技术特点 使用案例 评分 (5分制)
Boa 完整引擎 纯 Rust实现,代码库6.1K Stars,编译后体积约1MB,ES5完整支持,ES6+兼容性85% 嵌入式项目脚本扩展,教育工具 3.5
Yavashark 完整引擎 纯 Rust实现,1225 Stars,编译后5.6MB,启动<10ms,ES2023完整支持,ES2025兼容性98% 嵌入式设备,边缘计算,WebAssembly运行时 4.0
Nova 完整引擎 纯 Rust实现,分层架构,热点路径比同类快15-20%,内存比Boa低30%,启动<5ms 实时应用脚本引擎,桌面应用插件系统 3.8
Brimstone 完整引擎 纯 Rust实现,基础JIT编译,热点路径比纯解释器快2-3倍,支持SIMD指令集 科学计算,游戏引擎脚本系统,WASM互操作层 4.2
Mozjs 绑定库 SpiderMonkey的Rust绑定,约200个Rust结构体和特征,ES2023完整支持 Firefox组件,需要完整JS支持的桌面应用 4.5
Jsparagus 解析器 纯 Rust实现,基于PEG生成器,解析比传统快10-15%,代码覆盖率>95% Firefox解析器组件,开发工具,IDE代码分析 4.3
Rustyscript 执行环境 轻量级JS执行环境,简化API 简单脚本执行需求 3.0

5.3 内存占用与启动性能对比

引擎类型 典型内存占用 启动时间 适用环境
Rust 轻量引擎 < 10MB 毫秒级 嵌入式设备、微控制器
Rust 完整引擎 10-50MB 亚秒级 桌面应用、服务端
C++ 主流引擎 50-200MB 秒级 浏览器、Node.js
C 轻量引擎 < 5MB 微秒级 极资源受限环境

六、JS 引擎发展趋势与 Rust 的未来角色

6.1 安全性优先的设计理念

随着网络安全威胁的日益复杂,JavaScript 引擎的安全性将成为首要考量。根据行业趋势,我们可以预见:

  • 内存安全漏洞减少:如华为的实践所示,将核心组件 Rust 化可减少高达 67% 的内存安全漏洞 1,这一数据将推动更多引擎开发者采用 Rust
  • 形式化验证增加:Rust 的类型系统为形式化验证提供了基础,未来可能会看到更多经过形式化验证的 JS 引擎组件
  • 零信任架构融合:JavaScript 引擎将更深入地融入零信任安全架构,Rust 的不可变性和所有权特性使其成为实现这一目标的理想语言

6.2 多样化部署环境的适应

JavaScript 引擎正从浏览器和服务器扩展到更广泛的领域:

  • 嵌入式与物联网:Rust 实现的轻量级 JS 引擎将成为 IoT 设备的理想选择,支持在资源受限环境中运行 JS 代码
  • 边缘计算节点:在 5G/6G 网络推动下,边缘节点需要高效执行 JS 代码,Rust 引擎可提供低延迟、高吞吐量的解决方案
  • 智能汽车与工业系统:这些对安全性和实时性要求极高的领域,将从 Rust 实现的 JS 引擎中获益良多

6.3 WebAssembly 与 JavaScript 的深度融合

WebAssembly 的普及正在改变 JavaScript 引擎的架构设计:

  • 统一执行模型:未来 JS 引擎可能采用更统一的执行模型,无缝切换 JavaScript 和 WebAssembly 代码
  • 混合优化策略:Rust 作为 WebAssembly 的主要开发语言,将在优化混合执行环境方面发挥关键作用
  • 共享内存与协作:JavaScript 和 WebAssembly 模块之间的内存共享和协作将更加高效,Rust 的安全并发特性将促进这一发展

6.4 云原生与服务器端应用

JavaScript 在服务器端的应用正在扩展,Rust 实现的引擎将在以下方面提供价值:

  • 微服务架构:轻量级 Rust JS 引擎适合构建高效的微服务,减少资源占用
  • 无服务器计算:更快的启动时间和更低的内存占用,使 Rust JS 引擎成为 FaaS 场景的理想选择
  • 实时数据处理:利用 Rust 的性能优势,处理高吞吐量的实时数据流

6.5 生态系统整合与标准演进

Rust 与 JavaScript 生态系统的整合将加深:

  • 工具链共享:更多构建工具、调试器和性能分析工具将同时支持 Rust 和 JavaScript
  • 标准库互操作:JavaScript 标准库可能会借鉴 Rust 的某些设计理念,如更严格的类型检查
  • 跨语言框架:更多框架将支持 Rust 和 JavaScript 的无缝协作,如 Tauri 等桌面应用框架

6.6 量化的未来展望:2025-2030

基于当前技术发展趋势,我们可以对 Rust 在 JS 引擎领域的未来做出以下预测:

  • 2025 年:至少有一个主流浏览器引擎将有 20% 以上的组件使用 Rust 实现
  • 2026 年:首个完全使用 Rust 实现的生产级 JS 引擎将问世
  • 2028 年:Rust 实现的 JS 引擎在嵌入式和边缘计算领域的市场份额将超过 30%
  • 2030 年:JavaScript 引擎的安全漏洞将比 2023 年减少 50%,主要归功于 Rust 等内存安全语言的采用

七、结论

Rust 在 JavaScript 引擎赛道中正在扮演越来越重要的角色。从纯 Rust 实现的 Boa 引擎,到 Mozilla 的 Jsparagus 解析器,再到与现有 C++ 引擎的集成方案,Rust 正在为 JavaScript 引擎带来新的技术可能性。

随着 Rust 生态系统的不断成熟和性能优化工作的持续推进,我们有理由相信,未来将会看到更多性能卓越、安全可靠的 JavaScript 引擎采用 Rust 实现或部分组件 Rust 化。对于开发者而言,了解 Rust 在 JavaScript 引擎中的应用,不仅有助于选择合适的技术栈,也能更好地理解现代 Web 技术的发展方向。

在这个性能与安全并重的时代,Rust 正在为 JavaScript 引擎的发展开辟一条新的道路,而这条道路,值得我们持续关注和探索。


更多 JavaScript 基础知识的学习,可以学习我写的这本 《JavaScript 语言编程进阶》 小册。

纯Monorepo vs 混合式Monorepo

2025年11月23日 11:00

一、开篇:用生活场景理解两种模式

如果要同时管理 “个人博客”“通用工具库”“公司业务系统” 三个项目,两种模式给出了不同的 “管理方案”——

纯 Monorepo:“一家人住同一套大公寓”

所有项目都放进一个 Git 仓库(相当于一套大公寓),共用 “水电燃气”(工程化配置,如 ESLint、构建工具)和 “家具家电”(依赖包,如 React、Vue)。改工具库的代码,博客项目立刻能用上,不用 “搬东西”(发版、升级)。

混合式 Monorepo:“邻居住同一小区,共用健身房 + 偶尔串门借东西”

每个项目还是独立的 Git 仓库(相当于小区里的不同住户),但都放进同一个文件夹(如 apps/,相当于小区),共用 “公共设施”(pnpm 依赖缓存、turbo 构建加速);遇到需要改工具库的情况,能临时 “串门借东西”(本地关联公共库代码),改完后再 “归位”(公共库发版、业务项目升级)。

二、核心对比:8 个维度讲透差异

对比维度 纯 Monorepo 混合式 Monorepo
1. 仓库结构 1 个 Git 仓库,所有项目是仓库内的子目录(如 packages/工具库apps/博客 N 个独立 Git 仓库,放在同一目录下(如 apps/博客 内有自己的 .git 文件夹)
2. 项目联动 改公共库后,业务项目实时生效,1 次提交同步所有变更 日常:公共库发版 + 业务项目升级;特殊场景:本地关联公共库,改完后分别提交
3. 依赖管理 全仓库依赖统一在根目录 package.json 管理,子项目直接引用本地目录 日常:各项目独立 package.json,共用 pnpm 缓存;特殊场景:pnpm link 本地关联公共库
4. 准备工作 需配置 “工作区”(如 pnpm workspaces),指定子项目目录 零配置门槛:克隆独立仓库到同一文件夹,特殊场景加 pnpm link 即可
5. 典型工具 工作区工具:pnpm workspaces、Lerna、Turborepo(全仓库构建) 缓存工具 + 关联工具:pnpm(缓存 + link)、turbo(单项目加速)、Verdaccio(私有包仓库)
6. 版本控制 所有项目共用一套版本记录,工具库升级 = 仓库整体提交 每个项目独立版本,公共库版本和业务项目版本分开管理
7. 团队协作 适合 “全栈式协作”:同一批人既改业务又维护工具库 适合 “分工 + 灵活协作”:基建组管公共库,业务组管项目,特殊场景临时联动
8. 迁移成本 需合并多个独立仓库代码,整理历史提交 直接把现有仓库放进同一目录,特殊场景加本地关联配置

三、关键场景:什么时候需要高频联动?

虽然大部分时候公共库很稳定,但以下 4 种情况,纯 Monorepo 的 “一键联动” 会更高效:

  1. 小团队 / 初创团队:没人专职维护公共库,开发博客时发现工具库缺功能,得自己改工具库再同步业务;

  2. 公共库重大更新:比如组件库从 Vue2 升到 Vue3,业务项目要跟着改引用方式,天天需要联调;

  3. 业务专属公共库:比如电商团队的 “订单工具库”,只服务订单业务,业务改需求就得同步改工具库;

  4. ToB 定制需求:客户要在报表页加特殊筛选,得改公共表格组件,再同步到业务项目,周期短要求快。

而混合式 Monorepo 能兼顾:日常用 “独立仓库 + 发版” 保证稳定,遇到以上场景用 “本地关联” 临时联动,不用强行合并仓库。

四、实际操作:两种模式怎么用?

案例 1:新增 “日历组件库”

  • 纯 Monorepo
  1. 根仓库新建 packages/日历组件

  2. 博客项目直接引用 import 日历 from '@我的仓库/日历组件'

  3. 一次提交:git commit -m "新增日历组件+博客接入"

  • 混合式 Monorepo
  1. 单独建 “日历组件” 仓库,开发完发私有 npm 包(用 Verdaccio);

  2. 博客项目执行 pnpm install @我的账号/日历组件

  3. 两次提交:先提交组件库,再提交博客项目。

案例 2:修复工具库 bug(高频联动场景)

  • 纯 Monorepo
  1. packages/工具库 的 bug;

  2. 切换到博客项目验证;

  3. 一次提交搞定。

  • 混合式 Monorepo
  1. 日常:改工具库→发版 v1.0.1→博客项目 pnpm update→提交;

  2. 紧急联动:用 pnpm link ../工具库 本地关联→改完工具库直接验证→工具库发版 + 博客项目升级→分别提交。

四、工具选型:两种模式的 “好搭档”

纯 Monorepo 必备工具

工具名称 作用 简单用法
pnpm workspaces 管理全仓库依赖和子项目目录 根目录写 packages: ['packages/*', 'apps/*']
Turborepo 全仓库增量构建,一次启动所有项目 turbo run dev 根目录执行
Lerna 批量给子项目发版 lerna publish 一键升版本

混合式 Monorepo 常用工具

工具名称 作用 简单用法
pnpm 依赖缓存 + 本地关联公共库 pnpm link ../工具库 关联;pnpm install 用缓存
Verdaccio 搭私有 npm 仓库,存公共库包 启动后 npm publish --registry 本地地址
turbo 加速单个项目构建启动 进入博客目录 turbo dev

五、常见问题解答

Q1:纯 Monorepo 仓库会越来越大吗?

不会!根目录 .gitignore 排除 node_modules/(依赖不进仓库)、dist/(构建产物不进仓库),大文件用 Git LFS 存,体积可控。

Q2:混合式 Monorepo 能复用工具库代码吗?

当然!日常用私有 npm 包(Verdaccio)安装引用,紧急情况用 pnpm link 本地关联,两种方式都能复用。

Q3:团队变大后能切换模式吗?

  • 混合式→纯 Monorepo:把所有仓库代码合并到一个新仓库,配好工作区即可;

  • 纯 Monorepo→混合式:拆分需要独立的项目,初始化新仓库发私有包,其他项目安装引用。

六、选型指南:3 步选对模式

第一步:看 “联动频率”

  • 每周都要改公共库 + 业务代码→纯 Monorepo;

  • 半年改一次公共库,日常只开发业务→混合式 Monorepo。

第二步:看 “团队分工”

  • 3-5 人小团队,一人多岗→纯 Monorepo;

  • 10 人以上,基建组和业务组分工明确→混合式 Monorepo。

第三步:看 “灵活度需求”

  • 想少配置,快速启动→混合式 Monorepo(克隆仓库就能用);

  • 愿意花 10 分钟配工作区,换长期高效→纯 Monorepo。

七、总结:没有 “最优”,只有 “适配”

  • 纯 Monorepo 是 “高效联动派”:适合高频改公共库、小团队协作,用 “一次提交” 省时间;

  • 混合式 Monorepo 是 “稳定灵活派”:适合分工明确、公共库稳定的场景,日常稳定 + 紧急联动两不误。

不用纠结 “必须选哪种”,小团队可以先从纯 Monorepo 起步,团队变大分工明确后,过渡到混合式;也可以直接用混合式,兼顾稳定和灵活 —— 核心是让模式适配你的开发节奏~

谈谈最进学习(低延迟)直播项目的坎坷与收获

作者 小熊哥722
2025年11月23日 10:51

最近一直在学习低延迟直播系统的开发,从协议选型到具体实现,踩过不少坑,也积累了很多经验。分享一下这段旅程中的思考和收获~

一、直播核心协议选型:从认知到决策

开发的第一步,是理清主流直播协议的特性,找到适配场景的技术方向。以下是我对核心协议的整理与分析:

1. HTTP-FLV:兼容性之王

核心特点:基于 HTTP 分块编码传输 FLV 容器格式,轻量化、易部署。

  • 封装与传输:FLV 仅支持 H.264/H.265(视频)、AAC/MP3(音频),封装简单(头部 + 音视频 Tag),解码延迟低;借助 HTTP 分块编码实现 “边传边播”,默认 80/443 端口可穿透防火墙。

  • 传输流程

    1. 播放端发送GET /live/stream.flv请求,携带Accept: */*
    2. 服务端响应Transfer-Encoding: chunked,按 1-8KB 拆分 FLV 流并实时推送。
  • 延迟构成

    • 编码延迟(50ms-300ms):依赖推流端低延迟配置(无 B 帧、短 GOP);
    • 服务端延迟(300ms-2s):GOP 缓存 / 转码是主因(优化后可压至 300ms 内);
    • 传输 + 播放端延迟(400ms-1.9s):HTTP 封装开销 + 播放器缓冲(优化后可至 0.3-1s)。
  • 优缺点:✅ 兼容性极强(浏览器 / APP / 机顶盒)、配置简单、支持 HTTPS;❌ 协议开销略高、依赖 GOP 缓存、Web 端需 FLV.js 解析。

2. RTMP:低延迟直播老将

核心特点:基于 TCP 的实时消息传输协议,专为音视频设计。

  • 传输机制:分层结构(应用层 / 消息层 / 协议层),Chunk 分块传输大帧,滑动窗口控流。
  • 延迟优势:优化后全链路延迟 0.8-2s,比 HTTP-FLV 低 20%-30%,服务端无转码时延迟可忽略。
  • 优缺点:✅ 延迟低、配置直接、兼容性广;❌ 弱网 TCP 重传易卡顿、无默认加密、Web 端需 Flash 替代方案。

3. HLS/LL-HLS:跨平台兼容首选

核心特点:切片传输 + 自适应码率,原生支持全平台。

  • 原生痛点:切片机制导致延迟 10-30s(切片生成 + 缓存 + 播放缓冲);
  • LL-HLS 优化:缩短切片至 0.5-2s、分片传输 Chunk、实时更新 m3u8,延迟降至 3-5s。
  • 优缺点:✅ 极致兼容、自适应码率、易部署;❌ 原生延迟高、切片开销大、实时性差。

4. WebRTC:实时互动利器

核心特点:浏览器原生实时通信,毫秒级延迟(50-300ms)。

  • 技术栈:集成采集 / 编码 / 传输 / 渲染,UDP 传输 + P2P 直连,内置回声消除等互动功能。
  • 延迟构成:采集编码(30-80ms)+ 传输(20-150ms)+ 解码渲染(10-30ms)。
  • 优缺点:✅ 延迟最低、互动性强、无插件依赖;❌ 部署复杂(需 STUN/TURN/SFU)、带宽占用高。

5. SRT:专业级低延迟传输

核心特点:UDP 可靠传输 + 加密,延迟 10-150ms,抗丢包(10% 丢包仍稳定)。

  • 技术优势:ARQ 重传 + FEC 纠错,原生 AES 加密,适配 4K/8K 高码率传输。
  • 优缺点:✅ 延迟极低、可靠性强、加密安全;❌ Web 端兼容性差、CDN 支持有限、配置精细。

协议特性对比表

特性 SRT WebRTC HTTP-FLV(优化后) RTMP(优化后)
原生延迟 10-150ms 50-300ms 1-2.5s 0.8-2s
传输协议 UDP(可靠传输) UDP(为主) TCP(HTTP/HTTP3) TCP
丢包容忍性 强(10% 丢包) 中(5% 丢包) 强(TCP 重传) 中(TCP 重传阻塞)
加密支持 原生 AES 需 DTLS 依赖 HTTPS 需 RTMPS
高并发支持 中(需媒体服务器) 低(P2P 带宽受限) 高(CDN 分发) 中(CDN 支持)
部署复杂度

二、开发实践:从选型到落地的坎坷

1. 初期选型:WebRTC 的尝试与碰壁

最开始选择 WebRTC 作为核心协议(一是初期对 SRT 认知不足,二是看中其浏览器原生兼容性):

  • 手动封装信令服务器 + WebRTC 推拉流组件,实现基础功能后发现并发极低
  • 转向 MediaSoup(SFU 框架)优化并发,但全英文文档 + 拓展复杂度高,最终放弃。

这段经历虽未落地,但吃透了 WebRTC 的信令交互、NAT 穿透、媒体流处理逻辑。

2. 最终选型:SRS 的救赎

SRS(Simple Real-Time Server)是开源实时视频服务器,支持多协议转换,成为我的最终选择:

  • 支持 RTMP/WebRTC/HTTP-FLV/SRT 等协议,开箱即用;
  • 解决了 WebRTC 并发问题,且支持协议转换(如 RTMP 转 WebRTC)。

SRS 配置(6.0 版本)

# main config for srs.
listen              1935;          # RTMP端口
max_connections     1000;          # 最大连接数
daemon              on;            # 守护进程模式

# HTTP API:用于控制和查询SRS状态
http_api {
    enabled         on;
    listen          1985;
}

# HTTP服务器:提供HLS/HTTP-FLV访问
http_server {
    enabled         on;
    listen          8080;
    dir             ./objs/nginx/html; # 静态文件目录
}

# WebRTC配置:UDP端口+公网候选地址
rtc_server {
    enabled on;
    listen  8000; # WebRTC的UDP端口
    candidate 0.0.0.0; # 公网服务器需替换为实际IP
}

vhost __defaultVhost__ {
    # HLS配置:低延迟切片
    hls {
        enabled         on;
        hls_path        ./objs/nginx/html/hls;
        hls_m3u8_file   [app]/[stream].m3u8;
        hls_ts_file     [app]/[stream]-[seq].ts;
        hls_fragment    1; # 1秒切片,降低延迟
    }

    # HTTP-FLV挂载
    http_remux {
        enabled     on;
        mount       [vhost]/[app]/[stream].flv;
    }

    # WebRTC与RTMP互转
    rtc {
        enabled     on;
        rtmp_to_rtc on; # RTMP推流转WebRTC播放
        rtc_to_rtmp on; # WebRTC推流转RTMP播放
    }

    # 播放优化:GOP缓存+队列控制
    play {
        gop_cache_max_frames 2500;
        gop_cache on; # 缓存GOP实现秒开
        queue_length 10; # 播放队列长度(缓存10帧)
    }
}

踩坑与解决

  • AI 提供的配置常出现语法错误(如路径格式、参数冲突),需结合官方文档逐行校验;
  • WebRTC 推流需确保candidate配置正确(公网 IP / 端口映射),否则无法建立连接;
  • 低延迟优化需关闭 GOP 缓存、缩短 HLS 切片、减小播放器缓冲。

三、收获与总结

  1. 协议选型无优劣,适配场景是关键: 互动场景选 WebRTC,低延迟直播选 SRT/RTMP,跨平台兼容选 LL-HLS;

  2. 开源工具的力量:SRS 这类成熟框架节省了底层开发成本,聚焦业务逻辑即可快速落地;

  3. 实践出真知:从手动封装组件到踩坑 SRS 配置,每一步试错都加深了对直播技术栈的理解。

目前已实现 OBS(WebRTC)推流、前端 / VLC 拉流的低延迟直播,后续计划探索 SRT 协议集成、高并发优化。万事开头难,技术沉淀在于持续踩坑与复盘~

从 0 到 1 实现 LocalStorage 待办清单:CSS 进阶 + 前端工程化思想实践

2025年11月23日 10:21

在前端开发中,LocalStorage 是浏览器提供的核心本地存储方案,而 CSS 布局与工程化编码则是提升开发效率和代码质量的关键。本文将结合实际案例,带大家从零实现一个可持久化的待办清单,同时拆解 CSS 继承 / 弹性布局的核心用法,以及前端函数式封装的工程化思想。

一、核心知识点预热:先搞懂这 3 个关键技术

在动手写代码前,我们先梳理案例中涉及的核心技术点,帮大家打通知识盲区:

1. LocalStorage:浏览器的「永久储物柜」

  • 存储位置:浏览器本地,与服务器无关

  • 存储特性:永久存储(除非手动清除或代码删除),页面刷新 / 关闭后数据不丢失

  • 存储规则:仅支持 key-value 键值对,且值必须是字符串(对象 / 数组需用 JSON.stringify() 序列化)

  • 核心 API

    javascript

    运行

    // 存数据
    localStorage.setItem('key', JSON.stringify(数据));
    // 取数据(需反序列化)
    JSON.parse(localStorage.getItem('key'));
    // 删数据(单个/全部)
    localStorage.removeItem('key');
    localStorage.clear();
    

2. CSS 关键特性:这些细节能少写 80% 冗余代码

(1)继承性:不是所有属性都能「子承父业」

  • 可继承属性font-sizecolortext-align 等文本相关属性(子元素自动继承父元素样式)

  • 不可继承属性backgroundwidthheightborder 等布局 / 盒模型相关属性(需手动设置)

  • 实用技巧:用 inherit 强制继承父属性,比如让子元素高度跟随父元素:

    css

    .child {
      height: inherit; /* 继承父元素 height */
    }
    

(2)outline 与 overflow:细节优化神器

  • outline:元素轮廓,类似 border 但不占据盒模型空间(适合表单聚焦样式)
  • overflow:控制子元素超出父元素时的表现(hidden 隐藏溢出、auto 自动滚动)

(3)Flex 布局:快速实现居中与对齐

Flex 是现代布局方案,核心是「格式化上下文」,只需 3 行代码实现元素居中:

css

.parent {
  display: flex; /* 开启 Flex 布局 */
  justify-content: center; /* 主轴(水平)居中 */
  align-items: center; /* 交叉轴(垂直)居中 */
}

3. 前端工程化:拒绝流程式代码,拥抱函数封装

当代码超过 10 行,就该考虑封装函数!核心优势:

  • 复用性:同一逻辑多处调用,减少冗余
  • 可维护性:隐藏实现细节,修改时只需改函数内部
  • 可读性:函数名即功能,代码逻辑更清晰

二、实战:实现 LocalStorage 待办清单

1. 项目结构

plaintext

todo-list/
├── common.css  # 样式文件
└── index.html  # 结构+逻辑文件

2. 样式文件:common.css

css

/* 基础重置:统一盒模型 */
html {
  box-sizing: border-box;
  min-height: 100vh; /* 占满视口高度 */
  display: flex; /* 页面整体居中 */
  justify-content: center;
  align-items: center;
  text-align: center; /* 文本居中 */
  background-color: #f5f5f5;
}

*,
*::before,
*::after {
  box-sizing: inherit; /* 继承盒模型 */
}

/* 容器样式 */
.wrapper {
  padding: 20px;
  max-width: 350px;
  background-color: rgba(255, 255, 255, 0.95);
  box-shadow: 0 0 0 10px rgba(0, 0, 0, 0.1); /* 外发光效果 */
  border-radius: 8px;
}

h2 {
  margin: 0 0 20px;
  font-weight: 200;
  color: #333;
}

/* 待办列表样式 */
.plates {
  margin: 0;
  padding: 0;
  text-align: left;
  list-style: none;
}

.plates li {
  border-bottom: 1px solid rgba(0, 0, 0, 0.1);
  padding: 10px 0;
  font-weight: 100;
  display: flex; /* 复选框与文本同行 */
  align-items: center;
}

.plates label {
  flex: 1; /* 文本占满剩余空间 */
  cursor: pointer; /* 鼠标悬浮变指针 */
  color: #444;
}

/* 自定义复选框样式 */
.plates input {
  display: none; /* 隐藏原生复选框 */
}

.plates input + label:before {
  content: "⬜️";
  margin-right: 10px;
  font-size: 18px;
}

.plates input:checked + label:before {
  content: "✅"; /* 选中时切换图标 */
}

/* 表单样式 */
.add-items {
  margin-top: 20px;
  display: flex;
  gap: 10px; /* 输入框与按钮间距 */
}

.add-items input[type="text"] {
  flex: 1;
  padding: 10px;
  border: 1px solid rgba(0, 0, 0, 0.1);
  border-radius: 4px;
  outline: 3px solid transparent;
  transition: outline 0.3s;
}

.add-items input[type="text"]:focus {
  outline: 3px solid rgba(14, 14, 211, 0.8); /* 聚焦高亮 */
}

.add-items input[type="submit"] {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  background-color: rgba(14, 14, 211, 0.8);
  color: white;
  cursor: pointer;
  transition: background-color 0.3s;
}

.add-items input[type="submit"]:hover {
  background-color: rgba(14, 14, 211, 1);
}

3. 核心文件:index.html

html

预览

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>LocalStorage 待办清单</title>
  <link rel="stylesheet" href="./common.css">
</head>
<body>
  <div class="wrapper">
    <h2>LOCAL TAPAS</h2>
    <ul class="plates">
      <li>Loading Tapas...</li>
    </ul>
    <form class="add-items">
      <input 
        type="text" 
        placeholder="输入待办事项" 
        required
        name="item"
      >
      <input type="submit" value="+ 添加事项">
    </form>
  </div>

  <script>
    // 1. 获取 DOM 元素
    const addItemsForm = document.querySelector('.add-items');
    const itemsList = document.querySelector('.plates');
    // 2. 从 LocalStorage 取数据(无则初始化空数组)
    const items = JSON.parse(localStorage.getItem('todos')) || [];

    /**
     * 渲染待办列表
     * @param {Array} plates - 待办数组
     * @param {Element} platesList - 列表 DOM 元素
     */
    function populateList(plates = [], platesList) {
      // 数组 map 生成 DOM 字符串,join 拼接(避免逗号分隔)
      platesList.innerHTML = plates.map((plate, index) => `
        <li>
          <input 
            type="checkbox" 
            data-index="${index}" 
            id="item${index}"
            ${plate.done ? 'checked' : ''}
          />
          <label for="item${index}">${plate.text}</label>
        </li>
      `).join('');
    }

    /**
     * 添加待办事项
     * @param {Event} event - 表单提交事件
     */
    function addItem(event) {
      event.preventDefault(); // 阻止表单默认提交行为
      // 获取输入框值(去空格)
      const text = this.querySelector('[name=item]').value.trim();
      if (!text) return; // 空值不添加

      // 新增待办对象
      const newItem = {
        text,
        done: false // 默认未完成
      };

      items.push(newItem);
      // 存入 LocalStorage(序列化数组)
      localStorage.setItem('todos', JSON.stringify(items));
      // 重新渲染列表
      populateList(items, itemsList);
      this.reset(); // 重置表单
    }

    /**
     * 切换待办完成状态
     * @param {Event} event - 点击事件
     */
    function toggleDone(event) {
      const target = event.target;
      // 只处理复选框的点击
      if (target.tagName !== 'INPUT') return;

      // 获取数据索引(从 data-index 属性)
      const index = target.dataset.index;
      // 切换完成状态
      items[index].done = !items[index].done;
      // 更新 LocalStorage
      localStorage.setItem('todos', JSON.stringify(items));
      // 重新渲染
      populateList(items, itemsList);
    }

    // 3. 绑定事件监听
    addItemsForm.addEventListener('submit', addItem);
    itemsList.addEventListener('click', toggleDone);

    // 4. 页面加载时渲染列表
    populateList(items, itemsList);
  </script>
</body>
</html>

三、关键技术拆解:为什么这么写?

1. LocalStorage 持久化逻辑

  • 初始化:页面加载时从 localStorage 读取 todos 数据,若不存在则初始化为空数组
  • 新增待办:添加后立即用 JSON.stringify() 序列化数组,存入 localStorage
  • 状态切换:修改待办完成状态后,同步更新 localStorage,确保刷新后状态不丢失

2. CSS 进阶技巧

  • 盒模型继承:通过 box-sizing: inherit 让所有元素(包括伪元素)继承 border-box,避免计算宽度时的麻烦
  • Flex 布局妙用:页面整体居中、待办项复选框与文本对齐、表单输入框与按钮同行分布,都用 Flex 实现,简洁高效
  • 自定义复选框:隐藏原生复选框,用 :before 伪元素实现自定义图标,提升视觉体验
  • 聚焦状态优化:输入框聚焦时添加蓝色轮廓,提升交互反馈

3. 工程化编码思想

(1)函数式封装:拒绝流程式代码

  • 把「渲染列表」「添加待办」「切换状态」拆分为 3 个独立函数,每个函数只做一件事
  • 函数参数化:populateList 接收待办数组和列表 DOM 元素,增强复用性
  • 避免全局变量污染:核心数据 items 通过函数参数传递,而非依赖全局变量

(2)细节优化:提升用户体验

  • 输入框去空格:用 trim() 避免添加空待办
  • 空值判断:输入为空时不添加待办
  • 表单重置:添加后清空输入框,方便连续输入
  • 事件委托:给列表父元素绑定点击事件,而非给每个复选框绑定,提升性能(尤其待办较多时)

四、扩展与优化方向

  1. 添加删除功能:给每个待办项增加删除按钮,点击时删除对应项并更新 localStorage
  2. 批量操作:实现「全选 / 全不选」「清空已完成」功能
  3. 数据持久化优化:封装 localStorage 操作工具函数,避免重复写 JSON.stringify 和 JSON.parse
  4. 样式升级:添加动画效果(如待办项添加 / 删除时的过渡动画)
  5. 响应式优化:适配移动端,优化小屏幕下的布局

五、总结

本文通过一个简单的待办清单案例,串联了 LocalStorage 本地存储、CSS 进阶特性和前端工程化编码思想。核心要点:

  • LocalStorage 是前端持久化存储的基础,关键是掌握「序列化 / 反序列化」技巧
  • CSS 不仅是样式,合理运用继承、Flex 布局和伪元素,能大幅提升开发效率和视觉体验
  • 工程化编码的核心是「拆分与封装」,让代码更易维护、可复用

这个案例虽然简单,但包含了前端开发的核心思想,适合新手入门练习,也能帮助有经验的开发者巩固基础。动手试试扩展功能,让它变得更强大吧!

mini-react 实现function 组件

作者 Holin_浩霖
2025年11月23日 00:47

代码结构如下:

  1. React.js 作用
  • render() : 渲染入口,初始化根 Fiber 并启动调度
  • createElement() : 将 JSX 转换为虚拟 DOM 对象
  • workLoop() : 调度器,利用 requestIdleCallback 进行时间切片
  • performUnitOfWork() : 处理单个 Fiber 工作单元
  • commitRoot() : 提交阶段入口
作用:实现一个非常精简的 Fiber 渲染器的核心逻辑(非完整实现,偏教学/探索用途)。
目标与分层:
- render(): 接收虚拟 DOM(由 JSX 或 createElement 创建),将整体渲染任务拆分为 fiber 工作单元并启动时间切片调度。
- performUnitOfWork(): 在 render 阶段构建/链接 fiber 节点并创建真实 DOM(但不立即挂载到页面上,挂载在 commit 阶段统一处理以优化性能)。
- commitRoot()/commitWork(): 将计算完成的 DOM 树统一提交到真实 DOM(避免 render 阶段频繁 DOM 操作)。
重要约定(fiber 结构):
- fiber.type: 节点类型(字符串标签或 'TEXT_ELEMENT')。
- fiber.props: 属性对象(包含 children 数组)。
- fiber.dom: 对应的真实 DOM 节点(在 render 阶段 createDom 后创建)。
- fiber.parent: 父 fiber 引用(用于 commit 时挂载)。
- fiber.child: 第一个子 fiber 链接(深度优先处理顺序)。
- fiber.sibling: 兄弟 fiber 链接(以支持广度上的顺序处理)。
设计说明(简要):
- 为支持页面响应性,渲染任务被拆分为多个小的工作单元(fiber),并通过 requestIdleCallback 的空闲时间片执行。
- render 阶段只负责构建 DOM 节点并建立 fiber 链表(depth-first 构建),真正的 DOM 插入在 commit 阶段一次性完成。
- 目前未

JavaScript

  1. ReactDom.js 作用:创建根节点
  • createRoot() : 创建根节点,提供渲染接口
  1. fiberUtils.js 作用
  • commitWork() : 递归挂载 Fiber 树到真实 DOM
功能:实现 Fiber 提交阶段的操作(commit),把 render 阶段构建的 fiber 树转为真实 DOM 结构。
说明:commit 阶段的核心是把已存在且正确设置 props/dom 的 fiber 节点插入文档中。该模块将提交逻辑单独抽离,
以便让渲染逻辑和提交逻辑职责分离,且更容易进行单元测试。

JavaScript

  1. domUtils.js
  • createDom() : 创建真实 DOM 节点
  • updateProperties() : 更新 DOM 属性
  • appendDomToRootContainer() : DOM 挂载逻辑
功能概览:
- 本文件包含与真实 DOM 操作相关的纯工具函数,负责在 render/commit 阶段处理 DOM 的创建、属性更新与父容器挂载逻辑。
- 把这些与副作用相关的细节抽象到独立模块,能使主渲染逻辑更简洁且更容易测试。
约束与说明:
- 这些函数会直接操作浏览器 DOM,因此它们的副作用应只在 commit 阶段执行。
- updateProperties 非常基础:仅直接把 props 的字段(非 children)赋值到元素上,未做差分、事件绑定处理或样式合并(这些为后续功能)。

JavaScript

  1. childrenUtils.js
  • normalizeChildren() : 规范化 children 数组
  • 处理各种输入类型(undefined、单值、数组)
  • 扁平化嵌套数组
  • 将文本节点转换为 TEXT_ELEMENT 结构
功能:规范化 children 列表,返回一个扁平化且统一结构的 children 数组(便于后续构建 fiber)
这个工具做了下列工作:
1) 接受各种可能的 children 输入形态:
   - undefined / null
   - 单个节点对象(VNode)
   - 单个原始值(string / number)
   - 数组(可能嵌套)
2) 将 children 规范成一个扁平数组,移除 null/undefined
3) 将原始文本(string / number)转换为一个统一的 TEXT_ELEMENT 虚拟节点,
   以便后续的 createDom / props 处理一致
返回值:一个标准化的 children 数组(所有项都为 VNode 样式对象,或整体为空数组)
设计注意点:
- 此处没有深度拷贝 children 对象,因此传入的对象引用会保留。
- 对于非常深/大的 children 数组,flat(Infinity) 可能引发性能问题,生产代码中应使用更高效的迭代器或限制层级。

JavaScript

整体渲染流程

performUnitOfWork 详细流程

commitWork 提交流程

children规范化流程

详细执行流程

阶段一:初始化阶段

  1. 应用启动
  • main.jsx 调用 ReactDom.createRoot(document.getElementById('root'))
  • 创建根容器,返回包含 render 方法的对象
  1. 开始渲染
  • 调用 root.render(App)
  • 触发 React.render(node, container)
  1. 设置根 Fiber
  • 创建根 Fiber 对象,设置 dom 为容器节点
  • props.children 包含要渲染的 App 组件
  • 设置 nextWorkOfUnit 为根 Fiber
  • 保存 root 引用用于后续提交

阶段二:调度器启动

  1. 工作循环初始化
  • window.requestIdleCallback(workLoop) 启动调度
  • workLoop 检查浏览器空闲时间
  1. 时间切片处理
  • 在 deadline.timeRemaining() > 1ms 时持续处理
  • 每次循环调用 performUnitOfWork(nextWorkOfUnit)
  • 处理完成后更新 nextWorkOfUnit 为返回的下一个 Fiber

阶段三:Render 阶段(Fiber 树构建)

3.1 处理单个 Fiber 单元

  1. Fiber 类型判断
  • 检查是否为函数组件(typeof fiber.type === 'function')
  • 函数组件:执行函数获取返回的 VNode,更新 fiber.props.children
  • 普通组件:创建 DOM 节点并设置属性
  1. DOM 节点创建
  • 调用 createDom(fiber) 创建对应节点
  • TEXT_ELEMENT 创建 TextNode,其他创建 Element
  • 调用 updateProperties 设置 DOM 属性
  1. 子节点规范化
  • 调用 normalizeChildren(fiber.props.children)
  • 处理各种 children 输入情况
  • 扁平化数组,转换文本节点为 TEXT_ELEMENT
  • 过滤 null/undefined 节点
  1. 构建子 Fiber 链表
  • 遍历规范化后的 children 数组
  • 为每个子节点创建对应的 Fiber
  • 建立 parent-child-sibling 链接关系
  • 第一个子节点:fiber.child = newFiber
  • 后续子节点:prevSibling.sibling = newFiber

3.2 深度优先遍历

  1. 返回下一个工作单元
  • 优先返回子节点:if (fiber.child) return fiber.child
  • 其次返回兄弟节点:if (fiber.sibling) return fiber.sibling
  • 向上回溯查找父级的兄弟节点
  • 遍历完成返回 null

阶段四:Commit 阶段(DOM 挂载)

4.1 提交准备

  • 当 nextWorkOfUnit 为 null 且 root 存在时
  • 调用 commitRoot() 进入提交阶段

4.2 递归挂载 DOM

  1. commitWork 执行
  • 检查当前 Fiber 是否有 DOM 节点
  • 查找最近的有 DOM 的父节点(跳过函数组件)
  • 执行 parentFiber.dom.appendChild(fiber.dom)
  1. 深度优先挂载
  • 先递归挂载子节点:commitWork(fiber.child)
  • 再递归挂载兄弟节点:commitWork(fiber.sibling)

4.3 完成渲染

  • 所有 DOM 节点挂载完成
  • 清空 root 引用
  • 渲染流程结束

四、关键数据结构

Fiber 节点结构:

javascript

{
  type: 'div' | 'TEXT_ELEMENT' | Function,
  props: { children: [], ...attributes },
  dom: HTMLElement | Text,
  parent: Fiber,
  child: Fiber,
  sibling: Fiber
}

Plain Text

虚拟 DOM 结构:

javascript

// 元素节点
{ type: 'div', props: { id: 'app', children: [...] } }


// 文本节点  
{ type: 'TEXT_ELEMENT', props: { nodeValue: 'text', children: [] } }

Plain Text

五、总结

1. 时间切片调度

  • 使用 requestIdleCallback 避免阻塞主线程
  • 将渲染任务拆分为多个工作单元
  • 在浏览器空闲时段执行

2. 阶段分离设计

  • Render 阶段:纯计算,构建 Fiber 树,不操作 DOM
  • Commit 阶段:批量 DOM 操作,避免频繁重排重绘

3. 链表数据结构

  • 使用 child、sibling 指针构建树形结构
  • 支持高效的深度优先遍历
  • 便于暂停和恢复渲染任务

4. 统一节点处理

  • 所有节点(包括文本)都转换为统一结构
  • 简化后续处理逻辑
  • 支持函数组件和普通组件

5. 模块化设计

  • 每个模块职责单一明确
  • 工具函数独立,便于测试和维护
  • 清晰的依赖关系和数据流向

六、扩展性考虑

当前架构为后续功能扩展提供了良好基础,后续需要做这些:

  • Diff 算法:可在 performUnitOfWork 中实现节点比较
  • 事件系统:在 updateProperties 中添加事件处理
  • Hooks 支持:在函数组件处理时维护状态
  • 错误边界:添加异常捕获和处理机制React Fiber 渲染器核心逻辑详解

使用 Vite + Vue 3 搭建项目并配置路由的全流程(含国内镜像加速)

作者 AAA阿giao
2025年11月23日 00:08

适用于 Windows / macOS / Linux 用户 | 包含 NVM 安装、Node 环境配置、Vite 项目创建、Vue Router 集成

在现代前端开发中,Vite 凭借其极速的冷启动和热更新能力,已成为构建 Vue、React 等应用的首选工具。本文将手把手带你从零开始,使用国内镜像加速,通过 NVM 管理 Node.js 版本,创建一个基于 Vite + Vue 3 的项目,并集成 Vue Router 实现前端路由功能。


第一步:安装 NVM(Node Version Manager)

NVM 是一个用于管理多个 Node.js 版本的工具,特别适合需要在不同项目中切换 Node 版本的开发者。

Windows 用户

推荐使用 nvm-windows

  1. 卸载已安装的 Node.js(如有)

  2. 下载安装包:github.com/coreybutler…

  3. 安装时注意:

    • 安装路径不要包含空格(如 C:\nvm
    • 自动配置环境变量(勾选相关选项)

macOS / Linux 用户

使用 curl 安装:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

然后重启终端或执行:

source ~/.bashrc  # 或 ~/.zshrc

第二步:配置国内镜像源(加速下载)

由于网络原因,从官方源下载 Node.js 和 npm 包可能非常慢。我们可以通过设置镜像地址解决。

设置 NVM 的 Node 和 npm 镜像(Windows)

在命令行中执行以下命令(需以管理员身份运行 PowerShell 或 CMD):

nvm npm_mirror https://npmmirror.com/mirrors/npm/
nvm node_mirror https://npmmirror.com/mirrors/node/

这两行命令会修改 NVM 的默认下载源为 淘宝 NPM 镜像(npmmirror.com) ,大幅提升下载速度。

注意:npmmirror.com 是原 npm.taobao.org 的新域名,由阿里云维护。


第三步:安装并使用指定版本的 Node.js

1. 查看可用的 Node.js 版本

nvm list available  # Windows
nvm ls-remote       # macOS / Linux

2. 安装推荐版本(如 LTS 版本 v18.18.2)

nvm install 18.18.2

推荐使用 LTS(长期支持)版本,稳定且兼容性好。

3. 切换并使用该版本

nvm use 18.18.2

验证是否生效:

node -v  # 应输出 v18.18.2
npm -v   # 显示对应 npm 版本

第四步:创建 Node.js 全局目录(可选但推荐)

为避免权限问题和路径混乱,建议手动创建全局模块和缓存目录。

Windows 示例(以 C 盘为例)

mkdir C:\nodejs\node_global
mkdir C:\nodejs\node_cache

然后配置 npm:

npm config set prefix "C:\nodejs\node_global"
npm config set cache "C:\nodejs\node_cache"

将全局目录加入系统 PATH(Windows)

  • 打开“系统属性” → “环境变量”
  • 用户变量Path 中添加:C:\nodejs\node_global

这样你就可以全局使用 vuevite 等命令了。


第五步:配置 .npmrc 文件(提升后续安装速度)

在用户主目录下(Windows 通常是 C:\Users<你的用户名>)创建或编辑 .npmrc 文件

.npmrc 文件在文章顶部下载

✅ 此配置确保所有依赖(包括 sass、puppeteer 等)都从国内镜像下载。

💡 将此文件复制到 C:\Users<你的用户名>.npmrc(Windows)或 ~/.npmrc(macOS/Linux)。


第六步:使用 Vite 创建 Vue 3 项目

现在一切准备就绪,开始创建项目!

执行创建命令

npm create vite@latest my-app -- --template vue

参数说明:

  • my-app:项目文件夹名称
  • --template vue:指定使用 Vue 模板(纯 Vue 3,无 TypeScript、Router 等)

进入项目并安装依赖

cd my-app
npm install

此时会从 registry.npmmirror.com 安装依赖,速度飞快!


第七步:启动开发服务器

npm run dev

你会看到类似输出:

  VITE v5.0.0  ready in 400 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: http://192.168.x.x:5173/

打开浏览器访问 http://localhost:5173,即可看到 Vue 的欢迎页面。


第八步:集成 Vue Router(实现页面路由)

Vite 默认模板不包含路由,我们需要手动安装并配置。

1. 安装 Vue Router

npm install vue-router@4

Vue 3 必须使用 vue-router 4.x

2. 创建路由配置文件

src 目录下新建 router/index.js

// 管理路由的文件 
// 导入组件
import Login from '../views/user/login.vue'  // 登录组件


// 导入路由中的方法
import { createRouter, createWebHashHistory } from 'vue-router'
// 配置组件和访问路径
const routes = [
    { path: '/login', component: Login }, // /login是登录组件的访问路径,component表示组件的名称

]
// 创建路由对象
const router = createRouter({
    // 路由的数据
    routes,
    history: createWebHashHistory(), // 路由模式,createWebHashHistory表示使用哈希模式
})
// 导出路由实例
export default router

为什么用 createWebHashHistory()

  • 它使用 URL 的 # 部分(如 http://localhost:5173/#/about
  • 不需要服务器配置,适合静态部署(如 GitHub Pages、Vercel、Netlify)

3. 在 main.js 中挂载路由

修改 src/main.js

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
// 导入路由对象
import router from './router'
// 创建项目对象
const app = createApp(App)
// 挂载路由对象
app.use(router)
// 挂载组件
app.mount('#app')

4. 在 App.vue 中添加导航和路由出口

修改 src/App.vue

<script setup>

</script>

<template>
  <RouterView></RouterView>
</template>

<style scoped>

</style>

5. 重新运行项目

npm run dev

现在点击“首页”和“关于”,页面内容会动态切换,说明路由已生效!


第九步:构建与部署(可选)

开发完成后,构建生产版本:

npm run build

生成的 dist/ 文件夹可直接部署到任何静态服务器(如 Nginx、GitHub Pages、Vercel 等)。

预览构建结果:

npm run preview

总结:完整流程回顾

步骤 操作
1 安装 NVM(多版本 Node 管理)
2 配置国内镜像:nvm node_mirrornvm npm_mirror
3 nvm install 18.18.2 + nvm use 18.18.2
4 创建全局目录并配置 PATH
5 复制 .npmrc 到用户目录,启用全量镜像
6 npm create vite@latest my-app -- --template vue
7 npm install + npm run dev
8 安装 vue-router@4,配置 createWebHashHistory()
9 修改 main.jsApp.vue,启用路由

小贴士

  • 如果你希望使用 Vue 官方脚手架(带 Router/Pinia) ,也可以运行:

    npm create vue@latest
    

    它会交互式让你选择是否集成 Router、Pinia、ESLint 等。

  • 开发中遇到依赖安装慢?始终检查 .npmrc 是否生效:npm config get registry

  • Vite 支持 TypeScript、JSX、CSS 预处理器等,按需扩展即可。


至此,你已经成功搭建了一个高性能、可路由、国内加速优化的 Vue 3 项目!接下来就可以自由开发你的应用了。

JavaScript 原型链解密:原来 proto 和 prototype 这么好懂

2025年11月22日 23:13

一文搞懂 JavaScript 原型:从「蒙圈」到「通透」

作为前端开发者,JavaScript 的原型机制可能是你入门时的第一个「小坎」。明明看着简单,可一到实际应用就容易晕头转向 —— 原型原型链prototype__proto__ 这些概念总像绕口令一样让人迷糊。今天咱们就用最接地气的方式,把原型这点事儿彻底说清楚。

先从一个「反常识」的问题说起

你有没有想过:为什么数组能直接调用 push() 方法?

const arr = [];

arr.push(1); // 这玩意儿从哪来的?

我们明明没给数组 arr 定义 push 方法,它却能直接用。这就像你去朋友家串门,没提前打招呼却能直接推门而入 —— 因为朋友家的「钥匙」早就放在了一个大家都知道的地方。在 JavaScript 里,这个「放钥匙的地方」就是 原型

原型是什么?先认识 prototype(显示原型)

函数天生带「仓库」

在 JavaScript 里,所有函数天生就有一个 prototype 属性,它就像一个「公共仓库」。这个仓库里的东西(属性或方法),能被函数创建的所有实例共享。

举个例子,我们定义一个 Car 构造函数:

// 构造函数

function Car(color) {

 this.color = color; // 每个车的颜色可能不同,放在实例里

}

// 给构造函数的 prototype 加东西(公共属性)

Car.prototype.name = 'su7-Ultra';

Car.prototype.length = 4800; // 假设所有车长度相同

Car.prototype.run = function() {

 console.log('车在跑~');

};

这里的 Car.prototype 就是「公共仓库」。当我们用 new 创建实例时:

const car1 = new Car('pink');

const car2 = new Car('blue');

car1car2 都会「共享」prototype 里的 namelengthrun 方法。你可以直接访问它们:

console.log(car1.name); // 'su7-Ultra'(来自原型)

console.log(car2.run()); // '车在跑~'(来自原型)

ScreenShot_2025-11-22_223220_862.png

为什么要这么设计?

如果把 namerun 这些公共属性直接写在构造函数里,每次创建实例都会重复创建一遍,就像每个小区都自己造一套共享健身器材,纯属浪费内存。原型就像小区的「公共健身区」,大家共用一套,既省空间又好维护。

对象的「隐形连接」:__proto__(隐式原型)

每个对象(包括函数创建的实例)都有一个 __proto__ 属性,它指向创建这个对象的构造函数的 prototype。简单说就是:

实例对象.__proto__ === 构造函数.prototype

还是用上面的 Car 举例:

console.log(car1.__proto__ === Car.prototype); // true

这就像每个小区居民都知道「公共健身区」在哪 ——__proto__ 就是实例对象找到「公共仓库」的地图。

function Car() {
  this.name = 'su7'
}
const car = new Car()// {name: 'su7'}
console.log(car.constructor); // 输出 Car函数,从原型上继承来的
// 让每一个实例对象都能知道自己是由谁创建出来的

ScreenShot_2025-11-22_230441_641.png

new 关键字到底干了啥?

当你用 new 创建实例时,JavaScript 悄悄做了 5 件事(堪称「幕后黑手」):

  1. 创建空对象const obj = {}(先搭个空架子)

  2. 绑定 this:让构造函数里的 this 指向这个空对象(Car.call(obj)

  3. 执行构造函数:给空对象加属性(比如 obj.color = color

  4. 连接原型obj.__proto__ = Car.prototype 将这个空对象的隐式原型(proto) 赋值成 构造函数的显示原型(prototype)(给空对象一张「地图」)

  5. 返回对象:把这个打扮好的对象 return 出去

所以 const car1 = new Car('pink') 其实是 JavaScript 帮你完成了一系列操作,最终得到一个带属性、连原型的对象。

Car.prototype.run = function() {
  console.log('running');
}

function Car() {   // new Function()
  // const obj = {}  //// 步骤1:创建空对象
  // Car.call(obj)  // 步骤2   call 方法将 Car 函数中的 this = obj
  this.name = 'su7'  // 步骤3
  // obj.__proto__ = Car.prototype  // 步骤4:连接原型
  // return obj  //步骤5:返回对象
}
const car = new Car() // {name: 'su7'}.__proto__  == Car.prototype

car.run()

ScreenShot_2025-11-22_230945_260.png

实例与原型的爱恨情仇

实例可以访问原型上的属性,但想修改原型上的属性可没那么容易:

Person.prototype.say = function() {
  console.log('想吃漂亮饭');
}

function Person() {
  this.name = '小橘'
}
const p = new Person()// p 里面显示拥有一个属性 name 隐式拥有一个属性 say
p.say = 'hello' // 这只是给p加了个say属性,不是修改原型上的

console.log(p); // {name: '小橘', say: 'hello'}

ScreenShot_2025-11-22_225901_629.png

这就像你可以用公司的打印机(原型上的方法),但你自己买了台新打印机(实例上的属性),并不会影响公司那台。

原型链:对象的「向上查找」神功

v8 在访问对象中的属性时,会先访问该对象中的显示属性,如果找不到,就去对象的隐式原型__proto__ 上找,如果还找不到,就去__proto__.proto 上找,层层往上,直到找到null为止。这种查找关系被称为 原型链.

举个生活例子:

你想吃西瓜(访问 xigua 属性):

  • 先翻自己的冰箱(对象本身),没有;

  • 去客厅的公共冰箱(__proto__ 指向的原型),还没有;

  • 去小区便利店(__proto__.__proto__),找到了!

代码演示:

Grand.prototype.house = function() {
  console.log('四合院');
}
function Grand() {
  this.card = 10000
}
Parent.prototype = new Grand()  // {card: 10000}.__proto__ =Grand.prototype= Grand.prototype.__proto__ = Object.prototype.__proto__ = null
function Parent() {
  this.lastName = '张'
}
Child.prototype = new Parent()  // {lastName: '张'}.__proto__ = Parent.prototype
function Child() {
  this.age = 18
}
const c = new Child()  // {age: 18}.__proto__ = Child.prototype
console.log(c.card);
c.house()
console.log(c.toString());

ScreenShot_2025-11-22_224922_990.png 现在访问 child.house()

  • child 自身没有 house 方法;

  • child.__proto__(爸爸的实例),没有;

  • child.__proto__.__proto__(爷爷的实例),没有;

  • child.__proto__.__proto__.__proto__(爷爷的 prototype),找到了!执行 house() 方法。

这就是原型链的查找过程,是不是像「刨根问底」一样?

实战技巧:给内置对象加方法

JavaScript 的内置对象(比如 ArrayObject)也有原型。我们可以给它们的原型加方法,让所有实例都能用。

比如给数组加一个 abc 方法:

// 给 Array 的原型加方法

Array.prototype.abc = function() {

 return '我是数组的新方法~';

};

// 所有数组都能用

const arr = [];

console.log(arr.abc()); // 输出'我是数组的新方法~'

不过要注意:别随便修改内置对象的原型,可能会和其他代码冲突(比如别人也加了同名方法)。

总结:原型家族关系图

最后用一张「关系图」帮你理清:

构造函数(Car)

 ↓ 拥有

prototype(仓库)

 ↓ 被指向

实例对象(car1)的 __proto__

 ↓ 向上找

prototype 的 __proto__(比如 Object.prototype)

 ↓ 直到

null(原型链的终点)

28031b9733ee49618e82928f8dc762be~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.jpg

记住这几点,原型就难不倒你了:

  1. 函数有 prototype(公共仓库);

  2. 对象有 __proto__(指向构造函数的 prototype);

  3. new 会帮你绑定原型关系;

  4. 属性查找顺着原型链往上走。

原型机制是 JavaScript 的核心,理解它能让你在写代码时更得心应手。下次再遇到原型相关的问题,不妨回头看看这篇文章,说不定会有新收获~

【前端三剑客-6/Lesson11(2025-10-28)构建现代响应式网页:从 HTML 到 CSS 弹性布局再到 JavaScript 交互的完整指南 🌈

作者 Jing_Rainbow
2025年11月22日 22:51

🌈在现代前端开发中,构建一个美观、交互性强且适配多端设备的网页,离不开 HTMLCSS(或其预处理器)JavaScript 的紧密协作。本文将全面深入地剖析如何使用这些技术打造一个具有动态面板效果的响应式网页,并详细补充相关知识体系,涵盖 语义结构、弹性布局(Flexbox)、CSS 过渡动画、媒体查询、Stylus 预处理器原理、DOM 操作与事件监听 等核心内容。


📄 一、HTML 结构:语义化页面骨架

首先看 index.html 文件的内容:

DocumentExplore The WorldWild ForestSunny BeachCity on WinterMountains - Clouds

虽然这个 HTML 片段缺少完整的 <html><head><body> 标签,但从上下文可以推断出其真实结构应类似如下(这是现代 Web 开发的标准写法):

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Explore The World</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="container">
    <div class="panel"><h3>Wild Forest</h3></div>
    <div class="panel"><h3>Sunny Beach</h3></div>
    <div class="panel"><h3>City on Winter</h3></div>
    <div class="panel"><h3>Mountains - Clouds</h3></div>
  </div>
  <script src="common.js"></script>
</body>
</html>

✅ 关键点解析:

  • 语义清晰:每个 .panel 代表一个可点击的“探索区域”,标题用 <h3> 表示层级。
  • 模块化设计:所有面板包裹在 .container 中,便于统一布局控制。
  • 外部资源引入:通过 <link> 引入 CSS,通过 <script> 引入 JS,实现关注点分离(Separation of Concerns)。

🎨 二、CSS 样式:弹性布局 + 动画 + 响应式

style.css 是整个视觉表现的核心。我们逐行解读并扩展其背后的原理。

🔧 全局重置与基础布局

* { margin: 0; padding: 0; }
  • 通配符选择器:清除所有元素默认内外边距,避免浏览器样式差异(但生产环境建议使用更精细的 reset 或 normalize.css)。
body {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: center;
  height: 100vh;
  overflow: hidden;
}
  • display: flex:开启弹性盒模型(Flexbox),使子元素按弹性规则排列。
  • justify-content: center:主轴(水平方向)居中对齐。
  • align-items: center:交叉轴(垂直方向)居中对齐。
  • height: 100vh:视口高度 100%,确保 body 占满屏幕。
  • overflow: hidden:隐藏滚动条,防止内容溢出导致滚动。

💡 Flexbox 小课堂

  • 主轴(main axis):由 flex-direction 决定,默认为 row(水平)。
  • 交叉轴(cross axis):垂直于主轴。
  • 子元素称为 flex items,父容器称为 flex container

🖼️ 面板容器与面板项

.container {
  display: flex;
  width: 90vw;
}
  • 容器本身也是 flex 容器,内部 .panel 并排显示。
  • 90vw:宽度为视口宽度的 90%,留白美观。
.container .panel {
  height: 80vh;
  border-radius: 50px;
  color: #fff;
  cursor: pointer;
  flex: 0.5;
  margin: 10px;
  position: relative;
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  transition: all 700ms ease-in;
}

🔍 属性详解:

  • flex: 0.5:等价于 flex: 0 1 0.5,表示不放大(flex-grow=0)、可收缩(flex-shrink=1)、基准尺寸为 0.5 份。多个 panel 会按比例分配空间。
  • background-*:设置背景图全屏覆盖、居中、不重复,常用于 hero image。
  • transition: all 700ms ease-in
    • all:监听所有可过渡属性的变化(如 width、flex、opacity)。
    • 700ms:动画持续时间。
    • ease-in:缓动函数,开始慢,结束快。
    • ⚠️ 注意:transition 只对 已有属性值变化 起作用,不能用于 display: none ↔ block
.container .panel h3 {
  font-size: 24px;
  position: absolute;
  left: 20px;
  bottom: 20px;
  margin: 0;
  opacity: 0;
  transition: opacity 300ms ease-in 400ms;
}
  • 文字绝对定位在左下角。
  • 初始 opacity: 0(完全透明)。
  • transition-delay: 400ms:点击后延迟 0.4 秒才开始淡入,制造“先展开再显字”的层次感。

🌟 激活状态:动态放大与文字显现

.container .panel.active {
  flex: 5;
}
.container .panel.active h3 {
  opacity: 1;
}
  • 当某个 panel 添加 .active 类时:
    • flex: 5 → 占据绝大部分容器宽度(因为其他是 0.5,总和约 2,5 远大于它们)。
    • opacity: 1 → 标题淡入可见。

🎯 设计意图:用户点击某张风景图,该图放大突出,同时显示标题,形成焦点引导。

📱 响应式适配:移动端优化

@media (max-width: 480px) {
  .container { width: 100vw; }
  .panel:nth-of-type(4),
  .panel:nth-of-type(5) { display: none; }
}
  • 媒体查询(Media Query):当屏幕宽度 ≤ 480px(典型手机尺寸)时生效。
  • 容器占满全宽。
  • 隐藏第 4、5 个面板(但你的 HTML 只有 4 个,可能是预留扩展?)。此举减少信息密度,提升小屏体验。

📏 常见断点参考

  • 手机:<576px
  • 平板:≥576px
  • 桌面:≥992px

⚙️ 三、JavaScript 交互:DOM 操作与事件驱动

common.js 实现了点击切换激活面板的功能:

const panels = document.querySelectorAll('.panel');

panels.forEach(function(panel) {
  panel.addEventListener('click', function() {
    const cur = document.querySelector('.active');
    if (cur) {
      cur.classList.remove('active');
    }
    panel.classList.add('active');
  });
});

🧠 代码逻辑拆解:

  1. 获取所有面板querySelectorAll 返回 NodeList(类数组对象)。
  2. 遍历绑定点击事件:每个 panel 监听 click
  3. 移除旧激活项:查找当前 .active 元素并移除类。
  4. 激活当前项:给被点击的 panel 添加 .active

🛠️ 技术要点:

  • 事件委托 vs 直接绑定:此处直接绑定,因面板数量固定且少。若动态增删,建议用事件委托(监听 container)。
  • classList API:现代操作 class 的标准方式,比 className 更安全。
  • 排他性设计:同一时间仅一个 panel 激活,符合 UX 原则。

💡 进阶思考

  • 可添加键盘导航(Arrow Keys)。
  • 支持自动轮播(setInterval + 模拟点击)。
  • 使用 requestAnimationFrame 优化性能。

🧪 四、Stylus 预处理器:提升 CSS 开发效率(来自 readme.md)

readme.md 介绍了 Stylus —— 一种强大的 CSS 预处理器。

🌱 什么是 CSS 预处理器?

浏览器只能解析标准 CSS。预处理器(如 Stylus、Sass、Less)允许你用更简洁、可编程的语法编写样式,再编译成浏览器能识别的 CSS。

📦 Stylus 核心特性(对比 CSS):

特性 CSS 写法 Stylus 写法
嵌套 重复写选择器 直接嵌套,结构清晰
变量 不支持(需 CSS 自定义属性) primary-color = #333
混合(Mixins) 可复用代码块
函数 有限 自定义逻辑
省略符号 必须写 {} : ; 可省略,靠缩进

例如,你的 CSS 中这段:

.container .panel.active h3 { opacity: 1; }

在 Stylus 中可写作:

.container
  .panel
    &.active
      h3
        opacity: 1

🔧 编译流程(readme.md 提示):

# 全局安装 Stylus
npm i -g stylus

# 编译单次
stylus style.styl -o style.css

# 监听文件变化自动编译(开发推荐)
stylus style.styl -o style.css -w

优势总结

  • 减少重复代码
  • 提高可维护性
  • 支持逻辑运算(如颜色变暗 darken(color, 10%)
  • 自动生成厂商前缀(autoprefixer 配合)

🧩 五、整合与最佳实践

🔄 开发工作流建议:

  1. Stylus 编写 style.styl
  2. 启动监听:stylus style.styl -o style.css -w
  3. 编写 HTML 结构
  4. 用 JS 实现交互逻辑
  5. 在不同设备测试响应式效果

🧪 性能与兼容性:

  • Flexbox 兼容性:IE10+ 支持(需 -ms- 前缀),现代浏览器完美支持。
  • Transition 兼容性:IE10+,注意 Safari 早期版本需 -webkit-
  • JS 降级:若 JS 禁用,至少保证基础样式可用(渐进增强)。

🎨 设计扩展建议:

  • 为每个 panel 设置不同 background-image(通过内联样式或额外 class)
  • 添加 hover 效果(桌面端)
  • 使用 CSS :focus 提升无障碍访问(a11y)

🏁 结语:现代前端开发的缩影

你提供的这组文件,虽小却五脏俱全,完美体现了 现代 Web 开发的核心范式

  • 结构(HTML):清晰、语义化
  • 样式(CSS/Stylus):响应式、动画化、模块化
  • 行为(JavaScript):交互驱动、DOM 操作
  • 工具链(Stylus):提升效率、工程化思维

通过深入理解每一行代码背后的原理,你不仅能复现这个“探索世界”面板,更能举一反三,构建更复杂的交互式应用。🚀

🌍 Keep exploring. Keep coding.
—— 你的下一个项目,或许就从这里启航!✨

使用 LocalStorage 实现本地待办事项(To-Do)列表

作者 ohyeah
2025年11月22日 22:29

在现代 Web 开发中,前端开发者常常需要在浏览器中持久化存储一些用户数据。比如用户的偏好设置、表单草稿、或者像本文要实现的——一个简单的待办事项(To-Do List)应用。HTML5 提供了一个非常实用的 API:localStorage,它允许我们在用户的浏览器中以键值对的形式永久保存数据。

本文将通过一个完整的示例,带你了解如何使用 localStorage 实现一个可持久化存储的 To-Do 列表,并结合 JavaScript 的函数式编程思想和事件委托机制,写出结构清晰、易于维护的代码。


什么是 localStorage?

localStorage 是 Web Storage API 的一部分,用于在浏览器中长期存储数据。它的特点包括:

  • 持久性:除非用户手动清除,否则数据不会过期。
  • 键值对存储:只能存储字符串类型的数据(key 和 value 都是字符串)。
  • 同源策略:只有同协议、同域名、同端口的页面才能共享同一个 localStorage

由于 localStorage 只能存字符串,因此我们通常会配合 JSON.stringify()JSON.parse() 来存储和读取对象或数组。


项目结构概览

我们的目标是实现一个简单的“Tapas”(小食)清单,用户可以:

  • 添加新的 Tapas 项;
  • 勾选已完成的项;
  • 页面刷新后数据依然保留。

HTML 结构如下:

<div class="wrapper">
  <h2>LOCAL TAPAS</h2>
  <ul class="plates">
    <li>Loading Tapas...</li>
  </ul>
  <form class="add-items">
    <input type="text" placeholder="Item Name" required name="item">
    <input type="submit" value="+ Add Item">
  </form>
</div>

核心逻辑集中在 <script> 标签中,下面我们逐步拆解。


初始化数据与渲染列表

首先,我们从 localStorage 中读取已有的待办事项:

const items = JSON.parse(localStorage.getItem('todos')) || [];

如果 localStorage 中没有 'todos' 这个 key,就返回一个空数组作为默认值。

接着,我们封装一个 populateList 函数,用于将数据渲染到页面上:

function populateList(plates = [], platesList) {
  platesList.innerHTML = plates.map((plate, i) => {
    return `
      <li>
        <input type="checkbox" data-index=${i} id="item${i}" ${plate.done ? 'checked' : ''} />
        <label for="item${i}">${plate.text}</label>
      </li>
    `;
  }).join('');
}

这个函数接收两个参数:

  • plates:待办事项数组;
  • platesList:要渲染到的 DOM 元素(即 <ul class="plates">)。

通过 map 方法生成每个列表项的 HTML 字符串,并用 join('') 拼接成完整内容,最后赋值给 innerHTML。这种方式避免了频繁操作 DOM,性能更优。

调用一次即可完成初始渲染:

populateList(items, itemsList);

添加新事项

当用户在表单中输入内容并点击“+ Add Item”时,会触发 submit 事件。我们通过事件监听器处理:

addItems.addEventListener('submit', addItem);

addItem 函数的核心逻辑如下:

function addItem(e) {
  e.preventDefault(); // 阻止表单默认提交(页面跳转)
  const text = this.querySelector('[name=item]').value.trim();
  //通过属性选择器 拿到表单中name为item的input中的值 并去除左右空格 这里其实也运用到包装类的概念
  //因为拿到的value是字符串 简单数据类型 但是可以调用方法.trim()
  const item = {
    text,
    done: false
    //通过done来控制勾选框的选中 一开始设置为false
  };
  
  items.push(item);//把输入的item添加到items
  localStorage.setItem('todos', JSON.stringify(items)); // 持久化存储
  populateList(items, itemsList); // 重新渲染
  this.reset(); // 清空表单内容 优化用户体验
}

这里有几个关键点:

  • 使用 e.preventDefault() 防止页面刷新;
  • 通过 this(指向表单元素)获取输入框的值;
  • 构造新对象 { text, done: false } 并加入数组;
  • 调用 localStorage.setItem() 将整个数组转为字符串后保存;
  • 重新渲染列表并清空表单。

勾选/取消勾选事项(事件委托)

如果为每个复选框单独绑定事件,效率低下且难以维护。因此我们采用事件委托:将点击事件绑定在父容器 <ul class="plates"> 上,通过判断点击目标是否为 input 来决定是否处理。

itemsList.addEventListener('click', toggleDone);

function toggleDone(e) {
  const el = e.target;
  //指定  当点击的为input时 才进行下面操作
  if (el === 'INPUT') {
    const index = el.dataset.index;
    items[index].done = !items[index].done;
    //通过done来控制勾选 点击取反
    localStorage.setItem('todos', JSON.stringify(items));
    //点击后修改了item中的done值 再次进行本地存储
    populateList(items, itemsList);//再次渲染
  }
}

每次切换状态后,我们同样更新 localStorage 并重新渲染整个列表,确保视图与数据一致。


为什么使用函数式封装?

文中提到:“拒绝流程式代码,超过10行就封装函数”。这其实是一种良好的编程习惯:

  • 提高可读性populateListaddItem 等函数名清晰表达了意图;
  • 便于复用:渲染逻辑被抽离,可在多处调用;
  • 降低耦合:数据操作与 DOM 操作分离,逻辑更清晰;
  • 易于测试与调试:每个函数职责单一。

此外,默认参数(如 plates = [])和解构赋值(如 { text, done })也体现了 ES6 的简洁语法优势。


总结

通过这个小小的“Local Tapas”项目,我们不仅实现了基本的增删改查(CRUD)功能,更重要的是掌握了以下核心知识点:

  1. localStorage 的基本用法:存储字符串、配合 JSON 处理复杂数据;
  2. 事件委托:高效处理动态生成元素的交互;
  3. 函数式编程思想:封装细节、关注输入输出;
  4. DOM 操作优化:批量更新而非逐个操作。

虽然这个应用很简单,但它展示了现代前端开发中“数据驱动视图”的典型模式:数据变化 → 更新存储 → 重新渲染。这种模式也是 React、Vue 等框架的思想基础。

你可以在此基础上继续扩展,比如:

  • 添加删除按钮;
  • 支持编辑事项;
  • 按完成状态过滤;
  • 加入动画效果等。

但无论如何,记住:好的代码始于清晰的结构和合理的抽象。而 localStorage,正是你构建离线优先(Offline-First)应用的第一步。


本文代码已通过实际测试,可直接运行。欢迎在评论区交流你的改进想法!

掌握 JS 中迭代器的未来用法

作者 Yanni4Night
2025年11月22日 22:09

两个提案

在 ECMAScript 中有两个重要的迭代器相关提案,它们提供不同的功能:

  1. Iterator Helpers 提案为 Iterator.prototype 添加的原型方法(如 mapfilter 等),允许直接在迭代器实例上链式调用操作方法
  2. Iterator Sequencing:提供 Iterator 对象上的静态方法(如 Iterator.concat),用于创建和操作迭代序列

本文将重点介绍这两个提案的核心功能和用法。

1. Iterator Helpers(原型方法)

Iterator Helpers 提案为 Iterator.prototype 添加了一系列类似于数组的方法,让开发者可以直接在迭代器实例上链式调用操作方法。

1.1 为什么需要 Iterator Helpers?

在 JavaScript 中,迭代器是一个很强大的概念,它让我们能够遍历各种数据结构。但是,在 Iterator Helpers 提案之前,如果你想对迭代器进行一些常见操作(比如映射、过滤等),通常需要先将其转换为数组,这在处理大数据集时可能会造成内存浪费。

举个例子,以前你可能需要这样做:

// 创建一个迭代器
function* generateValues() {
  yield 1;
  yield 2;
  yield 3;
  yield 4;
  yield 5;
}

const iterator = generateValues();

// 想要过滤出偶数并乘以2,需要先转成数组
const result = Array.from(iterator)
  .filter(num => num % 2 === 0)
  .map(num => num * 2);

console.log(result); // [4, 8]

而有了 Iterator Helpers 原型方法,我们可以直接在迭代器上操作,避免中间数组的创建:

// 使用 Iterator Sequencing(提案实现)
const result = generateValues()
  .filter(num => num % 2 === 0)
  .map(num => num * 2);

// 迭代结果
for (const value of result) {
  console.log(value); // 输出 4, 8
}

1.2 Iterator Helpers 提供的主要原型方法

Iterator Helpers 提案为 Iterator.prototype 添加了以下核心方法:

1.2.1 map()

类似于数组的 map 方法,Iterator.prototype.map() 对迭代器中的每个值应用一个转换函数,并返回一个包含转换后值的新迭代器。

// 示例:将数字乘以2
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.values()
  .map(num => num * 2);

for (const num of doubled) {
  console.log(num); // 2, 4, 6, 8, 10
}

1.2.2 filter()

Iterator.prototype.filter() 方法创建一个新迭代器,包含所有通过指定测试函数的元素。

// 示例:筛选出偶数
const numbers = [1, 2, 3, 4, 5, 6];
const evenNumbers = numbers.values()
  .filter(num => num % 2 === 0);

for (const num of evenNumbers) {
  console.log(num); // 2, 4, 6
}

1.2.3 take()

Iterator.prototype.take() 方法创建一个新迭代器,最多包含原始迭代器的前 n 个元素。

// 示例:只取前3个元素
const numbers = [1, 2, 3, 4, 5];
const firstThree = numbers.values()
  .take(3);

for (const num of firstThree) {
  console.log(num); // 1, 2, 3
}

1.2.4 drop()

Iterator.prototype.drop() 方法创建一个新迭代器,跳过原始迭代器的前 n 个元素。

// 示例:跳过前2个元素
const numbers = [1, 2, 3, 4, 5];
const afterFirstTwo = numbers.values()
  .drop(2);

for (const num of afterFirstTwo) {
  console.log(num); // 3, 4, 5
}

1.2.5 flatMap()

Iterator.prototype.flatMap() 方法先对迭代器中的每个元素应用一个映射函数,然后将结果扁平化为一个新的迭代器。

// 示例:将每个数字扩展为包含该数字的数组
const numbers = [1, 2, 3];
const expanded = numbers.values()
  .flatMap(num => [num, num * 2]);

for (const num of expanded) {
  console.log(num); // 1, 2, 2, 4, 3, 6
}

1.2.6 some() 和 every()

Iterator.prototype.some()Iterator.prototype.every() 方法测试迭代器的元素是否满足指定的条件。

// 示例:检查是否有偶数
try {
  const numbers = [1, 3, 5, 7, 8];
  const hasEven = numbers.values()
    .some(num => num % 2 === 0);
  console.log(hasEven); // true
} catch (e) {
  console.log("some() 方法在当前环境中不可用");
}

// 示例:检查是否所有元素都是正数
try {
  const numbers = [1, 2, 3, 4, 5];
  const allPositive = numbers.values()
    .every(num => num > 0);
  console.log(allPositive); // true
} catch (e) {
  console.log("every() 方法在当前环境中不可用");
}

1.2.7 reduce()

Iterator.prototype.reduce() 方法对迭代器中的每个元素执行一个reducer函数,将其结果汇总为单个返回值。

// 示例:计算所有元素的总和
try {
  const numbers = [1, 2, 3, 4, 5];
  const sum = numbers.values()
    .reduce((acc, num) => acc + num, 0);
  console.log(sum); // 15
} catch (e) {
  console.log("reduce() 方法在当前环境中不可用");
}

1.3 实际应用场景示例

1.3.1 场景一:处理大型数据集

当处理大型数据集时,Iterator Helpers 可以帮助我们避免一次性将所有数据加载到内存中:

// 假设有一个生成大量数据的生成器函数
function* generateLargeDataset() {
  // 模拟生成大量数据
  for (let i = 0; i < 1000000; i++) {
    yield { id: i, value: Math.random() };
  }
}

// 使用 Iterator Helpers 处理数据,无需一次性加载全部到内存
const processedData = generateLargeDataset()
  .filter(item => item.value > 0.5)  // 只保留值大于0.5的项
  .map(item => ({ id: item.id, value: item.value * 100 }))  // 值乘以100
  .take(10);  // 只取前10个结果

// 逐一处理结果
for (const item of processedData) {
  console.log(item);
}

1.3.2 场景二:异步数据流处理

结合异步迭代器,Iterator Helpers 可以优雅地处理异步数据流:

// 注意:这里展示的是结合异步迭代器的用法,
// 实际的异步Iterator Helpers可能在其他提案中
async function processAsyncData() {
  // 假设这是一个异步数据源
  async function* fetchDataInChunks() {
    for (let i = 0; i < 10; i++) {
      // 模拟网络请求延迟
      await new Promise(resolve => setTimeout(resolve, 100));
      yield [i * 10, i * 10 + 1, i * 10 + 2];
    }
  }

  // 处理异步数据
  const asyncIterator = fetchDataInChunks();
  
  // 在支持异步Iterator Helpers的环境中
  // 可以这样链式处理:
  /*
  for await (const item of asyncIterator
    .flatMap(chunk => chunk)
    .filter(num => num % 2 === 0)
    .map(num => num * 2)) {
    console.log(item);
  }
  */
  
  // 当前环境的替代实现
  for await (const chunk of asyncIterator) {
    for (const num of chunk) {
      if (num % 2 === 0) {
        console.log(num * 2);
      }
    }
  }
}

// processAsyncData();

2. Iterator Sequencing(静态方法)

Iterator Sequencing 提案提供了 Iterator 对象上的静态方法,用于创建和操作迭代序列。

2.1 Iterator.concat(...items)

将多个可迭代对象连接成一个新的迭代器。

// 示例:连接多个数组的迭代器
const iter1 = [1, 2, 3].values();
const iter2 = [4, 5, 6].values();
const concatenated = Iterator.concat(iter1, iter2);

// 遍历连接后的迭代器
for (const item of concatenated) {
  console.log(item); // 输出: 1, 2, 3, 4, 5, 6
}

提案状态和兼容性

两个提案目前的状态如下:

  • Iterator Helpers 提案已经被纳入 ECMAScript 2025 标准,在支持 ES2025 的环境中可以直接使用
  • Iterator Sequencing 提案目前处于 Stage 3 阶段(截至 2025 年 11 月),有望在未来版本中标准化

要检查你的环境是否支持这些特性,可以使用以下代码:

// 检查 Iterator Helpers 支持
const supportsIteratorHelpers = typeof Symbol.iterator !== 'undefined' && 
                                typeof Iterator.prototype.map === 'function';

// 检查 Iterator Sequencing 支持
const supportsIteratorSequencing = typeof Iterator !== 'undefined' && 
                                    typeof Iterator.concat === 'function';

console.log('Iterator Helpers 支持:', supportsIteratorHelpers);
console.log('Iterator Sequencing 支持:', supportsIteratorSequencing);

在生产环境中使用时,对于尚未完全支持的环境,你可以考虑使用 polyfill 或转译工具来确保代码的兼容性。

另一种更详细的检测方法:

try {
  // 测试基本支持
  const test = [1].values().map(x => x);
  console.log("Iterator Helpers 基本支持");
  
  // 测试具体方法
  const methods = ['map', 'filter', 'take', 'drop', 'flatMap', 'some', 'every', 'reduce'];
  methods.forEach(method => {
    try {
      const result = [1].values()[method] ? true : false;
      console.log(`${method}: 支持`);
    } catch (e) {
      console.log(`${method}: 不支持`);
    }
  });
} catch (e) {
  console.log("Iterator Helpers 不支持");
}

如果你需要在不支持的环境中使用这些特性,可以考虑使用 polyfill 库,如 core-js。

总结

Iterator 相关的这两个提案为 JavaScript 带来了强大的迭代器操作能力:

  1. Iterator Helpers(原型方法):让开发者可以像操作数组一样直接在迭代器实例上链式调用方法,提高代码简洁性和内存效率
  2. Iterator Sequencing(静态方法):提供了创建和组合迭代序列的工具方法,增强了迭代器的创建和操作能力

这些提案共同丰富了 JavaScript 的迭代器生态,使得处理各种数据结构和数据流变得更加高效和便捷。

  1. 代码更简洁:直接在迭代器上链式调用方法,使代码更加清晰易读
  2. 内存效率更高:避免了不必要的数组转换,特别适合处理大型数据集
  3. 迭代器 API 标准化:提供了一套统一的 API 来操作各种迭代器
  4. 与函数式编程范式更契合:鼓励使用函数式编程风格处理数据

随着 Iterator Helpers 提案被纳入 ECMAScript 2025 标准,我们可以期待在未来的 JavaScript 版本中更广泛地使用这些功能,进一步提升我们的开发效率和代码质量。


更多 JavaScript 基础知识的学习,可以学习我写的这本 《JavaScript 语言编程进阶》 小册。

浅谈Tree Shaking

作者 WaterFly
2025年11月22日 21:50

Tree Shaking 基础知识

什么是 Tree Shaking?

Tree Shaking(摇树优化)是一种通过静态分析消除 JavaScript 中未使用代码(Dead Code)的优化技术。

形象比喻:

想象一棵树(代码库):
🌳 整棵树 = 完整的代码库
🍂 枯叶 = 未使用的代码
🤝 摇树 = 构建工具分析
✨ 结果 = 只保留需要的代码

工作原理

1. 静态分析(编译时)

Tree Shaking 依赖于 ES Module (ESM) 的静态结构特性:

// ✅ ESM - 支持 Tree Shaking(静态导入)
import { funcA } from './utils';  // 编译时确定

// ❌ CommonJS - 不支持 Tree Shaking(动态导入)
const { funcA } = require('./utils');  // 运行时确定

关键区别:

特性 ESM CommonJS
导入时机 编译时(静态) 运行时(动态)
结构分析 ✅ 可分析 ❌ 不可分析
Tree Shaking ✅ 支持 ❌ 不支持
语法 import/export require/module.exports

2. 依赖图分析

构建工具如何工作:

// utils.js
export function add(a, b) { return a + b; }      // 被使用 ✅
export function subtract(a, b) { return a - b; } // 未使用 ❌
export function multiply(a, b) { return a * b; } // 未使用 ❌

// main.js
import { add } from './utils';  // 只导入 add
console.log(add(1, 2));

分析过程:

1. 入口分析:main.js 导入了什么?
   → { add } from './utils'

2. 依赖追踪:add 函数依赖什么?
   → 无其他依赖

3. 标记清除:
   ✅ add       → 保留(被使用)
   ❌ subtract  → 删除(未使用)
   ❌ multiply  → 删除(未使用)

4. 最终产物:
   只包含 add 函数

3. 副作用(Side Effects)处理

什么是副作用?

副作用是指除了返回值之外,还会影响外部状态的操作

// ❌ 有副作用的代码
import './polyfill';           // 修改全局对象
import './styles.css';         // 注入样式
console.log('Module loaded');  // 控制台输出
window.myGlobal = 123;         // 修改 window

// ✅ 无副作用的代码(纯函数)
export function add(a, b) {
  return a + b;  // 只返回值,不影响外部
}

常见的副作用:

  • 修改全局变量或对象
  • 修改函数参数
  • 发起网络请求
  • DOM 操作
  • 原型链修改
  • 导入 CSS 文件
  • 执行 IIFE(立即执行函数)
// 修改全局变量或对象
window.myGlobalVar = 'value';
global.config = { theme: 'dark' };

// 修改函数参数
function addProperty(obj) {
  obj.newProp = 'value';
}

// 发起网络请求
fetch('/api/data');

// DOM 操作
document.body.classList.add('loaded');

// 原型链修改
Array.prototype.myMethod = function() {};

// 导入 CSS 文件
import './styles.css';

// 执行 IIFE(立即执行函数)
(function() {
  console.log('初始化');
})();

为什么要标记副作用?

// package.json
{
  "sideEffects": false  // 告诉打包工具:"所有代码都无副作用"
}

示例:

// utils.js
export function add(a, b) { return a + b; }

// polyfill.js
Array.prototype.includes = function() { ... };  // 有副作用!

// main.js
import { add } from './utils';  // 使用 utils
import './polyfill';            // 不使用,但有副作用

// ❌ 如果 sideEffects: false
// → polyfill.js 被删除(认为无副作用)
// → 导致运行时错误!

// ✅ 如果 sideEffects: ["**/polyfill.js"]
// → polyfill.js 被保留(标记为有副作用)
// → 运行正常

副作用代码与sideEffects配置的关系

  • 副作用代码,这是客观事实,也就是说一段代码是否是副作用代码,已经是确定的。

  • package.json sideEffects,文件级别配置,用来人为声明文件是否“可能有副作用”,从而决定未引用的文件能否被整体删除。

    • 默认为true,则编译器不敢假设文件无副作用,所以不会删除未引用的文件。属于保守策略。
    • 如果配置为false,则编译器会把所有文件都视为无副作用文件,只要文件没有被import,就可以被整个删除。属于激进策略
    • 如果配置为文件数组,则配置的文件会认为有副作用,即使文件没被引用也会被保留和打包;其他文件认为没有副作用代码,如果文件没被引用则编译会被移除不会被打包。比如在库中,一般CSS文件和polyfill文件就要被包含在内,比如:["*.css", "src/polyfill.js"]

Tree Shaking 的前提条件

✅ 必须满足的条件

  1. 使用 ESM 模块格式
// package.json
{
  "type": "module",
  "module": "dist/esm/index.js"
}
  1. 构建工具支持

    • Webpack 4+
    • Rollup(原生支持)
    • Vite(基于 Rollup)
    • esbuild
  2. 正确配置 sideEffects

{
  "sideEffects": false,           // 无副作用
  // 或
  "sideEffects": ["**/*.css"]     // 只有 CSS 有副作用
}
  1. 避免动态导入(在需要 Tree Shaking 的地方)
// ❌ 动态导入 - 无法 Tree Shaking
const moduleName = 'utils';
import(`./${moduleName}`);

// ✅ 静态导入 - 可以 Tree Shaking
import { func } from './utils';

常见阻碍 Tree Shaking 的情况

1. 使用 CommonJS

// ❌ 不支持 Tree Shaking
module.exports = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
};

// ✅ 支持 Tree Shaking
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

2. 默认导出整个对象

// ❌ 难以 Tree Shaking
export default {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
};

// 用户使用
import utils from './utils';
utils.add(1, 2);  // 整个对象都被导入

// ✅ 易于 Tree Shaking
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// 用户使用
import { add } from './utils';  // 只导入需要的

3. 副作用未正确标记

// styles.css
.button { color: red; }

// Component.js
import './styles.css';  // 副作用:注入样式
export function Component() { ... }

// package.json
{
  "sideEffects": false  // ❌ 错误!CSS 会被删除
}

// ✅ 正确配置
{
  "sideEffects": ["**/*.css"]  // CSS 不会被删除
}

4. 类和构造函数

// ❌ 类的方法可能无法 Tree Shaking
class Utils {
  add(a, b) { return a + b; }
  subtract(a, b) { return a - b; }
}

// 用户使用
import { Utils } from './utils';
const u = new Utils();
u.add(1, 2);  // 整个类都被导入

// ✅ 更好的方式(纯函数)
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

Tree Shaking 实战示例

示例 1:基础 Tree Shaking

库代码:

// math-lib/index.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }

用户代码:

// app.js
import { add } from 'math-lib';

console.log(add(1, 2));

打包结果:

// bundle.js(简化)
function add(a, b) { return a + b; }
console.log(add(1, 2));

// ✅ subtract、multiply、divide 被删除

示例 2:组件库 Tree Shaking

库代码:

// ui-lib/index.js
export { Button } from './Button';
export { Input } from './Input';
export { Select } from './Select';
export { Modal } from './Modal';

// ui-lib/Button.js
import './Button.css';  // 副作用
export function Button() { ... }

// package.json
{
  "sideEffects": ["**/*.css"]  // 保护 CSS
}

用户代码:

// app.js
import { Button } from 'ui-lib';

Button();

打包结果:

// bundle.js
import './Button.css';  // ✅ CSS 被保留(标记为副作用)
function Button() { ... }
Button();

// ✅ Input、Select、Modal 及其 CSS 都被删除

Tree Shaking 效果验证

方法 1:分析打包结果

# Webpack
npm run build -- --analyze

# Rollup
rollup -c --plugin visualizer

# Vite
vite build --mode production
# 查看 dist/stats.html

方法 2:手动检查

// 1. 构建生产版本
npm run build

// 2. 搜索未使用的代码
grep -r "unusedFunction" dist/

// 3. 如果找到 → Tree Shaking 失败
// 如果未找到 → Tree Shaking 成功

方法 3:对比体积

// 测试 1:引入所有
import * as All from 'my-lib';
// 打包体积:100 KB

// 测试 2:只引入一个
import { Button } from 'my-lib';
// 打包体积:20 KB

// ✅ Tree Shaking 有效:减少 80%

为库开发者的 Tree Shaking 检查清单

✅ 构建配置

  • 使用 ESM 输出格式(format: 'es'
  • 配置 preserveModules: true(保留模块结构)
  • 设置 package.jsonmodule 字段
  • 设置 package.jsonsideEffects 字段

✅ 代码规范

  • 使用 export 而非 export default {}
  • 避免在模块顶层执行副作用代码
  • 纯函数优于类(函数更容易 Tree Shaking)
  • 将 CSS 导入标记为副作用

✅ 文档说明

  • 在 README 中说明支持 Tree Shaking
  • 提供按需引入的示例
  • 标注哪些文件有副作用

常见误区

❌ 误区 1:"压缩 = Tree Shaking"

// 压缩(Minify):缩短变量名、删除空格
function add(a,b){return a+b}

// Tree Shaking:删除未使用的代码
// 如果 add 没被用,整个函数被删除

// 两者不同!

❌ 误区 2:"webpack 自动 Tree Shaking"

// ❌ 不会自动 Tree Shaking(CommonJS)
const lib = require('my-lib');

// ✅ 才能 Tree Shaking(ESM)
import { func } from 'my-lib';

❌ 误区 3:"设置 sideEffects: false 就够了"

// package.json
{ "sideEffects": false }

// ❌ 但代码中有副作用
import './polyfill';  // 会被删除!
import './styles.css'; // 会被删除!

// ✅ 正确配置
{ "sideEffects": ["**/polyfill.js", "**/*.css"] }

小结

Tree Shaking 核心要点:

  1. 基于 ESM 的静态分析
  2. 编译时 确定依赖关系
  3. 删除 未使用的代码
  4. 保留 有副作用的代码
  5. 需要 构建工具、模块格式、配置三者配合

记忆口诀:

ESM 是前提(import/export)
静态分析为基础(编译时确定)
标记副作用要清楚(sideEffects)
保留结构才有效(preserveModules)

附录

vue3 的专属二维码组件 vue3-next-qrcode 迎来 4.0.0 版本

作者 Account_Ray
2025年11月22日 21:40

vue3-next-qrcode:一个功能强大的 Vue 3 二维码组件库

前言

在现代 Web 应用开发中,二维码已经成为不可或缺的功能之一。无论是分享链接、支付场景还是身份验证,二维码都扮演着重要角色。今天给大家介绍一个功能强大的 Vue 3 二维码组件库 —— vue3-next-qrcode,它不仅支持基础的二维码生成,还提供了 LOGO、GIF 背景、SSR 等高级特性。

项目亮点

🎯 核心特性

  • 🏄🏼‍♂️ 基于 Vue 3 Composition API:完全拥抱 Vue 3 生态,易于使用和集成
  • 🎯 TypeScript 支持:使用 TypeScript 构建,提供完整的类型定义
  • 🚀 SSR 支持:完美支持 Nuxt 3 和 Nuxt 2 服务端渲染
  • 🎨 Composable API:提供 useQRCode Hook,灵活生成二维码
  • ♿ 无障碍支持:内置 ARIA 标签和键盘导航
  • 🎭 GIF 背景:支持动态 GIF 背景,并自动缓存优化性能
  • 📦 Tree-shaking:优化打包体积,按需加载
  • 🔄 自动刷新:文本变化时自动重新生成二维码
  • 💾 内置下载:一键下载生成的二维码

快速开始

安装

# npm
npm install vue3-next-qrcode

# yarn
yarn add vue3-next-qrcode

# pnpm
pnpm add vue3-next-qrcode

基础用法

<script setup lang="ts">
import { Vue3NextQrcode } from 'vue3-next-qrcode'
import 'vue3-next-qrcode/es/style.css'
</script>

<template>
  <Vue3NextQrcode text="https://github.com/XiaoDaiGua-Ray/vue3-next-qrcode" />
</template>

就这么简单!一个基础的二维码就生成了。

进阶功能

1. 自定义样式

<template>
  <Vue3NextQrcode
    text="你好世界"
    :size="300"
    :margin="20"
    colorDark="#000000"
    colorLight="#ffffff"
    :correctLevel="3"
  />
</template>

2. 添加 Logo

<template>
  <Vue3NextQrcode
    text="https://example.com"
    logoImage="https://example.com/logo.png"
    :logoScale="0.3"
    :logoMargin="10"
    :logoCornerRadius="8"
  />
</template>

3. 使用 GIF 背景(亮点功能!)

这是一个非常酷的功能,可以让你的二维码动起来:

<template>
  <Vue3NextQrcode
    text="动态二维码"
    :gifBackgroundURL="gifUrl"
    :dotScale="0.5"
    colorDark="#64d9d6"
  />
</template>

<script setup>
const gifUrl = 'https://example.com/background.gif'
</script>

4. 状态管理

支持加载中、错误等状态,提供更好的用户体验:

<template>
  <div>
    <!-- 加载状态 -->
    <Vue3NextQrcode text="加载中..." status="loading" />

    <!-- 错误状态 -->
    <Vue3NextQrcode
      text="错误"
      status="error"
      errorDescription="二维码已过期"
      errorActionDescription="重新加载"
      :onReload="handleReload"
    />

    <!-- 自定义加载插槽 -->
    <Vue3NextQrcode text="自定义加载" status="loading">
      <template #loading>
        <div class="custom-spinner">加载中...</div>
      </template>
    </Vue3NextQrcode>
  </div>
</template>

5. 下载二维码

<template>
  <div>
    <Vue3NextQrcode ref="qrcodeRef" text="下载我!" />
    <button @click="handleDownload">下载二维码</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const qrcodeRef = ref()

const handleDownload = async () => {
  try {
    await qrcodeRef.value?.downloadQRCode('my-qrcode.png')
    console.log('下载成功!')
  } catch (error) {
    console.error('下载失败:', error)
  }
}
</script>

🎨 Composable API(v4.0.0 新特性)

v4.0.0 版本引入了 useQRCode Composable,提供了更灵活的编程式二维码生成方式:

<script setup lang="ts">
import { ref } from 'vue'
import { useQRCode } from 'vue3-next-qrcode'

const { qrcodeURL, isLoading, error, generate, clear } = useQRCode()

const text = ref('你好世界')

const handleGenerate = async () => {
  await generate({
    text: text.value,
    size: 300,
    margin: 20,
    colorDark: '#000000',
    colorLight: '#ffffff',
  })
}
</script>

<template>
  <div>
    <input v-model="text" placeholder="输入文本" />
    <button @click="handleGenerate" :disabled="isLoading">
      {{ isLoading ? '生成中...' : '生成' }}
    </button>
    <button @click="clear">清除</button>

    <div v-if="error" class="error">{{ error.message }}</div>
    <img v-if="qrcodeURL" :src="qrcodeURL" alt="二维码" />
  </div>
</template>

这种方式特别适合需要动态控制二维码生成时机的场景。

🌐 SSR 支持(Nuxt)

对于使用 Nuxt 的开发者,vue3-next-qrcode 提供了完善的 SSR 支持方案。

方法 1:使用 ClientOnly(推荐)

<template>
  <ClientOnly>
    <QRCodeClient text="你好 Nuxt 3" />
    <template #fallback>
      <div>加载二维码中...</div>
    </template>
  </ClientOnly>
</template>

<script setup>
import { QRCodeClient } from 'vue3-next-qrcode'
import 'vue3-next-qrcode/es/style.css'
</script>

方法 2:动态导入

<template>
  <LazyQRCode v-if="mounted" text="你好 Nuxt" />
</template>

<script setup>
import { ref, onMounted } from 'vue'

const LazyQRCode = defineAsyncComponent(() =>
  import('vue3-next-qrcode').then((m) => m.Vue3NextQrcode),
)

const mounted = ref(false)

onMounted(() => {
  mounted.value = true
})
</script>

Nuxt 配置

// nuxt.config.ts
export default defineNuxtConfig({
  build: {
    transpile: ['vue3-next-qrcode'],
  },

  vite: {
    optimizeDeps: {
      include: ['vue3-next-qrcode'],
    },
  },
})

🎨 CSS 变量自定义样式

支持通过 CSS 变量进行深度样式定制:

<template>
  <Vue3NextQrcode
    text="样式化二维码"
    :defineProvider="{
      '--r-qrcode-primary-color': '#1677ff',
      '--r-qrcode-primary-color-2': '#69b1ff',
      '--r-qrcode-spin-size': '4px',
    }"
  />
</template>

可用的 CSS 变量包括:

  • --r-qrcode-width:二维码宽度
  • --r-qrcode-height:二维码高度
  • --r-qrcode-border-radius:边框圆角
  • --r-qrcode-mask-color:遮罩层颜色
  • --r-qrcode-primary-color:主题色
  • --r-qrcode-primary-color-2:次要主题色
  • --r-qrcode-spin-size:加载动画大小

v4.0.0 重大更新

最新的 v4.0.0 版本带来了许多重要改进:

破坏性变更

  • CSS 类名前缀从 ray-qrcode 统一改为 r-qrcode
  • img_tag 属性改为 data-component

新增特性

  1. useQRCode Composable:提供更灵活的二维码生成能力
  2. QRCodeClient 组件:专为 Nuxt SSR 环境优化
  3. GIF 背景缓存机制:避免重复加载相同的 GIF,提升性能
  4. 完整的 ARIA 无障碍支持:包括 role、aria-label、键盘导航
  5. 优化的 TypeScript 类型定义:提供更好的类型推导

性能优化

  • 使用 Composition API 重构组件内部实现
  • 使用 shallowRef 优化大对象性能
  • 优化 watcher 管理,避免内存泄漏
  • 使用 fetch API 替代 XMLHttpRequest,更好地支持 SSR
  • 添加防止并发渲染的锁机制,提升稳定性

开发体验

  • 添加 ESLint 和 Prettier 配置
  • 添加 GitHub Actions CI 工作流
  • 优化 package.jsonexports 字段
  • 添加 sideEffects 配置,优化 tree-shaking

实际应用场景

1. 动态链接分享

<script setup>
import { ref, computed } from 'vue'

const baseUrl = 'https://example.com/share'
const userId = ref('12345')
const shareUrl = computed(() => `${baseUrl}?user=${userId.value}`)
</script>

<template>
  <Vue3NextQrcode :text="shareUrl" :watchText="true" :size="200" />
</template>

2. 支付二维码

<script setup>
const paymentInfo = ref({
  amount: 100,
  orderId: 'ORDER123',
})

const paymentUrl = computed(
  () =>
    `payment://pay?amount=${paymentInfo.value.amount}&order=${paymentInfo.value.orderId}`,
)

const handlePaymentSuccess = (dataURL) => {
  console.log('支付二维码生成成功')
  // 可以将 dataURL 发送到后端保存
}
</script>

<template>
  <Vue3NextQrcode
    :text="paymentUrl"
    :size="300"
    logoImage="/logo.png"
    :logoScale="0.2"
    :onSuccess="handlePaymentSuccess"
  />
</template>

3. 带状态的二维码

<script setup>
import { ref, onMounted } from 'vue'

const qrStatus = ref('loading')
const qrText = ref('')

onMounted(async () => {
  try {
    // 模拟从后端获取二维码内容
    const response = await fetch('/api/qrcode')
    const data = await response.json()
    qrText.value = data.url
    qrStatus.value = undefined
  } catch (error) {
    qrStatus.value = 'error'
  }
})

const handleReload = async () => {
  qrStatus.value = 'loading'
  // 重新加载逻辑
}
</script>

<template>
  <Vue3NextQrcode
    :text="qrText"
    :status="qrStatus"
    errorDescription="二维码加载失败"
    :onReload="handleReload"
  />
</template>

性能优化建议

  1. 使用 watchText 控制更新:如果二维码内容不需要实时更新,可以设置 :watchText="false" 来避免不必要的重新渲染

  2. GIF 背景缓存:v4.0.0 已经内置了 GIF 缓存机制,相同的 GIF URL 只会加载一次

  3. 按需加载:在 Nuxt 或大型应用中,使用动态导入来减少初始加载体积

  4. 合理设置尺寸:根据实际需求设置合适的二维码尺寸,避免生成过大的图片

总结

vue3-next-qrcode 是一个功能完善、性能优秀的 Vue 3 二维码组件库。无论是简单的二维码生成,还是复杂的业务场景(如带 Logo、GIF 背景、状态管理等),它都能轻松应对。

特别是 v4.0.0 版本带来的 Composable API 和 SSR 优化,让它在现代 Vue 3 应用中更加得心应手。如果你正在寻找一个强大的 Vue 3 二维码解决方案,不妨试试 vue3-next-qrcode!

相关链接


如果这篇文章对你有帮助,欢迎点赞收藏!如果在使用过程中遇到问题,也欢迎在 GitHub 提 Issue 或 PR。

精读 GitHub - servo 浏览器(一)

2025年11月22日 21:31

一、简介

项目地址:github.com/servo/servo

这一期的精读 GitHub 系列是 servo 浏览器,我们将从源码的角度去拆解 servo 浏览器,逐步厘清整个浏览器的工作原理。

servo 是一款实验性质的现代浏览器引擎,采用 Rust 语言编写(其实是为了写 servo 创建了 Rust 这门语言,但 servo 没火,Rust 先火起来了!),servo 目前还没有独立的 App,需要使用 servoshell 来运行网页;servo 不仅展示了 Rust 语言在系统编程中的强大能力,还开创了并行化布局和渲染的全新理念(其中很多已经被 Firefox 吸收),是一个学习浏览器原理与 Rust 语言非常棒的开源项目。

自 2012 年以来,目前已收获 30K+的 star: 在这里插入图片描述

二、安装使用

这里我们以 macOS 为例,介绍如何在编译 servo,以及在 servo 上运行一个网页,其他操作系统按照官方 README 步骤操作即可。

macOS 上编译 servo 步骤:

1)下载 servo 源码:

git clone https://github.com/servo/servo

2)安装 Xcode brew

3)安装uv

curl -LsSf https://astral.sh/uv/install.sh | sh

4)安装 rustup

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

5)重启命令行保证安装的工具生效,在 servo 根目录下运行./mach bootstrap安装依赖

6)在 servo 根目录下运行./mach build进行编译:mach 是 servo 的构建和管理工具

编译成功后,可以通过如下命令运行一个网页:

# 运行网页
./mach run https://example.com

# 无头浏览器模式运行:不启动图形界面
./mach run --headless https://example.com

效果如下:

image.png

三、架构介绍

3.1 目录结构

在这里插入图片描述./mach run https://baidu.com命令运行为例,来深入理解这些目录之间是如何协同工作的:

1)ports/servoshell 启动程序,调用 components/constellation 创建一个新的 Tab

2)constellation 指挥 components/net 去下载网页

3)下载的 HTML 传给 components/script 解析成 DOM 树

4)components/style 并行计算样式,附着在 DOM 上

5)components/layout 读取 DOM 和样式,计算位置,生成布局树(Layout Tree)

6)最后,结果被送到 components/compositing 显示到屏幕上

3.2 架构图

在这里插入图片描述 上图是单内容进程时的架构图,实际上每个 tab 都可以视为一个进程。

关键点:

1) Constellation

核心协调者,可以理解为 tab 管理器,用于管理多个任务管线(Pipeline)

2)多 Pipeline 并行

图中展示了 A 和 B 两个 Script 线程,servo 在设计上支持一个进程内运行多个独立的渲染管线,比如 iframe 可以有自己独立的管线

3)Script 与 Layout 分离

每个 Script 线程都有一个专属的 Layout 线程

从图中可以看出,网页处理主要分为三个阶段:

阶段 核心职责 说明
Script 所有权 DOM 树,执行 JavaScript,处理导航事件等 当需要知道元素位置(如 offsetWidth)时,必须发消息询问 Layout
Layout 快照 DOM 树,计算样式,构建 Box Tree 和 Fragment Tree 最终生成 Display List 并发送给 Compositor
Compositor UI 线程,接收显示列表,转发给 WebRender 进行 GPU 渲染 UI 事件(如点击、滚动)的第一接收者,通常会将事件转发给 Script 处理

3.3 servo 的加速秘籍

servo 在哪些地方变快了,又是怎么做到的呢?

可以总结如下: 在这里插入图片描述

四、总结

通过本文,我们对 servo 有了整体的了解,后续,我们将从源码角度逐步拆解各部分。

更多精彩内容在公众号 「非专业程序员Ping」,欢迎订阅交流!

❌
❌