1. 引言:为什么选择 Vue 3 + TypeScript?

在当今的前端开发领域,Vue.js 以其渐进式、易上手和功能强大的特性,成为了最受欢迎的框架之一。而 TypeScript 作为 JavaScript 的超集,提供了强大的静态类型系统,极大地提升了代码的可维护性、可读性和开发体验。Vue 3 与 TypeScript 的结合,堪称现代前端开发的“黄金搭档”。

学习路径建议

  1. 掌握基础:HTML、CSS、JavaScript (ES6+)。
  2. 框架入门:Vue 3 核心概念(响应式、组合式 API、组件)。
  3. 类型加持:TypeScript 基础类型、接口、泛型。
  4. 工程实践:构建工具、状态管理、路由、测试。
  5. 生态深入:UI 库、工具链、性能优化。

本文将带你从零开始,系统性地掌握 Vue 3 + TypeScript 的全栈开发技能,并提供丰富的官方资源链接和可运行的代码示例。

2. 环境搭建与项目创建

2.1 安装 Node.js 与包管理器

Vue 的开发离不开 Node.js 环境。请前往 Node.js 官网 下载并安装最新的 LTS(长期支持)版本。安装完成后,在终端中验证:

node --version
npm --version

推荐使用更快的包管理器 pnpmyarn

# 安装 pnpm
npm install -g pnpm
pnpm --version

2.2 使用 Vite 创建 Vue + TypeScript 项目

Vite 是新一代的前端构建工具,启动速度和热更新极快,是 Vue 官方推荐的构建工具。

通过以下命令创建项目:

# 使用 npm
npm create vue@latest

# 或使用 pnpm
pnpm create vue@latest

在交互式命令行中,根据提示进行选择:

  • Project name: vue-ts-demo
  • Add TypeScript?: Yes
  • Add JSX Support?: 按需选择
  • Add Vue Router for Single Page Application development?: Yes (推荐)
  • Add Pinia for state management?: Yes (推荐)
  • Add Vitest for Unit Testing?: 按需选择
  • Add an End-to-End Testing Solution?: 按需选择
  • Add ESLint for code quality?: Yes (推荐)

项目创建完成后,进入目录并安装依赖:

cd vue-ts-demo
pnpm install # 或 npm install

启动开发服务器:

pnpm dev

打开浏览器访问 http://localhost:5173,你将看到 Vue 的欢迎页面。

2.3 项目结构概览

vue-ts-demo/
├── public/                 # 静态资源
├── src/
│   ├── assets/            # 图片、字体等资源
│   ├── components/        # 可复用组件
│   ├── views/             # 页面级组件 (配合路由)
│   ├── router/            # 路由配置 (index.ts)
│   ├── stores/            # Pinia 状态管理仓库
│   ├── App.vue            # 根组件
│   └── main.ts            # 应用入口文件
├── index.html             # HTML 模板
├── package.json           # 项目依赖和脚本
├── tsconfig.json          # TypeScript 配置
├── vite.config.ts         # Vite 配置
└── README.md

3. TypeScript 基础快速入门

在深入 Vue 之前,需要掌握一些 TypeScript 的核心概念,这些将在 Vue 组件中频繁使用。

3.1 基本类型与类型注解

TypeScript 扩展了 JavaScript 的类型系统。

// 变量类型注解
let username: string = '张三';
let age: number = 25;
let isStudent: boolean = true;
let hobbies: string[] = ['篮球', '音乐', '编程'];
let scores: Array<number> = [90, 85, 95]; // 泛型数组

// 元组 (Tuple) - 固定长度和类型的数组
let person: [string, number] = ['李四', 30];

// 枚举 (Enum)
enum Direction {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT'
}
let move: Direction = Direction.Up;

// 任意类型 (Any) - 应谨慎使用
let dynamicData: any = '可以是任何东西';
dynamicData = 42;

3.2 接口 (Interface) 与类型别名 (Type Alias)

用于定义对象的形状,是 TypeScript 的精华所在。

// 接口定义对象结构
interface User {
  id: number;
  name: string;
  email: string;
  age?: number; // 可选属性
  readonly createdAt: Date; // 只读属性
}

// 使用接口
const currentUser: User = {
  id: 1,
  name: '王五',
  email: 'wangwu@example.com',
  createdAt: new Date()
};
// currentUser.createdAt = new Date(); // 错误!只读属性不能修改

// 类型别名
type ID = number | string; // 联合类型
type Callback = (data: string) => void; // 函数类型

// 在 Vue 组件中,我们常用接口来定义 Props 和 Emits 的类型

3.3 泛型 (Generics)

泛型让组件支持多种类型,提高复用性。

// 一个简单的泛型函数
function identity<T>(arg: T): T {
  return arg;
}
let output1 = identity<string>("hello"); // 显式指定类型
let output2 = identity(42); // 类型推断为 number

// 泛型接口
interface ApiResponse<T> {
  code: number;
  message: string;
  data: T; // 响应数据的类型由外部决定
}
const userResponse: ApiResponse<User> = {
  code: 200,
  message: '成功',
  data: currentUser
};

4. Vue 3 核心概念与组合式 API

Vue 3 引入了组合式 API (Composition API),它比 Vue 2 的选项式 API 更灵活,尤其适合复杂组件和 TypeScript 集成。

4.1 响应式基础:refreactive

<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';

// ref: 用于定义响应式的基本类型值 (返回一个带有 .value 属性的对象)
const count = ref<number>(0); // 显式指定泛型类型为 number
const message = ref('Hello Vue 3'); // 类型推断为 string

// reactive: 用于定义响应式的对象
interface UserState {
  name: string;
  age: number;
}
const state = reactive<UserState>({
  name: '小明',
  age: 20
});

// 在模板中,ref 会自动解包,无需 .value
// 在脚本中,需要 .value 访问
const increment = () => {
  count.value++; // 正确
  // count++ // 错误!不能直接操作
  state.age += 1; // reactive 对象属性可直接修改
};
</script>

<template>
  <div>
    <p>{{ count }}</p> <!-- 自动解包,显示 0 -->
    <p>{{ state.name }} 今年 {{ state.age }} 岁</p>
    <button @click="increment">增加</button>
  </div>
</template>

4.2 计算属性与侦听器

<script setup lang="ts">
import { ref, computed, watch } from 'vue';

const price = ref<number>(100);
const quantity = ref<number>(2);

// 计算属性:基于响应式依赖进行缓存计算
const totalPrice = computed<number>(() => {
  return price.value * quantity.value;
});

// 侦听器:监听响应式数据的变化
watch(quantity, (newVal, oldVal) => {
  console.log(`数量从 ${oldVal} 变为 ${newVal}`);
  // 可以执行副作用,如发送请求、操作 DOM 等
});

// 深度侦听对象
const userInfo = ref({ name: '张三', details: { age: 25 } });
watch(
  userInfo,
  (newVal) => {
    console.log('用户信息变化:', newVal);
  },
  { deep: true } // 深度监听
);
</script>

4.3 生命周期钩子

组合式 API 中使用 onXxx 函数注册生命周期钩子。

<script setup lang="ts">
import { onMounted, onUpdated, onUnmounted } from 'vue';

onMounted(() => {
  console.log('组件挂载完成,可以访问 DOM');
  // 常用于初始化数据、订阅事件、调用接口
});

onUpdated(() => {
  console.log('组件更新完成');
});

onUnmounted(() => {
  console.log('组件即将卸载');
  // 清理定时器、取消订阅、释放资源
});
</script>

5. 组件开发:Props、Emits 与 Slots

5.1 使用 TypeScript 定义 Props

使用 defineProps 宏函数,并配合接口或类型字面量来获得完整的类型推断和校验。

<!-- ChildComponent.vue -->
<script setup lang="ts">
// 方式1:使用接口
interface Props {
  title: string;
  count?: number; // 可选属性
  isActive: boolean;
  list: string[];
  onAction?: () => void; // 函数类型
}

const props = defineProps<Props>();

// 方式2:使用运行时声明与类型注解(Vue 3.3+ 推荐)
// 可以定义默认值,但语法稍复杂
// import { withDefaults } from 'vue';
// interface Props { title: string; count?: number; }
// const props = withDefaults(defineProps<Props>(), {
//   count: 0
// });

// 使用 props
console.log(props.title);
</script>

<template>
  <div :class="{ active: isActive }">
    <h3>{{ title }}</h3>
    <p>数量: {{ count }}</p>
    <ul>
      <li v-for="(item, index) in list" :key="index">{{ item }}</li>
    </ul>
  </div>
</template>

5.2 使用 TypeScript 定义 Emits

使用 defineEmits 宏函数来定义组件可以触发的事件及其载荷类型。

<!-- ChildComponent.vue -->
<script setup lang="ts">
// 定义事件及其载荷类型
interface Emits {
  (e: 'update:title', value: string): void;
  (e: 'submit', payload: { id: number; data: string }): void;
  (e: 'cancel'): void; // 无载荷事件
}

const emit = defineEmits<Emits>();

const handleClick = () => {
  emit('update:title', '新的标题');
  emit('submit', { id: 1, data: '提交的数据' });
};
</script>

<template>
  <button @click="handleClick">触发事件</button>
</template>

在父组件中使用:

<!-- ParentComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';

const title = ref('初始标题');

const handleSubmit = (payload: { id: number; data: string }) => {
  console.log('收到提交:', payload);
};
</script>

<template>
  <ChildComponent
    :title="title"
    @update:title="title = $event"
    @submit="handleSubmit"
  />
</template>

5.3 插槽 (Slots) 与作用域插槽

<!-- LayoutComponent.vue -->
<script setup lang="ts">
import { useSlots } from 'vue';

// 检查插槽内容
const slots = useSlots();
const hasHeader = !!slots.header;
</script>

<template>
  <div class="layout">
    <header v-if="hasHeader">
      <slot name="header"></slot>
    </header>
    <main>
      <!-- 默认插槽 -->
      <slot></slot>
    </main>
    <footer>
      <!-- 作用域插槽:向父组件传递数据 -->
      <slot name="footer" :year="2024" :author="'Vue Team'"></slot>
    </footer>
  </div>
</template>

使用插槽的父组件:

<template>
  <LayoutComponent>
    <template #header>
      <h1>这是页头</h1>
    </template>

    <p>这是默认插槽的内容。</p>

    <template #footer="{ year, author }">
      <p>© {{ year }} {{ author }}. All rights reserved.</p>
    </template>
  </LayoutComponent>
</template>

6. 路由管理:Vue Router 4

6.1 安装与配置

如果创建项目时未选择 Vue Router,可以手动安装:

pnpm add vue-router@4

创建路由配置文件 src/router/index.ts

import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import AboutView from '../views/AboutView.vue';
import UserProfile from '../views/UserProfile.vue';

// 定义路由记录的类型
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: HomeView,
    meta: {
      requiresAuth: false, // 路由元信息,可用于权限控制
      title: '首页'
    }
  },
  {
    path: '/about',
    name: 'About',
    component: AboutView,
    meta: { title: '关于我们' }
  },
  {
    path: '/user/:id', // 动态路由
    name: 'UserProfile',
    component: UserProfile,
    props: true // 将路由参数作为 props 传递给组件
  },
  {
    path: '/:pathMatch(.*)*', // 404 页面捕获
    name: 'NotFound',
    component: () => import('../views/NotFound.vue') // 路由懒加载
  }
];

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL), // 使用 HTML5 History 模式
  routes
});

// 全局前置守卫
router.beforeEach((to, from) => {
  // 修改页面标题
  const pageTitle = to.meta.title as string;
  if (pageTitle) {
    document.title = `${pageTitle} - 我的 Vue 应用`;
  }
  // 可以在此进行登录验证等
  // if (to.meta.requiresAuth && !isAuthenticated) {
  //   return { name: 'Login' };
  // }
});

export default router;

main.ts 中注册路由:

import { createApp } from 'vue';
import App from './App.vue';
import router from './router';

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

6.2 在组件中使用路由

<!-- App.vue -->
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
</script>

<template>
  <header>
    <nav>
      <!-- 声明式导航 -->
      <RouterLink to="/">首页</RouterLink>
      <RouterLink :to="{ name: 'About' }">关于</RouterLink>
      <RouterLink :to="`/user/${userId}`">我的主页</RouterLink>
    </nav>
  </header>
  <main>
    <!-- 路由出口 -->
    <RouterView />
  </main>
</template>
<!-- UserProfile.vue -->
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';

// 通过 useRoute 获取当前路由信息(只读)
const route = useRoute();
// 路由参数是字符串类型,需要转换
const userId = computed(() => Number(route.params.id) || 0);

// 通过 useRouter 进行编程式导航
const router = useRouter();
const goBack = () => {
  router.go(-1); // 后退一页
};
const goHome = () => {
  router.push('/'); // 跳转到首页
  // 或 router.push({ name: 'Home' });
};
</script>

<template>
  <div>
    <h1>用户 ID: {{ userId }}</h1>
    <button @click="goBack">返回</button>
    <button @click="goHome">回首页</button>
  </div>
</template>

7. 状态管理:Pinia (Vue 官方推荐)

Pinia 是 Vue 的下一代状态管理库,比 Vuex 更简单、类型安全且支持组合式 API。

7.1 安装与创建 Store

pnpm add pinia

main.ts 中注册 Pinia:

import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

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

创建一个用户 Store:src/stores/user.ts

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

// 定义用户类型
interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
}

// 定义登录请求参数类型
interface LoginCredentials {
  email: string;
  password: string;
  rememberMe?: boolean;
}

// 定义登录响应类型
interface LoginResponse {
  user: User;
  token: string;
  expiresIn: number;
}

// 定义 Store
export const useUserStore = defineStore('user', () => {
  // State
  const user = ref<User | null>(null);
  const token = ref<string>('');

  // 初始化时从 localStorage 恢复 token
  const initFromStorage = () => {
    const storedToken = localStorage.getItem('auth_token');
    const storedUser = localStorage.getItem('user_info');
    
    if (storedToken) {
      token.value = storedToken;
    }
    
    if (storedUser) {
      try {
        user.value = JSON.parse(storedUser);
      } catch (error) {
        console.error('Failed to parse stored user data:', error);
        localStorage.removeItem('user_info');
      }
    }
  };

  // 调用初始化
  initFromStorage();

  // Getters (计算属性)
  const isLoggedIn = computed(() => !!token.value);
  const userName = computed(() => user.value?.name || '');
  const userEmail = computed(() => user.value?.email || '');
  const userAvatar = computed(() => user.value?.avatar || '/default-avatar.png');

  // Actions (方法)
  const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
    try {
      // 模拟 API 调用
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(credentials),
      });

      if (!response.ok) {
        throw new Error('登录失败,请检查用户名和密码');
      }

      const data: LoginResponse = await response.json();
      
      // 更新 state
      user.value = data.user;
      token.value = data.token;

      // 持久化到 localStorage
      if (credentials.rememberMe) {
        localStorage.setItem('auth_token', data.token);
        localStorage.setItem('user_info', JSON.stringify(data.user));
        
        // 设置 token 过期时间(可选)
        const expiresAt = new Date();
        expiresAt.setSeconds(expiresAt.getSeconds() + data.expiresIn);
        localStorage.setItem('token_expires_at', expiresAt.toISOString());
      } else {
        // 仅保存在 sessionStorage(浏览器关闭后清除)
        sessionStorage.setItem('auth_token', data.token);
        sessionStorage.setItem('user_info', JSON.stringify(data.user));
      }

      return data;
    } catch (error) {
      console.error('登录错误:', error);
      throw error;
    }
  };

  const logout = (): void => {
    // 清除 state
    user.value = null;
    token.value = '';

    // 清除所有存储的认证信息
    localStorage.removeItem('auth_token');
    localStorage.removeItem('user_info');
    localStorage.removeItem('token_expires_at');
    sessionStorage.removeItem('auth_token');
    sessionStorage.removeItem('user_info');

    // 可以在这里添加其他清理逻辑,如重定向到登录页
    console.log('用户已登出');
  };

  const refreshToken = async (): Promise<string> => {
    try {
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${token.value}`,
        },
      });

      if (!response.ok) {
        throw new Error('Token 刷新失败');
      }

      const data = await response.json();
      token.value = data.token;
      
      // 更新存储的 token
      if (localStorage.getItem('auth_token')) {
        localStorage.setItem('auth_token', data.token);
      } else if (sessionStorage.getItem('auth_token')) {
        sessionStorage.setItem('auth_token', data.token);
      }

      return data.token;
    } catch (error) {
      console.error('Token 刷新错误:', error);
      logout(); // 刷新失败时自动登出
      throw error;
    }
  };

  const updateProfile = async (profileData: Partial<User>): Promise<User> => {
    try {
      const response = await fetch('/api/user/profile', {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${token.value}`,
        },
        body: JSON.stringify(profileData),
      });

      if (!response.ok) {
        throw new Error('更新个人资料失败');
      }

      const updatedUser: User = await response.json();
      user.value = updatedUser;

      // 更新存储的用户信息
      const storage = localStorage.getItem('user_info') ? localStorage : sessionStorage;
      storage.setItem('user_info', JSON.stringify(updatedUser));

      return updatedUser;
    } catch (error) {
      console.error('更新个人资料错误:', error);
      throw error;
    }
  };

  // 返回所有 state、getters 和 actions
  return {
    user,
    token,
    isLoggedIn,
    userName,
    userEmail,
    userAvatar,
    login,
    logout,
    refreshToken,
    updateProfile,
  };
});

在组件中使用 Store 的完整示例src/components/LoginForm.vue

<script setup lang="ts">
import { ref, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';

// 使用 Store
const userStore = useUserStore();
const router = useRouter();

// 表单数据
const email = ref<string>('');
const password = ref<string>('');
const rememberMe = ref<boolean>(false);
const isLoading = ref<boolean>(false);
const errorMessage = ref<string>('');

// 计算属性:表单验证
const isFormValid = computed(() => {
  return email.value.trim() !== '' && password.value.trim() !== '';
});

// 登录处理
const handleLogin = async (): Promise<void> => {
  if (!isFormValid.value) {
    errorMessage.value = '请输入邮箱和密码';
    return;
  }

  isLoading.value = true;
  errorMessage.value = '';

  try {
    const credentials = {
      email: email.value,
      password: password.value,
      rememberMe: rememberMe.value,
    };

    await userStore.login(credentials);
    
    // 登录成功后跳转到首页
    router.push('/');
    
    // 显示成功消息
    console.log('登录成功,欢迎', userStore.userName);
  } catch (error) {
    errorMessage.value = error instanceof Error ? error.message : '登录失败,请重试';
  } finally {
    isLoading.value = false;
  }
};

// 登出处理
const handleLogout = (): void => {
  userStore.logout();
  router.push('/login');
};

// 更新个人资料示例
const updateUserProfile = async (): Promise<void> => {
  try {
    await userStore.updateProfile({
      name: '新的用户名',
      avatar: 'https://example.com/new-avatar.jpg',
    });
    console.log('个人资料更新成功');
  } catch (error) {
    console.error('更新失败:', error);
  }
};
</script>

<template>
  <div class="login-container">
    <!-- 登录表单 -->
    <form v-if="!userStore.isLoggedIn" @submit.prevent="handleLogin">
      <h2>用户登录</h2>
      
      <div v-if="errorMessage" class="error-message">
        {{ errorMessage }}
      </div>

      <div class="form-group">
        <label for="email">邮箱地址</label>
        <input
          id="email"
          v-model="email"
          type="email"
          placeholder="请输入邮箱"
          required
        />
      </div>

      <div class="form-group">
        <label for="password">密码</label>
        <input
          id="password"
          v-model="password"
          type="password"
          placeholder="请输入密码"
          required
        />
      </div>

      <div class="form-group checkbox">
        <input
          id="rememberMe"
          v-model="rememberMe"
          type="checkbox"
        />
        <label for="rememberMe">记住我</label>
      </div>

      <button
        type="submit"
        :disabled="!isFormValid || isLoading"
        class="login-button"
      >
        {{ isLoading ? '登录中...' : '登录' }}
      </button>
    </form>

    <!-- 用户信息展示 -->
    <div v-else class="user-info">
      <h2>欢迎回来!</h2>
      <div class="profile">
        <img
          :src="userStore.userAvatar"
          :alt="userStore.userName"
          class="avatar"
        />
        <div class="details">
          <h3>{{ userStore.userName }}</h3>
          <p>{{ userStore.userEmail }}</p>
          <p>Token: {{ userStore.token.substring(0, 20) }}...</p>
        </div>
      </div>
      
      <div class="actions">
        <button @click="updateUserProfile" class="btn-secondary">
          更新资料
        </button>
        <button @click="handleLogout" class="btn-logout">
          退出登录
        </button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.login-container {
  max-width: 400px;
  margin: 2rem auto;
  padding: 2rem;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  background-color: #fff;
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

.form-group input[type="email"],
.form-group input[type="password"] {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.form-group.checkbox {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.login-button {
  width: 100%;
  padding: 0.75rem;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

.login-button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.login-button:hover:not(:disabled) {
  background-color: #3aa876;
}

.error-message {
  padding: 0.75rem;
  margin-bottom: 1rem;
  background-color: #fee;
  color: #c33;
  border-radius: 4px;
  border: 1px solid #fcc;
}

.user-info {
  text-align: center;
}

.profile {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin: 2rem 0;
}

.avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  object-fit: cover;
}

.details h3 {
  margin: 0;
  color: #333;
}

.details p {
  margin: 0.25rem 0;
  color: #666;
}

.actions {
  display: flex;
  gap: 1rem;
  justify-content: center;
  margin-top: 2rem;
}

.btn-secondary,
.btn-logout {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-secondary {
  background-color: #f0f0f0;
  color: #333;
}

.btn-secondary:hover {
  background-color: #e0e0e0;
}

.btn-logout {
  background-color: #ff6b6b;
  color: white;
}

.btn-logout:hover {
  background-color: #ff5252;
}
</style>

类型安全调用的关键点

  1. Store 类型推断:Pinia 会自动推断 useUserStore() 的返回类型,包含所有 state、getters 和 actions
  2. 严格类型检查:所有函数参数和返回值都有明确的 TypeScript 类型定义
  3. 响应式更新:Store 中的 refcomputed 在组件中自动保持响应式
  4. 持久化策略:根据用户选择使用 localStorage(长期存储)或 sessionStorage(会话存储)
  5. 错误处理:所有异步操作都有完整的 try-catch 错误处理
  6. 组件解耦:业务逻辑集中在 Store 中,组件只负责 UI 和用户交互

这个完整的 Store 实现提供了:

  • 完整的登录/登出 action 方法
  • Token 持久化逻辑(localStorage/sessionStorage)
  • 用户信息管理
  • Token 自动刷新机制
  • 类型安全的组件调用示例
  • 完整的样式和用户体验

8. HTTP 请求:Axios 与 TypeScript 封装

在实际项目中,前后端通信离不开 HTTP 请求。Axios 是目前最流行的 HTTP 库,结合 TypeScript 可以做到类型安全的 API 调用。

8.1 安装与基本配置

pnpm add axios

创建 src/utils/request.ts,封装一个带有拦截器的 Axios 实例:

import axios, {
  type AxiosInstance,
  type AxiosRequestConfig,
  type InternalAxiosRequestConfig
} from 'axios';
import { ElMessage } from 'element-plus'; // 可选,需要先安装 element-plus

// 响应通用结构
export interface ApiResponse<T = any> {
  code: number;
  message: string;
  data: T;
}

const request: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 10000,
  headers: { 'Content-Type': 'application/json;charset=UTF-8' }
});

// 请求拦截器:附加 token
request.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = localStorage.getItem('auth_token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// 响应拦截器:统一错误处理
request.interceptors.response.use(
  (response) => {
    const { data } = response;
    if (data.code === 200) return data.data;
    ElMessage.error(data.message || '请求失败');
    return Promise.reject(new Error(data.message));
  },
  (error) => {
    let message = '网络异常,请稍后重试';
    if (error.response) {
      const status = error.response.status;
      if (status === 401) message = '登录已过期,请重新登录';
      else if (status === 403) message = '没有权限';
      else if (status === 404) message = '请求的资源不存在';
      else if (status === 500) message = '服务器错误';
    }
    ElMessage.error(message);
    return Promise.reject(error);
  }
);

export default request;

8.2 类型安全的 API 接口层

创建 src/api/user.ts

import request from '@/utils/request';

export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
}

export interface LoginParams {
  email: string;
  password: string;
  rememberMe?: boolean;
}

export interface LoginResult {
  user: User;
  token: string;
  expiresIn: number;
}

export const userApi = {
  login(params: LoginParams): Promise<LoginResult> {
    return request.post('/auth/login', params);
  },
  getCurrentUser(): Promise<User> {
    return request.get('/user/me');
  },
  updateProfile(data: Partial<User>): Promise<User> {
    return request.put('/user/profile', data);
  }
};

8.3 在组件中调用

<script setup lang="ts">
import { ref } from 'vue';
import { userApi, type User } from '@/api/user';

const currentUser = ref<User | null>(null);
const loading = ref(false);

const fetchUser = async () => {
  try {
    loading.value = true;
    currentUser.value = await userApi.getCurrentUser();
  } catch (error) {
    console.error('获取用户信息失败:', error);
  } finally {
    loading.value = false;
  }
};
</script>

9. UI 组件库集成:Element Plus

Element Plus 是 Vue 3 生态中最成熟的桌面端组件库,提供了丰富的 UI 组件和良好的 TypeScript 支持。

9.1 安装与全局引入

pnpm add element-plus @element-plus/icons-vue

main.ts 中注册:

import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';

const app = createApp(App);
app.use(ElementPlus);
app.mount('#app');

9.2 典型表单示例

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';

interface UserForm {
  name: string;
  email: string;
  age: number | null;
}

const formRef = ref<FormInstance>();
const formData = reactive<UserForm>({
  name: '',
  email: '',
  age: null
});

const rules: FormRules<UserForm> = {
  name: [{ required: true, message: '必填', trigger: 'blur' }],
  email: [{ required: true, message: '必填', trigger: 'blur' },
           { type: 'email', message: '邮箱格式错误', trigger: 'blur' }],
  age: [{ required: true, message: '必填', trigger: 'blur' },
        { type: 'number', min: 0, max: 150, message: '年龄不合理', trigger: 'blur' }]
};

const submit = async () => {
  if (!formRef.value) return;
  const valid = await formRef.value.validate().catch(() => false);
  if (valid) {
    ElMessage.success('提交成功');
    // 调用 API
  }
};
</script>

<template>
  <el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
    <el-form-item label="姓名" prop="name">
      <el-input v-model="formData.name" />
    </el-form-item>
    <el-form-item label="邮箱" prop="email">
      <el-input v-model="formData.email" />
    </el-form-item>
    <el-form-item label="年龄" prop="age">
      <el-input-number v-model="formData.age" :min="0" />
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="submit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

10. 测试:单元测试与 E2E 测试简介

10.1 单元测试(Vitest + Vue Test Utils)

如果项目创建时已选择 Vitest,可直接使用;否则安装:

pnpm add -D vitest @vue/test-utils jsdom

package.json 中添加脚本:

"scripts": {
  "test": "vitest"
}

编写组件测试 src/components/__tests__/Counter.spec.ts

import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from '../Counter.vue';

describe('Counter.vue', () => {
  it('点击按钮会增加计数', async () => {
    const wrapper = mount(Counter);
    const button = wrapper.find('button');
    expect(wrapper.text()).toContain('0');
    await button.trigger('click');
    expect(wrapper.text()).toContain('1');
  });
});

10.2 E2E 测试(Cypress)

Cypress 提供了强大的端到端测试能力:

pnpm add -D cypress

通过 npx cypress open 打开测试面板,可以编写模拟用户真实操作的脚本,比如登录流程、页面跳转等。

11. 项目构建与部署

11.1 生产环境构建

pnpm build

构建产物在 dist/ 目录,可部署到任何静态服务器,如 Nginx、Vercel、Netlify 等。

11.2 部署到 Vercel(免费)

  • 将项目推送到 GitHub 仓库
  • 访问 https://vercel.com 导入仓库
  • Vercel 会自动检测 Vite 项目并配置构建命令
  • 点击部署,即可获得一个公网可访问的地址

11.3 环境变量

在项目根目录创建 .env.production.env.development,定义环境变量:

# .env.development
VITE_API_BASE_URL=http://localhost:3000/api

在代码中通过 import.meta.env.VITE_API_BASE_URL 读取,构建时会被替换。

12. 总结与学习资源

恭喜你!通过这篇超过千行的实战教程,你已经系统掌握了 Vue 3 + TypeScript 全栈开发的核心技能:

  • ✅ 使用 Vite 构建现代化 Vue 项目
  • ✅ TypeScript 的基础类型、接口、泛型在 Vue 中的运用
  • ✅ Vue 3 组合式 API:refreactive、计算属性、侦听器、生命周期
  • ✅ 组件通信:Props、Emits、Slots 的 TS 类型定义
  • ✅ Vue Router 4 的路由配置、动态路由、导航守卫
  • ✅ Pinia 状态管理的完整实践(Token 持久化、类型安全)
  • ✅ Axios 拦截器封装与类型安全的 API 调用
  • ✅ Element Plus 组件库的集成与使用
  • ✅ 单元测试与 E2E 测试的基础配置
  • ✅ 生产环境构建与部署

📚 推荐学习资源

  • Vue 3 官方文档:https://cn.vuejs.org/
  • TypeScript 中文手册:https://www.typescriptlang.org/zh/
  • Vite 官方文档:https://cn.vitejs.dev/
  • Pinia 官方文档:https://pinia.vuejs.org/zh/
  • Vue Router 官方文档:https://router.vuejs.org/zh/
  • Element Plus 官方文档:https://element-plus.org/zh-CN/
  • Vitest 官方文档:https://cn.vitest.dev/
  • Vue 3 技术揭秘(强烈推荐):https://ustbhuangyi.github.io/vue-analysis/

🚀 下一步行动

  1. 动手实践:依照本教程创建一个完整的“任务管理”或“博客系统”练手项目
  2. 阅读官方文档:深入每个工具的更多高级特性
  3. 加入社区:关注 Vue 官方微信公众号、参与 GitHub Discussions 和 Stack Overflow
  4. 持续学习:探索 VueUse 工具库、Nuxt.js 全栈框架、Serverless 部署等进阶主题

记住:学会一门技术最好的方式就是立即用它做一个小项目。如果你在实践过程中遇到任何问题,欢迎查阅文档或到 Vue Land 等社区提问。

前端开发的世界精彩纷呈,祝你在这条黄金赛道上越走越远!🎉

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐