07. TypeScript实战:项目配置与最佳实践

TypeScript 实战:项目配置与最佳实践

TypeScript Config

学会了 TypeScript 的类型系统,现在我们来看看如何在实际项目中配置和使用 TypeScript。这一期,我们学习 tsconfig.json、模块系统、声明文件和工程化实践。

tsconfig.json 详解

基本结构

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

常用配置项

目标和模块

{
  "compilerOptions": {
    // 编译目标 ES 版本
    "target": "ES2020",

    // 模块系统
    "module": "ESNext",

    // 模块解析策略
    "moduleResolution": "node",

    // 生成 source map
    "sourceMap": true
  }
}

严格模式

{
  "compilerOptions": {
    // 启用所有严格选项
    "strict": true,

    // 允许从没有设置默认导出的模块中导入
    "allowSyntheticDefaultImports": true,

    // 启用 ES 模块互操作性
    "esModuleInterop": true,

    // 不允许隐式 any
    "noImplicitAny": true,

    // 严格的 null 检查
    "strictNullChecks": true
  }
}

路径映射

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}

使用:

import { Button } from '@components/Button'
import { formatDate } from '@utils/date'

输出配置

{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

推荐配置

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "moduleResolution": "node",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "moduleDetection": "force",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}

模块系统

ES 模块

// utils.ts
export function add(a: number, b: number): number {
  return a + b
}

export const PI = 3.14

export default class Calculator {
  // ...
}
// main.ts
import Calculator, { add, PI } from './utils'

命名导出

// 导出多个
export { add, subtract, multiply }

// 重命名导出
export { add as sum }

// 全部导出
export * from './math'

动态导入

async function loadModule() {
  const { add } = await import('./utils')
  console.log(add(1, 2))
}

声明文件

什么是声明文件

声明文件(.d.ts)描述 JavaScript 代码的类型信息。

// math.d.ts
declare function add(a: number, b: number): number
declare const PI: number

编写声明文件

// jquery.d.ts
declare const $: {
  (selector: string): {
    html(content: string): void
    on(event: string, handler: (e: Event) => void): void
  }
  ajax(options: {
    url: string
    method?: string
    data?: any
    success?: (response: any) => void
  }): void
}

模块声明

// my-module.d.ts
declare module 'my-module' {
  export function doSomething(value: string): number
  export interface Options {
    debug: boolean
  }
  export default class MyClass {
    constructor(options: Options)
    run(): void
  }
}

全局声明

// global.d.ts
declare global {
  interface Window {
    myCustomProperty: string
  }

  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production'
      API_URL: string
    }
  }
}

export {}

DefinitelyTyped

大部分库都有社区维护的声明文件:

npm install --save-dev @types/lodash
npm install --save-dev @types/node
npm install --save-dev @types/react

项目结构

目录组织

src/
├── components/     # 组件
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.styles.ts
│   │   ├── Button.types.ts
│   │   └── index.ts
│   └── index.ts
├── hooks/          # 自定义 Hooks
├── utils/          # 工具函数
├── types/          # 全局类型
│   └── index.d.ts
├── api/            # API 接口
├── constants/      # 常量
└── App.tsx

类型文件组织

// types/user.ts
export interface User {
  id: number
  name: string
  email: string
}

export type UserRole = 'admin' | 'user' | 'guest'

export interface UserWithRole extends User {
  role: UserRole
}
// types/api.ts
export interface ApiResponse {
  code: number
  message: string
  data: T
}

export interface PaginatedResponse {
  items: T[]
  total: number
  page: number
  pageSize: number
}
// types/index.ts
export * from './user'
export * from './api'

工具链

ESLint

npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
// .eslintrc.js
module.exports = {
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended'
  ],
  rules: {
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/explicit-function-return-type': 'off'
  }
}

Prettier

npm install --save-dev prettier eslint-config-prettier
// .prettierrc
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5"
}

Husky + lint-staged

npm install --save-dev husky lint-staged
npx husky install
// package.json
{
  "scripts": {
    "lint": "eslint src --ext .ts,.tsx",
    "type-check": "tsc --noEmit"
  },
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}
npx husky add .husky/pre-commit "npx lint-staged"

最佳实践

1. 严格模式

始终启用 strict: true

{
  "compilerOptions": {
    "strict": true
  }
}

2. 避免 any

// Bad
function parse(data: any) {
  return JSON.parse(data)
}

// Good
function parse(data: string): unknown {
  return JSON.parse(data)
}

3. 使用 unknown 代替 any

// Bad
let value: any = getValue()
value.foo()  // 不安全

// Good
let value: unknown = getValue()
if (typeof value === 'object' && value !== null && 'foo' in value) {
  (value as { foo: () => void }).foo()
}

4. 类型优先

// Bad
const user = {
  name: 'Alice',
  age: 30
}

// Good
interface User {
  name: string
  age: number
}

const user: User = {
  name: 'Alice',
  age: 30
}

5. 使用类型推断

// Bad
let x: number = 10
let arr: number[] = [1, 2, 3]

// Good(让 TS 推断)
let x = 10
let arr = [1, 2, 3]

6. 使用 const 断言

// 不使用 const 断言
let x = [1, 2]  // number[]
x = [3, 4]      // OK

// 使用 const 断言
let y = [1, 2] as const  // readonly [1, 2]
y = [3, 4]    // Error

7. 使用满足约束

type Colors = 'red' | 'green' | 'blue'

// Bad
const favoriteColor: Colors = 'red'

// Good(更好的类型推断)
const favoriteColor = 'red' as const satisfies Colors

8. 使用模板字面量类型

type EventName = 'click' | 'scroll' | 'mousemove'
type EventHandler = <code>on${Capitalize}
// "onClick" | "onScroll" | "onMousemove"

function addHandler(event: EventHandler, handler: () => void) {
  // ...
}

addHandler('onClick', () => {})  // OK
addHandler('click', () => {})    // Error

调试技巧

使用 tsc --noEmit

只做类型检查,不生成文件:

npx tsc --noEmit

使用 tsc --watch

监视模式:

npx tsc --watch

类型可视化

// 使用工具类型查看推断结果
type ShowType = { [K in keyof T]: T[K] }

type Result = ShowType

常见问题

模块导入问题

// 如果遇到 "Cannot find module" 错误
// 检查 tsconfig.json

{
  "compilerOptions": {
    "moduleResolution": "node",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}

类型扩展

// 扩展第三方类型
declare module 'third-party-lib' {
  interface Options {
    customOption: string
  }
}

类型断言滥用

// Bad
const value = something as any as MyType

// Good
function isMyType(value: unknown): value is MyType {
  return typeof value === 'object' && 
         value !== null && 
         'requiredField' in value
}

if (isMyType(something)) {
  // something 是 MyType
}

总结

TypeScript 项目配置要点:

  • tsconfig.json:正确配置编译选项
  • 模块系统:使用 ES 模块
  • 声明文件:为 JS 库添加类型
  • 工具链:ESLint + Prettier + Husky
  • 最佳实践:严格模式、避免 any、类型优先

下一期,我们学习 TypeScript 与前端框架的结合。


工具推荐

  • ts-node - 直接运行 TypeScript
  • ts-loader - Webpack TypeScript loader
  • tsdx - TypeScript 库开发工具

Views: 18

06. TypeScript基础:类型系统入门

TypeScript 基础:类型系统入门

TypeScript

JavaScript 是动态类型语言,变量类型在运行时才确定。这带来了灵活性,但也导致了很多运行时错误。TypeScript 给 JavaScript 加上了静态类型系统,让错误在编译时就能被发现。

这一期,我们学习 TypeScript 的基础类型系统。

为什么需要 TypeScript

JavaScript 的痛点

// 这段代码在 JavaScript 中完全合法
let name = "Alice"
name = 123  // 没有报错
name.toUpperCase()  // 运行时报错:name.toUpperCase is not a function

TypeScript 的解决方案

let name: string = "Alice"
name = 123  // 编译时报错:Type 'number' is not assignable to type 'string'

TypeScript 的优势

优势 说明
类型安全 编译时发现错误
智能提示 IDE 自动补全
代码文档 类型即文档
重构友好 改代码有信心

安装 TypeScript

全局安装

npm install -g typescript

项目安装

npm install typescript --save-dev

初始化配置

npx tsc --init

这会生成 tsconfig.json 文件。

基础类型

布尔值

let isDone: boolean = false

数字

let decimal: number = 6
let hex: number = 0xf00d
let binary: number = 0b1010
let octal: number = 0o744

字符串

let color: string = "blue"
color = 'red'
color = <code>green

数组

// 方式1:元素类型后面接 []
let list1: number[] = [1, 2, 3]

// 方式2:使用数组泛型
let list2: Array = [1, 2, 3]

元组

元组表示已知元素数量和类型的数组。

let tuple: [string, number]

tuple = ["hello", 10]  // OK
tuple = [10, "hello"]  // Error

访问元素:

console.log(tuple[0].substring(1))  // OK
console.log(tuple[1].substring(1))  // Error, number 没有 substring

枚举

enum Color {
  Red,
  Green,
  Blue
}

let c: Color = Color.Green
console.log(c)  // 1

自定义值:

enum Color {
  Red = 1,
  Green = 2,
  Blue = 4
}

let c: Color = Color.Green

反向映射:

enum Color {
  Red = 1,
  Green,
  Blue
}

let colorName: string = Color[2]
console.log(colorName)  // 'Green'

any

任意类型,放弃类型检查。

let notSure: any = 4
notSure = "maybe a string"
notSure = false

注意:尽量少用 any,否则就失去了 TypeScript 的意义。

unknown

类型安全的 any。

let notSure: unknown = 4

// 必须类型检查后才能使用
if (typeof notSure === "number") {
  console.log(notSure.toFixed(2))
}

void

没有任何类型,通常用于函数返回值。

function warnUser(): void {
  console.log("This is my warning message")
}

null 和 undefined

let u: undefined = undefined
let n: null = null

默认情况下 nullundefined 是所有类型的子类型。

never

永不存在的值的类型。

// 抛出异常的函数
function error(message: string): never {
  throw new Error(message)
}

// 无限循环
function infiniteLoop(): never {
  while (true) {}
}

object

非原始类型。

declare function create(o: object | null): void

create({ prop: 0 })  // OK
create(null)         // OK
create(42)           // Error
create("string")     // Error

类型断言

告诉 TypeScript "我知道这个类型"。

尖括号语法

let someValue: any = "this is a string"

let strLength: number = (someValue).length

as 语法(推荐)

let someValue: any = "this is a string"

let strLength: number = (someValue as string).length

类型推断

TypeScript 会自动推断类型。

let x = 3  // 推断为 number
let y = "hello"  // 推断为 string

// 从右向左推断
let z = x + 1  // number

联合类型

表示可以是多种类型之一。

let id: string | number

id = "abc123"  // OK
id = 123       // OK
id = false     // Error

实际应用:

function formatValue(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase()
  } else {
    return value.toFixed(2)
  }
}

字面量类型

精确到具体的值。

let x: "hello"
x = "hello"  // OK
x = "world"  // Error

联合字面量:

type Direction = "up" | "down" | "left" | "right"

function move(dir: Direction) {
  // ...
}

move("up")     // OK
move("north")  // Error

类型别名

给类型起个名字。

type Name = string
type NameResolver = () => string
type NameOrResolver = Name | NameResolver

function getName(n: NameOrResolver): Name {
  if (typeof n === "string") {
    return n
  } else {
    return n()
  }
}

实践建议

1. 避免使用 any

// Bad
let data: any = fetchData()

// Good
interface User {
  id: number
  name: string
}

let data: User = fetchData()

2. 使用类型推断

// Bad
let x: number = 10

// Good(让 TS 推断)
let x = 10

3. 优先使用 interface

// Bad
type User = {
  name: string
}

// Good(interface 可扩展)
interface User {
  name: string
}

4. 使用严格模式

tsconfig.json 中启用:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

总结

TypeScript 的基础类型系统包括:

  • 原始类型:boolean, number, string
  • 复合类型:array, tuple, enum
  • 特殊类型:any, unknown, void, never
  • 类型操作:联合类型、类型断言、类型推断

掌握了这些,你已经可以用 TypeScript 写代码了。下一期,我们学习接口、泛型和高级类型。


学习资源

Views: 14

05. Vue3生态实战:Router、Pinia与完整项目

Vue3生态实战:Router、Pinia与完整项目

Vue Ecosystem

恭喜你走到最后一期!前四期我们学习了ES6、Vite、Vue3基础和组合式API。现在,让我们把这些知识整合起来,构建一个完整的Vue3应用。

这一期,我们学习Vue Router(路由)和Pinia(状态管理),然后做一个实战项目。

Vue Router

安装

pnpm add vue-router

基础配置

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

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: About
  },
  {
    path: '/users/:id',
    name: 'User',
    component: () => import('@/views/User.vue') // 懒加载
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/NotFound.vue')
  }
]

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

export default router
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(router)
app.mount('#app')
<!-- App.vue -->
<template>
  <nav>
    <router-link to="/">Home</router-link>
    <router-link to="/about">About</router-link>
  </nav>
  <router-view />
</template>

路由参数

<script setup>
import { useRoute } from 'vue-router'

const route = useRoute()

// 获取参数
console.log(route.params.id) // /users/123 → 123
console.log(route.query.page) // /users?page=2 → 2
</script>

编程式导航

<script setup>
import { useRouter } from 'vue-router'

const router = useRouter()

function goToUser(id) {
  router.push({ name: 'User', params: { id } })
}

function goBack() {
  router.back()
}

function replace() {
  router.replace({ name: 'Home' }) // 不留历史记录
}
</script>

嵌套路由

const routes = [
  {
    path: '/settings',
    component: () => import('@/views/Settings.vue'),
    children: [
      {
        path: '', // /settings
        component: () => import('@/views/SettingsProfile.vue')
      },
      {
        path: 'account', // /settings/account
        component: () => import('@/views/SettingsAccount.vue')
      },
      {
        path: 'security', // /settings/security
        component: () => import('@/views/SettingsSecurity.vue')
      }
    ]
  }
]
<!-- Settings.vue -->
<template>
  <div>
    <h1>Settings</h1>
    <nav>
      <router-link to="/settings">Profile</router-link>
      <router-link to="/settings/account">Account</router-link>
      <router-link to="/settings/security">Security</router-link>
    </nav>
    <router-view />
  </div>
</template>

导航守卫

// 全局前置守卫
router.beforeEach((to, from) => {
  // 需要登录
  if (to.meta.requiresAuth && !isAuthenticated()) {
    return { name: 'Login', query: { redirect: to.fullPath } }
  }
})

// 全局后置守卫
router.afterEach((to) => {
  document.title = to.meta.title || 'My App'
})
// 路由元信息
const routes = [
  {
    path: '/admin',
    component: Admin,
    meta: {
      requiresAuth: true,
      title: 'Admin Dashboard'
    }
  }
]

Pinia

Pinia是Vue3官方推荐的状态管理库,比Vuex更简单、更强大。

安装

pnpm add pinia

基础配置

// src/stores/index.js
import { createPinia } from 'pinia'

const pinia = createPinia()
export default pinia
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from './stores'

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

定义Store

// src/stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 组合式API风格(推荐)
export const useUserStore = defineStore('user', () => {
  // State
  const user = ref(null)
  const token = ref(localStorage.getItem('token'))

  // Getters
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => user.value?.name ?? 'Guest')

  // Actions
  async function login(credentials) {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const data = await response.json()

    user.value = data.user
    token.value = data.token
    localStorage.setItem('token', data.token)
  }

  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }

  return {
    user,
    token,
    isLoggedIn,
    userName,
    login,
    logout
  }
})

使用Store

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// 解构state和getters(保持响应性)
const { user, isLoggedIn, userName } = storeToRefs(userStore)

// actions直接解构
const { login, logout } = userStore

// 直接访问
console.log(userStore.user)
</script>

<template>
  <div v-if="isLoggedIn">
    Welcome, {{ userName }}
    <button @click="logout">Logout</button>
  </div>
  <div v-else>
    <button @click="login">Login</button>
  </div>
</template>

选项式API风格

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),

  getters: {
    doubleCount: (state) => state.count * 2
  },

  actions: {
    increment() {
      this.count++
    },

    async fetchCount() {
      const response = await fetch('/api/count')
      this.count = await response.json()
    }
  }
})

持久化

// 使用pinia-plugin-persistedstate
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export const useUserStore = defineStore('user', () => {
  // ...
}, {
  persist: true // 启用持久化
})

实战项目:任务管理应用

让我们把学到的知识整合起来,做一个任务管理应用。

项目结构

src/
├── main.js
├── App.vue
├── router/
│   └── index.js
├── stores/
│   ├── index.js
│   ├── tasks.js
│   └── user.js
├── composables/
│   └── useLocalStorage.js
├── views/
│   ├── Home.vue
│   ├── Login.vue
│   └── Tasks.vue
└── components/
    ├── TaskItem.vue
    └── TaskForm.vue

任务Store

// src/stores/tasks.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useTaskStore = defineStore('tasks', () => {
  const tasks = ref([
    { id: 1, title: 'Learn Vue3', completed: false },
    { id: 2, title: 'Build a project', completed: false }
  ])

  const filter = ref('all') // all, active, completed

  const filteredTasks = computed(() => {
    if (filter.value === 'active') {
      return tasks.value.filter(t => !t.completed)
    }
    if (filter.value === 'completed') {
      return tasks.value.filter(t => t.completed)
    }
    return tasks.value
  })

  const remainingCount = computed(() =>
    tasks.value.filter(t => !t.completed).length
  )

  function addTask(title) {
    tasks.value.push({
      id: Date.now(),
      title,
      completed: false
    })
  }

  function removeTask(id) {
    const index = tasks.value.findIndex(t => t.id === id)
    if (index > -1) {
      tasks.value.splice(index, 1)
    }
  }

  function toggleTask(id) {
    const task = tasks.value.find(t => t.id === id)
    if (task) {
      task.completed = !task.completed
    }
  }

  function clearCompleted() {
    tasks.value = tasks.value.filter(t => !t.completed)
  }

  return {
    tasks,
    filter,
    filteredTasks,
    remainingCount,
    addTask,
    removeTask,
    toggleTask,
    clearCompleted
  }
}, {
  persist: true
})

任务列表组件

<!-- src/views/Tasks.vue -->
<script setup>
import { useTaskStore } from '@/stores/tasks'
import { storeToRefs } from 'pinia'
import TaskItem from '@/components/TaskItem.vue'
import TaskForm from '@/components/TaskForm.vue'

const taskStore = useTaskStore()
const { filteredTasks, filter, remainingCount } = storeToRefs(taskStore)
const { addTask, removeTask, toggleTask, clearCompleted } = taskStore
</script>

<template>
  <div class="tasks">
    <h1>Tasks</h1>
    <TaskForm @add="addTask" />

    <ul class="task-list">
      <TaskItem
        v-for="task in filteredTasks"
        :key="task.id"
        :task="task"
        @toggle="toggleTask(task.id)"
        @remove="removeTask(task.id)"
      />
    </ul>

    <div class="filters">
      <button
        :class="{ active: filter === 'all' }"
        @click="filter = 'all'"
      >
        All
      </button>
      <button
        :class="{ active: filter === 'active' }"
        @click="filter = 'active'"
      >
        Active
      </button>
      <button
        :class="{ active: filter === 'completed' }"
        @click="filter = 'completed'"
      >
        Completed
      </button>
    </div>

    <div class="footer">
      {{ remainingCount }} items left
      <button @click="clearCompleted">Clear completed</button>
    </div>
  </div>
</template>

<style scoped>
.tasks {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.filters button.active {
  font-weight: bold;
}

.task-list {
  list-style: none;
  padding: 0;
}
</style>

任务项组件

<!-- src/components/TaskItem.vue -->
<script setup>
defineProps({
  task: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['toggle', 'remove'])
</script>

<template>
  <li class="task-item" :class="{ completed: task.completed }">
    <input
      type="checkbox"
      :checked="task.completed"
      @change="emit('toggle')"
    />
    <span>{{ task.title }}</span>
    <button @click="emit('remove')">×</button>
  </li>
</template>

<style scoped>
.task-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.task-item.completed span {
  text-decoration: line-through;
  color: #999;
}
</style>

任务表单组件

<!-- src/components/TaskForm.vue -->
<script setup>
import { ref } from 'vue'

const title = ref('')

const emit = defineEmits(['add'])

function handleSubmit() {
  if (title.value.trim()) {
    emit('add', title.value.trim())
    title.value = ''
  }
}
</script>

<template>
  <form class="task-form" @submit.prevent="handleSubmit">
    <input
      v-model="title"
      type="text"
      placeholder="What needs to be done?"
    />
    <button type="submit">Add</button>
  </form>
</template>

<style scoped>
.task-form {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.task-form input {
  flex: 1;
  padding: 10px;
  font-size: 16px;
}
</style>

项目优化

代码分割

// router/index.js
const routes = [
  {
    path: '/admin',
    component: () => import(/* webpackChunkName: "admin" */ '@/views/Admin.vue')
  }
]

懒加载组件

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

const HeavyComponent = defineAsyncComponent(() =>
  import('@/components/HeavyComponent.vue')
)
</script>

构建优化

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui': ['element-plus']
        }
      }
    }
  }
})

系列总结

恭喜你完成了现代前端开发到Vue3的完整学习之旅!

第一期:ES6+、Node.js、npm基础
第二期:Vite构建工具
第三期:Vue3响应式和组件
第四期:组合式API和逻辑复用
第五期:Router、Pinia和实战项目

你现在掌握了:

  • 现代JavaScript语法
  • Vite项目构建
  • Vue3核心概念
  • 组合式API和组合式函数
  • Vue Router路由管理
  • Pinia状态管理
  • 完整项目开发流程

下一步

  • 学习TypeScript(Vue3的最佳伴侣)
  • 探索UI框架(Element Plus、Naive UI)
  • 学习服务端渲染(Nuxt 3)
  • 阅读官方文档(https://vuejs.org/

进阶资源


恭喜!你已经掌握了现代前端开发到Vue3的核心技能。去构建你的下一个项目吧!

Views: 16

04. Vue3组合式API:代码组织的艺术

Vue3组合式API:代码组织的艺术

Composition API

如果你写过大型Vue2项目,一定遇到过这样的困扰:

  • 一个组件几千行,data、methods、computed分散各处
  • 想复用一段逻辑,只能用mixin,然后命名冲突
  • TypeScript支持不完善,类型推断总有问题

组合式API(Composition API)就是为了解决这些问题而生的。它让你按功能组织代码,而不是按选项组织。

选项式API vs 组合式API

选项式API(Options API)


export default {
  data() {
    return {
      count: 0,
      user: null
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    },
    fetchUser() {
      // ...
    }
  },
  mounted() {
    this.fetchUser()
  }
}

问题:

  • 相关代码分散在不同选项中
  • 逻辑复用困难(mixin有命名冲突)
  • TypeScript类型推断不好

组合式API(Composition API)


import { ref, computed, onMounted } from 'vue'

// 计数器逻辑
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
  count.value++
}

// 用户逻辑
const user = ref(null)
async function fetchUser() {
  user.value = await fetch('/api/user').then(r => r.json())
}
onMounted(fetchUser)

优势:

  • 相关代码放在一起
  • 逻辑可以提取成独立函数
  • 完美的TypeScript支持

setup函数

``是setup函数的语法糖:

<!-- 等价于 -->

export default {
  setup() {
    const count = ref(0)
    return { count }
  }
}

<!-- 语法糖 -->

const count = ref(0)

所有顶层绑定自动暴露给模板。

Props和Emits


// 定义props
const props = defineProps({
  title: String
})

// 定义emits
const emit = defineEmits(['update'])

// 使用
console.log(props.title)
emit('update', data)

响应式转换

Vue3提供响应式语法糖(实验性):


// 启用后,ref可以省略.value
let count = $ref(0)
count++  // 自动处理

// 解构保持响应性
const { x, y } = $(useMouse())

组合式函数

组合式函数(Composables)是Vue3复用逻辑的核心方式。

基础示例

// useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return {
    count,
    doubleCount,
    increment,
    decrement,
    reset
  }
}
<!-- 使用 -->

import { useCounter } from './useCounter'

const { count, doubleCount, increment } = useCounter(10)

  <p>Count: {{ count }}</p>
  <p>Double: {{ doubleCount }}</p>
  <button>+1</button>

实用组合式函数

useMouse - 追踪鼠标位置:

import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

useFetch - 数据获取:

import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function fetch() {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  // 支持响应式URL
  watchEffect(fetch)

  return { data, error, loading, refetch: fetch }
}

import { useFetch } from './useFetch'

const { data, loading, error } = useFetch('/api/users')

  <div>Loading...</div>
  <div>Error: {{ error.message }}</div>
  <div>
    <ul>
      <li>
        {{ user.name }}
      </li>
    </ul>
  </div>

useLocalStorage - 本地存储:

import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const data = ref(stored ? JSON.parse(stored) : defaultValue)

  watch(
    data,
    (newVal) => {
      if (newVal === null) {
        localStorage.removeItem(key)
      } else {
        localStorage.setItem(key, JSON.stringify(newVal))
      }
    },
    { deep: true }
  )

  return data
}

import { useLocalStorage } from './useLocalStorage'

const theme = useLocalStorage('theme', 'light')

  <button>
    Toggle Theme
  </button>

provide/inject

跨层级组件通信:

<!-- 祖先组件 -->

import { provide, ref } from 'vue'

const theme = ref('dark')
provide('theme', theme)
provide('toggleTheme', () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
})
<!-- 后代组件(任意层级) -->

import { inject } from 'vue'

const theme = inject('theme')
const toggleTheme = inject('toggleTheme')

  <div>
    <button>Toggle</button>
  </div>

响应式provide

// 使用Symbol避免命名冲突
export const ThemeKey = Symbol('theme')

// 提供默认值
const theme = inject(ThemeKey, 'light')

// 只读
provide(ThemeKey, readonly(theme))

模板引用


import { ref, onMounted } from 'vue'

const inputRef = ref(null)

onMounted(() => {
  inputRef.value.focus()
})

v-for中的ref


const listRef = ref([])

function setRef(el) {
  if (el) {
    listRef.value.push(el)
  }
}

  <div>
    {{ item }}
  </div>

组件ref


import ChildComponent from './Child.vue'

const childRef = ref(null)

function callChildMethod() {
  childRef.value.someMethod()
}

异步组件


import { defineAsyncComponent } from 'vue'

// 异步加载
const AsyncComponent = defineAsyncComponent(() =>
  import('./HeavyComponent.vue')
)

// 带加载状态
const AsyncComponentWithOptions = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000
})

最佳实践

命名约定

// 组合式函数以use开头
useCounter()
useMouse()
useFetch()

// 私有函数以_开头或使用__
function _internalHelper() {}

返回值

// 返回响应式引用
export function useCounter() {
  const count = ref(0)
  return { count }  // 不要返回 count.value
}

// 使用时保持响应性
const { count } = useCounter()
// count是ref,仍然是响应式的

清理副作用

export function useEventListener(target, event, callback) {
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))

  // 或者返回清理函数
  return () => target.removeEventListener(event, callback)
}

// 使用
const cleanup = useEventListener(window, 'resize', handleResize)
cleanup()  // 手动清理

参数处理

// 支持ref和原始值
import { toValue } from 'vue'

export function useFetch(url) {
  // toValue会解包ref
  const finalUrl = toValue(url)

  watchEffect(() => {
    fetch(toValue(url))  // 响应式URL
  })
}

// 使用
const url = ref('/api/users')
const { data } = useFetch(url)  // url变化时自动重新fetch

小结

你现在掌握了:

  • 组合式API的核心思想(按功能组织代码)
  • 组合式函数的编写和使用
  • provide/inject跨层级通信
  • 模板引用和异步组件
  • 最佳实践和命名约定

下一期,我们进入Vue3生态,学习Vue Router路由和Pinia状态管理,完成一个实战项目。


练习任务

  1. 编写一个useCounter组合式函数
  2. 编写一个useDebounce防抖函数
  3. 使用provide/inject实现主题切换
  4. 实现一个异步加载的组件

下期预告:《Vue3生态:Router、Pinia与实战项目》—— 路由怎么配?状态怎么管?来做一个完整项目!

Views: 13

03. Vue3基础:响应式系统和组件入门

Vue3基础:响应式系统和组件入门

Vue Logo

终于进入Vue3的世界了!这一期,我们学习Vue3最核心的概念:响应式系统和组件。

理解这两个概念,你就掌握了Vue3的80%。

响应式系统

什么是响应式?

// 普通变量 - 不是响应式的
let count = 0
count++
// Vue不知道count变了,UI不会更新

// 响应式变量 - Vue会追踪变化
const count = ref(0)
count.value++
// Vue知道count变了,自动更新UI

响应式 = 数据变化时,UI自动更新。

ref vs reactive

Vue3提供两种响应式API:

ref:包装单个值

import { ref } from 'vue'

const count = ref(0)
const message = ref('Hello')
const user = ref({ name: 'Vue' })

// 读取和修改需要.value
console.log(count.value)  // 0
count.value++

reactive:包装对象/数组

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: {
    name: 'Vue'
  }
})

// 直接访问属性
console.log(state.count)  // 0
state.count++

// 数组
const list = reactive([1, 2, 3])
list.push(4)

怎么选?

场景 推荐
基本类型(数字、字符串) ref
对象 都可以
需要整体替换 ref
解构友好 reactive

我的建议:默认用ref,对象很大时用reactive

模板中自动解包


import { ref } from 'vue'

const count = ref(0)

  <!-- 不需要.value,自动解包 -->
  <p>Count: {{ count }}</p>
  <button>+1</button>

响应式丢失问题

// ❌ 解构会丢失响应性
const state = reactive({ count: 0 })
let { count } = state  // count是普通数字
count++  // 不会触发更新!

// ✅ 使用toRefs
import { toRefs } from 'vue'
const { count } = toRefs(state)  // count是ref
count.value++  // 会触发更新

// ✅ 使用toRef(单个属性)
import { toRef } from 'vue'
const count = toRef(state, 'count')

computed

计算属性:基于响应式数据派生新值,自动缓存。


import { ref, computed } from 'vue'

const firstName = ref('Vue')
const lastName = ref('3')

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

// 可写计算属性
const fullName2 = computed({
  get: () => ${firstName.value} ${lastName.value},
  set: (val) => {
    [firstName.value, lastName.value] = val.split(' ')
  }
})

  

{{ fullName }}

watch

侦听数据变化:


import { ref, watch, watchEffect } from 'vue'

const count = ref(0)

// watch:明确指定侦听源
watch(count, (newVal, oldVal) => {
  console.log(<code>count changed: ${oldVal} -> ${newVal})
})

// watch多个源
watch([count, anotherRef], ([newCount, newAnother]) => {
  // ...
})

// watchEffect:自动追踪依赖
watchEffect(() => {
  console.log(count is ${count.value})
})

// 立即执行 + 可停止
const stop = watchEffect(() => {
  // ...
}, { immediate: true })

// 停止侦听
stop()

watch vs watchEffect

  • watch:明确指定侦听源,可访问新旧值
  • watchEffect:自动追踪,适合副作用

组件基础

定义组件


import { ref } from 'vue'

const count = ref(0)

  <button>
    Clicked {{ count }} times
  </button>

button {
  color: blue;
}

``是Vue3的语法糖,自动暴露变量给模板。

Props:父传子

<!-- Child.vue -->

// 定义props
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  }
})

// 使用props
console.log(props.title)

  <h1>{{ title }}</h1>
  <p>Count: {{ count }}</p>
<!-- Parent.vue -->

import Child from './Child.vue'

Emits:子传父

<!-- Child.vue -->

const emit = defineEmits(['update', 'delete'])

function handleClick() {
  emit('update', { id: 1, name: 'Vue' })
}

  <button>Update</button>
<!-- Parent.vue -->

import Child from './Child.vue'

function onUpdate(data) {
  console.log('Received:', data)
}

v-model:双向绑定

<!-- 自定义组件支持v-model -->

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const value = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val)
})

Vue3.4+可以用defineModel


const modelValue = defineModel()

插槽

<!-- Card.vue -->

  <div class="card">
    <div class="header">

    </div>
    <div class="content">

    </div>
    <div class="footer">

    </div>
  </div>
<!-- 使用 -->

    <h1>Title</h1>

  <p>Content goes here</p>

    <button>OK</button>

作用域插槽:

<!-- List.vue -->

const items = ['Apple', 'Banana', 'Orange']

  <ul>
    <li>

    </li>
  </ul>
<!-- 使用 -->

    {{ index + 1 }}. {{ item }}

模板语法

文本插值


  <p>{{ message }}</p>
  <p>{{ count + 1 }}</p>
  <p>{{ formatDate(date) }}</p>

指令


  <!-- 条件渲染 -->
  <div>Type A</div>
  <div>Type B</div>
  <div>Other</div>

  <!-- 列表渲染 -->
  <ul>
    <li>
      {{ item.name }}
    </li>
  </ul>

  <!-- 事件绑定 -->
  <button>Click</button>
  <button>+1</button>
  ...

  <!-- 属性绑定 -->
  <img />
  <div>...</div>
  <div>...</div>

  <!-- 双向绑定 -->

  ...

  <!-- 显示/隐藏 -->
  <div>...</div>

v-if vs v-show

特性 v-if v-show
渲染方式 条件为假时不渲染 始终渲染,用CSS隐藏
切换成本 高(销毁/重建) 低(只改CSS)
初始成本 低(不渲染) 高(总是渲染)

建议:频繁切换用v-show,条件很少改变用v-if

Class和Style绑定


  <!-- 对象语法 -->
  <div></div>

  <!-- 数组语法 -->
  <div></div>

  <!-- 混合 -->
  <div></div>

  <!-- Style对象 -->
  <div></div>

  <!-- Style数组 -->
  <div></div>

生命周期


import { onMounted, onUpdated, onUnmounted } from 'vue'

onMounted(() => {
  console.log('组件已挂载')
})

onUpdated(() => {
  console.log('组件已更新')
})

onUnmounted(() => {
  console.log('组件已卸载')
})

生命周期钩子:

选项式API 组合式API
beforeCreate setup()
created setup()
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated onUpdated
beforeUnmount onBeforeUnmount
unmounted onUnmounted

小结

你现在掌握了:

  • 响应式系统(ref、reactive、computed、watch)
  • 组件通信(props、emits、v-model、插槽)
  • 模板语法(指令、条件、循环)
  • 生命周期钩子

下一期,我们深入学习组合式API - Vue3最强大的特性。你会学会如何组织代码、复用逻辑、提取组合式函数。


练习任务

  1. 创建一个计数器组件,用ref实现
  2. 创建一个父子组件,通过props和emits通信
  3. 实现一个自定义v-model组件
  4. 使用作用域插槽渲染列表

下期预告:《Vue3组合式API:代码组织的艺术》—— 组合式函数怎么写?如何复用逻辑?

Views: 15