React集成MathJax的DOM优化、组件迁移等技术问题咨询
先聊聊背景:在React里集成MathJax确实会碰到不少DOM管理的麻烦——MathJax有自己的并发系统,还会修改React没追踪到的DOM,导致一堆反模式的手动DOM操作。下面针对你提出的四个问题逐一解答:
问题1:RenderGroup中的if (!ref)判断必要性,以及拦截ref的合理性
为什么需要if (!ref)判断?
React在组件更新(或卸载重建)时,会先把旧组件的ref设置为null,再把新组件实例传入ref回调。如果去掉这个判断,当组件更新时,你会把null推入this.promises数组,后续Promise.all就会因为处理null而抛出错误。这个判断是为了过滤掉更新过程中临时的null值,只收集有效组件实例的ready Promise。
拦截ref获取ready Promise的方式是否合理?
这个思路是合理的,但现有实现有个小问题:在render方法里重置this.promises = []不符合React渲染逻辑——render应该是纯函数,不应该修改实例状态(因为React可能会多次调用render)。优化方案是把promises存在useRef(Hooks版本)或者类组件的实例属性里,在componentDidMount/useEffect里重置,而不是在render里。
问题2:类组件迁移为Hooks组件,RenderGroup如何暴露.ready属性
函数组件没有实例,但可以通过**forwardRef + useRef**来暴露ready状态。下面是迁移后的核心代码:
MJX组件(Hooks版本)
import { useState, useRef, useEffect, forwardRef, ForwardedRef } from 'react'; interface MJXProps extends React.HTMLAttributes<HTMLSpanElement> { display?: boolean; children: React.ReactNode; } export const MJX = forwardRef((props: MJXProps, ref: ForwardedRef<{ ready: Promise<void> }>) => { const domElement = useRef<HTMLSpanElement>(null); const [readyPromise, setReadyPromise] = useState<Promise<void> | null>(null); const resolveReady = useRef<(() => void) | null>(null); // 初始化ready Promise并暴露给父组件 useEffect(() => { const promise = new Promise<void>((resolve) => { resolveReady.current = resolve; }); setReadyPromise(promise); if (ref && typeof ref === 'object') { ref.current = { ready: promise }; } }, [ref]); // MathJax排版逻辑 useEffect(() => { let isMounted = true; const typeset = async () => { await MathJaxReady; if (!isMounted || !domElement.current) return; await new Promise<void>((resolve) => { MathJax.Hub.Queue(["Typeset", MathJax.Hub, domElement.current]); MathJax.Hub.Queue(resolve); }); resolveReady.current?.(); }; typeset(); return () => { isMounted = false; }; }, [props.children]); // 处理props更新(替代shouldComponentUpdate) useEffect(() => { if (!domElement.current) return; // 更新className if (props.className !== domElement.current.className) { domElement.current.className = props.className || ''; } // 更新style if (props.style) { Object.assign(domElement.current.style, props.style); } // 更新TeX内容 const jax = MathJax.Hub.getAllJax(domElement.current)[0]; if (jax) { const text = Array.isArray(props.children) ? props.children.join('') : String(props.children); jax.Text(text); } }, [props.children, props.className, props.style]); const { children, display, ...attrs } = props; const [open, close] = display ? ['\\[', '\\]'] : ['\\(', '\\)']; return ( <span {...attrs} ref={domElement}>{open + children + close}</span> ); });
RenderGroup组件(Hooks版本)
import { useRef, useEffect, forwardRef, ForwardedRef } from 'react'; interface RenderGroupProps { children: React.ReactNode; } export const RenderGroup = forwardRef((props: RenderGroupProps, ref: ForwardedRef<{ ready: Promise<void> }>) => { const promisesRef = useRef<Promise<void>[]>([]); const readyRef = useRef<Promise<void> | null>(null); // 收集子MJX的ready Promise const collectMJXReady = (mjxRef: { ready: Promise<void> } | null) => { if (mjxRef) { promisesRef.current.push(mjxRef.ready); } }; // 生成并暴露ready Promise useEffect(() => { readyRef.current = Promise.all(promisesRef.current).then(() => {}); if (ref && typeof ref === 'object') { ref.current = { ready: readyRef.current! }; } return () => { promisesRef.current = []; }; }, [props.children, ref]); // 递归映射子组件 const mappedChildren = recursiveMap(props.children, (child) => { if (child.type === MJX) { return React.cloneElement(child, { ref: collectMJXReady }); } return child; }); return <>{mappedChildren}</>; });
核心思路:用forwardRef把组件内部的ready Promise通过ref暴露给父组件,函数组件的状态用useRef和useState维护,替代类组件的实例属性。
问题3:摆脱命令式管理,回归React声明式特性
根本问题是MathJax生成的不在React的虚拟DOM树里,所以无法用React的状态绑定。解决思路是让React完全控制,只让MathJax渲染公式结构,把输入框的位置留给React:
方案:占位符 + React Portal
- 自定义MathJax扩展,让
\FormInputPlaceholder生成带唯一标识的占位元素(比如<span class="mjx-input-placeholder" data-id="input1"></span>)。 - 在MJX排版完成后,React找到这些占位元素,用
React.createPortal把自己的组件挂载进去。
示例代码:
import { useRef, useEffect } from 'react'; import ReactDOM from 'react-dom'; interface ExampleProps { setParentValue: (i: number, value: number) => void; values: number[]; } export const Example = ({ setParentValue, values }: ExampleProps) => { const divRef = useRef<HTMLDivElement>(null); const rgRef = useRef<{ ready: Promise<void> }>(null); useEffect(() => { rgRef.current?.ready.then(() => { if (!divRef.current) return; // 找到所有MathJax生成的占位符 const placeholders = divRef.current.querySelectorAll('.mjx-input-placeholder'); placeholders.forEach((placeholder, index) => { // 用Portal挂载React管理的input ReactDOM.createPortal( <input type="number" className="matrix-entry" value={values[index]} onChange={(e) => setParentValue(index, Number(e.target.value))} />, placeholder ); }); }); }, [values, setParentValue]); return ( <div ref={divRef}> <RenderGroup ref={rgRef}> <MJX>{String.raw` \begin{bmatrix} \FormInputPlaceholder[input1]\\ \FormInputPlaceholder[input2] \end{bmatrix} `}</MJX> <MJX>+</MJX> <MJX>{String.raw` \begin{bmatrix} \FormInputPlaceholder[input3]\\ \FormInputPlaceholder[input4] \end{bmatrix} `}</MJX> <MJX>=</MJX> <MJX>{String.raw` \begin{bmatrix} ${values[0]+values[2]}\\ ${values[1]+values[3]} \end{bmatrix} `}</MJX> </RenderGroup> </div> ); };
这样完全由React管理,用value和onChange绑定状态,彻底摆脱命令式的DOM操作。
问题4:为recursiveMap添加TypeScript泛型,解决类型报错
给recursiveMap添加泛型约束,明确子组件的类型,就能解决fn(child)的类型错误:
export function recursiveMap<T extends React.ReactElement<any>>( children: React.ReactNode, fn: (child: T) => T ): React.ReactNode { return React.Children.map(children, (child) => { if (!React.isValidElement<T>(child)) { return child; } // 递归处理子节点 if (child.props.children) { return React.cloneElement(child, { children: recursiveMap(child.props.children, fn) }); } return fn(child); }); }
泛型说明:
T extends React.ReactElement<any>:约束fn的参数和返回值都是React元素类型。React.isValidElement<T>(child):确保child是符合泛型T的有效React元素,避免类型断言。
使用时,TypeScript会自动推断子组件的类型,或者你可以手动指定泛型参数,比如recursiveMap<typeof MJX>(children, fn)。
内容的提问来源于stack exchange,提问作者Yuri Sulyma

