阅读视图

发现新文章,点击刷新页面。

得物App智能巡检技术的探索与实践

一、背景

随着得物App各业务功能的丰富和升级,得物App内可供用户体验的内容和活动逐步增多,在用户App内体验时长不断增长的大背景下,App使用过程中的体验问题变得愈发重要。同时,在整个功能研发流程中,App端的测试时间相对有限,对于App上的各种场景的体验问题无法实现完全的覆盖,传统的UI自动化回归无法全面满足应用质量保障的需求。特别是在涉及页面交互和用户体验等较为主观的问题时,往往只能依赖于测试人员手动体验相关场景来进行质量保障,整体测试效率较低。

前段时间,我们结合内部的前端页面巡检平台,实现了对App上核心场景和玩法的日常巡检执行能力,对于基础的页面展示问题检查、交互事件检测和图片相似检测等问题已经初步具备有效的检测能力。针对应用体验类问题在传统自动化方式下的检测难题,我们结合AI模型在内部场景应用的经验,开始尝试在App上利用大型模型的分析能力进行巡检,并最终实现得物App智能巡检的应用落地。相较于传统的App质量保障方式,App智能巡检在帮助业务排查应用体验类问题有着极大的优势。

二、架构总览

App智能巡检系统架构

在App智能巡检的整个架构流程中,涉及了对于内部多个平台和服务的交互,这些平台和服务在整个流程中的定位不同,各自发挥着不同的作用:

  • 巡检平台

巡检平台作为整个智能巡检流程的管理中心,是用户直接能够进行交互的平台,所有用户需要检测的问题、检测的个性化规则、检测的目标场景等都可以在平台上完成。任务执行结束后,平台会对各个服务的执行结果进行汇总,并将结果和异常进行分析过滤,最终对于确定的异常问题会自动告警并通知给用户。

  • 自动化服务

自动化服务主要提供了App各端自动化任务执行的基本能力,是工作具体的承担者。在整个任务执行过程中,根据任务的配置信息,自动化服务会依次进行可用真机设备调度、执行环境初始化、进入目标页面、现场AI送检、自定义操作执行、通用异常查询分析等流程,最终将执行结果上报给巡检平台侧,进行结果归档。

  • 前端/客户端SDK

为了丰富巡检过程中可以活动到的异常信息和内容,我们和前端以及客户端平台进行合作,对相关检测能力进行了打通,除了执行过程本身可以检测和识别到的错误外,一些系统级别的错误,例如js错误、白屏错误、网络错误等都可以通过对应平台提供的sdk进行获取,相关的检测结果和执行步骤进行了关联绑定,方便用户快速识别异常来源。

  • 模型服务

对于视觉类任务的检测主要由模型服务来完成,模型基于用户配置的AI校验规则以及基础的通用检测规则,对执行现场的实时截图进行快速识别分析,对图中可疑的UI问题、交互问题以及不符合用户目标规则的内容进行深入探索,并产出最终的检测结果给到巡检平台。

  • 真机服务

真机服务用于提供云端的真机设备,在任务执行过程中,可以根据用户的执行系统、品牌、数量等需要进行空闲设备调度,以满足多设备的智能巡检需要。此外,对于巡检过程中发现的问题,用户可以远程登录对应的真机设备进行快速现场复现,研发修复相关问题后也可以通过真机设备快速验证。

三、主要功能设计

页面结构布局问题检测

在App使用过程中,最常见的UI问题包括页面的展示错位、组件重合或者排盘布局错乱等等,这类问题可以很直观地被用户感受到,直接影响到用户对于App的使用体验,此类问题我们称为页面结构布局问题类问题。

针对此类页面结构布局问题,传统的自动化手段一般缺乏一个统一的判断标准,因此无法在不同页面场景下应用,使用的维护成本也比较高。由于页面现场图片包含了大部分的有效信息,我们这里尝试将整体的页面信息提供给AI模型,让模型基于特定的规则来自动理解图片内容,并基于需要的检测规则来进行问题校验,此功能检测的基本流程如下:

页面结构布局问题检测流程

在功能整体的操作流程中,我们将基础的任务使用场景分成了两大类,针对不同类别的场景,AI检测和理解的侧重点有所不同,最终测试结果判断的标准也会有所不同:

  • 页面部分框架是否匹配

用于进行页面布局类检测,比如页面展示的内容、图片、文字、价格等和预期页面是否符合,模型通常会检测页面的元素布局以进行元素级别的匹配。

  • 页面完全匹配

用于文本文案展示类页面,校验文本与预期的是否完全保持一致,校验范围包含所有的图片、文字的,一般除了元素级别的匹配,还对整体页面进行了分析对比。

通用视觉体验问题检测

除了前面提到的页面布局问题,App体验和视觉类问题的表现形式实际还有非常多,为了能够覆盖这些不同的场景内容,我们额外设计了一个通用的视觉检测功能,用于更一般性场景的视觉问题分析,具体的校验规则可以由用户自由指定,当然平台也会提供一些默认的通用的规则,用户可以根据实际场景自由组合检测的目标和方式,该检测功能的基本流程如下:

通用视觉体验问题检测流程

在该功能的检测流程中,我们将整体的检验侧重点区分为针对文字和针对图片两类的检测两类,并基于各自的类型进行模型能力的调整:

  • 文字类校验

侧重于对页面中文案展示、文字排列、文字顺序等内容的校验,一般会先结合AI理解和ocr技术一起进行结果判断,通常用于文本展示较多的页面。

  • 图片类校验

侧重于对页面中展示元素的校验,一般包括所有页面元素的检验,文字相关校验的校验同样包括在内,但侧重点权重有所不同,结果会基于AI理解页面后产生,通常用于元素、图标、页面交互等丰富的页面场景。

页面展示一致性检测

前面两个检测能力都是针对单页面的异常问题检测,目前,得物App上的不同业务页面比较复杂,通用用户需要在多个不同页面中跳转,在这种情况下,会出现不同页面中的UI信息展示的是否符合预期标准的问题。此类UI问题由于设计多个页面,不同的页面可能由不同的业务团队和测试团队负责,其本身也没有固定的出现规律,一般只有依赖测试人员手工进行测试执行时随机发现,而且无法保障相关场景功能的覆盖面。

针对上述这种设计多页面的UI问题,我们经过分析后发现,在利用AI模型的基础上,解决该问题的关键是要理解目标商品或者展示之间的对应关系,同时在后续测试流程中保留这个关联信息,最终结合不同页面的现场信息和关联关系进行问题分析。我们基于此设计了一套页面展示一致性检测功能,该功能的整体流程如下:

页面展示一致性检测流程

相较于单页面的视觉体验问题检测,这类涉及多页面的UI问题检测有几个关键的差异点:

  • 检验层级和目标发现

由于整个检测流程涉及多级页面,在检测过程中需要判断检测的最终层级,防止对比页面不完全的问题,同时针对需要对比的商品内容,需要实时维护多级页面的对应展示关系,避免对比的对象出现差异。

  • 多级操作定位

在自动化执行过程中,由于后面层级的页面没有可靠的定位对比信息,我们采用了模型来识图定位,即用上级页面的目标图片来定位当前页面是否存在相同的目标,这个过程中要对相似的目标图片进行过滤和识别。

  • 多级页面对比分析

最终的分析结果依赖多级页面的现场截图信息和规则进行校验和对比,最终得到整个多级页面场景下的一致性分析结果。

AI操作与异常(无响应)检测

前置操作

除了直接对目标页面进行检测的场景,还有些目标入口可能无法直接到达,比如展示位置在目标页面某个板块,这个板块需要下滑动多屏距离才会展示。针对这种场景,我们引入了AI自动化操作能力,用户可以利用简短的语言描述对应的前置操作步骤,在实际任务执行时,AI会识别这些描述来完成相应的操作指令,在所有操作执行完成后,才进行其他视觉体验功能的智能巡检。该功能的执行流程如下:

前置AI操作任务流程

基于AI实现自然语言描述到实际操作的方式,我们参考业界一些实践的例子和开源的方案,最终采用AI模型完成操作分析和UI自动化框架完成操作执行的方式,在接入大模型的方式下(而且是开源免费的大模型,如果自建服务,token的耗费成本基本可以忽略),由于大模型本身有多模态能力,具备NLP、图像识别、目标定位、分类、检测,通过持续的对Prompt进行优化,就可以实现智能的UI自动化执行,实现大致的架构如下:

智能UI自动化架构图

具体的实现原理这里不进行详细的介绍,我们通过一个核心的能力实现简单分析下实现流程:

1.模型相关实现

import json
from openai import OpenAI
from ..config.llm_config import LLMConfig
from ..utils import get_logger
class ChatClient:
    #模型初始化
    def __init__(self, config_path=None, model_log_path=None):
        self.logger = get_logger(log_file=model_log_path)
        self.config = LLMConfig(config_path)
        self.openai = OpenAI(
            api_key=self.config.openai_api_key,
            base_url=self.config.openai_api_base,
        )
    def chat(self, prompt_data):
        #模型交互,提交截图和任务描述
        chat_response = self.openai.chat.completions.create(
            model=self.config.model,
            messages=prompt_data,
            max_tokens=self.config.max_tokens,
            temperature=self.config.temperature,
            extra_body={
                "vl_high_resolution_images"True,
            }
        )
        result = chat_response.choices[0].message.content
        json_str_result = result.replace("```json""").replace("```""")
        try:
            res_obj = json.loads(json_str_result)
            return res_obj
        except Exception as err:
            self.logger.info(f"LLM response err: {err}")


        #异常数据修复
        try:
            import json_repair
            res_obj = json_repair.repair_json(json_str_result, return_objects=True)
            return res_obj
        except Exception as err:
            self.logger.info(f"LLM response json_repair err: {err}")
        try:
            import re
            #返回的bbox处理
            if "bbox" in json_str_result:
                while re.search(r"\d+\s+\d+", json_str_result):
                    json_str_result = re.sub(r"(\d+)\s+(\d+)"r"\1,\2", json_str_result)
            res_obj = json.loads(json_str_result)
            return res_obj
        except Exception as err:
            self.logger.info(f"LLM response re.search err: {err}")

2.点击操作实现

def ai_tap(self, description):
    screenshot_base64 = self.get_resized_screenshot_as_base64()
    ret = {
        "screenshot": screenshot_base64,
    }
    prompt = Tap(description).get_prompt(screenshot_base64)
    res_obj = self.chat_client.chat(prompt)
    if "errors" in res_obj and res_obj["errors"]:
        ret["result"] = False
        ret["message"] = res_obj["errors"]
    else:
        #返回的bbox处理为实际坐标
        x, y = self.get_center_point(res_obj["bbox"])
        #进行具体的自动化操作
        self._click(x, y)
        ret["location"] = {"x": x, "y": y}
        ret["result"] =  True
        ret["message"] = ""
    return ret

独立路径校验操作

当然,AI操作配置也可以作为单独的功能校验逻辑,放在视觉任务检测后面去执行,此时操作执行的逻辑与其他任务相对独立,如果操作执行过程中出现错误同样会上报。

独立路径校验流程

操作无响应检测

在自动化的操作过程中,我们还需要关注操作本身是否是生效的,否则无法保证整个执行链路的完成,操作本身的有效性需要通过额外的方式来保障。在一些实际场景中,App内的一些点击操作也可能出现无响应的情况,这可能是设计问题,也可能是网络或者响应问题,总的来说,这也属于一种实际使用过程中的体验类问题。因此,在原有的执行流程中,我们引入了额外的差异分析模块,用于保证操作的有效完成。

操作无响应检测流程

在操作有效性的保障方案上,我们对比了图像像素处理和模型分析对比两种方式,从分析结果来看,两者的分析结果都能满足我们实际的场景需要,考虑到成本因素,大部分场景下我们会优先采用基于图像像素对比的方式,在一些判断结果无法满足的场景中,我们再使用模型来分析:

def check_operation_valid(screen_path_before, screen_path_after, cur_ops, screen_oss_path_before,
                          screen_oss_path_after):
    try:
        import cv2
        import numpy as np
        img_before = cv2.imread(screen_path_before)
        img_after = cv2.imread(screen_path_after)
        if img_before is None or img_after is None:
            raise RuntimeError(f"操作响应校验读取图片异常")
        # 确保两张图片大小相同
        if img_before.shape != img_after.shape:
            img_after = cv2.resize(img_after, (img_before.shape[1], img_before.shape[0]))
        # 转换为灰度图
        gray_before = cv2.cvtColor(img_before, cv2.COLOR_BGR2GRAY)
        gray_after = cv2.cvtColor(img_after, cv2.COLOR_BGR2GRAY)
        # 计算直方图
        hist_before = cv2.calcHist([gray_before], [0], None, [256], [0256])
        hist_after = cv2.calcHist([gray_after], [0], None, [256], [0256])
        # 归一化直方图
        hist_before = cv2.normalize(hist_before, hist_before).flatten()
        hist_after = cv2.normalize(hist_after, hist_after).flatten()
        # 计算相关系数 (范围[-1, 1],1表示完全相同)
        correlation = cv2.compareHist(hist_before, hist_after, cv2.HISTCMP_CORREL)
        # 计算卡方距离 (范围[0, ∞],0表示完全相同)
        chi_square = cv2.compareHist(hist_before, hist_after, cv2.HISTCMP_CHISQR)
        # 计算交集 (范围[0, 1],1表示完全相同)
        intersection = cv2.compareHist(hist_before, hist_after, cv2.HISTCMP_INTERSECT)
        # 设置相关系数阈值,超过此阈值视为两张图一致
        threshold = Thres
        threshold_chi_square = Thres_chi
        if correlation > threshold and chi_square < threshold_chi_square and intersection > threshold:
            raise RuntimeError(f"当前操作:{cur_ops} 疑似无效")
    except Exception as e:
        raise e

四、平台建设与使用

平台配置

基础规则配置

对于上述不同的检测类型和功能,在巡检平台上可以进行目标检测规则的创建和管理,所有的巡检场景和任务可以共同使用这些规则。

配置项

不同功能具体的检测规则不尽相同,配置内容也有所差异,但基本上只需要描述一个检测的大致范围,不需要太细化,比如下面是我们平台的通用检测规则,用于检查所有常见的排版、报错的异常问题:

巡检结果反馈

常规任务

一般来说,对于执行完成的检测任务,在对应的详情页可以查看到模型的逻辑和过程,比如下面是得物App内某ip品牌页的检测结果:

在对应测试记录的执行详情里,一般会展示以下关键的信息:

  • 当前规则的对比图和现场实时截图
  • 基于配置的检测规则,模型的具体分析过程和结论

通过现场的截图信息和模型分析描述,测试人员可以准确的定位和分析问题,如果当前结果是模型的误报,相关人员也可以反馈给我们,我们会根据检测结果不断优化模型的检测能力。

页面展示一致性检测

对于多级页面展示一致性的检测任务,最终返回的结果信息里,会有日志信息说明比较的过程和结果,如果有异常会额外提供不同页面的对比截图:

AI操作

在有AI操作配置的任务中,其结果详情页面会有整个流程的执行截图,来帮助测试人员定位问题和还原现场:

五、总结

在移动应用自动化测试领域,传统的元素和图像驱动方法正在逐渐向智能化驱动转型,这一转变不仅提升了自动化的使用效率和维护便利性,也使得基于模型的图像理解能力得以发挥,从而实现深度探索应用程序的潜力。

我们通过将现有的技术平台进行整合,基于视觉语言模型(VLM),开展场景化的智能巡检探索与实践。这种方法在多种任务场景下均能有效识别应用中的问题,相较于之前的方案,智能巡检整体的问题识别准确率从50%提升到80%, 整体图片相似度匹配准确率从50%提升到80%以上,在首次会场AI走查的过程中(纯技术),共发现17个配置问题,AI问题发现率达95%。

后续我们会继续结合AI大模型能力在App的相关场景进行更多的探索和应用,帮助测试人员更高效地保障App的质量,提升得物App的用户使用体验。

往期回顾

1.深度实践:得物算法域全景可观测性从 0 到 1 的演进之路

2.前端平台大仓应用稳定性治理之路|得物技术 

3.RocketMQ高性能揭秘:承载万亿级流量的架构奥秘|得物技术

4.PAG在得物社区S级活动的落地

5.Ant Design 6.0 尝鲜:上手现代化组件开发|得物技术

文 /锦祥

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

KuiTest:基于大模型通识的 UI 交互遍历测试

美团质效技术部联合复旦大学周扬帆教授团队推出 KuiTest——零规则 UI 功能性异常测试工具。KuiTest 通过将“人类预期”直接用作 Test Oracle,解决了长期以来 UI 测试 Oracle 泛化性差的自动化痛点。实验表明,KuiTest 异常召回率达 86%,误报率仅 1.2%,已在执行 21 万+测试用例,发现百余例有效缺陷,大幅降低人工成本并提升测试覆盖率。

Promise与async/await

本文深入解析JavaScript异步编程中的Promise与async/await机制,详细介绍了async函数的声明方式及其返回值特性,以及await操作符的工作原理和暂停/恢复执行机制。

vue3规范化示例

一、层级分离原则

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,业务组件处理数据逻辑
  • 拆分原则:按业务逻辑拆分,避免过度拆分增加复杂度

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

❌