阅读视图

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

为什么ChatGPT能"打字"给你看?从Buffer理解AI流式输出

什么是Buffer?

Buffer(缓冲区)是计算机内存中用于临时存储数据的一块区域。想象一下你正在用杯子接水龙头的水:水龙头直接流到杯子里,如果水流太快,杯子可能会溢出。但如果你在中间放一个水壶(缓冲区),水先流到水壶里,再从水壶倒到杯子里,整个过程就更加可控了。

在JavaScript中,Buffer就是那个"水壶"——它帮助我们在处理二进制数据(如图片、音频、网络传输等)时更加高效和可控。

为什么需要Buffer?

1. 文本 vs 二进制

计算机中一切数据最终都以二进制形式存储,但我们在编程时通常处理的是文本(字符串)。当需要处理非文本数据时,就需要Buffer。

生活比喻:就像快递运输,文本数据就像明信片,内容直接可见;二进制数据就像密封的包裹,你需要专门的工具(Buffer)来查看和处理里面的内容。

2. 效率问题

直接操作二进制数据比操作字符串更高效,特别是在处理大量数据时。

HTML5中的Buffer操作

1. TextEncoder 和 TextDecoder

这是HTML5提供的编码/解码工具:

// 编码:将字符串转换为二进制数据
const encoder = new TextEncoder();
const myBuffer = encoder.encode('你好 HTML5');
console.log(myBuffer); // Uint8Array(10) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, ...]

// 解码:将二进制数据转换回字符串
const decoder = new TextDecoder();
const originalText = decoder.decode(myBuffer);
console.log(originalText); // "你好 HTML5"

注意:中文字符通常占用3个字节,英文字符占用1个字节,空格也是1个字节。

2. ArrayBuffer - 原始的二进制缓冲区

// 创建一个12字节的缓冲区(就像申请一块12格的内存空间)
const buffer = new ArrayBuffer(12);

// 但ArrayBuffer本身不能直接操作,需要视图(View)来读写

3. 视图(TypedArray)- 操作缓冲区的"眼镜"

ArrayBuffer就像一块空白画布,而TypedArray就是不同颜色的画笔:

const buffer = new ArrayBuffer(16); // 16字节的缓冲区

// 不同的视图类型,用不同的方式"看待"同一块内存
const uint8View = new Uint8Array(buffer);   // 视为8位无符号整数(0-255)
const uint16View = new Uint16Array(buffer); // 视为16位无符号整数
const int32View = new Int32Array(buffer);   // 视为32位有符号整数

// 使用Uint8Array视图操作数据
const view = new Uint8Array(buffer);
const encoder = new TextEncoder();
const data = encoder.encode('Hello');

for(let i = 0; i < data.length; i++) {
    view[i] = data[i]; // 将数据复制到缓冲区
}

实际应用场景

1. 流式数据处理(AI响应示例)

// 模拟AI流式输出
async function simulateAIStreaming() {
    const responses = ["思考", "中", "请", "稍", "候"];
    const buffer = new ArrayBuffer(100);
    const view = new Uint8Array(buffer);
    const decoder = new TextDecoder();
    
    let position = 0;
    
    for (const word of responses) {
        // 模拟网络延迟
        await new Promise(resolve => setTimeout(resolve, 500));
        
        // 将每个词编码并添加到缓冲区
        const encoded = new TextEncoder().encode(word);
        for (let i = 0; i < encoded.length; i++) {
            view[position++] = encoded[i];
        }
        
        // 实时解码已接收的部分
        const receivedSoFar = decoder.decode(view.slice(0, position));
        console.log(`已接收: ${receivedSoFar}`);
    }
}

// 这就是streaming:true的效果——边生成边显示

2. 文件处理

// 读取图片文件并获取其二进制数据
fileInput.addEventListener('change', async (event) => {
    const file = event.target.files[0];
    const buffer = await file.arrayBuffer(); // 获取文件的二进制数据
    
    // 现在可以操作这个buffer
    const view = new Uint8Array(buffer);
    console.log(`文件大小: ${buffer.byteLength} 字节`);
    console.log(`前10个字节: ${view.slice(0, 10)}`);
});

关键概念对比

概念 比喻 作用
ArrayBuffer 空白的内存空间 分配一块原始二进制内存
TypedArray 有刻度的量杯 以特定格式(如整数、浮点数)读取/写入数据
DataView 多功能测量工具 更灵活地读写不同格式的数据
TextEncoder 打包机 将文本打包成二进制
TextDecoder 拆包机 将二进制解包成文本

常见TypedArray类型

// 不同"眼镜"看同一数据的不同效果
const buffer = new ArrayBuffer(16);
const data = [1, 2, 3, 4];

// 使用Uint8Array:每个数字占1字节
const uint8 = new Uint8Array(buffer);
uint8.set(data);
console.log(uint8); // [1, 2, 3, 4, 0, 0, ...]

// 使用Uint16Array:每个数字占2字节
const uint16 = new Uint16Array(buffer);
console.log(uint16); // [513, 1027, 0, 0, ...] 
// 为什么是513?因为1+2*256=513(小端序存储)

性能优化技巧

  1. 复用Buffer:避免频繁创建和销毁Buffer
  2. 批量操作:使用set()方法而不是循环赋值
  3. 适当大小:不要分配过大的Buffer,会浪费内存
// 优化示例:批量操作
const source = new Uint8Array([1, 2, 3, 4, 5]);
const targetBuffer = new ArrayBuffer(10);
const targetView = new Uint8Array(targetBuffer);

// 好:批量复制
targetView.set(source);

// 不好:逐个复制
for (let i = 0; i < source.length; i++) {
    targetView[i] = source[i];
}

总结

Buffer是JavaScript处理二进制数据的核心工具,特别是在:

  • 网络通信(流式传输)
  • 文件操作(图片、音频处理)
  • 加密算法
  • 与WebGL、Web Audio等API交互

记住这个流程: 文本 → TextEncoder → 二进制 → ArrayBuffer → TypedArray操作 → TextDecoder → 文本

就像快递系统:商品(数据)被包装(编码)→ 运输(二进制传输)→ 拆包(解码)→ 使用。

掌握Buffer操作,你就打开了JavaScript处理二进制世界的大门!


延伸学习

  1. Blob对象:文件相关的二进制操作
  2. Streams API:更高级的流式数据处理
  3. WebSocket.binaryType:网络通信中的二进制传输
  4. Canvas图像数据处理:getImageData()返回的就是Uint8ClampedArray

前端毛玻璃组件的位置/尺寸动态变化产生的闪烁问题及解决方案

当 CSS backdrop-filter 遇上动态尺寸变化,一个肉眼看不见的 Bug 如何在录屏时暴露无遗?

前言

CSS backdrop-filter 是实现毛玻璃(Frosted Glass)效果的标准方案,被广泛应用于模态框、侧边栏、卡片等 UI 组件中。当毛玻璃元素的尺寸固定时,一切都很美好。但当你需要让毛玻璃背景动态变化尺寸(比如随着内容高度自动增长),一个诡异的问题就会出现:

正常使用时看着还行,但一旦用录屏软件录制GIF或视频,就会出现明显的闪烁。

这篇文章将深入分析这个问题的根本原因,以及如何优雅地解决它。

相关项目:


问题描述

典型场景

假设你有一个带毛玻璃效果的容器,它的高度需要根据内容动态变化:

.glass-container {
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  height: auto; /* 或者通过 JS 动态设置 */
  transition: height 0.1s;
}

问题现象

正常使用时的效果,即使动态改变该区域的位置和尺寸,肉眼看起来也比较流畅,毛玻璃背景随内容高度变化平滑过渡。但当使用 ShareX、OBS、Loom 或其他录屏软件录制时,毛玻璃区域在位置或高度变化过程中会出现不怎么规律的闪烁

GIF1.gif

关键特征:

特征 描述
闪烁规律 不规律,非固定频率
肉眼可见性 完全看不见(刷新率太快)
浏览器 Chrome、Edge 均受影响(Chromium 内核)
触发条件 元素尺寸变化时
对照实验 移除 backdrop-filter 后问题消失

根本原因分析

backdrop-filter 的工作原理

backdrop-filter 与普通的 filter 不同,它不是对元素本身应用滤镜,而是对元素背后的内容实时采样并应用滤镜。

┌─────────────────────────────────┐
│         页面背景内容             │
│    (文字、图片、其他元素)         │
│                                 │
│    ┌─────────────────────┐      │
│    │  backdrop-filter    │      │
│    │  blur(20px)         │ ←── 实时采样这个区域背后的像素并模糊
│    └─────────────────────┘      │
│                                 │
└─────────────────────────────────┘

尺寸变化时发生了什么

当带有 backdrop-filter 的元素尺寸改变时,浏览器需要:

  1. 重新计算采样区域 - 元素变大了,需要采样更多像素
  2. 重新应用模糊滤镜 - 对新的采样区域重新计算模糊
  3. 合成渲染结果 - 将模糊后的结果与其他图层合成

这个过程会产生中间状态——可能是还没模糊完成的帧,或者是尺寸计算还没同步的帧。

为什么肉眼看不见但录屏能捕获

现代显示器刷新率通常是 60Hz 或更高,人眼很难捕捉到持续时间仅几毫秒的中间状态。但录屏软件是逐帧捕获的,它会忠实地记录每一帧的状态,包括那些转瞬即逝的异常帧。

这就是为什么:

  • ✅ 肉眼看:完美流畅
  • ❌ 录屏看:明显闪烁

失败的尝试

在找到正确方案之前,我尝试了多种常见的性能优化方法,但都失败了。记录这些失败的尝试同样重要。

尝试 1:CSS 硬件加速

.glass-container {
  transform: translateZ(0);
  will-change: width, height, opacity;
  backface-visibility: hidden;
}

结果:❌ 失败 — 硬件加速只优化了合成阶段,但 backdrop-filter 的采样和模糊计算仍然需要执行。

尝试 2:使用 requestAnimationFrame 同步更新

const updateHeight = height => {
  requestAnimationFrame(() => {
    element.style.height = `${Math.round(height)}px`;
  });
};

const observer = new ResizeObserver(entries => {
  updateHeight(entries[0].contentRect.height);
});

结果:❌ 失败 — rAF 确保了更新在正确的时机发生,但无法阻止 backdrop-filter 本身的重计算。

尝试 3:绕过 React 渲染周期

如果是 React 项目,你可能会尝试用 ref 直接操作 DOM 来避免重渲染:

// 直接操作 CSS 变量,不触发 React 重渲染
backdropRef.current.style.setProperty('--height', `${height}px`);

结果:❌ 失败 — 问题不在 React,而在浏览器的渲染管线。


解决方案:双层裁剪结构

核心思想

唉,折腾半天还得是曲线救国——既然问题是 backdrop-filter 元素尺寸变化触发了模糊重算,那解决方案就是:

让带有 backdrop-filter 的元素永不改变尺寸。

实现方式

使用"外层裁剪容器 + 内层固定尺寸"的双层结构:

<!-- 外层:负责动态尺寸,设置 overflow: hidden 裁剪 -->
<div class="glass-clipper">
  <!-- 内层:固定大尺寸,应用 backdrop-filter -->
  <div class="glass-inner"></div>
</div>

CSS 实现

/* 外层裁剪容器 */
.glass-clipper {
  position: relative;
  height: var(--dynamic-height, 200px); /* 动态变化 */
  overflow: hidden; /* 关键:裁剪超出部分 */
  border-radius: 16px;
  border: 1px solid rgba(255, 255, 255, 0.3);

  /* ⚠️ 重要:不要使用以下属性 */
  /* transform: translateZ(0); */
  /* will-change: ...; */
}

/* 内层毛玻璃 */
.glass-inner {
  position: absolute;
  width: 1000px; /* 固定大尺寸,覆盖所有可能的显示区域 */
  height: 1000px;
  bottom: 0;
  right: 0; /* 根据你的布局调整锚点 */

  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
}

工作原理图解

┌─────────────────────────────────────────┐
│           外层容器 (动态高度)            │
│           overflow: hidden              │
│  ┌─────────────────────────────────┐    │
│  │                                 │    │
│  │         可见区域                 │    │  ← 用户看到的区域
│  │                                 │    │
│  │   ┌──────────────────────────┐  │    │
│  │   │                          │  │    │
│  │   │    内层 (1000x1000)      │  │    │
│  │   │    backdrop-filter       │  │    │
│  │   │                          │  │    │
│  └───│──────────────────────────│──┘    │
│      │                          │       │
│      │   (被裁剪隐藏的部分)      │       │
│      │                          │       │
│      └──────────────────────────┘       │
└─────────────────────────────────────────┘

当高度增加时:
✅ 外层容器高度增加
✅ 更多内层区域变得可见
❌ 内层尺寸不变 → backdrop-filter 不重算

重要注意事项

1. 边框必须放在外层

如果把边框放在内层,会出现边框不完整的问题(部分边框被裁掉):

[IMAGE_PLACEHOLDER: border-issue.png]
备注:如果边框放在内层,会出现上下左右边框不一致的问题。

2. 外层不能使用 transform/will-change

这是最容易踩的坑。以下属性会创建隔离的层叠上下文 (Stacking Context):

  • transform: translateZ(0) / transform: translate3d(...)
  • will-change: transform / will-change: opacity
  • filter: ...
  • isolation: isolate

当外层创建了隔离的层叠上下文,内层的 backdrop-filter 就只能"看到"外层容器的背景,而看不到页面上真正的背景内容,毛玻璃效果会完全失效

chrome_RXSOrpd2Nl.png

chrome_R0yzXsL8ZM.png

3. 内层尺寸要足够大

内层的固定尺寸需要覆盖外层可能达到的最大尺寸。如果内层太小,当外层扩展超过内层时,会出现毛玻璃覆盖不全的问题。

4. 锚点位置根据布局调整

示例中使用 bottom: 0; right: 0; 将内层锚定到右下角。根据你的实际布局,可能需要调整为:

  • top: 0; left: 0; - 锚定左上角
  • top: 0; right: 0; - 锚定右上角
  • 等等

最终效果

GIF2.gif

总结

问题本质

项目 说明
根本原因 backdrop-filter 在元素尺寸变化时需要重新采样和计算模糊
可见性 中间状态持续时间极短,肉眼不可见,但录屏可捕获
影响范围 Chromium 内核浏览器(Chrome、Edge、Opera 等)

解决方案

[外层容器 - 动态尺寸, overflow:hidden]
  └── [内层 - 固定大尺寸, backdrop-filter]

调试 Checklist

如果你遇到了类似问题,按以下步骤排查:

  • 确认触发条件:临时移除 backdrop-filter,闪烁是否消失?
  • 检查尺寸变化:元素尺寸是否在动态变化?如果固定,可能是其他问题
  • 检查层叠上下文:外层是否有 transformwill-changefilter 等属性?
  • 尝试双层结构:使用本文的外层裁剪 + 内层固定方案

适用场景

  • ✅ 高度/宽度动态变化的毛玻璃容器
  • ✅ 内容驱动尺寸的对话框、侧边栏
  • ✅ 带 backdrop-filter 的可折叠/展开组件
  • ❌ 固定尺寸和位置的毛玻璃元素(不需要此方案)

参考资料


如果这篇文章对你有帮助,欢迎分享给其他开发者,一起避坑!

micro-app 微前端项目部署指南

部署指南

本文档介绍如何将 micro-app 微前端项目部署到生产环境。

目录


部署方式

micro-app 微前端项目支持两种部署方式:

  1. 同域部署:主应用和所有子应用部署在同一域名下

    • 优点:无需配置 CORS,部署简单
    • 缺点:所有应用必须部署在同一服务器
  2. 跨域部署:主应用和子应用部署在不同域名

    • 优点:可以独立部署和扩展
    • 缺点:需要配置 CORS,配置相对复杂

同域部署

目录结构

/usr/share/nginx/html/
├── main-app/
│   └── dist/          # 主应用构建产物
├── sub-app-1/
│   └── dist/          # 子应用 1 构建产物
├── sub-app-2/
│   └── dist/          # 子应用 2 构建产物
└── sub-app-3/
    └── dist/          # 子应用 3 构建产物

URL 映射

https://example.com/              → 主应用
https://example.com/sub-app-1/    → 子应用 1
https://example.com/sub-app-2/    → 子应用 2
https://example.com/sub-app-3/    → 子应用 3

Nginx 配置

使用 nginx/main-app.conf 配置文件:

# 复制配置文件
sudo cp nginx/main-app.conf /etc/nginx/sites-available/micro-app

# 创建符号链接
sudo ln -s /etc/nginx/sites-available/micro-app /etc/nginx/sites-enabled/

# 修改配置中的路径和域名
sudo nano /etc/nginx/sites-available/micro-app

# 测试配置
sudo nginx -t

# 重载配置
sudo nginx -s reload

环境变量配置

主应用 .env.production:

# 同域部署不需要配置,自动使用 window.location.origin
# VITE_DEPLOY_MODE=same-origin  # 默认值,可省略

跨域部署

目录结构

# 主应用服务器
/usr/share/nginx/html/main-app/dist/

# 子应用 1 服务器
/usr/share/nginx/html/sub-app-1/dist/

# 子应用 2 服务器
/usr/share/nginx/html/sub-app-2/dist/

# 子应用 3 服务器
/usr/share/nginx/html/sub-app-3/dist/

URL 映射

https://main.example.com/        → 主应用
https://sub1.example.com/         → 子应用 1
https://sub2.example.com/         → 子应用 2
https://sub3.example.com/         → 子应用 3

Nginx 配置

主应用服务器:使用 nginx/main-app.conf(仅配置主应用部分)

子应用服务器:每个子应用使用 nginx/sub-app.conf

# 为每个子应用复制配置文件
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-1
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-2
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-3

# 创建符号链接
sudo ln -s /etc/nginx/sites-available/sub-app-1 /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/sub-app-2 /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/sub-app-3 /etc/nginx/sites-enabled/

# 修改每个配置文件中的 server_name 和路径
sudo nano /etc/nginx/sites-available/sub-app-1
sudo nano /etc/nginx/sites-available/sub-app-2
sudo nano /etc/nginx/sites-available/sub-app-3

# 测试配置
sudo nginx -t

# 重载配置
sudo nginx -s reload

重要:子应用必须配置 CORS 头,否则 micro-app 无法加载。

环境变量配置

主应用 .env.production:

# 跨域部署模式
VITE_DEPLOY_MODE=cross-origin

# 子应用入口地址
VITE_SUB_APP_1_ENTRY=https://sub1.example.com
VITE_SUB_APP_2_ENTRY=https://sub2.example.com
VITE_SUB_APP_3_ENTRY=https://sub3.example.com

Nginx 配置

主应用配置

参考 nginx/main-app.conf,主要配置:

  1. 根路径:主应用部署在根路径 /
  2. 子应用路径:每个子应用部署在 /sub-app-X/ 路径下
  3. 路由回退:使用 try_files 确保 SPA 路由正常工作
  4. 静态资源缓存:配置长期缓存策略

子应用配置

参考 nginx/sub-app.conf,主要配置:

  1. CORS 头:必须配置,micro-app 需要跨域支持
  2. OPTIONS 预检:处理跨域预检请求
  3. 路由回退:使用 try_files 确保 SPA 路由正常工作
  4. 静态资源缓存:配置长期缓存策略

关键配置说明

CORS 配置(子应用必须):

add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-Requested-With' always;

路由回退(SPA 必需):

location / {
    try_files $uri $uri/ /index.html;
}

静态资源缓存:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

环境变量配置

开发环境

主应用 .env.development:

# 通常不需要修改,使用默认的 localhost 地址

子应用 .env.development:

# 根据实际需求配置
# VITE_API_BASE_URL=http://localhost:8080/api

生产环境

同域部署:

主应用 .env.production:

# 不需要配置,自动使用 window.location.origin

跨域部署:

主应用 .env.production:

VITE_DEPLOY_MODE=cross-origin
VITE_SUB_APP_1_ENTRY=https://sub1.example.com
VITE_SUB_APP_2_ENTRY=https://sub2.example.com
VITE_SUB_APP_3_ENTRY=https://sub3.example.com

子应用 .env.production:

# 根据实际需求配置
# VITE_API_BASE_URL=https://api.example.com

构建和部署步骤

1. 构建所有应用

# 在项目根目录执行
pnpm run build

构建产物会生成在:

  • main-app/dist/
  • sub-app-1/dist/
  • sub-app-2/dist/
  • sub-app-3/dist/

2. 配置环境变量

根据部署方式配置 .env.production 文件(见上文)。

3. 上传构建产物

同域部署:

# 上传所有构建产物到同一服务器
scp -r main-app/dist/* user@server:/usr/share/nginx/html/main-app/dist/
scp -r sub-app-1/dist/* user@server:/usr/share/nginx/html/sub-app-1/dist/
scp -r sub-app-2/dist/* user@server:/usr/share/nginx/html/sub-app-2/dist/
scp -r sub-app-3/dist/* user@server:/usr/share/nginx/html/sub-app-3/dist/

跨域部署:

# 主应用
scp -r main-app/dist/* user@main-server:/usr/share/nginx/html/main-app/dist/

# 子应用 1
scp -r sub-app-1/dist/* user@sub1-server:/usr/share/nginx/html/sub-app-1/dist/

# 子应用 2
scp -r sub-app-2/dist/* user@sub2-server:/usr/share/nginx/html/sub-app-2/dist/

# 子应用 3
scp -r sub-app-3/dist/* user@sub3-server:/usr/share/nginx/html/sub-app-3/dist/

4. 配置 Nginx

参考 Nginx 配置 部分。

5. 测试部署

  1. 访问主应用:https://example.com
  2. 检查子应用加载是否正常
  3. 检查路由跳转是否正常
  4. 检查数据通信是否正常
  5. 检查浏览器控制台是否有错误

常见问题

Q1: 子应用加载失败,显示 CORS 错误?

A: 检查子应用的 Nginx 配置是否包含 CORS 头:

add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;

Q2: 路由跳转后显示 404?

A: 检查 Nginx 配置是否包含路由回退:

location / {
    try_files $uri $uri/ /index.html;
}

Q3: 静态资源加载失败?

A: 检查:

  1. 资源路径是否正确
  2. Nginx 配置中的 alias 路径是否正确
  3. 文件权限是否正确

Q4: 生产环境子应用地址不正确?

A: 检查主应用的 .env.production 配置:

# 跨域部署
VITE_DEPLOY_MODE=cross-origin
VITE_SUB_APP_1_ENTRY=https://sub1.example.com

Q5: 如何启用 HTTPS?

A: 参考 Nginx 配置文件中的 HTTPS 配置示例:

  1. 获取 SSL 证书
  2. 配置证书路径
  3. 启用 HTTPS server 块
  4. 配置 HTTP 重定向到 HTTPS

Q6: 如何配置 CDN?

A: 修改环境变量中的入口地址为 CDN 地址:

VITE_SUB_APP_1_ENTRY=https://cdn.example.com/sub-app-1

从零到一:基于 micro-app 的企业级微前端模板完整实现指南

本文是一篇完整的技术实践文章,记录了如何从零开始构建一个企业级 micro-app 微前端模板项目。文章包含完整的技术选型、架构设计、核心代码实现、踩坑经验以及最佳实践,适合有一定前端基础的开发者深入学习。

📋 文章摘要

本文详细记录了基于 micro-app 框架构建企业级微前端模板的完整实现过程。项目采用 Vue 3 + TypeScript + Vite 技术栈,实现了完整的主子应用通信、路由同步、独立运行等核心功能。文章不仅包含技术选型分析、架构设计思路,还提供了大量可直接使用的代码示例和实战经验,帮助读者快速掌握微前端开发的核心技能。

🎯 你将学到什么

  • ✅ micro-app 框架的核心特性和使用技巧
  • ✅ 微前端架构设计思路和最佳实践
  • ✅ 主子应用双向通信的完整实现方案
  • ✅ 路由同步和跨应用导航的实现细节
  • ✅ TypeScript 类型安全的微前端开发实践
  • ✅ 事件总线解耦和代码组织技巧
  • ✅ 开发/生产环境配置管理方案
  • ✅ 常见问题的解决方案和踩坑经验

💎 项目亮点

  • 🚀 开箱即用:完整的项目模板,可直接用于生产环境
  • 🔒 类型安全:完整的 TypeScript 类型定义,零 @ts-ignore,零 any
  • 🎨 企业级实践:可支撑真实企业项目
  • 📦 独立运行:子应用支持独立开发和调试
  • 🔄 智能通信:策略模式处理不同类型事件,代码清晰易维护
  • 🛠️ 一键启动:并行启动所有应用,提升开发效率

🎉 开源地址

micro-app-front-end


📑 目录


一、项目背景与需求分析

1.1 为什么选择微前端?

随着前端应用规模的不断增长,传统的单体应用架构面临诸多挑战:

  • 团队协作困难:多个团队维护同一个代码库,容易产生冲突
  • 技术栈限制:难以引入新技术,升级成本高
  • 部署效率低:任何小改动都需要整体发布
  • 性能问题:应用体积过大,首屏加载慢

微前端架构通过将大型应用拆分为多个独立的小应用,每个应用可以独立开发、测试、部署,有效解决了上述问题。

1.2 项目需求

基于企业级微前端项目实践,我们需要构建一个开箱即用的 micro-app 微前端模板,具备以下核心特性:

  1. 完整的通信机制:主子应用之间的双向数据通信,支持多种事件类型
  2. 路由同步:自动处理路由同步,支持浏览器前进后退,用户体验流畅
  3. 独立运行:子应用支持独立开发和调试,提升开发效率
  4. 类型安全:完整的 TypeScript 类型定义,避免运行时错误
  5. 环境适配:支持开发/生产环境,同域/跨域部署
  6. 错误处理:完善的错误处理和降级方案,提高系统稳定性

1.3 项目目标

  • ✅ 提供可直接用于生产环境的完整模板
  • ✅ 代码结构清晰,易于维护和扩展
  • ✅ 完整的文档和最佳实践指南
  • ✅ 解决常见问题,避免重复踩坑

技术选型

微前端框架: micro-app

选择理由:

  1. 基于 WebComponent: 天然实现样式隔离
  2. 原生路由模式: 子应用使用 createWebHistory,框架自动劫持路由
  3. 内置通信机制: 无需额外配置,开箱即用
  4. 轻量级: 相比 qiankun 更轻量,性能更好

版本: @micro-zoe/micro-app@1.0.0-rc.28

前端框架: Vue 3 + TypeScript

选择理由:

  1. 组合式 API: 更好的逻辑复用和类型推导
  2. TypeScript 支持: 完整的类型安全
  3. 生态成熟: 丰富的插件和工具链

构建工具: Vite

选择理由:

  1. 极速开发体验: HMR 速度快
  2. 原生 ES 模块: 更好的开发体验
  3. 配置简单: 开箱即用

注意: Vite 作为子应用时,必须使用 iframe 沙箱模式


三、架构设计详解

3.1 项目结构

micro-app/
├── main-app/              # 主应用(基座应用)
│   ├── src/
│   │   ├── components/    # 组件目录
│   │   │   └── MicroAppContainer.vue  # 子应用容器组件
│   │   ├── config/        # 配置文件
│   │   │   └── microApps.ts  # 子应用配置管理
│   │   ├── router/        # 路由配置
│   │   ├── types/         # TypeScript 类型定义
│   │   │   └── micro-app.ts  # 微前端相关类型
│   │   ├── utils/         # 工具函数
│   │   │   ├── microAppCommunication.ts  # 通信工具
│   │   │   └── microAppEventBus.ts  # 事件总线
│   │   ├── views/         # 页面组件
│   │   ├── App.vue        # 根组件
│   │   └── main.ts        # 入口文件
│   ├── vite.config.ts     # Vite 配置
│   └── package.json
│
├── sub-app-1/             # 子应用 1
│   ├── src/
│   │   ├── plugins/       # 插件目录
│   │   │   └── micro-app.ts  # MicroAppService 通信服务
│   │   ├── router/        # 路由配置
│   │   ├── utils/         # 工具函数
│   │   │   ├── env.ts     # 环境检测
│   │   │   └── navigation.ts  # 导航工具
│   │   ├── types/         # 类型定义
│   │   ├── views/         # 页面组件
│   │   ├── App.vue        # 根组件
│   │   └── main.ts        # 入口文件(支持独立运行)
│   ├── vite.config.ts     # Vite 配置
│   └── package.json
│
├── sub-app-2/             # 子应用 2(结构同 sub-app-1)
├── sub-app-3/             # 子应用 3(结构同 sub-app-1)
├── docs/                  # 文档目录
│   ├── TROUBLESHOOTING.md  # 踩坑记录
│   ├── IMPLEMENTATION_LOG.md  # 实现过程记录
│   └── FAQ.md             # 常见问题
├── package.json           # 根目录配置(一键启动脚本)
└── README.md              # 项目说明文档

3.2 核心模块设计

3.2.1 主应用通信模块 (microAppCommunication.ts)

设计思路

主应用通信模块是整个微前端架构的核心,负责主子应用之间的数据通信。我们采用策略模式处理不同类型的事件,使代码结构清晰、易于扩展。

核心功能

  1. 向子应用发送数据microAppSetData()

    • 自动添加时间戳,确保数据变化被检测
    • 自动添加来源标识,便于调试
  2. 跨应用路由跳转microAppTarget()

    • 智能区分同应用内跳转和跨应用跳转
    • 同应用内:通过通信让子应用自己跳转
    • 跨应用:通过主应用路由跳转
  3. 统一的数据监听处理器microAppDataListener()

    • 使用策略模式处理不同类型的事件
    • 支持扩展新的事件类型

代码示例

/**
 * 向指定子应用发送数据
 * 自动添加时间戳,确保数据变化被检测到
 */
export const microAppSetData = (name: string, data: Partial<MicroData>): void => {
  const targetName = name || getServiceName();

  if (!targetName) {
    devWarn("无法发送数据:未指定子应用名称");
    return;
  }

  // 自动添加时间戳,确保数据变化
  const dataWithTimestamp: MicroData = {
    ...data,
    t: Date.now(),
    source: getServiceName(),
  };

  try {
    microApp.setData(targetName, dataWithTimestamp);
    devLog(`向 ${targetName} 发送数据`, dataWithTimestamp);
  } catch (error) {
    console.error(`[主应用通信] 发送数据失败:`, error);
  }
};

💡 完整代码:代码仓库中包含完整的通信模块实现,包含所有事件类型的处理逻辑。

3.2.2 事件总线模块 (microAppEventBus.ts)

设计思路

事件总线模块用于解耦生命周期钩子和业务逻辑。当子应用生命周期发生变化时,通过事件总线通知业务代码,而不是直接在生命周期钩子中处理业务逻辑。

核心特性

  • ✅ 支持一次性监听 (once)
  • ✅ 支持静默模式(避免无监听器警告)
  • ✅ 完整的 TypeScript 类型定义
  • ✅ 支持移除监听器

代码示例

/**
 * 事件总线类
 * 用于解耦生命周期钩子和业务逻辑
 */
class EventBus {
  private listeners: Map<string, EventListener[]> = new Map();
  private silent: boolean = true; // 默认静默模式

  /**
   * 监听事件
   */
  on<T = any>(event: string, callback: EventCallback<T>, once = false): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }

    this.listeners.get(event)!.push({ callback, once });
  }

  /**
   * 触发事件
   */
  emit<T = any>(event: string, data: T): void {
    const listeners = this.listeners.get(event);

    if (!listeners || listeners.length === 0) {
      if (!this.silent) {
        devWarn(`事件 ${event} 没有监听器`);
      }
      return;
    }

    // 执行监听器,并移除一次性监听器
    listeners.forEach((listener, index) => {
      listener.callback(data);
      if (listener.once) {
        listeners.splice(index, 1);
      }
    });
  }
}

💡 完整实现:代码仓库中包含完整的事件总线实现,包含所有方法和类型定义。

3.2.3 子应用通信服务 (MicroAppService)

设计思路

子应用通信服务是一个类,负责初始化数据监听器、处理主应用发送的数据、向主应用发送数据以及清理资源。采用策略模式处理不同类型的事件,使代码结构清晰。

核心方法

  1. init(): 初始化数据监听器
  2. handleData(): 处理主应用数据(策略模式)
  3. sendData(): 向主应用发送数据
  4. destroy(): 清理监听器

代码示例

/**
 * 子应用通信服务类
 */
export class MicroAppService {
  private serviceName: string;
  private dataListener: ((data: MicroData) => void) | null = null;

  constructor() {
    this.serviceName = getServiceName();
    this.init();
  }

  /**
   * 初始化数据监听器
   */
  public init(): void {
    if (!isMicroAppEnvironment()) {
      return;
    }

    const microApp = (window as any).microApp;
    if (!microApp) {
      return;
    }

    // 创建数据监听器
    this.dataListener = (data: MicroData) => {
      this.handleData(data);
    };

    // 添加数据监听器
    microApp.addDataListener(this.dataListener);
  }

  /**
   * 处理主应用发送的数据(策略模式)
   */
  private handleData(data: MicroData): void {
    switch (data.type) {
      case "target":
        // 处理路由跳转
        break;
      case "menuCollapse":
        // 处理菜单折叠
        break;
      // ... 其他事件类型
    }
  }
}

💡 完整实现:代码仓库中包含完整的 MicroAppService 实现,包含所有事件类型的处理逻辑。


四、核心功能实现

4.1 主应用通信模块

4.1.1 数据发送功能

主应用向子应用发送数据时,需要自动添加时间戳,确保 micro-app 能检测到数据变化:

/**
 * 向指定子应用发送数据
 */
export const microAppSetData = (name: string, data: Partial<MicroData>): void => {
  const dataWithTimestamp: MicroData = {
    ...data,
    t: Date.now(),  // 自动添加时间戳
    source: getServiceName(),  // 自动添加来源
  };

  try {
    microApp.setData(name, dataWithTimestamp);
  } catch (error) {
    console.error(`[主应用通信] 发送数据失败:`, error);
  }
};

4.1.2 跨应用路由跳转

智能区分同应用内跳转和跨应用跳转,提供更好的用户体验:

/**
 * 跨应用路由跳转
 */
export const microAppTarget = (
  service: string,
  url: string
): void => {
  const currentService = getServiceName();

  // 同应用内:通过通信让子应用自己跳转
  if (currentService === service) {
    microAppSetData(service, { type: "target", path: url });
    return;
  }

  // 跨应用:通过主应用路由跳转
  const routeMapping = routeMappings.find((m) => m.appName === service);
  if (routeMapping) {
    const fullPath = `${routeMapping.basePath}${url}`;
    router.push(fullPath).catch((error) => {
      console.error(`[主应用通信] 路由跳转失败:`, error);
    });
  }
};

4.1.3 统一的数据监听处理器

使用策略模式处理不同类型的事件,代码结构清晰、易于扩展:

/**
 * 统一的数据监听处理器(策略模式)
 */
export const microAppDataListener = (params: DataListenerParams): void => {
  const { service, data } = params;

  if (!data || !data.type) {
    return;
  }

  // 使用策略模式处理不同类型的事件
  const eventHandlers: Record<MicroAppEventType, (data: MicroData) => void> = {
    target: (eventData) => {
      // 处理路由跳转
      const targetService = eventData.service || eventData.data?.service;
      const targetUrl = eventData.url || eventData.path || "";
      if (targetService && targetUrl) {
        microAppTarget(targetService, targetUrl);
      }
    },
    navigate: (eventData) => {
      // 处理跨应用导航
      // ...
    },
    logout: () => {
      // 处理退出登录
      // ...
    },
    // ... 其他事件类型
  };

  const handler = eventHandlers[data.type];
  if (handler) {
    handler(data);
  }
};

4.2 事件总线模块

事件总线用于解耦生命周期钩子和业务逻辑,使代码更易维护:

/**
 * 监听生命周期事件
 */
export const onLifecycle = (
  event: LifecycleEventType,
  callback: (data: LifecycleEventData) => void,
  once = false
): void => {
  eventBus.on(event, callback, once);
};

/**
 * 触发生命周期事件
 */
export const emitLifecycle = (
  event: LifecycleEventType,
  data: LifecycleEventData
): void => {
  eventBus.emit(event, data);
};

4.3 子应用通信服务

子应用通过 MicroAppService 类管理通信逻辑:

/**
 * 子应用通信服务类
 */
export class MicroAppService {
  private serviceName: string;
  private dataListener: ((data: MicroData) => void) | null = null;

  constructor() {
    this.serviceName = getServiceName();
    this.init();
  }

  /**
   * 处理主应用发送的数据(策略模式)
   */
  private handleData(data: MicroData): void {
    switch (data.type) {
      case "target": {
        // 路由跳转
        const path = data.path || data.data?.path || "";
        if (path && path !== router.currentRoute.value.path) {
          router.push(path);
        }
        break;
      }
      case "menuCollapse": {
        // 菜单折叠
        const collapse = data.data?.collapse ?? false;
        // 处理菜单折叠逻辑
        break;
      }
      // ... 其他事件类型
    }
  }

  /**
   * 向主应用发送数据
   */
  public sendData(data: Partial<MicroData>): void {
    if (!isMicroAppEnvironment()) {
      return;
    }

    const microApp = (window as any).microApp;
    if (!microApp || typeof microApp.dispatch !== "function") {
      return;
    }

    const dataWithTimestamp: MicroData = {
      ...data,
      t: Date.now(),
      source: this.serviceName,
    };

    microApp.dispatch(dataWithTimestamp);
  }

  /**
   * 清理监听器
   */
  public destroy(): void {
    if (this.dataListener) {
      const microApp = (window as any).microApp;
      if (microApp && typeof microApp.removeDataListener === "function") {
        microApp.removeDataListener(this.dataListener);
      }
      this.dataListener = null;
    }
  }
}

💡 完整代码:代码仓库中包含所有核心模块的完整实现,包含详细的注释和类型定义。


五、关键实现细节

5.1 类型安全优先

决策背景

在微前端项目中,类型安全尤为重要。主子应用之间的通信如果没有类型约束,很容易出现运行时错误。

实现方案

1. 全局类型声明

window.microApp 添加全局类型声明:

// types/micro-app.d.ts
declare global {
  interface Window {
    microApp?: {
      setData: (name: string, data: any) => void;
      getData: () => any;
      addDataListener: (listener: (data: any) => void) => void;
      removeDataListener: (listener: (data: any) => void) => void;
      dispatch: (data: any) => void;
    };
    __MICRO_APP_ENVIRONMENT__?: boolean;
    __MICRO_APP_BASE_ROUTE__?: string;
    __MICRO_APP_NAME__?: string;
  }
}

2. 完整的类型定义

定义所有通信数据的类型:

/**
 * 微前端通信数据类型
 */
export interface MicroData {
  /** 事件类型(必填) */
  type: MicroAppEventType;
  /** 事件数据 */
  data?: Record<string, any>;
  /** 时间戳(确保数据变化) */
  t?: number;
  /** 来源应用 */
  source?: string;
  /** 路径(用于路由跳转) */
  path?: string;
  /** 目标服务(用于跨应用跳转) */
  service?: string;
  /** 目标URL(用于跨应用跳转) */
  url?: string;
}

3. 类型守卫

使用类型守卫确保类型安全:

function isMicroAppEnvironment(): boolean {
  return !!window.__MICRO_APP_ENVIRONMENT__;
}

收益

  • ✅ 更好的 IDE 提示和自动补全
  • ✅ 编译时错误检查,避免运行时错误
  • ✅ 代码可维护性显著提升
  • ✅ 重构更安全,类型系统会提示所有需要修改的地方

5.2 事件总线解耦

决策背景

在微前端项目中,子应用的生命周期钩子需要触发各种业务逻辑。如果直接在生命周期钩子中处理业务逻辑,会导致代码耦合度高,难以维护。

实现方案

1. 事件总线设计

class EventBus {
  private listeners: Map<string, EventListener[]> = new Map();
  private silent: boolean = true;

  on<T = any>(event: string, callback: EventCallback<T>, once = false): void {
    // 添加监听器
  }

  emit<T = any>(event: string, data: T): void {
    // 触发事件
  }

  off(event: string, callback?: EventCallback): void {
    // 移除监听器
  }
}

2. 生命周期钩子触发事件

// 生命周期钩子中触发事件
const onMounted = () => {
  emitLifecycle("mounted", { name: props.name });
};

3. 业务代码监听事件

// 业务代码中监听事件
onLifecycle("mounted", (data) => {
  // 处理业务逻辑
  console.log(`子应用 ${data.name} 已挂载`);
});

收益

  • ✅ 代码解耦,生命周期钩子和业务逻辑分离
  • ✅ 业务逻辑可以独立测试
  • ✅ 支持多个监听器,扩展性强
  • ✅ 代码结构清晰,易于维护

5.3 日志系统优化

决策背景

在开发环境中,详细的日志有助于调试。但在生产环境中,过多的日志会影响性能,还可能泄露敏感信息。

实现方案

const isDev = import.meta.env.DEV;

/**
 * 开发环境日志输出
 */
const devLog = (message: string, ...args: any[]) => {
  if (isDev) {
    console.log(`%c[标签] ${message}`, "color: #1890ff", ...args);
  }
};

/**
 * 开发环境警告输出
 */
const devWarn = (message: string, ...args: any[]) => {
  if (isDev) {
    console.warn(`%c[标签] ${message}`, "color: #faad14", ...args);
  }
};

/**
 * 错误日志(始终输出)
 */
const errorLog = (message: string, ...args: any[]) => {
  console.error(`[标签] ${message}`, ...args);
};

收益

  • ✅ 生产环境性能更好,无日志开销
  • ✅ 开发环境调试更方便,彩色日志易于识别
  • ✅ 避免敏感信息泄露
  • ✅ 错误日志始终输出,便于问题排查

5.4 Vite 子应用 iframe 沙箱

决策背景

根据 micro-app 官方文档,Vite 作为子应用时,必须使用 iframe 沙箱模式,否则会出现脚本执行错误。

实现方案

<micro-app
  :name="name"
  :url="url"
  router-mode="native"
  iframe  <!-- 必须添加此属性 -->
/>

收益

  • ✅ 解决 Vite 开发脚本执行错误
  • ✅ 更好的隔离性,样式和脚本完全隔离
  • ✅ 符合官方最佳实践

六、最佳实践与优化

6.1 统一配置管理

使用配置文件统一管理所有子应用地址,支持环境感知:

// config/microApps.ts
const envConfigs: Record<string, EnvConfig> = {
  "sub-app-1": {
    dev: "http://localhost:3000",
    prod: "//your-domain.com/sub-app-1",
    envKey: "VITE_SUB_APP_1_ENTRY",
  },
  // ...
};

export function getEntry(appName: string): string {
  const config = envConfigs[appName];

  // 优先级 1: 环境变量覆盖
  if (import.meta.env[config.envKey]) {
    return import.meta.env[config.envKey];
  }

  // 优先级 2: 根据环境选择配置
  if (import.meta.env.DEV) {
    return config.dev;
  }

  // 生产环境根据部署模式选择
  const deployMode = import.meta.env.VITE_DEPLOY_MODE || "same-origin";
  return deployMode === "same-origin"
    ? `${window.location.origin}/${appName}`
    : config.prod;
}

优势

  • ✅ 配置集中管理,易于维护
  • ✅ 自动适配开发/生产环境
  • ✅ 支持环境变量覆盖
  • ✅ 支持同域/跨域部署

6.2 自动添加时间戳

发送数据时自动添加时间戳,确保 micro-app 能检测到数据变化:

const dataWithTimestamp: MicroData = {
  ...data,
  t: Date.now(),  // 自动添加时间戳
  source: getServiceName(),  // 自动添加来源
};

优势

  • ✅ 确保 micro-app 能检测到数据变化
  • ✅ 避免数据未更新的问题
  • ✅ 便于调试,可以看到数据来源

6.3 智能路由跳转

区分同应用内跳转和跨应用跳转,提供更好的用户体验:

// 同应用内:通过通信让子应用自己跳转
// 跨应用:通过主应用路由跳转
if (currentService === service) {
  microAppSetData(service, { type: "target", path: url });
} else {
  router.push(fullPath);
}

优势

  • ✅ 避免路由记录混乱
  • ✅ 更好的用户体验
  • ✅ 支持浏览器前进后退

6.4 完善的错误处理

所有关键操作都使用 try-catch,提供降级方案:

try {
  microApp.setData(targetName, dataWithTimestamp);
} catch (error) {
  console.error(`[主应用通信] 发送数据失败:`, error);
  // 降级处理
}

优势

  • ✅ 提高系统稳定性
  • ✅ 更好的错误提示
  • ✅ 便于问题排查

6.5 子应用独立运行

子应用支持独立运行,便于开发调试:

// main.ts
if (!isMicroAppEnvironment()) {
  // 独立运行时直接挂载
  render();
} else {
  // 微前端环境导出生命周期函数
  window.mount = () => render();
  window.unmount = () => app.unmount();
}

优势

  • ✅ 提升开发效率
  • ✅ 便于独立调试
  • ✅ 支持独立部署

七、踩坑经验总结

在实现过程中,我们遇到了许多问题,以下是主要问题和解决方案:

7.1 Vue 无法识别 micro-app 自定义元素

问题:Vue 3 默认会将所有标签当作 Vue 组件处理,但 micro-app 是 WebComponent 自定义元素。

解决方案:在 vite.config.ts 中配置 isCustomElement

vue({
  template: {
    compilerOptions: {
      isCustomElement: (tag) => tag === "micro-app",
    },
  },
})

7.2 Vite 子应用必须使用 iframe 沙箱

问题:Vite 作为子应用时,如果不使用 iframe 沙箱,会出现脚本执行错误。

解决方案:在 MicroAppContainer 组件中添加 iframe 属性:

<micro-app
  :name="name"
  :url="url"
  router-mode="native"
  iframe  <!-- 必须添加 -->
/>

7.3 通信数据未接收

问题:发送数据后,子应用未接收到数据。

解决方案

  1. 确保添加了时间戳,确保数据变化被检测
  2. 使用 forceDispatch 强制发送数据
  3. 检查监听器是否正确注册

7.4 路由不同步

问题:子应用路由变化时,浏览器地址栏未更新。

解决方案

  1. 确保主应用使用 router-mode="native"
  2. 确保子应用使用 createWebHistory
  3. 检查基础路由配置是否正确

💡 更多踩坑记录:代码仓库中包含完整的踩坑记录文档(docs/TROUBLESHOOTING.md),包含所有遇到的问题和解决方案。


八、项目总结与展望

8.1 已完成功能

完整的通信机制:主子应用双向通信,支持多种事件类型 ✅ 路由同步:自动处理路由同步,支持浏览器前进后退 ✅ 独立运行:子应用支持独立开发和调试 ✅ 类型安全:完整的 TypeScript 类型定义,零 @ts-ignore,零 any事件总线:解耦生命周期和业务逻辑 ✅ 一键启动:并行启动所有应用,提升开发效率 ✅ 错误处理:完善的错误处理和降级方案 ✅ 日志优化:开发/生产环境区分,性能优化 ✅ 环境适配:支持开发/生产环境,同域/跨域部署

8.2 技术亮点

  1. 类型安全优先:完整的 TypeScript 类型定义,避免运行时错误
  2. 企业级实践:参考真实企业项目,但改进其不足
  3. 开箱即用:完整的配置和文档,快速上手
  4. 最佳实践:遵循 micro-app 官方推荐实践
  5. 代码质量:清晰的代码结构,完善的注释
  6. 可维护性:模块化设计,易于扩展

8.3 项目价值

  • 🎯 学习价值:完整的微前端实现示例,适合深入学习
  • 🚀 实用价值:可直接用于生产环境,节省开发时间
  • 📚 参考价值:最佳实践和踩坑经验,避免重复踩坑

📚 参考资源

官方文档

接口可不可以多版本共存?

比如第一版开发了路由 aaa,假设后面我们又开发了一版接口,但路由还是 aaa,怎么做?

nest new version-demo

创建 aaa 模块

nest g resource aaa --no-spec

跑起来

npm run start:dev

image.png

又开发了一版接口,但路由还是 aaa 怎么区分开 ?

image.png

image.png

image.png

不带 version 请求不到

image.png

带上 version

image.png

image.png

想所有版本都能访问这个接口,可以用 VERSION_NEUTRAL 这个常量

但此时都一样 如何区分开

image.png

单独建一个 version 2 的 controller

nest g controller aaa/aaa-v2 --no-spec --flat

更新为

import { Controller, Get, Version } from '@nestjs/common';
import { AaaService } from './aaa.service';

@Controller({
  path: 'aaa',
  version: '2',
})
export class AaaV2Controller {
  constructor(private readonly aaaService: AaaService) {}

  @Get()
  findAllV2() {
    return this.aaaService.findAll() + '222';
  }
}

一般是这样做,有一个 Controller 标记为 VERSION_NEUTRAL,其他版本的接口放在单独 Controller 里

注意,controller 之间同样要注意顺序,前面的 controller 先生效

image.png

image.png

image.png

除了用自定义 header 携带版本号,还可以这样

image.png

此处需要这样写

image.png

image.png

image.png

这些指定版本号的方式都不满足需求,可以自己写

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { VersioningType } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  //   app.enableVersioning({
  // type: VersioningType.HEADER,
  // header: 'version',

  //     type: VersioningType.URI,
  //   });

  const extractor = (request: Request) => {
    if (request.headers['disable-custom']) {
      return '';
    }
    return request.url.includes('fuhao') ? '2' : '1';
  };

  app.enableVersioning({
    type: VersioningType.CUSTOM,
    extractor,
  });

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

如果 url 里包含 fuhao,就返回版本 2 的接口,否则返回版本 1

image.png

image.png

image.png

useRoutes和createBrowserRouter的对比

一、 useRoutes

本质:Hook
版本:V6+
限制:需要配合BrowserRouter或者HashRouter进行使用
使用范围:在组件内部使用
返回值:得到路由匹配到的元素

二、createBrowserRouter

本质:函数
版本:V6.4+
限制:不需要配合BrowserRouter或者HashRouter进行使用,但是需要配合RouterProvider
使用范围:在组件外部创建路由配置
返回值:得到路由匹配到的元素

三、对比优缺点

useRoutes:简单、灵活,适合基础路由,但是不支持数据加载(loader)和错误处理(errorElement)
createBrowserRouter:功能更强,支持数据加载、错误处理等,适合复杂应用

四、代码示例:

useRouter:

function Router() { return useRoutes([ { path: "/", element: <Layout />, children: [ {const router = createBrowserRouter([ { path: "/", element: <Layout />, children: [ { path: "home", element: <Home />, loader: async () => { // 加载数据 return await fetchHomeData(); }, errorElement: <ErrorBoundary />, }, ], }, ]); path: "home", element: <Home /> }, ], }, ]); }

import { BrowserRouter as Router } from "react-router-dom"; root.render( <Router> {/* 需要 BrowserRouter */} <App /> </Router> );

createBrowserRouter:

const router = createBrowserRouter([ { path: "/", element: <Layout />, children: [ { path: "home", element: <Home />, loader: async () => { // 加载数据 return await fetchHomeData(); }, errorElement: <ErrorBoundary />, }, ], }, ]);

import { RouterProvider } from "react-router-dom"; import router from "./router/index"; function App() { return <RouterProvider router={router} />; // 不需要 BrowserRouter }

高德地图「点标记+点聚合」加载慢、卡顿问题 解决方案

coffee.joiepink.space/

Coffee网页的设计灵感来自于一个普通的在星巴克喝咖啡的下午,突发奇想能不能把全国的星巴克门店都整合到一起,用地图可视化的形式展示门店分布密度,为咖啡爱好者提供便捷的门店查询和导航服务。

于是便诞生了。

技术栈:Vue Amap UnoCSS Icônes Vant ESLint Vite

coffee-performance-01.png

痛点:本项目有8000+的数据量,需要在高德地图上点标记每家星巴克门店且支持交互,由于数据量过大,首次进入页面加载速度很慢,且把8000+点标记显示在地图上,点标记的交互动作会崩盘,地图操作响应速度也会变慢、卡顿,非常影响用户的使用体验

基于此,我从以下几个方面对项目进行了性能优化

  1. Amap SDK按需、动态加载
  2. 用 shallowRef 存地图相关实例
  3. 点聚合 + 只渲染视野内点位
  4. 视口变化防抖 + 只绑一次
  5. 首屏后再拉数据
  6. 主题切换与地图样式

1. Amap SDK按需、动态加载

在地图页面的js中,我并不在js顶部写import AMapLoader from '@amap/amap-jsapi-loader'

这种使用方法有两个弊端:

第一方面,在{Vite}打包的时候,这个依赖会被打包进入首屏就要加载的bundle(主chunk或和主入口一起被加载的chunk),用户第一次打开页面的时候,浏览器就会一起下载这份包含高德的JS,导致首屏体积变大,加载速度变慢。

第二方面,这种方式在模块被Node执行的时候们就会运行,于是会加载@amap/amap-jsapi-loader及其内部依赖, 而{Amap}内部SDK/loader会用到window,但是Node里面是没有window的,所以会导致报错(例如 Reference Error: window is not defined)。

为了避免以上两种问题,我在初始化{Amap}的函数里写const {default: AMapLoader} = await import('@amp/amp-jsapi-loader'),这个函数只在onMounted生命周期中调用,也就是说只在浏览器里、页面挂载之后才会执行

在{Vite}打包的时候,@amap/amap-jsapi-loader会被打包成单独的chunk,只有执行到const {default: AMapLoader} = await import('@amp/amp-jsapi-loader')的时候才会加载这段JS,首屏主bundle里并没有{Amap}相关代码,所以首包更小、首屏更快。

SSG时Node不会执行onMounred钩子,所以不会执行这段import,自然也就不会在Node里加载高德,不会碰到window,避免了报错。

async function initMap() {
  const { default: AMapLoader } = await import('@amap/amap-jsapi-loader') // [!code hl]
  const amapKey = import.meta.env.VITE_AMAP_KEY
  if (!amapKey)
    return Promise.reject(new Error('Missing VITE_AMAP_KEY'))
  window._AMapSecurityConfig = { securityJsCode: amapKey }
  return AMapLoader.load({
    key: amapKey,
    version: '2.0',
    plugins: ['AMap.Scale', 'AMap.MarkerCluster', 'AMap.Geolocation'], // [!code hl]
  })
}

initMap函数中,我使用{Amap}2.0的按插件加载特性,通过AMapLoader.load({plugins: [...]})按需加载需要的插件,这种方式在项目中精准引入需要使用的插件,使得项目请求更少、解析更少、地图初始化更轻,从而加快了加载速度、减小了打包的包体积。

2. 用 shallowRef 存地图相关实例

const map = shallowRef(null)
const currentLocationMarker = shallowRef(null)
const geolocation = shallowRef(null)
const Amap = shallowRef(null)

/** 根据 isDark 设置地图底图样式;返回 Promise,地图样式切换完成后再 resolve */
function applyMapStyle() {
    if (!map.value) return Promise.resolve()
  const style = i
   sDark.value ? 'amap://styles/dark' : 'amap://styles/normal'
  return new Promise((resolve) => {
      nextTick(() => {
        map.value?.setMapStyle(style)
      setTimeout(resolve, 1800)
    })
})
}

/** 点击切换主题 */
function onThemeToggle() {
    themeChanging.value = true
  nextTick(() => {
      toggleDark()

/** 监听 isDark,切完样式再关 loading */
watch(isDark, () => {
    const p = applyMapStyle()
  if (p) {
      p.finally(() => {
        themeChanging.value = false

    se {
  }
    themeChanging.value = false
}

先来说一下{Vue}中refshallowRef的区别

ref: {Vue}会对你塞进去的整个对象做深度响应式代理——递归的把对象里每一层属性都报称getter/setter(Proxy), 这样任意一层属性变了都会触发更新

shallowRef:只对[.value这一层]做响应式。当把一个对象赋值给shallowRef.value的时候,{Vue}不会去递归代理这个对象内部,内部属性变了{Vue}并不知道,但只有把整个对象换掉(重新赋值.value)时候才会触发更新

如果使用ref来存储{Amap}实例,会出现因深度代理造成的postMessage克隆报错,例如:DOMException: Failed to execute 'postMessage' on 'Worker': AMap.Map#xxx could not be cloned.

{Amap}的实例如Map Marker Geolocation MarkerCluster等内部会用到postMessage例如和地图iframe/worker通信

浏览器在发postMessage的时候,会对要传的数据做结构化克隆 structured clone,把对象序列号之后再在另一边反序列化,而Proxy对象不支持被克隆,所以会报错

能被结构化克隆的:普通对象、数组、部分简单类型

不能被结构化克隆的:函数、DOM节点、Proxy对象(Vue的响应式对象就是Proxy)

如果使用shallowRef存储的话,赋值给shallowRef.value的是{Amap}原始的实例对象,{Vue}不会去递归代理它里面的属性,也就不会报错。而ref存储会递归遍历、创建大量的Proxy,但其实并不需要在地图内部某个坐标变了就触发Vue更新,我们只需要在换地图、换marker、换聚合的这种整实例替换的时候更新即可。所以shallowRef的时候内存和CPU开销都更小,从而最性能有利。

3. 点聚合 + 只渲染视野内点位

为了解决数据量过大导致DOM数量巨大(每个门店创建一个marker标记)导致卡顿崩盘的问题,我使用{Amap}的点聚合 MarkerCluster将距离近的一批点在逻辑上归为一组,地图上只画一个聚合点,用户放大地图时,聚合会拆开变成更小的簇或者单点,缩小地图的时候,会合并成更大的簇。

即使使用点聚合 MarkerCluster,如果把全国所有门店(8000+)的数据量一次性都塞给点聚合 MarkerCluster,聚合算法要对这所有点都做距离计算、分簇、计算量十分之庞大,而绝大部分点并不在当前用户所见的屏幕内,用户根本看不到,却还是在耗费后台进行参与计算和内部管理,所以,更合理的做法是只把当前视野 current viewport内的点标记交给点聚合 MarkerCluster进行计算,而视野外的点不参与计算和渲染,当用户拖动地图画布或者放大缩小当前视野的时候,再进行计算参与。

具体做法如下:

function updateClusterByViewport(AmapInstance) {
    if (!map.value || !pointList.value?.length) return
  // 返回当前地图可视区域的地理范围(一个矩形,有西南角、东北角经纬度)。

  const b = map.value.getBounds()
  if (!b) return
  // 过滤当前
   视野内的点数据
  const inBounds = pointList.value.filter((point) => {
      const ll = point.lnglat
    const lng = Array.isArray(ll) ? ll[0] : (ll?.lng ?? ll?.longitude)
    const lat = Array.isArray(ll) ? ll[1] : (ll?.lat ?? ll?.latitude)
    // 判断点是否在当前视野矩形内
    return lng != null && lat != null && b.contains(new AmapInstance.LngLat(lng, lat))
   }
  // 把「视野内点数」存起来给界面用
  pointsInBounds.value = inBounds

    // 销毁旧聚合并只拿视野内的点重建聚合
  if (clusterRef.value) {
      clusterRef.value.setMap(null)
    clusterRef.value = null
   }
  if (!inBounds.length) return
  const myList = inBoun
   ds.map((point) => ({
      lnglat: Array.isArray(pont.lnlat)
        ? point.lnglat
      : [point.lnglat?.lng ?? point.lnglat?.longitude, point.lnglat?.lat ?? point.lnglat?.latitude],
   id:point.id,
  })
  const gridSize = 60
  const cluster = new AmapInstance.MarkerCluster(map.value, myList, {
      gridSize,
    renderClusterMarker: createRenderClusterMarker(AmapInstance, myList.length),
    renderMarker: createRenderStoreMarker(AmapInstance),
  })
  setupClusterClickZoom(cluster)
  clusterRef.value = cluster
}

4. 视口变化防抖 + 只绑一次

当用户拖拽、缩放地图的时候,地图会连续触发很多次moveend/zoomend的事件,如果每次触发都执行上文的updateClusterByViewport方法,计算执行过于频繁,容易造成页面卡顿、浪费CPU,因此为这些操作都加上防抖

const onViewportChange = useDebounceFn(() => updateClusterByViewport(Amap.value), 150)
map.value.on('moveend', onViewportChange)
map.value.on('zoomend', onViewportChange)

5. 首屏后再拉数据

首屏加载的时候,应该把注意力放在地图容器快速渲染上面,从而给用户一个比较好的使用体验。而加载数据(loadAndRenderStores)会执行请求数据、处理数据、渲染视野内点聚合这一系列操作,逻辑较重,因此如果在地图还没准备好、或者首屏还在渲染的时候同步做这些事情,就会占用主线程,从而拖慢首屏DOM的渲染、拖慢地图SDK的首次绘制,所以把目标变成:先让首屏和地图第一次渲染完成,再在浏览器空闲的时候去拉取数据、计算聚合。

map.value.on('complete', () => {
  const run = () => loadAndRenderStores(AmapInstance)
  if (typeof requestIdleCallback !== 'undefined') {
    requestIdleCallback(run, { timeout: 500 })
  }
  else {
    setTimeout(run, 0)
  }
  mapReady.value = true

  tryEndFirstScreenLoading()
})

6. 主题切换与地图样式

项目中 设置了DarkLight两种主题模式,而在切换的时候,地图样式切换是异步的,比导航条样式切换慢,这就会导致出现导航条已经变化主题,但地图主题还没更新,中间有一小段时间两者颜色不一致,甚至会闪动一下,带给用户不好的体验效果

为了解决这个问题,我在切换主题的[中间态]中,将页面用全屏loading遮罩层罩住,等地图样式基本切换完毕再隐藏,避免了中间态的闪烁问题

小结

至此,{Amap}相关的性能优化结束,首屏加载从原先的8,304ms优化到了4,181ms加载时间减少了4,123ms,性能提升了约49.65%,加载速度快了一倍

优化前:

coffee-performance-02.webp

优化后:

coffee-performance-03.png

Shadcn组件库的终极进化!React+Vite全速驱动,Ant-Design扩展组件+大屏可视化方案一网打尽🎯

vite-shadcn

VITE-SHADCN 是一个基于 Shadcn , Vite , React,Zustand,React-Router 等构建的项目 。已经参照ant-design扩展组件扩展了shadcn大量shadcn缺少的组件。并且实现了各种大屏以及可视化方案。

dashboard-zh-CN.png

disaster-command-zh-CN.png

rechart-zh-CN.png

dashboard-zh-CN.png

仓库地址:github.com/yluiop123/v…

项目访问地址:yluiop123.github.io/vite-shadcn

✅ 任务清单

  • 初始化项目
  • 配置 Vite + React + TypeScript + React-Router + Zustand + Axios + MSW +ShadCN 环境
  • 动态权限路由加载
  • 国际化、主题色切换、暗黑模式
  • 多布局
登录和路由权限控制
  • 登录功能
  • 路由权限控制
  • 角色切换(支持融合模式)
仪表盘
  • 基本仪表盘
  • 态势感知-网络安全
  • 态势感知-军事
  • 灾情指挥
  • 农林业管理
  • 工业管理
组件库
  • 普通组件
  • 布局组件
  • 导航组件
  • 数据展示组件
  • 反馈组件
  • react-hook-form 表单组件
  • tanstack/react-table 表格组件
  • 自定义组件
图表
  • Rechart 图表组件
  • Echart 图表组件
  • D3 图表组件
  • Antv 图表组件
三维
  • Babylon
  • Three
地图
  • Cesium地图
  • Deckgl地图
  • L7地图
  • Leaflet地图
  • Openlayers地图
系统管理
  • 用户管理
  • 角色管理
  • 权限管理
  • 组织管理
后端规划
  • springboot实现后端接口
  • 实现微服务

快速开始

1)环境

  • Node.js: v18+
  • pnpm: pnpm v10.28.2

2)技术栈

  • 框架: React 19 + Vite6
  • 状态管理: Zustand
  • UI 组件库: ShadCN + TailwindCSS
  • 国际化: react-intl
  • 路由: React Router v7
  • 接口模拟: Mock Service Worker (MSW)
  • 构建工具: Vite6

3)安装启动

# 克隆项目
git clone https://github.com/yluiop123/vite-shadcn.git
cd <项目目录>

# 安装依赖
pnpm install   

# 本地开发启动
pnpm dev    

#项目启动后访问 http://localhost:3000/   

4)命令行

我将为您更新表格,添加说明列:

命令 描述 说明
dev vite 启动开发服务器,支持热重载和实时编译
build tsc -b && vite build 构建生产版本,先进行 TypeScript 类型检查,再打包项目
build:github tsc -b && vite build --mode github 构建 GitHub 部署版本,使用特定的构建配置
lint eslint . 运行 ESLint 检查代码质量,识别潜在问题
preview vite preview 预览生产构建的项目,用于本地测试构建结果
preview:github vite preview --mode github 预览 GitHub 部署版本的构建结果
analyze cross-env ANALYZE=true vite build 分析打包结果,生成 bundle 分析报告

5)环境变量

项目默认使用 .env 文件作为环境变量配置。当通过 --mode 参数指定特定模式时,Vite 会自动加载对应的环境变量文件。例如,build:github 命令会加载 .env.github 文件中的配置。

以下是常用的环境变量配置及其说明:

VITE_BASE=/              # 项目部署的相对路径,用于指定应用的基础 URL
VITE_ROUTE=browserRouter # 路由类型,决定应用使用的路由策略
VITE_MOCK_ENABLE=true    # 是否启用 Mock 数据服务,用于开发和测试
VITE_BASE_API=/api/      # API 请求的统一前缀,用于后端接口调用
VITE_CESIUM_TOKEN=###    # Cesium 地图服务的认证令牌

目录结构

vite-shadcn
├── .github/                     # GitHub 配置文件
│   ├── workflows/
│   │   └── main.yml            # CI/CD 工作流配置
│   ├── copilot-instructions.md  # Copilot 指令
├── .trae/                       # Trae IDE 规则
│   └── rules/
├── public/                      # 静态资源目录
├── src/                         # 源代码目录
│   ├── assets/                  # 静态资源
│   ├── components/              # 通用组件
│   │   ├── ext/                 # 扩展组件
│   │   ├── ui/                  # Shadcn UI 基础组件
│   │   ├── app-sidebar.tsx      # 应用侧边栏
│   │   ├── chart-area-interactive.tsx # 交互式面积图
│   │   ├── color-switcher.tsx   # 颜色切换器
│   │   ├── dialog-form.tsx      # 表单对话框
│   │   ├── group-tree-select.tsx # 分组树选择器
│   │   ├── nav-main.tsx         # 主导航
│   │   ├── nav-user.tsx         # 用户导航
│   │   ├── permission-tree-select.tsx # 权限树选择器
│   │   ├── permission-tree-single-select.tsx # 权限单选树
│   │   ├── permission-type.tsx  # 权限类型
│   │   ├── role-select.tsx      # 角色选择器
│   │   ├── section-cards.tsx    # 区域卡片
│   │   ├── sidebar-menutree.tsx # 侧边栏菜单树
│   │   ├── site-header.tsx      # 站点头部
│   │   └── ...                  # 更多组件
│   ├── hooks/                   # React Hooks
│   │   └── use-mobile.ts        # 移动端检测 Hook
│   ├── lib/                     # 工具库
│   │   ├── axios.ts             # Axios 配置
│   │   ├── dict.ts              # 字典工具
│   │   ├── fixLeafletIcon.ts    # Leaflet 图标修复
│   │   ├── notify.ts            # 通知工具
│   │   └── utils.ts             # 通用工具函数
│   ├── locale/                  # 国际化
│   │   ├── en-US.ts             # 英文翻译
│   │   └── zh-CN.ts             # 中文翻译
│   ├── mock/                    # Mock 数据
│   ├── pages/                   # 页面组件
│   │   ├── chart/               # 图表页面
│   │   ├── component/           # 组件示例页面
│   │   ├── dashboard/           # 仪表板页面
│   │   ├── system/              # 系统管理页面
│   ├── store/                   # 状态管理
│   ├── themes/                  # 主题色文件
│   ├── App.tsx                  # 应用根组件
│   ├── index.css                # 全局样式
│   ├── layout.tsx               # 应用布局
│   └── main.tsx                 # 应用入口
├── .env                         # 环境变量
├── .env.github                  # GitHub 环境变量
├── .gitignore                   # Git 忽略文件
├── .hintrc                      # Webhint 配置
├── CODE_OF_CONDUCT.md           # 行为准则
├── LICENSE                      # 许可证
├── components.json              # 组件配置
└── package.json                 # 项目配置

路由与菜单

路由示例(React Router v7):

//src\routes.ts
const routeSetting: NavItem[] = [
  {
    key: "dashboard",
    title: "menu.dashboard",
    icon: LayoutDashboard,
    children: [
      { key: "normal", title: "menu.dashboard.normal", icon: Gauge },
    ],
  },
];

路由配置包含四个核心参数:

  • key: 路由路径标识符,用于唯一确定导航目标
  • title: 国际化配置键值,用于多语言文本映射
  • icon: 菜单图标元素,用于视觉标识
  • children: 子菜单数组,用于构建嵌套导航结构

如下,是其中一个页面的配置示例:

  1. 配置路由dashboard\normal
//src\routes.ts
const routeSetting: NavItem[] = [
  {
    key: "dashboard",
    title: "menu.dashboard",
    icon: LayoutDashboard,
    children: [
      { key: "normal", title: "menu.dashboard.normal", icon: Gauge },
    ],
  },
];

2.国家化文件中配置title中的key

//src\locale\en-US.ts
export default {
    'menu.dashboard': 'Dashboard',
    'menu.dashboard.normal': 'Normal',
};
//src\locale\zh-CN.ts
export default {
    'menu.dashboard': '仪表盘',
    'menu.dashboard.normal': '普通仪表盘',
};

3.增加页面

src\pages\component\general\index.tsx

注意必须在index.tsx下。

4.mock权限增加

下面这段模拟的是获取当前用户权限,需要在这段代码里增加新增菜单的权限。

//src\mock\system\permission.ts
    http.get<{ id: string }>(
    "/api/system/permissions/detail/:id",

对应的function 是getPermissionList

//src\mock\system\permission.ts
function getPermissionList(locale: string) {
    const dataArray: Permission[] = [
            //supper menu permissions
            {id: '0000', parentId:'',order: 0, path: "/dashboard",type: "directory",name:localeMap[locale]['menu.dashboard'] },
            {id: '0001', parentId:'',order: 1, path: "/component", type: "menu",name:localeMap[locale]['menu.component'] },
            {id: '000100', parentId:'0001',order: 0, path: "/component/general", type: "menu",name:localeMap[locale]['menu.component.general'] },

component/general 页面对应的权限标识为 id: '000100',其中 type 字段表示权限类型:

  • directory: 目录权限,包含该目录下所有子菜单的访问权限
  • menu: 菜单项权限,仅控制当前菜单项的访问权限

国际化

配置示例(react-intl):

//src\locale\en-US.ts
export default {
    'menu.dashboard': 'Dashboard',
};
//src\locale\zh-CN.ts
export default {
    'menu.dashboard': '仪表盘',
};

页面使用示例:

import { useIntl } from "react-intl";

const { formatMessage } = useIntl();
<div>{formatMessage({ id: "menu.dashboard", defaultMessage: "Dashboard" })}</div>

模拟数据

项目使用 MSW 模拟数据,msw的引入代码如下

//src\main.tsx
const mockEnable = (import.meta.env.VITE_MOCK_ENABLE||'true')=='true';
if(mockEnable){
  initMSW().then(()=>{
    createRootElement();
  })
}else{
  createRootElement();
}

mock数据的入口在如下文件,如果要新增mock的话,参照如下代码新增一个handlers就行了

//src\mock\index.ts
import { setupWorker } from "msw/browser";
import groupHandlers from "./components/group";
import permissionHandlers from "./components/permission";
import loginUserHandlers from "./login/user";
import systemGroupHandlers from "./system/group";
import systemPermissionHandlers from "./system/permission";
import systemRoleHandlers from "./system/role";
import systemUserHandlers from "./system/user";
const mockHandlers = [
  ...loginUserHandlers,
  ...groupHandlers,
  ...permissionHandlers,
  ...systemUserHandlers,
  ...systemRoleHandlers,
  ...systemGroupHandlers,
  ...systemPermissionHandlers
];
let worker: ReturnType<typeof setupWorker> | null = null;
export default async function initMSW() {
  if (worker) return worker;
  worker = setupWorker(...mockHandlers);
  // 启动 MSW
  await worker.start({

    serviceWorker: {
      url: `${import.meta.env.BASE_URL}mockServiceWorker.js`,
      options: { type: 'module', updateViaCache: 'none' },
    },
    onUnhandledRequest: (req) => {
      if (!req.url.startsWith('/api')) {
        return // 直接跳过,不拦截
      }
    },
  });
  return worker;
}

权限控制

用户权限从userInfo中获取

import { useUserStore } from '@/store';
const { userInfo} = useUserStore();

系统权限管理包含以下概念:

  • rolePermissions: 角色权限集合,定义特定角色所拥有的权限
  • userPermissions: 用户权限集合,定义用户账户级别的权限
  • currentPermission: 当前生效权限,为用户权限与所选角色权限的并集
  • currentMenuPermission: 当前菜单权限,用于控制具体菜单项的显示
  • currentDirectoryPermission: 当前目录权限,用于控制目录节点的显示,拥有目录权限时自动获得其下所有子菜单权限

系统支持多角色管理模式。当用户选择"全部角色"时,系统将整合用户权限与所有角色权限的并集作为当前权限集,实现灵活的权限控制策略。

主题

1.新增主题色在src\themes下

2.新增主题色后,需要导入

//src\index.css
@import "@/themes/blue.css";
@import "@/themes/green.css";
@import "@/themes/orange.css";
@import "@/themes/red.css";
@import "@/themes/rose.css";
@import "@/themes/violet.css";
@import "@/themes/yellow.css";
  • 主题色切换

下面可以配置主题色,Color的字符串颜色和src\themes中的一致

//src\store\theme.ts
export type Color =
  | "default"
  | "blue"
  | "green"
  | "orange"
  | "red"
  | "rose"
  | "violet"
  | "yellow";
import {useThemeStore } from '@/store/index';
const {color,setColor} = useThemeStore();
setColor('blue')

去中心化预测市场实战开发:Solidity+Viem 从合约设计到工程化落地

前言

在 Web3 生态迈向 2026 年的新阶段,去中心化预测市场(Decentralized Prediction Market,DPM)早已突破单纯的博弈工具属性,成为融合群体智慧价格发现去中心化金融对冲现实世界事件价值映射的核心 DeFi 组件。与中心化预测平台相比,基于智能合约的 DPM 凭借链上透明、无需信任、资产自管的特性,成为 Web3 落地现实应用的重要载体。

本文将从底层核心逻辑出发,拆解去中心化预测市场的设计精髓,通过Solidity实现可落地的智能合约架构,并结合 Web3 开发新标椎Viem完成工程化测试,同时给出从基础版本到生产级应用的优化方向与技术栈选型,让开发者能够快速上手并搭建属于自己的去中心化预测市场。

一、核心定位与底层逻辑

1. 市场定位

2026 年的 DPM 已突破博弈属性,成为融合群体智慧价格发现去中心化金融对冲现实世界事件价值映射的核心 DeFi 组件,核心优势是链上透明、无需信任、资产自管。

2. 底层核心法则:抵押品守恒(1=1+1)

所有设计围绕 “1 单位抵押品 = 1 份 Yes 代币 + 1 份 No 代币” 展开,分三个核心环节:

环节 核心动作 价值逻辑
资产对冲 用户存入抵押品,合约 1:1 铸造 Yes/No 结果代币 抵押品等价于 Yes+No 代币组合,单一代币成为投注头寸
概率定价 Yes/No 代币二级市场自由交易 代币价格直接反映市场对事件结果的概率预期(如 Yes=0.7ETH→70% 发生概率)
最终结算 预言机提交结果,胜出代币 = 1 单位抵押品,失败代币归零 抵押品总量不变,用户销毁胜出代币赎回抵押品

二、技术实现(Solidity+Viem)

智能合约

IOutcomeToken接口合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IOutcomeToken is IERC20 {
    function mint(address to, uint256 amount) external;
    function burn(address from, uint256 amount) external;
}

OutcomeToken(结果代币)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./IOutcomeToken.sol";

contract OutcomeToken is ERC20, IOutcomeToken {
    address public immutable market;

    error OnlyMarketAllowed();

    constructor(
        string memory name, 
        string memory symbol, 
        address _market
    ) ERC20(name, symbol) {
        market = _market;
    }

    modifier onlyMarket() {
        if (msg.sender != market) revert OnlyMarketAllowed();
        _;
    }

    function mint(address to, uint256 amount) external onlyMarket {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external onlyMarket {
        _burn(from, amount);
    }
}

MockV3Aggregator(预言机合约):在本地开发和测试环境中部署模拟合约,而在正式生产环境的项目中,则使用 Chainlink 提供的 MockV3Aggregator 合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MockV3Aggregator4 {
    int256 private _price;
    constructor(int256 initialPrice) { _price = initialPrice; }
    function updatePrice(int256 newPrice) external { _price = newPrice; }
    function latestRoundData() external view returns (uint80, int256 price, uint256, uint256, uint80) {
        return (0, _price, 0, 0, 0);
    }
}

PredictionMarket(预测市场合约)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "./OutcomeToken.sol";

contract PredictionMarket is Ownable, ReentrancyGuard {
    IOutcomeToken public immutable yesToken;
    IOutcomeToken public immutable noToken;
    AggregatorV3Interface public immutable priceFeed;

    uint256 public immutable targetPrice;
    bool public resolved;
    bool public winningOutcome; // true = Yes, false = No

    event MarketMinted(address indexed user, uint256 amount);
    event MarketResolved(bool winningOutcome, int256 finalPrice);
    event Redeemed(address indexed user, uint256 amount);

    error AlreadyResolved();
    error NotResolved();
    error InsufficientBalance();

    constructor(
        address _priceFeed, 
        uint256 _targetPrice
    ) Ownable(msg.sender) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        targetPrice = _targetPrice;
        
        // 实例化 Outcome 代币
        yesToken = new OutcomeToken("Predict YES", "YES", address(this));
        noToken = new OutcomeToken("Predict NO", "NO", address(this));
    }

    /**
     * @notice 存入 ETH 铸造 1:1 的 Yes 和 No 头寸
     */
    function mintPositions() external payable nonReentrant {
        if (resolved) revert AlreadyResolved();
        if (msg.value == 0) revert InsufficientBalance();

        yesToken.mint(msg.sender, msg.value);
        noToken.mint(msg.sender, msg.value);

        emit MarketMinted(msg.sender, msg.value);
    }

    /**
     * @notice 调用 Chainlink 获取价格并结算市场
     */
    function resolveMarket() external onlyOwner {
        if (resolved) revert AlreadyResolved();

        (, int256 price, , , ) = priceFeed.latestRoundData();
        
        // 判定逻辑:当前价 > 目标价则 Yes 赢
        winningOutcome = uint256(price) > targetPrice;
        resolved = true;

        emit MarketResolved(winningOutcome, price);
    }

    /**
     * @notice 结算后,胜方销毁代币取回 1:1 的 ETH
     */
    function redeem(uint256 amount) external nonReentrant {
        if (!resolved) revert NotResolved();

        if (winningOutcome) {
            yesToken.burn(msg.sender, amount);
        } else {
            noToken.burn(msg.sender, amount);
        }

        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");

        emit Redeemed(msg.sender, amount);
    }

    receive() external payable {}
}

测试脚本

测试用例

  1. Minting (铸造头寸)
  2. Resolution & Redemption (结算与兑付)
  3. 当价格超过目标时,YES 持有者应能兑现
  4. 未结算前不应允许兑现
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { parseEther, formatEther } from 'viem';
import { network } from "hardhat";

describe("PredictionMarket", function () {
    let market: any, mockOracle: any;
    let yesToken: any, noToken: any;
    let publicClient: any;
    let owner: any, user1: any;
    let deployerAddress: string;

    const TARGET_PRICE = 3000n * 10n**8n; // 假设目标价 3000 (8位小数)
    const INITIAL_PRICE = 2500n * 10n**8n; // 初始价 2500

    beforeEach(async function () {
        const { viem } = await (network as any).connect();
        publicClient = await viem.getPublicClient();
        [owner, user1] = await viem.getWalletClients();
        deployerAddress = owner.account.address;

        // 1. 部署 Mock 预言机 (初始价格 2500)
        mockOracle = await viem.deployContract("MockV3Aggregator4", [INITIAL_PRICE]);
        
        // 2. 部署预测市场主合约
        market = await viem.deployContract("PredictionMarket", [
            mockOracle.address,
            TARGET_PRICE
        ]);

        // 3. 获取生成的 YES/NO 代币合约实例
        const yesAddr = await market.read.yesToken();
        const noAddr = await market.read.noToken();
        
        yesToken = await viem.getContractAt("OutcomeToken", yesAddr);
        noToken = await viem.getContractAt("OutcomeToken", noAddr);

        console.log(`市场部署成功: ${market.address}`);
    });

    describe("Minting (铸造头寸)", function () {
        it("应该允许用户存入 ETH 并获得 1:1 的 Yes/No 代币", async function () {
            const mintAmount = parseEther("1");

            // 执行铸造
            const hash = await market.write.mintPositions({
                value: mintAmount,
                account: user1.account
            });
            await publicClient.waitForTransactionReceipt({ hash });

            // 检查余额
            const userYesBalance = await yesToken.read.balanceOf([user1.account.address]);
            const userNoBalance = await noToken.read.balanceOf([user1.account.address]);

            assert.equal(userYesBalance, mintAmount, "YES 代币数量不匹配");
            assert.equal(userNoBalance, mintAmount, "NO 代币数量不匹配");
            
            console.log(`用户1 成功铸造: ${formatEther(userYesBalance)} YES & NO`);
        });
    });

    describe("Resolution & Redemption (结算与兑付)", function () {
        beforeEach(async function () {
            // 预先为 user1 铸造 2 ETH 的头寸
            await market.write.mintPositions({
                value: parseEther("2"),
                account: user1.account
            });
        });

        it("当价格超过目标时,YES 持有者应能兑现", async function () {
            // 1. 模拟价格上涨至 3500 (超过目标 3000)
            const newPrice = 3500n * 10n**8n;
            await mockOracle.write.updatePrice([newPrice]);

            // 2. 所有者结算市场
            await market.write.resolveMarket();
            
            const winningOutcome = await market.read.winningOutcome();
            assert.equal(winningOutcome, true, "应该是 YES 赢");

            // 3. 用户兑现 YES 代币
            const redeemAmount = parseEther("2");
            const balanceBefore = await publicClient.getBalance({ address: user1.account.address });

            const hash = await market.write.redeem([redeemAmount], {
                account: user1.account
            });
            await publicClient.waitForTransactionReceipt({ hash });

            // 4. 检查结果
            const yesBalanceAfter = await yesToken.read.balanceOf([user1.account.address]);
            const balanceAfter = await publicClient.getBalance({ address: user1.account.address });

            assert.equal(yesBalanceAfter, 0n, "兑现后代币应销毁");
            assert.ok(balanceAfter > balanceBefore, "用户余额应增加 (忽略 Gas)");
            
            console.log("✅ YES 胜出,用户成功兑回 ETH");
        });

        it("未结算前不应允许兑现", async function () {
            await assert.rejects(
                market.write.redeem([parseEther("1")], { account: user1.account }),
                /NotResolved/,
                "未结算时不应允许 redeem"
            );
        });
    });
});

部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const MockV3AggregatorArtifact = await artifacts.readArtifact("MockV3Aggregator4");
  const PredictionMarketArtifact = await artifacts.readArtifact("PredictionMarket");
  const TARGET_PRICE = 3000n * 10n**8n; // 假设目标价 3000 (8位小数)
 const INITIAL_PRICE = 2500n * 10n**8n; // 初始价 2500
  // 部署(构造函数参数:recipient, initialOwner)
  const MockV3AggregatorHash = await deployer.deployContract({
    abi: MockV3AggregatorArtifact.abi,//获取abi
    bytecode: MockV3AggregatorArtifact.bytecode,//硬编码
    args: [INITIAL_PRICE],//部署者地址,初始所有者地址
  });
   const MockV3AggregatorReceipt = await publicClient.waitForTransactionReceipt({ hash: MockV3AggregatorHash });
   console.log("预言机合约地址:", MockV3AggregatorReceipt.contractAddress);
//
const PredictionMarketHash = await deployer.deployContract({
    abi: PredictionMarketArtifact.abi,//获取abi
    bytecode: PredictionMarketArtifact.bytecode,//硬编码
    args: [MockV3AggregatorReceipt.contractAddress,TARGET_PRICE],//部署者地址,初始所有者地址
  });
  // 等待确认并打印地址
  const PredictionMarketReceipt = await publicClient.waitForTransactionReceipt({ hash: PredictionMarketHash });
  console.log("预测市场合约地址:", PredictionMarketReceipt.contractAddress);
}
main().catch(console.error);

三、生态价值与未来展望

在 2026 年的 Web3 生态中,去中心化预测市场不仅是 DeFi 的核心组件,更是连接链上与链下世界的重要桥梁,其生态价值早已超越单纯的预测功能

1. 核心生态价值

价值方向 具体应用场景
群体智慧价格发现 金融对冲、企业决策、政策制定的概率数据支撑
DeFi 生态补充 开发事件保险、对冲工具、合成资产等创新产品
DAO 治理工具 为 DAO 提案提供社区预期参考,提升治理科学性
RWA 价值映射 实现现实事件(大宗商品 / 体育赛事 / 宏观数据)的链上价值映射

2. 未来演进方向

  • 技术层面:结合 ZKP、乐观预言机、跨链技术,提升去中心化程度、降低参与门槛;
  • 待解决问题:合规性、流动性、用户教育;
  • 核心目标:实现 “群体智慧的价值化”,成为 Web3 落地现实应用的核心载体。

总结

  • 底层逻辑:DPM 的核心是 “1=1+1 抵押品守恒法则”,贯穿铸造、定价、结算全流程;

  • 技术实现:合约采用模块化设计,结合 OpenZeppelin 保障安全,Viem 替代 Ethers 实现高效测试 / 部署;

  • 生态价值:DPM 的核心价值是挖掘群体智慧、推动链上链下融合,而非单纯的博弈功能;

  • 拓展性:本文代码为基础框架,可基于此拓展 AMM(提升流动性)、ERC1155(多结果支持)等生产级功能。

Cursor 500MB 太重?试试这个 5MB 的 CLI 方案

Cursor 500MB 太重?试试这个 5MB 的 CLI 方案

为什么我放弃了 Cursor

上个月团队让我试用 Cursor。下载完 500MB 安装包后,我开始怀疑人生。

启动要 10 秒,打开大项目要 30 秒,内存占用 2GB+。我只是想让 AI 帮我写个脚本,为什么要装个这么重的 IDE?

后来发现了 Blade Code。

Blade Code 是什么

一个 5MB 的 Node.js CLI 工具,专门做一件事:让 AI 快速完成编程任务。

不是 IDE,不是编辑器,就是个命令行工具。

对比数据

维度 Cursor Blade Code
安装包大小 500MB 5MB (npm 包)
启动速度 10秒 1秒
内存占用 2GB+ 50MB
适用场景 完整开发环境 快速任务、脚本、自动化
学习成本 需要适应新 IDE 会用命令行就行
价格 $20/月 MIT 开源

真实场景

场景 1:快速重构代码

blade "把这个文件的所有 var 改成 let/const"

3 秒完成,不用打开 IDE。

场景 2:批量处理文件

blade "把 src/ 下所有 .js 文件加上 'use strict'"

20 个文件,5 秒搞定。

场景 3:生成测试用例

blade "给 utils.ts 生成单元测试"

自动分析代码,生成完整测试文件。

为什么这么快

  1. 无 GUI 开销 - 纯命令行,没有渲染负担
  2. 按需加载 - 只加载需要的工具
  3. 流式响应 - 边生成边输出,不等全部完成
  4. 轻量设计 - 核心只有几 MB

20+ 内置工具

Blade Code 不只是个 AI 对话工具,它内置了 20+ 实用工具:

  • 文件操作:读、写、搜索、批量处理
  • 代码分析:AST 解析、依赖分析
  • Shell 执行:安全的命令执行
  • Git 集成:提交、分支、历史查询
  • Web 搜索:实时查询最新信息

安全设计

很多人担心 AI 工具会误删代码。Blade Code 有三层保护:

  1. 权限控制 - 危险操作需要确认
  2. 工具白名单 - 只能用预定义的工具
  3. 操作日志 - 所有操作可追溯

5 分钟上手

安装

npm install -g blade-code

配置 API Key

blade config

支持 OpenAI、Claude、Gemini、国产大模型。

开始使用

blade "帮我重构这个函数"

就这么简单。

适合谁用

适合:

  • 需要快速完成小任务的开发者
  • 喜欢命令行的极客
  • 想要轻量级 AI 工具的人
  • 需要脚本化 AI 能力的场景

不适合:

  • 需要完整 IDE 功能的人
  • 不习惯命令行的人
  • 需要图形界面的场景

和其他工具对比

vs Cursor

  • Cursor:完整 IDE,适合长时间开发
  • Blade Code:快速任务,适合脚本化场景

vs GitHub Copilot

  • Copilot:代码补全,需要在编辑器里用
  • Blade Code:独立工具,可以批量处理

vs OpenCode

  • OpenCode:95K stars,功能全面但复杂
  • Blade Code:专注 CLI,简单直接

开源 + 可扩展

Blade Code 是 MIT 开源的,代码在 GitHub: github.com/echoVic/bla…

支持 MCP (Model Context Protocol),可以自己写插件扩展功能。

总结

如果你觉得 Cursor 太重,需要快速完成小任务,喜欢命令行,想要免费的 AI 编程工具,试试 Blade Code。

5MB,1 秒启动,MIT 开源。

项目地址:github.com/echoVic/bla…

虚拟列表:从定高到动态高度的 Vue 3 & React 满分实现

前言

在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 DOM 会导致严重的页面卡顿甚至崩溃。虚拟列表(Virtual List) 作为前端性能优化的“核武器”,通过“只渲染可视区”的策略,能将渲染性能提升数个量级。本文将带你从零实现一个支持动态高度的通用虚拟列表。

定高虚拟列表滚动.gif

一、 核心原理解析

虚拟列表本质上是一个“障眼法”,其结构通常分为三层:

  1. 外层容器(Container) :固定高度,设置 overflow: auto,负责监听滚动事件。
  2. 占位背景(Placeholder) :高度等于“总数据量 × 列表项高度”,用于撑开滚动条,模拟真实滚动的视觉效果。
  3. 渲染内容区(Content Area) :绝对定位,根据滚动距离动态计算起始索引,并通过 translateY 偏移到当前可视区域。

image.png


二、 定高虚拟列表

1. 设计思路

  • 可视项数计算Math.ceil(容器高度 / 固定高度) ± 缓冲区 (BUFFER)
  • 起始索引Math.floor(滚动距离 / 固定高度)
  • 偏移量起始索引 * 固定高度

2. Vue 3 + TailwindCSS实现

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:用于撑开滚动条,高度 = 总数据量 * 每项高度 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表:通过 transform 定位到滚动位置 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <div
            v-for="item in visibleList"
            :key="item.id"
            class="py-2 px-4 border-b border-gray-200"
            :class="{
              'bg-pink-200 h-[100px]': item.id % 2 !== 0,
              'bg-green-200 h-[100px]': item.id % 2 === 0,
            }"
          >
            {{ item.name }}
          </div>
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();

const ITEM_HEIGHT = 100; // 列表项固定高度(与样式中的 h-[100px] 一致)
const BUFFER = 5; // 缓冲区数量,避免滚动时出现空白

const virtualListRef = ref<HTMLDivElement | null>(null);

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动容器的滚动距离

// 总列表高度(撑开滚动条用)
const totalHeight = computed(() => ListData.value.length * ITEM_HEIGHT);

// 可视区域高度(滚动容器的高度)
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || 0;
});

// 可视区域可显示的列表项数量(向上取整 + 缓冲区)
const visibleCount = computed(() => {
  return Math.ceil(viewportHeight.value / ITEM_HEIGHT) + BUFFER;
});

// 当前显示的起始索引
const startIndex = computed(() => {
  // 滚动距离 / 每项高度 = 跳过的项数(向下取整)
  const index = Math.floor(scrollTop.value / ITEM_HEIGHT);
  // 防止索引为负数
  return Math.max(0, index);
});

// 当前显示的结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value;
  // 防止超出总数据长度
  return Math.min(end, ListData.value.length);
});

// 可视区域需要渲染的列表数据
const visibleList = computed(() => {
  return ListData.value.slice(startIndex.value, endIndex.value);
});

// 可视区域的偏移量(让列表项定位到正确位置)
const offsetY = computed(() => {
  return startIndex.value * ITEM_HEIGHT;
});

// 处理滚动事件
const handleScroll = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 返回首页
const goBack = () => {
  router.push('/home');
};

// 初始化
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
});
</script>

3. 实现效果图

定高虚拟列表滚动.gif


三、 进阶:不定高(动态高度)虚拟列表

在实际业务(如社交动态、聊天记录)中,每个 Item 的高度往往是不固定的。

1. 核心改进思路

  • 高度映射表(Map) :记录每一个 Item 渲染后的真实高度。
  • 累计高度数组(Cumulative Heights) :存储每一项相对于顶部的偏移位置。
  • ResizeObserver:利用该 API 监听子组件高度变化,实时更新映射表,解决图片加载或文本折行导致的位移。

2. Vue 3 + tailwindCSS 实现(子组件抽离)

子组件: 负责上报真实高度:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.name }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    name: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

父组件:核心逻辑

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:撑开滚动条 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <!-- 渲染子组件,监听高度更新事件 -->
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import VirtualListItem from './listItem.vue'; // 引入子组件

const router = useRouter();

const MIN_ITEM_HEIGHT = 100; // 子项预设的最小高度
const BUFFER = 5; //上下缓冲区数目
const virtualListRef = ref<HTMLDivElement | null>(null); // 滚动容器引用

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动距离
const itemHeights = ref<Map<number, number>>(new Map()); // 子组件高度映射表
const cumulativeHeights = ref<number[]>([0]); // 累计高度数组
const scrollTimer = ref<number | null>(null); // 滚动节流定时器
const isUpdatingCumulative = ref(false); // 累计高度更新防抖

// 初始化位置数据
const initPositionData = () => {
  // 初始化高度映射表(默认最小高度)
  const heightMap = new Map<number, number>();
  ListData.value.forEach((item) => {
    heightMap.set(item.id, MIN_ITEM_HEIGHT);
  });
  // 初始化累计高度
  updateCumulativeHeights();
};

// 更新累计高度(核心)
const updateCumulativeHeights = () => {
  if (isUpdatingCumulative.value) return;
  isUpdatingCumulative.value = true;

  const itemCount = ListData.value.length;
  const cumulative = [0];
  let sum = 0;

  for (let i = 0; i < itemCount; i++) {
    const itemId = ListData.value[i].id;
    sum += itemHeights.value.get(itemId) || MIN_ITEM_HEIGHT;
    cumulative.push(sum);
  }

  cumulativeHeights.value = cumulative;
  isUpdatingCumulative.value = false;
};

// 处理子组件的高度更新事件
const handleItemHeightUpdate = (id: number, height: number) => {
  // 高度未变化则跳过
  if (itemHeights.value.get(id) === height) return;

  // 更新高度映射表
  itemHeights.value.set(id, height);

  // 异步更新累计高度(避免同步更新导致的性能问题)
  nextTick(() => {
    updateCumulativeHeights();
  });
};

// 总高度,根据统计高度数组最后一个值计算得出
const totalHeight = computed(() => {
  return cumulativeHeights.value[cumulativeHeights.value.length - 1] || 0;
});

// 列表可视区域高度
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || MIN_ITEM_HEIGHT * 5;
});

// 计算起始索引
const startIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  if (totalItemCount === 0) return 0;
  if (scrollTop.value <= 0) return 0;

  let baseStartIndex = 0;
  // 反向遍历找起始索引
  for (let i = cumulativeHeights.value.length - 1; i >= 0; i--) {
    if (cumulativeHeights.value[i] <= scrollTop.value) {
      baseStartIndex = i;
      break;
    }
  }
  const finalIndex = Math.max(0, baseStartIndex - BUFFER); // 确保不小于0
  return Math.min(finalIndex, totalItemCount - 1);
});

// 计算结束索引
const endIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  const viewportHeightVal = viewportHeight.value;
  if (totalItemCount === 0) return 0;

  const targetScrollBottom = scrollTop.value + viewportHeightVal; // 目标滚动到底部位置
  let baseEndIndex = totalItemCount - 1;
  for (let i = 0; i < cumulativeHeights.value.length; i++) {
    if (cumulativeHeights.value[i] > targetScrollBottom) {
      baseEndIndex = i - 1;
      break;
    }
  }
  const finalEndIndex = Math.min(baseEndIndex + BUFFER, totalItemCount - 1); // 确保不大于总项数-1
  return finalEndIndex;
});

// 可见列表
const visibleList = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return start <= end ? ListData.value.slice(start, end + 1) : [];
});

const offsetY = computed(() => {
  return cumulativeHeights.value[startIndex.value] || 0;
});

// 滚动节流处理
const handleScroll = () => {
  if (!virtualListRef.value) return;

  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  scrollTimer.value = window.setTimeout(() => {
    scrollTop.value = virtualListRef.value!.scrollTop;
  }, 20);
};

const handleResize = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

const goBack = () => {
  router.push('/home');
};

// 生命周期
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
  initPositionData();
  window.addEventListener('resize', handleResize); // 监听窗口大小变化
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  isUpdatingCumulative.value = false;
  itemHeights.value.clear();
});
</script>

3. React + tailwindCSS 实现(子组件抽离)

子组件:

import React, { useEffect, useRef, useState, useCallback } from 'react';

interface VirtualListItemProps {
  item: {
    id: number;
    name: string;
  };
  onUpdateHeight: (id: number, height: number) => void; // 替代 Vue 的 emit
}

const VirtualListItem: React.FC<VirtualListItemProps> = ({
  item,
  onUpdateHeight,
}) => {
  const itemRef = useRef<HTMLDivElement>(null);
  // 存储 ResizeObserver 实例(避免重复创建)
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  // 计算并上报高度
  const sendItemHeight = useCallback(() => {
    if (!itemRef.current) return;
    const realHeight = itemRef.current.offsetHeight;
    onUpdateHeight(item.id, realHeight);
  }, [item.id, onUpdateHeight]);

  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);

    // 初始化 ResizeObserver 监听高度变化
    if (window.ResizeObserver) {
      resizeObserverRef.current = new ResizeObserver(() => {
        sendItemHeight();
      });
      if (itemRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }
    }

    // 清理定时器(对应 Vue 的 onUnmounted 部分)
    return () => {
      clearTimeout(timer);
      if (resizeObserverRef.current) {
        resizeObserverRef.current.disconnect();
        resizeObserverRef.current = null;
      }
    };
  }, [sendItemHeight]); // 仅首次挂载执行

  //监听 item 变化重新计算高度
  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);
    return () => clearTimeout(timer);
  }, [item.id, sendItemHeight]); // item.id 变化时执行

  const itemClass = `py-2 px-4 border-b border-gray-200 ${
    item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'
  }`;

  const itemStyle: React.CSSProperties = {
    height: item.id % 2 === 0 ? '150px' : '100px',
  };

  return (
    <div ref={itemRef} className={itemClass} style={itemStyle}>
      {item.name}
    </div>
  );
};

export default VirtualListItem;


父组件:

import React, {
  useEffect,
  useRef,
  useState,
  useCallback,
  useMemo,
} from 'react';
import VirtualListItem from './listItem';

const VirtualList: React.FC = () => {
  const MIN_ITEM_HEIGHT = 100; // 最小项高度
  const BUFFER = 5; // 缓冲区项数

  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用

  const [listData, setListData] = useState<Array<{ id: number; name: string }>>(
    []
  ); // 列表数据
  const [scrollTop, setScrollTop] = useState(0); // 滚动位置
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 高度映射表(Map 结构)
  const [cumulativeHeights, setCumulativeHeights] = useState<number[]>([0]); // 累计高度数组
  const scrollTimerRef = useRef<number | null>(null); // 滚动节流定时器

  // 初始化模拟数据
  const initData = () => {
    const mockData = Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      name: `Item ${index}`,
    }));
    setListData(mockData);
    // 初始化高度映射表(默认最小高度)
    const initHeightMap = new Map<number, number>();
    mockData.forEach((item) => {
      initHeightMap.set(item.id, MIN_ITEM_HEIGHT);
    });
    setItemHeights(initHeightMap);
    // 初始化累计高度
    updateCumulativeHeights(initHeightMap, mockData);
  };

  useEffect(() => {
    initData();
    // 监听窗口大小变化
    const handleResize = () => {
      if (virtualListRef.current) {
        setScrollTop(virtualListRef.current.scrollTop);
      }
    };
    window.addEventListener('resize', handleResize);

    // 清理监听
    return () => {
      window.removeEventListener('resize', handleResize);
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
      itemHeights.clear(); // 清空 Map 释放内存
    };
  }, []);

  // 更新累计高度(核心函数)
  const updateCumulativeHeights = useCallback(
    (heightMap: Map<number, number>, data: typeof listData) => {
      const cumulative = [0];
      let sum = 0;
      for (let i = 0; i < data.length; i++) {
        const itemId = data[i].id;
        sum += heightMap.get(itemId) || MIN_ITEM_HEIGHT;
        cumulative.push(sum);
      }
      setCumulativeHeights(cumulative);
    },
    [MIN_ITEM_HEIGHT]
  );

  // 处理子组件的高度更新事件(对应 Vue 的 handleItemHeightUpdate)
  const handleItemHeightUpdate = useCallback(
    (id: number, height: number) => {
      // 高度未变化则跳过
      if (itemHeights.get(id) === height) return;

      // 更新高度映射表
      const newHeightMap = new Map(itemHeights);
      newHeightMap.set(id, height);
      setItemHeights(newHeightMap);

      // 异步更新累计高度
      setTimeout(() => {
        updateCumulativeHeights(newHeightMap, listData);
      }, 0);
    },
    [itemHeights, listData, updateCumulativeHeights]
  );

  // 滚动节流处理
  const handleScroll = useCallback(() => {
    if (!virtualListRef.current) return;

    // 节流:20ms 内只更新一次 scrollTop
    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }
    scrollTimerRef.current = setTimeout(() => {
      setScrollTop(virtualListRef.current!.scrollTop);
    }, 20);
  }, []);

  // 可视区域高度
  const viewportHeight = useMemo(() => {
    return virtualListRef.current?.clientHeight || MIN_ITEM_HEIGHT * 5;
  }, []);

  //  总列表高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;
    if (scrollTop <= 0) return 0;

    // 反向遍历找起始索引
    let baseStartIndex = 0;
    for (let i = cumulativeHeights.length - 1; i >= 0; i--) {
      if (cumulativeHeights[i] <= scrollTop) {
        baseStartIndex = i;
        break;
      }
    }

    const finalIndex = Math.max(0, baseStartIndex - BUFFER);
    return Math.min(finalIndex, totalItemCount - 1);
  }, [
    scrollTop,
    viewportHeight,
    totalHeight,
    cumulativeHeights,
    listData.length,
  ]);

  // 结束索引
  const endIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;

    const targetScrollBottom = scrollTop + viewportHeight;
    let baseEndIndex = totalItemCount - 1;

    for (let i = 0; i < cumulativeHeights.length; i++) {
      if (cumulativeHeights[i] > targetScrollBottom) {
        baseEndIndex = i - 1;
        break;
      }
    }

    let finalEndIndex = baseEndIndex + BUFFER;
    finalEndIndex = Math.min(finalEndIndex, totalItemCount - 1);
    return finalEndIndex;
  }, [scrollTop, viewportHeight, cumulativeHeights, listData.length]);

  // 可视区列表
  const visibleList = useMemo(() => {
    return startIndex <= endIndex
      ? listData.slice(startIndex, endIndex + 1)
      : [];
  }, [startIndex, endIndex, listData]);

  // 偏移量
  const offsetY = useMemo(() => {
    return cumulativeHeights[startIndex] || 0;
  }, [startIndex, cumulativeHeights]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div className="bg-white mt-10 h-[calc(100vh-200px)] rounded-xl">
        {/* 滚动容器 */}
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
        >
          {/* 占位容器:撑开滚动条 */}
          <div style={{ height: `${totalHeight}px` }}></div>

          {/* 可视区域列表:transform 偏移 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{ transform: `translateY(${offsetY}px)` }}
          >
            {visibleList.map((item) => (
              <VirtualListItem
                key={item.id}
                item={item}
                onUpdateHeight={handleItemHeightUpdate}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

4. 实现效果图

动高虚拟列表滚动.gif


四、 总结与避坑指南

1. 为什么需要缓冲区(BUFFER)?

如果只渲染可见部分,用户快速滚动时,异步渲染可能会导致瞬间的“白屏”。设置上下缓冲区可以预加载部分 DOM,让滑动更顺滑。

2. 性能进一步优化

  • 滚动节流(Throttle) :虽然滚动监听很快,但在 handleScroll 中加入 requestAnimationFrame 或 20ms 的节流,能有效减轻主线程压力。
  • Key 的选择:在虚拟列表中,key 必须是唯一的 id,绝对不能使用 index,否则在滚动重用 DOM 时会出现状态错乱。

3. 注意事项

  • 定高:逻辑简单,性能极高。
  • 不定高:依赖 ResizeObserver,需注意频繁重排对性能的影响,建议对 updateCumulativeHeights 做异步批处理。

这 7 个免费 Lottie 动画网站,帮你省下一个设计师的工资

大家好,我是大华!

有时候写前端,会觉得:同样是写页面,为什么有些产品一看就很舒服,而自己写的界面,怎么看都觉得很笨重。

是他们用了什么高深的技术吗?

其实那些看起来很好看,很丝滑的动画,大多数项目只是用了 Lottie。

比如下面这种动画效果:

什么是Lottie动画?

Lottie 并不是某种前端框架,而是一种动画文件格式和播放方案。 设计师在 After Effects 里把动画做好,导出成 JSON 文件,前端或客户端只需要通过 Lottie 播放器加载,就能把动画渲染出来。

和 GIF 或视频相比,Lottie 的体积更小、更轻量。 它是矢量动画,体积小、放大不会失真,还可以通过代码控制播放、暂停、循环,甚至动态改颜色和速度。

怎么使用?

一、在 HTML / 原生页面中使用 Lottie

这是最简单、也是最通用的一种方式,适合官网、活动页、Demo 页面。

现在官方更推荐用 lottie-web + <lottie-player> 这种方式,基本零学习成本。

1️⃣ 引入 Lottie 播放器

直接在 HTML 里引入官方 CDN:

<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>

2️⃣ 在页面中使用

<lottie-player
  src="https://assets.lottiefiles.com/packages/lf20_x62chJ.json"
  background="transparent"
  speed="1"
  style="width: 300px; height: 300px;"
  loop
  autoplay>
</lottie-player>

这样,一个简单的动画就已经跑起来了,效果如下:

你可以简单理解为: Lottie = 一个自定义的 HTML 标签,src 指向 JSON 文件即可。

常用属性也很好记:

  • loop:是否循环
  • autoplay:是否自动播放
  • speed:播放速度
  • style:控制大小

如果你只是想加个加载动画、空状态动画,这种方式已经完全够用了。


二、在 Vue 项目中使用 Lottie

在 Vue 里,一般会直接使用 lottie-web,控制力更强,适合业务场景。

1️⃣ 安装依赖

npm install lottie-web

2️⃣ 在组件中使用

<template>
  <div ref="lottieContainer" style="width: 300px; height: 300px;"></div>
</template>

<script>
import lottie from 'lottie-web'

export default {
  mounted() {
    lottie.loadAnimation({
      container: this.$refs.lottieContainer,
      renderer: 'svg',
      loop: true,
      autoplay: true,
      path: '/lottie/loading.json' // 本地或远程 JSON
    })
  }
}
</script>

这样动画会在组件挂载完成后自动播放。

这里有几个关键点,理解了基本就不怕用了:

  • container:动画挂载的 DOM
  • renderer:一般用 svg
  • path:Lottie JSON 文件路径
  • loop / autoplay:控制播放行为

三、在 Vue 里怎么控制动画?

这也是 Lottie 相比 GIF 最大的优势之一。

你可以拿到动画实例,然后随意控制:

const animation = lottie.loadAnimation({
  container: this.$refs.lottieContainer,
  renderer: 'svg',
  loop: false,
  autoplay: false,
  path: '/lottie/success.json'
})

// 手动播放
animation.play()

// 暂停
animation.pause()

// 停止
animation.stop()

比如:

  • 提交成功后再播放动画
  • 请求完成才显示动效
  • 根据状态切换不同动画

简而言之:Lottie 可以让你在应用或网站中添加更流畅的动画效果,并且不会降低性能或占用所存储的空间。


免费 Lottie 动画网站

说实话,动画制作成本很高。没人会为了做一个弹跳的购物车图标就去组件整个特效团队。 幸运的是,网上有很多免费的 Lottie 动画,你可以直接把它们添加到你的应用或者网站里面。

下面这些网站,基本可以覆盖你 80% 的日常需求,而且不花钱。

1. LottieFiles(官方首选)

Lottie 的官方平台,也是大多数人找动画的第一站。 提供大量免费动画资源,支持在线预览和修改颜色,下载的就是标准 JSON 文件,用起来非常省心。

网站:lottiefiles.com/


2. IconScout

IconScout 本身就是一个大型设计素材站,其中的免费 Lottie 动画覆盖了大量 UI 场景,从加载动画到角色动效都有,风格也比较多样。

网站:iconscout.com/


3. Storyset(Freepik 出品)

Storyset 提供可在浏览器中直接编辑的插画和 Lottie 动画,你可以自己改颜色、搭配元素,然后导出。即使完全不懂设计,也能做出“像定制过”的效果。

网站:storyset.com/


4. LottieFlow

由 Webflow 社区维护的免费 Lottie 库,主打无水印、无会员限制。 动画以 Web 场景为主,但 JSON 文件在任何项目中都能用。

网站:finsweet.com/lottieflow


其他可选资源

如果你想多囤一些风格不同的动画,这些也可以顺手收藏:


Lottie 真正解决的,其实是页面体验的问题。 它让动画变得轻量、可控,也大幅降低了开发和设计之间的协作成本。

你不需要是 After Effects 高手,也不用写复杂的动画逻辑。 很多时候,只是下载一个 JSON 文件,放进项目里,页面立刻就会多一点质感。

本文首发于公众号:程序员大华,专注前端、Java开发,AI应用和工具的分享。关注我,少走弯路,一起进步!

为什么有的函数要用 call,有的却完全不用?

——从 this 设计本质理解 JavaScript 方法分类

在学习 JavaScript 的过程中,很多人都会卡在一个问题上:

为什么 Object.getPrototypeOf(obj) 不需要 call
Object.prototype.toString 却必须用 call

更进一步的问题是:

我怎么提前知道一个函数到底是“参数型函数”还是“this 型函数”?

本文将从设计层面而不是“记规则”的角度,彻底解释这个问题。


一、困惑的根源:我们混淆了两种“函数设计方式”

在 JS 里,函数只有一种语法形式,但实际上有两种完全不同的设计思路

1️⃣ 参数型函数(Parameter-based)

Object.getPrototypeOf(obj)
Object.keys(obj)
Math.max(1, 2, 3)

特点:

  • 操作对象 通过参数传入
  • 函数内部 不依赖 this
  • this 是谁 无关紧要

2️⃣ this 型函数(This-based)

obj.toString()
arr.push(1)
Object.prototype.toString.call(value)

特点:

  • 操作对象 来自 this
  • 函数内部 强依赖 this
  • 必须明确 this 指向谁

👉 是否需要使用 call,只取决于这一点


二、为什么 Object.getPrototypeOf 不需要 call?

先看调用方式:

Object.getPrototypeOf(left)

它的“设计意图”非常明确:

  • 要操作的对象是 left
  • left 已经作为参数传入
  • 函数内部只关心参数,不关心 this

可以把它理解为伪代码:

function getPrototypeOf(obj) {
  return obj.__proto__
}

👉 这是一个纯工具函数(utility function)

所以:

  • 不需要 call
  • call 反而多余

三、为什么 Object.prototype.toString 必须用 call?

再看这个经典写法:

Object.prototype.toString.call(value)

为什么不能直接这样?

Object.prototype.toString(value) // ❌

因为这个方法的设计是:

  • 没有参数
  • 要检查的对象只能来自 this

伪代码理解:

Object.prototype.toString = function () {
  return "[object " + 内部类型(this) + "]"
}

👉 如果你不告诉它 this 是谁,它根本不知道要检查什么。

这就是 必须使用 call 的根本原因


四、一个极其重要的判断标准(80% 准确)

看方法“挂在哪里”

✅ 挂在构造函数本身上的(参数型)

Object.keys
Object.getPrototypeOf
Array.isArray
Math.max

特点:

  • Object.xxx
  • Array.xxx
  • Math.xxx

👉 几乎一定是参数型函数


✅ 挂在 prototype 上的(this 型)

Object.prototype.toString
Array.prototype.push
Array.prototype.slice
Function.prototype.call

特点:

  • xxx.prototype.xxx
  • 操作“当前对象”

👉 几乎一定依赖 this


口诀总结(非常重要)

静态方法用参数,原型方法靠 this


五、最可靠的方法:一行代码验证

如果你真的不确定,直接用这一招。

验证是否依赖 this

const fn = Object.prototype.toString
fn() // ❌ 报错或结果异常

👉 没有 this 就不能工作 → this 型函数


验证是否依赖参数

const fn = Object.getPrototypeOf
fn({}) // ✅ 正常执行

👉 this 不重要 → 参数型函数


六、为什么不能“所有函数都用 call”?

技术上可以,但语义上是错误的

Object.getPrototypeOf.call(null, obj)

问题在于:

  • this 被完全忽略
  • 代码可读性变差
  • 违背 JS API 的设计初衷

👉 call 的存在是为了解决 this,而不是统一写法


七、总结一句话(博客结尾版)

JS 中是否使用 call
不取决于“函数高级不高级”,
只取决于“这个函数是否依赖 this”。


八、你现在卡住,其实非常正常

你现在遇到的不是“语法问题”,而是:

从“会用 JS” → “理解 JS 设计” 的过渡阶段

这是一个所有中高级 JS 开发者都必经的坎。

Vue3中非响应式的全局状态管理

vue项目中,一般说到状态管理,我们会想到pinia,它可以帮助我们管理需要在多个页面、组件间共享的数据,并且根据数据的更新触发相关的渲染更新。但如果是数据变化不会引起页面刷新的全局数据呢?

 比如我当前开发的项目中,需要在项目初始化之后获取对应的引擎实例,该实例提供相关api用于处理页面逻辑,但该实例并不会触发页面的更新,此时就需要一个 非响应式的全局状态管理 -- globalState

适用场景:
  • 全局配置、缓存、临时数据
  • 跨页面/组件的事件通知
  • 不需要响应式的数据共享
不适用场景
  • 需要数据变化时自动刷新页面的场景【用PiniaVuex
逻辑梳理:
  1.  定义一个globalState类,全局只有一个实例
  2. 维护一份state数据,提供setgethasdeleteclear
  3. 提供事件总线功能,用于在不同组件间“广播消息”: on【监听事件】、emit【触发事件】、off【移除监听】

具体实现代码:

/stores/globalState.ts

// 全局状态管理(非响应式)
/**
 * 跨页面/组件共享数据,但又不需要响应式(不需要自动刷新UI)。
 * 存储全局配置、缓存、临时数据等。
 * 避免污染 window,比直接用 window.xxx 更安全、可控、易维护。
 * 比 Pinia/Vuex 更轻量,适合存储不需要响应式的数据。
 */

class GlobalState {
    private static instance: GlobalState
    private state: Map<string, any> = new Map()
    private eventListeners: Map<string, Function[]> = new Map()

    static getInstance(): GlobalState {
        if(!GlobalState.instance) {
            GlobalState.instance = new GlobalState()
        }
        return GlobalState.instance
    }
    
    set(key: string, value: any): void {
        this.state.set(key, value)
    }

    get(key: string): any {
        return this.state.get(key)
    }

    has(key: string): boolean {
        return this.state.has(key)
    }

    delete(key: string): boolean {
        return this.state.delete(key)
    }

    clear(): void {
        this.state.clear()
    }

    // 新增事件总线功能
    on(eventName: string, callback: Function): void {
        if(!this.eventListeners.has(eventName)) {
            this.eventListeners.set(eventName, [])
        }
        this.eventListeners.get(eventName)?.push(callback)
    }
    
    emit(eventName: string, data?: any): void {
        const listeners = this.eventListeners.get(eventName)
        if(listeners) {
            listeners.forEach(callback => {
                try {
                    callback(data)
                } catch (error) {
                    console.error('事件回调执行错误:', error)
                }
            })
        }
    }

    off(eventName: string, callback?: Function): void {
        if (!callback) {
            this.eventListeners.delete(eventName)
        } else {
            const listeners = this.eventListeners.get(eventName)
            if (listeners) {
                const index = listeners.indexOf(callback)
                if (index > -1) {
                    listeners.splice(index, 1)
                }
            }
        }
    }

}

export const globalState = GlobalState.getInstance()
使用
  1. 组件A -- a.vue  【当引擎实例化成功后,设置引擎数据,并触发广播,在适合的时机销毁相关数据】
// a.vue 当引擎实例化成功后,设置引擎数据,并触发广播
import { globalState } from "@/stores/globalState";
const InstanceHasInited = (data) => {
      if (engine) {
            globalState.set("engineInstance", data);
            // 触发事件,通知其他组件
            globalState.emit("engineInstance:created", data);
      }
});
// 销毁相关数据
onUnmounted(() => {
  globalState.delete("engineInstance");
  globalState.off("engineInstance:created");
});
  1. 组件B -- b.vue  【在需要使用引擎api获取数据的位置添加监听】

import { globalState } from "@/stores/globalState";
const handleInstanceCreated = (engine: Engine) => {
    if(engine) {
        // 调用相关api
    }
}
onMounted(() => {
  // 监听引擎实例创建事件
  globalState.on("engineInstance:created", handleInstanceCreated);
})

挑战全栈框架的极限:仅 7kb 的 Lupine.js 发布了

Lupine.js:一款"极其"高效的 Web 框架

在一个被庞大的元框架 (Meta-frameworks) 和复杂构建链主导的世界里,Lupine.js 提出了一个简单的问题:如果我们能拥有现代全栈框架的威力,却不需要那些臃肿的负担,会怎样?

Lupine.js 是一个 轻量级 (7kb gzipped)全栈 Web 框架,它结合了类 React 的前端体验和类 Express 的后端架构。它是完全从零开始设计,旨在实现极致的速度、简洁和高效。

og-image.png

为什么选择 Lupine.js?

1. 🪶 极其轻量的前端

lupine.web 前端包极其小巧——仅 7kb gzipped。然而,它保留了你熟悉和喜爱的开发体验:TSX 语法 (React JSX)、组件和 Hooks。没有沉重的运行时需要下载,这意味着即使在慢速网络下,你的页面也能瞬间加载。

2. ⚡ 内置服务端渲染 (SSR)

大多数框架将 SSR 视为附加功能。在 Lupine 中,SSR 是 一等公民lupine.api 后端经过优化,能够自动在服务器上渲染你的前端页面。

  • 无样式闪烁 (No FOUC): 关键 CSS 由服务端注入。
  • 零配置 SEO: Meta 标签 (og:image, description) 在页面离开服务器前就已经计算完毕。
  • 社交分享就绪: 分享到 Twitter/微信/Facebook 的链接开箱即用,效果完美。

3. 🎨 原生 CSS-in-JS 引擎

告别配置 PostCSS、Tailwind 或 styled-components 的烦恼。Lupine 内置了一个强大的 CSS-in-JS 引擎。

  • 样式隔离: 样式自动隔离到你的组件。
  • 嵌套支持: 支持 .parent & 语法。
  • 高性能: 样式在 SSR 期间被高效提取和注入。
const Button = () => {
  const css = {
    backgroundColor: '#0ac92a',
    '&:hover': {
      backgroundColor: '#08a823',
    },
  };
  return <button css={css}>点击我</button>;
};

4. 🚀 全栈合一

Lupine 不仅仅是一个前端库;它是完整的应用解决方案。

  • 后端 (lupine.api): 一个高效、极简的 Node.js 框架,类似于 Express。
  • 前端 (lupine.web): 一个响应式的 UI 库。
  • 开发体验: 运行 npm run dev,即可在同一个 VS Code 会话中同时调试前端和后端。

快速开始

准备好尝试了吗?几秒钟就能搭建一个新的项目。

第一步:创建项目

使用我们的 CLI 工具创建一个新应用。

npx create-lupine@latest my-awesome-app

第二步:运行项目

进入目录并启动开发服务器。

cd my-awesome-app
npm install
npm run dev

访问 http://localhost:11080,你将看到你的第一个 Lupine 应用正在运行!

代码活跃度

Lupine 正在积极开发中。你可以直接在 GitHub 上查看我们的代码频率和贡献: 👉 github.com/uuware/lupi…

总结

Lupine.js 非常适合这样的开发者:

  • 掌控力: 想要了解技术栈的每一个部分。
  • 速度: 想为用户提供最快的体验。
  • 简洁: 没有隐藏的魔法,只有干净的代码。

Lupine.js 在 GitHub 上点个 Star,并在你的下一个项目中尝试一下吧!

TypeScript 泛型从轻松入门到看懂源码

从「完全不懂泛型」一路走到「看懂下面这段代码到底在干嘛」:

//此代为为VxeTable组件库Grid配置式表格数据分页示例代码部分片段
<script lang="ts" setup>
import { reactive } from 'vue'
import type { VxeGridProps, VxeGridListeners } from 'vxe-table'

interface RowVO {  
  id: number  
  name: string  
  role: string  
  sex: string  
  age: number  
  address: string
}

const gridOptions = reactive<VxeGridProps<RowVO>>({  
  showOverflow: true,  
  border: true,  
  loading: false,  
  height: 500,  
  pagerConfig: pagerVO,  
  columns: [    
    { type: 'seq', width: 70, fixed: 'left' },    
    { field: 'name', title: 'Name', minWidth: 160 },    
    { field: 'email', title: 'Email', minWidth: 160 },    
    { field: 'nickname', title: 'Nickname', minWidth: 160 },    
    { field: 'age', title: 'Age', width: 100 },    
    { field: 'role', title: 'Role', minWidth: 160 },    
    { field: 'amount', title: 'Amount', width: 140 },    
    { field: 'updateDate', title: 'Update Date', visible: false },    
    { field: 'createDate', title: 'Create Date', visible: false },  
  ],  
  data: [],
})
</script>

VxeTable组件库简介:

  • 由于这篇文章引用到了VxeTable组件库的代码,所以在这里给没接触过的小伙伴做一个简单的介绍,老司机可自行跳过。
  • VxeTable是一个基于 Vue 的表格组件库,提供表格、表单、工具栏、分页等组件,适合中后台场景。性能与功能都较强,但学习成本和按需引入的配置需要投入时间。如果你的项目以表格为核心,且需要虚拟滚动、复杂交互等功能,VxeTable 是合适的选择。感兴趣的小伙伴可以通过下方贴上的官网链接学习了解。

(PS·即使没用过VxeTable也不影响你看懂这篇文章)

官网链接:VxeTable官网

一、什么是泛型?一句话版本

泛型 = 给 “类型” 加参数。

  • 函数可以有参数:function fn(x: number) {}
  • 类型也可以有 “参数”:Array<string>

这里 Array 就是一个「带类型参数」的类型,<string> 就是「传给它的类型参数」。

用人话说:

泛型就是:我写一份通用的类型 / 函数,真正用的时候再告诉它具体用什么类型

二、最普通的一层泛型

1. 最熟悉的例子:数组

// 这俩是完全等价的
const list1: string[] = []
const list2: Array<string> = []
  • Array<T> 是一个泛型类型
  • T 是它的类型参数
  • Array<string> 表示「元素类型是 string 的数组」

2. 自己写一个泛型函数

function identity<T>(value: T): T {  
  return value
}
identity<number>(1)      // T 被替换成 number
identity<string>('hi')   // T 被替换成 string

你可以理解为:

  • 定义:identity<T>T 是一个「占位的类型」
  • 使用:identity<number> → 这次调用里「把 T 换成 number

或许有同学不理解为什么要在函数名称后面写<T>。不用纠结,这是固定的写法,就像你要使用变量,就要先声明一样,如:

let data = []
data.push(123)

如果此处没有声明data,便用不了data。同理如果不在函数名称后面写<T>声明一下这是泛型参数,TypeScript 无法识别 T 是什么,如下:

//  错误:找不到名称 'T'
function identity(value: T): T {
  return value
}
// 报错:Cannot find name 'T'

TypeScript 会把 T 当作一个未声明的类型,因此报错。

三、类型也可以是泛型:接口 /type

1. 泛型接口

// 使用时传入不同的 T
interface ApiResponse<T> {  
  code: number  
  msg: string  
  data: T
}

interface User {  
  id: number  
  name: string
}

const res1: ApiResponse<User> = {  
  code: 0,  
  msg: 'ok',  
  data: { id: 1, name: '张三' },
}

const res2: ApiResponse<string[]> = {  
  code: 0,  
  msg: 'ok',  
  data: ['a', 'b'],
}

观察:

  • ApiResponse<T> 自己并不知道 T 是啥
  • 真正用的时候写 ApiResponse<User> / ApiResponse<string[]>
  • TypeScript 在这一刻才把 T 替换掉

四、嵌套泛型:泛型里面再套泛型

其实很简单,就是「类型参数本身也是一个泛型类型」。

// 一层:数组里放字符串
Array<string>

// 两层:Promise 里放数组,数组里放字符串
Promise<Array<string>>

// 换个写法更直观
type StringArray = Array<string>
type StringArrayPromise = Promise<StringArray>

你可以这么想:

  • 第一层:Array<T>
  • 第二层:Promise<第一层>

五、回到文章最开始的例子:VxeGridProps<RowVO>

先看定义的行数据类型:

interface RowVO {  
  id: number  
  name: string  
  role: string  
  sex: string  
  age: number  
  address: string
}

然后:

const gridOptions = reactive<VxeGridProps<RowVO>>({...})

拆开理解:

  • VxeGridProps<D = any> 是 vxe-table 提供的泛型接口
  • 你写的是 VxeGridProps<RowVO>
  • 这一刻,D 就被替换成了 RowVO

也就是在这一整次使用里,可以把它脑补成:

// 伪代码,仅用于理解
interface VxeGridProps_RowVO extends VxeTableProps<RowVO> {  
  columns?: VxeGridPropTypes.Columns<RowVO>  
  proxyConfig?: VxeGridPropTypes.ProxyConfig<RowVO>  
  // ...
}

可能很多同学看到这里会感到些疑惑,怎么一会儿T一会儿D的。其实不管是T还是D都是类型变量的自定义名称,叫什么都无所谓,语法上没有任何固定含义,就像你写 JS 时给变量起名num/name/age一样,只是前端社区形成了「约定俗成的命名习惯」,用不同字母对应不同语义,让代码更易读。

字母 全称 含义/使用场景 例子
T Type 通用类型(最常用,无特殊语义时都用 T) first<T>(arr: T[])
D Default/Date 通常指 “默认类型” 或 “日期类型”(小众) 泛型接口里的默认类型:interface Config<D = string>
K KeyKey 表示对象的「键」类型 getKey<K extends string>(obj: { [k: K]: any }, key: K)
V Value 表示对象的「值」类型 Map<K, V>(TS 内置的 Map 泛型)
E Element 表示数组 / 集合的「元素」类型 Array<E>(TS 内置的数组泛型)
P Parameter 表示函数的「参数」类型 function wrap<P>(fn: (arg: P) => void, arg: P)

六、类型参数是怎么一层一层 “传下去” 的?

到这一步为了更好的理解泛型,我将带着同学们追溯源码。一起来追踪一下源码看看吧。

1. 第一层:VxeGridProps<D>

源码里(简化):

export interface VxeGridProps<D = any> extends VxeTableProps<D> {  
  columns?: VxeGridPropTypes.Columns<D>  
  proxyConfig?: VxeGridPropTypes.ProxyConfig<D>  
  // ...
}

当你用 VxeGridProps<RowVO>

  • extends VxeTableProps<D> → 变成 extends VxeTableProps<RowVO>
  • columns?: Columns<D> → 变成 columns?: Columns<RowVO>
  • proxyConfig?: ProxyConfig<D> → 变成 proxyConfig?: ProxyConfig<RowVO>

记忆:哪里写了 <D>,就会被替换成 <RowVO>

2. 第二层:Columns<D> = Column<D>[]

export namespace VxeGridPropTypes {  
  export type Column<D = any> = VxeTableDefines.ColumnOptions<D>  
  export type Columns<D = any> = Column<D>[]
}

当你用的是 Columns<RowVO> 时:

  • Columns<D>Columns<RowVO>
  • = Column<D>[] 这一行里的 D 同样被替换成 RowVO,变成:
  • Columns<RowVO> = Column<RowVO>[]

接着:

  • Column<D> = VxeTableDefines.ColumnOptions<D>
  • 也会变成:Column<RowVO> = VxeTableDefines.ColumnOptions<RowVO>

所以:

columns 的每一项类型就是 ColumnOptions<RowVO>

七、第三层:ColumnOptions<D>D 真正用在哪里?

export interface ColumnOptions<D = any> extends VxeColumnProps<D> {  
  children?: ColumnOptions<D>[]  
  slots?: VxeColumnPropTypes.Slots<D>
}

继续替换:

  • ColumnOptions<D>ColumnOptions<RowVO>
  • extends VxeColumnProps<D> → 变成 extends VxeColumnProps<RowVO>
  • children?: ColumnOptions<D>[] → 变成 children?: ColumnOptions<RowVO>[]
  • slots?: Slots<D> → 变成 slots?: Slots<RowVO>

关键点:ColumnOptions<RowVO> 本身定义了「列配置」的结构它继承的 VxeColumnProps<RowVO> + Slots<RowVO> 等地方,会在「需要行数据的回调」里用到 RowVO,比如:

formatter(params: { row: RowVO; ... })
className(params: { row: RowVO; ... })

八、我在学习时候的疑惑?

我当时并不理解TypeScript 做的是统一替换

// 把 D 换成 RowVO:
type Columns<RowVO> = Column<RowVO>[]

// 再把 Column 展开:
type Column<RowVO> = VxeTableDefines.ColumnOptions<RowVO>

// 合起来就是:
type Columns<RowVO> = VxeTableDefines.ColumnOptions<RowVO>[]

就拿文章示例的代码来看,TypeScript 会把函数体中所有的<T>都 替换成你制定的类型。

不理解的代码:

export type Column<D = any> = VxeTableDefines.ColumnOptions<D>
export type Columns<D = any> = Column<D>[]

我当特别不能理解 Column<D>[]<D>是怎么变成<RowVO>的。直到我明白了TypeScript会做统一替换,根本不是按数据传参的逻辑去做的。

九、把整个链路串起来(从外到内)

你写了:

reactive<VxeGridProps<RowVO>>({...})

于是:

VxeGridProps<D> → VxeGridProps<RowVO>
extends VxeTableProps<D> → extends VxeTableProps<RowVO>
columns?: Columns<D> → columns?: Columns<RowVO>

然后:

Columns<D> = Column<D>[] → Columns<RowVO> = Column<RowVO>[]
Column<D> = ColumnOptions<D> → Column<RowVO> = ColumnOptions<RowVO>

再往下:

ColumnOptions<D> extends VxeColumnProps<D> → ColumnOptions<RowVO> extends VxeColumnProps<RowVO>

最终效果:

  • data 的类型是:RowVO[]
  • 所有回调里涉及「行数据」的地方,类型参数是 RowVO

十、总结这次案例

  • RowVO:描述 “一行数据长什么样”
  • VxeGridProps<RowVO>:告诉表格「我的每一行数据都是 RowVO
  • 泛型参数 <RowVO> 会一层层往下传,凡是类型里写了 <D> 的地方,就会变成 <RowVO>

你现在已经不是 “不懂泛型的小白” 了,你已经能:

  • 看懂「类型参数是怎么一层一层传下去的」
  • 顺着 VxeGridProps<RowVO> → Columns<RowVO> → ColumnOptions<RowVO> 这一整条链路往下追

这就已经是非常扎实的泛型理解了。

总结

  1. 泛型的核心是「给类型加参数」,使用时再指定具体类型,如 Array<string>VxeGridProps<RowVO>
  2. 嵌套泛型的本质是「类型参数本身也是泛型」,参数会逐层传递替换(DRowVO);

以上便是对泛型的分享,欢迎大家指正讨论,与大家共勉。

从Vue到Bevy与Nestjs:我为 Vue 构建了一套“无头”业务引擎

不知道大家是否见过那种动辄几千行、逻辑像乱麻一样缠绕的 .vue 文件?

笔者在许多开源项目和企业级项目里都见过类似的现象:各种 watch 互相套娃、生命周期里塞满异步逻辑、父子组件传值传到怀疑人生。当项目进入中后期,Vue 的响应式系统仿佛从‘利器’变成了‘诅咒’,每一行代码的改动都像是在玩扫雷。

这种“面条代码”的泛滥让我开始反思:当下的前端开发范式,真的能支撑起当今逻辑爆炸的复杂应用吗?


起初,我以为这种混乱只是人为因素——觉得只要通过规范的 Code Review、靠着开发者的自觉,就能压制住代码的腐烂。但随着项目规模的膨胀,我推翻了自己的想法。我发现 Vue 的 API 仿佛自带一种传染性

只要你的业务代码中还直接调用着 refwatchonMounted这些Vue最核心的功能,业务逻辑就不可避免地会向 UI 框架低头成为UI的附庸。今天为了省事顺手写下的每一个 watchcomputed,都是为未来的“谁改了我的变量”埋下伏笔。Vue的这种‘响应式链路’在项目初期极度丝滑,但在项目后期就是噩梦的开始。

直到最后,我发现一个几乎无法避开的实事:只要 UI 框架还掌握着状态的‘修改权’,业务代码就几乎注定会退化成面条。 于是我开始意识到,我必须从物理层面给 Vue 的权力‘断供’。这便是我设计 Virid 的初衷:我要的不是更优雅地写 Vue 代码的方法,而是一套根本不属于 Vue 的全新世界。


在这样的理念的推动下,我产生了一个极其激进的想法:**让逻辑彻底从 UI 中剥离,构建一套完全"无头"(Headless)的业务引擎。**当我将目光投向 Rust 的 Bevy ECS 架构NestJS 的 IoC 依赖注入时,我发现了我自己的答案。

Bevy 是 Rust 圈子里最硬核的开源项目之一,它的 ECS 系统美得像艺术品。但可惜它为游戏而生,天然自带高频 Tick,直接挪到前端开发中会显得格格不入。NestJS 是 JS 领域里依赖注入最成熟的实践。我一直在思考,如果能用 NestJS 的手感去写一套 Bevy 式的解耦逻辑,会发生什么?@Virid/core 就是这个思考的答案。它剔除了多余的资源损耗,保留了最核心的架构美感。


站在巨人的肩膀上,我为前端量身定制了一套“带帧双缓冲与优先级调度的消息中心”。

它绝非简单的 Event Bus 或 Pub/Sub 模式所能比拟。它本质上是一个融合了 NestJS 依赖注入Bevy调度核心的精密系统。通过帧双缓冲机制,它彻底消除了前端逻辑中常见的"竞态条件"与"状态踩踏";配合优先级调度,它确保了每一条业务指令都能在最合适的时间节拍里执行。

要使用@Virid/core,只需要简单的三步走。首先派生一个自己的消息。他可以携带任何你想要发送的数据。

// 初始化核心引擎
const app = createVirid();
// 派生一个自己的消息
class IncrementMessage extends SingleMessage {
  constructor(public amount: number) {
    super();
  }
}

接着,定义自己的Component并注册他,这是“数据中心”,他只负责存储数据,除此之外没有任何逻辑。

@Component()
class CounterComponent {
  public count = 0;
}
// 注册这个数据组件
app.bindComponent(CounterComponent);

最后,编写一个自己的system。他是纯静态的、不需要任何注册与调用,只需要编写他需要的参数。@Virid/core将会自己发现并在合适的时候调用它。

//定义系统
class CounterSystem {
  //默认优先级
  //无需任何操作,只要定义好后@Virid/core将会自动将system与对应的消息类型挂钩
  //当接收到对应的消息之后,@Virid/core将会注入所有需要的参数,自动执行整个system
  @System()
  static onIncrement(
    @Message(IncrementMessage) message: IncrementMessage,
    count: CounterComponent,
  ) {
    console.log("---------------------onIncrement----------------------");
    console.log("message :>> ", message);
    count.count += message.amount;
  }
   //设置一个很高的优先级
  @System(100)
  static onIncrementPriority(
    @Message(IncrementMessage) message: IncrementMessage,
    count: CounterComponent,
  ) {
    console.log(
      "---------------------onIncrementPriority----------------------",
    );
    console.log("message :>> ", message);
    count.count += message.amount;
  }
}

在任何地方,只要发送消息,onIncrement将会被自动调用。而且由于帧双缓冲机制,其天然自带防抖功能。

IncrementMessage.send(1);//这个消息将会被合并(如果使用EventMessage派生则不会被合并)
IncrementMessage.send(5);
//只需要发送上面的消息,CounterComponent将会被自动注入onIncrementPriority与onIncrement的调用中
//因为优先级的存在,控制台会先后显示onIncrementPriority与onIncrement的执行流程
//---------------------onIncrementPriority----------------------
//message :>>  IncrementMessage {
//  amount: 5
//}
//---------------------onIncrement----------------------
//message :>>  IncrementMessage {
//  amount: 5
//}

通过这种方式,业务逻辑、UI、数据三者能够彻底解耦,我们将不会再需要Vue做任何事情来介入业务,只要触发一个合适的信号,所有的系统将会自动合适的调用,并且调度系统将会严格保证执行的先后顺序。通过这样的设计,配合几个生命周期钩子。可以轻而易举的实现undo/redo与消息跟踪功能,这是在普通的Vue中难以做到的事。

由于 System 和 Component 都是纯粹的逻辑和数据,你可以在完全不启动浏览器、不渲染 Vue 组件的情况下,对业务逻辑进行 100% 的单元测试


解决了业务逻辑放和数据在哪儿的问题,剩下的就是解决与Vue之间的黏合问题。如何利用Vue的响应式和各种API,优雅的让我们的核心数据投影到UI上?在这个过程中,我创造了@virid/vue和大量的核心概念。

要控制Vue,我们需要一个“代理人”(Controller)来做这件事。让他负责充当ViridVue之间的沟通人。他将会全权接管Vue的所有操作,并统一转发给System。于是,Vue文件中将会只剩下一行script代码(以一个音乐列表的播放为例)。

<template>
  <div>
    <div>This is a playlist page with many songs</div>
    <div v-for="(item, index) in plct.playlist" :key="item.id">
      <Song :index="index"></Song>
    </div>
  </div>
</template>
<script setup lang="ts">
  import Song from "./Song.vue";
  import { useController } from "@virid/vue";
  import { PlaylistController } from "@/logic/controllers/playListController";
  const plct = useController(PlaylistController, { id: "playlist" });
</script>
<style lang="scss" scoped></style>

在普通的Vue中,业务逻辑与UI逻辑往往掺杂在一起,但是在Virid的核心调度之下我们拥有了一个全新的选择:让Vue永远只负责UI的显示与绘制,将业务逻辑转交给@Virid/core

为了兼容响应式,我引入了响应式装饰器@Responsive(),只要给任何变量打上这个装饰器,当我们访问的时候,其将会被Virid自动转换成Vue的响应式变量。这意味着我们可以直接告诉Virid,那些变量是需要响应式的。

@Component()
export class PlaylistComponent {
  // 当前正在播放的歌,第一次访问时将会被Virid转化为响应式变量
  @Responsive()
  public currentSong: Song = null!
  // 歌单列表,第一次访问时将会被Virid转化为响应式变量
  @Responsive()
  public playlist: Song[] = []
}

@Project()是一个非常强大的“桥梁”。使得Controller能够直接访问任何Component上的属性,同时将其转化为只读的。这意味着一个Controller能够任意观察Component中的数据,从而更新Vue组件,同时只读保证了Component数据的安全。

@Listener()装饰器用于“偷听”一个消息,但是与System不同的是,其只能偷听一种派生自ControllerMessage类型的消息,并且无法享受依赖注入的功能,这意味着一个Controller不能直接更改Component

@OnHook('onSetup')装饰器告诉Virid,需要在Vue的什么生命周期自动调用下面这个方法。Virid将会在合适的时机自动调用被修饰的方法。

@Watch()是一个在Vue原版上,融合了Virid特点的更强大的功能,其不仅能够检测Controller自身响应式变量的变化。还能够监测任意一个Component上的变量。但是,因为**@Watch()**中只能更改Controller自身的变量,因此其仍然无法修改任何Component

export class SongControllerMessage extends ControllerMessage {
  //到底是哪一首歌发来的消息?索引
  constructor(public readonly index: number) {
    super()
  }
}

@Controller()
export class PlaylistController {
   //告诉Virid自动将playlist变为响应式的
  @Responsive()
  public playlist!: Song[]
    
  //创建一个投影,从component中映射数据
  @Project(PlaylistComponent, (i) => i.currentSong)
  public currentSong!: Song

  @Listener(SongControllerMessage)
  onMessage(@Message(SongControllerMessage) message: SongControllerMessage) {
    console.log('song', this.playlist[message.index])
    //可以做一些操作统一拦截,或者直接调用播放器
    PlaySongMesage.send(this.playlist[message.index])
  }
    
  @OnHook('onSetup')
  async getPlaylist() {
    //在这里可以获取数据,例如从服务器获取数据,这里模拟一下
    await new Promise((resolve) => setTimeout(resolve, 1000))
    this.playlist = [
      new Song('歌曲1', '1'),
      new Song('歌曲2', '2'),
      new Song('歌曲3', '3'),
      new Song('歌曲4', '4'),
      new Song('歌曲5', '5'),
      new Song('歌曲6', '6'),
      new Song('歌曲7', '7')
    ]
  }
  //观测当前歌曲,如果变了就触发某些操作
  @Watch(PlaylistComponent, (i) => i.currentSong, {})
  watchCurrentSong() {
    console.log('监听到当前歌曲改变PlaylistComponent:', this.currentSong)
  }
}

对于每一首歌,我们同样需要创建一个对应的Controller来充当我们和Virid的代理人,但是与此同时,每一个Song组件也需要和父Playlist组件通讯。因此我创建了一些更强大工具。

在.Vue文件中,我们传递了这样的变量,但是!**我们只传递了Song组件的索引,并没有传递item本身。**因此,我们需要某种方式获得index,并且还要能够访问到父组件的属性。

<div v-for="(item, index) in plct.playlist" :key="item.id">
  <Song :index="index"></Song>
</div>

@Env()是一个用于标记的标记装饰器。当你在子组件的Controller中标记一个属性为 @Env()时,Virid将会负责将其安装到这个属性上,这意味着你不需要自己定义props,按需声明取用即可

@Inherit()是一个类似@Project()的工具,如果说@Project()ControllerComponent之间的只读桥梁。那么@Inherit()就是ControllerController之间的只读桥梁。@Inherit 彻底终结了前端组件通信中冗长的 Emit/Props 链路。它建立了一个虫洞,让子组件可以直接观察到远方父组件的状态的同时,无法对父组件产生任何副作用污染。

通过@Inherit()你可以从任意组件内“继承”任意Controller的状态,同时,他也是只读的,这保证了一个Controller永远无法偷偷修改另一个Controller中数据的权利,当另一个Controller因为组件卸载而销毁的时候,这样的连接将会自动断开,类似于一种WeakRef。

通过@Inherit()@Project(),我们可以实现非常强大的功能,不需要父组件给我们提供任何数据,Song将会自己知道哪个数据才是自己应该得到的。

@Controller()
export class SongController {
  @Env()
  public index!: number
  @OnHook('onSetup')
  public onSetup() {
    console.log('我的索引是:', this.index)
  }
  @Inherit(PlaylistController, 'playlist', (i) => i.playlist)
  public playlist!: Song[]

  @Project<SongController>((i) => i.playlist?.[i.index])
  public song!: Song

  playThisSong() {
    //其实直接播放也行,但是这里我们模拟一下需要发送给父组件让父组件处理的情况
    console.log('发送播放消息:', this.index)
    SongControllerMessage.send(this.index)
  }
}

最终,消息将在System中得到处理,从此整个Virid将得到完整的闭环。

//当Playlist调用 PlaySongMesage.send(this.playlist[message.index])时
//整个系统将被激活,从而更新正确的数据
@System()
  static playThisSong(
    @Message(PlaySongMesage) message: PlaySongMesage,
    playlist: PlaylistComponent,
    player: PlayerComponent
  ) {
    //把这首歌添加到playlist里,如果没有的话
    playlist.playlist.push(message.song)

    //开始播放这首歌
    playlist.currentSong = message.song
    player.player.play(message.song)
    //自动发送新消息,记录
    return new IncreasePlayNumMessage()
  }

Virid 不是为了消灭 Vue,而是为了解决业务逻辑被耦合在UI中的问题。它可能不适合所有的 Todo-list,但它一定适合那些让你夜不能寐的复杂系统。

项目地址:github.com/ArisuYuki/v…

Flutter ——流式布局(Wrap)

Flutter 中 Wrap组件是解决 Row/Column 溢出问题的另一种重要方案,下面从核心作用、基础用法、核心属性、实战场景和对比 Row 这几个方面,给你做全面且易懂的讲解。

一、Wrap 核心作用

Wrap 是流式布局组件,和 Row/Column 最大的区别是:

  • Row/Column 子组件总尺寸超过父容器时会溢出(出现警告);
  • Wrap 子组件总尺寸超过父容器时会自动换行 / 换列,不会溢出。

简单说:Wrap 就是 “可以自动换行的 Row” 或 “可以自动换列的 Column”。

二、基础用法

先看一个最基础的示例,直观感受 Wrap 的效果:

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("哈哈"),
        ),
        body: Padding(padding: EdgeInsetsGeometry.all(10),
          child: Wrap(
            children: [
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('1')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('2')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('3')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('4')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('5')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('6')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('7')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('8')),
              )
            ],
          ),
        ),
      ),
    );
  }
}
图片.png

效果:8 个 80x80 的容器会先在第一行排列,当剩余宽度不够放下下一个容器时,自动换行到第二行,完全适配父容器宽度,无溢出。

三、核心属性详解

Wrap 的属性和 Row/Column 高度相似,但新增了换行相关的属性:

属性 作用 常用值
direction 排列方向(主轴) Axis.horizontal(默认,水平)/ Axis.vertical(垂直)
alignment 主轴方向的对齐方式(单行 / 列的对齐) WrapAlignment.start(默认)/ center/ end/ spaceBetween/ spaceAround/ spaceEvenly
crossAxisAlignment 交叉轴方向的对齐方式(行 / 列之间的对齐) WrapCrossAlignment.start(默认)/ center/ end
runAlignment 多行 / 多列整体的对齐方式 WrapAlignment.start(默认)/ center/ end/ 等
spacing 主轴方向子组件之间的间距 数值(如 8.0)
runSpacing 交叉轴方向(行 / 列之间)的间距 数值(如 8.0)
children 子组件列表 Widget 数组

关键属性实战示例

Wrap(
  direction: Axis.horizontal, // 水平排列
  alignment: WrapAlignment.spaceBetween, // 单行内两端对齐
  runAlignment: WrapAlignment.center, // 多行整体居中
  crossAxisAlignment: WrapCrossAlignment.center, // 行内垂直居中
  spacing: 10, // 水平子组件间距 10
  runSpacing: 15, // 行与行之间的间距 15
  children: [
    Container(width: 70, height: 70, color: Colors.red),
    Container(width: 70, height: 80, color: Colors.green),
    Container(width: 70, height: 70, color: Colors.blue),
    Container(width: 70, height: 70, color: Colors.yellow),
    Container(width: 70, height: 70, color: Colors.purple),
    Container(width: 70, height: 70, color: Colors.yellow),
    Container(width: 70, height: 70, color: Colors.purple),
  ],
)
图片.png

四、常见使用场景

Wrap 是 Flutter 中实现 “标签流、按钮流、网格标签” 的首选组件,以下是两个高频实战场景:

场景 1:标签列表(最经典用法)

dart

// 模拟动态标签列表
Wrap(
  spacing: 8, // 标签之间的水平间距
  runSpacing: 8, // 行之间的垂直间距
  children: [
    // 标签组件封装
    _buildTag('Flutter'),
    _buildTag('Dart'),
    _buildTag('Android'),
    _buildTag('iOS'),
    _buildTag('前端'),
    _buildTag('移动端'),
    _buildTag('跨平台'),
    _buildTag('布局'),
    _buildTag('组件'),
  ],
)

// 封装标签 Widget
Widget _buildTag(String text) {
  return Container(
    padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
    decoration: BoxDecoration(
      color: Colors.grey[200],
      borderRadius: BorderRadius.circular(20),
    ),
    child: Text(text),
  );
}

五、Wrap vs Row/Column 核心对比

特性 Wrap Row/Column
溢出处理 自动换行 / 换列,无溢出 直接溢出,出现警告
空间占用 仅包裹子组件(mainAxisSize 固定为 min) 可设置 max/min,默认 max
适用场景 动态数量的子组件(标签、按钮) 固定数量的子组件(导航栏、表单行)
性能 略优(无需计算溢出) 需计算主轴空间,溢出时性能无影响

六、常见问题与注意事项

  1. Wrap 中使用 Expanded 无效:Expanded 是配合 Flex(Row/Column)的弹性布局组件,Wrap 不支持弹性分配空间,因此在 Wrap 的 children 中用 Expanded 不会有任何效果。

  2. 控制 Wrap 整体的宽度 / 高度:如果想让 Wrap 占满父容器宽度(而非仅包裹子组件),可以给 Wrap 包裹一个 Container 并设置宽度:

    dart

    Container(
      width: double.infinity, // 占满父容器宽度
      child: Wrap(/* ... */),
    )
    

总结

  1. 核心定位:Wrap 是流式布局,解决 Row/Column 溢出问题,子组件超出父容器时自动换行 / 换列。

  2. 核心属性spacing(子组件间距)、runSpacing(行 / 列间距)、direction(排列方向)是最常用的三个属性。

  3. 使用技巧

    • 动态数量的标签、按钮优先用 Wrap;
    • Wrap 不支持 Expanded,无需尝试弹性分配空间;
    • 控制间距优先用 spacing/runSpacing,而非子组件的 margin(更统一)。

掌握 Wrap 布局,就能轻松实现 Flutter 中绝大多数 “流式排列” 的 UI 场景,是替代 Row/Column 解决溢出问题的最佳选择。

Flutter——弹性布局(Flex、Expanded)

弹性布局(Flex)允许子组件按照一定比例来分配父容器空间。

一、核心关系与基础概念

首先要明确一个关键关系:Row 和 Column 都是 继承自Flex 组件的 ——

  • Row = Flex(direction: Axis.horizontal)(水平弹性布局)
  • Column = Flex(direction: Axis.vertical)(垂直弹性布局)

Expanded 是配合 Flex(包括 Row/Column)使用的 “空间分配器”,用于控制子组件在弹性布局中占据的空间比例。

1. Flex 组件的基础用法

Flex 是弹性布局的基类,直接使用的场景较少(优先用 Row/Column),但理解它能帮你掌握弹性布局的本质:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Flex & Expanded 详解')),
        body: Padding(
          padding: const EdgeInsets.all(20),
          // 基础 Flex 布局(水平方向,等价于 Row)
          child: Flex(
            direction: Axis.horizontal, // 布局方向:horizontal(水平)/vertical(垂直)
            children: [
              Container(width: 80, height: 80, color: Colors.red),
              Container(width: 80, height: 80, color: Colors.green),
              Container(width: 80, height: 80, color: Colors.blue),
            ],
          ),
        ),
      ),
    );
  }
}

二、Flex 核心属性(Row/Column 通用)

Flex 的核心属性和 Row/Column 完全一致,这里重点强调弹性布局相关的关键属性:

属性 作用 常用值
direction 布局方向(Flex 独有,Row/Column 已固定) Axis.horizontal / Axis.vertical
mainAxisAlignment 主轴对齐方式 start/center/end/spaceBetween/spaceAround/spaceEvenly
crossAxisAlignment 交叉轴对齐方式 start/center/end/stretch
mainAxisSize 主轴占用空间 max(默认,占满父容器)/ min(仅包裹子组件)
children 子组件列表 Widget 数组

三、Expanded 组件详解

Expanded 是 Flex 布局的 “黄金搭档” ,用于解决布局溢出问题,并按比例分配剩余空间。

1. 核心作用

  • 强制子组件占据 Flex 布局中剩余的全部 / 部分空间
  • 自动适配父容器尺寸,避免子组件溢出(最核心的用途)
  • 支持通过 flex 属性设置空间分配比例

2. 基础属性

属性 作用 默认值
flex 空间分配权重(比例) 1
child 需要分配空间的子组件 必填

3. 实战示例

示例 1:基础比例分配(解决溢出)
// Flex(Row)+ Expanded 按比例分配空间
Row(
  children: [
    // 占1份空间
    Expanded(
      flex: 1,
      child: Container(color: Colors.red, height: 60),
    ),
    // 占2份空间
    Expanded(
      flex: 2,
      child: Container(color: Colors.green, height: 60),
    ),
    // 占1份空间
    Expanded(
      flex: 1,
      child: Container(color: Colors.blue, height: 60),
    ),
  ],
)

效果:父容器宽度被分成 1+2+1=4 份,红色占 1/4,绿色占 2/4,蓝色占 1/4,完全适配父容器,无溢出。

示例 2:混合固定尺寸 + 弹性尺寸
// 固定尺寸 + Expanded 弹性尺寸
Row(
  children: [
    // 固定宽度的按钮
    const SizedBox(width: 80, child: ElevatedButton(onPressed: () {}, child: Text('返回'))),
    // 占满剩余所有空间的文本(flex 默认1)
    Expanded(
      child: Container(
        color: Colors.grey[200],
        alignment: Alignment.center,
        child: const Text('这是标题,会占满剩余空间'),
      ),
    ),
    // 固定宽度的图标
    const SizedBox(width: 80, child: Icon(Icons.more_vert)),
  ],
)

效果:左右两个固定宽度的组件,中间文本区域自动占满剩余所有宽度,适配不同屏幕尺寸。

示例 3:垂直方向(Column + Expanded)
// Column + Expanded 垂直方向分配空间
Column(
  children: [
    // 顶部固定高度
    Container(height: 80, color: Colors.red, child: const Center(child: Text('顶部'))),
    // 中间占满剩余空间(核心内容区)
    Expanded(
      child: Container(color: Colors.green, child: const Center(child: Text('核心内容区'))),
    ),
    // 底部固定高度
    Container(height: 80, color: Colors.blue, child: const Center(child: Text('底部'))),
  ],
)

效果:顶部和底部固定高度,中间内容区自动占满屏幕剩余高度,是 App 页面的经典布局方式。

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("哈哈"),
        ),
        body: Column(
          children: [Flex(direction: .horizontal,
            children: [
              Expanded(
                  flex: 1,
                  // 此处设置的宽度 100无效,flex 比 width 优先级高
                  child: Container(width: 100, height: 40,color: Colors.red,)),
              Expanded(
                  flex: 2,
                  child: Container(width: 100,height: 40,color: Colors.yellow,))
            ],
          )],
        ),
      ),
    );
  }
}

Expandedflex 优先级远高于子组件的 widthExpanded 的作用是强制子组件占据 Flex 布局中分配给它的空间,因此你给 Container 设置的 width: 100 会被完全忽略;

❌