小改动也会引发线上事故
有时候,一个小改动就能引发连锁反应,问题不在于改动本身的复杂度,而在于我们不知道这个改动可能影响到谁。
举个真实例子:我们有一个公共组件被多个页面引用,某次改了内部逻辑,没有引起警觉,结果多个页面上线后都出现了白屏。问题回头看其实很好修——但上线前没人意识到要测这些页面。
从这次问题中我们反思到:
哪些页面用了这个组件?中间经过了哪些中间层?页面是不是通过三层封装才引到它?有没有什么静态手段可以自动识别到这些路径?
说到底,这不是“组件写得复杂”的问题,而是我们对代码的波及路径没有工具支撑,缺少自动化分析手段。
所以我们着手做了一套工具,目标是清晰回答一个问题:
某个文件改了,它可能影响到哪些路由页面?这次改动到底要不要测某个页面?
这个思路最初的触发点,其实来自转转技术团队在一篇文章中提到的一个观点:「前端缺少变更影响范围自动化分析工具,导致测试策略全靠经验判断」。他们的尝试给了我很大启发
想解决的问题
手动去查找影响范围有几个问题:
- 组件嵌套、引用情况容易漏掉
- 改动可能会穿透多个中间层,开发者很难一眼看出
- 改动之后开发者可能会漏测某个页面
因此我们想实现的是这样的能力:通过git diff 计算得到可能会影响到的路由页面,再结合AI输出测试建议。拆解一下就是:
Git diff → 改动文件 → 引用链路 → 路由页面 → 测试建议
核心流程
获取改动文件
直接执行命令获取就行
const changedFiles = execSync('git diff origin/master --name-only')
.split('\n')
.filter(f => f.startsWith('src/'))
构建依赖图
我们需要知道,给定一个文件,哪些文件引用了他,并且得到他的引用链,一直到路由定义,这样就可以知道影响的url,为此,我们需要用babel parser去收集import语句,然后构建依赖关系,从而得到依赖链
使用 Babel Parser + Traverse 收集 import/require/import() 语句
核心代码如下:
traverse(ast, {
ImportDeclaration({ node }) {
result.push(node.source.value)
},
CallExpression({ node }) {
if (node.callee.name === 'require') {
result.push(node.arguments[0].value)
}
},
Import({ parent }) {
result.push(parent.arguments[0].value)
}
})
使用 enhanced-resolve 模拟 Webpack resolve 规则,还原真实绝对路径
const resolver = ResolverFactory.createResolver({
alias: {
COMPONENTS: path.resolve(__dirname, 'src/components'),
...
},
extensions: ['.ts', '.tsx', '.js'],
})
然后我们对整个 src 做全量扫描,构建出:
- forwardMap:
A引用了哪些模块 - reverseMap:
B被哪些模块引用了
反向查找路由页面
当我们拿到改动文件后 :
- 在
reverseMap上,从改动文件出发,DFS 搜索调用链; - 一旦链路中包含
src/routes/index.tsx,就说明已经传导到页面路由层; - 如果能定位到那个路由组件的文件路径,就可以知道对应的 URL。
核心函数简化如下:
function queryReverseChains(filePath) {
const stack = [[filePath]]
const chains = []
while (stack.length > 0) {
const chain = stack.pop()
const cur = chain[chain.length - 1]
const parents = reverseMap.get(cur) || []
if (parents.length === 0) {
chains.push(chain)
} else {
for (const p of parents) {
if (!chain.includes(p)) stack.push([...chain, p])
}
}
}
return chains
}
AI prompt 构建
需要把尽可能多的信息喂给AI,包含:
- 角色设置
- prd、技术文档、接口文档
- code diff
- 引用链+影响的路由
prompt如下:
你是一个前端稳定性测试分析助手,目标是根据提供的结构化代码变更数据,输出一份详细的风险评估和测试建议报告。
本次输入信息包含:
1. 改动文件路径和 Git diff 内容;
2. 改动文件的被引用路径调用链(从文件出发,向上传递到路由层);
3. 匹配到的页面路由路径(即最终影响的 URL);
4. 改动文件中的函数变动信息,包括函数名称、变动类型(add / delete / modified / rename)、是否为导出函数、是否签名变化;
5. 项目信息,如:路由框架类型、主路由配置文件路径、目标环境。
请你根据这些信息,输出一份结构化分析报告,内容包括:
---
### 一、影响概述
- 改动属于组件级 / 页面级 / 跨页面级;
- 传播路径调用链有几层,是否命中了核心页面入口;
- 是否为公共组件或导出函数改动,是否存在调用方未同步修改的风险。
---
### 二、影响页面列表(由路由路径决定)
- 列出所有受到影响的页面路径(如 /activity/detail);
- 可按改动链路深度或页面权重排序(可选)。
---
### 三、推荐测试点
对每个页面列出手工测试建议,包括但不限于:
- 页面是否正常加载,无白屏、无异常;
- 表单是否正常渲染、交互是否正常;
- 若表单组件有提交逻辑,是否触发了受影响的函数;
- 重要按钮、交互项是否未受变动影响;
- 是否建议补充回归自动化测试。
---
### 四、函数级风险分析(来自函数变动信息)
- 有无导出函数签名变更或删除,是否存在调用方兼容性风险;
- 有无逻辑大改动、错误处理缺失、magic number、嵌套过深等编码规范问题;
- 是否需要额外编写或补充单测、Mock 数据。
---
### 五、最终建议
- 是否建议执行完整页面回归测试;
- 是否建议补充自动化 E2E 脚本;
- 是否建议通知调用方联动修改;
- 是否建议对核心函数加入参数校验、try/catch 等防御式措施。
---
请使用中文自然语言进行输出,输出格式清晰,必要时使用 Markdown 分点、表格等方式。不要输出代码,仅输出结论和建议。
以下是结构化 JSON 输入(请按此格式解析):
完整代码
业务场景脱敏后代码如下,其中路由部分可能要根据自己的项目修改一下:
/* eslint-disable no-console */
const { execSync } = require('child_process')
const path = require('path')
const fs = require('fs')
const { ResolverFactory, CachedInputFileSystem } = require('enhanced-resolve')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const systemSettings = `...`
const basePath = '/project'
const routes = [
{
path: `${basePath}/example`,
name: '示例页面',
compPath: 'src/view/example/index.tsx',
}
]
const routesMap = new Map()
routes.forEach((route) => {
routesMap.set(route.compPath, route)
})
function getDiffChangedFiles() {
const changedFiles = execSync('git diff origin/master --name-only', { encoding: 'utf-8' })
.split('\n')
.filter((f) => f.trim() !== '')
.filter((f) => f.startsWith('src/'))
return changedFiles
}
function getFileDiff(filePath) {
try {
return execSync(`git diff --minimal origin/master -- ${filePath}`, { encoding: 'utf-8' })
} catch {
return ''
}
}
const changedFiles = getDiffChangedFiles()
const resolver = ResolverFactory.createResolver({
fileSystem: new CachedInputFileSystem(fs, 4000),
extensions: ['.ts', '.tsx', '.js', '.jsx', '.less', '.scss', '.css'],
alias: {
COMPONENTS: path.resolve(__dirname, 'src/components'),
HOOKS: path.resolve(__dirname, 'src/hooks'),
VIEW: path.resolve(__dirname, 'src/view'),
MODULES: path.resolve(__dirname, 'src/modules'),
CONSTANTS: path.resolve(__dirname, 'src/constants'),
SERVICES: path.resolve(__dirname, 'src/services'),
},
modules: [path.resolve(__dirname, 'src')],
mainFiles: ['index'],
})
function tryResolve(importPath, fromFile) {
return new Promise((resolve) => {
resolver.resolve({}, path.dirname(fromFile), importPath, {}, (err, result) => {
resolve(err ? null : result)
})
})
}
const babelPlugins = [
'jsx', 'typescript', 'classProperties', 'decorators-legacy',
'optionalChaining', 'nullishCoalescingOperator', 'dynamicImport'
]
function normalizePath(filePath) {
return path.relative(path.resolve(__dirname), filePath)
}
function parseImports(filePath) {
const result = []
try {
const code = fs.readFileSync(filePath, 'utf-8')
const ast = parser.parse(code, { sourceType: 'unambiguous', plugins: babelPlugins })
traverse(ast, {
ImportDeclaration({ node }) { result.push(node.source.value) },
CallExpression({ node }) {
if (node.callee.name === 'require' && node.arguments[0]?.value) {
result.push(node.arguments[0].value)
}
},
Import({ parent }) {
if (parent?.arguments?.[0]?.value) {
result.push(parent.arguments[0].value)
}
},
})
} catch {}
return result
}
const forwardMap = new Map()
const reverseMap = new Map()
async function processAllFiles(files) {
for (const filePath of files) {
const absPath = path.resolve(__dirname, filePath)
const imports = parseImports(absPath)
const resolvedList = []
for (const imp of imports) {
const resolved = await tryResolve(imp, absPath)
if (resolved) {
const norm = normalizePath(resolved)
resolvedList.push(norm)
if (!reverseMap.has(norm)) reverseMap.set(norm, [])
reverseMap.get(norm).push(normalizePath(absPath))
}
}
forwardMap.set(normalizePath(absPath), resolvedList)
}
}
function getRouteByFilePath(filePath) {
const normalizedPath = normalizePath(filePath)
return routesMap.get(normalizedPath) || null
}
function queryReverseChains(filePath, maxDepth = 10) {
const key = normalizePath(filePath)
if (!reverseMap.has(key)) return []
const seen = new Set()
const stack = [[key]]
const chains = []
while (stack.length > 0) {
const chain = stack.pop()
const cur = chain[chain.length - 1]
if (chain.length > maxDepth || seen.has(chain.join('>'))) continue
seen.add(chain.join('>'))
const parents = reverseMap.get(cur) || []
if (parents.length === 0) {
chains.push(chain)
} else {
for (const p of parents) {
if (!chain.includes(p)) stack.push([...chain, p])
}
}
}
return chains
}
function collectFiles(root, exts = ['.ts', '.tsx', '.js', '.jsx', '.less', '.scss', '.css']) {
const res = []
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) walk(fullPath)
else if (exts.includes(path.extname(fullPath))) res.push(fullPath)
}
}
walk(path.resolve(__dirname, root))
return res
}
function dedupeChains(chains) {
const seen = new Set()
const result = []
for (const chain of chains) {
const key = chain.join('>')
if (!seen.has(key)) {
seen.add(key)
result.push(chain)
}
}
return result
}
;(async () => {
console.time('🔥 全流程耗时')
const allSrcFiles = collectFiles('src')
console.time('依赖图构建耗时')
await processAllFiles(allSrcFiles)
console.timeEnd('依赖图构建耗时')
const fileDiffInfo = {}
for (const filePath of changedFiles) {
console.log(`处理文件: ${filePath}`)
const diff = getFileDiff(filePath)
const chains = queryReverseChains(filePath)
const uniqueChains = dedupeChains(chains.map((chain) => chain.map(normalizePath)))
const affectedRoutes = new Set()
const newChains = dedupeChains(
uniqueChains.map((chain) => {
if (chain.includes('src/routes/index.tsx')) {
const index = chain.indexOf('src/routes/index.tsx')
affectedRoutes.add(getRouteByFilePath(chain[index - 1])?.path || '未知路径')
return chain.slice(0, index - 1)
} else {
return chain
}
})
)
fileDiffInfo[filePath] = {
diff,
chainMaxDepth: Math.max(...newChains.map((chain) => chain.length)),
affectedRoutes: Array.from(affectedRoutes),
}
}
const outFilePath = path.resolve(__dirname, 'AICR.json')
fs.writeFileSync(outFilePath, JSON.stringify({
systemSettings,
project: {},
fileDiffInfo
}, null, 2), 'utf-8')
console.log(`分析结果已写入: ${outFilePath}`)
console.timeEnd('全流程耗时')
})()
招人
京东零售交易核心部门招人了,满足下列条件的欢迎发简历到 baichen3@jd.com ,直推大老板,流程很快:
- 3-5年工作经验
- 至少本科全日制,985 211 更优
- 5年内工作经历不超过2家
- 熟悉Web应用的性能优化,了解微前端、lowcode项目优先