javascript - 惯用的 React 和大量 DOM 操作 (MathJax)

标签 javascript reactjs typescript mathjax

我在 React 应用程序中使用 MathJax。 MathJax 带来了很大的复杂性:它有自己的并发管理系统,并对 DOM 进行了 React 不知道的更改。这导致了很多 DOM 微观管理,这些管理通常被认为是 React 中的反模式,我想知道我的代码是否可以做得更好。

在下面的代码中,MJX是一个以 TeX 字符串作为输入并将其输入 MathJax 的组件。 RenderGroup是一个方便的组件,可以跟踪其所有 MJX 的时间。后代已经排版完毕。

/// <reference types="mathjax" />
import * as React from "react";

/* Promise that resolves once MathJax is loaded and ready to go */
export const MathJaxReady = new Promise<typeof MathJax>((resolve, reject) => {
  const script = $("#js-async-mathjax");
  if (!script) return;

  if (window.hasOwnProperty("MathJax")) {
    MathJax.Hub.Register.StartupHook("End", resolve);
  } else {
    script.addEventListener("load", () => MathJax.Hub.Register.StartupHook("End", resolve));
  }
});

interface Props extends React.HTMLAttributes<HTMLSpanElement> {
  display?: boolean;
}

export class MJX extends React.Component<Props, {}> {
  private resolveReady: () => void;
  domElement: HTMLSpanElement;
  jax: MathJax.ElementJax;

  // Promise that resolves after initial typeset
  ready: Promise<void>;

  static defaultProps = {
    display: false
  }

  constructor(props: Props) {
    super(props);

    this.ready = new Promise((resolve, reject) => this.resolveReady = resolve);

    this.Typeset = this.Typeset.bind(this);
  }

  async componentDidMount() {
    await MathJaxReady;

    this.Typeset()
    .then(() => this.jax = MathJax.Hub.getAllJax(this.domElement)[0])
    .then(this.resolveReady);
  }

  shouldComponentUpdate(nextProps, nextState) {
    /* original span has been eaten by MathJax, manage updates ourselves */
    const text = this.props.children instanceof Array ? this.props.children.join("") : this.props.children,
          nextText = nextProps.children instanceof Array ? nextProps.children.join("") : nextProps.children;

    // rerender?
    if (this.jax && text !== nextText) {
      this.jax.Text(nextProps.children);
    }

    // classes changed?
    if (this.props.className !== nextProps.className) {
      const classes = this.props.className ? this.props.className.split(" ") : [],
            newClasses = nextProps.className ? nextProps.className.split(" ") : [];

      const add = newClasses.filter(_ => !classes.includes(_)),
            remove = classes.filter(_ => !newClasses.includes(_));

      for (const _ of remove)
        this.domElement.classList.remove(_);
      for (const _ of add)
        this.domElement.classList.add(_);
    }

    // style attribute changed?
    if (JSON.stringify(this.props.style) !== JSON.stringify(nextProps.style)) {
      Object.keys(this.props.style || {})
      .filter(_ => !(nextProps.style || {}).hasOwnProperty(_))
      .forEach(_ => this.props.style[_] = null);
      Object.assign(this.domElement.style, nextProps.style);
    }

    return false;
  }

  Typeset(): Promise<void> {
    return new Promise((resolve, reject) => {
      MathJax.Hub.Queue(["Typeset", MathJax.Hub, this.domElement]);
      MathJax.Hub.Queue(resolve);
    });
  }

  render() {
    const {children, display, ...attrs} = this.props;

    const [open, close] = display ? ["\\[", "\\]"] : ["\\(", "\\)"];

    return (
      <span {...attrs} ref={node => this.domElement = node}>{open + children + close}</span>
    );
  }
}

// wait for a whole bunch of things to be rendered
export class RenderGroup extends React.Component {
  private promises: Promise<void>[];

  ready: Promise<void>;

  componentDidMount() {
    this.ready = Promise.all(this.promises).then(() => {});
  }

  render() {
    this.promises = [];

    return recursiveMap(this.props.children, node => {
      if (typeof node.type === "function" && node.type.prototype instanceof MJX) {
        const originalRef = node.ref;
        return React.cloneElement(node, {
          ref: (ref: MJX) => {
            if (!ref) return;
            this.promises.push(ref.ready);
            if (typeof originalRef === "function") {
              originalRef(ref);
            } else if (originalRef && typeof originalRef === "object") {
              originalRef.current = ref;
            }
          }
        });
      }

      return node;
    });
  }
}

// recursive React.Children.map
export function recursiveMap(
  children: React.ReactNode,
  fn: (child: React.ReactElement<any>) => React.ReactElement<any>
) {
  return React.Children.map(children, (child) => {
    if (!React.isValidElement<any>(child)) {
      return child;
    }

    if ("children" in child.props) {
      child = React.cloneElement(child, {
        children: recursiveMap(child.props.children, fn)
      });
    }

    return fn(child);
  });
}

这是一个接近真实代码的示例。我们使用 MathJax 创建一些 <input> 2D 向量内的 s。在我的例子中,这将与交互式图形显示集成,因此条目的值将存储在父组件的状态中,并且 Example都可以从父级接收值并设置这些值。自 <input>在MathJax完成排版之前,s不存在,我们必须手动管理它们。

interface Props {
  setParentValue: (i: number, value: number) => void;
  values: number[];
}

class Example extends React.PureComponent<Props> {
  private div: HTMLDivElement;
  private inputs: HTMLInputElement[];
  private rg: RenderGroup;

  componentDidMount() {
    this.rg.ready.then(() => {
      this.inputs = this.div.querySelectorAll("input");
      for (let i = 0; i < this.inputs.length; ++i) {
        this.inputs[i].addEventListener("change", e => this.props.setParentValue(i, e.target.value));
      }
    });
  }

  shouldComponentUpdate(nextProps) {
    if (this.inputs) {
      for (let i = 0; i < nextProps.values.length; ++i) {
        if (this.props.values[i] !== nextProps.values[i])
          this.inputs[i].value = nextProps.values[i];
      }
    }
    return false;
  }

  render() {
    // render only runs once, using initial values
    return (
      <div ref={ref => this.div = ref}>
        <RenderGroup ref={ref => this.rg = ref}>
          <MJX>{String.raw`
            \begin{bmatrix}
              \FormInput[4][matrix-entry][${this.props.values[0]}]{input1}\\
              \FormInput[4][matrix-entry][${this.props.values[1]}]{input2}
            \end{bmatrix}
          `}</MJX>

          <MJX>+</MJX>

          <MJX>{String.raw`
            \begin{bmatrix}
              \FormInput[4][matrix-entry][${this.props.values[2]}]{input3}\\
              \FormInput[4][matrix-entry][${this.props.values[3]}]{input4}
            \end{bmatrix}
          `}</MJX>

          <MJX>=</MJX>

          <MJX>{String.raw`
            \begin{bmatrix}
              ${this.props.values[0]+this.props.values[2]}\\
              ${this.props.values[1]+this.props.values[3]}
            \end{bmatrix}
          `}</MJX>
        </RenderGroup>
      </div>
    );
  }
}

这是我的问题。

  1. RenderGroup很脆。例如,我不明白为什么我需要检查 if (!ref) ;但如果我省略该行,则 ref将(由于我不明白的原因)在后续更新中变为空并导致错误。拦截ref以获取ready Promise 似乎也很粗略。

  2. 我正在慢慢尝试将我的类组件迁移到 Hooks ;而这个isn't strictly necessary ,根据 React 团队的说法 it should be possible 。问题是函数组件没有实例,所以我不知道如何公开 .ready父组件如 Example 。我看到有一个 useImperativeHandle 对于这种情况,这似乎取决于最终对 HTML 组件的引用。我想在 MJX 的情况下我可以引用 <span> ,但这不适用于 RenderGroup .

  3. 强制管理输入是痛苦且容易出错的。有什么办法可以恢复 React 声明式的优点吗?

  4. 额外奖励:我还不知道如何输入 recursiveMap适本地; TypeScript 对 fn(child) 感到愤怒线。用泛型替换 any 也很好。

最佳答案

我个人没有使用过 MathJax,但根据我的经验,处理 resolveReady 内容的“惯用 React”方式可能是通过上下文向下传递回调,让子级通知父级在加载或准备就绪时。示例(带钩子(Hook)!):

const LoadingContext = createContext(() => () => {});
const LoadingProvider = memo(LoadingContext.Provider);

function RenderGroup({ children }) {
  const [areChildrenReady, setAreChildrenReady] = useState(false);

  const nextChildIdRef = useRef(0);
  const unfinishedChildrenRef = useRef(new Set());
  const startLoading = useCallback(() => {
    const childId = nextChildIdRef.current++;
    unfinishedChildrenRef.current.add(childId);
    setAreChildrenReady(!!unfinishedChildrenRef.current.size);
    const finishLoading = () => {
      unfinishedChildrenRef.current.delete(childId);
      setAreChildrenReady(!!unfinishedChildrenRef.current.size);
    };
    return finishLoading;
  }, []);

  useEffect(() => {
    if (areChildrenReady) {
      // do whatever
    }
  }, [areChildrenReady]);

  return (
    <LoadingProvider value={startLoading}>
      {children}
    </LoadingProvider>
  );
}

function ChildComponent() {
  const startLoading = useContext(LoadingContext);
  useEffect(() => {
    const finishLoading = startLoading();
    MathJaxReady
      .then(anotherPromise)
      .then(finishLoading);
  }, [startLoading]);
  return (
    // elements
  );
}

关于javascript - 惯用的 React 和大量 DOM 操作 (MathJax),我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/57722818/

相关文章:

javascript - 对象实例在分配后转换为对象

reactjs - 如何(同时)更改 react 中状态的多个属性?

javascript - 我在 Vite 3.2.4 版本中不断收到错误,显示 `[vite:esbuild] The service is no longer running: write EPIPE`

导航后 Angular 2 View 没有响应

javascript - 使用 Javascript 解析 JSON 对象是行不通的

javascript - 移动 safari 与主屏幕 webapp

javascript - 如何将垂直 <tr> 更改为水平 <tr>

javascript - React/Jest - 模拟获取并等待 componentDidMount 重新渲染

javascript - ESlint 错误,类型 '() => Promise<void>' 缺少类型 'Promise<void>' 中的以下属性 : then, 捕获,[Symbol.toStringTag],最后

reactjs - 你如何测试路由器与 jest 和 enzyme 的匹配参数?