React 的请求数据流疑难杂症,Remix 来解决!

waterfall

首发于公众号 前端从进阶到入院,欢迎关注。

Hi,我是 ssh,今天看到 Remix 团队带来了一篇关于如何解决 React 疑难杂症:Render + Fetch 渲染瀑布流的问题以及一些更深入的优化思考:Lazy Loading Routes in React Router 6.4+

这个瀑布流问题简单来说,就是有些嵌套层级的路由的 Fetch 会有无效排队的问题,比如你的应用里有着 a -> b -> c 这样的组件依赖关系,先渲染了 a 以后,才会渲染 b,然后才会渲染 c,但是 a,b,c 这三个组件里的 fetch 请求其实没有依赖关系,如果在全部渲染这些组件之前,就先行并行发出请求,就可以节省很多时间。

接下来,我来和大家一起分享学习:

正文

React Router 6.4引入了一个名为“Data Router”的概念,其主要目标是将 data fetch 与渲染分离,以消除 Render+Fetch 链和相关的 Loading 动画。

let router = createBrowserRouter([
  {


    path: "/",
    loader: () => ({ message: "Hello Data Router!" }),
    Component() {
      let data = useLoaderData() as { message: string };
      return <h1>{data.message}</h1>;
    },
  },

]);

这些链通常被称为“瀑布流”,但我们正在重新思考这个术语,因为大多数人听到瀑布流就会想到尼亚加拉瀑布,那儿的水全部一起喷涌而下形成一个漂亮的瀑布。但“全部一次性”加载数据似乎蛮好的,那为什么对瀑布流有意见呢?

实际上,我们要避免的“瀑布流”看起来更像上面的图片,类似一个楼梯。水一点点下落,然后停下来,再下落一点点,然后停下来,依此类推。现在想象一下,这个楼梯中的每一步都是一个 Loading 动画。用户会骂死你!因此,在本文中,我们使用术语“链”来表示那些本质上是按顺序排列的 Fetch,每个 Fetch 都被前一个 Fetch 阻塞。

Render+Fetch 链

如果你还没有阅读过Remixing React Router文章或者去年在 Reactathon 上观看了 Ryan 的When to Fetch演讲,你可能希望在继续阅读本文之前先了解一下它们。它们涵盖了我们引入 Data Router 的原因背后的很多背景知识。

简而言之,当你的 Router 不知道有什么数据需要请求时,情况就会变成顺序的请求链,随着渲染子组件,才能发现后续的数据请求。

将 data fetch 与组件耦合会导致 Render+Fetch 链

但是引入 Data Router 可以使你能够并行 fetch 数据并一次性渲染所有内容:

Route 关联的所有 Fetch 全部并行请求,消除了缓慢的 Render+Fetch 链

为了实现这一点,Data Router 将你的 Route 定义从渲染周期中提取出来,以便我们的 Router 可以提前识别嵌套的数据需求。

// app.jsx


import Layout, { getUser } from `./layout`;

import Home from `./home`;

import Projects, { getProjects } from `./projects`;
import Project, { getProject } from `./project`;

const routes = [{
  path: '/',
  loader: () => getUser(),
  element: <Layout />,
  children: [{
    index: true,
    element: <Home />,
  }, {
    path: 'projects',
    loader: () => getProjects(),
    element: <Projects />,
    children: [{
      path: ':projectId',
      loader: ({ params }) => getProject(params.projectId),
      element: <Project />,
    }],
  }],
}]

但这也有一个缺点。到目前为止,我们已经讨论了如何优化 data fetches,但我们还必须考虑如何优化 JS Bundle 的 fetches!在上面的 route 定义中,虽然我们可以并行 fetch 所有数据,但我们却通过下载包含所有 loader 和 Component 的 JavaScript Bundle 来阻塞 data fetch 的开始。

考虑一个用户在“/”根路径进入你的网站:

唯一的 JS Bundle 阻塞了 data fetch

即使这个用户用不到projects:projectId的 route 的 loader 和 Component,还是会下载!最坏的情况下,如果用户不进入这些 route,就永远用不到它们。这对我们的用户体验来说并不理想。

React.lazy 解救之道

React.lazy 提供了一个一等公民的原语,用于将组件树的部分进行分块,但它一样会遇到我们用 Data Router 想要消除的 fetch 和渲染的紧密耦合问题。这是因为当你使用 React.lazy() 时,会为组件创建一个异步 Chunk,但是直到渲染懒加载组件时,React 才会实际开始 fetch 这个 Chunk。

// app.jsx


const LazyComponent = React.lazy(() => import("./component"));

function App() {
  return (
    <React.Suspense fallback={<p>Loading lazy chunk...</p>}>
      <LazyComponent />
    </React.Suspense>
  );
}

React.lazy() 调用产生了类似的 Render + Fetch 链

因此,虽然我们可以在 Data Router 中利用 React.lazy(),但最终会导致一个在 data fetch 之后继续下载组件的链路。Ruben Casas 在一篇好文中介绍了如何使用 React.lazy() 在 Data Router 中利用代码分割的一些方法。但是从该文章可以看出,手动进行代码分割仍然有些冗长和繁琐。由于这种次优的开发体验,我们收到了来自 @rossipedia提案(和一个初步的POC 实现)。该提案很好地概述了当前的挑战,并让我们思考在 RouterProvider 中引入代码分割支持的最佳方式。我们要向这两位开发者(以及我们其他棒棒哒的社区成员)表示隆重的感谢,因为他们积极推动了 React Router 的进化 ?。

引入 route.lazy

如果我们希望延迟加载与 Data Router 很好的协作,我们需要能够在渲染周期之外引入延迟加载。就像我们将 data fetch 从渲染周期中提取出来一样,我们还希望将route Fetch也从渲染周期中提取出来。

如果你回过头来看一个 route 定义,它可以分为三个部分:

  • 路径匹配字段,例如 pathindexchildren
  • 数据加载/提交字段,如 loaderaction
  • 渲染字段,如 elementerrorElement

对于 Data Router 而言,真正需要的只是路径匹配字段,因为它需要能够识别与给定 URL 匹配的所有路由。匹配后,我们就有了正在进行的异步导航,因此我们也没有理由在该导航过程中 fetch route 信息。然后,在完成 data fetch 之前,我们不需要渲染字段相关的内容,因为在 data fetch 完成之前,我们是不会渲染目标 route 的。是的,这可能会引入一个”链”的概念(加载路由,然后加载数据),但这是一个可选的开关,你可以根据需要开启,这就看你自己的权衡了。

以下是使用上面的 Router 结构和新的 lazy() 方法(在 React Router v6.9.0 中可用)的示例:

// app.jsx


import Layout, { getUser } from `./layout`;

import Home from `./home`;


const routes = [{
  path: '/',
  loader: () => getUser(),
  element: <Layout />,
  children: [{
    index: true,
    element: <Home />,
  }, {
    path: 'projects',
    lazy: () => import("./projects"), // ? 延迟加载!
    children: [{
      path: ':projectId',
      lazy: () => import("./project"), // ? 延迟加载!
    }],
  }],
}]

// projects.jsx
export function loader = () => { ... }; // 原先名为 getProjects

export function Component() { ... } // 原先名为 Projects

// project.jsx
export function loader = () => { ... }; // 原先名为 getProject

export function Component() { ... } // 原先名为 Project

你可能会疑惑 export function Component 是什么呢?从这个延迟加载模块导出的属性会直接添加到 route 的定义中。因为导出一个 element 看起来有些奇怪,所以我们支持在 route 对象中使用 Component 来定义,而不是 element(不过element 也仍然有效!)。

在这种情况下,我们选择将布局和主页 route 保留在首屏 bundle 中,因为这是用户最常访问的入口。但是,我们将 projects:projectId route 的导入移到了自己的动态 import 中,只有在导航到这些 route 时才会加载。

初始加载时的网络图示如下:

lazy() 方法使我们的关键路径 bundle 变小了

现在我们的关键路径 bundle 中只包括我们认为在初始进入网站时关键的那些 Route。然后,当用户点击链接 /projects/123 时,我们通过 lazy() 方法并行 fetch 这些 Route,并执行它们返回的 loader 方法:

我们在导航时并行延迟加载 routes

这使我们在某种程度上得到了最佳的解决方案,因为我们能够将关键路径 bundle 裁剪为相关的主页 Router。然后在导航时,我们可以匹配路径并 fetch 所需的新的 route 定义。

高级用法和优化

一些敏锐的读者可能会感觉到这里有些不对劲。这是最优解吗?其实不是!但是考虑到我们只需编写很少的代码就能实现它,它还是相当不错的 ?。

在上面的示例中,我们的 route 模块包括我们的 loaderComponent,这意味着在开始执行 loader fetch 之前,我们需要下载两者的内容。实际上,你的 React Router SPA Loader 通常非常小,而且主要访问的是包含大部分业务逻辑的外部 API。而 Component 则定义了整个用户界面,包括与之相关的所有用户交互,它们可能会非常庞大。

单个 route 文件将 data fetch 阻塞在组件下载之后

下载巨型的 Component 树时阻塞了很小的 loader(很可能是对某个 API 进行 fetch() 调用)似乎有些不合理?如果我们转变到下面的情况,会怎么样?

我们可以通过将组件提取到单独的文件中来解除 data fetch 的阻塞

好消息是,你只需很小的代码改动即可实现!如果在 route 上单独直接定义loader/action,它就会和 lazy() 字段定义的文件并行下载。这使我们可以通过将 loader 和 Component 分别放入单独的文件中来解耦 loader data fetch 和组件 Chunk 下载:

const routes = [

  {


    path: "projects",

    async loader({ request, params }) {
      let { loader } = await import("./projects-loader");
      return loader({ request, params });
    },
    lazy: () => import("./projects-component"),
  },

];

route 上直接定义的任何字段始终优先于 lazy 返回的任何内容。因此不要在定义静态 loader 的同时又从 lazy 里返回一个 loader,否则将忽略 lazy 里的版本,还会收到控制台警告。

这种静态定义的 loader 概念还为直接内联代码提供了一些有趣的可能。例如,也许你有一个单独的 API 域名,根据请求 URL 才知道如何 fetch route 数据。你可以以最小的 bundle 成本内联所有 loader,并在 data fetch 和 Component(或 route 模块)chunk 下载之间实现完全并行化。

const routes = [

  {


    path: "projects",

    loader: ({ request }) => fetchDataForUrl(request.url),
    lazy: () => import("./projects-component"),
  },
];

瞧,没有 loader chunk!

更多信息

欲了解更多信息,请查看决策文档或 GitHub 中的示例。祝你懒加载愉快!

首发于公众号 前端从进阶到入院,分享 Vue 源码 / React / TS / 浏览器 / 工程化等各个前端领域,我的文章帮助了很多小伙伴进入大厂,欢迎关注。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYM30rHJ' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片