Java 后端开发者理解 Vue:从 Controller 思维到响应式 UI
如果你已经会 Java,熟悉 Spring、Controller、Service、DTO、Repository、拦截器、配置文件和请求响应链路,那么你看 Vue 代码时,大概率不是“完全看不懂”。你能看懂变量、函数、条件判断、数组遍历,也大概知道 <div>、<span>、class、@click、:disabled 在页面上会产生某些效果。
真正的问题通常不在“语法完全陌生”,而在于:你会不自觉用后端的心智模型去解释前端。
你可能会把 .vue 文件里的 <template> 当成类似 Spring XML、JSP、Thymeleaf 的配置或模板;会把 props 理解成 DTO 字段;会把 emit() 想成直接调用父组件方法;会把 v-model 想成子组件直接修改父组件变量;会把页面更新理解成“重新请求一次接口然后刷新页面”。这些直觉有一部分能帮你入门,但在 Vue 的核心机制上会误导你。
本文的目标不是泛泛讲 Vue,而是站在 Java/Spring 后端开发者的视角,把最容易困惑的部分拆开:Vue 页面为什么会自动刷新、ref() 和 .value 到底是什么、v-model 双向绑定是不是魔法、:xxx 和 @xxx 为什么不是普通 XML 属性、组件之间为什么要 props 向下 emit 向上,以及 Element Plus 组件、插槽、scoped CSS、:deep() 这些东西应该如何理解。
1. 先纠正一个根本误区:Vue 页面不是一次请求生成一次响应
后端开发者最熟悉的链路通常是:
HTTP 请求
-> Controller
-> Service
-> Repository / Mapper
-> 返回 JSON 或 HTML
-> 本次请求结束
这个模型的核心是“一次请求,一次处理,一次响应”。
但 Vue 单页应用不是这个模型。Vue 更像是浏览器里长期运行的一组对象:
浏览器加载页面
-> 创建 Vue 应用
-> 挂载 App.vue
-> 渲染组件树
-> 等待用户输入 / 接口返回 / SSE 推送 / 定时器触发
-> 修改响应式状态
-> Vue 局部更新 DOM
-> 继续等待下一次事件
也就是说,App.vue 不是 Controller,ChatHistory.vue 也不是 Controller,ConversationTimeline.vue 也不是 Controller。它们更像浏览器里的 UI 对象或 ViewModel。它们会长期存在,用户点按钮、输入文字、接口返回、SSE 推送、定时器轮播,都会不断修改这些组件里的状态。
Vue 的核心不是“每次都刷新整个页面”,而是:
响应式状态变化
-> Vue 发现哪些模板依赖了这些状态
-> 重新计算对应的虚拟节点
-> 最小化更新真实 DOM
所以,当你看到:
const isLoading = ref(false)
const userMessage = ref('')
const conversationTurns = ref([])
不要把它们当成普通局部变量。它们是 Vue 托管的响应式状态。只要这些状态变了,页面中依赖它们的地方就会跟着变。
例如:
<el-button :loading="isLoading" :disabled="isLoading">
发送
</el-button>
<ConversationTimeline :turns="conversationTurns" />
当代码执行:
isLoading.value = true
conversationTurns.value.push(newTurn)
页面会发生变化:按钮进入加载态,聊天时间线出现新消息。这里没有重新刷新整个浏览器页面,也不是重新让后端渲染 HTML。它只是浏览器里的状态变了,Vue 自动把状态同步到了 DOM。
这是你从 Java 后端切到 Vue 时最重要的一次“换脑”:
Spring MVC:请求驱动程序执行。
Vue:状态驱动页面变化。
2. .vue 文件的三层结构:template、script、style
一个典型 Vue 单文件组件长这样:
<template>
<div class="chat-box">
<el-input v-model="userMessage" />
<el-button :disabled="isLoading" @click="handleSendMessage">
发送
</el-button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const userMessage = ref('')
const isLoading = ref(false)
const handleSendMessage = () => {
// 发送消息
}
</script>
<style scoped>
.chat-box {
display: flex;
gap: 12px;
}
</style>
可以粗略理解成三层:
| 区域 | 作用 | Java 后端视角类比 |
|---|---|---|
<template> | 声明页面结构和绑定关系 | 有点像模板,但不是服务端模板,也不是 XML 配置 |
<script setup> | 写组件状态、函数、接口调用、事件处理 | 有点像 Controller + ViewModel 的混合体 |
<style scoped> | 写当前组件样式 | 类似当前组件私有的 CSS 规则 |
其中最容易被误解的是 <template>。它长得很像 HTML 或 XML,但它不是纯 HTML,更不是 Spring XML。Vue 会把 <template> 编译成 JavaScript 渲染函数。
比如:
<el-button :disabled="isLoading" @click="handleSendMessage">
发送
</el-button>
更接近下面这种意思:
createElement(ElButton, {
disabled: isLoading,
onClick: handleSendMessage
}, '发送')
所以你读 Vue 模板时,不能只按 XML 属性读。你必须识别哪些是静态属性,哪些是 JavaScript 表达式,哪些是 Vue 指令,哪些是事件监听。
3. 模板不是 XML:冒号、@、v-、# 都是 Vue 编译语法
Java 后端开发者看到下面这种代码时,最容易困惑:
<span class="status-dot" :class="{ 'is-loading': isLoading }"></span>
直觉上你可能会问:
为什么已经有 class="status-dot",后面又来一个 :class?两个 class 谁覆盖谁?:class 是什么 XML 语法?{ 'is-loading': isLoading } 又是什么配置?
这行应该拆开看。
| 片段 | 含义 |
|---|---|
<span> | 原生 HTML 行内容器,常用于小文字、小圆点、小图标、小徽标 |
class="status-dot" | 静态 class,永远存在 |
:class="..." | 动态 class,冒号是 v-bind: 简写,右边是 JS 表达式 |
{ 'is-loading': isLoading } | class 对象写法,isLoading 为 true 时添加 is-loading |
它最终会变成:
<!-- isLoading = false -->
<span class="status-dot"></span>
<!-- isLoading = true -->
<span class="status-dot is-loading"></span>
所以这里不是两个 class 互相覆盖,而是合并。
从 Java 角度看,它接近:
List<String> classes = new ArrayList<>();
classes.add("status-dot");
if (isLoading) {
classes.add("is-loading");
}
也就是说,status-dot 描述“这个元素是什么”,is-loading 描述“这个元素现在处于什么状态”。
前端里经常把 class 当成 UI 状态标记,例如:
| class | 常见含义 |
|---|---|
active | 当前选中 |
disabled | 禁用状态 |
is-loading | 加载中 |
pending | 等待中 |
stale | 数据过期 |
collapsed | 折叠状态 |
expanded | 展开状态 |
后端通常会把状态放在字段里,例如 status = RUNNING。前端则经常把状态转换成 class,让 CSS 根据 class 决定视觉效果。
4. Vue 模板里最核心的几种符号
你可以先把 Vue 模板符号简化成下面这张表:
| 写法 | 完整写法 | 作用 | 例子 |
|---|---|---|---|
class="x" | HTML 原生属性 | 固定 class | class="status-dot" |
:xxx="expr" | v-bind:xxx="expr" | 动态属性绑定 | :disabled="isLoading" |
@xxx="fn" | v-on:xxx="fn" | 事件监听 | @click="handleSendMessage" |
v-model="x" | 值绑定 + 更新事件 | 双向绑定 | v-model="userMessage" |
v-if="expr" | Vue 条件指令 | 条件渲染 | v-if="hasContent" |
v-for="item in list" | Vue 循环指令 | 列表渲染 | v-for="chat in chatList" |
{{ expr }} | 文本插值 | 显示变量值 | {{ currentUser.username }} |
#prefix | v-slot:prefix | 插槽简写 | <template #prefix> |
重点是:有冒号就不是字符串,而是 JavaScript 表达式。
例如:
<el-button disabled="isLoading" />
<el-button :disabled="isLoading" />
第一行更像把字符串 "isLoading" 传进去;第二行才是读取变量 isLoading 的布尔值。
再例如:
<el-button title="删除" />
<el-button :title="isLoading ? '处理中' : '删除'" />
第一行是固定字符串;第二行是动态表达式。
所以你读模板时,可以按这个顺序:
- 看标签名:原生 HTML、Element Plus 组件,还是自定义 Vue 组件。
- 看普通属性:
class="..."、type="primary"、size="small",这些大多是静态配置。 - 看冒号属性:
:disabled="..."、:class="..."、:turns="...",这些来自 JS 状态。 - 看事件:
@click、@submit、@chat-selected,这些是用户或子组件触发的动作。 - 看
v-if、v-for、v-model,这些决定节点是否存在、重复多少次、是否和状态双向同步。 - 看标签中间内容:普通文本、
{{ }}插值、插槽内容或子组件。
5. ref():为什么脚本里要 .value,模板里却不用?
Vue 3 里最常见的响应式写法是:
import { ref } from 'vue'
const isLoading = ref(false)
这里的 isLoading 不是普通布尔值,而是一个包装对象。真正的值在:
isLoading.value
所以在 <script setup> 里要这样修改:
isLoading.value = true
但是在 <template> 里可以直接写:
<el-button :loading="isLoading" />
<div v-if="isLoading">加载中</div>
因为 Vue 模板会自动解包 ref。也就是说:
script 里:isLoading.value
模板里:isLoading
这是一个很容易让 Java 开发者不舒服的点。你会觉得:同一个变量为什么一会儿要 .value,一会儿不用?
原因是:
const isLoading = ref(false)
并不是定义了一个普通 boolean,而是定义了一个能被 Vue 追踪读写的响应式容器。Vue 需要通过这个容器知道:“谁读取了它,谁依赖了它,什么时候它被修改了”。
可以粗略类比成:
class Ref<T> {
private T value;
public T getValue() {
// 这里不只是 get,还会被框架记录依赖
return value;
}
public void setValue(T value) {
this.value = value;
// 这里不只是 set,还会通知视图更新
notifySubscribers();
}
}
所以 ref() 的重点不是“包装了一层值”,而是“让 Vue 能追踪这个值的读取和修改”。
6. reactive():对象级响应式,常用于表单
如果是简单值,例如字符串、数字、布尔值、数组引用,常用 ref():
const keyword = ref('')
const isLoading = ref(false)
const chatList = ref([])
如果是一个对象,尤其是表单对象,常用 reactive():
const loginForm = reactive({
username: '',
password: ''
})
模板里可以这样绑定:
<el-input v-model="loginForm.username" />
<el-input v-model="loginForm.password" type="password" />
用户输入时,loginForm.username 会变;代码修改 loginForm.username 时,输入框也会变。
Java 开发者可以把 reactive() 理解成一个被代理过的对象:
LoginForm loginForm = proxy(new LoginForm());
只不过这个代理不是做事务、日志、鉴权,而是做“依赖收集”和“变更通知”。
需要注意:ref() 和 reactive() 的用法边界不要过度纠结。入门阶段可以先记:
单个值:ref
对象表单:reactive
数组列表:ref([]) 更常见
7. computed():不是普通函数,而是带缓存的派生状态
在 Java 里你可能会写:
public boolean hasContent() {
return conversationTurns.size() > 0;
}
在 Vue 里对应的常见写法是:
const hasContent = computed(() => conversationTurns.value.length > 0)
computed() 可以理解成“派生状态”:它不直接存储新数据,而是根据已有状态计算出来。
比如:
const filteredChats = computed(() => {
return chatList.value.filter(chat => chat.title.includes(keyword.value))
})
它的意思是:filteredChats 依赖 chatList 和 keyword。只要这两个依赖没变,Vue 可以复用上一次结果;依赖变了,它才重新计算。
所以 computed() 不是普通函数每次都无脑执行。它有缓存。
什么时候用 computed()?
适合:
已有状态 -> 推导出另一个展示状态
例如:
- 根据
conversationTurns判断是否有内容。 - 根据
chatList + keyword得出过滤后的历史会话。 - 根据
isLoading + userMessage判断按钮是否可点击。 - 根据
props.traces统计工具调用完成数量。
不适合:
发送请求、写 localStorage、滚动 DOM、弹消息提示
这些是副作用,应该放到函数或 watch() 里。
8. watch():不是万能监听器,而是做副作用的地方
Java 后端开发者看到 watch(),容易联想到监听器、AOP、事件订阅,于是想把很多逻辑都塞进去。
Vue 里的 watch() 更适合处理“状态变化后要做的副作用”。
例如:
watch(inputValue, (value) => {
emit('update:modelValue', value)
})
这表示:当 inputValue 变化时,向父组件发出更新事件。
再比如:
watch(conversationTurns, async () => {
await nextTick()
scrollToBottom()
}, { deep: true })
这表示:当消息列表变化后,等待 DOM 更新完成,再滚动到底部。
watch() 常见用途包括:
- 输入变化后请求搜索建议。
- props 变化后同步到本地状态。
- 消息流变化后滚动到底部。
- Markdown 内容变化后等待 DOM 更新并做高亮。
- 抽屉打开后定位到某个树节点。
但如果只是“根据 A 算出 B”,优先用 computed(),不要用 watch() 手动维护另一个变量。
错误倾向:
watch(chatList, () => {
filteredChats.value = chatList.value.filter(...)
})
更推荐:
const filteredChats = computed(() => chatList.value.filter(...))
一个简单判断标准:
纯计算:computed
有副作用:watch
用户操作入口:函数
9. v-model:双向绑定不是魔法,而是“传值 + 监听更新事件”
v-model 是 Java 后端开发者最需要理解的 Vue 语法糖之一。
你看到:
<el-input v-model="userMessage" />
不要把它理解成“输入框直接拿到了 userMessage 的内存地址并随便改”。
它更接近:
<el-input
:model-value="userMessage"
@update:model-value="userMessage = $event"
/>
也就是说:
- 当前组件把
userMessage的值传给输入框。 - 用户输入时,输入框发出
update:modelValue事件。 - 当前组件收到事件,把新值写回
userMessage。
所以 v-model 是下面两件事的组合:
属性绑定:当前组件 -> 子组件/输入框
事件绑定:子组件/输入框 -> 当前组件
在自定义组件里也是一样。
父组件:
<HomeSearchBox
v-model="userMessage"
:placeholder="placeholderCarouselText"
:disabled="isLoading"
@submit="handleHomeSearchSubmit"
/>
子组件:
const props = defineProps({
modelValue: String,
placeholder: String,
disabled: Boolean
})
const emit = defineEmits(['update:modelValue', 'submit'])
const inputValue = ref(props.modelValue)
watch(inputValue, (value) => {
emit('update:modelValue', value)
})
站在 Java 视角,可以理解成:
父组件字段:String userMessage
子组件入参:String modelValue
子组件内部状态:String inputValue
子组件回调父组件:emit("update:modelValue", value)
重点是:子组件并不是直接修改父组件变量,而是通过约定事件告诉父组件“新值是什么”。
这就解释了 Vue 里常说的:
props 向下,events 向上。
10. 属性绑定、事件绑定、双向绑定:三件事必须分清
Vue 模板里最常见的三种绑定是:
:xxx 属性绑定
@xxx 事件绑定
v-model 双向绑定
10.1 属性绑定:当前状态传给标签或子组件
<ConversationTimeline :turns="conversationTurns" />
<el-button :loading="isLoading" :disabled="!userMessage.trim()" />
这表示当前组件把状态传下去。
方向是:
当前组件 -> 标签 / 子组件
10.2 事件绑定:标签或子组件把动作通知当前组件
<el-button @click="handleSendMessage" />
<ChatHistory @chat-selected="handleChatSelected" />
这表示当某件事发生时,调用当前组件里的函数。
方向是:
标签 / 子组件 -> 当前组件
10.3 双向绑定:属性绑定 + 更新事件
<el-input v-model="userMessage" />
方向是:
当前组件 -> 输入框
输入框 -> 当前组件
可以浓缩成:
:xxx 我给别人值
@xxx 别人通知我事
v-model 我给别人值,别人变化后再通知我写回来
11. props 和 emit:不要按 Service 方法调用链理解组件通信
Vue 组件通信最常见的方式是:
父组件通过 props 把数据传给子组件。
子组件通过 emit 把事件通知父组件。
例如父组件:
<ChatHistory
:selected-chat-id="currentChatId"
@chat-selected="handleChatSelected"
@new-chat="handleNewChat"
/>
子组件:
const props = defineProps({
selectedChatId: String
})
const emit = defineEmits(['chat-selected', 'new-chat'])
当用户点击某个历史会话时,子组件可能会执行:
emit('chat-selected', chat)
父组件监听到了:
@chat-selected="handleChatSelected"
于是执行:
const handleChatSelected = (chat) => {
currentChatId.value = chat.chatId
// 加载聊天详情
}
注意,子组件不是直接调用父组件的 handleChatSelected()。它只是发出一个事件。父组件愿意怎么处理,是父组件自己的事。
这和后端里 Service 直接调用另一个 Service 不同。组件通信强调边界:
子组件负责展示和报告用户动作。
父组件负责保存状态和决定业务动作。
为什么要这样设计?
因为子组件如果直接改父组件状态,就会造成状态来源混乱。你会不知道一个字段到底是父组件改的、子组件改的、接口回调改的、还是 watch 改的。
所以 Vue 推荐:
父组件是状态所有者。
子组件是状态使用者。
子组件要改状态时,发事件给父组件。
12. defineProps() 和 :props="treeProps" 不是一回事
这里有一个非常容易混淆的点:props 这个词在 Vue 项目里可能出现两种含义。
第一种是真正的 Vue 组件 props:
const props = defineProps({
turns: Array,
selectedChatId: String
})
这表示当前组件声明自己能接收哪些父组件传来的数据。
第二种只是某个组件库自己的属性名,例如 Element Plus 的 el-tree:
<el-tree :data="treeData" :props="treeProps" />
这里的 :props="treeProps" 不是在声明 Vue 组件 props,而是给 el-tree 传一个配置对象,告诉树组件:
const treeProps = {
label: 'title',
children: 'children'
}
意思类似:树节点用哪个字段当标题,用哪个字段当子节点。
所以看到 props 要先判断上下文:
defineProps(...):Vue 组件声明入参。
:props="xxx":把名叫 props 的属性传给某个组件,具体含义看组件库定义。
13. v-if 和 v-show:一个是创建/销毁,一个是显示/隐藏
后端开发者很容易把 v-if 和 v-show 都理解成“控制显示”。但它们的机制不同。
<div v-if="isLogin">已登录</div>
<div v-show="isLogin">已登录</div>
v-if:条件为 false 时,这个节点根本不存在于 DOM 中。
v-show:节点一直存在,只是通过 CSS 的 display: none 隐藏。
可以这样理解:
| 指令 | false 时 | 适合场景 |
|---|---|---|
v-if | DOM 不存在,组件会被销毁 | 条件不满足就不该存在,例如登录页/主页面切换 |
v-show | DOM 还在,只是隐藏 | 高频切换显示隐藏,需要保留内部状态 |
例如一个复杂地图、图表、输入框,如果只是频繁展示/隐藏,用 v-show 可能更合适,因为它不会每次销毁重建。
但如果是权限不允许、数据不存在、某个组件完全不该出现,用 v-if 更清晰。
14. v-for 和 :key:不是简单循环,而是给 DOM 身份
Vue 列表渲染常见写法:
<div v-for="chat in chatList" :key="chat.chatId">
{{ chat.title }}
</div>
v-for 看起来像 Java 的 for-each,但它不只是循环生成字符串。Vue 会根据数组生成一组虚拟节点,然后和旧节点比较,决定哪些真实 DOM 需要新增、删除、移动或复用。
所以 :key 很重要。
:key="chat.chatId"
它告诉 Vue:这个节点的稳定身份是 chat.chatId。
如果没有稳定 key,Vue 可能错误复用 DOM,导致一些奇怪问题:
- 删除一项后,输入框内容错位。
- 列表动画异常。
- 某个项的选中状态跑到另一项上。
- 子组件内部状态被错误复用。
后端类比:
:key 类似数据库主键。
不要随便用数组下标当 key,除非列表完全静态、不插入、不删除、不排序。
15. @click.stop、@submit.prevent:事件修饰符不是奇怪语法,而是帮你少写样板代码
Vue 的事件绑定可以带修饰符。
例如:
<button @click.stop="deleteChat(chat.chatId)">
删除
</button>
.stop 表示阻止事件冒泡。
典型场景是历史会话列表:
<div class="chat-item" @click="selectChat(chat)">
<span>{{ chat.title }}</span>
<button @click.stop="deleteChat(chat.chatId)">删除</button>
</div>
如果没有 .stop,你点删除按钮时,点击事件会从 button 冒泡到外层 div,导致同时触发 selectChat(chat)。结果就是:你明明想删除,却先选中了这个会话。
.stop 等价于:
event.stopPropagation()
deleteChat(chat.chatId)
再看:
<form @submit.prevent="handleLogin">
...
</form>
.prevent 表示阻止浏览器默认行为。表单提交的默认行为通常会刷新页面,而单页应用不希望这样。
它等价于:
event.preventDefault()
handleLogin()
所以事件修饰符不是神秘语法,而是 Vue 帮你把常见 DOM 事件样板代码简化掉。
16. nextTick():为什么状态改了,DOM 还没立刻变?
Java 后端里,执行:
obj.setName("newName");
下一行读 obj.getName() 肯定是新值。
Vue 里状态也是立刻变的,但 DOM 更新不是同步立刻完成。Vue 会把多次状态变化合并到下一轮 DOM 更新中,避免频繁操作真实 DOM。
例如:
conversationTurns.value.push(newTurn)
scrollToBottom()
这里可能有问题:你刚 push 了新消息,但真实 DOM 里的新消息节点可能还没渲染出来,立刻滚动到底部可能滚不到真正底部。
所以要写:
conversationTurns.value.push(newTurn)
await nextTick()
scrollToBottom()
nextTick() 的含义是:
等 Vue 把本轮状态变化对应的 DOM 更新做完,再执行后面的代码。
常见场景:
- 新消息出现后滚动到底部。
- 抽屉打开后定位树节点。
- 输入框重新出现后自动 focus。
- Markdown 渲染完成后做代码高亮。
- 条件渲染后的元素需要测量宽高。
入门时可以记一句:
状态改完后,如果下一步要操作真实 DOM,就考虑 nextTick。
17. 插槽 slot 和 #prefix:父组件把一段模板交给子组件渲染
你可能会看到:
<el-autocomplete v-model="inputValue">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #default="{ item }">
<div class="suggestion-item">
{{ item.value }}
</div>
</template>
</el-autocomplete>
#prefix、#default 这些是插槽语法。
插槽可以理解成:子组件预留了几个坑,父组件决定这些坑里放什么内容。
例如 el-autocomplete 内部可能设计了:
prefix 插槽:输入框前面放什么
默认插槽:每个候选项怎么渲染
父组件通过:
<template #prefix>
...
</template>
告诉子组件:“你的 prefix 位置放这个图标。”
通过:
<template #default="{ item }">
...
</template>
告诉子组件:“你的每个候选项按我这个模板渲染;其中 item 是你传出来的数据。”
Java 后端类比:
普通 props:把数据传给子组件。
slot:把一段展示模板传给子组件。
这比单纯传字符串更灵活。比如候选项不只是显示文字,还要显示图标、标签、副标题、高亮内容,就适合用插槽。
常见插槽:
| 写法 | 含义 |
|---|---|
#prefix | 前缀区域 |
#suffix | 后缀区域 |
#header | 卡片或面板头部 |
#title | 折叠项标题 |
#dropdown | 下拉菜单内容 |
#label | 标签页标题 |
#default | 默认内容区域 |
18. scoped CSS 和 :deep():为什么样式有时改不到组件内部?
Vue 文件里经常写:
<style scoped>
.chat-card {
border-radius: 12px;
}
</style>
scoped 表示这些 CSS 默认只影响当前组件,避免全局污染。
Vue 大概会给当前组件的 DOM 加上类似这样的标记:
<div class="chat-card" data-v-xxxx></div>
然后 CSS 也变成:
.chat-card[data-v-xxxx] {
border-radius: 12px;
}
这样它就不会误伤其他组件里的 .chat-card。
但问题是,如果你想改子组件内部结构,比如 Element Plus 的内部 DOM:
<el-avatar class="user-avatar" />
你直接写:
.user-avatar .el-avatar {
width: 40px;
}
可能改不到,因为 .el-avatar 是子组件内部生成的 DOM,scoped 规则默认不穿透进去。
这时要用:
.user-avatar :deep(.el-avatar) {
width: 40px;
height: 40px;
}
:deep() 的含义是:穿透 scoped 边界,去修改子组件内部 DOM。
常见使用场景:
- 修改 Element Plus 内部样式。
- 修改
v-html渲染出来的 Markdown 内容。 - 修改第三方组件内部结构。
但不要滥用 :deep()。它会增加组件对第三方组件 DOM 结构的依赖。一旦组件库升级内部结构变了,你的样式可能失效。
19. Element Plus 组件:el-button 不是 HTML 标签,而是 Vue 组件
你会看到很多:
<el-button type="primary" :loading="isLoading">
发送
</el-button>
<el-input v-model="keyword" :prefix-icon="Search" />
<el-drawer v-model="drawerVisible" append-to-body>
...
</el-drawer>
这些 el- 开头的标签不是浏览器原生标签,而是 Element Plus 组件。
可以理解成:
Element Plus = 前端 UI 组件库
el-button = 封装好的按钮组件
el-input = 封装好的输入框组件
el-drawer = 封装好的抽屉组件
el-tree = 封装好的树组件
它们和 Java 后端里的“框架组件”有点类似:你不需要自己从零写按钮、输入框、表单校验、弹窗、抽屉、树形控件,而是通过组件属性和事件进行配置。
例如:
<el-button
type="primary"
:loading="isLoading"
:disabled="!userMessage.trim()"
@click="handleSendMessage"
>
发送
</el-button>
这里:
| 属性/事件 | 含义 |
|---|---|
type="primary" | 按钮样式类型,固定字符串 |
:loading="isLoading" | 动态加载状态 |
:disabled="!userMessage.trim()" | 输入为空时禁用 |
@click="handleSendMessage" | 点击时发送消息 |
el-icon 也值得单独说。通常写法是:
<el-icon><Search /></el-icon>
el-icon 是图标外壳,负责大小、颜色、对齐;<Search /> 才是真正的图标组件。
20. teleported / append-to-body:为什么抽屉、弹窗要挂到 body 下?
有些组件会写:
<el-drawer
v-model="drawerVisible"
append-to-body
>
...
</el-drawer>
或者类似:
<el-dialog teleported>
...
</el-dialog>
它们的核心目的是:把弹层、抽屉、下拉框这类浮层挂到 body 下,而不是留在当前组件 DOM 层级里。
为什么?
因为当前组件的父容器可能有:
overflow: hidden;
position: relative;
z-index: 1;
transform: translateX(...);
这些 CSS 可能导致弹层被裁切、层级不够、定位异常。
挂到 body 下以后,弹层就更容易覆盖整个页面,不受当前局部容器限制。
后端视角可以理解成:
它不是改变数据流,而是改变 DOM 挂载位置,解决视觉层级和裁切问题。
21. v-html、Markdown、DOMPurify:为什么不能直接把后端字符串塞页面?
聊天项目里,后端经常返回 Markdown 文本。前端可能这样渲染:
<div class="answer-richtext" v-html="renderedMarkdown"></div>
v-html 的意思是:把字符串当 HTML 插入页面。
这很危险。因为如果字符串里有:
<script>alert('xss')</script>
或者恶意事件属性,就可能造成 XSS 风险。
所以常见链路是:
后端返回 Markdown
-> markdown-it 转成 HTML
-> DOMPurify 清洗危险 HTML
-> v-html 插入页面
其中:
| 工具 | 作用 |
|---|---|
markdown-it | 把 Markdown 转成 HTML |
DOMPurify | 清洗 HTML,降低 XSS 风险 |
highlight.js | 给代码块做语法高亮 |
v-html | 把清洗后的 HTML 插入页面 |
这里要特别注意:前端清洗只是防线之一,后端也不能完全信任用户输入或模型输出。
22. 浏览器里的状态持久化:localStorage 不是 Redis,也不是 Session
前端项目里常见:
localStorage.setItem('sessionId', sessionId)
localStorage.setItem('currentUser', JSON.stringify(user))
localStorage 是浏览器本地存储。刷新页面后,它还在。
所以登录后,页面刷新时可以从 localStorage 里恢复:
const sessionId = localStorage.getItem('sessionId')
const currentUser = JSON.parse(localStorage.getItem('currentUser'))
但是它不是后端 Session,也不是 Redis。
特点:
| 特点 | 说明 |
|---|---|
| 存在浏览器本地 | 用户自己浏览器里的一小块存储 |
| 刷新页面不丢 | 只要不清除浏览器数据就还在 |
| 容易被前端 JS 读取 | 不适合保存高敏感密钥 |
| 和服务端状态不是一回事 | 后端仍然要校验 token/session |
后端开发者不要误以为 localStorage 里的状态一定可信。它只是前端恢复 UI 状态的辅助。
23. SSE / EventSource:接口不是一次性 JSON,而是持续推事件
后端开发者熟悉普通接口:
请求 /api/chat
返回一个 JSON
请求结束
但聊天流式输出可能是 SSE:
请求 /api/chat/stream
后端持续返回 event: thinking
后端持续返回 event: tool_plan
后端持续返回 event: content
后端持续返回 event: complete
连接结束
前端可以用:
const eventSource = new EventSource(url)
eventSource.addEventListener('content', (event) => {
const data = JSON.parse(event.data)
assistantTurn.content += data.delta
})
每收到一个事件,前端就修改响应式状态。于是页面逐字更新、工具调用过程逐步出现、状态标签不断变化。
这和普通 JSON 接口的区别是:
| 普通 JSON 接口 | SSE 流式接口 |
|---|---|
| 一次请求,一次响应 | 一次连接,多次事件 |
| 前端等完整结果 | 前端边收边渲染 |
| 适合普通 CRUD | 适合聊天、日志、进度、通知 |
| 返回体结束后才更新完整数据 | 每个事件都可能触发页面局部更新 |
所以你看到聊天页面“自己一点点出现内容”,不要理解成页面一直刷新,而是 SSE 一直推事件,前端一直改状态,Vue 一直局部更新 DOM。
24. 一次聊天发送的完整前端链路
把前面的概念串起来,一次聊天发送大概是这样:
用户在输入框输入
-> v-model 更新 userMessage
用户点击发送按钮
-> @click 调用 handleSendMessage
handleSendMessage 设置 isLoading = true
-> 按钮进入 loading,输入框禁用
handleSendMessage 往 conversationTurns 追加用户消息和助手占位消息
-> ConversationTimeline 局部渲染新气泡
前端连接后端 SSE
-> 收到 intent / thinking / tool_plan / content / complete 等事件
每个事件修改当前 assistant turn
-> Vue 局部刷新回答内容、工具剧场、证据面板
watch 监听消息变化
-> nextTick 后滚动到底部
收到 complete
-> isLoading = false
-> 按钮恢复,输入框可继续输入
这个链路里,你应该关注的是状态如何流动:
输入框 -> userMessage -> 发送函数 -> conversationTurns -> 子组件 props -> 页面展示
SSE -> assistantTurn -> conversationTurns -> 页面局部更新
而不是纠结“哪个地方刷新了整个页面”。实际没有整页刷新。
25. 从 Java/Spring 到 Vue 的类比表
| Java / Spring 概念 | Vue 近似概念 | 关键差异 |
|---|---|---|
| Controller 方法 | 事件处理函数 | Controller 一次请求执行一次;事件函数在浏览器里反复被触发 |
| DTO | props / 接口返回对象 | props 是父组件传入,不一定来自后端接口 |
| Service | apiService / 业务函数 | 前端函数通常异步执行,且会修改 UI 状态 |
| Bean 属性 | ref / reactive | Vue 状态变化会自动驱动 DOM 更新 |
| getter | computed | computed 有依赖追踪和缓存 |
| 监听器 / AOP | watch | watch 更适合处理副作用,不适合替代计算属性 |
| ApplicationEvent | emit | emit 主要是组件树内通信,不是全局事件总线 |
| JSP / Thymeleaf | template | Vue 模板在前端响应式更新,不是后端一次性渲染 |
| XML 属性 | Vue 动态属性 | :xxx 右侧是 JS 表达式,不是字符串 |
| 表单对象 | reactive form | 输入变化会反向更新响应式对象 |
| Session / Redis | localStorage | localStorage 在浏览器本地,不可信也不等于后端会话 |
| 拦截器清理资源 | onBeforeUnmount | 组件卸载前清理定时器、SSE、事件监听 |
26. 读 Vue 文件的推荐顺序
不要一上来就从 CSS 读,也不要先陷入每个函数。对一个 .vue 文件,建议按下面顺序:
第一步:先看 <template> 组件树
看它大概渲染什么区域:
- 是登录表单?
- 是聊天历史?
- 是消息时间线?
- 是右侧知识库面板?
- 是地图卡片?
- 是工具调用剧场?
先建立页面结构,不要急着读每个表达式。
第二步:标出所有 v-model
v-model 对应可变 UI 状态:
- 输入框内容。
- 抽屉是否打开。
- tabs 当前选中项。
- collapse 展开的面板。
- radio 当前选择。
这些是用户最直接能改变的状态。
第三步:标出所有 : 动态属性
例如:
:disabled="isLoading"
:loading="isLoading"
:class="{ active: selectedChatId === chat.chatId }"
:turns="conversationTurns"
这些说明页面哪些地方会随状态变化。
第四步:标出所有 @ 事件
例如:
@click="handleSendMessage"
@submit="handleSubmit"
@chat-selected="handleChatSelected"
@followup-click="handleFollowupClick"
这些是用户操作入口或子组件回调入口。
第五步:看 defineProps()
确认这个组件依赖父组件传入哪些数据。
const props = defineProps({
turns: Array,
isLoading: Boolean
})
第六步:看 defineEmits()
确认这个组件会把哪些事件通知给父组件。
const emit = defineEmits(['submit', 'select', 'delete'])
第七步:看 ref/reactive/computed/watch
区分:
| 类型 | 看什么 |
|---|---|
ref / reactive | 这个组件自己维护什么状态 |
computed | 哪些展示数据是派生出来的 |
watch | 哪些状态变化会触发副作用 |
第八步:最后看 CSS
CSS 不只是美化,它负责把状态变成视觉效果。尤其看:
.active
.is-loading
.disabled
.stale
:deep(...)
@media (...)
@keyframes ...
这些通常对应 UI 状态、第三方组件穿透、响应式布局和动画。
27. 你最容易卡住的困惑清单
27.1 我能看懂标签,但不知道页面为什么变了
原因:你没有把 ref/reactive 视为响应式状态。
解决:看到状态修改,就去 template 里找谁依赖它。
isLoading.value = true
对应:
<el-button :loading="isLoading" />
<div v-if="isLoading">加载中</div>
27.2 我能看懂 v-model,但不知道谁改了谁
解决:把它拆成:
:modelValue 传入
@update:modelValue 写回
27.3 我看到 props,不知道是不是后端 DTO
解决:props 是父组件传给子组件的入参,不一定来自后端。
27.4 我看到 emit,以为是在调用父组件方法
解决:emit 是发事件,父组件是否监听、怎么处理,由父组件决定。
27.5 我看到 <template #prefix> 不知道这是什么标签
解决:它不是普通 DOM 标签,而是插槽模板。父组件把一段模板交给子组件渲染。
27.6 我看到 ref() 和 ref="xxx" 混淆
两者不是一回事:
const isLoading = ref(false)
这是响应式状态。
<el-input ref="inputRef" />
这是模板引用,用来拿 DOM 或子组件实例。
27.7 我看到 :deep() 不知道为什么 CSS 要穿透
因为 scoped CSS 默认不影响子组件内部 DOM。要改 Element Plus 内部样式或 v-html 生成内容时,需要 :deep()。
27.8 我看到页面刷新,以为是重新请求了页面
很多情况下不是刷新页面,而是响应式状态变了,Vue 局部更新 DOM。
28. Vue 里的“页面刷新”应该怎么调试?
后端排查问题时,你可能会看日志、断点、SQL、接口返回。前端排查 Vue 页面更新,也有类似思路。
28.1 先问:这个页面由哪个状态控制?
例如按钮为什么禁用?
<el-button :disabled="isLoading || !userMessage.trim()">
那就看:
isLoading.value
userMessage.value
28.2 再问:这个状态在哪里被修改?
搜索:
isLoading.value =
userMessage.value =
conversationTurns.value
找到谁在改它。
28.3 再问:这个状态有没有传给子组件?
例如:
<ConversationTimeline :turns="conversationTurns" />
那就去子组件里看:
const props = defineProps({
turns: Array
})
28.4 再问:子组件有没有 emit 事件回来?
例如:
<ChatHistory @chat-selected="handleChatSelected" />
那就去子组件找:
emit('chat-selected', chat)
这样你就能把前端代码还原成:
状态在哪里定义
状态在哪里修改
状态传给谁
谁又通过事件通知回来
这和后端排查调用链很像,只是调用链从“方法调用”变成了“状态和事件流”。
29. 对 Java 后端来说,学习 Vue 的优先级
你不需要一开始就把所有前端生态都学完。按你的背景,建议优先级如下。
P0:必须掌握
ref()/.valuereactive()computed()watch()v-if/v-showv-for/:keyv-model:xxx动态属性@xxx事件绑定props/emit<script setup>基本写法- 组件父子通信
- 接口请求
async/await
这些决定你能不能读懂业务页面。
P1:必须逐步熟悉
- slot /
#prefix/#default - Element Plus 组件属性和事件
nextTick()onMounted()/onBeforeUnmount()localStorage- SSE / EventSource
v-html、Markdown 渲染、安全清洗- scoped CSS /
:deep()
这些决定你能不能读懂复杂交互和组件库代码。
P2:后续再深入
- Vue Router
- Pinia / 状态管理
- Vite 构建配置
- 组件抽象设计
- 性能优化
- 虚拟 DOM diff 细节
- TypeScript 类型增强
- 前端工程化、测试和打包
这些不是不重要,而是不用在第一轮补盲时全部压上来。
30. 最后的核心总结
作为 Java/Spring 后端开发者,你学 Vue 最大的障碍不是“不会写 JavaScript”,而是要换掉几个后端直觉:
第一,不要把 Vue 页面理解成后端模板的一次性渲染。Vue 是浏览器里长期运行的响应式应用。
第二,不要把 <template> 当成 XML 配置。:xxx、@xxx、v-model、v-if、v-for、#slot 都是 Vue 模板编译语法。
第三,不要把 v-model 当成神秘双向魔法。它本质是“属性传值 + 更新事件”。
第四,不要把组件通信理解成随便调用对方方法。常规组件通信是 props 向下、emit 向上。
第五,不要把 class 只看成样式名。前端经常把 class 当作 UI 状态标记,状态变化后 CSS 决定视觉效果。
第六,不要用“整个页面刷新”的思路看 Vue。大多数变化只是响应式状态驱动的局部 DOM 更新。
第七,不要急着背 API。先建立这条主线:
用户操作 / 接口返回 / SSE 推送
-> 修改 ref / reactive 状态
-> computed 推导展示状态
-> template 根据状态重新渲染
-> 子组件通过 props 接收数据
-> 子组件通过 emit 把事件交回父组件
-> CSS class / scoped style 把状态变成视觉效果
当你能按这条线去读 .vue 文件时,Vue 就不再是一堆奇怪符号,而是一个前端版的“状态驱动系统”。你原本的 Java/Spring 经验依然有价值:你仍然会用分层、边界、状态一致性、事件流、接口契约去理解系统。只是到了前端,这些概念的落点从 Controller、Service、DTO,变成了组件、props、emit、ref、computed、watch 和 template。