lessons-learned.md 17.9 KB

Taro + Vue 3 项目开发经验教训总结

本文档总结了在开发 Manulife WeApp 项目过程中遇到的问题、解决方案和最佳实践,为后续 Taro 项目提供参考。

目录


组件抽取与复用

✅ 最佳实践

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" />

✅ NutUI 最佳实践

  1. 优先使用原生组件: 当 NutUI 组件样式限制时
  2. 添加 key 属性: 动态切换图标时
  3. 深度样式覆盖: 尝试 :deep() 选择器
  4. 查阅文档: 确认 NutUI 版本和已知问题

静态资源加载问题

❌ 坑: 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. 图片优化

策略:

  1. 使用 CDN 参数优化图片
  2. 图片懒加载
  3. 响应式图片
// 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
  }
}

架构设计

✅ 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

总结

🎯 核心经验

  1. "第 3 次出现原则": 代码重复 3 次时必须抽取
  2. NutUI 陷阱: textarea、IconFont 等组件有坑,优先使用原生组件
  3. 静态资源: SVG 图标必须使用 import 导入
  4. 样式策略: TailwindCSS(80%) + Less(20%) 混合使用
  5. 性能优化: shallowRef + markRaw 处理组件对象响应式
  6. 代码质量: 强制 JSDoc 注释,统一命名规范
  7. 架构设计: 分层清晰,职责单一

📚 推荐阅读

🔄 持续更新

本文档会随着项目开发持续更新,记录新的经验教训。


最后更新: 2026-01-31 维护者: Claude Code 项目: Manulife WeApp