NutUI组件库在真实项目中的实践踩坑与优化总结
项目初期的技术选型
去年底接了个电商类H5活动页,需求挺典型:3天上线、兼容iOS/Android主流微信/QQ浏览器、要带商品列表、轮播图、弹窗抽奖、地址选择器,还得支持微信JSSDK调用分享。团队里没人想手撸一套UI组件——毕竟时间紧,而且“滚动卡顿”“弹窗蒙层穿透”这种坑我们三年前就踩吐了。
一开始列了三个备选:Vant、NutUI、Taro UI。Vant文档最全但体积大(gzip后 180KB+),我们整个项目 JS 总包才压到 300KB;Taro UI 偏向多端,但这次纯 H5,没必要为未来可能的小程序做冗余适配;最后选了 NutUI,理由很实在:轻(gzip 后 68KB)、文档清晰、对 Vue 3 + Composition API 支持原生、组件粒度细,比如 Popup 和 Overlay 是分离的,改蒙层样式不牵连弹窗逻辑——这点在后面救了我两次。
最大的坑:性能问题
真上手才发现,不是组件不好,是“怎么用”比“有没有”更致命。最典型的是商品瀑布流页面:后端给的是分页数据,前端用 NutList + NutPullRefresh 做下拉加载。本地测没问题,一上真机(尤其 iOS 14 微信)就卡成 PPT。反复录屏对比发现,滚动时 TouchMove 事件被频繁触发,每次触发都走一次 onScroll,而里面又调了 getBoundingClientRect() 判断是否触底——这个操作在低端机上每秒能干掉 20 帧。
开始没想到这么严重,还以为是图片懒加载没配好。折腾了半天发现 Chrome DevTools 的 Performance 面板里,Layout 任务占了主线程 70% 时间,点进去全是 getBoundingClientRect() 调用栈。后来查 NutUI 源码,发现 NutList 默认开了 immediate-check,也就是每次滚动都立刻检查是否触底,完全没防抖。
解决办法很简单粗暴:关掉它,自己手写节流判断。核心代码就这几行:
const scrollRef = ref(null)
const isScrolling = ref(false)
const handleScroll = throttle(() => {
if (!scrollRef.value) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef.value
if (scrollTop + clientHeight >= scrollHeight - 20 && !isScrolling.value) {
isScrolling.value = true
loadMore().finally(() => {
isScrolling.value = false
})
}
}, 150)
onMounted(() => {
scrollRef.value?.addEventListener('scroll', handleScroll)
})
onBeforeUnmount(() => {
scrollRef.value?.removeEventListener('scroll', handleScroll)
})
又踩坑了,touchmove滚动失效
另一个让我骂出声的问题:在 NutPopup 里放了一个长表单,iOS 上滑动表单时,滚到顶部/底部会直接把整个页面带偏(就是 popup 外层的 body 也跟着动)。查了一圈,是 iOS Safari 的默认行为:当可滚动区域触顶/触底时,会把滚动事件冒泡给 body,body 又没禁止 overflow,于是整个页面晃。
网上一堆方案:加 position: fixed、监听 touchstart/touchmove 阻止默认行为……都不够稳。最后用了 NutUI 官方提过但文档没展开的方案:在 popup 打开时,手动给 document.body 加 class 锁住 overflow,并用 touchmove.prevent 阻断穿透:
body.popup-open {
position: fixed;
width: 100%;
overflow: hidden;
}
const openPopup = () => {
document.body.classList.add('popup-open')
// iOS 防穿透必须同时加 prevent,仅 css 不够
document.body.addEventListener('touchmove', preventDefault, { passive: false })
}
const closePopup = () => {
document.body.classList.remove('popup-open')
document.body.removeEventListener('touchmove', preventDefault)
}
const preventDefault = (e) => {
e.preventDefault()
}
注意这里 { passive: false } 是关键,iOS 必须显式声明,否则 preventDefault 无效。这个点我踩了三次,每次都在同一行代码上改来改去……
最终的解决方案
整体下来,NutUI 在项目中表现是合格的,甚至超出预期。我们没用它的主题定制功能(嫌麻烦,直接覆盖 CSS 变量),但所有组件的插槽(slot)都用得很顺,比如 NutAddress 的顶部自定义标题、NutStepper 的按钮图标替换,一行 slot 就搞定,不用 fork 组件。
唯一留下的小尾巴是:NutUI 的 NutCalendar 在非中文 locale 下月份名称还是中文(比如传 locale="en-US",但一月仍显示“一月”)。翻源码发现它内部没做 i18n 映射,只靠 format 函数处理日期,月份名是硬编码的。我们最后妥协了:不改源码,改 UI,把日历只用在内部运营后台(中文环境),面向用户的预约页换成了原生 + 校验逻辑——虽然少了点交互感,但稳定,且省了 24KB 包体积。
回顾与反思
回看这三周开发,NutUI 最大的价值不是组件多,而是它的设计哲学很务实:每个组件职责单一、props 清晰、不强绑定状态管理、也不搞过度封装。比如 NutPicker 不自动请求数据,你爱从 Vuex 读、从 API 拉、还是写死数组,它只管渲染和回调。这种“不替你做决定”的思路,反而让集成成本更低。
当然也有遗憾:文档里有些高级用法藏得太深,比如 NutImagePreview 的自定义缩放手势,得翻 GitHub issues 才找到 scale 属性说明;还有部分组件 TypeScript 类型导出不完整(NutToast 的 clear 方法类型缺失),导致 IDE 提示不准,只能加 // @ts-ignore 硬上。
总的来说,如果你的项目是中短期 H5、团队熟悉 Vue、对包体积敏感,NutUI 是个靠谱的选择。它不像 Vant 那样“全家桶”,但正因如此,你不会被它带着跑偏——该自己控制的部分,它真就撒手不管。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如如何配合 Pinia 实现全局 Toast 管理、如何用 NutUI 的 createApp 方式动态挂载组件,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论