【翻译】为 React 编译器调整库逻辑

16 阅读4分钟

原文链接:playfulprogramming.com/posts/react…

作者:Corbin Crutchley

作为 TanStack Form 的首席维护者,我希望确保我们能妥善支持即将发布的 React Compiler。

虽然这要求我们重新审视内部代码编写依赖的某些策略,但经过测试和用户反馈,我认为我们已成功实现了这一目标。

毕竟,我们通过示例代码测试未发现任何问题。直到后来收到关于三元运算符与编译器问题的报告,我们才重新审视并验证了对编译器的支持。

启用 React 编译器时发现边界情况

无需深入代码库,以下是我们错误操作的核心要点,现将其重新构造为滚动监听器钩子:

export function usePosition() {
  const position = useState(() => ({ current: null }))[0];
  // `useScroll` is a hook that subscribes to scroll events
  // and gives the current scroll position
  position.current = useScroll();
  return position;
}

当此钩子在父组件中使用时:

export default function ScrollPosition() {
  const position = usePosition();
  return <div style={{ position: 'fixed', top: '1rem', left: '1rem' }}>
    <p>
      {position.current.scrollY}
    </p>
  </div>;
}

即使启用了React编译器,一切似乎也运行正常。

然而,将position移至子组件时:

function ShowScroll({ position }) {
  return (
    <p>
      {position.current.scrollY}
    </p>
  );
}
export default function ScrollPosition() {
  const position = usePosition();
  return <div style={{ position: 'fixed', top: '1rem', left: '1rem' }}>
    <ShowScroll position={position} />
  </div>;
}

突然间,position.current.scrollY 不再更新了。

如果我们查看通过 React 编译器编译的此代码的最小版本,我们会得到:

import { c as _c } from "react/compiler-runtime";
import { usePosition } from "./useScroll";
function ShowScroll(t0) {
  const $ = _c(2);
  const { position } = t0;
  let t1;
  if ($[0] !== position.current.scrollY) {
    t1 = <p>{position.current.scrollY}</p>;
    $[0] = position.current.scrollY;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  return t1;
}
export default function ScrollPosition() {
  const $ = _c(2);
  const position = usePosition();
  let t0;
  if ($[0] !== position) {
    t0 = <ShowScroll position={position} />;
    $[0] = position;
    $[1] = t0;
  } else {
    t0 = $[1];
  }
  return t0;
}

如果我们接着关注ScrollPosition

export default function ScrollPosition() {
  const $ = _c(2);
  const position = usePosition();
  let t0;
  if ($[0] !== position) {
    t0 = <ShowScroll position={position} />;
    $[0] = position;
    $[1] = t0;
  } else {
    t0 = $[1];
  }
  return t0;
}

我们可以看出,t0 被定义为一个备忘录化的 <ShowScroll> 元素,该元素在position引用稳定性发生变化之前不会更新。 对比当我们拥有以下代码时生成的代码:

import { usePosition } from './useScroll';
export default function ScrollPosition() {
  const position = usePosition();
   return (
    <p>
      {position.current.scrollY}
    </p>
  );
}
import { c as _c } from "react/compiler-runtime";
import { usePosition } from "./useScroll";
export default function ScrollPosition() {
  const $ = _c(2);
  const position = usePosition();
  let t0;
  if ($[0] !== position.current.scrollY) {
    t0 = <p>{position.current.scrollY}</p>;
    $[0] = position.current.scrollY;
    $[1] = t0;
  } else {
    t0 = $[1];
  }
  return t0;
}

在此处,我们看到 position.current.scrollY 本身是通过引用进行比较,而非通过 position 对象进行比较。

这解释了为何我们的代码在某些边界情况下会出错,而在其他情况下却不会。

为什么ESLint没有检测到这个问题?

虽然ESLint会标记这个特定情况:

Modifying a value returned from 'useState()', which should not be modified directly. Use the setter function to update instead.
  34 |   const position = useState(() => ({ current: null }))[0];
  35 |
> 36 |   position.current = useScroll();
     |   ^^^^^^^^ value cannot be modified
  37 |
  38 |   return position;
  39 | }  react-hooks/immutability

当时我们的代码编写方式似乎并未被React的ESLint规则覆盖。要知道,TanStack Form采用的是包含所有库逻辑的类。这些类存在于框架之外,随后通过手动重新渲染(类似于useSyncExternalStore的方式)被重新整合回特定框架中。

为何采用类实现库逻辑

此设计使我们能够一次性编写所有核心逻辑,同时支持 React 之外的多种框架。因此,TanStack Form 目前可支持:

以下是采用此模式对上述滚动处理程序示例的简化重写方案:

import { useLayoutEffect, useMemo, useReducer } from 'react';
class ScrollHandler {
  scrollX = 0;
  scrollY = 0;
  cb = () => {};
  constructor(cb) {
    this.cb = cb;
  }
  listener = () => {
    this.scrollY = window.scrollY;
    this.scrollX = window.scrollX;
    this.cb();
  }
  mount = () => {
    window.addEventListener('scroll', this.listener);
    return () => {
      window.removeEventListener('scroll', this.listener);
    }
  }
}
export function usePosition() {
  const [_, rerender] = useReducer(() => ({}), {});
  const scrollHandler = useMemo(() => ({current: new ScrollHandler(rerender)}), [rerender]);
  // Using `useLayoutEffect` for simplicity
  useLayoutEffect(() => {
    const cleanup = scrollHandler.current.mount();
    return () => cleanup();
  }, [scrollHandler]);
  return scrollHandler;
}

若对该代码再次运行相同的ESLint规则检查,不会报告任何问题。

如何检测所有编译器错误

image.png 比ESLint漏检更糟糕的是,React编译器默认不会向您报告这些问题。相反,在许多情况下它会选择默默绕过组件的优化。

幸运的是,React团队在编译器设置中提供了一个panicThreshold标志,启用后可让React更一致地报告这些问题

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [
          ['babel-plugin-react-compiler', { panicThreshold: 'all_errors' }],
        ],
      },
    }),
  ],
});

这在调试可能存在各种ESLint报告问题的库代码时极具帮助。

同样地,若需模拟Babel忽略已安装库的转换(如同该库位于node_modules目录时的情况),只需将库代码添加至{babel: {exclude: []}}即可,如下所示:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [
          ['babel-plugin-react-compiler', { panicThreshold: 'all_errors' }],
        ],
        // Simulate `useScroll` being in a dependency, therefore not covered by Babel
        exclude: ['src/useScroll.js'],
      },
    }),
  ],
});

修复该错误

解决此类问题的长期方案是采纳编译器的建议,避免在此处修改位置:

export function usePosition() {
  const basePosition = useState(() => ({ current: null }))[0];
  const scroll = useScroll();
  
  const position = useMemo(() => {
    return {
      ...basePosition,
      current: scroll
    }
  }, [scroll, basePosition]);
  
  return position;
}

这在某些性能边界场景下看似不利,但请注意:React编译器应能处理几乎所有因每次滚动创建新位置对象而可能引发的性能问题。具体而言,它能实现:

  • 避免不必要的节点重新渲染
  • 为你缓存position.current.scrollY的使用
  • 以及更多优化