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 最佳实践
- 优先使用原生组件: 当 NutUI 组件样式限制时
- 添加 key 属性: 动态切换图标时
-
深度样式覆盖: 尝试
:deep()选择器 - 查阅文档: 确认 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. 图片优化
策略:
- 使用 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
}
}
架构设计
✅ 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
总结
🎯 核心经验
- "第 3 次出现原则": 代码重复 3 次时必须抽取
- NutUI 陷阱: textarea、IconFont 等组件有坑,优先使用原生组件
-
静态资源: SVG 图标必须使用
import导入 - 样式策略: TailwindCSS(80%) + Less(20%) 混合使用
-
性能优化:
shallowRef+markRaw处理组件对象响应式 - 代码质量: 强制 JSDoc 注释,统一命名规范
- 架构设计: 分层清晰,职责单一
📚 推荐阅读
🔄 持续更新
本文档会随着项目开发持续更新,记录新的经验教训。
最后更新: 2026-01-31 维护者: Claude Code 项目: Manulife WeApp