环境标注:小红书开发者工具 + 小红书小组件 compileType: "miniwidget" + 基硎库 3.133.1 + 真机小红书App测试 | 2026-08-25
前言
本文复盘「跟着诗词去旅行」小红书小组件的开发实践。这是一个把58首中华诗词映射到47座城市衣食住行的国风旅游种草工具,参加小红书国风vibecoding大赛。
不承诺未证实的结果。本文只记录在小组件这个平台形态下,我实际遇到的架构约束和解决方案。如果你也在开发小红书小组件,或者关注单页面应用在小程序生态里的实践,本文可能有参考价值。
一、小红书小组件是什么
小红书小组件是一种运行在小红书App内的轻量应用形态,在 project.config.json 中通过 compileType: "miniwidget" 声明:
json
{
"compileType": "miniwidget",
"appid": "6a89cfb8bfcd5e0001b958f7",
"libVersion": "3.133.1"
}
和微信小程序、uni-app跨端方案相比,小组件有几个关键差异:
| 维度 | 微信小程序 | uni-app | 小红书小组件 |
|---|---|---|---|
| 页面数量 | 多页面,wx.navigateTo跳转 | 多页面,路由栈 | 仅允许1个页面 |
| API | wx.* 原生 | uni.* 跨端 | wx.* 兼容微信API |
| 配置 | app.json pages数组 | pages.json | app.json pages仅1项 |
| Canvas | 支持导出 | 支持导出 | 真机导出不可用(下一篇详述) |
最核心的约束是仅允许1个页面 。app.json 的 pages 数组只能有一项:
json
{
"pages": ["pages/index/index"],
"window": { "navigationBarTitleText": "跟着诗词去旅行" }
}
这意味着 wx.navigateTo 在小组件里没有意义------没有第二个页面可以跳。所有功能必须在单页面内完成。
二、单页面约束带来的架构挑战
「跟着诗词去旅行」的功能并不简单,包含:
- 今日一诗(每日推荐)
- 诗词列表(58首,季节+省份双筛选)
- 衣食住行(5个Tab,每城景点详情)
- 打卡(写感受+点亮印章)
- 印章册(收集进度+称号解锁)
- 心愿单(想去/已去切换)
- 攻略生成(复制文案到剪贴板)
- 中英双语切换
如果允许多页面,这些功能自然拆成6-8个页面。但单页面约束下,必须在一套视图栈里管理所有交互状态。
直接在一个页面里堆所有UI会导致:
- wxml巨长难以维护
- 所有数据一次性绑定,setData性能差
- 交互状态互相干扰
三、viewState切换架构
核心思路是用一个 viewState 字段控制顶层视图切换,每个状态对应一个独立面板。不是路由跳转,而是条件渲染。
3.1 状态定义
javascript
Page({
data: {
viewState: 'main', // 'main' | 'stamp' | 'wishlist'
showFilter: false, // 筛选sheet叠加
showCheckinSheet: false, // 打卡sheet叠加
// ... 其他数据
},
})
viewState 管理的是整页切换 (主视图/印章册/心愿单),show* 管理的是叠加层(筛选sheet/打卡sheet/详情弹窗)。这两类状态分开管理,避免一个状态字段承担过多职责。
3.2 视图层条件渲染
html
<!-- 主视图 -->
<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 状态切换函数
javascript
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 之外的二级状态:
javascript
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 阻止冒泡,避免点收藏又触发展开收起:
html
<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:
html
<textarea class="checkin-input" catchtap="onStopPropagation" bindinput="onCheckinInput" />
javascript
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 状态,导致打开筛选再打开打卡时互相覆盖。拆成 showFilter 和 showCheckinSheet 两个独立状态后解决。
六、总结
要点
- 小红书小组件
compileType: "miniwidget"仅允许1个页面,wx.navigateTo不可用 - 用
viewState管理整页切换,show*管理叠加层,职责分离 - 顶层
wx:if而非hidden,减少非激活面板的渲染开销 catchtap=""阻止冒泡,textarea需单独加catchtap- setData只传展示字段,完整数据按需读取
经验教训
单页面约束初看是限制,实际逼迫你把交互状态建模得更清晰。viewState本质是一个有限状态机,每个状态对应一组确定的UI和数据。这种约束反而让架构更干净------没有路由栈的复杂性,所有状态变迁都在一个文件里可追溯。
讨论与后续
- 小组件的包体积限制是2M,本项目212KB,还有空间加功能
- 如果功能继续增长,viewState会不会爆炸?考虑过状态机库,但小组件场景下手动管理更可控