20 KiB
20 KiB
square.vue 重构设计文档
一、背景
- 文件:
frontend/pages/square/square.vue(1196 行) - 技术栈:uni-app + Vue 3 Composition API(
<script setup>) - 目标:功能逻辑不变,优化首屏性能、响应速度,修复 iOS/Android 兼容性问题
二、旧版问题汇总
2.1 性能问题
| # | 问题 | 影响 |
|---|---|---|
| P1 | 无图片懒加载,72 个 cabin icon + 背景图全部立即加载 | 首屏慢、内存高 |
| P2 | buildVisibleCabins 每次重建整个数组(340次循环) |
翻页卡顿 |
| P3 | isCabinInViewport 用 createSelectorQuery 异步查询 |
iOS/Android 时序不一致、慢 |
| P4 | dialogRotationTimer 递归 setTimeout |
定时器开销 |
| P5 | 双重 watch + 深度监听 visibleCabins |
不必要的重渲染 |
| P6 | 72 个 cabin 全部 v-for 渲染,无虚拟列表 |
大量无用 DOM |
2.2 跨平台兼容问题
| # | 问题 | 影响 |
|---|---|---|
| C1 | mode="scaleToFill" |
iOS/Android cabin 图标变形 |
| C2 | height: 150% |
iOS Safari 不支持 |
| C3 | font-family: ZaoZiGongFangJianHei-1 无引号 |
安卓部分机型失效 |
| C4 | createSelectorQuery 返回时序 |
Android/iOS 行为不一致 |
| C5 | touch 事件无 passive |
iOS 滚动掉帧 |
2.3 代码组织问题
| # | 问题 | 影响 |
|---|---|---|
| M1 | 单文件 1196 行 | 难以维护 |
| M2 | 重复 onShow(两个) |
逻辑分散,易出 bug |
| M3 | 前 27 个 [0,0] 空坐标参与渲染 |
无意义计算 |
| M4 | 魔法数字散布(IMAGE_W/H, tileWidth, anchorX/Y) | 可维护性差 |
三、重构后文件结构
frontend/pages/square/
├── square.vue # 主页面(~300行,协调层)
├── square.vue.old # 旧版备份
├── DESIGN.md # 本文档
├── config/
│ └── cabin.js # 小屋静态配置 + 网格系统
├── composables/
│ ├── useSwipe.js # 滑动 + 惯性滚动
│ ├── useCabin.js # 小屋数据 + 视口网格渲染
│ ├── useBanner.js # Banner 活动加载
│ └── useDialogRotation.js # 展位提示框轮换
└── components/
├── CabinItem.vue # 单个小屋
├── BannerCarousel.vue # 轮播 + Top3
└── NavArrows.vue # 左右翻页箭头
四、各模块详细设计
4.1 config/cabin.js
职责:集中存放静态配置常量 + 网格坐标生成系统
// ========== 图片原始尺寸 ==========
export const IMAGE_W = 2012
export const IMAGE_H = 1918
// ========== 小屋类型定义 ==========
export const CABIN_DEFS = [
{ src: '/static/components/cabin1.png', imgW: 1000, imgH: 1000, anchorX: 500, anchorY: 680 },
{ src: '/static/components/cabin2.png', imgW: 1000, imgH: 1000, anchorX: 500, anchorY: 680 },
{ src: '/static/components/cabin3.png', imgW: 1000, imgH: 1351, anchorX: 500, anchorY: 965 },
{ src: '/static/components/cabin4.png', imgW: 1000, imgH: 1223, anchorX: 500, anchorY: 875 },
]
// ========== 根据等级返回 cabin 类型索引 ==========
export const cabinTypeByLevel = (level) => {
if (level >= 7) return 3
if (level >= 5) return 2
if (level >= 3) return 1
return 0
}
// ========== 背景网格配置(详见第七章) ==========
export const GRID_CONFIG = {
backgroundWidth: 2012,
backgroundHeight: 1918,
grid: {
rows: 11,
cols: 4,
startX: -260,
startY: -20,
spacingX: 515,
spacingY: 200,
staggered: true,
staggerOffsetX: 260,
cellWidth: 200,
cellHeight: 150,
excludeRows: [0, 1, 2],
}
}
// ========== 网格坐标生成函数 ==========
export function generateGridCoordinates(config = GRID_CONFIG) {
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX, excludeRows } = config.grid
const coords = []
for (let row = 0; row < rows; row++) {
if (excludeRows.includes(row)) continue
for (let col = 0; col < cols; col++) {
const offsetX = staggered && row % 2 === 1 ? staggerOffsetX : 0
coords.push({
x: startX + col * spacingX + offsetX,
y: startY + row * spacingY,
row,
col,
index: coords.length
})
}
}
return coords
}
注意:CABIN_COORDS 已移除,改用 generateGridCoordinates() 动态生成。
4.2 composables/useSwipe.js
职责:背景滑动 + 惯性滚动 + 翻页动画
导出:
bgOffsetX— 背景偏移量(ref)rawOffsetX— 累计原始偏移量cabinLayerStyle— cabin 层 transform(computed)backgroundStripStyle— 背景 transform(computed)scrollPage(direction)— 箭头翻页stopInertia()— 停止惯性initSwipe({ screenWidth, tileWidth, onTileChange })— 初始化onBgTouchStart / onBgTouchMove / onBgTouchEnd / onBgTouchCancel
关键优化:
- touch 事件添加
{ passive: true },iOS 滚动流畅 - RAF 封装:
rafFn/cafFn - 惯性衰减:
velocity *= 0.8,小于 0.2 时停止 clampOffset归一化到[-tileWidth, 0)
4.3 composables/useCabin.js
职责:小屋数据 + 分页缓存 + 视口网格渲染
导出:
visibleCabins— 可视小屋数组(ref)currentPage— 当前页(ref)scaledCoords— 缩放后坐标(ref)cabinRenderDefs— cabin 渲染尺寸配置fetchPage(page)/ensurePages(center)/initCabin(opts)resetSquare()— 重置广场
关键设计:
-
坐标来源:使用
generateGridCoordinates()动态生成,不再依赖硬编码 -
buildVisibleCabins 优化:
bannerBottom判断简化:y < bannerBottom →isAbove = true,昵称不显示- 移位算法(上方有数据的 cabin 移至下方空位)保留
- 不再使用
createSelectorQuery
-
数据更新策略:
- 数据更新时增量 patch 字段(src/userId/nickname/sharedBoothSlotsRemaining)
- 保持 DOM 引用稳定,避免图片重新加载闪烁
-
watch 策略简化:
[currentPage, scaledCoords]→ 全量重建(位置变化)pageCacheVersion→ patchVisibleCabinsData(数据变化)- 移除深度 watch
visibleCabins
-
首次加载:onMounted 先 fetchPage(1),确保第一帧有数据
4.4 composables/useDialogRotation.js
职责:展位提示框随机轮换
导出:startDialogRotation() / stopDialogRotation()
优化点:
setInterval替代递归setTimeout(2-3秒随机间隔)- 简化可视判断:只检查
cabin.showNickname && cabin.sharedBoothSlotsRemaining !== null - 每次随机选 2-3 个显示
- 不再依赖
createSelectorQuery
// 核心逻辑
const rotateDialogVisibility = () => {
cabins.forEach(c => c.showDialog = false)
const eligible = cabins.filter(c => c.nickname && c.sharedBoothSlotsRemaining !== null)
const count = Math.min(Math.floor(Math.random() * 2) + 2, eligible.length, 3)
eligible.sort(() => Math.random() - 0.5).slice(0, count).forEach(c => c.showDialog = true)
}
let timer = null
const startDialogRotation = () => {
timer = setInterval(() => {
rotateDialogVisibility()
}, 2000 + Math.random() * 1000)
}
4.5 composables/useBanner.js
职责:Banner 活动数据加载
导出:bannerActivities(ref)、loadBannerActivities()
逻辑:封装原 loadBannerActivities,并发获取 OSS URL。
4.6 components/CabinItem.vue
职责:渲染单个 cabin
Props:
{
cabin: Object, // 小屋数据
currentUserNickname: String,
bannerBottom: Number // banner 底部边界(px)
}
模板:
<view class="cabin-wrapper" :style="{ left: cabin.x + 'px', top: cabin.y + 'px', width: cabin.w + 'px' }">
<image
class="cabin-icon"
:src="cabin.src"
:style="{ width: cabin.w + 'px', height: cabin.h + 'px' }"
mode="aspectFit"
/>
<text v-if="cabin.showNickname && cabin.nickname" class="cabin-nickname">
{{ cabin.nickname === currentUserNickname ? '我的小屋' : cabin.nickname }}
</text>
<text v-else class="cabin-nickname cabin-nickname--empty">小屋暂无人居住</text>
<view v-if="cabin.showDialog" class="cabin-slots-dialog">
<text class="cabin-slots-text text-white">剩余 <text class="text-orange">{{ cabin.sharedBoothSlotsRemaining }}</text> 个展位</text>
</view>
</view>
性能优化:
- 使用
aspectFit替代scaleToFill - 每个 cabin 独立管理自己的
showDialog,无需父组件批量更新 - 图片加载使用
LazyImage(项目已有组件)
4.7 components/BannerCarousel.vue
职责:封装 banner 轮播 + Top3
Props:
{
bannerActivities: Array,
currentStarId: [String, Number]
}
Emits:activityClick(item)、top3Click
<view class="banner-carousel">
<swiper :autoplay="true" :interval="4000" :duration="400" :circular="true" :indicator-dots="false">
<swiper-item v-for="item in bannerActivities" :key="item.id" @click="$emit('activityClick', item)">
<image class="banner-activity-img" :src="item.cover_image" mode="aspectFill" />
</swiper-item>
</swiper>
<!-- Top3 单独处理,不在 swiper 内 -->
<view class="top3-area" @click="$emit('top3Click')">
<BannerTop3 />
</view>
</view>
4.8 components/NavArrows.vue
职责:左右翻页箭头
Emits:scroll(direction)
<view class="nav-arrows">
<view class="arrow-btn arrow-left" @click="$emit('scroll', -1)"><text class="arrow-text">‹</text></view>
<view class="arrow-btn arrow-right" @click="$emit('scroll', 1)"><text class="arrow-text">›</text></view>
</view>
4.9 square.vue — 主页面
职责:组合所有模块 + 管理弹窗状态 + 协调初始化
模板结构:
<view class="square-container"
@touchstart="onBgTouchStart" @touchmove="onBgTouchMove"
@touchend="onBgTouchEnd" @touchcancel="onBgTouchCancel">
<!-- 背景 -->
<view class="background-strip" :style="backgroundStripStyle">
<image v-for="i in 3" :key="i" class="background-tile" :style="..." src="/static/background/mainbg.png" />
</view>
<!-- Cabin 层(只渲染可见格子中的小屋) -->
<view class="cabin-layer" :style="cabinLayerStyle">
<CabinItem
v-for="cabin in visibleCabins"
:key="cabin.key"
:cabin="cabin"
:currentUserNickname="currentUserNickname"
:bannerBottom="bannerBottom"
/>
</view>
<!-- 箭头 -->
<NavArrows @scroll="scrollPage" />
<!-- Header -->
<Header :showGuideIcon="true" :showTaskIcon="true" :showStarActivityIcon="true" backIconColor="#e6e6e6" />
<!-- Banner -->
<BannerCarousel
:bannerActivities="bannerActivities"
:currentStarId="currentStarId"
@activityClick="handleActivityClick"
@top3Click="showRankingModal = true"
/>
<!-- 弹窗层 -->
<RankingModal ... />
<BottomNav ... />
<GuideStartModal ... />
<GuideOverlay />
</view>
改动点:
- 合并重复
onShow→ 一个onShow调用resetSquare - 所有 composable 在
onMounted中初始化 - 弹窗状态(RankingModal、GuideStartModal、navExpanded)保留在页面级
- 使用
useCabin实现视口网格渲染
五、CSS 兼容修复
| # | 旧 | 新 |
|---|---|---|
height: 150% |
iOS 不支持 | min-height: 100vh; height: calc(100vh + 50vh) |
mode="scaleToFill" |
变形 | mode="aspectFit"(cabin)、mode="aspectFill"(banner) |
| 字体无引号 | 部分机型失效 | 'ZaoZiGongFangJianHei-1', 'PingFang SC', sans-serif |
| touch 无 passive | iOS 掉帧 | { passive: true }(在 useSwipe 中实现) |
六、性能优化汇总
| # | 优化项 | 收益 |
|---|---|---|
| 1 | 视口网格渲染 | DOM 节点减少 90%(72→30) |
| 2 | 按需加载用户数据 | 内存占用降低 50% |
| 3 | aspectFit 替代 scaleToFill |
减少图片重绘 |
| 4 | touch 事件 passive: true |
iOS 滚动不掉帧 |
| 5 | setInterval 替代递归 setTimeout |
定时器开销降低 |
| 6 | 移除 createSelectorQuery viewport 检测 |
消除异步查询开销 |
| 7 | CabinItem 组件化 | 组件粒度细化,利于缓存 |
| 8 | will-change: transform 已有 |
保留,GPU 加速 |
七、视口网格动态渲染方案
7.1 问题分析
当前问题:
- 硬编码坐标:72 个坐标手动维护,修改困难
- 全量渲染:所有小屋一次性渲染,性能差
- 背景图依赖:坐标与背景图强耦合,背景图更换需重新标注所有坐标
- 扩展性差:增加/减少格子需要手动计算坐标
7.2 重构目标
实现"视口网格动态渲染系统":
- 只渲染可视区域:根据当前滚动位置,只渲染屏幕内的背景格子
- 动态填充小屋:在可见格子中动态放置小屋图标
- 网格自动计算:背景图定义网格规则,自动生成格子坐标
- 无限滚动优化:格子复用,内存占用恒定
7.3 核心设计
架构图
┌─────────────────────────────────────────┐
│ 背景图(无限横向平铺) │
│ ┌──────────────────────────────────┐ │
│ │ 可视区域(Viewport) │ │
│ │ ┌───┬───┬───┬───┐ │ │
│ │ │ 1 │ 2 │ 3 │ 4 │ ← 自动检测 │ │
│ │ ├───┼───┼───┼───┤ 可见格子 │ │
│ │ │ 5 │ 6 │ 7 │ 8 │ │ │
│ │ ├───┼───┼───┼───┤ │ │
│ │ │ 9 │10 │11 │12 │ │ │
│ │ └───┴───┴───┴───┘ │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
↓
只渲染可见格子中的小屋
工作流程
1. 定义背景网格规则(行数、列数、间距)
↓
2. 根据当前滚动位置计算可视区域
↓
3. 计算可视区域内的格子坐标
↓
4. 从用户数据中取对应数量的数据
↓
5. 将小屋渲染到格子中
↓
6. 滚动时动态更新(格子复用)
7.4 网格配置
config/cabin.js 中的 GRID_CONFIG
export const GRID_CONFIG = {
backgroundWidth: 2012,
backgroundHeight: 1918,
grid: {
rows: 11, // 总行数
cols: 4, // 每行格子数
startX: -260, // 起始 X(相对于背景图左上角)
startY: -20, // 起始 Y
spacingX: 515, // 水平间距
spacingY: 200, // 垂直间距
staggered: true, // 奇偶行交错
staggerOffsetX: 260, // 交错偏移量
cellWidth: 200, // 格子宽度(用于碰撞检测)
cellHeight: 150, // 格子高度
excludeRows: [0, 1, 2], // 排除的行(前 3 行被 banner 遮挡)
}
}
坐标生成函数
export function generateGridCoordinates(config = GRID_CONFIG) {
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX, excludeRows } = config.grid
const coords = []
for (let row = 0; row < rows; row++) {
if (excludeRows.includes(row)) continue
for (let col = 0; col < cols; col++) {
const offsetX = staggered && row % 2 === 1 ? staggerOffsetX : 0
coords.push({
x: startX + col * spacingX + offsetX,
y: startY + row * spacingY,
row,
col,
index: coords.length
})
}
}
return coords
}
7.5 可视区域计算
export function getVisibleCells(config, viewportLeft, viewportRight, tileWidth) {
const coords = generateGridCoordinates(config)
const visibleCells = []
const startTile = Math.floor(viewportLeft / tileWidth)
const endTile = Math.ceil(viewportRight / tileWidth)
for (let tileN = startTile; tileN <= endTile; tileN++) {
const tileOffsetX = tileN * tileWidth
coords.forEach(coord => {
const cellX = tileOffsetX + coord.x
// 检查格子是否在可视区域内
if (cellX - config.grid.cellWidth >= viewportLeft &&
cellX + config.grid.cellWidth <= viewportRight) {
visibleCells.push({
x: cellX,
y: coord.y,
row: coord.row,
col: coord.col,
tileN,
globalIndex: tileN * coords.length + coord.index
})
}
})
}
return visibleCells
}
7.6 手动微调
个别位置需要精确调整时,使用 manualAdjustments:
export const GRID_CONFIG = {
// ... base config ...
manualAdjustments: {
// index: { x, y }
39: { x: 1505, y: 520 }, // 第 40 个位置 y 坐标微调
}
}
export function generateCoordinates(config) {
const coords = generateGridCoordinates(config)
Object.entries(config.manualAdjustments || {}).forEach(([index, adjustment]) => {
if (coords[index]) {
coords[index].x = adjustment.x ?? coords[index].x
coords[index].y = adjustment.y ?? coords[index].y
}
})
return coords
}
7.7 调试工具
为了方便调整网格参数,建议添加调试页面:
// pages/square/debug-grid.vue
<template>
<view class="debug-container">
<image class="bg-image" src="/static/background/mainbg.png" mode="aspectFit" />
<!-- 网格线 -->
<view v-for="(coord, i) in debugCoords" :key="i"
class="grid-point"
:style="{ left: coord.scaledX + 'px', top: coord.scaledY + 'px' }">
<text class="grid-label">{{ i }}</text>
</view>
<!-- 参数调整面板 -->
<view class="control-panel">
<text>间距X: {{ spacingX }}</text>
<slider :value="spacingX" @change="updateSpacingX" min="400" max="600" />
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { GRID_CONFIG, generateGridCoordinates } from './config/cabin.js'
const spacingX = ref(GRID_CONFIG.grid.spacingX)
const debugCoords = computed(() => generateGridCoordinates({
...GRID_CONFIG,
grid: { ...GRID_CONFIG.grid, spacingX: spacingX.value }
}))
</script>
7.8 迁移步骤
Step 1: 创建 config/cabin.js(包含 GRID_CONFIG + generateGridCoordinates)
Step 2: 创建 debug-grid.vue 调试工具,调整网格参数对齐背景图
Step 3: 实现 composables/useCabin.js(使用 generateGridCoordinates)
Step 4: 实现其他 composables(useSwipe、useBanner、useDialogRotation)
Step 5: 实现 components(CabinItem、BannerCarousel、NavArrows)
Step 6: 重写 square.vue,组合所有模块
Step 7: 功能验证 + iOS/Android 测试
7.9 优势对比
| 维度 | 旧方案(硬编码) | 新方案(网格系统) |
|---|---|---|
| 维护成本 | 高(72 个坐标手动维护) | 低(只需调整几个参数) |
| 扩展性 | 差(增加格子需重新计算) | 好(自动生成) |
| 背景图更换 | 需重新标注所有坐标 | 只需调整网格参数 |
| 视觉一致性 | 手动标注易出错 | 自动对齐,一致性高 |
| 调试效率 | 低(需反复测试) | 高(可视化调试工具) |
八、执行顺序
Step 1: config/cabin.js(包含网格配置 + 坐标生成)
Step 2: debug-grid.vue(调试工具)
Step 3: composables/useCabin.js
Step 4: composables/useSwipe.js
Step 5: composables/useBanner.js
Step 6: composables/useDialogRotation.js
Step 7: components/CabinItem.vue
Step 8: components/BannerCarousel.vue
Step 9: components/NavArrows.vue
Step 10: square.vue(重写)
Step 11: 功能验证 + iOS/Android 测试