六维教程

Vue3 项目实战

系列到此,你已经把 Vue3 的组合式 API 从数据、模板、组件、路由、状态管理全学了一遍。

这一篇把它们串起来,从空白脚手架构建一个「工具箱」静态工具站,字符统计、JSON 格式化、Base64 编解码这些常备工具一个页面全搞定,带完整头部、卡片式首页和全局轻提示,做完它,你就有底气独立开工了。

规划

页面/组件 用到的主要技术
全局头部 HeaderBar 路由导航、收藏数徽标(Pinia store)
首页 / 列表渲染、动态样式(卡片 hover)、收藏区(storeToRefs)
工具页 /tools/:name 动态路由参数、动态组件、watch 参数变化、懒加载
字符统计 v-model、computed 实时统计
JSON 格式化 computed 派生、错误捕获、复制到剪贴板
Base64 编解码 模式切换、computed 转换
全局轻提示 Toast Teleport + Transition(复习 Vue3 Teleport
收藏功能 Pinia + watch 持久化到 localStorage

目录结构沿用 create-vue 的规范(不知道就回到起步篇再搭一遍):

src/
├── main.js
├── App.vue                      # HeaderBar + router-view + Footer + Toast
├── router/index.js              # 路由(动态路由、懒加载)
├── stores/useFavoritesStore.js  # 收藏,localStorage 持久化
├── data/tools.js                # 工具清单(名称、配色、简介)
├── utils/toast.js               # 全局轻提示状态
├── utils/copy.js                # 复制到剪贴板 + 提示
├── views/HomeView.vue / ToolView.vue / NotFound.vue
├── components/HeaderBar.vue / AppFooter.vue / Toast.vue
└── components/tools/CharCounter.vue / JsonFormatter.vue / Base64Tool.vue

假设你已装好 vue-router 与 pinia(装法见 Vue3 路由基础Vue3 状态管理入门 两篇),我们开干。

工具清单

工具站的核心是”数据驱动”,工具长什么样,由一份清单说了算,首页卡片、工具外壳都从这份数据取,以后加新工具,往数组里加一项就行:

// src/data/tools.js
export const tools = [
  { id: 'charcount', name: '字符统计', desc: '实时统计字符、字数、行数、字节数' },
  { id: 'json', name: 'JSON 格式化', desc: '校验并美化 JSON 文本' },
  { id: 'base64', name: 'Base64 编解码', desc: '文本与 Base64 互相转换' }
]

id 会拼进 URL,name 是显示名,desc 是简介。

路由配置

三个路由:首页、动态的工具页、兜底 404。

工具页用 :name 占位,一条路由匹配所有工具(Vue3 路由进阶 的动态路由):

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
  { path: '/tools/:name', name: 'tool', component: () => import('../views/ToolView.vue') },
  { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('../views/NotFound.vue') }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

页面都写成 () => import(...) 懒加载,打包时按路由拆文件,首屏只下载首页(路由进阶篇讲过)。

布局外壳

App.vue

整站骨架:Header 在上、内容区居中、Footer 垫底:

<!-- App.vue -->
<script setup>
import HeaderBar from './components/HeaderBar.vue'
import AppFooter from './components/AppFooter.vue'
</script>

<template>
  <HeaderBar />
  <main class="page">
    <router-view />
  </main>
  <AppFooter />
</template>

<style>
/* 全局基础样式:现代网站的骨架 */
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  color: #1f2328;
  background: #f7f7fb;
}
.page { max-width: 960px; margin: 0 auto; padding: 32px 20px 48px; }
</style>

HeaderBar.vue

吸顶头部,白底加毛玻璃,左侧 Logo、右侧导航:

<!-- components/HeaderBar.vue -->
<template>
  <header class="header">
    <div class="wrap">
      <router-link to="/" class="logo">工具箱</router-link>
      <nav>
        <router-link to="/">首页</router-link>
      </nav>
    </div>
  </header>
</template>

<style scoped>
.header {
  position: sticky; top: 0; z-index: 10;
  background: rgba(255, 255, 255, .85);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid #eee;
}
.wrap {
  max-width: 960px; margin: 0 auto; padding: 0 20px; height: 60px;
  display: flex; align-items: center; justify-content: space-between;
}
.logo { font-size: 20px; font-weight: 700; color: #4f46e5; text-decoration: none; }
nav { display: flex; align-items: center; gap: 18px; }
nav a { color: #333; text-decoration: none; }
nav a.router-link-active { color: #4f46e5; font-weight: 600; }
</style>

AppFooter.vue

<!-- components/AppFooter.vue -->
<template>
  <footer class="footer">
    <p>工具箱 · 纯前端实现,数据不出浏览器</p>
  </footer>
</template>

<style scoped>
.footer {
  border-top: 1px solid #eee;
  padding: 24px 20px;
  text-align: center;
  color: #999;
  font-size: 13px;
}
</style>

页面

首页

Hero 区 + 全部工具卡片网格。卡片用数据驱动渲染,hover 抬升是动态样式(Vue3 动态样式 的复习),色块文字取名字第一个字、颜色按顺序轮换,都在模板里现算,数据保持干净:

<!-- views/HomeView.vue -->
<script setup>
import { tools } from '../data/tools'

// 色块颜色按顺序轮换用
const colors = ['#6366f1', '#0ea5e9', '#10b981']
const tileColor = (tool) => colors[tools.indexOf(tool) % colors.length]
</script>

<template>
  <section class="hero">
    <h1>常用小工具,一个页面全搞定</h1>
    <p>字符统计、JSON 格式化、Base64 编解码,全部在浏览器本地完成,数据不会上传。</p>
  </section>

  <section>
    <h2>全部工具</h2>
    <div class="grid">
      <article v-for="t in tools" :key="t.id" class="card">
        <router-link :to="`/tools/${t.id}`">
          <span class="tile" :style="{ background: tileColor(t) }">{{ t.name[0] }}</span>
          <h3>{{ t.name }}</h3>
          <p>{{ t.desc }}</p>
        </router-link>
      </article>
    </div>
  </section>
</template>

<style scoped>
.hero { text-align: center; padding: 48px 0 40px; }
.hero h1 { margin: 0 0 12px; font-size: 30px; }
.hero p { margin: 0; color: #666; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.card {
  position: relative; background: #fff; border-radius: 12px; padding: 20px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .06);
  transition: transform .15s ease, box-shadow .15s ease;
}
.card:hover { transform: translateY(-3px); box-shadow: 0 8px 20px rgba(0, 0, 0, .1); }
.card a { text-decoration: none; color: inherit; }
.tile {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; border-radius: 10px;
  color: #fff; font-size: 20px; font-weight: 700;
}
.card h3 { margin: 12px 0 6px; }
.card p { margin: 0; color: #888; font-size: 13px; line-height: 1.6; }
</style>

三个工具

三个工具互不依赖,各自一个组件,下面逐个实现:

字符统计

computed 实时算,输入一边打字一边出结果,正是 Vue3 计算与侦听 的用途,注意字符数按 Unicode 码点数,[...t].length 能数对表情这类字符,new Blob([t]).size 得到 UTF-8 字节数:

<!-- components/tools/CharCounter.vue -->
<script setup>
import { ref, computed } from 'vue'

const text = ref('')

const stats = computed(() => {
  const t = text.value
  return {
    chars: [...t].length,                                  // 字符数
    words: t.trim() ? t.trim().split(/\s+/).length : 0,    // 字数
    lines: t ? t.split('\n').length : 0,                   // 行数
    bytes: new Blob([t]).size                              // UTF-8 字节数
  }
})
</script>

<template>
  <div class="tool">
    <textarea v-model="text" rows="8" placeholder="在这里粘贴或输入文字……" />
    <div class="stats">
      <div class="stat"><b>{{ stats.chars }}</b><span>字符</span></div>
      <div class="stat"><b>{{ stats.words }}</b><span>字数</span></div>
      <div class="stat"><b>{{ stats.lines }}</b><span>行数</span></div>
      <div class="stat"><b>{{ stats.bytes }}</b><span>字节</span></div>
    </div>
  </div>
</template>

<style scoped>
textarea {
  width: 100%; padding: 12px;
  border: 1px solid #e5e7eb; border-radius: 10px;
  font-size: 14px; resize: vertical; outline: none;
}
textarea:focus { border-color: #6366f1; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 16px; }
.stat { background: #fff; border-radius: 10px; padding: 16px; text-align: center; }
.stat b { display: block; font-size: 24px; color: #4f46e5; }
.stat span { color: #888; font-size: 13px; }
</style>

JSON 格式化

解析失败要给出提示,用 computed 派生错误信息和格式化结果,”复制”走 copyText

<!-- components/tools/JsonFormatter.vue -->
<script setup>
import { ref, computed } from 'vue'
import { copyText } from '../../utils/copy'

const input = ref('')

const errorMsg = computed(() => {
  const t = input.value.trim()
  if (!t) return ''
  try { JSON.parse(t); return '' }
  catch (e) { return 'JSON 格式有误:' + e.message }
})

const output = computed(() => {
  if (!input.value.trim() || errorMsg.value) return ''
  return JSON.stringify(JSON.parse(input.value), null, 2)
})
</script>

<template>
  <div class="tool">
    <textarea v-model="input" rows="8" placeholder='{"name":"工具箱","tools":3}' />
    <p v-if="errorMsg" class="err">{{ errorMsg }}</p>
    <div class="row">
      <button :disabled="!output" @click="copyText(output)">复制结果</button>
    </div>
    <pre v-if="output">{{ output }}</pre>
  </div>
</template>

<style scoped>
textarea {
  width: 100%; padding: 12px;
  border: 1px solid #e5e7eb; border-radius: 10px;
  font-size: 13px; resize: vertical; outline: none;
}
.err { color: #ef4444; font-size: 13px; }
.row { margin: 12px 0; }
button {
  padding: 8px 18px; border: none; border-radius: 8px;
  background: #4f46e5; color: #fff; cursor: pointer;
}
button:disabled { background: #c7c9d9; cursor: not-allowed; }
pre {
  background: #0f172a; color: #e2e8f0;
  padding: 14px; border-radius: 10px;
  font-size: 13px; overflow: auto;
}
</style>

Base64 编解码

一个”编码/解码”切换 + 一个输入框,computed 根据模式算结果。用 TextEncoder 处理中文,比老的 escape/unescape 更正规:

<!-- components/tools/Base64Tool.vue -->
<script setup>
import { ref, computed } from 'vue'
import { copyText } from '../../utils/copy'

const mode = ref('encode')
const input = ref('')

const output = computed(() => {
  const t = input.value
  if (!t) return ''
  try {
    if (mode.value === 'encode') {
      const bytes = new TextEncoder().encode(t)
      let bin = ''
      bytes.forEach(b => { bin += String.fromCharCode(b) })
      return btoa(bin)
    }
    const bytes = Uint8Array.from(atob(t), c => c.charCodeAt(0))
    return new TextDecoder().decode(bytes)
  } catch {
    return '无法' + (mode.value === 'encode' ? '编码' : '解码')
  }
})
</script>

<template>
  <div class="tool">
    <div class="tabs">
      <button :class="{ on: mode === 'encode' }" @click="mode = 'encode'">编码</button>
      <button :class="{ on: mode === 'decode' }" @click="mode = 'decode'">解码</button>
    </div>
    <textarea
      v-model="input"
      rows="6"
      :placeholder="mode === 'encode' ? '要编码的文字……' : '要解码的 Base64……'"
    />
    <div class="row">
      <button :disabled="!output" @click="copyText(output)">复制结果</button>
    </div>
    <pre>{{ output }}</pre>
  </div>
</template>

<style scoped>
.tabs { display: flex; gap: 8px; margin-bottom: 12px; }
.tabs button {
  padding: 8px 16px; border: 1px solid #e5e7eb;
  background: #fff; border-radius: 8px; cursor: pointer;
}
.tabs button.on { background: #4f46e5; color: #fff; border-color: #4f46e5; }
textarea {
  width: 100%; padding: 12px;
  border: 1px solid #e5e7eb; border-radius: 10px;
  font-size: 13px; resize: vertical; outline: none;
}
.row { margin: 12px 0; }
button { padding: 8px 18px; border: none; border-radius: 8px; background: #4f46e5; color: #fff; cursor: pointer; }
button:disabled { background: #c7c9d9; cursor: not-allowed; }
pre {
  background: #0f172a; color: #e2e8f0;
  padding: 14px; border-radius: 10px; font-size: 13px; overflow: auto;
}
</style>

复制与轻提示

三个工具里有”复制结果”的需求,复制完要弹个提示。这两个小功能现在补上,都放 utils 目录。

先看轻提示的状态,模块级响应式,任何组件都能调 toast('已复制')

// src/utils/toast.js
import { ref } from 'vue'

const visible = ref(false)
const msg = ref('')
let timer = null

export function toast(text) {
  msg.value = text
  visible.value = true
  clearTimeout(timer)
  timer = setTimeout(() => { visible.value = false }, 1800)
}

export function useToast() {
  return { visible, msg }
}

复制功能调用它,成功失败都弹一句:

// src/utils/copy.js
import { toast } from './toast'

export async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text)
    toast('已复制')
  } catch {
    toast('复制失败')
  }
}

提示要显示出来,做一个 Toast 组件,Teleport 挂到 body,Transition 做淡入淡出(复习 Vue3 Teleport):

<!-- components/Toast.vue -->
<script setup>
import { useToast } from '../utils/toast'

const { visible, msg } = useToast()
</script>

<template>
  <Teleport to="body">
    <Transition name="toast">
      <div v-if="visible" class="toast">{{ msg }}</div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.toast {
  position: fixed; left: 50%; bottom: 40px; transform: translateX(-50%);
  padding: 10px 20px; border-radius: 8px;
  background: rgba(30, 30, 30, .9); color: #fff; font-size: 14px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
}
.toast-enter-active, .toast-leave-active { transition: all .2s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
</style>

最后在 App.vue 挂上它,import 加一行、模板在 Footer 后面加一行:

<script setup>
import HeaderBar from './components/HeaderBar.vue'
import AppFooter from './components/AppFooter.vue'
import Toast from './components/Toast.vue'   // 加这一行
</script>

<template>
  <HeaderBar />
  <main class="page">
    <router-view />
  </main>
  <AppFooter />
  <Toast />                                  // 加这一行
</template>

工具外壳

工具页共用一套外壳,顶部工具名,中间用动态组件渲染对应工具。一条路由 /tools/:name 通吃所有工具,参数变了组件复用,需要干活时用 watchVue3 路由进阶 的参数变化那节),这里拿它同步页面标题:

<!-- views/ToolView.vue -->
<script setup>
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { tools } from '../data/tools'
import CharCounter from '../components/tools/CharCounter.vue'
import JsonFormatter from '../components/tools/JsonFormatter.vue'
import Base64Tool from '../components/tools/Base64Tool.vue'

const route = useRoute()

// 色块颜色按顺序轮换用,和首页保持一致
const colors = ['#6366f1', '#0ea5e9', '#10b981']
const tileColor = (tool) => colors[tools.indexOf(tool) % colors.length]

// 工具名 → 组件的注册表,动态组件靠它决定渲染谁
const toolMap = {
  charcount: CharCounter,
  json: JsonFormatter,
  base64: Base64Tool
}

const tool = computed(() => tools.find(t => t.id === route.params.name))
const Comp = computed(() => toolMap[route.params.name])

watch(() => route.params.name, () => {
  document.title = `${tool.value?.name || '工具箱'} · 工具箱`
}, { immediate: true })
</script>

<template>
  <div v-if="tool">
    <div class="tool-head">
      <span class="tile" :style="{ background: tileColor(tool) }">{{ tool.name[0] }}</span>
      <div class="title">
        <h1>{{ tool.name }}</h1>
        <p>{{ tool.desc }}</p>
      </div>
    </div>
    <div class="tool-body">
      <component :is="Comp" />
    </div>
  </div>
  <div v-else class="missing">
    这个工具不存在,<router-link to="/">回首页</router-link>
  </div>
</template>

<style scoped>
.tool-head {
  display: flex; align-items: center; gap: 16px;
  background: #fff; border-radius: 12px; padding: 20px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .06);
}
.tile {
  display: inline-flex; align-items: center; justify-content: center;
  width: 48px; height: 48px; border-radius: 12px;
  color: #fff; font-size: 22px; font-weight: 700;
}
.title { flex: 1; }
.title h1 { margin: 0; font-size: 20px; }
.title p { margin: 4px 0 0; color: #888; font-size: 13px; }
.tool-body {
  background: #fff; border-radius: 12px; padding: 24px;
  margin-top: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, .06);
}
.missing { padding: 40px; text-align: center; color: #888; }
</style>
<!-- views/NotFound.vue -->
<template>
  <div class="nf">
    <h1>404</h1>
    <p>页面不存在</p>
    <router-link to="/">回首页</router-link>
  </div>
</template>

<style scoped>
.nf { text-align: center; padding: 80px 0; color: #888; }
</style>

最后在 main.js 接上 pinia 和路由:

// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

到这里站点已经能跑能用了。先 npm run dev 玩一圈,剩下的功能继续往下加。

收藏功能(全局状态)

基本功能都通了,但还缺一样”现代网站”该有的:收藏。它一出现就横跨三个地方:

  • 首页每张工具卡片右上角,点星标收藏
  • 工具页标题旁边,也能切换收藏
  • 顶部导航栏要显示收藏总数

三个组件分属不同页面,却要读写同一份数据。每个组件自己存一份,导航栏就永远不知道首页收藏了什么,这正是 Vue3 状态管理入门 说的”跨页面共享”,交给 Pinia。

1. 定义收藏 store

watch 把收藏同步到 localStorage,刷新页面也不丢(Vue3 Pinia 实战 的持久化思路):

// src/stores/useFavoritesStore.js
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'

export const useFavoritesStore = defineStore('favorites', () => {
  // 初值从 localStorage 读,刷新不丢
  const ids = ref(JSON.parse(localStorage.getItem('fav-tools') || '[]'))

  // ids 一变就写回 localStorage
  watch(ids, (v) => {
    localStorage.setItem('fav-tools', JSON.stringify(v))
  }, { deep: true })

  const isFav = (id) => ids.value.includes(id)
  const toggle = (id) => {
    const i = ids.value.indexOf(id)
    if (i === -1) ids.value.push(id)
    else ids.value.splice(i, 1)
  }

  return { ids, isFav, toggle }
})

2. 接进三处

① 导航栏徽标(HeaderBar.vue)。script 里拿 store 实例,导航里加一个徽标:

<script setup>
import { useFavoritesStore } from '../stores/useFavoritesStore'
import { storeToRefs } from 'pinia'

const fav = useFavoritesStore()
const { ids } = storeToRefs(fav)
</script>
<nav>
  <router-link to="/">首页</router-link>
  <span class="fav-badge">★ {{ ids.length }}</span>
</nav>

样式补一行 .fav-badge { color: #f59e0b; font-size: 14px; }

② 首页卡片星标(HomeView.vue)。script 加 store 和收藏列表,卡片里加星标按钮,底部加”我的收藏”区:

import { computed } from 'vue'
import { useFavoritesStore } from '../stores/useFavoritesStore'

const fav = useFavoritesStore()
const favTools = computed(() => tools.filter(t => fav.ids.includes(t.id)))

每张卡片的 <router-link> 后面加一个按钮:

<button class="star" :class="{ on: fav.isFav(t.id) }" @click="fav.toggle(t.id)"></button>

“全部工具”下面再加一块收藏区,没收藏时整个不显示:

<section v-if="favTools.length">
  <h2>我的收藏</h2>
  <div class="grid">
    <article v-for="t in favTools" :key="t.id" class="card">
      <router-link :to="`/tools/${t.id}`">
        <span class="tile" :style="{ background: tileColor(t) }">{{ t.name[0] }}</span>
        <h3>{{ t.name }}</h3>
        <p>{{ t.desc }}</p>
      </router-link>
      <button class="star on" @click="fav.toggle(t.id)"></button>
    </article>
  </div>
</section>

样式补星标两行:

.star { position: absolute; top: 16px; right: 16px; border: none; background: none; font-size: 20px; color: #d1d5db; cursor: pointer; }
.star.on { color: #f59e0b; }

③ 工具页星标(ToolView.vue)。script 加 const fav = useFavoritesStore(),标题右侧加按钮:

<button class="star" :class="{ on: fav.isFav(tool.id) }" @click="fav.toggle(tool.id)"></button>
.star { border: none; background: none; font-size: 22px; color: #d1d5db; cursor: pointer; }
.star.on { color: #f59e0b; }

三处用的是同一个 store 实例,首页收藏了,导航栏的徽标立刻跟着变,这就是跨页面共享数据在真实项目里的样子。

跑起来

开发预览:

npm run dev          # 开发,热更新

打包与本地验收:

npm run build        # 生成 dist/
npm run preview      # 本地预览生产包

部署提醒Vue3 路由基础 的 404 坑):用 createWebHistory 上线时,静态托管/服务器要把「所有路径」回退到 index.html,否则刷新 /tools/json 会 404。拿到线上环境照此配置,或临时改用 hash 模式。

复盘与下一步

这个项目用到并串起了全系列:响应式、模板、条件/列表渲染、事件、表单、动态样式、组件的 props/emits/插槽、动态组件、数据请求 + 组合式函数、路由(含动态与懒加载)、Pinia(含 localStorage 持久化)、Teleport + 过渡动画。

开发顺序本身就是个复盘:先把简单的骨架搭起来让页面能跑,再把最复杂的跨页面状态放到真正需要的那一刻去加,复杂的东西才好用、才好懂。

想再进一步,给你几条路:

  • 加新工具:在 tools.js 里加一项、ToolView 里注册一个组件,就多一个页面,可以照这个模式自己补「字数统计」「时间戳转换」「颜色转换」等。
  • 工程化:接入 TypeScript 把工具清单和 store 都写类型;用 ESLint + Prettier 统一规范(起步篇留过钩子)。
  • 体验:给工具页套 KeepAlive 缓存输入(Vue3 动态组件与缓存)、加复制反馈骨架屏、错误边界(onErrorCaptured)。
  • 语言:中文文档优先看 Vue3 官方文档,遇到问题 GitHub 搜案例。

祝编码愉快,下一个项目见。

上一篇
Vue3 Pinia 实战
下一篇
Nuxt4 教程