代码变更暗藏危机?代码影响范围分析为你保驾护航

一、引言

1. 什么是代码影响范围(函数维度)

在大型前端项目中,随着代码库的增长和开发周期的延续,代码变更的影响变得愈发复杂。代码影响范围分析是一种评估变更对现有代码库中不同文件、模块和函数的影响的方法。特别是当变更发生在函数级别时,其影响不仅限于直接修改的部分,还可能波及到调用该函数的其他函数、组件或模块。

简单来说,代码影响范围指的是项目中修改、添加或删除的文件,以及这些变更中的函数或变量在被其他部分引用时可能引发的连锁反应。通过项目的依赖树,开发者可以有效追踪和管理这些影响的传播。

2. 代码影响范围的背景与价值

在实际项目开发中,尤其是涉及多人协作和多个模块的团队开发中,代码变更的影响可能会扩展到多个文件、函数或模块。未能准确识别变更的传播范围可能导致一系列潜在问题,如:

  • 🛠️ 公共组件的修改未被完整验证,导致某些功能异常;
  • 🚫 公共方法的修改引发逻辑错误,影响多个模块的正常运行。

因此,代码影响范围分析不仅能显著降低因变更引起的潜在故障风险,还能精准进行代码评审、提升开发效率。通过这种分析,开发团队可以:

  • 🔍 精准进行代码评审,通过识别变更的传播范围,减少人工检查的盲点,确保变更的安全性和合理性。
  • 🧪 智能测试优化,结合变更影响,按需选择测试用例,减少冗余测试。
  • 📈 评估项目复杂度,提升代码质量。

3. 代码影响范围与代码覆盖率的区别

指标 代码影响范围 代码覆盖率
关注点 变更代码的传播影响 测试代码覆盖的范围
作用 评估变更风险,优化测试 评估测试完整性
计算方式 依赖分析、AST 解析 代码执行情况
优点 1. 🔍 帮助开发者准确识别受影响区域 2. 🧪 有助于优化测试用例的选择,减少冗余测试 3. 📈 帮助开发者评估项目复杂度 1. 🧪 确保所有代码路径都经过测试 2. 📊 提供对项目代码质量的直观度量
缺点 1. 依赖分析复杂,可能导致较高的计算开销 2. 不容易完全捕捉到所有间接影响 1. 仅关注代码的覆盖率,不考虑变更的传播影响 2. 不一定能反映测试是否有效或覆盖潜在的 bug
应用场景 1. 项目中代码变更频繁时,确保变更不引发潜在问题 2. 多人协作开发时,帮助识别跨模块的影响 1. 确保项目的每个代码路径都被至少一次测试经过 2. 测试代码覆盖率较低时,作为提升测试质量的依据

代码影响范围分析侧重于识别项目中受变更影响的路由、模块、文件或组件,重点是追踪变更的传播,确保变更不会引发未被检测到的问题。

代码覆盖率分析则关注测试覆盖的范围,确保所有代码路径都被充分测试,从而避免遗漏任何潜在的错误。

二、架构流程设计

代码影响范围架构设计分为多个关键步骤。通过以下的架构流程,可以系统化了解该项目是如何分析和标记代码变更的影响范围。

整个架构的主要模块包括:

  • 🌳 项目依赖树构建:解析项目中的依赖关系,建立完整的调用链路;

  • 📄 代码变更分析:基于 GitLab Diff 数据,提取变更的函数和变量;

  • 🎯 影响范围标记:追踪变更函数的调用路径,确定受影响的文件和模块;

  • 📉 影响结果展示:将分析结果可视化,以便代码评审和测试。

通过这些模块的协同作用,开发团队可以精准掌握代码变更的影响,提高代码质量并降低潜在风险。

具体可以看下图了解:

三、核心技术实现

下面的核心技术部分,只是部分逻辑,并未列举所有情况。

1. 项目依赖树构建

a. 判断项目类型

首先需要去判断项目类型,具体步骤如下:

  • 项目的依赖树构建需要先判断项目类型,以确定后续解析路由的方式。该模块主要判断:
  • 📁 是否是 Umi 框架(基于 zz.config.ts 配置文件或 package.json 依赖项)
  • 🚀 是 Vue 还是 React 项目(基于 package.json 依赖项)

以下是代码示例:

js 复制代码
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const getFileContent = require("./getFileContent") //获取代码文件
/**
 * 判断是否是 Umi 项目
 * - 通过解析 `zz.config.ts` 中的 tplType 或者判断 `package.json` 中是否有 umi 依赖
*/
async function getIsUmiProject({ appName, commitId }) {
  // 读取 zz.config.ts 并解析 AST
  const zzConfigCode = await getFileContent({ appName, srcPath: "zz.config.ts", commitId });
  if (zzConfigCode) {
    const ast = parser.parse(zzConfigCode, { sourceType: "module", plugins: ["typescript", "jsx"] });
    let tplType = null;
    traverse(ast, {
      ObjectProperty(path) {
        if (path.node.key.name === "tplType") tplType = path.node.value.value;
      }
    });
    if (tplType === "umi") return true;
  }

  // 判断 package.json 中是否包含 umi 依赖
  const packageJsonContent = await getFileContent({ appName, srcPath: "package.json", commitId });
  if (packageJsonContent) {
    const packageJson = JSON.parse(packageJsonContent);
    const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
    return Object.keys(dependencies).some(dep => dep.includes("@umijs") || dep === "umi");
  }
  return false;
}

/**
 * 判断项目类型(Vue 或 React)
 * - 通过 `package.json` 中的依赖项进行判断
 */
async function getIsVueOrReact({ appName, commitId }) {
  const packageJsonContent = await getFileContent({ appName, srcPath: "package.json", commitId });
  if (packageJsonContent) {
    const packageJson = JSON.parse(packageJsonContent);
    const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
    if (dependencies["vue"]) return "vue";
    if (dependencies["react"]) return "react";
  }
}
b. 解析项目路由信息

(1)在构建项目依赖树时,解析项目的路由信息是关键一环,因为路由定义了页面或组件的组织结构,是依赖分析的重要起点。不同类型的项目(React/Vue)有不同的路由实现方式,因此解析路由的核心在于:

  • 判断项目类型:来决定分析路由的策略。

  • 识别路由结构:

    • 📁 Umi.js 项目:检测是否是 Umi 项目 (getIsUmiProject),如果是,则解析 config 目录。
    • 🚀 Vue 项目:检查 src/router 目录,并确认是否使用 Vue Router (getIsUseVueRouter)。
    • 🎨 普通 React 项目:扫描 src/router 目录,提取路由定义。
  • 递归解析路由配置(调用 loopRouter 函数)。

  • 添加主入口文件:

    • 🚀 Vue 项目:尝试获取 src/main.js 或 src/main.ts。
    • 🎨 React 项目:寻找 src/app.jsx、src/App.jsx 等入口文件。

    以下是解析路由的示例代码:

javascript 复制代码
async function getRouters({ appName, commitId }) {
  const arr = [];
  const projectType = await getIsVueOrReact({ appName, commitId });
  const isUmi = await getIsUmiProject({ appName, commitId });
  // 确定路由目录
  let routerPath = isUmi? "config" : "src/router"
  // 递归获取所有路由
  await loopRouter({ appName, arr, srcPath: routerPath, commitId, isUmi });
  // 添加主入口文件
  const mainEntry = projectType === "vue"
    ? ["src/main.js", "src/main.ts"]
    : ["src/app.jsx", "src/App.jsx", "src/App.tsx", "src/app.tsx"];

  for (const filePath of mainEntry) {
    if (await getFileContent({ appName, commitId, srcPath: filePath })) {
      arr.push({ filePath, modulePath: "/", moduleTitle: "项目主入口文件" });
      break;
    }
  }
  return arr;
}

(2)在 loopRouter 方法中,主要进行的是对项目中的路由结构的遍历与解析。具体是:

  • 递归处理树形结构:如果路由结果是 tree 类型,表示是一个目录,递归调用 loopRouter 继续解析该目录下的路由。

  • 解析代码文件:如果路由是 blob 类型(即文件),通过 getFileContent 获取文件内容,再通过 getRouterAttributesOfAst 获取 AST(抽象语法树),从中提取路由相关的组件信息。

  • 构建路由信息:解析得到的路由组件路径(moduleComponent)可能是相对路径,需要通过 getFullPath 解析成绝对路径。然后将相关的路由信息(如文件路径、模块路径、模块标题)添加到 arr 数组中。

    以下是loopRouter示例代码:

    js 复制代码
    async function loopRouter({ appName, arr, srcPath = '', commitId, isUmi }) {
        const routerRes = await getFileList({ appName, srcPath: 'src/router', commitId });
        for (const item of routerRes) {
          if (item.type === 'tree') {
            // 递归处理目录
            await loopRouter({ appName, arr, srcPath: item.path, commitId, isUmi});
          } else if (item.type === 'blob') {
            // 处理文件类型,获取文件内容并解析
            const code = await getFileContent({ appName, srcPath: item.path, commitId });
            const data = await getRouterAttributesOfAst({ type: 'content', source: code });
            
            for (const dataItem of data) {
              if (dataItem.moduleComponent) {
                // 根据项目类型修正模块路径
                if (isUmi && dataItem.moduleComponent.startsWith('./')) {
                  dataItem.moduleComponent = dataItem.moduleComponent.replace('./', '@/pages/');
                }
                dataItem.componentPath = await getFullPath({
                  appName,
                  usePath: item.path,
                  importOrExportPath: dataItem.moduleComponent,
                  commitId
                });
              }
              arr.push({
                filePath: dataItem.componentPath,
                modulePath: dataItem.modulePath,
                moduleTitle: dataItem.moduleTitle
              });
            }
          } else {
            arr.push({  // 处理其他类型(非路由文件)
              filePath: item.path,
              modulePath: item.modulePath || item.path,
              moduleTitle: item.moduleTitle
            });
          }
        }
    }

    核心功能: 解析路由文件的核心功能是通过 getRouterAttributesOfAst 方法解析具体的路由文件。该方法根据不同的项目类型(如 Umi、Vue 或 React),从代码中提取出相关的路由信息(如组件路径、模块路径等)。

    在前面,我们已经分析了项目的路由信息以及主入口文件,并将其作为依赖树的根节点。接下来,我们将基于根节点构建完整的依赖树,其中核心步骤涉及 AST(抽象语法树) 的解析。

c. AST 解析构建依赖树(Babel AST)

(1)获取项目文件的 import/export

构建依赖树的第一步是解析项目中的 JavaScript/TypeScript/Vue 文件,获取文件的导入(import)和导出(export)信息。通过这些信息,开发人员可以清晰地了解文件间的依赖关系。

核心功能:

  • 📥 解析文件中的 import 语句,确定文件导入了哪些模块或组件。
  • 📥 解析文件中的 export 语句,了解文件暴露了哪些内容给其他文件使用。
  • 动态导入解析:支持对 import () 和 require.context () 等动态导入的解析。
  • 📦 支持多种文件类型:能够处理 .js、.ts、.jsx、.tsx 和 .vue 文件,提取它们的依赖信息。

以下代码示例,展示了如何解析文件中的导入和导出:

js 复制代码
const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generate = require("@babel/generator").default;
const types = require("@babel/types");
const getFullPath = require("../utils/getFullPath");
const parseRequireContext = require("./parseRequireContext");
const fs = require("fs");
/**
 * 获取文件的导入和导出信息
 * @param {string} params.appName - 应用名称
 * @param {string} params.filePath - 文件路径
 * @param {string} [params.code] - 文件内容
 * @param {string} params.commitId - 提交ID
 * @param {string} [params.lang] - 语言类型,默认为 js 或 ts
 * @returns {Object} 导入和导出信息
 */
 async function getImportsAndExports({ appName, filePath, code = "", commitId, lang }) {
  lang = lang || (filePath.includes("ts") ? "ts" : "js");
  const imports = [], exports = [];
  // 解析代码为AST
    const ast = babelParser.parse(code, {
      filename: lang === "ts" ? "anyName.ts" : "anyName.tsx",
      sourceType: "module",
      plugins: ["typescript", "decorators-legacy", lang === "ts" ? "jsx" : "jsx"],
    });
    // 遍历AST捕获导入和导出语句
    traverse(ast, {
      // 处理导入声明
      ImportDeclaration(path) {
        const importObj = { path: path.node.source.value };
        const specifiers = path.node.specifiers;
        if (specifiers.length === 1 && types.isImportDefaultSpecifier(specifiers[0])) {
          importObj.type = "default";
          importObj.value = [specifiers[0].local.name];
        } else if (specifiers.length > 1 || types.isImportSpecifier(specifiers[0])) {
          importObj.type = "named";
          importObj.value = specifiers.map(spec => spec.local.name);
        }
        imports.push(importObj);
      },
      ExportNamedDeclaration(path) {
        // 处理命名导出
        if (path.node.specifiers.length) { 
          path.node.specifiers.forEach(spec => exports.push({ name: spec.exported.name, type: "named", path: filePath, code: generate(spec).code }));
        }
        // 处理变量声明的导出
        if (path.node.declaration && types.isVariableDeclaration(path.node.declaration)) { 
          path.node.declaration.declarations.forEach(decl => exports.push({ name: decl.id.name, type: "named", path: filePath, code: generate(decl).code }));
        }
        // 处理从其他模块导出的默认导出
        if (path.node.source) {
          imports.push({ path: path.node.source.value, type: "default", code: ["default"] });
        }
      },
      // 处理默认导出
      ExportDefaultDeclaration(path) {
        exports.push({ name: "default", type: "default", path: filePath, code: generate(path.node.declaration).code });
      }
    });
  // 解析导入路径并转化为绝对路径
  for (let imp of imports) {
    if (imp.path?.length > 1) {
      imp.path = await getFullPath({ appName, usePath: filePath, importOrExportPath: imp.path, commitId });
    }
  }
  // 过滤并去重导入路径
  const filteredImports = imports.filter(item => 
    item.path && fs.existsSync(item.path) &&
    !item.path.includes("src/router/") && 
    !item.path.includes("config/routes")
  );
  // 返回结果
  return { imports: filteredImports, exports };}

2. 代码变更分析

在项目开发中,每次代码提交都会引入变更,而这些变更可能会影响其他文件的正常运行。因此,如何分析代码变更并准确标记受影响的文件,成为提升代码质量、减少 Bug 传播的重要手段。

这一部分,将详细说如何提取变更文件中的函数和变量,并如何基于这些变更标记受影响的文件。

a. 获取 GitLab 分支的差异(Diff)

🔍 要分析代码变更,首先需要获取 GitLab 上两个分支之间的 diff 数据,这些数据通常包括:

  • 📄 变更的文件路径
  • ✏️ 修改的具体行数
  • ➕➖✏️ 变更类型(新增、修改、删除)

我们可以通过 GitLab API 获取这些信息:

js 复制代码
GET https://gitlab.xxx.com/api/v4/projects/${gitlabProjectId}/repository/compare?from=${beforeCommitId}&to=${commitId}

该 API 返回的 diff 数据将作为后续分析的基础。

b. 标记文件的新增、删除、修改

📋 获取 diff 数据后,接下来根据文件的变更类型进行初步分类:

  • ➕ 新增文件(新增的代码逻辑,通常不会影响其他文件)
  • ➖ 删除文件(需要关注依赖该文件的其他模块)
  • ✏️ 修改文件(需要进一步分析修改内容)

这一步只是标记文件变更,但我们需要进一步深入文件内容,分析文件中具体哪些函数和变量发生了变更,以精准判断影响范围。

c. 记录变更文件中发生变化的函数或变量

🔎 在代码变更分析中,了解哪些文件发生了变化只是第一步。接下来,我们需要深入分析具体哪些函数或变量在这些文件中发生了变更。只有准确标记这些函数和变量,才能更好地分析影响的范围。

💡 为了提取文件中的函数和变量,我们使用了 Babel 解析代码的抽象语法树(AST),并从中提取出所有的函数声明和变量声明。这可以帮助我们识别哪些函数和变量在代码中被修改、删除或新增。对于 Vue 文件,我们通过解析脚本部分来提取相应的信息(下面代码仅对非 vue 的进行了说明,如果你要处理 vue 你需要对 vue2.0 及 3.0 都要有不同的解析策略,但原理是都是 ast 解析)。

以下是如何提取函数和变量的示例代码:

js 复制代码
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const parseVueCode = require('./parseVueCode');
const { parseCallList } = require('./parseCallList');
const { getVueFunctionsAndVariables } = require('./getVueFunctionsAndVariables');
// 提取文件中的函数和变量声明
async function getFunctionsAndVariable({ code, path }) {
  const variables = []; // 存储变量信息
  const functions = []; // 存储函数信息
  let lang = path.includes('ts') ? 'ts' : 'js'
  if (path.endsWith('.vue')) { // 解析 Vue 文件的 script 部分
      return await getVueFunctionsAndVariables({ code, path }
  }
  // 处理非 Vue 文件(例如 JavaScript/TypeScript 文件)
  const ast = parser.parse(code, {
    filename: lang === 'ts' ? 'anyName.ts' : 'anyName.tsx',
    sourceType: 'module',
    plugins: ['jsx', 'typescript', 'decorators-legacy'],
  }); 
  // 遍历 AST,提取函数和变量信息
  traverse(ast, {
    // 处理变量声明
    VariableDeclarator({ node }) {
      if (!node.init) return; // 排除没有初始化的变量
      const name = node.id.name;
      const type = node.init.type.includes('Function') ? 'FunctionVariable' : 'Variable';
      const target = type === 'FunctionVariable' ? functions : variables;
      // 如果没有记录该变量或函数,则添加到对应的数组中
      if (!target.some(item => item.name === name)) {
        const info = { name, type, code: generate(node.init).code, callList: [] };
        target.push(info); // 将变量信息加入目标数组
      }
    },
    // 处理函数声明
    FunctionDeclaration({ node }) {
      if (!node.id) return;
      functions.push({ name: node.id.name, type: 'FunctionDeclaration', code: generate(node).code, callList: [] });
    },
    // 处理默认导出
    ExportDefaultDeclaration({ node }) {
      const { declaration } = node;
      if (!declaration) return;
      const type = declaration.type.includes('Function') ? 'Function' : 'Variable';
      const target = type === 'Function' ? functions : variables;
       // 只添加一个默认导出,避免重复
      if (!target.some(item => item.name === 'default')) {
        target.push({ name: 'default', type, code: generate(declaration).code, callList: [] });
      }
    }
  });
  return { variables, functions };
}

注意:上面仅仅是说明如何提取,并未覆盖所有场景,也未检查是否重复提取的变量或者函数是否重复。

e. 对比 master 和 当前分支的变更

🔄 我们需要对比 master 和 当前分支 之间的变更,找出 新增、修改、删除 的函数和变量。

具体需要包含以下几个流程:

  • 📋 获取 diffData 变更文件列表
    • ⚙️ 通过 Git API 获取 master 和 当前分支 之间的变更文件。
  • 💻 解析 master 和 当前分支 的代码
    • 📖 读取变更文件在 master 和 当前分支 中的代码。
    • 📄 使用 getFunctionsAndVariable 提取函数和变量信息。
  • 🔍 对比变更
    • 遍历 master 和 当前分支 的函数 / 变量列表,对比 name 和 code:
      • ➕ 新增:当前分支新增的函数 / 变量(master 无)。
      • ✏️ 修改:相同名称但代码不同。
      • ➖ 删除:master 有,但当前分支移除。

代码示例:这里仅展示对比变更代码的核心代码,未包含整个项目的完整对比。完整对比需要遍历 diff 文件列表,获取 master 和 当前分支 版本的所有相关代码文件。

js 复制代码
async function compareFunctionsAndVariables({ masterData, currentData, otherResult, fileParseData,filePath}) {
  // 对比 master 和当前分支的函数/变量列表,找出新增、删除、修改项
  const compareItems = async (masterList, currentList, type) => {
    const added = []; // 存储新增的函数/变量
    const deleted = []; // 存储删除的函数/变量
    const modified = []; // 存储修改的函数/变量
    // 以 name 作为键,将 masterList 转换为 Map,提高查找效率
    const masterMap = new Map(masterList.map(item => [item.name, item]));

    for (const item of currentList) {
      const masterItem = masterMap.get(item.name);
      // 处理新增项
      if (!masterItem) {
        await handleAddorRemoveCallList({
          callList: item.callList,
          fileArr: fileParseData.fileArr,
          otherResult,
          filePath
        });
        added.push(item);
      } else if (masterItem.code !== item.code) {
      // 处理修改项(相同名称但代码不同)
        await handleModifiedCallList({
          oldCallList: masterItem.callList,
          newCallList: item.callList,
          fileParseData,
          otherResult,
          filePath
        });
        modified.push(item);
      }
    }
    // 遍历 masterList,找出被删除的函数/变量
    for (const item of masterList) {
      if (!currentList.some(i => i.name === item.name)) {
        await handleAddorRemoveCallList({
          callList: item.callList,
          fileArr: fileParseData.beforeFileArr, // 这里传入的是 `beforeFileArr`,区别于新增时的 `fileArr`
          otherResult,
          filePath
        });
        deleted.push(item);
      }
    }
    return { added, deleted, modified };
  };
  // 分别对比函数和变量的变更
  const functionsDiff = await compareItems(masterData.functions, currentData.functions, 'function');
  const variablesDiff = await compareItems(masterData.variables, currentData.variables, 'variable');
  // 如果有新增/修改的函数或变量,则返回差异数据,否则返回 null
  return (functionsDiff.added.length || functionsDiff.modified.length ||
          variablesDiff.added.length || variablesDiff.modified.length)
    ? {
        functions: [...functionsDiff.added, ...functionsDiff.modified],
        variables: [...variablesDiff.added, ...variablesDiff.modified]
      }
    : null;
}

3. 影响文件标记及输出影响依赖树

标记影响节点并添加到依赖树的核心逻辑包含了两部分:变更数据补充依赖树节点 (processChanges) 以及向上标记受影响的父节点 (propagateChanges)

a. 将变更数据补充依赖树节点

🔍 这里我们对于删除的文件或者函数不做分析,只举例找项目依赖树上被标记为修改的文件,并将变更信息补充到对应的树节点上。

具体的逻辑如下:

  • 📌 (1) 匹配文件路径:在 changes 列表中查找与当前 node.filePath 匹配的变更项。
  • 📝 (2) 记录变更详情,如果当前节点有变更:
    • i. 记录变更的 diff(变更详情)
    • ii. 记录 diffResults(变更的函数 & 变量)
    • iii. 标记 updateStatus(变更状态,如 modified)
  • 🔄 (3) 递归处理子节点:遍历 node.children,对其执行 processChanges,确保整个树结构都被处理。

代码示例:

js 复制代码
function processChanges(node, changes = []) {
     // 在 changes 数组中查找与当前 node 文件路径匹配的变更对象
    const change = changes.find((c) => c.filePath === node.filePath);
    if (change) {
      node.diff = change.diff; // 将变更信息应用到 node 上
      node.diffResults = { functions: [], variables: [] };
      // 如果 diffResults 中存在函数变更,则添加到 node 的 diffResults.functions
      if (change.diffResults?.functions?.length) {
        node.diffResults.functions.push(...change.diffResults.functions);
      }
      // 如果 diffResults 中存在变量变更,则提取变量名称并添加到 node.diffResults.variables
      if (change.diffResults?.variables?.length) {
        node.diffResults.variables.push(
          ...change.diffResults.variables.map((v) => ({ name: v.name }))
        );
      }
      // 更新 node 的状态
      node.updateStatus = change.updateStatus;
    }
    // 递归处理子节点
    node.children?.forEach((child) => processChanges(child, changes));
}
b. 向上标记受影响的父节点

🔍 在项目代码的影响范围分析中,我们不仅需要标记直接变更的文件,还需要向上传播变更影响,标记依赖于变更的父节点。由于每个树节点已经包含了相应的变更状态、变更函数及变量信息,并且函数或变量可能被其他文件通过 import 进行引用,因此需要向上遍历树结构,分析并标记受影响的父节点。

具体的逻辑:

  • 📋 (1) 将当前节点加入 path 数组中,以便追踪从根节点到当前节点的路径。
  • ✅ (2) 判断当前节点有 diffResults(包含变更的函数和变量),则继续进行后续处理。
  • 📦 (3) 合并变更函数和变量:将当前节点的 functions 和 variables 的名称提取出来,形成一个 Set 集合 affectedNames,该集合包含了当前节点变更的函数和变量名称。
  • ⬆️ (4) 向上传播变更影响:
    • a. 从当前节点的父节点开始,向上遍历路径。每次向上传播时,检查父节点是否通过 import 引用了当前节点的变更函数或变量。
    • b. 遍历父节点的 imports,如果该父节点引用了当前节点的变更函数或变量(即该函数或变量的名称在 affectedNames 中),则将该父节点标记为 affected(修改状态)。
    • c. 确保父节点的 diffResults 存在,并将受影响的函数(从 importsReferenceMap 中提取的函数)添加到父节点的 diffResults.functions 中。
  • 🔄 (5) 递归处理子节点:递归调用 propagateChanges,确保整个依赖树上的所有相关节点都能被标记为受影响。
  • 🧹 (6) 清理路径:在函数结束时,从路径 path 中移除当前节点,以保持路径的正确性。

代码示例:

js 复制代码
function propagateChanges(node, path = []) {
  // 将当前节点加入路径
  path.push(node)

  // 如果当前节点有 diffResults,检查这些变更是否影响到它的父节点
  if (node.diffResults) {
    const { functions = [], variables = [] } = node.diffResults
    // 将函数和变量的名称合并到一个数组中,这些都是当前节点变更的名称
    const affectedNames = new Set([
      ...functions.map((func) => func.name),
      ...variables.map((vari) => vari.name)
    ])

    // 从当前节点向上查找,检查是否有父节点引用了这些变更
    for (let i = path.length - 2; i >= 0; i--) {
      let parent = path[i]
      if (parent.imports && parent.imports.length) {
        // 获取父节点中所有 type 为 "named" 的导入函数
        const allImportedFuncs = parent.imports
          ? parent.imports.flatMap((ref) =>
              ref && ref.type === "named" ? ref.value : []
            )
          : []
        // 检查是否有任何一个导入的函数在 affectedNames 中
        const hasAffectedImport = allImportedFuncs.some((funcName) =>
          affectedNames.has(funcName)
        )
        if (hasAffectedImport) {
          // 如果父节点引用了当前变更的函数或变量,标记父节点为修改
          parent.updateStatus = parent.updateStatus || "affected"
          // 确保父节点的 diffResults 存在
          if (!parent.diffResults) {
            parent.diffResults = { functions: [] }
          }
          if (parent.importsReferenceMap && parent.importsReferenceMap.length) {
            parent.importsReferenceMap.forEach((ref) => {
              if (affectedNames.has(ref.importedFunc)) {
                // 添加引用的原始函数(originFuncs)到父节点的 diffResults.functions,如果它还没有被添加
                ref.originFuncs.forEach((originFunc) => {
                  if (
                    !parent.diffResults.functions.some(
                      (f) => f.name === originFunc
                    )
                  ) {
                    parent.diffResults.functions.push({ name: originFunc })
                  }
                })
              }
            })
          }
        }
      }
    }
  }
  // 递归处理所有子节点
  if (node.children) {
    node.children.forEach((child) => propagateChanges(child, path))
  }
  // 从路径中移除当前节点
  path.pop()
}
c. 调用前两个方法,将变更信息应用到项目的依赖树上

该函数只要调用processChanges和propagateChanges方法:将变更(changes)传递给每个节点,更新树中的每个节点的状态和变更信息。然后将变更影响传播到父节点。

js 复制代码
exports.patchDiffToTree = (tree, changes) => {
  try {
    tree.forEach((node) => processChanges(node, changes)) // 先处理变更
    tree.forEach((node) => propagateChanges(node)) // 再向上传播影响
    return tree
  } catch (error) {
    console.error("在 patchDiffToTree 中发生错误,错误信息:", error)
  }
  return tree
}

4. 影响可视化

📊 对于整个影响的可视化,我们的目标是将经过依赖树节点上被标记为新增、修改、删除、影响的节点在前端页面中展示出来。通过这样的可视化展示,开发人员可以直观地了解本次迭代代码的开发,在整个项目中影响了哪些路由、组件或者文件等,清晰地知道代码变更影响的范围。

在本项目中,我们使用了 react-org-tree 这个 React 组件库,进行了二次封装来展示整个依赖树。通过这一封装,变更的影响不仅能够在依赖树中得到标记,还能够通过交互式的界面展示给用户。

由于影响可视化部分主要是前端展示,这里不再详细描述实现细节。我们仅看最后的效果,如图:

四、结语

本篇文章的目的是向大家展示如何搭建一个代码影响范围分析工具以及如何处理变更影响分析,并展示了如何标记受影响的文件和节点。然而,文章中的代码仅展示了核心部分,对于 React/Vue 一些细节和特殊场景的处理,这里并未做深入探讨。实际上,构建一个完整的分析系统,需要面对许多复杂的情况,这些问题在不同的框架、开发环境或代码结构中会有所不同。

我的目标是通过这篇文章,让大家能够理解搭建代码依赖树的价值以及如何分析代码变更的影响。如果你也希望为自己的团队搭建类似的工具,以下是一些你需要关注的步骤和注意事项:

框架差异:React 和 Vue 的分析差异

React 和 Vue 在开发模式和特性上有所不同,这导致了代码依赖分析的差异。以下表格总结了两者的主要差异点:

方面 React Vue
依赖解析 JSX + import SFC(单文件组件) + import
组件间引用分析 Hooks、HOC(高阶组件)、render props mixins、directives(指令)
影响传播检测 组件树、context 依赖、useContext 组件层级、props 传递、provide / inject
难以分析的问题 动态 props 传递、闭包影响、事件委托 组合式 API 中的动态调用
函数变更分析 useEffect、useCallback、useMemo 和状态更新函数(如 setState) methods、setup 中的函数
函数传递与调用分析 函数作为参数传递,组件内部函数的变化 methods 中的函数、v-bind 和 v-on 传递的函数
状态管理变更 useState、useReducer 影响的变量,store 依赖的状态 Vuex / Pinia store 变更,computed 计算属性影响
模板解析 JSX 中的函数调用和变量使用需要 AST 解析 Vue 模板(template)部分的表达式需结合 Vue 解析

> 转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 > 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

相关推荐
musk12127 分钟前
electron 打包太大 试试 tauri , tauri 安装打包demo
前端·electron·tauri
翻滚吧键盘36 分钟前
js代码09
开发语言·javascript·ecmascript
万少1 小时前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL1 小时前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl021 小时前
java web5(黑马)
java·开发语言·前端
Amy.Wang1 小时前
前端如何实现电子签名
前端·javascript·html5
海天胜景2 小时前
vue3 el-table 行筛选 设置为单选
javascript·vue.js·elementui
今天又在摸鱼2 小时前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js
蓝婷儿2 小时前
每天一个前端小知识 Day 21 - 浏览器兼容性与 Polyfill 策略
前端
百锦再2 小时前
Vue中对象赋值问题:对象引用被保留,仅部分属性被覆盖
前端·javascript·vue.js·vue·web·reactive·ref