Lesson 03 · Vue 3
你已经会 TypeScript。缺的不是语法,是「界面该怎么描述」这套思路。 全程用项目里的真实文件当教材。
gameboxclient 工作区快照。
本课引用的每个文件都可以在项目里直接打开对照着看。
1. 在 Network 面板里,怎么分辨两个请求分别在干什么?
答案:B。几乎所有业务请求都是
POST /1000y/engine/cmd,动作藏在 body 的
cmd 字段里。按 URL 筛选在这个项目里没用。
2. 请求最终打到测试服还是正式服,由什么决定?
答案:A。src/config/axios/service.ts:79 的
getBaseURL() 就是一句 pcode.includes('_dev'),
两个域名硬编码在函数里,默认分支是正式服。
C 有关但不直接 —— 选服会改写 pcode,是通过 A 起作用的。
在 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 管。
这是整节课唯一真正要转过来的弯
从「我去改界面」转成「我改数据,界面自己跟」。 剩下的
ref、computed、v-for
全都是这一件事的具体写法。
如果你在代码里看到有人用 document.querySelector 去改 DOM,
那基本上就是没转过这个弯 —— 在 Vue 项目里这是个警号。
.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 也是这种。 <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 reactiveref |
reactive |
|
|---|---|---|
| 能包什么 | 任何值,包括数字、字符串 | 只能是对象或数组 |
| 怎么访问 | script 里要 .value |
直接用,像普通对象 |
| 能整个替换吗 | 能,x.value = 新对象 |
不能。x = 新对象 会丢掉响应式 |
| 项目里 |
绝大多数(Npc/index.vue 的
mapID、checkList…)
|
少数(Npc/index.vue:77 的 searchData)
|
建议:统一用 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。
如果你在 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 要带 keysrc/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,不要用数组下标 ——
用下标的话,列表中间插入一项会导致后面全部错位重渲染,
带输入框的列表还会串值。
上面 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', …),
:69 在 onUnmounted 里对应
removeListener。成对出现,这是对的。
在这个项目里尤其重要:IPC 监听、setInterval、 Node
的文件监听 —— 忘了清理不只是内存泄漏,还会出现
「一个事件触发多次」的诡异 bug(组件重建时又注册了一个)。
反面教材就在项目里:
src/api/login/index.ts:105 的
checkstartBuild() 起了个 setInterval,
只在拿到明确结束态时才清。Jenkins 那边一异常,这个定时器就永远转下去了。
不是为了批判,是为了让你看到时不困惑、也不模仿。
❌ 别学
import 和 require 混用。
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。你不需要学完,只要知道遇到不认识的 El* 标签就去官网搜组件名,那里的 API 表就是全部答案。
项目里最高频的几个:
| 组件 | 干什么 | 在哪见过 |
|---|---|---|
ElSelect / ElOption |
下拉框 |
选服、选地图、schema 里 editor: 'select' 的字段
|
ElTable / ElTableColumn |
表格 | 所有列表页 |
ElMessage |
右上角轻提示 | 几乎每个操作反馈 |
ElMessageBox.confirm |
确认弹窗(返回 Promise) |
删除确认、离开页面确认(src/permission.ts 的守卫里)
|
v-loading |
指令形式的加载遮罩 | App.vue 的全局 loading、各列表页 |
class="flex items-center p-1 w-full" 这种)、 以及
src/styles/ 下的 Less 变量。 光看不动手,这一课等于没上。下面每一步都在真实文件上做,做完记得改回来。
src/views/GameEditor/DelData/index.vue, 在
<el-table> 上面加一行
<div>当前有 {{ delList.length }} 条</div>。
保存,进 /expert/delData 看数字出现
readJsonFile 的
delList.value = jsonData.data 那行下面, 加一句
console.log(delList.value.length)。 再试着把它改成
console.log(delList.length) —— 观察后者打出
undefined
ElButton 上加
disabled="false"(不带冒号),保存,
观察按钮被禁用了。再改成
:disabled="false", 按钮恢复可点
src/components/PropEditor/Components/Select.vue, 找到
getOptions。理解它在干什么:
当前值不在选项列表里时,补一个「其他(已失效)」进去。
这是很实际的防御 —— 配置表里的旧数据经常引用已删除的选项
watch 里加一句
console.log('watch 触发', val),
进任意编辑页,观察它在页面刚打开时就打了一次 —— 那是
immediate: true 的效果
npm run ts:check,错误数应该和你动手前一致