Skip to content

Client Only

在 React Router 初期支持 ssr 模型,要使用 React Router(Remix) 就必须熟悉 SSR。此时 ClientOnly 组件就产生了。

ClientOnly 就是在客户端选择的组件。

问题

  • 图标
  • @ant-design/pro-compoennts 等等组件都是客户端才需要渲染的,与我们的服务端 ssr 没有关系,也没有必要。

我们可以使用 ClientOnly 做服务端最小化 ssr 渲染, 渲染放到客户端渲染。但是就此也带来新的挑战。

你需要了解 React/React Router 的渲染机制。

渲染机制

  • 子组件优先父组件执行, useEffect 也是子组件优先执行,这将都数据流造成影响

安装 remix-utils

sh
pnpm add remix-utils

使用

ts
function Compo() {
  return <ClientOnly fallback={<FakeChart />}>{() => <Chart />}</ClientOnly>;
}

ClientOnly 的实现

tsx
import * as React from "react";
import { useHydrated } from "./use-hydrated.js";

type Props = {
	/**
	 * You are encouraged to add a fallback that is the same dimensions
	 * as the client rendered children. This will avoid content layout
	 * shift which is disgusting
	 */
	children(): React.ReactNode;
	fallback?: React.ReactNode;
};

/**
 * Render the children only after the JS has loaded client-side. Use an optional
 * fallback component if the JS is not yet loaded.
 *
 * Example: Render a Chart component if JS loads, renders a simple FakeChart
 * component server-side or if there is no JS. The FakeChart can have only the
 * UI without the behavior or be a loading spinner or skeleton.
 * ```tsx
 * return (
 *   <ClientOnly fallback={<FakeChart />}>
 *     {() => <Chart />}
 *   </ClientOnly>
 * );
 * ```
 */
export function ClientOnly({ children, fallback = null }: Props) {
	return useHydrated() ? <>{children()}</> : <>{fallback}</>;
}

useHyDrated 的实现

ts
import { useSyncExternalStore } from "react";

function subscribe() {
	// biome-ignore lint/suspicious/noEmptyBlockStatements: Mock function
	return () => {};
}

/**
 * Return a boolean indicating if the JS has been hydrated already.
 * When doing Server-Side Rendering, the result will always be false.
 * When doing Client-Side Rendering, the result will always be false on the
 * first render and true from then on. Even if a new component renders it will
 * always start with true.
 *
 * Example: Disable a button that needs JS to work.
 * ```tsx
 * let hydrated = useHydrated();
 * return (
 *   <button type="button" disabled={!hydrated} onClick={doSomethingCustom}>
 *     Click me
 *   </button>
 * );
 * ```
 */
export function useHydrated() {
	return useSyncExternalStore(
		subscribe,
		() => true,
		() => false,
	);
}

问题: ClientOnly 对 useEffect 造成两次渲染

以下是一个示例:

tsx
import {
  type RouteConfig,
  index,
  route,
  layout,
} from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  layout("./routes/layout.tsx", [route("about", "routes/about.tsx")]),
] satisfies RouteConfig;

about 页面是来有 layout,为了防止子组件在 layout 之前渲染,我们需要 data 存在我们想要的数据之前拦截。

tsx
import { useEffect, useState } from "react";
import { Outlet } from "react-router";

export default function Layout() {
    const [data, setData] = useState(null)
    useEffect(() => {
        setData([1])
        console.log("layout")
    }, [])
    if(!data) {
        return <div>loading...</div>
    }
    return <div>
        xxxx
        <Outlet />
    </div>
}

目标

  • 不能让 ClientOnly 造成 useEffect 执行两次
  • 不能让 子组件先渲染,先获取数据,保持父组件先过去数据

不让 ClientOnly 造成 useEffect 执行两次

不做 Root 级别的 ClientOnly 只在需要使用的地方执行一次。

不能让 子组件先渲染,先获取数据,保持父组件先过去数据

在获取到实现之前不渲染子组件