前端 UI 开发从入门到精通(Vue 技术栈):HTTP、CSS 与主流组件库实战
- 约 22 分钟读完 - 全文 8631 字前端 UI 开发从入门到精通(Vue 技术栈):HTTP、CSS 与主流组件库实战
目标读者:会写一点 Vue,但面对「这个布局怎么调」「这个接口怎么接」「组件库样式怎么改」还是会心里发虚的同学。
姊妹篇:《前端开发从入门到精通:JavaScript → TypeScript → Vue 3 实战指南》讲的是语言内功,这篇讲的是 UI 实战。
先说一句可能得罪人的话:很多前端写了两三年业务,其实一直在「UI 开发的入门区」打转。会调组件库的 API ≠ 会做 UI 开发。真正的分水岭在于:你能不能在没有设计稿的情况下把页面排得不难看,能不能在接口报错时五分钟内定位是前端问题还是后端问题,能不能在组件库不满足需求时优雅地绕过去而不是对着它硬改。
这篇文章讲三块内容,对应 UI 开发的三个基本功:
flowchart LR
A["HTTP<br/>数据从哪来"] --> D["UI 页面"]
B["CSS<br/>页面长什么样"] --> D
C["组件库<br/>用什么零件搭"] --> D
D --> E["一个完整页面<br/>= 数据 + 布局 + 组件"]
E --> F["精通<br/>= 能排错 能绕坑 能设计"]
- HTTP:页面不是孤立的,数据要从服务器来。请求发不出去、发出去收不到、收到了不渲染,每一种「不工作」背后都是一类知识。
- CSS:UI 开发的半壁江山。大部分人不是不会 CSS,而是「会一半」——能写出来,但不知道为什么生效、为什么失效。
- 组件库:Element Plus、Ant Design Vue、Naive UI 怎么选,怎么用出「高级感」,以及怎么避开它们各自的坑。
老规矩,知识点之外,我会穿插真实的踩坑故事和可以运行的 Demo。
第一篇 HTTP:数据是怎么到页面上的
1.1 一次请求的完整旅程
很多新人对 HTTP 的理解停留在「axios.get(url) 然后 .then 拿数据」。我们先看看这一次调用背后发生了什么:
sequenceDiagram
participant P as 页面组件
participant A as axios 实例
participant I as 请求拦截器
participant B as 浏览器
participant S as 后端服务
P->>A: http.get('/api/users')
A->>I: 带上 token、组装 baseURL
I->>B: 发起真实网络请求
B->>S: DNS 解析 → TCP 连接 → TLS 握手 → 发送报文
S-->>B: 状态码 + 响应体
B-->>I: 进入响应拦截器
I-->>A: 统一解包 { code, data, message }
A-->>P: 返回 data 或抛出业务错误
这张图里每一个环节都可能出错,而「精通」的标志就是:看到现象,能立刻判断问题出在哪一层。
- 请求根本没发出去(Network 面板里看不到)→ 你的 JS 代码有问题,或者拦截器里 throw 了。
- 请求发出去了,状态码是 4xx/5xx → 后端或网关的问题,但参数对不对还得你自己查。
- 状态码 200,但页面上没数据 → 响应拦截器解包逻辑或组件渲染的问题。
- 状态码是
(blocked:cors)或者红色的OPTIONS→ 跨域,见下面的踩坑故事。
1.2 先懂协议,再谈封装
不需要背 RFC,但这几件事必须形成肌肉记忆:
- 方法语义:GET 查询、POST 创建、PUT 全量更新、PATCH 局部更新、DELETE 删除。语义不止是给后端看的——GET 请求会被浏览器和 CDN 缓存、会被浏览器历史记录保留,你拿 GET 做删除操作,等于给自己埋雷。
- 状态码:200(成功)/ 301 302(重定向)/ 400(参数错,你的锅)/ 401(没登录或 token 过期)/ 403(登录了但没权限)/ 404(路径错)/ 500(后端炸了)/ 502 504(网关或后端挂了)。
- Content-Type:
application/json是今天的默认;multipart/form-data用于文件上传。踩坑点:用 axios 上传文件时不要手动设置Content-Type,浏览器需要自动生成带 boundary 的头部,手动设置反而会让后端解析不了。
1.3 一个生产可用的请求封装(Demo)
下面是我这些年沉淀下来的一版 axios 封装骨架,去掉公司特定逻辑后依然可直接用:
// src/utils/http.ts
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
// 后端统一响应格式:{ code: 0, data: T, message: 'ok' }
interface ApiResponse<T = unknown> {
code: number
data: T
message: string
}
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE ?? '/api',
timeout: 15000,
})
// 请求拦截:带 token
http.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 响应拦截:统一解包 + 统一错误提示
http.interceptors.response.use(
(res) => {
const body = res.data as ApiResponse
if (body.code === 0) return body.data // 成功:直接把 data 交出去
if (body.code === 401) {
// token 过期:清登录态,跳登录页
localStorage.removeItem('token')
location.href = '/login'
return Promise.reject(new Error(body.message))
}
ElMessage.error(body.message || '请求失败')
return Promise.reject(new Error(body.message))
},
(err: AxiosError) => {
// 网络层错误:超时、断网、5xx
if (err.code === 'ECONNABORTED') ElMessage.error('请求超时,请重试')
else ElMessage.error(err.response ? `服务异常(${err.response.status})` : '网络异常')
return Promise.reject(err)
},
)
export function get<T>(url: string, config?: AxiosRequestConfig) {
return http.get<unknown, T>(url, config)
}
export function post<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
return http.post<unknown, T>(url, data, config)
}用法:
import { get } from '@/utils/http'
interface User { id: number; name: string; email: string }
const users = await get<User[]>('/users') // 拿到的就是解包后的 data
这个封装的核心思想只有一句话:让业务代码只关心成功路径。解包、错误提示、登录跳转全部收敛到拦截器里,组件里不需要到处写 if (res.code === 0)。
1.4 踩坑故事一:那个只在测试环境出现的跨域
现象:本地开发一切正常,部署到测试环境后所有接口报 CORS 错误,后端同事说「我配了 Access-Control-Allow-Origin: * 了啊」。
排查:打开 Network 面板,发现带 Authorization 头的请求之前,浏览器先发了一个 OPTIONS 预检请求,而预检请求返回 404——Nginx 配置里只把 GET/POST 转发给了后端,OPTIONS 落到了静态文件规则上。
教训,一次记住三个知识点:
flowchart TD
A["浏览器要发跨域请求"] --> B{"是简单请求吗?<br/>GET/POST + 无自定义头<br/>+ 特定 Content-Type"}
B -->|"是"| C["直接发真实请求<br/>靠响应头 ACAO 放行"]
B -->|"否(比如带 Authorization)"| D["先发 OPTIONS 预检"]
D --> E{"预检返回 2xx 且<br/>CORS 头齐全?"}
E -->|"是"| C
E -->|"否(404/405/缺头)"| F["报 CORS 错误<br/>真实请求根本没发"]
style F fill:#ffe4e1
- 带自定义头(如
Authorization)的请求一定会触发预检。 - 预检失败时,真实请求根本没有发出,后端日志里查不到,容易误判成「后端没问题」。
- 开发环境不跨域是因为 Vite 的
server.proxy帮你转发了——代理方案只在开发环境存在,上线后跨域问题会准时出现。
1.5 踩坑故事二:疯狂抖动的搜索框
现象:搜索建议框,用户每敲一个字就发一次请求。结果因为网络延迟,先发的请求后返回,后返回的旧结果覆盖了新结果,搜索「北京」时页面一闪而过地显示「北」的结果。
解法,三板斧,按成本从低到高:
import { refDebounced } from '@vueuse/core' // 或者手写防抖
// 方案 1:防抖,减少请求次数(300ms 内不重复触发)
const keyword = ref('')
const debouncedKeyword = refDebounced(keyword, 300)
// 方案 2:竞态防护,只接受最后一次请求的结果
let seq = 0
watch(debouncedKeyword, async (kw) => {
const mySeq = ++seq
const list = await get<Suggestion[]>('/suggest', { params: { kw } })
if (mySeq !== seq) return // 这期间又发了新请求,丢弃旧结果
suggestions.value = list
})
// 方案 3:AbortController 直接取消旧请求(axios 支持 signal)
真实项目里我通常 1 + 2 组合用。方案 3 最彻底,但要小心把取消错误误报成「请求失败」的 toast。
1.6 思考:请求封装是在封装「约定」
很多人封装 axios 只是在封装函数调用,其实真正值钱的是把团队协作约定固化进代码:token 放哪个头、错误码怎么定、401 怎么处理、超时多久算超时。这些约定一旦写进拦截器,新人在第一天就「被迫」遵守了整个团队的规范。好的基础设施,是让正确的事情自动发生。
第二篇 CSS:UI 开发的半壁江山
2.1 先建立一个心智模型
CSS 难学的根源是:它不是一个「语言」,而是一堆相互独立的机制的合集。布局、层叠、盒模型、格式化上下文各自为政,你以为是玄学的问题,拆开看都是明确的规则。
mindmap
root((CSS 四大机制))
盒模型
content-box
border-box
margin 折叠
布局
normal flow 文档流
flex 一维布局
grid 二维布局
position 定位
层叠
优先级 选择器权重
继承
z-index 与层叠上下文
格式化上下文
BFC
IFC
入门阶段我建议只抓两条主线:盒模型 + flex/grid 布局,能解决 90% 的日常问题;层叠和 BFC 等踩到坑再回头补。
2.2 盒模型:一切从盒子开始
flowchart LR
subgraph box["border-box 盒模型"]
M["margin<br/>(外边距,不算进盒子)"] --> B["border<br/>边框"]
B --> P["padding<br/>内边距"]
P --> C["content<br/>内容"]
end
一句话规则:项目全局设置 box-sizing: border-box,然后你写的 width: 200px 就是「视觉上的 200px 宽」,padding 和 border 往内挤,不再往外扩。这条规则值得写进每个项目的 reset 里:
*, *::before, *::after {
box-sizing: border-box;
}2.3 布局:Flex 管一维,Grid 管二维
记住这个分工就不会纠结选型:
- 一排或一列的元素(导航栏、表单行、卡片列表的某一排)→ Flex。
- 有行有列的整体结构(页面骨架、照片墙、仪表盘)→ Grid。
Flex 日常 90% 的用法就这几个:
.toolbar {
display: flex;
align-items: center; /* 交叉轴居中(垂直居中) */
justify-content: space-between; /* 主轴两端对齐 */
gap: 12px; /* 元素间距,别再给子元素写 margin-right 了 */
}
.toolbar .spacer {
flex: 1; /* 占满剩余空间,把后面的元素顶到最右 */
min-width: 0; /* ⚠️ 踩坑点,见 2.5 */
}Grid 搭页面骨架,三行 CSS 出「头-侧-主」经典布局:
.admin-layout {
display: grid;
height: 100vh;
grid-template:
"header header" 56px
"aside main" 1fr
/ 220px 1fr;
}
.admin-layout > header { grid-area: header; }
.admin-layout > aside { grid-area: aside; }
.admin-layout > main { grid-area: main; overflow: auto; }grid-template 的「ASCII 画」语法是我认为 CSS 里最优雅的设计之一——布局长什么样,代码里就画成什么样。
2.4 现代 CSS 三件套:变量、容器查询、:has()
如果你的项目不再需要兼容 IE(2026 年了,真的不需要了),这三样东西能显著减少 JS 代码量:
:root {
--primary: #409eff;
--radius: 6px;
}
[data-theme='dark'] {
--primary: #79bbff;
}
.btn {
background: var(--primary);
border-radius: var(--radius);
}
/* :has() —— 终于可以「父选子」了 */
.form-item:has(.el-input.is-error) {
border-color: red;
}CSS 变量配合 Vue 更是绝配——在组件里改一个变量,整个子树的样式跟着变,比传 props 干净得多:
<template>
<section :style="{ '--card-color': themeColor }">
<slot />
</section>
</template>2.5 踩坑故事三:flex: 1 了,为什么还是不生效
现象:侧边栏固定宽度 + 主内容 flex: 1 的经典布局,主内容里放了一张表格,结果表格把主内容越撑越宽,整个页面出现了横向滚动条。
原因:flex 子项的默认 min-width 是 auto,意思是「最小也不能比内容窄」。表格内容很宽,flex: 1 就压不住它。
解法:一行代码,但不知道的人能查一下午:
.main { flex: 1; min-width: 0; overflow: auto; }同理,grid 里是 minmax(0, 1fr)。这是前端面试的高频题,更是实际项目的高频坑。
2.6 踩坑故事四:z-index: 9999 的军备竞赛
现象:项目里的 z-index 从 10 涨到 100,再到 999、9999,最后有人写了 z-index: 2147483647(32 位整数最大值),结果弹窗还是被遮罩盖住。
原因:z-index 只在同一个层叠上下文内比较。某个父元素一旦因为 transform、opacity < 1、position: fixed 等属性创建了新的层叠上下文,它内部天大的 z-index 也突破不了父级的层级。
flowchart TD
R["根层叠上下文"] --> A["父容器 A<br/>z-index: 10<br/>(因 transform 成为新层叠上下文)"]
R --> B["遮罩层 B<br/>z-index: 100"]
A --> C["弹窗 C<br/>z-index: 99999999<br/>❌ 依然被 B 盖住"]
style C fill:#ffe4e1
解法:别玩军备竞赛。Vue 项目里弹窗、通知这类组件一律用 <Teleport to="body"> 挂到 body 下,跳出业务 DOM 的层叠上下文,然后从一份常量表里管理有限的几个层级:
// src/constants/z-index.ts —— 全项目只允许用这几个值
export const Z_INDEX = {
dropdown: 1000,
sticky: 1020,
overlay: 1030,
modal: 1040,
toast: 1050,
} as const2.7 踩坑故事五:scoped 样式改不动组件库
现象:想改 Element Plus 按钮的圆角,在 .vue 文件里写了 .el-button { border-radius: 20px; },毫无效果。
原因:Vue 的 scoped 会给你的选择器加上 [data-v-xxx] 属性后缀,而组件库渲染出来的 DOM 上没有这个属性,选择器匹配不上。
解法:用深度选择器 :deep():
<style scoped>
/* ✅ 正确:穿透 scoped,作用到子组件内部 DOM */
.my-page :deep(.el-button) {
border-radius: 20px;
}
/* 注意:前面最好带一个自己页面的类名,避免穿透后污染全局 */
</style>更进一步的感悟:单组件改样式用 :deep(),但如果是全局性的设计调整(比如全公司项目按钮都用 20px 圆角),应该用组件库的 CSS 变量定制(见第三篇),而不是到处写覆盖。覆盖样式是技术债,CSS 变量才是正路。
2.8 一个完整的小 Demo:响应式卡片列表
把上面讲的 flex/grid、CSS 变量、响应式串起来:
<template>
<div class="card-grid">
<article v-for="item in list" :key="item.id" class="card">
<h3>{{ item.title }}</h3>
<p>{{ item.desc }}</p>
</article>
</div>
</template>
<style scoped>
.card-grid {
/* 自动填充:每列至少 260px,剩下的均分。不需要媒体查询! */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
padding: 16px;
}
.card {
border: 1px solid var(--border-color, #e4e7ed);
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 10%);
}
</style>repeat(auto-fill, minmax(260px, 1fr)) 这一行值得抄进你的代码库:一行 CSS 实现「宽屏四列、窄屏一列」的响应式,不需要任何媒体查询和 JS。
2.9 思考:CSS 的功夫在「约束」不在「技巧」
年轻时我以为 CSS 高手是知道很多冷门属性的人。后来带项目才发现,CSS 写得好的人,是给自己设了约束的人:颜色只用变量表里那十几个、间距只用 4 的倍数、z-index 只用常量表里的值。没有约束的自由发挥,三个月后就变成没人敢动的样式屎山。这其实也是设计系统(Design System)的思想雏形。
第三篇 主流 UI 组件库:选型、驾驭与避坑
3.1 2026 年的 Vue 组件库格局
flowchart TD
Q{"你的项目是什么类型?"}
Q -->|"中后台管理系统"| A["Element Plus<br/>或 Ant Design Vue"]
Q -->|"追求现代 DX 和 TypeScript"| B["Naive UI"]
Q -->|"Material Design 风格 / 海外项目"| C["Vuetify"]
Q -->|"高度定制设计 / 自有设计体系"| D["无样式方案<br/>Headless UI / Radix Vue<br/>+ Tailwind CSS"]
逐个说说我深度使用后的体感:
- Element Plus:国内中后台的事实标准。生态最大、资料最多、招人最好招。缺点是默认样式有点「年代感」,以及某些复杂组件(表格、表单)的细节坑比较多。团队项目、要交接给别人的项目,选它不会错。
- Ant Design Vue:蚂蚁设计体系的 Vue 移植版,设计规范完整、文档里连「什么时候该用什么组件」都讲了。表单项的校验体验比 Element Plus 细腻。缺点:Vue 版毕竟是移植,更新节奏和细节打磨略逊于 React 版。
- Naive UI:一个「很有性格」的库——TypeScript 原生写成,主题系统全部走 JS/TS(不是 CSS 变量),配合 Volar 类型提示非常爽。个人项目、新项目我首推它。缺点是生态相对小,遇到问题搜答案没 Element Plus 那么容易。
- Vuetify:Material Design 的完整实现,功能大而全。海外项目或甲方指定 Material 风格时用。包体积偏大,需要做好按需引入。
3.2 踩坑故事六:一次「手贱」升级引发的全站样式错乱
现象:pnpm update 顺手把 Element Plus 从 2.3 升到了 2.7,页面上所有 el-select 的下拉框宽度和位置全乱了。
原因:Element Plus 在中间版本重构了下拉浮层的实现(从旧的 popper 换成新版 floating 机制),类名和结构都变了,我们项目里靠 :deep(.el-select-dropdown) 写的覆盖样式全部失效。
教训,三条:
- 组件库锁版本:
package.json里组件库版本号不要带^,写死,升级走单独 PR,升级前把 CHANGELOG 里的 Breaking Changes 看完。 - 少写 deep 覆盖,多用官方变量:Element Plus 2.x 大部分视觉定制都能通过 CSS 变量完成:
// styles/element-theme.scss —— 官方推荐的定制方式
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: ('primary': ('base': #6c5ce7)),
$border-radius: ('base': 8px),
);- 给 UI 留回归测试的预算:哪怕只是几张关键页面的截图对比(Playwright 的
toHaveScreenshot()),也能让这类升级从「全站翻车」变成「CI 变红,改完再发」。
3.3 踩坑故事七:表格一万行,页面卡成 PPT
现象:后端一次返回了一万条数据(「反正前端会分页的嘛」),el-table 直接渲染,页面滚动时帧率掉到个位数。
解法路线图:
flowchart TD
A["大数据量表格卡顿"] --> B{"能说服后端分页吗?"}
B -->|"能,首选"| C["服务端分页<br/>每页 20-50 条"]
B -->|"不能,数据必须一次展示"| D["虚拟滚动<br/>el-table-v2 / vxe-table"]
D --> E{"还需要复杂单元格?"}
E -->|"是"| F["vxe-table<br/>虚拟滚动 + 复杂表格之王"]
E -->|"否,纯展示"| G["el-table-v2<br/>官方虚拟表格"]
几句实在话:
- 长列表性能问题,第一解永远是分页。一万条数据对人没有任何浏览价值,只有检索价值。
- 真要全量展示,普通
el-table趁早放弃,直接上虚拟滚动(只渲染可视区域的几十行 DOM)。 - 表格重渲染还常因为每一行都内联了处理函数和作用域插槽,数据量大时把行组件抽出来 +
v-memo能再救一把。
3.4 踩坑故事八:表单校验「有时候不触发」
现象:el-form 的校验规则明明写了,用户快速输入时偶尔能绕过校验提交。
原因:两个叠加问题——表单项的 prop 和 v-model 绑定的字段名不一致(校验根本没绑定上),以及提交时没有 await formRef.validate(),直接发了请求。
正确姿势,背下来这个模板:
<template>
<el-form ref="formRef" :model="form" :rules="rules">
<!-- prop 必须等于 form 里的字段名,一字不差 -->
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
const formRef = ref<FormInstance>()
const form = reactive({ email: '' })
const rules: FormRules = {
email: [
{ required: true, message: '邮箱必填', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不对', trigger: 'blur' },
],
}
async function submit() {
// ✅ 校验不过直接 return,永远不要让请求绕过校验
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
await post('/users', form)
}
</script>3.5 综合 Demo:一个「五脏俱全」的用户管理页
把三篇内容合在一起:HTTP 封装拿数据、Grid/Flex 做布局、Element Plus 出组件。这是中后台最常见的页面形态,也是检验三项基本功的试金石:
<template>
<div class="user-page">
<!-- 工具栏:Flex 布局,左侧搜索,右侧操作 -->
<div class="toolbar">
<el-input
v-model="keyword"
placeholder="搜索用户名"
clearable
style="width: 240px"
@input="onSearch"
/>
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<!-- 表格:服务端分页 -->
<el-table v-loading="loading" :data="users" stripe>
<el-table-column prop="name" label="姓名" />
<el-table-column prop="email" label="email" show-overflow-tooltip />
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="fetchUsers"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { get, post } from '@/utils/http'
interface User { id: number; name: string; email: string }
const users = ref<User[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const loading = ref(false)
let seq = 0 // 竞态防护:只认最后一次请求
async function fetchUsers() {
const mySeq = ++seq
loading.value = true
try {
const res = await get<{ list: User[]; total: number }>('/users', {
params: { page: page.value, size: pageSize.value, kw: keyword.value },
})
if (mySeq !== seq) return
users.value = res.list
total.value = res.total
} finally {
if (mySeq === seq) loading.value = false
}
}
let timer: ReturnType<typeof setTimeout>
function onSearch() {
clearTimeout(timer)
timer = setTimeout(() => { page.value = 1; fetchUsers() }, 300) // 防抖
}
async function remove(row: User) {
await ElMessageBox.confirm(`确认删除 ${row.name}?`, '警告', { type: 'warning' })
await post(`/users/${row.id}/delete`)
ElMessage.success('已删除')
fetchUsers()
}
onMounted(fetchUsers)
</script>
<style scoped>
.user-page {
padding: 16px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
</style>这个页面看起来平平无奇,但里面藏着前面讲的所有功课:请求解包、loading 状态、防抖、竞态防护、服务端分页、flex 工具栏、scoped 样式。「精通」从来不是会多少奇技淫巧,而是这些基本功每一环都不掉链子。
第四篇 从「会用」到「精通」:我的几点感悟
4.1 UI 的本质是状态,样式是状态的投影
写久了你会发现,一个页面真正的复杂度不在布局,而在状态:加载中、为空、加载失败、部分成功、无权限、已过期……新手只设计「一切正常」的那个画面,老手写组件前先列状态机:
stateDiagram-v2
[*] --> Loading: 进入页面
Loading --> Empty: 请求成功 数据为空
Loading --> Success: 请求成功 有数据
Loading --> Error: 请求失败
Error --> Loading: 点击重试
Success --> Loading: 刷新/搜索
每个状态对应的 UI 都提前想好,页面就永远不会有「白屏」「转圈转到天荒地老」这种低级体验问题。
4.2 别和组件库对着干
我年轻时花过两周时间,试图把 Element 的表格改成设计师画的「无边框卡片式表格」,覆盖了上百行样式,升级到下个版本全废了。后来的做法是:在组件库的设计体系内做选择,而不是对抗它。设计师给的稿子和组件库默认风格差距大时,拿着组件库的主题定制能力去和设计谈,99% 的情况能在「改三个 CSS 变量」的范围内达成一致。剩下 1% 真正需要高度定制的场景,那就该上无样式方案(Headless UI + Tailwind),而不是魔改有样式库。
4.3 学习路线建议
flowchart LR
A["第 1-2 个月<br/>HTTP 基础 + flex/grid<br/>能独立写出页面"] --> B["第 3-6 个月<br/>吃透一个组件库<br/>+ 请求封装 + 表单表格"]
B --> C["第 6-12 个月<br/>性能 虚拟滚动<br/>主题定制 设计系统"]
C --> D["一年以后<br/>组件设计本身<br/>给团队写基础设施"]
几个反直觉的建议:
- CSS 不要靠看视频学,靠调。把你欣赏的网站用 DevTools 打开,把它的布局一层层拆出来,比任何课程都快。
- 组件库文档要通读一遍,不是背 API,而是知道「它有什么」。不知道有
el-table-v2的人,遇到性能问题只会想到魔改。 - 每踩一个坑,写三条笔记:现象、根因、以后怎么一眼认出它。这篇文章里的故事,就是这么攒出来的。
结语
回到开头那张图:UI 页面 = 数据 + 布局 + 组件。HTTP 决定数据来不来得了,CSS 决定页面好不好看,组件库决定你搭得快不快。三样都是「入门一个月、精通好几年」的东西,但好在它们的学习曲线有一个共同点——坑是有限集。本文里的八个坑,你每绕开一个,就离精通近了一步。
祝你的页面没有横向滚动条,弹窗永远在最上层。