首页
关于
壁纸
Search
1
新泰寺山
4 阅读
2
h5移动端项目总结
3 阅读
3
生活随笔
2 阅读
4
vue 中怎么实现样式隔离?
1 阅读
5
vue3-vite-语法大全
1 阅读
前端笔记
生活随笔
旅行见闻
Search
七叶
累计撰写
41
篇文章
累计收到
1
条评论
首页
栏目
前端笔记
生活随笔
旅行见闻
页面
关于
壁纸
搜索到
41
篇与
的结果
2026-04-29
h5移动端项目总结
项目概述金贝移动端是一个面向房产经纪人的综合性商机管理平台,提供潜客包、准客包、钻展、商机直通车、CPL/CPT、金贝会员等多种商机产品的购买、管理和跟进服务。项目采用前后端分离架构,前端使用 React 技术栈,后端使用 Node.js 中间层,支持多端(APP 内嵌、H5)访问。核心业务模块潜客包/准客包管理:商机包的购买、查看、跟进、转委托、退名额等全流程管理钻展(抢购、报买):广告位购买与管理商机直通车:CPA 商机购买CPL/CPT:按线索/按时间计费的商机包金贝会员:会员权益管理订单管理:订单列表、详情、支付数据看板:商机数据统计与分析预算池:预算管理与分配🛠️ 技术栈前端技术栈技术版本用途React^16.8.6UI 框架Redux^4.0.1状态管理React Router^5.1.0路由管理antd-mobile^2.3.1 / ^5.34.0UI 组件库(v2 + v5 混用)dayjs^1.8.8日期时间处理axios^0.19.2HTTP 请求库webpack^5.75.0构建工具less^4.1.3CSS 预处理器react-hot-loader^4.3.12热更新@antv/f2^3.8.6移动端图表库后端技术栈Node.js (v12)TypeScriptExpress/Koa (中间层)Redis (缓存)开发工具Babel:ES6+ 转译ESLint:代码规范PostCSS:CSS 后处理(px2rem、px-to-viewport)vconsole:移动端调试工具第三方服务集成✨ 项目亮点完整的商机处理闭环设计业务价值:实现了从商机领取到关闭的全流程管理,提升经纪人工作效率。技术实现:统一的交互入口:通过 bottomAreaClick 方法统一处理所有商机操作多场景适配:根据商机来源(opptySource)、类型(新房/二手/租赁)、状态(opptyStatus)动态展示操作按钮操作类型包括:加私/转委托:根据 conversionType 动态展示,支持预校验和跳转联系:区分电话(contactType === 1)和 IM(contactType === 2)记录跟进:弹窗录入,支持局部数据更新申请退名额:复杂的时间规则和风控校验查看工单:跳转司南工单系统代码示例:javascript 体验AI代码助手 代码解读复制代码bottomAreaClick = (e, item, desc, callBack) => { const { opptyId, opptyProcessVo, custId } = item const { contactType, conversionType } = opptyProcessVo e.stopPropagation() switch (desc) {case '加私': this.openXinfang(conversionPageScheme, opptyId) break case '联系': contactType == 2 ? this.imCustomer(custId, opptyId) : this.callTel(opptyId) break // ... 其他操作}}复杂状态标签体系的可视化呈现业务价值:通过标签系统清晰展示商机状态,降低业务理解成本。技术实现:动态标签生成:根据 conventionLevel、isGiven、isWeihupan 等状态动态拼装标签数组交互式标签:点击特定标签(赠送、已联系、未联系、已委托)显示业务提示样式差异化:不同标签使用不同颜色和样式(如 赠送 红色、维护盘商机 金色背景)代码示例:javascript 体验AI代码助手 代码解读复制代码// 标签动态生成if (isGiven && Array.isArray(tags)) { tags.unshift('赠送')}if (+conventionLevel === -1) { tags.push('待委托')}if (+conventionLevel === 0) { tags.push('已联系')}if (+conventionLevel === 1) { tags.push('已委托')}// 标签点击交互isGivenClick = (evt, item, shangjiItem) => { if (item === '已联系') {if (shangjiItem.opptySource === '400') { Toast.info('您已拨打过客户电话', 2) } else { Toast.info('您已回复过客户消息', 2) }}}多端跳转与埋点闭环业务价值:打通 APP、H5、工单系统等多个平台,实现数据追踪和运营分析。技术实现:统一埋点封装:sendDig(clickId, opptyId) 方法统一上报点击事件多端跳转封装:通过 Utils 工具类封装拨打电话、IM 联系、跳转客户端等功能环境区分:根据 window.location.host 区分测试和正式环境,使用不同的跳转链接埋点数据:包含 opptyId、agent_ucid、click_id、c_uicode 等关键字段代码示例:javascript 体验AI代码助手 代码解读复制代码sendDig(clickId, opptyId) { if (window.$ULOG) {window.$ULOG.send(this.evtId, { event: 'mModuleClick', action: { opptyId, c_uicode: 'qiankebao_qiankebaoliebiao', click_id: clickId, agent_ucid: window._GLOBAL_DATA.userInfo.id, }, })}}列表性能与体验优化业务价值:提升移动端加载速度和用户体验,降低服务器压力。技术实现:无限滚动:使用 antd-mobile-v5 的 InfiniteScroll 组件实现分页加载滚动位置保持:记录跟进后恢复滚动位置,避免页面跳回顶部局部数据更新:通过回调函数 addTempRecordData 更新列表中的单条数据,避免全量刷新按需插入数据:根据权限和数据返回情况动态插入数据概览卡片代码示例:javascript 体验AI代码助手 代码解读复制代码// 无限滚动<InfiniteScroll loadMore={() => {this.getPackages()}} hasMore={this.state.packages.pageNum < this.state.packages.totalPage} threshold={120}/>// 滚动位置保持record = (data) => { this.setState({ textareaModal: false }) KeFetch(api.saveRecord, { method: 'post', data }).then(() => {document.documentElement.scrollTo(0, this.ScroolTop) this.genjiCallBack && this.genjiCallBack(data)})}统一请求封装与错误处理业务价值:统一 API 调用规范,提升代码可维护性和错误处理能力。技术实现:KeFetch 封装:基于 Ketch 库封装统一请求方法多环境适配:支持 Node 层(SUCCESS_CODE_NODE: 100000)和 H5 层(SUCCESS_CODE_H5: 1)不同的成功码统一错误提示:自动处理错误码并展示 Toast 提示超时控制:默认 20s 超时🎯 项目难点难点一:多状态、多来源商机的分支逻辑复杂问题描述:不同商机来源(OPPTY_POOL、SSC、CUSTOMER_CLUE、400 等)有不同的处理逻辑不同商机类型(新房/二手/租赁)需要不同的跳转路径不同场景(OPPTY、ZHUN_KE_BAO、B_PLUS、CPS)影响功能展示解决方案:统一入口函数:通过 bottomAreaClick 方法统一处理所有操作,内部根据 desc 参数分支处理预校验封装:将复杂的校验逻辑抽离成独立方法(如 delegatePrecheck、refundPrecheck)状态机模式:使用 switch-case 清晰表达不同操作的处理流程配置化:通过 getOpptyType() 方法统一获取场景类型,避免重复判断代码示例:javascript 体验AI代码助手 代码解读复制代码getOpptyType = () => { const { type } = Utils.getUrlParams() if (type === ZHUN_KE_BAO) return ZHUN_KE_BAO else if (type === B_PLUS) return B_PLUS else if (type === CPS) return CPS else return OPPTY}// 转委托预校验inputCustomer = async (d, conversionPageScheme) => { if (d.opptySource == 'OPPTY_POOL' || d.opptySource == 'SSC') {KeFetch(api.delegatePrecheck, { data: { opptyId: d.opptyId } }) .then((data) => { if (data.resultCode == 1) { // 继续转委托流程 } else { Modal.alert('提示', data.tip || '') } })}}收获:通过统一入口和预校验封装,将复杂的业务逻辑集中管理,提升了代码的可维护性和可扩展性。难点二:时间规则与风控、退款规则耦合问题描述:申请退名额需要满足多个条件:商机下发时间需满 25 小时风控拦截:14 天内提交不通过工单超过 3 次后端预校验:refundPrecheck 接口返回是否允许退款时间计算和展示需要精确到秒解决方案:时间库统一:使用 dayjs 统一处理所有时间计算和格式化状态分离:将不同场景拆解成独立的 Modal 状态(antiCheatModal、refundModal)链式调用:使用 dayjs(opptyTime).add(25, 'hour') 等链式 API 提升可读性用户提示:在弹窗中明确展示截止时间,提升用户体验代码示例:javascript 体验AI代码助手 代码解读复制代码case '申请退名额': const { opptyStatus, opptyTime } = item const deadline = dayjs(opptyTime).add(25, 'hour')if (opptyStatus === 4) {// 风控拦截 this.setState({ antiCheatModal: true })} else if (!dayjs().isAfter(deadline)) {// 时间未到 this.setState({ refundModal: true, deadline: deadline.format('YYYY-MM-DD HH:mm:ss'), })} else {// 后端预校验 KeFetch(api.refundPrecheck, { data: { opptyId } }).then((res) => { const { refund, remark } = res if (remark) { Modal.alert('提示', remark, [/* ... */]) } else if (!refund) { this.onNext(item) // 跳转司南 } })} break收获:通过时间库统一和状态分离,将复杂的业务规则清晰地表达出来,便于后续根据运营策略调整。难点三:列表内局部数据更新与用户滚动位置保持问题描述:用户在列表中点击「记录跟进」后,需要更新对应商机的 opptyBrief 数据更新后页面不能跳回顶部,需要保持用户当前的滚动位置需要支持新增和更新两种场景解决方案:滚动位置缓存:在打开弹窗前记录当前滚动位置 this.ScroolTop回调函数传递:通过 bottomAreaClick 的 callBack 参数传递更新函数局部状态更新:在子组件中通过 useState 维护列表状态,通过 addTempRecordData 方法更新单条数据恢复滚动位置:接口成功后调用 document.documentElement.scrollTo(0, this.ScroolTop)代码示例:javascript 体验AI代码助手 代码解读复制代码// 父组件:记录滚动位置case '记录跟进': this.genjiCallBack = callBack this.ScroolTop = document.body.scrollTop || document.documentElement.scrollTop this.genji(opptyId, opptyRemarkVo || {}) break// 父组件:恢复滚动位置record = (data) => { this.setState({ textareaModal: false }) KeFetch(api.saveRecord, { method: 'post', data }).then(() => {document.documentElement.scrollTo(0, this.ScroolTop) this.genjiCallBack && this.genjiCallBack(data)})}// 子组件:局部数据更新const addTempRecordData = recordData => { const newData = opptyListState.map(item => {if (item.opptyId === recordData.opptyId) { if (Array.isArray(item.opptyBrief)) { const result = item.opptyBrief.filter(item2 => item2.name === '跟进反馈') if (result.length <= 0) { item.opptyBrief.push({ name: '跟进反馈', desc: `${recordData.opptyFlagName},${recordData.remark}`, }) } else { item.opptyBrief.map(item2 => { if (item2.name === '跟进反馈') { item2.desc = `${recordData.opptyFlagName},${recordData.remark}` } return item2 }) } } } return item}) setOpptyListState(newData)}收获:通过滚动位置缓存和回调函数机制,实现了局部数据更新和用户体验的平衡,避免了全量刷新带来的性能问题。难点四:老项目技术栈混用带来的兼容问题问题描述:项目中同时使用 antd-mobile v2 和 v5 两个版本类组件和函数组件混用(QianKe 是类组件,waitDeal 是函数组件)需要兼容不同版本的 API 和组件特性解决方案:渐进式升级:新功能使用新版本(如 InfiniteScroll 使用 v5),老功能保持原样统一接口设计:通过 props 和回调函数统一组件间的通信接口工具函数封装:将通用逻辑抽离成工具函数,避免重复代码文档记录:在代码注释中标注版本差异和注意事项代码示例:javascript 体验AI代码助手 代码解读复制代码// 使用 v5 的 InfiniteScrollimport { InfiniteScroll } from 'antd-mobile-v5'// 使用 v2 的 Modal、Toastimport { Modal, Toast } from 'antd-mobile'// 统一回调接口<QkbOrder data={item} onClick={this.itemClick} onSpeedClick={this.executeSpeed} bottomAreaClick={this.bottomAreaClick} purposeValiageClick={this.purposeValiageClick}/>收获:通过渐进式升级和统一接口设计,在保证项目稳定性的同时,逐步引入新技术,为后续整体重构打下基础。📁 项目结构bash 体验AI代码助手 代码解读复制代码fe-banner-pub-mobile/├── client/ # 前端代码│ ├── src/│ │ ├── components/ # 公共组件│ │ │ └── QkbOrder/ # 潜客包订单组件│ │ │ └── component/│ │ │ └── waitDeal.js # 待处理商机列表│ │ ├── containers/ # 页面容器│ │ │ └── ShangJi/ # 商机模块│ │ │ ├── Package/ # 我的商品│ │ │ │ └── Qianke/ # 潜客包页面│ │ │ ├── Home/ # 首页│ │ │ ├── Data/ # 数据看板│ │ │ └── Order/ # 订单管理│ │ ├── config/ # 配置文件│ │ │ ├── apiConfig.js # API 配置│ │ │ └── digConfig.js # 埋点配置│ │ ├── utils/ # 工具函数│ │ │ ├── keFetch.js # 请求封装│ │ │ └── storage.js # 本地存储│ │ ├── router/ # 路由配置│ │ ├── store/ # Redux store│ │ └── App.js # 根组件│ ├── webpack/ # Webpack 配置│ └── package.json├── server/ # Node.js 中间层│ ├── src/│ │ ├── apis/ # API 接口│ │ ├── actions/ # 业务逻辑│ │ └── configs/ # 配置文件│ └── package.json└── README.md🔧 核心功能实现潜客包列表加载javascript 体验AI代码助手 代码解读复制代码getPackages(Kdata) { const pageNum = (this.state.packages.pageNum || 0) + 1 const scene = this.getOpptyType()KeFetch(api.getPackages, { data: { cityCode: workCity(), pageSize: 10, pageNum, scene, }, }).then((data) => { // 根据权限动态插入数据概览 if (data.statisticsShow && Kdata && scene === OPPTY) { const hasDataOverviewPermission = (window._GLOBAL_DATA.userInfo.perms || []) .includes('BRAND_M_qianke_dataOverview')if (!this.hasInsert && hasDataOverviewPermission) { data.list.unshift({ ...Kdata, type: 'qkDataOverview' }) this.hasInsert = true} }data.list = [...(this.state.packages.list || []), ...data.list] this.setState({ packages: data, loading: false, }) })}虚拟号码拨打javascript 体验AI代码助手 代码解读复制代码callTel = (opptyId) => { this.sendDig(10016, opptyId) KeFetch(api.getShangjiVirtualPhone, { data: { opptyId } }) .then((data) => { if (data.virtualPhone) { Modal.alert( `客户电话${data.virtualPhone}`, '此号码为虚拟号码,非客户真实号码', [ { text: '取消', onPress: () => {} }, { text: '立即拨打', onPress: () => Utils.callTelphone(data.virtualPhone) }, ] )} })}转委托流程javascript 体验AI代码助手 代码解读复制代码inputCustomer = async (d, conversionPageScheme) => { // 预校验 if (d.opptySource == 'OPPTY_POOL' || d.opptySource == 'SSC') { const precheckData = await KeFetch(api.delegatePrecheck, { data: { opptyId: d.opptyId }, }) if (precheckData.resultCode !== 1) { Modal.alert('提示', precheckData.tip || '') return } }// 执行转委托 const res = await KeFetch(api.delegateQiankeOpportunity, { data: { opptyId: d.opptyId }, })if (res.result) { Modal.alert('提示', res.resultDesc || '', [ { text: '去编辑委托', onPress: () => { Utils.inputCustomer(() => { Modal.alert('请先将客户端升级至最新版本', '', [ { text: '取消' }, { text: '立即升级', onPress: () => Utils.openAbout() }, ]) }, res.conversionPageScheme) },}, ]) }}📊 性能优化代码分割:使用 Webpack 的 code splitting 按路由分割代码图片优化:使用 url-loader 和 file-loader 处理图片资源CSS 优化:使用 mini-css-extract-plugin 提取 CSS,使用 px2rem 适配移动端无限滚动:使用 InfiniteScroll 实现分页加载,避免一次性加载大量数据局部更新:通过回调函数更新局部数据,避免全量刷新🐛 常见问题与解决方案鸿蒙系统 Picker 组件滑动穿透问题:antd-mobile v2 的 Picker 组件在鸿蒙系统滑动选择时会有滑动穿透 bug。解决方案:使用 components/WithTouchMoveControlHoc 高阶组件包裹 Picker 组件。Node 版本兼容问题:client 端使用 Node 14,server 端使用 Node 12。解决方案:使用 nvm 进行 Node 版本切换,或在启动脚本中自动切换。VPN 冲突问题:Node 层加了 Redis 之后,挂了外网 VPN 项目启动不起来。解决方案:关闭 VPN 后再启动项目。🚀 部署流程开发环境:bash 体验AI代码助手 代码解读复制代码npm installnpm start # 自动切换 Node 版本并启动前后端📝 总结技术收获复杂业务逻辑处理:通过统一入口函数和预校验封装,将复杂的业务规则清晰地表达出来用户体验优化:通过滚动位置保持、局部数据更新等技术手段,提升用户操作体验多端适配:通过 JSBridge 和工具函数封装,实现 APP 和 H5 的统一适配性能优化:通过无限滚动、代码分割等技术,提升页面加载速度和运行性能业务理解商机管理全流程:深入理解了从商机产生到关闭的完整业务流程风控规则:理解了时间规则、风控拦截等业务规则的设计思路数据埋点:理解了埋点数据对运营分析和产品优化的重要性项目价值提升效率:通过完整的商机处理闭环,大幅提升经纪人的工作效率规范操作:通过统一的交互入口和校验逻辑,规范了业务操作流程数据支撑:通过完善的埋点体系,为后续的运营分析和策略优化提供了数据支撑作者:用户4481853632859链接:https://juejin.cn/post/7600068631359143942来源:稀土掘金著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
2026年04月29日
3 阅读
2026-04-29
复刻小红书Web端打开详情过渡动画
小红书Web端效果展示先看效果浏览小红书Web端被这种丝滑的过渡吸引,因此想要复刻这种过渡效果。首先想到就是利用FLIP动画实现何为FLIP 动画?一种动画范式,分为四步完成First:记录动画元素的初始位置、状态Last: 移动元素到最终位置,记录元素的最终位置、状态Invert:计算差异并反向应用,让元素"看起来"还在初始位置Play:通过动画过渡到最终状态接下来通过小案例理解上述四步案例1——方块移动First:首先记录下元素的初始位置javascript 体验AI代码助手 代码解读复制代码// 1 First 记录初始状态const first = box.getBoundingClientRect()Last:执行DOM变化,并且记录下最终状态javascript 体验AI代码助手 代码解读复制代码if (isMoved) { box.classList.remove('moved')} else { box.classList.add('moved')}isMoved = !isMoved// 立即获取最终位置,此时元素已经在新的位置,但还没动画const last = box.getBoundingClientRect()此时元素的布局位置已经发生变化,但是由于浏览器没有渲染,因此页面上没有体现Invert: 计算差异并反向应用javascript 体验AI代码助手 代码解读复制代码const deltaX = first.left - last.leftconst deltaY = first.top - last.topconsole.log('位置差异:', { deltaX, deltaY })box.style.transform = translate(${deltaX}px, ${deltaY}px)box.style.transition = 'none'这一步是动画核心:在运用translate(deltaXpx,{deltaX}px, deltaXpx,{deltaY}px) 元素已经在视觉上回到了原始位置。因此用户打开浏览器看到的的方块依然在原地,其实已经经历了 位置左移——》translate回到原地,两个操作那为啥用户看不到其中的变化呢?因为浏览器会聚合同步代码,放在一帧中渲染。这也是FLIP动画非常绝妙的地方。Play:执行动画javascript 体验AI代码助手 代码解读复制代码requestAnimationFrame(() => { box.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)' box.style.transform = 'none'})通过box.style.transform = 'none' 让元素回到布局原点。完整代码:html 体验AI代码助手 代码解读复制代码<!DOCTYPE html> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>FLIP案例1: 单元素移动</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: lightgray; min-height: 100vh; display: flex; justify-content: center; align-items: center; } .container { text-align: center; } .move-btn { padding: 12px 24px; font-size: 16px; background: white; border: none; border-radius: 8px; cursor: pointer; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); margin-bottom: 40px; transition: transform 0.2s; } .move-btn:hover { transform: scale(1.05); } .move-btn:active { transform: scale(0.95); } .box { width: 120px; height: 120px; background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border-radius: 12px; display: flex; align-items: center; justify-content: center; color: white; font-size: 18px; font-weight: bold; box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); margin-left: 0; } .box.moved { margin-left: calc(100vw - 200px); } </style> <div class="container"> <button id="moveBtn" class="move-btn">点击移动方块</button> <div id="box" class="box">方块</div> </div> <script> const moveBtn = document.querySelector('#moveBtn') const box = document.querySelector('#box') let isMoved = false moveBtn.addEventListener('click', () => { // ========== FLIP动画的四个步骤 ========== // 1 First 记录初始状态 const first = box.getBoundingClientRect() console.log('初始位置:', { left: first.left, top: first.top, width: first.width, height: first.height }) // 2 Last 执行DOM变化并记录最终状态 if (isMoved) { box.classList.remove('moved') } else { box.classList.add('moved') } isMoved = !isMoved // 立即获取最终位置,此时元素已经在新的位置,但还没动画 const last = box.getBoundingClientRect() console.log('最终位置:', { left: last.left, top: last.top, width: last.width, height: last.height }) // 3 Invert 计算差异并反向应用 const deltaX = first.left - last.left const deltaY = first.top - last.top console.log('位置差异:', { deltaX, deltaY }) // 此时元素已经被传回了原始位置 box.style.transform = `translate(${deltaX}px, ${deltaY}px)` box.style.transition = 'none' // 4 Play 执行动画 requestAnimationFrame(() => { box.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)' box.style.transform = 'none' }) // 动画结束 回收inline style box.addEventListener( 'transitionend', function cleanup() { box.style.transition = '' box.style.transform = 'none' box.removeEventListener('transitionend', cleanup) }, { once: true } ) }) </script>适用范围肯定有人觉得不是直接通过translate移动就行了么?没错。这个案例只是让你了解FLIP动画的范式FLIP动画有它自己的适用范围,例如:列表排序/过滤:删掉一项后其他项自动补位,每项偏移量不同,你算不过来布局切换:比如从网格视图切到列表视图,每个元素位置都变了这些场景的共同点是:你改了 DOM 或 CSS 类之后,让浏览器布局引擎算出新位置,然后 FLIP 帮你把这个"瞬间跳变"变成平滑动画。小红书过渡复刻首先是页面静态样式html 体验AI代码助手 代码解读复制代码<!doctype html> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>小红书页面切换动画</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 20px; } h1 { text-align: center; font-size: 22px; color: #333; margin-bottom: 4px; } .tip { text-align: center; font-size: 13px; color: #999; margin-bottom: 20px; } /* ====== 卡片列表 - 最简单的flex排列 ====== */ .grid { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; } /* ====== 卡片 ====== */ .card { width: 220px; background: #fff; border-radius: 12px; overflow: hidden; cursor: pointer; } .card-image img { display: block; width: 100%; } .card-title { padding: 10px 12px; font-size: 13px; color: #333; } /* ====== 遮罩 ====== */ .overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.65); z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.35s ease; } .overlay.visible { opacity: 1; pointer-events: auto; } /* ====== 详情弹窗 - 左图右文的简单布局 ====== */ .detail { position: fixed; z-index: -1; background: #fff; border-radius: 12px; overflow: hidden; visibility: hidden; } .detail.visible { display: flex; z-index: 101; visibility: visible; inset: 0; margin: auto; width: fit-content; height: 600px; } /* 弹窗左侧 - 图片 */ .detail-img { background: #f7f7f7; display: flex; align-items: center; justify-content: center; } .detail-img img { width: auto; max-width: 600px; height: 100%; object-fit: contain; display: block; } /* 弹窗右侧 */ .detail-body { width: 0; padding: 24px; overflow-y: auto; transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1); } .detail-body.visible { width: 300px; } .detail-body h2 { font-size: 18px; color: #333; margin-bottom: 12px; } .detail-body p { font-size: 14px; color: #555; line-height: 1.8; white-space: pre-wrap; } /* 关闭按钮 */ .close-btn { position: absolute; top: 12px; right: 12px; width: 30px; height: 30px; border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.4); color: #fff; font-size: 18px; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; } </style> <h1>小红书卡片展开动画</h1> <p class="tip">点击卡片,观察图片从列表位置平滑展开到弹窗的过渡效果</p> <!-- 卡片列表 动态插入 --> <div class="grid" id="grid"></div> <!-- 详情页 --> <!-- 遮罩 --> <div class="overlay" id="overlay"></div> <!-- 详情 --> <div class="detail" id="detail"> <button class="close-btn" id="closeBtn">×</button> <div class="detail-img" id="detailImgWrapper"> <img id="detailImgEl" src="" alt="" /> </div> <div class="detail-body" id="detailBody"> <h2 id="detailTitle"></h2> <p id="detailDesc"></p> </div> </div> <script> const cards = [ { image: '../imgs/test.jpg', title: '春日穿搭分享', desc: '米色针织开衫搭配白色半身裙,\n既舒适又显气质。\n\n搭配要点:\n1. 柔和色调营造温柔感\n2. 针织材质增添春日气息\n3. 配饰简约,突出整体感' }, { image: '../imgs/31-400x600.jpg', title: '咖啡拉花教程', desc: '在家制作拉花其实不难!\n\n步骤:\n1. 制作浓缩咖啡基底\n2. 打发牛奶至细腻光滑\n3. 从中心注入,控制流速\n4. 轻轻摇晃拉花缸' }, { image: '../imgs/451-400x400.jpg', title: '周末野餐攻略', desc: '必带物品:\n- 防水野餐垫\n- 保温箱\n- 便携餐具\n- 遮阳伞\n\n食物推荐:\n三明治、水果拼盘、气泡水' }, { image: '../imgs/507-400x550.jpg', title: '北欧风客厅改造', desc: '设计要点:\n1. 白灰为主色调\n2. 简洁线条家具\n3. 多层次照明\n4. 绿植增添生机\n\n总花费控制在15k以内' }, { image: '../imgs/1008-400x520.jpg', title: '健康早餐食谱', desc: '推荐搭配:\n- 全麦面包 + 煎蛋 + 牛油果\n- 燕麦粥 + 坚果 + 蓝莓\n\n制作时间都在15分钟内!' }, { image: '../imgs/825-400x650.jpg', title: '绝美日落合集', desc: '拍摄技巧:\n1. 日落前30分钟(黄金时段)\n2. 剪影构图\n3. 白平衡偏暖\n4. 低角度拍摄\n\n器材:手机就够了!' } ] const gridEl = document.querySelector('#grid') // 渲染卡片列表 cards.forEach((card) => { const el = document.createElement('div') el.className = 'card' el.innerHTML = ` <div class="card-image"><img src="${card.image}" alt=""></div> <div class="card-title">${card.title}</div> ` el.addEventListener('click', () => open(el, card)) gridEl.appendChild(el) }) </script>这里注意详情页中图片使用object-fit: contain保障了横图或者竖图总能完整呈现按步骤拆解First:首先将详情页定位到点击的卡片图片处,并且长宽与图片一致javascript 体验AI代码助手 代码解读复制代码// 点击卡片的【封面图】const innerCardEl = cardEl.querySelector('.card-image')activeCardEl = innerCardEloverlayEl.classList.add('visible') // 开启遮罩层detailBodyEl.classList.add('visible') // 内容区展开// 填充详情页内容detailImgEl.src = cardData.imagedetailTitleEl.textContent = cardData.titledetailDescEl.textContent = cardData.desc// First - 记录卡片在页面中的位置const firstRect = innerCardEl.getBoundingClientRect()Last:移动DOM,并且记录下最终的状态javascript 体验AI代码助手 代码解读复制代码// Last - 让详情页以最终状态显示,获取最终位置detailEl.classList.add('visible')detailEl.offsetHeightconst lastRect = detailEl.getBoundingClientRect()Invert:通过transform逆向移动到原始位置,让详情页看起来没用发生概念javascript 体验AI代码助手 代码解读复制代码// Invert - 从最终位置反推回卡片位置const deltaX = firstRect.left - lastRect.leftconst deltaY = firstRect.top - lastRect.topconst deltaW = firstRect.width / lastRect.widthconst deltaH = firstRect.height / lastRect.heightdetailEl.style.transformOrigin = 'top left'detailEl.style.transform = translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})Play:开始动画javascript 体验AI代码助手 代码解读复制代码// Play - 动画回到最终位置requestAnimationFrame(() => { requestAnimationFrame(() => {detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)' detailEl.style.transform = 'none' detailEl.addEventListener( 'transitionend', () => { detailEl.style.transition = '' detailEl.style.transform = '' detailEl.style.transformOrigin = '' }, { once: true } )})})关闭的过渡,就是打开的逆向过程javascript 体验AI代码助手 代码解读复制代码 function close() { if (!activeCardEl) return overlayEl.classList.remove('visible')// First - 详情页当前位置(居中状态) const firstRect = detailEl.getBoundingClientRect()// Last - 目标是回到卡片位置 const lastRect = activeCardEl.getBoundingClientRect()// Invert - 从当前居中位置出发,计算到卡片位置的变换 const deltaX = lastRect.left - firstRect.left const deltaY = lastRect.top - firstRect.top const deltaW = lastRect.width / firstRect.width const deltaH = lastRect.height / firstRect.heightdetailEl.style.transformOrigin = 'top left' detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)' detailEl.style.transform = translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})detailEl.addEventListener('transitionend', () => { detailEl.classList.remove('visible') detailBodyEl.classList.remove('visible') detailEl.style.transition = '' detailEl.style.transform = '' detailEl.style.transformOrigin = '' activeCardEl = null }, { once: true })}完整代码:html 体验AI代码助手 代码解读复制代码<!doctype html> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>小红书页面切换动画</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 20px; } h1 { text-align: center; font-size: 22px; color: #333; margin-bottom: 4px; } .tip { text-align: center; font-size: 13px; color: #999; margin-bottom: 20px; } /* ====== 卡片列表 - 最简单的flex排列 ====== */ .grid { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; } /* ====== 卡片 ====== */ .card { width: 220px; background: #fff; border-radius: 12px; overflow: hidden; cursor: pointer; } .card-image img { display: block; width: 100%; } .card-title { padding: 10px 12px; font-size: 13px; color: #333; } /* ====== 遮罩 ====== */ .overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.65); z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.35s ease; } .overlay.visible { opacity: 1; pointer-events: auto; } /* ====== 详情弹窗 - 左图右文的简单布局 ====== */ .detail { position: fixed; z-index: -1; background: #fff; border-radius: 12px; overflow: hidden; visibility: hidden; } .detail.visible { display: flex; z-index: 101; visibility: visible; inset: 0; margin: auto; width: fit-content; height: 600px; } /* 弹窗左侧 - 图片 */ .detail-img { background: #f7f7f7; display: flex; align-items: center; justify-content: center; } .detail-img img { width: auto; max-width: 600px; height: 100%; object-fit: contain; display: block; } /* 弹窗右侧 */ .detail-body { width: 0; padding: 24px; overflow-y: auto; transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1); } .detail-body.visible { width: 300px; } .detail-body h2 { font-size: 18px; color: #333; margin-bottom: 12px; } .detail-body p { font-size: 14px; color: #555; line-height: 1.8; white-space: pre-wrap; } /* 关闭按钮 */ .close-btn { position: absolute; top: 12px; right: 12px; width: 30px; height: 30px; border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.4); color: #fff; font-size: 18px; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; } </style> <h1>小红书卡片展开动画</h1> <p class="tip">点击卡片,观察图片从列表位置平滑展开到弹窗的过渡效果</p> <!-- 卡片列表 动态插入 --> <div class="grid" id="grid"></div> <!-- 详情页 --> <!-- 遮罩 --> <div class="overlay" id="overlay"></div> <!-- 详情 --> <div class="detail" id="detail"> <button class="close-btn" id="closeBtn">×</button> <div class="detail-img" id="detailImgWrapper"> <img id="detailImgEl" src="" alt="" /> </div> <div class="detail-body" id="detailBody"> <h2 id="detailTitle"></h2> <p id="detailDesc"></p> </div> </div> <script> const cards = [ { image: '../imgs/test.jpg', title: '春日穿搭分享', desc: '米色针织开衫搭配白色半身裙,\n既舒适又显气质。\n\n搭配要点:\n1. 柔和色调营造温柔感\n2. 针织材质增添春日气息\n3. 配饰简约,突出整体感' }, { image: '../imgs/31-400x600.jpg', title: '咖啡拉花教程', desc: '在家制作拉花其实不难!\n\n步骤:\n1. 制作浓缩咖啡基底\n2. 打发牛奶至细腻光滑\n3. 从中心注入,控制流速\n4. 轻轻摇晃拉花缸' }, { image: '../imgs/451-400x400.jpg', title: '周末野餐攻略', desc: '必带物品:\n- 防水野餐垫\n- 保温箱\n- 便携餐具\n- 遮阳伞\n\n食物推荐:\n三明治、水果拼盘、气泡水' }, { image: '../imgs/507-400x550.jpg', title: '北欧风客厅改造', desc: '设计要点:\n1. 白灰为主色调\n2. 简洁线条家具\n3. 多层次照明\n4. 绿植增添生机\n\n总花费控制在15k以内' }, { image: '../imgs/1008-400x520.jpg', title: '健康早餐食谱', desc: '推荐搭配:\n- 全麦面包 + 煎蛋 + 牛油果\n- 燕麦粥 + 坚果 + 蓝莓\n\n制作时间都在15分钟内!' }, { image: '../imgs/825-400x650.jpg', title: '绝美日落合集', desc: '拍摄技巧:\n1. 日落前30分钟(黄金时段)\n2. 剪影构图\n3. 白平衡偏暖\n4. 低角度拍摄\n\n器材:手机就够了!' } ] const detailHeight = 742 // 详情页固定高度 const gridEl = document.querySelector('#grid') // 渲染卡片列表 cards.forEach((card) => { const el = document.createElement('div') el.className = 'card' el.innerHTML = ` <div class="card-image"><img src="${card.image}" alt=""></div> <div class="card-title">${card.title}</div> ` el.addEventListener('click', () => open(el, card)) gridEl.appendChild(el) }) const overlayEl = document.querySelector('#overlay') const detailEl = document.querySelector('#detail') const detailImgEl = document.querySelector('#detailImgEl') const detailTitleEl = document.querySelector('#detailTitle') const detailDescEl = document.querySelector('#detailDesc') const closeBtnEl = document.querySelector('#closeBtn') const detailBodyEl = document.querySelector('#detailBody') let activeCardEl = null // 点击卡片打开详情 function open(cardEl, cardData) { const innerCardEl = cardEl.querySelector('.card-image') activeCardEl = innerCardEl overlayEl.classList.add('visible') detailBodyEl.classList.add('visible') detailImgEl.src = cardData.image detailTitleEl.textContent = cardData.title detailDescEl.textContent = cardData.desc // First - 记录卡片在页面中的位置 const firstRect = innerCardEl.getBoundingClientRect() // Last - 让详情页以最终状态显示,获取最终位置 detailEl.classList.add('visible') detailEl.offsetHeight const lastRect = detailEl.getBoundingClientRect() // Invert - 从最终位置反推回卡片位置 const deltaX = firstRect.left - lastRect.left const deltaY = firstRect.top - lastRect.top const deltaW = firstRect.width / lastRect.width const deltaH = firstRect.height / lastRect.height detailEl.style.transformOrigin = 'top left' detailEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})` // Play - 动画回到最终位置 requestAnimationFrame(() => { requestAnimationFrame(() => { detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)' detailEl.style.transform = 'none' detailEl.addEventListener( 'transitionend', () => { detailEl.style.transition = '' detailEl.style.transform = '' detailEl.style.transformOrigin = '' }, { once: true } ) }) }) } function close() { if (!activeCardEl) return overlayEl.classList.remove('visible') // First - 详情页当前位置(居中状态) const firstRect = detailEl.getBoundingClientRect() // Last - 目标是回到卡片位置 const lastRect = activeCardEl.getBoundingClientRect() // Invert - 从当前居中位置出发,计算到卡片位置的变换 const deltaX = lastRect.left - firstRect.left const deltaY = lastRect.top - firstRect.top const deltaW = lastRect.width / firstRect.width const deltaH = lastRect.height / firstRect.height detailEl.style.transformOrigin = 'top left' detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)' detailEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})` detailEl.addEventListener( 'transitionend', () => { detailEl.classList.remove('visible') detailBodyEl.classList.remove('visible') detailEl.style.transition = '' detailEl.style.transform = '' detailEl.style.transformOrigin = '' activeCardEl = null }, { once: true } ) } closeBtnEl.addEventListener('click', close) overlayEl.addEventListener('click', close) </script>
2026年04月29日
0 阅读
2026-04-29
单位:px、em、rem、vw、vh、clamp 怎么选?
单位:px、em、rem、vw、vh、clamp 怎么选?CSS 单位是响应式布局的核心,也是我刚学响应式时踩坑最多的知识点之一——明明写好的尺寸,换个屏幕、调个字体大小就错乱,排查后才发现是单位选得不对。px 是固定值、em/rem 是相对值、vw/vh 跟着视口走、clamp 能做流体排版,掌握它们的区别和用法,响应式开发、页面可访问性都会轻松很多。今天就把我整理的单位干货、实操经验和避坑技巧,分享给大家。一、绝对单位:px 为主,其他慎用(我的日常用法)绝对单位里,我日常开发只用 px(像素),其他单位(mm、cm、in、pt 等)几乎用不到,大多用于打印场景,新手不用花太多精力记忆。css 体验AI代码助手 代码解读复制代码/ px:像素,固定值,不会随任何因素变化 /font-size: 16px;width: 200px;/ 打印场景专用,日常开发基本用不上 /mm, cm, in, pt分享我的实操心得:px 最适合用来设置固定不变的尺寸,比如边框宽度、小间距、图标大小、圆角等,这些地方不需要随字体、视口缩放,用 px 最精准,也最不容易出错。我早期曾用 em 设边框,结果字体一调,边框也跟着变粗,踩过一次坑后就再也不这么做了。二、相对单位:em 好用但易踩坑,嵌套需谨慎em 是相对单位,核心是“相对于当前元素的 font-size”——如果当前元素没设置 font-size,就继承父级的 font-size,这也是它容易踩坑的地方。css 体验AI代码助手 代码解读复制代码.parent { font-size: 16px; }.child { font-size: 1.5em; / 相对于父级16px,就是24px / padding: 1em; / 重点:相对于自己的font-size(24px),就是24px / margin: 0.5em; / 相对于自己的font-size,就是12px /}/ 嵌套会累积,这是我踩过的大坑! /.grandchild { font-size: 1.2em; } / 相对于子元素24px,就是28.8px,越嵌套越大 /避坑提醒:em 最大的问题就是“嵌套累积”,嵌套层级越深,计算出来的尺寸越乱,我早期做导航菜单嵌套时,用 em 设字体,结果二级、三级菜单字体越变越大,排查了很久才找到原因。现在我只用 em 做简单的局部适配,嵌套场景坚决不用。三、相对单位:rem 我的首选,全局缩放超省心后来接触到 rem,直接解决了 em 嵌套累积的痛点!rem 也是相对单位,但它只相对于根元素(html)的 font-size,和父级元素没有关系,不会出现嵌套累积的问题,现在是我做全局布局、字体设置的首选单位。css 体验AI代码助手 代码解读复制代码/ 根元素字体大小,默认通常是16px,我会明确设置,避免浏览器差异 /html { font-size: 16px; }.box { font-size: 1rem; / 相对于html的16px,就是16px / padding: 1.5rem; / 16px×1.5=24px,计算直观 / width: 20rem; / 16px×20=320px,不用复杂换算 /}/ 响应式神器:只需修改根字号,全站元素就会等比缩放 /@media (max-width: 768px) { html { font-size: 14px; } / 小屏缩小根字号,字体、间距、布局同步缩小 /}我的实操用法:rem 适合设置字体大小、容器间距、布局宽度等需要全局适配的样式,配合媒体查询修改根元素 font-size,就能轻松实现全站等比缩放,不用逐个修改每个元素的尺寸,效率大幅提升。四、视口单位:vw、vh、vmin、vmax 做全屏/自适应超方便视口单位是相对于“视口(浏览器可见区域)”的尺寸,和元素、根元素都无关,适合做全屏布局、自适应组件,比如首页Banner、全屏弹窗等,是响应式布局的“好帮手”。css 体验AI代码助手 代码解读复制代码/ vw:视口宽度的 1%,视口宽1000px,1vw就是10px /.full-width { width: 100vw; } / 占满视口宽度 /.half-width { width: 50vw; } / 占视口宽度的一半 // vh:视口高度的 1%,视口高800px,1vh就是8px /.full-height { height: 100vh; } / 占满视口高度 // vmin:vw 和 vh 里较小的那个,适合做正方形自适应 /.square { width: 50vmin; height: 50vmin; } / 始终是正方形,随视口缩放 // vmax:vw 和 vh 里较大的那个,适合做全屏英雄区 /.hero { height: 100vmax; }高频坑点:这几个单位我踩过两个关键的坑,一定要记牢!1. 100vw 会包含浏览器滚动条的宽度,如果页面有纵向滚动条,用 100vw 会导致横向溢出,出现横向滚动条;2. 移动端用 100vh 时,浏览器地址栏显隐会导致高度跳动,体验很差,后来发现用 dvh 就能解决。五、现代视口单位:dvh、svh、lvh 解决移动端高度跳动问题为了解决传统 vh 在移动端的痛点,浏览器新增了 dvh、svh、lvh 三个现代视口单位,我现在做移动端全屏布局,全靠它们,再也不会出现高度跳动的问题。arduino 体验AI代码助手 代码解读复制代码/ dvh:动态视口高度,地址栏显隐时会自动调整高度,最常用、最推荐 /.min-height: 100dvh; / 移动端全屏布局首选,适配所有场景 // svh:小视口高度,仅当浏览器地址栏始终可见时的视口高度 /.min-height: 100svh;/ lvh:大视口高度,仅当浏览器地址栏始终隐藏时的视口高度 /.min-height: 100lvh;我的实操建议:做移动端全屏页面、吸底组件时,优先用 dvh,它能自动适配地址栏的显隐,保证页面高度始终贴合视口,体验更流畅;svh 和 lvh 只用在特殊场景,日常开发很少用到。六、clamp:流体排版神器,不用媒体查询也能自适应clamp 是我近期最常用的“黑科技”,它能实现“流体排版”,语法很简单:clamp(最小值, 首选值, 最大值),意思是在最小值和最大值之间,根据首选值随视口平滑变化,不用写一堆媒体查询,就能实现自适应,极大减少代码量。css 体验AI代码助手 代码解读复制代码/ 字体:最小1.5rem(24px),最大2.5rem(40px),随视口宽度平滑变化 /h1 { font-size: clamp(1.5rem, 4vw + 1rem, 2.5rem);}/ 容器:最小320px(小屏不挤),最大1200px(大屏不宽),随视口自适应 /.container { width: clamp(320px, 90vw, 1200px); margin: 0 auto; / 水平居中,适配所有屏幕 /}/ 间距:小屏16px,大屏24px,随视口同步变化,不用单独写媒体查询 /.section { padding: clamp(16px, 5vw, 24px);}我的实操技巧:首选值常用 vw + rem 或 calc 做线性变化,比如 4vw + 1rem,既能保证小屏有足够的尺寸,又能让大屏尺寸不夸张;clamp 适合设置字体、容器宽度、间距等需要“平滑自适应”的样式,比媒体查询更简洁、更流畅。七、百分比 %:相对父级,布局常用但易踩坑百分比 % 也是相对单位,核心是“相对于父级元素的 content 区域尺寸”,日常布局用得很多,但新手容易踩坑,尤其是高度设置。css 体验AI代码助手 代码解读复制代码.child { width: 50%; / 相对于父级content宽度的50%,布局常用 / height: 100%; / 相对于父级content高度的100%,容易失效 / padding: 10%; / 重点:margin、padding的%,始终相对父级宽度,不是高度! /}避坑提醒:这是我早期踩过的高频坑——子元素设置 height: 100% 时,如果父级元素没有明确设置高度(比如父级 height 为 auto),子元素的 100% 就会失效,显示为内容高度。另外,margin 和 padding 的百分比,不管是水平还是垂直方向,都相对于父级的宽度,不是高度,新手很容易记混。八、我的实际用法建议(直接套用,少踩坑)结合我多年的开发经验,整理了一套单位使用规范,新手可以直接套用,不用再纠结怎么选,高效又避坑:css 体验AI代码助手 代码解读复制代码/ 1. 根字号:设置rem基准,避免浏览器差异 /html { font-size: 16px; }/ 2. 字体:rem(全局统一)或 clamp(流体自适应) /body { font-size: 1rem; } / 全局基础字体 /h1 { font-size: clamp(1.5rem, 2vw + 1rem, 2.5rem); } / 标题流体排版 // 3. 间距:rem为主,保证全局一致性,配合clamp做自适应间距 /.gap { gap: 1rem; }.padding { padding: 1.5rem; }.section-padding { padding: clamp(16px, 5vw, 24px); }/ 4. 布局宽度:%(局部适配)、vw、clamp(全局自适应) /.container { width: min(90vw, 1200px); } / 结合min更稳妥 /.child-box { width: 50%; } / 父级容器内的局部适配 // 5. 小固定值:px(精准不变) /border: 1px solid #eee; / 边框固定 /border-radius: 4px; / 圆角固定 /.icon-size { width: 24px; height: 24px; } / 图标固定尺寸 // 6. 全屏布局:dvh(移动端)、vh(PC端) /.full-screen { min-height: 100dvh; } / 移动端全屏首选 /九、个人总结:单位选择避坑核心(新手必看)固定尺寸用 px:边框、圆角、小图标、固定间距,精准不混乱;全局适配用 rem:字体、全局间距、布局,配合媒体查询改根字号,全站等比缩放;嵌套场景避 em:em 易累积,只用在简单局部适配,嵌套层级深的场景坚决不用;全屏/视口适配用 vw/vh + dvh:PC端用 vw/vh,移动端全屏用 dvh,避免高度跳动;流体排版用 clamp:字体、容器宽度、自适应间距,不用媒体查询,简洁又流畅;百分比慎用:记住“宽高相对父级、边距相对父级宽度”,父级无明确高度时,height: 100% 会失效。其实单位选择没有绝对的对错,核心是结合场景,保证页面适配流畅、维护便捷。我从一开始分不清各种单位,到现在能熟练搭配使用,核心就是多实操、多踩坑、多总结,新手只要记住上面的规则,就能少走很多弯路。
2026年04月29日
0 阅读
2026-04-29
vue3-vite-语法大全
一、核心基础: 完整基础语法(必背)✅ 1. 组件结构(单文件组件 SFC 标准写法)vue3-vite-demo 中所有 .vue 组件都是这个结构,重中之重,所有页面的基础vuexml 体验AI代码助手 代码解读复制代码{{ msg }}点击 // Vue3组合式API 核心区域,所有逻辑写这里 // 特点:无需export default、无需return、变量/方法自动暴露给template使用 // 没有this!没有this!没有this!所有内容直接调用 / scoped:样式只作用于当前组件,防止样式污染,必加 / / lang="scss":需要安装sass依赖,npm i sass -D 即可使用 / div { color: red; }✅ 2. 响应式数据定义(Vue3 核心,3 种常用方式)响应式:数据变化时,页面视图自动更新,Vue3 废弃了 Vue2 的 data(){return{}},全部用组合式 API 定义,优先级:ref > reactive > readonlyvuephp 体验AI代码助手 代码解读复制代码// 1. 引入核心APIimport { ref, reactive, readonly } from 'vue'// ✔ ref:定义【基本数据类型】 字符串/数字/布尔值 (最常用)// 赋值/取值 必须加 .value (script里必须加,template里直接用变量名)const num = ref(0)const str = ref('Vue3+Vite')const isShow = ref(true)const token = ref('')// 修改ref数据num.value += 1token.value = 'abc123xxx'// ✔ reactive:定义【引用数据类型】 对象/数组 (常用)// 赋值/取值 无需.value,直接操作,响应式深度绑定const user = reactive({ name: '张三', age: 20, info: { address: '北京' }})const list = reactive([ { id: 1, name: 'vue3' }, { id: 2, name: 'vite' }])// 修改reactive数据user.name = '李四'user.info.address = '上海'list.push({ id:3, name:'axios' })// ✔ readonly:定义【只读响应式数据】 只读不可修改const readOnlyUser = readonly(user)// readOnlyUser.name = '王五' → 报错,无法修改{{ num }} {{ str }} {{ isShow }}{{ user.name }} {{ user.info.address }}{{ list }}✅ 核心区别记忆:ref 管基本类型,.value 是标识;reactive 管复杂类型,直接用✅ 3. 方法定义 & 事件绑定(methods 替代方案)Vue3 中没有 methods 选项,直接在 中定义普通函数即可,函数自动暴露给模板,模板中直接调用vuexml 体验AI代码助手 代码解读复制代码import { ref } from 'vue'const count = ref(0)// ✔ 定义普通方法const addCount = () => { count.value++}// ✔ 定义带参数的方法const setCount = (val) => { count.value = val}// ✔ 定义异步方法(axios请求/定时器/async-await)const getList = async () => { // 异步请求示例 // const res = await axios.get('/api/list') console.log('异步请求执行')}点击+1设置为10获取数据阻止冒泡 二、父子组件通信(高频!开发必用,4 种核心写法)Vue3 组件通信是项目开发的核心,所有写法都是基于 ,没有 this.$parent / this.$children,全部是官方推荐的标准化写法,按使用频率排序,覆盖所有业务场景✅ 1. 父传子:defineProps (最常用,父组件给子组件传值)子组件只读父组件传递的数据,不能直接修改 props,单向数据流原则vuexml 体验AI代码助手 代码解读复制代码 import { ref, reactive } from 'vue' import Child from './Child.vue' // 引入子组件(Vite中.vue后缀不能省略) const parentMsg = ref('父组件传递的内容') const userInfo = reactive({ name: '张三', age:20 }) const arr = reactive([1,2,3]) {{ msg }}{{ user.name }}{{ list }} // ✔ 核心:defineProps 接收父组件传参,无需引入,Vue自动提供 // 写法1:简单声明(推荐,简洁) const props = defineProps(['msg', 'user', 'list']) // 写法2:带类型校验的声明(规范,项目推荐) const props = defineProps({ msg: String, user: Object, list: Array, num: { type: Number, default: 0, // 默认值 required: false // 是否必传 } }) // script中使用props console.log(props.msg) console.log(props.user.name) ✅ 2. 子传父:defineEmits (最常用,子组件给父组件传值 / 触发事件)子组件不能直接修改父组件数据,通过派发事件的方式通知父组件修改,完美遵循单向数据流vuexml 体验AI代码助手 代码解读复制代码 子组件传值给父组件 传递数字 // ✔ 核心:defineEmits 声明事件,无需引入,Vue自动提供 // 写法1:数组声明事件名 const emit = defineEmits(['sendMsg', 'sendNumber']) // 写法2:对象声明(带参数校验,规范) const emit = defineEmits({ sendMsg: (val) => typeof val === 'string', sendNumber: (val) => typeof val === 'number' }) // 子组件派发事件,携带参数 const sendData = () => { emit('sendMsg', '我是子组件的内容') } const sendNum = (num) => { emit('sendNumber', num) } 子组件传递的内容:{{ childMsg }}子组件传递的数字:{{ childNum }} import { ref } from 'vue' import Child from './Child.vue' const childMsg = ref('') const childNum = ref(0) // 父组件接收子组件的参数并处理 const getMsg = (val) => { childMsg.value = val } const getNum = (val) => { childNum.value = val } ✅ 3. 父组件获取子组件的属性 / 方法:defineExpose (常用,子组件暴露内容)Vue3 中 组件的内容是默认关闭暴露的,父组件无法直接获取子组件的变量 / 方法,必须通过 defineExpose 主动暴露,父组件用 ref 获取vuexml 体验AI代码助手 代码解读复制代码子组件 import { ref } from 'vue' // 子组件的变量和方法 const childNum = ref(999) const childFn = () => { return '我是子组件的方法' } // ✔ 核心:defineExpose 暴露内容给父组件,无需引入 defineExpose({ childNum, childFn }) 获取子组件内容 import { ref } from 'vue' import Child from './Child.vue' // ✔ 定义ref绑定子组件 const childRef = ref(null) const getChildData = () => { // 获取子组件暴露的变量 console.log(childRef.value.childNum) // 999 // 调用子组件暴露的方法 console.log(childRef.value.childFn()) // 我是子组件的方法 } ✅ 4. 祖孙组件通信:provide / inject (跨层级传值,无需逐层传递)解决「父传子传孙」的多层级传参问题,父组件通过 provide 提供数据,子孙组件通过 inject 注入数据,无论层级多深都能获取,无需逐层 props 传递,非常实用vuexml 体验AI代码助手 代码解读复制代码 import { provide, ref } from 'vue' import Child from './Child.vue' // ✔ provide:提供数据,参数1=key,参数2=值(响应式) const theme = ref('dark') provide('themeColor', theme) provide('title', 'Vue3+Vite教程') {{ themeColor }} {{ title }} import { inject } from 'vue' // ✔ inject:注入数据,参数1=父组件的key,参数2=默认值(可选) const themeColor = inject('themeColor') const title = inject('title', '默认标题') // 修改响应式数据,父组件也会同步更新 themeColor.value = 'light' 三、计算属性 & 监听器(核心 API,Vue3 写法)✅ 1. 计算属性 computed - 依赖值变化自动更新,有缓存(推荐)替代复杂的模板表达式,处理数据格式化、数据拼接、条件计算等,有缓存,依赖值不变时不会重复执行,性能优于方法vuexml 体验AI代码助手 代码解读复制代码import { ref, computed } from 'vue'const num = ref(10)const list = ref([1,2,3,4,5])// ✔ 只读计算属性(最常用)const doubleNum = computed(() => { return num.value * 2})// ✔ 可修改的计算属性(带get/set)const fullName = computed({ get() {return '张' + '三'}, set(val) {console.log('修改了姓名:', val)}})// ✔ 复杂计算:过滤数组const filterList = computed(() => { return list.value.filter(item => item > 2)}){{ doubleNum }} {{ filterList }} ✅ 2. 监听器 watch - 监听数据变化,执行回调(4 种写法全覆盖)Vue3 废弃了 Vue2 的 watch:{},改用组合式 API watch,功能更强大,支持监听单个值、多个值、深度监听、立即执行,无数据变化不执行,按需触发vuexml 体验AI代码助手 代码解读复制代码import { ref, reactive, watch } from 'vue'const num = ref(0)const user = reactive({ name: '张三', age:20 })const list = ref([1,2,3])// ✔ 写法1:监听单个ref数据(最常用)watch(num, (newVal, oldVal) => { console.log('num变化了:', newVal, oldVal)})// ✔ 写法2:监听单个reactive对象(深度监听,自动开启,无需配置)watch(user, (newVal, oldVal) => { console.log('user变化了:', newVal)})// ✔ 写法3:监听多个数据,用数组包裹watch([num, ()=>user.name], (newVal, oldVal) => { console.log('num或name变化了', newVal)})// ✔ 写法4:监听reactive对象的单个属性 + 配置项(核心!)// immediate:立即执行一次;deep:深度监听watch(() => user.info.address, (newVal) => { console.log('地址变化了', newVal)}, { immediate: true, // 组件初始化时立即执行 deep: true // 深度监听对象内部属性})// ✔ 停止监听:定义变量接收watch返回值,调用即可const stopWatch = watch(num, ()=>{})stopWatch() // 停止监听✅ 补充:watchEffect 懒人版监听,无需指定监听目标,自动收集依赖,初始化必执行,适合简单场景,按需使用即可。四、生命周期钩子(Vue3 完整生命周期,无 Options API)✅ 核心说明Vue3 生命周期全部是组合式 API 函数,需要手动引入才能使用所有生命周期钩子都写在 中,按执行顺序调用没有 beforeCreate / created,这两个钩子的逻辑直接写在 全局即可(组件初始化时最先执行)所有钩子都是同步执行,支持异步操作(async/await)✅ 完整生命周期钩子(按执行顺序,必背)vuexml 体验AI代码助手 代码解读复制代码import { onMounted, onUpdated, onUnmounted, onBeforeMount, onBeforeUpdate, onErrorCaptured } from 'vue'// 替代 beforeCreate / created → 直接写这里即可console.log('组件初始化,最先执行')// 挂载前:DOM还未渲染onBeforeMount(() => { console.log('onBeforeMount:挂载前')})// 挂载完成:DOM渲染完毕(最常用!获取DOM/发起请求/初始化数据)✅✅✅onMounted(() => { console.log('onMounted:挂载完成') // 在这里发axios请求、初始化echarts、操作DOM,不会报错})// 更新前:数据变化,DOM还未更新onBeforeUpdate(() => { console.log('onBeforeUpdate:更新前')})// 更新完成:数据变化,DOM更新完毕onUpdated(() => { console.log('onUpdated:更新完成')})// 卸载前:组件销毁前执行onBeforeUnmount(() => { console.log('onBeforeUnmount:卸载前')})// 卸载完成:组件销毁完毕(常用!清除定时器/取消请求/解绑事件)✅✅✅onUnmounted(() => { console.log('onUnmounted:卸载完成') clearInterval(timer) // 清除定时器})// 捕获子组件的错误onErrorCaptured(() => { console.log('onErrorCaptured:捕获到错误')})✅ 开发高频使用:onMounted(初始化请求) + onUnmounted(清理副作用),占 90% 的业务场景五、Vite 项目 核心配置文件 vite.config.js(完整版,可直接复制)你的项目 vue3-vite-demo 根目录的 vite.config.js 是 Vite 的核心配置文件,所有项目优化、路径别名、跨域代理、打包配置都在这里写,下面是完整版通用配置,包含开发中 99% 的配置项,复制即用,注释清晰!javascript运行javascript 体验AI代码助手 代码解读复制代码// vite.config.jsimport { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'// 路径别名:需要安装 npm i @types/node -D 解决路径报错import path from 'path'// https://vitejs.dev/config/export default defineConfig({ // 1. 插件配置 plugins: [vue()],// 2. 路径别名配置(最常用!简化导入路径,不用写../../)✅ resolve: {alias: { '@': path.resolve(__dirname, './src'), // @ 指向 src目录 '@components': path.resolve(__dirname, './src/components'), '@api': path.resolve(__dirname, './src/api') }},// 3. 开发服务器配置(解决跨域!接口代理,必配)✅✅✅ server: {port: 8080, // 项目启动端口 open: true, // 启动后自动打开浏览器 host: '0.0.0.0', // 允许局域网访问 // 跨域代理:解决前端请求后端接口的跨域问题 proxy: { '/api': { target: 'https://localhost:44352', // 你的后端接口地址 changeOrigin: true, // 开启跨域 rewrite: (path) => path.replace(/^/api/, '') // 去掉/api前缀 } }},// 4. 打包配置(优化打包体积、解决中文乱码、指定打包目录)✅ build: {outDir: 'dist', // 打包输出目录 assetsDir: 'assets', // 静态资源目录 charset: 'utf-8', // 解决打包后中文乱码 minify: 'esbuild', // 打包压缩方式,更快更小 sourcemap: false, // 关闭sourcemap,减小打包体积 rollupOptions: { // 分包配置,优化打包速度 output: { chunkFileNames: 'js/[name]-[hash].js', entryFileNames: 'js/[name]-[hash].js', assetFileNames: '[ext]/[name]-[hash].[ext]' } }},// 5. CSS配置(全局样式、预处理器) css: {preprocessorOptions: { scss: { // 全局引入scss变量/混合器,无需在每个组件中import additionalData: '@import "@/styles/variable.scss";' } }}})六、项目开发 高频实战语法(你之前用到的,补充完整版)✅ 1. Axios 请求在 Vue3 中的完整写法(你的登录接口,无报错版)结合你之前的代码,补充 Vue3 + Axios 的标准写法,包含异步、错误捕获、中文不乱码,直接替换你的代码即可vuexml 体验AI代码助手 代码解读复制代码import { ref, onMounted } from 'vue'import axios from 'axios'const token = ref('')const loading = ref(false)// 封装登录请求方法const login = async () => { loading.value = true try {const res = await axios.post( 'https://localhost:44352/Auth/login', // 去掉重复的?role=system { role: 'system', password: '123456' }, { headers: { 'Content-Type': 'application/json;charset=utf-8' } } ) token.value = res.data.token console.log('登录成功,token:', token.value)} catch (err) {console.log('登录失败:', err)} finally {loading.value = false}}// 方式1:点击按钮触发(推荐,业务常用)// 方式2:组件挂载完成后自动触发onMounted(() => { // login()}) 登录token:{{ token }}✅ 2. 路由配置(Vue3 + VueRouter4 完整版,项目必配)vue3-vite-demo 中如果需要路由,安装 npm i vue-router@4,然后在 src/router/index.js 配置,核心写法:javascript运行javascript 体验AI代码助手 代码解读复制代码// src/router/index.jsimport { createRouter, createWebHistory } from 'vue-router'import Home from '@/views/Home.vue'import Login from '@/views/Login.vue'const routes = [ { path: '/', redirect: '/home' }, { path: '/home', component: Home }, { path: '/login', component: Login }]const router = createRouter({ history: createWebHistory(), // 无#号路由 routes})// 路由守卫:登录拦截router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) {next('/login')} else {next()}})export default router然后在 main.js 引入:javascript运行javascript 体验AI代码助手 代码解读复制代码// main.jsimport { createApp } from 'vue'import './style.css'import App from './App.vue'import router from './router'createApp(App).use(router).mount('#app')七、Vue3 与 Vue2 核心区别(避坑必备,快速记忆)语法核心:Vue2 是 Options API(选项式),Vue3 是 Composition API(组合式)+ 语法糖this 关键字:Vue2 处处用 this,Vue3 中没有 this,所有内容直接调用响应式原理:Vue2 是 Object.defineProperty,Vue3 是 Proxy + Reflect,支持数组 / 对象的深度响应式,无需特殊处理生命周期:Vue2 是选项式(created/mounted),Vue3 是组合式 API(onMounted/onUnmounted),需要手动引入组件通信:Vue2 是 props/emit/refs,Vue3 是 defineProps/defineEmits/defineExpose,更规范打包工具:Vue2 是 VueCLI + Webpack,Vue3 推荐 Vite,打包 / 启动速度提升 10 倍 +作者:牛牛不牛妞妞链接:https://juejin.cn/post/7596339303861846067来源:稀土掘金著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
2026年04月29日
1 阅读
2026-04-29
UniApp+vue3开发微信小程序,全局注册组件-不需要每个page都写一遍的那种
全局注册组件-不需要每个page都写一遍的那种需求来源:项目开发进入尾声,临近上线了客户要求给系统加水印功能分析:微信小程序没有vue的路由机制,不能像vue一样在App.vue里注册一个全局组件。麻烦一点的方式就是每个page都写一遍,虽然是copy paste,但是重复没有意义的事儿我是一点都不想干。那就写个组件,在源码层面,编译前给每个page都加上这个水印组件。实现思路写一个打水印的组件组件全局注册写一个vite插件,实现修改源码的功能vite.config中使用插件overStep1:水印组件实现js 体验AI代码助手 代码解读复制代码// /components/watermark/watermark.vue<viewv-if="mask" class="watermark-mask" :style="{ backgroundImage: `url(${mask})` }" import { ref, onMounted, nextTick } from "vue"; import { useStore } from "vuex"; const store = useStore(); const user = computed(() => store.getters.user); const mask = ref(""); const draw = () => { try { // 微信小程序创建离屏 canvas 的标准写法 const canvas = uni.createOffscreenCanvas({ type: "2d", width: 200, height: 200, }); const ctx = canvas.getContext("2d"); ctx.font = "14px Arial"; ctx.fillStyle = "rgba(200, 200, 200, 0.2)"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.translate(100, 100); ctx.rotate(-Math.PI / 9); ctx.fillText(`${user.value.nickName || ''} ${user.value.phonenumber || ''}`, 0, 0); // 生成 base64 mask.value = canvas.toDataURL(); console.log("水印生成成功"); } catch (e) { console.error("水印生成失败:", e); } }; watch( () => store.getters.user, () => { draw(); }, { deep: true, } ); onMounted(() => { console.log("水印组件已挂载"); }); .watermark-mask { position: fixed; inset: 0; z-index: 999999; pointer-events: none; background-repeat: repeat; / 确保不遮挡背景 / background-color: transparent;}Step2: 组件全局注册方案一:在pages.json中注册js 体验AI代码助手 代码解读复制代码// 在你的pages.json中加入这段代码全局注册一下"easycom": {"autoscan": true, "custom": { "^watermark$": "@/components/watermark/watermark.vue" }}hh,于是问题出现了,编译好的代码里能看到watermark标签,但是小程序模拟器审查元素没有这个元素。😁😁组件代码中已经引入了watermark组件,且标签类似 ,ai说标签带有u-i(uni-app唯一标识)和bind:__l(生命周期绑定),说明uniapp编译器已经把它识别为一个组件了,但是模拟器里找不到该节点,说明页面.json中没有注册这个组件。微信小程序会直接忽略没有在json中注册的节点,所以模拟器里找不到。插件是动态注入的,uniapp的依赖扫描器可能在插件运行前就完成了扫描,导致它没有把watermark写进页面的usingComponents里。方案一PASS!!方案二:在main.js中注册防止uniapp扫描依赖漏掉,直接全局强制注册组件js 体验AI代码助手 代码解读复制代码import watermark from "@/components/watermark/watermark.vue";const app = createSSRApp(App);app.component("watermark", watermark);Step3: vite插件修改源码js 体验AI代码助手 代码解读复制代码import path from "path";import fs from "fs";export default function viteInsetLoader() { // 读取 pages.json // 项目里我的每个page里都有一些子组件./components/...vue,只需要给page级页面注册就好了,为了减少代码冗余我这里加了个是否是page的判断 const pagesJsonPath = path.resolve(__dirname, "pages.json"); const pagesJson = JSON.parse(fs.readFileSync(pagesJsonPath, "utf-8")); const pages = pagesJson.pages.map((item) => item.path); return {name: "vite-inset-loader", // 确保在 uni 插件之前执行,这样修改的是原始源码 enforce: "pre", transform(code, id) { // 1. 过滤非页面文件 const isPage = pages.find((item) => id.indexOf(item) > -1); if (!isPage || !id.endsWith(".vue")) return null; // 2. 检查代码中是否包含 <template> if (code.includes("<template>")) { console.log("--- 正在注入水印到页面 ---:", id); // 3. 注入逻辑:建议注入在第一个 <view> 之后,或者 </template> 之前 // 尽量匹配带空格或换行的 </template> const newCode = code.replace(/([\s\S]*)<\/template>/, (match, p1) => { return `${p1} <watermark />\n</template>`; }); return { code: newCode, map: null, }; } return null; },};}Step4: 使用插件js 体验AI代码助手 代码解读复制代码import uni from "@dcloudio/vite-plugin-uni";import appendWatermark from "./vite-plugin-watermark";export default defineConfig({ plugins: [uni(), appendWatermark(), // 写在uni后面]})补充一句:web-view的页面水印加不上,就算是z-index:99999999也不起作用,小程序的webview层级太高咧,h5自行加吧。先总结到这里,持续更新。。。。
2026年04月29日
0 阅读
1
...
3
4
5
...
9