NutUI组件库在真实项目中的实践踩坑与优化总结

一涵菲 框架 阅读 1,274
赞 31 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年底接了个电商类H5活动页,需求挺典型:3天上线、兼容iOS/Android主流微信/QQ浏览器、要带商品列表、轮播图、弹窗抽奖、地址选择器,还得支持微信JSSDK调用分享。团队里没人想手撸一套UI组件——毕竟时间紧,而且“滚动卡顿”“弹窗蒙层穿透”这种坑我们三年前就踩吐了。

NutUI组件库在真实项目中的实践踩坑与优化总结

一开始列了三个备选:Vant、NutUI、Taro UI。Vant文档最全但体积大(gzip后 180KB+),我们整个项目 JS 总包才压到 300KB;Taro UI 偏向多端,但这次纯 H5,没必要为未来可能的小程序做冗余适配;最后选了 NutUI,理由很实在:轻(gzip 后 68KB)、文档清晰、对 Vue 3 + Composition API 支持原生、组件粒度细,比如 PopupOverlay 是分离的,改蒙层样式不牵连弹窗逻辑——这点在后面救了我两次。

最大的坑:性能问题

真上手才发现,不是组件不好,是“怎么用”比“有没有”更致命。最典型的是商品瀑布流页面:后端给的是分页数据,前端用 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 类型导出不完整(NutToastclear 方法类型缺失),导致 IDE 提示不准,只能加 // @ts-ignore 硬上。

总的来说,如果你的项目是中短期 H5、团队熟悉 Vue、对包体积敏感,NutUI 是个靠谱的选择。它不像 Vant 那样“全家桶”,但正因如此,你不会被它带着跑偏——该自己控制的部分,它真就撒手不管。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如如何配合 Pinia 实现全局 Toast 管理、如何用 NutUI 的 createApp 方式动态挂载组件,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论