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 原生属性固定 classclass="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 }}
#prefixv-slot:prefix插槽简写<template #prefix>

重点是:有冒号就不是字符串,而是 JavaScript 表达式。

例如:

<el-button disabled="isLoading" />
<el-button :disabled="isLoading" />

第一行更像把字符串 "isLoading" 传进去;第二行才是读取变量 isLoading 的布尔值。

再例如:

<el-button title="删除" />
<el-button :title="isLoading ? '处理中' : '删除'" />

第一行是固定字符串;第二行是动态表达式。

所以你读模板时,可以按这个顺序:

  1. 看标签名:原生 HTML、Element Plus 组件,还是自定义 Vue 组件。
  2. 看普通属性:class="..."type="primary"size="small",这些大多是静态配置。
  3. 看冒号属性::disabled="...":class="...":turns="...",这些来自 JS 状态。
  4. 看事件:@click@submit@chat-selected,这些是用户或子组件触发的动作。
  5. v-ifv-forv-model,这些决定节点是否存在、重复多少次、是否和状态双向同步。
  6. 看标签中间内容:普通文本、{{ }} 插值、插槽内容或子组件。

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 依赖 chatListkeyword。只要这两个依赖没变,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"
/>

也就是说:

  1. 当前组件把 userMessage 的值传给输入框。
  2. 用户输入时,输入框发出 update:modelValue 事件。
  3. 当前组件收到事件,把新值写回 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. propsemit:不要按 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-ifv-show:一个是创建/销毁,一个是显示/隐藏

后端开发者很容易把 v-ifv-show 都理解成“控制显示”。但它们的机制不同。

<div v-if="isLogin">已登录</div>
<div v-show="isLogin">已登录</div>

v-if:条件为 false 时,这个节点根本不存在于 DOM 中。

v-show:节点一直存在,只是通过 CSS 的 display: none 隐藏。

可以这样理解:

指令false 时适合场景
v-ifDOM 不存在,组件会被销毁条件不满足就不该存在,例如登录页/主页面切换
v-showDOM 还在,只是隐藏高频切换显示隐藏,需要保留内部状态

例如一个复杂地图、图表、输入框,如果只是频繁展示/隐藏,用 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 一次请求执行一次;事件函数在浏览器里反复被触发
DTOprops / 接口返回对象props 是父组件传入,不一定来自后端接口
ServiceapiService / 业务函数前端函数通常异步执行,且会修改 UI 状态
Bean 属性ref / reactiveVue 状态变化会自动驱动 DOM 更新
gettercomputedcomputed 有依赖追踪和缓存
监听器 / AOPwatchwatch 更适合处理副作用,不适合替代计算属性
ApplicationEventemitemit 主要是组件树内通信,不是全局事件总线
JSP / ThymeleaftemplateVue 模板在前端响应式更新,不是后端一次性渲染
XML 属性Vue 动态属性:xxx 右侧是 JS 表达式,不是字符串
表单对象reactive form输入变化会反向更新响应式对象
Session / RedislocalStoragelocalStorage 在浏览器本地,不可信也不等于后端会话
拦截器清理资源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() / .value
  • reactive()
  • computed()
  • watch()
  • v-if / v-show
  • v-for / :key
  • v-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@xxxv-modelv-ifv-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。