21 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 | patchVisibleCabinsData 定义但未使用 |
dead code |
| M4 | 前 27 个 [0,0] 空坐标参与渲染 |
无意义计算 |
| M5 | 魔法数字散布(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
// ========== 每页小屋数量(有效坐标数) ==========
// 前27个 [0,0] 空坐标已移除,减少无意义渲染
export const PAGE_SIZE = 45
// ========== 小屋坐标(背景原图坐标系) ==========
export const CABIN_COORDS = [
[-260, -20], [235, -20], [750, -20], [1265, -20],
[0, 150], [510, 150], [1010, 150], [1505, 150],
[-260, 350], [235, 350], [750, 350], [1265, 350],
[0, 530], [510, 530], [1010, 530], [1505, 520],
[-260, 730], [235, 730], [750, 730], [1265, 730],
[0, 910], [510, 910], [1010, 910], [1505, 910],
[-260, 1115], [235, 1115], [750, 1115], [1265, 1115],
[0, 1300], [510, 1300], [1010, 1300], [1505, 1300],
[-260, 1510], [235, 1510], [750, 1510], [1265, 1510],
[0, 1690], [510, 1690], [1010, 1690], [1505, 1690],
[-260, 1910], [235, 1910], [750, 1910], [1265, 1910],
]
// ========== 小屋类型定义 ==========
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
}
性能收益:前 27 个空坐标移除,buildVisibleCabins 循环从 72×5=360 次降到 45×5=225 次
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 构建
导出:
visibleCabins— 可视小屋数组(ref)currentPage— 当前页(ref)scaledCoords— 缩放后坐标(ref)cabinRenderDefs— cabin 渲染尺寸配置fetchPage(page)/ensurePages(center)/initCabin(opts)resetSquare()— 重置广场
关键设计:
-
坐标过滤:CABIN_COORDS 移至 config/cabin.js,前 27 个 [0,0] 移除
-
buildVisibleCabins 优化:
bannerBottom判断简化:y < bannerBottom →isAbove = true,昵称不显示- 移位算法(上方有数据的 cabin 移至下方空位)保留
- 不再使用
createSelectorQuery
-
patchVisibleCabinsData 启用:
- 数据更新时增量 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 @click="$emit('top3Click')">
<BannerTop3 />
</swiper-item>
<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>
</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)保留在页面级
五、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 } |
六、性能优化汇总
| # | 优化项 | 收益 |
|---|---|---|
| 1 | 前 27 个 [0,0] 空坐标过滤 | 循环次数下降 37%(360→225) |
| 2 | aspectFit 替代 scaleToFill |
减少图片重绘 |
| 3 | patchVisibleCabinsData 启用 |
数据更新避免 DOM 重建 |
| 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 网格布局配置方案
方案 A:规则网格 + 排除列表(推荐)
适用场景:背景图是规则网格,只有少数位置需要留空
// config/gridLayout.js
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, // 偶数行向右偏移
// 排除的格子(不渲染小屋的位置)
excludePositions: [
// 格式:[row, col],从 0 开始
// 例如:前 27 个位置留空
[0, 0], [0, 1], [0, 2], [0, 3],
[1, 0], [1, 1], [1, 2], [1, 3],
// ... 可根据背景图调整
]
}
}
// 自动生成坐标函数
export function generateGridCoordinates(config) {
const coords = []
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX, excludePositions } = config.grid
const excludeSet = new Set(excludePositions.map(([r, c]) => `${r},${c}`))
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
// 跳过排除的位置
if (excludeSet.has(`${row},${col}`)) continue
// 计算坐标
const offsetX = staggered && row % 2 === 1 ? staggerOffsetX : 0
const x = startX + col * spacingX + offsetX
const y = startY + row * spacingY
coords.push({
x,
y,
row,
col,
index: coords.length // 用户数据索引
})
}
}
return coords
}
方案 B:自定义网格模板(灵活度最高)
适用场景:背景图是不规则网格,需要精确控制每个位置
// config/gridLayout.js
export const GRID_TEMPLATE = {
backgroundWidth: 2012,
backgroundHeight: 1918,
// 定义每一行的布局
rows: [
// 第 0 行:4 个格子,交错布局
{
y: -20,
cells: [
{ x: -260, enabled: false }, // 留空
{ x: 235, enabled: false },
{ x: 750, enabled: false },
{ x: 1265, enabled: false }
]
},
// 第 1 行:4 个格子
{
y: 150,
cells: [
{ x: 0, enabled: true },
{ x: 510, enabled: true },
{ x: 1010, enabled: true },
{ x: 1505, enabled: true }
]
},
// ... 继续定义其他行
]
}
// 生成坐标函数
export function generateTemplateCoordinates(template) {
const coords = []
template.rows.forEach((row, rowIndex) => {
row.cells.forEach((cell, colIndex) => {
if (cell.enabled) {
coords.push({
x: cell.x,
y: row.y,
row: rowIndex,
col: colIndex,
index: coords.length
})
}
})
})
return coords
}
方案 C:混合方案(推荐用于你的场景)
结合规则网格和手动微调
// config/gridLayout.js
export const GRID_CONFIG = {
backgroundWidth: 2012,
backgroundHeight: 1918,
// 基础网格规则
baseGrid: {
rows: 11,
cols: 4,
startX: -260,
startY: -20,
spacingX: 515,
spacingY: 200,
staggered: true,
staggerOffsetX: 260
},
// 排除位置(前 27 个)
excludeCount: 27,
// 手动微调(覆盖自动生成的坐标)
manualAdjustments: {
// 格式:index: { x, y }
39: { x: 1505, y: 520 }, // 第 40 个位置 y 坐标微调
// 可以继续添加需要微调的位置
}
}
// 生成坐标函数
export function generateCoordinates(config) {
const coords = []
const { rows, cols, startX, startY, spacingX, spacingY, staggered, staggerOffsetX } = config.baseGrid
let index = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
// 跳过前 N 个位置
if (index < config.excludeCount) {
index++
continue
}
// 计算基础坐标
const offsetX = staggered && row % 2 === 1 ? staggerOffsetX : 0
let x = startX + col * spacingX + offsetX
let y = startY + row * spacingY
// 应用手动微调
if (config.manualAdjustments[index]) {
x = config.manualAdjustments[index].x ?? x
y = config.manualAdjustments[index].y ?? y
}
coords.push({ x, y, row, col, index })
index++
}
}
return coords
}
7.4 配置文件重构
新的 config/cabin.js
import { GRID_CONFIG, generateCoordinates } from './gridLayout.js'
// ========== 图片原始尺寸 ==========
export const IMAGE_W = GRID_CONFIG.backgroundWidth
export const IMAGE_H = GRID_CONFIG.backgroundHeight
// ========== 动态生成坐标 ==========
export const CABIN_COORDS = generateCoordinates(GRID_CONFIG).map(coord => [coord.x, coord.y])
// ========== 每页小屋数量 ==========
export const PAGE_SIZE = CABIN_COORDS.length
// ========== 小屋类型定义 ==========
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 { GRID_CONFIG } from './gridLayout.js'
7.5 可视化调试工具
为了方便调整网格参数,建议添加一个调试页面:
// 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, generateCoordinates } from './config/gridLayout.js'
const spacingX = ref(GRID_CONFIG.baseGrid.spacingX)
// 实时预览网格调整效果
</script>
7.6 迁移步骤
Step 1: 创建 config/gridLayout.js,定义网格规则
Step 2: 使用调试工具 debug-grid.vue 调整参数,直到网格对齐背景图
Step 3: 更新 config/cabin.js,使用动态生成的坐标
Step 4: 测试 square.vue,确保功能正常
Step 5: 删除旧的硬编码坐标数组
7.7 优势对比
| 维度 | 旧方案(硬编码) | 新方案(网格系统) |
|---|---|---|
| 维护成本 | 高(72 个坐标手动维护) | 低(只需调整几个参数) |
| 扩展性 | 差(增加格子需重新计算) | 好(自动生成) |
| 背景图更换 | 需重新标注所有坐标 | 只需调整网格参数 |
| 视觉一致性 | 手动标注易出错 | 自动对齐,一致性高 |
| 调试效率 | 低(需反复测试) | 高(可视化调试工具) |
7.8 推荐方案
根据你的需求,推荐使用 方案 C(混合方案):
- 使用规则网格自动生成大部分坐标
- 对个别需要微调的位置使用
manualAdjustments - 配合可视化调试工具快速调整参数
这样既保持了灵活性,又大幅降低了维护成本。
八、执行顺序
Step 1: config/gridLayout.js(新增)
Step 2: config/cabin.js(重构,使用动态坐标)
Step 3: pages/square/debug-grid.vue(新增,调试工具)
Step 4: composables/useSwipe.js
Step 5: composables/useCabin.js
Step 6: composables/useDialogRotation.js
Step 7: composables/useBanner.js
Step 8: components/CabinItem.vue
Step 9: components/BannerCarousel.vue
Step 10: components/NavArrows.vue
Step 11: square.vue(重写)
Step 12: 功能验证 + iOS/Android 测试