普通视图
Solid 初探:启发 Vue Vapor 的极致框架
浏览器&Websocket&热更新
热更新基本流程图
![]()
一、先明确:什么是热更新(HMR)?
热更新是指:在开发过程中,当代码发生修改并保存后,浏览器无需刷新整个页面,仅更新修改的模块(如组件、样式、逻辑等),同时保留页面当前状态(如表单输入、滚动位置、组件数据等) 。
与传统的 “自动刷新”(如 live-reload)相比,HMR 的核心优势是:
- 局部更新:只替换修改的部分,不影响其他模块;
- 状态保留:避免因全页刷新导致的状态丢失;
- 速度极快:Vite 的 HMR 几乎是 “即时” 的(毫秒级)。
二、前端开发中:浏览器与开发服务器的 “连接基础”
要实现热更新,首先需要建立开发服务器与浏览器之间的 “实时通信通道”,否则浏览器无法知道 “代码何时被修改了”。
在 Vite 中:
-
开发服务器(Vite Dev Server) :启动项目时(
vite dev),Vite 会在本地启动一个 HTTP 服务器(默认端口 5173),负责提供页面资源(HTML、JS、CSS 等),同时监听文件变化。 - 浏览器:通过 HTTP 协议访问开发服务器,加载并渲染页面。
- 通信桥梁:仅靠 HTTP 协议无法实现 “服务器主动通知浏览器”(HTTP 是 “请求 - 响应” 模式,服务器不能主动发消息),因此需要 WebSocket 建立 “双向通信通道”。
三、WebSocket:浏览器与服务器的 “实时对讲机”
WebSocket 是一种全双工通信协议,允许客户端(浏览器)和服务器在建立连接后,双向实时发送消息(无需客户端反复请求)。这是热更新的 “通信核心”。
在 Vite 中,WebSocket 的作用是:
- 服务器监听文件变化,当文件被修改时,通过 WebSocket 向浏览器 “发送更新通知”;
- 浏览器收到通知后,通过 WebSocket 向服务器 “请求更新的模块内容”;
- 双方通过 WebSocket 交换 “更新信息”(如哪个模块变了、新模块的地址等)。
四、Vite 热更新的完整流程(一步一步拆解)
假设我们在开发一个 Vue 项目,修改了 src/components/Hello.vue 并保存,Vite 的热更新流程如下:
步骤 1:Vite 开发服务器监听文件变化
- Vite 启动时,会通过
chokidar库(文件监听工具)对项目目录(如src/)进行监听,实时检测文件的创建、修改、删除等操作。 - 当我们修改并保存
Hello.vue时,文件系统会触发 “修改事件”,Vite 服务器立刻感知到:src/components/Hello.vue发生了变化。
步骤 2:Vite 服务器编译 “变更模块”(而非全量编译)
-
Vite 基于 “原生 ESM(ES 模块)” 工作:开发时不会打包所有文件,而是让浏览器直接通过
<script type="module">加载模块。 -
当
Hello.vue被修改后,Vite 只会重新编译这个单文件组件(.vue 文件):- 解析模板(template)生成渲染函数;
- 处理脚本(script)和样式(style);
- 生成该组件的 “更新后模块内容”,并标记其唯一标识(如
id=123)。
-
同时,Vite 会分析 “依赖关系”:判断哪些模块依赖了
Hello.vue(比如父组件、页面等),确定需要更新的 “模块范围”。
步骤 3:服务器通过 WebSocket 向浏览器发送 “更新通知”
-
Vite 服务器内置了 WebSocket 服务(默认路径为
ws://localhost:5173/ws),浏览器加载页面时,会自动通过 JavaScript 连接这个 WebSocket。 -
服务器将 “变更信息” 通过 WebSocket 发送给浏览器,信息格式类似:
{ "type": "update", // 类型:更新 "updates": [ { "type": "js-update", // 更新类型:JS 模块 "path": "/src/components/Hello.vue", // 变更文件路径 "acceptedPath": "/src/components/Hello.vue", "timestamp": 1699999999999 // 时间戳(避免缓存) } ] }这个消息告诉浏览器:
Hello.vue模块更新了,需要处理。
步骤 4:浏览器接收通知,请求 “更新的模块内容”
-
浏览器的 Vite 客户端(Vite 注入的 HMR 运行时脚本)接收到 WebSocket 消息后,解析出需要更新的模块路径(
Hello.vue)。 -
客户端通过 HTTP 请求(而非 WebSocket)向服务器获取 “更新后的模块内容”,请求地址类似:
http://localhost:5173/src/components/Hello.vue?t=1699999999999(
t参数是时间戳,用于避免浏览器缓存旧内容)。
步骤 5:浏览器 “替换旧模块” 并 “局部更新视图”
-
客户端拿到新的
Hello.vue模块内容后,会执行 “模块替换”:- 对于 Vue 组件,Vite 会利用 Vue 的
defineComponent和热更新 API(import.meta.hot),将旧组件的实例替换为新组件的实例; - 保留组件的状态(如
data中的数据),仅更新模板、样式或逻辑; - 对于样式文件(如
.css),会直接替换<style>标签内容,无需重新渲染组件。
- 对于 Vue 组件,Vite 会利用 Vue 的
-
替换完成后,Vue 的虚拟 DOM 会对比新旧节点,只更新页面中受影响的部分(如
Hello.vue对应的 DOM 区域),实现 “局部刷新”。
步骤 6:处理 “无法热更新” 的情况(降级为刷新)
- 某些场景下(如修改了入口文件
main.js、路由配置、全局状态等),模块依赖关系过于复杂,无法安全地局部更新。 - 此时 Vite 会通过 WebSocket 发送 “全页刷新” 指令,浏览器收到后执行
location.reload(),确保代码更新生效。
五、关键技术点:Vite 如何实现 “极速 HMR”?
- 原生 ESM 按需加载:开发时不打包,浏览器直接加载模块,修改后只需重新编译单个模块,而非整个包(对比 Webpack 的 “打包后更新” 快得多)。
-
精确的依赖分析:Vite 会跟踪模块间的依赖关系(通过
import语句),修改一个模块时,只通知依赖它的模块更新,范围最小化。 - 轻量的客户端运行时:Vite 向浏览器注入的 HMR 脚本非常精简,仅负责接收通知、请求新模块、替换旧模块,逻辑高效。
-
与框架深度集成:针对 Vue、React 等框架,Vite 提供了专门的 HMR 处理逻辑(如 Vue 的
@vitejs/plugin-vue插件),确保组件状态正确保留。
总结:Vite 热更新的核心链路
文件修改(保存)
↓
Vite 服务器监听文件变化
↓
编译变更模块(仅修改的文件)
↓
WebSocket 发送更新通知(告诉浏览器“哪个模块变了”)
↓
浏览器通过 HTTP 请求新模块内容
↓
替换旧模块,框架(如 Vue)局部更新视图
↓
页面更新完成(状态保留,无需全量刷新)
场景假设:你修改了 src/App.vue 并保存
1. Vite 脚手架确实内置了 WebSocket 服务
-
当你运行
vite dev时,Vite 会同时启动两个服务:-
HTTP 服务:默认
http://localhost:5173,负责给浏览器提供页面、JS、CSS 等资源(比如你在浏览器输入这个地址就能看到项目)。 -
WebSocket 服务:默认
ws://localhost:5173/ws,专门用来和浏览器 “实时聊天”(双向通信)。
-
HTTP 服务:默认
-
浏览器打开项目页面时,会自动通过一段 Vite 注入的 JS 代码,连接这个 WebSocket(相当于浏览器和服务器之间架了一根 “实时电话线”)。
2. 当文件变化时,Vite 先 “发现变化”,再通过 WebSocket 喊一声 “有东西改了!”
-
你修改
App.vue并按Ctrl+S保存:- Vite 会通过文件监听工具(类似 “监控摄像头”)立刻发现
App.vue变了。 - 它会快速处理这个文件(比如编译 Vue 模板、处理样式),生成 “更新后的内容”,并记下来 “是
App.vue这个文件变了”。
- Vite 会通过文件监听工具(类似 “监控摄像头”)立刻发现
-
然后,Vite 通过 WebSocket 给浏览器发一条消息(就像打电话通知):
{ "type": "update", "updates": [{"path": "/src/App.vue", "timestamp": 123456}] }翻译成人话:“喂,浏览器!
src/App.vue这个文件刚刚改了,赶紧处理一下!”
3. 浏览器收到通知后,用 HTTP 请求 “主动要新内容”
-
浏览器接收到 WebSocket 的消息后,知道了 “
App.vue变了”,但此时它还没有新内容。 -
于是浏览器会通过 HTTP 协议,向 Vite 的 HTTP 服务发一个请求,要新的
App.vue内容:GET http://localhost:5173/src/App.vue?t=123456(
t=123456是时间戳,确保拿到的是最新的,不是缓存的旧内容)。 -
Vite 的 HTTP 服务收到请求后,把刚才处理好的 “更新后的
App.vue内容” 返回给浏览器。
4. 浏览器拿到新内容,局部更新页面
-
浏览器收到新的
App.vue代码后,会:- 丢掉旧的
App.vue模块(但保留组件状态,比如输入框里的文字)。 - 用新代码替换,并重新渲染
App.vue对应的页面部分(不会刷新整个页面)。
- 丢掉旧的
-
最终你看到的效果:页面上只有
App.vue相关的部分变了,其他内容和状态都没变。
一句话总结核心流程
你改了文件 → Vite 发现 → 用 WebSocket 通知浏览器“哪个文件变了” → 浏览器用 HTTP 要这个文件的新内容 → 浏览器局部更新页面
WebSocket 只负责 “传递变化的消息”(谁变了),真正的新内容还是通过 HTTP 请求获取 —— 分工明确,效率更高。这也是 Vite 热更新快的原因之一:只传必要的消息,拿必要的新内容,不做多余的事。
为啥websocket不一步到位传变化内容给浏览器
这个问题很关键!核心原因是: “直接传变化内容” 看似省一步,实际会引发更复杂的问题,反而降低效率。Vite 选择 “WebSocket 传通知 + HTTP 取内容” 的分工,是权衡了前端开发的复杂性后的最优解。
1. 首先,“变化的内容” 可能非常大,不适合用 WebSocket 直接传
-
前端开发中,一个文件的修改可能涉及大量内容(比如一个复杂的 Vue 组件、包含数百行 CSS 的样式文件)。
-
WebSocket 虽然支持二进制传输,但设计初衷是 “轻量实时通信”(比如消息通知、状态同步),并不擅长高效传输大体积的代码内容。
-
如果直接通过 WebSocket 传完整的更新内容,会:
- 增加 WebSocket 连接的负担,可能导致消息堵塞(比如同时修改多个大文件时);
- 浪费带宽(HTTP 对静态资源传输有更成熟的优化,如压缩、缓存控制)。
2. 其次,“变化的内容” 可能需要 “按需处理”,浏览器需要主动决策
- 一个文件的修改可能影响多个模块(比如 A 依赖 B,B 依赖 C,改了 C 后 A、B 都可能需要更新)。
- 浏览器需要先知道 “哪些模块变了”,再根据自己当前的模块依赖关系,决定 “要不要请求这个模块的新内容”(比如某些模块可能已经被卸载,不需要更新)。
- 如果服务器直接把所有相关内容都推过来,浏览器可能收到很多无用信息(比如已经不需要的模块内容),反而增加处理成本。
3. 更重要的是:HTTP 对 “代码模块” 的传输有天然优势
-
缓存控制:浏览器请求新模块时,通过
?t=时间戳可以轻松避免缓存(确保拿到最新内容),而 WebSocket 消息没有内置的缓存机制,需要手动处理。 - 断点续传与重试:HTTP 对大文件传输有成熟的断点续传和失败重试机制,WebSocket 若传输中断,通常需要重新建立连接并重传全部内容。
-
与浏览器模块系统兼容:现代浏览器原生支持通过
<script type="module">加载 ES 模块(Vite 开发时的核心机制),而模块加载天然依赖 HTTP 请求。直接用 WebSocket 传代码,还需要手动模拟模块加载逻辑,反而更复杂。
4. 举个生活例子:像外卖点餐
-
WebSocket 就像 “短信通知”:店家(服务器)告诉你 “你点的餐好了”(哪个文件变了),短信内容很短,效率高。
-
HTTP 请求就像 “去取餐”:你收到通知后,自己去店里(服务器)拿餐(新内容),按需行动。
-
如果店家直接 “把餐扔到你家”(WebSocket 传内容),可能会出现:
- 你不在家(浏览器没准备好处理),餐浪费了;
- 点了 3 个菜,店家一次性全扔过来(大文件),可能洒了(传输失败)。
总结
Vite 之所以让 WebSocket 只传 “通知”、让 HTTP 负责 “传内容”,是因为:
- 两者分工明确:WebSocket 擅长轻量实时通信,HTTP 擅长高效传输资源;
- 适应前端开发的复杂性:模块依赖多变,按需请求比盲目推送更高效;
- 利用浏览器原生能力:HTTP 与 ES 模块加载机制无缝兼容,减少额外逻辑。
这种设计看似多了一次 HTTP 请求,实则通过 “各司其职” 让整个热更新流程更稳定、更高效 —— 这也是 Vite 热更新速度远超传统工具的原因之一。
Vue 中的 JSX:让组件渲染更灵活的正确方式
在日常 Vue 项目中,你可能已经非常熟悉 template 写法:结构清晰、语义明确、直观易读。但当业务进入更复杂的阶段,你会发现:
- 模板语法存在一定限制
- 某些 UI 渲染逻辑十分动态
- 条件/循环/组件嵌套变得越来越难写
- h 函数(
createVNode)看得懂,但自己写非常痛苦
这时,你可能会想:有没有一种方式既能保持 DOM 结构的直观性,又能充分利用 JavaScript 的灵活表达?
答案是:JSX。
你可能会问:JSX 不是 React 的东西吗?
是,但 Vue 同样支持 JSX,并且在组件库、动态 UI 控件、高度抽象组件中大量使用。
本文将从三个核心问题带你理解 Vue 中的 JSX:
- JSX 的本质是什么?
- 为什么需要 JSX,它能解决什么问题?
- 在 Vue 中如何优雅地使用 JSX?
h 函数:理解 JSX 的前置知识
Vue 组件的 template 最终会被编译为一个 render 函数,render 函数会返回 虚拟 DOM(VNode) 。
也就是说,下面这段模板:
<h3>你好</h3>
最终会变成类似这样的 JavaScript:
h('h3', null, '你好')
也就是说:
h 函数 = 手写虚拟 DOM 的入口
JSX = h 函数的语法糖
为什么需要 JSX?来看一个真实例子
假设我们做一个动态标题组件 <Heading />,它根据 level 动态渲染 <h1> ~ <h6>:
如果使用 template,你可能写成这样:
<h1 v-if="level === 1"><slot /></h1>
<h2 v-else-if="level === 2"><slot /></h2>
...
<h6 v-else-if="level === 6"><slot /></h6>
非常冗余、难拓展、维护成本高。
使用 h 函数可以简化为:
import { h, defineComponent } from 'vue'
export default defineComponent({
props: { level: Number },
setup(props, { slots }) {
return () => h('h' + props.level, {}, slots.default())
}
})
但写 h 函数并不优雅,标签、属性、事件都要自己构造。
这时 JSX 就来了。
在 Vue 中使用 JSX
① 安装 JSX 插件(Vite 项目)
npm install @vitejs/plugin-vue-jsx -D
② 在 vite.config.js 中启用
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
export default {
plugins: [vue(), vueJsx()]
}
③ 使用 JSX 改写 Heading 组件
import { defineComponent } from 'vue'
export default defineComponent({
props: { level: Number },
setup(props, { slots }) {
const Tag = 'h' + props.level
return () => <Tag>{slots.default()}</Tag>
}
})
是不是比手写 h 爽太多了?
结构依然直观,但不受 template 语法局限。
JSX 的核心能力:灵活、动态、纯 JavaScript
举个再明显的例子:Todo 列表
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup() {
const title = ref('')
const todos = ref([])
const addTodo = () => {
if (title.value.trim()) {
todos.value.push({ title: title.value })
title.value = ''
}
}
return () => (
<div>
<input vModel={title.value} />
<button onClick={addTodo}>添加</button>
<ul>
{todos.value.length
? todos.value.map(t => <li>{t.title}</li>)
: <li>暂无数据</li>}
</ul>
</div>
)
}
})
可以看到:
| 模板语法 | JSX 对应写法 |
|---|---|
v-model |
vModel={value} |
@click |
onClick={fn} |
v-for |
array.map() |
v-if |
三元 / if 表达式 |
本质是 JavaScript,可以随意写逻辑。
JSX vs Template:应该如何选择?
| 对比点 | template | JSX |
|---|---|---|
| 可读性 | 强,结构清晰 | 视业务复杂度而定 |
| 动态表达能力 | 较弱(语法受限) | 非常强(JS 语法全支持) |
| 编译优化 | 优秀,可静态提升 | 不如 template 友好 |
| 适用场景 | 普通业务 UI | 高动态逻辑、组件库、渲染函数场景 |
一句话总结选择策略:
业务组件优先 template,
高动态组件或组件库优先 JSX。
JSX 并不是来替代 template 的,而是:
当 template 无法优雅表达渲染逻辑时,JSX 给你打开了一扇窗。
- 它让组件变得更灵活
- 它让写 render 函数变得不再痛苦
- 它让 Vue 在复杂组件抽象层面更加强大
掌握 JSX,是从“会写 Vue”向“会设计 Vue 组件”的关键一步。
Vue 权限控制神技!自定义 auth 指令优雅实现按钮级权限管理
Vue 权限控制神技!自定义 auth 指令优雅实现按钮级权限管理
在中后台系统开发中,按钮级别的权限控制是常见需求 —— 不同角色用户看到的操作按钮可能不同,直接写 if-else 判断又会导致代码冗余混乱。今天分享一个 Vue 自定义指令v-auth,一行代码就能搞定按钮的显示、隐藏或禁用,大幅提升代码整洁度!
一、指令核心功能
这个v-auth指令基于 Vuex 存储的用户权限数据,实现两大核心能力:
-
超级管理员自动放行,无需额外判断
-
普通用户支持两种权限控制模式:
- 隐藏模式:无权限时直接移除 DOM 元素
- 禁用模式:无权限时保留元素但添加禁用状态和样式
二、完整代码实现
// 权限控制指令:v-auth
Vue.directive('auth', {
async inserted(el, binding) {
const { value, modifiers } = binding;
// 确保权限数据已加载,未加载则异步获取
if (!store.getters.permissions) {
//获取权限数据
}
const permissions = store.getters.permissions || []; // 兜底处理,避免报错
// 超级管理员特权:拥有所有权限直接放行
if (permissions.includes('*:*:*')) return;
// 权限校验核心逻辑
const isDisabled = modifiers.disabled; // 是否启用禁用模式
const hasPermission = permissions.includes(value); // 校验用户是否拥有目标权限
if (!hasPermission) {
if (isDisabled) {
// 禁用模式:添加禁用属性和自定义样式
el.disabled = true;
el.classList.add('disabled-button');
} else {
// 隐藏模式:从DOM中移除元素
el.parentNode?.removeChild(el);
}
}
}
});
三、代码逻辑逐行解析
1. 指令触发时机
使用inserted钩子,在元素插入 DOM 后执行校验,确保操作目标元素存在。
2. 权限数据加载
- 先检查 Vuex 中是否已缓存权限数据
- 未加载则调用
user/getInfo异步接口获取,等待加载完成再继续校验
3. 权限判断逻辑
- 超级管理员通过
*:*:*标识直接放行,适配系统最高权限场景 - 普通用户通过
binding.value获取需要校验的权限标识(如"user:add") - 通过
binding.modifiers.disabled切换控制模式,灵活适配不同 UI 需求
四、实际使用场景
1. 隐藏模式(默认)
无权限时直接隐藏按钮,适用于非核心操作按钮:
<el-button v-auth="'user:add'">新增用户</el-button>
2. 禁用模式
无权限时保留按钮但禁用,适用于需要提示用户权限不足的场景:
<el-button v-auth.disabled="'user:edit'">编辑用户</el-button>
六、总结
按钮权限控制的工作流程图:
flowchart TD
A(("① 用户登录")) --> B[系统初始化]
B --> C[渲染带v-auth的组件]
C --> D{指令解析}
D --> |解析到v-auth节点| E["② inserted钩子触发"]
E --> F{权限数据已加载?}
F -- 否 --> G["③ 调用store.dispatch()"]
G --> H[获取用户权限数据]
H --> F
F -- 是 --> I{是超级管理员?}
I -- 是 --> J["✅ 放行渲染"]
I -- 否 --> K["④ 权限校验"]
K --> L{检查修饰符}
L -- disabled --> M["⑤ 禁用模式处理"]
L -- 无修饰符 --> N["⑤ 隐藏模式处理"]
M --> O{权限匹配?}
O -- 匹配 --> P["✅ 保持可用状态"]
O -- 不匹配 --> Q["🛑 添加disabled属性"]
N --> R{权限匹配?}
R -- 匹配 --> S["✅ 正常显示"]
R -- 不匹配 --> T["🛑 移除DOM节点"]
Q --> U[结束]
T --> U
P --> U
S --> U
style A fill:#4CAF50,color:white
style G fill:#2196F3,color:white
style Q fill:#FF5722,color:white
style T fill:#FF5722,color:white
style J fill:#4CAF50,color:white
这个v-auth指令将权限控制逻辑封装复用,摆脱了模板中大量的权限判断代码,让权限管理更优雅、维护成本更低。适用于各类中后台系统的按钮、菜单等元素权限控制,搭配 Vuex 的状态管理可实现全系统权限统一管控。
《uni-app跨平台开发完全指南》- 04 - 页面布局与样式基础
uni-app:掌握页面布局与样式
新手刚接触uni-app布局可能会遇到以下困惑:明明在模拟器上完美显示的页面,到了真机上就面目全非;iOS上对齐的元素,到Android上就错位几个像素,相信很多开发者都经历过。今天就带大家摸清了uni-app布局样式的门道,把这些经验毫无保留地分享给大家,让你少走弯路。
一、Flex布局
1.1 为什么Flex布局是移动端首选?
传统布局的痛点:
/* 传统方式实现垂直居中 */
.container {
position: relative;
height: 400px;
}
.center {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 100px;
margin-top: -50px; /* 需要计算 */
margin-left: -100px; /* 需要计算 */
}
Flex布局:
/* Flex布局实现垂直居中 */
.container {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.center {
width: 200px;
height: 100px;
}
从对比中不难看出,Flex布局用更少的代码、更清晰的逻辑解决了复杂的布局问题。
1.2 Flex布局的核心概念
为了更好地理解Flex布局,我们先来看一下它的基本模型:
Flex容器 (display: flex)
├─────────────────────────────────┤
│ 主轴方向 (flex-direction) → │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ │ 元素1 │ │ 元素2 │ │ 元素3 │ ← Flex元素
│ └─────────┘ └─────────┘ └─────────┘
│ │
│ ↑ │
│ 交叉轴方向 │
└─────────────────────────────────┘
Flex布局的两大核心:
-
容器:设置
display: flex的元素,控制内部项目的布局 - 元素:容器的直接子元素,受容器属性控制
1.3 容器属性
1.3.1 flex-direction:布局方向
这个属性决定了元素的排列方向,是Flex布局的基础:
.container {
/* 水平方向,从左到右(默认) */
flex-direction: row;
/* 水平方向,从右到左 */
flex-direction: row-reverse;
/* 垂直方向,从上到下 */
flex-direction: column;
/* 垂直方向,从下到上 */
flex-direction: column-reverse;
}
实际应用场景分析:
| 属性值 | 适用场景 |
|---|---|
row |
水平导航、卡片列表 |
column |
表单布局、设置页面 |
row-reverse |
阿拉伯语等从右向左语言 |
column-reverse |
聊天界面(最新消息在底部) |
1.3.2 justify-content:主轴对齐
这个属性控制元素在主轴上的对齐方式,使用频率非常高:
.container {
display: flex;
/* 起始位置对齐 */
justify-content: flex-start;
/* 末尾位置对齐 */
justify-content: flex-end;
/* 居中对齐 */
justify-content: center;
/* 两端对齐,项目间隔相等 */
justify-content: space-between;
/* 每个项目两侧间隔相等 */
justify-content: space-around;
/* 均匀分布,包括两端 */
justify-content: space-evenly;
}
空间分布对比关系:
- start - 从头开始
- end - 从尾开始
- center - 居中对齐
- between - 元素"之间"有间隔
- around - 每个元素"周围"有空间
- evenly - 所有空间"均匀"分布
1.3.3 align-items:交叉轴对齐
控制元素在交叉轴上的对齐方式:
.container {
display: flex;
height: 300rpx; /* 需要明确高度 */
/* 交叉轴起点对齐 */
align-items: flex-start;
/* 交叉轴终点对齐 */
align-items: flex-end;
/* 交叉轴中点对齐 */
align-items: center;
/* 基线对齐(文本相关) */
align-items: baseline;
/* 拉伸填充(默认) */
align-items: stretch;
}
温馨提示:align-items的效果与flex-direction密切相关:
- 当
flex-direction: row时,交叉轴是垂直方向 - 当
flex-direction: column时,交叉轴是水平方向
1.4 元素属性
1.4.1 flex-grow
控制元素放大比例,默认0(不放大):
.item {
flex-grow: <number>; /* 默认0 */
}
计算原理:
总剩余空间 = 容器宽度 - 所有元素宽度总和
每个元素分配空间 = (元素的flex-grow / 所有元素flex-grow总和) × 总剩余空间
示例分析:
.container {
width: 750rpx;
display: flex;
}
.item1 { width: 100rpx; flex-grow: 1; }
.item2 { width: 100rpx; flex-grow: 2; }
.item3 { width: 100rpx; flex-grow: 1; }
/* 计算过程:
剩余空间 = 750 - (100+100+100) = 450rpx
flex-grow总和 = 1+2+1 = 4
item1分配 = (1/4)×450 = 112.5rpx → 最终宽度212.5rpx
item2分配 = (2/4)×450 = 225rpx → 最终宽度325rpx
item3分配 = (1/4)×450 = 112.5rpx → 最终宽度212.5rpx
*/
1.4.2 flex-shrink
控制元素缩小比例,默认1(空间不足时缩小):
.item {
flex-shrink: <number>; /* 默认1 */
}
小技巧:设置flex-shrink: 0可以防止元素被压缩,常用于固定宽度的元素。
1.4.3 flex-basis
定义元素在分配多余空间之前的初始大小:
.item {
flex-basis: auto | <length>; /* 默认auto */
}
1.4.4 flex
flex是flex-grow、flex-shrink和flex-basis的简写:
.item {
/* 等价于 flex: 0 1 auto */
flex: none;
/* 等价于 flex: 1 1 0% */
flex: 1;
/* 等价于 flex: 1 1 auto */
flex: auto;
/* 自定义 */
flex: 2 1 200rpx;
}
1.5 完整页面布局实现
让我们用Flex布局实现一个典型的移动端页面:
<view class="page-container">
<!-- 顶部导航 -->
<view class="header">
<view class="nav-back">←</view>
<view class="nav-title">商品详情</view>
<view class="nav-actions">···</view>
</view>
<!-- 内容区域 -->
<view class="content">
<!-- 商品图 -->
<view class="product-image">
<image src="/static/product.jpg" mode="aspectFit"></image>
</view>
<!-- 商品信息 -->
<view class="product-info">
<view class="product-name">高端智能手机 8GB+256GB</view>
<view class="product-price">
<text class="current-price">¥3999</text>
<text class="original-price">¥4999</text>
</view>
<view class="product-tags">
<text class="tag">限时优惠</text>
<text class="tag">分期免息</text>
<text class="tag">赠品</text>
</view>
</view>
<!-- 规格选择 -->
<view class="spec-section">
<view class="section-title">选择规格</view>
<view class="spec-options">
<view class="spec-option active">8GB+256GB</view>
<view class="spec-option">12GB+512GB</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="footer">
<view class="footer-actions">
<view class="action-btn cart">购物车</view>
<view class="action-btn buy-now">立即购买</view>
</view>
</view>
</view>
.page-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
/* 头部导航 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
background: white;
border-bottom: 1rpx solid #eee;
}
.nav-back, .nav-actions {
width: 60rpx;
text-align: center;
font-size: 36rpx;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: bold;
}
/* 内容区域 */
.content {
flex: 1;
overflow-y: auto;
}
.product-image {
height: 750rpx;
background: white;
}
.product-image image {
width: 100%;
height: 100%;
}
.product-info {
padding: 32rpx;
background: white;
margin-bottom: 20rpx;
}
.product-name {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 20rpx;
line-height: 1.4;
}
.product-price {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.current-price {
font-size: 48rpx;
color: #ff5000;
font-weight: bold;
margin-right: 20rpx;
}
.original-price {
font-size: 28rpx;
color: #999;
text-decoration: line-through;
}
.product-tags {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.tag {
padding: 8rpx 20rpx;
background: #fff2f2;
color: #ff5000;
font-size: 24rpx;
border-radius: 8rpx;
}
/* 规格选择 */
.spec-section {
background: white;
padding: 32rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 24rpx;
}
.spec-options {
display: flex;
gap: 20rpx;
}
.spec-option {
padding: 20rpx 40rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
font-size: 28rpx;
}
.spec-option.active {
border-color: #007AFF;
background: #f0f8ff;
color: #007AFF;
}
/* 底部操作栏 */
.footer {
background: white;
border-top: 1rpx solid #eee;
padding: 20rpx 32rpx;
}
.footer-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: bold;
}
.cart {
background: #fff2f2;
color: #ff5000;
border: 2rpx solid #ff5000;
}
.buy-now {
background: #ff5000;
color: white;
}
这个例子展示了如何用Flex布局构建复杂的页面结构,包含了水平布局、垂直布局、空间分配等各种技巧。
二、跨端适配:rpx单位系统
2.1 像素密度
要理解rpx的价值,首先要明白移动端面临的问题:
设备现状:
设备A: 4.7英寸, 750×1334像素, 326ppi
设备B: 6.1英寸, 828×1792像素, 326ppi
设备C: 6.7英寸, 1284×2778像素, 458ppi
同样的CSS像素在不同设备上的物理尺寸不同,这就是我们需要响应式单位的原因。
2.2 rpx的工作原理
rpx的核心思想很简单:以屏幕宽度为基准的相对单位
rpx计算原理:
1rpx = (屏幕宽度 / 750) 物理像素
不同设备上的表现:
| 设备宽度 | 1rpx对应的物理像素 | 计算过程 |
|---|---|---|
| 750px | 1px | 750/750 = 1 |
| 375px | 0.5px | 375/750 = 0.5 |
| 1125px | 1.5px | 1125/750 = 1.5 |
2.3 rpx与其他单位的对比分析
为了更好地理解rpx,我们把它和其他常用单位做个对比:
/* 不同单位的对比示例 */
.element {
width: 750rpx; /* 总是占满屏幕宽度 */
width: 100%; /* 占满父容器宽度 */
width: 375px; /* 固定像素值 */
width: 50vw; /* 视窗宽度的50% */
}
2.4 rpx实际应用与问题排查
2.4.1 设计稿转换
情况一:750px设计稿(推荐)
设计稿测量值 = 直接写rpx值
设计稿200px → width: 200rpx
情况二:375px设计稿
rpx值 = (设计稿测量值 ÷ 375) × 750
设计稿200px → (200÷375)×750 = 400rpx
情况三:任意尺寸设计稿
// 通用转换公式
function pxToRpx(px, designWidth = 750) {
return (px / designWidth) * 750;
}
// 使用示例
const buttonWidth = pxToRpx(200, 375); // 返回400
2.4.2 rpx常见问题
问题1:边框模糊
/* 不推荐 - 可能在不同设备上模糊 */
.element {
border: 1rpx solid #e0e0e0;
}
/* 推荐 - 使用px保证清晰度 */
.element {
border: 1px solid #e0e0e0;
}
问题2:大屏设备显示过大
.container {
width: 750rpx; /* 在小屏上合适,大屏上可能太大 */
}
/* 解决方案:媒体查询限制最大宽度 */
@media (min-width: 768px) {
.container {
width: 100%;
max-width: 500px;
margin: 0 auto;
}
}
2.5 响应式网格布局案例
<view class="product-grid">
<view class="product-card" v-for="item in 8" :key="item">
<image class="product-img" src="/static/product.jpg"></image>
<view class="product-info">
<text class="product-name">商品标题{{item}}</text>
<text class="product-desc">商品描述信息</text>
<view class="product-bottom">
<text class="product-price">¥199</text>
<text class="product-sales">销量: 1.2万</text>
</view>
</view>
</view>
</view>
.product-grid {
display: flex;
flex-wrap: wrap;
padding: 20rpx;
gap: 20rpx; /* 间隙,需要确认平台支持 */
}
.product-card {
width: calc((100% - 20rpx) / 2); /* 2列布局 */
background: white;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.08);
}
/* 兼容不支持gap的方案 */
.product-grid {
display: flex;
flex-wrap: wrap;
padding: 20rpx;
justify-content: space-between;
}
.product-card {
width: 345rpx; /* (750-20*2-20)/2 = 345 */
margin-bottom: 20rpx;
}
.product-img {
width: 100%;
height: 345rpx;
display: block;
}
.product-info {
padding: 20rpx;
}
.product-name {
display: block;
font-size: 28rpx;
font-weight: bold;
margin-bottom: 10rpx;
line-height: 1.4;
}
.product-desc {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 20rpx;
line-height: 1.4;
}
.product-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-price {
font-size: 32rpx;
color: #ff5000;
font-weight: bold;
}
.product-sales {
font-size: 22rpx;
color: #999;
}
/* 平板适配 */
@media (min-width: 768px) {
.product-card {
width: calc((100% - 40rpx) / 3); /* 3列布局 */
}
}
/* 大屏适配 */
@media (min-width: 1024px) {
.product-grid {
max-width: 1200px;
margin: 0 auto;
}
.product-card {
width: calc((100% - 60rpx) / 4); /* 4列布局 */
}
}
这个网格布局会在不同设备上自动调整列数,真正实现"一次编写,到处运行"。
三、样式作用域
3.1 全局样式
全局样式是整个应用的样式基石,应该在App.vue中统一定义:
/* App.vue - 全局样式体系 */
<style>
/* CSS变量定义 */
:root {
/* 颜色 */
--color-primary: #007AFF;
--color-success: #4CD964;
--color-warning: #FF9500;
--color-error: #FF3B30;
--color-text-primary: #333333;
--color-text-secondary: #666666;
--color-text-tertiary: #999999;
/* 间距 */
--spacing-xs: 10rpx;
--spacing-sm: 20rpx;
--spacing-md: 30rpx;
--spacing-lg: 40rpx;
--spacing-xl: 60rpx;
/* 圆角 */
--border-radius-sm: 8rpx;
--border-radius-md: 12rpx;
--border-radius-lg: 16rpx;
--border-radius-xl: 24rpx;
/* 字体 */
--font-size-xs: 20rpx;
--font-size-sm: 24rpx;
--font-size-md: 28rpx;
--font-size-lg: 32rpx;
--font-size-xl: 36rpx;
/* 阴影 */
--shadow-sm: 0 2rpx 8rpx rgba(0,0,0,0.1);
--shadow-md: 0 4rpx 20rpx rgba(0,0,0,0.12);
--shadow-lg: 0 8rpx 40rpx rgba(0,0,0,0.15);
}
/* 全局重置样式 */
page {
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
SimSun, sans-serif;
background-color: #F8F8F8;
color: var(--color-text-primary);
font-size: var(--font-size-md);
line-height: 1.6;
}
/* 工具类 - 原子CSS */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.flex { display: flex; }
.flex-column { flex-direction: column; }
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.m-10 { margin: 10rpx; }
.m-20 { margin: 20rpx; }
.p-10 { padding: 10rpx; }
.p-20 { padding: 20rpx; }
/* 通用组件样式 */
.uni-button {
padding: 24rpx 48rpx;
border-radius: var(--border-radius-md);
font-size: var(--font-size-lg);
border: none;
background-color: var(--color-primary);
color: white;
transition: all 0.3s ease;
}
.uni-button:active {
opacity: 0.8;
transform: scale(0.98);
}
</style>
3.2 局部样式
局部样式通过scoped属性实现样式隔离,避免样式污染:
scoped样式原理:
<!-- 编译前 -->
<template>
<view class="container">
<text class="title">标题</text>
</view>
</template>
<style scoped>
.container {
padding: 32rpx;
}
.title {
color: #007AFF;
font-size: 36rpx;
}
</style>
<!-- 编译后 -->
<template>
<view class="container" data-v-f3f3eg9>
<text class="title" data-v-f3f3eg9>标题</text>
</view>
</template>
<style>
.container[data-v-f3f3eg9] {
padding: 32rpx;
}
.title[data-v-f3f3eg9] {
color: #007AFF;
font-size: 36rpx;
}
</style>
3.3 样式穿透
当需要修改子组件样式时,使用深度选择器:
/* 修改uni-ui组件样式 */
.custom-card ::v-deep .uni-card {
border-radius: 24rpx;
box-shadow: var(--shadow-lg);
}
.custom-card ::v-deep .uni-card__header {
padding: 32rpx 32rpx 0;
border-bottom: none;
}
/* 兼容不同平台的写法 */
.custom-card /deep/ .uni-card__content {
padding: 32rpx;
}
3.4 条件编译
uni-app的条件编译可以针对不同平台编写特定样式:
/* 通用基础样式 */
.button {
padding: 24rpx 48rpx;
border-radius: 12rpx;
font-size: 32rpx;
}
/* 微信小程序特有样式 */
/* #ifdef MP-WEIXIN */
.button {
border-radius: 8rpx;
}
/* #endif */
/* H5平台特有样式 */
/* #ifdef H5 */
.button {
cursor: pointer;
transition: all 0.3s ease;
}
.button:hover {
opacity: 0.9;
transform: translateY(-2rpx);
}
/* #endif */
/* App平台特有样式 */
/* #ifdef APP-PLUS */
.button {
border-radius: 16rpx;
}
/* #endif */
3.5 样式架构
推荐的项目样式结构:
styles/
├── variables.css # CSS变量定义
├── reset.css # 重置样式
├── mixins.css # 混合宏
├── components/ # 组件样式
│ ├── button.css
│ ├── card.css
│ └── form.css
├── pages/ # 页面样式
│ ├── home.css
│ ├── profile.css
│ └── ...
└── utils.css # 工具类
在App.vue中导入:
<style>
/* 导入样式文件 */
@import './styles/variables.css';
@import './styles/reset.css';
@import './styles/utils.css';
@import './styles/components/button.css';
</style>
四、CSS3高级特性
4.1 渐变与阴影
4.1.1 渐变
/* 线性渐变 */
.gradient-bg {
/* 基础渐变 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* 多色渐变 */
background: linear-gradient(90deg,
#FF6B6B 0%,
#4ECDC4 33%,
#45B7D1 66%,
#96CEB4 100%);
/* 透明渐变 - 遮罩效果 */
background: linear-gradient(
to bottom,
rgba(0,0,0,0.8) 0%,
rgba(0,0,0,0) 100%
);
}
/* 文字渐变效果 */
.gradient-text {
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
4.1.2 阴影
/* 基础阴影层级 */
.shadow-layer-1 {
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.shadow-layer-2 {
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.12);
}
.shadow-layer-3 {
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.15);
}
/* 内阴影 */
.shadow-inner {
box-shadow: inset 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
}
/* 多重阴影 */
.shadow-multi {
box-shadow:
0 2rpx 4rpx rgba(0, 0, 0, 0.1),
0 8rpx 16rpx rgba(0, 0, 0, 0.1);
}
/* 悬浮效果 */
.card {
transition: all 0.3s ease;
box-shadow: var(--shadow-md);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-4rpx);
}
4.2 变换与动画
4.2.1 变换
/* 2D变换 */
.transform-2d {
/* 平移 */
transform: translate(100rpx, 50rpx);
/* 缩放 */
transform: scale(1.1);
/* 旋转 */
transform: rotate(45deg);
/* 倾斜 */
transform: skew(15deg, 5deg);
/* 组合变换 */
transform: translateX(50rpx) rotate(15deg) scale(1.05);
}
/* 3D变换 */
.card-3d {
perspective: 1000rpx; /* 透视点 */
}
.card-inner {
transition: transform 0.6s;
transform-style: preserve-3d; /* 保持3D空间 */
}
.card-3d:hover .card-inner {
transform: rotateY(180deg);
}
.card-front, .card-back {
backface-visibility: hidden; /* 隐藏背面 */
}
.card-back {
transform: rotateY(180deg);
}
4.2.2 动画
/* 关键帧动画 */
@keyframes slideIn {
0% {
opacity: 0;
transform: translateY(60rpx) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-20rpx);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* 动画类 */
.slide-in {
animation: slideIn 0.6s ease-out;
}
.bounce {
animation: bounce 0.6s ease-in-out;
}
.pulse {
animation: pulse 2s infinite;
}
/* 交互动画 */
.interactive-btn {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.interactive-btn:active {
transform: scale(0.95);
opacity: 0.8;
}
4.3 高级交互动效
<template>
<view class="interactive-demo">
<!-- 悬浮操作按钮 -->
<view class="fab" :class="{ active: menuOpen }" @click="toggleMenu">
<text class="fab-icon">+</text>
</view>
<!-- 悬浮菜单 -->
<view class="fab-menu" :class="{ active: menuOpen }">
<view class="fab-item" @click="handleAction('share')"
:style="{ transitionDelay: '0.1s' }">
<text class="fab-icon">📤</text>
<text class="fab-text">分享</text>
</view>
<view class="fab-item" @click="handleAction('favorite')"
:style="{ transitionDelay: '0.2s' }">
<text class="fab-icon">❤️</text>
<text class="fab-text">收藏</text>
</view>
<view class="fab-item" @click="handleAction('download')"
:style="{ transitionDelay: '0.3s' }">
<text class="fab-icon">📥</text>
<text class="fab-text">下载</text>
</view>
</view>
<!-- 动画卡片网格 -->
<view class="animated-grid">
<view class="grid-item" v-for="(item, index) in gridItems"
:key="index"
:style="{
animationDelay: `${index * 0.1}s`,
background: item.color
}"
@click="animateItem(index)">
<text class="item-text">{{ item.text }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
menuOpen: false,
gridItems: [
{ text: '卡片1', color: 'linear-gradient(135deg, #667eea, #764ba2)' },
{ text: '卡片2', color: 'linear-gradient(135deg, #f093fb, #f5576c)' },
{ text: '卡片3', color: 'linear-gradient(135deg, #4facfe, #00f2fe)' },
{ text: '卡片4', color: 'linear-gradient(135deg, #43e97b, #38f9d7)' },
{ text: '卡片5', color: 'linear-gradient(135deg, #fa709a, #fee140)' },
{ text: '卡片6', color: 'linear-gradient(135deg, #a8edea, #fed6e3)' }
]
}
},
methods: {
toggleMenu() {
this.menuOpen = !this.menuOpen
},
handleAction(action) {
uni.showToast({
title: `执行: ${action}`,
icon: 'none'
})
this.menuOpen = false
},
animateItem(index) {
// 可以添加更复杂的动画逻辑
console.log('点击卡片:', index)
}
}
}
</script>
<style scoped>
.interactive-demo {
padding: 40rpx;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* 悬浮操作按钮 */
.fab {
position: fixed;
bottom: 80rpx;
right: 40rpx;
width: 120rpx;
height: 120rpx;
background: #FF3B30;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(255, 59, 48, 0.4);
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
z-index: 1000;
cursor: pointer;
}
.fab-icon {
font-size: 48rpx;
color: white;
transition: transform 0.4s ease;
}
.fab.active {
transform: rotate(135deg);
background: #007AFF;
}
/* 悬浮菜单 */
.fab-menu {
position: fixed;
bottom: 220rpx;
right: 70rpx;
opacity: 0;
visibility: hidden;
transform: translateY(40rpx) scale(0.8);
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.fab-menu.active {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.fab-item {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
padding: 24rpx 32rpx;
margin-bottom: 20rpx;
border-radius: 50rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
transform: translateX(60rpx);
opacity: 0;
transition: all 0.4s ease;
}
.fab-menu.active .fab-item {
transform: translateX(0);
opacity: 1;
}
.fab-text {
font-size: 28rpx;
color: #333;
margin-left: 16rpx;
white-space: nowrap;
}
/* 动画网格 */
.animated-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 30rpx;
margin-top: 40rpx;
}
.grid-item {
height: 200rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
animation: cardEntrance 0.6s ease-out both;
transition: all 0.3s ease;
cursor: pointer;
}
.grid-item:active {
transform: scale(0.95);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.3);
}
.item-text {
color: white;
font-size: 32rpx;
font-weight: bold;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
/* 入场动画 */
@keyframes cardEntrance {
from {
opacity: 0;
transform: translateY(60rpx) scale(0.9) rotateX(45deg);
}
to {
opacity: 1;
transform: translateY(0) scale(1) rotateX(0);
}
}
/* 响应式调整 */
@media (max-width: 750px) {
.animated-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 751px) and (max-width: 1200px) {
.animated-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1201px) {
.animated-grid {
grid-template-columns: repeat(4, 1fr);
max-width: 1200px;
margin: 40rpx auto;
}
}
</style>
五、性能优化
5.1 样式性能优化
5.1.1 选择器性能
/* 不推荐 - 性能差 */
.container .list .item .title .text {
color: red;
}
/* 推荐 - 性能好 */
.item-text {
color: red;
}
/* 不推荐 - 通用选择器性能差 */
* {
margin: 0;
padding: 0;
}
/* 推荐 - 明确指定元素 */
view, text, image {
margin: 0;
padding: 0;
}
5.1.2 动画性能优化
/* 不推荐 - 触发重排的属性 */
.animate-slow {
animation: changeWidth 1s infinite;
}
@keyframes changeWidth {
0% { width: 100rpx; }
100% { width: 200rpx; }
}
/* 推荐 - 只触发重绘的属性 */
.animate-fast {
animation: changeOpacity 1s infinite;
}
@keyframes changeOpacity {
0% { opacity: 1; }
100% { opacity: 0.5; }
}
/* 启用GPU加速 */
.gpu-accelerated {
transform: translateZ(0);
will-change: transform;
}
5.2 维护性
5.2.1 BEM命名规范
/* Block - 块 */
.product-card { }
/* Element - 元素 */
.product-card__image { }
.product-card__title { }
.product-card__price { }
/* Modifier - 修饰符 */
.product-card--featured { }
.product-card__price--discount { }
5.2.2 样式组织架构
styles/
├── base/ # 基础样式
│ ├── variables.css
│ ├── reset.css
│ └── typography.css
├── components/ # 组件样式
│ ├── buttons.css
│ ├── forms.css
│ └── cards.css
├── layouts/ # 布局样式
│ ├── header.css
│ ├── footer.css
│ └── grid.css
├── utils/ # 工具类
│ ├── spacing.css
│ ├── display.css
│ └── text.css
└── themes/ # 主题样式
├── light.css
└── dark.css
通过本节的学习,我们掌握了:Flex布局 、rpx单位、样式设计、css3高级特性,欢迎在评论区留言,我会及时解答。
版权声明:本文内容基于实战经验总结,欢迎分享交流,但请注明出处。禁止商业用途转载。
GPT-6 会带来科学革命?奥特曼最新设想:AI CEO、便宜医疗与全新计算机
访谈链接:🎧 YouTube:Tyler Cowen x Sam Altman 全程访谈
在经历资本重组、Ilya 离场、与微软“重新订婚”之后,Sam Altman 再次出现在公众视野。但这次,他没有讲产品,而是描绘了一个AI 改写科学、社会与人的世界图景。
![]()
这期访谈由经济学家 Tyler Cowen 主持,发生在 10 月 17 日的 Progress Conference。访谈发布后,AI 圈几乎是震了一下——它不像新闻稿,更像一份来自未来的备忘录。
奥特曼的核心论点可以用一句话概括:
| “GPT-6 将带来科学革命,人类正走向‘两人加 AI’就能运营十亿美元公司的时代。” |
|---|
二、科学革命的信号:GPT-6 不再只是“聪明”,而是能发现
在奥特曼看来,GPT-3 让我们第一次看到 AI 像有灵魂;GPT-5 让我们第一次看到 AI 有了创造。 而 GPT-6 的目标,是让“AI 成为科学研究的合作者”。
| “GPT-5 已经出现了零星的科学灵感闪光,但 GPT-6 可能会真正推动科研突破。” |
|---|
对科研机构的提醒是:
| “别等到发布才开始准备。AI 不只是辅助工具,而是你下一位研究员。” |
|---|
这句话的重量不亚于 90 年代互联网刚被提出时那种“你必须上网”的宣言。
奥特曼甚至公开提到,OpenAI 内部已经在思考 “由 AI 运营公司” 的实验。
| “如果 OpenAI 不是第一个有 AI CEO 的公司,那我就失职了。” |
|---|
他的逻辑很冷静:
两三年内,会出现由两三个人 + AI 一起运营的十亿美元级公司;
企业的部分部门(财务、研发、运营)将有 80% 工作由 AI 自动完成;
“CEO 这个角色的外向部分(媒体、政治)依然是人,但决策和执行层面会被 AI 接管。”
这意味着,AI 不仅取代岗位,还可能重塑组织结构的定义。在奥特曼的思路里,未来的“公司”,更像是一台以 AI 为核心的“智能自治体”。
访谈中一个被忽视但极具爆发潜力的细节,是 ChatGPT 正在测试电商功能。
| “旅行和商品推荐功能会先上线,我们只收取标准交易费,不掺广告。” |
|---|
奥特曼坦言:
| “搜索引擎广告模式与用户利益相悖;而 ChatGPT 的商业逻辑,是基于信任。” |
|---|
未来当 ChatGPT 告诉你哪家酒店最好时,你相信它——这信任本身,就是新的经济护城河。但他也认为:AI 会压低几乎所有行业的利润率,包括中介、预订、甚至 SaaS 模型。这是一个“低利润但高效率”的未来经济。
当被问到“为什么不多造 GPU”时,奥特曼回答惊人:
| “因为我们得先造出更多的电子。” |
|---|
他认为算力的真正瓶颈是能源。短期靠天然气,长期靠核聚变 + 太阳能。OpenAI 正在下注未来几十年的能源革命,而不仅仅是模型参数。
这句话背后的现实是:AI 的未来属于能源公司 + 模型公司 + 芯片公司三位一体的结构。
六、AI 教育:本科回报率下降,但“使用 AI 的能力”会取代学历
奥特曼预测:
| “普通本科的经济回报会持续下降。能有效使用 AI 的人,会在各行各业获得更高回报。” |
|---|
这意味着未来教育体系可能分裂成两类:
实验型 AI 学校:用 AI 直接参与学习;
传统大学体系:被迫改革但进度缓慢。
他甚至预言:“AI 学习曲线极低,人类会像当年学 Google 一样自然掌握。”
换句话说,Prompt Engineering 只是开端,AI Literacy(AI 素养)才是未来的学历。
奥特曼在访谈中也谈到社会层面:
住房与医疗成本将下降——因为 AI 能极大降低制药和诊疗成本;
成人自由应被尊重——OpenAI 将恢复部分内容限制,让成年人“像成年人一样使用 AI”;
心理健康保护将被系统化——AI 将被视作“高敏感内容的心理交互体”,需特殊防护。
这是 OpenAI 第一次在“社会治理”层面谈伦理与自由的平衡。
在访谈尾声,奥特曼抛出一句未来学级别的爆点:
| “OpenAI 的目标,是发明一种全新的计算设备,一种为 AI 重新设计的人机界面。” |
|---|
他认为,过去 50 年的计算范式都是围绕“人操控计算机”建立的,而未来将是:
| “AI 操控世界,人类只需确认。” |
|---|
这台新机器,或许正是他与 Jony Ive 合作的“AI 硬件”项目雏形——一台介于手机、伴侣与助理之间的“新形态计算设备”。
从开发者视角看,GPT-6 的关键词其实是 System Rewrite。它不是更聪明的 LLM,而是一种操作系统级的范式转变:
我们写代码,不再是写功能,而是在编排智能体;
我们做架构,不再是分层,而是设计任务协作图;
我们建应用,不再是页面,而是智能节点间的对话接口。
AI 将不只是“编程工具”,而是“系统的第二意识”。从这里看,奥特曼口中的“AI CEO”“AI 科学家”“AI 设备”,其实都是同一个母体的分身:人工智能体的社会化。
奥特曼最后被问到的终极问题是: “当你能对超级智能输入一句提示时,你会输入什么?”
他没有回答。但这个空白本身,或许就是人类与 AI 的分界线:
—— AI 负责创造一切,而人类,仍在寻找意义。
前端人必看的 node_modules 瘦身秘籍:从臃肿到轻盈,Umi 项目依赖优化实战
目录
- 一、量化分析:给你的依赖做 "CT 扫描"
- 二、精简依赖清单
- 三、Umi 专属优化:框架特性深度利用
- 四、依赖管理升级:从 npm 到 pnpm
- 五、删除非必要文件 —— 用
autoclean斩断 “垃圾文件” - 六、长期维护 —— 避免 “二次臃肿”
- 七、实战案例:1.5GB 到 900MB 的蜕变
- 八、总结
在现代前端开发中,当你执行npm install后看到 node_modules 文件夹膨胀到 1.5GB 时,不必惊讶 —— 这已是常态。但对于 Umi 框架项目而言,这个 "体积怪兽" 不仅吞噬磁盘空间,更会导致开发启动缓慢、构建时长增加、部署包体积飙升等一系列问题。本文将基于 Umi 框架特性,提供一套可落地的完整优化方案,从分析到执行,一步步将 node_modules 体积控制在合理范围。
graph TD
A[node_modules臃肿] -->| 安装analytics分析插件 | B(查看包体积分布情况)
B --> C[解决方案]
C --> | 安装depcheck |D[剔除无用的插件]
C --> E[依赖替换计划]
C --> F[umi内置优化]
C --> G[依赖管理升级]
C --> |autoclean|H[删除无用空文件]
C --> I[持续维护]
一、量化分析:给你的依赖做 "CT 扫描"
在优化之前,我们需要精准定位问题 —— 哪些依赖在 "作恶"?Umi 项目可通过以下工具组合进行全面体检。
1.1 安装分析工具链
# 全局安装核心分析工具
npm install -g depcheck
1.2 全方位扫描依赖状况
1.2.1 检测冗余依赖
# 在项目根目录执行
depcheck
该命令会输出三类关键信息:
Unused dependencies
├── lodash # 生产依赖中未使用
└── moment # 生产依赖中未使用
Unused devDependencies
├── eslint-plugin-vue # 开发依赖中未使用
└── webpack-cli # 开发依赖中未使用
Missing dependencies
└── axios # 代码中使用了,但未在package.json声明
1.2.2 depcheck介绍
depcheck并非简单 “字符串匹配”,而是通过AST 语法分析 + 依赖图谱构建实现精准检测,核心步骤分 3 步:
-
依赖图谱采集:解析
package.json中的dependencies/devDependencies,生成 “已声明依赖列表”;同时遍历项目源码目录(默认排除node_modules/dist等目录),记录所有通过import/require引入的 “实际使用依赖列表”。 -
AST 语法树分析:对
.js/.ts/.jsx等源码文件构建抽象语法树(AST),提取ImportDeclaration(ES 模块)、CallExpression(CommonJS 模块)中的依赖标识符(如import lodash from 'lodash'中的lodash),排除 “仅声明未调用” 的依赖(如代码中import moment from 'moment'但未使用moment变量)。 -
双向比对与分类:- 未使用依赖(Unused dependencies):已声明但未在 AST 中找到调用的依赖;
- 缺失依赖(Missing dependencies):AST 中找到调用但未在
package.json声明的依赖; - 开发 / 生产依赖混淆:结合 “依赖使用场景” 判断(如
eslint仅在开发阶段调用,若出现在dependencies中则提示分类错误)。
- 缺失依赖(Missing dependencies):AST 中找到调用但未在
1.2.3 analyze 介绍
Umi 框架内置的体积分析配置(即 analyze 配置项)本质上是对 Webpack 生态中 webpack-bundle-analyzer 插件的封装,通过自动化配置简化了开发者手动集成该插件的流程,最终实现对项目打包体积的可视化分析。
-
原理解析
-
底层依赖:
webpack-bundle-analyzerUmi 基于 Webpack 构建,而webpack-bundle-analyzer是 Webpack 生态中最常用的体积分析工具。它的工作原理是:- 在 Webpack 构建结束后,解析打包产物(如
dist目录下的 JS/CSS 文件)和对应的sourcemap(用于映射打包代码与原始源码)。 - 分析每个
chunk(打包后的代码块)的体积、内部包含的模块(如第三方依赖、业务代码)及其体积占比。 - 通过可视化界面(交互式树状图、列表)展示分析结果,支持按体积排序、查看模块依赖关系等。
- 在 Webpack 构建结束后,解析打包产物(如
-
Umi 内置配置的封装逻辑 Umi 的
analyze配置并非重新实现体积分析功能,而是通过框架层自动处理了webpack-bundle-analyzer的集成细节,具体包括:-
条件性引入插件 当开发者在 Umi 配置文件(
config/config.ts或.umirc.ts)中开启analyze: { ... }时,Umi 会在 Webpack 配置阶段自动引入webpack-bundle-analyzer插件,并将用户配置的参数(如analyzerPort、openAnalyzer等)传递给该插件。 例如,用户修改 Umi 配置文件(config/config.ts或.umirc.ts):import { defineConfig } from 'umi'; export default defineConfig({ analyze: { analyzerMode: 'server', // 分析模式 server本地服务器 static 静态html文件 disabled禁用分析 analyzerPort: 8888, // 端口 openAnalyzer: true, // 是否自动在浏览器中打开 generateStatsFile: false, // 是否生成统计文件 statsFilename: 'stats.json', // 文件名称 logLevel: 'info', // 日志等级 defaultSizes: 'parsed', // stat // gzip // 显示文件大小的计算方式 }, }Umi 会将其转化为 Webpack 插件配置:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = { plugins: [ new BundleAnalyzerPlugin({ analyzerMode: 'server', // 启动本地服务展示报告 analyzerPort: 8888, // 服务端口 openAnalyzer: true, // 构建后自动打开浏览器 }), ], };
-
-
-
与 Umi 构建流程联动Umi 的构建命令(
umi build)会触发 Webpack 的打包过程。当analyze配置开启时,Webpack 在打包完成后会执行webpack-bundle-analyzer的逻辑:
-
启动一个本地 HTTP 服务(默认端口
8888),将分析结果以 HTML 页面的形式展示。 -
自动打开浏览器访问该服务,开发者可直观查看体积分析报告
-
默认参数的合理性优化Umi 对
analyze配置提供了合理的默认值(如默认analyzerMode: 'server'、openAnalyzer: true),无需开发者手动配置即可快速使用,降低了使用门槛。
1.2.4 分析报告的生成逻辑
-
数据来源:Webpack 打包过程中会生成
stats对象(包含构建过程的详细信息,如模块依赖、chunk 组成、体积等),webpack-bundle-analyzer通过解析该对象获取基础数据。 -
体积计算:报告中展示的体积通常是未压缩的原始体积(便于分析模块真实占比),但也会标注 gzip 压缩后的体积(更接近生产环境实际传输大小)。
-
可视化呈现:通过树状图(每个节点代表一个模块或 chunk,大小与体积成正比)和列表(按体积排序)展示,支持点击节点查看子模块细节。
-
stats对象拆解以及体积计算规则-
stats 对象的核心数据结构:Webpack 构建时会生成包含 “模块依赖树” 的
stats对象,关键字段包括:-modules:所有参与构建的模块(含业务代码、第三方依赖),每个模块记录id(唯一标识)、size(原始体积)、dependencies(子依赖列表)、resource(文件路径);
-
chunks:打包后的代码块,每个 chunk 记录id、modules(包含的模块 ID 列表)、size(chunk 原始体积)、gzipSize(gzip 压缩后体积); -
assets:最终输出的静态资源(如main.xx.js),关联对应的 chunk 及体积。
-
体积计算的两个维度:- 原始体积(parsed size):模块经过 Webpack 解析(如 babel 转译、loader 处理)后的未压缩体积,反映 “模块真实占用的内存空间”,用于定位 “大体积模块根源”;
- 压缩体积(gzip size):通过 ZIP 压缩算法计算的体积,接近生产环境 CDN 传输的实际大小,用于评估 “用户加载速度影响”;
- 注意:
analyze报告中的 “重复依赖体积”,是通过比对不同 chunk 中modules的resource路径(如node_modules/lodash/lodash.js在两个 chunk 中均出现),累加重复模块的体积得出。
-
-
Umi 内置的体积分析配置本质是对
webpack-bundle-analyzer插件的 “零配置” 封装,通过框架层自动处理插件引入、参数传递和构建流程联动,让开发者无需关心 Webpack 底层细节,仅通过简单配置即可快速生成项目体积分析报告,从而定位大体积依赖、冗余代码等问题,为性能优化提供依据。
1.2.5 使用
# 启动分析(需要配置环境变量)
ANALYZE=1 umi dev
# 构建分析(需要配置环境变量)
ANALYZE=1 umi build
- 查看每个依赖包的体积占比
- 识别重复引入的依赖
- 发现意外引入的大型依赖
二、精简依赖清单
经过分析后,首先要做的就是 "减肥"—— 移除不必要的依赖,这是最直接有效的优化手段。
2.1 移除未使用依赖
根据depcheck的输出结果,执行卸载命令:
# 卸载未使用的生产依赖
npm uninstall <package-name>
# 卸载未使用的开发依赖
npm uninstall --save-dev <package-name>
清理 “未声明但已安装” 的依赖(防止误删):
npm prune # 仅保留package.json中声明的依赖
注意事项:
-
卸载前先在代码中搜索确认该依赖确实未被使用
-
对于不确定的依赖,可先移至 devDependencies 观察一段时间
-
团队协作项目需同步更新 package-lock.json 或 yarn.lock
2.2 区分依赖类型
确保依赖类型划分正确,避免开发依赖混入生产依赖:
{
"dependencies": {
// 仅包含运行时必需的依赖
"react": "^18.2.0",
"react-dom": "^18.2.0",
"dayjs": "^1.11.7" // 运行时需要的日期处理库
},
"devDependencies": {
// 开发和构建时需要的工具
"@umijs/preset-react": "^2.9.0",
"@types/react": "^18.0.26",
"eslint": "^8.30.0", // 仅开发时使用的代码检查工具
"umi": "^3.5.40"
}
}
2.3 依赖替换计划
2.3.1 拆解其体积膨胀的底层机制
-
全量打包与冗余代码:-
moment:默认包含所有地区的语言包(如locale/zh-cn.js、locale/en-gb.js),即使项目仅用 “日期格式化” 功能,也会打包全部语言包(占总体积的 40% 以上);-
lodash(全量包):包含 100 + 工具函数,项目若仅用debounce/throttle,仍会打包其余 90% 未使用函数,属于 “按需加载缺失” 导致的冗余。
-
-
ES5 兼容代码冗余:
传统依赖(如
axios@0.27.0前版本)为兼容 IE 浏览器,会内置Promise/Array.prototype.includes等 ES6+API 的 polyfill(如core-js代码),而现代前端项目(如基于 Umi 3+)已通过browserslist指定 “不兼容 IE”,这些 polyfill 成为无效冗余代码,占体积 15%-20%。 -
依赖嵌套层级深:
以
axios为例,其依赖follow-redirects(处理重定向),而follow-redirects又依赖debug(日志工具),debug再依赖ms(时间格式化)—— 这种 “依赖链过长” 导致 “间接依赖体积累加”,且若其他依赖也依赖debug的不同版本,会引发 “版本分叉”(如debug@3.x和debug@4.x同时存在)。 针对 Umi 项目常用的大型依赖,推荐以下轻量替代方案:
| 功能场景 | 传统重量级依赖 | 推荐轻量替代 | 体积减少 | 替换难度 |
|---|---|---|---|---|
| 日期处理 | moment(240kB) | dayjs(7kB) | 97% | 低 |
| 工具库 | lodash(248kB) | lodash-es (按需加载) | 90%+ | 中 |
| HTTP 客户端 | axios(142kB) | ky(4.8kB) | 95% | 中 |
| 状态管理 | redux+react-redux(36kB) | zustand(1.5kB) | 95% | 中 |
| 表单处理 | antd-form (含在 antd 中) | react-hook-form(10kB) | 视情况 | 中高 |
| UI 组件库 | antd (完整,~500kB) | antd 按需加载 + lodash-es | 60-80% | 低 |
2.3.2 “轻量” 并非 “功能阉割”,而是 “技术设计优化”
-
模块化架构设计:-
dayjs:采用 “核心 + 插件” 架构,核心体积仅 7kB(含基础日期处理),语言包、高级功能(如相对时间relativeTime)需手动导入(如import 'dayjs/locale/zh-cn'),避免 “全量打包”;-
lodash-es:基于 ES 模块(ESM)设计,支持 “树摇(Tree Shaking)”—— Webpack/Rollup 会自动剔除未使用的函数(如import { debounce } from 'lodash-es',仅打包debounce相关代码),而传统lodash(CommonJS 模块)因 “函数挂载在全局对象”(如_ = require('lodash')),无法被 Tree Shaking 优化。
-
-
现代语法原生兼容:
ky(替代axios)仅支持 ES6 + 环境,直接使用原生fetch API(无需内置Promisepolyfill),且移除axios中 “过时功能”(如transformRequest的兼容处理),体积从 142kB 降至 4.8kB,核心是 “放弃旧环境兼容,聚焦现代浏览器”。 -
依赖链扁平化:
zustand(替代redux+react-redux)无任何第三方依赖,核心逻辑仅 1.5kB,而redux依赖loose-envify(环境变量处理)、react-redux依赖hoist-non-react-statics(组件静态属性提升),间接依赖体积累加导致总大小达 36kB—— 轻量依赖的 “零依赖 / 少依赖” 设计,从根源减少 “依赖嵌套冗余”。
替换实操示例(moment → dayjs):
-
卸载旧依赖:
npm uninstall moment -
安装新依赖:
npm install dayjs --save -
代码替换(批量替换可使用 IDE 全局替换功能):
// 旧代码 import moment from 'moment'; moment().format('YYYY-MM-DD');
// 新代码 import dayjs from 'dayjs'; dayjs().format('YYYY-MM-DD');
效果:中小型项目可减少 10%-30% 的体积,尤其适合历史项目的 “首次瘦身”。
## 三、Umi 专属优化:框架特性深度利用
Umi 框架内置了多项优化能力,充分利用这些特性可显著减少依赖体积。
### 3.1 路由级懒加载配置
Umi 的路由系统默认支持懒加载,只需正确配置路由即可实现按路由分割代码:
```js
export default [
{
path: '/',
component: '../layouts/BasicLayout',
routes: [
{
path: '/',
name: '首页',
component: './Home'
},
{
path: '/dashboard',
name: '数据看板',
component: './Dashboard',
// 可配置更精细的分割策略
// 仅在访问该路由时才加载echarts
chunkGroup: 'dashboard'
},
{
path: '/analysis',
name: '深度分析',
component: './Analysis',
// 大型页面单独分割
chunkGroup: 'analysis'
},
{
path: '/setting',
name: '系统设置',
component: './Setting'
}
]
}
];
优化效果:访问首页时仅加载首页所需依赖,不会加载 dashboard 所需的 echarts 等重型库
3.2 组件级动态导入
对于页面内的大型组件(如富文本编辑器、图表组件),使用 Umi 的dynamic方法实现按需加载:
import { dynamic, useState } from 'umi';
import { Button } from 'antd';
// 动态导入ECharts组件(仅在需要时加载)
const EChartComponent = dynamic({
loader: () => import('@/components/EChartComponent'),
// 加载状态提示
loading: () => <div className="loading">图表加载中...</div>,
// 延迟加载,避免快速切换导致的不必要加载
delay: 200,
});
// 动态导入数据导出组件(仅在点击按钮时加载)
const DataExportComponent = dynamic({
loader: () => import('@/components/DataExportComponent'),
loading: () => <div className="loading">准备导出工具...</div>,
});
export default function Dashboard() {
const [showExport, setShowExport] = useState(false);
return (
<div className="dashboard">
<h1>数据看板</h1>
{/* 图表组件会在页面加载时开始加载 */}
<EChartComponent />
<Button onClick={() => setShowExport(true)}>
导出数据
</Button>
{/* 导出组件仅在点击按钮后才会加载 */}
{showExport && <DataExportComponent />}
</div>
);
}
3.3 配置外部依赖 (Externals)
import { defineConfig } from 'umi';
export default defineConfig({
// 配置外部依赖
externals: {
// 键:包名,值:全局变量名
react: 'window.React',
'react-dom': 'window.ReactDOM',
'react-router': 'window.ReactRouter',
lodash: 'window._',
echarts: 'window.echarts',
},
// 配置CDN链接(生产环境)
scripts: [
'https://cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js',
'https://cdn.jsdelivr.net/npm/react-dom@18.2.0/umd/react-dom.production.min.js',
'https://cdn.jsdelivr.net/npm/react-router@6.8.1/umd/react-router.min.js',
'https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js',
'https://cdn.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js',
],
// 开发环境仍使用本地依赖,避免CDN不稳定
define: {
'process.env.NODE_ENV': process.env.NODE_ENV,
},
// 条件性加载CDN
headScripts: process.env.NODE_ENV === 'production' ? [
// 生产环境额外的CDN脚本
] : [],
});
注意:配置 externals 后需确保代码中不再通过import引入这些库
3.4 优化 Ant Design 等 UI 组件库
Umi 配合@umijs/plugin-antd可实现 Ant Design 的按需加载
import { defineConfig } from 'umi';
export default defineConfig({
antd: {
// 启用按需加载
import: true,
// 配置主题,减少不必要的样式生成
theme: {
'primary-color': '#1890ff',
'link-color': '#1890ff',
'success-color': '#52c41a',
// 只保留必要的主题变量,减少css体积
},
},
// 配置babel-plugin-import优化其他组件库
extraBabelPlugins: [
[
'import',
{
libraryName: 'lodash',
libraryDirectory: '',
camel2DashComponentName: false,
},
'lodash',
],
[
'import',
{
libraryName: '@ant-design/icons',
libraryDirectory: 'es/icons',
camel2DashComponentName: false,
},
'antd-icons',
],
],
});
四、依赖管理升级:从 npm 到 pnpm
npm/yarn 的 “嵌套依赖” 机制是根源之一。例如:
-
项目依赖A@1.0.0,而A又依赖B@2.0.0;
-
同时项目依赖C@3.0.0,C又依赖B@1.0.0;
-
此时 node_modules 中会同时存在B@1.0.0和B@2.0.0,即使两者差异极小,也会重复占用空间。
对于复杂项目,这种 “版本分叉” 会呈指数级增长,最终导致大量重复代码堆积。
4.1 为什么pnpm会比npm要快
- 少复制文件:npm 安装软件包时,就像在每个项目里都单独建了一个小仓库,把每个软件包都复制一份放进去。如果有 10 个项目都要用同一个软件包,那这个软件包就会被复制 10 次,很浪费时间。而 pnpm 呢,就像建了一个大的中央仓库,把所有软件包都放在里面,每个项目需要某个软件包时,不是再复制一份,而是通过一种类似 “快捷方式” 的硬链接去引用中央仓库里的软件包,这样就不用重复复制,安装速度自然就快了。
- 安装速度快:pnpm 在安装软件包时,就像有多个工人同时工作,能一起去下载和安装不同的软件包,充分利用了电脑的性能。而 npm 通常是一个工人先完成一个软件包的安装,再去安装下一个,所以 pnpm 安装多个软件包时会更快。
- 依赖关系清晰:npm 在解析软件包的依赖关系时,就像一个人在迷宫里慢慢找路,有时候可能会走一些冤枉路,重复去解析一些已经解析过的依赖关系。而 pnpm 则像有一张清晰的地图,能一下子就找到每个软件包需要的其他软件包,不会做多余的工作,所以解析速度更快。
- 管理大型项目更高效:如果项目很大,或者有很多子项目(这种情况叫 Monorepo),npm 管理起来就会比较吃力,就像一个人要同时照顾很多孩子,可能会顾不过来。而 pnpm 对这种大型项目做了优化,能更好地管理各个子项目的依赖关系,让它们共享一些依赖的软件包,避免重复安装,所以处理起来更快。
Umi 项目迁移步骤如下(3分钟搞定):
4.2 安装 pnpm
# 安装pnpm
npm install -g pnpm
# 验证安装
pnpm --version
4.3 清理旧依赖
# 删除现有node_modules
rm -rf node_modules
# 删除锁文件
rm -f package-lock.json yarn.lock
4.4 用 pnpm 重新安装依赖
# 安装依赖(会生成pnpm-lock.yaml)
pnpm install
# 验证安装结果
pnpm ls
4.5 umi3.x + 低版本node(16) 升级pnpm指南
pnpm需要至少Node.js v18.12的版本才能正常运行。所以实际项目中有的node版本可能是18以下,这里来教大家怎么升级
4.5.1 启动
pnpm run start
4.5.2 报错
node:internal/crypto/hash:69
this[kHandle] = new _Hash(algorithm, xofLen);
^
Error: error:0308010C:digital envelope routines::unsupported
常发生在使用较新的 Node.js 版本(如 v18+)运行一些基于 Webpack 4 或更早版本构建的项目时,原因是 Node.js 升级后对 OpenSSL 加密算法的支持发生了变化,导致旧版构建工具不兼容。
4.5.2 解决方案
-
临时设置环境变量(最简单,推荐测试用) Windows(cmd 命令行):
set NODE_OPTIONS=--openssl-legacy-provider && npm startWindows(PowerShell):
$env:NODE_OPTIONS="--openssl-legacy-provider" && npm startMac/Linux(终端):
NODE_OPTIONS=--openssl-legacy-provider npm start -
降低node版本
nvm ls nvm install nvm use使用nvm直接降级即可
-
升级umi4.x
五、删除非必要文件 —— 用autoclean斩断 “垃圾文件”
核心目标:移除依赖中的测试、文档、日志等无用文件。
工具:yarn 自带的autoclean或 npm 生态的modclean。
以npm modclean(更轻量,无需额外安装):
-
安装modclean:
npm install modclean -g -
执行清理(默认清理常见无用文件,支持自定义规则):
modclean -n default -o # -n:规则集,-o:删除空文件夹注意不同的
modclean版本配置不一样modclean3.x版本可直接运行上面命令,2.x版本需要配置文件
步骤 1:创建配置文件(.modcleanrc)添加 empty: true 配置(作用等同于 -o 参数):
{
"empty": true, // 启用:清理后自动删除空文件夹
"rules": {
"default": { // 复用默认规则集(等同于命令行 -n default)
"include": [
"**/__tests__/**",
"**/test/**",
"**/docs/**",
"**/examples/**",
"**/*.log",
"**/*.md",
"**/.gitignore"
]
}
},
"defaultRule": "default" // 默认使用上述规则集
}
步骤 2:执行清理命令
modclean -c .modcleanrc # -c 指定配置文件路径
验证效果 查看 node_modules 中是否存在空文件夹(Mac/Linux)
find ./node_modules -type d -empty
Windows 系统(PowerShell):
Get-ChildItem -Path ./node_modules -Directory -Recurse | Where-Object { $_.GetFiles().Count -eq 0 -and $_.GetDirectories().Count -eq 0 }
理想结果:执行后无任何输出,说明所有空文件夹已被删除; 效果:单个依赖的体积可减少大概40% ,例如lodash清理后从 2MB 降至 1.2MB,axios从 1.5MB 降至 0.9MB。
六、长期维护 —— 避免 “二次臃肿”
优化后若不维护,node_modules 可能再次膨胀,需建立 3 个习惯:
-
锁定依赖版本:使用
package-lock.json(npm)或yarn.lock(yarn),避免安装时自动升级到高版本(可能引入冗余依赖)。 -
定期更新依赖:用
npm outdated或yarn outdated查看过时依赖,优先更新体积小、无破坏性变更的包(避免因依赖过旧导致兼容性问题,间接增加依赖体积)。 -
新增依赖前检查体积:在
bundlephobia查询新依赖的体积,拒绝 “大而全” 但仅用少量功能的包(如仅用lodash的debounce,则直接引入lodash.debounce而非全量lodash)。
6.1 从 “人工操作” 升级到 “工程化监控”
bundlephobia 能快速查询依赖体积,核心是 “云端模拟 Webpack 构建 + 体积分析”,步骤如下:
-
依赖下载与构建: 当查询
lodash时,bundlephobia 会从 npm 仓库下载lodash的最新版本,通过 “模拟 Webpack+Tree Shaking” 构建(默认配置mode: production、optimization.usedExports: true),生成 “全量导入”(import _ from 'lodash')和 “按需导入”(import { debounce } from 'lodash')两种场景的构建产物。 -
体积计算与对比:- 原始体积:构建产物的未压缩大小(对应 Webpack 的
parsed size);- 压缩体积:通过
gzip(默认压缩级别 6)和brotli(更高效的压缩算法)计算的体积; - 依赖链体积:自动解析该依赖的所有子依赖体积,累加得出 “总依赖体积”(如
axios的 142kB 包含follow-redirects等子依赖的体积)。
- 压缩体积:通过
-
版本对比功能: 记录该依赖历史版本的体积变化(如
moment@2.29.0到moment@2.29.4的体积是否增加),并标注 “体积突变版本”(如某版本引入新子依赖导致体积暴涨)—— 帮助用户选择 “体积稳定的版本”。6.2 如何在 CI/CD 流程中集成体积监控”,避免 “依赖体积回退”
核心工具为
size-limit(基于 Webpack 的体积检测工具): -
size-limit 的工作原理:- 配置文件(
.size-limit.json)中指定 “需要监控的入口文件”(如src/index.js)和 “体积阈值”(如100kB);- 运行
size-limit时,工具会模拟生产环境构建(使用 Webpack/Rollup),计算入口文件对应的 chunk 体积; - 若体积超过阈值,直接报错(如 “体积 120kB 超过阈值 100kB”),阻断 CI 流程(如 GitHub Actions)。
- 运行
-
与 Git 钩子的集成: 通过
husky配置pre-commit钩子,每次提交代码前自动运行size-limit,若新增依赖导致体积超标,禁止提交 —— 原理是 “在代码提交阶段提前拦截问题,避免等到构建时才发现”。 -
体积变化报告生成: 集成
size-limit --json输出体积变化数据,结合github-action-size等工具,在 PR(Pull Request)中自动生成 “体积对比报告”(如 “本次 PR 新增依赖导致体积增加 15kB”),让团队直观看到 “依赖变更的体积影响”。
七、实战案例:1.5GB 到 900MB 的蜕变
| 指标 | 初始状态 | 优化后状态 | 优化幅度 |
|---|---|---|---|
| node_modules 体积 | 1.5GB | 996MB | 减少35.5% |
| 依赖安装时间 | 1分钟 | 26.6秒 | 减少50.8% |
| 项目构建时间 | 2分38秒 | 1分20秒 | 减少57.5% |
八、总结
node_modules 体积膨胀是现代 JavaScript 开发中的普遍问题,但通过系统的分析和有针对性的优化,我们完全可以驯服这个 "体积怪兽"。从精简依赖清单到选择轻量替代品,从使用现代包管理器到构建优化,每一步都能带来显著的改善。 记住,控制 node_modules 体积是一个持续的过程,需要团队共同努力和长期坚持。通过建立良好的依赖管理习惯和自动化监控机制,我们可以保持项目的轻盈和高效,让开发体验更加流畅。 最后,每引入一个新依赖,都应该深思熟虑,因为每一行不需要的代码,都是未来的技术债务。
作者:洞窝-佳宇
基于Monaco的diffEditor实现内容对比
前言
最近收到一个需求,实现两个配置文件对比的能。一开始想着那简单直接用采用monaco的diffEditor组件就可以了。在开发的时候发现没这么简单,因为monaco内置的diffEditor只有两种状态新增、删除,但是我们产品需要我们存在三种状态新增、删除、更新
-
monaco默认的效果,行样式没有与我的样式保持一致,只存在两种状态

-
我需要实现效果,行样式保持一致,并且存在三种状态

需求分析
- 需要计算出
新增、删除、差异各占多少行,这里采用diffEidtor提供的getLineChanges方法获取所有行改动,然后分析数据 - 如何判断
新增行、删除行、差异行呢?(这里主要想明白,你的状态是跟着视图走的,左侧空行代表新增,右侧空行代表删除、两侧都存在代表更新,是不是一说就明白呢? 但是我之前还结合charChanges算了好久,后面发现根本就不需要)
1. 因为originalStartLineNumber和originalEndLineNumber为1,而modifiedStartLineNumber和modifiedEndLineNumber是1-2。那么表示第一行为更新状态、第二行为新增状态
2. 由于originalStartLineNumber和originalEndLineNumber为3,但是modifiedEndLineNumber为0,那么表示更新后被移除了,则第三行为删除状态
[ { "originalStartLineNumber": 1, "originalEndLineNumber": 1, "modifiedStartLineNumber": 1, "modifiedEndLineNumber": 2, "charChanges": [...]
},
{
"originalStartLineNumber": 3,
"originalEndLineNumber": 3,
"modifiedStartLineNumber": 3,
"modifiedEndLineNumber": 0
}
]
3. 想明白新增行、删除行、差异行的计算,那么我们就聚焦到这些行变化的颜色,其实也不算复杂,首先将默认行的背景色改为透明、然后我们再根据变更状态添加对应的行装饰器就可以实现我们需要的效果了
代码实现
- 设置diffEditor变化的背景色为透明
// 覆盖Monaco Editor的默认diff样式
.monaco-diff-editor .line-insert {
background-color: transparent !important;
}
.monaco-diff-editor .line-delete {
background-color: transparent !important;
}
.monaco-editor .line-insert {
background-color: transparent !important;
}
.monaco-editor .line-delete {
background-color: transparent !important;
}
// 将整行的char-delete和line-delete背景设为透明,但保留字符级别的删除标记
.monaco-diff-editor .char-delete[style*='width:100%'] {
background-color: transparent !important;
}
.monaco-diff-editor .char-insert[style*='width:100%'] {
background-color: transparent !important;
}
// 简单的diff行样式 - 参考断点行的实现方式
.diff-line-added {
background-color: #44ca6240 !important;
}
.diff-line-deleted {
background-color: #f87d7c40 !important;
}
.diff-line-modified {
background-color: #ffad5d40 !important;
}
// 暗色主题
.monaco-editor.vs-dark {
// 覆盖暗色主题下的Monaco默认样式
.line-insert {
background-color: transparent !important;
}
.line-delete {
background-color: transparent !important;
}
.diff-line-added {
background-color: #44ca6260 !important;
}
.diff-line-deleted {
background-color: #f87d7c60 !important;
}
.diff-line-modified {
background-color: #ffad5d60 !important;
}
.char-delete[style*='width:100%'] {
background-color: transparent !important;
}
.char-insert[style*='width:100%'] {
background-color: transparent !important;
}
}
- 注册DiffEditor编辑器,主要关注的是onMount的处理
<DiffEditor
width="900"
height="300"
language="javascript"
theme={
this.props.colorMode === ColorMode.Light
? 'vs-light'
: 'vs-dark'
}
original={leftTest}
modified={rightTest}
options={options}
onMount={this.editorDidMount}
{...config}
/>
- 当编辑器加载完成时,onDidUpdateDiff监听文本变化,然后执行applyCustomDiffDecorations
editorDidMount(editor, monaco) {
this.diffEditor = editor
this.monaco = monaco
// 调用 onRef 回调,将当前组件实例传递给父组件
this.onRef(this)
// 防抖函数,避免频繁调用
let debounceTimer = null
// 监听差异更新事件
editor.onDidUpdateDiff(() => {
// 清除之前的定时器
if (debounceTimer) {
clearTimeout(debounceTimer)
}
// 设置新的定时器,延迟执行
debounceTimer = setTimeout(() => {
this.applyCustomDiffDecorations()
}, 100) // 100ms 防抖
})
}
- 基于monaco的[deltaDecorations]实现行装饰器,
stats就是新增、删除、差异的数据统计
// 应用自定义diff装饰并计算差异统计
applyCustomDiffDecorations() {
if (!this.diffEditor || !this.monaco) return
const lineChanges = this.diffEditor.getLineChanges()
if (!lineChanges || lineChanges.length === 0) {
// 清除之前的装饰
if (this.originalDecorationIds) {
this.diffEditor
.getOriginalEditor()
.deltaDecorations(this.originalDecorationIds, [])
}
if (this.modifiedDecorationIds) {
this.diffEditor
.getModifiedEditor()
.deltaDecorations(this.modifiedDecorationIds, [])
}
// 重置差异统计
this.updateDiffStatsIfChanged({
additions: 0,
deletions: 0,
modifications: 0,
})
return
}
const originalEditor = this.diffEditor.getOriginalEditor()
const modifiedEditor = this.diffEditor.getModifiedEditor()
const originalDecorations = []
const modifiedDecorations = []
// 差异统计
const stats = {
additions: 0,
deletions: 0,
modifications: 0,
}
// 使用Map来记录每一行的变更类型,避免重复处理
const allOriginalLineTypes = new Map() // 左侧编辑器行类型
const allModifiedLineTypes = new Map() // 右侧编辑器行类型
lineChanges.forEach((change) => {
const originalStartLine = change.originalStartLineNumber
const originalEndLine = change.originalEndLineNumber
const modifiedStartLine = change.modifiedStartLineNumber
const modifiedEndLine = change.modifiedEndLineNumber
// 当前变更的行类型
const originalLineTypes = new Map() // 左侧编辑器行类型
const modifiedLineTypes = new Map() // 右侧编辑器行类型
// 根据用户提供的规则进行判断
if (originalEndLine === 0 && modifiedEndLine > 0) {
for (let i = modifiedStartLine; i <= modifiedEndLine; i++) {
modifiedLineTypes.set(i, 'added')
}
} else if (originalEndLine > 0 && modifiedEndLine === 0) {
for (let i = originalStartLine; i <= originalEndLine; i++) {
originalLineTypes.set(i, 'deleted')
}
} else if (originalEndLine > 0 && modifiedEndLine > 0) {
// 规则3: 两边都有行号,需要根据行数差异判断
const originalLines = originalEndLine - originalStartLine + 1
const modifiedLines = modifiedEndLine - modifiedStartLine + 1
if (originalLines === modifiedLines) {
// 行数相同,全部标记为修改
for (let i = originalStartLine; i <= originalEndLine; i++) {
originalLineTypes.set(i, 'modified')
}
for (let i = modifiedStartLine; i <= modifiedEndLine; i++) {
modifiedLineTypes.set(i, 'modified')
}
} else {
// 行数不同,按照用户规则处理
const minLines = Math.min(originalLines, modifiedLines)
if (originalLines > modifiedLines) {
// 左侧行数更多:对应行标记为修改,多出的左侧行标记为删除
for (let i = 0; i < minLines; i++) {
originalLineTypes.set(originalStartLine + i, 'modified')
modifiedLineTypes.set(modifiedStartLine + i, 'modified')
}
// 多出的左侧行标记为删除
for (let i = minLines; i < originalLines; i++) {
originalLineTypes.set(originalStartLine + i, 'deleted')
}
} else {
for (let i = 0; i < minLines; i++) {
originalLineTypes.set(originalStartLine + i, 'modified')
modifiedLineTypes.set(modifiedStartLine + i, 'modified')
}
// 多出的右侧行标记为新增
for (let i = minLines; i < modifiedLines; i++) {
modifiedLineTypes.set(modifiedStartLine + i, 'added')
}
}
}
}
// 统计各类型行数
const addedCount = Array.from(modifiedLineTypes.values()).filter(
(type) => type === 'added',
).length
const deletedCount = Array.from(originalLineTypes.values()).filter(
(type) => type === 'deleted',
).length
const modifiedCount = Math.max(
Array.from(originalLineTypes.values()).filter(
(type) => type === 'modified',
).length,
Array.from(modifiedLineTypes.values()).filter(
(type) => type === 'modified',
).length,
)
stats.additions += addedCount
stats.deletions += deletedCount
stats.modifications += modifiedCount
// 将当前变更的行类型合并到全局Map中
originalLineTypes.forEach((type, lineNumber) => {
allOriginalLineTypes.set(lineNumber, type)
})
modifiedLineTypes.forEach((type, lineNumber) => {
allModifiedLineTypes.set(lineNumber, type)
})
// 根据行类型添加装饰器
// 处理左侧编辑器
originalLineTypes.forEach((type, lineNumber) => {
if (type === 'deleted') {
// 删除行 - 添加红色背景装饰
originalDecorations.push({
range: new this.monaco.Range(lineNumber, 1, lineNumber, 1),
options: {
isWholeLine: true,
className: 'diff-line-deleted',
},
})
} else if (type === 'modified') {
// 修改行 - 添加橙色背景
originalDecorations.push({
range: new this.monaco.Range(lineNumber, 1, lineNumber, 1),
options: {
isWholeLine: true,
className: 'diff-line-modified',
},
})
}
})
// 处理右侧编辑器
modifiedLineTypes.forEach((type, lineNumber) => {
if (type === 'added') {
// 新增行 - 添加绿色背景装饰
modifiedDecorations.push({
range: new this.monaco.Range(lineNumber, 1, lineNumber, 1),
options: {
isWholeLine: true,
className: 'diff-line-added',
},
})
} else if (type === 'modified') {
// 修改行 - 添加橙色背景
modifiedDecorations.push({
range: new this.monaco.Range(lineNumber, 1, lineNumber, 1),
options: {
isWholeLine: true,
className: 'diff-line-modified',
},
})
}
})
})
// 更新差异统计
this.updateDiffStatsIfChanged(stats)
// 应用装饰并保存装饰ID以便后续清理
this.originalDecorationIds = originalEditor.deltaDecorations(
this.originalDecorationIds || [],
originalDecorations,
)
this.modifiedDecorationIds = modifiedEditor.deltaDecorations(
this.modifiedDecorationIds || [],
modifiedDecorations,
)
}
总结
这一节主要讲解了monaco的DiffEditor实现配置文件对比。在这一章我们也初步学习了Monaco的行装饰器的使用,其实编辑器的debugger模式,先基于DAP协议获取到当前debugger的堆栈聚焦行,然后我们在通过行装饰器绘制对应的高亮行。至于堆栈信息只需要绘制对应的堆栈面板接口,是不是感觉就特别清晰了
为什么写这篇文章呢?
- 是因为我没有找到相关文章,其他文章都是直接实现
DiffEditor效果,并不满足需要的三种状态新增、删除、差异。 - 在研发任务排期紧张的时候帮助遇到相同需求的小伙伴减少工作压力,哈哈哈。
《uni-app跨平台开发完全指南》- 03 - Vue.js基础入门
Vue.js 基础
本系列是《uni-app跨平台开发完全指南》系列教程,旨在帮助开发者从零开始掌握uni-app开发。本章将深入讲解Vue.js的核心概念,为你后续的uni-app开发打下坚实基础。
为什么学习Vue.js对uni-app开发如此重要?
很多初学者可能会问:"我直接学uni-app不行吗?为什么要先学Vue.js?"
这里有个很重要的概念需要理解:uni-app的本质是基于Vue.js的跨端实现框架。更形象一点,如果说uni-app是整车制造,那么Vue.js就属于发动机。如果你不懂发动机原理,虽然也能开车,但一旦出现故障,就束手无策了。同样,不掌握Vue.js基础,在uni-app开发中遇到复杂问题时,你会很难找到根本解决方案。
一、Vue.js 简介与开发环境搭建
1.1 Vue.js 是什么?
简单来说,Vue.js是一个用于构建用户界面的渐进式JavaScript框架。所谓"渐进式",意味着你可以根据项目需求,逐步采用Vue.js的特性:
- 可以在老项目中局部使用Vue.js增强交互
- 也可以使用Vue.js全家桶开发完整的前端应用
- 还可以用Vue.js开发原生移动应用(如uni-app)
1.2 环境准备:第一个Vue应用
让我们从最简单的HTML页面开始:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的第一个Vue应用</title>
<!-- 引入Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
</head>
<body>
<!-- Vue实例挂载点 -->
<div id="app">
<h1>{{ message }}</h1>
<button @click="reverseMessage">反转消息</button>
</div>
<script>
// 创建Vue实例
var app = new Vue({
el: '#app', // 指定挂载元素
data: { // 定义数据
message: 'Hello Vue!'
},
methods: { // 定义方法
reverseMessage: function() {
this.message = this.message.split('').reverse().join('');
}
}
});
</script>
</body>
</html>
代码解析:
-
el: '#app':告诉Vue这个实例要控制页面中id为app的元素 -
data:定义这个Vue实例的数据,可以在模板中使用 -
{{ message }}:模板语法,将data中的message值渲染到页面 -
@click:事件绑定,点击时执行reverseMessage方法
二、Vue 核心概念
2.1 数据绑定
数据绑定是Vue最核心的特性之一,它建立了数据与DOM之间的自动同步关系。
2.1.1 文本插值:{{ }}
<div id="app">
<!-- 基本文本插值 -->
<p>消息:{{ message }}</p>
<!-- JS表达式 -->
<p>计算:{{ number + 1 }}</p>
<p>三元表达式:{{ isActive ? '激活' : '未激活' }}</p>
<p>反转:{{ message.split('').reverse().join('') }}</p>
</div>
<script>
new Vue({
el: '#app',
data: {
message: 'Hello Vue!',
number: 10,
isActive: true
}
});
</script>
重要提示:{{ }}中支持JavaScript表达式,但不支持语句(如if、for等)。
2.1.2 属性绑定:v-bind
<div id="app">
<!-- 绑定HTML属性 -->
<div v-bind:title="tooltip">鼠标悬停查看提示</div>
<!-- 绑定CSS类 -->
<div v-bind:class="{ active: isActive, 'text-danger': hasError }">
动态类名
</div>
<!-- 绑定样式 -->
<div v-bind:style="{ color: activeColor, fontSize: fontSize + 'px' }">
动态样式
</div>
<!-- 简写 -->
<img :src="imageSrc" :alt="imageAlt">
</div>
<script>
new Vue({
el: '#app',
data: {
tooltip: '这是一个提示信息',
isActive: true,
hasError: false,
activeColor: 'red',
fontSize: 20,
imageSrc: 'path/to/image.jpg',
imageAlt: '示例图片'
}
});
</script>
v-bind原理:当数据变化时,Vue会自动更新对应的DOM属性。
2.2 指令系统
指令是带有v-前缀的特殊属性,它们为HTML添加了动态行为。
2.2.1 条件渲染:v-if vs v-show
<div id="app">
<!-- v-if:条件性地渲染一块内容 -->
<p v-if="score >= 90">优秀!</p>
<p v-else-if="score >= 60">及格</p>
<p v-else>不及格</p>
<!-- v-show:总是渲染,只是切换display -->
<p v-show="isVisible">这个元素会显示/隐藏</p>
<button @click="toggle">切换显示</button>
<button @click="changeScore">改变分数</button>
</div>
<script>
new Vue({
el: '#app',
data: {
score: 85,
isVisible: true
},
methods: {
toggle: function() {
this.isVisible = !this.isVisible;
},
changeScore: function() {
this.score = Math.floor(Math.random() * 100);
}
}
});
</script>
v-if 与 v-show 的区别:
| 特性 | v-if | v-show |
|---|---|---|
| 渲染方式 | 条件为false时不渲染DOM元素 | 总是渲染,只是切换display属性 |
| 切换开销 | 更高的切换开销(创建/销毁组件) | 更高的初始渲染开销 |
| 适用场景 | 运行时条件很少改变 | 需要非常频繁地切换 |
2.2.2 列表渲染:v-for
<div id="app">
<!-- 遍历数组 -->
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }} - ¥{{ item.price }}
</li>
</ul>
<!-- 遍历对象 -->
<ul>
<li v-for="(value, key) in userInfo" :key="key">
{{ key }}: {{ value }}
</li>
</ul>
<!-- 遍历数字范围 -->
<span v-for="n in 5" :key="n">{{ n }} </span>
</div>
<script>
new Vue({
el: '#app',
data: {
items: [
{ id: 1, name: '苹果', price: 5 },
{ id: 2, name: '香蕉', price: 3 },
{ id: 3, name: '橙子', price: 4 }
],
userInfo: {
name: '张三',
age: 25,
city: '北京'
}
}
});
</script>
关键点:
- :key的重要性:为每个节点提供唯一标识,优化列表渲染性能
- 可以使用
(item, index)或(value, key, index)语法
2.2.3 事件处理:v-on
<div id="app">
<!-- 基本事件处理 -->
<button v-on:click="counter += 1">点击次数: {{ counter }}</button>
<!-- 方法事件处理器 -->
<button @click="sayHello">打招呼</button>
<!-- 内联处理器中的方法 -->
<button @click="say('Hello', $event)">带参数的事件</button>
<!-- 事件修饰符 -->
<form @submit.prevent="onSubmit">
<input type="text">
<button type="submit">提交</button>
</form>
<!-- 按键修饰符 -->
<input @keyup.enter="onEnter" placeholder="按回车键触发">
</div>
<script>
new Vue({
el: '#app',
data: {
counter: 0
},
methods: {
sayHello: function(event) {
alert('Hello!');
console.log(event); // 原生事件对象
},
say: function(message, event) {
alert(message);
if (event) {
event.preventDefault();
}
},
onSubmit: function() {
alert('表单提交被阻止了!');
},
onEnter: function() {
alert('你按了回车键!');
}
}
});
</script>
常用事件修饰符:
-
.stop:阻止事件冒泡 -
.prevent:阻止默认行为 -
.capture:使用事件捕获模式 -
.self:只当事件是从侦听器绑定的元素本身触发时才触发回调 -
.once:只触发一次 -
.passive:告诉浏览器你不想阻止事件的默认行为
2.2.4 双向数据绑定:v-model
<div id="app">
<!-- 文本输入 -->
<input v-model="message" placeholder="编辑我">
<p>消息是: {{ message }}</p>
<!-- 多行文本 -->
<textarea v-model="multilineText"></textarea>
<p style="white-space: pre-line;">{{ multilineText }}</p>
<!-- 复选框 -->
<input type="checkbox" id="checkbox" v-model="checked">
<label for="checkbox">{{ checked ? '已选中' : '未选中' }}</label>
<!-- 多个复选框 -->
<div>
<input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
<label for="jack">Jack</label>
<input type="checkbox" id="john" value="John" v-model="checkedNames">
<label for="john">John</label>
<input type="checkbox" id="mike" value="Mike" v-model="checkedNames">
<label for="mike">Mike</label>
<br>
<span>选中的名字: {{ checkedNames }}</span>
</div>
<!-- 单选按钮 -->
<div>
<input type="radio" id="one" value="One" v-model="picked">
<label for="one">One</label>
<input type="radio" id="two" value="Two" v-model="picked">
<label for="two">Two</label>
<br>
<span>选中的值: {{ picked }}</span>
</div>
<!-- 选择框 -->
<select v-model="selected">
<option disabled value="">请选择</option>
<option>A</option>
<option>B</option>
<option>C</option>
</select>
<span>选中的值: {{ selected }}</span>
</div>
<script>
new Vue({
el: '#app',
data: {
message: '',
multilineText: '',
checked: false,
checkedNames: [],
picked: '',
selected: ''
}
});
</script>
v-model原理:本质上是语法糖,它负责监听用户的输入事件以更新数据。
// v-model 相当于:
<input
:value="message"
@input="message = $event.target.value">
2.3 计算属性与监听器
2.3.1 计算属性:computed
<div id="app">
<input v-model="firstName" placeholder="姓">
<input v-model="lastName" placeholder="名">
<!-- 使用计算属性 -->
<p>全名(计算属性): {{ fullName }}</p>
<!-- 使用方法 -->
<p>全名(方法): {{ getFullName() }}</p>
<!-- 示例代码 -->
<div>
<h3>购物车</h3>
<div v-for="item in cart" :key="item.id">
{{ item.name }} - ¥{{ item.price }} × {{ item.quantity }}
</div>
<p>总价: {{ totalPrice }}</p>
<p>打折后: {{ discountedTotal }}</p>
</div>
</div>
<script>
new Vue({
el: '#app',
data: {
firstName: '张',
lastName: '三',
cart: [
{ id: 1, name: '商品A', price: 100, quantity: 2 },
{ id: 2, name: '商品B', price: 200, quantity: 1 }
],
discount: 0.8 // 8折
},
computed: {
// 计算属性:基于依赖进行缓存
fullName: function() {
console.log('计算属性 fullName 被调用了');
return this.firstName + ' ' + this.lastName;
},
// 计算总价
totalPrice: function() {
return this.cart.reduce((total, item) => {
return total + (item.price * item.quantity);
}, 0);
},
// 基于其他计算属性的计算属性
discountedTotal: function() {
return this.totalPrice * this.discount;
}
},
methods: {
// 方法:每次重新渲染都会调用
getFullName: function() {
console.log('方法 getFullName 被调用了');
return this.firstName + ' ' + this.lastName;
}
}
});
</script>
计算属性的依赖追踪流程:
graph TD
A[访问计算属性] --> B{脏数据?}
B -->|是| C[重新计算值]
B -->|否| D[返回缓存值]
C --> E[标记为干净数据]
E --> D
F[依赖数据变化] --> G[标记为脏数据]
G --> A
计算属性特点:
- 基于它们的响应式依赖进行缓存
- 只在相关响应式依赖发生改变时才会重新求值
- 多次访问计算属性会立即返回之前的计算结果,而不必再次执行函数
2.3.2 监听器:watch
<div id="app">
<input v-model="question" placeholder="输入问题">
<p>答案: {{ answer }}</p>
<!-- 示例 -->
<input v-model="user.name" placeholder="用户名">
<input v-model="user.age" type="number" placeholder="年龄">
<p>用户信息变化次数: {{ changeCount }}</p>
</div>
<script>
new Vue({
el: '#app',
data: {
question: '',
answer: '我无法给你答案直到你提问!',
user: {
name: '',
age: 0
},
changeCount: 0
},
watch: {
// 简单监听:question发生变化时执行
question: function(newQuestion, oldQuestion) {
this.answer = '等待你停止输入...';
this.getAnswer();
},
// 深度监听:对象内部属性的变化
user: {
handler: function(newVal, oldVal) {
this.changeCount++;
console.log('用户信息发生变化:', newVal);
},
deep: true,
immediate: true
}
},
methods: {
getAnswer: function() {
// 模拟异步操作
setTimeout(() => {
this.answer = '这是对你问题的回答';
}, 1000);
}
}
});
</script>
计算属性 vs 监听器:
| 场景 | 使用计算属性 | 使用监听器 |
|---|---|---|
| 数据派生 | 适用于现有数据计算新数据 | 不适用 |
| 异步操作 | 不支持异步 | 支持异步 |
| 性能优化 | 自动缓存 | 无缓存 |
| 复杂逻辑 | 声明式 | 命令式 |
三、组件化开发
组件化就像搭积木一样,把复杂的界面拆分成独立、可复用的部分。
3.1 组件注册与使用
3.1.1 全局组件
<div id="app">
<!-- 使用全局组件 -->
<my-button></my-button>
<user-card
name="张三"
:age="25"
avatar="path/to/avatar.jpg">
</user-card>
</div>
<script>
// 全局组件注册
Vue.component('my-button', {
template: `
<button class="my-btn" @click="onClick">
<slot>默认按钮</slot>
</button>
`,
methods: {
onClick: function() {
this.$emit('btn-click'); // 触发自定义事件
}
}
});
// 另一个全局组件
Vue.component('user-card', {
props: ['name', 'age', 'avatar'], // 定义组件属性
template: `
<div class="user-card">
<img :src="avatar" :alt="name" class="avatar">
<div class="info">
<h3>{{ name }}</h3>
<p>年龄: {{ age }}</p>
</div>
</div>
`
});
new Vue({
el: '#app'
});
</script>
<style>
.my-btn {
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.user-card {
border: 1px solid #ddd;
padding: 15px;
margin: 10px 0;
border-radius: 8px;
display: flex;
align-items: center;
}
.avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 15px;
}
.info h3 {
margin: 0 0 5px 0;
}
</style>
3.1.2 局部组件
<div id="app">
<product-list></product-list>
</div>
<script>
// 定义局部组件
var ProductList = {
template: `
<div class="product-list">
<h2>商品列表</h2>
<product-item
v-for="product in products"
:key="product.id"
:product="product"
@add-to-cart="onAddToCart">
</product-item>
</div>
`,
data: function() {
return {
products: [
{ id: 1, name: 'iPhone', price: 5999, stock: 10 },
{ id: 2, name: 'MacBook', price: 9999, stock: 5 },
{ id: 3, name: 'iPad', price: 3299, stock: 8 }
]
};
},
methods: {
onAddToCart: function(product) {
console.log('添加到购物车:', product.name);
// 这里可以调用Vuex或触发全局事件
}
}
};
// 子组件
var ProductItem = {
props: ['product'],
template: `
<div class="product-item">
<h3>{{ product.name }}</h3>
<p>价格: ¥{{ product.price }}</p>
<p>库存: {{ product.stock }}</p>
<button
@click="addToCart"
:disabled="product.stock === 0">
{{ product.stock === 0 ? '缺货' : '加入购物车' }}
</button>
</div>
`,
methods: {
addToCart: function() {
this.$emit('add-to-cart', this.product);
}
}
};
new Vue({
el: '#app',
components: {
'product-list': ProductList,
'product-item': ProductItem
}
});
</script>
<style>
.product-list {
max-width: 600px;
margin: 0 auto;
}
.product-item {
border: 1px solid #eee;
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
.product-item h3 {
color: #333;
margin-top: 0;
}
</style>
3.2 组件通信
组件通信是组件化开发的核心,Vue提供了多种通信方式,用一张图来看下:
graph TB
A[组件通信] --> B[父子通信]
A --> C[兄弟通信]
A --> D[跨级通信]
A --> E[全局通信]
B --> B1[Props Down]
B --> B2[Events Up]
B --> B3[v-model]
B --> B4[refs]
C --> C1[Event Bus]
C --> C2[共同父级]
D --> D1[Provide/Inject]
D --> D2[attrs/listeners]
E --> E1[Vuex]
E --> E2[全局事件]
下面结合一段具体代码示例,带大家了解下组件间是如何通信的:
<div id="app">
<h2>组件通信示例</h2>
<!-- 1. 父子组件 -->
<parent-component></parent-component>
<!-- 2. 事件总线 -->
<component-a></component-a>
<component-b></component-b>
</div>
<script>
// 事件总线(用于非父子组件通信)
var eventBus = new Vue();
// 组件A
Vue.component('component-a', {
template: `
<div class="component">
<h3>组件A</h3>
<button @click="sendMessage">发送消息给组件B</button>
</div>
`,
methods: {
sendMessage: function() {
eventBus.$emit('message-from-a', '你好,这是来自组件A的消息!');
}
}
});
// 组件B
Vue.component('component-b', {
template: `
<div class="component">
<h3>组件B</h3>
<p>收到消息: {{ receivedMessage }}</p>
</div>
`,
data: function() {
return {
receivedMessage: ''
};
},
mounted: function() {
var self = this;
eventBus.$on('message-from-a', function(message) {
self.receivedMessage = message;
});
}
});
// 父组件
Vue.component('parent-component', {
template: `
<div class="parent">
<h3>父组件</h3>
<p>父组件数据: {{ parentData }}</p>
<!-- 父传子:通过props -->
<child-component
:message="parentData"
@child-event="onChildEvent">
</child-component>
<!-- 子传父:通过自定义事件 -->
<p>子组件消息: {{ childMessage }}</p>
</div>
`,
data: function() {
return {
parentData: '来自父组件的数据',
childMessage: ''
};
},
methods: {
onChildEvent: function(message) {
this.childMessage = message;
}
}
});
// 子组件
Vue.component('child-component', {
props: ['message'], // 接收父组件数据
template: `
<div class="child">
<h4>子组件</h4>
<p>收到父组件的消息: {{ message }}</p>
<button @click="sendToParent">发送消息给父组件</button>
</div>
`,
methods: {
sendToParent: function() {
this.$emit('child-event', '来自子组件的问候!');
}
}
});
new Vue({
el: '#app'
});
</script>
<style>
.component, .parent, .child {
border: 1px solid #ccc;
padding: 15px;
margin: 10px;
border-radius: 5px;
}
.parent {
background: #f0f8ff;
}
.child {
background: #f9f9f9;
margin-left: 30px;
}
</style>
四、生命周期函数
4.1 生命周期
Vue实例有一个完整的生命周期,包括创建、挂载、更新、销毁等阶段。每个阶段都提供了相应的生命周期钩子,让我们可以在特定阶段执行自定义逻辑。
sequenceDiagram
participant P as Parent Component
participant C as Child Component
participant VD as Virtual DOM
participant RD as Real DOM
Note over P: 1. 父组件创建
P->>C: 2. 创建子组件实例
Note over C: 3. beforeCreate
Note over C: 4. 初始化注入
Note over C: 5. created
C->>VD: 6. 编译模板为渲染函数
Note over C: 7. beforeMount
C->>RD: 8. 创建$el并挂载
Note over C: 9. mounted
Note over C: 10. 等待数据变化
C->>C: 11. 数据变化
Note over C: 12. beforeUpdate
C->>VD: 13. 重新渲染
VD->>RD: 14. 打补丁
Note over C: 15. updated
P->>C: 16. 销毁子组件
Note over C: 17. beforeDestroy
C->>C: 18. 清理工作
Note over C: 19. destroyed
其实生命周期钩子函数不用刻意去记忆,实在不知道直接控制台打印看日志结果就行了,当然能记住最好~~~
4.2 生命周期钩子
<div id="app">
<h2>用计时器来演示生命周期狗子函数</h2>
<p>计数器: {{ count }}</p>
<button @click="count++">增加</button>
<button @click="destroy">销毁实例</button>
<div v-if="showChild">
<lifecycle-demo :count="count"></lifecycle-demo>
</div>
<button @click="showChild = !showChild">切换子组件</button>
</div>
<script>
Vue.component('lifecycle-demo', {
props: ['count'],
template: `
<div class="lifecycle-demo">
<h3>子组件 - 计数: {{ count }}</h3>
<p>生命周期调用记录:</p>
<ul>
<li v-for="log in logs" :key="log.id">{{ log.message }}</li>
</ul>
</div>
`,
data: function() {
return {
logs: [],
logId: 0
};
},
// 生命周期钩子
beforeCreate: function() {
this.addLog('beforeCreate: 实例刚被创建,data和methods还未初始化');
},
created: function() {
this.addLog('created: 实例创建完成,data和methods已初始化');
// 这里可以调用API获取初始数据
this.fetchData();
},
beforeMount: function() {
this.addLog('beforeMount: 模板编译完成,但尚未挂载到页面');
},
mounted: function() {
this.addLog('mounted: 实例已挂载到DOM元素,可以访问$el');
// 这里可以操作DOM或初始化第三方库
this.initializeThirdPartyLib();
},
beforeUpdate: function() {
this.addLog('beforeUpdate: 数据更新前,虚拟DOM重新渲染之前');
},
updated: function() {
this.addLog('updated: 数据更新完成,DOM已重新渲染');
// 这里可以执行依赖于DOM更新的操作
},
beforeDestroy: function() {
this.addLog('beforeDestroy: 实例销毁前,此时实例仍然完全可用');
// 这里可以清理定时器、取消订阅等
this.cleanup();
},
destroyed: function() {
// 注意:在destroyed钩子中无法添加日志,因为组件已销毁
console.log('destroyed: 实例已销毁,所有绑定和监听器已被移除');
},
methods: {
addLog: function(message) {
this.logs.push({
id: this.logId++,
message: message + ' - ' + new Date().toLocaleTimeString()
});
},
fetchData: function() {
// 模拟接口请求
setTimeout(() => {
this.addLog('数据获取完成');
}, 100);
},
initializeThirdPartyLib: function() {
this.addLog('三方库初始化完成');
},
cleanup: function() {
this.addLog('清理工作完成');
}
}
});
new Vue({
el: '#app',
data: {
count: 0,
showChild: true
},
methods: {
destroy: function() {
this.$destroy();
alert('Vue实例被销毁');
}
}
});
</script>
<style>
.lifecycle-demo {
border: 2px solid #4CAF50;
padding: 15px;
margin: 10px 0;
background: #f9fff9;
}
.lifecycle-demo ul {
max-height: 200px;
overflow-y: auto;
background: white;
padding: 10px;
border: 1px solid #ddd;
}
</style>
4.3 生命周期使用场景总结
| 生命周期钩子 | 常见使用场景 |
|---|---|
| created | - API数据请求 - 事件监听器初始化 - 定时器设置 |
| mounted | - DOM操作 - 三方库初始化(如图表库) - 插件初始化 |
| updated | - DOM依赖的操作 - 基于新状态的操作 |
| beforeDestroy | - 清除定时器 - 取消事件监听 - 清理三方库实例 |
五、响应式原理
响应式就是当数据发生变化时,视图会自动更新。这听起来很简单,但底层原理有着巧妙的设计。
5.1 原理
Vue的响应式系统基于三个核心概念:
5.1.1 数据劫持(Object.defineProperty)
// 简化的响应式原理
function defineReactive(obj, key, val) {
// 递归处理嵌套对象
observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function() {
console.log(`读取 ${key}: ${val}`);
// 这里会进行依赖收集
return val;
},
set: function(newVal) {
if (newVal === val) return;
console.log(`设置 ${key}: ${newVal}`);
val = newVal;
// 这里会通知依赖更新
updateView();
}
});
}
function observe(obj) {
if (!obj || typeof obj !== 'object') return;
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key]);
});
}
// 测试
const data = { message: 'Hello', count: 0 };
observe(data);
// 现在data是响应式的
data.message = 'Hello Vue!'; // 控制台会输出:设置 message: Hello Vue!
console.log(data.message); // 控制台会输出:读取 message: Hello Vue!
5.1.2 依赖收集与派发更新
Vue的响应式系统实际更为复杂,包含依赖收集和派发更新机制:
// 简化的Dep(依赖)类
class Dep {
constructor() {
this.subscribers = new Set();
}
depend() {
if (activeUpdate) {
this.subscribers.add(activeUpdate);
}
}
notify() {
this.subscribers.forEach(sub => sub());
}
}
let activeUpdate = null;
function autorun(update) {
function wrappedUpdate() {
activeUpdate = wrappedUpdate;
update();
activeUpdate = null;
}
wrappedUpdate();
}
// 使用示例
const dep = new Dep();
autorun(() => {
dep.depend();
console.log('更新视图');
});
// 当数据变化时
dep.notify(); // 输出:更新视图
5.2 注意事项
5.2.1 数组更新检测
<div id="app">
<h3>数组响应式注意事项</h3>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
<button @click="addItem">添加项目successed</button>
<button @click="addItemWrong">添加项目error</button>
<button @click="changeItemProperty">修改项目属性</button>
</div>
<script>
new Vue({
el: '#app',
data: {
items: [
{ id: 1, name: '项目1' },
{ id: 2, name: '项目2' }
]
},
methods: {
// 推荐使用数组变异方法
addItem: function() {
this.items.push({
id: this.items.length + 1,
name: '项目' + (this.items.length + 1)
});
},
// 不推荐直接通过索引设置
addItemWrong: function() {
// 这种方式不会触发视图更新!
this.items[this.items.length] = {
id: this.items.length + 1,
name: '项目' + (this.items.length + 1)
};
console.log('数组已修改,但视图不会更新');
},
// 对象属性的响应式
changeItemProperty: function() {
// Vue.set 或 this.$set 确保响应式
this.$set(this.items[0], 'newProperty', '新属性值');
}
}
});
</script>
5.2.2 响应式API
// 响应式API
new Vue({
data: {
user: {
name: '张三'
},
list: [1, 2, 3]
},
created() {
// 添加响应式属性
this.$set(this.user, 'age', 25);
// 删除响应式属性
this.$delete(this.user, 'name');
// 数组响应式方法
this.list = this.$set(this.list, 0, 100); // 替换第一个元素
// 或者使用Vue.set全局方法
Vue.set(this.list, 1, 200);
}
});
六、项目实战:TodoList应用
用一个完整的TodoList应用来综合运用以上所学知识:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue.js TodoList应用</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.todo-app {
max-width: 500px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 20px;
text-align: center;
}
.header h1 {
margin-bottom: 10px;
font-size: 2.5em;
}
.input-section {
padding: 20px;
border-bottom: 1px solid #eee;
}
.todo-input {
width: 100%;
padding: 15px;
border: 2px solid #e1e1e1;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.todo-input:focus {
outline: none;
border-color: #667eea;
}
.add-btn {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
margin-top: 10px;
transition: transform 0.2s;
}
.add-btn:hover {
transform: translateY(-2px);
}
.filters {
display: flex;
padding: 15px 20px;
border-bottom: 1px solid #eee;
}
.filter-btn {
flex: 1;
padding: 10px;
background: none;
border: none;
cursor: pointer;
transition: all 0.3s;
border-radius: 5px;
margin: 0 5px;
}
.filter-btn.active {
background: #667eea;
color: white;
}
.todo-list {
max-height: 400px;
overflow-y: auto;
}
.todo-item {
display: flex;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #f1f1f1;
transition: background-color 0.3s;
}
.todo-item:hover {
background-color: #f9f9f9;
}
.todo-item.completed {
opacity: 0.6;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
}
.todo-checkbox {
margin-right: 15px;
transform: scale(1.2);
}
.todo-text {
flex: 1;
font-size: 16px;
}
.delete-btn {
background: #ff4757;
color: white;
border: none;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
}
.delete-btn:hover {
background: #ff3742;
}
.stats {
padding: 15px 20px;
text-align: center;
color: #666;
border-top: 1px solid #eee;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
}
</style>
</head>
<body>
<div id="app">
<div class="todo-app">
<!-- 头部 -->
<div class="header">
<h1>TodoList</h1>
<p>Vue.js应用</p>
</div>
<div class="input-section">
<input
v-model="newTodo"
@keyup.enter="addTodo"
placeholder="添加新任务..."
class="todo-input">
<button @click="addTodo" class="add-btn">
添加任务
</button>
</div>
<!-- 过滤器 -->
<div class="filters">
<button
@click="filter = 'all'"
:class="['filter-btn', { active: filter === 'all' }]">
全部 ({{ totalTodos }})
</button>
<button
@click="filter = 'active'"
:class="['filter-btn', { active: filter === 'active' }]">
待完成 ({{ activeTodos }})
</button>
<button
@click="filter = 'completed'"
:class="['filter-btn', { active: filter === 'completed' }]">
已完成 ({{ completedTodos }})
</button>
</div>
<!-- Todo列表 -->
<div class="todo-list">
<div v-if="filteredTodos.length === 0" class="empty-state">
{{ emptyMessage }}
</div>
<div
v-for="todo in filteredTodos"
:key="todo.id"
:class="['todo-item', { completed: todo.completed }]">
<input
type="checkbox"
v-model="todo.completed"
class="todo-checkbox">
<span class="todo-text">{{ todo.text }}</span>
<button
@click="removeTodo(todo.id)"
class="delete-btn">
删除
</button>
</div>
</div>
<!-- 统计信息 -->
<div class="stats">
<span v-if="totalTodos > 0">
进度: {{ completionRate }}% ({{ completedTodos }}/{{ totalTodos }})
</span>
<span v-else>还没有任务,添加一个吧!</span>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: {
newTodo: '', // 新任务输入
todos: [], // 任务列表
filter: 'all', // 当前过滤器
nextId: 1 // 下一个任务ID
},
// 计算属性
computed: {
// 总任务数
totalTodos() {
return this.todos.length;
},
// 活跃任务数
activeTodos() {
return this.todos.filter(todo => !todo.completed).length;
},
// 已完成任务数
completedTodos() {
return this.todos.filter(todo => todo.completed).length;
},
// 完成率
completionRate() {
if (this.totalTodos === 0) return 0;
return Math.round((this.completedTodos / this.totalTodos) * 100);
},
// 过滤后的任务列表
filteredTodos() {
switch (this.filter) {
case 'active':
return this.todos.filter(todo => !todo.completed);
case 'completed':
return this.todos.filter(todo => todo.completed);
default:
return this.todos;
}
},
// 空状态消息
emptyMessage() {
switch (this.filter) {
case 'active':
return '没有待完成的任务!';
case 'completed':
return '还没有完成的任务!';
default:
return '还没有任务,添加一个吧!';
}
}
},
methods: {
// 添加新任务
addTodo() {
if (this.newTodo.trim() === '') return;
this.todos.push({
id: this.nextId++,
text: this.newTodo.trim(),
completed: false,
createdAt: new Date()
});
this.newTodo = '';
},
// 删除任务
removeTodo(id) {
this.todos = this.todos.filter(todo => todo.id !== id);
}
},
// 生命周期钩子
created() {
console.log('TodoList应用已创建');
// 加载本地存储的数据。。。
},
mounted() {
console.log('TodoList应用已挂载');
}
});
</script>
</body>
</html>
这个TodoList应用综合运用了:v-model、@click、@keyup、v-if、v-for、computed、:class、生命周期钩子
七、总结
7.1 核心概念
- 数据驱动:Vue的核心思想,数据变化自动更新视图
- 指令系统:v-bind, v-model, v-for, v-if等指令的强大功能
- 组件化:将UI拆分为独立可复用的组件
- 生命周期:理解组件从创建到销毁的完整过程
- 响应式原理:理解数据变化的侦测机制
7.2 组件设计原则
// 好的组件设计
Vue.component('user-profile', {
props: {
user: {
type: Object,
required: true,
validator: function(value) {
return value.name && value.email;
}
}
},
template: `
<div class="user-profile">
<img :src="user.avatar" :alt="user.name">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
`
});
// 不好的组件设计(props验证不足,模板混乱)
Vue.component('bad-component', {
props: ['user'],
template: '<div>...</div>' // 模板过长,难以维护
});
7.3 状态管理建议
// 对于复杂应用,考虑使用Vuex
// 对于简单应用,合理组织组件间通信
// 好的状态组织
new Vue({
data: {
// 相关状态分组
user: {
profile: {},
preferences: {}
},
ui: {
loading: false,
sidebarOpen: true
}
}
});
7.4 常见问题
| 常见问题 | 错误做法 | 正确做法 |
|---|---|---|
| 数组更新 | this.items[0] = newValue |
this.$set(this.items, 0, newValue) |
| 对象属性 | this.obj.newProp = value |
this.$set(this.obj, 'newProp', value) |
| 异步更新 | 直接操作DOM | 使用this.$nextTick()
|
| 事件监听 | 不清理事件监听器 | 在beforeDestroy中清理 |
结语
至此Vue.js基础就学习完了,想要掌握更多的Vue.js知识可去官网深入学习,掌握好Vue.js,uni-app学习就会事半功倍。
如果觉得本文对你有帮助,请一键三连(点赞、关注、收藏)支持一下!有任何问题欢迎在评论区留言讨论。
在 Vue 3 项目中使用 MQTT 获取数据
在 Vue 3 项目中使用 MQTT 获取数据,需通过 MQTT.js 库实现与 MQTT 服务器的连接、订阅主题及消息处理。以下是分步指南:
一、初始化 Vue 3 项目
使用 Vue CLI 或 Vite 创建项目:
bash
1npm create vue@latest # 使用 Vue CLI
2# 或
3npm create vite@latest my-vue3-mqtt -- --template vue
二、安装 MQTT.js 库
通过 npm 或 yarn 安装:
bash
1npm install mqtt
2# 或
3yarn add mqtt
三、集成 MQTT 到 Vue 3 组件
1. 创建 MQTT 连接
在组件中引入 mqtt 并建立连接:
javascript
1import { onMounted, onBeforeUnmount, ref } from 'vue';
2import mqtt from 'mqtt';
3
4export default {
5 setup() {
6 const client = ref(null);
7 const messages = ref([]);
8
9 const connectMqtt = () => {
10 const options = {
11 keepalive: 30,
12 clientId: `vue3_${Math.random().toString(16).slice(2)}`,
13 username: 'your_username', // 可选
14 password: 'your_password', // 可选
15 clean: true,
16 };
17
18 // 使用 WebSocket 协议(ws:// 或 wss://)
19 client.value = mqtt.connect('ws://your_mqtt_server:8083/mqtt', options);
20
21 client.value.on('connect', () => {
22 console.log('Connected to MQTT Broker');
23 // 订阅主题
24 client.value.subscribe('test/topic', { qos: 1 }, (err) => {
25 if (!err) console.log('Subscription successful');
26 });
27 });
28
29 client.value.on('message', (topic, message) => {
30 const data = JSON.parse(message.toString());
31 messages.value.push({ topic, data });
32 console.log(`Received: ${message.toString()} from ${topic}`);
33 });
34
35 client.value.on('error', (err) => {
36 console.error('MQTT Error:', err);
37 });
38
39 client.value.on('reconnect', () => {
40 console.log('Reconnecting...');
41 });
42
43 client.value.on('close', () => {
44 console.log('Disconnected from MQTT Broker');
45 });
46 };
47
48 onMounted(() => {
49 connectMqtt();
50 });
51
52 onBeforeUnmount(() => {
53 if (client.value) {
54 client.value.end();
55 }
56 });
57
58 return { messages };
59 }
60};
2. 发送消息(可选)
若需发布消息,可添加方法:
javascript
1const publishMessage = (topic, payload) => {
2 if (client.value) {
3 client.value.publish(topic, JSON.stringify(payload), { qos: 1 }, (err) => {
4 if (err) console.error('Publish failed:', err);
5 else console.log('Message published');
6 });
7 }
8};
四、模板中显示消息
在组件模板中渲染接收到的消息:
html
1<template>
2 <div>
3 <h2>MQTT Messages</h2>
4 <ul>
5 <li v-for="(msg, index) in messages" :key="index">
6 <strong>{{ msg.topic }}:</strong> {{ msg.data }}
7 </li>
8 </ul>
9 </div>
10</template>
五、关键配置说明
-
连接协议:
- 浏览器端必须使用
ws://(非加密)或wss://(加密)协议。 - 端口通常为
8083(ws)或8084(wss),需与服务器配置一致。
- 浏览器端必须使用
-
QoS 等级:
-
0:至多一次(可能丢失)。 -
1:至少一次(可能重复)。 -
2:只有一次(确保到达)。
-
-
断线重连:
- MQTT.js 默认自动重连,可通过
reconnectPeriod调整重试间隔(毫秒)。
- MQTT.js 默认自动重连,可通过
-
安全认证:
- 若服务器启用认证,需在
options中配置username和password。
- 若服务器启用认证,需在
六、完整示例代码
javascript
1<script setup>
2import { ref, onMounted, onBeforeUnmount } from 'vue';
3import mqtt from 'mqtt';
4
5const client = ref(null);
6const messages = ref([]);
7
8const connectMqtt = () => {
9 const options = {
10 keepalive: 30,
11 clientId: `vue3_${Math.random().toString(16).slice(2)}`,
12 clean: true,
13 };
14
15 client.value = mqtt.connect('ws://your_mqtt_server:8083/mqtt', options);
16
17 client.value.on('connect', () => {
18 console.log('Connected');
19 client.value.subscribe('test/topic', { qos: 1 }, (err) => {
20 if (!err) console.log('Subscribed');
21 });
22 });
23
24 client.value.on('message', (topic, message) => {
25 messages.value.push({ topic, data: JSON.parse(message.toString()) });
26 });
27
28 client.value.on('error', (err) => {
29 console.error('Error:', err);
30 });
31};
32
33onMounted(() => {
34 connectMqtt();
35});
36
37onBeforeUnmount(() => {
38 if (client.value) client.value.end();
39});
40</script>
41
42<template>
43 <div>
44 <h2>MQTT Messages</h2>
45 <ul>
46 <li v-for="(msg, index) in messages" :key="index">
47 <strong>{{ msg.topic }}:</strong> {{ msg.data }}
48 </li>
49 </ul>
50 </div>
51</template>
七、常见问题解决
-
连接失败:
- 检查服务器地址是否为
ws://或wss://。 - 确认端口和路径(如
/mqtt)是否正确。
- 检查服务器地址是否为
-
消息乱码:
- 使用
message.toString()转换Uint8Array为字符串。
- 使用
-
跨域问题:
- 若服务器未配置 CORS,需通过代理或修改服务器配置解决。
-
性能优化:
- 高频消息时,使用防抖或节流减少渲染次数。
- 合并消息或使用 QoS 0 降低开销。
vue多页项目如何在每次版本更新时做提示
一、遇到的问题
项目中使用懒加载方式加载组件,在新部署镜像后,由于浏览器缓存又去加载旧的js chunk,但是之时旧的js chunk已经不存在,加载不出来造成bug
![]()
二、解决方式
在每次部署后更改版本号,在页面做提示,当前版本又更新,提示用户刷新页面
(1)可以使用的方案有哪些
- 使用轮训查询最新的版本号做对比
- 使用websocket
- 使用service worker
(2)最终采用了什么方案
最终使用了方案1;原因是配置简单方便;缺点是会加大服务器压力!~ (1)在public中创建一个version.json文件,写清楚各个模块的版本, 我这里项目vue多页的,每个项目都要单独版本管理
{
"A项目": {
"version": "1.18.0",
"description": ""
},
"B项目": {
"version": "1.18.0",
"description": ""
},
"C项目": {
"version": "1.18.0",
"description": ""
},
}
(2)创建一个全局的versionUpdate方法,来检测版本是否更新
import 'element-plus/dist/index.css'
import { ElMessageBox } from 'element-plus'
/**
* 版本信息接口
*/
type TVersionInfo = {
[moduleName: string]: TModuleInfo
}
/**
* 模块版本存储信息
*/
type TModuleInfo = {
version: string
description?: string
}
/**
* 基于version.json的版本检测和更新提示工具
*/
export class VersionUpdateService {
private versionCheckInterval: number | null = null
private readonly CHECK_INTERVAL = 5 * 60 * 1000 // 5分钟检查一次
private moduleName: string
private storageKey: string
constructor(moduleName: string = 'home') {
this.moduleName = moduleName
this.storageKey = `module-version-${moduleName}`
}
/**
* 获取模块版本信息
*/
private getModuleVersionInfo(): TModuleInfo | null {
const stored = localStorage.getItem(this.storageKey)
return stored ? JSON.parse(stored) : null
}
/**
* 保存模块版本信息
*/
private saveModuleVersionInfo(info: TModuleInfo): void {
localStorage.setItem(this.storageKey, JSON.stringify(info))
}
/**
* 从version.json获取版本信息(统一从 public/version.json 中按模块名读取)
*/
private async fetchVersionInfo(): Promise<TModuleInfo | null> {
try {
const fullUrl = `${window.location.origin}/version.json?t=${Date.now()}`
console.log(`[${this.moduleName}] 正在获取version.json: ${fullUrl}`)
const response = await fetch(fullUrl, {
method: 'GET',
cache: 'no-cache',
headers: { 'Content-Type': 'application/json' }
})
if (!response.ok) {
console.warn(`[${this.moduleName}] 无法获取version.json: ${response.status} ${response.statusText}`)
return null
}
// 期望 public/version.json 结构为:{ "A项目": { ... }, "B项目": { ... }, "C项目": { ... }, ... }
const indexData = await response.json() as TVersionInfo
console.log(`[${this.moduleName}] 获取到版本信息:`, indexData)
return indexData[this.moduleName]
} catch (error) {
console.warn(`[${this.moduleName}] 获取version.json失败:`, error)
return null
}
}
/**
* 检查是否有新版本
*/
private async checkForUpdate(): Promise<boolean> {
const currentVersionInfo = await this.fetchVersionInfo()
if (!currentVersionInfo) {
console.warn(`[${this.moduleName}] 无法获取当前版本信息,跳过检测`)
return false
}
const storedInfo = this.getModuleVersionInfo()
if (!storedInfo) {
// 第一次检查,保存当前版本信息
this.saveModuleVersionInfo(currentVersionInfo)
console.log(`[${this.moduleName}] 首次检查,保存版本信息`)
return false
}
const versionUpdated = currentVersionInfo.version !== storedInfo.version
if (versionUpdated) {
console.log(`[${this.moduleName}] 检测到版本更新:`, currentVersionInfo)
return true
}
console.log(`[${this.moduleName}] 当前为最新版本:`, currentVersionInfo)
return false
}
/**
* 显示更新提示
*/
private showUpdateNotification(currentVersionInfo: TModuleInfo): void {
const moduleTitle = this.getModuleTitle(this.moduleName)
const currentModuleInfo = currentVersionInfo
const message = `有新版本可用:${currentModuleInfo.version}\n${currentModuleInfo.description}`
ElMessageBox.confirm(
message,
`${moduleTitle}版本更新`,
{
confirmButtonText: '立即刷新',
cancelButtonText: '稍后提醒',
type: 'info',
center: true
}
).then(() => {
this.updateVersionInfo(currentVersionInfo)
this.reloadPage()
}).catch(() => {
console.log(`[${this.moduleName}] 用户选择稍后更新`)
})
}
/**
* 获取模块标题
*/
private getModuleTitle(moduleName: string): string {
const titles: Record<string, string> = {
'A项目': 'A项目名称'
...
}
return titles[moduleName] || moduleName
}
/**
* 更新版本信息
*/
private async updateVersionInfo(currentVersionInfo: TModuleInfo): Promise<void> {
this.saveModuleVersionInfo(currentVersionInfo)
console.log(`[${this.moduleName}] 版本信息已更新:`, currentVersionInfo.version)
}
/**
* 刷新页面
*/
private reloadPage(): void {
if ('caches' in window) {
caches.keys().then(names => {
names.forEach(name => {
caches.delete(name)
})
})
}
setTimeout(() => {
window.location.reload()
}, 100)
}
/**
* 开始定期检查
*/
public startVersionCheck(): void {
this.performVersionCheck()
this.versionCheckInterval = window.setInterval(() => {
this.performVersionCheck()
}, this.CHECK_INTERVAL)
}
/**
* 执行版本检查
*/
private async performVersionCheck(): Promise<void> {
const currentVersionInfo = await this.fetchVersionInfo()
if (!currentVersionInfo) return
const hasUpdate = await this.checkForUpdate()
if (hasUpdate) {
this.showUpdateNotification(currentVersionInfo)
}
}
/**
* 停止版本检查
*/
public stopVersionCheck(): void {
if (this.versionCheckInterval) {
clearInterval(this.versionCheckInterval)
this.versionCheckInterval = null
}
}
/**
* 获取所有模块版本信息(调试用)
*/
public static getAllModuleVersions(): Record<string, TModuleInfo | null> {
const modules = ['A项目'...]
const result: Record<string, TModuleInfo | null> = {}
modules.forEach(module => {
const key = `module-version-${module}`
const stored = localStorage.getItem(key)
result[module] = stored ? JSON.parse(stored) : null
})
return result
}
/**
* 清除指定模块的版本信息
*/
public static clearModuleVersion(moduleName: string): void {
const key = `module-version-${moduleName}`
localStorage.removeItem(key)
console.log(`已清除模块 [${moduleName}] 的版本信息`)
}
/**
* 初始化版本更新检测
*/
public static init(moduleName: string = 'home'): VersionUpdateService {
const service = new VersionUpdateService(moduleName)
service.startVersionCheck()
return service
}
}
/**
* 初始化版本更新检测
*/
export const initVersionUpdateJson = (moduleName?: string) => {
return VersionUpdateService.init(moduleName || 'home')
}
/**
* 兼容旧版本的导出
*/
export const initVersionUpdate = initVersionUpdateJson
3、在每个模块中的main.ts中引入使用这个方法
import { initVersionUpdateJson } from '@/utils/VersionUpdate'
// 初始化版本检测
initVersionUpdateJson('chess') // 这里传入的是项目名称
大规模图片列表性能优化:基于 IntersectionObserver 的懒加载与滚动加载方案
![]()
📝 背景与目标
在 渲染大量图片的功能场景中,千级图片一次性渲染会引发系列性能问题,包括首屏渲染阻塞、内存占用激增、滚动交互卡顿及网络带宽浪费。本方案的核心目标是,在保障用户体验不受损的前提下,通过 “按需渲染、按需加载、渐进获取” 三大核心策略,将大规模图片列表的渲染成本与网络开销控制在合理范围。
这篇文章将详细拆解实现方案:基于 IntersectionObserver API 构建的 “图片懒加载 + 滚动加载更多” 组合方案,涵盖抽象设计、核心代码实现、细节优化策略及可扩展方向,为同类大规模媒体列表场景提供可复用的技术参考。
🏗️ 系统设计总览
架构分层
系统采用 “组件层 - 状态层 - 工具层” 三层架构,职责边界清晰,便于复用与测试:
-
组件层(View):
ImageFavoriteModal.vue负责图片网格渲染,整合搜索、懒加载触发、滚动加载调度、图片预览 / 下载 / 取消收藏等交互逻辑。 -
状态层(Store):
useImageStore统一管理收藏图片数据的获取、分页状态维护及数据追加合并,提供标准化数据接口。 -
工具层(Utils):
imageLazyLoad.js封装IntersectionObserverAPI,提供图片懒加载观察器与滚动触底加载更多观察器两大核心能力。
设计核心原则
- 首屏直出:固定渲染并加载首批 12 张图片,平衡 “快速可见” 与 “资源可控”。
- 视口触发加载:通过观察器监听 DOM 元素可见状态,仅当图片进入视口时触发真实地址加载。
- 渐进式数据获取:采用分页加载模式,单页请求 100 张图片,触底阈值触发下一页请求。
- 分层解耦:组件专注 UI 渲染与交互,状态层负责数据管理,工具层封装通用能力,降低耦合度。
🛠️ 核心能力抽象与职责拆分
1. 图片懒加载观察器(按需加载核心)
核心职责
监听图片 DOM 元素的视口进入状态,仅当元素进入视口(含预加载阈值)时,标记为 “可加载” 状态,触发 <el-image> 组件拉取真实图片资源。
核心实现代码
// ai_multimodal_web/src/utils/imageLazyLoad.js
/**
* 图片懒加载工具函数
* 基于Intersection Observer API实现图片元素可见性监听
* @param {Function} callback - 元素进入视口时的回调函数
* @param {Object} options - 观察器配置项(覆盖默认配置)
* @returns {Object} 观察器操作方法(observe/unobserve/disconnect)
*/
export function createImageLazyLoader(callback, options = {}) {
// 默认配置:提前100px触发加载,提升滚动流畅度
const defaultOptions = {
root: null,
rootMargin: '100px',
threshold: 0.1,
...options
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// 元素进入视口时执行回调
if (entry.isIntersecting) {
callback(entry);
}
});
}, defaultOptions);
// 单个元素观察
const observe = (element) => {
if (element instanceof HTMLElement) observer.observe(element);
};
// 单个元素取消观察
const unobserve = (element) => {
if (element) observer.unobserve(element);
};
// 销毁观察器
const disconnect = () => {
observer.disconnect();
};
return { observe, unobserve, disconnect };
}
批量观察封装(提升开发效率)
// ai_multimodal_web/src/utils/imageLazyLoad.js
/**
* 批量图片元素懒加载监听
* @param {HTMLElement[]} elements - 需要监听的图片元素数组
* @param {Function} onIntersect - 元素进入视口时的回调(参数:目标元素、观察器条目)
* @param {Object} options - 观察器配置项
* @returns {Object} 批量观察操作方法
*/
export function observeImageElements(elements, onIntersect, options = {}) {
const loader = createImageLazyLoader((entry) => {
if (entry.target) {
onIntersect(entry.target, entry);
}
}, options);
// 批量观察所有元素
const observeAll = () => {
if (!elements || elements.length === 0) return;
Array.from(elements).forEach((element) => {
if (element instanceof HTMLElement) {
loader.observe(element);
}
});
};
// 批量取消观察
const unobserveAll = () => {
if (!elements || elements.length === 0) return;
Array.from(elements).forEach((element) => {
if (element) loader.unobserve(element);
});
};
return {
observe: observeAll,
unobserve: unobserveAll,
disconnect: loader.disconnect
};
}
设计关键要点
-
预加载阈值:通过
rootMargin: '100px'配置,提前加载即将进入视口的图片,避免滚动时出现空白。 - 批量操作封装:简化组件层调用逻辑,避免重复创建观察器实例,提升代码复用性。
-
类型校验:增加
HTMLElement类型判断,增强工具函数鲁棒性。
2. 滚动加载更多观察器(按需获取数据)
核心职责
监听列表底部的 “触底触发哨兵元素”,当元素进入视口(含预加载阈值)时,触发下一页数据请求,实现列表数据的渐进式追加。
核心实现代码
// ai_multimodal_web/src/utils/imageLazyLoad.js
/**
* 滚动加载更多工具函数
* 基于Intersection Observer API监听触底触发元素
* @param {Function} onLoadMore - 触发加载更多时的回调函数
* @param {Object} options - 配置项(triggerElement:触发元素,threshold:预加载阈值)
* @returns {Object} 观察器操作方法(observe/updateTrigger/disconnect)
*/
export function createScrollLoadMore(onLoadMore, options = {}) {
const { triggerElement = null, threshold = 200 } = options;
let observer = null;
// 初始化观察器
const setupObserver = (targetElement) => {
// 若已有观察器,先销毁避免内存泄漏
if (observer) observer.disconnect();
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
onLoadMore();
}
});
}, {
root: null,
rootMargin: `${threshold}px`, // 预加载阈值,提前触发请求
threshold: 0.1
});
if (targetElement) observer.observe(targetElement);
};
// 开始观察(支持传入触发元素)
const observe = (element = null) => {
const target = element || triggerElement;
if (target) setupObserver(target);
};
// 更新触发元素(适用于列表刷新场景)
const updateTrigger = (element) => {
setupObserver(element);
};
// 销毁观察器
const disconnect = () => {
if (observer) observer.disconnect();
observer = null;
};
return { observe, updateTrigger, disconnect };
}
设计关键要点
-
预加载阈值:通过
threshold配置(默认 200px),提前触发数据请求,掩盖网络延迟,提升用户体验。 -
幂等性保障:触发加载后通过业务层
isLoadingMore锁控制,避免重复请求。 -
动态更新支持:提供
updateTrigger方法,适配列表数据刷新后触发元素位置变更的场景。
3. 组件层整合实现(首屏直出 + 懒加载 + 分页加载)
ImageFavoriteModal.vue 作为核心组件,整合三大核心能力,实现 “首屏快速呈现、滚动平滑加载” 的交互体验。
核心逻辑设计
- 首屏优化:直接渲染并加载前 12 张图片,确保用户快速看到有效内容。
-
加载状态管理:通过
loadedImageIndices集合记录已进入视口的图片索引,控制<el-image>的src绑定时机。 - 观察器生命周期:组件初始化时创建观察器,数据追加后重建观察器,组件卸载时销毁观察器。
- 分页调度:首屏加载第 1 页(100 张)数据,触底时加载下一页,数据更新后同步更新观察器。
关键代码实现
1. 加载状态判断逻辑
// ai_multimodal_web/src/components/aiStudio/ImageFavoriteModal.vue
/**
* 判断图片是否需要加载
* @param {Number} index - 图片在列表中的索引
* @returns {Boolean} 是否加载图片
*/
const shouldLoadImage = (index) => {
// 首屏前12张直接加载
if (index < 12) return true;
// 其余图片需已进入视口(通过索引集合判断)
return loadedImageIndices.value.has(index);
};
2. 图片列表渲染与 src 绑定
<el-image
ref="imageItemRefs"
:data-image-index="index"
:src="shouldLoadImage(index) ? getImageFullUrl(image.imageUrl) : undefined"
:lazy="true"
fit="cover"
:preview-src-list="previewImageList"
@error="handleImageError"
@load="handleImageLoad(index)"
:z-index="3000"
:preview-teleported="true"
:initial-index="index"
class="favorite-image"
:class="{
'lazy-loading': !shouldLoadImage(index),
'loaded': shouldLoadImage(index)
}">
<template #placeholder>
<div class="image-placeholder">加载中...</div>
</template>
<template #error>
<div class="image-error">图片加载失败</div>
</template>
</el-image>
3. 懒加载观察器初始化
// ai_multimodal_web/src/components/aiStudio/ImageFavoriteModal.vue
/**
* 初始化图片懒加载观察器
* 跳过首屏12张图片,仅监听后续元素
*/
const setupImageLazyLoad = () => {
// 销毁现有观察器,避免内存泄漏
if (imageLazyLoader) imageLazyLoader.disconnect();
// 筛选需要监听的图片元素(非首屏+有效DOM)
const imageElements = imageItemRefs.value
.filter((el, index) => el && index >= 12)
.filter(Boolean);
if (imageElements.length === 0) return;
// 创建批量观察器
imageLazyLoader = observeImageElements(
imageElements,
(element) => {
// 从DOM数据集获取图片索引
const index = parseInt(element.dataset?.imageIndex) || 0;
// 标记为已加载,触发src绑定
if (!loadedImageIndices.value.has(index) && index >= 12) {
loadedImageIndices.value.add(index);
}
},
{ rootMargin: '100px', threshold: 0.1 }
);
// 启动观察
imageLazyLoader.observe();
};
4. 滚动加载更多初始化
// ai_multimodal_web/src/components/aiStudio/ImageFavoriteModal.vue
/**
* 初始化滚动加载更多观察器
*/
const setupScrollLoadMore = () => {
// 销毁现有观察器
if (scrollLoader) scrollLoader.disconnect();
// 无更多数据或无触发元素时,不初始化
if (!hasMore.value || !loadMoreTriggerRef.value) return;
// 创建滚动加载观察器
scrollLoader = createScrollLoadMore(async () => {
await loadMore();
}, { threshold: 200 });
// 监听触底触发元素
scrollLoader.observe(loadMoreTriggerRef.value);
};
5. 分页数据加载逻辑
// ai_multimodal_web/src/components/aiStudio/ImageFavoriteModal.vue
/**
* 加载下一页图片数据
*/
const loadMore = async () => {
// 加载中或无更多数据时,阻止重复请求
if (isLoadingMore.value || !hasMore.value) return;
try {
isLoadingMore.value = true;
// 计算下一页页码
const nextPage = imageStore.favoritePagination.page + 1;
// 从状态层获取数据(追加模式)
await imageStore.loadFavoriteImages(nextPage, PAGE_SIZE, true);
// 等待DOM更新完成后,重建观察器
await nextTick();
setupImageLazyLoad();
setupScrollLoadMore();
} finally {
// 无论成功失败,都关闭加载状态
isLoadingMore.value = false;
}
};
💾 数据层设计:稳定的分页与数据格式化
状态层 useImageStore 承担数据管理核心职责,为组件层提供标准化、稳定的数据接口,屏蔽数据请求与格式化细节。
核心职责
- 支持两种数据更新模式:替换模式(首次加载 / 刷新)与追加模式(滚动加载更多)。
- 数据格式化:统一图片数据字段(
imageUrl、imageId、timestamp等),避免组件层分支判断。 - 分页状态维护:基于接口返回数据,计算并维护
hasNext、hasPrev等状态,为加载更多提供依据。
核心实现代码
// ai_multimodal_web/src/stores/image.js
import { defineStore } from 'pinia';
import { getCollectedImages } from '@/api/image';
export const useImageStore = defineStore('image', () => {
// 收藏图片列表数据
const favoriteImages = ref([]);
// 分页状态:page-当前页,limit-单页条数,total-总条数,totalPages-总页数,hasNext-是否有下一页,hasPrev-是否有上一页
const favoritePagination = ref({
page: 1,
limit: 20,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false
});
/**
* 加载收藏图片列表
* @param {Number} page - 页码(默认1)
* @param {Number} limit - 单页条数(默认20)
* @param {Boolean} append - 是否追加模式(默认false:替换模式)
* @returns {Array} 格式化后的图片列表
*/
const loadFavoriteImages = async (page = 1, limit = 20, append = false) => {
// 发起接口请求(隐藏加载态,避免频繁弹窗)
const response = await getCollectedImages({ page, limit }, { showLoading: false });
// 数据格式化:统一字段格式,适配组件层渲染需求
const formattedImages = response.data?.map(item => ({
imageId: item.id || item.imageId,
imageUrl: item.url || item.imageUrl,
timestamp: item.createTime || item.timestamp,
// 其他需要的字段...
})) || [];
// 数据更新:替换或追加
if (append) {
favoriteImages.value = [...favoriteImages.value, ...formattedImages];
} else {
favoriteImages.value = formattedImages;
}
// 更新分页状态
const total = response.total || 0;
const totalPages = Math.ceil(total / limit);
favoritePagination.value = {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
};
return formattedImages;
};
return {
favoriteImages,
favoritePagination,
loadFavoriteImages
// 其他辅助方法...
};
});
🚀 性能与体验优化细节
1. 首屏加载优化
- 固定首屏加载 12 张图片,平衡 “快速可见” 与 “资源占用”,缩短首屏渲染时间。
- 首屏图片直接绑定
src,无需等待观察器触发,提升感知性能。
2. 滚动体验优化
-
懒加载预加载阈值:
rootMargin: '100px',提前加载即将进入视口的图片,避免滚动时出现空白。 -
滚动加载预请求:
threshold: 200px,提前触发下一页数据请求,掩盖网络延迟。
3. 资源与内存优化
-
观察器生命周期管理:组件卸载、弹窗关闭时,及时调用
disconnect销毁观察器,释放 DOM 监听资源,避免内存泄漏。 - 分页大小适配:当前设置 100 张 / 页,平衡网络请求次数与单次请求开销,可根据图片平均体积、网络环境微调。
4. 交互体验优化
-
占位与错误态:为
<el-image>配置占位态与错误态,避免加载过程中页面布局抖动,提供友好反馈。 -
预览列表缓存:
preview-src-list基于filteredImages映射生成,避免每次预览时临时创建大数组,提升预览打开速度。 -
跨域下载兼容:针对跨域图片资源,通过
fetch -> blob -> ObjectURL转换流程,避免浏览器跨域下载限制。
🛡️ 易错点与防御性编程策略
1. 重复加载问题
-
加载锁控制:通过
isLoadingMore状态变量,阻止加载过程中重复触发loadMore。 - 观察器防抖:数据加载完成前,避免多次触发观察器回调,确保单次分页请求唯一。
2. DOM 与数据一致性问题
-
DOM 更新时机:数据追加后,需通过
await nextTick()等待 DOM 渲染完成,再重建观察器,避免获取不到新渲染的 DOM 元素。 -
索引一致性:通过
data-image-index为图片元素绑定固定索引,配合loadedImageIndices集合,确保删除 / 过滤图片后加载状态准确。
3. 兼容性与降级处理
-
浏览器兼容性:
IntersectionObserver在部分低端浏览器或 SSR 环境下不支持,可通过if ('IntersectionObserver' in window)检测,降级为scroll事件节流监听方案。 -
接口异常处理:为
loadFavoriteImages添加异常捕获,避免请求失败导致列表加载中断,可提供重试机制。
💡 方案选型:懒加载 + 分页 vs. 虚拟滚动
技术选型对比
| 方案 | 核心逻辑 | 优势 | 适用场景 |
|---|---|---|---|
| 懒加载 + 分页 | 渲染全部 DOM,仅按需加载图片资源;分页控制列表长度 | 实现简单、无额外依赖、改造成本低;交互流畅 | 数据量中等(千级以内)、单条 Item 结构简单的场景 |
| 虚拟滚动 | 仅渲染视口内 DOM,通过滚动位移复用 DOM 节点 | 极致节省 CPU / 内存;支持万级以上大数据量 | 数据量极大(万级以上)、单条 Item 结构复杂的场景 |
当前方案合理性说明
- 业务规模匹配:当前收藏图片量多为千级以内,懒加载+分页方案已能满足性能需求,无需引入复杂依赖。
- 开发与维护成本:方案基于原生 API 实现,无额外第三方依赖,开发成本低、维护便捷,可快速复用到其他场景。
-
平滑升级路径:若未来收藏量增长至万级以上,可基于现有架构平滑升级为虚拟滚动方案(如集成
vue-virtual-scroller),无需重构核心逻辑。
📈 可扩展优化方向
1. 动态适配能力
- 动态首屏数量:基于容器可视区域高度与单张图片占位高度,计算最优首屏渲染数量,适配不同屏幕尺寸。
-
智能分页大小:根据图片平均体积、用户网络质量(通过
navigator.connection.effectiveType获取),动态调整单页加载数量。
2. 性能与可靠性优化
- 请求缓存与去重:对已加载过的分页数据进行缓存,避免重复请求;同一页码请求进行去重处理,减少无效接口调用。
- 加载失败重试:为单张图片加载失败提供重试按钮,或实现自动重试机制(限制重试次数),提升加载成功率。
-
滚动节流增强:极端场景下(如快速滚动),为
onLoadMore添加节流控制(如 200ms 间隔),避免频繁触发请求。
3. 功能扩展
- 图片预加载策略:针对用户高频操作(如预览过的图片),提前加载相关图片资源,提升二次访问速度。
- 批量操作优化:支持批量下载、批量取消收藏时,优化数据更新与观察器重建逻辑,避免操作卡顿。
✅ 方案总结
本方案基于 IntersectionObserver API,通过 “工具层抽象、组件层整合、状态层支撑” 的架构设计,实现了大规模图片列表的性能优化。核心价值如下:
- 解耦设计:将视口监听逻辑抽象为通用工具,组件层专注 UI 与交互,状态层统一数据管理,提升代码复用性与可维护性。
- 成本可控:通过 “首屏直出 + 按需加载 + 渐进获取” 组合策略,有效降低首屏渲染压力、网络带宽开销与内存占用。
- 体验与性能平衡:通过预加载阈值、占位态、错误处理等细节优化,确保性能提升的同时不牺牲用户体验。
- 可扩展性强:方案架构灵活,支持根据业务规模平滑升级,可快速复用到其他媒体列表场景(如视频列表、文件列表)。
关键文件清单
-
工具层:
src/utils/imageLazyLoad.js(懒加载与滚动加载观察器封装) -
状态层:
src/stores/image.js(分页请求、数据格式化与状态管理) -
组件层:
src/components/aiStudio/ImageFavoriteModal.vue(UI 渲染、交互整合与观察器绑定)
本方案已在实际业务中落地验证,性能与体验均达到预期,可作为同类大规模媒体列表性能优化的参考模板。
多模态Ai项目全流程开发中,从需求分析,到Ui设计,前后端开发,部署上线,感兴趣打开链接(带项目功能演示),多模态AI项目开发中...
项目中使用el-table实现行合并及合并后序号不连续解决方案
用 Three.js 打造炫酷波浪粒子背景动画:从原理到实现
纯前端提取图片颜色插件Color-Thief教学+实战完整指南
基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交
基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交
![]()
上一篇文章我们实现了六边形蜂窝的区域客流,这一篇我们的目标是实现 **实时公交**。实现实时公交的方式我们需要得数据:实时更新位置的公交车、
当前公交车排班路线,
还有就是线路所经过的站台
一般公交车更新实时位置都是socket,这里为了方便我们使用历史行进轨迹数据traceData.json进行调试,站台我们采用glb模型,并将模型朝向路线保证视觉正确,公交车运行时需要沿路行驶并且需要正确的朝向,开搞。
数据获取
基于上一篇文章的初始化地图代码,我们需要以下数据文件(文件在下方仓库地址):
- 线路数据(routeData)这是公交车司机当天排班的公交车指定行进线路,比如101路线路
- 站点数据(stand2Data)当天排班的公交车指定行进线路所经过的上下行站点,每个站点最少站台
- 轨迹数据(traceData)公交车的实际行进线路数据,一般都在指定行进线路上
资源文件准备:
import traceData from '@/resources/trace/reace1.json';
import routeData from '@/resources/route/route2.json';
import stand2Data from '@/resources/stand/stand2.json';
routeData线路数据是上/下行两组 WGS84 点位:
{
"up": [ { "lat": 33.27627349350771, "lon": 117.32730936865975 }, ... ],
"down": [ ... ]
}
stand2Data站点数据结构与此相近,但包含名称、分组等属性,
{
"up": [
{
"id": 862,
"name": "小学",
"remarks": "北",
"lat": 33.3333833,
"lon": 117.3255,
...
}
],
"down": [ ... ]
}
轨迹数据则提供车辆按时间序列格式的数据。
[
{
"routeName": "101路",
"driverName": "张xx",
"start": "2025-10-13 07:40:02",
"end": "2025-10-13 09:39:50",
"locations": [
{
"lineId": 15,
"driverId": 37,
"posTime": "2025-10-13 07:41:03", //上报时间
"latitude1": 33.33392333984375,
"longitude1": 117.32551574707031,
"speed": 7000, //当前速度
"gpsMileage": 35010000,
},
...
]
}
]
车辆模型加载
![]()
开始,我们用mapvthree.gltfLoader把公交车模型加载,分三步:定位、调整缩放和朝向、加入场景,这里需要注意three默认的加载地址在根目录public。
加载与首帧定位:
//加载模型
mapvthree.gltfLoader.load('/model/bus.glb', (gltf: any) => {
busModel = gltf.scene;
// 取轨迹起点作为起始位置(WGS84 -> BD09 -> 墨卡托)
const firstLocation = locations[0];
if (firstLocation.latitude1 && firstLocation.longitude1) {
const [bdLon, bdLat] = wgs84tobd09(firstLocation.longitude1, firstLocation.latitude1);
const [x, y, z] = bd09ToMercator(bdLon, bdLat);
busModel.position.set(x, y, z);
}
// 方向、大小合适
busModel.rotateX(Math.PI / 2);
busModel.rotateY(Math.PI * 3 / 2);
busModel.scale.setScalar(0.9);
engine.add(busModel);
// 创建车上放的提示框,后续会更随车辆一起移动
const tooltipDom = createBusTooltip();
const firstLocationForTooltip = locations[0];
if (firstLocationForTooltip.latitude1 && firstLocationForTooltip.longitude1) {
const [bdLon, bdLat] = wgs84tobd09(firstLocationForTooltip.longitude1, firstLocationForTooltip.latitude1);
busTooltip = engine.add(new mapvthree.DOMOverlay({
point: [bdLon, bdLat, 50], //抬高50
dom: tooltipDom
}));
}
});
这里的提示框,我们用自带的 DOMOverlay,传入位置和dom即可;它的跟随逻辑会在raf动画段落里和公交车同步更新。
站台模型加载与朝向设置
我们不仅需要加载站台模型,还需要设置站台的朝向设,一般的站台都是面朝马路的,所以这里我们需要首先找到站台和线路中最近的交点A ,然后计算出 站点 和 A点 为站点的朝向向量,这样就能让站台正确的朝向马路了。示例图如下
![]()
清楚怎么设置之后,首先我们处理好我们的站点和线路的经纬度格式:
// 创建站点标记
const createStationMarkers = () => {
const standData = stand2Data as any;
if (!standData || (!Array.isArray(standData.up) && !Array.isArray(standData.down))) {
console.warn('站点数据格式不正确');
return;
}
// 合并上行和下行站点数据
const allStations = [
...(Array.isArray(standData.up) ? standData.up : []),
...(Array.isArray(standData.down) ? standData.down : [])
];
// 转换为 GeoJSON 格式(wgs转 BD09)
const features = allStations
.filter((station: any) => typeof station?.lon === 'number' && typeof station?.lat === 'number')
.map((station: any) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [wgs84tobd09(station.lon, station.lat)[0], wgs84tobd09(station.lon, station.lat)[1]],
},
properties: {
id: station.id,
name: station.name,
remarks: station.remarks,
up: station.up || 0,
down: station.down || 0,
groupId: station.groupId,
groupName: station.groupName || '',
longitude: station.lon,
latitude: station.lat,
},
}));
const geojson = { type: 'FeatureCollection', features } as const;
const dataSource = mapvthree.GeoJSONDataSource.fromGeoJSON(geojson as any);
// 创建公交线路的 Turf LineString
const routeLineString = createRouteLineString();
接着我们再加载站台模型,设置好大小、初始化朝向和位置
// 加载车站模型
let busStopModel: any = null;
const stationModels: any[] = [];
mapvthree.gltfLoader.load('/model/bus_stop.glb', (gltf: any) => {
busStopModel = gltf.scene;
// 初始化朝向
busStopModel.rotateX(Math.PI / 2);
busStopModel.scale.set(7, 7, 7);
// 为每个站点创建模型实例
features.forEach((feature: any) => {
const originalLon = feature.properties.longitude;
const originalLat = feature.properties.latitude;
const [bdLon, bdLat] = wgs84tobd09(originalLon, originalLat);
const [x, y, z] = bd09ToMercator(bdLon, bdLat);
const stationModel = busStopModel.clone();
stationModel.position.set(x, y, z);
engine.scene.add(stationModel);
stationModels.push(stationModel);
});
});
最后我们来设置站台的朝向,朝向马路,代码如下:
// 如果存在公交线路,计算站点到线路的最近点并设置模型朝向
if (routeLineString) {
const stationPoint = turf.point([bdLon, bdLat]);
//找到最近的点
const nearestPoint = turf.nearestPointOnLine(routeLineString, stationPoint);
const nearestCoords = nearestPoint.geometry.coordinates;
// 计算方位角
const bearing = turf.bearing(stationPoint, turf.point(nearestCoords));
// 转换角度(正北为0、顺时针为正) → Three Y轴旋转
const rotationY = (bearing - 180) * Math.PI / 180;
stationModel.rotateY(rotationY);
}
站台model调整前后对比效果图如下:
![]()
沿路行驶与转向动画
因为原数据的行驶速度过慢,我们采用固定速度,让车辆匀速的方式沿线前进,同时在转弯处做平滑的朝向过渡,避免瞬间旋转。
核心变量与参数:
// 动画参数
const speedKmh = 100; // 行驶速度
const speedMs = speedKmh * 1000 / 3600; // m/s
const totalTimeSeconds = totalDistance / speedMs; // 总行驶时间
// 角度过渡
let currentRotationY = 0; // 当前朝向(弧度)
const rotationSpeed = 2; // 最大旋转速度(弧度/秒)
动画主循环负责两件事:位置插值与朝向插值。
- 位置插值(沿线段线性插值)
const elapsed = (Date.now() - startTime) / 1000;
const progress = Math.min(elapsed / totalTimeSeconds, 1);
const currentDistance = progress * totalDistance;
// 通过累计里程数组 `distances` 定位当前所在的线段
let pointIndex = 0;
for (let i = 0; i < distances.length - 1; i++) {
if (currentDistance >= (distances[i] || 0) && currentDistance <= (distances[i + 1] || 0)) {
pointIndex = i; break;
}
}
// 计算线段内比例,并对两端墨卡托坐标做线性插值
const segmentStart = distances[pointIndex] || 0;
const segmentEnd = distances[pointIndex + 1] || 0;
const t = segmentEnd > segmentStart ? (currentDistance - segmentStart) / (segmentEnd - segmentStart) : 0;
// WGS84 转 墨卡托
const [startBdLon, startBdLat] = wgs84tobd09(startLocation.longitude1, startLocation.latitude1);
const [endBdLon, endBdLat] = wgs84tobd09(endLocation.longitude1, endLocation.latitude1);
const [startX, startY] = bd09ToMercator(startBdLon, startBdLat);
const [endX, endY] = bd09ToMercator(endBdLon, endBdLat);
const currentX = startX + (endX - startX) * t;
const currentY = startY + (endY - startY) * t;
busModel.position.set(currentX, currentY, 0);
- 朝向角插值(平滑转向,避免突变)
算法:用当前线段向量 end - start 求出航向角 targetAngle = atan2(dy, dx);再用“夹角归一到 ([-π, π])”与“最大角速度”把 currentRotationY 朝 targetAngle 推进。这样在急转弯处也会过渡自然,当然你也可以直接用gsap过渡
const directionX = endX - startX;
const directionY = endY - startY;
const directionLength = Math.sqrt(directionX * directionX + directionY * directionY);
if (directionLength > 0) {
const targetAngle = Math.atan2(directionY, directionX);
// 夹角归一 [-π, π]
let angleDiff = targetAngle - currentRotationY;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
// 最大角速度限制
const deltaTime = 1 / 60;
const maxRotationChange = rotationSpeed * deltaTime;
if (Math.abs(angleDiff) > maxRotationChange) {
currentRotationY += Math.sign(angleDiff) * maxRotationChange;
} else {
currentRotationY = targetAngle;
}
busModel.rotation.y = currentRotationY;
}
效果示例:
![]()
相机跟随与视角策略
我们需要提供“自由”和“跟随”两种视角模式的切换,在跟随时的时候我们只跟随距离,就像FPS游戏中TPP视角一样,吃鸡PUBG就是这种策略。
跟随实现:每帧在更新车辆位置中用mapthree中的 lookAt 把镜头看向车辆当前位置,设置固定距离 range: 300
//raf动画循环
if (cameraFollowMode) {
const [currentBdLon, currentBdLat] = mercatorToBd09(currentX, currentY);
engine.map.lookAt([currentBdLon, currentBdLat], {
range: 300
} as any);
}
自由视角直接将cameraFollowMode设为false即可;
停之停之!文章写到这里我我发现百度地图的开发者更新了新功能,他们支持了自定义镜头动画,赶紧换上
![]()
现在只需要传入model即可,不需要再每一帧手动更新相机位置,相比于之前的拖动丝滑的很多,lock设置为true即可固定视角
const tracker = engine.add(new mapvthree.ObjectTracker())
tracker.track(busModel, {
range: 300,
pitch: 80,
heading: 10,
lock: false, //不锁定视角
})
![]()
高级参数可视化
以上实现就已经完成实时公交的基本形式的可视化了,但是我们要做就要做的更详细一点,加入类似于电子公交屏的功能:距离下一站距离、当前到达、下一站和运行状态。
-
运行状态:公交车运行状态机,包含四个状态
-
driving:正常行驶 -
approaching:减速进站(距离站点 < 200米) -
stopped:站台停靠(距离站点 < 30米,停留3秒) -
departing:启动离站
-
- 距离计算:使用 Turf.js 计算车辆当前位置到下一站的直线距离(可以用线段截取计算的方式实际剩余距离)
- 进度显示:基于距离计算的到站进度
-
站点状态管理:
-
passedStations:已通过站点数组 -
currentStationIndex:当前最接近的站点 -
nextStationIndex:下一站 - 站点状态分为:已通过、当前站、下一站、未到达
-
通过在 updateBusStatus 函数每帧去更新:计算车辆到所有站点的距离,找到最近站点,更新到站状态,并计算到下一站的距离和进度即可~
// raf更新函数....
// 更新公交车位置和状态
const updateBusStatus = (currentLon: number, currentLat: number) => {
// 计算到所有站点的距离
const currentPoint = turf.point([currentLon, currentLat]);
const stationDistances = currentStations.value.map((station: any, index: number) => {
const [bdLon, bdLat] = wgs84tobd09(station.lon, station.lat);
const stationPoint = turf.point([bdLon, bdLat]);
const distance = turf.distance(currentPoint, stationPoint, { units: 'meters' });
return { index, distance, station };
});
// 找到最近的站点
const nearestStation = stationDistances.reduce((min: any, current: any) =>
current.distance < min.distance ? current : min
);
// 到站判断(距离小于50米认为到站)
const stationThreshold = 50;
const isAtStation = nearestStation.distance < stationThreshold;
// 处理到站状态
handleStationStateMachine(nearestStation.index, nearestStation.distance);
// 计算到下一站的距离
const nextStation = currentStations.value[busStatus.value.nextStationIndex];
if (nextStation) {
const [nextBdLon, nextBdLat] = wgs84tobd09(nextStation.lon, nextStation.lat);
const nextStationPoint = turf.point([nextBdLon, nextBdLat]);
busStatus.value.distanceToNext = turf.distance(currentPoint, nextStationPoint, { units: 'meters' });
}
};
效果图:
![]()
其实上面的数据在实际业务中是后端不会再前端去计算,这里也只是阐述一下业务逻辑,实现一下效果,还有就是实际业务是要接入实时位置更新的,我们需要实时去更新公交车的位置,简单的阐述一下业务,一般的做法是每辆车需要维护一个信号队列,然后逐个去执行队列,这样车辆的延迟是第一个和第二个信号之间的时间差,画了一个逻辑图:
![]()
而且实际中实时数据是会抖动的,出现长时间没信号、信号批量涌入、gps信号乱跳这些都会出现,若接入真实 GPS,可对点做卡尔曼等滤波处理,减少抖动,让公交车的行进看起来更自然更流畅一些。
好了,以上就是线路客流、区域客流和实时公交的所有内容了,本人技术十分有限,如有不合理或者错误的地方还望指出
基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流
基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流
![]()
前言
在上一篇我们实现了公交线路客流,通过飞线效果展示了站点间的客流流向。现在我们来搞一下区域客流可视化,采用六边形蜂窝网格来展示不同区域的客流热力图,除了保证数据更加直观外,当然也要利用JSAPIThree高灵活自定义的优势来搞点帅的东西。
在公交行业的区域客流可视化主要的是:
- 哪些区域的公交客流最密集
- 通过热力图快速识别热点区域
- 面子攻城(bushi)
与线路客流相比,区域客流更注重空间分布特征这块。我们使用六边形蜂窝网格将城市区域进行规则划分(也支持正方形、三角形),每个六边形代表一个单元,通过统计单元内的公交站点数量和客流数据,生成蜂窝热力图来直观展示每块区域的客流密度分布。
技术实现
数据准备
基于上一篇文章的初始化地图代码,我们需要以下数据文件(文件在下方仓库地址):
-
边界数据 (
guzhen.json) - 城市或区域边界数据 -
站点数据 (
stands.json) - 公交站点位置和客流数据
边界数据采用标准的 GeoJSON 格式(这种数据推荐去阿里的datav中可以直接获取包括省市区)。站点数据包含每个站点的经纬度坐标和客流统计信息。
蜂窝网格生成
六边形相对比矩形和三角形看起来更专业一点。我们使用 Turf.js 的 hexGrid 函数来生成蜂窝网格(truf也支持三角形和矩形)。
网格生成原理:
-
边界框计算:使用
bbox函数计算多边形的包围盒 - 网格生成 :- 在边界框内生成指定radius的蜂窝网格
-
空间裁剪:使用
booleanIntersects过滤与目标区域相交的六边形,也就是整个区域内的蜂窝
import { bbox, polygon, hexGrid, booleanIntersects, booleanContains } from '@turf/turf'
// 生成 1.5km 六边形蜂窝并裁剪到目标边界
const hexLinesFC = () => {
const boundary = guzhen.features[0].geometry
const wgsPolygon = polygon([boundary.coordinates[0]])
const box = bbox(wgsPolygon)
// 生成 1.5公里半径的六边形网格
const grid = hexGrid(box, 1.5, { units: 'kilometers' })
// 过滤与边界之外的六边形
const features = grid.features.filter((cell) => booleanIntersects(cell, wgsPolygon))
return { type: 'FeatureCollection', features }
}
booleanIntersects 函数是空间相交判断,booleanContains函数是判断否在空间内,我们只保留与目标区域重叠的六边形
站点数据统计
为每个六边形计算站点数量和总客流数据,这是为了生成热力图用的的数值。
统计原理:
-
包含判断 - 使用
booleanContains函数判断站点是否在六边形内 - 数据聚合 - 累加六边形内所有站点的客流数据
// 计算每个六边形内的站点数据
const calculateHexagonData = (hexagon) => {
let totalUp = 0 //六边形内所有站点的上车人数总和
let stationCount = 0 // 六边形内包含的站点数量
// 遍历所有站点,检查是否在六边形内
for (const station of stands) {
for (const stand of station.stands) {
const standPoint = point([stand.lon, stand.lat])
//是否在内部
if (booleanContains(hexagon, standPoint)) {
totalUp += stand.up || 0
stationCount++
}
}
}
return { totalUp, stationCount }
}
然后我们可以用使用处理好的所有数据使用mapvthree.Polyline进行预览,代码如下:
// 生成六边形蜂窝并裁剪到边界
const hexLinesFC = (): any => {
const g = (guzhen as any)?.features?.[0]?.geometry
if (!g) return { type: 'FeatureCollection', features: [] }
// 使用边界外环构造 turf 多边形
let wgsOuter: [number, number][] = []
if (g.type === 'Polygon') {
wgsOuter = (g.coordinates?.[0] || []) as [number, number][]
} else if (g.type === 'MultiPolygon') {
wgsOuter = (g.coordinates?.[0]?.[0] || []) as [number, number][]
}
if (!wgsOuter || wgsOuter.length < 3) return { type: 'FeatureCollection', features: [] }
const wgsPolygon = turfPolygon([wgsOuter])
const box = turfBbox(wgsPolygon)
const radius = 1.5
// 生成 5 公里六边形网格
const grid = turfHexGrid(box, radius, { units: 'kilometers' } as any)
// 过滤与多边形相交的六边形
const features: any[] = []
for (const cell of grid.features || []) {
try {
if (turfBooleanIntersects(cell as any, wgsPolygon as any)) {
const ring: [number, number][] = (cell.geometry as any)?.coordinates?.[0] || []
if (Array.isArray(ring) && ring.length > 0) {
// 计算六边形内的站点数据
const hexData = calculateHexagonData(cell)
const bdCoords = ring.map(([lon, lat]) => wgs84tobd09(lon, lat))
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: bdCoords },
properties: {
type: 'hex',
radius_km: radius,
totalUp: hexData.totalUp,
stationCount: hexData.stationCount,
hexagonId: features.length,
},
})
}
}
} catch (_e) {}
}
return { type: 'FeatureCollection', features }
}
//传入数据
const hexSource = mapvthree.GeoJSONDataSource.fromGeoJSON(hexLinesFC() as any)
const hexLayer = engine.add(
new mapvthree.Polyline({
flat: true,
lineWidth: 1.5,
keepSize: true,
color: '#7A7AFF',
}),
)
hexLayer.dataSource = hexSource
目前的基础效果就是这个样子:
![]()
蜂窝区块可视化
现在我们要让这些六边形更加的层次分明,要用颜色和透明度来直观展示客流密度分布,让数据更可视化。我们使用 THREE.js 的 LineSegments 来绘制六边形边框,为了实现更吊的热力图效果。上面的 mapvthree 蜂窝可以暂时隐藏,专注于我们自定义效果的实现。
1. 六边形边框着色
接着我们使用 HSL色彩空间实现根据蜂窝内的总下车人数从绿色到红色的自然过渡
const createHexagonLineSegments = () => {
const hexData = hexLinesFC()
const vertices = []
const colors = []
// 找到客流最大的六边形作为基准
const maxTotalUp = Math.max(...hexData.features.map((f) => f.properties.totalUp))
for (const feature of hexData.features) {
const { totalUp, stationCount } = feature.properties
const coords = feature.geometry.coordinates
// 根据客流数据调色
let heatColor = new THREE.Color()
if (stationCount > 0) {
const intensity = totalUp / maxTotalUp
// 从绿色到红色渐变
heatColor.setHSL(0.33 - intensity * 0.33, 1.0, 0.5)
} else {
// 没有站点的区域保持灰色
heatColor.setHSL(0, 0, 0.3)
}
// 设置颜色
for (let i = 0; i < coords.length - 1; i++) {
const [x1, y1] = bd09ToMercator(coords[i][0], coords[i][1])
const [x2, y2] = bd09ToMercator(coords[i + 1][0], coords[i + 1][1])
vertices.push(x1, y1, 0, x2, y2, 0)
colors.push(heatColor.r, heatColor.g, heatColor.b)
colors.push(heatColor.r, heatColor.g, heatColor.b)
}
}
// 创建几何体,让每条线都有颜色
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
const material = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.8,
})
return new THREE.LineSegments(geometry, material)
}
好的,上色之后我们可以很直观的看到哪里的客流多:
![]()
2. 填充六边形
光有边框还不够,我们再来给热力图填充颜色。半透明的填充让整个热力图数据效果看起来更加直观,视觉层次也更丰富。
const createHexagonFillPolygons = () => {
const hexData = hexLinesFC()
const polygons = []
for (const feature of hexData.features) {
const { totalUp, stationCount } = feature.properties
const coords = feature.geometry.coordinates
if (stationCount === 0) continue
// 填充着色
const intensity = totalUp / maxTotalUp
const heatColor = new THREE.Color()
heatColor.setHSL(0.33 - intensity * 0.33, 1.0, 0.5)
// 创建三角形面片
const vertices = []
const center = calculateCenter(coords)
for (let i = 0; i < coords.length - 1; i++) {
const [x1, y1] = bd09ToMercator(coords[i][0], coords[i][1])
const [x2, y2] = bd09ToMercator(coords[i + 1][0], coords[i + 1][1])
vertices.push(center.x, center.y, 0, x1, y1, 0, x2, y2, 0)
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
const material = new THREE.MeshBasicMaterial({
color: heatColor,
transparent: true,
opacity: 0.2,
side: THREE.DoubleSide,
})
polygons.push(new THREE.Mesh(geometry, material))
}
return polygons
}
这样,我们的热力图就有了底层边框和内部填充:
![]()
看起来蛮吊的,还能不能更唬人一点
3. 添加扫光shader效果
扫描效果还是非常适合这种网格的面,采用从左上到右下的渐变矩形扫光 大致效果如图所示:
![]()
const createSweepShaderMaterial = () => {
return new THREE.ShaderMaterial({
uniforms: {
time: { value: 0.0 },
sweepColor: { value: new THREE.Color(0x00ffa8) },
sweepSpeed: { value: 0.5 },
},
vertexShader: `
attribute vec3 color;
varying vec3 vColor;
varying vec2 vUv;
void main() {
vColor = color;
vUv = position.xy;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float time;
uniform vec3 sweepColor;
uniform float sweepSpeed;
varying vec3 vColor;
varying vec2 vUv;
void main() {
uniform float time;
uniform vec3 sweepColor;
uniform float sweepWidth;
uniform float sweepSpeed;
uniform float glowIntensity;
varying vec2 vUv;
varying vec3 vColor;
varying float vOpacity;
void main() {
float sweepPos = mod(time * sweepSpeed, 2.0);
float diagonalDist = (vUv.x + (1.0 - vUv.y)) * 0.5;
float dist = abs(diagonalDist - sweepPos);
// 光衰减和柔尾
float gradient = 1.0 - smoothstep(0.0, sweepWidth, dist);
float softGlow = exp(-dist / (sweepWidth * 0.3));
float sweep = mix(gradient, softGlow, 0.5);
// 脉冲
sweep *= 0.7 + 0.3 * sin(time * 8.0);
sweep = clamp(sweep, 0.0, 1.0);
// 混色 和 发光
vec3 finalColor = mix(vColor, sweepColor, sweep);
finalColor += sweepColor * sweep * glowIntensity;
// bloom 触发
finalColor *= 10.0;
gl_FragColor = vec4(finalColor, vOpacity);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
})
}
扫光效果就出来了,看起来很科幻,这领导看不得拍手叫好?
![]()
区域掩膜效果
为了突出更加沉浸的显示目标区域,我们创建一个黑色掩膜来遮挡区域外的内容,让观众的注意力集中在目标区域。
实现的步骤:
- 世界矩形 - 创建覆盖整个地球的大矩形
- 区域掏空 - 将目标区域从世界矩形中挖出
- 黑色填充 - 使用黑色填充
// 创建区域掩膜
const buildMaskFC = () => {
const boundary = guzhen.features[0].geometry
// 世界矩形
const worldRect = [
[-180, -85],
[180, -85],
[180, 85],
[-180, 85],
[-180, -85],
]
// 目标区域作为洞
const hole = boundary.coordinates[0].map(([lon, lat]) => wgs84tobd09(lon, lat))
return {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [worldRect, hole], // 外环 + 内环
},
}
}
const maskLayer = engine.add(
new mapvthree.Polygon({
flat: true,
color: '#0D161C',
opacity: 1,
}),
)
maskLayer.dataSource = mapvthree.GeoJSONDataSource.fromGeoJSON(buildMaskFC())
效果如图,会更专注聚焦这个区域
![]()
站点粒子效果
最后为所有公交站点添加发光粒子效果,能够清晰的看到站点分布在蜂窝的情况,我们使用threejs的粒子Points,并让他发光以增强效果,首先将Point位置投影到站点实际的位置 然后使用canvas为粒子创建纹理材质,最后增加亮度触发Bloom即可~
const createStationParticles = () => {
const positions = []
// 收集坐标
for (const station of stands) {
for (const stand of station.stands) {
const [x, y] = bd09ToMercator(
wgs84tobd09(stand.lon, stand.lat)[0],
wgs84tobd09(stand.lon, stand.lat)[1],
)
positions.push(x, y, 0)
}
}
// 创建粒子
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
// 创建纹理
const texture = createCircleTexture(64, '#ffffff')
// 创建发光材质
const material = new THREE.PointsMaterial({
size: 5,
map: texture,
transparent: true,
blending: THREE.AdditiveBlending,
})
// 触发泛光
material.color.setRGB(4, 4, 4)
return new THREE.Points(geometry, material)
}
// 生成纹理
const createCircleTexture = (size, color) => {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = size
const ctx = canvas.getContext('2d')
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
ctx.fill()
return new THREE.CanvasTexture(canvas)
}
![]()
技术要点
1. 空间计算
主要的实现还是靠turf,turf真是对数学不好的开发的一种福音啊,好用爱用, 拿到边界数据使用bbox计算出边界,然后在这个包围盒通过turfHexGrid生成Hexagon蜂窝,最后用booleanContains裁剪掉地区边界外的蜂窝。
2. 颜色生成
先将客流的数据都维持在0-1之间,这里也叫数据归一化,然后更具数值为设置HSL颜色也就是类似css的rab(255,255,255) 这种写法。
3. Shader
shader glsl不像js那样可以打印调试,完全靠抽象的脑补,这里主要的步骤: 位置计算 → 距离场 → 光脉冲 → 合成
总结
区域客流可视化通过六边形蜂窝网格和热力效果,除了能把复杂的空间数据转化为直观的视效,还结合扫光动画和粒子效果增加视觉体验。
下一篇我们将继续实现实时公交
实现无缝滚动无滚动条的 Element UI 表格(附完整代码)
实现无缝滚动无滚动条的 Element UI 表格(附完整代码)
在后台管理系统或数据监控场景中,经常需要实现表格无缝滚动展示数据,同时希望隐藏滚动条保持界面整洁。本文将基于 Element UI 实现一个 无滚动条、无缝循环、hover 暂停、状态高亮 的高性能滚动表格,全程流畅无卡顿,适配多浏览器。
![]()
最终效果
- 🚀 无缝循环滚动,无停顿、无跳跃
- 🚫 视觉上完全隐藏滚动条,保留滚动功能
- 🛑 鼠标悬浮自动暂停,离开恢复滚动
- 🌈 支持状态字段高亮(如不同状态显示不同颜色)
- 🎨 美观的表格样式,hover 行高亮反馈
- 🛠 高度可配置(行高、滚动速度、表格高度等)
技术栈
- Vue 2 + Element UI(适配 Vue 2 项目,Vue 3 可快速迁移)
- SCSS(样式模块化,便于维护)
实现思路
- 无缝滚动核心:通过「数据拼接」(原数据 + 原数据副本)实现视觉上的无限循环,滚动到原数据末尾时瞬间重置滚动位置,无感知切换
- 隐藏滚动条:多浏览器兼容 CSS 屏蔽滚动条样式,同时预留滚动条宽度避免内容裁剪
-
流畅滚动优化:避免 DOM 频繁重绘,用
scrollTop控制滚动,关闭平滑滚动避免停顿 - 交互增强:hover 暂停滚动、行 hover 高亮、状态字段颜色区分
配置说明
| 参数名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tableData |
Array | [] | 表格数据源(必传) |
columns |
Array | [] | 列配置(必传,支持 statusConfig 状态样式) |
rowHeight |
Number | 36 | 行高(单位:px) |
scrollSpeed |
Number | 20 | 滚动速度(毫秒 / 像素),值越小越快 |
scrollPauseOnHover |
Boolean | true | 鼠标悬浮是否暂停滚动 |
tableHeight |
Number | 300 | 表格高度(父组件配置) |
完整代码实现
1. 滚动表格组件(SeamlessScrollTable.vue)
<template>
<div class="tableView">
<el-table
:data="combinedData"
ref="scrollTable"
style="width: 100%"
height="100%"
@cell-mouse-enter="handleMouseEnter"
@cell-mouse-leave="handleMouseLeave"
:cell-style="handleCellStyle"
:show-header="true"
>
<el-table-column
v-for="(column, index) in columns"
v-bind="column"
:key="index + (column.prop || index)"
:min-width="column.minWidth || '100px'"
>
<template slot-scope="scope">
<span v-if="column.statusConfig" :class="getColumnStatusClass(column, scope.row)">
{{ scope.row[column.prop] }}
</span>
<span v-else>
{{ scope.row[column.prop] }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
name: 'SeamlessScrollTable',
props: {
tableData: {
type: Array,
required: true,
default: () => [],
},
columns: {
type: Array,
required: true,
default: () => [],
},
rowHeight: {
type: Number,
default: 36,
},
scrollSpeed: {
type: Number,
default: 20, // 滚动速度(毫秒/像素),20-40ms
},
scrollPauseOnHover: {
type: Boolean,
default: true,
},
},
data() {
return {
autoPlay: true,
timer: null,
offset: 0,
combinedData: [], // 拼接后的数据,用于实现无缝滚动
}
},
computed: {
// 计算表格可滚动的总高度(仅当数据足够多时才滚动)
scrollableHeight() {
return this.tableData.length * this.rowHeight
},
// 表格容器可视高度
viewportHeight() {
return this.$refs.scrollTable?.$el.clientHeight || 0
},
},
watch: {
tableData: {
handler(newVal) {
// 数据变化时,重新拼接数据
this.combinedData = [...newVal, ...newVal]
this.offset = 0
this.restartScroll()
},
immediate: true,
deep: true,
},
autoPlay(newVal) {
newVal ? this.startScroll() : this.pauseScroll()
},
},
mounted() {
this.$nextTick(() => {
// 只有当数据总高度 > 可视高度时,才启动滚动
if (this.scrollableHeight > this.viewportHeight) {
this.startScroll()
}
})
},
beforeDestroy() {
this.pauseScroll()
},
methods: {
handleMouseEnter() {
this.scrollPauseOnHover && (this.autoPlay = false)
},
handleMouseLeave() {
this.scrollPauseOnHover && (this.autoPlay = true)
},
startScroll() {
this.pauseScroll()
const tableBody = this.$refs.scrollTable?.bodyWrapper
if (!tableBody || this.tableData.length === 0) return
this.timer = setInterval(() => {
if (!this.autoPlay) return
this.offset += 1
tableBody.scrollTop = this.offset
// 关键:当滚动到原数据末尾时,瞬间重置滚动位置到开头
if (this.offset >= this.scrollableHeight) {
this.offset = 0
tableBody.scrollTop = 0
}
}, this.scrollSpeed)
},
pauseScroll() {
this.timer && clearInterval(this.timer)
this.timer = null
},
restartScroll() {
this.pauseScroll()
if (this.scrollableHeight > this.viewportHeight) {
this.startScroll()
}
},
getColumnStatusClass(column, row) {
const statusKey = column.statusField || column.prop
const statusValue = row[statusKey]
return typeof column.statusConfig === 'function'
? column.statusConfig(statusValue, row)
: column.statusConfig[statusValue] || ''
},
handleCellStyle() {
return {
padding: '4px 0',
height: `${this.rowHeight}px`,
lineHeight: `${this.rowHeight}px`,
}
},
},
}
</script>
<style scoped lang="scss">
.tableView {
width: 100%;
height: 100%;
overflow: hidden;
::v-deep .el-table {
background-color: transparent;
color: #303133;
border-collapse: separate;
border-spacing: 0;
&::before {
display: none;
}
th.el-table__cell.is-leaf {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: transparent !important;
font-weight: 500;
color: rgba(0, 0, 0, 0.6);
padding: 8px 0;
}
tr.el-table__row {
background-color: transparent;
transition: background-color 0.2s ease;
&:hover td {
background-color: rgba(0, 0, 0, 0.02) !important;
}
}
.el-table__cell {
border: none;
padding: 4px 0;
.cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 8px;
}
}
.el-table__body-wrapper {
height: 100%;
scroll-behavior: auto;
&::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
}
::v-deep .status-warning {
color: #e6a23c;
font-weight: 500;
}
::v-deep .status-danger {
color: #f56c6c;
font-weight: 500;
}
::v-deep .status-success {
color: #67c23a;
font-weight: 500;
}
::v-deep .status-info {
color: #409eff;
font-weight: 500;
}
}
</style>
2. 父组件使用示例(TableIndex.vue)
<template>
<div class="table-container">
<h2 class="table-title">设备状态监控表格</h2>
<div class="table-wrapper" :style="{ height: tableHeight + 'px' }">
<!-- 配置滚动参数 -->
<seamless-scroll-table
:table-data="tableData"
:columns="columns"
:row-height="36"
:scroll-speed="30"
/>
</div>
</div>
</template>
<script>
import SeamlessScrollTable from './SeamlessScrollTable.vue'
export default {
name: 'DeviceStatusTable',
components: { SeamlessScrollTable },
data() {
return {
tableHeight: 300, // 表格高度可配置
// 表格数据
tableData: [
{ id: '1001', name: '设备A', type: '温度', state: '待检查' },
{ id: '1002', name: '设备B', type: '压力', state: '已超期' },
{ id: '1003', name: '设备C', type: '湿度', state: '已完成' },
{ id: '1004', name: '设备D', type: '电压', state: '超期完成' },
{ id: '1005', name: '设备E', type: '电流', state: '待检查' },
{ id: '1006', name: '设备F', type: '电阻', state: '已超期' },
{ id: '1007', name: '设备G', type: '功率', state: '已完成' },
],
// 列配置
columns: [
{ prop: 'id', label: '编号', minWidth: '140px' },
{ prop: 'name', label: '名称', width: '100px' },
{ prop: 'type', label: '设备类型', width: '120px' },
{
prop: 'state',
label: '状态',
width: '100px',
statusField: 'state',
// 状态样式配置(支持对象/函数)
statusConfig: {
待检查: 'status-warning',
已超期: 'status-danger',
已完成: 'status-success',
超期完成: 'status-info',
},
},
],
}
},
methods: {
getStatusClass(state) {
const statusMap = {
待检查: 'status-warning',
已超期: 'status-danger',
已完成: 'status-success',
超期完成: 'status-info',
}
return statusMap[state] || ''
},
},
}
</script>
<style scoped lang="scss">
.table-container {
width: 100%;
max-width: 500px;
margin: 0 auto;
padding: 20px;
box-sizing: border-box;
}
.table-title {
color: #303133;
margin-bottom: 16px;
font-size: 18px;
font-weight: 500;
text-align: center;
position: relative;
}
.table-wrapper {
background-color: #ffffff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
box-sizing: border-box;
}
</style>