修 React #321:loadJs 按 src 全局去重,避免 React 执行两遍 (#73)

用户 Console 决定性证据:
  AntD 日期控件已挂上 5 处   ← 首屏
  AntD 日期控件已挂上 1 处   ← 点开浮层编辑
  react-dom.min.js:121 Error: Minified React error #321 (Invalid hook call)
    at c.useContext (VM303 react.min.js:28:406)
    at e (antd-with-locales.min.js:77:5889)
    renderPicker @ app.js:2279 / mountAntdSingleDT @ app.js:2282

#321 = Invalid hook call,本次成因是"存在多份 React 副本"。

决定性线索是 VM 前缀:VM303 / VM320。
那是浏览器给 eval/new Function 动态执行的代码分配的脚本 ID;
普通 <script src> 显示的是文件 URL。同一个文件出现 VM 前缀
⇒ 它被动态执行了不止一次。

根因:app.js 里有两个独立 IIFE,各自一个 loadJs,都不去重。
  · Semi 段 boot():**无条件** loadJs(conf.react) + loadJs(conf.dom)
  · antd 段 ensure():只在 !window.React 时才加载 —— 看着谨慎,
    但依赖"当前全局有没有 React",而 UMD 执行完才赋值。

时序:Semi 的 boot() 排在 requestIdleCallback 里。
用户点开任务 → fbBootAntd() → antd 开始加载 react.min.js(第一次)
→ Semi 的 idle 回调恰好触发 → 又插一遍(第二次)
→ 同一段 UMD 执行两次 → window.React 被覆盖
→ 但 antd 内部已捕获第一次的 React 引用
→ antd 的 useContext 在第二套的 dispatcher 上调用 → #321

修法:两个 loadJs 都改为按 src 全局去重,共享 window.__fbJsLoaded。
  · 用全局而不是 IIFE 局部变量:两个 loadJs 在不同闭包里,互相看不见
  · 不用 if (!window.React) 那种判断:UMD 执行完才赋值,
    两个请求几乎同时发出时两边都还看不到 window.React,照样各插一个。
    按 URL 去重是确定的,不依赖时序。

验证(复刻逻辑跑了三种时序):
  antd 先 / Semi 后  → 第二个复用 ✅ 只有一份
  Semi 先 / antd 后  → 第二个复用 ✅ 只有一份
  不同文件           → 各自加载 ✅ 没被错误复用
This commit is contained in:
2026-11-05 17:38:51 +08:00
parent 870a63df5a
commit a43950069d
+42 -2
View File
@@ -90,14 +90,21 @@
document.head.appendChild(l); document.head.appendChild(l);
}); });
} }
/* ⚠️ 与 Semi 段那个 `loadJs` 用**同一个全局登记表** —— 两处都加载 React
(Semi 无条件、antd 有条件),不共享去重就会执行两次 ⇒ React #321。
详见 Semi 段 `loadJs` 上方的完整说明。 */
function loadJs(src) { function loadJs(src) {
return new Promise(function (res, rej) { var reg = window.__fbJsLoaded;
if (!reg) { reg = window.__fbJsLoaded = {}; }
if (reg[src]) { return reg[src]; }
reg[src] = new Promise(function (res, rej) {
var el = document.createElement('script'); var el = document.createElement('script');
el.src = src; el.src = src;
el.onload = function () { res(); }; el.onload = function () { res(); };
el.onerror = function () { rej(new Error('加载失败: ' + src)); }; el.onerror = function () { rej(new Error('加载失败: ' + src)); };
document.head.appendChild(el); document.head.appendChild(el);
}); });
return reg[src];
} }
function ensureCropper() { function ensureCropper() {
if (window.Cropper) { return Promise.resolve(); } if (window.Cropper) { return Promise.resolve(); }
@@ -1597,14 +1604,47 @@
document.head.appendChild(l); document.head.appendChild(l);
}); });
} }
/**
* 加载一个 JS 文件。
*
* 🔴🔴 2026-11-05 修 React #321(Invalid hook call):
*
* 症状:首页点任务 → 浮层点「编辑」→ 控制台的 `AntD 日期控件已挂上 1 处`
* 紧接着抛 `Minified React error #321`,日期控件崩掉。
*
* 根因:**React 被加载了两遍**。
* · Semi 段的 `boot()` 用 `requestIdleCallback` 延迟跑,
* 里面**无条件** `loadJs(conf.react)` + `loadJs(conf.dom)`;
* · antd 段的 `ensure()` 只在 `!window.React` 时才加载 —— 看着更"谨慎",
* 但两边谁先谁后取决于 idle 回调的时机。
* ⇒ antd 先加载、Semi 的 idle 后到 ⇒ Semi **又插了一遍**同一个
* `react.min.js` ⇒ 那段 UMD **执行第二次** ⇒ `window.React` 被覆盖。
* 而 antd 已经在内部捕获了**第一次**的 React 引用 ⇒ 两套 React 并存
* ⇒ antd 的 hook(useContext)在"第二套"的 dispatcher 上调用 ⇒ #321。
*
* 堆栈里 `VM303 react.min.js` / `VM320 react-dom.min.js` 这种 **VM 前缀**
* 就是"同一个文件被 eval 执行了不止一次"的铁证。
*
* ⇒ 修法:按 `src` **全局去重**,同一个地址永远只执行一次。
* 缓存在 `window.__fbJsLoaded`(跨 IIFE 共享 —— 本文件里有两个独立的
* `loadJs` 定义,Semi 一个、Semi 外层一个,各自闭包内的变量互相看不见)。
*
* ⚠️ 不能只给 Semi 那边加判断(`if (!window.React)`):那种写法依赖"现有全局
* 是否已经有 React",而 UMD 执行完才赋值 —— 两个请求几乎同时发出时,
* 两边都还看不到 `window.React`,照样会各插一个。**按 URL 去重才是确定的。**
*/
function loadJs(src) { function loadJs(src) {
return new Promise(function (res, rej) { var reg = window.__fbJsLoaded;
if (!reg) { reg = window.__fbJsLoaded = {}; }
if (reg[src]) { return reg[src]; }
reg[src] = new Promise(function (res, rej) {
var s = document.createElement('script'); var s = document.createElement('script');
s.src = src; s.src = src;
s.onload = function () { res(); }; s.onload = function () { res(); };
s.onerror = function () { rej(new Error('加载失败: ' + src)); }; s.onerror = function () { rej(new Error('加载失败: ' + src)); };
document.head.appendChild(s); document.head.appendChild(s);
}); });
return reg[src];
} }
function pad(n) { return (n < 10 ? '0' : '') + n; } function pad(n) { return (n < 10 ? '0' : '') + n; }