fix: 字符级对比无界计算卡死修复——maxEditLength 封顶编辑距离、降级阈值收紧至 3 万字符(0.5.5)
This commit is contained in:
@@ -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])
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function Toolbar({
|
||||
/>
|
||||
字符级对比
|
||||
{options.charMode && charModeDowngraded && (
|
||||
<span className="opt-badge warn" title="内容较大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
|
||||
<span className="opt-badge warn" title="内容或差异过大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
|
||||
已降级
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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_EDIT,diffChars
|
||||
* 携 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 {
|
||||
|
||||
@@ -116,12 +116,12 @@ describe('useDiff - 字符级大字符量重路径', () => {
|
||||
|
||||
it('恰好阈值不触发重路径,超过阈值触发', () => {
|
||||
const factory = makeFactory()
|
||||
// 两侧各 25000 字符 = 恰好 50000(CHAR_HEAVY_INPUT_CHARS):不触发
|
||||
const at = 'a'.repeat(25000)
|
||||
// 两侧各 3750 字符 = 恰好 7500(CHAR_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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user