Web3——Svelte × Reown AppKit 接入文档(升级版)

290 阅读6分钟

目标:用 Reown AppKit(Web3Modal 正统升级)在 Svelte/SvelteKit 中实现“开箱即用”的钱包选择器 / 账户面板 / 切链按钮,并结合 wagmi + viem 完成读写合约、签名、事件监听等链交互。
文档按模块拆分,复制即用;所有关键代码都给到可运行实现 + 中文注释


0. 项目准备

0.1 获取 Project ID

AppKit 需要一个项目 ID 才能调用官方聚合与二维码弹窗。

  • 进入 Reown AppKit 官方控制台(Dashboard),创建项目并复制 Project ID

    常见入口:dashboard.reown.com(或搜索 “Reown AppKit Dashboard”)。

  • 放入前端环境变量(见 1.2)。

0.2 环境要求

  • Node 18+(建议 20+)
  • SvelteKit v2+ / v5(本文以 SvelteKit 为例)
  • 基础 TS / EVM 知识(链 ID、签名、EIP-1559)

1. 初始化项目与安装依赖

# 新建 SvelteKit 项目(已有项目可跳过)
npm create svelte@latest svelte-appkit-demo
cd svelte-appkit-demo
npm i

# 安装 AppKit 与适配器 + wagmi/viem(核心链交互)
npm i @reown/appkit @reown/appkit-adapter-wagmi wagmi viem

1.2 环境变量

在项目根目录新增 .env / .env.production

# 建议使用 SvelteKit 公共前缀(可在浏览器端读取)
PUBLIC_APPKIT_PROJECT_ID=你的_Reown_AppKit_Project_ID

2. 目录结构建议

src/
 └─ lib/
     ├─ appkit.ts                # AppKit + WagmiAdapter 初始化(只在浏览器执行)
     ├─ web3/
     │   ├─ stores.ts           # watchAccount / watchChainId → Svelte stores
     │   └─ actions.ts          # 读写合约 / 签名 / 切链 等无框架 API
     └─ components/
         ├─ ConnectDock.svelte  # 顶部工具条(AppKit 按钮 + 自定义状态)
         └─ Examples.svelte     # 余额读取 / 签名 / 写交易 示例
 routes/
 ├─ +layout.svelte
 └─ +page.svelte

3. AppKit 初始化(核心)

@reown/appkit-adapter-wagmi 生成 wagmiConfig,并交给 AppKit。随后读写合约依旧用 @wagmi/core,只是把 wagmiConfig 换成这份。

src/lib/appkit.ts

// 只在浏览器端初始化(SSR 保护)
import { browser } from '$app/environment'

// AppKit & 适配器
import { createAppKit } from '@reown/appkit'
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'

// 内置多链预设(可按需扩充)
import { arbitrum, mainnet, base, optimism, polygon, sepolia } from '@reown/appkit/networks'

// 从环境变量读取 Project ID(SvelteKit 公共变量)
import { PUBLIC_APPKIT_PROJECT_ID } from '$env/static/public'

// 导出:给其他模块使用
export let appKit: ReturnType<typeof createAppKit> | undefined
export let wagmiConfig: any // 实际是 wagmi 的 Config 类型

if (browser) {
  if (!PUBLIC_APPKIT_PROJECT_ID) {
    console.warn('PUBLIC_APPKIT_PROJECT_ID 未设置,AppKit 将无法弹出钱包选择器')
  }

  // 选择支持的链(顺序会影响默认链)
  const networks = [base, arbitrum, mainnet, optimism, polygon, sepolia]

  // 用 AppKit 的 WagmiAdapter 生成 wagmiConfig(后续 read/write 都靠它)
  const wagmiAdapter = new WagmiAdapter({
    projectId: PUBLIC_APPKIT_PROJECT_ID,
    networks,
    ssr: true,           // 为 SvelteKit 友好
    // 可选:自定义 transports(RPC),放专用服务商以提升质量
    // transports: { [base.id]: http('https://base-mainnet.g.alchemy.com/v2/xxx') }
  })

  wagmiConfig = wagmiAdapter.wagmiConfig

  // 创建 AppKit(注册 Web Components + 默认模态)
  appKit = createAppKit({
    adapters: [wagmiAdapter],
    projectId: PUBLIC_APPKIT_PROJECT_ID,
    networks,
    defaultNetwork: base,     // 默认链(按钮 & Modal 初始展示)
    features: {
      email: false,           // 如需启用邮箱/社交登录,置为 true(需在控制台开通)
      onramp: false,          // 法币买币(需要控制台配置)
      swap: false             // 代币兑换(需要控制台配置)
    },
    themeMode: 'dark',        // 'light' | 'dark'
    themeVariables: {         // 自定义主题变量(与 Web Components 样式变量一致)
      '--w3m-accent': '#00d0b6',
      '--w3m-color-mix': '#14161a'
    },
    metadata: {               // 钱包会展示的 dapp 信息
      name: 'Svelte × Reown AppKit',
      description: 'SvelteKit + AppKit + Wagmi/Viem 最小可用集成',
      url: 'https://example.com',
      icons: ['https://avatars.githubusercontent.com/u/179229932?s=200&v=4']
    }
  })
}

说明

  • AppKit 会注册一组 Web Components(如 <appkit-button/> 等),你在页面放上即可。
  • wagmiConfigWagmiAdapter 提供;后续所有 @wagmi/core 的 actions 都使用这份 wagmiConfig

4. 全局挂载(一次性引入 AppKit)

src/routes/+layout.svelte

<script lang="ts">
  // 仅导入即可完成 AppKit 初始化与 Web Components 注册
  import '$lib/appkit'
</script>

<slot />

5. 顶部工具条(AppKit 组件 + 自定义状态)

src/lib/components/ConnectDock.svelte

<script lang="ts">
  import { appKit } from '$lib/appkit'
  import { address, shortAddr, chainId, status } from '$lib/web3/stores'

  function openConnect() { appKit?.open({ view: 'Connect' }) } // 编程式打开
  function openNetworks() { appKit?.open({ view: 'Networks' }) }
  function openAccount() { appKit?.open({ view: 'Account' }) }
</script>

<nav class="dock">
  <!-- 方式 A:使用 AppKit 自带 Web Components(开箱即用) -->
  <appkit-connect-button />
  <appkit-network-button />
  <appkit-account-button />

  <!-- 方式 B:自定义按钮(用编程式 API 打开) -->
  <button on:click={openConnect}>连接钱包</button>
  <button on:click={openNetworks}>切换网络</button>
  <button on:click={openAccount}>账户</button>

  <!-- 自定义状态展示(Svelte Store 来自 watchAccount/watchChainId) -->
  <div class="state">
    <span>状态:{$status}</span>
    <span>链:{$chainId ?? '-'}</span>
    <span>地址:{$address ? $shortAddr : '-'}</span>
  </div>
</nav>

<style>
  .dock{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:10px;border-bottom:1px solid #2a2a2a}
  .state{display:flex;gap:10px;color:#999}
  button{padding:6px 10px}
</style>

常用 Web Components

  • <appkit-connect-button/>:连接/断开入口
  • <appkit-network-button/>:切换网络
  • <appkit-account-button/>:账户信息/地址/复制/断开
  • 你也可以完全不用这些按钮,改用 appKit.open({view:'...'})编程式控制 Modal。

6. Svelte Stores(账户/链状态订阅)

src/lib/web3/stores.ts

import { writable, derived } from 'svelte/store'
import { getAccount, watchAccount, getChainId, watchChainId, reconnect } from '@wagmi/core'
import type { Address } from 'viem'
import { browser } from '$app/environment'
import { wagmiConfig } from '$lib/appkit'

export const address = writable<Address | undefined>()
export const isConnected = writable(false)
export const chainId = writable<number | undefined>()
export const status = writable<'disconnected' | 'connecting' | 'connected'>('disconnected')
export const shortAddr = derived(address, ($a) => $a ? `${$a.slice(0,6)}...${$a.slice(-4)}` : '')

if (browser) {
  reconnect(wagmiConfig).catch(() => {})

  const unwatchAccount = watchAccount(wagmiConfig, {
    onChange(a){
      address.set(a.address as Address | undefined)
      isConnected.set(!!a.isConnected)
      status.set(a.status ?? 'disconnected')
    }
  })
  const unwatchChain = watchChainId(wagmiConfig, { onChange: id => chainId.set(id) })

  const a = getAccount(wagmiConfig)
  address.set(a.address as Address | undefined)
  isConnected.set(!!a.isConnected)
  status.set(a.status ?? 'disconnected')
  chainId.set(getChainId(wagmiConfig))

  // 可选:页面卸载时清理
  // addEventListener('beforeunload', () => { unwatchAccount(); unwatchChain() })
}

7. 统一动作 API(读写/签名/切链)

src/lib/web3/actions.ts

// 无框架 API:你可以在任意组件中直接调用
import {
  readContract, writeContract, waitForTransactionReceipt,
  signMessage, signTypedData, switchChain, simulateContract,
  getAccount, getPublicClient
} from '@wagmi/core'
import type { Abi, Address } from 'viem'
import { wagmiConfig } from '$lib/appkit'

// 读取合约
export async function contractRead<T = unknown>(
  p: { address: Address; abi: Abi; functionName: string; args?: any[] }
){
  return readContract(wagmiConfig, p) as Promise<T>
}

// 写入合约(推荐先 simulate)
export async function contractWrite(
  p: { address: Address; abi: Abi; functionName: string; args?: any[] }
){
  const sim = await simulateContract(wagmiConfig, p as any)
  const hash = await writeContract(wagmiConfig, { ...p, ...sim.request })
  return waitForTransactionReceipt(wagmiConfig, { hash })
}

// 基础签名(EIP-191)
export const personalSign = (message: string) =>
  signMessage(wagmiConfig, { message })

// 结构化签名(EIP-712)
export async function signTyped(domain: any, types: any, value: Record<string, any>){
  const acct = getAccount(wagmiConfig)
  if (!acct.address) throw new Error('Not connected')
  const primaryType = Object.keys(types)[0]
  return signTypedData(wagmiConfig, { account: acct.address, domain, types, primaryType, message: value })
}

// 切链
export const switchTo = (id: number) => switchChain(wagmiConfig, { chainId: id })

// 事件监听(例:监听最新区块)
export function watchNewBlocks(cb:(n:number)=>void){
  const pc = getPublicClient(wagmiConfig)
  return pc.watchBlocks({ onBlock: (b)=> cb(Number(b.number)) })
}

8. 示例页面:余额读取 / 签名 / 写交易

src/lib/components/Examples.svelte

<script lang="ts">
  import { chainId, address } from '$lib/web3/stores'
  import { contractRead, contractWrite, personalSign } from '$lib/web3/actions'
  import type { Address } from 'viem'

  // 示例:Base 链 USDC(只为演示;请替换为你的业务合约)
  const USDC: Address = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
  const ERC20 = [
    {"inputs":[{"name":"owner","type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},
    {"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"name":"approve","outputs":[{"type":"bool"}],"stateMutability":"nonpayable","type":"function"}
  ] as const

  let balance = '-'
  let signed = ''
  let pending = false

  async function loadBalance(){
    if(!$address) return
    const raw = await contractRead<bigint>({ address: USDC, abi: ERC20 as any, functionName:'balanceOf', args: [$address] })
    balance = (Number(raw)/1e6).toLocaleString() + ' USDC'
  }

  async function doSign(){
    signed = await personalSign('Hello from AppKit + Svelte!')
  }

  async function doApprove(){
    if(!$address) return
    pending = true
    try {
      const r = await contractWrite({
        address: USDC, abi: ERC20 as any, functionName:'approve',
        args: ['0x0000000000000000000000000000000000000001', 0n] // 示例:approve 0
      })
      alert(`Tx confirmed at block ${r.blockNumber}`)
    } catch(e) {
      alert((e as Error).message)
    } finally {
      pending = false
    }
  }
</script>

<div class="card">
  <div>链:{$chainId ?? '-'}</div>
  <div>地址:{$address ?? '-'}</div>

  <button on:click={loadBalance} disabled={!$address || pending}>读 USDC 余额</button>
  <div>余额:{balance}</div>

  <button on:click={doSign} disabled={!$address || pending}>签名一段消息</button>
  <div style="word-break:break-all">{signed}</div>

  <button on:click={doApprove} disabled={!$address || pending}>
    {pending ? '发送中…' : '发送一笔写交易(示例)'}
  </button>
</div>

<style>
  .card{padding:12px;border:1px dashed #666;border-radius:8px;display:grid;gap:8px}
</style>

9. 页面组装

src/routes/+page.svelte

<script>
  import ConnectDock from '$lib/components/ConnectDock.svelte'
  import Examples from '$lib/components/Examples.svelte'
</script>

<h1>Svelte × Reown AppKit Playground</h1>
<ConnectDock />
<Examples />

10. 主题 / 多语言 / 编程式控制

10.1 主题

createAppKit 里设置:

themeMode: 'dark',                // 或 'light'
themeVariables: {
  '--w3m-accent': '#00d0b6',      // 主题主色
  '--w3m-border-radius': '10px'   // 圆角等
}

10.2 编程式控制视图

// 打开指定视图:Connect / Account / Networks / AllWallets ...
appKit?.open({ view: 'Connect' })
appKit?.close()

注:可结合你自己的按钮/路由控制弹窗逻辑。


11. 可选功能(按需开启)

下述功能常在 Reown 控制台 配置后方可使用,且可能需要额外 SDK/服务端配合。根据项目需求逐步打开,避免一次性拉太多依赖。

  • Email / 社交登录(Embedded / MPC 钱包):在 features.email=true 并完成控制台配置后启用。
  • 法币买币 On-Rampfeatures.onramp=true,需在控制台绑定商户或支付渠道。
  • Swap 兑换features.swap=true,适用于快速换币入口。
  • 自定义钱包白名单 / 推荐位:控制台可配置优先展示的钱包、默认链等。

12. 事件监听 & L2 实战建议

  • 链事件:用 getPublicClient().watchContractEvent / watchBlocks 订阅;或在写交易后 waitForTransactionReceipt 再刷新。
  • 多链映射:把合约地址做成 Record<number, Address>,按 $chainId 动态选择。
  • Gas/费用:默认由 RPC 估算;若 L2(如 Base/OP/Arb)拥堵,可给 maxFeePerGasmaxPriorityFeePerGas 合理上限。
  • 错误文案:识别常见错误(User rejected / execution reverted / nonce too low / replacement underpriced),翻译成人类可读提示。

13. 常见问题(FAQ)

  • 必须用 Project ID 吗?
    用 AppKit 的聚合弹窗就需要;否则建议改走“裸 wagmi + viem”并自己集成 WalletConnect。
  • SSR 报 window 未定义
    将 AppKit 初始化放在 browser 分支(本文已处理),或在组件的 onMount 中执行。
  • Injected 钱包不出现/冲突
    桌面浏览器装多个扩展会覆盖 provider,先禁用其他扩展测试;移动端请在钱包自带 DApp 浏览器中打开。
  • 切链失败
    个别钱包不支持自动 wallet_addEthereumChain,需要用户在钱包内手动切换。
  • AppKit 事件回调想打点?
    建议复用 wagmi 的 watchAccount/watchChainId 与交易回执事件,在业务侧统一埋点。

14. 安全与 UX 清单

  • 优先 EIP-712 结构化签名;签名前给出可读摘要(金额/地址/截止时间)。
  • 所有 pending/confirmed/failed 状态明确可见;提供复制 Tx Hash。
  • 断网/超时/被替换交易(speed up/cancel)要有兜底提示。
  • RPC 降级与限速重试、日志脱敏与隐私合规。

15. License

本文与示例代码 MIT License,可自由复制与二次开发。


Reown AppKit 的连接弹框本质是一个“多钱包聚合器” ,内置了 WalletConnect v2 能力,既支持扫码登录,也支持唤起(deep-link/通用链接)登录,还覆盖桌面扩展钱包与内置 DApp 浏览器注入的 provider。简明对照给你👇

支持的典型登录路径

  • 桌面扩展钱包(Injected)
    已安装(MetaMask、Rabby、OKX 等)→ 直接连接;未安装→ 给出安装或改用扫码。
  • 移动钱包扫码(WalletConnect v2)
    弹出官方 QR 模态框;用 MetaMask/Trust/OKX/TokenPocket/Bitget 等手机钱包 扫描二维码 即可建立会话。
  • 移动端唤起(Deep Link / Universal Link)
    在手机浏览器里点弹框里的“MetaMask/OKX/Trust…”→ 直接拉起已安装的钱包 App;没装则跳商店下载。
  • DApp 浏览器内置注入
    若用户在钱包自带 DApp 浏览器打开页面,AppKit 会识别 injected provider 并直接连接。
  • 邮箱/社交登录、嵌入式/MPC 钱包(可选模块)
    在 Dashboard 开启相应功能后,弹框可展示 Email / 社交登录 按钮,走托管或嵌入式钱包通道(是否显示取决于你在 AppKit 配置里启用与否)。)