Lesson 03 · Vue 3

Vue 3 速成,只学用得上的

你已经会 TypeScript。缺的不是语法,是「界面该怎么描述」这套思路。 全程用项目里的真实文件当教材。

基线:2026-08-12 的 gameboxclient 工作区快照。 本课引用的每个文件都可以在项目里直接打开对照着看。

先复习

1. 在 Network 面板里,怎么分辨两个请求分别在干什么?

2. 请求最终打到测试服还是正式服,由什么决定?

先说人话:Vue 和 Laya 的根本区别

在 LayaBox 里,你是这样做界面的:

// 你亲手把界面搭出来,也亲手把它改掉
const label = new Laya.Label()
label.text = "血量:" + hp
this.addChild(label)

// hp 变了?你得记得自己去更新
function onHpChange(newHp) {
  label.text = "血量:" + newHp
}

你负责「怎么变」。数据变了,你要亲自找到对应的显示对象、 亲自改它的属性。忘了改,界面就跟数据对不上。

Vue 反过来:

<template>
  <div>血量:{{ hp }}</div>
</template>

<script setup>
const hp = ref(100)
// 以后只要改 hp.value,那个 div 自己会更新
</script>

你只负责「是什么」。你描述「这个 div 里显示 hp」, 然后只管改 hp.value。谁去更新 DOM、什么时候更新,Vue 管。

这是整节课唯一真正要转过来的弯

从「我去改界面」转成「我改数据,界面自己跟」。 剩下的 refcomputedv-for 全都是这一件事的具体写法。

如果你在代码里看到有人用 document.querySelector 去改 DOM, 那基本上就是没转过这个弯 —— 在 Vue 项目里这是个警号。

一、单文件组件(SFC):三段结构

.vue 文件就是三块拼起来的。看项目里最短的一个真实文件, src/views/GameEditor/cosDownload/index.vue, 一共 51 行,去掉样板后是这样:

<template>
  <!-- 界面长什么样 -->
  <ElButton @click="clickDownloadCosResources()">查找服务器已有文件</ElButton>
</template>

<script lang="ts" setup>
  // 数据和逻辑
  import { ElMessage } from 'element-plus'
  import Fs from 'node:fs'

  const clickDownloadCosResources = async () => {
    // …下载并写文件
    ElMessage({ type: 'success', message: '下载成功' })
  }
</script>

<style lang="less" scoped>
  /* 样式,scoped 表示只作用于本组件 */
</style>

<script setup> 是 Vue 3 的语法糖: 这个块里定义的所有顶层变量和函数,模板里可以直接用, 不需要 return,不需要 this

项目里还有第二种写法。src/App.vue 用的是 defineComponent({ setup() { return () => <div>…</div> } }) —— 没有 <template>,直接用 TSX 返回渲染函数。 src/layout/Header.vue 也是这种。

两种都是合法的 Vue 3,功能等价。新代码统一用 <script setup>,它是官方推荐、也是项目里的多数派。

二、响应式:ref 和那个烦人的 .value

Vue 需要知道「哪些数据变了要重画界面」。JS 没法监听一个普通变量的赋值, 所以 Vue 把值包进一个对象里,通过这个对象的 getter/setter 做拦截。 这个包装就是 ref

import { ref } from 'vue'

const delList = ref([])        // 包起来

delList.value = jsonData.data  // 在 script 里必须写 .value
<!-- 但在模板里不用写 .value,Vue 自动拆包 -->
<el-table :data="delList">

.value 是新手最高频的错误来源

规则很简单:<script> 里要写, <template> 里不写。

忘了写 .value 的症状很典型:不报错,但界面不动。 因为你只是把那个 ref 对象整个替换掉了,Vue 追踪的还是原来那个。 看到「数据明明改了界面没反应」,先检查 .value

ref vs reactive

ref reactive
能包什么 任何值,包括数字、字符串 只能是对象或数组
怎么访问 script 里要 .value 直接用,像普通对象
能整个替换吗 能,x.value = 新对象 不能x = 新对象 会丢掉响应式
项目里 绝大多数(Npc/index.vuemapIDcheckList…) 少数(Npc/index.vue:77searchData

建议:统一用 ref多写几个 .value 换来一致性,比记两套规则划算。这也是 Vue 官方文档的倾向。

computed:派生数据

「由别的数据算出来的值」用 computed,它会缓存, 依赖不变就不重算。src/components/PropEditor/Components/Select.vue 里有个现成的:

const getLabel = computed(() => Props.config?.meta?.label || Props.config.summary)

「标签文字优先用 meta.label,没有就用 summary」—— 这种逻辑放 computed 里,模板保持干净。

watch:数据变了要做副作用

同一个文件的下半段:

watch(
  () => Props.row[Props.prop],   // 监听什么
  (val: any) => {                // 变了做什么
    currentData.value = val
  },
  { immediate: true }            // 立即先跑一次
)

immediate: true 表示不等它变,挂载时先执行一次。 在这里是为了让控件初始就显示出当前值。这个选项在项目里出现频率很高。

什么时候用 computed,什么时候用 watch?算出一个值computed; 要做一件事(发请求、改别的状态、写日志)用 watch。 如果你在 watch 里只是给另一个变量赋值,多半该用 computed

三、模板语法:五个符号

现在精读一个完整文件。src/views/GameEditor/DelData/index.vue, 91 行,是项目里结构最清晰的页面之一。它的模板:

<template>
  <div class="p-20px w-full">
    <el-table :data="delList">
      <el-table-column prop="excelName" label="表名" />
      <el-table-column prop="id" label="删除ID" />
      <el-table-column prop="data" label="删除数据" show-overflow-tooltip />
      <el-table-column label="操作" fixed="right">
        <template #default="{ row }">
          <el-button type="primary" size="small" @click="revert(row)">恢复</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>
写法 叫什么 意思
{{ hp }} 插值 把这个变量的值渲染成文本
:data="delList" 属性绑定(v-bind 的简写) 冒号是关键。有冒号 = 传变量; 没冒号 = 传字符串字面量。label="表名" 传的就是「表名」四个字
@click="revert(row)" 事件(v-on 的简写) 点击时调用这个函数
v-if="…" / v-for="…" 条件 / 循环 控制这个元素渲不渲染、渲染几个
v-model="mapID" 双向绑定 表单控件专用。等于 :modelValue 加一个 @update:modelValue,用户改控件时自动写回变量

冒号漏写是最隐蔽的 bug

:disabled="false" 传的是布尔值 false → 不禁用。
disabled="false" 传的是字符串「false」→ 非空字符串是真值 → 禁用了

看到「按钮怎么点不动」,先检查那个冒号。

v-for 要带 key

src/views/GameEditor/Npc/index.vue 里的例子:

<ElOption
  v-for="item in NpcListData"
  :key="item.id"
  :label="`${item.npcName}(${item.id})`"
  :value="item.id"
/>

:key 让 Vue 知道列表更新时哪一项是哪一项。 用数据本身的唯一 id,不要用数组下标 —— 用下标的话,列表中间插入一项会导致后面全部错位重渲染, 带输入框的列表还会串值。

插槽(slot):把一段模板传给子组件

上面 DelData 那段里的 <template #default="{ row }"> 就是插槽。 意思是:「el-table-column 你负责画表格列, 但每个单元格里面画什么,我来告诉你 —— 而且请你把当前行的数据 row 递给我」。

这是 Element Plus 组件的常规用法,也是本项目 PropEditor 能做到通用的关键机制。

四、组件之间怎么传东西

src/components/PropEditor/Components/Select.vue, 它是被反复复用的叶子组件:

interface PropsType extends ParmasType {
  config: SelectType
}

const Props = defineProps<PropsType>()

defineProps<T>()<script setup> 里声明「我接收哪些参数」的写法,直接用 TS 类型声明,不用写运行时对象。 这对你应该很自然。

父组件传参就是普通的属性绑定(NpcEdit.vue:32-37):

<PropEditor
  prop="npc"
  :row="ViewData.NPC.currentData"
  :config="NpcConfig.get('npc') || {}"
  :change="modifyData"
/>

子组件怎么往回传

Vue 的标准答案是 emit:子组件声明自己会发出哪些事件, 父组件用 @事件名 接。

// 标准做法(本项目没这么写)
const emit = defineEmits<{ (e: 'change', value: any): void }>()
emit('change', val)

本项目用的是另一种:把回调函数当 prop 传下去

// src/components/PropEditor/type.ts
export interface ParmasType {
  prop: string
  row: any
  config: ExtendType
  /* 改变事件 - 用于接收改变后的值 */
  change: (value: any, prop?: string) => void   // ← 回调当 prop
}

// src/components/PropEditor/Components/Select.vue
function modify(val: any) {
  Props.change && Props.change(val)
}

⚠️ 隔离

用 prop 传回调函数代替 emit 整个 PropEditor 体系(一个入口 + 十几个控件组件)都建立在这个约定上, ParmasType 是它们共同的接口。你不能只改其中一个。

代价:Vue DevTools 的事件面板看不到这些「事件」, 因为它们根本不是事件;父子关系在调试器里也断了一截。 另外每个用到的地方都要写一遍 Props.change && Props.change(val) 防空判断。

规范做法defineEmits,见 Vue · 组件事件

你现在该做的:PropEditor 体系 照现有约定写(一致性 > 正确性,这里改一半更糟)。 体系的新组件一律用 emit

五、生命周期

你只需要三个:

钩子 什么时候 项目里拿来干什么
onBeforeMount 组件即将挂载,DOM 还没有 拉初始数据。Npc/index.vue:149 在这里拉地图列表
onMounted DOM 已经在了 需要真实 DOM 的初始化。App.vue:55 在这里注册 IPC 监听
onUnmounted 组件销毁时 清理。App.vue:68 在这里 removeListener

✅ 照做

src/App.vue:64 注册 ipcRenderer.on('decompress-finish', …):69onUnmounted 里对应 removeListener成对出现,这是对的。

在这个项目里尤其重要:IPC 监听、setInterval、 Node 的文件监听 —— 忘了清理不只是内存泄漏,还会出现 「一个事件触发多次」的诡异 bug(组件重建时又注册了一个)。

反面教材就在项目里: src/api/login/index.ts:105checkstartBuild() 起了个 setInterval, 只在拿到明确结束态时才清。Jenkins 那边一异常,这个定时器就永远转下去了。

六、这个项目里你会看到的坏味道

不是为了批判,是为了让你看到时不困惑、也不模仿

❌ 别学

importrequire 混用。 DelData/index.vue 上面用 import { ref } from 'vue', 下面几行又写 const fs = require('fs')

能跑是因为 nodeIntegration 开着(课 01 讲过), 但同一个文件两套模块系统会让打包器的静态分析失效, 也让「这个模块到底有没有被用到」变得无法判断。

规范做法:统一用 ESM。需要 Node 模块就写 import Fs from 'node:fs' —— cosDownload/index.vue 就是这么写的,同一个项目里两种风格都有。

❌ 别学

满屏的 any const gameEnv: any = useGameEvnWithOut() 这种写法在项目里到处都是。

你是 TS 背景的,这一条对你应该最刺眼:标了 any 等于把类型检查关掉了npm run ts:check 现在有几百个错误,很大一部分根源在这。

你能做的:不要求你去改存量。 但新写的代码不要加 any —— 遇到类型不对,就说明你发现了一个真实的类型问题,那正是你该弄清楚的地方。

❌ 别学

把逻辑写进模板的 class 表达式。 Select.vue 的模板里有这种:

:class="`label ${
  config?.meta?.class && config?.meta?.class.indexOf('flex-col') > -1
    ? 'w-full'
    : 'w-[calc(38%)]'
} m-1 flex items-center …`"

问题:模板里的表达式没有类型检查、没法加断点、 没法写测试,而且每次重渲染都要重新求值一遍字符串拼接。

规范做法:挪进 computed。 同一个文件里的 getLabel 就是正确示范 —— 作者知道怎么写对, 只是没有一致地执行。

七、Element Plus:204 个文件在用

界面组件全部来自 Element Plus。你不需要学完,只要知道遇到不认识的 El* 标签就去官网搜组件名,那里的 API 表就是全部答案。

项目里最高频的几个:

组件 干什么 在哪见过
ElSelect / ElOption 下拉框 选服、选地图、schema 里 editor: 'select' 的字段
ElTable / ElTableColumn 表格 所有列表页
ElMessage 右上角轻提示 几乎每个操作反馈
ElMessageBox.confirm 确认弹窗(返回 Promise) 删除确认、离开页面确认(src/permission.ts 的守卫里)
v-loading 指令形式的加载遮罩 App.vue 的全局 loading、各列表页
样式是三套并存的:Element Plus 自带主题、 WindiCSS 原子类(class="flex items-center p-1 w-full" 这种)、 以及 src/styles/ 下的 Less 变量。

改样式前先看清这个元素的样式来自哪一套。 WindiCSS 的类名规则见 Windi CSS 文档 —— 语法跟 Tailwind 基本一致。

真机验证

光看不动手,这一课等于没上。下面每一步都在真实文件上做,做完记得改回来。