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)等弹窗组件
✅ 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>
⚠️ 双设计宽度系统
项目配置:
// 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
}
}
架构设计
✅ 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
开发工作流
✅ 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,直接调用并自己处理错误 - 架构设计: 分层清晰,职责单一
-
Mock 数据切换: ⭐ 使用环境变量
process.env.NODE_ENV自动判断,禁止硬编码 - ⚠️ 写代码前必查: 先搜索项目中是否有类似实现,保持写法一致
📚 推荐阅读
🔄 持续更新
本文档会随着项目开发持续更新,记录新的经验教训。
最后更新: 2026-02-08 维护者: Claude Code 项目: Manulife WeApp
更新记录:
- 2026-02-08: 新增 "开发工作流" 章节,记录 Mock 数据环境自动切换模式