为什么 React.lazy() 需要默认导出以及如何加载命名导出项
了解 React.lazy() 从动态导入中接收的内容,为何命名导出会破坏其功能,如何将它们重映射为默认导出,以及 Suspense 和代码分割是如何协同工作的。
React.lazy() 是 React 中最简短的 API 之一:它用于包裹动态导入的内容,在 Suspense 中渲染结果,随后打包工具会将该组件拆分到独立的代码块中。但这种简洁性背后隐藏着一个约定:被导入的模块必须以默认导出的形式提供组件。如果改为使用命名导出,应用就会因 React 内部抛出的错误而崩溃,且这些错误往往无法指向真正的故障原因。本文将解释通过 lazy() 实际传递了什么内容、为何命名导出会导致问题、两种解决该问题的方法,以及一种适用于其他 API 的调试技巧。
常见的使用方式如下:
const Dashboard = lazy(() => import("./Dashboard"));
从表面上看似乎没有任何风险,而这正是故障发生时令人困惑的原因。
理想情况:使用默认导出
从定义一个组件并将其作为默认值导出的 Dashboard.jsx 文件开始。
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
在使用该组件的文件中,从 React 导入 lazy 并将其指向该模块。
import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
大多数情况下,人们不会去考虑这两行代码之间发生了什么;React 会在首次渲染时直接加载该组件。有趣的部分在于箭头函数内部的表达式。
import("./Dashboard")
这与静态导入语句有本质区别:
import Dashboard from "./Dashboard";
第一种形式是动态导入,而动态导入的返回值是一个 Promise。等待它的结果有助于更清晰地理解其运作方式:
const module = await import("./Dashboard");
返回的并不是 Dashboard 组件,而是一个模块命名空间对象,该对象的属性即为模块的导出内容。因为文件以这行代码结尾:
export default Dashboard;
解析后的模块大致具有这样的结构:
{
default: Dashboard
}
而这正是 React.lazy() 所设计的用于处理的格式。
一个会破坏它的微小改动
现在假设团队更倾向于使用带名称的导出。之前的文件结构如下:
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
它被重写为按名称导出组件:
export const Dashboard = () => {
return <div>Dashboard</div>;
};
lazy 调用的方式完全保持不变:
const Dashboard = lazy(() => import("./Dashboard"));
一切看起来都没有问题。组件存在,文件也存在,路径也正确。直接对同一文件进行常规的静态导入也能正常工作:
import { Dashboard } from "./Dashboard";
变化在于模块对象的形态。
比较两种模块形态
如下所示的默认导出:
export default Dashboard;
会生成大致如下的模块结构:
{
default: Dashboard
}
如下所示的命名导出:
export const Dashboard = ...
则会生成如下的模块结构:
{
Dashboard: Dashboard
}
React.lazy()会从特定位置读取组件:
module.default
它从不查看命名属性:
module.Dashboard
因此,命名导出本身并无错误。问题纯粹在于模块结构不再符合lazy()所依赖的形态。在开发环境中,React通常会提示懒加载元素解析为undefined或元素类型无效,这些描述的是症状而非缺失的default键问题。
为何 lazy() 会查找默认值
一旦了解传递给 lazy() 的内容,其设计逻辑就会更加清晰。
lazy(() => import("./Dashboard"))
React 在这里接收的并非组件,而是一个加载器函数,该函数会返回一个 Promise,最终该 Promise 会解析为一个模块。React 会取该模块的 default 属性,并将其视为组件。其流程如下:
lazy()
↓
loader function
↓
import("./Dashboard")
↓
Promise
↓
module
↓
module.default
↓
React component
当模块包含 default 出口时,React 就能获得所需的内容:
{
default: Dashboard
}
当模块仅包含带名称的出口时,React 就没有 default 可以读取:
{
Dashboard: Dashboard
}
这就是出现不一致的地方。读取 default 是一种合理的惯例:它是所有模块都能达成共识的导出名称,无需 React 了解任何关于命名规则的信息。
修复方法一:改为默认导出
如果该组件是文件所提供的核心内容,最简单的修复方式就是将其作为默认值导出。
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
这样处理后,原有的调用方式就能按预期正常工作:
const Dashboard = lazy(() => import("./Dashboard"));
对于仅包含一个主要组件的文件来说,这是一种简洁且符合常规的选择。
修复方法二:保留命名导出并重新映射
有时更改导出方式并不可行或并不理想,例如当代码库通过检查规则强制要求使用命名导出,或者当文件同时导出了多个组件时。此时可以保持原有命名版本的导出方式不变:
export const Dashboard = () => {
return <div>Dashboard</div>;
};
你仍然可以使用 lazy();只需指定哪个导出项作为默认值即可。加载器会在动态导入上附加一个 .then(),并返回一个新的对象,将该对象的 module.Dashboard 放在 default 键下。
const Dashboard = lazy(() =>
import("./Dashboard").then((module) => ({
default: module.Dashboard,
}))
);
第一次看可能会觉得奇怪,但逻辑其实很简单:
Load the module
↓
Get the Dashboard export
↓
Put it under "default"
↓
Give it back to React.lazy()
换句话说,这个对象:
{
Dashboard: Dashboard
}
在 React 接收到它之前会被转换成这个样子:
{
default: Dashboard
}
现在 React.lazy() 接收到的数据结构正好符合它的预期。如果一个项目需要懒加载多个命名导出,建议将这种模式封装在一个小的共享辅助函数中,这样重映射逻辑就能集中在一处。同时,应将每个 lazy() 调用放在模块作用域内,而非组件内部;如果在渲染时声明它,每次都会创建一个新的懒加载组件并重置其状态。
为什么静态导入不会遇到这个问题
使用静态命名导入时,你可以明确指定需要哪个导出:
import { Dashboard } from "./Dashboard";
JavaScript 会自动为你解析 Dashboard 的绑定关系。而使用默认导入时,你实际上是在请求默认导出:
import Dashboard from "./Dashboard";
不过,懒加载器并不会选择任何内容:
lazy(() => import("./Dashboard"))
它将整个模块对象交给React,而React必须从中挑选出所需内容。这就是为什么默认导出仅在懒加载情况下才有意义。
Suspense在流程中的作用
React.lazy()很少会单独出现,通常与一起使用:
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
一旦记住组件并非立即可用,这种搭配就变得合理了。浏览器可能需要在React能够渲染Dashboard之前下载额外的JavaScript代码块,而在那段时间内React需要其他内容来显示。fallback属性正好提供了这样的内容。完整的流程如下:
React needs Dashboard
↓
Dashboard hasn't been loaded
↓
Dynamic import starts
↓
Suspense shows fallback
↓
JavaScript chunk loads
↓
Module resolves
↓
React gets module.default
↓
Dashboard renders
将lazy()视为一个序列后,它就不再那么神秘了:Suspense负责处理等待过程,而最终渲染的则是default导出的内容。相关的一点是,如果数据块下载失败,Promise会被拒绝,而Suspense无法处理这种情况;此时需要在懒加载组件周围添加错误边界来捕获该错误。
代码分割才是真正的优势
使用动态导入的主要原因就是代码分割。如果没有这样的调用:
lazy(() => import("./Dashboard"))
仪表板代码很可能会被包含在启动应用程序所需的JavaScript中。而通过动态导入,打包工具可以将其放入独立的代码块中。从概念上讲,输出结果会分为一个初始打包包:
Initial bundle
├── React
├── App
└── Home
以及稍后才会被加载的代码块:
Dashboard chunk
└── Dashboard
只有当用户真正需要时,控制台代码才会被下载。适合这样处理的包括:
- 管理员控制台
- 分析页面
- 设置界面
- 大型编辑器
- 用户很少访问的路由
- 依赖庞大的复杂组件
但这并不意味着所有组件都应采用懒加载方式。每次拆分都会增加一次网络请求和加载状态,因此一个小按钮并没有从单独的代码块中获益。目标并非让所有内容都懒加载,而是确保初始打包文件中不包含用户暂时不需要的代码。
React内部出现的错误并不一定是React的缺陷
当React内部出现异常时,人们很容易怀疑是React本身出了问题。堆栈跟踪中可能会包含类似这样的信息:
lazyInitializer
resolveLazy
beginWork
显而易见的结论是 React 内部出现了问题。在深入探究之前,先检查你所调用的 API 的规范。这里的关键问题是 React.lazy() 期望其加载器返回什么。简而言之,答案是:
Promise<{
default: Component
}>
将其与模块实际提供的内容进行比较。如果文件中包含以下内容:
export const Dashboard = ...
那么最终得到的值实际上就是这样的:
{
Dashboard: Dashboard
}
将两者放在一起对比,原因就显而易见了。并非 React 出了问题,而是模块的导出内容与加载器的规范不匹配。
理解 lazy() 调用的思维模型
不要将这行代码理解为“延迟加载仪表板”:
const Dashboard = lazy(() => import("./Dashboard"));
将其视为一条小型处理流程来理解会更有帮助:
When Dashboard is needed
↓
run the dynamic import
↓
get the module
↓
take its default export
↓
use that as the component
该模型使得这些问题的诊断变得十分迅速。它同时也解释了为何以下文件中的第一个能立即正常工作:
// Dashboard.jsx
export default function Dashboard() {
return <div>Dashboard</div>;
}
而第二个文件则需要额外的处理:
// Dashboard.jsx
export const Dashboard = () => {
return <div>Dashboard</div>;
};
当通过此调用加载时:
lazy(() => import("./Dashboard"));
更广泛的调试经验
这个小小的 React 细节其实蕴含着更重要的教训。当 API 表现出异常行为时,不要只关注自己输入的语法,而应检查实际在 API 中传递的数值。像下面的调用看似很简单:
lazy(() => import("./Dashboard"))
但实际上它是一系列转换操作的组合:
dynamic import
↓
Promise
↓
module object
↓
default export
↓
React component
一旦这条调用链显现出来,错误就很容易理解了,而且这种方法在 JavaScript 中同样适用。不要去问为什么某个库会抛出错误,而应思考自己到底向函数传递了什么以及它期望收到什么返回值。有时候,整个差异仅仅源于一个属性名称:
default
关键要点
import()返回的是模块对象而非组件,而React.lazy()会从组件的default属性中读取组件内容。- 命名导出是可行的;只是在惰性加载时需要用
.then((module) => ({ default: module.Name }))对其进行重新映射。 - 应在模块作用域中声明惰性组件,用
Suspense包装它们以处理加载状态,并为失败的代码块下载添加错误边界。