fix: 字符级对比无界计算卡死修复——maxEditLength 封顶编辑距离、降级阈值收紧至 3 万字符(0.5.5)

This commit is contained in:
2026-08-18 13:46:45 +08:00
parent 1c34f386af
commit 2b43b5e676
9 changed files with 78 additions and 23 deletions
+13 -4
View File
@@ -34,7 +34,7 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 用户偏好(比较选项 + 视图开关)持久化键 */
const PREFS_KEY = 'difflens.prefs.v1'
/** 全部比较选项的默认值(严格对比) */
/** 全部比较选项的默认值(严格对比charDiffMaxEdit 为内部数值参数,不属于偏好 */
const DEFAULT_OPTIONS: DiffOptions = {
trimWhitespace: false,
ignoreCase: false,
@@ -43,6 +43,15 @@ const DEFAULT_OPTIONS: DiffOptions = {
charMode: false
}
/** 布尔型偏好键:持久化恢复只遍历这些字段(数值型内部参数不持久化);as const 收窄保证赋值类型为 boolean */
const PREF_BOOL_KEYS = [
'trimWhitespace',
'ignoreCase',
'ignoreBlankLines',
'ignoreAllWhitespace',
'charMode'
] as const satisfies (keyof DiffOptions)[]
/**
* 读取持久化偏好:只接受已知字段的严格 boolean 值;
* 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。
@@ -63,7 +72,7 @@ function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } {
}
const bool = (v: unknown): boolean => v === true
const options: DiffOptions = { ...DEFAULT_OPTIONS }
for (const k of Object.keys(DEFAULT_OPTIONS) as (keyof DiffOptions)[]) {
for (const k of PREF_BOOL_KEYS) {
options[k] = bool(obj.options?.[k])
}
return { options, onlyDiff: bool(obj.onlyDiff) }
@@ -294,10 +303,10 @@ export default function App(): ReactElement {
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
// 字符级对比因内容超限自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次)
// 字符级对比因内容超限或差异过大自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次)
useEffect(() => {
if (diff?.charModeDowngraded) {
showToast('内容大,字符级对比已自动降级为行级对比')
showToast('内容或差异过大,字符级对比已自动降级为行级对比')
}
}, [diff, showToast])
+1 -1
View File
@@ -141,7 +141,7 @@ export default function Toolbar({
/>
{options.charMode && charModeDowngraded && (
<span className="opt-badge warn" title="内容大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
<span className="opt-badge warn" title="内容或差异过大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
</span>
)}
+22
View File
@@ -445,6 +445,28 @@ describe('computeDiff - 字符级对比(超限降级)', () => {
expect(result.summary.changedLines).toBe(0)
})
it('编辑距离超限快速降级(总量未超但差异过大)', () => {
// 内部参数注入小阈值(10):生产默认 3000,触发截断需跑满上限轮数迭代,测试以小值毫秒级构造
const left = 'a'.repeat(2000)
const right = 'b'.repeat(2000)
const result = computeDiff(left, right, { charMode: true, charDiffMaxEdit: 10 })
expect(result.charModeDowngraded).toBe(true)
// 降级结果与等效行级选项直接计算一致
const direct = computeDiff(left, right, {
ignoreAllWhitespace: true,
ignoreBlankLines: true
})
expect(result.summary).toEqual(direct.summary)
})
it('编辑距离在限内正常完成字符级对比', () => {
const left = 'x'.repeat(4000)
const right = 'y'.repeat(100) + 'x'.repeat(3900)
const { summary, charModeDowngraded } = computeDiff(left, right, { charMode: true })
expect(charModeDowngraded).toBeUndefined()
expect(summary.changedLines).toBeGreaterThan(0)
})
it('降级结果与等效行级选项直接计算一致', () => {
const left = 'a'.repeat(100001) + '\nb'
const right = 'a'.repeat(100001) + '\nc'
+30 -8
View File
@@ -46,9 +46,16 @@ export interface DiffOptions {
/**
* 字符级对比:全部空白与换行结构都不参与判等,两侧全文归一化为字符流做 diff,
* 差异以字符粒度回映射到行(跨行重组也能判等)。隐含 ignoreAllWhitespace 的全部语义;
* 归一化流超限(CHAR_DIFF_MAX_CHARS时自动降级为行级对比并置 charModeDowngraded。
* 归一化流超限(CHAR_DIFF_MAX_CHARS或编辑距离超限(CHAR_DIFF_MAX_EDIT)时
* 自动降级为行级对比并置 charModeDowngraded。
*/
charMode?: boolean
/**
* 字符级对比的编辑距离上限覆盖值(内部参数,测试注入用):
* 生产不传,使用 CHAR_DIFF_MAX_EDIT 默认值。触发截断需跑满上限轮数的
* Myers 迭代,测试以小阈值毫秒级构造降级场景。
*/
charDiffMaxEdit?: number
}
export interface DiffResult {
@@ -112,7 +119,7 @@ export function computeDiff(
if (options.charMode) {
const charResult = computeCharDiff(leftText, rightText, options)
if (charResult) return charResult
// 归一化流超限:降级为行级对比,选项取字符级语义的最接近子集
// 内容超限或差异过大:降级为行级对比,选项取字符级语义的最接近子集
// (全部空白忽略 + 空行忽略;换行结构差异是行级架构无法消除的边界)
const result = computeLineDiff(leftText, rightText, {
...options,
@@ -287,14 +294,23 @@ function computeLineDiff(
/**
* 字符级对比的字符总量上限(两侧归一化流合计):
* diffChars 为 O(ND)超限后计算可能耗时失控,自动降级为行级对比。
* diffChars 为 O(ND)字符量与编辑距离任一超限都可能导致耗时失控,
* 超限自动降级为行级对比。3 万字符按实测将最坏耗时控制在约 2 秒内。
*/
export const CHAR_DIFF_MAX_CHARS = 200000
export const CHAR_DIFF_MAX_CHARS = 30000
/**
* 字符级对比的编辑距离上限:diffChars 以 maxEditLength 迭代轮数封顶,
* 编辑距离超限立即放弃(返回 undefined)并降级为行级对比——
* 差异过大时字符级高亮已失去可读性,行级语义足够。
* 实测 3000 轮 × 3 万字符 ≈ 1.8 秒(worker 内计算,界面不冻结)。
*/
export const CHAR_DIFF_MAX_EDIT = 3000
/**
* 字符级对比的重输入阈值(两侧原始字符总量):超过即走 worker 后台计算。
* 按 CHAR_DIFF_MAX_CHARS / 4 取值——降级上限之内仍可能出现编辑距离极大、
* O(ND) 耗时失控的输入,且此类输入往往行数很少(压缩 JSON / base64 单行),
* 按 CHAR_DIFF_MAX_CHARS / 4 取值(留 4 倍余量给空白占比)——降级上限之内仍可能出现
* 编辑距离极大、O(ND) 耗时失控的输入,且此类输入往往行数很少(压缩 JSON / base64 单行),
* 行数维度的重输入判定防不住,需以字符量兜底。
*/
export const CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS / 4
@@ -375,7 +391,8 @@ function charSegs(text: string, stat: Map<number, number> | undefined): Seg[] {
* - 变更组之间的行(含纯空白行)为“锚行”,左右按序 zip 配对输出 unchanged,短侧空槽仍标 unchanged
* (字符级语义下纯空白行不构成差异)。
*
* 内容超限(CHAR_DIFF_MAX_CHARS返回 null,由调用方降级行级。
* 内容超限(CHAR_DIFF_MAX_CHARS或编辑距离超限(CHAR_DIFF_MAX_EDITdiffChars
* 携 maxEditLength 超限返回 undefined)均返回 null,由调用方降级行级。
*/
function computeCharDiff(
leftText: string,
@@ -395,7 +412,12 @@ function computeCharDiff(
const right = buildCharStream(rightOrig, normCh)
if (left.text.length + right.text.length > CHAR_DIFF_MAX_CHARS) return null
const parts = diffChars(left.text, right.text)
// maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死;
// jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底)
const parts = diffChars(left.text, right.text, {
maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT
})
if (!parts) return null
// 流区间块序列:common 为 eq 块,连续 removed/added 合并为 ne 块(与行级 parts 循环同构)
interface Block {
+4 -4
View File
@@ -116,12 +116,12 @@ describe('useDiff - 字符级大字符量重路径', () => {
it('恰好阈值不触发重路径,超过阈值触发', () => {
const factory = makeFactory()
// 两侧各 25000 字符 = 恰好 50000CHAR_HEAVY_INPUT_CHARS):不触发
const at = 'a'.repeat(25000)
// 两侧各 3750 字符 = 恰好 7500CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS/4):不触发
const at = 'a'.repeat(3750)
const hook1 = renderHook(() => useDiff(at, at, CHAR_ON, factory))
expect(hook1.result.current.computing).toBe(false)
// 25001 + 25000 = 50001:触发
const over = 'a'.repeat(25001)
// 3751 + 3750 = 7501:触发
const over = 'a'.repeat(3751)
const hook2 = renderHook(() => useDiff(over, at, CHAR_ON, factory))
expect(hook2.result.current.computing).toBe(true)
})