每次做完一个uniapp项目,都会有朋友跑来问我:为什么同样的页面,在微信小程序上好好的,一到App端就白屏?为什么封装好的组件,H5能跑,App上却不显示?这些问题,大多数都出在同一个地方——对uniapp组件体系的理解不够透彻。
说到uniapp的组件,很多刚从vue转过来的人第一反应是“不就是vue组件嘛”,对,但不完全对。uniapp的组件横跨了小程序、App、H5三大端,同一套代码要跑在三套不同的渲染引擎上,里面的门道远比想象中复杂。我从最早用vue2的uniapp做小程序,到现在用vue3技术栈跑App和鸿蒙,踩过的坑攒了满满一筐,这篇文章就是把这几年和组件打交道的经验梳理一遍,从内置组件到自定义组件,从组件通信到平台适配,把关键部分拆开讲透,希望能帮正在做uniapp开发的你少走几条弯路。这篇文章适合刚入门uniapp的新手,也适合已经在写业务但经常被跨端问题折磨的开发者。
1. uniapp组件体系全景拆解
1.1 内置组件:跨端一致的能力底座
uniapp官方内置了view、text、image、scroll-view、swiper、video、map、canvas、web-view等几十个基础组件。这些组件在小程序端会被映射成小程序原生组件,在App端会被映射成对应的原生视图或由内置webview渲染,在H5端则生成标准的HTML元素。同一个view组件,小程序里编译成<view>,H5里可能就变成了<div>。
这里就引申出一个关键点:内置组件的跨端一致性是“能力层面”的一致,而不是“样式层面”的绝对一致。比如view在小程序端有hover-class属性,H5端没有;button组件在不同平台上的默认样式差异很大,直接拿一套CSS想同时喂饱三个端,几乎不可能。
实操中我的习惯是,所有内置组件尽量只使用uniapp官方文档明确列出的公共属性,凡是标注了“仅App平台支持”或“仅微信小程序支持”的属性,必须用条件编译单独处理。另外要注意,内置组件的原生能力是有性能差异的,比如scroll-view在长列表场景下,小程序端用enhanced属性开启增强模式后滚动会更顺滑,而App端如果渲染几千条节点,最好启用recycle或改用virtual-list方案,否则滚动掉帧会很明显。
还有一个容易被忽略的点是内置组件的层级问题。在小程序端,video、map、canvas这类原生组件天然盖在普通view上面,传统CSS的z-index对它们无效。虽然现在微信小程序推出了same-layer渲染机制,但并非所有组件所有场景都支持。所以设计弹窗、下拉面板、自定义导航栏时,必须提前避开原生组件层,或者在切换弹窗时动态隐藏原生组件,否则就会出现“弹窗被视频盖住”的应用事故。
1.2 自定义组件:业务复用与平台差异屏蔽
自定义组件是uniapp业务开发的绝对主力。把重复出现的页面区块封装成组件,把复杂交互拆进组件内部,这既是vue的基本功,也是uniapp项目的保命策略。因为只有把差异屏蔽在组件边界之内,页面层才能保持干净,跨端问题才不会到处扩散。
我封装组件时会遵循几条原则。第一,组件只做展示和交互回调,不直接发请求改全局状态,所有数据由父组件通过props注入,交互结果通过emit抛出,保持组件的纯函数属性。第二,凡是涉及平台差异的代码,全部在组件内部用条件编译收口,页面层根本不需要感知某个功能在App上要调原生插件、在小程序上要调云函数。第三,组件内部不写死业务文案和样式尺寸,通过props和custom-class或customStyle留出扩展口子,否则换一个UI风格就要重构组件。
这里特别想提一下easycom规范。uniapp支持easycom自动引入组件,只要组件放在components/组件名/组件名.vue目录下,页面中直接写<组件名 />,不用手动import也不需要注册。这个机制我自己项目中一直在用,配合uni_modules生态,装第三方组件库时几乎零配置。不过需要注意两点:第一,easycom的组件名不能和内置组件重名;第二,自定义easycom规则时,easycom.json里的autoscan字段默认是true,但如果你同时配置了自定义规则,可能会覆盖默认扫描目录,新加的组件忽然就找不到了,排查起来容易懵。
2. 组件通信:父传子、子传父、跨层级的完整方案
2.1 最基础的props与emit
组件通信是uniapp面试题里的常客,也是日常业务里最容易写出“屎山”的地方。最基本的父传子就是props,子传父就是$emit事件,这跟vue完全一致,没什么好说的。
props传参时有一个高频坑:小程序端不支持在props里直接传函数,也不支持在模板里调用组件内部的方法。很多人写vue写习惯了,父组件给子组件传一个回调函数,H5上跑得好好的,小程序端却收不到更新或者事件不触发。正确的做法是统一用emit抛事件,由父组件监听事件去执行逻辑,保持数据流单向清晰。
还有一个细节是props默认值和类型校验。type不写、默认值不写,在H5端没事,在小程序端一旦父组件传入undefined,组件内部可能直接报错。我的建议是每个props都声明type和default,数组和对象类型的默认值必须用工厂函数返回。另外,props命名不要用驼峰带首字母大写,比如userName没问题,但UserName在某些低版本小程序端会被转成username,取不到值。
emit事件同理,事件名尽量全小写加短横线,比如@update:value写的事件名,发射时用this.$emit('update:value'),不要用this.$emit('updateValue'),否则H5正常,小程序端监听不到。这类问题不会报错,只会让你莫名其妙地发现“点击之后没反应”,很难查。
2.2 provide/inject、全局状态与事件总线
当组件嵌套层级较深时,一层层props透传非常痛苦,这时候可以组合使用provide/inject。uniapp的vue3版本完全支持provide/inject,适合做“祖先组件向任意后代注入配置”这类场景,比如主题色、全局配置对象、接口基地址等。
要特别注意的是,provide/inject是非响应式的默认行为。如果注入的值是普通对象,改动了组件内不会自动同步。我的做法是注入一个reactive对象,或者在provide时返回一个computed的引用,这样后代组件读取到的值就是实时的。如果项目用了pinia,通常我会把全局状态都放进store,provide/inject只用来传store实例或特定配置,避免组件里到处裸奔全局变量。
说到全局状态,vuex和pinia在uniapp里都能用。vue3项目我推荐pinia,轻量、天然支持setup语法、ts友好。但有一点必须提醒:小程序端的store持久化不能用localStorage直接读写,需要走uni.setStorageSync和uni.getStorageSync。并且当你的项目拆了分包之后,不在主包里的页面如果引用了主包里的store模块,会出现“找不到模块”的编译错误。所以跨分包的公共代码,尽量放到common或uni_modules这类不参与分包的目录。
事件总线在小程序端还有一个特殊之处:uni.$emit和uni.$on是官方提供的全局事件方案,适合A页面通知B页面刷新这种场景,但记得在页面onUnload里uni.$off掉,否则会累积监听器,影响性能和内存。我见过一个项目,多个页面都用uni.$on监听同一个事件,页面关闭后不注销,结果每次触发事件,所有已关闭页面的回调全跑一遍,数据被反复覆盖,排查起来特别费劲。
2.3 动态组件与keep-alive的正确用法
动态组件<component :is="...">在uniapp中是可以用的,但比纯vue项目多了不少限制。首先,is属性接收的是组件的名字或选项对象。在vue3的uniapp项目中,配合defineAsyncComponent可以实现异步加载组件。但这里要清楚,小程序端不支持运行时动态渲染任意组件,凡是import的组件必须静态出现在代码中,否则编译器无法将它们打进bundle。
keep-alive在uniapp里更特殊。小程序端的页面栈是原生管理的,<keep-alive>只对组件级别的缓存有效,对页面级别无效——页面销毁后状态就没了,靠onLoad参数恢复。App端也一样,别指望用keep-alive缓存页面。如果你需要“页面切走再回来,状态不清空”的效果,我的办法是把页面数据放到pinia或vuex里,用onShow钩子做数据恢复,切走时不清空store。另一个办法是把需要保活的区域抽成组件,在父组件里用<keep-alive>包裹,这样组件级别的缓存是有效的,但要注意缓存的组件不能依赖页面的onLoad参数。
组件的显隐控制也要小心。v-if和v-show在小程序端渲染逻辑不同:v-if是真条件渲染,频繁切换开销大;v-show虽然看起来只是切换display,但小程序端节点仍然保留在渲染树里。如果列表项非常多,v-show反而会让初始渲染变慢。长列表里的显隐切换,配合wx:if条件渲染加v-if可能是更稳的选择。总之,动态组件和缓存机制在uniapp里都有平台特性,没有万金油方案,先搞清楚目标平台的底层行为再决策。
3. 高频场景组件实操:video、swiper、map、web-view、canvas
3.1 video组件:模块配置、全屏与视频方向
video组件在uniapp项目里几乎都要用到,带货类、教育类、内容类产品尤其如此。但video组件的坑也是出了名的多。
先说模块配置。如果你用vue2或老版本vue3项目,在App端使用video必须先在manifest里勾选VideoPlayer模块。很多新手在H5端开发一切正常,打包成App后黑屏,控制台还提示“未添加videoplayer模块”。这里说的是uniapp离线打包或云打包的模块配置,不是页面里写代码能解决的。2023年之后的vue3版本默认集成VideoPlayer,但如果你的项目是从老版本升级过来的,manifest里缺失模块配置是常有的事。检查路径:manifest.json -> App模块配置 -> VideoPlayer(视频播放),勾选后重新打包。
第二个常见问题是视频方向。iPad或手机横屏拍摄的视频,在小程序端用video组件播放正常,但录制完ips视频后播放时画面转了个90度。这是因为视频文件里的rotation元数据没有被播放器正确处理。解决思路有两个:一个是录制时对视频做预处理,用ffmpeg这类工具把旋转角度烧录进画面;另一个是播放时通过objectFit="contain"配合direction属性设置播放方向。如果你是用camera组件或原生插件录制视频,建议录制完成后立即读取视频的方向信息,再做统一校正。
video组件的层级问题同样值得单独说。小程序端video是原生组件,弹窗、toast、下拉刷新这些UI都可能被video盖住,常规对策是弹窗出现时调用this.$refs.video.pause()并设置v-if隐藏video,或者用cover-view做覆盖层。App端uni-app的video在部分机型上也有层级问题,尤其是双webview渲染模式下,弹窗层和视频层的混排需要格外测试。
3.2 swiper轮播图组件:自动播放与复杂卡片
轮播图组件swiper用来做banner图、商品图、卡片滑动,是封装频率最高的组件之一。基本的autoplay、interval、circular三个属性要记牢,但更值得做的是把它封装成一个通用XbSwiper组件,统一处理占位图、图片懒加载、点击事件、指示器样式。
我封装轮播图时一般会处理几个细节。第一,图片列表为空时显示占位图,避免swiper空白。第二,current属性控制当前索引,如果需要外部干预跳转,必须用.sync或v-model方式修改,否则组件内滑动和外部跳转指令会互相打架。第三,swiper里每张图的高度使用height: 100%,配合mode="aspectFill",避免不同尺寸图片撑破容器。第四,如果banner图数量非常多,不要把所有图片一次性塞进去,用v-for渲染“当前页前后各一页”的懒渲染方案,性能会好很多。
还有一个很多人不知道的点:swiper是可以嵌套的,但纵向swiper套横向swiper时,小程序端偶尔会出现手势冲突。我建议同一页面里避免横向+纵向双向滑动嵌套,非用不可时,内层swiper设置disable-touch或外层用scroll-view代替。
3.3 map地图组件:定位与常用API封装
map组件在有LBS需求的场景(门店查询、配送定位、行程轨迹)里非常有存在感。但map组件也是跨端表现差异巨大的组件之一,微信小程序里一套API,App端高德或百度一套API,API风格和事件回调字段都不一样。
我见过不少项目直接用map组件的原生事件做业务,比如@markertap在小程序端能用,App端部分版本里事件名变成markertap之外的小写形式,甚至不触发。稳妥做法是把map组件做二次封装,内部通过条件编译分别处理,对外暴露统一接口。比如封装一个getCenter方法,小程序端返回{latitude, longitude},App端返回高德的经纬度结构,再在封装层归一化成同一格式。做法虽然有点笨,但能救火。
经纬度转换也是高频需求。uniapp的uni.getLocation默认返回wgs84坐标系,在国内落地必须转gcj02,否则地图上点位会偏移几百米甚至更远。小程序端可以通过type: 'gcj02'直接获取,H5端如果用的是浏览器定位,默认就是gcj02,但App端不同定位模块返回的坐标系可能不同。封装时统一在内部做转换,用网上那些坐标偏移算法就行,测试时注意边界。
3.4 web-view组件:H5与原生双向通信
web-view在uniapp里承载了很多“原生能力不够H5凑”的场景,比如复杂的富文本页面、大促活动页、第三方支付协议页。但web-view组件最大的问题是它是一个完全独立的内嵌浏览器,与uniapp应用的数据、登录态、原生API是隔离的。
在H5端,uniapp项目本身就跑在浏览器里,再用web-view嵌入另一个网页,其实就是一个iframe。但到了App端和小程序端,web-view里的网页与外部通信需要靠postMessage加特定的事件桥接。小程序端微信公众号的JSSDK、App端plus.webview的evalJS,这些细节如果不在封装层处理,页面间通信会很痛苦。
我常用的一种桥接方案是:在uniapp项目中内置一个bridge.js脚本,挂在web-view加载的H5页面中。H5页面通过uni.postMessage把事件发给外层,外层通过onMessage接收;反过来外层通过获取web-view实例的evalJS方法调用H5页面的全局函数。注意微信小程序里web-view的postMessage不是实时到达的,需要等页面分享、后退或组件销毁时才统一返回信息,这是一个很多人踩了才发现的坑。如果要做实时双向通信,微信小程序端建议使用wx.miniProgram.postMessage搭配wx.miniProgram.navigateBack的固定套路,或者干脆换成原生组件实现这部分功能。
3.5 canvas组件:跨端绘制与导出白图
canvas绘制是生成海报、二维码、图表这类需求的必经之路。但canvas在uniapp里的坑深得让人绝望,最著名的就是iOS Safari下,用canvas绘制完导出图片会得到一张白图。
原因基本锁定在canvas绘制时机的差异上。iOS Safari对canvas的离屏渲染策略比较特殊,如果在canvas真正完成布局前就调用了uni.canvasToTempFilePath,导出内容可能是空的。解决方法是绘制完成后的回调里加一个短暂延迟,或者等待canvas的draw回调真正结束再导出。还有一个技巧是在绘制前强制触发一次canvas的尺寸重置,再重新走一遍绘制流程,很多真机问题都能靠这个“歪招”缓解。
另外,uniapp的canvas分为type="2d"新接口和旧版接口,新接口返回的是Canvas实例,旧接口通过uni.createCanvasContext获取上下文。两者API完全不同。我的建议是统一用type="2d"的新接口,因为小程序端从基础库2.9.0开始推荐新接口,App端2d canvas也陆续补齐了能力,新项目没必要再碰旧接口。但注意新接口在小程序端的canvas宽度默认是100%乘高度,用uni.getSystemInfo拿到的窗口宽度来换算,导出时destWidth要设置为canvas宽度的像素值,否则导出图片模糊。
3.6 常用生态组件:mp-html、echarts、打印组件
第三方组件是uniapp组件体系的重要补充。mp-html是富文本渲染最好的选择,它比内置rich-text强大得多,支持表格、视频、代码块、自定义标签,在App和小程序端表现都很稳定。接入方式很简单:npm安装后easycom自动注册,或者手动import,用法就是把<rich-text>替换成<mp-html :content="htmlString" />。我一般还会在mp-html上做两件事:一是自定义tagStyle覆盖全局样式,避免H5端字体大小和App端不一致;二是设置lazy-load懒加载图片,长富文本页面性能会明显提升。
echarts是图表组件的首选。市面上有qiun-data-charts和ucharts这类简化方案,但我个人更推荐直接使用渲染到canvas的echarts。一个关键注意点是:echarts在H5端直接引入npm包没问题,但在小程序端不能直接用,因为小程序没有DOM环境。常用方案是下载echarts的微信小程序定制版,通过组件方式包一层。实际项目中,我习惯把它封装成XbChart组件,props里传入option对象,组件内部监听option变化并调用setOption,从而让业务方只需维护echarts配置,不用碰渲染逻辑。
打印需求在uniapp里比较特殊,常见于搭配蓝牙打印机的“小票打印”场景。微信小程序可以用wx.print相关API,但更多是配合第三方打印组件,比如菜鸟打印组件的cnprintclient。这类组件通常走的是原生插件或web-view桥接,在H5端几乎没法直接使用。我的建议是:先把打印业务抽象成统一的print(template, data)方法,底层对接打印机厂家SDK,页面层永远只传数据和模板编号。这样即使更换打印机合作方,页面代码也不用大改。
4. 跨端适配、条件编译与打包发布实战
4.1 条件编译:让同一套代码跑遍端上
条件编译是uniapp最核心的“跨端魔法”。它的原理和C语言的#ifdef一样,编译时直接剔除不属于当前平台的代码块。写法有三种:注释式、普通注释式和样式式。
// #ifdef H5 console.log('只在H5端执行'); // #endif // #ifndef MP-WEIXIN console.log('除非是微信小程序端,否则执行'); // #endif样式里的条件编译也很常用,比如不同端设置不同的padding和字体:
/* #ifdef H5 */ .banner { height: 200px; } /* #endif */ /* #ifdef MP-WEIXIN */ .banner { height: 180px; } /* #endif */条件编译最容易出问题的地方是“误伤”。比如在组件里写// #ifdef H5时,如果注释符号后面多了空格,或者#endif漏写,编译会报奇怪的错。还有,条件编译只能用在.vue、.js、.css这些文件里,纯.js文件里也可以写,但要注意条件编译注释不能嵌套。我自己通常会在项目里维护一个platform.js,集中做平台能力判断和polyfill,页面代码尽量少出现条件编译,维护起来更清晰。
4.2 manifest配置:模块、权限与应用信息
manifest.json是uniapp项目的“根据地”,模块勾选、appid、权限声明、SDK配置都在这里。我见过很多“功能在H5正常,打包后崩溃”的案例,追到最后都是manifest的模块配置漏了。
拿video模块来说,前面提过必须勾选VideoPlayer。map模块对应Geolocation和Map。如果用到蓝牙,要勾选Bluetooth。用到本地存储、文件操作,涉及FileSystem。这些模块勾选之前,最好先去uni的插件市场或官方文档看最新要求,因为不同版本的HBuilderX对模块名的命名有变动。
还有一个容易忽略的是权限声明。Android平台打包后,涉及摄像头、录音、定位、相册的权限必须在manifest里提前声明,否则应用市场审核或真机运行时会被系统拦截。注意,App端的权限提示文案越具体越好,系统弹窗的文案直接用manifest里的iosPrivacyInfo或androidPermissions描述,含糊的文案会被审核打回。
4.3 原生插件与原生组件桥接
uniapp支持通过原生插件扩展能力。uni-app生态里有大量原生插件,从扫码、蓝牙、NFC到各种硬件控制都有。接入原生插件的流程通常是:在插件市场购买或下载,项目里手动添加插件到nativeplugins目录,然后在manifest里配置插件id和参数,最后在代码中通过uni.requireNativePlugin获取插件实例调用。
原生插件是uniapp组件体系里“最强的外援”,但也最容易出问题,比如版本兼容、权限冲突、IOS证书配置等。我建议在使用原生插件前,认真读插件市场里的评论区,看看近期有没有人反馈崩溃问题;最好选更新频繁、文档详细的插件。如果插件文档非常简陋、demo还跑不通,果断换方案。另外,原生插件在“真机运行”时通常有效,但“模拟器运行”可能直接不支持,调试时优先用真机,否则会浪费大量时间。
4.4 打包上架安卓应用市场的流程
打包上架是一套独立流程,这里提几个组件相关的高频坑。第一,HBuilderX云打包时,如果项目引用了原生插件,必须勾选对应的云打包渠道并填写插件参数,否则打包会失败。第二,用wgt包做资源热更新时,原生插件和原生代码变更不支持热更新,只能整包更新,这个限制在应用市场审核时要想清楚。第三,安卓上架前需要做加固和签名,各市场的加固方案不同,签名证书建议用统一的keystore,避免换证书导致无法覆盖更新。
现在的安卓应用市场审核越来越严,隐私政策弹窗、权限动态申请、应用内更新提示这些都要做合规。uniapp项目可以配合uni-popup做一个自定义隐私弹窗,再通过条件编译控制只在App端弹出,合规性强也方便维护。
5. 常见问题与排查技巧实录
5.1 日志不打印问题
很多人在小程序端或App端调试时发现console.log完全不输出。常见原因有三个:第一,代码运行环境在压缩或混淆模式下可能被过滤;第二,App端真机调试需要开启“调试模式”,并在HBuilderX控制台切换平台;第三,你在onHide或组件生命周期里打的日志,可能被vconsole插件覆盖或忽略。
我的排查习惯是先用console.log(JSON.stringify(data))打印结构化数据,避免对象被浏览器控制台折叠成“无法展开”的状态。再不行,直接用uni.showToast做一些关键节点的视觉提示,确认逻辑走到了哪一步。H5端则直接打开浏览器DevTools看网络请求。
5.2 video组件提示“未添加模块”
这是几乎每周都会有人在群里问一遍的问题。现象是:H5上视频播放正常,打包到App后页面报错或视频区域空白。原因就是manifest.json里缺少VideoPlayer模块。检查步骤:打开manifest.json,切到App模块配置页,找到VideoPlayer勾选,保存后重新打包。如果你用的是离线打包或自定义基座,还需要在原生工程里确认模块已经集成,否则光勾选manifest也没用。
这里特别提醒,修改manifest之后,HBuilderX会用“自定义调试基座”需要手动重新制作。我遇到过一次:勾选了模块、重新云打包也没报错,但真机仍然黑屏,最后发现是自定义基座没有重新打包,手机上跑的还是旧基座。所以改了manifest后,一定要把基座和测试包同步更新。
5.3 iOS Safari canvas导出白图
这个坑在3.5里详细说过,这里补充一个排查小技巧。如果导出白图,先在导出前把canvas绘制的内容用uni.getImageInfo去读取一下,或者在ctx.draw()回调里直接调用uni.canvasToTempFilePath看结果是否为空。如果确认绘制有内容但导出是白图,试试在导出时给canvasId明确传值,再检查destWidth和destHeight是不是设了奇怪的倍数。
我还遇到过canvas在iOS上模糊的问题,通常原因是canvas的实际像素尺寸和CSS尺寸不一致。导出时要让canvas的绘图缓冲区尺寸是显示尺寸的2倍或3倍(按设备像素比),导出图片的清晰度会好很多。画一个200px宽的海报,canvas内部宽度设为600,导出时destWidth为600,这样iPhone上也不会模糊。
5.4 热词问题速查表
| 场景 | 问题现象 | 排查要点 |
|---|---|---|
| 父传子 | 子组件props不更新 | 检查props命名是否用小写短横线;确认父组件是否用:propName传值而非冒号后大写 |
| 子传父 | 事件不触发 | 确认事件名是否全小写短横线;检查$emit放在哪个生命周期里 |
| video | App端黑屏 | manifest勾选VideoPlayer模块;自定义基座是否重新打包 |
| canvas | iOS导出白图 | 等待绘制回调完成再加延迟导出;检查canvas尺寸与实际绘制是否一致 |
| web-view | 收不到H5的回传消息 | 小程序端postMessage在返回或分享时才触发;App端需要evalJS桥接 |
| swiper | 图片变形 | 设mode="aspectFill"让图片填充;指示器和卡片宽度要按数据量动态计算 |
| map | 定位偏移 | 确认坐标系是否统一为gcj02;App端不同定位模块坐标系不同 |
| 组件缓存 | 页面状态丢失 | 页面级keep-alive无效,改用pinia恢复数据或组件级缓存 |
| 原生插件 | 打包失败 | 检查manifest插件配置、签名证书和云打包渠道 |
6. 组件化架构设计的几条实操建议
如果项目已经从“单页验证”走向“多业务线并行”,组件化架构的合理度会直接决定后续开发效率。
第一条,建立组件分层。基础组件层(XbButton、XbInput、XbPopup)只负责通用交互,不承载业务。业务组件层(商品卡片、订单状态条、支付面板)组合基础组件并传入业务数据。页面层只负责布局和数据获取,不抱团堆逻辑。这样改动业务时,基础组件几乎不动。
第二条,用uni_modules管理公共组件。uni_modules是uniapp官方推荐的组件包格式,目录结构规范,支持发布到插件市场,也支持项目内共享。项目里的通用组件放到uni_modules/xx-组件名下,再配合easycom自动引入,所有页面都能直接用,不需要手动import,这是效率提升非常明显的一个点。
第三条,每个组件都要写README。组件多了以后,靠记忆根本撑不住。最低限度要在组件目录下放一个README.md,写明props、events、slots、平台特殊处理,哪怕只写要点也行。这段文字在半年后会救你一次。
第四条,组件内的平台适配代码要集中。条件编译可以写,但不要散落得到处都是。在组件内部单独放一个platform.js或platform.vue片段,把差异集中管理,组件主体尽量保持逻辑清晰。比如一个视频组件,play()方法在H5端调用DOM元素API,App端调原生插件,小程序端调video context,三种实现都放在platform区块里,内部再封装统一方法,外部使用的时候不需要关心平台。
7. 组件性能优化的一些个人经验
做组件时如果只顾功能不管性能,项目一大就会开始卡顿。这里分享几个上过线、压过真机的经验。
长列表渲染。小程序和App端一次性渲染几百个组件节点,即使逻辑不重也会明显卡顿。我的常规做法:列表数据切片,每次渲染30-50条,滚动到底部再追加;图片懒加载,不要给所有图片设src。如果列表项里还有子组件、复杂交互,优先考虑把它们拆成“轻组件”,减少创建小程序原生节点时的开销。
组件通信频率。千万不要在组件里watch一个props对象,每次父组件setData或更新数据时,小程序端会做全量diff。高频更新的数据(比如进度条数值、图片加载状态)尽量合并成一次更新,或者使用nextTick延迟,能显著减少卡顿感。
图片资源处理。组件内部的图标、背景图尽量使用iconfont或者base64小图,减少网络请求。大图统一走uniCloud或CDN,并设置合理的widthFix和heightFix,避免图片加载后撑破布局。
8. 一个实际组件封装案例:商品卡片
说了这么多理论,放一个实际封装过的商品卡片例子。需求是:在首页推荐流、搜索结果页、订单页共用同一种商品卡片布局,但点击行为和部分字段不同。
组件名为ProductCard,放在uni_modules/ProductCard/ProductCard.vue。props设计为product对象和showTag布尔值。内部只负责渲染商品图、标题、价格、销量标签,点击时$emit('click', product)。推荐流里监听click跳详情,订单页里监听click去再买一单,逻辑全部在页面层处理。这样商品卡片组件本身完全无状态,改一个样式,三个页面同时生效。
如果搜索页和首页的商品数据结构有差异,组件内部做一层normalize映射,把goodsName和title统一成name字段。这个映射逻辑放组件内部,页面层不用关心数据结构差异。
<template> <view class="product-card" @click="handleClick"> <image :src="product.cover" mode="aspectFill" lazy-load /> <view class="info"> <text class="name">{{ product.name }}</text> <text class="price">¥{{ product.price }}</text> <text v-if="showTag" class="tag">{{ product.tagText }}</text> </view> </view> </template> <script setup> const props = defineProps({ product: { type: Object, required: true }, showTag: { type: Boolean, default: false } }) const emit = defineEmits(['click']) function handleClick() { emit('click', props.product) } </script>页面里使用:
<ProductCard :product="item" show-tag @click="goDetail" />这个例子看起来简单,但它体现了组件封装的核心:解耦、复用、对外边界清晰。实际项目中组件封装得越彻底,跨端问题就越好解决。
做uniapp组件开发这些年,我最大的一个感受是:组件本身不难,难的是你永远不知道你的代码下一步会跑在哪个端上。它对开发者的要求是“带着平台差异意识去写每一行代码”,而不是写完再回来补救。希望这篇文章的实操经验能帮你少踩一些不必要的坑。如果你在组件通信、video、canvas或者打包上遇到过其他稀奇古怪的问题,不妨按照上面这些思路去排查,多半能找到方向。