普通视图
年内首只翻倍基诞生
SpaceX 冲刺上市,资本开支飙升数百亿
苹果公司提高2026年MacBook Neo产量目标至1000万台
壳牌一季度利润69亿美元超预期
今年4月A股新开户数249.13万户
美拿汽车关税施压,欧盟仍未就落实与美贸易协议达成一致
韩国宣布延长囤积石油禁令至7月
DeepWay深向向港交所提交上市申请书
周六福:第一季度实现营业收入约12.73亿元
ST文峰:控股股东筹划控制权变更,股票停牌
美股三大指数集体收跌,热门中概股普跌
Bun 能上生产吗?我的实战结论
前段时间,不是看了 Cloude Code 源码嘛,里边用到 Bun。之前也听说过,但一直没有尝试过。还听到有同事自己也在用,所以就勾起我想好好熟悉一下 Bun。
于是,我给自己定了个小目标:把 Bun 从“听说很快”练到“我知道它哪里快、哪里会翻车、该怎么用”。
然后我就做了一个 10 章的小项目,从 hello world 一路打到 runtime、http、sqlite、测试、websocket 和最终小项目。
项目地址先放这里:
👉 bun-learning-journey
这篇文章我想聊三件事:
- Bun 到底是什么,我为什么愿意花时间系统练一遍
- Bun 为啥会比 Node / Deno 给人的“体感更快”
- Bun 现在市场使用率和成熟度到底怎么样,能不能上生产
我为什么做这个项目
我最开始对 Bun 的印象就一句话:“快,但是不稳”。
这种印象很容易停留在口水战里,所以我干脆用项目把它拆开。
这个仓库我按学习路径做成了 10 章:
-
01-hello:最小启动 -
02-runtime:顶层 await、Bun.env、Bun.sleep -
03-package-manager:bun install、脚本执行 -
04-http-server:Bun.serve+ REST -
05-file-io:Bun.file/Bun.write -
06-sqlite:bun:sqlite做 CRUD 和分页/JOIN -
07-testing:bun:test+ mock + coverage -
08-bundler:Bun.build+ splitting + define -
09-websocket:实时聊天(房间、私聊、输入中、SQLite 持久化) -
10-final-project:收口做完整小应用
做完之后我的结论是:
Bun 不是“银弹”,但它确实是我近两年在 JS 侧感受到“开发链路最短”的工具。
Bun 到底快在哪
很多文章只说“Bun 很快”,但不说机制。我按我这次实操的体感,总结成 5 点:
1)启动快:运行时和转译链路很短
Bun 基于 JavaScriptCore,启动延迟很低;官方文档也一直强调它的启动速度优势。
我在本地最直观的感受是:跑小脚本、跑工具脚本、跑测试时,“按下回车到看到输出”的时间明显短。
(官方文档里也给了 bun vs node 的启动对比)Bun Runtime 文档
![]()
2)少进程、少拼装:一个二进制干很多事
Node 生态常见链路是:Node + npm/pnpm + tsx/ts-node + jest/vitest + bundler。
Bun 把 runtime / package manager / test runner / bundler 集成在一起,少了很多工具间切换和冷启动。
这个在中小项目里,体感加速非常明显。
3)内置能力多,减少第三方依赖
我在练习里大量用到这些内置能力:
Bun.serve-
Bun.file/Bun.write bun:sqlitebun:testBun.build
这意味着我不用先装一堆包再拼接脚手架,项目从“搭环境”到“写业务”的时间被压缩了。
4)包管理很激进,安装速度很顶
很多同学第一次感知 Bun,都是 bun install。
它在缓存和链接策略上做得比较激进,安装速度确实常常比 npm 快一截。
(具体倍率和场景差异很大,别迷信单次 benchmark。)
5)Web 场景优化明显(HTTP / WebSocket)
我在第 4 章和第 9 章写 HTTP + WS 时,性能和 API 设计都很顺手。
尤其 Bun.serve + pub/sub 的 WebSocket 路线,代码量很少就能做出完整聊天室。
市场使用率:现在到底在哪个阶段
我尽量不“吹”,就说我看到的事实和判断。
先说结论
- Node 仍然是绝对主流,这一点没有争议。
- Bun 在快速增长,尤其在新项目、工具链、CLI、边缘服务里越来越常见。
-
Bun 更像“增量替换”而不是“一夜替代”:先从
bun install、bun test、工具脚本开始替换,再逐步评估 runtime 迁移。
公开信号(截至我写文时)
我自己的理解:
这两个数字说明 Bun 已经过了“玩具期”,但距离 Node 那种“企业默认项”还有距离。
所以我会把它定义为:“可生产,但要分场景上线”。
我建议怎么用 Bun(实战向)
如果你在团队里推动,我建议按这个顺序:
-
低风险切入:先用
bun install、bun test、脚本执行 -
服务侧试点:挑一个中小 API 服务用
Bun.serve跑 -
数据库场景验证:试
bun:sqlite或你现有驱动兼容性 - CI 加回归:覆盖 Linux / macOS,补关键链路测试
- 最后再谈全量迁移:尤其是复杂 Node 历史项目,不要一刀切
我这个项目能帮你什么
如果你想系统练 Bun,我这个仓库就是按“从入门到可落地”设计的:
你可以直接这样用:
- 从
01到10按顺序跑 - 每章先看 README,再看
index.ts - 我把很多练习都补成了可运行实现(不是只给题目)
如果你已经会 Node,这个项目大概率能帮你在 1~2 天内建立 Bun 的完整心智模型。
最后的态度:我会继续用吗?
会,而且会继续扩大使用范围。
但我的策略不是“全换”,而是有节奏地换:
- 新项目优先考虑 Bun
- 老项目我可能就动了,稳定第一,如有真实的需要场景,再切换
- 性能敏感、工具链痛点明显的地方优先上
一句话总结:
Bun 不只是“快”,更值钱的是它把 JavaScript 工具链做短了。
而“链路变短”,通常就是工程效率真正提升的开始。
你的前端滤镜慢得像PPT?用Rust+WebAssembly,一秒处理4K图
你给网页加了个“复古滤镜”功能,结果一拖动滑块,页面直接卡死。用户点一下,风扇狂转,手机发烫。今天我们用 Rust + WebAssembly 写一个图片滤镜,让图像处理速度飞起来。原来C++能做的事,Rust也能做,而且更安全、更简单。
前言
纯 JS 处理图像有多慢?假设你要把一张 4K 图片(约 8 百万像素)转成黑白,每个像素都要计算 R、G、B 的平均值。JS 需要遍历所有像素,做 2400 万次运算。这在现代设备上可能还要 100 毫秒,但一旦加上更复杂的滤镜(高斯模糊、边缘检测),帧率直接掉到个位数。
WebAssembly 的出现,让浏览器能以接近原生的速度执行代码。而 Rust 凭借零成本抽象和内存安全,成了写 Wasm 的首选语言。今天我们就来实战:用 Rust 写一个图像灰度滤镜,编译成 Wasm,然后在网页上让用户拖拽实时预览。全程可运行,不画饼。
一、为什么用 Rust 写 Wasm,而不是 C++?
-
工具链友好:
wasm-pack一键打包,自动生成 JS 胶水代码和 TypeScript 类型定义。 - 内存安全:不用担心悬垂指针、缓冲区溢出,Rust 编译器帮你查。
- 体积小:默认优化下,一个简单的滤镜函数可能只有几 KB。
- 社区活跃:前端工具链(SWC、Biome)都用 Rust,生态会越来越好。
二、环境准备
你需要安装 Rust(curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh)和 wasm-pack(cargo install wasm-pack)。
创建一个新项目:
cargo new --lib image-filter
cd image-filter
编辑 Cargo.toml:
[package]
name = "image-filter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
三、写一个灰度滤镜
在 src/lib.rs 中:
use wasm_bindgen::prelude::*;
// 将 Rust 函数暴露给 JS
#[wasm_bindgen]
pub fn grayscale(data: &mut [u8], width: u32, height: u32) {
// data 是 RGBA 像素数组,每个像素 4 个字节:R, G, B, A
for pixel in data.chunks_exact_mut(4) {
let r = pixel[0] as u32;
let g = pixel[1] as u32;
let b = pixel[2] as u32;
// 灰度公式:0.299*R + 0.587*G + 0.114*B
let gray = ((r * 299 + g * 587 + b * 114) / 1000) as u8;
pixel[0] = gray;
pixel[1] = gray;
pixel[2] = gray;
// alpha 不变
}
}
这个函数会直接修改原数组,没有内存拷贝,效率极高。
四、编译成 Wasm
wasm-pack build --target web
输出在 pkg/ 目录,包含 .wasm 文件、JS 绑定和 TypeScript 类型。
五、在网页中使用
创建一个 index.html:
<!DOCTYPE html>
<html>
<head>
<title>Rust Wasm 图像滤镜</title>
<style>
canvas { border: 1px solid #ccc; max-width: 100%; }
.container { display: flex; gap: 20px; flex-wrap: wrap; }
button { margin-top: 10px; padding: 8px 16px; }
</style>
</head>
<body>
<h1>Rust + WebAssembly 实时灰度滤镜</h1>
<input type="file" id="upload" accept="image/*">
<div class="container">
<div>
<canvas id="original" width="400" height="400"></canvas>
<div>原图</div>
</div>
<div>
<canvas id="filtered" width="400" height="400"></canvas>
<div>灰度滤镜(Rust Wasm)</div>
</div>
</div>
<button id="apply">应用滤镜</button>
<script type="module">
import init, { grayscale } from './pkg/image_filter.js';
async function run() {
await init(); // 加载 Wasm
const upload = document.getElementById('upload');
const originalCanvas = document.getElementById('original');
const filteredCanvas = document.getElementById('filtered');
const applyBtn = document.getElementById('apply');
let originalImageData = null;
upload.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const img = new Image();
img.onload = () => {
// 绘制原图
originalCanvas.width = img.width;
originalCanvas.height = img.height;
filteredCanvas.width = img.width;
filteredCanvas.height = img.height;
const ctx = originalCanvas.getContext('2d');
ctx.drawImage(img, 0, 0);
originalImageData = ctx.getImageData(0, 0, img.width, img.height);
// 默认显示原图到右侧
applyFilter();
};
img.src = URL.createObjectURL(file);
});
function applyFilter() {
if (!originalImageData) return;
// 复制图像数据(避免修改原图)
const dataCopy = new Uint8ClampedArray(originalImageData.data);
const width = originalImageData.width;
const height = originalImageData.height;
// 调用 Rust 函数,直接修改 dataCopy
grayscale(dataCopy, width, height);
// 显示到右侧 canvas
const imageData = new ImageData(dataCopy, width, height);
const ctx = filteredCanvas.getContext('2d');
ctx.putImageData(imageData, 0, 0);
}
applyBtn.addEventListener('click', applyFilter);
}
run();
</script>
</body>
</html>
注意:pkg 目录需要在一个静态服务器下运行,比如 npx http-server。直接打开 HTML 会因 CORS 或跨域问题无法加载 Wasm。
六、效果实测
选择一个高分辨率图片,点击“应用滤镜”。你会发现几乎是瞬间完成——因为 Rust 循环编译成 Wasm 后,速度比纯 JS 循环快 5-10 倍。即使 4K 图片,也感觉不到延迟。
如果想对比 JS 版本,可以用同样的灰度算法写一个纯 JS 函数,你会明显感受到卡顿(尤其是拖动滑块实时调整时)。
七、进阶:让滑块实时预览
把 applyBtn 换成 range slider,监听 input 事件,每帧都调用 grayscale。由于 Wasm 够快,可以做到 60fps 实时调参。
const intensitySlider = document.getElementById('intensity');
intensitySlider.addEventListener('input', () => {
const val = parseFloat(intensitySlider.value);
// 将强度作为参数传给 Rust(需要修改 Rust 函数,增加亮度系数)
// 略...
});
你甚至可以实现更复杂的滤镜(模糊、边缘检测、油画效果),只要把算法用 Rust 实现,其余和灰度类似。
八、生产环境注意事项
-
内存共享:上面例子是把
Uint8ClampedArray传给 Rust,wasm-bindgen会自动共享内存,不需要拷贝。 -
大图处理:尽量用
ImageBitmap和 OffscreenCanvas,避免阻塞主线程。Wasm 本身是在主线程跑的,除非你用 Worker。 -
体积优化:
wasm-pack build --release可以大幅减小体积。还可以用wasm-opt进一步优化。 - 浏览器兼容:所有现代桌面和移动浏览器都支持 Wasm。IE 已死,放心用。
九、总结:Rust + Wasm 是前端的“涡轮增压”
- 计算密集型任务(图像处理、音视频编解码、物理模拟)用 Rust 写 Wasm,性能接近原生。
- 开发体验好:
wasm-pack生成开箱即用的 JS 模块。 - 适合替换现有 JS 中的性能瓶颈,而不是重写整个应用。
下次老板让你加个“实时滤镜”,别再写三重循环的 JS 了。用 Rust,一秒处理 4K 图,用户只会觉得你的网站“好丝滑”。
基于 Trae Solo 移动办公修复 Vue3 前端服务问题
问题发现
2023年10月24日 14:35 (UTC+8)
📝 问题说明
在项目即将发版的最后阶段,测试团队反馈线上预发布环境的前端页面出现白屏现象,具体表现为用户登录后,主控制台无法加载,控制台报错 Uncaught TypeError: Cannot read properties of undefined (reading 'offsetWidth')。
由于该项目采用 Vue3 + Vite 架构,且问题仅在生产构建后的环境中出现,本地开发环境运行正常。此时核心前端开发人员正在外出差,仅携带一部平板设备,无法立即使用传统的 IDE 进行复杂的本地调试和构建排查。
🔍 问题分析
通过远程查看线上环境的 Source Map 和错误堆栈,初步分析如下:
构建差异:本地 dev 模式正常,build 后异常,推测是 Vite 在进行 Tree-shaking 或代码压缩时,处理了某些未正确引入的第三方组件库变量。 生命周期时序:报错信息 offsetWidth 通常与 DOM 元素挂载有关。分析代码发现,某个 ECharts 图表组件在 onMounted 钩子中尝试访问父级容器的宽度,但由于 Vue3 的 Teleport 组件配置不当,导致在该时刻 DOM 节点尚未真实渲染到文档流中。 环境依赖:该问题需要重新调整 Vite 配置文件 (vite.config.js) 并修改组件逻辑,需要完整的 Node.js 环境来重新构建和验证。
🛠️ 如何使用 Trae Solo 移动端远程修复问题
在无法使用传统 PC 开发环境的情况下,通过 Trae Solo 移动办公工具进行紧急修复的步骤如下:
第一步:连接云端开发环境
打开 Trae Solo 移动端应用,利用其内置的 远程终端 和 云端桌面 功能。通过安全网关连接至公司内网的开发服务器。Trae Solo 提供的低延迟网络通道确保了在 4G/5G 网络下也能流畅操作命令行。
第二步:代码定位与诊断
利用 Trae Solo 集成的 AI 代码助手,在移动端输入自然语言指令:
"查找项目中使用 ECharts 且涉及 offsetWidth 计算的 Vue3 组件。"
Trae Solo 迅速定位到 src/components/DashboardChart.vue 文件。通过移动端的代码查看器,确认了问题代码段:
// 问题代码
const chartDom = document.getElementById('chart');
const myChart = echarts.init(chartDom); // chartDom 可能为 null
第三步:利用 AI 辅助修复
在 Trae Solo 的编辑模式下,选中问题代码,使用 "Fix with AI" 功能。输入提示词:
"修复 Vue3 组件在 mounted 时 DOM 未渲染完成的报错,增加空值检查。"
Trae Solo 自动生成了修复后的代码:
// 修复后代码
<template>
<div ref="chartRef" style="width: 100%; height: 400px;"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const chartRef = ref(null);
let myChart = null;
onMounted(() => {
if (chartRef.value) {
myChart = echarts.init(chartRef.value);
myChart.setOption({
// 你的配置
});
}
});
onUnmounted(() => {
myChart?.dispose(); // 销毁实例
});
</script>
在移动端确认修改并保存。
![]()
第四步:远程构建与验证
通过 Trae Solo 的浮窗终端执行构建命令:
npm run build
构建成功后,使用 Trae Solo 的文件管理功能将构建产物上传至测试服务器。
第五步:结果确认
刷新预发布环境页面,控制台报错消失,页面正常渲染,问题修复完成。全程无需打开笨重的笔记本电脑。
📊 总结:使用 Trae Solo 修复问题的优缺点
✅ 优点
极致的移动性:打破了物理空间的限制,在出差、通勤等无 PC 场景下也能处理紧急线上故障,极大地提高了响应速度。 AI 赋能提效:集成的 AI 助手非常适合移动端操作,减少了在小屏幕上敲击复杂代码的痛苦,能快速生成修复逻辑。 安全合规:通过 Trae Solo 连接内网服务器,代码不落盘(存储在本地移动设备),避免了敏感代码通过微信等不安全渠道传输的风险。
❌ 缺点
操作体验受限:受限于移动设备屏幕尺寸,进行复杂的代码对比或多文件重构时,视野不如 PC 端广阔,操作效率略低。 网络依赖强:远程构建和文件传输严重依赖网络稳定性,若处于信号盲区,操作可能出现延迟或中断。 复杂调试困难:虽然可以修改代码,但在移动端进行复杂的断点调试仍不如 PC 端的浏览器开发者工具直观便捷。
总结:Trae Solo 为开发者提供了一个可靠的“口袋开发环境”,在应对 Vue3 前端服务的紧急修复场景中,它不仅是一个应急工具,更是移动办公时代提升开发者灵活性的重要利器。
用 wagmi v2 和 Next.js 14 硬扛 NFT 市场前端:从合约调用失败到批量上架,我踩了这些坑
背景
上个月,团队接了一个 NFT 市场的前端单子,要求用 Next.js 14 的 App Router 搭,后端合约是 Solidity 写的,已经部署到 Sepolia 测试网。我的任务就是实现用户连接钱包、查看自己持有的 NFT、选择上架(挂单)、以及购买别人挂单的 NFT。
听起来很常规对吧?我当时也觉得,wagmi v2 都出了,RainbowKit 也有现成的组件,应该很快能搞定。结果我一头扎进去,整整两天时间都耗在了“合约调用失败”和“签名不通过”这两个坑里。后来发现,问题出在 wagmi v2 的 API 变化、EIP-712 签名的处理方式,以及 Next.js 服务端渲染时对 Web3 库的兼容性上。
这篇文章,我就把自己踩过的坑、用的笨办法、最终怎么跑通的,全部写出来。如果你也在用 wagmi v2 做 NFT 市场,或者准备上 Next.js 14,希望能帮你少走弯路。
问题分析
最初的思路
我一开始的想法很简单:用 RainbowKit 做钱包连接,用 wagmi 的 useWriteContract 直接调合约的 listItem 方法,把 NFT 上架。合约那边我已经拿到了 ABI,上架函数签名是 listItem(address nftAddress, uint256 tokenId, uint256 price)。
代码大概长这样:
// 最初的错误写法
const { writeContract } = useWriteContract()
const handleListItem = async () => {
writeContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceABI,
functionName: 'listItem',
args: [nftAddress, tokenId, price],
})
}
看起来没问题吧?但点击按钮后,钱包弹出了 MetaMask 的交易确认,我点了确认,然后...交易一直 pending,最后直接 revert 了。
为什么行不通
我打开浏览器的控制台,发现 wagmi 抛了一个错误:
ContractFunctionExecutionError: The contract function "listItem" reverted with the following reason: "ERC721: transfer caller is not owner nor approved"
这个错误很经典:合约在执行 safeTransferFrom 的时候,发现调用者(也就是我当前的钱包地址)并没有被授权转移这个 NFT。我的合约里确实需要先 approve 市场合约,然后才能 listItem。
但问题在于:wagmi v2 的 useWriteContract 默认会把当前连接的 account 作为 from 地址,而 listItem 内部会检查 msg.sender 是否拥有该 NFT 的转移权限。我虽然在前端调用了 approve,但因为交易是异步的,approve 还没被确认,我就立刻调了 listItem,导致合约认为我没有授权。
当时我就踩了这个坑:没有做交易等待和状态检查。
排查过程
我花了半天时间,把交易流程拆成两步:
- 先调用 ERC721 的
approve方法,授权市场合约管理这个 NFT。 - 等
approve交易被确认后,再调用市场合约的listItem。
但这样又带来一个新问题:用户需要签两次 MetaMask 交易,体验很差。而且如果用户在第一步授权后、第二步上架前刷新了页面,授权就白做了。
后来我想到可以用 useWaitForTransactionReceipt 来监听交易状态,但 wagmi v2 的 API 变了,useWaitForTransactionReceipt 返回的是 data 而不是 receipt。我一开始没看文档,直接按 v1 的写法来,结果一直拿不到交易哈希。
// 错误的写法(v1 风格)
const { data } = useWaitForTransactionReceipt({
hash: txHash, // v2 里这个参数叫 hash,但返回结构变了
})
正确的做法是:
// wagmi v2 的正确写法
const { data: receipt, isLoading, isError } = useWaitForTransactionReceipt({
hash: txHash,
})
receipt 才是交易收据对象,data 在 v2 里已经被废弃了。这个细节让我多花了两个小时。
核心实现
1. 搭建基本项目结构和钱包连接
我用的技术栈是 Next.js 14 + wagmi v2 + RainbowKit。首先创建项目:
npx create-next-app@latest nft-marketplace --typescript --tailwind --app
cd nft-marketplace
npm install wagmi viem @rainbow-me/rainbowkit
然后在 app/providers.tsx 里配置 wagmi 和 RainbowKit:
'use client'
import { WagmiProvider, createConfig, http } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import '@rainbow-me/rainbowkit/styles.css'
const config = getDefaultConfig({
appName: 'NFT Marketplace',
projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID!, // 需要去 WalletConnect 申请
chains: [sepolia],
transports: {
[sepolia.id]: http('https://sepolia.infura.io/v3/YOUR_INFURA_KEY'),
},
})
const queryClient = new QueryClient()
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
)
}
注意这个细节:'use client' 是必须的,因为 wagmi 和 RainbowKit 都是客户端组件,不能在服务端渲染。Next.js 14 的 App Router 默认是服务端组件,所以必须用 'use client' 包裹。
然后在 app/layout.tsx 里引入:
import { Providers } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
2. 实现 NFT 上架功能(含 EIP-712 签名)
我后来决定改用 EIP-712 离线签名 的方式来实现上架,这样用户只需要签一条消息(不需要 gas),然后由后端或者前端直接提交交易。这能避免前面说的“先授权再上架”的糟糕体验。
合约那边支持了 EIP-712 的 createListing 函数,接受一个签名和一个 Listing 结构体。前端需要构建 domain、types、value,然后用 wagmi 的 signTypedData 来签名。
这里有个坑:wagmi v2 的 signTypedData 返回的是 0x 开头的签名,而合约那边期望的是 bytes 类型。如果你直接用 signTypedData 的结果,合约会校验失败,因为签名格式不对。
我排查了半天,发现是因为 wagmi v2 默认使用了 viem 的 signTypedData,它返回的是 0x 前缀的 hex 字符串。但合约接收的是 bytes,在 Solidity 里 bytes 和 string 是不同的。正确的做法是:不要对签名做任何处理,直接传 0x 开头的字符串给合约,因为 viem 的 encodeFunctionData 会自动把它转成 bytes。
完整的上架逻辑如下:
// app/components/ListItem.tsx
'use client'
import { useAccount, useSignTypedData, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { useState } from 'react'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { parseEther } from 'viem'
export function ListItem({ nftAddress, tokenId }: { nftAddress: string; tokenId: string }) {
const { address } = useAccount()
const [price, setPrice] = useState('')
const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined)
const { signTypedDataAsync } = useSignTypedData()
const { writeContractAsync } = useWriteContract()
// 监听交易确认
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
})
const handleList = async () => {
if (!address || !price) return
// 1. 构建 EIP-712 签名数据
const domain = {
name: 'NFTMarketplace',
version: '1',
chainId: 11155111, // Sepolia
verifyingContract: MARKETPLACE_ADDRESS,
}
const types = {
Listing: [
{ name: 'seller', type: 'address' },
{ name: 'nftAddress', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'price', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
}
const deadline = Math.floor(Date.now() / 1000) + 3600 // 1小时后过期
const value = {
seller: address,
nftAddress: nftAddress as `0x${string}`,
tokenId: BigInt(tokenId),
price: parseEther(price),
deadline: BigInt(deadline),
}
// 2. 用户签名(不需要 gas)
const signature = await signTypedDataAsync({
domain,
types,
primaryType: 'Listing',
message: value,
})
// 3. 调用合约的 createListing
const hash = await writeContractAsync({
address: MARKETPLACE_ADDRESS,
abi: marketplaceABI,
functionName: 'createListing',
args: [value, signature],
})
setTxHash(hash)
}
return (
<div>
<input
type="text"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="输入价格 (ETH)"
/>
<button onClick={handleList} disabled={isConfirming}>
{isConfirming ? '上架中...' : '上架 NFT'}
</button>
{isSuccess && <p>上架成功!交易哈希:{txHash}</p>}
</div>
)
}
3. 实现购买 NFT 功能
购买逻辑相对简单,因为用户只需要调用市场合约的 buyItem 函数,并附上 ETH(如果合约要求支付)。但这里也有一个坑:wagmi v2 的 useWriteContract 默认不携带 value,如果你需要发送 ETH,必须显式设置 value 参数。
// app/components/BuyNFT.tsx
'use client'
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { parseEther } from 'viem'
import { useState } from 'react'
interface Listing {
listingId: string
price: string
seller: string
}
export function BuyNFT({ listing }: { listing: Listing }) {
const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined)
const { writeContractAsync } = useWriteContract()
const { isLoading, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
})
const handleBuy = async () => {
const hash = await writeContractAsync({
address: MARKETPLACE_ADDRESS,
abi: marketplaceABI,
functionName: 'buyItem',
args: [BigInt(listing.listingId)],
value: parseEther(listing.price), // 这里必须传 value
})
setTxHash(hash)
}
return (
<div>
<p>卖家:{listing.seller.slice(0, 6)}...{listing.seller.slice(-4)}</p>
<p>价格:{listing.price} ETH</p>
<button onClick={handleBuy} disabled={isLoading}>
{isLoading ? '购买中...' : '立即购买'}
</button>
{isSuccess && <p>购买成功!</p>}
</div>
)
}
注意这个细节:value 的单位是 wei,所以要用 parseEther 把 ETH 字符串转成 BigInt。如果你直接传字符串,合约会报错说 msg.value 不足。
4. 查询并展示所有挂单
为了展示市场上的 NFT 列表,我需要从合约读取事件或者调用 getAllListings 函数。这里我选择用 wagmi 的 useReadContract 来读取合约状态。
但有个问题:useReadContract 是同步的(在 React 里是异步的,但返回值是固定的),你不能在它返回之前做条件渲染。我一开始用 if (!data) return <Loading />,结果页面一直 loading,因为 useReadContract 在服务端渲染时会返回 undefined。
正确的做法:用 isFetching 和 isFetched 来判断状态。
// app/components/Listings.tsx
'use client'
import { useReadContract } from 'wagmi'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { BuyNFT } from './BuyNFT'
interface Listing {
listingId: bigint
seller: string
nftAddress: string
tokenId: bigint
price: bigint
isActive: boolean
}
export function Listings() {
const { data, isFetching, isFetched, error } = useReadContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceABI,
functionName: 'getAllListings',
args: [],
})
if (isFetching) return <p>加载中...</p>
if (error) return <p>加载失败:{error.message}</p>
if (!isFetched || !data) return <p>暂无数据</p>
const listings = data as Listing[]
return (
<div>
{listings
.filter((l) => l.isActive)
.map((listing) => (
<div key={listing.listingId.toString()}>
<p>NFT 地址:{listing.nftAddress}</p>
<p>Token ID:{listing.tokenId.toString()}</p>
<p>价格:{listing.price.toString()} wei</p>
<BuyNFT
listing={{
listingId: listing.listingId.toString(),
price: listing.price.toString(),
seller: listing.seller,
}}
/>
</div>
))}
</div>
)
}
5. 处理批量上架
用户可能想一次上架多个 NFT,但合约只支持单个 createListing。我的方案是:用 Promise.all 并行签名,然后逐个提交交易。但这里要注意,signTypedDataAsync 每次调用都会弹 MetaMask 签名窗口,用户必须点很多次确认。
更好的做法:让用户只签一次,把多个 listing 打包成一个数组签名。但这需要合约支持批量签名,如果合约不支持,就只能用 Promise.allSettled 来处理部分失败的情况。
// 批量上架(逐个签名,逐个提交)
const handleBatchList = async (items: { nftAddress: string; tokenId: string; price: string }[]) => {
const results = []
for (const item of items) {
try {
const value = { seller: address, nftAddress: item.nftAddress, tokenId: BigInt(item.tokenId), price: parseEther(item.price), deadline: BigInt(deadline) }
const signature = await signTypedDataAsync({ domain, types, primaryType: 'Listing', message: value })
const hash = await writeContractAsync({ address: MARKETPLACE_ADDRESS, abi: marketplaceABI, functionName: 'createListing', args: [value, signature] })
results.push({ tokenId: item.tokenId, hash, status: 'pending' })
} catch (err) {
results.push({ tokenId: item.tokenId, error: err, status: 'failed' })
}
}
return results
}
这里有个坑:如果用户中途取消了签名,signTypedDataAsync 会抛出一个 UserRejectedRequestError,你必须用 try/catch 捕获,否则整个 Promise.allSettled 都会失败。
完整代码
由于篇幅限制,这里只给出核心组件的完整代码。完整的项目结构如下:
nft-marketplace/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── providers.tsx
├── components/
│ ├── ListItem.tsx
│ ├── BuyNFT.tsx
│ └── Listings.tsx
├── lib/
│ └── contract.ts
└── package.json
lib/contract.ts 内容:
import { Abi } from 'viem'
export const MARKETPLACE_ADDRESS = '0xYourMarketplaceContractAddress'
export const marketplaceABI: Abi = [
// 这里放合约 ABI,我直接从 Hardhat 编译产物复制过来
{
type: 'function',
name: 'createListing',
inputs: [
{ name: 'listing', type: 'tuple', components: [
{ name: 'seller', type: 'address' },
{ name: 'nftAddress', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'price', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
]},
{ name: 'signature', type: 'bytes' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'buyItem',
inputs: [{ name: 'listingId', type: 'uint256' }],
outputs: [],
stateMutability: 'payable',
},
{
type: 'function',
name: 'getAllListings',
inputs: [],
outputs: [{ name: '', type: 'tuple[]', components: [
{ name: 'listingId', type: 'uint256' },
{ name: 'seller', type: 'address' },
{ name: 'nftAddress', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'price', type: 'uint256' },
{ name: 'isActive', type: 'bool' },
]}],
stateMutability: 'view',
},
] as const
踩坑记录
-
wagmi v2 的
useWaitForTransactionReceipt返回结构变了:v1 返回{ data: receipt },v2 返回{ data: receipt, ... },但data字段已废弃,应该用receipt变量。我一开始没看文档,直接写data.transactionHash报错。 -
EIP-712 签名格式问题:wagmi v2 的
signTypedData返回的签名是0x开头的 hex 字符串,合约接收bytes类型。最初我尝试用viem的hexToBytes转换,结果合约校验失败。后来发现直接传0x字符串给合约的bytes参数即可,viem内部会自动处理。 -
Next.js 服务端渲染与 wagmi 不兼容:所有用到 wagmi hooks 的组件都必须加
'use client',否则会报hooks can only be called inside a component错误。我一开始没注意,把useReadContract放在了服务端组件里,导致页面直接白屏。 -
useWriteContract不携带value:购买 NFT 时需要发送 ETH,但useWriteContract默认不传value。我花了半小时排查为什么交易一直失败,最后发现合约要求msg.value等于价格,而我没传value参数。 -
用户取消签名时的错误处理:
signTypedDataAsync如果用户取消,会抛出UserRejectedRequestError,如果不捕获,整个流程会中断。我用try/catch捕获后,把失败项记录下来,让用户选择重试。
小结
这次踩坑的核心收获是:wagmi v2 的 API 变化很多,一定要看最新文档;EIP-712 签名要特别注意类型定义和格式;Next.js 14 的 App Router 强制要求所有客户端组件加 'use client'。
如果你想继续深挖,可以研究一下 wagmi v2 的 useSimulateContract,它可以在调用前模拟交易,提前发现错误,避免用户浪费 gas。另外,批量签名和批量交易也是 NFT 市场常见的需求,可以看看合约是否支持 multicall。
希望这篇文章能帮你少走一些弯路。如果你也在做 NFT 市场,欢迎留言交流。
JS中模拟函数重载的使用
JavaScript 语言本身不支持传统意义上的函数重载(即像 Java、C++ 那样,同名函数根据参数类型或数量的不同而自动调用不同版本)。在 JS 中,如果定义了多个同名函数,后面的函数会直接覆盖前面的函数。
不过,我们可以通过一些技巧来模拟实现函数重载的效果。同时,jQuery 库中也大量运用了这种“伪重载”的设计思想,这也是它 API 极其灵活好用的重要原因。
JavaScript 中模拟函数重载的常见方法
核心思路是:在一个函数内部,通过判断传入参数的数量或类型,来分发执行不同的逻辑分支。
1. 基于参数数量(arguments.length)
利用函数内部的 arguments 对象(或 ES6 的剩余参数 ...args)来获取实际传入的参数个数,从而执行不同的逻辑。
function add(...args) {
switch (args.length) {
case 0:
return 0;
case 1:
return args * 2; // 传1个参数返回它的2倍
case 2:
return args + args; // 传2个参数返回它们的和
default:
return args.reduce((a, b) => a + b, 0); // 3个及以上返回总和
}
}
console.log(add()); // 0
console.log(add(5)); // 10
console.log(add(3, 4)); // 7
2. 基于参数类型(typeof / instanceof)
通过判断参数的数据类型,来执行不同的操作。
function process(input) {
if (typeof input === "string") {
console.log("处理字符串逻辑:", input.toUpperCase());
} else if (typeof input === "number") {
console.log("处理数字逻辑:", input * input);
} else if (Array.isArray(input)) {
console.log("处理数组逻辑:", input.join("-"));
}
}
process("hello"); // 处理字符串逻辑: HELLO
process(10); // 处理数字逻辑: 100
process(); // 处理数组逻辑: 1-2-3
3. 现代推荐:使用 ES6 默认参数与对象解构
在实际开发中,为了避免繁琐的 if-else 或 switch 判断,更推荐使用 ES6 的默认参数和接收一个配置对象的方式。这种方式可读性极强,且没有参数顺序的困扰。
// 使用默认参数
function load(url, method = "GET", timeout = 5000) {
console.log(`发起请求: ${url}, 方法: ${method}, 超时: ${timeout}ms`);
}
load("/api/data"); // 发起请求: /api/data, 方法: GET, 超时: 5000ms
load("/api/data", "POST"); // 发起请求: /api/data, 方法: POST, 超时: 5000ms
// 使用对象解构
function createUser(options) {
const { name = "匿名", age = 18, role = "user" } = options;
return { name, age, role };
}
createUser({ name: "Tom" }); // { name: 'Tom', age: 18, role: 'user' }
createUser({ name: "Jerry", role: "admin" }); // { name: 'Jerry', age: 18, role: 'admin' }
jQuery 中的函数重载实现
是的,jQuery 中大量使用了函数重载的设计模式。这极大地降低了开发者的记忆成本,让同一个方法名能够承载多种语义(比如同一个方法既能“获取值”也能“设置值”)。
1. 经典的 Getter / Setter 重载 这是 jQuery 最常见的重载形式。通过判断传入参数的数量或类型,来决定是执行“获取(getter)”还是“设置(setter)”操作。
-
css()方法:-
$('div').css('color'):传入1个字符串参数,获取该元素的 color 样式值。 -
$('div').css('color', 'red'):传入2个参数,设置该元素的 color 样式为 red。 -
$('div').css({ color: 'red', fontSize: '14px' }):传入1个对象参数,批量设置样式。
-
-
attr()方法:-
$('#id').attr('title'):获取 title 属性的值。 -
$('#id').attr('title', 'jQuery'):设置 title 属性的值。
-
2. 核心构造函数 $() 的重载
jQuery 的核心 $() 函数本身就是一个极其强大的重载函数,它根据传入参数的不同,能实现完全不同的功能(内部支持多达 9 种不同的重载场景):
-
$(selector):传入 CSS 选择器字符串,匹配页面元素。 -
$(htmlString):传入 HTML 字符串,动态创建 DOM 元素。 -
$(element):传入一个原生 DOM 对象,将其包装成 jQuery 对象。 -
$(callback):传入一个函数,作为 DOM 加载完成后的回调(相当于$(document).ready())。
3. 源码中的参数判断逻辑
在 jQuery 的源码中,经常能看到通过判断参数类型来进行逻辑分发的代码。例如在 off() 方法中,如果第二个参数是函数,它就会自动调整参数的赋值逻辑,以兼容不同的调用方式:
// jQuery off 方法的部分源码逻辑示意
off: function (types, selector, fn) {
// 如果传入的第二个参数是一个函数,说明用户跳过了 selector 参数
if (selector === false || typeof selector === 'function') {
fn = selector;
selector = undefined;
}
// 后续逻辑
}
Vue 3.6 Vapor Mode:跳过虚拟 DOM,性能极致优化
虚拟 DOM 曾是前端框架的革命性思想,而今天,我们正在超越它。
一、引言
Vue 3.6 正式进入 Beta 阶段,Vapor Mode 作为本轮更新的最大亮点,终于揭开了神秘面纱。这是一个彻底改变 Vue 渲染架构的编译模式——跳过虚拟 DOM,直接操作真实 DOM。
从 React 在 2013 年引入虚拟 DOM 思想,到 Vue 2.0 于 2016 年采纳这一方案,再到今天 Vue 3.6 选择「告别」它,前端框架领域正在经历一场静默的范式转移。Svelte 在 2019 年证明了编译时优化可以消除虚拟 DOM 的开销,SolidJS 证明了细粒度响应式无需虚拟 DOM 也能达到极致性能,而现在,拥有全球数百万开发者的 Vue 正式加入这场变革。
Vapor Mode 的命名本身就充满隐喻——Vapor(蒸汽)的目标是让「虚拟 DOM 运行时」像水蒸气一样蒸发消散。这个名称不仅是营销概念,它准确描述了这项技术的核心价值:消除传统虚拟 DOM 带来的运行时开销。
二、Vapor Mode 是什么
2.1 核心概念:无虚拟 DOM 的编译模式
Vapor Mode 是 Vue 单文件组件(SFC)的一种全新编译策略。它的核心思路非常直接:在编译时分析模板,生成直接操作真实 DOM 的 JavaScript 代码,而不是生成虚拟 DOM 节点。
传统 Vue 组件的编译流程是:
-
解析
.vue模板文件 -
编译为返回虚拟 DOM 节点(VNode)的渲染函数
-
运行时执行渲染函数,生成 VNode 树
-
对比新旧 VNode 树(diffing)
-
根据差异补丁化更新真实 DOM
Vapor Mode 改变了这个流程的第 2 和第 3 步:
-
解析
.vue模板文件 -
编译为直接创建和更新 DOM 元素的命令式代码
-
运行时执行编译后的代码,响应式状态变化直接触发精确的 DOM 变更——无需 VNode 分配,无需树对比,无需补丁计算
2.2 与传统 VDOM 模式的本质区别
让我们通过一个简单组件来直观理解差异:
<!-- UserCard.vue -->
<template>
<div class="user-card">
<h2>{{ user.name }}</h2>
<p>{{ user.bio }}</p>
<span :class="{ online: user.isOnline }">
{{ user.isOnline ? '在线' : '离线' }}
</span>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const user = reactive({
name: '张三',
bio: '前端工程师',
isOnline: true
})
</script>
传统 Vue 编译输出(简化):
function render(_ctx) {
return h("div", { class: "user-card" }, [
h("h2", null, _ctx.user.name),
h("p", null, _ctx.user.bio),
h("span", { class: { online: _ctx.user.isOnline } },
_ctx.user.isOnline ? '在线' : '离线'
)
])
}
当 user.name 变化时,整个组件的渲染函数重新执行,产生新的 VNode 树,然后 diff 算法遍历两棵树,最终发现只有 <h2> 的文本节点需要更新。
Vapor Mode 编译输出(简化):
import { template, setText, effect } from 'vue/vapor'
const t0 = template('<div class="user-card"><h2></h2><p></p><span></span></div>')
function render(_ctx) {
const el = t0()
const [h2, p, span] = el.children
// 静态内容只创建一次
h2.textContent = _ctx.user.name
p.textContent = _ctx.user.bio
// 响应式绑定:每个状态只更新它影响的 DOM 节点
effect(() => {
h2.textContent = _ctx.user.name
})
effect(() => {
p.textContent = _ctx.user.bio
})
effect(() => {
span.textContent = _ctx.user.isOnline ? '在线' : '离线'
span.classList.toggle('online', _ctx.user.isOnline)
})
return el
}
编译时,Vue 已经「知道」了每个响应式变量对应哪个 DOM 节点。运行时,当 user.name 变化时,只有 <h2> 的文本节点被更新,没有 VNode 分配,没有树遍历,没有 diff 计算。
2.3 技术定位
Vapor Mode 是一个 100% 可选(opt-in)的功能,不会破坏任何现有代码。Vue 官方明确表示:
Vapor Mode has demonstrated the same level of performance with Solid and Svelte 5 in 3rd party benchmarks.
这意味着 Vue 开发者现在可以在不换框架的情况下,获得与 Svelte 5、SolidJS 相当的运行时性能。
三、工作原理深度解析
3.1 编译时优化策略
Vapor 编译器在构建阶段完成以下几个关键任务:
模板静态分析
编译器会区分模板中的静态部分和动态部分。静态 HTML 结构只生成一次,存储在模板缓存中;只有动态绑定的部分才会生成响应式 effect。
依赖追踪
编译器分析每个响应式变量在模板中的使用位置,为每个绑定生成精确的更新函数。这种「编译时依赖追踪」避免了运行时 diffing 的开销。
DOM 引用提取
编译产物中包含对所有需要动态更新的 DOM 节点的直接引用(通过 el.children、el.querySelector 等),而不是通过 VNode 间接访问。
3.2 响应式系统与 DOM 的直接绑定
Vapor Mode 的运行时使用 effect 函数建立响应式状态与 DOM 更新之间的精确映射:
// 当 count.value 变化时,只更新这个特定的文本节点
effect(() => {
textNode.data = String(count.value)
})
每个 effect 都是独立的、更新的最小单元。相比传统模式中「组件重新渲染→生成完整 VNode 树→diff→补丁更新」,Vapor Mode 的更新链路缩短为:状态变化→触发精确 effect→更新特定 DOM 节点。
3.3 Alien Signals:响应式系统的底层革新
Vue 3.6 不仅引入了 Vapor Mode,还同步重构了响应式系统的底层实现。新的 @vue/reactivity 包基于 Johnson Chu 开发的 alien-signals 库,采用 Push-Pull 混合算法,显著提升了响应式性能。
Push-Pull 算法的工作方式:
-
Push 阶段:响应式值变化时,只向依赖方推送「dirty(数据已过期)」通知,不立即重新计算
-
Pull 阶段:值被实际读取时,才触发真正的重新计算(惰性求值)
plaintext
[ref 值变化] → Push: dirty 通知 → [值被读取] → Pull: 执行重算
alien-signals 的实现特点:
-
核心部分不使用 Array、Set、Map 等高成本数据结构
-
采用链表等更轻量高效的结构
-
排除递归调用,防止循环引用
性能提升数据(官方):
| 指标 | Vue 3.5 | Vue 3.6(alien-signals) | 改善 |
|---|---|---|---|
| 内存使用量 | 基准值 | -14% | -14% |
| 10 万组件挂载 | - | ~100ms | - |
关键是:这一切都是向后兼容的。你不需要改任何代码,只需升级到 Vue 3.6,就能享受 alien-signals 的性能提升。ref、computed、watch、effectScope 等 API 保持不变。
3.4 编译输出对比
让我们看一个包含更多场景的组件对比:
输入模板:
<template>
<div class="list">
<h1>{{ title }}</h1>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }} - {{ item.count }}
</li>
</ul>
<button @click="addItem">添加</button>
</div>
</template>
<script setup vapor>
import { ref } from 'vue'
const title = ref('物品列表')
const items = ref([
{ id: 1, name: '苹果', count: 5 },
{ id: 2, name: '香蕉', count: 3 }
])
function addItem() {
items.value.push({
id: Date.now(),
name: '新物品',
count: 0
})
}
</script>
传统 VDOM 编译思路:
每次 items 变化,生成新的 VNode 树 → diff 计算 → 对整个列表区域执行补丁更新。
Vapor Mode 编译思路:
-
title变化 → 只更新<h1>文本 -
items数组变化 → 通过列表渲染优化,只处理变化的行 -
事件监听器直接绑定到按钮 DOM 节点
Vapor 编译器会生成类似以下的代码结构:
const t0 = template('<div class="list"><h1></h1><ul></ul><button></button></div>')
const t1 = template('<li></li>')
function render(_ctx) {
const el = t0()
const [h1, ul, button] = el.children
// 静态设置
h1.textContent = '物品列表'
button.textContent = '添加'
button.addEventListener('click', _ctx.addItem)
// 响应式绑定
effect(() => {
h1.textContent = _ctx.title
})
// 列表渲染 - Vapor 专用指令
_renderList(ul, _ctx.items, (item) => {
const li = t1()
effect(() => {
setText(li, `${item.name} - ${item.count}`)
})
return li
})
return el
}
四、性能对比
4.1 官方基准测试数据
根据 Vue 官方发布的数据和第三方基准测试:
| 测试场景 | Vue 3 + VDOM | Vue 3.6 + Vapor | Svelte 5 | SolidJS |
|---|---|---|---|---|
| 10,000 行表格首次渲染 | 247ms | 185ms | 192ms | 145ms |
| 更新 1,000 行数据 | 41ms | 23ms | 26ms | 52ms |
| 50 个复杂组件内存占用 | 18.7MB | 12.4MB | 11.8MB | 14.1MB |
关键发现:
-
Vapor Mode 首次渲染比传统 Vue 快约 25%,比 React 18 快约 30%
-
部分更新场景下,Vapor Mode 比传统 Vue 快近 50%
-
内存占用减少约 34%(相比传统 Vue)
4.2 js-framework-benchmark 结果
2026 年最新测试数据(综合多项基准测试):
| 框架 | 操作/秒 | 相对性能 | 基准包体积 |
|---|---|---|---|
| Vanilla JS | ~15,000 | 基准 | 0-5 KB |
| SolidJS | ~14,800 | 99% | 8.2 KB |
| Svelte 5 | ~13,200 | 88% | 12.1 KB |
| Vue 3.6 + Vapor | ~11,200 | 75% | <10 KB |
| Vue 3.6 默认 | ~9,800 | 65% | 34.3 KB |
| React 19 | ~8,700 | 58% | 42.5 KB |
注意:Vue 3.6 + Vapor 的测试数据来自社区,随着编译器优化持续进行,性能还在不断提升中。
4.3 打包体积对比
这是 Vapor Mode 最直观的优势之一:
| 框架/配置 | 未压缩 | Gzip 压缩后 |
|---|---|---|
| Vue 3.6 + Vapor Mode | ~40KB | <10KB |
| Vue 3.6 默认 | ~58KB | ~22KB |
| React 19 | ~72KB | ~28KB |
| Svelte 5 | ~28KB | ~12KB |
当你使用 createVaporApp 创建纯 Vapor 应用时,虚拟 DOM 运行时代码完全不会打包进产物,基础体积直接降到 10KB 以下。
4.4 性能提升的本质原因
-
消除 VNode 分配开销:每次渲染,传统模式都需要创建新的 JavaScript 对象来表示虚拟节点,Vapor Mode 直接操作 DOM,无此开销
-
消除 diff 计算:传统模式的 diffing 算法在最坏情况下是 O(n³),Vapor Mode 编译时已知更新目标,完全绕过 diffing
-
细粒度更新:只有实际依赖变化的 DOM 节点才会更新,组件级别的整体重渲染不复存在
-
内存优化:无需维护 VNode 树,GC 压力大幅降低
五、如何使用 Vapor Mode
5.1 安装 Vue 3.6 Beta
# 使用 npm
npm install vue@3.6.0-beta.1
# 或使用 yarn
yarn add vue@3.6.0-beta.1
# 或使用 pnpm
pnpm add vue@3.6.0-beta.1
如果你使用 Vite(推荐),确保 @vitejs/plugin-vue 也是最新版本:
npm install @vitejs/plugin-vue@latest vite@latest
5.2 组件级别开启方式
方式一:在 <script setup> 添加 vapor 属性
<!-- Counter.vue -->
<script setup vapor>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<div class="counter">
<h2>计数器</h2>
<p>当前值:{{ count }}</p>
<button @click="increment">增加</button>
</div>
</template>
<style scoped>
.counter {
text-align: center;
padding: 20px;
}
button {
padding: 8px 16px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
这是最简单的迁移方式——只需添加一个 vapor 属性即可。
5.3 全局配置选项
Vite 项目配置(vite.config.js/ts):
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({
// 可选:全局配置 Vapor Mode
compilerOptions: {
mode: 'vapor' // 或在单个组件的 script setup 上指定
}
})
]
})
Vue CLI 项目配置(vue.config.js):
module.exports = {
chainWebpack: config => {
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
options.compilerOptions = {
...options.compilerOptions,
mode: 'vapor'
}
return options
})
}
}
5.4 完整应用实例:两种创建方式
方式一:创建纯 Vapor 应用(推荐用于新项目)
// main.ts
import { createVaporApp } from 'vue'
import App from './App.vue'
createVaporApp(App).mount('#app')
这种方式下,虚拟 DOM 运行时代码不会被引入,基础包体积最小。
方式二:混合模式(渐进式迁移现有项目)
// main.ts
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
createApp(App)
.use(vaporInteropPlugin) // 启用 Vapor 互操作
.mount('#app')
安装 vaporInteropPlugin 后,你可以:
-
在任意组件的
<script setup>上添加vapor属性使其使用 Vapor 模式 -
Vapor 组件和 VDOM 组件可以相互嵌套
-
逐步迁移性能敏感的组件,其他部分保持不变
5.5 TypeScript 类型支持
Vapor Mode 完整支持 TypeScript,所有现有类型定义都适用:
<script setup vapor lang="ts">
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
}
const user = ref<User>({
id: 1,
name: '张三',
email: 'zhangsan@example.com'
})
const displayName = computed(() => {
return user.value.name.toUpperCase()
})
function updateName(newName: string) {
user.value.name = newName
}
</script>
<template>
<div class="user-profile">
<h2>{{ displayName }}</h2>
<p>邮箱:{{ user.email }}</p>
<button @click="updateName('李四')">更改姓名</button>
</div>
</template>
六、适用场景分析
6.1 最佳使用场景
Vapor Mode 在以下场景中能发挥最大价值:
静态内容为主的页面
-
企业官网首页
-
文档站点
-
落地页
-
博客文章页
这类页面初始化后几乎不需要动态更新,Vapor Mode 可以让它们以极低的 JS 开销运行。
性能敏感的高频更新组件
-
数据仪表盘的核心数字展示
-
实时股价/行情显示
-
游戏计分系统
-
聊天消息列表(高频滚动)
在高频更新场景下,Vapor Mode 的细粒度更新优势会被显著放大。
移动端 H5 页面
-
首屏加载速度直接影响用户留存
-
设备性能有限,减少 JS 解析量尤为重要
-
Vapor Mode 的 <10KB 基础包体积极具竞争力
列表渲染场景
-
长列表(虚拟滚动列表)
-
表格组件
-
瀑布流布局
传统 VDOM 在列表更新时需要 diff 整棵树,Vapor Mode 只需处理实际变化的行。
6.2 不适合使用的情况
复杂动态结构的组件
如果组件的模板结构会根据条件大幅变化(不同的子组件、动态标签名等),Vapor 编译器的静态分析效果会打折扣。
大量使用第三方 UI 库
目前主流的 Vue UI 组件库(如 Element Plus、Ant Design Vue、Vuetify)尚未适配 Vapor Mode,直接在 Vapor 组件中使用会有限制。
重度依赖实例 API
以下 API 在 Vapor 组件中不可用或表现不同:
-
getCurrentInstance()→ 返回null -
app.config.globalProperties→ 不可用 -
onVueComponentMounted等生命周期钩子 → 不支持
6.3 渐进式迁移策略
第一步:识别收益最大的组件
使用 Chrome DevTools 的 Performance 面板,找出 render 时间最长的组件,或者直接分析高频更新的交互区域。
第二步:从简单组件开始
先迁移不涉及复杂 props/slots 传递的独立组件,积累经验。
第三步:逐步扩大范围
当团队熟悉 Vapor 模式后,可以逐步覆盖更多组件。
第四步:评估混合边界
Vue 官方建议在应用中划分清晰的「Vapor 区域」和「VDOM 区域」,避免过度混合带来的复杂性。
七、注意事项与限制
7.1 兼容性问题
必须使用 <script setup>
Vapor Mode 只支持 <script setup> 语法,不支持:
-
传统 Options API(
data()、methods、computed等) -
手动的
setup()函数
如果你有大量 Options API 代码,需要先迁移到 Composition API。
不支持的功能清单
| 类别 | 功能 | 说明 |
|---|---|---|
| API | Options API | 需迁移到 Composition API |
| API | getCurrentInstance() |
Vapor 组件中返回 null
|
| API | app.config.globalProperties |
不可用 |
| API |
@vue:xxx 生命周期事件 |
不支持每个元素的生命周期钩子 |
| 渲染 | 渲染函数(Render Functions) | 不支持 JSX |
| 渲染 | 自定义渲染器 | 不支持 |
| 功能 | Suspense(纯 Vapor) | 不支持,但可在 VDOM Suspense 中渲染 Vapor 组件 |
7.2 自定义指令的新接口
Vapor Mode 中的自定义指令接口与 VDOM 模式不同:
// VDOM 模式
type Directive = (
el: HTMLElement,
binding: DirectiveBinding,
vnode: VNode
) => void
// Vapor Mode
type VaporDirective = (
node: Element | VaporComponentInstance,
value?: () => any, // 响应式 getter
argument?: string,
modifiers?: DirectiveModifiers
) => (() => void) | void // 可选返回清理函数
关键区别:binding.value 变成了 value,它是一个响应式 getter 函数。使用示例:
// Vapor 模式下的自定义指令
const vFocus = (el, source) => {
watchEffect(() => {
if (source()) {
el.focus()
}
})
return () => console.log('cleanup')
}
7.3 调试工具支持
Vapor Mode 是新特性,Vue DevTools 和其他调试工具的 Vapor 相关支持还在完善中。预计在正式版发布后会有更好的调试体验。
7.4 生态兼容现状
目前适配良好的场景:
-
Vue 核心功能:
ref、computed、watch、reactive、provide/inject等 -
条件渲染:
v-if、v-show -
列表渲染:
v-for(带 key) -
事件绑定:
@click等 -
模板语法:
:class、:style、:src等绑定 -
过渡动画:
Transition、TransitionGroup
需要等待适配的:
-
第三方 UI 组件库(Element Plus、Ant Design Vue 等)
-
某些依赖于 VDOM 实例 API 的库
-
SSR 框架集成(Nuxt 等)
7.5 已知限制
-
Vapor 插槽在 VDOM 组件中不能使用
slots.default(),必须使用renderSlot -
动态组件
<component :is="...">在复杂场景下可能有限制 -
VDOM 组件库在 Vapor 模式下可能有兼容性问题
Vue 官方表示,随着版本迭代,这些限制会逐步解决。
八、与传统 VDOM 模式的选择指南
8.1 决策矩阵
| 维度 | 选择 Vapor Mode | 选择 VDOM Mode |
|---|---|---|
| 页面类型 | 静态为主、性能敏感 | 高度动态、交互复杂 |
| 包体积要求 | 极致的轻量化 | 允许一定开销 |
| 更新频率 | 高频细粒度更新 | 常规更新频率 |
| UI 库依赖 | 使用原生 HTML/CSS | 依赖第三方组件库 |
| API 使用 | 纯 Composition API | Options API 或混合 |
| 项目阶段 | 新项目 | 现有大型项目 |
8.2 迁移成本评估
从 VDOM 迁移到 Vapor 的成本:
| 因素 | 成本评估 |
|---|---|
| 语法变更 | 低(只需加 vapor 属性) |
| API 适配 | 中(Options API 需迁移) |
| 组件重构 | 取决于组件复杂度 |
| 测试覆盖 | 高(需完整回归测试) |
| 第三方库适配 | 高(取决于依赖情况) |
推荐迁移路径:
现有项目:
↓ 新增组件用 Vapor Mode
↓ 识别高频更新组件 → 迁移
↓ 静态页面逐步迁移
↓ 评估并迁移核心功能组件
新项目:
↓ 选择 createVaporApp 或 createApp + plugin
↓ 全部使用 Vapor Mode
↓ 按需引入 VDOM 组件(通过 interop)
8.3 混合模式最佳实践
<!-- App.vue (VDOM 组件) -->
<script setup>
import Header from './components/Header.vue'
import Footer from './components/Footer.vue'
import Dashboard from './components/Dashboard.vue' // Vapor 组件
import DataTable from './components/DataTable.vue' // Vapor 组件
</script>
<template>
<div class="app">
<Header /> <!-- VDOM -->
<Dashboard /> <!-- Vapor:性能敏感的仪表盘 -->
<DataTable /> <!-- Vapor:高频更新的数据表 -->
<Footer /> <!-- VDOM -->
</div>
</template>
关键是:识别瓶颈、精准优化,而不是盲目全部迁移。
九、总结与展望
9.1 Vue 的战略选择
Vapor Mode 代表了 Vue 团队的一次重要战略选择:不再追求虚拟 DOM 的极致优化,而是选择「消灭它」。这是一个有魄力的决定,因为:
-
Vue 拥有全球数百万开发者,稳定性至关重要
-
渐进式迁移策略(opt-in)确保现有项目不受影响
-
与 alien-signals 的协同优化形成组合拳
9.2 前端渲染范式的演进
jQuery 时代 → 虚拟 DOM 时代 → 编译时优化时代
手动 DOM 操作 声明式 UI 直接 DOM 操作
(Vue 2, React) (Vue Vapor, Svelte, Solid)
虚拟 DOM 的历史使命是提供「声明式 UI + 高效更新」的平衡。随着编译器技术的发展,这个平衡可以由编译时完成,无需运行时开销。
9.3 展望
近期(2026 上半年):
-
Vue 3.6 正式版发布
-
Vapor Mode 稳定性提升
-
主流 UI 库开始适配
中期:
-
Nuxt 等框架集成 Vapor Mode
-
DevTools 支持完善
-
更多性能优化场景验证
长期:
-
Vapor Mode 可能成为新项目的默认选择
-
Vue 的性能标签从「易用但稍慢」升级为「易用且极致」
-
推动行业进一步向编译时优化演进
9.4 给开发者的建议
-
保持关注:Vue 3.6 正式版发布时,是评估 Vapor Mode 的最佳时机
-
小范围试点:在非关键项目中尝试 Vapor Mode,积累第一手经验
-
优化意识:即使暂时不迁移Vapor Mode,理解其背后的编译优化思路也有助于写出更高效的 Vue 代码
-
拥抱变化:前端技术演进迅速,保持学习心态,享受框架进化带来的红利
Vapor Mode 不是噱头,它是 Vue 回应时代变化、追求技术极致的产物。当 Svelte 和 SolidJS 已经证明了「无虚拟 DOM」路线的可行性,Vue 选择加入这场变革——不是抛弃自己的特色,而是在保持 Vue 灵魂(优雅的 API、渐进式理念、绝佳的开发体验)的同时,补上了性能这块短板。
这场前端渲染技术的范式转移,正在发生。Vue 3.6,是一个重要的节点。
参考资料:
- Vue 3.6.0-beta.1 Release Notes
- Reading Vue.js Core - Vapor
- Vue 3.6 の新機能を徹底解説 - Vapor ModeとAlien Signals
- js-framework-benchmark
- Escuela Vue - Vue 3.6 Vapor Mode
本文由AI辅助整理
🍎Vue官方Skills深度解读:那些被悄悄藏起来的宝藏
![]()
Vue官方Skills是一套被严重低估的最佳实践指南。本文深度解读vue-best-practicesSkill,涵盖从响应式核心、组件模式到性能优化的22个实用技巧,每个技巧都配有清晰的正确/错误对比。这些技巧不是"推荐做法",而是Vue团队多年沉淀下来的正确做事方式。
引言:为什么这套Skills值得你花时间
Vue的文档已经很完善了,但文档告诉你的是"怎么用"。而这套Skills告诉你的是"怎么用对"。
举几个例子:
-
shallowRefvsref,什么时候该用哪个? -
v-ifvsv-show,真的只是"条件渲染vs显示隐藏"这么简单? - 为什么你写的
watch(useAttrs()...)从来不触发?
这套Skills的独特之处在于:它不是教你概念,而是直接告诉你错误模式和正确模式。每个知识点都有BAD/GOOD对比,看完就能用。
本文基于vue-best-practicesSkill的所有参考资料编写,涵盖22个最佳实践,覆盖组件、响应式、性能、动画等维度。
---
name: vue-best-practices
description: MUST be used for Vue.js tasks. Strongly recommends Composition API with `<script setup>` and TypeScript as the standard approach. Covers Vue 3, SSR, Volar, vue-tsc. Load for any Vue, .vue files, Vue Router, Pinia, or Vite with Vue work. ALWAYS use Composition API unless the project explicitly requires Options API.
license: MIT
metadata:
author: github.com/vuejs-ai
version: "18.0.0"
---
# Vue 最佳实践工作流
使用此 Skill 作为指令集。除非用户明确要求不同顺序,否则请按顺序遵循此工作流。
## 核心原则
- **保持状态可预测:** 一个真实数据源,派生其他一切。
- **让数据流显式化:** Props 向下传递,Events 向上传递,适用于大多数场景。
- **倾向于小而聚焦的组件:** 更容易测试、复用和维护。
- **避免不必要的重渲染:** 明智地使用 computed 属性和 watchers。
- **可读性很重要:** 编写清晰、自文档化的代码。
## 1) 编码前先确认架构(必须)
- 默认技术栈:Vue 3 + Composition API + `<script setup lang="ts">`。
- 如果项目明确使用 Options API,在有相关 Skill 的情况下加载 `vue-options-api-best-practices` Skill。
- 如果项目明确使用 JSX,在有相关 Skill 的情况下加载 `vue-jsx-best-practices` Skill。
### 1.1 必须阅读的核心参考资料(必须)
- 在实现任何 Vue 任务之前,请务必阅读并应用这些核心参考资料:
- `references/reactivity.md`
- `references/sfc.md`
- `references/component-data-flow.md`
- `references/composables.md`
- 在整个任务期间保持这些参考资料处于活跃的工作上下文中,而不仅仅是在出现特定问题时才查阅。
### 1.2 编码前规划组件边界(必须)
对于任何非平凡的功能,在实现前创建简要的组件地图。
- 用一句话定义每个组件的单一职责。
- 将入口/根和路由级视图组件默认作为组合层。
- 将功能 UI 和功能逻辑从入口/根/视图组件中移出,除非任务本身就是一个有意为之的小型单文件演示。
- 在组件地图中定义每个子组件的 props/emits 契约。
- 当添加超过一个组件时,倾向于按功能文件夹布局(`components/<feature>/...`、`composables/use<Feature>.ts`)。
## 2) 应用必要的 Vue 基础知识(必须)
这些是必要的、必须掌握的基础知识。使用在第 `1.1` 节中已加载的核心参考资料,将它们应用到每个 Vue 任务中。
### 响应式
- 来自 `1.1` 的必读参考:[reactivity](references/reactivity.md)
- 保持源状态最小化(`ref`/`reactive`),用 `computed` 派生所有可派生值。
- 需要时用 watchers 处理副作用。
- 避免在模板中重新计算昂贵的逻辑。
### SFC 结构和模板安全
- 来自 `1.1` 的必读参考:[sfc](references/sfc.md)
- 保持 SFC 各部分按此顺序排列:`<script>` → `<template>` → `<style>`。
- 保持 SFC 职责聚焦;拆分大型组件。
- 保持模板声明式;将分支/派生逻辑移到 script 中。
- 应用 Vue 模板安全规则(`v-html`、列表渲染、条件渲染选择)。
### 保持组件职责聚焦
当组件有 **超过一个明确职责** 时进行拆分(例如:数据编排 + UI,或多个独立的 UI 区域)。
- 倾向于 **更小的组件 + composables**,而不是一个"超大组件"
- 将 **UI 区域** 移入子组件(props 入,events 出)。
- 将 **状态/副作用** 移入 composables(`useXxx()`)。
应用客观的拆分触发条件。当 **任意** 条件为真时拆分组件:
- 它同时拥有编排/状态和多个区域的大量展示性标记。
- 它有 3+ 个不同的 UI 区域(例如:表单、过滤器、列表、底部/状态)。
- 模板块被重复或可能变得可复用(条目行、卡片、列表条目)。
入口/根和路由视图规则:
- 保持入口/根和路由视图组件精简:应用 shell/布局、提供者连接、功能组合。
- 当功能包含独立部分时,不要将完整功能实现放在入口/根/视图组件中。
- 对于 CRUD/列表功能(待办、表格、目录、收件箱),至少拆分到:
- 功能容器组件
- 输入/表单组件
- 列表(和/或条目)组件
- 底部/操作或过滤器/状态组件
- 只允许非常小的临时演示使用单文件实现;如果选择了单文件,明确说明为什么不需要拆分。
### 组件数据流
- 来自 `1.1` 的必读参考:[component-data-flow](references/component-data-flow.md)
- 使用 props 向下、events 向上的模型作为主要模式。
- 只对真正的双向组件契约使用 `v-model`。
- 只对深层树依赖或共享上下文使用 provide/inject。
- 用 `defineProps`、`defineEmits` 和 `InjectionKey` 保持契约显式且带类型。
### Composables
- 来自 `1.1` 的必读参考:[composables](references/composables.md)
- 当逻辑被复用、是状态化的或副作用较重时,提取到 composables 中。
- 保持 composable API 小巧、带类型且可预测。
- 将功能逻辑与展示组件分离。
## 3) 只有在需求明确时才考虑可选功能
### 3.1 标准可选功能
不要默认添加这些。只在需求存在时加载匹配的参考。
- Slots:父组件需要控制子组件内容/布局时 -> [component-slots](references/component-slots.md)
- Fallthrough attributes:包装器/基础组件必须安全转发 attrs/events 时 -> [component-fallthrough-attrs](references/component-fallthrough-attrs.md)
- 内置组件 `<KeepAlive>` 用于有状态视图缓存 -> [component-keep-alive](references/component-keep-alive.md)
- 内置组件 `<Teleport>` 用于浮层/传送门 -> [component-teleport](references/component-teleport.md)
- 内置组件 `<Suspense>` 用于异步子树回退边界 -> [component-suspense](references/component-suspense.md)
- 动画相关功能:选择与所需运动行为最匹配的简单方法。
- 内置组件 `<Transition>` 用于入场/离场效果 -> [transition](references/component-transition.md)
- 内置组件 `<TransitionGroup>` 用于列表变动的动画 -> [transition-group](references/component-transition-group.md)
- 基于类的动画用于非入场/离场效果 -> [animation-class-based-technique](references/animation-class-based-technique.md)
- 状态驱动动画用于用户输入驱动的动画 -> [animation-state-driven-technique](references/animation-state-driven-technique.md)
### 3.2 较少使用的可选功能
只在有明确的产品或技术需求时才使用这些。
- 指令:当行为是 DOM 特定的,且不适合用 composable/组件实现时 -> [directives](references/directives.md)
- 异步组件:重型/很少使用的 UI 应该懒加载 -> [component-async](references/component-async.md)
- 渲染函数:只在模板无法表达需求时使用 -> [render-functions](references/render-functions.md)
- 插件:当行为必须在应用范围内安装时 -> [plugins](references/plugins.md)
- 状态管理模式:跨功能边界的应用级共享状态 -> [state-management](references/state-management.md)
## 4) 在行为正确后运行性能优化
性能工作是功能完成后的处理。在核心行为实现并验证之前不要优化。
- 大列表渲染瓶颈 -> [perf-virtualize-large-lists](references/perf-virtualize-large-lists.md)
- 静态子树不必要地重渲染 -> [perf-v-once-v-memo-directives](references/perf-v-once-v-memo-directives.md)
- 热路径中过度抽象 -> [perf-avoid-component-abstraction-in-lists](references/perf-avoid-component-abstraction-in-lists.md)
- 昂贵的更新被触发过于频繁 -> [updated-hook-performance](references/updated-hook-performance.md)
## 5) 完成前的最终自检
- 核心行为正常工作且符合需求。
- 所有必读参考资料都已阅读并应用。
- 响应式模型是最小化且可预测的。
- SFC 结构和模板规则得到遵循。
- 组件职责聚焦且拆分合理,必要时进行了拆分。
- 入口/根和路由视图组件保持作为组合层,除非有明确的小演示例外。
- 组件拆分决策是明确的且可辩护的(职责边界清晰)。
- 数据流契约是显式的且带类型的。
- 在复用/复杂度合理的地方使用了 composables。
- 适用的地方将状态/副作用移入了 composables。
- 只在需求明确时才使用可选功能。
- 性能更改只在功能完成后才应用。
第一部分:响应式核心(Reactivity Core)
1. shallowRef vs ref:不是选哪个的问题
很多人以为这是性能优化选项,其实不是。这是一个关于"更新语义"的选择。
// 场景:你经常替换整个值(重新获取、重置等)→ 用 ref
const user = ref(null)
user.value = await fetchUser() // 触发更新,整个user被替换
// 场景:你只替换顶层,但内部属性会变(不可变数据风格)→ 用 shallowRef
const user = shallowRef(null)
user.value = { name: 'Alice', age: 30 }
user.value.age = 31 // ❌ 不触发更新
user.value = { ...user.value, age: 31 } // ✅ 触发更新
// 场景:外部库实例、class实例 → 用 shallowRef
const canvas = shallowRef(new Canvas())
// 什么时候用 reactive
// 场景:表单、单个状态对象,经常就地修改属性
const form = reactive({
username: '',
password: '',
remember: false
})
form.username = 'alice' // 方便
// ❌ 避免:form = reactive({...}) 整个替换
核心原则:ref()适合经常重新赋值的场景,reactive()适合就地修改的场景,shallowRef()适合你不希望Vue深层代理的对象。
2. computed的五个正确用法
① 永远不要在computed里写副作用
// ❌ BAD
const doubled = computed(() => {
if (count.value > 10) console.warn('太大了!')
return count.value * 2
})
// ✅ GOOD - 用watch处理副作用
const doubled = computed(() => count.value * 2)
watch(count, (val) => {
if (val > 10) console.warn('太大了!')
})
② 过滤/排序不要写在模板里
<!-- ❌ BAD - 每次渲染都重新计算 -->
<li v-for="item in items.filter(item => item.active).sort(...)">
{{ item.name }}
</li>
<!-- ❌ BAD - 调用函数也是同样的问题 -->
<li v-for="item in getSortedItems()">
<!-- ✅ GOOD -->
<script setup>
const visibleItems = computed(() =>
items.value
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
)
</script>
<template>
<li v-for="item in visibleItems">
</template>
③ 用computed处理动态class/style
<!-- ❌ BAD -->
<button :class="{ 'btn-primary': type === 'primary' && !disabled, 'btn-disabled': disabled }">
<!-- ✅ GOOD -->
<script setup>
const buttonClasses = computed(() => ({
btn: true,
[`btn-${props.type}`]: !props.disabled,
'btn-disabled': props.disabled
}))
</script>
<template>
<button :class="buttonClasses">
</template>
3. watch的正确姿势
① 用immediate: true替代重复的onMounted调用
// ❌ BAD
const userId = ref(1)
onMounted(() => loadUser(userId.value))
watch(userId, (id) => loadUser(id))
// ✅ GOOD
watch(userId, (id) => loadUser(id), { immediate: true })
② 异步清理是搜索框的救星
// 当用户快速输入时,取消上一个请求
const query = ref('')
const results = ref<string[]>([])
watch(query, async (q, _prev, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort()) // 关键:自动取消
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
})
results.value = await res.json()
})
4. reactive的正确打开方式
禁止直接解构
// ❌ BAD - 丢失响应式
const { count } = reactive({ count: 0 })
// ✅ GOOD - 用toRefs保持响应式
const state = reactive({ count: 0 })
const { count } = toRefs(state)
watch(count, ...) // 现在能watch了
watch的正确姿势
// ❌ BAD - 传了非getter值
watch(state.count, () => {...})
// ✅ GOOD - 用getter
watch(() => state.count, () => {...})
// 或
watch(count, () => {...}) // 通过toRefs解构出来的ref
第二部分:组件模式(Component Patterns)
5. Slots的五个正确做法
① 用简写语法 # 替代 v-slot:
<!-- ❌ BAD -->
<MyComponent>
<template v-slot:header>...</template>
</MyComponent>
<!-- ✅ GOOD -->
<MyComponent>
<template #header>...</template>
</MyComponent>
② 可选插槽要加v-if检查
<!-- ❌ BAD - 没有header时依然渲染空的header标签 -->
<template>
<article class="card">
<header class="card-header">
<slot name="header" />
</header>
</article>
</template>
<!-- ✅ GOOD -->
<template>
<article class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header" />
</header>
</article>
</template>
③ TypeScript项目用defineSlots定义插槽类型
<script setup lang="ts">
defineProps<{ products: Product[] }>()
defineSlots<{
default(props: { product: Product; index: number }): any
empty(): any
}>()
</script>
④ 给插槽提供兜底内容
<!-- ❌ BAD -->
<button type="submit" class="btn-primary">
<slot />
</button>
<!-- ✅ GOOD -->
<button type="submit" class="btn-primary">
<slot>Submit</slot>
</button>
⑤ 纯逻辑复用优先用Composables
<!-- ❌ BAD - 用renderless组件做纯逻辑复用 -->
<MouseTracker v-slot="{ x, y }">
<p>{{ x }}, {{ y }}</p>
</MouseTracker>
<!-- ✅ GOOD -->
<script setup>
const { x, y } = useMouse()
</script>
<template>
<p>{{ x }}, {{ y }}</p>
</template>
6. 组件数据流的正确理解
Props是单向的,永远不要在子组件里修改props
<!-- ❌ BAD -->
<script setup>
const props = defineProps({ count: Number })
function increment() {
props.count++ // ❌ 禁止
}
</script>
<!-- ✅ GOOD - 正确的做法 -->
<script setup>
const props = defineProps({ count: Number })
const emit = defineEmits(['update'])
function increment() {
emit('update', props.count + 1)
}
</script>
用defineModel简化v-model(Vue 3.4+)
<!-- ❌ BAD - Vue 3.4之前的写法 -->
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input :value="props.modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>
<!-- ✅ GOOD - Vue 3.4+ -->
<script setup>
const model = defineModel({ type: String })
</script>
<template>
<input v-model="model" />
</template>
Provide/Inject用Symbol Keys避免冲突
// ❌ BAD - 字符串key可能冲突
provide('theme', theme)
// ✅ GOOD - Symbol唯一性 + TypeScript类型支持
import type { InjectionKey } from 'vue'
export const themeKey: InjectionKey<Theme> = Symbol('theme')
provide(themeKey, theme)
7. Fallthrough Attributes的正确访问方式
属性名要用方括号访问
<script setup>
const attrs = useAttrs()
// ❌ 这些都访问不到
attrs.data-testid // 语法错误
attrs.dataTestid // undefined
attrs['on-click'] // undefined
attrs['@click'] // undefined
// ✅ 正确的访问方式
attrs['data-testid']
attrs['aria-label']
attrs.onClick // 事件监听器用onX
</script>
useAttrs()不是响应式的
<script setup>
// ❌ BAD - 这个watch永远不会触发
watch(() => attrs.class, (newVal) => {
console.log(newVal) // Never runs
})
// ✅ GOOD - 用onUpdated
onUpdated(() => {
console.log('Latest attrs:', attrs)
})
// ✅ 或者:promote到props
const props = defineProps({ class: String })
watch(() => props.class, (newVal) => {...})
</script>
第三部分:异步与缓存
8. 异步组件的懒加载策略
在SSR中使用延迟水合
<script setup>
import {
defineAsyncComponent,
hydrateOnVisible, // 进入视口时水合
hydrateOnIdle // 空闲时水合
} from 'vue'
// 评论组件:用户滚动到视口才水合
const AsyncComments = defineAsyncComponent({
loader: () => import('./Comments.vue'),
hydrate: hydrateOnVisible({ rootMargin: '100px' })
})
// 页脚:5ms空闲时水合
const AsyncFooter = defineAsyncComponent({
loader: () => import('./Footer.vue'),
hydrate: hydrateOnIdle(5000)
})
</script>
防止加载闪烁
<script setup>
// ❌ BAD - delay:0 会在网络快时闪一下
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
delay: 0 // 太短了
})
// ✅ GOOD - delay:200 足够短不显眼,足够长不闪动
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 默认值,通常最合适
timeout: 30000
})
</script>
9. KeepAlive的正确使用
不是所有缓存都是好的
<!-- ✅ 应该用KeepAlive的场景: -->
<!-- 标签页切换 -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
<!-- ❌ 不该用KeepAlive的场景: -->
<!-- 搜索页 - 用户期望每次新鲜结果 -->
<!-- 地图/大表格 - 内存消耗大 -->
<!-- 敏感流程 - 退出时必须清理 -->
必须设置max限制缓存大小
<!-- ❌ BAD - 无限缓存 -->
<KeepAlive>
<component :is="currentView" />
</KeepAlive>
<!-- ✅ GOOD - 最多缓存5个 -->
<KeepAlive :max="5">
<component :is="currentView" />
</KeepAlive>
缓存失效的正确方式
<script setup>
const currentView = ref('Dashboard')
const viewKeys = reactive({ Dashboard: 0, Settings: 0 })
function invalidateCache(view) {
viewKeys[view]++
}
</script>
<template>
<KeepAlive>
<component :is="currentView" :key="`${currentView}-${viewKeys[currentView]}`" />
</KeepAlive>
</template>
10. Suspense的嵌套与触发机制
必须给default和fallback都包一个根节点
<!-- ❌ BAD -->
<Suspense>
<AsyncHeader />
<AsyncList />
<template #fallback>
<LoadingSpinner />
<LoadingHint />
</template>
</Suspense>
<!-- ✅ GOOD -->
<Suspense>
<div> <!-- 必须包一层 -->
<AsyncHeader />
<AsyncList />
</div>
<template #fallback>
<div>
<LoadingSpinner />
<LoadingHint />
</div>
</template>
</Suspense>
Pending状态只在根节点变化时触发
<!-- ❌ BAD - 异步工作发生在深层,但Suspense只跟踪根节点 -->
<Suspense>
<TabContainer>
<AsyncDashboard v-if="tab === 'dashboard'" />
<AsyncSettings v-else />
</TabContainer>
</Suspense>
<!-- ✅ GOOD - 根节点是动态的 -->
<Suspense>
<component :is="tabs[tab]" :key="tab" />
</Suspense>
组件嵌套顺序
<!-- ✅ 正确的嵌套顺序:RouterView -> Transition -> KeepAlive -> Suspense -->
<RouterView v-slot="{ Component }">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>Loading...</template>
</Suspense>
</KeepAlive>
</Transition>
</RouterView>
第四部分:性能优化
11. v-once和v-memo:跳过不必要的更新
v-once:静态内容只渲染一次
<template>
<!-- ❌ BAD - 每次渲染都检查 -->
<div class="terms-content">
<h1>Terms of Service</h1>
<p>Version: {{ termsVersion }}</p>
</div>
<!-- ✅ GOOD - 渲染一次后跳过 -->
<div class="terms-content" v-once>
<h1>Terms of Service</h1>
<p>Version: {{ termsVersion }}</p>
</div>
</template>
v-memo:列表项选择性更新
<template>
<!-- ❌ BAD - selectedId变化时所有1000项都重渲染 -->
<div v-for="item in list" :key="item.id">
<div :class="{ selected: item.id === selectedId }">
<ExpensiveComponent :data="item" />
</div>
</div>
<!-- ✅ GOOD - 只有选中项变化的那两项重渲染 -->
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id === selectedId]"
>
<div :class="{ selected: item.id === selectedId }">
<ExpensiveComponent :data="item" />
</div>
</div>
</template>
12. 大列表虚拟化:50+项就开始考虑
<!-- ❌ BAD - 渲染10000项 = 10000个DOM节点 -->
<template>
<div class="user-list">
<UserCard v-for="user in users" :key="user.id" :user="user" />
</div>
</template>
<!-- ✅ GOOD - 只渲染可见的~20项 -->
<template>
<RecycleScroller class="user-list" :items="users" :item-size="80" key-field="id" v-slot="{ item }">
<UserCard :user="item" />
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>
<style scoped>
.user-list {
height: 600px; /* 必须有固定高度 */
}
</style>
推荐库对比:
| 库 | 适用场景 |
|---|---|
vue-virtual-scroller |
通用场景,最流行 |
@tanstack/vue-virtual |
复杂布局,headless设计 |
vue-virtual-scroll-grid |
网格布局 |
13. 避免列表中的过度组件抽象
<!-- ❌ BAD - 每项UserCard创建5个组件实例 -->
<!-- UserCard.vue -->
<template>
<Card> <!-- 组件#1 -->
<CardHeader> <!-- 组件#2 -->
<UserAvatar /> <!-- 组件#3 -->
</CardHeader>
<CardBody> <!-- 组件#4 -->
<Text>{{ user.name }}</Text>
</CardBody>
</Card>
</template>
<!-- 100个用户 = 500个组件实例 -->
<!-- ✅ GOOD - 扁平化结构 -->
<template>
<div class="user-card">
<div class="card-header">
<img :src="user.avatar" :alt="user.name" class="avatar" />
</div>
<div class="card-body">
<span class="user-name">{{ user.name }}</span>
</div>
</div>
</template>
<!-- 100个用户 = 100个组件实例 -->
14. updated钩子里的禁忌
<script setup>
// ❌ BAD - updated里调用API,每次渲染都触发
onUpdated(() => {
fetch('/api/sync', { method: 'POST', body: JSON.stringify(items.value) })
})
// ❌ BAD - 在updated里修改状态 = 无限循环
onUpdated(() => {
renderCount.value++ // 触发更新 → 再次调用onUpdated → 无限循环!
})
// ✅ GOOD - 用watch精确控制
watch(items, (newItems) => {
syncToServer(newItems)
}, { deep: true })
// ✅ GOOD - 只用于DOM同步
onUpdated(() => {
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
})
</script>
第五部分:样式与动画
15. SFC样式规范
始终用scoped样式
<!-- ❌ BAD -->
<style>
button { border-radius: 999px; } /* 全局污染 */
</style>
<!-- ✅ GOOD -->
<style scoped>
.btn-primary { border-radius: 999px; }
</style>
用class选择器而非元素选择器
<!-- ❌ BAD - 元素选择器性能差,且样式脆弱 -->
<style scoped>
article { max-width: 800px; }
h1 { font-size: 2rem; }
</style>
<!-- ✅ GOOD -->
<style scoped>
.article { max-width: 800px; }
.article-title { font-size: 2rem; }
</style>
v-if vs v-show的选择标准
<template>
<!-- 频繁切换 → 用v-show,保持DOM只切换display -->
<ComplexPanel v-show="isPanelOpen" />
<!-- 很少显示/初始成本高 → 用v-if,初始渲染时才创建 -->
<AdminPanel v-if="isAdmin" />
</template>
16. Transition组件的正确用法
只包裹单个元素
<!-- ❌ BAD -->
<Transition name="fade">
<h3>Title</h3>
<p>Description</p>
</Transition>
<!-- ✅ GOOD -->
<Transition name="fade">
<div>
<h3>Title</h3>
<p>Description</p>
</div>
</Transition>
同元素类型切换要加key
<!-- ❌ BAD - 相同<p>标签,Vue复用元素,不触发动画 -->
<Transition name="fade">
<p v-if="isActive">Active</p>
<p v-else>Inactive</p>
</Transition>
<!-- ✅ GOOD -->
<Transition name="fade" mode="out-in">
<p v-if="isActive" key="active">Active</p>
<p v-else key="inactive">Inactive</p>
</Transition>
只使用transform和opacity做动画
<style>
/* ❌ BAD - 触发重排重绘,性能差 */
.slide-enter-active,
.slide-leave-active {
transition: height 0.3s ease;
}
/* ✅ GOOD - GPU加速,只触发重绘 */
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
</style>
17. TransitionGroup的列表动画
必须用稳定唯一key
<!-- ❌ BAD - 用index做key会导致动画错位 -->
<TransitionGroup name="list" tag="ul">
<li v-for="(item, index) in items" :key="index">
</TransitionGroup>
<!-- ✅ GOOD -->
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
</TransitionGroup>
交错动画
<template>
<TransitionGroup
tag="ul"
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
>
<li v-for="(item, index) in items" :key="item.id" :data-index="index">
{{ item.name }}
</li>
</TransitionGroup>
</template>
<script setup>
function onBeforeEnter(el) {
el.style.opacity = 0
el.style.transform = 'translateY(12px)'
}
function onEnter(el, done) {
const delay = Number(el.dataset.index) * 80 // 每项延迟80ms
setTimeout(() => {
el.style.transition = 'all 0.25s ease'
el.style.opacity = 1
el.style.transform = 'translateY(0)'
setTimeout(done, 250)
}, delay)
}
</script>
18. 状态驱动的CSS动画
鼠标跟随
<template>
<div class="container" @mousemove="onMousemove">
<div class="follower" :style="{ transform: `translate(${x}px, ${y}px)` }" />
</div>
</template>
<script setup>
const x = ref(0)
const y = ref(0)
function onMousemove(e) {
const rect = e.currentTarget.getBoundingClientRect()
x.value = e.clientX - rect.left
y.value = e.clientY - rect.top
}
</script>
<style>
.follower {
transition: transform 0.1s ease-out; /* 平滑跟随 */
pointer-events: none;
}
</style>
19. 基于类的反馈动画
shake、pulse等效果
<template>
<div :class="{ shake: showError }">
<button @click="submitForm">Submit</button>
<span v-if="showError">Error occurred!</span>
</div>
</template>
<script setup>
const showError = ref(false)
function submitForm() {
if (!isValid()) {
showError.value = true
setTimeout(() => showError.value = false, 820) // 匹配动画时长
}
}
</script>
<style>
.shake {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0); /* 启用GPU加速 */
}
</style>
用animationend自动清理
<script setup>
const isAnimating = ref(false)
function triggerAnimation() {
isAnimating.value = true
// 动画结束时自动重置,不需要setTimeout
}
</script>
<template>
<div :class="{ animate: isAnimating }" @animationend="isAnimating = false">
Content
</div>
</template>
第六部分:工具与扩展
20. 指令(Directives)的正确姿势
只用于DOM访问
// ❌ BAD - 复杂行为应该用组件或composable
const vShowPassword = {
mounted(el) {
el.addEventListener('click', () => {
el.type = el.type === 'password' ? 'text' : 'password'
})
}
}
// ✅ GOOD - 函数简写用于单钩子
const vFocus = (el) => el.focus()
// ✅ GOOD - 完整对象用于多钩子+清理
const vResize = {
mounted(el) {
const observer = new ResizeObserver(() => {})
observer.observe(el)
el._observer = observer
},
unmounted(el) {
el._observer?.disconnect() // 必须清理
}
}
TypeScript类型增强
import type { Directive } from 'vue'
type HighlightValue = string
export const vHighlight = {
mounted(el, binding) {
el.style.backgroundColor = binding.value
}
} satisfies Directive<HTMLElement, HighlightValue>
declare module 'vue' {
interface ComponentCustomProperties {
vHighlight: typeof vHighlight
}
}
SSR要实现getSSRProps
const vTooltip = {
mounted(el, binding) {
el.setAttribute('data-tooltip', binding.value)
el.classList.add('has-tooltip')
},
getSSRProps(binding) { // 必须实现,避免水合不匹配
return {
'data-tooltip': binding.value,
class: 'has-tooltip'
}
}
}
21. 插件(Plugins)的正确写法
符合app.use()契约
import type { App, Plugin } from 'vue'
interface MyOptions {
apiKey: string
debug?: boolean
}
// ✅ GOOD - 对象形式
const myPlugin: Plugin<[MyOptions]> = {
install(app: App, options: MyOptions) {
app.provide(serviceKey, createService(options))
}
}
// ✅ GOOD - 函数形式
function simplePlugin(app: App, options?: { message: string }) {
app.config.globalProperties.$greet = () => options?.message ?? 'Hello!'
}
app.use(myPlugin, { apiKey: 'xxx', debug: true })
用Symbol Keys防止冲突
import type { InjectionKey } from 'vue'
import type { AxiosInstance } from 'axios'
export const httpKey: InjectionKey<AxiosInstance> = Symbol('http')
export const configKey: InjectionKey<AppConfig> = Symbol('appConfig')
// 提供注入helper,缺失时抛出明确错误
export function useAuth(): AuthService {
const auth = inject(authKey)
if (!auth) {
throw new Error('Auth plugin not installed. Did you forget app.use(authPlugin)?')
}
return auth
}
22. 渲染函数(Render Functions)的必要模式
优先用模板,只在必要时用渲染函数
<!-- ❌ BAD - 简单场景用渲染函数 -->
<script setup>
import { h, ref } from 'vue'
const count = ref(0)
const render = () => h('div', `Count: ${count.value}`)
</script>
<!-- ✅ GOOD - 用模板 -->
<script setup>
const count = ref(0)
</script>
<template>
<div>Count: {{ count }}</div>
</template>
列表必须有key
// ❌ BAD
return () => h('ul',
items.value.map(item => h('li', item.name))
)
// ✅ GOOD
return () => h('ul',
items.value.map(item => h('li', { key: item.id }, item.name))
)
事件修饰符要用withModifiers
import { h, withModifiers, withKeys } from 'vue'
// ❌ BAD
const handleClick = (e) => {
e.stopPropagation()
e.preventDefault()
}
// ✅ GOOD
h('button', {
onClick: withModifiers(handleClick, ['stop', 'prevent'])
}, 'Click')
总结:这些技巧的共同主题
通读这22个最佳实践,你会发现几个贯穿始终的主题:
1. 响应式要精确
- 什么时候用
ref/reactive/shallowRef不是随意的,是根据更新模式决定的 -
watch要精准触发,computed要纯净无副作用
2. 组件边界要清晰
- Props down, events up是铁律
- Slots是API设计,不是实现技巧
- Provide/Inject用Symbol避免魔法字符串
3. 性能要测量后优化
-
v-once/v-memo是给真正需要优化的地方用的 - 大列表虚拟化有明确阈值(50+项)
- 组件抽象不是越少越好,也不是越多越好
4. SSR有额外的坑
- 异步组件要考虑水合策略
- 指令需要
getSSRProps - 状态管理不能用运行时单例
5. 动画要选对工具
- 入离场 → Transition
- 列表 → TransitionGroup
- 停留在DOM里的反馈 → class绑定
这套Skills的真正价值在于:它把Vue团队的踩坑经验整理成了可以直接照着做的模式。下次你写Vue代码时,可以对照检查清单看看是否用对了这些模式。