普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月13日掘金 前端

vue3规范化示例

作者 一尘子
2026年1月13日 14:09

一、层级分离原则

1. 模板层(UI)分离

❌ 反例:模板中写复杂表达式和数据操作

<template>
  <div>
    <!-- 反例1: 模板中写复杂表达式 -->
    <div>
      {{ goodsList.filter(item => item.price > 100).map(item => item.name).join(',') }}
    </div>
    
    <!-- 反例2: 直接在模板中修改数据 -->
    <button @click="goodsList.push({ id: Date.now(), name: '新商品', price: 99 })">
      添加商品
    </button>
    
    <!-- 反例3: 模板中写复杂计算 -->
    <div>
      总价: {{ goodsList.reduce((sum, item) => sum + item.price * item.count, 0).toFixed(2) }}
    </div>
    
    <!-- 反例4: 用样式控制业务逻辑 -->
    <div :style="{ display: isVisible ? 'block' : 'none' }">
      内容
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const goodsList = ref([
  { id: 1, name: '商品A', price: 150, count: 2 },
  { id: 2, name: '商品B', price: 80, count: 1 },
]);
const isVisible = ref(true);
</script>

✅ 正例:模板只负责渲染,逻辑放在 computed/methods

<template>
  <div>
    <!-- 正例1: 调用简单的 computed -->
    <div>{{ filteredGoodsNames }}</div>
    
    <!-- 正例2: 调用方法处理事件 -->
    <button @click="handleAddGoods">添加商品</button>
    
    <!-- 正例3: 使用 computed 计算总价 -->
    <div>总价: {{ totalPrice }}</div>
    
    <!-- 正例4: 用 v-if 控制渲染 -->
    <div v-if="isVisible">内容</div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';

// 原始数据
const goodsList = ref([
  { id: 1, name: '商品A', price: 150, count: 2 },
  { id: 2, name: '商品B', price: 80, count: 1 },
]);
const isVisible = ref(true);

// 数据处理放在 computed
const filteredGoodsNames = computed(() => {
  return goodsList.value
    .filter(item => item.price > 100)
    .map(item => item.name)
    .join(',');
});

const totalPrice = computed(() => {
  return goodsList.value
    .reduce((sum, item) => sum + item.price * item.count, 0)
    .toFixed(2);
});

// 事件处理放在 methods
const handleAddGoods = () => {
  goodsList.value.push({
    id: Date.now(),
    name: '新商品',
    price: 99,
    count: 1,
  });
};
</script>

2. 逻辑层(数据)分离

❌ 反例:在 data 中存储派生数据,在模板中直接调用接口

<template>
  <div>
    <!-- 反例: 在模板中直接调用接口 -->
    <button @click="fetchGoods">加载商品</button>
    <div v-for="item in formattedList" :key="item.id">
      {{ item.displayName }} - ¥{{ item.formattedPrice }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';

// 反例1: 存储格式化后的派生数据
const formattedList = ref([
  { id: 1, displayName: '商品A', formattedPrice: '150.00' },
]);

// 反例2: 在事件中直接写接口请求
const fetchGoods = () => {
  axios.get('/api/goods').then(res => {
    // 每次都要手动格式化并同步到 formattedList
    formattedList.value = res.data.map((item: any) => ({
      id: item.id,
      displayName: `${item.name} (${item.category})`,
      formattedPrice: item.price.toFixed(2),
    }));
  });
};
</script>

✅ 正例:data 只存原始数据,computed 处理派生数据,接口抽离到 api

<template>
  <div>
    <button @click="handleFetchGoods" :loading="loading">加载商品</button>
    <div v-for="item in formattedGoodsList" :key="item.id">
      {{ item.displayName }} - ¥{{ item.formattedPrice }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import { fetchGoodsList } from '@/api/goods'; // 接口抽离到 api

// 正例1: data 只存储原始数据
const goodsList = ref([]);
const loading = ref(false);

// 正例2: 派生数据用 computed 自动计算
const formattedGoodsList = computed(() => {
  return goodsList.value.map(item => ({
    id: item.id,
    displayName: `${item.name} (${item.category})`,
    formattedPrice: item.price.toFixed(2),
  }));
});

// 正例3: 接口请求和业务逻辑抽离
const handleFetchGoods = async () => {
  try {
    loading.value = true;
    const res = await fetchGoodsList();
    goodsList.value = res.data; // 只更新原始数据,computed 自动更新
  } catch (error) {
    console.error('加载失败', error);
  } finally {
    loading.value = false;
  }
};
</script>
// src/api/goods.ts - 接口抽离
import request from '@/utils/request';

export const fetchGoodsList = () => {
  return request.get('/api/goods');
};

3. 组件层(业务解耦)

❌ 反例:通用组件包含业务逻辑

<!-- Table.vue - 反例: 通用组件直接调用业务接口 -->
<template>
  <el-table :data="tableData" :loading="loading">
    <el-table-column prop="name" label="名称" />
    <el-table-column prop="price" label="价格" />
  </el-table>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';

// 反例: 通用组件内部直接调用业务接口
const tableData = ref([]);
const loading = ref(false);

onMounted(() => {
  loading.value = true;
  axios.get('/api/goods').then(res => {
    tableData.value = res.data;
    loading.value = false;
  });
});
</script>
<!-- GoodsList.vue - 反例: 父组件传原始数据,子组件内部格式化 -->
<template>
  <Table :data="goodsList" />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import Table from './Table.vue';
import { fetchGoodsList } from '@/api/goods';

const goodsList = ref([]);

const loadData = async () => {
  const res = await fetchGoodsList();
  goodsList.value = res.data; // 传原始数据给子组件
};
</script>

✅ 正例:通用组件只接收 props,业务组件处理数据

<!-- Table.vue - 正例: 通用组件只接收 props,不包含业务逻辑 -->
<template>
  <el-table :data="data" :loading="loading" v-bind="$attrs">
    <slot />
  </el-table>
</template>

<script setup lang="ts">
defineProps<{
  data: any[];
  loading?: boolean;
}>();
</script>
<!-- GoodsList.vue - 正例: 业务组件处理数据,传给通用组件 -->
<template>
  <Table :data="formattedGoodsList" :loading="loading">
    <el-table-column prop="name" label="名称" />
    <el-table-column prop="price" label="价格" />
  </Table>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import Table from './Table.vue';
import { fetchGoodsList } from '@/api/goods';

const goodsList = ref([]);
const loading = ref(false);

// 业务组件负责数据格式化
const formattedGoodsList = computed(() => {
  return goodsList.value.map(item => ({
    ...item,
    price: `¥${item.price.toFixed(2)}`,
  }));
});

const loadData = async () => {
  try {
    loading.value = true;
    const res = await fetchGoodsList();
    goodsList.value = res.data;
  } finally {
    loading.value = false;
  }
};

loadData();
</script>

二、拆分原则

1. 函数拆分

❌ 反例:过度拆分,逻辑分散

// src/utils/goods-list/getItem.ts
export const getItem = (list: any[], id: number) => {
  return list.find(item => item.id === id);
};

// src/utils/goods-list/setItem.ts
export const setItem = (list: any[], item: any) => {
  const index = list.findIndex(i => i.id === item.id);
  if (index > -1) {
    list[index] = item;
  }
};

// src/utils/goods-list/checkItem.ts
export const checkItem = (item: any) => {
  return item && item.price > 0;
};

// src/utils/goods-list/filterItem.ts
export const filterItem = (list: any[], condition: any) => {
  return list.filter(item => item.price > condition.minPrice);
};

// GoodsList.vue
<script setup lang="ts">
import { getItem } from '@/utils/goods-list/getItem';
import { setItem } from '@/utils/goods-list/setItem';
import { checkItem } from '@/utils/goods-list/checkItem';
import { filterItem } from '@/utils/goods-list/filterItem';

// 调用时需要跨文件引入,逻辑链路变长
const handleUpdate = (id: number, newData: any) => {
  const item = getItem(goodsList.value, id);
  if (checkItem(item)) {
    setItem(goodsList.value, { ...item, ...newData });
  }
};
</script>

✅ 正例:按业务逻辑拆分,函数内聚

<!-- GoodsList.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
import { fetchGoodsList } from '@/api/goods';

const goodsList = ref([]);
const loading = ref(false);

// 正例: 按业务逻辑拆分成核心函数,函数内聚
const loadGoodsData = async () => {
  try {
    loading.value = true;
    const res = await fetchGoodsList();
    goodsList.value = formatGoodsData(res.data);
  } catch (error) {
    console.error('加载失败', error);
  } finally {
    loading.value = false;
  }
};

// 格式化数据 - 仅当前组件使用,写在组件内
const formatGoodsData = (data: any[]) => {
  return data.map(item => ({
    ...item,
    displayPrice: `¥${item.price.toFixed(2)}`,
    isAvailable: item.stock > 0,
  }));
};

// 如需复用,再抽离到 utils/goods.ts
loadGoodsData();
</script>

2. 组件拆分

❌ 反例:过度拆分,增加通信成本

<!-- SearchInput.vue -->
<template>
  <el-input v-model="inputValue" @input="handleInput" />
</template>

<script setup lang="ts">
import { ref } from 'vue';

const inputValue = defineModel<string>('modelValue');
const emit = defineEmits(['update:modelValue']);

const handleInput = (value: string) => {
  emit('update:modelValue', value);
};
</script>

<!-- SearchButton.vue -->
<template>
  <el-button @click="handleClick">{{ text }}</el-button>
</template>

<script setup lang="ts">
defineProps<{ text: string }>();
const emit = defineEmits(['click']);

const handleClick = () => {
  emit('click');
};
</script>

<!-- SearchWrapper.vue -->
<template>
  <div class="search-wrapper">
    <SearchInput v-model="keyword" />
    <SearchButton text="搜索" @click="handleSearch" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import SearchInput from './SearchInput.vue';
import SearchButton from './SearchButton.vue';

const keyword = ref('');
const emit = defineEmits(['search']);

const handleSearch = () => {
  emit('search', keyword.value);
};
</script>

✅ 正例:保留单个组件,内部封装

<!-- SearchBar.vue - 正例: 单个组件,逻辑闭环 -->
<template>
  <div class="search-bar">
    <el-input v-model="keyword" placeholder="请输入关键词" clearable />
    <el-button type="primary" @click="handleSearch">搜索</el-button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const keyword = ref('');
const emit = defineEmits(['search']);

const handleSearch = () => {
  emit('search', keyword.value);
};
</script>

三、目录拆分原则

组件目录结构说明

当工具函数、样式、组合式函数仅被单个组件使用时,应放在该组件所在目录下,而不是全局 src/utils

组件目录结构示例:
ProductList/
  ├── index.vue              # 主组件
  ├── index.scss             # 组件样式
  ├── composables/           # 仅当前组件使用的组合式函数
  │   ├── useProductData.ts
  │   └── useProductFilter.ts
  ├── utils/                 # 仅当前组件使用的工具函数
  │   ├── formatProduct.ts
  │   └── validateProduct.ts
  └── components/            # 仅当前组件使用的子组件
      └── ProductCard.vue

1. 工具函数拆分

❌ 反例:仅单组件使用的函数放在全局 utils

src/
  utils/
    product-list/            # 反例:仅在 ProductList.vue 使用
      formatProduct.ts
      filterProduct.ts
      validateProduct.ts
<!-- src/views/product/ProductList.vue -->
<script setup lang="ts">
import { formatProduct } from '@/utils/product-list/formatProduct';
import { filterProduct } from '@/utils/product-list/filterProduct';
import { validateProduct } from '@/utils/product-list/validateProduct';

// 引入路径长,且这些函数只在当前组件使用
</script>

✅ 正例:仅当前组件使用的工具函数放在组件目录下

src/views/product/ProductList/
  ├── index.vue
  ├── utils/                 # 正例:仅当前组件使用
  │   ├── formatProduct.ts
  │   └── filterProduct.ts
  └── index.scss
// src/views/product/ProductList/utils/formatProduct.ts
// 仅 ProductList 组件使用
export const formatProductPrice = (price: number): string => {
  return ${price.toFixed(2)}`;
};

export const formatProductName = (name: string, category: string): string => {
  return `${name} (${category})`;
};
// src/views/product/ProductList/utils/filterProduct.ts
// 仅 ProductList 组件使用
export const filterByPriceRange = (products: any[], min: number, max: number) => {
  return products.filter(p => p.price >= min && p.price <= max);
};

export const filterByCategory = (products: any[], category: string) => {
  return products.filter(p => p.category === category);
};
<!-- src/views/product/ProductList/index.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
import { formatProductPrice, formatProductName } from './utils/formatProduct';
import { filterByPriceRange } from './utils/filterProduct';

const products = ref([]);

// 引入路径短,且明确表示这些函数属于当前组件
const formattedProducts = computed(() => {
  return products.value.map(p => ({
    ...p,
    displayPrice: formatProductPrice(p.price),
    displayName: formatProductName(p.name, p.category),
  }));
});
</script>

2. 组合式函数(Composables)拆分

❌ 反例:仅单组件使用的 composable 放在全局

src/
  composables/
    useProductData.ts        # 反例:仅在 ProductList.vue 使用
    useProductFilter.ts      # 反例:仅在 ProductList.vue 使用
<!-- src/views/product/ProductList.vue -->
<script setup lang="ts">
import { useProductData } from '@/composables/useProductData';
import { useProductFilter } from '@/composables/useProductFilter';

// 看起来像是全局可复用的,但实际上只在当前组件使用
</script>

✅ 正例:仅当前组件使用的 composable 放在组件目录下

src/views/product/ProductList/
  ├── index.vue
  ├── composables/           # 正例:仅当前组件使用
  │   ├── useProductData.ts
  │   └── useProductFilter.ts
  └── index.scss
// src/views/product/ProductList/composables/useProductData.ts
// 仅 ProductList 组件使用
import { ref, computed } from 'vue';
import { fetchProductList } from '@/api/product';

export const useProductData = () => {
  const products = ref([]);
  const loading = ref(false);
  const error = ref(null);

  const loadProducts = async () => {
    try {
      loading.value = true;
      const res = await fetchProductList();
      products.value = res.data;
    } catch (err) {
      error.value = err;
    } finally {
      loading.value = false;
    }
  };

  const formattedProducts = computed(() => {
    return products.value.map(p => ({
      ...p,
      displayPrice: ${p.price.toFixed(2)}`,
    }));
  });

  return {
    products,
    formattedProducts,
    loading,
    error,
    loadProducts,
  };
};
// src/views/product/ProductList/composables/useProductFilter.ts
// 仅 ProductList 组件使用
import { ref, computed } from 'vue';

export const useProductFilter = (products: any[]) => {
  const searchKeyword = ref('');
  const priceRange = ref([0, 10000]);
  const selectedCategory = ref('');

  const filteredProducts = computed(() => {
    let result = products.value;

    // 按关键词过滤
    if (searchKeyword.value) {
      result = result.filter(p =>
        p.name.toLowerCase().includes(searchKeyword.value.toLowerCase())
      );
    }

    // 按价格范围过滤
    result = result.filter(
      p => p.price >= priceRange.value[0] && p.price <= priceRange.value[1]
    );

    // 按分类过滤
    if (selectedCategory.value) {
      result = result.filter(p => p.category === selectedCategory.value);
    }

    return result;
  });

  return {
    searchKeyword,
    priceRange,
    selectedCategory,
    filteredProducts,
  };
};
<!-- src/views/product/ProductList/index.vue -->
<script setup lang="ts">
import { useProductData } from './composables/useProductData';
import { useProductFilter } from './composables/useProductFilter';
import { onMounted } from 'vue';

// 引入路径短,且明确表示这些 composable 属于当前组件
const { formattedProducts, loading, loadProducts } = useProductData();
const { searchKeyword, priceRange, filteredProducts } = useProductFilter(formattedProducts);

onMounted(() => {
  loadProducts();
});
</script>

<template>
  <div>
    <input v-model="searchKeyword" placeholder="搜索商品" />
    <div v-for="product in filteredProducts" :key="product.id">
      {{ product.displayName }} - {{ product.displayPrice }}
    </div>
  </div>
</template>

3. 样式拆分

❌ 反例:仅单组件使用的样式放在全局

src/
  styles/
    product-list.scss        # 反例:仅在 ProductList.vue 使用
<!-- src/views/product/ProductList.vue -->
<style lang="scss">
@use '@/styles/product-list.scss';
</style>

✅ 正例:仅当前组件使用的样式放在组件目录下

src/views/product/ProductList/
  ├── index.vue
  ├── index.scss             # 正例:组件主样式
  ├── styles/                # 正例:样式拆分(如果样式较多)
  │   ├── variables.scss
  │   └── mixins.scss
  └── components/
      └── ProductCard.vue
      └── ProductCard.scss
// src/views/product/ProductList/index.scss
// 仅 ProductList 组件使用
@use './styles/variables.scss';
@use './styles/mixins.scss';

.product-list {
  padding: 20px;
  
  &__header {
    display: flex;
    justify-content: space-between;
    margin-bottom: 20px;
  }
  
  &__content {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 16px;
  }
}
// src/views/product/ProductList/styles/variables.scss
// 仅 ProductList 组件使用
$product-card-width: 200px;
$product-card-gap: 16px;
$product-header-height: 60px;
// src/views/product/ProductList/styles/mixins.scss
// 仅 ProductList 组件使用
@mixin product-card-hover {
  transition: transform 0.2s;
  
  &:hover {
    transform: translateY(-4px);
  }
}
<!-- src/views/product/ProductList/index.vue -->
<template>
  <div class="product-list">
    <div class="product-list__header">...</div>
    <div class="product-list__content">...</div>
  </div>
</template>

<style scoped lang="scss">
@import './index.scss';
</style>

4. 子组件拆分

❌ 反例:仅单组件使用的子组件放在全局 components

src/
  components/
    ProductCard.vue          # 反例:仅在 ProductList.vue 使用
    ProductFilter.vue        # 反例:仅在 ProductList.vue 使用
<!-- src/views/product/ProductList.vue -->
<script setup lang="ts">
import ProductCard from '@/components/ProductCard.vue';
import ProductFilter from '@/components/ProductFilter.vue';

// 看起来像是全局组件,但实际上只在当前组件使用
</script>

✅ 正例:仅当前组件使用的子组件放在组件目录下

src/views/product/ProductList/
  ├── index.vue
  ├── components/            # 正例:仅当前组件使用
  │   ├── ProductCard.vue
  │   ├── ProductCard.scss
  │   ├── ProductFilter.vue
  │   └── ProductFilter.scss
  └── index.scss
<!-- src/views/product/ProductList/components/ProductCard.vue -->
<!-- 仅 ProductList 组件使用 -->
<template>
  <div class="product-card">
    <img :src="product.image" :alt="product.name" />
    <h3>{{ product.name }}</h3>
    <p class="price">{{ product.displayPrice }}</p>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  product: any;
}>();
</script>

<style scoped lang="scss">
@import './ProductCard.scss';
</style>
<!-- src/views/product/ProductList/index.vue -->
<script setup lang="ts">
import ProductCard from './components/ProductCard.vue';
import ProductFilter from './components/ProductFilter.vue';

// 引入路径短,且明确表示这些组件属于当前组件
</script>

<template>
  <div class="product-list">
    <ProductFilter />
    <ProductCard
      v-for="product in products"
      :key="product.id"
      :product="product"
    />
  </div>
</template>

5. 何时抽离到全局

当以下情况出现时,再考虑抽离到全局:

✅ 需要复用时抽离到全局

# 情况1: 多个组件开始使用相同的工具函数
src/views/product/ProductList/utils/formatProduct.ts  # 原本在这里
src/views/order/OrderList.vue                        # 现在也要用
src/views/cart/CartList.vue                          # 现在也要用

# 应该抽离到:
src/utils/product/formatProduct.ts
// src/utils/product/formatProduct.ts
// 多个组件复用,抽离到全局
export const formatProductPrice = (price: number): string => {
  return ${price.toFixed(2)}`;
};

export const formatProductName = (name: string, category: string): string => {
  return `${name} (${category})`;
};
<!-- src/views/product/ProductList/index.vue -->
<script setup lang="ts">
// 从全局引入
import { formatProductPrice } from '@/utils/product/formatProduct';
</script>
<!-- src/views/order/OrderList.vue -->
<script setup lang="ts">
// 从全局引入
import { formatProductPrice } from '@/utils/product/formatProduct';
</script>

完整示例:组件目录结构

src/views/product/ProductList/
  ├── index.vue                    # 主组件
  ├── index.scss                   # 组件主样式
  │
  ├── composables/                 # 仅当前组件使用的组合式函数
  │   ├── useProductData.ts
  │   ├── useProductFilter.ts
  │   └── useProductPagination.ts
  │
  ├── utils/                       # 仅当前组件使用的工具函数
  │   ├── formatProduct.ts
  │   ├── filterProduct.ts
  │   └── validateProduct.ts
  │
  ├── components/                  # 仅当前组件使用的子组件
  │   ├── ProductCard/
  │   │   ├── index.vue
  │   │   └── index.scss
  │   ├── ProductFilter/
  │   │   ├── index.vue
  │   │   └── index.scss
  │   └── ProductPagination/
  │       ├── index.vue
  │       └── index.scss
  │
  └── styles/                      # 样式拆分(如果样式较多)
      ├── variables.scss
      ├── mixins.scss
      └── animations.scss

总结对比

场景 ❌ 反例(全局) ✅ 正例(组件目录)
仅单组件使用的工具函数 src/utils/product-list/format.ts ProductList/utils/format.ts
仅单组件使用的 composable src/composables/useProductData.ts ProductList/composables/useProductData.ts
仅单组件使用的样式 src/styles/product-list.scss ProductList/index.scss
仅单组件使用的子组件 src/components/ProductCard.vue ProductList/components/ProductCard.vue
多个组件复用 - 抽离到全局 src/utils/src/composables/

原则:

  • 仅当前组件使用 → 放在组件目录下(utils/composables/components/styles/
  • 多个组件复用 → 抽离到全局(src/utils/src/composables/src/components/

总结

  • 模板层:只负责渲染,不写复杂表达式和数据操作
  • 逻辑层:data 存原始数据,computed 处理派生数据,接口抽离到 api
  • 组件层:通用组件只接收 props,业务组件处理数据逻辑
  • 拆分原则:按业务逻辑拆分,避免过度拆分增加复杂度

这些示例展示了如何在实际项目中应用这些原则。

❌
❌