阅读视图

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

return null:Next.js App Router 博客的 14 个 SEO 死穴

return null:Next.js App Router 博客的 14 个 SEO 死穴

Googlebot 爬你的博客,看到的是一片空白。不是服务器挂了,不是页面 404,是你亲手写的 return null 把整个 <body> 清空了。


0. 症状

部署了一个 Next.js 16 + App Router 的技术博客,文章全是 Server Component,metadata 配得整整齐齐,sitemap 也有,robots.txt 也放了。但 Google Search Console 里,收录数是 0。

curl 一看 HTML 源码:

<body>
  <!-- 空的 -->
</body>

6 篇精心写的深度技术文章,Googlebot 一个字都没看到。


1. 元凶:ClientOnly 的 return null

根 layout 里有一个 ClientOnly 组件包裹了整个 {children}

// components/AuthProvider.tsx
'use client'

export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => { setMounted(true) }, [])

  if (!mounted) return null  // ← SSR 阶段永远走这里

  return <AuthProvider>{children}</AuthProvider>
}
// app/layout.tsx
<body>
  <ClientOnly>{children}</ClientOnly>
</body>

SSR 阶段 mounted = falsereturn null → HTML body 为空。

这个组件的原意是等客户端 hydration 完成后再渲染,避免 auth 状态闪烁。但副作用是:所有页面的 SSR 输出为零。Googlebot 虽然能执行 JS,但需要等 hydration 完成才能看到内容,爬取效率和索引优先级大幅下降。

修复:删掉 if (!mounted) return null,让 SSR 阶段也正常输出 children。

export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => { setMounted(true) }, [])
  // 不阻塞 SSR:mounted=false 时也输出 children
  return <AuthProvider>{children}</AuthProvider>
}

Auth 状态在 SSR 阶段是空的,没关系——博客文章不需要登录态。


2. cookies() 暗杀 ISR

修完 SSR 后,给博客列表页配了 ISR:

export const revalidate = 3600 // 每小时重新生成

但发现每次请求仍然走服务端渲染,ISR 缓存完全没生效。

原因:页面里调用了 cookies()

// blog/page.tsx
import { cookies } from 'next/headers'

export default async function BlogPage() {
  const cookieStore = await cookies()  // ← 这行杀死了 ISR
  const token = cookieStore.get('token')?.value
  // ...
}

在 Next.js App Router 中,cookies() 是动态函数(Dynamic Function)。一旦调用,无论你怎么设 revalidate,页面都会强制进入动态渲染模式。ISR 形同虚设。

修复:把 cookie 逻辑移到客户端组件里。博客列表页本来就不需要在服务端读 cookie。


3. 缺 metadataBase,canonical 全废

每篇文章都配了 openGraph.url,但没在根 layout 设 metadataBase

// ❌ 之前
export const metadata: Metadata = {
  title: "DiffServ — V8 Performance Lab",
}

// ✅ 之后
export const metadata: Metadata = {
  metadataBase: new URL("https://diffserv.xyz"),
  title: "DiffServ — V8 Performance Lab",
}

没有 metadataBase,所有相对路径的 canonical URL、OG 图片地址都无法被 Next.js 解析为绝对 URL。搜索引擎拿到的是残缺的 meta 信息。


4. www 和裸域同时响应,权重分裂

Nginx 配置:

server_name diffserv.xyz www.diffserv.xyz;

两个域名同时响应相同内容,Google 视为两个独立站点,PageRank 被一分为二。

修复:www 单独做 301:

server {
    listen 443 ssl http2;
    server_name www.diffserv.xyz;
    return 301 https://diffserv.xyz$request_uri;
}

5. 没有 HSTS,每次首访多一次重定向

有 HTTP→HTTPS 301,但没有 Strict-Transport-Security 头。用户每次输入 diffserv.xyz 都要经历一次 80→443 的重定向,白白多 100-300ms。

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

6. 静态资源没有长缓存头

Next.js 的 /_next/static/ 文件名自带 content hash,天然可以永久缓存。但 Nginx 没配:

location /_next/static/ {
    proxy_pass http://web;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

没有这行,浏览器每次都要发条件请求验证缓存,白白浪费 RTT。


7. 没有 RSS

技术博客没有 /feed.xml = 放弃了 Feedly、Inoreader 等 RSS 阅读器的整个流量入口。在 Next.js App Router 里用 Route Handler 生成:

// app/feed.xml/route.ts
export async function GET() {
  const items = blogPosts.map(post => `
    <item>
      <title>${post.title}</title>
      <link>https://diffserv.xyz/blog/${post.slug}</link>
      <pubDate>${new Date(post.date).toUTCString()}</pubDate>
      <description>${post.description}</description>
    </item>
  `).join('')

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0">
      <channel>
        <title>DiffServ Lab</title>
        <link>https://diffserv.xyz</link>
        ${items}
      </channel>
    </rss>`

  return new Response(xml, {
    headers: { 'Content-Type': 'application/rss+xml' },
  })
}

8. 没有 OG 图片

所有文章声明了 twitter.card: summary_large_image 但没给图片 URL。社交平台分享是纯文本链接,点击率比带图低 40%+。

Next.js App Router 支持 app/opengraph-image.tsx 动态生成 OG 图片,或者在 public/ 放一张默认图然后在全局 metadata 里引用。


9. JSON-LD 缺 dateModifiedimage

Google Rich Results 要求 BlogPosting 类型至少包含 headlinedatePublisheddateModifiedimageauthor。缺少 dateModifiedimage,搜索结果中不会显示富媒体摘要(发布日期、缩略图)。


10. 没有 404 / 500 页面

Next.js App Router 默认的 404 是一个白底黑字的 "404 | This page could not be found",没有导航、没有推荐内容。用户点到死链直接流失。

创建 app/not-found.tsxapp/error.tsx,至少给一个回首页的链接和几篇推荐文章。


11. next.config.ts 为空

const nextConfig: NextConfig = {};

至少加两行:

const nextConfig: NextConfig = {
  poweredByHeader: false,           // 隐藏 X-Powered-By: Next.js
  images: { formats: ['image/avif', 'image/webp'] },
};

poweredByHeader 暴露技术栈给攻击者;不启用 AVIF 意味着放弃了 30-50% 的图片压缩率。


12. viewport 禁止缩放

export const viewport: Viewport = {
  maximumScale: 1,
  userScalable: false,
}

WCAG 2.1 明确要求用户能放大到至少 200%。这两行让 Lighthouse Accessibility 直接扣分。删掉。


13. sitemap lastModified 每次构建都变

lastModified: new Date(),  // ← 每次 ISR 重生成都是新时间

Google 看到所有 URL 的 lastModified 同时变化,会重新爬取全站,浪费 crawl budget。硬编码真实的修改日期。


14. 内部链接用了 <a> 而不是 <Link>

部分博客文章里的内部跳转(/lab/blog/xxx)用了原生 <a> 标签。Next.js 的 <Link> 组件会自动 prefetch 目标页面,用 <a> 则触发全页刷新,白白丢掉了客户端路由的性能优势。


对标 Astro:Next.js 的额外成本

维度 Astro 默认 Next.js 需要手动做
SSR 输出 纯 HTML,零 JS 需确保不被 ClientOnly 阻断
ISR 默认 SSG 需手动配 revalidate,且不能碰 cookies()
RSS @astrojs/rss 一行配 手写 Route Handler
OG 图片 社区包成熟 opengraph-image.tsx 或手动
零 JS 默认不发送 runtime Server Component 不 hydrate,但仍有 React runtime 开销
sitemap @astrojs/sitemap 自动 手动实现,需注意 lastModified

Astro 的优势是默认值就是最佳实践。Next.js 的优势是灵活性——但灵活性的代价是你必须知道每个默认值背后的坑。

如果你的博客是纯内容站,Astro 确实省心。但如果你的站点同时有博客、交互式 Lab、用户系统、API——Next.js 的全栈能力是 Astro 替代不了的。关键是:把该配的配好,把该删的删掉


修完之后

14 项全部修完后的状态:

  • HTML 源码可见全部文章内容,Googlebot 无需执行 JS
  • ISR 缓存生效,TTFB 从 ~500ms 降到 ~50ms
  • 社交分享带品牌 OG 图片
  • RSS 接入全球阅读器生态
  • HSTS preload + www 301 + immutable 缓存
  • Lighthouse Performance / SEO / Accessibility / Best Practices 全绿

不需要换框架。Next.js 能做到 Astro 做的一切,前提是你知道哪些地方需要手动补。


GitHub: hlng2002/stw-sentinel 在线实验: diffserv.xyz/lab

AI聊天界面的布局细节和打字跟随方法

AI 问答界面如何布局?

在豆包的AI问答聊天界面,为什么输入框总是会跟随在最底部?左边有导航栏,无论怎么缩小放大屏幕都会在当前问答展示界面的水平线中间?

难道是通过 position: fixed; 来实现的?但是它怎么能够解决第二个问题呢?先打开控制台看看。

在问答界面,输入框是一直被挤在最下方的,通过检查控制台会发现输入框好像会一直跟随在屏幕最下方?

image.png

但是随着控制台一直向上拉长,输入框又会被控制台覆盖?

image.png

说明根本不是通过固定定位来实现的效果。

下面来实现一下它的这种效果:这里展示的是最外层容器的布局。

<!-- 根容器 -->
<div class="chat">
    <!-- 展示容器 -->
    <div v-show='!isChat' class="chat-content">
    </div>
    <!-- 对话界面 -->
    <div v-show='isChat' class="chat-scroll-container">
    </div>
    <!-- 输入框 -->
    <div class="input-section">
    </div>
</div>

.chat {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.chat-container {
  width: 100%;
  max-width: 1000px;
  flex: 1;
  overflow-y: auto;
}
.chat-scroll-container {
  height: 100%;
  width: 100%;
  overflow-y: auto;
  flex: 1;
  /* 隐藏滚动条但保留功能 */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE/Edge */
}
.input-section {
  width: 100%;
  height: 150px;
  flex-shrink: 0;
}

可以看到核心的实现,其实就是让输入框的兄弟容器将剩余空间全部占据,而 input-section 本身只需要不压缩自身的高度就可以,当屏幕缩小后,flex:1;能占据的空间变小,而输入框高度不变将一直在外层容器最下方,当空间展示不下时会出现的可视区域外。

flex-shrink

  • 父容器必须是弹性容器。
  • 默认表示子元素固定宽度,不被压缩。
  • 在父元素使用 flex-direction: column; 改变了弹性方向后,表示子元素固定高度,不被压缩。
  • 为 1 时,表示容器会适应父容器高度被压缩。

如何让视线跟随 AI 生成的内容

下方父容器为滚动容器,子元素为主要内容展示容器。以下介绍两种 AI 打字跟随的监听方法,控制跟随与用户操作停止跟随。

<div class="chat-scroll-container" ref="scrollContainerRef" @scroll="handleScroll">
    <div class="chat-messages" ref="chatMessagesRef">
    </div>
</div>

const chatMessagesRef = ref(null);
// 滚动容器引用
const scrollContainerRef = ref(null);
// 是否启用自动滚动跟随
const enableAutoScroll = ref(true);

// 上次滚动位置
let lastScrollTop = 0;

const handleScroll = throttle(() => {
  const el = scrollContainerRef.value;
  if (!el) return;
  const { scrollTop, scrollHeight, clientHeight } = el;
  // 判断当前是否已经在底部(留20px容差)
  const isAtBottom = scrollTop + clientHeight >= scrollHeight - 20;
  // 如果用户在向上滚动超过阈值,取消自动跟随
  if (isAtBottom === false && scrollTop < lastScrollTop) {
    const upDistance = lastScrollTop - scrollTop;
    if (upDistance > 10) {
      enableAutoScroll.value = false;
    }
  }
  // 如果滚动到底部,重新开启自动跟随
  if (isAtBottom) {
    enableAutoScroll.value = true;
  }

  // 记录本次滚动位置
  lastScrollTop = scrollTop;
}, 100);

.chat-scroll-container{
    height: 1000px; 
    .chat-messages{
    // 高度由内容支撑
    }
 }

MutationObserver

监听容器的变化,包括高度、内容变化、DOM的操作等等。大多都抛弃该做法

  • 触发次数极多

  • 性能开销

  • 容易抖动、重复触发

  • 性能不如ResizeObserver

let observer = null;
onMounted(() => {
  initObserver();
});

// 初始化 MutationObserver
const initObserver = () => {
  if (!scrollContainerRef.value) return;
  // 断开旧的 observer
  if (observer) {
    observer.disconnect();
  }
  observer = new MutationObserver(() => {
    if (!enableAutoScroll.value) return;
    // 内容变化时,自动滚动到底部
    scrollToBottom();
  });
  observer.observe(scrollContainerRef.value, {
    childList: true,
    subtree: true,
    characterData: true,
  });
};
// 滚动操作
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

ResizeObserver

监听容器是否发生尺寸变化,而不是滚动容器。操作跟随需要操作滚动容器。

// 启用监听
onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value); // 监听高度变化容器
});
// 滚动操作,操作滚动容器
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑记录

背景

上个月,我接手了一个新的 NFT 铸造平台前端项目。项目要求很简单:用户点击一个“连接钱包”按钮,弹出 MetaMask 进行连接和授权,然后前端获取到用户的以太坊地址并显示出来。这听起来是 Web3 开发的“Hello World”,对吧?我心想,用老伙计 ethers.js 应该分分钟搞定。毕竟我之前在 DeFi 项目里用过很多次了。于是,我自信满满地开始敲代码,没想到接下来的几个小时,我几乎把 ethers.js 连接钱包的常见坑全踩了一遍。从 window.ethereumundefined 到账户切换监听失效,再到网络切换时的状态混乱,整个过程堪称一部小型历险记。

问题分析

我最开始的思路非常直接:在 React 组件的 useEffect 里,或者在一个按钮的点击事件中,直接调用 ethers.providers.Web3Provider 并传入 window.ethereum,然后调用 provider.send(“eth_requestAccounts”) 来请求账户。代码大概长这样:

const connectWallet = async () => {
  if (window.ethereum) {
    const provider = new ethers.providers.Web3Provider(window.ethereum);
    const accounts = await provider.send(“eth_requestAccounts”, []);
    setAccount(accounts[0]);
  } else {
    alert(‘请安装 MetaMask!’);
  }
};

但一运行就出问题了。首先,开发服务器热更新时,有时会报错 window.ethereum is undefined。其次,连接成功后,我切换到 MetaMask 的另一个账户,前端页面上的地址并没有自动更新。最后,当用户在 MetaMask 里切换网络(比如从 Goerli 切到 Mainnet),我的应用完全感知不到,还显示着旧网络下的状态。

我意识到,我把问题想得太简单了。一个健壮的钱包连接模块,至少需要处理三件事:1. Provider 的可靠获取(处理未安装钱包、页面加载时机);2. 账户变化的监听;3. 网络变化的监听。而我最初的代码,只完成了最基础的“一次性连接”功能。

核心实现

第一步:安全地获取 Provider 并连接账户

首先,我们不能直接假设 window.ethereum 存在。用户可能没安装 MetaMask,或者我们的代码在服务器端渲染(SSR)时执行。所以,获取 Provider 的逻辑必须放在客户端生命周期内,并且做好错误处理。

这里有个坑: window.ethereum 的类型。在 TypeScript 中,直接访问会报错。我们需要扩展 Window 接口。同时,MetaMask 注入的 ethereum 对象有一个 request 方法,但 ethers.jsWeb3Provider 封装得很好,我们通常用 provider.sendprovider.getSigner

我的思路是:创建一个自定义 Hook,比如叫 useEthereumProvider,来安全地创建和管理 Provider 实例。

import { BrowserProvider, JsonRpcSigner } from ‘ethers’;
import { useEffect, useState } from ‘react’;

// 扩展 Window 接口以包含 ethereum
declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useEthereumProvider = () => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);

  useEffect(() => {
    // 确保在客户端环境下执行
    if (typeof window !== ‘undefined’ && window.ethereum) {
      // 注意:ethers v6 中,Web3Provider 已更名为 BrowserProvider
      const ethersProvider = new BrowserProvider(window.ethereum);
      setProvider(ethersProvider);

      // 尝试获取已连接的账户
      ethersProvider.getSigner().then(s => setSigner(s)).catch(console.error);
    }
  }, []); // 空依赖数组,仅初始化一次

  return { provider, signer };
};

注意,我用了 ethers v6 的 BrowserProvider。如果你还在用 v5,请使用 ethers.providers.Web3Provider。这个 Hook 在组件挂载时安全地初始化 Provider。

第二步:实现连接钱包函数

有了 Provider,接下来实现具体的连接函数。这个函数需要处理用户点击“连接钱包”按钮的动作。

const [account, setAccount] = useState<string>(‘’);
const { provider } = useEthereumProvider();

const handleConnect = async () => {
  if (!provider) {
    alert(‘未检测到钱包Provider,请确认MetaMask已安装’);
    return;
  }

  try {
    // 请求账户访问权限。这里会弹出MetaMask授权窗口。
    const accounts = await provider.send(‘eth_requestAccounts’, []);
    if (accounts && accounts[0]) {
      setAccount(accounts[0]);
      // 获取 Signer 实例,用于后续签名交易
      const signer = await provider.getSigner();
      // 你可以将 signer 存储到状态或 context 中
    }
  } catch (error: any) {
    console.error(‘连接钱包失败:’, error);
    // 用户拒绝了请求
    if (error.code === 4001) {
      alert(‘您拒绝了连接请求。’);
    }
  }
};

注意这个细节: provider.send(‘eth_requestAccounts’, []) 是触发 MetaMask 弹出授权窗口的关键调用。它返回一个 Promise,用户授权后 resolve,拒绝后 reject 并带有错误码 4001

第三步:监听账户和网络变化

这是让应用“活”起来的关键。MetaMask 允许用户随时切换账户或网络,我们的前端需要实时响应。

window.ethereum 对象提供了 on 方法用于监听事件。主要监听两个事件:‘accountsChanged’‘chainChanged’

useEffect(() => {
  // 确保 ethereum 对象存在
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    console.log(‘accountsChanged’, accounts);
    if (accounts.length === 0) {
      // 用户断开了连接,或者锁定了钱包
      setAccount(‘’);
      alert(‘请连接您的钱包。’);
    } else if (accounts[0] !== account) {
      // 切换到了新账户
      setAccount(accounts[0]);
      // 通常这里需要重新获取 Signer,因为账户变了
      if (provider) {
        provider.getSigner().then(newSigner => {
          // 更新 signer 状态
        });
      }
    }
  };

  const handleChainChanged = (_chainId: string) => {
    // _chainId 是十六进制字符串,例如 ‘0x1’ (Mainnet)
    console.log(‘chainChanged’, _chainId);
    // 当网络切换时,MetaMask 建议页面重载
    // 但为了更好体验,我们可以不重载,而是更新应用内的网络状态,并重置相关数据
    window.location.reload(); // 简单粗暴但有效
    // 更优方案:更新 networkId 状态,并重新初始化合约实例等
  };

  // 添加监听
  window.ethereum.on(‘accountsChanged’, handleAccountsChanged);
  window.ethereum.on(‘chainChanged’, handleChainChanged);

  // 组件卸载时移除监听
  return () => {
    if (window.ethereum) {
      window.ethereum.removeListener(‘accountsChanged’, handleAccountsChanged);
      window.ethereum.removeListener(‘chainChanged’, handleChainChanged);
    }
  };
}, [account, provider]); // 依赖 account 和 provider

这里有个大坑: 关于 chainChanged 事件的处理。MetaMask 官方文档早期建议在 chainChanged 时刷新页面,因为许多 dApp 的状态(如合约实例)依赖于网络。虽然不刷新也可以,但你需要手动更新所有依赖网络的状态。为了简单可靠,我选择了刷新页面。在更复杂的应用中,你可能需要设计一个状态管理系统来优雅地处理网络切换。

第四步:获取当前网络信息

除了账户,我们通常还需要知道用户当前连接到了哪个网络。

const [chainId, setChainId] = useState<number | null>(null);
const { provider } = useEthereumProvider();

useEffect(() => {
  if (!provider) return;

  const fetchNetwork = async () => {
    try {
      const network = await provider.getNetwork();
      // network.chainId 是 BigInt 类型 (ethers v6)
      setChainId(Number(network.chainId));
    } catch (error) {
      console.error(‘获取网络信息失败:’, error);
    }
  };

  fetchNetwork();
  // 注意:provider.getNetwork() 可能不会随 chainChanged 自动更新。
  // 所以我们依赖上一步的 chainChanged 事件,触发重新获取或页面刷新。
}, [provider]);

完整代码

下面是一个整合了以上所有功能的、可直接运行的 React 组件示例。

// WalletConnector.tsx
import { BrowserProvider, JsonRpcSigner } from ‘ethers’;
import React, { useEffect, useState } from ‘react’;

declare global {
  interface Window {
    ethereum?: any;
  }
}

const WalletConnector: React.FC = () => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [account, setAccount] = useState<string>(‘’);
  const [chainId, setChainId] = useState<number | null>(null);
  const [loading, setLoading] = useState<boolean>(false);

  // 1. 初始化 Provider
  useEffect(() => {
    if (typeof window !== ‘undefined’ && window.ethereum) {
      const ethersProvider = new BrowserProvider(window.ethereum);
      setProvider(ethersProvider);
      // 尝试静默获取已连接的账户
      ethersProvider.getSigner()
        .then(s => {
          setSigner(s);
          s.getAddress().then(addr => setAccount(addr));
        })
        .catch(() => {/* 用户未连接,忽略错误 */});
    }
  }, []);

  // 2. 获取初始网络
  useEffect(() => {
    if (!provider) return;
    provider.getNetwork().then(network => {
      setChainId(Number(network.chainId));
    });
  }, [provider]);

  // 3. 设置事件监听
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log(‘账户变更:’, accounts);
      if (accounts.length === 0) {
        // 断开连接
        setAccount(‘’);
        setSigner(null);
        alert(‘钱包已断开。’);
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
        // 更新 signer
        provider?.getSigner().then(s => setSigner(s));
      }
    };

    const handleChainChanged = (_chainId: string) => {
      console.log(‘网络变更:’, _chainId);
      // 简单处理:刷新页面
      window.location.reload();
    };

    window.ethereum.on(‘accountsChanged’, handleAccountsChanged);
    window.ethereum.on(‘chainChanged’, handleChainChanged);

    return () => {
      if (window.ethereum) {
        window.ethereum.removeListener(‘accountsChanged’, handleAccountsChanged);
        window.ethereum.removeListener(‘chainChanged’, handleChainChanged);
      }
    };
  }, [account, provider]);

  // 4. 连接钱包函数
  const handleConnect = async () => {
    if (!provider) {
      alert(‘请安装 MetaMask 钱包扩展!’);
      return;
    }
    setLoading(true);
    try {
      const accounts = await provider.send(‘eth_requestAccounts’, []);
      const currentAccount = accounts[0];
      setAccount(currentAccount);
      const currentSigner = await provider.getSigner();
      setSigner(currentSigner);
      // 获取并更新网络
      const network = await provider.getNetwork();
      setChainId(Number(network.chainId));
    } catch (error: any) {
      console.error(‘连接失败:’, error);
      if (error.code === 4001) {
        alert(‘连接请求被拒绝。’);
      }
    } finally {
      setLoading(false);
    }
  };

  // 5. 断开连接 (MetaMask 没有真正的“断开”,这里只是清除本地状态)
  const handleDisconnect = () => {
    setAccount(‘’);
    setSigner(null);
    alert(‘已断开本地连接。如需完全断开,请在 MetaMask 中操作。’);
  };

  return (
    <div style={{ padding:20px’, border:1px solid #ccc’, borderRadius:8px’ }}>
      <h3>钱包连接状态</h3>
      {!provider ? (
        <p>⚠️ 未检测到钱包Provider。请确保 MetaMask 已安装并启用。</p>
      ) : (
        <>
          <p>
            <strong>网络ID:</strong> {chainId ? `0x${chainId.toString(16)}` : ‘未知’}
          </p>
          <p>
            <strong>当前账户:</strong> {account ? `${account.substring(0, 6)}…${account.substring(account.length - 4)}` : ‘未连接’}
          </p>
          <div>
            {!account ? (
              <button onClick={handleConnect} disabled={loading}>
                {loading ? ‘连接中…’ : ‘连接 MetaMask’}
              </button>
            ) : (
              <div>
                <button onClick={handleDisconnect} style={{ marginLeft:10px’ }}>
                  断开连接
                </button>
              </div>
            )}
          </div>
          {signer && (
            <p style={{ marginTop:10px’, color:green’ }}>
              ✅ Signer 已就绪,可进行签名操作。
            </p>
          )}
        </>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum is undefined (Next.js/SSR 环境)

    • 现象: 在 Next.js 项目中,组件首次渲染或热更新时控制台报错。
    • 原因: 代码在服务端或构建时执行,window 对象不存在。
    • 解决: 所有访问 window.ethereum 的代码都必须包裹在 if (typeof window !== ‘undefined’) 条件判断中,或放在 useEffect、事件处理函数等客户端生命周期钩子中。
  2. 账户切换后页面不更新

    • 现象: 在 MetaMask 里切换了账户,但 dApp 页面上显示的地址还是旧的。
    • 原因: 没有监听 accountsChanged 事件。
    • 解决: 按照上文所述,正确添加 window.ethereum.on(‘accountsChanged’, callback) 监听,并在回调中更新 React 状态。注意: 当用户断开连接时,accounts 数组为空,需要处理这个情况。
  3. 网络切换后合约调用出错

    • 现象: 用户从 Goerli 切换到 Mainnet,dApp 仍尝试在旧网络的合约地址上调用,导致 RPC 错误。
    • 原因: 没有监听 chainChanged 事件,或监听后没有更新依赖网络的合约实例等状态。
    • 解决: 监听 chainChanged 事件。采用简单方案(刷新页面)或复杂方案(更新全局网络状态并重新初始化所有网络相关的对象,如 Provider、Signer、合约实例等)。
  4. ethers v5 与 v6 的 API 差异

    • 现象: 照着旧教程写代码,发现 Web3Provider 等类找不到。
    • 原因: 项目安装的是 ethers v6,其 API 有重大变更。
    • 解决: 查阅官方升级指南。关键变化:ethers.providers.Web3Provider 变为 ethers.BrowserProviderprovider.getSigner().getAddress() 返回 Promise;chainId 是 BigInt 类型。务必检查你使用的版本。

小结

通过这次实践,我深刻体会到 Web3 前端开发中“细节决定成败”。一个看似简单的钱包连接,需要考虑 Provider 的生命周期、用户交互的多种可能(授权、拒绝、切换)以及钱包状态的持续同步。最终稳定可用的代码,是初始化、请求授权、事件监听和状态管理四部分紧密协作的结果。如果你要在此基础上继续深挖,下一步可以考虑将钱包状态管理抽象为 Context 或使用状态管理库(如 Zustand、Jotai),以支持多组件共享,并集成更多钱包类型(如 WalletConnect)以实现更好的用户体验。

elpis 全栈里程碑一总结

elpis 里程碑一总结

1.项目架构设计

image.png

1-1.聚焦buff层

buff分三个层级:

  1. 接入层: router接口路由分发, router-schema路由规则校验 ,middleware路由中间件
  2. 业务层: controller处理器, env环境分发,config提取,extend服务拓展,schedule定时任务
  3. 服务层: service处理器

1-2.而elpis-core就是基于buff层设计的用node.js+koa

2.对于elpis-core的理解

graph TD
运行前磁盘文件 --> 解析器  -->  运行时内存

1.elpis-core是一个自动把文件挂载到koa实例上的一个引擎,也是一个轻量版的egg.js内核,他的设计理念是约定式加载,通过写好的各种loader,从预先约定好的目录结构中读取各种js 文件,按照一定的顺序挂载到由koa 创建的app 实例上面,可通过app.midddleware.${目录}.${文件}访问

  • 例如:
 *   app/service
 *     |
 *     |-- custom-module
 *          |
 *          |-- custom-service.js
 *  => app.service.customModule.customservice

3.elpis-core的结构

loader 说明
server.js 业务模块的自动加载
extend.js 自动加载扩展,例:外部日志工具log4js
router.js 引入了一个Koa挂载 extend 到 app 上,这个extend可以用来引入日志工具,先把所有 app/router的文件加载到KoaRouter 下,再将路由注册到 app下
routerSchema.js 对应router的参数的一个具体校验解释文件,
controller.js 控制器自动加载
config.js 配置区分 本地/测试/生产环境,通过env环境读取不同文件配置 env.config,然后通过 env.config 覆盖 default.config 加载到 app.config 中
middleware.js 引入自定义中间件loader,如模板渲染中间件等,让中间件自动加载

4.koa内部模型(洋葱圈模型)

image.png

什么是洋葱圈模型? 中间件执行流程的形象比喻,通过 next() 让代码先一层层进入,再一层层退出,像切开的洋葱一样。
为什么是先进后出? 因为 next() 会暂停当前函数,调用下一个函数,这符合调用栈的后进先出(LIFO) 特性。
有什么好处? 每个中间件可以在请求前响应后都执行逻辑,实现对称处理,非常适合日志、认证、错误处理等场景。

5.相关中间件

中间件 说明
koa-static 解决静态资源的加载,可以在app/public目录下自动加载相关的静态资源,如:css、png等。
koa-nunjucks-2 用于服务器端渲染 HTML(SSR), 全局中间件中引入了koa-nunjucks-2,挂载到了ctx上,从而使得ctx上有render方法
koa-bodyparser 用于解析 HTTP 请求体,并将数据挂载到 ctx.request.body ,因为Koa 默认无法直接获取请求体中的 body 数据。
log4js 为日志工具属于Extend,通过 app.logger.info 记录日志并落地磁盘。
api-params-verify 参数校验基于 JSON-Schema 和 Ajv,配合中间件使用,确保接口数据安全。
api-sign-verify 接口签名防止数据篡改。前后端约定 Key,通过 MD5(参数+Key) 校验合法性。
error-handler 对一些异常的报错进行处理,避免用户请求服务出问题,返回一些不必要的内容

6.SSR

BFF部署在服务器内网,向后端多个服务发起请求延迟极低,甚至可以并行请求。这远比在浏览器端一个一个请求后端API要快得多,所以BFF层提供了一个完美的地方来做SSR所需要的数据准备工作

1.当前项目提供SSR,在router中调用controller中方法,在controller中写了renderPage方法,因为引入了koaNunjucks,在ctx中可以拿到render方法,所以可以直接在浏览器中输入路由地址渲染出所对应的页面

//middleware
module.exports = (app)=>{

    // 配置静态根目录
    const koaStatic = require('koa-static');
    app.use(koaStatic(path.resolve(process.cwd(), `./app/public`)));
    
        // 模板渲染引擎
        const koaNunjucks = require('koa-nunjucks-2');
        app.use(koaNunjucks({
            ext: 'tpl',
            path: path.resolve(process.cwd(), `./app/public`),
            nunjucksConfig: {
                noCache:true,
                trimBlocks: true
            }
        }));
}
//controller
module.exports = (app) => { 
    return class ViewController {
        /**
         * 渲染页面
         * @param {*} ctx 上下文
         */
        async renderPage(ctx){
            await ctx.render(`dist/entry.${ctx.params.page}`,{
                name:app.option?.name,
                env:app.env.get(),
                option:JSON.stringify(app.option),
            });
        }
    }
}
//router
module.exports = (app,router) => { 
    const {view: ViewController } = app.controller;
    // 用户输入http://ip:
    router.get('/view/:page',ViewController.renderPage.bind(ViewController))
}

7.为何 Controller 和 Service 要用class?

在 Elpis-Core 中,Controller 和 Service 被设计为 Class(类) ,这带来了极高的可扩展性。

  • 复用性:可以定义一个 BaseController(基类),封装通用方法(如 success 成功返回、fail 错误返回)。
  • 继承性:业务 Controller(如 ProjectController)继承基类,直接复用公共方法。 总结: BFF层级(后端)其实主要由解析引擎以及业务模块两大板块组成

8.总结

解析引擎elpis-core的主要作用是将各个业务模块聚合在一起并保证其在程序内存中正常运行。这也是例如Eggjs、Nextjs、Nestjs等框架做的事情,也是这些框架设计背后的思想

TailwindCSS 核心概念与实用技巧:从传统CSS到Utility-First迁移指南

引言:为什么越来越多人用Tailwind?

你是否还在为CSS命名发愁?

.container .header .button-primary 想破脑袋还是避免不了命名冲突。

传统CSS开发中,我们常常遇到这些痛点:

1. CSS文件越来越臃肿 项目迭代一段时间后,你会发现写了大量重复样式,却不敢删除旧代码,怕哪里出问题。最后CSS文件几千行,大部分都是无用代码。

2. 命名是永恒的难题 使用BEM命名规范?button button--primary button--large 虽然规范,但写起来冗长又繁琐。稍微复杂点的组件,命名就变成了玄学。

3. 样式和组件分离 写React/Vue组件时,JSX/模板里写了结构,还要跑到另一个CSS文件写样式,来回切换上下文,开发效率被打断。

4. 改样式要改多个文件 调整个间距颜色,要找到对应的CSS类,修改完还要回来检查,一不小心影响其他地方样式。

TailwindCSS 为什么能在近几年迅速流行?因为它从根本上解决了这些问题。

它把CSS带回到你的HTML中,用原子化的Utility类让你不用再写CSS,同时保持代码整洁可维护。

据统计,npm 下载量已经突破百万,Vue、React、Next.js 等主流框架都官方支持,越来越多团队开始全面采用。


什么是Utility-First?和传统CSS/BEM/CSS-in-JS的区别

先搞懂核心思想:Utility-First就是原子化CSS

简单说,Tailwind提供了大量功能单一的工具类,比如 text-center 代表文字居中,pt-4 代表上内边距1rem。

你不需要再写新的CSS,只需要在HTML中组合这些工具类就能构建出任何样式。

我们来对比一下不同方案:

传统CSS写法

<!-- HTML -->
<button class="btn btn-primary">点击我</button>
/* CSS */
.btn {
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  font-weight: 500;
}

.btn-primary {
  background-color: #3b82f6;
  color: white;
}

.btn-primary:hover {
  background-color: #2563eb;
}

BEM写法

<button class="button button--primary button--medium">点击我</button>
.button {
  font-family: system-ui;
  border: none;
  outline: none;
}

.button--primary {
  background-color: var(--color-primary);
  color: white;
}

.button--medium {
  padding: 8px 16px;
  font-size: 14px;
}

BEM解决了命名冲突问题,但还是需要不断写新的CSS,类名越来越长。

Tailwind Utility-First 写法

<button
  class="px-4 py-2 font-medium text-white bg-blue-500
             hover:bg-blue-600 rounded"
>
  点击我
</button>

不需要写任何CSS!所有样式都通过组合Utility类直接在HTML中完成。

CSS-in-JS 写法(对比参考)

// Styled Components 写法
const Button = styled.button`
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  font-weight: 500;
  background-color: #3b82f6;
  color: white;

  &:hover {
    background-color: #2563eb;
  }
`;

<Button>点击我</Button>;

CSS-in-JS把CSS放到JS里,解决了作用域问题,但运行时有开销,调试也相对麻烦。

Tailwind 则是纯CSS方案,构建时移除无用代码,最终产物体积很小,同时保留了CSS的原生优势。

一句话总结区别:

  • 传统CSS/BEM:语义化命名,一个类对应多个样式属性
  • Utility-First:功能单一,一个类只做一件事
  • CSS-in-JS:JS掌管样式,组件级作用域

Tailwind核心概念详解

1. 配置文件 tailwind.config.js

安装完Tailwind后,根目录会有一个 tailwind.config.js 配置文件。

这是Tailwind的神经中枢,你可以在这里自定义主题、断点、颜色、间距等等。

基础配置示例:

/** @type {import('tailwindcss').Config} */
module.exports = {
  // 扫描所有项目文件,找出用到的类,用于Tree Shaking
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,vue,html}"],
  theme: {
    // 扩展默认主题,不会覆盖
    extend: {
      // 自定义颜色
      colors: {
        primary: "#165DFF",
        secondary: "#6b7280",
      },
      // 自定义字体
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
      },
      // 自定义断点
      screens: {
        "3xl": "1920px",
      },
    },
  },
  // 第三方插件
  plugins: [],
};

如果你想完全覆盖默认主题,可以直接在 theme 里定义,不使用 extend

theme: {
  // 完全自定义颜色,会替换Tailwind默认颜色
  colors: {
    blue: {
      50: '#f0f9ff',
      100: '#e0f2fe',
      // ... 一直到 900
      600: '#2563eb',
    }
  }
}

对于中文开发者,建议在配置中加入中文字体优化:

theme: {
  extend: {
    fontFamily: {
      chinese: ['PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'sans-serif'],
    },
  },
}

使用的时候直接:

<body class="font-chinese"></body>

2. @layer 分层机制

Tailwind 用 @layer 把样式分成三层:basecomponentsutilities

这个分层机制帮你正确排序CSS优先级,避免特异性冲突。

/* 在你的style.css中 */
@tailwind base;
@tailwind components;
@tailwind utilities;

我们分别解释:

@layer base - 基础样式层

用于重置浏览器默认样式,或者给HTML标签添加默认样式。

@layer base {
  h1 {
    @apply text-3xl font-bold mb-4;
  }
  h2 {
    @apply text-2xl font-semibold mb-3;
  }
  a {
    @apply text-blue-600 hover:underline;
  }
}

Base层优先级最低,后面的classes和utilities可以覆盖它。

@layer components - 组件层

用来提取可复用的组件样式,优先级高于base。

@layer components {
  .btn {
    @apply px-4 py-2 rounded font-medium;
  }
  .btn-primary {
    @apply bg-primary text-white hover:bg-primary/90;
  }
  .card {
    @apply bg-white rounded-lg shadow p-6;
  }
}

然后就可以在HTML中直接使用:

<button class="btn btn-primary">提交</button>
<div class="card">内容</div>

@layer utilities - 工具类层

优先级最高,如果你需要添加自定义工具类,放在这里。

@layer utilities {
  .text-shadow {
    text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  }
  .content-auto {
    content-visibility: auto;
  }
}

放在 @layer utilities 里的自定义工具类,优先级比Tailwind自带的工具类还要高吗?不,它和Tailwind自带的utilities同级,后面写的会覆盖前面的。

记住这个优先级顺序:base < components < utilities,这样就不会出现奇怪的样式覆盖问题。

3. Purge / Tree-shaking 工作原理

Tailwind v3 默认就开启了Tree-shaking,它会扫描你所有的模板文件,只保留实际用到的Utility类。

工作流程:

  1. 你在 content 配置里指定了要扫描的文件路径
  2. 构建时,Tailwind 从这些文件中提取出所有用到的class名称
  3. 只生成这些class对应的CSS,没有用到的全部移除

举个例子,你的项目只用到了 px-4 py-2 bg-blue-500,那Tailwind就只会生成这几个类对应的CSS,其他所有没用到的padding、margin、颜色都不会出现在最终CSS文件中。

所以即使Tailwind默认包含了几千个Utility类,最终打包出来的CSS通常只有几KB到十几KB,比你自己写的CSS还小。

配置示例(v3标准写法):

// tailwind.config.js
module.exports = {
  content: [
    // 所有可能用到Tailwind类的文件都要写在这里
    "./index.html",
    "./src/**/*.{js,jsx,ts,tsx,vue,html}",
  ],
};

注意事项:如果你用了动态class拼接,需要用安全列表:

module.exports = {
  content: [...],
  safelist: [
    // 强制保留这些类,不会被摇掉
    'bg-red-500',
    'bg-green-500',
    'bg-yellow-500',
    // 或者用模式匹配
    {
      pattern: /bg-(red|green|yellow)-.+/,
    }
  ]
}

这在中文开发中很常见,比如后台配置返回不同状态的样式类,一定要记得加safelist,不然生产环境样式会丢。

4. 响应式断点系统

Tailwind的响应式设计非常简单,默认提供了五个断点:

断点 最小值 对应设备
sm 640px 手机横屏
md 768px 平板
lg 1024px 小桌面
xl 1280px 大桌面
2xl 1536px 超大桌面

使用方法非常简单:在类名前加上断点前缀就是了。

示例:移动端单列,平板双列,桌面三列

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  <div>卡片1</div>
  <div>卡片2</div>
  <div>卡片3</div>
</div>

解释一下:

  • grid-cols-1:默认(小于640px)单列
  • md:grid-cols-2:宽度 ≥768px 变成双列
  • lg:grid-cols-3:宽度 ≥1024px 变成三列

更实际的导航栏示例:移动端汉堡菜单,桌面端全链接

<nav class="bg-white shadow fixed w-full">
  <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
    <div class="flex justify-between h-16">
      <div class="flex">Logo</div>
      <!-- 桌面端菜单 -->
      <div class="hidden md:flex items-center space-x-4">
        <a href="#" class="text-gray-700 hover:text-blue-600">首页</a>
        <a href="#" class="text-gray-700 hover:text-blue-600">产品</a>
        <a href="#" class="text-gray-700 hover:text-blue-600">关于</a>
      </div>
      <!-- 移动端汉堡按钮 -->
      <div class="md:hidden flex items-center">
        <button>🍔</button>
      </div>
    </div>
  </div>
</nav>

hidden md:flex 的意思是:默认隐藏,大于等于md(768px)才显示,完美实现响应式切换。

自定义断点也很简单,在配置里加就行:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      screens: {
        xs: "480px", // 比sm更小的断点
        "3xl": "1920px", // 更大屏幕
      },
    },
  },
};

实用开发技巧

1. 提取组件(@apply) vs 保持纯utility

这是Tailwind开发中最常见的问题:什么时候该提取组件,什么时候直接堆Utility类?

两种方式都可以,我们来看具体例子。

直接保持纯Utility写法

<button
  class="px-4 py-2 text-sm font-medium text-white bg-blue-600
             hover:bg-blue-700 rounded-lg focus:outline-none
             focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
  提交
</button>

优点:所有样式都在这里,一目了然,不用跳去别的文件看。适合一次性、不重复使用的按钮。

使用 @apply 提取为可复用组件

@layer components {
  .btn-primary {
    @apply px-4 py-2 text-sm font-medium text-white bg-blue-600
           hover:bg-blue-700 rounded-lg focus:outline-none
           focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
  }
}

然后HTML就很简洁:

<button class="btn-primary">提交</button>
<button class="btn-primary">保存</button>

优点:复用方便,统一修改只改一处。适合项目中多处使用的组件。

在Vue/React组件中提取

这其实是更推荐的方式,因为你已经在使用组件化框架了,为什么不直接用组件呢?

React示例:

function Button({ children, ...props }) {
  return (
    <button
      className="px-4 py-2 text-sm font-medium text-white bg-blue-600
                 hover:bg-blue-700 rounded-lg focus:outline-none
                 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
      {...props}
    >
      {children}
    </button>
  );
}

// 使用
<Button>点击我</Button>;

Vue示例:

<template>
  <button
    class="px-4 py-2 text-sm font-medium text-white bg-blue-600
                hover:bg-blue-700 rounded-lg focus:outline-none
                focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  >
    <slot />
  </button>
</template>

我的建议

  • 如果用组件化框架(React/Vue),优先用JSX/Vue组件提取,不要用@apply写到CSS里
  • 如果是纯HTML项目或者需要配合后端模板引擎,用@layer components提取
  • 不要过度提取,只提取真正会复用的组件,一次性的代码直接堆Utility就好

2. 暗色模式实现

Tailwind v3 内置暗色模式支持,开箱即用。

先在配置中开启:

// tailwind.config.js
module.exports = {
  darkMode: "class", // 或者 'media' 跟随系统
  // ...
};

darkMode: 'media' 会自动根据系统暗色切换,darkMode: 'class' 适合手动切换(用户点击按钮切换)。

使用方式:加上 dark: 前缀。

<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1>你好,世界</h1>
  <p>这是一段文字</p>
</div>

htmlbody 标签加上 dark class 后,暗色模式就激活了:

<html class="dark">
  <!-- 所有dark:前缀的样式都会生效 -->
</html>

实现手动切换的JS代码:

// 检查用户偏好
if (
  localStorage.theme === "dark" ||
  (!("theme" in localStorage) &&
    window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
  document.documentElement.classList.add("dark");
} else {
  document.documentElement.classList.remove("dark");
}

// 切换函数
function toggleDarkMode() {
  if (document.documentElement.classList.contains("dark")) {
    document.documentElement.classList.remove("dark");
    localStorage.theme = "light";
  } else {
    document.documentElement.classList.add("dark");
    localStorage.theme = "dark";
  }
}

卡片带暗色的完整示例:

<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
  <h3 class="text-gray-900 dark:text-white font-semibold">标题</h3>
  <p class="text-gray-600 dark:text-gray-300 mt-2">
    这是描述文字,在暗色模式下会变浅。
  </p>
  <button
    class="mt-4 px-4 py-2 bg-blue-600 dark:bg-blue-500
                 text-white rounded"
  >
    按钮
  </button>
</div>

3. Hover/Focus 等交互状态

Tailwind给所有交互状态都提供了变体前缀,直接用就行。

基础示例:

<!-- Hover -->
<button class="bg-blue-500 hover:bg-blue-600 text-white">Hover我变色</button>

<!-- Focus -->
<input
  class="border focus:outline-none focus:ring-2
             focus:ring-blue-500 border-gray-300 rounded px-3 py-2"
  placeholder="点击我看看"
/>

多个状态可以叠加:

<button
  class="bg-green-500 hover:bg-green-600
             focus:ring-2 focus:ring-green-500 focus:ring-offset-2
             active:bg-green-700
             disabled:opacity-50 disabled:cursor-not-allowed
             text-white px-4 py-2 rounded"
>
  按钮
</button>

常用状态变体列表:

  • hover: 鼠标悬停
  • focus: 获得焦点
  • active: 鼠标按下
  • disabled: 禁用状态
  • first: 第一个子元素
  • last: 最后一个子元素
  • odd: 奇数行
  • even: 偶数行
  • hover:dark: / dark:hover: 暗色模式下的hover

响应式和状态可以组合,顺序没关系:md:hover:bg-blue-500hover:md:bg-blue-500 效果一样。

4. group-hover 群组变体

很多时候我们希望鼠标悬停在父元素上,改变子元素的样式,这就需要 group-hover

使用分两步:

  1. 给父元素加上 group class
  2. 给子元素加上 group-hover: 前缀

卡片示例:鼠标悬停卡片时,让按钮背景变色。

<div class="group card border rounded-lg p-6 hover:shadow-lg">
  <h3 class="group-hover:text-blue-600">卡片标题</h3>
  <p>卡片内容...</p>
  <button
    class="bg-gray-200 group-hover:bg-blue-600
                 group-hover:text-white mt-4 px-4 py-2 rounded"
  >
    查看详情
  </button>
</div>

导航栏下拉菜单示例:

<div class="group relative inline-block">
  <button class="group-hover:text-blue-600">产品菜单 ▼</button>
  <div class="absolute hidden group-hover:block w-48 bg-white shadow">
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品1</a>
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品2</a>
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品3</a>
  </div>
</div>

完美!不需要写任何JS,纯CSS实现悬停显示下拉菜单。

还有 group-focusgroup-active,用法一样,针对focus和active状态。

5. 任意值方括号语法

Tailwind v3 最香的功能就是任意值语法,用方括号 [] 直接写任意值。

什么时候用?当你需要一个Tailwind默认没提供的值,不用去改配置文件,直接写:

/* 自定义宽度 */
<div class="w-[310px]">
  /* 自定义定位 */
  <div class="top-[13px] left-[7px]">
    /* 自定义颜色 */
    <div class="bg-[#165DFF] text-[#fff]">
      /* 自定义字体大小 */
      <h1 class="text-[32px]">
        /* 自定义间距 */
        <div class="m-[14px] p-[8px]"></div>
      </h1>
    </div>
  </div>
</div>

组合响应式也没问题:

<div class="w-[300px] md:w-[500px] lg:w-[720px]"></div>

甚至可以写CSS自定义属性:

<div class="bg-[--primary-color]"></div>

这解决了什么问题?以前你想要一个特殊尺寸,必须去tailwind.config.js里扩展,现在直接方括号搞定,非常方便。

但是注意:不要滥用,能用上默认值就用默认值,比如 px-4 能满足就别写 px-[16px]。只有默认值满足不了的时候再用任意值语法。


常见迁移误区

误区一:过早提取组件

很多人从传统CSS转过来,习惯了一切都抽成组件,刚写了一个按钮就想着提取出来。

错误示例

项目才刚开始,按钮只用到一次,就急着提取:

@layer components {
  .header-button {
    @apply ... /* 只在头部用到一次 */;
  }
  .sidebar-item {
    @apply ... /* 只用到一次 */;
  }
}

问题:需求一变,这个组件就不用了,你白提了,而且还要维护CSS。

正确做法

重复出现第二次的时候再提取。

第一次写,直接堆Utility,第二次碰到一样的,复制过去,第三次还碰到,这时候你知道它真的需要复用,再提取也不迟。

误区二:混乱的class顺序

很多人写Tailwind,class顺序乱排,读起来非常费劲。

混乱示例

<button
  class="text-white hover:bg-blue-600 px-4 bg-blue-500 py-2 rounded"
></button>

顺序乱了,你很难快速读懂这个按钮有哪些样式。

推荐的排序思路

按这个顺序排列,可读性大大提高:

  1. 定位布局类:position, top/right/bottom/left, z-index, display, flex/grid, flex-wrap, justify-, items-, gap, w, h, m, p
  2. 边框阴影:border, rounded, shadow
  3. 背景文字颜色:bg, text
  4. 字体样式:font-, text-
  5. 交互状态:hover:, focus:, active:, disabled:, group-hover:
  6. 响应式变体:sm:, md:, lg:, xl:

整理之后

<button
  class="px-4 py-2 bg-blue-500 text-white rounded
             hover:bg-blue-600"
></button>

舒服多了对不对?

很多编辑器有Tailwind插件,可以自动排序,推荐开启。如果你用VSCode,安装 bradlc.vscode-tailwindcss 插件,开启 editor.codeActionsOnSave 自动排序。

误区三:不知道什么时候用自定义CSS

很多人转了Tailwind之后,觉得什么都能用Utility搞定,其实不是。

Tailwind不排斥自定义CSS,该用的时候就要用。

适合用自定义CSS的场景

场景一:复杂的媒体查询和关键帧动画

/* 自定义动画,这用Utility不好写,放在全局CSS就行 */
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-fadeIn {
  animation: fadeIn 0.3s ease-out;
}

然后把它放在 @layer utilities 里,就能在HTML中用了:

@layer utilities {
  .animate-fadeIn {
    animation: fadeIn 0.3s ease-out;
  }
}

场景二:复杂伪元素

/* 比如清除浮动 */
@layer utilities {
  .clearfix::after {
    content: "";
    display: table;
    clear: both;
  }
}

场景三:你就是需要写原生CSS的时候

Tailwind只是工具,不是宗教,如果你觉得写原生CSS更清晰更简洁,那就直接写。

错误做法

用一堆方括号拼出一个复杂CSS,可读性极差:

<div class="[&:nth-child(2n+1)]:mr-0 [&>span]:absolute [...]"></div>

这种情况不如抽出来写自定义CSS。

误区四:覆盖Tailwind默认样式时优先级错了

如果你没有用 @layer,直接写在全局CSS,会出现优先级问题。

错误示例

/* 没有加@layer,这个样式会被Tailwind utilities覆盖 */
.btn-primary {
  background-color: red !important; /* 被迫加important */
}

正确做法

@layer components {
  /* components层优先级在utilities之前,不需要important */
  .btn-primary {
    background-color: red;
  }
}

记住:只要是自定义的Tailwind相关样式,都放到 @layer 里面,让Tailwind帮你处理优先级。


总结:给传统CSS开发者的迁移建议

从传统CSS转到Utility-First开发思维,需要一个适应过程,这里给大家几个实用建议:

1. 不要一开始就全量迁移

如果你有一个成熟的老项目,不用一下子全部改成Tailwind。可以配合着用,新组件用Tailwind写,旧组件慢慢迁移。

Tailwind和传统CSS可以和平共处。

2. 不要害怕HTML变"脏"

刚转过来会觉得一堆class写在HTML里很脏,这不符和"表现与结构分离"的思想啊?

这是思维转换中最关键的一步。其实,当你适应了Utility-First,你会发现这样反而更直观,不用来回跳文件找样式。

3. 优先用默认配置,少自定义

Tailwind默认的设计系统已经非常完善了,颜色、间距、断点都有了,能满足90%的场景。不要一开始就去推翻默认配置重写一遍。

默认值够用就用默认值,不够用了再用方括号或者配置扩展。

4. 善用编辑器插件提升开发效率

VSCode的Tailwind CSS IntelliSense 插件一定要装,自动补全class名称,提示颜色,非常好用。

5. 记住这个决策树

遇到问题不知道该怎么做,问自己:

  • 这个会复用吗?不会 → 直接堆Utility
  • 会复用吗?会 → 用React/Vue组件提取(如果用框架)
  • 框架也不好处理 → 用@layer components提取
  • Utility搞不定 → 写自定义CSS,放到@layer utilities

Tailwind不是银弹,但它确实解决了CSS开发中长期存在的很多问题。对于从传统CSS转过来的开发者,只要适应了Utility-First的思维,开发效率会提升很多。

开始动手试试吧,从一个小组件开始,慢慢你就会爱上这种开发方式。


图片大模型实践:可灵(Kling)文生图前后端实现

图片大模型实践:可灵(Kling)文生图前后端实现

本文讲图片模型里「可灵文生图」这一条链路:鉴权、代理、前端如何拼 URL、如何从异步任务结果里取出最终图片地址。语音或其它模型后续再单独开章节。

建议阅读顺序:先看下面「快速跑通」与「架构与数据流」,需要对照实现时再打开附录里的核心摘录或 GitHub 完整文件——不必在中间通读近千行粘贴代码。

可以先看下文本模型的文章,这篇是后续。

模型的使用,大差不差,去模型网站买额度,然后生成key,然后接口调用。


效果图

keling.gif

先去申请 可灵的 Key,可以的话充点小钱做实验。


一、快速跑通(三文件 + Git)

准备一个新目录,放入下面三个文件即可跑通可灵文生图(.env.local 勿提交到 Git)。

文件 作用
index-keling.html 前端单页:拼 URL、轮询、用 img 展示结果图
server.js 后端:读环境变量、签 JWT、转发 /kling/v1/...
.env.local(自建) 配置 ACCESS_KEY_IDACCESS_KEY_SECRET

克隆仓库:

git clone https://github.com/frontzhm/text-model.git
cd text-model

仓库主页: github.com/frontzhm/te…

.env.local 示例(与 server.js 同目录):

ACCESS_KEY_ID=你的AccessKey
ACCESS_KEY_SECRET=你的SecretKey
# 可选:KLING_API_ORIGIN=https://api-beijing.klingai.com

启动:

node server.js
# 另开终端,用静态服务打开页面(避免 file:// 下 ES Module 限制)
npx --yes serve .
# 浏览器访问 /index-keling.html,「代理」填 http://127.0.0.1:3000

二、为什么要有「后端」这一层?

可灵 API 与很多厂商一样,要求:

  1. 鉴权:用 AccessKey + SecretKey 按固定规则生成 JWT,放在 Authorization: Bearer <token> 里;
  2. HTTPS + 指定域名:国内新系统常用 https://api-beijing.klingai.com(与旧域名不同,用错域容易出现 401 / Auth failed);
  3. 浏览器限制:Secret 不能进前端;也不适合在页面里实现签名逻辑。

因此加一层 BFF:本仓库的 server.js 负责读 .env.local签发 JWT、把 /kling/v1/... 转发到可灵域名;浏览器只访问本地 http://127.0.0.1:3000


三、后端:server.js 里三件事

3.1 读环境变量

从项目根目录的 .env.local / .env 按行解析 KEY=value,例如:

  • ACCESS_KEY_ID / ACCESS_KEY_SECRET(或 KLING_* 别名)
  • 可选:KLING_API_ORIGIN(默认 https://api-beijing.klingai.com

3.2 生成 JWT(与官方 Python jwt.encode 一致)

  • Headeralg=HS256typ=JWT
  • Payloadiss = AccessKeyId,exp = now+1800s,nbf = now−5s
  • Signature:对 base64url(header).base64url(payload)HMAC-SHA256,再 Base64URL

使用 Node 内置 crypto.createHmac,无需 jsonwebtoken 包。

3.3 反向代理:路径「前缀剥离」+ 上游拼接

浏览器请求例如:http://127.0.0.1:3000/kling/v1/images/generations

  1. 剥前缀 /kling → 可灵 REST 路径 /v1/images/generations
  2. 拼上游KLING_API_ORIGIN + restPath + search
  3. 带上 Authorization: Bearer <刚签的 JWT> 转发 fetch,原样回写 status 与 body。

restPath 必须 /v1/ 开头且不含 ..,防止代理滥用。


四、前端:index-keling.html 在做什么?

技术栈:Vue 3(CDN ESM)。页面不存 AK/SK,只填代理根地址、Prompt、resolution / aspect_ratio 等。

4.1 创建任务(POST)

base = 代理根(去掉末尾 /),拼接提交地址:

endpoint = base + "/kling/v1/images/generations"

body 为 JSON payload(字段以官方文档为准),示例含 promptnegative_promptaspect_ratioresolution1k 一般比 2k 更省)。

响应里取 data.task_id

4.2 轮询(GET)——URL 拼接

resultUrl = endpoint + "/" + encodeURIComponent(task_id)

resultUrl 定时 GET,读 data.task_statussubmitted / processing 继续;failed 报错;否则解析 data.task_result.images[0].url

4.3 「图片拼接」指什么?(不是多图拼画布)

  • 接口 URLbase + 固定路径 + / + encodeURIComponent(id)
  • 展示:先把 imgUrl 设为 loading 图,成功后改为结果里的 HTTPS 图片 URL<img :src="imgUrl"> 由浏览器再去拉 CDN 图。

五、一次点击「Generate」的时序

sequenceDiagram
  participant B as 浏览器 index-keling.html
  participant S as server.js 代理
  participant K as api-beijing.klingai.com

  B->>S: POST /kling/v1/images/generations + JSON payload
  S->>S: 签发 JWT
  S->>K: POST /v1/images/generations + Bearer JWT
  K-->>S: 200 + task_id
  S-->>B: 透传 JSON

  loop 轮询
    B->>S: GET /kling/v1/images/generations/{task_id}
    S->>K: GET /v1/images/generations/{task_id} + Bearer JWT
    K-->>S: task_status + task_result...
    S-->>B: 透传 JSON
  end

  B->>B: imgUrl = task_result.images[0].url

六、省钱与排错

  • 分辨率payload.resolution1k 通常比 2k 更省(以官方计费为准)。
  • 401 / Auth failed:核对 北京域、AK/SK、重启 node server.js 后是否读到 .env.local
  • 422 / 字段错误:对照当前模型文档改 payload 字段名。

七、仓库文件对照

内容 文件
前端单页 index-keling.html
JWT + 代理 + DeepSeek 其它路由 server.js
环境说明 README.md

八、后续(语音等)

可按同一模板扩展:鉴权方式 → 是否需代理 → 前端拼 URL 还是拼流;语音若走流式或 WebSocket,「拼接」更多在 chunk 缓冲与解码,建议另开一篇写。


附录 A:核心代码摘录(与仓库一致)

完整可运行代码请以仓库为准;下面仅保留与可灵最相关的片段。

A.1 server.js:JWT + 代理(节选)

const KLING_API_ORIGIN = (
  process.env.KLING_API_ORIGIN || 'https://api-beijing.klingai.com'
).trim()
const KLING_PATH_PREFIX = '/kling'

function signKlingJwt(accessKeyId, accessKeySecret) {
  const now = Math.floor(Date.now() / 1000)
  const header = { alg: 'HS256', typ: 'JWT' }
  const payload = { iss: accessKeyId, exp: now + 1800, nbf: now - 5 }
  const h = toBase64Url(JSON.stringify(header))
  const p = toBase64Url(JSON.stringify(payload))
  const signingInput = `${h}.${p}`
  const sig = crypto
    .createHmac('sha256', accessKeySecret)
    .update(signingInput)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
  return `${signingInput}.${sig}`
}

// createServer 内:pathname 以 /kling 开头则 await proxyKlingRequest(...)
// proxyKlingRequest:restPath = pathname 去掉 /kling;拼 targetUrl;Bearer 调用 fetch(upstream)

toBase64UrlreadRequestBodyCORSloadDotEnv 及 DeepSeek 路由见仓库文件。)

A.2 index-keling.html:提交与轮询 URL(节选)

const endpoint = `${base}/kling/v1/images/generations`
const payload = {
  prompt: prompt.value.trim(),
  negative_prompt: negativeWords,
  aspect_ratio: aspectRatio.value,
  resolution: resolution.value
}
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(payload) })
const id = (await res.json()).data?.task_id
const resultUrl = `${endpoint}/${encodeURIComponent(id)}`
// 循环 fetch(resultUrl) 直到非 processing/submitted …

(Vue template<style>、localStorage 与错误处理见仓库完整 HTML。)


附录 B:完整源码一键打开(Raw)

便于整文件复制:


Vue 项目结构与命名规范

Vue 项目结构与命名规范

统一命名规则

  1. 普通文件夹:全小写(单单词 / 小驼峰双单词),统一、易读、兼容 URL
  2. 页面/视图文件夹:大驼峰(PascalCase),明确标识路由页面
  3. .vue 组件文件:大驼峰(PascalCase),官方推荐,与组件名保持一致
  4. JS / 工具 / 样式文件:小驼峰(camelCase),遵循 JavaScript 通用规范

官方依据


vue3-project/
├── .vscode/
├── node_modules/
├── public/
│   └── favicon.ico
├── src/
│   ├── assets/
│   │   ├── image/
│   │   │   ├── logo.png
│   │   │   └── userAvatar.png
│   │   └── styleGlobal/
│   │       ├── base.css
│   │       └── commonStyle.css
│   ├── components/
│   │   ├── common/
│   │   │   ├── Button.vue
│   │   │   └── UserInfo.vue
│   │   └── userCommon/
│   │       ├── Card.vue
│   │       └── OrderList.vue
│   ├── views/
│   │   ├── Home/
│   │   │   ├── index.vue
│   │   │   ├── HomeBanner.vue
│   │   │   ├── banner/
│   │   │   │   ├── Item.vue
│   │   │   │   └── BannerItem.vue
│   │   │   └── homeSection/
│   │   │       ├── Block.vue
│   │   │       └── SectionBlock.vue
│   │   └── UserCenter/
│   │       ├── index.vue
│   │       └── UserOrder.vue
│   ├── router/
│   │   ├── index.js
│   │   └── routeGuard.js
│   ├── store/
│   │   ├── modules/
│   │   │   ├── user.js
│   │   │   └── userInfo.js
│   │   └── index.js
│   ├── api/
│   │   ├── request.js
│   │   └── orderList.js
│   ├── utils/
│   │   ├── time.js
│   │   └── formatDate.js
│   ├── composables/
│   │   ├── index.js
│   │   └── useUser.js
│   ├── App.vue
│   └── main.js
├── .env.development
├── .env.production
├── .gitignore
├── index.html
├── package.json
├── vite.config.js
└── README.md

CSS mask 完全指南:从渐变裁切到弹幕遮挡

CSS 属性里,mask 大概是被低估最严重的那一个。很多人知道它能"遮住一些东西",但真正上手时又觉得无从下手。其实 mask 的语法和 background 几乎一模一样——如果你已经玩转了渐变背景,那 mask 对你来说就是换个属性名的事。

本文会从语法开始,一路讲到弹幕遮挡、转场动画这些实战场景。每个案例都附带可运行的代码。


1. mask 到底是什么?

一句话:mask 决定元素的哪些部分可见、哪些部分透明

它接受的值和 background 一样——渐变、图片、SVG 都行。工作原理也简单:

  • mask 中有颜色的区域(不管什么颜色),对应元素内容可见
  • mask 中透明的区域,对应元素内容不可见

来看最基础的例子:

.demo {
  background: url(photo.jpg);
  -webkit-mask: linear-gradient(90deg, transparent, #000);
  mask: linear-gradient(90deg, transparent, #000);
}

效果是图片从左侧完全透明,到右侧完全可见——一个从无到有的渐隐效果。

这里 #000 换成 redblue 或任何颜色,效果完全一样。mask 只关心透明度,不关心色相。


2. mask 语法详解

根据 MDN CSS mask 文档:

The mask shorthand CSS property hides an element (partially or fully) by masking or clipping the image at specific points. It is a shorthand for mask-image, mask-mode, mask-repeat, mask-position, mask-clip, mask-origin, mask-size, and mask-composite.

mask 是一个简写属性,包含以下子属性:

子属性 作用 对应的 background 属性
mask-image 遮罩图像(渐变/图片/SVG) background-image
mask-size 遮罩尺寸 background-size
mask-repeat 是否平铺 background-repeat
mask-position 遮罩定位 background-position
mask-origin 定位参考框 background-origin
mask-clip 裁切参考框 background-clip
mask-composite 多个遮罩的合成方式 无对应属性

看到没有?除了 mask-composite,其他属性和 background 完全对应。如果你已经熟悉了 background-sizebackground-position 这些属性,mask 的学习成本几乎为零。

兼容性前缀

目前(2026 年)在 Chrome、Edge 等 Blink 内核浏览器中,mask 仍需 -webkit- 前缀。实际写代码时建议这样写:

.el {
  -webkit-mask: linear-gradient(#000, transparent);
  mask: linear-gradient(#000, transparent);
}

或者直接在构建工具中配置 autoprefixer,让它帮你加前缀。


3. 基础用法:渐变遮罩裁切

3.1 案例:图片切角效果

多层线性渐变可以拼出切角图形,这个技巧在 background 上就能用。把同样的渐变写到 mask 里,就能把任意元素裁成切角造型——不管元素里面是图片、文字还是渐变背景。

.notch-image {
  width: 300px;
  height: 200px;
  background: url(https://picsum.photos/300/200) no-repeat center/cover;
  -webkit-mask:
    linear-gradient(135deg, transparent 15px, #fff 0) top left,
    linear-gradient(-135deg, transparent 15px, #fff 0) top right,
    linear-gradient(-45deg, transparent 15px, #fff 0) bottom right,
    linear-gradient(45deg, transparent 15px, #fff 0) bottom left;
  -webkit-mask-size: 50% 50%;
  -webkit-mask-repeat: no-repeat;
  mask:
    linear-gradient(135deg, transparent 15px, #fff 0) top left,
    linear-gradient(-135deg, transparent 15px, #fff 0) top right,
    linear-gradient(-45deg, transparent 15px, #fff 0) bottom right,
    linear-gradient(45deg, transparent 15px, #fff 0) bottom left;
  mask-size: 50% 50%;
  mask-repeat: no-repeat;
}

四个方向的渐变各占 50% 50%,拼在一起刚好覆盖整个元素。每个渐变在角落处用 transparent 挖掉一个三角形,组合起来就是四角切角。

这里的 #fff 0 用了渐变简写技巧:0 会被浏览器修正为前一个色标的位置 15px,形成硬边界。


3.2 案例:内切圆角按钮

普通的内切圆角用 radial-gradient 就能画出来。但问题在于:如果按钮背景是渐变色而不是纯色,直接用 background 画内切圆角基本无解——你没法让两层渐变"叠加"出一个圆角效果。

mask 能解决这个问题:把内切圆角的形状写成 mask,background 想用什么渐变都行

.inset-btn {
  padding: 16px 48px;
  font-size: 16px;
  color: #fff;
  border: none;
  background: linear-gradient(45deg, #2179f5, #e91e63);
  -webkit-mask:
    radial-gradient(
        circle at 100% 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right bottom,
    radial-gradient(circle at 0 0, transparent 0, transparent 12px, #fff 13px)
      left top,
    radial-gradient(
        circle at 100% 0,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right top,
    radial-gradient(
        circle at 0 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      left bottom;
  -webkit-mask-size: 50% 50%;
  -webkit-mask-repeat: no-repeat;
  mask:
    radial-gradient(
        circle at 100% 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right bottom,
    radial-gradient(circle at 0 0, transparent 0, transparent 12px, #fff 13px)
      left top,
    radial-gradient(
        circle at 100% 0,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right top,
    radial-gradient(
        circle at 0 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      left bottom;
  mask-size: 50% 50%;
  mask-repeat: no-repeat;
}

原理:四个 radial-gradient 分别处理四个角,每个径向渐变的圆心在对应角落,0~12px 的范围是透明的(挖出圆弧),13px 往外是白色(保留内容)。

改变 12px 的值可以调整圆弧大小。这种方案的好处是 background 完全自由——纯色、渐变、图片都没问题。


4. 进阶用法:渐变消失与融合

4.1 案例:横向滚动列表的渐变消失

在很多产品里都能看到这种效果:一个横向可滚动的列表,右侧内容渐渐消失,暗示用户"还有更多内容"。

不用 mask 的话你可能会想到覆盖一个半透明遮罩层。但这有个麻烦:遮罩层会挡住点击事件,还需要设置 pointer-events: none

用 mask 就一行代码:

.scroll-list {
  display: flex;
  overflow-x: auto;
  gap: 12px;
  -webkit-mask: linear-gradient(90deg, #000 70%, transparent);
  mask: linear-gradient(90deg, #000 70%, transparent);
}

linear-gradient(90deg, #000 70%, transparent) 的意思是:从左到右,前 70% 完全可见,剩下 30% 逐渐透明。就这么简单。

要注意一点:mask 作用于整个元素及其内容,包括文字、子元素、甚至滚动条。这正是 mask 和 "覆盖一层遮罩" 的本质区别——mask 是从元素自身出发做裁切,而不是在上面盖东西。


4.2 案例:两张图片融合

mask 做图片融合非常直观:两张图片叠在一起,上层图片加一个 mask,mask 的透明区域会露出下层图片。

.blend {
  position: relative;
  width: 400px;
  height: 300px;
  background: url(https://picsum.photos/400/300?random=1) no-repeat center/cover;
}

.blend::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/300?random=2) no-repeat center/cover;
  -webkit-mask: linear-gradient(45deg, #000 40%, transparent 60%);
  mask: linear-gradient(45deg, #000 40%, transparent 60%);
}

linear-gradient(45deg, #000 40%, transparent 60%) 中,40% 到 60% 这段是过渡区——两张图片在这里平滑融合。如果你把它改成 #000 50%, transparent 50%,那就是硬切割,没有过渡。

除了 linear-gradient 做线性方向的融合,radial-gradient 可以做径向区域的融合——在画面中某个位置开一个"窗口",露出下层的内容:

.radial-blend {
  position: relative;
  width: 520px;
  height: 320px;
  overflow: hidden;
}

.radial-blend .layer-cold {
  position: absolute;
  inset: 0;
  background: url(scene-cold.jpg) center / cover no-repeat;
}

.radial-blend .layer-warm {
  position: absolute;
  inset: 0;
  background: url(scene-warm.jpg) center / cover no-repeat;
  -webkit-mask: radial-gradient(circle at 35% 45%, #000 20%, transparent 60%);
  mask: radial-gradient(circle at 35% 45%, #000 20%, transparent 60%);
}

上层暖色调图片通过 radial-gradient 只在左侧偏上的位置可见,向外逐渐透明,露出底层冷色调图片。两张风格不同的照片在圆形过渡区自然融合。


5. mask-composite:组合遮罩

当一个元素有多个 mask 时,mask-composite 决定它们之间怎么合成。

根据 MDN mask-composite 文档:

The mask-composite CSS property represents a compositing operation used on the current mask layer with the mask layers below it.

标准语法支持四个关键字:

mask-composite: add; /* 叠加(默认)*/
mask-composite: subtract; /* 减去 */
mask-composite: intersect; /* 取交集 */
mask-composite: exclude; /* 排除重叠 */

但 WebKit 浏览器用的是另一套语法(-webkit-mask-composite),常用的值有:

-webkit-mask-composite: source-over; /* 对应 add */
-webkit-mask-composite: source-in; /* 对应 intersect */
-webkit-mask-composite: source-out; /* 只显示上层独有部分 */
-webkit-mask-composite: destination-out; /* 只显示下层独有部分 */
-webkit-mask-composite: xor; /* 对应 exclude */

案例:两个圆弧取交集

假设你想裁出一个"两个圆弧重叠"的形状:

.composite-demo {
  width: 300px;
  height: 200px;
  background: linear-gradient(135deg, #667eea, #764ba2);
  -webkit-mask:
    radial-gradient(circle at 100% 0, #000, #000 200px, transparent 200px),
    radial-gradient(circle at 0 0, #000, #000 200px, transparent 200px);
  -webkit-mask-composite: source-in;
  mask:
    radial-gradient(circle at 100% 0, #000, #000 200px, transparent 200px),
    radial-gradient(circle at 0 0, #000, #000 200px, transparent 200px);
  mask-composite: intersect;
}

如果不加 mask-composite,两个 mask 默认是 add(叠加),你看到的是两个圆弧的并集。加上 intersect(或 -webkit-mask-composite: source-in),就只保留两个圆弧重叠的部分

这个能力在做异形裁切时很有用:单个渐变很难画出的形状,可以通过多个简单渐变组合得到。


6. 高阶动画:mask 驱动的转场

mask 不只是静态裁切。通过动态改变 mask 的值,可以实现各种转场和切换效果。

6.1 渐变不能直接做动画——怎么办?

CSS 渐变本身不支持 transitionanimation。也就是说你写 transition: mask 0.3s 是没用的,linear-gradient 内部的参数变化不会有平滑过渡。

两种绕过方案:

  1. 逐帧动画:用 SASS 循环生成 0% 到 100% 共 101 帧的 @keyframes,每一帧写死 mask 的值
  2. CSS @property:注册一个自定义属性,让浏览器知道这个变量是 <percentage> 类型,这样它就能被动画插值

第一种方案的代码经过 SASS 编译后非常臃肿(101 帧)。推荐用第二种。

6.2 案例:conic-gradient 扇形转场(CSS @property 方案)

这是一个经典的转场效果:上层图片像扇形展开一样逐渐覆盖下层图片。hover 时触发动画。

@property --conic-p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: -10%;
}

.transition-box {
  position: relative;
  width: 400px;
  height: 400px;
  background: url(https://picsum.photos/400/400?random=5) no-repeat center/cover;
  cursor: pointer;
}

.transition-box::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/400?random=100) no-repeat
    center/cover;
  pointer-events: none;
  -webkit-mask: conic-gradient(
    #000 0,
    #000 var(--conic-p),
    transparent calc(var(--conic-p) + 10%),
    transparent
  );
  mask: conic-gradient(
    #000 0,
    #000 var(--conic-p),
    transparent calc(var(--conic-p) + 10%),
    transparent
  );
}

.transition-box:hover::before {
  animation: conicSweep 1.5s ease-in-out forwards;
}

@keyframes conicSweep {
  from {
    --conic-p: -10%;
  }
  to {
    --conic-p: 100%;
  }
}

这里有几个关键点:

  • @property --conic-p:注册之后,浏览器知道 --conic-p 是百分比类型,可以在动画中平滑插值。mask 里的 conic-gradient 会随着 --conic-p 从 -10% 变化到 100%,像时钟指针一样扫过整个圆。
  • pointer-events: none:伪元素覆盖在容器上层,如果不加这个属性,鼠标事件会被伪元素拦截,导致容器的 :hover 状态无法触发。
  • calc(var(--conic-p) + 10%) 多出的 10% 是过渡区,让边缘不那么生硬。如果你想要硬边界,把 +10% 去掉就行。

同样的思路,换成 linear-gradient 就是一个从左到右的滑动转场:

@property --slide-p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

.slide-box {
  position: relative;
  width: 400px;
  height: 400px;
  background: url(https://picsum.photos/400/400?random=10) no-repeat
    center/cover;
  cursor: pointer;
}

.slide-box::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/400?random=200) no-repeat
    center/cover;
  pointer-events: none;
  -webkit-mask: linear-gradient(
    90deg,
    #000 var(--slide-p),
    transparent calc(var(--slide-p) + 5%)
  );
  mask: linear-gradient(
    90deg,
    #000 var(--slide-p),
    transparent calc(var(--slide-p) + 5%)
  );
}

.slide-box:hover::before {
  animation: slideReveal 1.2s ease-in-out forwards;
}

@keyframes slideReveal {
  from {
    --slide-p: 0%;
  }
  to {
    --slide-p: 100%;
  }
}

和扇形转场的原理完全一样,只是把 conic-gradient 换成了 linear-gradient--slide-p 从 0% 变化到 100%,实色区域从左往右推进,形成滑动揭示的效果。

如果你的目标浏览器不支持 @property(比如旧版 Firefox),也可以用 SASS 逐帧方案替代:

@keyframes maskSlide {
  @for $i from 0 through 100 {
    #{$i}% {
      mask: linear-gradient(
        90deg,
        #000 #{$i + '%'},
        transparent #{$i + 5 + '%'}
      );
    }
  }
}

编译后会生成 101 帧的 @keyframes,每一帧写死 mask 的值,代码量大但兼容性最好。


7. 实战:弹幕人物遮挡效果

在 BiliBili 或虎牙直播中,弹幕经过人物区域时会自动"绕道"——弹幕看起来在人物的后面。这个效果的实现原理就是 mask。

原理

  1. 视频画面和弹幕容器是两层叠加结构,弹幕在上层
  2. 后端通过图像识别算法,实时计算出人物的轮廓区域
  3. 生成一张 SVG/PNG 图片:人物轮廓区域是透明的,其他区域是白色/实色的
  4. 把这张图片设为弹幕容器的 mask-image
  5. 根据 mask 的工作原理——透明区域对应的弹幕内容不可见——弹幕就"消失"在人物背后了
  6. 随着视频播放,后端不断更新 mask 图片,实现实时遮挡

简化模拟

后端的实时图像识别我们没法在前端模拟,但原理可以用 radial-gradient 来演示:

.barrage-container {
  position: absolute;
  inset: 0;
  -webkit-mask: radial-gradient(
    circle at 100px 100px,
    transparent 60px,
    #fff 80px,
    #fff 100%
  );
  mask: radial-gradient(
    circle at 100px 100px,
    transparent 60px,
    #fff 80px,
    #fff 100%
  );
  animation: maskFollow 6s infinite alternate linear;
}

@keyframes maskFollow {
  to {
    -webkit-mask-position: 80vw 0;
    mask-position: 80vw 0;
  }
}

radial-gradient(100px, 100px) 位置挖了一个半径 60px 的圆形透明区域,60px 到 80px 是过渡,80px 以外完全可见。通过动画移动 mask-position,这个"挖洞"就会跟着移动。

真实场景中,这个 "挖洞" 的形状不是简单的圆形,而是从后端返回的人物轮廓 SVG。但 mask 的使用方式完全相同。

要搞清楚一点:mask 遮挡的是弹幕容器,不是人物。mask 的透明区域让弹幕不可见,从而"露出"弹幕下方的人物画面。


9. 兼容性

mask 属性的浏览器支持已经相当好了:

浏览器 支持情况
Chrome / Edge 支持(需 -webkit- 前缀)
Firefox 完全支持(无需前缀)
Safari 支持(需 -webkit- 前缀)
IE 不支持

如果你不需要兼容 IE,mask 可以放心用。前缀问题交给 autoprefixer 处理:

// postcss.config.js
module.exports = {
  plugins: [require('autoprefixer')],
};

mask-composite 的兼容性稍差一些,使用前建议在 Can I Use 上确认目标浏览器的支持情况。


10. 总结

核心原则

mask 中有颜色 → 内容可见透明 → 内容不可见。记住这一条就够了。

技巧速查表

技巧 实现方式 典型场景
渐变遮罩 mask: linear-gradient(...) 内容淡出、列表渐隐
切角/异形裁切 多重 linear-gradient + mask-size: 50% 50% 图片切角、优惠券造型
内切圆角 多重 radial-gradient 不规则按钮、卡片
图片融合 伪元素叠加 + mask 两图过渡、径向区域融合
组合遮罩 mask-composite: intersect 多 mask 取交集/差集
渐变动画转场 @property + conic-gradient 扇形展开、滑动切换
图表重绘 @property + conic-gradient + :hover 数据可视化 hover 效果
弹幕遮挡 radial-gradient / 实时图片 视频直播弹幕
雪碧图转场 mask: url(sprite.png) + steps() 精致页面转场

和 background 的关系

mask 的语法和 background 几乎一一对应——多层叠加、repeat、position、size 这些在 background 上能做的事,mask 上全能做。多出来的 mask-composite 让多个 mask 之间的布尔运算成为可能,这是 background 没有的能力。


延伸阅读

node服务器是什么

Node 服务器是什么

核心概念

Node.js 基础

txt
Node.js = JavaScript 运行环境 + 服务端能力

通俗理解

  • JavaScript 本来只能在浏览器里运行
  • Node.js 让 JavaScript 可以在服务器(你的电脑)上运行
  • Node 服务器 就是用 Node.js 编写的 HTTP 服务器程序

类比理解

传统认知

txt
服务器 = 一台远程电脑 + Apache/Nginx 软件
         ↓
       处理网页请求

Node 服务器

txt
Node 服务器 = 你的电脑 + Node.js 程序
              ↓
            处理网页请求

最简单的 Node 服务器代码

javascript
// server.js
const http = require('http');
// 创建服务器
const server = http.createServer((req, res) => {
  // 有人访问时的处理逻辑
  res.writeHead(200, { 'Content-Type''text/html' });
  res.end('<h1>Hello World!</h1>');
});
// 监听 3000 端口
server.listen(3000() => {
  console.log('服务器运行在 http://localhost:3000');
});

执行

bash
$ node server.js
服务器运行在 http://localhost:3000
# 浏览器访问 localhost:3000 就能看到 "Hello World!"

Node 服务器 vs 传统服务器

特性 传统服务器 (Apache/Nginx) Node 服务器
编程语言 C/C++ 编写 JavaScript 编写
配置方式 配置文件 (.conf) 代码控制
动态功能 需要 PHP/Python 等后端语言 JavaScript 一站式
适用场景 静态文件托管、反向代理 全栈开发、实时应用

解锁对象遍历:当字符串遇上for...in循环

前言:

哈喽大家好,我是心连欣。在JavaScript的世界里,对象(Object)是我们组织数据的核心方式。从简单的用户信息到复杂的后端接口返回,对象无处不在。今天,我们通过一个实际的代码案例,深入探讨如何遍历对象,并特别关注一个容易被忽视的细节——当对象的属性值是字符串时,我们应该注意什么?

基础回顾:对象的创建与结构

首先,我们来看一个典型的用户信息对象。它包含了名字、年龄、性别和地址等属性。

let obj = {
      name:'xx',
      age:'21',
      gender:'女',
      address:'重庆',
    }

在这个对象中,nameagegender 和 address 是键(Key),而 'xx''21''女''重庆' 是对应的值(Value)。

细心的同学可能已经发现,这里的值全部被单引号包裹,这意味着它们在JavaScript中都是字符串类型。即使是 age: '21',它也不是数字21,而是字符"2"和"1"的组合。这一点在后续的数据处理中至关重要。

遍历神器:for...in循环

要读取对象中的每一个数据,最通用的方法是使用 for...in 循环。它的作用就像是把对象里的每一把“钥匙”都拿出来,然后开门看看里面装了什么。

 for(let k in obj){
      console.log(k);
      console.log(obj[k]);
    }

代码解析:

  1. let k in obj:循环开始时,变量 k 会依次接收对象的每一个属性名(字符串形式)。第一次循环 k 是 "name",第二次是 "age",以此类推。
  2. console.log(k) :直接输出属性名。
  3. console.log(obj[k]) :这一点真的非常非常非常重要!!!!!这是方括号表示法。因为 k 是一个变量,我们不能用点语法(obj.k 是错的),必须用 obj[k] 来动态获取对应的值。

注意:如果我们采用obj.k的语法来打印,就会出现以下错误:

image.png 导致出现undefined的提示。

深度解析:字符串遍历的陷阱与注意点

回到我们的核心问题:如果遍历出的内容是字符串,我们需要注意什么?

通过控制台输出,我们看到 age 的值是 '21'。在视觉上它和数字21没有区别,但在代码逻辑中,它们天差地别。

注意点一:类型检测

在遍历过程中,如果你不确定拿到的值是什么类型,务必使用 typeof 进行检测。

 for(let k in obj){
    let value = obj[k];
    if(typeof value === 'string'){
        console.log(`属性 ${k} 的值是字符串:${value}`);
    }
}

查看结果如下图:

image.png注意点二:隐式转换与计算错误

这是新手最容易踩的坑。假设我们想计算用户的年龄加5岁后的数值。

  • 错误写法:
// 假设 age 是字符串 '21' 
let nextYear = obj.age + 5;
console.log(nextYear); // 输出结果是 "215",而不是 26!
  • 原因:在JavaScript中,字符串与数字使用 + 号连接时,会执行字符串拼接操作,而不是数学加法。
  • 正确写法:

在进行计算前,必须将字符串转换为数字。可以使用 Number() 函数或一元加号 +

// 方法1:使用 Number() 
let nextYear1 = Number(obj.age) + 5;
// 方法2:使用一元加号(更简洁) 
let nextYear2 = +obj.age + 5; 
console.log(nextYear1); // 输出 26

注意点三:空字符串与逻辑判断

在遍历表单对象或用户输入时,字符串可能是空的 ""。在 if 判断中,空字符串会被视为 false(假值)。

let user = { name: 'xx', nickname: '' }; 
for(let k in user){ 
if(user[k])
{ console.log(`${k} 有值`); 
} else
{ console.log(`${k} 是空值`); // nickname 会触发这个分支 } }

总结

遍历对象不仅仅是打印出键和值,更重要的是理解数据的类型。

  1. 识别类型:时刻警惕被引号包裹的数字(如 '21'),它们本质是字符串。
  2. 动态取值:在 for...in 循环中,使用 obj[k] 来获取值。
  3. 类型转换:在进行数学运算之前,务必使用 Number() 或 + 将字符串转为数字,避免逻辑错误。

掌握了这些细节,你才能真正驾驭对象数据,写出健壮的代码!

什么是node.js 小白也能看明白

Node.js = JavaScript 运行环境

核心概念:什么是"运行环境"

类比 1:人类语言

txt
中文        需要      中国人/翻译官      才能理解
JavaScript  需要      运行环境          才能执行

类比 2:播放器

txt
MP4 视频文件  →  需要视频播放器  →  才能播放
.js 代码文件  →  需要 JS 运行环境  →  才能执行

JavaScript 的两个运行环境

1. 浏览器(传统环境)

javascript
// test.js
console.log('Hello');
alert('弹窗');
document.body.style.color = 'red';

怎么运行

html
<script src="test.js"></script>

浏览器提供的能力

  • window 对象
  • document 对象(操作页面)
  • alert() 函数
  • fetch() 网络请求
  • localStorage 本地存储

2. Node.js(服务端环境)

javascript
// test.js
console.log('Hello');
const fs = require('fs'); // 读写文件
const http = require('http'); // 创建服务器

怎么运行

bash
$ node test.js
Hello

Node.js 提供的能力

  • fs 模块(读写文件)
  • http 模块(网络服务)
  • path 模块(路径处理)
  • process 对象(进程信息)
  • 访问操作系统 API

对比理解

特性 浏览器环境 Node.js 环境
执行方式 嵌入 HTML 中 命令行执行 node xxx.js
核心能力 操作网页 DOM 操作文件系统、网络
全局对象 window global
用途 前端开发 后端开发、工具开发
典型 API alert(), document fs.readFile(), http.createServer()

深入理解:Node.js 的组成

txt
Node.js = V8 引擎 + C++ 扩展库 + JavaScript 标准库

1. V8 引擎(核心)

  • Google 开发的 JavaScript 引擎
  • 将 JS 代码编译成机器码执行
  • 浏览器 Chrome 也用 V8

2. C++ 扩展库

  • 文件系统操作(libuv)
  • 网络通信
  • 加密/解密
  • 压缩/解压

3. JavaScript 标准库

javascript
const fs = require('fs');      // 文件系统
const http = require('http');  // HTTP 服务
const path = require('path');  // 路径处理

为什么 JavaScript 需要"运行环境"

JavaScript 特点

javascript
// JavaScript 本身只是"语法规则"
let a = 1;
function add(x, y) { return x + y; }

JavaScript 不能直接

  • ❌ 读写文件
  • ❌ 创建服务器
  • ❌ 操作网页
  • ❌ 访问数据库

运行环境提供"超能力"

javascript
// 浏览器环境提供的能力
document.getElementById('app'); // ← document 是浏览器给的
// Node.js 环境提供的能力
require('fs').readFileSync('file.txt'); // ← fs 是 Node.js 给的

实际例子

代码:same.js

javascript
console.log('当前时间:'new Date());
console.log('1 + 1 ='1 + 1);

在浏览器运行

html
<script src="same.js"></script>

输出到浏览器控制台 ✅

在 Node.js 运行

bash
$ node same.js

输出到终端命令行 ✅


代码:browser-only.js

javascript
alert('Hello')// ← 浏览器专属 API

在浏览器运行:✅ 弹窗
在 Node.js 运行:❌ 报错 alert is not defined


代码:node-only.js

javascript
const fs = require('fs'); // ← Node.js 专属 API
console.log(fs.readFileSync('test.txt''utf-8'));

在 Node.js 运行:✅ 输出文件内容
在浏览器运行:❌ 报错 require is not defined

Node.js 软件的安装

bash
# 查看是否安装
$ node -v
v18.17.0
# 查看安装路径

Node.js 就是一个可执行程序

txt
/usr/local/bin/node  ← 这是一个软件
    ↓
类似于:
/Applications/Chrome.app
/Applications/VSCode.app

核心总结

概念 解释
JavaScript 编程语言(只是语法规则)
运行环境 能够"执行"JS 代码的程序
Node.js 一个可以在服务器/电脑上运行 JS 的软件
npm start 用 Node.js 执行 ice.js 工具

一句话理解

Node.js 就像是一个"翻译官",它能读懂 JavaScript 代码,并把代码变成计算机能执行的指令。

txt
你写的 JS 代码(人类语言)
        ↓
    Node.js(翻译官)
        ↓
  机器码(计算机语言)

没有 Node.js,你的 .js 文件就只是一个文本文件,无法运行。
就像没有视频播放器,你的 .mp4 文件只能看文件图标,无法播放一样。

Vue3项目中给组件名称的方式

1.不是用插件给组件设置名称的方式

<template>
    <div><div>
</template>
<script lang="ts">
export default {
    name: "xxxx"
}
</script>

<script lang="ts" setup>

</script>
<style scoped></style>

2.通过vite-plugin-vue-setup-extend插件(推荐)

<template>
    <div><div>
</template>
<script lang="ts" setup name="xxxx">

</script>
<style scoped></style>

3.vite-plugin-vue-setup-extend安装与配置

(1)第一步

npm i vite-plugin-vue-setup-extend -D

(2)第二步配置vite.config.ts

import VueSetupExtend from "vite-plugin-vue-setup-extend";

export default defineConfig({
    plugins: [
        ...
        VueSetupExtend()
    ],
    ...
})

使用纯canvas绘制一个掘金首页

使用纯 Canvas 绘制一个掘金首页

在前端开发中,我们习惯了使用 HTML 和 CSS 来构建用户界面。但你是否想过,如果完全抛弃 DOM 树,使用纯 Canvas 来绘制一个复杂的现代 Web 页面(比如稀土掘金的首页),会是怎样的体验?

react-canvas 这个项目中,我们进行了一次硬核的尝试:基于 Skia (CanvasKit) 和 Yoga 布局引擎,使用 React 自定义渲染器从零构建了掘金的首页。

🔗 在线体验地址react-canvas-design.vercel.app/#/juejin
💻 GitHub 仓库github.com/ouzhou/reac…

screenshot-20260414-161131.png

技术栈揭秘

要实现这个目标,我们不能使用标准的 react-dom。我们的底层基础设施包括:

  1. CanvasKit (Skia WebAssembly):作为底层的 2D 图形渲染引擎,负责绘制所有的矩形、文本、图像和 SVG 路径。
  2. Yoga Layout:Facebook 开源的跨平台 Flexbox 布局引擎。由于 Canvas 本身没有布局概念,我们通过 Yoga 来计算每个元素的坐标和尺寸。
  3. @react-canvas/react-v2:我们自己实现的 React 渲染器,将 React 组件树映射为底层的渲染节点。

核心实现思路

在纯 Canvas 的世界里,没有 <div><span><img>。一切都是自定义的节点。

1. 基础组件映射

我们将传统的 HTML 标签替换为了 react-canvas 提供的基础组件:

  • <div> -> <View>:作为基础的容器,支持 Flexbox 布局。
  • <span> / <p> -> <Text>:用于文本渲染,底层调用 Skia 的 Paragraph API。
  • <img> -> <Image>:用于渲染网络图片(如掘金的 Logo)。
  • <svg> -> <SvgPath>:用于渲染矢量图标。
  • 滚动区域 -> <ScrollView>:由于 Canvas 没有原生滚动条,我们需要自己处理滚动事件和视口裁剪。

2. 初始化画布与字体

Canvas 绘制中文需要显式加载字体文件,否则会出现乱码(豆腐块)。我们在最外层使用 CanvasProvider 初始化运行时,并加载了思源黑体:

import { CanvasProvider, Canvas, View, Text } from "@react-canvas/react-v2";
import localParagraphFontUrl from "../assets/NotoSansSC-Regular.otf?url";

<CanvasProvider initOptions={{ defaultParagraphFontUrl: localParagraphFontUrl }}>
  {({ isReady, runtime }) => (
    <Canvas
      width={vw}
      height={vh}
      paragraphFontProvider={runtime.paragraphFontProvider}
      defaultParagraphFontFamily={runtime.defaultParagraphFontFamily}
    >
      {/* 页面内容 */}
    </Canvas>
  )}
</CanvasProvider>

3. Flexbox 布局与样式

得益于 Yoga,我们可以像写 React Native 一样使用 Flexbox 布局。所有的样式都是内联的 JS 对象,而不是 CSS 类:

// 掘金顶部导航栏的布局示例
<View
  style={{
    width: vw,
    height: 60,
    backgroundColor: "#ffffff",
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    paddingLeft: 24,
    paddingRight: 24,
  }}
>
  {/* Logo 和 导航项 */}
</View>

4. 交互状态 (Hover)

在 DOM 中,我们通常用 :hover 伪类来处理鼠标悬停状态。在 react-canvas 中,style 属性支持传入一个函数,接收当前的交互状态:

<View
  style={({ hovered }) => ({
    padding: 16,
    backgroundColor: hovered ? "#fafafa" : "#ffffff", // 悬停时改变背景色
    cursor: "pointer",
  })}
>
  <Text>文章标题</Text>
</View>

5. 绘制细节与踩坑:分割线

在传统的 CSS 中,我们可以轻松地写出 border-bottom: 1px solid #eee。但在我们目前的自定义渲染器中,单边边框的支持还在完善中。

为了在 Canvas 中画出完美的 1px 分割线,我们采用了绝对定位的 <View> 元素来模拟:

// 模拟 border-bottom
<View style={{ 
  position: "absolute", 
  bottom: 0, 
  left: 0, 
  right: 0, 
  height: 1, 
  backgroundColor: "#f1f1f1" 
}} />

最终效果

通过组合这些基础能力,我们成功地 1:1 还原了掘金首页的复杂布局,包括:

  • 固定的顶部导航栏(带搜索框和图标)
  • 左侧固定的分类导航侧边栏
  • 中间的文章信息流(包含标题、摘要、作者、时间、点赞数和封面图)
  • 右侧的签到卡片、排行榜和活动 Banner
  • 右下角的悬浮按钮

所有的渲染都在一个 <canvas> 标签内完成!

总结

使用纯 Canvas 绘制复杂的 Web UI 是一次非常有趣的探索。虽然它失去了 DOM 带来的无障碍性(A11y)、SEO 和原生的文本选中能力,但它带来了极致的渲染控制权和跨平台的一致性(同一套代码可以轻易移植到原生 App 甚至桌面端)。

这正是 Flutter、React Native Skia 等技术的核心魅力所在。通过 react-canvas,我们在 Web 端也体验到了这种“掌控每一个像素”的快感。

Vue2 与 Vue3 超全基础知识点汇总

本文涵盖Vue2/Vue3 所有核心基础 API,每个知识点都配完整可运行代码 + 逐行注释,0 基础也能看懂。全文从入门到实战,对比两代 Vue 差异。

1. Vue 基础认知

1.1 核心定义

Vue 是一套用于构建用户界面的渐进式 JavaScript 框架,核心是数据驱动视图,数据变→视图自动变,无需手动操作 DOM。

1.2 Vue2 vs Vue3 核心区别

对比项 Vue2 Vue3
响应式原理 Object.defineProperty ES6 Proxy(无监听缺陷)
代码风格 选项式 API(Options API) 组合式 API(Composition API)+ 兼容选项式
TS 支持 差,需手动配置 原生 TS,类型推导完美
构建工具 Vue-CLI(Webpack) Vite(极速启动)
体积 全量打包,体积大 按需引入,Tree-Shaking 优化
性能 普通 渲染性能提升 55%,内存减少 33%

2. 项目创建与目录结构

2.1 Vue2 项目创建

bash 运行

# 1. 全局安装Vue脚手架
npm install -g @vue/cli

# 2. 创建项目
vue create vue2-project

# 3. 运行项目
cd vue2-project
npm run serve

2.2 Vue3 项目创建(推荐 Vite)

bash 运行

# 1. Vite创建Vue3项目
npm create vite@latest vue3-project -- --template vue

# 2. 安装依赖
cd vue3-project
npm install

# 3. 运行项目
npm run dev

3. 入口文件 main.js 完整写法

3.1 Vue2 入口文件

javascript 运行

// 引入Vue核心库
import Vue from 'vue'
// 引入根组件
import App from './App.vue'
// 关闭生产环境提示
Vue.config.productionTip = false

// 创建Vue实例,挂载根组件到#app
new Vue({
  // 渲染函数
  render: h => h(App)
}).$mount('#app') // 挂载到public/index.html的#app节点

3.2 Vue3 入口文件

javascript 运行

// 引入createApp创建应用实例
import { createApp } from 'vue'
// 引入根组件
import App from './App.vue'
// 引入样式
import './style.css'

// 1. 创建应用实例
const app = createApp(App)
// 2. 挂载到DOM节点
app.mount('#app')

// 拓展:全局配置(Vue3无全局污染)
// app.config.globalProperties.$msg = '全局变量'

4. 模板核心语法(全指令详解)

Vue 模板语法基于 HTML,所有指令以 v- 开头,数据变视图自动更新

4.1 文本插值 {{}}

<!-- Vue2 和 Vue3 模板插值用法完全一致 -->
<template>
  <div>
    <!-- 直接渲染变量 -->
    <h1>{{ msg }}</h1>
    <!-- 渲染表达式 -->
    <p>{{ num + 1 }}</p>
    <p>{{ isShow ? '显示' : '隐藏' }}</p>
  </div>
</template>

<!-- Vue2 -->
<script>
export default {
  data() {
    return {
      msg: 'Hello Vue2',
      num: 10,
      isShow: true
    }
  }
}
</script>

<!-- Vue3 -->
<script setup>
import { ref } from 'vue'
const msg = ref('Hello Vue3')
const num = ref(10)
const isShow = ref(true)
</script>

4.2 v-html 渲染 HTML 标签

{{}} 会转义标签,v-html 可解析原生 HTML

<template>
  <div v-html="htmlStr"></div>
</template>

<!-- Vue2 -->
<script>
export default {
  data() {
    return { htmlStr: '<span style="color:red">红色文字</span>' }
  }
}
</script>

<!-- Vue3 -->
<script setup>
import { ref } from 'vue'
const htmlStr = ref('<span style="color:red">红色文字</span>')
</script>

⚠️ 安全警告:仅在信任的内容上使用 v-html,防止 XSS 攻击!

4.3 v-bind 绑定属性(简写:)

动态绑定标签属性(src、class、style、disabled 等)

<template>
  <!-- 完整写法 -->
  <img v-bind:src="imgUrl" />
  <!-- 简写: (最常用) -->
  <img :src="imgUrl" />
  <!-- 绑定class -->
  <div :class="{ active: isActive }">class绑定</div>
</template>

<!-- Vue2 -->
<script>
export default {
  data() {
    return {
      imgUrl: 'https://xxx.jpg',
      isActive: true
    }
  }
}
</script>

4.4 v-on 绑定事件(简写 @)

绑定点击、输入、鼠标等事件

<template>
  <!-- 完整写法 -->
  <button v-on:click="handleClick">点击</button>
  <!-- 简写@ (最常用) -->
  <button @click="handleClick">点击</button>
  <!-- 传参 -->
  <button @click="handleClickParams(10)">传参点击</button>
</template>

<!-- Vue2 -->
<script>
export default {
  methods: {
    handleClick() { alert('Vue2点击事件') },
    handleClickParams(num) { alert('参数:' + num) }
  }
}
</script>

4.5 v-model 双向绑定(表单专用)

表单元素(input/textarea/select)数据双向同步

vue2

<template>
  <input v-model="inputVal" placeholder="请输入" />
  <p>输入内容:{{ inputVal }}</p>
</template>

<!-- Vue2 -->
<script>
export default {
  data() { return { inputVal: '' } }
}
</script>

vue3 统一语法,支持多 v-model,废弃 .sync

<!-- 父组件 -->
<Child v-model:msg="msg" v-model:age="age" />

<!-- 子组件 -->
<script setup>
const props = defineProps(['msg', 'age'])
const emit = defineEmits(['update:msg', 'update:age'])
</script>

4.6 v-for 列表渲染(必须加 key)

循环渲染数组 / 对象,key 必须唯一,不能用 index

<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<!-- Vue2 -->
<script>
export default {
  data() {
    return { list: [{id:1,name:'苹果'},{id:2,name:'香蕉'}] }
  }
}
</script>

4.7 v-if /v-else/v-else-if 条件渲染

控制元素创建 / 销毁,切换开销大

<template>
  <div v-if="score >= 90">优秀</div>
  <div v-else-if="score >= 60">及格</div>
  <div v-else">不及格</div>
</template>

4.8 v-show 条件显示

控制元素显示 / 隐藏(display:none),切换开销小

<template>
  <div v-show="isShow">v-show显示内容</div>
</template>

4.9 其他指令

  • v-once:只渲染一次,后续数据更新不重新渲染
  • v-pre:跳过编译,直接显示原始内容
  • v-cloak:解决插值表达式闪烁问题

5. 响应式数据 API 全解(最核心)

响应式:数据改变 → 视图自动刷新,无需操作 DOM

5.1 Vue2 响应式 API

5.1.1 data 定义响应式数据

data 必须是函数,返回对象,防止组件复用数据污染

<script>
export default {
  // data是函数,返回对象
  data() {
    return {
      // 基本数据类型
      msg: 'Vue2',
      num: 0,
      // 引用数据类型
      user: { name: '张三', age: 18 },
      list: [1,2,3]
    }
  }
}
</script>

5.1.2 Vue2 响应式缺陷 & 修复 API

Vue2 用 Object.defineProperty无法监听数组下标修改、对象新增属性

<script>
export default {
  data() { return { user: {}, list: [1,2] } },
  mounted() {
    // 1. 对象新增属性 → 视图不更新
    this.user.name = '张三' // 无效
    // 修复:this.$set
    this.$set(this.user, 'name', '张三')

    // 2. 数组下标修改 → 视图不更新
    this.list[0] = 100 // 无效
    // 修复:this.$set
    this.$set(this.list, 0, 100)

    // 3. 删除对象属性 → 视图不更新
    // 修复:this.$delete
    this.$delete(this.user, 'name')
  }
}
</script>

5.2 Vue3 响应式 API(组合式)

Vue3 用 Proxy,无任何响应式缺陷,所有操作都能监听

5.2.1 ref 定义基本数据类型

用于:字符串、数字、布尔、null、undefined

<script setup>
// 1. 引入ref
import { ref } from 'vue'

// 2. 定义响应式数据
const msg = ref('Hello Vue3') // 字符串
const num = ref(0) // 数字
const isShow = ref(true) // 布尔

// 3. JS中修改值必须加 .value
const changeMsg = () => {
  msg.value = '修改后的Vue3'
  num.value++
}
</script>

<template>
  <!-- 模板中自动解包,无需.value -->
  <p>{{ msg }}</p>
  <p>{{ num }}</p>
  <button @click="changeMsg">修改数据</button>
</template>

5.2.2 reactive 定义引用数据类型

用于:对象、数组、Map、Set

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

// 定义对象
const user = reactive({ name: '李四', age: 20 })
// 定义数组
const list = reactive(['苹果', '香蕉'])

// 修改数据:直接修改,无需.value
const updateUser = () => {
  user.age = 21 // 直接改
  list[0] = '葡萄' // 直接改数组下标,无缺陷
}
</script>

5.2.3 toRefs 解构 reactive 数据

reactive 解构后会丢失响应式,用 toRefs 修复

<script setup>
import { reactive, toRefs } from 'vue'
const user = reactive({ name: '王五', age: 25 })

// 错误:解构后无响应式
// const { name, age } = user

// 正确:toRefs 保持响应式
const { name, age } = toRefs(user)

// 修改数据
const changeAge = () => {
  age.value = 26
}
</script>

5.2.4 toRef 单独抽取一个属性

<script setup>
import { reactive, toRef } from 'vue'
const user = reactive({ name: '赵六' })
// 抽取单个属性
const name = toRef(user, 'name')
</script>

5.2.5 其他响应式 API

  • unref:如果是 ref 返回.value,否则返回本身
  • shallowRef:浅响应式,只监听.value 修改
  • shallowReactive:浅响应式,只监听第一层属性

6. 方法(methods)与事件处理

6.1 Vue2 定义方法

所有方法放在 methods 选项中

<template>
  <button @click="add">点击+1</button>
  <p>数字:{{ num }}</p>
</template>

<script>
export default {
  data() { return { num: 0 } },
  // 方法存放位置
  methods: {
    add() {
      // 通过this访问data数据
      this.num++
    }
  }
}
</script>

6.2 Vue3 定义方法

直接在 <script setup> 中定义函数,模板直接用

<template>
  <button @click="add">点击+1</button>
  <p>数字:{{ num }}</p>
</template>

<script setup>
import { ref } from 'vue'
const num = ref(0)

// 直接定义函数
const add = () => {
  num.value++
}
</script>

6.3 事件修饰符

<!-- 阻止冒泡 -->
<button @click.stop="handle">.stop</button>
<!-- 阻止默认行为 -->
<a @click.prevent="handle">.prevent</a>
<!-- 只触发一次 -->
<button @click.once="handle">.once</button>

7. 生命周期钩子完整对比 + 使用

生命周期:Vue 实例从创建→挂载→更新→销毁的全过程

7.1 完整生命周期对应表

Vue2 钩子 Vue3 钩子 执行时机
beforeCreate setup 创建前(无 this)
created setup 创建后(可访问数据)
beforeMount onBeforeMount 挂载前
mounted onMounted 挂载完成(操作 DOM、发请求)
beforeUpdate onBeforeUpdate 更新前
updated onUpdated 更新完成
beforeDestroy onBeforeUnmount 销毁前
destroyed onUnmounted 销毁完成

7.2 Vue2 生命周期使用

<script>
export default {
  data() { return { msg: 'Vue2生命周期' } },
  // 挂载完成,最常用
  mounted() {
    console.log('DOM渲染完成,可发请求')
  },
  // 销毁前
  beforeDestroy() {
    console.log('组件销毁,清除定时器')
  }
}
</script>

7.3 Vue3 生命周期使用

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const msg = ref('Vue3生命周期')

// 挂载完成
onMounted(() => {
  console.log('DOM渲染完成')
})

// 组件销毁
onUnmounted(() => {
  console.log('组件销毁')
})
</script>

8. 计算属性 computed(缓存特性)

8.1 作用

处理复杂逻辑,有缓存,依赖数据不变时不会重新计算,比 methods 性能高

8.2 Vue2 computed

<template>
  <p>全名:{{ fullName }}</p>
</template>

<script>
export default {
  data() {
    return { firstName: '张', lastName: '三' }
  },
  // 计算属性
  computed: {
    // 只读计算属性
    fullName() {
      return this.firstName + this.lastName
    },
    // 读写计算属性(get+set)
    fullName2: {
      get() { return this.firstName + this.lastName },
      set(val) {
        const arr = val.split(' ')
        this.firstName = arr[0]
        this.lastName = arr[1]
      }
    }
  }
}
</script>

8.3 Vue3 computed

<script setup>
import { ref, computed } from 'vue'
const firstName = ref('李')
const lastName = ref('四')

// 只读计算属性
const fullName = computed(() => {
  return firstName.value + lastName.value
})

// 读写计算属性
const fullName2 = computed({
  get() { return firstName.value + lastName.value },
  set(val) {
    const arr = val.split(' ')
    firstName.value = arr[0]
    lastName.value = arr[1]
  }
})
</script>

9. 侦听器 watch /watchEffect(监听数据变化)

9.1 watch 监听指定数据(Vue2)

<script>
export default {
  data() { return { num: 0, user: { age: 18 } } },
  watch: {
    // 监听基本类型
    num(newVal, oldVal) {
      console.log('新值:', newVal, '旧值:', oldVal)
    },
    // 深度监听对象(必须加deep:true)
    user: {
      handler(newVal) { console.log('user变化:', newVal) },
      deep: true,
      immediate: true // 立即执行一次
    }
  }
}
</script>

9.2 watch 监听指定数据(Vue3)

<script setup>
import { ref, reactive, watch } from 'vue'
const num = ref(0)
const user = reactive({ age: 18 })

// 监听基本类型
watch(num, (newVal, oldVal) => {
  console.log(newVal, oldVal)
})

// 深度监听对象
watch(user, (newVal) => {
  console.log(newVal)
}, { deep: true, immediate: true })
</script>

9.3 watchEffect 自动监听(Vue3 专属)

无需指定依赖,自动收集,代码更简洁

<script setup>
import { ref, watchEffect } from 'vue'
const num = ref(0)

// 只要num变化,自动执行
watchEffect(() => {
  console.log('num变化:', num.value)
})
</script>

10. 组件基础定义与使用

组件:可复用的 Vue 实例,一个组件就是一个.vue 文件

10.1 Vue2 组件使用

<!-- 父组件 App.vue -->
<template>
  <div>
    <!-- 使用子组件 -->
    <Child />
  </div>
</template>

<script>
// 1. 引入子组件
import Child from './components/Child.vue'
export default {
  // 2. 注册组件
  components: { Child }
}
</script>

<!-- 子组件 Child.vue -->
<template>
  <div>我是子组件</div>
</template>

10.2 Vue3 组件使用

<script setup>引入即注册,无需手动注册

<!-- 父组件 -->
<template>
  <Child />
</template>

<script setup>
// 引入直接用,无需注册
import Child from './components/Child.vue'
</script>

11. 组件通信 8 种方式(全场景)

11.1 父传子 props(最常用)

Vue2 父传子

<!-- 父组件 -->
<Child :msg="父组件数据" :user="user" />
<script>
import Child from './Child.vue'
export default {
  components: { Child },
  data() { return { msg: '父组件传给子组件', user: { name: '父' } } }
}
</script>

<!-- 子组件 -->
<script>
export default {
  // 接收父组件数据
  props: {
    msg: String,
    user: Object
  }
}
</script>

Vue3 父传子

<!-- 父组件 -->
<Child :msg="父组件数据" />
<script setup>
import Child from './Child.vue'
const msg = ref('父组件数据')
</script>

<!-- 子组件 -->
<script setup>
import { defineProps } from 'vue'
// 定义props,接收数据
const props = defineProps({
  msg: {
    type: String,
    default: ''
  }
})
// 模板中直接用{{ msg }}
</script>

11.2 子传父 $emit /defineEmits

Vue2 子传父

<!-- 子组件 -->
<button @click="sendToParent">发送给父组件</button>
<script>
export default {
  methods: {
    sendToParent() {
      // 触发自定义事件,传参
      this.$emit('sendMsg', '子组件数据')
    }
  }
}
</script>

<!-- 父组件 -->
<Child @sendMsg="handleReceive" />
<script>
export default {
  methods: {
    handleReceive(val) { console.log('接收子组件数据:', val) }
  }
}
</script>

Vue3 子传父

<!-- 子组件 -->
<script setup>
import { defineEmits } from 'vue'
// 定义自定义事件
const emit = defineEmits(['sendMsg'])
const send = () => {
  emit('sendMsg', 'Vue3子组件数据')
}
</script>

<!-- 父组件 -->
<Child @sendMsg="handleReceive" />

11.3 其他通信方式

1、兄弟组件通信:Vue2 → EventBus;Vue3 → mitt / Pinia

2、跨级组件通信:provide /inject

适用场景:多层嵌套组件(如祖父→父→孙→曾孙),无需逐层透传props,由祖先组件提供数据,后代组件直接注入使用。


Vue2 vs Vue3 核心差异

特性 Vue2 Vue3
响应式 默认非响应式,需手动用computed/ref包装 原生支持响应式,直接传递ref/reactive即可
API 位置 选项式 API(provide/inject选项) 组合式 API(setup中用provide/inject函数)
TS 支持 强(类型自动推导)

Vue2 示例

javascript 运行

// 祖先组件(App.vue)
export default {
  provide() {
    return {
      // 传递响应式数据,必须用computed包装
      appName: computed(() => this.appName),
      userInfo: { name: '张三', age: 30 }
    }
  },
  data() {
    return {
      appName: 'Vue2 应用'
    }
  }
}

// 后代组件(任意层级,如孙组件)
export default {
  inject: ['appName', 'userInfo'],
  mounted() {
    console.log('注入的appName:', this.appName.value) // computed需.value
    console.log('注入的userInfo:', this.userInfo)
  }
}

Vue3 示例(组合式 API + <script setup>

<!-- 祖先组件 App.vue -->
<script setup>
import { provide, ref, reactive } from 'vue'

// 响应式数据
const appName = ref('Vue3 应用')
const userInfo = reactive({ name: '张三', age: 30 })

// 提供数据,直接传递响应式变量
provide('appName', appName)
provide('userInfo', userInfo)
</script>

<!-- 后代组件(任意层级) -->
<script setup>
import { inject } from 'vue'

// 注入数据,第二个参数为默认值(可选)
const appName = inject('appName', '默认应用名')
const userInfo = inject('userInfo')

console.log('注入的appName:', appName.value) // ref需.value
console.log('注入的userInfo:', userInfo)
</script>

3、ref / $refs:父组件获取子组件实例

适用场景:父组件需要直接调用子组件的方法、访问子组件的 data,或操作子组件的 DOM 元素。


Vue2 vs Vue3 核心差异

表格

特性 Vue2 Vue3
获取实例 this.$refs.child 选项式同 Vue2;组合式需在onMounted后通过ref获取
<script setup>支持 无此语法 子组件必须用defineExpose显式暴露属性 / 方法
DOM 访问 this.$refs.dom 同 Vue2,组合式需ref绑定

Vue2 示例

<!-- 子组件 Child.vue -->
<template>
  <div>子组件</div>
</template>
<script>
export default {
  data() {
    return {
      childMsg: '我是子组件数据'
    }
  },
  methods: {
    childMethod() {
      console.log('子组件方法被调用')
      return '子组件返回值'
    }
  }
}
</script>

<!-- 父组件 Parent.vue -->
<template>
  <Child ref="childComp" />
  <div ref="domBox">父组件DOM</div>
</template>
<script>
import Child from './Child.vue'
export default {
  components: { Child },
  mounted() {
    // 获取子组件实例
    const childInstance = this.$refs.childComp
    console.log('子组件数据:', childInstance.childMsg)
    // 调用子组件方法
    const res = childInstance.childMethod()
    console.log('子组件方法返回值:', res)

    // 获取DOM元素
    const dom = this.$refs.domBox
    console.log('DOM元素:', dom)
  }
}
</script>

Vue3 示例(<script setup>

<!-- 子组件 Child.vue -->
<template>
  <div>子组件</div>
</template>
<script setup>
import { ref } from 'vue'
const childMsg = ref('我是子组件数据')
const childMethod = () => {
  console.log('子组件方法被调用')
  return '子组件返回值'
}

// 【关键】<script setup>默认闭包,必须显式暴露给父组件!
defineExpose({
  childMsg,
  childMethod
})
</script>

<!-- 父组件 Parent.vue -->
<template>
  <Child ref="childComp" />
  <div ref="domBox">父组件DOM</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'

// 声明ref,绑定到子组件/DOM
const childComp = ref(null)
const domBox = ref(null)

onMounted(() => {
  // 必须在onMounted后才能获取到实例/DOM!
  console.log('子组件实例:', childComp.value)
  console.log('子组件数据:', childComp.value.childMsg)
  const res = childComp.value.childMethod()
  console.log('子组件方法返回值:', res)

  // 获取DOM
  console.log('DOM元素:', domBox.value)
})
</script>

4、全局状态管理:Vuex(Vue2) / Pinia(Vue3)

适用场景:中大型项目,任意关系组件共享全局状态,需要统一管理状态读写、异步操作。


Vue2 方案:Vuex(Vue2 官方状态管理)

Vuex 核心概念:State(状态)、Mutations(同步修改)、Actions(异步操作)、Getters(计算属性)、Modules(模块化)。

javascript 运行

// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    count: 0,
    userInfo: null
  },
  mutations: {
    // 同步修改state(唯一修改state的方式)
    increment(state, payload = 1) {
      state.count += payload
    },
    setUserInfo(state, user) {
      state.userInfo = user
    }
  },
  actions: {
    // 异步操作,提交mutation
    async fetchUserInfo({ commit }) {
      const res = await fetch('/api/user')
      const user = await res.json()
      commit('setUserInfo', user)
    }
  },
  getters: {
    doubleCount: state => state.count * 2
  }
})

// 组件中使用
export default {
  computed: {
    count() {
      return this.$store.state.count
    },
    doubleCount() {
      return this.$store.getters.doubleCount
    }
  },
  methods: {
    addCount() {
      this.$store.commit('increment', 2) // 提交mutation
    },
    getUserInfo() {
      this.$store.dispatch('fetchUserInfo') // 触发action
    }
  }
}

Vue3 方案:Pinia(Vue3 官方推荐,替代 Vuex)

Pinia 是 Vue3 的下一代状态管理,相比 Vuex:

  • 去掉Mutations,直接在actions中修改状态(同步 / 异步都支持)
  • 自动模块化,无需手动注册
  • 完美支持 TS,组合式 API 友好
  • 体积仅~1KB,DevTools 支持更好

javascript 运行

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    userInfo: null
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    // 同步/异步直接写,无需mutation
    increment(payload = 1) {
      this.count += payload
    },
    async fetchUserInfo() {
      const res = await fetch('/api/user')
      const user = await res.json()
      this.userInfo = user
    }
  }
})

// 组件中使用(<script setup>)
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const counterStore = useCounterStore()
// 解构保持响应式(必须用storeToRefs)
const { count, doubleCount, userInfo } = storeToRefs(counterStore)

// 直接调用action
const addCount = () => counterStore.increment(2)
const getUserInfo = () => counterStore.fetchUserInfo()
</script>

5、parent/parent / children:父子直接访问(不推荐)


12. 插槽 Slot(默认 / 具名 / 作用域)

插槽:父组件向子组件传递 HTML 结构

12.1 默认插槽

<!-- 子组件 -->
<slot>默认内容</slot>
<!-- 父组件 -->
<Child>我是插入的内容</Child>

12.2 具名插槽(多个插槽)

Vue2

<!-- 子组件 -->
<slot name="header"></slot>
<!-- 父组件 -->
<template slot="header">头部内容</template>

Vue3(v-slot 简写 #)

<!-- 子组件 -->
<slot name="header"></slot>
<!-- 父组件 -->
<template #header>头部内容</template>

12.3 作用域插槽(子传数据给插槽)

<!-- 子组件 -->
<slot :user="user"></slot>
<script setup>
const user = reactive({ name: '插槽数据' })
</script>

<!-- 父组件 -->
<template #default="scope">
  {{ scope.user.name }}
</template>

13. 自定义指令 Directive

13.1 Vue2 自定义指令

javascript 运行

// 全局指令
Vue.directive('focus', {
  inserted(el) { el.focus() }
})

// 局部指令
export default {
  directives: {
    focus: { inserted(el) { el.focus() } }
  }
}

13.2 Vue3 自定义指令

javascript 运行

// 全局指令
app.directive('focus', {
  mounted(el) { el.focus() }
})

// 局部指令
<script setup>
const vFocus = { mounted: (el) => el.focus() }
</script>
<template> <input v-focus /> </template>

14. 过滤器 Filter(Vue2 有 / Vue3 废弃)

Vue2 过滤器

<template>
  <p>{{ msg | reverse }}</p>
</template>
<script>
export default {
  data() { return { msg: 'abc' } },
  filters: {
    reverse(val) { return val.split('').reverse().join('') }
  }
}
</script>

Vue3 替代方案

计算属性函数替代过滤器

<script setup>
import { ref, computed } from 'vue'
const msg = ref('abc')
const reverseMsg = computed(() => msg.value.split('').reverse().join(''))
</script>

15. 混入 Mixin(Vue2 有 / Vue3 废弃)

Vue2 用于复用代码,Vue3 用组合式函数替代

// mixin.js
export default {
  data() { return { mixinMsg: 'mixin数据' } },
  methods: { mixinFun() { console.log('mixin方法') } }
}

// 组件使用
import myMixin from './mixin.js'
export default { mixins: [myMixin] }

16. 路由 Vue-Router(完整配置 + 使用)

16.1 Vue2 + Vue-Router@3

javascript 运行

// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)

const routes = [
  { path: '/', component: Home }
]
const router = new VueRouter({ routes })
export default router

// main.js 引入
import router from './router'
new Vue({ router }).$mount('#app')

16.2 Vue3 + Vue-Router@4

javascript 运行

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
  { path: '/', component: Home }
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default router

// 组件使用
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter() // 跳转
const route = useRoute() // 获取参数
// 跳转
const goHome = () => router.push('/')
</script>

17. Vue3 新增高级 API

17.1 Teleport 传送门

将组件渲染到指定 DOM,常用于弹窗

<teleport to="body">
  <div class="modal">弹窗内容</div>
</teleport>

17.2 Suspense 异步组件

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    <div>加载中...</div>
  </template>
</Suspense>

17.3 defineComponent 类型推导

<script setup>
import { defineComponent } from 'vue'
const MyComponent = defineComponent({/*...*/})
</script>

18. Vue2 废弃 API 汇总

  1. $on / $off / $once(EventBus 废弃)
  2. filter 过滤器
  3. mixin 混入
  4. $children / $destroy
  5. 旧版插槽语法 slot / slot-scope
  6. .sync 修饰符

19. 新手常见问题与注意事项

  1. Vue3 ref 修改变量必须加 .value,模板中不用
  2. v-for 必须加唯一 key,不要用 index
  3. v-if 和 v-for 不要同标签使用
  4. Vue2 对象 / 数组修改用 $set,Vue3 直接修改
  5. 计算属性有缓存,methods 无缓存
  6. 组件名要大驼峰,避免和 HTML 标签冲突

文末总结

  1. Vue2 以选项式 API为主,适合小型项目,响应式有缺陷;
  2. Vue3 以组合式 API为主,适合中大型项目,响应式完美,TS 友好;
  3. 新手优先学 Vue3 + Vite + Pinia,这是未来主流技术栈。

Vue 3 响应式“背刺”现场:别让解构赋值切断你的依赖收集

摘要:Vue 3 的 Proxy 响应式系统是一把双刃剑。它在带来强大能力的同时,也用“黑盒”特性掩盖了底层的依赖追踪逻辑。当你随手写下 const { count } = props 时,你可能已经亲手切断了响应式的“神经中枢”。本文将深入 Vue 3 的 Track/Trigger 机制,剖析解构陷阱、异步失效与数组变异的本质,并给出从“避坑”到“掌控”的终极解决方案。


一、 那个让你在深夜抓狂的“幽灵 Bug”

你一定遇到过这样的场景:

场景 A:解构后的“僵尸数据”

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

const props = defineProps(['count'])

// ❌ 错误示范:为了少写几个字
const { count } = props 

setTimeout(() => {
  count++ // 即使父组件传来了新的值,或者你试图修改它
  console.log(count) // 数字在变
}, 1000)
// 💥 结果:视图纹丝不动,像被施了定身咒
</script>

<template>
  <div>{{ count }}</div> <!-- 永远停留在初始值 -->
</template>

场景 B:异步回调里的“静默失败”

import { ref } from 'vue'

const count = ref(0)

someAsyncFunction().then(() => {
  // ❌ 直接赋值
  count = 100 
})
// 💥 结果:数据变了,但 DOM 没更新

场景 C:数组与对象的“叛逆期”

const state = reactive({ list: [1, 2, 3] })

// ❌ 直接通过索引修改
state.list[1] = 99 // 视图不更新!

// ❌ 整体替换嵌套对象
state.user = { name: 'new' } // 如果 user 原本不存在,没问题;如果原本存在且是响应式,可能丢失响应性

如果不理解 Vue 3 响应式的底层逻辑,你会觉得这是“魔法失效”,甚至怀疑人生。但真相是:Vue 从来不是魔法,它是基于精确的“依赖收集”与“触发更新”的数学游戏。你的每一次“随意操作”,都在破坏这个游戏的规则。


二、 揭秘:Proxy 黑盒下的 Track 与 Trigger

要理解为什么会“断链”,必须看懂 Vue 3 的核心机制:依赖追踪(Track)触发更新(Trigger)

1. Ref 的真相:.value 是唯一的“钥匙”

ref 并不是把值变响应式,而是创建了一个对象:{ value: ... }

  • 读取时:触发 get 陷阱,Vue 记录“谁用了我”(Track)。
  • 修改时:触发 set 陷阱,Vue 通知“用我的人更新”(Trigger)。

为什么解构会失效? 当你执行 const { count } = propsconst { value: c } = countRef 时,你实际上是从对象中提取了一个原始值(Primitive Type)。

const countRef = ref(0)
const { value: c } = countRef 
// c 现在是 0,是一个数字,不是 ref 对象!
// 它和 countRef.value 已经没有任何引用关系了

这就像你把电话线剪断了。你对着数字 c 赋值,Vue 的 Proxy 根本监听不到,因为 c 不在 Proxy 的监控范围内。

2. Reactive 的陷阱:引用地址的“生死劫”

reactive 使用 Proxy 包裹对象。

  • 解构 reactive 对象const { count } = reactiveObj 同样会解包出原始值,失去响应性。
  • 数组索引赋值arr[0] = 1。虽然 Proxy 能拦截数组操作,但 Vue 2 的 Object.defineProperty 遗留的坑在 Vue 3 依然存在——通过索引直接设置数组项,Vue 无法检测到长度或特定索引的变化(除非使用 Vue 3 的补丁方法,但在某些极端的嵌套 Proxy 下仍可能失效,更推荐使用 splice 或工具函数)。
  • 整体替换state.obj = newObj。如果 obj 原本是响应式的,直接赋值会切断旧对象与 Proxy 的联系。

3. 异步更新的“时间差”

Vue 的更新是微任务队列(nextTick)。如果你在原生的 setTimeoutPromise.then 或者原生的 DOM 事件监听器(非 @click)中修改数据,虽然数据本身变了,但如果脱离了 Vue 的执行上下文,或者修改方式不对(如直接给 ref 赋值而非 .value),更新调度就会失败。


三、 破局:从“避坑”到“掌控”的实战方案

知道了原理,我们就能对症下药。以下是分级解决方案:

Level 1:标准修复 —— 永远不要解构 Ref/Reactive

黄金法则:在 <script> 中始终通过 .value 访问 Ref,在模板中自动解包。

1. 修复 Props 解构: 使用 toRefsstoreToRefs (Pinia)。

import { toRefs } from 'vue'

const props = defineProps(['count', 'name'])
// ✅ 正确:将 props 的每个属性转换为 ref
const { count, name } = toRefs(props) 

watch(count, (newVal) => { ... }) // 现在可以监听了

2. 修复 Reactive 解构: 如果必须解构(比如要传给外部非响应式函数),使用 toRefs;如果只是在模板用,直接用 state.xxx

Level 2:进阶修复 —— 显式触发与浅层引用

有时候我们需要性能优化,或者处理复杂对象,这时候需要更底层的 API。

1. ShallowRef:只监听引用变化 如果你有一个巨大的对象,不想 Vue 深度监听它的每一个属性(性能开销大),用 shallowRef

import { shallowRef, triggerRef } from 'vue'

const bigData = shallowRef({ /* ...巨大数据... */ })

// 修改时必须用 .value
bigData.value = { ...newData } 

// ⚠️ 注意:修改内部属性不会触发更新,必须手动通知
// bigData.value.someProp = 1 // 视图不更新
triggerRef(bigData) // ✅ 强制触发更新

2. triggerRef 与 customRef:上帝视角

  • triggerRef: 强制关联的副作用重新执行。
  • customRef: 自定义 Ref 的 getset,可以实现防抖 Ref、缓存 Ref 等高级逻辑。

Level 3:架构级修复 —— 状态管理的“去解构化”

在大型项目中,为了避免手动写 .value 的繁琐和解构的陷阱,推荐以下模式:

1. Pinia 的 StoreToRefs

import { storeToRefs } from 'pinia'
import { useCounterStore } from './stores/counter'

const store = useCounterStore()
// ✅ 解构出来的依然是 Ref,且保持响应式
const { count, doubleCount } = storeToRefs(store) 
// ❌ 不要直接解构 store,那会失去响应式
// const { count } = store // Error!

2. VueUse 的 useVModeluseVField: 针对表单场景,不要手动 v-model + watch。使用 VueUse 库封装好的钩子,它内部处理了所有的 update:modelValue 逻辑和响应式链接。

3. 规范:只读 + 动作分离 不要直接在组件里 state.count++

// ❌ 随意修改
state.count++

// ✅ 通过 Action 修改,确保上下文正确
const actions = {
  increment() {
    state.count++
  }
}

四、 总结:别把 Vue 当黑盒

Vue 3 的响应式系统比 Vue 2 更强大,但也更“挑剔”。它要求开发者对 “引用”“值” 有清晰的认知。

  • Ref 是个盒子,.value 是盒子里的东西,解构是把东西拿出来扔掉盒子。
  • Reactive 是个代理,整体替换等于换了一个代理。
  • 异步 需要在 Vue 的调度机制内运行,或者手动强制更新。

最后的建议: 如果你发现视图不更新,请按以下顺序排查:

  1. 我是不是解构了 Ref/Reactive?-> 用 toRefs 修复。
  2. 我是不是直接给 Ref 赋值了(没写 .value)?
  3. 我是不是在非 Vue 上下文(原生事件、setTimeout)里修改了数据?-> 确保操作的是 .value
  4. 我是不是直接修改了数组索引?-> 用 splicetriggerRef

理解了 Track 和 Trigger,你就不再是被框架“背刺”的受害者,而是驾驭响应式系统的架构师。

水厂水泵工作流程图canvas动画

水厂水泵工作流程图.jpg展示地址 http://jstopo.top

let state = {
    qpaoTime: 0,
    waterLen: 0,
    countNum: 0,
    timeR: 0
  };
  const starCanvas = document.getElementById('myCanvas');
  const drawLineFunc = (ctx, objs)=>{//绘制线路
      objs.forEach(item=>{
          ctx.save();
          ctx.beginPath();
          ctx.strokeStyle = item.color;
          ctx.lineWidth = item.isDash ? 5:item.lineWidth;
          ctx.lineCap = item.lineCap;
          // item.isDash ? ctx.setLineDash(item.lineDash||[]):ctx.setLineDash([]);
          item.lines.forEach((line,indx)=>{
              if(!item.isDash){
                  if(line.isMoveTo) ctx.moveTo(line.x,line.y);
                  else ctx.lineTo(line.x,line.y);
              }else if(item.isDash && !line.isMoveTo){
                  if( line.y == item.lines[indx-1].y){//不能是第一个点
                      if(line.x > item.lines[indx-1].x){//管道x向右
                          let c = state.countNum, total_c = line.x - item.lines[indx-1].x;
                          while(c <= total_c-4){
                              let ax = item.lines[indx-1].x+c;
                              ctx.moveTo(ax,line.y);
                              let ax2 = item.lines[indx-1].x+c+8;
                              ctx.lineTo(ax2, line.y);
                              c += 12;
                          }
                      }else{//管道x向左
                          let c = state.countNum, total_c = item.lines[indx-1].x - line.x;
                          while(c <= total_c-6){
                              let ax = item.lines[indx-1].x-c;
                              ctx.moveTo(ax,line.y);
                              let ax2 = item.lines[indx-1].x-c-8;
                              ctx.lineTo(ax2, line.y);
                              c += 12;
                          }
                      }
                      
                  }else if(line.x == item.lines[indx-1].x){
                      if(line.y > item.lines[indx-1].y){//管道y向下
                          let c = state.countNum, total_c = line.y - item.lines[indx-1].y;
                          while(c <= total_c-6){
                              let ay = item.lines[indx-1].y+c;
                              ctx.moveTo(line.x, ay);
                              let ay2 = item.lines[indx-1].y+c+8;
                              ctx.lineTo(line.x, ay2);
                              c += 12;
                          }
                      }else{//管道y向上
                          let c = state.countNum, total_c = item.lines[indx-1].y - line.y;
                          while(c <= total_c-4){
                              let ay = item.lines[indx-1].y-c;
                              ctx.moveTo(line.x, ay);
                              let ay2 = item.lines[indx-1].y-c-8;
                              ctx.lineTo(line.x, ay2);
                              c += 12;
                          }
                      }
                      
                  }
              }
          })
          ctx.stroke();
          ctx.restore();
      })
  }
  const drawInitRect = (ctx)=>{//绘制文字矩形
      ctx.save();
      ctx.beginPath();ctx.font = "normal 14px 微软雅黑";
      ctx.lineWidth = 2;ctx.fillStyle = "#333";
      ctx.rect(160, 32, 100, 80);
      ctx.fillText("闸门", 196, 130);
      ctx.moveTo(370+12, 18);
      ctx.arc(370, 38, 18, 0, Math.PI*2);
      ctx.rect(330, 57, 80, 26);
      ctx.fillText("5.5 m³/H", 416, 33);
      ctx.fillText("瞬时流量", 416, 50);
      ctx.fillText("7.5 GJ", 416, 96);
      ctx.fillText("瞬时热量", 416, 111);
  
      ctx.fillText("9.3 m³/H", 216, 370);
      ctx.fillText("瞬时流量", 216, 390);
  
      ctx.fillText("6.8 GJ", 216, 510);
      ctx.fillText("瞬时热量", 216, 530);
  
      ctx.fillText("自流井", 326, 770);
      ctx.fillText("水箱", 908, 750);
      ctx.fillText("水量 73.9%", 788, 690);
      ctx.moveTo(466, 56);
      ctx.lineTo(466, 85);
      ctx.lineTo(490, 85);
      ctx.lineTo(490, 105);
      ctx.lineTo(510, 105);
      ctx.lineTo(510, 85);
      ctx.lineTo(538, 85);
      ctx.lineTo(538, 56);
      ctx.lineTo(468, 56);
      ctx.moveTo(420, 401);
      ctx.rect(300, 572, 100, 180);
      ctx.moveTo(930-52, 606);
      ctx.arc(930, 606, 52, Math.PI, Math.PI*2);
      ctx.lineTo(982, 706);ctx.lineTo(1003, 706);ctx.lineTo(1003, 730);
      ctx.lineTo(860, 730);ctx.lineTo(860, 706);ctx.lineTo(878, 706);ctx.lineTo(878, 606);
      ctx.fill();
      ctx.restore();
      Animation_of_water_flowing_up(ctx);
  }
  const Animation_of_water_flowing_up = (ctx)=>{//水向上流动动画
    const grd = ctx.createLinearGradient(300, 752, 300, 752-state.waterLen);
    ctx.save();
    ctx.beginPath();
    grd.addColorStop(0.3,"#832392");
    grd.addColorStop(1,"#c636de");
    ctx.fillStyle = grd;ctx.strokeStyle = grd;
    //瀑布流动
    ctx.moveTo(400, 647);
    ctx.quadraticCurveTo(352, 678, 352-state.countNum, 752);
    ctx.lineTo(362-state.countNum, 749);
    ctx.quadraticCurveTo(366, 678, 400, 653);
    //水储存在箱子里
    ctx.moveTo(300, 752);
    ctx.lineTo(400, 752);
    ctx.lineTo(400, 751-state.waterLen);
    ctx.lineTo(300, 751-state.waterLen);
    ctx.lineTo(300, 752);
    //水箱 水量
    ctx.moveTo(975, 706);
    ctx.lineTo(885, 706);
    ctx.lineTo(885, 706-state.waterLen);
    ctx.lineTo(975, 706-state.waterLen);
    ctx.moveTo(975, 706);
    ctx.stroke();
    ctx.fill();
    ctx.restore();
    if(state.waterLen > 30){
        randomBubble(ctx,400,752,300,752-state.waterLen);
        randomBubble(ctx,955,706,895,706-state.waterLen);
    }
  }
  const randomBubble = (ctx,sx,sy,ex,ey)=>{//水里随机的气泡动画
      const width = sx - ex, height = sy - ey-16;
      ctx.save();
      ctx.beginPath();ctx.strokeStyle = "rgba(255,255,255,0.4)";
      ctx.arc(sx-Math.random()*width,sy-8-Math.floor(Math.random()*height),Math.floor(Math.random() * 8) + 1,0,Math.PI*2);
      ctx.fillStyle = "rgba(255,255,255,0.4)";
      ctx.fill();ctx.stroke();
      ctx.restore();
  }
  const requestAmatinat = (ctx,width,height)=>{
      const dotMove = ()=>{
          ctx.clearRect(0,0,width,height);
          drawLineFunc(ctx,[
              {
                  isDash:false,
                  lines:[
                    {x:1180,y:682,isMoveTo:true},
                    {x:1180,y:546},{x:1420,y:546},
                    {x:1420,y:546},{x:1420,y:160}
                  ],
                  color:'#333',lineWidth:16,lineCap:'round'
              },
              {
                isDash:true,
                lines:[
                  {x:1180,y:682,isMoveTo:true},
                  {x:1180,y:546},{x:1420,y:546},
                  {x:1420,y:546},
                  {x:1420,y:160}
                ],
                color:'#c636de',
                lineWidth:5,
                lineCap:'butt',
                lineDash:[12, 3]
              },
              {isDash:false,lines:[
                {x:500,y: 70,isMoveTo:true},
                {x:500,y:310},
                {x:width - 160,y:310},
                {x:100,y:70,isMoveTo:true},
                {x:width - 160,y:70},
                {x:100,y:70,isMoveTo:true},
                {x:width - 160,y:70},
                {x:320,y:221,isMoveTo:true},
                {x:320, y: 470},
                {x:1180,y: 470},
                {x:1180,y: 410},
                {x:width - 160, y:410}
              ],color:'#333',lineWidth:16,lineCap:'round'},
              {
                  isDash:true,
                  lines:[
                    {x:500,y:70,isMoveTo:true},
                    {x:500,y:310},
                    {x:width - 160,y:310},
                    {x:100,y:70,isMoveTo:true},
                    {x:width - 160,y:70},
                  ],
                  color:'#ff9800',
                  lineWidth:5,
                  lineCap:'butt',
                  lineDash:[12, 3]
              },
              {
                isDash:false,
                lines:[
                {x:100,y:220,isMoveTo:true},
                {x:width - 600,y:220},
                {x:width - 600,y:160},
                {x:width - 160,y:160},
                {x:100,y:550,isMoveTo:true},
                {x:830, y:550},{x:830,y:650},{x:880,y:650},
                {x:980,y:682,isMoveTo:true},
                {x:1536, y:682},
                {x:1536, y:410},
                {x:500,y:550,isMoveTo:true},
                {x:500,y:650},{x:401,y:650},
                {x:401,y:680,isMoveTo:true},
                {x:610,y:680},{x:610,y:750},
                {x:710,y:750},{x:710,y:790},
                ],
                color:'#333',lineWidth:16,lineCap:'round'
              },
              {
                isDash:true,
                lines:[
                    {x:100,y:220,isMoveTo:true},
                    {x:width - 600, y:220},
                    {x:width - 600, y:160},
                    {x:width - 160, y:160},
                    {x:320,y:221,isMoveTo:true},
                    {x:320, y:470},
                    {x:1180, y:470},
                    {x:1180, y:410},
                    {x:width - 160, y:410},
                    {x:100,y:550,isMoveTo:true},
                    {x:830, y:550},{x:830,y:650},{x:880,y:650},
                    {x:980,y:682,isMoveTo:true},
                    {x:1536, y:682},{x:1536, y:410},
                    {x:500,y:550,isMoveTo:true},
                    {x:500,y:650},{x:401,y:650},
                    {x:401,y:680,isMoveTo:true},
                    {x:610,y:680},{x:610,y:750},
                    {x:710,y:750},{x:710,y:830},
                  ],
                  color:'#c636de',
                  lineWidth:5,
                  lineCap:'butt',
                  lineDash:[12, 3]
              },
          ]);
          drawInitRect(ctx);
          state.countNum+=0.6;
          state.waterLen+=0.2;
          state.qpaoTime+=1;
          if(state.qpaoTime >= 100){
              state.qpaoTime = 0;
          }
          if(state.waterLen >= 110){
              state.waterLen = 110;
              cancelAnimationFrame(state.timeR);
          }
          if(state.countNum >= 12){
              state.countNum = 0;
              cancelAnimationFrame(state.timeR);
          }
          state.timeR = requestAnimationFrame(dotMove);
      }
      dotMove();
  }
  const drawChart = ()=>{
      if(!starCanvas) return;
      starCanvas.width = starCanvas.clientWidth;
      starCanvas.height = starCanvas.clientHeight;
  
      const ctx = starCanvas.getContext('2d');
      requestAmatinat(ctx,starCanvas.width,starCanvas.height);
  }
  window.onload = ()=>{
    drawChart();
  }

5 分钟用 Vite SSR 搭建一个全栈 React 应用

Vite 是 JavaScript 生态中最快的开发服务器。但用它做 SSR 一直意味着自己接 renderToPipeableStream、配置 client/server 构建、处理 hydration。

Pareto 是基于 Vite 7 的 React SSR 框架,帮你处理好这一切。文件路由、流式 SSR、loader、状态管理、62 KB 的客户端包——零配置。

5 分钟,从零到一个全栈 React 应用。

1. 创建项目(30 秒)

npx create-pareto@latest my-app
cd my-app
npm install
npm run dev

打开 http://localhost:3000。编辑 app/page.tsx,通过 Vite 的 HMR 即时热更新。

2. 理解项目结构(30 秒)

my-app/
  app/
    layout.tsx        # 根布局(header、nav、footer)
    page.tsx          # 首页 (/)
    head.tsx          # 根 <title> 和 meta 标签
    not-found.tsx     # 404 页面
    globals.css       # 全局样式
  pareto.config.ts    # 框架配置(可选)
  package.json
  tsconfig.json

app/ 下任何包含 page.tsx 的目录就是一个路由。嵌套目录创建嵌套路由。就这样。

3. 创建带服务端数据的页面(1 分钟)

/posts 创建新路由:

// app/posts/loader.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  // 只在服务端运行
  return {
    posts: [
      { id: 1, title: 'Hello World', body: '第一篇文章' },
      { id: 2, title: 'Vite SSR', body: '真的很快' },
    ],
  }
}
// app/posts/page.tsx
import { useLoaderData } from '@paretojs/core'

interface Post {
  id: number
  title: string
  body: string
}

export default function PostsPage() {
  const { posts } = useLoaderData<{ posts: Post[] }>()

  return (
    <div>
      <h1>文章列表</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}
// app/posts/head.tsx
export default function Head() {
  return (
    <>
      <title>文章 — My App</title>
      <meta name="description" content="所有博客文章" />
    </>
  )
}

访问 http://localhost:3000/posts。Loader 在服务端运行,HTML 是服务端渲染的,客户端 hydrate。查看源码——文章内容就在 HTML 里。

4. 为慢数据添加流式渲染(1 分钟)

真实应用需要查数据库、调 API。有些快,有些慢。用 defer() 流式传输慢数据,不阻塞页面:

// app/dashboard/loader.ts
import { defer } from '@paretojs/core'

async function getQuickStats() {
  return { users: 1_234, pageViews: 56_789 }
}

async function getSlowAnalytics() {
  // 模拟一个慢 API 调用
  await new Promise((r) => setTimeout(r, 2000))
  return { topPage: '/posts', bounceRate: 0.42 }
}

export async function loader() {
  const stats = await getQuickStats()  // 先解析快数据
  return defer({
    stats,                               // 已解析——包含在初始 HTML
    analytics: getSlowAnalytics(),       // Promise——后续流式传输
  })
}
// app/dashboard/page.tsx
import { useLoaderData, Await } from '@paretojs/core'

export default function DashboardPage() {
  const { stats, analytics } = useLoaderData()

  return (
    <div>
      <h1>仪表板</h1>
      <p>{stats.users} 用户 · {stats.pageViews} 页面浏览</p>

      <Await resolve={analytics} fallback={<p>加载分析数据...</p>}>
        {(data) => (
          <div>
            <p>热门页面:{data.topPage}</p>
            <p>跳出率:{(data.bounceRate * 100).toFixed(0)}%</p>
          </div>
        )}
      </Await>
    </div>
  )
}

访问 http://localhost:3000/dashboard。统计数据立即显示。分析数据 2 秒后流入。页面从不阻塞。

5. 添加客户端导航(30 秒)

<Link> 实现 SPA 风格的导航:

// app/layout.tsx
import type { PropsWithChildren } from 'react'
import { Link } from '@paretojs/core'

export default function RootLayout({ children }: PropsWithChildren) {
  return (
    <>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/posts">文章</Link>
        <Link to="/dashboard">仪表板</Link>
      </nav>
      <main>{children}</main>
    </>
  )
}

点击即时导航。Loader 数据通过 NDJSON 流式获取——延迟数据逐步流入,和初始 SSR 渲染行为一致。

6. 添加状态管理(30 秒)

Pareto 内置 defineStore(),集成 Immer——不需要额外依赖:

// app/stores/theme.ts
import { defineStore } from '@paretojs/core/store'

export const themeStore = defineStore((set) => ({
  mode: 'light' as 'light' | 'dark',
  toggle: () => set((d) => {
    d.mode = d.mode === 'light' ? 'dark' : 'light'
  }),
}))
// 在任何组件中使用
import { themeStore } from '../stores/theme'

function ThemeToggle() {
  const { mode, toggle } = themeStore.useStore()
  return <button onClick={toggle}>主题:{mode}</button>
}

状态在 SSR 期间自动序列化,客户端自动 hydrate。零样板代码。

7. 添加 API 端点(30 秒)

创建 route.ts 文件来定义 JSON API 端点:

// app/api/time/route.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  return { time: new Date().toISOString() }
}

GET http://localhost:3000/api/time 返回 {"time":"2026-04-03T..."}。标准 REST 端点,无需额外配置。

8. 构建和部署(1 分钟)

npm run build
npm run start

你的生产服务器是标准 Node.js 进程,跑 Express + Vite 优化后的构建产物。部署到任何地方:Docker、Fly.io、Railway、VPS、Kubernetes。

不需要特殊托管。不需要 serverless 运行时兼容。不锁定供应商。

你刚刚构建了什么

5 分钟内,你拥有了:

  • 文件路由 — 目录映射为路由
  • 服务端渲染 — 首次加载完整 HTML,利好 SEO
  • 流式 SSR — 慢数据不阻塞页面
  • 客户端导航 — SPA 体验 + NDJSON 流式传输
  • Head 管理 — 每个路由独立的 title 和 meta 标签
  • 状态管理 — Immer 驱动的 store,SSR hydration 全自动
  • API 端点 — JSON 路由和页面共存
  • TypeScript — 全链路类型安全
  • 62 KB 客户端包 — 比 Next.js 小 73%

全部基于 Vite 7——即时启动开发服务器、React Fast Refresh、原生 ESM。

为什么选 Vite 做 SSR?

Vite 的原生 ESM 开发服务器意味着开发时零打包。你的 100 个路由的应用启动速度和 1 个路由一样快。对比基于 Webpack 的框架,开发服务器启动时间随项目规模线性增长。

插件生态是另一个优势——PostCSS、Tailwind、MDX 以及数百个 Rollup/Vite 插件开箱即用,不需要框架包装层。

下一步

npx create-pareto@latest my-app
cd my-app && npm install && npm run dev

Pareto — 轻量级流式 React SSR 框架 | 文档

VueUse 全面指南|Vue3组合式工具集实战

VueUse 是基于 Vue3 Composition API 开发的实用函数集合库,由 Vue 核心团队成员主导维护,收录了200+开箱即用的工具函数,覆盖 DOM 操作、浏览器 API、响应式状态管理、性能优化等几乎所有前端开发场景。其核心理念是“拒绝重复造轮子”,将开发中常用但繁琐的逻辑(如本地存储、鼠标监听、防抖节流)封装成可复用的组合式函数,让开发者专注于业务逻辑,大幅提升开发效率。

VueUse 完美适配 Vue3,原生支持 TypeScript,支持摇树优化(Tree Shaking),按需引入不冗余,同时兼容 Vue2(需使用对应版本)和 SSR 场景,是 Vue3 项目开发的必备工具库之一。

一、VueUse 核心特点

  • Composition API 原生适配:所有函数均基于 Vue3 setup 语法和 ref/reactive 构建,API 风格与 Vue3 原生语法高度一致,上手无压力,无需额外学习成本。
  • 类型友好:全程使用 TypeScript 编写,自带完整类型定义,开发时可获得精准代码提示,减少类型错误,适配 TS 项目开发需求。
  • 模块化设计:采用按需引入机制,仅打包用到的函数,避免引入全部模块造成的体积膨胀,优化项目打包性能。
  • 场景覆盖广泛:涵盖响应式状态、浏览器能力、DOM 操作、表单控制、网络请求、性能优化等200+场景,满足日常开发99%的需求。
  • 灵活通用:支持 CDN 引入(无需打包器),适配 Vite、Webpack、Nuxt 等多种构建工具,同时支持 SSR 友好,可搭配 Vue Router、Firebase 等插件使用。
  • 中文文档完善:官方提供中文文档,每个函数均有交互式演示,查询便捷,新手可快速上手。

二、环境安装(Vue3 实战首选)

VueUse 核心包为 @vueuse/core,包含绝大多数常用工具函数;若需特定场景(如音频、地图),可安装对应子包。以下是主流安装方式,推荐使用 npm 或 pnpm:

2.1 核心包安装(必装)

// npm 安装(推荐,适配绝大多数项目)
npm install @vueuse/core -S

// yarn 安装
yarn add @vueuse/core

// pnpm 安装(高效包管理,推荐)
pnpm add @vueuse/core

2.2 特定场景子包安装(按需选择)

若需使用音频、地图、Firebase 等特定功能,可单独安装对应子包:

// 音频相关工具(如播放、录音)
npm install @vueuse/sound -S

// 地图相关工具(如高德、百度地图集成)
npm install @vueuse/map -S

// Firebase 集成工具
npm install @vueuse/firebase -S

2.3 CDN 引入(无需打包器,快速测试)

适合快速演示或无需打包的简单项目,引入后可通过 window.VueUse 访问所有函数:

<script src="https://unpkg.com/@vueuse/shared"></script>
<script src="https://unpkg.com/@vueuse/core"></script>

2.4 Nuxt 项目适配

Nuxt 3 已内置 VueUse 支持,无需单独安装,仅需在配置文件中注册模块即可实现自动引入:

// nuxt.config.ts(Nuxt 3)
export default defineNuxtConfig({
  modules: ['@vueuse/nuxt']
})

三、核心用法(按场景分类,实战必备)

VueUse 的使用逻辑简单统一:按需引入所需函数,在 setup 语法中调用,即可获得响应式结果或封装好的逻辑,无需手动处理事件绑定、销毁等冗余操作。以下按高频场景分类讲解,代码可直接复制套用。

3.1 响应式状态与本地存储(最常用)

用于处理响应式状态切换、计数器、本地存储(localStorage/sessionStorage)等场景,自动处理 JSON 序列化和响应式同步,刷新页面数据不丢失。

3.1.1 useLocalStorage(本地持久化存储)

替代原生 localStorage,返回响应式 ref 对象,修改后自动同步到本地存储,适合存储用户偏好、登录态等需要持久化的数据:

<template>
  <div>
    <p>当前主题:{{ theme }}</p>
    <button @click="theme = theme === 'light' ? 'dark' : 'light'">切换主题</button>
  </div>
</template>

<script setup lang="ts">
// 按需引入
import { useLocalStorage } from '@vueuse/core'

// 第一个参数:localStorage 键名;第二个参数:默认值
const theme = useLocalStorage('app_theme', 'light')
// 修改值时,自动同步到 localStorage
// theme.value = 'dark'
</script>

3.1.2 useSessionStorage(会话级存储)

用法与 useLocalStorage 完全一致,区别在于数据存储在 sessionStorage 中,关闭页面后自动丢失,适合存储临时数据(如表单草稿):

import { useSessionStorage } from '@vueuse/core'

// 存储临时表单数据
const tempForm = useSessionStorage('temp_form', { username: '', password: '' })

3.1.3 useToggle(布尔值切换)

快速实现布尔值切换逻辑,适合弹窗显示/隐藏、开关状态等场景:

<template>
  <button @click="toggle">{{ isShow ? '隐藏' : '显示' }}弹窗</button>
  <div v-if="isShow" class="modal">弹窗内容</div>
</template>

<script setup lang="ts">
import { useToggle } from '@vueuse/core'

// 接收默认值,返回 [状态值, 切换函数]
const [isShow, toggle] = useToggle(false)
// 也可自定义切换值(如切换主题字符串)
// const [theme, toggleTheme] = useToggle('light', ['light', 'dark'])
</script>

3.1.4 useCounter(计数器工具)

封装计数器逻辑,支持增减、重置、设置值等操作,适合数量选择、分页页码等场景:

<template>
  <div>
    <button @click="dec()">-</button>
    <span>{{ count }}</span>
    <button @click="inc()">+</button>
    <button @click="reset()">重置</button>
    <button @click="set(10)">设为10</button>
  </div>
</template>

<script setup lang="ts">
import { useCounter } from '@vueuse/core'

// 默认值为0,可指定初始值和范围(如 min:0, max:10)
const { count, inc, dec, reset, set } = useCounter(0, { min: 0, max: 10 })
</script>

3.2 浏览器能力封装(简化原生 API)

将浏览器原生 API(如鼠标监听、网络状态、窗口尺寸)封装为响应式函数,自动处理事件绑定与销毁,避免内存泄漏。

3.2.1 useMouse(鼠标位置监听)

实时获取鼠标坐标,支持限制监听范围(如某元素内),适合鼠标跟随、悬浮交互等场景:

<template>
  <div>
    <p>鼠标位置:({{ x.toFixed(0) }}, {{ y.toFixed(0) }})</p>
    <div 
      class="follow" 
      :style="{ left: `${x + 10}px`, top: `${y + 10}px` }"
    ></div>
  </div>
</template>

<script setup lang="ts">
import { useMouse } from '@vueuse/core'

// 获取鼠标x、y坐标(响应式)
const { x, y } = useMouse()
// 限制监听范围(仅在id为container的元素内监听)
// const { x, y } = useMouse({ target: document.getElementById('container') })
</script>

<style scoped>
.follow {
  position: fixed;
  width: 10px;
  height: 10px;
  background: red;
  border-radius: 50%;
}
</style>

3.2.2 useNetwork(网络状态监听)

监听用户网络连接状态(在线/离线),适合提示用户网络异常、离线缓存等场景:

<template>
  <div v-if="!isOnline" class="offline-tip">
    ❌ 网络已断开,请检查网络连接
  </div>
</template>

<script setup lang="ts">
import { useNetwork } from '@vueuse/core'

const { isOnline, downlink } = useNetwork()
// isOnline:是否在线(布尔值)
// downlink:网络速度(Mbps)
</script>

3.2.3 useDark(深色模式切换)

快速实现深色/浅色模式切换,自动同步系统主题偏好,支持自定义主题类名:

<template>
  <div>
    <h1>当前模式:{{ isDark ? '🌙 深色' : '☀️ 浅色' }}</h1>
    <button @click="toggleDark()">切换主题</button>
  </div>
</template>

<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'

// 监听系统深色模式,同步到 html 标签的 class(默认添加 dark 类)
const isDark = useDark({
  selector: 'html',
  valueDark: 'dark',
  valueLight: ''
})
// 结合 useToggle 实现切换
const toggleDark = useToggle(isDark)
</script>

<style>
html.dark {
  background-color: #121212;
  color: #fff;
}
</style>

3.2.4 useWindowSize(窗口尺寸监听)

实时获取窗口宽高,响应式更新,适合响应式布局、适配移动端/桌面端场景:

import { useWindowSize } from '@vueuse/core'
import { computed } from 'vue'

const { width, height } = useWindowSize()
// 判断是否为移动端(屏幕宽度 < 768px)
const isMobile = computed(() => width.value < 768)

3.3 表单与输入控制(优化交互体验)

封装防抖、节流、剪贴板等常用表单交互逻辑,简化输入框搜索、复制粘贴等功能开发。

3.3.1 useDebounce(防抖输入)

对输入值进行防抖处理,延迟执行逻辑,适合搜索框、输入验证等场景,避免频繁触发请求:

<template>
  <input 
    v-model="searchInput" 
    placeholder="请输入搜索关键词"
    style="width: 300px; padding: 8px;"
  />
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useDebounce } from '@vueuse/core'

const searchInput = ref('')
// 防抖处理:延迟500ms,返回防抖后的响应式值
const debouncedInput = useDebounce(searchInput, 500)

// 监听防抖后的值,触发搜索逻辑
watch(debouncedInput, (val) => {
  if (val) {
    console.log('搜索关键词:', val)
    // 调用搜索接口...
  }
})
</script>

3.3.2 useThrottle(节流控制)

限制函数执行频率,适合滚动事件、resize 事件等频繁触发的场景,优化性能:

import { useThrottle } from '@vueuse/core'

// 对窗口滚动事件进行节流,200ms内仅执行一次
const scrollY = useThrottle(window.scrollY, 200)

3.3.3 useCopyToClipboard(剪贴板操作)

简化复制文本到剪贴板的逻辑,自带复制状态反馈,无需编写原生 API 代码:

<template>
  <div>
    <input v-model="copyText" placeholder="请输入要复制的内容" />
    <button @click="copy()">{{ copied ? '已复制✅' : '点击复制' }}</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useCopyToClipboard } from '@vueuse/core'

const copyText = ref('https://vueuse.org')
// 接收复制源,返回 [复制函数, 复制状态]
const { copy, copied } = useCopyToClipboard({ source: copyText })
</script>

3.4 DOM 操作与交互(简化 DOM 操作)

封装常用 DOM 操作逻辑,自动处理元素监听、尺寸获取、拖拽等功能,避免手动操作 DOM 带来的冗余代码。

3.4.1 useScroll(滚动位置监听)

监听元素或窗口的滚动位置,适合滚动加载、回到顶部、滚动导航等场景:

<template>
  <div ref="container" class="scroll-container"&gt;
    <!-- 滚动内容 -->
  </div>
  <button @click="scrollToTop()" v-if="y > 100">回到顶部</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useScroll } from '@vueuse/core'

const container = ref(null)
// 监听指定元素的滚动位置(默认监听窗口)
const { y, scrollTo } = useScroll(container)

// 回到顶部
const scrollToTop = () => {
  scrollTo({ top: 0, behavior: 'smooth' })
}
</script>

3.4.2 useElementSize(元素尺寸监听)

实时获取元素的宽高,响应式更新,适合自适应布局、元素尺寸变化监听等场景:

<template>
  <div ref="box" class="box">自适应盒子</div>
  <p>盒子尺寸:{{ width }}px × {{ height }}px</p>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useElementSize } from '@vueuse/core'

const box = ref(null)
// 获取元素宽高(响应式)
const { width, height } = useElementSize(box)
</script>

3.4.3 onClickOutside(点击外部关闭)

监听元素外部的点击事件,适合弹窗、下拉菜单等场景,点击外部自动关闭:

<template>
  <button @click="isOpen = true">打开下拉菜单</button>
  <div ref="menu" v-if="isOpen" class="menu">
    下拉菜单内容
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { onClickOutside } from '@vueuse/core'

const isOpen = ref(false)
const menu = ref(null)

// 点击 menu 外部,关闭下拉菜单
onClickOutside(menu, () => {
  isOpen.value = false
})
</script>

3.5 网络请求(简化请求逻辑)

封装 fetch API,自带加载状态、错误处理,返回响应式数据,适合简单网络请求场景,可替代 axios 基础用法。

3.5.1 useFetch(通用网络请求)

<template>
  <div>
    <div v-if="isLoading">加载中...</div>
    <div v-if="error" class="error">请求失败:{{ error.message }}</div>
    <div v-if="data">{{ data.content }}</div>
  </div>
</template>

<script setup lang="ts">
import { useFetch } from '@vueuse/core'

// 发起 GET 请求,返回响应式数据、加载状态、错误信息
const { data, isLoading, error, execute } = useFetch('https://api.example.com/data', {
  method: 'GET',
  // 可选配置:请求头、参数等
  headers: { 'Content-Type': 'application/json' }
})

// 手动触发请求(如点击按钮发起请求)
// const handleFetch = () => execute()
</script>

四、VueUse 进阶技巧(实战提升)

4.1 函数组合使用

VueUse 的函数可自由组合,实现复杂功能,例如结合 useDark、useLocalStorage、useToggle 实现主题切换并持久化:

import { useDark, useToggle, useLocalStorage } from '@vueuse/core'

// 结合本地存储,持久化主题状态
const theme = useLocalStorage('app_theme', 'light')
const isDark = useDark({ valueDark: 'dark', valueLight: 'light' })
// 同步主题状态与本地存储
theme.value = isDark.value ? 'dark' : 'light'
// 切换主题时同步更新本地存储
const toggleTheme = useToggle(isDark, [false, true])
toggleTheme(() => {
  theme.value = isDark.value ? 'dark' : 'light'
})

4.2 自定义配置参数

大多数函数支持自定义配置,例如限制计数器范围、自定义本地存储键名、指定监听目标等,灵活适配业务需求:

// 1. 限制计数器范围(0-100)
const { count, inc } = useCounter(0, { min: 0, max: 100 })

// 2. 自定义本地存储键名和存储方式
const user = useLocalStorage('user_info', {}, {
  storage: sessionStorage, // 改用 sessionStorage 存储
  mergeDefaults: true // 合并默认值和存储值
})

// 3. 指定鼠标监听目标(仅在指定元素内监听)
const { x, y } = useMouse({ target: document.getElementById('container') })

4.3 避免常见误区

  • 不要全局引入所有函数:VueUse 支持摇树优化,按需引入即可,全局引入(如 import * as VueUse from '@vueuse/core')会导致打包体积膨胀。
  • 注意浏览器兼容性:部分函数(如 useBattery、useGeolocation)依赖浏览器原生 API,需做降级处理,避免在低版本浏览器中报错。
  • Vue2 适配:VueUse v12.0 及以上版本不再支持 Vue2,若使用 Vue2 项目,需安装 v11.x 版本:npm install @vueuse/core@11 -S

五、常用函数速查表(快速查询)

函数分类 常用函数 核心功能
响应式状态 useToggle、useCounter、useStorage 布尔值切换、计数器、响应式存储
浏览器能力 useMouse、useNetwork、useDark、useWindowSize 鼠标监听、网络状态、深色模式、窗口尺寸
表单控制 useDebounce、useThrottle、useCopyToClipboard 防抖、节流、剪贴板操作
DOM 操作 useScroll、useElementSize、onClickOutside 滚动监听、元素尺寸、点击外部关闭
网络请求 useFetch、useWebSocket 通用请求、WebSocket 连接

六、官方资源与学习渠道

总结:VueUse 是 Vue3 开发的“效率神器”,通过封装常用逻辑,大幅减少冗余代码,提升开发效率和代码可维护性。新手可从本文讲解的高频函数入手,结合官方文档,快速上手并应用到实际项目中,逐步掌握所有核心用法。

Vue3+Pinia实战完整版|从入门到精通,替代Vuex的状态管理首选

本文专为Vue3开发者打造,从Pinia基础认知入手,逐步讲解环境搭建、核心API用法,结合Vue3+TypeScript实战案例,覆盖日常开发99%场景,新手可直接套用代码,快速掌握Pinia全局状态管理,替代传统Vuex,提升开发效率。

核心定位:Pinia是Vue官方推荐的全局状态管理工具,2019年推出,旨在替代Vuex,采用组合式API风格,轻量、简洁且原生支持TS,适配Vue3和Vue2(本文重点聚焦Vue3+TS实战)。

一、Pinia基础认知(入门必看)

1.1 什么是Pinia

Pinia是一个用于跨组件、跨页面进行状态共享的全局状态管理库,功能与Vuex、Redux一致,但API更简洁,使用体验更贴近Vue3组合式API,本质上是Vuex5的最终实现形态——Vue官方团队在探索Vuex下一次迭代时,发现Pinia已满足大部分需求,最终决定用Pinia替代Vuex。

1.2 Pinia核心特点

  • 完整TS支持:无需手动编写复杂类型声明,原生支持类型推断,TS开发体验拉满,补全更流畅。
  • 极致轻量:压缩后体积仅1KB左右,无多余依赖,不增加项目负担。
  • 简化语法:移除Vuex中繁琐的mutations,仅保留state、getters、actions,降低学习和使用成本。
  • actions多支持:既支持同步操作,也支持异步操作(如接口请求),无需区分同步/异步逻辑。
  • 扁平化结构:无模块嵌套,只有store概念,每个store独立存在,可自由调用,无需管理复杂的命名空间。
  • 自动注册:store一旦创建,无需手动添加到全局,自动挂载,开箱即用。
  • 跨版本兼容:同时支持Vue3和Vue2,除初始化安装和SSR配置外,两者API完全一致。

1.3 Pinia与Vuex的核心区别

Pinia最初是为探索Vuex下一次迭代而设计,整合了Vuex核心团队的诸多想法,最终成为Vuex的替代方案,两者核心区别如下:

对比维度 Vuex Pinia
核心结构 State、Getters、Mutations(同步)、Actions(异步) State、Getters、Actions(同步+异步),无Mutations
版本适配 Vuex4适配Vue3,Vuex3适配Vue2,无法跨版本使用 最新版2.x,同时适配Vue3和Vue2
TS支持 需创建自定义复杂包装器,类型推断不友好 原生支持TS,类型推断完善,无需额外配置
模块结构 支持模块嵌套,需配置命名空间,逻辑繁琐 扁平化结构,无嵌套,store独立,可自由调用
注册方式 需手动注册store到全局 自动注册,创建后即可使用
API复杂度 API繁琐,需记住mutations提交、命名空间等规则 API简洁,贴近组合式API,上手成本低

1.4 适用场景

任何需要跨组件、跨页面共享状态的Vue3项目,无论是中小型项目(如个人博客、管理后台),还是大型项目(如电商平台),Pinia都能胜任,尤其适合TS开发的项目,能大幅提升开发效率和代码可维护性。

二、Vue3+Pinia环境搭建(实战第一步)

本章节以Vue3+TypeScript项目为例,讲解Pinia的安装、全局注册,步骤简洁,可直接复制命令和代码执行。

2.1 前提条件

已创建Vue3+TS项目(若未创建,执行命令:npm create vue@latest,选择TS、Pinia(可选,此处可跳过,后续手动安装))。

2.2 安装Pinia

打开终端,进入项目根目录,执行以下命令(三选一,推荐npm或yarn):

// npm 安装(推荐)
npm install pinia -S

// yarn 安装
yarn add pinia

// cnpm 安装
cnpm install pinia -S

2.3 全局注册Pinia(Vue3)

修改项目入口文件main.ts,引入并挂载Pinia实例,全局仅需配置一次:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
// 引入Pinia的createPinia方法
import { createPinia } from 'pinia'

// 创建Pinia实例
const pinia = createPinia()
// 创建Vue应用并挂载Pinia
const app = createApp(App)
app.use(pinia) // 挂载Pinia到Vue应用
app.mount('#app')

补充:Vue2中注册方式略有不同(需引入PiniaVuePlugin),本文聚焦Vue3,Vue2用法可参考文末补充说明。

三、Pinia核心用法(Vue3+TS实战)

Pinia的核心是Store(仓库),每个Store对应一个独立的状态模块,通过defineStore方法创建,包含state(状态)、getters(计算属性)、actions(业务逻辑)三部分,以下逐一讲解。

3.1 初始化Store(核心步骤)

推荐在项目根目录下创建src/store文件夹,用于存放所有Store文件,按业务模块划分(如用户模块、购物车模块),规范命名(如userStore.tscartStore.ts)。

步骤:先定义Store名称枚举(避免重复),再创建Store实例。

第一步:定义Store名称枚举(可选,推荐)

创建src/store/store-name.ts,用于统一管理Store名称,避免重复(尤其多Store场景):

// src/store/store-name.ts
// 用枚举定义Store名称,唯一且直观
export const enum Names {
  Test = 'TEST', // 测试Store名称
  User = 'USER', // 用户Store名称
  Cart = 'CART'  // 购物车Store名称
}

第二步:创建Store实例

创建src/store/index.ts(或按模块拆分,如userStore.ts),使用defineStore方法创建Store,核心包含state、getters、actions:

// src/store/index.ts
import { defineStore } from 'pinia';
import { Names } from './store-name'; // 引入Store名称枚举

// defineStore接收两个参数:
// 1. 唯一标识(必须与枚举值一致,全局唯一,不可重复)
// 2. 配置对象(包含state、getters、actions)
export const useTestStore = defineStore(Names.Test, {
  // 1. state:存储全局状态,必须是箭头函数(避免SSR数据污染,优化TS类型推导)
  state: () => {
    return {
      current: 1, // 数字类型状态
      name: '小马', // 字符串类型状态
      list: [1, 2, 3] // 数组类型状态
    };
  },

  // 2. getters:类似组件的computed,用于修饰状态,有缓存功能
  getters: {
    // 方式一:接收state作为参数(推荐,类型推断更友好)
    myGetCount(state) {
      // 缓存特性:页面多次使用,仅执行一次计算
      console.log('getters被调用');
      return state.current + 1;
    },

    // 方式二:不传递参数,使用this访问state(需指定返回值类型,否则TS推导失败)
    myGetName(): string {
      return `姓名:${this.name}`;
    },

    // 进阶:getters依赖其他getters
    myGetCombined(): string {
      return `${this.myGetName()},计数+1:${this.myGetCount}`;
    }
  },

  // 3. actions:类似组件的methods,用于修改state,支持同步和异步
  actions: {
    // 同步action:修改state(不能用箭头函数,否则this指向异常)
    setCurrentParam(num: number) {
      this.current += num; // 直接通过this访问state并修改
    },

    // 同步action:批量修改多个状态
    updateState(newCurrent: number, newName: string) {
      this.current = newCurrent;
      this.name = newName;
    },

    // 异步action:结合async/await(如接口请求)
    async fetchData() {
      // 模拟接口请求(实际开发中替换为真实接口)
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve({ current: 10, name: '异步更新后' });
        }, 1000);
      });
      // 异步请求成功后,修改state
      const data = res as { current: number; name: string };
      this.current = data.current;
      this.name = data.name;
    }
  },
});

3.2 组件中使用Store(核心实战)

在Vue3组件(<script setup lang="ts">)中,引入Store实例,即可访问、修改状态,调用actions,以下是完整示例。

3.2.1 基础使用(访问state、getters)

<template>
  <div class="pinia-demo">
    <h3>基础使用</h3>
    <!-- 直接访问state -->
    <p>当前计数:{{ testStore.current }}</p>
    <p>姓名:{{ testStore.name }}</p>
    <!-- 访问getters(直接当作属性使用,无需调用) -->
    <p>计数+1:{{ testStore.myGetCount }}</p>
    <p>组合getters:{{ testStore.myGetCombined }}</p>
  </div>
</template>

<script setup lang="ts">
// 1. 引入Store实例
import { useTestStore } from '@/store';

// 2. 创建Store实例(Pinia自动管理单例,多次调用返回同一个实例)
const testStore = useTestStore();
</script>

3.2.2 修改state(5种方式,实战常用)

Pinia提供多种修改state的方式,按需选择,推荐使用$patch(批量修改)和actions(业务逻辑封装)。

<template>
  <div class="pinia-demo">
    <h3>修改state</h3>
    <p>当前计数:{{ testStore.current }}</p>
    <button @click="handleDirectModify">1.直接修改</button>
    <button @click="handlePatchObj">2.$patch对象批量修改</button>
    <button @click="handlePatchFn">3.$patch函数自定义修改</button>
    <button @click="handleReplaceState">4.$state替换整个状态</button>
    <button @click="handleActionsModify">5.通过actions修改</button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
const testStore = useTestStore();

// 方式1:直接修改(简单场景可用,不推荐复杂场景)
const handleDirectModify = () => {
  testStore.current++; // 直接修改单个状态
  // testStore.name = '新姓名'; // 直接修改单个状态
};

// 方式2:$patch对象形式(批量修改多个状态,推荐简单批量场景)
const handlePatchObj = () => {
  testStore.$patch({
    current: 10,
    name: '批量修改后',
    list: [4, 5, 6]
  });
};

// 方式3:$patch函数形式(自定义修改逻辑,推荐复杂场景)
const handlePatchFn = () => {
  testStore.$patch((state) => {
    state.current += 5; // 复杂计算修改
    state.list.push(7); // 数组操作
    if (state.current > 20) {
      state.name = '计数超标';
    }
  });
};

// 方式4:$state替换整个状态(需修改所有属性,不推荐常规场景)
const handleReplaceState = () => {
  testStore.$state = {
    current: 0,
    name: '替换整个状态',
    list: []
  };
};

// 方式5:通过actions修改(推荐,封装业务逻辑,便于维护和复用)
const handleActionsModify = () => {
  testStore.setCurrentParam(3); // 调用同步action
  // testStore.updateState(15, 'actions修改'); // 调用同步action
  // testStore.fetchData(); // 调用异步action
};
</script>

3.2.3 响应式解构state(关键技巧)

直接解构state会丢失响应性(Pinia的state默认用reactive处理,与Vue3 reactive解构规则一致),需使用Pinia提供的storeToRefs方法,实现响应式解构。

<template>
  <div class="pinia-demo">
    <h3>响应式解构</h3>
    <p>解构后计数:{{ current }}</p>
    <p>解构后姓名:{{ name }}</p>
    <button @click="handleChange">修改解构后的值</button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
import { storeToRefs } from 'pinia'; // 引入storeToRefs

const testStore = useTestStore();

// 错误写法:直接解构,失去响应性
// const { current, name } = testStore;

// 正确写法:用storeToRefs解构,保持响应性
const { current, name } = storeToRefs(testStore);

// 修改解构后的值(需用.value,因为storeToRefs会将状态转为ref)
const handleChange = () => {
  current.value++;
  name.value = '解构后修改';
};
</script>

3.2.4 调用异步actions(实战常用)

actions支持async/await,可直接在组件中调用异步action,处理接口请求等异步逻辑,示例如下:

<template>
  <div class="pinia-demo">
    <h3>异步actions</h3>
    <p>当前计数:{{ testStore.current }}</p>
    <p>姓名:{{ testStore.name }}</p>
    <button @click="handleFetchData" :disabled="loading">
      {{ loading ? '加载中...' : '异步请求更新' }}
    </button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
import { ref } from 'vue';

const testStore = useTestStore();
const loading = ref(false);

// 调用异步action
const handleFetchData = async () => {
  loading.value = true;
  try {
    await testStore.fetchData(); // 等待异步action执行完成
  } catch (err) {
    console.error('异步请求失败:', err);
  } finally {
    loading.value = false;
  }
};
</script>

3.3 多Store使用(实战场景)

Pinia无模块嵌套,多个Store独立存在,可在组件中同时引入多个Store,也可在一个Store中引入另一个Store(实现Store间通信)。

3.3.1 组件中引入多个Store

// src/store/userStore.ts(新增用户Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

export const useUserStore = defineStore(Names.User, {
  state: () => ({
    token: '',
    userInfo: { name: '游客', age: 18 }
  }),
  actions: {
    login(token: string, userInfo: any) {
      this.token = token;
      this.userInfo = userInfo;
    },
    logout() {
      this.token = '';
      this.userInfo = { name: '游客', age: 18 };
    }
  }
});

// 组件中使用多个Store
<script setup lang="ts">
import { useTestStore } from '@/store';
import { useUserStore } from '@/store/userStore';

const testStore = useTestStore();
const userStore = useUserStore();

// 调用不同Store的方法
const handleLogin = () => {
  userStore.login('abc123', { name: '小明', age: 20 });
};
</script>

3.3.2 Store间通信(一个Store调用另一个Store)

在一个Store的actions中,引入另一个Store实例,即可实现Store间的数据交互:

// src/store/cartStore.ts(购物车Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';
import { useUserStore } from './userStore'; // 引入用户Store

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({
    cartList: [] as { id: number; name: string; price: number }[]
  }),
  actions: {
    // 添加商品到购物车(需判断用户是否登录)
    addToCart(goods: { id: number; name: string; price: number }) {
      const userStore = useUserStore(); // 实例化用户Store
      if (!userStore.token) {
        alert('请先登录');
        return;
      }
      this.cartList.push(goods);
    }
  }
});

四、Vue3+Pinia实战案例(模拟电商场景)

结合前面的核心用法,实现一个简单的电商场景实战案例,包含「用户登录/退出」「购物车添加/删除」「全局状态共享」,整合多Store、异步actions、响应式解构等核心知识点,可直接复制到项目中使用。

4.1 实战准备(创建3个Store)

创建store-name.tsuserStore.ts(用户)、cartStore.ts(购物车)、goodsStore.ts(商品),代码如下:

// 1. store-name.ts(Store名称枚举)
export const enum Names {
  User = 'USER',
  Cart = 'CART',
  Goods = 'GOODS'
}

// 2. userStore.ts(用户Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

// 定义用户信息类型(TS类型约束)
interface UserInfo {
  name: string;
  age: number;
  avatar: string;
}

export const useUserStore = defineStore(Names.User, {
  state: () => ({
    token: localStorage.getItem('token') || '', // 持久化存储token
    userInfo: {} as UserInfo
  }),
  actions: {
    // 登录(异步,模拟接口请求)
    async login(account: string, password: string) {
      // 模拟接口请求
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve({
            token: 'pinia_demo_token_123',
            userInfo: { name: '小明', age: 22, avatar: 'https://picsum.photos/200/200' }
          });
        }, 1000);
      });
      const data = res as { token: string; userInfo: UserInfo };
      this.token = data.token;
      this.userInfo = data.userInfo;
      // 本地持久化token(避免页面刷新丢失)
      localStorage.setItem('token', data.token);
    },
    // 退出登录
    logout() {
      this.token = '';
      this.userInfo = {} as UserInfo;
      localStorage.removeItem('token');
    }
  }
});

// 3. goodsStore.ts(商品Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

// 商品类型约束
interface Goods {
  id: number;
  name: string;
  price: number;
  img: string;
  stock: number;
}

export const useGoodsStore = defineStore(Names.Goods, {
  state: () => ({
    goodsList: [] as Goods[] // 商品列表
  }),
  actions: {
    // 异步获取商品列表(模拟接口)
    async fetchGoodsList() {
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve([
            { id: 1, name: 'Vue3实战教程', price: 99, img: 'https://picsum.photos/200/200', stock: 100 },
            { id: 2, name: 'Pinia入门手册', price: 59, img: 'https://picsum.photos/200/200', stock: 50 },
            { id: 3, name: 'TS入门到精通', price: 79, img: 'https://picsum.photos/200/200', stock: 80 }
          ]);
        }, 800);
      });
      this.goodsList = res as Goods[];
    }
  }
});

// 4. cartStore.ts(购物车Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';
import { useUserStore } from './userStore';
import { Goods } from './goodsStore'; // 复用商品类型

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({
    cartList: [] as { goods: Goods; count: number }[] // 购物车列表(商品+数量)
  }),
  getters: {
    // 计算购物车总价格
    cartTotalPrice(state) {
      return state.cartList.reduce((total, item) => {
        return total + item.goods.price * item.count;
      }, 0);
    },
    // 计算购物车商品总数
    cartTotalCount(state) {
      return state.cartList.reduce((total, item) => total + item.count, 0);
    }
  },
  actions: {
    // 添加商品到购物车
    addToCart(goods: Goods, count: number = 1) {
      const userStore = useUserStore();
      if (!userStore.token) {
        alert('请先登录');
        return;
      }
      // 判断商品是否已在购物车中
      const existingItem = this.cartList.find(item => item.goods.id === goods.id);
      if (existingItem) {
        existingItem.count += count;
      } else {
        this.cartList.push({ goods, count });
      }
    },
    // 从购物车删除商品
    removeFromCart(goodsId: number) {
      this.cartList = this.cartList.filter(item => item.goods.id !== goodsId);
    },
    // 修改购物车商品数量
    updateCartCount(goodsId: number, count: number) {
      const item = this.cartList.find(item => item.goods.id === goodsId);
      if (item) {
        item.count = count;
      }
    },
    // 清空购物车
    clearCart() {
      this.cartList = [];
    }
  }
});

4.2 实战组件开发(3个核心组件)

4.2.1 登录组件(Login.vue)

<template>
  <div class="login-container">
    <h2>用户登录</h2>
    <div class="form-item">
      <label>账号:</label>
      <input v-model="account" type="text" placeholder="请输入账号" />
    </div>
    <div class="form-item">
      <label>密码:</label>
      <input v-model="password" type="password" placeholder="请输入密码" />
    </div>
    <button @click="handleLogin" :disabled="loading">
      {{ loading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useUserStore } from '@/store/userStore';
import { useRouter } from 'vue-router'; // 路由跳转(需配置路由)

const userStore = useUserStore();
const router = useRouter();
const account = ref('');
const password = ref('');
const loading = ref(false);

const handleLogin = async () => {
  if (!account.value || !password.value) {
    alert('请输入账号和密码');
    return;
  }
  loading.value = true;
  try {
    await userStore.login(account.value, password.value);
    alert('登录成功');
    router.push('/home'); // 登录成功跳转首页
  } catch (err) {
    alert('登录失败,请重试');
  } finally {
    loading.value = false;
  }
};
</script>

<style scoped>
.login-container {
  width: 300px;
  margin: 100px auto;
  text-align: center;
}
.form-item {
  margin: 15px 0;
  text-align: left;
}
input {
  width: 100%;
  padding: 8px;
  margin-top: 5px;
}
button {
  width: 100%;
  padding: 10px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

4.2.2 首页组件(Home.vue)

<template>
  <div class="home-container">
    <header class="home-header">
      <h1>Pinia电商实战</h1>
      <div class="user-info">
        <img v-if="userInfo.avatar" :src="userInfo.avatar" alt="用户头像" class="avatar" />
        <span v-if="userInfo.name">欢迎您,{{ userInfo.name }}</span>
        <button @click="handleLogout" v-if="token">退出登录</button>
        <button @click="toLogin" v-else>去登录</button>
        <div class="cart-icon" @click="toCart">
          购物车({{ cartTotalCount }})
        </div>
      </header>

      <section class="goods-list">
        <h2>商品列表</h2>
        <div class="goods-item" v-for="goods in goodsList" :key="goods.id">
          <img :src="goods.img" alt="商品图片" class="goods-img" />
          <div class="goods-info">
            <h3>{{ goods.name }}</h3>
            <p class="price">¥{{ goods.price }}</p>
            <p class="stock">库存:{{ goods.stock }}</p>
            <button @click="addToCart(goods)">加入购物车</button>
          </div>
        </div>
      </section>
    </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { useUserStore } from '@/store/userStore';
import { useGoodsStore } from '@/store/goodsStore';
import { useCartStore } from '@/store/cartStore';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';

// 实例化Store
const userStore = useUserStore();
const goodsStore = useGoodsStore();
const cartStore = useCartStore();
const router = useRouter();

// 响应式解构状态(避免失去响应性)
const { token, userInfo } = storeToRefs(userStore);
const { goodsList } = storeToRefs(goodsStore);
const { cartTotalCount, addToCart } = cartStore;

// 组件挂载时,获取商品列表
onMounted(() => {
  goodsStore.fetchGoodsList();
});

// 退出登录
const handleLogout = () => {
  userStore.logout();
  alert('退出成功');
  router.push('/login');
};

// 跳转登录页
const toLogin = () => {
  router.push('/login');
};

// 跳转购物车页
const toCart = () => {
  if (!token.value) {
    alert('请先登录');
    router.push('/login');
    return;
  }
  router.push('/cart');
};
</script>

<style scoped>
.home-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 20px;
  border-bottom: 1px solid #eee;
}
.avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  margin-right: 10px;
}
.user-info {
  display: flex;
  align-items: center;
}
.user-info button {
  margin-left: 20px;
  padding: 5px 10px;
  cursor: pointer;
}
.cart-icon {
  margin-left: 20px;
  cursor: pointer;
  font-weight: bold;
}
.goods-list {
  padding: 20px;
}
.goods-item {
  display: flex;
  margin: 20px 0;
  border-bottom: 1px solid #eee;
  padding-bottom: 20px;
}
.goods-img {
  width: 100px;
  height: 100px;
  margin-right: 20px;
}
.goods-info {
  flex: 1;
}
.price {
  color: #ff4400;
  font-size: 18px;
  font-weight: bold;
}
.stock {
  color: #666;
  margin: 10px 0;
}
.goods-info button {
  padding: 8px 15px;
  background: #ff4400;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

4.2.3 购物车组件(Cart.vue)

<template>
  <div class="cart-container">
    <h2>我的购物车</h2>
    <div class="cart-empty" v-if="cartList.length === 0">
      购物车为空,快去添加商品吧!
    </div>
    <div class="cart-list" v-else>
      <div class="cart-item" v-for="item in cartList" :key="item.goods.id">
        <img :src="item.goods.img" alt="商品图片" class="cart-img" />
        <div class="cart-info">
          <h3>{{ item.goods.name }}</h3>
          <p class="price">¥{{ item.goods.price }}</p>
          <div class="count-control">
            <button @click="updateCount(item.goods.id, item.count - 1)" :disabled="item.count <= 1">-</button>
            <span>{{ item.count }}</span>
            <button @click="updateCount(item.goods.id, item.count + 1)" :disabled="item.count >= item.goods.stock">+</button>
          </div>
        </div>
        <button class="delete-btn" @click="removeFromCart(item.goods.id)">删除</button>
      </div>
      <div class="cart-footer">
        <button class="clear-btn" @click="clearCart">清空购物车</button>
        <div class="total-info">
          合计:<span class="total-price">¥{{ cartTotalPrice.toFixed(2) }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useCartStore } from '@/store/cartStore';
import { useUserStore } from '@/store/userStore';
import { storeToRefs } from 'pinia';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';

const cartStore = useCartStore();
const userStore = useUserStore();
const router = useRouter();

// 响应式解构
const { cartList, cartTotalPrice } = storeToRefs(cartStore);
const { token } = storeToRefs(userStore);
const { updateCartCount, removeFromCart, clearCart } = cartStore;

// 组件挂载时,判断是否登录
onMounted(() => {
  if (!token.value) {
    alert('请先登录');
    router.push('/login');
  }
});

// 修改商品数量
const updateCount = (goodsId: number, count: number) => {
  updateCartCount(goodsId, count);
};
</script>

<style scoped>
.cart-container {
  padding: 20px;
}
.cart-empty {
  text-align: center;
  padding: 50px;
  color: #666;
  font-size: 18px;
}
.cart-item {
  display: flex;
  align-items: center;
  margin: 20px 0;
  border-bottom: 1px solid #eee;
  padding-bottom: 20px;
}
.cart-img {
  width: 80px;
  height: 80px;
  margin-right: 20px;
}
.cart-info {
  flex: 1;
}
.price {
  color: #ff4400;
  font-weight: bold;
  margin: 10px 0;
}
.count-control {
  display: flex;
  align-items: center;
}
.count-control button {
  width: 30px;
  height: 30px;
  border: 1px solid #eee;
  background: #fff;
  cursor: pointer;
}
.count-control button:disabled {
  background: #eee;
  cursor: not-allowed;
}
.count-control span {
  width: 60px;
  text-align: center;
}
.delete-btn {
  padding: 8px 15px;
  background: #ff0000;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.cart-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 30px;
}
.clear-btn {
  padding: 8px 15px;
  background: #666;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.total-info {
  font-size: 18px;
  font-weight: bold;
}
.total-price {
  color: #ff4400;
  margin-left: 10px;
}
</style>

4.3 路由配置(router/index.ts)

配置路由,实现组件跳转,需先安装vue-router:npm install vue-router@4 -S,然后配置路由:

// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Login from '@/views/Login.vue';
import Home from '@/views/Home.vue';
import Cart from '@/views/Cart.vue';

const routes: RouteRecordRaw[] = [
  { path: '/', redirect: '/home' },
  { path: '/login', component: Login },
  { path: '/home', component: Home },
  { path: '/cart', component: Cart }
];

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

export default router;

4.4 入口文件配置(main.ts)

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import router from './router' // 引入路由

const app = createApp(App)
app.use(createPinia()) // 挂载Pinia
app.use(router) // 挂载路由
app.mount('#app')

4.5 实战效果说明

  1. 未登录状态下,点击「加入购物车」「购物车图标」,会提示登录并跳转登录页;

  2. 登录成功后,跳转首页,显示用户信息,可查看商品列表、添加商品到购物车;

  3. 购物车页面可修改商品数量、删除商品、清空购物车,实时显示合计价格和商品总数;

  4. 退出登录后,清空用户状态和token,购物车状态保留(可结合持久化插件优化,见下文)。

五、Pinia进阶技巧(实战必备)

5.1 数据持久化(避免页面刷新丢失)

Pinia默认不持久化数据,页面刷新后state会重置,可使用pinia-plugin-persistedstate插件实现本地存储(localStorage/sessionStorage)。

// 安装插件
npm install pinia-plugin-persistedstate -S
// main.ts 配置插件
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 引入插件
import router from './router'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 挂载插件

const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

在Store中配置持久化(以购物车Store为例):

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({ /* ... */ }),
  getters: { /* ... */ },
  actions: { /* ... */ },
  // 配置持久化
  persist: {
    key: 'cartStore', // 存储的key(localStorage中的key)
    storage: localStorage, // 存储方式(localStorage/sessionStorage)
    paths: ['cartList'] // 需要持久化的state字段(默认全部持久化)
  }
});

5.2 调试工具使用(Vue DevTools)

Pinia支持Vue DevTools,可实时查看Store状态、跟踪actions执行,便于调试:

  • Vue3中,安装Vue DevTools扩展,打开开发者工具,切换到「Pinia」面板,即可查看所有Store的state、getters;
  • 支持跟踪actions执行记录,可查看每次actions调用的参数和状态变化;
  • Vue3中暂不支持time-travel功能(时间回溯),Vue2中支持(需配合Vuex接口)。

5.3 模块热更新(HMR)

Pinia支持模块热更新,修改Store代码后,无需重新加载页面,即可生效,且会保留现有状态,提升开发效率,无需额外配置,Vue3项目默认支持。

六、常见问题与解决方案(实战避坑)

  • 问题1:组件中解构state后,修改值不生效? 解决方案:使用storeToRefs解构,修改时需加.value(如current.value++),直接解构会丢失响应性。
  • 问题2:actions中使用this指向异常? 解决方案:actions中的方法不能用箭头函数,需用普通函数,否则this无法指向Store实例。
  • 问题3:页面刷新后,Pinia状态丢失? 解决方案:使用pinia-plugin-persistedstate插件,配置持久化存储。
  • 问题4:多个Store之间无法通信? 解决方案:在需要通信的Store中,引入目标Store实例,即可访问其state和actions。
  • 问题5:TS类型推断失败,提示“this类型为any”? 解决方案:getters中使用this时,需指定返回值类型;actions中修改state时,确保state字段类型与赋值类型一致。
  • 问题6:Vue2中使用Pinia报错? 解决方案:Vue2中需额外引入PiniaVuePlugin,注册方式参考上传文档中的Vue2配置。

七、总结

Pinia是Vue3官方推荐的状态管理工具,相比Vuex,它更简洁、轻量、易上手,原生支持TS,完美适配组合式API,是Vue3项目的首选状态管理方案。

本文从基础认知、环境搭建、核心用法,到完整实战案例,覆盖了Pinia开发的全流程,重点讲解了Vue3+TS下的实战技巧,新手可按步骤搭建环境、编写代码,快速上手;老手可通过实战案例查漏补缺,优化项目中的状态管理逻辑。

核心要点:Store是Pinia的核心,每个Store包含state、getters、actions;修改state推荐使用$patch和actions;解构state需用storeToRefs保持响应性;结合插件可实现数据持久化,提升用户体验。

❌