Vue.js 实战精要:从零构建现代化前端应用
1. 从零开始:用 Vite 快速搭建你的第一个 Vue 3 项目
很多新手朋友一上来就被各种配置吓到了,觉得搭建环境是个大工程。其实现在早就不是那个需要手动配半天 webpack 的时代了。我刚开始学的时候也走过弯路,但现在我首推 Vite,因为它真的太快了,快到让你感觉不到“构建”这个过程的存在。它不像传统的打包工具那样,启动时要先把你整个项目的模块都分析一遍,而是利用了现代浏览器原生支持 ES 模块的特性,按需提供源码。简单说,就是你改一行代码,浏览器几乎立刻就更新了,几乎没有等待时间。
那第一步,我们得确保你电脑上装了 Node.js。去官网下载最新的 LTS(长期支持)版本就行,安装过程一路下一步。装好后,打开你的终端(Windows 用户用 PowerShell 或 CMD,Mac 用户用终端),输入node -v和npm -v,能看到版本号就说明成功了。接下来,我们不用 Vue CLI,直接用 Vite 官方提供的模板。在终端里找个你喜欢的文件夹,执行下面这行命令:
npm create vue@latest这个命令会启动一个交互式的项目创建向导。它会问你几个问题,比如项目名、是否要 TypeScript、JSX 支持、路由、状态管理等等。对于初学者,我的建议是:项目名随便起一个,比如my-vue-blog;TypeScript可以先选 No,等熟悉了基础再加;JSX选 No;Vue Router选 Yes,这个我们后面做多页面要用;Pinia选 Yes,这是新一代的状态管理工具,比 Vuex 简单好用;ESLint和Prettier建议都选 Yes,它们能帮你保持代码整洁规范。一路回车下来,项目就创建好了。
接着,按照终端的提示,进入项目目录,安装依赖,然后启动开发服务器:
cd my-vue-blog npm install npm run dev执行完npm run dev,通常一两秒钟,终端就会告诉你一个本地地址,比如http://localhost:5173。你把它复制到浏览器打开,一个最基础的 Vue 应用页面就出来了。整个过程可能不到一分钟,是不是比想象中简单多了?这个项目已经自带了 Vue Router 和 Pinia 的基本配置,文件结构也非常清晰。src目录下,components放组件,views或pages放页面,router和stores里分别是路由和状态管理的配置。这种开箱即用的体验,能让你跳过繁琐的配置,直接开始写业务逻辑,对于保持学习热情至关重要。
2. 理解核心:组件、响应式与 Composition API
项目跑起来了,我们得聊聊 Vue 的灵魂。Vue 的核心思想是“组件化”和“响应式”。你可以把一个复杂的网页,想象成一个乐高模型,而每个组件就是一块乐高积木。比如一个博客网站,导航栏是一个组件,文章列表是一个组件,侧边栏又是一个组件。组件可以复用,可以组合,这让开发和维护变得非常清晰。在 Vue 3 里,我们写一个组件通常用一个.vue文件,里面包含三部分:<template>(模板)、<script>(脚本)和<style>(样式)。
响应式是 Vue 的魔法。你定义一个数据,当这个数据变化时,用到它的视图会自动更新。在 Vue 2 里,我们用的是data()函数和Options API。但在 Vue 3,我强烈推荐你直接从Composition API学起。它用起来更灵活,逻辑组织也更清晰。它的核心是ref和reactive这两个函数。ref用来定义基本类型(如字符串、数字)的响应式数据,而reactive用来定义对象或数组。在模板里,ref定义的值需要用.value来访问,但在模板中 Vue 会自动帮你“解包”,所以直接写名字就行。
让我们写一个最简单的计数器组件来感受一下。在src/components下新建一个Counter.vue文件:
<template> <div class="counter"> <h3>{{ title }}</h3> <p>当前的计数是:{{ count }}</p> <button @click="increment">点我加1</button> <button @click="decrement">点我减1</button> <p>双倍计数:{{ doubleCount }}</p> </div> </template> <script setup> import { ref, computed } from 'vue' // 定义响应式数据 const count = ref(0) const title = ref('我的第一个计数器') // 定义计算属性 const doubleCount = computed(() => count.value * 2) // 定义方法 function increment() { count.value++ } function decrement() { count.value-- } </script> <style scoped> .counter { border: 1px solid #eee; padding: 20px; border-radius: 8px; margin: 10px; } </style>注意看<script setup>这个语法,这是 Composition API 的语法糖,它让代码变得更简洁。所有顶层的绑定(变量、函数)都能直接在模板里使用。computed用来创建依赖其他响应式数据的计算属性,只有当依赖变化时它才会重新计算,效率很高。<style scoped>里的scoped表示这里的样式只作用于当前组件,不会影响其他组件,避免了样式冲突。把这个组件引入到App.vue里,你就能在页面上看到一个可以交互的计数器了。通过这个例子,你应该能体会到,数据和视图是联动的,你只需要操作数据,视图的事交给 Vue。
3. 构建骨架:用 Vue Router 设计博客的路由系统
一个博客不能只有一个页面。我们需要首页展示文章列表,点击文章要能进入详情页,可能还有关于我、归档等页面。这就需要路由来管理了。Vue Router 就是专门干这个的。在我们用 Vite 创建项目时,如果选了 Yes,它已经帮我们安装并配置好了基础路由。打开src/router/index.js(或index.ts)文件,你会看到类似下面的结构:
import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'home', component: HomeView }, { path: '/about', name: 'about', // 路由级代码分割,懒加载组件 component: () => import('../views/AboutView.vue') } ] }) export default routercreateWebHistory创建的是历史模式,它的路由路径像http://localhost/about,更美观,但需要服务器做一些配置。如果怕麻烦,可以用createWebHashHistory,它会在 URL 里带个#,像http://localhost/#/about,但部署简单。routes数组里定义了我们的路由表。每个路由对象至少需要path(路径)和component(组件)。name属性是可选的,但在编程式导航时很有用。
对于我们的博客,我们来规划几个路由:
/:首页,显示文章列表。/article/:id:文章详情页,id是动态参数,代表文章的唯一标识。/archive:归档页面,按时间分类文章。/about:关于我页面。
我们来添加文章详情页的路由,并演示动态路由和懒加载。首先,在src/views下创建ArticleDetail.vue。然后在路由文件中添加:
{ path: '/article/:id', name: 'article', component: () => import('../views/ArticleDetail.vue'), // 一个可选的 props 配置,可以将路由参数作为组件的 props 传入,让组件更纯净 props: true }在首页的列表组件里,我们可以这样跳转到详情页:
<template> <div v-for="article in articleList" :key="article.id"> <h3>{{ article.title }}</h3> <p>{{ article.summary }}</p> <!-- 使用 router-link 进行声明式导航 --> <router-link :to="`/article/${article.id}`">阅读全文</router-link> <!-- 或者在方法里使用编程式导航 --> <button @click="goToDetail(article.id)">编程式跳转</button> </div> </template> <script setup> import { useRouter } from 'vue-router' const router = useRouter() function goToDetail(id) { // 使用 name 跳转更稳定,即使 path 改了也没关系 router.push({ name: 'article', params: { id: id } }) } </script>在ArticleDetail.vue组件里,我们可以通过route对象获取到传递过来的id参数,然后根据这个id去请求对应的文章数据:
<script setup> import { useRoute } from 'vue-router' import { ref, onMounted } from 'vue' import { fetchArticleById } from '@/api/article' // 假设有个 API 模块 const route = useRoute() const article = ref(null) const loading = ref(true) onMounted(async () => { const articleId = route.params.id try { article.value = await fetchArticleById(articleId) } catch (error) { console.error('获取文章失败', error) } finally { loading.value = false } }) </script>路由守卫也是一个非常重要的概念。比如,我们想在用户进入某个页面之前检查他是否登录,就可以用“全局前置守卫”:
// 在 router/index.js 中 router.beforeEach((to, from) => { // 假设我们有一个元信息 meta.requiresAuth 来标记哪些页面需要登录 if (to.meta.requiresAuth && !isAuthenticated()) { // 如果未登录,则重定向到登录页 return { name: 'login' } } })通过这样的设计,我们博客的页面骨架就清晰了,不同页面之间的跳转和参数传递也变得非常顺畅。
4. 状态管理:使用 Pinia 管理博客的全局数据
随着应用变大,组件之间需要共享的数据会越来越多。比如用户登录信息、博客的主题模式(深色/浅色)、全站的提示消息等。如果只用组件间的props和emit来传递,会变得非常复杂和混乱。这时候就需要一个“状态管理库”来帮忙。Vue 官方以前推荐 Vuex,但现在有了更现代、更简单的选择——Pinia。它和 Vuex 的核心思想类似,但 API 设计更贴合 Composition API,用起来也更直观。
Pinia 的核心概念是Store(仓库)。一个 Store 就是一个包含状态和业务逻辑的实体。我们的博客可以创建几个 Store,比如:
useUserStore:管理用户登录状态、用户信息。useThemeStore:管理 UI 主题(亮色/暗色)。useNotificationStore:管理全局的通知消息。
我们用创建项目时自带的 Pinia 来创建一个主题 Store。在src/stores目录下,新建一个theme.js(或theme.ts):
import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useThemeStore = defineStore('theme', () => { // 状态 const themeMode = ref('light') // 'light' 或 'dark' // 计算属性 const isDarkMode = computed(() => themeMode.value === 'dark') // 动作(Action) function toggleTheme() { themeMode.value = themeMode.value === 'light' ? 'dark' : 'light' // 可以在这里把主题保存到 localStorage,实现持久化 localStorage.setItem('app-theme', themeMode.value) // 也可以在这里动态修改 HTML 根元素的 class,用于 CSS 变量切换 document.documentElement.setAttribute('data-theme', themeMode.value) } function initializeTheme() { const savedTheme = localStorage.getItem('app-theme') if (savedTheme) { themeMode.value = savedTheme } document.documentElement.setAttribute('data-theme', themeMode.value) } return { themeMode, isDarkMode, toggleTheme, initializeTheme } })这个 Store 使用了defineStore并传入一个函数(这是 Composition API 风格的写法),里面定义了响应式状态themeMode、计算属性isDarkMode和修改状态的方法toggleTheme、initializeTheme。现在,在任何组件里,我们都可以使用这个 Store 了:
<template> <div :class="{ 'dark-theme': theme.isDarkMode }"> <button @click="theme.toggleTheme"> 切换主题 (当前: {{ theme.themeMode }}) </button> <p>这是正文内容...</p> </div> </template> <script setup> import { useThemeStore } from '@/stores/theme' const theme = useThemeStore() // 在应用初始化时调用,比如在 App.vue 的 onMounted 里 // theme.initializeTheme() </script> <style> .dark-theme { background-color: #333; color: #fff; } </style>你看,在组件里使用 Store 非常简单,就像调用一个函数一样。而且状态是响应式的,当你在一个组件里调用toggleTheme改变了themeMode,所有用到这个 Store 的组件都会自动更新。Pinia 还支持模块化,你可以把不同的业务状态分到不同的 Store 文件里,结构非常清晰。相比于 Vuex 的state、mutations、actions、getters分块定义,Pinia 这种“一体化”的写法,逻辑更集中,也更符合直觉。
5. 实战演练:搭建一个博客首页与文章列表
理论说了这么多,我们动手来搭一个博客首页。这个页面需要展示文章列表,并且有加载、错误处理等状态。我们会用到之前学的组件、路由、状态管理,还会涉及到一些实战中的小技巧。
首先,我们来设计文章的数据结构。通常一篇文章会包含标题、摘要、封面图、发布时间、分类、标签等。我们可以先定义一个简单的结构。在src/api/mockData.js里模拟一些数据:
// 模拟文章数据 export const mockArticles = [ { id: 1, title: 'Vue 3 Composition API 入门指南', summary: '详细介绍了 Vue 3 全新的 Composition API 的使用方法和设计理念,帮助你更好地组织组件逻辑。', coverImage: 'https://picsum.photos/seed/picsum/300/200', publishTime: '2023-10-26', category: '前端框架', tags: ['Vue3', 'Composition API', '教程'] }, { id: 2, title: 'Pinia vs Vuex:下一代状态管理该选谁?', summary: '对比分析了 Pinia 和 Vuex 的优缺点,并给出了在新时代 Vue 项目中的选型建议。', coverImage: 'https://picsum.photos/seed/picsum2/300/200', publishTime: '2023-10-25', category: '状态管理', tags: ['Pinia', 'Vuex', '状态管理'] }, // ... 更多模拟数据 ]然后,我们创建一个ArticleList.vue组件,放在src/components下。这个组件负责获取数据并渲染列表:
<template> <div class="article-list"> <div v-if="loading" class="loading">加载中...</div> <div v-else-if="error" class="error">{{ error }}</div> <div v-else> <div v-for="article in articles" :key="article.id" class="article-card"> <img :src="article.coverImage" :alt="article.title" class="cover" /> <div class="content"> <h3 class="title"> <router-link :to="{ name: 'article', params: { id: article.id } }"> {{ article.title }} </router-link> </h3> <p class="summary">{{ article.summary }}</p> <div class="meta"> <span class="time">{{ formatDate(article.publishTime) }}</span> <span class="category">{{ article.category }}</span> <span v-for="tag in article.tags" :key="tag" class="tag">{{ tag }}</span> </div> </div> </div> </div> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { mockArticles } from '@/api/mockData' // 暂时用模拟数据 const articles = ref([]) const loading = ref(true) const error = ref(null) // 模拟一个异步请求 function fetchArticles() { return new Promise((resolve) => { setTimeout(() => { resolve(mockArticles) }, 800) // 模拟网络延迟 }) } onMounted(async () => { try { articles.value = await fetchArticles() } catch (err) { error.value = '获取文章列表失败,请稍后重试。' console.error(err) } finally { loading.value = false } }) // 一个简单的日期格式化函数 function formatDate(dateString) { const options = { year: 'numeric', month: 'long', day: 'numeric' } return new Date(dateString).toLocaleDateString('zh-CN', options) } </script> <style scoped> .article-list { max-width: 800px; margin: 0 auto; } .article-card { display: flex; margin-bottom: 30px; border-bottom: 1px solid #eee; padding-bottom: 20px; } .cover { width: 200px; height: 140px; object-fit: cover; border-radius: 8px; margin-right: 20px; } .content { flex: 1; } .title a { color: inherit; text-decoration: none; } .title a:hover { color: #42b983; /* Vue 主题色 */ } .meta { margin-top: 10px; font-size: 0.9em; color: #666; } .meta > * { margin-right: 10px; } .tag { display: inline-block; background: #f0f0f0; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; } .loading, .error { text-align: center; padding: 40px; font-size: 1.2em; } </style>这个组件里,我们处理了三种状态:加载中、加载失败和加载成功。使用了v-if、v-else-if、v-else来条件渲染不同的 UI。列表渲染用了v-for,并且记得给每个循环项一个唯一的:key,这能帮助 Vue 高效地更新 DOM。点击文章标题,会通过<router-link>跳转到对应的文章详情页。
接下来,我们把ArticleList组件放到首页视图HomeView.vue中。同时,我们可以把顶部的导航栏也做成一个可复用的组件AppHeader.vue,里面可以放上博客标题、导航菜单,以及之前我们做的主题切换按钮。这样,一个博客首页的雏形就出来了。在实际项目中,数据请求会放在更专门的 Service 层或使用axios这样的 HTTP 库,状态管理也可能把文章列表数据放到 Pinia Store 里供多个组件共享,但基本的思路和流程就是这样一步步构建起来的。
6. 进阶技巧:Composition API 复用与 TypeScript 初探
当你写了几个组件后,可能会发现一些逻辑(比如获取数据、监听窗口大小、格式化时间)在多个组件里重复出现。这时候,Composition API 的另一个巨大优势就体现出来了:逻辑复用。我们可以把这些可复用的逻辑提取成“组合式函数”(Composables)。这比 Vue 2 时代的 Mixin 要清晰和强大得多,因为组合式函数是基于函数的,你可以清楚地看到数据的来源,没有命名冲突的烦恼。
举个例子,我们几乎每个页面都需要处理加载和错误状态。我们可以创建一个useFetch组合式函数。在src/composables目录下(没有就新建一个),创建useFetch.js:
import { ref } from 'vue' export function useFetch(url) { const data = ref(null) const error = ref(null) const loading = ref(false) async function doFetch() { loading.value = true error.value = null data.value = null try { const response = await fetch(url) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } data.value = await response.json() } catch (e) { error.value = e.message || '请求失败' } finally { loading.value = false } } // 可以立即执行一次,也可以由组件手动调用 // onMounted(doFetch) return { data, error, loading, doFetch } }然后在我们的ArticleList.vue组件里,就可以这样使用:
<script setup> import { useFetch } from '@/composables/useFetch' import { onMounted } from 'vue' const { data: articles, error, loading, doFetch } = useFetch('/api/articles') onMounted(() => { doFetch() }) </script>看,是不是清爽多了?所有与请求相关的状态和逻辑都被封装到了一个函数里,组件只需要关心如何使用这些状态。你可以创建各种各样的组合式函数,比如useLocalStorage、useMousePosition、useDebounce等等,极大地提高了代码的复用性和可维护性。
另一个让项目变得更健壮的工具是TypeScript。Vue 3 对 TypeScript 的支持是原生的,非常好。即使你之前没学过 TypeScript,也可以从简单的类型标注开始。它能帮你提前发现很多潜在的错误,比如拼写错误、传参类型不对等。给我们的ArticleList组件加上类型:
首先,确保你的项目支持 TypeScript(创建时选了 Yes,或者后来安装了typescript和@vue/tsconfig)。然后,我们可以为文章定义一个接口(Interface):
// src/types/article.ts export interface IArticle { id: number title: string summary: string coverImage: string publishTime: string category: string tags: string[] }接着,修改我们的组件为.vue文件,并在<script>标签上加上lang="ts":
<script setup lang="ts"> import { ref, onMounted } from 'vue' import type { IArticle } from '@/types/article' import { mockArticles } from '@/api/mockData' const articles = ref<IArticle[]>([]) // 这里指定了数组内部是 IArticle 类型 const loading = ref(true) const error = ref<string | null>(null) // 指定 error 可以是 string 或 null // 现在 fetchArticles 的返回值类型就被推断为 Promise<IArticle[]> async function fetchArticles(): Promise<IArticle[]> { // ... 模拟请求 return mockArticles as IArticle[] } onMounted(async () => { try { articles.value = await fetchArticles() } catch (err) { error.value = err instanceof Error ? err.message : '未知错误' } finally { loading.value = false } }) </script>加了类型之后,如果你不小心把articles.value赋值为一个字符串,或者调用一个article对象上不存在的方法,IDE(比如 VSCode)会立刻给你红色波浪线提示。这就像有个经验丰富的伙伴在帮你做代码审查,能有效减少运行时错误。从长远来看,在稍具规模的项目中引入 TypeScript,绝对是利大于弊的。
