Template System 进阶(三):落地踩坑记——CJS/ESM 导出、setState(函数) 陷阱与 React 单例之战
承接上一篇《进阶(二):Core / Extra 分离与“钥匙带大门”——把 React 栈的加载时间榨干》。这次把方案真正落到运行时,结果一路踩了三个典型大坑:CJS/ESM 导出形态、组件动态注入时的状态更新误用、以及“双 React 实例”。把它们记下来,既作复盘,也方便后人不再踩雷。
1) CJS→ESM 不是魔法:Wrapper + Treeshake 的组合拳会“偷换导出形态”
现象
我按“Core/Extra 两段式”构建 vendor,Core 里把 react/react-dom 打成 ESM,Extra 里做 react-dom/client 入口壳。Wrapper 写成:
- 
export * from 'react'
- 
export { default } from 'react'
结果构建后的 react 只剩下一个 default 对象,没有 export { useContext, useState, … }。远程组件按具名导入时直接报:
The requested module "react" does not provide an export named "useContext"
根因
react 源包是 CommonJS。export * 不能把 CJS 的“对象属性”自动变成 ESM 的“具名导出”。再叠加激进 treeshake(尤其是 moduleSideEffects:false),更容易把未被宿主显式使用的导出给“优化掉”。
结论
- 
Wrapper 必须“手动实体化具名导出”:把 CJS 命名空间对象中的字段挑出来再导出( useState、useEffect、useContext…),同时保留 default。
- 
Core 段 treeshake 要保守:至少对 React/ReactDOM 禁摇树或开最低强度;Extra 段保持正常 treeshake 即可。 
- 
构建后做 肉眼验收:产物里必须能看到 export { useContext, useState, … }这一行,才算合格。
要点:“Wrapper 写法决定导出形态,Treeshake 决定导出是否被剪。” 两者要配套。
2) 动态远程组件不是值类型:setState(函数)会被当作“更新器”执行
现象
用 useState(null) 存远程组件,拉到组件后直接 setState(Comp),结果 React 把 Comp 当作 updater 调用了一次(参数是旧 state null),组件函数内立即解构 props,触发
Cannot destructure property 'modalSearchProps' of 'object null' as it is null
根因
setState 支持两种形态:set(value) 与 set(prev => next)。你传了一个“函数”,React 就会调用它。组件本身是函数,所以踩雷。
结论
- 
存“函数值”时要用 惰性赋值:把组件包一层再放进 state,避免被当 updater。 
- 
更高阶的做法是 lazy/dynamic:交给 React 去管理异步组件加载与边界,但只要记住“给 state 传函数要小心”,问题就不会再复发。 
要点:“要存函数,用
setState(() => fn);要执行函数,别放进setState。”
3) “Cannot read properties of null (reading 'useState')”:这是“双 React 实例”的报警器
现象
远程组件渲染时报:
Cannot read properties of null (reading 'useState')
这通常是生产版里“Invalid hook call”的别名表现。
根因
同一棵 React 树里出现了 两份 React 实例:
- 
宿主(Next)用它自己的 React; 
- 
远程组件因为 import map 指向 CDN,又拉了一份 React; 
- 
Hook 的 dispatcher 由“当前渲染器”维护,跨实例访问就是 null。
结论(方案定案的方向)
- 
整树只允许一份 React。我的最终路线是: - 
在宿主提供 react/react-jsx-runtime代理模块(ESM),把宿主这份 React 的命名导出“实体化”并导出;
- 
import map 把 "react"/"react/jsx-runtime"映射到 宿主代理(相对 URL),把"react-dom"/"react-dom/client"仍映射到 CDN;
- 
继续只 modulepreloadreact-dom/client(带上react-dom)+react/jsx-runtime(现在指向代理/本地,几乎 0 RTT)。
 
- 
- 
或者走另一条:把宿主也 external,让宿主与远程都从 import map 加载同一份 CDN React/ReactDOM。两条路本质一致:单例。 
要点:“单例 React 是前提,预抓链路是优化。” 先保证“只有一个 React”,再谈 RTT。
4) 进度与价值:链路已打通,质量可控
尽管踩坑,但现在这条“远程 vendor + 主题产物 + manifest + 缓存治理”的链路已经打通,并具备这些特性:
- 
Manifest 驱动: manifest.vendor.json+manifest.<theme>.json,可版本化、可回滚;
- 
缓存有章法:vendor 长缓存、manifest 协商缓存; 
- 
预抓有策略: react-dom/client小入口“钥匙带大门”,react/jsx-runtime单独预抓;
- 
Import Map 一致性:预抓与运行时解析 URL 完全一致,命中稳定。 
这条链路在“多个子程序共享同一套 CDN vendor”的微前端里具参考价值——统一版本、统一缓存、统一回滚。只是我当前宿主是 Next 应用,需要考虑“宿主自带 React”的现实,所以下一步必须把 React 单例纳入设计。
5) 下一篇预告:宿主单例化与 Import Map 代理
下一步我要把“单例 React”的方案固化成 Theme-Loader SDK 的可选开关:
- 
useHostReact: true:把"react"与"react/jsx-runtime"自动映射到宿主代理;
- 
strictVersion: true:SSR 比对宿主 React 与 CDNreact-dom版本,不一致则告警/降级;
- 
buildHeadTags():继续输出结构化<link>(不再拼字符串),导入顺序严格:import map → preload。
并会详细展开两个实现分支:
- 
宿主代理(推荐):宿主输出 ESM 代理,让远程共享宿主 React; 
- 
统一 CDN:宿主也 external,整个站都从 import map 加载同一份 React/ReactDOM。 
6) 今日小结(给未来的我)
- 
Wrapper 要“手动导出具名导出”,别指望 CJS→ESM 魔法;Core 对 React 家族 禁摇树。 
- 
动态远程组件存到 state 时要惰性赋值,避免把组件函数当 updater 执行。 
- 
同树两份 React 必炸,方案设计里要把“React 单例”作为硬约束。 
- 
Import Map 的右值可以用相对 URL(别再被“只能 http”误导),这正是宿主代理的关键。 
——“把坑踩薄了,路才会平”。下一篇,把“单例化方案”收进 SDK,彻底封装成对业务“傻瓜可用”的接口。

 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号