使用 React Suspense 和Hooks为开发者助力

Suspense 是在 React 16.6 中引入的(那是 5 年前的事了),但很长一段时间它的唯一目的只是代码分割。你之前可以用它在各处使用 promise 进行自定义数据获取,但那基本上就是它的全部用途。而在 React 18 中,Suspense 得到了显著的升级,支持服务器端渲染(SSR)流和选择性水合。最近,新的 React 文档中记录了一个新的钩子(称为 use ),显著简化了使用 Suspense 进行数据获取的功能。

尽管 <Suspense /> 成为 React 的一部分已有 5 年了,但 React 团队仍然认为用于数据获取的 Suspense 尚未做好生产准备,因此不建议在生产中使用。此外,我们今天将介绍的 use 钩子并未作为稳定的 React 版本的一部分公开。要尝试它,你需要使用 canary 或实验性的 React 通道。你可以在这里找到关于这些通道的更多信息。
目录

  • 它能做什么
  • 是什么原因导致子树挂起
  • 组件会发生什么情况
  • 好处
  • Use hook 101
  • 使用数据获取
  • 自己的惰性组件
  • 简单、有效的<Offscreen />

它能做什么

在概念层面上,Suspense 是一个边界,有点像错误边界。尽管它不是用于隔离子树中的错误(而是将错误冒泡到根并导致应用程序崩溃),而是用于隔离在渲染时正在加载的子树

如果 React 在渲染应用程序时偶然发现尚未准备好渲染的组件,它将向上走到最近的 Suspense 边界,挂起其 children 子树(这意味着它将隐藏子树)但不卸载它),并显示提供的 fallback 。一旦引起悬念的组件完成加载,整个树就会重新渲染并再次显示。

但让我们看一下细节:组件如何可能尚未准备好渲染、“暂停”子树的真正含义是什么,以及 Suspense 有哪些实际用例。

是什么原因导致子树挂起

第一种也是最广为人知的挂起方法是渲染惰性组件。惰性组件是由 React.lazy 返回的组件,它用于将某些组件移出初始包,从而减少其大小和加载时间。当你第一次渲染这样的组件时,React 会找到最近的 <Suspense /> 并挂起它的子树。当组件被加载时,子树将被重新渲染。如果加载失败,错误将传播到最近的错误边界。

另一种方法是使用支持 Suspense 的数据获取库(比如 Relay),或直接调用带有 Promise 的 use(或者如果 use 不可用,则抛出一个 Promise)。在这种情况下,当 Promise 处于挂起状态时,React 将挂起,然后在 Promise 解决时重新渲染。与懒加载类似,错误会传播到最近的错误边界。关于 use 的更多信息稍后在文章中会提到。

最后一个案例与服务器端渲染有关。如果在 SSR 期间,Suspense 边界内的某个组件抛出错误,则该错误不会传播到最近的错误边界(即使有一个比 Suspense 边界更近的错误边界)。相反,React 将从 Suspense 渲染 fallback 并将其发送到客户端。在客户端,React 将尝试再次渲染组件。如果成功,后备将被渲染的组件替换。

如果组件再次抛出错误,它将像平常一样对待 – 传播到最近的错误边界(或者如果没有错误边界,则使您的应用程序崩溃)。

组件会发生什么情况

正如我所说,当 React 挂起子树时,React 不会卸载它,并且会保留组件的当前状态。相反,React 只是“隐藏”挂起的子树。在底层,Suspense 使用新的 Offscreen 组件(仅在实验通道中可用),该组件将 style="display: none !important;" 分配给元素并触发布局效果清理,因此您可以停止可能依赖于 DOM 元素的订阅。

如果你想深入了解 Suspense 实现的细节,我强烈推荐 JSer 的这篇文章

好处

通过 Suspense,React 将“加载”状态引入到 React 世界中。之前,您使用组件状态来指示数据加载状态。你的代码中可以有这样的东西:

const { data, isLoading, error } = useData();

根据这些属性的值,您可以渲染加载微调器、错误屏幕或带有数据的实际视图。 React 无法知道您的组件是否正在加载某些数据。

借助 Suspense,您现在可以让 React 知道组件何时正在加载某些内容,这反过来又允许 React 进行一些有用的优化。

首先,这简化了数据获取代码。您不需要检查 isLoading 或 error , use 之后的所有代码都保证有可供使用的数据。

const OldComponent = () => { 
    const { transactions, isLoading, error } = useTransatcions(); 
    // If you need to use transactions in hooks, it becomes 
    // even more cumbersome because you need to always check 
    // if data isn't undefined (and you obviously can't move 
    // hooks below ifs because it will make them conditional)
    if (isLoading) { return (<Spinner />); }
    if (error) { return (<Error />); } 
    // Finally you can render actual component!
    return (<TransactionsTable transactions={transactions} />);
}; 
// becomes this
const SuspensePowered = () => { 
    const transactions = useTransatcionsSuspense();
    // Use any hooks you want here 
    // transactions are populated with data at this point 
    return (<TransactionsTable transactions={transactions} />);
}

另一个好处是改进了服务器端渲染(SSR)。我已经提到过,Suspense 在在服务器上渲染应用程序时有助于从错误中恢复,但事情并不仅仅如此。如果你使用支持 Suspense 的数据获取库,这使得 react-dom/server 能够在组件渲染时以流的方式生成 HTML。React 将为挂起的边界发出回退并继续渲染其他组件。一旦数据被获取,React 将渲染子树并发出 HTML,同时附带一个小的内联脚本,该脚本将替换之前渲染的回退。

因此,即使在 React 主包加载到客户端浏览器之前,您也可以进行渐进式加载,现在一个大组件(加载大量数据)不会减慢整个应用程序的渲染速度。

Suspense给SSR带来的另一个好处是选择性水合。现在,客户端 React 不需要等待所有代码加载完毕才进行水化。以前, lazy 组件与 SSR 并不真正兼容,您必须选择使用 SSR 和大捆绑包,或者不使用 SSR 但使用较小的捆绑包。一旦加载了相应 Suspense 子树中的所有组件,Suspense 就允许 React 逐部分地水合应用程序。

Use hook 101

use 钩子最近已经有了文档说明,这意味着迟早我们会在稳定版的 React 中看到它。然而,目前它仅在 canary 和实验性通道中可用。所以你还有一些时间来掌握它。

虽然 use 绝对是一个钩子,并且您不能在函数组件之外使用它,但它有点特殊:您可以在条件语句和循环中使用它。

这个钩子用于读取资源的值。目前,它仅支持上下文和 promises,但这在将来可能会发生变化。对于读取上下文,它基本上与 useContext 钩子相似。但如果你将一个 promise 传递给它,它将在 promise 尚未解决时挂起组件,并在 promise 完成时返回 promise 的数据。任何错误都将传播到错误边界(或者你可以在 promise 上添加 .catch 并在失败的情况下返回替代值)。以下是如何使用这个钩子的示例:

import { use, Suspense } from 'react'; 
const onlineUsersPromise = getOnlineUsers(); 
const Users = () => { 
    const users = use(onlineUsersPromise);
    // ^^ this will suspend if onlineUsersPromise
    // isn't resolved when component is rendering 
    return (<div> 
        <h2>Users online</h2>
        <ul> {users.map(user => (<li>{user}</li>))} </ul>
       </div>);
}; 
const App = () => { 
    return (<Suspense fallback="Loading..."> 
        <Users /> 
    </Suspense>);
}

正如您所看到的,我们在组件外部创建 Promise,这看起来不太方便,不是吗?您可以尝试直接在组件中创建Promise,如下所示:

const users = use(getOnlineUsers());

但这样做是行不通的,因为在组件的每次渲染中,我们都会创建一个新的 promise,并向服务器发送大量请求。如果你正在使用 Suspense 解决数据获取的问题,你需要编写一些粘合代码,不能直接使用 use。至少你需要以某种方式记忆化(memoize)这个 promise。

使用数据获取

那么,让我们编写这个粘合代码,以及围绕 use 制作我们自己的数据获取钩子。我们不会重复造轮子,而是将我们的钩子的 API 设计得类似于 SWR 或 react-query。你提供一个字符串,它充当全局缓存键,以及一个返回 Promise 的函数。

如果缓存中没有该键的记录,我们创建一个 Promise 并将其保存到缓存中;否则,我们会重用缓存中的Promise。

function useData<T>(key: string, func: () => Promise<T>): T;

不幸的是,如果您的组件将在第一次渲染时挂起(在安装之前),则您无法为每个组件实例提供本地缓存(使用 useMemo 或 useRef ),因为在第一次挂起后,任何引用和记忆值将被丢弃。所以我们需要某种全局缓存。为了简单起见,我们将仅使用全局 Map ,但您当然可以使用上下文将缓存本地化到应用程序的一部分。

const dataCache = new Map<string, Promise<any>>();
const useData = <T,>(key: string, load: () => Promise<T>) => { 
    let promise = dataCache.get(key); 
    if (!promise) { 
        promise = load(); 
        dataCache.set(key, promise); 
    } 
    const data = use(promise as Promise<T>); 
    return data;
};

显然,上面的代码显然缺少一些来自大型数据获取库的功能。例如,重新验证或更好的缓存控制。但它能够完成工作。使用方式如下:

const WinesList = ({ type }: { type: string }) => { 
const wines = useData(`wines-${type}`, () => getWines(type)); 
return (<ul key={type}> {
    wines.map(wine => { 
        return (<li key={wine.id}>{wine.wine} from {wine.location}</li>) 
    })
} </ul>);};

这就是今天的全部内容!文章的重点部分到此结束,但我还为您提供了一些 use 的实验技巧。这是我们重新发明轮子的部分。

自己的惰性组件

使用 use 钩子,您可以自己实现 React.lazy 。原始的 lazy 实现在底层不使用 use ,但概念是相同的。

const myLazy = <P extends JSX.IntrinsicAttributes, R = unknown>(loader: () => Promise<ComponentType<P>>) => { 
let promise: Promise<ComponentType<P>> | undefined = undefined; 
return forwardRef<R, P>((props, ref) => { 
    if (!promise) { promise = loader(); } 
    const Component = use(promise); 
    return (<Component {...props} ref={ref} />) });
}; 
const LazyCallout = myLazy(() => import('@blog/components/Callout').then(m => m.Callout));

我们使用闭包创建一个对外部代码隐藏但可用于我们返回的惰性组件的所有实例(如 LazyCallout )的状态。实际组件的加载将在惰性组件的第一个实例的第一次渲染时触发,稍后的调用将重用缓存的 Promise。

简单、有效的<Offscreen />

您已经可以使用 <Offscreen /> ,但这需要使用实验通道中的 React。如果这不是您想要的,但您仍然希望拥有 Offscreen,我们可以用 Suspense 来模仿它。实施有点冗长,所以请耐心等待。

// This function returns promise which can be resolved from outside
const createPromiseWithResolve = () => { 
    let resolve: () => void = () => { }; 
    const promise = new Promise<void>((_resolve) => { 
        resolve = _resolve; 
    }); 
    return { promise, resolve };
};

function usePrevious<T>(value: T) { 
    const ref = useRef<T | undefined>(undefined); 
    useEffect(() => { 
        ref.current = value; 
    });
    return ref.current;
}
const Offscreen = ({ children, mode }: { 
    children: ReactNode, 
    mode: 'hidden' | 'visible' 
}) => { 
    const previousMode = usePrevious(mode); 
    const promiseRecordRef = useRef< 
        ReturnType<typeof createPromiseWithResolve> | undefined 
    >(undefined); 
    if (previousMode !== mode || !promiseRecordRef.current) {             
        promiseRecordRef.current?.resolve();
        promiseRecordRef.current = mode === 'hidden' ? createPromiseWithResolve() : { 
            promise: Promise.resolve(), resolve: () => { } 
       }; 
    } 
    return (<Suspense> 
        <OffscreenInner promise={promiseRecordRef.current.promise}>
             {children}  
        </OffscreenInner> 
    </Suspense>);
 };
 // --- Usage:
const ControlledOffscreen = () => { 
    const [showRed, setShowRed] = useState(true); 
    const [showGreen, setShowGreen] = useState(true); 
    return (<div> 
        <div> 
            <Button onClick={() => setShowRed(true)}>Show red</Button> 
            <Button onClick={() => setShowRed(false)}>Hide red</Button> 
            <Button onClick={() => setShowGreen(true)}>Show green</Button> 
            <Button onClick={() => setShowGreen(false)}>Hide green</Button> 
        </div> 
        <div> 
            <Offscreen mode={showRed ? 'visible' : 'hidden'}> 
                <div style={{ color: 'red' }}>Red</div>
            </Offscreen> 
            <Offscreen mode={showGreen ? 'visible' : 'hidden'}> 
                <div style={{ color: 'green' }}>Green</div> 
            </Offscreen> 
        </div> 
    </div>);
};

主要工作是在 <Offscreen /> 组件内完成的。在第一次渲染时,我们创建一个新的 Promise:根据 mode 属性的不同,它可能已经被解决或处于挂起状态。然后,我们将这个 Promise 提供给 OffscreenInner。由于在我们的组件内部渲染了一个 suspense 边界,我们需要在此边界内部再放置另一个组件来进行挂起。如果我们在父组件中调用 use,它将传播到组件外部的 suspense 边界(如果存在),这不是我们需要的行为。因此,我们使用了一个额外的组件来在那里调用 use。我们追踪 mode 的先前值,如果它发生变化,我们解决当前的 Promise 并生成一个新的。

Offscreen 在以下情况下会很有用:如果你想保持某些组件已挂载但不显示其任何 DOM 节点。例如,如果你正在编写一个路由器,可能希望保持路由已挂载以提高导航速度。当然,你可以基于属性翻转样式:<div style={{display: mode === 'visible' ? 'block' : 'none'}} />,但使用这种方法,React 将不会清理布局效果,这可能会引发一些问题。

这种方法有一个显着的缺点:
如果一个 Promise 不是自行解决的(就像我们的情况一样),它会显著减慢你的服务器端渲染(SSR),因为服务器会等待一段时间,然后触发超时,并尝试在客户端解决 Promise,这将使你的站点感觉更慢。

这是可能通过推迟 API 解决的问题,但它也只存在于实验性通道中。

原文链接:https://juejin.cn/post/7347994218236264502 作者:两根头发一个中分

(0)
上一篇 2024年3月20日 下午5:09
下一篇 2024年3月21日 上午10:00

相关推荐

发表回复

登录后才能评论