Vue 3 与现代 JavaScript 常用语法指南
面向刚接触前端、准备阅读和维护 Vue 3 项目的开发者。
本文示例默认使用 Vue 3、
<script setup>和 TypeScript。阅读顺序建议:JavaScript 基础 -> Vue 模板 -> 响应式 -> 组件通信 -> 实战示例。
快速导航
- JavaScript 基础:第 1 至 10 节,重点掌握变量、函数、对象、数组、模块和异步。
- Vue 模板语法:第 11 至 12 节,重点掌握
:、@、v-if、v-for和v-model。 - Vue 组件开发:第 13 至 22 节,重点掌握响应式、组件通信、生命周期和 TypeScript。
- 实战与复习:第 23 至 28 节,包含完整示例、项目写法、易错点和速查表。
1. 先理解"语法糖"
语法糖不是新能力,而是同一种能力的简便写法。掌握简写的同时,也要知道它展开后是什么。
js
// 普通写法
const verboseUser = {
name: name,
sayHello: function () {
console.log('hello')
},
}
// ES6 语法糖
const conciseUser = {
name,
sayHello() {
console.log('hello')
},
}
Vue 中也有大量语法糖:
vue
<!-- 完整写法 -->
<button v-bind:disabled="loading" v-on:click="submit">提交</button>
<!-- 常用简写 -->
<button :disabled="loading" @click="submit">提交</button>
2. JavaScript、ES6 与 TypeScript 的关系
| 名称 | 作用 | 常见内容 |
|---|---|---|
| JavaScript | 浏览器真正执行的编程语言 | 变量、函数、对象、数组、异步任务 |
| ES6 / ES2015 | 2015 年发布的一次重要 JavaScript 标准 | let、const、箭头函数、解构、Promise、模块化 |
| 现代 JavaScript | ES6 以及后续版本的常用语法统称 | async/await、可选链、空值合并等 |
| TypeScript | 在 JavaScript 上增加类型检查 | interface、type、泛型、参数和返回值类型 |
| Vue | 用状态驱动页面的前端框架 | 模板、响应式、组件、生命周期 |
| Nuxt | 基于 Vue 的应用框架 | 文件路由、布局、自动导入、构建 |
注意:async/await、可选链 ?. 和空值合并 ?? 严格来说不是 ES6,但现代 Vue 项目会高频使用,本文一并介绍。
3. 变量:const、let 与 var
3.1 优先使用 const
js
const name = '张三'
let age = 18
age = 19
- 默认使用
const。 - 确实需要重新赋值时使用
let。 - 新代码不要使用
var,它存在函数作用域和变量提升等容易混淆的问题。
const 表示变量不能重新指向其他值,不表示对象内部不能修改:
js
const user = { name: '张三' }
user.name = '李四' // 可以
// user = { name: '王五' } // 报错:不能重新赋值
4. 字符串与模板字符串
反引号可以在字符串中插入变量,也可以直接换行:
js
const name = '张三'
const age = 18
const text = `姓名:${name},年龄:${age}`
const message = `第一行
第二行`
传统拼接与模板字符串对比:
js
const oldText = '姓名:' + name + ',年龄:' + age
const newText = `姓名:${name},年龄:${age}`
${} 内可以写表达式:
js
const price = 10
const count = 3
const result = `合计:${price * count} 元`
5. 函数常用语法
5.1 函数声明
项目业务方法推荐使用函数声明,名称清晰,并且不会受声明位置影响:
ts
/**
* 计算订单总价。
*
* @param price 商品单价
* @param count 商品数量
* @returns 订单总价
*/
function calculateTotal(price: number, count: number): number {
return price * count
}
5.2 箭头函数
箭头函数适合短回调:
js
const numbers = [1, 2, 3]
const doubled = numbers.map(item => item * 2)
以下写法等价:
js
const verboseAdd = (a, b) => {
return a + b
}
const conciseAdd = (a, b) => a + b
只有一个参数时可以省略括号:
js
const names = users.map(user => user.name)
注意:箭头函数没有自己的 this。对象方法、需要动态 this 的函数不要盲目改成箭头函数。
5.3 默认参数
js
function greet(name = '游客') {
return `你好,${name}`
}
greet() // 你好,游客
greet('张三') // 你好,张三
只有参数为 undefined 或未传递时才会使用默认值;传入 null 不会触发默认值。
5.4 剩余参数 ...
js
function sum(...numbers) {
return numbers.reduce((total, number) => total + number, 0)
}
sum(1, 2, 3) // 6
这里的 ...numbers 表示"把剩余参数收集成数组"。
6. 对象常用语法
6.1 属性简写与方法简写
js
const name = '张三'
const age = 18
const user = {
name,
age,
greet() {
return `你好,我是${this.name}`
},
}
等价于:
js
const user = {
name: name,
age: age,
greet: function () {
return `你好,我是${this.name}`
},
}
6.2 对象解构
js
const user = {
id: 1,
name: '张三',
age: 18,
}
const { name, age } = user
重命名、默认值和嵌套解构:
js
const { name: userName, gender = '未知' } = user
const response = {
data: {
list: [1, 2, 3],
},
}
const {
data: { list },
} = response
函数参数也可以解构:
ts
interface User {
id: number
name: string
}
function printUser({ id, name }: User) {
console.log(id, name)
}
6.3 对象展开 ...
js
const user = { id: 1, name: '张三' }
const updatedUser = {
...user,
name: '李四',
}
后面的同名属性会覆盖前面的属性。展开是浅拷贝,嵌套对象仍然共享引用:
js
const source = {
profile: { city: '北京' },
}
const copied = { ...source }
copied.profile.city = '上海'
console.log(source.profile.city) // 上海
6.4 可选链 ?.
js
const city = user.profile?.address?.city
const firstName = users?.[0]?.name
const result = callback?.()
如果 ?. 左侧是 null 或 undefined,表达式直接返回 undefined,不会继续访问并报错。
6.5 空值合并 ??
js
const pageSize = config.pageSize ?? 20
只有左侧为 null 或 undefined 时才使用右侧默认值。
js
0 || 20 // 20
0 ?? 20 // 0
'' || '默认文字' // 默认文字
'' ?? '默认文字' // 空字符串
当 0、false、空字符串是有效业务值时,应该使用 ??,不要使用 || 设置默认值。
7. 数组常用语法与方法
7.1 数组解构
js
const colors = ['red', 'green', 'blue']
const [first, second] = colors
const [head, ...rest] = colors
交换变量:
js
let a = 1
let b = 2
;[a, b] = [b, a]
开头的分号用于避免这一行被错误地连接到上一条语句。
7.2 数组展开
js
const oldList = [1, 2]
const newList = [...oldList, 3]
const merged = [...oldList, ...newList]
7.3 高频数组方法
js
const users = [
{ id: 1, name: '张三', enabled: true },
{ id: 2, name: '李四', enabled: false },
]
// 转换每一项,返回新数组
const names = users.map(user => user.name)
// 过滤,返回符合条件的新数组
const enabledUsers = users.filter(user => user.enabled)
// 找到第一项,找不到返回 undefined
const user = users.find(user => user.id === 2)
// 找到第一项的下标,找不到返回 -1
const index = users.findIndex(user => user.id === 2)
// 是否至少有一项满足条件
const hasEnabledUser = users.some(user => user.enabled)
// 是否每一项都满足条件
const allEnabled = users.every(user => user.enabled)
// 遍历,不返回有业务意义的新数组
users.forEach(user => console.log(user.name))
汇总计算使用 reduce:
js
const orders = [
{ amount: 10 },
{ amount: 20 },
]
const total = orders.reduce((sum, order) => sum + order.amount, 0)
判断方法是否修改原数组:
| 不修改原数组 | 会修改原数组 |
|---|---|
map、filter、slice、concat |
push、pop、shift、unshift |
find、some、every、reduce |
splice、sort、reverse |
Vue 响应式数组支持两类方法,但维护复杂状态时,返回新数组的写法通常更容易推理。
8. 判断、短路运算与三元表达式
8.1 三元表达式
js
const text = isLogin ? '退出登录' : '立即登录'
适合简单的二选一,不要嵌套多层三元表达式。
8.2 && 与 ||
js
isLogin && loadUserInfo()
const displayName = nickname || username || '游客'
&& 左侧为真时才执行右侧;|| 返回第一个真值。业务代码中如果分支较复杂,普通 if 更容易阅读。
8.3 真值与假值
以下值在条件判断中为假:
js
false
0
-0
0n
''
null
undefined
NaN
空数组 [] 和空对象 {} 都是真值:
js
if ([]) {
console.log('会执行')
}
9. 模块化:import 与 export
命名导出:
ts
// userUtil.ts
export function formatUserName(name: string) {
return name.trim()
}
export const DEFAULT_NAME = '游客'
ts
// 使用方
import { DEFAULT_NAME, formatUserName } from './userUtil'
默认导出:
ts
// appConfig.ts
export default {
pageSize: 20,
}
ts
import appConfig from './appConfig'
一个模块只能有一个默认导出,可以有多个命名导出。命名导入的名称必须对应,默认导入的本地名称可以自行命名。
Nuxt 项目会自动导入部分 Vue API、组合式函数和组件,所以有些文件中看不到 import。遇到不认识的标识符,先搜索项目或查看自动导入类型声明,不要默认它是浏览器原生能力。
10. Promise 与 async/await
Promise 表示一个未来才会完成或失败的异步结果:
js
fetch('/api/users')
.then(response => response.json())
.then(data => {
console.log(data)
})
.catch(error => {
console.error(error)
})
async/await 是更容易阅读的异步写法:
ts
async function loadUsers() {
try {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
console.log(data)
}
catch (error) {
console.error('加载用户失败', error)
}
}
注意事项:
async函数始终返回 Promise。await只能直接写在async函数或支持顶层await的模块中。- 独立请求可以用
Promise.all并行执行。 - 存在前后依赖的请求必须按顺序等待。
ts
const [users, roles] = await Promise.all([
loadUsers(),
loadRoles(),
])
不要在 forEach 中期待 await 按顺序等待:
ts
// 错误理解:外层不会等待所有回调完成
items.forEach(async (item) => {
await saveItem(item)
})
// 串行执行
for (const item of items) {
await saveItem(item)
}
// 并行执行
await Promise.all(items.map(item => saveItem(item)))
11. Vue 单文件组件的基本结构
vue
<script setup lang="ts">
const title = ref('用户列表')
</script>
<template>
<main>
<h1>{{ title }}</h1>
</main>
</template>
<style scoped lang="scss">
main {
padding: 16px;
}
</style>
<script setup>:状态、方法、组件逻辑。<template>:页面结构和 Vue 模板语法。<style scoped>:当前组件的局部样式。lang="ts":脚本使用 TypeScript。lang="scss":样式使用 SCSS。scoped:样式选择器只作用于当前组件。
<script setup> 是 Vue 3 编译期语法糖,其中声明的变量、函数和导入的组件都可以直接在模板中使用。
12. Vue 模板常用语法
12.1 文本插值 {``{ }}
vue
<p>{{ user.name }}</p>
<p>{{ count + 1 }}</p>
<p>{{ isLogin ? '已登录' : '未登录' }}</p>
插值内适合简单表达式,不要放赋值语句、循环或复杂业务逻辑。复杂结果应放入 computed。
12.2 属性绑定 v-bind 与 :
vue
<img v-bind:src="avatarUrl" v-bind:alt="userName">
<img :src="avatarUrl" :alt="userName">
布尔属性:
vue
<button :disabled="loading">提交</button>
动态 class:
vue
<div :class="{ active: isActive, disabled: !enabled }" />
<div :class="['base', statusClass]" />
动态 style:
vue
<div :style="{ color: textColor, fontSize: `${fontSize}px` }" />
同名属性可以进一步简写。以下写法需要当前 Vue 编译器支持:
vue
<!-- :id="id" 的简写 -->
<div :id />
维护已有项目时,优先跟随项目当前写法;不确定编译版本时使用 :id="id" 最稳妥。
12.3 事件绑定 v-on 与 @
vue
<button v-on:click="handleClick">点击</button>
<button @click="handleClick">点击</button>
<button @click="deleteUser(user.id)">删除</button>
接收原生事件对象:
vue
<input @input="handleInput">
<button @click="handleClick($event, user.id)">点击</button>
ts
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
console.log(target.value)
}
12.4 事件修饰符
vue
<form @submit.prevent="submitForm">
<button type="submit">保存</button>
</form>
<button @click.stop="handleButtonClick">阻止冒泡</button>
<button @click.once="handleOnce">只触发一次</button>
<input @keyup.enter="search">
常用修饰符:
| 修饰符 | 作用 |
|---|---|
.prevent |
调用 event.preventDefault() |
.stop |
调用 event.stopPropagation() |
.once |
监听器最多触发一次 |
.self |
只有点击元素自身才触发 |
.enter |
按下回车键时触发 |
12.5 条件渲染 v-if 与 v-show
vue
<p v-if="loading">加载中...</p>
<p v-else-if="errorMessage">{{ errorMessage }}</p>
<UserList v-else :users="users" />
<aside v-show="visible">侧边栏</aside>
| 指令 | 实际行为 | 适用场景 |
|---|---|---|
v-if |
条件为假时不创建或销毁 DOM | 条件较少变化、组件较重 |
v-show |
DOM 始终存在,只切换 display |
频繁显示和隐藏 |
12.6 列表渲染 v-for
vue
<ul>
<li v-for="(user, index) in users" :key="user.id">
{{ index + 1 }}. {{ user.name }}
</li>
</ul>
对象遍历:
vue
<div v-for="(value, key) in user" :key="key">
{{ key }}:{{ value }}
</div>
注意事项:
v-for必须提供稳定且唯一的key。- 优先使用数据库 ID,不要随意使用数组下标作为
key。 - 不建议在同一元素上同时写
v-if和v-for,先用computed过滤列表。
ts
const enabledUsers = computed(() => {
return users.value.filter(user => user.enabled)
})
12.7 双向绑定 v-model
vue
<input v-model="keyword">
<textarea v-model="description" />
<input v-model="agreed" type="checkbox">
v-model 是"传值 + 监听更新事件"的语法糖。原生输入框可以近似理解为:
vue
<input
:value="keyword"
@input="keyword = ($event.target as HTMLInputElement).value"
>
常用修饰符:
vue
<input v-model.trim="name">
<input v-model.number="age" type="number">
<input v-model.lazy="description">
| 修饰符 | 作用 |
|---|---|
.trim |
去除输入值首尾空格 |
.number |
尝试将输入值转换成数字 |
.lazy |
使用 change 事件而不是每次 input 都更新 |
Naive UI 常用具名 v-model:
vue
<n-input v-model:value="keyword" />
<n-modal v-model:show="modalVisible" />
<n-checkbox v-model:checked="agreed" />
v-model:value="keyword" 展开后是:
vue
<n-input
:value="keyword"
@update:value="keyword = $event"
/>
12.8 v-html
vue
<div v-html="htmlContent" />
v-html 会把字符串当作 HTML 插入。不要直接渲染用户输入或未经清洗的后端内容,否则可能产生 XSS 安全问题。普通文本一律使用 {``{ }}。
13. Vue 响应式:ref 与 reactive
13.1 ref
ref 可以保存基本类型,也可以保存对象和数组:
ts
const count = ref(0)
const keyword = ref('')
const users = ref<User[]>([])
count.value++
users.value.push({ id: 1, name: '张三' })
在 <script> 中需要 .value,在模板中会自动解包:
vue
<p>{{ count }}</p>
<button @click="count++">加一</button>
13.2 reactive
reactive 只适合对象、数组等引用类型:
ts
const form = reactive({
name: '',
age: 18,
})
form.name = '张三'
不要直接解构 reactive 对象的基本类型属性,否则会失去响应式联系:
ts
const form = reactive({ name: '张三', age: 18 })
// name 和 age 只是普通变量,后续不会跟随 form 更新
const { name, age } = form
// 需要解构时使用 toRefs
const { name: reactiveName, age: reactiveAge } = toRefs(form)
13.3 怎么选择
| 场景 | 推荐 |
|---|---|
| 字符串、数字、布尔值 | ref |
| 可能整体替换的数组或对象 | ref |
| 属性较多、按字段修改的表单对象 | reactive |
| 团队希望统一心智模型 | 全部使用 ref 也可以 |
不要用普通变量保存需要驱动界面的状态:
ts
let nonReactiveCount = 0 // 修改后不会触发模板更新
const reactiveCount = ref(0) // 正确
14. 派生状态:computed
当一个值可以根据其他响应式状态计算得出时,使用 computed:
ts
const firstName = ref('三')
const lastName = ref('张')
const fullName = computed(() => {
return `${lastName.value}${firstName.value}`
})
vue
<p>{{ fullName }}</p>
列表搜索示例:
ts
const keyword = ref('')
const users = ref<User[]>([])
const filteredUsers = computed(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
if (!normalizedKeyword) {
return users.value
}
return users.value.filter(user =>
user.name.toLowerCase().includes(normalizedKeyword),
)
})
computed 有缓存,依赖不变时不会重复计算。它应该负责"计算结果",不要在其中发请求、改其他状态或操作 DOM。
15. 监听变化:watch 与 watchEffect
15.1 watch
ts
watch(keyword, (newValue, oldValue) => {
console.log('关键词变化', oldValue, '->', newValue)
})
监听对象的某个属性:
ts
watch(
() => form.userId,
(userId) => {
loadUserDetail(userId)
},
)
监听多个来源:
ts
watch([keyword, page], ([newKeyword, newPage]) => {
console.log(newKeyword, newPage)
})
常用配置:
ts
watch(
() => form.filters,
() => {
search()
},
{
deep: true,
immediate: true,
},
)
immediate: true:创建监听后立即执行一次。deep: true:深度监听嵌套属性;大型对象使用时要注意性能。
15.2 watchEffect
ts
watchEffect(() => {
document.title = `${title.value}(${count.value})`
})
watchEffect 会自动收集执行期间读取的响应式依赖,并立即运行一次。需要明确新旧值、精确控制依赖时使用 watch。
15.3 与 computed 的区别
| API | 用途 |
|---|---|
computed |
根据状态计算另一个值 |
watch |
状态变化后执行请求、日志、存储等副作用 |
watchEffect |
自动收集依赖并执行副作用 |
16. 生命周期
ts
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
| 生命周期 | 常见用途 |
|---|---|
onBeforeMount |
组件挂载到页面前的准备 |
onMounted |
访问 DOM、绑定浏览器事件、初始化第三方库 |
onBeforeUpdate |
Vue 更新 DOM 前读取旧状态,较少使用 |
onUpdated |
DOM 更新完成后的处理,避免在这里持续修改状态 |
onBeforeUnmount |
组件销毁前处理收尾工作 |
onUnmounted |
清除事件、定时器、订阅和第三方实例 |
等待本轮状态变化反映到 DOM:
ts
async function openEditor() {
visible.value = true
await nextTick()
editorRef.value?.focus()
}
请求不依赖 DOM 时不必机械地全部放进 onMounted。在 Nuxt 中访问 window、document 等浏览器对象时,要确认代码只在客户端执行。
17. 父子组件通信:Props 与 Emit
17.1 父组件向子组件传值
子组件:
vue
<script setup lang="ts">
interface Props {
userId: number
title?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '用户详情',
})
</script>
<template>
<h2>{{ props.title }}</h2>
<p>用户 ID:{{ props.userId }}</p>
</template>
父组件:
vue
<UserDetail :user-id="currentUserId" title="个人资料" />
要点:
- JavaScript 中的
userId在模板属性中通常写成user-id。 - Props 是只读输入,子组件不要直接修改。
defineProps和withDefaults是编译期宏,不需要手动导入。
17.2 子组件通知父组件
子组件:
vue
<script setup lang="ts">
const emit = defineEmits<{
saved: [id: number]
cancelled: []
}>()
/**
* 通知父组件当前用户已经保存成功。
*
* @param id 已保存的用户 ID
* @returns 无返回值
*/
function notifySaved(id: number) {
emit('saved', id)
}
</script>
<template>
<button @click="notifySaved(1)">保存</button>
<button @click="emit('cancelled')">取消</button>
</template>
父组件:
vue
<UserEditor
@saved="handleSaved"
@cancelled="editorVisible = false"
/>
数据通常向下通过 Props 传递,事件向上通过 Emit 通知,这叫"单向数据流"。
18. 组件上的 v-model
父组件:
vue
<UserNameInput v-model="userName" />
它展开后是:
vue
<UserNameInput
:model-value="userName"
@update:model-value="userName = $event"
/>
子组件的兼容写法:
vue
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
/**
* 将输入框的新值通知给父组件,完成组件双向绑定。
*
* @param event 原生输入事件
* @returns 无返回值
*/
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
<template>
<input :value="props.modelValue" @input="handleInput">
</template>
较新的 Vue 版本还支持 defineModel 语法糖:
vue
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
<template>
<input v-model="model">
</template>
使用 defineModel 前要确认项目的 Vue 和编译器版本支持。维护老项目时,Props + update:modelValue 的写法兼容性更明确。
具名 v-model:
vue
<!-- 父组件 -->
<UserDialog v-model:show="dialogVisible" />
ts
// 子组件的事件约定
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{
'update:show': [value: boolean]
}>()
19. 插槽 Slot
插槽让父组件把一段模板传给子组件。
子组件:
vue
<template>
<section class="panel">
<header>
<slot name="header" />
</header>
<slot />
</section>
</template>
父组件:
vue
<BasePanel>
<template #header>
<h2>用户信息</h2>
</template>
<p>这里是默认插槽内容</p>
</BasePanel>
#header 是 v-slot:header 的简写。
作用域插槽允许子组件把数据交给父组件模板:
vue
<!-- 子组件 -->
<slot name="item" :user="user" :index="index" />
vue
<!-- 父组件 -->
<template #item="{ user, index }">
{{ index + 1 }}. {{ user.name }}
</template>
20. 模板引用 ref
访问 DOM:
vue
<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)
/**
* 让搜索输入框获得焦点。
*
* @returns 无返回值
*/
function focusInput() {
inputRef.value?.focus()
}
</script>
<template>
<input ref="inputRef">
<button @click="focusInput">聚焦</button>
</template>
访问子组件公开的方法时,子组件需要显式暴露:
ts
// 子组件
function reset() {
// 重置组件内部状态
}
defineExpose({ reset })
defineExpose 也是 <script setup> 编译期宏。
21. 跨层组件通信:provide 与 inject
祖先组件:
ts
const theme = ref<'light' | 'dark'>('light')
provide('theme', theme)
后代组件:
ts
const theme = inject<Ref<'light' | 'dark'>>('theme')
适用于主题、表单上下文、组件库上下文等"同一组件树内跨多层共享"的数据。跨页面、跨布局的全局业务状态通常更适合 Pinia。
大型项目建议使用 InjectionKey,避免字符串 key 冲突并获得完整类型提示。
22. TypeScript 在 Vue 中的常用写法
22.1 基础类型
ts
const name = ref<string>('')
const age = ref<number>(0)
const loading = ref<boolean>(false)
const selectedId = ref<number | null>(null)
能够自动推断时不必重复标注:
ts
const name = ref('')
const loading = ref(false)
空数组通常需要明确元素类型:
ts
interface User {
id: number
name: string
enabled: boolean
}
const users = ref<User[]>([])
22.2 interface 与 type
ts
interface User {
id: number
name: string
avatar?: string
}
type Status = 'idle' | 'loading' | 'success' | 'error'
type UserId = number | string
- 对象结构常用
interface。 - 联合类型、工具类型、别名常用
type。 ?表示属性可选。
22.3 避免滥用 any
ts
const data = ref<any>(null) // 类型检查基本失效
未知外部数据优先使用 unknown,验证后再使用:
ts
function printMessage(value: unknown) {
if (typeof value === 'string') {
console.log(value.toUpperCase())
}
}
类型断言不是数据转换:
ts
const input = event.target as HTMLInputElement
as 只是告诉 TypeScript"按这个类型检查",不会在运行时把错误数据变正确。
23. 一个完整的 Vue 列表示例
vue
<script setup lang="ts">
interface User {
id: number
name: string
enabled: boolean
}
const keyword = ref('')
const loading = ref(false)
const users = ref<User[]>([
{ id: 1, name: '张三', enabled: true },
{ id: 2, name: '李四', enabled: false },
])
const filteredUsers = computed(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
if (!normalizedKeyword) {
return users.value
}
return users.value.filter(user =>
user.name.toLowerCase().includes(normalizedKeyword),
)
})
/**
* 切换指定用户的启用状态。
*
* @param userId 需要切换状态的用户 ID
* @returns 无返回值
*/
function toggleUser(userId: number) {
const user = users.value.find(item => item.id === userId)
if (user) {
user.enabled = !user.enabled
}
}
/**
* 删除指定用户。
*
* @param userId 需要删除的用户 ID
* @returns 无返回值
*/
function deleteUser(userId: number) {
users.value = users.value.filter(user => user.id !== userId)
}
</script>
<template>
<main class="user_page">
<input v-model.trim="keyword" placeholder="请输入用户名">
<p v-if="loading">
加载中...
</p>
<p v-else-if="filteredUsers.length === 0">
暂无数据
</p>
<ul v-else>
<li v-for="user in filteredUsers" :key="user.id">
<span :class="{ disabled: !user.enabled }">
{{ user.name }}
</span>
<button @click="toggleUser(user.id)">
{{ user.enabled ? '禁用' : '启用' }}
</button>
<button @click="deleteUser(user.id)">
删除
</button>
</li>
</ul>
</main>
</template>
<style scoped>
.user_page {
padding: 16px;
}
.disabled {
color: #999;
text-decoration: line-through;
}
</style>
这个例子串联了:
interface数据类型。ref响应式状态。computed派生列表。v-model.trim输入绑定。v-if / v-else-if / v-else状态展示。v-for列表渲染。:class动态样式。@click事件绑定。find、filter和箭头函数。
24. 本项目中最常见的写法
24.1 Vue 与 Nuxt API 自动导入
本项目配置了自动导入,因此通常可以直接使用:
ts
const route = useRoute()
const router = useRouter()
const keyword = ref('')
const count = computed(() => keyword.value.length)
不需要在每个文件中重复编写:
ts
import { computed, ref } from 'vue'
但自动导入是 Nuxt 的构建能力,不是 JavaScript 本身的语法。
24.2 页面元信息
ts
definePageMeta({
title: '用户管理',
layout: 'backstage',
name: 'userManage',
authKey: 'UserManage',
})
definePageMeta 是 Nuxt 编译宏,用于配置页面标题、布局、路由名和项目权限信息。
24.3 Naive UI 的具名双向绑定
vue
<n-input v-model:value="form.name" />
<n-select v-model:value="form.roleId" :options="roleOptions" />
<n-switch v-model:value="form.enabled" />
<n-modal v-model:show="modalVisible" />
不要把 Naive UI 的 v-model:value 机械地改成原生输入框常见的 v-model,组件的属性和更新事件名称由组件库约定。
24.4 项目 API 请求
本项目业务请求优先使用 useAppFetch,不要在页面中随意重复封装 fetch 或 Axios:
ts
const loading = ref(false)
const rows = ref<User[]>([])
const { useLoadingRequest } = useAppFetch()
/**
* 加载用户列表,并同步页面加载状态和列表数据。
*
* @returns 请求完成后的 Promise
*/
async function loadUsers() {
await useLoadingRequest({
k: 'backstage/userList',
method: 'get',
params: {
keyword: keyword.value,
},
loadings: [loading],
state: rows,
})
}
接口 key 和准确参数以实际模块配置与后端接口为准。
25. 新手高频错误
25.1 忘记 .value
ts
const count = ref(0)
count++ // 错误
count.value++ // 正确
模板中不需要 .value:
vue
<p>{{ count }}</p>
25.2 直接修改 Props
ts
const props = defineProps<{ count: number }>()
props.count++ // 错误:Props 是只读的
应通过 emit 通知父组件,或复制到本地状态后明确管理同步关系。
25.3 把 computed 当普通变量修改
ts
const fullName = computed(() => `${lastName.value}${firstName.value}`)
// fullName.value = '李四' // 只读 computed 不能直接修改
25.4 v-for 使用不稳定的 key
vue
<!-- 列表会增删或排序时不推荐 -->
<li v-for="(user, index) in users" :key="index" />
<!-- 推荐 -->
<li v-for="user in users" :key="user.id" />
25.5 用 map 只做副作用
js
// 不合适:创建了一个没有使用的新数组
users.map(user => console.log(user.name))
// 合适
users.forEach(user => console.log(user.name))
25.6 异步请求缺少错误和加载状态
页面请求至少要考虑:
- 加载中。
- 请求失败。
- 空数据。
- 重复提交。
- 组件卸载后的请求影响。
在本项目中优先通过 useAppFetch 的请求方法统一处理这些状态。
25.7 误解对象和数组比较
js
{} === {} // false
[] === [] // false
对象和数组按引用比较,不是按内容比较。判断空数组使用 list.length === 0,判断对象字段应比较具体字段或使用可靠的深比较工具。
25.8 混淆 null 与 undefined
undefined:通常表示尚未赋值或字段不存在。null:通常表示业务上明确"当前没有值"。
团队接口中应保持统一口径,避免同一字段有时缺失、有时为空字符串、有时为 null。
26. 常用语法糖速查表
| 简写 | 完整写法或含义 |
|---|---|
:title="title" |
v-bind:title="title" |
@click="submit" |
v-on:click="submit" |
#header |
v-slot:header |
v-model="name" |
:model-value + @update:model-value |
v-model:value="name" |
:value + @update:value |
{ name } |
{ name: name } |
method() {} |
method: function () {} |
你好,${name} |
字符串拼接 |
const { id } = user |
读取 user.id 到变量 id |
const [first] = list |
读取 list[0] 到变量 first |
{ ...source } |
浅拷贝对象属性 |
[...source] |
浅拷贝数组元素 |
(...args) |
收集剩余参数为数组 |
value => value.id |
单参数箭头函数 |
user?.profile?.name |
遇到 null/undefined 时停止访问 |
value ?? defaultValue |
仅在空值时使用默认值 |
<script setup> |
Vue 组合式 API 的编译期简化写法 |
defineProps |
<script setup> 的 Props 编译宏 |
defineEmits |
<script setup> 的 Emit 编译宏 |
defineExpose |
暴露内容给父组件模板引用 |
defineModel |
较新 Vue 中声明组件 v-model 的编译宏 |
27. 推荐学习顺序与练习
第一阶段:能看懂页面
学习内容:
const、let、函数、对象和数组。- 模板字符串、解构、展开、箭头函数。
map、filter、find、some。async/await和try/catch。- Vue 的
{``{ }}、:、@、v-if、v-for、v-model。
练习:用静态数组渲染用户列表,增加搜索和启用状态筛选。
第二阶段:能修改组件
学习内容:
ref、reactive、computed、watch。- Props、Emit、组件
v-model。 - 生命周期和模板引用。
- TypeScript 的
interface、联合类型和空值类型。
练习:把用户列表拆成父页面、列表组件和编辑弹窗三个组件。
第三阶段:能开发业务页面
学习内容:
- Nuxt 页面、布局、路由和自动导入。
- 本项目
useAppFetch请求封装。 - Naive UI 与项目公共组件。
- Pinia 状态管理。
- loading、空状态、错误状态、权限和表单校验。
练习:参考项目现有页面,实现一个包含查询、新增、编辑、删除的完整管理功能。
28. 阅读代码时的快速判断法
看到一段代码时,按以下问题依次判断:
- 数据从哪里来:本地变量、Props、Pinia 还是接口?
- 它是否响应式:
ref、reactive、computed还是普通变量? - 谁会修改它:当前组件、父组件还是请求回调?
- 模板如何使用它:插值、属性、条件、循环还是事件?
- 修改后影响哪里:当前元素、整个列表、子组件还是全局状态?
- 异步过程是否覆盖加载、失败、空数据和重复操作?
把这六个问题回答清楚,大多数 Vue 页面就不再只是"看起来会动的 HTML",而是一条可以追踪的数据流。