JS + Vue 学习笔记(二):从组件通信到异步请求与本地持久化
第一天,我练习了 JavaScript 基础、Vue 模板和响应式。第二天的重点,是把这些零散知识接起来:把列表拆成组件,让父子组件通信,请求接口并搜索用户,再把待办保存到浏览器中。
这篇笔记按实际学习过程整理,记录知识点,也记录我写错过的地方。示例使用 Vue 3 的 <script setup lang="ts">;不同小节的代码是独立示例,或对前一段的替换,不需要全部重复粘贴。
一、import / export:把函数放到另一个文件
我先把姓名拼接函数放到工具文件 utils.ts 中:
ts
export default function formatName(firstName: string, lastName: string) {
return `${firstName} ${lastName}`
}
然后在组件中导入并使用:
ts
import { computed, ref } from 'vue'
import formatName from './utils'
const firstName = ref('Gao')
const lastName = ref('Kangjun')
const fullName = computed(() => {
return formatName(firstName.value, lastName.value)
})
export default 声明默认导出。导入默认导出时不写花括号,并且可以自己起名字:
ts
import makeName from './utils'
const fullName = computed(() => {
return makeName(firstName.value, lastName.value)
})
我最初把调用处误写成了:
ts
return (firstName.value, lastName.value)
这不是函数调用,而是逗号表达式,最终得到的是最后一个表达式的值,也就是 lastName.value,不会把姓名拼接起来。
这次记住了:导入函数不等于调用函数;调用要写"函数名 + 括号 + 参数"。
二、回调函数:传入函数,由接收方执行
vue
<script setup lang="ts">
function runTask(fn: () => void) {
fn()
}
function showMessage() {
alert('学习第二天!')
}
</script>
<template>
<button @click="runTask(showMessage)">执行任务</button>
</template>
点击按钮后,runTask 接收到 showMessage 这个函数,再通过 fn() 调用它。
这里要区分:
ts
runTask(showMessage) // 传入函数
runTask(showMessage()) // 先执行函数,再传入它的返回值
第二行不符合这里的参数要求,因为 showMessage() 没有返回一个可调用函数。
fn: () => void 是 TypeScript 的函数类型,表示这个回调不接收参数,调用方不使用它的返回值。
三、拆分组件,再用 props 和 emit 连接起来
1. 引入组件后,还要在模板中使用
我把列表抽成 TodoList.vue,父组件通过两步使用它:
ts
import TodoList from './components/TodoList.vue'
vue
<TodoList />
只写 import 不会自动把列表显示到页面上。模板中的组件标签决定它显示在哪里。
2. props:父组件传数据给子组件
父组件保存数组:
ts
const studyTasks = ref(['学习 props', '学习 emit', '学习异步请求'])
模板把数组传给子组件:
vue
<TodoList :todos="studyTasks" />
其中 todos 是子组件接收的属性名,studyTasks 是父组件里的变量名。冒号表示绑定表达式的值。
子组件通过 defineProps 声明接收的数据:
ts
defineProps<{
todos: string[]
}>()
属性名 todos 必须对应;父组件的变量名不必和它相同。 defineProps 是 <script setup> 中的编译宏,不需要从 Vue 导入。
3. emit:子组件通知父组件修改数据
为了实现删除,我让子组件发出事件,父组件收到后修改数组。
父组件的最小完整示例:
vue
<script setup lang="ts">
import { ref } from 'vue'
import TodoList from './components/TodoList.vue'
const studyTasks = ref(['学习 props', '学习 emit', '学习异步请求'])
function removeTask(index: number) {
studyTasks.value.splice(index, 1)
}
</script>
<template>
<TodoList :todos="studyTasks" @remove="removeTask" />
</template>
对应的 TodoList.vue:
vue
<script setup lang="ts">
defineProps<{
todos: string[]
}>()
const emit = defineEmits<{
remove: [index: number]
}>()
</script>
<template>
<ul>
<li v-for="(todo, index) in todos" :key="todo">
{{ todo }}
<button @click="emit('remove', index)">删除</button>
</li>
</ul>
</template>
这段代码中,'remove' 是事件名,index 是事件携带的数据。父组件的 removeTask 会接收到这个下标。
完整过程是:
text
子组件点击删除 → emit('remove', index)
→ 父组件 removeTask(index)
→ 数组变化 → 列表更新
这里用文字作为 key,只适用于当前文字互不相同的练习数据。实际待办通常更适合使用稳定的唯一 ID。
4. 我遇到的三处错误
| 当时的写法 | 原因 | 修改方式 |
|---|---|---|
定义 remove,模板却绑定 removeTask |
名字不一致 | 统一函数名 |
studyTasks.values.splice(...) |
把 ref 的 .value 写成了 .values |
改成 studyTasks.value.splice(...) |
v-for="todo in todos",按钮却使用 index |
没有取出下标 | 改成 v-for="(todo, index) in todos" |
这些错误提醒我:使用一个名字之前,要确认它在当前作用域中已经定义。
四、插槽:父组件提供内容,子组件决定位置
子组件 Child.vue:
vue
<template>
<section>
<h2>学习任务</h2>
<slot />
</section>
</template>
父组件导入后使用:
vue
<Child>
<button>开始学习</button>
</Child>
按钮会出现在标题下面,因为那里是 <slot /> 的位置。
这块只做了概念和代码阅读练习:父组件写在组件标签中间的内容,会显示在子组件插槽的位置。
五、生命周期:观察组件的挂载和卸载
在 TodoList.vue 中添加:
ts
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
console.log('TodoList 挂载了')
})
onUnmounted(() => {
console.log('TodoList 卸载了')
})
父组件定义 const showTasks = ref(true),再用 v-if 控制它:
vue
<button @click="showTasks = !showTasks">显示 / 隐藏任务列表</button>
<TodoList
v-if="showTasks"
:todos="studyTasks"
@remove="removeTask"
/>
隐藏时卸载,重新显示时创建并挂载新实例,所以 onMounted 会再次执行。
这也让我理解了:这里的"隐藏"会涉及组件销毁和重建,并非单纯改变外观。
六、watch 和 watchEffect:监听响应式变化
ts
import { ref, watch, watchEffect } from 'vue'
const firstName = ref('Gao')
const lastName = ref('Kangjun')
watch(firstName, (newValue, oldValue) => {
console.log('名字从', oldValue, '变成', newValue)
})
watchEffect(() => {
console.log('当前姓名', firstName.value, lastName.value)
})
| 对比项 | watch | watchEffect |
|---|---|---|
| 监听源 | 显式指定 | 自动跟踪同步执行时读取的响应式依赖 |
| 初次执行 | 默认不执行回调 | 立即执行一次 |
| 新旧值 | 回调可以接收 | 不直接提供 |
上面的 watch 只监听 firstName;watchEffect 读取了两个 ref,所以两者变化都可能使它重新运行。
如果只读取:
ts
watchEffect(() => {
console.log(firstName.value)
})
修改 lastName 就不会因此触发这个副作用。
我也开始分清用途:computed 适合推导一个值;watch 和 watchEffect 适合执行日志、保存数据等操作。对于异步 watchEffect,自动依赖收集只涵盖首次 await 之前同步读取的依赖,不能把它理解为整个异步过程都会被自动跟踪。
七、Promise 与 async / await:等待异步结果
1. 手动完成一个 Promise
ts
const task = new Promise<string>((resolve) => {
setTimeout(() => {
resolve('用户数据加载成功')
}, 1000)
})
task.then((result) => console.log(result))
创建 Promise 时,传入的执行器函数立即运行,启动计时器。结果未确定时是 pending;调用 resolve 并传入这里的字符串后,它变为 fulfilled,成功结果交给 .then 的回调。
这段代码的延时来自 setTimeout,Promise 本身不会自动延时。
2. 用 async / await 接收结果
下面是上一段 .then 的另一种写法:
ts
async function runTask() {
try {
const result = await task
console.log(result)
} catch (error) {
console.error('任务失败', error)
}
}
runTask()
await 等待 Promise 的结果,暂停当前异步函数的后续执行,等待期间不会因此阻塞整个页面。
成功后继续向下执行;如果等待的 Promise 失败,就抛出拒绝原因,进入这里的 catch。try 内其他抛出的错误也会被它捕获。
八、JSON 与 fetch:从接口拿到可以使用的数据
1. JSON 字符串和 JavaScript 数据互转
ts
const tasks = ['学习 Vue', '完成练习']
const saved = JSON.stringify(tasks)
const restored = JSON.parse(saved)
JSON.stringify:把数组、对象等可序列化数据转成 JSON 字符串。JSON.parse:把合法 JSON 字符串解析成 JavaScript 数据。
JSON 属性名和字符串值使用双引号;解析不合法 JSON 会抛出错误。
2. 请求接口并渲染,再加入搜索
下面把当天的用户列表练习整理成一个最小完整组件:
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
type User = {
id: number
name: string
username: string
email: string
}
const users = ref<User[]>([])
const searchText = ref('')
const filteredUsers = computed(() => {
const keyword = searchText.value.trim().toLowerCase()
return users.value.filter((user) =>
user.name.toLowerCase().includes(keyword),
)
})
async function loadUsers() {
try {
const res = await fetch('https://jsonplaceholder.typicode.com/users')
if (!res.ok) {
throw new Error(`请求失败,状态码:${res.status}`)
}
users.value = await res.json()
console.log('用户列表:', users.value)
} catch (error) {
console.error('加载用户失败:', error)
}
}
</script>
<template>
<section>
<h2>接口用户列表</h2>
<button @click="loadUsers">加载用户</button>
<input v-model="searchText" placeholder="按姓名搜索" />
<p>匹配到 {{ filteredUsers.length }} 位用户</p>
<ul>
<li v-for="user in filteredUsers" :key="user.id">
{{ user.name }} --- {{ user.email }}
</li>
</ul>
</section>
</template>
这里有两次等待:await fetch(...) 先取得响应对象;await res.json() 再读取响应体并解析成 JavaScript 数据,不需要再调用 JSON.parse。
HTTP 404、500 等响应不会仅因为状态码自动使 fetch 拒绝,所以要检查 res.ok。
把结果赋给 users.value 后,模板才能通过响应式数组显示用户。只打印日志不会给列表填入数据。类型声明帮助编辑器检查代码,但不会自动验证接口返回值的实际结构;这个练习按接口预期结构使用数据。
3. 搜索框清空后,为什么显示所有用户
搜索词变化,使计算属性缓存失效;下次读取时重新筛选。filter 返回新数组,不会修改原来的用户数组。
清空搜索框后,keyword 变成 '':
ts
'Leanne Graham'.includes('') // true
因此所有用户都会保留。
我最初把它说成"空字符串为真",后来纠正了:空字符串本身是假值;返回 true 的是 字符串.includes('') 这个表达式。
九、localStorage:让待办在刷新后保留
这块持久化的是第一天的待办数组 items,不和接口用户数组 users 混用。
以下是整理后的完整基础示例:
vue
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const STORAGE_KEY = 'day2-todos'
const newItem = ref('')
const items = ref<string[]>([])
onMounted(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
const parsed: unknown = JSON.parse(saved ?? '[]')
if (
Array.isArray(parsed) &&
parsed.every((item) => typeof item === 'string')
) {
items.value = parsed
}
} catch (error) {
console.error('读取待办失败:', error)
}
})
watch(
items,
(newItems) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newItems))
} catch (error) {
console.error('保存待办失败:', error)
}
},
{ deep: true },
)
function addItem() {
const value = newItem.value.trim()
if (value === '') return
items.value.push(value)
newItem.value = ''
}
</script>
<template>
<section>
<h2>我的待办</h2>
<form @submit.prevent="addItem">
<input v-model="newItem" placeholder="输入一条待办" />
<button type="submit">添加</button>
</form>
<p v-if="items.length === 0">目前还没有待办</p>
<ul v-else>
<li v-for="(item, index) in items" :key="index">
{{ index + 1 }}. {{ item }}
</li>
</ul>
</section>
</template>
这里是只添加字符串的简单列表,临时使用下标作为 key;如果以后加入排序、删除或有自身状态的子组件,应改用稳定 ID。
1. 为什么保存前要 stringify
localStorage 存储字符串。保存数组前先序列化,读取后再解析,才能还原数组结构。
2. 为什么读取时用 saved ?? '\[\]'
找不到指定键时,getItem 返回 null。用字符串 '[]' 作为默认值,解析后就是空数组。
unknown 提醒我:读取出来的数据还不可信。通过数组检查和字符串元素检查之后,再赋给待办状态。
3. 为什么使用 deep: true
添加待办时使用的是 items.value.push(value),修改了原数组内容。这里使用深度监听,让数组内部变化也能触发保存。
4. 为什么没有 immediate: true
本例先创建空数组,挂载时再恢复旧数据。如果监听回调立即执行,就可能先把空数组写进存储,覆盖尚未读取的数据。因此保留 watch 默认不立即执行回调的行为。
5. 实际遇到的键名拼写问题
我曾写成:
ts
const STORAGE_KEY = 'day2-tados'
页面清除提示却使用 'day2-todos'。
因为保存和读取都用了同一个错误拼写,刷新恢复仍然可以正常工作;清除时删了另一个键,数据就还在。最终统一为 day2-todos 后,完成了清除验收。
6. 浏览器中怎么验收
我按以下顺序操作:
-
添加两条待办。
-
刷新页面,确认数据仍在。
-
在当前练习页面打开开发者工具,进入 Console,执行:
jslocalStorage.removeItem('day2-todos') -
不再添加内容,直接刷新,确认列表为空。
我粘贴命令时还遇到了浏览器控制台的安全提醒。这不是 Vue 报错;理解命令作用后,可以手动输入。这条命令只删除当前源下的指定存储项,返回 undefined 是正常现象。
删除存储项后,页面上的数据不会立刻消失,因为当前内存中的 items 还在。刷新后重新读取,才会变成空列表。
本地存储按源区分:同一个源包含协议、主机和端口。验收时应使用同一个开发地址;换端口或切换 localhost 与 127.0.0.1,可能看到不同的存储空间。
十、联调:沿着数据流排查问题
第 16 块没有增加新的 Vue API,而是把各块连起来检查。
持久化的数据流是:
text
输入 → newItem → addItem → items
↓ watch
JSON.stringify → localStorage
↓ 刷新、onMounted
页面 ← items ← 数据校验 ← JSON.parse ← 读取
如果添加成功但刷新丢失,就优先检查保存、键名和恢复赋值。如果接口请求成功但列表没有出现,就检查响应数据有没有赋给模板使用的响应式变量。
| 现象 | 优先检查 |
|---|---|
| 组件没有显示 | 是否既导入组件,又在模板使用 |
| 事件绑定报红 | 函数名、事件名、作用域中的变量是否对应 |
| 搜索词改变但列表不变 | v-for 是否遍历 filteredUsers |
| JSON 解析失败 | 存储内容是否合法 JSON |
| 刷新后待办消失 | watch 是否触发、读取后是否赋值 |
| 清除后数据仍在 | 删除的键是否和保存键一致,是否已刷新 |
当天代码检查时,类型检查和构建均通过;我也实际走通了待办的添加、刷新恢复和清除流程。组件通信与接口搜索部分做过源码检查,不能仅凭构建成功就声称全部浏览器交互都已验收。
学习过程中,我把组件与接口练习保存在 App_day2.vue,持久化练习后来另存为 App_day2_storage.vue。备份组件只有被入口或其他组件导入使用后才会运行,不会因为放在源码目录中就自动显示。
十一、第二天留下的理解
这一天最有帮助的,是把"数据由谁保存、操作在什么时候发生"想清楚。
父组件保存列表,子组件通过 props 接收数据、通过 emit 发出操作通知;computed 从已有状态推导结果,watch 负责变化后的操作;Promise 表达异步结果,await 帮助按顺序处理它;localStorage 则把内存中的待办保存成字符串,并在下次打开时恢复。
目前完成的是一轮学习与基础练习。接下来还需要在不同场景里反复使用,才能从"看得懂例子"逐渐走到"能自己组织代码"。