Skip to content

Commit ffe2611

Browse files
Alexzjtgemini-code-assist[bot]wang1212
authored
perf: use charWidthCache to improve performance by 1.5% (#1978)
* perf: 避免重复dirty父级元素提升2%性能 * perf: accept gemini review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * refactor: 修复eslint * perf: use charWidthCache to improve performance by 1.5% * refactor: 修复评审意见 * refactor: 修复评审意见 * refactor: 使用LRU * refactor: 使用LRU * chore: add changeset --------- Co-authored-by: huiyu.zjt <huiyu.zjt@antgroup.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: wang1212 <mrwang1212@126.com>
1 parent 4fb9eaa commit ffe2611

5 files changed

Lines changed: 155 additions & 32 deletions

File tree

.changeset/fast-ads-flash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@antv/g-lite': patch
3+
---
4+
5+
perf: use charWidthCache to improve performance by 1.5%

packages/g-lite/src/Canvas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ export class Canvas extends EventTarget implements ICanvas {
417417
clearEventRetain(insertedEventCache);
418418
clearEventRetain(removedEventCache);
419419
clearEventRetain(destroyEventCache);
420+
runtime.textService.clearCache();
420421
}
421422

422423
/**

packages/g-lite/src/services/TextService.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CanvasLike, GlobalRuntime } from '..';
22
import type { ParsedTextStyleProps } from '../display-objects';
33
import { Rectangle } from '../shapes';
44
import { toFontString } from '../utils';
5+
import LRU from '../utils/LRU';
56

67
export interface TextMetrics {
78
font: string;
@@ -20,7 +21,6 @@ interface IFontMetrics {
2021
descent: number;
2122
fontSize: number;
2223
}
23-
type CharacterWidthCache = Record<string, number>;
2424

2525
const TEXT_METRICS = {
2626
MetricsString: '|ÉqÅ',
@@ -78,13 +78,21 @@ const regexCannotEnd = new RegExp(
7878
* Borrow from pixi/packages/text/src/TextMetrics.ts
7979
*/
8080
export class TextService {
81-
constructor(private runtime: GlobalRuntime) {}
81+
constructor(private runtime: GlobalRuntime) {
82+
this.charWidthCache = new LRU<LRU<number>>(100);
83+
}
8284

8385
/**
8486
* font metrics cache
8587
*/
8688
private fontMetricsCache: Record<string, IFontMetrics> = {};
8789

90+
/**
91+
* A global cache for character widths, keyed by font string.
92+
* e.g. { '16px Arial': { 'a': 8, 'b': 9 } }
93+
*/
94+
private charWidthCache: LRU<LRU<number>>;
95+
8896
/**
8997
* Calculates the ascent, descent and fontSize of a given font-style.
9098
*/
@@ -367,12 +375,21 @@ export class TextService {
367375
// @see https://github.com/antvis/G/issues/1932
368376
let prevLineLastCharIndex = -1;
369377

370-
const cache: { [key in string]: number } = {};
371-
const calcWidth = (txt: string): number => {
378+
// --- 优化核心 ---
379+
// 1. 获取或创建当前字体对应的字符缓存
380+
const font = toFontString(parsedStyle);
381+
let charCache = this.charWidthCache.get(font);
382+
if (!charCache) {
383+
charCache = new LRU<number>(500);
384+
this.charWidthCache.put(font, charCache);
385+
}
386+
387+
// 2. calcWidth 现在直接使用持久化的 charCache
388+
const calcWidth = (char: string): number => {
372389
return this.getFromCache(
373-
txt,
390+
char,
374391
letterSpacing,
375-
cache,
392+
charCache,
376393
context as CanvasRenderingContext2D,
377394
);
378395
};
@@ -615,17 +632,22 @@ export class TextService {
615632
private getFromCache(
616633
key: string,
617634
letterSpacing: number,
618-
cache: CharacterWidthCache,
635+
cache: LRU<number>,
619636
context: CanvasRenderingContext2D,
620637
): number {
621-
let width = cache[key];
638+
let width = cache.get(key);
622639
if (typeof width !== 'number') {
623640
const spacing = key.length * letterSpacing;
624641
const metrics = context.measureText(key);
625642

626643
width = metrics.width + spacing;
627-
cache[key] = width;
644+
cache.put(key, width);
628645
}
629646
return width;
630647
}
648+
649+
public clearCache() {
650+
this.fontMetricsCache = {};
651+
this.charWidthCache.clear();
652+
}
631653
}

packages/g-lite/src/utils/LRU.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* A classic LRU (Least Recently Used) cache implementation.
3+
* It evicts the least recently used item when the cache is full.
4+
*
5+
* It uses a Map for O(1) key-based access
6+
* to maintain the usage order of items.
7+
*
8+
* 通过利用 JavaScript 内置 Map 的特性(按插入顺序迭代),我们不再需要手动维护一个双向链表
9+
*/
10+
export default class LRU<T> {
11+
private readonly capacity: number;
12+
private cache: Map<string | number, T>;
13+
14+
constructor(capacity: number) {
15+
if (capacity <= 0) {
16+
throw new Error('LRU capacity must be a positive number.');
17+
}
18+
this.capacity = capacity;
19+
// Using a Map directly simplifies the implementation significantly.
20+
// A Map in modern JS engines iterates in insertion order, which is exactly what we need.
21+
this.cache = new Map<string | number, T>();
22+
}
23+
24+
/**
25+
* Retrieves an item from the cache. Marks the item as recently used.
26+
* @param key The key of the item to retrieve.
27+
* @returns The value of the item, or undefined if not found.
28+
*/
29+
public get(key: string | number): T | undefined {
30+
if (!this.cache.has(key)) {
31+
return undefined;
32+
}
33+
34+
// Get the value.
35+
const value = this.cache.get(key);
36+
37+
// Mark as recently used by deleting and re-setting the key.
38+
// This moves the key to the end of the Map's internal order.
39+
this.cache.delete(key);
40+
this.cache.set(key, value);
41+
42+
return value;
43+
}
44+
45+
/**
46+
* Adds or updates an item in the cache. Marks the item as recently used.
47+
* If the cache is full, it removes the least recently used item.
48+
* @param key The key of the item.
49+
* @param value The value of the item.
50+
*/
51+
public put(key: string | number, value: T): void {
52+
// If the key already exists, delete it first to ensure it's moved to the end.
53+
if (this.cache.has(key)) {
54+
this.cache.delete(key);
55+
}
56+
57+
// Add the new item. It will be the most recently used.
58+
this.cache.set(key, value);
59+
60+
// Check if the cache has exceeded its capacity.
61+
if (this.cache.size > this.capacity) {
62+
// Evict the least recently used item, which is the first one in the Map's iteration order.
63+
const leastRecentlyUsedKey = this.cache.keys().next().value;
64+
this.cache.delete(leastRecentlyUsedKey);
65+
}
66+
}
67+
68+
/**
69+
* Returns the current number of items in the cache.
70+
*/
71+
public len(): number {
72+
return this.cache.size;
73+
}
74+
75+
/**
76+
* Clears all items from the cache.
77+
*/
78+
public clear(): void {
79+
this.cache.clear();
80+
}
81+
}

packages/g-lite/src/utils/text.ts

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isNumber } from '@antv/util';
22
import type { ParsedTextStyleProps } from '../display-objects/Text';
3+
import { memoize } from './memoize';
34

45
const genericFontFamilies = [
56
'serif',
@@ -11,37 +12,50 @@ const genericFontFamilies = [
1112
];
1213
const stringRegExp = /([\"\'])[^\'\"]+\1/;
1314

14-
export function toFontString(attributes: Partial<ParsedTextStyleProps>) {
15+
function getFontAttr(attributes: Partial<ParsedTextStyleProps>) {
1516
const {
1617
fontSize = 16,
1718
fontFamily = 'sans-serif',
1819
fontStyle = 'normal',
1920
fontVariant = 'normal',
2021
fontWeight = 'normal',
2122
} = attributes;
23+
return { fontSize, fontFamily, fontStyle, fontVariant, fontWeight };
24+
}
25+
26+
export const toFontString = memoize(
27+
function toFontStringRaw(attributes: Partial<ParsedTextStyleProps>) {
28+
const { fontSize, fontFamily, fontStyle, fontVariant, fontWeight } =
29+
getFontAttr(attributes);
2230

23-
// build canvas api font setting from individual components. Convert a numeric this.fontSize to px
24-
// const fontSizeString: string = isNumber(fontSize) ? `${fontSize}px` : fontSize.toString();
25-
const fontSizeString: string =
26-
(isNumber(fontSize) && `${fontSize}px`) || '16px';
27-
// Clean-up fontFamily property by quoting each font name
28-
// this will support font names with spaces
31+
// build canvas api font setting from individual components. Convert a numeric this.fontSize to px
32+
// const fontSizeString: string = isNumber(fontSize) ? `${fontSize}px` : fontSize.toString();
33+
const fontSizeString: string =
34+
(isNumber(fontSize) && `${fontSize}px`) || '16px';
35+
// Clean-up fontFamily property by quoting each font name
36+
// this will support font names with spaces
2937

30-
const fontFamilies: string[] = fontFamily.split(',');
38+
const fontFamilies: string[] = fontFamily.split(',');
3139

32-
for (let i = fontFamilies.length - 1; i >= 0; i--) {
33-
// Trim any extra white-space
34-
let fontFamily = fontFamilies[i].trim();
35-
// Check if font already contains strings
36-
if (
37-
!stringRegExp.test(fontFamily) &&
38-
genericFontFamilies.indexOf(fontFamily) < 0
39-
) {
40-
fontFamily = `"${fontFamily}"`;
40+
for (let i = fontFamilies.length - 1; i >= 0; i--) {
41+
// Trim any extra white-space
42+
let fontFamily = fontFamilies[i].trim();
43+
// Check if font already contains strings
44+
if (
45+
!stringRegExp.test(fontFamily) &&
46+
genericFontFamilies.indexOf(fontFamily) < 0
47+
) {
48+
fontFamily = `"${fontFamily}"`;
49+
}
50+
fontFamilies[i] = fontFamily;
4151
}
42-
fontFamilies[i] = fontFamily;
43-
}
44-
return `${fontStyle} ${fontVariant} ${fontWeight} ${fontSizeString} ${fontFamilies.join(
45-
',',
46-
)}`;
47-
}
52+
return `${fontStyle} ${fontVariant} ${fontWeight} ${fontSizeString} ${fontFamilies.join(
53+
',',
54+
)}`;
55+
},
56+
(attributes: Partial<ParsedTextStyleProps>) => {
57+
const { fontSize, fontFamily, fontStyle, fontVariant, fontWeight } =
58+
getFontAttr(attributes);
59+
return `${fontStyle}_${fontVariant}_${fontWeight}_${fontSize}_${fontFamily}`;
60+
},
61+
);

0 commit comments

Comments
 (0)