跳至主要内容

为懂js html css基础的人写一个vue的入门文档,以便学后可以使用claude code开发vue单页应用 因此也不要有过多代码细节 着重原理概念以及用法的讲解

Vue 入门指南(面向 Claude Code 开发者)

写给已经掌握 JS / HTML / CSS 基础、但没接触过任何前端框架的你。这篇文档会先带你把一个项目"跑起来",再逐步讲概念,尽量少跳步骤。


一、为什么需要 Vue:从一个具体问题说起

假设你要用原生 JS 做一个计数器:一个数字,一个"+1"按钮,点一下数字加一。原生写法大致是这样的思路:

  1. 用 document.getElementById 或类似方法,先"抓到"页面上显示数字的那个元素;
  2. 定义一个变量存当前的数字;
  3. 给按钮绑定点击事件,点击时变量加一;
  4. 再手动把这个变量的新值,写回刚才抓到的那个元素里(比如 .innerText = 新值)。

问题出在第 4 步:数据变了,你必须自己记得去更新对应的 DOM。页面简单时还好,一旦页面上有几十个地方的内容依赖同一批数据,你就要在每个数据变化的地方,手动更新一堆 DOM 元素,非常容易漏改、改错。

Vue 做的事情就是把第 4 步接管过去:你只要告诉 Vue "这个数字应该显示在这里",之后每次数字变化,Vue 自动帮你把页面对应的部分更新掉,你不用再手写这一步。这就是"响应式"和"声明式渲染"两个词想表达的意思——你只声明"结果应该是什么样",不用管"怎么一步步改出来"。

记住这个对比,后面所有概念都是围绕"如何声明数据、如何声明界面"展开的。


二、开发环境与项目是怎么跑起来的

在讲语法之前,先搞清楚一个 Vue 项目从零到能在浏览器里看到效果,中间发生了什么。这一步很多教程会跳过,但恰恰是最容易让人懵的地方。

1. 需要先装好的东西

  • Node.js:一个能让 JS 脱离浏览器、在电脑上直接运行的环境。前端的各种工具(包括创建项目的脚手架)本质上都是用 JS 写的小程序,需要靠 Node.js 来运行。装好后,终端里可以运行 node -v 查看版本确认装好了。
  • npm:随 Node.js 一起安装的"包管理工具",作用是下载别人写好的代码库("包"),并帮你管理项目依赖了哪些包、版本是多少。

有了 Claude Code,这两样的安装排查它也可以帮你完成,但你需要知道它们是"运行和管理前端工具链的地基"。

2. 创建一个项目

在终端里进入你想放项目的文件夹,运行官方脚手架命令(Claude Code 会帮你执行,你只需要知道这一步在做什么):它会问你项目名、要不要加路由、要不要加状态管理等几个问题,然后生成一整套初始文件和文件夹——不是空文件夹,而是一个已经能跑起来的最小项目。

3. 项目生成后,文件夹里大致有什么

my-project/
  node_modules/   自动下载的所有依赖包(体积很大,不用管,不要手动改)
  public/         不需要处理、原样发布的静态文件(比如网站图标)
  src/            你真正会写代码的地方
    App.vue       整个应用最外层的组件
    main.js       入口文件:创建 Vue 应用、挂载到页面上
  index.html      唯一的一个 HTML 页面("单页应用"的"单页"就是指它)
  package.json    记录项目信息和依赖了哪些包
  vite.config.js  构建工具的配置文件

刚开始你几乎只需要关心 src/ 文件夹里的内容,其他文件了解个大概即可。

4. 让项目跑起来

在项目文件夹里运行一个"启动开发服务器"的命令后,终端会打印一个本地网址(通常是 http://localhost:5173 之类),用浏览器打开它,就能看到项目的初始页面。

这里有个对新手很重要、但常被忽略的体验:这个开发服务器会一直"看着"你的代码文件。你在编辑器里改一行代码、保存,浏览器里的页面会自动刷新,几乎不需要你手动刷新浏览器——这个叫"热更新",是开发体验的核心部分。也就是说,你或 Claude Code 每改一处代码,直接切回浏览器就能立刻看到效果,这是你验证改动是否正确的主要方式。

5. "开发"和"上线"是两件事

你平时改代码、浏览器自动刷新看效果的过程,叫开发模式,只在你自己电脑上跑,别人访问不到。真正要给别人用,需要执行"打包/构建"命令,把所有代码处理、压缩成浏览器能直接运行的静态文件(通常会生成一个 dist 文件夹),再把这个文件夹部署到服务器或托管平台上。开发时你不需要关心打包这一步,等项目做完要发布时再考虑。


三、一个最小的 Vue 组件长什么样

有了运行环境的概念,现在看看代码本身。Vue 项目里每个界面单元写在一个 .vue 文件里,固定分三块:

<template>
  <button @click="count++">点击次数:{{ count }}</button>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<style>
button { padding: 8px 16px; }
</style>

对照着看:

  • <template>:这块基本就是 HTML,唯一特殊的地方是 {{ count }}(把变量的值显示出来)和 @click(点击时执行后面的代码)。
  • <script setup>:这块是普通 JS,唯一特殊的地方是 ref(0)——它把数字 0 包装成一个"Vue 能监控变化"的数据。
  • <style>:普通 CSS,只会作用在这个组件自己身上,不会影响其他组件(这点和普通 CSS 不一样,普通 CSS 是全局生效的)。

先记住这个"三段式结构"和这个计数器的样子,后面讲的所有概念,都是在给这三块里能写的东西"加功能"。


四、响应式数据:ref() 到底在做什么

回到上面的例子:const count = ref(0)。

如果不用 ref,直接写 let count = 0,count++ 之后变量确实变了,但没有任何机制通知模板"这个值变了,该更新界面了"。ref() 做的事情,就是把这个值包一层"外壳",这个外壳自带监控功能:每次通过 .value 修改它(在 <script> 里要写 count.value++;在 <template> 里 Vue 会自动帮你省略 .value,直接写 count),Vue 就知道"这个数据变了,去检查所有用到它的地方,更新界面"。

reactive() 是另一个类似的函数,区别是它直接包装一整个对象(比如 { name: '小明', age: 20 }),不需要 .value,但代价是不能把整个对象换掉,只能改里面的某个属性。实际项目里 ref() 用得更多、更通用,先掌握它就够了。

一句话记住:想让一个数据"活起来"(改了自动刷新界面),就用 ref() 包一下,读写的时候多带一个 .value(模板里除外)。


五、模板语法:数据如何显示到页面上

模板里除了 {{ }} 和 @click 之外,还有几种常用写法,都是"把 HTML 的某部分和数据关联起来"的不同方式:

语法 作用 类比
{{ 变量 }} 把值显示在文本中 就是上面例子里的用法
:属性名(v-bind 简写) 把 HTML 属性绑定到变量 比如图片的 src 跟着变量变化
@事件名(v-on 简写) 绑定事件处理逻辑 上面例子里的 @click
v-if 条件成立才渲染这块 类似 JS 里的 if
v-for 遍历数组,重复渲染 类似 JS 里的 for 循环,常用来渲染列表
v-model 表单输入和变量双向同步 输入框内容变了变量自动更新,反过来也一样

看到这张表就能明白:模板语法本质上就是把"变量/事件/循环/判断"这些 JS 里已经会的概念,用一种 HTML 能识别的写法表达出来,不是全新的知识,只是换了个写法。下面用一个稍微完整点的例子,把这几种语法串在一起看:

<template>
  <input v-model="keyword" placeholder="输入图片描述" />

  <img :src="imageUrl" :alt="keyword" />

  <p v-if="items.length === 0">暂无数据</p>
  <ul v-else>
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
      <button @click="removeItem(item.id)">删除</button>
    </li>
  </ul>
</template>

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

const keyword = ref('')
const imageUrl = ref('https://example.com/pic.jpg')
const items = ref([
  { id: 1, name: '苹果' },
  { id: 2, name: '香蕉' }
])

function removeItem(id) {
  items.value = items.value.filter(item => item.id !== id)
}
</script>

对照着看几个细节:

  • v-model="keyword" 让输入框和 keyword 双向同步,用户打字时 keyword.value 自动更新,不需要自己手写 input 事件监听。
  • :src 和 :alt 是同一种 v-bind 语法,一个标签上可以同时绑定多个属性。
  • v-if / v-else 成对出现,根据 items.length 是否为 0 二选一渲染。
  • v-for="item in items" 遍历数组时,必须配上 :key="item.id"——这是给每一项一个唯一标识,方便 Vue 在列表增删时准确判断哪一项变了,而不是把整个列表重新渲染一遍。忘记写 :key 是新手最常见的报错/警告来源之一。
  • @click="removeItem(item.id)" 说明事件绑定可以直接调用一个带参数的函数,不局限于 count++ 这种简单表达式。

六、组件:把页面拆成积木

上面的计数器只有一个组件(App.vue 本身)。真实项目会把页面拆成很多小组件,比如"导航栏组件""商品卡片组件""底部组件",再组合到一起,方便复用和维护。

组件之间需要"说话",有两个方向:

  1. 父传子:Props。父组件把数据传给子组件(类似调用函数时传参数)。子组件只能"使用"这份数据,不能自己改它。
  2. 子传父:Emit(触发事件)。子组件想告诉父组件"发生了一件事"(比如某个按钮被点了),就触发一个自定义事件,父组件监听这个事件、决定怎么反应。

记住方向:数据往下传(props),消息往上报(emit)。这是几乎所有组件化框架通用的模式,不是 Vue 独有的。

用代码看一下这个方向具体怎么写。假设有一个子组件 LikeButton.vue,父组件传入当前点赞数,子组件点击后通知父组件"点赞数应该加一":

<!-- 子组件 LikeButton.vue -->
<template>
  <button @click="handleClick">👍 {{ count }}</button>
</template>

<script setup>
// defineProps 声明"我会接收哪些数据",父组件传入的值会自动出现在 props 里
const props = defineProps({
  count: Number
})

// defineEmits 声明"我会触发哪些事件",相当于先登记一下事件的名字
const emit = defineEmits(['increase'])

function handleClick() {
  // 触发 increase 事件,把消息交给父组件,自己并不直接修改 count
  emit('increase')
}
</script>
<!-- 父组件 App.vue -->
<template>
  <LikeButton :count="likeCount" @increase="likeCount++" />
</template>

<script setup>
import { ref } from 'vue'
import LikeButton from './LikeButton.vue'

const likeCount = ref(0)
</script>

这里能看出为什么"子组件不能直接改父组件传来的数据":count 这个数字真正的存储位置是父组件的 likeCount,子组件只是"借来看一眼"。子组件点击按钮后,并没有自己把数字加一,而是通过 emit('increase') 喊了一声,父组件听到后自己执行 likeCount++。这样数据始终只有一个"来源"(父组件),不会出现子组件和父组件各自维护一份、彼此不同步的情况。

defineProps 里的 Number 是什么意思

const props = defineProps({
  count: Number
})

这里 Number 不是给 count 赋值,而是声明这个 prop 的类型应该是数字。Vue 在开发模式下会做类型校验——如果父组件不小心传了字符串 "5" 而不是数字 5,控制台会给出警告,帮你更早发现问题。常见类型还有 String、Boolean、Array、Object、Function。如果只是想快速写、不关心类型校验,也可以简化成只写名字的数组 defineProps(['count']),但拿不到类型检查和默认值这些好处。

父组件的 JS 里并没有定义 count,它是怎么传进来的

回顾父组件那一行:<LikeButton :count="likeCount" @increase="likeCount++" />。等号两边其实是两件不同的事:

  • 左边的 count:这是"目标 prop 的名字",必须和子组件 defineProps 里声明的名字对上,子组件写的是 count,父组件这里就必须写 count,不能随便改。
  • 右边的 "likeCount":这是一个 JS 表达式,取的是父组件自己 ref(0) 定义的那个变量当前的值。

整句话翻译成人话就是:"把我这边 likeCount 变量的值,传给子组件那个叫 count 的 prop"。父组件的变量完全可以叫任何名字,两边变量名不需要一致,就像调用函数时"形参名"和"传入的实参变量名"本来就不需要一样:

function greet(name) { ... }   // 形参叫 name
const myUsername = 'Tom'
greet(myUsername)              // 调用时传的变量叫 myUsername,不需要也叫 name

为什么要在模板里写,不能直接在 JS 里写吗

其实模板本身最终会被编译成 JS 代码,:count="likeCount" 已经是"在 JS 里写"了,只是换了一种更简洁的写法。去掉语法糖,这行标签编译后大致等价于:

h(LikeButton, {
  count: likeCount.value,           // 把当前值传进去
  onIncrease: () => { likeCount++ } // 把处理函数传进去
})

h() 可以理解成"创建一个组件实例,把参数传给它",和普通函数调用长得很像。所以模板并不是"JS 之外多出来的一层",它就是 JS 的另一种写法——只是更接近 HTML、更好读,同时 Vue 还能在编译阶段基于模板结构做很多手写 JS 很难做到的性能优化,这也是几乎没人手写 h() 的原因。

如果数据是接口返回的 JSON 数组,怎么传

思路完全一样,只是把"手写的初始值"换成"网络请求回来的结果",通常有两种拆法:

拆法一:整个数组传给一个子组件,子组件自己遍历

<!-- 父组件 -->
<template>
  <UserList :users="users" />
</template>

<script setup>
import { ref, onMounted } from 'vue'
import UserList from './UserList.vue'

const users = ref([])   // 先给个空数组占位,接口没返回前页面不会报错

onMounted(async () => {
  const res = await fetch('/api/users')
  users.value = await res.json()   // 用接口返回的数组,整体替换掉 .value
})
</script>
<!-- 子组件 UserList.vue -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

<script setup>
defineProps({ users: Array })
</script>

拆法二:v-for 写在父组件,每一项对应一个独立的子组件实例(适合每一项都要单独处理点击、删除等交互,做法和后面第十二节的 TodoItem 例子一致):

<template>
  <UserCard v-for="user in users" :key="user.id" :user="user" />
</template>

两种拆法怎么选:如果子组件只是整体展示、不需要每一项单独交互,用拆法一更省事;如果每一项都要有自己独立的操作(勾选、删除、展开),倾向于拆法二。无论哪种,核心都不变:接口数据先存进一个 ref,等数据到位后通过 :prop名="变量名" 传下去,Vue 检测到 .value 变化会自动重新渲染。

还有一个概念叫 Slot(插槽):允许父组件往子组件"内部"塞一段自定义内容,类似普通 HTML 标签里可以嵌套任意子元素。刚开始不需要马上用到,知道有这个东西即可。


七、计算属性与侦听器

  • 计算属性 computed:由已有数据自动算出的新值,比如根据购物车里的商品,自动算出总价。它会缓存结果,只有依赖的数据变化时才重新计算。
  • 侦听器 watch:监听某个数据变化,一旦变化就执行一段逻辑(比如变化时去发一次网络请求)。

两者对照着看更清楚:

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

const price = ref(10)
const quantity = ref(2)

// computed:声明"总价 = 单价 × 数量",quantity 或 price 变了它自动重新算
const total = computed(() => price.value * quantity.value)

// watch:监听 quantity,变化时"顺便"做一件事——这里是打印日志,
// 实际项目里常见的是发一次网络请求,比如根据数量重新查库存
watch(quantity, (newValue, oldValue) => {
  console.log(`数量从 ${oldValue} 变成了 ${newValue}`)
})

注意 total 用起来就像一个普通的 ref(模板里直接写 {{ total }} 即可),但你不需要手动更新它——只要 price 或 quantity 变了,total 自动跟着重新计算,这也是"声明式"思路的又一体现:你只声明了"总价是怎么算出来的",不需要在每个改动 price/quantity 的地方,额外去手写"顺便也把 total 更新一下"。

区分方法很简单:只是想"展示一个算出来的值" → 用 computed;数据变了要"顺便做点别的事情"(不是单纯展示)→ 用 watch。


八、生命周期:组件的"一生"

一个组件从出现在页面上、到之后被移除,会经历几个固定阶段,Vue 允许你在特定阶段插入代码,最常用的是:

  • 挂载完成时:组件已经出现在页面上了,适合在这时发起网络请求、拿数据。
  • 卸载前:组件即将从页面消失,适合清理定时器等,避免内存泄漏。

代码上分别对应 onMounted 和 onBeforeUnmount 两个函数:

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const users = ref([])
let timer = null

// 组件刚出现在页面上时执行一次:常见用途是请求数据
onMounted(async () => {
  const res = await fetch('/api/users')
  users.value = await res.json()

  // 假设还要每隔 5 秒刷新一次
  timer = setInterval(() => {
    console.log('刷新一次数据')
  }, 5000)
})

// 组件即将被移除前执行:清理掉上面开的定时器,否则组件消失了定时器还在后台跑
onBeforeUnmount(() => {
  clearInterval(timer)
})
</script>

这里能看出为什么"挂载时开的东西,卸载前要记得关掉"很重要:如果不清理 timer,即使这个组件已经从页面上消失了(比如用户切换到了别的路由),这个定时器仍然会在后台一直执行,长期积累会拖慢整个应用,这类问题叫"内存泄漏"。

刚开始只需要记住"组件出现在页面上那一刻,可以在这里发请求、做初始化;组件消失前,把这里开的东西关掉",其余生命周期阶段用到时再查。


九、单页应用为什么需要"路由"

还记得前面提到项目里只有一个 index.html 吗?"单页应用"里所谓"切换页面",其实是在不刷新、不重新加载 HTML 的情况下,动态替换当前显示的组件——这套机制由 vue-router 这个库提供:

  • 你写一份"路径 → 组件"的对照表,比如 / 对应首页组件,/about 对应关于页组件;
  • 用户点击链接或地址栏变化时,路由库监听到变化,自动切换显示对应组件;
  • 路径里还可以带参数,比如 /user/123 里的 123 可以在组件里读到,用来加载指定用户的信息。

只要你的应用有"不止一个页面/视图",基本就离不开路由。


十、状态管理:数据要不要"全局共享"

组件不多的时候,用 props/emit 逐层传数据没问题。但项目变大后,经常出现"很多互不相关的组件都要用同一份数据"(比如当前登录用户信息),一层层传参会很啰嗦、很容易出错。

这时候用状态管理库(Vue 生态目前推荐 Pinia):把这类公共数据放到一个全局的地方统一管理,任何组件都能直接读取或修改,不用层层传递。

用代码感受一下"全局共享"具体是什么样。先定义一个"仓库"(Pinia 里叫 store),把用户信息放进去:

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

export const useUserStore = defineStore('user', () => {
  const name = ref('')
  const isLoggedIn = ref(false)

  function login(userName) {
    name.value = userName
    isLoggedIn.value = true
  }

  function logout() {
    name.value = ''
    isLoggedIn.value = false
  }

  // 把想暴露给外部使用的数据和方法 return 出去
  return { name, isLoggedIn, login, logout }
})

之后,任何组件都可以直接引入这个 store 来读取或修改用户信息,不需要经过任何中间组件传递:

<!-- 比如导航栏组件 NavBar.vue,和登录表单完全不是父子关系 -->
<template>
  <span v-if="userStore.isLoggedIn">你好,{{ userStore.name }}</span>
  <span v-else>未登录</span>
</template>

<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>

对比一下就能体会区别:如果不用 Pinia,NavBar.vue 要拿到用户信息,得靠某个共同的祖先组件把数据一层层通过 props 往下传,中间路过的每个组件都要"帮忙转发",即使它们自己根本用不到这份数据。有了 Pinia,NavBar.vue 直接自己去"仓库"里取,不牵扯任何中间组件。

判断标准:只有一两个相邻组件要用的数据,不需要它;很多分散在各处的组件都要用的数据,才考虑它。


十一、和 Claude Code 配合开发的建议

  1. 先想清楚"页面"和"数据",再让它动手:这个应用大致有哪几个页面(对应路由)、哪些数据需要跨组件共享(可能需要 Pinia),想清楚这两点,给出的需求会更清楚,生成的代码结构也会更合理。
  2. 按组件粒度提需求:与其说"帮我做个后台系统",不如说"做一个用户列表组件,接收 users 数组作为 props,点击删除按钮时触发事件通知父组件"——描述里带上 props / emit / v-for 这些概念,Claude Code 生成的代码会更贴近你的预期。
  3. 改完代码,回浏览器看热更新的效果:这是你验证一次改动对不对最直接的方式,不需要每次都读懂全部代码细节。
  4. 遇到报错先定位在哪一层:模板语法写错(指令用错)、响应式数据没包对(该用 ref 却当普通变量用)、组件通信断了(props 没传对、emit 没监听)——这是新手最常见的三类问题,报错信息通常会有线索,可以直接把报错贴给 Claude Code 帮你定位。

十二、完整例子:多个组件协作出一个"待办事项"页面

前面每一节都是孤立的小片段,这里把 响应式数据、模板语法、组件、props、emit 串起来,做一个真实一点的例子:一个输入框加一个按钮用来新增待办事项,下面是列表,每一项可以勾选完成、可以删除。整体拆成三个组件:

App.vue(父组件,管理数据)
 ├─ TodoInput.vue(子组件,负责输入和新增)
 └─ TodoItem.vue(子组件,负责单条展示和操作,会被 v-for 渲染多份)

为什么这么拆:待办事项列表本身(数据)只有一份,理应放在最外层的 App.vue 统一管理;"输入新增"和"单条展示"是两个相对独立、职责不同的界面单元,拆成组件后各自逻辑更简单,也方便以后复用。

<!-- TodoInput.vue:只负责"输入 + 触发新增事件",自己不存储列表 -->
<template>
  <input v-model="text" placeholder="输入待办事项,回车添加" @keyup.enter="handleAdd" />
</template>

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

const text = ref('')
const emit = defineEmits(['add'])

function handleAdd() {
  if (text.value.trim() === '') return
  emit('add', text.value)   // 把输入的文字,通过事件交给父组件
  text.value = ''           // 清空输入框,自己的状态自己管
}
</script>
<!-- TodoItem.vue:只负责展示"一条"数据,以及把用户操作报告给父组件 -->
<template>
  <li>
    <input
      type="checkbox"
      :checked="todo.done"
      @change="emit('toggle', todo.id)"
    />
    <span :class="{ done: todo.done }">{{ todo.text }}</span>
    <button @click="emit('remove', todo.id)">删除</button>
  </li>
</template>

<script setup>
defineProps({
  todo: Object   // 接收一条待办事项 { id, text, done }
})
const emit = defineEmits(['toggle', 'remove'])
</script>

<style scoped>
.done { text-decoration: line-through; color: #999; }
</style>
<!-- App.vue:唯一真正存储列表数据的地方,负责响应两个子组件的事件 -->
<template>
  <h2>待办事项({{ remainingCount }} 项未完成)</h2>

  <TodoInput @add="addTodo" />

  <ul>
    <TodoItem
      v-for="todo in todos"
      :key="todo.id"
      :todo="todo"
      @toggle="toggleTodo"
      @remove="removeTodo"
    />
  </ul>
</template>

<script setup>
import { ref, computed } from 'vue'
import TodoInput from './TodoInput.vue'
import TodoItem from './TodoItem.vue'

const todos = ref([
  { id: 1, text: '学习 Vue', done: false },
  { id: 2, text: '搭建项目', done: true }
])

// 由 todos 派生出的值:未完成的数量,不需要手动维护
const remainingCount = computed(() => todos.value.filter(t => !t.done).length)

function addTodo(text) {
  todos.value.push({ id: Date.now(), text, done: false })
}

function toggleTodo(id) {
  const target = todos.value.find(t => t.id === id)
  if (target) target.done = !target.done
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}
</script>

顺着数据流走一遍,把前面几节的概念对上号:

  1. 新增:用户在 TodoInput 里打字(v-model 双向绑定到它自己的 text),回车触发 handleAdd,通过 emit('add', text.value) 向上把新内容交给 App.vue;App.vue 监听到 @add,执行 addTodo,往 todos(唯一数据源)里追加一条。
  2. 展示:todos 这份数据通过 v-for 渲染出多个 TodoItem,每个都通过 :todo="todo" 向下把自己那一条数据传下去(props)——这就是"数据往下传"。
  3. 勾选/删除:TodoItem 自己不负责修改 done 状态或从数组里删除,它只负责在用户操作时 emit('toggle', todo.id) / emit('remove', todo.id) 向上报告"发生了什么、是哪一条",真正的修改逻辑(toggleTodo / removeTodo)仍然写在持有数据的 App.vue 里——这就是"消息往上报"。
  4. 自动更新的部分:remainingCount 用 computed 声明,todos 增删或某一项 done 变化时,它会自动重新计算,<h2> 里的数字不需要任何手动更新代码。

整个例子里,todos 这份数据自始至终只存在于 App.vue 一个地方,两个子组件都只是"临时借用"(props)或"报告事件"(emit),不会出现两处数据不同步的情况——这也是为什么前面反复强调"数据往下传,消息往上报"这个方向的原因。等你熟悉这个例子后,可以试着让 Claude Code 在此基础上加一个"按完成状态筛选"的功能,用来检验自己是否看懂了这条数据流。


小结

把这篇文档串起来看,其实是一条逐渐展开的线索:原生 JS 手动改 DOM 太麻烦 → Vue 用响应式数据 + 声明式模板自动接管这件事 → 用 .vue 文件把结构/逻辑/样式装进一个组件 → 用 props/emit 把多个组件拼成一个应用 → 用路由处理多页面、用 Pinia 处理跨组件共享数据。语法细节不用死记,边用 Claude Code 开发、边对照这条线索理解代码在做什么,会比一开始就抠语法更有效。

此博客中的热门博文

PyTorch学习路线图

  第一阶段:基础知识(2-3周) 第1周:Python与机器学习基础 Python数据结构与NumPy基础 张量概念与线性代数基础 机器学习基本概念 第2-3周:PyTorch核心 张量操作与计算图 自动微分(autograd)机制 数据加载与预处理(DataLoader, Dataset) 构建神经网络模块(nn.Module) 第二阶段:深度学习基础(4-6周) 第4周:线性模型与优化器 线性回归与逻辑回归实现 优化器(SGD, Adam等) 损失函数与评估指标 第5-6周:基础神经网络 多层感知机(MLP) 卷积神经网络(CNN) 循环神经网络(RNN, LSTM, GRU) 第7-8周:训练技巧 模型保存与加载 学习率调度 正则化技术 迁移学习 第三阶段:进阶应用(6-8周) 第9-10周:计算机视觉 图像分类 目标检测 图像分割 视觉Transformer 第11-12周:自然语言处理 词嵌入 序列到序列模型 Transformer架构 预训练语言模型应用 第13-14周:生成模型 自编码器 变分自编码器(VAE) 生成对抗网络(GAN) 扩散模型 第四阶段:工程实践(4-6周) 第15-16周:模型部署 模型量化与优化 TorchScript与ONNX导出 服务化部署 移动端部署 第17-18周:高级训练技术 分布式训练 混合精度训练 梯度累积与梯度裁剪 模型剪枝与蒸馏 第19-20周:项目实战 完整项目流程 模型性能优化 工业级代码实践

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...