小红书小组件(miniwidget)开发实战:单页面viewState切换架构

0 阅读6分钟

环境标注:小红书开发者工具 + 小红书小组件 compileType: "miniwidget" + 基硎库 3.133.1 + 真机小红书App测试 | 2026-08-25

前言

本文复盘「跟着诗词去旅行」小红书小组件的开发实践。这是一个把58首中华诗词映射到47座城市衣食住行的国风旅游种草工具,参加小红书国风vibecoding大赛。

不承诺未证实的结果。本文只记录在小组件这个平台形态下,我实际遇到的架构约束和解决方案。如果你也在开发小红书小组件,或者关注单页面应用在小程序生态里的实践,本文可能有参考价值。

一、小红书小组件是什么

小红书小组件是一种运行在小红书App内的轻量应用形态,在 project.config.json 中通过 compileType: "miniwidget" 声明:

{
  "compileType": "miniwidget",
  "appid": "6a89cfb8bfcd5e0001b958f7",
  "libVersion": "3.133.1"
}

和微信小程序、uni-app跨端方案相比,小组件有几个关键差异:

维度微信小程序uni-app小红书小组件
页面数量多页面,wx.navigateTo跳转多页面,路由栈仅允许1个页面
APIwx.* 原生uni.* 跨端wx.* 兼容微信API
配置app.json pages数组pages.jsonapp.json pages仅1项
Canvas支持导出支持导出真机导出不可用(下一篇详述)

最核心的约束是仅允许1个页面app.jsonpages 数组只能有一项:

{
  "pages": ["pages/index/index"],
  "window": { "navigationBarTitleText": "跟着诗词去旅行" }
}

这意味着 wx.navigateTo 在小组件里没有意义——没有第二个页面可以跳。所有功能必须在单页面内完成。

二、单页面约束带来的架构挑战

「跟着诗词去旅行」的功能并不简单,包含:

  • 今日一诗(每日推荐)
  • 诗词列表(58首,季节+省份双筛选)
  • 衣食住行(5个Tab,每城景点详情)
  • 打卡(写感受+点亮印章)
  • 印章册(收集进度+称号解锁)
  • 心愿单(想去/已去切换)
  • 攻略生成(复制文案到剪贴板)
  • 中英双语切换

如果允许多页面,这些功能自然拆成6-8个页面。但单页面约束下,必须在一套视图栈里管理所有交互状态。

直接在一个页面里堆所有UI会导致:

  1. wxml巨长难以维护
  2. 所有数据一次性绑定,setData性能差
  3. 交互状态互相干扰

三、viewState切换架构

核心思路是用一个 viewState 字段控制顶层视图切换,每个状态对应一个独立面板。不是路由跳转,而是条件渲染。

3.1 状态定义

Page({
  data: {
    viewState: 'main',  // 'main' | 'stamp' | 'wishlist'
    showFilter: false,   // 筛选sheet叠加
    showCheckinSheet: false,  // 打卡sheet叠加
    // ... 其他数据
  },
})

viewState 管理的是整页切换(主视图/印章册/心愿单),show* 管理的是叠加层(筛选sheet/打卡sheet/详情弹窗)。这两类状态分开管理,避免一个状态字段承担过多职责。

3.2 视图层条件渲染

<!-- 主视图 -->
<view class="page" wx:if="{{viewState === 'main'}}">
  <view class="header">...</view>
  <view class="today-card">...</view>
  <view class="filter-toggle" bindtap="onToggleFilter">...</view>
  <scroll-view class="list-scroll">...</view>
  <view class="bottom-nav">
    <view bindtap="onGoStamp">印章册</view>
    <view bindtap="onGoWishlist">心愿单</view>
  </view>
</view>

<!-- 印章册面板 -->
<view class="panel-page" wx:if="{{viewState === 'stamp'}}">
  ...
  <view class="back-btn" bindtap="onBackMain">返回</view>
</view>

<!-- 心愿单面板 -->
<view class="panel-page" wx:if="{{viewState === 'wishlist'}}">
  ...
  <view class="back-btn" bindtap="onBackMain">返回</view>
</view>

<!-- 筛选Sheet(叠加层) -->
<view class="sheet-mask" wx:if="{{showFilter}}" bindtap="onCloseFilter">
  <view class="filter-sheet" catchtap="">...</view>
</view>

<!-- 打卡Sheet(叠加层) -->
<view class="sheet-mask" wx:if="{{showCheckinSheet}}" bindtap="onCloseCheckin">
  <view class="checkin-sheet" catchtap="">...</view>
</view>

关键点:

  • 顶层用 wx:if 而非 hidden,未激活的面板不渲染,减少 setData 开销
  • 叠加层(sheet)用独立的 show* 状态,可以和任何 viewState 组合
  • sheet 的遮罩层 bindtap 关闭,内容层 catchtap="" 阻止冒泡

3.3 状态切换函数

onGoStamp: function() {
  this._loadStamps()
  this.setData({ viewState: 'stamp' })
},
onGoWishlist: function() {
  this._loadWishlist()
  this.setData({ viewState: 'wishlist' })
},
onBackMain: function() {
  this.setData({ viewState: 'main' })
},

切换时先加载数据再切状态,避免面板渲染后数据还没到导致闪烁。

四、实现细节

4.1 衣食住行Tab的二级状态

主视图里诗词展开后,有食/景/衣/住/行5个Tab。这是 viewState 之外的二级状态:

data: {
  expandedId: '',      // 当前展开的诗词ID
  activeTab: 'spots',  // 当前Tab,默认"景"
  tabItems: [],        // 当前Tab的景点列表
  favItems: [],        // 收藏状态数组
}

onTabChange: function(e) {
  var key = e.currentTarget.dataset.key
  var items = this.data.expandedTravel.data[key] || []
  var favItems = []
  for (var j = 0; j < items.length; j++) {
    favItems.push(storage.isFavorite(this.data.expandedId, key, j))
  }
  this.setData({ activeTab: key, tabItems: items, favItems: favItems })
}

Tab切换时同步刷新景点列表和收藏状态,保证UI和数据一致。

4.2 catchtap阻止冒泡

列表项点击展开(bindtap),展开面板内的子操作(收藏/打卡/攻略)用 catchtap 阻止冒泡,避免点收藏又触发展开收起:

<view class="list-item" data-id="{{item.id}}" bindtap="onToggleExpand">
  <view class="expand-panel" wx:if="{{expandedId === item.id}}" catchtap="">
    <view class="item-fav" catchtap="onToggleFavorite"></view>
    <view class="action-btn" catchtap="onShowCheckin">打卡</view>
  </view>
</view>

catchtap="" 空处理是阻止冒泡的常用技巧,比给每个子元素单独写catchtap更简洁。

4.3 textarea的冒泡陷阱

打卡sheet里有textarea输入感受。发现一个问题:点击textarea时sheet会关闭。原因是textarea是原生组件,父级view的 catchtap="" 拦不住它的冒泡。

解决方法是给textarea本身加 catchtap

<textarea class="checkin-input" catchtap="onStopPropagation" bindinput="onCheckinInput" />
onStopPropagation: function() {},

空函数即可,目的是让事件在textarea处停下。

五、踩坑记录

5.1 wx.navigateTo静默失败

最初想用 wx.navigateTo 跳到第二个页面,代码不报错但无反应。查文档才确认小组件仅允许1个页面。这是最开始的架构约束发现,直接促成了viewState方案。

5.2 setData数据量过大

诗词列表58项,每项含诗句/作者/城市/景点/季节等字段。最初一次性setData整个列表,在低端机上明显卡顿。优化为只setData列表的展示字段(id/poem/author/city/spot/seasonText),完整数据在展开时按需从signs.js读取。

5.3 sheet层级冲突

筛选sheet和打卡sheet最初用同一个 showSheet 状态,导致打开筛选再打开打卡时互相覆盖。拆成 showFiltershowCheckinSheet 两个独立状态后解决。

六、总结

要点

  1. 小红书小组件 compileType: "miniwidget" 仅允许1个页面,wx.navigateTo 不可用
  2. viewState 管理整页切换,show* 管理叠加层,职责分离
  3. 顶层 wx:if 而非 hidden,减少非激活面板的渲染开销
  4. catchtap="" 阻止冒泡,textarea需单独加 catchtap
  5. setData只传展示字段,完整数据按需读取

经验教训

单页面约束初看是限制,实际逼迫你把交互状态建模得更清晰。viewState本质是一个有限状态机,每个状态对应一组确定的UI和数据。这种约束反而让架构更干净——没有路由栈的复杂性,所有状态变迁都在一个文件里可追溯。

讨论与后续

  • 小组件的包体积限制是2M,本项目212KB,还有空间加功能
  • 如果功能继续增长,viewState会不会爆炸?考虑过状态机库,但小组件场景下手动管理更可控