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 源包是 CommonJSexport * 不能把 CJS 的“对象属性”自动变成 ESM 的“具名导出”。再叠加激进 treeshake(尤其是 moduleSideEffects:false),更容易把未被宿主显式使用的导出给“优化掉”。

结论

  • Wrapper 必须“手动实体化具名导出”:把 CJS 命名空间对象中的字段挑出来再导出(useStateuseEffectuseContext…),同时保留 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。我的最终路线是:

    1. 在宿主提供 react/react-jsx-runtime 代理模块(ESM),把宿主这份 React 的命名导出“实体化”并导出;

    2. import map 把 "react"/"react/jsx-runtime" 映射到 宿主代理(相对 URL),把 "react-dom"/"react-dom/client" 仍映射到 CDN

    3. 继续只 modulepreload react-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 与 CDN react-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,彻底封装成对业务“傻瓜可用”的接口。

posted @ 2025-08-15 11:18  PEAR2020  阅读(11)  评论(0)    收藏  举报