Vue+js Day2

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. 浏览器中怎么验收

我按以下顺序操作:

  1. 添加两条待办。

  2. 刷新页面,确认数据仍在。

  3. 在当前练习页面打开开发者工具,进入 Console,执行:

    js 复制代码
    localStorage.removeItem('day2-todos')
  4. 不再添加内容,直接刷新,确认列表为空。

我粘贴命令时还遇到了浏览器控制台的安全提醒。这不是 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 则把内存中的待办保存成字符串,并在下次打开时恢复。

目前完成的是一轮学习与基础练习。接下来还需要在不同场景里反复使用,才能从"看得懂例子"逐渐走到"能自己组织代码"。

相关推荐
掘金酱1 小时前
带着作品来,坐下来聊|掘金 X-lab · Coffee Talk 邀请你来见一面
前端·javascript·后端
CoovallyAIHub2 小时前
用 AI 做 PPT 被辞退,法院判公司赔:员工贴进对话框的数据,到底谁负责?
前端·agent
fruge2 小时前
SSH、RDP、VNC不必各开一个客户端:Next Terminal用Web入口统一管理远程资产和会话记录
运维·前端·ssh
honkun63 小时前
vue 下拉树组件 vxe-table-select 实现分页多选
前端·javascript·vue.js
大熊程序猿3 小时前
用 ASP.NET Core + SignalR 做一个带实时对战房间的打字练习平台
javascript
OpsEye3 小时前
大量无效 Token 消耗,是很多企业被忽略的隐形成本
javascript·ai编程
用户621007587023 小时前
Web BIM 开发实践:如何将构件选择、属性查询接入业务系统?
前端
羲云3 小时前
AI 分站搭完就能发链接?上线前先过这 5 关
前端