Taro + Vue 3 项目开发经验教训总结
本文档总结了在开发 Manulife WeApp 项目过程中遇到的问题、解决方案和最佳实践,为后续 Taro 项目提供参考。
目录
- 组件抽取与复用
- NutUI 组件使用陷阱
- 生命周期钩子使用陷阱
- 静态资源加载问题
- 样式处理策略
- 性能优化
- 代码质量
- 架构设计
- 跨页面通信 ⭐ 新增
-
开发工作流 ⭐ 新增
- Mock 数据环境自动切换 ⭐ 新增
组件抽取与复用
✅ 最佳实践
1. "第 3 次出现原则"(Rule of Three)
原则:当相同代码模式出现 3 次时,必须抽取为 Composable 或组件。
案例 1: useSectionList Composable
-
问题:
onboarding,family-office,signing三个页面都使用相同的分组列表模式 -
解决: 创建
src/composables/useSectionList.js - 收益: 减少约 60 行重复代码,提升可维护性
// src/composables/useSectionList.js
/**
* 分组列表页面 Composable
*
* @description 统一管理分组列表数据和点击事件
* @param {Object} sectionData - 分组数据
* @param {Function} handleClick - 点击回调函数
* @returns {Object} { sections, onItemClick }
*/
export function useSectionList(sectionData, handleClick) {
const sections = ref(sectionData)
const onItemClick = (item) => {
handleClick(item)
}
return { sections, onItemClick }
}
案例 2: useFileOperation Composable
- 问题: 收藏页和产品详情页都有约 200 行重复的文件操作代码
-
解决: 创建
src/composables/useFileOperation.js - 收益: 减少 ~290 行重复代码,统一修改点
案例 3: useListItemClick Composable
- 问题: 多个列表页面的点击逻辑分散且重复
-
解决: 创建
src/composables/useListItemClick.js - 收益: 支持上下文感知的行为路由,扩展性强
2. 组件设计原则
案例: SectionCard 默认渐变色优化
- 问题: 三个页面都重复设置默认渐变色
-
解决: 在
SectionCard组件内部使用computed属性内置默认值 - 收益: 修改默认值只需在一处,简化页面代码
<!-- src/components/SectionCard.vue -->
<script setup>
const props = defineProps({
bgGradient: {
type: String,
default: ''
}
})
// ✅ GOOD - 内置默认渐变色
const computedBgGradient = computed(() => {
return props.bgGradient || 'linear-gradient(90deg, #EFF6FF 0%, #DBEAFE 100%)'
})
</script>
3. 何时抽取组件
触发条件:
- ✅ 代码重复 ≥ 2 次 → 警惕
- ✅ 代码重复 ≥ 3 次 → 必须抽取
- ✅ v-for 模板超过 5 行 → 提取列表项组件
- ✅ 组件模板超过 150 行 → 拆分组件
- ✅ 函数超过 50 行 → 拆分函数
❌ 反模式
1. 过早抽取
// ❌ BAD - 代码只出现 1 次就抽取
function useSpecificFeature() {
// 只在一个地方使用...
}
// ✅ GOOD - 等待第 3 次出现再抽取
// 第 1、2 次直接实现,第 3 次时抽取
2. 过度抽象
// ❌ BAD - 过度通用的抽象,难以理解
function useGenericList({ config, handlers, options }) {
// 太多参数,过于复杂
}
// ✅ GOOD - 专注特定场景
function useSectionList(sectionData, handleClick) {
// 简单直接,易于理解
}
NutUI 组件使用陷阱
❌ 坑 1: NutUI textarea 样式无法覆盖
问题描述:
<!-- ❌ 无法生效 -->
<nut-textarea
v-model="content"
class="custom-textarea"
/>
// ❌ 深度选择器无效
.custom-textarea :deep(.nut-textarea__textarea) {
padding: 24rpx;
}
原因: NutUI textarea 组件使用 Shadow DOM 或内部样式隔离
解决方案: 使用原生小程序 <textarea> 组件
<!-- ✅ 完全控制样式 -->
<textarea
v-model="content"
class="custom-textarea"
maxlength="200"
@input="handleInput"
/>
<view class="char-count">{{ content.length }}/200</view>
❌ 坑 2: IconFont 动态切换不响应
问题描述:
<!-- ❌ 图标不更新 -->
<IconFont :name="iconName" />
const iconName = ref('heart')
setTimeout(() => {
iconName.value = 'heart-fill' // 视图不更新!
}, 1000)
原因: NutUI 的 IconFont 组件在某些环境下未正确响应 props 变化
解决方案: 添加 :key 强制重新渲染
<!-- ✅ 添加 key 属性 -->
<IconFont :name="iconName" :key="iconName" />
❌ 坑 3: 嵌套 nut-popup 导致真机层级冲突
问题描述:
<!-- ❌ 真机测试时,内层弹窗被外层弹窗的底部按钮遮挡 -->
<PlanPopup title="申请计划书">
<!-- 表单内容 -->
<!-- 嵌套的弹窗 -->
<nut-popup position="bottom" v-model:visible="showIndustryPicker">
<nut-picker :columns="industryColumns" />
</nut-popup>
</PlanPopup>
场景: SchemeA 组件使用 PlanPopup 容器(外层 nut-popup),内部再嵌套一个行业选择器(内层 nut-popup)
原因:
- 小程序渲染机制: 微信小程序的双线程渲染导致嵌套弹窗的层级处理异常
-
渲染上下文: 外层
nut-popup创建了独立的渲染上下文,内层即使设置更高z-index也可能被遮挡 - DOM 顺序: 外层弹窗的底部按钮在 DOM 中位于内层弹窗之后,真机渲染时可能覆盖
解决方案: 将内层弹窗提升到外层弹窗外部
<!-- ✅ 正确:弹窗并列,不嵌套 -->
<PlanPopup title="申请计划书">
<!-- 表单内容 -->
</PlanPopup>
<!-- 内层弹窗提升到外层,设置更高的 z-index -->
<nut-popup
position="bottom"
v-model:visible="showIndustryPicker"
:z-index="9999"
:overlay="true"
>
<nut-picker :columns="industryColumns" />
</nut-popup>
关键点:
- ✅ 将内层
nut-popup移到外层组件外部 - ✅ 设置
:z-index="9999"确保显示在最上层 - ✅ 保持 DOM 顺序:外层弹窗 → 内层弹窗(后渲染的在上层)
适用场景:
- 任何需要在
nut-popup内部再使用nut-popup的情况 - 特别是选择器(Picker)、对话框(Dialog)等弹窗组件
❌ 坑 4: scroll-view 必须使用 : 绑定语法(Taro/Vue 陷阱) ⭐ 2026-02-08 新增
问题描述:
<!-- ❌ 滚动不生效! -->
<scroll-view scroll-y>
内容...
</scroll-view>
<!-- ❌ 滚动也不生效! -->
<scroll-view scroll-y="true">
内容...
</scroll-view>
错误表现:
- scroll-view 组件显示正常,但无法滚动
- 没有报错信息,静默失败
- 调试时发现滚动事件不触发
原因: 在 Taro/Vue 中,布尔属性必须使用 : 绑定语法才能正确传递布尔值
-
scroll-y或scroll-y="true"→ 传递字符串"true",scroll-view 无法识别为布尔值 -
:scroll-y="true"→ 传递布尔值true,scroll-view 正确识别为启用滚动
解决方案: 始终使用 : 绑定语法
<!-- ✅ 正确:使用 : 绑定 -->
<scroll-view :scroll-y="true">
内容...
</scroll-view>
同样适用于其他 scroll-view 布尔属性:
<!-- ✅ 横向滚动 -->
<scroll-view :scroll-x="true">
<!-- ✅ 返回顶部 -->
<scroll-view :scroll-y="true" :enable-back-to-top="true">
关键点:
- ⚠️ 永远不要省略
:符号,即使是布尔值 - ⚠️ 永远不要使用
scroll-y="true"字符串形式 - ✅ 始终使用
:scroll-y="true"绑定形式 - ✅ 这也是 Taro 框架的一个重要陷阱,容易疏忽
✅ NutUI 最佳实践
- 优先使用原生组件: 当 NutUI 组件样式限制时
- 添加 key 属性: 动态切换图标时
- 避免嵌套弹窗: 始终将内层弹窗提升到外层外部
-
深度样式覆盖: 尝试
:deep()选择器 - 查阅文档: 确认 NutUI 版本和已知问题
生命周期钩子使用陷阱
❌ 坑: 误用 useShow 而非 useDidShow(重复 2 次)
问题描述:
// ❌ 错误:使用了 Vue 3 的 useShow(在 Taro 中不可用)
import { useShow } from '@tarojs/taro'
useShow(() => {
fetchUserProfile()
})
错误表现:
- IDE 提示 "useShow 未使用"(因为 Taro 中没有这个钩子)
- 页面返回时不会触发刷新
正确做法:
// ✅ 正确:使用 Taro 的 useDidShow
import Taro, { useLoad, useDidShow } from '@tarojs/taro'
useLoad(() => {
// 页面首次加载时触发
fetchUserProfile()
})
useDidShow(() => {
// 每次页面显示时触发(包括从其他页面返回)
fetchUserProfile()
})
Taro 生命周期钩子对照表:
| 生命周期 | Taro 钩子 | 用途 | 备注 |
|---|---|---|---|
| 页面加载 | useLoad |
首次进入页面时触发 | 只触发一次 |
| 页面显示 | useDidShow |
每次页面显示时触发 | 包括从其他页面返回 |
| 页面渲染完成 | useReady |
首次渲染完成后触发 | 可操作 DOM |
| 页面隐藏 | useDidHide |
页面隐藏时触发 | 清理定时器等 |
| 页面卸载 | useUnload |
页面卸载时触发 | 清理资源 |
⚠️ 重要检查清单(写代码前必须执行):
-
搜索现有用法: 在项目中搜索关键字,确认其他页面是如何使用的
# 在终端执行 grep -r "useDidShow\|useShow" src/pages/ -
参考现有页面: 查看项目中已有的页面实现
# 例如查看 feedback-list 页面 cat src/pages/feedback-list/index.vue | grep "import.*@tarojs/taro" -
统一命名规范: 本项目统一使用 Taro 的钩子
- ✅
useDidShow(Taro 官方) - ❌
useShow(Vue 3 Composition API,在小程序中不适用)
- ✅
历史记录:
- 第 1 次错误:在
src/pages/mine/index.vue中使用useShow - 第 2 次错误:在同一位置再次使用
useShow(未检查项目现有用法) - 教训: ⚠️ 写代码前必须先搜索项目中是否已有类似实现
最佳实践:
// ✅ 推荐的导入方式(一行导入所有需要的钩子)
import Taro, { useLoad, useDidShow, useReady } from '@tarojs/taro'
// ✅ 页面加载时获取数据
useLoad((options) => {
console.log('页面参数:', options)
fetchData()
})
// ✅ 页面显示时刷新数据(从其他页面返回时)
useDidShow(() => {
refreshData()
})
静态资源加载问题
❌ 坑: SVG 图标加载失败(500 错误)
问题描述:
// ❌ 字符串路径导致加载失败
export const getDocumentIcon = (type) => {
const icons = {
pdf: '/assets/images/icon/doc/doc.svg', // ❌ 500 错误
doc: '/assets/images/icon/doc/doc.svg',
// ...
}
return icons[type]
}
原因: Taro 构建工具无法正确处理字符串路径的静态资源引用
解决方案: 使用 ES6 import 导入
// ✅ 使用 import 导入
import pdfIcon from '@/assets/images/icon/doc/pdf.svg'
import docIcon from '@/assets/images/icon/doc/doc.svg'
// ...
export const getDocumentIcon = (type) => {
const icons = {
pdf: pdfIcon,
doc: docIcon,
// ...
}
return icons[type]
}
✅ 静态资源处理规则
| 资源类型 | 引用方式 | 示例 |
|---|---|---|
| SVG 图标 |
import 导入 |
import icon from '@/assets/icon.svg' |
| 图片 |
import 导入或字符串路径 |
两种方式都支持 |
| 字体 | 配置在 config/index.js
|
参考项目配置 |
| 远程资源 | 字符串 URL | https://cdn.example.com/image.png |
样式处理策略
✅ TailwindCSS vs Less 使用指南
使用 TailwindCSS(80% 场景)
适用场景:
- 布局(flex、grid、absolute)
- 间距(padding、margin、gap)
- 排版(font-size、font-weight、text-align)
- 颜色(bg-、text-、border-*)
- 响应式设计(sm:、md:、lg:)
示例:
<div class="
flex items-center justify-between <!-- 布局 -->
p-4 mb-2 <!-- 间距 -->
bg-white rounded-lg shadow-md <!-- 颜色、圆角、阴影 -->
">
<h1 class="text-xl font-bold">标题</h1>
</div>
使用 Less(20% 场景)
适用场景:
- 组件特定样式(需要
scoped) - 深度选择器(
:deep()) - 动画和过渡
- 伪元素(
::before、::after) - 复杂的计算表达式
示例:
<style lang="less" scoped>
.custom-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
// 深度选择器修改第三方组件
:deep(.nut-button) {
background-color: rgba(255, 255, 255, 0.2);
}
// 伪元素
&::before {
content: '';
position: absolute;
// ...
}
// 动画
@keyframes slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
}
</style>
❌ 坑: LESS 修饰符类与基础类样式堆叠 ⭐ 2026-02-08 新增
问题描述:
在 LoadMoreList 组件中,基础类 .load-more-content 设置了 padding: 32rpx(所有边),修饰符类 .load-more-content.scrollable 又添加了 padding-bottom: calc(160rpx + env(safe-area-inset-bottom)),导致底部 padding 堆叠,约为 32rpx + 160rpx + safe-area-inset-bottom ≈ 192rpx + safe-area,底部空白过高。
错误代码:
.load-more-content {
// 基础 padding: 所有 4 边都是 32rpx
padding: 32rpx;
// 可滚动状态
&.scrollable {
// ❌ 只添加 padding-bottom,会与基础 padding 堆叠
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
}
错误表现:
- 底部空白过高,约为正常的 2 倍
- 用户需要滚动很长距离才能看到最后的加载提示
- 在 iPhone X 等有安全区域的设备上更加明显
原因: LESS 嵌套选择器中,修饰符类的属性会与基础类堆叠(叠加)而不是覆盖
- 基础类:
padding: 32rpx(上下左右都是 32rpx) - 修饰符类:
padding-bottom: calc(160rpx + env(safe-area-inset-bottom)) -
结果:
padding-top: 32rpx,padding-left: 32rpx,padding-right: 32rpx,padding-bottom: 32rpx + 160rpx + safe-area
解决方案: 在修饰符类中覆盖整个 padding 属性,而不是只添加子属性
.load-more-content {
// 基础 padding: 所有 4 边都是 32rpx
padding: 32rpx;
// 可滚动状态
&.scrollable {
// ✅ 覆盖整个 padding 属性,防止堆叠
// 顶部 32rpx, 左右 32rpx, 底部 160rpx + safe-area
padding: 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
}
}
padding 简写格式说明:
padding: [top] [right] [bottom] [left];
-
32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom))=- 顶部: 32rpx
- 右边: 32rpx
- 底部: calc(160rpx + env(safe-area-inset-bottom))
- 左边: 32rpx (与右边相同)
关键点:
- ✅ 使用
padding简写属性覆盖整个 padding,而不是只写padding-bottom - ✅ 明确指定所有 4 个边的值,避免隐式继承
- ⚠️ 规则: 需要覆盖基础类的 padding/margin/border 等属性时,重写整个属性而不是只写子属性
适用场景:
- ✅ 任何 LESS/SCSS 嵌套选择器中
- ✅ 修饰符类需要覆盖基础类的 padding、margin、border 等属性时
- ✅ 需要完全替换而不是堆叠样式的场景
最佳实践:
// ✅ GOOD - 明确覆盖
.component {
padding: 16px;
&.modifier {
// 覆盖整个属性,防止堆叠
padding: 8px 16px;
}
}
// ❌ BAD - 可能堆叠
.component {
padding: 16px;
&.modifier {
// 只添加一个方向,其他方向会堆叠
padding-bottom: 8px;
}
}
相关文件:
-
src/components/LoadMoreList/index.vue:423(已修复)
历史记录:
- 第 1 次: 在 material-list 页面发现底部 padding 过高
- 教训: ⚠️ LESS 嵌套选择器中,修饰符类的属性会与基础类堆叠,需要覆盖整个属性而不是只写子属性
❌ 坑 5: LoadMoreList 页面的双重滚动问题 ⭐ 2026-02-08 新增
问题描述:
使用 LoadMoreList 组件的页面出现双重滚动问题:
- 整个页面可以滚动
- LoadMoreList 内部的 scroll-view 也可以滚动
- 用户滚动时体验混乱
错误代码:
<!-- ❌ 页面容器没有限制高度和溢出 -->
<template>
<view class="bg-[#F9FAFB]">
<LoadMoreList
:list="currentList"
:page="currentPage"
@load-more="handleLoadMore"
>
<!-- 列表项 -->
</LoadMoreList>
</view>
</template>
// ❌ 缺少高度和溢出控制
.feedback-list {
min-height: 100vh; // ❌ 使用 min-height 而非 height
// ❌ 缺少 overflow: hidden
}
错误表现:
- 页面级和组件级都可以滚动
- 用户滚动时不确定是哪个在滚动
- 固定元素(如导航栏、筛选栏)可能随页面滚动消失
原因分析:
-
页面容器未限制高度: 使用
min-height: 100vh或没有设置高度 -
未禁用页面级溢出: 缺少
overflow: hidden - 滚动上下文混乱: 浏览器无法确定应该滚动哪个容器
解决方案: 在页面容器添加 height: 100vh 和 overflow: hidden
<!-- ✅ 正确:页面容器固定高度并禁用溢出 -->
<template>
<!-- ✅ TailwindCSS 方式 -->
<view class="h-screen overflow-hidden bg-[#F9FAFB]">
<LoadMoreList
:list="currentList"
:page="currentPage"
@load-more="handleLoadMore"
>
<!-- 列表项 -->
</LoadMoreList>
</view>
</template>
// ✅ Less 方式
.feedback-list {
height: 100vh; // ✅ 固定高度
overflow: hidden; // ✅ 禁用页面级滚动
}
关键点:
- ✅ 页面容器必须设置
height: 100vh(固定高度) - ✅ 页面容器必须设置
overflow: hidden(禁用页面级滚动) - ✅ 让 LoadMoreList 内部的 scroll-view 处理所有滚动
- ✅ 如果页面有固定顶部(如导航栏、搜索栏),放在 LoadMoreList 的
#header插槽中
修复的页面(共 4 个):
src/pages/feedback-list/index.vuesrc/pages/favorites/index.vuesrc/pages/material-list/index.vuesrc/pages/product-center/index.vue
已正确的页面(共 3 个):
-
src/pages/search/index.vue(已正确配置) -
src/pages/message/index.vue(LoadMoreList 是根元素) -
src/pages/week-hot-material/index.vue(LoadMoreList 是根元素)
最佳实践:
<!-- ✅ 推荐的 LoadMoreList 页面结构 -->
<template>
<!-- 外层容器:固定高度,禁用溢出 -->
<view class="h-screen overflow-hidden bg-[#F9FAFB]">
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="id"
@load-more="handleLoadMore"
>
<!-- 固定头部(可选) -->
<template #header>
<view class="sticky top-0 bg-white z-10">
<NavHeader title="页面标题" />
<SearchBar v-model="searchValue" />
</view>
</template>
<!-- 列表项 -->
<template #item="{ item }">
<ProductCard :product="item" />
</template>
</LoadMoreList>
</view>
</template>
检查清单: 使用 LoadMoreList 组件时,确认:
-
页面容器设置了
height: 100vh(或h-screen) -
页面容器设置了
overflow: hidden(或overflow-hidden) -
固定元素(导航栏、搜索栏)放在
#header插槽中 - 只有 LoadMoreList 内部的 scroll-view 可以滚动
适用场景:
- ✅ 所有使用 LoadMoreList 组件的分页列表页面
- ✅ 需要固定顶部元素的滚动页面
- ✅ 需要防止双重滚动的任何场景
⚠️ 双设计宽度系统
项目配置:
// config/index.js
const designWidth = {
750: [375, 375], // NutUI 组件: 375px 基准
750: [750, 750] // 其他所有页面: 750px 基准
}
使用规则:
- NutUI 组件 → 参考 375px 设计稿
- 自定义页面 → 参考 750px 设计稿
示例:
<!-- NutUI 组件: 使用 375px 设计稿 -->
<nut-button :custom-style="buttonStyle">按钮</nut-button>
<script>
const buttonStyle = {
fontSize: '14px', // 375px 设计稿: 14px
padding: '8px 16px'
}
</script>
<!-- 自定义元素: 使用 750px 设计稿 -->
<view class="custom-box">
内容
</view>
<style lang="less" scoped>
.custom-box {
width: 750px; // 750px 设计稿: 750px
height: 200px;
}
</style>
性能优化
✅ 1. 响应式数据优化
问题: Vue 3 对包含组件对象的响应式数据进行深度代理,导致性能问题
解决方案: 使用 shallowRef + markRaw
import { shallowRef, markRaw } from 'vue'
// ❌ BAD - 深度响应式
const menuItems = ref([
{
icon: markRaw(IconFont), // 组件对象
name: 'heart',
title: '我的收藏'
}
// ...
])
// ✅ GOOD - 浅层响应式
const menuItems = shallowRef([
{
icon: markRaw(IconFont), // 标记为原始对象
name: 'heart',
title: '我的收藏'
}
// ...
])
收益:
- 消除 "Component that was made a reactive object" 警告
- 避免不必要的深度代理
- 提升页面初始化和渲染性能
✅ 2. 页面滚动优化
问题: 整页滚动,顶部筛选区域会随列表滚动消失
解决方案: 固定顶部 + 列表独立滚动
<template>
<view class="page-container">
<!-- 固定顶部筛选 -->
<view class="fixed-header">
<search-bar />
<filter-tabs />
</view>
<!-- 独立滚动列表 -->
<scroll-view
scroll-y
class="scrollable-list"
:style="{ height: listHeight }"
>
<view v-for="item in list" :key="item.id">
{{ item.title }}
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const listHeight = ref('calc(100vh - 200rpx)') // 减去固定区域高度
onMounted(() => {
// 动态计算列表高度
const query = Taro.createSelectorQuery()
query.select('.fixed-header').boundingClientRect()
query.exec((res) => {
const headerHeight = res[0].height
listHeight.value = `calc(100vh - ${headerHeight}px)`
})
})
</script>
<style lang="less" scoped>
.page-container {
height: 100vh;
display: flex;
flex-direction: column;
}
.fixed-header {
position: sticky;
top: 0;
z-index: 10;
background-color: #fff;
}
.scrollable-list {
flex: 1;
overflow-y: auto;
}
</style>
收益:
- 顶部筛选区域始终可见
- 列表滚动更流畅
- 用户体验显著提升
✅ 3. 图片优化
策略:
- 使用 CDN 参数优化图片
- 图片懒加载
- 响应式图片
// src/utils/image.js
/**
* 优化 CDN 图片 URL
*
* @param {string} url - 原始 URL
* @param {Object} options - 优化参数
* @param {number} options.width - 宽度
* @param {number} options.quality - 质量(1-100)
*/
export function optimizeImageUrl(url, options = {}) {
if (!url || !url.includes('cdn.ipadbiz.cn')) {
return url
}
const { width = 750, quality = 70 } = options
const params = new URLSearchParams({
'imageMogr2/thumbnail': `${width}x`,
strip: '',
quality: quality.toString()
})
return `${url}?${params.toString()}`
}
代码质量
✅ 1. JSDoc 注释规范
强制要求:
- ✅ 所有函数必须有 JSDoc 注释
- ✅ 包含
@description说明功能 - ✅ 所有参数都有
@param说明 - ✅ 返回值有
@returns说明 - ✅ 复杂逻辑需要详细注释
示例:
/**
* 获取文档图标
*
* @description 根据文件类型返回对应的 SVG 图标路径
* @param {string} type - 文件类型(pdf、doc、xls、ppt、txt、img、video、zip、unknown)
* @returns {string} SVG 图标路径
*
* @example
* const icon = getDocumentIcon('pdf')
* // 返回: '/assets/images/icon/doc/pdf.svg'
*/
export function getDocumentIcon(type) {
const iconMap = {
pdf: pdfIcon,
doc: docIcon,
// ...
}
return iconMap[type] || unknownIcon
}
✅ 2. 命名规范
文件命名:
- 组件:
PascalCase.vue(多单词) - 页面:
index.vue+index.config.js - Composables:
useXxx.js - 工具函数:
xxxXxx.js(camelCase)
变量命名:
- 常量:
UPPER_SNAKE_CASE - 响应式变量:
camelCase - 组件引用:
PascalCase
示例:
// ✅ GOOD
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 常量
const userList = ref([]) // 响应式变量
import UserCard from '@/components/UserCard.vue' // 组件
// ❌ BAD
const max_upload_size = 10 * 1024 * 1024
const UserList = ref([])
import userCard from '@/components/UserCard.vue'
✅ 3. 错误处理
原则:
- 所有
async函数必须有try-catch - 用户友好的错误提示
- 日志记录便于调试
示例:
/**
* 获取产品列表
*
* @description 从 API 获取产品列表数据
* @param {Object} params - 查询参数
* @returns {Promise<Object>} 产品列表数据
*/
export async function fetchProductList(params) {
try {
const { data } = await getProductListAPI(params)
// 检查 API 响应码
if (data.code !== 1) {
Taro.showToast({
title: data.msg || '获取失败',
icon: 'none'
})
return null
}
return data.data
} catch (err) {
console.error('获取产品列表失败:', err)
Taro.showToast({
title: '网络异常,请重试',
icon: 'none'
})
return null
}
}
❌ 坑: API 调用使用了 fn() 包装(重复 2 次)
问题描述:
// ❌ 错误:使用了 fn() 包装 API 调用
import { fileListAPI } from '@/api/file'
import { fn } from '@/api/fn'
const res = await fn(fileListAPI(params))
if (res.code === 1) {
data.value = res.data
} else {
throw new Error(res.msg || '请求失败')
}
错误表现:
- 重复检查
res.code === 1(fn()内部检查一次,页面又检查一次) - 与项目中其他页面的写法不一致(product-detail、index、message 等页面直接调用 API)
- 可能导致意外的日志输出(即使成功也打印"接口请求失败")
原因分析:
-
fn()函数的作用:- 内部已经处理了失败情况并显示了 toast
- 返回的是
{ code, data, msg }格式 - 适用于需要统一错误处理的场景
-
为什么不应该用
fn():- 项目中大部分页面(product-detail、index、message 等)都是直接调用 API
- 直接调用代码更清晰,更容易理解
- 避免了重复检查和不一致的写法
-
历史记录:
- 第 1 次错误:在
src/pages/category-list/index.vue中使用fn() - 第 2 次错误:在
src/pages/material-list/index.vue中使用fn() - 教训: ⚠️ 写代码前必须先搜索项目中是否已有类似实现,保持写法一致
- 第 1 次错误:在
正确做法:
// ✅ 正确:直接调用 API,自己处理错误
import { fileListAPI } from '@/api/file'
import Taro from '@tarojs/taro'
const res = await fileListAPI(params)
if (res.code === 1 && res.data) {
// 成功处理
data.value = res.data
} else {
// 失败处理:手动显示 toast
Taro.showToast({
title: res.msg || '请求失败',
icon: 'none',
duration: 2000
})
}
对比其他页面的写法(product-detail、index、message 等):
// src/pages/product-detail/index.vue:143
const res = await detailAPI({ i: id })
if (res.code === 1 && res.data) {
productDetail.value = res.data
} else {
Taro.showToast({
title: res.msg || '获取产品详情失败',
icon: 'none',
duration: 2000
})
}
// src/pages/index/index.vue:245
const res = await listAPI({ recommend: 'hot' })
if (res.code === 1 && res.data && res.data.list) {
hotProducts.value = res.data.list
}
⚠️ 重要检查清单(写 API 调用代码前必须执行):
-
搜索现有用法:在项目中搜索相同 API 的调用方式
# 在终端执行 grep -r "fileListAPI" src/pages/ -
参考现有页面:查看项目中已有的页面实现
# 例如查看 product-detail 页面 cat src/pages/product-detail/index.vue | grep -A 10 "await.*API(" -
统一命名规范:本项目统一使用直接调用 API 的方式
- ✅
const res = await fileListAPI(params)(直接调用) - ❌
const res = await fn(fileListAPI(params))(使用 fn() 包装)
- ✅
最佳实践:
// ✅ 推荐的 API 调用方式
import { fileListAPI } from '@/api/file'
import Taro from '@tarojs/taro'
const fetchList = async (params) => {
try {
const res = await fileListAPI(params)
if (res.code === 1 && res.data) {
// 成功处理
return res.data
} else {
// 失败处理
Taro.showToast({
title: res.msg || '请求失败',
icon: 'none',
duration: 2000
})
return null
}
} catch (err) {
console.error('请求失败:', err)
Taro.showToast({
title: '网络异常,请重试',
icon: 'none',
duration: 2000
})
return null
}
}
Vue 3 响应式数据和表单状态管理
❌ 坑:v-model 双向绑定导致表单数据丢失或重置失败
场景:用户填写表单后关闭弹窗(未提交),再次打开时数据依然存在,或者在输入过程中数据意外丢失。
问题根因:
- v-model 每次更新都创建新对象 ```javascript // 父组件 const formData = ref({})
// v-model 更新时(子组件 emit) formData.value = {age: 30} // ← 每次都是新对象!
2. **reactive() 只在初始化时读取 props**
```javascript
// 子组件
const form = reactive(props.modelValue || {})
// 问题:
// - 只在组件创建时读取一次 props.modelValue
// - 之后 props.modelValue 变化,form 不会自动更新
- watch 监听策略错误 ```javascript // ❌ 错误:每次 props 变化都清空并复制 watch(() => props.modelValue, (newVal) => { Object.keys(form).forEach(key => delete form[key]) Object.assign(form, newVal) })
// 问题: // - v-model 更新创建新对象,触发 watch // - 清空操作导致用户输入丢失 // - 可能触发无限循环:form → emit → props → watch → form
**✅ 解决方案**:区分"重置"和"正常更新"
```javascript
// ✅ 正确:只在重置时清空,正常更新时只合并新字段
let previousModelValue = null
watch(
() => props.modelValue,
(newVal) => {
if (!newVal) {
// null 或 undefined:清空
Object.keys(form).forEach(key => delete form[key])
previousModelValue = null
return
}
// 判断是否是重置(从有数据变为空对象)
const isReset = previousModelValue &&
Object.keys(previousModelValue).length > 0 &&
Object.keys(newVal).length === 0
if (isReset) {
// 父组件重置了:清空表单
Object.keys(form).forEach(key => delete form[key])
previousModelValue = newVal
} else {
// 正常更新:只合并新字段,不删除已有字段
// 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
})
previousModelValue = newVal
}
},
{ immediate: true }
)
关键要点:
- ✅ 不要用引用判断(
newVal !== previousModelValue),因为 v-model 每次都创建新对象 - ✅ 用内容判断:从有数据 → 空对象 = 重置
- ✅ 正常更新时只合并新字段,保留已有字段
- ✅ 不要用
{ deep: true }监听 props(可能导致循环)
涉及文件:
-
src/components/PlanFormContainer.vue- 父组件 -
src/components/PlanTemplates/LifeInsuranceTemplate.vue- 子组件 -
src/components/PlanTemplates/CriticalIllnessTemplate.vue- 子组件 -
src/components/PlanTemplates/SavingsTemplate.vue- 子组件
调试时间:约 1.5 小时(3 次尝试) 影响:表单数据丢失、重置失败
架构设计
✅ 1. 统一的列表点击处理
问题: 不同列表页面的点击逻辑分散,难以维护
解决方案: 创建 useListItemClick Composable
// src/composables/useListItemClick.js
import { ListType } from '@/constants/list'
/**
* 列表项点击处理 Composable
*
* @description 根据列表类型智能分发点击行为
* @param {ListType} type - 列表类型
* @param {Object} options - 配置选项
* @param {Function} options.beforeClick - 点击前钩子
* @param {Function} options.afterClick - 点击后钩子
* @returns {Function} 点击处理函数
*/
export function useListItemClick(type, options = {}) {
const { beforeClick, afterClick } = options
const handleClick = async (item) => {
// 执行点击前钩子
if (beforeClick) {
const shouldContinue = await beforeClick(item)
if (!shouldContinue) return
}
// 根据类型分发行为
switch (type) {
case ListType.FILE:
await handleFileClick(item)
break
case ListType.PRODUCT:
await handleProductClick(item)
break
case ListType.SEARCH:
await handleSearchClick(item)
break
default:
console.warn('未知的列表类型:', type)
}
// 执行点击后钩子
if (afterClick) {
afterClick(item)
}
}
return { handleClick }
}
使用示例:
<script setup>
import { useListItemClick } from '@/composables/useListItemClick'
import { ListType } from '@/constants/list'
const { handleClick } = useListItemClick(ListType.PRODUCT, {
beforeClick: (item) => {
console.log('点击前:', item)
return true
},
afterClick: (item) => {
console.log('点击后:', item)
}
})
</script>
<template>
<view v-for="item in products" :key="item.id" @tap="handleClick(item)">
{{ item.name }}
</view>
</template>
✅ 2. 统一的文件操作
问题: 文件下载、预览、打开等逻辑在多个页面重复
解决方案: 创建 useFileOperation Composable
// src/composables/useFileOperation.js
/**
* 文件操作 Composable
*
* @description 封装文件下载、打开、预览等核心逻辑
* @returns {Object} 文件操作方法
*/
export function useFileOperation() {
const hasShownOfficeTip = ref(false)
/**
* 查看文件
*
* @param {Object} file - 文件对象
* @param {string} file.url - 文件 URL
* @param {string} file.name - 文件名
*/
const viewFile = async (file) => {
const ext = getFileExtension(file.name)
// Office 文档提示
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) {
if (!hasShownOfficeTip.value) {
Taro.showModal({
title: '提示',
content: 'Office 文档建议使用电脑端查看',
confirmText: '继续',
cancelText: '取消'
}).then((res) => {
if (res.confirm) {
hasShownOfficeTip.value = true
openFile(file)
}
})
return
}
}
await openFile(file)
}
/**
* 下载文件
*
* @param {Object} file - 文件对象
*/
const downloadFile = async (file) => {
Taro.showLoading({ title: '下载中...' })
try {
const { tempFilePath } = await Taro.downloadFile({
url: file.url
})
Taro.openDocument({
filePath: tempFilePath
})
} catch (err) {
console.error('下载失败:', err)
Taro.showToast({
title: '下载失败',
icon: 'none'
})
} finally {
Taro.hideLoading()
}
}
return {
viewFile,
downloadFile
}
}
✅ 3. 分层架构
推荐架构:
src/
├── api/ # API 层 - 接口定义
├── composables/ # 逻辑层 - 可复用逻辑
├── components/ # 组件层 - UI 组件
├── pages/ # 页面层 - 页面组件
├── stores/ # 状态层 - 全局状态
└── utils/ # 工具层 - 工具函数
原则:
- API 层只负责接口调用
- Composables 负责业务逻辑复用
- Components 负责纯 UI 展示
- Pages 组装 Components 和 Composables
跨页面通信
❌ 坑: useDidShow 导致列表意外刷新
问题描述:
在收藏列表页(favorites)和反馈列表页(feedback-list)中,使用 useDidShow 钩子会导致列表在关闭图片预览时意外刷新。
错误表现:
- 用户点击图片预览(使用
Taro.previewImage()) - 关闭预览后,
useDidShow钩子触发 - 列表重新加载,显示 loading 动画
- 用户体验差,感觉应用卡顿
错误代码:
// ❌ 错误:使用 useDidShow 导致意外刷新
import { useLoad, useDidShow } from '@tarojs/taro'
useLoad(() => {
// 页面首次加载
fetchList({ page: 0, limit: pageSize })
})
useDidShow(() => {
// ❌ 每次页面显示都刷新(包括关闭图片预览时)
refreshList()
})
根本原因:
-
useDidShow在任何页面显示时都会触发 - 关闭
Taro.previewImage()会触发页面显示事件 - 不区分正常的页面返回(需要刷新)和模态框关闭(不需要刷新)
解决方案: 仅使用 useLoad + 事件总线
// ✅ 正确:仅 useLoad 初始化 + 事件总线刷新特定事件
import { useLoad } from '@tarojs/taro'
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus, { Events } from '@/utils/eventBus'
// 首次加载时获取列表
useLoad(() => {
console.log('[Favorites] 页面加载,获取列表')
currentPage.value = 0
hasMore.value = true
fetchList({ page: 0, limit: pageSize })
})
// 监听收藏更新事件(收藏/取消收藏时触发)
onMounted(() => {
console.log('[Favorites] 注册事件监听')
const unsubscribe = eventBus.on(Events.FAVORITES_UPDATE, async (data) => {
console.log('[Favorites] 收到收藏更新事件:', data)
await refreshList()
})
// 组件卸载时取消监听
onUnmounted(() => {
unsubscribe()
console.log('[Favorites] 取消事件监听')
})
})
收益:
- ✅ 避免了关闭图片预览时的意外刷新
- ✅ 保留了跨页面操作后的自动刷新(如收藏、提交反馈)
- ✅ 用户体验显著提升
相关文件:
-
src/pages/favorites/index.vue(已修复) -
src/pages/feedback-list/index.vue(已修复)
✅ 最佳实践:事件总线模式
使用场景判断
关键原则:跨页面操作需要事件总线,单页面操作使用本地更新
| 操作类型 | 是否跨页面 | 数据同步方式 | 示例 |
|---|---|---|---|
| 收藏操作 | ✅ 是 | 事件总线 | 在产品详情页收藏 → 收藏列表页自动刷新 |
| 提交反馈 | ✅ 是 | 事件总线 | 提交反馈后 → 反馈列表页自动刷新 |
| 删除收藏 | ❌ 否 | 本地更新 | 在收藏列表页删除 → 立即从列表移除 |
事件总线实现
1. 创建事件总线(src/utils/eventBus.js):
/**
* 事件总线工具
*
* @description 轻量级事件总线,用于跨页面组件通信
* @module utils/eventBus
* @author Claude Code
* @created 2026-02-08
*/
const eventBus = {
events: {},
/**
* 监听事件
*
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
* @returns {Function} 取消监听函数
*/
on(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
// 返回取消监听函数
return () => this.off(event, callback)
},
/**
* 发送事件
*
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*/
emit(event, data) {
if (!this.events[event]) return
this.events[event].forEach(callback => {
try {
callback(data)
} catch (err) {
console.error(`[EventBus] 事件处理错误 [${event}]:`, err)
}
})
},
/**
* 取消监听事件
*
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
off(event, callback) {
if (!this.events[event]) return
if (callback) {
// 移除特定的回调
this.events[event] = this.events[event].filter(cb => cb !== callback)
} else {
// 移除所有回调
delete this.events[event]
}
},
/**
* 清空所有事件监听器
*/
clear() {
this.events = {}
}
}
/**
* 事件名称常量
* @enum {string}
*/
export const Events = {
/** 反馈提交成功 */
FEEDBACK_SUBMIT: 'feedback:submit',
/** 收藏列表更新 */
FAVORITES_UPDATE: 'favorites:update',
/** 用户信息更新 */
USER_UPDATE: 'user:update'
}
export default eventBus
2. 发送事件(在操作页面):
// src/pages/feedback/index.vue
import eventBus, { Events } from '@/utils/eventBus'
const onSubmit = async () => {
// ... 提交逻辑
if (res.code === 1) {
Taro.showToast({ title: '提交成功', icon: 'success' })
// 发送事件通知反馈列表页刷新
eventBus.emit(Events.FEEDBACK_SUBMIT, {
timestamp: Date.now()
})
// 返回上一页
setTimeout(() => {
Taro.navigateBack()
}, 1500)
}
}
3. 接收事件(在列表页面):
// src/pages/feedback-list/index.vue
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus, { Events } from '@/utils/eventBus'
onMounted(() => {
console.log('[Feedback] 注册事件监听')
// 监听反馈提交成功事件
const unsubscribe = eventBus.on(Events.FEEDBACK_SUBMIT, async (data) => {
console.log('[Feedback] 收到反馈提交事件:', data)
// 刷新列表
await refreshList()
})
// 组件卸载时取消监听
onUnmounted(() => {
unsubscribe()
console.log('[Feedback] 取消事件监听')
})
})
4. Composable 中发送事件(收藏操作):
// src/composables/useCollectOperation.js
import eventBus, { Events } from '@/utils/eventBus'
export function useCollectOperation(options = {}) {
const { onSuccess, onError } = options
const toggleCollect = async (item, successMsg, errorMsg = '操作失败') => {
try {
// 乐观更新 UI
const newCollectStatus = !item.collected
item.collected = newCollectStatus
const metaId = item.meta_id || item.id
// 调用 API
const res = newCollectStatus
? await addAPI({ meta_id: metaId })
: await delAPI({ meta_id: metaId })
if (res.code === 1) {
Taro.showToast({
title: successMsg || (newCollectStatus ? '已收藏' : '已取消收藏'),
icon: 'success',
duration: 1000
})
// 发送收藏更新事件(通知收藏列表页刷新)
eventBus.emit(Events.FAVORITES_UPDATE, {
metaId,
collected: newCollectStatus,
timestamp: Date.now()
})
onSuccess?.(item, newCollectStatus)
return true
} else {
// API 失败,回滚 UI 状态
item.collected = !newCollectStatus
Taro.showToast({
title: res.msg || errorMsg,
icon: 'none',
duration: 2000
})
onError?.(item, res.msg)
return false
}
} catch (err) {
// 发生错误,回滚 UI 状态
item.collected = !item.collected
console.error('[useCollectOperation] 收藏操作失败:', err)
Taro.showToast({
title: '网络错误,请重试',
icon: 'none',
duration: 2000
})
onError?.(item, err.message)
return false
}
}
return { toggleCollect }
}
生命周期最佳实践
LoadMoreList 页面(分页列表页面)的生命周期使用规范:
// ✅ 正确:LoadMoreList 页面生命周期
import { useLoad } from '@tarojs/taro'
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus, { Events } from '@/utils/eventBus'
// 首次加载时获取列表(只执行一次)
useLoad(() => {
console.log('[Page] 页面加载,获取列表')
currentPage.value = 0
hasMore.value = true
fetchList({ page: 0, limit: pageSize })
})
// 监听跨页面事件(需要刷新时)
onMounted(() => {
const unsubscribe = eventBus.on(Events.SOME_EVENT, async (data) => {
console.log('[Page] 收到事件:', data)
await refreshList()
})
onUnmounted(() => {
unsubscribe()
})
})
关键点:
- ✅ 使用
useLoad进行一次性初始化 - ✅ 使用事件总线监听跨页面操作
- ✅ 在
onMounted中注册监听器 - ✅ 在
onUnmounted中取消监听器(防止内存泄漏) - ❌ 不使用
useDidShow(避免意外刷新)
适用范围:
- ✅ 所有使用
LoadMoreList组件的分页列表页面 - ✅ 需要跨页面通信的场景
- ✅ 需要在特定事件后刷新数据的场景
检查清单
在实现列表页面时,确认:
-
是否为 LoadMoreList 页面(分页列表)?
-
是 → 使用
useLoad+ 事件总线 -
否 → 根据需求使用
useLoad或useDidShow
-
是 → 使用
-
是否需要跨页面刷新?
- 是 → 使用事件总线模式
- 否 → 使用本地更新即可
-
是否正确清理事件监听器?
-
在
onUnmounted中调用unsubscribe()
-
在
-
是否避免了
useDidShow的误用?-
不在 LoadMoreList 页面中使用
useDidShow
-
不在 LoadMoreList 页面中使用
历史记录:
-
第 1 次错误: favorites 页面使用
useDidShow导致关闭图片预览时列表刷新 -
第 2 次错误: feedback-list 页面使用
useDidShow导致同样问题 -
教训: LoadMoreList 页面应使用
useLoad+ 事件总线,避免useDidShow导致的意外刷新
开发工作流
✅ Mock 数据环境自动切换模式
问题描述
在开发过程中,使用 Mock 数据进行前端开发可以提高效率,但手动切换 Mock 数据和真实 API 容易出错:
// ❌ BAD - 硬编码开关,容易忘记切换
const USE_MOCK_DATA = true // 开发时用 true,部署时忘记改成 false
const res = USE_MOCK_DATA
? await mockWeekHotAPI(params)
: await weekHotAPI(params)
// 风险:部署到生产环境时仍然使用 Mock 数据
错误表现:
- 🔴 开发完成后忘记关闭 Mock 数据开关
- 🔴 生产环境返回假数据,导致严重问题
- 🔴 需要手动在每个页面中切换,容易遗漏
解决方案:基于环境变量自动切换
使用 process.env.NODE_ENV 判断当前环境,自动选择使用 Mock 数据还是真实 API:
// ✅ GOOD - 环境变量自动判断
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const res = USE_MOCK_DATA
? await mockWeekHotAPI(params)
: await weekHotAPI(params)
console.log('[Week Hot] 使用 Mock 数据:', USE_MOCK_DATA)
环境说明
| 环境 | NODE_ENV | 命令 | Mock 数据 |
|---|---|---|---|
| 开发环境 | 'development' |
pnpm dev:weapp |
✅ 启用 |
| 生产环境 | 'production' |
pnpm build:weapp |
❌ 禁用 |
实施步骤
-
修改所有使用 Mock 数据的页面(共 5 个):
-
src/pages/search/index.vue(line 152) -
src/pages/week-hot-material/index.vue(line 76) -
src/pages/message/index.vue(line 57) -
src/pages/material-list/index.vue(line 131) -
src/pages/product-center/index.vue(line 159)
-
-
统一修改代码:
// 修改前 const USE_MOCK_DATA = true // ❌ 硬编码
// 修改后 const USE_MOCK_DATA = process.env.NODE_ENV === 'development' // ✅ 环境判断
3. **添加日志**(可选,便于调试):
```javascript
console.log('[PageName] 使用 Mock 数据:', USE_MOCK_DATA)
收益
✅ 开发环境:
- 快速迭代,无需等待后端接口
- 测试分页、加载更多等前端逻辑
- 模拟各种数据场景
✅ 生产环境:
- 自动切换到真实 API
- 无需手动修改代码
- 避免假数据上线风险
✅ 安全性:
- 无法在生产环境误用 Mock 数据
- 一次配置,永久生效
使用示例
<script setup>
import { ref } from 'vue'
import { weekHotAPI } from '@/api/file'
import { mockWeekHotAPI } from '@/utils/mockData'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const list = ref([])
const loading = ref(false)
const fetchList = async (params) => {
loading.value = true
try {
console.log('[Week Hot] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockWeekHotAPI(params)
: await weekHotAPI(params)
if (res.code === 1 && res.data) {
list.value = res.data.list
}
} catch (err) {
console.error('获取列表失败:', err)
} finally {
loading.value = false
}
}
</script>
注意事项
-
确保 Mock 数据结构一致:
- Mock 数据的返回格式必须与真实 API 一致
- 特别是
{ code, data, msg }结构 - 确保分页字段名称一致
-
生产构建前检查:
# 开发环境(使用 Mock) pnpm dev:weapp
# 生产构建前检查环境变量 pnpm build:weapp # 确认打包后的代码使用真实 API
3. **代码审查清单**:
- [ ] 所有 Mock 数据开关都改为 `process.env.NODE_ENV === 'development'`
- [ ] 添加了调试日志
- [ ] Mock 数据结构符合真实 API
#### 相关文件
- Mock 数据定义:`src/utils/mockData.js`
- 使用 Mock 的页面(5 个):
- `src/pages/search/index.vue`
- `src/pages/week-hot-material/index.vue`
- `src/pages/message/index.vue`
- `src/pages/material-list/index.vue`
- `src/pages/product-center/index.vue`
#### 最佳实践总结
⚠️ **强制要求**:所有使用 Mock 数据的页面都必须使用环境变量判断,禁止硬编码开关。
```javascript
// ✅ 推荐写法
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
// ❌ 禁止写法
const USE_MOCK_DATA = true // 容易导致生产环境误用
总结
🎯 核心经验
- "第 3 次出现原则": 代码重复 3 次时必须抽取
- NutUI 陷阱: textarea、IconFont 等组件有坑,优先使用原生组件
-
静态资源: SVG 图标必须使用
import导入 - 样式策略: TailwindCSS(80%) + Less(20%) 混合使用
-
性能优化:
shallowRef+markRaw处理组件对象响应式 - 代码质量: 强制 JSDoc 注释,统一命名规范
-
API 调用规范: ⚠️ 不要使用
fn()包装 API,直接调用并自己处理错误 - 架构设计: 分层清晰,职责单一
-
跨页面通信: ⭐ 使用事件总线模式,LoadMoreList 页面避免使用
useDidShow(防止意外刷新) -
Mock 数据切换: ⭐ 使用环境变量
process.env.NODE_ENV自动判断,禁止硬编码 - ⚠️ 写代码前必查: 先搜索项目中是否有类似实现,保持写法一致
📚 推荐阅读
🔄 持续更新
本文档会随着项目开发持续更新,记录新的经验教训。
最后更新: 2026-02-08 维护者: Claude Code 项目: Manulife WeApp
更新记录:
- 2026-02-08: 新增 "跨页面通信" 章节,记录事件总线实现和 useDidShow 陷阱解决方案
- 2026-02-08: 新增 "开发工作流" 章节,记录 Mock 数据环境自动切换模式