@hsu-react/ui 2.5.5 → 2.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/components/Chart/Bar/index.js +6 -1
- package/es/components/Chart/Bubble/index.js +6 -1
- package/es/components/Chart/Common/index.js +6 -1
- package/es/components/Chart/Gauge/index.js +6 -1
- package/es/components/Chart/Heatmap/index.js +9 -2
- package/es/components/Chart/Line/index.js +6 -1
- package/es/components/Chart/Pie/Pie3D/index.js +15 -7
- package/es/components/Chart/Pie/index.js +9 -2
- package/es/components/Chart/Polar/index.js +9 -2
- package/es/components/Chart/Radar/index.js +6 -1
- package/es/hooks/useShallowStable.d.ts +32 -0
- package/es/hooks/useShallowStable.js +54 -0
- package/lib/components/Chart/Bar/index.js +6 -1
- package/lib/components/Chart/Bubble/index.js +6 -1
- package/lib/components/Chart/Common/index.js +6 -1
- package/lib/components/Chart/Gauge/index.js +6 -1
- package/lib/components/Chart/Heatmap/index.js +9 -2
- package/lib/components/Chart/Line/index.js +6 -1
- package/lib/components/Chart/Pie/Pie3D/index.js +15 -7
- package/lib/components/Chart/Pie/index.js +9 -2
- package/lib/components/Chart/Polar/index.js +9 -2
- package/lib/components/Chart/Radar/index.js +6 -1
- package/lib/hooks/useShallowStable.d.ts +32 -0
- package/lib/hooks/useShallowStable.js +59 -0
- package/package.json +1 -1
- package/src/__tests__/callbackDepsGuard.test.ts +4 -51
- package/src/__tests__/depsScan.ts +158 -0
- package/src/__tests__/freshObjectDepsGuard.test.ts +144 -0
- package/src/components/Chart/Bar/index.tsx +6 -1
- package/src/components/Chart/Bubble/index.tsx +6 -1
- package/src/components/Chart/Common/index.tsx +6 -1
- package/src/components/Chart/Gauge/index.tsx +6 -1
- package/src/components/Chart/Heatmap/index.tsx +20 -12
- package/src/components/Chart/Line/index.tsx +6 -1
- package/src/components/Chart/Pie/Pie3D/index.tsx +12 -3
- package/src/components/Chart/Pie/index.tsx +10 -2
- package/src/components/Chart/Polar/index.tsx +10 -2
- package/src/components/Chart/Radar/index.tsx +6 -1
- package/src/components/Chart/optionMemo.test.tsx +232 -0
- package/src/hooks/useShallowStable.ts +65 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把一个**内容没变、但每次渲染都新建**的对象收敛成**引用恒定**的对象:
|
|
3
|
+
* 浅比较(自有键 + `Object.is` 比值)相等就继续返回上一次那个引用。
|
|
4
|
+
*
|
|
5
|
+
* 用途只有一个:让它可以直接进 `useMemo` / `useEffect` 的依赖数组。
|
|
6
|
+
*
|
|
7
|
+
* React 的依赖数组按 `Object.is` 比引用,所以依赖里只要混进一个「每次渲染都新建
|
|
8
|
+
* 的对象」,memo 就**恒不命中**、effect 就**每渲染必重跑**。组件内部最容易出现
|
|
9
|
+
* 这种对象的地方是 rest 解构:
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const { className, style, ...coreOption } = props; // coreOption 每次都是新对象
|
|
13
|
+
* const option = useMemo(() => ({ ...coreOption }), [coreOption]); // 永远不命中
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* 消费方就算把每一个 prop 都 memo 好,也躲不掉——新对象是组件自己建的。
|
|
17
|
+
* 正确写法是让这个对象回到值语义:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const { className, style, ...restOption } = props;
|
|
21
|
+
* const coreOption = useShallowStable(restOption); // 内容不变 → 引用不变
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* 注意这**不是**「缓存结果跳过副作用」:memo 该重算时照样重算、effect 该重跑时
|
|
25
|
+
* 照样重跑——只要 rest 里任何一个值真的变了,浅比较就不相等,新引用立刻透出去。
|
|
26
|
+
*
|
|
27
|
+
* 渲染期间读写 ref 的安全性:并发渲染下被丢弃的那次渲染可能已经把 ref 换成了它
|
|
28
|
+
* 自己的对象,但那个对象与当次 props 浅相等,所以后续渲染拿到的引用**内容永远
|
|
29
|
+
* 与当前 props 一致**,不会读到过期值。
|
|
30
|
+
*/
|
|
31
|
+
export declare function useShallowStable<T extends object>(value: T): T;
|
|
32
|
+
export default useShallowStable;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.default = void 0;
|
|
7
|
+
exports.useShallowStable = useShallowStable;
|
|
8
|
+
var _react = require("react");
|
|
9
|
+
/**
|
|
10
|
+
* 把一个**内容没变、但每次渲染都新建**的对象收敛成**引用恒定**的对象:
|
|
11
|
+
* 浅比较(自有键 + `Object.is` 比值)相等就继续返回上一次那个引用。
|
|
12
|
+
*
|
|
13
|
+
* 用途只有一个:让它可以直接进 `useMemo` / `useEffect` 的依赖数组。
|
|
14
|
+
*
|
|
15
|
+
* React 的依赖数组按 `Object.is` 比引用,所以依赖里只要混进一个「每次渲染都新建
|
|
16
|
+
* 的对象」,memo 就**恒不命中**、effect 就**每渲染必重跑**。组件内部最容易出现
|
|
17
|
+
* 这种对象的地方是 rest 解构:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const { className, style, ...coreOption } = props; // coreOption 每次都是新对象
|
|
21
|
+
* const option = useMemo(() => ({ ...coreOption }), [coreOption]); // 永远不命中
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* 消费方就算把每一个 prop 都 memo 好,也躲不掉——新对象是组件自己建的。
|
|
25
|
+
* 正确写法是让这个对象回到值语义:
|
|
26
|
+
*
|
|
27
|
+
* ```tsx
|
|
28
|
+
* const { className, style, ...restOption } = props;
|
|
29
|
+
* const coreOption = useShallowStable(restOption); // 内容不变 → 引用不变
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* 注意这**不是**「缓存结果跳过副作用」:memo 该重算时照样重算、effect 该重跑时
|
|
33
|
+
* 照样重跑——只要 rest 里任何一个值真的变了,浅比较就不相等,新引用立刻透出去。
|
|
34
|
+
*
|
|
35
|
+
* 渲染期间读写 ref 的安全性:并发渲染下被丢弃的那次渲染可能已经把 ref 换成了它
|
|
36
|
+
* 自己的对象,但那个对象与当次 props 浅相等,所以后续渲染拿到的引用**内容永远
|
|
37
|
+
* 与当前 props 一致**,不会读到过期值。
|
|
38
|
+
*/
|
|
39
|
+
function useShallowStable(value) {
|
|
40
|
+
const ref = (0, _react.useRef)(value);
|
|
41
|
+
if (!shallowEqual(ref.current, value)) {
|
|
42
|
+
ref.current = value;
|
|
43
|
+
}
|
|
44
|
+
return ref.current;
|
|
45
|
+
}
|
|
46
|
+
function shallowEqual(a, b) {
|
|
47
|
+
if (Object.is(a, b)) return true;
|
|
48
|
+
const aKeys = Object.keys(a);
|
|
49
|
+
const bKeys = Object.keys(b);
|
|
50
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
51
|
+
for (const key of aKeys) {
|
|
52
|
+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
|
53
|
+
if (!Object.is(a[key], b[key])) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
var _default = exports.default = useShallowStable;
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { hookDeps, walkSources } from "./depsScan";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* 防回归守卫:**回调 prop 不许进 effect 依赖数组**。
|
|
@@ -37,62 +38,14 @@ const ALLOWED: Record<string, string> = {
|
|
|
37
38
|
"components/Tree/index.tsx::onSelectPath": "同上,纯 ref 同步 effect",
|
|
38
39
|
};
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const full = path.join(dir, name);
|
|
43
|
-
if (fs.statSync(full).isDirectory()) walk(full, out);
|
|
44
|
-
else if (/\.(tsx?|jsx?)$/.test(name) && !/\.test\./.test(name))
|
|
45
|
-
out.push(full);
|
|
46
|
-
}
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** 找出每个 useEffect / useLayoutEffect 的依赖数组,返回 [行号, 依赖名] */
|
|
51
|
-
function effectDeps(src: string): Array<[number, string]> {
|
|
52
|
-
const found: Array<[number, string]> = [];
|
|
53
|
-
const re = /use(?:Layout)?Effect\(/g;
|
|
54
|
-
let m: RegExpExecArray | null;
|
|
55
|
-
|
|
56
|
-
while ((m = re.exec(src))) {
|
|
57
|
-
let i = m.index + m[0].length;
|
|
58
|
-
let depth = 1;
|
|
59
|
-
let quote: string | null = null;
|
|
60
|
-
let prev = "";
|
|
61
|
-
|
|
62
|
-
while (i < src.length && depth > 0) {
|
|
63
|
-
const c = src[i];
|
|
64
|
-
if (quote) {
|
|
65
|
-
if (c === quote && prev !== "\\") quote = null;
|
|
66
|
-
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
67
|
-
else if (c === "(" || c === "[" || c === "{") depth++;
|
|
68
|
-
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
69
|
-
prev = c;
|
|
70
|
-
i++;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const body = src.slice(m.index, i).replace(/\)\s*$/, "");
|
|
74
|
-
const deps = body.match(/,\s*\[([\s\S]*)\]\s*$/);
|
|
75
|
-
if (!deps) continue;
|
|
76
|
-
|
|
77
|
-
const line = src.slice(0, m.index).split("\n").length;
|
|
78
|
-
for (const raw of deps[1].split(",")) {
|
|
79
|
-
const name = raw
|
|
80
|
-
.replace(/\/\/.*$/gm, "")
|
|
81
|
-
.trim()
|
|
82
|
-
.split(/[.?[]/)[0]
|
|
83
|
-
.trim();
|
|
84
|
-
if (name) found.push([line, name]);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return found;
|
|
89
|
-
}
|
|
41
|
+
const EFFECT_RE = /use(?:Layout)?Effect\(/g;
|
|
42
|
+
const effectDeps = (src: string) => hookDeps(src, EFFECT_RE);
|
|
90
43
|
|
|
91
44
|
describe("回调 prop 不进 effect 依赖数组", () => {
|
|
92
45
|
it("全库无未登记的命中", () => {
|
|
93
46
|
const offenders: string[] = [];
|
|
94
47
|
|
|
95
|
-
for (const file of
|
|
48
|
+
for (const file of walkSources(SRC)) {
|
|
96
49
|
const rel = path.relative(SRC, file).split(path.sep).join("/");
|
|
97
50
|
const src = fs.readFileSync(file, "utf8");
|
|
98
51
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 两条防回归守卫(`callbackDepsGuard` / `freshObjectDepsGuard`)共用的源码扫描工具。
|
|
6
|
+
* 只做静态文本扫描,故意不引入 AST 依赖——守卫本身要足够轻,才不会被人嫌慢删掉。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 去掉注释再扫描:文档注释里的示例代码不是真实代码,不能算命中 */
|
|
10
|
+
export function stripComments(src: string) {
|
|
11
|
+
let out = "";
|
|
12
|
+
let i = 0;
|
|
13
|
+
let quote: string | null = null;
|
|
14
|
+
while (i < src.length) {
|
|
15
|
+
const c = src[i];
|
|
16
|
+
const next = src[i + 1];
|
|
17
|
+
if (quote) {
|
|
18
|
+
out += c;
|
|
19
|
+
if (c === "\\") {
|
|
20
|
+
out += next ?? "";
|
|
21
|
+
i += 2;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (c === quote) quote = null;
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
29
|
+
quote = c;
|
|
30
|
+
out += c;
|
|
31
|
+
i++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (c === "/" && next === "/") {
|
|
35
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === "/" && next === "*") {
|
|
39
|
+
i += 2;
|
|
40
|
+
// 把块注释里的换行原样留下,报出来的行号才对得上
|
|
41
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
|
|
42
|
+
if (src[i] === "\n") out += "\n";
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
i += 2;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
out += c;
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 遍历 `src/` 下的源码文件;守卫自己的目录不参与扫描 */
|
|
55
|
+
export function walkSources(dir: string, out: string[] = []) {
|
|
56
|
+
for (const name of fs.readdirSync(dir)) {
|
|
57
|
+
if (name === "__tests__") continue;
|
|
58
|
+
const full = path.join(dir, name);
|
|
59
|
+
if (fs.statSync(full).isDirectory()) walkSources(full, out);
|
|
60
|
+
else if (/\.(tsx?|jsx?)$/.test(name) && !/\.test\./.test(name))
|
|
61
|
+
out.push(full);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 找出每个匹配 `hookRe` 的 hook 调用的依赖数组,返回 `[行号, 依赖名]`。
|
|
68
|
+
* `hookRe` 必须带 `g`,且以 `\(` 结尾(例如 `/use(?:Layout)?Effect\(/g`)。
|
|
69
|
+
*/
|
|
70
|
+
export function hookDeps(
|
|
71
|
+
src: string,
|
|
72
|
+
hookRe: RegExp,
|
|
73
|
+
): Array<[number, string]> {
|
|
74
|
+
const found: Array<[number, string]> = [];
|
|
75
|
+
const re = new RegExp(hookRe.source, "g");
|
|
76
|
+
let m: RegExpExecArray | null;
|
|
77
|
+
|
|
78
|
+
while ((m = re.exec(src))) {
|
|
79
|
+
let i = m.index + m[0].length;
|
|
80
|
+
let depth = 1;
|
|
81
|
+
let quote: string | null = null;
|
|
82
|
+
let prev = "";
|
|
83
|
+
|
|
84
|
+
while (i < src.length && depth > 0) {
|
|
85
|
+
const c = src[i];
|
|
86
|
+
if (quote) {
|
|
87
|
+
if (c === quote && prev !== "\\") quote = null;
|
|
88
|
+
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
89
|
+
else if (c === "(" || c === "[" || c === "{") depth++;
|
|
90
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
91
|
+
prev = c;
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body = src.slice(m.index, i).replace(/\)\s*$/, "");
|
|
96
|
+
const deps = body.match(/,\s*\[([\s\S]*)\]\s*$/);
|
|
97
|
+
if (!deps) continue;
|
|
98
|
+
|
|
99
|
+
const line = src.slice(0, m.index).split("\n").length;
|
|
100
|
+
for (const raw of deps[1].split(",")) {
|
|
101
|
+
const name = raw
|
|
102
|
+
.replace(/\/\/.*$/gm, "")
|
|
103
|
+
.trim()
|
|
104
|
+
.split(/[.?[]/)[0]
|
|
105
|
+
.trim();
|
|
106
|
+
if (name) found.push([line, name]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 找出「每次渲染都会新建一个对象/数组」的解构绑定,返回 `绑定名 → 说明`。
|
|
115
|
+
*
|
|
116
|
+
* 两种形态:
|
|
117
|
+
* - rest 包:`const { a, ...rest } = props` —— `rest` 每次都是新对象;
|
|
118
|
+
* - 字面量默认值:`const { list = [] } = props` —— 消费方没传时每次都是新数组。
|
|
119
|
+
*/
|
|
120
|
+
export function freshObjectBindings(src: string): Map<string, string> {
|
|
121
|
+
const found = new Map<string, string>();
|
|
122
|
+
|
|
123
|
+
const re = /\b(?:const|let|var)\s*\{/g;
|
|
124
|
+
let m: RegExpExecArray | null;
|
|
125
|
+
|
|
126
|
+
while ((m = re.exec(src))) {
|
|
127
|
+
// 取出与 `{` 配对的那一段解构模式
|
|
128
|
+
let i = re.lastIndex;
|
|
129
|
+
let depth = 1;
|
|
130
|
+
let quote: string | null = null;
|
|
131
|
+
let prev = "";
|
|
132
|
+
while (i < src.length && depth > 0) {
|
|
133
|
+
const c = src[i];
|
|
134
|
+
if (quote) {
|
|
135
|
+
if (c === quote && prev !== "\\") quote = null;
|
|
136
|
+
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
137
|
+
else if (c === "(" || c === "[" || c === "{") depth++;
|
|
138
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
139
|
+
prev = c;
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
// 后面必须紧跟 `=`,否则不是解构赋值
|
|
143
|
+
if (!/^\s*=/.test(src.slice(i))) continue;
|
|
144
|
+
|
|
145
|
+
const pattern = src.slice(re.lastIndex, i - 1);
|
|
146
|
+
|
|
147
|
+
for (const r of pattern.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
|
|
148
|
+
found.set(r[1], "rest 解构包:每次渲染都是新对象");
|
|
149
|
+
}
|
|
150
|
+
for (const r of pattern.matchAll(
|
|
151
|
+
/([A-Za-z_$][\w$]*)\s*=\s*(?:[[{])/g,
|
|
152
|
+
)) {
|
|
153
|
+
found.set(r[1], "解构默认值是对象/数组字面量:不传时每次渲染都是新对象");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return found;
|
|
158
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
freshObjectBindings,
|
|
6
|
+
hookDeps,
|
|
7
|
+
stripComments,
|
|
8
|
+
walkSources,
|
|
9
|
+
} from "./depsScan";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 防回归守卫:**每次渲染都新建的对象不许进 hook 依赖数组**。
|
|
13
|
+
*
|
|
14
|
+
* React 的依赖数组按 `Object.is` 比引用。依赖里只要有一个「组件自己每次渲染新建
|
|
15
|
+
* 的对象」,`useMemo` 就**恒不命中**、`useEffect` 就**每渲染必重跑**。
|
|
16
|
+
* 组件内部最常见的两个来源:
|
|
17
|
+
*
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const { className, ...coreOption } = props; // rest 包,每次都是新对象
|
|
20
|
+
* const { inRangeColor = ["#fff"] } = props; // 字面量默认值,不传时每次都是新数组
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* 这条缺陷和回调那条一样没有任何信号:不报错、不崩,只是图表每渲染一次就
|
|
24
|
+
* `setOption(notMerge)` 整个重设——动画重播、鼠标悬浮态被清掉,人工 review 拦不住。
|
|
25
|
+
*
|
|
26
|
+
* 正确写法:
|
|
27
|
+
* - rest 包 → `useShallowStable(rest)`(见 `src/hooks/useShallowStable.ts`);
|
|
28
|
+
* - 字面量默认值 → 提到模块级常量。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SRC = path.resolve(__dirname, "..");
|
|
32
|
+
const HOOK_RE = /use(?:Memo|Callback|(?:Layout)?Effect)\(/g;
|
|
33
|
+
|
|
34
|
+
/** `文件相对路径::依赖名` → 无害的理由(确实无害才登记这里) */
|
|
35
|
+
const ALLOWED: Record<string, string> = {};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 存量欠账:守卫上线(2.5.6)时就已经存在的命中。**它们不是无害的**,只是不在这
|
|
39
|
+
* 一轮的改动范围内(这轮只修 Chart 一族)。修法与 Chart 相同:rest 包走
|
|
40
|
+
* `useShallowStable`,字面量默认值提到模块级常量。
|
|
41
|
+
*
|
|
42
|
+
* 这份名单**只许变短**——下面「只许变短」那条用例会在某项修好却忘了删时报错。
|
|
43
|
+
* 新写的代码一律直接失败,不许往这里加。
|
|
44
|
+
*/
|
|
45
|
+
const KNOWN_DEBT = [
|
|
46
|
+
"components/Checkbox/CheckboxGroup/index.tsx::options",
|
|
47
|
+
"components/FormItem/FormCodeMirror/index.tsx::rules",
|
|
48
|
+
"components/FormItem/ItemContainer/index.tsx::tipsConfig",
|
|
49
|
+
"components/Operate/index.tsx::menu",
|
|
50
|
+
"components/Pagination/index.tsx::pageSizeOptions",
|
|
51
|
+
"components/Panel/ListPanel/ListModalPanel/index.tsx::modalConfig",
|
|
52
|
+
"components/Search/_hooks/useSearchCommon.ts::moreSearchItems",
|
|
53
|
+
"components/Select/AutoCompleteSelect/index.tsx::options",
|
|
54
|
+
"components/Spreadsheet/index.tsx::xOptionsRest",
|
|
55
|
+
"components/Table/_hooks/useTableColumns.tsx::columns",
|
|
56
|
+
"components/Tree/index.tsx::treeData",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
interface Hit {
|
|
60
|
+
/** `文件相对路径::依赖名` */
|
|
61
|
+
key: string;
|
|
62
|
+
file: string;
|
|
63
|
+
line: number;
|
|
64
|
+
why: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scan(): Hit[] {
|
|
68
|
+
const hits: Hit[] = [];
|
|
69
|
+
|
|
70
|
+
for (const file of walkSources(SRC)) {
|
|
71
|
+
const rel = path.relative(SRC, file).split(path.sep).join("/");
|
|
72
|
+
const src = stripComments(fs.readFileSync(file, "utf8"));
|
|
73
|
+
const fresh = freshObjectBindings(src);
|
|
74
|
+
if (!fresh.size) continue;
|
|
75
|
+
|
|
76
|
+
for (const [line, dep] of hookDeps(src, HOOK_RE)) {
|
|
77
|
+
const why = fresh.get(dep);
|
|
78
|
+
if (!why) continue;
|
|
79
|
+
hits.push({ key: `${rel}::${dep}`, file: rel, line, why });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return hits;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("每次渲染都新建的对象不进 hook 依赖数组", () => {
|
|
87
|
+
it("全库无未登记的新命中", () => {
|
|
88
|
+
const offenders = scan()
|
|
89
|
+
.filter(({ key }) => !ALLOWED[key] && !KNOWN_DEBT.includes(key))
|
|
90
|
+
.map(({ file, line, why }) => `${file}:${line} ${why}`);
|
|
91
|
+
|
|
92
|
+
expect(offenders).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("Chart 一族一条都不剩(这一轮修的就是它)", () => {
|
|
96
|
+
const chartHits = scan()
|
|
97
|
+
.filter(({ file }) => file.startsWith("components/Chart/"))
|
|
98
|
+
.map(({ key, line }) => `${key}@${line}`);
|
|
99
|
+
|
|
100
|
+
expect(chartHits).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("存量欠账名单只许变短", () => {
|
|
104
|
+
const live = new Set(scan().map(({ key }) => key));
|
|
105
|
+
// 修好了就把它从 KNOWN_DEBT 里删掉,别留着已经不存在的条目
|
|
106
|
+
expect(KNOWN_DEBT.filter((key) => !live.has(key))).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("守卫本身能抓到问题(自测)", () => {
|
|
110
|
+
const restCase = `
|
|
111
|
+
const { className, ...coreOption } = props;
|
|
112
|
+
const option = useMemo(() => ({ ...coreOption }), [coreOption]);
|
|
113
|
+
`;
|
|
114
|
+
expect(freshObjectBindings(restCase).has("coreOption")).toBe(true);
|
|
115
|
+
expect(hookDeps(restCase, HOOK_RE).map(([, d]) => d)).toContain(
|
|
116
|
+
"coreOption",
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const defaultCase = `
|
|
120
|
+
const { colors = ["#fff"], size = { w: 1 }, name = "x" } = props;
|
|
121
|
+
useEffect(() => {}, [colors, size, name]);
|
|
122
|
+
`;
|
|
123
|
+
const fresh = freshObjectBindings(defaultCase);
|
|
124
|
+
expect(fresh.has("colors")).toBe(true);
|
|
125
|
+
expect(fresh.has("size")).toBe(true);
|
|
126
|
+
// 原始值默认值不是新对象,不该被误判
|
|
127
|
+
expect(fresh.has("name")).toBe(false);
|
|
128
|
+
|
|
129
|
+
// 稳定引用(useShallowStable 的返回值 / 模块级常量)不该被误判
|
|
130
|
+
const fixed = `
|
|
131
|
+
const { className, ...restOption } = props;
|
|
132
|
+
const coreOption = useShallowStable(restOption);
|
|
133
|
+
const option = useMemo(() => ({ ...coreOption }), [coreOption]);
|
|
134
|
+
`;
|
|
135
|
+
expect(freshObjectBindings(fixed).has("coreOption")).toBe(false);
|
|
136
|
+
|
|
137
|
+
// 注释里的示例代码不算命中
|
|
138
|
+
expect(
|
|
139
|
+
freshObjectBindings(
|
|
140
|
+
stripComments(`/** const { ...coreOption } = props; */\nconst a = 1;`),
|
|
141
|
+
).size,
|
|
142
|
+
).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { resolveChartChrome } from "../_utils/chartTheme";
|
|
26
26
|
import useIsDark from "../../../hooks/useIsDark";
|
|
27
27
|
import useLatestRef from "../../../hooks/useLatestRef";
|
|
28
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
28
29
|
|
|
29
30
|
export interface ChartBarProps extends ChartCommonProps {
|
|
30
31
|
chartTitle?: string;
|
|
@@ -70,8 +71,12 @@ const ChartBar: React.FC<ChartBarProps> = (props) => {
|
|
|
70
71
|
enableLegendAutoScroll = false,
|
|
71
72
|
legendVisibleCount = 8,
|
|
72
73
|
legendScrollInterval = 1500,
|
|
73
|
-
...
|
|
74
|
+
...restOption
|
|
74
75
|
} = props;
|
|
76
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
77
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
78
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
79
|
+
const coreOption = useShallowStable(restOption);
|
|
75
80
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
76
81
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
77
82
|
// Defers `echarts.init` until the container has a box — see the hook for why
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
drawCircles,
|
|
14
14
|
} from "../_utils/bubble";
|
|
15
15
|
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
16
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
16
17
|
|
|
17
18
|
export interface BubbleDataItem {
|
|
18
19
|
name: string;
|
|
@@ -71,8 +72,12 @@ const Bubble: React.FC<ChartBubbleProps> = (props) => {
|
|
|
71
72
|
title,
|
|
72
73
|
onChart,
|
|
73
74
|
onClick,
|
|
74
|
-
...
|
|
75
|
+
...restOption
|
|
75
76
|
} = props;
|
|
77
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
78
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
79
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
80
|
+
const coreOption = useShallowStable(restOption);
|
|
76
81
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
77
82
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
78
83
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
|
@@ -4,9 +4,14 @@ import styles from "../index.module.scss";
|
|
|
4
4
|
import { ChartCommonProps, ChartOptionType, ChartsOption } from "..";
|
|
5
5
|
import * as echarts from "echarts";
|
|
6
6
|
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
7
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
7
8
|
|
|
8
9
|
const Common: React.FC<ChartCommonProps> = (props) => {
|
|
9
|
-
const { className, style, onChart, ...
|
|
10
|
+
const { className, style, onChart, ...restOption } = props;
|
|
11
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
12
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
13
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
14
|
+
const coreOption = useShallowStable(restOption);
|
|
10
15
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
11
16
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
12
17
|
// Defers `echarts.init` until the container has a box — see the hook for why
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|
11
11
|
|
|
12
12
|
import styles from "../index.module.scss";
|
|
13
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
13
14
|
|
|
14
15
|
export interface ChartGaugeProps extends ChartCommonProps {
|
|
15
16
|
color?: string[] | string;
|
|
@@ -28,8 +29,12 @@ const Gauge: React.FC<ChartGaugeProps> = (props) => {
|
|
|
28
29
|
axisLabel,
|
|
29
30
|
title,
|
|
30
31
|
color = "#1675FB",
|
|
31
|
-
...
|
|
32
|
+
...restSeries
|
|
32
33
|
} = series as echarts.GaugeSeriesOption;
|
|
34
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
35
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
36
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
37
|
+
const coreSeries = useShallowStable(restSeries);
|
|
33
38
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
34
39
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
35
40
|
// Defers `echarts.init` until the container has a box — see the hook for why
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { resolveChartChrome } from "../_utils/chartTheme";
|
|
11
11
|
import useIsDark from "../../../hooks/useIsDark";
|
|
12
12
|
import { useLatestRef } from "../../../hooks/useLatestRef";
|
|
13
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
13
14
|
|
|
14
15
|
export interface HeatmapDataItem {
|
|
15
16
|
/** X-axis index or name */
|
|
@@ -43,6 +44,19 @@ export interface ChartHeatmapProps extends ChartCommonProps {
|
|
|
43
44
|
inRangeColor?: string[];
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// 解构默认值写成字面量会每次渲染新建一个数组,进依赖数组就让 memo 恒不命中;提到模块级常量
|
|
48
|
+
const DEFAULT_IN_RANGE_COLOR = [
|
|
49
|
+
"#8BCCFB",
|
|
50
|
+
"#75C3FB",
|
|
51
|
+
"#5AB5F6",
|
|
52
|
+
"#42AEFA",
|
|
53
|
+
"#2DA1F5",
|
|
54
|
+
"#0F89E1",
|
|
55
|
+
"#056FBB",
|
|
56
|
+
"#025794",
|
|
57
|
+
"#0A4089",
|
|
58
|
+
];
|
|
59
|
+
|
|
46
60
|
const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
|
|
47
61
|
const {
|
|
48
62
|
className,
|
|
@@ -61,19 +75,13 @@ const Heatmap: React.FC<ChartHeatmapProps> = (props) => {
|
|
|
61
75
|
onChart,
|
|
62
76
|
onClick,
|
|
63
77
|
zeroColor,
|
|
64
|
-
inRangeColor =
|
|
65
|
-
|
|
66
|
-
"#75C3FB",
|
|
67
|
-
"#5AB5F6",
|
|
68
|
-
"#42AEFA",
|
|
69
|
-
"#2DA1F5",
|
|
70
|
-
"#0F89E1",
|
|
71
|
-
"#056FBB",
|
|
72
|
-
"#025794",
|
|
73
|
-
"#0A4089",
|
|
74
|
-
],
|
|
75
|
-
...coreOption
|
|
78
|
+
inRangeColor = DEFAULT_IN_RANGE_COLOR,
|
|
79
|
+
...restOption
|
|
76
80
|
} = props;
|
|
81
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
82
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
83
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
84
|
+
const coreOption = useShallowStable(restOption);
|
|
77
85
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
78
86
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
79
87
|
// Defers `echarts.init` until the container has a box — see the hook for why
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { resolveChartChrome } from "../_utils/chartTheme";
|
|
26
26
|
import useIsDark from "../../../hooks/useIsDark";
|
|
27
27
|
import useLatestRef from "../../../hooks/useLatestRef";
|
|
28
|
+
import useShallowStable from "../../../hooks/useShallowStable";
|
|
28
29
|
|
|
29
30
|
export interface ChartLineProps extends ChartCommonProps {
|
|
30
31
|
chartTitle?: string;
|
|
@@ -71,8 +72,12 @@ const ChartLine: React.FC<ChartLineProps> = (props) => {
|
|
|
71
72
|
enableLegendAutoScroll = false,
|
|
72
73
|
legendVisibleCount = 8,
|
|
73
74
|
legendScrollInterval = 1500,
|
|
74
|
-
...
|
|
75
|
+
...restOption
|
|
75
76
|
} = props;
|
|
77
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
78
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
79
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
80
|
+
const coreOption = useShallowStable(restOption);
|
|
76
81
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
77
82
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
|
78
83
|
// Defers `echarts.init` until the container has a box — see the hook for why
|
|
@@ -8,6 +8,7 @@ import { useMouseOverHandler, useGlobalOutHandler } from "./_hooks";
|
|
|
8
8
|
import { getPie3DOption } from "./_utils/option";
|
|
9
9
|
import styles from "../../index.module.scss";
|
|
10
10
|
import { useLatestRef } from "../../../../hooks/useLatestRef";
|
|
11
|
+
import useShallowStable from "../../../../hooks/useShallowStable";
|
|
11
12
|
|
|
12
13
|
export interface Pie3DDataItem {
|
|
13
14
|
name: string;
|
|
@@ -86,6 +87,10 @@ export interface SeriesItem {
|
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// 解构默认值写成字面量会每次渲染新建一个对象,进依赖数组就让 memo 恒不命中;提到模块级常量
|
|
91
|
+
const DEFAULT_TOOLTIP = { show: false };
|
|
92
|
+
const DEFAULT_LABEL: LabelConfig = { show: true };
|
|
93
|
+
|
|
89
94
|
const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
|
|
90
95
|
const {
|
|
91
96
|
className,
|
|
@@ -100,13 +105,17 @@ const ChartPie3D: React.FC<ChartPie3DProps> = (props) => {
|
|
|
100
105
|
minHeight = 1,
|
|
101
106
|
maxHeight = 10,
|
|
102
107
|
yOffset = -0.2,
|
|
103
|
-
tooltip =
|
|
104
|
-
label =
|
|
108
|
+
tooltip = DEFAULT_TOOLTIP,
|
|
109
|
+
label = DEFAULT_LABEL,
|
|
105
110
|
enableMouseControl = false,
|
|
106
111
|
onChart,
|
|
107
112
|
onClick,
|
|
108
|
-
...
|
|
113
|
+
...restOption
|
|
109
114
|
} = props;
|
|
115
|
+
// rest 解构出来的对象每次渲染都是新引用,直接进依赖数组会让 chartOption 的 memo
|
|
116
|
+
// 恒不命中 —— 父组件每渲染一次就重跑一次 setOption(notMerge),动画重播、悬浮态被清掉。
|
|
117
|
+
// useShallowStable 让它回到值语义:内容浅相等就复用同一引用,真变了立刻透出新引用。
|
|
118
|
+
const coreOption = useShallowStable(restOption);
|
|
110
119
|
|
|
111
120
|
const chartRef = useRef<HTMLDivElement>(null);
|
|
112
121
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|