Skip to content
This page has been auto-translated and may contain errors.View in English

代码分割

默认情况下,打包工具会把你的整个应用打包成一个 JavaScript 文件,文件顶部的每一个 import 都会被加入其中。这个单一的包会随着应用增长而变大,浏览器必须下载完整个包才能开始渲染任何内容。

在性能好、网络快的设备上你可能察觉不到。但在网络差的手机上,一个庞大的库文件就能卡住整个首次加载,包括那些根本不需要这个库的功能。这门课程用一个依赖了大型模拟数据包的商品列表来演示这个问题:如果所有代码都打包在一起,即使是同一个页面上的计数器在限速网络下也要等超过二十秒才能出现。

代码分割 就是解决方案。它把包分成若干块(称为 chunk),只在用户真正需要的时候才下载这一块。用户永远不会用到的功能根本不用下载。

动态 import()

这个机制的基础是普通的 JavaScript。文件顶部的 import 总是会运行。但你也可以把 import() 当作函数来调用,这样就能在代码的任何地方按需加载一个模块,它会返回一个 Promise,resolve 时提供该模块。

jsx
// 总是被加载,属于主包的一部分
import { formatPrice } from './format'

// 只在这个处理器运行时才被加载
async function showChart() {
  const { renderChart } = await import('./charts')
  renderChart()
}

打包工具会把每一个 import() 调用视为一条分割线:被 import 的模块,加上它独有的依赖(不与主包共享的部分),会被放进一个独立的 chunk 中,在调用执行的第一时刻通过网络获取。

lazy 和 Suspense

React 用一对工具包装了这个机制。lazy 接收一个返回动态 import() 的函数,给你返回一个可以像其他组件一样渲染的组件。这个加载器的 Promise 必须 resolve 到一个模块,其中 默认导出 就是该组件;模块中的其他命名导出没问题。对于你无法控制的、只导出命名组件的文件,可以改造 Promise:lazy(() => import('./charts').then(m => ({ default: m.RevenueChart })))

渲染一个 lazy 组件会触发 suspense:React 暂停那部分树的 渲染,直到 chunk 到达。光有暂停是错误,所以你需要用一个 Suspense 边界包裹 lazy 组件,并提供 fallback prop,即下载过程中显示的 UI。

jsx
import { lazy, Suspense, useState } from 'react'

const ProductsList = lazy(() => import('./ProductsList'))

export default function App() {
  const [showProducts, setShowProducts] = useState(false)

  return (
    <>
      <Counter />
      <button onClick={() => setShowProducts(prev => !prev)}>
        Show products
      </button>
      {showProducts && (
        <Suspense fallback={<h2>Loading...</h2>}>
          <ProductsList />
        </Suspense>
      )}
    </>
  )
}

现在主包不再包含 ProductsList 及其庞大的依赖。计数器立刻出现。点击按钮会开始下载 chunk,fallback 在加载时显示,组件在到达时渲染。第一次加载后模块会被缓存,再次隐藏和显示就是瞬间的事。

版本说明

lazySuspense 是从 react 导入的。较老的代码,包括这门课程的课时内容,写成 React.lazyReact.Suspense;它们是同样的 API。

从路由开始分割

你可以去找单个庞大的组件,但大多数应用的默认分割线是路由。每个页面是一个自然的单元:访问主页的用户根本不需要仪表盘的代码,所以让每个页面都成为一个 lazy 组件,由 路由 来决定哪些 chunk 才需要下载。

jsx
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'

const Home = lazy(() => import('./pages/Home'))
const Vans = lazy(() => import('./pages/Vans'))
const Dashboard = lazy(() => import('./pages/Dashboard'))

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<h2>Loading...</h2>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/vans" element={<Vans />} />
          <Route path="/host" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

一个围绕路由的 Suspense 边界覆盖了每个页面。首次加载会获取主包加上当前 URL 对应的 chunk;其他页面的代码在用户导航时才下载。这种宽泛的范围有代价:导航到一个还没有到达的 chunk 时,整个路由区域会被替换成 fallback,所以要把共享的布局(如头部和导航)放在边界外,这样在切换时它们能保持不变。

分割有下限。每个 chunk 都要多付出一次网络请求和一段 fallback UI 的时间,所以分割一个微小的组件得不偿失。小组件和用户立刻能看到的首屏内容应该留在主包中。分割那些很重、很少用或被导航隐藏的东西。

分割在构建时发生。打包工具会静态地找到每一个 import() 调用,为那个模块图发出一个独立的 chunk,并把调用重写成对该 chunk URL 的 fetch。

lazy 本身很简洁:它保存加载器函数返回的 Promise,在首次渲染时抛出它,这就是 suspension 的信号。React 捕获它,向上走到最近的 Suspense 边界,提交 fallback,然后在 Promise resolve 时重试渲染。加载器只运行一次;之后 resolve 的组件被缓存在 lazy 包装器上,再次渲染是同步的,浏览器缓存使得重新访问很便宜,即使跨越重载也是。

边界的位置是用户体验的决策。根部附近的单个边界意味着任何 suspending 的后代都会使大区域变空白,内容到达时可能造成布局抖动;紧紧围绕 lazy 子树的边界让页面的其余部分保持可交互,这是这门课程选择的做法。

路由表是调和两条规则的例外:那里的宽泛边界物有所值,因为导航本来就要替换那整个区域,fallback 代替一个即将被换掉的页面。在页面内部,要紧紧地划分。

当 chunk 加载失败时

为永远到不了的 chunk 做准备。失败的 fetch 会 reject 加载器的 Promise,Suspense 只覆盖等待中的状态:React 会在渲染期间重新抛出 rejection,没被任何东西捕获的渲染错误会卸载整个树。

把边界和错误边界配对。错误边界是一个组件,它能捕获下面的渲染错误并显示备用 UI。React 的内置版本是这本手册里仅有的仍然必须是类组件的东西,因为没有 Hook 等价物,所以大多数项目会安装 react-error-boundary 并用它的 ErrorBoundary 包裹路由。

常见的原因是旧版本部署,一个打开的标签页请求了新构建不再提供的带哈希的 chunk,用户在这里需要的是页面重载而不是重试。

也要注意瀑布:一个 lazy 路由只在 chunk 到达后才开始获取数据,这就把两次往返串联了。React Router 的数据 API(路由的 loader 在渲染前运行)或意图预加载(在链接悬停或获得焦点时开始 import(),在渲染需要它之前)可以折叠这种瀑布。提前调用同一个 import('./ProductsList') 是安全的;模块注册表会去重它,后面的 lazy 渲染会找到已经在加载中或完成的 chunk。

Juno只在需要的时候才下载代码 通常浏览器必须下载你应用的所有 JavaScript 才能显示任何内容,一个大功能可能会卡住其他一切。代码分割让你可以说"稍后加载这部分,只在用户要求时。"

你用 lazy 包裹一个动态 import,把组件放在带有 fallback(比如加载消息)的 Suspense 中,React 就会在代码下载时显示 fallback。你应用的其余部分立刻就会出现。

Juno只在需要的时候才下载代码const Page = lazy(() => import('./Page')) 给你一个组件,其代码在自己的 chunk 中,首次渲染时获取;文件必须默认导出该组件,一个带有 fallbackSuspense 边界覆盖下载。

让路由成为你的默认分割点:每个页面都是一个 lazy 组件,一个边界围绕路由表。跳过分割微小的组件和首屏 UI,因为每个 chunk 都要付出一次请求的代价。

Juno只在需要的时候才下载代码 打包工具在构建时在每个 import() 调用位置划分一个 chunk;lazy 在首次渲染时抛出加载器 Promise,最近的 Suspense 边界显示它的 fallback,resolve 的模块被缓存所以后续渲染是同步的。

划分边界来控制 suspension 会使多少 UI 变空白,在意图时预加载或让路由器在渲染前获取路由的数据来避免 chunk-then-data 瀑布。重复的 import() 调用会去重,所以提前预热一个 chunk 是无代价的。

接下来:身份验证,会讲会话、令牌和服务端验证如何结合在一起。