阅读视图

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

巴西专家:外资2026年大举押注巴西和中国

巴西媒体《Money Times》日前刊登瓦加斯基金会(FGV)金融专家夏华声(Hsia Hua Sheng)题为《外国投资者在2026年重新大举押注巴西和中国》的文章称,2026年4月,全球投资者重新加大了对巴西和中国等新兴市场的配置力度。文章引用数据分析,中国正在经历一场结构性转型。2026年第一季度中国经济同比增长5%,增长主要集中在高技术制造、半导体、机器人、电池和人工智能等领域。这些行业在传统指数中的代表性仍然有限。许多指数仍然更多集中于银行、房地产、国有企业和传统工业领域。(新华财经)

年内首只翻倍基诞生

截至5月7日,广发基金旗下广发远见智选基金年内收益率已达100.97%,成为年内首只实现净值翻倍的主动权益基金。从排行来看,当前主动权益类基金年内业绩前五席位分别为——广发远见智选、金信量化精选、国寿安保数字经济、平安科技精选、华商致远回报,年内收益率依次为100.97%、74.93%、73.95%、70.33%、67.80%。除此之外,前海开源沪港深乐享生活、红土创新新科技股票、华泰柏瑞质量成长、永赢先锋半导体、华富数字经济、华宝绿色领先、融通产业趋势等产品,同样稳居年内业绩第一梯队,科技、数字经济、半导体相关主题成为今年基金赚钱主线。(证券时报)

SpaceX 冲刺上市,资本开支飙升数百亿

随着埃隆・马斯克执掌的太空探索技术公司筹备大规模首次公开募股,其资本开支规模正以数十亿美元的幅度持续攀升。 据得州当地政府一份项目公告显示,SpaceX 计划与特斯拉联合打造的太拉晶圆芯片产业园,预估资本投入至少需要550亿美元。这家涉足火箭、卫星及人工智能领域的企业,正持续加码航天相关基建投资,包括佛罗里达州发射场、得州新建太阳能电池工厂。公司还规划未来数年投入更多资金,拟向近地轨道发射多达百万颗人工智能卫星。(新浪财经)

苹果公司提高2026年MacBook Neo产量目标至1000万台

5月7日消息,据报道,苹果公司已要求供应商将2026年MacBook Neo笔记本电脑产量目标,由最初的500万至600万台大幅上调至1000万台,以缓解当前市场供应紧张局面,即使该公司不得不向台积电支付巨额溢价以确保A18 Pro芯片的供应。(界面)

壳牌一季度利润69亿美元超预期

壳牌周四公布的一季度利润达到69亿美元,创两年新高,超出市场预期。受此推动,该公司将股息提高5%。与此同时,壳牌将季度股票回购规模从35亿美元放缓至30亿美元,以将现金调配至资产负债表,应对战争导致能源供应中断后债务增加所带来的短期流动性压力。(新浪财经)

美拿汽车关税施压,欧盟仍未就落实与美贸易协议达成一致

近日,美国和欧盟围绕贸易协议和关税问题再起纷争。美国上周威胁将欧盟汽车关税提高至25%,理由是欧盟没有遵守去年夏天与美达成的贸易协议。6日,欧盟内部就落实该协议举行谈判,但仍未达成一致。据美国彭博社以及欧洲版《政治报》等媒体报道,当地时间6日,欧洲议会和欧洲国家的代表就修改及落实去年与美国达成的贸易协议举行谈判,但未取得突破性进展。欧洲议会国际贸易委员会主席贝恩德·朗格6日称,欧洲议会在欧美贸易协议的立法程序上正取得进展,但还有一些路要走。欧洲议会表示,下一次谈判将于5月19日举行。(央视新闻)

韩国宣布延长囤积石油禁令至7月

韩国政府5月7日宣布,将禁止囤积和垄断石油产品的措施延长两个月至7月底,以应对中东战事导致的供应紧张以及稳定国内物价。韩国副总理兼企划财政部长官具润哲当天在主持部长级经济政策会议时表示,为防止出现以油价为由拒绝销售等行为,政府决定延长禁止囤积和垄断石油产品措施的实施期限。韩国政府今年3月出台禁止囤积和垄断石油产品的临时禁令,期限至5月12日。(新华社)

周六福:第一季度实现营业收入约12.73亿元

36氪获悉,周六福在港交所公告,本集团2026年第一季度实现营业收入约人民币12.73亿元,得益于“周六福”、“喵际”品牌线上渠道贡献的毛利同比大增约50%等因素,集团合并报表的盈利能力指标进一步提升,同期归属于上市公司股东的净利润同比增长约29%。

美股三大指数集体收跌,热门中概股普跌

36氪获悉,5月7日收盘,美股三大指数集体下跌,道指跌0.63%,标普500指数跌0.38%,纳指跌0.13%。大型科技股涨跌不一,特斯拉涨超3%,微软、英伟达涨超1%,Meta小幅上涨,英特尔跌3%,亚马逊跌超1%,苹果、谷歌小幅下跌。热门中概股普跌,腾讯音乐跌超4%,小鹏集团跌超2%,京东、网易、哔哩哔哩跌超1%,阿里巴巴、拼多多、百度、理想汽车小幅下跌。

Bun 能上生产吗?我的实战结论

前段时间,不是看了 Cloude Code 源码嘛,里边用到 Bun。之前也听说过,但一直没有尝试过。还听到有同事自己也在用,所以就勾起我想好好熟悉一下 Bun。

于是,我给自己定了个小目标:把 Bun 从“听说很快”练到“我知道它哪里快、哪里会翻车、该怎么用”

然后我就做了一个 10 章的小项目,从 hello world 一路打到 runtime、http、sqlite、测试、websocket 和最终小项目。

项目地址先放这里:
👉 bun-learning-journey

这篇文章我想聊三件事:

  1. Bun 到底是什么,我为什么愿意花时间系统练一遍
  2. Bun 为啥会比 Node / Deno 给人的“体感更快”
  3. Bun 现在市场使用率和成熟度到底怎么样,能不能上生产

我为什么做这个项目

我最开始对 Bun 的印象就一句话:“快,但是不稳”

这种印象很容易停留在口水战里,所以我干脆用项目把它拆开。

这个仓库我按学习路径做成了 10 章:

  • 01-hello:最小启动
  • 02-runtime:顶层 await、Bun.envBun.sleep
  • 03-package-managerbun install、脚本执行
  • 04-http-serverBun.serve + REST
  • 05-file-ioBun.file / Bun.write
  • 06-sqlitebun:sqlite 做 CRUD 和分页/JOIN
  • 07-testingbun:test + mock + coverage
  • 08-bundlerBun.build + splitting + define
  • 09-websocket:实时聊天(房间、私聊、输入中、SQLite 持久化)
  • 10-final-project:收口做完整小应用

做完之后我的结论是:
Bun 不是“银弹”,但它确实是我近两年在 JS 侧感受到“开发链路最短”的工具。

Bun 到底快在哪

很多文章只说“Bun 很快”,但不说机制。我按我这次实操的体感,总结成 5 点:

1)启动快:运行时和转译链路很短

Bun 基于 JavaScriptCore,启动延迟很低;官方文档也一直强调它的启动速度优势。

我在本地最直观的感受是:跑小脚本、跑工具脚本、跑测试时,“按下回车到看到输出”的时间明显短。
(官方文档里也给了 bun vs node 的启动对比)Bun Runtime 文档

image.png

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:sqlite
  • bun:test
  • Bun.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 installbun test、工具脚本开始替换,再逐步评估 runtime 迁移。

公开信号(截至我写文时)

  • npm 上 bun 包周下载量在百万级(我看到的是约 138 万/周npm 包页面
  • GitHub 仓库 star 大约 89k+(增长一直比较快)GitHub 仓库

我自己的理解:
这两个数字说明 Bun 已经过了“玩具期”,但距离 Node 那种“企业默认项”还有距离。
所以我会把它定义为:“可生产,但要分场景上线”

我建议怎么用 Bun(实战向)

如果你在团队里推动,我建议按这个顺序:

  1. 低风险切入:先用 bun installbun test、脚本执行
  2. 服务侧试点:挑一个中小 API 服务用 Bun.serve
  3. 数据库场景验证:试 bun:sqlite 或你现有驱动兼容性
  4. CI 加回归:覆盖 Linux / macOS,补关键链路测试
  5. 最后再谈全量迁移:尤其是复杂 Node 历史项目,不要一刀切

我这个项目能帮你什么

如果你想系统练 Bun,我这个仓库就是按“从入门到可落地”设计的:

👉 github.com/RainyNight9…

你可以直接这样用:

  • 0110 按顺序跑
  • 每章先看 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-packcargo 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>

在移动端确认修改并保存。

d6cbe233c8d27006aa6d74366f1ccc32.jpg

第四步:远程构建与验证

通过 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,导致合约认为我没有授权。

当时我就踩了这个坑:没有做交易等待和状态检查

排查过程

我花了半天时间,把交易流程拆成两步:

  1. 先调用 ERC721 的 approve 方法,授权市场合约管理这个 NFT。
  2. 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 结构体。前端需要构建 domaintypesvalue,然后用 wagmi 的 signTypedData 来签名。

这里有个坑:wagmi v2 的 signTypedData 返回的是 0x 开头的签名,而合约那边期望的是 bytes 类型。如果你直接用 signTypedData 的结果,合约会校验失败,因为签名格式不对。

我排查了半天,发现是因为 wagmi v2 默认使用了 viemsignTypedData,它返回的是 0x 前缀的 hex 字符串。但合约接收的是 bytes,在 Solidity 里 bytesstring 是不同的。正确的做法是:不要对签名做任何处理,直接传 0x 开头的字符串给合约,因为 viemencodeFunctionData 会自动把它转成 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

正确的做法:用 isFetchingisFetched 来判断状态。

// 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

踩坑记录

  1. wagmi v2 的 useWaitForTransactionReceipt 返回结构变了:v1 返回 { data: receipt },v2 返回 { data: receipt, ... },但 data 字段已废弃,应该用 receipt 变量。我一开始没看文档,直接写 data.transactionHash 报错。

  2. EIP-712 签名格式问题:wagmi v2 的 signTypedData 返回的签名是 0x 开头的 hex 字符串,合约接收 bytes 类型。最初我尝试用 viemhexToBytes 转换,结果合约校验失败。后来发现直接传 0x 字符串给合约的 bytes 参数即可,viem 内部会自动处理。

  3. Next.js 服务端渲染与 wagmi 不兼容:所有用到 wagmi hooks 的组件都必须加 'use client',否则会报 hooks can only be called inside a component 错误。我一开始没注意,把 useReadContract 放在了服务端组件里,导致页面直接白屏。

  4. useWriteContract 不携带 value:购买 NFT 时需要发送 ETH,但 useWriteContract 默认不传 value。我花了半小时排查为什么交易一直失败,最后发现合约要求 msg.value 等于价格,而我没传 value 参数。

  5. 用户取消签名时的错误处理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-elseswitch 判断,更推荐使用 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 组件的编译流程是:

  1. 解析 .vue 模板文件

  2. 编译为返回虚拟 DOM 节点(VNode)的渲染函数

  3. 运行时执行渲染函数,生成 VNode 树

  4. 对比新旧 VNode 树(diffing)

  5. 根据差异补丁化更新真实 DOM

Vapor Mode 改变了这个流程的第 2 和第 3 步:

  1. 解析 .vue 模板文件

  2. 编译为直接创建和更新 DOM 元素的命令式代码

  3. 运行时执行编译后的代码,响应式状态变化直接触发精确的 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.childrenel.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 的性能提升。refcomputedwatcheffectScope 等 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 性能提升的本质原因

  1. 消除 VNode 分配开销:每次渲染,传统模式都需要创建新的 JavaScript 对象来表示虚拟节点,Vapor Mode 直接操作 DOM,无此开销

  2. 消除 diff 计算:传统模式的 diffing 算法在最坏情况下是 O(n³),Vapor Mode 编译时已知更新目标,完全绕过 diffing

  3. 细粒度更新:只有实际依赖变化的 DOM 节点才会更新,组件级别的整体重渲染不复存在

  4. 内存优化:无需维护 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()methodscomputed 等)

  • 手动的 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 核心功能:refcomputedwatchreactiveprovide/inject

  • 条件渲染:v-ifv-show

  • 列表渲染:v-for(带 key)

  • 事件绑定:@click

  • 模板语法::class:style:src 等绑定

  • 过渡动画:TransitionTransitionGroup

需要等待适配的:

  • 第三方 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 的极致优化,而是选择「消灭它」。这是一个有魄力的决定,因为:

  1. Vue 拥有全球数百万开发者,稳定性至关重要

  2. 渐进式迁移策略(opt-in)确保现有项目不受影响

  3. 与 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 给开发者的建议

  1. 保持关注:Vue 3.6 正式版发布时,是评估 Vapor Mode 的最佳时机

  2. 小范围试点:在非关键项目中尝试 Vapor Mode,积累第一手经验

  3. 优化意识:即使暂时不迁移Vapor Mode,理解其背后的编译优化思路也有助于写出更高效的 Vue 代码

  4. 拥抱变化:前端技术演进迅速,保持学习心态,享受框架进化带来的红利

Vapor Mode 不是噱头,它是 Vue 回应时代变化、追求技术极致的产物。当 Svelte 和 SolidJS 已经证明了「无虚拟 DOM」路线的可行性,Vue 选择加入这场变革——不是抛弃自己的特色,而是在保持 Vue 灵魂(优雅的 API、渐进式理念、绝佳的开发体验)的同时,补上了性能这块短板。

这场前端渲染技术的范式转移,正在发生。Vue 3.6,是一个重要的节点。

参考资料:

本文由AI辅助整理

🍎Vue官方Skills深度解读:那些被悄悄藏起来的宝藏

vue-skills.png

Vue官方Skills是一套被严重低估的最佳实践指南。本文深度解读vue-best-practicesSkill,涵盖从响应式核心、组件模式到性能优化的22个实用技巧,每个技巧都配有清晰的正确/错误对比。这些技巧不是"推荐做法",而是Vue团队多年沉淀下来的正确做事方式。


引言:为什么这套Skills值得你花时间

Vue的文档已经很完善了,但文档告诉你的是"怎么用"。而这套Skills告诉你的是"怎么用对"。

举几个例子:

  • shallowRef vs ref,什么时候该用哪个?
  • v-if vs v-show,真的只是"条件渲染vs显示隐藏"这么简单?
  • 为什么你写的watch(useAttrs()...)从来不触发?

这套Skills的独特之处在于:它不是教你概念,而是直接告诉你错误模式和正确模式。每个知识点都有BAD/GOOD对比,看完就能用。

本文基于vue-best-practicesSkill的所有参考资料编写,涵盖22个最佳实践,覆盖组件、响应式、性能、动画等维度。

原skill

---
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)

github.com/vuejs-ai/sk…

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)

github.com/vuejs-ai/sk…

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>

第三部分:异步与缓存

github.com/vuejs-ai/sk…

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>

第四部分:性能优化

github.com/vuejs-ai/sk…

11. v-oncev-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>

第五部分:样式与动画

github.com/vuejs-ai/sk…

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>

第六部分:工具与扩展

github.com/vuejs-ai/sk…

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代码时,可以对照检查清单看看是否用对了这些模式。


延伸阅读

❌