最近看了一篇介绍 React-Router 6.4 Lazy Loading Routes 的文章,感觉收获很大,在这里分享一下。
首屏性能优化是一个老生常谈的问题,其中最常见的解法就是 Code-Splitting 路由懒加载。其中 import()
语法由 Webpack 等打包工具进行支持(浏览器环境降级为 __webpack_require__.e
通过 JSONP 方式加载异步 chunk),与框架无关,但是与前端框架产生割裂。从 React 17 开始,React 从框架层面提供了 React.lazy()
、 <Suspense />
原语实现懒加载。但是 React Router 6.4+ 之后,路由懒加载又有新的写法了。
为啥 React Router 本身要提供懒加载功能?这里需要先介绍一下 React Router v6.4 引入的 Data Router 功能。推荐看一下 Ryan Florence(Remix 和 React Router 作者)的演讲:When To Fetch: Remixing React Router。
Ryan 核心观点是假如路由和 data fetch 逻辑解耦,那么组件渲染、接口请求就是串行的,进而导致瀑布流问题:
如果引入 Data Router,路由整合了部分数据流功能,组件渲染、接口请求就可以并行,提升页面加载性能:
具体实现通过 data router 将接口请求逻辑提升到路由层面:
tsx
// 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 router 必须等待所有 Initial Chunk 加载完成才会发起请求,即首屏加载不需要的资源会阻塞 data fetch:
解法大家都知道,就是通过 Code-Splitting。这里为啥不建议用 React 提供的 React.lazy()
,因为该方式只会在真正渲染的时候再加载异步组件,无法提前加载,特别是嵌套路由比较明显,会导致瀑布流问题(例如先下载 <App />
组件代码、渲染 <App />
组件、再下载 <Component />
组件代码、渲染 <Component />
组件)。
这里可以借鉴上面 data loader,既然数据可以通过 loader 方式提升到路由层面并行加载,异步 chunk 加载也可以提升到路由层面进行 prefetch 预加载。
React Router 路由配置,可以分为三个部分:
- 路径匹配字段:
path
、index
、children
- 数据加载、提交相关字段:
loader
、action
- 渲染相关字段:
element
、errorElement
实际上对路由来说,最关键的就是路径匹配字段,其他逻辑都可以异步加载。另外,data fetch 逻辑和组件实际上是捆绑的关系,需要渲染组件的时候,才会调相关接口。这个意义上,路由配置可以优化如下:
tsx
// 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"), // 💤 Lazy load!
children: [{
path: ':projectId',
lazy: () => import("./project"), // 💤 Lazy load!
}],
}],
}]
// projects.jsx
export function loader = () => { ... }; // formerly named getProjects
export function Component() { ... } // formerly named Projects
// project.jsx
export function loader = () => { ... }; // formerly named getProject
export function Component() { ... } // formerly named Project
这样首屏加载效率可以明显提升:
但是需要注意,渲染子路由的时候,由于 loader
和组件打包到同一个异步 chunk,因此接口请求需要等待 chunk 加载完成,如果组件的逻辑比较多,仍会阻塞 data fetch:
如何优化上面的问题,解法是将 loader
和组件拆分为两个异步 chunk,这样就可以并行加载:
tsx
const routes = [
{
path: "projects",
async loader({ request, params }) {
let { loader } = await import("./projects-loader");
return loader({ request, params });
},
lazy: () => import("./projects-component"),
},
];
效果如下:
但是有时 data fetch 的逻辑很少,没必要单独分包,则可以用下面的方式:
tsx
const routes = [
{
path: "projects",
loader: ({ request }) => fetchDataForUrl(request.url),
lazy: () => import("./projects-component"),
},
];
效果如下:
以上的分包策略对嵌套路由非常有用,可最大限度减少瀑布流问题。但是如果路由嵌套层级比较深,每一级路由都需要加载异步 chunk、接口请求,可能会造成并行网络请求过多。React Router 也可以支持将所有子路由代码都合并到一个异步 chunk:
tsx
// Assume pages/Dashboard.jsx has all of our loaders/components for multiple
// dashboard routes
let dashboardRoute = {
path: "dashboard",
async lazy() {
let { Layout } = await import("./pages/Dashboard");
return { Component: Layout };
},
children: [
{
index: true,
async lazy() {
let { Index } = await import("./pages/Dashboard");
return { Component: Index };
},
},
{
path: "messages",
async lazy() {
let { messagesLoader, Messages } = await import(
"./pages/Dashboard"
);
return {
loader: messagesLoader,
Component: Messages,
};
},
},
],
};
总结一下,实际异步 chunk prefetch 在 Webpack 里面也提供了解决方案,Webpack 打包可借助 /* webpackPrefetch: true */
魔法注释实现 prefetch,在浏览器空闲的时候下载异步 chunk。对异步 chunk 进行 prefetch 可以提升路由导航的性能,但是也存在缺点,容易造成网络请求过多,预加载的异步模块过多,可能会导致浏览器的内存占用过高,进而影响页面性能,另外加载的只能是 JS 代码,不能是接口请求,还是存在瀑布流问题。React Router 6.4+ 在路由层面整合了数据流、Lazy Loading Routes 等功能,不仅支持路由层面 data fetch,还可以支持并行加载嵌套路由的异步 chunk,把异步 chunk 和接口请求视为同等优先级加载,最大限度避免了瀑布流问题。相比 Webpack 的 prefetch,Lazy Loading Routes 不是在浏览器空闲的时候 prefetch,而是根据需要渲染的路由,按需加载异步 chunk,缺点就是写法比较灵活,开发者需要对 Code-Splitting 分包有较为深入的理解,针对各种场景分包有相关实践经验。
参考: